e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
786 lines
26 KiB
Python
786 lines
26 KiB
Python
"""Memory v3 API — workbench backend.
|
|
|
|
Three layers, three modes (update / audit / dedup). Long-running work
|
|
is owned by the :mod:`runs` manager so a refresh / nav-away does not
|
|
kill it; clients re-attach by polling ``/runs/{id}/events?since=N``.
|
|
|
|
- ``GET /overview`` → all 11 docs' state + L1 backlog
|
|
- ``GET /doc/{layer}/{key}`` → raw MD
|
|
- ``GET /doc/{layer}/{key}/lines`` → line-numbered view
|
|
- ``PUT /doc/{layer}/{key}`` → user-edited save
|
|
- ``DELETE /doc/{layer}/{key}/entry/{id}`` → drop one entry
|
|
- ``POST /runs/start`` → start update/audit/dedup; returns run_id
|
|
- ``GET /runs/{id}`` → run state
|
|
- ``GET /runs/{id}/events?since=N`` → SSE-replay events from cursor N
|
|
- ``POST /runs/{id}/cancel`` → cooperative cancellation
|
|
- ``POST /runs/{id}/undo`` → restore latest run write
|
|
- ``GET /runs?layer=L2&key=chat`` → active+recent runs for one doc
|
|
- ``GET /settings`` → memory: settings subtree
|
|
- ``PUT /settings`` → save memory: settings subtree
|
|
- ``GET /trace/{surface}`` → paginated L1 events
|
|
- ``DELETE /trace/{surface}/day/{date}`` → drop one day of trace
|
|
- ``DELETE /trace/{surface}`` → drop all trace for a surface
|
|
- ``GET /backup`` → list v1-migration backup dirs (if any)
|
|
|
|
The legacy per-mode endpoints (``POST /doc/{layer}/{key}/update`` etc.)
|
|
are kept for the moment as thin wrappers that start a run and stream
|
|
its events — older clients keep working.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict
|
|
from datetime import date as date_cls
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
|
|
from deeptutor.services.memory import (
|
|
L3_SLOTS,
|
|
SURFACES,
|
|
Surface,
|
|
get_memory_store,
|
|
paths,
|
|
)
|
|
|
|
_ENTRY_ID_RE = re.compile(r"^m_[0-9A-HJKMNP-TV-Z]{26}$")
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
Layer = Literal["L2", "L3"]
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _validate_doc_key(layer: Layer, key: str) -> None:
|
|
if layer == "L2" and key not in SURFACES:
|
|
raise HTTPException(status_code=404, detail=f"unknown surface {key!r}")
|
|
if layer == "L3" and key not in L3_SLOTS:
|
|
raise HTTPException(status_code=404, detail=f"unknown L3 slot {key!r}")
|
|
|
|
|
|
def _validate_layer(layer: str) -> Layer:
|
|
if layer not in {"L2", "L3"}:
|
|
raise HTTPException(status_code=400, detail="layer must be L2 or L3")
|
|
return layer # type: ignore[return-value]
|
|
|
|
|
|
def _validate_surface(surface: str) -> Surface:
|
|
if surface not in SURFACES:
|
|
raise HTTPException(status_code=404, detail=f"unknown surface {surface!r}")
|
|
return surface # type: ignore[return-value]
|
|
|
|
|
|
# ── Overview / list ──────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/overview")
|
|
async def get_overview():
|
|
store = get_memory_store()
|
|
rows = [asdict(r) for r in store.overview()]
|
|
backup_dir = paths.backup_root()
|
|
backups: list[str] = []
|
|
if backup_dir.exists():
|
|
backups = sorted(p.name for p in backup_dir.iterdir() if p.is_dir())
|
|
return {"docs": rows, "backups": backups}
|
|
|
|
|
|
@router.get("/resolve_entry/{entry_id}")
|
|
async def resolve_entry(entry_id: str):
|
|
"""Find which L2 doc owns this entry id.
|
|
|
|
L3 docs cite L2 entries by their ``m_<ULID>`` entry id; the workbench
|
|
UI uses this resolver to turn an L3 footnote click into a navigation
|
|
to the right L2 surface + scroll-to anchor.
|
|
|
|
Scans the seven L2 mds in order; first hit wins. 404 if no L2 doc
|
|
contains the id (e.g. the entry was deleted or the id is stale).
|
|
"""
|
|
if not _ENTRY_ID_RE.match(entry_id):
|
|
raise HTTPException(status_code=400, detail="not a valid entry id")
|
|
from deeptutor.services.memory.document import parse
|
|
|
|
for surface in SURFACES:
|
|
path = paths.l2_file(surface)
|
|
if not path.exists():
|
|
continue
|
|
try:
|
|
doc = parse(path.read_text(encoding="utf-8"))
|
|
except Exception: # noqa: BLE001 — malformed L2 should not 500 the resolver
|
|
continue
|
|
for entry in doc.all_entries():
|
|
if entry.id == entry_id:
|
|
return {"layer": "L2", "key": surface, "entry_id": entry_id}
|
|
raise HTTPException(status_code=404, detail="entry not found in any L2 doc")
|
|
|
|
|
|
@router.get("/backup")
|
|
async def list_backups():
|
|
backup_dir = paths.backup_root()
|
|
if not backup_dir.exists():
|
|
return {"backups": []}
|
|
out: list[dict] = []
|
|
for entry in sorted(backup_dir.iterdir()):
|
|
if entry.is_dir():
|
|
files = sorted(p.name for p in entry.iterdir())
|
|
out.append({"name": entry.name, "files": files})
|
|
return {"backups": out}
|
|
|
|
|
|
# ── Doc read / write / delete ────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/doc/{layer}/{key}")
|
|
async def get_doc(layer: str, key: str):
|
|
lyr = _validate_layer(layer)
|
|
_validate_doc_key(lyr, key)
|
|
return {"layer": lyr, "key": key, "content": get_memory_store().read_raw(lyr, key)}
|
|
|
|
|
|
class DocWriteRequest(BaseModel):
|
|
content: str
|
|
|
|
|
|
@router.put("/doc/{layer}/{key}")
|
|
async def put_doc(layer: str, key: str, payload: DocWriteRequest):
|
|
lyr = _validate_layer(layer)
|
|
_validate_doc_key(lyr, key)
|
|
await get_memory_store().overwrite_doc(lyr, key, payload.content)
|
|
return {"layer": lyr, "key": key, "saved": True}
|
|
|
|
|
|
@router.delete("/doc/{layer}/{key}/entry/{entry_id}")
|
|
async def delete_entry(layer: str, key: str, entry_id: str):
|
|
lyr = _validate_layer(layer)
|
|
_validate_doc_key(lyr, key)
|
|
ok = await get_memory_store().delete_entry(lyr, key, entry_id)
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="entry not found")
|
|
return {"layer": lyr, "key": key, "deleted": entry_id}
|
|
|
|
|
|
@router.post("/doc/{layer}/{key}/reset")
|
|
async def reset_doc(layer: str, key: str):
|
|
"""Wipe the doc + its meta sidecar so the next update starts fresh.
|
|
|
|
Destructive — the caller has confirmed. After this returns the .md
|
|
file is gone *and* the ``seen_entity_refs`` set is cleared, so a
|
|
subsequent ``run_update`` re-ingests every L1 entity instead of
|
|
treating them as already-seen.
|
|
|
|
Refuses while a consolidator run is active on this doc; the caller
|
|
can cancel first.
|
|
"""
|
|
lyr = _validate_layer(layer)
|
|
_validate_doc_key(lyr, key)
|
|
|
|
from deeptutor.services.memory.consolidator.runs import get_run_manager
|
|
|
|
if get_run_manager().active_for(lyr, key) is not None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="cancel the active run before resetting this doc",
|
|
)
|
|
|
|
from deeptutor.services.memory.consolidator import meta as meta_mod
|
|
|
|
doc_path = paths.l2_file(key) if lyr == "L2" else paths.l3_file(key) # type: ignore[arg-type]
|
|
meta_path = (
|
|
meta_mod.l2_meta_path(key) # type: ignore[arg-type]
|
|
if lyr == "L2"
|
|
else meta_mod.l3_meta_path(key) # type: ignore[arg-type]
|
|
)
|
|
|
|
removed_doc = False
|
|
removed_meta = False
|
|
try:
|
|
if doc_path.exists():
|
|
doc_path.unlink()
|
|
removed_doc = True
|
|
if meta_path.exists():
|
|
meta_path.unlink()
|
|
removed_meta = True
|
|
except OSError as exc:
|
|
raise HTTPException(status_code=500, detail=f"reset failed: {exc}") from exc
|
|
|
|
return {
|
|
"layer": lyr,
|
|
"key": key,
|
|
"reset": True,
|
|
"removed_doc": removed_doc,
|
|
"removed_meta": removed_meta,
|
|
}
|
|
|
|
|
|
# ── Doc update (SSE-streamed consolidator) ───────────────────────────────
|
|
|
|
|
|
class LLMSelectionPayload(BaseModel):
|
|
profile_id: str
|
|
model_id: str
|
|
|
|
|
|
class RunStartRequest(BaseModel):
|
|
layer: str
|
|
key: str
|
|
mode: Literal["update", "audit", "dedup", "merge"]
|
|
language: str = "en"
|
|
budget: int | None = None
|
|
iterations: int | None = None
|
|
llm_selection: LLMSelectionPayload | None = None
|
|
|
|
|
|
def _runner_for(req: RunStartRequest):
|
|
"""Return an ``async on_event → None`` runner for the requested mode."""
|
|
from deeptutor.services.memory.consolidator import (
|
|
run_audit,
|
|
run_dedup,
|
|
run_merge,
|
|
run_update,
|
|
)
|
|
|
|
selection = (
|
|
{"profile_id": req.llm_selection.profile_id, "model_id": req.llm_selection.model_id}
|
|
if req.llm_selection
|
|
else None
|
|
)
|
|
|
|
if req.mode == "update":
|
|
|
|
async def go(on_event):
|
|
await run_update(
|
|
req.layer,
|
|
req.key,
|
|
language=req.language,
|
|
budget=req.budget,
|
|
llm_selection=selection,
|
|
on_event=on_event,
|
|
)
|
|
|
|
return go
|
|
if req.mode == "audit":
|
|
|
|
async def go(on_event):
|
|
await run_audit(
|
|
req.layer,
|
|
req.key,
|
|
language=req.language,
|
|
budget=req.budget,
|
|
llm_selection=selection,
|
|
on_event=on_event,
|
|
)
|
|
|
|
return go
|
|
if req.mode == "dedup":
|
|
|
|
async def go(on_event):
|
|
await run_dedup(
|
|
req.layer,
|
|
req.key,
|
|
language=req.language,
|
|
iterations=req.iterations,
|
|
llm_selection=selection,
|
|
on_event=on_event,
|
|
)
|
|
|
|
return go
|
|
if req.mode == "merge":
|
|
|
|
async def go(on_event):
|
|
await run_merge(
|
|
req.layer,
|
|
req.key,
|
|
language=req.language,
|
|
on_event=on_event,
|
|
)
|
|
|
|
return go
|
|
raise HTTPException(status_code=400, detail=f"unknown mode {req.mode!r}")
|
|
|
|
|
|
@router.post("/runs/start")
|
|
async def start_run(req: RunStartRequest):
|
|
"""Start one consolidator mode and return a run handle.
|
|
|
|
The run survives client disconnects; reconnect via
|
|
``GET /runs/{id}/events?since=N``.
|
|
"""
|
|
lyr = _validate_layer(req.layer)
|
|
_validate_doc_key(lyr, req.key)
|
|
if lyr == "L3" and req.key == "preferences" and req.mode not in ("dedup", "merge"):
|
|
raise HTTPException(
|
|
status_code=405,
|
|
detail="preferences is written by the write_memory tool, not consolidated",
|
|
)
|
|
from deeptutor.services.memory.consolidator.runs import (
|
|
RunBusyError,
|
|
get_run_manager,
|
|
)
|
|
|
|
manager = get_run_manager()
|
|
runner = _runner_for(req)
|
|
selection = (
|
|
{"profile_id": req.llm_selection.profile_id, "model_id": req.llm_selection.model_id}
|
|
if req.llm_selection
|
|
else None
|
|
)
|
|
try:
|
|
run = await manager.start(
|
|
layer=lyr,
|
|
key=req.key,
|
|
mode=req.mode,
|
|
runner=runner,
|
|
params={
|
|
"budget": req.budget,
|
|
"iterations": req.iterations,
|
|
"language": req.language,
|
|
"llm_selection": selection,
|
|
},
|
|
language=req.language,
|
|
)
|
|
except RunBusyError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc))
|
|
return run.to_dict()
|
|
|
|
|
|
@router.get("/runs/{run_id}")
|
|
async def get_run(run_id: str):
|
|
from deeptutor.services.memory.consolidator.runs import get_run_manager
|
|
|
|
run = get_run_manager().get(run_id)
|
|
if run is None:
|
|
raise HTTPException(status_code=404, detail="unknown run_id")
|
|
return run.to_dict()
|
|
|
|
|
|
@router.post("/runs/{run_id}/cancel")
|
|
async def cancel_run(run_id: str):
|
|
from deeptutor.services.memory.consolidator.runs import get_run_manager
|
|
|
|
ok = await get_run_manager().cancel(run_id)
|
|
if not ok:
|
|
raise HTTPException(status_code=409, detail="not active")
|
|
return {"run_id": run_id, "cancelled": True}
|
|
|
|
|
|
@router.post("/runs/{run_id}/undo")
|
|
async def undo_run_edit(run_id: str):
|
|
from deeptutor.services.memory.consolidator.runs import (
|
|
RunBusyError,
|
|
get_run_manager,
|
|
)
|
|
|
|
manager = get_run_manager()
|
|
try:
|
|
event = await manager.undo_last(run_id)
|
|
except KeyError:
|
|
raise HTTPException(status_code=404, detail="unknown run_id")
|
|
except RunBusyError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc))
|
|
if event is None:
|
|
raise HTTPException(status_code=409, detail="nothing to undo")
|
|
run = manager.get(run_id)
|
|
return {
|
|
"run_id": run_id,
|
|
"undone": True,
|
|
"undo_count": len(run.undo_stack) if run else 0,
|
|
"event": {"seq": event.seq, "ts": event.ts, **event.payload},
|
|
}
|
|
|
|
|
|
@router.get("/runs")
|
|
async def list_runs(layer: str | None = None, key: str | None = None):
|
|
from deeptutor.services.memory.consolidator.runs import get_run_manager
|
|
|
|
lyr = _validate_layer(layer) if layer is not None else None
|
|
if lyr and key is not None:
|
|
_validate_doc_key(lyr, key)
|
|
runs = get_run_manager().list_for(layer=lyr, key=key)
|
|
return {"runs": [r.to_dict() for r in runs]}
|
|
|
|
|
|
@router.get("/runs/{run_id}/events")
|
|
async def stream_run_events(run_id: str, since: int = 0):
|
|
"""SSE-replay events from ``since`` (exclusive) until the run ends.
|
|
|
|
Reconnecting after a refresh: pass the largest ``seq`` previously
|
|
observed. The manager replays the buffered tail, then blocks on
|
|
new events until the run reaches a terminal state.
|
|
"""
|
|
from deeptutor.services.memory.consolidator.runs import get_run_manager
|
|
|
|
manager = get_run_manager()
|
|
run = manager.get(run_id)
|
|
if run is None:
|
|
raise HTTPException(status_code=404, detail="unknown run_id")
|
|
|
|
async def producer():
|
|
cursor = max(0, since)
|
|
# Initial backfill (if any) is delivered as a batch up front.
|
|
while True:
|
|
events = await manager.wait_for_events(run, since=cursor)
|
|
for ev in events:
|
|
yield (
|
|
"data: "
|
|
+ json.dumps(
|
|
{"seq": ev.seq, "ts": ev.ts, **ev.payload},
|
|
ensure_ascii=False,
|
|
)
|
|
+ "\n\n"
|
|
)
|
|
cursor = max(cursor, run.events[-1].seq + 1 if run.events else cursor)
|
|
if not run.active:
|
|
# Drain any final events that arrived between wait return and now.
|
|
final = run.events[cursor:]
|
|
for ev in final:
|
|
yield (
|
|
"data: "
|
|
+ json.dumps(
|
|
{"seq": ev.seq, "ts": ev.ts, **ev.payload},
|
|
ensure_ascii=False,
|
|
)
|
|
+ "\n\n"
|
|
)
|
|
break
|
|
|
|
return StreamingResponse(producer(), media_type="text/event-stream")
|
|
|
|
|
|
# ── Legacy per-mode endpoints (kept as thin wrappers over /runs/start) ──
|
|
|
|
|
|
def _legacy_run_stream(req: RunStartRequest) -> StreamingResponse:
|
|
"""Old contract: POST /doc/{layer}/{key}/<mode> streams events inline."""
|
|
from deeptutor.services.memory.consolidator.runs import (
|
|
RunBusyError,
|
|
get_run_manager,
|
|
)
|
|
|
|
async def producer():
|
|
manager = get_run_manager()
|
|
runner = _runner_for(req)
|
|
selection = (
|
|
{"profile_id": req.llm_selection.profile_id, "model_id": req.llm_selection.model_id}
|
|
if req.llm_selection
|
|
else None
|
|
)
|
|
try:
|
|
run = await manager.start(
|
|
layer=req.layer,
|
|
key=req.key,
|
|
mode=req.mode,
|
|
runner=runner,
|
|
params={
|
|
"budget": req.budget,
|
|
"iterations": req.iterations,
|
|
"language": req.language,
|
|
"llm_selection": selection,
|
|
},
|
|
language=req.language,
|
|
)
|
|
except RunBusyError as exc:
|
|
yield (
|
|
"data: "
|
|
+ json.dumps({"stage": "error", "message": str(exc)}, ensure_ascii=False)
|
|
+ "\n\n"
|
|
)
|
|
return
|
|
cursor = 0
|
|
while True:
|
|
events = await manager.wait_for_events(run, since=cursor)
|
|
for ev in events:
|
|
yield (
|
|
"data: "
|
|
+ json.dumps({**ev.payload, "seq": ev.seq}, ensure_ascii=False)
|
|
+ "\n\n"
|
|
)
|
|
cursor = ev.seq + 1
|
|
if not run.active:
|
|
break
|
|
|
|
return StreamingResponse(producer(), media_type="text/event-stream")
|
|
|
|
|
|
class UpdateRequest(BaseModel):
|
|
language: str = "en"
|
|
budget: int | None = None
|
|
llm_selection: LLMSelectionPayload | None = None
|
|
|
|
|
|
class AuditRequest(BaseModel):
|
|
language: str = "en"
|
|
budget: int | None = None
|
|
llm_selection: LLMSelectionPayload | None = None
|
|
|
|
|
|
class DedupRequest(BaseModel):
|
|
language: str = "en"
|
|
iterations: int | None = None
|
|
llm_selection: LLMSelectionPayload | None = None
|
|
|
|
|
|
@router.post("/doc/{layer}/{key}/update")
|
|
async def update_doc(layer: str, key: str, payload: UpdateRequest | None = None):
|
|
lyr = _validate_layer(layer)
|
|
_validate_doc_key(lyr, key)
|
|
req = RunStartRequest(
|
|
layer=lyr,
|
|
key=key,
|
|
mode="update",
|
|
language=(payload.language if payload else "en") or "en",
|
|
budget=payload.budget if payload else None,
|
|
llm_selection=payload.llm_selection if payload else None,
|
|
)
|
|
return _legacy_run_stream(req)
|
|
|
|
|
|
@router.post("/doc/{layer}/{key}/audit")
|
|
async def audit_doc(layer: str, key: str, payload: AuditRequest | None = None):
|
|
lyr = _validate_layer(layer)
|
|
_validate_doc_key(lyr, key)
|
|
req = RunStartRequest(
|
|
layer=lyr,
|
|
key=key,
|
|
mode="audit",
|
|
language=(payload.language if payload else "en") or "en",
|
|
budget=payload.budget if payload else None,
|
|
llm_selection=payload.llm_selection if payload else None,
|
|
)
|
|
return _legacy_run_stream(req)
|
|
|
|
|
|
@router.post("/doc/{layer}/{key}/dedup")
|
|
async def dedup_doc(layer: str, key: str, payload: DedupRequest | None = None):
|
|
lyr = _validate_layer(layer)
|
|
_validate_doc_key(lyr, key)
|
|
req = RunStartRequest(
|
|
layer=lyr,
|
|
key=key,
|
|
mode="dedup",
|
|
language=(payload.language if payload else "en") or "en",
|
|
iterations=payload.iterations if payload else None,
|
|
llm_selection=payload.llm_selection if payload else None,
|
|
)
|
|
return _legacy_run_stream(req)
|
|
|
|
|
|
@router.get("/doc/{layer}/{key}/lines")
|
|
async def get_doc_lines(layer: str, key: str):
|
|
"""Return the line-numbered, footnote-stripped view of a doc.
|
|
|
|
Used by the workbench's "show line numbers" toggle so the same line
|
|
indices the audit/dedup LLMs see are visible to the user. Footnote
|
|
block is omitted because edit ops never reference it directly.
|
|
"""
|
|
lyr = _validate_layer(layer)
|
|
_validate_doc_key(lyr, key)
|
|
from deeptutor.services.memory import paths
|
|
from deeptutor.services.memory.consolidator.line_doc import render_view
|
|
from deeptutor.services.memory.document import Document, parse
|
|
|
|
path = paths.l2_file(key) if lyr == "L2" else paths.l3_file(key) # type: ignore[arg-type]
|
|
doc = (
|
|
parse(path.read_text(encoding="utf-8"))
|
|
if path.exists()
|
|
else Document(title=_default_title(lyr, key))
|
|
)
|
|
view = render_view(doc)
|
|
return {
|
|
"layer": lyr,
|
|
"key": key,
|
|
"lines": [
|
|
{
|
|
"number": line.number,
|
|
"kind": line.kind,
|
|
"text": line.text,
|
|
"entry_id": line.entry_id,
|
|
"section": line.section,
|
|
}
|
|
for line in view.lines
|
|
],
|
|
}
|
|
|
|
|
|
# ── Settings ────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/settings")
|
|
async def get_memory_settings_endpoint():
|
|
"""Return the current ``memory:`` subtree (defaults merged in)."""
|
|
from deeptutor.services.memory.settings import memory_settings_dict
|
|
|
|
return memory_settings_dict()
|
|
|
|
|
|
@router.put("/settings")
|
|
async def put_memory_settings(payload: dict):
|
|
"""Merge the payload into the ``memory:`` subtree and persist."""
|
|
from deeptutor.services.memory.settings import (
|
|
memory_settings_dict,
|
|
save_memory_settings,
|
|
)
|
|
|
|
save_memory_settings(payload)
|
|
return memory_settings_dict()
|
|
|
|
|
|
def _default_title(layer: str, key: str) -> str:
|
|
if layer == "L2":
|
|
return f"{key} memory"
|
|
return {
|
|
"recent": "Recent summary",
|
|
"profile": "User profile",
|
|
"scope": "Knowledge scope",
|
|
"preferences": "Preferences",
|
|
}.get(key, f"{key} memory")
|
|
|
|
|
|
class ApplyOpsRequest(BaseModel):
|
|
ops: list[dict]
|
|
|
|
|
|
@router.post("/doc/{layer}/{key}/apply")
|
|
async def apply_doc_ops(layer: str, key: str, payload: ApplyOpsRequest):
|
|
"""Commit a list of previously-previewed ops to a doc atomically."""
|
|
lyr = _validate_layer(layer)
|
|
_validate_doc_key(lyr, key)
|
|
if lyr == "L3" and key == "preferences":
|
|
raise HTTPException(
|
|
status_code=405,
|
|
detail="preferences is written by the write_memory tool, not consolidated",
|
|
)
|
|
if not payload.ops:
|
|
return {"accepted": True, "reason": "no ops to apply", "results": []}
|
|
|
|
report = await get_memory_store().apply_ops_payload(lyr, key, payload.ops)
|
|
return {
|
|
"accepted": report.accepted,
|
|
"reason": report.reason,
|
|
"results": [
|
|
{
|
|
"status": r.status,
|
|
"entry_id": r.entry_id,
|
|
"detail": r.detail,
|
|
}
|
|
for r in report.results
|
|
],
|
|
}
|
|
|
|
|
|
# ── Trace browser ────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/trace/{surface}")
|
|
async def get_trace(surface: str, limit: int = 200, offset: int = 0):
|
|
surf = _validate_surface(surface)
|
|
from deeptutor.services.memory.trace import iter_since
|
|
|
|
events = []
|
|
for i, event in enumerate(iter_since(surf)):
|
|
if i < offset:
|
|
continue
|
|
if len(events) >= max(1, min(limit, 1000)):
|
|
break
|
|
events.append(asdict(event))
|
|
return {"surface": surf, "events": events, "offset": offset, "limit": limit}
|
|
|
|
|
|
@router.delete("/trace/{surface}")
|
|
async def clear_trace(surface: str):
|
|
surf = _validate_surface(surface)
|
|
removed = 0
|
|
for path in paths.trace_dir(surf).glob("*.jsonl"):
|
|
try:
|
|
path.unlink()
|
|
removed += 1
|
|
except OSError:
|
|
continue
|
|
return {"surface": surf, "removed_files": removed}
|
|
|
|
|
|
@router.delete("/trace/{surface}/day/{day}")
|
|
async def clear_trace_day(surface: str, day: str):
|
|
surf = _validate_surface(surface)
|
|
try:
|
|
parsed = date_cls.fromisoformat(day)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="day must be YYYY-MM-DD")
|
|
path = paths.trace_file(surf, parsed)
|
|
if not path.exists():
|
|
raise HTTPException(status_code=404, detail="no trace for that day")
|
|
try:
|
|
path.unlink()
|
|
except OSError as exc:
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
return {"surface": surf, "day": day, "deleted": True}
|
|
|
|
|
|
# ── Snapshot (L1 workspace mirror) ───────────────────────────────────────
|
|
|
|
|
|
@router.get("/snapshot/{surface}")
|
|
async def get_snapshot(surface: str):
|
|
"""Return the current entity list for ``surface`` from workspace.
|
|
|
|
Snapshot is always derived live from workspace at call time. The response
|
|
also includes ``pending_changes`` — the diff vs the last persisted state.
|
|
Refresh commits these pending changes into ``changes.jsonl``.
|
|
"""
|
|
surf = _validate_surface(surface)
|
|
from deeptutor.services.memory import snapshot as snap
|
|
|
|
entities = snap.read_snapshot(surf)
|
|
pending = snap.pending_changes(surf, entities)
|
|
state = snap.current_state(surf)
|
|
return {
|
|
"surface": surf,
|
|
"entities": [e.to_dict() for e in entities],
|
|
"last_refresh": state.get("last_refresh"),
|
|
"pending_changes": [c.to_dict() for c in pending],
|
|
}
|
|
|
|
|
|
@router.post("/snapshot/{surface}/refresh")
|
|
async def refresh_snapshot(surface: str):
|
|
"""Reconcile persisted state with current workspace; record diffs."""
|
|
surf = _validate_surface(surface)
|
|
from deeptutor.services.memory import snapshot as snap
|
|
|
|
changes = snap.refresh_snapshot(surf)
|
|
state = snap.current_state(surf)
|
|
return {
|
|
"surface": surf,
|
|
"changes": [c.to_dict() for c in changes],
|
|
"last_refresh": state.get("last_refresh"),
|
|
}
|
|
|
|
|
|
@router.get("/snapshot/{surface}/changes")
|
|
async def get_changes(surface: str, limit: int = 200, offset: int = 0):
|
|
surf = _validate_surface(surface)
|
|
from deeptutor.services.memory import snapshot as snap
|
|
|
|
entries = snap.read_changes(surf, limit=limit, offset=offset)
|
|
return {
|
|
"surface": surf,
|
|
"changes": [c.to_dict() for c in entries],
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
|
|
|
|
@router.delete("/snapshot/{surface}/changes")
|
|
async def clear_snapshot_changes(surface: str):
|
|
surf = _validate_surface(surface)
|
|
from deeptutor.services.memory import snapshot as snap
|
|
|
|
snap.clear_changes(surf)
|
|
return {"surface": surf, "cleared": True}
|