feat(chat): save and view chat history — admin-only saved_chats, History page, open-a-chat return
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
"""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), ``POST``
|
||||
(create — auto-title from the first question when no ``title`` is
|
||||
supplied), ``GET /{chat_id}`` (full payload), ``PUT /{chat_id}``
|
||||
(re-Save upsert — full ``messages`` replacement, ``title`` replaced
|
||||
only when supplied), ``DELETE /{chat_id}``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import require_admin
|
||||
from app.db import get_db
|
||||
from app.models import SavedChat
|
||||
from app.schemas import (
|
||||
ChatMessage,
|
||||
SavedChatCreate,
|
||||
SavedChatList,
|
||||
SavedChatOut,
|
||||
SavedChatRow,
|
||||
SavedChatUpdate,
|
||||
)
|
||||
|
||||
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 _to_out(row: SavedChat) -> SavedChatOut:
|
||||
"""The full-payload response shape (create/get/put)."""
|
||||
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],
|
||||
)
|
||||
|
||||
|
||||
def _to_row(row: SavedChat) -> SavedChatRow:
|
||||
"""The list-page row shape (no payloads in the list)."""
|
||||
return SavedChatRow(
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
updated_at=row.updated_at,
|
||||
message_count=len(row.messages),
|
||||
)
|
||||
|
||||
|
||||
@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."""
|
||||
rows = db.scalars(
|
||||
select(SavedChat).order_by(SavedChat.updated_at.desc(), SavedChat.id.desc())
|
||||
).all()
|
||||
return SavedChatList(chats=[_to_row(row) 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>"``.
|
||||
"""
|
||||
title = (payload.title or "").strip() or _auto_title(payload.messages)
|
||||
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],
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@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."""
|
||||
row = db.get(SavedChat, chat_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="unknown chat")
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
row = db.get(SavedChat, chat_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="unknown chat")
|
||||
row.messages = [m.model_dump() for m in payload.messages]
|
||||
title = (payload.title or "").strip()
|
||||
if title:
|
||||
row.title = title
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
@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)
|
||||
Reference in New Issue
Block a user