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,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;
}