Files
resolutionflow/backend/app/api/endpoints/session_facts.py
Michael Chihlas 625dba7548 feat(pilot): Phase 2 — What we know (facts) with stable task-lane IDs
Adds the load-bearing structural feature of the FlowPilot migration: a
"What we know" panel that holds confirmed facts for a session, fed by AI
[PROMOTE] markers and engineer-added notes. Facts feed the resolution
note preview (Phase 3) and survive across turns via stable UUIDs assigned
to pending_task_lane items.

Backend:
- FactSynthesisService: create/update/soft-delete facts with atomic
  state_version bumps; LLM-backed synthesize_from_question/check on the
  fact_synthesis (Haiku) action tier per Section 6.6.
- /api/v1/ai-sessions/{id}/facts CRUD + /facts/promote (proposed_text or
  via synthesis). PATCH returns 403 for question/diagnostic_check facts
  (edit the source item instead, Section 7.3).
- unified_chat_service: [PROMOTE] marker parser (JSON-block per Section
  8.1 spec drift note), stable-UUID assignment for pending_task_lane
  questions/actions preserved by exact text/label match across turns.
- ASSISTANT_SYSTEM_PROMPT: documents [PROMOTE] format, when to/not to
  emit, hallucination guardrails, source_ref handling.
- 17 tests covering parser, stable IDs, service validation, CRUD,
  editability rule, both promote modes, 422 null-synthesis path,
  state_version invariant.

Frontend:
- src/components/pilot/sections/{WhatWeKnow,WhatWeKnowItem,AddNoteButton}
  — green-gradient section above Questions, dashed-circle check, inline
  edit/delete gated by the server's editable flag.
