Files
inquiry_robot/inquiry-agent/agent/llm/mode_sheet_map.py
T

353 lines
12 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.
"""
报价工作表映射 mode:上传时用千问产出 mappingJson。
本文件职责:加载映射分析提示词、拼用户消息、调千问、剥 Markdown、校验 JSON、
对照单元格清单做缺项检测,必要时再补跑一轮。
禁止:发明运价;在企微回调线程同步调用;出单阶段再调本 mode。
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any, Optional
from agent.llm.modes import LlmMode
logger = logging.getLogger(__name__)
MODE = LlmMode.SHEET_MAP
_PROMPT_CACHE: str | None = None
# 清单原文片段 → 白名单 code(用于缺项检测;不要求全覆盖词典)
_LABEL_TO_CODE: list[tuple[tuple[str, ...], str]] = [
(("提货地址", "Place of Receipt", "place of receipt"), "pickup_address"),
(("送货地址", "派送地址", "Place of Delivery", "place of delivery"), "delivery_address"),
(("货值", "Cargo Value", "cargo value"), "cargo_value"),
(("海关编码", "HS Code", "HS编码", "hs code"), "hs_code"),
(("油、电、磁", "油,电,磁", "油电磁", "IL, ELEC", "L,ELEC", "oil"), "oil_elec_mag"),
(("特殊说明", "特别说明", "Special Remarks"), "special_remarks"),
(("要求货物到达", "Requested Arriv", "抵达时间"), "eta_request"),
(("运输时效", "Transit Time", "航程时间", "Navigation time"), "transit_time"),
(("船期", "Sailing", "Requested Sh"), "sailing_schedule"),
(("开船日", "On Board"), "on_board_date"),
(("船公司", "Shipping Line", "Shipping Co"), "shipping_line"),
(("货好时间", "Cargo Ready"), "cargo_ready_date"),
(("贸易条款", "Terms"), "trade_terms"),
(("整柜或散货", "整柜或拼柜", "FCL or LCL"), "fcl_or_lcl"),
(("箱型", "柜量", "Type & Qty", "Cont."), "container_type_qty"),
(("起运港", "POL"), "pol"),
(("目的港", "POD"), "pod"),
(("品名", "Commodity"), "commodity"),
(("货物数量", "Quantity"), "quantity"),
(("询价日期", "Inquiry Date", "报价日期"), "quote_date"),
(("RFQ", "询价单号", "报价单编号"), "quote_no"),
(("有效期", "period"), "valid_until"),
]
_SEA_FEE_MARKERS: list[tuple[tuple[str, ...], tuple[str, ...]]] = [
# (清单关键词, 可匹配的 feeRegion id/title/tmsSource 子串)
(
("始发港费用", "Origin Charges"),
("origin_charges", "polcost", "始发港", "origin"),
),
(
("海运费用", "Ocean Freight"),
("ocean_freight", "oceancost", "海运费用", "ocean"),
),
(
("目的港费用", "Destination Charges"),
("destination_charges", "podcost", "目的港", "destination"),
),
]
def mode_meta() -> dict[str, Any]:
return {
"mode": MODE.value,
"provider": "qwen",
"purpose": "sheet_map",
"implemented": True,
}
def _prompt_path() -> Path:
return Path(__file__).resolve().parents[1] / "quote_templates" / "prompts" / "mapping_analyzer.txt"
def load_system_prompt() -> str:
"""加载映射分析器系统提示词(仓库内只读资源)。"""
global _PROMPT_CACHE
if _PROMPT_CACHE is not None:
return _PROMPT_CACHE
path = _prompt_path()
text = path.read_text(encoding="utf-8")
_PROMPT_CACHE = text
return text
def _inventory_lines(payload: dict[str, Any]) -> list[str]:
inventory = payload.get("cellInventory") or payload.get("cell_inventory") or []
if isinstance(inventory, list):
return [str(x) for x in inventory]
return [str(inventory)]
def _inventory_blob(payload: dict[str, Any]) -> str:
return "\n".join(_inventory_lines(payload))
def build_user_message(payload: dict[str, Any], *, gaps: list[str] | None = None) -> str:
"""
组装用户消息:运输方式 + 文件名 + 工作表 + 单元格清单。
gaps 非空时追加补全指令(第二轮)。
"""
mode = str(payload.get("transportMode") or payload.get("transport_mode") or "sea").strip()
mode_cn = {"sea": "海运", "air": "空运", "land": "陆运"}.get(mode, mode)
file_name = str(payload.get("fileName") or payload.get("file_name") or "")
sheet = str(payload.get("sheet") or "")
lines = _inventory_lines(payload)
max_lines = 2200
truncated_note = ""
if len(lines) > max_lines:
lines = lines[:max_lines]
truncated_note = f"\n(单元格清单已截断至前 {max_lines} 行)\n"
body = "\n".join(lines)
gap_block = ""
if gaps:
gap_lines = "\n".join(f"- {g}" for g in gaps)
gap_block = (
"\n【补跑缺口】上一轮 mapping 缺少以下项,请输出完整 mappingJson(不要只回增量):\n"
f"{gap_lines}\n"
)
return (
f"运输方式:{mode_cn}\n"
f"模板文件名:{file_name}\n"
f"工作表名:{sheet}\n"
f"单元格清单:地址 | 原文 | 合并范围 | 填充色\n"
f"{body}"
f"{truncated_note}"
f"{gap_block}\n"
"输出前自检 fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签,"
"再只输出 mappingJson。"
)
def strip_json_fence(raw: str) -> str:
"""去掉模型可能包的 Markdown 代码围栏。"""
text = (raw or "").strip()
if not text:
return ""
fence = re.match(r"^```(?:json)?\s*([\s\S]*?)\s*```$", text, re.IGNORECASE)
if fence:
return fence.group(1).strip()
# 偶发前后解释:取第一个 { 到最后一个 }
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return text[start : end + 1]
return text
def parse_mapping_json(raw: str) -> dict[str, Any]:
"""
解析模型正文为 mapping 对象。
成功:{ok:True, mapping:dict}
失败:{ok:False, error:str}
"""
cleaned = strip_json_fence(raw)
if not cleaned:
return {"ok": False, "error": "模型返回空内容"}
try:
data = json.loads(cleaned)
except json.JSONDecodeError as exc:
return {"ok": False, "error": f"mappingJson 不是合法 JSON: {exc}"}
if not isinstance(data, dict):
return {"ok": False, "error": "mappingJson 不是对象"}
if not isinstance(data.get("fields"), dict):
data["fields"] = data.get("fields") if isinstance(data.get("fields"), dict) else {}
data.setdefault("schemaVersion", "quote-template-mapping-v1")
return {"ok": True, "mapping": data}
def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None) -> list[str]:
"""
对照单元格清单,列出 mapping 明显缺口(中文说明,供补跑用户消息)。
不打外网。海运:费用段标题出现则要求对应 feeRegion;常见业务标签出现则要求 fields code。
"""
blob = _inventory_blob(payload)
if not blob.strip():
return []
mapping = mapping or {}
fields = mapping.get("fields") if isinstance(mapping.get("fields"), dict) else {}
field_codes = {str(k) for k in fields.keys()}
fee_regions = mapping.get("feeRegions") if isinstance(mapping.get("feeRegions"), list) else []
fee_blob = " ".join(
str(r.get("id") or "")
+ " "
+ str(r.get("title") or "")
+ " "
+ str(r.get("tmsSource") or "")
for r in fee_regions
if isinstance(r, dict)
).lower()
gaps: list[str] = []
mode = str(payload.get("transportMode") or payload.get("transport_mode") or "sea").strip().lower()
if mode in ("sea", "海运"):
for markers, matchers in _SEA_FEE_MARKERS:
if not any(m in blob for m in markers):
continue
if not any(x in fee_blob for x in matchers):
gaps.append(f"费用区缺失:清单有「{markers[0]}」,须建对应 feeRegion")
for labels, code in _LABEL_TO_CODE:
if code in field_codes:
continue
if any(lab in blob for lab in labels):
gaps.append(f"字段缺失:清单有「{labels[0]}」→ 应映射 {code}")
return gaps
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
"""
无网/单测入口:若带 assistant_json 则直接解析;否则返回未实现。
"""
raw = payload.get("assistant_json") or payload.get("content")
if raw is not None:
parsed = parse_mapping_json(str(raw))
parsed["stub"] = True
parsed["mode"] = MODE.value
return parsed
return {
"ok": False,
"stub": True,
"mode": MODE.value,
"error": "sheet_map_shell_needs_assistant_json_or_network",
"echo_keys": sorted(payload.keys()),
}
def _chat_once(
*,
chat: Any,
payload: dict[str, Any],
gaps: list[str] | None = None,
) -> dict[str, Any]:
messages = [
{"role": "system", "content": load_system_prompt()},
{"role": "user", "content": build_user_message(payload, gaps=gaps)},
]
result = chat(mode=MODE, messages=messages, temperature=0.0)
if not getattr(result, "ok", False):
err = str(getattr(result, "error", "") or "qwen_failed")
logger.warning("sheet_map 失败 versionId=%s err=%s", payload.get("versionId"), err)
return {
"ok": False,
"error": _humanize_llm_error(err),
"mapping": None,
"provider": getattr(result, "provider", "qwen"),
}
parsed = parse_mapping_json(str(result.content or ""))
if not parsed.get("ok"):
return {
"ok": False,
"error": str(parsed.get("error") or "解析 mapping 失败"),
"mapping": None,
"raw_preview": str(result.content or "")[:240],
}
return {
"ok": True,
"mapping": parsed["mapping"],
"provider": getattr(result, "provider", "qwen"),
}
def invoke_sheet_map(
payload: dict[str, Any],
*,
allow_network: bool = False,
chat_fn: Optional[Any] = None,
timeout_seconds: float = 180.0,
) -> dict[str, Any]:
"""
调用千问产出 mappingJson;若对照清单有明显缺口,再补跑一轮(最多 2 次 chat)。
allow_network=False 且无 chat_fn:不打外网。
副作用:可能出站 HTTPS;应在 Worker HTTP 槽内调用。
"""
if chat_fn is None and not allow_network:
return {"ok": False, "error": "sheet_map_network_disabled", "mapping": None}
from agent.llm.http_client import LlmHttpClient
if chat_fn is None:
client = LlmHttpClient.from_settings()
client.timeout_seconds = float(timeout_seconds)
chat = client.chat
else:
chat = chat_fn
first = _chat_once(chat=chat, payload=payload, gaps=None)
if not first.get("ok"):
return first
mapping = first["mapping"]
gaps = assess_mapping_gaps(payload, mapping)
rounds = 1
if gaps:
logger.info(
"sheet_map gaps versionId=%s n=%s sample=%s",
payload.get("versionId"),
len(gaps),
gaps[:5],
)
second = _chat_once(chat=chat, payload=payload, gaps=gaps)
rounds = 2
if second.get("ok") and second.get("mapping"):
mapping = second["mapping"]
# 第二轮仍失败结构时保留第一轮
elif not second.get("ok"):
logger.warning(
"sheet_map 补跑失败,保留第一轮 versionId=%s err=%s",
payload.get("versionId"),
second.get("error"),
)
remaining = assess_mapping_gaps(payload, mapping)
# 缺口落进 mappingJson,主账/后台可直接展示「表上有、未映射上」
if isinstance(mapping, dict):
mapping["mappingGaps"] = list(remaining)
logger.info(
"sheet_map ok versionId=%s rounds=%s fields=%s feeRegions=%s routeRegions=%s remain_gaps=%s",
payload.get("versionId"),
rounds,
len((mapping or {}).get("fields") or {}),
len((mapping or {}).get("feeRegions") or []),
len((mapping or {}).get("routeRegions") or []),
len(remaining),
)
return {
"ok": True,
"mapping": mapping,
"provider": first.get("provider", "qwen"),
"rounds": rounds,
"gaps_after": remaining,
}
def _humanize_llm_error(err: str) -> str:
e = (err or "").strip()
if e == "llm_data_usage_not_confirmed":
return "未确认 LLM 数据用途(LLM_DATA_USAGE_CONFIRMED),无法调用千问"
if e == "qwen_not_configured":
return "千问未配置(QWEN_API_KEY / QWEN_BASE_URL)"
if e.startswith("http_"):
return f"千问 HTTP 失败: {e}"
return f"千问调用失败: {e[:180]}"