"""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 = { "asset_ownership_evidence": "lease.ownership_evidence", "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", # 补齐原生方法文档声明、而 S1 字段表原本缺失的字段族:还款来源、涉诉、增信、保证金、保险、客户名称。 # 依据:skills/*/SKILL.md 的 inputs 声明,以及 REVIEW_TOPICS 已预设的 customer./repayment./legal./guarantee. 前缀。 "customer_name": "customer.name", "repayment_source_type": "repayment.source_type", "repayment_annual_amount": "repayment.annual_amount", "legal_case_count": "legal.case_count", "legal_amount": "legal.amount", "guarantee_amount": "guarantee.amount", "deposit_amount": "deposit.amount", "insurance_amount": "insurance.amount", } NUMERIC = set(FIELDS.values()) - {"collection.dependency", "collection.evidence", "credit.report_date", "customer.name", "repayment.source_type", "lease.ownership_evidence"} 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", "legal.case_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 = [], [] stem = Path(filename).stem def add(locator, text, **extra): if len(chunks) >= 15000: raise ValueError("材料内容超过首版处理上限") chunks.append({"locator": locator, "text": str(text)[:16000], **extra}) # 类型化定位:与自由字符串 locator 并存(claude_use/T00/04 §5.3)。 # **保留 locator** —— enqueue() 用它算证据 ID 的稳定哈希,改它会破坏版本冻结。 # document_id / document_version 由 material() 补齐后按契约校验。 def pdf(page): return {"kind": "pdf", "page": page, "excerpt": f"第{page}页"} def sheet_loc(sheet, cell_range): return {"kind": "table", "sheet": sheet, "cell_range": cell_range, "excerpt": f"{sheet}!{cell_range}" if sheet else cell_range} def manual(note): return {"kind": "manual", "note": note, "excerpt": note} 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, source_locator=sheet_loc(stem, f"第{index}行")) else: for index, row in enumerate(csv.reader(io.StringIO(text)), 1): add(f"第{index}行", " | ".join(row), cells=row, source_locator=sheet_loc(stem, f"第{index}行")) 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 # 正文段落不是行列可寻址的表格,归 manual(设计的三值枚举里没有第四种) add(f"正文第{para}段", "".join(t.text or "" for t in element.findall(".//w:t", ns)), source_locator=manual(f"正文第{para}段")) 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, source_locator=sheet_loc(f"表{table}", f"第{r}行")) 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): sheet_name = sheet.get("name") 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_name + "!" + cell.get("r") + "公式无缓存,未计算") add(sheet_name + "!" + cell.get("r"), text, formula=formula, cached_value=raw if formula is not None else None, source_locator=sheet_loc(sheet_name, cell.get("r"))) cells.append(text) if cells: add(sheet_name + "!第" + row.get("r") + "行", " | ".join(cells), cells=cells, source_locator=sheet_loc(sheet_name, f"第{row.get('r')}行")) 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"), source_locator=pdf(page["number"])) 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 document_id = uid("doc") # 补齐并**按契约校验**类型化定位:document_id/document_version 只有到这一步才知道, # 而 SourceLocator 要求二者必填。校验失败即视为解析产物不合规(不能落到库里当"定位")。 from pydantic import ValidationError from contracts.prescreen import SourceLocator for index, chunk in enumerate(chunks): locator = chunk.get("source_locator") if locator is None: raise ValueError(f"解析产物缺少类型化定位(第 {index + 1} 段)") try: chunk["source_locator"] = SourceLocator( **locator, document_id=document_id, document_version=version ).model_dump(mode="json") except ValidationError as exc: first = exc.errors()[0] raise ValueError(f"定位不合法({first['loc'][-1] if first['loc'] else '未知'}):{first['msg']}") from exc body = {"id": document_id, "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, adopt=True): """记录一个材料候选为证据。 adopt=True(默认,既有行为不变):证据入 sources,并把值写进 values / selected_sources / facts。 adopt=False(新增,供 S1 事实确认使用):**只**把证据记入 sources,不改 values / selected_sources / facts。用于"候选与已确认值冲突、待人工裁定" 的情形——证据已按 BR-22 留痕,但值不得被覆盖(BR-23)。 """ 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) if not adopt: return 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