""" DeepSeek B:调价意图与抽项。 本文件职责:判断是不是调价、抽出费用/方向/比例或改到金额。 禁止:模型改价;禁止把某一句口令写成唯一识别;未确认数据用途不得出站。 调用:私聊/群 Worker 或 inbox 线程在已开网时;禁止回调线程同步跑。 """ from __future__ import annotations import json import logging from typing import Any, Optional from agent.llm.modes import LlmMode from agent.schema.quote_adjust import AdjustExtract, AdjustItem logger = logging.getLogger(__name__) EXTRACT_ADJUST_TOOL = "extract_quote_adjust" def extract_adjust_tool_definition() -> dict[str, Any]: """B 抽调价项的 tool。金额只原样抽出,不算新价。""" return { "type": "function", "function": { "name": EXTRACT_ADJUST_TOOL, "description": ( "Decide if the sales message is adjusting an existing quote " "and extract each fee change. Do not require a fixed phrase." ), "parameters": { "type": "object", "additionalProperties": False, "properties": { "is_adjust": {"type": "boolean"}, "wants_total": {"type": "boolean"}, "items": { "type": "array", "items": { "type": "object", "additionalProperties": False, "properties": { "fee_name": {"type": "string"}, "kind": {"type": "string", "enum": ["percent", "target"]}, "direction": {"type": "string", "enum": ["up", "down", "set"]}, "value": {"type": "number"}, }, "required": ["fee_name", "kind", "direction", "value"], }, }, }, "required": ["is_adjust", "wants_total", "items"], }, }, } def parse_adjust_payload(raw: Any) -> Optional[AdjustExtract]: """把模型 JSON / tool 参数收成 AdjustExtract。结构不对返回 None,不猜。""" data = raw if isinstance(raw, str): text = raw.strip() if text.startswith("```"): text = text.strip("`") if text.lower().startswith("json"): text = text[4:].strip() try: data = json.loads(text) except json.JSONDecodeError: return None if not isinstance(data, dict): return None if "is_adjust" not in data: return None items: list[AdjustItem] = [] for row in data.get("items") or []: if not isinstance(row, dict): continue kind = str(row.get("kind") or "percent").strip() direction = str(row.get("direction") or "up").strip() if kind not in {"percent", "target"}: continue if direction not in {"up", "down", "set"}: continue try: value = float(row.get("value")) except (TypeError, ValueError): continue items.append( AdjustItem( fee_name=str(row.get("fee_name") or "").strip(), kind=kind, direction=direction, value=value, ) ) return AdjustExtract( is_adjust=bool(data.get("is_adjust")), items=items, wants_total=bool(data.get("wants_total")), ) def build_adjust_messages(text: str, fee_names: list[str]) -> list[dict[str, str]]: """B 的系统/用户消息。口令不写死,只抽项不算价。""" names = "、".join(n for n in fee_names if n) or "(当前报价未列出费用名)" return [ { "role": "system", "content": ( "你是询价调价抽取器。只根据销售原话判断是不是在调已有报价,并抽出每一项。" "必须调用 extract_quote_adjust。不要对销售说话,不要改价。" "口令不写死:上升、上浮、往上抬、加一成、涨一点、改到某金额、在现有报价基础上调整都算调价。" "没点名费用、只说报价:is_adjust=true,wants_total=false,items 为空。" "只动总销售价/总成本价/合计/总价/汇总价:wants_total=true,items 仍要一条(fee_name 可空),带 kind/direction/value。" "kind 只能 percent 或 target;direction 只能 up/down/set。" "value 必须来自原话数字;一成=10。禁止编造费用名和金额。" "不是调价(问候、新询价、谢谢)时 is_adjust=false。" f"当前可调费用名:{names}" ), }, {"role": "user", "content": text}, ] def invoke_extract_adjust( text: str, *, fee_names: list[str] | None = None, allow_network: bool = False, chat_fn: Optional[Any] = None, ) -> Optional[AdjustExtract]: """ 调用 DeepSeek B 抽调价。失败返回 None,由调用方走本地兜底。 chat_fn:单测注入。allow_network=False 且无 chat_fn:不打外网。 """ if chat_fn is None and not allow_network: return None from agent.llm.http_client import LlmHttpClient client = LlmHttpClient.from_settings() chat = chat_fn or client.chat result = chat( mode=LlmMode.EXTRACT_FIELDS, messages=build_adjust_messages(text, list(fee_names or [])), tools=[extract_adjust_tool_definition()], tool_choice={"type": "function", "function": {"name": EXTRACT_ADJUST_TOOL}}, ) if not getattr(result, "ok", False): logger.info("extract.adjust 失败 err=%s", getattr(result, "error", "")) return None raw_args: Any = None for call in getattr(result, "tool_calls", None) or []: fn = (call or {}).get("function") or {} if fn.get("name") == EXTRACT_ADJUST_TOOL: raw_args = fn.get("arguments") break if raw_args is None and getattr(result, "content", ""): raw_args = result.content parsed = parse_adjust_payload(raw_args) logger.info( "extract.adjust ok=%s items=%s wants_total=%s", None if parsed is None else parsed.is_adjust, 0 if parsed is None else len(parsed.items), False if parsed is None else parsed.wants_total, ) return parsed