consolidation of AI services
This commit is contained in:
parent
d043bba17f
commit
7b636ae051
File diff suppressed because it is too large
Load Diff
131
src/components/ui/AIQueryForm.astro
Normal file
131
src/components/ui/AIQueryForm.astro
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
// src/components/ui/AIQueryForm.astro
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="ai-query-section">
|
||||||
|
<div class="content-center-lg">
|
||||||
|
<h2 style="margin-bottom: 1rem; color: var(--color-primary);">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.75rem; vertical-align: middle;">
|
||||||
|
<path d="M9 11H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2h-4"/>
|
||||||
|
<path d="M9 11V7a3 3 0 0 1 6 0v4"/>
|
||||||
|
</svg>
|
||||||
|
Forensic AI
|
||||||
|
</h2>
|
||||||
|
<p id="ai-description" class="text-muted" style="max-width: 700px; margin: 0 auto; line-height: 1.6;">
|
||||||
|
Beschreiben Sie Ihr forensisches Szenario und erhalten Sie maßgeschneiderte Workflow-Empfehlungen
|
||||||
|
basierend auf bewährten DFIR-Workflows und der verfügbaren Software-Datenbank.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-input-container" style="max-width: 1000px; margin: 0 auto;">
|
||||||
|
<!-- Mode Toggle -->
|
||||||
|
<div class="ai-mode-toggle">
|
||||||
|
<span id="workflow-label" class="toggle-label active">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2 align-middle">
|
||||||
|
<polyline points="9,11 12,14 22,4"/>
|
||||||
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
||||||
|
</svg>
|
||||||
|
Workflow-Empfehlung
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="toggle-switch">
|
||||||
|
<div class="toggle-slider"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span id="tool-label" class="toggle-label">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2 align-middle">
|
||||||
|
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||||
|
</svg>
|
||||||
|
Spezifische Software oder Methode
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input Layout -->
|
||||||
|
<div class="ai-input-layout">
|
||||||
|
<div class="ai-textarea-section">
|
||||||
|
<textarea
|
||||||
|
id="ai-query-input"
|
||||||
|
placeholder="Beschreiben Sie Ihr forensisches Szenario..."
|
||||||
|
style="min-height: 220px; resize: vertical; font-size: 0.9375rem; line-height: 1.5;"
|
||||||
|
maxlength="2000"
|
||||||
|
></textarea>
|
||||||
|
<div id="ai-char-counter" style="font-size: 0.75rem; color: var(--color-text-secondary); text-align: right; margin-top: 0.25rem;">0/2000</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-suggestions-section">
|
||||||
|
<div id="smart-prompting-hint" class="smart-prompting-hint">
|
||||||
|
<div class="hint-card">
|
||||||
|
<div class="hint-icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="hint-content">
|
||||||
|
<h4 class="hint-title">Intelligente Hilfe</h4>
|
||||||
|
<p class="hint-description">
|
||||||
|
Während Sie tippen, analysiert die KI Ihre Eingabe und schlägt gezielten Fragen vor.
|
||||||
|
</p>
|
||||||
|
<div class="hint-trigger">
|
||||||
|
<span class="hint-label">Aktiviert ab:</span>
|
||||||
|
<span class="hint-value">40+ Zeichen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="smart-prompting-container" class="smart-prompting-container hidden">
|
||||||
|
<div class="prompting-card">
|
||||||
|
<div id="prompting-status" class="prompting-status">
|
||||||
|
<div class="status-icon">💡</div>
|
||||||
|
<span class="status-text">Analysiere Eingabe...</span>
|
||||||
|
<div id="prompting-spinner" class="prompting-spinner hidden">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2">
|
||||||
|
<path d="M21 12a9 9 0 11-6.219-8.56"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="suggested-questions" class="suggested-questions hidden">
|
||||||
|
<div class="suggestions-header">
|
||||||
|
<span class="suggestions-label">Zur besseren Analyse:</span>
|
||||||
|
</div>
|
||||||
|
<div id="questions-list" class="questions-list"></div>
|
||||||
|
<button id="dismiss-suggestions" class="dismiss-button">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Privacy Notice -->
|
||||||
|
<div style="margin-top: 0.5rem; margin-bottom: 1rem;">
|
||||||
|
<p style="font-size: 0.75rem; color: var(--color-text-secondary); text-align: center; line-height: 1.4;">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.25rem; vertical-align: middle;">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
Ihre Anfrage wird über die API von mistral.ai übertragen.
|
||||||
|
<a href="https://mistral.ai/privacy-policy/" target="_blank" rel="noopener noreferrer" style="color: var(--color-primary);">Datenschutzrichtlinien</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div style="display: flex; justify-content: center; gap: 1rem;">
|
||||||
|
<button id="ai-submit-btn" class="btn btn-accent" style="padding: 0.75rem 2rem;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
||||||
|
<path d="M14.828 14.828a4 4 0 0 1-5.656 0"/>
|
||||||
|
<path d="M9 9a3 3 0 1 1 6 0c0 .749-.269 1.433-.73 1.96L11 14v1a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1v-1l-3.27-3.04A3 3 0 0 1 5 9a3 3 0 0 1 6 0"/>
|
||||||
|
</svg>
|
||||||
|
<span id="submit-btn-text">Empfehlungen generieren</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
78
src/components/ui/AIResults.astro
Normal file
78
src/components/ui/AIResults.astro
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
// src/components/ui/AIResults.astro
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div id="ai-loading" class="ai-loading hidden">
|
||||||
|
<div style="text-align: center; padding: 2rem;">
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2" style="animation: pulse 2s ease-in-out infinite;">
|
||||||
|
<path d="M14.828 14.828a4 4 0 0 1-5.656 0"/>
|
||||||
|
<path d="M9 9a3 3 0 1 1 6 0c0 .749-.269 1.433-.73 1.96L11 14v1a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1v-1l-3.27-3.04A3 3 0 0 1 5 9a3 3 0 0 1 6 0"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p id="loading-text" class="text-secondary">Analysiere Szenario und generiere Empfehlungen...</p>
|
||||||
|
|
||||||
|
<!-- Queue Status -->
|
||||||
|
<div id="queue-status" class="queue-status-card hidden">
|
||||||
|
<div class="queue-header">
|
||||||
|
<div class="queue-position-display">
|
||||||
|
<div id="queue-position-badge" class="position-badge">1</div>
|
||||||
|
<span class="position-label">Position in Warteschlange</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="queue-details">
|
||||||
|
<div class="queue-stat">
|
||||||
|
<span class="stat-label">Warteschlange:</span>
|
||||||
|
<span id="queue-length" class="stat-value">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="queue-stat">
|
||||||
|
<span class="stat-label">Geschätzte Zeit:</span>
|
||||||
|
<span id="estimated-time" class="stat-value">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Micro-task Progress -->
|
||||||
|
<div id="micro-task-progress" class="micro-task-progress hidden">
|
||||||
|
<div class="micro-task-header">
|
||||||
|
<span class="micro-task-label">🔬 micro-Agent-Analysis</span>
|
||||||
|
<span id="micro-task-counter" class="micro-task-counter">1/6</span>
|
||||||
|
</div>
|
||||||
|
<div class="micro-task-steps">
|
||||||
|
<div class="micro-step" data-step="scenario">📋 Problemanalyse</div>
|
||||||
|
<div class="micro-step" data-step="approach">🎯 Ermittlungsansatz</div>
|
||||||
|
<div class="micro-step" data-step="considerations">⚠️ Herausforderungen</div>
|
||||||
|
<div class="micro-step" data-step="tools">🔧 Methoden</div>
|
||||||
|
<div class="micro-step" data-step="knowledge">📚 Evaluation</div>
|
||||||
|
<div class="micro-step" data-step="final">✅ Audit-Trail</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="queue-progress-container">
|
||||||
|
<div class="queue-progress-track">
|
||||||
|
<div id="queue-progress" class="queue-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="task-id-display">
|
||||||
|
<span class="task-label">Task:</span>
|
||||||
|
<code id="current-task-id" class="task-id">--</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div id="ai-error" class="ai-error hidden">
|
||||||
|
<div style="text-align: center; padding: 2rem;">
|
||||||
|
<div style="background-color: var(--color-error); color: white; padding: 1rem; border-radius: 0.5rem; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h3 style="margin-bottom: 0.5rem;">Fehler bei der KI-Anfrage</h3>
|
||||||
|
<p id="ai-error-message" style="margin: 0;">Ein unerwarteter Fehler ist aufgetreten.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div id="ai-results" class="ai-results hidden">
|
||||||
|
<!-- Results content will be populated by JavaScript -->
|
||||||
|
</div>
|
370
src/components/ui/AuditTrailView.astro
Normal file
370
src/components/ui/AuditTrailView.astro
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
---
|
||||||
|
// src/components/ui/AuditTrailView.astro
|
||||||
|
/// <reference types="../../env" />
|
||||||
|
---
|
||||||
|
|
||||||
|
<div id="audit-trail-container"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Types for the audit trail system
|
||||||
|
interface AuditTrailOptions {
|
||||||
|
title?: string;
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessedAuditTrail {
|
||||||
|
totalTime: number;
|
||||||
|
avgConfidence: number;
|
||||||
|
stepCount: number;
|
||||||
|
highConfidenceSteps: number;
|
||||||
|
lowConfidenceSteps: number;
|
||||||
|
phases: Array<{
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
displayName: string;
|
||||||
|
avgConfidence: number;
|
||||||
|
totalTime: number;
|
||||||
|
entries: CompressedAuditEntry[];
|
||||||
|
}>;
|
||||||
|
summary: {
|
||||||
|
analysisQuality: 'excellent' | 'good' | 'fair' | 'poor';
|
||||||
|
keyInsights: string[];
|
||||||
|
potentialIssues: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompressedAuditEntry {
|
||||||
|
timestamp: number;
|
||||||
|
phase: string;
|
||||||
|
action: string;
|
||||||
|
inputSummary: string;
|
||||||
|
outputSummary: string;
|
||||||
|
confidence: number;
|
||||||
|
processingTimeMs: number;
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit Trail Renderer Class with proper typing
|
||||||
|
class AuditTrailRenderer {
|
||||||
|
public containerId: string;
|
||||||
|
public options: AuditTrailOptions & {
|
||||||
|
title: string;
|
||||||
|
collapsible: boolean;
|
||||||
|
defaultExpanded: boolean;
|
||||||
|
};
|
||||||
|
componentId: string;
|
||||||
|
|
||||||
|
constructor(containerId: string, options: AuditTrailOptions = {}) {
|
||||||
|
this.containerId = containerId;
|
||||||
|
this.options = {
|
||||||
|
title: options.title || 'KI-Entscheidungspfad',
|
||||||
|
collapsible: options.collapsible !== false,
|
||||||
|
defaultExpanded: options.defaultExpanded || false,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
this.componentId = `audit-trail-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(processedAudit: ProcessedAuditTrail | null): void {
|
||||||
|
const container = document.getElementById(this.containerId);
|
||||||
|
if (!container) {
|
||||||
|
console.error(`[AUDIT RENDERER] Container ${this.containerId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!processedAudit || !processedAudit.phases || processedAudit.phases.length === 0) {
|
||||||
|
this.renderEmpty(container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.renderProcessed(container, processedAudit);
|
||||||
|
setTimeout(() => this.attachEventHandlers(), 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AUDIT RENDERER] Failed to render audit trail:', error);
|
||||||
|
this.renderError(container, error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderProcessed(container: HTMLElement, processedAudit: ProcessedAuditTrail): void {
|
||||||
|
const detailsId = `${this.componentId}-details`;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="audit-trail-container">
|
||||||
|
<div class="audit-trail-header ${this.options.collapsible ? 'clickable' : ''}"
|
||||||
|
${this.options.collapsible ? `data-target="${detailsId}"` : ''}>
|
||||||
|
<div class="audit-trail-title">
|
||||||
|
<div class="audit-icon">
|
||||||
|
<div class="audit-icon-gradient">✓</div>
|
||||||
|
<h4>${this.options.title}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="audit-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-dot stat-time"></div>
|
||||||
|
<span>${this.formatDuration(processedAudit.totalTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-dot" style="background-color: ${this.getConfidenceColor(processedAudit.avgConfidence)}"></div>
|
||||||
|
<span>${processedAudit.avgConfidence}% Vertrauen</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span>${processedAudit.stepCount} Schritte</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.options.collapsible ? `
|
||||||
|
<div class="toggle-icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="${detailsId}" class="audit-trail-details ${this.options.collapsible && !this.options.defaultExpanded ? 'collapsed' : ''}">
|
||||||
|
${this.renderSummary(processedAudit)}
|
||||||
|
${this.renderProcessFlow(processedAudit)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSummary(audit: ProcessedAuditTrail): string {
|
||||||
|
return `
|
||||||
|
<div class="audit-summary">
|
||||||
|
<div class="summary-header">📊 Analyse-Qualität</div>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-stat">
|
||||||
|
<div class="summary-value success">${audit.highConfidenceSteps}</div>
|
||||||
|
<div class="summary-label">Hohe Sicherheit</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-stat">
|
||||||
|
<div class="summary-value ${audit.lowConfidenceSteps > 0 ? 'warning' : 'success'}">
|
||||||
|
${audit.lowConfidenceSteps}
|
||||||
|
</div>
|
||||||
|
<div class="summary-label">Unsichere Schritte</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-stat">
|
||||||
|
<div class="summary-value">${this.formatDuration(audit.totalTime)}</div>
|
||||||
|
<div class="summary-label">Verarbeitungszeit</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${audit.summary.keyInsights && audit.summary.keyInsights.length > 0 ? `
|
||||||
|
<div class="insights-section">
|
||||||
|
<div class="insights-header success">✓ Erkenntnisse:</div>
|
||||||
|
<ul class="insights-list">
|
||||||
|
${audit.summary.keyInsights.map(insight => `<li>${this.escapeHtml(insight)}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${audit.summary.potentialIssues && audit.summary.potentialIssues.length > 0 ? `
|
||||||
|
<div class="insights-section">
|
||||||
|
<div class="insights-header warning">⚠ Hinweise:</div>
|
||||||
|
<ul class="insights-list">
|
||||||
|
${audit.summary.potentialIssues.map(issue => `<li>${this.escapeHtml(issue)}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderProcessFlow(audit: ProcessedAuditTrail): string {
|
||||||
|
if (!audit.phases || audit.phases.length === 0) {
|
||||||
|
return '<div class="audit-process-flow"><p>Keine Phasen verfügbar</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="audit-process-flow">
|
||||||
|
${audit.phases.map((phase, index) => `
|
||||||
|
<div class="phase-group ${index === audit.phases.length - 1 ? 'last-phase' : ''}">
|
||||||
|
<div class="phase-header" style="display: flex; align-items: center; gap: 1rem; margin-bottom: 0.75rem;">
|
||||||
|
<div class="phase-info" style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
|
<span class="phase-icon" style="font-size: 1rem;">${phase.icon || '📋'}</span>
|
||||||
|
<span class="phase-name" style="font-size: 0.875rem; font-weight: 500; color: var(--color-text);">${phase.displayName || phase.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="phase-divider" style="flex: 1; height: 1px; background-color: var(--color-border);"></div>
|
||||||
|
<div class="phase-stats" style="display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;">
|
||||||
|
<div class="confidence-bar" style="width: 48px; height: 8px; background-color: var(--color-bg-tertiary); border-radius: 4px; overflow: hidden;">
|
||||||
|
<div class="confidence-fill"
|
||||||
|
style="height: 100%; width: ${phase.avgConfidence || 0}%; background-color: ${this.getConfidenceColor(phase.avgConfidence || 0)}; border-radius: 4px; transition: var(--transition-fast);">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="confidence-text" style="font-size: 0.75rem; color: var(--color-text-secondary); min-width: 28px;">${phase.avgConfidence || 0}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="phase-entries" style="margin-left: 1.5rem; display: grid; gap: 0.5rem;">
|
||||||
|
${(phase.entries || []).map(entry => this.renderAuditEntry(entry)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAuditEntry(entry: CompressedAuditEntry): string {
|
||||||
|
return `
|
||||||
|
<div class="audit-entry" style="background-color: var(--color-bg); border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.75rem; transition: var(--transition-fast);"
|
||||||
|
onmouseover="this.style.backgroundColor='var(--color-bg-secondary)'"
|
||||||
|
onmouseout="this.style.backgroundColor='var(--color-bg)'">
|
||||||
|
<div class="entry-main" style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
|
||||||
|
<span class="entry-action" style="font-size: 0.875rem; font-weight: 500; color: var(--color-text);">
|
||||||
|
${this.getActionDisplayName(entry.action)}
|
||||||
|
</span>
|
||||||
|
<div class="entry-meta" style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--color-text-secondary);">
|
||||||
|
<div class="confidence-indicator"
|
||||||
|
style="width: 8px; height: 8px; border-radius: 50%; background-color: ${this.getConfidenceColor(entry.confidence || 0)};">
|
||||||
|
</div>
|
||||||
|
<span class="confidence-value" style="min-width: 28px; text-align: right;">${entry.confidence || 0}%</span>
|
||||||
|
<span class="processing-time" style="min-width: 40px; text-align: right;">${entry.processingTimeMs || 0}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${(entry.inputSummary && entry.inputSummary !== 'null') || (entry.outputSummary && entry.outputSummary !== 'null') ? `
|
||||||
|
<div class="entry-details" style="font-size: 0.75rem; color: var(--color-text-secondary); padding-top: 0.5rem; border-top: 1px solid var(--color-border);">
|
||||||
|
${entry.inputSummary && entry.inputSummary !== 'null' ? `
|
||||||
|
<div class="detail-item" style="margin-bottom: 0.25rem; word-break: break-word;">
|
||||||
|
<strong>Input:</strong> ${this.escapeHtml(entry.inputSummary)}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${entry.outputSummary && entry.outputSummary !== 'null' ? `
|
||||||
|
<div class="detail-item" style="margin-bottom: 0.25rem; word-break: break-word;">
|
||||||
|
<strong>Output:</strong> ${this.escapeHtml(entry.outputSummary)}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getActionDisplayName(action: string): string {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'pipeline-start': 'Analyse gestartet',
|
||||||
|
'embeddings-search': 'Ähnliche Tools gesucht',
|
||||||
|
'ai-tool-selection': 'Tools automatisch ausgewählt',
|
||||||
|
'ai-analysis': 'KI-Analyse durchgeführt',
|
||||||
|
'phase-tool-selection': 'Phasen-Tools evaluiert',
|
||||||
|
'tool-evaluation': 'Tool-Bewertung erstellt',
|
||||||
|
'background-knowledge-selection': 'Hintergrundwissen ausgewählt',
|
||||||
|
'confidence-scoring': 'Vertrauenswertung berechnet',
|
||||||
|
'pipeline-end': 'Analyse abgeschlossen'
|
||||||
|
};
|
||||||
|
return translations[action] || action;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderEmpty(container: HTMLElement): void {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="audit-trail-container">
|
||||||
|
<div class="audit-trail-header">
|
||||||
|
<div class="audit-trail-title">
|
||||||
|
<div class="audit-icon">
|
||||||
|
<div class="audit-icon-gradient">ⓘ</div>
|
||||||
|
<h4>Kein Audit-Trail verfügbar</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderError(container: HTMLElement, error: Error): void {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="audit-trail-container">
|
||||||
|
<div class="audit-trail-header">
|
||||||
|
<div class="audit-trail-title">
|
||||||
|
<div class="audit-icon">
|
||||||
|
<div class="audit-icon-gradient" style="background: var(--color-error);">✗</div>
|
||||||
|
<h4>Audit-Trail Fehler</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="audit-summary">
|
||||||
|
<p style="color: var(--color-error);">
|
||||||
|
Fehler beim Laden der Audit-Informationen: ${this.escapeHtml(error.message)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachEventHandlers(): void {
|
||||||
|
if (this.options.collapsible) {
|
||||||
|
const header = document.querySelector(`[data-target="${this.componentId}-details"]`) as HTMLElement;
|
||||||
|
const details = document.getElementById(`${this.componentId}-details`);
|
||||||
|
const toggleIcon = header?.querySelector('.toggle-icon svg') as SVGElement;
|
||||||
|
|
||||||
|
if (header && details && toggleIcon) {
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
const isCollapsed = details.classList.contains('collapsed');
|
||||||
|
details.classList.toggle('collapsed');
|
||||||
|
toggleIcon.style.transform = isCollapsed ? 'rotate(180deg)' : 'rotate(0deg)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use global utilities if available, fallback to local implementations
|
||||||
|
formatDuration(ms: number): string {
|
||||||
|
if (typeof window !== 'undefined' && window.AuditUtils && window.AuditUtils.formatDuration) {
|
||||||
|
return window.AuditUtils.formatDuration(ms);
|
||||||
|
}
|
||||||
|
// Fallback implementation
|
||||||
|
if (ms < 1000) return '< 1s';
|
||||||
|
if (ms < 60000) return `${Math.ceil(ms / 1000)}s`;
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const seconds = Math.ceil((ms % 60000) / 1000);
|
||||||
|
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfidenceColor(confidence: number): string {
|
||||||
|
if (typeof window !== 'undefined' && window.AuditUtils && window.AuditUtils.getConfidenceColor) {
|
||||||
|
return window.AuditUtils.getConfidenceColor(confidence);
|
||||||
|
}
|
||||||
|
// Fallback implementation
|
||||||
|
if (confidence >= 80) return 'var(--color-accent)';
|
||||||
|
if (confidence >= 60) return 'var(--color-warning)';
|
||||||
|
return 'var(--color-error)';
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text: string): string {
|
||||||
|
if (typeof window !== 'undefined' && window.AuditUtils && window.AuditUtils.escapeHtml) {
|
||||||
|
return window.AuditUtils.escapeHtml(text);
|
||||||
|
}
|
||||||
|
// Fallback implementation
|
||||||
|
if (typeof text !== 'string') return String(text);
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
AuditTrailRenderer?: new (
|
||||||
|
containerId: string,
|
||||||
|
options?: {
|
||||||
|
title?: string;
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
containerId: string;
|
||||||
|
options: {
|
||||||
|
title: string;
|
||||||
|
collapsible: boolean;
|
||||||
|
defaultExpanded: boolean;
|
||||||
|
};
|
||||||
|
render: (processedAudit: ProcessedAuditTrail | null) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.AuditTrailRenderer = AuditTrailRenderer;
|
||||||
|
</script>
|
74
src/config/appConfig.ts
Normal file
74
src/config/appConfig.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
// src/config/appConfig.ts
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
function getRequiredEnv(key: string): string {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required environment variable: ${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnvInt(key: string, defaultValue: number): number {
|
||||||
|
const value = process.env[key];
|
||||||
|
return value ? parseInt(value, 10) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnvFloat(key: string, defaultValue: number): number {
|
||||||
|
const value = process.env[key];
|
||||||
|
return value ? parseFloat(value) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnvBool(key: string, defaultValue: boolean): boolean {
|
||||||
|
const value = process.env[key];
|
||||||
|
return value ? value === 'true' : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
ai: {
|
||||||
|
endpoint: getRequiredEnv('AI_ANALYZER_ENDPOINT'),
|
||||||
|
apiKey: getRequiredEnv('AI_ANALYZER_API_KEY'),
|
||||||
|
model: getRequiredEnv('AI_ANALYZER_MODEL'),
|
||||||
|
maxSelectedItems: getEnvInt('AI_MAX_SELECTED_ITEMS', 25),
|
||||||
|
embeddingCandidates: getEnvInt('AI_EMBEDDING_CANDIDATES', 50),
|
||||||
|
similarityThreshold: getEnvFloat('AI_SIMILARITY_THRESHOLD', 0.3),
|
||||||
|
microTaskDelay: getEnvInt('AI_MICRO_TASK_DELAY_MS', 500),
|
||||||
|
embeddingSelectionLimit: getEnvInt('AI_EMBEDDING_SELECTION_LIMIT', 30),
|
||||||
|
embeddingConceptsLimit: getEnvInt('AI_EMBEDDING_CONCEPTS_LIMIT', 15),
|
||||||
|
noEmbeddingsToolLimit: getEnvInt('AI_NO_EMBEDDINGS_TOOL_LIMIT', 25),
|
||||||
|
noEmbeddingsConceptLimit: getEnvInt('AI_NO_EMBEDDINGS_CONCEPT_LIMIT', 10),
|
||||||
|
embeddingsMinTools: getEnvInt('AI_EMBEDDINGS_MIN_TOOLS', 8),
|
||||||
|
embeddingsMaxReductionRatio: getEnvFloat('AI_EMBEDDINGS_MAX_REDUCTION_RATIO', 0.75),
|
||||||
|
methodSelectionRatio: getEnvFloat('AI_METHOD_SELECTION_RATIO', 0.4),
|
||||||
|
softwareSelectionRatio: getEnvFloat('AI_SOFTWARE_SELECTION_RATIO', 0.5),
|
||||||
|
maxContextTokens: getEnvInt('AI_MAX_CONTEXT_TOKENS', 4000),
|
||||||
|
maxPromptTokens: getEnvInt('AI_MAX_PROMPT_TOKENS', 1500),
|
||||||
|
taskTimeout: getEnvInt('AI_TASK_TIMEOUT_MS', 300000)
|
||||||
|
},
|
||||||
|
|
||||||
|
audit: {
|
||||||
|
enabled: getEnvBool('FORENSIC_AUDIT_ENABLED', false),
|
||||||
|
detailLevel: (process.env.FORENSIC_AUDIT_DETAIL_LEVEL || 'standard') as 'minimal' | 'standard' | 'verbose',
|
||||||
|
retentionHours: getEnvInt('FORENSIC_AUDIT_RETENTION_HOURS', 72),
|
||||||
|
maxEntries: getEnvInt('FORENSIC_AUDIT_MAX_ENTRIES', 50)
|
||||||
|
},
|
||||||
|
|
||||||
|
confidence: {
|
||||||
|
semanticWeight: getEnvFloat('CONFIDENCE_SEMANTIC_WEIGHT', 0.3),
|
||||||
|
suitabilityWeight: getEnvFloat('CONFIDENCE_SUITABILITY_WEIGHT', 0.7),
|
||||||
|
minimumThreshold: getEnvInt('CONFIDENCE_MINIMUM_THRESHOLD', 40),
|
||||||
|
mediumThreshold: getEnvInt('CONFIDENCE_MEDIUM_THRESHOLD', 60),
|
||||||
|
highThreshold: getEnvInt('CONFIDENCE_HIGH_THRESHOLD', 80)
|
||||||
|
},
|
||||||
|
|
||||||
|
rateLimit: {
|
||||||
|
delayMs: getEnvInt('AI_RATE_LIMIT_DELAY_MS', 2000),
|
||||||
|
window: 60 * 1000, // 1 minute
|
||||||
|
maxRequests: getEnvInt('AI_RATE_LIMIT_MAX_REQUESTS', 4),
|
||||||
|
microTaskTotalLimit: getEnvInt('AI_MICRO_TASK_TOTAL_LIMIT', 50)
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AppConfig = typeof config;
|
123
src/env.d.ts
vendored
123
src/env.d.ts
vendored
@ -1,47 +1,134 @@
|
|||||||
/// <reference path="../.astro/types.d.ts" />
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
|
|
||||||
|
interface ProcessedAuditTrail {
|
||||||
|
totalTime: number;
|
||||||
|
avgConfidence: number;
|
||||||
|
stepCount: number;
|
||||||
|
highConfidenceSteps: number;
|
||||||
|
lowConfidenceSteps: number;
|
||||||
|
phases: Array<{
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
displayName: string;
|
||||||
|
avgConfidence: number;
|
||||||
|
totalTime: number;
|
||||||
|
entries: CompressedAuditEntry[];
|
||||||
|
}>;
|
||||||
|
summary: {
|
||||||
|
analysisQuality: 'excellent' | 'good' | 'fair' | 'poor';
|
||||||
|
keyInsights: string[];
|
||||||
|
potentialIssues: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompressedAuditEntry {
|
||||||
|
timestamp: number;
|
||||||
|
phase: string;
|
||||||
|
action: string;
|
||||||
|
inputSummary: string;
|
||||||
|
outputSummary: string;
|
||||||
|
confidence: number;
|
||||||
|
processingTimeMs: number;
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
// Theme utilities
|
||||||
themeUtils: {
|
themeUtils: {
|
||||||
initTheme: () => void;
|
initTheme: () => void;
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
getStoredTheme: () => string;
|
getStoredTheme: () => string;
|
||||||
|
getSystemTheme: () => string;
|
||||||
|
applyTheme: (theme: string) => void;
|
||||||
|
updateThemeToggle: (theme: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tool utilities - THESE WERE MISSING
|
||||||
toolsData: any[];
|
toolsData: any[];
|
||||||
|
createToolSlug: (toolName: string) => string;
|
||||||
|
findToolByIdentifier: (tools: any[], identifier: string) => any | undefined;
|
||||||
|
isToolHosted: (tool: any) => boolean;
|
||||||
|
|
||||||
|
// Modal and UI utilities
|
||||||
showToolDetails: (toolName: string, modalType?: string) => void;
|
showToolDetails: (toolName: string, modalType?: string) => void;
|
||||||
hideToolDetails: (modalType?: string) => void;
|
hideToolDetails: (modalType?: string) => void;
|
||||||
hideAllToolDetails: () => void;
|
hideAllToolDetails: () => void;
|
||||||
toggleKbEntry: (entryId: string) => void;
|
toggleKbEntry: (entryId: string) => void;
|
||||||
toggleDomainAgnosticSection: (sectionId: string) => void;
|
toggleDomainAgnosticSection: (sectionId: string) => void;
|
||||||
|
modalHideInProgress?: boolean;
|
||||||
|
|
||||||
|
// AI interface utilities
|
||||||
restoreAIResults?: () => void;
|
restoreAIResults?: () => void;
|
||||||
switchToAIView?: () => void;
|
switchToAIView?: () => void;
|
||||||
|
|
||||||
|
// Filter and search utilities - THESE WERE MISSING
|
||||||
clearTagFilters?: () => void;
|
clearTagFilters?: () => void;
|
||||||
clearAllFilters?: () => void;
|
clearAllFilters?: () => void;
|
||||||
|
applyScenarioSearch?: (scenarioId: string) => void;
|
||||||
createToolSlug: (toolName: string) => string;
|
selectPhase?: (phase: string) => void;
|
||||||
findToolByIdentifier: (tools: any[], identifier: string) => any | undefined;
|
selectApproach?: (approach: string) => void;
|
||||||
isToolHosted: (tool: any) => boolean;
|
prioritizeSearchResults: (tools: any[], searchTerm: string) => any[];
|
||||||
|
applyFilters?: () => void; // THIS WAS MISSING
|
||||||
|
|
||||||
|
// Navigation utilities
|
||||||
|
navigateToGrid?: (toolName: string) => void;
|
||||||
|
navigateToMatrix?: (toolName: string) => void;
|
||||||
|
toggleAllScenarios?: () => void;
|
||||||
|
showShareDialog?: (shareButton: Element) => void;
|
||||||
|
|
||||||
|
// Scroll utilities
|
||||||
|
scrollToElement: (element: Element | null, options?: ScrollIntoViewOptions) => void;
|
||||||
|
scrollToElementById: (elementId: string, options?: ScrollIntoViewOptions) => void;
|
||||||
|
scrollToElementBySelector: (selector: string, options?: ScrollIntoViewOptions) => void;
|
||||||
|
|
||||||
|
// Authentication utilities
|
||||||
checkClientAuth: (context?: string) => Promise<{authenticated: boolean; authRequired: boolean; expires?: string}>;
|
checkClientAuth: (context?: string) => Promise<{authenticated: boolean; authRequired: boolean; expires?: string}>;
|
||||||
requireClientAuth: (callback?: () => void, returnUrl?: string, context?: string) => Promise<boolean>;
|
requireClientAuth: (callback?: () => void, returnUrl?: string, context?: string) => Promise<boolean>;
|
||||||
showIfAuthenticated: (selector: string, context?: string) => Promise<void>;
|
showIfAuthenticated: (selector: string, context?: string) => Promise<void>;
|
||||||
setupAuthButtons: (selector?: string) => void;
|
setupAuthButtons: (selector?: string) => void;
|
||||||
|
|
||||||
scrollToElement: (element: Element | null, options?: ScrollIntoViewOptions) => void;
|
// Sharing utilities
|
||||||
scrollToElementById: (elementId: string, options?: ScrollIntoViewOptions) => void;
|
|
||||||
scrollToElementBySelector: (selector: string, options?: ScrollIntoViewOptions) => void;
|
|
||||||
|
|
||||||
applyScenarioSearch?: (scenarioId: string) => void;
|
|
||||||
selectPhase?: (phase: string) => void;
|
|
||||||
selectApproach?: (approach: string) => void;
|
|
||||||
navigateToGrid?: (toolName: string) => void;
|
|
||||||
navigateToMatrix?: (toolName: string) => void;
|
|
||||||
toggleAllScenarios?: () => void;
|
|
||||||
showShareDialog?: (shareButton: Element) => void;
|
|
||||||
modalHideInProgress?: boolean;
|
|
||||||
|
|
||||||
shareArticle: (button: HTMLElement, url: string, title: string) => Promise<void>;
|
shareArticle: (button: HTMLElement, url: string, title: string) => Promise<void>;
|
||||||
shareCurrentArticle: (button: HTMLElement) => Promise<void>;
|
shareCurrentArticle: (button: HTMLElement) => Promise<void>;
|
||||||
|
|
||||||
|
// Audit trail utilities
|
||||||
|
auditService?: {
|
||||||
|
processAuditTrail: (rawAuditTrail: any[]) => ProcessedAuditTrail | null;
|
||||||
|
formatDuration: (ms: number) => string;
|
||||||
|
getConfidenceColor: (confidence: number) => string;
|
||||||
|
getActionDisplayName: (action: string) => string;
|
||||||
|
isEnabled: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
AuditUtils?: {
|
||||||
|
formatDuration: (ms: number) => string;
|
||||||
|
getConfidenceColor: (confidence: number) => string;
|
||||||
|
escapeHtml: (text: string) => string;
|
||||||
|
sanitizeText: (text: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
AuditTrailRenderer?: new (
|
||||||
|
containerId: string,
|
||||||
|
options?: {
|
||||||
|
title?: string;
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
containerId: string;
|
||||||
|
options: {
|
||||||
|
title: string;
|
||||||
|
collapsible: boolean;
|
||||||
|
defaultExpanded: boolean;
|
||||||
|
};
|
||||||
|
render: (processedAudit: ProcessedAuditTrail | null) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventTarget extension for better typing
|
||||||
|
interface EventTarget {
|
||||||
|
closest?: (selector: string) => Element | null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,4 +136,4 @@ declare module 'js-yaml' {
|
|||||||
export function load(str: string): any;
|
export function load(str: string): any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
/// <reference types="../env" />
|
||||||
import Navigation from '../components/Navigation.astro';
|
import Navigation from '../components/Navigation.astro';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
@ -25,133 +26,123 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Import utility functions from consolidated modules
|
||||||
async function loadUtilityFunctions() {
|
async function loadUtilityFunctions() {
|
||||||
try {
|
try {
|
||||||
const { createToolSlug, findToolByIdentifier, isToolHosted } = await import('../utils/clientUtils.js');
|
const { createToolSlug, findToolByIdentifier, isToolHosted } = await import('../utils/toolHelpers.js');
|
||||||
|
|
||||||
(window as any).createToolSlug = createToolSlug;
|
window.createToolSlug = createToolSlug;
|
||||||
(window as any).findToolByIdentifier = findToolByIdentifier;
|
window.findToolByIdentifier = findToolByIdentifier;
|
||||||
(window as any).isToolHosted = isToolHosted;
|
window.isToolHosted = isToolHosted;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load utility functions:', error);
|
console.error('Failed to load utility functions:', error);
|
||||||
(window as any).createToolSlug = (toolName: string) => {
|
// Fallback implementation
|
||||||
|
window.createToolSlug = (toolName) => {
|
||||||
if (!toolName || typeof toolName !== 'string') return '';
|
if (!toolName || typeof toolName !== 'string') return '';
|
||||||
return toolName.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
return toolName.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToElement(element: Element | null, options = {}) {
|
// Consolidated UI utility functions
|
||||||
if (!element) return;
|
const UIUtils = {
|
||||||
|
scrollToElement(element, options = {}) {
|
||||||
setTimeout(() => {
|
if (!element) return;
|
||||||
const headerHeight = document.querySelector('nav')?.offsetHeight || 80;
|
|
||||||
const elementRect = element.getBoundingClientRect();
|
|
||||||
const absoluteElementTop = elementRect.top + window.pageYOffset;
|
|
||||||
const targetPosition = absoluteElementTop - headerHeight - 20;
|
|
||||||
|
|
||||||
window.scrollTo({
|
setTimeout(() => {
|
||||||
top: targetPosition,
|
const headerHeight = document.querySelector('nav')?.offsetHeight || 80;
|
||||||
behavior: 'smooth'
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
const absoluteElementTop = elementRect.top + window.pageYOffset;
|
||||||
|
const targetPosition = absoluteElementTop - headerHeight - 20;
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: targetPosition,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToElementById(elementId, options = {}) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (element) {
|
||||||
|
this.scrollToElement(element, options);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToElementBySelector(selector, options = {}) {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (element) {
|
||||||
|
this.scrollToElement(element, options);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
prioritizeSearchResults(tools, searchTerm) {
|
||||||
|
if (!searchTerm || !searchTerm.trim()) {
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerSearchTerm = searchTerm.toLowerCase().trim();
|
||||||
|
|
||||||
|
return tools.sort((a, b) => {
|
||||||
|
const aTagsLower = (a.tags || []).map(tag => tag.toLowerCase());
|
||||||
|
const bTagsLower = (b.tags || []).map(tag => tag.toLowerCase());
|
||||||
|
|
||||||
|
const aExactTag = aTagsLower.includes(lowerSearchTerm);
|
||||||
|
const bExactTag = bTagsLower.includes(lowerSearchTerm);
|
||||||
|
|
||||||
|
if (aExactTag && !bExactTag) return -1;
|
||||||
|
if (!aExactTag && bExactTag) return 1;
|
||||||
|
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToElementById(elementId: string, options = {}) {
|
|
||||||
const element = document.getElementById(elementId);
|
|
||||||
if (element) {
|
|
||||||
scrollToElement(element, options);
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function scrollToElementBySelector(selector: string, options = {}) {
|
// Theme management
|
||||||
const element = document.querySelector(selector);
|
const ThemeManager = {
|
||||||
if (element) {
|
THEME_KEY: 'dfir-theme',
|
||||||
scrollToElement(element, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prioritizeSearchResults(tools: any[], searchTerm: string) {
|
getSystemTheme() {
|
||||||
if (!searchTerm || !searchTerm.trim()) {
|
|
||||||
return tools;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowerSearchTerm = searchTerm.toLowerCase().trim();
|
|
||||||
|
|
||||||
return tools.sort((a, b) => {
|
|
||||||
const aTagsLower = (a.tags || []).map((tag: string) => tag.toLowerCase());
|
|
||||||
const bTagsLower = (b.tags || []).map((tag: string) => tag.toLowerCase());
|
|
||||||
|
|
||||||
const aExactTag = aTagsLower.includes(lowerSearchTerm);
|
|
||||||
const bExactTag = bTagsLower.includes(lowerSearchTerm);
|
|
||||||
|
|
||||||
if (aExactTag && !bExactTag) return -1;
|
|
||||||
if (!aExactTag && bExactTag) return 1;
|
|
||||||
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
(window as any).scrollToElement = scrollToElement;
|
|
||||||
(window as any).scrollToElementById = scrollToElementById;
|
|
||||||
(window as any).scrollToElementBySelector = scrollToElementBySelector;
|
|
||||||
(window as any).prioritizeSearchResults = prioritizeSearchResults;
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
loadUtilityFunctions();
|
|
||||||
|
|
||||||
const THEME_KEY = 'dfir-theme';
|
|
||||||
|
|
||||||
function getSystemTheme() {
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
}
|
},
|
||||||
|
|
||||||
function getStoredTheme() {
|
getStoredTheme() {
|
||||||
return localStorage.getItem(THEME_KEY) || 'auto';
|
return localStorage.getItem(this.THEME_KEY) || 'auto';
|
||||||
}
|
},
|
||||||
|
|
||||||
function applyTheme(theme: string) {
|
applyTheme(theme) {
|
||||||
const effectiveTheme = theme === 'auto' ? getSystemTheme() : theme;
|
const effectiveTheme = theme === 'auto' ? this.getSystemTheme() : theme;
|
||||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||||
}
|
},
|
||||||
|
|
||||||
function updateThemeToggle(theme: string) {
|
updateThemeToggle(theme) {
|
||||||
document.querySelectorAll('[data-theme-toggle]').forEach(button => {
|
document.querySelectorAll('[data-theme-toggle]').forEach(button => {
|
||||||
button.setAttribute('data-current-theme', theme);
|
button.setAttribute('data-current-theme', theme);
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
|
||||||
function initTheme() {
|
initTheme() {
|
||||||
const storedTheme = getStoredTheme();
|
const storedTheme = this.getStoredTheme();
|
||||||
applyTheme(storedTheme);
|
this.applyTheme(storedTheme);
|
||||||
updateThemeToggle(storedTheme);
|
this.updateThemeToggle(storedTheme);
|
||||||
}
|
},
|
||||||
|
|
||||||
function toggleTheme() {
|
toggleTheme() {
|
||||||
const current = getStoredTheme();
|
const current = this.getStoredTheme();
|
||||||
const themes = ['light', 'dark', 'auto'];
|
const themes = ['light', 'dark', 'auto'];
|
||||||
const currentIndex = themes.indexOf(current);
|
const currentIndex = themes.indexOf(current);
|
||||||
const nextIndex = (currentIndex + 1) % themes.length;
|
const nextIndex = (currentIndex + 1) % themes.length;
|
||||||
const nextTheme = themes[nextIndex];
|
const nextTheme = themes[nextIndex];
|
||||||
|
|
||||||
localStorage.setItem(THEME_KEY, nextTheme);
|
localStorage.setItem(this.THEME_KEY, nextTheme);
|
||||||
applyTheme(nextTheme);
|
this.applyTheme(nextTheme);
|
||||||
updateThemeToggle(nextTheme);
|
this.updateThemeToggle(nextTheme);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
// Authentication utilities
|
||||||
if (getStoredTheme() === 'auto') {
|
const AuthManager = {
|
||||||
applyTheme('auto');
|
async checkClientAuth(context = 'general') {
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
(window as any).themeUtils = {
|
|
||||||
initTheme,
|
|
||||||
toggleTheme,
|
|
||||||
getStoredTheme
|
|
||||||
};
|
|
||||||
|
|
||||||
async function checkClientAuth(context = 'general') {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/status');
|
const response = await fetch('/api/auth/status');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@ -183,10 +174,10 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
authRequired: true
|
authRequired: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
async function requireClientAuth(callback: () => void, returnUrl: string, context = 'general') {
|
async requireClientAuth(callback, returnUrl, context = 'general') {
|
||||||
const authStatus = await checkClientAuth(context);
|
const authStatus = await this.checkClientAuth(context);
|
||||||
|
|
||||||
if (authStatus.authRequired && !authStatus.authenticated) {
|
if (authStatus.authRequired && !authStatus.authenticated) {
|
||||||
const targetUrl = returnUrl || window.location.href;
|
const targetUrl = returnUrl || window.location.href;
|
||||||
@ -198,43 +189,38 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
async function showIfAuthenticated(selector: string, context = 'general') {
|
async showIfAuthenticated(selector, context = 'general') {
|
||||||
const authStatus = await checkClientAuth(context);
|
const authStatus = await this.checkClientAuth(context);
|
||||||
const element = document.querySelector(selector);
|
const element = document.querySelector(selector);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
(element as HTMLElement).style.display = (!authStatus.authRequired || authStatus.authenticated)
|
element.style.display = (!authStatus.authRequired || authStatus.authenticated)
|
||||||
? 'inline-flex'
|
? 'inline-flex'
|
||||||
: 'none';
|
: 'none';
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
function setupAuthButtons(selector = '[data-contribute-button]') {
|
setupAuthButtons(selector = '[data-contribute-button]') {
|
||||||
document.addEventListener('click', async (e) => {
|
document.addEventListener('click', async (e) => {
|
||||||
if (!e.target) return;
|
if (!e.target) return;
|
||||||
|
|
||||||
const button = (e.target as Element).closest(selector);
|
const button = (e.target as HTMLElement).closest(selector) as HTMLAnchorElement;
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
console.log('[AUTH] Contribute button clicked:', button.getAttribute('data-contribute-button'));
|
await this.requireClientAuth(() => {
|
||||||
|
window.location.href = button.href;
|
||||||
await requireClientAuth(() => {
|
}, button.href, 'contributions');
|
||||||
console.log('[AUTH] Navigation approved, redirecting to:', (button as HTMLAnchorElement).href);
|
|
||||||
window.location.href = (button as HTMLAnchorElement).href;
|
|
||||||
}, (button as HTMLAnchorElement).href, 'contributions');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
(window as any).checkClientAuth = checkClientAuth;
|
// Sharing utilities
|
||||||
(window as any).requireClientAuth = requireClientAuth;
|
const SharingUtils = {
|
||||||
(window as any).showIfAuthenticated = showIfAuthenticated;
|
async copyUrlToClipboard(url, button) {
|
||||||
(window as any).setupAuthButtons = setupAuthButtons;
|
|
||||||
|
|
||||||
async function copyUrlToClipboard(url: string, button: HTMLElement) {
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
|
|
||||||
@ -265,27 +251,257 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
button.innerHTML = originalHTML;
|
button.innerHTML = originalHTML;
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
async function shareArticle(button: HTMLElement, url: string, title: string) {
|
async shareArticle(button, url, title) {
|
||||||
const fullUrl = window.location.origin + url;
|
const fullUrl = window.location.origin + url;
|
||||||
await copyUrlToClipboard(fullUrl, button);
|
await this.copyUrlToClipboard(fullUrl, button);
|
||||||
|
},
|
||||||
|
|
||||||
|
async shareCurrentArticle(button) {
|
||||||
|
await this.copyUrlToClipboard(window.location.href, button);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
async function shareCurrentArticle(button: HTMLElement) {
|
// Audit utilities
|
||||||
await copyUrlToClipboard(window.location.href, button);
|
const AuditUtils = {
|
||||||
|
formatDuration(ms) {
|
||||||
|
if (ms < 1000) return '< 1s';
|
||||||
|
if (ms < 60000) return `${Math.ceil(ms / 1000)}s`;
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const seconds = Math.ceil((ms % 60000) / 1000);
|
||||||
|
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||||
|
},
|
||||||
|
|
||||||
|
getConfidenceColor(confidence) {
|
||||||
|
if (confidence >= 80) return 'var(--color-accent)';
|
||||||
|
if (confidence >= 60) return 'var(--color-warning)';
|
||||||
|
return 'var(--color-error)';
|
||||||
|
},
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
if (typeof text !== 'string') return String(text);
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
},
|
||||||
|
|
||||||
|
sanitizeText(text) {
|
||||||
|
if (typeof text !== 'string') return '';
|
||||||
|
|
||||||
|
return text
|
||||||
|
.replace(/^#{1,6}\s+/gm, '')
|
||||||
|
.replace(/^\s*[-*+]\s+/gm, '')
|
||||||
|
.replace(/^\s*\d+\.\s+/gm, '')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||||
|
.replace(/\*(.+?)\*/g, '$1')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.replace(/```[\s\S]*?```/g, '[CODE BLOCK]')
|
||||||
|
.replace(/`([^`]+)`/g, '$1')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\n\s*\n\s*\n/g, '\n\n')
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
(window as any).shareArticle = shareArticle;
|
// Filter and search functionality
|
||||||
(window as any).shareCurrentArticle = shareCurrentArticle;
|
const FilterUtils = {
|
||||||
|
clearTagFilters() {
|
||||||
|
const tagFilters = document.querySelectorAll('.tag-filter.active');
|
||||||
|
tagFilters.forEach(filter => filter.classList.remove('active'));
|
||||||
|
this.updateResults();
|
||||||
|
},
|
||||||
|
|
||||||
initTheme();
|
clearAllFilters() {
|
||||||
setupAuthButtons('[data-contribute-button]');
|
// Clear search input
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
if (searchInput) (searchInput as HTMLInputElement).value = '';
|
||||||
|
|
||||||
|
// Clear domain filters
|
||||||
|
const domainFilters = document.querySelectorAll('.domain-filter.active');
|
||||||
|
domainFilters.forEach(filter => filter.classList.remove('active'));
|
||||||
|
|
||||||
|
// Clear phase filters
|
||||||
|
const phaseFilters = document.querySelectorAll('.phase-filter.active');
|
||||||
|
phaseFilters.forEach(filter => filter.classList.remove('active'));
|
||||||
|
|
||||||
|
// Clear tag filters
|
||||||
|
this.clearTagFilters();
|
||||||
|
|
||||||
|
// Clear other filters
|
||||||
|
const otherFilters = document.querySelectorAll('.filter-item.active');
|
||||||
|
otherFilters.forEach(filter => filter.classList.remove('active'));
|
||||||
|
|
||||||
|
this.updateResults();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateResults() {
|
||||||
|
// Trigger filter update if function exists
|
||||||
|
if (typeof window.applyFilters === 'function') {
|
||||||
|
window.applyFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigation utilities
|
||||||
|
const NavigationUtils = {
|
||||||
|
navigateToGrid(toolName) {
|
||||||
|
if (toolName) {
|
||||||
|
const slug = window.createToolSlug(toolName);
|
||||||
|
window.location.href = `/?tool=${slug}&view=grid`;
|
||||||
|
} else {
|
||||||
|
window.location.href = '/?view=grid';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
navigateToMatrix(toolName) {
|
||||||
|
if (toolName) {
|
||||||
|
const slug = window.createToolSlug(toolName);
|
||||||
|
window.location.href = `/?tool=${slug}&view=matrix`;
|
||||||
|
} else {
|
||||||
|
window.location.href = '/?view=matrix';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
applyScenarioSearch(scenarioId) {
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
if (searchInput && scenarioId) {
|
||||||
|
(searchInput as HTMLInputElement).value = scenarioId;
|
||||||
|
if (typeof window.applyFilters === 'function') {
|
||||||
|
window.applyFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectPhase(phase) {
|
||||||
|
const phaseFilter = document.querySelector(`[data-phase="${phase}"]`);
|
||||||
|
if (phaseFilter) {
|
||||||
|
phaseFilter.classList.add('active');
|
||||||
|
FilterUtils.updateResults();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectApproach(approach) {
|
||||||
|
const approachFilter = document.querySelector(`[data-approach="${approach}"]`);
|
||||||
|
if (approachFilter) {
|
||||||
|
approachFilter.classList.add('active');
|
||||||
|
FilterUtils.updateResults();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal and UI state management
|
||||||
|
const ModalUtils = {
|
||||||
|
showShareDialog(shareButton) {
|
||||||
|
// Implementation for share dialog
|
||||||
|
const url = window.location.href;
|
||||||
|
SharingUtils.shareCurrentArticle(shareButton);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleAllScenarios() {
|
||||||
|
const scenarioCards = document.querySelectorAll('.scenario-card');
|
||||||
|
const expandedCards = document.querySelectorAll('.scenario-card.expanded');
|
||||||
|
|
||||||
|
if (expandedCards.length === scenarioCards.length) {
|
||||||
|
// Collapse all
|
||||||
|
scenarioCards.forEach(card => card.classList.remove('expanded'));
|
||||||
|
} else {
|
||||||
|
// Expand all
|
||||||
|
scenarioCards.forEach(card => card.classList.add('expanded'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleKbEntry(entryId) {
|
||||||
|
const entry = document.getElementById(entryId);
|
||||||
|
if (entry) {
|
||||||
|
entry.classList.toggle('expanded');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleDomainAgnosticSection(sectionId) {
|
||||||
|
const section = document.getElementById(sectionId);
|
||||||
|
if (section) {
|
||||||
|
section.classList.toggle('collapsed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize audit service if available
|
||||||
|
async function initializeAuditService() {
|
||||||
|
try {
|
||||||
|
const { auditService } = await import('../utils/auditService.js');
|
||||||
|
window.auditService = auditService;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Audit service not available:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose utilities to global scope
|
||||||
|
Object.assign(window, {
|
||||||
|
// UI utilities
|
||||||
|
scrollToElement: UIUtils.scrollToElement.bind(UIUtils),
|
||||||
|
scrollToElementById: UIUtils.scrollToElementById.bind(UIUtils),
|
||||||
|
scrollToElementBySelector: UIUtils.scrollToElementBySelector.bind(UIUtils),
|
||||||
|
prioritizeSearchResults: UIUtils.prioritizeSearchResults.bind(UIUtils),
|
||||||
|
|
||||||
|
// Theme utilities
|
||||||
|
themeUtils: ThemeManager,
|
||||||
|
|
||||||
|
// Auth utilities
|
||||||
|
checkClientAuth: AuthManager.checkClientAuth.bind(AuthManager),
|
||||||
|
requireClientAuth: AuthManager.requireClientAuth.bind(AuthManager),
|
||||||
|
showIfAuthenticated: AuthManager.showIfAuthenticated.bind(AuthManager),
|
||||||
|
setupAuthButtons: AuthManager.setupAuthButtons.bind(AuthManager),
|
||||||
|
|
||||||
|
// Sharing utilities
|
||||||
|
shareArticle: SharingUtils.shareArticle.bind(SharingUtils),
|
||||||
|
shareCurrentArticle: SharingUtils.shareCurrentArticle.bind(SharingUtils),
|
||||||
|
|
||||||
|
// Filter utilities
|
||||||
|
clearTagFilters: FilterUtils.clearTagFilters.bind(FilterUtils),
|
||||||
|
clearAllFilters: FilterUtils.clearAllFilters.bind(FilterUtils),
|
||||||
|
|
||||||
|
// Navigation utilities
|
||||||
|
navigateToGrid: NavigationUtils.navigateToGrid.bind(NavigationUtils),
|
||||||
|
navigateToMatrix: NavigationUtils.navigateToMatrix.bind(NavigationUtils),
|
||||||
|
applyScenarioSearch: NavigationUtils.applyScenarioSearch.bind(NavigationUtils),
|
||||||
|
selectPhase: NavigationUtils.selectPhase.bind(NavigationUtils),
|
||||||
|
selectApproach: NavigationUtils.selectApproach.bind(NavigationUtils),
|
||||||
|
|
||||||
|
// Modal utilities
|
||||||
|
showShareDialog: ModalUtils.showShareDialog.bind(ModalUtils),
|
||||||
|
toggleAllScenarios: ModalUtils.toggleAllScenarios.bind(ModalUtils),
|
||||||
|
toggleKbEntry: ModalUtils.toggleKbEntry.bind(ModalUtils),
|
||||||
|
toggleDomainAgnosticSection: ModalUtils.toggleDomainAgnosticSection.bind(ModalUtils),
|
||||||
|
|
||||||
|
// Audit utilities
|
||||||
|
AuditUtils: AuditUtils
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await loadUtilityFunctions();
|
||||||
|
await initializeAuditService();
|
||||||
|
|
||||||
|
// Initialize theme
|
||||||
|
ThemeManager.initTheme();
|
||||||
|
|
||||||
|
// Setup auth buttons
|
||||||
|
AuthManager.setupAuthButtons('[data-contribute-button]');
|
||||||
|
|
||||||
|
// Initialize AI button visibility
|
||||||
const initAIButton = async () => {
|
const initAIButton = async () => {
|
||||||
await showIfAuthenticated('#ai-view-toggle', 'ai');
|
await AuthManager.showIfAuthenticated('#ai-view-toggle', 'ai');
|
||||||
};
|
};
|
||||||
initAIButton();
|
initAIButton();
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
|
if (ThemeManager.getStoredTheme() === 'auto') {
|
||||||
|
ThemeManager.applyTheme('auto');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[BaseLayout] All utilities initialized successfully');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
@ -1,24 +1,15 @@
|
|||||||
// src/pages/api/ai/enhance-input.ts - Enhanced AI service compatibility
|
// src/pages/api/ai/enhance-input.ts
|
||||||
|
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { withAPIAuth } from '../../../utils/auth.js';
|
import { withAPIAuth } from '../../../utils/auth.js';
|
||||||
import { apiError, apiServerError, createAuthErrorResponse } from '../../../utils/api.js';
|
import { apiError, apiServerError, createAuthErrorResponse } from '../../../utils/api.js';
|
||||||
import { enqueueApiCall } from '../../../utils/rateLimitedQueue.js';
|
import { enqueueApiCall } from '../../../utils/rateLimitedQueue.js';
|
||||||
|
import { aiService } from '../../../services/ai/aiService.js';
|
||||||
|
import { config } from '../../../config/appConfig.js';
|
||||||
|
import { logger } from '../../../services/logger.js';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
function getEnv(key: string): string {
|
|
||||||
const value = process.env[key];
|
|
||||||
if (!value) {
|
|
||||||
throw new Error(`Missing environment variable: ${key}`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AI_ENDPOINT = getEnv('AI_ANALYZER_ENDPOINT');
|
|
||||||
const AI_ANALYZER_API_KEY = getEnv('AI_ANALYZER_API_KEY');
|
|
||||||
const AI_ANALYZER_MODEL = getEnv('AI_ANALYZER_MODEL');
|
|
||||||
|
|
||||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
||||||
const RATE_LIMIT_WINDOW = 60 * 1000;
|
const RATE_LIMIT_WINDOW = 60 * 1000;
|
||||||
const RATE_LIMIT_MAX = 5;
|
const RATE_LIMIT_MAX = 5;
|
||||||
@ -95,39 +86,6 @@ ${input}
|
|||||||
`.trim();
|
`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callAIService(prompt: string): Promise<Response> {
|
|
||||||
const endpoint = AI_ENDPOINT;
|
|
||||||
const apiKey = AI_ANALYZER_API_KEY;
|
|
||||||
const model = AI_ANALYZER_MODEL;
|
|
||||||
|
|
||||||
let headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (apiKey) {
|
|
||||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
||||||
console.log('[ENHANCE API] Using API key authentication');
|
|
||||||
} else {
|
|
||||||
console.log('[ENHANCE API] No API key - making request without authentication');
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestBody = {
|
|
||||||
model,
|
|
||||||
messages: [{ role: 'user', content: prompt }],
|
|
||||||
max_tokens: 300,
|
|
||||||
temperature: 0.7,
|
|
||||||
top_p: 0.9,
|
|
||||||
frequency_penalty: 0.2,
|
|
||||||
presence_penalty: 0.1
|
|
||||||
};
|
|
||||||
|
|
||||||
return fetch(`${endpoint}/v1/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(requestBody)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
const authResult = await withAPIAuth(request, 'ai');
|
const authResult = await withAPIAuth(request, 'ai');
|
||||||
@ -145,39 +103,52 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
const { input } = body;
|
const { input } = body;
|
||||||
|
|
||||||
if (!input || typeof input !== 'string' || input.length < 40) {
|
if (!input || typeof input !== 'string' || input.length < 40) {
|
||||||
|
logger.api('POST', '/api/ai/enhance-input', 400, {
|
||||||
|
error: 'Input too short',
|
||||||
|
userId,
|
||||||
|
inputLength: input?.length || 0
|
||||||
|
});
|
||||||
return apiError.badRequest('Input too short for enhancement (minimum 40 characters)');
|
return apiError.badRequest('Input too short for enhancement (minimum 40 characters)');
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedInput = sanitizeInput(input);
|
const sanitizedInput = sanitizeInput(input);
|
||||||
if (sanitizedInput.length < 40) {
|
if (sanitizedInput.length < 40) {
|
||||||
|
logger.api('POST', '/api/ai/enhance-input', 400, {
|
||||||
|
error: 'Input too short after sanitization',
|
||||||
|
userId
|
||||||
|
});
|
||||||
return apiError.badRequest('Input too short after sanitization');
|
return apiError.badRequest('Input too short after sanitization');
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemPrompt = createEnhancementPrompt(sanitizedInput);
|
const systemPrompt = createEnhancementPrompt(sanitizedInput);
|
||||||
const taskId = `enhance_${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 4)}`;
|
const taskId = `enhance_${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 4)}`;
|
||||||
|
|
||||||
const aiResponse = await enqueueApiCall(() => callAIService(systemPrompt), taskId);
|
logger.info('enhance-input', 'Processing enhancement request', {
|
||||||
|
userId,
|
||||||
|
inputLength: sanitizedInput.length,
|
||||||
|
taskId
|
||||||
|
});
|
||||||
|
|
||||||
if (!aiResponse.ok) {
|
const aiResponse = await enqueueApiCall(() =>
|
||||||
const errorText = await aiResponse.text();
|
aiService.call({
|
||||||
console.error('[ENHANCE API] AI enhancement error:', errorText, 'Status:', aiResponse.status);
|
prompt: systemPrompt,
|
||||||
|
maxTokens: 300,
|
||||||
|
temperature: 0.7,
|
||||||
|
context: 'input-enhancement'
|
||||||
|
})
|
||||||
|
, taskId);
|
||||||
|
|
||||||
|
if (!aiResponse.content) {
|
||||||
|
logger.error('enhance-input', 'No enhancement response received', undefined, {
|
||||||
|
userId,
|
||||||
|
taskId
|
||||||
|
});
|
||||||
return apiServerError.unavailable('Enhancement service unavailable');
|
return apiServerError.unavailable('Enhancement service unavailable');
|
||||||
}
|
}
|
||||||
|
|
||||||
const aiData = await aiResponse.json();
|
|
||||||
const aiContent = aiData.choices?.[0]?.message?.content;
|
|
||||||
|
|
||||||
if (!aiContent) {
|
|
||||||
return apiServerError.unavailable('No enhancement response');
|
|
||||||
}
|
|
||||||
|
|
||||||
let questions;
|
let questions;
|
||||||
try {
|
try {
|
||||||
const cleanedContent = aiContent
|
questions = aiService.parseJSONResponse(aiResponse.content, []);
|
||||||
.replace(/^```json\s*/i, '')
|
|
||||||
.replace(/\s*```\s*$/, '')
|
|
||||||
.trim();
|
|
||||||
questions = JSON.parse(cleanedContent);
|
|
||||||
|
|
||||||
if (!Array.isArray(questions)) {
|
if (!Array.isArray(questions)) {
|
||||||
throw new Error('Response is not an array');
|
throw new Error('Response is not an array');
|
||||||
@ -199,11 +170,22 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse enhancement response:', aiContent);
|
logger.warn('enhance-input', 'Failed to parse enhancement response', {
|
||||||
|
userId,
|
||||||
|
taskId,
|
||||||
|
error: error.message,
|
||||||
|
responsePreview: aiResponse.content.slice(0, 100)
|
||||||
|
});
|
||||||
questions = [];
|
questions = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[ENHANCE API] User: ${userId}, Forensics Questions: ${questions.length}, Input length: ${sanitizedInput.length}`);
|
logger.api('POST', '/api/ai/enhance-input', 200, {
|
||||||
|
userId,
|
||||||
|
taskId,
|
||||||
|
inputLength: sanitizedInput.length,
|
||||||
|
questionsGenerated: questions.length,
|
||||||
|
processingTime: `${aiResponse.requestTimeMs}ms`
|
||||||
|
});
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
@ -216,7 +198,9 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Enhancement error:', error);
|
logger.error('enhance-input', 'Enhancement processing failed', error as Error, {
|
||||||
|
endpoint: '/api/ai/enhance-input'
|
||||||
|
});
|
||||||
return apiServerError.internal('Enhancement processing failed');
|
return apiServerError.internal('Enhancement processing failed');
|
||||||
}
|
}
|
||||||
};
|
};
|
@ -4,7 +4,9 @@ import type { APIRoute } from 'astro';
|
|||||||
import { withAPIAuth } from '../../../utils/auth.js';
|
import { withAPIAuth } from '../../../utils/auth.js';
|
||||||
import { apiError, apiServerError, createAuthErrorResponse } from '../../../utils/api.js';
|
import { apiError, apiServerError, createAuthErrorResponse } from '../../../utils/api.js';
|
||||||
import { enqueueApiCall } from '../../../utils/rateLimitedQueue.js';
|
import { enqueueApiCall } from '../../../utils/rateLimitedQueue.js';
|
||||||
import { aiPipeline } from '../../../utils/aiPipeline.js';
|
import { pipelineOrchestrator } from '../../../services/ai/pipelineOrchestrator.js';
|
||||||
|
import { config } from '../../../config/appConfig.js';
|
||||||
|
import { logger } from '../../../services/logger.js';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
@ -16,10 +18,6 @@ interface RateLimitData {
|
|||||||
|
|
||||||
const rateLimitStore = new Map<string, RateLimitData>();
|
const rateLimitStore = new Map<string, RateLimitData>();
|
||||||
|
|
||||||
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
|
||||||
const MAIN_RATE_LIMIT_MAX = parseInt(process.env.AI_RATE_LIMIT_MAX_REQUESTS || '4', 10);
|
|
||||||
const MICRO_TASK_TOTAL_LIMIT = parseInt(process.env.AI_MICRO_TASK_TOTAL_LIMIT || '50', 10);
|
|
||||||
|
|
||||||
function sanitizeInput(input: string): string {
|
function sanitizeInput(input: string): string {
|
||||||
let sanitized = input
|
let sanitized = input
|
||||||
.replace(/```[\s\S]*?```/g, '[CODE_BLOCK_REMOVED]')
|
.replace(/```[\s\S]*?```/g, '[CODE_BLOCK_REMOVED]')
|
||||||
@ -39,26 +37,26 @@ function checkRateLimit(userId: string): { allowed: boolean; reason?: string; mi
|
|||||||
if (!userLimit || now > userLimit.resetTime) {
|
if (!userLimit || now > userLimit.resetTime) {
|
||||||
rateLimitStore.set(userId, {
|
rateLimitStore.set(userId, {
|
||||||
count: 1,
|
count: 1,
|
||||||
resetTime: now + RATE_LIMIT_WINDOW,
|
resetTime: now + config.rateLimit.window,
|
||||||
microTaskCount: 0
|
microTaskCount: 0
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
microTasksRemaining: MICRO_TASK_TOTAL_LIMIT
|
microTasksRemaining: config.rateLimit.microTaskTotalLimit
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userLimit.count >= MAIN_RATE_LIMIT_MAX) {
|
if (userLimit.count >= config.rateLimit.maxRequests) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: `Main rate limit exceeded. Max ${MAIN_RATE_LIMIT_MAX} requests per minute.`
|
reason: `Main rate limit exceeded. Max ${config.rateLimit.maxRequests} requests per minute.`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userLimit.microTaskCount >= MICRO_TASK_TOTAL_LIMIT) {
|
if (userLimit.microTaskCount >= config.rateLimit.microTaskTotalLimit) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: `Micro-task limit exceeded. Max ${MICRO_TASK_TOTAL_LIMIT} AI calls per minute.`
|
reason: `Micro-task limit exceeded. Max ${config.rateLimit.microTaskTotalLimit} AI calls per minute.`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,7 +64,7 @@ function checkRateLimit(userId: string): { allowed: boolean; reason?: string; mi
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
microTasksRemaining: MICRO_TASK_TOTAL_LIMIT - userLimit.microTaskCount
|
microTasksRemaining: config.rateLimit.microTaskTotalLimit - userLimit.microTaskCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,7 +72,11 @@ function incrementMicroTaskCount(userId: string, aiCallsMade: number): void {
|
|||||||
const userLimit = rateLimitStore.get(userId);
|
const userLimit = rateLimitStore.get(userId);
|
||||||
if (userLimit) {
|
if (userLimit) {
|
||||||
userLimit.microTaskCount += aiCallsMade;
|
userLimit.microTaskCount += aiCallsMade;
|
||||||
console.log(`[RATE LIMIT] User ${userId} now at ${userLimit.microTaskCount}/${MICRO_TASK_TOTAL_LIMIT} micro-task calls`);
|
logger.debug('rate-limit', `User micro-task usage updated`, {
|
||||||
|
userId,
|
||||||
|
currentCount: userLimit.microTaskCount,
|
||||||
|
limit: config.rateLimit.microTaskTotalLimit
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +97,10 @@ function cleanupExpiredRateLimits() {
|
|||||||
const toRemove = entries.slice(0, entries.length - maxStoreSize);
|
const toRemove = entries.slice(0, entries.length - maxStoreSize);
|
||||||
toRemove.forEach(([userId]) => rateLimitStore.delete(userId));
|
toRemove.forEach(([userId]) => rateLimitStore.delete(userId));
|
||||||
|
|
||||||
console.log(`[RATE LIMIT] Cleanup: removed ${toRemove.length} old entries`);
|
logger.info('rate-limit', `Cleanup completed`, {
|
||||||
|
removed: toRemove.length,
|
||||||
|
remaining: rateLimitStore.size
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,55 +123,60 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { query, mode = 'workflow', taskId: clientTaskId } = body;
|
const { query, mode = 'workflow', taskId: clientTaskId } = body;
|
||||||
|
|
||||||
console.log(`[MICRO-TASK API] Received request - TaskId: ${clientTaskId}, Mode: ${mode}, Query length: ${query?.length || 0}`);
|
|
||||||
console.log(`[MICRO-TASK API] Micro-task calls remaining: ${rateLimitResult.microTasksRemaining}`);
|
|
||||||
|
|
||||||
if (!query || typeof query !== 'string') {
|
if (!query || typeof query !== 'string') {
|
||||||
console.log(`[MICRO-TASK API] Invalid query for task ${clientTaskId}`);
|
logger.api('POST', '/api/ai/query', 400, { error: 'Invalid query', userId });
|
||||||
return apiError.badRequest('Query required');
|
return apiError.badRequest('Query required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['workflow', 'tool'].includes(mode)) {
|
if (!['workflow', 'tool'].includes(mode)) {
|
||||||
console.log(`[MICRO-TASK API] Invalid mode for task ${clientTaskId}: ${mode}`);
|
logger.api('POST', '/api/ai/query', 400, { error: 'Invalid mode', userId, mode });
|
||||||
return apiError.badRequest('Invalid mode. Must be "workflow" or "tool"');
|
return apiError.badRequest('Invalid mode. Must be "workflow" or "tool"');
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedQuery = sanitizeInput(query);
|
const sanitizedQuery = sanitizeInput(query);
|
||||||
if (sanitizedQuery.includes('[FILTERED]')) {
|
if (sanitizedQuery.includes('[FILTERED]')) {
|
||||||
console.log(`[MICRO-TASK API] Filtered input detected for task ${clientTaskId}`);
|
logger.api('POST', '/api/ai/query', 400, { error: 'Filtered input', userId });
|
||||||
return apiError.badRequest('Invalid input detected');
|
return apiError.badRequest('Invalid input detected');
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskId = clientTaskId || `ai_${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
|
const taskId = clientTaskId || `ai_${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
|
||||||
|
|
||||||
console.log(`[MICRO-TASK API] About to enqueue micro-task pipeline ${taskId}`);
|
logger.api('POST', '/api/ai/query', 200, {
|
||||||
|
taskId,
|
||||||
|
mode,
|
||||||
|
queryLength: sanitizedQuery.length,
|
||||||
|
userId,
|
||||||
|
microTasksRemaining: rateLimitResult.microTasksRemaining
|
||||||
|
});
|
||||||
|
|
||||||
const result = await enqueueApiCall(() =>
|
const result = await enqueueApiCall(() =>
|
||||||
aiPipeline.processQuery(sanitizedQuery, mode)
|
pipelineOrchestrator.processQuery(sanitizedQuery, mode)
|
||||||
, taskId);
|
, taskId);
|
||||||
|
|
||||||
if (!result || !result.recommendation) {
|
if (!result || !result.recommendation) {
|
||||||
return apiServerError.unavailable('No response from micro-task AI pipeline');
|
return apiServerError.unavailable('No response from AI pipeline');
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = result.processingStats;
|
const stats = result.processingStats;
|
||||||
const estimatedAICallsMade = stats.microTasksCompleted + stats.microTasksFailed;
|
const estimatedAICallsMade = stats.microTasksCompleted + stats.microTasksFailed;
|
||||||
incrementMicroTaskCount(userId, estimatedAICallsMade);
|
incrementMicroTaskCount(userId, estimatedAICallsMade);
|
||||||
|
|
||||||
console.log(`[MICRO-TASK API] Pipeline completed for ${taskId}:`);
|
logger.api('POST', '/api/ai/query', 200, {
|
||||||
console.log(` - Mode: ${mode}`);
|
taskId,
|
||||||
console.log(` - User: ${userId}`);
|
mode,
|
||||||
console.log(` - Query length: ${sanitizedQuery.length}`);
|
userId,
|
||||||
console.log(` - Processing time: ${stats.processingTimeMs}ms`);
|
queryLength: sanitizedQuery.length,
|
||||||
console.log(` - Micro-tasks completed: ${stats.microTasksCompleted}`);
|
processingTime: `${stats.processingTimeMs}ms`,
|
||||||
console.log(` - Micro-tasks failed: ${stats.microTasksFailed}`);
|
microTasksCompleted: stats.microTasksCompleted,
|
||||||
console.log(` - Estimated AI calls: ${estimatedAICallsMade}`);
|
microTasksFailed: stats.microTasksFailed,
|
||||||
console.log(` - Embeddings used: ${stats.embeddingsUsed}`);
|
estimatedAICallsMade,
|
||||||
console.log(` - Final items: ${stats.finalSelectedItems}`);
|
embeddingsUsed: stats.embeddingsUsed,
|
||||||
|
finalItems: stats.finalSelectedItems
|
||||||
|
});
|
||||||
|
|
||||||
const currentLimit = rateLimitStore.get(userId);
|
const currentLimit = rateLimitStore.get(userId);
|
||||||
const remainingMicroTasks = currentLimit ?
|
const remainingMicroTasks = currentLimit ?
|
||||||
MICRO_TASK_TOTAL_LIMIT - currentLimit.microTaskCount : MICRO_TASK_TOTAL_LIMIT;
|
config.rateLimit.microTaskTotalLimit - currentLimit.microTaskCount : config.rateLimit.microTaskTotalLimit;
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
@ -182,9 +192,9 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
estimatedAICallsMade
|
estimatedAICallsMade
|
||||||
},
|
},
|
||||||
rateLimitInfo: {
|
rateLimitInfo: {
|
||||||
mainRequestsRemaining: MAIN_RATE_LIMIT_MAX - (currentLimit?.count || 0),
|
mainRequestsRemaining: config.rateLimit.maxRequests - (currentLimit?.count || 0),
|
||||||
microTaskCallsRemaining: remainingMicroTasks,
|
microTaskCallsRemaining: remainingMicroTasks,
|
||||||
resetTime: Date.now() + RATE_LIMIT_WINDOW
|
resetTime: Date.now() + config.rateLimit.window
|
||||||
}
|
}
|
||||||
}), {
|
}), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@ -192,7 +202,9 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MICRO-TASK API] Pipeline error:', error);
|
logger.error('api', 'Pipeline processing failed', error as Error, {
|
||||||
|
endpoint: '/api/ai/query'
|
||||||
|
});
|
||||||
|
|
||||||
if (error.message.includes('embeddings')) {
|
if (error.message.includes('embeddings')) {
|
||||||
return apiServerError.unavailable('Embeddings service error - using AI fallback');
|
return apiServerError.unavailable('Embeddings service error - using AI fallback');
|
||||||
|
228
src/services/ai/aiService.ts
Normal file
228
src/services/ai/aiService.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
// src/services/ai/aiService.ts
|
||||||
|
import { config } from '../../config/appConfig.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
export interface AIRequest {
|
||||||
|
prompt: string;
|
||||||
|
maxTokens?: number;
|
||||||
|
temperature?: number;
|
||||||
|
context?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIResponse {
|
||||||
|
content: string;
|
||||||
|
requestTimeMs: number;
|
||||||
|
tokenCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AIService {
|
||||||
|
private readonly endpoint: string;
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly model: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.endpoint = config.ai.endpoint;
|
||||||
|
this.apiKey = config.ai.apiKey;
|
||||||
|
this.model = config.ai.model;
|
||||||
|
|
||||||
|
logger.info('ai-service', 'AI Service initialized', {
|
||||||
|
model: this.model,
|
||||||
|
hasApiKey: !!this.apiKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async call(request: AIRequest): Promise<AIResponse> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const timerId = logger.time('ai-service', `call-${request.context || 'unknown'}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.apiKey) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
model: this.model,
|
||||||
|
messages: [{ role: 'user', content: request.prompt }],
|
||||||
|
max_tokens: request.maxTokens || 1500,
|
||||||
|
temperature: request.temperature || 0.3
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug('ai-service', 'Making AI request', {
|
||||||
|
context: request.context,
|
||||||
|
promptLength: request.prompt.length,
|
||||||
|
maxTokens: requestBody.max_tokens,
|
||||||
|
temperature: requestBody.temperature
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${this.endpoint}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestTimeMs = Date.now() - startTime;
|
||||||
|
logger.timeEnd(timerId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error('ai-service', 'AI API request failed', new Error(`HTTP ${response.status}`), {
|
||||||
|
status: response.status,
|
||||||
|
context: request.context,
|
||||||
|
errorText: errorText.slice(0, 200)
|
||||||
|
});
|
||||||
|
throw new Error(`AI API error: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const content = data.choices?.[0]?.message?.content;
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
logger.error('ai-service', 'No response content from AI model', undefined, {
|
||||||
|
context: request.context,
|
||||||
|
choices: data.choices?.length || 0
|
||||||
|
});
|
||||||
|
throw new Error('No response from AI model');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('ai-service', 'AI request completed', {
|
||||||
|
context: request.context,
|
||||||
|
responseLength: content.length,
|
||||||
|
duration: `${requestTimeMs}ms`,
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: content.trim(),
|
||||||
|
requestTimeMs,
|
||||||
|
tokenCount: data.usage?.total_tokens
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const requestTimeMs = Date.now() - startTime;
|
||||||
|
logger.error('ai-service', 'AI service call failed', error as Error, {
|
||||||
|
context: request.context,
|
||||||
|
duration: `${requestTimeMs}ms`
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async callMicroTask(prompt: string, context: string, maxTokens: number = 500): Promise<AIResponse> {
|
||||||
|
return this.call({
|
||||||
|
prompt,
|
||||||
|
maxTokens,
|
||||||
|
temperature: 0.3,
|
||||||
|
context: `micro-task:${context}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
estimateTokens(text: string): number {
|
||||||
|
// Simple estimation: ~4 characters per token
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
validatePromptSize(prompt: string, maxTokens: number = config.ai.maxPromptTokens): boolean {
|
||||||
|
const estimatedTokens = this.estimateTokens(prompt);
|
||||||
|
if (estimatedTokens > maxTokens) {
|
||||||
|
logger.warn('ai-service', 'Prompt exceeds token limit', {
|
||||||
|
estimated: estimatedTokens,
|
||||||
|
limit: maxTokens,
|
||||||
|
promptLength: prompt.length
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility for parsing JSON responses with error handling
|
||||||
|
parseJSONResponse<T>(response: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
let cleaned = response.trim();
|
||||||
|
|
||||||
|
// Remove markdown code blocks
|
||||||
|
const jsonBlockPatterns = [
|
||||||
|
/```json\s*([\s\S]*?)\s*```/i,
|
||||||
|
/```\s*([\s\S]*?)\s*```/i,
|
||||||
|
/\{[\s\S]*\}/,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of jsonBlockPatterns) {
|
||||||
|
const match = cleaned.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
cleaned = match[1] || match[0];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle truncated JSON
|
||||||
|
if (!cleaned.endsWith('}') && !cleaned.endsWith(']')) {
|
||||||
|
logger.warn('ai-service', 'JSON appears truncated, attempting recovery');
|
||||||
|
|
||||||
|
let braceCount = 0;
|
||||||
|
let bracketCount = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
let lastCompleteStructure = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < cleaned.length; i++) {
|
||||||
|
const char = cleaned[i];
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"' && !escaped) {
|
||||||
|
inString = !inString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inString) {
|
||||||
|
if (char === '{') braceCount++;
|
||||||
|
if (char === '}') braceCount--;
|
||||||
|
if (char === '[') bracketCount++;
|
||||||
|
if (char === ']') bracketCount--;
|
||||||
|
|
||||||
|
if (braceCount === 0 && bracketCount === 0 && (char === '}' || char === ']')) {
|
||||||
|
lastCompleteStructure = cleaned.substring(0, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastCompleteStructure) {
|
||||||
|
cleaned = lastCompleteStructure;
|
||||||
|
} else {
|
||||||
|
if (braceCount > 0) cleaned += '}';
|
||||||
|
if (bracketCount > 0) cleaned += ']';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(cleaned);
|
||||||
|
|
||||||
|
logger.debug('ai-service', 'JSON parsing successful', {
|
||||||
|
originalLength: response.length,
|
||||||
|
cleanedLength: cleaned.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('ai-service', 'JSON parsing failed, using fallback', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
responsePreview: response.slice(0, 100)
|
||||||
|
});
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiService = new AIService();
|
228
src/services/ai/confidenceService.ts
Normal file
228
src/services/ai/confidenceService.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
// src/services/ai/confidenceService.ts
|
||||||
|
import { config } from '../../config/appConfig.js';
|
||||||
|
import { isToolHosted } from '../../utils/toolHelpers.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
export interface ConfidenceMetrics {
|
||||||
|
overall: number;
|
||||||
|
semanticRelevance: number;
|
||||||
|
taskSuitability: number;
|
||||||
|
uncertaintyFactors: string[];
|
||||||
|
strengthIndicators: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisContext {
|
||||||
|
userQuery: string;
|
||||||
|
mode: string;
|
||||||
|
embeddingsSimilarities: Map<string, number>;
|
||||||
|
selectedTools?: Array<{
|
||||||
|
tool: any;
|
||||||
|
phase: string;
|
||||||
|
priority: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConfidenceService {
|
||||||
|
private readonly semanticWeight: number;
|
||||||
|
private readonly suitabilityWeight: number;
|
||||||
|
private readonly minimumThreshold: number;
|
||||||
|
private readonly mediumThreshold: number;
|
||||||
|
private readonly highThreshold: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.semanticWeight = config.confidence.semanticWeight;
|
||||||
|
this.suitabilityWeight = config.confidence.suitabilityWeight;
|
||||||
|
this.minimumThreshold = config.confidence.minimumThreshold;
|
||||||
|
this.mediumThreshold = config.confidence.mediumThreshold;
|
||||||
|
this.highThreshold = config.confidence.highThreshold;
|
||||||
|
|
||||||
|
logger.info('confidence', 'Confidence service initialized', {
|
||||||
|
semanticWeight: this.semanticWeight,
|
||||||
|
suitabilityWeight: this.suitabilityWeight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateRecommendationConfidence(
|
||||||
|
tool: any,
|
||||||
|
context: AnalysisContext,
|
||||||
|
taskRelevance: number = 70,
|
||||||
|
limitations: string[] = []
|
||||||
|
): ConfidenceMetrics {
|
||||||
|
// Calculate semantic relevance from embeddings
|
||||||
|
const rawSemanticRelevance = context.embeddingsSimilarities.has(tool.name) ?
|
||||||
|
context.embeddingsSimilarities.get(tool.name)! * 100 : 50;
|
||||||
|
|
||||||
|
// Enhanced task suitability based on context
|
||||||
|
let enhancedTaskSuitability = taskRelevance;
|
||||||
|
|
||||||
|
if (context.mode === 'workflow') {
|
||||||
|
const toolSelection = context.selectedTools?.find((st: any) =>
|
||||||
|
st.tool && st.tool.name === tool.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (toolSelection && tool.phases && Array.isArray(tool.phases) &&
|
||||||
|
tool.phases.includes(toolSelection.phase)) {
|
||||||
|
const phaseBonus = Math.min(15, 100 - taskRelevance);
|
||||||
|
enhancedTaskSuitability = Math.min(100, taskRelevance + phaseBonus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate overall confidence
|
||||||
|
const overall = (
|
||||||
|
rawSemanticRelevance * this.semanticWeight +
|
||||||
|
enhancedTaskSuitability * this.suitabilityWeight
|
||||||
|
);
|
||||||
|
|
||||||
|
const uncertaintyFactors = this.identifyUncertaintyFactors(
|
||||||
|
tool, context, limitations, overall
|
||||||
|
);
|
||||||
|
|
||||||
|
const strengthIndicators = this.identifyStrengthIndicators(
|
||||||
|
tool, context, overall
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: ConfidenceMetrics = {
|
||||||
|
overall: Math.round(overall),
|
||||||
|
semanticRelevance: Math.round(rawSemanticRelevance),
|
||||||
|
taskSuitability: Math.round(enhancedTaskSuitability),
|
||||||
|
uncertaintyFactors,
|
||||||
|
strengthIndicators
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.confidence(
|
||||||
|
tool.name,
|
||||||
|
result.overall,
|
||||||
|
result.semanticRelevance,
|
||||||
|
result.taskSuitability
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private identifyUncertaintyFactors(
|
||||||
|
tool: any,
|
||||||
|
context: AnalysisContext,
|
||||||
|
limitations: string[],
|
||||||
|
confidence: number
|
||||||
|
): string[] {
|
||||||
|
const factors: string[] = [];
|
||||||
|
|
||||||
|
// Include provided limitations
|
||||||
|
if (limitations?.length > 0) {
|
||||||
|
factors.push(...limitations.slice(0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semantic similarity concerns
|
||||||
|
const similarity = context.embeddingsSimilarities.get(tool.name) || 0.5;
|
||||||
|
if (similarity < 0.7) {
|
||||||
|
factors.push('Geringe semantische Ähnlichkeit zur Anfrage');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill level vs query urgency mismatch
|
||||||
|
if (tool.skillLevel === 'expert' &&
|
||||||
|
/schnell|rapid|triage|urgent|sofort/i.test(context.userQuery)) {
|
||||||
|
factors.push('Experten-Tool für zeitkritisches Szenario');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.skillLevel === 'novice' &&
|
||||||
|
/komplex|erweitert|tiefgehend|advanced|forensisch/i.test(context.userQuery)) {
|
||||||
|
factors.push('Einsteiger-Tool für komplexe Analyse');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installation/setup requirements
|
||||||
|
if (tool.type === 'software' && !isToolHosted(tool) &&
|
||||||
|
tool.accessType === 'download') {
|
||||||
|
factors.push('Installation und Setup erforderlich');
|
||||||
|
}
|
||||||
|
|
||||||
|
// License considerations
|
||||||
|
if (tool.license === 'Proprietary') {
|
||||||
|
factors.push('Kommerzielle Software - Lizenzkosten zu beachten');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overall confidence concerns
|
||||||
|
if (confidence < 60) {
|
||||||
|
factors.push('Moderate Gesamtbewertung - alternative Ansätze empfohlen');
|
||||||
|
}
|
||||||
|
|
||||||
|
return factors.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private identifyStrengthIndicators(
|
||||||
|
tool: any,
|
||||||
|
context: AnalysisContext,
|
||||||
|
confidence: number
|
||||||
|
): string[] {
|
||||||
|
const indicators: string[] = [];
|
||||||
|
|
||||||
|
// High semantic similarity
|
||||||
|
const similarity = context.embeddingsSimilarities.get(tool.name) || 0.5;
|
||||||
|
if (similarity >= 0.7) {
|
||||||
|
indicators.push('Sehr gute semantische Übereinstimmung mit Ihrer Anfrage');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Documentation availability
|
||||||
|
if (tool.knowledgebase === true) {
|
||||||
|
indicators.push('Umfassende Dokumentation und Wissensbasis verfügbar');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediate availability
|
||||||
|
if (isToolHosted(tool)) {
|
||||||
|
indicators.push('Sofort verfügbar über gehostete Lösung');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balanced complexity
|
||||||
|
if (tool.skillLevel === 'intermediate' || tool.skillLevel === 'advanced') {
|
||||||
|
indicators.push('Ausgewogenes Verhältnis zwischen Funktionalität und Benutzerfreundlichkeit');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method alignment with query type
|
||||||
|
if (tool.type === 'method' &&
|
||||||
|
/methodik|vorgehen|prozess|ansatz/i.test(context.userQuery)) {
|
||||||
|
indicators.push('Methodischer Ansatz passt zu Ihrer prozeduralen Anfrage');
|
||||||
|
}
|
||||||
|
|
||||||
|
return indicators.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfidenceLevel(confidence: number): 'low' | 'medium' | 'high' {
|
||||||
|
if (confidence >= this.highThreshold) return 'high';
|
||||||
|
if (confidence >= this.mediumThreshold) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfidenceColor(confidence: number): string {
|
||||||
|
if (confidence >= this.highThreshold) return 'var(--color-accent)';
|
||||||
|
if (confidence >= this.mediumThreshold) return 'var(--color-warning)';
|
||||||
|
return 'var(--color-error)';
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateSelectionConfidence(
|
||||||
|
result: any,
|
||||||
|
candidateCount: number
|
||||||
|
): number {
|
||||||
|
if (!result?.selectedTools) return 30;
|
||||||
|
|
||||||
|
const selectionRatio = result.selectedTools.length / candidateCount;
|
||||||
|
const hasReasoning = result.reasoning && result.reasoning.length > 50;
|
||||||
|
|
||||||
|
let confidence = 60;
|
||||||
|
|
||||||
|
// Selection ratio scoring
|
||||||
|
if (selectionRatio > 0.05 && selectionRatio < 0.3) {
|
||||||
|
confidence += 20;
|
||||||
|
} else if (selectionRatio <= 0.05) {
|
||||||
|
confidence -= 10;
|
||||||
|
} else {
|
||||||
|
confidence -= 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quality indicators
|
||||||
|
if (hasReasoning) confidence += 15;
|
||||||
|
if (result.selectedConcepts?.length > 0) confidence += 5;
|
||||||
|
|
||||||
|
return Math.min(95, Math.max(25, confidence));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const confidenceService = new ConfidenceService();
|
510
src/services/ai/pipelineOrchestrator.ts
Normal file
510
src/services/ai/pipelineOrchestrator.ts
Normal file
@ -0,0 +1,510 @@
|
|||||||
|
// src/services/ai/pipelineOrchestrator.ts
|
||||||
|
import { getCompressedToolsDataForAI } from '../../utils/dataService.js';
|
||||||
|
import { getPrompt } from '../../config/prompts.js';
|
||||||
|
import { config } from '../../config/appConfig.js';
|
||||||
|
import { aiService } from './aiService.js';
|
||||||
|
import { toolSelector } from './toolSelector.js';
|
||||||
|
import { confidenceService } from './confidenceService.js';
|
||||||
|
import { auditService } from '../../utils/auditService.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
export interface AnalysisResult {
|
||||||
|
recommendation: any;
|
||||||
|
processingStats: {
|
||||||
|
embeddingsUsed: boolean;
|
||||||
|
candidatesFromEmbeddings: number;
|
||||||
|
finalSelectedItems: number;
|
||||||
|
processingTimeMs: number;
|
||||||
|
microTasksCompleted: number;
|
||||||
|
microTasksFailed: number;
|
||||||
|
contextContinuityUsed: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisContext {
|
||||||
|
userQuery: string;
|
||||||
|
mode: string;
|
||||||
|
filteredData: any;
|
||||||
|
selectedTools?: Array<{
|
||||||
|
tool: any;
|
||||||
|
phase: string;
|
||||||
|
priority: string;
|
||||||
|
justification?: string;
|
||||||
|
taskRelevance?: number;
|
||||||
|
limitations?: string[];
|
||||||
|
}>;
|
||||||
|
backgroundKnowledge?: Array<{
|
||||||
|
concept: any;
|
||||||
|
relevance: string;
|
||||||
|
}>;
|
||||||
|
seenToolNames: Set<string>;
|
||||||
|
embeddingsSimilarities: Map<string, number>;
|
||||||
|
scenarioAnalysis?: string;
|
||||||
|
problemAnalysis?: string;
|
||||||
|
investigationApproach?: string;
|
||||||
|
criticalConsiderations?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PipelineOrchestrator {
|
||||||
|
private readonly microTaskDelay: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.microTaskDelay = config.ai.microTaskDelay;
|
||||||
|
logger.info('pipeline', 'Pipeline orchestrator initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
async processQuery(userQuery: string, mode: string): Promise<AnalysisResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let completeTasks = 0;
|
||||||
|
let failedTasks = 0;
|
||||||
|
|
||||||
|
logger.pipeline('initialization', 'processing started', { userQuery: userQuery.slice(0, 50), mode });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const toolsData = await getCompressedToolsDataForAI();
|
||||||
|
|
||||||
|
const context: AnalysisContext = {
|
||||||
|
userQuery,
|
||||||
|
mode,
|
||||||
|
filteredData: {},
|
||||||
|
seenToolNames: new Set<string>(),
|
||||||
|
embeddingsSimilarities: new Map<string, number>()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 1: Get candidates and select tools
|
||||||
|
const candidateData = await toolSelector.getCandidates(userQuery, toolsData);
|
||||||
|
const toolSelection = await toolSelector.selectTools(
|
||||||
|
userQuery,
|
||||||
|
candidateData.tools,
|
||||||
|
candidateData.concepts,
|
||||||
|
mode,
|
||||||
|
candidateData.selectionMethod
|
||||||
|
);
|
||||||
|
|
||||||
|
context.filteredData = {
|
||||||
|
tools: toolSelection.selectedTools,
|
||||||
|
concepts: toolSelection.selectedConcepts,
|
||||||
|
domains: toolsData.domains,
|
||||||
|
phases: toolsData.phases,
|
||||||
|
'domain-agnostic-software': toolsData['domain-agnostic-software']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: Scenario/Problem Analysis
|
||||||
|
const analysisResult = await this.analyzeScenario(context);
|
||||||
|
if (analysisResult.success) completeTasks++; else failedTasks++;
|
||||||
|
await this.delay(this.microTaskDelay);
|
||||||
|
|
||||||
|
// Step 3: Investigation Approach
|
||||||
|
const approachResult = await this.generateApproach(context);
|
||||||
|
if (approachResult.success) completeTasks++; else failedTasks++;
|
||||||
|
await this.delay(this.microTaskDelay);
|
||||||
|
|
||||||
|
// Step 4: Critical Considerations
|
||||||
|
const considerationsResult = await this.generateCriticalConsiderations(context);
|
||||||
|
if (considerationsResult.success) completeTasks++; else failedTasks++;
|
||||||
|
await this.delay(this.microTaskDelay);
|
||||||
|
|
||||||
|
// Step 5: Mode-specific processing
|
||||||
|
if (mode === 'workflow') {
|
||||||
|
const workflowTasks = await this.processWorkflowMode(context, toolsData);
|
||||||
|
completeTasks += workflowTasks.completed;
|
||||||
|
failedTasks += workflowTasks.failed;
|
||||||
|
} else {
|
||||||
|
const toolTasks = await this.processToolMode(context);
|
||||||
|
completeTasks += toolTasks.completed;
|
||||||
|
failedTasks += toolTasks.failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Background Knowledge
|
||||||
|
const knowledgeResult = await this.selectBackgroundKnowledge(context);
|
||||||
|
if (knowledgeResult.success) completeTasks++; else failedTasks++;
|
||||||
|
await this.delay(this.microTaskDelay);
|
||||||
|
|
||||||
|
// Step 7: Final Recommendations
|
||||||
|
const finalResult = await this.generateFinalRecommendations(context);
|
||||||
|
if (finalResult.success) completeTasks++; else failedTasks++;
|
||||||
|
|
||||||
|
const recommendation = this.buildRecommendation(context, mode, finalResult.content);
|
||||||
|
|
||||||
|
const processingStats = {
|
||||||
|
embeddingsUsed: candidateData.selectionMethod === 'embeddings_candidates',
|
||||||
|
candidatesFromEmbeddings: candidateData.tools.length,
|
||||||
|
finalSelectedItems: (context.selectedTools?.length || 0) + (context.backgroundKnowledge?.length || 0),
|
||||||
|
processingTimeMs: Date.now() - startTime,
|
||||||
|
microTasksCompleted: completeTasks,
|
||||||
|
microTasksFailed: failedTasks,
|
||||||
|
contextContinuityUsed: true
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.pipeline('completion', 'processing completed', {
|
||||||
|
duration: `${processingStats.processingTimeMs}ms`,
|
||||||
|
tasksCompleted: completeTasks,
|
||||||
|
tasksFailed: failedTasks,
|
||||||
|
finalItems: processingStats.finalSelectedItems
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
recommendation: {
|
||||||
|
...recommendation,
|
||||||
|
auditTrail: auditService.isEnabled() ? [] : undefined // Audit trail handled by auditService
|
||||||
|
},
|
||||||
|
processingStats
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.error('pipeline', 'Processing failed', error as Error, {
|
||||||
|
duration: `${duration}ms`,
|
||||||
|
completedTasks: completeTasks,
|
||||||
|
failedTasks: failedTasks
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async analyzeScenario(context: AnalysisContext): Promise<{ success: boolean; content: string }> {
|
||||||
|
try {
|
||||||
|
const isWorkflow = context.mode === 'workflow';
|
||||||
|
const prompt = getPrompt('scenarioAnalysis', isWorkflow, context.userQuery);
|
||||||
|
|
||||||
|
const response = await aiService.callMicroTask(prompt, 'scenario-analysis', 400);
|
||||||
|
|
||||||
|
if (isWorkflow) {
|
||||||
|
context.scenarioAnalysis = response.content;
|
||||||
|
} else {
|
||||||
|
context.problemAnalysis = response.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.pipeline('micro-task', `${isWorkflow ? 'scenario' : 'problem'} analysis completed`);
|
||||||
|
return { success: true, content: response.content };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('pipeline', 'Scenario analysis failed', error as Error);
|
||||||
|
return { success: false, content: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateApproach(context: AnalysisContext): Promise<{ success: boolean; content: string }> {
|
||||||
|
try {
|
||||||
|
const isWorkflow = context.mode === 'workflow';
|
||||||
|
const prompt = getPrompt('investigationApproach', isWorkflow, context.userQuery);
|
||||||
|
|
||||||
|
const response = await aiService.callMicroTask(prompt, 'investigation-approach', 400);
|
||||||
|
context.investigationApproach = response.content;
|
||||||
|
|
||||||
|
logger.pipeline('micro-task', 'investigation approach completed');
|
||||||
|
return { success: true, content: response.content };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('pipeline', 'Investigation approach failed', error as Error);
|
||||||
|
return { success: false, content: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateCriticalConsiderations(context: AnalysisContext): Promise<{ success: boolean; content: string }> {
|
||||||
|
try {
|
||||||
|
const isWorkflow = context.mode === 'workflow';
|
||||||
|
const prompt = getPrompt('criticalConsiderations', isWorkflow, context.userQuery);
|
||||||
|
|
||||||
|
const response = await aiService.callMicroTask(prompt, 'critical-considerations', 350);
|
||||||
|
context.criticalConsiderations = response.content;
|
||||||
|
|
||||||
|
logger.pipeline('micro-task', 'critical considerations completed');
|
||||||
|
return { success: true, content: response.content };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('pipeline', 'Critical considerations failed', error as Error);
|
||||||
|
return { success: false, content: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processWorkflowMode(context: AnalysisContext, toolsData: any): Promise<{ completed: number; failed: number }> {
|
||||||
|
let completed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
const phases = toolsData.phases || [];
|
||||||
|
|
||||||
|
for (const phase of phases) {
|
||||||
|
try {
|
||||||
|
const result = await this.selectToolsForPhase(context, phase);
|
||||||
|
if (result.success) completed++; else failed++;
|
||||||
|
await this.delay(this.microTaskDelay);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('pipeline', `Phase ${phase.id} tool selection failed`, error as Error);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.pipeline('workflow', 'phase processing completed', {
|
||||||
|
phases: phases.length,
|
||||||
|
completed,
|
||||||
|
failed
|
||||||
|
});
|
||||||
|
|
||||||
|
return { completed, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processToolMode(context: AnalysisContext): Promise<{ completed: number; failed: number }> {
|
||||||
|
let completed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
const topTools = context.filteredData.tools.slice(0, 3);
|
||||||
|
|
||||||
|
for (let i = 0; i < topTools.length; i++) {
|
||||||
|
try {
|
||||||
|
const result = await this.evaluateSpecificTool(context, topTools[i], i + 1);
|
||||||
|
if (result.success) completed++; else failed++;
|
||||||
|
await this.delay(this.microTaskDelay);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('pipeline', `Tool evaluation failed for ${topTools[i]?.name}`, error as Error);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.pipeline('tool-mode', 'tool evaluation completed', {
|
||||||
|
toolsEvaluated: topTools.length,
|
||||||
|
completed,
|
||||||
|
failed
|
||||||
|
});
|
||||||
|
|
||||||
|
return { completed, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async selectToolsForPhase(context: AnalysisContext, phase: any): Promise<{ success: boolean }> {
|
||||||
|
const phaseTools = context.filteredData.tools.filter((tool: any) =>
|
||||||
|
tool && tool.phases && Array.isArray(tool.phases) && tool.phases.includes(phase.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (phaseTools.length === 0) {
|
||||||
|
logger.debug('pipeline', `No tools available for phase ${phase.id}`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prompt = getPrompt('phaseToolSelection', context.userQuery, phase, phaseTools);
|
||||||
|
const response = await aiService.callMicroTask(prompt, `phase-${phase.id}`, 1000);
|
||||||
|
|
||||||
|
const selections = aiService.parseJSONResponse(response.content, []);
|
||||||
|
|
||||||
|
if (Array.isArray(selections)) {
|
||||||
|
const validSelections = selections.filter((sel: any) => {
|
||||||
|
return phaseTools.some((tool: any) => tool && tool.name === sel.toolName);
|
||||||
|
});
|
||||||
|
|
||||||
|
validSelections.forEach((sel: any) => {
|
||||||
|
const tool = phaseTools.find((t: any) => t && t.name === sel.toolName);
|
||||||
|
if (tool) {
|
||||||
|
const taskRelevance = typeof sel.taskRelevance === 'number' ?
|
||||||
|
sel.taskRelevance : parseInt(String(sel.taskRelevance)) || 70;
|
||||||
|
|
||||||
|
this.addToolToSelection(context, tool, phase.id, this.derivePriorityFromScore(taskRelevance),
|
||||||
|
sel.justification, taskRelevance, sel.limitations);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.pipeline('phase-selection', `Phase ${phase.id} completed`, {
|
||||||
|
available: phaseTools.length,
|
||||||
|
selected: validSelections.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('pipeline', `Phase ${phase.id} tool selection failed`, error as Error);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evaluateSpecificTool(context: AnalysisContext, tool: any, rank: number): Promise<{ success: boolean }> {
|
||||||
|
try {
|
||||||
|
const existingSelection = context.selectedTools?.find((st: any) => st.tool && st.tool.name === tool.name);
|
||||||
|
const taskRelevance = existingSelection?.taskRelevance || 70;
|
||||||
|
const priority = this.derivePriorityFromScore(taskRelevance);
|
||||||
|
|
||||||
|
const prompt = getPrompt('toolEvaluation', context.userQuery, tool, rank, taskRelevance);
|
||||||
|
const response = await aiService.callMicroTask(prompt, `tool-eval-${tool.name}`, 1000);
|
||||||
|
|
||||||
|
const evaluation = aiService.parseJSONResponse(response.content, {
|
||||||
|
detailed_explanation: 'Evaluation failed',
|
||||||
|
implementation_approach: '',
|
||||||
|
pros: [],
|
||||||
|
limitations: [],
|
||||||
|
alternatives: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addToolToSelection(context, {
|
||||||
|
...tool,
|
||||||
|
evaluation: {
|
||||||
|
...evaluation,
|
||||||
|
rank,
|
||||||
|
task_relevance: taskRelevance
|
||||||
|
}
|
||||||
|
}, 'evaluation', priority, evaluation.detailed_explanation, taskRelevance, evaluation.limitations);
|
||||||
|
|
||||||
|
logger.pipeline('tool-evaluation', `Tool ${tool.name} evaluated`, { rank, taskRelevance });
|
||||||
|
return { success: true };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('pipeline', `Tool evaluation failed for ${tool.name}`, error as Error);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async selectBackgroundKnowledge(context: AnalysisContext): Promise<{ success: boolean }> {
|
||||||
|
const availableConcepts = context.filteredData.concepts;
|
||||||
|
|
||||||
|
if (availableConcepts.length === 0) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const selectedToolNames = context.selectedTools?.map((st: any) => st.tool && st.tool.name).filter(Boolean) || [];
|
||||||
|
const prompt = getPrompt('backgroundKnowledgeSelection', context.userQuery, context.mode, selectedToolNames, availableConcepts);
|
||||||
|
const response = await aiService.callMicroTask(prompt, 'background-knowledge', 700);
|
||||||
|
|
||||||
|
const selections = aiService.parseJSONResponse(response.content, []);
|
||||||
|
|
||||||
|
if (Array.isArray(selections)) {
|
||||||
|
context.backgroundKnowledge = selections.filter((sel: any) =>
|
||||||
|
sel.conceptName && availableConcepts.some((concept: any) => concept.name === sel.conceptName)
|
||||||
|
).map((sel: any) => ({
|
||||||
|
concept: availableConcepts.find((c: any) => c.name === sel.conceptName),
|
||||||
|
relevance: sel.relevance
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.pipeline('background-knowledge', 'selection completed', {
|
||||||
|
available: availableConcepts.length,
|
||||||
|
selected: context.backgroundKnowledge?.length || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('pipeline', 'Background knowledge selection failed', error as Error);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateFinalRecommendations(context: AnalysisContext): Promise<{ success: boolean; content: string }> {
|
||||||
|
try {
|
||||||
|
const selectedToolNames = context.selectedTools?.map((st: any) => st.tool && st.tool.name).filter(Boolean) || [];
|
||||||
|
const prompt = getPrompt('finalRecommendations', context.mode === 'workflow', context.userQuery, selectedToolNames);
|
||||||
|
|
||||||
|
const response = await aiService.callMicroTask(prompt, 'final-recommendations', 350);
|
||||||
|
|
||||||
|
logger.pipeline('final', 'recommendations generated');
|
||||||
|
return { success: true, content: response.content };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('pipeline', 'Final recommendations failed', error as Error);
|
||||||
|
return { success: false, content: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addToolToSelection(
|
||||||
|
context: AnalysisContext,
|
||||||
|
tool: any,
|
||||||
|
phase: string,
|
||||||
|
priority: string,
|
||||||
|
justification?: string,
|
||||||
|
taskRelevance?: number,
|
||||||
|
limitations?: string[]
|
||||||
|
): void {
|
||||||
|
context.seenToolNames.add(tool.name);
|
||||||
|
if (!context.selectedTools) context.selectedTools = [];
|
||||||
|
|
||||||
|
context.selectedTools.push({
|
||||||
|
tool,
|
||||||
|
phase,
|
||||||
|
priority,
|
||||||
|
justification,
|
||||||
|
taskRelevance,
|
||||||
|
limitations
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private derivePriorityFromScore(taskRelevance: number): string {
|
||||||
|
if (taskRelevance >= 80) return 'high';
|
||||||
|
if (taskRelevance >= 60) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRecommendation(context: AnalysisContext, mode: string, finalContent: string): any {
|
||||||
|
const isWorkflow = mode === 'workflow';
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
[isWorkflow ? 'scenario_analysis' : 'problem_analysis']:
|
||||||
|
isWorkflow ? context.scenarioAnalysis : context.problemAnalysis,
|
||||||
|
investigation_approach: context.investigationApproach,
|
||||||
|
critical_considerations: context.criticalConsiderations,
|
||||||
|
background_knowledge: context.backgroundKnowledge?.map((bk: any) => ({
|
||||||
|
concept_name: bk.concept.name,
|
||||||
|
relevance: bk.relevance
|
||||||
|
})) || []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isWorkflow) {
|
||||||
|
const recommendedToolsWithConfidence = context.selectedTools?.map((st: any) => {
|
||||||
|
const confidence = confidenceService.calculateRecommendationConfidence(
|
||||||
|
st.tool,
|
||||||
|
context,
|
||||||
|
st.taskRelevance || 70,
|
||||||
|
st.limitations || []
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: st.tool.name,
|
||||||
|
type: st.tool.type,
|
||||||
|
phase: st.phase,
|
||||||
|
priority: st.priority,
|
||||||
|
justification: st.justification || `Empfohlen für ${st.phase}`,
|
||||||
|
confidence,
|
||||||
|
recommendationStrength: confidenceService.getConfidenceLevel(confidence.overall)
|
||||||
|
};
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
recommended_tools: recommendedToolsWithConfidence,
|
||||||
|
workflow_suggestion: finalContent
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const recommendedToolsWithConfidence = context.selectedTools?.map((st: any) => {
|
||||||
|
const confidence = confidenceService.calculateRecommendationConfidence(
|
||||||
|
st.tool,
|
||||||
|
context,
|
||||||
|
st.taskRelevance || 70,
|
||||||
|
st.limitations || []
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: st.tool.name,
|
||||||
|
type: st.tool.type,
|
||||||
|
rank: st.tool.evaluation?.rank || 1,
|
||||||
|
suitability_score: st.priority,
|
||||||
|
detailed_explanation: st.tool.evaluation?.detailed_explanation || '',
|
||||||
|
implementation_approach: st.tool.evaluation?.implementation_approach || '',
|
||||||
|
pros: st.tool.evaluation?.pros || [],
|
||||||
|
cons: st.tool.evaluation?.limitations || [],
|
||||||
|
alternatives: st.tool.evaluation?.alternatives || '',
|
||||||
|
confidence,
|
||||||
|
recommendationStrength: confidenceService.getConfidenceLevel(confidence.overall)
|
||||||
|
};
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
recommended_tools: recommendedToolsWithConfidence,
|
||||||
|
additional_considerations: finalContent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pipelineOrchestrator = new PipelineOrchestrator();
|
354
src/services/ai/toolSelector.ts
Normal file
354
src/services/ai/toolSelector.ts
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
// src/services/ai/toolSelector.ts
|
||||||
|
import { config } from '../../config/appConfig.js';
|
||||||
|
import { aiService } from './aiService.js';
|
||||||
|
import { embeddingsService } from '../../utils/embeddings.js';
|
||||||
|
import { getPrompt } from '../../config/prompts.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
export interface ToolSelection {
|
||||||
|
selectedTools: any[];
|
||||||
|
selectedConcepts: any[];
|
||||||
|
selectionMethod: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateData {
|
||||||
|
tools: any[];
|
||||||
|
concepts: any[];
|
||||||
|
selectionMethod: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ToolSelector {
|
||||||
|
private readonly embeddingCandidates: number;
|
||||||
|
private readonly similarityThreshold: number;
|
||||||
|
private readonly embeddingSelectionLimit: number;
|
||||||
|
private readonly embeddingConceptsLimit: number;
|
||||||
|
private readonly noEmbeddingsToolLimit: number;
|
||||||
|
private readonly noEmbeddingsConceptLimit: number;
|
||||||
|
private readonly embeddingsMinTools: number;
|
||||||
|
private readonly embeddingsMaxReductionRatio: number;
|
||||||
|
private readonly methodSelectionRatio: number;
|
||||||
|
private readonly softwareSelectionRatio: number;
|
||||||
|
private readonly maxSelectedItems: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.embeddingCandidates = config.ai.embeddingCandidates;
|
||||||
|
this.similarityThreshold = config.ai.similarityThreshold;
|
||||||
|
this.embeddingSelectionLimit = config.ai.embeddingSelectionLimit;
|
||||||
|
this.embeddingConceptsLimit = config.ai.embeddingConceptsLimit;
|
||||||
|
this.noEmbeddingsToolLimit = config.ai.noEmbeddingsToolLimit;
|
||||||
|
this.noEmbeddingsConceptLimit = config.ai.noEmbeddingsConceptLimit;
|
||||||
|
this.embeddingsMinTools = config.ai.embeddingsMinTools;
|
||||||
|
this.embeddingsMaxReductionRatio = config.ai.embeddingsMaxReductionRatio;
|
||||||
|
this.methodSelectionRatio = config.ai.methodSelectionRatio;
|
||||||
|
this.softwareSelectionRatio = config.ai.softwareSelectionRatio;
|
||||||
|
this.maxSelectedItems = config.ai.maxSelectedItems;
|
||||||
|
|
||||||
|
logger.info('tool-selector', 'Tool selector initialized', {
|
||||||
|
embeddingCandidates: this.embeddingCandidates,
|
||||||
|
methodRatio: `${(this.methodSelectionRatio * 100).toFixed(0)}%`,
|
||||||
|
softwareRatio: `${(this.softwareSelectionRatio * 100).toFixed(0)}%`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCandidates(
|
||||||
|
userQuery: string,
|
||||||
|
toolsData: any
|
||||||
|
): Promise<CandidateData> {
|
||||||
|
try {
|
||||||
|
await embeddingsService.waitForInitialization();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('tool-selector', 'Embeddings initialization failed, using full dataset');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embeddingsService.isEnabled()) {
|
||||||
|
return this.getEmbeddingCandidates(userQuery, toolsData);
|
||||||
|
} else {
|
||||||
|
return this.getFullDatasetCandidates(toolsData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getEmbeddingCandidates(
|
||||||
|
userQuery: string,
|
||||||
|
toolsData: any
|
||||||
|
): Promise<CandidateData> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const similarItems = await embeddingsService.findSimilar(
|
||||||
|
userQuery,
|
||||||
|
this.embeddingCandidates,
|
||||||
|
this.similarityThreshold
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.embedding('similarity search completed', {
|
||||||
|
query: userQuery.slice(0, 50),
|
||||||
|
candidates: this.embeddingCandidates,
|
||||||
|
threshold: this.similarityThreshold,
|
||||||
|
results: similarItems.length,
|
||||||
|
duration: `${Date.now() - startTime}ms`
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolsMap = new Map(toolsData.tools.map((tool: any) => [tool.name, tool]));
|
||||||
|
const conceptsMap = new Map(toolsData.concepts.map((concept: any) => [concept.name, concept]));
|
||||||
|
|
||||||
|
const similarTools = similarItems
|
||||||
|
.filter((item: any) => item.type === 'tool')
|
||||||
|
.map((item: any) => toolsMap.get(item.name))
|
||||||
|
.filter((tool: any): tool is NonNullable<any> => tool !== undefined && tool !== null);
|
||||||
|
|
||||||
|
const similarConcepts = similarItems
|
||||||
|
.filter((item: any) => item.type === 'concept')
|
||||||
|
.map((item: any) => conceptsMap.get(item.name))
|
||||||
|
.filter((concept: any): concept is NonNullable<any> => concept !== undefined && concept !== null);
|
||||||
|
|
||||||
|
const totalAvailableTools = toolsData.tools.length;
|
||||||
|
const reductionRatio = similarTools.length / totalAvailableTools;
|
||||||
|
|
||||||
|
const useEmbeddings = similarTools.length >= this.embeddingsMinTools &&
|
||||||
|
reductionRatio <= this.embeddingsMaxReductionRatio;
|
||||||
|
|
||||||
|
if (useEmbeddings) {
|
||||||
|
logger.selection('embeddings', similarTools.length, similarConcepts.length, {
|
||||||
|
reduction: `${totalAvailableTools} → ${similarTools.length}`,
|
||||||
|
ratio: reductionRatio.toFixed(2)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
tools: similarTools,
|
||||||
|
concepts: similarConcepts,
|
||||||
|
selectionMethod: 'embeddings_candidates'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
logger.info('tool-selector', 'Embeddings filtering insufficient, using full dataset', {
|
||||||
|
toolsFound: similarTools.length,
|
||||||
|
minRequired: this.embeddingsMinTools,
|
||||||
|
reductionRatio: reductionRatio.toFixed(2),
|
||||||
|
maxRatio: this.embeddingsMaxReductionRatio
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getFullDatasetCandidates(toolsData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFullDatasetCandidates(toolsData: any): CandidateData {
|
||||||
|
logger.selection('full-dataset', toolsData.tools.length, toolsData.concepts.length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tools: toolsData.tools,
|
||||||
|
concepts: toolsData.concepts,
|
||||||
|
selectionMethod: 'full_dataset'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectTools(
|
||||||
|
userQuery: string,
|
||||||
|
candidateTools: any[],
|
||||||
|
candidateConcepts: any[],
|
||||||
|
mode: string,
|
||||||
|
selectionMethod: string
|
||||||
|
): Promise<ToolSelection> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const candidateMethods = candidateTools.filter((tool: any) =>
|
||||||
|
tool && tool.type === 'method'
|
||||||
|
);
|
||||||
|
const candidateSoftware = candidateTools.filter((tool: any) =>
|
||||||
|
tool && tool.type === 'software'
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('tool-selector', 'Starting AI tool selection', {
|
||||||
|
mode,
|
||||||
|
selectionMethod,
|
||||||
|
methods: candidateMethods.length,
|
||||||
|
software: candidateSoftware.length,
|
||||||
|
concepts: candidateConcepts.length
|
||||||
|
});
|
||||||
|
|
||||||
|
const { toolsToSend, conceptsToSend } = this.prepareSelectionData(
|
||||||
|
candidateMethods,
|
||||||
|
candidateSoftware,
|
||||||
|
candidateConcepts,
|
||||||
|
selectionMethod
|
||||||
|
);
|
||||||
|
|
||||||
|
const basePrompt = getPrompt(
|
||||||
|
'toolSelection',
|
||||||
|
mode,
|
||||||
|
userQuery,
|
||||||
|
selectionMethod,
|
||||||
|
this.maxSelectedItems
|
||||||
|
);
|
||||||
|
|
||||||
|
const prompt = getPrompt(
|
||||||
|
'toolSelectionWithData',
|
||||||
|
basePrompt,
|
||||||
|
toolsToSend,
|
||||||
|
conceptsToSend
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!aiService.validatePromptSize(prompt, 35000)) {
|
||||||
|
throw new Error('Prompt too large for AI model');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await aiService.call({
|
||||||
|
prompt,
|
||||||
|
maxTokens: 2500,
|
||||||
|
context: `tool-selection-${mode}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = aiService.parseJSONResponse(response.content, {
|
||||||
|
selectedTools: [],
|
||||||
|
selectedConcepts: [],
|
||||||
|
reasoning: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Array.isArray(result.selectedTools) || !Array.isArray(result.selectedConcepts)) {
|
||||||
|
throw new Error('AI selection returned invalid structure');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSelected = result.selectedTools.length + result.selectedConcepts.length;
|
||||||
|
if (totalSelected === 0) {
|
||||||
|
throw new Error('AI selection returned empty selection');
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTools = this.mapSelectedItems(result.selectedTools, candidateTools);
|
||||||
|
const selectedConcepts = this.mapSelectedItems(result.selectedConcepts, candidateConcepts);
|
||||||
|
|
||||||
|
const selectedMethods = selectedTools.filter((t: any) => t && t.type === 'method');
|
||||||
|
const selectedSoftware = selectedTools.filter((t: any) => t && t.type === 'software');
|
||||||
|
|
||||||
|
const confidence = this.calculateSelectionConfidence(result, candidateTools.length + candidateConcepts.length);
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
logger.selection('ai-completed', selectedTools.length, selectedConcepts.length, {
|
||||||
|
methods: selectedMethods.length,
|
||||||
|
software: selectedSoftware.length,
|
||||||
|
confidence: `${confidence}%`,
|
||||||
|
duration: `${duration}ms`,
|
||||||
|
selectionMethod
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedTools,
|
||||||
|
selectedConcepts,
|
||||||
|
selectionMethod,
|
||||||
|
confidence
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareSelectionData(
|
||||||
|
candidateMethods: any[],
|
||||||
|
candidateSoftware: any[],
|
||||||
|
candidateConcepts: any[],
|
||||||
|
selectionMethod: string
|
||||||
|
): { toolsToSend: any[], conceptsToSend: any[] } {
|
||||||
|
const isEmbeddingsBased = selectionMethod === 'embeddings_candidates';
|
||||||
|
|
||||||
|
const totalLimit = isEmbeddingsBased ?
|
||||||
|
this.embeddingSelectionLimit :
|
||||||
|
this.noEmbeddingsToolLimit;
|
||||||
|
|
||||||
|
const conceptLimit = isEmbeddingsBased ?
|
||||||
|
this.embeddingConceptsLimit :
|
||||||
|
this.noEmbeddingsConceptLimit;
|
||||||
|
|
||||||
|
const methodLimit = Math.ceil(totalLimit * this.methodSelectionRatio);
|
||||||
|
const softwareLimit = Math.floor(totalLimit * this.softwareSelectionRatio);
|
||||||
|
|
||||||
|
const toolsToSend = [
|
||||||
|
...candidateMethods.slice(0, methodLimit).map(this.createToolData),
|
||||||
|
...candidateSoftware.slice(0, softwareLimit).map(this.createToolData)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fill remaining capacity
|
||||||
|
const remainingCapacity = totalLimit - toolsToSend.length;
|
||||||
|
if (remainingCapacity > 0) {
|
||||||
|
if (candidateMethods.length > methodLimit) {
|
||||||
|
toolsToSend.push(
|
||||||
|
...candidateMethods
|
||||||
|
.slice(methodLimit, methodLimit + remainingCapacity)
|
||||||
|
.map(this.createToolData)
|
||||||
|
);
|
||||||
|
} else if (candidateSoftware.length > softwareLimit) {
|
||||||
|
toolsToSend.push(
|
||||||
|
...candidateSoftware
|
||||||
|
.slice(softwareLimit, softwareLimit + remainingCapacity)
|
||||||
|
.map(this.createToolData)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const conceptsToSend = candidateConcepts
|
||||||
|
.slice(0, conceptLimit)
|
||||||
|
.map(this.createConceptData);
|
||||||
|
|
||||||
|
logger.debug('tool-selector', 'Selection data prepared', {
|
||||||
|
toolsToSend: toolsToSend.length,
|
||||||
|
conceptsToSend: conceptsToSend.length,
|
||||||
|
methodLimit,
|
||||||
|
softwareLimit,
|
||||||
|
conceptLimit,
|
||||||
|
isEmbeddingsBased
|
||||||
|
});
|
||||||
|
|
||||||
|
return { toolsToSend, conceptsToSend };
|
||||||
|
}
|
||||||
|
|
||||||
|
private createToolData = (tool: any) => ({
|
||||||
|
name: tool.name,
|
||||||
|
type: tool.type,
|
||||||
|
description: tool.description,
|
||||||
|
domains: tool.domains,
|
||||||
|
phases: tool.phases,
|
||||||
|
platforms: tool.platforms || [],
|
||||||
|
tags: tool.tags || [],
|
||||||
|
skillLevel: tool.skillLevel,
|
||||||
|
license: tool.license,
|
||||||
|
accessType: tool.accessType,
|
||||||
|
projectUrl: tool.projectUrl,
|
||||||
|
knowledgebase: tool.knowledgebase,
|
||||||
|
related_concepts: tool.related_concepts || [],
|
||||||
|
related_software: tool.related_software || []
|
||||||
|
});
|
||||||
|
|
||||||
|
private createConceptData = (concept: any) => ({
|
||||||
|
name: concept.name,
|
||||||
|
type: 'concept',
|
||||||
|
description: concept.description,
|
||||||
|
domains: concept.domains,
|
||||||
|
phases: concept.phases,
|
||||||
|
tags: concept.tags || [],
|
||||||
|
skillLevel: concept.skillLevel,
|
||||||
|
related_concepts: concept.related_concepts || [],
|
||||||
|
related_software: concept.related_software || []
|
||||||
|
});
|
||||||
|
|
||||||
|
private mapSelectedItems(selectedNames: string[], candidates: any[]): any[] {
|
||||||
|
const candidatesMap = new Map(candidates.map((item: any) => [item.name, item]));
|
||||||
|
|
||||||
|
return selectedNames
|
||||||
|
.map((name: string) => candidatesMap.get(name))
|
||||||
|
.filter((item: any): item is NonNullable<any> => item !== undefined && item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateSelectionConfidence(result: any, candidateCount: number): number {
|
||||||
|
if (!result?.selectedTools) return 30;
|
||||||
|
|
||||||
|
const selectionRatio = result.selectedTools.length / candidateCount;
|
||||||
|
const hasReasoning = result.reasoning && result.reasoning.length > 50;
|
||||||
|
|
||||||
|
let confidence = 60;
|
||||||
|
|
||||||
|
if (selectionRatio > 0.05 && selectionRatio < 0.3) confidence += 20;
|
||||||
|
else if (selectionRatio <= 0.05) confidence -= 10;
|
||||||
|
else confidence -= 15;
|
||||||
|
|
||||||
|
if (hasReasoning) confidence += 15;
|
||||||
|
if (result.selectedConcepts?.length > 0) confidence += 5;
|
||||||
|
|
||||||
|
return Math.min(95, Math.max(25, confidence));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toolSelector = new ToolSelector();
|
159
src/services/logger.ts
Normal file
159
src/services/logger.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
// src/services/logger.ts
|
||||||
|
|
||||||
|
export enum LogLevel {
|
||||||
|
ERROR = 0,
|
||||||
|
WARN = 1,
|
||||||
|
INFO = 2,
|
||||||
|
DEBUG = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogContext {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
private logLevel: LogLevel;
|
||||||
|
private enabledContexts: Set<string>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.logLevel = this.getLogLevel();
|
||||||
|
this.enabledContexts = this.getEnabledContexts();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLogLevel(): LogLevel {
|
||||||
|
const level = process.env.LOG_LEVEL?.toUpperCase() || 'INFO';
|
||||||
|
switch (level) {
|
||||||
|
case 'ERROR': return LogLevel.ERROR;
|
||||||
|
case 'WARN': return LogLevel.WARN;
|
||||||
|
case 'DEBUG': return LogLevel.DEBUG;
|
||||||
|
default: return LogLevel.INFO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEnabledContexts(): Set<string> {
|
||||||
|
const contexts = process.env.LOG_CONTEXTS?.split(',') || [];
|
||||||
|
return new Set(contexts.map(c => c.trim().toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldLog(level: LogLevel, context: string): boolean {
|
||||||
|
if (level > this.logLevel) return false;
|
||||||
|
if (this.enabledContexts.size > 0 && !this.enabledContexts.has(context.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatMessage(level: string, context: string, message: string, data?: LogContext): string {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const contextStr = context.toUpperCase().padEnd(12);
|
||||||
|
let logMessage = `[${timestamp}] ${level.padEnd(5)} [${contextStr}] ${message}`;
|
||||||
|
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
const dataStr = Object.entries(data)
|
||||||
|
.map(([key, value]) => `${key}=${typeof value === 'object' ? JSON.stringify(value) : value}`)
|
||||||
|
.join(' ');
|
||||||
|
logMessage += ` | ${dataStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return logMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
error(context: string, message: string, error?: Error, data?: LogContext): void {
|
||||||
|
if (!this.shouldLog(LogLevel.ERROR, context)) return;
|
||||||
|
|
||||||
|
const logData = { ...data };
|
||||||
|
if (error) {
|
||||||
|
logData.error = error.message;
|
||||||
|
logData.stack = error.stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(this.formatMessage('ERROR', context, message, logData));
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(context: string, message: string, data?: LogContext): void {
|
||||||
|
if (!this.shouldLog(LogLevel.WARN, context)) return;
|
||||||
|
console.warn(this.formatMessage('WARN', context, message, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
info(context: string, message: string, data?: LogContext): void {
|
||||||
|
if (!this.shouldLog(LogLevel.INFO, context)) return;
|
||||||
|
console.log(this.formatMessage('INFO', context, message, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(context: string, message: string, data?: LogContext): void {
|
||||||
|
if (!this.shouldLog(LogLevel.DEBUG, context)) return;
|
||||||
|
console.log(this.formatMessage('DEBUG', context, message, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specialized logging methods for common contexts
|
||||||
|
pipeline(phase: string, action: string, details?: LogContext): void {
|
||||||
|
this.info('pipeline', `${phase}/${action}`, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
audit(phase: string, action: string, confidence: number, timeMs: number, metadata?: LogContext): void {
|
||||||
|
this.info('audit', `${phase}/${action} completed`, {
|
||||||
|
confidence: `${confidence}%`,
|
||||||
|
duration: `${timeMs}ms`,
|
||||||
|
...metadata
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
api(method: string, path: string, status: number, details?: LogContext): void {
|
||||||
|
const level = status >= 400 ? LogLevel.ERROR : status >= 300 ? LogLevel.WARN : LogLevel.INFO;
|
||||||
|
if (level === LogLevel.ERROR) {
|
||||||
|
this.error('api', `${method} ${path}`, undefined, { status, ...details });
|
||||||
|
} else if (level === LogLevel.WARN) {
|
||||||
|
this.warn('api', `${method} ${path}`, { status, ...details });
|
||||||
|
} else {
|
||||||
|
this.info('api', `${method} ${path}`, { status, ...details });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
embedding(action: string, details?: LogContext): void {
|
||||||
|
this.info('embedding', action, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
selection(method: string, toolCount: number, conceptCount: number, details?: LogContext): void {
|
||||||
|
this.info('selection', `${method} selection completed`, {
|
||||||
|
tools: toolCount,
|
||||||
|
concepts: conceptCount,
|
||||||
|
...details
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
confidence(toolName: string, overall: number, semantic: number, suitability: number): void {
|
||||||
|
this.debug('confidence', `${toolName} scored`, {
|
||||||
|
overall: `${overall}%`,
|
||||||
|
semantic: `${semantic}%`,
|
||||||
|
suitability: `${suitability}%`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
queue(action: string, details?: LogContext): void {
|
||||||
|
this.info('queue', action, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance timing
|
||||||
|
time(context: string, label: string): string {
|
||||||
|
const timerId = `${context}:${label}:${Date.now()}`;
|
||||||
|
console.time(timerId);
|
||||||
|
return timerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeEnd(timerId: string, message?: string): void {
|
||||||
|
console.timeEnd(timerId);
|
||||||
|
if (message) {
|
||||||
|
const [context] = timerId.split(':');
|
||||||
|
this.debug(context, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = new Logger();
|
||||||
|
|
||||||
|
// Export convenience functions for common patterns
|
||||||
|
export const logPipeline = logger.pipeline.bind(logger);
|
||||||
|
export const logAudit = logger.audit.bind(logger);
|
||||||
|
export const logAPI = logger.api.bind(logger);
|
||||||
|
export const logError = logger.error.bind(logger);
|
||||||
|
export const logInfo = logger.info.bind(logger);
|
File diff suppressed because it is too large
Load Diff
184
src/utils/aiUtils.ts
Normal file
184
src/utils/aiUtils.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
// src/utils/aiUtils.ts
|
||||||
|
import { isToolHosted, createToolSlug } from './toolHelpers.js';
|
||||||
|
|
||||||
|
export interface ModeConfig {
|
||||||
|
placeholder: string;
|
||||||
|
description: string;
|
||||||
|
submitText: string;
|
||||||
|
loadingText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModeConfig(): Record<string, ModeConfig> {
|
||||||
|
return {
|
||||||
|
workflow: {
|
||||||
|
placeholder: "Beschreiben Sie Ihr forensisches Szenario... z.B. 'Verdacht auf Ransomware-Angriff auf Windows-Domänencontroller'",
|
||||||
|
description: "Beschreiben Sie Ihre Untersuchungssituation und erhalten Empfehlungen für alle Phasen der Untersuchung.",
|
||||||
|
submitText: "Empfehlungen generieren",
|
||||||
|
loadingText: "Analysiere Szenario und generiere Empfehlungen..."
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
placeholder: "Beschreiben Sie Ihr Problem... z.B. 'Analyse von Android-Backups mit WhatsApp-Nachrichten'",
|
||||||
|
description: "Beschreiben Sie Ihre Untersuchungssituation und erhalten Empfehlungen für eine spezifische Aufgabenstellung.",
|
||||||
|
submitText: "Empfehlungen finden",
|
||||||
|
loadingText: "Analysiere Anforderungen und suche passende Methode..."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateShareURL(toolName: string, view: string, modal: string | null = null): string {
|
||||||
|
const toolSlug = createToolSlug(toolName);
|
||||||
|
const baseUrl = window.location.origin + window.location.pathname;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('tool', toolSlug);
|
||||||
|
params.set('view', view);
|
||||||
|
if (modal) {
|
||||||
|
params.set('modal', modal);
|
||||||
|
}
|
||||||
|
return `${baseUrl}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getErrorMessage(error: Error): string {
|
||||||
|
const errorMap = {
|
||||||
|
'429': 'Zu viele Anfragen. Bitte warten Sie einen Moment.',
|
||||||
|
'401': 'Authentifizierung erforderlich. Bitte melden Sie sich an.',
|
||||||
|
'503': 'KI-Service vorübergehend nicht verfügbar.'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [code, message] of Object.entries(errorMap)) {
|
||||||
|
if (error.message.includes(code)) return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Fehler: ${error.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderToolBadges(tool: any): string {
|
||||||
|
const isMethod = tool.type === 'method';
|
||||||
|
const hasValidProjectUrl = isToolHosted(tool);
|
||||||
|
|
||||||
|
let badges = '';
|
||||||
|
if (isMethod) {
|
||||||
|
badges += '<span class="badge" style="background-color: var(--color-method); color: white;">Methode</span>';
|
||||||
|
} else if (hasValidProjectUrl) {
|
||||||
|
badges += '<span class="badge badge-primary">CC24-Server</span>';
|
||||||
|
} else if (tool.license !== 'Proprietary') {
|
||||||
|
badges += '<span class="badge badge-success">Open Source</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.knowledgebase === true) {
|
||||||
|
badges += '<span class="badge badge-error">📖</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToolClass(tool: any, context: string = 'card'): string {
|
||||||
|
const isMethod = tool.type === 'method';
|
||||||
|
const hasValidProjectUrl = isToolHosted(tool);
|
||||||
|
|
||||||
|
if (context === 'recommendation') {
|
||||||
|
if (isMethod) return 'method';
|
||||||
|
if (hasValidProjectUrl) return 'hosted';
|
||||||
|
if (tool.license !== 'Proprietary') return 'oss';
|
||||||
|
return '';
|
||||||
|
} else {
|
||||||
|
if (isMethod) return 'card-method';
|
||||||
|
if (hasValidProjectUrl) return 'card-hosted';
|
||||||
|
if (tool.license !== 'Proprietary') return 'card-oss';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSuitabilityText(score: string): string {
|
||||||
|
const texts = {
|
||||||
|
high: 'GUT GEEIGNET',
|
||||||
|
medium: 'GEEIGNET',
|
||||||
|
low: 'VIELLEICHT GEEIGNET'
|
||||||
|
};
|
||||||
|
return texts[score] || 'GEEIGNET';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateText(text: string, maxLength: number): string {
|
||||||
|
if (!text || text.length <= maxLength) return text;
|
||||||
|
return text.slice(0, maxLength) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeText(text: string): string {
|
||||||
|
if (typeof text !== 'string') return '';
|
||||||
|
|
||||||
|
return text
|
||||||
|
.replace(/^#{1,6}\s+/gm, '')
|
||||||
|
.replace(/^\s*[-*+]\s+/gm, '')
|
||||||
|
.replace(/^\s*\d+\.\s+/gm, '')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||||
|
.replace(/\*(.+?)\*/g, '$1')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.replace(/```[\s\S]*?```/g, '[CODE BLOCK]')
|
||||||
|
.replace(/`([^`]+)`/g, '$1')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\n\s*\n\s*\n/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
if (typeof text !== 'string') return String(text);
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderConfidenceTooltip(confidence: any): string {
|
||||||
|
if (!confidence || typeof confidence.overall !== 'number') return '';
|
||||||
|
|
||||||
|
const confidenceColor = getConfidenceColor(confidence.overall);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<span class="confidence-tooltip-trigger"
|
||||||
|
style="display: inline-flex; align-items: center; gap: 0.125rem; cursor: help; margin-left: 0.25rem; position: relative;"
|
||||||
|
onmouseenter="this.querySelector('.confidence-tooltip').style.display = 'block'"
|
||||||
|
onmouseleave="this.querySelector('.confidence-tooltip').style.display = 'none'"
|
||||||
|
onclick="event.stopPropagation();">
|
||||||
|
<div style="width: 6px; height: 6px; border-radius: 50%; background-color: ${confidenceColor}; flex-shrink: 0;"></div>
|
||||||
|
<span style="font-size: 0.625rem; color: white; font-weight: 600; text-shadow: 0 1px 2px rgba(0,0,0,0.5);">${confidence.overall}%</span>
|
||||||
|
|
||||||
|
<div class="confidence-tooltip"
|
||||||
|
style="display: none; position: absolute; top: 100%; right: 0; z-index: 1001; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1rem; min-width: 320px; max-width: 400px; box-shadow: var(--shadow-lg); font-size: 0.75rem; color: var(--color-text); margin-top: 0.5rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
|
||||||
|
<strong style="font-size: 0.875rem; color: var(--color-primary);">KI-Vertrauenswertung</strong>
|
||||||
|
<span style="background-color: ${confidenceColor}; color: white; font-weight: 600; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.625rem;">${confidence.overall}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr; gap: 0.625rem; margin-bottom: 0.75rem;">
|
||||||
|
<div style="background: var(--color-bg-secondary); padding: 0.5rem; border-radius: 0.375rem; border-left: 3px solid var(--color-accent);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.25rem;">
|
||||||
|
<span style="font-weight: 600; font-size: 0.6875rem; color: var(--color-text);">🔍 Semantische Relevanz</span>
|
||||||
|
<strong style="color: var(--color-accent);">${confidence.semanticRelevance}%</strong>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.625rem; color: var(--color-text-secondary); line-height: 1.3;">
|
||||||
|
Wie gut die Tool-Beschreibung semantisch zu Ihrer Anfrage passt
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--color-bg-secondary); padding: 0.5rem; border-radius: 0.375rem; border-left: 3px solid var(--color-primary);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.25rem;">
|
||||||
|
<span style="font-weight: 600; font-size: 0.6875rem; color: var(--color-text);">🎯 Aufgaben-Eignung</span>
|
||||||
|
<strong style="color: var(--color-primary);">${confidence.taskSuitability}%</strong>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.625rem; color: var(--color-text-secondary); line-height: 1.3;">
|
||||||
|
KI-bewertete Eignung für Ihre spezifische Aufgabenstellung
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--color-border); font-size: 0.625rem; color: var(--color-text-secondary); text-align: center;">
|
||||||
|
Forensisch fundierte KI-Analyse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfidenceColor(confidence: number): string {
|
||||||
|
if (confidence >= 80) return 'var(--color-accent)';
|
||||||
|
if (confidence >= 60) return 'var(--color-warning)';
|
||||||
|
return 'var(--color-error)';
|
||||||
|
}
|
@ -1,17 +1,8 @@
|
|||||||
// src/utils/auditService.ts
|
// src/utils/auditService.ts
|
||||||
import 'dotenv/config';
|
import { config } from '../config/appConfig.js';
|
||||||
|
import { logger } from '../services/logger.js';
|
||||||
|
|
||||||
function env(key: string, fallback: string | undefined = undefined): string | undefined {
|
export interface AuditEntry {
|
||||||
if (typeof process !== 'undefined' && process.env?.[key] !== undefined) {
|
|
||||||
return process.env[key];
|
|
||||||
}
|
|
||||||
if (typeof import.meta !== 'undefined' && (import.meta as any).env?.[key] !== undefined) {
|
|
||||||
return (import.meta as any).env[key];
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuditEntry {
|
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
phase: string;
|
phase: string;
|
||||||
action: string;
|
action: string;
|
||||||
@ -22,14 +13,7 @@ interface AuditEntry {
|
|||||||
metadata: Record<string, any>;
|
metadata: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuditConfig {
|
export interface CompressedAuditEntry {
|
||||||
enabled: boolean;
|
|
||||||
detailLevel: 'minimal' | 'standard' | 'verbose';
|
|
||||||
retentionHours: number;
|
|
||||||
maxEntries: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CompressedAuditEntry {
|
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
phase: string;
|
phase: string;
|
||||||
action: string;
|
action: string;
|
||||||
@ -40,7 +24,7 @@ interface CompressedAuditEntry {
|
|||||||
metadata: Record<string, any>;
|
metadata: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessedAuditTrail {
|
export interface ProcessedAuditTrail {
|
||||||
totalTime: number;
|
totalTime: number;
|
||||||
avgConfidence: number;
|
avgConfidence: number;
|
||||||
stepCount: number;
|
stepCount: number;
|
||||||
@ -62,7 +46,8 @@ interface ProcessedAuditTrail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AuditService {
|
class AuditService {
|
||||||
private config: AuditConfig;
|
private readonly enabled: boolean;
|
||||||
|
private readonly detailLevel: 'minimal' | 'standard' | 'verbose';
|
||||||
private tempEntries: AuditEntry[] = [];
|
private tempEntries: AuditEntry[] = [];
|
||||||
|
|
||||||
private readonly phaseConfig = {
|
private readonly phaseConfig = {
|
||||||
@ -87,50 +72,13 @@ class AuditService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.config = this.loadConfig();
|
this.enabled = config.audit.enabled;
|
||||||
}
|
this.detailLevel = config.audit.detailLevel;
|
||||||
|
|
||||||
private loadConfig(): AuditConfig {
|
|
||||||
const enabledFlag = env('FORENSIC_AUDIT_ENABLED', 'false');
|
|
||||||
const detailLevel = env('FORENSIC_AUDIT_DETAIL_LEVEL', 'standard') as 'minimal' | 'standard' | 'verbose';
|
|
||||||
const retentionHours = parseInt(env('FORENSIC_AUDIT_RETENTION_HOURS', '72') || '72', 10);
|
|
||||||
const maxEntries = parseInt(env('FORENSIC_AUDIT_MAX_ENTRIES', '50') || '50', 10);
|
|
||||||
|
|
||||||
console.log('[AUDIT SERVICE] Configuration loaded:', {
|
|
||||||
enabled: enabledFlag === 'true',
|
|
||||||
detailLevel,
|
|
||||||
retentionHours,
|
|
||||||
maxEntries,
|
|
||||||
context: typeof process !== 'undefined' ? 'server' : 'client'
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
enabled: enabledFlag === 'true',
|
|
||||||
detailLevel,
|
|
||||||
retentionHours,
|
|
||||||
maxEntries
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getDebugInfo(): {
|
|
||||||
config: AuditConfig;
|
|
||||||
environment: Record<string, any>;
|
|
||||||
context: string;
|
|
||||||
} {
|
|
||||||
const context = typeof process !== 'undefined' ? 'server' : 'client';
|
|
||||||
|
|
||||||
return {
|
logger.info('audit-service', 'Audit service initialized', {
|
||||||
config: this.config,
|
enabled: this.enabled,
|
||||||
environment: {
|
detailLevel: this.detailLevel
|
||||||
FORENSIC_AUDIT_ENABLED: env('FORENSIC_AUDIT_ENABLED'),
|
});
|
||||||
FORENSIC_AUDIT_DETAIL_LEVEL: env('FORENSIC_AUDIT_DETAIL_LEVEL'),
|
|
||||||
FORENSIC_AUDIT_RETENTION_HOURS: env('FORENSIC_AUDIT_RETENTION_HOURS'),
|
|
||||||
FORENSIC_AUDIT_MAX_ENTRIES: env('FORENSIC_AUDIT_MAX_ENTRIES'),
|
|
||||||
processEnvKeys: typeof process !== 'undefined' ? Object.keys(process.env).filter(k => k.includes('AUDIT')) : [],
|
|
||||||
importMetaEnvAvailable: typeof import.meta !== 'undefined' && !!(import.meta as any).env
|
|
||||||
},
|
|
||||||
context
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addEntry(
|
addEntry(
|
||||||
@ -142,7 +90,7 @@ class AuditService {
|
|||||||
startTime: number,
|
startTime: number,
|
||||||
metadata: Record<string, any> = {}
|
metadata: Record<string, any> = {}
|
||||||
): void {
|
): void {
|
||||||
if (!this.config.enabled) return;
|
if (!this.enabled) return;
|
||||||
|
|
||||||
const entry: AuditEntry = {
|
const entry: AuditEntry = {
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@ -156,76 +104,73 @@ class AuditService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.tempEntries.push(entry);
|
this.tempEntries.push(entry);
|
||||||
console.log(`[AUDIT] ${phase}/${action}: ${confidence}% confidence, ${entry.processingTimeMs}ms`);
|
logger.audit(phase, action, confidence, entry.processingTimeMs, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
mergeAndClear(auditTrail: AuditEntry[]): void {
|
mergeAndClear(auditTrail: AuditEntry[]): void {
|
||||||
if (!this.config.enabled || this.tempEntries.length === 0) return;
|
if (!this.enabled || this.tempEntries.length === 0) return;
|
||||||
|
|
||||||
auditTrail.unshift(...this.tempEntries);
|
auditTrail.unshift(...this.tempEntries);
|
||||||
const entryCount = this.tempEntries.length;
|
const entryCount = this.tempEntries.length;
|
||||||
this.tempEntries = [];
|
this.tempEntries = [];
|
||||||
|
|
||||||
console.log(`[AUDIT] Merged ${entryCount} entries into audit trail`);
|
logger.debug('audit-service', `Merged audit entries`, { entryCount });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
processAuditTrail(rawAuditTrail: AuditEntry[]): ProcessedAuditTrail | null {
|
processAuditTrail(rawAuditTrail: AuditEntry[]): ProcessedAuditTrail | null {
|
||||||
if (!this.config.enabled) {
|
if (!this.enabled) {
|
||||||
console.log('[AUDIT] Service disabled, returning null');
|
return null;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rawAuditTrail || !Array.isArray(rawAuditTrail) || rawAuditTrail.length === 0) {
|
if (!rawAuditTrail || !Array.isArray(rawAuditTrail) || rawAuditTrail.length === 0) {
|
||||||
console.log('[AUDIT] No audit trail data provided');
|
logger.debug('audit-service', 'No audit trail data to process');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[AUDIT] Processing', rawAuditTrail.length, 'audit entries');
|
logger.debug('audit-service', 'Processing audit trail', { entries: rawAuditTrail.length });
|
||||||
|
|
||||||
const totalTime = rawAuditTrail.reduce((sum, entry) => sum + (entry.processingTimeMs || 0), 0);
|
const totalTime = rawAuditTrail.reduce((sum, entry) => sum + (entry.processingTimeMs || 0), 0);
|
||||||
const validConfidenceEntries = rawAuditTrail.filter(entry => typeof entry.confidence === 'number');
|
const validConfidenceEntries = rawAuditTrail.filter(entry => typeof entry.confidence === 'number');
|
||||||
const avgConfidence = validConfidenceEntries.length > 0
|
const avgConfidence = validConfidenceEntries.length > 0
|
||||||
? Math.round(validConfidenceEntries.reduce((sum, entry) => sum + entry.confidence, 0) / validConfidenceEntries.length)
|
? Math.round(validConfidenceEntries.reduce((sum, entry) => sum + entry.confidence, 0) / validConfidenceEntries.length)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const highConfidenceSteps = rawAuditTrail.filter(entry => (entry.confidence || 0) >= 80).length;
|
const highConfidenceSteps = rawAuditTrail.filter(entry => (entry.confidence || 0) >= 80).length;
|
||||||
const lowConfidenceSteps = rawAuditTrail.filter(entry => (entry.confidence || 0) < 60).length;
|
const lowConfidenceSteps = rawAuditTrail.filter(entry => (entry.confidence || 0) < 60).length;
|
||||||
|
|
||||||
const groupedEntries = rawAuditTrail.reduce((groups, entry) => {
|
const groupedEntries = rawAuditTrail.reduce((groups, entry) => {
|
||||||
const phase = entry.phase || 'unknown';
|
const phase = entry.phase || 'unknown';
|
||||||
if (!groups[phase]) groups[phase] = [];
|
if (!groups[phase]) groups[phase] = [];
|
||||||
groups[phase].push(entry);
|
groups[phase].push(entry);
|
||||||
return groups;
|
return groups;
|
||||||
}, {} as Record<string, AuditEntry[]>);
|
}, {} as Record<string, AuditEntry[]>);
|
||||||
|
|
||||||
const phases = Object.entries(groupedEntries).map(([phase, entries]) => {
|
const phases = Object.entries(groupedEntries).map(([phase, entries]) => {
|
||||||
const phaseConfig = this.phaseConfig[phase] || { icon: '📋', displayName: phase };
|
const phaseConfig = this.phaseConfig[phase] || { icon: '📋', displayName: phase };
|
||||||
const validEntries = entries.filter(entry => entry && typeof entry === 'object');
|
const validEntries = entries.filter(entry => entry && typeof entry === 'object');
|
||||||
|
|
||||||
const phaseAvgConfidence = validEntries.length > 0
|
const phaseAvgConfidence = validEntries.length > 0
|
||||||
? Math.round(validEntries.reduce((sum, entry) => sum + (entry.confidence || 0), 0) / validEntries.length)
|
? Math.round(validEntries.reduce((sum, entry) => sum + (entry.confidence || 0), 0) / validEntries.length)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const phaseTotalTime = validEntries.reduce((sum, entry) => sum + (entry.processingTimeMs || 0), 0);
|
const phaseTotalTime = validEntries.reduce((sum, entry) => sum + (entry.processingTimeMs || 0), 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: phase,
|
name: phase,
|
||||||
icon: phaseConfig.icon,
|
icon: phaseConfig.icon,
|
||||||
displayName: phaseConfig.displayName,
|
displayName: phaseConfig.displayName,
|
||||||
avgConfidence: phaseAvgConfidence,
|
avgConfidence: phaseAvgConfidence,
|
||||||
totalTime: phaseTotalTime,
|
totalTime: phaseTotalTime,
|
||||||
entries: validEntries
|
entries: validEntries
|
||||||
.map(e => this.compressEntry(e))
|
.map(e => this.compressEntry(e))
|
||||||
.filter((e): e is CompressedAuditEntry => e !== null)
|
.filter((e): e is CompressedAuditEntry => e !== null)
|
||||||
};
|
};
|
||||||
}).filter(phase => phase.entries.length > 0);
|
}).filter(phase => phase.entries.length > 0);
|
||||||
|
|
||||||
const summary = this.generateSummary(rawAuditTrail, avgConfidence, lowConfidenceSteps);
|
const summary = this.generateSummary(rawAuditTrail, avgConfidence, lowConfidenceSteps);
|
||||||
|
|
||||||
const result: ProcessedAuditTrail = {
|
const result: ProcessedAuditTrail = {
|
||||||
totalTime,
|
totalTime,
|
||||||
avgConfidence,
|
avgConfidence,
|
||||||
stepCount: rawAuditTrail.length,
|
stepCount: rawAuditTrail.length,
|
||||||
@ -233,25 +178,30 @@ class AuditService {
|
|||||||
lowConfidenceSteps,
|
lowConfidenceSteps,
|
||||||
phases,
|
phases,
|
||||||
summary
|
summary
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[AUDIT] Successfully processed audit trail:', result);
|
logger.debug('audit-service', 'Audit trail processed successfully', {
|
||||||
return result;
|
totalTime: `${totalTime}ms`,
|
||||||
|
avgConfidence: `${avgConfidence}%`,
|
||||||
|
phases: phases.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AUDIT] Error processing audit trail:', error);
|
logger.error('audit-service', 'Error processing audit trail', error as Error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private compressEntry(entry: AuditEntry): CompressedAuditEntry | null {
|
private compressEntry(entry: AuditEntry): CompressedAuditEntry | null {
|
||||||
if (!entry || typeof entry !== 'object') {
|
if (!entry || typeof entry !== 'object') {
|
||||||
console.warn('[AUDIT] Invalid audit entry:', entry);
|
logger.warn('audit-service', 'Invalid audit entry', { entry });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
timestamp: entry.timestamp || Date.now(),
|
timestamp: entry.timestamp || Date.now(),
|
||||||
phase: entry.phase || 'unknown',
|
phase: entry.phase || 'unknown',
|
||||||
action: entry.action || 'unknown',
|
action: entry.action || 'unknown',
|
||||||
@ -260,17 +210,17 @@ class AuditService {
|
|||||||
confidence: entry.confidence || 0,
|
confidence: entry.confidence || 0,
|
||||||
processingTimeMs: entry.processingTimeMs || 0,
|
processingTimeMs: entry.processingTimeMs || 0,
|
||||||
metadata: entry.metadata || {}
|
metadata: entry.metadata || {}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AUDIT] Error compressing entry:', error);
|
logger.error('audit-service', 'Error compressing audit entry', error as Error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private compressData(data: any): any {
|
private compressData(data: any): any {
|
||||||
if (this.config.detailLevel === 'verbose') {
|
if (this.detailLevel === 'verbose') {
|
||||||
return data;
|
return data;
|
||||||
} else if (this.config.detailLevel === 'standard') {
|
} else if (this.detailLevel === 'standard') {
|
||||||
return this.summarizeForStorage(data);
|
return this.summarizeForStorage(data);
|
||||||
} else {
|
} else {
|
||||||
return this.minimalSummary(data);
|
return this.minimalSummary(data);
|
||||||
@ -387,25 +337,12 @@ class AuditService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isEnabled(): boolean {
|
isEnabled(): boolean {
|
||||||
return this.config.enabled;
|
return this.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
getConfig(): AuditConfig {
|
getConfig(): typeof config.audit {
|
||||||
return { ...this.config };
|
return config.audit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const auditService = new AuditService();
|
export const auditService = new AuditService();
|
||||||
export type { ProcessedAuditTrail, CompressedAuditEntry };
|
|
||||||
|
|
||||||
export const debugAuditService = {
|
|
||||||
getDebugInfo() {
|
|
||||||
return auditService.getDebugInfo();
|
|
||||||
},
|
|
||||||
isEnabled() {
|
|
||||||
return auditService.isEnabled();
|
|
||||||
},
|
|
||||||
getConfig() {
|
|
||||||
return auditService.getConfig();
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,36 +1,13 @@
|
|||||||
// src/utils/clientUtils.ts
|
// src/utils/clientUtils.ts
|
||||||
// Client-side utilities that mirror server-side toolHelpers.ts
|
// Client-side utilities - now only contains AutocompleteManager to avoid duplication
|
||||||
|
// Other utilities consolidated into globalUtils.ts
|
||||||
|
|
||||||
export function createToolSlug(toolName: string): string {
|
import { createToolSlug, findToolByIdentifier, isToolHosted } from './toolHelpers.js';
|
||||||
if (!toolName || typeof toolName !== 'string') {
|
|
||||||
console.warn('[toolHelpers] Invalid toolName provided to createToolSlug:', toolName);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return toolName.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
|
||||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
||||||
.replace(/-+/g, '-') // Remove duplicate hyphens
|
|
||||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findToolByIdentifier(tools: any[], identifier: string): any | undefined {
|
// Re-export tool helper functions to maintain API compatibility
|
||||||
if (!identifier || !Array.isArray(tools)) return undefined;
|
export { createToolSlug, findToolByIdentifier, isToolHosted };
|
||||||
|
|
||||||
return tools.find((tool: any) =>
|
|
||||||
tool.name === identifier ||
|
|
||||||
createToolSlug(tool.name) === identifier.toLowerCase()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isToolHosted(tool: any): boolean {
|
// Consolidated Autocomplete Functionality - this is unique and not duplicated elsewhere
|
||||||
return tool.projectUrl !== undefined &&
|
|
||||||
tool.projectUrl !== null &&
|
|
||||||
tool.projectUrl !== "" &&
|
|
||||||
tool.projectUrl.trim() !== "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consolidated Autocomplete Functionality
|
|
||||||
interface AutocompleteOptions {
|
interface AutocompleteOptions {
|
||||||
minLength?: number;
|
minLength?: number;
|
||||||
maxResults?: number;
|
maxResults?: number;
|
||||||
|
314
src/utils/globalUtils.ts
Normal file
314
src/utils/globalUtils.ts
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
// src/utils/globalUtils.ts
|
||||||
|
// Consolidated global utilities to eliminate duplication and provide consistent interface
|
||||||
|
|
||||||
|
import { createToolSlug, findToolByIdentifier, isToolHosted } from './toolHelpers.js';
|
||||||
|
import { auditService } from './auditService.js';
|
||||||
|
|
||||||
|
// Utility functions for UI interactions
|
||||||
|
export const UIUtils = {
|
||||||
|
scrollToElement(element: Element | null, options: ScrollIntoViewOptions = {}) {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const headerHeight = document.querySelector('nav')?.offsetHeight || 80;
|
||||||
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
const absoluteElementTop = elementRect.top + window.pageYOffset;
|
||||||
|
const targetPosition = absoluteElementTop - headerHeight - 20;
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: targetPosition,
|
||||||
|
behavior: 'smooth',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToElementById(elementId: string, options: ScrollIntoViewOptions = {}) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (element) {
|
||||||
|
this.scrollToElement(element, options);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToElementBySelector(selector: string, options: ScrollIntoViewOptions = {}) {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (element) {
|
||||||
|
this.scrollToElement(element, options);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
prioritizeSearchResults(tools: any[], searchTerm: string) {
|
||||||
|
if (!searchTerm || !searchTerm.trim()) {
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerSearchTerm = searchTerm.toLowerCase().trim();
|
||||||
|
|
||||||
|
return tools.sort((a, b) => {
|
||||||
|
const aTagsLower = (a.tags || []).map((tag: string) => tag.toLowerCase());
|
||||||
|
const bTagsLower = (b.tags || []).map((tag: string) => tag.toLowerCase());
|
||||||
|
|
||||||
|
const aExactTag = aTagsLower.includes(lowerSearchTerm);
|
||||||
|
const bExactTag = bTagsLower.includes(lowerSearchTerm);
|
||||||
|
|
||||||
|
if (aExactTag && !bExactTag) return -1;
|
||||||
|
if (!aExactTag && bExactTag) return 1;
|
||||||
|
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Theme management
|
||||||
|
export const ThemeManager = {
|
||||||
|
THEME_KEY: 'dfir-theme',
|
||||||
|
|
||||||
|
getSystemTheme(): 'dark' | 'light' {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
},
|
||||||
|
|
||||||
|
getStoredTheme(): string {
|
||||||
|
return localStorage.getItem(this.THEME_KEY) || 'auto';
|
||||||
|
},
|
||||||
|
|
||||||
|
applyTheme(theme: string) {
|
||||||
|
const effectiveTheme = theme === 'auto' ? this.getSystemTheme() : theme;
|
||||||
|
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateThemeToggle(theme: string) {
|
||||||
|
document.querySelectorAll('[data-theme-toggle]').forEach(button => {
|
||||||
|
button.setAttribute('data-current-theme', theme);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
initTheme() {
|
||||||
|
const storedTheme = this.getStoredTheme();
|
||||||
|
this.applyTheme(storedTheme);
|
||||||
|
this.updateThemeToggle(storedTheme);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleTheme() {
|
||||||
|
const current = this.getStoredTheme();
|
||||||
|
const themes = ['light', 'dark', 'auto'];
|
||||||
|
const currentIndex = themes.indexOf(current);
|
||||||
|
const nextIndex = (currentIndex + 1) % themes.length;
|
||||||
|
const nextTheme = themes[nextIndex];
|
||||||
|
|
||||||
|
localStorage.setItem(this.THEME_KEY, nextTheme);
|
||||||
|
this.applyTheme(nextTheme);
|
||||||
|
this.updateThemeToggle(nextTheme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Authentication utilities
|
||||||
|
export const AuthManager = {
|
||||||
|
async checkClientAuth(context: string = 'general') {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
switch (context) {
|
||||||
|
case 'contributions':
|
||||||
|
return {
|
||||||
|
authenticated: data.contributionAuthenticated,
|
||||||
|
authRequired: data.contributionAuthRequired,
|
||||||
|
expires: data.expires
|
||||||
|
};
|
||||||
|
case 'ai':
|
||||||
|
return {
|
||||||
|
authenticated: data.aiAuthenticated,
|
||||||
|
authRequired: data.aiAuthRequired,
|
||||||
|
expires: data.expires
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
authenticated: data.authenticated,
|
||||||
|
authRequired: data.contributionAuthRequired || data.aiAuthRequired,
|
||||||
|
expires: data.expires
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check failed:', error);
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
authRequired: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async requireClientAuth(callback?: () => void, returnUrl?: string, context: string = 'general') {
|
||||||
|
const authStatus = await this.checkClientAuth(context);
|
||||||
|
|
||||||
|
if (authStatus.authRequired && !authStatus.authenticated) {
|
||||||
|
const targetUrl = returnUrl || window.location.href;
|
||||||
|
window.location.href = `/api/auth/login?returnTo=${encodeURIComponent(targetUrl)}`;
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async showIfAuthenticated(selector: string, context: string = 'general') {
|
||||||
|
const authStatus = await this.checkClientAuth(context);
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
(element as HTMLElement).style.display = (!authStatus.authRequired || authStatus.authenticated)
|
||||||
|
? 'inline-flex'
|
||||||
|
: 'none';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setupAuthButtons(selector: string = '[data-contribute-button]') {
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
if (!e.target) return;
|
||||||
|
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const button = target.closest(selector) as HTMLAnchorElement;
|
||||||
|
if (!button) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
await this.requireClientAuth(() => {
|
||||||
|
window.location.href = button.href;
|
||||||
|
}, button.href, 'contributions');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sharing utilities
|
||||||
|
export const SharingUtils = {
|
||||||
|
async copyUrlToClipboard(url: string, button: HTMLElement) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
|
||||||
|
const originalHTML = button.innerHTML;
|
||||||
|
button.innerHTML = `
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.375rem;">
|
||||||
|
<polyline points="20,6 9,17 4,12"/>
|
||||||
|
</svg>
|
||||||
|
Kopiert!
|
||||||
|
`;
|
||||||
|
button.style.color = 'var(--color-accent)';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = originalHTML;
|
||||||
|
button.style.color = '';
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = url;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
|
||||||
|
const originalHTML = button.innerHTML;
|
||||||
|
button.innerHTML = 'Kopiert!';
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = originalHTML;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async shareArticle(button: HTMLElement, url: string, title: string) {
|
||||||
|
const fullUrl = window.location.origin + url;
|
||||||
|
await this.copyUrlToClipboard(fullUrl, button);
|
||||||
|
},
|
||||||
|
|
||||||
|
async shareCurrentArticle(button: HTMLElement) {
|
||||||
|
await this.copyUrlToClipboard(window.location.href, button);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Audit utilities for window exposure
|
||||||
|
export const AuditUtils = {
|
||||||
|
formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return '< 1s';
|
||||||
|
if (ms < 60000) return `${Math.ceil(ms / 1000)}s`;
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const seconds = Math.ceil((ms % 60000) / 1000);
|
||||||
|
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||||
|
},
|
||||||
|
|
||||||
|
getConfidenceColor(confidence: number): string {
|
||||||
|
if (confidence >= 80) return 'var(--color-accent)';
|
||||||
|
if (confidence >= 60) return 'var(--color-warning)';
|
||||||
|
return 'var(--color-error)';
|
||||||
|
},
|
||||||
|
|
||||||
|
escapeHtml(text: string): string {
|
||||||
|
if (typeof text !== 'string') return String(text);
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
},
|
||||||
|
|
||||||
|
sanitizeText(text: string): string {
|
||||||
|
if (typeof text !== 'string') return '';
|
||||||
|
|
||||||
|
return text
|
||||||
|
.replace(/^#{1,6}\s+/gm, '')
|
||||||
|
.replace(/^\s*[-*+]\s+/gm, '')
|
||||||
|
.replace(/^\s*\d+\.\s+/gm, '')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||||
|
.replace(/\*(.+?)\*/g, '$1')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.replace(/```[\s\S]*?```/g, '[CODE BLOCK]')
|
||||||
|
.replace(/`([^`]+)`/g, '$1')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\n\s*\n\s*\n/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tool utilities (re-export from toolHelpers)
|
||||||
|
export const ToolUtils = {
|
||||||
|
createToolSlug,
|
||||||
|
findToolByIdentifier,
|
||||||
|
isToolHosted
|
||||||
|
};
|
||||||
|
|
||||||
|
// Centralized initialization function
|
||||||
|
export function initializeGlobalUtils() {
|
||||||
|
// Expose theme utilities
|
||||||
|
window.themeUtils = ThemeManager;
|
||||||
|
|
||||||
|
// Expose UI utilities
|
||||||
|
window.scrollToElement = UIUtils.scrollToElement.bind(UIUtils);
|
||||||
|
window.scrollToElementById = UIUtils.scrollToElementById.bind(UIUtils);
|
||||||
|
window.scrollToElementBySelector = UIUtils.scrollToElementBySelector.bind(UIUtils);
|
||||||
|
window.prioritizeSearchResults = UIUtils.prioritizeSearchResults.bind(UIUtils);
|
||||||
|
|
||||||
|
// Expose auth utilities
|
||||||
|
window.checkClientAuth = AuthManager.checkClientAuth.bind(AuthManager);
|
||||||
|
window.requireClientAuth = AuthManager.requireClientAuth.bind(AuthManager);
|
||||||
|
window.showIfAuthenticated = AuthManager.showIfAuthenticated.bind(AuthManager);
|
||||||
|
window.setupAuthButtons = AuthManager.setupAuthButtons.bind(AuthManager);
|
||||||
|
|
||||||
|
// Expose sharing utilities
|
||||||
|
window.shareArticle = SharingUtils.shareArticle.bind(SharingUtils);
|
||||||
|
window.shareCurrentArticle = SharingUtils.shareCurrentArticle.bind(SharingUtils);
|
||||||
|
|
||||||
|
// Expose tool utilities
|
||||||
|
window.createToolSlug = ToolUtils.createToolSlug;
|
||||||
|
window.findToolByIdentifier = ToolUtils.findToolByIdentifier;
|
||||||
|
window.isToolHosted = ToolUtils.isToolHosted;
|
||||||
|
|
||||||
|
// Expose audit utilities
|
||||||
|
window.AuditUtils = AuditUtils;
|
||||||
|
window.auditService = auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
export function setupThemeChangeListener() {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
|
if (ThemeManager.getStoredTheme() === 'auto') {
|
||||||
|
ThemeManager.applyTheme('auto');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -1,14 +1,7 @@
|
|||||||
// src/utils/rateLimitedQueue.ts
|
// src/utils/rateLimitedQueue.ts
|
||||||
|
|
||||||
import dotenv from "dotenv";
|
import { config } from '../config/appConfig.js';
|
||||||
|
import { logger } from '../services/logger.js';
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const RATE_LIMIT_DELAY_MS =
|
|
||||||
Number.parseInt(process.env.AI_RATE_LIMIT_DELAY_MS ?? "2000", 10) || 2000;
|
|
||||||
|
|
||||||
const TASK_TIMEOUT_MS =
|
|
||||||
Number.parseInt(process.env.AI_TASK_TIMEOUT_MS ?? "300000", 10) || 300000;
|
|
||||||
|
|
||||||
export type Task<T = unknown> = () => Promise<T>;
|
export type Task<T = unknown> = () => Promise<T>;
|
||||||
|
|
||||||
@ -32,8 +25,8 @@ export interface QueueStatus {
|
|||||||
class RateLimitedQueue {
|
class RateLimitedQueue {
|
||||||
private tasks: QueuedTask[] = [];
|
private tasks: QueuedTask[] = [];
|
||||||
private isProcessing = false;
|
private isProcessing = false;
|
||||||
private delayMs = RATE_LIMIT_DELAY_MS;
|
private readonly delayMs: number;
|
||||||
private taskTimeoutMs = TASK_TIMEOUT_MS;
|
private readonly taskTimeoutMs: number;
|
||||||
private lastProcessedAt = 0;
|
private lastProcessedAt = 0;
|
||||||
private currentlyProcessingTaskId: string | null = null;
|
private currentlyProcessingTaskId: string | null = null;
|
||||||
|
|
||||||
@ -41,9 +34,17 @@ class RateLimitedQueue {
|
|||||||
private readonly TASK_RETENTION_MS = 300000; // 5 minutes
|
private readonly TASK_RETENTION_MS = 300000; // 5 minutes
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.delayMs = config.rateLimit.delayMs;
|
||||||
|
this.taskTimeoutMs = config.ai.taskTimeout;
|
||||||
|
|
||||||
this.cleanupInterval = setInterval(() => {
|
this.cleanupInterval = setInterval(() => {
|
||||||
this.cleanupOldTasks();
|
this.cleanupOldTasks();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
|
logger.info('queue', 'Rate limited queue initialized', {
|
||||||
|
delayMs: this.delayMs,
|
||||||
|
taskTimeoutMs: this.taskTimeoutMs
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanupOldTasks(): void {
|
private cleanupOldTasks(): void {
|
||||||
@ -64,7 +65,10 @@ class RateLimitedQueue {
|
|||||||
|
|
||||||
const cleaned = initialLength - this.tasks.length;
|
const cleaned = initialLength - this.tasks.length;
|
||||||
if (cleaned > 0) {
|
if (cleaned > 0) {
|
||||||
console.log(`[QUEUE] Cleaned up ${cleaned} old tasks, ${this.tasks.length} remaining`);
|
logger.queue('cleanup completed', {
|
||||||
|
cleaned,
|
||||||
|
remaining: this.tasks.length
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,6 +76,7 @@ class RateLimitedQueue {
|
|||||||
if (this.cleanupInterval) {
|
if (this.cleanupInterval) {
|
||||||
clearInterval(this.cleanupInterval);
|
clearInterval(this.cleanupInterval);
|
||||||
}
|
}
|
||||||
|
logger.info('queue', 'Rate limited queue shut down');
|
||||||
}
|
}
|
||||||
|
|
||||||
add<T>(task: Task<T>, taskId?: string): Promise<T> {
|
add<T>(task: Task<T>, taskId?: string): Promise<T> {
|
||||||
@ -95,6 +100,11 @@ class RateLimitedQueue {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.tasks.push(queuedTask);
|
this.tasks.push(queuedTask);
|
||||||
|
|
||||||
|
logger.queue('task queued', {
|
||||||
|
taskId: id,
|
||||||
|
queueLength: this.tasks.length
|
||||||
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.processQueue();
|
this.processQueue();
|
||||||
@ -162,6 +172,7 @@ class RateLimitedQueue {
|
|||||||
if (this.isProcessing) return;
|
if (this.isProcessing) return;
|
||||||
|
|
||||||
this.isProcessing = true;
|
this.isProcessing = true;
|
||||||
|
logger.queue('processing started');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
@ -176,6 +187,11 @@ class RateLimitedQueue {
|
|||||||
this.currentlyProcessingTaskId = nextTask.id;
|
this.currentlyProcessingTaskId = nextTask.id;
|
||||||
this.lastProcessedAt = Date.now();
|
this.lastProcessedAt = Date.now();
|
||||||
|
|
||||||
|
logger.queue('task processing started', {
|
||||||
|
taskId: nextTask.id,
|
||||||
|
queuePosition: 1
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
nextTask.task(),
|
nextTask.task(),
|
||||||
@ -189,25 +205,34 @@ class RateLimitedQueue {
|
|||||||
|
|
||||||
nextTask.status = "completed";
|
nextTask.status = "completed";
|
||||||
nextTask.completedAt = Date.now();
|
nextTask.completedAt = Date.now();
|
||||||
console.log(`[QUEUE] Task ${nextTask.id} completed`);
|
|
||||||
|
logger.queue('task completed', {
|
||||||
|
taskId: nextTask.id,
|
||||||
|
duration: `${Date.now() - nextTask.startedAt!}ms`
|
||||||
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
nextTask.status = err.message.includes("timed out") ? "timedout" : "failed";
|
nextTask.status = err.message.includes("timed out") ? "timedout" : "failed";
|
||||||
nextTask.completedAt = Date.now();
|
nextTask.completedAt = Date.now();
|
||||||
console.error(`[QUEUE] Task ${nextTask.id} failed:`, error);
|
|
||||||
|
logger.error('queue', `Task ${nextTask.id} failed`, err, {
|
||||||
|
duration: `${Date.now() - nextTask.startedAt!}ms`,
|
||||||
|
status: nextTask.status
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentlyProcessingTaskId = null;
|
this.currentlyProcessingTaskId = null;
|
||||||
|
|
||||||
const hasMoreQueued = this.tasks.some((t) => t.status === "queued");
|
const hasMoreQueued = this.tasks.some((t) => t.status === "queued");
|
||||||
if (hasMoreQueued) {
|
if (hasMoreQueued) {
|
||||||
console.log(`[QUEUE] Waiting ${this.delayMs}ms before next task`);
|
logger.debug('queue', `Waiting ${this.delayMs}ms before next task`);
|
||||||
await new Promise((r) => setTimeout(r, this.delayMs));
|
await new Promise((r) => setTimeout(r, this.delayMs));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
console.log(`[QUEUE] Queue processing finished`);
|
logger.queue('processing finished');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,4 +255,4 @@ export function shutdownQueue(): void {
|
|||||||
queue.shutdown();
|
queue.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
export default queue;
|
export default queue;
|
Loading…
x
Reference in New Issue
Block a user