File size: 4,933 Bytes
ac30e3f
f8bd24e
ac30e3f
 
f8bd24e
 
 
 
 
 
ac30e3f
f8bd24e
 
ac30e3f
 
 
 
f8bd24e
534e8d0
ac30e3f
 
534e8d0
ac30e3f
 
 
 
 
 
 
 
 
 
 
 
f8bd24e
 
 
 
 
 
 
ac30e3f
534e8d0
ac30e3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b3c3675
f8bd24e
 
ac30e3f
 
 
 
 
 
 
 
 
 
 
 
f8bd24e
ac30e3f
 
 
 
 
 
 
 
 
 
 
f8bd24e
ac30e3f
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
"""Generic, safe extraction parser and downloadable CSV assembler.

A standardized transport envelope contains source-defined columns + compact row arrays.
There are NO hardcoded environmental engineering or chart fields.
"""
from __future__ import annotations
import base64
import csv
import io
import json
import math
import re

MAX_ROWS = 1000
MAX_COLUMNS = 100
MAX_CELL_CHARS = 10000
PROVENANCE = ["source_paper_id", "source_title", "source_doi", "source_paper_key", "source_media_label", "source_page"]


def _primitive(value):
    return value is None or isinstance(value, (str, bool, int)) or (isinstance(value, float) and math.isfinite(value))


def _valid_columns(columns):
    return (isinstance(columns, list) and 1 <= len(columns) <= MAX_COLUMNS
            and all(isinstance(x, str) and x.strip() and len(x) <= 160 for x in columns)
            and len(set(columns)) == len(columns))


def decode_structured_answer(answer: str):
    """Return (short answer, records, warning), for backward-compatible callers.

    New output is generic columns/rows. Legacy records are supported as a fallback.
    """
    s = (answer or "").strip()
    if s.startswith("```"):
        s = re.sub(r"^```(?:json)?\s*", "", s, flags=re.I)
        s = re.sub(r"\s*```$", "", s)
    try:
        obj = json.loads(s)
    except (ValueError, TypeError):
        return answer, [], "Incomplete or invalid model JSON; no CSV generated"
    if not isinstance(obj, dict):
        return answer, [], "Expected a JSON object"
    readable = obj.get("answer") if isinstance(obj.get("answer"), str) else ""
    columns, rows = obj.get("columns"), obj.get("rows")
    if columns is not None or rows is not None:
        if not _valid_columns(columns):
            return readable, [], "Invalid or duplicated column names"
        if not isinstance(rows, list) or len(rows) > MAX_ROWS:
            return readable, [], "Invalid row count"
        for row in rows:
            if (not isinstance(row, list) or len(row) != len(columns)
                    or any(not _primitive(v) or (isinstance(v, str) and len(v) > MAX_CELL_CHARS) for v in row)):
                return readable, [], "Mismatched columns/rows or unsupported cell type"
        return readable, [dict(zip(columns, row)) for row in rows], None
    legacy = obj.get("records")
    if isinstance(legacy, list) and len(legacy) <= MAX_ROWS and all(isinstance(r, dict) for r in legacy):
        if all(all(isinstance(k, str) and len(k) <= 160 and _primitive(v) for k,v in r.items()) for r in legacy):
            return readable, legacy, None
    return readable, [], "No valid columns/rows in model output"


def normalize_records(records):
    """Source-preserving general normalization. Never infer merged-cell content.

    Keep all source-defined column values unchanged; report no table-specific fields.
    """
    return [dict(rec) for rec in records], []


def _clean_csv_cell(value):
    if value is None:
        return ""
    if isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t", "\r", "\n"):
        return "'" + value
    return value


def csv_attachment(records, sources):
    """Export arbitrary rectangular records to base64-encoded CSV for the authenticated API.

    Preserve source column order from model's `columns` list (dict insertion order).
    For several source items, source ID columns are populated only if provided
    explicitly by the model; never silently ascribe rows to the first source.
    """
    if not records:
        return None
    if len(records) > MAX_ROWS:
        raise ValueError("Too many rows")
    columns = list(dict.fromkeys(k for r in records for k in r.keys()))
    if not _valid_columns(columns):
        raise ValueError("Invalid CSV columns")
    for r in records:
        if any(not _primitive(v) for v in r.values()):
            raise ValueError("CSV data must contain only scalar values")
    extras = PROVENANCE if len(sources) == 1 else []
    names = columns + [x for x in extras if x not in columns]
    stream = io.StringIO(newline="")
    writer = csv.DictWriter(stream, fieldnames=names, extrasaction="ignore")
    writer.writeheader()
    src = sources[0] if len(sources) == 1 else {}
    provenance = dict(zip(PROVENANCE, [src.get(k) for k in ("paper_id","paper_title","doi","paper_key","label","page")]))
    for record in records:
        output = dict(record)
        for name in extras:
            if name not in output:
                output[name] = provenance[name]
        writer.writerow({key:_clean_csv_cell(output.get(key)) for key in names})
    data=stream.getvalue().encode("utf-8-sig")
    if len(data)>2_000_000:
        raise ValueError("CSV exceeds 2MB response limit")
    return {"filename":"waterscope_media_extracted.csv", "mime_type":"text/csv", "encoding":"base64",
            "content_base64":base64.b64encode(data).decode("ascii"), "row_count":len(records), "columns":names}