feat(ai): add opt-in auto intent routing for the assistant

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-17 12:53:55 +08:00
1 parent 023f0074bc
commit 3f3c21aae9
26 files changed
+1454 -16

No files matched your search

+469
View File
@@ -0,0 +1,469 @@
import { describe, expect, it, vi } from "vitest";
import * as api from "@/lib/backend/api";
import { AGENT_ACTIONS, ASK_ACTIONS, buildAgentRequest, isValidActionForMode, type AiAction, type AiContext } from "@/lib/ai/ai";
import { INTENT_CLASSIFY_MAX_TEXT_CHARS, actionForIntent, classifyIntentByLlm, intentCandidatesForMode, parseClassifierAction, requestsExplicitExecution, routeIntent, routeIntentByRules, type AiIntent, type AiIntentRouteInput } from "@/lib/ai/aiIntentRouter";
import type { AiConfig } from "@/stores/settingsStore";
/**
* #9118 — the "Auto" picker entry routes a send onto one existing action.
* These cases pin the two-stage contract: the rule layer must be narrow (no
* hijacking plain chat), the classifier branch must be candidate-bounded and
* fail-safe, "auto" must never reach the transport layer, and Agent mode must
* only reach an execution-capable action when the request explicitly asks to
* run/query (an "explain"/"fix" request must never execute SQL).
*/
const config = { provider: "openai", apiKey: "test", authMethod: "api-key", endpoint: "https://example.invalid", model: "model", apiStyle: "completions" } as AiConfig;
function input(overrides: Partial<AiIntentRouteInput> & { text: string }): AiIntentRouteInput {
return { hasCurrentSql: false, hasLastError: false, mode: "ask", ...overrides };
}
function context(overrides: Partial<AiContext> = {}): AiContext {
return { connectionId: "conn-1", connectionName: "Postgres", databaseType: "postgres", database: "app", currentSql: "select 1", tables: [], sqlFiles: [], csvFiles: [], truncated: false, ...overrides };
}
/** Gate-open sample: an unambiguous data request (查一下 anchors the execution gate). */
const EXPLICIT_DATA_TEXT = "查一下这张表里的数据";
describe("routeIntentByRules — Ask mode golden cases", () => {
// Every non-general Ask action is reachable from an unambiguous zh/en sample.
it("routes SQL-production requests to generate", () => {
expect(routeIntentByRules(input({ text: "帮我生成一条按用户统计订单量的 SQL" }))).toBe("generate");
expect(routeIntentByRules(input({ text: "generate a SQL query for monthly revenue per region" }))).toBe("generate");
});
it("routes explanations of the current SQL to explain", () => {
expect(routeIntentByRules(input({ text: "解释一下这段 SQL 做了什么", hasCurrentSql: true }))).toBe("explain");
expect(routeIntentByRules(input({ text: "explain what this query does", hasCurrentSql: true }))).toBe("explain");
});
it("routes optimization requests to optimize", () => {
expect(routeIntentByRules(input({ text: "优化一下这条 SQL 的查询性能", hasCurrentSql: true }))).toBe("optimize");
expect(routeIntentByRules(input({ text: "optimize this query, it is too slow", hasCurrentSql: true }))).toBe("optimize");
});
it("routes error reports to fix", () => {
expect(routeIntentByRules(input({ text: "这段 SQL 报错了,帮我看看", hasLastError: true, hasCurrentSql: true }))).toBe("fix");
expect(routeIntentByRules(input({ text: "please fix this SQL error", hasLastError: true, hasCurrentSql: true }))).toBe("fix");
});
it("routes dialect rewrites to convert", () => {
expect(routeIntentByRules(input({ text: "把这段 SQL 转成 PostgreSQL 方言", hasCurrentSql: true }))).toBe("convert");
expect(routeIntentByRules(input({ text: "convert this query to SQL Server", hasCurrentSql: true }))).toBe("convert");
});
it("routes sample/mock data requests to sampleData", () => {
expect(routeIntentByRules(input({ text: "生成一批测试数据插入到订单表" }))).toBe("sampleData");
expect(routeIntentByRules(input({ text: "create sample data for the orders table" }))).toBe("sampleData");
});
it("maps run-and-explain onto explain in Ask mode (Ask never executes)", () => {
expect(routeIntentByRules(input({ text: "执行这段 SQL 并解释结果", hasCurrentSql: true }))).toBe("explain");
});
it("maps data-query wording onto generate in Ask mode (Ask produces SQL, never runs it)", () => {
expect(routeIntentByRules(input({ text: "查询订单表里最近 7 天的订单" }))).toBe("generate");
expect(routeIntentByRules(input({ text: "fetch the latest orders for each customer" }))).toBe("generate");
});
});
describe("routeIntentByRules — Agent mode golden cases", () => {
it("routes data queries to query", () => {
expect(routeIntentByRules(input({ text: "查询订单表里最近 7 天的订单", mode: "agent" }))).toBe("query");
expect(routeIntentByRules(input({ text: "fetch the latest orders for each customer", mode: "agent" }))).toBe("query");
});
it("routes schema questions to exploreSchema", () => {
expect(routeIntentByRules(input({ text: "这个库里有哪些表", mode: "agent" }))).toBe("exploreSchema");
expect(routeIntentByRules(input({ text: "list tables in this database", mode: "agent" }))).toBe("exploreSchema");
});
it("routes run-and-explain requests to executeAndExplain", () => {
expect(routeIntentByRules(input({ text: "执行这段 SQL 并解释结果", hasCurrentSql: true, mode: "agent" }))).toBe("executeAndExplain");
expect(routeIntentByRules(input({ text: "run this query and explain the results", hasCurrentSql: true, mode: "agent" }))).toBe("executeAndExplain");
});
it("routes SQL-only production to generate", () => {
expect(routeIntentByRules(input({ text: "写一条按用户统计订单量的 SQL", mode: "agent" }))).toBe("generate");
expect(routeIntentByRules(input({ text: "write a SQL query that counts orders per user", mode: "agent" }))).toBe("generate");
});
it("maps Ask-style (non-execution) intents onto non-executing Agent actions", () => {
// Safety (#9118 review): an explanation request must NOT reach an
// execution-capable Agent action — Auto would run SQL the user never asked
// to run. `general` explains without an execute contract; `generate`'s
// SQL-first output contract would be wrong for an explanation.
const explained = routeIntentByRules(input({ text: "解释一下这段 SQL 做了什么", hasCurrentSql: true, mode: "agent" }));
expect(explained).toBe("general");
expect(explained).not.toBe("executeAndExplain");
expect(explained).not.toBe("query");
// Error repair produces corrected SQL; it does not investigate by running it.
const fixed = routeIntentByRules(input({ text: "这段 SQL 报错了", hasLastError: true, hasCurrentSql: true, mode: "agent" }));
expect(fixed).toBe("generate");
expect(fixed).not.toBe("query");
expect(fixed).not.toBe("executeAndExplain");
expect(routeIntentByRules(input({ text: "优化一下这条 SQL", hasCurrentSql: true, mode: "agent" }))).toBe("generate");
expect(routeIntentByRules(input({ text: "把这段 SQL 转成 MySQL", hasCurrentSql: true, mode: "agent" }))).toBe("generate");
expect(routeIntentByRules(input({ text: "生成一批测试数据", mode: "agent" }))).toBe("generate");
});
it("keeps every non-execution intent on a non-executing Agent action (table-level)", () => {
// Guards the mapping TABLE itself, not just the samples above: reverting
// `explain → executeAndExplain` / `fix → query` fails here even if the
// sample-based expectations were edited to match the reverted behavior.
const executionActions: AiAction[] = ["query", "executeAndExplain"];
const nonExecutionIntents: AiIntent[] = ["generate", "explain", "optimize", "fix", "convert", "sampleData", "exploreSchema"];
for (const intent of nonExecutionIntents) {
const action = actionForIntent(intent, "agent");
expect(executionActions, `${intent} → ${action}`).not.toContain(action);
expect(isValidActionForMode(action, "agent"), `${intent} → ${action}`).toBe(true);
}
// The only execution-capable intents are the ones the guard also gates on.
expect(actionForIntent("query", "agent")).toBe("query");
expect(actionForIntent("executeAndExplain", "agent")).toBe("executeAndExplain");
});
it("keeps an explanation of the current query outside the execution gate", () => {
// `查询` is also a noun in a static SQL explanation. It must not admit
// execution-capable actions unless the request names a data target.
expect(requestsExplicitExecution("解释一下这个查询")).toBe(false);
expect(routeIntentByRules(input({ text: "解释一下这个查询", mode: "agent" }))).toBe("general");
});
it("still routes explicitly execution-asking requests to the execution actions", () => {
// Guard against over-tightening: the invariant gates on an EXPLICIT
// run/query request, and those keep their tool-backed actions.
for (const text of ["查询订单表里最近 7 天的订单", "fetch the latest orders", "查一下今天的新增用户"]) {
expect(routeIntentByRules(input({ text, mode: "agent" })), text).toBe("query");
}
for (const text of ["执行这段 SQL 并解释结果", "run this query and explain the results"]) {
expect(routeIntentByRules(input({ text, hasCurrentSql: true, mode: "agent" })), text).toBe("executeAndExplain");
}
});
it("never returns an execution-capable Agent action for a non-run request", () => {
const nonRunSamples = [
"解释一下这段 SQL 做了什么",
"这段 SQL 报错了",
"优化一下这条 SQL",
"把这段 SQL 转成 MySQL",
"生成一批测试数据",
"写一条统计订单的 SQL",
"这个库里有哪些表",
"你好",
"Show me how to create a table",
"Get help writing a stored procedure",
"查询语句的执行顺序是什么?",
"查询语句如何使用索引?",
];
for (const text of nonRunSamples) {
const routed = routeIntentByRules(input({ text, hasCurrentSql: true, hasLastError: true, mode: "agent" }));
expect(["query", "executeAndExplain"], `${text} → ${routed}`).not.toContain(routed);
}
});
it("keeps every Ask-mode mapping non-executing", () => {
expect(routeIntentByRules(input({ text: "查询订单数据" }))).toBe("generate");
expect(routeIntentByRules(input({ text: "这个库里有哪些表" }))).toBe("generate");
expect(routeIntentByRules(input({ text: "执行这段 SQL 并解释结果", hasCurrentSql: true }))).toBe("explain");
});
it("only ever returns actions valid for the current mode", () => {
const samples = ["帮我生成一条 SQL", "解释一下这段 SQL", "优化这条 SQL 性能", "这段 SQL 报错了", "转成 MySQL 方言", "生成测试数据", "查询订单数据", "这个库里有哪些表", "执行这段 SQL 并解释结果", "你好"];
for (const mode of ["ask", "agent"] as const) {
for (const text of samples) {
const routed = routeIntentByRules(input({ text, hasCurrentSql: true, hasLastError: true, mode }));
if (routed) expect(isValidActionForMode(routed, mode), `${mode}/${text} → ${routed}`).toBe(true);
}
}
});
});
describe("requestsExplicitExecution — narrow execution gate", () => {
it("does not open for explanatory/code-generation uses of broad verbs (P1 regression)", () => {
// The gate is deliberately narrower than the rule layer's permissive
// routing vocabulary: show/get/find/list (看看/获取/找一下/列出) also
// introduce explanations and code-generation requests, which must never
// make execution-capable actions available to the classifier.
for (const text of [
"Show me how to create a table",
"Get help writing a stored procedure",
"list the pros and cons of composite indexes",
"find the best join order for this query",
"看看别人是怎么写分页的",
"获取一下连接超时的处理建议",
"找一下有没有现成的分页写法",
"查询语句的执行顺序是什么?",
"查询语句如何使用索引?",
]) {
expect(requestsExplicitExecution(text), text).toBe(false);
}
});
it("opens for unambiguous run/query-data phrasing in both languages", () => {
for (const text of ["run this", "execute the migration", "fetch the latest orders", "query the order records", "查询订单表里最近 7 天的订单", "查一下今天的新增用户", "统计一下各状态的订单数"]) {
expect(requestsExplicitExecution(text), text).toBe(true);
}
});
it("routes table-listing wording onto the non-executing schema action", () => {
// Canonical broad-verb data requests are answered by exploreSchema
// (list_tables) rather than the broad query bucket, so narrowing the gate
// does not degrade them into classifier guesses.
expect(routeIntentByRules(input({ text: "show me the tables", mode: "agent" }))).toBe("exploreSchema");
expect(routeIntentByRules(input({ text: "列出所有表", mode: "agent" }))).toBe("exploreSchema");
expect(routeIntentByRules(input({ text: "列出所有表" }))).toBe("generate");
});
});
describe("routeIntentByRules — context signals and narrowness", () => {
it("does not route a plain chat message that merely contains '优化'", () => {
expect(routeIntentByRules(input({ text: "帮我优化一下下周的会议安排" }))).toBeNull();
expect(routeIntentByRules(input({ text: "how can I optimize my team workflow?" }))).toBeNull();
});
it("requires SQL context for optimize/explain, but an explicit SQL object counts", () => {
expect(routeIntentByRules(input({ text: "优化一下这条 SQL" }))).toBe("optimize");
expect(routeIntentByRules(input({ text: "解释一下这个查询" }))).toBe("explain");
expect(routeIntentByRules(input({ text: "优化一下" }))).toBeNull();
expect(routeIntentByRules(input({ text: "explain this" }))).toBeNull();
});
it("treats a failed run plus error wording as a fix signal", () => {
expect(routeIntentByRules(input({ text: "为什么这次执行出错了", hasLastError: true }))).toBe("fix");
expect(routeIntentByRules(input({ text: "为什么慢", hasLastError: true }))).toBeNull();
});
it("does not let an unrelated failed run hijack an explicit dialect request", () => {
expect(routeIntentByRules(input({ text: "把这段 SQL 转成 MySQL", hasCurrentSql: true, hasLastError: true }))).toBe("convert");
});
it("falls through for greetings, thanks and empty text", () => {
expect(routeIntentByRules(input({ text: "你好" }))).toBeNull();
expect(routeIntentByRules(input({ text: "hello there, nice to meet you" }))).toBeNull();
expect(routeIntentByRules(input({ text: "谢谢" }))).toBeNull();
expect(routeIntentByRules(input({ text: " " }))).toBeNull();
});
it("treats a short confirmation/refusal reply as a continuation, not a new intent", () => {
// "可以" answers the previous proposal (aiProposalDetect): the mode default
// is used without a classifier call, so a write confirmation never stalls.
expect(routeIntentByRules(input({ text: "可以", hasCurrentSql: true, mode: "agent" }))).toBe("general");
expect(routeIntentByRules(input({ text: "go ahead", mode: "agent" }))).toBe("general");
expect(routeIntentByRules(input({ text: "不用了" }))).toBe("general");
// A real request that merely starts with a confirmation word is still routed.
expect(routeIntentByRules(input({ text: "可以帮我生成一条统计订单的 SQL" }))).toBe("generate");
});
});
describe("classifyIntentByLlm (slow path)", () => {
it("accepts a strict JSON answer from the candidate set", async () => {
// EXPLICIT_DATA_TEXT is an explicit data request, so the execution actions
// stay in the candidate set here (see the execution-invariant block below).
const complete = vi.fn().mockResolvedValue('{"action":"query"}');
await expect(classifyIntentByLlm(input({ text: EXPLICIT_DATA_TEXT, mode: "agent" }), { config, complete })).resolves.toBe("query");
});
it("only offers the current mode's actions to the model", async () => {
const askComplete = vi.fn().mockResolvedValue('{"action":"general"}');
await classifyIntentByLlm(input({ text: EXPLICIT_DATA_TEXT, mode: "ask" }), { config, complete: askComplete });
const askPrompt = askComplete.mock.calls[0][0].systemPrompt as string;
expect(askPrompt).toContain(`Allowed actions: ${ASK_ACTIONS.join(", ")}.`);
expect(askPrompt).not.toContain("executeAndExplain");
const agentComplete = vi.fn().mockResolvedValue('{"action":"general"}');
await classifyIntentByLlm(input({ text: EXPLICIT_DATA_TEXT, mode: "agent" }), { config, complete: agentComplete });
const agentPrompt = agentComplete.mock.calls[0][0].systemPrompt as string;
expect(agentPrompt).toContain(`Allowed actions: ${AGENT_ACTIONS.join(", ")}.`);
expect(agentPrompt).not.toContain("sampleData");
});
it("sends no history, no task contract and only the trimmed user text", async () => {
const complete = vi.fn().mockResolvedValue('{"action":"general"}');
const longText = "a".repeat(INTENT_CLASSIFY_MAX_TEXT_CHARS + 500);
await classifyIntentByLlm(input({ text: ` ${longText} `, mode: "ask" }), { config, complete });
const request = complete.mock.calls[0][0];
expect(request.taskContract).toBeUndefined();
expect(request.messages).toHaveLength(1);
expect(request.messages[0]).toEqual({ role: "user", content: longText.slice(0, INTENT_CLASSIFY_MAX_TEXT_CHARS) });
expect(request.maxTokens).toBeLessThanOrEqual(64);
expect(request.systemPrompt).not.toContain(longText);
});
it("falls back to general on prose, malformed JSON, unknown and out-of-candidate actions", async () => {
for (const answer of ["I think you want to query data", "", "not json", '{"action":"nonsense"}', '{"action":42}']) {
const complete = vi.fn().mockResolvedValue(answer);
await expect(classifyIntentByLlm(input({ text: EXPLICIT_DATA_TEXT, mode: "agent" }), { config, complete })).resolves.toBe("general");
}
// `query` is a valid Agent action but not an Ask one.
const complete = vi.fn().mockResolvedValue('{"action":"query"}');
await expect(classifyIntentByLlm(input({ text: EXPLICIT_DATA_TEXT, mode: "ask" }), { config, complete })).resolves.toBe("general");
});
it("falls back to general when the classifier rejects or times out", async () => {
const failing = vi.fn().mockRejectedValue(new Error("provider down"));
await expect(classifyIntentByLlm(input({ text: EXPLICIT_DATA_TEXT }), { config, complete: failing })).resolves.toBe("general");
const hanging = vi.fn().mockImplementation(() => new Promise<string>(() => {}));
await expect(classifyIntentByLlm(input({ text: EXPLICIT_DATA_TEXT }), { config, complete: hanging, timeoutMs: 5 })).resolves.toBe("general");
});
it("skips the call entirely when there is no text to classify", async () => {
const complete = vi.fn().mockResolvedValue('{"action":"query"}');
await expect(classifyIntentByLlm(input({ text: " ", mode: "agent" }), { config, complete })).resolves.toBe("general");
expect(complete).not.toHaveBeenCalled();
});
it("uses the plain completion path (never the agent loop) by default", async () => {
const complete = vi.spyOn(api, "aiComplete").mockResolvedValue('{"action":"optimize"}');
const agentStream = vi.spyOn(api, "aiAgentStream").mockResolvedValue("done");
try {
await expect(classifyIntentByLlm(input({ text: "优化一下这段 SQL", hasCurrentSql: true }), { config })).resolves.toBe("optimize");
expect(complete).toHaveBeenCalledTimes(1);
expect(agentStream).not.toHaveBeenCalled();
const request = complete.mock.calls[0][0];
expect(request.taskContract).toBeUndefined();
expect(request.config).toBe(config);
} finally {
complete.mockRestore();
agentStream.mockRestore();
}
});
});
describe("classifyIntentByLlm — Agent execution invariant", () => {
const NON_RUN_TEXT = "帮我把这段 SQL 描述得更清楚一点";
const RUN_TEXT = "查一下今天的新增用户";
it("keeps execution actions out of the candidate prompt for a non-run request", async () => {
expect(requestsExplicitExecution(NON_RUN_TEXT)).toBe(false);
const complete = vi.fn().mockResolvedValue('{"action":"general"}');
await classifyIntentByLlm(input({ text: NON_RUN_TEXT, hasCurrentSql: true, mode: "agent" }), { config, complete });
const prompt = complete.mock.calls[0][0].systemPrompt as string;
expect(prompt).toContain("Allowed actions: general, exploreSchema, generate.");
expect(prompt).not.toContain("executeAndExplain");
expect(prompt).not.toMatch(/Allowed actions:[^.]*\bquery\b/);
});
it("rejects a hallucinated execution pick for a non-run request", async () => {
for (const answer of ['{"action":"query"}', '{"action":"executeAndExplain"}']) {
const complete = vi.fn().mockResolvedValue(answer);
await expect(classifyIntentByLlm(input({ text: NON_RUN_TEXT, hasCurrentSql: true, mode: "agent" }), { config, complete }), answer).resolves.toBe("general");
}
// The same answers are honored when the user did ask to run.
const runComplete = vi.fn().mockResolvedValue('{"action":"query"}');
await expect(classifyIntentByLlm(input({ text: RUN_TEXT, mode: "agent" }), { config, complete: runComplete })).resolves.toBe("query");
const runPrompt = runComplete.mock.calls[0][0].systemPrompt as string;
expect(runPrompt).toContain(`Allowed actions: ${AGENT_ACTIONS.join(", ")}.`);
});
it("keeps execution actions unreachable for explanatory show/get requests (P1 regression)", async () => {
// Review finding: "Show me how to create a table" matched the broad
// data-request vocabulary, which used to admit `query` into the classifier
// candidates. The narrow gate must keep it closed end to end — rule layer
// AND classifier — even against a hostile `{"action":"query"}` answer.
for (const text of ["Show me how to create a table", "Get help writing a stored procedure", "查询语句的执行顺序是什么?", "查询语句如何使用索引?"]) {
expect(requestsExplicitExecution(text), text).toBe(false);
const rules = routeIntentByRules(input({ text, mode: "agent" }));
expect(rules === null || !["query", "executeAndExplain"].includes(rules as AiAction), `${text} → ${rules}`).toBe(true);
const complete = vi.fn().mockResolvedValue('{"action":"query"}');
const resolved = await routeIntent(input({ text, mode: "agent" }), { config, complete });
expect(resolved, text).toBe("general");
const prompt = complete.mock.calls[0][0].systemPrompt as string;
expect(prompt).toContain("Allowed actions: general, exploreSchema, generate.");
}
});
it("filters the candidate list programmatically, for both modes", () => {
const agentDefault = intentCandidatesForMode("agent", true);
expect(agentDefault).toEqual(AGENT_ACTIONS);
const agentReduced = intentCandidatesForMode("agent", false);
expect(agentReduced).toEqual(["general", "exploreSchema", "generate"]);
expect(agentReduced).not.toContain("query");
expect(agentReduced).not.toContain("executeAndExplain");
// Ask mode has no execution-capable action, so nothing changes there.
expect(intentCandidatesForMode("ask", false)).toEqual(ASK_ACTIONS);
expect(intentCandidatesForMode("ask", true)).toEqual(ASK_ACTIONS);
});
it("never resolves an execution action through routeIntent for a non-run request", async () => {
// The rule layer misses this text, so the (restricted) classifier answers.
const complete = vi.fn().mockResolvedValue('{"action":"executeAndExplain"}');
const resolved = await routeIntent(input({ text: NON_RUN_TEXT, hasCurrentSql: true, mode: "agent" }), { config, complete });
expect(resolved).toBe("general");
expect(complete).toHaveBeenCalledTimes(1);
});
});
describe("parseClassifierAction", () => {
it("extracts the action from a JSON object even when wrapped in prose or a fence", () => {
expect(parseClassifierAction('```json\n{"action":"fix"}\n```', "ask")).toBe("fix");
expect(parseClassifierAction('Sure: {"action":"optimize"}', "ask")).toBe("optimize");
expect(parseClassifierAction('{"action":" fix "}', "ask")).toBe("fix");
});
it("rejects answers outside the mode's candidate set", () => {
expect(parseClassifierAction('{"action":"auto"}', "ask")).toBeNull();
expect(parseClassifierAction('{"action":"exploreSchema"}', "ask")).toBeNull();
expect(parseClassifierAction('{"action":"exploreSchema"}', "agent")).toBe("exploreSchema");
});
});
describe("routeIntent (two-stage entry point)", () => {
it("never calls the model when the rule layer already matched", async () => {
const complete = vi.fn().mockResolvedValue('{"action":"general"}');
await expect(routeIntent(input({ text: "把这段 SQL 转成 PostgreSQL", hasCurrentSql: true }), { config, complete })).resolves.toBe("convert");
expect(complete).not.toHaveBeenCalled();
});
it("consults the classifier only after a rule miss", async () => {
const complete = vi.fn().mockResolvedValue('{"action":"exploreSchema"}');
// "帮我总结一下这份报告" matches no rule pattern, so the slow path runs.
await expect(routeIntent(input({ text: "帮我总结一下这份报告", mode: "agent" }), { config, complete })).resolves.toBe("exploreSchema");
expect(complete).toHaveBeenCalledTimes(1);
});
});
describe("'auto' never reaches the transport layer", () => {
it("keeps the concrete action tables free of 'auto'", () => {
expect(ASK_ACTIONS).not.toContain("auto");
expect(AGENT_ACTIONS).not.toContain("auto");
expect(isValidActionForMode("auto" as unknown as AiAction, "ask")).toBe(false);
expect(isValidActionForMode("auto" as unknown as AiAction, "agent")).toBe(false);
});
it("resolves every routed action into a concrete taskContract.action", async () => {
const samples = ["帮我生成一条 SQL", "解释一下这段 SQL", "优化这条 SQL 性能", "这段 SQL 报错了", "转成 MySQL 方言", "生成测试数据", "查询订单数据", "这个库里有哪些表", "执行这段 SQL 并解释结果"];
for (const mode of ["ask", "agent"] as const) {
for (const text of samples) {
const routeInput = input({ text, hasCurrentSql: true, hasLastError: true, mode });
const resolved = (await routeIntent(routeInput, { config, complete: vi.fn().mockResolvedValue('{"action":"general"}') })) as AiAction;
expect(isValidActionForMode(resolved, mode)).toBe(true);
const request = buildAgentRequest({ config, action: resolved, mode, instruction: text, context: context() }, undefined, undefined);
expect(request.taskContract.action).toBe(resolved);
expect(request.taskContract.action).not.toBe("auto");
}
}
});
it("never puts an Agent execution action on the wire for an explain/fix request", async () => {
// End-to-end shape of the safety finding: the resolved action is what lands
// in `taskContract.action`, so it must be non-executing for these inputs.
// Both are resolved by the rule layer (the injected classifier answer is a
// deliberately hostile "execute!" pick), and the classifier-only path is
// covered by the execution-invariant block above.
const cases = [
{ text: "解释一下这段 SQL 做了什么", answer: '{"action":"executeAndExplain"}' },
{ text: "这段 SQL 报错了", answer: '{"action":"query"}' },
];
for (const { text, answer } of cases) {
const resolved = await routeIntent(input({ text, hasCurrentSql: true, hasLastError: true, mode: "agent" }), { config, complete: vi.fn().mockResolvedValue(answer) });
expect(["query", "executeAndExplain"], `${text} → ${resolved}`).not.toContain(resolved);
const request = buildAgentRequest({ config, action: resolved, mode: "agent", instruction: text, context: context() }, undefined, undefined);
expect(request.taskContract.action).toBe(resolved);
}
});
});
+16
View File
@@ -56,6 +56,22 @@ export function isValidActionForMode(action: AiAction, mode: AiAssistantMode): b
return (mode === "agent" ? AGENT_ACTIONS : ASK_ACTIONS).includes(action);
}
/**
* UI-level picker selection: every concrete transport action plus the `auto`
* entry that lets the assistant pick one of them at send time (#9118).
*
* `auto` deliberately stays OUT of `AiAction`, `ASK_ACTIONS`/`AGENT_ACTIONS` and
* `isValidActionForMode`: it is resolved by `aiIntentRouter` before
* `buildAgentRequest`/`runAgentStream` run, so an "auto" string can never reach
* `AiTaskContract.action` — the backend interpolates that value into its system
* prompt and uses it for `validate_final_answer`/contract repair.
*/
export type AiActionSelection = AiAction | "auto";
export function isAutoActionSelection(selection: AiActionSelection): selection is "auto" {
return selection === "auto";
}
function isChineseLocale(locale: Locale): boolean {
return locale === "zh-CN" || locale === "zh-TW";
}
+398
View File
@@ -0,0 +1,398 @@
import * as api from "@/lib/backend/api";
import type { AiCompletionRequest } from "@/lib/backend/tauri";
import { ASK_ACTIONS, AGENT_ACTIONS, defaultActionForMode, isValidActionForMode, type AiAction } from "@/lib/ai/ai";
import { isShortAffirmative, isShortNegative } from "@/lib/ai/aiProposalDetect";
import type { AiConfig } from "@/stores/settingsStore";
import type { AiAssistantMode } from "@/types/ai";
/**
* Intent router behind the assistant's "Auto" picker entry (#9118).
*
* Two stages, cheapest first:
*
* 1. {@link routeIntentByRules} — pure zh/en heuristics, zero latency, zero
* network. Deliberately narrow in the style of `aiProposalDetect.ts`: an
* intent verb only routes when it co-occurs with an actionable object or a
* context signal (`hasCurrentSql` / `hasLastError`), so a chat message that
* merely contains "优化" does not hijack the request.
* 2. {@link classifyIntentByLlm} — one lightweight plain-completion call
* (`ai_complete`, never the agent loop) that must answer with strict JSON.
* Its candidate set is the current mode's action list, minus the Agent-mode
* execution-capable actions unless the request explicitly asks to run, and
* any failure (timeout, transport error, unparsable or out-of-candidate
* answer) falls back to the mode's default `general`.
*
* The router only ever returns a concrete `AiAction`; "auto" never leaves the
* UI, and the resolved action is always valid for the mode it was routed in.
*
* **Safety invariant**: in Agent mode an execution-capable action (`query`,
* `executeAndExplain`) is only selectable when the request explicitly asks to
* run/query/execute/fetch data. Ask-style intents (explain/optimize/convert/…)
* map to non-executing actions only — Auto must never turn "explain this SQL"
* into real database execution.
*/
/** Intents the rule layer can recognize before mapping them onto concrete actions. */
export type AiIntent = "generate" | "explain" | "optimize" | "fix" | "convert" | "sampleData" | "query" | "exploreSchema" | "executeAndExplain";
export interface AiIntentRouteInput {
/** Raw text the user typed for this turn. */
text: string;
/** The editor has SQL/command text to work on. */
hasCurrentSql: boolean;
/** The last execution failed — an error report is a hard routing signal. */
hasLastError: boolean;
mode: AiAssistantMode;
}
/** Hard cap on the classifier timeout; a slow model must never delay the real request. */
export const INTENT_CLASSIFY_TIMEOUT_MS = 3000;
/** The classifier only needs the user's phrasing — never schema, results or error payloads. */
export const INTENT_CLASSIFY_MAX_TEXT_CHARS = 500;
const INTENT_CLASSIFY_MAX_TOKENS = 32;
// ---------------------------------------------------------------------------
// Rule layer
// ---------------------------------------------------------------------------
// Phrase sets are picked by script (the aiProposalDetect.ts convention) so a
// zh sentence and an en sentence never have to share one regex.
const ZH_GENERATE_VERBS = [/生成/, /写一?[条个段]/, /创建/, /构造/, /拼一?条/, /来一?条/, /产出/];
const EN_GENERATE_VERBS = [/\bgenerate\b/i, /\bwrite\b/i, /\bcreate\b/i, /\bproduce\b/i, /\bbuild\b/i, /\bdraft\b/i];
const ZH_SQL_OBJECTS = [/sql/i, /语句/, /查询/, /脚本/, /代码/, /命令/];
const EN_SQL_OBJECTS = [/\bsql\b/i, /\bquery\b/i, /\bstatement\b/i, /\bscript\b/i, /\bcode\b/i, /\bcommand\b/i];
const ZH_EXPLAIN_VERBS = [/解释/, /说明/, /讲解/, /说清楚/, /什么意思/, /含义/, /做什么用/];
const EN_EXPLAIN_VERBS = [/\bexplain\b/i, /\bwhat does\b/i, /\bwhat is this\b/i, /\bwalk me through\b/i, /\bdescribe\b/i, /\bmeaning\b/i];
const ZH_OPTIMIZE_VERBS = [/优化/, /调优/, /提升性能/, /性能/, /加速/, /更高效/, /慢/];
const EN_OPTIMIZE_VERBS = [/\boptimize\b/i, /\boptimise\b/i, /\btune\b/i, /\bspeed up\b/i, /\bslower\b/i, /\bfaster\b/i, /\bperformance\b/i, /\bslow\b/i];
// An error report, not a bare "why" question.
const ERROR_REPORT_RE = /报错|出错|错误|异常|执行失败|运行失败|失败|无法执行|fix|error|failed|failure|exception|stack ?trace/i;
const FIX_REQUEST_RE = /修复|修一下|修一修|改正|纠正|排查|调试|解决|fix|debug|troubleshoot|correct/i;
const ZH_CONVERT_VERBS = [/转成/, /转换/, /转为/, /改成/, /改为/, /换成/, /译成/, /翻译成/, /改写成/];
const EN_CONVERT_VERBS = [/\bconvert\b/i, /\btranslate\b/i, /\brewrite\b/i, /\bport\b/i, /\bmigrate\b/i];
// A named target dialect is what makes a convert request actionable.
const DIALECT_NAME_RE = /\b(?:mysql|mariadb|postgres(?:ql)?|oracle|sql\s*server|sqlserver|mssql|sqlite|clickhouse|duckdb|hive|spark|trino|presto|redshift|snowflake|bigquery|tidb|oceanbase|db2|firebird|pg)\b|达梦|人大金仓|金仓|高斯|opengauss|kingbase/i;
const SAMPLE_DATA_RE = /测试数据|样例数据|示例数据|模拟数据|假数据|造数据|填充数据|演示数据|测试用数据|(?:mock|sample|dummy|fake|test|seed|demo)\s+(?:数据|data|rows|records|values)/i;
const ZH_EXEC_VERBS = [/执行/, /运行/, /跑一下/, /跑跑/, /试跑/];
const EN_EXEC_VERBS = [/\brun\b/i, /\bexecute\b/i];
const ZH_RESULT_EXPLAIN_VERBS = [/解释/, /说明/, /讲解/, /分析结果/, /看看结果/];
const EN_RESULT_EXPLAIN_VERBS = [/\bexplain\b/i, /\bwalk me through\b/i, /\binterpret\b/i];
// Listing/showing tables is schema inspection, not a data query — anchoring it
// here keeps the broad query bucket from turning it into a SELECT (Agent+Auto).
const ZH_SCHEMA_PHRASES = [/有哪些?(?:表|字段|列|索引|视图|数据库)/, /有(?:哪|什么)些表/, /表结构/, /表清单/, /字段信息/, /列信息/, /元数据/, /数据库对象/, /列出(?:所有|全部|一下)?的?(?:(?:数据)?表|视图|索引|数据库)/, /显示(?:所有|全部|一下)?的?表/];
const EN_SCHEMA_PHRASES = [/\b(?:what|which|list|show|name)\s+(?:me\s+|us\s+)?(?:all\s+|the\s+|these\s+)?(?:tables|views|indexes|columns|databases)\b/i, /\btable (?:structure|schema|list)\b/i, /\bschema\b/i, /\bmetadata\b/i, /\bcolumns? (?:of|in|for)\b/i];
const ZH_QUERY_VERBS = [/查询/, /查一下/, /查查/, /查看/, /看看/, /看下/, /看一下/, /统计/, /获取/, /拉取/, /取出?/, /列出/, /找一下/, /有多少/, /多少条/, /数量/, /汇总/, /合计/, /排行/];
const EN_QUERY_VERBS = [/\bquery\b/i, /\bselect\b/i, /\bfetch\b/i, /\bretrieve\b/i, /\blist\b/i, /\bshow\b/i, /\bcount\b/i, /\bhow many\b/i, /\btotal\b/i, /\bsum\b/i, /\bget\b/i, /\bfind\b/i, /\btop\b/i];
function anyMatch(patterns: RegExp[], text: string): boolean {
return patterns.some((pattern) => pattern.test(text));
}
function phrasesFor(isZh: boolean, zh: RegExp[], en: RegExp[]): RegExp[] {
return isZh ? zh : en;
}
/**
* Agent-mode actions that actually run statements against the database.
*
* **Safety invariant** (see `AGENT_INTENT_ACTIONS` and `intentCandidatesForMode`):
* under Auto these may only be selected when the request EXPLICITLY asks to
* run/query/execute data. An "explain this SQL" or "fix this error" request must
* never trigger real execution — that would be an unrequested, potentially
* expensive or sensitive query.
*/
const AGENT_EXECUTION_ACTIONS: ReadonlySet<AiAction> = new Set<AiAction>(["query", "executeAndExplain"]);
/**
* Narrow, unambiguous "run it against the database" phrases — the ONLY signals
* that may open the Agent execution gate (`requestsExplicitExecution`).
*
* Deliberately NOT derived from the rule layer's permissive data-request
* vocabulary (`ZH/EN_QUERY_VERBS`): broad verbs such as show / get / find /
* list (看看 / 获取 / 找一下 / 列出) also introduce explanation and
* code-generation requests ("show me how to create a table", "get help
* writing a stored procedure") that must never admit execution-capable
* actions. The broad bucket may still route such a text onto `query`, but
* `guardExecutionAction` re-checks every rule output against THIS gate, so in
* Agent mode the permissive vocabulary can only ever end in a non-executing
* action; the English data-object anchor additionally keeps bare "get/fetch"
* from opening the gate without a target.
*/
// Unlike English, Chinese has no word boundary between `查询` and the subject.
// Require a nearby data target so static questions such as “查询语句的执行顺序”
// do not authorize database I/O merely because they contain that word.
const ZH_EXPLICIT_DATA_QUERY_RE = /(?:查询|查一下|查查|统计|汇总|合计|排行|有多少|多少条)[^。!?;]{0,24}(?:数据|记录|订单|用户|客户|日志|指标|行数|条数|数量|总数)/;
// `执行` and `运行` are also used in static questions such as “执行顺序” and
// “运行机制”. Only their imperative forms, or an explicit current-SQL target,
// authorize the execution actions.
const ZH_EXPLICIT_RUN_RE = /(?:执行一下|运行一下|跑一跑|跑跑|试跑|(?:执行|运行)(?:这(?:条|段)|当前|该|下列|下面|此)(?:\s*(?:SQL|语句|查询|脚本))?|(?:执行|运行)\s*(?:SQL|查询|脚本))/;
const NARROW_EXECUTION_PHRASES_ZH = [ZH_EXPLICIT_RUN_RE, ZH_EXPLICIT_DATA_QUERY_RE];
const NARROW_EXECUTION_PHRASES_EN = [...EN_EXEC_VERBS, /\b(?:query|fetch|pull|retrieve|get|select)\b[^.!?;]{0,40}?\b(?:data|database|db|rows?|records?|results?|entries|tables?|views?|orders?|users?|customers?|logs?|events?|metrics?)\b/i];
/**
* True when the text explicitly asks to run/query/execute/fetch data, phrased
* narrowly and unambiguously. This gate decides BOTH halves of the Agent
* execution invariant: `guardExecutionAction` filters rule-layer outputs
* against it, and `intentCandidatesForMode` drops the execution actions from
* the classifier candidates when it is false.
*/
export function requestsExplicitExecution(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return false;
const isZh = /[一-鿿]/.test(trimmed);
return anyMatch(phrasesFor(isZh, NARROW_EXECUTION_PHRASES_ZH, NARROW_EXECUTION_PHRASES_EN), trimmed);
}
/**
* Agent mode has no Ask-only action, so a recognized Ask-style intent maps onto
* the closest Agent-mode equivalent. Ask-style intents map onto **non-executing**
* actions only: an explanation request uses `general` (whose contract answers
* without instructing execution — `generate`'s SQL-first output contract would be
* wrong for it) and error repair uses `generate` (corrected SQL, not executed).
* Intentional execution (`query`, `executeAndExplain`) is reachable exclusively
* through the data-request buckets, and `guardExecutionAction` re-checks each
* hit against the narrow execution gate before it can leave the router.
*/
const AGENT_INTENT_ACTIONS: Record<AiIntent, AiAction> = {
generate: "generate",
query: "query",
exploreSchema: "exploreSchema",
executeAndExplain: "executeAndExplain",
explain: "general",
fix: "generate",
optimize: "generate",
convert: "generate",
sampleData: "generate",
};
/**
* Ask mode produces SQL only (never runs it), so data/schema intents map onto
* `generate` and "run & explain" onto `explain`.
*/
const ASK_INTENT_ACTIONS: Record<AiIntent, AiAction> = {
generate: "generate",
explain: "explain",
optimize: "optimize",
fix: "fix",
convert: "convert",
sampleData: "sampleData",
query: "generate",
exploreSchema: "generate",
executeAndExplain: "explain",
};
/** Concrete action for a detected intent in the given mode (always mode-valid). */
export function actionForIntent(intent: AiIntent, mode: AiAssistantMode): AiAction {
return mode === "agent" ? AGENT_INTENT_ACTIONS[intent] : ASK_INTENT_ACTIONS[intent];
}
/**
* Pure, zero-latency rule layer. Returns the concrete action for the current
* mode, or `null` when nothing matched and the caller should fall through to the
* classifier.
*
* Context signals outrank text keywords: a failed last run routes error reports
* to `fix`, a named target dialect routes to `convert`, and sample/mock-data
* wording routes to `sampleData` before any generic verb is considered. The
* SQL-context-gated intents (`optimize`, `explain`) additionally require the
* editor to hold SQL or the text to reference it, which is what keeps plain
* conversation from being routed.
*
* Agent mode additionally enforces the execution invariant: an execution-capable
* action is only ever returned by the explicit data-request buckets (the checks
* below would already guarantee that; the wrapper's final guard makes it
* structural so a future verb-set edit cannot silently reintroduce
* "explain → execute").
*/
export function routeIntentByRules(input: AiIntentRouteInput): AiAction | null {
return guardExecutionAction(routeByRulesUnchecked(input), input);
}
/** Rule decision table, before the Agent execution guard. */
function routeByRulesUnchecked(input: AiIntentRouteInput): AiAction | null {
const text = input.text.trim();
if (!text) return null;
// A short "可以" / "go ahead" (or its negative, see `aiProposalDetect`) answers
// the previous assistant proposal: it is a continuation, not a new request.
// Route it to the mode default without spending a classifier call on it — the
// conversation history, not this turn's wording, carries the earlier action.
if (isShortAffirmative(text) || isShortNegative(text)) return defaultActionForMode(input.mode);
const isZh = /[一-鿿]/.test(text);
const sqlObject = anyMatch(phrasesFor(isZh, ZH_SQL_OBJECTS, EN_SQL_OBJECTS), text);
const sqlContext = input.hasCurrentSql || sqlObject;
// --- context signals (highest priority) ---
if (input.hasLastError && ERROR_REPORT_RE.test(text)) return actionForIntent("fix", input.mode);
if (anyMatch(phrasesFor(isZh, ZH_CONVERT_VERBS, EN_CONVERT_VERBS), text) && DIALECT_NAME_RE.test(text)) return actionForIntent("convert", input.mode);
if (SAMPLE_DATA_RE.test(text)) return actionForIntent("sampleData", input.mode);
// --- run & explain (before plain explain/query, which its wording also matches) ---
if (anyMatch(phrasesFor(isZh, ZH_EXEC_VERBS, EN_EXEC_VERBS), text) && anyMatch(phrasesFor(isZh, ZH_RESULT_EXPLAIN_VERBS, EN_RESULT_EXPLAIN_VERBS), text)) {
return actionForIntent("executeAndExplain", input.mode);
}
// --- schema inspection (before the generic query bucket) ---
if (anyMatch(phrasesFor(isZh, ZH_SCHEMA_PHRASES, EN_SCHEMA_PHRASES), text)) return actionForIntent("exploreSchema", input.mode);
// --- SQL-context-gated text operations ---
if (sqlContext && anyMatch(phrasesFor(isZh, ZH_OPTIMIZE_VERBS, EN_OPTIMIZE_VERBS), text)) return actionForIntent("optimize", input.mode);
if (sqlContext && anyMatch(phrasesFor(isZh, ZH_EXPLAIN_VERBS, EN_EXPLAIN_VERBS), text)) return actionForIntent("explain", input.mode);
// --- explicit fix request without an error report yet ---
if (anyMatch([FIX_REQUEST_RE], text) && (input.hasLastError || sqlContext)) return actionForIntent("fix", input.mode);
// --- text production, then the permissive data-request bucket ---
if (anyMatch(phrasesFor(isZh, ZH_GENERATE_VERBS, EN_GENERATE_VERBS), text) && sqlObject) return actionForIntent("generate", input.mode);
if (anyMatch(phrasesFor(isZh, ZH_QUERY_VERBS, EN_QUERY_VERBS), text)) return actionForIntent("query", input.mode);
return null;
}
/**
* Enforcement of the Agent execution invariant on every rule-layer output: an
* execution-capable action may only leave the router when the text matches the
* narrow explicit-execution gate. The broad data-request bucket matches
* permissive verbs (show/get/find/list, 看看/获取/找一下) whose explanatory or
* code-generation uses ("show me how to create a table") must never execute —
* the guard converts those hits to `null` so the request falls through to the
* classifier with the execution actions already excluded from its candidates.
*/
function guardExecutionAction(action: AiAction | null, input: AiIntentRouteInput): AiAction | null {
if (!action) return null;
if (input.mode !== "agent" || !AGENT_EXECUTION_ACTIONS.has(action)) return action;
return requestsExplicitExecution(input.text) ? action : null;
}
// ---------------------------------------------------------------------------
// Classifier layer
// ---------------------------------------------------------------------------
export interface AiIntentClassifyDeps {
config: AiConfig;
/**
* Injected for tests. Defaults to the plain completion path
* (`api.aiComplete` → `ai_complete`); the agent loop is never used here.
*/
complete?: (request: AiCompletionRequest) => Promise<string>;
timeoutMs?: number;
}
/**
* Candidate actions for a mode, in menu order.
*
* In Agent mode the execution-capable actions are dropped unless the request
* explicitly asks to run/query (`allowExecution`): the invariant is enforced by
* removing them from the candidate list programmatically, not by asking the
* model nicely in the prompt. `allowExecution` is deliberately NOT defaulted —
* every caller has to decide, so a new call site cannot silently re-open
* execution by forgetting the argument.
*/
export function intentCandidatesForMode(mode: AiAssistantMode, allowExecution: boolean): AiAction[] {
const candidates = mode === "agent" ? [...AGENT_ACTIONS] : [...ASK_ACTIONS];
if (mode === "agent" && !allowExecution) return candidates.filter((action) => !AGENT_EXECUTION_ACTIONS.has(action));
return candidates;
}
function buildClassifierSystemPrompt(input: AiIntentRouteInput, candidates: AiAction[]): string {
return [
"You are an intent router for a SQL assistant inside a database IDE.",
`Pick the single best action for the user's request. Mode: ${input.mode}.`,
`Allowed actions: ${candidates.join(", ")}.`,
`Context: the editor ${input.hasCurrentSql ? "contains SQL" : "is empty"}; the last execution ${input.hasLastError ? "failed" : "did not report an error"}.`,
'Reply with JSON only: {"action":"<one allowed action>"}.',
'If the request does not clearly match any action, reply {"action":"general"}.',
"Never explain, never add other keys, never wrap the JSON in prose or code fences.",
].join("\n");
}
/**
* Parse the classifier answer. Only an exact, mode-valid action is accepted —
* anything else (prose, unknown action, malformed JSON) returns `null`.
*/
export function parseClassifierAction(raw: string, mode: AiAssistantMode): AiAction | null {
const match = raw.match(/\{[^{}]*\}/);
if (!match) return null;
let parsed: unknown;
try {
parsed = JSON.parse(match[0]);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const action = (parsed as { action?: unknown }).action;
if (typeof action !== "string") return null;
const normalized = action.trim() as AiAction;
return isValidActionForMode(normalized, mode) ? normalized : null;
}
/**
* Slow path: one tiny completion call constrained to the current mode's action
* list. Resolves to the mode default (`general`) on missing user text, timeout,
* transport error, unparsable output or an out-of-candidate answer; it never
* rejects, so a failing classifier can never break the real request.
*
* Agent mode drops the execution-capable actions from the candidate list unless
* the request explicitly asks to run/query, and the parsed answer is checked
* against that same list: the invariant is programmatic, so neither prompt
* wording nor a hallucinated action name can make Auto execute SQL the user
* never asked to run.
*/
export async function classifyIntentByLlm(input: AiIntentRouteInput, deps: AiIntentClassifyDeps): Promise<AiAction> {
const fallback = defaultActionForMode(input.mode);
// Nothing to classify (attachment-only send): the classifier would only see an
// empty user message, which some providers reject outright. Skip the call and
// take the same default the failure path would have produced.
if (!input.text.trim()) return fallback;
const candidates = intentCandidatesForMode(input.mode, requestsExplicitExecution(input.text));
if (!candidates.length) return fallback;
const complete = deps.complete ?? ((request: AiCompletionRequest) => api.aiComplete(request));
const timeoutMs = deps.timeoutMs ?? INTENT_CLASSIFY_TIMEOUT_MS;
const request: AiCompletionRequest = {
config: deps.config,
systemPrompt: buildClassifierSystemPrompt(input, candidates),
// No conversation history and no schema/error payload: the phrasing alone
// is what the classifier needs, and nothing else leaves the client.
messages: [{ role: "user", content: input.text.trim().slice(0, INTENT_CLASSIFY_MAX_TEXT_CHARS) }],
maxTokens: INTENT_CLASSIFY_MAX_TOKENS,
};
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<string>((resolve) => {
timer = setTimeout(() => {
timedOut = true;
resolve("");
}, timeoutMs);
});
try {
const answer = await Promise.race([Promise.resolve(complete(request)).catch(() => ""), timeout]);
if (timedOut) return fallback;
const parsed = parseClassifierAction(answer, input.mode);
return parsed && candidates.includes(parsed) ? parsed : fallback;
} catch {
// `complete` threw synchronously.
return fallback;
} finally {
clearTimeout(timer);
}
}
/**
* Two-stage routing entry point: rules first, classifier second, `general` last.
* Always resolves to a concrete action valid for `input.mode`.
*/
export async function routeIntent(input: AiIntentRouteInput, deps: AiIntentClassifyDeps): Promise<AiAction> {
const byRules = routeIntentByRules(input);
if (byRules) return byRules;
return classifyIntentByLlm(input, deps);
}
@@ -313,6 +313,7 @@ describe("settings search", () => {
{ titleKey: "settings.exportRowLimit", category: "data", targetId: "data" },
{ titleKey: "settings.queryExportKeysetOptimizationEnabled", category: "data", targetId: "data" },
{ titleKey: "ai.defaultAiMode", category: "ai", targetId: "ai" },
{ titleKey: "ai.defaultAutoRouting", category: "ai", targetId: "ai" },
{ titleKey: "ai.maxAgentTurns", category: "ai", targetId: "ai" },
{ titleKey: "ai.maxRetriesGlobal", category: "ai", targetId: "ai" },
{ titleKey: "ai.globalInstructions", category: "ai", targetId: "ai" },
@@ -251,6 +251,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
{ id: "ai-config", category: "ai", titleKey: "ai.configList", targetId: "ai" },
{ id: "ai-prompts", category: "ai", titleKey: "ai.promptTemplates", descriptionKey: "ai.promptTemplatesDescription", targetId: "ai" },
{ id: "ai-default-mode", category: "ai", titleKey: "ai.defaultAiMode", descriptionKey: "ai.defaultAiModeDescription", targetId: "ai" },
{ id: "ai-default-auto-routing", category: "ai", titleKey: "ai.defaultAutoRouting", descriptionKey: "ai.defaultAutoRoutingDescription", targetId: "ai" },
{ id: "ai-agent-turn-limit", category: "ai", titleKey: "ai.maxAgentTurns", descriptionKey: "ai.maxAgentTurnsDescription", targetId: "ai" },
{ id: "ai-global-retries", category: "ai", titleKey: "ai.maxRetriesGlobal", descriptionKey: "ai.maxRetriesGlobalDescription", targetId: "ai" },
{ id: "ai-global-instructions", category: "ai", titleKey: "ai.globalInstructions", descriptionKey: "ai.globalInstructionsDescription", targetId: "ai" },