Files
inquiry_robot/inquiry-agent/agent/jobs/__init__.py
T

273 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
任务槽与认领循环:识别、LibreOffice、Archive、wake、LLM Stream。
本包约束(硬):LibreOffice 仅 1 槽;HTTP 类 2~3 槽;inbox/outbox 不得与 soffice 串同一阻塞线。
禁止:一把大锁串行所有工单;禁止 wecom:worker:singleton。
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass, field
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__)
@dataclass
class SlotPool:
"""
进程内有限槽线程池骨架。
用途:限制同类慢活并发(尤其 LibreOffice=1)。
"""
name: str
size: int
_sem: threading.BoundedSemaphore = field(init=False, repr=False)
def __post_init__(self) -> None:
if self.size < 1:
raise ValueError(f"槽位数必须 >=1: {self.name}")
self._sem = threading.BoundedSemaphore(self.size)
def run(self, fn: Callable[[], None], *, label: str = "") -> None:
"""占用一个槽执行 fn;勿在 HTTP 回调线程调用。"""
acquired = self._sem.acquire(timeout=30)
if not acquired:
logger.warning("槽超时未获取 name=%s label=%s", self.name, label)
return
try:
fn()
finally:
self._sem.release()
@dataclass
class WorkerRuntime:
"""
Worker 运行时:分槽 + wake / LLM / 识别 / 存档 / 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,
*,
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(
"LibreOffice 槽必须为 1(收到 %s),已强制为 1,避免本机双 soffice 互抢",
libreoffice_slots,
)
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:
"""启动分槽消费循环。"""
self._stop.clear()
self._threads = [
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 soffice=%s",
self.libreoffice.size,
self.http_jobs.size,
"ok" if lo.available() else "missing",
)
def stop(self) -> None:
self._stop.set()
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():
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():
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 _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)