feat: overhaul session documentation, PSA notes, and client communications

- Reformat PSA resolution/escalation notes: clean single-line header,
  steps with engineer responses inline, remove duplicate timing blocks,
  remove AI confidence section, add follow-up recommendations
- Standardize time display to decimal hours (e.g. 0.25 hrs) across all
  note formatters and status update context
- Add follow_up_recommendations to SessionDocumentation schema and
  surface in SessionDocView; extracted from resolution suggestion steps
- Add _build_what_we_know() helper: uses session.evidence_items when
  cockpit branch merges, falls back to deriving findings from steps
- Fix option label lookup in generate_status_update (was passing raw
  machine values to AI instead of human-readable labels)
- Add 'What We Know' section to status update ticket notes prompt
- Improve _build_session_context in resolution_output_generator to
  include intake text and full step details instead of truncated chat
- Add request_info audience type: client-facing information request
  that skips the length step and generates a numbered question list
- Improve client_update and email_draft prompts with per-context
  guidance (status/resolution/escalation) and fix escalation subject
  line from 'Specialist Review' to 'Specialist Assistance'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-05 15:18:31 +00:00
parent 22baad7992
commit f4143e52a1
7 changed files with 352 additions and 172 deletions

View File

@@ -911,16 +911,36 @@ async def generate_status_update(
steps_summary = []
for step in sorted(session.steps, key=lambda s: s.step_order):
content = step.content or {}
text = content.get("text", "")
response = step.free_text_input or step.selected_option or ("Skipped" if step.was_skipped else None)
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
continue
text = content.get("text", "").strip()
if not text:
continue
# Resolve option label instead of raw machine value
response = None
if step.was_skipped:
response = "Skipped"
elif step.selected_option and step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
response = opt.get("label", step.selected_option)
break
else:
response = step.selected_option
elif step.selected_option:
response = step.selected_option
elif step.free_text_input:
response = step.free_text_input
outcome = None
if step.action_result:
outcome = "Succeeded" if step.action_result.get("success") else "Did not resolve"
entry = f"Step {step.step_order + 1}: {text}"
if response:
entry += f"\n Engineer response: {response}"
entry = f"{step.step_order + 1}. {text}"
if response and response != "Skipped":
entry += f" {response}"
elif response == "Skipped":
entry += " (skipped)"
if outcome:
entry += f"\n Outcome: {outcome}"
entry += f" [{outcome}]"
steps_summary.append(entry)
steps_text = "\n".join(steps_summary) if steps_summary else "No diagnostic steps yet."
@@ -929,13 +949,8 @@ async def generate_status_update(
now = datetime.now(timezone.utc)
ref_time = session.resolved_at or now
delta = ref_time - session.created_at
total_minutes = int(delta.total_seconds() / 60)
if total_minutes < 60:
time_display = f"{total_minutes} minutes"
else:
hours = total_minutes // 60
remaining = total_minutes % 60
time_display = f"{hours}h {remaining}m"
total_hrs = round(delta.total_seconds() / 3600, 2)
time_display = f"{total_hrs} hrs"
# Extract client name from intake or ticket data
client_name = None
@@ -1135,8 +1150,9 @@ def _build_status_update_prompt(
Rules:
- Be technical, concise, and factual
- Use markdown formatting (bold headers, bullet lists)
- Include: current status, steps completed, findings, what's been ruled out, next steps
- Use plain text with simple section headers (no markdown bold/bullets — PSA renders raw text)
- Structure as: current status paragraph, then "What We Know" section, then next steps
- "What We Know" should list confirmed findings, ruled-out causes, and open questions — keep each item to one line
- Do NOT soften language or add pleasantries
- Do NOT include greetings or sign-offs
- {length_instruction}
@@ -1147,28 +1163,54 @@ Output ONLY the update text. No JSON, no markdown code fences, no preamble."""
elif audience == "client_update":
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
return f"""You are generating a client-facing {context_label}.
context_guidance = {
"status": "We're actively working on it. Describe progress made so far and what comes next without giving a timeline.",
"resolution": "This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language.",
"escalation": "Be reassuring — explain that a specialist is being brought in to assist, not that something failed.",
}.get(context, "")
return f"""You are generating a brief client-facing {context_label}.
Rules:
- Be professional, reassuring, and non-technical
- NEVER use technical jargon (no "transport rules", "MX records", "DNS", "registry", "GPO", etc.)
- NEVER include server names, IP addresses, internal tool names, or technical identifiers
- NEVER use technical jargon (no "transport rules", "MX records", "DNS", "registry", "GPO", "connector", etc.)
- NEVER include server names, IP addresses, internal tool names, or ticket IDs
- Explain findings in plain language a non-technical business owner would understand
- {client_greeting}
- Sign off with: {engineer_name}
- {length_instruction}
{"- This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language." if context == "resolution" else ""}
{"- Be reassuring — explain that a specialist is being brought in, not that something failed." if context == "escalation" else ""}
- {context_guidance}
Output ONLY the update text. No JSON, no markdown code fences, no preamble."""
elif audience == "request_info":
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
return f"""You are generating a brief, professional message requesting information from the client.
Rules:
- Be friendly, concise, and non-technical
- Start with one sentence explaining what you're currently working on (plain language, no jargon)
- Then list the specific questions you need answered, as a numbered list
- Each question should be clear and answerable by a non-technical user
- NEVER use technical jargon, server names, IP addresses, or internal tool names
- {client_greeting}
- Sign off with: {engineer_name}
- Keep it short — this is a targeted ask, not a status update
Output ONLY the message text. No JSON, no markdown code fences, no preamble."""
else: # email_draft
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
subject_hints = {
"status": "Update: [brief issue description]",
"resolution": "Resolved: [brief issue description]",
"escalation": "Update: [brief issue description] — Specialist Review",
"escalation": "Update: [brief issue description] — Specialist Assistance",
"need_info": "Quick Question: [brief issue description]",
}
context_guidance = {
"status": "We're actively working on it. Describe progress and next steps without giving a timeline.",
"resolution": "This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language.",
"escalation": "Be reassuring — explain that a specialist is being brought in to assist, not that something failed.",
}.get(context, "")
return f"""You are generating a complete email draft for client communication.
Rules:
@@ -1177,15 +1219,63 @@ Rules:
- {client_greeting}
- Be professional, reassuring, and non-technical
- NEVER use technical jargon, server names, IP addresses, or internal tool names
- Include a professional sign-off with:
{engineer_name}
- Include a professional sign-off with: {engineer_name}
- {length_instruction}
{"- This is good news — the issue is resolved." if context == "resolution" else ""}
{"- Be reassuring — explain that a specialist is being brought in." if context == "escalation" else ""}
- {context_guidance}
Output ONLY the email text (Subject + body). No JSON, no markdown code fences, no preamble."""
def _build_what_we_know(session: AISession) -> str:
"""Build a 'What We Know' summary from evidence_items (cockpit) or derived from steps.
When the cockpit branch merges, session.evidence_items will be populated by the AI
with confirmed/ruled_out/pending classifications. Until then, we derive findings
from completed diagnostic steps.
"""
evidence_items = getattr(session, 'evidence_items', None)
if evidence_items:
confirmed = [e['text'] for e in evidence_items if e.get('status') == 'confirmed']
ruled_out = [e['text'] for e in evidence_items if e.get('status') == 'ruled_out']
pending = [e['text'] for e in evidence_items if e.get('status') == 'pending']
parts = []
if confirmed:
parts.append("Confirmed:\n" + "\n".join(f" - {t}" for t in confirmed))
if ruled_out:
parts.append("Ruled out:\n" + "\n".join(f" - {t}" for t in ruled_out))
if pending:
parts.append("Still investigating:\n" + "\n".join(f" - {t}" for t in pending))
return "\n".join(parts)
# Derive from completed steps
findings = []
for step in sorted(session.steps or [], key=lambda s: s.step_order):
content = step.content or {}
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
continue
description = content.get("text", "").strip()
if not description or step.was_skipped:
continue
response = None
if step.selected_option and step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
response = opt.get("label", step.selected_option)
break
else:
response = step.selected_option
elif step.selected_option:
response = step.selected_option
elif step.free_text_input:
response = step.free_text_input
if response:
findings.append(f"{description}{response}")
if not findings:
return ""
return "Findings so far:\n" + "\n".join(f" - {f}" for f in findings)
def _build_status_update_context(
session: AISession,
steps_text: str,
@@ -1206,24 +1296,17 @@ def _build_status_update_context(
if session.psa_ticket_id:
parts.append(f"Ticket ID: {session.psa_ticket_id}")
parts.append(f"\nDiagnostic steps:\n{steps_text}")
what_we_know = _build_what_we_know(session)
if what_we_know:
parts.append(f"\nWhat we know:\n{what_we_know}")
parts.append(f"\nDiagnostic steps taken:\n{steps_text}")
if context == "resolution" and session.resolution_summary:
parts.append(f"\nResolution: {session.resolution_summary}")
if context == "escalation" and session.escalation_reason:
parts.append(f"\nEscalation reason: {session.escalation_reason}")
# Include recent conversation messages for richer context
messages = session.conversation_messages or []
if messages:
recent = messages[-10:] # Last 10 messages
convo_text = "\n".join(
f"{'Engineer' if m['role'] == 'user' else 'FlowPilot'}: {m['content'][:300]}"
for m in recent
if isinstance(m, dict) and "role" in m and "content" in m
)
parts.append(f"\nRecent conversation:\n{convo_text}")
return "\n".join(parts)
@@ -1420,6 +1503,7 @@ def _create_step_from_parsed(
def _generate_documentation(session: AISession) -> SessionDocumentation:
"""Generate structured documentation from a session's steps."""
diagnostic_steps = []
follow_up_recommendations: list[str] = []
for step in session.steps:
content = step.content or {}
@@ -1459,6 +1543,12 @@ def _generate_documentation(session: AISession) -> SessionDocumentation:
outcome=outcome,
))
# Collect follow-up recommendations from resolution suggestion steps
if content.get("type") == "resolution_suggestion":
recs = content.get("follow_up_recommendations", [])
if isinstance(recs, list):
follow_up_recommendations.extend(recs)
# Calculate duration
duration_display = None
if session.resolved_at and session.created_at:
@@ -1484,6 +1574,7 @@ def _generate_documentation(session: AISession) -> SessionDocumentation:
diagnostic_steps=diagnostic_steps,
resolution_summary=session.resolution_summary,
escalation_reason=session.escalation_reason,
follow_up_recommendations=follow_up_recommendations,
total_steps=session.step_count,
duration_display=duration_display,
generated_at=datetime.now(timezone.utc),