File size: 15,715 Bytes
65cee4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0fe511a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65cee4e
 
 
 
 
 
dec430b
 
0fe511a
 
 
 
65cee4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0fe511a
 
 
 
 
 
 
 
 
65cee4e
 
 
76f2b0b
65cee4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76f2b0b
65cee4e
dd9f94d
65cee4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0fe511a
 
65cee4e
 
 
 
0fe511a
65cee4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
"""
Versioned eval script for Prism. Writes timestamped JSON to eval-dashboard/public/data/runs/
and updates eval-dashboard/public/data/index.json.

Metrics:
  answer_correctness  β€” LLM judge (8B): generated answer vs ground_truth, 0–1
  answer_relevancy    β€” RAGAS: does answer address the question?
  context_recall      β€” RAGAS: did retrieval surface all needed chunks?
  precision_at_5      β€” (relevant chunks in top-5) / 5, source + keyword match
  latency_p50/p95/p99 β€” per-query end-to-end timing (retrieval + LLM, no Tavily)

Usage:
    python scripts/run_eval_versioned.py --version v2.0 --tag "baseline" --n 50
    python scripts/run_eval_versioned.py --version v2.1 --tag "HyDE enabled" --n 50

Prerequisites:
    pip install 'ragas>=0.2.0,<0.3.0' datasets
    .env with GROQ_API_KEY + EURON_API_KEY
    scripts/run_ingest.py run first (ChromaDB populated)
"""

import argparse
import json
import math
import os
import sys
import time
from datetime import datetime, date
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from dotenv import load_dotenv
load_dotenv()

EVAL_PAIRS_PATH = Path(__file__).resolve().parent.parent / "data" / "ground_truth" / "eval_pairs.json"
DASHBOARD_RUNS_DIR = Path(__file__).resolve().parent.parent / "eval-dashboard" / "public" / "data" / "runs"
DASHBOARD_INDEX_PATH = Path(__file__).resolve().parent.parent / "eval-dashboard" / "public" / "data" / "index.json"

CORRECTNESS_PROMPT = """\
You are an evaluation judge. Score the generated answer against the reference answer.

Use a 1–5 integer scale:
5 β€” All key facts present and correct
4 β€” Most key facts correct, minor omissions or imprecision
3 β€” Some key facts correct, moderate gaps
2 β€” Few facts correct, significant errors or hallucinations
1 β€” Completely wrong, irrelevant, or contradicts reference

Reference answer: {ground_truth}

Generated answer: {answer}

Respond with JSON only β€” a single object, nothing else:
{{"score": 4, "reason": "one sentence"}}"""


def _build_retriever(config, workspace_id="default"):
    from server.bm25_index import build_from_vectorstore
    from server.reranker import load_reranker
    from server.retriever import HybridRetriever
    from langchain_chroma import Chroma
    from langchain_openai import OpenAIEmbeddings

    print("Loading vectorstore...")
    embeddings = OpenAIEmbeddings(
        model="text-embedding-3-small",
        openai_api_key=os.getenv("EURON_API_KEY", ""),
        openai_api_base="https://api.euron.one/api/v1/euri",
    )
    vectorstore = Chroma(
        collection_name=workspace_id,
        embedding_function=embeddings,
        persist_directory="./chroma_db",
    )
    if vectorstore._collection.count() == 0:
        print(f"ERROR: ChromaDB collection '{workspace_id}' empty. Run scripts/run_ingest.py first.")
        sys.exit(1)

    print("Building BM25 index...")
    build_from_vectorstore(vectorstore, workspace_id=workspace_id)

    print("Loading reranker...")
    load_reranker()

    retrieval_cfg = config.get("retrieval", {})
    retriever = HybridRetriever(
        vectorstore=vectorstore,
        dense_weight=retrieval_cfg.get("dense_weight", 0.7),
        sparse_weight=retrieval_cfg.get("sparse_weight", 0.3),
        retrieve_k=retrieval_cfg.get("retrieve_k", 10),
        rerank_k=retrieval_cfg.get("rerank_k", 5),
        workspace_id=workspace_id,
        use_hyde=retrieval_cfg.get("hyde_enabled", False),
    )
    return retriever


