File size: 5,132 Bytes
36839fb | 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 | """Grounded RAG answering: retrieve top-k chunks, prompt the LLM to answer
ONLY from that context with [Company, p.X] citations, abstain when unsupported.
CLI: python -m src.answer "your question"
"""
from __future__ import annotations
import argparse
import re
from dataclasses import dataclass, field
from functools import lru_cache
from dotenv import load_dotenv
import config
from src.retrieve import RetrievedChunk, get_retriever
SYSTEM_PROMPT = f"""You answer questions about company annual reports (Equinor, DNB, and Norsk Hydro, 2025) using ONLY the document excerpts provided in the user message.
Rules:
1. Ground every statement in the excerpts. Use no outside knowledge, no estimates, no general knowledge about these companies.
2. Cite the source of every factual claim inline as [Company, p.X], where Company and X (the page number) come from the header line of the excerpt you used. Cite only excerpts you actually relied on.
3. If the excerpts do not contain the information needed to answer, start your reply with exactly this sentence: {config.ABSTAIN_STRING}
Then add one or two short sentences that help the reader: say what related information the excerpts DO contain (for example a different year, a target instead of an actual figure, an average instead of a closing value, or a related measure), citing it as in rule 2. If nothing in the excerpts is related, say so plainly. Never state a figure for the thing that was asked if it is not in the excerpts.
4. If the excerpts only partially answer the question, answer the supported part and say what is not stated.
5. Be concise: a direct answer in one to three sentences, with figures quoted exactly as written in the excerpts (keep units and currency).
"""
# Matches inline citations like [Equinor, p.252]; page group is digits only.
CITATION_RE = re.compile(r"\[([^\[\],]+?),\s*p\.\s*(\d+)\]")
@dataclass
class Citation:
"""A single (company, page) reference parsed from the answer text."""
company: str
page: int
@dataclass
class AnswerResult:
"""Everything the UI and the eval harness need about one QA turn."""
question: str
answer: str
citations: list[Citation] = field(default_factory=list)
chunks: list[RetrievedChunk] = field(default_factory=list)
abstained: bool = False
def parse_citations(text: str) -> list[Citation]:
"""Extract [Company, p.X] citations, deduplicated, in order of appearance."""
seen: set[tuple[str, int]] = set()
citations: list[Citation] = []
for company, page in CITATION_RE.findall(text):
key = (company.strip(), int(page))
if key not in seen:
seen.add(key)
citations.append(Citation(company=key[0], page=key[1]))
return citations
def build_context(chunks: list[RetrievedChunk]) -> str:
"""Format retrieved chunks as clearly-delimited, citable excerpts."""
blocks: list[str] = []
for i, c in enumerate(chunks, 1):
blocks.append(f"--- Excerpt {i} [{c.company}, p.{c.page}] ---\n{c.text}")
return "\n\n".join(blocks)
@lru_cache(maxsize=1)
def get_client():
"""Anthropic client; reads ANTHROPIC_API_KEY from .env / environment."""
import anthropic
load_dotenv()
return anthropic.Anthropic()
def answer(question: str, top_k: int = config.TOP_K) -> AnswerResult:
"""Retrieve context and produce a grounded, cited answer (or abstain)."""
chunks = get_retriever().search(question, top_k=top_k)
context = build_context(chunks)
response = get_client().messages.create(
model=config.ANSWER_MODEL,
max_tokens=config.MAX_ANSWER_TOKENS,
temperature=0.0,
system=SYSTEM_PROMPT,
messages=[
{
"role": "user",
"content": f"{context}\n\nQuestion: {question}",
}
],
)
text = "".join(b.text for b in response.content if b.type == "text").strip()
# Prefix match: an abstention starts with the abstention sentence and may be
# followed by an explanation of what related information IS stated.
abstained = text.startswith(config.ABSTAIN_STRING)
return AnswerResult(
question=question,
answer=text,
# Abstentions may carry a cited explanation, so citations are always parsed.
citations=parse_citations(text),
chunks=chunks,
abstained=abstained,
)
def main() -> None:
parser = argparse.ArgumentParser(description="Ask a grounded question.")
parser.add_argument("question")
parser.add_argument("-k", type=int, default=config.TOP_K)
args = parser.parse_args()
result = answer(args.question, top_k=args.k)
print(f"Q: {result.question}\n")
print(f"A: {result.answer}\n")
if result.abstained:
print("(abstained)")
else:
cites = ", ".join(f"[{c.company}, p.{c.page}]" for c in result.citations)
print(f"Citations: {cites or '(none found in answer)'}")
print("\nRetrieved:")
for i, c in enumerate(result.chunks, 1):
print(f" #{i} score={c.score:.3f} [{c.company}, p.{c.page}]")
if __name__ == "__main__":
main()
|