merge: fix/q-inline-handling
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei # Conflicts: # apps/server/src/modules/sources-identity/service/episode-resolution.ts
This commit is contained in:
commit
c465b533fd
80 files changed
+2766
-1016
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,110 @@
|
||||
import { DEFAULT_SETTINGS, DEPRECATED_SETTINGS_FIELDS, type GlobalSettings } from "@umt/contracts";
|
||||
import type { PiUpstreamRow } from "@umt/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createAgentModule } from "../../src/modules/agent/index.ts";
|
||||
import { agentSettings } from "../../src/modules/agent/runner.ts";
|
||||
import { ORGANIZE_IDENTIFY_ACTOR } from "../../src/modules/agent/usage.ts";
|
||||
import { createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
|
||||
import { DEV_KEY, json } from "../helpers.ts";
|
||||
|
||||
// 智能体不设硬性预算上限(2026-10-06):每天次数 / 每天花费 / 单次花费 / 单次 token 四个上限不再拦截(旧值兼容读取、不生效),
|
||||
// 只保留单次工具调用次数与单次最长时长两个防跑飞限制(缺省调宽);花费改为可见:/agent/usage 按会话类型统计今天与最近 7 天。
|
||||
|
||||
const upstream = { row: { provider: "fake", default_model: null, thinking_level: "off" } as unknown as PiUpstreamRow };
|
||||
|
||||
describe("预算上限移除,防跑飞限制保留", () => {
|
||||
it("旧设置里的 token / 费用上限不进运行参数;工具调用次数与时长照用", () => {
|
||||
const legacy = {
|
||||
...DEFAULT_SETTINGS,
|
||||
agent: { ...DEFAULT_SETTINGS.agent, maxTokensPerRun: 1000, maxCostUsdPerRun: 0.01 },
|
||||
budget: { ...DEFAULT_SETTINGS.budget, agentMaxRunsPerDay: 0, agentMaxCostUsdPerDay: 0 },
|
||||
} as GlobalSettings;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 只取用到的上游字段
|
||||
const s = agentSettings(legacy, {}, true, upstream as any);
|
||||
expect(s).not.toHaveProperty("costBudget");
|
||||
expect(s.maxToolCalls).toBe(DEFAULT_SETTINGS.agent.maxToolCalls);
|
||||
expect(s.maxDurationMs).toBe(DEFAULT_SETTINGS.agent.maxDurationSec * 1000);
|
||||
// 调用方显式划拨(子会话、巡检预算)仍然生效
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
const sub = agentSettings(legacy, { maxToolCalls: 3, maxCostUsd: 0.5 }, true, upstream as any);
|
||||
expect(sub.maxToolCalls).toBe(3);
|
||||
expect(sub.costBudget).toEqual({ maxCostUsd: 0.5 });
|
||||
});
|
||||
|
||||
it("缺省调宽;四个上限登记为弃用字段", () => {
|
||||
expect(DEFAULT_SETTINGS.agent.maxToolCalls).toBeGreaterThanOrEqual(100);
|
||||
expect(DEFAULT_SETTINGS.agent.maxDurationSec).toBeGreaterThanOrEqual(1800);
|
||||
expect(DEFAULT_SETTINGS.agent.investigateMaxToolCalls).toBeGreaterThanOrEqual(20);
|
||||
expect(DEFAULT_SETTINGS.agent.autoIdentifyOrganize).toBe(true);
|
||||
expect(DEFAULT_SETTINGS.budget).not.toHaveProperty("agentMaxRunsPerDay");
|
||||
expect(DEPRECATED_SETTINGS_FIELDS).toEqual(
|
||||
expect.arrayContaining([
|
||||
"budget.agentMaxRunsPerDay",
|
||||
"budget.agentMaxCostUsdPerDay",
|
||||
"agent.maxTokensPerRun",
|
||||
"agent.maxCostUsdPerRun",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe.skipIf(!hasPg)("花费统计 /agent/usage(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await createHarness({ extraModules: [createAgentModule()] });
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("今天与最近 7 天按会话类型统计次数、token 与费用;超过 7 天的不算", async () => {
|
||||
const day = 86_400_000;
|
||||
const now = Date.now();
|
||||
const run = async (
|
||||
trigger: string,
|
||||
at: number,
|
||||
usage: { totalTokens: number; costUsd: number },
|
||||
extra: Record<string, unknown> = {},
|
||||
) =>
|
||||
(
|
||||
await h.db
|
||||
.insertInto("agent_run")
|
||||
.values({
|
||||
trigger,
|
||||
goal: "g",
|
||||
status: "succeeded",
|
||||
mode: "execute",
|
||||
usage: JSON.stringify(usage),
|
||||
started_at: new Date(at),
|
||||
created_at: new Date(at),
|
||||
...extra,
|
||||
} as never)
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const chat = await run("user", now, { totalTokens: 1000, costUsd: 0.1 });
|
||||
await run("subagent", now, { totalTokens: 500, costUsd: 0.05 }, { parent_run_id: chat });
|
||||
await run("event", now, { totalTokens: 200, costUsd: 0.02 }, { created_by: ORGANIZE_IDENTIFY_ACTOR });
|
||||
await run("event", now - 3 * day, { totalTokens: 300, costUsd: 0.03 });
|
||||
await run("schedule", now - 3 * day, { totalTokens: 400, costUsd: 0.04 });
|
||||
await run("user", now - 10 * day, { totalTokens: 9999, costUsd: 9 });
|
||||
|
||||
const res = await h.app.request("/api/v1/agent/usage", { headers: { authorization: `Bearer ${DEV_KEY}` } });
|
||||
expect(res.status).toBe(200);
|
||||
const { today, last7d } = (await json(res)).data;
|
||||
expect(today.runs).toBe(3);
|
||||
expect(today.tokens).toBe(1700);
|
||||
expect(today.costUsd).toBeCloseTo(0.17);
|
||||
expect(today.byKind.map((k: { kind: string }) => k.kind)).toEqual(["chat", "organize_identify", "subagent"]);
|
||||
expect(last7d.runs).toBe(5);
|
||||
expect(last7d.costUsd).toBeCloseTo(0.24);
|
||||
expect(last7d.byKind).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "patrol", runs: 1, tokens: 400, costUsd: 0.04 },
|
||||
{ kind: "investigate", runs: 1, tokens: 300, costUsd: 0.03 },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -217,8 +217,8 @@ describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => {
|
||||
// 子超预算:budget_exceeded,不影响父会话
|
||||
expect(oppie.status).toBe("budget_exceeded");
|
||||
expect((oppie.budget as Any).maxToolCalls).toBe(1);
|
||||
// 缺省划拨:父剩余预算的一半(设置 maxToolCalls 40,派生时父已用 1 次)
|
||||
expect((dune.budget as Any).maxToolCalls).toBeLessThanOrEqual(20);
|
||||
// 缺省划拨:父剩余预算的一半(设置 maxToolCalls 缺省 120,派生时父已用 1 次)
|
||||
expect((dune.budget as Any).maxToolCalls).toBeLessThanOrEqual(60);
|
||||
|
||||
// maxConcurrent=1:串行(谁先占到名额不定,但两个运行区间不重叠)
|
||||
const [first, second] = [dune, oppie].sort((a, b) => a.started_at!.getTime() - b.started_at!.getTime()) as [
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit";
|
||||
import { sql } from "kysely";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
|
||||
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
|
||||
import { enqueueWatchedRefresh, isEntityWatched } from "../../src/modules/sources-identity/service/watched.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
|
||||
|
||||
// 集数识别分两层(2026-10-06):
|
||||
// 确定性校验 / 换算对所有已绑定作品的资源照做;拉 TMDB(自动建档后的元数据刷新)、开集数待确认只对关注中的作品做。
|
||||
// RSS 扫进来自动建档、没人关心的作品:不排刷新、不开事项;进入关注范围(建订阅等)补排刷新,刷新重算后才开内部事项。
|
||||
|
||||
const pg = await pgAvailable();
|
||||
|
||||
const season = (n: number, eps: number, start: string) => ({
|
||||
season_number: n,
|
||||
air_date: start,
|
||||
names: { "zh-CN": `第 ${n} 季`, "en-US": `Season ${n}` },
|
||||
episodes: Array.from({ length: eps }, (_, k) => ({
|
||||
episode_number: k + 1,
|
||||
air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10),
|
||||
names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` },
|
||||
})),
|
||||
});
|
||||
|
||||
// 已完结:S1 = 12、S2 = 13;绝对集号 40 超出全部季,刷新后换算不了 → 集数待确认
|
||||
const SHOW: TmdbFixture = {
|
||||
type: "tv",
|
||||
id: 990301,
|
||||
original_title: "Yoru no Kurage wa Oyogenai",
|
||||
original_language: "ja",
|
||||
origin_country: ["JP"],
|
||||
date: "2024-04-06",
|
||||
titles: { "zh-CN": "夜晚的水母不会游泳", "en-US": "Yoru no Kurage wa Oyogenai" },
|
||||
popularity: 30,
|
||||
alternative_titles: [],
|
||||
external_ids: {},
|
||||
rich: { status: "Ended" },
|
||||
seasons: [season(1, 12, "2024-04-06"), season(2, 13, "2025-04-05")],
|
||||
};
|
||||
|
||||
describe.skipIf(!pg)("集数识别两层:只对关注中的作品拉元数据、开集数待确认", () => {
|
||||
let h: Harness;
|
||||
let tmdb: MockTmdb;
|
||||
let siteId: string;
|
||||
let n = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SHOW] });
|
||||
h = await createHarness("episode_watch_scope", { tmdbUrl: tmdb.url });
|
||||
siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id;
|
||||
// 不靠订阅也查 TMDB(旧行为):RSS 扫进来的资源会自动建档,但作品不在关注范围
|
||||
await sql`insert into settings (key, value) values ('identity', ${JSON.stringify({ eagerTmdbLookup: true })}::jsonb)
|
||||
on conflict (key) do update set value = excluded.value`.execute(h.db);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await tmdb?.close();
|
||||
});
|
||||
|
||||
const observe = async (title: string) => {
|
||||
const r = await h.db
|
||||
.insertInto("source_release")
|
||||
.values({ site_id: siteId, site_release_id: String(830000 + ++n), title, subtitle: "夜晚的水母不会游泳" })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "observed" });
|
||||
expect(res.outcome, JSON.stringify(res)).toBe("auto");
|
||||
return { id: r.id, entityId: res.mediaEntityId as string };
|
||||
};
|
||||
const method = async (id: string) =>
|
||||
(
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("source_release")
|
||||
.select("episode_resolution")
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirstOrThrow()
|
||||
).episode_resolution as { method: string }
|
||||
).method;
|
||||
const refreshJobs = () => h.engine.enqueued.filter((e) => e.task === "refreshMetadata");
|
||||
const episodeDecisions = () =>
|
||||
h.db
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("kind", "=", "episode_mapping")
|
||||
.where("status", "in", ["open", "investigating", "awaiting_user"])
|
||||
.execute();
|
||||
|
||||
let entityId: string;
|
||||
let r40: string;
|
||||
|
||||
it("RSS 扫进来自动建档:确定性换算照做,但不排元数据刷新、不开集数待确认", async () => {
|
||||
const a = await observe("[ANi] Yoru no Kurage wa Oyogenai - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]");
|
||||
entityId = a.entityId;
|
||||
expect(await method(a.id)).toBe("unverified");
|
||||
expect(await isEntityWatched(h.db, entityId)).toBe(false);
|
||||
const b = await observe("[Lilith-Raws] Yoru no Kurage wa Oyogenai - 40 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]");
|
||||
r40 = b.id;
|
||||
expect(b.entityId).toBe(entityId);
|
||||
expect(refreshJobs()).toEqual([]);
|
||||
expect(await episodeDecisions()).toEqual([]);
|
||||
// 季详情一次都没拉(识别只用搜索与详情)
|
||||
expect(tmdb.callsTo(/\/season\//)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("即使季表已知、资源换算不了,非关注作品也不开事项", async () => {
|
||||
// 直接刷新一次(模拟别处拉过季表),非关注作品的待确认资源照样只记在资源上
|
||||
expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed");
|
||||
expect(await method(r40)).toBe("unresolved");
|
||||
expect(await episodeDecisions()).toEqual([]);
|
||||
await h.db.updateTable("media_entity").set({ last_metadata_refresh_at: null }).where("id", "=", entityId).execute();
|
||||
});
|
||||
|
||||
it("进入关注范围(建订阅):补排一次 full 刷新;刷新重算后对换算不了的资源开内部事项", async () => {
|
||||
await h.db
|
||||
.insertInto("subscription")
|
||||
.values({ media_entity_id: entityId, media_kind: "series", status: "active" })
|
||||
.execute();
|
||||
expect(await isEntityWatched(h.db, entityId)).toBe(true);
|
||||
expect(await enqueueWatchedRefresh(h.ctx, entityId, "subscription")).toBe(true);
|
||||
expect(refreshJobs()).toEqual([
|
||||
{
|
||||
task: "refreshMetadata",
|
||||
input: { mediaEntityId: entityId, full: true },
|
||||
key: `refreshMetadata:watched:${entityId}`,
|
||||
},
|
||||
]);
|
||||
// 执行入队的刷新(同 refreshMetadata 任务处理器)
|
||||
expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed");
|
||||
const ds = await episodeDecisions();
|
||||
expect(ds).toHaveLength(1);
|
||||
expect((ds[0]!.evidence as { sourceReleaseIds: string[] }).sourceReleaseIds).toEqual([r40]);
|
||||
// 已刷新过的不再补排
|
||||
expect(await enqueueWatchedRefresh(h.ctx, entityId, "download")).toBe(false);
|
||||
});
|
||||
|
||||
it("用户搜索 / 手动识别出的新作品照常排刷新", async () => {
|
||||
const r = await h.db
|
||||
.insertInto("source_release")
|
||||
.values({ site_id: siteId, site_release_id: String(830000 + ++n), title: "Oppenheimer.2023.2160p.UHD.BluRay" })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
const before = refreshJobs().length;
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" });
|
||||
expect(res.outcome, JSON.stringify(res)).toBe("auto");
|
||||
expect(await isEntityWatched(h.db, res.mediaEntityId as string)).toBe(true);
|
||||
expect(refreshJobs().length).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,8 @@ const TiB = 1024 ** 4;
|
||||
const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const;
|
||||
|
||||
describe("站点能力规格", () => {
|
||||
it("进入能力目录(41 个已有能力 + 整理元数据补全 + 起草巡检 + 集数映射 3 个);类别 / 风险 / 重放按约定", () => {
|
||||
expect(CAPABILITY_IDS).toHaveLength(46);
|
||||
it("进入能力目录(41 个已有能力 + 整理元数据补全 + 起草巡检 + 集数映射 3 个 + 指定整理作品);类别 / 风险 / 重放按约定", () => {
|
||||
expect(CAPABILITY_IDS).toHaveLength(47);
|
||||
expect(capabilitySpec("episode_mapping.inspect")).toMatchObject({ kind: "read", replay: "safe" });
|
||||
expect(capabilitySpec("episode_mapping.simulate")).toMatchObject({ kind: "preview", replay: "safe" });
|
||||
expect(capabilitySpec("episode_mapping.apply_rule")).toMatchObject({
|
||||
|
||||
Reference in new issue
Block a user