From edac634a330a9efa0ebafc0e4de8309351b8cd19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 18:25:53 +0000 Subject: [PATCH] feat: persist ConversationContext across sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- qracer/cli.py | 1 + qracer/conversation/context_store.py | 139 ++++++++++++ qracer/conversation/engine.py | 44 +++- tests/conversation/test_context_store.py | 259 +++++++++++++++++++++++ tests/conversation/test_engine.py | 146 +++++++++++++ 5 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 qracer/conversation/context_store.py create mode 100644 tests/conversation/test_context_store.py diff --git a/qracer/cli.py b/qracer/cli.py index 7b394c7..bb987e5 100644 --- a/qracer/cli.py +++ b/qracer/cli.py @@ -979,6 +979,7 @@ def repl() -> None: memory_searcher=memory_searcher, summaries_dir=summaries_dir, fact_store=fact_store, + context_path=_user_dir() / "context.json", ) task_executor = TaskExecutor(task_store, data_registry, llm_registry, engine=engine) diff --git a/qracer/conversation/context_store.py b/qracer/conversation/context_store.py new file mode 100644 index 0000000..b38b9e3 --- /dev/null +++ b/qracer/conversation/context_store.py @@ -0,0 +1,139 @@ +"""Cross-session persistence for ``ConversationContext``. + +The in-session ``ConversationContext`` is rebuilt from the session log on +every query. This module adds a durable layer: the context is serialized +to ``~/.qracer/context.json`` at the end of each query so a returning user +keeps their topic stack, last intent, and last activity timestamp across +restarts. + +The persisted state is conservative — topics older than +``DEFAULT_STALE_DAYS`` are discarded on load, and open theses from the +``FactStore`` are folded in so a returning user sees both recently +discussed tickers and still-active theses in their topic stack. +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timedelta +from pathlib import Path + +from qracer.conversation.constants import MAX_TOPIC_STACK +from qracer.conversation.context import ConversationContext + +logger = logging.getLogger(__name__) + +DEFAULT_STALE_DAYS = 7 + + +def save_context(context: ConversationContext, path: Path) -> None: + """Serialize a ``ConversationContext`` to ``path`` as JSON.""" + path.parent.mkdir(parents=True, exist_ok=True) + data = { + "current_topic": context.current_topic, + "topic_stack": list(context.topic_stack), + "intent": context.intent, + "depth": context.depth, + "last_activity": context.last_activity.isoformat(), + } + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def load_context(path: Path) -> ConversationContext: + """Load a persisted ``ConversationContext`` from ``path``. + + Returns an empty context if the file is missing or malformed so the + caller never has to special-case a first-run user. + """ + if not path.exists(): + return ConversationContext() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + logger.warning("Failed to load persisted context from %s", path, exc_info=True) + return ConversationContext() + if not isinstance(data, dict): + return ConversationContext() + try: + last_activity = datetime.fromisoformat(str(data.get("last_activity", ""))) + except (ValueError, TypeError): + last_activity = datetime.now() + topic_stack_raw = data.get("topic_stack") or [] + topic_stack = [t for t in topic_stack_raw if isinstance(t, str)] + current_topic = data.get("current_topic") + intent = data.get("intent") + depth = data.get("depth") + return ConversationContext( + current_topic=current_topic if isinstance(current_topic, str) else None, + topic_stack=topic_stack, + intent=intent if isinstance(intent, str) else None, + depth=depth if isinstance(depth, str) else "quick", + last_activity=last_activity, + ) + + +def decay_stale( + context: ConversationContext, max_age_days: int = DEFAULT_STALE_DAYS +) -> ConversationContext: + """Return a context with its topic stack cleared if it is stale. + + The data model tracks a single ``last_activity`` for the whole + conversation rather than per-topic timestamps, so "stale topics" is + implemented as "the whole stack is abandoned once the gap exceeds + the threshold". The returned context keeps ``last_activity`` so + downstream code can still detect staleness for messaging. + """ + if not context.topic_stack and context.current_topic is None: + return context + age = datetime.now() - context.last_activity + if age > timedelta(days=max_age_days): + return ConversationContext(last_activity=context.last_activity) + return context + + +def merge_with_theses( + context: ConversationContext, thesis_tickers: list[str] +) -> ConversationContext: + """Fold still-open ``FactStore`` theses into the topic stack. + + Existing entries keep their order; new ticker candidates are appended + up to ``MAX_TOPIC_STACK``. ``current_topic`` is seeded from the + topic stack if unset so a returning user with no session history + still gets a meaningful current topic. + """ + topic_stack = list(context.topic_stack) + for ticker in thesis_tickers: + if ticker not in topic_stack and len(topic_stack) < MAX_TOPIC_STACK: + topic_stack.append(ticker) + return ConversationContext( + current_topic=context.current_topic or (topic_stack[0] if topic_stack else None), + topic_stack=topic_stack, + intent=context.intent, + depth=context.depth, + last_activity=context.last_activity, + ) + + +def merge_contexts( + session: ConversationContext, persisted: ConversationContext +) -> ConversationContext: + """Combine an in-session context with a persisted one. + + Session values win on every field that represents a "right now" + signal (``current_topic``, ``intent``, ``depth``, ``last_activity``). + The persisted ``topic_stack`` supplies trailing entries so a brand + new session with an empty log still surfaces the user's prior + focus, bounded by ``MAX_TOPIC_STACK``. + """ + topic_stack = list(session.topic_stack) + for topic in persisted.topic_stack: + if topic not in topic_stack and len(topic_stack) < MAX_TOPIC_STACK: + topic_stack.append(topic) + return ConversationContext( + current_topic=session.current_topic or persisted.current_topic, + topic_stack=topic_stack, + intent=session.intent or persisted.intent, + depth=session.depth, + last_activity=session.last_activity, + ) diff --git a/qracer/conversation/engine.py b/qracer/conversation/engine.py index 2d546e5..c9329fe 100644 --- a/qracer/conversation/engine.py +++ b/qracer/conversation/engine.py @@ -24,6 +24,13 @@ is_stale, resolve_pronoun, ) +from qracer.conversation.context_store import ( + decay_stale, + load_context, + merge_contexts, + merge_with_theses, + save_context, +) from qracer.conversation.handlers import ( ComparisonHandler, PortfolioHandler, @@ -73,6 +80,7 @@ def __init__( language: str = "en", summaries_dir: Path | None = None, fact_store: FactStore | None = None, + context_path: Path | None = None, ) -> None: self._llm = llm_registry self._data = data_registry @@ -117,11 +125,37 @@ def __init__( self._session_id = session_logger.path.stem if session_logger else "unknown" self._compactor = SessionCompactor(llm_registry) if session_logger else None self._report_exporter = ReportExporter(report_dir) if report_dir else None - self._context: ConversationContext = ConversationContext() + self._context_path = context_path + self._persisted_context: ConversationContext = self._load_initial_context() + self._context: ConversationContext = self._persisted_context self._turn_counter = 0 self._last_response: EngineResponse | None = None self._config_version = 0 + def _load_initial_context(self) -> ConversationContext: + """Load, decay, and enrich the persisted context with open theses.""" + if self._context_path is None: + return ConversationContext() + ctx = decay_stale(load_context(self._context_path)) + if self._fact_store is not None: + try: + open_theses = self._fact_store.get_open_theses() + thesis_tickers = [t.ticker for t in open_theses] + if thesis_tickers: + ctx = merge_with_theses(ctx, thesis_tickers) + except Exception: + logger.warning("Failed to merge open theses into context", exc_info=True) + return ctx + + def _persist_context(self) -> None: + """Write the current context to disk if a path is configured.""" + if self._context_path is None: + return + try: + save_context(self._context, self._context_path) + except OSError: + logger.warning("Failed to persist conversation context", exc_info=True) + def update_registries( self, llm_registry: LLMRegistry, @@ -240,7 +274,10 @@ async def query(self, user_input: str) -> EngineResponse: # 0. Extract conversation context from session log. if self._session_logger is not None: turns = self._session_logger.read_all()[-50:] - self._context = extract_context(turns) + session_ctx = extract_context(turns) + # Fold persisted cross-session state in as trailing topics so a + # returning user keeps their prior focus even on a fresh log. + self._context = merge_contexts(session_ctx, self._persisted_context) # 0b. Check for stale context — notify user if returning after timeout. if is_stale(self._context) and self._context.current_topic: @@ -326,6 +363,9 @@ async def query(self, user_input: str) -> EngineResponse: response = EngineResponse(text=result.text, intent=intent, analysis=result.analysis) self._last_response = response self._persist_facts(result.analysis) + # Capture the latest context on disk so the next session resumes here. + self._persisted_context = self._context + self._persist_context() return response def _persist_facts(self, analysis: AnalysisResult) -> None: diff --git a/tests/conversation/test_context_store.py b/tests/conversation/test_context_store.py new file mode 100644 index 0000000..3e7a5a8 --- /dev/null +++ b/tests/conversation/test_context_store.py @@ -0,0 +1,259 @@ +"""Tests for the persistent ConversationContext store.""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta + +from qracer.conversation.context import ConversationContext +from qracer.conversation.context_store import ( + DEFAULT_STALE_DAYS, + decay_stale, + load_context, + merge_contexts, + merge_with_theses, + save_context, +) + + +class TestSaveLoadRoundTrip: + def test_roundtrip_preserves_all_fields(self, tmp_path): + activity = datetime(2026, 4, 10, 9, 30, 0) + ctx = ConversationContext( + current_topic="AAPL", + topic_stack=["AAPL", "TSLA", "NVDA"], + intent="research", + depth="deep", + last_activity=activity, + ) + path = tmp_path / "context.json" + + save_context(ctx, path) + loaded = load_context(path) + + assert loaded.current_topic == "AAPL" + assert loaded.topic_stack == ["AAPL", "TSLA", "NVDA"] + assert loaded.intent == "research" + assert loaded.depth == "deep" + assert loaded.last_activity == activity + + def test_save_creates_parent_directory(self, tmp_path): + ctx = ConversationContext(current_topic="AAPL", topic_stack=["AAPL"]) + path = tmp_path / "nested" / "dir" / "context.json" + + save_context(ctx, path) + + assert path.exists() + + def test_save_is_valid_json(self, tmp_path): + ctx = ConversationContext(current_topic="AAPL", topic_stack=["AAPL"]) + path = tmp_path / "context.json" + + save_context(ctx, path) + data = json.loads(path.read_text()) + + assert data["current_topic"] == "AAPL" + assert data["topic_stack"] == ["AAPL"] + + +class TestLoadFallbacks: + def test_missing_file_returns_empty_context(self, tmp_path): + ctx = load_context(tmp_path / "missing.json") + + assert ctx.current_topic is None + assert ctx.topic_stack == [] + assert ctx.intent is None + assert ctx.depth == "quick" + + def test_malformed_json_returns_empty_context(self, tmp_path): + path = tmp_path / "context.json" + path.write_text("{ this is not json") + + ctx = load_context(path) + + assert ctx.current_topic is None + assert ctx.topic_stack == [] + + def test_non_dict_json_returns_empty_context(self, tmp_path): + path = tmp_path / "context.json" + path.write_text("[]") + + ctx = load_context(path) + + assert ctx.topic_stack == [] + + def test_bad_timestamp_falls_back_to_now(self, tmp_path): + path = tmp_path / "context.json" + path.write_text(json.dumps({"topic_stack": ["AAPL"], "last_activity": "not-a-date"})) + + before = datetime.now() + ctx = load_context(path) + after = datetime.now() + + assert ctx.topic_stack == ["AAPL"] + assert before <= ctx.last_activity <= after + + def test_non_string_topics_are_filtered(self, tmp_path): + path = tmp_path / "context.json" + path.write_text( + json.dumps( + { + "topic_stack": ["AAPL", 42, None, "TSLA"], + "last_activity": datetime.now().isoformat(), + } + ) + ) + + ctx = load_context(path) + + assert ctx.topic_stack == ["AAPL", "TSLA"] + + +class TestDecayStale: + def test_fresh_context_preserved(self): + ctx = ConversationContext( + current_topic="AAPL", + topic_stack=["AAPL", "TSLA"], + last_activity=datetime.now() - timedelta(days=1), + ) + + result = decay_stale(ctx) + + assert result.topic_stack == ["AAPL", "TSLA"] + assert result.current_topic == "AAPL" + + def test_stale_context_clears_topics(self): + stale_activity = datetime.now() - timedelta(days=DEFAULT_STALE_DAYS + 1) + ctx = ConversationContext( + current_topic="AAPL", + topic_stack=["AAPL", "TSLA"], + last_activity=stale_activity, + ) + + result = decay_stale(ctx) + + assert result.topic_stack == [] + assert result.current_topic is None + # last_activity preserved so staleness can still be reported. + assert result.last_activity == stale_activity + + def test_custom_max_age(self): + ctx = ConversationContext( + current_topic="AAPL", + topic_stack=["AAPL"], + last_activity=datetime.now() - timedelta(days=2), + ) + + kept = decay_stale(ctx, max_age_days=7) + dropped = decay_stale(ctx, max_age_days=1) + + assert kept.topic_stack == ["AAPL"] + assert dropped.topic_stack == [] + + def test_empty_context_is_noop(self): + ctx = ConversationContext(last_activity=datetime.now() - timedelta(days=30)) + + result = decay_stale(ctx) + + assert result is ctx # fast-path returns unchanged + + +class TestMergeWithTheses: + def test_appends_new_thesis_tickers(self): + ctx = ConversationContext(topic_stack=["AAPL"]) + + result = merge_with_theses(ctx, ["NVDA", "TSLA"]) + + assert result.topic_stack == ["AAPL", "NVDA", "TSLA"] + + def test_deduplicates(self): + ctx = ConversationContext(topic_stack=["AAPL"]) + + result = merge_with_theses(ctx, ["AAPL", "TSLA"]) + + assert result.topic_stack == ["AAPL", "TSLA"] + + def test_respects_max_topic_stack(self): + ctx = ConversationContext(topic_stack=["A", "B", "C", "D"]) + + result = merge_with_theses(ctx, ["E", "F", "G"]) + + assert len(result.topic_stack) == 5 + assert result.topic_stack == ["A", "B", "C", "D", "E"] + + def test_seeds_current_topic_from_stack(self): + ctx = ConversationContext() + + result = merge_with_theses(ctx, ["NVDA", "TSLA"]) + + assert result.current_topic == "NVDA" + assert result.topic_stack == ["NVDA", "TSLA"] + + def test_existing_current_topic_preserved(self): + ctx = ConversationContext(current_topic="AAPL", topic_stack=["AAPL"]) + + result = merge_with_theses(ctx, ["NVDA"]) + + assert result.current_topic == "AAPL" + + def test_empty_thesis_list_is_noop_for_stack(self): + ctx = ConversationContext(current_topic="AAPL", topic_stack=["AAPL"]) + + result = merge_with_theses(ctx, []) + + assert result.topic_stack == ["AAPL"] + assert result.current_topic == "AAPL" + + +class TestMergeContexts: + def test_session_takes_precedence_for_scalars(self): + session = ConversationContext( + current_topic="NVDA", intent="buy", depth="deep", topic_stack=["NVDA"] + ) + persisted = ConversationContext( + current_topic="AAPL", intent="research", depth="quick", topic_stack=["AAPL"] + ) + + result = merge_contexts(session, persisted) + + assert result.current_topic == "NVDA" + assert result.intent == "buy" + assert result.depth == "deep" + + def test_persisted_supplies_missing_scalars(self): + session = ConversationContext() + persisted = ConversationContext( + current_topic="AAPL", intent="research", topic_stack=["AAPL"] + ) + + result = merge_contexts(session, persisted) + + assert result.current_topic == "AAPL" + assert result.intent == "research" + + def test_topic_stacks_merge_session_first(self): + session = ConversationContext(topic_stack=["NVDA", "TSLA"]) + persisted = ConversationContext(topic_stack=["AAPL", "TSLA", "GOOG"]) + + result = merge_contexts(session, persisted) + + # TSLA is deduped, order is session-first then persisted. + assert result.topic_stack == ["NVDA", "TSLA", "AAPL", "GOOG"] + + def test_merge_respects_max_topic_stack(self): + session = ConversationContext(topic_stack=["A", "B", "C"]) + persisted = ConversationContext(topic_stack=["D", "E", "F", "G"]) + + result = merge_contexts(session, persisted) + + assert len(result.topic_stack) == 5 + assert result.topic_stack == ["A", "B", "C", "D", "E"] + + def test_merge_keeps_session_last_activity(self): + now = datetime.now() + session = ConversationContext(last_activity=now) + persisted = ConversationContext(last_activity=now - timedelta(days=3)) + + result = merge_contexts(session, persisted) + + assert result.last_activity == now diff --git a/tests/conversation/test_engine.py b/tests/conversation/test_engine.py index 1e55129..a8921a0 100644 --- a/tests/conversation/test_engine.py +++ b/tests/conversation/test_engine.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from datetime import datetime, timedelta from unittest.mock import AsyncMock, patch from helpers import failed_result as _failed_result @@ -11,6 +12,7 @@ from qracer.config.models import Holding, PortfolioConfig from qracer.conversation.analysis_loop import AnalysisLoop, AnalysisResult +from qracer.conversation.context import ConversationContext from qracer.conversation.dispatcher import invoke_tool, invoke_tools from qracer.conversation.engine import ConversationEngine, EngineResponse from qracer.conversation.intent import Intent, IntentType @@ -935,3 +937,147 @@ async def test_no_crash_without_fact_store(self) -> None: response = await engine.query("AAPL price") assert response.text # Should produce a response without crashing + + +# --------------------------------------------------------------------------- +# Persistent ConversationContext across sessions +# --------------------------------------------------------------------------- + + +class TestPersistentContext: + async def test_context_file_written_after_query(self, tmp_path) -> None: + """Engine should write context.json after processing a query.""" + from qracer.memory.session_logger import SessionLogger + + context_path = tmp_path / "context.json" + session_logger = SessionLogger(tmp_path / "session.jsonl") + + intent_resp = json.dumps({"intent": "event_analysis", "tickers": ["AAPL"]}) + llm = _mock_llm_registry({Role.RESEARCHER: intent_resp, Role.STRATEGIST: "Response"}) + engine = ConversationEngine( + llm, DataRegistry(), session_logger=session_logger, context_path=context_path + ) + + with patch("qracer.conversation.handlers.invoke_tools") as mock_invoke: + mock_invoke.return_value = [_ok_result("price_event")] + await engine.query("Analyze AAPL") + + assert context_path.exists() + data = json.loads(context_path.read_text()) + assert "AAPL" in data["topic_stack"] + assert data["current_topic"] == "AAPL" + + async def test_context_resumed_in_new_engine(self, tmp_path) -> None: + """A fresh engine pointed at the same context_path should surface + prior-session topics via the topic_stack.""" + from qracer.memory.session_logger import SessionLogger + + context_path = tmp_path / "context.json" + + # First session: discuss AAPL. + session1 = SessionLogger(tmp_path / "session1.jsonl") + llm1 = _mock_llm_registry( + { + Role.RESEARCHER: json.dumps({"intent": "event_analysis", "tickers": ["AAPL"]}), + Role.STRATEGIST: "R", + } + ) + engine1 = ConversationEngine( + llm1, DataRegistry(), session_logger=session1, context_path=context_path + ) + with patch("qracer.conversation.handlers.invoke_tools") as mock_invoke: + mock_invoke.return_value = [_ok_result("price_event")] + await engine1.query("Analyze AAPL") + + # Second session: no log, brand-new engine picks up the persisted stack. + session2 = SessionLogger(tmp_path / "session2.jsonl") + llm2 = _mock_llm_registry( + { + Role.RESEARCHER: json.dumps({"intent": "macro_query", "tickers": []}), + Role.STRATEGIST: "R", + } + ) + engine2 = ConversationEngine( + llm2, DataRegistry(), session_logger=session2, context_path=context_path + ) + + assert "AAPL" in engine2._context.topic_stack + assert engine2._context.current_topic == "AAPL" + + async def test_stale_context_is_decayed_on_load(self, tmp_path) -> None: + """A context file older than 7 days should come back empty.""" + from qracer.conversation.context_store import save_context + from qracer.memory.session_logger import SessionLogger + + context_path = tmp_path / "context.json" + stale_ctx = ConversationContext( + current_topic="AAPL", + topic_stack=["AAPL", "TSLA"], + last_activity=datetime.now() - timedelta(days=30), + ) + save_context(stale_ctx, context_path) + + llm = _mock_llm_registry( + {Role.RESEARCHER: json.dumps({"intent": "macro_query", "tickers": []})} + ) + engine = ConversationEngine( + llm, + DataRegistry(), + session_logger=SessionLogger(tmp_path / "s.jsonl"), + context_path=context_path, + ) + + assert engine._context.topic_stack == [] + assert engine._context.current_topic is None + + async def test_open_theses_merged_into_topic_stack(self, tmp_path) -> None: + """Open FactStore theses should surface in the topic_stack on load.""" + from qracer.memory.fact_store import FactStore + from qracer.memory.session_logger import SessionLogger + from qracer.models.base import TradeThesis + + fact_store = FactStore() + fact_store.save_thesis( + TradeThesis( + ticker="NVDA", + entry_zone=(400.0, 420.0), + target_price=500.0, + stop_loss=380.0, + risk_reward_ratio=4.0, + catalyst="AI demand", + catalyst_date=None, + conviction=9, + summary="Long NVDA", + ), + session_id="prior", + ) + + llm = _mock_llm_registry( + {Role.RESEARCHER: json.dumps({"intent": "macro_query", "tickers": []})} + ) + engine = ConversationEngine( + llm, + DataRegistry(), + session_logger=SessionLogger(tmp_path / "s.jsonl"), + context_path=tmp_path / "context.json", + fact_store=fact_store, + ) + + assert "NVDA" in engine._context.topic_stack + fact_store.close() + + async def test_no_context_path_is_noop(self) -> None: + """Engine without a context_path should not crash on query.""" + llm = _mock_llm_registry( + { + Role.RESEARCHER: json.dumps({"intent": "macro_query", "tickers": []}), + Role.STRATEGIST: "R", + } + ) + engine = ConversationEngine(llm, DataRegistry()) # context_path defaults to None + + with patch("qracer.conversation.handlers.invoke_tools") as mock_invoke: + mock_invoke.return_value = [] + response = await engine.query("Macro question") + + assert response.text # no crash, no context written