Files
mini-openclaw-v2/backend/services/assistant_materials.py
T
qiaoqichaoandClaude Code 4df80c0053 fix(v2): 修复预审模型字段表三处缺陷并留出工具预算余量
- 补 8 个标准字段(还款来源/涉诉/增信/保证金/保险/客户名称),对齐原生方法文档声明的 inputs
- 字段守卫改为比对标准字段表,不再把"标准字段但本次无数据"误判为未知字段
- 向模型下发 standard_fields 并在提示词约束字段名,避免自造派生指标
- 补 insurance./deposit. 维度前缀;方案预算 12→18、120s→180s,版本升 1.1.0

实测(临时库 + DeepSeek):模型类技能由 failed 转为 partial(部分 unknown 属如实标注),
降级记录为空,工具调用 11 次。字段表三处为代码层改动,对既有库即时生效。
验证:scripts/verify.py 11/11 PASS,531 项单测。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-30 17:38:28 +08:00

279 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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",
# 补齐原生方法文档声明、而 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"}
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