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)
|
||||
- Character template format (during Phase 1)
|
||||
- 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
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
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.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from chat.config import load_settings
|
||||
from chat.db.connection import open_db
|
||||
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.snapshot import latest_snapshot_path, restore_from_snapshot
|
||||
|
||||
# Trigger handler registration:
|
||||
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.drawer import router as drawer_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.settings import router as settings_router
|
||||
from chat.web.sse import router as sse_router
|
||||
from chat.web.turns import router as turns_router
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = load_settings()
|
||||
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
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
|
||||
|
||||
# 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.add_middleware(FirstRunRedirectMiddleware)
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "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(kickoff_router)
|
||||
app.include_router(settings_router)
|
||||
|
||||
@@ -37,4 +37,12 @@ def load_settings() -> Settings:
|
||||
raw = tomllib.loads(config_path.read_text())
|
||||
if "CHAT_DB_PATH" in os.environ:
|
||||
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)
|
||||
|
||||
@@ -32,7 +32,22 @@ from chat.config import Settings
|
||||
from chat.db.connection import open_db
|
||||
from chat.eventlog.log import append_and_apply
|
||||
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.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__)
|
||||
|
||||
@@ -70,14 +85,27 @@ class BackgroundWorker:
|
||||
self._llm_client_factory = llm_client_factory
|
||||
self._queue: asyncio.Queue[SignificanceJob | None] = asyncio.Queue()
|
||||
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
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task is not None:
|
||||
return
|
||||
self._task = asyncio.create_task(self._run())
|
||||
self._tick_task = asyncio.create_task(self._tick_loop())
|
||||
|
||||
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:
|
||||
return
|
||||
await self._queue.put(None) # sentinel
|
||||
@@ -99,6 +127,40 @@ class BackgroundWorker:
|
||||
except Exception as exc: # noqa: BLE001 — worker must not die
|
||||
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:
|
||||
client = self._llm_client_factory()
|
||||
score = await compute_significance(
|
||||
@@ -123,6 +185,33 @@ class BackgroundWorker:
|
||||
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(
|
||||
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.
|
||||
|
||||
Used by the rewind flow (Requirements §10.1, T28) so the user can recover a
|
||||
pre-rewind state if the rewind was a mistake. Stored under
|
||||
``data/snapshots/{kind}/`` with a UTC timestamp filename.
|
||||
Two snapshot kinds, both covered by this module:
|
||||
|
||||
The dump captures both the event log (so the original event sequence is
|
||||
preserved verbatim) and every projected table (so a future restore could
|
||||
either re-load tables directly or re-project from the saved event log).
|
||||
* ``rewind`` (T28, Requirements §10.1): pre-rewind safety snapshot so the
|
||||
user can recover if a rewind was a mistake. Retention: 14 days.
|
||||
* ``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
|
||||
virtual table maintained by the ``memories_ai/au/ad`` triggers, so it would
|
||||
rebuild itself on a memories re-load. Snapshotting it would also fail
|
||||
virtual table maintained by the ``memories_ai/au/ad`` triggers, so it
|
||||
rebuilds itself on a memories re-load. Snapshotting it would also fail
|
||||
``PRAGMA table_info`` cleanly since FTS5 reports its columns differently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
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),
|
||||
# but listing tables explicitly keeps the snapshot stable across schema
|
||||
# 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
|
||||
``YYYYMMDDTHHMMSSZ`` form so chronological listing matches creation
|
||||
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.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
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
|
||||
# superseded/hidden flags are needed to faithfully reconstruct the
|
||||
@@ -98,3 +125,121 @@ def take_snapshot(
|
||||
# all use TEXT so this is mostly defensive.
|
||||
path.write_text(json.dumps(dump, default=str))
|
||||
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; }
|
||||
.timeline { flex: 1; overflow-y: auto; min-height: 200px; padding: 8px 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 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; }
|
||||
@@ -89,3 +113,13 @@ code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||
.memory-list li { padding: 4px 0; font-size: 13px; }
|
||||
.sig { display: inline-block; min-width: 16px; }
|
||||
.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 %}
|
||||
<div class="turn turn-{{ turn.role }}">
|
||||
<strong>{{ turn.speaker }}</strong>
|
||||
<p>{{ turn.text }}</p>
|
||||
{{ turn.text|render_prose|safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -46,4 +46,107 @@ document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => {
|
||||
e.target.setAttribute('aria-expanded', String(isHidden));
|
||||
});
|
||||
</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 %}
|
||||
|
||||
@@ -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.world import get_chat
|
||||
from chat.web.bots import get_conn
|
||||
from chat.web.render import render_prose
|
||||
from chat.web.turns import _read_recent_dialogue
|
||||
|
||||
TEMPLATES = Jinja2Templates(
|
||||
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()
|
||||
|
||||
|
||||
@@ -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.kickoff import get_llm_client
|
||||
from chat.web.pubsub import publish
|
||||
from chat.web.render import render_turn_html as _render_turn_html
|
||||
|
||||
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:
|
||||
"""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]:
|
||||
"""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
|
||||
turns (T29) drop out of the rendered timeline. Each entry is shaped
|
||||
``{"speaker": <id-or-"you">, "text": <prose>}`` for the prompt
|
||||
assembler and the chat-detail template.
|
||||
Includes ``user_turn``, ``user_turn_edit`` (T29 edited prose), and
|
||||
``assistant_turn``. Ordered oldest-first; superseded/hidden rows are
|
||||
skipped so regenerated turns (T29) drop out of the rendered timeline.
|
||||
Each entry is shaped ``{"speaker": <id-or-"you">, "text": <prose>}``
|
||||
for the prompt assembler and the chat-detail template.
|
||||
"""
|
||||
cur = conn.execute(
|
||||
"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 "
|
||||
"ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
@@ -90,7 +100,9 @@ def _read_recent_dialogue(conn, chat_id: str, limit: int = 200) -> list[dict]:
|
||||
p = json.loads(payload_json)
|
||||
if p.get("chat_id") != chat_id:
|
||||
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", "")})
|
||||
else:
|
||||
out.append(
|
||||
@@ -102,16 +114,6 @@ def _read_recent_dialogue(conn, chat_id: str, limit: int = 200) -> list[dict]:
|
||||
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")
|
||||
async def post_turn(
|
||||
chat_id: str,
|
||||
@@ -182,11 +184,16 @@ async def post_turn(
|
||||
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] = []
|
||||
truncated = False
|
||||
cancelled = False
|
||||
try:
|
||||
|
||||
async def _stream() -> None:
|
||||
async for chunk in client.stream(
|
||||
messages, model=settings.narrative_model
|
||||
):
|
||||
@@ -199,6 +206,11 @@ async def post_turn(
|
||||
"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:
|
||||
# Preserve the partial output before letting the cancellation
|
||||
# propagate so the transcript reflects what the user actually saw.
|
||||
@@ -207,6 +219,9 @@ async def post_turn(
|
||||
except Exception:
|
||||
# Surface as a truncated turn rather than losing the partial output.
|
||||
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)
|
||||
|
||||
@@ -412,6 +427,29 @@ async def post_turn(
|
||||
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).
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
def _author_bot_and_chat(db_path: Path, bot_id: str = "bot_a") -> None:
|
||||
"""Insert a bot and a chat directly via the event log (skip kickoff route)."""
|
||||
def _author_you(db_path: Path) -> None:
|
||||
"""Author a ``you_entity`` so the first-run middleware doesn't redirect."""
|
||||
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": ""},
|
||||
)
|
||||
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(
|
||||
conn,
|
||||
kind="bot_authored",
|
||||
@@ -53,13 +71,26 @@ def _author_bot_and_chat(db_path: Path, bot_id: str = "bot_a") -> None:
|
||||
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)
|
||||
assert response.status_code == 303
|
||||
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")
|
||||
assert response.status_code == 200
|
||||
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