陆运选项不全时改为复制填空清单,方便销售按编号回填。

停发填写样例图,同群只跟当前工单,重量体积统一为 KG/CBM。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 17:10:13 +08:00
co-authored by Cursor
parent 2334524bb8
commit 378bb892b3
33 changed files with 1204 additions and 265 deletions
+65 -20
View File
@@ -94,6 +94,13 @@ def operator_name(flow: Any, sender_id: str) -> str:
return (sender_id or "").strip() or "航线"
def is_air_ticket(ticket: object) -> bool:
"""按群找回的工单必须是空运。陆运旧单不能当空运当前单补含电含磁。"""
view = ticket_view(ticket)
line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper()
return not line or line == "AIR"
def can_fill_fields(flow: Any, ticket: object, sender_id: str) -> bool:
return is_sales(ticket, sender_id) or is_airline(flow, sender_id)
@@ -147,6 +154,8 @@ def activate_ticket(
ticket = hydrate_ticket(flow, _ticket_from_activate(book, no, out))
if is_handoff_ticket(ticket):
return None, "handoff_ignore"
# 先落默认协同,再决定要不要发摘要。发文件切单不说话,工单上仍要有含电/含磁。
_seed_air_activate_collab(flow, ticket)
if speak:
send_air_brief_and_sheet(flow=flow, ticket=ticket, chat_id=chat_id)
_watch_archive_room(chat_id, no)
@@ -156,6 +165,54 @@ def activate_ticket(
return ticket, "activated"
def _collab_slot_empty(val: object) -> bool:
"""协同项没写,或只写了横杠。横杠是摘要占位,不能当成已经填过。"""
return str(val or "").strip() in {"", "-", "—", "-"}
def _seed_air_activate_collab(flow: Any, ticket: object) -> dict[str, str]:
"""
空运群激活时预填协同,并记到主账。
私聊已经写过的含电/含磁先带上。
货物品名正好是「普货」、该项还空着,默认否。
群里已经记下的值不覆盖。只补空项。
"""
from agent.schema.field_validate import (
AIR_COLLAB_FIELDS,
air_general_cargo_collab_defaults,
pick_collab_from_facts,
)
view = ticket_view(ticket)
no = ticket_no(ticket)
facts = dict(view.get("facts") or {})
# 主账有时品名在货物名称列,事实里没带「品名」。群摘要仍能看到普货。
if not str(facts.get("品名") or facts.get("货物品名") or "").strip():
cargo = str(view.get("goodsName") or view.get("goods_name") or "").strip()
if cargo:
facts["品名"] = cargo
seeded = pick_collab_from_facts(facts, AIR_COLLAB_FIELDS)
for key, val in air_general_cargo_collab_defaults(facts).items():
if _collab_slot_empty(seeded.get(key)):
seeded[key] = val
collab = dict(view.get("collab_facts") or {})
to_write: dict[str, str] = {}
for key, val in seeded.items():
if val and _collab_slot_empty(collab.get(key)):
collab[key] = val
to_write[key] = val
if to_write:
patcher = getattr(getattr(flow, "ledger", None), "patch_collab_facts", None)
if callable(patcher):
patcher(
work_order_no=no,
facts=to_write,
sender_id=str(view.get("sales_wecom_id") or view.get("salesWecomId") or ""),
)
return collab
def send_air_brief_and_sheet(
*,
flow: Any,
@@ -177,25 +234,7 @@ def send_air_brief_and_sheet(
if room and not view.get("collab_chat_id"):
view["collab_chat_id"] = room
facts = dict(view.get("facts") or {})
from agent.schema.field_validate import AIR_COLLAB_FIELDS, pick_collab_from_facts
seeded = pick_collab_from_facts(facts, AIR_COLLAB_FIELDS)
collab = dict(view.get("collab_facts") or {})
if seeded:
changed = False
for k, v in seeded.items():
if v and not str(collab.get(k) or "").strip():
collab[k] = v
changed = True
if changed:
view["collab_facts"] = collab
patcher = getattr(getattr(flow, "ledger", None), "patch_collab_facts", None)
if callable(patcher):
patcher(
work_order_no=no,
facts=seeded,
sender_id=str(view.get("sales_wecom_id") or view.get("salesWecomId") or ""),
)
collab = _seed_air_activate_collab(flow, view)
view["collab_facts"] = collab
view_q = dict(view.get("quote") or {})
override = dict(quote_override or {})
@@ -358,6 +397,9 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
client = group_client_of(flow)
if not is_air_ticket(ticket):
send_group_text(client, chat_id, copy.air_need_work_order())
return "need_work_order"
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
if stopped:
return stopped
@@ -369,7 +411,10 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch
send_group_text(client, chat_id, copy.air_fields_not_recognized())
return "fields_empty"
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
ticket2 = current_ticket(flow, chat_id) or ticket
# 必须回读本单。按群再查会拿到同群更早的陆运/海运旧单(测服 WO202609230007)。
getter = getattr(flow.ledger, "get_ticket", None)
reloaded = getter(work_order_no=no) if callable(getter) else None
ticket2 = hydrate_ticket(flow, reloaded or ticket)
view = ticket_view(ticket2)
collab = dict(view.get("collab_facts") or {})
collab.update(fields)
+1 -1
View File
@@ -817,7 +817,7 @@ class AirTextInquiryFlow:
chat_fn=chat_fn,
inbound_raw=inbound_raw,
)
reply("请补充运输方式:空运、海运或陆运。")
reply(copy.ASK_TRANSPORT)
return "need_mode"
check = validate_required_fields(facts=facts, business_line="AIR")
+92 -28
View File
@@ -26,23 +26,22 @@ from agent.schema.field_validate import (
)
from agent.schema.land_options import option_prompt as _land_option_prompt
from agent.schema.quote_adjust import total_price_label
from agent.schema.sea_options import sea_class_prompt
# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。
_AIR_EXAMPLES = {
"起运港": "南京",
"目的港": "吉隆坡",
"品名": "茶叶",
"件数": "10件",
"毛重": "100KGS",
"体积": "2CBM",
"包装方式": "托盘",
"件数": "10",
"毛重": "100",
"体积": "2",
"包装方式": "托盘、散货",
"报价日期": "2026-09-15",
"货源地": "东莞",
"货好时间": "随时可提",
"提货地址": "东莞仓库",
"收货地址": "马尼拉",
"贸易条款": "FOB",
"贸易条款": "DDP、DAP、CIF、CIP",
}
_AIR_CLARIFY = tuple(
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
@@ -114,7 +113,15 @@ _CONTINUE_HINT = (
)
ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。"
ASK_TRANSPORT = (
"请回复数字或文字,告知我询价的运输方式:\n"
"1.陆运\n"
"2.海运\n"
"3.空运"
)
# 待补充清单:请销售整段复制,只在冒号后填值;示例行不算已填。
CLARIFY_PASTE_HINT = "请复制粘贴以下内容,通过回复数字或文字方式,告知我以下字段值:"
_PENDING_UNITS = {}
HANDOFF_ALREADY_COLLAB = "该工单已拉产品进群协同。"
HANDOFF_CLARIFY_REASON = "参数提取不全(连续 3 次反问仍不完整)"
HANDOFF_INTENT_REASON = "已识别您希望转人工处理"
@@ -179,10 +186,9 @@ ASK_LAND_SUBTYPE = _land_option_prompt(current={})
def land_sample_image_payload() -> dict[str, Any]:
"""
填写样例表图片出站。
填写样例表图片载荷。编号清单上线后不再主动出站,保留给旧调用。
走企微 image,不走文件气泡。路径随发布包,出站线程再读字节。
禁止在回调线程上传。
走企微 image,不走文件气泡。禁止在回调线程上传。
"""
from agent.schema.land_options import sample_image_path
@@ -195,7 +201,7 @@ def land_sample_image_payload() -> dict[str, Any]:
def land_sample_image_fallback() -> str:
"""图片失败时回文字样例,避免销售什么都看不到。"""
"""旧图失败兜底。新补问不再发图,一般不会走到这里。"""
from agent.schema.land_options import sample_fallback_text
return sample_fallback_text()
@@ -278,14 +284,40 @@ def _land_optional_keys(land_type: str) -> tuple[str, ...]:
return ()
def _pending_field_block(
index: int,
label: str,
example: str = "",
*,
optional: bool = False,
unit: str = "",
) -> str:
"""
待补充一项:编号 + 字段名 + 必填/单位,示例另起一行。
销售侧如:
1.件数(必填):
(如:10)
2.重量(KG)(必填):
(如:100)
复制回去时只认冒号后面的值;下一行「(如:…)」不算已填。
"""
mark = "(非必填)" if optional else "(必填)"
extra = f"({unit})" if (unit or "").strip() else ""
head = f"{index}.{label}{extra}{mark}:"
shown = (example or "").strip()
if shown:
return f"{head}\n(如:{shown})"
return head
def _land_field_line(key: str, value: str, *, required: bool, example: str = "") -> str:
"""销售侧一行:名称(必填/非必填):值或示例。"""
"""已识别一行:名称(必填/非必填):值。待补充走 _pending_field_block。"""
label = LAND_DISPLAY.get(key, key)
mark = "必填" if required else "非必填"
if value:
return f"{label}({mark}):{value}"
if example:
return f"{label}({mark}):(如:{example})"
_ = example
return f"{label}({mark}):"
@@ -350,11 +382,22 @@ def ask_land_fields(
if not pending:
return ""
lines.append("")
lines.append("待补充:")
lines.append(CLARIFY_PASTE_HINT)
seq = 0
for key in pending:
seq += 1
required = key not in optional
example = _LAND_EXAMPLES.get(key, "")
lines.append(_land_field_line(key, "", required=required, example=example))
label = LAND_DISPLAY.get(key, key)
lines.append(
_pending_field_block(
seq,
label,
example,
optional=not required,
unit=_PENDING_UNITS.get(key, ""),
)
)
return "\n".join(lines)
@@ -394,14 +437,13 @@ def _sea_known_value(facts: dict[str, str], key: str) -> str:
def _air_field_line(key: str, value: str, *, required: bool, example: str = "") -> str:
"""空运销售侧一行:名称(必填/非必填):值或示例。"""
"""空运核对卡一行:名称(必填/非必填):值。待补充走 _pending_field_block。"""
label = AIR_DISPLAY_LABEL(key)
mark = "必填" if required else "非必填"
text = (value or "").strip()
if text:
return f"{label}({mark}):{text}"
if example:
return f"{label}({mark}):(如:{example})"
_ = example
return f"{label}({mark}):"
@@ -440,7 +482,7 @@ def ask_clarify(
transport_mode: str = "空运",
) -> str:
"""
缺项补问:先回显已识别,再列待补充并给示例。
缺项补问:先回显已识别,再给可复制清单;示例另起一行,如「1.件数(必填):」下一行「(如:10)」。
报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。
空运非必填一并列出,没填进待补充,不挡查价。
@@ -470,18 +512,28 @@ def ask_clarify(
if val:
lines.append(f"{label}:{val}")
lines.append("")
lines.append("待补充:")
lines.append(CLARIFY_PASTE_HINT)
any_missing = False
seq = 0
if "运输方式" in missing_set:
any_missing = True
lines.append("运输方式:(如:空运)")
seq += 1
lines.append(_pending_field_block(seq, "运输方式", "空运"))
for key, label, example in rows:
if key in missing_set:
any_missing = True
seq += 1
if sea and key == "运输分类":
lines.append(sea_class_prompt())
lines.append(_pending_field_block(seq, "运输分类", "Port to Port、其他"))
else:
lines.append(f"{label}:(如:{example})")
lines.append(
_pending_field_block(
seq,
label,
example,
unit=_PENDING_UNITS.get(key, ""),
)
)
if not sea:
optional_pending = [
(key, label, example)
@@ -490,8 +542,9 @@ def ask_clarify(
]
for key, label, example in optional_pending:
any_missing = True
seq += 1
shown = "起运港" if key == "起运港" else label
lines.append(f"{shown}(非必填):(如:{example})")
lines.append(_pending_field_block(seq, shown, example, optional=True))
if not any_missing:
return ""
return "\n".join(lines)
@@ -507,8 +560,10 @@ def ask_missing(display_names: Iterable[str]) -> str:
"毛重": "毛重",
"重量": "毛重",
"重量(KG)": "毛重",
"重量(KG)": "毛重",
"体积": "体积",
"体积(CBM)": "体积",
"体积(CBM)": "体积",
"包装类型": "包装方式",
"包装方式": "包装方式",
"货物数量": "货量",
@@ -610,8 +665,8 @@ def inquiry_field_lines(
f"目的地:{dest}",
f"品名:{cargo}",
f"件数:{_with_unit(str(facts.get('件数') or ''), '件')}",
f"重量:{_with_unit(str(facts.get('毛重') or ''), 'KGS')}",
f"体积:{_with_unit(str(facts.get('体积') or ''), 'CBM')}",
f"重量(KG):{_with_unit(str(facts.get('毛重') or ''), 'KGS')}",
f"体积(CBM):{_with_unit(str(facts.get('体积') or ''), 'CBM')}",
f"包装类型:{pack}",
f"报价日期:{quote_date}",
]
@@ -2601,8 +2656,17 @@ def air_group_brief(
lines.append("")
lines.append("协同补充字段:")
collab = dict(collab_facts or {})
from agent.schema.field_validate import air_general_cargo_collab_defaults
# 品名是普货时,摘要里不能再把没写的含电/含磁显示成横杠。
for key, val in air_general_cargo_collab_defaults(facts).items():
current = (collab.get(key) or "").strip()
if current in {"", "-", "—", "-"}:
collab[key] = val
for key in AIR_COLLAB_FIELDS:
val = (collab.get(key) or "").strip() or "-"
val = (collab.get(key) or "").strip()
if val in {"", "-", "—", "-"}:
val = "-"
lines.append(f"{key}:{val}")
tms, manual, adjust = _split_brief_quotes(
quote=quote,
+2 -5
View File
@@ -24,7 +24,6 @@ from agent.schema.land_options import (
harvest_land_options,
looks_like_confirm,
option_prompt,
option_prompt_needs_sample,
sample_pair_allowed,
validate_land_options,
)
@@ -225,14 +224,12 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
message: str = "",
) -> None:
"""
选项补问:先发文字清单,需要样例时再发表格图。
选项补问:只发复制填空清单。填写样例图不再另发。
图片失败由出站回退文字样例。不在本方法上传素材。
不在本方法上传素材。
"""
body = (message or "").strip() or option_prompt(current=facts)
reply(body)
if option_prompt_needs_sample(current=facts):
reply(copy.land_sample_image_fallback(), copy.land_sample_image_payload())
def _confirm_or_hold(
self,
+5 -3
View File
@@ -22,7 +22,6 @@ from agent.llm.extract_text import parse_labeled_facts
from agent.schema.land_options import (
harvest_land_options,
option_prompt,
option_prompt_needs_sample,
scrub_land_placeholder_ports,
validate_land_options,
)
@@ -155,7 +154,7 @@ def refresh_segments(segments: list[Segment]) -> tuple[bool, list[Segment], str]
按各段运输方式校验必填,并把默认报价日期写回。
返回:是否已齐、更新后的段、缺口文案。缺口一条消息说完,不套群摘要。
待补充的举例与单聊相同,例如「品名:(如:茶叶)」。
待补充的举例与单聊相同:字段名一行,下一行「(如:茶叶)」。
"""
updated: list[Segment] = []
blocks: list[str] = []
@@ -369,7 +368,10 @@ def _segment_clarify(seg: Segment, check: dict[str, Any]) -> str:
return ""
missing = [str(x) for x in (check.get("missing") or []) if str(x).strip()]
if check.get("need_land_subtype"):
return "待补充:\n运输类型(必填):(如:国内运输拼车)"
return (
copy.CLARIFY_PASTE_HINT
+ "\n1.运输类型(必填):\n(如:国内运输拼车)"
)
if seg.mode == "LAND":
if check.get("need_land_options"):
# 和单聊一样先列出线路类别、运输分类,选完再问询价字段。
@@ -37,7 +37,6 @@ from agent.policy.multi_segments import (
)
from agent.schema.land_options import (
looks_like_confirm,
option_prompt_needs_sample,
sample_pair_allowed,
)
@@ -291,17 +290,11 @@ class MultiTextInquiryFlow:
def _reply_gaps(self, segments: list[Segment], gaps: str, reply: ReplyFn) -> None:
"""
补问正文。陆运还缺类型或线路时,再发一张和单聊相同的填写样例图。
补问正文。陆运选项走复制清单,不再另发填写样例图。
副作用:出站文字,可能再出一张图片。只在 Worker 里调。
副作用:出站文字。只在 Worker 里调。
"""
reply(gaps)
for seg in segments:
if seg.mode != "LAND":
continue
if option_prompt_needs_sample(current=seg.facts):
reply(copy.land_sample_image_fallback(), copy.land_sample_image_payload())
return
def _enrich_segments(
self,