/** * 规则计划器:一个确定性的「选能力」实现,用来给评测集做基线,也让假引擎能真调工具。 * * 它不是要替代模型——只是给出地板值:同样的评测集,规则版能拿多少分, * 真引擎(Pi)必须明显高过它才有意义。匹配依据全部来自能力描述(同义词 + 用途 + id), * 这也顺带检验描述写得够不够清楚。 */ import type { FakeTurn } from './fake-runtime.js'; import type { ToolDescriptor } from './runtime.js'; export type PlannerBehavior = 'tool' | 'clarify' | 'reject'; export interface PlannerDecision { behavior: PlannerBehavior; capabilityId?: string; args?: Record; /** 给用户看的话(澄清提问或拒绝理由) */ message: string; /** behavior=tool 时的理由,便于排查 */ reason?: string; candidates?: string[]; } export type Planner = (text: string, tools: ToolDescriptor[]) => PlannerDecision; /** 从 id 拆出关键词:aps.schedule.preview → [aps, schedule, preview] */ function idTokens(capabilityId: string): string[] { return capabilityId.split('.').filter((token) => token.length >= 3); } /** 中文按整串匹配,英文按小写匹配 */ function scoreTool(text: string, tool: ToolDescriptor): { score: number; hits: string[] } { const haystack = text.toLowerCase(); const keywords = new Set([...idTokens(tool.capabilityId), ...(tool.synonyms ?? [])]); const hits: string[] = []; let score = 0; for (const keyword of keywords) { const needle = keyword.toLowerCase(); if (needle.length >= 2 && haystack.includes(needle)) { hits.push(keyword); // 更长的匹配更可信:中文同义词通常 2–4 字 score += Math.min(needle.length, 6); } } return { score, hits }; } /** 不该由平台做的事:直连业务库、操作本机文件 */ const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; message: string }> = [ { pattern: /数据库|直连|表结构/, message: '平台不直连业务系统数据库,这类操作请在业务系统自己的界面里做。' }, { pattern: /服务器上的|配置文件|删除文件|删掉文件|rm\s|-rf/, message: '平台不给引擎文件与命令行权限,本机操作请自行在终端完成。' }, ]; /** 通用抽取表:按能力声明的参数名去文本里找值。规则版的抽取能力有限,这是基线的一部分。 */ const PERIOD_WORDS: Array<[RegExp, string]> = [ [/这个月|本月/, '本月'], [/这周|本周/, '本周'], [/上个月|上月/, '上月'], [/下周/, '下周'], ]; const METRIC_WORDS = ['交期达成率', '抛料率', '达成率', '利用率', 'OEE']; const ENTITY_WORDS = ['换型时间', '换模时间', '换型', '工厂日历', '设备', '能力池']; export function extractArgs(text: string, tool: ToolDescriptor): Record { const declared = ((tool.parameters as { properties?: Record }).properties ?? {}) as Record; const wants = (name: string) => Object.prototype.hasOwnProperty.call(declared, name); const args: Record = {}; const orderNo = text.match(/([A-Z]{2,}-\d{4}-\d{3,}|[A-Z]{2,}-\d{3,})/); if (orderNo?.[1]) args.orderNo = orderNo[1]; const days = text.match(/(\d+)\s*天/); if (days?.[1]) args.days = Number(days[1]); for (const [pattern, value] of PERIOD_WORDS) { if (pattern.test(text)) { args.period = value; if (value === '下周') args.horizon = '下周'; } } if (/下季度/.test(text)) args.horizon_days = 90; if (/下周/.test(text)) args.horizon ??= '下周'; if (/未排/.test(text)) args.status = '未排产'; if (/已排|排好了/.test(text)) args.status = '已排产'; if (wants('project') || /项目/.test(text)) { const named = text.match(/([\u4e00-\u9fa5A-Za-z0-9]{2,8})项目/); if (named?.[1] && named[1] !== '这个' && named[1] !== '当前' && named[1] !== '本') args.project = named[1]; else if (/这个项目|当前项目|本项目|项目里/.test(text)) args.project = '当前项目'; } if (wants('metric')) { const hit = METRIC_WORDS.find((word) => text.includes(word)); if (hit) args.metric = hit; } if (wants('entity')) { const hit = ENTITY_WORDS.find((word) => text.includes(word)); if (hit) args.entity = hit; } if (wants('text')) args.text = text; if (wants('query')) args.query = text; if (/CP-?SAT/i.test(text)) args.strategy = 'cpsat'; return args; } export function createRulePlanner(): Planner { return (text, tools) => { for (const forbidden of FORBIDDEN_PATTERNS) { if (forbidden.pattern.test(text)) { return { behavior: 'reject', message: forbidden.message }; } } const scored = tools .map((tool) => ({ tool, ...scoreTool(text, tool) })) .filter((entry) => entry.score > 0) .sort((a, b) => b.score - a.score); if (scored.length === 0) { return { behavior: 'reject', message: '没有匹配到可用的能力,我无法执行这个请求。' }; } const best = scored[0]; const runnerUp = scored[1]; if (!best) { return { behavior: 'reject', message: '没有匹配到可用的能力,我无法执行这个请求。' }; } // 同族能力分数接近时先问清楚,不硬猜(评测集里的「歧义」类正是考这个) if (runnerUp && best.score - runnerUp.score <= 2) { return { behavior: 'clarify', message: `你想做的是哪一件:${best.tool.capabilityId},还是 ${runnerUp.tool.capabilityId}?`, candidates: [best.tool.capabilityId, runnerUp.tool.capabilityId], }; } return { behavior: 'tool', capabilityId: best.tool.capabilityId, args: extractArgs(text, best.tool), message: '', reason: `命中关键词:${best.hits.join('、')}(${best.score} 分)`, }; }; } /** * 把规则计划器接成假引擎的回合脚本:命中能力就调工具,否则把澄清或拒绝的话说出来。 * 这样 M1 的「平台可对话(假引擎)」是一条真实链路:模型位由规则计划器占着,其余全是真的。 */ export function plannerToFakeTurn(planner: Planner): (text: string, tools: ToolDescriptor[]) => FakeTurn { return (text, tools) => { const decision = planner(text, tools); if (decision.behavior === 'tool' && decision.capabilityId !== undefined) { const capabilityId = decision.capabilityId; return { text: [`我来调用 ${capabilityId}。`], toolCalls: [{ capabilityId, args: decision.args ?? {} }], afterToolsText: [`${capabilityId} 已执行。`], }; } return { text: [decision.message] }; }; }