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
34 changes: 29 additions & 5 deletions qracer/conversation/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from qracer.data.registry import DataRegistry
from qracer.llm.registry import LLMRegistry
from qracer.memory.fact_store import FactStore
from qracer.memory.finding_extractor import extract_findings
from qracer.memory.memory_searcher import MemorySearcher
from qracer.memory.session_compactor import SessionCompactor
from qracer.memory.session_logger import SessionLogger, TurnRecord
Expand Down Expand Up @@ -330,9 +331,32 @@ async def query(self, user_input: str) -> EngineResponse:

def _persist_facts(self, analysis: AnalysisResult) -> None:
"""Extract and persist structured facts from analysis results."""
if self._fact_store is None or analysis.trade_thesis is None:
if self._fact_store is None:
return
try:
self._fact_store.save_thesis(analysis.trade_thesis, self._session_id)
except Exception:
logger.warning("Failed to persist thesis to fact store", exc_info=True)
if analysis.trade_thesis is not None:
try:
self._fact_store.save_thesis(analysis.trade_thesis, self._session_id)
except Exception:
logger.warning("Failed to persist thesis to fact store", exc_info=True)

# Extract and persist discrete findings from every successful tool
# result. Each tool-level failure is isolated so a bad payload for
# one tool never prevents findings from other tools being saved.
for result in analysis.results:
for draft in extract_findings(result):
try:
self._fact_store.save_finding(
entity=draft.entity,
statement=draft.statement,
confidence=draft.confidence,
source_tool=draft.source_tool,
session_id=self._session_id,
event_date=draft.event_date,
)
except Exception:
logger.warning(
"Failed to persist finding (tool=%s entity=%s)",
draft.source_tool,
draft.entity,
exc_info=True,
)
84 changes: 83 additions & 1 deletion qracer/memory/fact_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import duckdb

from qracer.memory.fact_models import PersistedThesis, ThesisStatus
from qracer.memory.fact_models import Finding, PersistedThesis, ThesisStatus
from qracer.models.base import TradeThesis

logger = logging.getLogger(__name__)
Expand All @@ -43,6 +43,21 @@
updated_at TIMESTAMP NOT NULL,
superseded_by INTEGER
);

CREATE SEQUENCE IF NOT EXISTS finding_id_seq START 1;

