Spaces:
Sleeping
Sleeping
Upload 6 files
Browse files
backend/media_analysis/data_export.py
CHANGED
|
@@ -29,6 +29,31 @@ def decode_structured_answer(answer: str) -> tuple[str, list[dict], str | None]:
|
|
| 29 |
return str(obj.get("answer") or ""), rows, None
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
def csv_attachment(records: list[dict], sources: list[dict]) -> dict | None:
|
| 33 |
if not records:
|
| 34 |
return None
|
|
|
|
| 29 |
return str(obj.get("answer") or ""), rows, None
|
| 30 |
|
| 31 |
|
| 32 |
+
NUMERIC_FIELDS = {"minimum", "maximum", "mean", "std", "value", "x", "y", "percent_value"}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def normalize_records(records: list[dict]) -> tuple[list[dict], list[str]]:
|
| 36 |
+
"""Normalize unambiguous numeric fields, retain originals when text formatting differs.
|
| 37 |
+
|
| 38 |
+
Never infer a missing sign, range, classification, or null value.
|
| 39 |
+
"""
|
| 40 |
+
result, warnings = [], []
|
| 41 |
+
for index, original in enumerate(records, 1):
|
| 42 |
+
item = dict(original)
|
| 43 |
+
for key in NUMERIC_FIELDS & item.keys():
|
| 44 |
+
value = item[key]
|
| 45 |
+
if isinstance(value, str):
|
| 46 |
+
cleaned = value.strip()
|
| 47 |
+
normalized = cleaned.replace(",", "")
|
| 48 |
+
if re.fullmatch(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)", normalized):
|
| 49 |
+
item[key + "_printed"] = value
|
| 50 |
+
item[key] = float(normalized) if "." in normalized else int(normalized)
|
| 51 |
+
elif cleaned:
|
| 52 |
+
warnings.append(f"Row {index}: {key} is not an unambiguous number: {value!r}")
|
| 53 |
+
result.append(item)
|
| 54 |
+
return result, warnings
|
| 55 |
+
|
| 56 |
+
|
| 57 |
def csv_attachment(records: list[dict], sources: list[dict]) -> dict | None:
|
| 58 |
if not records:
|
| 59 |
return None
|
backend/media_analysis/figure_analysis.py
CHANGED
|
@@ -119,7 +119,7 @@ def prepare_media_evidence(
|
|
| 119 |
|
| 120 |
def analyze_media_question(question: str, selected_papers: list[dict], *, enabled: bool | None = None,
|
| 121 |
figures_lookup=None, asset_resolver=None, vision_client=None, provider: str = "azure", model: str | None = None, max_output_tokens: int | None = None,
|
| 122 |
-
evidence_mode: str = "vision", export_csv: bool = False) -> dict:
|
| 123 |
"""Explicit opt-in entry point. Never silently substitutes a text summary answer."""
|
| 124 |
if enabled is None:
|
| 125 |
enabled = os.getenv("WATERSCOPE_ENABLE_MEDIA_ANALYSIS", "0").lower() in ("1", "true", "yes")
|
|
@@ -130,6 +130,12 @@ def analyze_media_question(question: str, selected_papers: list[dict], *, enable
|
|
| 130 |
if vision_client is None:
|
| 131 |
from .vision_client import analyze_images
|
| 132 |
vision_client = analyze_images
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
if evidence_mode not in ("vision", "json", "combined"):
|
| 134 |
raise ValueError("evidence_mode must be vision, json or combined")
|
| 135 |
if evidence_mode != "vision":
|
|
@@ -146,11 +152,13 @@ def analyze_media_question(question: str, selected_papers: list[dict], *, enable
|
|
| 146 |
max_output_tokens=max_output_tokens, evidence_mode=evidence_mode,
|
| 147 |
structured_output=export_csv)
|
| 148 |
if export_csv:
|
| 149 |
-
from .data_export import decode_structured_answer, csv_attachment
|
| 150 |
answer, records, warning = decode_structured_answer(result.get("answer", ""))
|
| 151 |
result["answer"] = answer
|
|
|
|
| 152 |
result["structured_data"] = records
|
|
|
|
| 153 |
result["csv_attachment"] = csv_attachment(records, result.get("sources") or [])
|
| 154 |
if warning:
|
| 155 |
result["export_warning"] = warning
|
| 156 |
-
return {"status": "ok", "vision_inspected": evidence_mode != "json", "evidence_mode": evidence_mode, **result}
|
|
|
|
| 119 |
|
| 120 |
def analyze_media_question(question: str, selected_papers: list[dict], *, enabled: bool | None = None,
|
| 121 |
figures_lookup=None, asset_resolver=None, vision_client=None, provider: str = "azure", model: str | None = None, max_output_tokens: int | None = None,
|
| 122 |
+
evidence_mode: str = "vision", export_csv: bool = False, analysis_mode: str = "auto") -> dict:
|
| 123 |
"""Explicit opt-in entry point. Never silently substitutes a text summary answer."""
|
| 124 |
if enabled is None:
|
| 125 |
enabled = os.getenv("WATERSCOPE_ENABLE_MEDIA_ANALYSIS", "0").lower() in ("1", "true", "yes")
|
|
|
|
| 130 |
if vision_client is None:
|
| 131 |
from .vision_client import analyze_images
|
| 132 |
vision_client = analyze_images
|
| 133 |
+
if analysis_mode not in ("auto", "extract_all", "interpret"):
|
| 134 |
+
raise ValueError("analysis_mode must be auto, extract_all, or interpret")
|
| 135 |
+
if analysis_mode == "extract_all":
|
| 136 |
+
question = question + "\n[Task: extract every available data row and classification subrow from the referenced media.]"
|
| 137 |
+
elif analysis_mode == "interpret":
|
| 138 |
+
question = question + "\n[Task: prioritize interpretation; extract only data relevant to the question.]"
|
| 139 |
if evidence_mode not in ("vision", "json", "combined"):
|
| 140 |
raise ValueError("evidence_mode must be vision, json or combined")
|
| 141 |
if evidence_mode != "vision":
|
|
|
|
| 152 |
max_output_tokens=max_output_tokens, evidence_mode=evidence_mode,
|
| 153 |
structured_output=export_csv)
|
| 154 |
if export_csv:
|
| 155 |
+
from .data_export import decode_structured_answer, csv_attachment, normalize_records
|
| 156 |
answer, records, warning = decode_structured_answer(result.get("answer", ""))
|
| 157 |
result["answer"] = answer
|
| 158 |
+
records, normalization_warnings = normalize_records(records)
|
| 159 |
result["structured_data"] = records
|
| 160 |
+
result["normalization_warnings"] = normalization_warnings
|
| 161 |
result["csv_attachment"] = csv_attachment(records, result.get("sources") or [])
|
| 162 |
if warning:
|
| 163 |
result["export_warning"] = warning
|
| 164 |
+
return {"status": "ok", "vision_inspected": evidence_mode != "json", "evidence_mode": evidence_mode, "analysis_mode": analysis_mode, **result}
|
backend/media_analysis/table_data.py
CHANGED
|
@@ -34,17 +34,22 @@ def load_table_evidence(folder: str, label: str, resolver: Callable[[str,str], A
|
|
| 34 |
cells = table.get("cells")
|
| 35 |
if not isinstance(rows, list) or len(rows) > 500:
|
| 36 |
raise ValueError("Invalid/oversized table text rows")
|
| 37 |
-
#
|
| 38 |
-
#
|
|
|
|
| 39 |
compact_rows = []
|
| 40 |
for row in rows:
|
| 41 |
words = []
|
| 42 |
-
for word in (row.get("words") or [])[:
|
| 43 |
bbox = word.get("bbox_points") or []
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
return {
|
| 49 |
"label": label, "data_file": filename,
|
| 50 |
"structure_extracted": bool(table.get("structure_extracted")),
|
|
|
|
| 34 |
cells = table.get("cells")
|
| 35 |
if not isinstance(rows, list) or len(rows) > 500:
|
| 36 |
raise ValueError("Invalid/oversized table text rows")
|
| 37 |
+
# Preserve every source row, but replace redundant per-word bounding boxes
|
| 38 |
+
# with a compact word@horizontal-position representation. A text row is NOT
|
| 39 |
+
# a verified table cell row, especially for merged/multi-line groups.
|
| 40 |
compact_rows = []
|
| 41 |
for row in rows:
|
| 42 |
words = []
|
| 43 |
+
for word in (row.get("words") or [])[:100]:
|
| 44 |
bbox = word.get("bbox_points") or []
|
| 45 |
+
token = str(word.get("text", ""))[:120]
|
| 46 |
+
if len(bbox) == 4:
|
| 47 |
+
words.append([token, round(float(bbox[0]), 1)])
|
| 48 |
+
else:
|
| 49 |
+
words.append([token, None])
|
| 50 |
+
compact_rows.append({"text": str(row.get("text", ""))[:1600], "positioned_words": words})
|
| 51 |
+
# Include all nonempty rows, including continuation rows and classifications.
|
| 52 |
+
# The original JSON remains in the dataset; this is only an inference view.
|
| 53 |
return {
|
| 54 |
"label": label, "data_file": filename,
|
| 55 |
"structure_extracted": bool(table.get("structure_extracted")),
|
backend/media_analysis/test_routes.py
CHANGED
|
@@ -48,6 +48,9 @@ def media_analyze_test():
|
|
| 48 |
evidence_mode = str(body.get("evidence_mode", "vision")).strip().lower()
|
| 49 |
if evidence_mode not in ("vision", "json", "combined"):
|
| 50 |
return jsonify({"error": "evidence_mode must be vision, json or combined"}), 400
|
|
|
|
|
|
|
|
|
|
| 51 |
export_csv = body.get("export_csv", False)
|
| 52 |
if not isinstance(export_csv, bool):
|
| 53 |
return jsonify({"error": "export_csv must be boolean"}), 400
|
|
@@ -70,7 +73,7 @@ def media_analyze_test():
|
|
| 70 |
sanitized.append(clean)
|
| 71 |
try:
|
| 72 |
result = analyze_media_question(question, sanitized, enabled=True, provider=provider, model=model, max_output_tokens=max_output_tokens,
|
| 73 |
-
evidence_mode=evidence_mode, export_csv=export_csv)
|
| 74 |
return jsonify(result)
|
| 75 |
except (MediaSelectionError, VisionConfigurationError, ValueError, FileNotFoundError) as exc:
|
| 76 |
return jsonify({"error": str(exc), "vision_inspected": False}), 400
|
|
|
|
| 48 |
evidence_mode = str(body.get("evidence_mode", "vision")).strip().lower()
|
| 49 |
if evidence_mode not in ("vision", "json", "combined"):
|
| 50 |
return jsonify({"error": "evidence_mode must be vision, json or combined"}), 400
|
| 51 |
+
analysis_mode = str(body.get("analysis_mode", "auto")).strip().lower()
|
| 52 |
+
if analysis_mode not in ("auto", "extract_all", "interpret"):
|
| 53 |
+
return jsonify({"error": "analysis_mode must be auto, extract_all, or interpret"}), 400
|
| 54 |
export_csv = body.get("export_csv", False)
|
| 55 |
if not isinstance(export_csv, bool):
|
| 56 |
return jsonify({"error": "export_csv must be boolean"}), 400
|
|
|
|
| 73 |
sanitized.append(clean)
|
| 74 |
try:
|
| 75 |
result = analyze_media_question(question, sanitized, enabled=True, provider=provider, model=model, max_output_tokens=max_output_tokens,
|
| 76 |
+
evidence_mode=evidence_mode, export_csv=export_csv, analysis_mode=analysis_mode)
|
| 77 |
return jsonify(result)
|
| 78 |
except (MediaSelectionError, VisionConfigurationError, ValueError, FileNotFoundError) as exc:
|
| 79 |
return jsonify({"error": str(exc), "vision_inspected": False}), 400
|
backend/media_analysis/vision_client.py
CHANGED
|
@@ -116,12 +116,22 @@ def analyze_images(question: str, evidence: list[dict], *, model: str | None = N
|
|
| 116 |
)
|
| 117 |
if structured_output:
|
| 118 |
instructions += (
|
| 119 |
-
" Return ONLY
|
| 120 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
"estimated (boolean), extraction_method (printed_table|positioned_text|visual_estimate), "
|
| 122 |
-
"and source_quality
|
| 123 |
-
"
|
| 124 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
)
|
| 126 |
content: list[dict] = [{"type": "input_text", "text": f"Question: {question.strip()}"}]
|
| 127 |
used = []
|
|
|
|
| 116 |
)
|
| 117 |
if structured_output:
|
| 118 |
instructions += (
|
| 119 |
+
" Return ONLY valid JSON with keys answer (brief readable summary) and records "
|
| 120 |
+
"(array containing exactly the requested extracted rows). For a request to extract "
|
| 121 |
+
"all data, include every parameter and every classification subrow, standard range, "
|
| 122 |
+
"percentage, reference, units and descriptive statistics, without silently omitting "
|
| 123 |
+
"rows. In repeated subrows, do not invent or repeat descriptive statistics that "
|
| 124 |
+
"occur only in the first source row: use null. "
|
| 125 |
+
"Use stable field names: parameter, unit, minimum, maximum, mean, std, "
|
| 126 |
+
"standard_range, class_label, classification, percent_samples, references, "
|
| 127 |
"estimated (boolean), extraction_method (printed_table|positioned_text|visual_estimate), "
|
| 128 |
+
"and source_quality. Preserve original printed parameter names, units, inequality "
|
| 129 |
+
"signs, and numeric precision. Numeric values must be JSON numbers (no thousands "
|
| 130 |
+
"separators); use *_printed fields if preservation of original formatting matters. "
|
| 131 |
+
"For ambiguous OCR signs, compare against image; if still unclear use null and "
|
| 132 |
+
"explain ambiguity. Source-quality is descriptive, never a proof of verification. "
|
| 133 |
+
"When extracting multiple sources include paper_id and media_label per record. "
|
| 134 |
+
"The answer must not claim a complete extraction if rows are omitted."
|
| 135 |
)
|
| 136 |
content: list[dict] = [{"type": "input_text", "text": f"Question: {question.strip()}"}]
|
| 137 |
used = []
|