fischer-agentkit/src/agentkit/rag_platform/tasks.py

706 lines
24 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.

"""U8 — TaskIQ 异步任务集成 — 文档向量化与批量索引。
包装 TaskIQ broker提供
- 任务参数 schema 校验Pydantic 模型)
- per-user 并发上限
- 任务状态机PENDING → RUNNING → COMPLETED | FAILED | CANCELLED
- Worker liveness & sweeperworker_heartbeat_at 超时检测)
- 错误消息净化(剥离内部路径/连接串)
- 降级模式broker 未配置时同步执行
Redis 隔离:使用独立 db=1与 bus/cache 的 db=0 分离),或 key 前缀 ``taskiq:``。
任务参数在 Redis 中带 TTL默认 1 小时)。
ponytail: TaskIQ broker 为可选依赖 — 通过 TYPE_CHECKING 导入,
未安装时模块仍可加载TaskManager 退化为同步执行。
"""
from __future__ import annotations
import asyncio
import inspect
import logging
import re
import uuid
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Protocol
from pydantic import BaseModel, ConfigDict, Field, field_validator
from agentkit.core.protocol import TaskStatus
from agentkit.rag_platform.document_processor import (
DEFAULT_CHUNK_OVERLAP,
DEFAULT_CHUNK_SIZE,
DocumentProcessor,
)
from agentkit.server.task_store import InMemoryTaskStore, TaskRecord
if TYPE_CHECKING:
from llama_index.core.embeddings import BaseEmbedding
from llama_index.vector_stores.postgres import PGVectorStore
from agentkit.rag_platform.store import KBStore
logger = logging.getLogger(__name__)
# 默认配置
DEFAULT_MAX_CONCURRENT_PER_USER = 3
DEFAULT_WORKER_TTL_SECONDS = 300 # worker 心跳超时阈值
DEFAULT_ERROR_MESSAGE_MAX_LENGTH = 500
DEFAULT_TASK_TTL_SECONDS = 3600 # 任务参数 TTL
# Redis 隔离 — 独立 db=1与 bus/cache 的 db=0 分离
TASKIQ_REDIS_DB = 1
TASKIQ_KEY_PREFIX = "taskiq:"
async def _maybe_await(result: object) -> object:
"""统一处理 sync/async 调用结果 — InMemoryTaskStore 方法为 syncRedisTaskStore 为 async。"""
if inspect.isawaitable(result):
return await result
return result
# ---------------------------------------------------------------------------
# 任务参数模型 — schema 校验
# ---------------------------------------------------------------------------
class VectorizeTaskParams(BaseModel):
"""向量化任务参数 — 单文档 parse → segment → vectorize → index。"""
model_config = ConfigDict()
document_id: str = Field(min_length=1, max_length=128)
kb_id: str = Field(min_length=1, max_length=128)
file_path: str = Field(min_length=1, max_length=1024)
file_type: str = Field(min_length=1, max_length=64)
chunk_size: int = Field(default=DEFAULT_CHUNK_SIZE, ge=50, le=8192)
chunk_overlap: int = Field(default=DEFAULT_CHUNK_OVERLAP, ge=0, le=1024)
user_id: str = Field(min_length=1, max_length=128)
@field_validator("chunk_overlap")
@classmethod
def _overlap_less_than_size(cls, v: int, info: object) -> int:
"""chunk_overlap 必须小于 chunk_size。"""
size = info.data.get("chunk_size", DEFAULT_CHUNK_SIZE)
if v >= size:
raise ValueError(f"chunk_overlap ({v}) must be < chunk_size ({size})")
return v
class BatchIndexTaskParams(BaseModel):
"""批量索引任务参数 — 多文档并行向量化。"""
model_config = ConfigDict()
kb_id: str = Field(min_length=1, max_length=128)
document_ids: list[str] = Field(min_length=1, max_length=100)
user_id: str = Field(min_length=1, max_length=128)
file_paths: dict[str, str] = Field(default_factory=dict) # document_id -> file_path
file_types: dict[str, str] = Field(default_factory=dict) # document_id -> file_type
chunk_size: int = Field(default=DEFAULT_CHUNK_SIZE, ge=50, le=8192)
chunk_overlap: int = Field(default=DEFAULT_CHUNK_OVERLAP, ge=0, le=1024)
# ---------------------------------------------------------------------------
# 错误消息净化
# ---------------------------------------------------------------------------
# 匹配 Unix/Windows 文件路径
_PATH_RE = re.compile(
r"(?:/[\w.\-]+)+|" # Unix 路径 /usr/local/...
r"(?:[A-Za-z]:\\[\w.\-]+(?:\\[\w.\-]+)*)" # Windows 路径 C:\Users\...
)
# 匹配 Redis/PostgreSQL 连接串
_CONN_STR_RE = re.compile(
r"(redis://|postgresql://|postgres://|mysql://)"
r"[^\s\"']+",
re.IGNORECASE,
)
# 匹配环境变量风格的密钥
_SECRET_RE = re.compile(
r"(api[_-]?key|token|password|secret)[\s:=]+[\w\-]+",
re.IGNORECASE,
)
def sanitize_error_message(msg: str, max_length: int = DEFAULT_ERROR_MESSAGE_MAX_LENGTH) -> str:
"""净化错误消息 — 剥离内部路径、连接串、密钥。
Args:
msg: 原始错误消息
max_length: 最大长度(截断)
Returns:
净化后的错误消息
"""
if not msg:
return ""
# 连接串优先于路径 — 避免 /localhost:6379/0 被路径正则部分匹配
cleaned = _CONN_STR_RE.sub("<connection-string>", msg)
cleaned = _PATH_RE.sub("<path>", cleaned)
cleaned = _SECRET_RE.sub(r"\1=<redacted>", cleaned)
if len(cleaned) > max_length:
cleaned = cleaned[: max_length - 3] + "..."
return cleaned
# ---------------------------------------------------------------------------
# Worker liveness & sweeper
# ---------------------------------------------------------------------------
class TaskStoreProtocol(Protocol):
"""TaskStore 协议 — InMemoryTaskStore / RedisTaskStore 均满足。"""
async def create(
self, task_id: str, agent_name: str, input_data: dict, skill_name: str | None = None
) -> TaskRecord: ...
def get(self, task_id: str) -> TaskRecord | None: ...
async def update_status(
self, task_id: str, status: TaskStatus, **kwargs: object
) -> TaskRecord: ...
def list_tasks(
self, status: TaskStatus | None = None, limit: int = 100
) -> list[TaskRecord]: ...
class WorkerSweeper:
"""检测超时任务并标记为 failed。
扫描 status=RUNNING 且 worker_heartbeat_at 超过 TTL 的任务,
将其状态置为 failederror_message="worker_timeout"
ponytail: 升级路径 — 当前依赖 list_tasks 全量扫描O(n)
生产环境可改为 Redis ZSET 按心跳时间戳排序O(log n))。
"""
def __init__(
self,
task_store: TaskStoreProtocol,
ttl_seconds: int = DEFAULT_WORKER_TTL_SECONDS,
) -> None:
self._store = task_store
self._ttl = ttl_seconds
async def sweep(self) -> int:
"""扫描超时任务,返回清理数量。"""
now = datetime.now(timezone.utc)
cutoff = now - timedelta(seconds=self._ttl)
cleaned = 0
running_tasks = await _maybe_await(
self._store.list_tasks(status=TaskStatus.RUNNING, limit=1000)
)
for record in running_tasks:
heartbeat = record.metadata.get("worker_heartbeat_at")
if heartbeat is None:
# 无心跳记录 — 使用 started_at 作为回退
heartbeat = record.started_at.isoformat() if record.started_at else None
if heartbeat is None:
continue
try:
hb_time = datetime.fromisoformat(heartbeat)
except (ValueError, TypeError):
continue
# 确保 timezone-aware
if hb_time.tzinfo is None:
hb_time = hb_time.replace(tzinfo=timezone.utc)
if hb_time < cutoff:
try:
await _maybe_await(
self._store.update_status(
record.task_id,
TaskStatus.FAILED,
error_message="worker_timeout",
completed_at=now,
metadata={**record.metadata, "swept_at": now.isoformat()},
)
)
cleaned += 1
logger.warning(
"Swept timed-out task %s (heartbeat=%s, cutoff=%s)",
record.task_id,
heartbeat,
cutoff.isoformat(),
)
except KeyError:
# 任务已被并发删除
continue
return cleaned
# ---------------------------------------------------------------------------
# TaskManager — 任务提交、查询、取消
# ---------------------------------------------------------------------------
class ConcurrencyLimitExceeded(Exception):
"""per-user 并发上限超出。"""
class TaskNotFoundError(Exception):
"""任务不存在。"""
class TaskManager:
"""异步任务管理器 — 包装 TaskIQ broker。
如果 broker 未配置或 TaskIQ 不可用,任务同步执行(降级模式)。
Args:
broker: TaskIQ broker 实例(可选)
task_store: 任务状态存储(默认 InMemoryTaskStore
max_concurrent_per_user: per-user 并发上限
task_ttl_seconds: 任务参数 TTLRedis 模式)
"""
def __init__(
self,
broker: object | None = None,
task_store: TaskStoreProtocol | None = None,
max_concurrent_per_user: int = DEFAULT_MAX_CONCURRENT_PER_USER,
task_ttl_seconds: int = DEFAULT_TASK_TTL_SECONDS,
) -> None:
self._broker = broker
self._store: TaskStoreProtocol = task_store or InMemoryTaskStore()
self._max_concurrent = max_concurrent_per_user
self._task_ttl = task_ttl_seconds
# user_id -> 当前 RUNNING 任务数
self._running_counts: dict[str, int] = {}
self._lock = asyncio.Lock()
@property
def store(self) -> TaskStoreProtocol:
"""暴露底层 task store供 sweeper 使用)。"""
return self._store
@property
def broker(self) -> object:
"""暴露底层 broker供 startup/shutdown 集成)。"""
return self._broker
# ── 任务提交 ────────────────────────────────────────────────
async def submit_vectorize(
self,
params: VectorizeTaskParams,
dependencies: dict[str, object] | None = None,
) -> str:
"""提交向量化任务,返回 task_id。
Args:
params: 向量化任务参数(已通过 schema 校验)
dependencies: 运行时依赖store/vector_store/embed_model
降级模式下直接使用broker 模式下由 worker 注入
Raises:
ConcurrencyLimitExceeded: per-user 并发上限超出
"""
await self._check_concurrency(params.user_id)
task_id = f"vec-{uuid.uuid4()}"
input_data = params.model_dump()
# 依赖对象不序列化 — 仅在降级模式内存中传递
deps = dependencies or {}
await _maybe_await(
self._store.create(
task_id=task_id,
agent_name="rag_vectorize",
input_data=input_data,
skill_name="vectorize",
)
)
# 记录 user_id 到 metadata 便于按用户查询
record = await _maybe_await(self._store.get(task_id))
if record is not None:
record.metadata["user_id"] = params.user_id
logger.info(
"Submitted vectorize task %s for document=%s user=%s",
task_id,
params.document_id,
params.user_id,
)
if self._broker is not None:
# broker 模式 — 异步派发
await self._dispatch_to_broker("vectorize", input_data, task_id)
else:
# 降级模式 — 同步执行(不阻塞调用方,使用 asyncio.create_task
asyncio.create_task(self._run_vectorize_degraded(task_id, params, deps))
return task_id
async def submit_batch_index(
self,
params: BatchIndexTaskParams,
dependencies: dict[str, object] | None = None,
) -> str:
"""提交批量索引任务,返回 task_id。"""
await self._check_concurrency(params.user_id)
task_id = f"batch-{uuid.uuid4()}"
input_data = params.model_dump()
deps = dependencies or {}
await _maybe_await(
self._store.create(
task_id=task_id,
agent_name="rag_batch_index",
input_data=input_data,
skill_name="batch_index",
)
)
record = await _maybe_await(self._store.get(task_id))
if record is not None:
record.metadata["user_id"] = params.user_id
logger.info(
"Submitted batch index task %s for %d documents user=%s",
task_id,
len(params.document_ids),
params.user_id,
)
if self._broker is not None:
await self._dispatch_to_broker("batch_index", input_data, task_id)
else:
asyncio.create_task(self._run_batch_index_degraded(task_id, params, deps))
return task_id
# ── 任务查询 ────────────────────────────────────────────────
async def get_task_status(self, task_id: str) -> TaskRecord | None:
"""查询任务状态。"""
return await _maybe_await(self._store.get(task_id))
async def list_tasks(
self,
user_id: str | None = None,
status: TaskStatus | None = None,
limit: int = 100,
) -> list[TaskRecord]:
"""查询任务历史 — 可按 user_id 和 status 过滤。"""
tasks = await _maybe_await(self._store.list_tasks(status=status, limit=limit))
if user_id is not None:
tasks = [t for t in tasks if t.metadata.get("user_id") == user_id]
return tasks
async def cancel_task(self, task_id: str) -> bool:
"""取消任务 — 仅 PENDING/RUNNING 可取消。
Returns:
True 如果取消成功
Raises:
TaskNotFoundError: 任务不存在
"""
record = await _maybe_await(self._store.get(task_id))
if record is None:
raise TaskNotFoundError(f"Task {task_id} not found")
if record.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED):
return False
await _maybe_await(
self._store.update_status(
task_id,
TaskStatus.CANCELLED,
completed_at=datetime.now(timezone.utc),
)
)
# 释放并发计数
user_id = record.metadata.get("user_id")
if user_id and self._running_counts.get(user_id, 0) > 0:
self._running_counts[user_id] -= 1
return True
# ── 心跳更新 ────────────────────────────────────────────────
async def update_heartbeat(self, task_id: str) -> None:
"""更新 worker 心跳时间戳 — 由 worker 周期性调用。"""
record = await _maybe_await(self._store.get(task_id))
if record is None:
return
now = datetime.now(timezone.utc).isoformat()
record.metadata["worker_heartbeat_at"] = now
# ── 内部方法 ────────────────────────────────────────────────
async def _check_concurrency(self, user_id: str) -> None:
"""检查 per-user 并发上限。"""
async with self._lock:
current = self._running_counts.get(user_id, 0)
if current >= self._max_concurrent:
raise ConcurrencyLimitExceeded(
f"User {user_id} has {current} running tasks (max={self._max_concurrent})"
)
self._running_counts[user_id] = current + 1
async def _release_concurrency(self, user_id: str) -> None:
"""释放并发计数。"""
async with self._lock:
if self._running_counts.get(user_id, 0) > 0:
self._running_counts[user_id] -= 1
async def _dispatch_to_broker(self, task_type: str, params: dict, task_id: str) -> None:
"""派发任务到 TaskIQ broker。"""
# broker.kiq 返回 TaskiqTask — 实际执行由 worker 进程完成
try:
await self._broker.kiq(task_type=task_type, params=params, task_id=task_id)
except Exception as e:
logger.error("Failed to dispatch task %s to broker: %s", task_id, e)
raise
async def _run_vectorize_degraded(
self,
task_id: str,
params: VectorizeTaskParams,
deps: dict[str, object],
) -> None:
"""降级模式 — 同步执行向量化任务(在 asyncio 任务中)。"""
now = datetime.now(timezone.utc)
try:
await _maybe_await(
self._store.update_status(task_id, TaskStatus.RUNNING, started_at=now)
)
await self.update_heartbeat(task_id)
await run_vectorize_task(params, **deps)
await _maybe_await(
self._store.update_status(
task_id,
TaskStatus.COMPLETED,
completed_at=datetime.now(timezone.utc),
progress=1.0,
)
)
except Exception as e:
sanitized = sanitize_error_message(str(e))
await _maybe_await(
self._store.update_status(
task_id,
TaskStatus.FAILED,
error_message=sanitized,
completed_at=datetime.now(timezone.utc),
)
)
logger.error("Vectorize task %s failed: %s", task_id, sanitized)
finally:
await self._release_concurrency(params.user_id)
async def _run_batch_index_degraded(
self,
task_id: str,
params: BatchIndexTaskParams,
deps: dict[str, object],
) -> None:
"""降级模式 — 同步执行批量索引任务。"""
now = datetime.now(timezone.utc)
total = len(params.document_ids)
succeeded: list[str] = []
failed: list[str] = []
try:
await _maybe_await(
self._store.update_status(task_id, TaskStatus.RUNNING, started_at=now)
)
await self.update_heartbeat(task_id)
for i, doc_id in enumerate(params.document_ids):
await self.update_heartbeat(task_id)
try:
single_params = VectorizeTaskParams(
document_id=doc_id,
kb_id=params.kb_id,
file_path=params.file_paths.get(doc_id, ""),
file_type=params.file_types.get(doc_id, "txt"),
chunk_size=params.chunk_size,
chunk_overlap=params.chunk_overlap,
user_id=params.user_id,
)
await run_vectorize_task(single_params, **deps)
succeeded.append(doc_id)
except Exception as e:
logger.warning("Batch item %s failed: %s", doc_id, e)
failed.append(doc_id)
# 更新进度
progress = (i + 1) / total
await _maybe_await(
self._store.update_status(
task_id,
TaskStatus.RUNNING,
progress=progress,
progress_message=f"Processed {i + 1}/{total}",
)
)
final_status = TaskStatus.COMPLETED if not failed else TaskStatus.PARTIALLY_COMPLETED
await _maybe_await(
self._store.update_status(
task_id,
final_status,
completed_at=datetime.now(timezone.utc),
progress=1.0,
output_data={
"succeeded": succeeded,
"failed": failed,
"total": total,
},
)
)
except Exception as e:
sanitized = sanitize_error_message(str(e))
await _maybe_await(
self._store.update_status(
task_id,
TaskStatus.FAILED,
error_message=sanitized,
completed_at=datetime.now(timezone.utc),
)
)
logger.error("Batch index task %s failed: %s", task_id, sanitized)
finally:
await self._release_concurrency(params.user_id)
# ---------------------------------------------------------------------------
# 任务执行函数 — 由 worker 调用
# ---------------------------------------------------------------------------
async def run_vectorize_task(
params: VectorizeTaskParams,
store: "KBStore",
vector_store: "PGVectorStore",
embed_model: "BaseEmbedding",
) -> None:
"""向量化任务 — 由 worker 执行。
封装 DocumentProcessor.process() — parse → segment → vectorize → index。
状态转换由 DocumentProcessor 内部管理pending → parsing → ... → indexed | failed
Args:
params: 向量化任务参数
store: KBStore 实例(用于文档状态更新)
vector_store: LlamaIndex PGVectorStore 实例
embed_model: LlamaIndex embedding 模型
Raises:
Exception: 管道任一阶段失败
"""
processor = DocumentProcessor(
chunk_size=params.chunk_size,
chunk_overlap=params.chunk_overlap,
)
await processor.process(
params.file_path,
params.file_type,
params.kb_id,
params.document_id,
vector_store,
embed_model,
store,
)
async def run_batch_index_task(
params: BatchIndexTaskParams,
store: "KBStore",
vector_store: "PGVectorStore",
embed_model: "BaseEmbedding",
) -> dict[str, list[str]]:
"""批量索引任务 — 顺序处理多个文档。
Returns:
{"succeeded": [...], "failed": [...]}
"""
succeeded: list[str] = []
failed: list[str] = []
for doc_id in params.document_ids:
try:
single_params = VectorizeTaskParams(
document_id=doc_id,
kb_id=params.kb_id,
file_path=params.file_paths.get(doc_id, ""),
file_type=params.file_types.get(doc_id, "txt"),
chunk_size=params.chunk_size,
chunk_overlap=params.chunk_overlap,
user_id=params.user_id,
)
await run_vectorize_task(single_params, store, vector_store, embed_model)
succeeded.append(doc_id)
except Exception as e:
logger.warning("Batch item %s failed: %s", doc_id, e)
failed.append(doc_id)
return {"succeeded": succeeded, "failed": failed}
# ---------------------------------------------------------------------------
# TaskIQ broker 工厂(可选 — 仅 Redis 可用时使用)
# ---------------------------------------------------------------------------
def create_broker(redis_url: str) -> object:
"""创建 TaskIQ Redis broker — 独立 db=1 隔离。
Args:
redis_url: Redis 连接 URLdb=0 用于 bus/cache此处强制改为 db=1
Returns:
配置好的 TaskiqBroker 实例
Raises:
ImportError: taskiq 未安装
"""
from urllib.parse import urlparse, urlunparse
from taskiq_redis import ListQueueBroker, RedisAsyncResultBackend
from taskiq import TaskiqBroker
# 强制使用 db=1 — 与 bus/cache 的 db=0 隔离
parsed = urlparse(redis_url)
# 替换 path 为 /1
new_path = "/1"
isolated_url = urlunparse(parsed._replace(path=new_path))
broker = TaskiqBroker(ListQueueBroker(url=isolated_url))
broker.with_result_backend(
RedisAsyncResultBackend(redis_url=isolated_url, result_ex_time=DEFAULT_TASK_TTL_SECONDS)
)
logger.info("TaskIQ broker created with Redis db=1 isolation")
return broker
__all__ = [
"BatchIndexTaskParams",
"ConcurrencyLimitExceeded",
"DEFAULT_MAX_CONCURRENT_PER_USER",
"DEFAULT_WORKER_TTL_SECONDS",
"TaskManager",
"TaskNotFoundError",
"TaskStoreProtocol",
"VectorizeTaskParams",
"WorkerSweeper",
"create_broker",
"run_batch_index_task",
"run_vectorize_task",
"sanitize_error_message",
]