Files
mini-openclaw-v2/backend/services/assistant_materials.py
T
qiaoqichaoandClaude Code c59fff68d3 feat(v2): S1 存储层——材料导入批次与事实冲突检测
T02 第二个子卡。复用 assistant_objects(新 kind),**未新建表**;旧库结构不变。

新增服务:
- services/material_import.py 批次生命周期:create/load/listing/mark_item/
  retry/cancel;状态由逐项状态派生(ready/partial/failed/parsing)
- services/fact_conflict.py 冲突检测/阻断/裁定;FactConflictUnresolved(E-17)

解决的**真实缺口**(BR-23 / AC-24):既有 apply_candidate 对 values/facts 是
后写覆盖,sources 虽累积多来源但无人检测分歧、无人阻断。本层在**新路径**前置
检测:冲突时证据仍入 sources(BR-22 留痕)但值不采用(BR-23),已确认值不被覆盖。

增量改动(均为可加性,未改既有语义与调用方行为):
- assistant_store.py:新增 idempotent_lookup / idempotent_remember 共享 helper。
  既有 enqueue 与 create_report 的内联实现**未改写**。用 INSERT OR REPLACE 而非
  裸 INSERT:重试路径上裸 INSERT 会抛 sqlite3.IntegrityError(非 Conflict,
  translate() 无法映射为 409)。
- assistant_materials.apply_candidate 新增 adopt=True 形参。adopt=False 只记证据
  不改值;既有调用方不传参,行为完全不变(有回归测试)。

设计决定:
- T00/04 曾提 3 个 kind;实现时发现契约中 ImportItem 无 revision 且内嵌于批次,
  items 是值对象,故收敛为 2 个 kind(import_batch / fact_conflict)
- E-17 有两个面:确认动作被拒 = 422;运行级结果 = HTTP 200 且冲突存于
  degradations。契约映射与注释均已写明,并有测试锁定

测试:新增 test_prescreen_storage.py(33 项)。全量 196 项 OK(124 → 196)。
scripts/verify.py 9/9 PASS(exit 0)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 14:49:03 +08:00

236 lines
13 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",
}
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, 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