diff --git a/src/turnContext.js b/src/turnContext.js index 6e755a2..f81955f 100644 --- a/src/turnContext.js +++ b/src/turnContext.js @@ -21,7 +21,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(['日', '天', '周', '月', '季度', '年']); @@ -189,7 +192,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)) { 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/turnContext.test.js b/tests/turnContext.test.js index d172709..73ee6af 100644 --- a/tests/turnContext.test.js +++ b/tests/turnContext.test.js @@ -5,6 +5,7 @@ import { TURN_TYPES, completeTaskState, createTurnContext, + extractExplicitSlots, } from '../src/turnContext.js'; function slot(value, source = 'explicit') { @@ -28,6 +29,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: '查询本月含税销售金额',