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:
@@ -127,6 +127,7 @@ class SessionDocumentation(BaseModel):
|
||||
diagnostic_steps: list[DocumentationStep]
|
||||
resolution_summary: str | None = None
|
||||
escalation_reason: str | None = None
|
||||
follow_up_recommendations: list[str] = []
|
||||
total_steps: int
|
||||
duration_display: str | None = None
|
||||
generated_at: datetime
|
||||
@@ -146,7 +147,7 @@ class StatusUpdateRequest(BaseModel):
|
||||
"""Generate a mid-session or post-session status update."""
|
||||
audience: str = Field(
|
||||
...,
|
||||
pattern="^(ticket_notes|client_update|email_draft)$",
|
||||
pattern="^(ticket_notes|client_update|email_draft|request_info)$",
|
||||
description="Who is this update for?",
|
||||
)
|
||||
length: str = Field(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -57,181 +57,199 @@ def _format_datetime(dt: datetime | None) -> str:
|
||||
return dt.strftime("%Y-%m-%d %I:%M %p UTC")
|
||||
|
||||
|
||||
def _get_engineer_response(step) -> str | None:
|
||||
"""Extract the engineer's response label from a step."""
|
||||
if step.was_skipped:
|
||||
return "Skipped"
|
||||
if step.selected_option and step.options_presented:
|
||||
for opt in step.options_presented:
|
||||
if opt.get("value") == step.selected_option:
|
||||
return opt.get("label", step.selected_option)
|
||||
return step.selected_option
|
||||
if step.selected_option:
|
||||
return step.selected_option
|
||||
if step.free_text_input:
|
||||
return step.free_text_input
|
||||
return None
|
||||
|
||||
|
||||
def format_resolution_note(session: AISession, include_steps: bool = True) -> str:
|
||||
"""Format a resolved session as a plain-text note for CW."""
|
||||
lines = [
|
||||
"═══ FlowPilot Session Documentation ═══",
|
||||
f"Session: {session.id}",
|
||||
]
|
||||
|
||||
# Engineer name from relationship if loaded, otherwise user_id
|
||||
engineer_name = getattr(session, 'user', None)
|
||||
if engineer_name and hasattr(engineer_name, 'name'):
|
||||
lines.append(f"Engineer: {engineer_name.name}")
|
||||
engineer_display = engineer_name.name if engineer_name and hasattr(engineer_name, 'name') else "Unknown"
|
||||
|
||||
lines.extend([
|
||||
f"Date: {_format_datetime(session.resolved_at)}",
|
||||
f"Started: {_format_datetime(session.created_at)}",
|
||||
f"Ended: {_format_datetime(session.resolved_at)}",
|
||||
])
|
||||
|
||||
# Duration
|
||||
duration_str = ""
|
||||
if session.resolved_at and session.created_at:
|
||||
delta = session.resolved_at - session.created_at
|
||||
minutes = int(delta.total_seconds() / 60)
|
||||
if minutes < 60:
|
||||
lines.append(f"Duration: {minutes}m")
|
||||
else:
|
||||
lines.append(f"Duration: {minutes // 60}h {minutes % 60}m")
|
||||
total_hrs = round(delta.total_seconds() / 3600, 2)
|
||||
duration_str = f" — {total_hrs} hrs"
|
||||
|
||||
lines.append("")
|
||||
lines.append("── Problem ──")
|
||||
lines.append(session.problem_summary or "No summary available")
|
||||
if session.problem_domain:
|
||||
lines.append(f"Domain: {session.problem_domain}")
|
||||
lines = [
|
||||
f"FlowPilot Session — {engineer_display}{duration_str}",
|
||||
f"Problem: {session.problem_summary or 'No summary available'}",
|
||||
]
|
||||
|
||||
# Diagnostic steps
|
||||
if include_steps and session.steps:
|
||||
lines.append("")
|
||||
lines.append("── Diagnosis Path ──")
|
||||
lines.append("Steps:")
|
||||
for step in session.steps:
|
||||
content = step.content or {}
|
||||
step_type = content.get("type", step.step_type).capitalize()
|
||||
description = content.get("text", "")
|
||||
|
||||
response_text = ""
|
||||
if step.was_skipped:
|
||||
response_text = "Skipped"
|
||||
elif step.selected_option:
|
||||
# Try to find the label
|
||||
if step.options_presented:
|
||||
for opt in step.options_presented:
|
||||
if opt.get("value") == step.selected_option:
|
||||
response_text = opt.get("label", step.selected_option)
|
||||
break
|
||||
else:
|
||||
response_text = step.selected_option
|
||||
else:
|
||||
response_text = step.selected_option
|
||||
elif step.free_text_input:
|
||||
response_text = step.free_text_input
|
||||
|
||||
lines.append(f"{step.step_order + 1}. [{step_type}] {description}")
|
||||
if response_text:
|
||||
lines.append(f" → Response: {response_text}")
|
||||
if step.action_result:
|
||||
result = step.action_result
|
||||
outcome = "Succeeded" if result.get("success") else "Did not resolve"
|
||||
if details := result.get("details"):
|
||||
outcome += f" — {details}"
|
||||
lines.append(f" → Result: {outcome}")
|
||||
step_type = content.get("type", "")
|
||||
if step_type == "resolution_suggestion":
|
||||
continue # Not a diagnostic step
|
||||
description = content.get("text", "").strip()
|
||||
if not description:
|
||||
continue
|
||||
response = _get_engineer_response(step)
|
||||
line = f"{step.step_order + 1}. {description}"
|
||||
if response and response != "Skipped":
|
||||
line += f" — {response}"
|
||||
elif response == "Skipped":
|
||||
line += " (skipped)"
|
||||
lines.append(line)
|
||||
|
||||
# Resolution
|
||||
lines.append("")
|
||||
lines.append("── Resolution ──")
|
||||
lines.append(session.resolution_summary or "No resolution summary")
|
||||
lines.append(f"Resolution: {session.resolution_summary or 'No resolution summary'}")
|
||||
if session.resolution_action:
|
||||
lines.append(session.resolution_action)
|
||||
|
||||
# Confidence
|
||||
lines.append("")
|
||||
lines.append("── AI Confidence ──")
|
||||
lines.append(f"Final confidence: {session.confidence_tier} ({session.confidence_score:.0%})")
|
||||
# Follow-up recommendations from resolution suggestion step
|
||||
follow_ups: list[str] = []
|
||||
for step in session.steps:
|
||||
content = step.content or {}
|
||||
if content.get("type") == "resolution_suggestion":
|
||||
recs = content.get("follow_up_recommendations", [])
|
||||
if isinstance(recs, list):
|
||||
follow_ups.extend(recs)
|
||||
if follow_ups:
|
||||
lines.append("")
|
||||
lines.append("Follow-up:")
|
||||
for rec in follow_ups:
|
||||
lines.append(f"- {rec}")
|
||||
|
||||
# Timing section (always present)
|
||||
# Timing
|
||||
lines.append("")
|
||||
lines.append("── Session Timing ──")
|
||||
lines.append(f"Start: {_format_datetime(session.created_at)}")
|
||||
lines.append(f"End: {_format_datetime(session.resolved_at)}")
|
||||
if session.resolved_at and session.created_at:
|
||||
delta = session.resolved_at - session.created_at
|
||||
minutes = int(delta.total_seconds() / 60)
|
||||
lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m")
|
||||
total_hrs = round(delta.total_seconds() / 3600, 2)
|
||||
lines.append(f"Total: {total_hrs} hrs")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Generated by ResolutionFlow FlowPilot")
|
||||
lines.append("Generated by ResolutionFlow")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _derive_what_we_know(session: AISession) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Return (confirmed, ruled_out, pending) findings.
|
||||
|
||||
Uses session.evidence_items when the cockpit branch is merged; falls back
|
||||
to deriving 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']
|
||||
return confirmed, ruled_out, pending
|
||||
|
||||
# Derive from completed steps — all answered steps become findings
|
||||
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 = _get_engineer_response(step)
|
||||
if response:
|
||||
findings.append(f"{description} — {response}")
|
||||
return findings, [], []
|
||||
|
||||
|
||||
def format_escalation_note(session: AISession, include_steps: bool = True) -> str:
|
||||
"""Format an escalated session as a plain-text note for CW."""
|
||||
engineer_obj = getattr(session, 'user', None)
|
||||
engineer_display = engineer_obj.name if engineer_obj and hasattr(engineer_obj, 'name') else "Unknown"
|
||||
|
||||
escalated_to_obj = getattr(session, 'escalated_to', None)
|
||||
escalated_to_display = escalated_to_obj.name if escalated_to_obj and hasattr(escalated_to_obj, 'name') else None
|
||||
|
||||
escalated_at = session.resolved_at or datetime.now(timezone.utc)
|
||||
duration_str = ""
|
||||
if session.created_at:
|
||||
delta = escalated_at - session.created_at
|
||||
total_hrs = round(delta.total_seconds() / 3600, 2)
|
||||
duration_str = f" — {total_hrs} hrs"
|
||||
|
||||
header = f"FlowPilot Escalation — {engineer_display}{duration_str}"
|
||||
if escalated_to_display:
|
||||
header += f" → {escalated_to_display}"
|
||||
lines = [
|
||||
"═══ FlowPilot Escalation Documentation ═══",
|
||||
f"Session: {session.id}",
|
||||
header,
|
||||
f"Problem: {session.problem_summary or 'No summary available'}",
|
||||
]
|
||||
|
||||
engineer_name = getattr(session, 'user', None)
|
||||
if engineer_name and hasattr(engineer_name, 'name'):
|
||||
lines.append(f"Escalated by: {engineer_name.name}")
|
||||
|
||||
escalated_to = getattr(session, 'escalated_to', None)
|
||||
if escalated_to and hasattr(escalated_to, 'name'):
|
||||
lines.append(f"Escalated to: {escalated_to.name}")
|
||||
else:
|
||||
lines.append("Escalated to: Unassigned")
|
||||
|
||||
lines.extend([
|
||||
f"Date: {_format_datetime(session.resolved_at or datetime.now(timezone.utc))}",
|
||||
f"Started: {_format_datetime(session.created_at)}",
|
||||
])
|
||||
|
||||
if session.resolved_at and session.created_at:
|
||||
delta = session.resolved_at - session.created_at
|
||||
minutes = int(delta.total_seconds() / 60)
|
||||
lines.append(f"Duration: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Duration: {minutes}m")
|
||||
|
||||
lines.append("")
|
||||
lines.append("── Problem ──")
|
||||
lines.append(session.problem_summary or "No summary available")
|
||||
|
||||
# Work completed
|
||||
# Work completed with responses
|
||||
if include_steps and session.steps:
|
||||
lines.append("")
|
||||
lines.append("── Work Completed ──")
|
||||
for step in session.steps:
|
||||
lines.append("Work completed:")
|
||||
for step in sorted(session.steps, key=lambda s: s.step_order):
|
||||
content = step.content or {}
|
||||
description = content.get("text", "")
|
||||
lines.append(f"{step.step_order + 1}. {description}")
|
||||
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
|
||||
continue
|
||||
description = content.get("text", "").strip()
|
||||
if not description:
|
||||
continue
|
||||
response = _get_engineer_response(step)
|
||||
line = f"{step.step_order + 1}. {description}"
|
||||
if response and response != "Skipped":
|
||||
line += f" — {response}"
|
||||
elif response == "Skipped":
|
||||
line += " (skipped)"
|
||||
lines.append(line)
|
||||
|
||||
# What We Know
|
||||
confirmed, ruled_out, pending = _derive_what_we_know(session)
|
||||
if confirmed or ruled_out or pending:
|
||||
lines.append("")
|
||||
lines.append("What we know:")
|
||||
for f in confirmed:
|
||||
lines.append(f" ✓ {f}")
|
||||
for f in ruled_out:
|
||||
lines.append(f" ✗ {f}")
|
||||
for f in pending:
|
||||
lines.append(f" ? {f}")
|
||||
|
||||
# Escalation reason
|
||||
lines.append("")
|
||||
lines.append("── Escalation Reason ──")
|
||||
lines.append(session.escalation_reason or "No reason provided")
|
||||
lines.append(f"Escalation reason: {session.escalation_reason or 'No reason provided'}")
|
||||
|
||||
# Escalation package details
|
||||
# Suggested next steps from escalation package
|
||||
pkg = session.escalation_package or {}
|
||||
if hypotheses := pkg.get("remaining_hypotheses"):
|
||||
lines.append("")
|
||||
lines.append("── Remaining Hypotheses ──")
|
||||
if isinstance(hypotheses, list):
|
||||
for h in hypotheses:
|
||||
lines.append(f"- {h}")
|
||||
else:
|
||||
lines.append(str(hypotheses))
|
||||
|
||||
if suggestions := pkg.get("suggested_next_steps"):
|
||||
lines.append("")
|
||||
lines.append("── Suggested Next Steps ──")
|
||||
if isinstance(suggestions, list):
|
||||
for s in suggestions:
|
||||
lines.append(f"- {s}")
|
||||
else:
|
||||
lines.append(str(suggestions))
|
||||
lines.append("Suggested next steps:")
|
||||
items = suggestions if isinstance(suggestions, list) else [str(suggestions)]
|
||||
for s in items:
|
||||
lines.append(f"- {s}")
|
||||
|
||||
# Timing
|
||||
lines.append("")
|
||||
lines.append("── Session Timing ──")
|
||||
lines.append(f"Start: {_format_datetime(session.created_at)}")
|
||||
escalated_at = session.resolved_at or datetime.now(timezone.utc)
|
||||
lines.append(f"Escalated: {_format_datetime(escalated_at)}")
|
||||
if session.created_at:
|
||||
delta = escalated_at - session.created_at
|
||||
minutes = int(delta.total_seconds() / 60)
|
||||
lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m")
|
||||
total_hrs = round(delta.total_seconds() / 3600, 2)
|
||||
lines.append(f"Total: {total_hrs} hrs")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Generated by ResolutionFlow FlowPilot")
|
||||
lines.append("Generated by ResolutionFlow")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -83,19 +83,55 @@ class ResolutionOutputGenerator:
|
||||
return output
|
||||
|
||||
def _build_session_context(self, session: AISession) -> str:
|
||||
intake = session.intake_content or {}
|
||||
intake_text = intake.get("text", "") or str(intake)
|
||||
parts = [
|
||||
f"Problem: {session.problem_summary or 'Unknown'}",
|
||||
f"Domain: {session.problem_domain or 'Unknown'}",
|
||||
f"Original intake: {intake_text[:300]}",
|
||||
f"Resolution: {session.resolution_summary or 'Not specified'}",
|
||||
f"Steps taken: {session.step_count}",
|
||||
]
|
||||
msgs = session.conversation_messages or []
|
||||
if msgs:
|
||||
parts.append("\nConversation highlights:")
|
||||
for msg in msgs[-10:]:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")[:200]
|
||||
parts.append(f" [{role}]: {content}")
|
||||
|
||||
steps = sorted(session.steps or [], key=lambda s: s.step_order)
|
||||
diagnostic = []
|
||||
follow_ups: list[str] = []
|
||||
for step in steps:
|
||||
content = step.content or {}
|
||||
step_type = content.get("type", "")
|
||||
if step_type == "resolution_suggestion":
|
||||
recs = content.get("follow_up_recommendations", [])
|
||||
if isinstance(recs, list):
|
||||
follow_ups.extend(recs)
|
||||
continue
|
||||
description = content.get("text", "").strip()
|
||||
if not description:
|
||||
continue
|
||||
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
|
||||
entry = f" {step.step_order + 1}. {description}"
|
||||
if response and response != "skipped":
|
||||
entry += f" — {response}"
|
||||
diagnostic.append(entry)
|
||||
|
||||
if diagnostic:
|
||||
parts.append("\nDiagnostic steps:")
|
||||
parts.extend(diagnostic)
|
||||
if follow_ups:
|
||||
parts.append("\nRecommended follow-up:")
|
||||
parts.extend(f" - {r}" for r in follow_ups)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _psa_notes_prompt(self, context: str) -> str:
|
||||
|
||||
@@ -187,6 +187,23 @@ export function SessionDocView({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Follow-up recommendations */}
|
||||
{documentation.follow_up_recommendations.length > 0 && (
|
||||
<div className="card-flat p-3 sm:p-4">
|
||||
<h4 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-2">
|
||||
Follow-up
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{documentation.follow_up_recommendations.map((rec, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-foreground">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rating */}
|
||||
{onRate && (
|
||||
<div className="card-flat p-3 sm:p-4 text-center">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { FileText, User, Mail, Zap, AlignLeft, Copy, Check, RotateCcw, ArrowLeftRight, Loader2 } from 'lucide-react'
|
||||
import { FileText, User, Mail, HelpCircle, Zap, AlignLeft, Copy, Check, RotateCcw, ArrowLeftRight, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { StatusUpdateAudience, StatusUpdateLength, StatusUpdateContext, StatusUpdateResponse } from '@/types/ai-session'
|
||||
@@ -12,10 +12,11 @@ interface StatusUpdateModalProps {
|
||||
hasPsaTicket?: boolean
|
||||
}
|
||||
|
||||
const AUDIENCES: { value: StatusUpdateAudience; icon: typeof FileText; label: string; description: string }[] = [
|
||||
const AUDIENCES: { value: StatusUpdateAudience; icon: typeof FileText; label: string; description: string; skipLength?: boolean }[] = [
|
||||
{ value: 'ticket_notes', icon: FileText, label: 'Ticket Notes', description: 'Technical, for your PSA' },
|
||||
{ value: 'client_update', icon: User, label: 'Client Update', description: 'Professional, non-technical' },
|
||||
{ value: 'email_draft', icon: Mail, label: 'Email Draft', description: 'Full email with subject line' },
|
||||
{ value: 'request_info', icon: HelpCircle, label: 'Request Information', description: 'Ask the client specific questions', skipLength: true },
|
||||
]
|
||||
|
||||
const LENGTHS: { value: StatusUpdateLength; icon: typeof Zap; label: string; description: string }[] = [
|
||||
@@ -38,9 +39,24 @@ export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status
|
||||
escalation: 'Share Escalation',
|
||||
}
|
||||
|
||||
const handleAudienceSelect = (value: StatusUpdateAudience) => {
|
||||
const handleAudienceSelect = async (value: StatusUpdateAudience) => {
|
||||
setAudience(value)
|
||||
setStep('length')
|
||||
const opt = AUDIENCES.find(a => a.value === value)
|
||||
if (opt?.skipLength) {
|
||||
// Skip length selection — always concise for request_info
|
||||
setLength('quick')
|
||||
setStep('generating')
|
||||
try {
|
||||
const res = await onGenerate(value, 'quick', context)
|
||||
setResult(res)
|
||||
setStep('result')
|
||||
} catch {
|
||||
setStep('audience')
|
||||
setAudience(null)
|
||||
}
|
||||
} else {
|
||||
setStep('length')
|
||||
}
|
||||
}
|
||||
|
||||
const handleLengthSelect = async (value: StatusUpdateLength) => {
|
||||
@@ -170,7 +186,7 @@ export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
||||
<Loader2 size={24} className="animate-spin text-blue-400" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generating {audience === 'email_draft' ? 'email draft' : audience === 'client_update' ? 'client update' : 'ticket notes'}...
|
||||
{audience === 'request_info' ? 'Drafting information request...' : audience === 'email_draft' ? 'Generating email draft...' : audience === 'client_update' ? 'Generating client update...' : 'Generating ticket notes...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -117,6 +117,7 @@ export interface SessionDocumentation {
|
||||
diagnostic_steps: DocumentationStep[]
|
||||
resolution_summary: string | null
|
||||
escalation_reason: string | null
|
||||
follow_up_recommendations: string[]
|
||||
total_steps: number
|
||||
duration_display: string | null
|
||||
generated_at: string
|
||||
@@ -131,7 +132,7 @@ export interface SessionCloseResponse {
|
||||
member_mapping_warning: string | null
|
||||
}
|
||||
|
||||
export type StatusUpdateAudience = 'ticket_notes' | 'client_update' | 'email_draft'
|
||||
export type StatusUpdateAudience = 'ticket_notes' | 'client_update' | 'email_draft' | 'request_info'
|
||||
export type StatusUpdateLength = 'quick' | 'detailed'
|
||||
export type StatusUpdateContext = 'status' | 'resolution' | 'escalation'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user