落地销售调价:按自然语言改价并单独记销售价,拉群和后台不再把调后价当成 TMS 原价。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 14:40:20 +08:00
co-authored by Cursor
parent e4743541f7
commit 26dd5d72a1
36 changed files with 2795 additions and 176 deletions
@@ -0,0 +1,170 @@
"""
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