From 1c1a9547038d1286cb244450dceebf84fd4058fa Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 16:47:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=95=B4=E7=90=86):=20=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E5=9C=A8=E6=95=B4=E7=90=86=E8=AE=B0=E5=BD=95=E5=B0=B1=E5=9C=B0?= =?UTF-8?q?=E5=A4=84=E7=90=86,=E8=AF=86=E5=88=AB=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8F=AB=E6=99=BA=E8=83=BD=E4=BD=93=E8=AF=86?= =?UTF-8?q?=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 整理记录「需要处理」:列表 attention 筛选与统计同口径(没被后来的整理取代,且有未关闭事项或失败 / 部分完成未忽略), 记录带 attention(原因类别、精简事项、能否让智能体识别);忽略端点记 attention_cleared_at(迁移 0078)并关闭关联事项 - 识别失败(organize_identity)按 agent.autoIdentifyOrganize 自动派一次识别会话;新能力 organize.assign_identity 指定作品后复用人工指定的钩子路径自动重新整理,只放行本事项的识别会话与用户对话;整理记录里可手动「让智能体识别」 - GET /attention:整理需要处理条数、等你批准的智能体操作、系统健康问题(铃铛 / 首页 / 运行状态共用) - 原因码文案不再指向待处理;命令面板「待处理」换成「需要处理的整理记录」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../acquisition-organize/capabilities.ts | 23 ++ .../acquisition-organize/routes/dashboard.ts | 55 ++++- .../routes/organization.ts | 82 +++++++ .../services/organize-records.ts | 103 +++++++- .../acquisition-organize/services/takeover.ts | 86 ++++++- apps/server/src/modules/agent/authorize.ts | 19 ++ apps/server/src/modules/agent/investigate.ts | 133 +++++++++- apps/server/src/modules/agent/service.ts | 14 +- apps/server/src/modules/agent/tasks.ts | 5 +- apps/server/src/modules/search/actions.ts | 7 +- .../organize-attention.test.ts | 227 ++++++++++++++++++ .../records-filter.test.ts | 10 +- .../server/test/e2e/organize-identify.test.ts | 121 ++++++++++ docs/runbook/organize.md | 25 ++ .../agent/skills/import-troubleshoot/SKILL.md | 7 +- .../agent/src/prompt/layers/capabilities.ts | 1 + packages/contracts/src/api/library.ts | 9 + packages/contracts/src/api/organize.ts | 16 ++ packages/contracts/src/capabilities-io.ts | 29 +++ packages/contracts/src/capability.ts | 15 ++ packages/contracts/src/organization.ts | 59 ++++- packages/contracts/src/reasons.ts | 8 +- packages/contracts/test/reasons.test.ts | 2 +- .../db/migrations/0078_organize_attention.ts | 15 ++ packages/db/src/types.ts | 1 + 25 files changed, 1037 insertions(+), 35 deletions(-) create mode 100644 apps/server/test/acquisition-organize/organize-attention.test.ts create mode 100644 apps/server/test/e2e/organize-identify.test.ts create mode 100644 packages/db/migrations/0078_organize_attention.ts diff --git a/apps/server/src/modules/acquisition-organize/capabilities.ts b/apps/server/src/modules/acquisition-organize/capabilities.ts index a3a997aa..0c8886cf 100644 --- a/apps/server/src/modules/acquisition-organize/capabilities.ts +++ b/apps/server/src/modules/acquisition-organize/capabilities.ts @@ -14,6 +14,7 @@ import { previewPolicy, publishRevision } from "./routes/rules.ts"; import { evaluateAcquisition, publishedAcquisitionPolicies } from "./services/acquisition.ts"; import { type AoDeps, enqueueTracked, ensureTaskRow, markTask } from "./services/common.ts"; import { type FrozenPlan, planImport } from "./services/organize.ts"; +import { assignOrganizeIdentity, OrganizeDecisionError } from "./services/takeover.ts"; import { errMessage, hashJson, jsonb, toApi, toApiList } from "./util.ts"; // 本模块领域的能力处理器(规格一律取 contracts): @@ -591,6 +592,28 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => }; }); + // 整理时没识别出作品:智能体查到作品后指定,随即重新整理(授权见 agent/authorize.ts:只放行本事项的识别会话或用户对话) + registry.register(capabilitySpec("organize.assign_identity"), async (input, ctx) => { + if (!input.mediaEntityId && !input.tmdb) + throw new CapabilityError("invalid_input", "需要 mediaEntityId 或 tmdb(类型 + ID)"); + try { + return await assignOrganizeIdentity( + deps(), + input.decisionId, + input.mediaEntityId ? { mediaEntityId: input.mediaEntityId } : { tmdb: input.tmdb }, + { type: ctx.source === "web" ? "user" : "agent", id: ctx.agentRunId ?? ctx.actorId }, + input.reasons.join(";"), + ); + } catch (e) { + if (e instanceof OrganizeDecisionError) + throw new CapabilityError( + e.code === "bad_request" ? "invalid_input" : e.code === "not_found" ? "not_found" : "conflict", + e.message, + ); + throw e; + } + }); + registry.register(capabilitySpec("organize.execute"), async (input, ctx) => { const d = deps(); const db = d.ctx.db; diff --git a/apps/server/src/modules/acquisition-organize/routes/dashboard.ts b/apps/server/src/modules/acquisition-organize/routes/dashboard.ts index e5ea0055..0d0f431e 100644 --- a/apps/server/src/modules/acquisition-organize/routes/dashboard.ts +++ b/apps/server/src/modules/acquisition-organize/routes/dashboard.ts @@ -1,17 +1,68 @@ -import { Dashboard, type DecisionKind, type SlotStatusCounts } from "@umt/contracts"; +import { Api, Dashboard, type Decision, type DecisionKind, type SlotStatusCounts } from "@umt/contracts"; import { sql } from "@umt/db"; import type { ModuleApp } from "../../types.ts"; import type { AoDeps } from "../services/common.ts"; import { cleanupCandidates } from "../services/downloads.ts"; +import { attentionSql } from "../services/organize-records.ts"; import { decideVolume, loadVolumes, usedPercent, volumeThresholds } from "../services/volumes.ts"; -import { startOfDay } from "../util.ts"; +import { startOfDay, toApiList } from "../util.ts"; import { mount } from "./helpers.ts"; // 首页六区块(REQ §13.4.3):只读投影,统计未知时给 null 并在 notes 说明。 const iso = (v: Date | null | undefined) => (v ? v.toISOString() : null); +/** 需要你批准的智能体操作:会话里的审批 / 方案选择、计划审批(整理记录上的审批在整理记录里处理)、原站页面等你接手 */ +const APPROVAL_SQL = sql`(subject_type = 'agent_run' + or (kind = 'approval_required' and coalesce(subject_type, '') <> 'organization_record') + or dedupe_key like 'browser.waiting:%')`; +/** 系统健康:站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线(恢复后自动关闭) */ +const HEALTH_SQL = sql`(kind in ('source_unreachable', 'downloader_offline', 'cache_volume_unknown', 'space_insufficient') + or dedupe_key like 'library_root_offline:%')`; +const OPEN_SQL = sql`status in ('open', 'investigating', 'awaiting_user')`; +const ATTENTION_ITEMS = 20; + export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) { + // 铃铛 / 首页 / 运行状态共用的「需要关注」汇总(口径见 contracts AttentionSummary) + mount( + app, + Api.attention.summary, + async () => { + const db = d.ctx.db; + const group = async (where: typeof APPROVAL_SQL) => { + const [items, total] = await Promise.all([ + db + .selectFrom("decision") + .selectAll() + .where(OPEN_SQL) + .where(where) + .orderBy("created_at", "desc") + .limit(ATTENTION_ITEMS) + .execute(), + db + .selectFrom("decision") + .select((eb) => eb.fn.countAll().as("n")) + .where(OPEN_SQL) + .where(where) + .executeTakeFirst(), + ]); + return { total: Number(total?.n ?? 0), items: toApiList(items) }; + }; + const [organize, approvals, health] = await Promise.all([ + db + .selectFrom("organization_record") + .select((eb) => eb.fn.countAll().as("n")) + .where("action", "in", ["copy", "move", "in_place", "hardlink"]) + .where(attentionSql()) + .executeTakeFirst(), + group(APPROVAL_SQL), + group(HEALTH_SQL), + ]); + return { organize: Number(organize?.n ?? 0), approvals, health }; + }, + { tag: "dashboard" }, + ); + mount( app, { diff --git a/apps/server/src/modules/acquisition-organize/routes/organization.ts b/apps/server/src/modules/acquisition-organize/routes/organization.ts index 5f8daeb5..80ded260 100644 --- a/apps/server/src/modules/acquisition-organize/routes/organization.ts +++ b/apps/server/src/modules/acquisition-organize/routes/organization.ts @@ -15,6 +15,7 @@ import { type SourceGuard, sourceGuard, } from "../services/organize-guard.ts"; +import { attentionSql } from "../services/organize-records.ts"; import { actorOf, errMessage, hashJson, jsonb, notFound } from "../util.ts"; import { mount, paginate } from "./helpers.ts"; @@ -50,7 +51,14 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) { query, ).execute(); const n = (s: string) => Number(rows.find((r) => r.status === s)?.n ?? 0); + const att = await recordFilters( + db() + .selectFrom("organization_record") + .select((eb) => eb.fn.countAll().as("n")), + { ...query, attention: true }, + ).executeTakeFirst(); return { + attention: Number(att?.n ?? 0), total: rows.reduce((a, r) => a + Number(r.n), 0), succeeded: n("succeeded"), partial: n("partial"), @@ -102,6 +110,79 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) { { tag: TAG }, ); + // 忽略:这条记录不再算需要处理;关联的未关闭事项一并关闭(dismissed) + mount( + app, + Api.organization.dismissAttention, + async (c, { params }) => { + const by = actorOf(c); + const rec = await db() + .transaction() + .execute(async (trx) => { + const r = await trx + .updateTable("organization_record") + .set({ attention_cleared_at: d.ports.now() }) + .where("id", "=", params.id) + .returningAll() + .executeTakeFirst(); + if (!r) return null; + const closed = await trx + .updateTable("decision") + .set({ + status: "dismissed", + resolution: jsonb({ optionKey: null, note: "在整理记录里忽略", by }), + resolved_by: by, + resolved_at: d.ports.now(), + }) + .where("subject_type", "=", "organization_record") + .where("subject_id", "=", params.id) + .where("status", "in", ["open", "investigating", "awaiting_user"]) + .returning("id") + .execute(); + for (const x of closed) + await d.ctx.outbox.emit( + trx, + "decision.updated", + { decisionId: x.id, status: "dismissed" }, + { idempotencyKey: `decision.updated:${x.id}:dismissed` }, + ); + return r; + }); + if (!rec) notFound("整理记录"); + await audit(d, by, "organize.attention_dismissed", { recordId: params.id }); + return (await loadRecordsPublic(db(), [rec!], false))[0]!; + }, + { tag: TAG }, + ); + + // 让智能体识别:没识别出作品的记录入队一次识别会话(agent 模块 resolveDecision trigger=identify),认出来自动重新整理 + mount( + app, + Api.organization.agentIdentify, + async (_c, { params }) => { + const dec = await db() + .selectFrom("decision") + .select(["id", "status", "evidence"]) + .where("subject_type", "=", "organization_record") + .where("subject_id", "=", params.id) + .where("dedupe_key", "like", "organize_identity:%") + .where("status", "in", ["open", "investigating", "awaiting_user"]) + .orderBy("created_at", "desc") + .executeTakeFirst(); + if (!dec) throw new ApiException("conflict", "这条记录不是「没识别出作品」,不能让智能体识别"); + if (typeof (dec.evidence as { downloadTaskId?: unknown } | null)?.downloadTaskId !== "string") + throw new ApiException("conflict", "这条记录没有关联的下载任务。用整理向导手动整理"); + if (dec.status === "investigating") return { decisionId: dec.id, queued: false }; + await d.ctx.engine.enqueue( + "resolveDecision", + { decisionId: dec.id, trigger: "identify" }, + { idempotencyKey: `resolveDecision:identify:${dec.id}:${randomUUID()}`, traceId: dec.id }, + ); + return { decisionId: dec.id, queued: true }; + }, + { tag: TAG }, + ); + // 手动发起整理:与自动整理走同一冻结计划流程;返回 record/task ID,由任务页追踪。 // 先过三道闸(organize-guard.ts):路径守卫(400)→ Move 授权(403)→ 纳管来源的做种义务(409 + 待处理)。 mount( @@ -273,6 +354,7 @@ function recordFilters=", new Date(query.since)); if (query.organizeOnly) q = q.where("action", "in", ["copy", "move", "in_place", "hardlink"]); + if (query.attention) q = q.where(attentionSql()); if (query.kind === "none") q = q.where("media_entity_id", "is", null); else if (query.kind) q = q.where("media_entity_id", "in", (eb: ExpressionBuilder) => diff --git a/apps/server/src/modules/acquisition-organize/services/organize-records.ts b/apps/server/src/modules/acquisition-organize/services/organize-records.ts index 3cfb0f77..184b5cc6 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-records.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-records.ts @@ -1,4 +1,4 @@ -import { reasonSentence } from "@umt/contracts"; +import { type DecisionOption, type RecordAttention, reasonSentence } from "@umt/contracts"; import { type OrganizationRecordRow, sql } from "@umt/db"; import { errMessage, jsonb } from "../util.ts"; import { type AoDeps, type Exec, enqueueSweep, timeSlot, upsertDecision } from "./common.ts"; @@ -9,11 +9,111 @@ import { finalizeRecord, removeMovedDownload } from "./organize-finalize.ts"; // 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→ // 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。 +// ── 需要处理(2026-10-06 去掉待处理中心:整理侧的问题在整理记录里就地处理) ── + +const OPEN_DECISION = ["open", "investigating", "awaiting_user"] as const; + +/** + * 一条整理记录「需要处理」的口径(列表筛选、统计、首页 / 运行状态汇总共用): + * 没被同一下载任务后来的整理记录取代,并且 + * (有关联的未关闭事项(subject = 这条记录)) 或 (失败 / 部分完成且没被用户忽略)。 + * alias:外层 organization_record 的别名(kysely selectFrom("organization_record") 即表名)。 + */ +export function attentionSql(alias = "organization_record") { + const r = sql.raw(alias); + return sql`(not exists ( + select 1 from organization_record r2 + where ${r}.download_task_id is not null and r2.download_task_id = ${r}.download_task_id + and r2.created_at > ${r}.created_at) + and (exists ( + select 1 from decision dd + where dd.subject_type = 'organization_record' and dd.subject_id = ${r}.id + and dd.status in ('open', 'investigating', 'awaiting_user')) + or (${r}.status in ('failed', 'partial') and ${r}.attention_cleared_at is null)))`; +} + +/** 事项归到哪类处理:没识别出作品 / 目标冲突 / 其它 */ +function attentionTopic(dedupeKey: string | null, kind: string | null, reason: string | null) { + if (dedupeKey?.startsWith("organize_identity:") || reason?.startsWith("identity_unknown")) return "identity" as const; + if (dedupeKey?.startsWith("organize_blocked:") || kind === "path_conflict" || reason?.startsWith("blocked")) + return "conflict" as const; + return "other" as const; +} + +async function loadAttention(exec: Exec, rows: OrganizationRecordRow[]) { + const ids = rows.map((r) => r.id); + const [decisions, superseded] = await Promise.all([ + exec + .selectFrom("decision") + .select([ + "id", + "kind", + "status", + "title", + "summary", + "options", + "agent_run_id", + "evidence", + "dedupe_key", + "subject_id", + ]) + .where("subject_type", "=", "organization_record") + .where("subject_id", "in", ids) + .where("status", "in", [...OPEN_DECISION]) + .orderBy("created_at", "desc") + .execute(), + exec + .selectFrom("organization_record as r") + .select("r.id") + .where("r.id", "in", ids) + .where((eb) => + eb.exists( + eb + .selectFrom("organization_record as r2") + .select("r2.id") + .whereRef("r2.download_task_id", "=", "r.download_task_id") + .whereRef("r2.created_at", ">", "r.created_at"), + ), + ) + .execute(), + ]); + const old = new Set(superseded.map((x) => x.id)); + const out = new Map(); + for (const r of rows) { + const d = decisions.find((x) => x.subject_id === r.id) ?? null; + const failed = (r.status === "failed" || r.status === "partial") && !r.attention_cleared_at; + if (old.has(r.id) || (!d && !failed)) { + out.set(r.id, null); + continue; + } + const topic = attentionTopic(d?.dedupe_key ?? null, d?.kind ?? null, r.reason); + const ev = (d?.evidence ?? {}) as Record; + out.set(r.id, { + topic, + decision: d + ? { + id: d.id, + kind: d.kind, + status: d.status, + title: d.title, + summary: d.summary, + options: (d.options ?? []) as DecisionOption[], + agentRunId: d.agent_run_id, + evidence: ev, + } + : null, + agentIdentify: !!d && d.dedupe_key.startsWith("organize_identity:") && typeof ev.downloadTaskId === "string", + }); + } + return out; +} + // ── 公开记录映射 ── export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[], includeFiles: boolean) { if (rows.length === 0) return []; const ids = rows.map((r) => r.id); + const attention = await loadAttention(exec, rows); const entityIds = [...new Set(rows.map((r) => r.media_entity_id).filter((x): x is string => !!x))]; const dlIds = [...new Set(rows.map((r) => r.download_task_id).filter((x): x is string => !!x))]; const [files, ops, entities, idents, units, sizes, dls] = await Promise.all([ @@ -196,6 +296,7 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[ startedAt: iso(r.started_at), finishedAt: iso(r.finished_at), changeSeq: String(r.change_seq), + attention: attention.get(r.id) ?? null, }; }); } diff --git a/apps/server/src/modules/acquisition-organize/services/takeover.ts b/apps/server/src/modules/acquisition-organize/services/takeover.ts index da1e2809..bc7ccc65 100644 --- a/apps/server/src/modules/acquisition-organize/services/takeover.ts +++ b/apps/server/src/modules/acquisition-organize/services/takeover.ts @@ -326,13 +326,7 @@ export function organizeDecisionHook(d: AoDeps): DecisionHook { if (row.dedupe_key.startsWith("organize_identity:")) { if (!dt) throw new HookError("not_found", "关联的下载任务不存在"); const entityId = await chosenEntity(d, body, opt, actor); - await d.ctx.db - .updateTable("download_task") - .set({ media_entity_id: entityId }) - .where("id", "=", dt.id) - .execute(); - const r = await startManualImport(d, { downloadTaskId: dt.id, mediaEntityId: entityId }, actor, "decision"); - return { kind: "organize", mediaEntityId: entityId, ...r }; + return reorganizeAs(d, dt.id, entityId, actor, "decision"); } // organize_blocked:目标已存在 → 替换 / 保留两者 / 跳过 const key = body.optionKey; @@ -367,6 +361,84 @@ export function organizeDecisionHook(d: AoDeps): DecisionHook { }; } +/** 回填下载任务的作品后按该作品重新整理(人工指定与智能体识别共用) */ +async function reorganizeAs( + d: AoDeps, + downloadTaskId: string, + entityId: string, + actor: DecisionHookActor, + via: string, +) { + await d.ctx.db + .updateTable("download_task") + .set({ media_entity_id: entityId }) + .where("id", "=", downloadTaskId) + .execute(); + const r = await startManualImport(d, { downloadTaskId, mediaEntityId: entityId }, actor, via); + return { kind: "organize", mediaEntityId: entityId, ...r }; +} + +const OPEN_DECISION = ["open", "investigating", "awaiting_user"]; + +/** + * organize.assign_identity:智能体给「没识别出作品」的整理事项指定作品(本地作品或 TMDB),随即重新整理并关闭事项。 + * 与人工在整理记录里「指定作品」走同一条路(回填下载任务 → 冻结手动整理计划 → 入队执行)。 + */ +export async function assignOrganizeIdentity( + d: AoDeps, + decisionId: string, + pick: { mediaEntityId?: string | undefined; tmdb?: { type: "movie" | "tv"; id: string } | undefined }, + actor: DecisionHookActor, + note: string, +): Promise<{ mediaEntityId: string; recordId: string | null; status: string; decisionStatus: "resolved" }> { + const db = d.ctx.db; + const row = await db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst(); + if (!row) throw new HookError("not_found", "事项不存在"); + if (!row.dedupe_key.startsWith("organize_identity:")) + throw new HookError("bad_request", "只能用于整理时「没识别出作品」的事项"); + if (!OPEN_DECISION.includes(row.status)) throw new HookError("conflict", "这条记录已经处理过了"); + const ev = (row.evidence ?? {}) as { downloadTaskId?: string | null }; + const dtId = ev.downloadTaskId ?? null; + if (!dtId) throw new HookError("not_found", "这条记录没有关联的下载任务,只能在整理向导里手动整理"); + const entityId = pick.mediaEntityId + ? await chosenEntity( + d, + { identity: { mediaEntityId: pick.mediaEntityId } }, + { key: "x", label: "x", input: { requires: "mediaEntityId" } }, + actor, + ) + : await chosenEntity( + d, + { identity: { tmdb: pick.tmdb } }, + { key: "x", label: "x", input: { requires: "tmdb" } }, + actor, + ); + const r = await reorganizeAs(d, dtId, entityId, actor, "agent-identify"); + const by = actor.id ? `${actor.type}:${actor.id}` : actor.type; + await db.transaction().execute(async (trx) => { + const closed = await trx + .updateTable("decision") + .set({ + status: "resolved", + resolution: jsonb({ optionKey: null, note, by, execution: { ...r, via: "organize.assign_identity" } }), + resolved_by: by, + resolved_at: new Date(), + }) + .where("id", "=", decisionId) + .where("status", "in", OPEN_DECISION as never[]) + .returning("id") + .executeTakeFirst(); + if (closed) + await d.ctx.outbox.emit( + trx, + "decision.updated", + { decisionId, status: "resolved" }, + { idempotencyKey: `decision.updated:${decisionId}:resolved` }, + ); + }); + return { mediaEntityId: entityId, recordId: r.recordId, status: r.status, decisionStatus: "resolved" }; +} + /** 已登记下载任务里,内容路径(本机视图)与这些源路径重叠的(手动按路径整理时找回对应下载任务) */ export async function downloadTasksUnder(exec: Exec, paths: readonly string[]): Promise { if (paths.length === 0) return []; diff --git a/apps/server/src/modules/agent/authorize.ts b/apps/server/src/modules/agent/authorize.ts index 9e690a71..8271d792 100644 --- a/apps/server/src/modules/agent/authorize.ts +++ b/apps/server/src/modules/agent/authorize.ts @@ -4,6 +4,7 @@ import type { CapabilitySpec } from "@umt/contracts"; import { type DB, type Kysely, sql } from "@umt/db"; import type { SettingsReader } from "../types.ts"; import { inlineApproval, optionApproval, proposeFirstBlock } from "./interaction.ts"; +import { investigationDecisionId } from "./runner.ts"; import { exceedsAncestors } from "./subagents.ts"; // 服务端授权(REQ §10.2、§10.3):propose/approve/execute 类能力必须命中 @@ -233,6 +234,24 @@ export function createAuthorizer(deps: { if (block) return { allowed: false, reason: block }; } + // 整理没识别出作品(organize.assign_identity):只放行这条事项的智能体识别会话(dedupe investigate:<事项>)与用户发起的对话; + // 不需要持续授权(用户在设置里开着「整理识别失败时叫智能体识别」即同意,默认开启) + if (d.id === "organize.assign_identity") { + if (!runId || (ctx.source !== "agent" && ctx.source !== "task")) + return { allowed: false, reason: "只有智能体会话或控制台可以指定整理作品" }; + const r = await db + .selectFrom("agent_run") + .select(["trigger", "dedupe_key"]) + .where("id", "=", runId) + .executeTakeFirst(); + if (r?.trigger === "user") return { allowed: true }; + const target = typeof inp.decisionId === "string" ? inp.decisionId : null; + const own = investigationDecisionId(r?.dedupe_key); + return target && own === target + ? { allowed: true } + : { allowed: false, reason: "只能处理本次识别的那条整理记录" }; + } + // 视觉子流程(spawn_reason = browser.solve_challenge)里的坐标动作:父会话那次 solve_challenge 已过授权, // 步数 / 预算 / 白名单由子流程约束,这里随之放行(否则只授权 solve_challenge 时子流程一步也点不了) if (runId && CHALLENGE_STEP_CAPS.has(d.id)) { diff --git a/apps/server/src/modules/agent/investigate.ts b/apps/server/src/modules/agent/investigate.ts index 02c44ce5..2c620947 100644 --- a/apps/server/src/modules/agent/investigate.ts +++ b/apps/server/src/modules/agent/investigate.ts @@ -2,8 +2,16 @@ import type { DecisionOption, DownloadStatus } from "@umt/contracts"; import { type DecisionRow, sql } from "@umt/db"; import type { ServerContext } from "../types.ts"; import { GRANT_ACTIONS } from "./authorize.ts"; -import { type DriveResult, INVESTIGATE_CAPABILITIES, RECOVER_CAPABILITIES, RECOVERY_ACTIONS } from "./runner.ts"; +import { + type DriveResult, + INVESTIGATE_CAPABILITIES, + investigationDecisionId, + ORGANIZE_IDENTIFY_CAPABILITIES, + RECOVER_CAPABILITIES, + RECOVERY_ACTIONS, +} from "./runner.ts"; import { startRun } from "./service.ts"; +import { ORGANIZE_IDENTIFY_ACTOR } from "./usage.ts"; // 后台自动调查与授权内恢复(REQ §8.4.2、§10.7、AT68 / AT71): // decision.created → autoInvestigate:按 settings.agent.autoInvestigateMode 决定只读调查或授权内恢复, @@ -13,6 +21,9 @@ import { startRun } from "./service.ts"; // 按业务事实(download_task / task / source_release 状态;集数映射看事项内资源是否都已不是待确认)核实恢复结果, // 核实成功才关闭事项; // 失败 / 超时 / 预算超限一律保持待处理(finalize 把事项转为 awaiting_user),不自动再试。 +// 整理没识别出作品(organize_identity:*):不看 autoInvestigate,按 agent.autoIdentifyOrganize(缺省开)派一次智能体识别, +// 认出来调 organize.assign_identity 指定作品并重新整理(事项随即关闭);认不出停在整理记录「需要处理」里, +// 用户可在那里再点「让智能体识别」(manual,另起一个会话)。 const KINDS = ["identity_ambiguous", "download_failed", "download_stalled", "episode_mapping"] as const; export const AUTO_ACTOR = "system:auto-investigate"; @@ -66,8 +77,7 @@ export interface RecoveryOutcome { summary: string; } -const decisionIdOf = (dedupeKey: string | null | undefined) => - dedupeKey?.startsWith("investigate:") ? dedupeKey.slice("investigate:".length) : null; +const decisionIdOf = investigationDecisionId; export const isInvestigationRun = (run: { dedupe_key: string | null }) => decisionIdOf(run.dedupe_key) !== null; @@ -128,12 +138,13 @@ async function relatedTask(ctx: ServerContext, d: DecisionRow) { } function suggestionFor(d: DecisionRow, reason: string): string { + if (d.dedupe_key.startsWith("organize_identity:")) return `${reason}。可在整理记录里指定作品或重新整理。`; if (d.kind === "identity_ambiguous") - return `${reason}。可在待处理中手动选择身份,或为 identity.resolve 发放持续授权。`; + return `${reason}。可在媒体库「未识别」里手动指定作品,或为 identity.resolve 发放持续授权。`; if (d.kind === "episode_mapping") - return `${reason}。智能体模拟通过的规则会作为建议留在事项上,可在待处理中确认;或为 episode_mapping.apply_rule 发放持续授权。`; + return `${reason}。智能体模拟通过的规则会作为建议留在作品的集数映射里,可在那里确认;或为 episode_mapping.apply_rule 发放持续授权。`; return ( - `${reason}。可在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权;` + + `${reason}。可在下载详情里「强制重新校验」(download.recheck),或为 download.recheck / task.retry 发放持续授权;` + `换源、删除需人工决定。` ); } @@ -167,8 +178,10 @@ async function addRecheckOption(ctx: ServerContext, d: DecisionRow) { export async function autoInvestigate( ctx: ServerContext, decisionId: string, + opts: { manual?: boolean } = {}, ): Promise<{ runId: string | null; created: boolean; mode?: "investigate" | "recover"; skipped?: string }> { const d = await ctx.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst(); + if (d?.dedupe_key.startsWith("organize_identity:")) return identifyOrganize(ctx, d, opts.manual === true); if (d?.status !== "open") return { runId: null, created: false }; if (!(KINDS as readonly string[]).includes(d.kind)) return { runId: null, created: false }; // Agent 自己引起的事项不再触发 Agent(防止递归自触发) @@ -284,6 +297,96 @@ export async function autoInvestigate( return { runId: run.id, created, mode }; } +/** 证据里的字段转成给模型看的一句(没有就空) */ +function evidenceLine(ev: Record): string { + const parsed = (ev.parsed ?? {}) as Record; + const parts = [ + typeof ev.torrentName === "string" ? `种子名「${ev.torrentName}」` : "", + typeof ev.mainFile === "string" ? `主视频「${ev.mainFile}」` : "", + parsed.title ? `解析出的标题「${String(parsed.title)}」` : "", + parsed.year ? `年份 ${String(parsed.year)}` : "", + parsed.season != null ? `第 ${String(parsed.season)} 季` : "", + Array.isArray(parsed.episodes) && parsed.episodes.length ? `集 ${parsed.episodes.join("、")}` : "", + ].filter(Boolean); + const cands = Array.isArray(ev.candidates) ? (ev.candidates as Record[]).slice(0, 5) : []; + const candText = cands.length + ? `;常规识别的候选:${cands + .map( + (c) => + `${String(c.title ?? "?")}${c.year ? ` (${String(c.year)})` : ""}${c.externalId ? ` tmdb:${String(c.entityType ?? "")}/${String(c.externalId)}` : ""}${c.localEntityId ? ` 本地作品 ${String(c.localEntityId)}` : ""}`, + ) + .join(";")}` + : ""; + return parts.length ? `${parts.join(",")}${candText}。` : candText ? `${candText.slice(1)}。` : ""; +} + +/** + * 整理没识别出作品 → 智能体识别一次(设置 agent.autoIdentifyOrganize,缺省开;manual = 用户在整理记录里点「让智能体识别」, + * 不看开关、事项已转人工也可以,另起一个会话)。会话在白名单内查作品,有把握才调 organize.assign_identity; + * 结果按恢复动作核实(事项关闭 = 已指定并重新整理),没认出来事项转 awaiting_user,留在整理记录「需要处理」里。 + */ +export async function identifyOrganize( + ctx: ServerContext, + d: DecisionRow, + manual: boolean, +): Promise<{ runId: string | null; created: boolean; mode?: "recover"; skipped?: string }> { + if (manual) { + if (!["open", "awaiting_user"].includes(d.status)) + return d.status === "investigating" && d.agent_run_id + ? { runId: d.agent_run_id, created: false, skipped: "running" } + : { runId: null, created: false, skipped: `status_${d.status}` }; + } else { + if (d.status !== "open") return { runId: null, created: false }; + const s = await ctx.settings.get("agent"); + if (!s.autoIdentifyOrganize) return { runId: null, created: false, skipped: "disabled" }; + } + const s = await ctx.settings.get("agent"); + const ev = (d.evidence ?? {}) as Record; + const goal = [ + `识别整理记录里的作品(待处理事项 ${d.id}):${d.title}。`, + evidenceLine(ev), + "用 catalog.lookup 按标题 / 年份查 TMDB,用 inventory.query 看媒体库里有没有这个作品;标题是拼音、缩写或译名对不上时可以用 web.search 查正式名称。", + `确认标题、年份、类型(电影 / 剧集)和季集都对得上时,调用一次 organize.assign_identity(decisionId 填 ${d.id},` + + "本地已有作品填 mediaEntityId,否则填 tmdb 类型和 ID,reasons 写依据),系统会按这个作品重新整理。", + "拿不准就不要指定,只说明查到了什么、为什么认不出。不下载、不删除、不改别的记录。", + ].join(""); + const { run, created } = await startRun(ctx, { + goal, + mode: "execute", + capabilityAllowlist: [...ORGANIZE_IDENTIFY_CAPABILITIES], + trigger: "event", + createdBy: ORGANIZE_IDENTIFY_ACTOR, + dedupeKey: manual ? `investigate:${d.id}#${Date.now()}` : `investigate:${d.id}`, + budget: { maxToolCalls: s.investigateMaxToolCalls, maxDurationSec: Math.min(900, s.maxDurationSec) }, + }); + if (created) { + const r = await ctx.db + .updateTable("decision") + .set({ status: "investigating", agent_run_id: run.id }) + .where("id", "=", d.id) + .where("status", "in", ["open", "awaiting_user"]) + .returning("id") + .executeTakeFirst(); + if (r) { + await mergeEvidence(ctx, d.id, { + mode: "recover", + requestedMode: "recover", + attempt: 1, + maxAttempts: 1, + recoverAttempted: true, + runId: run.id, + }); + await ctx.outbox.emit( + ctx.db, + "decision.updated", + { decisionId: d.id, status: "investigating" }, + { idempotencyKey: `decision.updated:${d.id}:investigating:${run.id}` }, + ); + } + } + return { runId: run.id, created, mode: "recover" }; +} + /** 本 run 调用过的恢复动作(账本)与被拒绝的调用(审计) */ export async function recoveryActions( ctx: ServerContext, @@ -356,6 +459,14 @@ export async function checkRecovery(ctx: ServerContext, actions: RecoveryAction[ : undefined; facts[`source_release:${id}`] = r ? { resolutionStatus: r.resolution_status } : null; states.push(r?.resolution_status === "resolved" && r.media_entity_id ? "recovered" : "pending"); + } else if (a.capabilityId === "organize.assign_identity") { + // 指定作品后事项随即关闭、重新整理已入队;事项关闭即视为恢复(整理结果看整理记录) + const id = String(a.input.decisionId ?? ""); + const d = id + ? await ctx.db.selectFrom("decision").select(["status"]).where("id", "=", id).executeTakeFirst() + : undefined; + facts[`decision:${id}`] = d ? { status: d.status } : null; + states.push(!d ? "failed" : d.status === "resolved" ? "recovered" : "pending"); } else if (a.capabilityId === "episode_mapping.apply_rule") { // 事项内资源都已不是待确认(规则写入后的重算会把事项自动关闭)即视为恢复 const id = String(a.input.decisionId ?? ""); @@ -415,15 +526,15 @@ export async function applyRecoveryOutcome( ? `已恢复(系统核实):执行 ${what} 后业务状态已恢复。` : outcome === "failed" ? done.length > 0 - ? `未恢复:执行 ${what} 后业务状态仍为失败,已退回待处理。` - : `未恢复:${what} 执行失败,已退回待处理。` + ? `未恢复:执行 ${what} 后业务状态仍为失败,已交还人工。` + : `未恢复:${what} 执行失败,已交还人工。` : outcome === "unverified" - ? `未恢复:执行 ${what} 后在核实时限内业务状态未恢复,已退回待处理。` + ? `未恢复:执行 ${what} 后在核实时限内业务状态未恢复,已交还人工。` : outcome === "budget_exceeded" - ? `未恢复:调查超出预算(${res.message ?? ""}),未执行恢复动作,已退回待处理。` + ? `未恢复:调查超出预算(${res.message ?? ""}),未执行恢复动作,已交还人工。` : outcome === "cancelled" ? "未恢复:运行已取消。" - : `未恢复:没有执行恢复动作${acts.denied.length ? `(被拒绝:${acts.denied.map((x) => x.capabilityId).join(", ")})` : ""},已退回待处理。`; + : `未恢复:没有执行恢复动作${acts.denied.length ? `(被拒绝:${acts.denied.map((x) => x.capabilityId).join(", ")})` : ""},已交还人工。`; const evidence: Partial = { runId, outcome, diff --git a/apps/server/src/modules/agent/service.ts b/apps/server/src/modules/agent/service.ts index 76ac9a08..cf36aa61 100644 --- a/apps/server/src/modules/agent/service.ts +++ b/apps/server/src/modules/agent/service.ts @@ -12,7 +12,14 @@ import { type AgentRunRow, type DB, type Kysely, sql } from "@umt/db"; import type { ServerContext } from "../types.ts"; import { dismissPendingInteractions } from "./interaction.ts"; import { buildGoalReport } from "./report.ts"; -import { type AgentEnv, type DriveResult, INVESTIGATE_CAPABILITIES, publishStatus, scopeFor } from "./runner.ts"; +import { + type AgentEnv, + type DriveResult, + INVESTIGATE_CAPABILITIES, + investigationDecisionId, + publishStatus, + scopeFor, +} from "./runner.ts"; import { cascadeCancel, descendants, @@ -364,8 +371,9 @@ export async function finalize( .where("id", "=", runId) .execute(); // 后台调查:结论回写待处理事项,交还人工(仍可被新证据重新唤醒) - if (run.dedupe_key?.startsWith("investigate:")) { - const decisionId = run.dedupe_key.slice("investigate:".length); + const investigated = investigationDecisionId(run.dedupe_key); + if (investigated) { + const decisionId = investigated; await ctx.db.transaction().execute(async (trx) => { const r = await trx .updateTable("decision") diff --git a/apps/server/src/modules/agent/tasks.ts b/apps/server/src/modules/agent/tasks.ts index 278436e4..4e8a89ba 100644 --- a/apps/server/src/modules/agent/tasks.ts +++ b/apps/server/src/modules/agent/tasks.ts @@ -177,12 +177,15 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv) name: "resolveDecision", version: 1, lane: "agent", - inputSchema: z.object({ decisionId: id, trigger: z.enum(["created", "updated"]).default("updated") }), + // identify:用户在整理记录里点「让智能体识别」(整理没识别出作品的事项,另起一个识别会话) + inputSchema: z.object({ decisionId: id, trigger: z.enum(["created", "updated", "identify"]).default("updated") }), retryPolicy: DB_RETRY, handler: async (t) => { const e = env(); if (t.input.trigger === "created") return t.step.run("investigate", () => autoInvestigate(e.ctx, t.input.decisionId)); + if (t.input.trigger === "identify") + return t.step.run("identify", () => autoInvestigate(e.ctx, t.input.decisionId, { manual: true })); return t.step.run("execute", () => executeResolution(e.ctx, t.input.decisionId)); }, }), diff --git a/apps/server/src/modules/search/actions.ts b/apps/server/src/modules/search/actions.ts index 840aabaf..18d17abb 100644 --- a/apps/server/src/modules/search/actions.ts +++ b/apps/server/src/modules/search/actions.ts @@ -23,13 +23,18 @@ const action = (id: string, group: string, title: string, href: string, keywords export const SEARCH_ACTIONS: readonly SearchAction[] = [ page("home", "概览", "首页", "/console", ["home", "dashboard", "概览"]), page("agent", "概览", "智能体会话", "/console/agent", ["agent", "会话"]), - page("inbox", "概览", "待处理", "/console/inbox", ["inbox", "收件箱", "decision"]), page("schedules", "概览", "计划", "/console/schedules", ["schedule", "cron", "巡检", "计划", "定时任务"]), page("sites", "获取", "站点", "/console/sites", ["site", "pt", "站点账号", "rss", "feed", "bt"]), page("subscriptions", "获取", "订阅", "/console/subscriptions", ["subscription", "追剧"]), page("rules", "获取", "规则", "/console/rules", ["rules", "收录策略", "policy"]), page("downloads", "整理", "下载面板", "/console/downloads", ["download", "qb", "下载任务"]), page("organize", "整理", "整理记录", "/console/organize", ["organize", "入库", "历史"]), + page("organize-attention", "整理", "需要处理的整理记录", "/console/organize?tab=records&attention=1", [ + "inbox", + "待处理", + "识别不到", + "decision", + ]), page("files", "整理", "文件管理", "/console/files", ["files", "文件"]), page("trash", "整理", "回收站", "/console/files/trash", ["trash", "删除"]), page("library", "整理", "媒体库", "/console/library", ["library", "媒体库", "作品"]), diff --git a/apps/server/test/acquisition-organize/organize-attention.test.ts b/apps/server/test/acquisition-organize/organize-attention.test.ts new file mode 100644 index 00000000..6b4c6307 --- /dev/null +++ b/apps/server/test/acquisition-organize/organize-attention.test.ts @@ -0,0 +1,227 @@ +import { capabilitySpec } from "@umt/contracts"; +import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit"; +import { sql } from "kysely"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts"; +import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; +import { assignOrganizeIdentity } from "../../src/modules/acquisition-organize/services/takeover.ts"; +import { createAuthorizer } from "../../src/modules/agent/authorize.ts"; +import { autoInvestigate } from "../../src/modules/agent/investigate.ts"; +import { ORGANIZE_IDENTIFY_CAPABILITIES } from "../../src/modules/agent/runner.ts"; +import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; + +// 去掉待处理中心后的整理侧(2026-10-06): +// - 下载完识别不到 → 整理记录「需要处理」(attention 口径:未被后来的整理取代,且有未关闭事项或失败 / 部分完成未忽略); +// - 识别失败自动派一次智能体识别(agent.autoIdentifyOrganize,缺省开;关掉不派), +// 智能体 organize.assign_identity 指定作品后重新整理、事项关闭,旧记录不再算需要处理; +// - 整理记录里「让智能体识别」入队一次识别会话;「忽略」清掉需要处理; +// - /attention:整理需要处理的条数;铃铛只放等你批准的智能体操作与系统健康,整理侧的审批不进铃铛。 + +const hasPg = await pgAvailable(); +const SIZE = 300_000; + +describe.skipIf(!hasPg)("整理记录需要处理 + 识别失败自动叫智能体识别", () => { + let h: Harness; + let qb: MockQb; + let lib: TempTree; + let cache: TempTree; + let n = 0; + + beforeAll(async () => { + qb = await createMockQb({ addVisibleAfterPolls: 0 }); + lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" }); + cache = await createTempTree({}, { prefix: "umt-cache-" }); + h = await createHarness("organize_attention", {}, { withAgent: true }); + await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url }); + await h.setSettings("retention", { seedRetentionDays: 0 }); + }, 60_000); + + afterAll(async () => { + await h?.close(); + await qb?.close(); + await lib?.cleanup(); + await cache?.cleanup(); + }); + + async function adoptCompleted(name: string, file: string) { + n++; + const hash = n.toString(16).padStart(40, "b"); + qb.seed({ hash, name, tags: ["umt"], save_path: cache.root }); + await qb.complete(hash, { files: [{ path: `${name}/${file}`, size: SIZE }] }); + await syncDownloads(h.d); + return h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", hash).executeTakeFirstOrThrow(); + } + + /** 接管任务识别不到 → 失败记录 + organize_identity 事项 */ + async function unidentified(name: string) { + const dt = await adoptCompleted(name, `${name}.mkv`); + const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" }); + expect(plan).toMatchObject({ status: "failed", reason: "identity_unknown" }); + const dec = await h.db + .selectFrom("decision") + .selectAll() + .where("dedupe_key", "=", `organize_identity:${dt.id}`) + .executeTakeFirstOrThrow(); + return { dt, recordId: plan.recordId!, dec }; + } + + const attentionIds = async () => { + const r = await h.api("GET", "/organization-records?attention=true&organizeOnly=true"); + expect(r.status).toBe(200); + return (r.body.data.items as { id: string }[]).map((x) => x.id); + }; + + it("识别不到:记录进「需要处理」,行内带原因与可做的动作;统计同口径", async () => { + const { recordId, dec } = await unidentified("Vqzt.Nowhere.2019.1080p.WEB-DL"); + expect(await attentionIds()).toContain(recordId); + const one = await h.api("GET", `/organization-records/${recordId}`); + expect(one.body.data.attention).toMatchObject({ + topic: "identity", + agentIdentify: true, + decision: { id: dec.id, kind: "identity_ambiguous", status: "open" }, + }); + const stats = await h.api("GET", "/organization-records/stats?organizeOnly=true"); + expect(stats.body.data.attention).toBeGreaterThanOrEqual(1); + const summary = await h.api("GET", "/attention"); + expect(summary.status).toBe(200); + expect(summary.body.data.organize).toBe(stats.body.data.attention); + // 整理侧事项不进铃铛(铃铛只放等你批准的智能体操作与系统健康) + const bell = [...summary.body.data.approvals.items, ...summary.body.data.health.items].map( + (x: { id: string }) => x.id, + ); + expect(bell).not.toContain(dec.id); + }); + + it("开关开(缺省):识别失败自动派一次智能体识别;同一事项不重复派", async () => { + const { dec } = await unidentified("Wqxx.Elsewhere.2020.1080p.WEB-DL"); + const out = await autoInvestigate(h.ctx, dec.id); + expect(out).toMatchObject({ created: true, mode: "recover" }); + const run = await h.db.selectFrom("agent_run").selectAll().where("id", "=", out.runId!).executeTakeFirstOrThrow(); + expect(run).toMatchObject({ + dedupe_key: `investigate:${dec.id}`, + created_by: "system:organize-identify", + trigger: "event", + mode: "execute", + }); + expect((run.scope as { capabilities: string[] }).capabilities.sort()).toEqual( + [...ORGANIZE_IDENTIFY_CAPABILITIES].sort(), + ); + expect(run.goal).toContain("organize.assign_identity"); + expect(run.goal).toContain(dec.id); + const after = await h.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow(); + expect(after).toMatchObject({ status: "investigating", agent_run_id: run.id }); + expect((await autoInvestigate(h.ctx, dec.id)).created).toBe(false); + }); + + it("开关关:不派智能体,事项留在整理记录里", async () => { + await h.setSettings("agent", { autoIdentifyOrganize: false }); + try { + const { dec } = await unidentified("Xqyy.Nomatch.2021.1080p.WEB-DL"); + expect(await autoInvestigate(h.ctx, dec.id)).toMatchObject({ runId: null, created: false, skipped: "disabled" }); + const runs = await h.db + .selectFrom("agent_run") + .select("id") + .where("dedupe_key", "like", `investigate:${dec.id}%`) + .execute(); + expect(runs).toHaveLength(0); + // 用户在整理记录里点「让智能体识别」不看开关:入队一次识别 + const rec = await h.db + .selectFrom("decision") + .select("subject_id") + .where("id", "=", dec.id) + .executeTakeFirstOrThrow(); + const r = await h.api("POST", `/organization-records/${rec.subject_id}/agent-identify`); + expect(r.status).toBe(200); + expect(r.body.data).toEqual({ decisionId: dec.id, queued: true }); + expect(h.engine.enqueued.at(-1)).toMatchObject({ + task: "resolveDecision", + input: { decisionId: dec.id, trigger: "identify" }, + }); + const manual = await autoInvestigate(h.ctx, dec.id, { manual: true }); + expect(manual.created).toBe(true); + const run = await h.db + .selectFrom("agent_run") + .select("dedupe_key") + .where("id", "=", manual.runId!) + .executeTakeFirstOrThrow(); + expect(run.dedupe_key).toMatch(new RegExp(`^investigate:${dec.id}#\\d+$`)); + } finally { + await h.setSettings("agent", {}); + } + }); + + it("智能体识别成功:assign_identity 只放行本事项的会话;指定作品后自动重新整理,事项关闭,旧记录不再需要处理", async () => { + await h.setSettings("automation", { enabled: true }); + const target = await seedEntity(h.db, { kind: "movie", title: "Silent Meridian", year: 2018 }); + const { recordId, dec } = await unidentified("Smrd.2018.1080p.BluRay.x264-GRP"); + const other = await unidentified("Qqqq.Other.2017.1080p.WEB-DL"); + const out = await autoInvestigate(h.ctx, dec.id); + const authorize = createAuthorizer({ db: h.db, settings: h.ctx.settings }); + const spec = capabilitySpec("organize.assign_identity"); + const ctx = { + actorId: "agent", + taskId: "t", + scopeIds: [], + signal: new AbortController().signal, + source: "agent" as const, + agentRunId: out.runId!, + }; + const input = (decisionId: string) => ({ decisionId, mediaEntityId: target, reasons: ["标题年份一致"] }); + const allowed = await authorize({ descriptor: spec, input: input(dec.id), ctx }); + expect(allowed.allowed).toBe(true); + const denied = await authorize({ descriptor: spec, input: input(other.dec.id), ctx }); + expect(denied).toMatchObject({ allowed: false }); + + const r = await assignOrganizeIdentity( + h.d, + dec.id, + { mediaEntityId: target }, + { type: "agent", id: out.runId! }, + "标题年份一致", + ); + expect(r).toMatchObject({ mediaEntityId: target, status: "running", decisionStatus: "resolved" }); + const closed = await h.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow(); + expect(closed.status).toBe("resolved"); + expect((await executeRecord(h.d, r.recordId!)).status).toBe("succeeded"); + const ids = await attentionIds(); + expect(ids).not.toContain(recordId); + expect(ids).not.toContain(r.recordId); + expect(ids).toContain(other.recordId); + // 同一事项再指定:已处理过 + await expect( + assignOrganizeIdentity(h.d, dec.id, { mediaEntityId: target }, { type: "agent", id: null }, "x"), + ).rejects.toThrow(/已经处理过/); + }); + + it("忽略:失败记录不再需要处理,关联事项一并关闭", async () => { + const { recordId, dec } = await unidentified("Zzyy.Ignore.2016.1080p.WEB-DL"); + const r = await h.api("POST", `/organization-records/${recordId}/dismiss-attention`); + expect(r.status).toBe(200); + expect(r.body.data.attention).toBeNull(); + expect(await attentionIds()).not.toContain(recordId); + const d = await h.db.selectFrom("decision").select("status").where("id", "=", dec.id).executeTakeFirstOrThrow(); + expect(d.status).toBe("dismissed"); + }); + + it("/attention:系统健康与等你批准的智能体操作进铃铛,整理记录上的审批不进", async () => { + const run = await h.db + .insertInto("agent_run") + .values({ trigger: "user", goal: "g", status: "waiting", scope: "{}", budget: "{}", created_by: "user:x" }) + .returning("id") + .executeTakeFirstOrThrow(); + const rec = await h.db.selectFrom("organization_record").select("id").limit(1).executeTakeFirstOrThrow(); + const ins = (kind: string, key: string, subjectType: string | null, subjectId: string | null) => + sql`insert into decision (kind, severity, title, subject_type, subject_id, dedupe_key) + values (${kind}, 'warning', ${key}, ${subjectType}, ${subjectId}, ${key})`.execute(h.db); + await ins("cache_volume_unknown", "cache_volume_unknown:vol-1", "downloader_instance", null); + await ins("approval_required", "approval:run", "agent_run", run.id); + await ins("approval_required", "organize_blocked:hnr", "organization_record", rec.id); + await ins("episode_mapping", "episode_mapping:x", "media_entity", null); + const s = (await h.api("GET", "/attention")).body.data; + const keys = (g: { items: { dedupeKey: string }[] }) => g.items.map((x) => x.dedupeKey); + expect(keys(s.health)).toEqual(["cache_volume_unknown:vol-1"]); + expect(keys(s.approvals)).toEqual(["approval:run"]); + expect(s.health.total).toBe(1); + expect(s.approvals.total).toBe(1); + }); +}); diff --git a/apps/server/test/acquisition-organize/records-filter.test.ts b/apps/server/test/acquisition-organize/records-filter.test.ts index f0ee3fa6..8455337c 100644 --- a/apps/server/test/acquisition-organize/records-filter.test.ts +++ b/apps/server/test/acquisition-organize/records-filter.test.ts @@ -107,7 +107,15 @@ describe.skipIf(skip)("整理记录筛选与统计", () => { it("统计与列表同口径,不受状态筛选影响", async () => { const all = await h.api("GET", "/organization-records/stats"); expect(all.status).toBe(200); - expect(all.body.data).toEqual({ total: 4, succeeded: 2, partial: 0, failed: 2, running: 0, cancelled: 0 }); + expect(all.body.data).toEqual({ + total: 4, + succeeded: 2, + partial: 0, + failed: 2, + running: 0, + cancelled: 0, + attention: 2, + }); const movies = await h.api("GET", "/organization-records/stats?kind=movie&organizeOnly=true"); expect(movies.body.data).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); }); diff --git a/apps/server/test/e2e/organize-identify.test.ts b/apps/server/test/e2e/organize-identify.test.ts new file mode 100644 index 00000000..700eff79 --- /dev/null +++ b/apps/server/test/e2e/organize-identify.test.ts @@ -0,0 +1,121 @@ +import { createHash } from "node:crypto"; +import type { FakeScript } from "@umt/agent"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createE2E, type E2E, pgAvailable, runEvents, seedBase, waitFor } from "./harness.ts"; + +// 整理识别不到作品时自动叫智能体识别(2026-10-06,agent.autoIdentifyOrganize 缺省开): +// 用户手动加进 qB 的任务(贴管理标签)下完 → 整理按文件名认不出 → 整理记录失败 + 内部事项 organize_identity +// → decision.created → resolveDecision → 识别会话(白名单只有查询类 + organize.assign_identity,不需要持续授权) +// → 智能体查到本地作品后 organize.assign_identity → 按该作品自动重新整理入库,事项关闭,记录不再需要处理。 + +const hasPg = await pgAvailable(); + +/** 识别会话目标里带着事项 id(脚本从目标里取) */ +const cur: { decisionId?: string; entityId?: string } = {}; + +const identifyScript: FakeScript = { + name: "organize-identify", + match: (goal) => { + const m = /待处理事项 ([0-9a-f-]{36})/.exec(goal); + if (m && goal.includes("识别整理记录里的作品")) cur.decisionId = m[1]; + return goal.includes("识别整理记录里的作品"); + }, + steps: [ + { + text: "先看媒体库里有没有这部。", + toolCalls: [{ capability: "inventory.query", input: { query: "Silent Meridian" } }], + }, + { + text: "标题、年份都对得上,指定为媒体库里的《Silent Meridian》。", + toolCalls: [ + { + capability: "organize.assign_identity", + input: () => ({ + decisionId: cur.decisionId ?? "", + mediaEntityId: cur.entityId ?? "", + reasons: ["种子名缩写 Smrd 对应 Silent Meridian,年份 2018 一致"], + }), + }, + ], + }, + ], + finalText: "已指定作品并重新整理。", +}; + +describe.skipIf(!hasPg)("整理识别不到作品 → 智能体识别 → 自动重新整理", () => { + let e: E2E; + + beforeAll(async () => { + e = await createE2E("orgid", { fakeScripts: [identifyScript] }); + await seedBase(e); + const ent = await e.db + .insertInto("media_entity") + .values({ kind: "movie", title: "Silent Meridian", year: 2018, status: "active" }) + .returning("id") + .executeTakeFirstOrThrow(); + cur.entityId = ent.id; + await e.settings({ retention: { seedRetentionDays: 0 } }); + await e.startWorker(); + }, 120_000); + + afterAll(async () => { + await e?.close(); + }, 60_000); + + it("识别失败自动派一次识别会话;指定作品后自动重新整理入库,事项关闭,记录不再需要处理", async () => { + const name = "Smrd.2018.1080p.BluRay.x264-QQQ"; + const hash = createHash("sha1").update("orgid-1").digest("hex"); + e.qb.seed({ hash, name, size: 400_000, progress: 1, tags: ["umt"], save_path: e.cache.root }); + await e.qb.complete(hash, { files: [{ path: `${name}/${name}.mkv`, size: 400_000 }], writeFiles: true }); + + const dec = await waitFor( + () => + e.db.selectFrom("decision").selectAll().where("dedupe_key", "like", "organize_identity:%").executeTakeFirst(), + 60_000, + "整理识别不到 → organize_identity 事项", + ); + const run = await waitFor( + () => + e.db.selectFrom("agent_run").selectAll().where("dedupe_key", "=", `investigate:${dec.id}`).executeTakeFirst(), + 60_000, + "识别会话", + ); + expect(run).toMatchObject({ trigger: "event", mode: "execute", created_by: "system:organize-identify" }); + + const done = await waitFor( + async () => { + const r = await e.db.selectFrom("agent_run").selectAll().where("id", "=", run.id).executeTakeFirstOrThrow(); + return r.finished_at ? r : null; + }, + 120_000, + "识别会话结束", + ); + const starts = (await runEvents(e, run.id)) + .filter((x) => x.type === "tool_call_start") + .map((x) => (x.payload as { capabilityId: string }).capabilityId); + expect(starts).toEqual(["inventory.query", "organize.assign_identity"]); + expect(done.status).toBe("succeeded"); + expect(done.summary).toContain("已恢复"); + + const closed = await e.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow(); + expect(closed.status).toBe("resolved"); + + // 自动重新整理:同一下载任务的新整理记录成功入库,作品是智能体指定的 + const rec = await waitFor( + async () => { + const r = await e.db + .selectFrom("organization_record") + .selectAll() + .where("status", "=", "succeeded") + .where("media_entity_id", "=", cur.entityId!) + .executeTakeFirst(); + return r ?? null; + }, + 90_000, + "重新整理入库", + ); + expect(rec.download_task_id).toBeTruthy(); + const attention = await e.api("GET", "/organization-records?attention=true"); + expect(attention.body.data.items).toEqual([]); + }, 300_000); +}); diff --git a/docs/runbook/organize.md b/docs/runbook/organize.md index cb462d4a..756d03c2 100644 --- a/docs/runbook/organize.md +++ b/docs/runbook/organize.md @@ -103,6 +103,31 @@ MP 对照源:`MoviePilot-Frontend/src/views/reorganize/TransferHistoryView.vue` | 批量重新整理 | 新增 | 选中记录的可用文件一起进整理弹窗(成功移动取目标,其他取源);同属一个作品时预选作品 | | 清空整理记录 | 不做 | 整理记录同时是文件变更的追溯记录;清理按保留期由计划页负责 | +## 需要处理(2026-10-06,待处理中心取消) + +整理侧的问题(下载完识别不到、目标已存在 / 路径冲突、文件被排除、非视频、整理卡住 / 失败)不再进待处理中心,在整理记录里就地处理。 +`decision` 表保留为内部机制(去重、触发智能体、自动关闭),整理侧事项的 subject 都是整理记录。 + +**口径**(`attentionSql`,`services/organize-records.ts`;列表 `?attention=true`、统计 `attention`、`GET /attention` 的 `organize` 共用): +没被同一下载任务后来的整理记录取代,并且(有 subject 为这条记录的未关闭事项,或失败 / 部分完成且 `attention_cleared_at` 为空)。 + +**行内动作**(`PublicOrganizationRecord.attention`:`topic` + 精简事项 + `agentIdentify`): + +| topic | 原因 | 动作 | +|---|---|---| +| `identity` | 没识别出作品(`organize_identity:*`) | 指定作品(本地 / TMDB,`/decisions/{id}/resolve` → 钩子重新整理)、候选、让智能体识别、重新整理、忽略 | +| `conflict` | 目标已存在 / 路径冲突(`organize_blocked:*`、`path_conflict`) | 事项选项:替换 / 保留两者 / 跳过;忽略 | +| `other` | 被排除、非视频、卡住、执行失败… | 重新整理(整理向导)、忽略 | + +- 忽略:`POST /organization-records/{id}/dismiss-attention` 记 `attention_cleared_at`(迁移 0078),关联的未关闭事项一并 `dismissed`。 +- 让智能体识别:`POST /organization-records/{id}/agent-identify`,见 agent.md「整理识别不到作品时叫智能体识别」;识别失败缺省会自动派一次。 +- 旧链接 `/console/inbox?kind=` 按问题类型跳转(`routes/console/redirects.ts inboxRedirect`):整理类 → 整理记录「需要处理」, + 下载类 → 下载面板,健康类 → 运行状态 `#attention`,集数待确认 → 订阅。 + +**铃铛与汇总**(`GET /attention`):铃铛只放等你批准的智能体操作(subject 为 agent_run 的审批 / 方案选择、计划审批、`browser.waiting:*`; +整理记录上的审批不进)与系统健康(`source_unreachable`、`downloader_offline`、`cache_volume_unknown`、`space_insufficient`、 +`library_root_offline:*`,恢复后自动关闭即消失)。首页「要我处理」与运行状态「需要关注」放汇总计数,点进去到对应页面。 + ## 删除 `POST /organization-records/delete {ids[≤200], deleteSource, deleteTarget, dryRun}`,scope `organize.execute`。 diff --git a/packages/agent/skills/import-troubleshoot/SKILL.md b/packages/agent/skills/import-troubleshoot/SKILL.md index 41e11f0a..d1397afc 100644 --- a/packages/agent/skills/import-troubleshoot/SKILL.md +++ b/packages/agent/skills/import-troubleshoot/SKILL.md @@ -8,6 +8,7 @@ triggers: - 识别(错|不出)|认错|没识别|对不上作品 - 为什么.{0,20}(没|不|还没) capabilities: + - catalog.lookup - inventory.query - download.list - download.inspect @@ -18,6 +19,7 @@ capabilities: - organize.execute - metadata.enrich - metadata.resolve_organize + - organize.assign_identity sessions: [chat, investigate, child] --- @@ -33,14 +35,15 @@ sessions: [chat, investigate, child] - 还在下载 / 无进展 → 汇报进度和最近进展时间; - 已完成待整理 / 整理中 → 看关联的核验入库任务(task.inspect 看错误); - 入库失败 → 读整理记录和错误,一句话说原因; - - 有「没识别出作品」的待处理事项 → decision.list 确认,说明要用户指定作品。 -4. 身份问题:资源还没对上作品时可以用 identity.resolve 提出映射;TMDB 查不到的作品,网页搜索开启时用 metadata.enrich 出建议。整理要用的资源连常规识别也对不上作品时,用 metadata.resolve_organize 补全译名重查 TMDB,仍没有合适条目才建立带来源证据的本地作品。 + - 整理记录「没识别出作品」→ decision.list 确认事项,按下面第 4 步认作品;认出来用 organize.assign_identity 指定(会重新整理),认不出说明要用户在整理记录里指定作品。 +4. 身份问题:资源还没对上作品时可以用 identity.resolve 提出映射;TMDB 查不到的作品,网页搜索开启时用 metadata.enrich 出建议。整理要用的资源连常规识别也对不上作品时,用 metadata.resolve_organize 补全译名重查 TMDB,仍没有合适条目才建立带来源证据的本地作品。整理时「没识别出作品」的记录(接管任务、文件名认不出):catalog.lookup / inventory.query 核对标题、年份、类型和季集都对得上后,organize.assign_identity 写一次(带依据),系统按该作品重新整理;拿不准不要指定。 5. 需要重新整理时:organize.plan 生成计划(不动文件),再 organize.execute。 ## 需要批准 - organize.execute(执行整理)、identity.resolve(身份映射)需要授权;没有授权时写一句「没做:重新整理,需要你批准」。 - organize.plan、查询类不需要批准。 +- organize.assign_identity(指定整理作品)不需要持续授权:用户对话里可直接用,后台识别会话只能处理它自己那条记录。 ## 汇报模板 diff --git a/packages/agent/src/prompt/layers/capabilities.ts b/packages/agent/src/prompt/layers/capabilities.ts index 7e9f17f5..61dcabed 100644 --- a/packages/agent/src/prompt/layers/capabilities.ts +++ b/packages/agent/src/prompt/layers/capabilities.ts @@ -130,6 +130,7 @@ export const INTENT_ROUTES: readonly IntentRoute[] = [ { cap: "web.fetch", note: "读其中一条结果的正文" }, { cap: "metadata.enrich", note: "TMDB 查不到的作品出元数据建议" }, { cap: "metadata.resolve_organize", note: "整理用的资源对不上作品时兜底识别或建本地作品" }, + { cap: "organize.assign_identity", note: "整理时没识别出作品:查到作品后指定,随即重新整理" }, ], }, ]; diff --git a/packages/contracts/src/api/library.ts b/packages/contracts/src/api/library.ts index 46cf35ba..7fbaf76b 100644 --- a/packages/contracts/src/api/library.ts +++ b/packages/contracts/src/api/library.ts @@ -149,6 +149,15 @@ export const libraryApi = { response: organization.Dashboard, }), }, + attention: { + summary: ep({ + method: "get", + path: "/attention", + scope: "read", + summary: "需要关注的汇总:整理记录需要处理的条数、等你批准的智能体操作、系统健康问题", + response: organization.AttentionSummary, + }), + }, downloadCache: { cleanup: ep({ method: "post", diff --git a/packages/contracts/src/api/organize.ts b/packages/contracts/src/api/organize.ts index 872c0f5a..8011ee50 100644 --- a/packages/contracts/src/api/organize.ts +++ b/packages/contracts/src/api/organize.ts @@ -49,6 +49,22 @@ export const organizeApi = { body: organization.OrganizationDeleteRequest, response: organization.OrganizationDeleteResult, }), + dismissAttention: ep({ + method: "post", + path: "/organization-records/{id}/dismiss-attention", + scope: "organize.execute", + summary: "忽略这条整理记录的问题:不再算需要处理,关联的未关闭事项一并关闭", + params: IdParam, + response: organization.PublicOrganizationRecord, + }), + agentIdentify: ep({ + method: "post", + path: "/organization-records/{id}/agent-identify", + scope: "organize.execute", + summary: "让智能体识别这条整理记录的作品(没识别出作品的记录),认出来自动重新整理", + params: IdParam, + response: organization.OrganizationAgentIdentifyResult, + }), create: ep({ method: "post", path: "/organization-records", diff --git a/packages/contracts/src/capabilities-io.ts b/packages/contracts/src/capabilities-io.ts index edc5b774..79733169 100644 --- a/packages/contracts/src/capabilities-io.ts +++ b/packages/contracts/src/capabilities-io.ts @@ -1088,6 +1088,34 @@ export const EpisodeMappingApplyRuleOutput = z.object({ decisionStatus: DecisionStatus, }); +/** organize.assign_identity:给「没识别出作品」的整理事项指定作品并重新整理 */ +/** mediaEntityId 与 tmdb 二选一(都给时用 mediaEntityId,都没给由处理器拒绝) */ +export const OrganizeAssignIdentityInput = z.object({ + /** 「没识别出作品」事项 ID(调查目标里给出) */ + decisionId: Id, + mediaEntityId: Id.optional(), + tmdb: z + .object({ + type: z.enum(["movie", "tv"]), + id: z + .string() + .trim() + .regex(/^\d{1,10}$/), + }) + .optional(), + /** 为什么是这个作品(依据),至少一条 */ + reasons: z.array(z.string().trim().min(1).max(300)).min(1).max(10), +}); +export const OrganizeAssignIdentityOutput = z.object({ + mediaEntityId: Id, + /** 重新整理的记录与状态 */ + recordId: Id.nullable(), + status: z.string(), + decisionStatus: DecisionStatus, +}); +export type OrganizeAssignIdentityInput = z.infer; +export type OrganizeAssignIdentityOutput = z.infer; + /** 能力 ID → 输入/输出 schema(server 注册处理器、agent 生成工具定义时共用) */ // ── schedule.draft_check:按自然语言需求起草巡检(建成未启用,用户在计划页检查后启用) ── @@ -1167,6 +1195,7 @@ export const CAPABILITY_IO = { "episode_mapping.inspect": { input: EpisodeMappingInspectInput, output: EpisodeMappingInspectOutput }, "episode_mapping.simulate": { input: EpisodeMappingSimulateInput, output: EpisodeMappingSimulateOutput }, "episode_mapping.apply_rule": { input: EpisodeMappingApplyRuleInput, output: EpisodeMappingApplyRuleOutput }, + "organize.assign_identity": { input: OrganizeAssignIdentityInput, output: OrganizeAssignIdentityOutput }, } as const satisfies Record; type CapabilityIo = typeof CAPABILITY_IO; diff --git a/packages/contracts/src/capability.ts b/packages/contracts/src/capability.ts index c1763ad2..0d8035d6 100644 --- a/packages/contracts/src/capability.ts +++ b/packages/contracts/src/capability.ts @@ -100,6 +100,7 @@ export const CAPABILITY_IDS = [ "episode_mapping.inspect", "episode_mapping.simulate", "episode_mapping.apply_rule", + "organize.assign_identity", ] as const; export const KnownCapabilityId = z.enum(CAPABILITY_IDS); export type KnownCapabilityId = z.infer; @@ -668,6 +669,19 @@ export const CAPABILITY_CATALOG: Record false, true, ), + // 整理时没识别出作品(organize_identity 事项):智能体查到作品后指定,随即按该作品重新整理 + "organize.assign_identity": d( + "organize.assign_identity", + "指定整理作品", + "给没识别出作品的整理记录指定作品(本地作品 ID 或 TMDB 类型 + ID),随即按这个作品重新整理。只用于「没识别出作品」事项;先用 catalog.lookup / inventory.query 核对标题、年份、季集,拿不准就不要指定", + "execute", + "OrganizeAssignIdentity", + "organize.execute", + "medium", + false, + false, + "never", + ), }; /** @@ -733,6 +747,7 @@ export const CAPABILITY_ACTIVITY: Record "episode_mapping.inspect": act("查", "集数映射"), "episode_mapping.simulate": act("模拟", "映射规则", "次"), "episode_mapping.apply_rule": act("写入", "映射规则", "条"), + "organize.assign_identity": act("指定", "整理作品", "个"), }; function d( diff --git a/packages/contracts/src/organization.ts b/packages/contracts/src/organization.ts index 470cf93d..948ab5df 100644 --- a/packages/contracts/src/organization.ts +++ b/packages/contracts/src/organization.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { AgentRunStatus, DecisionKind } from "./agent.ts"; +import { AgentRunStatus, Decision, DecisionKind } from "./agent.ts"; import { AssetLocation, MediaAsset, PathBinding } from "./assets.ts"; import { ActorType, EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common.ts"; import { MediaEntity, MediaKind } from "./content.ts"; @@ -73,6 +73,8 @@ export const OrganizationRecord = EntityBase.extend({ startedAt: Timestamp.nullable(), finishedAt: Timestamp.nullable(), changeSeq: ChangeSeq, + /** 用户在整理记录里忽略了这条记录的问题(不再算需要处理) */ + attentionClearedAt: Timestamp.nullable(), }); export type OrganizationRecord = z.infer; export const OrganizationRecordCreate = OrganizationRecord.pick({ @@ -211,6 +213,32 @@ export const PublicOrganizationFile = z.object({ }); export type PublicOrganizationFile = z.infer; +/** 整理记录关联的未关闭事项(在整理记录里就地处理;选项与「处理」端点同 /decisions/{id}/resolve) */ +export const RecordAttentionDecision = Decision.pick({ + id: true, + kind: true, + status: true, + title: true, + summary: true, + options: true, + agentRunId: true, + evidence: true, +}); +export type RecordAttentionDecision = z.infer; + +/** + * 需要处理(2026-10-06 去掉待处理中心后,整理侧的问题在整理记录里就地处理): + * topic:identity = 没识别出作品(可指定作品 / 让智能体识别);conflict = 目标已存在 / 路径冲突(按选项替换、保留两者或跳过); + * other = 其余失败或部分完成(排除、非视频、卡住、执行失败…,重新整理或忽略)。 + * agentIdentify:可以让智能体识别(没识别出作品且有关联下载任务)。 + */ +export const RecordAttention = z.object({ + topic: z.enum(["identity", "conflict", "other"]), + decision: RecordAttentionDecision.nullable(), + agentIdentify: z.boolean(), +}); +export type RecordAttention = z.infer; + export const PublicOrganizationRecord = z.object({ /** 稳定记录 ID */ id: Id, @@ -261,6 +289,8 @@ export const PublicOrganizationRecord = z.object({ finishedAt: Timestamp.nullable(), /** 该记录最近一次变更的游标值;客户端按 id 去重、以最大 changeSeq 续读 */ changeSeq: ChangeCursor, + /** 需要处理时给出原因与可做的动作;不需要为 null(旧客户端可忽略) */ + attention: RecordAttention.nullable().optional(), }); export type PublicOrganizationRecord = z.infer; @@ -303,6 +333,13 @@ export const OrganizationListQuery = z.object({ since: Timestamp.optional(), /** HIS-1:只看整理类记录(复制/移动/硬链接),不含文件管理的改名、删除、还原 */ organizeOnly: z.union([z.boolean(), z.enum(["true", "false"]).transform((v) => v === "true")]).optional(), + /** + * 只看需要处理的记录:有关联的未关闭事项,或失败 / 部分完成、没被同一下载任务后来的整理取代、也没被忽略的 + * (口径见 docs/runbook/organize.md「需要处理」) + */ + attention: z + .union([z.boolean(), z.enum(["true", "false", "1", "0"]).transform((v) => v === "true" || v === "1")]) + .optional(), }); export type OrganizationListQuery = z.infer; @@ -317,6 +354,8 @@ export const OrganizationStats = z.object({ /** 排队 + 执行中 */ running: NonNegInt, cancelled: NonNegInt, + /** 需要处理(同列表 attention=true 的口径,不受状态筛选影响) */ + attention: NonNegInt, }); export type OrganizationStats = z.infer; @@ -764,3 +803,21 @@ export const OrganizePlanArtifactContent = z.object({ .optional(), }); export type OrganizePlanArtifactContent = z.infer; + +/** 让智能体识别整理记录里的作品(没识别出作品的记录):入队一次识别会话 */ +export const OrganizationAgentIdentifyResult = z.object({ decisionId: Id, queued: z.boolean() }); +export type OrganizationAgentIdentifyResult = z.infer; + +/** + * 需要用户关注的汇总(铃铛、首页、运行状态共用): + * - organize:整理记录里需要处理的条数(点进整理记录「需要处理」); + * - approvals:需要你批准的智能体操作(会话里的审批 / 方案选择、计划审批、原站页面等你接手); + * - health:系统健康问题(站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线),恢复后自动消失。 + * 集数待确认、未识别资源等不在这里:前者在订阅详情与作品页集数映射里看原因,后者静默留在未识别里。 + */ +export const AttentionSummary = z.object({ + organize: NonNegInt, + approvals: z.object({ total: NonNegInt, items: z.array(Decision) }), + health: z.object({ total: NonNegInt, items: z.array(Decision) }), +}); +export type AttentionSummary = z.infer; diff --git a/packages/contracts/src/reasons.ts b/packages/contracts/src/reasons.ts index 3b8d0cdb..49ace094 100644 --- a/packages/contracts/src/reasons.ts +++ b/packages/contracts/src/reasons.ts @@ -1,4 +1,4 @@ -// 内部原因码 → 一句人话 + 下一步(DEAI-2)。整理记录、待处理、下载时间线、任务都用这张表; +// 内部原因码 → 一句人话 + 下一步(DEAI-2)。整理记录、下载时间线、任务都用这张表; // 原码只放在界面的「技术信息」里,不直接给人看。不认识的码返回 null,由调用方决定兜底文字。 export type ReasonText = { @@ -19,9 +19,9 @@ const REASONS: Record = { identity_unresolved: { text: "没识别出作品", action: "指定作品" }, episode_mapping: { text: "集数对不上作品的季集", action: "确认集数映射" }, episode_unresolved: { text: "集数对不上作品的季集", action: "确认集数映射" }, - target_exists: { text: "媒体库里已有同名文件", action: "在待处理里选择替换或跳过" }, + target_exists: { text: "媒体库里已有同名文件", action: "在整理记录里选择替换、保留两者或跳过" }, duplicate_target: { text: "改名后与其他文件同名", action: "改命名模板或排除其中一个" }, - replace_requires_grant: { text: "替换已有文件需要你批准", action: "在待处理里批准" }, + replace_requires_grant: { text: "替换已有文件需要你批准", action: "在整理记录里选择是否替换" }, hnr_unsatisfied: { text: "做种时间还不够,不能移动", action: "改用复制,或等做种完成" }, hnr_unknown: { text: "不确定做种要求是否满足", action: "改用复制,或确认站点规则" }, orphan_sidecar: { text: "字幕等附属文件找不到对应视频" }, @@ -51,7 +51,7 @@ const REASONS: Record = { downloader_unavailable: { text: "下载器连不上", action: "在设置 → 下载器里测试连接" }, downloader_error: { text: "下载器报错", action: "查看下载器日志" }, // DL-2b:下载面板整理状态、下载计划里出现的码 - blocked: { text: "有冲突要先处理", action: "去待处理" }, + blocked: { text: "有冲突要先处理", action: "在整理记录里处理" }, blocked_by_conflict: { text: "有冲突,暂不整理" }, transfer_failed: { text: "文件没有整理成功", action: "在整理记录里查看失败的文件" }, main_failed: { text: "主文件整理失败,附属文件一起跳过" }, diff --git a/packages/contracts/test/reasons.test.ts b/packages/contracts/test/reasons.test.ts index 6f04da44..4e570477 100644 --- a/packages/contracts/test/reasons.test.ts +++ b/packages/contracts/test/reasons.test.ts @@ -12,7 +12,7 @@ describe("reasonText(DEAI-2 原因码人话化)", () => { it("下载面板用到的码(DL-2b):整理失败、冲突、下载被删", () => { expect(reasonSentence("transfer_failed")).toBe("文件没有整理成功。在整理记录里查看失败的文件"); - expect(reasonSentence("blocked")).toBe("有冲突要先处理。去待处理"); + expect(reasonSentence("blocked")).toBe("有冲突要先处理。在整理记录里处理"); expect(reasonText("download_removed")?.text).toBe("下载任务已从下载器删除"); expect(reasonText("target_root_not_managed")?.text).toContain("本工具管理"); for (const c of ["blocked_by_conflict", "main_failed", "not_imported_kind"]) expect(reasonText(c)).not.toBeNull(); diff --git a/packages/db/migrations/0078_organize_attention.ts b/packages/db/migrations/0078_organize_attention.ts new file mode 100644 index 00000000..fa7c0bc1 --- /dev/null +++ b/packages/db/migrations/0078_organize_attention.ts @@ -0,0 +1,15 @@ +import type { Kysely } from "kysely"; +import { execDdl } from "../src/migration-helpers.ts"; + +// 整理记录「需要处理」(2026-10-06,去掉待处理中心):失败 / 部分完成的记录在整理记录里就地处理; +// 用户点「忽略」时记下时间,该记录不再算「需要处理」(关联的未关闭事项一并关闭)。 + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl(db, `alter table organization_record add column attention_cleared_at timestamptz;`); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl(db, `alter table organization_record drop column if exists attention_cleared_at;`); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 530f79d2..041d46d2 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -1093,6 +1093,7 @@ export interface OrganizationRecordTable { updated_at: GeneratedTimestamptz; requeue_count: Generated; requeued_at: Timestamptz | null; + attention_cleared_at: Timestamptz | null; } export interface OrganizeSourceSnapshotTable {