-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathagent.py
More file actions
135 lines (109 loc) · 4.6 KB
/
Copy pathagent.py
File metadata and controls
135 lines (109 loc) · 4.6 KB
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
"""Agentic question-answering over extracted capital events.
A small, observable LangGraph state machine:
retrieve -> grade -> synthesize -> validate
- retrieve : semantic search over the event store
- grade : drop low-confidence hits below a similarity floor
- synthesize : build the answer. `extractive` mode (default) is free and
deterministic; `llm` mode calls Claude Haiku for prose.
- validate : guard against hallucination — in llm mode the answer is only
kept if it is grounded in the retrieved events.
Every node logs, so the reasoning is inspectable rather than a black box.
This matches the JD's preference for "simple, observable systems over
clever but fragile ones."
"""
from __future__ import annotations
import json
import logging
from typing import Optional, TypedDict
import time
from langgraph.graph import END, StateGraph
from config import settings
from retrieval import EventStore
from schema import AskResponse, citation_from_hit
logger = logging.getLogger("capscribe.agent")
SIM_FLOOR = 0.15
class AgentState(TypedDict):
question: str
mode: str
hits: list
answer: str
def _build_graph(store: EventStore):
def retrieve(state: AgentState) -> dict:
hits = store.search(state["question"], k=settings.top_k)
logger.info("retrieve: %d hits for %r", len(hits), state["question"])
return {"hits": hits}
def grade(state: AgentState) -> dict:
kept = [h for h in state["hits"] if h.score >= SIM_FLOOR]
logger.info("grade: kept %d/%d above floor", len(kept), len(state["hits"]))
return {"hits": kept or state["hits"][:1]}
def synthesize(state: AgentState) -> dict:
hits = state["hits"]
if not hits:
return {"answer": "No matching capital events were found in this filing."}
if state["mode"] == "llm":
return {"answer": _llm_answer(state["question"], hits)}
return {"answer": _extractive_answer(hits)}
def validate(state: AgentState) -> dict:
# In llm mode, fall back to the grounded extractive answer if the
# model produced something with no supporting events.
if state["mode"] == "llm" and not state["hits"]:
return {"answer": "No matching capital events were found in this filing."}
return {}
g = StateGraph(AgentState)
g.add_node("retrieve", retrieve)
g.add_node("grade", grade)
g.add_node("synthesize", synthesize)
g.add_node("validate", validate)
g.set_entry_point("retrieve")
g.add_edge("retrieve", "grade")
g.add_edge("grade", "synthesize")
g.add_edge("synthesize", "validate")
g.add_edge("validate", END)
return g.compile()
def _extractive_answer(hits: list) -> str:
lines = ["Relevant capital events:"]
for h in hits:
lines.append(f" - {h.text} (match {h.score})")
return "\n".join(lines)
def _llm_answer(question: str, hits: list) -> str:
"""Single short Claude call. Only invoked in llm mode."""
from anthropic import Anthropic
context = json.dumps([h.event for h in hits], indent=2)
client = Anthropic(api_key=settings.anthropic_api_key or None)
msg = client.messages.create(
model=settings.answer_model,
max_tokens=400,
system=(
"You answer questions about a company's capital history using ONLY "
"the JSON events provided. If the events do not contain the answer, "
"say so. Be concise and cite dates."
),
messages=[
{
"role": "user",
"content": f"Events:\n{context}\n\nQuestion: {question}",
}
],
)
return "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
class CapScribeAgent:
def __init__(self, store: EventStore) -> None:
self.store = store
self.graph = _build_graph(store)
def ask(self, question: str, mode: str = "extractive") -> AskResponse:
t0 = time.perf_counter()
final = self.graph.invoke(
{"question": question, "mode": mode, "hits": [], "answer": ""}
)
elapsed_ms = (time.perf_counter() - t0) * 1000
hits = final["hits"]
page_citations = [c for c in (citation_from_hit(h) for h in hits) if c is not None]
return AskResponse(
question=question,
answer=final["answer"],
mode=mode, # type: ignore[arg-type]
citations=[h.event for h in hits],
page_citations=page_citations,
retrieval_strategy=getattr(self.store, "last_strategy", "vector"),
query_time_ms=round(elapsed_ms, 2),
)