Skip to content

feat: persist ConversationContext across sessions (closes #164)#168

Open
luceinaltis wants to merge 1 commit into
mainfrom
feat/164-persist-conversation-context
Open

feat: persist ConversationContext across sessions (closes #164)#168
luceinaltis wants to merge 1 commit into
mainfrom
feat/164-persist-conversation-context

Conversation

@luceinaltis
Copy link
Copy Markdown
Owner

Summary

Closes #164.

ConversationContext was previously rebuilt from the session log on every query and lost on process exit. This PR adds a durable layer so returning users keep their topic stack, last intent, and activity timestamp across restarts — the "welcome back" experience flagged as 구현 예정 in docs/user-experience.md.

What changed

  • qracer/conversation/context_store.py (new) — pure helpers:

    • save_context / load_context — JSON serialization, tolerant of missing files, malformed JSON, non-dict payloads, bad ISO timestamps, and non-string topic entries.
    • decay_stale(ctx, max_age_days=7) — clears topic_stack/current_topic once the gap since last_activity exceeds the threshold. Because the data model carries a single last_activity shared by all topics, "decay stale topics" is implemented as "the whole stack is abandoned once the threshold is crossed" — documented in the module docstring.
    • merge_with_theses(ctx, thesis_tickers) — folds open FactStore theses into the stack (deduped, bounded by MAX_TOPIC_STACK), seeding current_topic from the stack if unset.
    • merge_contexts(session, persisted) — combines an in-session context with the persisted one: session wins on every "right now" scalar (current_topic, intent, depth, last_activity); the persisted topic_stack supplies trailing entries.
  • qracer/conversation/engine.py — new context_path: Path | None = None kwarg. On init the persisted context is loaded, decayed, and enriched with FactStore.get_open_theses(). After each successful query the engine merges extract_context(turns) with the persisted context and writes the merged state back to disk. Load and save are both try/except-guarded so a bad file can never break the query loop.

  • qracer/cli.pyrepl wires context_path=_user_dir() / "context.json" so real users get persistence for free.

Scope mapping (issue #164)

Scope item Status
Serialize on session end / compaction ✅ Saved after every query (covers both)
Store in a dedicated context.json in ~/.qracer/ context.json
Load on session start, merge with FactStore open theses _load_initial_context
Decay stale topics (>7 days → remove from topic_stack) decay_stale, DEFAULT_STALE_DAYS=7

Test plan

  • uv run pytest tests/conversation/test_context_store.py tests/conversation/test_engine.py68 passed (23 new test_context_store cases + 5 new TestPersistentContext engine cases).
  • uv run pytest full suite — 806 passed, 14 skipped.
  • uv run ruff check on changed files — clean.
  • uv run pyright qracer/conversation/context_store.py qracer/conversation/engine.py — 0 errors.

New tests cover

  • Save/load round-trip preserves all fields (current_topic, stack, intent, depth, last_activity).
  • Missing file, malformed JSON, non-dict payload, bad timestamp, non-string topic entries all yield a safe default context.
  • Fresh context preserved by decay_stale; stale context clears stack but keeps last_activity; custom max_age_days; empty-context fast-path.
  • merge_with_theses appends, dedupes, respects MAX_TOPIC_STACK, seeds current_topic, preserves existing one.
  • merge_contexts session-wins semantics, persisted fallback for missing scalars, topic-stack merge order (session first), MAX_TOPIC_STACK bound, last_activity carries session's value.
  • Engine integration: context file written after a query, topics resumed in a fresh engine with the same context_path, stale context decayed on load, FactStore open theses merged into the stack, engine without context_path is a silent no-op.

Manual verification

from pathlib import Path
from qracer.conversation.context import ConversationContext
from qracer.conversation.context_store import save_context, load_context

ctx = ConversationContext(current_topic="AAPL", topic_stack=["AAPL", "NVDA"], intent="research")
save_context(ctx, Path("/tmp/ctx.json"))
load_context(Path("/tmp/ctx.json"))
# ConversationContext(current_topic='AAPL', topic_stack=['AAPL', 'NVDA'], intent='research', ...)

Closes #164.

ConversationContext was previously rebuilt from the session log on every
query and lost on process exit. This adds a durable layer so returning
users keep their topic stack, last intent, and activity timestamp.

### What changed

- **`qracer/conversation/context_store.py`** (new) — `save_context` /
  `load_context` (JSON, malformed-input tolerant), `decay_stale` that
  clears the topic_stack when the gap exceeds `DEFAULT_STALE_DAYS` (7),
  `merge_with_theses` that folds open FactStore theses into the stack
  up to `MAX_TOPIC_STACK`, and `merge_contexts` that combines an
  in-session context with the persisted one (session wins on scalars,
  persisted supplies trailing topics).
- **`qracer/conversation/engine.py`** — new `context_path` kwarg; on
  init the persisted context is loaded, decayed, and enriched with
  open theses, then exposed via `self._context`. After each query the
  engine merges the session-log context with the persisted one and
  writes the result back to disk. Load/save are try/except-guarded so
  a bad file never breaks the query loop.
- **`qracer/cli.py`** — `repl` wires `context_path` to
  `~/.qracer/context.json`.

### Tests

- `tests/conversation/test_context_store.py` (new, 23 tests) covers
  save/load round-trip, missing/malformed/non-dict fallbacks, bad
  timestamps, non-string topic filtering, decay (fresh/stale/custom
  threshold/empty fast-path), and both merge helpers including dedup
  and `MAX_TOPIC_STACK` bounds.
- `tests/conversation/test_engine.py` — 5 new integration tests:
  context file written after query, topics resumed in a fresh engine,
  stale context decayed on load, open theses merged into topic_stack,
  no-crash when `context_path` is unset.

Full suite: 806 passed, 14 skipped. Ruff + pyright clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: persist ConversationContext across sessions

2 participants