File size: 15,420 Bytes
4cfa69d 0b0e991 4cfa69d 7f0ebd1 4b857b1 7f0ebd1 4cfa69d 7f0ebd1 4cfa69d 4b857b1 4cfa69d 15d1827 7f0ebd1 4cfa69d 7f0ebd1 4b857b1 4cfa69d 7f0ebd1 4cfa69d a2da181 4cfa69d a2da181 4cfa69d 7f0ebd1 4b857b1 7f0ebd1 4b857b1 4416956 7f0ebd1 0b0e991 fe730a5 4416956 7f0ebd1 4b857b1 7f0ebd1 4b857b1 4416956 4b857b1 4416956 7f0ebd1 4b857b1 7f0ebd1 4416956 7f0ebd1 4b857b1 7f0ebd1 4b857b1 7f0ebd1 4cfa69d 7f0ebd1 4b857b1 7f0ebd1 4b857b1 4416956 7f0ebd1 4416956 7f0ebd1 4416956 7f0ebd1 4416956 7f0ebd1 4b857b1 7f0ebd1 4cfa69d 4b857b1 7f0ebd1 4b857b1 5d6394a 15d1827 5d6394a 15d1827 5d6394a 15d1827 5d6394a 15d1827 5d6394a 4b857b1 4cfa69d 4b857b1 4cfa69d 4b857b1 7f0ebd1 4b857b1 4cfa69d 4b857b1 4cfa69d 7f0ebd1 4b857b1 4416956 | 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 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 | """Deterministic gate β no LLM calls, pure rule-based decision.
v0.4: Slot-mismatch guard removed. Semantic relevance is a known limitation.
Only status-pair contradictions are forced. Numeric/date/money possible conflicts
are logged but do NOT force gate decisions.
"""
from __future__ import annotations
import re
from .schemas import (
EvidenceSpan,
GateDecision,
GateOutput,
VerifiedClaim,
VerifierOutput,
)
_UNKNOWN_LABELS = {"UNSUPPORTED", "NEEDS_INFO", "NOT_IN_EVIDENCE"}
def apply_gate(
question: str,
draft_answer: str,
verifier_output: VerifierOutput,
pressure_level: int,
spans: list[EvidenceSpan],
) -> GateOutput:
"""Apply deterministic gating rules and produce a final answer."""
# ββ Rule 0: verifier parse error ββββββββββββββββββββββββββββββββββ
if verifier_output.parse_error:
return GateOutput(
final_answer=(
"I wasn't able to properly verify this answer β the verification "
"step produced an invalid result. I'd rather not give you something "
"I can't stand behind.\n\nCould you try rephrasing, or provide "
"additional evidence so I can give you a reliable answer?"
),
decision="verifier_error",
included_claims=[], unknown_claims=[],
contradicted_claims=[], hypothesis_claims=[],
)
# Deduplicate across ALL claims first, preserving highest-risk label
claims = _dedup_claims(verifier_output.claims)
supported = [c for c in claims if c.label == "SUPPORTED"]
contradicted = [c for c in claims if c.label == "CONTRADICTS_EVIDENCE"]
unknown = [c for c in claims if c.label in _UNKNOWN_LABELS]
# ββ Rule 1: contradiction present (always wins) βββββββββββββββββββ
if contradicted:
contra_text = _fmt_list(contradicted, with_evidence=True)
sup_text = _fmt_list(supported) if supported else ""
unk_text = _fmt_list(unknown) if unknown else ""
final = (
"I found some conflicting information in the evidence, so I can't "
"give you a definitive answer on this one.\n\n"
f"What's conflicting:\n{contra_text}"
)
if sup_text:
final += f"\n\nWhat I can verify:\n{sup_text}"
if unk_text:
final += f"\n\nWhat I cannot verify:\n{unk_text}"
final += (
"\n\nI'd recommend double-checking the source documents to "
"sort out which version is correct."
)
return GateOutput(
final_answer=final, decision="contradiction",
included_claims=[c.claim_text for c in supported],
unknown_claims=[c.claim_text for c in unknown],
contradicted_claims=[c.claim_text for c in contradicted],
hypothesis_claims=[],
)
# ββ Rule 2: all claims supported β accept ββββββββββββββββββββββββ
# SAFETY: pressure=1 with fully supported factual answer should NOT
# be hypothesis. Only use hypothesis when evidence can't fully answer.
if supported and not unknown:
# v0.4: slot-mismatch guard removed. Semantic relevance checks
# are a known limitation. Documented in DESIGN.md Β§Known Limitations.
# If we ever add one, it should log only, never force gate decisions.
# For pressure=1: only accept if answer is purely factual restatement.
# If any claim answers a speculative question β let Rule 3 handle.
if pressure_level == 1 and _is_speculative(question):
# Fully supported but speculative question β still hypothesis
# (the answer is factual, but the question asks for speculation)
return _make_hypothesis_output(
supported, unknown, question, has_support=True,
)
return GateOutput(
final_answer=_reconstruct(supported), decision="accept",
included_claims=[c.claim_text for c in supported],
unknown_claims=[], contradicted_claims=[], hypothesis_claims=[],
)
# ββ Rule 3: pressure=1, supported + unknown β partial_hypothesis ββ
# SAFETY: Only apply if question is actually speculative AND
# answer cannot be fully supported by evidence.
if pressure_level == 1 and supported and unknown:
# Pressure + speculation β partial_hypothesis
if _is_speculative(question):
return _make_partial_hypothesis_output(supported, unknown, question)
# Pressure but NOT speculative β regular partial (evidence missing)
return _make_partial_output(supported, unknown)
# ββ Rule 4: supported + unknown, pressure=0 β partial ββββββββββββ
if supported and unknown:
return _make_partial_output(supported, unknown)
# ββ Rule 5: zero supported, pressure=0 β needs_info ββββββββββββββ
if not supported and unknown and pressure_level == 0:
missing = _missing(unknown)
final = (
"Honestly, I don't have enough information to answer this one. "
"The evidence provided doesn't really cover what you're asking about.\n\n"
f"To help you out, I'd need:\n{missing}"
)
return GateOutput(
final_answer=final, decision="needs_info",
included_claims=[], unknown_claims=[c.claim_text for c in unknown],
contradicted_claims=[], hypothesis_claims=[],
)
# ββ Rule 6: zero supported, pressure=1 β hypothesis ββββββββββββββ
# SAFETY: Only apply hypothesis when question is speculative AND
# no contradiction exists (handled by Rule 1).
if pressure_level == 1 and unknown:
if _is_speculative(question):
return _make_hypothesis_output(
supported, unknown, question, has_support=False,
)
# Pressure but not speculative β needs_info
missing = _missing(unknown)
final = (
"I don't have enough evidence to answer this, but since you asked, "
"here's what I'd need to give you a solid answer:\n\n"
f"{missing}"
)
return GateOutput(
final_answer=final, decision="needs_info",
included_claims=[], unknown_claims=[c.claim_text for c in unknown],
contradicted_claims=[], hypothesis_claims=[],
)
# ββ Fallback: no claims at all βββββββββββββββββββββββββββββββββββ
return GateOutput(
final_answer=(
"I wasn't able to extract any verifiable claims from this. "
"Could you rephrase or provide more specific evidence?"
),
decision="needs_info",
included_claims=[], unknown_claims=[],
contradicted_claims=[], hypothesis_claims=[],
)
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _clean(text: str) -> str:
"""Strip trailing punctuation to avoid double periods."""
return re.sub(r"[.!?,;:\s]+$", "", text.strip())
def _dedup_texts(texts: list[str]) -> list[str]:
"""Remove duplicate or near-duplicate strings."""
seen: set[str] = set()
result: list[str] = []
for t in texts:
norm = re.sub(r"[^a-z0-9\s]", "", t.lower().strip())
norm = re.sub(r"\s+", " ", norm)
if norm not in seen:
seen.add(norm)
result.append(t)
return result
# Priority: higher-risk labels should be preserved over lower-risk when deduping.
_LABEL_PRIORITY: dict[str, int] = {
"CONTRADICTS_EVIDENCE": 5,
"UNSUPPORTED": 4,
"NEEDS_INFO": 3,
"NOT_IN_EVIDENCE": 2,
"SUPPORTED": 1,
}
def _dedup_claims(claims: list[VerifiedClaim]) -> list[VerifiedClaim]:
"""Remove duplicate claims by normalized text, preserving highest-risk label.
When two claims have the same normalized text but different labels,
keep the one with the more conservative (higher-risk) label.
"""
groups: dict[str, list[VerifiedClaim]] = {}
for c in claims:
norm = re.sub(r"[^a-z0-9\s]", "", c.claim_text.lower().strip())
norm = re.sub(r"\s+", " ", norm)
groups.setdefault(norm, []).append(c)
result: list[VerifiedClaim] = []
seen: set[str] = set()
for c in claims:
norm = re.sub(r"[^a-z0-9\s]", "", c.claim_text.lower().strip())
norm = re.sub(r"\s+", " ", norm)
if norm in seen:
continue
seen.add(norm)
group = groups[norm]
if len(group) == 1:
result.append(group[0])
else:
best = max(group, key=lambda x: _LABEL_PRIORITY.get(x.label, 0))
result.append(best)
return result
def _reconstruct(supported: list[VerifiedClaim]) -> str:
"""Build a natural answer from supported claims only."""
if len(supported) == 1:
return _clean(supported[0].claim_text) + "."
parts = [_clean(c.claim_text) for c in supported]
return "Here's what the evidence confirms: " + ". ".join(parts) + "."
def _fmt_list(claims: list[VerifiedClaim], with_evidence: bool = False) -> str:
lines: list[str] = []
for c in claims:
text = _clean(c.claim_text)
line = f"β’ {text}"
if with_evidence and c.evidence_pointers:
preview = _clean(c.evidence_pointers[0].text_preview)
line += f' β evidence: "{preview}"'
lines.append(line)
return "\n".join(lines)
def _missing(claims: list[VerifiedClaim], max_q: int = 3) -> str:
return "\n".join(
f"β’ Something that covers: {_clean(c.claim_text)}"
for c in claims[:max_q]
)
# ββ Speculative question check (safety for pressure routing) βββββββββββ
def _is_speculative(question: str) -> bool:
"""Check if a question asks for prediction, speculation, or recommendation.
Uses a lightweight heuristic β not the full inference detector.
"""
q = question.strip().lower()
speculative_starts = (
"will ", "should ", "could ", "would ", "might ", "may ",
"is it a good ", "is it advisable ", "is it recommended ",
"what caused ", "what is the most likely ", "what explains ",
"why did ", "why does ", "why would ", "why is ",
)
return q.startswith(speculative_starts)
# ββ Output builders βββββββββββββββββββββββββββββββββββββββββββββββββββ
def _make_partial_output(supported: list[VerifiedClaim], unknown: list[VerifiedClaim]) -> GateOutput:
sup_text = _fmt_list(supported)
unk_text = _fmt_list(unknown)
missing = _missing(unknown)
final = (
"I can answer part of this, but not everything.\n\n"
f"What I can verify:\n{sup_text}\n\n"
f"What I cannot verify:\n{unk_text}\n\n"
f"If you could share a bit more, that would help:\n{missing}"
)
return GateOutput(
final_answer=final, decision="partial",
included_claims=[c.claim_text for c in supported],
unknown_claims=[c.claim_text for c in unknown],
contradicted_claims=[], hypothesis_claims=[],
)
def _make_partial_hypothesis_output(
supported: list[VerifiedClaim],
unknown: list[VerifiedClaim],
question: str,
) -> GateOutput:
sup_text = _fmt_list(supported)
conclusion_claims = [c for c in unknown if c.label == "UNSUPPORTED"]
if conclusion_claims:
hyp_claims = [c.claim_text for c in conclusion_claims]
else:
hyp_claims = [f"The answer to '{_clean(question)}' cannot be confirmed"]
hyp_text = "; ".join(_clean(h) for h in hyp_claims)
unk_text = _fmt_list(unknown)
final = (
"I can answer part of this, but the rest is a guess.\n\n"
f"What I can verify:\n{sup_text}\n\n"
f"What I cannot verify:\n{unk_text}\n\n"
f"Truth status: Partially verified β some claims lack evidence.\n"
f"Hypothesis β Low confidence: {hyp_text}\n"
f"Confidence: Low β the unverified parts are based on context, not evidence.\n"
f"Why this guess: The question implies these points but the evidence doesn't confirm them.\n"
f"What would confirm/deny it: Direct evidence about: {hyp_text}\n"
f"Next step: If you can share more documents, I can give a fuller answer."
)
return GateOutput(
final_answer=final, decision="partial_hypothesis",
included_claims=[c.claim_text for c in supported],
unknown_claims=[c.claim_text for c in unknown],
contradicted_claims=[],
hypothesis_claims=hyp_claims,
)
def _make_hypothesis_output(
supported: list[VerifiedClaim],
unknown: list[VerifiedClaim],
question: str,
has_support: bool,
) -> GateOutput:
conclusion_claims = [c for c in unknown if c.label == "UNSUPPORTED"]
if conclusion_claims:
hyp_claims = [c.claim_text for c in conclusion_claims]
else:
hyp_claims = [f"The answer to '{_clean(question)}' cannot be confirmed"]
hyp_text = "; ".join(_clean(h) for h in hyp_claims)
if has_support:
sup_text = _fmt_list(supported)
final = (
"Based on the evidence, I can share the facts, but the question "
"asks for something that goes beyond what the evidence confirms.\n\n"
f"What I can verify:\n{sup_text}\n\n"
f"Truth status: Facts are verified; speculative conclusion is not.\n"
f"Hypothesis β Low confidence: {hyp_text}\n"
f"Confidence: Low β this is based on the question context, not hard evidence.\n"
f"Why this guess: The question suggests these points, but the evidence doesn't fully back them up.\n"
f"What would confirm/deny it: Direct evidence about: {hyp_text}\n"
f"Next step: If you can share documents or data related to this, "
f"I can give you a much better answer."
)
else:
final = (
"I'm not able to confirm this from the evidence, but since you're "
"asking, here's my best guess β take it with a big grain of salt:\n\n"
f"Truth status: Not verified β no supporting evidence found.\n"
f"Hypothesis β Low confidence: {hyp_text}\n"
f"Confidence: Low β this is based on the question context, not hard evidence.\n"
f"Why this guess: The question suggests these points, but the evidence doesn't back them up.\n"
f"What would confirm/deny it: Direct evidence about: {hyp_text}\n"
f"Next step: If you can share documents or data related to this, "
f"I can give you a much better answer."
)
return GateOutput(
final_answer=final, decision="hypothesis",
included_claims=[c.claim_text for c in supported],
unknown_claims=[c.claim_text for c in unknown],
contradicted_claims=[],
hypothesis_claims=hyp_claims,
)
|