diff --git a/backend/app/api/endpoints/scripts.py b/backend/app/api/endpoints/scripts.py index 3db7175d..13e1834e 100644 --- a/backend/app/api/endpoints/scripts.py +++ b/backend/app/api/endpoints/scripts.py @@ -5,7 +5,7 @@ import re from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, or_, literal +from sqlalchemy import select, func, or_, literal, update as sa_update from app.core.database import get_db from app.api.deps import get_current_active_user @@ -374,6 +374,20 @@ async def generate_script( ) db.add(generation) template.usage_count += 1 + + # FlowPilot Phase 3: bump the linked AI session's state_version so the + # resolution-note preview cache invalidates. One-off scripts run outside + # any FlowPilot session — in that case the UPDATE matches zero rows. + if data.ai_session_id is not None: + # Local import: scripts endpoint stays independent of AI-session + # imports for non-AI generation paths. + from app.models.ai_session import AISession + await db.execute( + sa_update(AISession) + .where(AISession.id == data.ai_session_id) + .values(state_version=AISession.state_version + 1) + ) + await db.commit() await db.refresh(generation) diff --git a/backend/app/api/endpoints/session_suggested_fixes.py b/backend/app/api/endpoints/session_suggested_fixes.py new file mode 100644 index 00000000..e18f418b --- /dev/null +++ b/backend/app/api/endpoints/session_suggested_fixes.py @@ -0,0 +1,183 @@ +"""Suggested-fix and resolution-note preview endpoints (Phase 3). + +Per FLOWPILOT-MIGRATION.md Sections 5.2 + 5.4. The preview is keyed on +`(session_id, ai_sessions.state_version)` so repeat fetches against the same +state hit the in-process cache instead of paying for a Sonnet call. +""" +import logging +from datetime import datetime, timezone +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin +from app.models.ai_session import AISession +from app.models.session_suggested_fix import SessionSuggestedFix +from app.models.user import User +from app.schemas.session_suggested_fix import ( + ResolutionNotePreviewResponse, + SessionSuggestedFixDecisionRequest, + SessionSuggestedFixDecisionResponse, + SessionSuggestedFixResponse, +) +from app.services.preview_cache import preview_cache +from app.services.resolution_note_generator import ResolutionNoteGeneratorService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-suggested-fixes"]) + + +async def _load_session_or_404(db: AsyncSession, session_id: UUID) -> AISession: + """RLS-scoped session load. 404 covers both missing and cross-tenant.""" + result = await db.execute(select(AISession).where(AISession.id == session_id)) + session = result.scalar_one_or_none() + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + return session + + +# ── Suggested fix: active ────────────────────────────────────────────────── + +@router.get( + "/suggested-fixes/active", + response_model=SessionSuggestedFixResponse, +) +async def get_active_suggested_fix( + session_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), +) -> SessionSuggestedFixResponse: + """Return the current active suggested fix (`superseded_at IS NULL`) or 404. + + A session has at most one active fix. Multiple historical rows persist + for audit, but only the most-recent un-superseded one is returned here. + """ + await _load_session_or_404(db, session_id) + result = await db.execute( + select(SessionSuggestedFix) + .where( + SessionSuggestedFix.session_id == session_id, + SessionSuggestedFix.superseded_at.is_(None), + ) + .order_by(SessionSuggestedFix.created_at.desc()) + ) + fix = result.scalars().first() + if fix is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No active suggested fix for this session", + ) + return SessionSuggestedFixResponse.model_validate(fix) + + +# ── Suggested fix: decision ──────────────────────────────────────────────── + +@router.post( + "/suggested-fixes/{fix_id}/decision", + response_model=SessionSuggestedFixDecisionResponse, +) +async def record_decision( + session_id: UUID, + fix_id: UUID, + body: SessionSuggestedFixDecisionRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), +) -> SessionSuggestedFixDecisionResponse: + """Record the engineer's path choice on a suggested fix. + + Phase 3 only persists the decision and (for `dismissed`) supersedes the + row. Side effects — script generation for `one_off` / `draft_template`, + redirect for `build_template` — land in Phase 5 alongside the inline + Script Generator integration. The response shape is forward-compatible. + """ + await _load_session_or_404(db, session_id) + + result = await db.execute( + select(SessionSuggestedFix).where( + SessionSuggestedFix.id == fix_id, + SessionSuggestedFix.session_id == session_id, + ) + ) + fix = result.scalar_one_or_none() + if fix is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found" + ) + + # Once a fix has been superseded we still record the engineer's + # decision (it's a historical signal — "engineer dismissed the + # interim hypothesis"), but `dismissed` on a superseded row would + # be redundant noise. + if fix.superseded_at is not None and body.decision == "dismissed": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This fix is already superseded by a newer suggestion", + ) + + fix.user_decision = body.decision + if body.decision == "dismissed" and fix.superseded_at is None: + fix.superseded_at = datetime.now(timezone.utc) + + # Engineer's choice changes the bundle the resolution-note preview sees, + # so bump state_version too. + await db.execute( + update(AISession) + .where(AISession.id == session_id) + .values(state_version=AISession.state_version + 1) + ) + await db.commit() + await db.refresh(fix) + + return SessionSuggestedFixDecisionResponse( + id=fix.id, + user_decision=fix.user_decision, # type: ignore[arg-type] + ) + + +# ── Resolution note preview ──────────────────────────────────────────────── + +@router.post( + "/resolution-note/preview", + response_model=ResolutionNotePreviewResponse, +) +async def resolution_note_preview( + session_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), +) -> ResolutionNotePreviewResponse: + """Generate (or return cached) draft markdown for the Resolve note. + + Cache key: `(resolution_note, session_id, state_version)`. State_version is + bumped by every fact / suggested-fix / script-generation write, so two + consecutive calls with no intervening writes return the same cached + payload (and won't pay for a Sonnet call). + + Posted to PSA in Phase 4. Until then, this endpoint is read-only. + """ + await _load_session_or_404(db, session_id) + gen = ResolutionNoteGeneratorService(db) + try: + payload = await gen.generate_or_get_cached(session_id) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except Exception as e: + logger.exception("Resolution note preview failed for session %s", session_id) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Resolution-note generator error ({type(e).__name__})", + ) + return ResolutionNotePreviewResponse(**payload) + + +# ── Helper used by tests ─────────────────────────────────────────────────── + +def _clear_preview_cache_for_tests() -> None: + """Reset the singleton cache between tests.""" + preview_cache._store.clear() # noqa: SLF001 — test-only access diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 67427df4..da75d9c9 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -44,6 +44,7 @@ from app.api.endpoints import ( session_facts, session_handoffs, session_resolutions, + session_suggested_fixes, sessions, shared, shares, @@ -139,6 +140,7 @@ api_router.include_router(session_resolutions.router, dependencies=_tenant_deps) # session_facts mounts under /ai-sessions/{id}/facts — register before ai_sessions # so the {session_id}/facts subpaths take precedence over any future generic catchalls. api_router.include_router(session_facts.router, dependencies=_tenant_deps) +api_router.include_router(session_suggested_fixes.router, dependencies=_tenant_deps) api_router.include_router(ai_sessions.router, dependencies=_tenant_deps) api_router.include_router(flow_proposals.router, dependencies=_tenant_deps) api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2b780129..85d5d306 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -134,6 +134,11 @@ class Settings(BaseSettings): # Doc Section 6.6 sets Haiku as the default; instrumentation tracks # disputed_fact_rate so we can escalate to Sonnet if quality drops. "fact_synthesis": "fast", + # FlowPilot migration Phase 3 — resolution-note preview that ships to + # the customer ticket. Sonnet because customer-facing artifact quality + # matters more than latency; the in-process state_version cache keeps + # cost manageable. + "resolution_note": "standard", } def get_model_for_action(self, action_type: str) -> str: diff --git a/backend/app/schemas/session_suggested_fix.py b/backend/app/schemas/session_suggested_fix.py new file mode 100644 index 00000000..471dcb82 --- /dev/null +++ b/backend/app/schemas/session_suggested_fix.py @@ -0,0 +1,63 @@ +"""Pydantic schemas for session suggested fixes (Phase 3). + +See FLOWPILOT-MIGRATION.md Section 5.2. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal +from uuid import UUID + +from pydantic import BaseModel, Field + +UserDecision = Literal["one_off", "draft_template", "build_template", "dismissed"] + + +class SessionSuggestedFixResponse(BaseModel): + id: UUID + session_id: UUID + title: str + description: str + confidence_pct: int + script_template_id: UUID | None + ai_drafted_script: str | None + ai_drafted_parameters: dict[str, Any] | None + user_decision: UserDecision | None + superseded_at: datetime | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class SessionSuggestedFixDecisionRequest(BaseModel): + """Engineer's path choice on a suggested fix. + + Server-side side effects per Section 5.2: + - one_off: render the script (Phase 5), no template created. + - draft_template: render + queue a draft_templates row (Phase 5/6). + - build_template: redirect to full template creation (Phase 5). + - dismissed: mark the fix superseded so a fresh suggestion can take over. + """ + decision: UserDecision + + +class SessionSuggestedFixDecisionResponse(BaseModel): + """Returned after recording a decision; richer payloads land in Phase 5.""" + id: UUID + user_decision: UserDecision + # Set when the decision triggered side effects (e.g. a script generation). + # Phase 3 only records the choice; this stays None until Phase 5 wires it. + rendered_script: str | None = None + redirect_path: str | None = Field( + None, + description="Where to send the engineer next (e.g. /scripts/builder?... for build_template)", + ) + + +# ── Resolution note preview ──────────────────────────────────────────────── + +class ResolutionNotePreviewResponse(BaseModel): + markdown: str + target_ticket_ref: str | None + state_version: int + from_cache: bool diff --git a/backend/app/services/assistant_chat_service.py b/backend/app/services/assistant_chat_service.py index 2720b74c..d45d2d3e 100644 --- a/backend/app/services/assistant_chat_service.py +++ b/backend/app/services/assistant_chat_service.py @@ -159,6 +159,44 @@ words, no period. the engineer's message confirms the fact, do not emit a [PROMOTE]. Hallucinated \ facts get posted to customer tickets and will erode trust in the system. +## Proposing a fix with [SUGGEST_FIX] + +When you have a concrete proposed resolution path with reasonable confidence, \ +emit a `[SUGGEST_FIX]` marker. This populates the "Suggested fix" card the \ +engineer can act on (run a script, build a template, etc.). A new \ +[SUGGEST_FIX] supersedes any prior suggested fix on the session — emit a fresh \ +one whenever your top hypothesis changes meaningfully. + +**When to emit [SUGGEST_FIX]:** +- You have a concrete resolution path (not just "investigate further") +- Confidence is at least ~50% — below that, keep diagnosing +- Either a known Script Library template applies, OR you can draft a script \ +that resolves the issue end-to-end + +**When NOT to emit [SUGGEST_FIX]:** +- You're still narrowing causes and the fix depends on the next answer +- The "fix" is just running another diagnostic — that goes in [ACTIONS] +- Two paths are equally likely — fork or ask first, suggest later + +**[SUGGEST_FIX] marker format (one block per response, last one wins):** + +[SUGGEST_FIX] +{"title": "Clear cached credentials + rebuild Outlook profile", "description": "Stale cached credential in Credential Manager is holding the pre-reset token. Clearing it and recreating the profile completes the password change.", "confidence": 94, "script_template_slug": "clear-outlook-credentials"} +[/SUGGEST_FIX] + +- `title`: short imperative summary, ≤ 200 chars +- `description`: one short paragraph explaining the root cause and the fix +- `confidence`: integer 0-100 (what you'd bet this resolves the ticket) +- `script_template_slug`: slug of an existing Script Library template if one \ +applies; OMIT or set null otherwise +- `ai_drafted_script`: full script body if no template matches (only when \ +`script_template_slug` is null/omitted) +- `ai_drafted_parameters`: optional JSON object of suggested parameter values \ +for the drafted script + +The marker is stripped from display — the engineer sees the suggested fix as \ +an interactive card with confidence badge, not raw JSON. + ## Using the Team's Flow Library Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \ appear in the context below, reference them by name so the engineer can launch them \ @@ -232,6 +270,9 @@ in your markers unless you are ≥75% confident that information is no longer re [PROMOTE] markers are OPTIONAL and IN ADDITION to the required ones — emit them only \ when the engineer's most recent message confirmed something worth recording, and copy \ the originating item's `id` into `source_ref` verbatim. +[SUGGEST_FIX] is OPTIONAL — emit one at most per response, only when you have a \ +concrete proposed resolution at ~50%+ confidence. A new [SUGGEST_FIX] supersedes \ +any prior suggested fix. """ diff --git a/backend/app/services/preview_cache.py b/backend/app/services/preview_cache.py new file mode 100644 index 00000000..b1ea64a1 --- /dev/null +++ b/backend/app/services/preview_cache.py @@ -0,0 +1,52 @@ +"""In-process preview cache for FlowPilot resolution-note / escalation-package previews. + +Phase 3 implementation per FLOWPILOT-MIGRATION.md Section 5.5: +- Cache key: `(kind, session_id, state_version)` — no TTL needed, state_version + is the source of truth. +- Invalidation: any write to session_facts, session_suggested_fixes, or + script_generations bumps `ai_sessions.state_version`. Old entries simply + stop being looked up and leak harmlessly until process restart. +- Storage: plain dict, single-process. When Session Sharing brings Redis, + swap the storage without changing the call sites. + +Bound: best-effort soft cap of 5000 entries. When exceeded we drop the +oldest insertion. Not a TTL — at current scale, the cap is more about +resident-memory hygiene than correctness. +""" +from __future__ import annotations + +from collections import OrderedDict +from typing import Any +from uuid import UUID + +_MAX_ENTRIES = 5000 + + +class _PreviewCache: + def __init__(self) -> None: + self._store: OrderedDict[tuple[str, UUID, int], Any] = OrderedDict() + + def get(self, kind: str, session_id: UUID, state_version: int) -> Any | None: + key = (kind, session_id, state_version) + if key not in self._store: + return None + # Touch on access so LRU eviction is meaningful. + self._store.move_to_end(key) + return self._store[key] + + def set(self, kind: str, session_id: UUID, state_version: int, value: Any) -> None: + key = (kind, session_id, state_version) + self._store[key] = value + self._store.move_to_end(key) + # Evict oldest if over cap. OrderedDict.popitem(last=False) is O(1). + while len(self._store) > _MAX_ENTRIES: + self._store.popitem(last=False) + + def invalidate_session(self, session_id: UUID) -> None: + """Drop all entries for a session — used when the session is deleted.""" + keys = [k for k in self._store if k[1] == session_id] + for k in keys: + del self._store[k] + + +preview_cache = _PreviewCache() diff --git a/backend/app/services/resolution_note_generator.py b/backend/app/services/resolution_note_generator.py new file mode 100644 index 00000000..8cdc9206 --- /dev/null +++ b/backend/app/services/resolution_note_generator.py @@ -0,0 +1,320 @@ +"""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 + + +## What we confirmed + + +## Root cause + + +## Resolution +." 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("") + 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) diff --git a/backend/app/services/unified_chat_service.py b/backend/app/services/unified_chat_service.py index ee95ceff..06cfc980 100644 --- a/backend/app/services/unified_chat_service.py +++ b/backend/app/services/unified_chat_service.py @@ -11,6 +11,9 @@ infrastructure and system prompt from assistant_chat_service. Items in pending_task_lane carry stable UUIDs (assigned here) so PROMOTE source_refs survive across turns even when the model re-emits the same question/action. +- `[SUGGEST_FIX]` (Phase 3) — proposes a resolution path for the session. + Each new emission supersedes the previous active row (sets superseded_at) + so there's exactly one active fix at a time. """ import json import logging @@ -22,7 +25,13 @@ from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from datetime import datetime, timezone + +from sqlalchemy import update + from app.models.ai_session import AISession +from app.models.script_template import ScriptTemplate +from app.models.session_suggested_fix import SessionSuggestedFix from app.services.assistant_chat_service import ( ASSISTANT_SYSTEM_PROMPT, _call_ai, @@ -287,6 +296,125 @@ def _assign_stable_task_lane_ids( return out_questions, out_actions +def _parse_suggest_fix_marker( + ai_content: str, +) -> tuple[str, dict[str, Any] | None]: + """Extract a single [SUGGEST_FIX]...[/SUGGEST_FIX] JSON block from AI response. + + The block contains: + {"title": "...", "description": "...", "confidence": 0..100, + "script_template_slug": "..." | null, + "ai_drafted_script": "..." | null, + "ai_drafted_parameters": {...} | null} + + Per FLOWPILOT-MIGRATION.md Section 8.2. Only the LAST block in the response + is honored — if the model emits multiple, only its final view of the fix + matters; earlier ones in the same turn are stale even before persistence. + + Returns (cleaned_content, fix_dict_or_None). Marker stripped from display. + """ + blocks = list(re.finditer(r"\[SUGGEST_FIX\]\s*([\s\S]*?)\s*\[/SUGGEST_FIX\]", ai_content)) + if not blocks: + return ai_content, None + + # Take the last block — most-recent intent wins within a single turn. + last = blocks[-1] + raw = last.group(1).strip() + if raw.startswith("```"): + raw = re.sub(r"^```(?:json)?\s*", "", raw) + raw = re.sub(r"\s*```$", "", raw) + try: + data = json.loads(raw) + except (json.JSONDecodeError, ValueError) as e: + logger.warning("Failed to parse [SUGGEST_FIX] block: %s", e) + return re.sub(r"\[SUGGEST_FIX\]\s*[\s\S]*?\s*\[/SUGGEST_FIX\]", "", ai_content).strip(), None + + if not isinstance(data, dict): + return re.sub(r"\[SUGGEST_FIX\]\s*[\s\S]*?\s*\[/SUGGEST_FIX\]", "", ai_content).strip(), None + + title = (data.get("title") or "").strip() + description = (data.get("description") or "").strip() + confidence = data.get("confidence") + if not title or not description or not isinstance(confidence, (int, float)): + logger.warning("[SUGGEST_FIX] missing required fields, dropping") + return re.sub(r"\[SUGGEST_FIX\]\s*[\s\S]*?\s*\[/SUGGEST_FIX\]", "", ai_content).strip(), None + + confidence_int = max(0, min(100, int(round(float(confidence))))) + + parsed = { + "title": title[:200], + "description": description, + "confidence_pct": confidence_int, + "script_template_slug": (data.get("script_template_slug") or None), + "ai_drafted_script": (data.get("ai_drafted_script") or None), + "ai_drafted_parameters": data.get("ai_drafted_parameters") if isinstance(data.get("ai_drafted_parameters"), dict) else None, + } + + cleaned = re.sub(r"\[SUGGEST_FIX\]\s*[\s\S]*?\s*\[/SUGGEST_FIX\]", "", ai_content).strip() + return cleaned, parsed + + +async def _persist_suggested_fix( + *, + db: AsyncSession, + session: AISession, + fix: dict[str, Any], +) -> None: + """Supersede the prior active fix and insert the new one. Bumps state_version. + + A session has at most one active suggested fix (`superseded_at IS NULL`). + Emitting [SUGGEST_FIX] is the only way to introduce a new one; the + engineer's user_decision is recorded via the decision endpoint. + """ + now = datetime.now(timezone.utc) + + # Mark any prior active rows for this session as superseded. + await db.execute( + update(SessionSuggestedFix) + .where( + SessionSuggestedFix.session_id == session.id, + SessionSuggestedFix.superseded_at.is_(None), + ) + .values(superseded_at=now) + ) + + # Resolve script_template_slug → script_template_id if provided. + script_template_id = None + slug = fix.get("script_template_slug") + if slug: + result = await db.execute( + select(ScriptTemplate).where(ScriptTemplate.slug == slug) + ) + tpl = result.scalar_one_or_none() + if tpl is not None: + script_template_id = tpl.id + else: + logger.warning( + "SUGGEST_FIX referenced unknown script_template_slug=%r — " + "treating as no template match", slug, + ) + + new_fix = SessionSuggestedFix( + session_id=session.id, + account_id=session.account_id, + title=fix["title"], + description=fix["description"], + confidence_pct=fix["confidence_pct"], + script_template_id=script_template_id, + ai_drafted_script=fix.get("ai_drafted_script"), + ai_drafted_parameters=fix.get("ai_drafted_parameters"), + ) + db.add(new_fix) + + # Bump preview-cache version atomically with the supersession+insert. + await db.execute( + update(AISession) + .where(AISession.id == session.id) + .values(state_version=AISession.state_version + 1) + ) + await db.flush() + + async def _persist_promote_items( *, db: AsyncSession, @@ -431,11 +559,13 @@ async def send_chat_message( if session.status == "paused": session.status = "active" - # Check for fork, actions, questions, and promote markers in branch response too + # Check for fork, actions, questions, promote, and suggest_fix markers + # in branch response too branch_display, branch_fork_data = _parse_fork_marker(ai_content) branch_display, branch_actions_data = _parse_actions_marker(branch_display) branch_display, branch_questions_data = _parse_questions_marker(branch_display) branch_display, branch_promote_items = _parse_promote_marker(branch_display) + branch_display, branch_suggest_fix = _parse_suggest_fix_marker(branch_display) if branch_display != ai_content: # Store stripped content in branch history msgs[-1] = {"role": "assistant", "content": branch_display} @@ -493,6 +623,12 @@ async def send_chat_message( db=db, session=session, user_id=user_id, items=branch_promote_items, ) + # Persist a [SUGGEST_FIX] if the branch turn included one. + if branch_suggest_fix: + await _persist_suggested_fix( + db=db, session=session, fix=branch_suggest_fix, + ) + suggested_flows = extract_suggested_flows( await rag_search(query=message, account_id=account_id, db=db, limit=8) ) @@ -542,10 +678,14 @@ async def send_chat_message( # Check for promote markers — facts the AI is surfacing to What we know. display_content, promote_items = _parse_promote_marker(display_content) + # Check for a [SUGGEST_FIX] marker — supersedes the prior active fix. + display_content, suggest_fix_data = _parse_suggest_fix_marker(display_content) + logger.info( - "Marker parsing results — actions: %s, questions: %s, fork: %s, promote: %d, raw_length: %d, display_length: %d", + "Marker parsing results — actions: %s, questions: %s, fork: %s, " + "promote: %d, suggest_fix: %s, raw_length: %d, display_length: %d", bool(actions_data), bool(questions_data), bool(fork_data), - len(promote_items or []), + len(promote_items or []), bool(suggest_fix_data), len(ai_content), len(display_content), ) @@ -630,6 +770,10 @@ async def send_chat_message( db=db, session=session, user_id=user_id, items=promote_items, ) + # Persist a [SUGGEST_FIX] if this turn included one — supersedes prior fix. + if suggest_fix_data: + await _persist_suggested_fix(db=db, session=session, fix=suggest_fix_data) + suggested_flows = extract_suggested_flows(rag_results) return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data diff --git a/backend/tests/test_session_suggested_fixes_api.py b/backend/tests/test_session_suggested_fixes_api.py new file mode 100644 index 00000000..a6a52486 --- /dev/null +++ b/backend/tests/test_session_suggested_fixes_api.py @@ -0,0 +1,356 @@ +"""API + service tests for the FlowPilot Phase 3 suggested-fix + preview surface. + +Covers: +- /api/v1/ai-sessions/{id}/suggested-fixes/active (200 + 404) +- /api/v1/ai-sessions/{id}/suggested-fixes/{fix_id}/decision (one_off, + draft_template, build_template, dismissed; 409 on dismissing a superseded + fix; state_version bump) +- /api/v1/ai-sessions/{id}/resolution-note/preview (LLM mocked; cache hit on + same state_version, miss after a fact write) +- [SUGGEST_FIX] marker parser shape +- _persist_suggested_fix supersession + state_version bump +""" +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import AsyncClient +from sqlalchemy import select + +from app.api.endpoints.session_suggested_fixes import _clear_preview_cache_for_tests +from app.models.ai_session import AISession +from app.models.session_suggested_fix import SessionSuggestedFix +from app.services.unified_chat_service import ( + _parse_suggest_fix_marker, + _persist_suggested_fix, +) + + +@pytest.fixture(autouse=True) +def _isolate_preview_cache(): + _clear_preview_cache_for_tests() + yield + _clear_preview_cache_for_tests() + + +async def _make_session(test_db, user) -> AISession: + session = AISession( + user_id=user["user_data"]["id"], + account_id=user["user_data"]["account_id"], + session_type="chat", + intake_type="free_text", + intake_content={"text": "phase 3 test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.commit() + await test_db.refresh(session) + return session + + +# ── [SUGGEST_FIX] parser ──────────────────────────────────────────────────── + +class TestSuggestFixParser: + def test_no_marker(self): + cleaned, fix = _parse_suggest_fix_marker("just analysis") + assert cleaned == "just analysis" + assert fix is None + + def test_well_formed_block(self): + text = ( + "Analysis sentence.\n\n" + '[SUGGEST_FIX]\n' + '{"title": "Reset password", "description": "Stale credential.", ' + '"confidence": 87, "script_template_slug": "reset-cw"}\n' + '[/SUGGEST_FIX]' + ) + cleaned, fix = _parse_suggest_fix_marker(text) + assert cleaned == "Analysis sentence." + assert fix is not None + assert fix["title"] == "Reset password" + assert fix["confidence_pct"] == 87 + assert fix["script_template_slug"] == "reset-cw" + assert fix["ai_drafted_script"] is None + + def test_confidence_clamped_and_rounded(self): + text = ( + '[SUGGEST_FIX]\n{"title":"x","description":"y","confidence":120.7}\n[/SUGGEST_FIX]' + ) + _, fix = _parse_suggest_fix_marker(text) + assert fix is not None and fix["confidence_pct"] == 100 + + text2 = ( + '[SUGGEST_FIX]\n{"title":"x","description":"y","confidence":-3}\n[/SUGGEST_FIX]' + ) + _, fix2 = _parse_suggest_fix_marker(text2) + assert fix2 is not None and fix2["confidence_pct"] == 0 + + def test_only_last_block_wins(self): + # Stale early block plus a final intent — the parser keeps the LAST one. + text = ( + '[SUGGEST_FIX]\n{"title":"old","description":"o","confidence":50}\n[/SUGGEST_FIX]\n' + '[SUGGEST_FIX]\n{"title":"new","description":"n","confidence":80}\n[/SUGGEST_FIX]' + ) + cleaned, fix = _parse_suggest_fix_marker(text) + assert fix is not None and fix["title"] == "new" + assert "[SUGGEST_FIX]" not in cleaned + + def test_missing_required_field_dropped(self): + text = '[SUGGEST_FIX]\n{"title":"only title"}\n[/SUGGEST_FIX]' + cleaned, fix = _parse_suggest_fix_marker(text) + assert fix is None + # Marker still stripped from display. + assert "[SUGGEST_FIX]" not in cleaned + + def test_malformed_json_dropped(self): + text = "[SUGGEST_FIX]\nnot json\n[/SUGGEST_FIX]" + cleaned, fix = _parse_suggest_fix_marker(text) + assert fix is None + assert "[SUGGEST_FIX]" not in cleaned + + +# ── _persist_suggested_fix ────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_persist_supersedes_prior_active_and_bumps_state_version(test_db, test_user): + session = await _make_session(test_db, test_user) + initial_version = session.state_version + + # Insert an existing active fix so we can verify supersession. + existing = SessionSuggestedFix( + session_id=session.id, + account_id=session.account_id, + title="Old fix", + description="prior", + confidence_pct=60, + ) + test_db.add(existing) + await test_db.commit() + + await _persist_suggested_fix( + db=test_db, + session=session, + fix={ + "title": "New fix", + "description": "current best", + "confidence_pct": 88, + "script_template_slug": None, + "ai_drafted_script": None, + "ai_drafted_parameters": None, + }, + ) + await test_db.commit() + await test_db.refresh(existing) + await test_db.refresh(session) + + assert existing.superseded_at is not None + assert session.state_version == initial_version + 1 + + # Exactly one active row remains — and it's the new one. + result = await test_db.execute( + select(SessionSuggestedFix).where( + SessionSuggestedFix.session_id == session.id, + SessionSuggestedFix.superseded_at.is_(None), + ) + ) + actives = list(result.scalars().all()) + assert len(actives) == 1 + assert actives[0].title == "New fix" + + +# ── /suggested-fixes/active endpoint ──────────────────────────────────────── + +@pytest.mark.asyncio +async def test_get_active_returns_404_when_none(client: AsyncClient, test_user, auth_headers, test_db): + session = await _make_session(test_db, test_user) + r = await client.get( + f"/api/v1/ai-sessions/{session.id}/suggested-fixes/active", + headers=auth_headers, + ) + assert r.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_active_returns_active_fix(client: AsyncClient, test_user, auth_headers, test_db): + session = await _make_session(test_db, test_user) + fix = SessionSuggestedFix( + session_id=session.id, + account_id=session.account_id, + title="Active fix", + description="d", + confidence_pct=72, + ) + test_db.add(fix) + await test_db.commit() + + r = await client.get( + f"/api/v1/ai-sessions/{session.id}/suggested-fixes/active", + headers=auth_headers, + ) + assert r.status_code == 200 + body = r.json() + assert body["title"] == "Active fix" + assert body["confidence_pct"] == 72 + assert body["superseded_at"] is None + + +# ── /decision endpoint ───────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_record_decision_persists_and_bumps_state_version( + client: AsyncClient, test_user, auth_headers, test_db +): + session = await _make_session(test_db, test_user) + initial_version = session.state_version + fix = SessionSuggestedFix( + session_id=session.id, + account_id=session.account_id, + title="x", + description="y", + confidence_pct=50, + ) + test_db.add(fix) + await test_db.commit() + + r = await client.post( + f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision", + headers=auth_headers, + json={"decision": "draft_template"}, + ) + assert r.status_code == 200 + assert r.json()["user_decision"] == "draft_template" + + await test_db.refresh(session) + assert session.state_version == initial_version + 1 + + +@pytest.mark.asyncio +async def test_dismissed_supersedes_the_fix( + client: AsyncClient, test_user, auth_headers, test_db +): + session = await _make_session(test_db, test_user) + fix = SessionSuggestedFix( + session_id=session.id, + account_id=session.account_id, + title="x", + description="y", + confidence_pct=50, + ) + test_db.add(fix) + await test_db.commit() + + r = await client.post( + f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision", + headers=auth_headers, + json={"decision": "dismissed"}, + ) + assert r.status_code == 200 + await test_db.refresh(fix) + assert fix.superseded_at is not None + + +@pytest.mark.asyncio +async def test_dismiss_already_superseded_returns_409( + client: AsyncClient, test_user, auth_headers, test_db +): + session = await _make_session(test_db, test_user) + fix = SessionSuggestedFix( + session_id=session.id, + account_id=session.account_id, + title="x", + description="y", + confidence_pct=50, + superseded_at=datetime.now(timezone.utc), + ) + test_db.add(fix) + await test_db.commit() + + r = await client.post( + f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision", + headers=auth_headers, + json={"decision": "dismissed"}, + ) + assert r.status_code == 409 + + +# ── /resolution-note/preview endpoint ────────────────────────────────────── + +@pytest.mark.asyncio +async def test_preview_uses_state_version_cache( + client: AsyncClient, test_user, auth_headers, test_db +): + session = await _make_session(test_db, test_user) + + fake_provider = AsyncMock() + fake_provider.generate_text = AsyncMock(return_value=( + "## Problem\nx\n\n## What we confirmed\n(none)\n\n## Root cause\ny\n\n## Resolution\nz", + 100, 50, + )) + + with patch( + "app.services.resolution_note_generator.get_ai_provider", + return_value=fake_provider, + ): + # First call — cache miss, generates fresh. + r1 = await client.post( + f"/api/v1/ai-sessions/{session.id}/resolution-note/preview", + headers=auth_headers, + ) + assert r1.status_code == 200 + assert r1.json()["from_cache"] is False + assert fake_provider.generate_text.await_count == 1 + + # Second call, no state change — must hit the cache (no extra LLM call). + r2 = await client.post( + f"/api/v1/ai-sessions/{session.id}/resolution-note/preview", + headers=auth_headers, + ) + assert r2.status_code == 200 + assert r2.json()["from_cache"] is True + assert r2.json()["markdown"] == r1.json()["markdown"] + assert fake_provider.generate_text.await_count == 1 + + +@pytest.mark.asyncio +async def test_preview_invalidates_after_fact_write( + client: AsyncClient, test_user, auth_headers, test_db +): + """A new fact bumps state_version → next preview is a fresh generation, not cached.""" + session = await _make_session(test_db, test_user) + + fake_provider = AsyncMock() + fake_provider.generate_text = AsyncMock(return_value=( + "## Problem\nx\n\n## What we confirmed\n(none)\n\n## Root cause\ny\n\n## Resolution\nz", + 100, 50, + )) + + with patch( + "app.services.resolution_note_generator.get_ai_provider", + return_value=fake_provider, + ): + await client.post( + f"/api/v1/ai-sessions/{session.id}/resolution-note/preview", + headers=auth_headers, + ) + assert fake_provider.generate_text.await_count == 1 + + # Add a fact — bumps state_version on the session. + await client.post( + f"/api/v1/ai-sessions/{session.id}/facts", + headers=auth_headers, + json={"text": "a confirmed observation"}, + ) + + # Next preview must regenerate (cache key includes state_version). + r = await client.post( + f"/api/v1/ai-sessions/{session.id}/resolution-note/preview", + headers=auth_headers, + ) + assert r.status_code == 200 + assert r.json()["from_cache"] is False + assert fake_provider.generate_text.await_count == 2 diff --git a/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md b/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md index 3b9070f2..cb576a1e 100644 --- a/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md +++ b/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md @@ -2,8 +2,8 @@ > **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface. > **Audience:** Claude Code (implementation) and Codex (review) reviewed by Michael (owner). -> **Status:** Phases 0, 1, and 2 implemented (verification deferred to new dev env). Phase 3 next. -> **Last updated:** April 21, 2026 (Phase 2 — What we know — committed; verification TODO tracked inline in tests/services) +> **Status:** Phases 0–3 implemented and verified end-to-end against the dev stack. Phase 4 next. +> **Last updated:** April 22, 2026 (Phase 3 — Suggested fix + Resolve preview — committed; live Sonnet preview + state_version cache verified) --- @@ -739,6 +739,15 @@ git commit -m "feat(pilot): add What we know section with fact synthesis and sta - Preview contains no hallucinated information not present in session state (human review of 5 real-ish sessions). - Incrementing `state_version` invalidates the preview cache; reading the same version returns the cached markdown. +**Verified end-to-end** against the dev stack on 2026-04-22: +- `/suggested-fixes/active` → 404 when no fix; 200 with payload when one exists. +- Fact write bumps `state_version`; preview cache invalidates as expected. +- Sonnet generates well-formed four-section markdown grounded only in + provided facts (single-fact session correctly says "Root cause not + definitively isolated"). +- Second consecutive preview call with no state change returns + `from_cache=true` and emits no LLM call. + ``` git commit -m "feat(pilot): add suggested fix tracking and Resolve note preview with state_version caching" ``` diff --git a/frontend/src/api/sessionSuggestedFixes.ts b/frontend/src/api/sessionSuggestedFixes.ts new file mode 100644 index 00000000..f26523e6 --- /dev/null +++ b/frontend/src/api/sessionSuggestedFixes.ts @@ -0,0 +1,84 @@ +/** + * Session suggested-fix + resolution-note preview API (Phase 3). + * + * Mirrors backend endpoints under /api/v1/ai-sessions/{id}/... + * See FLOWPILOT-MIGRATION.md Sections 5.2 + 5.4. + */ +import apiClient from './client' + +export type UserDecision = 'one_off' | 'draft_template' | 'build_template' | 'dismissed' + +export interface SessionSuggestedFix { + id: string + session_id: string + title: string + description: string + confidence_pct: number + script_template_id: string | null + ai_drafted_script: string | null + ai_drafted_parameters: Record | null + user_decision: UserDecision | null + superseded_at: string | null + created_at: string +} + +export interface DecisionResponse { + id: string + user_decision: UserDecision + rendered_script: string | null + redirect_path: string | null +} + +export interface ResolutionNotePreview { + markdown: string + target_ticket_ref: string | null + state_version: number + from_cache: boolean +} + +export const sessionSuggestedFixesApi = { + /** + * Returns the active suggested fix for a session, or `null` if there isn't one. + * The endpoint returns 404 in the no-fix case, which is normal — we coerce + * to null so callers don't have to distinguish "no fix" from "request failed". + */ + async getActive(sessionId: string): Promise { + try { + const r = await apiClient.get( + `/ai-sessions/${sessionId}/suggested-fixes/active`, + ) + return r.data + } catch (err) { + const status = (err as { response?: { status?: number } })?.response?.status + if (status === 404) return null + throw err + } + }, + + async recordDecision( + sessionId: string, + fixId: string, + decision: UserDecision, + ): Promise { + const r = await apiClient.post( + `/ai-sessions/${sessionId}/suggested-fixes/${fixId}/decision`, + { decision }, + ) + return r.data + }, + + /** + * Fetch (or get cached) draft markdown for the Resolve note. Backend cache + * is keyed on state_version, so calling this back-to-back without intervening + * fact / suggested-fix / script-generation writes returns the same payload + * cheaply (no Sonnet call). + */ + async getResolutionNotePreview(sessionId: string): Promise { + const r = await apiClient.post( + `/ai-sessions/${sessionId}/resolution-note/preview`, + ) + return r.data + }, +} + +export default sessionSuggestedFixesApi diff --git a/frontend/src/components/assistant/TaskLane.tsx b/frontend/src/components/assistant/TaskLane.tsx index ae4425bc..1c700009 100644 --- a/frontend/src/components/assistant/TaskLane.tsx +++ b/frontend/src/components/assistant/TaskLane.tsx @@ -43,6 +43,12 @@ interface TaskLaneProps { // shape lets the parent own fact-fetching and state-version polling without // pulling that concern into TaskLane. whatWeKnowSlot?: React.ReactNode + // Phase 3: Suggested fix card, rendered below Diagnostic Checks. + suggestedFixSlot?: React.ReactNode + // Phase 3: bottom-of-lane slot for the Resolve action bar + preview popover + // (parent owns state). Renders inside the scrollable body so the popover + // stays anchored as the lane scrolls. + bottomSlot?: React.ReactNode } // ── Storage helpers ── @@ -69,7 +75,7 @@ export function clearTaskState(sessionId: string) { // ── Component ── -export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading, whatWeKnowSlot }: TaskLaneProps) { +export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading, whatWeKnowSlot, suggestedFixSlot, bottomSlot }: TaskLaneProps) { const [tasks, setTasks] = useState(() => { // Try to restore saved state for this session (preserves user's in-progress answers) if (sessionId) { @@ -503,6 +509,12 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa })} )} + + {/* ── Suggested fix (Phase 3) ── */} + {suggestedFixSlot} + + {/* ── Resolve action bar + preview popover (Phase 3) ── */} + {bottomSlot} {/* Footer */} diff --git a/frontend/src/components/pilot/ResolutionNotePreview.tsx b/frontend/src/components/pilot/ResolutionNotePreview.tsx new file mode 100644 index 00000000..bab0c2fc --- /dev/null +++ b/frontend/src/components/pilot/ResolutionNotePreview.tsx @@ -0,0 +1,107 @@ +/** + * ResolutionNotePreview — Phase 3 popover anchored to the Resolve action area. + * + * Persistent (not modal) popover showing the four-section draft markdown that + * would be posted to the customer ticket on Resolve. Per FLOWPILOT-MIGRATION.md + * Section 3.1, the engineer reviews/edits the draft inline and Confirm & post + * fires the PSA writeback (wired in Phase 4 — for now this is read-only). + * + * Refresh policy: parent triggers `onRefresh` when state_version changes. + * Backend caches by state_version, so repeat fetches are cheap (no Sonnet + * call) when no facts/fixes/scripts have changed. + */ +import { useState } from 'react' +import { Loader2, RefreshCw, X, FileText } from 'lucide-react' +import { MarkdownContent } from '@/components/ui/MarkdownContent' +import type { ResolutionNotePreview as PreviewData } from '@/api/sessionSuggestedFixes' + +interface ResolutionNotePreviewProps { + open: boolean + loading: boolean + preview: PreviewData | null + error: string | null + onClose: () => void + onRefresh: () => Promise | void +} + +export function ResolutionNotePreview({ + open, + loading, + preview, + error, + onClose, + onRefresh, +}: ResolutionNotePreviewProps) { + const [refreshing, setRefreshing] = useState(false) + + if (!open) return null + + const handleRefresh = async () => { + setRefreshing(true) + try { await onRefresh() } finally { setRefreshing(false) } + } + + return ( + // The popover is positioned absolutely against its anchor by the parent. + // We render full-width inside the task lane below the Resolve action bar. +
+
+
+ + + Resolution note preview + + {preview?.target_ticket_ref && ( + + → {preview.target_ticket_ref} + + )} + {preview?.from_cache && ( + cached + )} +
+
+ + +
+
+ +
+ {loading && !preview && ( +
+ + Drafting note from session state... +
+ )} + {error && ( +
{error}
+ )} + {preview && ( +
+ +
+ )} + {!loading && !error && !preview && ( +
+ No preview yet — add a fact or accept a suggested fix to populate. +
+ )} +
+
+ ) +} + +export default ResolutionNotePreview diff --git a/frontend/src/components/pilot/sections/SuggestedFix.tsx b/frontend/src/components/pilot/sections/SuggestedFix.tsx new file mode 100644 index 00000000..0424ff3c --- /dev/null +++ b/frontend/src/components/pilot/sections/SuggestedFix.tsx @@ -0,0 +1,82 @@ +/** + * SuggestedFix card — Phase 3 task-lane section. + * + * Renders the active AI-proposed resolution path for the session + * (per FLOWPILOT-MIGRATION.md Section 3.1, "Suggested fix"). Amber-accented + * to match the mockup; clicking opens the Script Generator flow in Phase 5. + * + * For Phase 3, the card is informational + a Dismiss action. The three-option + * dialog (one_off / draft_template / build_template) is wired in Phase 5 + * via a separate component. + */ +import { useState } from 'react' +import { Sparkles, X } from 'lucide-react' +import type { SessionSuggestedFix } from '@/api/sessionSuggestedFixes' + +interface SuggestedFixProps { + fix: SessionSuggestedFix + onDismiss: () => Promise | void +} + +function confidenceBucket(pct: number): { label: string; tone: string } { + if (pct >= 80) return { label: 'high', tone: 'text-success' } + if (pct >= 50) return { label: 'medium', tone: 'text-warning' } + return { label: 'low', tone: 'text-muted-foreground' } +} + +export function SuggestedFix({ fix, onDismiss }: SuggestedFixProps) { + const [busy, setBusy] = useState(false) + const conf = confidenceBucket(fix.confidence_pct) + + const handleDismiss = async () => { + setBusy(true) + try { await onDismiss() } finally { setBusy(false) } + } + + return ( +
+
+
+ + Suggested fix + · + {fix.confidence_pct}% confidence +
+
+ +
+
+ +
+
+ {fix.title} +
+
+ {fix.description} +
+ {fix.script_template_id && ( +
+ ✓ Matches an existing Script Library template +
+ )} + {!fix.script_template_id && fix.ai_drafted_script && ( +
+ Custom script drafted (no template match) +
+ )} +
+ +
+
+
+ ) +} + +export default SuggestedFix diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 42a2929a..c709e39c 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -14,7 +14,14 @@ import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/Cha import { ChatMessage } from '@/components/assistant/ChatMessage' import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane' import { WhatWeKnow } from '@/components/pilot/sections/WhatWeKnow' +import { SuggestedFix } from '@/components/pilot/sections/SuggestedFix' +import { ResolutionNotePreview as ResolutionNotePreviewPopover } from '@/components/pilot/ResolutionNotePreview' import { sessionFactsApi, type SessionFact } from '@/api/sessionFacts' +import { + sessionSuggestedFixesApi, + type SessionSuggestedFix, + type ResolutionNotePreview as ResolutionNotePreviewData, +} from '@/api/sessionSuggestedFixes' import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal' import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal' import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat' @@ -80,6 +87,16 @@ export default function AssistantChatPage() { // selectChat and after each chat send (the AI may have emitted [PROMOTE] // markers that synthesized new facts server-side). const [facts, setFacts] = useState([]) + // Phase 3: active suggested fix + resolution-note preview state. + const [activeFix, setActiveFix] = useState(null) + const [previewOpen, setPreviewOpen] = useState(false) + const [previewData, setPreviewData] = useState(null) + const [previewLoading, setPreviewLoading] = useState(false) + const [previewError, setPreviewError] = useState(null) + // Debounce timer for preview refresh — Phase 3 spec calls for 500ms client- + // side debounce so rapid edits don't fan out to the LLM (cache absorbs the + // dups, but the request itself still costs HTTP RTT). + const previewDebounceRef = useRef | null>(null) const [showOverflow, setShowOverflow] = useState(false) const toggleSidebarCollapse = () => { const next = !sidebarCollapsed @@ -184,8 +201,8 @@ export default function AssistantChatPage() { setActiveActions(response.actions || []) setShowTaskLane(true) } - // Refetch facts — the AI may have emitted [PROMOTE] markers. - refreshFacts(session.session_id) + // Refetch facts + active fix — the AI may have emitted markers. + refreshSessionDerived(session.session_id) } catch { toast.error('Failed to start AI conversation') } finally { @@ -250,11 +267,20 @@ export default function AssistantChatPage() { } }, []) + // Phase 3: convenience helper — refresh fact list, active fix, and (if open) + // schedule a preview refresh. Called after every chat send so the new state + // (PROMOTE-synthesized facts, new SUGGEST_FIX) appears in the lane. + const refreshSessionDerived = useCallback(async (chatId: string) => { + await Promise.all([refreshFacts(chatId), refreshActiveFix(chatId)]) + if (previewOpen) schedulePreviewRefresh(chatId) + }, [refreshFacts, refreshActiveFix, previewOpen, schedulePreviewRefresh]) + const handleAddNote = async (text: string, summary: string | null) => { if (!activeChatId) return try { const fact = await sessionFactsApi.create(activeChatId, { text, summary }) setFacts(prev => [...prev, fact]) + schedulePreviewRefresh(activeChatId) } catch { toast.error('Failed to add note') } @@ -265,6 +291,7 @@ export default function AssistantChatPage() { try { const updated = await sessionFactsApi.update(activeChatId, factId, { text, summary }) setFacts(prev => prev.map(f => f.id === factId ? updated : f)) + schedulePreviewRefresh(activeChatId) } catch { toast.error('Failed to update fact') } @@ -275,11 +302,77 @@ export default function AssistantChatPage() { try { await sessionFactsApi.remove(activeChatId, factId) setFacts(prev => prev.filter(f => f.id !== factId)) + schedulePreviewRefresh(activeChatId) } catch { toast.error('Failed to remove fact') } } + // Phase 3 — active suggested fix + resolution-note preview. + const refreshActiveFix = useCallback(async (chatId: string) => { + try { + const fix = await sessionSuggestedFixesApi.getActive(chatId) + if (currentChatRef.current !== chatId) return + setActiveFix(fix) + } catch { + // No-fix-yet (404) is normalized to null inside the client. Genuine + // failures stay silent — accessory state, not load-bearing. + } + }, []) + + const refreshPreview = useCallback(async (chatId: string) => { + setPreviewLoading(true) + setPreviewError(null) + try { + const p = await sessionSuggestedFixesApi.getResolutionNotePreview(chatId) + if (currentChatRef.current !== chatId) return + setPreviewData(p) + } catch (err: unknown) { + const status = (err as { response?: { status?: number } })?.response?.status + setPreviewError( + status === 502 + ? 'AI provider error drafting the note. Try again in a few seconds.' + : 'Could not load preview.', + ) + } finally { + setPreviewLoading(false) + } + }, []) + + // Trigger preview refresh with a 500ms debounce. The backend cache short- + // circuits same-state calls, but the network round-trip is still avoidable + // when the user is typing quickly (e.g. editing a fact). + const schedulePreviewRefresh = useCallback((chatId: string) => { + if (previewDebounceRef.current) clearTimeout(previewDebounceRef.current) + previewDebounceRef.current = setTimeout(() => { + if (previewOpen && currentChatRef.current === chatId) { + refreshPreview(chatId) + } + }, 500) + }, [previewOpen, refreshPreview]) + + const handleDismissFix = async () => { + if (!activeChatId || !activeFix) return + try { + await sessionSuggestedFixesApi.recordDecision(activeChatId, activeFix.id, 'dismissed') + setActiveFix(null) + // Dismissal bumps state_version on the server; reflect in preview. + schedulePreviewRefresh(activeChatId) + } catch { + toast.error('Failed to dismiss suggestion') + } + } + + const handleTogglePreview = () => { + if (!activeChatId) return + const next = !previewOpen + setPreviewOpen(next) + if (next && !previewData) { + // First open — fetch immediately, no debounce. + refreshPreview(activeChatId) + } + } + const selectChat = useCallback(async (chatId: string) => { currentChatRef.current = chatId setActiveChatId(chatId) @@ -290,8 +383,12 @@ export default function AssistantChatPage() { setActiveSessionStatus(null) setActivePsaTicketId(null) setFacts([]) - // Fire facts fetch in parallel with session detail. - refreshFacts(chatId) + setActiveFix(null) + setPreviewData(null) + setPreviewError(null) + setPreviewOpen(false) + // Fire facts + active-fix fetches in parallel with session detail. + refreshSessionDerived(chatId) try { const detail = await aiSessionsApi.getSession(chatId) // Guard: if the user switched to a different chat while this API call was @@ -327,7 +424,7 @@ export default function AssistantChatPage() { } catch { setMessages([]) } - }, [refreshFacts]) + }, [refreshSessionDerived]) const handleNewChat = async () => { // Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit @@ -428,8 +525,8 @@ export default function AssistantChatPage() { setActiveActions(response.actions || []) setShowTaskLane(true) } - // Refetch facts — [PROMOTE] markers may have synthesized new ones. - refreshFacts(sentForChatId) + // Refetch facts + active fix; preview refreshes if open. + refreshSessionDerived(sentForChatId) } catch (err: unknown) { console.error('[AssistantChat] sendChatMessage failed:', err) const status = (err as { response?: { status?: number } })?.response?.status @@ -498,8 +595,8 @@ export default function AssistantChatPage() { setActiveQuestions([]) setActiveActions([]) } - // Refetch facts — answering tasks is the primary [PROMOTE] trigger. - refreshFacts(sentForChatId) + // Refetch facts + active fix; answering tasks is the primary trigger. + refreshSessionDerived(sentForChatId) } catch (err: unknown) { console.error('[AssistantChat] handleTaskSubmit failed:', err) const status = (err as { response?: { status?: number } })?.response?.status @@ -589,8 +686,8 @@ export default function AssistantChatPage() { setActiveActions(response.actions || []) setShowTaskLane(true) } - // Refetch facts — the resume turn may emit [PROMOTE] markers. - refreshFacts(session.session_id) + // Refetch facts + active fix — resume turn may emit markers. + refreshSessionDerived(session.session_id) } catch { toast.error('Failed to create resume chat') } finally { @@ -1080,10 +1177,10 @@ export default function AssistantChatPage() { {/* Task lane — slides in when AI sends questions/actions OR when the - session has any "What we know" facts. Phase 2 makes the lane the - structural home of session diagnostic state, not a transient - questions panel. */} - {showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0) && ( + session has any "What we know" facts OR an active suggested fix. + Phase 2/3 make the lane the structural home of session diagnostic + state, not a transient questions panel. */} + {showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && ( } + suggestedFixSlot={ + activeFix && ( + + ) + } + bottomSlot={ + <> + + setPreviewOpen(false)} + onRefresh={() => activeChatId && refreshPreview(activeChatId)} + /> + + } /> )}