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>
184 lines
7.3 KiB
Python
184 lines
7.3 KiB
Python
"""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
|