File size: 10,157 Bytes
8bdc720
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279afe1
 
 
8bdc720
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7632104
8bdc720
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7632104
 
8bdc720
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8bd24e
8bdc720
 
7632104
8bdc720
 
 
 
 
 
 
 
 
7632104
8bdc720
 
 
 
f8bd24e
7632104
8bdc720
 
 
 
 
 
7632104
8bdc720
 
 
b3c3675
 
 
d5d68f6
 
b3c3675
 
f8bd24e
 
d5d68f6
 
f8bd24e
 
 
 
 
 
 
 
 
 
 
 
 
 
b3c3675
ac30e3f
 
 
 
534e8d0
 
 
 
b3c3675
f8bd24e
b3c3675
ac30e3f
 
 
 
 
 
 
 
f8bd24e
 
b3c3675
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
"""Resolve selected scientific paper media using the EXISTING paper_figures module.

This adapter is isolated from existing chat and routing. Production chat integration
should happen only after verifying IDs, manifest records and endpoint behavior.
"""
from __future__ import annotations

import os
import re
from typing import Any, Callable


class MediaSelectionError(ValueError):
    pass


def _canonical_label(label: str) -> tuple[str, int] | None:
    # Extracted manifest labels use figure_03 / table_04, while users ask
    # for Figure 3 / Fig. 3 / Table 4. Normalize both without touching manifests.
    match = re.search(r"(?<!\w)(fig(?:ure)?|table)[\s_.-]*(\d+)(?!\d)", label or "", re.I)
    if not match:
        return None
    kind = "table" if match.group(1).lower() == "table" else "figure"
    return kind, int(match.group(2))


def _requested_labels(question: str) -> list[tuple[str, int]]:
    found = []
    for match in re.finditer(r"\b(fig(?:ure)?\.?|table)\s*(\d+)\b", question, re.I):
        label = ("table" if match.group(1).lower() == "table" else "figure", int(match.group(2)))
        if label not in found:
            found.append(label)
    return found


def prepare_media_evidence(
    question: str,
    selected_papers: list[dict[str, Any]],
    *,
    figures_lookup: Callable[..., dict | None] | None = None,
    asset_resolver: Callable[[str, str], Any] | None = None,
    max_images: int = 4,
    media_image: str | None = None,
) -> list[dict]:
    """Explicit label-matching POC; never retrieves unselected papers.

    selected_papers must contain paper_id and at least one of paper_key/txt_file/
    pdf_file/metadata_row_index. Optional paper_title, doi, summary are carried
    without performing new retrieval. Ambiguous figure labels across selected
    papers are deliberately rejected (identify the paper or pass one paper).
    """
    if figures_lookup is None or asset_resolver is None:
        from metadata_search.paper_figures import figures_payload_for_paper, resolve_figure_asset
        figures_lookup = figures_lookup or figures_payload_for_paper
        asset_resolver = asset_resolver or resolve_figure_asset
    if not selected_papers:
        raise MediaSelectionError("Select at least one paper")
    if max_images < 1 or max_images > 4:
        raise ValueError("max_images must be 1-4 for the POC")
    labels = _requested_labels(question)
    if not labels:
        raise MediaSelectionError("POC requires an explicit Figure N or Table N reference")
    if len(selected_papers) > 4:
        raise MediaSelectionError("POC supports up to four selected papers")

    matched = []
    for paper in selected_papers:
        paper_id = str(paper.get("paper_id") or paper.get("document_id") or "").strip()
        if not paper_id:
            raise MediaSelectionError("Each selected paper needs a stable paper_id/document_id")
        lookup_args = {key: paper[key] for key in ("txt_file", "pdf_file", "paper_key", "metadata_row_index")
                       if paper.get(key) is not None and paper.get(key) != ""}
        if not lookup_args:
            raise MediaSelectionError(f"Paper {paper_id} missing a media lookup locator")
        payload = figures_lookup(**lookup_args)
        if not payload:
            continue
        folder = payload.get("paper_key")
        if not folder:
            continue
        for entry in payload.get("items") or []:
            actual = _canonical_label(entry.get("label", ""))
            if actual not in labels:
                continue
            # Reject duplicated figure labels inside one paper rather than guessing.
            image = entry.get("image")
            if media_image is not None and image != media_image:
                continue
            if not image:
                continue
            image_path = asset_resolver(folder, image)
            if image_path is None:
                raise MediaSelectionError(f"Image missing for paper {paper_id}, {entry.get('label')}")
            matched.append({
                "paper_id": paper_id,
                "paper_title": paper.get("paper_title") or paper.get("title", ""),
                "doi": paper.get("doi", ""),
                "summary": paper.get("summary", ""),
                "paper_key": folder,
                "label": entry.get("label", ""),
                "panel": "",  # Full crop first; panel-specific cropping is not available.
                "page": entry.get("page"),
                "caption": entry.get("caption", ""),
                "image_url": entry.get("image_url", ""),
                "image_path": str(image_path),
                "kind": entry.get("kind", "figure"),
            })
    if not matched:
        raise MediaSelectionError("Requested image not found for this paper and figure/table label" if media_image else "Requested media not found in the selected papers")
    # Do not conflate 'Figure 3' across multiple papers without a paper qualifier.
    if len(selected_papers) > 1 and len(labels) == 1 and len(matched) > 1:
        # Explicit cross-paper comparisons are allowed; otherwise require disambiguation.
        if not re.search(r"\b(compare|across|between|both|all|different studies|selected papers)\b", question, re.I):
            raise MediaSelectionError("Figure/table label occurs in several selected papers; specify paper or ask an explicit cross-paper comparison")
    if len(matched) > max_images:
        raise MediaSelectionError(f"Matched {len(matched)} images; POC limit is {max_images}. Narrow the request")
    keys = [(x["paper_id"], _canonical_label(x["label"])) for x in matched]
    if len(set(keys)) != len(keys):
        raise MediaSelectionError("Multiple images share this figure/table number. Click Analyze & CSV under the intended image in the gallery.")
    return matched


