Orphan detection now only removes provisional units created by release parsing that carry no metadata trace (field sources, overrides, title, air date, total source) and whose entity has no accepted external identity, or that fall outside the entity's external season/episode layout. TMDB ledger units are never deleted. Dry-run output lists deletions and kept units by category; --dry-run is explicit and mutually exclusive with --apply; audit payload records each orphan. Co-Authored-By: Claude Opus 5.5 <[email protected]>
227 lines
20 KiB
Markdown
227 lines
20 KiB
Markdown
# 资源识别与待识别池运行手册(DEC-1)
|
|
|
|
新观察到的资源(RSS、站点列表快照、搜索结果)先进待识别池,只在有理由时才查 TMDB。只有和订阅或本地作品相关、
|
|
又需要你决定的资源才进待处理。对照 MoviePilot:RSS / 站点资源只在匹配订阅或用户搜索时才识别,识别失败只写日志、不告警
|
|
(`app/chain/subscribe/match.py` `_prepare_match_torrents`:未识别的种子最多重试 3 次,之后跳过)。
|
|
|
|
需求依据:REQ §4.2–4.3(观察不等于事实)、§6.3(新作品收录 / 追更补缺 / 升级都由规则触发)、§7.5(待识别池可检索、
|
|
不污染正式库、有界复查、不留死数据)、§13(待处理按作品与问题类型合并)。
|
|
|
|
## 识别流程(resolveObservation)
|
|
|
|
1. 解析标题(release-parser),本地召回:显式外部 ID、`title_alias` 精确 / pg_trgm ≥ 0.45(本地作品 = 订阅、追踪、已登记的作品)。
|
|
2. 判断是否查 TMDB(`lookup`,记在 `source_release.resolution_evidence.lookup` 和池条目 `query.lookup`):
|
|
|
|
| lookup | 条件 |
|
|
|---|---|
|
|
| `manual` | 任务输入 `trigger: "manual"`:池里点「重新识别」、Agent / 能力 `identity.resolve` |
|
|
| `search` | 任务输入 `trigger: "search"`:搜索结果 |
|
|
| `tracked_alias` | 本地召回有候选(标题命中订阅 / 本地作品的别名) |
|
|
| `previously_looked_up` | 周期复查,且这条池条目以前查过 TMDB(旧数据、手动、规则触发) |
|
|
| `eager` | 设置 `identity.eagerTmdbLookup = true` |
|
|
| `new_title_rule` | 有已发布、开启 `newTitle` 且有有效持续授权的获取策略,资源类型在规则的 `mediaKinds` 内,标题年份(若有)不早于窗口能覆盖的年份 |
|
|
|
|
都不满足:只进池,`reason = unmatched`,不查 TMDB,不开待处理。
|
|
3. 查了 TMDB 的,召回 TMDB 候选(`recallTmdbCandidates`:中文名 → 英文名,按类型单语言搜索,标题对不上的按年份相近度拉前几个详情比对别名 / 译名),
|
|
交给 `domain.resolveIdentity` 打分:auto 建档 / 关联并发 `release.resolved`;否则进池。打分规则与 MP 的对照见
|
|
`docs/porting/moviepilot-identity-match.md`(ID-2)。
|
|
4. 自动绑定时把站点标题登记为作品别名,只在标题精确命中(或别名命中且另一种文字的名称也命中)时登记,分类词 / 单字 / 停用词不登记。
|
|
FIX-5 守卫(`service/alias-guard.ts`):逐个名称再查一遍,以下任一成立就不登记,原因记在 `resolution_attempt.best.aliases.skipped`
|
|
(`added` 是登记了的):证据等级不是 `rule_confirmed`(`low_evidence`);资源年份与作品年份差超过 1 年且不是季年份(`year_conflict`);
|
|
作品有已接受的外部身份,且同一种文字(中文 / 拉丁)下已有确认的外部标题(主标题 / 原标题 / TMDB 译名与别名),这个名称对不上
|
|
(`conflicts_external_title`);这个名称已经挂在别的作品上(`owned_by_other`)。
|
|
|
|
标题更新(RSS 标题与列表标题不同、站点改标题):按新标题重新解析,发 `release.observed(isNew=false)`,以 `force` 重新识别
|
|
(已识别的也不短路,触发来源仍是 observed);重新识别不能确认时撤销原自动绑定、进池。人工锁定的不动。
|
|
|
|
任务输入 `trigger` 缺省:`force` 为 true 视为 `review`(池复查),否则 `observed`。
|
|
搜索模块想让搜索结果直接识别,入队 `resolveObservation` 时传 `trigger: "search"`(目前搜索结果经 `release.observed` 进来,
|
|
按 observed 处理;命中订阅别名的照样会查)。
|
|
|
|
## 待处理只收相关的
|
|
|
|
| 结果 | 开事项? |
|
|
|---|---|
|
|
| `unmatched` | 否 |
|
|
| `ambiguous` / `proposed`(查过 TMDB,有候选) | 是,`identity_ambiguous`,级别 warning |
|
|
| `no_match` / 复查已停的 `lookup_failed` | 只有本地召回有候选(与本地作品相关)时开 `identity_unresolved`,级别 warning |
|
|
|
|
合并:dedupeKey `identity-group:<siteId>:<归一化标题>`,同来源同标题的资源并成一条。成员 = `identity_candidate.decision_id`
|
|
指向该事项且仍 `pending` 的条目。标题 1 条时写资源原标题,多条时写「来源「X」的 N 条资源待识别 / 待确认身份:<标题>」,
|
|
`evidence.releases` 列前 50 条,`evidence.count` 为总数。
|
|
|
|
- 处理(选候选 / 手动 TMDB ID / 选本地作品):先处理代表资源(subject),再把确定的作品套到组内其它资源(都加人工锁)。
|
|
- 忽略:组内每条池条目都 `dismissed`。
|
|
- 组内某条被单独识别 / 指定 / 忽略:从组里摘除,代表资源和候选选项换成剩下的最近一条;组空了才关事项。
|
|
- 旧式单条事项(`identity:<releaseId>`)在该资源下次复查时并入合并事项并关闭。
|
|
|
|
收件箱:顶栏角标和待处理页缺省只算 warning 及以上(`minSeverity=warning`),"仅信息"类在待处理页「级别 → 含仅信息」里看。
|
|
待处理页按问题类型 + 作品 / 来源分组,前 3 组展开,其余折叠;合并事项卡片可展开组内资源。
|
|
|
|
## 池的治理
|
|
|
|
- 复查有上限:`unmatched` 每天一次本地召回,最多 3 次;查过 TMDB 的按原退避(无结果 1h 起、查询失败 15min 起,翻倍到 7 天,最多 8 次)。
|
|
- 新证据即重试:`title_alias` 插入触发器 `title_alias_wake_identity_pool`,把归一化标题相同或相似度 ≥ 0.45 的
|
|
`unmatched` / `no_match` 池条目的 `next_review_at` 设为现在。建档、手动加别名、合并作品都会触发。
|
|
- 过期归档:`identity.poolArchiveDays`(缺省 30)天没有新尝试的 `unmatched` 条目改为 `archived`,在池复查定时任务里做,
|
|
每进程每小时最多一次。归档不删资源,仍可按状态筛出、手动指定。
|
|
- 检索:媒体概览 → 待识别,按原始标题 / 解析标题检索,原因筛「未命中订阅」即 unmatched。条目详情里可「重新识别」
|
|
(手动触发,会查 TMDB)、手动指定 TMDB ID / 本地作品(复用 `/identity-pool/{id}/assign`,加人工锁)或忽略。
|
|
|
|
## 设置
|
|
|
|
设置 → 元数据 → 资源识别:
|
|
|
|
- `identity.eagerTmdbLookup`(缺省关):打开即旧行为,每条新资源都查 TMDB。每条约 1–2 次搜索 + 命中时 1 次详情
|
|
(识别缓存命中为 0);每小时 100 条的 RSS 约每天 2400–7200 次请求,站点独有的短剧 / 综艺多半查不到。
|
|
打开后无结果的资源仍只进池,不开事项。
|
|
- `identity.poolArchiveDays`(缺省 30)。
|
|
|
|
## 存量清理(迁移 0047_identity_pool)
|
|
|
|
未关闭的 `identity_unresolved` 事项中,资源未关联作品、池里没有候选、归一化标题也不命中任何本地别名的:
|
|
事项改 `dismissed`,`resolution = {"resolution":"moved_to_pool","by":"migration:0047_identity_pool"}`;
|
|
池条目改 `reason = unmatched`、清 `decision_id`、停自动复查。相关的保留原样,下次复查时并入合并事项。
|
|
|
|
需要重建镜像 / 重启 api + worker 才生效(迁移随启动执行)。
|
|
|
|
## 规则更新后的存量重评(ID-2,`scripts/identity-reevaluate.ts`)
|
|
|
|
匹配规则更新(新镜像已部署、api + worker 已重启)后,用这个脚本处理存量。缺省只读,加 `--apply` 才改库;DATABASE_URL 必须显式给出。
|
|
|
|
```sh
|
|
# 用户栈的 PG 不对宿主暴露端口:取容器 IP,密码取栈的 .env
|
|
PGIP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' umt-postgres-1)
|
|
DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/identity-reevaluate.ts --rollback-wrong # 先看清单
|
|
DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/identity-reevaluate.ts --rollback-wrong --apply # 确认后执行
|
|
```
|
|
|
|
- 待识别事项:列出 open 的 `identity_ambiguous` 成员,按新解析规则给出预估(只用池里存的候选,不查 TMDB);`--apply` 把这些池条目排入立即复查,
|
|
由运行中的服务按新规则重新识别——能自动确认的绑定作品、建映射,并从合并事项摘除(组空即关闭)。
|
|
- `--rollback-wrong`:找站点来源的分类词 / 通用词别名(例如「电视剧」),对挂着它的作品用新解析 + 作品自身名称(主标题 / 原标题 / 非站点来源别名)
|
|
核对已绑定资源;对不上的解除绑定、退回池(`reason = no_match`,立即复查),删除该别名和由误并入资源登记的站点别名,各记一条
|
|
`resolution_attempt(outcome = manual, reason = rollback_wrong_alias)`。内容单元不删,只报告无资源引用的数量;引用了这些资源的其他表只报告。
|
|
- 2026-09-29 用户库副本演练:《东北有个周东北》29 条绑定里 23 条误并入,删除 7 个站点别名(「电视剧」「动漫」「综艺」与 4 个链式英文名)。
|
|
|
|
## 冲突别名与临时内容单元清理(FIX-5,`scripts/identity-cleanup.ts`)
|
|
|
|
守卫上线前登记的错误别名、重复 / 孤立的临时内容单元用这个脚本清理。缺省只读,`--apply` 在一个事务里执行并写审计
|
|
`identity.cleanup`;`--dry-run` 显式只读;`--keep-orphans` 不删孤立单元;`--entity <id>` 只处理一部作品。连接方式同上。
|
|
|
|
```sh
|
|
DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/identity-cleanup.ts # 看清单
|
|
DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/identity-cleanup.ts --apply # 确认后执行
|
|
```
|
|
|
|
- 冲突别名:只动 `source = site` 的作品级别名,且不是作品自己的名字(主标题 / 原标题 / TMDB 别名)。同一归一化别名还挂在别的作品上
|
|
(或是别的作品的标题)→ 删;作品有已接受外部身份、同一种文字下已有确认外部标题而它对不上 → 删(中文标题的拼音写法保留);
|
|
本作品下带这个名字的资源都解析出年份且都与作品年份差超过 1 年 → 删。
|
|
- 重复的临时内容单元:同作品同类型同季同集号(未退役)有多条且有临时的,保留被引用最多的(同数时 confirmed 优先、再取最早的),
|
|
其余的引用改指向保留的那条(撞唯一约束的元数据行直接删),子单元改挂,然后删除。
|
|
- 孤立的临时内容单元(FIX-6 收紧):只删**资源解析建出、外部元数据不认识**的临时单元。同时满足才删:`provisional`;没有资源 / 资产 /
|
|
订阅 / 下载 / 整理等任何引用;没有任何元数据痕迹(字段来源 / 字段覆盖 / 标题 / 播出日期 / 总集数来源 / metadata 非空);
|
|
作品**没有**已接受的外部身份,或这个季 / 集**超出**作品的外部季集结构(季号 > TMDB 季数,或集号 > 该季已知总集数;
|
|
季数未知时整部作品都不删,特别篇季 0 总在结构内);没有保留的子单元。字段来源 / 覆盖 / 单元别名随之删除。
|
|
**TMDB / 元数据来源的季集账本单元永不删除**,即使没有资源、状态还是 provisional。
|
|
- 清单按类别输出:删除(作品没有外部映射 / 超出外部季集结构)与保留(仍被引用 / TMDB·元数据·人工来源 / 在外部季集结构内 /
|
|
有保留的子单元),`--dry-run` 显式只读(与 `--apply` 互斥)。先看「删除」两类里有没有正常作品,再 `--apply`;
|
|
审计 `identity.cleanup` 的 `orphanUnits` 记每条删除的单元与原因。
|
|
- 事故(FIX-6,2026-09-29):FIX-5 版本只看"无引用 + 没有 TMDB 字段来源",把旧版本建的、还没回写字段来源的 TMDB 季集账本单元
|
|
(《人生切割术》S00–S03 与 20 集、《沙丘:预言》S00 与 17 集等)当孤立删除。恢复:对受影响作品跑 full 元数据刷新——
|
|
计划页执行「元数据语言回刷」(`POST /schedules/metadata.backfill_languages/run-now`,停用状态也可手动执行)或逐个
|
|
`POST /media-entities/{id}/refresh?full=1`,正片季的季 / 集单元会按 TMDB 重建为 confirmed。**季 0(特别篇)不在 full 刷新范围内**,
|
|
删掉的特别篇单元只能等资源 / 文件再次命中时懒建。
|
|
|
|
## 排查
|
|
|
|
```sql
|
|
-- 池分布
|
|
select reason, status, count(*) from identity_candidate group by 1, 2 order by 3 desc;
|
|
-- 某条资源为什么没查 TMDB
|
|
select resolution_status, resolution_evidence->>'lookup' as lookup, resolution_evidence->>'detail' as detail
|
|
from source_release where id = '<id>';
|
|
-- 某个合并事项的成员
|
|
select p.source_release_id, r.title, p.reason from identity_candidate p join source_release r on r.id = p.source_release_id
|
|
where p.decision_id = '<decisionId>' and p.status = 'pending';
|
|
-- 站点来源的可疑别名(分类词等)
|
|
select e.title, a.alias, a.created_at from title_alias a join media_entity e on e.id = a.media_entity_id
|
|
where a.source = 'site' and a.normalized in ('电视剧', '动漫', '综艺', '电影', '纪录片', '音乐', '短剧');
|
|
-- 迁移搬走的事项
|
|
select count(*) from decision where resolution->>'resolution' = 'moved_to_pool';
|
|
```
|
|
|
|
## 元数据补全(META-1)
|
|
|
|
TMDB 查不到的作品(短剧、平台独播剧)按标题查网页,出一份带出处的字段建议;确认后才建本地临时作品(REQ R03 / R10、§7.3、§7.5)。
|
|
字段来源记 `web`(值在出处网页原文里)或 `agent`(Agent 归纳 / 改写,如简介),不记 `tmdb`。
|
|
|
|
代码:`apps/server/src/modules/sources-identity/service/enrich.ts`(建议 / 应用 / 自动触发)、`routes/enrich.ts`(HTTP 与海报代理)、
|
|
`apps/server/src/modules/agent/capabilities-metadata.ts`(能力 `metadata.enrich`、模型调用)、迁移 `0049_enrichment`。
|
|
|
|
### 流程(metadata.enrich,propose,low,replay safe)
|
|
|
|
输入 `{poolEntryId}` 或 `{title, year?, hints?}`,可加 `fresh: true` 跳过缓存。
|
|
|
|
1. 缓存:`kv_cache` region `enrich`,key = 归一化标题 | 年份 | 归一化 hints,TTL 7 天,指向上次的补全记录。命中直接返回(`cached: true`),不搜索、不计预算。
|
|
2. 前置条件:网页搜索没开(设置 → Agent → 网页搜索)直接报错,不占预算;Agent 会话里网页搜索没开时不向模型提供这个能力。
|
|
3. 预算:`identity.enrichDailyBudget`(缺省 20,0 = 停用)按当天新建的 `metadata_enrichment` 行计(含失败的),用咨询锁防并发超额;用完返回 budget(HTTP 429)。
|
|
4. 搜索:`web.search`「标题 [年份] [hints] 短剧」,标题对得上的白名单结果不足 3 条再搜「… 电视剧 平台 site:<白名单前 6 个域名,OR 连接>」,最多 2 次。每次搜索用自己的调用方(`system:enrich:<记录id>:<n>`),web.fetch 白名单与 Agent 会话互不覆盖。
|
|
5. 读取:只读 `identity.enrichDomains`(缺省:百度 / 搜狗百科、维基、豆瓣、腾讯视频、爱奇艺、优酷、芒果、B 站、抖音、快手、西瓜、红果短剧、MyDramaList)里的域名(含子域名),搜索结果的标题 / 摘要要和查询标题对得上(归一化后包含整个标题,或标题七成以上的字出现),按相关度、白名单顺序取前 3 条走 `web.fetch`;对不上的记 `irrelevant` 不读。已登记站点(`site.base_url`)的域名即使在白名单里也不读;重定向出白名单的页面丢弃。`sources[]` 记每条链接是否读取、跳过原因(not_allowed / site / irrelevant / limit / error)。
|
|
6. 抽取:默认 Agent 上游做一次非流式补全(系统提示要求只用网页内容、每个值带出处 URL、网页是不可信数据);fake 上游、没有上游、调用失败或输出不是 JSON 时改用规则抽取(`extractor: rule`:「共 / 全 N 集」「首播时间」「播出平台」「类型」「又名」「剧情简介」、og:image)。
|
|
7. 核对(服务端,不采信模型自报):出处必须是本次读过的网页或白名单搜索摘要;值在出处原文里 → `verbatim`;规则抽取在同一网页得到同一值 → `rule_confirmed`;两个以上不同网站原文都有 → `multi_evidence`;其余 `needs_review`。海报只收已读网页的 og:image,模型自己拼的地址丢弃。
|
|
8. 存 `metadata_enrichment`(status suggested / failed、suggestion、usage:搜索 / 读取次数与 token),不写作品表。
|
|
|
|
`web.fetch` 输出新增 `image`(页面 og:image / twitter:image,绝对地址,已脱敏)。
|
|
|
|
### 触发
|
|
|
|
- 待识别池条目详情 →「元数据补全」→「补全」看建议 →「应用」(可选「应用的字段同时锁定」、并入某个同名作品)。`POST /identity-pool/{id}/enrich`(organize.execute,经能力注册中心,授权 / 审计同 Agent)。
|
|
- 自动:`identity.autoEnrich`(缺省关)打开后,识别结果是 `no_match` 且查 TMDB 的理由是 `tracked_alias` / `new_title_rule` 时入队引擎任务 `enrichPoolEntry`(幂等键 `enrich:auto:<池条目>`,每条一次,不重试),只出建议不建作品;预算用完 / 网页搜索没开时任务正常结束(skipped)。
|
|
- Agent:`metadata.enrich` 在授权里直接放行(只存建议),受每日预算约束。
|
|
|
|
### 应用(POST /identity-pool/{id}/enrich/apply,只接受控制台管理员会话)
|
|
|
|
- 作品:指定 `mediaEntityId` 则并入;否则按标题 / 查询标题的归一化别名找已有作品(active / provisional,年份不冲突)去重并入;都没有就建 `status = provisional` 的作品(缺省剧集,`kind` 可改)。
|
|
- 字段:`metadata_field` 写 `source = web | agent`、`source_revision = enrich:<记录id>`、`evidence = {sourceUrl, evidenceLevel, enrichmentId, extractor}`;映射 title→title、year→year、synopsis→overview、platform→platform、episodeCount→episodeCount、genre→genres、posterUrl→posterUrl,然后按原规则投影(TMDB 值存在时仍优先 TMDB,锁定的字段不变)。`lock: true` 把应用的字段加进 `field_locks`。
|
|
- 别名:标题、查询标题、建议里的别名写 `title_alias`(source agent,kind other),会唤醒相近的池条目复查。
|
|
- 单元:剧集建第 1 季 + 1..集数(最多 200)临时单元。
|
|
- 待识别资源经 `assignReleaseIdentity` 关联到该作品(人工锁);记录改 applied,审计 `metadata.enrichment_applied`。不写外部身份。
|
|
- 后续 TMDB 收录:`provisionalEntitiesForTmdbReview()` 列出补全建的、还没有 TMDB 身份的临时作品,留给身份巡检按标题 / 年份查 TMDB 后用 `setEntityIdentity` 补映射。
|
|
|
|
作品详情「元数据」里来源显示「网页」/「Agent」,下面一行是证据等级和出处链接;网页海报经 `GET /api/v1/img/enrich/{记录id}` 代理(只取该记录里白名单网页给出的图片,出处仍须在白名单;不接受任意 URL;地址守卫同 web.fetch,最多 3 跳、5 MB、只收图片)。
|
|
|
|
### 设置(设置 → 元数据 → 资源识别)
|
|
|
|
| 字段 | 缺省 | 说明 |
|
|
|---|---|---|
|
|
| `identity.autoEnrich` | 关 | 订阅 / 规则命中而 TMDB 无结果时自动出建议 |
|
|
| `identity.enrichDailyBudget` | 20 | 每日次数(手动 + 自动 + Agent);0 = 停用 |
|
|
| `identity.enrichDomains` | 见上 | 可读取的域名,不放 PT 站 |
|
|
|
|
单次成本:1–2 次 Brave 搜索(10 分钟搜索缓存命中不计)、最多 3 次网页读取、1 次模型调用(约 3–6 千输入 token)。
|
|
|
|
迁移 `0049_enrichment`:`metadata_field` / `field_override` 来源加 `web`、`metadata_field.evidence`、`metadata_enrichment` 表。需要重建镜像 / 重启 api + worker 才生效。
|
|
|
|
```sql
|
|
-- 今天的补全
|
|
select status, trigger, count(*), sum((usage->>'searchCalls')::int) searches from metadata_enrichment
|
|
where created_at >= date_trunc('day', now()) group by 1, 2;
|
|
-- 某作品的补全字段与出处
|
|
select field, source, value, evidence->>'evidenceLevel' lvl, evidence->>'sourceUrl' url from metadata_field
|
|
where media_entity_id = '<id>' and source in ('web', 'agent');
|
|
```
|
|
|
|
### 真机记录(2026-09-29,开发库写记录,用户栈只读设置 / 凭证,未应用)
|
|
|
|
默认上游 OpenCode Go / deepseek-v4.1-flash + Brave,对池里 4 条 HHClub 短剧标题各跑一次(共 8 次 Brave 调用):
|
|
|
|
| 标题 | 结果 | 耗时 | 读取 / token |
|
|
|---|---|---|---|
|
|
| 我被亲戚误会破产了 | failed:搜索结果全是聚合站,不在白名单(当时还没有 site: 限定) | 1.8 s | 0 |
|
|
| 重生60 我有情报系统 | failed:加 site: 后有白名单结果,读了 3 个爱奇艺页(当时未做相关度过滤),模型判定没有这部作品的信息 | 6.2 s | 3 次 / 1601 入 533 出 |
|
|
| 京夜喜你 | failed:白名单结果标题都对不上,不读网页、不调模型 | 1.9 s | 0 |
|
|
| 春城恋曲 | 同上 | 1.8 s | 0 |
|
|
|
|
结论:平台自制短剧在百科 / 豆瓣 / 平台站基本没有可被搜索到的条目,补全多数会以"白名单网站里没有标题对得上的结果"结束,不消耗读取和模型;
|
|
要提高命中率可以在 `identity.enrichDomains` 里加入短剧平台的公开页面域名。聚合站、盗播站不要加。
|