JSONL-based logging with hallucination detection, query tracking, and performance analytics
SIAA includes a comprehensive quality monitoring system that logs every query in a structured JSONL (JSON Lines) format. The system automatically detects potential issues, tracks performance metrics, and provides endpoints for analysis.
LOG_ARCHIVO = "/opt/siaa/logs/calidad.jsonl" # One JSON line per queryLOG_MAX_LINEAS = 5000 # Rotate when reaching 5000 entries (~2MB)_log_lock = threading.Lock()
{"ts":"2026-03-08T14:23:45","tipo":"DOC","alerta":"OK","pregunta":"cuando debo reportar al sierju","respuesta":"Debe reportar antes del quinto día hábil de cada mes según el artículo 3 del PSAA16-10476.","docs":["acuerdo_no._psaa16-10476.md"],"ctx_chars":2400,"tiempo_s":28.3}
def registrar_consulta( tipo: str, # "CONV", "DOC", "CACHE_HIT", "ERROR" pregunta: str, respuesta: str, docs: list, ctx_chars: int, tiempo_seg: float, cache_hit: bool = False,): """ Escribe una línea JSONL en el archivo de log de calidad. Detecta automáticamente posibles problemas: - POSIBLE_ALUCINACION: el modelo respondió "No encontré" pero SÍ había documentos relevantes (el extractor encontró contexto pero el modelo lo ignoró o el contexto era incorrecto). - SIN_CONTEXTO: pregunta documental sin documentos encontrados. """ try: _asegurar_carpeta_log() # Automatic issue detection no_encontro = "no encontré esa información" in respuesta.lower() habia_docs = len(docs) > 0 and ctx_chars > 100 if no_encontro and habia_docs: alerta = "POSIBLE_ALUCINACION" # Had docs but said "no encontré" elif no_encontro and not habia_docs: alerta = "SIN_CONTEXTO" # No docs — correct to say "no encontré" elif tipo == "ERROR": alerta = "ERROR" else: alerta = "OK" entrada = { "ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "tipo": "CACHE_HIT" if cache_hit else tipo, "alerta": alerta, "pregunta": pregunta[:200], "respuesta": respuesta[:300], "docs": docs, "ctx_chars": ctx_chars, "tiempo_s": round(tiempo_seg, 2), } with _log_lock: # Rotate if exceeds maximum try: with open(LOG_ARCHIVO, "r", encoding="utf-8") as f: lineas = f.readlines() if len(lineas) >= LOG_MAX_LINEAS: # Keep last 4000 lines with open(LOG_ARCHIVO, "w", encoding="utf-8") as f: f.writelines(lineas[-4000:]) except FileNotFoundError: pass # First write with open(LOG_ARCHIVO, "a", encoding="utf-8") as f: f.write(json.dumps(entrada, ensure_ascii=False) + "\n") except Exception as e: print(f"[LOG] Error escribiendo log: {e}", flush=True)
When the log exceeds LOG_MAX_LINEAS, it automatically rotates:
siaa_proxy.py:258-267
# Rotate if exceeds maximumtry: with open(LOG_ARCHIVO, "r", encoding="utf-8") as f: lineas = f.readlines() if len(lineas) >= LOG_MAX_LINEAS: # Keep last 4000 lines with open(LOG_ARCHIVO, "w", encoding="utf-8") as f: f.writelines(lineas[-4000:])except FileNotFoundError: pass # First write
Rotation behavior:
Trigger: 5000 lines (~2 MB)
Action: Keep most recent 4000 lines
Result: Log shrinks by 20%, keeps recent history
Old entries are permanently deleted during rotation. Archive the log file externally if you need long-term history.
# Show only hallucinationscurl "http://localhost:5000/siaa/log?alerta=POSIBLE_ALUCINACION"# Show only errorscurl "http://localhost:5000/siaa/log?alerta=ERROR"# Show only successful queriescurl "http://localhost:5000/siaa/log?alerta=OK"
# Show only cache hitscurl "http://localhost:5000/siaa/log?tipo=CACHE_HIT"# Show only document queriescurl "http://localhost:5000/siaa/log?tipo=DOC"# Show only conversational queriescurl "http://localhost:5000/siaa/log?tipo=CONV"
=== Log SIAA — últimas 20 de 847 consultas ===Errores: 3 | Posibles alucinaciones: 12 | Cache hits: 243 | T.prom: 26.4s[2026-03-08T14:23:45] DOC 28.3s P: cuando debo reportar al sierju R: Debe reportar antes del quinto día hábil de cada mes según el artículo 3... Docs: ['acuerdo_no._psaa16-10476.md'][2026-03-08T14:22:10] ⚠ [POSIBLE_ALUCINACION] DOC 31.2s P: que dice el articulo 7 sobre roles R: No encontré esa información en los documentos disponibles. Docs: ['acuerdo_no._psaa16-10476.md']
@app.route("/siaa/log", methods=["GET"])def ver_log(): """ Muestra las últimas N entradas del log de calidad. Parámetros URL: ?n=50 → últimas N consultas (máx 500, defecto 50) ?tipo=ERROR → filtrar por tipo: OK, ERROR, POSIBLE_ALUCINACION, etc. ?alerta=OK → filtrar por alerta ?formato=txt → salida en texto plano (más fácil de leer en terminal) Ejemplo: curl http://localhost:5000/siaa/log?n=20&alerta=POSIBLE_ALUCINACION """ try: n = min(int(request.args.get("n", 50)), 500) filtro_tipo = request.args.get("tipo", "").upper() filtro_alerta = request.args.get("alerta", "").upper() fmt = request.args.get("formato", "json") # ... read and parse log file ... # Calculate summary todas_lineas = [json.loads(l) for l in lineas if l.strip()] total = len(todas_lineas) errores = sum(1 for e in todas_lineas if e.get("alerta") == "ERROR") alucs = sum(1 for e in todas_lineas if e.get("alerta") == "POSIBLE_ALUCINACION") hits = sum(1 for e in todas_lineas if e.get("tipo") == "CACHE_HIT") t_prom = round( sum(e.get("tiempo_s", 0) for e in todas_lineas if e.get("tiempo_s", 0) > 0) / max(sum(1 for e in todas_lineas if e.get("tiempo_s", 0) > 0), 1), 1 ) return jsonify({ "resumen": { "total_consultas": total, "errores": errores, "posibles_alucinaciones": alucs, "cache_hits": hits, "tiempo_promedio_s": t_prom, }, "entradas": entradas, "mostrando": len(entradas), })
import jsonfrom collections import Counter, defaultdict# Load logwith open('/opt/siaa/logs/calidad.jsonl') as f: logs = [json.loads(line) for line in f]# Alert distributionalert_counts = Counter(log['alerta'] for log in logs)print(f"Alerts: {alert_counts}")# Hallucination analysishallu = [log for log in logs if log['alerta'] == 'POSIBLE_ALUCINACION']hallu_docs = Counter(doc for log in hallu for doc in log['docs'])print(f"Most hallucination-prone docs: {hallu_docs.most_common(5)}")# Response time by hourfrom datetime import datetimetimes_by_hour = defaultdict(list)for log in logs: if log['tiempo_s'] > 0: hour = datetime.fromisoformat(log['ts']).hour times_by_hour[hour].append(log['tiempo_s'])avg_by_hour = {h: sum(times)/len(times) for h, times in times_by_hour.items()}print(f"Avg response time by hour: {avg_by_hour}")
Do chunks contain the answer? If no, improve routing/scoring.
3
Tune system prompt
Model may be too conservative. Try adjusting SYSTEM_DOCUMENTAL:
# siaa_proxy.py:325-341SYSTEM_DOCUMENTAL = """...5. Solo si el contexto es completamente ajeno al tema → responde: "No encontré..."# Consider changing to:5. Si encontraste información aunque sea parcial → responde con ella. Si el contexto habla del tema en términos generales, explica eso. Solo si el contexto es 100% ajeno → responde: "No encontré...""""