Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions qracer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
139 changes: 139 additions & 0 deletions qracer/conversation/context_store.py
Original file line number Diff line number Diff line change
@@ -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,
)
44 changes: 42 additions & 2 deletions qracer/conversation/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading