462 lines
19 KiB
Python
462 lines
19 KiB
Python
"""Saved-chat API — save and view chat history (phase 50, task 02).
|
|
|
|
Split gate (phase 55, task 01 — owner-locked A1, superseding the
|
|
phase-50 "save/history is admin-only" lock): under ``/api/chats`` the
|
|
WRITE surface is **public** (no session) — ``POST`` (create, including
|
|
the save-then-share ``share: true`` branch), ``PUT /{chat_id}`` (the
|
|
re-Save upsert), ``POST /{chat_id}/share``. WHY: a save is the
|
|
visitor's OWN conversation; the row id is an unguessable ``uuid4``, the
|
|
same trust model as the phase-51 share token — the id/token IS the
|
|
credential (a guest holds the handle to what they just saved, exactly
|
|
as a link-holder holds a token). The MANAGEMENT surface is
|
|
**admin-only** and keeps the phase-16 :func:`app.core.auth.require_admin`
|
|
gate — applied per-route on exactly those four decorators (list,
|
|
detail, delete, unshare — the owner's History surface; the
|
|
:mod:`app.api.steering` router-wide pattern is untouched). Guest chats
|
|
appear in the admin's History (saved by default — phase 55).
|
|
|
|
Conversations 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`` (public, phase 55) /
|
|
``POST /{chat_id}/unshare`` (admin-only — the owner's History
|
|
action).
|
|
|
|
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,
|
|
)
|
|
|
|
# Phase 55, task 01 (owner-locked A1): NO router-wide gate here — the
|
|
# write surface (create / re-Save / share) is public; exactly the four
|
|
# management routes below carry ``dependencies=[Depends(require_admin)]``.
|
|
router = APIRouter(
|
|
prefix="/chats",
|
|
tags=["chats"],
|
|
)
|
|
|
|
#: 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,
|
|
dependencies=[Depends(require_admin)], # management surface (phase 55)
|
|
)
|
|
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, admin-only (the owner's
|
|
History surface; phase 55 moved the gate per-route). 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).
|
|
|
|
PUBLIC (phase 55, task 01) — no session required: the save is the
|
|
visitor's own conversation; the unguessable ``uuid4`` row id IS the
|
|
credential (the phase-51 token's trust model).
|
|
|
|
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,
|
|
dependencies=[Depends(require_admin)], # management surface (phase 55)
|
|
)
|
|
def get_chat(
|
|
chat_id: uuid.UUID,
|
|
db: Session = Depends(get_db), # noqa: B008
|
|
) -> SavedChatOut:
|
|
"""One saved chat, full payload (the ``?chat=<id>`` load) —
|
|
admin-only (the ``?chat=<id>`` boot restore is the owner's "Open"
|
|
action, phase 55 A3); 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.
|
|
|
|
PUBLIC (phase 55, task 01) — no session required (the auto-save
|
|
upsert for the visitor's own conversation; same row-id trust model
|
|
as :func:`create_chat`).
|
|
|
|
``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,
|
|
dependencies=[Depends(require_admin)], # management surface (phase 55)
|
|
)
|
|
def delete_chat(
|
|
chat_id: uuid.UUID,
|
|
db: Session = Depends(get_db), # noqa: B008
|
|
) -> Response:
|
|
"""Remove a saved chat — admin-only (the owner's History action,
|
|
phase 55); 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).
|
|
|
|
PUBLIC (phase 55, task 01) — no session required: sharing is what
|
|
the visitor does with their own conversation (the row-id trust
|
|
model of :func:`create_chat`; revoking, by contrast, is the
|
|
owner's History action — :func:`unshare_chat` stays admin-only).
|
|
|
|
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,
|
|
dependencies=[Depends(require_admin)], # management surface (phase 55)
|
|
)
|
|
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.
|
|
|
|
Admin-only (the owner's History action — phase 55 kept the
|
|
revocation off the guest's reach, so a guest cannot un-revoke).
|
|
|
|
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)
|