Spaces:
Sleeping
Sleeping
File size: 5,907 Bytes
b2931f4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | """Provider-agnostic pieces shared by every synthesis backend.
Lives in its own module (not __init__) so provider modules can import the
shared types without a circular import through the dispatcher.
`SynthesisResult` is the contract `main.py` depends on. Every provider maps
its native response/usage onto this shape, so the HTTP layer and the future
LangGraph nodes never learn which model actually answered.
"""
from __future__ import annotations
import datetime
import decimal
from dataclasses import dataclass, field
from typing import Any
from finrag.retrieval.vector import RetrievedChunk
# The grounding contract. Every rule exists because LLMs violate it by default:
# - "Use ONLY the context" → without this, the model fills gaps from memory
# - "[N] citations" → without this, citations come back as prose mentions
# - "Don't round numbers" → without this, $394,328M becomes "about $400 billion"
# - "Fiscal vs calendar year" → without this, Apple FY2024 (ended Sep 2024)
# gets conflated with calendar 2024
# Provider-neutral: passed as Anthropic `system=` or Gemini `system_instruction`.
SYSTEM_PROMPT = """You are a financial analyst assistant answering questions about SEC 10-K filings.
Rules you must follow:
1. **Grounding**: Answer ONLY using the provided context chunks. If the context does not contain the answer, say so explicitly — do not fill gaps from prior knowledge.
2. **Citations**: Every factual claim must be followed by a citation in the form [N] where N is the chunk index from the provided context. Multiple supporting chunks: [1][3]. Cite even when paraphrasing.
3. **Numbers**: Quote exact figures from the source. Do not round unless explicitly asked. If a chunk says "$394,328 million", write "$394,328 million" — not "$394 billion".
4. **Fiscal year**: Be careful with fiscal vs calendar year. Apple's fiscal year ends in late September; Tesla and JPMorgan use calendar years. If the user says "2023", confirm which sense from context.
5. **Brevity**: Match the question's scope. A "what was X" question gets one number with a citation. A "how did X change" question gets a comparison sentence. Do not over-explain.
6. **Honest absence**: If the context doesn't answer the question, write "The provided context does not contain this information." Do not speculate.
"""
MAX_TOKENS = 1024
@dataclass
class SynthesisResult:
"""What synthesis returns, regardless of provider.
The cache token fields stay in the contract even for providers that
don't surface caching at this scale (both Anthropic <1024-token prompts
and Gemini's <~1024-token prefixes report 0) — keeping them lets the
frontend and eval harness read one stable shape.
"""
answer: str
model: str
input_tokens: int
output_tokens: int
cache_creation_input_tokens: int # tokens billed at cache-write rate
cache_read_input_tokens: int # tokens served from cache (~10% cost)
stop_reason: str
def format_chunks_for_prompt(chunks: list[RetrievedChunk]) -> str:
"""Format retrieved chunks with [N] anchors and provenance headers.
The [N] anchor at the start of each chunk is what the model references in
its citations. Position is 1-based to match human-reading convention.
The id= in the header is the real chunk_id — the only handle the model can
pass to lookup_citation. Without it the model has nothing real to deref and
fabricates ids like 'chunk_5' (which used to crash the hex→uint64 lookup).
"""
blocks: list[str] = []
for i, c in enumerate(chunks, start=1):
section = c.section_title or "unknown section"
# Mark table chunks so the model treats them as structured data and
# doesn't hallucinate cell positions.
kind_marker = " [table]" if c.chunk_type == "table" else ""
header = f"[{i}] {c.ticker} · FY{c.fiscal_year} · {section}{kind_marker} (id={c.chunk_id})"
blocks.append(f"{header}\n{c.text}")
return "\n\n---\n\n".join(blocks)
@dataclass
class ToolCall:
"""One tool invocation inside the agent loop, captured for the trace."""
tool: str
args: dict[str, Any]
result: Any
@dataclass
class ToolLoopResult:
"""Provider-neutral result of an agentic tool-calling loop.
Both the Gemini (native function-calling) and Anthropic (tool_use)
implementations return this shape, so the agent node never learns which
backend ran the loop — the same seam idea as SynthesisResult.
"""
answer: str
input_tokens: int
output_tokens: int
tool_calls: list[ToolCall] = field(default_factory=list)
def json_safe(v: Any) -> Any:
"""Coerce a tool result into JSON-serializable form for tool responses.
DuckDB rows can carry date/Decimal values; both providers' tool-result
channels want JSON scalars, so we stringify dates and float-ify Decimals.
"""
if isinstance(v, dict):
return {k: json_safe(x) for k, x in v.items()}
if isinstance(v, (list, tuple)):
return [json_safe(x) for x in v]
if isinstance(v, (datetime.date, datetime.datetime)):
return v.isoformat()
if isinstance(v, decimal.Decimal):
return float(v)
return v
def build_user_message(question: str, chunks: list[RetrievedChunk]) -> str:
"""The per-query content: question + formatted context. Never cached."""
return f"Question: {question}\n\nContext:\n\n{format_chunks_for_prompt(chunks)}"
def empty_result(model: str) -> SynthesisResult:
"""Returned when retrieval found nothing — skip the paid API call entirely."""
return SynthesisResult(
answer="No relevant context retrieved. Try rephrasing your question.",
model=model,
input_tokens=0,
output_tokens=0,
cache_creation_input_tokens=0,
cache_read_input_tokens=0,
stop_reason="empty_context",
)
|