Files

366 lines
17 KiB
Python

"""公共核心任务控制 HTTP 边界。
本模块负责任务列表、详情、取消、重试和公开摘要投影。scope-bound service、图片处理
repository、错误构造与 Agent 取消能力由入口通过 callback 注入,避免反向依赖 ``api.py``。
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, Field
from starlette.concurrency import run_in_threadpool
from backend.agent_resume import within_total_timeout
from backend.database import DatabaseError
from backend.image_naming import public_filename_fields
from backend.metadata import MetadataError
from backend.opencode_activity import AgentActivity
from backend.public_dto import (
PUBLIC_STAGE_STATUSES,
normalize_public_filename,
normalize_public_identifier,
sanitize_public_timestamp,
sanitize_task_result,
)
from backend.tasks import TaskRecord
from backend.upload_status import PERMANENT_UPLOAD_ERRORS
Service = Callable[[Request, str], Any]
ErrorFactory = Callable[[int, str, str], HTTPException]
ProcessingRepository = Callable[[Request], Any]
CancelAgent = Callable[[Request, str], None]
class UploadStatusRequest(BaseModel):
"""限制一次上传状态查询的请求数量,账户范围由服务端解析。"""
model_config = ConfigDict(extra="forbid")
request_ids: list[UUID] = Field(min_length=1, max_length=100)
async def upload_status(request: Request, payload: UploadStatusRequest, *, service: Service) -> dict[str, object]:
"""查询指定上传请求的最新结果,返回已脱敏的上传记录。"""
items = await run_in_threadpool(service(request, "tasks").upload_status, [value.hex for value in payload.request_ids])
return {"items": items}
def activity_payload(value: object) -> dict[str, object] | None:
"""将 reader 领域值收敛为完整的三个公开活跃度字段。
输入可以是 ``AgentActivity`` 或 reader 返回的 mapping;字段不完整或类型不安全时
返回 ``None``。调用场景是任务摘要投影,输出只包含可公开的活跃度字段。
"""
if isinstance(value, AgentActivity):
value = value.as_dict()
if not isinstance(value, Mapping):
return None
completed = value.get("agent_completed_turns", value.get("completed_turns"))
running = value.get("agent_turn_running", value.get("turn_running"))
last_activity = value.get("agent_last_activity_at", value.get("last_activity_at"))
if isinstance(completed, bool) or not isinstance(completed, int) or completed < 0:
return None
last_activity = sanitize_public_timestamp(last_activity)
if not isinstance(running, bool) or last_activity is None:
return None
return {
"agent_completed_turns": completed,
"agent_turn_running": running,
"agent_last_activity_at": last_activity,
}
def read_agent_activity(request: Request, records: list[TaskRecord]) -> dict[str, object]:
"""为当前任务页执行一次有界活跃度批量读取,失败时返回空映射。
输入是当前 scope 已查询的任务记录;输出按 task id 索引的原始领域值。SQLite 观测
只提供诊断信息,读取失败不得改变任务 API 的主响应语义。
"""
task_ids = [record.task_id for record in records if record.task_type == "meme_context_generation"]
reader = getattr(request.app.state, "agent_activity", None)
if not task_ids or reader is None:
return {}
read_many = getattr(reader, "read_many", None)
if not callable(read_many):
read_many = getattr(reader, "read", None)
if not callable(read_many):
return {}
try:
values = read_many(task_ids)
except Exception: # noqa: BLE001
return {}
if not isinstance(values, Mapping):
return {}
return {str(task_id): value for task_id, value in values.items() if isinstance(task_id, str)}
def task_summary(
request: Request,
record: TaskRecord,
activities: Mapping[str, object] | None = None,
*,
service: Service,
processing_repository: ProcessingRepository,
) -> dict[str, object]:
"""将任务转换为安全摘要,并按需装配完整活跃度字段。
关键输入是当前 scope 的 ``TaskRecord`` 和可选活跃度映射;输出是去除 payload、scope
身份和物理路径的公开任务 DTO。调用场景是四个任务控制 route 及旧兼容 wrapper。
"""
if activities is None:
activities = read_agent_activity(request, [record])
elif not isinstance(activities, Mapping):
activities = {}
data = record.as_dict(include_payload=False)
payload = record.payload if isinstance(record.payload, Mapping) else {}
task_type = record.task_type if isinstance(record.task_type, str) else ""
public_submission_mode = data.get("submission_mode")
settings = getattr(request.app.state, "settings", None)
if settings is not None and task_type == "meme_context_generation" and data.get("resume_available"):
resume_enabled = bool(getattr(settings, "agent_resume_enabled", False))
if not resume_enabled:
data["resume_available"] = False
data["resume_reason"] = "resume_disabled"
elif isinstance(data.get("resume_attempts"), int) and data["resume_attempts"] >= int(getattr(settings, "agent_resume_max_attempts", 2)):
data["resume_available"] = False
data["resume_reason"] = "resume_budget_exhausted"
elif isinstance(record.resume_started_at, str):
try:
resume_started_at = datetime.fromisoformat(record.resume_started_at.replace("Z", "+00:00"))
except ValueError:
resume_started_at = None
if resume_started_at is not None and not within_total_timeout(
resume_started_at,
timeout_seconds=int(getattr(settings, "agent_resume_timeout_seconds", 900)),
):
data["resume_available"] = False
data["resume_reason"] = "resume_budget_exhausted"
if task_type in {"visual_embedding_generation", "meme_context_generation", "image_auto_rename", "text_embedding_generation"}:
# NULL 来源只代表旧历史无法可靠归类,不能被前端解释为 standalone。
data["historical_unclassified"] = public_submission_mode is None
data["read_only"] = public_submission_mode is None
data["retry_allowed"] = False
data["image_stage"] = data.get("image_stage") or {
"visual_embedding_generation": "visual",
"meme_context_generation": "agent",
"image_auto_rename": "auto_rename",
"text_embedding_generation": "text_embedding",
}.get(task_type)
if task_type == "image_auto_rename":
# pipeline 自动命名只有 warning 才能从专用阶段入口恢复;不可降级
# failure/blocked/unknown_execution 必须保持停止状态。standalone
# 终态失败则允许按当前 Meme 输入重新提交独立 Task。
recoverable_errors = {
"auto_rename_title_missing",
"auto_rename_invalid_filename",
"auto_rename_target_exists",
"auto_rename_target_changed",
}
record_error = record.error if isinstance(record.error, Mapping) else {}
recoverable = public_submission_mode == "standalone" and record.status == "failed" and record_error.get("error") in recoverable_errors
processing_job_id = data.get("processing_job_id")
if public_submission_mode == "pipeline" and isinstance(processing_job_id, str) and processing_job_id:
try:
processing = processing_repository(request).snapshot(processing_job_id)
stage = next((item for item in processing.stages if isinstance(item, Mapping) and item.get("stage") == "auto_rename"), None) if processing else None
recoverable = bool(stage and stage.get("status") == "warning")
stage_status = stage.get("status") if stage and isinstance(stage.get("status"), str) and stage.get("status") in PUBLIC_STAGE_STATUSES else None
data["image_stage_status"] = stage_status
except (DatabaseError, TypeError, ValueError):
recoverable = False
data["image_stage_recoverable"] = recoverable
if task_type == "meme_context_generation":
# 只暴露可观察策略;完整 payload 仍留在后端数据库和 Worker 边界内。
policy = payload.get("reverse_image_policy")
data["reverse_image_policy"] = policy if isinstance(policy, str) and policy in {"forbid", "auto"} else "forbid"
activity = activity_payload(activities.get(record.task_id)) if isinstance(record.task_id, str) else None
if activity is not None:
data.update(activity)
elif task_type == "visual_embedding_generation":
visual_result = sanitize_task_result(
"visual_embedding_generation",
{
"visual_model": payload.get("visual_model"),
"dimensions": payload.get("visual_dimensions"),
"preprocess_version": payload.get("preprocess_version"),
},
) or {}
data["visual"] = {
"model": visual_result.get("visual_model"),
"dimensions": visual_result.get("dimensions"),
"preprocess_version": visual_result.get("preprocess_version"),
}
meme_id = normalize_public_identifier(payload.get("meme_id"))
if meme_id:
try:
meme_record, _image = service(request, "metadata").image_for_meme(meme_id)
image_data: dict[str, object] = {"meme_id": meme_id, "media_url": f"/media/{meme_id}"}
try:
image_data.update(public_filename_fields(meme_record))
except ValueError:
# 展示字段损坏时保留 Meme 关联,但绝不把内容寻址 key 投影给客户端。
pass
data["image"] = image_data
except MetadataError:
data["image"] = {"meme_id": meme_id}
return data
async def list_tasks(
request: Request,
*,
status: list[str] = Query(default=[]),
task_type: list[str] = Query(default=[]),
cursor: str | None = Query(default=None, max_length=128),
limit: int = Query(default=50, ge=1, le=100),
service: Service,
processing_repository: ProcessingRepository,
) -> dict[str, object]:
"""按筛选条件分页列出任务安全摘要。
输出包含 ``items`` 与 ``next_cursor``;任务查询始终通过注入的当前 scope service 完成。
"""
def read_page():
"""在线程内完成数据库读取与摘要生成,释放事件循环。"""
records, next_cursor = service(request, "tasks").list(statuses=set(status) or None, task_types=set(task_type) or None, cursor=cursor, limit=limit)
activities = read_agent_activity(request, records)
targets = [(record.result["meme_id"], record.result["image_sha256"]) for record in records if record.task_type == "image_upload" and record.result and record.result.get("meme_id") and record.result.get("image_sha256")]
snapshots = processing_repository(request).latest_for_targets(targets) if targets else {}
latest = {str(identifier): snapshot.as_dict() for identifier, snapshot in snapshots.items()}
items = []
for record in records:
item = task_summary(request, record, activities, service=service, processing_repository=processing_repository)
if record.task_type == "image_upload":
item["processing"] = latest.get(str((record.result or {}).get("meme_id")))
items.append(item)
return {"items": items, "next_cursor": next_cursor}
return await run_in_threadpool(read_page)
async def get_task(
request: Request,
task_id: str,
*,
service: Service,
error: ErrorFactory,
processing_repository: ProcessingRepository,
) -> dict[str, object]:
"""查询持久任务详情,并保留图片处理父 Job 的旧轮询回退。
找不到普通任务时只查询当前 scope 的图片处理 repository;两者均缺失时返回稳定
``task_not_found`` 错误,不泄露其它 scope 的任务事实。
"""
return await run_in_threadpool(_get_task, request, task_id, service=service, error=error, processing_repository=processing_repository)
def _get_task(request: Request, task_id: str, *, service: Service, error: ErrorFactory, processing_repository: ProcessingRepository) -> dict[str, object]:
"""在同一工作线程读取任务及其图片摘要,返回公开详情。"""
record = service(request, "tasks").get(task_id)
if record is None:
# 图片处理 job 与叶子 Task 使用不同控制面,但旧前端只知道统一的
# ``/tasks/{id}`` 轮询入口;回退查询仍严格绑定当前请求 scope。
try:
snapshot = processing_repository(request).snapshot(task_id)
except (TypeError, ValueError):
snapshot = None
if snapshot is not None:
# 上传/合集旧客户端把父 Job 当作视觉任务轮询;新客户端使用
# /images/processing/{job_id} 获取完整四阶段 DTO。这里只保留
# 旧轮询所需的任务类型兼容值,不改变父 Job 的真实状态。
data = snapshot.as_dict()
data["task_type"] = "visual_embedding_generation"
data["image_stage"] = "visual"
return data
raise error(404, "task_not_found", "任务不存在")
return task_summary(request, record, service=service, processing_repository=processing_repository)
async def cancel_task(
request: Request,
task_id: str,
*,
service: Service,
error: ErrorFactory,
processing_repository: ProcessingRepository,
cancel_agent: CancelAgent | None = None,
) -> dict[str, object]:
"""取消单个未完成任务,不停止共享 Agent 容器或其他 session。
已完成任务直接返回摘要;任务服务负责真正的状态转换,Agent 取消只作为可选适配
能力按当前任务类型调用。
"""
return await run_in_threadpool(_cancel_task, request, task_id, service=service, error=error, processing_repository=processing_repository, cancel_agent=cancel_agent)
def _cancel_task(request: Request, task_id: str, *, service: Service, error: ErrorFactory, processing_repository: ProcessingRepository, cancel_agent: CancelAgent | None) -> dict[str, object]:
"""在线程内完成取消及结果查询,数据库和执行器等待不占用事件循环。"""
tasks = service(request, "tasks")
record = tasks.get(task_id)
if record is None:
raise error(404, "task_not_found", "任务不存在")
if record.status in {"succeeded", "failed"}:
return task_summary(request, record, service=service, processing_repository=processing_repository)
if not tasks.cancel(task_id):
record = tasks.get(task_id)
if record is None:
raise error(404, "task_not_found", "任务不存在")
if record.task_type == "meme_context_generation" and cancel_agent is not None:
cancel_agent(request, task_id)
current = tasks.get(task_id)
return task_summary(request, current or record, service=service, processing_repository=processing_repository)
async def retry_task(
request: Request,
task_id: str,
*,
service: Service,
error: ErrorFactory,
processing_repository: ProcessingRepository,
) -> dict[str, object]:
"""只重试当前失败阶段;视觉/Agent/文本任务不会隐式级联。
task service 的稳定 RuntimeError code 映射为既有 HTTP 错误;未知诊断文本不直接
暴露给客户端。
"""
return await run_in_threadpool(_retry_task, request, task_id, service=service, error=error, processing_repository=processing_repository)
def _retry_task(request: Request, task_id: str, *, service: Service, error: ErrorFactory, processing_repository: ProcessingRepository) -> dict[str, object]:
"""在线程内提交重试并返回任务摘要。"""
try:
record = service(request, "tasks").retry(task_id)
except RuntimeError as exc:
code = str(exc).split(":", 1)[0]
if code == "task_not_found":
raise error(404, code, "任务不存在") from exc
if code == "task_not_failed":
raise error(409, code, "只有失败任务可以重试") from exc
if code == "image_stage_retry_forbidden":
raise error(409, code, "图片阶段必须通过完整 Job 或专用阶段入口重试") from exc
if code == "agent_backpressure":
raise error(429, code, "Agent 等待队列已满,请稍后重试") from exc
if code == "upload_input_expired":
raise error(409, code, "上传输入已过期,请重新选择文件") from exc
if code in PERMANENT_UPLOAD_ERRORS:
raise error(409, code, f"当前上传无法重试:{code}") from exc
raise error(409, code, "任务重试失败") from exc
return task_summary(request, record, service=service, processing_repository=processing_repository)
__all__ = [
"activity_payload",
"cancel_task",
"get_task",
"list_tasks",
"read_agent_activity",
"retry_task",
"task_summary",
]