diff --git a/src/conversationRuntime.js b/src/conversationRuntime.js index 8fb442b..3a7879c 100644 --- a/src/conversationRuntime.js +++ b/src/conversationRuntime.js @@ -31,8 +31,8 @@ export async function resolveConversationRuntime({ forceNewTask, memorySlots, classifier, - ...(maxInheritTurns ? { maxInheritTurns } : {}), - ...(maxInheritedSlots ? { maxInheritedSlots } : {}), + ...(maxInheritTurns != null ? { maxInheritTurns } : {}), + ...(maxInheritedSlots != null ? { maxInheritedSlots } : {}), }, ); conversationContext = await refineTurnContextWithHarness({ diff --git a/src/turnContext.js b/src/turnContext.js index 3c83473..d8e53f2 100644 --- a/src/turnContext.js +++ b/src/turnContext.js @@ -25,7 +25,10 @@ const SLOT_LABELS = { dimensions: '维度', analysisMode: '分析方式', }; -const TIME_PATTERN = /(?:近|最近|过去)\s*(?:\d+|[一二两三四五六七八九十]+)\s*个?(?:天|日|周|星期|月|季度|年)|(?:本|这|上|下)(?:个)?(?:周|星期|月|季度|年)|(?:今天|今日|昨天|昨日|前天|明天|后天|今年|去年|明年)|(?:第[一二三四1-4]季度|Q[1-4])|\d{4}年(?:\d{1,2}月)?|\d{1,2}月/gi; +const EXPLICIT_DATE_PATTERN = String.raw`(?:\d{4}年)?\d{1,2}月\d{1,2}[日号]|\d{4}[-/.]\d{1,2}[-/.]\d{1,2}`; +// Match complete day ranges before their year/month prefixes so inheritance keeps day precision. +const EXPLICIT_DATE_RANGE_PATTERN = String.raw`(?:${EXPLICIT_DATE_PATTERN})(?:\s*(?:到|至|~|—|-|-)\s*(?:${EXPLICIT_DATE_PATTERN}|\d{1,2}[日号]))?`; +const TIME_PATTERN = new RegExp(String.raw`${EXPLICIT_DATE_RANGE_PATTERN}|(?:近|最近|过去)\s*(?:\d+|[一二两三四五六七八九十]+)\s*个?(?:天|日|周|星期|月|季度|年)|(?:本|这|上|下)(?:个)?(?:周|星期|月|季度|年)|(?:今天|今日|昨天|昨日|前天|明天|后天|今年|去年|明年)|(?:第[一二三四1-4]季度|Q[1-4])|\d{4}年(?:\d{1,2}月)?|\d{1,2}月`, 'gi'); const TIME_HINT_PATTERN = /(?:近|最近|过去)\s*(?:\d+|[一二两三四五六七八九十]+)\s*个?(?:天|日|周|星期|月|季度|年)|(?:本|这|上|下)(?:个)?(?:周|星期|月|季度|年)|今天|今日|昨天|昨日|前天|明天|后天|今年|去年|明年|第[一二三四1-4]季度|Q[1-4]|\d{4}年|\d{1,2}月/i; const FILTER_PATTERN = /(?:只看|仅看|限定|过滤|筛选|排除)([^,。;,;]{1,24})/g; const TIME_GRAINS = new Set(['日', '天', '周', '月', '季度', '年']); @@ -231,7 +234,7 @@ export function inferClarificationSlots(question) { return {}; } const slots = {}; - if (TIME_HINT_PATTERN.test(source)) { + if (TIME_HINT_PATTERN.test(source) || extractExplicitSlots(source).time?.length) { return slots; } if (turnPatterns().fieldLabel?.test(source)) { @@ -514,7 +517,7 @@ export function createTurnContext({ inheritedSlots, memorySlots: usableMemorySlots, clearedSlots: cleared, - result: previousTask?.result ?? null, + result: canInherit ? previousTask?.result ?? null : null, status: 'PLANNING', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/tests/conversationRuntimeBudgets.test.js b/tests/conversationRuntimeBudgets.test.js new file mode 100644 index 0000000..7df5655 --- /dev/null +++ b/tests/conversationRuntimeBudgets.test.js @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { resolveConversationRuntime } from '../src/conversationRuntime.js'; +import { SessionMemoryStore } from '../src/memory.js'; +import { completeTaskState, createTurnContext, TURN_TYPES } from '../src/turnContext.js'; + +function salesMemory({ inheritDepth = 0 } = {}) { + const initial = createTurnContext({ question: '查询销售额,本月,只看华东' }); + const taskState = completeTaskState({ + contextTask: { ...initial.taskState, inheritDepth }, + answer: { + resolvedQuestion: initial.question, + queryFingerprint: 'sales-fingerprint-123', + dataHash: 'sales-data-hash-123', + }, + artifactId: 'sales-artifact-123', + messageId: 'sales-message-123', + }); + const rows = [ + { id: 'user-123', role: 'user', content: initial.originalQuestion }, + { + id: 'sales-message-123', role: 'assistant', content: '本月华东销售额为100万元。', + result: { question: initial.question, taskState, artifactId: 'sales-artifact-123' }, + }, + ]; + const memory = new SessionMemoryStore({ + getChatSession: () => ({ id: 'session-123', themeId: 'sales' }), + listChatMessages: () => rows, + }); + return { memory, taskState }; +} + +async function resolveBudget(options = {}, fixtureOptions = {}) { + const { memory, taskState } = salesMemory(fixtureOptions); + const runtime = await resolveConversationRuntime({ + memory, + sessionId: 'session-123', + userId: 'user-123', + question: '那按渠道拆开呢', + classifier: false, + harness: { mode: 'local-rule' }, + ...options, + }); + return { ...runtime, previousTask: taskState }; +} + +test('runtime zero turn budget starts a clean execution task despite completed history', async () => { + const { conversationContext: context, executionContext, understanding, previousTask } = + await resolveBudget({ maxInheritTurns: 0 }); + + assert.equal(understanding.turns[1].taskState.result.artifactId, 'sales-artifact-123'); + assert.equal(context.turnType, TURN_TYPES.NEW_TASK); + assert.equal(context.reason, 'task_context_expired'); + assert.notEqual(context.taskId, previousTask.taskId); + assert.deepEqual(context.inheritedSlots, {}); + assert.equal(context.taskState.result, null); + assert.equal(executionContext.taskState.result, null); + assert.deepEqual(executionContext.messages, []); + assert.doesNotMatch(executionContext.sourceContext, /本月|华东|销售额|sales-(?:artifact|fingerprint|data-hash|message)-123/); + assert.doesNotMatch(executionContext.systemMessage.content, /sales-(?:artifact|fingerprint|data-hash|message)-123/); +}); + +test('runtime zero slot budget excludes prior conditions from the execution contract', async () => { + const { conversationContext: context, executionContext, previousTask } = + await resolveBudget({ maxInheritedSlots: 0 }); + + assert.equal(context.turnType, TURN_TYPES.CONTINUATION); + assert.equal(context.taskId, previousTask.taskId); + assert.deepEqual(context.inheritedSlots, {}); + assert.equal(context.inheritQueryContext, false); + assert.doesNotMatch(executionContext.sourceContext, /本月|华东|销售额/); + assert.match(executionContext.sourceContext, /渠道/); + assert.equal(context.taskState.result.artifactId, 'sales-artifact-123'); +}); + +for (const { name, options } of [ + { name: 'omitted', options: {} }, + { name: 'undefined', options: { maxInheritTurns: undefined, maxInheritedSlots: undefined } }, + { name: 'null', options: { maxInheritTurns: null, maxInheritedSlots: null } }, +]) { + test(`runtime ${name} budgets retain default inheritance`, async () => { + const { conversationContext: context, executionContext } = await resolveBudget(options); + + assert.equal(context.turnType, TURN_TYPES.CONTINUATION); + assert.equal(context.inheritBudget.maxTurns, 4); + assert.equal(context.inheritBudget.maxSlots, 3); + assert.deepEqual(Object.keys(context.inheritedSlots), ['metric', 'time', 'filters']); + for (const condition of ['销售额', '本月', '华东']) { + assert.ok(executionContext.sourceContext.includes(condition)); + } + }); +} + +test('runtime positive budgets limit inherited slots and allow remaining turns', async () => { + const { conversationContext: context, executionContext } = await resolveBudget({ + maxInheritTurns: 2, maxInheritedSlots: 1, + }, { inheritDepth: 1 }); + + assert.equal(context.turnType, TURN_TYPES.CONTINUATION); + assert.equal(context.inheritBudget.usedTurns, 2); + assert.equal(context.inheritBudget.remainingTurns, 0); + assert.deepEqual(Object.keys(context.inheritedSlots), ['metric']); + assert.match(executionContext.sourceContext, /销售额/); + assert.doesNotMatch(executionContext.sourceContext, /本月|华东/); +}); + +test('runtime positive turn budget expires at its configured depth', async () => { + const { conversationContext: context, executionContext } = await resolveBudget({ + maxInheritTurns: 1, maxInheritedSlots: 1, + }, { inheritDepth: 1 }); + + assert.equal(context.turnType, TURN_TYPES.NEW_TASK); + assert.equal(context.reason, 'task_context_expired'); + assert.deepEqual(context.inheritedSlots, {}); + assert.equal(executionContext.taskState.result, null); +}); diff --git a/tests/postgresql.query.http.test.js b/tests/postgresql.query.http.test.js index 32f4b11..d7ee04a 100644 --- a/tests/postgresql.query.http.test.js +++ b/tests/postgresql.query.http.test.js @@ -122,7 +122,7 @@ test('live PostgreSQL: authenticated Agent contract, row policy, audit and same- }); assert.equal(streamed.status, 200); const events = (await streamed.text()).trim().split('\n').map((line) => JSON.parse(line)); - assert.ok(!events.some((event) => event.type === 'stream_error'), JSON.stringify(events)); + assert.deepEqual(events.filter((event) => event.type === 'stream_error'), []); const success = { response: streamed, data: events.find((event) => event.type === 'final')?.answer }; assert.equal(success.response.status, 200, JSON.stringify(success.data)); assert.ok(success.data); diff --git a/tests/queryDateRange.http.test.js b/tests/queryDateRange.http.test.js new file mode 100644 index 0000000..36b7196 --- /dev/null +++ b/tests/queryDateRange.http.test.js @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { withSkillServer } from './fixtures/skillServer.js'; + +test('authenticated streaming contracts keep explicit date ranges intact and apply row policy', async () => { + await withSkillServer(async ({ application, login, baseUrl }) => { + const cookie = await login('east_manager'); + const user = application.database.getUserByUsername('east_manager'); + const source = application.database.saveDataSource({ + code: 'range_pg', name: 'Range PostgreSQL', dbType: 'POSTGRESQL', + host: 'localhost', port: 5432, databaseName: 'test', username: 'test', + encryptedPassword: '', options: {}, + }); + const dataset = application.database.saveBusinessDataset({ + code: 'range_sales', name: 'Range sales', datasourceId: source.id, + schemaName: 'public', primaryTable: 'sales', + config: { autoLatestDateRange: false, policyFieldMap: { region: 'region' } }, + }); + application.database.replaceDatasetFields(dataset.id, [ + { fieldName: 'day', dataType: 'date', semanticType: 'DATE', role: 'TIME', + aggregator: 'NONE', allowedOperators: ['BETWEEN'] }, + { fieldName: 'region', dataType: 'text', semanticType: 'STRING', role: 'DIMENSION', + aggregator: 'NONE', allowedOperators: ['IN'] }, + { fieldName: 'amount', dataType: 'numeric', semanticType: 'NUMBER', role: 'METRIC', + aggregator: 'SUM', allowedOperators: ['>', 'BETWEEN'] }, + ]); + application.database.grantDatasetAccess(user.id, dataset.id, true); + application.database.saveTheme({ ...application.database.getTheme(1), indicatorIds: [], + businessDatasetIds: [dataset.id], primaryBusinessDatasetId: dataset.id, + semanticPolicy: {}, systemPrompt: 'amount 为销售额,使用 SUM;day 为日期,region 为区域。', + }, 1); + application.database.savePlatformSetting('supersonic.enabled', false); + const statements = []; + // Keep auth, context, compiler, SQL generation and audit real; replace only database I/O. + application.businessDatasets.getPoolById = () => ({ query: async (config) => { + statements.push(config); + return [[{ amount: 40 }], [{ name: 'amount' }]]; + } }); + application.agent.harnessFactory = { forTheme: () => ({ + mode: 'local-rule', model: 'range-acceptance', capabilities: {}, + run: async ({ executeTool }) => { + await executeTool('list_business_datasets', {}); + await executeTool('get_business_dataset_definition', { datasetId: dataset.id }); + const compiled = await executeTool('compile_query_contract', { + sourceType: 'BUSINESS_DATASET', datasetId: dataset.id, + conditions: [ + { id: 'time', sourceText: '2026年1月1日至2026年3月31日', kind: 'TIME', status: 'RESOLVED' }, + { id: 'region', sourceText: 'west', kind: 'FILTER', status: 'RESOLVED' }, + { id: 'metric', sourceText: 'amount', kind: 'METRIC', status: 'RESOLVED' }, + ], + metricFields: [{ field: 'amount', sourceText: 'amount', aggregator: 'SUM', conditionIds: ['metric'] }], + dimensionFields: [], + filterFields: [{ field: 'region', operator: 'IN', value: ['west'], sourceText: 'west', conditionIds: ['region'] }], + timeWindows: [{ sourceText: '2026年1月1日至2026年3月31日', dateMode: 'BETWEEN', + startDate: '2026-01-01', endDate: '2026-03-31', conditionIds: ['time'] }], + limit: 20, + }); + assert.equal(compiled.valid, true, JSON.stringify(compiled.issues)); + await executeTool('execute_query_contract', { contractId: compiled.contractId }); + return { content: '受控查询已完成。', trace: [] }; + }, + }) }; + const response = await fetch(`${baseUrl}/api/chat/query/stream`, { + method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ themeId: 1, question: '2026年1月1日至2026年3月31日 west amount' }), + }); + assert.equal(response.status, 200); + const events = (await response.text()).trim().split('\n').map(line => JSON.parse(line)); + assert.deepEqual(events.filter(event => event.type === 'stream_error'), []); + const answer = events.find(event => event.type === 'final')?.answer; + assert.ok(answer); + assert.deepEqual(answer.data.rows, [{ amount: 40 }]); + const plan = application.database.getQueryPlan(answer.semanticParse.planId, user.id); + assert.equal(plan.status, 'EXECUTED'); + assert.equal(plan.plan.timeWindows[0].startDate, '2026-01-01'); + assert.equal(plan.plan.timeWindows[0].endDate, '2026-03-31'); + const audit = application.database.listDatasetQueryLogs(100).filter(log => log.datasetId === dataset.id); + assert.equal(audit.length, 1); + const statement = statements.find(item => item.sql === audit[0].sqlText); + assert.ok(statement); + assert.ok(statement.values.includes('2026-01-01')); + assert.ok(statement.values.includes('2026-03-31')); + assert.ok(statement.values.flat(Infinity).includes('华东')); + assert.ok(!statement.values.flat(Infinity).includes('west')); + }); +}); diff --git a/tests/taskResultBoundary.test.js b/tests/taskResultBoundary.test.js new file mode 100644 index 0000000..3ce445e --- /dev/null +++ b/tests/taskResultBoundary.test.js @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { resolveConversationContext } from '../src/conversationResolver.js'; +import { + TURN_TYPES, + completeTaskState, + createTurnContext, + formatTaskForPrompt, + publicContextDecision, + serializeTaskForContract, +} from '../src/turnContext.js'; + +function salesHistory() { + const context = createTurnContext({ question: '查询本月销售额,只看华东' }); + return [ + { role: 'user', content: context.originalQuestion }, + { + role: 'assistant', + content: '本月华东销售额为100万元。', + result: { + taskState: completeTaskState({ + contextTask: context.taskState, + answer: { + resolvedQuestion: context.question, + queryFingerprint: 'sales-fingerprint-123', + dataHash: 'sales-data-hash-123', + }, + artifactId: 'sales-artifact-123', + messageId: 'sales-message-123', + }), + }, + }, + ]; +} + +for (const { name, question, options, extraHistory = [], reason } of [ + { name: 'explicit new question', question: '新问题,查询今年订单量', + reason: 'explicit_new_task' }, + { name: 'forced task reset', question: '那按渠道拆开呢', + options: { forceNewTask: true }, reason: 'forced_new_task' }, + { name: 'zero inheritance budget', question: '那按渠道拆开呢', + options: { maxInheritTurns: 0 }, reason: 'task_context_expired' }, + { name: 'elapsed history budget', question: '那按渠道拆开呢', + options: { maxInheritTurns: 1 }, reason: 'task_context_expired', + extraHistory: [ + { role: 'user', content: '谢谢' }, + { role: 'assistant', content: '不客气' }, + { role: 'user', content: '好的' }, + ] }, +]) { + test(`${name} clears prior result from task state, prompt and execution contract`, () => { + const history = [...salesHistory(), ...extraHistory]; + const context = resolveConversationContext(question, history, options); + + assert.equal(context.turnType, TURN_TYPES.NEW_TASK); + assert.equal(context.reason, reason); + assert.notEqual(context.taskId, history[1].result.taskState.taskId); + assert.deepEqual(context.inheritedSlots, {}); + assert.equal(publicContextDecision(context).referencedArtifactId, null); + assert.equal(context.taskState.result, null); + for (const serialized of [ + JSON.stringify(context.taskState), + formatTaskForPrompt(context.taskState), + serializeTaskForContract(context.taskState), + ]) { + assert.doesNotMatch(serialized, /sales-(?:artifact|fingerprint|data-hash|message)-123/); + } + }); +} + +for (const { question, turnType, referencedArtifactId } of [ + { question: '那按渠道拆开呢', turnType: TURN_TYPES.CONTINUATION, + referencedArtifactId: null }, + { question: '导出Excel', turnType: TURN_TYPES.RESULT_OPERATION, + referencedArtifactId: 'sales-artifact-123' }, + { question: '上述结果按渠道拆开', turnType: TURN_TYPES.CONTINUATION, + referencedArtifactId: 'sales-artifact-123' }, +]) { + test(`${question} preserves the eligible result and its lineage`, () => { + const history = salesHistory(); + const context = resolveConversationContext(question, history); + + assert.equal(context.turnType, turnType); + assert.equal(context.taskId, history[1].result.taskState.taskId); + assert.equal(publicContextDecision(context).referencedArtifactId, referencedArtifactId); + assert.deepEqual(context.taskState.result, { + artifactId: 'sales-artifact-123', + messageId: 'sales-message-123', + queryFingerprint: 'sales-fingerprint-123', + dataHash: 'sales-data-hash-123', + }); + assert.match(formatTaskForPrompt(context.taskState), /sales-artifact-123/); + assert.match(serializeTaskForContract(context.taskState), /sales-artifact-123/); + }); +} diff --git a/tests/turnContext.test.js b/tests/turnContext.test.js index f7e7117..8282a36 100644 --- a/tests/turnContext.test.js +++ b/tests/turnContext.test.js @@ -31,6 +31,46 @@ function completedHistory(context, content = '查询已完成') { ]; } +for (const time of [ + '2026年1月1日至2026年3月31日', + '2026-01-01 至 2026-03-31', + '2026年1月1日至3月31日', + '2026年1月1日', +]) { + test(`explicit time slots preserve the complete date expression: ${time}`, () => { + assert.deepEqual(extractExplicitSlots(`${time}销售额`).time.map(item => item.sourceText), [time]); + }); +} + +test('two distinct explicit date ranges remain separate time slots', () => { + const question = '对比2026年1月1日至2026年1月31日与2026年3月1日至2026年3月31日销售额'; + assert.deepEqual(extractExplicitSlots(question).time.map(item => item.sourceText), [ + '2026年1月1日至2026年1月31日', '2026年3月1日至2026年3月31日', + ]); +}); + +test('an ISO date clarification fills time without replacing the inherited region filter', () => { + const first = createTurnContext({ question: '销售额,只看华东' }); + const clarified = createTurnContext({ question: '2026-01-01', history: completedHistory(first), + turnType: TURN_TYPES.CLARIFICATION_ANSWER }); + assert.deepEqual(clarified.slots.time.map(item => item.sourceText), ['2026-01-01']); + assert.equal(clarified.explicitSlots.filters, undefined); + assert.deepEqual(clarified.inheritedSlots.filters.map(item => item.sourceText), ['华东']); + assert.deepEqual(clarified.slots.filters.map(item => item.sourceText), ['华东']); +}); + +test('followups inherit full date ranges and filters while a time revision replaces only time', () => { + const first = createTurnContext({ question: '2026年1月1日至2026年3月31日销售额,只看华东' }); + const second = createTurnContext({ question: '按渠道拆开', history: completedHistory(first), + turnType: TURN_TYPES.CONTINUATION }); + assert.deepEqual(second.inheritedSlots.time.map(item => item.sourceText), ['2026年1月1日至2026年3月31日']); + assert.deepEqual(second.inheritedSlots.filters.map(item => item.sourceText), ['华东']); + const revised = createTurnContext({ question: '改查2026年4月1日至2026年4月15日', + history: completedHistory(second), turnType: TURN_TYPES.REVISION }); + assert.deepEqual(revised.slots.time.map(item => item.sourceText), ['2026年4月1日至2026年4月15日']); + assert.deepEqual(revised.inheritedSlots.filters.map(item => item.sourceText), ['华东']); +}); + test('continuations keep the task id, advance the turn id and inherit field by field', () => { const first = createTurnContext({ question: '查询本月含税销售金额',