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