aps-agent/server/agent/rule-planner.ts

164 lines
6.6 KiB
TypeScript
Raw Normal View History

/**
* 规则计划器:一个确定性的「选能力」实现,用来给评测集做基线,也让假引擎能真调工具。
*
* 它不是要替代模型——只是给出地板值:同样的评测集,规则版能拿多少分,
* 真引擎(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 (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] };
};
}