diff --git a/docs/master-plan-status.md b/docs/master-plan-status.md index ed6a53c..02339cd 100644 --- a/docs/master-plan-status.md +++ b/docs/master-plan-status.md @@ -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 diff --git a/src/atocore/api/routes.py b/src/atocore/api/routes.py index d49d815..323197d 100644 --- a/src/atocore/api/routes.py +++ b/src/atocore/api/routes.py @@ -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 diff --git a/src/atocore/interactions/__init__.py b/src/atocore/interactions/__init__.py new file mode 100644 index 0000000..7345f54 --- /dev/null +++ b/src/atocore/interactions/__init__.py @@ -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", +] diff --git a/src/atocore/interactions/service.py b/src/atocore/interactions/service.py new file mode 100644 index 0000000..b029e30 --- /dev/null +++ b/src/atocore/interactions/service.py @@ -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 diff --git a/src/atocore/models/database.py b/src/atocore/models/database.py index 4700722..34b8d9a 100644 --- a/src/atocore/models/database.py +++ b/src/atocore/models/database.py @@ -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() diff --git a/tests/test_interactions.py b/tests/test_interactions.py new file mode 100644 index 0000000..48685fa --- /dev/null +++ b/tests/test_interactions.py @@ -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]