745 lines
26 KiB
Python
745 lines
26 KiB
Python
"""
|
|
意图:私聊附件询价。
|
|
|
|
本文件职责:发卡等材料 → 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]]
|
|
|
|
|
|
def prefer_extracted_oral(raw_text: str, organized: str) -> str:
|
|
"""
|
|
千问把报价单整理成空、几个字或几十个字时,改用抽出的单元格原文。
|
|
|
|
测服复现:陆运 997 字被压成 48 字、海运 1325 字被压成 52 字,
|
|
多段补问就只剩货量/运输分类,起运港目的港整段待补充。
|
|
整理结果够长、没有被大幅压短时仍用整理后的,方便人读。
|
|
"""
|
|
raw = (raw_text or "").strip()
|
|
org = (organized or "").strip()
|
|
if not raw:
|
|
return org
|
|
if not org:
|
|
return raw
|
|
# 报价单原文很长、整理后只剩几分之一:用原文。997→48、1325→52 都走这里。
|
|
if len(raw) >= 200 and len(org) * 8 < len(raw):
|
|
return raw
|
|
if len(org) < 8 and len(raw) >= 40:
|
|
return raw
|
|
if len(org) < 40 and len(org) * 5 < len(raw):
|
|
return raw
|
|
return org
|
|
|
|
_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
|
|
from agent.policy.multi_text_flow import get_multi_text_flow
|
|
|
|
_drop(sender_id)
|
|
get_multi_text_flow().drop_current_bookmark(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 is_idle_attachment_wait(sender_id: str, *, now: Optional[float] = None) -> bool:
|
|
"""已发卡但还没传 Excel/PDF/图。此时销售打字应走文字询价。"""
|
|
wave = get_attach_wave_book().active(sender_id, now=now)
|
|
return wave is not None and not wave.started
|
|
|
|
|
|
def abandon_idle_attach_wait(sender_id: str) -> bool:
|
|
"""
|
|
作废未开始认的等待票。已开始认返回 False。
|
|
|
|
副作用:旧上传卡随后按过期拒绝。
|
|
"""
|
|
return get_attach_wave_book().abandon_if_idle(sender_id)
|
|
|
|
|
|
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 {},
|
|
)
|
|
# 认完附件一律新开:不能粘在发卡前留下的多段/单段补问草稿上。
|
|
drop_all_private_bookmarks(sender_id)
|
|
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 (
|
|
not wo
|
|
and sess
|
|
and wave
|
|
and (sess.business_line or "").upper() == "SEA"
|
|
):
|
|
# 海运先出核对卡,工单要等确定。附件键先留在书签上。
|
|
files = []
|
|
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"
|
|
files.append(
|
|
{
|
|
"file_name": name,
|
|
"object_key": key,
|
|
"content_type": ctype,
|
|
}
|
|
)
|
|
for engine in (get_sea_text_flow(), get_air_text_flow(), get_land_text_flow()):
|
|
hit = engine.session_of(sender_id)
|
|
if hit is sess:
|
|
engine.remember_pending_files(sender_id, files)
|
|
break
|
|
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 = prefer_extracted_oral(raw_text, str(packed.get("text") or ""))
|
|
elif not packed.get("ok"):
|
|
org_err = str(packed.get("error") or org_err or "attachment_text_failed")
|
|
logger.info(
|
|
"attachment 认完 sender=%s idx=%s name=%s raw_len=%s oral_len=%s err=%s",
|
|
sender_id,
|
|
index,
|
|
filename,
|
|
len(raw_text),
|
|
len((organized or "").strip()),
|
|
org_err or "-",
|
|
)
|
|
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 "",
|
|
)
|