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
4 changes: 2 additions & 2 deletions src/conversationRuntime.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export async function resolveConversationRuntime({
forceNewTask,
memorySlots,
classifier,
...(maxInheritTurns ? { maxInheritTurns } : {}),
...(maxInheritedSlots ? { maxInheritedSlots } : {}),
...(maxInheritTurns != null ? { maxInheritTurns } : {}),
...(maxInheritedSlots != null ? { maxInheritedSlots } : {}),
},
);
conversationContext = await refineTurnContextWithHarness({
Expand Down
9 changes: 6 additions & 3 deletions src/turnContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(['日', '天', '周', '月', '季度', '年']);
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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(),
Expand Down
117 changes: 117 additions & 0 deletions tests/conversationRuntimeBudgets.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
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/);
});
}
Loading
Loading