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