feat(server): W2-B sources, identity resolution, metadata and site workbench
- Sites / accounts / feeds: registry view (/site-definitions), site CRUD with
enable/disable (history stays queryable, marked site_disabled), accounts and
feed URLs stored as secrets (only secret_ref / redacted urlDisplay exposed),
feed enable/disable/reset/fetch; one shared rate limiter + adapter per site.
- Tasks: ingestFeed (FeedFetchState/FeedCursor on feed_source, observation
feed+guid upsert with digest-change updates, source_release site+id
aggregation across feeds, auth failure -> account invalid + decision, no
retry, gap -> feed.gap_suspected -> budgeted backfill snapshot),
resolveObservation (parseRelease -> local recall via title_alias pg_trgm +
accepted identities -> TMDB -> resolveIdentity; auto binds entity /
provisional units / identities / aliases, otherwise identity_candidate pool
with evidence version and bounded review backoff; manual lock respected),
refreshMetadata (metadata_field + field_override, user locks win, paths
untouched), snapshotSite (site_snapshot coverage records).
- Events: feed.due, feed.gap_suspected, release.observed (self-consumed),
release.resolved, decision.created/updated; scheduler in worker start().
- Identity API: entity detail with redirects, manual mapping correction with
identity_revision, merge with identity_redirect, pool list/assign/dismiss/
retry, content unit numbering lock, aliases, external identities.
- Decisions: list/summary (by entity and kind)/detail/resolve; identity
options executed directly.
- Workbench: /sites/{id}/releases (snapshot first, coalesced live search with
backoff, origin + freshness) and /sites/{id}/releases/{rid} (volatile field
recheck times, refresh keeps data on failure).
- Capabilities: catalog.lookup, release.search, release.inspect,
identity.resolve.
- Migrations 0018-0020: site.adapter api, source_release.resolution_locked,
site_snapshot, identity_candidate, resolution_attempt, identity_redirect,
metadata_field, field_override.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
829ca4f7ac
commit
a24bda952b
39 files changed
+6861
-15
No files matched your search
@@ -119,3 +119,142 @@ export const IdentityRevisionCreate = IdentityRevision.pick({
|
||||
reason: true,
|
||||
}).partial({ fromStatus: true, toStatus: true, before: true, actorId: true, reason: true });
|
||||
export type IdentityRevisionCreate = z.infer<typeof IdentityRevisionCreate>;
|
||||
|
||||
// ── W2-B:待识别池、识别尝试、合并跳转与人工纠错(REQ §7.2、§7.5) ──
|
||||
|
||||
export const IdentityCandidateStatus = z.enum(["pending", "resolved", "dismissed", "archived"]);
|
||||
export type IdentityCandidateStatus = z.infer<typeof IdentityCandidateStatus>;
|
||||
/** proposed = 证据足够但自动化关闭/只读,待确认 */
|
||||
export const IdentityCandidateReason = z.enum(["ambiguous", "no_match", "proposed", "lookup_failed"]);
|
||||
export type IdentityCandidateReason = z.infer<typeof IdentityCandidateReason>;
|
||||
|
||||
/** 待识别池里的一个候选(只存 JSON,不建正式作品) */
|
||||
export const PoolCandidate = z.object({
|
||||
provider: z.string(),
|
||||
entityType: z.enum(["movie", "tv"]),
|
||||
externalId: z.string(),
|
||||
title: z.string(),
|
||||
year: z.number().int().nullable(),
|
||||
/** 本地候选的作品 ID */
|
||||
localEntityId: Id.nullable(),
|
||||
level: z.enum(["rule_confirmed", "multi_evidence", "needs_confirm", "rejected"]),
|
||||
evidence: z.array(z.string()),
|
||||
});
|
||||
export type PoolCandidate = z.infer<typeof PoolCandidate>;
|
||||
|
||||
/** 待识别池条目:每条 source_release 至多一条;失败原因、证据版本与下一次复查时间 */
|
||||
export const IdentityCandidate = EntityBase.extend({
|
||||
sourceReleaseId: Id,
|
||||
status: IdentityCandidateStatus,
|
||||
reason: IdentityCandidateReason,
|
||||
/** 解析出的检索条件(标题/年份/季集) */
|
||||
query: JsonObject,
|
||||
candidates: z.array(PoolCandidate),
|
||||
evidenceVersion: z.string(),
|
||||
attempts: z.number().int().nonnegative(),
|
||||
lastAttemptAt: Timestamp.nullable(),
|
||||
nextReviewAt: Timestamp.nullable(),
|
||||
decisionId: Id.nullable(),
|
||||
resolvedMediaEntityId: Id.nullable(),
|
||||
resolvedBy: z.string().nullable(),
|
||||
resolvedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type IdentityCandidate = z.infer<typeof IdentityCandidate>;
|
||||
|
||||
export const ResolutionOutcome = z.enum([
|
||||
"auto",
|
||||
"proposed",
|
||||
"ambiguous",
|
||||
"no_match",
|
||||
"lookup_failed",
|
||||
"manual",
|
||||
"skipped_locked",
|
||||
]);
|
||||
export type ResolutionOutcome = z.infer<typeof ResolutionOutcome>;
|
||||
|
||||
/** 一次识别尝试(只追加) */
|
||||
export const ResolutionAttempt = EntityBase.extend({
|
||||
sourceReleaseId: Id,
|
||||
observationId: Id.nullable(),
|
||||
outcome: ResolutionOutcome,
|
||||
reason: z.string().nullable(),
|
||||
evidenceVersion: z.string(),
|
||||
candidateCount: z.number().int().nonnegative(),
|
||||
mediaEntityId: Id.nullable(),
|
||||
best: JsonObject.nullable(),
|
||||
error: JsonObject.nullable(),
|
||||
actorType: ActorType,
|
||||
actorId: z.string().nullable(),
|
||||
});
|
||||
export type ResolutionAttempt = z.infer<typeof ResolutionAttempt>;
|
||||
|
||||
/** 作品合并后的旧 ID 跳转 */
|
||||
export const IdentityRedirect = EntityBase.extend({
|
||||
fromEntityId: Id,
|
||||
toEntityId: Id,
|
||||
reason: z.string().nullable(),
|
||||
actorType: ActorType,
|
||||
actorId: z.string().nullable(),
|
||||
});
|
||||
export type IdentityRedirect = z.infer<typeof IdentityRedirect>;
|
||||
|
||||
/** 外部身份引用(provider + 类型 + ID) */
|
||||
export const ExternalRef = z.object({
|
||||
provider: IdentityProvider,
|
||||
entityType: z.enum(["movie", "tv"]),
|
||||
externalId: z.string().min(1),
|
||||
});
|
||||
export type ExternalRef = z.infer<typeof ExternalRef>;
|
||||
|
||||
/**
|
||||
* 人工指定来源资源的身份:本地作品 / 外部身份(未登记时按外部详情建档)/ 池内候选序号,三选一。
|
||||
* 指定后加人工锁,自动识别不再改写。
|
||||
*/
|
||||
export const ReleaseIdentityAssign = z
|
||||
.object({
|
||||
mediaEntityId: Id.optional(),
|
||||
external: ExternalRef.optional(),
|
||||
candidateIndex: z.number().int().min(0).optional(),
|
||||
/** 同时关联到某个内容单元(季/集);缺省按解析出的季集建临时单元 */
|
||||
contentUnitId: Id.nullable().optional(),
|
||||
lock: z.boolean().default(true),
|
||||
reason: z.string().max(2000).optional(),
|
||||
})
|
||||
.refine((v) => [v.mediaEntityId, v.external, v.candidateIndex].filter((x) => x !== undefined).length === 1, {
|
||||
message: "mediaEntityId / external / candidateIndex 必须且只能给一个",
|
||||
});
|
||||
export type ReleaseIdentityAssign = z.infer<typeof ReleaseIdentityAssign>;
|
||||
|
||||
/** 改作品的外部映射:旧的同 provider+类型 accepted 映射退役(留修订),新映射 accepted + 人工锁 */
|
||||
export const EntityIdentitySet = ExternalRef.extend({
|
||||
reason: z.string().max(2000).optional(),
|
||||
/** 默认加人工锁 */
|
||||
lock: z.boolean().default(true),
|
||||
});
|
||||
export type EntityIdentitySet = z.infer<typeof EntityIdentitySet>;
|
||||
|
||||
export const IdentityPoolQuery = z.object({
|
||||
status: IdentityCandidateStatus.optional(),
|
||||
reason: IdentityCandidateReason.optional(),
|
||||
/** 按原始标题/解析标题检索(已观察到的资源必须能从待识别入口找回) */
|
||||
q: z.string().trim().min(1).max(200).optional(),
|
||||
siteId: Id.optional(),
|
||||
cursor: z.string().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
});
|
||||
export type IdentityPoolQuery = z.infer<typeof IdentityPoolQuery>;
|
||||
|
||||
/** 待识别池列表条目:附来源资源摘要 */
|
||||
export const IdentityPoolItem = IdentityCandidate.extend({
|
||||
release: z.object({
|
||||
id: Id,
|
||||
siteId: Id,
|
||||
siteReleaseId: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().nullable(),
|
||||
categoryRaw: z.string().nullable(),
|
||||
firstSeenAt: Timestamp,
|
||||
resolutionStatus: z.string(),
|
||||
}),
|
||||
});
|
||||
export type IdentityPoolItem = z.infer<typeof IdentityPoolItem>;
|
||||
Reference in new issue
Block a user