- TaskLane gains a whatWeKnowSlot prop (existing assistant/ folder kept
  per the doc's "rename is opportunistic" guidance).
- AssistantChatPage fetches facts on selectChat and refetches after each
  chat send (so [PROMOTE]-synthesized facts appear immediately); auto-
  opens the lane when facts exist.

Verification: end-to-end smoke against the local docker stack confirms
all five endpoints (list/create/patch/delete/promote) plus the 403
editability rule. pytest suite verifies the same with mocked LLM. Live
[PROMOTE] flow remains untested until used in the UI — the marker shape
is covered by parser tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:13:44 -04:00

316 lines
12 KiB
Python

"""Session fact endpoints — the "What we know" CRUD surface for a FlowPilot session.
All routes are sub-resources of `/ai-sessions/{session_id}`. Tenant isolation is
enforced by RLS on `session_facts.account_id`; a user from another account
literally cannot see or write facts for this session.
Editability rule (per FLOWPILOT-MIGRATION.md Section 7.3):
- `user_note` and `ai_synthesis` facts are editable at the card level.
- `question` and `diagnostic_check` facts are read-only at the card level —
edit the source question/check instead. PATCH returns 403 for those.
Fact promotion writes always bump `ai_sessions.state_version` so the
resolution-note preview cache invalidates (Section 5.5).
"""
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
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_fact import SessionFact
from app.models.user import User
from app.schemas.session_fact import (
SessionFactCreateRequest,
SessionFactListResponse,
SessionFactPromoteRequest,
SessionFactResponse,
SessionFactUpdateRequest,
)
from app.services.fact_synthesis_service import (
FactSynthesisService,
list_facts_for_session,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-facts"])
# Source types whose facts can be edited at the card level (Section 7.3).
_EDITABLE_SOURCE_TYPES = frozenset({"user_note", "ai_synthesis"})
def _to_response(fact: SessionFact) -> SessionFactResponse:
"""Wrap an ORM SessionFact in the response model with the editable flag."""
return SessionFactResponse(
id=fact.id,
session_id=fact.session_id,
text=fact.text,
source_type=fact.source_type, # type: ignore[arg-type]
source_ref=fact.source_ref,
source_summary=fact.source_summary,
created_by=fact.created_by,
created_at=fact.created_at,
updated_at=fact.updated_at,
editable=fact.source_type in _EDITABLE_SOURCE_TYPES,
)
async def _load_session_or_404(db: AsyncSession, session_id: UUID) -> AISession:
"""Load the session via RLS-scoped SELECT. Returns 404 if missing/cross-tenant.
Tenant isolation: RLS on `ai_sessions` filters by current account, so a
cross-tenant access returns no rows and we 404 (rather than 403, which
would leak the row's existence).
"""
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
async def _load_fact_or_404(
db: AsyncSession, session_id: UUID, fact_id: UUID
) -> SessionFact:
"""Load a non-deleted fact for the session. 404 if missing or already deleted."""
result = await db.execute(
select(SessionFact).where(
SessionFact.id == fact_id,
SessionFact.session_id == session_id,
SessionFact.deleted_at.is_(None),
)
)
fact = result.scalar_one_or_none()
if fact is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fact not found")
return fact
# ── List ──
@router.get("/facts", response_model=SessionFactListResponse)
async def list_facts(
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> SessionFactListResponse:
"""List facts for a session, oldest first."""
await _load_session_or_404(db, session_id)
facts = await list_facts_for_session(db, session_id)
return SessionFactListResponse(facts=[_to_response(f) for f in facts])
# ── Create (manual user note) ──
@router.post("/facts", response_model=SessionFactResponse, status_code=201)
async def create_fact(
session_id: UUID,
body: SessionFactCreateRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> SessionFactResponse:
"""Create a manual fact (the "+ Add a note" UI affordance).
Always recorded as `source_type=user_note`. Source-typed creation goes
through `/facts/promote` so the originating item ID is captured.
"""
session = await _load_session_or_404(db, session_id)
service = FactSynthesisService(db)
try:
fact = await service.create_fact(
session_id=session.id,
account_id=session.account_id,
user_id=current_user.id,
source_type="user_note",
text=body.text,
summary=body.summary,
source_ref=None,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
await db.commit()
await db.refresh(fact)
return _to_response(fact)
# ── Update ──
@router.patch("/facts/{fact_id}", response_model=SessionFactResponse)
async def update_fact(
session_id: UUID,
fact_id: UUID,
body: SessionFactUpdateRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> SessionFactResponse:
"""Edit fact text or summary.
Returns 403 for `question` and `diagnostic_check`-sourced facts: the
source item is the canonical input, so editing the fact card would
desync the two. Engineers edit the source instead.
"""
fact = await _load_fact_or_404(db, session_id, fact_id)
if fact.source_type not in _EDITABLE_SOURCE_TYPES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"Facts sourced from {fact.source_type!r} are read-only at the "
"card level. Edit the originating question or diagnostic check instead."
),
)
if body.text is None and body.summary is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one of `text` or `summary` must be provided",
)
service = FactSynthesisService(db)
try:
fact = await service.update_fact(fact, text=body.text, summary=body.summary)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
await db.commit()
await db.refresh(fact)
return _to_response(fact)
# ── Soft delete ──
@router.delete("/facts/{fact_id}", status_code=204)
async def delete_fact(
session_id: UUID,
fact_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> None:
"""Soft-delete a fact. All source types are deletable.
Soft delete (rather than hard) preserves provenance for audit and lets
accidental deletes be recovered if needed. The `editable` flag does NOT
control deletion — even read-only facts can be removed when the
underlying question/check turned out to be wrong.
"""
fact = await _load_fact_or_404(db, session_id, fact_id)
service = FactSynthesisService(db)
await service.soft_delete_fact(fact)
await db.commit()
# ── Promote (AI marker + engineer-driven) ──
@router.post("/facts/promote", response_model=SessionFactResponse, status_code=201)
async def promote_fact(
session_id: UUID,
body: SessionFactPromoteRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> SessionFactResponse:
"""Convert a question answer / check result into a fact.
Two modes:
- `proposed_text` provided → persisted as-is.
- `raw_input` provided → server drafts text/summary via FactSynthesisService.
Exactly one of the two must be set. The engineer-facing UI typically uses
`proposed_text` after letting the engineer review/edit a draft.
"""
if (body.proposed_text is None) == (body.raw_input is None):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Exactly one of `proposed_text` or `raw_input` must be provided",
)
if body.source_type == "ai_synthesis" and body.source_ref is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="`source_ref` must be null for source_type=ai_synthesis",
)
session = await _load_session_or_404(db, session_id)
service = FactSynthesisService(db)
text = body.proposed_text
summary = body.proposed_summary
if text is None:
# Synthesize via LLM. Caller must hint which task-lane item the input
# came from so we can shape the prompt appropriately.
raw = body.raw_input or ""
if body.source_type == "question":
draft = await service.synthesize_from_question(
question_text=_lookup_task_lane_text(session, body.source_ref, "questions"),
raw_answer=raw,
)
elif body.source_type == "diagnostic_check":
draft = await service.synthesize_from_check(
check_label=_lookup_task_lane_text(session, body.source_ref, "actions"),
check_output=raw,
)
else:
# ai_synthesis with raw_input: the raw input IS the synthesis.
# Re-run through the question synthesizer with an empty question
# so the conservative prompt still applies.
draft = await service.synthesize_from_question(
question_text="(none — synthesizing from engineer summary)",
raw_answer=raw,
)
text = draft["text"]
summary = summary or draft["summary"]
if not text:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
"Synthesizer found no substantive fact in the input. "
"Edit the input or supply `proposed_text` directly."
),
)
try:
fact = await service.create_fact(
session_id=session.id,
account_id=session.account_id,
user_id=current_user.id,
source_type=body.source_type,
text=text,
summary=summary,
source_ref=body.source_ref,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
await db.commit()
await db.refresh(fact)
return _to_response(fact)
def _lookup_task_lane_text(
session: AISession, source_ref: UUID | None, list_key: str
) -> str:
"""Find the originating question text / action label from pending_task_lane.
Falls back to a generic placeholder if the source item is no longer in
the lane (e.g., the AI dropped it from a later turn). The synthesizer is
forgiving — an empty/generic question still produces a useful fact when
the engineer's answer is substantive on its own.
"""
if source_ref is None:
return ""
lane = session.pending_task_lane or {}
items = lane.get(list_key) or []
sref = str(source_ref)
for item in items:
if isinstance(item, dict) and str(item.get("id")) == sref:
return str(item.get("text") or item.get("label") or "")
return ""