def _make_llm(model: str, temperature: float = 0.1, max_tokens: int = 500):
    from langchain_groq import ChatGroq
    return ChatGroq(
        model=model,
        api_key=os.getenv("GROQ_API_KEY", ""),
        temperature=temperature,
        max_tokens=max_tokens,
    )


def _answer_query(llm, retriever, query: str) -> tuple[str, list, list[dict]]:
    """Returns (answer, lc_docs, chunk_dicts)."""
    from langchain_core.messages import HumanMessage, SystemMessage

    docs = retriever.invoke(query)
    contexts = [d.page_content for d in docs]
    chunk_dicts = [
        {"content": d.page_content, "source": d.metadata.get("source", "")}
        for d in docs
    ]
    ctx_text = "\n\n".join(f"[Doc {i+1}]\n{c}" for i, c in enumerate(contexts))
    messages = [
        SystemMessage(content=(
            "You are a research assistant. Answer using only the provided context. "
            "Be concise and precise. State which document supports your answer."
        )),
        HumanMessage(content=f"Context:\n{ctx_text}\n\nQuestion: {query}\n\nAnswer:"),
    ]
    response = llm.invoke(messages)
    return response.content.strip(), docs, chunk_dicts


def _score_correctness(judge_llm, answer: str, ground_truth: str) -> tuple[float, str]:
    """Returns (score 0–1, reason). Judge uses 1–5 integer scale normalized to 0–1."""
    from langchain_core.messages import HumanMessage
    import re
    prompt = CORRECTNESS_PROMPT.format(ground_truth=ground_truth, answer=answer)
    try:
        resp = judge_llm.invoke([HumanMessage(content=prompt)])
        raw = resp.content.strip()
        # extract JSON block
        start = raw.find("{")
        end = raw.rfind("}") + 1
        if start == -1:
            raise ValueError(f"No JSON in response: {raw[:120]}")
        parsed = json.loads(raw[start:end])
        raw_score = float(parsed["score"])
        # normalize 1–5 β†’ 0–1; if model returns 0–1 directly, keep as-is
        score = (raw_score - 1) / 4 if raw_score > 1 else raw_score
        score = max(0.0, min(1.0, score))
        return round(score, 4), parsed.get("reason", "")
    except json.JSONDecodeError:
        # fallback: find first integer 1–5 in response
        m = re.search(r'\b([1-5])\b', raw)
        if m:
            raw_score = int(m.group(1))
            score = (raw_score - 1) / 4
            return round(score, 4), f"fallback parse from: {raw[:80]}"
        print(f"    correctness judge parse failed: {raw[:120]}")
        return None, f"parse error: {raw[:80]}"
    except Exception as e:
        print(f"    correctness judge error: {e}")
        return None, f"error: {e}"


def _compute_percentile(values: list[float], p: int) -> int:
    if not values:
        return 0
    return int(np.percentile(values, p))


