feat(identity): web metadata enrichment for titles missing on TMDB (META-1)

- metadata.enrich (propose, low, replay safe): normalized-title cache (kv_cache
  region enrich, 7 days) -> daily budget -> web.search (title + 短剧 / 电视剧 平台,
  second query limited to whitelist via site:) -> web.fetch of up to 3 relevant
  whitelisted pages (registered site domains never read, redirects out of the
  whitelist dropped) -> upstream model extraction with rule fallback -> server-side
  evidence check (rule_confirmed / multi_evidence / needs_review). Suggestion only.
- POST /identity-pool/{id}/enrich (suggest) and /enrich/apply (console session):
  provisional entity or same-title merge, season/episode units, field-level
  metadata with source web/agent + evidence, aliases, release assignment.
  Poster proxy GET /img/enrich/{id} (og:image from whitelisted pages only).
- Auto-enrich once per pool entry when subscription/rule lookups find nothing on
  TMDB (identity.autoEnrich, off by default); identity.enrichDailyBudget,
  identity.enrichDomains settings; web.fetch now returns og:image.
- Pool sheet "元数据补全" section; entity detail shows evidence links.
- Migration 0049_enrichment (metadata_enrichment, metadata_field.evidence, source
  web). Requires image rebuild / api + worker restart to take effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 15:57:48 +08:00
1 parent 18a5cf1de7
commit f4666a4671
36 files changed
+2514 -25

No files matched your search

