feat(assets): 优化移动端分析体验并记录 AI 提问日志

This commit is contained in:
shishengliang
2026-10-13 16:24:28 +08:00
parent 39ecde55d4
commit 4dec824285
28 changed files with 781 additions and 86 deletions
@@ -0,0 +1,160 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import pool from '../../db.js';
import router from './index.js';
import { questionLog, questionOutcome, businessTypes, finishQuestionLog, redactQuestion, type QuestionCompletion } from './question-log.js';
import { vehicleRepository } from '../vehicles/repository.js';
import { gapStore } from './gaps.js';
import { acquire } from './session.js';
import type { AuthUser } from '../../auth/types.js';
import type { AssistantResult } from '../../../shared/assets/assistant.js';
const user: AuthUser = { userId: 'log-test', userName: '测试用户', loginName: 'test', roles: [], permissionLevel: 'personal', depCode: 'D', depName: '测试部门' };
const result = (extra: Partial<AssistantResult> = {}): AssistantResult => ({
plan: { kind: 'assets', category: 'inventory', groupBy: 'region', view: 'table', filters: {} },
title: '库存', summary: '库存共 2 台', rows: [], groups: [], metrics: [], source: 'test', fetchedAt: '', ...extra,
});
function appFor(u = user) {
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use('*', async (c, next) => { c.set('user', u); await next(); });
app.route('/ai', router);
return app;
}
function ask(app: ReturnType<typeof appFor>, body: object = { message: '库存有多少车?', subject: null }) {
return app.request('/ai/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
}
test('each question has its own durable ID, full text and attributed preview identity; SQL is parameterized', async t => {
const calls: { sql: string; values: unknown[] }[] = [];
t.mock.method(pool, 'query', async (options: { sql: string }, values: unknown[]) => { calls.push({ sql: options.sql, values }); return [{ affectedRows: 1 }, []]; });
const preview = { ...user, preview: { actorId: 'real-actor', sessionId: 'preview-session' } };
const message = '按区域统计库存'.repeat(100) + " ' OR 1=1 --";
const first = await questionLog.start(preview, { message, subject: '公司甲' });
const second = await questionLog.start(preview, { message, subject: '公司甲' });
assert.notEqual(first, second);
assert.equal(calls[0].values[1], 'real-actor');
assert.equal(calls[0].values[2], user.userId);
assert.equal(calls[0].values[6], 1);
assert.equal(calls[0].values[12], message);
assert.ok(!calls[0].sql.includes(message));
await questionLog.finish(first, { outcome: 'success', sessionId: 'session', result: result() });
assert.match(calls[2].sql, /outcome='processing'/);
assert.equal(calls[2].values[4], '["inventory"]');
assert.match(String(calls[2].values[6]), /库存共 2 台/);
assert.ok(!String(calls[2].values[6]).includes('rows'));
});
test('outcomes and business categories separate partial, help, empty, unsupported and failure', () => {
assert.equal(questionOutcome(result()), 'success');
for (const value of ['partial', 'empty', 'unsupported', 'help', 'clarification'] as const) {
const r = result({ outcome: value });
if (value === 'clarification') r.plan.kind = 'clarify';
assert.equal(questionOutcome(r), value);
}
assert.equal(questionOutcome(result(), 'generation'), 'failed');
assert.deepEqual(businessTypes(result({ sections: [
{ id: 'a', title: '库存', question: '库存', display: 'table', result: result() },
{ id: 'b', title: '交车', question: '交车', display: 'table', result: result({ plan: { ...result().plan, kind: 'flow' } }) },
] })), ['inventory', 'flow']);
assert.equal(redactQuestion('库存 sk-12345678 password=secret123 Bearer token123'), '库存 [凭据已隐藏] password=[凭据已隐藏] Bearer [凭据已隐藏]');
});
test('storage outage rejects the request before querying data or invoking AI', async t => {
t.mock.method(questionLog, 'start', async () => { throw new Error('private DB error'); });
const query = t.mock.method(vehicleRepository, 'getVehicles', async () => []);
const response = await ask(appFor());
assert.equal(response.status, 503);
assert.equal(query.mock.callCount(), 0);
assert.ok(!(await response.text()).includes('private DB'));
});
test('concurrency rejection and expired session are still durable classified questions', async t => {
const start = t.mock.method(questionLog, 'start', async () => 'id');
const finish = t.mock.method(questionLog, 'finish', async (_id: string, _completion: QuestionCompletion) => {});
const release = acquire(user.userId)!;
try { assert.equal((await ask(appFor())).status, 429); } finally { release(); }
assert.equal((await ask(appFor(), { message: '追问', subject: null, sessionId: 'missing' })).status, 409);
assert.equal(start.mock.callCount(), 2);
assert.equal(finish.mock.calls[0].arguments[1].reason, 'busy');
assert.equal(finish.mock.calls[1].arguments[1].reason, 'session_expired');
});
test('failed queries are logged for preview users without creating gap feedback', async t => {
const start = t.mock.method(questionLog, 'start', async () => 'id');
const finish = t.mock.method(questionLog, 'finish', async (_id: string, _completion: QuestionCompletion) => {});
const gap = t.mock.method(gapStore, 'record', async () => {});
t.mock.method(vehicleRepository, 'getVehicles', async () => { throw new Error('private failure'); });
const response = await ask(appFor({ ...user, preview: { actorId: 'actor', sessionId: 'preview' } }));
const text = await response.text();
assert.match(text, /查询未完成/);
assert.ok(!text.includes('private failure'));
assert.equal(start.mock.callCount(), 1);
assert.equal(finish.mock.calls[0].arguments[1].outcome, 'failed');
assert.equal(gap.mock.callCount(), 0);
});
test('finalization retries transient failures and reports persistent failures', async t => {
let calls = 0, alwaysFail = false;
t.mock.method(questionLog, 'finish', async () => { if (++calls === 1 || alwaysFail) throw new Error('transient'); });
assert.equal(await finishQuestionLog('id', { outcome: 'success' }), true);
assert.equal(calls, 2);
alwaysFail = true;
const warning = t.mock.method(console, 'error', () => {});
assert.equal(await finishQuestionLog('id', { outcome: 'failed' }), false);
assert.deepEqual(warning.mock.calls[0].arguments, ['asset_ai_question_completion_failed', 'id']);
});
test('successful questions, follow-ups, actions and feedback keep distinct operation types', async t => {
const statements: unknown[][] = [];
t.mock.method(pool, 'query', async (options: { sql?: string } | string, values: unknown[]) => {
if (typeof options !== 'string' && options.sql?.includes('bi_asset_ai_questions')) statements.push(values);
return [[], []];
});
t.mock.method(vehicleRepository, 'getVehicles', async () => []);
t.mock.method(gapStore, 'record', async () => {});
const old = process.env.ASSET_AI_EXTERNAL_ENABLED;
process.env.ASSET_AI_EXTERNAL_ENABLED = '0';
t.after(() => { if (old === undefined) delete process.env.ASSET_AI_EXTERNAL_ENABLED; else process.env.ASSET_AI_EXTERNAL_ENABLED = old; });
const app = appFor({ ...user, userId: 'success-test' });
const response = await ask(app);
const events = (await response.text()).split('\n').filter(line => line.startsWith('data:')).map(line => JSON.parse(line.slice(5)));
const r = events.find(event => event.type === 'result')?.result as AssistantResult;
assert.ok(r, JSON.stringify(events));
assert.equal(statements[0][11], 'question');
assert.ok(['success', 'empty'].includes(String(statements[1][0])));
assert.equal(statements[1][2], r.sessionId);
assert.equal(statements[1][3], r.resultId);
assert.deepEqual(JSON.parse(String(statements[1][4])), ['inventory']);
await questionLog.start(user, { message: '按区域看', subject: null, sessionId: r.sessionId });
assert.equal(statements.at(-1)![11], 'followup');
await questionLog.start(user, { subject: null, sessionId: r.sessionId, baseResultId: r.resultId, action: { type: 'category', category: 'abnormal' } });
assert.equal(statements.at(-1)![11], 'action');
assert.deepEqual(JSON.parse(String(statements.at(-1)![13])), { type: 'category', category: 'abnormal' });
const feedback = await app.request('/ai/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: r.sessionId, resultId: r.resultId, subject: null }) });
assert.equal(feedback.status, 200);
assert.equal(statements.at(-2)![11], 'feedback');
assert.equal(statements.at(-1)![0], 'feedback');
});
test('client cancellation finalizes the durable question and releases the user query lock', async t => {
t.mock.method(questionLog, 'start', async () => 'cancel-id');
let complete!: (value: QuestionCompletion) => void;
const completed = new Promise<QuestionCompletion>(resolve => { complete = resolve; });
t.mock.method(questionLog, 'finish', async (_id: string, completion: QuestionCompletion) => { complete(completion); });
let continueQuery!: () => void;
t.mock.method(vehicleRepository, 'getVehicles', async () => { await new Promise<void>(resolve => { continueQuery = resolve; }); return []; });
const cancelledUser = { ...user, userId: 'cancel-test' };
const response = await ask(appFor(cancelledUser));
const reader = response.body!.getReader();
await reader.read();
await reader.cancel();
continueQuery();
const completion = await completed;
assert.equal(completion.outcome, 'cancelled');
// Finalizer releases the lock after the asynchronous database update.
await new Promise(resolve => setImmediate(resolve));
const release = acquire(cancelledUser.userId);
assert.ok(release);
release();
});