Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/turnContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(['日', '天', '周', '月', '季度', '年']);
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -438,7 +441,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(),
Expand Down
2 changes: 1 addition & 1 deletion tests/postgresql.query.http.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
86 changes: 86 additions & 0 deletions tests/queryDateRange.http.test.js
Original file line number Diff line number Diff line change
@@ -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'));
});
});
96 changes: 96 additions & 0 deletions tests/taskResultBoundary.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
}
41 changes: 41 additions & 0 deletions tests/turnContext.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
TURN_TYPES,
completeTaskState,
createTurnContext,
extractExplicitSlots,
} from '../src/turnContext.js';

function slot(value, source = 'explicit') {
Expand All @@ -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: '查询本月含税销售金额',
Expand Down
Loading