feat(整理): 问题在整理记录就地处理,识别失败自动叫智能体识别
- 整理记录「需要处理」:列表 attention 筛选与统计同口径(没被后来的整理取代,且有未关闭事项或失败 / 部分完成未忽略), 记录带 attention(原因类别、精简事项、能否让智能体识别);忽略端点记 attention_cleared_at(迁移 0078)并关闭关联事项 - 识别失败(organize_identity)按 agent.autoIdentifyOrganize 自动派一次识别会话;新能力 organize.assign_identity 指定作品后复用人工指定的钩子路径自动重新整理,只放行本事项的识别会话与用户对话;整理记录里可手动「让智能体识别」 - GET /attention:整理需要处理条数、等你批准的智能体操作、系统健康问题(铃铛 / 首页 / 运行状态共用) - 原因码文案不再指向待处理;命令面板「待处理」换成「需要处理的整理记录」 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
414ea7c890
commit
1c1a954703
25 files changed
+1037
-35
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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<boolean>`(subject_type = 'agent_run'
|
||||
or (kind = 'approval_required' and coalesce(subject_type, '') <> 'organization_record')
|
||||
or dedupe_key like 'browser.waiting:%')`;
|
||||
/** 系统健康:站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线(恢复后自动关闭) */
|
||||
const HEALTH_SQL = sql<boolean>`(kind in ('source_unreachable', 'downloader_offline', 'cache_volume_unknown', 'space_insufficient')
|
||||
or dedupe_key like 'library_root_offline:%')`;
|
||||
const OPEN_SQL = sql<boolean>`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<string>().as("n"))
|
||||
.where(OPEN_SQL)
|
||||
.where(where)
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
return { total: Number(total?.n ?? 0), items: toApiList<Decision>(items) };
|
||||
};
|
||||
const [organize, approvals, health] = await Promise.all([
|
||||
db
|
||||
.selectFrom("organization_record")
|
||||
.select((eb) => eb.fn.countAll<string>().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,
|
||||
{
|
||||
|
||||
@@ -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<string>().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<Q extends SelectQueryBuilder<DB, "organization_record", a
|
||||
if (query.downloadTaskId) q = q.where("download_task_id", "=", query.downloadTaskId);
|
||||
if (query.since) q = q.where("created_at", ">=", 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<DB, "organization_record">) =>
|
||||
|
||||
@@ -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<boolean>`(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<string, RecordAttention | null>();
|
||||
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<string, unknown>;
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<DownloadTaskRow[]> {
|
||||
if (paths.length === 0) return [];
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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, unknown>): string {
|
||||
const parsed = (ev.parsed ?? {}) as Record<string, unknown>;
|
||||
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<string, unknown>[]).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<string, unknown>;
|
||||
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<AutoRecoverEvidence> = {
|
||||
runId,
|
||||
outcome,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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", "媒体库", "作品"]),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user