""" 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,未写年用今年。" "「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。" "换城市必须覆盖旧值,不要沿用上一句的港口。" "运输方式仅在原话能判断时填 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