211 lines
7.3 KiB
Python
211 lines
7.3 KiB
Python
"""
|
|
DeepSeek B:文字询价字段抽取。
|
|
|
|
本文件职责:定义抽取 tool、解析模型 JSON、可选真网调用。
|
|
禁止:本地正则猜港口/箱型;禁止改六态;未确认数据用途不得出站。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any, Optional
|
|
|
|
from agent.llm.modes import LlmMode
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MODE = LlmMode.EXTRACT_FIELDS
|
|
|
|
EXTRACT_TOOL_NAME = "extract_inquiry_fields"
|
|
|
|
_FIELD_KEYS = (
|
|
"起运港",
|
|
"目的港",
|
|
"品名",
|
|
"件数",
|
|
"货量",
|
|
"毛重",
|
|
"体积",
|
|
"包装方式",
|
|
"报价日期",
|
|
"整柜或拼柜",
|
|
"箱型箱量",
|
|
"车型/数量",
|
|
"车型数量",
|
|
"通关口岸",
|
|
)
|
|
|
|
|
|
def mode_meta() -> dict[str, Any]:
|
|
"""返回本 mode 元数据。"""
|
|
return {
|
|
"mode": MODE.value,
|
|
"provider": "deepseek",
|
|
"purpose": "text_field_extract",
|
|
"implemented": True,
|
|
}
|
|
|
|
|
|
def extract_tool_definition() -> dict[str, Any]:
|
|
"""
|
|
DeepSeek B 唯一抽取 tool。
|
|
|
|
模型必须调用本 tool;不得输出给销售看的正文。
|
|
未在原话出现的字段填空串,禁止编造。
|
|
"""
|
|
field_props = {k: {"type": "string"} for k in _FIELD_KEYS}
|
|
return {
|
|
"type": "function",
|
|
"function": {
|
|
"name": EXTRACT_TOOL_NAME,
|
|
"description": "从销售原话抽取询价字段,未出现的填空串",
|
|
"parameters": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"transport_mode": {
|
|
"type": "string",
|
|
"enum": ["AIR", "SEA", "LAND", "UNKNOWN"],
|
|
},
|
|
"land_subtype": {"type": "string"},
|
|
"fields": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": field_props,
|
|
},
|
|
},
|
|
"required": ["transport_mode", "fields"],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def parse_extract_payload(raw: Any) -> dict[str, Any]:
|
|
"""
|
|
把模型 tool 参数或 JSON 正文变成 business_line + facts。
|
|
|
|
空串字段丢弃。非法结构返回 ok=False,不猜。
|
|
"""
|
|
data = raw
|
|
if isinstance(raw, str):
|
|
try:
|
|
data = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return {"ok": False, "error": "invalid_json", "facts": {}, "business_line": ""}
|
|
if not isinstance(data, dict):
|
|
return {"ok": False, "error": "not_object", "facts": {}, "business_line": ""}
|
|
mode = str(data.get("transport_mode") or "").strip().upper()
|
|
if mode == "UNKNOWN":
|
|
mode = ""
|
|
fields_in = data.get("fields") if isinstance(data.get("fields"), dict) else {}
|
|
facts: dict[str, str] = {}
|
|
for key in _FIELD_KEYS:
|
|
val = str(fields_in.get(key) or "").strip()
|
|
if val:
|
|
facts[key] = val
|
|
land = str(data.get("land_subtype") or "").strip()
|
|
return {
|
|
"ok": True,
|
|
"business_line": mode if mode in {"AIR", "SEA", "LAND"} else "",
|
|
"land_subtype": land,
|
|
"facts": facts,
|
|
"source": "deepseek_b",
|
|
}
|
|
|
|
|
|
def build_extract_messages(text: str) -> list[dict[str, str]]:
|
|
"""B 的系统/用户消息。只抽合同字段,不写销售可见话术。"""
|
|
return [
|
|
{
|
|
"role": "system",
|
|
"content": (
|
|
"你是询价字段抽取器。只根据销售原话抽取字段,调用 extract_inquiry_fields。"
|
|
"未出现的字段必须空串,禁止编造港口、重量、日期。"
|
|
"空运件数/毛重/体积/包装方式;海运货量与箱型箱量;陆运按二级类型。"
|
|
"海运时 15件/20吨/3箱 必须写入货量,不要只写到件数把货量留空。"
|
|
"原话已带单位必须抽取,不要因为没写「毛重:」就留空:"
|
|
"10件/3pcs→空运件数;海运同样数字+件也要写入货量;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;"
|
|
"托盘/散货/卡板/纸箱/木箱→包装方式(销售说什么记什么,散货也要抽)。"
|
|
"9月15日/9月15号/2026-09-15→报价日期,规范成 yyyy-MM-dd,未写年用今年。"
|
|
"陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。"
|
|
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
|
|
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
|
|
"SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。"
|
|
"换城市必须覆盖旧值,不要沿用上一句的港口。"
|
|
"运输方式仅在原话能判断时填 AIR/SEA/LAND,否则 UNKNOWN。"
|
|
),
|
|
},
|
|
{"role": "user", "content": text},
|
|
]
|
|
|
|
|
|
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
无网入口:有 assistant_json / tool_arguments 则解析;否则保持骨架错误码。
|
|
"""
|
|
raw = payload.get("assistant_json") or payload.get("tool_arguments")
|
|
if raw is not None:
|
|
parsed = parse_extract_payload(raw)
|
|
parsed["stub"] = True
|
|
parsed["mode"] = MODE.value
|
|
return parsed
|
|
return {
|
|
"ok": False,
|
|
"stub": True,
|
|
"mode": MODE.value,
|
|
"error": "extract_fields_shell_not_implemented",
|
|
"echo_keys": sorted(payload.keys()),
|
|
}
|
|
|
|
|
|
def invoke_extract_fields(
|
|
text: str,
|
|
*,
|
|
allow_network: bool = False,
|
|
chat_fn: Optional[Any] = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
调用 DeepSeek B 抽字段。
|
|
|
|
chat_fn:单测注入,签名同 LlmHttpClient.chat。
|
|
allow_network=False 且无 chat_fn:不调外网。
|
|
"""
|
|
if chat_fn is None and not allow_network:
|
|
return {"ok": False, "error": "b_network_disabled", "facts": {}, "business_line": ""}
|
|
from agent.llm.http_client import LlmHttpClient
|
|
from agent.llm.modes import LlmMode
|
|
|
|
client = LlmHttpClient.from_settings()
|
|
chat = chat_fn or client.chat
|
|
result = chat(
|
|
mode=LlmMode.EXTRACT_FIELDS,
|
|
messages=build_extract_messages(text),
|
|
tools=[extract_tool_definition()],
|
|
tool_choice={"type": "function", "function": {"name": EXTRACT_TOOL_NAME}},
|
|
)
|
|
if not getattr(result, "ok", False):
|
|
logger.info("extract.b 失败 err=%s", getattr(result, "error", ""))
|
|
return {
|
|
"ok": False,
|
|
"error": getattr(result, "error", "b_failed"),
|
|
"facts": {},
|
|
"business_line": "",
|
|
}
|
|
raw_args: Any = None
|
|
for call in result.tool_calls or []:
|
|
fn = call.get("function") or {}
|
|
if fn.get("name") == EXTRACT_TOOL_NAME:
|
|
raw_args = fn.get("arguments")
|
|
break
|
|
if raw_args is None and result.content:
|
|
raw_args = result.content
|
|
parsed = parse_extract_payload(raw_args)
|
|
logger.info(
|
|
"extract.b ok=%s mode=%s keys=%s",
|
|
parsed.get("ok"),
|
|
parsed.get("business_line") or "-",
|
|
sorted((parsed.get("facts") or {}).keys()),
|
|
)
|
|
return parsed
|