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:
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user