From 1a45f66358a56cb474653241297e26941041d15f Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 5 Apr 2026 15:18:31 +0000 Subject: [PATCH] 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 --- backend/app/schemas/ai_session.py | 3 +- backend/app/services/flowpilot_engine.py | 165 ++++++++--- .../app/services/psa_documentation_service.py | 258 ++++++++++-------- .../services/resolution_output_generator.py | 53 +++- .../components/flowpilot/SessionDocView.tsx | 17 ++ .../flowpilot/StatusUpdateModal.tsx | 26 +- frontend/src/types/ai-session.ts | 3 +- 7 files changed, 352 insertions(+), 173 deletions(-) diff --git a/backend/app/schemas/ai_session.py b/backend/app/schemas/ai_session.py index 8768e48f..9902ef59 100644 --- a/backend/app/schemas/ai_session.py +++ b/backend/app/schemas/ai_session.py @@ -135,6 +135,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 @@ -154,7 +155,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( diff --git a/backend/app/services/flowpilot_engine.py b/backend/app/services/flowpilot_engine.py index 3a9a6f78..20c06299 100644 --- a/backend/app/services/flowpilot_engine.py +++ b/backend/app/services/flowpilot_engine.py @@ -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), diff --git a/backend/app/services/psa_documentation_service.py b/backend/app/services/psa_documentation_service.py index 6a40bbf5..17a62587 100644 --- a/backend/app/services/psa_documentation_service.py +++ b/backend/app/services/psa_documentation_service.py @@ -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) diff --git a/backend/app/services/resolution_output_generator.py b/backend/app/services/resolution_output_generator.py index c3d5f690..04cb16b9 100644 --- a/backend/app/services/resolution_output_generator.py +++ b/backend/app/services/resolution_output_generator.py @@ -100,11 +100,13 @@ class ResolutionOutputGenerator: steps_taken: list[str] | None = None, recommendations: str | None = None, ) -> 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}", ] # Structured handoff fields from cockpit conclude modal @@ -117,7 +119,7 @@ class ResolutionOutputGenerator: if recommendations: parts.append(f"Recommendations: {recommendations}") - # Triage metadata + # Triage metadata (cockpit branch) if getattr(session, 'client_name', None): parts.append(f"Client: {session.client_name}") if getattr(session, 'triage_hypothesis', None): @@ -128,13 +130,46 @@ class ResolutionOutputGenerator: icon = {"confirmed": "✓", "ruled_out": "✗", "pending": "?"}.get(item.get("status", ""), "?") parts.append(f" {icon} {item.get('text', '')}") - 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}") + # Diagnostic steps from FlowPilot session steps + diagnostic = [] + follow_ups: list[str] = [] + for step in sorted(session.steps or [], key=lambda s: s.step_order): + 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: diff --git a/frontend/src/components/flowpilot/SessionDocView.tsx b/frontend/src/components/flowpilot/SessionDocView.tsx index a8fa3b98..0694d453 100644 --- a/frontend/src/components/flowpilot/SessionDocView.tsx +++ b/frontend/src/components/flowpilot/SessionDocView.tsx @@ -187,6 +187,23 @@ export function SessionDocView({ ))} + {/* Follow-up recommendations */} + {documentation.follow_up_recommendations.length > 0 && ( +
+

+ Follow-up +

+
    + {documentation.follow_up_recommendations.map((rec, i) => ( +
  • + + {rec} +
  • + ))} +
+
+ )} + {/* Rating */} {onRate && (
diff --git a/frontend/src/components/flowpilot/StatusUpdateModal.tsx b/frontend/src/components/flowpilot/StatusUpdateModal.tsx index 4279807e..0eeaadd6 100644 --- a/frontend/src/components/flowpilot/StatusUpdateModal.tsx +++ b/frontend/src/components/flowpilot/StatusUpdateModal.tsx @@ -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

- 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...'}

)} diff --git a/frontend/src/types/ai-session.ts b/frontend/src/types/ai-session.ts index 48eba6e3..2fd4ccb0 100644 --- a/frontend/src/types/ai-session.ts +++ b/frontend/src/types/ai-session.ts @@ -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'