diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py
index 77d479c..1e748f2 100644
--- a/inquiry-agent/agent/handlers/air_group_collab.py
+++ b/inquiry-agent/agent/handlers/air_group_collab.py
@@ -205,6 +205,46 @@ def handle_air_group(
intent,
wo or "-",
)
+ # #region agent log
+ try:
+ import json
+ import time
+ from pathlib import Path
+
+ from agent.llm.extract_text import labeled_quote_date
+
+ _payload = {
+ "sessionId": "717349",
+ "runId": "post-fix",
+ "hypothesisId": "H1",
+ "location": "air_group_collab.py:handle_air_group",
+ "message": "air group inbound classified",
+ "data": {
+ "chat_tail": chat_id[-8:],
+ "sender_tail": (message.sender_id or "")[-8:],
+ "intent": intent,
+ "wo": wo or "",
+ "text_head": (text or "")[:100],
+ "labeled_qd": labeled_quote_date(text),
+ "source": str((message.raw or {}).get("source") or ""),
+ },
+ "timestamp": int(time.time() * 1000),
+ }
+ _line = json.dumps(_payload, ensure_ascii=False) + "\n"
+ for _p in (
+ Path(__file__).resolve().parents[3] / "debug-717349.log",
+ Path(r"C:\Users\ytd_extdev_0903\incoming\debug-717349.log"),
+ Path(r"E:\wwwroot\agent\current\debug-717349.log"),
+ ):
+ try:
+ _p.parent.mkdir(parents=True, exist_ok=True)
+ with _p.open("a", encoding="utf-8") as _fh:
+ _fh.write(_line)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # #endregion
if intent in {INTENT_LOCK, INTENT_RELEASE, INTENT_RELEASE_ASK}:
return _handle_cabin(
engine,
@@ -548,6 +588,36 @@ def _handle_bound(
keep_tms=intent == INTENT_KEEP_TMS,
)
if intent == INTENT_FIELDS:
+ # #region agent log
+ try:
+ import json
+ import time
+ from pathlib import Path
+
+ _p = Path(__file__).resolve().parents[3] / "debug-866775.log"
+ with _p.open("a", encoding="utf-8") as _fh:
+ _fh.write(
+ json.dumps(
+ {
+ "sessionId": "866775",
+ "hypothesisId": "C",
+ "location": "air_group_collab.py:_handle_bound",
+ "message": "INTENT_FIELDS path",
+ "data": {
+ "text_head": (message.content or "")[:80],
+ "has_quote_date_label": (
+ "报价日期" in (message.content or "")
+ ),
+ },
+ "timestamp": int(time.time() * 1000),
+ },
+ ensure_ascii=False,
+ )
+ + "\n"
+ )
+ except Exception:
+ pass
+ # #endregion
return apply_air_fields(
flow=engine,
ticket=ticket,
diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py
index 6529bc3..d081687 100644
--- a/inquiry-agent/agent/ledger/http_ledger.py
+++ b/inquiry-agent/agent/ledger/http_ledger.py
@@ -476,44 +476,6 @@ class HttpLedger:
body["airline"] = airline.strip().upper()
if (flight_no or "").strip():
body["flightNo"] = flight_no.strip().upper()
- # #region agent log
- try:
- import json as _json
- import time as _time
- from pathlib import Path as _Path
-
- _line = _json.dumps(
- {
- "sessionId": "3ba460",
- "runId": "pre-fix",
- "hypothesisId": "C",
- "location": "http_ledger.py:lock_cabin",
- "message": "ledger_lock_called",
- "data": {
- "wo": work_order_no,
- "optionNo": option_no,
- "airline": (airline or "").strip(),
- "flightNo": (flight_no or "").strip(),
- "instruction_head": (instruction_text or "")[:80],
- },
- "timestamp": int(_time.time() * 1000),
- },
- ensure_ascii=False,
- ) + "\n"
- for _p in (
- _Path(r"d:\own\友通达询价智能体(新)\debug-3ba460.log"),
- _Path(r"C:/Users/ytd_extdev_0903/incoming/debug-3ba460.log"),
- _Path(r"E:/wwwroot/agent/current/debug-3ba460.log"),
- ):
- try:
- _p.parent.mkdir(parents=True, exist_ok=True)
- with _p.open("a", encoding="utf-8") as _fh:
- _fh.write(_line)
- except Exception:
- pass
- except Exception:
- pass
- # #endregion
return self._post(
"/inquiry/agent/tms/lockCabin",
body,
diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py
index 9ab97b7..6ccc3f7 100644
--- a/inquiry-agent/agent/ledger/memory_ledger.py
+++ b/inquiry-agent/agent/ledger/memory_ledger.py
@@ -304,12 +304,18 @@ class MemoryLedger:
}
def update_facts(self, *, work_order_no: str, facts: dict[str, 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.facts = dict(facts)
+ merged = dict(ticket.facts or {})
+ for key, raw in (facts or {}).items():
+ name = str(key or "").strip()
+ val = str(raw or "").strip()
+ if name and val:
+ merged[name] = val
+ ticket.facts = merged
return {"ok": True}
def void_quote_files(self, *, work_order_no: str) -> dict[str, Any]:
diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py
index 3a1c54e..f793817 100644
--- a/inquiry-agent/agent/llm/extract_text.py
+++ b/inquiry-agent/agent/llm/extract_text.py
@@ -46,7 +46,8 @@ _LABEL_KEYS = (
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|"
+ _QUOTE_DATE_LABEL_RE
+ r"|运输分类|车型/数量|车型数量|"
- r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|增值服务(非必填)|增值服务|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
+ r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|增值服务(非必填)|增值服务|商品海关编码|海关编码|HS编码|HS|"
+ r"是否含油|是否含电|是否带电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸"
)
_LABEL_LINE = re.compile(rf"^({_LABEL_KEYS})\s*[::]\s*(.+)$")
@@ -739,7 +740,11 @@ def _merge_optional_inquiry_labels(text: str, facts: dict[str, str]) -> dict[str
def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]:
- """按标签切原话。同一起点只留最长标签。"""
+ """
+ 按标签切原话。同一起点只留最长标签。
+
+ 冒号后本行为空视为未填,不跨行吞下一栏(核对卡复制粘贴时空起运地不能变成「目的地:…」)。
+ """
raw = text or ""
hits: list[tuple[int, str]] = []
for label in labels:
@@ -771,8 +776,15 @@ def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]:
# 增值服务只认冒号后同一行。空行不能把下一行「报价日期(非必填):」收成服务。
if label.startswith("增值服务"):
chunk = chunk.split("\n", 1)[0]
- chunk = re.sub(r"^[\s::,,、]+", "", chunk)
- chunk = re.sub(r"[\s,,、]+$", "", chunk)
+ same_line = chunk.split("\n", 1)[0]
+ same_line_body = re.sub(r"^[\s::,,、]+", "", same_line).strip()
+ # 冒号后本行为空 = 未填。禁止跨行把下一栏「目的地:吉隆坡」收成起运地。
+ # 核对卡复制粘贴时非必填常整行只有「起运地(非必填):」,下一行才是必填目的地。
+ if not same_line_body:
+ chunk = ""
+ else:
+ chunk = re.sub(r"^[\s::,,、]+", "", chunk)
+ chunk = re.sub(r"[\s,,、]+$", "", chunk)
if chunk:
out[label] = chunk
return out
@@ -834,15 +846,18 @@ def extract_air_collab_fields(
injected: Optional[dict[str, str]] = None,
) -> dict[str, str]:
"""
- 空运群只认是否含电、是否含磁。
+ 空运群协同项:是否含电、是否含磁(「是否带电」折成是否含电)。
复用海运口语收割,再滤成这两项。
"""
keys = ("是否含电", "是否含磁")
if injected:
+ from agent.schema.field_validate import normalize_facts
+
+ norm = normalize_facts(dict(injected))
return {
k: str(v).strip()
- for k, v in (injected or {}).items()
+ for k, v in norm.items()
if k in keys and str(v or "").strip()
}
got = extract_collab_fields(text, allow_b=allow_b)
@@ -933,28 +948,38 @@ def _ascii_amount(raw: str) -> str:
def _currency_of(text: str, hint: str = "") -> str:
"""
- 从正文/提示抽三字码。正文没写币种时只回传 hint,禁止默认编造 USD。
+ 从正文抽三字码;正文没写币种时才用 hint,禁止默认编造 USD。
+ 正文显式币种优先于 baseline:TMS 已是 HKD 时,产品写「1RMB/KG」必须落 CNY,
+ 禁止把 hint 拼进正文导致 HKD 盖掉 RMB(WO202610140040)。
+ RMB 是人民币拼音缩写,统一映射 CNY。
港币必须先于「元」判断,避免「港元」被误判成 CNY。
- WO202610090107:TMS 是 HKD,产品只写「运输费调整为1100」时必须继承 HKD。
+ WO202610090107:TMS 是 HKD,产品只写「运输费调整为1100」时继承 HKD。
"""
- blob = f"{hint} {text}".upper()
- raw = f"{hint} {text}"
- if any(x in blob for x in ("HKD", "港币", "港元", "HK$")) or "港币" in raw or "港元" in raw:
+ text_only = (text or "").upper()
+ text_raw = text or ""
+ # 正文显式币种(不含 hint),与「仅继承 TMS」区分开。
+ text_has_hkd = any(x in text_only for x in ("HKD", "港币", "港元", "HK$")) or (
+ "港币" in text_raw or "港元" in text_raw
+ )
+ text_has_cny = any(x in text_only for x in ("CNY", "RMB", "人民币")) or (
+ "元" in text_raw and "港元" not in text_raw and "港币" not in text_raw
+ )
+ # 先看正文,再退回 hint;禁止 hint+正文混检让 baseline HKD 压过正文 RMB。
+ if text_has_hkd:
return "HKD"
- if any(x in blob for x in ("CNY", "RMB", "人民币")) or (
- "元" in raw and "港元" not in raw and "港币" not in raw
- ):
+ if text_has_cny:
return "CNY"
- if any(x in blob for x in ("USD", "美金", "美元")):
+ if any(x in text_only for x in ("USD", "美金", "美元")):
return "USD"
- if any(x in blob for x in ("EUR", "欧元")):
+ if any(x in text_only for x in ("EUR", "欧元")):
return "EUR"
- if any(x in blob for x in ("JPY", "日元")):
+ if any(x in text_only for x in ("JPY", "日元")):
return "JPY"
- if any(x in blob for x in ("GBP", "英镑")):
+ if any(x in text_only for x in ("GBP", "英镑")):
return "GBP"
- return str(hint or "").strip().upper()
+ hint_u = str(hint or "").strip().upper()
+ return "CNY" if hint_u == "RMB" else hint_u
def _normalize_fee_project(raw: str) -> str:
@@ -983,6 +1008,94 @@ def _strip_group_bot_mentions(text: str) -> str:
return cleaned.strip()
+# 报价日期整段:标签+日期。费用抽取前剥掉,避免把年份 2026 当成金额。
+_RE_QUOTE_DATE_CHUNK = re.compile(
+ rf"(?:{_QUOTE_DATE_LABEL_RE})(?:(非必填)|\(非必填\))?\s*[::]?\s*"
+ r"(?:\d{4}\s*[-/.年]\s*\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?"
+ r"|\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?)",
+ re.I,
+)
+# 报价有效期(TMS 展示用,不是航线报价格);剥掉避免年份当金额。
+_RE_VALID_UNTIL_CHUNK = re.compile(
+ r"报价有效期\s*[::]?\s*"
+ r"(?:\d{4}\s*[-/.年]\s*\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?"
+ r"|\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?)",
+ re.I,
+)
+# 时效要求:空运无此字段;剥掉避免「3天」被抽成费用 3。
+_RE_TRANSIT_REQ_CHUNK = re.compile(
+ r"(?:时效要求|要求运输时效)\s*[::]?\s*"
+ r"\d+(?:\.\d+)?\s*(?:天|日|工作日|个工作日)?",
+ re.I,
+)
+
+
+def _strip_quote_date_chunks(text: str) -> str:
+ """去掉「报价日期:2026-10-15」整段,留给费用行抽取。"""
+ return _RE_QUOTE_DATE_CHUNK.sub(" ", text or "")
+
+
+def _strip_non_fee_meta_chunks(text: str) -> str:
+ """
+ 去掉不能当费用金额的元数据段:报价日期、报价有效期、时效要求。
+
+ 运行证据(717349):「报价有效期:2026-10-31」抽成 total=2026;
+ 「时效要求:3天」抽成 total=3。
+ """
+ out = _strip_quote_date_chunks(text or "")
+ out = _RE_VALID_UNTIL_CHUNK.sub(" ", out)
+ out = _RE_TRANSIT_REQ_CHUNK.sub(" ", out)
+ return out
+
+
+def labeled_quote_date(text: str) -> str:
+ """
+ 群里带标签的报价日期,规范成 yyyy-MM-dd;没有则空串。
+
+ 给空运协同意图与补字段用。不改主账。
+ """
+ from agent.schema.field_validate import _quote_date_labeled_value
+
+ return str(_quote_date_labeled_value(text) or "").strip()
+
+
+def is_quote_date_only_message(text: str) -> bool:
+ """
+ 整句只是在补报价日期(可带 @、工单号),没有费用金额。
+
+ 运行证据:报价日期:2026-10-15 曾被抽成空运费 2026。
+ """
+ raw = (text or "").strip()
+ if not raw or not labeled_quote_date(raw):
+ return False
+ rest = _strip_group_bot_mentions(_strip_non_fee_meta_chunks(raw))
+ rest = re.sub(r"WO\d{12}", "", rest, flags=re.I)
+ rest = re.sub(r"[\s::,,。、;;@#]+", "", rest)
+ return not rest.strip()
+
+
+def is_non_fee_meta_only_message(text: str) -> bool:
+ """
+ 整句只是报价日期 / 报价有效期 / 时效要求(可带 @、工单号),没有费用。
+
+ 这类句不能走航线报价;有效期来自 TMS,时效空运不写工单。
+ """
+ raw = (text or "").strip()
+ if not raw:
+ return False
+ has_meta = bool(
+ labeled_quote_date(raw)
+ or _RE_VALID_UNTIL_CHUNK.search(raw)
+ or _RE_TRANSIT_REQ_CHUNK.search(raw)
+ )
+ if not has_meta:
+ return False
+ rest = _strip_group_bot_mentions(_strip_non_fee_meta_chunks(raw))
+ rest = re.sub(r"WO\d{12}", "", rest, flags=re.I)
+ rest = re.sub(r"[\s::,,。、;;@#]+", "", rest)
+ return not rest.strip()
+
+
def _split_fee_project_prefix(text: str) -> tuple[str, str]:
"""
拆句首分段标签。例:起运港、增值费_板,单价… → (起运港, 增值费_板,单价…)
@@ -1111,12 +1224,18 @@ def extract_product_quote(
compact = stripped.replace(" ", "").replace(" ", "")
if re.fullmatch(r"WO\d{12}", compact, re.I):
return {}
+ # 只补报价日期/有效期/时效要求时直接空:年份、天数不能当费用金额。
+ if is_quote_date_only_message(raw) or is_non_fee_meta_only_message(raw):
+ return {}
band = _parse_weight_band_quote(stripped or raw, baseline_currency=base_ccy)
if band:
return band
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
# 必须用剥掉 @询价小助手 后的正文,否则句首「起运港、」分段标签匹配不到。
- scrubbed = _strip_rate_and_band_noise(stripped or raw)
+ # 顺带剥掉报价日期/有效期/时效要求,避免年份、天数再抽成金额。
+ scrubbed = _strip_rate_and_band_noise(
+ _strip_non_fee_meta_chunks(stripped or raw)
+ )
rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else []
if rows:
ccy = rows[0].get("currency") or _currency_of(raw, base_ccy) or base_ccy
@@ -1137,7 +1256,9 @@ def extract_product_quote(
}
out.update(_extract_rate_fields(raw))
return out
- hit = _RE_MONEY.search(_ascii_amount(scrubbed or raw))
+ # 剥掉报价日期后只剩空白时,禁止回退到原文再抽金额(否则年份 2026 会当空运费)。
+ money_src = scrubbed if scrubbed.strip() else ""
+ hit = _RE_MONEY.search(_ascii_amount(money_src)) if money_src else None
if not hit:
rates = _extract_rate_fields(raw)
return rates if rates else {}
diff --git a/inquiry-agent/agent/llm/mode_extract_fields.py b/inquiry-agent/agent/llm/mode_extract_fields.py
index b57b4e3..3ea8cdb 100644
--- a/inquiry-agent/agent/llm/mode_extract_fields.py
+++ b/inquiry-agent/agent/llm/mode_extract_fields.py
@@ -135,8 +135,11 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
"陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。"
"原话单独出现 DAP、DDP、CIF、CIP、FOB、EXW 时写入贸易条款;没出现留空,禁止编造。"
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
- "「货交上海」「货交:上海」「货交到上海」是提货地址=上海,不是起运港。"
- "没有另写起运地、三字码航线或「从哪飞/到」时,起运港必须空串。"
+ "陆运口语「广州到深圳」「广东省深圳市福田区到香港」→起运港=左侧地名、目的港=右侧地名;"
+ "不要把左侧写成提货地址,右侧目的地也不能丢。"
+ "「货交上海」「货交:上海」「货交到上海」才是提货地址=上海,不是起运港。"
+ "仅空运且只有「货交…」、没有另写起运地/三字码航线/「从哪飞」时,起运港必须空串;"
+ "陆运「A到B」必须写入起运港和目的港。"
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
"SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。"
"换城市必须覆盖旧值,不要沿用上一句的港口。"
@@ -214,4 +217,35 @@ def invoke_extract_fields(
parsed.get("business_line") or "-",
sorted((parsed.get("facts") or {}).keys()),
)
+ # #region agent log
+ try:
+ import json
+ import time
+ from pathlib import Path
+
+ _facts = dict(parsed.get("facts") or {})
+ _log = {
+ "sessionId": "0593f5",
+ "runId": "post-fix",
+ "hypothesisId": "A",
+ "location": "mode_extract_fields.py:invoke_extract_fields",
+ "message": "DeepSeek B place fields",
+ "data": {
+ "text_head": (text or "")[:80],
+ "ok": parsed.get("ok"),
+ "business_line": parsed.get("business_line"),
+ "land_subtype": parsed.get("land_subtype"),
+ "起运港": _facts.get("起运港"),
+ "目的港": _facts.get("目的港"),
+ "提货地址": _facts.get("提货地址"),
+ "fact_keys": sorted(_facts.keys()),
+ },
+ "timestamp": int(time.time() * 1000),
+ }
+ Path(__file__).resolve().parents[3].joinpath("debug-0593f5.log").open(
+ "a", encoding="utf-8"
+ ).write(json.dumps(_log, ensure_ascii=False) + "\n")
+ except Exception:
+ pass
+ # #endregion
return parsed
diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py
index cc788b9..05c22ec 100644
--- a/inquiry-agent/agent/policy/air_group_ops.py
+++ b/inquiry-agent/agent/policy/air_group_ops.py
@@ -320,7 +320,10 @@ def send_air_brief_and_sheet(
必须先发出工单信息,模板等摘要发出后再发,禁止后台线程抢先发文件。
填表报价必须带上 TMS 总销售价:群票上的 latest quote 常只有增点费/拆板费明细,
若直接拿去出单,预估费用会落成明细加总(如 200)而不是 salePrice(如 7918.25)。
+ 调用方刚写回的询价字段(如报价日期)优先,不能被 hydrate 旧快照盖掉。
"""
+ incoming_view = ticket_view(ticket) if ticket is not None else {}
+ incoming_facts = dict(incoming_view.get("facts") or {})
ticket = hydrate_ticket(flow, ticket)
view = ticket_view(ticket)
no = ticket_no(ticket)
@@ -328,6 +331,12 @@ 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 {})
+ # 群里刚补的报价日期等:有值就盖到摘要用的 facts 上。
+ for key, raw in incoming_facts.items():
+ val = str(raw or "").strip()
+ if key and val:
+ facts[key] = val
+ view["facts"] = facts
collab = _seed_air_activate_collab(flow, view)
view["collab_facts"] = collab
view_q = dict(view.get("quote") or {})
@@ -617,11 +626,19 @@ def resolve_cabin_ticket(
def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, chat_id: str) -> str:
"""
- 补含电/含磁。对齐海运:写入后重发群摘要 + 空运模板,不当一句「已记下」。
+ 补含电/含磁,以及报价日期。
+
+ 含电含磁走协同字段;报价日期写入询价 facts(主账 quote_date 列)。
+ 货好时间、贸易条款、客户名称不在群里补(产品口径)。
+ 写入后重发群摘要 + 空运模板,不当一句「已记下」。
"""
+ from agent.llm.extract_text import labeled_quote_date
+
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
client = group_client_of(flow)
+ fields = extract_air_collab_fields(text, allow_b=True)
+ quote_date = labeled_quote_date(text)
if not is_air_ticket(ticket):
send_group_text(client, chat_id, copy.air_need_work_order())
return "need_work_order"
@@ -631,11 +648,23 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch
if not can_fill_fields(flow, ticket, sender_id):
send_group_text(client, chat_id, copy.air_fields_denied())
return "fields_denied"
- fields = extract_air_collab_fields(text, allow_b=True)
- if not fields:
+ if not fields and not quote_date:
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)
+ if fields:
+ flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
+ write_ok = True
+ if quote_date:
+ # 只打补丁键:主账侧合并,避免整包 facts 误伤。
+ patch = {"报价日期": quote_date}
+ writer = getattr(flow.ledger, "update_facts", None)
+ if callable(writer):
+ try:
+ out = writer(work_order_no=no, facts=patch) or {}
+ write_ok = bool(out.get("ok", True))
+ except Exception:
+ write_ok = False
+ logger.exception("空运群写报价日期失败 wo=%s", no)
# 必须回读本单。按群再查会拿到同群更早的陆运/海运旧单(测服 WO202609230007)。
getter = getattr(flow.ledger, "get_ticket", None)
reloaded = getter(work_order_no=no) if callable(getter) else None
@@ -644,6 +673,10 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch
collab = dict(view.get("collab_facts") or {})
collab.update(fields)
view["collab_facts"] = collab
+ if quote_date:
+ facts_view = dict(view.get("facts") or {})
+ facts_view["报价日期"] = quote_date
+ view["facts"] = facts_view
view["collab_chat_id"] = chat_id
send_air_brief_and_sheet(flow=flow, ticket=view, chat_id=chat_id)
return "fields_saved"
@@ -1012,8 +1045,9 @@ def apply_air_lock(
航线锁舱。只有工单已是「已成交」才打主账。
还没回复成交:点名销售先确认。协商中、未成交各用一句,不催销售改口。
- 舱位编号、航司、航班号没齐时只请补全,不调 TMS 锁舱,不标系统异常。
- 三项齐了(话里写的优先,报价里已有的可以补上)才打主账锁舱。
+ 话里写的优先,报价里已有的舱位可以补上;补不上也要打主账——
+ 主账会合并历史 TMS / 现拉运价库(产品报价无舱位时不能在智能体侧先挡住)。
+ 主账回 lock_params_incomplete 再群里补问,不标系统异常。
上次锁舱失败标过暂停的,航线再锁仍要打 TMS。锁成功不再推成交跟进。
"""
if not is_airline(flow, sender_id):
diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py
index 56d6c63..bcab9c5 100644
--- a/inquiry-agent/agent/policy/air_text_flow.py
+++ b/inquiry-agent/agent/policy/air_text_flow.py
@@ -32,6 +32,20 @@ logger = logging.getLogger(__name__)
ReplyFn = Callable[..., Optional[str]]
+def _ledger_quote_pending_cabin(quote: dict[str, Any]) -> dict[str, Any]:
+ """
+ 未选舱前写主账用的薄快照:保留 airOptions 供恢复,清空总价/费用明细,
+ 并打 pending_cabin_pick,避免后台把备选舱位当成已报价。
+ """
+ out = dict(quote or {})
+ out["pending_cabin_pick"] = "1"
+ out["fee_lines"] = []
+ out["total"] = ""
+ out.pop("selected_air_option", None)
+ out.pop("selectedAirOption", None)
+ return out
+
+
@dataclass
class FlowSession:
"""一单会话书签(运行账,不是六态真相)。"""
@@ -334,10 +348,11 @@ class AirTextInquiryFlow:
number = (sess.work_order_no or "").strip()
if number and not is_virtual_no(number):
return None
+ hold_before = str(getattr(sess, "sensitive_hold", "") or "") if sess else ""
blocked, new_hold, _edits = judge(
text=text,
facts=facts,
- hold=str(getattr(sess, "sensitive_hold", "") or "") if sess else "",
+ hold=hold_before,
)
if sess is not None:
sess.sensitive_hold = new_hold
@@ -1379,19 +1394,23 @@ class AirTextInquiryFlow:
options = parse_air_options(quote)
# 没填报价日期:不按起运地收口,TMS 返回的舱位全部给销售点。1 条也要点。
+ # 未选定前六态保持询价中,禁止把备选舱位费写进后台「已报价」。
if not str((sess.facts or {}).get("报价日期") or "").strip() and options:
quote = dict(quote)
quote["airOptions"] = sort_cabin_options(options)
- up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
+ ledger_quote = _ledger_quote_pending_cabin(quote)
+ up = self._ledger.upsert_quote(
+ work_order_no=no, quote=ledger_quote, to_status="询价中"
+ )
if not up.get("ok"):
- reply("主账写入报价失败,不能当作已报价。")
+ reply("主账写入舱位备选失败,请稍后重试。")
sess.phase = "ledger_fail"
self._save(sess)
return "ledger_fail"
sess.quote = quote
copy.remember_tms_quote(sess, quote)
sess.tms_quote = dict(quote)
- sess.status = "已报价"
+ sess.status = "询价中"
return self._offer_air_routes(
sess=sess,
work_order_no=no,
@@ -1402,6 +1421,9 @@ class AirTextInquiryFlow:
stated_origin = str(
(sess.facts or {}).get("起运港") or (sess.facts or {}).get("起运地") or ""
).strip()
+ # 摘要占位「-」等于没写起运地,禁止当地名过滤舱位。
+ if stated_origin in {"-", "—", "-"}:
+ stated_origin = ""
# 写了起运地:只认这个起运地的线路,直接报价,不让销售再选。
# 没写起运地:TMS 按目的地回多条时,才进选线页。
if stated_origin:
@@ -1425,6 +1447,27 @@ class AirTextInquiryFlow:
)
elif options and not needs_route_pick(quote):
quote = project_selected_quote(quote, options[0])
+ # 仍要选线:未选定前只落备选快照,六态保持询价中。
+ if not stated_origin and needs_route_pick(quote):
+ ledger_quote = _ledger_quote_pending_cabin(quote)
+ up = self._ledger.upsert_quote(
+ work_order_no=no, quote=ledger_quote, to_status="询价中"
+ )
+ if not up.get("ok"):
+ reply("主账写入线路备选失败,请稍后重试。")
+ sess.phase = "ledger_fail"
+ self._save(sess)
+ return "ledger_fail"
+ sess.quote = quote
+ copy.remember_tms_quote(sess, quote)
+ sess.tms_quote = dict(quote)
+ sess.status = "询价中"
+ return self._offer_air_routes(
+ sess=sess,
+ work_order_no=no,
+ quote=quote,
+ reply=reply,
+ )
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
if not up.get("ok"):
reply("主账写入报价失败,不能当作已报价。")
@@ -1435,13 +1478,6 @@ class AirTextInquiryFlow:
copy.remember_tms_quote(sess, quote)
sess.tms_quote = dict(quote)
sess.status = "已报价"
- if not stated_origin and needs_route_pick(quote):
- return self._offer_air_routes(
- sess=sess,
- work_order_no=no,
- quote=quote,
- reply=reply,
- )
sess.phase = "wait_collab"
sess.allowed = (copy.BTN_SKIP_COLLAB,)
sess.wait_version += 1
diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py
index 51c3261..0e61d7f 100644
--- a/inquiry-agent/agent/policy/inquiry_copy.py
+++ b/inquiry-agent/agent/policy/inquiry_copy.py
@@ -650,6 +650,42 @@ def ask_clarify(
if key == "起运港":
continue
val = known(src, key)
+ # #region agent log
+ if key == "added_services":
+ try:
+ import json as _dbg_json
+ import time as _dbg_time
+ from pathlib import Path as _DbgPath
+
+ _payload = {
+ "sessionId": "5ea5b3",
+ "runId": "post-fix",
+ "hypothesisId": "H3",
+ "location": "inquiry_copy.py:ask_clarify",
+ "message": "clarify display added_services",
+ "data": {
+ "raw_facts": str(src.get("added_services") or ""),
+ "display_val": val,
+ "label": label,
+ },
+ "timestamp": int(_dbg_time.time() * 1000),
+ }
+ for _p in (
+ _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log",
+ _DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"),
+ _DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"),
+ ):
+ try:
+ _p.parent.mkdir(parents=True, exist_ok=True)
+ with _p.open("a", encoding="utf-8") as _f:
+ _f.write(
+ _dbg_json.dumps(_payload, ensure_ascii=False) + "\n"
+ )
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # #endregion
if val:
lines.append(f"{label}:{val}")
lines.append("")
@@ -818,6 +854,30 @@ def inquiry_field_lines(
]
# 建单确认和空运群:有内容才多一行。没填不出现。核对卡的空行不在这里。
added = (facts.get("added_services") or facts.get("增值服务") or "").strip()
+ # #region agent log
+ try:
+ import json as _dbg_json
+ import time as _dbg_time
+ from pathlib import Path as _DbgPath
+
+ _payload = {
+ "sessionId": "5ea5b3",
+ "runId": "pre-fix",
+ "hypothesisId": "H3",
+ "location": "inquiry_copy.py:air_confirm_lines",
+ "message": "display added_services value",
+ "data": {
+ "added": added,
+ "facts_keys": sorted(str(k) for k in (facts or {}).keys()),
+ },
+ "timestamp": int(_dbg_time.time() * 1000),
+ }
+ _path = _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log"
+ with _path.open("a", encoding="utf-8") as _f:
+ _f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n")
+ except Exception:
+ pass
+ # #endregion
if added and added not in {"-", "—", "-"}:
lines.insert(-1, f"增值服务:{added}")
return lines
@@ -3140,7 +3200,7 @@ def air_sales_only_deal() -> str:
def air_fields_not_recognized() -> str:
- return "听到了。空运群里可补充是否含电、是否含磁。"
+ return "听到了。空运群里可补充是否含电、是否含磁,或报价日期。"
def air_quote_not_recognized() -> str:
@@ -3148,7 +3208,7 @@ def air_quote_not_recognized() -> str:
def air_fields_denied() -> str:
- return "只有这张单的销售或航线可以补含电/含磁。"
+ return "只有这张单的销售或航线可以补含电/含磁或报价日期。"
def air_no_tms_quote_to_lock(work_order_no: str) -> str:
@@ -3346,9 +3406,10 @@ def air_text_quote_for_lock_example(work_order_no: str = "") -> str:
需要锁舱时,请航线用纯文字报价的示例句。
附件报价不拆金额、常锁不成;产品口径改为引导航线改发文字价。
+ 示例必须带计费单位(空运常见 KG),避免航线漏写单位导致文字价无法落库。
"""
no = (work_order_no or "").strip() or "工单号"
- return f"{no},空运段,空运费 单价CNY99.00,数量100 ,金额CNY9900.00@询价小助手"
+ return f"{no},空运段,KG,空运费 单价CNY99.00,数量100 ,金额CNY9900.00@询价小助手"
def air_attachment_quote_lock_tip(*, work_order_no: str) -> str:
diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py
index c6b1553..11805e5 100644
--- a/inquiry-agent/agent/policy/land_text_flow.py
+++ b/inquiry-agent/agent/policy/land_text_flow.py
@@ -138,6 +138,39 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
sess.work_order_no or "-",
)
sess = None
+ # #region agent log
+ try:
+ import json as _json
+ import time as _time
+ from pathlib import Path as _Path
+
+ with _Path(r"d:\own\友通达询价智能体(新)\debug-d331ea.log").open("a", encoding="utf-8") as _dbg:
+ _dbg.write(
+ _json.dumps(
+ {
+ "sessionId": "d331ea",
+ "runId": "post-fix",
+ "hypothesisId": "D",
+ "location": "land_text_flow.py:on_text:sess_keep",
+ "message": "land sess keep-or-clear",
+ "data": {
+ "phase_in": phase,
+ "kept_sess": sess is not None,
+ "in_front": phase in _LAND_FRONT,
+ "sensitive_hold": (
+ str(getattr(sess, "sensitive_hold", "") or "")[:120] if sess else ""
+ ),
+ "text": (text or "")[:120],
+ },
+ "timestamp": int(_time.time() * 1000),
+ },
+ ensure_ascii=False,
+ )
+ + "\n"
+ )
+ except Exception:
+ pass
+ # #endregion
snap = extract_inquiry_snapshot(
text,
diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py
index 3013b1a..eb0b324 100644
--- a/inquiry-agent/agent/policy/sea_group_ops.py
+++ b/inquiry-agent/agent/policy/sea_group_ops.py
@@ -931,12 +931,13 @@ def apply_product_quote(
sess.collab_facts = merged
line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper()
base = _current_quote(view, sess)
+ baseline_ccy = _quote_currency_hint(base)
quote = extract_product_quote(
text,
injected=injected_quote,
business_line=line,
transport_mode="陆运" if line == "LAND" else ("海运" if line == "SEA" else ""),
- baseline_currency=_quote_currency_hint(base),
+ baseline_currency=baseline_ccy,
)
if not quote:
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
@@ -1644,9 +1645,13 @@ def _merged_total(
总销售价:有 TMS 原合计就按改价差额加减,不能只加产品改的那几行。
美元行已含在 TMS 人民币合计里,不擅自换汇;只调整和合计同币种的差额。
+ 产品正文已点币种(如 RMB→CNY)时,表头跟产品,禁止 TMS HKD 再盖回去(WO040)。
"""
src = dict(base or {})
- header_ccy = str(src.get("currency") or incoming.get("currency") or "").strip()
+ # 产品显式币种优先:否则「1RMB/KG」合并后表头仍停在 TMS HKD。
+ header_ccy = str(incoming.get("currency") or src.get("currency") or "").strip()
+ if header_ccy.upper() == "RMB":
+ header_ccy = "CNY"
base_total = _parse_fee_amount(src.get("total"))
if base_total is not None:
total_ccy = (header_ccy or "CNY").upper()
diff --git a/inquiry-agent/agent/policy/sensitive_cargo.py b/inquiry-agent/agent/policy/sensitive_cargo.py
index ac778da..55da939 100644
--- a/inquiry-agent/agent/policy/sensitive_cargo.py
+++ b/inquiry-agent/agent/policy/sensitive_cargo.py
@@ -103,6 +103,41 @@ _WRITE_KEYS = {
# 建单前要看的当前字段。没写进这几项、但原话里出现过的,靠 sensitive_hold 记住。
_SCAN_KEYS = ("品名", "货物品名", "起运港", "起运地", "始发站", "备注")
+# 新开一票的说法。命中且本句没有违禁词时,丢掉上一句没改口的 hold。
+_FRESH_INQUIRY = re.compile(
+ r"(查一下|帮我查|询一下|询个价|询价|请报价|报一下价|报个价|费用)"
+)
+# 只补量尺、件数、包装:不算新询价,旧 hold 继续有效。
+_MEASURE_SUPPLEMENT = re.compile(
+ r"(?:"
+ r"重量|毛重|体积|件数|货量|包装(?:方式|类型)?"
+ r")\s*[::]?\s*\d"
+ r"|\d+\s*(?:kgs?|kg|公斤|千克|吨|t|cbm|方|件|箱|托)",
+ re.I,
+)
+
+
+def looks_like_fresh_inquiry(text: str) -> bool:
+ """
+ 当前句是不是另开一票,而不是接着补上一票的字段。
+
+ 「查一下目的地吉隆坡的费用」算新开;「重量100KGS」不算。
+ 用来在本句已无违禁词时清掉 sticky hold,避免正常询价被上一句炸弹拖死。
+ """
+ body = (text or "").strip()
+ if not body:
+ return False
+ if _MEASURE_SUPPLEMENT.search(body) and not _FRESH_INQUIRY.search(body):
+ return False
+ return bool(_FRESH_INQUIRY.search(body))
+
+
+def scrub_blocked_scan_fields(facts: dict[str, Any]) -> None:
+ """新开一票时,把品名/起运/备注里仍带着违禁词的旧值清掉,避免 field_hit 再拦。"""
+ for key in _SCAN_KEYS:
+ if contains_blocked(str((facts or {}).get(key) or "")):
+ facts[key] = ""
+
def contains_blocked(text: str) -> bool:
"""
@@ -203,6 +238,7 @@ def judge(*, text: str, facts: dict[str, Any], hold: str) -> tuple[bool, str]:
当前句已经把品名说成普通货(运送衣服、改成衣服、换成衣服):前面的违禁原话作废。
同一句里先提到旧词再改口,按改口后的品名放行。
只补重量、件数:旧原话继续有效。
+ 本句无违禁词且像另开一票(查一下…费用):旧 hold 作废,并清掉字段里残留的违禁词。
品名改干净之后,起运地或备注里还留着违禁词,仍然拦截。
会就地更新 facts 里本句写明的品名、起运地、备注。
第三个返回值是本句写下的键,多段用来写回每一段,避免把别的字段盖掉。
@@ -210,14 +246,23 @@ def judge(*, text: str, facts: dict[str, Any], hold: str) -> tuple[bool, str]:
edits = apply_field_edits(text, facts)
replaced = bool(spoken_cargo(text)) or is_cargo_rename(text)
message_hit = contains_blocked(text)
+ fresh = False
if replaced and not contains_blocked(str(facts.get("品名") or "")):
# 已经说出新的普通货品名。旧词留在这句里只是在改口,不再记住。
new_hold = ""
message_hit = False
elif message_hit:
new_hold = (text or "").strip() or (hold or "")
+ elif hold and looks_like_fresh_inquiry(text):
+ # 本句没有违禁词,且是另开一票(查一下…费用),上一句违禁原话作废。
+ # 只补重量/件数不走这里,旧 hold 继续拦。
+ fresh = True
+ new_hold = ""
+ scrub_blocked_scan_fields(facts)
else:
new_hold = hold or ""
field_text = "\n".join(str((facts or {}).get(key) or "") for key in _SCAN_KEYS)
- blocked = message_hit or contains_blocked(new_hold) or contains_blocked(field_text)
+ hold_hit = contains_blocked(new_hold)
+ field_hit = contains_blocked(field_text)
+ blocked = message_hit or hold_hit or field_hit
return blocked, new_hold, edits
diff --git a/inquiry-agent/agent/routing/air_group_intent.py b/inquiry-agent/agent/routing/air_group_intent.py
index f074524..67af5ee 100644
--- a/inquiry-agent/agent/routing/air_group_intent.py
+++ b/inquiry-agent/agent/routing/air_group_intent.py
@@ -7,7 +7,10 @@
from __future__ import annotations
+import json
import logging
+import time
+from pathlib import Path
from typing import Optional
from agent.policy import inquiry_copy as copy
@@ -32,6 +35,7 @@ from agent.routing.group_intent import (
logger = logging.getLogger(__name__)
+
INTENT_ACTIVATE = "air_activate"
INTENT_LOCK = "air_lock"
INTENT_RELEASE = "air_release"
@@ -131,18 +135,50 @@ def classify_air_group_text(
if use_net and getattr(settings, "llm_data_usage_confirmed", False):
got = _classify_with_model(raw, settings)
if got in _VALID:
- return _prefer_staff_quote(got, raw)
+ preferred = _prefer_staff_quote(got, raw)
+ from agent.llm.extract_text import (
+ extract_product_quote,
+ is_non_fee_meta_only_message,
+ is_quote_date_only_message,
+ labeled_quote_date,
+ )
+
+ # 模型若把「报价日期/有效期/时效」判成报价:有日期/含电含磁回补字段,否则 other。
+ if preferred == INTENT_QUOTE and (
+ is_quote_date_only_message(raw)
+ or is_non_fee_meta_only_message(raw)
+ or (labeled_quote_date(raw) and not extract_product_quote(raw))
+ ):
+ if labeled_quote_date(raw) or (
+ "是否含电" in raw or "是否含磁" in raw or "是否带电" in raw
+ ):
+ preferred = INTENT_FIELDS
+ elif is_non_fee_meta_only_message(raw):
+ preferred = INTENT_OTHER
+ else:
+ preferred = INTENT_FIELDS
+ return preferred
except Exception:
logger.exception("空运群意图模型失败,回退规则")
- from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
+ from agent.llm.extract_text import (
+ extract_air_collab_fields,
+ extract_product_quote,
+ is_non_fee_meta_only_message,
+ is_quote_date_only_message,
+ labeled_quote_date,
+ )
from agent.schema.quote_adjust import extract_adjust
# 费用金额先当航线报价。「空运费改为900」不是销售调价。
+ # 报价有效期/时效要求整句不是报价。
+ if is_non_fee_meta_only_message(raw) and not labeled_quote_date(raw):
+ return INTENT_OTHER
if text_is_staff_fee_quote(raw):
return INTENT_QUOTE
if extract_adjust(raw).is_adjust:
return INTENT_ADJUST
- if extract_product_quote(raw):
+ quote_hit = extract_product_quote(raw)
+ if quote_hit:
return INTENT_QUOTE
confirmed = _hint_confirm_tms(raw)
if confirmed:
@@ -153,7 +189,9 @@ def classify_air_group_text(
closed = _hint_deal_outcome(raw)
if closed:
return closed
- if extract_air_collab_fields(raw):
+ air_fields = extract_air_collab_fields(raw)
+ quote_date = labeled_quote_date(raw)
+ if air_fields or quote_date:
return INTENT_FIELDS
if _looks_like_option(raw):
return INTENT_PICK_OPTION
@@ -244,7 +282,8 @@ def _classify_with_model(text: str, settings) -> str:
"说出释放舱位、释放舱、把舱位释放了时不要回 air_release;"
"air_activate=只是指定/切换工单号;"
"air_pick_option=在补充或选择舱位编号、航司、航班号,写法不限;"
- "collab_fields=在补是否含电/是否含磁;"
+ "collab_fields=在补是否含电/是否含磁/是否带电,或补报价日期;"
+ "只有报价有效期或时效要求、没有费用金额时回 other,不要回 product_quote;"
"quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;"
"product_quote=航线在报某一项费用的金额。写出「空运费9900」「空运费改为900USD」是报价,不是调价;"
"quote_confirm_tms=确认沿用当前TMS报价;"
@@ -264,5 +303,7 @@ def _classify_with_model(text: str, settings) -> str:
{"role": "user", "content": text[:500]},
],
)
- got = str((data or {}).get("content") or "").strip().split()[0] if data else ""
+ # chat 返回 LlmHttpResult,不是 dict。
+ content = str(getattr(data, "content", "") or "").strip()
+ got = content.split()[0] if content else ""
return got if got in _VALID else ""
diff --git a/inquiry-agent/agent/schema/added_services.py b/inquiry-agent/agent/schema/added_services.py
index 61f5154..f8d3e26 100644
--- a/inquiry-agent/agent/schema/added_services.py
+++ b/inquiry-agent/agent/schema/added_services.py
@@ -3,6 +3,7 @@
本文件职责:把销售原话收成多项 added_services,供确认、工单和 TMS 的 addedServices 使用。
12 个服务词只要完整出现就记;名单外的必须写在「增值服务」后面才记,并保留原词。
+服务词后面紧跟的「+数字」(如 TC +1.5、托盘+1)并进同一项,写成 TC+1.5、托盘+1。
包装类型/包装方式这一栏里的「托盘」只算包装,不抄进增值服务。
「增值服务:」同一行为空时,不把下一行收进来,也不沿用上一句已经记下的服务。
不挡查价。不改六态。海运、陆运不走这里。
@@ -30,6 +31,8 @@ _CN_RE = re.compile("|".join(re.escape(word) for word in _CN_TERMS))
_MUST_GO_RE = re.compile(r"(?i)MUST\s*GO")
_DG_RE = re.compile(r"(?i)(? list[str]:
补问示例行「(如:托盘、…)」整行不看,避免清单被当成已填。
「不带电」不算带电;「卡转低消」不算再加一项卡转。
+ 「TC +1.5」记成 TC+1.5,数量跟服务名同一项。
副作用:无。
"""
raw = _without_clarify_examples(text or "")
events: list[tuple[int, str]] = []
# 包装栏里的托盘已在 _canonical_events 排除,这里只收正文里真正的服务词。
- events.extend(_canonical_events(raw))
+ events.extend((start, word) for start, _end, word in _canonical_events(raw))
for match in _LABEL_RE.finditer(raw):
value = (match.group(1) or "").strip()
if not value or value.startswith("(如") or value.startswith("(如"):
@@ -100,7 +104,52 @@ def harvest_added_services(text: str) -> list[str]:
if extra:
events.append((base + at + max(piece.find(extra), 0), extra))
events.sort(key=lambda item: item[0])
- return _dedupe([word for _pos, word in events])
+ result = _dedupe([word for _pos, word in events])
+ # #region agent log
+ try:
+ import json as _dbg_json
+ import time as _dbg_time
+ from pathlib import Path as _DbgPath
+
+ _tails: list[dict[str, str]] = []
+ for _m in list(_TC_RE.finditer(raw)) + list(_CN_RE.finditer(raw)):
+ if _m.group(0) not in {"TC", "tc", "托盘"} and _m.group(0).casefold() != "tc":
+ continue
+ _end = _m.end()
+ _tails.append(
+ {
+ "word": _m.group(0),
+ "after": raw[_end : _end + 12],
+ }
+ )
+ _payload = {
+ "sessionId": "5ea5b3",
+ "runId": "post-fix",
+ "hypothesisId": "H1",
+ "location": "added_services.py:harvest_added_services",
+ "message": "harvest result vs trailing modifiers",
+ "data": {
+ "text_preview": (raw or "")[:120],
+ "found": result,
+ "match_tails": _tails,
+ },
+ "timestamp": int(_dbg_time.time() * 1000),
+ }
+ for _path in (
+ _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log",
+ _DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"),
+ _DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"),
+ ):
+ try:
+ _path.parent.mkdir(parents=True, exist_ok=True)
+ with _path.open("a", encoding="utf-8") as _f:
+ _f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n")
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # #endregion
+ return result
def apply_air_value_added(
@@ -115,6 +164,7 @@ def apply_air_value_added(
只应在空运调用。副作用:返回新字典,不改入参。
"""
merged = dict(facts or {})
+ before_added = str(merged.get("added_services") or "")
found = harvest_added_services(text)
labeled = _labeled_added_value(text)
# 销售把「增值服务:」留空:这一栏就是空的。
@@ -122,23 +172,81 @@ def apply_air_value_added(
# 本句没写这一栏、也没提到服务时,才保留已经记下的旧值。
if labeled == "":
items = found
+ branch = "labeled_empty"
elif found:
items = _dedupe(split_added_services(previous) + found)
+ branch = "found_merge"
else:
items = _dedupe(
split_added_services(previous)
+ split_added_services(str(merged.get("added_services") or ""))
)
+ branch = "keep_previous"
if items:
merged["added_services"] = "、".join(items)
elif not str(merged.get("added_services") or "").strip():
merged.pop("added_services", None)
_keep_stated_package(text, merged)
+ # #region agent log
+ try:
+ import json as _dbg_json
+ import time as _dbg_time
+ from pathlib import Path as _DbgPath
+
+ _payload = {
+ "sessionId": "5ea5b3",
+ "runId": "post-fix",
+ "hypothesisId": "H2",
+ "location": "added_services.py:apply_air_value_added",
+ "message": "apply merge before/after",
+ "data": {
+ "text_preview": (text or "")[:120],
+ "before_added": before_added,
+ "found": found,
+ "previous": previous,
+ "labeled": labeled,
+ "branch": branch,
+ "after_added": str(merged.get("added_services") or ""),
+ "pack": str(merged.get("包装方式") or ""),
+ },
+ "timestamp": int(_dbg_time.time() * 1000),
+ }
+ for _path in (
+ _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log",
+ _DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"),
+ _DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"),
+ ):
+ try:
+ _path.parent.mkdir(parents=True, exist_ok=True)
+ with _path.open("a", encoding="utf-8") as _f:
+ _f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n")
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # #endregion
return merged
-def _canonical_events(raw: str) -> list[tuple[int, str]]:
- """按原文位置列出 12 个标准词。重叠区间只留更长的那一项。"""
+def _attach_qty_suffix(raw: str, start: int, end: int, word: str) -> tuple[int, int, str]:
+ """
+ 服务词后面若有 +数字,并进同一项。
+
+ 例:TC +1.5 → (起点, 终点, "TC+1.5")。没有数量则原样返回。
+ 为何:销售口播常写「TC +1.5、托盘 +1」,只记服务名会丢数量。
+ """
+ match = _QTY_SUFFIX_RE.match(raw or "", end)
+ if not match:
+ return start, end, word
+ return start, match.end(), f"{word}+{match.group(1)}"
+
+
+def _canonical_events(raw: str) -> list[tuple[int, int, str]]:
+ """
+ 按原文位置列出 12 个标准词(可带 +数量)。
+
+ 返回 (起点, 终点, 展示词)。重叠区间只留更长的那一项。
+ """
found: list[tuple[int, int, str]] = []
package_pallets = _package_pallet_spans(raw)
for match in _CN_RE.finditer(raw):
@@ -147,21 +255,21 @@ def _canonical_events(raw: str) -> list[tuple[int, str]]:
continue
if word == "托盘" and _span_inside(match.start(), match.end(), package_pallets):
continue
- found.append((match.start(), match.end(), word))
+ found.append(_attach_qty_suffix(raw, match.start(), match.end(), word))
for match in _MUST_GO_RE.finditer(raw):
- found.append((match.start(), match.end(), "MUST GO"))
+ found.append(_attach_qty_suffix(raw, match.start(), match.end(), "MUST GO"))
for match in _DG_RE.finditer(raw):
- found.append((match.start(), match.end(), "DG"))
+ found.append(_attach_qty_suffix(raw, match.start(), match.end(), "DG"))
for match in _TC_RE.finditer(raw):
- found.append((match.start(), match.end(), "TC"))
+ found.append(_attach_qty_suffix(raw, match.start(), match.end(), "TC"))
found.sort(key=lambda item: (item[0], -(item[1] - item[0])))
accepted: list[tuple[int, int]] = []
- events: list[tuple[int, str]] = []
+ events: list[tuple[int, int, str]] = []
for start, end, word in found:
if any(not (end <= left or start >= right) for left, right in accepted):
continue
accepted.append((start, end))
- events.append((start, word))
+ events.append((start, end, word))
return events
@@ -171,31 +279,19 @@ def _outside_list_text(piece: str) -> str:
「托盘」本身会被标准词扫到,这里不重复收。
「打木架和带电」只把打木架留下来,带电仍由标准词按出现位置记。
+ 带 +数量 的标准词整段擦掉,避免残留「+1.5」再当成名单外服务。
"""
spans = _canonical_events(piece)
if not spans:
return piece.strip()
chars = list(piece)
- for start, word in spans:
- # _canonical_events 只有起点。用标准词长度擦掉;MUST GO 用原文长度。
- end = start + _span_len(piece, start, word)
+ for start, end, _word in spans:
for index in range(start, min(end, len(chars))):
chars[index] = " "
rest = _FILLER.sub("", "".join(chars))
return rest.strip()
-def _span_len(piece: str, start: int, word: str) -> int:
- """标准词在原文里占几位。MUST GO 中间空格可有可无。"""
- if word == "MUST GO":
- match = _MUST_GO_RE.match(piece, start)
- if match:
- return match.end() - match.start()
- if word in {"DG", "TC"}:
- return 2
- return len(word)
-
-
def _labeled_added_value(text: str) -> str | None:
"""
原话里有没有单独的「增值服务」栏。
diff --git a/inquiry-agent/agent/schema/air_route_options.py b/inquiry-agent/agent/schema/air_route_options.py
index 2838819..7b531c3 100644
--- a/inquiry-agent/agent/schema/air_route_options.py
+++ b/inquiry-agent/agent/schema/air_route_options.py
@@ -432,6 +432,8 @@ def project_selected_quote(quote: dict[str, Any] | None, option: dict[str, Any]
base["valid_until"] = src.get("validUntil")
base["selected_air_option"] = src
base["route_line"] = route_line(src, {})
+ # 选定后不再是待选舱快照。
+ base.pop("pending_cabin_pick", None)
return base
diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py
index e0619f1..fb82a2d 100644
--- a/inquiry-agent/agent/schema/field_validate.py
+++ b/inquiry-agent/agent/schema/field_validate.py
@@ -76,7 +76,8 @@ LAND_DISPLAY = {
# 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。
SEA_COLLAB_FIELDS = ("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
-# 空运群只补含电/含磁,摘要不要把海运协同五项搬过去。
+# 空运群只补含电/含磁(及报价日期),摘要不要把海运协同五项搬过去。
+# 货好时间、贸易条款、客户名称不在群里补(产品口径)。
AIR_COLLAB_FIELDS = ("是否含电", "是否含磁")
# 群摘要对外展示名。内部继续用 HS编码,避免改合同键。
SEA_COLLAB_DISPLAY = {
@@ -222,6 +223,10 @@ _ALIASES = {
"海关编码": "HS编码",
"客户名称": "客户名称",
"货值": "货值",
+ # 模板/口语常写「是否带电」,合同内部键仍是是否含电。
+ "是否带电": "是否含电",
+ "是否含电": "是否含电",
+ "是否含磁": "是否含磁",
"是否为危险品": "是否为危险品",
"通关口岸": "通关口岸",
"通关口岸(非必填)": "通关口岸",
@@ -253,11 +258,15 @@ def shanghai_today() -> str:
return datetime.now(_SHANGHAI).date().isoformat()
+# 销售卡片/摘要占位横杠:等于没写,禁止落进 facts 再当起运地出站(运行证据 WO202610150086)。
+_PLACEHOLDER_DASHES = frozenset({"-", "—", "-"})
+
+
def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
"""
把展示名/别名折成合同内部键,去掉首尾空白。
- 空值丢弃;不解释语义。
+ 空值与摘要占位「-」丢弃;不解释语义。
"""
out: dict[str, str] = {}
for raw_key, raw_val in (facts or {}).items():
@@ -268,7 +277,7 @@ def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
if raw_val is None:
continue
val = str(raw_val).strip()
- if not val:
+ if not val or val in _PLACEHOLDER_DASHES:
continue
out[key] = val
_fold_land_vehicle_qty(out)
@@ -527,9 +536,16 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。
「(如:随时可提)」这种补问示例行不算销售说了货好时间。
危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。
+ 「是否带电:否」里的「带电」不能当成肯定(运行证据 session 717349)。
"""
raw = _without_clarify_examples(text or "")
out: dict[str, str] = {}
+ # 先剥掉「是否带电/含电/含磁:是|否」标签行,再口语匹配,避免标签里的「带电」误判成是。
+ oral_src = re.sub(
+ r"是否(?:带电|含电|含磁|含油)\s*[::]\s*[是否]",
+ " ",
+ raw,
+ )
yes_no = (
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
("是否含油", ("不含油", "不带油", "无油"), ("含油", "带油")),
@@ -541,9 +557,9 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
),
)
for key, no_words, yes_words in yes_no:
- if any(w in raw for w in no_words):
+ if any(w in oral_src for w in no_words):
out[key] = "否"
- elif any(w in raw for w in yes_words):
+ elif any(w in oral_src for w in yes_words):
out[key] = "是"
ready = ("随时可提", "货好随时", "随时货好", "货已好", "已经货好")
for word in ready:
diff --git a/inquiry-agent/agent/schema/tms_air_query.py b/inquiry-agent/agent/schema/tms_air_query.py
index 919a5b0..01f70c1 100644
--- a/inquiry-agent/agent/schema/tms_air_query.py
+++ b/inquiry-agent/agent/schema/tms_air_query.py
@@ -202,15 +202,27 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
包装只看销售是否说了,散货等原文原样保留。
"""
src = dict(facts or {})
+ # 摘要占位「-」当空:不能拿去 resolve 机场,否则 TMS 回 1003 起飞机场无法匹配。
+ _dash = frozenset({"-", "—", "-"})
origin = str(src.get("起运港") or src.get("起运地") or "").strip()
+ if origin in _dash:
+ origin = ""
dest = str(src.get("目的港") or "").strip()
+ if dest in _dash:
+ dest = ""
cargo = str(src.get("品名") or "").strip()
+ if cargo in _dash:
+ cargo = ""
pieces = _first_number(str(src.get("件数") or ""))
weight = _first_number(str(src.get("毛重") or ""))
volume = volume_cbm_number(str(src.get("体积") or ""))
pack_raw = str(src.get("包装方式") or src.get("包装类型") or "").strip()
+ if pack_raw in _dash:
+ pack_raw = ""
pack_code = resolve_package_type(pack_raw)
quote_date = str(src.get("报价日期") or "").strip()
+ if quote_date in _dash:
+ quote_date = ""
# 三字码必须按当前地名重算,禁止沿用上一句的 NKG/KUL
origin_code = resolve_airport_code(origin)
dest_code = resolve_airport_code(dest)
@@ -240,6 +252,10 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
ok = not missing
out_facts = dict(src)
+ # 横杠占位从出站 facts 清掉,避免建单把「-」写入起运港列。
+ for _k in ("起运港", "起运地"):
+ if str(out_facts.get(_k) or "").strip() in _dash:
+ out_facts.pop(_k, None)
if origin:
out_facts["起运港"] = origin
if origin_code:
@@ -247,6 +263,8 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
else:
out_facts.pop("originCode", None)
else:
+ out_facts.pop("起运港", None)
+ out_facts.pop("起运地", None)
out_facts.pop("originCode", None)
if dest:
out_facts["目的港"] = dest
diff --git a/inquiry-agent/tests/test_air_added_services.py b/inquiry-agent/tests/test_air_added_services.py
index 12d45a6..2f5df51 100644
--- a/inquiry-agent/tests/test_air_added_services.py
+++ b/inquiry-agent/tests/test_air_added_services.py
@@ -51,6 +51,16 @@ class AirAddedServicesTests(unittest.TestCase):
self.assertEqual(comma, ["托盘", "前置仓", "带电"])
spoken = harvest_added_services("这票 must go,要dg和tc")
self.assertEqual(spoken, ["MUST GO", "DG", "TC"])
+ # 服务名后的 +数量要跟进同一项,不能只剩 TC、托盘。
+ self.assertEqual(
+ harvest_added_services("查一下香港到吉隆坡书籍TC +1.5、托盘 +1的报价"),
+ ["TC+1.5", "托盘+1"],
+ )
+ self.assertEqual(
+ harvest_added_services("增值服务:TC+1.5、托盘 +1"),
+ ["TC+1.5", "托盘+1"],
+ )
+
def test_longest_match_and_negation(self) -> None:
self.assertEqual(harvest_added_services("要卡转低消"), ["卡转低消"])
diff --git a/inquiry-agent/tests/test_land_group_collab.py b/inquiry-agent/tests/test_land_group_collab.py
index 13b30a0..2425770 100644
--- a/inquiry-agent/tests/test_land_group_collab.py
+++ b/inquiry-agent/tests/test_land_group_collab.py
@@ -369,11 +369,41 @@ class LandGroupCollabTests(unittest.TestCase):
self.assertTrue(rows)
self.assertEqual(str(rows[0].get("name") or ""), "陆运费")
self.assertEqual(str(land.get("total") or ""), "1")
+ self.assertEqual(str(land.get("currency") or ""), "CNY")
sea = extract_product_quote("价格为1RMB/KG", business_line="SEA")
self.assertEqual(str((sea.get("fee_rows") or [{}])[0].get("name") or ""), "海运费")
air = extract_product_quote("价格为1RMB/KG", business_line="AIR")
self.assertEqual(str((air.get("fee_rows") or [{}])[0].get("name") or ""), "空运费")
+ def test_land_rmb_overrides_tms_hkd_baseline(self) -> None:
+ """WO040:TMS 已是 HKD 时,产品写「价格为1RMB/KG」必须落 CNY,禁止 hint 盖成 HKD。"""
+ from agent.llm.extract_text import extract_product_quote
+ from agent.policy.sea_group_ops import _quote_currency_hint, merge_quote_fees
+
+ tms = {
+ "currency": "HKD",
+ "cost": "HKD 1000",
+ "total": "1000",
+ "source": "TMS",
+ "fee_lines": [
+ {"name": "运输费", "amount": "1000", "currency": "HKD"},
+ ],
+ }
+ self.assertEqual(_quote_currency_hint(tms), "HKD")
+ quote = extract_product_quote(
+ "价格为1RMB/KG",
+ business_line="LAND",
+ baseline_currency=_quote_currency_hint(tms),
+ )
+ self.assertEqual(str(quote.get("currency") or ""), "CNY")
+ self.assertEqual(str((quote.get("fee_rows") or [{}])[0].get("currency") or ""), "CNY")
+ merged = merge_quote_fees(tms, quote)
+ self.assertEqual(str(merged.get("currency") or ""), "CNY")
+ self.assertEqual(
+ str((merged.get("fee_rows") or [{}])[-1].get("currency") or ""),
+ "CNY",
+ )
+
def test_land_adjust_without_currency_inherits_tms_hkd(self) -> None:
"""WO202610090107:TMS 港币,产品只写「运输费调整为1100」时继承 HKD,禁止默认 USD。"""
from agent.llm.extract_text import extract_product_quote
diff --git a/inquiry-agent/tests/test_sensitive_cargo.py b/inquiry-agent/tests/test_sensitive_cargo.py
index e7379b2..9dd394f 100644
--- a/inquiry-agent/tests/test_sensitive_cargo.py
+++ b/inquiry-agent/tests/test_sensitive_cargo.py
@@ -68,6 +68,19 @@ class JudgeTests(unittest.TestCase):
self.assertEqual(hold3, "")
self.assertEqual(edits.get("品名"), "服装")
+ def test_fresh_inquiry_clears_hold_without_cargo_rename(self) -> None:
+ """问过炸弹被拦后,再查吉隆坡费用应放行;不能继续套旧 hold。"""
+ hold = "查一下广西到胡志明炸弹的费用"
+ facts = {"品名": "炸弹", "起运港": "广西"}
+ blocked, new_hold, _edits = judge(
+ text="查一下目的地吉隆坡的费用",
+ facts=facts,
+ hold=hold,
+ )
+ self.assertFalse(blocked)
+ self.assertEqual(new_hold, "")
+ self.assertFalse(contains_blocked(str(facts.get("品名") or "")))
+
def test_rename_sentence_that_still_says_the_word_stays_blocked(self) -> None:
blocked, hold, _edits = judge(text="不是炸弹,品名服装", facts={"品名": "服装"}, hold="")
self.assertTrue(blocked)
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java
index 47075c7..7a3cd97 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java
@@ -13,6 +13,7 @@ import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate;
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion;
import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService;
+import org.jeecg.modules.inquiry.support.InquiryQuoteTemplateBizScope;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
@@ -32,7 +33,8 @@ import java.util.Map;
* 报价模板管理 API。
*
* 路径 /inquiry/quoteTemplate;权限 inquiry:template:*。
- * 智能体只读列表走 /agent/listEnabled(服务账号 token)。
+ * 有菜单权限只表示能进页面。列表按角色业务范围收窄:陆运中港只看陆运模板。
+ * 智能体只读列表走 /agent/listEnabled(服务账号 token),不按登录人的角色过滤。
* 禁止:在本 Controller 调 LLM;LibreOffice 仅允许预览截图接口短等本机 soffice。
*/
@Slf4j
@@ -48,12 +50,22 @@ public class InquiryQuoteTemplateController {
@ApiOperation("分页列表(含当前版本摘要)")
@RequiresPermissions("inquiry:template:view")
@GetMapping("/list")
- public Result>> list(@RequestParam(required = false) String keyword,
- @RequestParam(required = false) String bizType,
- @RequestParam(required = false) String status,
- @RequestParam(defaultValue = "1") Integer pageNo,
- @RequestParam(defaultValue = "20") Integer pageSize) {
- return Result.OK(quoteTemplateService.pageWithVersion(keyword, bizType, status, pageNo, pageSize));
+ public Result