/** * 每轮动态规则选择与正文加载(预算控制) */ import * as fs from "node:fs"; import * as path from "node:path"; import { BASE_RULE_NAME, CODING_SIGNAL_KEYWORDS, DOMAIN_RULES, MAX_DOMAIN_RULES, MAX_INJECT_BYTES, SKIP_CODING_KEYWORDS, STYLE_RULE_NAME, type DomainRuleSpec, type ProductFamily, } from "./rule-catalog"; export interface RouteInput { prompt: string; productIds: string[]; packageRoot: string; } export interface RouteResult { selected: string[]; injection: string; bytes: number; skipped: boolean; reason: string; } export function familyFromProductIds(productIds: string[]): ProductFamily { const ids = productIds.map(id => id.toLowerCase()); const hasCosmic = ids.some(id => id.includes("cosmic")); const hasEnt = ids.some(id => id.includes("enterprise")); if (hasCosmic && !hasEnt) return "cosmic"; if (hasEnt && !hasCosmic) return "enterprise"; return "unknown"; } function normalize(text: string): string { return text.toLowerCase(); } function hasAny(hay: string, needles: readonly string[]): boolean { return needles.some(n => hay.includes(n.toLowerCase())); } function familyAllows(spec: DomainRuleSpec, family: ProductFamily): boolean { if (spec.families.includes(family)) return true; if (family === "unknown") return true; // 画像未定时靠关键词 return false; } function scoreRule(spec: DomainRuleSpec, hay: string, family: ProductFamily): number { if (!familyAllows(spec, family)) return -1; let hits = 0; for (const kw of spec.keywords) { if (hay.includes(kw.toLowerCase())) hits++; } if (hits === 0) return -1; return hits * 10 + spec.priority; } export function selectRuleNames( prompt: string, productIds: string[], ): { names: string[]; skipped: boolean; reason: string } { const hay = normalize(prompt); if (hasAny(hay, SKIP_CODING_KEYWORDS) && !hasAny(hay, CODING_SIGNAL_KEYWORDS)) { return { names: [], skipped: true, reason: "文档类任务,跳过业务编码规则注入" }; } if (!hasAny(hay, CODING_SIGNAL_KEYWORDS) && productIds.length === 0) { return { names: [], skipped: true, reason: "无编码信号且无产品画像,跳过规则正文注入" }; } const family = familyFromProductIds(productIds); const scored: { name: string; score: number }[] = []; for (const spec of DOMAIN_RULES) { const s = scoreRule(spec, hay, family); if (s > 0) scored.push({ name: spec.name, score: s }); } scored.sort((a, b) => b.score - a.score); const domain = scored.slice(0, MAX_DOMAIN_RULES).map(x => x.name); const names = [BASE_RULE_NAME, STYLE_RULE_NAME, ...domain.filter(n => n !== BASE_RULE_NAME && n !== STYLE_RULE_NAME)].slice( 0, 2 + MAX_DOMAIN_RULES, ); if (names.length === 0) { return { names: [], skipped: true, reason: "未命中领域规则" }; } return { names, skipped: false, reason: `family=${family}; selected=${names.join(",")}`, }; } export function stripFrontmatter(raw: string): string { if (!raw.startsWith("---")) return raw.trim(); const end = raw.indexOf("\n---", 3); if (end < 0) return raw.trim(); return raw.slice(end + 4).replace(/^\r?\n/, "").trim(); } export function loadRuleBody(packageRoot: string, ruleName: string): string | null { const filePath = path.join(packageRoot, "rules", `${ruleName}.md`); if (!fs.existsSync(filePath)) return null; try { return stripFrontmatter(fs.readFileSync(filePath, "utf8")); } catch { return null; } } export function buildInjection( packageRoot: string, ruleNames: string[], ): { text: string; bytes: number; loaded: string[] } { const parts: string[] = []; const loaded: string[] = []; let bytes = 0; for (const name of ruleNames) { const body = loadRuleBody(packageRoot, name); if (!body) continue; const block = `### rule://${name}\n\n${body}`; const blockBytes = Buffer.byteLength(block, "utf8"); if (bytes + blockBytes > MAX_INJECT_BYTES && loaded.length > 0) break; if (blockBytes > MAX_INJECT_BYTES && loaded.length === 0) { const truncated = body.slice(0, Math.max(0, Math.floor(MAX_INJECT_BYTES / 2))); const tblock = `### rule://${name}\n\n${truncated}\n\n…(已截断至预算)`; parts.push(tblock); loaded.push(name); bytes = Buffer.byteLength(tblock, "utf8"); break; } parts.push(block); loaded.push(name); bytes += blockBytes; } if (parts.length === 0) return { text: "", bytes: 0, loaded: [] }; const header = [ "", "## 本轮金蝶编码规则(动态注入)", "", `已加载:${loaded.map(n => `rule://${n}`).join(", ")}`, `注入约 ${bytes} 字节(预算 ${MAX_INJECT_BYTES})。编码时必须遵守下列约束。`, "", ].join("\n"); const text = header + parts.join("\n\n"); return { text, bytes: Buffer.byteLength(text, "utf8"), loaded }; } export function routeRules(input: RouteInput): RouteResult { const { names, skipped, reason } = selectRuleNames(input.prompt, input.productIds); if (skipped || names.length === 0) { return { selected: [], injection: "", bytes: 0, skipped: true, reason }; } const { text, bytes, loaded } = buildInjection(input.packageRoot, names); if (loaded.length === 0) { return { selected: [], injection: "", bytes: 0, skipped: true, reason: `规则文件未找到: ${names.join(",")}`, }; } return { selected: loaded, injection: text, bytes, skipped: false, reason }; }