fix(ai): improve agent metadata query execution

This commit is contained in:
t8y2 2026-06-04 11:19:14 +08:00
parent 2f9d58a4fa
commit b086b91fdc
6 changed files with 102 additions and 14 deletions

View File

@ -40,6 +40,7 @@ export interface AiContext {
lastError?: string;
lastResultPreview?: string;
tables: AiSchemaTable[];
schemaScope?: "focused_table" | "database";
truncated: boolean;
}
@ -140,6 +141,7 @@ export function buildSystemPrompt(action: AiAction, context: AiContext, mode: Ai
const schema = formatSchema(context);
const resultPreview = context.lastResultPreview ? `\nLast result preview:\n${context.lastResultPreview}\n` : "";
const lastError = context.lastError ? `\nLast error:\n${context.lastError}\n` : "";
const schemaScope = context.schemaScope ?? "database";
const isZh = isChineseLocale(currentLocale());
@ -149,11 +151,17 @@ export function buildSystemPrompt(action: AiAction, context: AiContext, mode: Ai
...buildActionPromptLines(action, isZh),
];
if (context.truncated) {
if (schemaScope === "focused_table") {
lines.push(
isZh
? "Schema 已截断:如果请求可能涉及未出现的表或字段,不要猜测。请让用户用 @table 指定相关表,或先生成只读探索查询。"
: "Schema is truncated: if the request may involve tables or columns not shown, do not guess. Ask the user to mention the relevant @table, or generate a read-only exploration query first.",
? "Schema 上下文只覆盖当前打开的表;数据库中可能还有其他表。用户询问当前有哪些表、某表是否存在,或提到上下文中不存在的表时,不要直接断言不存在,优先生成只读元数据查询来核实。"
: "Schema context covers only the currently opened table; the database may contain other tables. When the user asks what tables exist, whether a table exists, or mentions a table absent from context, do not conclude it is missing; prefer a read-only metadata query to verify.",
);
} else if (context.truncated) {
lines.push(
isZh
? "Schema 已截断:如果请求可能涉及未出现的表或字段,不要猜测。请让用户用 @table 指定相关表,或先生成只读探索/元数据查询。"
: "Schema is truncated: if the request may involve tables or columns not shown, do not guess. Ask the user to mention the relevant @table, or generate a read-only exploration/metadata query first.",
);
}
@ -165,7 +173,7 @@ export function buildSystemPrompt(action: AiAction, context: AiContext, mode: Ai
`Database type: ${context.databaseType}`,
`Connection: ${context.connectionName}`,
`Database: ${context.database}`,
context.truncated ? "Schema context is truncated." : "Schema context is complete.",
schemaCoverageLine(context, isZh),
"",
`Current SQL:\n${context.currentSql.trim() || "(empty)"}`,
lastError,
@ -186,8 +194,11 @@ function buildBasePromptLines(isZh: boolean): string[] {
? "严格使用当前数据库方言标识符引用、分页、日期函数、字符串拼接、LIMIT/TOP/OFFSET 语法必须匹配数据库类型。"
: "Strictly use the active database dialect; identifier quoting, pagination, date functions, string concatenation, and LIMIT/TOP/OFFSET syntax must match the database type.",
isZh
? "下面的 Schema 上下文已包含表、列、索引和外键信息,直接使用即可。不要查询 information_schema 或系统表来获取结构信息。"
: "The schema context below already contains tables, columns, indexes, and foreign keys — use it directly. Do NOT query information_schema or system tables.",
? "对于普通数据查询,优先使用下面已加载的 Schema 上下文,不要为了重复确认已给出的结构而查询 information_schema 或系统表。"
: "For ordinary data queries, prefer the loaded schema context below. Do not query information_schema or system tables merely to rediscover structure already provided.",
isZh
? "例外:当用户明确询问当前有哪些表/Schema、某张表是否存在、或需要盘点数据库对象时应生成符合当前方言的只读元数据查询例如 SHOW TABLES、information_schema、sqlite_master 等)。"
: "Exception: when the user explicitly asks what tables/schemas exist, whether a table exists, or asks for database object inventory, generate a read-only metadata query appropriate for the active dialect (for example SHOW TABLES, information_schema, sqlite_master).",
isZh
? "表注释和列注释是语义别名;当用户用中文业务名描述表或字段时,优先根据注释匹配真实表名和字段名。"
: "Table and column comments are semantic aliases; when the user describes tables or fields by business names, prefer matching those comments to the real table and column names.",
@ -222,6 +233,9 @@ function buildModePromptLines(mode: AiAssistantMode, isZh: boolean): string[] {
isZh
? "如果安全执行条件不满足,先说明原因,再给只读预览或澄清问题。"
: "If safe execution requirements are not met, explain why first, then provide a read-only preview or a clarifying question.",
isZh
? "当用户问“有哪些表”“当前表列表”“表是否存在”这类元数据问题时,优先返回一个可执行的只读元数据 SQL让系统执行后再基于结果回答。"
: "When the user asks metadata questions such as what tables exist, the current table list, or whether a table exists, prefer returning one executable read-only metadata SQL so the system can run it before answering from results.",
];
}
@ -232,6 +246,17 @@ function buildModePromptLines(mode: AiAssistantMode, isZh: boolean): string[] {
];
}
function schemaCoverageLine(context: AiContext, isZh: boolean): string {
if (context.schemaScope === "focused_table") {
return isZh
? "Schema context scope: focused table only; not a complete database table list."
: "Schema context scope: focused table only; not a complete database table list.";
}
return context.truncated
? "Schema context is truncated."
: "Schema context is complete for the loaded database scope.";
}
function buildActionPromptLines(action: AiAction, isZh: boolean): string[] {
const skill = aiSkillForAction(action);
return isZh
@ -293,8 +318,10 @@ export async function buildAiContext(
const tables: AiSchemaTable[] = [];
const tableKeys = new Set<string>();
let truncated = false;
let schemaScope: AiContext["schemaScope"] = "database";
if (tab.tableMeta) {
schemaScope = "focused_table";
const s = tab.tableMeta.schema ?? "";
const tName = tab.tableMeta.tableName;
const [indexes, foreignKeys] = await Promise.all([
@ -376,6 +403,7 @@ export async function buildAiContext(
lastError: extractLastError(tab.result),
lastResultPreview: formatResultPreview(tab.result),
tables,
schemaScope,
truncated,
};
}

View File

@ -25,8 +25,6 @@ const NON_PRODUCTION_RE =
const LOCAL_HOST_RE = /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|::1)$/i;
const NEGATIVE_EXECUTION_RE =
/(不要|别|不用|禁止|只生成|仅生成|只写|仅写).{0,12}(执行|运行|跑)|do\s+not\s+execute|don't\s+execute|dont\s+execute|without\s+executing|only\s+(generate|write|return)/i;
const ACTION_INTENT_RE =
/(帮我查|帮忙查|请查|查一下|查询|查看|看一下|看下|统计|计算|算|求|平均|最大|最小|总数|数量|列出|显示|获取|执行|运行|跑一下|show|list|query|find|count|get|fetch|execute|run)/i;
export function stripAiSqlComments(sql: string): string {
return sql
@ -121,7 +119,7 @@ export function shouldAttemptAiAutoExecute(instruction: string, action: string):
if (action !== "generate") return false;
const normalized = instruction.trim();
if (!normalized || NEGATIVE_EXECUTION_RE.test(normalized)) return false;
return ACTION_INTENT_RE.test(normalized);
return true;
}
export function extractFirstSqlCodeBlock(content: string): string | undefined {

View File

@ -65,6 +65,30 @@ test("agent mode auto-executes read SQL when the user asks to query", () => {
assert.equal(plan.handoffSql, "SELECT count(*) FROM users");
});
test("agent mode auto-executes table inventory metadata SQL for natural Chinese questions", () => {
const sql = "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'public' ORDER BY tablename;";
const plan = buildAiAgentPlan(
planInput({
instruction: "当前有哪些表",
assistantContent: `SQL\n\n\n\n\`\`\`sql\n${sql}\n\`\`\``,
}),
);
assert.deepEqual(plan.steps, [
{ kind: "generate_sql", status: "done", sql },
{
kind: "risk_check",
status: "done",
action: "auto_execute",
environment: "non_production",
category: "read",
reasons: [],
},
{ kind: "execute_sql", status: "pending", sql },
]);
assert.equal(plan.handoffSql, sql);
});
test("agent mode skips execution when the user explicitly asks not to run", () => {
const plan = buildAiAgentPlan(planInput({ instruction: "只生成 SQL不要执行" }));

View File

@ -81,6 +81,15 @@ test("prompt gives explicit guidance for truncated schema context", () => {
assert.match(prompt, /@table/);
});
test("focused table context is not presented as a complete table list", () => {
const prompt = buildSystemPrompt("generate", context({ schemaScope: "focused_table" }), "agent");
assert.match(prompt, /focused table only; not a complete database table list/);
assert.match(prompt, /当前打开的表/);
assert.match(prompt, /只读元数据查询/);
assert.doesNotMatch(prompt, /Schema context is complete\./);
});
test("prompt enforces database dialect and single executable statement safety", () => {
const prompt = buildSystemPrompt("generate", context({ databaseType: "sqlserver" }), "agent");

View File

@ -104,7 +104,20 @@ const cases: PromptEvalCase[] = [
action: "generate",
mode: "ask",
context: { truncated: true },
mustInclude: [/Schema context is truncated/, /不要猜测/, /@table/, /只读探索查询/],
mustInclude: [/Schema context is truncated/, /不要猜测/, /@table/, /只读探索\/元数据查询/],
},
{
name: "focused table context tells agent to verify table inventory with metadata SQL",
action: "generate",
mode: "agent",
context: { schemaScope: "focused_table" },
mustInclude: [
/focused table only; not a complete database table list/,
/不要直接断言不存在/,
/只读元数据查询/,
/有哪些表/,
],
mustNotInclude: [/Schema context is complete\./],
},
{
name: "sqlserver generation requires dialect-specific pagination and quoting",

View File

@ -28,7 +28,10 @@ test("classifyConnectionEnvironment treats local and dev targets as non-producti
test("classifyConnectionEnvironment treats production signals and unknown targets as production-like", () => {
assert.equal(classifyConnectionEnvironment(conn({ name: "prod-db", host: "10.0.0.9" })), "production");
assert.equal(classifyConnectionEnvironment(conn({ name: "analytics", host: "10.0.0.9", database: "warehouse" })), "unknown");
assert.equal(
classifyConnectionEnvironment(conn({ name: "analytics", host: "10.0.0.9", database: "warehouse" })),
"unknown",
);
});
test("read SQL auto-executes on production and non-production", () => {
@ -38,7 +41,10 @@ test("read SQL auto-executes on production and non-production", () => {
test("single insert auto-executes only on non-production targets", () => {
assert.equal(classifyAiSqlExecution("INSERT INTO users(name) VALUES ('a')", conn()).action, "auto_execute");
assert.equal(classifyAiSqlExecution("INSERT INTO users(name) VALUES ('a')", conn({ name: "prod-db" })).action, "confirm");
assert.equal(
classifyAiSqlExecution("INSERT INTO users(name) VALUES ('a')", conn({ name: "prod-db" })).action,
"confirm",
);
});
test("scoped single update auto-executes only on non-production targets", () => {
@ -56,10 +62,13 @@ test("broad or destructive writes do not auto-execute", () => {
test("comments and multi-statement writes do not bypass policy", () => {
assert.equal(classifyAiSqlExecution("-- SELECT\nDROP TABLE users", conn()).action, "block");
assert.equal(classifyAiSqlExecution("INSERT INTO users(name) VALUES ('a'); UPDATE users SET name='b' WHERE id=1", conn()).action, "confirm");
assert.equal(
classifyAiSqlExecution("INSERT INTO users(name) VALUES ('a'); UPDATE users SET name='b' WHERE id=1", conn()).action,
"confirm",
);
});
test("AI auto-execution only attempts action-oriented generate requests", () => {
test("AI auto-execution trusts generated SQL in agent generate mode unless the user opts out", () => {
assert.equal(shouldAttemptAiAutoExecute("查一下用户数量", "generate"), true);
assert.equal(shouldAttemptAiAutoExecute("帮我查ihli的平均值", "generate"), true);
assert.equal(shouldAttemptAiAutoExecute("看下 ihli 平均是多少", "generate"), true);
@ -67,7 +76,14 @@ test("AI auto-execution only attempts action-oriented generate requests", () =>
assert.equal(shouldAttemptAiAutoExecute("计算 ihli 总数", "generate"), true);
assert.equal(shouldAttemptAiAutoExecute("显示最近 10 条订单", "generate"), true);
assert.equal(shouldAttemptAiAutoExecute("获取用户数量", "generate"), true);
assert.equal(shouldAttemptAiAutoExecute("当前有哪些表", "generate"), true);
assert.equal(shouldAttemptAiAutoExecute("这个库有什么表", "generate"), true);
assert.equal(shouldAttemptAiAutoExecute("当前表列表", "generate"), true);
assert.equal(shouldAttemptAiAutoExecute("show me recent orders", "generate"), true);
assert.equal(shouldAttemptAiAutoExecute("现在库里表的情况是?", "generate"), true);
assert.equal(shouldAttemptAiAutoExecute("生成一个查询用户数量的 SQL", "generate"), true);
assert.equal(shouldAttemptAiAutoExecute("你好", "generate"), true);
assert.equal(shouldAttemptAiAutoExecute("", "generate"), false);
assert.equal(shouldAttemptAiAutoExecute("只生成 SQL不要执行", "generate"), false);
assert.equal(shouldAttemptAiAutoExecute("先别跑,帮我查一下用户数量", "generate"), false);
assert.equal(shouldAttemptAiAutoExecute("优化这条 SQL", "optimize"), false);