feat(ai): add opt-in auto intent routing for the assistant
This commit is contained in:
1 parent
023f0074bc
commit
3f3c21aae9
26 files changed
+1454
-16
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user