def _ingest_contextual(data_dir: str, ctx_model: str) -> None:
    """Clear eval_ctx ChromaDB collection and re-ingest with LLM context prefixes."""
    import chromadb
    from server.ingest import load_documents, chunk_documents, contextualize_chunks, embed_and_store

    client = chromadb.PersistentClient(path="./chroma_db")
    try:
        client.delete_collection("eval_ctx")
        print("  Cleared existing eval_ctx collection.")
    except Exception:
        pass

    documents = load_documents(data_dir)
    chunks = chunk_documents(documents)
    print(f"  Contextualizing {len(chunks)} chunks with {ctx_model}...")
    chunks = contextualize_chunks(chunks, documents, model=ctx_model, sleep_between_calls=0.1)
    embed_and_store(chunks, collection_name="eval_ctx")
    print(f"  Done. eval_ctx collection ready ({len(chunks)} chunks).")


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--version", required=True, help="Version tag e.g. v2.1")
    parser.add_argument("--tag", required=True, help="Short description e.g. 'HyDE enabled'")
    parser.add_argument("--n", type=int, default=50, help="Number of eval pairs (default 50)")
    parser.add_argument("--workspace", default="default", help="ChromaDB workspace collection name")
    parser.add_argument("--judge-model", default="openai/gpt-oss-20b",
                        help="Groq judge model for correctness + RAGAS (default: openai/gpt-oss-20b)")
    parser.add_argument("--contextual", action="store_true",
                        help="Ingest corpus with LLM context prefixes into eval_ctx before eval")
    parser.add_argument("--data-dir", default="data/raw",
                        help="Source documents dir for --contextual ingest (default: data/raw)")
    args = parser.parse_args()

    print(f"=== Prism Eval β€” {args.version} | {args.tag} ===\n")

    missing = [k for k in ("GROQ_API_KEY", "EURON_API_KEY") if not os.getenv(k)]
    if missing:
        print(f"ERROR: Missing env vars: {', '.join(missing)}")
        sys.exit(1)

    try:
        from ragas import evaluate, EvaluationDataset, SingleTurnSample, RunConfig
        from ragas.metrics import AnswerRelevancy, ContextRecall
        from ragas.llms import LangchainLLMWrapper
        from ragas.embeddings import LangchainEmbeddingsWrapper
        from langchain_openai import OpenAIEmbeddings as _OAIEmb
    except ImportError as e:
        print(f"ERROR: {e}\nInstall: pip install 'ragas>=0.2.0,<0.3.0' datasets")
        sys.exit(1)

    from server.utils import load_config
    from server.eval.precision import compute_precision_at_k

    config = load_config()
    workspace_id = "eval_ctx" if args.contextual else args.workspace

    if args.contextual:
        ctx_cfg = config.get("contextual_retrieval", {})
        ctx_model = ctx_cfg.get("model", args.judge_model)
        print(f"Contextual retrieval: ingesting {args.data_dir} β†’ eval_ctx ({ctx_model})...")
        _ingest_contextual(args.data_dir, ctx_model=ctx_model)

    retriever = _build_retriever(config, workspace_id=workspace_id)
    answer_llm = _make_llm(config["llm"]["model"], temperature=0.1, max_tokens=500)
    judge_llm = _make_llm(args.judge_model, temperature=0.0, max_tokens=200)

    ragas_llm = LangchainLLMWrapper(_make_llm(args.judge_model, temperature=0.0, max_tokens=2048))
    ragas_emb = LangchainEmbeddingsWrapper(
        _OAIEmb(
            model="text-embedding-3-small",
            openai_api_key=os.getenv("EURON_API_KEY", ""),
            openai_api_base="https://api.euron.one/api/v1/euri",
        )
    )

    with open(EVAL_PAIRS_PATH) as f:
        all_pairs = json.load(f)
    pairs = all_pairs[:args.n]
    print(f"Evaluating {len(pairs)} pairs with judge={args.judge_model}\n")

    per_query = []
    ragas_samples = []
    latencies = []

    for i, pair in enumerate(pairs):
        query = pair["query"]
        ground_truth = pair.get("ground_truth", "")
        print(f"[{i+1:02d}/{len(pairs)}] {query[:70]}")

        try:
            t0 = time.time()
            answer, lc_docs, chunk_dicts = _answer_query(answer_llm, retriever, query)
            latency_ms = int((time.time() - t0) * 1000)
            latencies.append(latency_ms)

            correctness, correctness_reason = _score_correctness(judge_llm, answer, ground_truth)
            precision = compute_precision_at_k(query, chunk_dicts, pair, k=5)

            print(f"       correctness={correctness:.2f} p@5={precision:.2f} latency={latency_ms}ms")

            ragas_samples.append(SingleTurnSample(
                user_input=query,
                response=answer,
                retrieved_contexts=[d.page_content for d in lc_docs],
                reference=ground_truth,
            ))

            per_query.append({
                "query": query,
                "answer": answer,
                "ground_truth": ground_truth,
                "answer_correctness": round(correctness, 4) if correctness is not None else None,
                "correctness_reason": correctness_reason,
                "answer_relevancy": None,   # filled after RAGAS run
                "context_recall": None,     # filled after RAGAS run
                "precision_at_5": precision,
                "latency_ms": latency_ms,
                "retrieved_sources": [c["source"] for c in chunk_dicts],
            })

        except Exception as e:
            print(f"       SKIP: {e}")

    # RAGAS batch eval β€” max_workers=2 to avoid Groq free-tier rate limit timeouts
    print(f"\nRunning RAGAS on {len(ragas_samples)} samples (answer_relevancy + context_recall)...")
    print(f"Using max_workers=1 to avoid Groq rate limits β€” will take ~{len(ragas_samples) // 2}-{len(ragas_samples)} min for {len(ragas_samples)} samples")
    dataset = EvaluationDataset(samples=ragas_samples)
    ragas_cfg = RunConfig(timeout=180, max_workers=1, max_retries=10)
    results = evaluate(
        dataset=dataset,
        metrics=[
            AnswerRelevancy(llm=ragas_llm, embeddings=ragas_emb),
            ContextRecall(llm=ragas_llm),
        ],
        run_config=ragas_cfg,
    )
    scores_df = results.to_pandas()
    print(f"RAGAS columns: {list(scores_df.columns)}")

    def _safe_float(val):
        """Return float or None β€” never NaN."""
        try:
            f = float(val)
            return None if math.isnan(f) else round(f, 4)
        except (TypeError, ValueError):
            return None

    # patch per_query with RAGAS per-sample scores
    ragas_idx = 0
    for item in per_query:
        if ragas_idx < len(scores_df):
            row = scores_df.iloc[ragas_idx]
            item["answer_relevancy"] = _safe_float(row.get("answer_relevancy"))
            item["context_recall"] = _safe_float(row.get("context_recall"))
            ragas_idx += 1

    def safe_mean(key):
        vals = [r[key] for r in per_query if r.get(key) is not None]
        return round(float(np.mean(vals)), 4) if vals else None

    metrics = {
        "answer_correctness": safe_mean("answer_correctness"),
        "answer_relevancy": safe_mean("answer_relevancy"),
        "context_recall": safe_mean("context_recall"),
        "precision_at_5": safe_mean("precision_at_5"),
        "latency_p50_ms": _compute_percentile(latencies, 50),
        "latency_p95_ms": _compute_percentile(latencies, 95),
        "latency_p99_ms": _compute_percentile(latencies, 99),
    }

    retrieval_cfg = config.get("retrieval", {})
    run_data = {
        "version": args.version,
        "tag": args.tag,
        "computed_at": datetime.now().isoformat(timespec="seconds"),
        "sample_count": len(per_query),
        "config": {
            "hyde_enabled": retrieval_cfg.get("hyde_enabled", False),
            "multi_query_enabled": retrieval_cfg.get("multi_query_enabled", False),
            "contextual_retrieval": args.contextual,
            "retrieve_k": retrieval_cfg.get("retrieve_k", 10),
            "rerank_k": retrieval_cfg.get("rerank_k", 5),
            "llm": config.get("llm", {}).get("model", "unknown"),
            "judge_model": args.judge_model,
            "workspace": workspace_id,
        },
        "metrics": metrics,
        "per_query": per_query,
    }

    # Write run file
    DASHBOARD_RUNS_DIR.mkdir(parents=True, exist_ok=True)
    run_filename = f"{args.version}_{date.today().strftime('%Y%m%d')}.json"
    run_path = DASHBOARD_RUNS_DIR / run_filename
    with open(run_path, "w") as f:
        json.dump(run_data, f, indent=2)
    print(f"\nRun written to: {run_path}")

    # Update index
    if DASHBOARD_INDEX_PATH.exists():
        with open(DASHBOARD_INDEX_PATH) as f:
            index = json.load(f)
    else:
        index = []

    # Replace existing entry for same version, else append
    entry = {"version": args.version, "tag": args.tag, "date": str(date.today()), "file": run_filename}
    index = [e for e in index if e["version"] != args.version]
    index.append(entry)
    index.sort(key=lambda e: e["date"])

    with open(DASHBOARD_INDEX_PATH, "w") as f:
        json.dump(index, f, indent=2)
    print(f"Index updated: {DASHBOARD_INDEX_PATH}")

    print(f"\n=== Results β€” {args.version} ===")
    for k, v in metrics.items():
        print(f"  {k:25s}: {v}")
    print(f"\nCommit eval-dashboard/public/data/ to update the live dashboard.")


if __name__ == "__main__":
    main()