Spaces:
Sleeping
Sleeping
Upload 6 files
Browse files
backend/media_analysis/data_export.py
CHANGED
|
@@ -1,26 +1,37 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
filesystem persistence, or unauthenticated download route needed.
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
import base64
|
| 9 |
import csv
|
| 10 |
import io
|
| 11 |
import json
|
|
|
|
| 12 |
import re
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
# Only accepted tabular fields become output columns; other fields may be kept
|
| 16 |
-
# for backward compatibility with earlier full-record responses.
|
| 17 |
-
TABLE_COLUMNS = ["parameter", "unit", "minimum", "maximum", "mean", "std",
|
| 18 |
-
"standard_range", "class_label", "classification",
|
| 19 |
-
"percent_samples", "references"]
|
| 20 |
-
CHART_COLUMNS = ["point_id", "x", "y", "unit_x", "unit_y", "estimated"]
|
| 21 |
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
s = (answer or "").strip()
|
| 25 |
if s.startswith("```"):
|
| 26 |
s = re.sub(r"^```(?:json)?\s*", "", s, flags=re.I)
|
|
@@ -28,101 +39,76 @@ def decode_structured_answer(answer: str) -> tuple[str, list[dict], str | None]:
|
|
| 28 |
try:
|
| 29 |
obj = json.loads(s)
|
| 30 |
except (ValueError, TypeError):
|
| 31 |
-
return answer, [], "
|
| 32 |
if not isinstance(obj, dict):
|
| 33 |
-
return answer, [], "
|
| 34 |
-
readable =
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
if
|
| 38 |
-
return readable, [], "
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
if
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
"""
|
| 62 |
-
result, warnings = [], []
|
| 63 |
-
for index, original in enumerate(records, 1):
|
| 64 |
-
item = dict(original)
|
| 65 |
-
for key in NUMERIC_FIELDS & item.keys():
|
| 66 |
-
value = item[key]
|
| 67 |
-
if isinstance(value, str):
|
| 68 |
-
cleaned = value.strip()
|
| 69 |
-
normalized = cleaned.replace(",", "")
|
| 70 |
-
if re.fullmatch(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)", normalized):
|
| 71 |
-
item[key + "_printed"] = value
|
| 72 |
-
item[key] = float(normalized) if "." in normalized else int(normalized)
|
| 73 |
-
elif cleaned:
|
| 74 |
-
warnings.append(f"Row {index}: {key} is not an unambiguous number: {value!r}")
|
| 75 |
-
# Separate class designations that were embedded in a standard range.
|
| 76 |
-
# This is a purely textual rearrangement: no thresholds or signs are inferred.
|
| 77 |
-
rng = item.get("standard_range")
|
| 78 |
-
if isinstance(rng, str):
|
| 79 |
-
m = re.fullmatch(r"\s*(.*?)\s+(Class\s+(?:I|II|III|IV|V))\s*", rng, re.I)
|
| 80 |
-
if m and m.group(1).strip():
|
| 81 |
-
extracted_range = m.group(1).strip()
|
| 82 |
-
extracted_class = "Class " + m.group(2).split()[-1].upper()
|
| 83 |
-
existing_class = item.get("class_label")
|
| 84 |
-
if not existing_class:
|
| 85 |
-
item["class_label"] = extracted_class
|
| 86 |
-
item["standard_range"] = extracted_range
|
| 87 |
-
elif re.sub(r"\s+", " ", str(existing_class)).strip().lower() == extracted_class.lower():
|
| 88 |
-
item["standard_range"] = extracted_range
|
| 89 |
-
else:
|
| 90 |
-
warnings.append(f"Row {index}: conflicting class_label and standard_range; preserved original")
|
| 91 |
-
result.append(item)
|
| 92 |
-
return result, warnings
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def csv_attachment(records: list[dict], sources: list[dict]) -> dict | None:
|
| 96 |
if not records:
|
| 97 |
return None
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
for
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
writer = csv.DictWriter(output, fieldnames=fields, extrasaction="ignore")
|
| 111 |
writer.writeheader()
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
for k,v in row.items()})
|
| 124 |
-
data=output.getvalue().encode("utf-8-sig")
|
| 125 |
-
if len(data) > 1_000_000:
|
| 126 |
-
raise ValueError("Generated CSV exceeds 1MB response limit")
|
| 127 |
return {"filename":"waterscope_media_extracted.csv", "mime_type":"text/csv", "encoding":"base64",
|
| 128 |
-
"content_base64":base64.b64encode(data).decode("ascii"), "row_count":len(records)}
|
|
|
|
| 1 |
+
"""Generic, safe extraction parser and downloadable CSV assembler.
|
| 2 |
|
| 3 |
+
A standardized transport envelope contains source-defined columns + compact row arrays.
|
| 4 |
+
There are NO hardcoded environmental engineering or chart fields.
|
|
|
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
import base64
|
| 8 |
import csv
|
| 9 |
import io
|
| 10 |
import json
|
| 11 |
+
import math
|
| 12 |
import re
|
| 13 |
|
| 14 |
+
MAX_ROWS = 1000
|
| 15 |
+
MAX_COLUMNS = 100
|
| 16 |
+
MAX_CELL_CHARS = 10000
|
| 17 |
+
PROVENANCE = ["source_paper_id", "source_title", "source_doi", "source_paper_key", "source_media_label", "source_page"]
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
def _primitive(value):
|
| 21 |
+
return value is None or isinstance(value, (str, bool, int)) or (isinstance(value, float) and math.isfinite(value))
|
| 22 |
|
| 23 |
+
|
| 24 |
+
def _valid_columns(columns):
|
| 25 |
+
return (isinstance(columns, list) and 1 <= len(columns) <= MAX_COLUMNS
|
| 26 |
+
and all(isinstance(x, str) and x.strip() and len(x) <= 160 for x in columns)
|
| 27 |
+
and len(set(columns)) == len(columns))
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def decode_structured_answer(answer: str):
|
| 31 |
+
"""Return (short answer, records, warning), for backward-compatible callers.
|
| 32 |
+
|
| 33 |
+
New output is generic columns/rows. Legacy records are supported as a fallback.
|
| 34 |
+
"""
|
| 35 |
s = (answer or "").strip()
|
| 36 |
if s.startswith("```"):
|
| 37 |
s = re.sub(r"^```(?:json)?\s*", "", s, flags=re.I)
|
|
|
|
| 39 |
try:
|
| 40 |
obj = json.loads(s)
|
| 41 |
except (ValueError, TypeError):
|
| 42 |
+
return answer, [], "Incomplete or invalid model JSON; no CSV generated"
|
| 43 |
if not isinstance(obj, dict):
|
| 44 |
+
return answer, [], "Expected a JSON object"
|
| 45 |
+
readable = obj.get("answer") if isinstance(obj.get("answer"), str) else ""
|
| 46 |
+
columns, rows = obj.get("columns"), obj.get("rows")
|
| 47 |
+
if columns is not None or rows is not None:
|
| 48 |
+
if not _valid_columns(columns):
|
| 49 |
+
return readable, [], "Invalid or duplicated column names"
|
| 50 |
+
if not isinstance(rows, list) or len(rows) > MAX_ROWS:
|
| 51 |
+
return readable, [], "Invalid row count"
|
| 52 |
+
for row in rows:
|
| 53 |
+
if (not isinstance(row, list) or len(row) != len(columns)
|
| 54 |
+
or any(not _primitive(v) or (isinstance(v, str) and len(v) > MAX_CELL_CHARS) for v in row)):
|
| 55 |
+
return readable, [], "Mismatched columns/rows or unsupported cell type"
|
| 56 |
+
return readable, [dict(zip(columns, row)) for row in rows], None
|
| 57 |
+
legacy = obj.get("records")
|
| 58 |
+
if isinstance(legacy, list) and len(legacy) <= MAX_ROWS and all(isinstance(r, dict) for r in legacy):
|
| 59 |
+
if all(all(isinstance(k, str) and len(k) <= 160 and _primitive(v) for k,v in r.items()) for r in legacy):
|
| 60 |
+
return readable, legacy, None
|
| 61 |
+
return readable, [], "No valid columns/rows in model output"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def normalize_records(records):
|
| 65 |
+
"""Source-preserving general normalization. Never infer merged-cell content.
|
| 66 |
+
|
| 67 |
+
Keep all source-defined column values unchanged; report no table-specific fields.
|
| 68 |
+
"""
|
| 69 |
+
return [dict(rec) for rec in records], []
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _clean_csv_cell(value):
|
| 73 |
+
if value is None:
|
| 74 |
+
return ""
|
| 75 |
+
if isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t", "\r", "\n"):
|
| 76 |
+
return "'" + value
|
| 77 |
+
return value
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def csv_attachment(records, sources):
|
| 81 |
+
"""Export arbitrary rectangular records to base64-encoded CSV for the authenticated API.
|
| 82 |
+
|
| 83 |
+
Preserve source column order from model's `columns` list (dict insertion order).
|
| 84 |
+
For several source items, source ID columns are populated only if provided
|
| 85 |
+
explicitly by the model; never silently ascribe rows to the first source.
|
| 86 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
if not records:
|
| 88 |
return None
|
| 89 |
+
if len(records) > MAX_ROWS:
|
| 90 |
+
raise ValueError("Too many rows")
|
| 91 |
+
columns = list(dict.fromkeys(k for r in records for k in r.keys()))
|
| 92 |
+
if not _valid_columns(columns):
|
| 93 |
+
raise ValueError("Invalid CSV columns")
|
| 94 |
+
for r in records:
|
| 95 |
+
if any(not _primitive(v) for v in r.values()):
|
| 96 |
+
raise ValueError("CSV data must contain only scalar values")
|
| 97 |
+
extras = PROVENANCE if len(sources) == 1 else []
|
| 98 |
+
names = columns + [x for x in extras if x not in columns]
|
| 99 |
+
stream = io.StringIO(newline="")
|
| 100 |
+
writer = csv.DictWriter(stream, fieldnames=names, extrasaction="ignore")
|
|
|
|
| 101 |
writer.writeheader()
|
| 102 |
+
src = sources[0] if len(sources) == 1 else {}
|
| 103 |
+
provenance = dict(zip(PROVENANCE, [src.get(k) for k in ("paper_id","paper_title","doi","paper_key","label","page")]))
|
| 104 |
+
for record in records:
|
| 105 |
+
output = dict(record)
|
| 106 |
+
for name in extras:
|
| 107 |
+
if name not in output:
|
| 108 |
+
output[name] = provenance[name]
|
| 109 |
+
writer.writerow({key:_clean_csv_cell(output.get(key)) for key in names})
|
| 110 |
+
data=stream.getvalue().encode("utf-8-sig")
|
| 111 |
+
if len(data)>2_000_000:
|
| 112 |
+
raise ValueError("CSV exceeds 2MB response limit")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
return {"filename":"waterscope_media_extracted.csv", "mime_type":"text/csv", "encoding":"base64",
|
| 114 |
+
"content_base64":base64.b64encode(data).decode("ascii"), "row_count":len(records), "columns":names}
|
backend/media_analysis/figure_analysis.py
CHANGED
|
@@ -153,7 +153,10 @@ def analyze_media_question(question: str, selected_papers: list[dict], *, enable
|
|
| 153 |
structured_output=export_csv)
|
| 154 |
if export_csv:
|
| 155 |
from .data_export import decode_structured_answer, csv_attachment, normalize_records
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
| 157 |
result["answer"] = answer if not warning else (
|
| 158 |
"Structured extraction did not complete; no downloadable CSV was generated. "
|
| 159 |
"Please retry or narrow the request."
|
|
@@ -161,7 +164,14 @@ def analyze_media_question(question: str, selected_papers: list[dict], *, enable
|
|
| 161 |
records, normalization_warnings = normalize_records(records)
|
| 162 |
result["structured_data"] = records
|
| 163 |
result["normalization_warnings"] = normalization_warnings
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
if warning:
|
| 166 |
result["export_warning"] = warning
|
| 167 |
return {"status": "ok", "vision_inspected": evidence_mode != "json", "evidence_mode": evidence_mode, "analysis_mode": analysis_mode, **result}
|
|
|
|
| 153 |
structured_output=export_csv)
|
| 154 |
if export_csv:
|
| 155 |
from .data_export import decode_structured_answer, csv_attachment, normalize_records
|
| 156 |
+
if result.get("response_status") == "incomplete" or result.get("incomplete_reason"):
|
| 157 |
+
answer, records, warning = "", [], "Model output incomplete; CSV was not generated"
|
| 158 |
+
else:
|
| 159 |
+
answer, records, warning = decode_structured_answer(result.get("answer", ""))
|
| 160 |
result["answer"] = answer if not warning else (
|
| 161 |
"Structured extraction did not complete; no downloadable CSV was generated. "
|
| 162 |
"Please retry or narrow the request."
|
|
|
|
| 164 |
records, normalization_warnings = normalize_records(records)
|
| 165 |
result["structured_data"] = records
|
| 166 |
result["normalization_warnings"] = normalization_warnings
|
| 167 |
+
sources = result.get("sources") or []
|
| 168 |
+
if len(sources) > 1 and records:
|
| 169 |
+
names = {key.lower() for rec in records for key in rec}
|
| 170 |
+
if not any(name in names for name in ("source", "source_id", "paper_id", "paper_title", "source_media_label", "media_label", "source_label")):
|
| 171 |
+
warning = warning or "Multiple sources require a source identifier column; CSV not generated"
|
| 172 |
+
records = []
|
| 173 |
+
result["structured_data"] = []
|
| 174 |
+
result["csv_attachment"] = csv_attachment(records, sources)
|
| 175 |
if warning:
|
| 176 |
result["export_warning"] = warning
|
| 177 |
return {"status": "ok", "vision_inspected": evidence_mode != "json", "evidence_mode": evidence_mode, "analysis_mode": analysis_mode, **result}
|
backend/media_analysis/vision_client.py
CHANGED
|
@@ -116,26 +116,27 @@ def analyze_images(question: str, evidence: list[dict], *, model: str | None = N
|
|
| 116 |
)
|
| 117 |
if structured_output:
|
| 118 |
instructions += (
|
| 119 |
-
"
|
| 120 |
-
"
|
| 121 |
-
"
|
| 122 |
-
"
|
| 123 |
-
"
|
| 124 |
-
"
|
| 125 |
-
"
|
| 126 |
-
"
|
| 127 |
-
"
|
| 128 |
-
"
|
| 129 |
-
"
|
| 130 |
-
"
|
| 131 |
-
"
|
| 132 |
-
"
|
| 133 |
-
"
|
| 134 |
-
"
|
| 135 |
-
"
|
| 136 |
-
"
|
| 137 |
-
"
|
| 138 |
-
"
|
|
|
|
| 139 |
)
|
| 140 |
content: list[dict] = [{"type": "input_text", "text": f"Question: {question.strip()}"}]
|
| 141 |
used = []
|
|
|
|
| 116 |
)
|
| 117 |
if structured_output:
|
| 118 |
instructions += (
|
| 119 |
+
" Return ONLY a JSON object with keys 'answer', 'columns', 'rows'. "
|
| 120 |
+
"'answer' is one concise sentence. 'columns' is an array of the ORIGINAL "
|
| 121 |
+
"source data column names appropriate to this figure/table; choose them "
|
| 122 |
+
"from the actual source, NEVER from a fixed schema. 'rows' is a compact "
|
| 123 |
+
"array of arrays with exactly one value per column. Include every requested "
|
| 124 |
+
"data row and every classification/continuation row in source order. "
|
| 125 |
+
"For complete extraction, keep ALL source data, units, groupings, "
|
| 126 |
+
"multilevel headers and references where present. When a group/merged cell "
|
| 127 |
+
"spans several rows, fill its displayed text in each affected data row "
|
| 128 |
+
"to make the CSV self-contained, only if group membership is unambiguous; "
|
| 129 |
+
"otherwise use null and flag uncertainty in the short answer. "
|
| 130 |
+
"Statistics that are printed only in the group's first row may remain "
|
| 131 |
+
"null in subsequent rows to distinguish them from truly repeated data. "
|
| 132 |
+
"Never invent numbers; preserve signs, inequalities, units and printed "
|
| 133 |
+
"precision. For plotted values, state in the answer they are approximate "
|
| 134 |
+
"and add an 'estimated' column when providing numerical estimates. "
|
| 135 |
+
"Use JSON numbers for numerical cells where possible; use strings for "
|
| 136 |
+
"ranges and expressions. No per-row metadata, confidence essays, markdown, "
|
| 137 |
+
"or embedded CSV. The server adds provenance and prepares the download. "
|
| 138 |
+
"Use at most 100 columns and 1000 rows; if the media is too large, "
|
| 139 |
+
"explain limitations instead of silently omitting data."
|
| 140 |
)
|
| 141 |
content: list[dict] = [{"type": "input_text", "text": f"Question: {question.strip()}"}]
|
| 142 |
used = []
|