Files
inquiry_robot/inquiry-agent/agent/ledger/memory_ledger.py
T

730 lines
28 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
collab_chat_id: str = ""
collab_facts: dict[str, str] = field(default_factory=dict)
collab_member_ids: list[str] = field(default_factory=list)
product_ids: list[str] = field(default_factory=list)
product_names: list[str] = field(default_factory=list)
collab_ended: bool = False
attachments: list[dict[str, Any]] = field(default_factory=list)
quote_file_ready: bool = False
lost_reason: str = ""
next_followup_at: str = ""
deal_notify_summary: str = ""
system_exception: str = ""
system_exception_reason: str = ""
system_exception_step: str = ""
system_exception_payload: dict[str, Any] = field(default_factory=dict)
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] = {}
self._sea_staff: list[dict[str, Any]] = []
self._land_staff: list[dict[str, Any]] = []
self._staff_by_wecom: dict[str, dict[str, Any]] = {}
self._staff_by_name: dict[str, dict[str, Any]] = {}
self._exception_notify_users: list[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],
business_line: str = "",
) -> dict[str, Any]:
_ = business_line
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,
remark: str = "",
lost_reason: str = "",
next_followup_at: str = "",
deal_notify_summary: str = "",
) -> dict[str, Any]:
_ = remark
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
if to_status == "未成交":
ticket.next_followup_at = ""
if lost_reason:
ticket.lost_reason = lost_reason
elif to_status == "已成交":
ticket.next_followup_at = ""
if deal_notify_summary:
ticket.deal_notify_summary = deal_notify_summary
elif to_status == "协商中":
ticket.next_followup_at = next_followup_at
return {"ok": True, "status": ticket.status}
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
reason = (lost_reason or "").strip()
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
if ticket.status != "未成交":
return {"ok": False, "error": "not_lost", "status": ticket.status}
if not reason:
return {"ok": False, "error": "reason_blank"}
ticket.lost_reason = reason
return {"ok": True, "work_order_no": work_order_no, "lost_reason": reason}
def claim_due_negotiate(self) -> list[dict[str, Any]]:
now = datetime.now(_SHANGHAI).strftime("%Y-%m-%d %H:%M:%S")
hits: list[dict[str, Any]] = []
with self._lock:
for ticket in self._tickets.values():
due = (ticket.next_followup_at or "").strip()
if ticket.status != "协商中" or not due or due > now:
continue
ticket.next_followup_at = ""
hits.append(
{
"work_order_no": ticket.work_order_no,
"sales_wecom_id": ticket.sales_wecom_id,
"sales_name": "",
"business_line": ticket.business_line,
"collab_chat_id": ticket.collab_chat_id,
"status": ticket.status,
"facts": dict(ticket.facts),
"quote": dict(ticket.quote),
}
)
return hits
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, "facts": dict(facts or {})}
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,
"facts": dict(facts or {}),
}
with self._lock:
ticket = self._tickets.get((work_order_no or "").strip())
if ticket is not None:
ticket.quote_file_ready = True
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 == "未成交":
wait_phase = "wait_lost_reason" if not (ticket.lost_reason or "").strip() else "done"
elif status == "协商中":
wait_phase = "wait_deal"
elif status == "已报价" and quote:
# 文件已发给销售 → 成交跟进;否则海运协同卡、空运方案卡
if ticket.quote_file_ready or quote.get("quoteFileIssued"):
wait_phase = "wait_deal"
elif (ticket.business_line or "").upper() == "SEA":
wait_phase = "wait_collab"
else:
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,
"collab_chat_id": ticket.collab_chat_id,
"collab_facts": dict(ticket.collab_facts),
"lost_reason": ticket.lost_reason,
"lostReason": ticket.lost_reason,
"deal_notify_summary": ticket.deal_notify_summary,
"next_followup_at": ticket.next_followup_at,
"system_exception": ticket.system_exception,
"systemException": ticket.system_exception,
"system_exception_reason": ticket.system_exception_reason,
"systemExceptionReason": ticket.system_exception_reason,
"system_exception_step": ticket.system_exception_step,
"system_exception_payload": dict(ticket.system_exception_payload),
}
def upsert_exception_notify_user(self, row: dict[str, Any]) -> None:
"""单测注入「系统异常消息通知」接收人(后台账号已绑企微)。"""
data = dict(row)
uid = str(data.get("wecomId") or data.get("wecom_id") or "").strip()
if not uid:
return
data["wecomId"] = uid
with self._lock:
self._exception_notify_users = [
x for x in self._exception_notify_users
if str(x.get("wecomId") or "") != uid
]
self._exception_notify_users.append(data)
def list_exception_notify_users(self) -> list[dict[str, Any]]:
"""角色勾了系统异常消息通知、且账号已绑企微的人。"""
with self._lock:
return [dict(x) for x in self._exception_notify_users]
def mark_system_exception(
self,
*,
work_order_no: str,
system_exception: str,
system_exception_reason: str,
step: str = "",
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""写入工单两列与重试上下文;已关闭拒绝。不改六态。"""
no = (work_order_no or "").strip()
with self._lock:
ticket = self._tickets.get(no)
if ticket is None:
return {"ok": False, "error": "not_found"}
if ticket.status == "已关闭":
return {"ok": False, "error": "closed"}
ticket.system_exception = (system_exception or "").strip()
ticket.system_exception_reason = (system_exception_reason or "").strip()
ticket.system_exception_step = (step or "").strip()
ticket.system_exception_payload = dict(payload or {})
return {"ok": True, "status": ticket.status}
def clear_system_exception(self, *, work_order_no: str) -> dict[str, Any]:
"""重试成功后清两列。"""
no = (work_order_no or "").strip()
with self._lock:
ticket = self._tickets.get(no)
if ticket is None:
return {"ok": False, "error": "not_found"}
ticket.system_exception = ""
ticket.system_exception_reason = ""
ticket.system_exception_step = ""
ticket.system_exception_payload = {}
return {"ok": True}
def upsert_staff(self, row: dict[str, Any]) -> None:
"""单测注入任意岗位(含会话存档账号「询价机器人」)。"""
data = dict(row)
uid = str(data.get("wecomId") or data.get("wecom_id") or "").strip()
name = str(data.get("name") or "").strip()
with self._lock:
if uid:
self._staff_by_wecom[uid] = data
if name:
self._staff_by_name[name] = data
def set_sea_staff(self, rows: list[dict[str, Any]]) -> None:
"""单测注入海运产品岗。"""
with self._lock:
self._sea_staff = [dict(x) for x in rows]
for row in self._sea_staff:
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
name = str(row.get("name") or "").strip()
if uid:
self._staff_by_wecom[uid] = dict(row)
if name:
self._staff_by_name[name] = dict(row)
def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]:
"""
岗位=产品·海运、启用:写了「海运·全部线路」必拉;
写了具体港口的,对得上本单起运港/目的港才拉。
"""
origin = (origin or "").strip()
dest = (destination or "").strip()
hits: list[dict[str, Any]] = []
with self._lock:
for row in self._sea_staff:
if str(row.get("roleCode") or row.get("role_code") or "") != "sea":
continue
if str(row.get("status") or "active") != "active":
continue
routes = str(row.get("routes") or "")
if "海运·全部线路" in routes or "海运全线" in routes:
hits.append(dict(row))
continue
if origin and origin in routes:
hits.append(dict(row))
continue
if dest and dest in routes:
hits.append(dict(row))
return hits
def set_land_staff(self, rows: list[dict[str, Any]]) -> None:
"""单测注入陆运产品岗。"""
with self._lock:
self._land_staff = [dict(x) for x in rows]
for row in self._land_staff:
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
name = str(row.get("name") or "").strip()
if uid:
self._staff_by_wecom[uid] = dict(row)
if name:
self._staff_by_name[name] = dict(row)
def match_land_staff(self, *, route_category: str) -> list[dict[str, Any]]:
"""
岗位=产品·陆运、启用:负责线路整词含本单线路类别才拉。
不按斜杠切开「中港/中亚/中欧」。
"""
want = (route_category or "").strip()
hits: list[dict[str, Any]] = []
if not want:
return hits
with self._lock:
for row in self._land_staff:
if str(row.get("roleCode") or row.get("role_code") or "") != "land":
continue
if str(row.get("status") or "active") != "active":
continue
tokens = [
p.strip()
for p in str(row.get("routes") or "").replace(",", ",").replace("、", ",").split(",")
if p.strip()
]
if want in tokens:
hits.append(dict(row))
return hits
def get_staff_by_wecom_id(self, *, wecom_id: str) -> dict[str, Any]:
"""按企微 userid 取员工展示名。"""
with self._lock:
return dict(self._staff_by_wecom.get((wecom_id or "").strip()) or {})
def list_staff_by_role(self, *, role_code: str) -> list[dict[str, Any]]:
"""按岗位列出启用员工。空运群 @ 航线用。"""
want = (role_code or "").strip().lower()
hits: list[dict[str, Any]] = []
with self._lock:
for row in self._staff_by_wecom.values():
code = str(row.get("roleCode") or row.get("role_code") or "").strip().lower()
status = str(row.get("status") or "active").strip().lower()
if code == want and status not in {"disabled", "0", "停用"}:
hits.append(dict(row))
return hits
def find_staff_by_name(self, *, name: str) -> dict[str, Any]:
"""按员工姓名取企微 userid。"""
with self._lock:
return dict(self._staff_by_name.get((name or "").strip()) or {})
def bind_collab_group(
self,
*,
work_order_no: str,
chat_id: str,
member_ids: list[str],
product_names: list[str] | None = None,
product_ids: list[str] | None = None,
) -> 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.collab_chat_id = (chat_id or "").strip()
ticket.collab_member_ids = [str(x) for x in member_ids]
ticket.product_ids = [str(x) for x in (product_ids or [])]
ticket.product_names = [str(x).strip() for x in (product_names or []) if str(x).strip()]
return {"ok": True, "chat_id": ticket.collab_chat_id}
def end_collab_group(self, *, work_order_no: 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.collab_ended = True
ticket.collab_facts["__ended"] = "1"
return {"ok": True, "collab_ended": True}
def attach_file(
self,
*,
work_order_no: str,
file_name: str,
object_key: str = "",
content_type: str = "",
sender_id: 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"}
row = {
"file_name": file_name,
"object_key": object_key or file_name,
"content_type": content_type,
"sender_id": sender_id,
}
ticket.attachments.append(row)
return {"ok": True, "file_name": file_name}
def upsert_product_quote(
self,
*,
work_order_no: str,
quote: dict[str, Any],
to_status: str = "已报价",
remark: str = "",
kind: str = "",
) -> dict[str, Any]:
"""手工报价 / 销售调价盖 TMS 价。remark/kind 只给 HTTP 主账记流水。"""
_ = remark, kind
return self.upsert_quote(
work_order_no=work_order_no, quote=quote, to_status=to_status
)
def patch_collab_facts(
self,
*,
work_order_no: str,
facts: dict[str, str],
sender_id: str = "",
) -> dict[str, Any]:
"""合并协同字段。不改六态、不写报价版本。"""
_ = sender_id
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
for key, raw in (facts or {}).items():
name = str(key or "").strip()
if not name:
continue
val = str(raw or "").strip()
if val:
ticket.collab_facts[name] = val
else:
ticket.collab_facts.pop(name, None)
return {"ok": True, "facts": dict(ticket.collab_facts)}
def find_by_collab_chat(self, *, chat_id: str) -> MemoryTicket | None:
"""用工微群 chat_id 找回工单。"""
raw = (chat_id or "").strip()
if not raw:
return None
with self._lock:
for ticket in self._tickets.values():
if ticket.collab_chat_id == raw:
return ticket
return None
def activate_air_group(self, *, chat_id: str, work_order_no: str) -> dict[str, Any]:
"""
空运群当前工单:一单一群,未结案才能激活。
不改六态。
"""
no = (work_order_no or "").strip()
room = (chat_id or "").strip()
with self._lock:
ticket = self._tickets.get(no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
if (ticket.business_line or "").upper() != "AIR":
return {"ok": False, "error": "not_air", "status": ticket.status}
if ticket.status in {"已成交", "未成交", "已关闭"}:
return {"ok": False, "error": "terminal", "status": ticket.status}
if ticket.collab_chat_id and ticket.collab_chat_id != room:
return {"ok": False, "error": "occupied", "other_chat_id": ticket.collab_chat_id}
for other in self._tickets.values():
if other.work_order_no != no and other.collab_chat_id == room:
if (other.business_line or "").upper() == "AIR":
other.collab_chat_id = ""
ticket.collab_chat_id = room
return {
"ok": True,
"work_order_no": no,
"chat_id": room,
"status": ticket.status,
"business_line": ticket.business_line,
}
def lock_cabin(
self,
*,
work_order_no: str,
option_no: str,
sender_id: str = "",
operator_name: str = "",
instruction_text: str = "",
) -> dict[str, Any]:
"""单测可注入 _cabin_lock_result;默认成功。"""
_ = sender_id, operator_name, instruction_text
forced = getattr(self, "_cabin_lock_result", None)
if isinstance(forced, dict):
return dict(forced)
no = (work_order_no or "").strip()
ticket = self.get_ticket(work_order_no=no)
quote = dict(ticket.quote or {}) if ticket else {}
return {
"ok": True,
"status": "LOCKED",
"lockId": f"CA-{no}-LOCK",
"quoteId": str(quote.get("quoteId") or f"QT-{no}"),
"optionNo": option_no,
"tmsRequestId": f"TMS-LOCK-{no}",
}
def release_cabin(
self,
*,
work_order_no: str,
lock_id: str,
sender_id: str = "",
operator_name: str = "",
instruction_text: str = "",
) -> dict[str, Any]:
_ = lock_id, sender_id, operator_name, instruction_text
forced = getattr(self, "_cabin_release_result", None)
if isinstance(forced, dict):
return dict(forced)
no = (work_order_no or "").strip()
return {
"ok": True,
"status": "RELEASED",
"releaseId": f"CA-{no}-RLSE",
"tmsRequestId": f"TMS-RLSE-{no}",
}