落地并存测服全端:智能体 Graph/路由、后台切 8180、主账与部署脚本齐备,便于线上重发与验收。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
任务槽与认领循环:识别、LibreOffice、Archive 等。
|
||||
任务槽与认领循环:识别、LibreOffice、Archive、wake、LLM Stream。
|
||||
|
||||
本包约束(硬):LibreOffice 仅 1 槽;HTTP 类 2~3 槽;inbox/outbox 不得与 soffice 串同一阻塞线。
|
||||
禁止:一把大锁串行所有工单;禁止 wecom:worker:singleton。
|
||||
@@ -11,7 +11,18 @@ import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
from typing import Callable, Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.graph.runtime import GraphRuntime
|
||||
from agent.jobs.archive import archive_stream, process_archive_stub
|
||||
from agent.jobs.libreoffice import LibreOfficeConverter
|
||||
from agent.jobs.pdf_job import pdf_stream, process_pdf_job
|
||||
from agent.jobs.recognition import process_recognition_stub
|
||||
from agent.jobs.wake_consumer import claim_and_apply_wake, wake_stream
|
||||
from agent.llm import LlmGateway, LlmMode
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
from agent.routing import DeepSeekARouter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,7 +33,6 @@ class SlotPool:
|
||||
进程内有限槽线程池骨架。
|
||||
|
||||
用途:限制同类慢活并发(尤其 LibreOffice=1)。
|
||||
为何不用进程级 Redis 单例:同机只应起一份 Worker 服务;防重入靠任务认领。
|
||||
"""
|
||||
|
||||
name: str
|
||||
@@ -35,11 +45,7 @@ class SlotPool:
|
||||
self._sem = threading.BoundedSemaphore(self.size)
|
||||
|
||||
def run(self, fn: Callable[[], None], *, label: str = "") -> None:
|
||||
"""
|
||||
占用一个槽执行 fn;槽满则阻塞等待(调用方应在 Worker 线程,勿在 HTTP 回调线程调用)。
|
||||
|
||||
副作用:执行 fn;不写库(骨架)。
|
||||
"""
|
||||
"""占用一个槽执行 fn;勿在 HTTP 回调线程调用。"""
|
||||
acquired = self._sem.acquire(timeout=30)
|
||||
if not acquired:
|
||||
logger.warning("槽超时未获取 name=%s label=%s", self.name, label)
|
||||
@@ -53,20 +59,33 @@ class SlotPool:
|
||||
@dataclass
|
||||
class WorkerRuntime:
|
||||
"""
|
||||
Worker 运行时:持有分槽与 inbox/outbox 占位线程。
|
||||
Worker 运行时:分槽 + wake / LLM / 识别 / 存档 / LibreOffice 循环。
|
||||
|
||||
启动时机:worker_app main;停止:进程退出或 stop()。
|
||||
禁止:把所有任务丢进 LibreOffice 槽。
|
||||
启动时机:worker_app main。
|
||||
"""
|
||||
|
||||
libreoffice: SlotPool
|
||||
http_jobs: SlotPool
|
||||
settings: Settings
|
||||
graph: Optional[GraphRuntime] = None
|
||||
redis: Optional[RedisRuntime] = None
|
||||
llm: Optional[LlmGateway] = None
|
||||
_stop: threading.Event = field(default_factory=threading.Event)
|
||||
_threads: list[threading.Thread] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, libreoffice_slots: int, http_job_slots: int) -> "WorkerRuntime":
|
||||
"""按配置创建槽;强制 LibreOffice 槽为 1,防止误配双 soffice。"""
|
||||
def from_settings(
|
||||
cls,
|
||||
libreoffice_slots: int,
|
||||
http_job_slots: int,
|
||||
*,
|
||||
graph: Optional[GraphRuntime] = None,
|
||||
redis: Optional[RedisRuntime] = None,
|
||||
llm: Optional[LlmGateway] = None,
|
||||
settings: Optional[Settings] = None,
|
||||
) -> "WorkerRuntime":
|
||||
"""按配置创建槽;强制 LibreOffice 槽为 1。"""
|
||||
cfg = settings or get_settings()
|
||||
lo = 1 if libreoffice_slots != 1 else libreoffice_slots
|
||||
if libreoffice_slots != 1:
|
||||
logger.warning(
|
||||
@@ -76,47 +95,178 @@ class WorkerRuntime:
|
||||
return cls(
|
||||
libreoffice=SlotPool("libreoffice", lo),
|
||||
http_jobs=SlotPool("http_jobs", max(2, http_job_slots)),
|
||||
settings=cfg,
|
||||
graph=graph,
|
||||
redis=redis,
|
||||
llm=llm,
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
启动 inbox / outbox / 空转巡检线程(骨架:只心跳日志,不消费真实队列)。
|
||||
|
||||
副作用:创建守护线程;不连 Redis(骨架)。
|
||||
"""
|
||||
"""启动分槽消费循环。"""
|
||||
self._stop.clear()
|
||||
self._threads = [
|
||||
threading.Thread(target=self._inbox_loop, name="inbox-loop", daemon=True),
|
||||
threading.Thread(target=self._outbox_loop, name="outbox-loop", daemon=True),
|
||||
threading.Thread(target=self._http_slot_idle, name="http-slots-idle", daemon=True),
|
||||
threading.Thread(target=self._wake_loop, name="wake-loop", daemon=True),
|
||||
threading.Thread(target=self._llm_loop, name="llm-loop", daemon=True),
|
||||
threading.Thread(target=self._recognition_loop, name="recog-loop", daemon=True),
|
||||
threading.Thread(target=self._archive_loop, name="archive-loop", daemon=True),
|
||||
threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True),
|
||||
]
|
||||
for t in self._threads:
|
||||
t.start()
|
||||
lo = LibreOfficeConverter.from_settings(self.settings)
|
||||
logger.info(
|
||||
"WorkerRuntime 已启动 libreoffice_slots=%s http_job_slots=%s",
|
||||
"WorkerRuntime 已启动 libreoffice_slots=%s http_job_slots=%s soffice=%s",
|
||||
self.libreoffice.size,
|
||||
self.http_jobs.size,
|
||||
"ok" if lo.available() else "missing",
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""请求停止占位循环;不保证立刻打断正在跑的槽任务(骨架无真实任务)。"""
|
||||
self._stop.set()
|
||||
|
||||
def _inbox_loop(self) -> None:
|
||||
"""inbox 独立线程:后续从 PG/队列认领入站;禁止与 soffice 共用阻塞队列。"""
|
||||
def _wake_loop(self) -> None:
|
||||
"""HTTP 类槽消费结构化 wake → Graph。"""
|
||||
if not self.redis or not self.graph:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
q = wake_stream(self.redis.client)
|
||||
consumer = f"wake-{threading.get_ident()}"
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
|
||||
def _outbox_loop(self) -> None:
|
||||
"""outbox 独立线程:后续发送企微;失败重试;过期丢弃不计业务成功。"""
|
||||
def _job() -> None:
|
||||
claim_and_apply_wake(queue=q, graph=self.graph, consumer=consumer)
|
||||
|
||||
self.http_jobs.run(_job, label="wake")
|
||||
time.sleep(0.2)
|
||||
|
||||
def _llm_loop(self) -> None:
|
||||
"""
|
||||
HTTP 类槽消费 LLM Stream。
|
||||
|
||||
ordered_actions:走 DeepSeekARouter(默认 stub;LLM_ALLOW_NETWORK 才可能真网)。
|
||||
其它 mode:骨架 stub。
|
||||
"""
|
||||
if not self.redis or not self.llm:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
stream = self.redis.llm_stream
|
||||
stream.ensure_group()
|
||||
consumer = f"llm-{threading.get_ident()}"
|
||||
router = DeepSeekARouter.from_settings(
|
||||
self.settings,
|
||||
allow_network=bool(self.settings.llm_allow_network),
|
||||
)
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
|
||||
def _http_slot_idle(self) -> None:
|
||||
"""证明 HTTP 类槽可并行占用;骨架不跑真实 LLM。"""
|
||||
def _job() -> None:
|
||||
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return
|
||||
entry_id, data = claimed[0]
|
||||
mode = str(data.get("mode") or "ordered_actions")
|
||||
payload = dict(data.get("payload") or {})
|
||||
try:
|
||||
if mode == LlmMode.ORDERED_ACTIONS.value:
|
||||
decision = router.route(
|
||||
text=str(payload.get("text") or ""),
|
||||
sender_id=str(payload.get("sender_id") or ""),
|
||||
)
|
||||
logger.info(
|
||||
"llm 槽 A 路由 id=%s intent=%s",
|
||||
entry_id,
|
||||
decision.intent,
|
||||
)
|
||||
else:
|
||||
result = self.llm.invoke_stub(mode=mode, payload=payload)
|
||||
logger.info(
|
||||
"llm 槽处理 id=%s mode=%s stub=%s",
|
||||
entry_id,
|
||||
mode,
|
||||
result.get("stub"),
|
||||
)
|
||||
finally:
|
||||
stream.ack(entry_id)
|
||||
|
||||
self.http_jobs.run(_job, label="llm")
|
||||
time.sleep(0.2)
|
||||
|
||||
def _recognition_loop(self) -> None:
|
||||
"""HTTP 类槽消费识别 Stream。"""
|
||||
if not self.redis:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
stream = self.redis.recognition_stream
|
||||
stream.ensure_group()
|
||||
consumer = f"recog-{threading.get_ident()}"
|
||||
while not self._stop.is_set():
|
||||
def _noop() -> None:
|
||||
time.sleep(0.05)
|
||||
|
||||
self.http_jobs.run(_noop, label="idle-ping")
|
||||
time.sleep(5.0)
|
||||
def _job() -> None:
|
||||
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return
|
||||
entry_id, data = claimed[0]
|
||||
try:
|
||||
process_recognition_stub(data)
|
||||
finally:
|
||||
stream.ack(entry_id)
|
||||
|
||||
self.http_jobs.run(_job, label="recognition")
|
||||
time.sleep(0.2)
|
||||
|
||||
def _archive_loop(self) -> None:
|
||||
"""HTTP 类槽消费存档 Stream(与 soffice 隔离)。"""
|
||||
if not self.redis:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
stream = archive_stream(self.redis)
|
||||
stream.ensure_group()
|
||||
consumer = f"arch-{threading.get_ident()}"
|
||||
while not self._stop.is_set():
|
||||
|
||||
def _job() -> None:
|
||||
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return
|
||||
entry_id, data = claimed[0]
|
||||
try:
|
||||
process_archive_stub(data)
|
||||
finally:
|
||||
stream.ack(entry_id)
|
||||
|
||||
self.http_jobs.run(_job, label="archive")
|
||||
time.sleep(0.2)
|
||||
|
||||
def _pdf_loop(self) -> None:
|
||||
"""仅 LibreOffice 单槽消费 PDF Stream。"""
|
||||
if not self.redis:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
stream = pdf_stream(self.redis)
|
||||
stream.ensure_group()
|
||||
converter = LibreOfficeConverter.from_settings(self.settings)
|
||||
consumer = f"pdf-{threading.get_ident()}"
|
||||
while not self._stop.is_set():
|
||||
|
||||
def _job() -> None:
|
||||
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return
|
||||
entry_id, data = claimed[0]
|
||||
try:
|
||||
result = process_pdf_job(data, converter=converter)
|
||||
logger.info(
|
||||
"pdf 槽 id=%s ok=%s err=%s",
|
||||
entry_id,
|
||||
result.ok,
|
||||
result.error or "-",
|
||||
)
|
||||
finally:
|
||||
stream.ack(entry_id)
|
||||
|
||||
self.libreoffice.run(_job, label="pdf")
|
||||
time.sleep(0.3)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
微盛存档任务壳:独立 Stream,与 LibreOffice 槽隔离。
|
||||
|
||||
本文件职责:enqueue / process_stub;正式下载与登记后续接对象存储 + 主账附件元数据。
|
||||
禁止:与 soffice 共用阻塞线;禁止把存档成功冒充业务成交成功。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.redis_coord import get_redis_runtime, try_acquire_idempotency
|
||||
from agent.redis_coord.keys import STREAM_ARCHIVE
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
from agent.redis_coord.stream_queue import StreamQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchiveEnqueueResult:
|
||||
accepted: bool
|
||||
entry_id: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def archive_stream(redis: Optional[RedisRuntime] = None) -> StreamQueue:
|
||||
"""存档 Stream(懒创建消费组由调用方 ensure)。"""
|
||||
rt = redis or get_redis_runtime()
|
||||
return StreamQueue(client=rt.client, stream_suffix=STREAM_ARCHIVE)
|
||||
|
||||
|
||||
def enqueue_archive(
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
redis: Optional[RedisRuntime] = None,
|
||||
) -> ArchiveEnqueueResult:
|
||||
"""存档任务入队(快回)。"""
|
||||
rt = redis or get_redis_runtime()
|
||||
q = archive_stream(rt)
|
||||
q.ensure_group()
|
||||
if idempotency_key:
|
||||
ok = try_acquire_idempotency(
|
||||
rt.client,
|
||||
scope="archive",
|
||||
idempotency_key=idempotency_key,
|
||||
ttl_seconds=600,
|
||||
)
|
||||
if not ok:
|
||||
return ArchiveEnqueueResult(accepted=False, reason="duplicate_idempotency_key")
|
||||
entry_id = q.enqueue(payload=payload, idempotency_key=idempotency_key)
|
||||
logger.info("archive.enqueue id=%s", entry_id)
|
||||
return ArchiveEnqueueResult(accepted=True, entry_id=entry_id)
|
||||
|
||||
|
||||
def process_archive_stub(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""存档占位:不访问微盛、不写对象存储。"""
|
||||
logger.info("archive.stub keys=%s", sorted(data.keys()))
|
||||
return {"ok": True, "stub": True, "note": "存档壳:未下载未登记"}
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
死信壳:失败任务落库 / 内存,不含自动重放业务。
|
||||
|
||||
本文件职责:enqueue_dead_letter;正式重放由运维/后续模块显式触发。
|
||||
禁止:把死信成功当成业务成功。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.runtime_db import assert_safe_database, connect_psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeadLetterRecord:
|
||||
id: str
|
||||
source: str
|
||||
source_id: str = ""
|
||||
job_type: str = ""
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
error: str = ""
|
||||
attempts: int = 0
|
||||
|
||||
|
||||
class MemoryDeadLetterStore:
|
||||
"""内存死信壳。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._rows: list[DeadLetterRecord] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def enqueue(self, record: DeadLetterRecord) -> str:
|
||||
with self._lock:
|
||||
if not record.id:
|
||||
record.id = str(uuid.uuid4())
|
||||
self._rows.append(record)
|
||||
logger.info(
|
||||
"dead_letter.memory source=%s type=%s id=%s",
|
||||
record.source,
|
||||
record.job_type,
|
||||
record.id,
|
||||
)
|
||||
return record.id
|
||||
|
||||
def list_recent(self, limit: int = 20) -> list[DeadLetterRecord]:
|
||||
with self._lock:
|
||||
return list(reversed(self._rows[-limit:]))
|
||||
|
||||
|
||||
class PostgresDeadLetterStore:
|
||||
"""PG agent_dead_letter 壳(须 002 迁移)。"""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
assert_safe_database(dsn)
|
||||
self._dsn = dsn
|
||||
|
||||
def enqueue(self, record: DeadLetterRecord) -> str:
|
||||
did = record.id or str(uuid.uuid4())
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_dead_letter (
|
||||
id, source, source_id, job_type, payload, error, attempts
|
||||
) VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)
|
||||
""",
|
||||
(
|
||||
did,
|
||||
record.source,
|
||||
record.source_id,
|
||||
record.job_type,
|
||||
json.dumps(record.payload, ensure_ascii=False),
|
||||
record.error,
|
||||
record.attempts,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("dead_letter.pg id=%s source=%s", did, record.source)
|
||||
return did
|
||||
|
||||
def list_recent(self, limit: int = 20) -> list[DeadLetterRecord]:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, source, source_id, job_type, payload, error, attempts
|
||||
FROM agent_dead_letter
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [
|
||||
DeadLetterRecord(
|
||||
id=str(r[0]),
|
||||
source=str(r[1]),
|
||||
source_id=str(r[2] or ""),
|
||||
job_type=str(r[3] or ""),
|
||||
payload=dict(r[4] or {}),
|
||||
error=str(r[5] or ""),
|
||||
attempts=int(r[6] or 0),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def create_dead_letter_store(*, dsn: str = "", backend: str = "auto"):
|
||||
"""工厂。"""
|
||||
if backend == "memory" or not dsn:
|
||||
return MemoryDeadLetterStore()
|
||||
return PostgresDeadLetterStore(dsn)
|
||||
|
||||
|
||||
def enqueue_dead_letter(
|
||||
*,
|
||||
source: str,
|
||||
error: str,
|
||||
job_type: str = "",
|
||||
source_id: str = "",
|
||||
payload: Optional[dict[str, Any]] = None,
|
||||
attempts: int = 0,
|
||||
store=None,
|
||||
) -> str:
|
||||
"""
|
||||
统一入死信入口(框架)。
|
||||
|
||||
store 为空则用内存临时店(调用方应注入进程级单例)。
|
||||
"""
|
||||
st = store or MemoryDeadLetterStore()
|
||||
return st.enqueue(
|
||||
DeadLetterRecord(
|
||||
id=str(uuid.uuid4()),
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
job_type=job_type,
|
||||
payload=dict(payload or {}),
|
||||
error=error,
|
||||
attempts=attempts,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
LibreOffice Excel→PDF 转换壳。
|
||||
|
||||
本文件职责:探测 soffice、在「调用方已占用 LibreOffice 单槽」的前提下同步转 PDF。
|
||||
禁止:在企微回调 / H5 线程调用;禁止本进程同时起两台 soffice(由 SlotPool size=1 保证)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LibreOfficeResult:
|
||||
"""转换结果;ok=False 时 error 说明原因,不抛到 Worker 主循环。"""
|
||||
|
||||
ok: bool
|
||||
pdf_path: str = ""
|
||||
error: str = ""
|
||||
returncode: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class LibreOfficeConverter:
|
||||
"""
|
||||
soffice 无界面转换器。
|
||||
|
||||
调用约定:仅从 WorkerRuntime.libreoffice 槽内进入 convert()。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Optional[Settings] = None) -> "LibreOfficeConverter":
|
||||
return cls(settings=settings or get_settings())
|
||||
|
||||
def executable(self) -> Path:
|
||||
return Path(self.settings.libreoffice_path)
|
||||
|
||||
def available(self) -> bool:
|
||||
"""本机是否存在配置的 soffice 可执行文件。"""
|
||||
return self.executable().is_file()
|
||||
|
||||
def convert_xlsx_to_pdf(self, xlsx_path: str | Path) -> LibreOfficeResult:
|
||||
"""
|
||||
将 Excel 转为同目录 PDF。
|
||||
|
||||
副作用:启动 soffice 子进程(超时见 settings);成功写 PDF 文件。
|
||||
线程:必须已在 LibreOffice 单槽内。
|
||||
"""
|
||||
source = Path(xlsx_path).resolve()
|
||||
if not source.is_file():
|
||||
return LibreOfficeResult(ok=False, error="source_not_found")
|
||||
exe = self.executable()
|
||||
if not exe.is_file():
|
||||
return LibreOfficeResult(ok=False, error="libreoffice_not_installed")
|
||||
out_dir = source.parent
|
||||
target = source.with_suffix(".pdf")
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
str(exe),
|
||||
"--headless",
|
||||
"--convert-to",
|
||||
"pdf",
|
||||
"--outdir",
|
||||
str(out_dir),
|
||||
str(source),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=float(self.settings.libreoffice_timeout_seconds),
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
logger.warning("LibreOffice 执行失败: %s", exc)
|
||||
return LibreOfficeResult(ok=False, error=f"exec_failed:{exc}")
|
||||
if completed.returncode != 0 or not target.is_file():
|
||||
stderr = (completed.stderr or b"")[:400]
|
||||
logger.warning(
|
||||
"LibreOffice 未生成 PDF rc=%s stderr=%s",
|
||||
completed.returncode,
|
||||
stderr,
|
||||
)
|
||||
return LibreOfficeResult(
|
||||
ok=False,
|
||||
error="pdf_not_generated",
|
||||
returncode=int(completed.returncode),
|
||||
)
|
||||
head = target.read_bytes()[:5]
|
||||
if head != b"%PDF-":
|
||||
target.unlink(missing_ok=True)
|
||||
return LibreOfficeResult(ok=False, error="pdf_header_invalid")
|
||||
logger.info("LibreOffice 转 PDF 成功 path=%s", target)
|
||||
return LibreOfficeResult(ok=True, pdf_path=str(target), returncode=0)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
报价 PDF 任务壳:入队后由 LibreOffice **单槽**消费。
|
||||
|
||||
本文件职责:enqueue_pdf_job / process 调用 LibreOfficeConverter。
|
||||
禁止:在 http_jobs 槽跑 soffice;禁止回调线程转换。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.jobs.libreoffice import LibreOfficeConverter, LibreOfficeResult
|
||||
from agent.redis_coord import get_redis_runtime, try_acquire_idempotency
|
||||
from agent.redis_coord.keys import STREAM_PDF
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
from agent.redis_coord.stream_queue import StreamQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PdfEnqueueResult:
|
||||
accepted: bool
|
||||
entry_id: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def pdf_stream(redis: Optional[RedisRuntime] = None) -> StreamQueue:
|
||||
rt = redis or get_redis_runtime()
|
||||
return StreamQueue(client=rt.client, stream_suffix=STREAM_PDF)
|
||||
|
||||
|
||||
def enqueue_pdf_job(
|
||||
*,
|
||||
xlsx_path: str,
|
||||
inquiry_no: str = "",
|
||||
quote_version: int = 0,
|
||||
idempotency_key: str = "",
|
||||
redis: Optional[RedisRuntime] = None,
|
||||
) -> PdfEnqueueResult:
|
||||
"""Excel→PDF 任务入队;真正转换只在 LibreOffice 槽。"""
|
||||
rt = redis or get_redis_runtime()
|
||||
q = pdf_stream(rt)
|
||||
q.ensure_group()
|
||||
if idempotency_key:
|
||||
ok = try_acquire_idempotency(
|
||||
rt.client,
|
||||
scope="pdf",
|
||||
idempotency_key=idempotency_key,
|
||||
ttl_seconds=600,
|
||||
)
|
||||
if not ok:
|
||||
return PdfEnqueueResult(accepted=False, reason="duplicate_idempotency_key")
|
||||
payload = {
|
||||
"xlsx_path": xlsx_path,
|
||||
"inquiry_no": inquiry_no,
|
||||
"quote_version": quote_version,
|
||||
}
|
||||
entry_id = q.enqueue(payload=payload, idempotency_key=idempotency_key)
|
||||
logger.info("pdf.enqueue id=%s inquiry=%s", entry_id, inquiry_no or "-")
|
||||
return PdfEnqueueResult(accepted=True, entry_id=entry_id)
|
||||
|
||||
|
||||
def process_pdf_job(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
converter: Optional[LibreOfficeConverter] = None,
|
||||
) -> LibreOfficeResult:
|
||||
"""
|
||||
处理一条 PDF 任务(调用方必须已占用 libreoffice 槽)。
|
||||
|
||||
缺 xlsx_path 或本机无 soffice 时返回 ok=False,不抛崩 Worker。
|
||||
"""
|
||||
path = str(data.get("xlsx_path") or "")
|
||||
conv = converter or LibreOfficeConverter.from_settings()
|
||||
if not path:
|
||||
return LibreOfficeResult(ok=False, error="missing_xlsx_path")
|
||||
return conv.convert_xlsx_to_pdf(path)
|
||||
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
PG agent_job 认领壳(与 Redis Stream 二选一真相时用)。
|
||||
|
||||
本文件职责:enqueue / claim / complete / requeue_expired;不含业务处理体。
|
||||
禁止:一把大锁串行所有工单;禁止 Redis wecom:worker:singleton。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.runtime_db import assert_safe_database, connect_psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobRecord:
|
||||
"""一条任务行。"""
|
||||
|
||||
id: str
|
||||
job_type: str
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
state: str = "pending"
|
||||
idempotency_key: str = ""
|
||||
attempts: int = 0
|
||||
last_error: str = ""
|
||||
|
||||
|
||||
class MemoryJobStore:
|
||||
"""内存任务认领壳。"""
|
||||
|
||||
def __init__(self, *, claim_timeout_sec: float = 180.0) -> None:
|
||||
self._rows: dict[str, JobRecord] = {}
|
||||
self._claimed_at: dict[str, float] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._claim_timeout = claim_timeout_sec
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
job_type: str,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
) -> tuple[bool, str]:
|
||||
with self._lock:
|
||||
if idempotency_key:
|
||||
for j in self._rows.values():
|
||||
if j.job_type == job_type and j.idempotency_key == idempotency_key:
|
||||
return False, "duplicate_idempotency_key"
|
||||
job_id = str(uuid.uuid4())
|
||||
self._rows[job_id] = JobRecord(
|
||||
id=job_id,
|
||||
job_type=job_type,
|
||||
payload=dict(payload),
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
return True, job_id
|
||||
|
||||
def claim(self, *, worker_id: str, job_type: str = "") -> Optional[JobRecord]:
|
||||
import time
|
||||
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
for jid, job in self._rows.items():
|
||||
if job_type and job.job_type != job_type:
|
||||
continue
|
||||
if job.state == "pending" or (
|
||||
job.state == "claimed"
|
||||
and now - self._claimed_at.get(jid, 0) > self._claim_timeout
|
||||
):
|
||||
job.state = "claimed"
|
||||
job.attempts += 1
|
||||
self._claimed_at[jid] = now
|
||||
return JobRecord(
|
||||
id=job.id,
|
||||
job_type=job.job_type,
|
||||
payload=dict(job.payload),
|
||||
state=job.state,
|
||||
idempotency_key=job.idempotency_key,
|
||||
attempts=job.attempts,
|
||||
last_error=job.last_error,
|
||||
)
|
||||
return None
|
||||
|
||||
def complete(self, job_id: str, *, error: str = "", result: Optional[dict] = None) -> None:
|
||||
with self._lock:
|
||||
job = self._rows.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
job.state = "failed" if error else "done"
|
||||
job.last_error = error
|
||||
logger.info("job.memory.complete id=%s state=%s", job_id, job.state)
|
||||
|
||||
|
||||
class PostgresJobStore:
|
||||
"""PG agent_job 认领壳。"""
|
||||
|
||||
def __init__(self, dsn: str, *, claim_timeout_sec: float = 180.0) -> None:
|
||||
assert_safe_database(dsn)
|
||||
self._dsn = dsn
|
||||
self._claim_timeout = int(claim_timeout_sec)
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
job_type: str,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
) -> tuple[bool, str]:
|
||||
job_id = str(uuid.uuid4())
|
||||
try:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_job (id, job_type, idempotency_key, payload, state)
|
||||
VALUES (%s, %s, %s, %s::jsonb, 'pending')
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
job_type,
|
||||
idempotency_key,
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return True, job_id
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if "unique" in str(exc).lower() or "duplicate" in str(exc).lower():
|
||||
return False, "duplicate_idempotency_key"
|
||||
raise
|
||||
|
||||
def claim(self, *, worker_id: str, job_type: str = "") -> Optional[JobRecord]:
|
||||
type_filter = "AND job_type = %s" if job_type else ""
|
||||
params: list[Any] = [self._claim_timeout, worker_id]
|
||||
if job_type:
|
||||
params.append(job_type)
|
||||
sql = f"""
|
||||
WITH cte AS (
|
||||
SELECT id FROM agent_job
|
||||
WHERE (
|
||||
state = 'pending'
|
||||
OR (state = 'claimed' AND claimed_at < NOW() - (%s || ' seconds')::interval)
|
||||
)
|
||||
{type_filter}
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE agent_job j SET
|
||||
state = 'claimed',
|
||||
claimed_at = NOW(),
|
||||
claimed_by = %s,
|
||||
attempts = j.attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM cte WHERE j.id = cte.id
|
||||
RETURNING j.id, j.job_type, j.payload, j.state, j.idempotency_key, j.attempts, j.last_error
|
||||
"""
|
||||
# 参数顺序:timeout, [job_type], worker_id — 上面拼法易错,改用固定两段
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
if job_type:
|
||||
row = conn.execute(
|
||||
"""
|
||||
WITH cte AS (
|
||||
SELECT id FROM agent_job
|
||||
WHERE job_type = %s AND (
|
||||
state = 'pending'
|
||||
OR (state = 'claimed'
|
||||
AND claimed_at < NOW() - (%s || ' seconds')::interval)
|
||||
)
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE agent_job j SET
|
||||
state = 'claimed',
|
||||
claimed_at = NOW(),
|
||||
claimed_by = %s,
|
||||
attempts = j.attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM cte WHERE j.id = cte.id
|
||||
RETURNING j.id, j.job_type, j.payload, j.state,
|
||||
j.idempotency_key, j.attempts, j.last_error
|
||||
""",
|
||||
(job_type, str(self._claim_timeout), worker_id),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"""
|
||||
WITH cte AS (
|
||||
SELECT id FROM agent_job
|
||||
WHERE state = 'pending'
|
||||
OR (state = 'claimed'
|
||||
AND claimed_at < NOW() - (%s || ' seconds')::interval)
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE agent_job j SET
|
||||
state = 'claimed',
|
||||
claimed_at = NOW(),
|
||||
claimed_by = %s,
|
||||
attempts = j.attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM cte WHERE j.id = cte.id
|
||||
RETURNING j.id, j.job_type, j.payload, j.state,
|
||||
j.idempotency_key, j.attempts, j.last_error
|
||||
""",
|
||||
(str(self._claim_timeout), worker_id),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
if not row:
|
||||
return None
|
||||
return JobRecord(
|
||||
id=str(row[0]),
|
||||
job_type=str(row[1]),
|
||||
payload=dict(row[2] or {}),
|
||||
state=str(row[3]),
|
||||
idempotency_key=str(row[4] or ""),
|
||||
attempts=int(row[5] or 0),
|
||||
last_error=str(row[6] or ""),
|
||||
)
|
||||
|
||||
def complete(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
error: str = "",
|
||||
result: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
state = "failed" if error else "done"
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE agent_job SET
|
||||
state = %s,
|
||||
last_error = %s,
|
||||
result = %s::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
state,
|
||||
error,
|
||||
json.dumps(result or {}, ensure_ascii=False),
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("job.pg.complete id=%s state=%s", job_id, state)
|
||||
|
||||
|
||||
def create_job_store(*, dsn: str = "", backend: str = "auto", claim_timeout_sec: float = 180.0):
|
||||
"""工厂:Memory 或 PG。"""
|
||||
if backend == "memory" or not dsn:
|
||||
return MemoryJobStore(claim_timeout_sec=claim_timeout_sec)
|
||||
return PostgresJobStore(dsn, claim_timeout_sec=claim_timeout_sec)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
识别任务壳:入队 Redis Stream,Worker HTTP 槽认领后跑占位处理。
|
||||
|
||||
本文件职责:enqueue / process_stub;正式识别(附件/OCR/字段)后续按 payload.kind 分支。
|
||||
禁止:回调线程同步等识别完成;禁止本模块写主账六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.redis_coord import get_redis_runtime, try_acquire_idempotency
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
from agent.redis_coord.stream_queue import StreamQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecognitionEnqueueResult:
|
||||
accepted: bool
|
||||
entry_id: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def recognition_stream(redis: Optional[RedisRuntime] = None) -> StreamQueue:
|
||||
"""返回识别 Stream 句柄。"""
|
||||
rt = redis or get_redis_runtime()
|
||||
return rt.recognition_stream
|
||||
|
||||
|
||||
def enqueue_recognition(
|
||||
*,
|
||||
kind: str,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
redis: Optional[RedisRuntime] = None,
|
||||
) -> RecognitionEnqueueResult:
|
||||
"""
|
||||
识别任务入队(快回)。
|
||||
|
||||
kind 例:attachment_text / vision / sheet_map。
|
||||
"""
|
||||
rt = redis or get_redis_runtime()
|
||||
if idempotency_key:
|
||||
ok = try_acquire_idempotency(
|
||||
rt.client,
|
||||
scope="recognition",
|
||||
idempotency_key=idempotency_key,
|
||||
ttl_seconds=600,
|
||||
)
|
||||
if not ok:
|
||||
return RecognitionEnqueueResult(accepted=False, reason="duplicate_idempotency_key")
|
||||
body = {"kind": kind, "payload": payload}
|
||||
entry_id = rt.recognition_stream.enqueue(
|
||||
payload=body,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
logger.info("recognition.enqueue kind=%s id=%s", kind, entry_id)
|
||||
return RecognitionEnqueueResult(accepted=True, entry_id=entry_id)
|
||||
|
||||
|
||||
def process_recognition_stub(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
识别占位:回显 kind,不读附件、不调模型。
|
||||
|
||||
Worker 槽调用;正式实现替换为本函数旁的真实处理器。
|
||||
"""
|
||||
kind = str(data.get("kind") or "unknown")
|
||||
payload = data.get("payload") or {}
|
||||
logger.info("recognition.stub kind=%s keys=%s", kind, sorted(payload.keys()) if isinstance(payload, dict) else [])
|
||||
return {
|
||||
"ok": True,
|
||||
"stub": True,
|
||||
"kind": kind,
|
||||
"note": "识别壳:未跑真实抽取",
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Worker 侧:消费主账/文件完成等结构化 wake。
|
||||
|
||||
本文件职责:Redis Stream 入队/认领;在 HTTP 类槽内调 GraphRuntime.wake_from_worker。
|
||||
禁止:在企微回调线程调用;禁止用户文字冒充本路径。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.graph.runtime import GraphRuntime
|
||||
from agent.graph.wake import WorkerWakePayload
|
||||
from agent.redis_coord.client import RedisClient
|
||||
from agent.redis_coord.stream_queue import StreamQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STREAM_WAKE = "stream:wake"
|
||||
WAKE_GROUP = "inquiry-wake-workers"
|
||||
|
||||
|
||||
def wake_stream(client: RedisClient) -> StreamQueue:
|
||||
"""结构化 wake 专用 Stream。"""
|
||||
q = StreamQueue(client=client, stream_suffix=STREAM_WAKE, group=WAKE_GROUP)
|
||||
q.ensure_group()
|
||||
return q
|
||||
|
||||
|
||||
def enqueue_wake(client: RedisClient, payload: WorkerWakePayload) -> str:
|
||||
"""
|
||||
入队 wake(HTTP bridge 快回路径)。
|
||||
|
||||
副作用:XADD;不跑 Graph。
|
||||
"""
|
||||
payload.validate()
|
||||
q = wake_stream(client)
|
||||
return q.enqueue(
|
||||
payload={
|
||||
"thread_id": payload.thread_id,
|
||||
"inquiry_no": payload.inquiry_no,
|
||||
"quote_version": int(payload.quote_version),
|
||||
"wait_version": int(payload.wait_version),
|
||||
"kind": payload.kind,
|
||||
"detail": payload.detail or {},
|
||||
},
|
||||
idempotency_key=f"wake:{payload.thread_id}:{payload.wait_version}:{payload.kind}",
|
||||
)
|
||||
|
||||
|
||||
def claim_and_apply_wake(
|
||||
*,
|
||||
queue: StreamQueue,
|
||||
graph: GraphRuntime,
|
||||
consumer: str,
|
||||
) -> bool:
|
||||
"""
|
||||
认领 1 条并 wake Graph。成功/失败均 ack(骨架防堵死;正式应进死信)。
|
||||
|
||||
返回:是否领到任务。
|
||||
"""
|
||||
claimed = queue.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return False
|
||||
entry_id, data = claimed[0]
|
||||
try:
|
||||
wake = WorkerWakePayload(
|
||||
thread_id=str(data.get("thread_id") or ""),
|
||||
inquiry_no=str(data.get("inquiry_no") or ""),
|
||||
quote_version=int(data.get("quote_version") or 0),
|
||||
wait_version=int(data.get("wait_version") or 0),
|
||||
kind=str(data.get("kind") or ""),
|
||||
detail=dict(data.get("detail") or {}),
|
||||
)
|
||||
graph.wake_from_worker(wake)
|
||||
logger.info("wake 已处理 id=%s thread=%s kind=%s", entry_id, wake.thread_id, wake.kind)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("wake 处理失败 id=%s", entry_id)
|
||||
finally:
|
||||
queue.ack(entry_id)
|
||||
return True
|
||||
Reference in New Issue
Block a user