看板平均响应按协同分发统计;查价失败可从主账恢复;报价模板匹配与填表更稳,后台改名运营后台。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-05 18:00:08 +08:00
co-authored by Cursor
parent e95e8869a6
commit 58df358577
28 changed files with 1493 additions and 245 deletions
+18 -5
View File
@@ -118,15 +118,28 @@ class HttpLedger:
facts: dict[str, str],
business_line: str = "",
) -> dict[str, Any]:
"""
打主账查 TMS。查价可能慢于默认 10s:单独放宽,仍禁止无限等。
若仍超时,上层 query_tms_resilient 会回读主账:已 persistHit 有价则按成功续办。
"""
body: dict[str, Any] = {"work_order_no": work_order_no, "facts": facts}
line = (business_line or str((facts or {}).get("业务线") or "")).strip()
if line:
body["businessLine"] = line
out = self._post(
"/inquiry/agent/tms/query",
body,
idem=f"tms:{work_order_no}",
)
# TMS 出站 + persistHit 常超过默认 10s,超时会被误判成接口异常。
prev_timeout = self._client.timeout_seconds
self._client.timeout_seconds = max(float(prev_timeout or 10), 45.0)
self._client.close()
try:
out = self._post(
"/inquiry/agent/tms/query",
body,
idem=f"tms:{work_order_no}",
)
finally:
self._client.timeout_seconds = prev_timeout
self._client.close()
if "classification" not in out and not out.get("ok"):
return {
"ok": False,
+12 -6
View File
@@ -1212,6 +1212,7 @@ class AirTextInquiryFlow:
from agent.policy.system_exception import (
confirm_tms_quote_if_tech,
query_tms_resilient,
recover_tms_query_from_ledger,
)
tms = query_tms_resilient(self._ledger, work_order_no=no, facts=facts)
@@ -1238,12 +1239,17 @@ class AirTextInquiryFlow:
):
self.drop_current_bookmark(sender_id)
return "system_exception"
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
return "tms_tech"
# 主账已落价但回包超时:按成功续办,禁止再报接口异常
recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no)
if recovered is not None:
tms = recovered
else:
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
return "tms_tech"
if not tms.get("has_price"):
# ok=true 才是 TMS 已查过、确认没价
self._emit_tms_miss(reply, no)
+13 -6
View File
@@ -390,7 +390,10 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
_ = retrying
no = (sess.work_order_no or "").strip()
if not tms.get("ok"):
from agent.policy.system_exception import confirm_tms_quote_if_tech
from agent.policy.system_exception import (
confirm_tms_quote_if_tech,
recover_tms_query_from_ledger,
)
if confirm_tms_quote_if_tech(
ledger=self._ledger,
@@ -402,11 +405,15 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
):
self.drop_current_bookmark(sess.sender_id)
return "system_exception"
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
self._park_tms_retry(sess)
return "tms_tech"
recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no)
if recovered is not None:
tms = recovered
else:
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
self._park_tms_retry(sess)
return "tms_tech"
self._clear_parked_confirm(sess.sender_id)
if not tms.get("has_price"):
self._emit_tms_miss(reply, no)
+11 -6
View File
@@ -416,6 +416,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
from agent.policy.system_exception import (
confirm_tms_quote_if_tech,
query_tms_resilient,
recover_tms_query_from_ledger,
)
tms = query_tms_resilient(
@@ -443,12 +444,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
):
self.drop_current_bookmark(sender_id)
return "system_exception"
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
return "tms_tech"
recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no)
if recovered is not None:
tms = recovered
else:
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
return "tms_tech"
if not tms.get("has_price"):
self._emit_tms_miss(reply, no)
sess.phase = "tms_miss"
+98 -3
View File
@@ -310,8 +310,85 @@ def reply_paused_ticket(*, ticket: Any, reply: ReplyFn) -> None:
reply(copy.system_exception_fallback(kind, names=names))
def _quote_has_usable_total(quote: dict[str, Any] | None) -> bool:
"""报价包是否已有可用于出卡的总价。"""
src = dict(quote or {})
total = str(src.get("total") or src.get("salePrice") or src.get("total_amount") or "").strip()
return bool(total and total != "-")
def recover_tms_query_from_ledger(ledger: Any, *, work_order_no: str) -> dict[str, Any] | None:
"""
查价超时/失败后回读主账:若已 persistHit 落成已报价+销售价,按查价成功续办。
产品口径:主账已经查到价,只是智能体等回包超时,不能标 TMS异常、不能给销售发接口异常,
应直接推当前工单报价。无价才走真正的系统异常。
"""
no = (work_order_no or "").strip()
if not no or ledger is None:
return None
getter = getattr(ledger, "get_for_agent", None)
snap: Any = None
if callable(getter):
try:
snap = getter(work_order_no=no, sender_id="")
except TypeError:
try:
snap = getter(work_order_no=no)
except Exception: # noqa: BLE001
snap = None
except Exception: # noqa: BLE001
snap = None
if not isinstance(snap, dict) or not snap:
getter2 = getattr(ledger, "get_ticket", None)
if callable(getter2):
try:
snap = getter2(work_order_no=no)
except Exception: # noqa: BLE001
snap = None
if not isinstance(snap, dict) or not snap:
return None
status = str(snap.get("status") or "").strip()
tms_hit = snap.get("tms_hit") in (1, "1", True, "true")
sale = str(snap.get("sale_price") or snap.get("salePrice") or "").strip()
quote = dict(snap.get("quote") or {})
tms_quote = dict(snap.get("tms_quote") or snap.get("tmsQuote") or {})
if not _quote_has_usable_total(quote) and _quote_has_usable_total(tms_quote):
quote = dict(tms_quote)
if not _quote_has_usable_total(quote) and sale and sale != "-":
quote = dict(quote)
quote["total"] = sale
quote.setdefault("source", "TMS")
quote.setdefault("source_label", "TMS标准报价")
priced = status == "已报价" and (
tms_hit or (sale and sale != "-") or _quote_has_usable_total(quote)
)
if not priced:
return None
if not _quote_has_usable_total(quote):
return None
logger.warning(
"tms.query 超时/失败但主账已有价,按成功续办 wo=%s total=%s",
no,
quote.get("total"),
)
return {
"ok": True,
"has_price": True,
"classification": "HAS_PRICE",
"quote": quote,
"recovered": True,
"source": "LEDGER_AFTER_TIMEOUT",
}
def query_tms_resilient(ledger: Any, **kwargs: Any) -> dict[str, Any]:
"""TMS 查价:最多 3 次,ok=true(含无价)即成功。"""
"""
TMS 查价:最多 3 次,ok=true(含无价)即成功。
三次仍失败时,再读主账:若查价过程中已落库有价(常见于智能体超时、主账已 persistHit),
按成功回包返回,避免误标 TMS异常。
"""
getter = getattr(ledger, "query_tms", None)
if not callable(getter):
return {"ok": False, "classification": "TMS_TECH_FAILURE", "has_price": False, "quote": {}}
@@ -319,7 +396,14 @@ def query_tms_resilient(ledger: Any, **kwargs: Any) -> dict[str, Any]:
lambda: getter(**kwargs),
is_ok=lambda x: bool((x or {}).get("ok")),
)
return out if isinstance(out, dict) else {"ok": False, "classification": "TMS_TECH_FAILURE"}
result = out if isinstance(out, dict) else {"ok": False, "classification": "TMS_TECH_FAILURE"}
if result.get("ok"):
return result
no = str(kwargs.get("work_order_no") or "").strip()
recovered = recover_tms_query_from_ledger(ledger, work_order_no=no) if no else None
if recovered is not None:
return recovered
return result
def confirm_tms_quote_if_tech(
@@ -332,9 +416,20 @@ def confirm_tms_quote_if_tech(
ticket_status: str = "询价中",
facts: dict[str, Any] | None = None,
) -> bool:
"""技术失败则标 TMS异常并回话告警。返回是否已按系统异常处理。"""
"""
技术失败则标 TMS异常并回话告警。返回是否已按系统异常处理。
主账其实已有价时不再标异常(兜底:调用方应优先走 query_tms_resilient 恢复)。
"""
if not is_tms_tech_failure(tms):
return False
# 最后一道闸:主账其实已有价时禁止再打 TMS异常(正常应已在 query_tms_resilient 恢复)。
if recover_tms_query_from_ledger(ledger, work_order_no=work_order_no) is not None:
logger.warning(
"confirm_tms_quote_if_tech 跳过标异常:主账已有价 wo=%s",
work_order_no,
)
return False
err = str(tms.get("classification") or tms.get("error") or "TMS_TECH_FAILURE").strip()
confirm_system_exception(
event=SystemExceptionEvent(
+17 -3
View File
@@ -178,7 +178,8 @@ _GENERIC_KEYWORDS = frozenset(
_LAND_KEYWORD_ALIASES = {
"国内拼车": ("国内运输拼车", "拼车"),
"国内零担": ("国内运输拼车", "国内长途/零担", "零担"),
"国内整车": ("国内运输整车", "整车"),
# 「整车」不能当国内整车别名:跨境整车 / 中港整车都含这两个字。
"国内整车": ("国内运输整车",),
"跨境整车": ("跨境整车",),
"跨境集拼": ("跨境集拼",),
"跨境零担": ("跨境集拼",),
@@ -198,6 +199,19 @@ def _keyword_needles(kw: str) -> list[str]:
return out
def _needle_in(text: str, needle: str) -> bool:
"""
陆运针:普通包含。单独「整车」不命中跨境/中港,避免国内整车模板抢走跨境单。
"""
hay = (text or "").lower()
n = (needle or "").strip().lower()
if not hay or not n or n not in hay:
return False
if n == "整车" and ("跨境" in hay or "中港" in hay):
return False
return True
def _keyword_score(raw: Any, haystack: str, type_val: str) -> tuple[int, int]:
"""
运输类型上的命中优先,其次全文词长。
@@ -212,11 +226,11 @@ def _keyword_score(raw: Any, haystack: str, type_val: str) -> tuple[int, int]:
if k in _GENERIC_KEYWORDS:
continue
needles = _keyword_needles(kw)
if type_val and any(n in type_val for n in needles):
if type_val and any(_needle_in(type_val, n) for n in needles):
type_hits += 1
length += len(k)
any_hit = True
elif any(n in haystack for n in needles):
elif any(_needle_in(haystack, n) for n in needles):
length += len(k)
any_hit = True
if not any_hit:
@@ -143,6 +143,61 @@ class QuoteTemplateMatchTests(unittest.TestCase):
self.assertIsNotNone(hit)
self.assertEqual(hit.name, "跨境整车")
def test_land_admin_domestic_ftl_keyword_does_not_steal_cross(self) -> None:
"""后台「国内整车」不得靠「整车」二字抢走跨境整车(WO202610030032)。"""
hit = pick_quote_template(
templates=[
{
"templateId": "dom",
"name": "国内陆运整车报价单",
"bizType": "陆运",
"keywords": ["国内整车"],
"isDefault": False,
},
{
"templateId": "xb",
"name": "跨境整车报价单",
"bizType": "陆运",
"keywords": ["东南亚"],
"isDefault": False,
},
],
business_line="LAND",
facts={
"运输类型": "跨境整车",
"线路类别": "东南亚",
"运输分类": "普件(东南亚、中亚、中欧涉及国内+国外段)",
"起运港": "上海",
"目的港": "春疆货场",
},
)
self.assertIsNotNone(hit)
self.assertEqual(hit.name, "跨境整车报价单")
def test_land_admin_domestic_ftl_still_hits_domestic_type(self) -> None:
hit = pick_quote_template(
templates=[
{
"templateId": "dom",
"name": "国内陆运整车报价单",
"bizType": "陆运",
"keywords": ["国内整车"],
"isDefault": False,
},
{
"templateId": "xb",
"name": "跨境整车报价单",
"bizType": "陆运",
"keywords": ["东南亚"],
"isDefault": False,
},
],
business_line="LAND",
facts={"运输类型": "国内运输整车", "线路类别": "国内长途/零担"},
)
self.assertIsNotNone(hit)
self.assertEqual(hit.name, "国内陆运整车报价单")
def test_land_falls_back_to_default(self) -> None:
hit = pick_quote_template(
templates=[
@@ -22,8 +22,11 @@ from agent.policy.system_exception import (
SystemExceptionEvent,
call_with_retries,
confirm_system_exception,
confirm_tms_quote_if_tech,
extract_work_order_nos,
is_tms_tech_failure,
query_tms_resilient,
recover_tms_query_from_ledger,
should_mark_ticket,
ticket_is_paused,
)
@@ -196,6 +199,89 @@ class TestSystemExceptionRules(unittest.TestCase):
self.assertEqual(out.get("classification"), "TMS_TECH_FAILURE")
class TestTmsTimeoutRecoverPriced(unittest.TestCase):
"""查到价但智能体超时:主账已有价则按成功续办,不标 TMS异常。"""
def setUp(self) -> None:
self.ledger = MemoryLedger()
created = self.ledger.create_ticket(
sender_id="sales1",
business_line="AIR",
facts={"起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶"},
)
self.wo = str(created.get("work_order_no") or "")
self.ledger.upsert_quote(
work_order_no=self.wo,
quote={
"total": "CNY 5001.00",
"currency": "CNY",
"source": "TMS",
"source_label": "TMS标准报价",
},
to_status="已报价",
)
def test_recover_from_ledger_when_already_quoted(self) -> None:
got = recover_tms_query_from_ledger(self.ledger, work_order_no=self.wo)
self.assertIsNotNone(got)
assert got is not None
self.assertTrue(got.get("ok"))
self.assertTrue(got.get("has_price"))
self.assertTrue(got.get("recovered"))
self.assertIn("5001", str((got.get("quote") or {}).get("total") or ""))
def test_query_resilient_recovers_after_tech_fail(self) -> None:
self.ledger.query_tms = ( # type: ignore[method-assign]
lambda **_kwargs: {
"ok": False,
"classification": "TMS_TECH_FAILURE",
"has_price": False,
"quote": {},
}
)
out = query_tms_resilient(
self.ledger, work_order_no=self.wo, facts={"起运港": "南京", "目的港": "吉隆坡"}
)
self.assertTrue(out.get("ok"))
self.assertTrue(out.get("has_price"))
self.assertTrue(out.get("recovered"))
self.assertEqual(out.get("classification"), "HAS_PRICE")
def test_confirm_skips_mark_when_ledger_priced(self) -> None:
replies: list[str] = []
marked = confirm_tms_quote_if_tech(
ledger=self.ledger,
reply=replies.append,
work_order_no=self.wo,
tms={"ok": False, "classification": "TMS_TECH_FAILURE"},
sender_id="sales1",
ticket_status="询价中",
)
self.assertFalse(marked)
self.assertEqual(replies, [])
snap = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertFalse(str(snap.get("system_exception") or "").strip())
def test_true_tech_fail_still_marks_when_no_price(self) -> None:
created = self.ledger.create_ticket(
sender_id="sales2",
business_line="AIR",
facts={"起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶"},
)
wo2 = str(created.get("work_order_no") or "")
replies: list[str] = []
marked = confirm_tms_quote_if_tech(
ledger=self.ledger,
reply=replies.append,
work_order_no=wo2,
tms={"ok": False, "classification": "TMS_TECH_FAILURE"},
sender_id="sales2",
ticket_status="询价中",
)
self.assertTrue(marked)
self.assertTrue(any("TMS接口异常" in x for x in replies))
class TestConfirmSystemException(unittest.TestCase):
def setUp(self) -> None:
self.ledger = MemoryLedger()