livrare lot 2

This commit is contained in:
EVOTECH IT SRL 2026-07-10 03:39:53 -07:00
commit 8ecc78e729
763 changed files with 164593 additions and 0 deletions

View file

@ -0,0 +1,782 @@
/**
* 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<string> {
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(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\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<string, any> = {};
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<string> {
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<string, string> = {
'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<string, any> = {
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<string> {
// 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;