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