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

162 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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