Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a302ed427a | |||
| 0353d592cd | |||
| 330077afcf | |||
| 8390703b73 | |||
| b9644fad31 |
@@ -149,3 +149,21 @@ Don't jump phases. Phase 1 must work end-to-end before Phase 2 lands.
|
|||||||
- Inference hosting (start with a cloud API, re-evaluate later)
|
- Inference hosting (start with a cloud API, re-evaluate later)
|
||||||
- Character template format (during Phase 1)
|
- Character template format (during Phase 1)
|
||||||
- Multi-session / multi-character casts: **out of scope for v1**. Leave cheap schema hooks only.
|
- Multi-session / multi-character casts: **out of scope for v1**. Leave cheap schema hooks only.
|
||||||
|
|
||||||
|
## Phase 1 status
|
||||||
|
|
||||||
|
Phase 1 shipped end-to-end across **35 tasks** (T0–T35). The single-bot core loop is functional: event log + projector, schema + migrations, settings/bot authoring, kickoff confirm, streaming turns, drawer rendering, regenerate/rewind, scene close + per-POV summaries, significance classifier, snapshots/backups, first-run navigation, and friendly 404/500 pages. **168 tests passing.**
|
||||||
|
|
||||||
|
Deferred to Phase 2: second bot, group node, scene configurations, witness filtering across multi-entity scenes, activity/containers, scene-transition compression. Phase 3: event queue + triggers, time skips, active threads. Phase 4: vector retrieval, branching, surgical delete + regenerate, impact-preview UI.
|
||||||
|
|
||||||
|
### Known v1 limitations (read before extending)
|
||||||
|
|
||||||
|
- **Drawer edits scope**: only affinity, significance, and pin can be hand-edited from the drawer. Other v1 fields (knowledge, summary text, traits) are deferred to Phase 1.5.
|
||||||
|
- **Cold-load snapshot path** is wired and unit-tested but rarely exercised in dev — long-running sessions are the only realistic trigger.
|
||||||
|
- **WAL sidecar files** (`-wal`, `-shm`) are not captured in nightly backups; the nightly snapshot is a fresh `.backup()` so this is fine for restore but worth knowing if you copy the db file by hand.
|
||||||
|
- **HTMX SSE event names** may need a version check if you bump the htmx CDN URL in `base.html` — the swap targets are name-coupled.
|
||||||
|
- **"You" activity rows** can linger after `bot_reset` (the reset purges the bot's chats and the bot's own activity row but not the "you" row that was associated with those chats). Cosmetic, fixed in Phase 1.5.
|
||||||
|
- **Projector replay is non-idempotent** for plain `INSERT` events. After appending, call `apply_event(conn, event)` for the new row only — calling `project(conn)` re-runs every handler from scratch and will trip uniqueness or duplicate inserts.
|
||||||
|
- **8-pin auto-cap eviction** is FIFO over the auto-pinned set only. Manual pins survive the eviction; this is by design (manual intent > auto-pin signal).
|
||||||
|
- **Regenerate (T29) does not broadcast `turn_html` over SSE** — the page must refresh to show the regenerated turn. Acceptable for v1 single-tab usage; Phase 1.5 should wire the SSE event.
|
||||||
|
- **First-run middleware** fires only on bare `/` and `/chats`. Sub-paths like `/chats/<id>` and `/chats/<id>/drawer` pass through (correct: HTMX partials should not page-redirect, and a deep-link to a missing chat should 404, not redirect mid-setup).
|
||||||
|
|||||||
+56
-1
@@ -1,13 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
from chat.config import load_settings
|
from chat.config import load_settings
|
||||||
|
from chat.db.connection import open_db
|
||||||
from chat.db.migrate import apply_migrations
|
from chat.db.migrate import apply_migrations
|
||||||
|
from chat.eventlog.log import read_events
|
||||||
|
from chat.eventlog.projector import apply_event
|
||||||
from chat.services.background import BackgroundWorker
|
from chat.services.background import BackgroundWorker
|
||||||
|
from chat.services.snapshot import latest_snapshot_path, restore_from_snapshot
|
||||||
|
|
||||||
# Trigger handler registration:
|
# Trigger handler registration:
|
||||||
import chat.state.entities # noqa: F401
|
import chat.state.entities # noqa: F401
|
||||||
@@ -20,17 +29,40 @@ from chat.web.bots import router as bots_router
|
|||||||
from chat.web.chat import router as chat_router
|
from chat.web.chat import router as chat_router
|
||||||
from chat.web.drawer import router as drawer_router
|
from chat.web.drawer import router as drawer_router
|
||||||
from chat.web.kickoff import router as kickoff_router
|
from chat.web.kickoff import router as kickoff_router
|
||||||
|
from chat.web.middleware import FirstRunRedirectMiddleware
|
||||||
from chat.web.nav import router as nav_router
|
from chat.web.nav import router as nav_router
|
||||||
from chat.web.settings import router as settings_router
|
from chat.web.settings import router as settings_router
|
||||||
from chat.web.sse import router as sse_router
|
from chat.web.sse import router as sse_router
|
||||||
from chat.web.turns import router as turns_router
|
from chat.web.turns import router as turns_router
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
apply_migrations(settings.db_path)
|
apply_migrations(settings.db_path)
|
||||||
|
|
||||||
|
# T31 cold-load fast-path: if a periodic snapshot exists, restore
|
||||||
|
# projected tables from it and replay only events past its
|
||||||
|
# ``last_event_id``. Migrations already ran above, so any new tables
|
||||||
|
# introduced after the snapshot was taken are present and empty —
|
||||||
|
# the replay-forward step refills them from the event log.
|
||||||
|
snapshot_path = latest_snapshot_path(settings.data_dir, kind="periodic")
|
||||||
|
if snapshot_path is not None:
|
||||||
|
with open_db(settings.db_path) as conn:
|
||||||
|
last_event_id = restore_from_snapshot(conn, snapshot_path)
|
||||||
|
for event in read_events(
|
||||||
|
conn, branch_id=1, after_id=last_event_id
|
||||||
|
):
|
||||||
|
apply_event(conn, event)
|
||||||
|
log.info(
|
||||||
|
"cold-load restored from %s, replayed events past id %d",
|
||||||
|
snapshot_path,
|
||||||
|
last_event_id,
|
||||||
|
)
|
||||||
|
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
|
|
||||||
# Background worker for the async significance pass (T22). Each job
|
# Background worker for the async significance pass (T22). Each job
|
||||||
@@ -55,10 +87,33 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="chat", lifespan=lifespan)
|
app = FastAPI(title="chat", lifespan=lifespan)
|
||||||
|
app.add_middleware(FirstRunRedirectMiddleware)
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
ERROR_TEMPLATES = Jinja2Templates(
|
||||||
|
directory=str(Path(__file__).resolve().parent / "templates")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
||||||
|
"""Render a friendly HTML page for 404/500; JSON for everything else."""
|
||||||
|
if exc.status_code in (404, 500):
|
||||||
|
return ERROR_TEMPLATES.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"errors.html",
|
||||||
|
{
|
||||||
|
"status_code": exc.status_code,
|
||||||
|
"detail": exc.detail or "Something went wrong.",
|
||||||
|
"active_nav": "chats",
|
||||||
|
},
|
||||||
|
status_code=exc.status_code,
|
||||||
|
)
|
||||||
|
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||||
|
|
||||||
|
|
||||||
app.include_router(bots_router)
|
app.include_router(bots_router)
|
||||||
app.include_router(kickoff_router)
|
app.include_router(kickoff_router)
|
||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
|
|||||||
@@ -37,4 +37,12 @@ def load_settings() -> Settings:
|
|||||||
raw = tomllib.loads(config_path.read_text())
|
raw = tomllib.loads(config_path.read_text())
|
||||||
if "CHAT_DB_PATH" in os.environ:
|
if "CHAT_DB_PATH" in os.environ:
|
||||||
raw["db_path"] = Path(os.environ["CHAT_DB_PATH"])
|
raw["db_path"] = Path(os.environ["CHAT_DB_PATH"])
|
||||||
|
if "CHAT_DATA_DIR" in os.environ:
|
||||||
|
raw["data_dir"] = Path(os.environ["CHAT_DATA_DIR"])
|
||||||
|
elif "data_dir" not in raw and "db_path" in raw:
|
||||||
|
# T31: when ``CHAT_DB_PATH`` is overridden (typical in tests) but
|
||||||
|
# ``data_dir`` isn't, derive ``data_dir`` from the db's parent so
|
||||||
|
# snapshot/auxiliary files stay alongside the test db rather than
|
||||||
|
# leaking into the real repo data dir.
|
||||||
|
raw["data_dir"] = Path(raw["db_path"]).parent
|
||||||
return Settings(**raw)
|
return Settings(**raw)
|
||||||
|
|||||||
@@ -32,7 +32,22 @@ from chat.config import Settings
|
|||||||
from chat.db.connection import open_db
|
from chat.db.connection import open_db
|
||||||
from chat.eventlog.log import append_and_apply
|
from chat.eventlog.log import append_and_apply
|
||||||
from chat.llm.client import LLMClient
|
from chat.llm.client import LLMClient
|
||||||
|
from chat.services.backup import (
|
||||||
|
prune_backups,
|
||||||
|
should_take_backup,
|
||||||
|
take_backup,
|
||||||
|
)
|
||||||
from chat.services.significance import compute_significance
|
from chat.services.significance import compute_significance
|
||||||
|
from chat.services.snapshot import (
|
||||||
|
prune_periodic_snapshots,
|
||||||
|
should_take_periodic_snapshot,
|
||||||
|
take_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
# T32: tick-loop wake interval. 60s gives a single backup window per
|
||||||
|
# target hour with plenty of slack: should_take_backup's 23h freshness
|
||||||
|
# guard prevents back-to-back runs.
|
||||||
|
BACKUP_TICK_INTERVAL_SECONDS = 60.0
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -70,14 +85,27 @@ class BackgroundWorker:
|
|||||||
self._llm_client_factory = llm_client_factory
|
self._llm_client_factory = llm_client_factory
|
||||||
self._queue: asyncio.Queue[SignificanceJob | None] = asyncio.Queue()
|
self._queue: asyncio.Queue[SignificanceJob | None] = asyncio.Queue()
|
||||||
self._task: asyncio.Task | None = None
|
self._task: asyncio.Task | None = None
|
||||||
|
# T32: nightly-backup tick loop runs alongside the job loop. The
|
||||||
|
# event is set by stop() to wake the loop early so shutdown is
|
||||||
|
# snappy even mid-tick.
|
||||||
|
self._tick_task: asyncio.Task | None = None
|
||||||
|
self._tick_stop: asyncio.Event = asyncio.Event()
|
||||||
self.enabled = enabled
|
self.enabled = enabled
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
if self._task is not None:
|
if self._task is not None:
|
||||||
return
|
return
|
||||||
self._task = asyncio.create_task(self._run())
|
self._task = asyncio.create_task(self._run())
|
||||||
|
self._tick_task = asyncio.create_task(self._tick_loop())
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
|
# Stop the tick loop first — it has no in-flight work to drain,
|
||||||
|
# so signalling early lets it exit while the job loop is still
|
||||||
|
# finishing its sentinel handoff.
|
||||||
|
self._tick_stop.set()
|
||||||
|
if self._tick_task is not None:
|
||||||
|
await self._tick_task
|
||||||
|
self._tick_task = None
|
||||||
if self._task is None:
|
if self._task is None:
|
||||||
return
|
return
|
||||||
await self._queue.put(None) # sentinel
|
await self._queue.put(None) # sentinel
|
||||||
@@ -99,6 +127,40 @@ class BackgroundWorker:
|
|||||||
except Exception as exc: # noqa: BLE001 — worker must not die
|
except Exception as exc: # noqa: BLE001 — worker must not die
|
||||||
log.exception("significance job failed: %s", exc)
|
log.exception("significance job failed: %s", exc)
|
||||||
|
|
||||||
|
async def _tick_loop(self) -> None:
|
||||||
|
"""Periodic-operations loop (T32 nightly backup).
|
||||||
|
|
||||||
|
Wakes every :data:`BACKUP_TICK_INTERVAL_SECONDS` seconds and
|
||||||
|
asks :func:`should_take_backup` whether a backup is due. The
|
||||||
|
scheduling decision lives in the backup module so we don't
|
||||||
|
duplicate the "is it 03:00?" logic here. Failures are caught
|
||||||
|
and logged so a flaky disk doesn't kill the loop — the next
|
||||||
|
tick will retry.
|
||||||
|
|
||||||
|
Wait uses :func:`asyncio.wait_for` on ``_tick_stop`` so that
|
||||||
|
:meth:`stop` can interrupt a sleeping tick instead of having to
|
||||||
|
wait the full interval.
|
||||||
|
"""
|
||||||
|
while not self._tick_stop.is_set():
|
||||||
|
try:
|
||||||
|
if should_take_backup(self._settings.data_dir):
|
||||||
|
take_backup(
|
||||||
|
db_path=self._settings.db_path,
|
||||||
|
data_dir=self._settings.data_dir,
|
||||||
|
)
|
||||||
|
prune_backups(self._settings.data_dir, keep=14)
|
||||||
|
log.info("nightly backup taken")
|
||||||
|
except Exception as exc: # noqa: BLE001 — never break the loop
|
||||||
|
log.exception("backup tick failed: %s", exc)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
self._tick_stop.wait(),
|
||||||
|
timeout=BACKUP_TICK_INTERVAL_SECONDS,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Normal path: timed out waiting for stop, run another tick.
|
||||||
|
pass
|
||||||
|
|
||||||
async def _process(self, job: SignificanceJob) -> None:
|
async def _process(self, job: SignificanceJob) -> None:
|
||||||
client = self._llm_client_factory()
|
client = self._llm_client_factory()
|
||||||
score = await compute_significance(
|
score = await compute_significance(
|
||||||
@@ -123,6 +185,33 @@ class BackgroundWorker:
|
|||||||
memory_id=job.memory_id,
|
memory_id=job.memory_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# T31: piggy-back the periodic snapshot check on the background
|
||||||
|
# worker so we don't need a separate timer task. The classifier
|
||||||
|
# pass already runs out-of-band, so snapshot I/O on the same
|
||||||
|
# worker is a natural fit. Each snapshot opens its own
|
||||||
|
# connection so we don't conflate the snapshot's read-only view
|
||||||
|
# with the significance-write transaction above. Failures are
|
||||||
|
# caught and logged: a flaky disk shouldn't take down the
|
||||||
|
# significance pipeline.
|
||||||
|
try:
|
||||||
|
with open_db(self._settings.db_path) as conn:
|
||||||
|
if should_take_periodic_snapshot(
|
||||||
|
conn, self._settings.data_dir
|
||||||
|
):
|
||||||
|
snapshot_path = take_snapshot(
|
||||||
|
conn,
|
||||||
|
data_dir=self._settings.data_dir,
|
||||||
|
kind="periodic",
|
||||||
|
)
|
||||||
|
prune_periodic_snapshots(
|
||||||
|
self._settings.data_dir, keep=5
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"periodic snapshot taken: %s", snapshot_path
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — never break the worker
|
||||||
|
log.exception("periodic snapshot failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
def _auto_pin_with_cap(
|
def _auto_pin_with_cap(
|
||||||
conn,
|
conn,
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Nightly DB backup service (T32, Requirements §12).
|
||||||
|
|
||||||
|
A simple in-process scheduler: at 03:00 local time daily, copy
|
||||||
|
``chat.db`` to ``data/backups/chat-<utc-timestamp>.db`` and prune to the
|
||||||
|
14 most recent. The BackgroundWorker tick loop calls
|
||||||
|
:func:`should_take_backup` every 60 seconds; when it returns True the
|
||||||
|
worker calls :func:`take_backup` then :func:`prune_backups`.
|
||||||
|
|
||||||
|
The launchd plist suggested in §12 can replace this later by invoking a
|
||||||
|
small script that calls :func:`take_backup` directly. For v1 the
|
||||||
|
in-process loop is enough — the daemon already runs continuously to
|
||||||
|
serve requests, so there's no extra moving part to install.
|
||||||
|
|
||||||
|
Backups capture the live ``.db`` file via :func:`shutil.copy2`. SQLite's
|
||||||
|
WAL mode means an in-flight transaction's pages might live in the
|
||||||
|
``-wal`` sidecar rather than the main file, but our codebase commits
|
||||||
|
every write transaction synchronously, so the .db alone is sufficient
|
||||||
|
for v1. A truly safe online backup would use
|
||||||
|
``sqlite3.Connection.backup()``; deferred.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 03:00 local time per Requirements §12. Hardcoded for v1 — making this
|
||||||
|
# configurable via Settings is straightforward but not needed yet.
|
||||||
|
DEFAULT_BACKUP_HOUR = 3
|
||||||
|
|
||||||
|
# Retention window per Requirements §12 ("Last 14 retained").
|
||||||
|
DEFAULT_KEEP = 14
|
||||||
|
|
||||||
|
# Wake interval for should_take_backup's freshness check. We wake the
|
||||||
|
# tick loop every 60s, so a backup taken in the previous tick within the
|
||||||
|
# same target hour must NOT trigger another. 23h gives us a generous
|
||||||
|
# safety margin against scheduling jitter while still allowing a single
|
||||||
|
# backup per day.
|
||||||
|
FRESHNESS_HOURS = 23
|
||||||
|
|
||||||
|
|
||||||
|
def take_backup(*, db_path: Path, data_dir: Path) -> Path:
|
||||||
|
"""Copy ``db_path`` to ``data_dir/backups/chat-<utc-timestamp>.db``.
|
||||||
|
|
||||||
|
Returns the new file path. Creates the backup directory if missing.
|
||||||
|
Uses :func:`shutil.copy2` so the destination's mtime is preserved —
|
||||||
|
:func:`should_take_backup` reads mtime to gate fresh backups.
|
||||||
|
"""
|
||||||
|
backup_dir = data_dir / "backups"
|
||||||
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
backup_path = backup_dir / f"chat-{timestamp}.db"
|
||||||
|
shutil.copy2(db_path, backup_path)
|
||||||
|
return backup_path
|
||||||
|
|
||||||
|
|
||||||
|
def prune_backups(data_dir: Path, *, keep: int = DEFAULT_KEEP) -> int:
|
||||||
|
"""Remove all but the most recent ``keep`` backup files.
|
||||||
|
|
||||||
|
Returns the number of files removed. Safe when the directory is
|
||||||
|
missing (returns 0). Sorting is by filename, which is the UTC
|
||||||
|
timestamp embedded in the name — lexicographic order matches
|
||||||
|
chronological order.
|
||||||
|
"""
|
||||||
|
backup_dir = data_dir / "backups"
|
||||||
|
if not backup_dir.exists():
|
||||||
|
return 0
|
||||||
|
files = sorted(backup_dir.glob("chat-*.db"))
|
||||||
|
to_remove = files[:-keep] if len(files) > keep else []
|
||||||
|
for f in to_remove:
|
||||||
|
f.unlink()
|
||||||
|
return len(to_remove)
|
||||||
|
|
||||||
|
|
||||||
|
def should_take_backup(
|
||||||
|
data_dir: Path, *, target_hour: int = DEFAULT_BACKUP_HOUR
|
||||||
|
) -> bool:
|
||||||
|
"""Decide whether a nightly backup is due.
|
||||||
|
|
||||||
|
Two conditions must hold:
|
||||||
|
|
||||||
|
* The current local hour matches ``target_hour``.
|
||||||
|
* No backup file in ``data_dir/backups/`` has an mtime within the
|
||||||
|
last :data:`FRESHNESS_HOURS` (23h). The 23h window prevents a
|
||||||
|
double-backup within the same target hour while still allowing
|
||||||
|
the next day's run to fire on time.
|
||||||
|
|
||||||
|
Local time (not UTC) is used for the hour comparison per the
|
||||||
|
requirements ("03:00 local time"). The filename embeds a UTC stamp
|
||||||
|
so file ordering remains unambiguous across DST transitions.
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
if now.hour != target_hour:
|
||||||
|
return False
|
||||||
|
backup_dir = data_dir / "backups"
|
||||||
|
if not backup_dir.exists():
|
||||||
|
return True
|
||||||
|
files = list(backup_dir.glob("chat-*.db"))
|
||||||
|
if not files:
|
||||||
|
return True
|
||||||
|
most_recent = max(files, key=lambda f: f.stat().st_mtime)
|
||||||
|
age_hours = (
|
||||||
|
datetime.now().timestamp() - most_recent.stat().st_mtime
|
||||||
|
) / 3600
|
||||||
|
return age_hours >= FRESHNESS_HOURS
|
||||||
+154
-9
@@ -1,26 +1,42 @@
|
|||||||
"""Snapshot service — write a JSON dump of all projected tables to disk.
|
"""Snapshot service — write a JSON dump of all projected tables to disk.
|
||||||
|
|
||||||
Used by the rewind flow (Requirements §10.1, T28) so the user can recover a
|
Two snapshot kinds, both covered by this module:
|
||||||
pre-rewind state if the rewind was a mistake. Stored under
|
|
||||||
``data/snapshots/{kind}/`` with a UTC timestamp filename.
|
|
||||||
|
|
||||||
The dump captures both the event log (so the original event sequence is
|
* ``rewind`` (T28, Requirements §10.1): pre-rewind safety snapshot so the
|
||||||
preserved verbatim) and every projected table (so a future restore could
|
user can recover if a rewind was a mistake. Retention: 14 days.
|
||||||
either re-load tables directly or re-project from the saved event log).
|
* ``periodic`` (T31, Requirements §10.4): full-state checkpoint taken
|
||||||
|
every 100 events OR every 30 minutes since the last one. Retention:
|
||||||
|
the most recent 5 are kept; older ones are pruned on write.
|
||||||
|
|
||||||
|
Both kinds live under ``data/snapshots/{kind}/`` with a UTC timestamp
|
||||||
|
filename so chronological listing matches creation order.
|
||||||
|
|
||||||
|
The dump captures the event log (so the original event sequence is
|
||||||
|
preserved verbatim), every projected table, and a top-level
|
||||||
|
``last_event_id`` recording the highest ``event_log.id`` at snapshot
|
||||||
|
time. The ``last_event_id`` is what the cold-load fast-path uses to
|
||||||
|
replay only events past the snapshot rather than the entire log.
|
||||||
|
|
||||||
The FTS shadow table ``memories_fts`` is intentionally skipped — it's a
|
The FTS shadow table ``memories_fts`` is intentionally skipped — it's a
|
||||||
virtual table maintained by the ``memories_ai/au/ad`` triggers, so it would
|
virtual table maintained by the ``memories_ai/au/ad`` triggers, so it
|
||||||
rebuild itself on a memories re-load. Snapshotting it would also fail
|
rebuilds itself on a memories re-load. Snapshotting it would also fail
|
||||||
``PRAGMA table_info`` cleanly since FTS5 reports its columns differently.
|
``PRAGMA table_info`` cleanly since FTS5 reports its columns differently.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sqlite3 import Connection
|
from sqlite3 import Connection
|
||||||
|
|
||||||
|
# Periodic snapshot triggers (Requirements §10.4): "every 100 events OR
|
||||||
|
# every 30 minutes since last snapshot". Module-level so tests can read
|
||||||
|
# them and so the values stay together with the policy that uses them.
|
||||||
|
EVENT_COUNT_THRESHOLD = 100
|
||||||
|
TIME_THRESHOLD_SECONDS = 30 * 60 # 30 minutes
|
||||||
|
|
||||||
# Order doesn't affect correctness for snapshotting (we read, not write),
|
# Order doesn't affect correctness for snapshotting (we read, not write),
|
||||||
# but listing tables explicitly keeps the snapshot stable across schema
|
# but listing tables explicitly keeps the snapshot stable across schema
|
||||||
# evolution: a new table won't silently change the dump shape until it's
|
# evolution: a new table won't silently change the dump shape until it's
|
||||||
@@ -49,13 +65,24 @@ def take_snapshot(
|
|||||||
directories as needed. Filename is a UTC timestamp in
|
directories as needed. Filename is a UTC timestamp in
|
||||||
``YYYYMMDDTHHMMSSZ`` form so chronological listing matches creation
|
``YYYYMMDDTHHMMSSZ`` form so chronological listing matches creation
|
||||||
order.
|
order.
|
||||||
|
|
||||||
|
The dump's top-level ``last_event_id`` is the highest ``event_log.id``
|
||||||
|
at snapshot time (0 if the log is empty). This is what the cold-load
|
||||||
|
fast-path uses to know which suffix of the log to replay.
|
||||||
"""
|
"""
|
||||||
snapshot_dir = data_dir / "snapshots" / kind
|
snapshot_dir = data_dir / "snapshots" / kind
|
||||||
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
path = snapshot_dir / f"{timestamp}.json"
|
path = snapshot_dir / f"{timestamp}.json"
|
||||||
|
|
||||||
dump: dict[str, list] = {}
|
dump: dict = {}
|
||||||
|
|
||||||
|
# Record the high-water-mark id up front so cold-load can replay
|
||||||
|
# only events past it. ``MAX(id)`` is None on an empty log; treat
|
||||||
|
# that as 0 (i.e. "replay everything").
|
||||||
|
cur = conn.execute("SELECT MAX(id) FROM event_log")
|
||||||
|
max_id_row = cur.fetchone()
|
||||||
|
dump["last_event_id"] = max_id_row[0] if max_id_row[0] is not None else 0
|
||||||
|
|
||||||
# Event log: pull every column we care about. ``ts`` and the
|
# Event log: pull every column we care about. ``ts`` and the
|
||||||
# superseded/hidden flags are needed to faithfully reconstruct the
|
# superseded/hidden flags are needed to faithfully reconstruct the
|
||||||
@@ -98,3 +125,121 @@ def take_snapshot(
|
|||||||
# all use TEXT so this is mostly defensive.
|
# all use TEXT so this is mostly defensive.
|
||||||
path.write_text(json.dumps(dump, default=str))
|
path.write_text(json.dumps(dump, default=str))
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def latest_snapshot_path(data_dir: Path, kind: str = "periodic") -> Path | None:
|
||||||
|
"""Return the most recent snapshot file for ``kind``, or None if none exist.
|
||||||
|
|
||||||
|
Sorting by filename works because :func:`take_snapshot` uses a UTC
|
||||||
|
timestamp in ``YYYYMMDDTHHMMSSZ`` form — lexicographic order matches
|
||||||
|
chronological order.
|
||||||
|
"""
|
||||||
|
snapshot_dir = data_dir / "snapshots" / kind
|
||||||
|
if not snapshot_dir.exists():
|
||||||
|
return None
|
||||||
|
files = sorted(snapshot_dir.glob("*.json"))
|
||||||
|
return files[-1] if files else None
|
||||||
|
|
||||||
|
|
||||||
|
def should_take_periodic_snapshot(
|
||||||
|
conn: Connection, data_dir: Path
|
||||||
|
) -> bool:
|
||||||
|
"""Decide whether a periodic snapshot is due per Requirements §10.4.
|
||||||
|
|
||||||
|
The policy:
|
||||||
|
|
||||||
|
* No prior snapshot and at least one event in the log → take one.
|
||||||
|
* Time since last snapshot ≥ ``TIME_THRESHOLD_SECONDS`` → take one.
|
||||||
|
* New events since last snapshot's ``last_event_id`` ≥
|
||||||
|
``EVENT_COUNT_THRESHOLD`` → take one.
|
||||||
|
|
||||||
|
"Time since last snapshot" is measured by the file's mtime — we
|
||||||
|
don't trust the timestamp embedded in the filename for clock drift
|
||||||
|
reasons.
|
||||||
|
"""
|
||||||
|
latest = latest_snapshot_path(data_dir, kind="periodic")
|
||||||
|
if latest is None:
|
||||||
|
# No prior snapshot; take one if there are any events to capture.
|
||||||
|
cur = conn.execute("SELECT COUNT(*) FROM event_log")
|
||||||
|
return cur.fetchone()[0] > 0
|
||||||
|
|
||||||
|
age_seconds = time.time() - latest.stat().st_mtime
|
||||||
|
if age_seconds >= TIME_THRESHOLD_SECONDS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Count events appended since the last snapshot was written. Reading
|
||||||
|
# ``last_event_id`` from the dump is cheap (a few KB at most for the
|
||||||
|
# header) but we still avoid loading the full file by parsing once.
|
||||||
|
last_dump = json.loads(latest.read_text())
|
||||||
|
last_event_id = last_dump.get("last_event_id", 0)
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM event_log WHERE id > ?", (last_event_id,)
|
||||||
|
)
|
||||||
|
new_event_count = cur.fetchone()[0]
|
||||||
|
return new_event_count >= EVENT_COUNT_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
def prune_periodic_snapshots(data_dir: Path, keep: int = 5) -> int:
|
||||||
|
"""Delete all but the most recent ``keep`` periodic snapshots.
|
||||||
|
|
||||||
|
Returns the number of files removed. Safe to call when the directory
|
||||||
|
doesn't exist (returns 0). Sorting is by filename, which is the UTC
|
||||||
|
timestamp — same ordering :func:`latest_snapshot_path` uses.
|
||||||
|
"""
|
||||||
|
snapshot_dir = data_dir / "snapshots" / "periodic"
|
||||||
|
if not snapshot_dir.exists():
|
||||||
|
return 0
|
||||||
|
files = sorted(snapshot_dir.glob("*.json"))
|
||||||
|
to_remove = files[:-keep] if len(files) > keep else []
|
||||||
|
for f in to_remove:
|
||||||
|
f.unlink()
|
||||||
|
return len(to_remove)
|
||||||
|
|
||||||
|
|
||||||
|
def restore_from_snapshot(conn: Connection, snapshot_path: Path) -> int:
|
||||||
|
"""Restore projected tables from ``snapshot_path``.
|
||||||
|
|
||||||
|
Returns the snapshot's ``last_event_id`` so callers (the cold-load
|
||||||
|
fast-path in :func:`chat.app.lifespan`) know what suffix of the
|
||||||
|
event log still needs replaying.
|
||||||
|
|
||||||
|
Projected tables are cleared in the same FK-respecting order as
|
||||||
|
:func:`chat.services.rewind.execute_rewind`, then re-populated from
|
||||||
|
the dump. ``memories_fts`` is skipped — it's a virtual FTS5 table
|
||||||
|
that rebuilds itself when rows hit ``memories``. The event log
|
||||||
|
itself is *not* touched: cold-load assumes the on-disk log is the
|
||||||
|
source of truth and the snapshot is just a fast-forward to skip
|
||||||
|
re-projecting old events.
|
||||||
|
"""
|
||||||
|
dump = json.loads(snapshot_path.read_text())
|
||||||
|
|
||||||
|
# Same delete order as rewind: child tables before parents so FK
|
||||||
|
# ON DELETE doesn't fire on referenced rows.
|
||||||
|
conn.execute("DELETE FROM memories")
|
||||||
|
conn.execute("DELETE FROM activity")
|
||||||
|
conn.execute("DELETE FROM scenes")
|
||||||
|
conn.execute("DELETE FROM containers")
|
||||||
|
conn.execute("DELETE FROM chat_state")
|
||||||
|
conn.execute("DELETE FROM chats")
|
||||||
|
conn.execute("DELETE FROM edges")
|
||||||
|
conn.execute("DELETE FROM bots")
|
||||||
|
conn.execute("DELETE FROM you_entity")
|
||||||
|
conn.execute("DELETE FROM classifier_failures")
|
||||||
|
|
||||||
|
for table in PROJECTED_TABLES:
|
||||||
|
if table == "memories_fts":
|
||||||
|
# Rebuilt by triggers when memories rows are inserted below.
|
||||||
|
continue
|
||||||
|
rows = dump.get(table, [])
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
cols = list(rows[0].keys())
|
||||||
|
placeholders = ", ".join("?" * len(cols))
|
||||||
|
col_list = ", ".join(cols)
|
||||||
|
for row in rows:
|
||||||
|
conn.execute(
|
||||||
|
f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})",
|
||||||
|
tuple(row[c] for c in cols),
|
||||||
|
)
|
||||||
|
|
||||||
|
return dump.get("last_event_id", 0)
|
||||||
|
|||||||
@@ -75,6 +75,30 @@ code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
|||||||
.drawer-toggle { padding: 4px 10px; border: 1px solid #ccc; background: #fff; color: #1c1c1c; border-radius: 3px; cursor: pointer; }
|
.drawer-toggle { padding: 4px 10px; border: 1px solid #ccc; background: #fff; color: #1c1c1c; border-radius: 3px; cursor: pointer; }
|
||||||
.timeline { flex: 1; overflow-y: auto; min-height: 200px; padding: 8px 0; }
|
.timeline { flex: 1; overflow-y: auto; min-height: 200px; padding: 8px 0; }
|
||||||
.turn { margin: 12px 0; }
|
.turn { margin: 12px 0; }
|
||||||
|
.turn strong { display: block; margin-bottom: 4px; }
|
||||||
|
.turn p { margin: 0 0 8px; }
|
||||||
|
.turn p:last-child { margin-bottom: 0; }
|
||||||
|
.turn-you strong { color: #1a73e8; }
|
||||||
|
.turn-bot strong { color: #1c1c1c; }
|
||||||
|
/* ``*action*`` — italic narration. */
|
||||||
|
.action { font-style: italic; color: #555; }
|
||||||
|
/* ``((ooc))`` — author-to-system aside. Dim, italic, smaller, set off
|
||||||
|
from surrounding prose so it doesn't read as in-fiction speech. */
|
||||||
|
.ooc {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.turn blockquote {
|
||||||
|
border-left: 3px solid #ccc;
|
||||||
|
padding-left: 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
.turn-input { display: flex; flex-direction: column; gap: 8px; padding-top: 12px; border-top: 1px solid #e5e5e5; }
|
.turn-input { display: flex; flex-direction: column; gap: 8px; padding-top: 12px; border-top: 1px solid #e5e5e5; }
|
||||||
.turn-input textarea { padding: 8px; font: inherit; border: 1px solid #ccc; border-radius: 3px; resize: vertical; }
|
.turn-input textarea { padding: 8px; font: inherit; border: 1px solid #ccc; border-radius: 3px; resize: vertical; }
|
||||||
.drawer { position: fixed; top: 0; right: 0; width: 360px; height: 100vh; background: #fff; border-left: 1px solid #e5e5e5; padding: 16px; overflow-y: auto; z-index: 10; }
|
.drawer { position: fixed; top: 0; right: 0; width: 360px; height: 100vh; background: #fff; border-left: 1px solid #e5e5e5; padding: 16px; overflow-y: auto; z-index: 10; }
|
||||||
@@ -89,3 +113,13 @@ code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
|||||||
.memory-list li { padding: 4px 0; font-size: 13px; }
|
.memory-list li { padding: 4px 0; font-size: 13px; }
|
||||||
.sig { display: inline-block; min-width: 16px; }
|
.sig { display: inline-block; min-width: 16px; }
|
||||||
.sig-3 { color: #d4af37; }
|
.sig-3 { color: #d4af37; }
|
||||||
|
/* Streaming UX (T34): typing indicator, Stop button, disconnect banner. */
|
||||||
|
.streaming { opacity: 0.85; }
|
||||||
|
.streaming-text:after {
|
||||||
|
content: "\025AE";
|
||||||
|
margin-left: 2px;
|
||||||
|
animation: blink 1s steps(2, start) infinite;
|
||||||
|
}
|
||||||
|
@keyframes blink { to { visibility: hidden; } }
|
||||||
|
.stop-streaming { background: #c33; border-color: #a00; margin-bottom: 8px; align-self: flex-start; }
|
||||||
|
.connection-lost { margin-bottom: 8px; }
|
||||||
|
|||||||
+104
-1
@@ -19,7 +19,7 @@
|
|||||||
{% for turn in turns %}
|
{% for turn in turns %}
|
||||||
<div class="turn turn-{{ turn.role }}">
|
<div class="turn turn-{{ turn.role }}">
|
||||||
<strong>{{ turn.speaker }}</strong>
|
<strong>{{ turn.speaker }}</strong>
|
||||||
<p>{{ turn.text }}</p>
|
{{ turn.text|render_prose|safe }}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -46,4 +46,107 @@ document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => {
|
|||||||
e.target.setAttribute('aria-expanded', String(isHidden));
|
e.target.setAttribute('aria-expanded', String(isHidden));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
// Streaming UX (T34): typing indicator, Stop button, send-lock,
|
||||||
|
// disconnect banner. Listens to the existing HTMX SSE channel for
|
||||||
|
// `token` (per-chunk) and `turn_html` (final swap) events. The
|
||||||
|
// mid-stream disconnect path is server-side: ``request.is_disconnected()``
|
||||||
|
// in T19 commits truncated; this script just shows the banner when
|
||||||
|
// the SSE EventSource fires `error` after the connection drops.
|
||||||
|
(function () {
|
||||||
|
const shell = document.querySelector('.chat-shell');
|
||||||
|
if (!shell) return;
|
||||||
|
const chatId = shell.dataset.chatId;
|
||||||
|
const form = shell.querySelector('.turn-input');
|
||||||
|
if (!form) return;
|
||||||
|
const textarea = form.querySelector('textarea[name="prose"]');
|
||||||
|
const sendBtn = form.querySelector('button[type="submit"]');
|
||||||
|
const timeline = document.getElementById('timeline');
|
||||||
|
|
||||||
|
let isStreaming = false;
|
||||||
|
let typingEl = null;
|
||||||
|
|
||||||
|
function ensureTypingEl() {
|
||||||
|
if (typingEl) return typingEl;
|
||||||
|
typingEl = document.createElement('div');
|
||||||
|
typingEl.className = 'turn turn-bot streaming';
|
||||||
|
typingEl.innerHTML = '<strong>...</strong><p class="streaming-text"></p>';
|
||||||
|
timeline.appendChild(typingEl);
|
||||||
|
return typingEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlock() {
|
||||||
|
isStreaming = false;
|
||||||
|
if (sendBtn) sendBtn.disabled = false;
|
||||||
|
if (textarea) textarea.disabled = false;
|
||||||
|
const stop = shell.querySelector('.stop-streaming');
|
||||||
|
if (stop) stop.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBanner(msg) {
|
||||||
|
let banner = shell.querySelector('.connection-lost');
|
||||||
|
if (banner) return;
|
||||||
|
banner = document.createElement('div');
|
||||||
|
banner.className = 'connection-lost error';
|
||||||
|
banner.textContent = msg;
|
||||||
|
form.parentElement.insertBefore(banner, form);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTMX SSE extension dispatches `htmx:sseMessage` with detail.type
|
||||||
|
// (event name) and detail.data (payload string).
|
||||||
|
shell.addEventListener('htmx:sseMessage', (e) => {
|
||||||
|
const evt = e.detail.type;
|
||||||
|
const data = e.detail.data;
|
||||||
|
if (evt === 'token' && isStreaming) {
|
||||||
|
let parsed;
|
||||||
|
try { parsed = JSON.parse(data); } catch (_) { return; }
|
||||||
|
const el = ensureTypingEl();
|
||||||
|
el.querySelector('.streaming-text').textContent += (parsed.text || '');
|
||||||
|
} else if (evt === 'turn_html') {
|
||||||
|
// The server already pushes the final HTML via sse-swap on the
|
||||||
|
// timeline element; we just remove the typing placeholder and
|
||||||
|
// unlock the input. (Don't replace innerHTML here — HTMX has
|
||||||
|
// already done the append by the time this fires.)
|
||||||
|
if (typingEl) {
|
||||||
|
typingEl.remove();
|
||||||
|
typingEl = null;
|
||||||
|
}
|
||||||
|
unlock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SSE connection lost — show a banner and unlock so the user can
|
||||||
|
// retry. The server commits the partial as truncated when its
|
||||||
|
// request.is_disconnected() poll trips (T19).
|
||||||
|
shell.addEventListener('htmx:sseError', () => {
|
||||||
|
if (isStreaming) {
|
||||||
|
showBanner('connection lost — partial response saved');
|
||||||
|
unlock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', () => {
|
||||||
|
isStreaming = true;
|
||||||
|
if (sendBtn) sendBtn.disabled = true;
|
||||||
|
if (textarea) textarea.disabled = true;
|
||||||
|
if (!shell.querySelector('.stop-streaming')) {
|
||||||
|
const stopBtn = document.createElement('button');
|
||||||
|
stopBtn.type = 'button';
|
||||||
|
stopBtn.className = 'stop-streaming btn';
|
||||||
|
stopBtn.textContent = 'Stop';
|
||||||
|
stopBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/chats/' + encodeURIComponent(chatId) + '/turns/cancel', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Network error on cancel is non-fatal — server will time out
|
||||||
|
// its own stream eventually and commit truncated.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
form.parentElement.insertBefore(stopBtn, form);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block title %}Error - chat{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="error-page">
|
||||||
|
<h1>{{ status_code }}</h1>
|
||||||
|
<p>{{ detail }}</p>
|
||||||
|
<p><a href="/chats">Back to chats</a></p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -16,11 +16,17 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from chat.state.entities import get_bot
|
from chat.state.entities import get_bot
|
||||||
from chat.state.world import get_chat
|
from chat.state.world import get_chat
|
||||||
from chat.web.bots import get_conn
|
from chat.web.bots import get_conn
|
||||||
|
from chat.web.render import render_prose
|
||||||
from chat.web.turns import _read_recent_dialogue
|
from chat.web.turns import _read_recent_dialogue
|
||||||
|
|
||||||
TEMPLATES = Jinja2Templates(
|
TEMPLATES = Jinja2Templates(
|
||||||
directory=str(Path(__file__).resolve().parent.parent / "templates")
|
directory=str(Path(__file__).resolve().parent.parent / "templates")
|
||||||
)
|
)
|
||||||
|
# Register the prose renderer as a Jinja filter so the chat-detail
|
||||||
|
# template can use ``{{ turn.text|render_prose|safe }}`` (Task 33).
|
||||||
|
# The renderer escapes user content internally; ``|safe`` is required
|
||||||
|
# because the output contains intentional ``<p>``/``<em>``/etc. tags.
|
||||||
|
TEMPLATES.env.filters["render_prose"] = render_prose
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
from chat.state.entities import get_you, list_bots
|
||||||
|
|
||||||
|
|
||||||
|
class FirstRunRedirectMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Redirect users through the first-run flow (per requirements §16.2).
|
||||||
|
|
||||||
|
Behavior on GET requests to landing routes (``/`` and ``/chats``):
|
||||||
|
|
||||||
|
- No ``you_entity`` → ``/settings``
|
||||||
|
- ``you_entity`` exists but no bots → ``/bots/new``
|
||||||
|
- Otherwise pass through to the underlying handler.
|
||||||
|
|
||||||
|
The middleware is a no-op for:
|
||||||
|
|
||||||
|
- Non-GET requests (POST/PUT writes proceed and surface their own errors).
|
||||||
|
- Static assets, health checks, and any path under ``/settings``,
|
||||||
|
``/bots``, ``/api``, ``/health``, ``/favicon`` — so the user can
|
||||||
|
actually complete setup once redirected.
|
||||||
|
- Sub-paths of ``/chats`` (e.g. ``/chats/<id>``, ``/chats/<id>/drawer``);
|
||||||
|
only the bare landing pages get the redirect treatment. Sub-resources
|
||||||
|
either 404 cleanly or are HTMX partials that should not page-redirect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SKIP_PREFIXES = (
|
||||||
|
"/static",
|
||||||
|
"/settings",
|
||||||
|
"/bots",
|
||||||
|
"/health",
|
||||||
|
"/favicon",
|
||||||
|
"/api",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
if request.method != "GET":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
path = request.url.path
|
||||||
|
if any(path.startswith(p) for p in self.SKIP_PREFIXES):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Only fire on the landing routes themselves.
|
||||||
|
if path != "/" and path != "/chats":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
settings = request.app.state.settings
|
||||||
|
with open_db(settings.db_path) as conn:
|
||||||
|
you = get_you(conn)
|
||||||
|
bots = list_bots(conn)
|
||||||
|
|
||||||
|
if you is None:
|
||||||
|
return RedirectResponse(url="/settings", status_code=303)
|
||||||
|
if not bots:
|
||||||
|
return RedirectResponse(url="/bots/new", status_code=303)
|
||||||
|
return await call_next(request)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Transcript display formatting (Task 33, Requirements §16.3).
|
||||||
|
|
||||||
|
Bot and user prose is rendered with **lightweight markdown**:
|
||||||
|
|
||||||
|
* ``*action*`` → ``<em class="action">…</em>`` — italic narration.
|
||||||
|
* ``**bold**`` → ``<strong>…</strong>`` — emphasis.
|
||||||
|
* ``((ooc))`` → ``<span class="ooc">((ooc))</span>`` — author-to-system
|
||||||
|
asides; visible to the reader, dimmed/italic in CSS, and stripped from
|
||||||
|
the prompt sent to the bot (see :func:`chat.web.turns._strip_ooc_for_prompt`).
|
||||||
|
* ``> line`` → ``<blockquote>line</blockquote>``.
|
||||||
|
* Double newline → paragraph break.
|
||||||
|
* Everything else is HTML-escaped and wrapped in ``<p>…</p>``.
|
||||||
|
|
||||||
|
No headings, code blocks, links, images, or tables — out of scope per
|
||||||
|
Requirements §16.3. The renderer is the single source of truth used by
|
||||||
|
both the chat-detail GET (initial timeline render, via Jinja filter) and
|
||||||
|
the per-turn SSE fragments emitted from :mod:`chat.web.turns`.
|
||||||
|
|
||||||
|
Order of operations matters:
|
||||||
|
|
||||||
|
1. ``html.escape`` the whole input first — every replacement below assumes
|
||||||
|
user-supplied ``<``/``>``/``&`` are already neutralised, so the wrapper
|
||||||
|
tags we add can never collide with an attacker-controlled tag.
|
||||||
|
2. OOC wrap before action/bold so its inner ``*`` are not interpreted.
|
||||||
|
3. Bold (``**``) before action (``*``) — the bold pattern is stricter and
|
||||||
|
would otherwise be partially consumed by the action regex.
|
||||||
|
4. Blockquote pass over already-escaped lines (so we match ``>``).
|
||||||
|
5. Paragraph split on double newline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
|
||||||
|
# ``((…))`` — non-greedy, allows newlines so a multi-line OOC aside still
|
||||||
|
# wraps cleanly. The inner ``[^)]*?`` keeps it from spanning across a
|
||||||
|
# closing-paren boundary.
|
||||||
|
_OOC_PATTERN = re.compile(r"\(\([^)]*?\)\)", re.DOTALL)
|
||||||
|
|
||||||
|
# ``**bold**`` — strict: no embedded asterisks or newlines. Must run
|
||||||
|
# *before* the single-asterisk action pattern, otherwise ``**x**`` would
|
||||||
|
# be partly consumed by ``*…*``.
|
||||||
|
_BOLD_PATTERN = re.compile(r"\*\*([^*\n]+)\*\*")
|
||||||
|
|
||||||
|
# ``*action*`` — single-asterisk italics; same restriction as bold.
|
||||||
|
_ACTION_PATTERN = re.compile(r"\*([^*\n]+)\*")
|
||||||
|
|
||||||
|
# ``> line`` at start of a line — note we match the *escaped* form
|
||||||
|
# ``>`` because this pass runs after ``html.escape``.
|
||||||
|
_BLOCKQUOTE_PATTERN = re.compile(r"^>\s?(.+)$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def render_prose(text: str) -> str:
|
||||||
|
"""Render prose to safe HTML.
|
||||||
|
|
||||||
|
Returns an empty string for empty/whitespace-only input so the caller
|
||||||
|
can append the result without producing stray ``<p></p>`` tags.
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Normalise CRLF so paragraph splitting on ``\n\n`` works for input
|
||||||
|
# pasted from Windows clients.
|
||||||
|
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
|
||||||
|
escaped = html.escape(text)
|
||||||
|
|
||||||
|
# OOC first — the wrapped span survives subsequent passes.
|
||||||
|
escaped = _OOC_PATTERN.sub(
|
||||||
|
lambda m: f'<span class="ooc">{m.group(0)}</span>', escaped
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bold strictly before action (regex precedence — see module docstring).
|
||||||
|
escaped = _BOLD_PATTERN.sub(r"<strong>\1</strong>", escaped)
|
||||||
|
escaped = _ACTION_PATTERN.sub(r'<em class="action">\1</em>', escaped)
|
||||||
|
|
||||||
|
# Blockquote on already-escaped ``>`` markers.
|
||||||
|
escaped = _BLOCKQUOTE_PATTERN.sub(r"<blockquote>\1</blockquote>", escaped)
|
||||||
|
|
||||||
|
# Paragraph splitting — drop empty fragments so a trailing ``\n\n``
|
||||||
|
# doesn't yield an empty ``<p></p>`` block.
|
||||||
|
paragraphs = [p.strip() for p in escaped.split("\n\n") if p.strip()]
|
||||||
|
return "".join(f"<p>{p}</p>" for p in paragraphs)
|
||||||
|
|
||||||
|
|
||||||
|
def render_turn_html(speaker: str, text: str, role: str = "bot") -> str:
|
||||||
|
"""Render a full transcript turn as ``<div class="turn …">…</div>``.
|
||||||
|
|
||||||
|
Used by both the SSE fragment publisher in :mod:`chat.web.turns`
|
||||||
|
(per-turn live updates) and indirectly by the chat-detail Jinja
|
||||||
|
template (initial render, via the ``render_prose`` filter).
|
||||||
|
|
||||||
|
``role`` selects the CSS class (``turn-you`` vs ``turn-bot``); the
|
||||||
|
speaker label and role name are HTML-escaped defensively even though
|
||||||
|
they currently come from trusted server-side state.
|
||||||
|
"""
|
||||||
|
speaker_html = html.escape(speaker)
|
||||||
|
role_html = html.escape(role)
|
||||||
|
body_html = render_prose(text)
|
||||||
|
return (
|
||||||
|
f'<div class="turn turn-{role_html}">'
|
||||||
|
f"<strong>{speaker_html}</strong>"
|
||||||
|
f"{body_html}"
|
||||||
|
f"</div>"
|
||||||
|
)
|
||||||
+57
-19
@@ -53,10 +53,19 @@ from chat.state.world import active_scene, get_chat, get_container
|
|||||||
from chat.web.bots import get_conn
|
from chat.web.bots import get_conn
|
||||||
from chat.web.kickoff import get_llm_client
|
from chat.web.kickoff import get_llm_client
|
||||||
from chat.web.pubsub import publish
|
from chat.web.pubsub import publish
|
||||||
|
from chat.web.render import render_turn_html as _render_turn_html
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level registry of in-flight streaming tasks, keyed by chat_id.
|
||||||
|
# The POST /chats/<id>/turns/cancel route looks up the task and calls
|
||||||
|
# .cancel(); the streaming coroutine in post_turn catches the resulting
|
||||||
|
# CancelledError, commits the partial as truncated, and unregisters.
|
||||||
|
# Single-process v1 only — sufficient for one user with multiple tabs.
|
||||||
|
_in_flight_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
|
||||||
|
|
||||||
def _strip_ooc_for_prompt(parsed: ParsedTurn) -> str:
|
def _strip_ooc_for_prompt(parsed: ParsedTurn) -> str:
|
||||||
"""Concatenate non-OOC segments back to a prose string for the prompt.
|
"""Concatenate non-OOC segments back to a prose string for the prompt.
|
||||||
|
|
||||||
@@ -69,16 +78,17 @@ def _strip_ooc_for_prompt(parsed: ParsedTurn) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _read_recent_dialogue(conn, chat_id: str, limit: int = 200) -> list[dict]:
|
def _read_recent_dialogue(conn, chat_id: str, limit: int = 200) -> list[dict]:
|
||||||
"""Return ``user_turn`` and ``assistant_turn`` events for ``chat_id``.
|
"""Return user-side and assistant_turn events for ``chat_id``.
|
||||||
|
|
||||||
Ordered oldest-first. Skips superseded and hidden rows so regenerated
|
Includes ``user_turn``, ``user_turn_edit`` (T29 edited prose), and
|
||||||
turns (T29) drop out of the rendered timeline. Each entry is shaped
|
``assistant_turn``. Ordered oldest-first; superseded/hidden rows are
|
||||||
``{"speaker": <id-or-"you">, "text": <prose>}`` for the prompt
|
skipped so regenerated turns (T29) drop out of the rendered timeline.
|
||||||
assembler and the chat-detail template.
|
Each entry is shaped ``{"speaker": <id-or-"you">, "text": <prose>}``
|
||||||
|
for the prompt assembler and the chat-detail template.
|
||||||
"""
|
"""
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"SELECT id, kind, payload_json FROM event_log "
|
"SELECT id, kind, payload_json FROM event_log "
|
||||||
"WHERE kind IN ('user_turn', 'assistant_turn') "
|
"WHERE kind IN ('user_turn', 'user_turn_edit', 'assistant_turn') "
|
||||||
" AND superseded_by IS NULL AND hidden = 0 "
|
" AND superseded_by IS NULL AND hidden = 0 "
|
||||||
"ORDER BY id DESC LIMIT ?",
|
"ORDER BY id DESC LIMIT ?",
|
||||||
(limit,),
|
(limit,),
|
||||||
@@ -90,7 +100,9 @@ def _read_recent_dialogue(conn, chat_id: str, limit: int = 200) -> list[dict]:
|
|||||||
p = json.loads(payload_json)
|
p = json.loads(payload_json)
|
||||||
if p.get("chat_id") != chat_id:
|
if p.get("chat_id") != chat_id:
|
||||||
continue
|
continue
|
||||||
if kind == "user_turn":
|
if kind in ("user_turn", "user_turn_edit"):
|
||||||
|
# Edited prose substitutes for the original user_turn (the
|
||||||
|
# original is marked superseded_by and filtered above).
|
||||||
out.append({"speaker": "you", "text": p.get("prose", "")})
|
out.append({"speaker": "you", "text": p.get("prose", "")})
|
||||||
else:
|
else:
|
||||||
out.append(
|
out.append(
|
||||||
@@ -102,16 +114,6 @@ def _read_recent_dialogue(conn, chat_id: str, limit: int = 200) -> list[dict]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _render_turn_html(speaker_label: str, text: str, *, role: str) -> str:
|
|
||||||
"""Render a single turn as a small HTML fragment (escaped)."""
|
|
||||||
return (
|
|
||||||
f'<div class="turn turn-{role}">'
|
|
||||||
f"<strong>{html.escape(speaker_label)}</strong>"
|
|
||||||
f"<p>{html.escape(text)}</p>"
|
|
||||||
f"</div>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chats/{chat_id}/turns")
|
@router.post("/chats/{chat_id}/turns")
|
||||||
async def post_turn(
|
async def post_turn(
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
@@ -182,11 +184,16 @@ async def post_turn(
|
|||||||
budget_hard=settings.narrative_budget_hard,
|
budget_hard=settings.narrative_budget_hard,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. Stream and accumulate tokens.
|
# 5. Stream and accumulate tokens. The stream runs as a Task so the
|
||||||
|
# /turns/cancel route can invoke ``Task.cancel()`` to abort it
|
||||||
|
# mid-stream. ``accumulated`` is a closure over the inner coroutine,
|
||||||
|
# so when the await on ``stream_task`` raises CancelledError below
|
||||||
|
# we still see whatever tokens were appended before cancellation.
|
||||||
accumulated: list[str] = []
|
accumulated: list[str] = []
|
||||||
truncated = False
|
truncated = False
|
||||||
cancelled = False
|
cancelled = False
|
||||||
try:
|
|
||||||
|
async def _stream() -> None:
|
||||||
async for chunk in client.stream(
|
async for chunk in client.stream(
|
||||||
messages, model=settings.narrative_model
|
messages, model=settings.narrative_model
|
||||||
):
|
):
|
||||||
@@ -199,6 +206,11 @@ async def post_turn(
|
|||||||
"speaker_id": host_bot["id"],
|
"speaker_id": host_bot["id"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
stream_task = asyncio.create_task(_stream())
|
||||||
|
_in_flight_tasks[chat_id] = stream_task
|
||||||
|
try:
|
||||||
|
await stream_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# Preserve the partial output before letting the cancellation
|
# Preserve the partial output before letting the cancellation
|
||||||
# propagate so the transcript reflects what the user actually saw.
|
# propagate so the transcript reflects what the user actually saw.
|
||||||
@@ -207,6 +219,9 @@ async def post_turn(
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Surface as a truncated turn rather than losing the partial output.
|
# Surface as a truncated turn rather than losing the partial output.
|
||||||
truncated = True
|
truncated = True
|
||||||
|
finally:
|
||||||
|
# Always unregister so a subsequent turn can register a fresh task.
|
||||||
|
_in_flight_tasks.pop(chat_id, None)
|
||||||
|
|
||||||
full_text = "".join(accumulated)
|
full_text = "".join(accumulated)
|
||||||
|
|
||||||
@@ -412,6 +427,29 @@ async def post_turn(
|
|||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cancel route (Task 34).
|
||||||
|
#
|
||||||
|
# Fire-and-forget: the Stop button POSTs here, we mark the in-flight
|
||||||
|
# streaming Task as cancelled, and return 204 immediately. The cancel
|
||||||
|
# propagates into the streaming coroutine on its next await, the
|
||||||
|
# CancelledError handler in ``post_turn`` catches it, and the partial
|
||||||
|
# is committed with ``truncated=True``. No body is needed — the SSE
|
||||||
|
# channel is the conveyor of state. If no turn is in flight (or the
|
||||||
|
# task already completed), we 204 silently so the client can fire the
|
||||||
|
# Stop button without a precondition check.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chats/{chat_id}/turns/cancel")
|
||||||
|
async def cancel_turn(chat_id: str, request: Request):
|
||||||
|
task = _in_flight_tasks.get(chat_id)
|
||||||
|
if task is None or task.done():
|
||||||
|
return Response(status_code=204)
|
||||||
|
task.cancel()
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Rewind routes (Task 28).
|
# Rewind routes (Task 28).
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Tests for nightly DB backups (T32).
|
||||||
|
|
||||||
|
The backup service is intentionally simple: a flat ``data/backups/`` dir
|
||||||
|
containing timestamped copies of ``chat.db``, with retention of the most
|
||||||
|
recent 14. The scheduling decision (``should_take_backup``) is a pure
|
||||||
|
function of clock + filesystem state so it can be unit-tested without
|
||||||
|
spinning up the BackgroundWorker tick loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from chat.services.backup import (
|
||||||
|
prune_backups,
|
||||||
|
should_take_backup,
|
||||||
|
take_backup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_backup_creates_timestamped_copy(tmp_path):
|
||||||
|
db = tmp_path / "chat.db"
|
||||||
|
db.write_text("fake db contents")
|
||||||
|
backup_path = take_backup(db_path=db, data_dir=tmp_path / "data")
|
||||||
|
assert backup_path.exists()
|
||||||
|
assert backup_path.name.startswith("chat-")
|
||||||
|
assert backup_path.name.endswith(".db")
|
||||||
|
# Contents copied
|
||||||
|
assert backup_path.read_text() == "fake db contents"
|
||||||
|
# Located in data/backups/
|
||||||
|
assert backup_path.parent == tmp_path / "data" / "backups"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_keeps_last_14(tmp_path):
|
||||||
|
backup_dir = tmp_path / "data" / "backups"
|
||||||
|
backup_dir.mkdir(parents=True)
|
||||||
|
# Create 17 dummy backup files spanning days 1..17 of Jan 2026.
|
||||||
|
# Filenames sort lexicographically by the embedded timestamp, so
|
||||||
|
# prune_backups should drop the three oldest.
|
||||||
|
for i in range(1, 18):
|
||||||
|
(backup_dir / f"chat-202601{i:02d}T000000Z.db").write_text(
|
||||||
|
f"backup {i}"
|
||||||
|
)
|
||||||
|
removed = prune_backups(tmp_path / "data", keep=14)
|
||||||
|
assert removed == 3
|
||||||
|
remaining = sorted(backup_dir.glob("chat-*.db"))
|
||||||
|
assert len(remaining) == 14
|
||||||
|
# Days 1, 2, 3 removed; day 4 is now the oldest retained backup.
|
||||||
|
assert remaining[0].name == "chat-20260104T000000Z.db"
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_take_backup_when_no_prior_and_target_hour_matches(tmp_path):
|
||||||
|
from chat.services import backup as backup_mod
|
||||||
|
|
||||||
|
class FakeDateTime(datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return datetime(2026, 4, 26, 3, 0, 0)
|
||||||
|
|
||||||
|
with patch.object(backup_mod, "datetime", FakeDateTime):
|
||||||
|
assert should_take_backup(tmp_path / "data") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_not_take_backup_outside_target_hour(tmp_path):
|
||||||
|
from chat.services import backup as backup_mod
|
||||||
|
|
||||||
|
class FakeDateTime(datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return datetime(2026, 4, 26, 14, 0, 0)
|
||||||
|
|
||||||
|
with patch.object(backup_mod, "datetime", FakeDateTime):
|
||||||
|
assert should_take_backup(tmp_path / "data") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_not_take_backup_when_recent_backup_exists(tmp_path):
|
||||||
|
backup_dir = tmp_path / "data" / "backups"
|
||||||
|
backup_dir.mkdir(parents=True)
|
||||||
|
recent = backup_dir / "chat-recent.db"
|
||||||
|
recent.write_text("x")
|
||||||
|
# mtime defaults to "now" — within the 23h freshness window so
|
||||||
|
# should_take_backup must return False even at the target hour.
|
||||||
|
from chat.services import backup as backup_mod
|
||||||
|
|
||||||
|
class FakeDateTime(datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return datetime(2026, 4, 26, 3, 0, 0)
|
||||||
|
|
||||||
|
with patch.object(backup_mod, "datetime", FakeDateTime):
|
||||||
|
assert should_take_backup(tmp_path / "data") is False
|
||||||
+35
-4
@@ -20,11 +20,29 @@ def client(tmp_path, monkeypatch):
|
|||||||
yield c
|
yield c
|
||||||
|
|
||||||
|
|
||||||
def _author_bot_and_chat(db_path: Path, bot_id: str = "bot_a") -> None:
|
def _author_you(db_path: Path) -> None:
|
||||||
"""Insert a bot and a chat directly via the event log (skip kickoff route)."""
|
"""Author a ``you_entity`` so the first-run middleware doesn't redirect."""
|
||||||
from chat.db.connection import open_db
|
from chat.db.connection import open_db
|
||||||
|
|
||||||
with open_db(db_path) as conn:
|
with open_db(db_path) as conn:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="you_authored",
|
||||||
|
payload={"name": "Me", "pronouns": "", "persona": ""},
|
||||||
|
)
|
||||||
|
project(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _author_bot_and_chat(db_path: Path, bot_id: str = "bot_a") -> None:
|
||||||
|
"""Insert a you_entity, bot, and chat via the event log (skip kickoff route)."""
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
|
||||||
|
with open_db(db_path) as conn:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="you_authored",
|
||||||
|
payload={"name": "Me", "pronouns": "", "persona": ""},
|
||||||
|
)
|
||||||
append_event(
|
append_event(
|
||||||
conn,
|
conn,
|
||||||
kind="bot_authored",
|
kind="bot_authored",
|
||||||
@@ -53,13 +71,26 @@ def _author_bot_and_chat(db_path: Path, bot_id: str = "bot_a") -> None:
|
|||||||
project(conn)
|
project(conn)
|
||||||
|
|
||||||
|
|
||||||
def test_root_redirects_to_chats(client):
|
def test_root_redirects_to_chats_when_setup_complete(client, tmp_path):
|
||||||
|
# With both you_entity and a bot present, the first-run middleware
|
||||||
|
# passes through and the nav router sends "/" → "/chats".
|
||||||
|
_author_bot_and_chat(tmp_path / "test.db", "bot_a")
|
||||||
response = client.get("/", follow_redirects=False)
|
response = client.get("/", follow_redirects=False)
|
||||||
assert response.status_code == 303
|
assert response.status_code == 303
|
||||||
assert response.headers["location"] == "/chats"
|
assert response.headers["location"] == "/chats"
|
||||||
|
|
||||||
|
|
||||||
def test_chats_list_empty_state(client):
|
def test_chats_list_empty_state(client, tmp_path):
|
||||||
|
# Author you + a bot but NO chats — should render the empty-state
|
||||||
|
# chats list, not redirect.
|
||||||
|
_author_bot_and_chat(tmp_path / "test.db", "bot_a")
|
||||||
|
# Drop the chat row so we hit the empty-state branch (the helper
|
||||||
|
# creates a chat — undo it via a fresh seed without chat_created).
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
conn.execute("DELETE FROM chats")
|
||||||
|
conn.commit()
|
||||||
response = client.get("/chats")
|
response = client.get("/chats")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
body = response.text.lower()
|
body = response.text.lower()
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from chat.app import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
cfg = tmp_path / "config.toml"
|
||||||
|
cfg.write_text('featherless_api_key = "test"\n')
|
||||||
|
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
monkeypatch.setenv("CHAT_DB_PATH", str(db))
|
||||||
|
with TestClient(app) as c:
|
||||||
|
if hasattr(app.state, "background_worker"):
|
||||||
|
app.state.background_worker.enabled = False
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_minimal_state(db_path):
|
||||||
|
"""Set up enough state so the first-run middleware doesn't redirect."""
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
from chat.eventlog.log import append_event
|
||||||
|
from chat.eventlog.projector import project
|
||||||
|
|
||||||
|
with open_db(db_path) as conn:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="you_authored",
|
||||||
|
payload={"name": "Me", "pronouns": "", "persona": ""},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="bot_authored",
|
||||||
|
payload={
|
||||||
|
"id": "bot_a",
|
||||||
|
"name": "BotA",
|
||||||
|
"persona": "",
|
||||||
|
"voice_samples": [],
|
||||||
|
"traits": [],
|
||||||
|
"backstory": "",
|
||||||
|
"initial_relationship_to_you": "",
|
||||||
|
"kickoff_prose": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
project(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def test_404_renders_friendly_page_for_html(client, tmp_path):
|
||||||
|
_setup_minimal_state(tmp_path / "test.db")
|
||||||
|
response = client.get("/chats/no_such_chat")
|
||||||
|
assert response.status_code == 404
|
||||||
|
body = response.text
|
||||||
|
assert "404" in body
|
||||||
|
assert "back to" in body.lower()
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from chat.app import app
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
from chat.eventlog.log import append_event
|
||||||
|
from chat.eventlog.projector import project
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
cfg = tmp_path / "config.toml"
|
||||||
|
cfg.write_text('featherless_api_key = "test"\n')
|
||||||
|
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
monkeypatch.setenv("CHAT_DB_PATH", str(db))
|
||||||
|
with TestClient(app) as c:
|
||||||
|
if hasattr(app.state, "background_worker"):
|
||||||
|
app.state.background_worker.enabled = False
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_redirects_to_settings_when_no_you(client):
|
||||||
|
response = client.get("/", follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "/settings"
|
||||||
|
|
||||||
|
|
||||||
|
def test_chats_redirects_to_settings_when_no_you(client):
|
||||||
|
response = client.get("/chats", follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "/settings"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirects_to_bots_new_when_you_exists_but_no_bots(client, tmp_path):
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="you_authored",
|
||||||
|
payload={
|
||||||
|
"name": "Me",
|
||||||
|
"pronouns": "they/them",
|
||||||
|
"persona": "engineer",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
project(conn)
|
||||||
|
response = client.get("/chats", follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "/bots/new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_redirects_to_bots_new_when_you_exists_but_no_bots(client, tmp_path):
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="you_authored",
|
||||||
|
payload={
|
||||||
|
"name": "Me",
|
||||||
|
"pronouns": "they/them",
|
||||||
|
"persona": "engineer",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
project(conn)
|
||||||
|
response = client.get("/", follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "/bots/new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_redirect_when_setup_complete(client, tmp_path):
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="you_authored",
|
||||||
|
payload={
|
||||||
|
"name": "Me",
|
||||||
|
"pronouns": "they/them",
|
||||||
|
"persona": "engineer",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="bot_authored",
|
||||||
|
payload={
|
||||||
|
"id": "bot_a",
|
||||||
|
"name": "BotA",
|
||||||
|
"persona": "...",
|
||||||
|
"voice_samples": [],
|
||||||
|
"traits": [],
|
||||||
|
"backstory": "",
|
||||||
|
"initial_relationship_to_you": "",
|
||||||
|
"kickoff_prose": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
project(conn)
|
||||||
|
response = client.get("/chats", follow_redirects=False)
|
||||||
|
# /chats page renders normally (200) instead of redirecting.
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_page_accessible_without_you(client):
|
||||||
|
"""Don't redirect FROM /settings — user needs to fill it out."""
|
||||||
|
response = client.get("/settings", follow_redirects=False)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_bots_new_accessible_without_redirect(client, tmp_path):
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="you_authored",
|
||||||
|
payload={"name": "Me", "pronouns": "", "persona": ""},
|
||||||
|
)
|
||||||
|
project(conn)
|
||||||
|
response = client.get("/bots/new", follow_redirects=False)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_bots_list_accessible_without_redirect_when_empty(client, tmp_path):
|
||||||
|
"""The bot list page itself should never redirect — even when empty."""
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="you_authored",
|
||||||
|
payload={"name": "Me", "pronouns": "", "persona": ""},
|
||||||
|
)
|
||||||
|
project(conn)
|
||||||
|
response = client.get("/bots", follow_redirects=False)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_to_settings_not_redirected(client):
|
||||||
|
"""POST should bypass middleware — it's a write, not a landing nav."""
|
||||||
|
response = client.post(
|
||||||
|
"/settings",
|
||||||
|
data={"name": "Me", "pronouns": "", "persona": ""},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
# Settings POST returns 200 with the saved page (no HTTPException raised).
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_endpoint_not_redirected(client):
|
||||||
|
response = client.get("/health", follow_redirects=False)
|
||||||
|
assert response.status_code == 200
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Tests for the transcript renderer (Task 33).
|
||||||
|
|
||||||
|
Lightweight markdown for transcript turns:
|
||||||
|
- ``*action*`` → ``<em class="action">action</em>``
|
||||||
|
- ``**bold**`` → ``<strong>bold</strong>``
|
||||||
|
- ``((ooc))`` → ``<span class="ooc">((ooc))</span>``
|
||||||
|
- ``> line`` → ``<blockquote>line</blockquote>``
|
||||||
|
- paragraph breaks (double newline) → ``</p><p>``
|
||||||
|
- everything HTML-escaped first
|
||||||
|
|
||||||
|
No headings, no code blocks, no links — out of scope per Requirements §16.3.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from chat.web.render import render_prose, render_turn_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_prose_escapes_html():
|
||||||
|
"""Raw HTML in user content must be escaped — no XSS surface."""
|
||||||
|
out = render_prose("<script>alert(1)</script>")
|
||||||
|
assert "<script>" not in out
|
||||||
|
assert "<script>" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_prose_action_to_italic():
|
||||||
|
out = render_prose("*walks over*")
|
||||||
|
assert '<em class="action">walks over</em>' in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_prose_bold_before_action():
|
||||||
|
"""Bold (``**``) must be processed before action (``*``)."""
|
||||||
|
out = render_prose("**emphasis** and *action*")
|
||||||
|
assert "<strong>emphasis</strong>" in out
|
||||||
|
assert '<em class="action">action</em>' in out
|
||||||
|
# Make sure we didn't double-wrap: no stray asterisks left behind.
|
||||||
|
assert "*" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_prose_ooc_wrapped():
|
||||||
|
out = render_prose("((this is OOC))")
|
||||||
|
assert '<span class="ooc">' in out
|
||||||
|
assert "((this is OOC))" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_prose_paragraphs():
|
||||||
|
out = render_prose("First.\n\nSecond.")
|
||||||
|
# Two <p> opens and two closes.
|
||||||
|
assert out.count("<p>") == 2
|
||||||
|
assert out.count("</p>") == 2
|
||||||
|
assert "<p>First.</p>" in out
|
||||||
|
assert "<p>Second.</p>" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_prose_blockquote():
|
||||||
|
out = render_prose("> a quote")
|
||||||
|
assert "<blockquote>a quote</blockquote>" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_prose_empty():
|
||||||
|
"""Empty / whitespace-only inputs produce empty output, not stray tags."""
|
||||||
|
assert render_prose("") == ""
|
||||||
|
assert render_prose(" ") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_turn_html_includes_role_class():
|
||||||
|
out = render_turn_html("BotA", "Hello.", role="bot")
|
||||||
|
assert 'class="turn turn-bot"' in out
|
||||||
|
assert "<strong>BotA</strong>" in out
|
||||||
|
assert "Hello." in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_turn_html_escapes_speaker():
|
||||||
|
"""Speaker label is also HTML-escaped — names are user-controlled."""
|
||||||
|
out = render_turn_html("<bad>", "hi", role="you")
|
||||||
|
# Raw tag should not appear; escaped form should.
|
||||||
|
assert "<bad>" not in out
|
||||||
|
assert "<bad>" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_prose_mixed_full_message():
|
||||||
|
"""Realistic turn with action, dialogue, and an OOC aside."""
|
||||||
|
text = "*looks up* \"You're back late.\" ((she's tired))"
|
||||||
|
out = render_prose(text)
|
||||||
|
assert '<em class="action">looks up</em>' in out
|
||||||
|
# The apostrophe in ``she's`` is HTML-escaped to ``'``.
|
||||||
|
assert '<span class="ooc">((she's tired))</span>' in out
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Tests for Task 31 — periodic snapshots with retention and cold-load fast-path.
|
||||||
|
|
||||||
|
Per Requirements §10.4 the periodic snapshot policy is:
|
||||||
|
|
||||||
|
* Take a snapshot every 100 events OR every 30 minutes since the last one,
|
||||||
|
whichever comes first.
|
||||||
|
* Store under ``data/snapshots/periodic/`` with a UTC timestamp filename.
|
||||||
|
* Retain only the last 5 periodic snapshots; prune older ones on write.
|
||||||
|
* On cold load, restore from the most recent snapshot and replay events
|
||||||
|
past the snapshot's ``last_event_id`` to bring projected state forward.
|
||||||
|
|
||||||
|
These tests cover the functional core (snapshot timing, pruning, restore).
|
||||||
|
Worker- and lifespan-level wiring is covered by the integration tests in
|
||||||
|
``test_turn_flow`` and the existing app boot tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
from chat.db.migrate import apply_migrations
|
||||||
|
from chat.eventlog.log import append_event
|
||||||
|
from chat.eventlog.projector import project
|
||||||
|
from chat.services.snapshot import (
|
||||||
|
latest_snapshot_path,
|
||||||
|
prune_periodic_snapshots,
|
||||||
|
restore_from_snapshot,
|
||||||
|
should_take_periodic_snapshot,
|
||||||
|
take_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Importing the state modules registers their projector handlers as a
|
||||||
|
# side effect — restoring + replaying needs them present.
|
||||||
|
import chat.state.entities # noqa: F401
|
||||||
|
import chat.state.edges # noqa: F401
|
||||||
|
import chat.state.manual_edit # noqa: F401
|
||||||
|
import chat.state.memory # noqa: F401
|
||||||
|
import chat.state.world # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
def _bot_payload(bot_id: str, name: str) -> dict:
|
||||||
|
return {
|
||||||
|
"id": bot_id,
|
||||||
|
"name": name,
|
||||||
|
"persona": "fancy",
|
||||||
|
"voice_samples": ["sample"],
|
||||||
|
"traits": ["shy"],
|
||||||
|
"backstory": "",
|
||||||
|
"initial_relationship_to_you": "coworker",
|
||||||
|
"kickoff_prose": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_snapshot_includes_last_event_id(tmp_path):
|
||||||
|
db = tmp_path / "t.db"
|
||||||
|
apply_migrations(db)
|
||||||
|
with open_db(db) as conn:
|
||||||
|
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||||
|
project(conn)
|
||||||
|
path = take_snapshot(conn, data_dir=tmp_path / "data", kind="periodic")
|
||||||
|
dump = json.loads(path.read_text())
|
||||||
|
assert "last_event_id" in dump
|
||||||
|
assert dump["last_event_id"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_take_periodic_when_no_prior_and_events_exist(tmp_path):
|
||||||
|
db = tmp_path / "t.db"
|
||||||
|
apply_migrations(db)
|
||||||
|
with open_db(db) as conn:
|
||||||
|
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||||
|
project(conn)
|
||||||
|
assert should_take_periodic_snapshot(conn, tmp_path / "data") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_not_take_when_recent_and_few_events(tmp_path):
|
||||||
|
db = tmp_path / "t.db"
|
||||||
|
apply_migrations(db)
|
||||||
|
with open_db(db) as conn:
|
||||||
|
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||||
|
project(conn)
|
||||||
|
# Take a snapshot to establish a recent baseline.
|
||||||
|
take_snapshot(conn, data_dir=tmp_path / "data", kind="periodic")
|
||||||
|
# Right after — should be False (within time threshold and < 100 new events).
|
||||||
|
assert should_take_periodic_snapshot(conn, tmp_path / "data") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_keeps_last_5(tmp_path):
|
||||||
|
snapshot_dir = tmp_path / "data" / "snapshots" / "periodic"
|
||||||
|
snapshot_dir.mkdir(parents=True)
|
||||||
|
# Create 8 dummy snapshot files with sortable names.
|
||||||
|
for i in range(8):
|
||||||
|
p = snapshot_dir / f"2026010{i}T000000Z.json"
|
||||||
|
p.write_text(json.dumps({"last_event_id": i}))
|
||||||
|
removed = prune_periodic_snapshots(tmp_path / "data", keep=5)
|
||||||
|
assert removed == 3
|
||||||
|
remaining = sorted(snapshot_dir.glob("*.json"))
|
||||||
|
assert len(remaining) == 5
|
||||||
|
# The 5 most recent (highest names) should remain.
|
||||||
|
assert remaining[0].name == "20260103T000000Z.json"
|
||||||
|
assert remaining[-1].name == "20260107T000000Z.json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_snapshot_path_returns_none_when_missing(tmp_path):
|
||||||
|
# No directory yet.
|
||||||
|
assert latest_snapshot_path(tmp_path / "data", kind="periodic") is None
|
||||||
|
# Empty directory.
|
||||||
|
(tmp_path / "data" / "snapshots" / "periodic").mkdir(parents=True)
|
||||||
|
assert latest_snapshot_path(tmp_path / "data", kind="periodic") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_from_snapshot_repopulates_tables(tmp_path):
|
||||||
|
# Source DB: seed a bot, snapshot it.
|
||||||
|
db1 = tmp_path / "t1.db"
|
||||||
|
apply_migrations(db1)
|
||||||
|
with open_db(db1) as conn:
|
||||||
|
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||||
|
project(conn)
|
||||||
|
snapshot_path = take_snapshot(
|
||||||
|
conn, data_dir=tmp_path / "data", kind="periodic"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fresh DB — restore from the snapshot, no event-log replay needed.
|
||||||
|
db2 = tmp_path / "t2.db"
|
||||||
|
apply_migrations(db2)
|
||||||
|
with open_db(db2) as conn:
|
||||||
|
last_id = restore_from_snapshot(conn, snapshot_path)
|
||||||
|
assert last_id >= 1
|
||||||
|
bot = conn.execute(
|
||||||
|
"SELECT name FROM bots WHERE id = 'bot_a'"
|
||||||
|
).fetchone()
|
||||||
|
assert bot is not None
|
||||||
|
assert bot[0] == "BotA"
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Streaming UX tests (T34): cancel route, recent-dialogue user_turn_edit
|
||||||
|
inclusion, and the chat-shell embeds the streaming JS hooks.
|
||||||
|
|
||||||
|
The cancel route is exercised at the no-op level only — the full mid-stream
|
||||||
|
cancel path is covered indirectly by T19's CancelledError handling. We
|
||||||
|
verify here that the route itself is registered and silently 204s when no
|
||||||
|
in-flight task exists, since the JS Stop button fires unconditionally.
|
||||||
|
|
||||||
|
The user_turn_edit inclusion test is the T29 follow-up fix: without it,
|
||||||
|
the original user_turn drops out of the timeline (correctly) but the
|
||||||
|
edited prose never lands (incorrectly), so the rendered chat detail is
|
||||||
|
missing the user's most recent words.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from chat.app import app
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
from chat.eventlog.log import append_event
|
||||||
|
from chat.eventlog.projector import project
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
cfg = tmp_path / "config.toml"
|
||||||
|
cfg.write_text('featherless_api_key = "test"\n')
|
||||||
|
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
monkeypatch.setenv("CHAT_DB_PATH", str(db))
|
||||||
|
with TestClient(app) as c:
|
||||||
|
# Disable the lifespan-managed background worker so it doesn't
|
||||||
|
# try to score significance through Featherless with the fake key.
|
||||||
|
worker = getattr(app.state, "background_worker", None)
|
||||||
|
if worker is not None:
|
||||||
|
worker.enabled = False
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_chat(
|
||||||
|
db_path: Path,
|
||||||
|
bot_id: str = "bot_a",
|
||||||
|
chat_id: str = "chat_bot_a",
|
||||||
|
) -> None:
|
||||||
|
"""Seed a bot + chat with the activity rows the prompt assembler expects."""
|
||||||
|
with open_db(db_path) as conn:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="bot_authored",
|
||||||
|
payload={
|
||||||
|
"id": bot_id,
|
||||||
|
"name": "BotA",
|
||||||
|
"persona": "...",
|
||||||
|
"voice_samples": [],
|
||||||
|
"traits": [],
|
||||||
|
"backstory": "",
|
||||||
|
"initial_relationship_to_you": "",
|
||||||
|
"kickoff_prose": "...",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="chat_created",
|
||||||
|
payload={
|
||||||
|
"id": chat_id,
|
||||||
|
"host_bot_id": bot_id,
|
||||||
|
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||||
|
"narrative_anchor": "Day 1",
|
||||||
|
"weather": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="edge_update",
|
||||||
|
payload={
|
||||||
|
"source_id": bot_id,
|
||||||
|
"target_id": "you",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="edge_update",
|
||||||
|
payload={
|
||||||
|
"source_id": "you",
|
||||||
|
"target_id": bot_id,
|
||||||
|
"chat_id": chat_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="activity_change",
|
||||||
|
payload={
|
||||||
|
"entity_id": "you",
|
||||||
|
"posture": "sitting",
|
||||||
|
"action": {"verb": "talking"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="activity_change",
|
||||||
|
payload={
|
||||||
|
"entity_id": bot_id,
|
||||||
|
"posture": "sitting",
|
||||||
|
"action": {"verb": "listening"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
project(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_route_no_op_when_no_in_flight(client, tmp_path):
|
||||||
|
"""Hitting cancel with nothing streaming returns 204 silently."""
|
||||||
|
_seed_chat(tmp_path / "test.db")
|
||||||
|
response = client.post("/chats/chat_bot_a/turns/cancel")
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_turn_edit_appears_in_recent_dialogue(client, tmp_path):
|
||||||
|
"""The chat-detail timeline includes a user_turn_edit's prose.
|
||||||
|
|
||||||
|
Original user_turn is superseded by the edit, so it drops out, but
|
||||||
|
the edit's prose should render in its place.
|
||||||
|
"""
|
||||||
|
db_path = tmp_path / "test.db"
|
||||||
|
_seed_chat(db_path)
|
||||||
|
with open_db(db_path) as conn:
|
||||||
|
ut_id = append_event(
|
||||||
|
conn,
|
||||||
|
kind="user_turn",
|
||||||
|
payload={
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"prose": "OriginalUserText",
|
||||||
|
"segments": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
edit_id = append_event(
|
||||||
|
conn,
|
||||||
|
kind="user_turn_edit",
|
||||||
|
payload={
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"prose": "EditedUserText",
|
||||||
|
"supersedes_user_turn_id": ut_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
|
||||||
|
(edit_id, ut_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
# No project() call — user_turn / user_turn_edit have no projector
|
||||||
|
# handlers (transcript-only kinds), and re-projecting would replay
|
||||||
|
# chat_created and trip its UNIQUE constraint.
|
||||||
|
|
||||||
|
response = client.get("/chats/chat_bot_a")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.text
|
||||||
|
assert "EditedUserText" in body
|
||||||
|
# The original (now-superseded) prose must not render.
|
||||||
|
assert "OriginalUserText" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_html_includes_stop_streaming_script(client, tmp_path):
|
||||||
|
"""The chat shell embeds the streaming-JS hooks (Stop button + send-lock)."""
|
||||||
|
_seed_chat(tmp_path / "test.db")
|
||||||
|
response = client.get("/chats/chat_bot_a")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.text
|
||||||
|
# Either the CSS class for the Stop button or the JS state flag must
|
||||||
|
# appear in the embedded script — both are load-bearing for T34.
|
||||||
|
assert "stop-streaming" in body or "isStreaming" in body
|
||||||
|
# Cancel route reference must be wired so the Stop button can call it.
|
||||||
|
assert "/turns/cancel" in body
|
||||||
Reference in New Issue
Block a user