/** * AGENT V3 — TECHNIQUES routes (extracted from routes.ts). * * Endpoints: * GET /techniques/definitions * GET /techniques/config * GET /techniques/models * GET /techniques/stage-assignments * PUT /techniques/stage-assignments * POST /techniques/test-model * POST /techniques/test-openrouter * POST /techniques/analyze-async, /analyze, /analyze-media (all → dispatchTechniquesAsync) * GET /techniques/results/:sessionId */ import { Router, Request, Response } from 'express'; import { resolveUserId } from '../shared/auth/guards'; import crypto from 'crypto'; import { validateTextInput, MAX_TEXT_LENGTH } from '../config/analysisLimits'; import { callVision } from '../shared/media/vision'; import { transcribe, type TranscriptionResult } from '../shared/media/transcription'; import { processVideoUrl } from '../shared/media/video-processor'; import type { InputType } from '../shared/types/analysis-session'; import { ConfigKeys, AgentKeys } from '../shared/redis/keys'; import { scanKeys } from '../shared/redis/scan'; import { checkCredits, deductCredits } from '../shared/credits'; import { toTechniquesResult } from '../components/component-runner'; import { buildEmptyMediaResponse } from '../shared/helpers/empty-media-response'; import { sanitizeForLog } from '../shared/helpers/sanitize-log'; import { validateExternalUrl } from '../shared/helpers/validate-url'; import { internalError } from '../shared/helpers/error-response'; import { TierStageAssignmentsSchema } from '../shared/helpers/config-schemas'; import { log } from '../shared/logger'; import { getRedis, upload } from './_init'; import { persistStandaloneResult } from './_helpers/standalone-session'; const router = Router(); const REDIS_PREFIX = ConfigKeys.techniquesPrefix; const FRAMEWORK_PREFIX = 'didi:framework'; // ============================================================================ // GET /api/v3/techniques/definitions - Technique definitions for frontend display // Returns: { technique_id, technique_name, dimension, subdimension, description, severity } // Cached in-memory for 5 minutes (data rarely changes). // ============================================================================ let definitionsCache: { data: any; ts: number } | null = null; const DEFINITIONS_CACHE_MS = 5 * 60 * 1000; router.get('/techniques/definitions', async (req: Request, res: Response) => { try { const now = Date.now(); if (definitionsCache && (now - definitionsCache.ts) < DEFINITIONS_CACHE_MS) { return res.json({ success: true, data: definitionsCache.data }); } const r = getRedis(); const raw = await r.get('didi:framework:techniques'); if (!raw) { return res.json({ success: true, data: [] }); } const framework = JSON.parse(raw); const definitions: any[] = []; for (const dim of framework.dimensions || []) { for (const sub of dim.subdimensions || []) { for (const tech of sub.techniques || []) { definitions.push({ technique_id: tech.technique_id, technique_name: tech.technique_name, dimension: dim.dimension_code, dimension_name: dim.dimension_name, subdimension: sub.subdimension_name, severity: tech.severity, description_en: typeof tech.description === 'object' ? tech.description?.en : tech.description, description_ro: typeof tech.description === 'object' ? tech.description?.ro : tech.description, }); } } } definitionsCache = { data: definitions, ts: now }; res.json({ success: true, data: definitions }); } catch (error) { internalError(res, error); } }); // ============================================================================ // GET /api/v3/techniques/config - Get full configuration // ============================================================================ router.get('/techniques/config', async (req: Request, res: Response) => { try { const r = getRedis(); const [manifest, availableModels, stageAssignments, prompts, schemas, scoringConfig] = await Promise.all([ r.get(`${REDIS_PREFIX}:manifest`), r.get(`${REDIS_PREFIX}:available_models`), r.get(`${REDIS_PREFIX}:stage_assignments`), Promise.all([ r.get(`${REDIS_PREFIX}:prompts:screening`), r.get(`${REDIS_PREFIX}:prompts:deep_analysis`), ]), Promise.all([ r.get(`${REDIS_PREFIX}:schemas:screening`), r.get(`${REDIS_PREFIX}:schemas:complete`), ]), r.get(`${REDIS_PREFIX}:scoring_config`), ]); res.json({ success: true, data: { manifest: manifest ? JSON.parse(manifest) : null, available_models: availableModels ? JSON.parse(availableModels) : null, stage_assignments: stageAssignments ? JSON.parse(stageAssignments) : null, prompts: { screening: prompts[0] ? JSON.parse(prompts[0]) : null, deep_analysis: prompts[1] ? JSON.parse(prompts[1]) : null, }, schemas: { screening: schemas[0] ? JSON.parse(schemas[0]) : null, complete: schemas[1] ? JSON.parse(schemas[1]) : null, }, scoring_config: scoringConfig ? JSON.parse(scoringConfig) : null, }, }); } catch (error) { res.status(500).json({ success: false, error: (error as Error).message, }); } }); // ============================================================================ // GET /api/v3/techniques/models - Get available models from config // ============================================================================ router.get('/techniques/models', async (req: Request, res: Response) => { try { const r = getRedis(); // First try component config, then fall back to didi:framework:providers let data = await r.get(`${REDIS_PREFIX}:available_models`); if (data) { // Use component config format const { models } = JSON.parse(data); return res.json({ success: true, data: { models }, }); } // Fallback to didi:framework:providers data = await r.get(`${FRAMEWORK_PREFIX}:providers`); if (!data) { return res.status(404).json({ success: false, error: 'Models not configured. Run sync-redis from didiFramework to load config.', }); } const { providers, models } = JSON.parse(data); // Transform to expected format with model_key const transformedModels = models.map((m: any) => ({ model_key: `${m.provider_code}:${m.model_code.split('/').pop()}`, provider: m.provider_code, provider_config: { base_url: providers.find((p: any) => p.provider_id === m.provider_id)?.base_url || '', auth_type: providers.find((p: any) => p.provider_id === m.provider_id)?.auth_type || 'bearer', }, model_code: m.model_code, model_name: m.model_name, context_window: m.context_window, max_output_tokens: m.max_output_tokens, cost_input_1m: parseFloat(m.input_cost_per_1m) || 0, cost_output_1m: parseFloat(m.output_cost_per_1m) || 0, speed_tier: m.provider_code === 'groq' ? 'ultra_fast' : 'medium', quality_tier: parseFloat(m.input_cost_per_1m) >= 2 ? 'premium' : 'high', supports_vision: m.supports_vision, })); res.json({ success: true, data: { models: transformedModels, providers: providers, }, }); } catch (error) { res.status(500).json({ success: false, error: (error as Error).message, }); } }); // ============================================================================ // GET /api/v3/techniques/stage-assignments - Get stage assignments // ============================================================================ router.get('/techniques/stage-assignments', async (req: Request, res: Response) => { try { const r = getRedis(); const data = await r.get(`${REDIS_PREFIX}:stage_assignments`); if (!data) { return res.status(404).json({ success: false, error: 'Stage assignments not configured.', }); } res.json({ success: true, data: JSON.parse(data), }); } catch (error) { res.status(500).json({ success: false, error: (error as Error).message, }); } }); // ============================================================================ // PUT /api/v3/techniques/stage-assignments - Update stage assignments // ============================================================================ router.put('/techniques/stage-assignments', async (req: Request, res: Response) => { try { const r = getRedis(); const parsed = TierStageAssignmentsSchema.safeParse(req.body?.stage_assignments); if (!parsed.success) { return res.status(400).json({ success: false, error: 'Invalid stage_assignments payload', details: parsed.error.issues, }); } await r.set(`${REDIS_PREFIX}:stage_assignments`, JSON.stringify(parsed.data)); res.json({ success: true, message: 'Stage assignments updated', data: parsed.data, }); } catch (error) { res.status(500).json({ success: false, error: (error as Error).message, }); } }); // ============================================================================ // POST /api/v3/techniques/test-model - Test a specific model // ============================================================================ router.post('/techniques/test-model', async (req: Request, res: Response) => { try { const { model_key, test_prompt, provider_routing } = req.body; if (!model_key) { return res.status(400).json({ success: false, error: 'model_key is required', }); } const r = getRedis(); const modelsData = await r.get(`${REDIS_PREFIX}:available_models`); if (!modelsData) { return res.status(404).json({ success: false, error: 'Models not configured', }); } const { models } = JSON.parse(modelsData); const model = models.find((m: any) => m.model_key === model_key); if (!model) { return res.status(404).json({ success: false, error: `Model ${model_key} not found`, }); } // Test the model with a simple prompt const prompt = test_prompt || 'Respond with JSON: {"status": "ok", "model": "your_model_name"}'; const startTime = Date.now(); try { const response = await callLLM(model, prompt, { provider_routing }); const duration = Date.now() - startTime; res.json({ success: true, data: { model_key, model_name: model.model_name, provider: model.provider, provider_routing: provider_routing || null, response_time_ms: duration, response: response.substring(0, 500), // Limit response size status: 'connected', }, }); } catch (llmError) { res.json({ success: false, data: { model_key, model_name: model.model_name, provider: model.provider, provider_routing: provider_routing || null, status: 'error', error: (llmError as Error).message, }, }); } } catch (error) { res.status(500).json({ success: false, error: (error as Error).message, }); } }); // ============================================================================ // POST /api/v3/techniques/test-openrouter - Test OpenRouter with provider routing // ============================================================================ router.post('/techniques/test-openrouter', async (req: Request, res: Response) => { try { const { model_code = 'google/gemini-2.0-flash-001', provider_order = ['Google AI Studio'], allow_fallbacks = false, test_prompt } = req.body; const apiKey = process.env.OPENROUTER_API_KEY; if (!apiKey) { return res.status(400).json({ success: false, error: 'OPENROUTER_API_KEY not configured', }); } const prompt = test_prompt || 'Respond with JSON: {"status": "ok", "provider": "google-ai-studio", "model": "gemini-flash"}'; const startTime = Date.now(); const body = { model: model_code, messages: [{ role: 'user', content: prompt }], max_tokens: 500, temperature: 0.3, provider: { order: provider_order, allow_fallbacks, }, }; const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'HTTP-Referer': 'https://didi.ai', 'X-Title': 'DIDI Agent V3', }, body: JSON.stringify(body), signal: AbortSignal.timeout(30000), }); const duration = Date.now() - startTime; if (!response.ok) { const errorText = await response.text(); return res.json({ success: false, data: { model_code, provider_order, status: 'error', response_time_ms: duration, error: `${response.status} - ${errorText}`, }, }); } const data = await response.json() as { choices?: { message?: { content?: string } }[]; model?: string; usage?: { prompt_tokens?: number; completion_tokens?: number }; }; res.json({ success: true, data: { model_code, model_used: data.model, provider_order, allow_fallbacks, response_time_ms: duration, response: data.choices?.[0]?.message?.content?.substring(0, 500) || '', usage: data.usage, status: 'connected', }, }); } catch (error) { res.status(500).json({ success: false, error: (error as Error).message, }); } }); // ============================================================================ // Helper: fetch URL content (M17 first, then direct fetch fallback) // ============================================================================ async function fetchUrlContent(url: string): Promise { validateExternalUrl(url); const M17_WEB_API = process.env.M17_WEB_API_URL; if (!M17_WEB_API) throw new Error('M17_WEB_API_URL env var is not set'); try { log.info(`[URL] Fetching via M17: ${sanitizeForLog(url)}`); const response = await fetch(`${M17_WEB_API}/v1/fetch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ urls: [url], extract_text: true }), signal: AbortSignal.timeout(60000), }); if (response.ok) { const data = await response.json() as { pages?: { url: string; title?: string; text: string }[]; total_fetched?: number; }; if (data.pages && data.pages.length > 0 && data.pages[0].text) { const page = data.pages[0]; const title = page.title ? `Title: ${page.title}\n\n` : ''; log.info(`[URL] M17 extracted ${page.text.length} chars`); return title + page.text; } } log.info(`[URL] M17 failed, falling back to direct fetch`); } catch (e) { log.warn(`[URL] M17 error: ${e}, falling back to direct fetch`); } const response = await fetch(url, { signal: AbortSignal.timeout(30000), headers: { 'User-Agent': 'Mozilla/5.0 (compatible; DIDI-Bot/1.0)' }, }); if (!response.ok) { throw new Error(`Failed to fetch URL: ${response.status}`); } const html = await response.text(); return html .replace(/]*>[\s\S]*?<\/script>/gi, '') .replace(/]*>[\s\S]*?<\/style>/gi, '') .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim() .substring(0, MAX_TEXT_LENGTH); } // ============================================================================ // Shared async dispatch handler for all techniques endpoints // All routes (text, media, async) use the same async pattern: // validate → credit check → dispatch to queue → return 202 // Media preprocessing (video/audio/image) handled by worker-media-preprocess // ============================================================================ async function dispatchTechniquesAsync(req: Request, res: Response) { try { const { text, media_url, url, media_type, user_id: body_uid, user_email: body_email, plan_type = 1 } = req.body; const user_id = resolveUserId(req, body_uid); const user_email = req.jwtEmail || body_email; if (!text && !media_url && !url) { return res.status(400).json({ success: false, error: 'One of text, media_url, or url is required' }); } const validTypes = ['text', 'image', 'audio', 'video', 'url']; const inputType = media_type || (text ? 'text' : url ? 'url' : null); if (!inputType || !validTypes.includes(inputType)) { return res.status(400).json({ success: false, error: `media_type is required. Valid types: ${validTypes.join(', ')}` }); } // Credit check if (user_id) { const creditCheck = await checkCredits(user_id, inputType); if (creditCheck === null) { return res.status(402).json({ success: false, error: 'Credit service temporarily unavailable. Please try again in a few moments.', error_code: 'CREDIT_SERVICE_UNAVAILABLE', }); } if (!creditCheck.hasEnoughCredits) { return res.status(402).json({ success: false, error: 'Insufficient credits', data: { creditsRemained: creditCheck.creditsRemained, creditCost: creditCheck.creditCost, planName: creditCheck.planName }, }); } } const sessionId = crypto.randomUUID(); log.info(`[${sessionId}] Techniques analysis (async), type: ${inputType}`); // For text/url: validate and pass content; for media: workers handle extraction let content = text || ''; if (inputType === 'url' && (url || media_url) && !text) { try { content = await fetchUrlContent(url || media_url); } catch (e) { log.warn(`[${sessionId}] Failed to fetch URL: ${(e as Error).message}`); content = `URL: ${url || media_url}`; } } // Validate text for text/url inputs (media extraction done by workers) if (['text', 'url'].includes(inputType) && content) { const textValidation = validateTextInput(content); if (!textValidation.valid) { return res.status(400).json({ success: false, error: textValidation.error, error_code: 'INVALID_TEXT_INPUT', details: { validation_error: textValidation.error_code, stats: textValidation.stats }, }); } } const { dispatch } = await import('../queue/dispatcher'); type PlanType = 1 | 2 | 3 | 4 | 5 | 6; const planTypeNum = (plan_type >= 1 && plan_type <= 6 ? plan_type : 1) as PlanType; const result = await dispatch( sessionId, { content, url, mediaPath: media_url, userId: user_id, userEmail: user_email, inputType }, planTypeNum, ['techniques'], ); if (!result.async) { // Fallback to sync if RabbitMQ unavailable const { ComponentRunner } = await import('../components/component-runner'); const r = getRedis(); const llmClient = createLLMClient(); const runner = new ComponentRunner(r, llmClient); const syncResult = await runner.runTechniques({ text: content || undefined, media_url, media_type: inputType, sessionId }); const startTime = Date.now(); persistStandaloneResult({ sessionId, userId: user_id, userEmail: user_email, inputType: inputType as InputType, inputText: inputType === 'text' ? content : undefined, mediaUrl: media_url, result: syncResult, durationMs: Date.now() - startTime, llmUsage: runner.getLastUsageTracker(), }); return res.json({ success: true, async: false, data: { session_id: sessionId, media_type: inputType, result: syncResult } }); } // Deduct credits BEFORE responding so a failure can't be lost behind a 202. // We don't refund here even if dispatch already happened — the task is in the // queue and a BILLING_GAP log is the operational signal. if (user_id) { try { const ok = await deductCredits(user_id, inputType, sessionId); if (!ok) log.error(`[Credits] BILLING_GAP session=${sessionId} user=${user_id} type=${inputType}`); } catch (e) { log.error(`[Credits] BILLING_GAP session=${sessionId} user=${user_id} type=${inputType} err=`, (e as Error).message); } } res.status(202).json({ success: true, async: true, data: { session_id: sessionId, status: 'processing', media_type: inputType, queued_components: result.queued, plan_type: planTypeNum, poll_url: `/api/v3/pipeline/${sessionId}/queue-status`, result_url: `/api/v3/pipeline/${sessionId}/result`, }, }); } catch (error) { log.error('[Techniques] Error:', error); internalError(res, error); } } // POST /api/v3/techniques/analyze-async router.post('/techniques/analyze-async', dispatchTechniquesAsync); // POST /api/v3/techniques/analyze — now async (same handler) router.post('/techniques/analyze', dispatchTechniquesAsync); // POST /api/v3/techniques/analyze-media — now async (same handler) router.post('/techniques/analyze-media', dispatchTechniquesAsync); // ============================================================================ // GET /api/v3/techniques/results/:sessionId - Get analysis results // ============================================================================ router.get('/techniques/results/:sessionId', async (req: Request, res: Response) => { try { const { sessionId } = req.params; const r = getRedis(); const keys = await scanKeys(r, AgentKeys.componentPattern(sessionId, 'techniques')); const results: Record = {}; for (const key of keys) { const stage = key.split(':').pop()!; const data = await r.get(key); results[stage] = data ? JSON.parse(data) : null; } res.json({ success: true, data: { session_id: sessionId, results, }, }); } catch (error) { res.status(500).json({ success: false, error: (error as Error).message, }); } }); // ============================================================================ // LLM Client Implementation // ============================================================================ interface LLMCallOptions { provider_routing?: { order?: string[]; allow_fallbacks?: boolean; }; max_tokens?: number; temperature?: number; } async function callLLM(model: any, prompt: string, options?: LLMCallOptions): Promise { const { provider, provider_config, model_code } = model; // Get API key from environment const apiKeyEnvName = `${provider.toUpperCase()}_API_KEY`; const apiKey = process.env[apiKeyEnvName] || process.env.OPENROUTER_API_KEY; if (!apiKey && provider_config.auth_type !== 'none') { throw new Error(`API key not found for provider ${provider}. Set ${apiKeyEnvName}`); } const headers: Record = { 'Content-Type': 'application/json', }; if (provider_config.auth_type === 'bearer') { headers['Authorization'] = `Bearer ${apiKey}`; } else if (provider_config.auth_type === 'x-api-key') { headers['x-api-key'] = apiKey!; } else if (provider_config.auth_type === 'api_key') { headers['x-goog-api-key'] = apiKey!; } // OpenRouter specific headers if (provider === 'openrouter') { headers['HTTP-Referer'] = 'https://didi.ai'; headers['X-Title'] = 'DIDI Agent V3'; } // Build request body const body: Record = { model: model_code, messages: [ { role: 'user', content: prompt } ], max_tokens: options?.max_tokens || 500, temperature: options?.temperature || 0.3, }; // OpenRouter Provider Routing - specify which provider to use // See: https://openrouter.ai/docs/provider-routing if (provider === 'openrouter' && options?.provider_routing) { body.provider = { order: options.provider_routing.order || [], allow_fallbacks: options.provider_routing.allow_fallbacks ?? true, }; } const response = await fetch(`${provider_config.base_url}/chat/completions`, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30000), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`LLM API error: ${response.status} - ${errorText}`); } const data = await response.json() as { choices?: { message?: { content?: string } }[]; usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }; }; // Track usage if caller provided a tracker if ((options as any)?._usage_tracker && Array.isArray((options as any)._usage_tracker) && data.usage) { (options as any)._usage_tracker.push({ model: model_code, prompt_tokens: data.usage.prompt_tokens || 0, completion_tokens: data.usage.completion_tokens || 0, total_tokens: data.usage.total_tokens || (data.usage.prompt_tokens || 0) + (data.usage.completion_tokens || 0), }); } return data.choices?.[0]?.message?.content || ''; } function createLLMClient() { return { async call(prompt: string, systemPrompt: string, options: any): Promise { // Get model info from available_models const r = getRedis(); const modelsData = await r.get(`${REDIS_PREFIX}:available_models`); if (!modelsData) { throw new Error('Models not configured'); } const { models } = JSON.parse(modelsData); const model = models.find((m: any) => m.model_key === options.model_key); if (!model) { throw new Error(`Model ${options.model_key} not found`); } const fullPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt; // Pass provider_routing from model config or options const providerRouting = options.provider_routing || model.provider_routing; const llmOptions: LLMCallOptions & { _usage_tracker?: any[] } = { temperature: options.temperature, max_tokens: options.max_tokens, }; if (providerRouting && providerRouting.length > 0) { llmOptions.provider_routing = { order: providerRouting, allow_fallbacks: true, }; } // Pass through usage tracker if present if (options._usage_tracker) { llmOptions._usage_tracker = options._usage_tracker; } return callLLM(model, fullPrompt, llmOptions); }, }; } export default router;