落地私聊附件询价:发卡走 H5 收 Excel/PDF,认成口播再复用三线文字流程,避免附件卡在识别或退成纯文本。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,661 @@
|
||||
"""
|
||||
意图:私聊附件询价。
|
||||
|
||||
本文件职责:发卡等材料 → H5 收 Excel/PDF、企微收图 → 抽原文/认图 → 拼口播 → 新开后复用文字询价。
|
||||
禁止:千问填最终缺项;另写海运/空运/陆运附件专线;回调/H5 线程同步等千问;群附件走本 Handler。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from agent.channel.clarification import issue_token, validate_token
|
||||
from agent.channel.h5.copy_store import build_upload_url
|
||||
from agent.channel.queue import get_message_store
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.attachment_extract import allowed_attachment, extract_attachment_text
|
||||
from agent.policy.attachment_wave import WAIT_TTL_SECONDS, get_attach_wave_book
|
||||
from agent.policy.image_wave import get_image_wave_book
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ExtractFn = Callable[[str, bytes], dict[str, Any]]
|
||||
AttachTextFn = Callable[..., dict[str, Any]]
|
||||
VisionFn = Callable[..., dict[str, Any]]
|
||||
DownloadFn = Callable[[str], tuple[bytes, str]]
|
||||
PutFn = Callable[..., dict[str, Any]]
|
||||
|
||||
_STASH: dict[str, bytes] = {}
|
||||
_STASH_LOCK = threading.Lock()
|
||||
_PRECEDE_NOISE = (
|
||||
r"附件询价",
|
||||
r"上传询价附件",
|
||||
r"上传附件",
|
||||
r"帮我传个\s*excel\s*询价",
|
||||
r"我有个\s*excel\s*要询价",
|
||||
r"传个\s*(excel|pdf)",
|
||||
r"传份\s*(excel|pdf)",
|
||||
)
|
||||
|
||||
|
||||
def extra_precede(text: str) -> str:
|
||||
"""开口里去掉「要传附件」这类话,剩下的才带进口播。"""
|
||||
body = (text or "").strip()
|
||||
if not body:
|
||||
return ""
|
||||
cleaned = body
|
||||
for pat in _PRECEDE_NOISE:
|
||||
cleaned = re.sub(pat, " ", cleaned, flags=re.IGNORECASE)
|
||||
return " ".join(cleaned.split())
|
||||
|
||||
|
||||
def _stash_put(key: str, data: bytes) -> None:
|
||||
"""同进程单测/回退:对象存储 dry_run 时仍能把字节交给识别。"""
|
||||
if not key:
|
||||
return
|
||||
with _STASH_LOCK:
|
||||
_STASH[key] = data or b""
|
||||
|
||||
|
||||
def _stash_get(key: str) -> bytes:
|
||||
with _STASH_LOCK:
|
||||
return _STASH.get(key, b"")
|
||||
|
||||
|
||||
def drop_all_private_bookmarks(sender_id: str) -> None:
|
||||
"""三线当前书签都丢掉;已出号仍能按工单号找回。"""
|
||||
from agent.handlers.image_inquiry import drop_all_private_bookmarks as _drop
|
||||
|
||||
_drop(sender_id)
|
||||
|
||||
|
||||
def is_active_attachment_wait(sender_id: str, *, now: Optional[float] = None) -> bool:
|
||||
"""业务结果尚未发出、链接也未过期的附件等待票。"""
|
||||
return get_attach_wave_book().active(sender_id, now=now) is not None
|
||||
|
||||
|
||||
def append_attach_text(sender_id: str, text: str) -> bool:
|
||||
"""认完之前的字并进本票,不走文字询价。"""
|
||||
return get_attach_wave_book().append_follow_text(sender_id, text)
|
||||
|
||||
|
||||
def handle_attachment_inquiry(
|
||||
message: InboundMessage,
|
||||
*,
|
||||
store=None,
|
||||
now: Optional[float] = None,
|
||||
) -> str:
|
||||
"""
|
||||
听成要传附件:新开一票、发上传卡。不建单、不等 H5。
|
||||
|
||||
须已有 sender_id。群消息不要调用。
|
||||
"""
|
||||
if not message.has_sender():
|
||||
logger.warning("attachment_inquiry 拒绝:无 sender_id")
|
||||
return "reject_no_sender"
|
||||
if (message.chat_type or "single") == "group":
|
||||
logger.info("attachment_inquiry 忽略群 chat=%s", message.chat_id)
|
||||
return "ignore_group_attachment"
|
||||
|
||||
stamp = time.time() if now is None else float(now)
|
||||
img_book = get_image_wave_book()
|
||||
if img_book.active(message.sender_id) is not None:
|
||||
img_book.mark_business_done(message.sender_id)
|
||||
|
||||
drop_all_private_bookmarks(message.sender_id)
|
||||
token = issue_token(
|
||||
thread_id=f"attach:{message.sender_id}",
|
||||
wait_version=0,
|
||||
sender_id=message.sender_id,
|
||||
allowed_actions=["upload_attachment"],
|
||||
ttl_seconds=WAIT_TTL_SECONDS,
|
||||
)
|
||||
wave = get_attach_wave_book().open_wait(
|
||||
message.sender_id,
|
||||
precede_text=extra_precede(message.content or ""),
|
||||
token=token,
|
||||
now=stamp,
|
||||
)
|
||||
url = build_upload_url(token)
|
||||
payload = copy.attachment_upload_card(url)
|
||||
msg_store = store or get_message_store()
|
||||
msg_store.enqueue_outbound(
|
||||
touser=message.sender_id,
|
||||
content="上传询价附件",
|
||||
dedupe_key=f"attach-card:{wave.wave_id}",
|
||||
payload=payload,
|
||||
)
|
||||
logger.info("attachment_inquiry 发卡 wave=%s sender=%s", wave.wave_id, message.sender_id)
|
||||
return "wait_upload"
|
||||
|
||||
|
||||
def join_attachment_image(
|
||||
message: InboundMessage,
|
||||
*,
|
||||
store=None,
|
||||
vision_fn: Optional[VisionFn] = None,
|
||||
download_fn: Optional[DownloadFn] = None,
|
||||
put_fn: Optional[PutFn] = None,
|
||||
process_now: bool = True,
|
||||
now: Optional[float] = None,
|
||||
) -> str:
|
||||
"""等材料期间的私聊图:并入本票,必要时 ack「正在识别图片」。"""
|
||||
if not message.has_sender():
|
||||
return "reject_no_sender"
|
||||
stamp = time.time() if now is None else float(now)
|
||||
book = get_attach_wave_book()
|
||||
media_id = str((message.media or {}).get("media_id") or "")
|
||||
filename = str((message.media or {}).get("filename") or "image.jpg")
|
||||
wave, how = book.join_image(
|
||||
message.sender_id, media_id, filename=filename, now=stamp
|
||||
)
|
||||
if how != "joined" or wave is None:
|
||||
return "inactive"
|
||||
msg_store = store or get_message_store()
|
||||
index = len(wave.materials) - 1
|
||||
if not wave.ack_sent:
|
||||
msg_store.enqueue_outbound(
|
||||
touser=message.sender_id,
|
||||
content=copy.IMAGE_RECOGNIZING,
|
||||
dedupe_key=f"attach-ack:{wave.wave_id}",
|
||||
)
|
||||
book.mark_ack(message.sender_id, "image")
|
||||
if process_now and vision_fn is not None:
|
||||
_recognize_image(
|
||||
sender_id=message.sender_id,
|
||||
index=index,
|
||||
media_id=media_id,
|
||||
filename=filename,
|
||||
vision_fn=vision_fn,
|
||||
download_fn=download_fn,
|
||||
put_fn=put_fn,
|
||||
)
|
||||
wave = book.active(message.sender_id) or wave
|
||||
if book.pending(wave) <= 0:
|
||||
return finish_attach_wave(message.sender_id, store=msg_store, seed_message=message)
|
||||
return "recognizing"
|
||||
_enqueue_attach(
|
||||
sender_id=message.sender_id,
|
||||
index=index,
|
||||
role="image",
|
||||
filename=filename,
|
||||
media_id=media_id,
|
||||
message_id=message.message_id,
|
||||
wave_id=wave.wave_id,
|
||||
)
|
||||
return "recognizing"
|
||||
|
||||
|
||||
def accept_h5_uploads(
|
||||
*,
|
||||
sender_id: str,
|
||||
files: list[tuple[str, bytes]],
|
||||
store=None,
|
||||
extract_fn: Optional[ExtractFn] = None,
|
||||
attach_text_fn: Optional[AttachTextFn] = None,
|
||||
put_fn: Optional[PutFn] = None,
|
||||
process_now: bool = True,
|
||||
now: Optional[float] = None,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
H5 一次提交:校验后缀、按顺序入材料、开始认。
|
||||
|
||||
返回 (phase, hint)。hint 给网页看;phase 给调用方。
|
||||
"""
|
||||
sid = (sender_id or "").strip()
|
||||
stamp = time.time() if now is None else float(now)
|
||||
book = get_attach_wave_book()
|
||||
wave = book.active(sid, now=stamp)
|
||||
if wave is None:
|
||||
return "expired", copy.ATTACH_H5_EXPIRED
|
||||
incoming = [(str(n or "").strip(), raw or b"") for n, raw in files if str(n or "").strip()]
|
||||
if not incoming:
|
||||
return "need_file", copy.ATTACH_H5_NEED_FILE
|
||||
for name, _raw in incoming:
|
||||
if not allowed_attachment(name):
|
||||
return "reject_type", copy.ATTACH_H5_REJECT_TYPE
|
||||
start_index = len(wave.materials)
|
||||
wave = book.add_files(sid, [n for n, _r in incoming], now=stamp)
|
||||
if wave is None:
|
||||
return "expired", copy.ATTACH_H5_EXPIRED
|
||||
msg_store = store or get_message_store()
|
||||
if not wave.ack_sent:
|
||||
msg_store.enqueue_outbound(
|
||||
touser=sid,
|
||||
content=copy.ATTACH_RECOGNIZING,
|
||||
dedupe_key=f"attach-ack:{wave.wave_id}",
|
||||
)
|
||||
book.mark_ack(sid, "file")
|
||||
for offset, (name, raw) in enumerate(incoming):
|
||||
index = start_index + offset
|
||||
object_key = f"inquiry-attach/{sid}/{wave.wave_id}/{index}/{name}"
|
||||
_store_bytes(object_key, raw, put_fn=put_fn)
|
||||
if process_now:
|
||||
_recognize_file(
|
||||
sender_id=sid,
|
||||
index=index,
|
||||
filename=name,
|
||||
raw=raw,
|
||||
object_key=object_key,
|
||||
extract_fn=extract_fn,
|
||||
attach_text_fn=attach_text_fn,
|
||||
)
|
||||
else:
|
||||
_enqueue_attach(
|
||||
sender_id=sid,
|
||||
index=index,
|
||||
role="file",
|
||||
filename=name,
|
||||
object_key=object_key,
|
||||
wave_id=wave.wave_id,
|
||||
)
|
||||
_stash_put(object_key, raw)
|
||||
wave = book.active(sid, now=stamp)
|
||||
if process_now and wave is not None and book.pending(wave) <= 0:
|
||||
finish_attach_wave(sid, store=msg_store)
|
||||
return "done", copy.ATTACH_H5_OK
|
||||
return "recognizing", copy.ATTACH_H5_OK
|
||||
|
||||
|
||||
def finish_queued_attachment(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Worker 识别槽:认完一份材料,pending 为 0 则出业务结果。"""
|
||||
sender_id = str(payload.get("sender_id") or "")
|
||||
index = int(payload.get("index") or 0)
|
||||
role = str(payload.get("role") or "file")
|
||||
filename = str(payload.get("filename") or "")
|
||||
if role == "image":
|
||||
raw_hook = payload.get("__bytes")
|
||||
vision_hook = payload.get("__vision")
|
||||
|
||||
def _vision(**_kwargs: Any) -> dict[str, Any]:
|
||||
if isinstance(vision_hook, dict):
|
||||
return dict(vision_hook)
|
||||
from agent.config import get_settings
|
||||
from agent.llm.mode_vision import invoke_vision
|
||||
|
||||
settings = get_settings()
|
||||
return invoke_vision(
|
||||
payload=_kwargs.get("payload") or {},
|
||||
allow_network=bool(settings.llm_allow_network and settings.llm_data_usage_confirmed),
|
||||
)
|
||||
|
||||
def _download(mid: str) -> tuple[bytes, str]:
|
||||
if isinstance(raw_hook, (bytes, bytearray)):
|
||||
return bytes(raw_hook), ""
|
||||
from agent.channel.outbox.sender import WeComAppClient
|
||||
from agent.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
client = WeComAppClient(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
secret=settings.wecom_secret,
|
||||
agent_id=int(settings.wecom_agent_id or "0") or 0,
|
||||
)
|
||||
return client.download_media(mid)
|
||||
|
||||
_recognize_image(
|
||||
sender_id=sender_id,
|
||||
index=index,
|
||||
media_id=str(payload.get("media_id") or ""),
|
||||
filename=filename or "image.jpg",
|
||||
vision_fn=_vision,
|
||||
download_fn=_download,
|
||||
put_fn=None,
|
||||
)
|
||||
else:
|
||||
raw = payload.get("__bytes")
|
||||
object_key = str(payload.get("object_key") or "")
|
||||
blob = bytes(raw) if isinstance(raw, (bytes, bytearray)) else _load_bytes(object_key)
|
||||
extract_hook = payload.get("__extract")
|
||||
text_hook = payload.get("__attach_text")
|
||||
_recognize_file(
|
||||
sender_id=sender_id,
|
||||
index=index,
|
||||
filename=filename,
|
||||
raw=blob,
|
||||
object_key=object_key,
|
||||
extract_fn=extract_hook if callable(extract_hook) else None,
|
||||
attach_text_fn=text_hook if callable(text_hook) else None,
|
||||
)
|
||||
book = get_attach_wave_book()
|
||||
wave = book.active(sender_id)
|
||||
if wave is None:
|
||||
return {"ok": False, "phase": "no_active_wave", "pending": 0}
|
||||
if book.pending(wave) > 0:
|
||||
return {"ok": True, "phase": "recognizing", "pending": book.pending(wave)}
|
||||
phase = finish_attach_wave(
|
||||
sender_id,
|
||||
seed_message=InboundMessage(
|
||||
sender_id=sender_id,
|
||||
message_id=str(payload.get("message_id") or f"attach:{payload.get('wave_id') or sender_id}:{index}"),
|
||||
content="",
|
||||
msg_type="file",
|
||||
),
|
||||
)
|
||||
return {"ok": True, "phase": phase}
|
||||
|
||||
|
||||
def finish_attach_wave(
|
||||
sender_id: str,
|
||||
*,
|
||||
store=None,
|
||||
seed_message: Optional[InboundMessage] = None,
|
||||
) -> str:
|
||||
"""本票材料都认完:拼口播、当文字询价;看不清则说明+补问同一条。"""
|
||||
from agent.handlers.text_inquiry import handle_text_inquiry
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
from agent.policy.land_text_flow import get_land_text_flow
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
|
||||
book = get_attach_wave_book()
|
||||
wave = book.active(sender_id)
|
||||
oral = book.compose_oral(sender_id)
|
||||
reason = ""
|
||||
if not oral.strip():
|
||||
reason = copy.ATTACH_TECH_FAIL if book.has_errors(sender_id) else copy.ATTACH_UNREADABLE
|
||||
|
||||
msg_store = store or get_message_store()
|
||||
prefix_used = {"v": False}
|
||||
|
||||
class _StoreProxy:
|
||||
def enqueue_outbound(self, **kwargs):
|
||||
body = str(kwargs.get("content") or "")
|
||||
extra = dict(kwargs.get("payload") or {})
|
||||
if reason and not prefix_used["v"] and body:
|
||||
body = copy.image_fail_then_clarify(reason, body)
|
||||
kwargs["content"] = body
|
||||
prefix_used["v"] = True
|
||||
return msg_store.enqueue_outbound(
|
||||
touser=kwargs.get("touser") or sender_id,
|
||||
content=body,
|
||||
dedupe_key=str(kwargs.get("dedupe_key") or ""),
|
||||
payload=extra,
|
||||
)
|
||||
|
||||
inbound = InboundMessage(
|
||||
sender_id=sender_id,
|
||||
message_id=(
|
||||
seed_message.message_id
|
||||
if seed_message and seed_message.message_id
|
||||
else f"attach:{(wave.wave_id if wave else sender_id)}:{int(time.time())}"
|
||||
),
|
||||
content=oral,
|
||||
msg_type="text",
|
||||
media=dict(seed_message.media) if seed_message else {},
|
||||
raw=dict(seed_message.raw) if seed_message else {},
|
||||
)
|
||||
phase = handle_text_inquiry(inbound, store=_StoreProxy())
|
||||
if reason and not prefix_used["v"]:
|
||||
msg_store.enqueue_outbound(
|
||||
touser=sender_id,
|
||||
content=copy.image_fail_then_clarify(reason, copy.ASK_TRANSPORT),
|
||||
dedupe_key=f"attach-empty:{(wave.wave_id if wave else inbound.message_id)}",
|
||||
)
|
||||
phase = "need_mode"
|
||||
book.mark_business_done(sender_id)
|
||||
sess = (
|
||||
get_sea_text_flow().session_of(sender_id)
|
||||
or get_air_text_flow().session_of(sender_id)
|
||||
or get_land_text_flow().session_of(sender_id)
|
||||
)
|
||||
wo = (sess.work_order_no if sess else "") or ""
|
||||
if wo and wave:
|
||||
ledger = None
|
||||
for engine in (get_sea_text_flow(), get_air_text_flow(), get_land_text_flow()):
|
||||
hit = engine.session_of(sender_id)
|
||||
if hit and hit.work_order_no == wo:
|
||||
ledger = engine.ledger
|
||||
break
|
||||
if ledger is not None:
|
||||
for row in wave.materials:
|
||||
key = row.object_key
|
||||
if not key:
|
||||
continue
|
||||
name = row.filename or ("image.jpg" if row.kind == "image" else "attachment.bin")
|
||||
ctype = "image/jpeg" if row.kind == "image" else "application/octet-stream"
|
||||
try:
|
||||
ledger.attach_file(
|
||||
work_order_no=wo,
|
||||
file_name=name,
|
||||
object_key=key,
|
||||
content_type=ctype,
|
||||
sender_id=sender_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("挂附件失败 wo=%s name=%s", wo, name)
|
||||
logger.info("attachment_inquiry 波次完成 phase=%s oral_len=%s", phase, len(oral))
|
||||
return phase
|
||||
|
||||
|
||||
def lookup_upload_sender(token: str) -> Optional[str]:
|
||||
"""
|
||||
H5 打开/提交:token 有效且对应附件票仍活跃才返回 sender_id。
|
||||
不消费 token,业务结果发出前可再传。
|
||||
"""
|
||||
rec = validate_token(token, action="upload_attachment", consume=False)
|
||||
if rec is None:
|
||||
return None
|
||||
book = get_attach_wave_book()
|
||||
wave = book.active(rec.sender_id)
|
||||
if wave is None:
|
||||
return None
|
||||
if wave.token and wave.token != token:
|
||||
return None
|
||||
return rec.sender_id
|
||||
|
||||
|
||||
def _enqueue_attach(**payload: Any) -> None:
|
||||
try:
|
||||
from agent.jobs.recognition import enqueue_recognition
|
||||
|
||||
enqueue_recognition(
|
||||
kind="attachment",
|
||||
payload=dict(payload),
|
||||
idempotency_key=str(
|
||||
payload.get("message_id")
|
||||
or f"{payload.get('wave_id')}:{payload.get('index')}"
|
||||
),
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
logger.info("attachment 识别队跳过(本环境无 jobs 依赖) sender=%s", payload.get("sender_id"))
|
||||
except Exception:
|
||||
logger.exception("attachment 入识别队失败 sender=%s", payload.get("sender_id"))
|
||||
|
||||
|
||||
def _blob_put(object_key: str, raw: bytes) -> None:
|
||||
"""
|
||||
把附件字节交给 Redis 短交接,供 Worker 识别槽读取。
|
||||
|
||||
HTTP 与 Worker 不是同一进程,内存 stash 跨不过去;对象存储 dry_run 也不落盘。
|
||||
TTL 与等待票一致。失败只打日志,不在请求线程抛给销售。
|
||||
"""
|
||||
key = (object_key or "").strip()
|
||||
if not key:
|
||||
return
|
||||
try:
|
||||
import base64
|
||||
|
||||
from agent.redis_coord.keys import ATTACH_BLOB
|
||||
from agent.redis_coord.runtime import get_redis_runtime
|
||||
|
||||
rt = get_redis_runtime()
|
||||
packed = base64.b64encode(raw or b"").decode("ascii")
|
||||
rt.client.raw.set(rt.client.key(ATTACH_BLOB, key), packed, ex=WAIT_TTL_SECONDS)
|
||||
except Exception:
|
||||
logger.exception("附件字节 Redis 交接失败 key=%s", key)
|
||||
|
||||
|
||||
def _blob_get(object_key: str) -> bytes:
|
||||
"""Worker 读 HTTP 放下的附件字节。没有则空。"""
|
||||
key = (object_key or "").strip()
|
||||
if not key:
|
||||
return b""
|
||||
try:
|
||||
import base64
|
||||
|
||||
from agent.redis_coord.keys import ATTACH_BLOB
|
||||
from agent.redis_coord.runtime import get_redis_runtime
|
||||
|
||||
rt = get_redis_runtime()
|
||||
raw = rt.client.raw.get(rt.client.key(ATTACH_BLOB, key))
|
||||
if not raw:
|
||||
return b""
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("ascii")
|
||||
return base64.b64decode(str(raw))
|
||||
except Exception:
|
||||
logger.exception("读附件 Redis 交接失败 key=%s", key)
|
||||
return b""
|
||||
|
||||
|
||||
def _store_bytes(object_key: str, raw: bytes, *, put_fn: Optional[PutFn]) -> None:
|
||||
_stash_put(object_key, raw)
|
||||
_blob_put(object_key, raw)
|
||||
if put_fn is not None:
|
||||
try:
|
||||
put_fn(key=object_key, data=raw, content_type="application/octet-stream")
|
||||
except Exception:
|
||||
logger.exception("附件落存储失败 key=%s", object_key)
|
||||
return
|
||||
try:
|
||||
from agent.config import get_settings
|
||||
from agent.storage.s3_client import S3ObjectStorage
|
||||
|
||||
S3ObjectStorage.from_settings(get_settings(), allow_network=False).put_bytes(
|
||||
key=object_key, data=raw, content_type="application/octet-stream"
|
||||
)
|
||||
except Exception:
|
||||
logger.info("附件对象存储跳过 key=%s", object_key)
|
||||
|
||||
|
||||
def _load_bytes(object_key: str) -> bytes:
|
||||
hit = _stash_get(object_key)
|
||||
if hit:
|
||||
return hit
|
||||
blob = _blob_get(object_key)
|
||||
if blob:
|
||||
return blob
|
||||
try:
|
||||
from agent.config import get_settings
|
||||
from agent.storage.s3_client import S3ObjectStorage
|
||||
|
||||
settings = get_settings()
|
||||
got = S3ObjectStorage.from_settings(
|
||||
settings,
|
||||
allow_network=bool(settings.llm_allow_network),
|
||||
).get_bytes(key=object_key)
|
||||
data = bytes(got.get("data") or b"")
|
||||
logger.info("读附件对象存储 key=%s bytes=%s", object_key, len(data))
|
||||
return data
|
||||
except Exception:
|
||||
logger.exception("读附件字节失败 key=%s", object_key)
|
||||
return b""
|
||||
|
||||
|
||||
def _recognize_file(
|
||||
*,
|
||||
sender_id: str,
|
||||
index: int,
|
||||
filename: str,
|
||||
raw: bytes,
|
||||
object_key: str,
|
||||
extract_fn: Optional[ExtractFn],
|
||||
attach_text_fn: Optional[AttachTextFn],
|
||||
) -> None:
|
||||
book = get_attach_wave_book()
|
||||
blob = raw or b""
|
||||
logger.info(
|
||||
"attachment 认文件 sender=%s idx=%s name=%s bytes=%s",
|
||||
sender_id,
|
||||
index,
|
||||
filename,
|
||||
len(blob),
|
||||
)
|
||||
if not blob:
|
||||
book.complete_material(sender_id, index, object_key=object_key, error="empty_bytes")
|
||||
return
|
||||
extracted = (extract_fn or extract_attachment_text)(filename, blob)
|
||||
raw_text = str(extracted.get("text") or "")
|
||||
err = str(extracted.get("error") or "")
|
||||
organized = raw_text
|
||||
org_err = err
|
||||
if raw_text.strip():
|
||||
if attach_text_fn is not None:
|
||||
packed = attach_text_fn(payload={"raw_text": raw_text}) or {}
|
||||
else:
|
||||
from agent.config import get_settings
|
||||
from agent.llm.mode_attachment_text import invoke_attachment_text
|
||||
|
||||
settings = get_settings()
|
||||
packed = invoke_attachment_text(
|
||||
payload={"raw_text": raw_text},
|
||||
allow_network=bool(settings.llm_allow_network and settings.llm_data_usage_confirmed),
|
||||
)
|
||||
if packed.get("ok") and str(packed.get("text") or "").strip():
|
||||
organized = str(packed.get("text") or "")
|
||||
elif not packed.get("ok"):
|
||||
org_err = str(packed.get("error") or org_err or "attachment_text_failed")
|
||||
book.complete_material(
|
||||
sender_id,
|
||||
index,
|
||||
text=organized,
|
||||
object_key=object_key,
|
||||
error=org_err if not (organized or "").strip() else "",
|
||||
)
|
||||
|
||||
|
||||
def _recognize_image(
|
||||
*,
|
||||
sender_id: str,
|
||||
index: int,
|
||||
media_id: str,
|
||||
filename: str,
|
||||
vision_fn: Optional[VisionFn],
|
||||
download_fn: Optional[DownloadFn],
|
||||
put_fn: Optional[PutFn],
|
||||
) -> None:
|
||||
import base64
|
||||
|
||||
from agent.llm.mode_vision import sniff_image_mime
|
||||
|
||||
book = get_attach_wave_book()
|
||||
err = ""
|
||||
raw = b""
|
||||
if download_fn is None:
|
||||
raw, err = b"", "no_download"
|
||||
else:
|
||||
raw, err = download_fn(media_id)
|
||||
if not raw:
|
||||
book.complete_material(sender_id, index, error=err or "media_empty")
|
||||
return
|
||||
mime = sniff_image_mime(raw) or "image/jpeg"
|
||||
object_key = f"inquiry-attach/{sender_id}/{media_id or filename}"
|
||||
_store_bytes(object_key, raw, put_fn=put_fn)
|
||||
vision: dict[str, Any] = {"ok": False, "text": "", "empty": True, "error": err or "no_vision"}
|
||||
if vision_fn is not None:
|
||||
b64 = base64.b64encode(raw).decode("ascii")
|
||||
vision = vision_fn(payload={"image_b64": b64, "mime": mime, "media_id": media_id}) or vision
|
||||
else:
|
||||
from agent.config import get_settings
|
||||
from agent.llm.mode_vision import invoke_vision
|
||||
|
||||
settings = get_settings()
|
||||
vision = invoke_vision(
|
||||
payload={"image_b64": base64.b64encode(raw).decode("ascii"), "mime": mime},
|
||||
allow_network=bool(settings.llm_allow_network and settings.llm_data_usage_confirmed),
|
||||
)
|
||||
text = str(vision.get("text") or "")
|
||||
if not vision.get("ok"):
|
||||
err = str(vision.get("error") or err or "vision_failed")
|
||||
book.complete_material(
|
||||
sender_id,
|
||||
index,
|
||||
text=text,
|
||||
object_key=object_key,
|
||||
error=err if not text.strip() else "",
|
||||
)
|
||||
Reference in New Issue
Block a user