"""Bounded attachment parsing and field provenance; no macros/formula execution.""" import base64 import csv import io import json import re import zipfile from hashlib import sha256 from pathlib import Path from xml.etree import ElementTree as ET from services.assistant_store import uid, now from services.skill_executor import number FIELDS = { "facility_amount": "facility.amount", "lease_asset_value": "lease.total_value", "operating_cashflow": "financial.operating_cashflow", "financial_liabilities": "financial.total_liabilities", "due_diligence_liabilities": "due_diligence.liabilities", "duplicate_asset_count": "lease.duplicate_count", "collection_dependency": "collection.dependency", "collection_evidence": "collection.evidence", "financial_assets": "financial.total_assets", "credit_overdue_count": "credit.overdue_count", "credit_report_date": "credit.report_date", "facility_term": "facility.term", } NUMERIC = set(FIELDS.values()) - {"collection.dependency", "collection.evidence", "credit.report_date"} LIMIT = 10 * 1024 * 1024 def scalar(field, value): if value is None or value in ("", "null", "缺失"): return None if field in NUMERIC: result = number(value) if field in ("lease.duplicate_count", "credit.overdue_count") and (result < 0 or result != result.to_integral_value()): raise ValueError("数量必须为非负整数") if field in ("facility.amount", "lease.total_value") and result <= 0: raise ValueError("融资金额和租赁物价值必须为正数") return str(result) if field == "collection.dependency": if value not in (True, False, "true", "false", "是", "否"): raise ValueError("回款依赖须为布尔值") return value in (True, "true", "是") return str(value)[:4000] def safe_zip(content): archive = zipfile.ZipFile(io.BytesIO(content)) if len(archive.infolist()) > 2000 or sum(i.file_size for i in archive.infolist()) > 40 * LIMIT: archive.close() raise ValueError("压缩包解压尺寸超过限制") return archive def parse(content, filename): if not content or len(content) > LIMIT: raise ValueError("材料为空或超过 10 MB") suffix = Path(filename).suffix.lower() chunks, warnings = [], [] def add(locator, text, **extra): if len(chunks) >= 15000: raise ValueError("材料内容超过首版处理上限") chunks.append({"locator": locator, "text": str(text)[:16000], **extra}) if suffix in (".txt", ".csv"): text = content.decode("utf-8-sig", errors="strict") if suffix == ".txt": for index, line in enumerate(text.splitlines(), 1): add(f"第{index}行", line) else: for index, row in enumerate(csv.reader(io.StringIO(text)), 1): add(f"第{index}行", " | ".join(row), cells=row) elif suffix == ".docx": with safe_zip(content) as archive: root = ET.fromstring(archive.read("word/document.xml")) ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"} para, table = 0, 0 for element in root.find("w:body", ns): if element.tag.endswith("}p"): para += 1 add(f"正文第{para}段", "".join(t.text or "" for t in element.findall(".//w:t", ns))) elif element.tag.endswith("}tbl"): table += 1 for r, row in enumerate(element.findall("w:tr", ns), 1): cells = ["".join(t.text or "" for t in cell.findall(".//w:t", ns)) for cell in row.findall("w:tc", ns)] add(f"表{table}第{r}行", " | ".join(cells), cells=cells) elif suffix == ".xlsx": ns = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} with safe_zip(content) as archive: strings = [] if "xl/sharedStrings.xml" in archive.namelist(): strings = ["".join(t.text or "" for t in s.findall(".//x:t", ns)) for s in ET.fromstring(archive.read("xl/sharedStrings.xml"))] rels = {r.get("Id"): r.get("Target") for r in ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))} book = ET.fromstring(archive.read("xl/workbook.xml")) for sheet in book.findall("x:sheets/x:sheet", ns): target = rels.get(sheet.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"), "") path = target.lstrip("/") if target.startswith("/") else "xl/" + target if ".." in path or path not in archive.namelist(): raise ValueError("工作表引用无效") for row in ET.fromstring(archive.read(path)).findall("x:sheetData/x:row", ns): cells = [] for cell in row.findall("x:c", ns): raw = cell.findtext("x:v", default="", namespaces=ns) kind = cell.get("t") text = strings[int(raw)] if kind == "s" and raw else ("".join(t.text or "" for t in cell.findall(".//x:t", ns)) if kind == "inlineStr" else raw) formula = cell.findtext("x:f", namespaces=ns) if formula is not None and raw == "": warnings.append(sheet.get("name") + "!" + cell.get("r") + "公式无缓存,未计算") add(sheet.get("name") + "!" + cell.get("r"), text, formula=formula, cached_value=raw if formula is not None else None) cells.append(text) if cells: add(sheet.get("name") + "!第" + row.get("r") + "行", " | ".join(cells), cells=cells) elif suffix in (".pdf", ".png", ".jpg", ".jpeg"): from api.collateral_agent import extract_pages for page in extract_pages(content, filename): add("第" + str(page["number"]) + "页", page.get("text", ""), method=page.get("method"), status=page.get("status")) if not page.get("text", "").strip(): warnings.append("第" + str(page["number"]) + "页未提取到文字,请启用 OCR 或补充可读材料") else: raise ValueError("支持 TXT、CSV、DOCX、XLSX、PDF、PNG、JPG;不执行宏或外链") if sum(len(c["text"]) for c in chunks) > 300000: raise ValueError("材料文字超过 30 万字,请拆分") return chunks, warnings def material(project_id, filename, content, actor, version=1): filename = Path(filename.replace("\\", "/")).name[:180] try: chunks, warnings = parse(content, filename) except (OSError, zipfile.BadZipFile, ET.ParseError, UnicodeError, csv.Error, IndexError, TypeError) as exc: raise ValueError("材料内容损坏、编码不支持或格式不匹配,请检查原文件") from exc body = {"id": uid("doc"), "project_id": project_id, "name": filename, "version": version, "content_hash": sha256(content).hexdigest(), "created_at": now(), "actor_id": actor, "size": len(content), "chunks": chunks, "warnings": warnings, "status": "partial" if warnings else "parsed", "raw_base64": base64.b64encode(content).decode(), "candidates": []} for chunk in body["chunks"]: chunk["evidence_id"] = uid("evdoc") asset_rows = [c for c in chunks if c.get("cells")] if asset_rows and asset_rows[0]["cells"][:2] == ["asset_id", "value"]: assets, duplicates, conflicting = {}, 0, False for row in asset_rows[1:]: cells = row["cells"] if len(cells) < 2 or not cells[0].strip(): body["warnings"].append(row["locator"] + "缺少设备编号或价值") continue value = number(cells[1]) if value <= 0: raise ValueError("设备价值必须大于零") if cells[0] in assets: duplicates += 1 if assets[cells[0]] != value: conflicting = True else: assets[cells[0]] = value metadata = {"document_id": body["id"], "source": filename, "locator": "设备清单去重计算;" + "、".join(c["locator"] for c in asset_rows[1:]), "currency": "CNY", "unit": "万元", "period": "2025", "scope": "合并", "formula_version": "unique-asset-id-sum@1.0.0"} # Only the explicit demo schema below establishes these dimensions. Other asset files require confirmation. if asset_rows[0]["cells"][2:] != ["currency=CNY", "unit=万元", "period=2025", "scope=合并"]: metadata.update(currency=None, unit=None, period=None, scope=None) body["warnings"].append("设备清单未声明币种、单位、期间或口径;人工确认前不得计算覆盖率") body["candidates"].extend([{**metadata, "id": uid("candidate"), "field": "lease.duplicate_count", "value": str(duplicates), "unit": "count"}, {**metadata, "id": uid("candidate"), "field": "lease.total_value", "value": None if conflicting or not assets else str(sum(assets.values()))}]) if conflicting: body["warnings"].append("同一设备编号价值不一致,未计算总价值") if body["warnings"]: body["status"] = "partial" for chunk in chunks: cells = chunk.get("cells") or re.split(r"[::=]", chunk["text"], maxsplit=1) if len(cells) < 2: continue field = FIELDS.get(cells[0].strip(), cells[0].strip()) if field not in FIELDS.values(): continue try: value = scalar(field, cells[1].strip()) body["candidates"].append({"id": uid("candidate"), "field": field, "value": value, "document_id": body["id"], "locator": chunk["locator"], "source": filename, "currency": cells[2] if len(cells) > 2 else None, "unit": cells[3] if len(cells) > 3 else None, "period": cells[4] if len(cells) > 4 else None, "scope": cells[5] if len(cells) > 5 else None}) except ValueError as exc: body["warnings"].append(chunk["locator"] + ": " + str(exc)) body["status"] = "partial" return body def demo_material(project): stream = io.StringIO(newline="") writer = csv.writer(stream) writer.writerow(["field", "value", "currency", "unit", "period", "scope"]) for field, value in project["facts"].items(): name = FIELDS.get(field, field) if name not in FIELDS.values(): continue writer.writerow([name, "null" if value is None else (json.dumps(value) if isinstance(value, bool) else value), "CNY" if name in NUMERIC else "", "count" if name.endswith("count") else ("万元" if name in NUMERIC else ""), "2025", "合并"]) return material(project["id"], project["id"] + "演示源数据.csv", stream.getvalue().encode("utf-8-sig"), "demo-fixture") def demo_assets(project): """Real parseable list; duplicate values are excluded from the amount numerator.""" amount = number(project["facts"]["lease_asset_value"]) count = int(number(project["facts"].get("duplicate_asset_count", 0))) stream = io.StringIO(newline="") writer = csv.writer(stream) writer.writerow(["asset_id", "value", "currency=CNY", "unit=万元", "period=2025", "scope=合并"]) writer.writerow(["ASSET-001", str(amount)]) for _ in range(count): writer.writerow(["ASSET-001", str(amount)]) return material(project["id"], project["id"] + "设备清单.csv", stream.getvalue().encode("utf-8-sig"), "demo-fixture") def apply_candidate(project, candidate, actor, reason): value = scalar(candidate["field"], candidate.get("value")) evidence = dict(candidate, id=uid("ev"), project_id=project["id"], value=value, actor_id=actor, reason=reason, accepted_at=now()) money_fields = NUMERIC - {"lease.duplicate_count", "credit.overdue_count", "facility.term"} if candidate["field"] in money_fields and value is not None and candidate.get("unit"): from api.risk_agent_alignment import SCALE if candidate["unit"] not in SCALE: raise ValueError("不支持的金额单位,请确认元、万元或亿元") evidence.update(raw_value=value, raw_unit=candidate["unit"], unit="万元") value = str(number(value) * SCALE[candidate["unit"]]) evidence["value"] = value project.setdefault("sources", {}).setdefault(candidate["field"], []).append(evidence) project.setdefault("values", {})[candidate["field"]] = value project.setdefault("selected_sources", {})[candidate["field"]] = evidence["id"] old = next((k for k, v in FIELDS.items() if v == candidate["field"]), candidate["field"]) project.setdefault("facts", {})[old] = value return evidence