File size: 5,739 Bytes
6a5d1ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
final_answer.py -- Final Answer Agent
========================================
Responsibility: turn verified reasoning notes into the user-facing answer,
with explicit sections for findings, evidence, sources, confidence, and
(for research-oriented questions) research gaps.

Primary path: a real local LLM (flan-t5) is prompted with ONLY the
retrieved+verified evidence text and asked to write the explanation --
never asked to use outside knowledge. If unavailable, a deterministic
extractive fallback assembles the answer directly from the Reasoning
Agent's grounded notes (still not fabricated, just less fluent).

Confidence is computed from measurable signals: sub-question coverage,
proportion of SUPPORTED (vs weak) evidence, and presence of unresolved
contradictions -- not asserted by the LLM.
"""
from __future__ import annotations

from typing import List

from src.agents.evidence import _domain_floor_for_backend
from src.agents.state import ResearchState, TraceEvent
from src.rag.embeddings import get_embedding_backend
from src.utils.llm import get_llm


def _compute_confidence(state: ResearchState) -> tuple[str, float]:
    evidence = state.get("evidence", [])
    if not evidence:
        return "Low", 0.1
    supported = sum(1 for e in evidence if e["relevance_label"] == "SUPPORTED")
    weak = sum(1 for e in evidence if e["relevance_label"] == "WEAKLY_SUPPORTED")
    total_used = supported + weak
    support_ratio = supported / max(1, total_used)

    sub_qs = state.get("sub_questions", [])
    answered = len({e["sub_question"] for e in evidence if e["relevance_label"] in ("SUPPORTED", "WEAKLY_SUPPORTED")})
    coverage = answered / max(1, len(sub_qs))

    contradiction_penalty = 0.15 * len(state.get("contradictions", []))
    verification_bonus = 0.1 if state.get("verification_passed") else -0.1

    score = max(0.0, min(1.0, 0.5 * support_ratio + 0.4 * coverage + verification_bonus - contradiction_penalty))
    if score >= 0.7:
        label = "High"
    elif score >= 0.4:
        label = "Moderate"
    else:
        label = "Low"
    return label, round(score, 2)


def _research_gaps(state: ResearchState) -> List[str]:
    gaps = []
    for c in state.get("contradictions", []):
        gaps.append(
            f"Conflicting reported results between '{c['doc_a']}' and '{c['doc_b']}' are unresolved in "
            f"the literature retrieved -- a controlled replication would help ({c['reason']})."
        )
    for claim in state.get("unsupported_claims", []):
        gaps.append(claim.replace("No sufficiently relevant evidence found for:", "Under-explored in the retrieved corpus:"))
    if not gaps:
        gaps.append("No explicit research gaps were surfaced by the retrieved evidence for this query.")
    return gaps


def _extractive_answer(state: ResearchState) -> str:
    notes = state.get("reasoning_notes", [])
    if not notes:
        return "Insufficient evidence to provide a reliable answer."
    return "\n\n".join(f"- {n}" for n in notes)


def finalize(state: ResearchState) -> ResearchState:
    trace: List[TraceEvent] = list(state.get("trace", []))
    evidence = state.get("evidence", [])
    reasoning_notes = state.get("reasoning_notes", [])

    best_score = max((e["relevance_score"] for e in evidence), default=0.0)
    domain_floor = _domain_floor_for_backend(get_embedding_backend().backend_name)
    on_topic = best_score >= domain_floor

    if not evidence or not any(e["relevance_label"] in ("SUPPORTED", "WEAKLY_SUPPORTED") for e in evidence) or not on_topic:
        state["final_answer"] = (
            "Insufficient evidence to provide a reliable answer. The retrieved corpus does not appear "
            "to contain material relevant to this question (best passage relevance "
            f"{best_score:.2f} vs. required {domain_floor:.2f})."
            if evidence else "Insufficient evidence to provide a reliable answer."
        )
        state["key_findings"] = []
        state["research_gaps"] = ["No usable, on-topic evidence was retrieved for this question."]
        state["confidence"], state["confidence_score"] = "Low", 0.0
        trace.append({"agent": "Final Answer", "message": "No usable on-topic evidence -- returning an honest insufficiency notice."})
        state["trace"] = trace
        return state

    llm = get_llm()
    used_llm = False
    answer_text = None
    if llm.available:
        context = "\n".join(f"- {n}" for n in reasoning_notes)[:2500]
        prompt = (
            "Using ONLY the notes below (do not add outside knowledge), write a clear, "
            "well-organized answer to the research question. Be concise.\n\n"
            f"Question: {state['question']}\n\nNotes:\n{context}\n\nAnswer:"
        )
        generated = llm.generate(prompt, max_new_tokens=250)
        if generated and len(generated.split()) >= 5:
            answer_text = generated
            used_llm = True
    if answer_text is None:
        answer_text = _extractive_answer(state)

    key_findings = [n.split(": ", 1)[0] + ": " + n.split(": ", 1)[1].split(". ")[0] for n in reasoning_notes if ": " in n][:6]

    state["final_answer"] = answer_text
    state["key_findings"] = key_findings
    state["research_gaps"] = _research_gaps(state)
    state["confidence"], state["confidence_score"] = _compute_confidence(state)

    method = "a local instruction-tuned LLM grounded strictly in retrieved evidence" if used_llm else "deterministic extractive synthesis of verified evidence"
    trace.append({
        "agent": "Final Answer",
        "message": f"Generated final answer using {method}. Confidence: {state['confidence']} ({state['confidence_score']}).",
    })
    state["trace"] = trace
    return state