落地私聊附件询价:发卡走 H5 收 Excel/PDF,认成口播再复用三线文字流程,避免附件卡在识别或退成纯文本。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user