aps-agent/server/aps_domain/multimodal.py

843 lines
39 KiB
Python
Raw Normal View History

# ============================================================
# 多模态输入管线框架(moduleId: domain-multimodal, 可重生 ✅)
# 矩阵 101(方向 Y):语音/图片/文件多模态与低置信确认
# - Extractor 协议:raw(文本/字节/文件名)→ ExtractionCandidate 列表
# (结构化候选:{kind, value, confidence(0-1), source, fields})
# - ExtractorRegistry:按 kind 注册/查询;未注册提取器显式报错(不编造)
# - 内置 stub 提取器:file_import(复用 importers 的 detect/preview/validate
# 思路输出候选+置信度)、text_order(「订单 单号 x 数量 n」模板抽取)、
# image_meta(图片/附件元数据:文件名/大小/类型/路径 → 订单/物料/日期线索
# 候选;无 OCR/VLM 能力时置信 ≤0.6 且标注 requiresVlm,走低置信确认门禁)
# - 低置信确认门禁(ingest_candidates / stage_multimodal_confirm):
# 候选置信度 < 阈值(默认 0.7,可配)→ harness P2 确认卡,确认前绝不
# 写世界状态;置信度 ≥ 阈值且字段完整 → 直通既有导入路径。
# - 审计:multimodal.extract / multimodal.stage / multimodal.apply
# (确认卡批准/驳回由 harness 通用通道写 import.commit(.reject),stage
# 审计携带 confirmId 关联——复用既有 P2 权力矩阵,见 stage_multimodal_confirm)
# 真实 ASR/VLM/OCR:实现 Extractor 协议并经 ExtractorRegistry.register()
# (replace=True 可覆盖同名 stub)接入;未注册时抛 ExtractorNotRegisteredError
# (fail closed,不编造识别结果)。真实 OCR/VLM 属外部模型服务扩展,本模块 stub
# 只做文件名/元数据级结构化候选,绝不编造图片识别内容。
# ============================================================
from __future__ import annotations
import base64
import os
import re
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable
World = dict[str, Any]
# 低置信确认阈值(环境变量可覆盖;ingest 亦可按请求覆盖)
DEFAULT_CONFIDENCE_THRESHOLD = 0.7
# 文本订单 stub 模板:口语/短文本订单语句的结构化槽位(确定性模板,非 LLM)
_TEXT_PATTERNS: dict[str, re.Pattern[str]] = {
"orderNo": re.compile(
r"(?:订单号|单号|工单号|订单编号|订单号是|单号是)\s*[::]?\s*"
r"([A-Za-z0-9][A-Za-z0-9\-_/]{1,31})"),
"quantity": re.compile(r"(?:数量|quantity|个数)\s*[::]?\s*(\d+(?:\.\d+)?)"),
"deliveryDate": re.compile(
r"(?:交期|交货日期|交货期|计划交期|dueDate|截止)\s*[::]?\s*"
r"(\d{4}[-/年]\d{1,2}[-/月]\d{1,2}日?)"),
"customerName": re.compile(
r"(?:客户名称|客户名|客户|customer)\s*[::]?\s*([\u4e00-\u9fa5A-Za-z0-9]{1,20})"),
"productCode": re.compile(
r"(?:产品编码|产品料号|物料编码|料号|productCode|产品)\s*[::]?\s*"
r"([A-Za-z0-9][A-Za-z0-9\-_]{1,31})"),
}
_TEXT_REQUIRED_FIELDS = ("orderNo", "quantity")
_TEXT_OPTIONAL_FIELDS = ("deliveryDate", "customerName", "productCode")
# 图片/附件元数据 stub 模板(矩阵 101 扩展:方向 JJ)
# 真实 OCR/VLM 需外部模型服务;stub 仅从文件名/大小/类型/路径提取结构化线索。
# 置信度封顶 0.6(IMAGE_META_MAX_CONFIDENCE)且标注 requiresVlm=True——低于
# 默认 0.7 阈值,必然走既有低置信确认门禁,确认前不写世界状态。
IMAGE_META_MAX_CONFIDENCE = 0.6
_IMAGE_ORDER_PATTERNS: tuple[re.Pattern[str], ...] = (
# 中文前缀:订单/订单号/工单/工单号 + 可选分隔 + 单号(数字或字母数字)
re.compile(
r"(?:订单|订单号|工单|工单号|订单编号|order|orderno)"
r"\s*[-_::]?\s*([A-Za-z0-9][A-Za-z0-9\-_/]{1,31})"),
# 标准前缀:SO/PO/WO/MO + 可选分隔 + 至少 3 位数字(前后非字母数字,避免把
# report/consolidated 等单词里的子串误判为单号)
re.compile(
r"(?<![A-Za-z0-9])(?:so|po|wo|mo)[-_]?\s*(\d{3,})", re.IGNORECASE),
)
_IMAGE_MATERIAL_PATTERN = re.compile(
r"(?:物料|物料编码|物料代码|料号|(?<![a-z])bom(?![a-z]))"
r"\s*[-_::]?\s*([A-Za-z0-9][A-Za-z0-9\-_/]{1,31})", re.IGNORECASE)
# 日期线索(优先级:完整日期 > 年月 > 紧凑 8 位;与订单号捕获区间重叠时视为
# 订单号而非日期——避免把订单数字误报为日期,守住「不编造」)
_IMAGE_DATE_FULL = re.compile(r"(\d{4})[-_/.年](\d{1,2})[-_/.月](\d{1,2})日?")
_IMAGE_DATE_MONTH = re.compile(r"(\d{4})[-_/.年](\d{1,2})月?")
_IMAGE_DATE_COMPACT = re.compile(r"(?<![0-9])(\d{4})(\d{2})(\d{2})(?![0-9])")
_IMAGE_ATTACH_KIND_RULES: tuple[tuple[str, re.Pattern[str]], ...] = (
("order", re.compile(
r"订单|工单|(?<![a-z])(?:order|orderno|so|po|wo|mo)(?![a-z])", re.IGNORECASE)),
("material", re.compile(
r"物料|料号|(?<![a-z])(?:bom|material)(?![a-z])", re.IGNORECASE)),
("drawing", re.compile(
r"图纸|工艺|(?<![a-z])(?:drawing|drg|dwg)(?![a-z])", re.IGNORECASE)),
("quote", re.compile(r"报价|(?<![a-z])quote(?![a-z])", re.IGNORECASE)),
("image", re.compile(
r"照片|图片|扫描|(?<![a-z])(?:scan|photo|img|image)(?![a-z])", re.IGNORECASE)),
)
_MIME_BY_EXT: dict[str, str] = {
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
".svg": "image/svg+xml", ".tif": "image/tiff", ".tiff": "image/tiff",
".heic": "image/heic", ".heif": "image/heif",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xls": "application/vnd.ms-excel", ".csv": "text/csv", ".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".doc": "application/msword",
}
@dataclass(frozen=True)
class ExtractionCandidate:
"""一条结构化提取候选(矩阵 101:ASR/VLM/文件解析的输出契约)。
kind: 候选类型(file_import / text_order / 扩展注册的 kind 如 asr、vlm)
value: 结构化载荷(file_import → importers preview 结果;text_order → 订单字段)
confidence: 置信度 0-1(< 阈值时须经确认才能写世界状态)
source: 来源标识(STUB_FILE / STUB_TEXT / 扩展来源名)
fields: 字段级置信明细(每字段原始值/置信度/完整性)
complete: 字段完整性(False 时拒绝写入,不编造缺失内容)
"""
kind: str
value: Any
confidence: float
source: str
fields: dict[str, Any] = field(default_factory=dict)
complete: bool = True
def to_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"value": self.value,
"confidence": round(float(self.confidence), 4),
"source": self.source,
"fields": self.fields,
"complete": bool(self.complete),
}
class ExtractorNotRegisteredError(KeyError):
"""请求的多模态提取器未注册:显式报错,绝不编造真实 ASR/VLM 结果。"""
def __init__(self, kind: str, *, registered: tuple[str, ...] = ()) -> None:
self.kind = kind
self.registered = tuple(sorted(registered))
super().__init__(
f"未注册的多模态提取器:{kind!r}(已注册:{', '.join(self.registered) or '无'})。"
"真实 ASR/VLM 需实现 Extractor 协议并经 ExtractorRegistry.register() 接入;"
"stub 框架不编造未接入服务的识别结果。"
)
@runtime_checkable
class Extractor(Protocol):
"""多模态提取器协议:raw(文本/字节/文件名)→ 结构化候选列表。"""
kind: str
source: str
def extract(self, raw: Any) -> list[ExtractionCandidate]:
...
class ExtractorRegistry:
"""按 kind 注册/查询提取器;未注册 kind 显式报错。"""
def __init__(self) -> None:
self._extractors: dict[str, Extractor] = {}
def register(self, extractor: Extractor, *, replace: bool = False) -> None:
"""注册一个提取器(真实 ASR/VLM 扩展入口)。
replace=True 可覆盖内置 stub(例如接入真实 ASR 后替换同名 stub)。
"""
kind = getattr(extractor, "kind", None)
if not kind or not isinstance(kind, str) or not kind.strip():
raise TypeError("extractor 必须暴露非空字符串 kind")
if kind in self._extractors and not replace:
raise ValueError(f"提取器已注册:{kind!r}(replace=True 可覆盖)")
self._extractors[kind.strip()] = extractor
def get(self, kind: str) -> Extractor:
try:
return self._extractors[kind]
except KeyError:
raise ExtractorNotRegisteredError(kind, registered=tuple(self._extractors)) from None
def list_kinds(self) -> list[str]:
return sorted(self._extractors)
def extract(self, kind: str, raw: Any) -> list[ExtractionCandidate]:
return self.get(kind).extract(raw)
class DxfDrawingExtractor:
"""工程图纸 DXF 提取器:标题栏/位置号/技术说明 → 结构化候选。
解析由 server.aps_domain.dxf_drawing 承担(块属性标题栏 + 中文标签兜底 +
文件名线索),候选置信度 = 物料候选置信度(标题栏图号/名称交叉校验后
通常 0.9;仅文件名线索时 0.5)。完整 = 物料编码与名称均已识别;
材质/数量/工序等图纸未承载字段保持 missing(不编造)。
写入正式主数据走 /api/dxf/stage → master.*.upsert 的 P2 确认卡,
不经 importers 批次(candidates_to_batches 对本 kind 显式 fail closed)。
"""
kind = "dxf_drawing"
source = "DXF_PARSE"
def extract(self, raw: Any) -> list[ExtractionCandidate]:
from server.aps_domain.dxf_drawing import (
drawing_to_master_candidates,
parse_dxf,
parse_dxf_bytes,
)
if isinstance(raw, dict) and raw.get("data") is not None:
parsed = parse_dxf_bytes(str(raw.get("filename") or "upload.dxf"),
bytes(raw["data"]))
elif isinstance(raw, dict) and raw.get("path"):
parsed = parse_dxf(str(raw["path"]))
elif isinstance(raw, str) and raw.strip():
parsed = parse_dxf(raw.strip())
else:
raise ValueError("dxf_drawing 提取器需要 {filename, data(bytes)}"
" 或 {path} 或路径字符串输入")
cands = drawing_to_master_candidates(parsed)
material = cands["material"]
tb_fields = (parsed.get("titleBlock") or {}).get("fields") or {}
value: dict[str, Any] = {
"drawingId": parsed["drawingId"],
"filename": parsed["filename"],
"dxfVersion": parsed["dxfVersion"],
"material": material,
"positionCount": len(parsed.get("positionNumbers") or []),
"noteCount": len(parsed.get("technicalNotes") or []),
"warnings": parsed.get("warnings") or [],
"candidates": cands,
}
complete = not material.get("missingFields")
fields = {
"titleBlock": {k: {"value": v.get("value"),
"confidence": v.get("confidence"),
"source": v.get("source")}
for k, v in tb_fields.items()},
"positionCount": value["positionCount"],
"bomComplete": bool(cands["bom"].get("complete")),
"routingComplete": bool(cands["routing"].get("complete")),
"missing": material.get("missingFields") or [],
"complete": complete,
"requiresConfirm": True,
}
return [ExtractionCandidate(
kind="dxf_drawing", value=value,
confidence=float(material.get("confidence") or 0.0),
source="DXF_PARSE", fields=fields, complete=complete,
)]
def default_registry(*, world: World | None = None) -> ExtractorRegistry:
"""内置 stub 注册表(file_import + text_order + image_meta + dxf_drawing)。
world:file_import 校验/识别用世界(None 时按当前加载的世界状态取)。
真实 ASR/VLM/OCR 扩展:default_registry().register(MyExtractor(), replace=True)。
"""
registry = ExtractorRegistry()
registry.register(FileImportExtractor(world=world))
registry.register(TextOrderExtractor())
registry.register(ImageMetaExtractor())
registry.register(DxfDrawingExtractor())
return registry
# ---------------- stub 提取器 ----------------
def _live_world() -> World:
from server.state.store import get_store
return get_store().data
def _coerce_file_raw(raw: Any) -> tuple[str, bytes]:
"""file_import 输入归一化:{filename, data(bytes)} 或 (filename, data)。"""
if isinstance(raw, tuple) and len(raw) == 2:
filename, data = raw
elif isinstance(raw, dict):
filename = str(raw.get("filename") or "upload")
data = raw.get("data")
else:
raise ValueError("file_import 提取器需要 {filename, data(bytes)} 或 (filename, data) 输入")
if not isinstance(data, (bytes, bytearray)):
raise TypeError("file_import 提取器需要字节内容 data")
return filename, bytes(data)
def _batch_confidence(batch: dict[str, Any]) -> float:
ok = int(batch.get("okCount") or 0)
err = int(batch.get("errorCount") or 0)
if ok + err <= 0:
return 0.0
return round(ok / (ok + err), 4)
class FileImportExtractor:
"""文件导入 stub:复用 importers 的 detect_kind/validate_batch/preview_file。
置信度 = 有效行占比(错误行不含置信);字段完整 = canCommit 且有效行 > 0。
真实 OCR/VLM 表格识别可作为扩展注册(kind=file_import 覆盖或新 kind)。
"""
kind = "file_import"
source = "STUB_FILE"
def __init__(self, *, world: World | None = None) -> None:
self._world = world
def extract(self, raw: Any) -> list[ExtractionCandidate]:
from server.aps_domain.importers import preview_file
filename, data = _coerce_file_raw(raw)
world = self._world if self._world is not None else _live_world()
preview = preview_file(filename, data, world)
total = int(preview.get("totalOk") or 0) + int(preview.get("totalErrors") or 0)
if total <= 0:
confidence, complete = 0.0, False
else:
confidence = round(int(preview.get("totalOk") or 0) / total, 4)
complete = bool(preview.get("canCommit")) and int(preview.get("totalOk") or 0) > 0
batches: dict[str, Any] = {}
for b in preview.get("batches") or []:
kind = str(b.get("kind") or "unknown")
batches[kind] = {
"sheet": b.get("sheet"),
"okCount": int(b.get("okCount") or 0),
"errorCount": int(b.get("errorCount") or 0),
"confidence": _batch_confidence(b),
}
fields = {
"batches": batches,
"canCommit": bool(preview.get("canCommit")),
"totalOk": int(preview.get("totalOk") or 0),
"totalErrors": int(preview.get("totalErrors") or 0),
"complete": complete,
}
return [ExtractionCandidate(
kind="file_import", value=preview, confidence=confidence,
source="STUB_FILE", fields=fields, complete=complete,
)]
def _parse_quantity(v: Any) -> int | None:
try:
return int(float(str(v).strip()))
except (TypeError, ValueError):
return None
class TextOrderExtractor:
"""文本订单 stub:「订单 单号 x 数量 n」模板抽取(确定性模板)。
必填槽位:订单号 + 数量(完整);可选槽位:交期/客户/产品。
置信度:完整模板 0.6 起步,补齐可选槽位递增(交期 +0.2、客户 +0.1、
产品 +0.1,封顶 1.0);槽位缺失(不完整)0.3——低置信必须经确认。
未命中任何模板槽位 → 返回空(不编造)。
"""
kind = "text_order"
source = "STUB_TEXT"
def extract(self, raw: Any) -> list[ExtractionCandidate]:
if isinstance(raw, str):
text = raw
elif isinstance(raw, dict):
text = str(raw.get("text") or "")
else:
text = str(raw or "")
text = text.strip()
if not text:
return []
matched: dict[str, str] = {}
for slot, pattern in _TEXT_PATTERNS.items():
m = pattern.search(text)
if m:
matched[slot] = m.group(1).strip()
# 宽松兜底:订单 <code>(ASCII 字母开头,避免把纯数字数量误识别为单号)
if "orderNo" not in matched:
m = re.search(r"订单\s+([A-Za-z][A-Za-z0-9\-_/]{1,31})", text)
if m:
matched["orderNo"] = m.group(1).strip()
if not matched:
return [] # 无任何模板命中:不编造
complete = all(f in matched for f in _TEXT_REQUIRED_FIELDS)
missing = [f for f in _TEXT_REQUIRED_FIELDS if f not in matched] + [
f for f in _TEXT_OPTIONAL_FIELDS if f not in matched]
if not complete:
confidence = 0.3
else:
confidence = 0.6 + (0.2 if "deliveryDate" in matched else 0.0) \
+ (0.1 if "customerName" in matched else 0.0) \
+ (0.1 if "productCode" in matched else 0.0)
qty = _parse_quantity(matched.get("quantity"))
value: dict[str, Any] = {
"orderNo": matched.get("orderNo", ""),
"quantity": qty if qty is not None else matched.get("quantity", ""),
"deliveryDate": matched.get("deliveryDate", ""),
"customerName": matched.get("customerName", ""),
"productCode": matched.get("productCode", ""),
}
fields = {
"matched": matched,
"required": list(_TEXT_REQUIRED_FIELDS),
"optional": list(_TEXT_OPTIONAL_FIELDS),
"missing": missing,
"complete": complete,
}
return [ExtractionCandidate(
kind="text_order", value=value, confidence=round(min(confidence, 1.0), 4),
source="STUB_TEXT", fields=fields, complete=complete,
)]
def _coerce_image_meta_raw(raw: Any) -> tuple[str, str | None, int | None, str | None]:
"""image_meta 输入归一化:raw → (name, path, size, mime)。
接受文件名/路径字符串或字典 {path|name|filename|size|mime|data_base64|...}。
无 path/name 时显式报错(不编造);path 存在时 size 优先取真实文件大小。
"""
if isinstance(raw, str):
name, path = os.path.basename(raw), raw
size, mime = None, None
elif isinstance(raw, dict):
path = str(raw.get("path") or "").strip() or None
name = str(raw.get("name") or raw.get("filename") or "").strip()
if not name and path:
name = os.path.basename(path)
size = raw.get("size")
mime = str(raw.get("mime") or "").strip() or None
else:
raise ValueError(
"image_meta 提取器需要 文件名/路径 字符串或 {path|name|size|mime} 字典输入")
if not name:
raise ValueError("image_meta 提取器需要 path 或 name(本地图片/附件路径或文件名)")
if size is not None:
try:
size = int(size)
except (TypeError, ValueError):
size = None
if path and os.path.isfile(path):
try:
size = os.path.getsize(path) # 本地路径:取真实大小
except OSError:
pass
if not mime:
mime = _MIME_BY_EXT.get(os.path.splitext(name)[1].lower())
return name, path, size, mime
def _image_date_value(name: str, order_span: tuple[int, int] | None) -> str:
"""从文件名提取日期线索(完整日期 > 年月 > 紧凑 8 位)。
order_span:订单号捕获区间;日期若与订单号重叠(如 SO-20260801 的 8 位数字
已被识别为单号),不再重复识别为日期——防止把订单数字误报为日期(不编造)。
"""
def overlaps(m: re.Match[str]) -> bool:
return (order_span is not None
and m.start(1) < order_span[1] and order_span[0] < m.end(1))
m = _IMAGE_DATE_FULL.search(name)
if m and not overlaps(m):
return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
m = _IMAGE_DATE_MONTH.search(name)
if m and not overlaps(m):
return f"{m.group(1)}-{int(m.group(2)):02d}"
m = _IMAGE_DATE_COMPACT.search(name)
if m and not overlaps(m):
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
return ""
def _image_attach_kind(stem: str) -> str:
"""附件类型线索(确定性关键词,首个命中优先;无命中 → attachment)。"""
for kind, pattern in _IMAGE_ATTACH_KIND_RULES:
if pattern.search(stem):
return kind
return "attachment"
class ImageMetaExtractor:
"""图片/附件元数据 stub(矩阵 101 扩展:方向 JJ)。
输入:本地图片/附件路径或文件名(raw={path|name|size|mime|...},或字符串)。
输出:结构化候选——从文件名提取订单/物料/日期线索(订单-102285668.xlsx →
orderNo=102285668;物料BOM-2026-08.xlsx → materialCode=BOM-2026-08 +
date=2026-08;IMG_20260802.png → date=2026-08-02),并携带大小/类型/路径等
元数据。
不编造:
- 无 OCR/VLM 能力时置信度封顶 0.6(IMAGE_META_MAX_CONFIDENCE)并标注
requiresVlm=True,必然低于默认 0.7 阈值 → 走既有低置信确认门禁;
- 文件名无任何订单/物料/日期线索 → 返回空列表(绝不编造识别内容);
- 与订单号重叠的 8 位数字不被重复识别为日期(如 SO-20260801 只认单号)。
真实 OCR/VLM:实现 Extractor 协议并经 ExtractorRegistry.register()
(replace=True 覆盖本 stub)后,ExtractorRegistry.extract("image_meta", raw)
即切换为真实识别结果。
"""
kind = "image_meta"
source = "STUB_IMAGE_META"
def extract(self, raw: Any) -> list[ExtractionCandidate]:
name, path, size, mime = _coerce_image_meta_raw(raw)
stem = name.lower()
matched: dict[str, str] = {}
order_span: tuple[int, int] | None = None
for pattern in _IMAGE_ORDER_PATTERNS:
m = pattern.search(name)
if m:
matched["orderNo"] = m.group(1).strip()
order_span = (m.start(1), m.end(1))
break
m = _IMAGE_MATERIAL_PATTERN.search(name)
if m:
matched["materialCode"] = m.group(1).strip()
date = _image_date_value(name, order_span)
if date:
matched["date"] = date
if not matched:
return [] # 无任何文件名线索:不编造
attach_kind = _image_attach_kind(stem)
confidence = (0.2 + 0.1 * len(matched)
+ (0.1 if attach_kind != "attachment" else 0.0)
+ (0.1 if size is not None else 0.0))
confidence = round(min(confidence, IMAGE_META_MAX_CONFIDENCE), 4)
value: dict[str, Any] = {
"filename": name,
"path": path or "",
"size": size,
"mime": mime,
"attachmentKind": attach_kind,
"orderNo": matched.get("orderNo", ""),
"materialCode": matched.get("materialCode", ""),
"date": matched.get("date", ""),
"requiresVlm": True, # stub 无真实识别能力
"patterns": sorted(matched),
}
fields: dict[str, Any] = {
"matched": matched,
"missing": [f for f in ("orderNo", "materialCode", "date") if f not in matched],
"complete": True,
"requiresVlm": True,
"filename": name, "path": path or "", "mime": mime, "size": size,
}
return [ExtractionCandidate(
kind="image_meta", value=value, confidence=confidence,
source="STUB_IMAGE_META", fields=fields, complete=True,
)]
# ---------------- 入库归一化与确认门禁 ----------------
def _coerce_candidate(c: Any) -> ExtractionCandidate:
"""候选入参归一化:接受 ExtractionCandidate 或 JSON dict(extract 输出回传)。"""
if isinstance(c, ExtractionCandidate):
return c
if isinstance(c, dict):
try:
return ExtractionCandidate(
kind=str(c["kind"]),
value=c.get("value"),
confidence=float(c["confidence"]),
source=str(c.get("source") or "UNKNOWN"),
fields=c.get("fields") or {},
complete=bool(c.get("complete", False)),
)
except (KeyError, TypeError, ValueError) as exc:
raise ValueError(f"候选结构非法:{exc}") from exc
raise TypeError(f"候选必须是 ExtractionCandidate 或 dict,得到 {type(c).__name__}")
def _threshold(threshold: float | None) -> float:
if threshold is not None:
try:
threshold = float(threshold)
except (TypeError, ValueError) as exc:
raise ValueError("置信度阈值必须是数字") from exc
if not (0.0 <= threshold <= 1.0):
raise ValueError("置信度阈值必须在 0-1 之间")
return threshold
try:
return float(os.environ.get("APS_MULTIMODAL_CONFIDENCE_THRESHOLD")
or DEFAULT_CONFIDENCE_THRESHOLD)
except ValueError:
return DEFAULT_CONFIDENCE_THRESHOLD
def _text_order_batch(value: dict[str, Any]) -> dict[str, Any]:
"""文本订单候选 → 既有 orders 导入批次(缺交期按当日入柔性订单池,缺字段 fail closed)。"""
qty = _parse_quantity(value.get("quantity"))
if qty is None or qty <= 0:
raise ValueError("文本订单候选缺少有效数量,拒绝写入世界状态(fail closed,不编造)")
from server.timeutil import today0
row: dict[str, Any] = {
"orderNo": str(value.get("orderNo") or "").strip(),
"quantity": qty,
"deliveryDate": str(value.get("deliveryDate") or "").strip() or today0().strftime("%Y-%m-%d"),
"customerName": str(value.get("customerName") or "").strip(),
}
product_code = str(value.get("productCode") or "").strip()
if product_code:
row["productCode"] = product_code
return {"kind": "orders", "sheet": "multimodal-text", "okRows": [row]}
def candidates_to_batches(candidates: list[Any]) -> list[dict[str, Any]]:
"""多模态候选 → 既有 importers 批次(确认卡冻结参数 / 直通入库共用)。
支持可入库的 kind:file_import / text_order / image_meta。
image_meta → attachments 元数据登记批次(文件名/大小/类型/订单/物料/日期线索;
真实附件入库接线属外部 VLM 扩展,apply_import_commit 尚无 attachments 分支时
确认后不写域表——元数据完整保留在确认卡参数与审计中,绝不落虚假业务行)。
其余扩展 kind 拒绝入库(扩展提取器只输出识别结果,入库语义需显式接线)。
"""
batches: list[dict[str, Any]] = []
for c in candidates:
cand = _coerce_candidate(c)
if cand.kind == "file_import":
for b in (cand.value or {}).get("batches") or []:
if not b.get("okRows"):
continue
batches.append({
"kind": b.get("kind"), "sheet": b.get("sheet"),
"okRows": b.get("okRows") or [],
})
elif cand.kind == "text_order":
batches.append(_text_order_batch(cand.value or {}))
elif cand.kind == "image_meta":
v = cand.value or {}
batches.append({
"kind": "attachments",
"sheet": "image-meta",
"okRows": [{
"filename": v.get("filename") or "",
"path": v.get("path") or "",
"size": v.get("size"),
"mime": v.get("mime"),
"attachmentKind": v.get("attachmentKind"),
"orderNo": v.get("orderNo") or "",
"materialCode": v.get("materialCode") or "",
"date": v.get("date") or "",
"requiresVlm": bool(v.get("requiresVlm")),
}],
})
else:
hint = (";dxf_drawing 候选请走 /api/dxf/stage 的 master.*.upsert P2 确认卡"
if cand.kind == "dxf_drawing" else "")
raise ValueError(
f"不支持直接入库的多模态候选 kind:{cand.kind!r}"
"(直通/确认只支持 file_import、text_order、image_meta)" + hint)
if not batches:
raise ValueError("候选归一化后没有可入库的批次(确认/直通均无可写内容)")
return batches
def _value_preview(c: ExtractionCandidate) -> str:
if c.kind == "file_import":
v = c.value or {}
return (f"文件 {v.get('filename', '')}:有效 {v.get('totalOk', 0)} 行"
f" / 错误 {v.get('totalErrors', 0)} 行")
if c.kind == "text_order":
v = c.value or {}
parts = [f"{k}={v.get(k) or '—'}" for k in ("orderNo", "quantity", "deliveryDate",
"customerName", "productCode")]
return ",".join(parts)
if c.kind == "image_meta":
v = c.value or {}
clues = ",".join(p for p in ("orderNo", "materialCode", "date") if v.get(p))
clues = clues or "无文件名线索(需 VLM 识别)"
return (f"附件 {v.get('filename') or '?'}({v.get('attachmentKind') or '?'} · "
f"{v.get('mime') or '未知类型'} · {v.get('size') or '大小未知'} 字节):{clues}")
return f"{c.kind} 候选(字段 {len(c.fields)} 项)"
def confirmation_for_multimodal(candidates: list[Any], threshold: float) -> tuple[str, list[str]]:
"""低置信确认卡内容:候选明细/置信度/来源(确认前绝不写世界状态)。"""
cands = [_coerce_candidate(c) for c in candidates]
title = "多模态候选确认 · 低置信入库"
lines = [f"置信度阈值 {threshold}:以下 {len(cands)} 条候选需人工确认后才会写入世界状态"]
for i, c in enumerate(cands, 1):
conf = f"{max(0.0, min(1.0, float(c.confidence))) * 100:.0f}%"
lines.append(f"{i}. [{c.kind} · 来源 {c.source} · 置信 {conf} · 完整 {c.complete}] {_value_preview(c)}")
lines.append("批准后经既有导入路径写入主干(P2,自动建档可回滚);驳回不产生任何写入")
return title, lines
def stage_multimodal_confirm(world: World, next_id, candidates: list[Any], *,
session_id: str = "web", actor: str = "multimodal",
threshold: float | None = None) -> dict[str, Any]:
"""低置信候选 → P2 确认卡(复用既有 P2 动作 import.commit)。
说明:harness._POWER_MAP 是封闭表(server/agent_core/harness.py,本轮写范围外),
未登记动作默认按最高 P3(双人确认)处理,不符合 P2 单确认语义;因此低置信卡
复用既有 P2 动作 import.commit,确认执行走既有 execute_confirmed 分支(建档+审计+落盘)。
出卡仅登记意图与候选明细,确认前绝不写世界状态。
"""
from server.agent_core import harness
from server.agent_core.audit import write_audit
threshold = _threshold(threshold)
cands = [_coerce_candidate(c) for c in candidates]
if not cands:
raise ValueError("没有可确认的多模态候选")
low = [c for c in cands if not c.complete or float(c.confidence) < threshold]
if not low:
raise ValueError("没有低置信候选需要确认(置信度均 ≥ 阈值且字段完整)")
batches = candidates_to_batches(cands) # 冻结进确认卡参数(确认后原样执行)
title, lines = confirmation_for_multimodal(cands, threshold)
block = harness.stage_confirmation(
session_id or "web",
"import.commit",
{"filename": "multimodal-input", "batches": batches,
"multimodalMeta": {"threshold": threshold, "candidateCount": len(cands)}},
title=title, summary_lines=lines,
)
confirm_id = str(block.props["confirmId"])
write_audit(world, next_id, actor=actor, category="GATE", action="multimodal.stage",
target={"type": "MULTIMODAL", "id": confirm_id}, power="P2",
rationale={
"confirmId": confirm_id,
"threshold": threshold,
"action": "import.commit", # 复用既有 P2 权力矩阵动作
"candidates": [
{"kind": c.kind, "confidence": round(float(c.confidence), 4),
"source": c.source, "complete": bool(c.complete)} for c in cands
],
},
result="PENDING")
return {
"status": "staged",
"message": f"{title} 已进入 P2 确认队列(低置信候选,确认前未写世界状态)。",
"confirmId": confirm_id,
"block": block.model_dump(),
"threshold": threshold,
"candidates": [c.to_dict() for c in cands],
}
def apply_multimodal_candidates(world: World, next_id, candidates: list[Any], *,
actor: str = "multimodal",
threshold: float | None = None,
confirm_id: str | None = None) -> dict[str, Any]:
"""高置信/已确认候选 → 世界状态(直通既有导入路径 apply_import_commit)。
fail closed:任一候选不完整/置信度越界/文本候选缺数量 → 抛 ValueError,
不做任何写入(先全量校验再入库,无部分写入)。
"""
from server.agent_core.audit import write_audit
threshold = _threshold(threshold)
cands = [_coerce_candidate(c) for c in candidates]
incomplete = [c for c in cands if not c.complete]
if incomplete:
raise ValueError(
"候选字段不完整,拒绝写入世界状态:"
+ ", ".join(f"{c.kind} 缺失 {c.fields.get('missing')}" if isinstance(c.fields, dict)
else c.kind for c in incomplete))
for c in cands:
conf = float(c.confidence)
if not (0.0 <= conf <= 1.0):
raise ValueError(f"候选置信度越界:{c.kind} {conf!r}")
batches = candidates_to_batches(cands) # 文本候选缺数量在此 fail closed
from server.aps_domain.importers import apply_import_commit
applied = apply_import_commit(world, next_id, batches)
write_audit(world, next_id, actor=actor, category="WORLD_WRITE", action="multimodal.apply",
target={"type": "MULTIMODAL", "id": ",".join(c.kind for c in cands) or "unknown"},
power="P2",
rationale={
"summary": applied["summary"],
"total": applied["total"],
"sources": [c.source for c in cands],
"threshold": threshold,
"confirmId": confirm_id,
})
return applied
def ingest_candidates(world: World, next_id, candidates: list[Any], *,
session_id: str = "web", actor: str = "multimodal",
threshold: float | None = None) -> dict[str, Any]:
"""多模态入库门禁(矩阵 101 核心):
1. 字段不完整 → 显式拒绝(不编造、不出卡、不写世界状态)
2. 全部 置信度 ≥ 阈值 且字段完整 → 直通既有导入路径(apply_multimodal_candidates)
3. 任一候选低置信 → P2 确认卡(stage_multimodal_confirm;确认前绝不写世界状态,
批准后经 execute_confirmed 写,驳回不写)
"""
threshold = _threshold(threshold)
cands = [_coerce_candidate(c) for c in candidates]
if not cands:
raise ValueError("没有可入库的多模态候选")
incomplete = [c for c in cands if not c.complete]
if incomplete:
return {
"status": "incomplete",
"message": "候选字段不完整,拒绝写入(不编造缺失内容;请补充后重试)",
"threshold": threshold,
"candidates": [c.to_dict() for c in cands],
"missing": [
{"kind": c.kind,
"missingFields": c.fields.get("missing") if isinstance(c.fields, dict) else []}
for c in incomplete
],
}
low = [c for c in cands if float(c.confidence) < threshold]
if not low:
applied = apply_multimodal_candidates(world, next_id, cands, actor=actor,
threshold=threshold)
return {
"status": "applied",
"message": "多模态候选高置信直通导入完成(未出确认卡)。",
"summary": applied["summary"],
"total": applied["total"],
"threshold": threshold,
"candidates": [c.to_dict() for c in cands],
}
return stage_multimodal_confirm(world, next_id, cands, session_id=session_id,
actor=actor, threshold=threshold)
# ---------------- 网关辅助 ----------------
def build_raw(kind: str, *, text: str | None = None, filename: str | None = None,
data_base64: str | None = None) -> Any:
"""把网关请求字段归一化为提取器 raw 输入。
文件类:base64 → bytes({filename, data});文本类:原样文本;
扩展 kind:透传字段字典(由已注册的扩展提取器自行解释)。
"""
if kind == "file_import":
if not data_base64:
raise ValueError("文件类提取需要 base64 文件内容(dataBase64)")
try:
data = base64.b64decode(data_base64, validate=True)
except (ValueError, TypeError) as exc:
raise ValueError("dataBase64 不是合法的 base64 内容") from exc
return {"filename": filename or "upload", "data": data}
if kind == "image_meta":
name = str(filename or "").strip()
if not name:
raise ValueError("图片/附件元数据提取需要 filename(本地路径或文件名)")
raw: dict[str, Any] = {"filename": name}
if data_base64:
raw["data_base64"] = data_base64 # 供真实 VLM 解码识别(stub 不使用)
return raw
if kind == "text_order":
text = str(text or "").strip()
if not text:
raise ValueError("文本类提取需要 text 内容")
return text
return {"kind": kind, "text": text, "filename": filename, "data_base64": data_base64}