落地私聊空运询价与TMS报价回填,按后台模板出单。
查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
文字询价字段抽取入口。
|
||||
|
||||
本文件职责:生产走 DeepSeek B;单测可注入 facts;无网时只认「键:值」结构化行,不猜港口。
|
||||
禁止:用正则把「上海到洛杉矶」拆成起运目的;禁止改六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MODE_WORDS = (
|
||||
("空运", "AIR"),
|
||||
("海运", "SEA"),
|
||||
("陆运", "LAND"),
|
||||
)
|
||||
|
||||
# 仅「键:值」一行一条,键必须是合同字段或展示名
|
||||
_LABEL_LINE = re.compile(
|
||||
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|整柜或拼柜|箱型箱量|报价日期|车型数量)\s*[::]\s*(.+)$"
|
||||
)
|
||||
_LABEL_INLINE = re.compile(
|
||||
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|整柜或拼柜|箱型箱量|报价日期|车型数量)\s*[::]\s*(\S+)"
|
||||
)
|
||||
|
||||
|
||||
def detect_transport_mode(text: str) -> str:
|
||||
"""
|
||||
运输方式:空运/海运/陆运,或空运口语「飞」(南京飞吉隆坡)。
|
||||
|
||||
写了海运/陆运以书面为准。只说飞就是空运,补问件数时不得再问运输方式。
|
||||
"""
|
||||
raw = text or ""
|
||||
found = [code for word, code in _MODE_WORDS if word in raw]
|
||||
if len(found) == 1:
|
||||
return found[0]
|
||||
if len(found) > 1:
|
||||
return ""
|
||||
if "飞" in raw:
|
||||
return "AIR"
|
||||
return ""
|
||||
|
||||
|
||||
def parse_labeled_facts(text: str) -> dict[str, str]:
|
||||
"""只采集「字段名:值」,整行优先;同一句里的标签也收。不猜港口。"""
|
||||
facts: dict[str, str] = {}
|
||||
raw = text or ""
|
||||
for line in raw.splitlines():
|
||||
m = _LABEL_LINE.match(line.strip())
|
||||
if m:
|
||||
facts[m.group(1)] = m.group(2).strip()
|
||||
if facts:
|
||||
return facts
|
||||
for m in _LABEL_INLINE.finditer(raw):
|
||||
facts[m.group(1)] = m.group(2).strip()
|
||||
return facts
|
||||
|
||||
|
||||
def extract_inquiry_snapshot(
|
||||
text: str,
|
||||
*,
|
||||
injected_facts: Optional[dict[str, Any]] = None,
|
||||
injected_mode: str = "",
|
||||
b_result: Optional[dict[str, Any]] = None,
|
||||
allow_b: bool = False,
|
||||
chat_fn: Optional[Any] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
抽出业务线 + 字段快照。
|
||||
|
||||
优先 injected_*(单测);其次合并标签行与 DeepSeek B(口语)。
|
||||
标签行覆盖 B 的同名字段。B 未出现的字段保持空,不本地猜港口。
|
||||
件数/毛重/体积若原话已带 TMS 单位(件/KGS/CBM),空字段可机读补上。
|
||||
"""
|
||||
from agent.schema.field_validate import harvest_oral_measures, normalize_facts
|
||||
|
||||
if injected_facts is not None:
|
||||
mode = (injected_mode or detect_transport_mode(text) or "").upper()
|
||||
return {
|
||||
"business_line": mode,
|
||||
"land_subtype": "",
|
||||
"facts": harvest_oral_measures(text, normalize_facts(dict(injected_facts))),
|
||||
"source": "injected",
|
||||
}
|
||||
mode = detect_transport_mode(text)
|
||||
facts = parse_labeled_facts(text)
|
||||
source = "labeled_or_mode"
|
||||
b_payload = b_result
|
||||
if b_payload is None and (allow_b or chat_fn is not None):
|
||||
from agent.llm.mode_extract_fields import invoke_extract_fields
|
||||
|
||||
b_payload = invoke_extract_fields(
|
||||
text, allow_network=allow_b, chat_fn=chat_fn
|
||||
)
|
||||
if b_payload and b_payload.get("ok"):
|
||||
merged = dict(b_payload.get("facts") or {})
|
||||
merged.update(facts)
|
||||
facts = merged
|
||||
mode = mode or str(b_payload.get("business_line") or "")
|
||||
source = "deepseek_b"
|
||||
facts = harvest_oral_measures(text, normalize_facts(facts))
|
||||
if not mode and facts.get("起运港") and facts.get("目的港") and "飞" in (text or ""):
|
||||
mode = "AIR"
|
||||
logger.info("extract.snapshot mode=%s keys=%s src=%s", mode or "-", sorted(facts), source)
|
||||
return {
|
||||
"business_line": mode,
|
||||
"land_subtype": (b_payload or {}).get("land_subtype") or "",
|
||||
"facts": facts,
|
||||
"source": source,
|
||||
}
|
||||
@@ -119,15 +119,22 @@ class LlmHttpClient:
|
||||
body["tools"] = tools
|
||||
if tool_choice is not None:
|
||||
body["tool_choice"] = tool_choice
|
||||
# deepseek-v4-flash 思考模式拒绝 named/required tool_choice,抽字段会 400
|
||||
if provider == "deepseek":
|
||||
body["thinking"] = {"type": "disabled"}
|
||||
try:
|
||||
with httpx.Client(timeout=self.timeout_seconds) as client:
|
||||
resp = client.post(url, headers=headers, json=body)
|
||||
data = resp.json() if resp.content else {}
|
||||
if resp.status_code >= 400:
|
||||
err_msg = ""
|
||||
if isinstance(data, dict) and isinstance(data.get("error"), dict):
|
||||
err_msg = str(data["error"].get("message") or "")[:200]
|
||||
logger.warning(
|
||||
"llm.http 失败 provider=%s status=%s",
|
||||
"llm.http 失败 provider=%s status=%s err=%s",
|
||||
provider,
|
||||
resp.status_code,
|
||||
err_msg,
|
||||
)
|
||||
return LlmHttpResult(
|
||||
ok=False,
|
||||
|
||||
@@ -1,31 +1,149 @@
|
||||
"""
|
||||
DeepSeek B:文本字段抽取 mode 壳。
|
||||
DeepSeek B:文字询价字段抽取。
|
||||
|
||||
本文件职责:声明 mode 元数据与空 invoke_shell;不写字段合同激活规则。
|
||||
禁止:在本文件改主账六态。
|
||||
本文件职责:定义抽取 tool、解析模型 JSON、可选真网调用。
|
||||
禁止:本地正则猜港口/箱型;禁止改六态;未确认数据用途不得出站。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
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 的框架元数据(无 prompt 正文业务)。"""
|
||||
"""返回本 mode 元数据。"""
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "deepseek",
|
||||
"purpose": "text_field_extract",
|
||||
"implemented": False,
|
||||
"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。"
|
||||
"未出现的字段必须空串,禁止编造港口、重量、日期。"
|
||||
"空运件数/毛重/体积/包装方式;海运货量与箱型箱量;陆运按二级类型。"
|
||||
"原话已带单位必须抽取,不要因为没写「毛重:」就留空:"
|
||||
"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,
|
||||
@@ -33,3 +151,54 @@ def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user