starfriend commited on
Commit
ac30e3f
·
verified ·
1 Parent(s): 534e8d0

Upload 6 files

Browse files
backend/media_analysis/data_export.py CHANGED
@@ -1,26 +1,37 @@
1
- """Stateless CSV export for model-produced structured records.
2
 
3
- Only export parsed machine-readable records, not numbers scraped from prose.
4
- The API returns base64 content for Spyder to save locally; no public files,
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
- def decode_structured_answer(answer: str) -> tuple[str, list[dict], str | None]:
 
 
 
 
 
 
 
 
 
 
 
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, [], "Model output was not complete JSON; CSV not generated"
32
  if not isinstance(obj, dict):
33
- return answer, [], "Model output must be a JSON object"
34
- readable = str(obj.get("answer") or "")
35
- if isinstance(obj.get("columns"), list) and isinstance(obj.get("rows"), list):
36
- columns = obj["columns"]
37
- if columns not in (TABLE_COLUMNS, CHART_COLUMNS):
38
- return readable, [], "Unsupported or incorrectly ordered data columns; CSV not generated"
39
- data_rows = obj["rows"]
40
- if len(data_rows) > 250 or any(not isinstance(r, list) or len(r) != len(columns) for r in data_rows):
41
- return readable, [], "Invalid row length or record count; CSV not generated"
42
- if any(any(not isinstance(v, (str, int, float, bool, type(None))) for v in row) for row in data_rows):
43
- return readable, [], "Nested values in data rows; CSV not generated"
44
- return readable, [dict(zip(columns, r)) for r in data_rows], None
45
- # Legacy full-record schema remains supported for existing clients.
46
- rows = obj.get("records")
47
- if not isinstance(rows, list):
48
- return readable, [], "No structured records or compact rows; CSV not generated"
49
- if len(rows) > 250 or any(not isinstance(r,dict) for r in rows):
50
- return readable, [], "Invalid legacy record structure"
51
- return readable, rows, None
52
-
53
-
54
- NUMERIC_FIELDS = {"minimum", "maximum", "mean", "std", "value", "x", "y", "percent_value"}
55
-
56
-
57
- def normalize_records(records: list[dict]) -> tuple[list[dict], list[str]]:
58
- """Normalize unambiguous numeric fields, retain originals when text formatting differs.
59
-
60
- Never infer a missing sign, range, classification, or null value.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- reserved = {"paper_id", "paper_title", "doi", "paper_key", "media_label", "page", "extraction_method", "estimated", "source_quality"}
99
- allowed = set()
100
- for rec in records:
101
- for key, value in rec.items():
102
- if isinstance(key,str) and len(key) <= 70 and isinstance(value,(str,int,float,bool,type(None))):
103
- allowed.add(key)
104
- if not allowed:
105
- return None
106
- preferred = TABLE_COLUMNS + CHART_COLUMNS
107
- fields = [x for x in preferred if x in allowed] + sorted(allowed - reserved - set(preferred)) + [
108
- "paper_id", "paper_title", "doi", "paper_key", "media_label", "page", "extraction_method", "estimated", "source_quality"]
109
- output = io.StringIO(newline="")
110
- writer = csv.DictWriter(output, fieldnames=fields, extrasaction="ignore")
111
  writer.writeheader()
112
- for rec in records:
113
- row = {k:v for k,v in rec.items() if k in fields and isinstance(v,(str,int,float,bool,type(None)))}
114
- if len(sources) == 1:
115
- src = sources[0]
116
- for k, source_key in [("paper_id","paper_id"),("paper_title","paper_title"),("doi","doi"),("paper_key","paper_key"),("media_label","label"),("page","page")]:
117
- row[k] = src.get(source_key) or ""
118
- # Never silently claim exactness for unverified positioned text.
119
- row.setdefault("extraction_method", "printed_table_transcription" if "parameter" in rec else "model_assisted")
120
- row.setdefault("estimated", False if "parameter" in rec else "")
121
- row.setdefault("source_quality", "model_extracted_unverified")
122
- writer.writerow({k:("'"+v if isinstance(v,str) and v[:1] in ("=","+","-","@") else v)
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
- answer, records, warning = decode_structured_answer(result.get("answer", ""))
 
 
 
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
- result["csv_attachment"] = csv_attachment(records, result.get("sources") or [])
 
 
 
 
 
 
 
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
- " For downloadable data extraction return ONLY one compact valid JSON object with "
120
- "keys 'answer', 'columns', and 'rows'. 'answer' MUST be one short sentence, "
121
- "NOT a reproduction of the table. 'columns' MUST be exactly "
122
- "['parameter','unit','minimum','maximum','mean','std','standard_range',"
123
- "'class_label','classification','percent_samples','references']. "
124
- "'rows' MUST be an array of positional arrays following those columns, with "
125
- "one array per ACTUAL classification/data subrow in source order. "
126
- "Preserve ALL requested cells, parameters, subrows, units, percentage values, "
127
- "inequalities and reference text. For stats/units/reference spanning multiple "
128
- "subrows, give the value only on its printed first row and null thereafter. "
129
- "Use null for genuinely blank/unreadable cells; never infer a missing percentage. "
130
- "Numbers must be JSON numbers with no thousands separators, preserving signs. "
131
- "Class I/II/III goes in class_label, not standard_range. "
132
- "Print-table transcriptions are NOT visual estimates. Do not add repeated "
133
- "paper IDs, sources, confidence descriptions, provenance, CSV text, or any "
134
- "other fields to rows: the server adds metadata and generates the CSV. "
135
- "Do NOT include Markdown fences or a duplicate CSV in the answer. "
136
- "If source is an image plot rather than a table, use columns "
137
- "['point_id','x','y','unit_x','unit_y','estimated'] and data rows; "
138
- "numerical visual estimates must have estimated=true."
 
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 = []