@@ -212,6 +212,8 @@ export function createAuthorizer(deps: {
? { allowed: true }
: { allowed: false, reason: "只有 Agent 会话可以让用户选方案" };
}
// metadata.enrich 只存补全建议(不写作品表);次数由每日预算约束,应用建议另走控制台会话
if (d.id === "metadata.enrich") return { allowed: true };
// 先看方案再执行:用户选定方案前,Agent 的执行类调用一律拒绝
if (runId && d.kind === "execute" && (ctx.source === "agent" || ctx.source === "task")) {
const block = await proposeFirstBlock(db, runId);
@@ -0,0 +1,121 @@
import { randomUUID } from "node:crypto";
import {
CapabilityError,
type CapabilityRegistry,
type HandlerContext,
PiUpstreamClient,
resolveAgentSettings,
} from "@umt/agent";
import {
capabilitySpec,
DEFAULT_WEB_SEARCH,
type EnrichmentTrigger,
type WebFetchOutput,
type WebSearchOutput,
} from "@umt/contracts";
import { resolveRunUpstream } from "../pi/index.ts";
import {
AUTO_ENRICH_ACTOR,
type EnrichDeps,
EnrichError,
type ModelCompletion,
suggestEnrichment,
} from "../sources-identity/service/enrich.ts";
import type { ServerContext } from "../types.ts";
import { agentSettings } from "./runner.ts";
// META-1 metadata.enrich(propose,low,replay safe):TMDB 查不到的作品按标题查网页出带证据的字段建议,不写作品表。
// 网络走 web.search / web.fetch 能力(同一注册中心:白名单 = 本次补全自己的搜索结果,审计照常记账);
// 模型用默认 Agent 上游做一次补全(fake 上游或没有上游时按规则抽取)。应用建议在控制台 /identity-pool/{id}/enrich/apply。
export interface MetadataCapabilityOptions {
/** 模型调用(测试注入 fake);返回 null = 按规则抽取 */
complete?: EnrichDeps["complete"];
}
/** 默认 Agent 上游做一次非流式补全;fake 上游 / 未配置上游返回 null */
export async function upstreamComplete(
ctx: ServerContext,
system: string,
user: string,
signal?: AbortSignal,
): Promise<ModelCompletion | null> {
const up = await resolveRunUpstream(ctx).catch(() => null);
if (!up || up.row.provider === "fake") return null;
const s = await ctx.settings.all();
const settings = resolveAgentSettings(agentSettings(s, {}, false, up));
const client = new PiUpstreamClient(up.spec, up.credentials);
const { models, model } = await client.resolveRunModel(settings);
const maxTokens = Math.max(512, Math.min(model.maxTokens || 2048, 2048));
const res = await models.completeSimple(
{ ...model, maxTokens },
{ systemPrompt: system, messages: [{ role: "user", content: user, timestamp: Date.now() }] },
{
maxTokens,
...(signal ? { signal } : {}),
timeoutMs: 90_000,
maxRetries: 1,
// OpenCode(Zen / Go)按会话路由,缺 x-opencode-session 会被拒
sessionId: `umt-enrich-${randomUUID()}`,
},
);
if (res.stopReason === "error" || res.stopReason === "aborted") throw new Error(res.errorMessage ?? "上游返回错误");
const text = res.content
.filter((b): b is { type: "text"; text: string } => b.type === "text")
.map((b) => b.text)
.join("");
return {
text,
model: res.responseModel ?? res.model ?? null,
inputTokens: res.usage?.input ?? 0,
outputTokens: res.usage?.output ?? 0,
};
}
function triggerOf(c: HandlerContext): EnrichmentTrigger {
if (c.actorId === AUTO_ENRICH_ACTOR) return "auto";
return c.source === "agent" || c.agentRunId ? "agent" : "manual";
}
export function registerMetadataCapabilities(
registry: CapabilityRegistry,
ctx: ServerContext,
opts: MetadataCapabilityOptions = {},
): void {
registry.register(capabilitySpec("metadata.enrich"), async (input, c) => {
// 网页搜索没开就不占预算
const agent = await ctx.settings.get("agent").catch(() => null);
const ws = { ...DEFAULT_WEB_SEARCH, ...(agent?.webSearch ?? {}) };
if (ws.provider === "none" || !ws.apiKeySecretRef || !registry.has("web.search"))
throw new CapabilityError("precondition", "网页搜索未开启,在设置 → Agent → 网页搜索里开启后再补全");
// 内部调用 web.* 用本次补全自己的调用方身份:白名单与 Agent 会话的互不覆盖
const sub = (scope: string) => ({
actorId: scope,
taskId: c.taskId,
scopeIds: [],
signal: c.signal,
source: "task" as const,
});
const deps: EnrichDeps = {
async search(query, scope) {
const out = (await registry.invoke("web.search", { query }, sub(scope))) as WebSearchOutput;
return out.results.map((r) => ({ title: r.title || null, url: r.url, snippet: r.snippet || null }));
},
async fetch(url, scope) {
const p = (await registry.invoke("web.fetch", { url }, sub(scope))) as WebFetchOutput;
return { url: p.url, finalUrl: p.finalUrl, title: p.title, text: p.text, image: p.image ?? null };
},
complete: opts.complete ?? ((system, user, signal) => upstreamComplete(ctx, system, user, signal)),
};
try {
return await suggestEnrichment(ctx, deps, input, {
trigger: triggerOf(c),
createdBy: c.actorId,
signal: c.signal,
});
} catch (e) {
if (e instanceof EnrichError) throw new CapabilityError(e.code, e.message);
throw e;
}
});
}
+2
View File
@@ -1,6 +1,7 @@
import type { ServerContext, ServerModule } from "../types.ts";
import { registerTaskCapabilities } from "./capabilities.ts";
import { registerDownloadRecoveryCapabilities } from "./capabilities-download.ts";
import { registerMetadataCapabilities } from "./capabilities-metadata.ts";
import { registerChallengeCapability } from "./challenge.ts";
import { registerInteractionCapabilities } from "./interaction.ts";
import { registerAgentRoutes } from "./routes.ts";
@@ -40,6 +41,7 @@ export function createAgentModule(opts: AgentModuleOptions = {}): ServerModule &
registerCapabilities(registry, ctx) {
registerTaskCapabilities(registry, ctx);
registerDownloadRecoveryCapabilities(registry, ctx, opts);
registerMetadataCapabilities(registry, ctx);
registerInteractionCapabilities(registry, ctx);
registerChallengeCapability(registry, () => env(ctx));
registerSubagentCapabilities(registry, ctx, {
+2 -1
View File
@@ -428,7 +428,8 @@ export async function drive(
const webSearch = s.agent.webSearch?.provider !== "none" && !!s.agent.webSearch?.apiKeySecretRef;
const capabilities = (scope.capabilities?.length ? scope.capabilities : Object.keys(CAPABILITY_CATALOG)).filter(
(id) =>
id.startsWith("web.")
// metadata.enrich(META-1)也要网页搜索
id.startsWith("web.") || id === "metadata.enrich"
? webSearch
: id === "agent.spawn"
? canSpawn