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

223 lines
8.5 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.
"""
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。"
"未出现的字段必须空串,禁止编造港口、重量、日期。"
"空运件数/毛重/体积/包装方式;海运货量与箱型箱量;陆运按二级类型。"
"陆运整车/中港零担的车型写入车型/数量:45HQ(13.5厢车)、9米6/1、3T;"
"陆运不要把 45HQ/厢车 写成箱型箱量。"
"海运时 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,未写年用今年。"
"询价日期、走货日期、询价时间、走货时间、发货时间、发货日期、出货日期、出货时间、提货时间、提货日期一律写入报价日期。"
"货好时间、提货地址不要写成报价日期。"
"陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。"
"原话单独出现 DAP、DDP、CIF、CIP、FOB、EXW 时写入贸易条款;没出现留空,禁止编造。"
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
"陆运口语「广州到深圳」「广东省深圳市福田区到香港」→起运港=左侧地名、目的港=右侧地名;"
"不要把左侧写成提货地址,右侧目的地也不能丢。"
"「货交上海」「货交:上海」「货交到上海」才是提货地址=上海,不是起运港。"
"仅空运且只有「货交…」、没有另写起运地/三字码航线/「从哪飞」时,起运港必须空串;"
"陆运「A到B」必须写入起运港和目的港。"
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
"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