CREATE TABLE IF NOT EXISTS findings (
id INTEGER PRIMARY KEY DEFAULT nextval('finding_id_seq'),
entity VARCHAR NOT NULL,
statement VARCHAR NOT NULL,
confidence DOUBLE NOT NULL,
source_tool VARCHAR NOT NULL,
session_id VARCHAR NOT NULL,
event_date VARCHAR,
created_at TIMESTAMP NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_findings_entity ON findings(entity);
"""


Expand Down Expand Up @@ -242,6 +257,73 @@ def update_thesis_status(
[status.value, superseded_by, datetime.now(), thesis_id],
)

# ------------------------------------------------------------------
# Finding CRUD
# ------------------------------------------------------------------

def save_finding(
self,
*,
entity: str,
statement: str,
confidence: float,
source_tool: str,
session_id: str,
event_date: str | None = None,
) -> int:
"""Persist a Finding and return its new id.

Confidence is clamped to the ``[0.0, 1.0]`` range; upstream extractors
may emit out-of-range values when parsing noisy inputs.
"""
clamped_confidence = max(0.0, min(1.0, float(confidence)))
self._conn.execute(
"""
INSERT INTO findings (
entity, statement, confidence, source_tool,
session_id, event_date, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[
entity,
statement,
clamped_confidence,
source_tool,
session_id,
event_date,
datetime.now(),
],
)
new_id: int = self._conn.execute("SELECT currval('finding_id_seq')").fetchone()[0] # type: ignore[index]
return new_id

def get_findings(self, entity: str, limit: int = 20) -> list[Finding]:
"""Return findings for *entity* ordered most-recent first."""
rows = self._conn.execute(
"""
SELECT id, entity, statement, confidence, source_tool,
session_id, event_date, created_at
FROM findings
WHERE entity = ?
ORDER BY created_at DESC
LIMIT ?
""",
[entity, limit],
).fetchall()
return [
Finding(
id=row[0],
entity=row[1],
statement=row[2],
confidence=row[3],
source_tool=row[4],
session_id=row[5],
event_date=row[6],
created_at=row[7],
)
for row in rows
]

# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
Expand Down
154 changes: 154 additions & 0 deletions qracer/memory/finding_extractor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Zero-LLM-cost extraction of Finding drafts from ToolResult data.

Called from :meth:`ConversationEngine._persist_facts` for each successful
tool result. Parses structured ``ToolResult.data`` dicts — no LLM calls,
no expensive post-processing.

Extractors are registered per ``ToolResult.tool`` name. Tools without a
registered extractor yield no findings (graceful degradation).
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

from qracer.models import ToolResult


@dataclass
class FindingDraft:
"""A Finding value ready to be persisted (no db id yet)."""

entity: str
statement: str
confidence: float
source_tool: str
event_date: str | None = None


# Sentiment-string → confidence weight. Directional sentiment (positive /
# negative) carries stronger signal than neutral / unlabeled news.
_SENTIMENT_CONFIDENCE: dict[str, float] = {
"positive": 0.7,
"negative": 0.7,
"neutral": 0.4,
}

_DEFAULT_NEWS_CONFIDENCE = 0.5
_DEFAULT_FUNDAMENTALS_CONFIDENCE = 0.9
_MAX_NEWS_FINDINGS = 3


def _extract_thesis(data: dict[str, Any]) -> list[FindingDraft]:
thesis = data.get("thesis") or {}
ticker = thesis.get("ticker")
catalyst = thesis.get("catalyst")
conviction = thesis.get("conviction")
if not ticker or not catalyst or conviction is None:
return []
statement = (
f"{ticker}: {catalyst} — target ${thesis.get('target_price')}, "
f"stop ${thesis.get('stop_loss')}, R/R {thesis.get('risk_reward_ratio')}x, "
f"conviction {conviction}/10"
)
return [
FindingDraft(
entity=ticker,
statement=statement,
confidence=max(0.0, min(1.0, float(conviction) / 10.0)),
source_tool="trade_thesis",
event_date=thesis.get("catalyst_date"),
)
]


def _extract_news(data: dict[str, Any]) -> list[FindingDraft]:
ticker = data.get("ticker")
articles = data.get("articles") or []
if not ticker or not articles:
return []
drafts: list[FindingDraft] = []
for art in articles[:_MAX_NEWS_FINDINGS]:
title = art.get("title")
if not title:
continue
raw_sentiment = art.get("sentiment")
sentiment = (raw_sentiment or "").strip().lower()
confidence = _SENTIMENT_CONFIDENCE.get(sentiment, _DEFAULT_NEWS_CONFIDENCE)
source = art.get("source") or "unknown"
label = sentiment or "news"
drafts.append(
FindingDraft(
entity=ticker,
statement=f"[{label}] {title} ({source})",
confidence=confidence,
source_tool="news",
event_date=art.get("published_at"),
)
)
return drafts


def _format_ratio(value: float) -> str:
return f"{value:.2f}"


def _format_money(value: float) -> str:
return f"${value:,.0f}"


def _format_percent(value: float) -> str:
return f"{value:.2%}"


def _extract_fundamentals(data: dict[str, Any]) -> list[FindingDraft]:
ticker = data.get("ticker")
if not ticker:
return []
parts: list[str] = []
if (pe := data.get("pe_ratio")) is not None:
parts.append(f"P/E {_format_ratio(float(pe))}")
if (mc := data.get("market_cap")) is not None:
parts.append(f"market cap {_format_money(float(mc))}")
if (rev := data.get("revenue")) is not None:
parts.append(f"revenue {_format_money(float(rev))}")
if (eps := data.get("earnings")) is not None:
parts.append(f"earnings {_format_money(float(eps))}")
if (dy := data.get("dividend_yield")) is not None:
parts.append(f"dividend yield {_format_percent(float(dy))}")
if not parts:
return []
return [
FindingDraft(
entity=ticker,
statement=f"{ticker} fundamentals: " + ", ".join(parts),
confidence=_DEFAULT_FUNDAMENTALS_CONFIDENCE,
source_tool="fundamentals",
)
]


_EXTRACTORS: dict[str, Callable[[dict[str, Any]], list[FindingDraft]]] = {
"trade_thesis": _extract_thesis,
"news": _extract_news,
"fundamentals": _extract_fundamentals,
}


def extract_findings(tool_result: ToolResult) -> list[FindingDraft]:
"""Return zero-or-more FindingDrafts for a single ToolResult.

Failed results, unknown tools, and extractor exceptions all yield an
empty list so a bad payload never breaks the persistence pipeline.
"""
if not tool_result.success:
return []
extractor = _EXTRACTORS.get(tool_result.tool)
if extractor is None:
return []
try:
return extractor(tool_result.data or {})
except Exception: # pragma: no cover - defensive guard
return []
Loading
Loading