Files
resolutionflow/backend/app/services/escalation_package_generator.py
Michael Chihlas 5bee264d70 fix(suggested-fix-pending): apply PR #156 review fixes
- Page-level Resolve patches applied_pending → applied_success before
  opening the resolution flow, so resolved sessions don't carry a
  provisional pending fix.
- Page-level Escalate intercept now catches applied_pending in addition
  to verifying/partial; intercept copy generalized from "Verifying state"
  to "still needs an outcome."
- PendingBanner gains a Dismiss action, matching the PR body and the
  backend's allowed pending → dismissed transition.
- resolution_note_generator and escalation_package_generator system
  prompts no longer include real-looking pending examples (anti-parrot
  guardrail compliance).

Verified via Docker: prompt anti-parrot 2/2, suggested-fix outcome suite
21/21, frontend tsc -b clean, npm run build clean.

Co-Authored-By: Codex <noreply@openai.com>
2026-04-30 23:02:46 -04:00

319 lines
13 KiB
Python

"""EscalationPackageGeneratorService — drafts the handoff package for a session.
Parallel to ResolutionNoteGeneratorService but oriented around handoff to
another engineer instead of closing the ticket. The output markdown follows
FLOWPILOT-MIGRATION.md Section 6.3:
## Problem
## What we've confirmed
## What we've tried
## Current hypothesis
## Suggested next steps
Same caching story as resolution-note previews: keyed on
`(session_id, ai_sessions.state_version)` via `preview_cache`, invalidated by
any fact / suggested-fix / script-generation write.
Model: Sonnet (`escalation_package` action tier per Section 6.6). MCP off.
"""
from __future__ import annotations
import logging
from typing import Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ai_provider import get_ai_provider
from app.core.config import settings
from app.models.ai_session import AISession
from app.models.script_template import ScriptGeneration, ScriptTemplate
from app.models.session_fact import SessionFact
from app.models.session_suggested_fix import SessionSuggestedFix
from app.services.preview_cache import preview_cache
from app.services.script_template_engine import ScriptTemplateEngine
logger = logging.getLogger(__name__)
_ESCALATION_SYSTEM_PROMPT = """\
You produce structured escalation handoff packages for an MSP troubleshooting \
platform. The package is read by the next engineer picking up the ticket; it \
must give them a running start without making them re-read the chat transcript.
Output exactly this markdown structure, no preamble, no closing remarks, no \
extra headings:
## Problem
<one short paragraph stating the issue the first engineer was working on, \
past tense, no hedging. Derived from the session's intake/title and incident \
header.>
## What we've confirmed
<bulleted list of facts from the "What we know" section, each a short line. \
If there are no facts, write "Nothing confirmed yet." and continue.>
## What we've tried
<Bulleted list of diagnostic checks run and scripts generated during the \
session. The content of this section also depends on the outcome recorded for \
the active suggested fix, as given in the input bundle under "Outcome status":>
- applied_failed: List the fix as a tried path. Include the failure reason if \
provided. State that it did not resolve the issue.
- applied_partial: Include the fix as a partially tried path. Include partial \
notes if provided. Indicate it was not fully completed or not verified.
- applied_pending: List the fix as applied but awaiting verification. Include \
the pending reason if provided. Make it clear the next engineer should follow \
up to confirm it worked.
- applied_success: Note that the fix was applied and verified but escalation \
is still needed for another reason (unusual — reflect this accurately).
- dismissed: Do not mention the fix as a tried path; it was only considered.
- proposed (no outcome yet): Do not list it here; it goes in Current hypothesis.
If nothing has been tried at all (no checks, no scripts, no applied/partial \
fix), write "No diagnostic actions run yet." and continue.
## Current hypothesis
<The content depends on the outcome recorded for the active suggested fix:>
- proposed (no outcome yet): State the fix title and confidence. If confidence \
is below 60% or there is no active fix, say "No leading hypothesis yet — \
symptoms are still being narrowed."
- applied_failed or dismissed: Say the proposed fix did not hold or was set \
aside. State any remaining uncertainty.
- applied_partial: Note the partial application and what remains open.
- applied_pending: Note that the fix is in place but unverified. Reference the \
pending reason. Frame this as the leading hypothesis pending confirmation.
- applied_success: Unusual in an escalate path — state the fix resolved the \
original symptom but a new or related issue requires escalation.
## Suggested next steps
<bulleted list of 2-4 concrete next actions the receiving engineer should \
take. Prefer specifics: commands to run, tickets to check, people to contact. \
Derive from the gap between confirmed facts and a complete resolution. \
If the active suggested fix failed (applied_failed), inform the next steps \
accordingly — e.g. suggest alternatives or deeper investigation paths, \
drawing on the failure reason if provided. \
If the fix is partially applied (applied_partial), the first step is typically \
to complete or verify it. \
If the fix is pending verification (applied_pending), the first step is \
typically to confirm whether the fix held — reference what was being waited on. \
If the fix is still proposed (no outcome), the first step is to try it if \
confidence is high (>80%).>
Strict rules:
- Use ONLY the input I provide. Never invent command names, KB articles, or \
configuration specifics that aren't in the input.
- Do not include placeholder text like "TBD" or empty bullets.
- Do not include the engineer's name, the AI's name, session IDs, or the \
chat transcript verbatim.
- Markdown headings exactly as shown (## level), no bolding.
- The tone is a peer handing off to a peer, not a status report.
"""
class EscalationPackageGeneratorService:
"""Generates and caches the five-section Escalate handoff markdown."""
KIND = "escalation_package"
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def generate_or_get_cached(
self, session_id: UUID, *, force: bool = False,
) -> dict[str, Any]:
session = await self._load_session(session_id)
cached = preview_cache.get(self.KIND, session.id, session.state_version) if not force else None
if cached is not None:
return {**cached, "from_cache": True}
markdown = await self._render(session)
target = self._target_ticket_ref(session)
payload = {
"markdown": markdown,
"target_ticket_ref": target,
"state_version": session.state_version,
}
preview_cache.set(self.KIND, session.id, session.state_version, payload)
return {**payload, "from_cache": False}
# ── Internals (parallel to ResolutionNoteGenerator) ───────────────────
async def _load_session(self, session_id: UUID) -> AISession:
result = await self.db.execute(
select(AISession).where(AISession.id == session_id)
)
session = result.scalar_one_or_none()
if session is None:
raise ValueError(f"Session {session_id} not found")
return session
async def _render(self, session: AISession) -> str:
facts = await self._load_facts(session.id)
active_fix = await self._load_active_fix(session.id)
gens = await self._load_redacted_generations(session.id)
bundle = self._build_input_bundle(session, facts, active_fix, gens)
model = settings.get_model_for_action("escalation_package")
provider = get_ai_provider(model=model)
system_blocks: list[dict[str, Any]] = [
{
"type": "text",
"text": _ESCALATION_SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
# cacheable: identical across every escalation-package preview call
},
]
try:
text, _in, _out = await provider.generate_text(
system_prompt=system_blocks,
messages=[{"role": "user", "content": bundle}],
max_tokens=1400,
)
except Exception:
logger.exception("Escalation package generation failed for session %s", session.id)
raise
return text.strip()
async def _load_facts(self, session_id: UUID) -> list[SessionFact]:
result = await self.db.execute(
select(SessionFact)
.where(
SessionFact.session_id == session_id,
SessionFact.deleted_at.is_(None),
)
.order_by(SessionFact.created_at.asc())
)
return list(result.scalars().all())
async def _load_active_fix(self, session_id: UUID) -> SessionSuggestedFix | None:
result = await self.db.execute(
select(SessionSuggestedFix)
.where(
SessionSuggestedFix.session_id == session_id,
SessionSuggestedFix.superseded_at.is_(None),
)
.order_by(SessionSuggestedFix.created_at.desc())
)
return result.scalars().first()
async def _load_redacted_generations(
self, session_id: UUID
) -> list[dict[str, Any]]:
result = await self.db.execute(
select(ScriptGeneration)
.where(ScriptGeneration.ai_session_id == session_id)
.order_by(ScriptGeneration.created_at.asc())
)
gens = list(result.scalars().all())
if not gens:
return []
template_ids = {g.template_id for g in gens}
tpl_result = await self.db.execute(
select(ScriptTemplate).where(ScriptTemplate.id.in_(template_ids))
)
templates_by_id = {t.id: t for t in tpl_result.scalars().all()}
engine = ScriptTemplateEngine()
out: list[dict[str, Any]] = []
for g in gens:
tpl = templates_by_id.get(g.template_id)
sensitive_keys: set[str] = set()
schema = (tpl.parameters_schema if tpl else {}) or {}
params = schema.get("parameters") if isinstance(schema, dict) else None
if isinstance(params, list):
for p in params:
if isinstance(p, dict) and p.get("field_type") == "password":
k = p.get("key") or p.get("variable_name")
if isinstance(k, str):
sensitive_keys.add(k)
redacted_params = engine.redact_sensitive(g.parameters_used or {}, sensitive_keys)
out.append({
"template_name": tpl.name if tpl else "(unknown template)",
"template_slug": tpl.slug if tpl else None,
"parameters_used": redacted_params,
"created_at": g.created_at.isoformat(),
})
return out
@staticmethod
def _target_ticket_ref(session: AISession) -> str | None:
if not session.psa_ticket_id:
return None
return f"CW #{session.psa_ticket_id}"
@staticmethod
def _build_input_bundle(
session: AISession,
facts: list[SessionFact],
active_fix: SessionSuggestedFix | None,
generations: list[dict[str, Any]],
) -> str:
lines: list[str] = []
lines.append("# Session context")
lines.append(f"Title: {session.title or '(untitled)'}")
if session.problem_summary:
lines.append(f"Problem summary: {session.problem_summary}")
if session.problem_domain:
lines.append(f"Domain: {session.problem_domain}")
intake_text = (session.intake_content or {}).get("text") if isinstance(session.intake_content, dict) else None
if intake_text:
lines.append(f"Intake message: {intake_text}")
if session.psa_ticket_id:
lines.append(f"Linked PSA ticket: CW #{session.psa_ticket_id}")
lines.append("")
lines.append("# Confirmed facts (What we know)")
if not facts:
lines.append("(none)")
else:
for f in facts:
tag = f.source_type
summary = f"{f.source_summary}" if f.source_summary else ""
lines.append(f"- [{tag}] {f.text}{summary}")
lines.append("")
lines.append("# Diagnostic checks run during the session")
check_facts = [f for f in facts if f.source_type == "diagnostic_check"]
if not check_facts and not generations:
lines.append("(none)")
else:
for f in check_facts:
lines.append(f"- {f.text}")
for g in generations:
lines.append(f"- Ran script {g['template_name']} (slug={g['template_slug']})")
if g["parameters_used"]:
lines.append(f" parameters: {g['parameters_used']}")
lines.append("")
lines.append("# Active suggested fix (current hypothesis)")
if active_fix is None:
lines.append("(no active suggested fix)")
else:
lines.append(f"Title: {active_fix.title}")
lines.append(f"Confidence: {active_fix.confidence_pct}%")
lines.append(f"Description: {active_fix.description}")
lines.append(f"Outcome status: {active_fix.status}")
if active_fix.applied_at:
lines.append(f"Applied at: {active_fix.applied_at.isoformat()}")
if active_fix.verified_at:
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
if active_fix.partial_notes:
lines.append(f"Partial notes: {active_fix.partial_notes}")
if active_fix.pending_reason:
lines.append(f"Pending reason: {active_fix.pending_reason}")
if active_fix.failure_reason:
lines.append(f"Failure reason: {active_fix.failure_reason}")
lines.append("")
lines.append(
"Produce the five-section escalation handoff now. Use only the input above."
)
return "\n".join(lines)