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>
316 lines
12 KiB
Python
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 ""
|