Rene Fichtmueller c82b187548 feat: fix template resolution + add 40 routing rules for all project task types
- completion.ts now uses taskType directly for resolvePrompt (not decision.prompt_template)
  so tip_transceiver_enrich.yaml is used instead of generic_qa fallback template
- routing-rules.yaml: +40 task type entries for TIP (8), EO Pulse (8), SwitchBlade (9),
  PeerCortex (6), NOGnet (9), internal (2) — all with correct model tier assignments
- qwen2.5:3b for fast tasks (classify, short outputs)
- qwen2.5:14b for medium (most analysis tasks)
- qwen2.5:32b for large (blog posts, detailed reports, CSRD)
2026-04-02 23:11:21 +02:00

304 lines
11 KiB
TypeScript

import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { z } from 'zod';
import { classifyInput } from '../pipeline/pre-classifier.js';
import { route } from '../pipeline/router.js';
import { resolvePrompt } from '../pipeline/prompt-resolver.js';
import { callOllamaWithFallbackChain } from '../pipeline/llm-client.js';
import { runPostValidation } from '../pipeline/post-validator.js';
import { evaluateConfidence } from '../pipeline/confidence-gate.js';
import { writeAuditLog, writeBanAnalytics, hashText } from '../observability/audit-log.js';
import { addToReviewQueue } from '../observability/review-queue.js';
import {
requestsTotal,
latencySeconds,
tokensTotal,
confidenceScore,
banlistHitsTotal,
validationFailuresTotal,
} from '../observability/metrics.js';
import { logger } from '../observability/logger.js';
const CompletionRequestSchema = z.object({
caller: z.string().min(1).max(100),
task_type: z.string().optional(),
input: z.string().min(1).max(50_000),
language: z.enum(['de', 'en']).optional(),
context: z.record(z.unknown()).optional(),
options: z
.object({
model: z.string().optional(),
temperature: z.number().min(0).max(2).optional(),
max_tokens: z.number().int().positive().max(16_384).optional(),
return_validation_details: z.boolean().optional(),
})
.optional(),
});
type CompletionRequest = z.infer<typeof CompletionRequestSchema>;
const SKIP_SHIELDX_CALLERS = new Set(['internal', 'shieldx']);
async function runShieldXScan(input: string, caller: string): Promise<{ passed: boolean; reason?: string }> {
const GATEWAY_URL = `http://localhost:${process.env['PORT'] ?? '3100'}`;
try {
const response = await fetch(`${GATEWAY_URL}/v1/completion`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Caller-ID': 'internal' },
body: JSON.stringify({
caller: 'internal',
task_type: 'shieldx_threat_classification',
input,
options: { return_validation_details: false },
}),
signal: AbortSignal.timeout(8000),
});
if (!response.ok) return { passed: true }; // Fail open if ShieldX is down
const result = await response.json() as { output?: string; status?: string };
if (result.status !== 'approved' || !result.output) return { passed: true };
type ShieldResult = { threat_detected: boolean; threat_type?: string; confidence?: number };
let parsed: ShieldResult;
try {
parsed = JSON.parse(result.output) as ShieldResult;
} catch {
return { passed: true };
}
if (parsed.threat_detected && (parsed.confidence ?? 0) > 0.8) {
logger.warn({ caller, threat_type: parsed.threat_type }, 'ShieldX threat detected');
return { passed: false, reason: `Threat detected: ${parsed.threat_type ?? 'unknown'}` };
}
return { passed: true };
} catch (err) {
// ShieldX unavailable — fail open (log but continue)
logger.warn({ err, caller }, 'ShieldX scan failed, continuing without scan');
return { passed: true };
}
}
export async function completionRoute(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/completion',
{
config: { rateLimit: false }, // Custom rate limiting via caller
},
async (request: FastifyRequest, reply: FastifyReply) => {
const startMs = Date.now();
let body: CompletionRequest;
try {
body = CompletionRequestSchema.parse(request.body);
} catch (err) {
return reply.status(400).send({
statusCode: 400,
error: 'Bad Request',
message: err instanceof z.ZodError ? err.errors[0]?.message ?? 'Invalid request' : 'Invalid request body',
});
}
const { caller, input, language, context, options } = body;
const returnValidationDetails = options?.return_validation_details ?? false;
// Stage 2: ShieldX scan
if (!SKIP_SHIELDX_CALLERS.has(caller)) {
const shieldResult = await runShieldXScan(input, caller);
if (!shieldResult.passed) {
requestsTotal.labels({ caller, task_type: 'unknown', status: 'rejected' }).inc();
return reply.status(400).send({
statusCode: 400,
error: 'Rejected',
message: shieldResult.reason ?? 'Input rejected by security scan',
});
}
}
// Stage 3: Pre-classifier
let taskType = body.task_type;
let classificationResult;
if (!taskType) {
try {
classificationResult = await classifyInput(input);
taskType = classificationResult.task_type;
} catch (err) {
logger.warn({ err }, 'Pre-classifier failed');
taskType = 'generic_qa';
}
}
// Stage 4: Router
let decision;
try {
decision = route(taskType, caller, {
model: options?.model,
temperature: options?.temperature,
max_tokens: options?.max_tokens,
});
} catch (err) {
return reply.status(400).send({
statusCode: 400,
error: 'Routing Error',
message: err instanceof Error ? err.message : 'Failed to route request',
});
}
// Stage 5: Prompt assembly
// Use taskType directly for template lookup (so tip_transceiver_enrich.yaml is used,
// not the generic_qa fallback from routing). The router only selects the model.
const resolved = resolvePrompt(
taskType ?? decision.prompt_template,
{
input,
user_context: context,
source_data: context?.['source_data'] as string | undefined,
},
language ?? 'en',
);
// Stage 6: Ollama call with circuit breaker + retry
let ollamaResponse;
try {
ollamaResponse = await callOllamaWithFallbackChain(
{
model: decision.model,
prompt: resolved.prompt,
system: resolved.system,
options: {
temperature: decision.temperature,
num_predict: decision.max_tokens,
},
format: decision.output_format === 'json' ? 'json' : '',
stream: false,
},
decision.fallback_chain,
decision.tier,
);
} catch (err) {
const latency = Date.now() - startMs;
logger.error({ err, caller, taskType }, 'Ollama call failed');
requestsTotal.labels({ caller, task_type: taskType, status: 'rejected' }).inc();
latencySeconds.labels({ caller, task_type: taskType, model: decision.model }).observe(latency / 1000);
return reply.status(503).send({
statusCode: 503,
error: 'Service Unavailable',
message: 'LLM service unavailable, please retry',
});
}
const outputText = ollamaResponse.response;
const latencyMs = Date.now() - startMs;
// Stage 7: Post-validation chain
const validationOutput = await runPostValidation(outputText, {
validators: decision.validators,
language,
output_format: decision.output_format,
requires_fact_check: decision.requires_fact_check,
schema: resolved.schema,
});
// Stage 8: Confidence gate
const confidenceResult = evaluateConfidence(validationOutput);
// Record metrics
requestsTotal.labels({ caller, task_type: taskType, status: confidenceResult.status }).inc();
latencySeconds.labels({ caller, task_type: taskType, model: ollamaResponse.model ?? decision.model }).observe(latencyMs / 1000);
tokensTotal.labels({ direction: 'in', model: decision.model }).inc(ollamaResponse.prompt_eval_count ?? 0);
tokensTotal.labels({ direction: 'out', model: decision.model }).inc(ollamaResponse.eval_count ?? 0);
confidenceScore.labels({ task_type: taskType, model: decision.model }).observe(confidenceResult.score);
// Record ban hits in metrics
for (const violation of validationOutput.ban_violations) {
banlistHitsTotal.labels({ term: violation.term, language: violation.language, category: violation.category }).inc();
}
// Record validation failures
for (const result of validationOutput.results) {
if (!result.passed) {
validationFailuresTotal.labels({ validator: result.validator, task_type: taskType }).inc();
}
}
// Stage 9: Audit log
const inputHash = hashText(input);
const outputHash = hashText(outputText);
const callId = await writeAuditLog({
caller,
task_type: taskType,
model_used: decision.model,
prompt_id: resolved.prompt_id,
prompt_version: resolved.prompt_version,
input_hash: inputHash,
output_text: confidenceResult.status !== 'pending_review' ? outputText : undefined,
output_hash: outputHash,
token_count_in: ollamaResponse.prompt_eval_count ?? 0,
token_count_out: ollamaResponse.eval_count ?? 0,
latency_ms: latencyMs,
confidence: confidenceResult.score,
status: confidenceResult.status,
validation_log: validationOutput.results,
ban_hits: validationOutput.ban_violations,
metadata: {
classification: classificationResult,
model_tier: decision.tier,
fallback_used: ollamaResponse.model !== decision.model,
},
});
// Write ban analytics
if (validationOutput.ban_violations.length > 0 && callId) {
void writeBanAnalytics(callId, validationOutput.ban_violations, caller, taskType);
}
// Add to review queue if pending_review
if (confidenceResult.status === 'pending_review' && callId) {
void addToReviewQueue({
callId,
caller,
taskType,
inputText: input,
outputText,
confidence: confidenceResult.score,
validationLog: validationOutput.results,
});
}
// Stage 10: Response
const responseBody: Record<string, unknown> = {
id: callId,
status: confidenceResult.status,
confidence: Math.round(confidenceResult.score * 100) / 100,
model: decision.model,
task_type: taskType,
latency_ms: latencyMs,
tokens: {
in: ollamaResponse.prompt_eval_count ?? 0,
out: ollamaResponse.eval_count ?? 0,
},
};
if (confidenceResult.status !== 'pending_review') {
responseBody['output'] = outputText;
} else {
responseBody['output'] = null;
responseBody['message'] = 'Output is pending human review due to low confidence';
}
if (returnValidationDetails) {
responseBody['validation'] = validationOutput.results;
responseBody['confidence_detail'] = {
base_score: confidenceResult.base_score,
total_impact: confidenceResult.total_impact,
final_score: confidenceResult.score,
};
}
return reply.status(200).send(responseBody);
},
);
}