- 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>
349 lines
15 KiB
Python
349 lines
15 KiB
Python
"""ResolutionNoteGeneratorService — drafts the structured Resolve note for a session.
|
|
|
|
Produces the four-section markdown that ships to the customer ticket (per
|
|
FLOWPILOT-MIGRATION.md Section 6.2):
|
|
|
|
## Problem
|
|
## What we confirmed
|
|
## Root cause
|
|
## Resolution
|
|
|
|
The output is the *draft* — engineers review and edit in the preview popover
|
|
before clicking Confirm & post (Phase 4). Caching is keyed on
|
|
`(session_id, ai_sessions.state_version)` per Section 5.5; the cache lives in
|
|
`preview_cache` and invalidates automatically when any fact / suggested fix /
|
|
script generation bumps the session's state_version.
|
|
|
|
Model: Sonnet (`resolution_note` action tier — quality matters because the
|
|
output is customer-facing). MCP intentionally disabled — this is a summary
|
|
of existing state, not a research task.
|
|
|
|
Sensitive parameter values in script_generations are redacted using the
|
|
script template's `parameters_schema` (`field_type: "password"`). Existing
|
|
ScriptTemplateEngine.redact_sensitive handles the substitution.
|
|
"""
|
|
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__)
|
|
|
|
|
|
_RESOLUTION_NOTE_SYSTEM_PROMPT = """\
|
|
You produce structured resolution notes for an MSP troubleshooting platform. \
|
|
The notes are posted as ticket notes in the customer's PSA, so they must read \
|
|
like a competent senior engineer summarized the work — not like an AI \
|
|
narration. Your output goes in front of paying customers.
|
|
|
|
Output exactly this markdown structure, no preamble, no closing remarks, no \
|
|
extra headings:
|
|
|
|
## Problem
|
|
<one short paragraph stating the issue the engineer worked on, derived from the \
|
|
session's intake/title and the incident header. Past tense. No "user reported" \
|
|
hedging — state the problem directly.>
|
|
|
|
## What we confirmed
|
|
<bulleted list of facts from the "What we know" section, each one a short line. \
|
|
Group similar facts together; do not invent connecting prose. If there are no \
|
|
facts, write "Nothing was confirmed." and skip to Root cause.>
|
|
|
|
## Root cause
|
|
<one short paragraph naming the root cause based on the active suggested fix \
|
|
and confirmed facts. If the suggested fix is low-confidence (<60%) or absent, \
|
|
say "Root cause not definitively isolated." and explain what is suspected based \
|
|
on facts.>
|
|
|
|
## Resolution
|
|
<The content of this section depends on the outcome recorded for the active \
|
|
suggested fix, as given in the input bundle under "fix.status":>
|
|
|
|
- applied_success: Write in past tense using closure language. State that the \
|
|
fix was applied and verified as working. If verified_at is provided, you may \
|
|
reference it as the time resolution was confirmed. Example phrasing: \
|
|
"Applied <fix title>; confirmed working."
|
|
- applied_failed: Acknowledge that the proposed fix did not resolve the issue \
|
|
and was discarded. If failure_reason is provided, include it. Then describe \
|
|
the actual resolution path taken (derived from facts and scripts run). This \
|
|
state means the engineer resolved the issue another way; the note should cover \
|
|
that actual resolution, not just the failed attempt.
|
|
- applied_partial: Note that the fix was partially applied. If partial_notes \
|
|
are provided, include them. Then describe the final resolution path taken.
|
|
- applied_pending: Note that the fix was applied and verification is pending. \
|
|
If pending_reason is provided, include it as the provided waiting reason. \
|
|
Frame the resolution as provisional — the fix is in place but not yet \
|
|
confirmed. Do not write closure language.
|
|
- dismissed: Treat the fix as considered and set aside. Do not center the note \
|
|
on it. Describe the resolution based on what was actually confirmed and done.
|
|
- proposed (no outcome yet): Write "Resolution not yet applied — fix proposed: \
|
|
<fix title>." Pull verbatim script names and template references when available.
|
|
|
|
Strict rules:
|
|
- Use ONLY the facts and state I provide. Never invent specifics that are not \
|
|
in the input.
|
|
- Do not include placeholder text like "TBD", "TODO", or empty bullets.
|
|
- Do not include the engineer's name, the AI's name, internal session IDs, or \
|
|
the session's chat transcript.
|
|
- Markdown headings exactly as shown (## level), no bolding the headings.
|
|
- No trailing whitespace, no double-blank lines, no horizontal rules.
|
|
"""
|
|
|
|
|
|
class ResolutionNoteGeneratorService:
|
|
"""Generates and caches the four-section Resolve note markdown."""
|
|
|
|
KIND = "resolution_note"
|
|
|
|
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]:
|
|
"""Return the preview for the session.
|
|
|
|
Reads `(KIND, session_id, state_version)` from the in-process cache;
|
|
on miss, generates fresh markdown and stores under the same key.
|
|
`force=True` bypasses the cache and refreshes the cached entry.
|
|
|
|
Returns `{"markdown": str, "target_ticket_ref": str | None,
|
|
"state_version": int, "from_cache": bool}`.
|
|
"""
|
|
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 ─────────────────────────────────────────────────────────
|
|
|
|
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:
|
|
"""Build the prompt input bundle, call the model, return markdown."""
|
|
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("resolution_note")
|
|
provider = get_ai_provider(model=model)
|
|
|
|
# Cache the system prompt — identical across every preview call for
|
|
# every session. Per-session bundle is in the user message, uncached.
|
|
system_blocks: list[dict[str, Any]] = [
|
|
{
|
|
"type": "text",
|
|
"text": _RESOLUTION_NOTE_SYSTEM_PROMPT,
|
|
"cache_control": {"type": "ephemeral"},
|
|
# cacheable: identical across every resolution-note preview call
|
|
},
|
|
]
|
|
|
|
try:
|
|
text, _in, _out = await provider.generate_text(
|
|
system_prompt=system_blocks,
|
|
messages=[{"role": "user", "content": bundle}],
|
|
max_tokens=1200,
|
|
)
|
|
except Exception:
|
|
logger.exception("Resolution note 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]]:
|
|
"""Pull script_generations for the session, redacting password params.
|
|
|
|
Password fields are inferred from the linked template's
|
|
`parameters_schema` (`field_type: "password"`). The existing
|
|
ScriptTemplateEngine.redact_sensitive handles the substitution.
|
|
"""
|
|
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 = self._sensitive_keys_from_schema(
|
|
(tpl.parameters_schema if tpl else {}) or {}
|
|
)
|
|
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 _sensitive_keys_from_schema(schema: dict[str, Any]) -> set[str]:
|
|
"""Extract password-typed parameter keys from a template's schema.
|
|
|
|
The schema shape is `{"parameters": [{"key": "...", "field_type": "password", ...}]}`
|
|
per the existing Script Generator convention. Tolerate both that shape
|
|
and the simpler `{"key": {"field_type": "password"}}` form.
|
|
"""
|
|
keys: set[str] = set()
|
|
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):
|
|
keys.add(k)
|
|
elif isinstance(schema, dict):
|
|
for k, v in schema.items():
|
|
if isinstance(v, dict) and v.get("field_type") == "password":
|
|
keys.add(k)
|
|
return keys
|
|
|
|
@staticmethod
|
|
def _target_ticket_ref(session: AISession) -> str | None:
|
|
"""Display ref for the linked PSA ticket, e.g. 'CW #48291'.
|
|
|
|
ConnectWise is the only PSA wired today (per the Phase 1 constraint),
|
|
so a CW prefix is reasonable. Other PSAs will need provider-aware
|
|
formatting in Phase 4.
|
|
"""
|
|
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:
|
|
"""Compose the structured input the LLM sees for one preview call."""
|
|
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("# Active suggested fix")
|
|
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}")
|
|
if active_fix.user_decision:
|
|
lines.append(f"Engineer decision: {active_fix.user_decision}")
|
|
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("# Scripts run during the session (passwords redacted)")
|
|
if not generations:
|
|
lines.append("(none)")
|
|
else:
|
|
for g in generations:
|
|
lines.append(f"- {g['template_name']} (slug={g['template_slug']})")
|
|
if g["parameters_used"]:
|
|
lines.append(f" parameters: {g['parameters_used']}")
|
|
|
|
lines.append("")
|
|
lines.append(
|
|
"Produce the four-section resolution note now. Use only the input above."
|
|
)
|
|
return "\n".join(lines)
|