162 lines
6.5 KiB
TypeScript
162 lines
6.5 KiB
TypeScript
/**
|
||
* 规则计划器:一个确定性的「选能力」实现,用来给评测集做基线,也让假引擎能真调工具。
|
||
*
|
||
* 它不是要替代模型——只是给出地板值:同样的评测集,规则版能拿多少分,
|
||
* 真引擎(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<string, unknown>;
|
||
/** 给用户看的话(澄清提问或拒绝理由) */
|
||
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<string>([...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<string, unknown> {
|
||
const declared = ((tool.parameters as { properties?: Record<string, unknown> }).properties ?? {}) as Record<string, unknown>;
|
||
const wants = (name: string) => Object.prototype.hasOwnProperty.call(declared, name);
|
||
const args: Record<string, unknown> = {};
|
||
|
||
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 (/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] };
|
||
};
|
||
} |