73 lines
4.3 KiB
TypeScript
73 lines
4.3 KiB
TypeScript
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;
|
||
}
|