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
+1 -1
View File
@@ -43,7 +43,7 @@ export function createPreviewRouter(service: Service = previewService) {
return router;
}
// These POSTs only query data (AI previews suppress gap recording). Everything else is denied by default.
// These POSTs do not change business data. AI previews suppress gap feedback but retain attributed question audit records.
const READ_POSTS = new Set(['/api/vehicles/analysis', '/api/vehicles/analysis/details', '/api/asset-assistant/ask', '/api/asset-assistant/export']);
export function createPreviewMiddleware(service: Service = previewService) {
return async (c: Context, next: Next) => {
@@ -1,3 +1,10 @@
import { beforeEach } from 'node:test';
import { questionLog } from './question-log.js';
beforeEach(t => {
if (!('mock' in t)) throw new Error('Expected test context');
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
t.mock.method(questionLog, 'finish', async () => {});
});
import assert from "node:assert/strict";
import test from "node:test";
import { Hono } from "hono";
@@ -1,3 +1,10 @@
import { beforeEach } from 'node:test';
import { questionLog } from './question-log.js';
beforeEach(t => {
if (!('mock' in t)) throw new Error('Expected test context');
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
t.mock.method(questionLog, 'finish', async () => {});
});
import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import { Hono } from "hono";
@@ -1,3 +1,10 @@
import { beforeEach } from 'node:test';
import { questionLog } from './question-log.js';
beforeEach(t => {
if (!('mock' in t)) throw new Error('Expected test context');
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
t.mock.method(questionLog, 'finish', async () => {});
});
import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import { Hono } from "hono";
@@ -1,3 +1,10 @@
import { beforeEach } from 'node:test';
import { questionLog } from './question-log.js';
beforeEach(t => {
if (!('mock' in t)) throw new Error('Expected test context');
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
t.mock.method(questionLog, 'finish', async () => {});
});
import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import { Hono } from "hono";
@@ -1,3 +1,10 @@
import { beforeEach } from 'node:test';
import { questionLog } from './question-log.js';
beforeEach(t => {
if (!('mock' in t)) throw new Error('Expected test context');
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
t.mock.method(questionLog, 'finish', async () => {});
});
import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import { Hono } from "hono";
+27 -1
View File
@@ -21,6 +21,7 @@ import {
} from "./session.js";
import { resultScope, followupSuggestions } from "./presentation.js";
import { gapStore } from "./gaps.js";
import { questionLog, questionOutcome, finishQuestionLog } from "./question-log.js";
import {
CATEGORY_LABELS,
GAP_STATUSES,
@@ -99,6 +100,10 @@ router.post("/feedback", async (c) => {
const session = getSession(c.get("user"), body.subject, body.sessionId),
node = getNode(session, body.resultId)!;
if (!node.flagged) {
const logId = await questionLog.start(c.get("user"), { message: node.question || node.demand,
subject: body.subject, sessionId: session.id, baseResultId: node.id }, "feedback");
const saved = await finishQuestionLog(logId, { outcome: "feedback", reason: "unsatisfied", resultId: node.id });
if (!saved) return c.json({ error: "反馈已接收,处理状态暂未保存,请稍后重试。" }, 503);
await gapStore.record(c.get("user").userId, node.demand, "unsatisfied");
node.flagged = true;
}
@@ -220,15 +225,24 @@ router.post("/ask", async (c) => {
)
return c.json({ error: "请输入有效问题或选择分析操作。" }, 400);
const input: AssistantRequest = { ...raw, subject: raw.subject || null };
let logId: string;
try {
logId = await questionLog.start(user, input);
} catch {
return c.json({ error: "提问记录暂不可用,本次查询未执行,请稍后重试。" }, 503);
}
const release = acquire(user.userId);
if (!release)
if (!release) {
await finishQuestionLog(logId, { outcome: "rejected", reason: "busy" });
return c.json({ error: "上一条查询仍在执行,请稍后重试。" }, 429);
}
let session, base;
try {
session = getSession(user, input.subject, input.sessionId);
base = getNode(session, input.baseResultId);
} catch {
release();
await finishQuestionLog(logId, { outcome: "rejected", reason: "session_expired" });
return c.json({ error: "会话已过期或范围已变化,请新建对话。" }, 409);
}
const currentSession = session,
@@ -238,6 +252,7 @@ router.post("/ask", async (c) => {
stream.onAbort(() => controller.abort());
const emit = (event: object) =>
stream.writeSSE({ data: JSON.stringify(event) });
let logFinished = false;
let demand = "查询执行",
reason: GapReason | undefined;
try {
@@ -516,8 +531,17 @@ router.post("/ask", async (c) => {
type: "step",
label: "分析完成",
});
logFinished = true;
const saved = await finishQuestionLog(logId, { outcome: questionOutcome(result, gapReason),
reason: gapReason, sessionId: currentSession.id, result });
if (!saved) result.warning = [result.warning, "提问已记录,处理结果暂未保存。"].filter(Boolean).join(" ");
await emit({ type: "result", result });
} catch {
if (!logFinished) {
logFinished = true;
await finishQuestionLog(logId, { outcome: controller.signal.aborted ? "cancelled" : "failed",
reason: controller.signal.aborted ? "client_aborted" : "generation", sessionId: currentSession.id });
}
if (!controller.signal.aborted) {
let recorded = false;
try {
@@ -534,6 +558,8 @@ router.post("/ask", async (c) => {
});
}
} finally {
if (!logFinished) await finishQuestionLog(logId, { outcome: controller.signal.aborted ? "cancelled" : "failed",
reason: controller.signal.aborted ? "client_aborted" : "unfinished", sessionId: currentSession.id });
release();
}
});
@@ -1,3 +1,10 @@
import { beforeEach } from 'node:test';
import { questionLog } from './question-log.js';
beforeEach(t => {
if (!('mock' in t)) throw new Error('Expected test context');
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
t.mock.method(questionLog, 'finish', async () => {});
});
import assert from "node:assert/strict";
import test from "node:test";
import { Hono } from "hono";
@@ -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();
});
@@ -0,0 +1,72 @@
import { randomUUID } from 'node:crypto';
import pool from '../../db.js';
import type { AuthUser } from '../../auth/types.js';
import type { AssistantRequest, AssistantResult, GapReason } from '../../../shared/assets/assistant.js';
export type QuestionOutcome = 'processing' | 'success' | 'empty' | 'partial' | 'clarification' | 'help' | 'unsupported' | 'failed' | 'cancelled' | 'rejected' | 'feedback';
export type QuestionCompletion = {
outcome: QuestionOutcome;
sessionId?: string;
resultId?: string;
result?: AssistantResult;
reason?: string;
};
// Credentials are never useful for question analysis. Keep the rest of the accepted question intact.
export function redactQuestion(value: string): string {
return value.replace(/sk-[a-z0-9_-]{8,}/gi, '[凭据已隐藏]')
.replace(/(Bearer\s+)[\w.+/=-]+/gi, '$1[凭据已隐藏]')
.replace(/((?:password|passwd|api[_-]?key|access[_-]?token|secret|密码|口令)\s*[:=:]\s*)[^\s,,;;]+/gi, '$1[凭据已隐藏]');
}
export function questionOutcome(result: AssistantResult, reason?: GapReason): QuestionOutcome {
if (result.outcome === 'partial' || result.unanswered?.length) return 'partial';
if (reason === 'unsupported' || result.outcome === 'unsupported') return 'unsupported';
if (reason === 'generation') return 'failed';
if (result.outcome === 'help' || result.answerKind === 'help') return 'help';
if (result.plan.kind === 'clarify') return 'clarification';
if (result.outcome === 'empty' || reason === 'empty') return 'empty';
return 'success';
}
export function businessTypes(result?: AssistantResult): string[] {
if (!result) return [];
const items = result.sections?.flatMap(section => section.result ? [section.result] : []) || [result];
return [...new Set(items.map(item => item.plan.kind === 'flow' ? 'flow' : item.plan.kind === 'clarify' ? 'general' : item.plan.category))];
}
export const questionLog = {
async start(user: AuthUser, input: AssistantRequest, eventType?: 'feedback'): Promise<string> {
const id = randomUUID();
await pool.query({ sql: `INSERT INTO bi_asset_ai_questions
(id,actor_id,user_id,login_name,user_name,permission_level,is_preview,preview_session_id,
session_id,base_result_id,subject,event_type,question,action_json,outcome,started_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,'processing',UTC_TIMESTAMP(3))`, timeout: 5000 }, [
id, user.preview?.actorId || user.userId, user.userId, user.loginName, user.userName,
user.permissionLevel, user.preview ? 1 : 0, user.preview?.sessionId || null,
input.sessionId || null, input.baseResultId || null, input.subject,
eventType || (input.action ? 'action' : input.sessionId ? 'followup' : 'question'),
input.message ? redactQuestion(input.message) : null,
input.action ? JSON.stringify(input.action, (_key, value) => typeof value === "string" ? redactQuestion(value) : value) : null,
]);
return id;
},
async finish(id: string, completion: QuestionCompletion): Promise<void> {
const result = completion.result;
// Persist the textual answer and classification, not a duplicate of vehicle detail datasets.
await pool.query({ sql: `UPDATE bi_asset_ai_questions SET outcome=?,reason=?,session_id=COALESCE(?,session_id),
result_id=?,business_types=?,engine=?,answer=?,finished_at=UTC_TIMESTAMP(3),
duration_ms=TIMESTAMPDIFF(MICROSECOND,started_at,UTC_TIMESTAMP(3)) DIV 1000
WHERE id=? AND outcome='processing'`, timeout: 5000 }, [
completion.outcome, completion.reason || null, completion.sessionId || null,
completion.resultId || result?.resultId || null, JSON.stringify(businessTypes(result)),
result?.engine || null, result ? redactQuestion(JSON.stringify({ summary: result.summary, warning: result.warning,
unanswered: result.unanswered, sections: result.sections?.map(section => ({ title: section.title, summary: section.result?.summary })) })) : null, id,
]);
},
};
/** A failed update never erases the durable original question. Retry once and expose failure to the caller. */
export async function finishQuestionLog(id: string, completion: QuestionCompletion): Promise<boolean> {
for (let attempt = 0; attempt < 2; attempt++) {
try { await questionLog.finish(id, completion); return true; } catch { /* retry transient failures */ }
}
console.error('asset_ai_question_completion_failed', id);
return false;
}
@@ -1,3 +1,10 @@
import { beforeEach } from 'node:test';
import { questionLog } from './question-log.js';
beforeEach(t => {
if (!('mock' in t)) throw new Error('Expected test context');
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
t.mock.method(questionLog, 'finish', async () => {});
});
import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import { Hono } from "hono";