调价后的新价格写入报价单;多段段前的货物带到各段,协同群不再把体积里的星号吃掉。海运字段齐了先核对再查价。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 22:39:54 +08:00
co-authored by Cursor
parent 0a14b4cc15
commit 7c77e45e03
32 changed files with 1384 additions and 75 deletions
+119 -4
View File
@@ -1,7 +1,7 @@
"""
海运文字询价确定性流程。
本文件职责:海运七项补问 → 建单出确认卡 → 主账查 TMS → 有价/无价卡 → 跳过协同或拉群。
本文件职责:海运补问 → 无工单号核对卡 → 回复确定后建单查 TMS → 有价/无价卡 → 跳过协同或拉群。
空运 Owner 仍在 air_text_flow,禁止把海运再堆回去。
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
禁止:直连 TMS;本地拆箱型;用户文字冒充群报价版本。
@@ -108,6 +108,21 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
adjusted = try_private_adjust(self, sess, text, reply)
if adjusted:
return adjusted
from agent.schema.land_options import looks_like_confirm
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
# 确定查价不再抽字段,避免「确定」被模型改成空 facts。
logger.info("sea.confirm sender=%s", sender_id)
return self._confirm_or_hold(
sender_id=sender_id,
facts=dict(sess.facts),
text=sess.immutable_text or text,
reply=reply,
sess=sess,
)
if not sess and looks_like_confirm(text):
reply(copy.SEA_CONFIRM_EXPIRED)
return "confirm_expired"
# 上一单已出号:新一轮从本句重收,禁止把旧贸易条款/分类带进下一张工单。
sess = self._detach_ticketed_for_new_inquiry(sess)
@@ -132,7 +147,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
return llm_phase
mode = (snap.get("business_line") or "").upper()
facts = dict(snap.get("facts") or {})
if sess and sess.phase in {"clarify", "need_mode"}:
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm"}:
from agent.llm.extract_text import detect_transport_mode
from agent.schema.field_validate import harvest_oral_measures
@@ -205,7 +220,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
# 整句只回「港到港」是贸易条款举例,不要改写成运输分类。
class_source = "" if short_sea_trade_reply(source) == "港到港" else source
facts = harvest_sea_class(class_source, facts)
# 贸易条款标签行原样优先;整句回复门到门/港到港要收下;没写这两个词时模型冒充的门到门丢掉。
# 贸易条款标签行原样优先;整句回复或长句里写了门到门/港到港都要收下。
# 原话没出现这两个词时,模型冒充的门到门、港到港继续丢掉。
facts = apply_spoken_sea_trade_reply(source, facts)
spoken = harvest_spoken_trade_term(source)
if spoken.get("贸易条款") and not str(facts.get("贸易条款") or "").strip():
@@ -243,13 +259,109 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
),
)
return self._create_and_quote(
return self._hold_for_confirm(
sender_id=sender_id,
facts=facts,
text=sess.immutable_text if sess and sess.immutable_text else text,
reply=reply,
sess=sess,
)
def _hold_for_confirm(
self,
*,
sender_id: str,
facts: dict[str, str],
text: str,
reply: ReplyFn,
sess: Optional[FlowSession],
) -> str:
"""必填已齐:出海运核对卡,等确定再建单。核对卡没有工单号。"""
reply(copy.sea_confirm_card(facts))
pending = tuple(getattr(sess, "pending_files", ()) or ()) if sess else ()
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="wait_confirm",
business_line="SEA",
facts=facts,
immutable_text=text,
wait_version=(sess.wait_version + 1) if sess else 1,
pending_files=pending,
)
)
return "wait_confirm"
def _confirm_or_hold(
self,
*,
sender_id: str,
facts: dict[str, str],
text: str,
reply: ReplyFn,
sess: FlowSession,
) -> str:
"""确定意图:字段仍齐则建单查价。缺了就回到补问,不建单。"""
check = validate_required_fields(facts=facts, business_line="SEA")
tms_ready = assemble_sea_query(check["facts"])
ready_facts = dict(tms_ready.get("facts") or facts)
if not check["ok"] or not tms_ready.get("ok"):
keys = list(check["missing"] or [])
for key in tms_ready.get("missing") or []:
if key not in keys:
keys.append(key)
new_sess = FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="clarify",
business_line="SEA",
facts=ready_facts,
immutable_text=sess.immutable_text or text,
wait_version=sess.wait_version + 1,
)
return self._clarify_or_handoff(
sess=sess,
keys=keys,
new_sess=new_sess,
reply=reply,
clarify_text=copy.ask_clarify(
facts=new_sess.facts,
missing_keys=keys,
transport_mode="海运",
),
)
return self._create_and_quote(
sender_id=sender_id,
facts=dict(tms_ready["facts"]),
text=text,
reply=reply,
)
def _flush_pending_files(self, sender_id: str, work_order_no: str) -> None:
"""确定建单后,把核对前收下的图片/附件挂到这张工单。挂完即清,避免重试重复挂。"""
sess = self.session_of(sender_id)
files = tuple(getattr(sess, "pending_files", ()) or ()) if sess else ()
if not files or not (work_order_no or "").strip():
return
for row in files:
key = str(row.get("object_key") or "").strip()
if not key:
continue
try:
self._ledger.attach_file(
work_order_no=work_order_no,
file_name=str(row.get("file_name") or "") or "attachment.bin",
object_key=key,
content_type=str(row.get("content_type") or "") or "application/octet-stream",
sender_id=sender_id,
)
except Exception:
logger.exception("海运核对后挂附件失败 wo=%s", work_order_no)
if sess is not None:
sess.pending_files = ()
self._save(sess)
def _create_and_quote(
self,
*,
@@ -266,6 +378,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
old = self.session_of(sender_id)
if old:
void_virtual_no(old)
pending = tuple(getattr(old, "pending_files", ()) or ()) if old else ()
self._save(
FlowSession(
sender_id=sender_id,
@@ -274,6 +387,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
business_line="SEA",
facts=facts,
immutable_text=text,
pending_files=pending,
)
)
created = self._ledger.create_ticket(
@@ -286,6 +400,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
if not no:
reply("建单失败,请稍后把刚才那条再发一次。海运字段已经记下。")
return "ledger_fail"
self._flush_pending_files(sender_id, no)
first, history_wo = self._resolve_similar(created, facts=facts, current_no=no)
out_id = reply(
copy.inquiry_card(