171 lines
6.5 KiB
Python
171 lines
6.5 KiB
Python
"""
|
||
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
|