Files
proxycast/scripts/knowledge-provider-e2e.mjs
T
2026-05-08 14:09:05 +08:00

270 lines
9.2 KiB
JavaScript
Executable File

#!/usr/bin/env node
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
const DEFAULTS = {
invokeUrl: "http://127.0.0.1:3030/invoke",
packName: "provider-e2e-persona",
output: null,
};
const DEFAULT_SOURCE_TEXT = [
"# 访谈摘录:林澈",
"",
"林澈是一个面向中小品牌的内容增长顾问,过去 8 年主要帮助茶饮、文旅和本地生活团队搭建内容栏目、私域转化和直播复盘机制。",
"",
"他说自己最常被客户找来解决的问题不是“写一篇爆款”,而是“让团队每周都能稳定产出能转化的内容”。他反复强调:内容不是灵感管理,而是经营节奏管理。",
"",
"代表案例:2025 年帮助一家地方茶饮品牌从零搭建小红书和视频号内容日历。三个月内完成 12 个栏目测试,沉淀 4 个可复用栏目,其中“门店真实一天”和“顾客点单理由”成为固定栏目。团队从临时追热点改为每周选题会 + 每日素材池。",
"",
"表达风格:直接、克制、偏实战,不喜欢“颠覆”“闭环神话”“私域收割”这类词。他常说:“内容的第一目标不是热闹,是让用户知道你为什么值得信任。”",
"",
"边界:不能声称服务过上市公司;不能夸大 GMV;不能把客户数据写成公开案例。",
].join("\n");
function printHelp() {
console.log(`Usage: node scripts/knowledge-provider-e2e.mjs [options]
真实 Provider E2E,验证 knowledge_compile_pack -> Builder Skill -> documents/ -> compiled/splits -> persona context。
默认不会调用外部模型;必须显式传 --allow-external-provider。
Options:
--invoke-url <url> DevBridge invoke URL,默认 ${DEFAULTS.invokeUrl}
--provider <id> API Key Provider id,例如 custom-xxx 或 lime-hub
--model <model> 模型 id
--working-dir <dir> E2E workspace;默认创建临时目录
--pack-name <name> pack name,默认 ${DEFAULTS.packName}
--source-file <path> 自定义 Markdown 来源;默认使用内置非敏感示例
--output <path> 写出 sanitized evidence JSON
--list-providers 只列出 provider 摘要,不调用模型
--allow-external-provider 明确允许外部模型调用、资料外发与费用消耗
--help, -h 显示帮助
`);
}
function parseArgs(argv) {
const options = { ...DEFAULTS, allowExternalProvider: false, listProviders: false };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
options.help = true;
continue;
}
if (arg === "--allow-external-provider") {
options.allowExternalProvider = true;
continue;
}
if (arg === "--list-providers") {
options.listProviders = true;
continue;
}
if (["--invoke-url", "--provider", "--model", "--working-dir", "--pack-name", "--source-file", "--output"].includes(arg)) {
const value = argv[index + 1];
if (!value) {
throw new Error(`${arg} 需要参数`);
}
const key = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
options[key] = value;
index += 1;
continue;
}
throw new Error(`未知参数:${arg}`);
}
return options;
}
async function invoke(invokeUrl, cmd, args = {}) {
const response = await fetch(invokeUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ cmd, args }),
});
const text = await response.text();
let payload;
try {
payload = JSON.parse(text);
} catch {
throw new Error(`DevBridge 非 JSON 响应:${text.slice(0, 400)}`);
}
if (payload.error) {
throw new Error(payload.error);
}
return payload.result;
}
function sanitizeProviders(providers) {
return providers.map((provider) => ({
id: provider.id,
name: provider.name,
type: provider.type,
enabled: provider.enabled,
api_key_count: provider.api_key_count,
custom_models: Array.isArray(provider.custom_models)
? provider.custom_models.slice(0, 8)
: provider.custom_models,
}));
}
function loadSourceText(sourceFile) {
if (!sourceFile) {
return DEFAULT_SOURCE_TEXT;
}
return fs.readFileSync(path.resolve(sourceFile), "utf8");
}
function ensureProviderOptions(options) {
if (!options.provider || !options.model) {
throw new Error("真实 Provider E2E 需要同时传 --provider 和 --model;可先用 --list-providers 查看摘要");
}
if (!options.allowExternalProvider) {
throw new Error("拒绝执行外部模型调用:请显式传 --allow-external-provider,表示已确认资料外发和费用消耗风险");
}
}
function evidenceOutputPath(options, workingDir) {
if (options.output) {
return path.resolve(options.output);
}
return path.join(workingDir, "provider-e2e-result.json");
}
function readJsonIfExists(filePath) {
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
async function runProviderE2E(options) {
ensureProviderOptions(options);
const workingDir = options.workingDir
? path.resolve(options.workingDir)
: fs.mkdtempSync(path.join(os.tmpdir(), "lime-knowledge-provider-e2e-"));
fs.mkdirSync(workingDir, { recursive: true });
const packName = options.packName;
const sourceText = loadSourceText(options.sourceFile);
const sessionId = `knowledge-provider-e2e-${Date.now()}`;
await invoke(options.invokeUrl, "knowledge_import_source", {
request: {
workingDir,
packName,
description: "真实 Provider E2E:内容增长顾问个人 IP 知识包",
packType: "personal-profile",
language: "zh-CN",
sourceFileName: "provider-e2e-source.md",
sourceText,
},
});
const compileResult = await invoke(options.invokeUrl, "knowledge_compile_pack", {
request: {
workingDir,
name: packName,
builderRuntime: {
enabled: true,
providerOverride: options.provider,
modelOverride: options.model,
sessionId,
},
},
});
await invoke(options.invokeUrl, "knowledge_update_pack_status", {
request: { workingDir, name: packName, status: "ready" },
});
const detail = await invoke(options.invokeUrl, "knowledge_get_pack", {
request: { workingDir, name: packName },
});
const context = await invoke(options.invokeUrl, "knowledge_resolve_context", {
request: {
workingDir,
name: packName,
task: "根据个人 IP 知识库写一段不夸大的视频号简介",
maxChars: 8000,
activation: "explicit",
writeRun: true,
runReason: "provider-e2e-script",
},
});
const runRecord = JSON.parse(fs.readFileSync(compileResult.run.absolutePath, "utf8"));
const docPath = detail.documents?.[0]?.absolutePath;
const doc = docPath && fs.existsSync(docPath) ? fs.readFileSync(docPath, "utf8") : "";
const indexPath = path.join(workingDir, ".lime", "knowledge", "packs", packName, "compiled", "index.json");
const compiledIndex = readJsonIfExists(indexPath);
const evidence = {
workingDir,
packName,
provider: {
id: options.provider,
model: options.model,
},
compile: {
selectedSourceCount: compileResult.selectedSourceCount,
runRelativePath: compileResult.run.relativePath,
warnings: compileResult.warnings,
},
runtimeBinding: runRecord.builder_skill?.runtime_binding ?? null,
artifacts: {
documents: (detail.documents ?? []).map((file) => file.relativePath),
compiled: (detail.compiled ?? []).map((file) => file.relativePath),
runs: (detail.runs ?? []).map((file) => file.relativePath),
compiledIndexExists: Boolean(compiledIndex),
splitCount: compiledIndex?.splits?.length ?? 0,
},
context: {
status: context.status,
runPath: context.runPath,
tokenEstimate: context.tokenEstimate,
selectedFiles: context.selectedFiles,
warningCount: context.warnings.length,
fencedPrefix: context.fencedContext.slice(0, 180),
},
documentShape: {
startsWithMarkdownHeading: doc.trimStart().startsWith("# "),
startsWithJsonFence: doc.trimStart().startsWith("```json"),
containsBoundary: doc.includes("上市公司") || doc.includes("GMV") || doc.includes("不得"),
length: [...doc].length,
preview: doc.slice(0, 600),
},
};
const outputPath = evidenceOutputPath(options, workingDir);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, `${JSON.stringify(evidence, null, 2)}\n`);
return { outputPath, evidence };
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
if (options.listProviders) {
const providers = await invoke(options.invokeUrl, "get_api_key_providers");
console.log(JSON.stringify(sanitizeProviders(providers), null, 2));
return;
}
const { outputPath, evidence } = await runProviderE2E(options);
console.log(JSON.stringify({ outputPath, evidence }, null, 2));
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});