File size: 8,721 Bytes
4939f56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Final synthesis — techspec.md §3.

Builds a single structured "Final Report" for a completed pipeline run:
one headline, per-finding claims each tagged with the run's confidence tier
(identified / homolog / de_novo), and honest caveats.

An LLM pass polishes the wording when a provider is reachable; the findings
and caveats themselves are always assembled deterministically from real step
results, so the panel never invents content and never blocks the pipeline.
"""

import logging

logger = logging.getLogger(__name__)

_CAVEATS = {
    "identified": [
        "Annotations describe the matched database entry — transferability to your exact input depends on sequence identity.",
    ],
    "homolog": [
        "The query was resolved via sequence similarity, so annotations describe a homolog, not the exact protein.",
        "Verify functional transferability before relying on homolog-derived annotations experimentally.",
    ],
    "de_novo": [
        "No database match was found — every annotation is a computational prediction on the raw sequence.",
        "Treat composition- and structure-based hints as hypotheses for experimental follow-up.",
    ],
}


def _tier(context: dict) -> str:
    return (context.get("query") or {}).get("confidence") or "identified"


def build_findings(context: dict) -> tuple[list[dict], list[dict]]:
    """Deterministic findings + stats from actual step results."""
    tier = _tier(context)
    findings: list[dict] = []
    stats = context.get("query", {})

    blast = context.get("blast") or {}
    top_hit = blast.get("top_hit")
    if top_hit:
        findings.append({
            "claim": f"Closest database match: {top_hit.get('description', 'unknown')} ({top_hit.get('accession', '?')})",
            "confidence_tier": tier,
            "source_tool": "blast",
            "page_url": f"https://www.ncbi.nlm.nih.gov/protein/{top_hit.get('accession', '')}" if top_hit.get("accession") else None,
        })
    elif blast.get("count", 0) == 0:
        findings.append({
            "claim": "No significant similarity to any database sequence.",
            "confidence_tier": tier,
            "source_tool": "blast",
            "page_url": None,
        })

    uniprot = context.get("uniprot") or {}
    if uniprot.get("_de_novo"):
        comp = uniprot.get("composition") or {}
        findings.append({
            "claim": (
                f"De novo characterization: {comp.get('sequence_type', 'protein')} of "
                f"{comp.get('length', stats.get('length', '?'))} residues; function hints are heuristic only."
            ),
            "confidence_tier": tier,
            "source_tool": "uniprot",
            "page_url": None,
        })
    elif uniprot and not uniprot.get("error"):
        name = uniprot.get("full_name") or uniprot.get("accession") or "entry"
        findings.append({
            "claim": f"Annotated as {name} ({uniprot.get('accession', '?')}) in UniProt.",
            "confidence_tier": tier,
            "source_tool": "uniprot",
            "page_url": f"https://www.uniprot.org/uniprotkb/{uniprot.get('accession', '')}" if uniprot.get("accession") else None,
        })

    domains = context.get("domains") or {}
    dom_list = domains.get("domains") or []
    if dom_list:
        top_domain = dom_list[0]
        findings.append({
            "claim": (
                f"{len(dom_list)} domain(s) detected — top: {top_domain.get('name', '?')} "
                f"({top_domain.get('source_db', '?')})."
            ),
            "confidence_tier": tier,
            "source_tool": "domains",
            "page_url": (
                f"https://www.ebi.ac.uk/interpro/entry/InterPro/{top_domain['accession']}"
                if top_domain.get("accession") else None
            ),
        })

    msa = context.get("msa") or {}
    if msa.get("aln_fasta"):
        findings.append({
            "claim": f"Multiple sequence alignment built over {msa.get('sequence_count', '?')} sequences.",
            "confidence_tier": tier,
            "source_tool": "msa",
            "page_url": None,
        })

    pathway = context.get("pathway_enrichment") or {}
    pw_list = (pathway.get("pathways") if isinstance(pathway, dict) else None) or []
    if pw_list:
        top_pw = pw_list[0]
        st_id = top_pw.get("stId", "")
        findings.append({
            "claim": (
                f"Pathway enrichment: {len(pw_list)} Reactome pathway(s), "
                f"e.g. {top_pw.get('name', '?')} (FDR {top_pw.get('entitiesFDR', '?')})."
            ),
            "confidence_tier": tier,
            "source_tool": "pathway_enrichment",
            "page_url": f"https://reactome.org/PathwayBrowser/#{st_id}" if st_id else None,
        })
    elif isinstance(pathway, dict) and pathway.get("error"):
        findings.append({
            "claim": f"Pathway enrichment unavailable ({str(pathway.get('error'))[:80]}).",
            "confidence_tier": tier,
            "source_tool": "pathway_enrichment",
            "page_url": None,
        })

    af = context.get("alphafold") or {}
    if af.get("structure_available"):
        source_label = "ESMFold prediction" if af.get("source") == "esmfold" else "AlphaFold DB model"
        plddt = af.get("mean_plddt") or af.get("confidence")
        detail = f", mean pLDDT {plddt}" if plddt else ""
        findings.append({
            "claim": f"3D structure available ({source_label}{detail}).",
            "confidence_tier": tier,
            "source_tool": "alphafold",
            "page_url": af.get("pdb_url"),
        })

    return findings, [f"Confidence tier for this run: {tier}."] + _CAVEATS.get(tier, [])


def _headline(context: dict, findings: list[dict]) -> str:
    tier = _tier(context)
    if tier == "de_novo":
        return "Novel sequence — characterized de novo from composition and predicted structure."
    top = next((f for f in findings if f["source_tool"] == "uniprot"), None)
    if top:
        return f"Identified with {tier}-confidence annotation: {top['claim'].removeprefix('Annotated as ').rstrip('.')}"
    hit = next((f for f in findings if f["source_tool"] == "blast"), None)
    return hit["claim"] if hit else "Analysis complete."


async def _polish_with_llm(headline: str, summary_parts: list[str]) -> str | None:
    """Best-effort LLM rewrite of the headline+summary. Returns None on failure."""
    try:
        from app.ai.interpreter import _get_acompletion
        from app.ai import llm_client

        acompletion = _get_acompletion()
        candidates = llm_client.get_all_candidates()
        if acompletion is None or not candidates:
            return None

        prompt = (
            "Rewrite the following research summary into ONE crisp paragraph (max 90 words).\n"
            "Do not add facts that are not present. Keep hedged language.\n\n"
            f"Headline: {headline}\nFindings:\n- " + "\n- ".join(summary_parts)
        )
        candidate = candidates[0]
        response = await acompletion(
            model=candidate["model"],
            messages=[{"role": "user", "content": prompt}],
            temperature=0.3,
            max_tokens=300,
            timeout=20,
            api_key=candidate["api_key"],
        )
        text = response.choices[0].message.content if response.choices else ""
        return text.strip() or None
    except Exception as exc:
        logger.info("Synthesis LLM polish skipped: %s", exc)
        return None


async def synthesize(context: dict) -> dict:
    """Produce the final_report block stored in context_json."""
    findings, caveats = build_findings(context)
    headline = _headline(context, findings)

    polished = await _polish_with_llm(
        headline, [f["claim"] for f in findings] if findings else ["No significant results."]
    )
    report = {
        "headline": headline,
        "summary": polished or " ".join(f["claim"] for f in findings if f["source_tool"] == "blast")
        or "Analysis complete — see per-tool sections below.",
        "findings": findings,
        "caveats": caveats,
        "_mode": "llm_polished" if polished else "deterministic",
    }
    logger.info(
        "Final synthesis built (%s): %d findings",
        report["_mode"],
        len(findings),
    )
    return report


def synthesize_sync(context: dict) -> dict:
    """Synchronous variant (no LLM polish) for export paths."""
    findings, caveats = build_findings(context)
    return {
        "headline": _headline(context, findings),
        "summary": " ".join(f["claim"] for f in findings[:3]),
        "findings": findings,
        "caveats": caveats,
        "_mode": "deterministic",
    }