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:
rootandClaude Opus 5.5 committed 2026-10-06 16:47:18 +08:00
1 parent 414ea7c890
commit 1c1a954703
25 files changed
+1037 -35

No files matched your search

+9
View File
@@ -149,6 +149,15 @@ export const libraryApi = {
response: organization.Dashboard,
}),
},
attention: {
summary: ep({
method: "get",
path: "/attention",
scope: "read",
summary: "需要关注的汇总:整理记录需要处理的条数、等你批准的智能体操作、系统健康问题",
response: organization.AttentionSummary,
}),
},
downloadCache: {
cleanup: ep({
method: "post",
+16
View File
@@ -49,6 +49,22 @@ export const organizeApi = {
body: organization.OrganizationDeleteRequest,
response: organization.OrganizationDeleteResult,
}),
dismissAttention: ep({
method: "post",
path: "/organization-records/{id}/dismiss-attention",
scope: "organize.execute",
summary: "忽略这条整理记录的问题:不再算需要处理,关联的未关闭事项一并关闭",
params: IdParam,
response: organization.PublicOrganizationRecord,
}),
agentIdentify: ep({
method: "post",
path: "/organization-records/{id}/agent-identify",
scope: "organize.execute",
summary: "让智能体识别这条整理记录的作品(没识别出作品的记录),认出来自动重新整理",
params: IdParam,
response: organization.OrganizationAgentIdentifyResult,
}),
create: ep({
method: "post",
path: "/organization-records",
+29
View File
@@ -1088,6 +1088,34 @@ export const EpisodeMappingApplyRuleOutput = z.object({
decisionStatus: DecisionStatus,
});
/** organize.assign_identity:给「没识别出作品」的整理事项指定作品并重新整理 */
/** mediaEntityId 与 tmdb 二选一(都给时用 mediaEntityId,都没给由处理器拒绝) */
export const OrganizeAssignIdentityInput = z.object({
/** 「没识别出作品」事项 ID(调查目标里给出) */
decisionId: Id,
mediaEntityId: Id.optional(),
tmdb: z
.object({
type: z.enum(["movie", "tv"]),
id: z
.string()
.trim()
.regex(/^\d{1,10}$/),
})
.optional(),
/** 为什么是这个作品(依据),至少一条 */
reasons: z.array(z.string().trim().min(1).max(300)).min(1).max(10),
});
export const OrganizeAssignIdentityOutput = z.object({
mediaEntityId: Id,
/** 重新整理的记录与状态 */
recordId: Id.nullable(),
status: z.string(),
decisionStatus: DecisionStatus,
});
export type OrganizeAssignIdentityInput = z.infer<typeof OrganizeAssignIdentityInput>;
export type OrganizeAssignIdentityOutput = z.infer<typeof OrganizeAssignIdentityOutput>;
/** 能力 ID → 输入/输出 schema(server 注册处理器、agent 生成工具定义时共用) */
// ── schedule.draft_check:按自然语言需求起草巡检(建成未启用,用户在计划页检查后启用) ──
@@ -1167,6 +1195,7 @@ export const CAPABILITY_IO = {
"episode_mapping.inspect": { input: EpisodeMappingInspectInput, output: EpisodeMappingInspectOutput },
"episode_mapping.simulate": { input: EpisodeMappingSimulateInput, output: EpisodeMappingSimulateOutput },
"episode_mapping.apply_rule": { input: EpisodeMappingApplyRuleInput, output: EpisodeMappingApplyRuleOutput },
"organize.assign_identity": { input: OrganizeAssignIdentityInput, output: OrganizeAssignIdentityOutput },
} as const satisfies Record<KnownCapabilityId, { input: z.ZodType; output: z.ZodType }>;
type CapabilityIo = typeof CAPABILITY_IO;
+15
View File
@@ -100,6 +100,7 @@ export const CAPABILITY_IDS = [
"episode_mapping.inspect",
"episode_mapping.simulate",
"episode_mapping.apply_rule",
"organize.assign_identity",
] as const;
export const KnownCapabilityId = z.enum(CAPABILITY_IDS);
export type KnownCapabilityId = z.infer<typeof KnownCapabilityId>;
@@ -668,6 +669,19 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
false,
true,
),
// 整理时没识别出作品(organize_identity 事项):智能体查到作品后指定,随即按该作品重新整理
"organize.assign_identity": d(
"organize.assign_identity",
"指定整理作品",
"给没识别出作品的整理记录指定作品(本地作品 ID 或 TMDB 类型 + ID),随即按这个作品重新整理。只用于「没识别出作品」事项;先用 catalog.lookup / inventory.query 核对标题、年份、季集,拿不准就不要指定",
"execute",
"OrganizeAssignIdentity",
"organize.execute",
"medium",
false,
false,
"never",
),
};
/**
@@ -733,6 +747,7 @@ export const CAPABILITY_ACTIVITY: Record<KnownCapabilityId, CapabilityActivity>
"episode_mapping.inspect": act("查", "集数映射"),
"episode_mapping.simulate": act("模拟", "映射规则", "次"),
"episode_mapping.apply_rule": act("写入", "映射规则", "条"),
"organize.assign_identity": act("指定", "整理作品", "个"),
};
function d(
+58 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { AgentRunStatus, DecisionKind } from "./agent.ts";
import { AgentRunStatus, Decision, DecisionKind } from "./agent.ts";
import { AssetLocation, MediaAsset, PathBinding } from "./assets.ts";
import { ActorType, EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common.ts";
import { MediaEntity, MediaKind } from "./content.ts";
@@ -73,6 +73,8 @@ export const OrganizationRecord = EntityBase.extend({
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
changeSeq: ChangeSeq,
/** 用户在整理记录里忽略了这条记录的问题(不再算需要处理) */
attentionClearedAt: Timestamp.nullable(),
});
export type OrganizationRecord = z.infer<typeof OrganizationRecord>;
export const OrganizationRecordCreate = OrganizationRecord.pick({
@@ -211,6 +213,32 @@ export const PublicOrganizationFile = z.object({
});
export type PublicOrganizationFile = z.infer<typeof PublicOrganizationFile>;
/** 整理记录关联的未关闭事项(在整理记录里就地处理;选项与「处理」端点同 /decisions/{id}/resolve) */
export const RecordAttentionDecision = Decision.pick({
id: true,
kind: true,
status: true,
title: true,
summary: true,
options: true,
agentRunId: true,
evidence: true,
});
export type RecordAttentionDecision = z.infer<typeof RecordAttentionDecision>;
/**
* 需要处理(2026-10-06 去掉待处理中心后,整理侧的问题在整理记录里就地处理):
* topic:identity = 没识别出作品(可指定作品 / 让智能体识别);conflict = 目标已存在 / 路径冲突(按选项替换、保留两者或跳过);
* other = 其余失败或部分完成(排除、非视频、卡住、执行失败…,重新整理或忽略)。
* agentIdentify:可以让智能体识别(没识别出作品且有关联下载任务)。
*/
export const RecordAttention = z.object({
topic: z.enum(["identity", "conflict", "other"]),
decision: RecordAttentionDecision.nullable(),
agentIdentify: z.boolean(),
});
export type RecordAttention = z.infer<typeof RecordAttention>;
export const PublicOrganizationRecord = z.object({
/** 稳定记录 ID */
id: Id,
@@ -261,6 +289,8 @@ export const PublicOrganizationRecord = z.object({
finishedAt: Timestamp.nullable(),
/** 该记录最近一次变更的游标值;客户端按 id 去重、以最大 changeSeq 续读 */
changeSeq: ChangeCursor,
/** 需要处理时给出原因与可做的动作;不需要为 null(旧客户端可忽略) */
attention: RecordAttention.nullable().optional(),
});
export type PublicOrganizationRecord = z.infer<typeof PublicOrganizationRecord>;
@@ -303,6 +333,13 @@ export const OrganizationListQuery = z.object({
since: Timestamp.optional(),
/** HIS-1:只看整理类记录(复制/移动/硬链接),不含文件管理的改名、删除、还原 */
organizeOnly: z.union([z.boolean(), z.enum(["true", "false"]).transform((v) => v === "true")]).optional(),
/**
* 只看需要处理的记录:有关联的未关闭事项,或失败 / 部分完成、没被同一下载任务后来的整理取代、也没被忽略的
* (口径见 docs/runbook/organize.md「需要处理」)
*/
attention: z
.union([z.boolean(), z.enum(["true", "false", "1", "0"]).transform((v) => v === "true" || v === "1")])
.optional(),
});
export type OrganizationListQuery = z.infer<typeof OrganizationListQuery>;
@@ -317,6 +354,8 @@ export const OrganizationStats = z.object({
/** 排队 + 执行中 */
running: NonNegInt,
cancelled: NonNegInt,
/** 需要处理(同列表 attention=true 的口径,不受状态筛选影响) */
attention: NonNegInt,
});
export type OrganizationStats = z.infer<typeof OrganizationStats>;
@@ -764,3 +803,21 @@ export const OrganizePlanArtifactContent = z.object({
.optional(),
});
export type OrganizePlanArtifactContent = z.infer<typeof OrganizePlanArtifactContent>;
/** 让智能体识别整理记录里的作品(没识别出作品的记录):入队一次识别会话 */
export const OrganizationAgentIdentifyResult = z.object({ decisionId: Id, queued: z.boolean() });
export type OrganizationAgentIdentifyResult = z.infer<typeof OrganizationAgentIdentifyResult>;
/**
* 需要用户关注的汇总(铃铛、首页、运行状态共用):
* - organize:整理记录里需要处理的条数(点进整理记录「需要处理」);
* - approvals:需要你批准的智能体操作(会话里的审批 / 方案选择、计划审批、原站页面等你接手);
* - health:系统健康问题(站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线),恢复后自动消失。
* 集数待确认、未识别资源等不在这里:前者在订阅详情与作品页集数映射里看原因,后者静默留在未识别里。
*/
export const AttentionSummary = z.object({
organize: NonNegInt,
approvals: z.object({ total: NonNegInt, items: z.array(Decision) }),
health: z.object({ total: NonNegInt, items: z.array(Decision) }),
});
export type AttentionSummary = z.infer<typeof AttentionSummary>;
+4 -4
View File
@@ -1,4 +1,4 @@
// 内部原因码 → 一句人话 + 下一步(DEAI-2)。整理记录、待处理、下载时间线、任务都用这张表;
// 内部原因码 → 一句人话 + 下一步(DEAI-2)。整理记录、下载时间线、任务都用这张表;
// 原码只放在界面的「技术信息」里,不直接给人看。不认识的码返回 null,由调用方决定兜底文字。
export type ReasonText = {
@@ -19,9 +19,9 @@ const REASONS: Record<string, ReasonText> = {
identity_unresolved: { text: "没识别出作品", action: "指定作品" },
episode_mapping: { text: "集数对不上作品的季集", action: "确认集数映射" },
episode_unresolved: { text: "集数对不上作品的季集", action: "确认集数映射" },
target_exists: { text: "媒体库里已有同名文件", action: "在待处理里选择替换或跳过" },
target_exists: { text: "媒体库里已有同名文件", action: "在整理记录里选择替换、保留两者或跳过" },
duplicate_target: { text: "改名后与其他文件同名", action: "改命名模板或排除其中一个" },
replace_requires_grant: { text: "替换已有文件需要你批准", action: "在待处理里批准" },
replace_requires_grant: { text: "替换已有文件需要你批准", action: "在整理记录里选择是否替换" },
hnr_unsatisfied: { text: "做种时间还不够,不能移动", action: "改用复制,或等做种完成" },
hnr_unknown: { text: "不确定做种要求是否满足", action: "改用复制,或确认站点规则" },
orphan_sidecar: { text: "字幕等附属文件找不到对应视频" },
@@ -51,7 +51,7 @@ const REASONS: Record<string, ReasonText> = {
downloader_unavailable: { text: "下载器连不上", action: "在设置 → 下载器里测试连接" },
downloader_error: { text: "下载器报错", action: "查看下载器日志" },
// DL-2b:下载面板整理状态、下载计划里出现的码
blocked: { text: "有冲突要先处理", action: "去待处理" },
blocked: { text: "有冲突要先处理", action: "在整理记录里处理" },
blocked_by_conflict: { text: "有冲突,暂不整理" },
transfer_failed: { text: "文件没有整理成功", action: "在整理记录里查看失败的文件" },
main_failed: { text: "主文件整理失败,附属文件一起跳过" },