def analyze_media_question(question: str, selected_papers: list[dict], *, enabled: bool | None = None,
                           figures_lookup=None, asset_resolver=None, vision_client=None, provider: str = "azure", model: str | None = None, max_output_tokens: int | None = None,
                           evidence_mode: str = "vision", export_csv: bool = False, analysis_mode: str = "auto", media_image: str | None = None) -> dict:
    """Explicit opt-in entry point. Never silently substitutes a text summary answer."""
    if enabled is None:
        enabled = os.getenv("WATERSCOPE_ENABLE_MEDIA_ANALYSIS", "0").lower() in ("1", "true", "yes")
    if not enabled:
        return {"status": "disabled", "answer": None, "vision_inspected": False}
    items = prepare_media_evidence(question, selected_papers,
                                   figures_lookup=figures_lookup, asset_resolver=asset_resolver, media_image=media_image)
    if vision_client is None:
        from .vision_client import analyze_images
        vision_client = analyze_images
    if analysis_mode not in ("auto", "extract_all", "interpret"):
        raise ValueError("analysis_mode must be auto, extract_all, or interpret")
    if analysis_mode == "extract_all":
        question = question + ("\n[Task: extract all legitimately readable values from the selected media; "
                               "do not invent chart points or force numerical estimates where unreadable.]")
    elif analysis_mode == "interpret":
        question = question + "\n[Task: prioritize interpretation; extract only data relevant to the question.]"
    if evidence_mode not in ("vision", "json", "combined"):
        raise ValueError("evidence_mode must be vision, json or combined")
    # Figure images usually have no positional JSON; combined mode automatically
    # uses the image and caption without requiring a fictitious figure JSON.
    if evidence_mode != "vision":
        from .table_data import load_table_evidence
        if asset_resolver is None:
            from metadata_search.paper_figures import resolve_figure_asset
            asset_resolver = resolve_figure_asset
        for item in items:
            if item.get("kind") == "table":
                item["table_data"] = load_table_evidence(item["paper_key"], item["label"], asset_resolver)
        if evidence_mode == "json" and any(not x.get("table_data") for x in items):
            raise MediaSelectionError("JSON-only mode requires extracted table JSON for every requested item")
    result = vision_client(question, items, provider=provider, model=model,
                           max_output_tokens=max_output_tokens, evidence_mode=evidence_mode,
                           structured_output=export_csv)
    if export_csv:
        from .data_export import decode_structured_answer, csv_attachment, normalize_records
        if result.get("response_status") == "incomplete" or result.get("incomplete_reason"):
            answer, records, warning = "", [], "Model output incomplete; CSV was not generated"
        else:
            answer, records, warning = decode_structured_answer(result.get("answer", ""))
        result["answer"] = answer if not warning else (
            "Structured extraction did not complete; no downloadable CSV was generated. "
            "Please retry or narrow the request."
        )
        records, normalization_warnings = normalize_records(records)
        result["structured_data"] = records
        result["normalization_warnings"] = normalization_warnings
        sources = result.get("sources") or []
        if len(sources) > 1 and records:
            names = {key.lower() for rec in records for key in rec}
            if not any(name in names for name in ("source", "source_id", "paper_id", "paper_title", "source_media_label", "media_label", "source_label")):
                warning = warning or "Multiple sources require a source identifier column; CSV not generated"
                records = []
                result["structured_data"] = []
        result["csv_attachment"] = csv_attachment(records, sources)
        if warning:
            result["export_warning"] = warning
    return {"status": "ok", "vision_inspected": evidence_mode != "json", "evidence_mode": evidence_mode, "analysis_mode": analysis_mode, **result}