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",
    )