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
@@ -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