184 lines
6.4 KiB
Python
184 lines
6.4 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), ``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)
|