落地并存测服全端:智能体 Graph/路由、后台切 8180、主账与部署脚本齐备,便于线上重发与验收。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-13 21:15:58 +08:00
co-authored by Cursor
parent 0eb1adecc8
commit 8103b3b651
143 changed files with 8968 additions and 277 deletions
+185 -35
View File
@@ -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)
+62
View File
@@ -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": "存档壳:未下载未登记"}
+146
View File
@@ -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,
)
)
+102
View File
@@ -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)
+80
View File
@@ -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)
+267
View File
@@ -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)
+79
View File
@@ -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": "识别壳:未跑真实抽取",
}
+82
View File
@@ -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