feat(pilot): Phase 3 — Suggested fix tracking + Resolve preview with state_version cache
Adds the AI-proposed resolution path and the inline preview of the
markdown that will be posted to the customer ticket on Resolve. The
preview is keyed on (session_id, ai_sessions.state_version) so back-to-
back fetches against unchanged state hit an in-process cache instead
of paying for a Sonnet call.
Backend:
- preview_cache: in-process LRU keyed on (kind, session_id, state_version).
No TTL — state_version is the source of truth. Soft-cap 5000 entries.
- unified_chat_service: [SUGGEST_FIX] parser (last-block-wins, JSON
payload, confidence clamped 0-100), supersession persistence (sets
superseded_at on prior active row), atomic state_version bump.
- ResolutionNoteGeneratorService: pulls session, facts, active fix, and
redacted script_generations into a structured input bundle for Sonnet;
produces the four-section markdown (Problem / What we confirmed /
Root cause / Resolution). Sensitive script parameters redacted via
ScriptTemplateEngine.redact_sensitive driven by the template's
parameters_schema.
- /api/v1/ai-sessions/{id}/suggested-fixes/active — 200 with the active
fix or 404.
- /api/v1/ai-sessions/{id}/suggested-fixes/{fix_id}/decision — records
one_off / draft_template / build_template / dismissed; dismiss
supersedes; bumps state_version. 409 on dismissing an already-
superseded fix.
- /api/v1/ai-sessions/{id}/resolution-note/preview — generates or returns
cached markdown; from_cache flag in payload signals cache hit.
- scripts.py POST /generate now bumps state_version on the linked
ai_session_id when present (third source of preview-cache invalidation
per Section 5.5).
- ASSISTANT_SYSTEM_PROMPT documents [SUGGEST_FIX] (when to/not to emit,
format, supersession semantics).
- 12 tests covering the parser (well-formed, last-wins, malformed,
confidence clamping), supersession + state_version invariant, all
decision branches, preview cache hit-on-no-change + miss-after-write.
Frontend:
- src/components/pilot/sections/SuggestedFix.tsx — amber-accented card
with confidence badge; dismiss action wired to the decision endpoint.
- src/components/pilot/ResolutionNotePreview.tsx — popover with refresh,
loading state, cached/fresh indicator, ticket-ref display.
- src/api/sessionSuggestedFixes.ts — typed client; getActive normalizes
404 to null so callers don't have to special-case.
- TaskLane gains suggestedFixSlot + bottomSlot props (rendered after
Diagnostic Checks; bottomSlot anchors the Resolve action).
- AssistantChatPage: refreshSessionDerived helper batches fact + fix
refresh; fact mutations and chat sends both schedule a 500ms-debounced
preview refresh per the Section 5.5 spec.
Verified end-to-end against the dev stack with a real Sonnet call:
- /active 404 → fact create → preview generates four-section markdown
grounded only in provided facts → second preview call hits cache
(from_cache=true, no LLM call) → fact write 2 → cache miss, regenerates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ import re
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.core.database import get_db
|
||||||
from app.api.deps import get_current_active_user
|
from app.api.deps import get_current_active_user
|
||||||
@@ -374,6 +374,20 @@ async def generate_script(
|
|||||||
)
|
)
|
||||||
db.add(generation)
|
db.add(generation)
|
||||||
template.usage_count += 1
|
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.commit()
|
||||||
await db.refresh(generation)
|
await db.refresh(generation)
|
||||||
|
|
||||||
|
|||||||
183
backend/app/api/endpoints/session_suggested_fixes.py
Normal file
183
backend/app/api/endpoints/session_suggested_fixes.py
Normal file
@@ -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
|
||||||
@@ -44,6 +44,7 @@ from app.api.endpoints import (
|
|||||||
session_facts,
|
session_facts,
|
||||||
session_handoffs,
|
session_handoffs,
|
||||||
session_resolutions,
|
session_resolutions,
|
||||||
|
session_suggested_fixes,
|
||||||
sessions,
|
sessions,
|
||||||
shared,
|
shared,
|
||||||
shares,
|
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
|
# 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.
|
# 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_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(ai_sessions.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(flow_proposals.router, dependencies=_tenant_deps)
|
api_router.include_router(flow_proposals.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps)
|
api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps)
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ class Settings(BaseSettings):
|
|||||||
# Doc Section 6.6 sets Haiku as the default; instrumentation tracks
|
# Doc Section 6.6 sets Haiku as the default; instrumentation tracks
|
||||||
# disputed_fact_rate so we can escalate to Sonnet if quality drops.
|
# disputed_fact_rate so we can escalate to Sonnet if quality drops.
|
||||||
"fact_synthesis": "fast",
|
"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:
|
def get_model_for_action(self, action_type: str) -> str:
|
||||||
|
|||||||
63
backend/app/schemas/session_suggested_fix.py
Normal file
63
backend/app/schemas/session_suggested_fix.py
Normal file
@@ -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
|
||||||
@@ -159,6 +159,44 @@ words, no period.
|
|||||||
the engineer's message confirms the fact, do not emit a [PROMOTE]. Hallucinated \
|
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.
|
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
|
## Using the Team's Flow Library
|
||||||
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
|
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 \
|
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 \
|
[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 \
|
when the engineer's most recent message confirmed something worth recording, and copy \
|
||||||
the originating item's `id` into `source_ref` verbatim.
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
52
backend/app/services/preview_cache.py
Normal file
52
backend/app/services/preview_cache.py
Normal file
@@ -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()
|
||||||
320
backend/app/services/resolution_note_generator.py
Normal file
320
backend/app/services/resolution_note_generator.py
Normal file
@@ -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
|
||||||
|
<one short paragraph stating the issue the engineer worked on, derived from the \
|
||||||
|
session's intake/title and the incident header. Past tense. No "user reported" \
|
||||||
|
hedging — state the problem directly.>
|
||||||
|
|
||||||
|
## What we confirmed
|
||||||
|
<bulleted list of facts from the "What we know" section, each one a short line. \
|
||||||
|
Group similar facts together; do not invent connecting prose. If there are no \
|
||||||
|
facts, write "Nothing was confirmed." and skip to Root cause.>
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
<one short paragraph naming the root cause based on the active suggested fix \
|
||||||
|
and confirmed facts. If the suggested fix is low-confidence (<60%) or absent, \
|
||||||
|
say "Root cause not definitively isolated." and explain what is suspected based \
|
||||||
|
on facts.>
|
||||||
|
|
||||||
|
## Resolution
|
||||||
|
<one short paragraph describing the resolution applied. If a script ran during \
|
||||||
|
the session, mention it (e.g. "Cleared cached credentials via the \
|
||||||
|
clear-outlook-credentials script."). If no resolution has been performed yet, \
|
||||||
|
write "Resolution not yet applied — fix proposed: <fix title>." Pull verbatim \
|
||||||
|
script names and template references when available.>
|
||||||
|
|
||||||
|
Strict rules:
|
||||||
|
- Use ONLY the facts and state I provide. Never invent specifics that are not \
|
||||||
|
in the input.
|
||||||
|
- Do not include placeholder text like "TBD", "TODO", or empty bullets.
|
||||||
|
- Do not include the engineer's name, the AI's name, internal session IDs, or \
|
||||||
|
the session's chat transcript.
|
||||||
|
- Markdown headings exactly as shown (## level), no bolding the headings.
|
||||||
|
- No trailing whitespace, no double-blank lines, no horizontal rules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ResolutionNoteGeneratorService:
|
||||||
|
"""Generates and caches the four-section Resolve note markdown."""
|
||||||
|
|
||||||
|
KIND = "resolution_note"
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def generate_or_get_cached(
|
||||||
|
self, session_id: UUID, *, force: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return the preview for the session.
|
||||||
|
|
||||||
|
Reads `(KIND, session_id, state_version)` from the in-process cache;
|
||||||
|
on miss, generates fresh markdown and stores under the same key.
|
||||||
|
`force=True` bypasses the cache and refreshes the cached entry.
|
||||||
|
|
||||||
|
Returns `{"markdown": str, "target_ticket_ref": str | None,
|
||||||
|
"state_version": int, "from_cache": bool}`.
|
||||||
|
"""
|
||||||
|
session = await self._load_session(session_id)
|
||||||
|
cached = preview_cache.get(self.KIND, session.id, session.state_version) if not force else None
|
||||||
|
if cached is not None:
|
||||||
|
return {**cached, "from_cache": True}
|
||||||
|
|
||||||
|
markdown = await self._render(session)
|
||||||
|
target = self._target_ticket_ref(session)
|
||||||
|
payload = {
|
||||||
|
"markdown": markdown,
|
||||||
|
"target_ticket_ref": target,
|
||||||
|
"state_version": session.state_version,
|
||||||
|
}
|
||||||
|
preview_cache.set(self.KIND, session.id, session.state_version, payload)
|
||||||
|
return {**payload, "from_cache": False}
|
||||||
|
|
||||||
|
# ── Internals ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _load_session(self, session_id: UUID) -> AISession:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(AISession).where(AISession.id == session_id)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
if session is None:
|
||||||
|
raise ValueError(f"Session {session_id} not found")
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def _render(self, session: AISession) -> str:
|
||||||
|
"""Build the prompt input bundle, call the model, return markdown."""
|
||||||
|
facts = await self._load_facts(session.id)
|
||||||
|
active_fix = await self._load_active_fix(session.id)
|
||||||
|
gens = await self._load_redacted_generations(session.id)
|
||||||
|
|
||||||
|
bundle = self._build_input_bundle(session, facts, active_fix, gens)
|
||||||
|
|
||||||
|
model = settings.get_model_for_action("resolution_note")
|
||||||
|
provider = get_ai_provider(model=model)
|
||||||
|
|
||||||
|
# Cache the system prompt — identical across every preview call for
|
||||||
|
# every session. Per-session bundle is in the user message, uncached.
|
||||||
|
system_blocks: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": _RESOLUTION_NOTE_SYSTEM_PROMPT,
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
# cacheable: identical across every resolution-note preview call
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
text, _in, _out = await provider.generate_text(
|
||||||
|
system_prompt=system_blocks,
|
||||||
|
messages=[{"role": "user", "content": bundle}],
|
||||||
|
max_tokens=1200,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Resolution note generation failed for session %s", session.id)
|
||||||
|
raise
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
async def _load_facts(self, session_id: UUID) -> list[SessionFact]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SessionFact)
|
||||||
|
.where(
|
||||||
|
SessionFact.session_id == session_id,
|
||||||
|
SessionFact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(SessionFact.created_at.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def _load_active_fix(self, session_id: UUID) -> SessionSuggestedFix | None:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SessionSuggestedFix)
|
||||||
|
.where(
|
||||||
|
SessionSuggestedFix.session_id == session_id,
|
||||||
|
SessionSuggestedFix.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(SessionSuggestedFix.created_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
async def _load_redacted_generations(
|
||||||
|
self, session_id: UUID
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Pull script_generations for the session, redacting password params.
|
||||||
|
|
||||||
|
Password fields are inferred from the linked template's
|
||||||
|
`parameters_schema` (`field_type: "password"`). The existing
|
||||||
|
ScriptTemplateEngine.redact_sensitive handles the substitution.
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(ScriptGeneration)
|
||||||
|
.where(ScriptGeneration.ai_session_id == session_id)
|
||||||
|
.order_by(ScriptGeneration.created_at.asc())
|
||||||
|
)
|
||||||
|
gens = list(result.scalars().all())
|
||||||
|
if not gens:
|
||||||
|
return []
|
||||||
|
|
||||||
|
template_ids = {g.template_id for g in gens}
|
||||||
|
tpl_result = await self.db.execute(
|
||||||
|
select(ScriptTemplate).where(ScriptTemplate.id.in_(template_ids))
|
||||||
|
)
|
||||||
|
templates_by_id = {t.id: t for t in tpl_result.scalars().all()}
|
||||||
|
|
||||||
|
engine = ScriptTemplateEngine()
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for g in gens:
|
||||||
|
tpl = templates_by_id.get(g.template_id)
|
||||||
|
sensitive_keys = self._sensitive_keys_from_schema(
|
||||||
|
(tpl.parameters_schema if tpl else {}) or {}
|
||||||
|
)
|
||||||
|
redacted_params = engine.redact_sensitive(g.parameters_used or {}, sensitive_keys)
|
||||||
|
out.append({
|
||||||
|
"template_name": tpl.name if tpl else "(unknown template)",
|
||||||
|
"template_slug": tpl.slug if tpl else None,
|
||||||
|
"parameters_used": redacted_params,
|
||||||
|
"created_at": g.created_at.isoformat(),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sensitive_keys_from_schema(schema: dict[str, Any]) -> set[str]:
|
||||||
|
"""Extract password-typed parameter keys from a template's schema.
|
||||||
|
|
||||||
|
The schema shape is `{"parameters": [{"key": "...", "field_type": "password", ...}]}`
|
||||||
|
per the existing Script Generator convention. Tolerate both that shape
|
||||||
|
and the simpler `{"key": {"field_type": "password"}}` form.
|
||||||
|
"""
|
||||||
|
keys: set[str] = set()
|
||||||
|
params = schema.get("parameters") if isinstance(schema, dict) else None
|
||||||
|
if isinstance(params, list):
|
||||||
|
for p in params:
|
||||||
|
if isinstance(p, dict) and p.get("field_type") == "password":
|
||||||
|
k = p.get("key") or p.get("variable_name")
|
||||||
|
if isinstance(k, str):
|
||||||
|
keys.add(k)
|
||||||
|
elif isinstance(schema, dict):
|
||||||
|
for k, v in schema.items():
|
||||||
|
if isinstance(v, dict) and v.get("field_type") == "password":
|
||||||
|
keys.add(k)
|
||||||
|
return keys
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _target_ticket_ref(session: AISession) -> str | None:
|
||||||
|
"""Display ref for the linked PSA ticket, e.g. 'CW #48291'.
|
||||||
|
|
||||||
|
ConnectWise is the only PSA wired today (per the Phase 1 constraint),
|
||||||
|
so a CW prefix is reasonable. Other PSAs will need provider-aware
|
||||||
|
formatting in Phase 4.
|
||||||
|
"""
|
||||||
|
if not session.psa_ticket_id:
|
||||||
|
return None
|
||||||
|
return f"CW #{session.psa_ticket_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_input_bundle(
|
||||||
|
session: AISession,
|
||||||
|
facts: list[SessionFact],
|
||||||
|
active_fix: SessionSuggestedFix | None,
|
||||||
|
generations: list[dict[str, Any]],
|
||||||
|
) -> str:
|
||||||
|
"""Compose the structured input the LLM sees for one preview call."""
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append("# Session context")
|
||||||
|
lines.append(f"Title: {session.title or '(untitled)'}")
|
||||||
|
if session.problem_summary:
|
||||||
|
lines.append(f"Problem summary: {session.problem_summary}")
|
||||||
|
if session.problem_domain:
|
||||||
|
lines.append(f"Domain: {session.problem_domain}")
|
||||||
|
intake_text = (session.intake_content or {}).get("text") if isinstance(session.intake_content, dict) else None
|
||||||
|
if intake_text:
|
||||||
|
lines.append(f"Intake message: {intake_text}")
|
||||||
|
if session.psa_ticket_id:
|
||||||
|
lines.append(f"Linked PSA ticket: CW #{session.psa_ticket_id}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("# Confirmed facts (What we know)")
|
||||||
|
if not facts:
|
||||||
|
lines.append("(none)")
|
||||||
|
else:
|
||||||
|
for f in facts:
|
||||||
|
tag = f.source_type
|
||||||
|
summary = f" — {f.source_summary}" if f.source_summary else ""
|
||||||
|
lines.append(f"- [{tag}] {f.text}{summary}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("# Active suggested fix")
|
||||||
|
if active_fix is None:
|
||||||
|
lines.append("(no active suggested fix)")
|
||||||
|
else:
|
||||||
|
lines.append(f"Title: {active_fix.title}")
|
||||||
|
lines.append(f"Confidence: {active_fix.confidence_pct}%")
|
||||||
|
lines.append(f"Description: {active_fix.description}")
|
||||||
|
if active_fix.user_decision:
|
||||||
|
lines.append(f"Engineer decision: {active_fix.user_decision}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
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)
|
||||||
@@ -11,6 +11,9 @@ infrastructure and system prompt from assistant_chat_service.
|
|||||||
Items in pending_task_lane carry stable UUIDs (assigned here) so PROMOTE
|
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
|
source_refs survive across turns even when the model re-emits the same
|
||||||
question/action.
|
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 json
|
||||||
import logging
|
import logging
|
||||||
@@ -22,7 +25,13 @@ from uuid import UUID
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.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 (
|
from app.services.assistant_chat_service import (
|
||||||
ASSISTANT_SYSTEM_PROMPT,
|
ASSISTANT_SYSTEM_PROMPT,
|
||||||
_call_ai,
|
_call_ai,
|
||||||
@@ -287,6 +296,125 @@ def _assign_stable_task_lane_ids(
|
|||||||
return out_questions, out_actions
|
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(
|
async def _persist_promote_items(
|
||||||
*,
|
*,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@@ -431,11 +559,13 @@ async def send_chat_message(
|
|||||||
if session.status == "paused":
|
if session.status == "paused":
|
||||||
session.status = "active"
|
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_fork_data = _parse_fork_marker(ai_content)
|
||||||
branch_display, branch_actions_data = _parse_actions_marker(branch_display)
|
branch_display, branch_actions_data = _parse_actions_marker(branch_display)
|
||||||
branch_display, branch_questions_data = _parse_questions_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_promote_items = _parse_promote_marker(branch_display)
|
||||||
|
branch_display, branch_suggest_fix = _parse_suggest_fix_marker(branch_display)
|
||||||
if branch_display != ai_content:
|
if branch_display != ai_content:
|
||||||
# Store stripped content in branch history
|
# Store stripped content in branch history
|
||||||
msgs[-1] = {"role": "assistant", "content": branch_display}
|
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,
|
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(
|
suggested_flows = extract_suggested_flows(
|
||||||
await rag_search(query=message, account_id=account_id, db=db, limit=8)
|
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.
|
# Check for promote markers — facts the AI is surfacing to What we know.
|
||||||
display_content, promote_items = _parse_promote_marker(display_content)
|
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(
|
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),
|
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),
|
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,
|
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)
|
suggested_flows = extract_suggested_flows(rag_results)
|
||||||
|
|
||||||
return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data
|
return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data
|
||||||
|
|||||||
356
backend/tests/test_session_suggested_fixes_api.py
Normal file
356
backend/tests/test_session_suggested_fixes_api.py
Normal file
@@ -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
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
> **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface.
|
> **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface.
|
||||||
> **Audience:** Claude Code (implementation) and Codex (review) reviewed by Michael (owner).
|
> **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.
|
> **Status:** Phases 0–3 implemented and verified end-to-end against the dev stack. Phase 4 next.
|
||||||
> **Last updated:** April 21, 2026 (Phase 2 — What we know — committed; verification TODO tracked inline in tests/services)
|
> **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).
|
- 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.
|
- 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"
|
git commit -m "feat(pilot): add suggested fix tracking and Resolve note preview with state_version caching"
|
||||||
```
|
```
|
||||||
|
|||||||
84
frontend/src/api/sessionSuggestedFixes.ts
Normal file
84
frontend/src/api/sessionSuggestedFixes.ts
Normal file
@@ -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<string, unknown> | 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<SessionSuggestedFix | null> {
|
||||||
|
try {
|
||||||
|
const r = await apiClient.get<SessionSuggestedFix>(
|
||||||
|
`/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<DecisionResponse> {
|
||||||
|
const r = await apiClient.post<DecisionResponse>(
|
||||||
|
`/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<ResolutionNotePreview> {
|
||||||
|
const r = await apiClient.post<ResolutionNotePreview>(
|
||||||
|
`/ai-sessions/${sessionId}/resolution-note/preview`,
|
||||||
|
)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default sessionSuggestedFixesApi
|
||||||
@@ -43,6 +43,12 @@ interface TaskLaneProps {
|
|||||||
// shape lets the parent own fact-fetching and state-version polling without
|
// shape lets the parent own fact-fetching and state-version polling without
|
||||||
// pulling that concern into TaskLane.
|
// pulling that concern into TaskLane.
|
||||||
whatWeKnowSlot?: React.ReactNode
|
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 ──
|
// ── Storage helpers ──
|
||||||
@@ -69,7 +75,7 @@ export function clearTaskState(sessionId: string) {
|
|||||||
|
|
||||||
// ── Component ──
|
// ── 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<TaskResponse[]>(() => {
|
const [tasks, setTasks] = useState<TaskResponse[]>(() => {
|
||||||
// Try to restore saved state for this session (preserves user's in-progress answers)
|
// Try to restore saved state for this session (preserves user's in-progress answers)
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
@@ -503,6 +509,12 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Suggested fix (Phase 3) ── */}
|
||||||
|
{suggestedFixSlot}
|
||||||
|
|
||||||
|
{/* ── Resolve action bar + preview popover (Phase 3) ── */}
|
||||||
|
{bottomSlot}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
107
frontend/src/components/pilot/ResolutionNotePreview.tsx
Normal file
107
frontend/src/components/pilot/ResolutionNotePreview.tsx
Normal file
@@ -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> | 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.
|
||||||
|
<div className="rounded-lg border border-default bg-elevated/30 mx-3 mb-3 overflow-hidden shadow-lg">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-default bg-bg-page">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText size={13} className="text-accent" />
|
||||||
|
<span className="text-[0.75rem] font-semibold text-heading">
|
||||||
|
Resolution note preview
|
||||||
|
</span>
|
||||||
|
{preview?.target_ticket_ref && (
|
||||||
|
<span className="text-[0.6875rem] font-mono text-accent-text">
|
||||||
|
→ {preview.target_ticket_ref}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{preview?.from_cache && (
|
||||||
|
<span className="text-[0.6875rem] text-muted-foreground italic">cached</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing || loading}
|
||||||
|
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors disabled:opacity-40"
|
||||||
|
title="Refresh preview"
|
||||||
|
>
|
||||||
|
{refreshing ? <Loader2 size={11} className="animate-spin" /> : <RefreshCw size={11} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors"
|
||||||
|
title="Close preview"
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-3 py-3 max-h-[40vh] overflow-y-auto">
|
||||||
|
{loading && !preview && (
|
||||||
|
<div className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
Drafting note from session state...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="text-[0.75rem] text-danger">{error}</div>
|
||||||
|
)}
|
||||||
|
{preview && (
|
||||||
|
<div className="prose prose-invert prose-sm max-w-none text-[0.8125rem] leading-relaxed">
|
||||||
|
<MarkdownContent content={preview.markdown} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && !preview && (
|
||||||
|
<div className="text-[0.75rem] text-muted-foreground italic">
|
||||||
|
No preview yet — add a fact or accept a suggested fix to populate.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResolutionNotePreview
|
||||||
82
frontend/src/components/pilot/sections/SuggestedFix.tsx
Normal file
82
frontend/src/components/pilot/sections/SuggestedFix.tsx
Normal file
@@ -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> | 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 (
|
||||||
|
<section>
|
||||||
|
<div className="sticky top-0 z-10 pb-2" style={{ background: 'var(--color-bg-page)' }}>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-warning" />
|
||||||
|
Suggested fix
|
||||||
|
<span className="text-muted-foreground">·</span>
|
||||||
|
<span className={`tabular-nums ${conf.tone}`}>{fix.confidence_pct}% confidence</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border-l-[3px] border-l-warning border border-warning/25 bg-warning-dim/15 p-3 mb-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Sparkles size={14} className="text-warning shrink-0 mt-0.5" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[0.8125rem] font-medium text-heading leading-snug">
|
||||||
|
{fix.title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[0.75rem] text-muted-foreground leading-relaxed">
|
||||||
|
{fix.description}
|
||||||
|
</div>
|
||||||
|
{fix.script_template_id && (
|
||||||
|
<div className="mt-1.5 text-[0.6875rem] text-success">
|
||||||
|
✓ Matches an existing Script Library template
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!fix.script_template_id && fix.ai_drafted_script && (
|
||||||
|
<div className="mt-1.5 text-[0.6875rem] text-accent-text">
|
||||||
|
Custom script drafted (no template match)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
disabled={busy}
|
||||||
|
className="p-1 rounded text-muted-foreground hover:text-danger hover:bg-elevated/40 transition-colors"
|
||||||
|
title="Dismiss this suggestion"
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SuggestedFix
|
||||||
@@ -14,7 +14,14 @@ import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/Cha
|
|||||||
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
||||||
import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
|
import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
|
||||||
import { WhatWeKnow } from '@/components/pilot/sections/WhatWeKnow'
|
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 { sessionFactsApi, type SessionFact } from '@/api/sessionFacts'
|
||||||
|
import {
|
||||||
|
sessionSuggestedFixesApi,
|
||||||
|
type SessionSuggestedFix,
|
||||||
|
type ResolutionNotePreview as ResolutionNotePreviewData,
|
||||||
|
} from '@/api/sessionSuggestedFixes'
|
||||||
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
||||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||||
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
|
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]
|
// selectChat and after each chat send (the AI may have emitted [PROMOTE]
|
||||||
// markers that synthesized new facts server-side).
|
// markers that synthesized new facts server-side).
|
||||||
const [facts, setFacts] = useState<SessionFact[]>([])
|
const [facts, setFacts] = useState<SessionFact[]>([])
|
||||||
|
// Phase 3: active suggested fix + resolution-note preview state.
|
||||||
|
const [activeFix, setActiveFix] = useState<SessionSuggestedFix | null>(null)
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false)
|
||||||
|
const [previewData, setPreviewData] = useState<ResolutionNotePreviewData | null>(null)
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
|
const [previewError, setPreviewError] = useState<string | null>(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<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const [showOverflow, setShowOverflow] = useState(false)
|
const [showOverflow, setShowOverflow] = useState(false)
|
||||||
const toggleSidebarCollapse = () => {
|
const toggleSidebarCollapse = () => {
|
||||||
const next = !sidebarCollapsed
|
const next = !sidebarCollapsed
|
||||||
@@ -184,8 +201,8 @@ export default function AssistantChatPage() {
|
|||||||
setActiveActions(response.actions || [])
|
setActiveActions(response.actions || [])
|
||||||
setShowTaskLane(true)
|
setShowTaskLane(true)
|
||||||
}
|
}
|
||||||
// Refetch facts — the AI may have emitted [PROMOTE] markers.
|
// Refetch facts + active fix — the AI may have emitted markers.
|
||||||
refreshFacts(session.session_id)
|
refreshSessionDerived(session.session_id)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to start AI conversation')
|
toast.error('Failed to start AI conversation')
|
||||||
} finally {
|
} 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) => {
|
const handleAddNote = async (text: string, summary: string | null) => {
|
||||||
if (!activeChatId) return
|
if (!activeChatId) return
|
||||||
try {
|
try {
|
||||||
const fact = await sessionFactsApi.create(activeChatId, { text, summary })
|
const fact = await sessionFactsApi.create(activeChatId, { text, summary })
|
||||||
setFacts(prev => [...prev, fact])
|
setFacts(prev => [...prev, fact])
|
||||||
|
schedulePreviewRefresh(activeChatId)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to add note')
|
toast.error('Failed to add note')
|
||||||
}
|
}
|
||||||
@@ -265,6 +291,7 @@ export default function AssistantChatPage() {
|
|||||||
try {
|
try {
|
||||||
const updated = await sessionFactsApi.update(activeChatId, factId, { text, summary })
|
const updated = await sessionFactsApi.update(activeChatId, factId, { text, summary })
|
||||||
setFacts(prev => prev.map(f => f.id === factId ? updated : f))
|
setFacts(prev => prev.map(f => f.id === factId ? updated : f))
|
||||||
|
schedulePreviewRefresh(activeChatId)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to update fact')
|
toast.error('Failed to update fact')
|
||||||
}
|
}
|
||||||
@@ -275,11 +302,77 @@ export default function AssistantChatPage() {
|
|||||||
try {
|
try {
|
||||||
await sessionFactsApi.remove(activeChatId, factId)
|
await sessionFactsApi.remove(activeChatId, factId)
|
||||||
setFacts(prev => prev.filter(f => f.id !== factId))
|
setFacts(prev => prev.filter(f => f.id !== factId))
|
||||||
|
schedulePreviewRefresh(activeChatId)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to remove fact')
|
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) => {
|
const selectChat = useCallback(async (chatId: string) => {
|
||||||
currentChatRef.current = chatId
|
currentChatRef.current = chatId
|
||||||
setActiveChatId(chatId)
|
setActiveChatId(chatId)
|
||||||
@@ -290,8 +383,12 @@ export default function AssistantChatPage() {
|
|||||||
setActiveSessionStatus(null)
|
setActiveSessionStatus(null)
|
||||||
setActivePsaTicketId(null)
|
setActivePsaTicketId(null)
|
||||||
setFacts([])
|
setFacts([])
|
||||||
// Fire facts fetch in parallel with session detail.
|
setActiveFix(null)
|
||||||
refreshFacts(chatId)
|
setPreviewData(null)
|
||||||
|
setPreviewError(null)
|
||||||
|
setPreviewOpen(false)
|
||||||
|
// Fire facts + active-fix fetches in parallel with session detail.
|
||||||
|
refreshSessionDerived(chatId)
|
||||||
try {
|
try {
|
||||||
const detail = await aiSessionsApi.getSession(chatId)
|
const detail = await aiSessionsApi.getSession(chatId)
|
||||||
// Guard: if the user switched to a different chat while this API call was
|
// Guard: if the user switched to a different chat while this API call was
|
||||||
@@ -327,7 +424,7 @@ export default function AssistantChatPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
setMessages([])
|
setMessages([])
|
||||||
}
|
}
|
||||||
}, [refreshFacts])
|
}, [refreshSessionDerived])
|
||||||
|
|
||||||
const handleNewChat = async () => {
|
const handleNewChat = async () => {
|
||||||
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
|
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
|
||||||
@@ -428,8 +525,8 @@ export default function AssistantChatPage() {
|
|||||||
setActiveActions(response.actions || [])
|
setActiveActions(response.actions || [])
|
||||||
setShowTaskLane(true)
|
setShowTaskLane(true)
|
||||||
}
|
}
|
||||||
// Refetch facts — [PROMOTE] markers may have synthesized new ones.
|
// Refetch facts + active fix; preview refreshes if open.
|
||||||
refreshFacts(sentForChatId)
|
refreshSessionDerived(sentForChatId)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('[AssistantChat] sendChatMessage failed:', err)
|
console.error('[AssistantChat] sendChatMessage failed:', err)
|
||||||
const status = (err as { response?: { status?: number } })?.response?.status
|
const status = (err as { response?: { status?: number } })?.response?.status
|
||||||
@@ -498,8 +595,8 @@ export default function AssistantChatPage() {
|
|||||||
setActiveQuestions([])
|
setActiveQuestions([])
|
||||||
setActiveActions([])
|
setActiveActions([])
|
||||||
}
|
}
|
||||||
// Refetch facts — answering tasks is the primary [PROMOTE] trigger.
|
// Refetch facts + active fix; answering tasks is the primary trigger.
|
||||||
refreshFacts(sentForChatId)
|
refreshSessionDerived(sentForChatId)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('[AssistantChat] handleTaskSubmit failed:', err)
|
console.error('[AssistantChat] handleTaskSubmit failed:', err)
|
||||||
const status = (err as { response?: { status?: number } })?.response?.status
|
const status = (err as { response?: { status?: number } })?.response?.status
|
||||||
@@ -589,8 +686,8 @@ export default function AssistantChatPage() {
|
|||||||
setActiveActions(response.actions || [])
|
setActiveActions(response.actions || [])
|
||||||
setShowTaskLane(true)
|
setShowTaskLane(true)
|
||||||
}
|
}
|
||||||
// Refetch facts — the resume turn may emit [PROMOTE] markers.
|
// Refetch facts + active fix — resume turn may emit markers.
|
||||||
refreshFacts(session.session_id)
|
refreshSessionDerived(session.session_id)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to create resume chat')
|
toast.error('Failed to create resume chat')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1080,10 +1177,10 @@ export default function AssistantChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task lane — slides in when AI sends questions/actions OR when the
|
{/* 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
|
session has any "What we know" facts OR an active suggested fix.
|
||||||
structural home of session diagnostic state, not a transient
|
Phase 2/3 make the lane the structural home of session diagnostic
|
||||||
questions panel. */}
|
state, not a transient questions panel. */}
|
||||||
{showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0) && (
|
{showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
|
||||||
<TaskLane
|
<TaskLane
|
||||||
questions={activeQuestions}
|
questions={activeQuestions}
|
||||||
actions={activeActions}
|
actions={activeActions}
|
||||||
@@ -1101,6 +1198,30 @@ export default function AssistantChatPage() {
|
|||||||
onDeleteFact={handleDeleteFact}
|
onDeleteFact={handleDeleteFact}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
suggestedFixSlot={
|
||||||
|
activeFix && (
|
||||||
|
<SuggestedFix fix={activeFix} onDismiss={handleDismissFix} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
bottomSlot={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleTogglePreview}
|
||||||
|
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-heading transition-colors px-3 mt-1"
|
||||||
|
>
|
||||||
|
<FileText size={12} />
|
||||||
|
{previewOpen ? 'Hide' : 'Preview'} Resolve note
|
||||||
|
</button>
|
||||||
|
<ResolutionNotePreviewPopover
|
||||||
|
open={previewOpen}
|
||||||
|
loading={previewLoading}
|
||||||
|
preview={previewData}
|
||||||
|
error={previewError}
|
||||||
|
onClose={() => setPreviewOpen(false)}
|
||||||
|
onRefresh={() => activeChatId && refreshPreview(activeChatId)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user