feat: 功能清单进智能体提示,补字幕按功能模块约定改为样板
- 外围功能模块(补字幕、原站视图、桌面模式、网页搜索、MCP、定时任务)声明 manifest,与 FEATURES 一一对应; 系统提示「能力地图」末尾列出外围功能(没有开关,能力只列本次可用的) - 补字幕:订阅 organization.finished(认出值得补字幕的整理,自动补字幕还没做,不建任务), 写入字幕后在同一事务发 subtitle.written;原有行为不变 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
1db689d9a7
commit
7aa5182f3d
14 files changed
+237
-17
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { featureManifest } from "@umt/contracts";
|
||||
import { sourcesIdentityModule } from "../sources-identity/index.ts";
|
||||
import type { ServerContext, ServerModule } from "../types.ts";
|
||||
import { registerBrowserCapabilities } from "./capabilities.ts";
|
||||
@@ -36,6 +37,7 @@ export function createBrowserModule(opts: BrowserModuleOptions = {}): ServerModu
|
||||
};
|
||||
return {
|
||||
name: "browser",
|
||||
manifest: featureManifest("browser"),
|
||||
service,
|
||||
registerRoutes(app, ctx) {
|
||||
registerBrowserRoutes(app, () => service(ctx));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { featureManifest } from "@umt/contracts";
|
||||
import { browserServiceFor } from "../browser/index.ts";
|
||||
import type { ServerContext, ServerModule } from "../types.ts";
|
||||
import { dbSessionChecks, desktopUpstream, registerDesktopRoutes } from "./desktop.ts";
|
||||
@@ -11,6 +12,7 @@ export function createDesktopModule(): ServerModule {
|
||||
let closeAll: (() => void) | null = null;
|
||||
return {
|
||||
name: "desktop",
|
||||
manifest: featureManifest("desktop"),
|
||||
registerRoutes(app, ctx: ServerContext) {
|
||||
const r = registerDesktopRoutes(app, {
|
||||
upstream: desktopUpstream({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { featureManifest } from "@umt/contracts";
|
||||
import denoJson from "../../../deno.json" with { type: "json" };
|
||||
import type { ServerModule } from "../types.ts";
|
||||
import { createMcpConfigStore } from "./config-store.ts";
|
||||
@@ -13,6 +14,7 @@ export function createMcpModule(opts: { version?: string } = {}): ServerModule &
|
||||
const table = new McpSessionTable();
|
||||
return {
|
||||
name: "mcp",
|
||||
manifest: featureManifest("mcp"),
|
||||
sessions: table,
|
||||
registerRoutes(app, ctx) {
|
||||
registerMcpRoutes(app, ctx, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { featureManifest } from "@umt/contracts";
|
||||
import type { ServerContext, ServerModule } from "../types.ts";
|
||||
import { builtinAgentCheckDefs } from "./agent-checks.ts";
|
||||
import { registerScheduleCapabilities } from "./capabilities.ts";
|
||||
@@ -26,6 +27,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
|
||||
let scheduler: Scheduler | null = null;
|
||||
return {
|
||||
name: "schedules",
|
||||
manifest: featureManifest("schedules"),
|
||||
scheduler: () => scheduler,
|
||||
registerRoutes(app, ctx) {
|
||||
registerScheduleRoutes(app, ctx, () => scheduleRegistry(ctx));
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type EventPayload, parseEventPayload } from "@umt/contracts";
|
||||
import type { OutboxRouteFn } from "@umt/tasks";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
|
||||
// 补字幕订阅的生命周期事件(ADR-006 功能模块样板)。
|
||||
// 「入库后自动补字幕」还没做(docs/plan/subtitles.md):这里只认出值得补字幕的整理,记一条调试日志,不建任务。
|
||||
// 以后做自动补字幕时把 null 换成 { task: "...", input } 即可,订阅关系不用再改。
|
||||
|
||||
const IMPORT_ACTIONS = new Set(["copy", "move", "in_place"]);
|
||||
|
||||
/** 整理结束事件里值得补字幕的:成功或部分成功、入库类动作(复制 / 移动 / 原地入库)、认到了作品 */
|
||||
export function subtitleCandidate(
|
||||
p: EventPayload<"organization.finished">,
|
||||
): { recordId: string; mediaEntityId: string } | null {
|
||||
if (p.outcome === "failed" || !IMPORT_ACTIONS.has(p.action) || !p.mediaEntityId) return null;
|
||||
return { recordId: p.recordId, mediaEntityId: p.mediaEntityId };
|
||||
}
|
||||
|
||||
export function subtitlesOutboxRoutes(ctx: Pick<ServerContext, "logger">): Record<string, OutboxRouteFn> {
|
||||
return {
|
||||
"organization.finished": (ev) => {
|
||||
const p = parseEventPayload("organization.finished", ev.payload);
|
||||
const c = p ? subtitleCandidate(p) : null;
|
||||
if (c) ctx.logger.debug(c, "补字幕:整理已完成,自动补字幕还没做,跳过");
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Api, type SubtitleApplyRequest, type SubtitleFetchRequest, type SubtitleSource } from "@umt/contracts";
|
||||
import {
|
||||
Api,
|
||||
featureManifest,
|
||||
type SubtitleApplyRequest,
|
||||
type SubtitleFetchRequest,
|
||||
type SubtitleSource,
|
||||
} from "@umt/contracts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import { route } from "../agent/http.ts";
|
||||
import { human } from "../browser/routes.ts";
|
||||
import { sourcesIdentityModule } from "../sources-identity/index.ts";
|
||||
import type { ServerModule } from "../types.ts";
|
||||
import { subtitlesOutboxRoutes } from "./events.ts";
|
||||
import {
|
||||
downloadFromSession,
|
||||
downloadOnline,
|
||||
@@ -13,10 +20,15 @@ import {
|
||||
} from "./online/index.ts";
|
||||
import { applySubtitles, parseSubtitles, subtitleTargets, subtitleWrites, undoSubtitleWrite } from "./service.ts";
|
||||
|
||||
/** 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销 */
|
||||
/**
|
||||
* 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销。
|
||||
* 功能模块样板(ADR-006):manifest 进功能清单(给智能体),订阅 organization.finished(events.ts),
|
||||
* 写入后发 subtitle.written;前端经插槽挂在作品页操作区与整理记录行菜单(apps/web/app/features/subtitles.tsx)。
|
||||
*/
|
||||
export function createSubtitlesModule(): ServerModule {
|
||||
return {
|
||||
name: "subtitles",
|
||||
manifest: featureManifest("subtitles"),
|
||||
registerRoutes(app, ctx) {
|
||||
const tag = { tag: "subtitles" };
|
||||
const rt = () => sourcesIdentityModule.runtime(ctx);
|
||||
@@ -69,6 +81,7 @@ export function createSubtitlesModule(): ServerModule {
|
||||
route(app, Api.subtitles.undo, ({ params }) => undoSubtitleWrite(ctx, params.id), tag);
|
||||
},
|
||||
registerTasks() {},
|
||||
outboxRoutes: (ctx) => subtitlesOutboxRoutes(ctx),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type {
|
||||
SubtitleApplyRequest,
|
||||
SubtitleApplyResult,
|
||||
SubtitleLanguage,
|
||||
SubtitleParsedItem,
|
||||
SubtitleParseResponse,
|
||||
SubtitleSource,
|
||||
SubtitleTarget,
|
||||
SubtitleTargetsResponse,
|
||||
SubtitleWriteView,
|
||||
import {
|
||||
lifecycleKey,
|
||||
type SubtitleApplyRequest,
|
||||
type SubtitleApplyResult,
|
||||
type SubtitleLanguage,
|
||||
type SubtitleParsedItem,
|
||||
type SubtitleParseResponse,
|
||||
type SubtitleSource,
|
||||
type SubtitleTarget,
|
||||
type SubtitleTargetsResponse,
|
||||
type SubtitleWriteView,
|
||||
} from "@umt/contracts";
|
||||
import type { SubtitleWriteTable } from "@umt/db";
|
||||
import {
|
||||
@@ -259,7 +260,7 @@ export async function applySubtitles(
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
return await trx
|
||||
const w = await trx
|
||||
.insertInto("subtitle_write")
|
||||
.values({
|
||||
id,
|
||||
@@ -277,6 +278,20 @@ export async function applySubtitles(
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
// 生命周期广播(ADR-006):字幕已写入
|
||||
await ctx.outbox.emit(
|
||||
trx,
|
||||
"subtitle.written",
|
||||
{
|
||||
writeId: w.id,
|
||||
mediaEntityId: w.media_entity_id,
|
||||
videoLocationId: t.locationId,
|
||||
language: w.language as SubtitleLanguage,
|
||||
source: w.source as SubtitleSource,
|
||||
},
|
||||
{ idempotencyKey: lifecycleKey("subtitle.written", w.id) },
|
||||
);
|
||||
return w;
|
||||
});
|
||||
written.push(writeView(row));
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { featureManifest } from "@umt/contracts";
|
||||
import type { ServerContext, ServerModule } from "../types.ts";
|
||||
import { registerWebSearchCapabilities } from "./capabilities.ts";
|
||||
import { registerWebSearchRoutes } from "./routes.ts";
|
||||
@@ -24,6 +25,7 @@ export function createWebSearchModule(opts: WebSearchModuleOptions = {}): Server
|
||||
};
|
||||
return {
|
||||
name: "websearch",
|
||||
manifest: featureManifest("websearch"),
|
||||
service,
|
||||
registerRoutes(app, ctx) {
|
||||
registerWebSearchRoutes(app, () => service(ctx));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiResponse, CAPABILITY_IDS, capabilitySpec } from "@umt/contracts";
|
||||
import { apiResponse, CAPABILITY_IDS, capabilitySpec, FEATURES, LIFECYCLE_EVENTS } from "@umt/contracts";
|
||||
import { defineTask, TASK_NAMES, TaskRegistry } from "@umt/tasks";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts";
|
||||
import { buildHookRegistry, buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts";
|
||||
import { createApp } from "../src/http/app.ts";
|
||||
import { requireScope } from "../src/http/auth.ts";
|
||||
import { EventBus } from "../src/http/sse.ts";
|
||||
@@ -103,7 +103,7 @@ describe("ServerModule 骨架", () => {
|
||||
expect(TASK_NAMES.filter((n) => !owned.has(n))).toEqual([]);
|
||||
});
|
||||
|
||||
it("outbox 路由合并;同一事件被两个模块认领报错", () => {
|
||||
it("outbox 路由合并;同一非广播事件被两个模块认领报错", () => {
|
||||
const merged = Object.keys(collectOutboxRoutes([...SERVER_MODULES, pingModule], fakeCtx));
|
||||
expect(merged).toContain("test.ping");
|
||||
expect(merged).toContain("release.resolved");
|
||||
@@ -111,6 +111,94 @@ describe("ServerModule 骨架", () => {
|
||||
expect(() => collectOutboxRoutes([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复认领/);
|
||||
});
|
||||
|
||||
it("生命周期广播事件可以被多个模块订阅:按模块顺序合成,各自的任务都投递,某个订阅方出错不连累别人", () => {
|
||||
const { logger } = memoryLogger();
|
||||
const ctx = { role: "all", logger } as unknown as ServerContext;
|
||||
const sub = (name: string, fn: () => unknown): ServerModule => ({
|
||||
name,
|
||||
registerRoutes() {},
|
||||
registerTasks() {},
|
||||
outboxRoutes: () => ({ "organization.finished": fn as never }),
|
||||
});
|
||||
const routes = collectOutboxRoutes(
|
||||
[
|
||||
...SERVER_MODULES,
|
||||
sub("a", () => ({ task: "taskA", input: { n: 1 } })),
|
||||
sub("broken", () => {
|
||||
throw new Error("坏了");
|
||||
}),
|
||||
sub("b", () => [{ task: "taskB", input: {} }]),
|
||||
sub("none", () => null),
|
||||
],
|
||||
ctx,
|
||||
);
|
||||
const ev = {
|
||||
id: "e1",
|
||||
type: "organization.finished",
|
||||
at: new Date(),
|
||||
payload: {
|
||||
recordId: "00000000-0000-4000-8000-000000000001",
|
||||
outcome: "succeeded",
|
||||
action: "copy",
|
||||
trigger: "auto",
|
||||
mediaEntityId: "00000000-0000-4000-8000-000000000002",
|
||||
downloadTaskId: null,
|
||||
reason: null,
|
||||
changeSeq: "7",
|
||||
},
|
||||
};
|
||||
// 补字幕(样板)订阅了整理结束,返回 null(自动补字幕还没做)
|
||||
expect(routes["organization.finished"]!(ev)).toEqual([
|
||||
{ task: "taskA", input: { n: 1 } },
|
||||
{ task: "taskB", input: {} },
|
||||
]);
|
||||
// 只有补字幕一个订阅方时它自己的路由不建任务
|
||||
expect(collectOutboxRoutes(SERVER_MODULES, ctx)["organization.finished"]!(ev)).toBeNull();
|
||||
// 非广播事件仍只能一个模块认领(release.observed 由 sources-identity 认领)
|
||||
expect(() => collectOutboxRoutes([...SERVER_MODULES, sub("x", () => null)], ctx)).not.toThrow();
|
||||
const dup: ServerModule = { ...sub("y", () => null), outboxRoutes: () => ({ "release.observed": () => null }) };
|
||||
expect(() => collectOutboxRoutes([...SERVER_MODULES, dup], ctx)).toThrow(/重复认领/);
|
||||
// 广播事件都登记了 payload schema
|
||||
expect(LIFECYCLE_EVENTS.length).toBeGreaterThanOrEqual(17);
|
||||
});
|
||||
|
||||
it("功能清单:外围功能模块都声明 manifest,与 contracts FEATURES 一一对应", () => {
|
||||
const declared = SERVER_MODULES.filter((m) => m.manifest).map((m) => [m.name, m.manifest!.id]);
|
||||
for (const [name, id] of declared) expect(id).toBe(name);
|
||||
expect(declared.map(([n]) => n).sort()).toEqual(FEATURES.map((f) => f.id).sort());
|
||||
const subtitles = SERVER_MODULES.find((m) => m.name === "subtitles")!;
|
||||
expect(subtitles.manifest?.subscribes).toEqual(["organization.finished"]);
|
||||
expect(Object.keys(subtitles.outboxRoutes?.(fakeCtx) ?? {})).toEqual(subtitles.manifest?.subscribes);
|
||||
});
|
||||
|
||||
it("链式钩子按模块顺序注册;生产装配目前没有模块注册钩子(无注册零开销)", async () => {
|
||||
const order: string[] = [];
|
||||
const mod = (name: string): ServerModule => ({
|
||||
name,
|
||||
registerRoutes() {},
|
||||
registerTasks() {},
|
||||
registerHooks(hooks) {
|
||||
hooks.rewrite("organize.rename", (n) => {
|
||||
order.push(name);
|
||||
return `${n}.${name}`;
|
||||
});
|
||||
},
|
||||
});
|
||||
const hooks = buildHookRegistry([mod("first"), mod("second")], fakeCtx);
|
||||
expect(
|
||||
await hooks.runRewrite("organize.rename", "a", {
|
||||
targetPath: "/a",
|
||||
sourcePath: "/b",
|
||||
mediaEntityId: "e",
|
||||
mediaType: "movie",
|
||||
season: null,
|
||||
episodes: null,
|
||||
}),
|
||||
).toBe("a.first.second");
|
||||
expect(order).toEqual(["first", "second"]);
|
||||
expect(buildHookRegistry(SERVER_MODULES, fakeCtx).list()).toEqual({});
|
||||
});
|
||||
|
||||
it("引擎就绪前调用给出明确错误", async () => {
|
||||
const e = new DeferredTaskEngine();
|
||||
expect(e.ready).toBe(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parseEventPayload } from "@umt/contracts";
|
||||
import { zipSync } from "fflate";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createSubtitlesModule } from "../../src/modules/subtitles/index.ts";
|
||||
@@ -159,6 +160,18 @@ describe.skipIf(!hasPg)("补字幕:上传、写入、撤销(真实 PG + 临时
|
||||
|
||||
const hist = await api("GET", `/subtitles/writes?entityId=${entityId}`);
|
||||
expect(hist.body.data.items).toHaveLength(2);
|
||||
// 生命周期广播(ADR-006):每个写入一条 subtitle.written,payload 过 contracts schema
|
||||
const events = await h.db
|
||||
.selectFrom("outbox_event")
|
||||
.select(["payload", "idempotency_key"])
|
||||
.where("type", "=", "subtitle.written")
|
||||
.orderBy("at")
|
||||
.execute();
|
||||
expect(events.map((e) => parseEventPayload("subtitle.written", e.payload))).toEqual([
|
||||
{ writeId: w1.id, mediaEntityId: entityId, videoLocationId: loc.e1, language: "zh-Hant", source: "upload" },
|
||||
{ writeId: w2.id, mediaEntityId: entityId, videoLocationId: loc.e2, language: "zh-Hans&en", source: "upload" },
|
||||
]);
|
||||
expect(events.map((e) => e.idempotency_key)).toEqual([`subtitle.written/${w1.id}`, `subtitle.written/${w2.id}`]);
|
||||
|
||||
expect((await api("POST", `/subtitles/writes/${w2.id}/undo`)).body.data.undoneAt).toBeTruthy();
|
||||
expect(await readFile(join(dir, "示例剧 - S01E02.chi.zh-cn.srt"), "utf8")).toBe("旧字幕");
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
参考 MoviePilot 插件「海拉鲁字幕大师」(ifsherlock/MoviePilot-Plugins `plugins.v3/subtitlemanualupload` 1.2.6)。本项目只取**手动上传**和**在线搜索**;调轴、AI 翻译联动、入库后自动补字幕不做。自动补字幕以后接进我们自己的工作流(整理完成、订阅缺字幕、智能体调用),复用本模块的能力,不照插件的事件队列做。
|
||||
|
||||
功能模块样板(ADR-006,2026-10-06):本模块已声明 manifest、订阅 `organization.finished`(认出值得补字幕的整理,自动补字幕还没做,只记调试日志)、写入后发 `subtitle.written`;前端经插槽挂在作品页操作区与整理记录行菜单。做自动补字幕时把订阅路由换成真实任务即可。
|
||||
|
||||
## 拆分
|
||||
|
||||
| 层面 | 放在哪 |
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
type IntentRoute,
|
||||
needsApproval,
|
||||
type RouteStep,
|
||||
renderFeatures,
|
||||
} from "./layers/capabilities.ts";
|
||||
export { renderEnvironment } from "./layers/environment.ts";
|
||||
export { AGENT_REPORT_FORMAT, REPORT_FORMAT } from "./layers/report.ts";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AUTHORIZED_KINDS, CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
|
||||
import { AUTHORIZED_KINDS, CAPABILITY_CATALOG, FEATURES, type KnownCapabilityId } from "@umt/contracts";
|
||||
import type { PromptSection } from "../types.ts";
|
||||
|
||||
// 第 3 层:能力地图,按用户意图组织("要查健康 → …","要查下载 → …")。工具描述里已有每个能力"何时用 / 何时不用",
|
||||
@@ -190,6 +190,20 @@ export function renderRoutes(available: ReadonlySet<string>): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能清单(ADR-006):外围功能各一行,说明做什么、入口在哪、智能体能不能做。清单来自 contracts 的 FEATURES
|
||||
* (服务端模块的 manifest 指向同一份),功能都已启用、没有开关;能力只列本次可用的。
|
||||
*/
|
||||
export function renderFeatures(available: ReadonlySet<string>): string[] {
|
||||
return [
|
||||
"- 外围功能(都已启用,没有开关;用户问到时按这里指路):",
|
||||
...FEATURES.map((f) => {
|
||||
const caps = f.capabilities.filter((c) => available.has(c));
|
||||
return ` - ${f.name}:${f.summary}${f.usage}${caps.length ? `(能力:${caps.join("、")})` : ""}`;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function capabilityLayer(capabilities: readonly string[] | undefined): PromptSection {
|
||||
if (!capabilities?.length) return { layer: "capabilities", title: "能力地图", lines: [] };
|
||||
const available = new Set(capabilities);
|
||||
@@ -202,6 +216,7 @@ export function capabilityLayer(capabilities: readonly string[] | undefined): Pr
|
||||
"按用户意图选路线,箭头是常用顺序;只做回答问题需要的几步。标「需要批准」的没有授权时会被拒绝或出审批卡。",
|
||||
...routes,
|
||||
...CAPABILITY_TIPS.map((t) => `- ${t}`),
|
||||
...renderFeatures(available),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CAPABILITY_CATALOG, FEATURES } from "@umt/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildLayeredPrompt, renderFeatures } from "../src/index.ts";
|
||||
|
||||
// 功能清单(ADR-006):外围功能的 manifest 进系统提示「能力地图」一节,让智能体知道有哪些功能、入口在哪;没有开关。
|
||||
|
||||
const section = (p: string, title: string) => {
|
||||
const start = p.indexOf(`## ${title}`);
|
||||
const rest = p.slice(start);
|
||||
const next = rest.indexOf("\n## ", 3);
|
||||
return next < 0 ? rest : rest.slice(0, next);
|
||||
};
|
||||
|
||||
describe("功能清单进系统提示", () => {
|
||||
it("能力地图里列出每个外围功能的名称、说明与入口", () => {
|
||||
const p = buildLayeredPrompt({ kind: "chat", role: "x", goal: "y", capabilities: ["download.list"] });
|
||||
const map = section(p, "能力地图");
|
||||
expect(map).toContain("外围功能(都已启用,没有开关");
|
||||
for (const f of FEATURES) expect(map).toContain(` - ${f.name}:${f.summary}`);
|
||||
expect(map).toContain("作品页「补字幕」");
|
||||
});
|
||||
|
||||
it("功能的能力只列本次可用的;没给的能力 ID 不出现在清单里", () => {
|
||||
const lines = renderFeatures(new Set(["web.search"]));
|
||||
const web = lines.find((l) => l.includes("网页搜索"))!;
|
||||
expect(web).toContain("(能力:web.search)");
|
||||
const text = lines.join("\n");
|
||||
for (const id of Object.keys(CAPABILITY_CATALOG)) if (id !== "web.search") expect(text).not.toContain(id);
|
||||
});
|
||||
|
||||
it("没有能力的会话(视觉子流程)不带功能清单", () => {
|
||||
const p = buildLayeredPrompt({ kind: "vision", role: "x", goal: "y" });
|
||||
expect(p).not.toContain("外围功能");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user