"""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