Files
inquiry_robot/inquiry-agent/agent/ledger/memory_ledger.py
T
jillion886andCursor 99da1faf30 落地私聊空运询价与TMS报价回填,按后台模板出单。
查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 20:25:35 +08:00

278 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
主账内存假账本(单测 / 无 8180 时)。
本文件职责:实现建单(每次新开)、相似检索、TMS fixture、报价写入、六态流转,语义对齐 Java 主账。
禁止:冒充已打真实 TMS;禁止被生产 Worker 当默认 Owner(生产必须 HTTP 主账)。
"""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Optional
from zoneinfo import ZoneInfo
from agent.schema.fingerprint import inquiry_fingerprint
logger = logging.getLogger(__name__)
_SHANGHAI = ZoneInfo("Asia/Shanghai")
@dataclass
class MemoryTicket:
"""内存工单。"""
work_order_no: str
sales_wecom_id: str
business_line: str
facts: dict[str, str]
fingerprint: str
status: str = "询价中"
quote: dict[str, Any] = field(default_factory=dict)
quote_version: int = 0
class MemoryLedger:
"""
线程安全内存主账。
TMS fixture:facts.tms_force=1002 或目的港含「无价」→ 无价;否则有价。
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._tickets: dict[str, MemoryTicket] = {}
self._seq = 0
# 单测默认同业务有一张默认模板,避免「生成 Excel」误走失败话术
self._templates: list[dict[str, Any]] = [
{
"templateId": "mem-default-air",
"name": "空运默认",
"bizType": "空运",
"keywords": [],
"isDefault": True,
}
]
self.last_render: dict[str, Any] = {}
def _next_no(self) -> str:
self._seq += 1
day = datetime.now(_SHANGHAI).strftime("%Y%m%d")
return f"WO{day}{self._seq:04d}"
def find_similar_open(
self,
*,
facts: dict[str, str],
business_line: str,
exclude_work_order_no: str = "",
) -> dict[str, Any]:
"""
全库相同需求检索:需求指纹一模一样才算,不限六态。
多条只回最新一条;exclude_work_order_no 排除刚建的本单。
只供卡片展示,不复用当前工单号。
"""
fp = inquiry_fingerprint(facts, business_line=business_line)
skip = (exclude_work_order_no or "").strip()
latest: MemoryTicket | None = None
with self._lock:
for t in self._tickets.values():
if t.fingerprint != fp:
continue
if skip and t.work_order_no == skip:
continue
latest = t
if latest is None:
return {"found": False, "first_or_same": "first", "work_order_no": "", "status": ""}
return {
"found": True,
"first_or_same": "same",
"work_order_no": latest.work_order_no,
"status": latest.status,
}
def create_ticket(
self,
*,
sender_id: str,
business_line: str,
facts: dict[str, str],
idempotency_key: str = "",
) -> dict[str, Any]:
_ = idempotency_key
# 出询价确认必须新开一单,相同需求不复用未关闭工单
fp = inquiry_fingerprint(facts, business_line=business_line)
with self._lock:
no = self._next_no()
ticket = MemoryTicket(
work_order_no=no,
sales_wecom_id=sender_id,
business_line=business_line,
facts=dict(facts),
fingerprint=fp,
)
self._tickets[no] = ticket
similar = self.find_similar_open(
facts=facts, business_line=business_line, exclude_work_order_no=no
)
logger.info(
"memory_ledger.create %s first_or_same=%s similar=%s",
no,
similar.get("first_or_same"),
similar.get("work_order_no"),
)
return {
"ok": True,
"created": True,
"first_or_same": similar.get("first_or_same") or "first",
"found": bool(similar.get("found")),
"similar_work_order_no": similar.get("work_order_no") or "",
"work_order_no": no,
"status": "询价中",
}
def query_tms(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]:
dest = str(facts.get("目的港") or "")
force = str(facts.get("tms_force") or "")
if force == "1002" or force == "1003" or "无价" in dest or "歧义" in dest:
return {
"ok": True,
"classification": "TMS_NO_QUOTE_1002",
"has_price": False,
"quote": {},
}
if force == "tech":
return {"ok": False, "classification": "TMS_TECH_FAILURE", "has_price": False, "quote": {}}
quote = {
"freight": "USD 1,200",
"other_fee": "USD 80",
"total": "USD 1,280",
"eta": "3-5 天",
"valid_until": "2026-09-20",
"source": "TMS",
"source_label": "TMS 标准报价",
"fee_lines": [
{"name": "空运费", "amount": "USD 1,200"},
{"name": "其他附加费", "amount": "USD 80"},
],
}
return {
"ok": True,
"classification": "HAS_PRICE",
"has_price": True,
"quote": quote,
}
def upsert_quote(
self,
*,
work_order_no: str,
quote: dict[str, Any],
to_status: str = "",
) -> dict[str, Any]:
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.quote_version += 1
ticket.quote = dict(quote)
if to_status:
ticket.status = to_status
return {
"ok": True,
"quote_version": ticket.quote_version,
"status": ticket.status,
}
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.status = to_status
return {"ok": True, "status": ticket.status}
def get_ticket(self, *, work_order_no: str) -> MemoryTicket | None:
with self._lock:
return self._tickets.get(work_order_no)
def set_quote_templates(self, templates: list[dict[str, Any]]) -> None:
"""单测注入后台模板列表;空列表=未维护关键词也未维护默认。"""
with self._lock:
self._templates = list(templates)
def render_quote(
self,
*,
work_order_no: str,
business_line: str,
facts: dict[str, Any],
quote: Optional[dict[str, Any]] = None,
format: str = "xlsx",
sales_wecom_id: str = "",
) -> dict[str, Any]:
"""
按询价需求匹配内存模板。不填真实 xlsx,只回假字节供流程/单测。
"""
from agent.quote_templates.match import FAIL_MESSAGE, pick_quote_template
_ = sales_wecom_id
with self._lock:
templates = list(self._templates)
picked = pick_quote_template(
templates=templates,
business_line=business_line,
facts=facts,
quote=quote,
)
if picked is None:
out = {"ok": False, "error": "NO_TEMPLATE", "message": FAIL_MESSAGE}
self.last_render = out
return out
ext = "pdf" if str(format).lower() == "pdf" else "xlsx"
out = {
"ok": True,
"matchType": picked.match_type,
"templateId": picked.template_id,
"templateName": picked.name,
"fileName": f"{work_order_no}_quote.{ext}",
"fileBase64": "UEsDBAoAAAAAA",
"needsPdf": False,
}
self.last_render = out
return out
def get_for_agent(self, *, work_order_no: str, sender_id: str) -> dict[str, Any]:
"""
智能体续办读取:工单是否存在、是否本人、字段/报价/推断停点。
"""
no = (work_order_no or "").strip()
ticket = self.get_ticket(work_order_no=no)
if ticket is None:
return {"ok": False, "found": False, "owned": False, "work_order_no": no}
owned = ticket.sales_wecom_id == sender_id
status = ticket.status
quote = dict(ticket.quote or {})
wait_phase = "done"
if status in {"已成交", "未成交", "协商中", "已关闭"}:
wait_phase = "done"
elif status == "已报价" and quote:
wait_phase = "wait_adopt"
else:
wait_phase = "tms_miss"
return {
"ok": True,
"found": True,
"owned": owned,
"work_order_no": ticket.work_order_no,
"status": status,
"business_line": ticket.business_line,
"facts": dict(ticket.facts),
"quote": quote,
"wait_phase": wait_phase,
}