1717)
1818CITATION_PATTERN = re .compile (r"\[([A-Za-z0-9][A-Za-z0-9_-]*)\]" )
1919INSUFFICIENT_EVIDENCE_TOKEN = "INSUFFICIENT_EVIDENCE"
20+ REPAIRABLE_FAILURE_REASONS = frozenset (
21+ {"missing_citations" , "out_of_range_citation" , "unknown_citation" }
22+ )
2023
2124ANSWER_PROMPT = """You are a review analyst.
2225Answer the question using only the supplied reviews. Do not add facts that are not present.
23- When evidence is mixed or limited, say so clearly. Every factual claim must cite one or more
24- retrieved evidence numbers exactly as shown, for example [1]. Never cite an evidence number
25- that is not supplied. Source IDs are validation metadata; do not copy them into the answer.
26- If the supplied reviews do not answer the question, reply exactly INSUFFICIENT_EVIDENCE.
26+ Always answer every part that at least one supplied review supports. Mixed or incomplete evidence is
27+ not a reason to abstain; describe the limitation and answer only the supported portion. Every
28+ factual claim must cite one or more retrieved evidence numbers exactly as shown, for example
29+ [1]. Never cite an evidence number that is not supplied. Source IDs are validation metadata;
30+ do not copy them into the answer. Reply exactly INSUFFICIENT_EVIDENCE only when no supplied
31+ review answers any part of the question. Never use INSUFFICIENT_EVIDENCE as prose or place it
32+ beside an answer.
2733
2834Question:
2935{question}
3440Answer:
3541"""
3642
43+ REPAIR_PROMPT = """You are repairing one rejected review-analysis response.
44+ Using only the supplied reviews, rewrite it once so every factual claim has one or more valid
45+ evidence citations such as [1]. Use only evidence numbers that appear below. Preserve supported
46+ meaning, remove unsupported claims, and answer every supported part even when evidence is mixed
47+ or incomplete. If no supplied review answers any part, reply exactly INSUFFICIENT_EVIDENCE.
48+ Never use INSUFFICIENT_EVIDENCE as prose or place it beside an answer.
49+
50+ Question:
51+ {question}
52+
53+ Supplied review records:
54+ {context}
55+
56+ Rejected response:
57+ <rejected_response>
58+ {rejected_response}
59+ </rejected_response>
60+
61+ Rewritten answer:
62+ """
63+
3764
3865@dataclass (frozen = True )
3966class CitedReview :
@@ -48,6 +75,11 @@ class AnswerResult:
4875 sources : tuple [CitedReview , ...]
4976 retrieved_source_ids : tuple [str , ...] = ()
5077 abstained : bool = False
78+ raw_response : str = ""
79+ repair_response : str | None = None
80+ initial_failure_reason : str | None = None
81+ failure_reason : str | None = None
82+ repair_attempted : bool = False
5183
5284
5385def create_chat_model (
@@ -99,27 +131,28 @@ def _remove_standalone_control_token(answer: str) -> str | None:
99131def _validate_and_number_citations (
100132 answer : str ,
101133 matches : list [ReviewMatch ],
102- ) -> tuple [str , tuple [CitedReview , ...]] | None :
134+ ) -> tuple [tuple [ str , tuple [CitedReview , ...]] | None , str | None ] :
103135 retrieved : dict [str , ReviewMatch ] = {}
104136 evidence_aliases : dict [str , str ] = {}
105137 for evidence_number , match in enumerate (matches , start = 1 ):
106138 source_id = str (
107139 match .document .metadata .get ("source_id" ) or match .document .id or ""
108140 )
109141 if not source_id :
110- return None
142+ return None , "retrieved_source_missing_id"
111143 retrieved [source_id ] = match
112144 evidence_aliases [str (evidence_number )] = source_id
113145
114146 cited_tokens = CITATION_PATTERN .findall (answer )
115147 if not cited_tokens :
116- return None
148+ return None , "missing_citations"
117149
118150 resolved_ids : list [str ] = []
119151 for token in cited_tokens :
120152 source_id = evidence_aliases .get (token , token )
121153 if source_id not in retrieved :
122- return None
154+ reason = "out_of_range_citation" if token .isdigit () else "unknown_citation"
155+ return None , reason
123156 resolved_ids .append (source_id )
124157
125158 ordered_ids = list (dict .fromkeys (resolved_ids ))
@@ -140,7 +173,26 @@ def _validate_and_number_citations(
140173 )
141174 for source_id in ordered_ids
142175 )
143- return numbered_answer , sources
176+ return (numbered_answer , sources ), None
177+
178+
179+ def _evaluate_model_response (
180+ response : str ,
181+ matches : list [ReviewMatch ],
182+ ) -> tuple [tuple [str , tuple [CitedReview , ...]] | None , str | None ]:
183+ normalized = response .strip ()
184+ if normalized == INSUFFICIENT_EVIDENCE_TOKEN :
185+ return None , "clean_abstention"
186+ normalized_without_control = _remove_standalone_control_token (normalized )
187+ if normalized_without_control is None :
188+ return None , "embedded_control_token"
189+ if not normalized_without_control :
190+ return None , "invalid_remainder"
191+ return _validate_and_number_citations (normalized_without_control , matches )
192+
193+
194+ def _response_text (response : Any ) -> str :
195+ return str (response .content if hasattr (response , "content" ) else response )
144196
145197
146198def answer_question (
@@ -177,47 +229,95 @@ def answer_question(
177229 countries = countries ,
178230 )
179231 if not matches :
180- return AnswerResult (answer = NO_MATCH_MESSAGE , sources = ())
232+ return AnswerResult (
233+ answer = NO_MATCH_MESSAGE ,
234+ sources = (),
235+ failure_reason = "empty_retrieval" ,
236+ )
181237
182238 retrieved_source_ids = tuple (
183239 str (match .document .metadata .get ("source_id" ) or match .document .id or "" )
184240 for match in matches
185241 )
186242 if any (not source_id for source_id in retrieved_source_ids ):
187- return AnswerResult (answer = CITATION_VALIDATION_MESSAGE , sources = ())
243+ return AnswerResult (
244+ answer = CITATION_VALIDATION_MESSAGE ,
245+ sources = (),
246+ failure_reason = "retrieved_source_missing_id" ,
247+ )
188248
189249 answer_model = model or create_chat_model (model = chat_model , base_url = ollama_host )
250+ context = _format_context (matches )
190251 prompt = ANSWER_PROMPT .format (
191252 question = normalized_question ,
192- context = _format_context ( matches ) ,
253+ context = context ,
193254 )
194- response = answer_model .invoke (prompt )
195- answer = response .content if hasattr (response , "content" ) else str (response )
196- normalized_answer = answer .strip ()
197- if normalized_answer == INSUFFICIENT_EVIDENCE_TOKEN :
255+ raw_response = _response_text (answer_model .invoke (prompt ))
256+ validated , failure_reason = _evaluate_model_response (raw_response , matches )
257+ if validated is not None :
258+ validated_answer , sources = validated
259+ return AnswerResult (
260+ answer = validated_answer ,
261+ sources = sources ,
262+ retrieved_source_ids = retrieved_source_ids ,
263+ raw_response = raw_response ,
264+ )
265+ if failure_reason == "clean_abstention" :
198266 return AnswerResult (
199267 answer = NO_MATCH_MESSAGE ,
200268 sources = (),
201269 retrieved_source_ids = retrieved_source_ids ,
202270 abstained = True ,
271+ raw_response = raw_response ,
272+ failure_reason = failure_reason ,
203273 )
204- normalized_answer = _remove_standalone_control_token (normalized_answer )
205- if normalized_answer is None :
274+ if failure_reason not in REPAIRABLE_FAILURE_REASONS :
206275 return AnswerResult (
207276 answer = CITATION_VALIDATION_MESSAGE ,
208277 sources = (),
209278 retrieved_source_ids = retrieved_source_ids ,
279+ raw_response = raw_response ,
280+ failure_reason = failure_reason ,
210281 )
211- validated = _validate_and_number_citations (normalized_answer , matches )
212- if validated is None :
282+
283+ initial_failure_reason = failure_reason
284+ repair_prompt = REPAIR_PROMPT .format (
285+ question = normalized_question ,
286+ context = context ,
287+ rejected_response = raw_response ,
288+ )
289+ repair_response = _response_text (answer_model .invoke (repair_prompt ))
290+ repaired , repair_failure_reason = _evaluate_model_response (repair_response , matches )
291+ if repaired is not None :
292+ repaired_answer , sources = repaired
213293 return AnswerResult (
214- answer = CITATION_VALIDATION_MESSAGE ,
294+ answer = repaired_answer ,
295+ sources = sources ,
296+ retrieved_source_ids = retrieved_source_ids ,
297+ raw_response = raw_response ,
298+ repair_response = repair_response ,
299+ initial_failure_reason = initial_failure_reason ,
300+ repair_attempted = True ,
301+ )
302+ if repair_failure_reason == "clean_abstention" :
303+ return AnswerResult (
304+ answer = NO_MATCH_MESSAGE ,
215305 sources = (),
216306 retrieved_source_ids = retrieved_source_ids ,
307+ abstained = True ,
308+ raw_response = raw_response ,
309+ repair_response = repair_response ,
310+ initial_failure_reason = initial_failure_reason ,
311+ failure_reason = repair_failure_reason ,
312+ repair_attempted = True ,
217313 )
218- validated_answer , sources = validated
219314 return AnswerResult (
220- answer = validated_answer ,
221- sources = sources ,
315+ answer = CITATION_VALIDATION_MESSAGE ,
316+ sources = () ,
222317 retrieved_source_ids = retrieved_source_ids ,
318+ raw_response = raw_response ,
319+ repair_response = repair_response ,
320+ initial_failure_reason = initial_failure_reason ,
321+ failure_reason = repair_failure_reason ,
322+ repair_attempted = True ,
223323 )
0 commit comments