feat(phase9-A): interaction capture loop foundation
Phase 9 Commit A from the agreed plan: turn AtoCore from a stateless
context enhancer into a system that records what it actually fed to an
LLM and what came back. This is the audit trail Reflection (Commit B)
and Extraction (Commit C) will be layered on top of.
The interactions table existed in the schema since the original PoC
but nothing wrote to it. This change makes it real:
Schema migration (additive only):
- response full LLM response (caller decides how much)
- memories_used JSON list of memory ids in the context pack
- chunks_used JSON list of chunk ids in the context pack
- client identifier of the calling system
(openclaw, claude-code, manual, ...)
- session_id groups multi-turn conversations
- project project name (mirrors the memory module pattern,
no FK so capture stays cheap)
- indexes on session_id, project, created_at
The created_at column is now written explicitly with a SQLite-compatible
'YYYY-MM-DD HH:MM:SS' format so the same string lives in the DB and the
returned dataclass. Without this the `since` filter on list_interactions
would silently fail because CURRENT_TIMESTAMP and isoformat use different
shapes that do not compare cleanly as strings.
New module src/atocore/interactions/:
- Interaction dataclass
- record_interaction() persists one round-trip (prompt required;
everything else optional). Refuses empty prompts.
- list_interactions() filters by project / session_id / client / since,
newest-first, hard-capped at 500
- get_interaction() fetch by id, full response + context pack
API endpoints:
- POST /interactions capture one interaction
- GET /interactions list with summaries (no full response)
- GET /interactions/{id} full record incl. response + pack
Trust model:
- Capture is read-only with respect to memories, project state, and
source chunks. Nothing here promotes anything into trusted state.
- The audit trail becomes the dataset Commit B (reinforcement) and
Commit C (extraction + review queue) will operate on.
Tests (13 new, all green):
- service: persist + roundtrip every field
- service: minimum-fields path (prompt only)
- service: empty / whitespace prompt rejected
- service: get by id returns None for missing
- service: filter by project, session, client
- service: ordering newest-first with limit
- service: since filter inclusive on cutoff (the bug the timestamp
fix above caught)
- service: limit=0 returns empty
- API: POST records and round-trips through GET /interactions/{id}
- API: empty prompt returns 400
- API: missing id returns 404
- API: list filter returns summaries (not full response bodies)
Full suite: 118 passing (was 105).
master-plan-status.md updated to move Phase 9 from "not started" to
"started" with the explicit note that Commit A is in and Commits B/C
remain.
This commit is contained in:
@@ -29,10 +29,14 @@ read-only additive mode.
|
||||
- Phase 4 - Identity / Preferences
|
||||
- Phase 8 - OpenClaw Integration
|
||||
|
||||
### Started
|
||||
|
||||
- Phase 9 - Reflection (Commit A: capture loop in place; Commits B/C
|
||||
reinforcement and extraction still pending)
|
||||
|
||||
### Not Yet Complete In The Intended Sense
|
||||
|
||||
- Phase 6 - AtoDrive
|
||||
- Phase 9 - Reflection
|
||||
- Phase 10 - Write-back
|
||||
- Phase 11 - Multi-model
|
||||
- Phase 12 - Evaluation
|
||||
|
||||
@@ -25,6 +25,11 @@ from atocore.ingestion.pipeline import (
|
||||
ingest_file,
|
||||
ingest_folder,
|
||||
)
|
||||
from atocore.interactions.service import (
|
||||
get_interaction,
|
||||
list_interactions,
|
||||
record_interaction,
|
||||
)
|
||||
from atocore.memory.service import (
|
||||
MEMORY_TYPES,
|
||||
create_memory,
|
||||
@@ -446,6 +451,106 @@ def api_invalidate_project_state(req: ProjectStateInvalidateRequest) -> dict:
|
||||
return {"status": "invalidated", "project": req.project, "category": req.category, "key": req.key}
|
||||
|
||||
|
||||
class InteractionRecordRequest(BaseModel):
|
||||
prompt: str
|
||||
response: str = ""
|
||||
response_summary: str = ""
|
||||
project: str = ""
|
||||
client: str = ""
|
||||
session_id: str = ""
|
||||
memories_used: list[str] = []
|
||||
chunks_used: list[str] = []
|
||||
context_pack: dict | None = None
|
||||
|
||||
|
||||
@router.post("/interactions")
|
||||
def api_record_interaction(req: InteractionRecordRequest) -> dict:
|
||||
"""Capture one interaction (prompt + response + what was used).
|
||||
|
||||
This is the foundation of the AtoCore reflection loop. It records
|
||||
what the system fed to an LLM and what came back, but does not
|
||||
promote anything into trusted state. Phase 9 Commit B/C will layer
|
||||
reinforcement and extraction on top of this audit trail.
|
||||
"""
|
||||
try:
|
||||
interaction = record_interaction(
|
||||
prompt=req.prompt,
|
||||
response=req.response,
|
||||
response_summary=req.response_summary,
|
||||
project=req.project,
|
||||
client=req.client,
|
||||
session_id=req.session_id,
|
||||
memories_used=req.memories_used,
|
||||
chunks_used=req.chunks_used,
|
||||
context_pack=req.context_pack,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {
|
||||
"status": "recorded",
|
||||
"id": interaction.id,
|
||||
"created_at": interaction.created_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/interactions")
|
||||
def api_list_interactions(
|
||||
project: str | None = None,
|
||||
session_id: str | None = None,
|
||||
client: str | None = None,
|
||||
since: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> dict:
|
||||
"""List captured interactions, optionally filtered by project, session,
|
||||
client, or creation time. Hard-capped at 500 entries per call."""
|
||||
interactions = list_interactions(
|
||||
project=project,
|
||||
session_id=session_id,
|
||||
client=client,
|
||||
since=since,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"count": len(interactions),
|
||||
"interactions": [
|
||||
{
|
||||
"id": i.id,
|
||||
"prompt": i.prompt,
|
||||
"response_summary": i.response_summary,
|
||||
"response_chars": len(i.response),
|
||||
"project": i.project,
|
||||
"client": i.client,
|
||||
"session_id": i.session_id,
|
||||
"memories_used": i.memories_used,
|
||||
"chunks_used": i.chunks_used,
|
||||
"created_at": i.created_at,
|
||||
}
|
||||
for i in interactions
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/interactions/{interaction_id}")
|
||||
def api_get_interaction(interaction_id: str) -> dict:
|
||||
"""Fetch a single interaction with the full response and context pack."""
|
||||
interaction = get_interaction(interaction_id)
|
||||
if interaction is None:
|
||||
raise HTTPException(status_code=404, detail=f"Interaction not found: {interaction_id}")
|
||||
return {
|
||||
"id": interaction.id,
|
||||
"prompt": interaction.prompt,
|
||||
"response": interaction.response,
|
||||
"response_summary": interaction.response_summary,
|
||||
"project": interaction.project,
|
||||
"client": interaction.client,
|
||||
"session_id": interaction.session_id,
|
||||
"memories_used": interaction.memories_used,
|
||||
"chunks_used": interaction.chunks_used,
|
||||
"context_pack": interaction.context_pack,
|
||||
"created_at": interaction.created_at,
|
||||
}
|
||||
|
||||
|
||||
class BackupCreateRequest(BaseModel):
|
||||
include_chroma: bool = False
|
||||
|
||||
|
||||
27
src/atocore/interactions/__init__.py
Normal file
27
src/atocore/interactions/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Interactions: capture loop for AtoCore.
|
||||
|
||||
This module is the foundation for Phase 9 (Reflection) and Phase 10
|
||||
(Write-back). It records what AtoCore fed to an LLM and what came back,
|
||||
so that later phases can:
|
||||
|
||||
- reinforce active memories that the LLM actually relied on
|
||||
- extract candidate memories / project state from real conversations
|
||||
- inspect the audit trail of any answer the system helped produce
|
||||
|
||||
Nothing here automatically promotes information into trusted state.
|
||||
The capture loop is intentionally read-only with respect to trust.
|
||||
"""
|
||||
|
||||
from atocore.interactions.service import (
|
||||
Interaction,
|
||||
get_interaction,
|
||||
list_interactions,
|
||||
record_interaction,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Interaction",
|
||||
"get_interaction",
|
||||
"list_interactions",
|
||||
"record_interaction",
|
||||
]
|
||||
219
src/atocore/interactions/service.py
Normal file
219
src/atocore/interactions/service.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Interaction capture service.
|
||||
|
||||
An *interaction* is one round-trip of:
|
||||
- a user prompt
|
||||
- the AtoCore context pack that was assembled for it
|
||||
- the LLM response (full text or a summary, caller's choice)
|
||||
- which memories and chunks were actually used in the pack
|
||||
- a client identifier (e.g. ``openclaw``, ``claude-code``, ``manual``)
|
||||
- an optional session identifier so multi-turn conversations can be
|
||||
reconstructed later
|
||||
|
||||
The capture is intentionally additive: it never modifies memories,
|
||||
project state, or chunks. Reflection (Phase 9 Commit B/C) and
|
||||
write-back (Phase 10) are layered on top of this audit trail without
|
||||
violating the AtoCore trust hierarchy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from atocore.models.database import get_connection
|
||||
from atocore.observability.logger import get_logger
|
||||
|
||||
log = get_logger("interactions")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Interaction:
|
||||
id: str
|
||||
prompt: str
|
||||
response: str
|
||||
response_summary: str
|
||||
project: str
|
||||
client: str
|
||||
session_id: str
|
||||
memories_used: list[str] = field(default_factory=list)
|
||||
chunks_used: list[str] = field(default_factory=list)
|
||||
context_pack: dict = field(default_factory=dict)
|
||||
created_at: str = ""
|
||||
|
||||
|
||||
def record_interaction(
|
||||
prompt: str,
|
||||
response: str = "",
|
||||
response_summary: str = "",
|
||||
project: str = "",
|
||||
client: str = "",
|
||||
session_id: str = "",
|
||||
memories_used: list[str] | None = None,
|
||||
chunks_used: list[str] | None = None,
|
||||
context_pack: dict | None = None,
|
||||
) -> Interaction:
|
||||
"""Persist a single interaction to the audit trail.
|
||||
|
||||
The only required field is ``prompt`` so this can be called even when
|
||||
the caller is in the middle of a partial turn (for example to record
|
||||
that AtoCore was queried even before the LLM response is back).
|
||||
"""
|
||||
if not prompt or not prompt.strip():
|
||||
raise ValueError("Interaction prompt must be non-empty")
|
||||
|
||||
interaction_id = str(uuid.uuid4())
|
||||
# Store created_at explicitly so the same string lives in both the DB
|
||||
# column and the returned dataclass. SQLite's CURRENT_TIMESTAMP uses
|
||||
# 'YYYY-MM-DD HH:MM:SS' which would not compare cleanly against ISO
|
||||
# timestamps with 'T' and tz offset, breaking the `since` filter on
|
||||
# list_interactions.
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
memories_used = list(memories_used or [])
|
||||
chunks_used = list(chunks_used or [])
|
||||
context_pack_payload = context_pack or {}
|
||||
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO interactions (
|
||||
id, prompt, context_pack, response_summary, response,
|
||||
memories_used, chunks_used, client, session_id, project,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
interaction_id,
|
||||
prompt,
|
||||
json.dumps(context_pack_payload, ensure_ascii=True),
|
||||
response_summary,
|
||||
response,
|
||||
json.dumps(memories_used, ensure_ascii=True),
|
||||
json.dumps(chunks_used, ensure_ascii=True),
|
||||
client,
|
||||
session_id,
|
||||
project,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
log.info(
|
||||
"interaction_recorded",
|
||||
interaction_id=interaction_id,
|
||||
project=project,
|
||||
client=client,
|
||||
session_id=session_id,
|
||||
memories_used=len(memories_used),
|
||||
chunks_used=len(chunks_used),
|
||||
response_chars=len(response),
|
||||
)
|
||||
|
||||
return Interaction(
|
||||
id=interaction_id,
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
response_summary=response_summary,
|
||||
project=project,
|
||||
client=client,
|
||||
session_id=session_id,
|
||||
memories_used=memories_used,
|
||||
chunks_used=chunks_used,
|
||||
context_pack=context_pack_payload,
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
|
||||
def list_interactions(
|
||||
project: str | None = None,
|
||||
session_id: str | None = None,
|
||||
client: str | None = None,
|
||||
since: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[Interaction]:
|
||||
"""List captured interactions, optionally filtered.
|
||||
|
||||
``since`` is an ISO timestamp string; only interactions created at or
|
||||
after that time are returned. ``limit`` is hard-capped at 500 to keep
|
||||
casual API listings cheap.
|
||||
"""
|
||||
if limit <= 0:
|
||||
return []
|
||||
limit = min(limit, 500)
|
||||
|
||||
query = "SELECT * FROM interactions WHERE 1=1"
|
||||
params: list = []
|
||||
|
||||
if project:
|
||||
query += " AND project = ?"
|
||||
params.append(project)
|
||||
if session_id:
|
||||
query += " AND session_id = ?"
|
||||
params.append(session_id)
|
||||
if client:
|
||||
query += " AND client = ?"
|
||||
params.append(client)
|
||||
if since:
|
||||
query += " AND created_at >= ?"
|
||||
params.append(since)
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
return [_row_to_interaction(row) for row in rows]
|
||||
|
||||
|
||||
def get_interaction(interaction_id: str) -> Interaction | None:
|
||||
"""Fetch one interaction by id, or return None if it does not exist."""
|
||||
if not interaction_id:
|
||||
return None
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM interactions WHERE id = ?", (interaction_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_interaction(row)
|
||||
|
||||
|
||||
def _row_to_interaction(row) -> Interaction:
|
||||
return Interaction(
|
||||
id=row["id"],
|
||||
prompt=row["prompt"],
|
||||
response=row["response"] or "",
|
||||
response_summary=row["response_summary"] or "",
|
||||
project=row["project"] or "",
|
||||
client=row["client"] or "",
|
||||
session_id=row["session_id"] or "",
|
||||
memories_used=_safe_json_list(row["memories_used"]),
|
||||
chunks_used=_safe_json_list(row["chunks_used"]),
|
||||
context_pack=_safe_json_dict(row["context_pack"]),
|
||||
created_at=row["created_at"] or "",
|
||||
)
|
||||
|
||||
|
||||
def _safe_json_list(raw: str | None) -> list[str]:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(item) for item in value]
|
||||
|
||||
|
||||
def _safe_json_dict(raw: str | None) -> dict:
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
return value
|
||||
@@ -59,6 +59,12 @@ CREATE TABLE IF NOT EXISTS interactions (
|
||||
prompt TEXT NOT NULL,
|
||||
context_pack TEXT DEFAULT '{}',
|
||||
response_summary TEXT DEFAULT '',
|
||||
response TEXT DEFAULT '',
|
||||
memories_used TEXT DEFAULT '[]',
|
||||
chunks_used TEXT DEFAULT '[]',
|
||||
client TEXT DEFAULT '',
|
||||
session_id TEXT DEFAULT '',
|
||||
project TEXT DEFAULT '',
|
||||
project_id TEXT REFERENCES projects(id),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -68,6 +74,9 @@ CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(memory_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_status ON memories(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_project ON interactions(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_project_name ON interactions(project);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_session ON interactions(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_created_at ON interactions(created_at);
|
||||
"""
|
||||
|
||||
|
||||
@@ -90,6 +99,33 @@ def _apply_migrations(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("ALTER TABLE memories ADD COLUMN project TEXT DEFAULT ''")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project)")
|
||||
|
||||
# Phase 9 Commit A: capture loop columns on the interactions table.
|
||||
# The original schema only carried prompt + project_id + a context_pack
|
||||
# JSON blob. To make interactions a real audit trail of what AtoCore fed
|
||||
# the LLM and what came back, we record the full response, the chunk
|
||||
# and memory ids that were actually used, plus client + session metadata.
|
||||
if not _column_exists(conn, "interactions", "response"):
|
||||
conn.execute("ALTER TABLE interactions ADD COLUMN response TEXT DEFAULT ''")
|
||||
if not _column_exists(conn, "interactions", "memories_used"):
|
||||
conn.execute("ALTER TABLE interactions ADD COLUMN memories_used TEXT DEFAULT '[]'")
|
||||
if not _column_exists(conn, "interactions", "chunks_used"):
|
||||
conn.execute("ALTER TABLE interactions ADD COLUMN chunks_used TEXT DEFAULT '[]'")
|
||||
if not _column_exists(conn, "interactions", "client"):
|
||||
conn.execute("ALTER TABLE interactions ADD COLUMN client TEXT DEFAULT ''")
|
||||
if not _column_exists(conn, "interactions", "session_id"):
|
||||
conn.execute("ALTER TABLE interactions ADD COLUMN session_id TEXT DEFAULT ''")
|
||||
if not _column_exists(conn, "interactions", "project"):
|
||||
conn.execute("ALTER TABLE interactions ADD COLUMN project TEXT DEFAULT ''")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_interactions_session ON interactions(session_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_interactions_project_name ON interactions(project)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_interactions_created_at ON interactions(created_at)"
|
||||
)
|
||||
|
||||
|
||||
def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
|
||||
211
tests/test_interactions.py
Normal file
211
tests/test_interactions.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Tests for the Phase 9 Commit A interaction capture loop."""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from atocore.interactions.service import (
|
||||
get_interaction,
|
||||
list_interactions,
|
||||
record_interaction,
|
||||
)
|
||||
from atocore.main import app
|
||||
from atocore.models.database import init_db
|
||||
|
||||
|
||||
# --- Service-level tests --------------------------------------------------
|
||||
|
||||
|
||||
def test_record_interaction_persists_all_fields(tmp_data_dir):
|
||||
init_db()
|
||||
interaction = record_interaction(
|
||||
prompt="What is the lateral support material for p05?",
|
||||
response="The current lateral support uses GF-PTFE pads per Decision D-024.",
|
||||
response_summary="lateral support: GF-PTFE per D-024",
|
||||
project="p05-interferometer",
|
||||
client="claude-code",
|
||||
session_id="sess-001",
|
||||
memories_used=["mem-aaa", "mem-bbb"],
|
||||
chunks_used=["chunk-111", "chunk-222", "chunk-333"],
|
||||
context_pack={"budget": 3000, "chunks": 3},
|
||||
)
|
||||
|
||||
assert interaction.id
|
||||
assert interaction.created_at
|
||||
|
||||
fetched = get_interaction(interaction.id)
|
||||
assert fetched is not None
|
||||
assert fetched.prompt.startswith("What is the lateral support")
|
||||
assert fetched.response.startswith("The current lateral support")
|
||||
assert fetched.response_summary == "lateral support: GF-PTFE per D-024"
|
||||
assert fetched.project == "p05-interferometer"
|
||||
assert fetched.client == "claude-code"
|
||||
assert fetched.session_id == "sess-001"
|
||||
assert fetched.memories_used == ["mem-aaa", "mem-bbb"]
|
||||
assert fetched.chunks_used == ["chunk-111", "chunk-222", "chunk-333"]
|
||||
assert fetched.context_pack == {"budget": 3000, "chunks": 3}
|
||||
|
||||
|
||||
def test_record_interaction_minimum_fields(tmp_data_dir):
|
||||
init_db()
|
||||
interaction = record_interaction(prompt="ping")
|
||||
assert interaction.id
|
||||
assert interaction.prompt == "ping"
|
||||
assert interaction.response == ""
|
||||
assert interaction.memories_used == []
|
||||
assert interaction.chunks_used == []
|
||||
|
||||
|
||||
def test_record_interaction_rejects_empty_prompt(tmp_data_dir):
|
||||
init_db()
|
||||
with pytest.raises(ValueError):
|
||||
record_interaction(prompt="")
|
||||
with pytest.raises(ValueError):
|
||||
record_interaction(prompt=" ")
|
||||
|
||||
|
||||
def test_get_interaction_returns_none_for_unknown_id(tmp_data_dir):
|
||||
init_db()
|
||||
assert get_interaction("does-not-exist") is None
|
||||
assert get_interaction("") is None
|
||||
|
||||
|
||||
def test_list_interactions_filters_by_project(tmp_data_dir):
|
||||
init_db()
|
||||
record_interaction(prompt="p04 question", project="p04-gigabit")
|
||||
record_interaction(prompt="p05 question", project="p05-interferometer")
|
||||
record_interaction(prompt="another p05", project="p05-interferometer")
|
||||
|
||||
p05 = list_interactions(project="p05-interferometer")
|
||||
p04 = list_interactions(project="p04-gigabit")
|
||||
|
||||
assert len(p05) == 2
|
||||
assert len(p04) == 1
|
||||
assert all(i.project == "p05-interferometer" for i in p05)
|
||||
assert p04[0].prompt == "p04 question"
|
||||
|
||||
|
||||
def test_list_interactions_filters_by_session_and_client(tmp_data_dir):
|
||||
init_db()
|
||||
record_interaction(prompt="a", session_id="sess-A", client="openclaw")
|
||||
record_interaction(prompt="b", session_id="sess-A", client="claude-code")
|
||||
record_interaction(prompt="c", session_id="sess-B", client="openclaw")
|
||||
|
||||
sess_a = list_interactions(session_id="sess-A")
|
||||
openclaw = list_interactions(client="openclaw")
|
||||
|
||||
assert len(sess_a) == 2
|
||||
assert len(openclaw) == 2
|
||||
assert {i.client for i in sess_a} == {"openclaw", "claude-code"}
|
||||
|
||||
|
||||
def test_list_interactions_orders_newest_first_and_respects_limit(tmp_data_dir):
|
||||
init_db()
|
||||
# created_at has 1-second resolution; sleep enough to keep ordering
|
||||
# deterministic regardless of insert speed.
|
||||
for index in range(5):
|
||||
record_interaction(prompt=f"prompt-{index}")
|
||||
time.sleep(1.05)
|
||||
|
||||
items = list_interactions(limit=3)
|
||||
assert len(items) == 3
|
||||
# Newest first: prompt-4, prompt-3, prompt-2
|
||||
assert items[0].prompt == "prompt-4"
|
||||
assert items[1].prompt == "prompt-3"
|
||||
assert items[2].prompt == "prompt-2"
|
||||
|
||||
|
||||
def test_list_interactions_respects_since_filter(tmp_data_dir):
|
||||
init_db()
|
||||
first = record_interaction(prompt="early")
|
||||
time.sleep(1.05)
|
||||
second = record_interaction(prompt="late")
|
||||
|
||||
after_first = list_interactions(since=first.created_at)
|
||||
ids_after_first = {item.id for item in after_first}
|
||||
assert second.id in ids_after_first
|
||||
assert first.id in ids_after_first # cutoff is inclusive
|
||||
|
||||
after_second = list_interactions(since=second.created_at)
|
||||
ids_after_second = {item.id for item in after_second}
|
||||
assert second.id in ids_after_second
|
||||
assert first.id not in ids_after_second
|
||||
|
||||
|
||||
def test_list_interactions_zero_limit_returns_empty(tmp_data_dir):
|
||||
init_db()
|
||||
record_interaction(prompt="ping")
|
||||
assert list_interactions(limit=0) == []
|
||||
|
||||
|
||||
# --- API-level tests ------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_interactions_endpoint_records_interaction(tmp_data_dir):
|
||||
init_db()
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/interactions",
|
||||
json={
|
||||
"prompt": "What changed in p06 this week?",
|
||||
"response": "Polisher kinematic frame parameters updated to v0.3.",
|
||||
"response_summary": "p06 frame parameters bumped to v0.3",
|
||||
"project": "p06-polisher",
|
||||
"client": "claude-code",
|
||||
"session_id": "sess-xyz",
|
||||
"memories_used": ["mem-1"],
|
||||
"chunks_used": ["chunk-a", "chunk-b"],
|
||||
"context_pack": {"chunks": 2},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "recorded"
|
||||
interaction_id = body["id"]
|
||||
|
||||
# Round-trip via the GET endpoint
|
||||
fetched = client.get(f"/interactions/{interaction_id}")
|
||||
assert fetched.status_code == 200
|
||||
fetched_body = fetched.json()
|
||||
assert fetched_body["prompt"].startswith("What changed in p06")
|
||||
assert fetched_body["response"].startswith("Polisher kinematic frame")
|
||||
assert fetched_body["project"] == "p06-polisher"
|
||||
assert fetched_body["chunks_used"] == ["chunk-a", "chunk-b"]
|
||||
assert fetched_body["context_pack"] == {"chunks": 2}
|
||||
|
||||
|
||||
def test_post_interactions_rejects_empty_prompt(tmp_data_dir):
|
||||
init_db()
|
||||
client = TestClient(app)
|
||||
response = client.post("/interactions", json={"prompt": ""})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_get_unknown_interaction_returns_404(tmp_data_dir):
|
||||
init_db()
|
||||
client = TestClient(app)
|
||||
response = client.get("/interactions/does-not-exist")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_list_interactions_endpoint_returns_summaries(tmp_data_dir):
|
||||
init_db()
|
||||
client = TestClient(app)
|
||||
client.post(
|
||||
"/interactions",
|
||||
json={"prompt": "alpha", "project": "p04-gigabit", "response": "x" * 10},
|
||||
)
|
||||
client.post(
|
||||
"/interactions",
|
||||
json={"prompt": "beta", "project": "p05-interferometer", "response": "y" * 50},
|
||||
)
|
||||
|
||||
response = client.get("/interactions", params={"project": "p05-interferometer"})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["count"] == 1
|
||||
assert body["interactions"][0]["prompt"] == "beta"
|
||||
assert body["interactions"][0]["response_chars"] == 50
|
||||
# The list endpoint never includes the full response body
|
||||
assert "response" not in body["interactions"][0]
|
||||
Reference in New Issue
Block a user