feedcomposer commited on
Commit
7cc493d
·
verified ·
1 Parent(s): 1b238e9

Upload folder using huggingface_hub

Browse files
batch_eval.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ KVL Batch Evaluator
3
+ Usage:
4
+ python batch_eval.py docs/ # evaluate all .md files in a directory
5
+ python batch_eval.py a.md b.md # evaluate specific files
6
+ python batch_eval.py docs/ --workers 4 # 4 documents in parallel
7
+ python batch_eval.py docs/ --out results/ # write reports to a specific folder
8
+ """
9
+
10
+ from __future__ import annotations
11
+ import os, sys, time, json, argparse
12
+ from pathlib import Path
13
+ from concurrent.futures import ThreadPoolExecutor, as_completed
14
+ from datetime import datetime
15
+
16
+ import anthropic
17
+ from dotenv import load_dotenv
18
+ from sentence_transformers import SentenceTransformer
19
+
20
+ from kvl import ingestor, scorer, report
21
+ from kvl.modules import novelty, retrieval, generation, attribution, demand
22
+ from kvl.config import model_meta
23
+
24
+ load_dotenv()
25
+
26
+
27
+ def bar(score, width=16):
28
+ filled = round(score / 100 * width)
29
+ return "█" * filled + "░" * (width - filled)
30
+
31
+
32
+ def evaluate_one(path: Path, client, embedder, quiet: bool = False) -> dict:
33
+ """Run the full KVL pipeline on a single file. Returns a result dict."""
34
+ t0 = time.time()
35
+
36
+ def log(msg):
37
+ if not quiet:
38
+ print(f" [{path.name}] {msg}")
39
+
40
+ try:
41
+ doc = ingestor.parse(path.read_text(encoding="utf-8"))
42
+ log(f"Ingested — {doc.word_count:,} words, {len(doc.chunks)} chunks")
43
+
44
+ module_results = {}
45
+ module_results["novelty"] = novelty.evaluate(client, doc, progress_cb=log)
46
+ module_results["retrieval"] = retrieval.evaluate(client, doc, embedder, progress_cb=log)
47
+ module_results["generation"] = generation.evaluate(client, doc, progress_cb=log)
48
+ module_results["attribution"] = attribution.evaluate(client, doc, module_results["generation"], embedder, progress_cb=log)
49
+ module_results["demand"] = demand.evaluate(client, doc, progress_cb=log)
50
+
51
+ dim_scores = {k: module_results[k]["score"] for k in module_results}
52
+ kvs_result = scorer.compute(dim_scores)
53
+ meta = model_meta(datetime.now().strftime("%Y-%m-%d %H:%M UTC"))
54
+
55
+ elapsed = round(time.time() - t0)
56
+ log(f"Done in {elapsed}s — KVS {kvs_result['kvs']}/100 ({kvs_result['classification']})")
57
+
58
+ return {
59
+ "file": str(path),
60
+ "title": doc.title,
61
+ "kvs": kvs_result["kvs"],
62
+ "classification": kvs_result["classification"],
63
+ "dim_scores": dim_scores,
64
+ "kvs_result": kvs_result,
65
+ "module_results": module_results,
66
+ "meta": meta,
67
+ "elapsed_s": elapsed,
68
+ "error": None,
69
+ }
70
+
71
+ except Exception as e:
72
+ log(f"ERROR: {e}")
73
+ return {"file": str(path), "title": path.stem, "error": str(e)}
74
+
75
+
76
+ def main():
77
+ parser = argparse.ArgumentParser(description="KVL Batch Evaluator")
78
+ parser.add_argument("inputs", nargs="+", help=".md files or directories")
79
+ parser.add_argument("--workers", type=int, default=3,
80
+ help="Number of documents to evaluate in parallel (default: 3)")
81
+ parser.add_argument("--out", default=None,
82
+ help="Output directory for reports (default: alongside each input file)")
83
+ parser.add_argument("--quiet", action="store_true",
84
+ help="Suppress per-document progress output")
85
+ parser.add_argument("--summary-only", action="store_true",
86
+ help="Print summary table only, skip writing individual reports")
87
+ args = parser.parse_args()
88
+
89
+ # Collect all .md files
90
+ paths: list[Path] = []
91
+ for inp in args.inputs:
92
+ p = Path(inp)
93
+ if p.is_dir():
94
+ paths.extend(sorted(p.glob("**/*.md")))
95
+ elif p.suffix == ".md" and p.exists():
96
+ paths.append(p)
97
+ else:
98
+ print(f"Warning: skipping {inp} (not a .md file or directory)")
99
+
100
+ if not paths:
101
+ print("No .md files found.")
102
+ sys.exit(1)
103
+
104
+ print(f"\n{'='*60}")
105
+ print(f"KVL Batch Evaluator — {len(paths)} document(s), {args.workers} worker(s)")
106
+ print(f"{'='*60}\n")
107
+
108
+ client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
109
+ print("Loading embedding model...")
110
+ embedder = SentenceTransformer("all-MiniLM-L6-v2")
111
+ print("Ready.\n")
112
+
113
+ out_dir = Path(args.out) if args.out else None
114
+ if out_dir:
115
+ out_dir.mkdir(parents=True, exist_ok=True)
116
+
117
+ results = []
118
+ batch_start = time.time()
119
+
120
+ with ThreadPoolExecutor(max_workers=args.workers) as pool:
121
+ futures = {
122
+ pool.submit(evaluate_one, p, client, embedder, args.quiet): p
123
+ for p in paths
124
+ }
125
+ for future in as_completed(futures):
126
+ result = future.result()
127
+ results.append(result)
128
+
129
+ # Write individual report immediately as each document finishes
130
+ if not args.summary_only and result.get("error") is None:
131
+ dest = out_dir or futures[future].parent
132
+ report_path = dest / (futures[future].stem + "_kvl_report.md")
133
+ rpt = report.generate(
134
+ result["title"],
135
+ result["kvs_result"],
136
+ result["module_results"],
137
+ result["meta"],
138
+ )
139
+ report_path.write_text(rpt, encoding="utf-8")
140
+ if not args.quiet:
141
+ print(f" Report → {report_path}\n")
142
+
143
+ batch_elapsed = round(time.time() - batch_start)
144
+
145
+ # ── Summary table ─────────────────────────────────────────────────────────
146
+ results.sort(key=lambda r: r.get("kvs", -1), reverse=True)
147
+
148
+ print(f"\n{'='*60}")
149
+ print(f"BATCH SUMMARY ({len(paths)} docs, {batch_elapsed}s total)")
150
+ print(f"{'='*60}")
151
+ print(f"{'Document':<30} {'KVS':>4} {'Bar':<16} {'Classification'}")
152
+ print("-" * 72)
153
+
154
+ for r in results:
155
+ if r.get("error"):
156
+ print(f"{'[ERROR] ' + Path(r['file']).name:<30} {r['error'][:40]}")
157
+ continue
158
+ name = Path(r["file"]).stem[:28]
159
+ kvs = r["kvs"]
160
+ cls = r["classification"]
161
+ print(f"{name:<30} {kvs:>4} {bar(kvs):<16} {cls}")
162
+
163
+ print()
164
+
165
+ # ── Per-dimension breakdown ───────────────────────────────────────────────
166
+ good = [r for r in results if not r.get("error")]
167
+ if good:
168
+ dims = ["novelty", "retrieval", "generation", "attribution", "demand"]
169
+ dim_labels = {
170
+ "novelty": "Knowledge Novelty (30%)",
171
+ "retrieval": "Retrieval Utility (20%)",
172
+ "generation": "Generation Utility (25%)",
173
+ "attribution": "Attribution (15%)",
174
+ "demand": "Demand Utility (10%)",
175
+ }
176
+ print("Dimension averages across all documents:")
177
+ for d in dims:
178
+ scores = [r["dim_scores"][d] for r in good]
179
+ avg = round(sum(scores) / len(scores))
180
+ print(f" {dim_labels[d]} avg {avg:>3}/100 {bar(avg)}")
181
+
182
+ # ── Save machine-readable summary ─────────────────────────────────────────
183
+ summary_dest = out_dir or Path(".")
184
+ summary_path = summary_dest / f"kvl_batch_summary_{datetime.now().strftime('%Y%m%d_%H%M')}.json"
185
+ summary_data = [
186
+ {
187
+ "file": r["file"],
188
+ "title": r.get("title"),
189
+ "kvs": r.get("kvs"),
190
+ "classification": r.get("classification"),
191
+ "dim_scores": r.get("dim_scores"),
192
+ "elapsed_s": r.get("elapsed_s"),
193
+ "error": r.get("error"),
194
+ }
195
+ for r in results
196
+ ]
197
+ summary_path.write_text(json.dumps(summary_data, indent=2), encoding="utf-8")
198
+ print(f"\nJSON summary → {summary_path}")
199
+ print(f"Total wall-clock time: {batch_elapsed}s "
200
+ f"(vs {sum(r.get('elapsed_s',0) for r in good)}s sequential)")
201
+
202
+
203
+ if __name__ == "__main__":
204
+ main()
kvl/modules/attribution.py CHANGED
@@ -45,45 +45,35 @@ def _semantic_overlap(answer: str, context: str, embedder) -> float:
45
  return float(np.dot(embs[0], embs[1]))
46
 
47
 
48
- def evaluate(client: anthropic.Anthropic, doc: Document, generation_results: dict, embedder, progress_cb=None) -> dict:
49
  """Return grounding score (0-100) using outputs from the generation module."""
 
 
50
  details_list = generation_results.get("details", [])
51
  if not details_list:
52
  return {"score": 50, "details": [], "summary": "No generation results to assess grounding."}
53
 
54
  context = " ".join(doc.raw.split()[:4000])
55
- results = []
56
- grounding_scores = []
57
-
58
- for i, item in enumerate(details_list):
59
- if progress_cb:
60
- progress_cb(f"Checking grounding for answer {i+1}/{len(details_list)}...")
61
 
 
62
  rag_answer = item.get("rag_answer", "")
63
  if not rag_answer:
64
- continue
65
-
66
  raw = _call_claude(client, _GROUNDING_PROMPT.format(context=context, answer=rag_answer))
67
  raw = raw.strip()
68
  if raw.startswith("```"):
69
  raw = "\n".join(raw.split("\n")[1:])
70
  raw = raw.rsplit("```", 1)[0]
71
-
72
  try:
73
  judgment = json.loads(raw)
74
  except json.JSONDecodeError:
75
  judgment = {"grounding_fraction": 0.5, "hallucination_detected": False, "reason": "Parse error."}
76
 
77
- # Combine LLM grounding fraction with semantic overlap
78
  llm_grounding = judgment.get("grounding_fraction", 0.5)
79
  semantic_sim = _semantic_overlap(rag_answer, context, embedder)
80
- # Penalise if hallucination detected
81
  hallucination_penalty = 0.2 if judgment.get("hallucination_detected", False) else 0.0
82
- combined = (0.7 * llm_grounding + 0.3 * semantic_sim) - hallucination_penalty
83
- combined = max(0.0, min(1.0, combined))
84
-
85
- grounding_scores.append(combined)
86
- results.append({
87
  "question": item.get("question", ""),
88
  "answer": rag_answer,
89
  "grounding_fraction": llm_grounding,
@@ -93,7 +83,16 @@ def evaluate(client: anthropic.Anthropic, doc: Document, generation_results: dic
93
  "ungrounded_claims": judgment.get("ungrounded_claims", []),
94
  "reason": judgment.get("reason", ""),
95
  "combined_score": round(combined, 3),
96
- })
 
 
 
 
 
 
 
 
 
97
 
98
  if not grounding_scores:
99
  return {"score": 50, "details": results, "summary": "No grounding assessments completed."}
 
45
  return float(np.dot(embs[0], embs[1]))
46
 
47
 
48
+ def evaluate(client: anthropic.Anthropic, doc: Document, generation_results: dict, embedder, progress_cb=None, max_workers: int = 6) -> dict:
49
  """Return grounding score (0-100) using outputs from the generation module."""
50
+ from concurrent.futures import ThreadPoolExecutor
51
+
52
  details_list = generation_results.get("details", [])
53
  if not details_list:
54
  return {"score": 50, "details": [], "summary": "No generation results to assess grounding."}
55
 
56
  context = " ".join(doc.raw.split()[:4000])
 
 
 
 
 
 
57
 
58
+ def _check_grounding(item):
59
  rag_answer = item.get("rag_answer", "")
60
  if not rag_answer:
61
+ return None
 
62
  raw = _call_claude(client, _GROUNDING_PROMPT.format(context=context, answer=rag_answer))
63
  raw = raw.strip()
64
  if raw.startswith("```"):
65
  raw = "\n".join(raw.split("\n")[1:])
66
  raw = raw.rsplit("```", 1)[0]
 
67
  try:
68
  judgment = json.loads(raw)
69
  except json.JSONDecodeError:
70
  judgment = {"grounding_fraction": 0.5, "hallucination_detected": False, "reason": "Parse error."}
71
 
 
72
  llm_grounding = judgment.get("grounding_fraction", 0.5)
73
  semantic_sim = _semantic_overlap(rag_answer, context, embedder)
 
74
  hallucination_penalty = 0.2 if judgment.get("hallucination_detected", False) else 0.0
75
+ combined = max(0.0, min(1.0, (0.7 * llm_grounding + 0.3 * semantic_sim) - hallucination_penalty))
76
+ return {
 
 
 
77
  "question": item.get("question", ""),
78
  "answer": rag_answer,
79
  "grounding_fraction": llm_grounding,
 
83
  "ungrounded_claims": judgment.get("ungrounded_claims", []),
84
  "reason": judgment.get("reason", ""),
85
  "combined_score": round(combined, 3),
86
+ }
87
+
88
+ if progress_cb:
89
+ progress_cb(f"Checking grounding for {len(details_list)} answers in parallel...")
90
+
91
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
92
+ raw_results = list(pool.map(_check_grounding, details_list))
93
+
94
+ results = [r for r in raw_results if r is not None]
95
+ grounding_scores = [r["combined_score"] for r in results]
96
 
97
  if not grounding_scores:
98
  return {"score": 50, "details": results, "summary": "No grounding assessments completed."}
kvl/modules/generation.py CHANGED
@@ -97,7 +97,7 @@ def _judge(client: anthropic.Anthropic, question: str, baseline: str, rag: str)
97
  return {"accuracy": 3, "completeness": 3, "specificity": 3, "improvement": 50, "reason": "Parse error."}
98
 
99
 
100
- def evaluate(client: anthropic.Anthropic, doc: Document, progress_cb=None) -> dict:
101
  """Return generation utility score (0-100) and per-question details."""
102
  if progress_cb:
103
  progress_cb("Generating evaluation questions...")
@@ -109,29 +109,30 @@ def evaluate(client: anthropic.Anthropic, doc: Document, progress_cb=None) -> di
109
  # Use the full document as RAG context (capped for token limits)
110
  context = " ".join(doc.raw.split()[:4000])
111
 
112
- results = []
113
- improvement_scores = []
114
-
115
- for i, question in enumerate(questions):
116
- if progress_cb:
117
- progress_cb(f"Evaluating question {i+1}/{len(questions)}: baseline vs RAG...")
118
-
119
  baseline = _baseline_answer(client, question)
120
  rag = _rag_answer(client, question, context)
121
  judgment = _judge(client, question, baseline, rag)
122
-
123
- improvement = judgment.get("improvement", 50)
124
- improvement_scores.append(improvement)
125
- results.append({
126
  "question": question,
127
  "baseline_answer": baseline,
128
  "rag_answer": rag,
129
  "accuracy": judgment.get("accuracy", 3),
130
  "completeness": judgment.get("completeness", 3),
131
  "specificity": judgment.get("specificity", 3),
132
- "improvement": improvement,
133
  "reason": judgment.get("reason", ""),
134
- })
 
 
 
 
 
 
 
 
 
135
 
136
  avg_improvement = sum(improvement_scores) / len(improvement_scores)
137
  score = round(avg_improvement)
 
97
  return {"accuracy": 3, "completeness": 3, "specificity": 3, "improvement": 50, "reason": "Parse error."}
98
 
99
 
100
+ def evaluate(client: anthropic.Anthropic, doc: Document, progress_cb=None, max_workers: int = 6) -> dict:
101
  """Return generation utility score (0-100) and per-question details."""
102
  if progress_cb:
103
  progress_cb("Generating evaluation questions...")
 
109
  # Use the full document as RAG context (capped for token limits)
110
  context = " ".join(doc.raw.split()[:4000])
111
 
112
+ def _evaluate_question(args):
113
+ client, question, context = args
 
 
 
 
 
114
  baseline = _baseline_answer(client, question)
115
  rag = _rag_answer(client, question, context)
116
  judgment = _judge(client, question, baseline, rag)
117
+ return {
 
 
 
118
  "question": question,
119
  "baseline_answer": baseline,
120
  "rag_answer": rag,
121
  "accuracy": judgment.get("accuracy", 3),
122
  "completeness": judgment.get("completeness", 3),
123
  "specificity": judgment.get("specificity", 3),
124
+ "improvement": judgment.get("improvement", 50),
125
  "reason": judgment.get("reason", ""),
126
+ }
127
+
128
+ if progress_cb:
129
+ progress_cb(f"Evaluating {len(questions)} questions in parallel (baseline vs RAG)...")
130
+
131
+ from concurrent.futures import ThreadPoolExecutor
132
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
133
+ results = list(pool.map(_evaluate_question, [(client, q, context) for q in questions]))
134
+
135
+ improvement_scores = [r["improvement"] for r in results]
136
 
137
  avg_improvement = sum(improvement_scores) / len(improvement_scores)
138
  score = round(avg_improvement)
kvl/modules/novelty.py CHANGED
@@ -89,8 +89,23 @@ def _judge_novelty(client: anthropic.Anthropic, claim: str, answer: str) -> dict
89
  return {"score": 0.5, "reason": "Could not parse judge response."}
90
 
91
 
92
- def evaluate(client: anthropic.Anthropic, doc: Document, progress_cb=None) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  """Return novelty score (0-100) and detailed results."""
 
 
94
  if progress_cb:
95
  progress_cb("Extracting factual claims from document...")
96
 
@@ -98,19 +113,11 @@ def evaluate(client: anthropic.Anthropic, doc: Document, progress_cb=None) -> di
98
  if not claims:
99
  return {"score": 50, "details": [], "summary": "Could not extract claims from document."}
100
 
101
- results = []
102
- for i, item in enumerate(claims):
103
- if progress_cb:
104
- progress_cb(f"Testing claim {i+1}/{len(claims)} against model knowledge...")
105
- answer = _closed_book_answer(client, item["question"])
106
- judgment = _judge_novelty(client, item["claim"], answer)
107
- results.append({
108
- "claim": item["claim"],
109
- "question": item["question"],
110
- "model_answer": answer,
111
- "known_score": judgment["score"],
112
- "reason": judgment.get("reason", ""),
113
- })
114
 
115
  avg_known = sum(r["known_score"] for r in results) / len(results)
116
  novelty_score = round((1 - avg_known) * 100)
 
89
  return {"score": 0.5, "reason": "Could not parse judge response."}
90
 
91
 
92
+ def _evaluate_claim(args):
93
+ client, item = args
94
+ answer = _closed_book_answer(client, item["question"])
95
+ judgment = _judge_novelty(client, item["claim"], answer)
96
+ return {
97
+ "claim": item["claim"],
98
+ "question": item["question"],
99
+ "model_answer": answer,
100
+ "known_score": judgment["score"],
101
+ "reason": judgment.get("reason", ""),
102
+ }
103
+
104
+
105
+ def evaluate(client: anthropic.Anthropic, doc: Document, progress_cb=None, max_workers: int = 6) -> dict:
106
  """Return novelty score (0-100) and detailed results."""
107
+ from concurrent.futures import ThreadPoolExecutor
108
+
109
  if progress_cb:
110
  progress_cb("Extracting factual claims from document...")
111
 
 
113
  if not claims:
114
  return {"score": 50, "details": [], "summary": "Could not extract claims from document."}
115
 
116
+ if progress_cb:
117
+ progress_cb(f"Testing {len(claims)} claims in parallel...")
118
+
119
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
120
+ results = list(pool.map(_evaluate_claim, [(client, item) for item in claims]))
 
 
 
 
 
 
 
 
121
 
122
  avg_known = sum(r["known_score"] for r in results) / len(results)
123
  novelty_score = round((1 - avg_known) * 100)