Files
ATOCore/tests/test_interactions.py
Anto01 ea3fed3d44 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.
2026-04-06 19:31:43 -04:00

212 lines
7.3 KiB
Python

"""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]