Files
brain-of-reese/app/api/chats.py
T

410 lines
16 KiB
Python

"""Saved-chat API — save and view chat history (phase 50, task 02).
Admin-only CRUD under ``/api/chats`` (the phase-16
:func:`app.core.auth.require_admin` gate, applied router-wide exactly
like :mod:`app.api.steering`): conversations the owner explicitly
**Saves** are stored in Postgres (``saved_chats``, migration 0008).
A10 extension (owner permission 2026-08-29, recorded per AGENTS.md
rule 3 — a recorded revision, not a silent deviation): ``/api/chat``
itself stays stateless; nothing is stored about a conversation that was
not saved, and phase 14's browser-local persistence is untouched
(saving is an additional, explicit action). The stored ``messages``
payload is the exact ``bor.chat.v1`` localStorage record shape
(phase 14 — raw text, never HTML), so a saved chat restores
pixel-identical through the existing ``renderStoredMessage`` path.
Routes: ``GET`` (list, latest activity first — no payloads, but the
phase-51 ``share_url`` and the phase-53 ``stale`` flag so the
History's Share and Stale columns render without a second fetch),
``POST`` (create — auto-title from the first question
when no ``title`` is supplied; (phase 51, task 02) ``share: true`` sets
the token in the SAME commit — the save-then-share contract;
(phase 53, task 03) stamps the row's ``sources_version`` on the
PENDING row so it ships in the same INSERT),
``GET /{chat_id}`` (full payload), ``PUT /{chat_id}``
(re-Save upsert — full ``messages`` replacement, ``title`` replaced
only when supplied; re-stamps ``sources_version`` to the current
generation — a Re-Save is the owner affirming this content against
the current KB), ``DELETE /{chat_id}``, and (phase 51, task 01)
``POST /{chat_id}/share`` / ``POST /{chat_id}/unshare`` on this
admin-gated router.
Staleness (phase 53, task 03): every saved row carries the
``sources_meta`` generation it was saved against (``sources_version``,
migration 0010). ``GET``/list/detail expose ``stale`` — true iff the
row's stamp is behind the current generation, computed server-side
from ONE ``current_sources_version`` read per request (the client
never does staleness math). Share/unshare stay version-immune (raw SQL
on ``share_token`` only — the version, like ``updated_at``, is
untouched); ``SharedChatOut`` (the public snapshot) carries no
staleness surface at all — it is frozen by design (phase 51).
Sharing (phase 51, owner-locked 2026-08-29): a saved chat's
``share_token`` (a 128-bit ``uuid4``, migration 0009) makes it
publicly readable at ``/shared/<token>`` — two more routers in this
file, registered in ``app.main`` with **no** admin dependency:
* ``public_router`` — ``GET /shared/{token}`` (mounted under ``/api``
→ ``GET /api/shared/<token>``): the anonymous read, a minimal
``SharedChatOut`` snapshot (no id/timestamps/token); wrong or
revoked tokens 404 with one message (no enumeration).
* ``shared_page_router`` — ``GET /shared/{token}`` (mounted with **no**
prefix, before the static catch-all): serves
``frontend/shared.html`` (the page lands in phase 51, task 03). A
stale deploy without the file 404s with the SAME JSON as the API —
never a 500.
"""
from __future__ import annotations
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import FileResponse, JSONResponse
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core.auth import require_admin
from app.db import get_db
from app.models import SavedChat
from app.rag.sources_meta import current_sources_version
from app.schemas import (
ChatMessage,
SavedChatCreate,
SavedChatList,
SavedChatOut,
SavedChatRow,
SavedChatUpdate,
SharedChatOut,
ShareOut,
UnshareOut,
)
router = APIRouter(
prefix="/chats",
tags=["chats"],
dependencies=[Depends(require_admin)], # phase 16: save/history is admin-only
)
#: Auto-title cap (owner-locked convention, phase 50): the first user
#: message's text, whitespace-collapsed, truncated to 120 chars.
_AUTO_TITLE_MAX = 120
def _auto_title(messages: list[ChatMessage]) -> str:
"""The auto-title (owner-locked, phase 50): the **first user
message**'s text, whitespace-collapsed, truncated to 120 chars.
Returns "" when the conversation has no user message (defensive —
the UI cannot produce one; the route then falls back to
``"Chat <id-hex8>"``).
"""
first_user = next((m.text for m in messages if m.who == "user"), None)
if first_user is None:
return ""
return " ".join(first_user.split())[:_AUTO_TITLE_MAX]
def _share_url(row: SavedChat) -> str | None:
"""The row's share link path (phase 51) — ``None`` when unshared.
``None`` is ABSENT from the JSON (the schemas' omission rule), so an
unshared chat exposes no share surface at all.
"""
return f"/shared/{row.share_token}" if row.share_token else None
def _to_out(row: SavedChat, current_version: int) -> SavedChatOut:
"""The full-payload response shape (create/get/put).
``current_version`` is the caller's ONE per-request
``current_sources_version`` read — the helpers stay pure (no session
argument, no second query); ``stale`` is true iff the row's stamp
is behind that generation.
"""
return SavedChatOut(
id=row.id,
title=row.title,
created_at=row.created_at,
updated_at=row.updated_at,
message_count=len(row.messages),
messages=[ChatMessage.model_validate(m) for m in row.messages],
share_url=_share_url(row),
stale=row.sources_version < current_version,
)
def _to_row(row: SavedChat, current_version: int) -> SavedChatRow:
"""The list-page row shape (no payloads in the list; ``share_url``
and ``stale`` are not payloads — they are the History Share and
Stale columns' data). ``current_version`` is the caller's ONE
per-request read (see :func:`_to_out`)."""
return SavedChatRow(
id=row.id,
title=row.title,
updated_at=row.updated_at,
message_count=len(row.messages),
share_url=_share_url(row),
stale=row.sources_version < current_version,
)
@router.get("", response_model=SavedChatList)
def list_chats(
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatList:
"""All saved chats, latest activity first (``updated_at desc, id
desc``) — the History page's table order. Each row carries the
phase-53 ``stale`` flag: the current generation is read ONCE per
request (one PK read of the seeded row) and compared against every
row's stamp in the serializer helpers — no per-row queries."""
current_version = current_sources_version(db)
rows = db.scalars(
select(SavedChat).order_by(SavedChat.updated_at.desc(), SavedChat.id.desc())
).all()
return SavedChatList(chats=[_to_row(row, current_version) for row in rows])
@router.post("", response_model=SavedChatOut, status_code=201)
def create_chat(
payload: SavedChatCreate,
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatOut:
"""Store one explicitly saved conversation (201).
Auto-title when ``title`` is absent/blank: the first user message's
text, whitespace-collapsed, truncated to 120 chars (owner-locked
convention); a conversation with no user message (defensive) falls
back to ``"Chat <id-hex8>"``.
``share: true`` (phase 51, task 02 — the save-then-share contract):
the fresh row carries ``share_token = uuid.uuid4()`` in the SAME
INSERT/commit — one request saves AND shares, and the 201 body
carries ``share_url`` (the chat page copies it in one action).
``sources_version`` (phase 53, task 03): stamped on the PENDING
row with the current generation — it ships in the SAME INSERT (the
``share_token`` precedent), so a save can never be committed
unstamped, and the 201 body's ``stale`` flag (freshly stamped →
always false) is honest by construction.
"""
title = (payload.title or "").strip() or _auto_title(payload.messages)
current_version = current_sources_version(db) # once per request
row = SavedChat(
title=title,
# Plain model_dump (no exclude_none): the stored JSONB keeps
# every bor.chat.v1 key, explicit null included — the phase-37
# tool records carry `argument: null` in localStorage, so this
# is what makes a real payload round-trip byte-identical (the
# restore path is null-safe for every optional key).
messages=[m.model_dump() for m in payload.messages],
# Phase 53: the KB generation this save is made against, set
# before db.add so it rides the same INSERT (see above).
sources_version=current_version,
)
if payload.share:
# Phase 51: set on the PENDING row, so the token ships in the
# same INSERT (one commit — save and share are one action).
row.share_token = uuid.uuid4()
db.add(row)
db.flush() # python-side uuid default lands the id before the fallback
if not row.title:
row.title = f"Chat {row.id.hex[:8]}"
db.commit()
db.refresh(row)
return _to_out(row, current_version)
@router.get("/{chat_id}", response_model=SavedChatOut)
def get_chat(
chat_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatOut:
"""One saved chat, full payload (the ``?chat=<id>`` load); 404 when
the id is unknown. The ``stale`` flag (phase 53) tells the chat
page whether to reveal its stale banner (task 05) before the
messages render."""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
return _to_out(row, current_sources_version(db))
@router.put("/{chat_id}", response_model=SavedChatOut)
def update_chat(
chat_id: uuid.UUID,
payload: SavedChatUpdate,
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatOut:
"""Re-Save upsert: full ``messages`` replacement on the same row.
``title`` is replaced only when supplied (an absent/blank ``title``
keeps the current one); 404 when the id is unknown. ``updated_at``
bumps via the model's ``onupdate=func.now()`` — the attribute
assignment above is the ORM change that triggers it, so the History
page's "latest activity first" order follows re-Saves.
``sources_version`` is re-stamped to the CURRENT generation
unconditionally (phase 53, task 03 — ASSUMPTION: a Re-Save ALWAYS
re-stamps, not "only when stale"): re-Saving is the owner
affirming this content against the current KB, which makes the
response's ``stale`` flag false and doubles as the manual escape
hatch for a false-positive stale row (one fewer code path than a
conditional re-stamp).
"""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
current_version = current_sources_version(db) # once per request
row.messages = [m.model_dump() for m in payload.messages]
title = (payload.title or "").strip()
if title:
row.title = title
# Phase 53: the affirmation stamp — see the docstring above.
row.sources_version = current_version
db.commit()
db.refresh(row)
return _to_out(row, current_version)
@router.delete("/{chat_id}", status_code=204)
def delete_chat(
chat_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> Response:
"""Remove a saved chat; 404 when the id is unknown."""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
db.delete(row)
db.commit()
return Response(status_code=204)
@router.post("/{chat_id}/share", response_model=ShareOut)
def share_chat(
chat_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> ShareOut:
"""Turn a saved chat into a public link (phase 51, task 01).
Returns ``{"chat_id", "share_url"}`` with ``share_url =
"/shared/<token>"`` (200, idempotent — an existing token is
returned unchanged; a new token is a 128-bit ``uuid4``).
``updated_at`` is **not** bumped: sharing is not a content edit,
so the token is written with a raw SQL ``UPDATE`` that touches ONLY
``share_token`` — the column's ``onupdate=func.now()`` default is
registered on the Table (via ``mapped_column``), so even a Core
``update(SavedChat)`` DML statement would pick it up; the ORM
object is never mutated either. 404 when the id is unknown.
"""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
token = row.share_token
if token is None:
token = uuid.uuid4()
# Raw SQL on purpose: sets ONLY share_token, so the column's
# onupdate default for ``updated_at`` never fires (the History
# page's "latest activity" order must follow content edits only).
db.execute(
text("UPDATE saved_chats SET share_token = :tok WHERE id = :id"),
{"tok": token, "id": chat_id},
)
db.commit()
return ShareOut(chat_id=row.id, share_url=f"/shared/{token}")
@router.post("/{chat_id}/unshare", response_model=UnshareOut)
def unshare_chat(
chat_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> UnshareOut:
"""Revoke a shared chat (phase 51, task 01): ``share_token`` → NULL.
Idempotent — an unshared chat unshares cleanly (200, no write).
``updated_at`` is not bumped (raw SQL ``UPDATE`` touching only
``share_token``, same reasoning as :func:`share_chat`); 404 when
the id is unknown.
"""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
if row.share_token is not None:
db.execute(
text("UPDATE saved_chats SET share_token = NULL WHERE id = :id"),
{"id": chat_id},
)
db.commit()
return UnshareOut(chat_id=row.id, shared=False)
#: Public read surface (phase 51, task 01) — **no** admin dependency:
#: a guest with the link reads the shared chat anonymously. Mounted
#: under ``/api`` in ``app.main`` → ``GET /api/shared/<token>``.
public_router = APIRouter(tags=["chats"])
def _to_shared_out(row: SavedChat) -> SharedChatOut:
"""The PUBLIC read shape: title + messages only — no id,
timestamps, or token (a content snapshot, not a handle)."""
return SharedChatOut(
title=row.title,
messages=[ChatMessage.model_validate(m) for m in row.messages],
)
@public_router.get("/shared/{token}", response_model=SharedChatOut)
def read_shared_chat(
token: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> SharedChatOut:
"""Anonymous read of a shared chat by token (phase 51, task 01).
No admin dependency — the token IS the credential. A wrong or a
revoked (unshared) token 404s with ONE message: ``unknown or
revoked share link`` — deliberately no enumeration between the two
cases.
"""
row = db.execute(
select(SavedChat).where(SavedChat.share_token == token).limit(1)
).scalars().first()
if row is None:
raise HTTPException(status_code=404, detail="unknown or revoked share link")
return _to_shared_out(row)
#: The share PAGE route (phase 51, task 01) — mounted with **no**
#: prefix, BEFORE the static catch-all in ``app.main`` (the
#: API-routes-first convention): ``/shared/<uuid>`` is not a static
#: file, so without this route the ``StaticFiles`` mount would 404 it.
shared_page_router = APIRouter(tags=["chats"])
#: The page's filename inside the static dir (the file lands in phase
#: 51, task 03; until then the guard below keeps a stale deploy from
#: 500'ing).
_SHARED_PAGE_NAME = "shared.html"
@shared_page_router.get("/shared/{token}", response_model=None)
def shared_page(token: uuid.UUID) -> FileResponse | JSONResponse:
"""Serve the anonymous shared-chat page for ``/shared/<token>``.
The page (``frontend/shared.html``, task 03) fetches
``GET /api/shared/<token>`` itself and renders the conversation
read-only — including the "invalid or revoked" state for a wrong
or revoked token — so this route serves the page for any well-
formed token and never 404s on the token's validity. The guard:
when the page file is missing (a stale deploy — the API is ahead
of the static bundle), return the SAME 404 JSON as the API rather
than a 500.
"""
page = Path(get_settings().static_dir).expanduser().resolve() / _SHARED_PAGE_NAME
if not page.is_file():
return JSONResponse(
status_code=404, content={"detail": "unknown or revoked share link"}
)
return FileResponse(page)