starfriend commited on
Commit
b3c3675
·
verified ·
1 Parent(s): f8bd24e

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
- # Keep word x/y coordinates so model can align columns. Truncate payload
38
- # deterministically rather than sending the enormous extraction JSON.
 
39
  compact_rows = []
40
  for row in rows:
41
  words = []
42
- for word in (row.get("words") or [])[:60]:
43
  bbox = word.get("bbox_points") or []
44
- words.append({"text": str(word.get("text", ""))[:100],
45
- "x": round(float(bbox[0]), 1) if len(bbox) == 4 else None,
46
- "y": round(float(bbox[1]), 1) if len(bbox) == 4 else None})
47
- compact_rows.append({"text": str(row.get("text", ""))[:1000], "words": words})
 
 
 
 
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 a valid JSON object with keys answer (scientific narrative string) "
120
- "and records (array of objects, one per requested data row). Include units, "
 
 
 
 
 
 
121
  "estimated (boolean), extraction_method (printed_table|positioned_text|visual_estimate), "
122
- "and source_quality when possible. For multiple media items each record MUST "
123
- "identify paper_id and media_label. Do not fabricate rows or values. "
124
- "For uncertain cells use null and explain uncertainty in answer."
 
 
 
 
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 = []