Files
project-lyra/tests/test_transcript.py
T
serversdown a4412aa023 feat: full-fidelity conversation export (chat + tool calls)
Chat only ever lived in SQLite's `exchanges` table (what was *said*); tool
calls were transient — logged to the in-memory ring buffer and gone at
end-of-turn. This adds a persistent record of what Lyra *did* and exports the
two merged into one transcript.

- memory: new `tool_events` table + `add_tool_event`/`tool_events` accessors;
  `delete_session` cascades to it.
- chat: persist each tool call (name/args/result) right where it fires, in both
  the non-stream and stream paths. Move the user `remember` to just after
  assembly so its timestamp precedes mid-turn tool events — keeps the export in
  true chronological order (and records when the message actually arrived).
- transcript: new module renders a session as Markdown (Brian/Lyra speech with
  ⚙ tool-call lines interleaved) or JSON (machine-readable event stream).
- server: GET /sessions/{id}/export?format=md|json (attachment download).
- ui: ⬇ Export button by the session selector (Markdown or JSON).

Doubles as the receipt for "did the tool actually fire?" — the thing that was
invisible when she'd reply about a hand without logging it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:22:03 +00:00

83 lines
2.8 KiB
Python

"""Conversation export: speech (exchanges) + actions (tool_events) merged in order."""
from __future__ import annotations
import importlib
import json
import pytest
def _const_embed(texts):
return [[1e-6] * 8 for _ in texts]
@pytest.fixture
def mods(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
from lyra import llm
monkeypatch.setattr(llm, "embed", _const_embed)
import lyra.memory as memory
importlib.reload(memory)
import lyra.transcript as transcript
importlib.reload(transcript)
return memory, transcript
def _seed(memory):
"""A turn where Brian narrates a hand and Lyra logs it, then replies."""
memory.ensure_session("s1", name="Meadows 1/3")
memory.remember("s1", "user", "got it in with a set, he had the flush draw and bricked")
memory.add_tool_event("s1", "record_hand", {"result": "won", "pot": 750}, "hand #42 logged")
memory.add_tool_event("s1", "log_stack", {"amount": 750, "note": "doubled up"}, "ok")
memory.remember("s1", "assistant", "Clean stack-off — logged it to your timeline.")
def test_tool_events_roundtrip_parses_args(mods):
memory, _ = mods
_seed(memory)
events = memory.tool_events("s1")
assert [e["tool"] for e in events] == ["record_hand", "log_stack"]
assert events[0]["args"] == {"result": "won", "pot": 750} # parsed back to a dict
assert events[1]["result"] == "ok"
def test_markdown_interleaves_speech_and_actions_in_order(mods):
memory, transcript = mods
_seed(memory)
md = transcript.as_markdown("s1", name="Meadows 1/3")
# user message, then both tool calls, then assistant reply — in that order
i_user = md.index("got it in with a set")
i_hand = md.index("record_hand")
i_stack = md.index("log_stack")
i_reply = md.index("Clean stack-off")
assert i_user < i_hand < i_stack < i_reply
assert "**Brian**" in md and "**Lyra**" in md
assert "" in md
def test_json_export_is_machine_readable(mods):
memory, transcript = mods
_seed(memory)
payload = transcript.as_json("s1", name="Meadows 1/3")
assert payload["session_id"] == "s1"
types = [e["type"] for e in payload["events"]]
assert types == ["message", "tool", "tool", "message"]
json.dumps(payload) # must be serializable
def test_build_returns_filename_and_media_type(mods):
memory, transcript = mods
_seed(memory)
body_md, mt_md, fn_md = transcript.build("s1", "md", "Meadows 1/3")
body_js, mt_js, fn_js = transcript.build("s1", "json", "Meadows 1/3")
assert fn_md.endswith(".md") and "markdown" in mt_md
assert fn_js.endswith(".json") and mt_js == "application/json"
assert body_md and body_js
def test_delete_session_clears_tool_events(mods):
memory, _ = mods
_seed(memory)
memory.delete_session("s1")
assert memory.tool_events("s1") == []