落地私聊附件询价:发卡走 H5 收 Excel/PDF,认成口播再复用三线文字流程,避免附件卡在识别或退成纯文本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 17:25:38 +08:00
co-authored by Cursor
parent 8219dce84b
commit 65ef75f1ef
19 changed files with 2420 additions and 8 deletions
@@ -0,0 +1,110 @@
"""
Excel / PDF 抽出纯文本。
本文件职责:把允许的附件字节变成可见原文,供千问整理、再走文字询价。
属于 policy 包。禁止在这里猜港口/箱型/缺项;禁止写六态。
识别槽与 H5 都只调用本模块的纯函数。
"""
from __future__ import annotations
import io
import logging
from typing import Any
logger = logging.getLogger(__name__)
_ALLOWED_EXT = {".xls", ".xlsx", ".pdf"}
_REJECT_HINT = "only_xlsx_xls_pdf"
def attachment_ext(filename: str) -> str:
"""小写后缀,含点。无后缀返回空。"""
name = (filename or "").strip().rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." not in name:
return ""
return "." + name.rsplit(".", 1)[-1].lower()
def allowed_attachment(filename: str) -> bool:
"""H5 只收 xls / xlsx / pdf。"""
return attachment_ext(filename) in _ALLOWED_EXT
def extract_attachment_text(filename: str, raw: bytes) -> dict[str, Any]:
"""
抽出单元格或 PDF 可见字。
返回 ok/text/error/empty。失败时 text 为空,由上层走「没看清 / 暂时没认出」。
"""
ext = attachment_ext(filename)
if ext not in _ALLOWED_EXT:
return {"ok": False, "text": "", "empty": True, "error": _REJECT_HINT}
blob = raw or b""
if not blob:
return {"ok": False, "text": "", "empty": True, "error": "empty_bytes"}
try:
if ext == ".pdf":
text = _pdf_text(blob)
elif ext == ".xlsx":
text = _xlsx_text(blob)
else:
text = _xls_text(blob)
except Exception as exc: # noqa: BLE001
logger.exception("抽附件文本失败 name=%s", filename)
return {"ok": False, "text": "", "empty": True, "error": f"extract_fail:{type(exc).__name__}"}
body = (text or "").strip()
return {"ok": True, "text": body, "empty": not bool(body), "error": ""}
def _xlsx_text(raw: bytes) -> str:
from openpyxl import load_workbook
wb = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
parts: list[str] = []
try:
for sheet in wb.worksheets:
title = str(sheet.title or "").strip()
if title:
parts.append(f"# {title}")
for row in sheet.iter_rows(values_only=True):
cells = [str(c).strip() for c in row if c is not None and str(c).strip()]
if cells:
parts.append("\t".join(cells))
finally:
wb.close()
return "\n".join(parts)
def _xls_text(raw: bytes) -> str:
try:
import xlrd
except ImportError:
return ""
book = xlrd.open_workbook(file_contents=raw)
parts: list[str] = []
for sheet in book.sheets():
title = str(sheet.name or "").strip()
if title:
parts.append(f"# {title}")
for r in range(sheet.nrows):
cells = [
str(sheet.cell_value(r, c)).strip()
for c in range(sheet.ncols)
if str(sheet.cell_value(r, c)).strip()
]
if cells:
parts.append("\t".join(cells))
return "\n".join(parts)
def _pdf_text(raw: bytes) -> str:
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(raw))
parts: list[str] = []
for page in reader.pages:
piece = page.extract_text() or ""
if piece.strip():
parts.append(piece.strip())
return "\n".join(parts)
@@ -0,0 +1,413 @@
"""
私聊附件等材料波次账。
本文件职责:记住「这一票」Excel/PDF、企微图和开口/期间的字,供附件询价拼口播。
属于 policy 包。生产波次落 Redis(HTTP 发卡/收 H5、Worker 识别共用);单测可纯内存。
禁止:调模型、建单、写六态;禁止一把大锁串全部销售。
未开始认时 30 分钟过期。一旦发图或 H5 提交,只看业务结果出没出。
"""
from __future__ import annotations
import json
import logging
import threading
import uuid
from dataclasses import asdict, dataclass, field
from typing import Any, Optional
from agent.policy.image_wave import WaveKV
# 规格:上传链接与未开始认的等待同一时效。
WAIT_TTL_SECONDS = 30 * 60
# 已开始认的波次短协调:超时后当新开,不当六态。
_WAVE_TTL_SEC = 2 * 3600
logger = logging.getLogger(__name__)
@dataclass
class AttachMaterial:
"""一份按到达/上传顺序记下的材料。text 在识别完成后才填。"""
kind: str
filename: str = ""
media_id: str = ""
text: str = ""
object_key: str = ""
error: str = ""
done: bool = False
@dataclass
class AttachWave:
"""
一张新询价对应的附件等材料波次。
started:已发图或已 H5 提交。pending 用 materials 里未 done 的份数。
"""
sender_id: str
wave_id: str
token: str = ""
opened_at: float = 0.0
started: bool = False
ack_sent: bool = False
ack_kind: str = ""
business_done: bool = False
precede_text: str = ""
follow_text: str = ""
materials: list[AttachMaterial] = field(default_factory=list)
class AttachWaveBook:
"""
附件波次账。按 sender 一把锁,不同销售互不等。
生产必须挂 Redis:发卡在 HTTP、识别在 Worker,两边不能各记一份内存。
"""
def __init__(self, kv: Optional[WaveKV] = None) -> None:
self._lock = threading.Lock()
self._kv = kv
self._waves: dict[str, AttachWave] = {}
def _hydrate(self, sender_id: str) -> None:
sid = (sender_id or "").strip()
if not sid or self._kv is None:
return
try:
raw = self._kv.get_json("wave", sid)
if raw:
self._waves[sid] = _wave_from_dict(raw)
else:
self._waves.pop(sid, None)
except Exception:
logger.exception("读附件波次失败 sender=%s", sid)
def _persist(self, sender_id: str) -> None:
sid = (sender_id or "").strip()
if not sid or self._kv is None:
return
try:
wave = self._waves.get(sid)
if wave is None:
self._kv.delete("wave", sid)
else:
self._kv.set_json("wave", sid, _wave_to_dict(wave))
except Exception:
logger.exception("写附件波次失败 sender=%s", sid)
def pending(self, wave: Optional[AttachWave]) -> int:
"""还没认完的材料份数。"""
if wave is None:
return 0
return sum(1 for m in wave.materials if not m.done)
def open_wait(
self,
sender_id: str,
*,
precede_text: str,
token: str,
now: float,
) -> AttachWave:
"""
听成附件询价:开等材料票。覆盖上一票附件波次。
副作用:写账。不发送企微、不建单。
"""
sid = (sender_id or "").strip()
wave = AttachWave(
sender_id=sid,
wave_id=uuid.uuid4().hex,
token=(token or "").strip(),
opened_at=float(now),
precede_text=(precede_text or "").strip(),
)
with self._lock:
self._waves[sid] = wave
self._persist(sid)
return wave
def active(self, sender_id: str, now: Optional[float] = None) -> Optional[AttachWave]:
"""
未出业务结果的当前票。
尚未开始认且超过 30 分钟:视为过期,不当活跃。
"""
sid = (sender_id or "").strip()
stamp = float(now) if now is not None else 0.0
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None or wave.business_done:
return None
if not wave.started:
base = stamp if now is not None else wave.opened_at
if now is not None and (base - wave.opened_at) > WAIT_TTL_SECONDS:
return None
return wave
def join_image(
self,
sender_id: str,
media_id: str,
*,
filename: str,
now: float,
) -> tuple[Optional[AttachWave], str]:
"""
等材料期间发来的图并入。无活跃票返回 inactive。
副作用:materials 追加、started=True。
"""
sid = (sender_id or "").strip()
wave = self.active(sid, now=now)
if wave is None:
return None, "inactive"
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None or wave.business_done:
return None, "inactive"
wave.started = True
wave.materials.append(
AttachMaterial(
kind="image",
filename=(filename or "").strip() or "image.jpg",
media_id=(media_id or "").strip(),
)
)
self._persist(sid)
return wave, "joined"
def add_files(
self,
sender_id: str,
filenames: list[str],
*,
now: float,
) -> Optional[AttachWave]:
"""H5 一次提交的多个文件按选中顺序追加。"""
sid = (sender_id or "").strip()
names = [str(x).strip() for x in (filenames or []) if str(x).strip()]
if not names:
return self.active(sid, now=now)
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None or wave.business_done:
return None
if not wave.started and (float(now) - wave.opened_at) > WAIT_TTL_SECONDS:
return None
wave.started = True
for name in names:
wave.materials.append(AttachMaterial(kind="file", filename=name))
self._persist(sid)
return wave
def complete_material(
self,
sender_id: str,
index: int,
*,
text: str = "",
object_key: str = "",
error: str = "",
) -> Optional[AttachWave]:
"""
第 index 份材料认完。按上传顺序填原文,不按完成先后重排。
调用:Worker 识别槽。不得在本方法出发业务回复。
"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None:
return None
if index < 0 or index >= len(wave.materials):
logger.warning("附件材料下标越界 sender=%s idx=%s", sid, index)
return wave
row = wave.materials[index]
row.text = (text or "").strip()
if object_key:
row.object_key = object_key
if error:
row.error = error
row.done = True
self._persist(sid)
return wave
def append_follow_text(self, sender_id: str, text: str) -> bool:
"""业务结果发出前的字并进本票。发出后或无票返回 False。"""
body = (text or "").strip()
sid = (sender_id or "").strip()
if not sid or not body:
return False
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None or wave.business_done:
return False
if wave.follow_text:
wave.follow_text = f"{wave.follow_text}\n{body}"
else:
wave.follow_text = body
self._persist(sid)
return True
def mark_ack(self, sender_id: str, kind: str) -> None:
"""已经回过「正在识别」,同一次不得再 ack。"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is not None:
wave.ack_sent = True
wave.ack_kind = (kind or "").strip()
self._persist(sid)
def compose_oral(self, sender_id: str) -> str:
"""开口字 + 按材料顺序的原文 + 后到的字。"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None:
return ""
parts: list[str] = []
if wave.precede_text.strip():
parts.append(wave.precede_text.strip())
for row in wave.materials:
if row.text.strip():
parts.append(row.text.strip())
if wave.follow_text.strip():
parts.append(wave.follow_text.strip())
return "\n".join(parts)
def mark_business_done(self, sender_id: str) -> None:
"""业务补问/卡片已发出。之后旧 H5 与并入图都必须拒绝。"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is not None:
wave.business_done = True
self._persist(sid)
def has_errors(self, sender_id: str) -> bool:
"""本票是否有下载/识别技术失败(用于选没看清还是暂时没认出)。"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None:
return False
return any(bool(m.error) for m in wave.materials)
def _wave_to_dict(wave: AttachWave) -> dict[str, Any]:
data = asdict(wave)
return data
def _wave_from_dict(raw: dict[str, Any]) -> AttachWave:
materials = []
for item in raw.get("materials") or []:
if not isinstance(item, dict):
continue
materials.append(
AttachMaterial(
kind=str(item.get("kind") or "file"),
filename=str(item.get("filename") or ""),
media_id=str(item.get("media_id") or ""),
text=str(item.get("text") or ""),
object_key=str(item.get("object_key") or ""),
error=str(item.get("error") or ""),
done=bool(item.get("done")),
)
)
return AttachWave(
sender_id=str(raw.get("sender_id") or ""),
wave_id=str(raw.get("wave_id") or uuid.uuid4().hex),
token=str(raw.get("token") or ""),
opened_at=float(raw.get("opened_at") or 0.0),
started=bool(raw.get("started")),
ack_sent=bool(raw.get("ack_sent")),
ack_kind=str(raw.get("ack_kind") or ""),
business_done=bool(raw.get("business_done")),
precede_text=str(raw.get("precede_text") or ""),
follow_text=str(raw.get("follow_text") or ""),
materials=materials,
)
class RedisAttachKV:
"""附件波次落到本前缀 Redis。HTTP 与 Worker 必须读同一把键。"""
def __init__(self, client: Any) -> None:
self._client = client
def get_json(self, kind: str, sender_id: str) -> Optional[dict[str, Any]]:
from agent.redis_coord.keys import ATTACH_WAVE
_ = kind
raw = self._client.raw.get(self._client.key(ATTACH_WAVE, sender_id))
if not raw:
return None
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
data = json.loads(raw)
return dict(data) if isinstance(data, dict) else None
def set_json(self, kind: str, sender_id: str, data: dict[str, Any]) -> None:
from agent.redis_coord.keys import ATTACH_WAVE
_ = kind
self._client.raw.set(
self._client.key(ATTACH_WAVE, sender_id),
json.dumps(data, ensure_ascii=False),
ex=_WAVE_TTL_SEC,
)
def delete(self, kind: str, sender_id: str) -> None:
from agent.redis_coord.keys import ATTACH_WAVE
_ = kind
self._client.raw.delete(self._client.key(ATTACH_WAVE, sender_id))
def _production_kv() -> Optional[WaveKV]:
try:
from agent.redis_coord.runtime import get_redis_runtime
rt = get_redis_runtime()
if rt is None or rt.client is None:
return None
return RedisAttachKV(rt.client)
except Exception:
logger.exception("附件波次 Redis 不可用,本进程退回内存账")
return None
_BOOK: Optional[AttachWaveBook] = None
_BOOK_GUARD = threading.Lock()
def get_attach_wave_book() -> AttachWaveBook:
"""进程内一份句柄;生产内容在 Redis。"""
global _BOOK
with _BOOK_GUARD:
if _BOOK is None:
_BOOK = AttachWaveBook(kv=_production_kv())
return _BOOK
def reset_attach_wave_book_for_test(kv: Optional[WaveKV] = None) -> AttachWaveBook:
"""单测重置。传入同一 kv 可模拟 HTTP/Worker 两份句柄。"""
global _BOOK
with _BOOK_GUARD:
_BOOK = AttachWaveBook(kv=kv)
return _BOOK
@@ -104,6 +104,42 @@ ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。"
IMAGE_RECOGNIZING = "正在识别图片"
IMAGE_UNREADABLE = "图片没看清询价信息,请换一张更清楚的,或直接打字。"
IMAGE_TECH_FAIL = "图片暂时没认出来,请再发一张或直接打字。"
# 私聊附件询价:H5 或发图开始认;看不清/失败与补问拼成一条。
ATTACH_RECOGNIZING = "正在识别附件"
ATTACH_UNREADABLE = "附件没看清询价信息,请换一份更清楚的 Excel / PDF(或把图片发到会话),或直接打字。"
ATTACH_TECH_FAIL = "附件暂时没认出来,请再传一次或直接打字。"
ATTACH_H5_REJECT_TYPE = "只接受 Excel、PDF;图片请发到当前企微会话。"
ATTACH_H5_NEED_FILE = "请选择要上传的 Excel 或 PDF。"
ATTACH_H5_EXPIRED = "已过期,请回企微重新说附件询价。"
ATTACH_H5_OK = "已收到,请回企微查看识别结果。"
def attachment_upload_card(url: str) -> dict[str, Any]:
"""
附件上传入口模板卡(text_notice 跳转 H5)。
必须跟现网同一卡型:企微 news_notice 强制要 card_image.url,缺了会 42044,
出站再回退成一句「上传询价附件」,销售侧就看不到卡片。
文案对齐规格:Excel/PDF 走 H5,图片发当前会话。url 必须已含 token、不含 userid。
"""
jump = (url or "").strip()
fallback = "https://ai.ytd-scm.com/inquiry-form"
action_url = jump or fallback
card: dict[str, Any] = {
"card_type": "text_notice",
"source": {"desc": "附件询价", "desc_color": 0},
"main_title": {
"title": "上传询价附件",
"desc": "附件入口支持 Excel、PDF;图片请直接在企微聊天窗口发送",
},
"sub_title_text": (
"Excel、PDF 请点击下方入口上传;图片请直接在当前企微会话发送。"
"字段完整时自动进入建单与查价;缺失字段会在企微中补问"
),
"jump_list": [{"type": 1, "title": "上传询价附件", "url": action_url}],
"card_action": {"type": 1, "url": action_url},
}
return {"msgtype": "template_card", "template_card": card}
def image_fail_then_clarify(reason: str, clarify: str) -> str: