diff --git a/apps/desktop/src/lib/ai.ts b/apps/desktop/src/lib/ai.ts index 37cfb5afe..02339453f 100644 --- a/apps/desktop/src/lib/ai.ts +++ b/apps/desktop/src/lib/ai.ts @@ -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(); 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, }; } diff --git a/apps/desktop/src/lib/aiSqlExecutionPolicy.ts b/apps/desktop/src/lib/aiSqlExecutionPolicy.ts index 22d176b1c..7f8e7967b 100644 --- a/apps/desktop/src/lib/aiSqlExecutionPolicy.ts +++ b/apps/desktop/src/lib/aiSqlExecutionPolicy.ts @@ -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 { diff --git a/packages/app-tests/aiAgentPlan.test.ts b/packages/app-tests/aiAgentPlan.test.ts index c43f2562f..547170133 100644 --- a/packages/app-tests/aiAgentPlan.test.ts +++ b/packages/app-tests/aiAgentPlan.test.ts @@ -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,不要执行" })); diff --git a/packages/app-tests/aiPrompt.test.ts b/packages/app-tests/aiPrompt.test.ts index fb31dc0d4..b351f5240 100644 --- a/packages/app-tests/aiPrompt.test.ts +++ b/packages/app-tests/aiPrompt.test.ts @@ -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"); diff --git a/packages/app-tests/aiPromptEval.test.ts b/packages/app-tests/aiPromptEval.test.ts index 426677460..aa596660d 100644 --- a/packages/app-tests/aiPromptEval.test.ts +++ b/packages/app-tests/aiPromptEval.test.ts @@ -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", diff --git a/packages/app-tests/aiSqlExecutionPolicy.test.ts b/packages/app-tests/aiSqlExecutionPolicy.test.ts index 89ec89b47..de419a116 100644 --- a/packages/app-tests/aiSqlExecutionPolicy.test.ts +++ b/packages/app-tests/aiSqlExecutionPolicy.test.ts @@ -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);