mirror of
https://github.com/hoshikawa2/agent_framework_oci_evaluator.git
synced 2026-07-09 10:34:19 +00:00
first commit
This commit is contained in:
38
.env.example
Normal file
38
.env.example
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Oracle Autonomous Database / Wallet - same pattern used by Agent Framework
|
||||||
|
ADB_USER=admin
|
||||||
|
ADB_PASSWORD=change-me
|
||||||
|
ADB_DSN=oradb23ai_high
|
||||||
|
ADB_WALLET_LOCATION=/path/to/Wallet_ORADB23ai
|
||||||
|
ADB_WALLET_PASSWORD=change-me
|
||||||
|
ADB_TABLE_PREFIX=AGENTFW
|
||||||
|
|
||||||
|
# Langfuse collector / publisher
|
||||||
|
ENABLE_LANGFUSE=true
|
||||||
|
LANGFUSE_PUBLIC_KEY=pk-lf-...
|
||||||
|
LANGFUSE_SECRET_KEY=sk-lf-...
|
||||||
|
LANGFUSE_HOST=http://localhost:3005
|
||||||
|
PUBLISH_LANGFUSE_SCORES=false
|
||||||
|
|
||||||
|
# LLM - same style as Agent Framework env-driven config
|
||||||
|
LLM_PROVIDER=oci_openai
|
||||||
|
LLM_PROFILE=judge
|
||||||
|
LLM_PROFILES_PATH=configs/llm_profiles/llm_profiles.yaml
|
||||||
|
OCI_GENAI_BASE_URL=https://inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com/openai/v1
|
||||||
|
OCI_GENAI_MODEL_ID=meta.llama-3.3-70b-instruct
|
||||||
|
OCI_GENAI_API_KEY=seu_token_aqui
|
||||||
|
LLM_TEMPERATURE=0
|
||||||
|
LLM_MAX_TOKENS=900
|
||||||
|
LLM_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
|
# Execution
|
||||||
|
AGENTS_CONFIG_PATH=configs/judge/agents.yaml
|
||||||
|
TRACE_PROMPT_PATH=configs/judge/trace_metrics.yaml
|
||||||
|
SESSION_PROMPT_PATH=configs/judge/session_metrics.yaml
|
||||||
|
OUTPUT_DIR=output
|
||||||
|
BATCH_SIZE=50
|
||||||
|
MAX_ATTEMPTS=3
|
||||||
|
|
||||||
|
# Optional GCS export compatibility with TIM package
|
||||||
|
ENABLE_GCS_UPLOAD=false
|
||||||
|
JUDGE_GCS_BUCKET=
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS=configs/GCP_ACCESS_KEY.json
|
||||||
12
.idea/.gitignore
generated
vendored
Normal file
12
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Environment-dependent path to Maven home directory
|
||||||
|
/mavenHomeManager.xml
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Zeppelin ignored files
|
||||||
|
/ZeppelinRemoteNotebooks/
|
||||||
9
.idea/agent_framework_evaluator.iml
generated
Normal file
9
.idea/agent_framework_evaluator.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
7
.idea/codeStyles/Project.xml
generated
Normal file
7
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<ScalaCodeStyleSettings>
|
||||||
|
<option name="MULTILINE_STRING_CLOSING_QUOTES_ON_NEW_LINE" value="true" />
|
||||||
|
</ScalaCodeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
||||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
6
.idea/misc.xml
generated
Normal file
6
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_24" default="true" project-jdk-name="24" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/agent_framework_evaluator.iml" filepath="$PROJECT_DIR$/.idea/agent_framework_evaluator.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
24
.oca/custom_code_review_guidelines.txt
Normal file
24
.oca/custom_code_review_guidelines.txt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Sample guideline, please follow similar structure for guideline with code samples
|
||||||
|
# 1. Suggest using streams instead of simple loops for better readability.
|
||||||
|
# <example>
|
||||||
|
# *Comment:
|
||||||
|
# Category: Minor
|
||||||
|
# Issue: Use streams instead of a loop for better readability.
|
||||||
|
# Code Block:
|
||||||
|
#
|
||||||
|
# ```java
|
||||||
|
# // Calculate squares of numbers
|
||||||
|
# List<Integer> squares = new ArrayList<>();
|
||||||
|
# for (int number : numbers) {
|
||||||
|
# squares.add(number * number);
|
||||||
|
# }
|
||||||
|
# ```
|
||||||
|
# Recommendation:
|
||||||
|
#
|
||||||
|
# ```java
|
||||||
|
# // Calculate squares of numbers
|
||||||
|
# List<Integer> squares = Arrays.stream(numbers)
|
||||||
|
# .map(n -> n * n) // Map each number to its square
|
||||||
|
# .toList();
|
||||||
|
# ```
|
||||||
|
# </example>
|
||||||
5
Dockerfile
Normal file
5
Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . /app
|
||||||
|
RUN pip install --no-cache-dir -e .
|
||||||
|
CMD ["python", "-m", "evaluator.cli", "run-agents", "--source", "langfuse"]
|
||||||
1546
README.en-US.md
Normal file
1546
README.en-US.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
configs/.DS_Store
vendored
Normal file
BIN
configs/.DS_Store
vendored
Normal file
Binary file not shown.
55
configs/identity.yaml
Normal file
55
configs/identity.yaml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
identity:
|
||||||
|
version: "2"
|
||||||
|
required:
|
||||||
|
- session_key
|
||||||
|
keys:
|
||||||
|
customer_key:
|
||||||
|
description: Cliente/assinante/consumidor canônico.
|
||||||
|
sources:
|
||||||
|
- business_context.customer_key
|
||||||
|
- customer_key
|
||||||
|
- msisdn
|
||||||
|
- customer_id
|
||||||
|
- user_id
|
||||||
|
- ani
|
||||||
|
- from
|
||||||
|
contract_key:
|
||||||
|
description: Contrato, conta, fatura, pedido ou asset principal.
|
||||||
|
sources:
|
||||||
|
- business_context.contract_key
|
||||||
|
- contract_key
|
||||||
|
- invoice_id
|
||||||
|
- current_invoice_number
|
||||||
|
- order_id
|
||||||
|
- pedido_id
|
||||||
|
- asset_id
|
||||||
|
interaction_key:
|
||||||
|
description: Chave externa da interação/call/chat vinda do canal.
|
||||||
|
sources:
|
||||||
|
- business_context.interaction_key
|
||||||
|
- interaction_key
|
||||||
|
- ura_call_id
|
||||||
|
- call_id
|
||||||
|
- message_id
|
||||||
|
account_key:
|
||||||
|
description: Conta de cobrança/conta comercial.
|
||||||
|
sources:
|
||||||
|
- business_context.account_key
|
||||||
|
- account_key
|
||||||
|
- account_id
|
||||||
|
- billing_account_id
|
||||||
|
resource_key:
|
||||||
|
description: Recurso/linha/produto/asset específico.
|
||||||
|
sources:
|
||||||
|
- business_context.resource_key
|
||||||
|
- resource_key
|
||||||
|
- asset_id
|
||||||
|
- product_id
|
||||||
|
- sku
|
||||||
|
session_key:
|
||||||
|
description: Sessão técnica estável já escopada por tenant e agente.
|
||||||
|
sources:
|
||||||
|
- business_context.session_key
|
||||||
|
- session_key
|
||||||
|
- conversation_key
|
||||||
|
- session_id
|
||||||
21
configs/judge/agents.yaml
Normal file
21
configs/judge/agents.yaml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
agents:
|
||||||
|
- agent_id: telecom_contas
|
||||||
|
enabled: true
|
||||||
|
days_back: 1
|
||||||
|
percentage: 1.0
|
||||||
|
langfuse_agent_aliases: [telecom_contas, billing_agent, financeiro_agent]
|
||||||
|
gcs_prefix: agnt_ai_contas/llm_qa/input
|
||||||
|
|
||||||
|
- agent_id: retail_orders
|
||||||
|
enabled: true
|
||||||
|
days_back: 1
|
||||||
|
percentage: 1.0
|
||||||
|
langfuse_agent_aliases: [retail_orders, orders_agent, retail_agent]
|
||||||
|
gcs_prefix: agnt_ai_orders/llm_qa/input
|
||||||
|
|
||||||
|
- agent_id: financeiro_agent
|
||||||
|
enabled: true
|
||||||
|
days_back: 1
|
||||||
|
percentage: 1.0
|
||||||
|
langfuse_agent_aliases: [financeiro_agent, billing_agent, telecom_contas]
|
||||||
|
gcs_prefix: agnt_ai_financeiro/llm_qa/input
|
||||||
17
configs/judge/session_metrics.yaml
Normal file
17
configs/judge/session_metrics.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
session_metrics: |
|
||||||
|
Você é um avaliador imparcial de uma sessão completa de conversa entre
|
||||||
|
cliente e agente. Vai receber a TRANSCRIÇÃO da sessão (alternância
|
||||||
|
user/agent). Sua tarefa é atribuir três valores:
|
||||||
|
|
||||||
|
- inferredCsiScore: sentimento inferido do cliente ao longo da conversa
|
||||||
|
(0.0 negativo, 0.5 neutro, 1.0 positivo).
|
||||||
|
- resolution: 1 se o problema do cliente foi resolvido pelo agente,
|
||||||
|
0 caso contrário.
|
||||||
|
- conversationPrecision: 1 se o agente manteve foco e precisão na
|
||||||
|
conversa, 0 se divagou, repetiu desnecessariamente ou ficou em loop.
|
||||||
|
|
||||||
|
Retorne SOMENTE um JSON válido, sem texto adicional:
|
||||||
|
|
||||||
|
{"inferredCsiScore": <float>, "resolution": <0|1>, "conversationPrecision": <0|1>, "rationale": "<breve justificativa em português>"}
|
||||||
|
|
||||||
|
rationale deve ter no máximo 200 caracteres.
|
||||||
16
configs/judge/trace_metrics.yaml
Normal file
16
configs/judge/trace_metrics.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
trace_metrics: |
|
||||||
|
Você é um avaliador imparcial de respostas de agentes conversacionais.
|
||||||
|
Vai receber o HISTÓRICO da conversa (últimos turnos), a MENSAGEM DO
|
||||||
|
USUÁRIO e a RESPOSTA DO AGENTE. Sua tarefa é atribuir três notas
|
||||||
|
numéricas entre 0.0 e 1.0:
|
||||||
|
|
||||||
|
- judgeScore: qualidade global da resposta (clareza, utilidade, tom).
|
||||||
|
- accuracyScore: factualidade da resposta frente ao contexto fornecido.
|
||||||
|
- alucinationScore: grau em que a resposta contém afirmações NÃO
|
||||||
|
suportadas pelo contexto (alto = mais alucinação = pior).
|
||||||
|
|
||||||
|
Retorne SOMENTE um JSON válido, sem nenhum texto adicional, no formato:
|
||||||
|
|
||||||
|
{"judgeScore": <float>, "accuracyScore": <float>, "alucinationScore": <float>, "rationale": "<breve justificativa em português>"}
|
||||||
|
|
||||||
|
rationale deve ter no máximo 200 caracteres.
|
||||||
11
configs/llm_profiles/llm_profiles.yaml
Normal file
11
configs/llm_profiles/llm_profiles.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
profiles:
|
||||||
|
judge:
|
||||||
|
provider: oci_openai
|
||||||
|
model: meta.llama-3.3-70b-instruct
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 900
|
||||||
|
mock:
|
||||||
|
provider: mock
|
||||||
|
model: mock-judge
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 300
|
||||||
BIN
evaluator/.DS_Store
vendored
Normal file
BIN
evaluator/.DS_Store
vendored
Normal file
Binary file not shown.
0
evaluator/__init__.py
Normal file
0
evaluator/__init__.py
Normal file
BIN
evaluator/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
evaluator/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
evaluator/__pycache__/cli.cpython-313.pyc
Normal file
BIN
evaluator/__pycache__/cli.cpython-313.pyc
Normal file
Binary file not shown.
BIN
evaluator/__pycache__/engine.cpython-313.pyc
Normal file
BIN
evaluator/__pycache__/engine.cpython-313.pyc
Normal file
Binary file not shown.
0
evaluator/analytics/__init__.py
Normal file
0
evaluator/analytics/__init__.py
Normal file
BIN
evaluator/analytics/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
evaluator/analytics/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
evaluator/analytics/__pycache__/vloop.cpython-313.pyc
Normal file
BIN
evaluator/analytics/__pycache__/vloop.cpython-313.pyc
Normal file
Binary file not shown.
86
evaluator/analytics/vloop.py
Normal file
86
evaluator/analytics/vloop.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(text: Any) -> str:
|
||||||
|
"""Same deterministic spirit as Agent Framework VLOOP: lower + strip.
|
||||||
|
|
||||||
|
We also collapse whitespace because offline telemetry can contain line breaks,
|
||||||
|
repeated spaces, or formatting differences from Langfuse observations.
|
||||||
|
"""
|
||||||
|
if text is None:
|
||||||
|
return ""
|
||||||
|
return re.sub(r"\s+", " ", str(text).lower()).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _message_role(message: Any) -> str:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
return str(message.get("role") or "").lower()
|
||||||
|
return str(getattr(message, "role", "") or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _message_content(message: Any) -> str:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
return str(message.get("content") or "")
|
||||||
|
return str(getattr(message, "content", "") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def user_texts_from_record(record_or_raw: Any) -> list[str]:
|
||||||
|
"""Extract user/human texts from ConversationRecord or its JSON dict."""
|
||||||
|
if isinstance(record_or_raw, dict):
|
||||||
|
messages = record_or_raw.get("messages") or []
|
||||||
|
input_text = record_or_raw.get("input_text") or ""
|
||||||
|
else:
|
||||||
|
messages = getattr(record_or_raw, "messages", []) or []
|
||||||
|
input_text = getattr(record_or_raw, "input_text", "") or ""
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
for message in messages:
|
||||||
|
role = _message_role(message)
|
||||||
|
if role in {"user", "human", "cliente", "customer"}:
|
||||||
|
text = _normalize(_message_content(message))
|
||||||
|
if text:
|
||||||
|
out.append(text)
|
||||||
|
|
||||||
|
# Ensure the canonical current user input participates even if messages were
|
||||||
|
# reconstructed only from observations and missed the trace-level input.
|
||||||
|
canonical = _normalize(input_text)
|
||||||
|
if canonical and canonical not in out:
|
||||||
|
out.append(canonical)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def detect_vloop(record_or_raw: Any, history_window: int = 6, min_previous_repetitions: int = 2) -> bool:
|
||||||
|
"""Offline equivalent of Agent Framework VLOOP.
|
||||||
|
|
||||||
|
Framework logic:
|
||||||
|
normalized = lower(text).strip()
|
||||||
|
history = lower(history_texts)[-6:]
|
||||||
|
repeated = history.count(normalized) >= 2
|
||||||
|
|
||||||
|
Offline telemetry does not provide the exact guardrail context, so we rebuild
|
||||||
|
it from user messages. The current user text is the last user message. The
|
||||||
|
previous history is the prior messages in the same reconstructed trace/session.
|
||||||
|
"""
|
||||||
|
texts = user_texts_from_record(record_or_raw)
|
||||||
|
if not texts:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current = texts[-1]
|
||||||
|
if not current:
|
||||||
|
return False
|
||||||
|
|
||||||
|
history = texts[:-1][-history_window:]
|
||||||
|
if history.count(current) >= min_previous_repetitions:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Defensive fallback: if the reconstructed messages do not preserve a clear
|
||||||
|
# current turn, flag any user utterance repeated 3+ times in the recent window.
|
||||||
|
recent = texts[-(history_window + 1):]
|
||||||
|
return any(recent.count(t) >= (min_previous_repetitions + 1) for t in set(recent) if t)
|
||||||
|
|
||||||
|
|
||||||
|
def vloop_flag(record_or_raw: Any) -> int:
|
||||||
|
return 1 if detect_vloop(record_or_raw) else 0
|
||||||
BIN
evaluator/api/__pycache__/main.cpython-313.pyc
Normal file
BIN
evaluator/api/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
37
evaluator/api/main.py
Normal file
37
evaluator/api/main.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from evaluator.persistence.repository import EvaluationRepository
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
app = FastAPI(title='Agent Framework Evaluator')
|
||||||
|
|
||||||
|
@app.get('/health')
|
||||||
|
def health(): return {'status':'ok'}
|
||||||
|
|
||||||
|
@app.get('/runs')
|
||||||
|
async def runs(limit:int=20): return await EvaluationRepository(auto_init_schema=False).alist_runs(limit)
|
||||||
|
|
||||||
|
@app.get('/runs/{run_id}/progress')
|
||||||
|
async def run_progress(run_id:str, events:int=20):
|
||||||
|
return await EvaluationRepository(auto_init_schema=False).aget_run_progress(run_id, events)
|
||||||
|
|
||||||
|
@app.get("/runs/{run_id}/results")
|
||||||
|
async def results(run_id: str, limit: int = 100):
|
||||||
|
return await EvaluationRepository(auto_init_schema=False).alist_results(run_id, limit)
|
||||||
|
|
||||||
|
@app.get('/ui', response_class=HTMLResponse)
|
||||||
|
def ui():
|
||||||
|
return '''<!doctype html><html><head><title>Agent Framework Evaluator</title><style>body{font-family:Arial;margin:32px}table{border-collapse:collapse;width:100%}td,th{border:1px solid #ddd;padding:8px}th{background:#eee}</style></head><body><h1>Agent Framework Evaluator</h1><p>Offline LLM-as-a-Judge with Agent Framework telemetry.</p><table id="runs"><thead><tr><th>Run</th><th>Agent</th><th>Source</th><th>Status</th><th>Total</th><th>Processed</th><th>Failed</th><th>Created</th></tr></thead><tbody></tbody></table><script>async function load(){const r=await fetch('/runs'); const data=await r.json(); document.querySelector('#runs tbody').innerHTML=data.map(x=>`<tr><td><a href="/runs/${x.run_id}/progress">${x.run_id}</a></td><td>${x.agent_id||''}</td><td>${x.source}</td><td>${x.status}</td><td>${x.total_items}</td><td>${x.processed_items}</td><td>${x.failed_items}</td><td>${x.created_at}</td></tr>`).join('')} load(); setInterval(load,5000);</script></body></html>'''
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def debug_exception_handler(request: Request, exc: Exception):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={
|
||||||
|
"error": str(exc),
|
||||||
|
"traceback": traceback.format_exc(),
|
||||||
|
},
|
||||||
|
)
|
||||||
80
evaluator/cli.py
Normal file
80
evaluator/cli.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import typer
|
||||||
|
from rich import print
|
||||||
|
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn
|
||||||
|
from evaluator.config.agents import load_agents
|
||||||
|
from evaluator.engine import EvaluationEngine
|
||||||
|
from evaluator.persistence.repository import EvaluationRepository
|
||||||
|
from evaluator.config.settings import settings
|
||||||
|
|
||||||
|
app = typer.Typer(help='Agent Framework TIM-style LLM Judge Evaluator')
|
||||||
|
|
||||||
|
|
||||||
|
def _run_progress(coro_factory):
|
||||||
|
async def runner():
|
||||||
|
state={'run_id': None}
|
||||||
|
with Progress(SpinnerColumn(), TextColumn('[bold blue]{task.fields[stage]}'), BarColumn(), TextColumn('{task.completed}/{task.total}'), TextColumn('{task.percentage:>3.0f}%'), TimeElapsedColumn()) as progress:
|
||||||
|
task=progress.add_task('evaluation', total=1, stage='starting')
|
||||||
|
async def cb(event):
|
||||||
|
state['run_id'] = event.get('run_id') or state['run_id']
|
||||||
|
stage = event.get('stage','')
|
||||||
|
msg = event.get('message','')
|
||||||
|
if state['run_id']:
|
||||||
|
snap = await EvaluationRepository(auto_init_schema=False).aget_run_progress(state['run_id'], event_limit=1)
|
||||||
|
total=int(snap.get('total_items') or 0) or 1
|
||||||
|
done=int(snap.get('done_items') or 0)
|
||||||
|
progress.update(task,total=total,completed=done,stage=f'{stage}: {msg}'[:120])
|
||||||
|
result = await coro_factory(cb)
|
||||||
|
progress.update(task, completed=1, total=1, stage='finished')
|
||||||
|
return result
|
||||||
|
return asyncio.run(runner())
|
||||||
|
|
||||||
|
@app.command("reset-db")
|
||||||
|
def reset_db():
|
||||||
|
repo = EvaluationRepository(auto_init_schema=False)
|
||||||
|
repo.store.drop_schema()
|
||||||
|
repo.store._init_schema()
|
||||||
|
print({"status": "OK", "message": "Evaluator schema dropped and recreated successfully."})
|
||||||
|
|
||||||
|
@app.command('init-db')
|
||||||
|
def init_db():
|
||||||
|
EvaluationRepository(auto_init_schema=True)
|
||||||
|
print({'status':'OK','message':'schema checked/created'})
|
||||||
|
|
||||||
|
@app.command('show-config')
|
||||||
|
def show_config():
|
||||||
|
print({'env_path': str(settings.project_root / '.env'), 'adb_dsn': settings.ADB_DSN, 'wallet': settings.ADB_WALLET_LOCATION, 'langfuse': settings.enable_langfuse, 'publish_langfuse_scores': settings.publish_langfuse_scores, 'llm_provider': settings.llm_provider, 'llm_profile': settings.llm_profile, 'oci_genai_base_url': settings.OCI_GENAI_BASE_URL, 'oci_genai_model': settings.OCI_GENAI_MODEL, 'oci_genai_api_key_configured': bool(settings.OCI_GENAI_API_KEY), 'agents_config': settings.agents_config_path})
|
||||||
|
|
||||||
|
@app.command('run')
|
||||||
|
def run(period_start: datetime, period_end: datetime, source: str='langfuse', limit: int|None=None, show_progress: bool=True):
|
||||||
|
if show_progress:
|
||||||
|
result = _run_progress(lambda cb: EvaluationEngine(progress_callback=cb).run(period_start, period_end, source, limit))
|
||||||
|
else:
|
||||||
|
result = asyncio.run(EvaluationEngine().run(period_start, period_end, source, limit))
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
@app.command('run-agents')
|
||||||
|
def run_agents(source: str='langfuse', agent_id: str|None=None, limit: int|None=None):
|
||||||
|
async def main():
|
||||||
|
results=[]
|
||||||
|
now=datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
for agent in load_agents():
|
||||||
|
if agent_id and agent.agent_id != agent_id: continue
|
||||||
|
start = now - timedelta(days=agent.days_back)
|
||||||
|
engine=EvaluationEngine()
|
||||||
|
results.append(await engine.run_agent(agent, start, now, source=source, limit=limit))
|
||||||
|
return results
|
||||||
|
print(asyncio.run(main()))
|
||||||
|
|
||||||
|
@app.command('progress')
|
||||||
|
def progress(run_id: str, events: int=20):
|
||||||
|
print(asyncio.run(EvaluationRepository(auto_init_schema=False).aget_run_progress(run_id, event_limit=events)))
|
||||||
|
|
||||||
|
@app.command('runs')
|
||||||
|
def runs(limit: int=20):
|
||||||
|
print(asyncio.run(EvaluationRepository(auto_init_schema=False).alist_runs(limit)))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app()
|
||||||
BIN
evaluator/collectors/.DS_Store
vendored
Normal file
BIN
evaluator/collectors/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
evaluator/collectors/__pycache__/agent_framework.cpython-313.pyc
Normal file
BIN
evaluator/collectors/__pycache__/agent_framework.cpython-313.pyc
Normal file
Binary file not shown.
BIN
evaluator/collectors/__pycache__/base.cpython-313.pyc
Normal file
BIN
evaluator/collectors/__pycache__/base.cpython-313.pyc
Normal file
Binary file not shown.
BIN
evaluator/collectors/__pycache__/langfuse.cpython-313.pyc
Normal file
BIN
evaluator/collectors/__pycache__/langfuse.cpython-313.pyc
Normal file
Binary file not shown.
BIN
evaluator/collectors/__pycache__/mock.cpython-313.pyc
Normal file
BIN
evaluator/collectors/__pycache__/mock.cpython-313.pyc
Normal file
Binary file not shown.
42
evaluator/collectors/agent_framework.py
Normal file
42
evaluator/collectors/agent_framework.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from evaluator.collectors.base import ConversationCollector
|
||||||
|
from evaluator.core.models import ConversationRecord, ConversationMessage
|
||||||
|
from evaluator.persistence.oracle_store import OracleStore, _json_loads
|
||||||
|
from evaluator.config.settings import settings
|
||||||
|
|
||||||
|
class AgentFrameworkCollector(ConversationCollector):
|
||||||
|
def __init__(self):
|
||||||
|
self.store = OracleStore(settings, auto_init_schema=False)
|
||||||
|
|
||||||
|
async def collect(self, period_start: datetime, period_end: datetime, agent_aliases: set[str] | None = None, limit: int | None = None):
|
||||||
|
return await self.store.to_thread(self._collect, period_start, period_end, agent_aliases or set(), limit or 100)
|
||||||
|
|
||||||
|
def _collect(self, period_start, period_end, aliases, limit):
|
||||||
|
records=[]
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
cur=conn.cursor()
|
||||||
|
cur.execute(f"""
|
||||||
|
select * from (
|
||||||
|
select SESSION_ID, AGENT_ID, CHANNEL, CONTEXT_JSON, METADATA_JSON, CREATED_AT
|
||||||
|
from {self.store.t('AGENT_SESSION')}
|
||||||
|
where CREATED_AT >= :start_at and CREATED_AT < :end_at
|
||||||
|
order by CREATED_AT desc
|
||||||
|
) where rownum <= :max_rows
|
||||||
|
""", dict(start_at=period_start, end_at=period_end, max_rows=limit))
|
||||||
|
sessions=cur.fetchall()
|
||||||
|
for session_id, agent_id, channel, ctx, meta, created_at in sessions:
|
||||||
|
if aliases and agent_id not in aliases: continue
|
||||||
|
cur.execute(f"""
|
||||||
|
select ROLE, CONTENT, METADATA_JSON, CREATED_AT, MESSAGE_ID
|
||||||
|
from {self.store.t('AGENT_MESSAGE')}
|
||||||
|
where SESSION_ID=:session_id order by CREATED_AT
|
||||||
|
""", dict(session_id=session_id))
|
||||||
|
rows=cur.fetchall()
|
||||||
|
msgs=[]
|
||||||
|
for role, content, msg_meta, msg_created, message_id in rows:
|
||||||
|
msgs.append(ConversationMessage(role=role, content=content or '', created_at=str(msg_created), metadata=_json_loads(msg_meta.read() if hasattr(msg_meta,'read') else msg_meta,{})))
|
||||||
|
input_text=next((m.content for m in msgs if m.role in ('user','human')), '')
|
||||||
|
output_text=next((m.content for m in reversed(msgs) if m.role in ('assistant','ai','agent')), '')
|
||||||
|
records.append(ConversationRecord(session_id=session_id, trace_id=session_id, message_id=rows[-1][4] if rows else None, agent_id=agent_id, channel=channel, input_text=input_text, output_text=output_text, messages=msgs, metadata=_json_loads(meta.read() if hasattr(meta,'read') else meta,{}), raw={}))
|
||||||
|
return records
|
||||||
8
evaluator/collectors/base.py
Normal file
8
evaluator/collectors/base.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import datetime
|
||||||
|
from evaluator.core.models import ConversationRecord
|
||||||
|
|
||||||
|
class ConversationCollector(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def collect(self, period_start: datetime, period_end: datetime, agent_aliases: set[str] | None = None, limit: int | None = None) -> list[ConversationRecord]: ...
|
||||||
355
evaluator/collectors/langfuse.py
Normal file
355
evaluator/collectors/langfuse.py
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from evaluator.collectors.base import ConversationCollector
|
||||||
|
from evaluator.config.settings import settings
|
||||||
|
from evaluator.core.models import ConversationMessage, ConversationRecord
|
||||||
|
from evaluator.identity.resolver import IdentityResolver
|
||||||
|
from evaluator.config.settings import settings
|
||||||
|
|
||||||
|
def _iso_z(dt: datetime) -> str:
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata(obj: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
return {}
|
||||||
|
meta = obj.get("metadata") or {}
|
||||||
|
return meta if isinstance(meta, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _first_value(*values: Any) -> Any:
|
||||||
|
for value in values:
|
||||||
|
if value not in (None, "", [], {}):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _content_to_text(value: Any) -> str:
|
||||||
|
"""Convert Langfuse/OpenAI-style content payloads to plain text."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float, bool)):
|
||||||
|
return str(value)
|
||||||
|
if isinstance(value, list):
|
||||||
|
parts: list[str] = []
|
||||||
|
for item in value:
|
||||||
|
text = _content_to_text(item)
|
||||||
|
if text:
|
||||||
|
parts.append(text)
|
||||||
|
return "\n".join(parts)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
# OpenAI multimodal content often comes as {"type":"text","text":"..."}
|
||||||
|
for key in ("text", "content", "message", "value", "input", "output", "completion"):
|
||||||
|
if key in value:
|
||||||
|
text = _content_to_text(value.get(key))
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
# Chat completion response variants.
|
||||||
|
choices = value.get("choices")
|
||||||
|
if isinstance(choices, list) and choices:
|
||||||
|
return _content_to_text(choices[0])
|
||||||
|
msg = value.get("message")
|
||||||
|
if isinstance(msg, dict):
|
||||||
|
return _content_to_text(msg.get("content"))
|
||||||
|
return ""
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _messages_from_value(value: Any, default_role: str) -> list[ConversationMessage]:
|
||||||
|
"""Extract chat messages from strings/lists/dicts returned by Langfuse."""
|
||||||
|
if value in (None, "", [], {}):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip()
|
||||||
|
return [ConversationMessage(role=default_role, content=text)] if text else []
|
||||||
|
|
||||||
|
if isinstance(value, dict):
|
||||||
|
# Common wrappers: {"messages": [...]}, {"input": ...}, {"output": ...}
|
||||||
|
for key in ("messages", "conversation", "chat"):
|
||||||
|
if isinstance(value.get(key), list):
|
||||||
|
return _messages_from_value(value[key], default_role)
|
||||||
|
|
||||||
|
if "role" in value and "content" in value:
|
||||||
|
role = str(value.get("role") or default_role)
|
||||||
|
content = _content_to_text(value.get("content")).strip()
|
||||||
|
return [ConversationMessage(role=role, content=content, metadata={"source": "langfuse"})] if content else []
|
||||||
|
|
||||||
|
text = _content_to_text(value).strip()
|
||||||
|
return [ConversationMessage(role=default_role, content=text, metadata={"source": "langfuse"})] if text else []
|
||||||
|
|
||||||
|
if isinstance(value, list):
|
||||||
|
out: list[ConversationMessage] = []
|
||||||
|
for item in value:
|
||||||
|
out.extend(_messages_from_value(item, default_role))
|
||||||
|
return out
|
||||||
|
|
||||||
|
text = _content_to_text(value).strip()
|
||||||
|
return [ConversationMessage(role=default_role, content=text, metadata={"source": "langfuse"})] if text else []
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_id(trace: dict[str, Any], detail: dict[str, Any] | None = None) -> str | None:
|
||||||
|
detail = detail or {}
|
||||||
|
meta = {**_metadata(trace), **_metadata(detail)}
|
||||||
|
return (
|
||||||
|
meta.get("agent_id")
|
||||||
|
or meta.get("agentId")
|
||||||
|
or meta.get("agent")
|
||||||
|
or detail.get("name")
|
||||||
|
or trace.get("name")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _channel(trace: dict[str, Any], detail: dict[str, Any] | None = None) -> str | None:
|
||||||
|
detail = detail or {}
|
||||||
|
meta = {**_metadata(trace), **_metadata(detail)}
|
||||||
|
return meta.get("channel") or meta.get("channel_id") or meta.get("channelId")
|
||||||
|
|
||||||
|
|
||||||
|
def _observation_sort_key(obs: dict[str, Any]) -> str:
|
||||||
|
return str(
|
||||||
|
obs.get("startTime")
|
||||||
|
or obs.get("start_time")
|
||||||
|
or obs.get("createdAt")
|
||||||
|
or obs.get("created_at")
|
||||||
|
or obs.get("timestamp")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LangfuseCollector(ConversationCollector):
|
||||||
|
"""Collect traces from Langfuse and hydrate each trace with detail/observations.
|
||||||
|
|
||||||
|
The list endpoint often returns only trace metadata. If we judge that directly,
|
||||||
|
prompts reach the LLM as empty conversations and the judge correctly returns
|
||||||
|
"Conversa vazia"/"Resposta vazia". This collector therefore fetches each trace
|
||||||
|
detail and observations before building ConversationRecord.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.identity_resolver = IdentityResolver(settings.identity_config_path)
|
||||||
|
|
||||||
|
async def collect(
|
||||||
|
self,
|
||||||
|
period_start: datetime,
|
||||||
|
period_end: datetime,
|
||||||
|
agent_aliases: set[str] | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> list[ConversationRecord]:
|
||||||
|
if not settings.can_use_langfuse:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Langfuse disabled or credentials missing. Set ENABLE_LANGFUSE=true and LANGFUSE_PUBLIC_KEY/SECRET_KEY."
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"fromTimestamp": _iso_z(period_start),
|
||||||
|
"toTimestamp": _iso_z(period_end),
|
||||||
|
"limit": limit or 100,
|
||||||
|
}
|
||||||
|
auth = (settings.langfuse_public_key, settings.langfuse_secret_key)
|
||||||
|
aliases = {a for a in (agent_aliases or set()) if a}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(base_url=settings.langfuse_host, timeout=60) as client:
|
||||||
|
response = await client.get("/api/public/traces", params=params, auth=auth)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise RuntimeError(f"Langfuse traces API failed {response.status_code}: {response.text}")
|
||||||
|
payload = response.json()
|
||||||
|
traces = payload.get("data") or payload.get("traces") or []
|
||||||
|
|
||||||
|
records: list[ConversationRecord] = []
|
||||||
|
for trace in traces:
|
||||||
|
if not isinstance(trace, dict):
|
||||||
|
continue
|
||||||
|
trace_id = trace.get("id")
|
||||||
|
detail = await self._fetch_trace_detail(client, trace_id, auth) if trace_id else {}
|
||||||
|
observations = await self._fetch_observations(client, trace_id, auth) if trace_id else []
|
||||||
|
|
||||||
|
agent_id = _agent_id(trace, detail)
|
||||||
|
if aliases and agent_id and agent_id not in aliases:
|
||||||
|
continue
|
||||||
|
|
||||||
|
record = self._to_record(trace, detail, observations, agent_id)
|
||||||
|
# Do not send empty traces to the LLM judge. Empty records produce
|
||||||
|
# valid but misleading scores such as "Conversa vazia".
|
||||||
|
if not (record.input_text or record.output_text or record.messages):
|
||||||
|
continue
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
return records
|
||||||
|
|
||||||
|
async def _fetch_trace_detail(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
trace_id: str,
|
||||||
|
auth: tuple[str | None, str | None],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
# Langfuse versions differ slightly. This endpoint works in current public API;
|
||||||
|
# if unavailable, the collector falls back to the list payload.
|
||||||
|
response = await client.get(f"/api/public/traces/{trace_id}", auth=auth)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
return {}
|
||||||
|
payload = response.json()
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
return payload.get("data") if isinstance(payload.get("data"), dict) else payload
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def _fetch_observations(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
trace_id: str,
|
||||||
|
auth: tuple[str | None, str | None],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
# Try common Langfuse public API shapes. Ignore failures because trace detail
|
||||||
|
# may already contain observations in some versions.
|
||||||
|
candidates = [
|
||||||
|
("/api/public/observations", {"traceId": trace_id, "limit": 100}),
|
||||||
|
("/api/public/observations", {"trace_id": trace_id, "limit": 100}),
|
||||||
|
]
|
||||||
|
for path, params in candidates:
|
||||||
|
response = await client.get(path, params=params, auth=auth)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
continue
|
||||||
|
payload = response.json()
|
||||||
|
items = payload.get("data") or payload.get("observations") or [] if isinstance(payload, dict) else []
|
||||||
|
if isinstance(items, list):
|
||||||
|
return [x for x in items if isinstance(x, dict)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _to_record(
|
||||||
|
self,
|
||||||
|
trace: dict[str, Any],
|
||||||
|
detail: dict[str, Any],
|
||||||
|
observations: list[dict[str, Any]],
|
||||||
|
agent_id: str | None,
|
||||||
|
) -> ConversationRecord:
|
||||||
|
meta = {**_metadata(trace), **_metadata(detail)}
|
||||||
|
trace_id = trace.get("id") or detail.get("id")
|
||||||
|
session_id = (
|
||||||
|
detail.get("sessionId")
|
||||||
|
or detail.get("session_id")
|
||||||
|
or trace.get("sessionId")
|
||||||
|
or trace.get("session_id")
|
||||||
|
or trace_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Detail payload may already include observations.
|
||||||
|
detail_observations = detail.get("observations") or []
|
||||||
|
if isinstance(detail_observations, list):
|
||||||
|
observations = [*observations, *[x for x in detail_observations if isinstance(x, dict)]]
|
||||||
|
observations = sorted(observations, key=_observation_sort_key)
|
||||||
|
|
||||||
|
input_value = _first_value(
|
||||||
|
detail.get("input"),
|
||||||
|
trace.get("input"),
|
||||||
|
meta.get("input"),
|
||||||
|
meta.get("user_message"),
|
||||||
|
meta.get("message"),
|
||||||
|
meta.get("question"),
|
||||||
|
)
|
||||||
|
output_value = _first_value(
|
||||||
|
detail.get("output"),
|
||||||
|
trace.get("output"),
|
||||||
|
meta.get("output"),
|
||||||
|
meta.get("response"),
|
||||||
|
meta.get("answer"),
|
||||||
|
)
|
||||||
|
|
||||||
|
messages: list[ConversationMessage] = []
|
||||||
|
messages.extend(_messages_from_value(input_value, "user"))
|
||||||
|
messages.extend(_messages_from_value(output_value, "assistant"))
|
||||||
|
|
||||||
|
for obs in observations:
|
||||||
|
obs_meta = _metadata(obs)
|
||||||
|
obs_kind = str(obs.get("type") or obs.get("name") or "observation").lower()
|
||||||
|
source_meta = {"source": "langfuse_observation", "observation_id": obs.get("id"), "observation_type": obs_kind}
|
||||||
|
if obs_meta:
|
||||||
|
source_meta["metadata"] = obs_meta
|
||||||
|
|
||||||
|
# Prefer preserving explicit chat roles from observation input.
|
||||||
|
before = len(messages)
|
||||||
|
messages.extend(_messages_from_value(obs.get("input"), "user"))
|
||||||
|
for m in messages[before:]:
|
||||||
|
m.metadata.update(source_meta)
|
||||||
|
|
||||||
|
before = len(messages)
|
||||||
|
default_output_role = "assistant" if obs_kind in {"generation", "span", "event", "observation"} else "assistant"
|
||||||
|
messages.extend(_messages_from_value(obs.get("output"), default_output_role))
|
||||||
|
for m in messages[before:]:
|
||||||
|
m.metadata.update(source_meta)
|
||||||
|
|
||||||
|
messages = self._deduplicate_messages(messages)
|
||||||
|
|
||||||
|
input_text = _content_to_text(input_value).strip()
|
||||||
|
output_text = _content_to_text(output_value).strip()
|
||||||
|
|
||||||
|
if not input_text:
|
||||||
|
first_user = next((m.content for m in messages if m.role.lower() in {"user", "human"} and m.content), "")
|
||||||
|
input_text = first_user.strip()
|
||||||
|
if not output_text:
|
||||||
|
last_assistant = next(
|
||||||
|
(m.content for m in reversed(messages) if m.role.lower() in {"assistant", "agent", "ai"} and m.content),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
output_text = last_assistant.strip()
|
||||||
|
|
||||||
|
raw = {"trace": trace, "detail": detail, "observations": observations}
|
||||||
|
|
||||||
|
identity_payload = {
|
||||||
|
**(trace.get("metadata") or {}),
|
||||||
|
**(trace.get("input") or {}),
|
||||||
|
"business_context": (trace.get("input") or {}).get("business_context") or {},
|
||||||
|
"session_id": trace.get("sessionId") or trace.get("id"),
|
||||||
|
"message_id": (trace.get("input") or {}).get("message_id"),
|
||||||
|
"conversation_key": (trace.get("input") or {}).get("conversation_key"),
|
||||||
|
}
|
||||||
|
|
||||||
|
business_context = self.identity_resolver.resolve(identity_payload)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
**(trace.get("metadata") or {}),
|
||||||
|
"business_context": business_context,
|
||||||
|
"ura_call_id": business_context.get("interaction_key"),
|
||||||
|
}
|
||||||
|
channel = (
|
||||||
|
trace.get("metadata", {}).get("channel")
|
||||||
|
or trace.get("input", {}).get("channel")
|
||||||
|
or trace.get("input", {}).get("metadata", {}).get("channel")
|
||||||
|
or "web"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ConversationRecord(
|
||||||
|
trace_id=trace_id,
|
||||||
|
session_id=business_context.get("session_key") or trace_id,
|
||||||
|
message_id=business_context.get("interaction_key") or trace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
channel=channel,
|
||||||
|
input_text=input_text,
|
||||||
|
output_text=output_text,
|
||||||
|
messages=messages,
|
||||||
|
metadata=metadata,
|
||||||
|
raw=raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _deduplicate_messages(self, messages: list[ConversationMessage]) -> list[ConversationMessage]:
|
||||||
|
out: list[ConversationMessage] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for msg in messages:
|
||||||
|
content = (msg.content or "").strip()
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
key = (msg.role.lower(), content)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
msg.content = content
|
||||||
|
out.append(msg)
|
||||||
|
return out
|
||||||
9
evaluator/collectors/mock.py
Normal file
9
evaluator/collectors/mock.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from evaluator.collectors.base import ConversationCollector
|
||||||
|
from evaluator.core.models import ConversationRecord, ConversationMessage
|
||||||
|
|
||||||
|
class MockCollector(ConversationCollector):
|
||||||
|
async def collect(self, period_start: datetime, period_end: datetime, agent_aliases: set[str] | None=None, limit: int | None=None):
|
||||||
|
agent = next(iter(agent_aliases), 'telecom_contas') if agent_aliases else 'telecom_contas'
|
||||||
|
return [ConversationRecord(trace_id='mock-trace-1', session_id='mock-session-1', message_id='mock-message-1', agent_id=agent, channel='web', input_text='quero minha fatura', output_text='Sua fatura está em aberto no valor de R$ 120.', messages=[ConversationMessage(role='user', content='quero minha fatura'), ConversationMessage(role='assistant', content='Sua fatura está em aberto no valor de R$ 120.')], metadata={'mock': True})]
|
||||||
BIN
evaluator/config/.DS_Store
vendored
Normal file
BIN
evaluator/config/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
evaluator/config/__pycache__/agents.cpython-313.pyc
Normal file
BIN
evaluator/config/__pycache__/agents.cpython-313.pyc
Normal file
Binary file not shown.
BIN
evaluator/config/__pycache__/settings.cpython-313.pyc
Normal file
BIN
evaluator/config/__pycache__/settings.cpython-313.pyc
Normal file
Binary file not shown.
36
evaluator/config/agents.py
Normal file
36
evaluator/config/agents.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
from evaluator.config.settings import settings
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentConfig:
|
||||||
|
agent_id: str
|
||||||
|
enabled: bool = True
|
||||||
|
days_back: int = 1
|
||||||
|
percentage: float = 1.0
|
||||||
|
langfuse_agent_aliases: list[str] = field(default_factory=list)
|
||||||
|
gcs_prefix: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def aliases(self) -> set[str]:
|
||||||
|
return {self.agent_id, *self.langfuse_agent_aliases}
|
||||||
|
|
||||||
|
|
||||||
|
def load_agents(path: str | None = None) -> list[AgentConfig]:
|
||||||
|
p = settings.path(path or settings.agents_config_path)
|
||||||
|
data = yaml.safe_load(p.read_text()) or {}
|
||||||
|
agents = []
|
||||||
|
for item in data.get("agents", []):
|
||||||
|
cfg = AgentConfig(
|
||||||
|
agent_id=item["agent_id"],
|
||||||
|
enabled=bool(item.get("enabled", True)),
|
||||||
|
days_back=int(item.get("days_back", item.get("daysBack", 1))),
|
||||||
|
percentage=float(item.get("percentage", 1.0)),
|
||||||
|
langfuse_agent_aliases=list(item.get("langfuse_agent_aliases", [])),
|
||||||
|
gcs_prefix=str(item.get("gcs_prefix", "")),
|
||||||
|
)
|
||||||
|
if cfg.enabled:
|
||||||
|
agents.append(cfg)
|
||||||
|
return agents
|
||||||
140
evaluator/config/settings.py
Normal file
140
evaluator/config/settings.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
ENV_PATH = ROOT_DIR / ".env"
|
||||||
|
load_dotenv(ENV_PATH, override=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=str(ENV_PATH), extra="ignore", case_sensitive=False)
|
||||||
|
|
||||||
|
adb_user: str = Field(default="", validation_alias="ADB_USER")
|
||||||
|
adb_password: str = Field(default="", validation_alias="ADB_PASSWORD")
|
||||||
|
adb_dsn: str = Field(default="", validation_alias="ADB_DSN")
|
||||||
|
adb_wallet_location: str | None = Field(default=None, validation_alias="ADB_WALLET_LOCATION")
|
||||||
|
adb_wallet_password: str | None = Field(default=None, validation_alias="ADB_WALLET_PASSWORD")
|
||||||
|
adb_table_prefix: str = Field(default="AGENTFW", validation_alias="ADB_TABLE_PREFIX")
|
||||||
|
|
||||||
|
enable_langfuse: bool = Field(default=False, validation_alias="ENABLE_LANGFUSE")
|
||||||
|
langfuse_public_key: str | None = Field(default=None, validation_alias="LANGFUSE_PUBLIC_KEY")
|
||||||
|
langfuse_secret_key: str | None = Field(default=None, validation_alias="LANGFUSE_SECRET_KEY")
|
||||||
|
langfuse_host: str = Field(default="http://localhost:3005", validation_alias="LANGFUSE_HOST")
|
||||||
|
publish_langfuse_scores: bool = Field(default=False, validation_alias="PUBLISH_LANGFUSE_SCORES")
|
||||||
|
|
||||||
|
# llm_provider: str = Field(default="mock", validation_alias="LLM_PROVIDER")
|
||||||
|
# llm_profile: str = Field(default="judge", validation_alias="LLM_PROFILE")
|
||||||
|
# llm_profiles_path: str = Field(default="configs/llm_profiles/llm_profiles.yaml", validation_alias="LLM_PROFILES_PATH")
|
||||||
|
# oci_genai_endpoint: str | None = Field(default=None, validation_alias="OCI_GENAI_ENDPOINT")
|
||||||
|
# oci_genai_model_id: str | None = Field(default=None, validation_alias="OCI_GENAI_MODEL_ID")
|
||||||
|
# oci_genai_compartment_id: str | None = Field(default=None, validation_alias="OCI_GENAI_COMPARTMENT_ID")
|
||||||
|
# oci_genai_auth_type: str = Field(default="api_key", validation_alias="OCI_GENAI_AUTH_TYPE")
|
||||||
|
# oci_config_path: str | None = Field(default=None, validation_alias="OCI_CONFIG_PATH")
|
||||||
|
# oci_config_profile: str = Field(default="DEFAULT", validation_alias="OCI_CONFIG_PROFILE")
|
||||||
|
# llm_temperature: float = Field(default=0.0, validation_alias="LLM_TEMPERATURE")
|
||||||
|
# llm_max_tokens: int = Field(default=900, validation_alias="LLM_MAX_TOKENS")
|
||||||
|
|
||||||
|
# LLM / OCI GenAI OpenAI-compatible, mesmo padrão do Agent Framework
|
||||||
|
llm_provider: str = Field(default="mock", validation_alias="LLM_PROVIDER")
|
||||||
|
llm_profile: str = Field(default="judge", validation_alias="LLM_PROFILE")
|
||||||
|
llm_profiles_path: str = Field(default="configs/llm_profiles/llm_profiles.yaml", validation_alias="LLM_PROFILES_PATH")
|
||||||
|
|
||||||
|
oci_genai_base_url: str | None = Field(default=None, validation_alias="OCI_GENAI_BASE_URL")
|
||||||
|
oci_genai_endpoint: str | None = Field(default=None, validation_alias="OCI_GENAI_ENDPOINT") # compatibilidade
|
||||||
|
oci_genai_model: str = Field(default="openai.gpt-4.1", validation_alias="OCI_GENAI_MODEL")
|
||||||
|
oci_genai_model_id: str | None = Field(default=None, validation_alias="OCI_GENAI_MODEL_ID") # compatibilidade
|
||||||
|
oci_genai_api_key: str | None = Field(default=None, validation_alias="OCI_GENAI_API_KEY")
|
||||||
|
oci_genai_project_ocid: str | None = Field(default=None, validation_alias="OCI_GENAI_PROJECT_OCID")
|
||||||
|
|
||||||
|
llm_temperature: float = Field(default=0.0, validation_alias="LLM_TEMPERATURE")
|
||||||
|
llm_max_tokens: int = Field(default=900, validation_alias="LLM_MAX_TOKENS")
|
||||||
|
llm_timeout_seconds: int = Field(default=120, validation_alias="LLM_TIMEOUT_SECONDS")
|
||||||
|
|
||||||
|
agents_config_path: str = Field(default="configs/judge/agents.yaml", validation_alias="AGENTS_CONFIG_PATH")
|
||||||
|
trace_prompt_path: str = Field(default="configs/judge/trace_metrics.yaml", validation_alias="TRACE_PROMPT_PATH")
|
||||||
|
session_prompt_path: str = Field(default="configs/judge/session_metrics.yaml", validation_alias="SESSION_PROMPT_PATH")
|
||||||
|
output_dir: str = Field(default="output", validation_alias="OUTPUT_DIR")
|
||||||
|
batch_size: int = Field(default=50, validation_alias="BATCH_SIZE")
|
||||||
|
max_attempts: int = Field(default=3, validation_alias="MAX_ATTEMPTS")
|
||||||
|
enable_gcs_upload: bool = Field(default=False, validation_alias="ENABLE_GCS_UPLOAD")
|
||||||
|
judge_gcs_bucket: str | None = Field(default=None, validation_alias="JUDGE_GCS_BUCKET")
|
||||||
|
google_application_credentials: str | None = Field(default=None, validation_alias="GOOGLE_APPLICATION_CREDENTIALS")
|
||||||
|
identity_config_path: str = "configs/identity.yaml"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def project_root(self) -> Path:
|
||||||
|
return ROOT_DIR
|
||||||
|
|
||||||
|
def path(self, value: str | Path) -> Path:
|
||||||
|
p = Path(value)
|
||||||
|
return p if p.is_absolute() else ROOT_DIR / p
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ADB_USER(self): return self.adb_user
|
||||||
|
@property
|
||||||
|
def ADB_PASSWORD(self): return self.adb_password
|
||||||
|
@property
|
||||||
|
def ADB_DSN(self): return self.adb_dsn
|
||||||
|
@property
|
||||||
|
def ADB_WALLET_LOCATION(self): return self.adb_wallet_location
|
||||||
|
@property
|
||||||
|
def ADB_WALLET_PASSWORD(self): return self.adb_wallet_password
|
||||||
|
@property
|
||||||
|
def ADB_TABLE_PREFIX(self): return (self.adb_table_prefix or "AGENTFW").upper().rstrip("_")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_langfuse_credentials(self) -> bool:
|
||||||
|
return bool(self.langfuse_public_key and self.langfuse_secret_key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_use_langfuse(self) -> bool:
|
||||||
|
return bool(self.enable_langfuse and self.has_langfuse_credentials)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_publish_langfuse_scores(self) -> bool:
|
||||||
|
return bool(self.publish_langfuse_scores and self.can_use_langfuse)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def OCI_GENAI_BASE_URL(self) -> str | None:
|
||||||
|
return self.oci_genai_base_url or self.oci_genai_endpoint
|
||||||
|
|
||||||
|
@property
|
||||||
|
def OCI_GENAI_MODEL(self) -> str:
|
||||||
|
return self.oci_genai_model_id or self.oci_genai_model
|
||||||
|
|
||||||
|
@property
|
||||||
|
def OCI_GENAI_API_KEY(self) -> str | None:
|
||||||
|
return self.oci_genai_api_key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def OCI_GENAI_PROJECT_OCID(self) -> str | None:
|
||||||
|
return self.oci_genai_project_ocid
|
||||||
|
|
||||||
|
@property
|
||||||
|
def LLM_PROVIDER(self) -> str:
|
||||||
|
return self.llm_provider
|
||||||
|
|
||||||
|
@property
|
||||||
|
def LLM_TEMPERATURE(self) -> float:
|
||||||
|
return self.llm_temperature
|
||||||
|
|
||||||
|
@property
|
||||||
|
def LLM_MAX_TOKENS(self) -> int:
|
||||||
|
return self.llm_max_tokens
|
||||||
|
|
||||||
|
@property
|
||||||
|
def LLM_TIMEOUT_SECONDS(self) -> int:
|
||||||
|
return self.llm_timeout_seconds
|
||||||
|
|
||||||
|
@property
|
||||||
|
def LLM_PROFILES_PATH(self) -> str:
|
||||||
|
return self.llm_profiles_path
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
if settings.ADB_WALLET_LOCATION:
|
||||||
|
os.environ["TNS_ADMIN"] = settings.ADB_WALLET_LOCATION
|
||||||
BIN
evaluator/core/__pycache__/models.cpython-313.pyc
Normal file
BIN
evaluator/core/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
56
evaluator/core/models.py
Normal file
56
evaluator/core/models.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
class RunStatus(str, Enum):
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
PARTIAL = "PARTIAL"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
|
||||||
|
class ItemStatus(str, Enum):
|
||||||
|
PENDING = "PENDING"
|
||||||
|
PROCESSING = "PROCESSING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
SKIPPED = "SKIPPED"
|
||||||
|
|
||||||
|
class ConversationMessage(BaseModel):
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
created_at: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
class ConversationRecord(BaseModel):
|
||||||
|
trace_id: str | None = None
|
||||||
|
session_id: str
|
||||||
|
message_id: str | None = None
|
||||||
|
agent_id: str | None = None
|
||||||
|
channel: str | None = None
|
||||||
|
input_text: str = ""
|
||||||
|
output_text: str = ""
|
||||||
|
messages: list[ConversationMessage] = Field(default_factory=list)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
raw: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
class TraceJudgeResult(BaseModel):
|
||||||
|
judgeScore: float
|
||||||
|
accuracyScore: float
|
||||||
|
alucinationScore: float
|
||||||
|
rationale: str = ""
|
||||||
|
judge_name: str = "trace_metrics"
|
||||||
|
judge_type: str = "trace"
|
||||||
|
|
||||||
|
class SessionJudgeResult(BaseModel):
|
||||||
|
inferredCsiScore: float
|
||||||
|
resolution: int
|
||||||
|
conversationPrecision: int
|
||||||
|
rationale: str = ""
|
||||||
|
judge_name: str = "session_metrics"
|
||||||
|
judge_type: str = "session"
|
||||||
|
|
||||||
|
class CombinedJudgeResult(BaseModel):
|
||||||
|
trace: TraceJudgeResult
|
||||||
|
session: SessionJudgeResult | None = None
|
||||||
144
evaluator/engine.py
Normal file
144
evaluator/engine.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import inspect, json, random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
from evaluator.collectors.base import ConversationCollector
|
||||||
|
from evaluator.collectors.langfuse import LangfuseCollector
|
||||||
|
from evaluator.collectors.agent_framework import AgentFrameworkCollector
|
||||||
|
from evaluator.collectors.mock import MockCollector
|
||||||
|
from evaluator.config.agents import AgentConfig
|
||||||
|
from evaluator.config.settings import settings
|
||||||
|
from evaluator.core.models import ConversationRecord, RunStatus
|
||||||
|
from evaluator.judges.llm_judge import TIMStyleLLMJudge
|
||||||
|
from evaluator.output.legacy_exporter import export_legacy_txt_gz
|
||||||
|
from evaluator.persistence.repository import EvaluationRepository
|
||||||
|
from evaluator.publishers.langfuse_scores import LangfuseScorePublisher
|
||||||
|
|
||||||
|
ProgressCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
|
||||||
|
|
||||||
|
class EvaluationEngine:
|
||||||
|
def __init__(self, repository: EvaluationRepository | None=None, progress_callback: ProgressCallback | None=None):
|
||||||
|
self.repository = repository or EvaluationRepository(auto_init_schema=False)
|
||||||
|
self.progress_callback = progress_callback
|
||||||
|
self.judge = TIMStyleLLMJudge()
|
||||||
|
self.langfuse_publisher = LangfuseScorePublisher()
|
||||||
|
|
||||||
|
# async def _emit(self, run_id: str, stage: str, message: str='', **details):
|
||||||
|
# details.pop('run_id', None)
|
||||||
|
# await self.repository.arecord_progress(run_id, stage, message, details)
|
||||||
|
# event={'run_id': run_id, 'stage': stage, 'message': message, 'details': details}
|
||||||
|
async def _emit(self, progress_run_id: str, stage: str, message: str = "", **details):
|
||||||
|
details.pop("run_id", None)
|
||||||
|
|
||||||
|
await self.repository.arecord_progress(
|
||||||
|
progress_run_id,
|
||||||
|
stage,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
)
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"run_id": progress_run_id,
|
||||||
|
"stage": stage,
|
||||||
|
"message": message,
|
||||||
|
"details": details,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.progress_callback:
|
||||||
|
r = self.progress_callback(event)
|
||||||
|
if inspect.isawaitable(r): await r
|
||||||
|
|
||||||
|
def collector_for(self, source: str) -> ConversationCollector:
|
||||||
|
if source == 'langfuse': return LangfuseCollector()
|
||||||
|
if source == 'agent_framework': return AgentFrameworkCollector()
|
||||||
|
if source == 'mock': return MockCollector()
|
||||||
|
raise ValueError('source must be langfuse, agent_framework or mock')
|
||||||
|
|
||||||
|
async def run_agent(self, agent: AgentConfig, period_start: datetime, period_end: datetime, source: str='langfuse', limit: int | None=None) -> dict:
|
||||||
|
run_id = await self.repository.acreate_run(period_start, period_end, source, agent.agent_id)
|
||||||
|
try:
|
||||||
|
await self._emit(run_id, 'RUN_CREATED', f'Agent run created: {agent.agent_id}', agent_id=agent.agent_id, source=source)
|
||||||
|
collector = self.collector_for(source)
|
||||||
|
await self._emit(run_id, 'COLLECTING', 'Collecting conversations')
|
||||||
|
records = await collector.collect(period_start, period_end, agent_aliases=agent.aliases, limit=limit)
|
||||||
|
await self._emit(run_id, 'COLLECTED', f'Collected {len(records)} records before sampling')
|
||||||
|
records = self._sample(records, agent.percentage)
|
||||||
|
await self._emit(run_id, 'SAMPLED', f'Kept {len(records)} records', percentage=agent.percentage)
|
||||||
|
inserted = await self.repository.ainsert_items(run_id, records)
|
||||||
|
await self._emit(run_id, 'ITEMS_INSERTED', f'Inserted {inserted} items')
|
||||||
|
summary = await self._process(run_id)
|
||||||
|
output_path = export_legacy_txt_gz(self.repository, run_id, agent.agent_id)
|
||||||
|
await self._emit(run_id, 'EXPORTED', f'Exported {output_path}', output_file=str(output_path))
|
||||||
|
return {**summary, 'agent_id': agent.agent_id, 'output_file': str(output_path), 'uploaded_to': None}
|
||||||
|
except Exception as exc:
|
||||||
|
await self.repository.amark_run_status(run_id, RunStatus.PARTIAL, str(exc))
|
||||||
|
await self._emit(run_id, 'PARTIAL', f'Run failed: {exc}', error=str(exc))
|
||||||
|
return {'status':'PARTIAL','run_id':run_id,'agent_id':agent.agent_id,'error':str(exc)}
|
||||||
|
|
||||||
|
async def run(self, period_start: datetime, period_end: datetime, source: str='langfuse', limit: int | None=None) -> dict:
|
||||||
|
run_id = await self.repository.acreate_run(period_start, period_end, source, None)
|
||||||
|
try:
|
||||||
|
collector = self.collector_for(source)
|
||||||
|
await self._emit(run_id, 'COLLECTING', 'Collecting conversations')
|
||||||
|
records = await collector.collect(period_start, period_end, limit=limit)
|
||||||
|
await self._emit(run_id, 'COLLECTED', f'Collected {len(records)} records')
|
||||||
|
await self.repository.ainsert_items(run_id, records)
|
||||||
|
return await self._process(run_id)
|
||||||
|
except Exception as exc:
|
||||||
|
await self.repository.amark_run_status(run_id, RunStatus.PARTIAL, str(exc))
|
||||||
|
await self._emit(run_id, 'PARTIAL', f'Run failed: {exc}', error=str(exc))
|
||||||
|
return {'status':'PARTIAL','run_id':run_id,'error':str(exc)}
|
||||||
|
|
||||||
|
async def _process(self, run_id: str) -> dict:
|
||||||
|
processed_records: list[ConversationRecord] = []
|
||||||
|
while True:
|
||||||
|
items = await self.repository.afetch_next_items(run_id, settings.batch_size)
|
||||||
|
if not items: break
|
||||||
|
await self._emit(run_id, 'BATCH_STARTED', f'Processing {len(items)} items')
|
||||||
|
for item in items:
|
||||||
|
item_id=item['item_id']
|
||||||
|
await self.repository.amark_item_processing(item_id)
|
||||||
|
try:
|
||||||
|
raw=item['raw_json']
|
||||||
|
if hasattr(raw, 'read'): raw = raw.read()
|
||||||
|
record = ConversationRecord.model_validate(json.loads(raw))
|
||||||
|
result = await self.judge.judge_trace(record)
|
||||||
|
await self.repository.asave_trace_result(run_id, item_id, record, result)
|
||||||
|
await self.langfuse_publisher.publish_trace_score(record, result)
|
||||||
|
await self.repository.amark_item_completed(run_id, item_id)
|
||||||
|
processed_records.append(record)
|
||||||
|
|
||||||
|
#await self._emit(run_id, 'ITEM_COMPLETED', f'Item completed {item_id}', trace_id=record.trace_id)
|
||||||
|
loop_result = getattr(result, "loop_result", None)
|
||||||
|
|
||||||
|
await self._emit(
|
||||||
|
run_id,
|
||||||
|
"ITEM_COMPLETED",
|
||||||
|
f"Item completed {item_id}",
|
||||||
|
trace_id=record.trace_id,
|
||||||
|
session_id=record.session_id,
|
||||||
|
judgeScore=result.judgeScore,
|
||||||
|
accuracyScore=result.accuracyScore,
|
||||||
|
alucinationScore=result.alucinationScore,
|
||||||
|
rationale=result.rationale,
|
||||||
|
loop=getattr(loop_result, "loop", 0) if loop_result else 0,
|
||||||
|
loop_reason=getattr(loop_result, "reason", "") if loop_result else "",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
await self.repository.amark_item_failed(run_id, item_id, str(exc))
|
||||||
|
await self._emit(run_id, 'ITEM_FAILED', f'Item failed {item_id}', error=str(exc))
|
||||||
|
if processed_records:
|
||||||
|
sessions = await self.judge.judge_sessions(processed_records)
|
||||||
|
for sid, result in sessions.items():
|
||||||
|
agent_id = next((r.agent_id for r in processed_records if r.session_id == sid), None)
|
||||||
|
await self.repository.asave_session_result(run_id, sid, agent_id, result)
|
||||||
|
await self._emit(run_id, 'SESSION_JUDGE_COMPLETED', f'Evaluated {len(sessions)} sessions')
|
||||||
|
await self.repository.amark_run_status(run_id, RunStatus.COMPLETED)
|
||||||
|
summary = await self.repository.asummarize_run(run_id)
|
||||||
|
await self._emit(run_id, 'COMPLETED', 'Run completed', **summary)
|
||||||
|
return {'status':'COMPLETED', **summary}
|
||||||
|
|
||||||
|
def _sample(self, records: list[ConversationRecord], percentage: float) -> list[ConversationRecord]:
|
||||||
|
if percentage >= 1: return records
|
||||||
|
rng = random.Random(42)
|
||||||
|
return [r for r in records if rng.random() <= percentage]
|
||||||
BIN
evaluator/identity/__pycache__/resolver.cpython-313.pyc
Normal file
BIN
evaluator/identity/__pycache__/resolver.cpython-313.pyc
Normal file
Binary file not shown.
41
evaluator/identity/resolver.py
Normal file
41
evaluator/identity/resolver.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_get(data: dict, path: str):
|
||||||
|
cur = data
|
||||||
|
for part in path.split("."):
|
||||||
|
if not isinstance(cur, dict):
|
||||||
|
return None
|
||||||
|
cur = cur.get(part)
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityResolver:
|
||||||
|
def __init__(self, path: str = "configs/identity.yaml"):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.config = yaml.safe_load(self.path.read_text(encoding="utf-8")) or {}
|
||||||
|
self.identity = self.config.get("identity", {})
|
||||||
|
self.keys = self.identity.get("keys", {})
|
||||||
|
|
||||||
|
def resolve(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
out = {}
|
||||||
|
|
||||||
|
for key, spec in self.keys.items():
|
||||||
|
value = None
|
||||||
|
for source in spec.get("sources", []):
|
||||||
|
value = _deep_get(payload, source)
|
||||||
|
if value not in (None, ""):
|
||||||
|
break
|
||||||
|
out[key] = str(value) if value not in (None, "") else None
|
||||||
|
|
||||||
|
out["metadata"] = {
|
||||||
|
"identity_version": self.identity.get("version"),
|
||||||
|
"identity_source": str(self.path),
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
BIN
evaluator/judges/__pycache__/llm_judge.cpython-313.pyc
Normal file
BIN
evaluator/judges/__pycache__/llm_judge.cpython-313.pyc
Normal file
Binary file not shown.
81
evaluator/judges/llm_judge.py
Normal file
81
evaluator/judges/llm_judge.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
from evaluator.config.settings import settings
|
||||||
|
from evaluator.core.models import ConversationRecord, TraceJudgeResult, SessionJudgeResult
|
||||||
|
from evaluator.llm.client import LLMClient, create_llm_client
|
||||||
|
from evaluator.prompts.loader import load_prompt
|
||||||
|
|
||||||
|
|
||||||
|
def _json_from_text(text: str) -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except Exception:
|
||||||
|
m = re.search(r"\{.*\}", text, flags=re.S)
|
||||||
|
if not m:
|
||||||
|
raise
|
||||||
|
return json.loads(m.group(0))
|
||||||
|
|
||||||
|
|
||||||
|
def _history(record: ConversationRecord, max_chars: int = 6000) -> str:
|
||||||
|
if record.messages:
|
||||||
|
text = "\n".join(f"{m.role}: {m.content}" for m in record.messages)
|
||||||
|
else:
|
||||||
|
text = f"user: {record.input_text}\nagent: {record.output_text}"
|
||||||
|
return text[-max_chars:]
|
||||||
|
|
||||||
|
|
||||||
|
class TIMStyleLLMJudge:
|
||||||
|
def __init__(self, llm: LLMClient | None = None):
|
||||||
|
self.llm = llm or create_llm_client()
|
||||||
|
self.trace_prompt = load_prompt(settings.trace_prompt_path, 'trace_metrics')
|
||||||
|
self.session_prompt = load_prompt(settings.session_prompt_path, 'session_metrics')
|
||||||
|
|
||||||
|
async def judge_trace(self, record: ConversationRecord) -> TraceJudgeResult:
|
||||||
|
prompt = f"""{self.trace_prompt}
|
||||||
|
|
||||||
|
HISTÓRICO:
|
||||||
|
{_history(record)}
|
||||||
|
|
||||||
|
MENSAGEM DO USUÁRIO:
|
||||||
|
{record.input_text}
|
||||||
|
|
||||||
|
RESPOSTA DO AGENTE:
|
||||||
|
{record.output_text}
|
||||||
|
|
||||||
|
METADATA:
|
||||||
|
{json.dumps(record.metadata, ensure_ascii=False, default=str)}
|
||||||
|
"""
|
||||||
|
raw = await self.llm.complete(prompt)
|
||||||
|
data = _json_from_text(raw)
|
||||||
|
data.setdefault("judge_name", "trace_metrics")
|
||||||
|
data.setdefault("judge_type", "trace")
|
||||||
|
data.setdefault("judgeScore", data.get("judge_score", 0))
|
||||||
|
data.setdefault("accuracyScore", data.get("accuracy_score", 0))
|
||||||
|
data.setdefault("alucinationScore", data.get("alucination_score", 1))
|
||||||
|
data.setdefault("rationale", data.get("reasoning", ""))
|
||||||
|
return TraceJudgeResult(**data)
|
||||||
|
|
||||||
|
async def judge_sessions(self, records: list[ConversationRecord]) -> dict[str, SessionJudgeResult]:
|
||||||
|
grouped: dict[str, list[ConversationRecord]] = defaultdict(list)
|
||||||
|
for r in records:
|
||||||
|
grouped[r.session_id].append(r)
|
||||||
|
out = {}
|
||||||
|
for session_id, items in grouped.items():
|
||||||
|
transcript = "\n".join(_history(r, 3000) for r in items)[-9000:]
|
||||||
|
prompt = f"""{self.session_prompt}
|
||||||
|
|
||||||
|
TRANSCRIÇÃO DA SESSÃO:
|
||||||
|
{transcript}
|
||||||
|
"""
|
||||||
|
raw = await self.llm.complete(prompt)
|
||||||
|
data = _json_from_text(raw)
|
||||||
|
data.setdefault("judge_name", "session_metrics")
|
||||||
|
data.setdefault("judge_type", "session")
|
||||||
|
data.setdefault("inferredCsiScore", data.get("inferred_csi_score", 0))
|
||||||
|
data.setdefault("resolution", data.get("resolution", 0))
|
||||||
|
data.setdefault("conversationPrecision", data.get("conversation_precision", 0))
|
||||||
|
data.setdefault("rationale", data.get("reasoning", ""))
|
||||||
|
out[session_id] = SessionJudgeResult(**data)
|
||||||
|
return out
|
||||||
BIN
evaluator/llm/__pycache__/client.cpython-313.pyc
Normal file
BIN
evaluator/llm/__pycache__/client.cpython-313.pyc
Normal file
Binary file not shown.
BIN
evaluator/llm/__pycache__/profile_resolver.cpython-313.pyc
Normal file
BIN
evaluator/llm/__pycache__/profile_resolver.cpython-313.pyc
Normal file
Binary file not shown.
98
evaluator/llm/client.py
Normal file
98
evaluator/llm/client.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from evaluator.config.settings import settings
|
||||||
|
from evaluator.llm.profile_resolver import LLMProfileResolver
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient:
|
||||||
|
async def complete(self, prompt: str, profile_name: str | None = None) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class MockLLMClient(LLMClient):
|
||||||
|
async def complete(self, prompt: str, profile_name: str | None = None) -> str:
|
||||||
|
if "inferredCsiScore" in prompt:
|
||||||
|
return json.dumps({
|
||||||
|
"inferredCsiScore": 0.5,
|
||||||
|
"resolution": 1,
|
||||||
|
"conversationPrecision": 1,
|
||||||
|
"rationale": "Avaliação mock."
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"judgeScore": 0.7,
|
||||||
|
"accuracyScore": 0.7,
|
||||||
|
"alucinationScore": 0.1,
|
||||||
|
"rationale": "Avaliação mock."
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
class OCICompatibleLLMClient(LLMClient):
|
||||||
|
"""
|
||||||
|
Mesmo padrão do Agent Framework:
|
||||||
|
- LLM_PROVIDER=oci_openai
|
||||||
|
- OCI_GENAI_BASE_URL
|
||||||
|
- OCI_GENAI_API_KEY
|
||||||
|
- OCI_GENAI_MODEL
|
||||||
|
- llm_profiles.yaml opcional
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.resolver = LLMProfileResolver(settings)
|
||||||
|
|
||||||
|
async def complete(self, prompt: str, profile_name: str | None = None) -> str:
|
||||||
|
effective = self.resolver.resolve(profile_name or settings.llm_profile)
|
||||||
|
|
||||||
|
provider = str(effective.get("provider") or settings.LLM_PROVIDER)
|
||||||
|
model = str(effective.get("model") or settings.OCI_GENAI_MODEL)
|
||||||
|
base_url = effective.get("base_url") or settings.OCI_GENAI_BASE_URL
|
||||||
|
api_key = effective.get("api_key") or settings.OCI_GENAI_API_KEY
|
||||||
|
temperature = effective.get("temperature", settings.LLM_TEMPERATURE)
|
||||||
|
max_tokens = effective.get("max_tokens", settings.LLM_MAX_TOKENS)
|
||||||
|
timeout = effective.get("timeout_seconds", settings.LLM_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
if provider == "mock":
|
||||||
|
return await MockLLMClient().complete(prompt, profile_name=profile_name)
|
||||||
|
|
||||||
|
if provider not in ("oci_openai", "openai_compatible"):
|
||||||
|
raise ValueError(f"Unsupported LLM provider: {provider}")
|
||||||
|
|
||||||
|
if not base_url:
|
||||||
|
raise RuntimeError("OCI_GENAI_BASE_URL is required for oci_openai provider")
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
raise RuntimeError("OCI_GENAI_API_KEY is required for oci_openai provider")
|
||||||
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
client = AsyncOpenAI(
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=api_key,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=[
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
return resp.choices[0].message.content or ""
|
||||||
|
|
||||||
|
|
||||||
|
def create_llm_client() -> LLMClient:
|
||||||
|
provider = (settings.LLM_PROVIDER or "mock").lower()
|
||||||
|
|
||||||
|
if provider in ("mock", "none"):
|
||||||
|
return MockLLMClient()
|
||||||
|
|
||||||
|
if provider in ("oci_openai", "openai_compatible", "oci"):
|
||||||
|
return OCICompatibleLLMClient()
|
||||||
|
|
||||||
|
raise ValueError(f"Unsupported LLM_PROVIDER={settings.LLM_PROVIDER}")
|
||||||
80
evaluator/llm/profile_resolver.py
Normal file
80
evaluator/llm/profile_resolver.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_profile_name(value: str | None) -> str:
|
||||||
|
name = (value or "default").strip()
|
||||||
|
name = name.replace("-", "_").replace(".", "_").replace(" ", "_")
|
||||||
|
name = re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
||||||
|
return re.sub(r"_+", "_", name).strip("_") or "default"
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProfileResolver:
|
||||||
|
def __init__(self, settings: Any):
|
||||||
|
self.settings = settings
|
||||||
|
self.path = self._find_profiles_file()
|
||||||
|
self.enabled = self.path is not None
|
||||||
|
self._profiles = self._load_profiles(self.path) if self.enabled else {}
|
||||||
|
|
||||||
|
def _find_profiles_file(self) -> Path | None:
|
||||||
|
root = getattr(self.settings, "project_root", Path.cwd())
|
||||||
|
configured = Path(getattr(self.settings, "LLM_PROFILES_PATH", "") or "").expanduser()
|
||||||
|
candidates = []
|
||||||
|
if configured:
|
||||||
|
candidates.append(configured if configured.is_absolute() else Path(root) / configured)
|
||||||
|
candidates += [
|
||||||
|
Path(root) / "llm_profiles.yaml",
|
||||||
|
Path(root) / "configs/llm_profiles/llm_profiles.yaml",
|
||||||
|
Path(root) / "config/llm_profiles.yaml",
|
||||||
|
Path("llm_profiles.yaml"),
|
||||||
|
Path("configs/llm_profiles/llm_profiles.yaml"),
|
||||||
|
]
|
||||||
|
for path in candidates:
|
||||||
|
if path and path.exists() and path.is_file():
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _load_profiles(self, path: Path) -> dict[str, dict[str, Any]]:
|
||||||
|
# Expand ${VAR} placeholders, matching the way env-driven framework config is commonly used.
|
||||||
|
text = os.path.expandvars(path.read_text(encoding="utf-8"))
|
||||||
|
data = yaml.safe_load(text) or {}
|
||||||
|
raw = data.get("profiles", data)
|
||||||
|
profiles = {}
|
||||||
|
for name, value in raw.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
profiles[_canonical_profile_name(str(name))] = dict(value)
|
||||||
|
return profiles
|
||||||
|
|
||||||
|
def env_defaults(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"provider": getattr(self.settings, "LLM_PROVIDER", "mock"),
|
||||||
|
"model": getattr(self.settings, "OCI_GENAI_MODEL", "mock-llm"),
|
||||||
|
"temperature": getattr(self.settings, "LLM_TEMPERATURE", 0.0),
|
||||||
|
"max_tokens": getattr(self.settings, "LLM_MAX_TOKENS", 900),
|
||||||
|
"timeout_seconds": getattr(self.settings, "LLM_TIMEOUT_SECONDS", 120),
|
||||||
|
"base_url": getattr(self.settings, "OCI_GENAI_BASE_URL", None),
|
||||||
|
"api_key": getattr(self.settings, "OCI_GENAI_API_KEY", None),
|
||||||
|
"project_ocid": getattr(self.settings, "OCI_GENAI_PROJECT_OCID", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
def resolve(self, profile_name: str | None = None, **overrides) -> dict[str, Any]:
|
||||||
|
profile_key = _canonical_profile_name(profile_name)
|
||||||
|
effective = self.env_defaults()
|
||||||
|
|
||||||
|
if self.enabled:
|
||||||
|
effective.update(copy.deepcopy(self._profiles.get("default") or {}))
|
||||||
|
effective.update(copy.deepcopy(self._profiles.get(profile_key) or {}))
|
||||||
|
|
||||||
|
for key, value in overrides.items():
|
||||||
|
if value is not None:
|
||||||
|
effective[key] = value
|
||||||
|
|
||||||
|
effective["profile_name"] = profile_key
|
||||||
|
return effective
|
||||||
BIN
evaluator/output/.DS_Store
vendored
Normal file
BIN
evaluator/output/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
evaluator/output/__pycache__/legacy_exporter.cpython-313.pyc
Normal file
BIN
evaluator/output/__pycache__/legacy_exporter.cpython-313.pyc
Normal file
Binary file not shown.
278
evaluator/output/legacy_exporter.py
Normal file
278
evaluator/output/legacy_exporter.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
import json
|
||||||
|
|
||||||
|
from evaluator.config.settings import settings
|
||||||
|
from evaluator.persistence.repository import EvaluationRepository
|
||||||
|
from evaluator.analytics.vloop import vloop_flag
|
||||||
|
|
||||||
|
HEADER = [
|
||||||
|
"judgeScore", "accuracyScore", "alucinationScore", "promptLength", "loop",
|
||||||
|
"inferredCsiScore", "resolution", "conversationPrecision",
|
||||||
|
"uraCallId", "channelId", "sessionId", "messageId"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _q(v) -> str:
|
||||||
|
return '"' + str("" if v is None else v).replace('"', '""') + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def export_legacy_txt_gz(repo: EvaluationRepository, run_id: str, agent_id: str) -> Path:
|
||||||
|
output_dir = settings.path(settings.output_dir)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = output_dir / f"AGENTE_{agent_id}_LLM_JUDGE_{datetime.now().strftime('%Y%m%d')}.TXT.GZ"
|
||||||
|
|
||||||
|
with repo.store.connect() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
select SESSION_ID, INFERRED_CSI_SCORE, RESOLUTION, CONVERSATION_PRECISION
|
||||||
|
from {repo.store.t('EVALUATION_RESULT')}
|
||||||
|
where RUN_ID = :run_id
|
||||||
|
and JUDGE_TYPE = 'SESSION'
|
||||||
|
""", {"run_id": run_id})
|
||||||
|
|
||||||
|
session_metrics = {
|
||||||
|
sid: {
|
||||||
|
"inferredCsiScore": csi,
|
||||||
|
"resolution": res,
|
||||||
|
"conversationPrecision": prec,
|
||||||
|
}
|
||||||
|
for sid, csi, res, prec in cur.fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
select r.TRACE_ID, r.SESSION_ID, r.JUDGE_SCORE, r.ACCURACY_SCORE,
|
||||||
|
r.ALUCINATION_SCORE, r.RATIONALE, i.CHANNEL, i.MESSAGE_ID, i.RAW_JSON
|
||||||
|
from {repo.store.t('EVALUATION_RESULT')} r
|
||||||
|
left join {repo.store.t('EVALUATION_ITEM')} i on i.ITEM_ID = r.ITEM_ID
|
||||||
|
where r.RUN_ID = :run_id
|
||||||
|
and r.JUDGE_TYPE = 'TRACE'
|
||||||
|
order by r.CREATED_AT
|
||||||
|
""", {"run_id": run_id})
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
with gzip.open(path, "wt", encoding="utf-8") as f:
|
||||||
|
for trace_id, session_id, judge, accuracy, alucination, rationale, channel, message_id, raw_json in rows:
|
||||||
|
session = session_metrics.get(session_id, {})
|
||||||
|
|
||||||
|
raw: dict[str, Any] = {}
|
||||||
|
ura_call_id = ""
|
||||||
|
channel_id = channel or ""
|
||||||
|
prompt_length = 0
|
||||||
|
loop = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
from evaluator.persistence.oracle_store import _json_loads
|
||||||
|
|
||||||
|
# raw = _json_loads(
|
||||||
|
# raw_json.read() if hasattr(raw_json, "read") else raw_json,
|
||||||
|
# {},
|
||||||
|
# )
|
||||||
|
raw = normalize_raw(raw_json)
|
||||||
|
|
||||||
|
metadata = raw.get("metadata") or {}
|
||||||
|
|
||||||
|
channel_id = (
|
||||||
|
metadata.get("channel_id")
|
||||||
|
or metadata.get("channelId")
|
||||||
|
or metadata.get("channel")
|
||||||
|
or channel_id
|
||||||
|
)
|
||||||
|
|
||||||
|
ura_call_id = extract_ura_call_id(raw, metadata, message_id)
|
||||||
|
prompt_length = extract_prompt_length(raw)
|
||||||
|
loop = vloop_flag(raw)
|
||||||
|
|
||||||
|
# print(
|
||||||
|
# "[DEBUG promptLength]",
|
||||||
|
# "trace_id=", trace_id,
|
||||||
|
# "type(raw)=", type(raw),
|
||||||
|
# "keys=", list(raw.keys())[:20] if isinstance(raw, dict) else None,
|
||||||
|
# "prompt_length=", prompt_length,
|
||||||
|
# "input_text_len=", len(str(raw.get("input_text") or "")) if isinstance(raw, dict) else None,
|
||||||
|
# "messages=", len(raw.get("messages") or []) if isinstance(raw, dict) else None,
|
||||||
|
# )
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[legacy_exporter] metadata extraction failed trace_id={trace_id}: {exc}")
|
||||||
|
|
||||||
|
vals = [
|
||||||
|
judge,
|
||||||
|
accuracy,
|
||||||
|
alucination,
|
||||||
|
prompt_length,
|
||||||
|
loop,
|
||||||
|
session.get("inferredCsiScore"),
|
||||||
|
session.get("resolution"),
|
||||||
|
session.get("conversationPrecision"),
|
||||||
|
ura_call_id,
|
||||||
|
channel_id,
|
||||||
|
session_id,
|
||||||
|
message_id or trace_id,
|
||||||
|
]
|
||||||
|
|
||||||
|
f.write("|;".join(_q(v) for v in vals) + "\n")
|
||||||
|
|
||||||
|
f.write("|;".join([_q("TOTAL"), _q(len(rows))]) + "\n")
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
def extract_ura_call_id(raw: dict, metadata: dict | None = None, message_id: str | None = None) -> str:
|
||||||
|
metadata = metadata or {}
|
||||||
|
|
||||||
|
business_context = (
|
||||||
|
metadata.get("business_context")
|
||||||
|
or metadata.get("businessContext")
|
||||||
|
or raw.get("business_context")
|
||||||
|
or raw.get("businessContext")
|
||||||
|
or raw.get("metadata", {}).get("business_context")
|
||||||
|
or raw.get("metadata", {}).get("businessContext")
|
||||||
|
or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(business_context, dict):
|
||||||
|
business_context = {}
|
||||||
|
|
||||||
|
trace = raw.get("raw", {}).get("trace", {}) or raw.get("trace", {}) or {}
|
||||||
|
detail = raw.get("raw", {}).get("detail", {}) or raw.get("detail", {}) or {}
|
||||||
|
|
||||||
|
trace_input = trace.get("input") or {}
|
||||||
|
detail_input = detail.get("input") or {}
|
||||||
|
|
||||||
|
trace_metadata = trace.get("metadata") or {}
|
||||||
|
detail_metadata = detail.get("metadata") or {}
|
||||||
|
|
||||||
|
trace_bc = trace_input.get("business_context") or {}
|
||||||
|
detail_bc = detail_input.get("business_context") or {}
|
||||||
|
|
||||||
|
return str(
|
||||||
|
business_context.get("interaction_key")
|
||||||
|
or business_context.get("ura_call_id")
|
||||||
|
or metadata.get("ura_call_id")
|
||||||
|
or metadata.get("uraCallId")
|
||||||
|
or metadata.get("interaction_key")
|
||||||
|
or trace_metadata.get("ura_call_id")
|
||||||
|
or detail_metadata.get("ura_call_id")
|
||||||
|
or trace_bc.get("interaction_key")
|
||||||
|
or detail_bc.get("interaction_key")
|
||||||
|
or message_id
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
def normalize_raw(raw):
|
||||||
|
if hasattr(raw, "read"):
|
||||||
|
raw = raw.read()
|
||||||
|
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
raw = raw.decode("utf-8")
|
||||||
|
|
||||||
|
if isinstance(raw, str):
|
||||||
|
raw = raw.strip()
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
raw = json.loads(raw)
|
||||||
|
|
||||||
|
# caso esteja duplamente serializado
|
||||||
|
if isinstance(raw, str):
|
||||||
|
raw = json.loads(raw)
|
||||||
|
|
||||||
|
return raw if isinstance(raw, dict) else {}
|
||||||
|
|
||||||
|
def extract_prompt_length(raw: dict) -> int:
|
||||||
|
# 1. tokens reais do Langfuse/framework
|
||||||
|
tokens = find_prompt_tokens(raw)
|
||||||
|
if tokens > 0:
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
# 2. input_size dos spans
|
||||||
|
input_size = find_input_size(raw)
|
||||||
|
if input_size > 0:
|
||||||
|
return input_size
|
||||||
|
|
||||||
|
# 3. fallback garantido pelo ConversationRecord
|
||||||
|
return (
|
||||||
|
len(str(raw.get("input_text") or ""))
|
||||||
|
+ len(str(raw.get("output_text") or ""))
|
||||||
|
+ sum(
|
||||||
|
len(str(m.get("content") or ""))
|
||||||
|
for m in raw.get("messages", [])
|
||||||
|
if isinstance(m, dict)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _walk(obj):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
yield obj
|
||||||
|
for value in obj.values():
|
||||||
|
yield from _walk(value)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for item in obj:
|
||||||
|
yield from _walk(item)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_positive_int(value) -> int:
|
||||||
|
try:
|
||||||
|
n = int(value)
|
||||||
|
return n if n > 0 else 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def find_prompt_tokens(raw: dict) -> int:
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
for obj in _walk(raw):
|
||||||
|
for key in (
|
||||||
|
"prompt_tokens",
|
||||||
|
"promptTokens",
|
||||||
|
"input_tokens",
|
||||||
|
"inputTokens",
|
||||||
|
):
|
||||||
|
n = _to_positive_int(obj.get(key))
|
||||||
|
if n:
|
||||||
|
candidates.append(n)
|
||||||
|
|
||||||
|
usage = obj.get("usage")
|
||||||
|
if isinstance(usage, dict):
|
||||||
|
for key in ("input", "prompt_tokens", "promptTokens", "input_tokens", "inputTokens"):
|
||||||
|
n = _to_positive_int(usage.get(key))
|
||||||
|
if n:
|
||||||
|
candidates.append(n)
|
||||||
|
|
||||||
|
usage_details = obj.get("usageDetails") or obj.get("usage_details")
|
||||||
|
if isinstance(usage_details, dict):
|
||||||
|
for key in ("input", "prompt_tokens", "promptTokens", "input_tokens", "inputTokens"):
|
||||||
|
n = _to_positive_int(usage_details.get(key))
|
||||||
|
if n:
|
||||||
|
candidates.append(n)
|
||||||
|
|
||||||
|
return max(candidates) if candidates else 0
|
||||||
|
|
||||||
|
|
||||||
|
def find_input_size(raw: dict) -> int:
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
for obj in _walk(raw):
|
||||||
|
for key in ("input_size", "inputSize"):
|
||||||
|
n = _to_positive_int(obj.get(key))
|
||||||
|
if n:
|
||||||
|
candidates.append(n)
|
||||||
|
|
||||||
|
return max(candidates) if candidates else 0
|
||||||
|
|
||||||
|
def calculate_text_length(raw: dict) -> int:
|
||||||
|
return (
|
||||||
|
len(str(raw.get("input_text") or ""))
|
||||||
|
+ len(str(raw.get("output_text") or ""))
|
||||||
|
+ sum(
|
||||||
|
len(str(m.get("content") or ""))
|
||||||
|
for m in raw.get("messages", [])
|
||||||
|
if isinstance(m, dict)
|
||||||
|
)
|
||||||
|
)
|
||||||
BIN
evaluator/persistence/__pycache__/oracle_store.cpython-313.pyc
Normal file
BIN
evaluator/persistence/__pycache__/oracle_store.cpython-313.pyc
Normal file
Binary file not shown.
BIN
evaluator/persistence/__pycache__/repository.cpython-313.pyc
Normal file
BIN
evaluator/persistence/__pycache__/repository.cpython-313.pyc
Normal file
Binary file not shown.
282
evaluator/persistence/oracle_store.py
Normal file
282
evaluator/persistence/oracle_store.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _json_dumps(value: Any) -> str:
|
||||||
|
return json.dumps(value or {}, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_loads(value: str | bytes | None, default: Any):
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
value = value.decode("utf-8")
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OracleSettings:
|
||||||
|
user: str
|
||||||
|
password: str
|
||||||
|
dsn: str
|
||||||
|
wallet_location: str | None = None
|
||||||
|
wallet_password: str | None = None
|
||||||
|
table_prefix: str = "AGENTFW"
|
||||||
|
|
||||||
|
|
||||||
|
class OracleStore:
|
||||||
|
"""Oracle Autonomous Database store following the Agent Framework pattern.
|
||||||
|
|
||||||
|
Uses direct oracledb.connect() with wallet arguments. Synchronous DB calls are
|
||||||
|
exposed through asyncio.to_thread(), matching the main framework style.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, settings, auto_init_schema: bool = False):
|
||||||
|
self.settings = settings
|
||||||
|
self.cfg = OracleSettings(
|
||||||
|
user=settings.ADB_USER or "",
|
||||||
|
password=settings.ADB_PASSWORD or "",
|
||||||
|
dsn=settings.ADB_DSN or "",
|
||||||
|
wallet_location=getattr(settings, "ADB_WALLET_LOCATION", None),
|
||||||
|
wallet_password=getattr(settings, "ADB_WALLET_PASSWORD", None),
|
||||||
|
table_prefix=(getattr(settings, "ADB_TABLE_PREFIX", "AGENTFW") or "AGENTFW").upper().rstrip("_"),
|
||||||
|
)
|
||||||
|
if not self.cfg.user or not self.cfg.password or not self.cfg.dsn:
|
||||||
|
raise RuntimeError("ADB_USER, ADB_PASSWORD and ADB_DSN are required")
|
||||||
|
if auto_init_schema:
|
||||||
|
self._init_schema()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def t(self, name: str) -> str:
|
||||||
|
return f"{self.cfg.table_prefix}_{name}".upper()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connect(self):
|
||||||
|
import oracledb
|
||||||
|
oracledb.defaults.fetch_lobs = False
|
||||||
|
kwargs = {}
|
||||||
|
if self.cfg.wallet_location:
|
||||||
|
kwargs["config_dir"] = self.cfg.wallet_location
|
||||||
|
kwargs["wallet_location"] = self.cfg.wallet_location
|
||||||
|
if self.cfg.wallet_password:
|
||||||
|
kwargs["wallet_password"] = self.cfg.wallet_password
|
||||||
|
conn = oracledb.connect(user=self.cfg.user, password=self.cfg.password, dsn=self.cfg.dsn, **kwargs)
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
async def to_thread(self, func, *args, **kwargs):
|
||||||
|
return await asyncio.to_thread(func, *args, **kwargs)
|
||||||
|
|
||||||
|
def _exec_ddl_ignore_exists(self, cur, ddl: str):
|
||||||
|
try:
|
||||||
|
cur.execute(ddl)
|
||||||
|
except Exception as exc:
|
||||||
|
msg = str(exc)
|
||||||
|
if "ORA-00955" in msg or "ORA-01408" in msg or "ORA-02275" in msg:
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _column_exists(self, cur, table_name: str, column_name: str) -> bool:
|
||||||
|
cur.execute("""
|
||||||
|
select count(*)
|
||||||
|
from user_tab_columns
|
||||||
|
where table_name = :table_name
|
||||||
|
and column_name = :column_name
|
||||||
|
""", {"table_name": self.t(table_name), "column_name": column_name.upper()})
|
||||||
|
return int(cur.fetchone()[0] or 0) > 0
|
||||||
|
|
||||||
|
def _ensure_column(self, cur, table_name: str, column_name: str, ddl_type: str):
|
||||||
|
if not self._column_exists(cur, table_name, column_name):
|
||||||
|
cur.execute(f"alter table {self.t(table_name)} add {column_name.upper()} {ddl_type}")
|
||||||
|
|
||||||
|
def drop_schema(self):
|
||||||
|
tables = [
|
||||||
|
"EVALUATION_RESULT",
|
||||||
|
"EVALUATION_FINDING",
|
||||||
|
"EVALUATION_METRIC",
|
||||||
|
"EVALUATION_ITEM",
|
||||||
|
"EVALUATION_PROGRESS_EVENT",
|
||||||
|
"EVALUATION_RUN",
|
||||||
|
]
|
||||||
|
with self.connect() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
for table in tables:
|
||||||
|
try:
|
||||||
|
cur.execute(f"drop table {self.t(table)} cascade constraints purge")
|
||||||
|
except Exception as exc:
|
||||||
|
if "ORA-00942" in str(exc):
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _init_schema(self):
|
||||||
|
with self.connect() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
self._exec_ddl_ignore_exists(cur, f"""
|
||||||
|
create table {self.t('EVALUATION_RUN')} (
|
||||||
|
RUN_ID varchar2(64) primary key,
|
||||||
|
AGENT_ID varchar2(128),
|
||||||
|
SOURCE varchar2(64),
|
||||||
|
PERIOD_START timestamp with time zone,
|
||||||
|
PERIOD_END timestamp with time zone,
|
||||||
|
STATUS varchar2(32) not null,
|
||||||
|
TOTAL_ITEMS number default 0 not null,
|
||||||
|
PROCESSED_ITEMS number default 0 not null,
|
||||||
|
FAILED_ITEMS number default 0 not null,
|
||||||
|
RETRY_COUNT number default 0 not null,
|
||||||
|
ERROR_MESSAGE clob,
|
||||||
|
LAST_HEARTBEAT_AT timestamp with time zone,
|
||||||
|
CREATED_AT timestamp with time zone not null,
|
||||||
|
UPDATED_AT timestamp with time zone not null
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
self._exec_ddl_ignore_exists(cur, f"""
|
||||||
|
create table {self.t('EVALUATION_PROGRESS_EVENT')} (
|
||||||
|
ID number generated always as identity primary key,
|
||||||
|
RUN_ID varchar2(64) not null,
|
||||||
|
STAGE varchar2(128) not null,
|
||||||
|
MESSAGE varchar2(1000),
|
||||||
|
DETAILS_JSON clob check (DETAILS_JSON is json),
|
||||||
|
CREATED_AT timestamp with time zone not null,
|
||||||
|
constraint {self.t('FK_EVAL_PROGRESS_RUN')}
|
||||||
|
foreign key (RUN_ID) references {self.t('EVALUATION_RUN')}(RUN_ID)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self._exec_ddl_ignore_exists(cur, f"create index {self.t('IX_EVAL_PROGRESS_RUN')} on {self.t('EVALUATION_PROGRESS_EVENT')}(RUN_ID, CREATED_AT)")
|
||||||
|
|
||||||
|
self._exec_ddl_ignore_exists(cur, f"""
|
||||||
|
create table {self.t('EVALUATION_ITEM')} (
|
||||||
|
ITEM_ID varchar2(64) primary key,
|
||||||
|
RUN_ID varchar2(64) not null,
|
||||||
|
TRACE_ID varchar2(256),
|
||||||
|
SESSION_ID varchar2(256),
|
||||||
|
MESSAGE_ID varchar2(256),
|
||||||
|
AGENT_ID varchar2(128),
|
||||||
|
CHANNEL varchar2(64),
|
||||||
|
STATUS varchar2(32) not null,
|
||||||
|
ATTEMPT_COUNT number default 0 not null,
|
||||||
|
ERROR_MESSAGE clob,
|
||||||
|
RAW_JSON clob check (RAW_JSON is json),
|
||||||
|
CREATED_AT timestamp with time zone not null,
|
||||||
|
UPDATED_AT timestamp with time zone not null,
|
||||||
|
constraint {self.t('FK_EVAL_ITEM_RUN')}
|
||||||
|
foreign key (RUN_ID) references {self.t('EVALUATION_RUN')}(RUN_ID)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self._exec_ddl_ignore_exists(cur, f"create index {self.t('IX_EVAL_ITEM_RUN')} on {self.t('EVALUATION_ITEM')}(RUN_ID, STATUS, CREATED_AT)")
|
||||||
|
|
||||||
|
self._exec_ddl_ignore_exists(cur, f"""
|
||||||
|
create table {self.t('EVALUATION_RESULT')} (
|
||||||
|
RESULT_ID varchar2(64) primary key,
|
||||||
|
RUN_ID varchar2(64) not null,
|
||||||
|
ITEM_ID varchar2(64),
|
||||||
|
TRACE_ID varchar2(256),
|
||||||
|
SESSION_ID varchar2(256),
|
||||||
|
AGENT_ID varchar2(128),
|
||||||
|
JUDGE_NAME varchar2(128) not null,
|
||||||
|
JUDGE_TYPE varchar2(32),
|
||||||
|
SCORE number,
|
||||||
|
JUDGE_SCORE number,
|
||||||
|
ACCURACY_SCORE number,
|
||||||
|
ALUCINATION_SCORE number,
|
||||||
|
HALLUCINATION_SCORE number,
|
||||||
|
INFERRED_CSI_SCORE number,
|
||||||
|
RESOLUTION number,
|
||||||
|
CONVERSATION_PRECISION number,
|
||||||
|
TOOL_USAGE_SCORE number,
|
||||||
|
ROUTING_SCORE number,
|
||||||
|
RATIONALE clob,
|
||||||
|
REASONING clob,
|
||||||
|
RESULT_JSON clob check (RESULT_JSON is json),
|
||||||
|
CREATED_AT timestamp with time zone not null,
|
||||||
|
constraint {self.t('FK_EVAL_RESULT_RUN')}
|
||||||
|
foreign key (RUN_ID) references {self.t('EVALUATION_RUN')}(RUN_ID),
|
||||||
|
constraint {self.t('FK_EVAL_RESULT_ITEM')}
|
||||||
|
foreign key (ITEM_ID) references {self.t('EVALUATION_ITEM')}(ITEM_ID)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self._exec_ddl_ignore_exists(cur, f"create index {self.t('IX_EVAL_RESULT_RUN')} on {self.t('EVALUATION_RESULT')}(RUN_ID, ITEM_ID)")
|
||||||
|
|
||||||
|
self._exec_ddl_ignore_exists(cur, f"""
|
||||||
|
create table {self.t('EVALUATION_METRIC')} (
|
||||||
|
METRIC_ID varchar2(64) primary key,
|
||||||
|
RUN_ID varchar2(64) not null,
|
||||||
|
ITEM_ID varchar2(64),
|
||||||
|
METRIC_NAME varchar2(128) not null,
|
||||||
|
METRIC_VALUE number,
|
||||||
|
DIMENSIONS_JSON clob check (DIMENSIONS_JSON is json),
|
||||||
|
CREATED_AT timestamp with time zone not null,
|
||||||
|
constraint {self.t('FK_EVAL_METRIC_RUN')}
|
||||||
|
foreign key (RUN_ID) references {self.t('EVALUATION_RUN')}(RUN_ID)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
self._exec_ddl_ignore_exists(cur, f"""
|
||||||
|
create table {self.t('EVALUATION_FINDING')} (
|
||||||
|
FINDING_ID varchar2(64) primary key,
|
||||||
|
RUN_ID varchar2(64) not null,
|
||||||
|
ITEM_ID varchar2(64),
|
||||||
|
SEVERITY varchar2(32),
|
||||||
|
CATEGORY varchar2(128),
|
||||||
|
TITLE varchar2(512),
|
||||||
|
DESCRIPTION clob,
|
||||||
|
EVIDENCE_JSON clob check (EVIDENCE_JSON is json),
|
||||||
|
CREATED_AT timestamp with time zone not null,
|
||||||
|
constraint {self.t('FK_EVAL_FINDING_RUN')}
|
||||||
|
foreign key (RUN_ID) references {self.t('EVALUATION_RUN')}(RUN_ID)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Non-destructive compatibility for older generated schemas.
|
||||||
|
for col, typ in [
|
||||||
|
("RETRY_COUNT", "number default 0"),
|
||||||
|
("ERROR_MESSAGE", "clob"),
|
||||||
|
("LAST_HEARTBEAT_AT", "timestamp with time zone"),
|
||||||
|
("UPDATED_AT", "timestamp with time zone"),
|
||||||
|
]:
|
||||||
|
self._ensure_column(cur, "EVALUATION_RUN", col, typ)
|
||||||
|
for col, typ in [
|
||||||
|
("ID", "number generated always as identity"),
|
||||||
|
]:
|
||||||
|
# Identity column cannot always be added cleanly; ignore if table is old without ID.
|
||||||
|
try:
|
||||||
|
self._ensure_column(cur, "EVALUATION_PROGRESS_EVENT", col, typ)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for col, typ in [
|
||||||
|
("JUDGE_NAME", "varchar2(128) default 'unknown_judge' not null"),
|
||||||
|
("JUDGE_TYPE", "varchar2(32)"),
|
||||||
|
("SCORE", "number"),
|
||||||
|
("JUDGE_SCORE", "number"),
|
||||||
|
("ACCURACY_SCORE", "number"),
|
||||||
|
("ALUCINATION_SCORE", "number"),
|
||||||
|
("HALLUCINATION_SCORE", "number"),
|
||||||
|
("INFERRED_CSI_SCORE", "number"),
|
||||||
|
("RESOLUTION", "number"),
|
||||||
|
("CONVERSATION_PRECISION", "number"),
|
||||||
|
("TOOL_USAGE_SCORE", "number"),
|
||||||
|
("ROUTING_SCORE", "number"),
|
||||||
|
("RATIONALE", "clob"),
|
||||||
|
("REASONING", "clob"),
|
||||||
|
("RESULT_JSON", "clob"),
|
||||||
|
]:
|
||||||
|
self._ensure_column(cur, "EVALUATION_RESULT", col, typ)
|
||||||
410
evaluator/persistence/repository.py
Normal file
410
evaluator/persistence/repository.py
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from evaluator.config.settings import settings
|
||||||
|
from evaluator.core.models import ConversationRecord, ItemStatus, RunStatus, TraceJudgeResult, SessionJudgeResult
|
||||||
|
from evaluator.persistence.oracle_store import OracleStore, _json_dumps, _json_loads
|
||||||
|
|
||||||
|
|
||||||
|
class EvaluationRepository:
|
||||||
|
def __init__(self, auto_init_schema: bool = False):
|
||||||
|
self.store = OracleStore(settings, auto_init_schema=auto_init_schema)
|
||||||
|
|
||||||
|
def create_run(self, period_start: datetime, period_end: datetime, source: str, agent_id: str | None = None) -> str:
|
||||||
|
run_id = str(uuid.uuid4())
|
||||||
|
now = self.store.now()
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
conn.cursor().execute(f"""
|
||||||
|
insert into {self.store.t('EVALUATION_RUN')}
|
||||||
|
(RUN_ID, AGENT_ID, PERIOD_START, PERIOD_END, SOURCE, STATUS, TOTAL_ITEMS,
|
||||||
|
PROCESSED_ITEMS, FAILED_ITEMS, RETRY_COUNT, LAST_HEARTBEAT_AT, CREATED_AT, UPDATED_AT)
|
||||||
|
values (:run_id, :agent_id, :period_start, :period_end, :source, :status,
|
||||||
|
0, 0, 0, 0, :heartbeat_at, :created_at, :updated_at)
|
||||||
|
""", {
|
||||||
|
"run_id": run_id,
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"period_start": period_start,
|
||||||
|
"period_end": period_end,
|
||||||
|
"source": source,
|
||||||
|
"status": RunStatus.RUNNING.value,
|
||||||
|
"heartbeat_at": now,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
})
|
||||||
|
return run_id
|
||||||
|
|
||||||
|
async def acreate_run(self, *args, **kwargs):
|
||||||
|
return await self.store.to_thread(self.create_run, *args, **kwargs)
|
||||||
|
|
||||||
|
def record_progress(self, run_id: str, stage: str, message: str = "", details: dict | None = None):
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
conn.cursor().execute(f"""
|
||||||
|
insert into {self.store.t('EVALUATION_PROGRESS_EVENT')}
|
||||||
|
(RUN_ID, STAGE, MESSAGE, DETAILS_JSON, CREATED_AT)
|
||||||
|
values (:run_id, :stage, :message, :details_json, :created_at)
|
||||||
|
""", {
|
||||||
|
"run_id": run_id,
|
||||||
|
"stage": stage,
|
||||||
|
"message": (message or "")[:1000],
|
||||||
|
"details_json": _json_dumps(details or {}),
|
||||||
|
"created_at": self.store.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def arecord_progress(self, *args, **kwargs):
|
||||||
|
return await self.store.to_thread(self.record_progress, *args, **kwargs)
|
||||||
|
|
||||||
|
def insert_items(self, run_id: str, records: list[ConversationRecord]) -> int:
|
||||||
|
inserted = 0
|
||||||
|
now = self.store.now()
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
for record in records:
|
||||||
|
try:
|
||||||
|
cur.execute(f"""
|
||||||
|
insert into {self.store.t('EVALUATION_ITEM')}
|
||||||
|
(ITEM_ID, RUN_ID, TRACE_ID, SESSION_ID, MESSAGE_ID, AGENT_ID, CHANNEL,
|
||||||
|
STATUS, ATTEMPT_COUNT, RAW_JSON, CREATED_AT, UPDATED_AT)
|
||||||
|
values (:item_id, :run_id, :trace_id, :session_id, :message_id, :agent_id,
|
||||||
|
:channel, :status, 0, :raw_json, :created_at, :updated_at)
|
||||||
|
""", {
|
||||||
|
"item_id": str(uuid.uuid4()),
|
||||||
|
"run_id": run_id,
|
||||||
|
"trace_id": record.trace_id,
|
||||||
|
"session_id": record.session_id,
|
||||||
|
"message_id": record.message_id,
|
||||||
|
"agent_id": record.agent_id,
|
||||||
|
"channel": record.channel,
|
||||||
|
"status": ItemStatus.PENDING.value,
|
||||||
|
"raw_json": record.model_dump_json(),
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
})
|
||||||
|
inserted += 1
|
||||||
|
except Exception as exc:
|
||||||
|
if "ORA-00001" not in str(exc):
|
||||||
|
raise
|
||||||
|
cur.execute(f"""
|
||||||
|
update {self.store.t('EVALUATION_RUN')}
|
||||||
|
set TOTAL_ITEMS = (
|
||||||
|
select count(*) from {self.store.t('EVALUATION_ITEM')} where RUN_ID = :run_id
|
||||||
|
),
|
||||||
|
UPDATED_AT = :updated_at
|
||||||
|
where RUN_ID = :run_id
|
||||||
|
""", {"run_id": run_id, "updated_at": self.store.now()})
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
async def ainsert_items(self, *args, **kwargs):
|
||||||
|
return await self.store.to_thread(self.insert_items, *args, **kwargs)
|
||||||
|
|
||||||
|
def fetch_next_items(self, run_id: str, batch_size: int) -> list[dict]:
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(f"""
|
||||||
|
select * from (
|
||||||
|
select ITEM_ID, RUN_ID, TRACE_ID, SESSION_ID, MESSAGE_ID, AGENT_ID, CHANNEL,
|
||||||
|
STATUS, ATTEMPT_COUNT, RAW_JSON
|
||||||
|
from {self.store.t('EVALUATION_ITEM')}
|
||||||
|
where RUN_ID = :run_id
|
||||||
|
and STATUS in (:pending, :failed)
|
||||||
|
and ATTEMPT_COUNT < :max_attempts
|
||||||
|
order by CREATED_AT
|
||||||
|
) where rownum <= :batch_size
|
||||||
|
""", {
|
||||||
|
"run_id": run_id,
|
||||||
|
"pending": ItemStatus.PENDING.value,
|
||||||
|
"failed": ItemStatus.FAILED.value,
|
||||||
|
"max_attempts": settings.max_attempts,
|
||||||
|
"batch_size": batch_size,
|
||||||
|
})
|
||||||
|
cols = [d[0].lower() for d in cur.description]
|
||||||
|
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
async def afetch_next_items(self, *args, **kwargs):
|
||||||
|
return await self.store.to_thread(self.fetch_next_items, *args, **kwargs)
|
||||||
|
|
||||||
|
def mark_item_processing(self, item_id: str):
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
conn.cursor().execute(f"""
|
||||||
|
update {self.store.t('EVALUATION_ITEM')}
|
||||||
|
set STATUS = :status,
|
||||||
|
ATTEMPT_COUNT = ATTEMPT_COUNT + 1,
|
||||||
|
UPDATED_AT = :updated_at
|
||||||
|
where ITEM_ID = :item_id
|
||||||
|
""", {
|
||||||
|
"status": ItemStatus.PROCESSING.value,
|
||||||
|
"updated_at": self.store.now(),
|
||||||
|
"item_id": item_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def amark_item_processing(self, *args, **kwargs):
|
||||||
|
return await self.store.to_thread(self.mark_item_processing, *args, **kwargs)
|
||||||
|
|
||||||
|
def mark_item_completed(self, run_id: str, item_id: str):
|
||||||
|
now = self.store.now()
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(f"""
|
||||||
|
update {self.store.t('EVALUATION_ITEM')}
|
||||||
|
set STATUS = :status,
|
||||||
|
UPDATED_AT = :updated_at
|
||||||
|
where ITEM_ID = :item_id
|
||||||
|
""", {
|
||||||
|
"status": ItemStatus.COMPLETED.value,
|
||||||
|
"updated_at": now,
|
||||||
|
"item_id": item_id,
|
||||||
|
})
|
||||||
|
self._refresh_run_counters(cur, run_id, now)
|
||||||
|
|
||||||
|
async def amark_item_completed(self, *args, **kwargs):
|
||||||
|
return await self.store.to_thread(self.mark_item_completed, *args, **kwargs)
|
||||||
|
|
||||||
|
def mark_item_failed(self, run_id: str, item_id: str, error: str):
|
||||||
|
now = self.store.now()
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(f"""
|
||||||
|
update {self.store.t('EVALUATION_ITEM')}
|
||||||
|
set STATUS = :status,
|
||||||
|
ERROR_MESSAGE = :error,
|
||||||
|
UPDATED_AT = :updated_at
|
||||||
|
where ITEM_ID = :item_id
|
||||||
|
""", {
|
||||||
|
"status": ItemStatus.FAILED.value,
|
||||||
|
"error": (error or "")[:4000],
|
||||||
|
"updated_at": now,
|
||||||
|
"item_id": item_id,
|
||||||
|
})
|
||||||
|
self._refresh_run_counters(cur, run_id, now)
|
||||||
|
|
||||||
|
async def amark_item_failed(self, *args, **kwargs):
|
||||||
|
return await self.store.to_thread(self.mark_item_failed, *args, **kwargs)
|
||||||
|
|
||||||
|
def _refresh_run_counters(self, cur, run_id: str, updated_at):
|
||||||
|
cur.execute(f"""
|
||||||
|
update {self.store.t('EVALUATION_RUN')}
|
||||||
|
set PROCESSED_ITEMS = (
|
||||||
|
select count(*) from {self.store.t('EVALUATION_ITEM')}
|
||||||
|
where RUN_ID = :run_id and STATUS = :completed
|
||||||
|
),
|
||||||
|
FAILED_ITEMS = (
|
||||||
|
select count(*) from {self.store.t('EVALUATION_ITEM')}
|
||||||
|
where RUN_ID = :run_id and STATUS = :failed
|
||||||
|
),
|
||||||
|
UPDATED_AT = :updated_at
|
||||||
|
where RUN_ID = :run_id
|
||||||
|
""", {
|
||||||
|
"run_id": run_id,
|
||||||
|
"completed": ItemStatus.COMPLETED.value,
|
||||||
|
"failed": ItemStatus.FAILED.value,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
def save_trace_result(self, run_id: str, item_id: str, record: ConversationRecord, result: TraceJudgeResult):
|
||||||
|
judge_name = getattr(result, "judge_name", None) or "trace_metrics"
|
||||||
|
judge_type = (getattr(result, "judge_type", None) or "TRACE").upper()
|
||||||
|
score = getattr(result, "judgeScore", None)
|
||||||
|
accuracy = getattr(result, "accuracyScore", None)
|
||||||
|
alucination = getattr(result, "alucinationScore", None)
|
||||||
|
rationale = getattr(result, "rationale", None) or ""
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
conn.cursor().execute(f"""
|
||||||
|
insert into {self.store.t('EVALUATION_RESULT')}
|
||||||
|
(RESULT_ID, RUN_ID, ITEM_ID, TRACE_ID, SESSION_ID, AGENT_ID, JUDGE_NAME,
|
||||||
|
JUDGE_TYPE, SCORE, JUDGE_SCORE, ACCURACY_SCORE, ALUCINATION_SCORE,
|
||||||
|
RATIONALE, RESULT_JSON, CREATED_AT)
|
||||||
|
values (:result_id, :run_id, :item_id, :trace_id, :session_id, :agent_id,
|
||||||
|
:judge_name, :judge_type, :score, :judge_score, :accuracy_score,
|
||||||
|
:alucination_score, :rationale, :result_json, :created_at)
|
||||||
|
""", {
|
||||||
|
"result_id": str(uuid.uuid4()),
|
||||||
|
"run_id": run_id,
|
||||||
|
"item_id": item_id,
|
||||||
|
"trace_id": record.trace_id,
|
||||||
|
"session_id": record.session_id,
|
||||||
|
"agent_id": record.agent_id,
|
||||||
|
"judge_name": judge_name,
|
||||||
|
"judge_type": judge_type,
|
||||||
|
"score": score,
|
||||||
|
"judge_score": score,
|
||||||
|
"accuracy_score": accuracy,
|
||||||
|
"alucination_score": alucination,
|
||||||
|
"rationale": rationale,
|
||||||
|
"result_json": result.model_dump_json(),
|
||||||
|
"created_at": self.store.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def asave_trace_result(self, *args, **kwargs):
|
||||||
|
return await self.store.to_thread(self.save_trace_result, *args, **kwargs)
|
||||||
|
|
||||||
|
def save_session_result(self, run_id: str, session_id: str, agent_id: str | None, result: SessionJudgeResult):
|
||||||
|
judge_name = getattr(result, "judge_name", None) or "session_metrics"
|
||||||
|
judge_type = (getattr(result, "judge_type", None) or "SESSION").upper()
|
||||||
|
rationale = getattr(result, "rationale", None) or ""
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
conn.cursor().execute(f"""
|
||||||
|
insert into {self.store.t('EVALUATION_RESULT')}
|
||||||
|
(RESULT_ID, RUN_ID, SESSION_ID, AGENT_ID, JUDGE_NAME, JUDGE_TYPE,
|
||||||
|
INFERRED_CSI_SCORE, RESOLUTION, CONVERSATION_PRECISION, RATIONALE,
|
||||||
|
RESULT_JSON, CREATED_AT)
|
||||||
|
values (:result_id, :run_id, :session_id, :agent_id, :judge_name, :judge_type,
|
||||||
|
:csi, :resolution, :precision, :rationale, :result_json, :created_at)
|
||||||
|
""", {
|
||||||
|
"result_id": str(uuid.uuid4()),
|
||||||
|
"run_id": run_id,
|
||||||
|
"session_id": session_id,
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"judge_name": judge_name,
|
||||||
|
"judge_type": judge_type,
|
||||||
|
"csi": getattr(result, "inferredCsiScore", None),
|
||||||
|
"resolution": getattr(result, "resolution", None),
|
||||||
|
"precision": getattr(result, "conversationPrecision", None),
|
||||||
|
"rationale": rationale,
|
||||||
|
"result_json": result.model_dump_json(),
|
||||||
|
"created_at": self.store.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def asave_session_result(self, *args, **kwargs):
|
||||||
|
return await self.store.to_thread(self.save_session_result, *args, **kwargs)
|
||||||
|
|
||||||
|
def mark_run_status(self, run_id: str, status: RunStatus, error: str | None = None):
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
conn.cursor().execute(f"""
|
||||||
|
update {self.store.t('EVALUATION_RUN')}
|
||||||
|
set STATUS = :status,
|
||||||
|
ERROR_MESSAGE = :error,
|
||||||
|
UPDATED_AT = :updated_at
|
||||||
|
where RUN_ID = :run_id
|
||||||
|
""", {
|
||||||
|
"status": status.value,
|
||||||
|
"error": error,
|
||||||
|
"updated_at": self.store.now(),
|
||||||
|
"run_id": run_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def amark_run_status(self, *args, **kwargs):
|
||||||
|
return await self.store.to_thread(self.mark_run_status, *args, **kwargs)
|
||||||
|
|
||||||
|
def summarize_run(self, run_id: str) -> dict:
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(f"""
|
||||||
|
select
|
||||||
|
(select count(*) from {self.store.t('EVALUATION_ITEM')} where RUN_ID = :run_id),
|
||||||
|
(select count(*) from {self.store.t('EVALUATION_ITEM')} where RUN_ID = :run_id and STATUS = 'COMPLETED'),
|
||||||
|
(select count(*) from {self.store.t('EVALUATION_ITEM')} where RUN_ID = :run_id and STATUS = 'FAILED'),
|
||||||
|
(select count(*) from {self.store.t('EVALUATION_RESULT')} where RUN_ID = :run_id and JUDGE_TYPE = 'TRACE'),
|
||||||
|
(select avg(JUDGE_SCORE) from {self.store.t('EVALUATION_RESULT')} where RUN_ID = :run_id and JUDGE_TYPE = 'TRACE')
|
||||||
|
from dual
|
||||||
|
""", {"run_id": run_id})
|
||||||
|
r = cur.fetchone()
|
||||||
|
return {
|
||||||
|
"run_id": run_id,
|
||||||
|
"total_items": int(r[0] or 0),
|
||||||
|
"completed_items": int(r[1] or 0),
|
||||||
|
"failed_items": int(r[2] or 0),
|
||||||
|
"evaluations": int(r[3] or 0),
|
||||||
|
"avg_score": float(r[4]) if r[4] is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def asummarize_run(self, *args, **kwargs):
|
||||||
|
return await self.store.to_thread(self.summarize_run, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_run_progress(self, run_id: str, event_limit: int = 20) -> dict:
|
||||||
|
summary = self.summarize_run(run_id)
|
||||||
|
total = summary["total_items"] or 0
|
||||||
|
done = summary["completed_items"] + summary["failed_items"]
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(f"""
|
||||||
|
select * from (
|
||||||
|
select STAGE, MESSAGE, DETAILS_JSON, CREATED_AT
|
||||||
|
from {self.store.t('EVALUATION_PROGRESS_EVENT')}
|
||||||
|
where RUN_ID = :run_id
|
||||||
|
order by CREATED_AT desc
|
||||||
|
) where rownum <= :max_rows
|
||||||
|
""", {"run_id": run_id, "max_rows": event_limit})
|
||||||
|
events = [
|
||||||
|
{
|
||||||
|
"stage": s,
|
||||||
|
"message": m,
|
||||||
|
"details": _json_loads(d.read() if hasattr(d, "read") else d, {}),
|
||||||
|
"created_at": str(c),
|
||||||
|
}
|
||||||
|
for s, m, d, c in cur.fetchall()
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
**summary,
|
||||||
|
"done_items": done,
|
||||||
|
"percent_complete": round((done / total) * 100, 2) if total else 0.0,
|
||||||
|
"events": events,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def aget_run_progress(self, *args, **kwargs):
|
||||||
|
return await self.store.to_thread(self.get_run_progress, *args, **kwargs)
|
||||||
|
|
||||||
|
def list_runs(self, limit: int = 20):
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(f"""
|
||||||
|
select * from (
|
||||||
|
select RUN_ID, AGENT_ID, PERIOD_START, PERIOD_END, SOURCE, STATUS,
|
||||||
|
TOTAL_ITEMS, PROCESSED_ITEMS, FAILED_ITEMS, CREATED_AT, UPDATED_AT
|
||||||
|
from {self.store.t('EVALUATION_RUN')}
|
||||||
|
order by CREATED_AT desc
|
||||||
|
) where rownum <= :max_rows
|
||||||
|
""", {"max_rows": limit})
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"run_id": r[0],
|
||||||
|
"agent_id": r[1],
|
||||||
|
"period_start": str(r[2]),
|
||||||
|
"period_end": str(r[3]),
|
||||||
|
"source": r[4],
|
||||||
|
"status": r[5],
|
||||||
|
"total_items": int(r[6] or 0),
|
||||||
|
"processed_items": int(r[7] or 0),
|
||||||
|
"failed_items": int(r[8] or 0),
|
||||||
|
"created_at": str(r[9]),
|
||||||
|
"updated_at": str(r[10]),
|
||||||
|
}
|
||||||
|
for r in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
async def alist_runs(self, *args, **kwargs):
|
||||||
|
return await self.store.to_thread(self.list_runs, *args, **kwargs)
|
||||||
|
|
||||||
|
def list_results(self, run_id: str, limit: int = 100) -> list[dict]:
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(f"""
|
||||||
|
select JUDGE_NAME, JUDGE_TYPE, SCORE, JUDGE_SCORE, ACCURACY_SCORE,
|
||||||
|
ALUCINATION_SCORE, INFERRED_CSI_SCORE, RESOLUTION,
|
||||||
|
CONVERSATION_PRECISION, RATIONALE, TRACE_ID, SESSION_ID, CREATED_AT
|
||||||
|
from {self.store.t('EVALUATION_RESULT')}
|
||||||
|
where RUN_ID = :run_id
|
||||||
|
order by CREATED_AT desc
|
||||||
|
""", {"run_id": run_id})
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"judge_name": r[0],
|
||||||
|
"judge_type": r[1],
|
||||||
|
"score": r[2],
|
||||||
|
"judge_score": r[3],
|
||||||
|
"accuracy_score": r[4],
|
||||||
|
"alucination_score": r[5],
|
||||||
|
"inferred_csi_score": r[6],
|
||||||
|
"resolution": r[7],
|
||||||
|
"conversation_precision": r[8],
|
||||||
|
"rationale": r[9],
|
||||||
|
"trace_id": r[10],
|
||||||
|
"session_id": r[11],
|
||||||
|
"created_at": str(r[12]),
|
||||||
|
}
|
||||||
|
for r in cur.fetchall()[:limit]
|
||||||
|
]
|
||||||
|
|
||||||
|
async def alist_results(self, *args, **kwargs):
|
||||||
|
return await self.store.to_thread(self.list_results, *args, **kwargs)
|
||||||
BIN
evaluator/prompts/__pycache__/loader.cpython-313.pyc
Normal file
BIN
evaluator/prompts/__pycache__/loader.cpython-313.pyc
Normal file
Binary file not shown.
11
evaluator/prompts/loader.py
Normal file
11
evaluator/prompts/loader.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import yaml
|
||||||
|
from evaluator.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def load_prompt(path: str, key: str) -> str:
|
||||||
|
p = settings.path(path)
|
||||||
|
data = yaml.safe_load(p.read_text()) or {}
|
||||||
|
if key not in data:
|
||||||
|
raise KeyError(f"Prompt key {key!r} not found in {p}")
|
||||||
|
return str(data[key])
|
||||||
BIN
evaluator/publishers/__pycache__/langfuse_scores.cpython-313.pyc
Normal file
BIN
evaluator/publishers/__pycache__/langfuse_scores.cpython-313.pyc
Normal file
Binary file not shown.
22
evaluator/publishers/langfuse_scores.py
Normal file
22
evaluator/publishers/langfuse_scores.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import httpx
|
||||||
|
from evaluator.config.settings import settings
|
||||||
|
from evaluator.core.models import ConversationRecord, TraceJudgeResult
|
||||||
|
|
||||||
|
class LangfuseScorePublisher:
|
||||||
|
async def publish_trace_score(self, record: ConversationRecord, result: TraceJudgeResult):
|
||||||
|
if not settings.can_publish_langfuse_scores or not record.trace_id:
|
||||||
|
return None
|
||||||
|
auth = (settings.langfuse_public_key, settings.langfuse_secret_key)
|
||||||
|
payloads = [
|
||||||
|
{'traceId': record.trace_id, 'name': 'offline_judge_score', 'value': result.judgeScore, 'comment': result.rationale},
|
||||||
|
{'traceId': record.trace_id, 'name': 'offline_accuracy_score', 'value': result.accuracyScore, 'comment': result.rationale},
|
||||||
|
{'traceId': record.trace_id, 'name': 'offline_alucination_score', 'value': result.alucinationScore, 'comment': result.rationale},
|
||||||
|
]
|
||||||
|
async with httpx.AsyncClient(base_url=settings.langfuse_host, timeout=30) as client:
|
||||||
|
for payload in payloads:
|
||||||
|
resp = await client.post('/api/public/scores', json=payload, auth=auth)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
# Don't fail the run because score publishing is supplementary.
|
||||||
|
return {'ok': False, 'status': resp.status_code, 'body': resp.text}
|
||||||
|
return {'ok': True}
|
||||||
20
k8s/cronjob.yaml
Normal file
20
k8s/cronjob.yaml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
apiVersion: batch/v1
|
||||||
|
kind: CronJob
|
||||||
|
metadata:
|
||||||
|
name: agent-framework-evaluator
|
||||||
|
spec:
|
||||||
|
schedule: "0 2 * * *"
|
||||||
|
suspend: true
|
||||||
|
concurrencyPolicy: Forbid
|
||||||
|
jobTemplate:
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: evaluator
|
||||||
|
image: agent-framework-evaluator:latest
|
||||||
|
command: ["python", "-m", "evaluator.cli", "run-agents", "--source", "langfuse"]
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: agent-framework-evaluator-env
|
||||||
BIN
output/.DS_Store
vendored
Normal file
BIN
output/.DS_Store
vendored
Normal file
Binary file not shown.
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[project]
|
||||||
|
name = "agent-framework-evaluator"
|
||||||
|
version = "0.2.0"
|
||||||
|
description = "Offline LLM-as-a-Judge evaluator for Agent Framework conversations"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"python-dotenv>=1.0.1",
|
||||||
|
"pydantic>=2.7.0",
|
||||||
|
"pydantic-settings>=2.2.1",
|
||||||
|
"oracledb>=2.4.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"pyyaml>=6.0.1",
|
||||||
|
"typer>=0.12.3",
|
||||||
|
"click>=8.1.7",
|
||||||
|
"rich>=13.7.0",
|
||||||
|
"fastapi>=0.111.0",
|
||||||
|
"uvicorn>=0.30.0"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
af-evaluator = "evaluator.cli:app"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["evaluator*"]
|
||||||
Reference in New Issue
Block a user