technophyle commited on
Commit
cc555b9
·
verified ·
1 Parent(s): 0405bbe

Sync from GitHub via hub-sync

Browse files
Files changed (3) hide show
  1. evals/run_eval.py +171 -712
  2. evals/sample_eval_set.json +229 -889
  3. src/rag_system.py +44 -10
evals/run_eval.py CHANGED
@@ -1,23 +1,11 @@
1
- """Evaluation harness for Code Compass RAG system.
2
-
3
- Computes 4 core metrics:
4
- - Hit rate @ top-5 (retrieval quality)
5
- - Grounded answer rate (citation accuracy)
6
- - LLM-as-judge faithfulness (Claude 3.5 Sonnet via RAGAS)
7
- - Query latency P95 (responsiveness)
8
- """
9
-
10
- import asyncio
11
  import json
12
  import os
13
- import sys
14
  import re
 
15
  import time
16
  from pathlib import Path
17
- from collections import Counter, defaultdict
18
  from statistics import mean
19
 
20
- import requests
21
  from dotenv import load_dotenv
22
 
23
  SERVER_ROOT = Path(__file__).resolve().parents[1]
@@ -26,770 +14,241 @@ if str(SERVER_ROOT) not in sys.path:
26
 
27
  load_dotenv(SERVER_ROOT / ".env")
28
 
29
- from src.bedrock_claude import create_bedrock_runtime_client, generate_bedrock_claude_text
30
- from src.embeddings import EmbeddingGenerator
31
-
32
 
33
- API_URL = os.getenv("CODEBASE_RAG_API_URL", "http://localhost:8000")
34
- REPO_ID = int(os.getenv("CODEBASE_RAG_REPO_ID", "1"))
35
- SESSION_ID = os.getenv("CODEBASE_RAG_SESSION_ID", "eval-session")
36
  TOP_K = int(os.getenv("CODEBASE_RAG_TOP_K", "8"))
37
- QUERY_TIMEOUT_SECONDS = int(os.getenv("CODEBASE_RAG_QUERY_TIMEOUT_SECONDS", "180"))
38
- QUERY_MAX_RETRIES = int(os.getenv("CODEBASE_RAG_QUERY_MAX_RETRIES", "5"))
39
- QUERY_RETRY_BASE_SECONDS = float(os.getenv("CODEBASE_RAG_QUERY_RETRY_BASE_SECONDS", "2"))
40
  EVAL_SET_PATH = Path(
41
- os.getenv(
42
- "CODEBASE_RAG_EVAL_SET",
43
- Path(__file__).with_name("sample_eval_set.json"),
44
- )
45
  )
 
 
46
 
47
 
48
  def log(message: str):
49
- """Log message to stderr with [eval] prefix."""
50
  print(f"[eval] {message}", file=sys.stderr, flush=True)
51
 
52
 
53
- def get_app_model_config():
54
- llm_provider = os.getenv("LLM_PROVIDER", "bedrock").lower()
55
- if llm_provider == "groq":
56
- llm_model = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
57
- elif llm_provider == "bedrock":
58
- llm_model = os.getenv(
59
- "BEDROCK_LLM_MODEL",
60
- "anthropic.claude-3-5-sonnet-20240620-v1:0",
61
- )
62
- elif llm_provider == "vertex_ai":
63
- llm_model = os.getenv("VERTEX_LLM_MODEL", "claude-3-5-sonnet@20240620")
64
- else:
65
- llm_model = "unknown"
66
-
67
- embedding_provider = os.getenv("EMBEDDING_PROVIDER", "auto").lower()
68
- if embedding_provider == "bedrock":
69
- embedding_model = os.getenv("BEDROCK_EMBEDDING_MODEL", "cohere.embed-v3:0")
70
- elif embedding_provider == "vertex_ai":
71
- embedding_model = os.getenv("VERTEX_EMBEDDING_MODEL", "gemini-embedding-001")
72
- elif embedding_provider == "openai":
73
- embedding_model = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
74
- elif embedding_provider == "local":
75
- embedding_model = os.getenv("EMBEDDING_MODEL") or os.getenv(
76
- "LOCAL_EMBEDDING_MODEL", "nomic-ai/CodeRankEmbed"
77
- )
78
- else:
79
- embedding_model = os.getenv("EMBEDDING_MODEL") or "auto"
80
-
81
- eval_model = os.getenv(
82
- "EVAL_MODEL",
83
- os.getenv("BEDROCK_EVAL_MODEL", "anthropic.claude-3-5-sonnet-20240620-v1:0"),
84
- )
85
- return {
86
- "llm_provider": llm_provider,
87
- "llm_model": llm_model,
88
- "embedding_provider": embedding_provider,
89
- "embedding_model": embedding_model,
90
- "eval_model": eval_model,
91
- }
92
-
93
-
94
- def load_eval_rows():
95
  return json.loads(EVAL_SET_PATH.read_text())
96
 
97
 
98
- def post_query(row):
99
- payload = {
100
- "repo_id": REPO_ID,
101
- "question": row["question"],
102
- "top_k": TOP_K,
103
- "history": row.get("turns", []),
104
- }
105
- case_id = row.get("id", row["question"])
106
-
107
- for attempt in range(1, QUERY_MAX_RETRIES + 1):
108
- response = requests.post(
109
- f"{API_URL}/api/query",
110
- json=payload,
111
- headers={"X-Session-Id": SESSION_ID},
112
- timeout=QUERY_TIMEOUT_SECONDS,
113
- )
114
- if response.ok:
115
- return response.json()
116
-
117
- detail = response.text
118
- try:
119
- parsed = response.json()
120
- detail = parsed.get("detail") or parsed
121
- except Exception:
122
- pass
123
-
124
- detail_text = str(detail)
125
- is_retryable = response.status_code in {429, 500, 502, 503, 504} and any(
126
- marker in detail_text
127
- for marker in [
128
- "ThrottlingException",
129
- "throttled",
130
- "Too many requests",
131
- "timed out",
132
- "timeout",
133
- "ServiceUnavailable",
134
- "temporarily unavailable",
135
- ]
136
- )
137
- if is_retryable and attempt < QUERY_MAX_RETRIES:
138
- retry_after = response.headers.get("Retry-After")
139
- try:
140
- wait_seconds = (
141
- float(retry_after)
142
- if retry_after
143
- else QUERY_RETRY_BASE_SECONDS * (2 ** (attempt - 1))
144
- )
145
- except ValueError:
146
- wait_seconds = QUERY_RETRY_BASE_SECONDS * (2 ** (attempt - 1))
147
- log(
148
- f"Retrying case {case_id} after transient query failure "
149
- f"(attempt {attempt}/{QUERY_MAX_RETRIES}, wait={wait_seconds:.1f}s): {detail_text}"
150
- )
151
- time.sleep(wait_seconds)
152
- continue
153
-
154
- raise RuntimeError(
155
- f"Query failed for eval case {case_id!r} "
156
- f"with status {response.status_code}: {detail}"
157
- )
158
-
159
- raise RuntimeError(f"Query failed for eval case {case_id!r}: exhausted retries")
160
 
161
 
162
  def normalize_path(path: str) -> str:
163
  return path.strip().lstrip("./").lower()
164
 
165
 
166
- STOPWORDS = {
167
- "a",
168
- "an",
169
- "and",
170
- "are",
171
- "as",
172
- "at",
173
- "be",
174
- "by",
175
- "for",
176
- "from",
177
- "how",
178
- "in",
179
- "into",
180
- "is",
181
- "it",
182
- "its",
183
- "of",
184
- "on",
185
- "or",
186
- "that",
187
- "the",
188
- "their",
189
- "this",
190
- "to",
191
- "what",
192
- "when",
193
- "where",
194
- "which",
195
- "with",
196
- }
197
-
198
-
199
  def tokenize_text(text: str):
200
- tokens = []
201
- for raw_token in re.findall(r"[A-Za-z0-9_./+-]+", text or ""):
202
- token = raw_token.lower()
203
- tokens.append(token)
204
 
205
- camel_parts = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", raw_token).split()
206
- split_parts = re.split(r"[._/+-]+", token)
207
- for part in [*camel_parts, *split_parts]:
208
- normalized = part.strip().lower()
209
- if normalized and normalized != token:
210
- tokens.append(normalized)
211
 
212
- return tokens
213
-
214
-
215
- def normalize_keywords(keywords):
216
- normalized = []
217
- seen = set()
218
- for keyword in keywords or []:
219
- phrase = " ".join(tokenize_text(str(keyword)))
220
- if not phrase or phrase in seen:
221
- continue
222
- seen.add(phrase)
223
- normalized.append(phrase)
224
- return normalized
225
 
226
 
227
  def compute_retrieval_metrics(expected_sources, actual_sources):
228
- """Compute retrieval metrics: hit rate and top-1 hit for given rank k."""
229
- expected = {normalize_path(path) for path in expected_sources}
230
- actual = [normalize_path(path) for path in actual_sources]
231
-
232
- def matches_expected(actual_path: str) -> bool:
233
- for expected_path in expected:
234
- expected_is_directory = (
235
- expected_path.endswith("/")
236
- or "." not in expected_path.rsplit("/", 1)[-1]
237
- )
238
- normalized_expected = expected_path.rstrip("/")
239
- if actual_path == expected_path:
240
- return True
241
- if expected_is_directory and actual_path.startswith(normalized_expected + "/"):
242
- return True
243
- return False
244
-
245
- # Hit rate: was any retrieved source relevant?
246
- hit = 1 if any(matches_expected(path) for path in actual) else 0
247
-
248
- # Top-1 hit: was the first retrieved source relevant?
249
- top1_hit = 1 if actual and matches_expected(actual[0]) else 0
250
 
251
- return {
252
- "retrieval_hit": hit,
253
- "top1_hit": top1_hit,
254
- }
255
 
256
-
257
- def keyword_match_details(row, answer: str):
258
- keywords = normalize_keywords(row.get("must_include_any", []))
259
  if not keywords:
260
- return None
261
-
262
- answer_tokens = tokenize_text(answer)
263
- if not answer_tokens:
264
- return {
265
- "coverage": 0.0,
266
- "matched_count": 0,
267
- "total_keywords": len(keywords),
268
- "matched_keywords": [],
269
- "missing_keywords": keywords,
270
- }
271
-
272
- matched_keywords = []
273
  for keyword in keywords:
274
- keyword_tokens = keyword.split()
275
- window = len(keyword_tokens)
276
- if window == 1:
277
- if keyword_tokens[0] in answer_tokens:
278
- matched_keywords.append(keyword)
279
- continue
280
-
281
- def answer_length_metrics(answer: str):
282
- """Check if answer has substantive content."""
283
- tokens = tokenize_text(answer)
284
- return {
285
- "answer_word_count": len(tokens),
286
- "has_substantive_answer": 1 if len(tokens) >= 40 else 0,
287
- }
288
-
289
-
290
- def validate_eval_rows(rows):
291
- errors = []
292
- warnings = []
293
- category_counts = Counter()
294
- id_counts = Counter()
295
- id_prefix_counts = Counter()
296
- expected_source_counts = []
297
- keyword_counts = []
298
- conversation_cases = 0
299
- benchmark_scope = {
300
- "type": "mixed_or_unknown",
301
- "dominant_prefix": None,
302
- "dominant_prefix_fraction": 0.0,
303
- }
304
 
305
- for index, row in enumerate(rows, start=1):
306
- row_id = row.get("id") or f"row-{index}"
307
- id_counts[row_id] += 1
308
- prefix = row_id.split("-", 1)[0].lower()
309
- if prefix:
310
- id_prefix_counts[prefix] += 1
311
- category_counts[row.get("category", "general")] += 1
312
-
313
- question = str(row.get("question", "")).strip()
314
- ground_truth = str(row.get("ground_truth", "")).strip()
315
- expected_sources = row.get("expected_sources", [])
316
- must_include_any = row.get("must_include_any", [])
317
-
318
- if not question:
319
- errors.append(f"{row_id}: missing question")
320
- if not ground_truth:
321
- errors.append(f"{row_id}: missing ground_truth")
322
- if not isinstance(expected_sources, list) or not expected_sources:
323
- errors.append(f"{row_id}: expected_sources must be a non-empty list")
324
- if must_include_any and not isinstance(must_include_any, list):
325
- errors.append(f"{row_id}: must_include_any must be a list when present")
326
- if isinstance(must_include_any, list):
327
- normalized_keywords = normalize_keywords(must_include_any)
328
- if len(normalized_keywords) != len([keyword for keyword in must_include_any if str(keyword).strip()]):
329
- warnings.append(
330
- f"{row_id}: duplicate or case-variant keywords were normalized; "
331
- "resume metrics are stricter than the raw checklist wording."
332
- )
333
- if row.get("turns"):
334
- conversation_cases += 1
335
- expected_source_counts.append(len(expected_sources) if isinstance(expected_sources, list) else 0)
336
- keyword_counts.append(len(must_include_any) if isinstance(must_include_any, list) else 0)
337
-
338
- duplicate_ids = sorted(row_id for row_id, count in id_counts.items() if count > 1)
339
- if duplicate_ids:
340
- errors.append(f"duplicate ids found: {', '.join(duplicate_ids)}")
341
-
342
- if len(rows) < 25:
343
- warnings.append(
344
- "Eval set has fewer than 25 cases. Good for iteration, but light for resume-grade benchmarking."
345
- )
346
- if len(category_counts) < 4:
347
- warnings.append("Eval set covers fewer than 4 categories, so breadth is limited.")
348
- if conversation_cases < 2:
349
- warnings.append("Eval set has very little multi-turn coverage.")
350
- if category_counts and min(category_counts.values()) < 2:
351
- sparse = sorted(category for category, count in category_counts.items() if count < 2)
352
- warnings.append(f"Some categories are underrepresented: {', '.join(sparse)}.")
353
-
354
- if id_prefix_counts:
355
- dominant_prefix, dominant_count = id_prefix_counts.most_common(1)[0]
356
- dominant_prefix_fraction = dominant_count / len(rows)
357
- if dominant_prefix_fraction >= 0.8:
358
- benchmark_scope = {
359
- "type": "single_repository",
360
- "dominant_prefix": dominant_prefix,
361
- "dominant_prefix_fraction": round(dominant_prefix_fraction, 4),
362
- }
363
 
364
- return {
365
- "case_count": len(rows),
366
- "category_counts": dict(sorted(category_counts.items())),
367
- "conversation_case_count": conversation_cases,
368
- "average_expected_sources": round(mean(expected_source_counts), 2) if expected_source_counts else 0.0,
369
- "average_keywords_per_case": round(mean(keyword_counts), 2) if keyword_counts else 0.0,
370
- "benchmark_scope": benchmark_scope,
371
- "errors": errors,
372
- "warnings": warnings,
373
- "is_valid": not errors,
374
- }
375
-
376
-
377
- def summarize_custom_metrics(details, latency_p95=None):
378
- """Compute only the 4 core metrics: hit rate @ top-5, grounded answer rate, faithfulness, latency P95."""
379
- # Grounded answer: retrieval hit AND has substantive answer AND no failed keyword checks
380
- grounded_answer_passes = [
381
- 1
382
- for item in details
383
- if item["retrieval_hit"] == 1
384
- and item["has_substantive_answer"] == 1
385
- ]
386
- return {
387
- "retrieval_hit_rate": round(mean(item["retrieval_hit"] for item in details), 4),
388
- "top1_hit_rate": round(mean(item["top1_hit"] for item in details), 4),
389
- "grounded_answer_rate": round(sum(grounded_answer_passes) / len(details), 4) if details else 0.0,
390
- "latency_p95_ms": round(latency_p95, 2) if latency_p95 is not None else None,
391
- }
392
 
393
 
394
- def summarize_by_category(details):
395
- """Summarize metrics by category using only the 4 core metrics."""
396
- grouped = defaultdict(list)
397
- for item in details:
398
- grouped[item["category"]].append(item)
399
-
400
- summary = {}
401
- for category, items in sorted(grouped.items()):
402
- summary[category] = {
403
- "case_count": len(items),
404
- "retrieval_hit_rate": round(mean(item["retrieval_hit"] for item in items), 4),
405
- "top1_hit_rate": round(mean(item["top1_hit"] for item in items), 4),
406
- "grounded_answer_rate": round(
407
- mean(
408
- 1
409
- if item["retrieval_hit"] == 1 and item["has_substantive_answer"] == 1
410
- else 0
411
- for item in items
412
- ),
413
- 4,
414
- ),
415
- }
416
- return summary
417
-
418
-
419
- def build_headline_metrics(custom_metrics, audit):
420
- """Build headline metrics section with only the 4 core metrics."""
421
- return {
422
- "sample_size": audit["case_count"],
423
- "category_count": len(audit["category_counts"]),
424
- "retrieval_hit_rate": custom_metrics["retrieval_hit_rate"],
425
- "top1_hit_rate": custom_metrics["top1_hit_rate"],
426
- "grounded_answer_rate": custom_metrics["grounded_answer_rate"],
427
- "latency_p95_ms": custom_metrics["latency_p95_ms"],
428
- }
429
 
430
 
431
- def build_metric_guidance(custom_metrics, ragas_report):
432
- """Build guidance using only the 4 core metrics."""
433
- # Primary gate: retrieval hit rate >= 80%
434
- retrieval_gate_pass = custom_metrics["retrieval_hit_rate"] >= 0.8
 
 
 
 
 
 
435
 
436
- next_focus = []
437
- if custom_metrics["grounded_answer_rate"] < 0.75:
438
- next_focus.append("Tighten answer grounding to ensure answers cite sources.")
439
- if custom_metrics["latency_p95_ms"] and custom_metrics["latency_p95_ms"] > 5000:
440
- next_focus.append("Optimize query latency for better responsiveness.")
 
441
 
442
  return {
443
- "primary_gate": "pass" if retrieval_gate_pass else "needs_work",
444
- "primary_gate_basis": "retrieval_hit_rate",
445
- "next_focus": next_focus,
 
 
 
 
 
 
 
 
 
 
446
  }
447
 
448
 
449
- def build_resume_summary(custom_metrics, audit, ragas_report, ragas_error):
450
- """Build resume summary using only the 4 core metrics: hit rate top-5, grounded answer rate, faithfulness, latency."""
451
- lines = [
452
- (
453
- f"Evaluated on {audit['case_count']} repo-QA cases across "
454
- f"{len(audit['category_counts'])} categories."
455
- ),
456
- (
457
- f"Retrieval hit rate @ top-5: {custom_metrics['retrieval_hit_rate']:.1%}, "
458
- f"top-1 hit rate: {custom_metrics['top1_hit_rate']:.1%}."
459
- ),
460
- (
461
- f"Grounded answer rate: {custom_metrics['grounded_answer_rate']:.1%}."
462
- ),
463
- ]
464
-
465
- if ragas_report and not ragas_error:
466
- lines.append(
467
- f"Faithfulness (Claude 3.5 Sonnet judge): {ragas_report.get('faithfulness', 0.0):.3f}."
468
- )
469
- else:
470
- lines.append("Faithfulness metrics skipped or unavailable.")
471
-
472
- if custom_metrics["latency_p95_ms"] is not None:
473
- lines.append(f"Query latency P95: {custom_metrics['latency_p95_ms']:.0f}ms.")
474
-
475
- scope = audit.get("benchmark_scope", {})
476
- if scope.get("type") == "single_repository":
477
- lines.append(
478
- "Benchmark scope: single-repository benchmark "
479
- f"({scope.get('dominant_prefix')}); use it to judge this target repo, not cross-repo generalization."
480
- )
481
-
482
- if audit["warnings"]:
483
- lines.append(
484
- "Benchmark caveat: "
485
- + " ".join(audit["warnings"][:2])
486
- )
487
-
488
- return " ".join(lines)
489
-
490
-
491
- def benchmark_readiness(audit, ragas_error, metric_guidance=None):
492
- reasons = []
493
- if audit["case_count"] < 25:
494
- reasons.append("small_sample")
495
- if len(audit["category_counts"]) < 4:
496
- reasons.append("limited_category_coverage")
497
- if audit["conversation_case_count"] < 2:
498
- reasons.append("limited_multi_turn_coverage")
499
- if audit["warnings"]:
500
- reasons.append("eval_set_warnings")
501
- if ragas_error not in {None, "disabled"}:
502
- reasons.append("ragas_instability")
503
- if metric_guidance and metric_guidance.get("primary_gate") != "pass":
504
- reasons.append("primary_gate_failed")
505
-
506
- if reasons:
507
- status = "single_repo_benchmark_needs_work"
508
- if audit.get("benchmark_scope", {}).get("type") != "single_repository":
509
- status = "internal_or_demo_benchmark"
510
- return {
511
- "status": status,
512
- "reasons": reasons,
513
- }
514
- if audit.get("benchmark_scope", {}).get("type") == "single_repository":
515
- return {
516
- "status": "single_repo_benchmark_ready",
517
- "reasons": [],
518
- }
519
  return {
520
- "status": "presentation_ready",
521
- "reasons": [],
 
 
 
 
522
  }
523
 
524
 
525
- def maybe_write_report(report):
526
- output_path = os.getenv("CODEBASE_RAG_EVAL_OUTPUT")
527
- if not output_path:
528
- return None
529
- target = Path(output_path)
530
- target.parent.mkdir(parents=True, exist_ok=True)
531
- target.write_text(json.dumps(report, indent=2))
532
- return str(target)
533
-
534
-
535
- def build_bedrock_ragas_llm(run_config):
536
- from langchain_core.outputs import Generation, LLMResult
537
- from ragas.llms.base import BaseRagasLLM
538
-
539
- class BedrockRagasLLM(BaseRagasLLM):
540
- def __init__(self, model: str, run_config):
541
- self.client = create_bedrock_runtime_client()
542
- self.model = model
543
- self.set_run_config(run_config)
544
-
545
- def _prompt_to_text(self, prompt):
546
- prefix = (
547
- "Return only valid JSON or the exact structured output requested by the prompt. "
548
- "Do not add markdown fences, explanations, or extra prose.\n\n"
549
- )
550
- if hasattr(prompt, "to_string"):
551
- return prefix + prompt.to_string()
552
- return prefix + str(prompt)
553
-
554
- def _generate_once(self, prompt, n=1, temperature=1e-8, stop=None, callbacks=None):
555
- prompt_text = self._prompt_to_text(prompt)
556
- text, _ = generate_bedrock_claude_text(
557
- self.client,
558
- self.model,
559
- "Return only valid JSON or the exact structured output requested.",
560
- prompt_text,
561
- max_tokens=int(os.getenv("EVAL_MAX_OUTPUT_TOKENS", "2048")),
562
- temperature=0.0,
563
- )
564
-
565
- generations = [Generation(text=text)] if text else []
566
-
567
- if not generations:
568
- raise RuntimeError("Bedrock Claude judge returned an empty response.")
569
-
570
- return LLMResult(generations=[generations])
571
-
572
- def generate_text(self, prompt, n=1, temperature=1e-8, stop=None, callbacks=None):
573
- return self._generate_once(
574
- prompt=prompt,
575
- n=n,
576
- temperature=temperature,
577
- stop=stop,
578
- callbacks=callbacks,
579
- )
580
-
581
- async def agenerate_text(self, prompt, n=1, temperature=1e-8, stop=None, callbacks=None):
582
- return await asyncio.to_thread(
583
- self._generate_once,
584
- prompt,
585
- n,
586
- temperature,
587
- stop,
588
- callbacks,
589
- )
590
-
591
- model = os.getenv(
592
- "EVAL_MODEL",
593
- os.getenv("BEDROCK_EVAL_MODEL", "anthropic.claude-3-5-sonnet-20240620-v1:0"),
594
- )
595
- return BedrockRagasLLM(model=model, run_config=run_config)
596
-
597
-
598
- def build_ragas_embeddings(run_config):
599
- from ragas.embeddings.base import BaseRagasEmbeddings
600
-
601
- class AppEmbeddingWrapper(BaseRagasEmbeddings):
602
- def __init__(self, generator, run_config):
603
- self.generator = generator
604
- self.set_run_config(run_config)
605
-
606
- def embed_query(self, text):
607
- return self.generator.embed_text(text).tolist()
608
-
609
- def embed_documents(self, texts):
610
- vectors = self.generator.embed_batch(list(texts))
611
- return vectors.tolist()
612
 
613
- async def aembed_query(self, text):
614
- return await asyncio.to_thread(self.embed_query, text)
615
 
616
- async def aembed_documents(self, texts):
617
- return await asyncio.to_thread(self.embed_documents, texts)
 
 
 
618
 
619
- return AppEmbeddingWrapper(EmbeddingGenerator(), run_config=run_config)
620
 
 
 
 
621
 
622
- def run_ragas(rows, outputs):
623
- if not ENABLE_RAGAS:
624
- log("RAGAS disabled via CODEBASE_RAG_ENABLE_RAGAS=0. Reporting custom metrics only.")
625
- return None, "disabled"
626
 
627
- try:
628
- from datasets import Dataset
629
- from ragas import evaluate
630
- from ragas.metrics import faithfulness
631
- from ragas.run_config import RunConfig
632
- except Exception as exc:
633
- log(f"Skipping RAGAS because the evaluation dependencies could not be loaded: {exc}")
634
- return None, f"import_error: {exc}"
635
-
636
- def build_ragas_dataset():
637
- samples = []
638
- for row, result in zip(rows, outputs):
639
- samples.append(
640
- {
641
- "question": row["question"],
642
- "answer": result["answer"],
643
- "contexts": [source["snippet"] for source in result.get("sources", [])],
644
- "ground_truth": row["ground_truth"],
645
- }
646
- )
647
- return Dataset.from_list(samples)
648
-
649
- log("Running RAGAS metrics. This can take a while.")
650
- try:
651
- timeout_seconds = int(os.getenv("EVAL_TIMEOUT_SECONDS", "180"))
652
- thread_timeout_seconds = float(os.getenv("EVAL_THREAD_TIMEOUT_SECONDS", str(max(timeout_seconds, 240))))
653
- max_workers = int(os.getenv("EVAL_MAX_WORKERS", "2"))
654
- run_config = RunConfig(
655
- timeout=timeout_seconds,
656
- thread_timeout=thread_timeout_seconds,
657
- max_workers=max_workers,
658
- max_retries=int(os.getenv("EVAL_MAX_RETRIES", "3")),
659
- max_wait=int(os.getenv("EVAL_MAX_WAIT_SECONDS", "60")),
660
- )
661
- log(
662
- "Using Bedrock for RAGAS judge model "
663
- f"({os.getenv('EVAL_MODEL', os.getenv('BEDROCK_EVAL_MODEL', 'anthropic.claude-3-5-sonnet-20240620-v1:0'))})"
664
- )
665
- log(
666
- f"RAGAS runtime: async={RAGAS_ASYNC}, raise_exceptions={RAGAS_RAISE_EXCEPTIONS}, "
667
- f"timeout={timeout_seconds}s, thread_timeout={thread_timeout_seconds}s, max_workers={max_workers}"
668
- )
669
- llm = build_bedrock_ragas_llm(run_config)
670
- embeddings = build_ragas_embeddings(run_config)
671
- # Only use faithfulness as the RAGAS metric (simplified to 4-core metrics)
672
- ragas_report = evaluate(
673
- build_ragas_dataset(),
674
- metrics=[faithfulness],
675
- llm=llm,
676
- embeddings=embeddings,
677
- run_config=run_config,
678
- is_async=RAGAS_ASYNC,
679
- raise_exceptions=RAGAS_RAISE_EXCEPTIONS,
680
- )
681
- return {key: float(value) for key, value in ragas_report.items()}, None
682
- except Exception as exc:
683
- log(f"RAGAS evaluation failed: {exc}")
684
- return None, str(exc)
685
 
 
 
686
 
687
- def run():
688
- log(f"Loading eval set from {EVAL_SET_PATH}")
689
- rows = load_eval_rows()
690
- audit = validate_eval_rows(rows)
691
- model_config = get_app_model_config()
692
- if audit["errors"]:
693
- raise RuntimeError("Eval set validation failed: " + "; ".join(audit["errors"]))
694
- for warning in audit["warnings"]:
695
- log(f"Eval set warning: {warning}")
696
- log(
697
- "Eval model config: "
698
- f"qna_provider={model_config['llm_provider']}, "
699
- f"qna_model={model_config['llm_model']}, "
700
- f"embedding_provider={model_config['embedding_provider']}, "
701
- f"embedding_model={model_config['embedding_model']}, "
702
- f"judge_model={model_config['eval_model']}"
703
- )
704
- log(
705
- f"Starting eval with api_url={API_URL}, repo_id={REPO_ID}, "
706
- f"session_id={SESSION_ID}, top_k={TOP_K}, cases={len(rows)}"
707
- )
708
- outputs = []
709
  details = []
710
- latencies = []
711
-
712
- for index, row in enumerate(rows, start=1):
713
- case_id = row.get("id", row["question"])
714
- log(f"[{index}/{len(rows)}] Querying case {case_id}")
715
- start_time = time.time()
716
- result = post_query(row)
717
- elapsed_ms = (time.time() - start_time) * 1000
718
- latencies.append(elapsed_ms)
719
- outputs.append(result)
720
- log(
721
- f"[{index}/{len(rows)}] Received answer for {case_id} "
722
- f"with {len(result.get('sources', []))} sources in {elapsed_ms:.0f}ms"
723
- )
724
-
725
- cited_paths = [source["file_path"] for source in result.get("sources", [])]
726
- metrics = compute_retrieval_metrics(row.get("expected_sources", []), cited_paths)
727
- length_metrics = answer_length_metrics(result.get("answer", ""))
728
-
729
- details.append(
730
- {
731
- "id": row.get("id", row["question"]),
732
- "category": row.get("category", "general"),
733
- "question": row["question"],
734
- "answer": result.get("answer", ""),
735
- "expected_sources": row.get("expected_sources", []),
736
- "retrieved_sources": cited_paths,
737
- "retrieval_hit": metrics["retrieval_hit"],
738
- "top1_hit": metrics["top1_hit"],
739
- **length_metrics,
740
- }
741
- )
742
-
743
- # Compute P95 latency
744
- latencies.sort()
745
- p95_index = int(len(latencies) * 0.95)
746
- latency_p95 = latencies[p95_index] if latencies else None
747
-
748
- log("Finished query loop. Computing aggregate metrics.")
749
- custom_metrics = summarize_custom_metrics(details, latency_p95)
750
- category_breakdown = summarize_by_category(details)
751
- ragas_report, ragas_error = run_ragas(rows, outputs)
752
- headline_metrics = build_headline_metrics(custom_metrics, audit)
753
- metric_guidance = build_metric_guidance(custom_metrics, ragas_report)
754
- resume_summary = build_resume_summary(custom_metrics, audit, ragas_report, ragas_error)
755
- readiness = benchmark_readiness(audit, ragas_error, metric_guidance)
756
 
757
  report = {
758
  "config": {
759
- "api_url": API_URL,
760
- "repo_id": REPO_ID,
761
- "session_id": SESSION_ID,
762
  "top_k": TOP_K,
763
- "qna_provider": model_config["llm_provider"],
764
- "qna_model": model_config["llm_model"],
765
- "embedding_provider": model_config["embedding_provider"],
766
- "embedding_model": model_config["embedding_model"],
767
- "eval_model": model_config["eval_model"],
768
- "query_timeout_seconds": QUERY_TIMEOUT_SECONDS,
769
- "query_max_retries": QUERY_MAX_RETRIES,
770
- "query_retry_base_seconds": QUERY_RETRY_BASE_SECONDS,
771
  "eval_set": str(EVAL_SET_PATH),
772
- "min_reference_overlap": MIN_REFERENCE_OVERLAP,
773
- "min_reference_term_matches": MIN_REFERENCE_TERM_MATCHES,
 
 
774
  },
775
- "eval_set_audit": audit,
776
- "headline_metrics": headline_metrics,
777
- "benchmark_readiness": readiness,
778
- "metric_guidance": metric_guidance,
779
- "ragas": ragas_report,
780
- "ragas_error": ragas_error,
781
- "custom_metrics": custom_metrics,
782
- "category_breakdown": category_breakdown,
783
- "resume_summary": resume_summary,
784
  "cases": details,
785
  }
786
- output_path = maybe_write_report(report)
787
- if output_path:
788
- log(f"Wrote JSON report to {output_path}")
 
 
 
789
 
790
  log("Eval complete. Printing JSON report.")
791
  print(json.dumps(report, indent=2))
792
 
793
 
794
  if __name__ == "__main__":
795
- run()
 
 
 
 
 
 
 
 
 
 
 
1
  import json
2
  import os
 
3
  import re
4
+ import sys
5
  import time
6
  from pathlib import Path
 
7
  from statistics import mean
8
 
 
9
  from dotenv import load_dotenv
10
 
11
  SERVER_ROOT = Path(__file__).resolve().parents[1]
 
14
 
15
  load_dotenv(SERVER_ROOT / ".env")
16
 
17
+ from src.rag_system import CodebaseRAGSystem
 
 
18
 
19
+ EVAL_SESSION_KEY = "eval-session"
 
 
20
  TOP_K = int(os.getenv("CODEBASE_RAG_TOP_K", "8"))
 
 
 
21
  EVAL_SET_PATH = Path(
22
+ os.getenv("CODEBASE_RAG_EVAL_SET", Path(__file__).with_name("sample_eval_set.json"))
 
 
 
23
  )
24
+ EVAL_OUTPUT_PATH = os.getenv("CODEBASE_RAG_EVAL_OUTPUT")
25
+ ENABLE_FAITHFULNESS = os.getenv("CODEBASE_RAG_ENABLE_FAITHFULNESS", "1") == "1"
26
 
27
 
28
  def log(message: str):
 
29
  print(f"[eval] {message}", file=sys.stderr, flush=True)
30
 
31
 
32
+ def load_eval_set():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  return json.loads(EVAL_SET_PATH.read_text())
34
 
35
 
36
+ def validate_eval_set(repositories):
37
+ errors = []
38
+ seen_ids = set()
39
+ for repo in repositories:
40
+ if not repo.get("github_url"):
41
+ errors.append(f"repo {repo.get('id', '?')}: missing github_url")
42
+ if not repo.get("cases"):
43
+ errors.append(f"repo {repo.get('id', '?')}: has no cases")
44
+ for case in repo.get("cases", []):
45
+ case_id = case.get("id") or case.get("question", "?")
46
+ if case_id in seen_ids:
47
+ errors.append(f"duplicate case id: {case_id}")
48
+ seen_ids.add(case_id)
49
+ if not case.get("question", "").strip():
50
+ errors.append(f"{case_id}: missing question")
51
+ if not case.get("ground_truth", "").strip():
52
+ errors.append(f"{case_id}: missing ground_truth")
53
+ if not case.get("expected_sources"):
54
+ errors.append(f"{case_id}: expected_sources must be a non-empty list")
55
+ return errors
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
 
58
  def normalize_path(path: str) -> str:
59
  return path.strip().lstrip("./").lower()
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  def tokenize_text(text: str):
63
+ return re.findall(r"[a-z0-9_]+", (text or "").lower())
 
 
 
64
 
 
 
 
 
 
 
65
 
66
+ def matches_expected(actual_path: str, expected_sources) -> bool:
67
+ actual = normalize_path(actual_path)
68
+ for expected in expected_sources:
69
+ expected_norm = normalize_path(expected).rstrip("/")
70
+ is_dir = "." not in expected_norm.rsplit("/", 1)[-1]
71
+ if actual == expected_norm:
72
+ return True
73
+ if is_dir and actual.startswith(expected_norm + "/"):
74
+ return True
75
+ return False
 
 
 
76
 
77
 
78
  def compute_retrieval_metrics(expected_sources, actual_sources):
79
+ hit = any(matches_expected(path, expected_sources) for path in actual_sources)
80
+ top1 = bool(actual_sources) and matches_expected(actual_sources[0], expected_sources)
81
+ return {"retrieval_hit": int(hit), "top1_hit": int(top1)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
 
 
 
 
83
 
84
+ def keyword_hits(answer: str, keywords):
 
 
85
  if not keywords:
86
+ return 0, 0
87
+ tokens = set(tokenize_text(answer))
88
+ matched = 0
 
 
 
 
 
 
 
 
 
 
89
  for keyword in keywords:
90
+ keyword_tokens = tokenize_text(keyword)
91
+ if keyword_tokens and all(token in tokens for token in keyword_tokens):
92
+ matched += 1
93
+ return matched, len(keywords)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
+ def judge_faithfulness(rag_system, question: str, answer: str, sources: list):
97
+ if not ENABLE_FAITHFULNESS or not answer.strip() or not sources:
98
+ return None
99
+ context = "\n\n".join(
100
+ f"[{i}] {source['file_path']}\n{source['snippet'][:800]}"
101
+ for i, source in enumerate(sources, start=1)
102
+ )
103
+ system_prompt = (
104
+ "You are a strict grading assistant. Given a question, retrieved code context, and a "
105
+ "generated answer, output ONLY a single number between 0 and 1 for how faithful the "
106
+ "answer is to the context (1.0 = every claim is supported, 0.0 = the answer invents or "
107
+ "contradicts facts not in the context). Output just the number."
108
+ )
109
+ user_prompt = f"Question: {question}\n\nContext:\n{context}\n\nAnswer:\n{answer}\n\nFaithfulness score:"
110
+ try:
111
+ text, _ = rag_system._generate_markdown_response(system_prompt, user_prompt)
112
+ match = re.search(r"(\d(?:\.\d+)?)", text)
113
+ if not match:
114
+ return None
115
+ return max(0.0, min(1.0, float(match.group(1))))
116
+ except Exception as exc:
117
+ log(f"Faithfulness judge failed: {exc}")
118
+ return None
 
 
 
 
 
119
 
120
 
121
+ def index_repo(rag_system, github_url: str, name: str):
122
+ repo = rag_system.create_or_reset_repository(github_url, EVAL_SESSION_KEY)
123
+ log(f"Indexing {name} ({github_url}), repo_id={repo.id}")
124
+ rag_system.index_repository(repo.id)
125
+ repo_state = rag_system.get_repository_for_session(repo.id, EVAL_SESSION_KEY)
126
+ if not repo_state or repo_state["status"] != "indexed":
127
+ detail = repo_state.get("error_message") if repo_state else "repository disappeared"
128
+ raise RuntimeError(f"Failed to index {name}: {detail}")
129
+ log(
130
+ f"Indexed {name}: {repo_state['file_count']} files, "
131
+ f"{repo_state['chunk_count']} chunks"
132
+ )
133
+ return repo.id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
 
136
+ def run_case(rag_system, repo_id: int, repo_name: str, case: dict):
137
+ start = time.time()
138
+ result = rag_system.answer_question(
139
+ repo_id=repo_id,
140
+ session_key=EVAL_SESSION_KEY,
141
+ question=case["question"],
142
+ top_k=TOP_K,
143
+ history=case.get("turns", []),
144
+ )
145
+ elapsed_ms = (time.time() - start) * 1000
146
 
147
+ sources = result.get("sources", [])
148
+ cited_paths = [source["file_path"] for source in sources]
149
+ retrieval = compute_retrieval_metrics(case.get("expected_sources", []), cited_paths)
150
+ matched, total_keywords = keyword_hits(result.get("answer", ""), case.get("must_include_any", []))
151
+ has_citations = bool(result.get("citations"))
152
+ grounded = retrieval["retrieval_hit"] == 1 and has_citations and (total_keywords == 0 or matched > 0)
153
 
154
  return {
155
+ "id": case.get("id", case["question"]),
156
+ "repo": repo_name,
157
+ "category": case.get("category", "general"),
158
+ "question": case["question"],
159
+ "answer": result.get("answer", ""),
160
+ "citations": result.get("citations", []),
161
+ "expected_sources": case.get("expected_sources", []),
162
+ "retrieved_sources": cited_paths,
163
+ "retrieval_hit": retrieval["retrieval_hit"],
164
+ "top1_hit": retrieval["top1_hit"],
165
+ "grounded": int(grounded),
166
+ "faithfulness": judge_faithfulness(rag_system, case["question"], result.get("answer", ""), sources),
167
+ "latency_ms": round(elapsed_ms, 1),
168
  }
169
 
170
 
171
+ def summarize(details):
172
+ if not details:
173
+ return {}
174
+ latencies = sorted(item["latency_ms"] for item in details)
175
+ p95_index = min(len(latencies) - 1, int(len(latencies) * 0.95))
176
+ faith_scores = [item["faithfulness"] for item in details if item["faithfulness"] is not None]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  return {
178
+ "case_count": len(details),
179
+ "retrieval_hit_rate": round(mean(item["retrieval_hit"] for item in details), 4),
180
+ "top1_hit_rate": round(mean(item["top1_hit"] for item in details), 4),
181
+ "grounded_answer_rate": round(mean(item["grounded"] for item in details), 4),
182
+ "faithfulness": round(mean(faith_scores), 4) if faith_scores else None,
183
+ "latency_p95_ms": round(latencies[p95_index], 1),
184
  }
185
 
186
 
187
+ def summarize_by_repo(details):
188
+ grouped = {}
189
+ for item in details:
190
+ grouped.setdefault(item["repo"], []).append(item)
191
+ return {repo: summarize(items) for repo, items in grouped.items()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
 
 
193
 
194
+ def summarize_by_category(details):
195
+ grouped = {}
196
+ for item in details:
197
+ grouped.setdefault(item["category"], []).append(item)
198
+ return {category: summarize(items) for category, items in sorted(grouped.items())}
199
 
 
200
 
201
+ def run():
202
+ eval_set = load_eval_set()
203
+ repositories = eval_set["repositories"]
204
 
205
+ errors = validate_eval_set(repositories)
206
+ if errors:
207
+ raise RuntimeError("Eval set validation failed: " + "; ".join(errors))
 
208
 
209
+ total_cases = sum(len(repo["cases"]) for repo in repositories)
210
+ log(f"Loaded eval set: {len(repositories)} repositories, {total_cases} cases")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
+ rag_system = CodebaseRAGSystem()
213
+ log(f"LLM provider={rag_system.llm_provider} model={rag_system.llm_model}")
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  details = []
216
+ try:
217
+ for repo_config in repositories:
218
+ repo_id = index_repo(rag_system, repo_config["github_url"], repo_config["name"])
219
+ cases = repo_config["cases"]
220
+ for index, case in enumerate(cases, start=1):
221
+ log(f"[{repo_config['id']} {index}/{len(cases)}] {case['id']}")
222
+ details.append(run_case(rag_system, repo_id, repo_config["name"], case))
223
+ finally:
224
+ rag_system.end_session(EVAL_SESSION_KEY)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
 
226
  report = {
227
  "config": {
228
+ "llm_provider": rag_system.llm_provider,
229
+ "llm_model": rag_system.llm_model,
 
230
  "top_k": TOP_K,
 
 
 
 
 
 
 
 
231
  "eval_set": str(EVAL_SET_PATH),
232
+ "repositories": [
233
+ {"id": repo["id"], "name": repo["name"], "github_url": repo["github_url"]}
234
+ for repo in repositories
235
+ ],
236
  },
237
+ "headline_metrics": summarize(details),
238
+ "repo_breakdown": summarize_by_repo(details),
239
+ "category_breakdown": summarize_by_category(details),
 
 
 
 
 
 
240
  "cases": details,
241
  }
242
+
243
+ if EVAL_OUTPUT_PATH:
244
+ target = Path(EVAL_OUTPUT_PATH)
245
+ target.parent.mkdir(parents=True, exist_ok=True)
246
+ target.write_text(json.dumps(report, indent=2))
247
+ log(f"Wrote JSON report to {target}")
248
 
249
  log("Eval complete. Printing JSON report.")
250
  print(json.dumps(report, indent=2))
251
 
252
 
253
  if __name__ == "__main__":
254
+ run()
evals/sample_eval_set.json CHANGED
@@ -1,889 +1,229 @@
1
- [
2
- {
3
- "id": "documenso-purpose",
4
- "category": "architecture",
5
- "question": "What is Documenso and what product problem is it trying to solve?",
6
- "ground_truth": "Documenso is an open-source document signing platform and DocuSign alternative. It lets users create, send, and sign documents electronically while emphasizing self-hosting, trust, and the ability to inspect how the signing system works under the hood.",
7
- "expected_sources": [
8
- "README.md",
9
- "MANIFEST.md",
10
- "ARCHITECTURE.md"
11
- ],
12
- "must_include_any": [
13
- "document signing",
14
- "self-host",
15
- "DocuSign",
16
- "open trust"
17
- ],
18
- "min_keyword_matches": 2
19
- },
20
- {
21
- "id": "documenso-monorepo-shape",
22
- "category": "architecture",
23
- "question": "How is the Documenso monorepo organized at a high level?",
24
- "ground_truth": "The repo is organized as a Turborepo/npm-workspaces monorepo. Applications live under apps, including the main Remix/React Router application, documentation site, and openpage-api public analytics API, while shared domain and infrastructure packages live under packages, including lib, trpc, api, prisma, ui, email, auth, and signing.",
25
- "expected_sources": [
26
- "ARCHITECTURE.md",
27
- "package.json",
28
- "turbo.json",
29
- "apps",
30
- "packages"
31
- ],
32
- "must_include_any": [
33
- "monorepo",
34
- "Turborepo",
35
- "apps",
36
- "packages"
37
- ],
38
- "min_keyword_matches": 3
39
- },
40
- {
41
- "id": "documenso-remix-hono-app",
42
- "category": "architecture",
43
- "question": "What role does apps/remix play in Documenso?",
44
- "ground_truth": "apps/remix is the main application. The architecture describes it as a React Router/Remix app served by a Hono server, exposing UI routes alongside API mounts such as /api/v1, /api/v2, /api/trpc, and /api/jobs.",
45
- "expected_sources": [
46
- "ARCHITECTURE.md",
47
- "apps/remix",
48
- "apps/remix/server",
49
- "apps/remix/app"
50
- ],
51
- "must_include_any": [
52
- "React Router",
53
- "Remix",
54
- "Hono",
55
- "apps/remix"
56
- ],
57
- "min_keyword_matches": 2
58
- },
59
- {
60
- "id": "documenso-package-responsibilities",
61
- "category": "architecture",
62
- "question": "What are the main responsibilities of the core packages in Documenso?",
63
- "ground_truth": "The core packages split responsibilities by layer: @documenso/lib holds server-only, client-only, and universal business logic; @documenso/trpc provides the current API V2 layer; @documenso/api maintains the older REST API V1; @documenso/prisma owns database access; @documenso/email owns React Email templates and mail delivery; @documenso/auth handles authentication; and @documenso/signing handles PDF signing.",
64
- "expected_sources": [
65
- "ARCHITECTURE.md",
66
- "packages/lib",
67
- "packages/trpc",
68
- "packages/api",
69
- "packages/prisma",
70
- "packages/email",
71
- "packages/auth",
72
- "packages/signing"
73
- ],
74
- "must_include_any": [
75
- "lib",
76
- "trpc",
77
- "prisma",
78
- "email",
79
- "signing"
80
- ],
81
- "min_keyword_matches": 3
82
- },
83
- {
84
- "id": "documenso-api-architecture-overview",
85
- "category": "architecture",
86
- "question": "How does Documenso separate API V1, API V2, and internal tRPC APIs?",
87
- "ground_truth": "Documenso keeps API V1 in packages/api/v1 using ts-rest, marks it as deprecated but maintained, and mounts it under /api/v1. API V2 lives under packages/trpc/server, uses tRPC plus trpc-to-openapi, and is mounted under /api/v2 and /api/v2-beta. Internal frontend-to-backend tRPC is mounted under /api/trpc and uses session-based auth.",
88
- "expected_sources": [
89
- "ARCHITECTURE.md",
90
- "packages/api",
91
- "packages/trpc/server",
92
- "apps/remix/server"
93
- ],
94
- "must_include_any": [
95
- "API V1",
96
- "API V2",
97
- "tRPC",
98
- "ts-rest",
99
- "OpenAPI"
100
- ],
101
- "min_keyword_matches": 3
102
- },
103
- {
104
- "id": "documenso-readme-positioning",
105
- "category": "docs",
106
- "question": "How does the README position Documenso to someone evaluating the project?",
107
- "ground_truth": "The README presents Documenso as the open-source DocuSign alternative and frames its mission around making digital document signing fast, easy, trustworthy, self-hostable, and inspectable under the hood.",
108
- "expected_sources": [
109
- "README.md"
110
- ],
111
- "must_include_any": [
112
- "DocuSign",
113
- "open-source",
114
- "self-host",
115
- "trust"
116
- ],
117
- "min_keyword_matches": 2
118
- },
119
- {
120
- "id": "documenso-readme-tech-stack",
121
- "category": "docs",
122
- "question": "What technology stack does the README advertise for Documenso?",
123
- "ground_truth": "The README lists a TypeScript application using React Router, Prisma, Tailwind, shadcn/ui, React Email, tRPC, PDF signing tooling, React-PDF, PDF-Lib, and Stripe.",
124
- "expected_sources": [
125
- "README.md",
126
- "ARCHITECTURE.md",
127
- "package.json"
128
- ],
129
- "must_include_any": [
130
- "TypeScript",
131
- "Prisma",
132
- "tRPC",
133
- "React Email",
134
- "Stripe"
135
- ],
136
- "min_keyword_matches": 3
137
- },
138
- {
139
- "id": "documenso-local-dev-quickstart",
140
- "category": "docs",
141
- "question": "What local development workflow does the README recommend for getting Documenso running quickly?",
142
- "ground_truth": "The README and developer docs recommend using Node.js, Postgres, and optionally Docker. The quickstart has users copy or create an env file, run npm run dx to start supporting services such as Postgres and Inbucket, then run npm run dev for the app.",
143
- "expected_sources": [
144
- "README.md",
145
- ".env.example",
146
- "apps/docs/content/docs/developers/local-development/quickstart.mdx",
147
- "docker"
148
- ],
149
- "must_include_any": [
150
- "Node.js",
151
- "Postgres",
152
- "Docker",
153
- "npm run dx",
154
- "npm run dev"
155
- ],
156
- "min_keyword_matches": 3
157
- },
158
- {
159
- "id": "documenso-self-hosting-docs",
160
- "category": "docs",
161
- "question": "What does the project documentation say about self-hosting Documenso?",
162
- "ground_truth": "The README and docs describe self-hosting by cloning the repository, copying .env.example to .env, setting required web, database, encryption, signing, and SMTP-related variables, then deploying with Docker, Docker Compose, or a manual flow. The docs also cover configuration and maintenance topics.",
163
- "expected_sources": [
164
- "README.md",
165
- ".env.example",
166
- "docker",
167
- "apps/docs/content/docs/self-hosting"
168
- ],
169
- "must_include_any": [
170
- "self-host",
171
- ".env.example",
172
- "database",
173
- "SMTP",
174
- "Docker"
175
- ],
176
- "min_keyword_matches": 3
177
- },
178
- {
179
- "id": "documenso-docs-app-purpose",
180
- "category": "docs",
181
- "question": "What is the purpose of the apps/docs application in the repository?",
182
- "ground_truth": "apps/docs is the documentation site application. ARCHITECTURE.md identifies it as the docs site, while the app's own README, package.json, and source config show the current implementation as a Next.js/Fumadocs MDX app with content loading, docs layout, and a search route.",
183
- "expected_sources": [
184
- "ARCHITECTURE.md",
185
- "apps/docs/README.md",
186
- "apps/docs/package.json",
187
- "apps/docs/source.config.ts",
188
- "apps/docs/src/lib/source.ts",
189
- "apps/docs/src/app/api/search/route.ts"
190
- ],
191
- "must_include_any": [
192
- "documentation",
193
- "Next.js",
194
- "Fumadocs",
195
- "search"
196
- ],
197
- "min_keyword_matches": 2
198
- },
199
- {
200
- "id": "documenso-required-env",
201
- "category": "config-setup",
202
- "question": "Which environment variables are central to running a self-hosted Documenso instance?",
203
- "ground_truth": "The setup expects values such as NEXTAUTH_SECRET, NEXT_PRIVATE_ENCRYPTION_KEY, NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY, NEXT_PUBLIC_WEBAPP_URL, NEXT_PRIVATE_DATABASE_URL, NEXT_PRIVATE_DIRECT_DATABASE_URL, NEXT_PRIVATE_SMTP_FROM_NAME, and NEXT_PRIVATE_SMTP_FROM_ADDRESS. The env example and self-hosting docs also cover internal URLs, signing transport, storage transport, and optional OAuth and webhook configuration.",
204
- "expected_sources": [
205
- "README.md",
206
- ".env.example",
207
- "apps/docs/content/docs/self-hosting/configuration/environment.mdx"
208
- ],
209
- "must_include_any": [
210
- "NEXTAUTH_SECRET",
211
- "NEXT_PUBLIC_WEBAPP_URL",
212
- "DATABASE_URL",
213
- "SMTP",
214
- "encryption"
215
- ],
216
- "min_keyword_matches": 3
217
- },
218
- {
219
- "id": "documenso-database-setup",
220
- "category": "config-setup",
221
- "question": "How is the database layer configured and managed in Documenso?",
222
- "ground_truth": "Documenso uses PostgreSQL with Prisma and Kysely. Database connection URLs are configured through NEXT_PRIVATE_DATABASE_URL and NEXT_PRIVATE_DIRECT_DATABASE_URL, the self-hosting docs describe database setup, and schema or migration work is handled from the packages/prisma package with commands such as prisma migrations.",
223
- "expected_sources": [
224
- "ARCHITECTURE.md",
225
- ".env.example",
226
- "apps/docs/content/docs/self-hosting/configuration/database.mdx",
227
- "packages/prisma",
228
- "packages/prisma/schema.prisma"
229
- ],
230
- "must_include_any": [
231
- "PostgreSQL",
232
- "Prisma",
233
- "Kysely",
234
- "DATABASE_URL",
235
- "migration"
236
- ],
237
- "min_keyword_matches": 3
238
- },
239
- {
240
- "id": "documenso-signing-config",
241
- "category": "config-setup",
242
- "question": "How is document signing configured for local and cloud-backed signing?",
243
- "ground_truth": "Signing is configured with NEXT_PRIVATE_SIGNING_TRANSPORT. The env example and self-hosting docs document local signing as the default and also support gcloud-hsm with related key, certificate, credential, certificate chain, and timestamp authority configuration.",
244
- "expected_sources": [
245
- ".env.example",
246
- "apps/docs/content/docs/self-hosting/configuration/signing-certificate",
247
- "packages/signing",
248
- "packages/signing/transports"
249
- ],
250
- "must_include_any": [
251
- "NEXT_PRIVATE_SIGNING_TRANSPORT",
252
- "local",
253
- "gcloud-hsm",
254
- "certificate",
255
- "timestamp"
256
- ],
257
- "min_keyword_matches": 3
258
- },
259
- {
260
- "id": "documenso-workspace-build-config",
261
- "category": "config-setup",
262
- "question": "Where should you look to understand package workspaces and build orchestration in Documenso?",
263
- "ground_truth": "The root package.json defines npm workspaces and scripts, while turbo.json defines Turborepo task orchestration. ARCHITECTURE.md explains the monorepo layout and how apps and packages fit together.",
264
- "expected_sources": [
265
- "package.json",
266
- "turbo.json",
267
- "ARCHITECTURE.md"
268
- ],
269
- "must_include_any": [
270
- "package.json",
271
- "workspaces",
272
- "turbo",
273
- "Turborepo"
274
- ],
275
- "min_keyword_matches": 3
276
- },
277
- {
278
- "id": "documenso-api-v2-document-router",
279
- "category": "api",
280
- "question": "Where is the current document API implemented and how is it exposed?",
281
- "ground_truth": "The current API V2 is implemented under packages/trpc/server, with document operations organized under a document router. It is exposed through /api/v2 and /api/v2-beta with tRPC and OpenAPI support, and it accepts API-token or session-cookie authentication depending on the route.",
282
- "expected_sources": [
283
- "ARCHITECTURE.md",
284
- "packages/trpc/server",
285
- "packages/trpc/server/document-router",
286
- "apps/remix/server"
287
- ],
288
- "must_include_any": [
289
- "packages/trpc/server",
290
- "document-router",
291
- "API V2",
292
- "OpenAPI",
293
- "tRPC"
294
- ],
295
- "min_keyword_matches": 3
296
- },
297
- {
298
- "id": "documenso-api-v1-deprecated",
299
- "category": "api",
300
- "question": "What is the older API V1 layer in Documenso and what is its status?",
301
- "ground_truth": "API V1 lives under packages/api/v1, uses ts-rest for contract-based REST, is mounted under /api/v1, and is described as deprecated but maintained.",
302
- "expected_sources": [
303
- "ARCHITECTURE.md",
304
- "packages/api",
305
- "packages/api/v1"
306
- ],
307
- "must_include_any": [
308
- "API V1",
309
- "packages/api/v1",
310
- "ts-rest",
311
- "deprecated"
312
- ],
313
- "min_keyword_matches": 3
314
- },
315
- {
316
- "id": "documenso-internal-trpc",
317
- "category": "api",
318
- "question": "How does the frontend talk to the backend internally in Documenso?",
319
- "ground_truth": "The frontend uses an internal tRPC API mounted under /api/trpc for frontend-to-backend communication. This internal API is separate from the public API V1 and V2 routes and uses session-based authentication.",
320
- "expected_sources": [
321
- "ARCHITECTURE.md",
322
- "apps/remix/server/trpc",
323
- "apps/remix/server/router.ts",
324
- "packages/trpc"
325
- ],
326
- "must_include_any": [
327
- "/api/trpc",
328
- "tRPC",
329
- "frontend",
330
- "session"
331
- ],
332
- "min_keyword_matches": 3
333
- },
334
- {
335
- "id": "documenso-public-api-auth",
336
- "category": "api",
337
- "question": "How are public API requests authenticated in Documenso?",
338
- "ground_truth": "Public API routes use API-token style authentication, typically through bearer or API-key headers. API V2 can also use session cookies where appropriate, while internal tRPC uses session-based auth.",
339
- "expected_sources": [
340
- "ARCHITECTURE.md",
341
- "packages/lib/server-only/public-api",
342
- "packages/trpc/server",
343
- "packages/api"
344
- ],
345
- "must_include_any": [
346
- "API Token",
347
- "Bearer",
348
- "session",
349
- "public API"
350
- ],
351
- "min_keyword_matches": 2
352
- },
353
- {
354
- "id": "documenso-openapi-support",
355
- "category": "api",
356
- "question": "Where does OpenAPI support fit into Documenso's API architecture?",
357
- "ground_truth": "OpenAPI support appears in both API layers. API V2 generates an OpenAPI document from the tRPC app router in packages/trpc/server/open-api.ts and mounts it from the Remix/Hono server. API V1 is the older ts-rest layer under packages/api/v1 and also exposes an OpenAPI document through packages/api/hono.ts.",
358
- "expected_sources": [
359
- "ARCHITECTURE.md",
360
- "apps/remix/server/router.ts",
361
- "packages/trpc/server/open-api.ts",
362
- "packages/api/hono.ts",
363
- "packages/api/v1/openapi.ts"
364
- ],
365
- "must_include_any": [
366
- "OpenAPI",
367
- "trpc-to-openapi",
368
- "tRPC",
369
- "API V2"
370
- ],
371
- "min_keyword_matches": 3
372
- },
373
- {
374
- "id": "documenso-document-business-logic",
375
- "category": "specific-function",
376
- "question": "Where is document business logic likely implemented in Documenso?",
377
- "ground_truth": "Document business logic belongs primarily in packages/lib/server-only/document, with API-facing operations routed through packages/trpc/server/document-router or the legacy packages/api layer. Database persistence is supported by packages/prisma.",
378
- "expected_sources": [
379
- "ARCHITECTURE.md",
380
- "packages/lib/server-only/document",
381
- "packages/trpc/server/document-router",
382
- "packages/prisma"
383
- ],
384
- "must_include_any": [
385
- "server-only",
386
- "document",
387
- "document-router",
388
- "prisma"
389
- ],
390
- "min_keyword_matches": 3
391
- },
392
- {
393
- "id": "documenso-recipient-field-logic",
394
- "category": "specific-function",
395
- "question": "Where should you look for recipient and field behavior in Documenso?",
396
- "ground_truth": "Recipient and field behavior is split between packages/lib/server-only/recipient and packages/lib/server-only/field for core logic, with packages/trpc/server/recipient-router and packages/trpc/server/field-router exposing API operations.",
397
- "expected_sources": [
398
- "packages/lib/server-only/recipient",
399
- "packages/lib/server-only/field",
400
- "packages/trpc/server/recipient-router",
401
- "packages/trpc/server/field-router",
402
- "ARCHITECTURE.md"
403
- ],
404
- "must_include_any": [
405
- "recipient",
406
- "field",
407
- "server-only",
408
- "router"
409
- ],
410
- "min_keyword_matches": 3
411
- },
412
- {
413
- "id": "documenso-template-envelope-logic",
414
- "category": "specific-function",
415
- "question": "How are templates and envelopes represented in the codebase?",
416
- "ground_truth": "Templates and envelopes have their own server-only domain areas under packages/lib/server-only/template and packages/lib/server-only/envelope. API V2 also organizes routes into template-router and envelope-router directories under packages/trpc/server.",
417
- "expected_sources": [
418
- "ARCHITECTURE.md",
419
- "packages/lib/server-only/template",
420
- "packages/lib/server-only/envelope",
421
- "packages/trpc/server/template-router",
422
- "packages/trpc/server/envelope-router"
423
- ],
424
- "must_include_any": [
425
- "template",
426
- "envelope",
427
- "server-only",
428
- "router"
429
- ],
430
- "min_keyword_matches": 3
431
- },
432
- {
433
- "id": "documenso-email-templates",
434
- "category": "specific-function",
435
- "question": "How are lifecycle emails represented in Documenso?",
436
- "ground_truth": "Lifecycle emails live in the @documenso/email package, with React Email templates under packages/email/templates. The package also includes mailer, transports, providers, render helpers, and reusable template components.",
437
- "expected_sources": [
438
- "ARCHITECTURE.md",
439
- "packages/email",
440
- "packages/email/templates",
441
- "packages/email/mailer.ts",
442
- "packages/email/render.tsx"
443
- ],
444
- "must_include_any": [
445
- "React Email",
446
- "templates",
447
- "mailer",
448
- "transports"
449
- ],
450
- "min_keyword_matches": 3
451
- },
452
- {
453
- "id": "documenso-signing-package",
454
- "category": "specific-function",
455
- "question": "What does the signing package do in Documenso?",
456
- "ground_truth": "The @documenso/signing package owns PDF signing behavior. Its signPdf entry point selects a signing transport, applies timestamp authority settings when configured, and supports local P12 signing and Google Cloud KMS/HSM-backed signing through transport implementations.",
457
- "expected_sources": [
458
- "ARCHITECTURE.md",
459
- "packages/signing/index.ts",
460
- "packages/signing/helpers",
461
- "packages/signing/transports",
462
- ".env.example"
463
- ],
464
- "must_include_any": [
465
- "PDF signing",
466
- "transports",
467
- "local",
468
- "Google",
469
- "KMS"
470
- ],
471
- "min_keyword_matches": 3
472
- },
473
- {
474
- "id": "documenso-job-system",
475
- "category": "specific-function",
476
- "question": "What role do jobs play in Documenso?",
477
- "ground_truth": "Jobs handle asynchronous operations such as email sending, document sealing, reminders, and webhooks. The architecture describes Inngest or local providers, with job definitions and clients under packages/lib/jobs.",
478
- "expected_sources": [
479
- "ARCHITECTURE.md",
480
- "packages/lib/jobs",
481
- "packages/lib/server-only/email",
482
- "packages/lib/server-only/webhooks"
483
- ],
484
- "must_include_any": [
485
- "jobs",
486
- "Inngest",
487
- "local",
488
- "email",
489
- "webhooks"
490
- ],
491
- "min_keyword_matches": 3
492
- },
493
- {
494
- "id": "documenso-document-send-flow",
495
- "category": "cross-file",
496
- "question": "How does a document send operation flow across the Documenso codebase?",
497
- "ground_truth": "A document send operation starts at an API or UI route, goes through the API layer such as packages/trpc/server/document-router, delegates core behavior to packages/lib/server-only/document and related recipient or field logic, persists through packages/prisma, and can trigger emails or jobs through packages/email and packages/lib/jobs.",
498
- "expected_sources": [
499
- "packages/trpc/server/document-router",
500
- "packages/lib/server-only/document",
501
- "packages/lib/server-only/recipient",
502
- "packages/prisma",
503
- "packages/email",
504
- "packages/lib/jobs"
505
- ],
506
- "must_include_any": [
507
- "document-router",
508
- "server-only/document",
509
- "prisma",
510
- "email",
511
- "jobs"
512
- ],
513
- "min_keyword_matches": 3
514
- },
515
- {
516
- "id": "documenso-signing-completion-flow",
517
- "category": "cross-file",
518
- "question": "How do recipient fields, PDF handling, signing, and completion emails connect?",
519
- "ground_truth": "Signing completion crosses several packages: recipient and field logic model who signs and which fields are completed, the seal-document job prepares the completed PDF, server-only PDF helpers insert fields/certificates/audit logs, @documenso/signing applies cryptographic signing, Prisma persists state, and document-completed email templates notify participants.",
520
- "expected_sources": [
521
- "packages/lib/server-only/recipient",
522
- "packages/lib/server-only/field",
523
- "packages/lib/server-only/pdf",
524
- "packages/lib/jobs/definitions/internal/seal-document.handler.ts",
525
- "packages/signing",
526
- "packages/prisma",
527
- "packages/email/templates/document-completed.tsx"
528
- ],
529
- "must_include_any": [
530
- "recipient",
531
- "field",
532
- "PDF",
533
- "signing",
534
- "document-completed"
535
- ],
536
- "min_keyword_matches": 3
537
- },
538
- {
539
- "id": "documenso-webhook-job-flow",
540
- "category": "cross-file",
541
- "question": "How do background jobs and webhooks complement each other in Documenso?",
542
- "ground_truth": "Background jobs handle asynchronous work, and webhook logic lives under packages/lib/server-only/webhooks. Together they allow document lifecycle events to be processed outside the immediate request path and delivered to external integrations.",
543
- "expected_sources": [
544
- "ARCHITECTURE.md",
545
- "packages/lib/jobs",
546
- "packages/lib/server-only/webhooks",
547
- ".env.example"
548
- ],
549
- "must_include_any": [
550
- "jobs",
551
- "webhooks",
552
- "asynchronous",
553
- "events"
554
- ],
555
- "min_keyword_matches": 3
556
- },
557
- {
558
- "id": "documenso-ui-to-api-flow",
559
- "category": "cross-file",
560
- "question": "How does the Remix UI connect to server routes and shared API packages?",
561
- "ground_truth": "The Remix UI is under apps/remix/app with route definitions and root app wiring. The server side under apps/remix/server wires routers and context, then connects to the shared packages/trpc APIs and packages/lib business logic.",
562
- "expected_sources": [
563
- "apps/remix/app/routes.ts",
564
- "apps/remix/app/root.tsx",
565
- "apps/remix/server/router.ts",
566
- "apps/remix/server/context.ts",
567
- "packages/trpc",
568
- "packages/lib"
569
- ],
570
- "must_include_any": [
571
- "apps/remix/app",
572
- "apps/remix/server",
573
- "router",
574
- "context",
575
- "tRPC"
576
- ],
577
- "min_keyword_matches": 3
578
- },
579
- {
580
- "id": "documenso-storage-pdf-flow",
581
- "category": "cross-file",
582
- "question": "How do storage, PDF processing, and database state fit together?",
583
- "ground_truth": "The architecture separates storage provider concerns, PDF handling, and database state. Server-side PDF behavior lives under packages/lib/server-only/pdf, upload and download storage logic lives under packages/lib/universal/upload, document data records are created through packages/lib/server-only/document-data, and packages/prisma persists metadata and workflow state.",
584
- "expected_sources": [
585
- "ARCHITECTURE.md",
586
- "packages/lib/server-only/pdf",
587
- "packages/lib/server-only/document-data",
588
- "packages/prisma",
589
- "packages/lib/universal/upload"
590
- ],
591
- "must_include_any": [
592
- "storage",
593
- "PDF",
594
- "database",
595
- "prisma",
596
- "metadata"
597
- ],
598
- "min_keyword_matches": 3
599
- },
600
- {
601
- "id": "documenso-auth-session-flow",
602
- "category": "cross-file",
603
- "question": "How does authentication show up across Documenso's app and packages?",
604
- "ground_truth": "Authentication spans the @documenso/auth package, server-only auth logic under packages/lib/server-only/auth, app server context in apps/remix/server/context.ts, and API layers that choose between session cookies and API tokens.",
605
- "expected_sources": [
606
- "ARCHITECTURE.md",
607
- "packages/auth",
608
- "packages/lib/server-only/auth",
609
- "apps/remix/server/context.ts",
610
- "packages/trpc/server"
611
- ],
612
- "must_include_any": [
613
- "auth",
614
- "session",
615
- "API token",
616
- "context"
617
- ],
618
- "min_keyword_matches": 3
619
- },
620
- {
621
- "id": "documenso-public-api-errors",
622
- "category": "error-handling",
623
- "question": "Where would Documenso validate or reject bad public API requests?",
624
- "ground_truth": "Bad public API requests are validated in the API layer and supporting server-only public API logic. The relevant code is under packages/trpc/server for API V2, packages/api for API V1, and packages/lib/server-only/public-api for shared public API behavior such as authentication, permissions, and validation.",
625
- "expected_sources": [
626
- "packages/trpc/server",
627
- "packages/api",
628
- "packages/lib/server-only/public-api"
629
- ],
630
- "must_include_any": [
631
- "validation",
632
- "API",
633
- "auth",
634
- "public-api"
635
- ],
636
- "min_keyword_matches": 2
637
- },
638
- {
639
- "id": "documenso-webhook-security-errors",
640
- "category": "error-handling",
641
- "question": "Where should you look for webhook security or SSRF-related safeguards?",
642
- "ground_truth": "Webhook safeguards belong in packages/lib/server-only/webhooks, with related configuration documented in .env.example such as NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS. These sources explain where outbound webhook behavior and security exceptions are controlled.",
643
- "expected_sources": [
644
- ".env.example",
645
- "packages/lib/server-only/webhooks"
646
- ],
647
- "must_include_any": [
648
- "webhook",
649
- "SSRF",
650
- "bypass",
651
- "hosts"
652
- ],
653
- "min_keyword_matches": 2
654
- },
655
- {
656
- "id": "documenso-signing-config-errors",
657
- "category": "error-handling",
658
- "question": "Where would invalid signing configuration most likely be enforced?",
659
- "ground_truth": "Invalid signing configuration is handled by the signing package and the code that reads signing environment variables. The key sources are .env.example for supported values, packages/signing/index.ts for transport selection and unsupported transport errors, and packages/signing/transports for local or gcloud-hsm certificate/key validation.",
660
- "expected_sources": [
661
- ".env.example",
662
- "packages/signing/index.ts",
663
- "packages/signing/transports",
664
- "packages/signing/helpers"
665
- ],
666
- "must_include_any": [
667
- "signing",
668
- "transport",
669
- "local",
670
- "gcloud-hsm"
671
- ],
672
- "min_keyword_matches": 3
673
- },
674
- {
675
- "id": "documenso-playwright-tests",
676
- "category": "tests",
677
- "question": "Where does Documenso keep end-to-end app tests?",
678
- "ground_truth": "The architecture identifies @documenso/app-tests as the E2E test package, and the packages/app-tests directory is intended for Playwright coverage of app behavior.",
679
- "expected_sources": [
680
- "ARCHITECTURE.md",
681
- "packages/app-tests",
682
- "packages/app-tests/package.json"
683
- ],
684
- "must_include_any": [
685
- "app-tests",
686
- "E2E",
687
- "Playwright"
688
- ],
689
- "min_keyword_matches": 2
690
- },
691
- {
692
- "id": "documenso-package-test-config",
693
- "category": "tests",
694
- "question": "How can you find package-level test configuration for shared logic?",
695
- "ground_truth": "Package-level tests and configuration can be found near the package they cover, such as packages/lib/vitest.config.ts for shared library tests and package.json scripts in the relevant package directories.",
696
- "expected_sources": [
697
- "packages/lib/vitest.config.ts",
698
- "packages/lib/package.json",
699
- "packages"
700
- ],
701
- "must_include_any": [
702
- "vitest",
703
- "package.json",
704
- "packages/lib"
705
- ],
706
- "min_keyword_matches": 2
707
- },
708
- {
709
- "id": "documenso-email-template-tests",
710
- "category": "tests",
711
- "question": "What should be tested when changing document lifecycle email behavior?",
712
- "ground_truth": "A good answer should point to packages/email templates and rendering/mailer code, and should mention checking template output, variables, delivery behavior, and any app or E2E tests that cover document lifecycle notifications.",
713
- "expected_sources": [
714
- "packages/email/templates",
715
- "packages/email/render.tsx",
716
- "packages/email/mailer.ts",
717
- "packages/app-tests"
718
- ],
719
- "must_include_any": [
720
- "email",
721
- "templates",
722
- "render",
723
- "mailer",
724
- "tests"
725
- ],
726
- "min_keyword_matches": 3
727
- },
728
- {
729
- "id": "documenso-followup-api-v2-code-path",
730
- "category": "conversation",
731
- "turns": [
732
- {
733
- "role": "user",
734
- "content": "How does Documenso expose document operations in the current public API?"
735
- },
736
- {
737
- "role": "assistant",
738
- "content": "The current public API is API V2, implemented through the tRPC server package and document router, with OpenAPI support."
739
- }
740
- ],
741
- "question": "show me the code path for that",
742
- "ground_truth": "The follow-up should stay anchored to packages/trpc/server/document-router and the apps/remix server routing that mounts API V2. It should avoid drifting to README-only setup material.",
743
- "expected_sources": [
744
- "packages/trpc/server/document-router",
745
- "packages/trpc/server",
746
- "apps/remix/server/router.ts",
747
- "ARCHITECTURE.md"
748
- ],
749
- "must_include_any": [
750
- "document-router",
751
- "packages/trpc/server",
752
- "API V2",
753
- "router"
754
- ],
755
- "min_keyword_matches": 3
756
- },
757
- {
758
- "id": "documenso-followup-email-flow",
759
- "category": "conversation",
760
- "turns": [
761
- {
762
- "role": "user",
763
- "content": "What happens after a document is sent for signing?"
764
- },
765
- {
766
- "role": "assistant",
767
- "content": "The send flow updates document state, recipient state, jobs, and lifecycle emails."
768
- }
769
- ],
770
- "question": "where does the email part live?",
771
- "ground_truth": "The follow-up should retrieve packages/email templates, mailer/render code, and any server-only email or job code that triggers those templates.",
772
- "expected_sources": [
773
- "packages/email/templates",
774
- "packages/email/mailer.ts",
775
- "packages/email/render.tsx",
776
- "packages/lib/server-only/email",
777
- "packages/lib/jobs"
778
- ],
779
- "must_include_any": [
780
- "packages/email",
781
- "templates",
782
- "mailer",
783
- "jobs"
784
- ],
785
- "min_keyword_matches": 3
786
- },
787
- {
788
- "id": "documenso-followup-self-hosting-config",
789
- "category": "conversation",
790
- "turns": [
791
- {
792
- "role": "user",
793
- "content": "How do I run Documenso myself?"
794
- },
795
- {
796
- "role": "assistant",
797
- "content": "Self-hosting uses the README flow: copy .env.example, set required URLs, database, SMTP, and secrets, run migrations, then start apps/remix."
798
- }
799
- ],
800
- "question": "which config files should I inspect?",
801
- "ground_truth": "The follow-up should point to .env.example, README self-hosting instructions, package.json or turbo config for scripts, and apps/remix for where the app starts.",
802
- "expected_sources": [
803
- ".env.example",
804
- "README.md",
805
- "package.json",
806
- "turbo.json",
807
- "apps/remix"
808
- ],
809
- "must_include_any": [
810
- ".env.example",
811
- "README",
812
- "package.json",
813
- "apps/remix"
814
- ],
815
- "min_keyword_matches": 3
816
- },
817
- {
818
- "id": "documenso-followup-signing-bridge",
819
- "category": "conversation",
820
- "turns": [
821
- {
822
- "role": "user",
823
- "content": "How does Documenso seal or sign completed documents?"
824
- },
825
- {
826
- "role": "assistant",
827
- "content": "PDF completion crosses server-only PDF/document logic and the @documenso/signing package, which supports local and Google-backed signing transports."
828
- }
829
- ],
830
- "question": "show me where signing plugs in",
831
- "ground_truth": "The follow-up should retrieve packages/signing, especially the signPdf entry point, transports, and helpers, plus the seal-document job where completed PDFs are decorated and passed into signing, and signing-related env configuration.",
832
- "expected_sources": [
833
- "packages/signing/index.ts",
834
- "packages/signing/transports",
835
- "packages/signing/helpers",
836
- "packages/lib/jobs/definitions/internal/seal-document.handler.ts",
837
- ".env.example"
838
- ],
839
- "must_include_any": [
840
- "packages/signing",
841
- "transports",
842
- "PDF",
843
- "signing"
844
- ],
845
- "min_keyword_matches": 3
846
- },
847
- {
848
- "id": "documenso-codegen-email-template-checklist",
849
- "category": "code-generation",
850
- "question": "Write a short implementation checklist for adding a new document lifecycle email template in Documenso",
851
- "ground_truth": "A good checklist should mention adding a React Email template under packages/email/templates, wiring rendering or mailer usage if needed, passing required variables from server-side document or job logic, and covering the change with focused tests or previews.",
852
- "expected_sources": [
853
- "packages/email/templates",
854
- "packages/email/mailer.ts",
855
- "packages/email/render.tsx",
856
- "packages/lib/jobs",
857
- "packages/lib/server-only/document"
858
- ],
859
- "must_include_any": [
860
- "packages/email/templates",
861
- "React Email",
862
- "mailer",
863
- "jobs",
864
- "tests"
865
- ],
866
- "min_keyword_matches": 3
867
- },
868
- {
869
- "id": "documenso-codegen-api-route-checklist",
870
- "category": "code-generation",
871
- "question": "Write a short implementation checklist for adding a new API V2 document operation in Documenso",
872
- "ground_truth": "A good checklist should point to packages/trpc/server and the document-router, define request and response types or schemas, delegate business logic to packages/lib/server-only/document, update OpenAPI exposure if applicable, persist with Prisma when needed, and add tests.",
873
- "expected_sources": [
874
- "packages/trpc/server/document-router",
875
- "packages/trpc/server",
876
- "packages/lib/server-only/document",
877
- "packages/prisma",
878
- "ARCHITECTURE.md"
879
- ],
880
- "must_include_any": [
881
- "document-router",
882
- "tRPC",
883
- "OpenAPI",
884
- "server-only/document",
885
- "Prisma"
886
- ],
887
- "min_keyword_matches": 3
888
- }
889
- ]
 
1
+ {
2
+ "repositories": [
3
+ {
4
+ "id": "documenso",
5
+ "name": "Documenso",
6
+ "github_url": "https://github.com/documenso/documenso.git",
7
+ "cases": [
8
+ {
9
+ "id": "documenso-purpose",
10
+ "category": "architecture",
11
+ "question": "What is Documenso and what product problem is it trying to solve?",
12
+ "ground_truth": "Documenso is an open-source document signing platform and DocuSign alternative. It lets users create, send, and sign documents electronically while emphasizing self-hosting, trust, and the ability to inspect how the signing system works under the hood.",
13
+ "expected_sources": ["README.md", "MANIFEST.md", "ARCHITECTURE.md"],
14
+ "must_include_any": ["document signing", "self-host", "DocuSign", "open trust"]
15
+ },
16
+ {
17
+ "id": "documenso-api-v2-document-router",
18
+ "category": "api",
19
+ "question": "Where is the current document API implemented and how is it exposed?",
20
+ "ground_truth": "The current API V2 is implemented under packages/trpc/server, with document operations organized under a document router. It is exposed through /api/v2 and /api/v2-beta with tRPC and OpenAPI support, and it accepts API-token or session-cookie authentication depending on the route.",
21
+ "expected_sources": ["ARCHITECTURE.md", "packages/trpc/server", "packages/trpc/server/document-router", "apps/remix/server"],
22
+ "must_include_any": ["packages/trpc/server", "document-router", "API V2", "OpenAPI", "tRPC"]
23
+ },
24
+ {
25
+ "id": "documenso-signing-package",
26
+ "category": "specific-function",
27
+ "question": "What does the signing package do in Documenso?",
28
+ "ground_truth": "The @documenso/signing package owns PDF signing behavior. Its signPdf entry point selects a signing transport, applies timestamp authority settings when configured, and supports local P12 signing and Google Cloud KMS/HSM-backed signing through transport implementations.",
29
+ "expected_sources": ["ARCHITECTURE.md", "packages/signing/index.ts", "packages/signing/helpers", "packages/signing/transports", ".env.example"],
30
+ "must_include_any": ["PDF signing", "transports", "local", "Google", "KMS"]
31
+ },
32
+ {
33
+ "id": "documenso-document-send-flow",
34
+ "category": "cross-file",
35
+ "question": "How does a document send operation flow across the Documenso codebase?",
36
+ "ground_truth": "A document send operation starts at an API or UI route, goes through the API layer such as packages/trpc/server/document-router, delegates core behavior to packages/lib/server-only/document and related recipient or field logic, persists through packages/prisma, and can trigger emails or jobs through packages/email and packages/lib/jobs.",
37
+ "expected_sources": ["packages/trpc/server/document-router", "packages/lib/server-only/document", "packages/lib/server-only/recipient", "packages/prisma", "packages/email", "packages/lib/jobs"],
38
+ "must_include_any": ["document-router", "server-only/document", "prisma", "email", "jobs"]
39
+ },
40
+ {
41
+ "id": "documenso-required-env",
42
+ "category": "config-setup",
43
+ "question": "Which environment variables are central to running a self-hosted Documenso instance?",
44
+ "ground_truth": "The setup expects values such as NEXTAUTH_SECRET, NEXT_PRIVATE_ENCRYPTION_KEY, NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY, NEXT_PUBLIC_WEBAPP_URL, NEXT_PRIVATE_DATABASE_URL, NEXT_PRIVATE_DIRECT_DATABASE_URL, NEXT_PRIVATE_SMTP_FROM_NAME, and NEXT_PRIVATE_SMTP_FROM_ADDRESS. The env example and self-hosting docs also cover internal URLs, signing transport, storage transport, and optional OAuth and webhook configuration.",
45
+ "expected_sources": ["README.md", ".env.example", "apps/docs/content/docs/self-hosting/configuration/environment.mdx"],
46
+ "must_include_any": ["NEXTAUTH_SECRET", "NEXT_PUBLIC_WEBAPP_URL", "DATABASE_URL", "SMTP", "encryption"]
47
+ },
48
+ {
49
+ "id": "documenso-playwright-tests",
50
+ "category": "tests",
51
+ "question": "Where does Documenso keep end-to-end app tests?",
52
+ "ground_truth": "The architecture identifies @documenso/app-tests as the E2E test package, and the packages/app-tests directory is intended for Playwright coverage of app behavior.",
53
+ "expected_sources": ["ARCHITECTURE.md", "packages/app-tests", "packages/app-tests/package.json"],
54
+ "must_include_any": ["app-tests", "E2E", "Playwright"]
55
+ },
56
+ {
57
+ "id": "documenso-webhook-security-errors",
58
+ "category": "error-handling",
59
+ "question": "Where should you look for webhook security or SSRF-related safeguards?",
60
+ "ground_truth": "Webhook safeguards belong in packages/lib/server-only/webhooks, with related configuration documented in .env.example such as NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS. These sources explain where outbound webhook behavior and security exceptions are controlled.",
61
+ "expected_sources": [".env.example", "packages/lib/server-only/webhooks"],
62
+ "must_include_any": ["webhook", "SSRF", "bypass", "hosts"]
63
+ },
64
+ {
65
+ "id": "documenso-followup-signing-bridge",
66
+ "category": "conversation",
67
+ "turns": [
68
+ {"role": "user", "content": "How does Documenso seal or sign completed documents?"},
69
+ {"role": "assistant", "content": "PDF completion crosses server-only PDF/document logic and the @documenso/signing package, which supports local and Google-backed signing transports."}
70
+ ],
71
+ "question": "show me where signing plugs in",
72
+ "ground_truth": "The follow-up should retrieve packages/signing, especially the signPdf entry point, transports, and helpers, plus the seal-document job where completed PDFs are decorated and passed into signing, and signing-related env configuration.",
73
+ "expected_sources": ["packages/signing/index.ts", "packages/signing/transports", "packages/signing/helpers", "packages/lib/jobs/definitions/internal/seal-document.handler.ts", ".env.example"],
74
+ "must_include_any": ["packages/signing", "transports", "PDF", "signing"]
75
+ }
76
+ ]
77
+ },
78
+ {
79
+ "id": "sqlite",
80
+ "name": "SQLite",
81
+ "github_url": "https://github.com/sqlite/sqlite.git",
82
+ "cases": [
83
+ {
84
+ "id": "sqlite-purpose",
85
+ "category": "architecture",
86
+ "question": "What is SQLite and how is the project organized at a high level?",
87
+ "ground_truth": "SQLite is a self-contained, serverless, zero-configuration embedded SQL database engine. Its core sources live under src/, tests are TCL scripts, the build produces both a library and a single-file amalgamation, and the project's own AGENTS.md documents the execution pipeline for contributors.",
88
+ "expected_sources": ["README.md", "src", "AGENTS.md"],
89
+ "must_include_any": ["embedded", "serverless", "amalgamation", "SQL database engine"]
90
+ },
91
+ {
92
+ "id": "sqlite-query-pipeline",
93
+ "category": "cross-file",
94
+ "question": "How does a SQL statement flow through SQLite's execution pipeline?",
95
+ "ground_truth": "SQL text is tokenized in tokenize.c, parsed by the Lemon-generated parser from parse.y, turned into VDBE bytecode by the code generator files (build.c, select.c, insert.c, update.c, delete.c, expr.c) after being optimized by the where*.c query optimizer, then executed by the VDBE in vdbe.c against the B-tree layer in btree.c, the pager in pager.c, the WAL in wal.c, and finally the OS-level VFS such as os_unix.c.",
96
+ "expected_sources": ["src/tokenize.c", "src/parse.y", "src/vdbe.c", "src/btree.c", "src/pager.c", "src/wal.c", "AGENTS.md"],
97
+ "must_include_any": ["tokenizer", "parser", "VDBE", "B-Tree", "pager", "WAL"]
98
+ },
99
+ {
100
+ "id": "sqlite-btree-role",
101
+ "category": "specific-function",
102
+ "question": "What role does btree.c play in SQLite?",
103
+ "ground_truth": "btree.c implements the B-tree storage engine SQLite uses to organize table and index data on disk. Its public interface is declared in btree.h, while btreeInt.h defines the data structures used only internally by the module.",
104
+ "expected_sources": ["src/btree.c", "src/btree.h", "src/btreeInt.h"],
105
+ "must_include_any": ["B-Tree", "storage engine", "btree.h"]
106
+ },
107
+ {
108
+ "id": "sqlite-vdbe-opcodes",
109
+ "category": "specific-function",
110
+ "question": "How are VDBE opcodes generated and where do they come from?",
111
+ "ground_truth": "VDBE opcode numbers and names are extracted automatically by scanning src/vdbe.c with the mkopcodeh.tcl script, which generates opcodes.h; a second script, mkopcodec.tcl, then generates opcodes.c, which provides the reverse opcode-to-name mapping used for EXPLAIN output.",
112
+ "expected_sources": ["src/vdbe.c", "mkopcodeh.tcl", "mkopcodec.tcl"],
113
+ "must_include_any": ["opcodes.h", "mkopcodeh.tcl", "VDBE", "EXPLAIN"]
114
+ },
115
+ {
116
+ "id": "sqlite-parser-generation",
117
+ "category": "config-setup",
118
+ "question": "How is the SQL grammar parser built for SQLite?",
119
+ "ground_truth": "The grammar is defined in src/parse.y and compiled into parse.c by the Lemon LALR(1) parser generator in tool/lemon.c, which uses tool/lempar.c as a template and also emits the parse.h header as a side effect.",
120
+ "expected_sources": ["src/parse.y", "tool/lemon.c", "tool/lempar.c"],
121
+ "must_include_any": ["parse.y", "Lemon", "LALR", "parse.c"]
122
+ },
123
+ {
124
+ "id": "sqlite-testing",
125
+ "category": "tests",
126
+ "question": "How does SQLite run its test suite and what kind of tests does it use?",
127
+ "ground_truth": "SQLite's tests are TCL scripts executed through the testfixture binary, which is built with make testfixture. AGENTS.md instructs contributors to run at least make devtest after any change under src/, and make sqlite3.c builds the amalgamation used for distribution.",
128
+ "expected_sources": ["test", "AGENTS.md", "Makefile.in"],
129
+ "must_include_any": ["testfixture", "TCL", "devtest", "test suite"]
130
+ },
131
+ {
132
+ "id": "sqlite-build-amalgamation",
133
+ "category": "config-setup",
134
+ "question": "What is the SQLite amalgamation and how is it produced?",
135
+ "ground_truth": "The amalgamation is the single-file distribution form of SQLite, sqlite3.c, assembled from the individual sources under src/ during the build. The public C API is declared in the src/sqlite.h.in template, which is expanded into the sqlite3.h header shipped with the amalgamation.",
136
+ "expected_sources": ["src/sqlite.h.in", "Makefile.in", "AGENTS.md"],
137
+ "must_include_any": ["amalgamation", "sqlite3.c", "sqlite.h.in"]
138
+ },
139
+ {
140
+ "id": "sqlite-followup-wal",
141
+ "category": "conversation",
142
+ "turns": [
143
+ {"role": "user", "content": "How does SQLite guarantee that transactions survive a crash?"},
144
+ {"role": "assistant", "content": "Durability is enforced through the pager and, depending on journal mode, the write-ahead log."}
145
+ ],
146
+ "question": "which files implement that WAL behavior?",
147
+ "ground_truth": "WAL-mode durability is implemented mainly in src/wal.c, working together with src/pager.c, which coordinates pager and journal behavior, and the OS-level VFS layer such as src/os_unix.c, which performs the actual fsync/durability calls.",
148
+ "expected_sources": ["src/wal.c", "src/pager.c", "src/os_unix.c"],
149
+ "must_include_any": ["wal.c", "pager.c", "fsync", "VFS"]
150
+ }
151
+ ]
152
+ },
153
+ {
154
+ "id": "fastapi",
155
+ "name": "FastAPI",
156
+ "github_url": "https://github.com/fastapi/fastapi.git",
157
+ "cases": [
158
+ {
159
+ "id": "fastapi-purpose",
160
+ "category": "architecture",
161
+ "question": "What is FastAPI and what is it built on top of?",
162
+ "ground_truth": "FastAPI is a Python web framework for building APIs. It is built on top of Starlette for the web-facing parts and Pydantic for data validation and serialization, and it automatically generates an OpenAPI schema along with interactive Swagger UI and ReDoc documentation.",
163
+ "expected_sources": ["README.md", "fastapi/applications.py", "pyproject.toml"],
164
+ "must_include_any": ["Starlette", "Pydantic", "OpenAPI"]
165
+ },
166
+ {
167
+ "id": "fastapi-app-class",
168
+ "category": "specific-function",
169
+ "question": "What does the FastAPI application class do and where is it defined?",
170
+ "ground_truth": "The central FastAPI class is defined in fastapi/applications.py. It ties together routing, dependency injection, middleware, exception handling, and OpenAPI schema generation for the whole application.",
171
+ "expected_sources": ["fastapi/applications.py"],
172
+ "must_include_any": ["applications.py", "routing", "OpenAPI"]
173
+ },
174
+ {
175
+ "id": "fastapi-routing",
176
+ "category": "implementation",
177
+ "question": "Where is path operation routing implemented in FastAPI?",
178
+ "ground_truth": "Routing is implemented in fastapi/routing.py, which defines APIRoute and APIRouter. It handles path matching, resolves dependencies for each incoming request, and serializes the response for every registered endpoint.",
179
+ "expected_sources": ["fastapi/routing.py"],
180
+ "must_include_any": ["APIRoute", "APIRouter", "routing.py"]
181
+ },
182
+ {
183
+ "id": "fastapi-dependency-injection",
184
+ "category": "cross-file",
185
+ "question": "How does FastAPI resolve dependencies declared with Depends()?",
186
+ "ground_truth": "Depends() and related parameter markers are defined in fastapi/params.py and fastapi/param_functions.py. The actual dependency tree resolution for each request happens in fastapi/dependencies/utils.py, which fastapi/routing.py calls while handling a request.",
187
+ "expected_sources": ["fastapi/dependencies/utils.py", "fastapi/params.py", "fastapi/param_functions.py", "fastapi/routing.py"],
188
+ "must_include_any": ["Depends", "dependencies/utils.py", "dependency"]
189
+ },
190
+ {
191
+ "id": "fastapi-openapi-generation",
192
+ "category": "api",
193
+ "question": "How does FastAPI generate the OpenAPI schema and interactive docs?",
194
+ "ground_truth": "The OpenAPI JSON schema is generated in fastapi/openapi/utils.py from the app's routes and Pydantic models. The Swagger UI and ReDoc HTML pages are served through helper functions such as get_swagger_ui_html and get_redoc_html in fastapi/openapi/docs.py.",
195
+ "expected_sources": ["fastapi/openapi/utils.py", "fastapi/openapi/docs.py"],
196
+ "must_include_any": ["OpenAPI", "Swagger", "ReDoc", "openapi/utils.py"]
197
+ },
198
+ {
199
+ "id": "fastapi-error-handling",
200
+ "category": "error-handling",
201
+ "question": "How does FastAPI turn validation failures and raised exceptions into HTTP responses?",
202
+ "ground_truth": "FastAPI defines HTTPException and RequestValidationError in fastapi/exceptions.py. The default handlers that convert those exceptions into JSON error responses are registered in fastapi/exception_handlers.py.",
203
+ "expected_sources": ["fastapi/exceptions.py", "fastapi/exception_handlers.py"],
204
+ "must_include_any": ["HTTPException", "RequestValidationError", "exception_handlers.py"]
205
+ },
206
+ {
207
+ "id": "fastapi-security",
208
+ "category": "specific-function",
209
+ "question": "How does FastAPI support authentication schemes like OAuth2 and HTTP Bearer tokens?",
210
+ "ground_truth": "Authentication helpers live under fastapi/security, which provides classes such as OAuth2PasswordBearer and HTTPBearer. They integrate with the dependency injection system and are automatically reflected in the generated OpenAPI security schema.",
211
+ "expected_sources": ["fastapi/security", "fastapi/openapi/utils.py"],
212
+ "must_include_any": ["OAuth2", "HTTPBearer", "security", "dependency"]
213
+ },
214
+ {
215
+ "id": "fastapi-followup-encoder",
216
+ "category": "conversation",
217
+ "turns": [
218
+ {"role": "user", "content": "How does FastAPI encode response data before it goes back to the client?"},
219
+ {"role": "assistant", "content": "Response bodies are converted with jsonable_encoder before being serialized, factoring in the declared response_model."}
220
+ ],
221
+ "question": "where is that encoder implemented and how is it tested?",
222
+ "ground_truth": "jsonable_encoder is implemented in fastapi/encoders.py, converting Pydantic models and other Python objects into JSON-compatible structures. Its behavior is covered by the pytest suite under the tests directory.",
223
+ "expected_sources": ["fastapi/encoders.py", "tests"],
224
+ "must_include_any": ["jsonable_encoder", "encoders.py", "tests"]
225
+ }
226
+ ]
227
+ }
228
+ ]
229
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/rag_system.py CHANGED
@@ -497,7 +497,7 @@ Rules:
497
  7. Keep the answer complete. Do not stop mid-sentence.
498
  8. Use short sections or bullets only when they genuinely help readability.
499
  9. Do not leave unfinished headings, dangling bullets, or trailing markdown markers like #, ##, or ###.
500
- 10. Do not include inline citation markers like [Source 1] in the prose. The UI already shows sources separately.
501
  11. If you cannot answer the question using the provided context, say: "I cannot find sufficient evidence in the codebase to answer this question."
502
  12. Prefer the most canonical source files for API and implementation questions, such as package exports, core modules, and session/query code, over tutorial prose when they disagree in specificity.
503
  13. Keep the answer tight. Lead with the direct answer, then add only the most important supporting detail.
@@ -560,15 +560,9 @@ Do not leave the answer unfinished.
560
  )
561
 
562
  answer_text = self._finalize_answer(answer_text)
 
563
  confidence = self._estimate_confidence(sources)
564
  summary = " ".join(answer_text.split())[:160] if answer_text else ""
565
- citations = [
566
- {
567
- "source": index,
568
- "reason": f"Relevant context from {source['file_path']}",
569
- }
570
- for index, source in enumerate(sources[: min(len(sources), 4)], start=1)
571
- ]
572
 
573
  return {
574
  "answer": answer_text,
@@ -699,7 +693,9 @@ Do not leave the answer unfinished.
699
  def _normalize_markdown_answer(raw_text: str) -> str:
700
  cleaned = (raw_text or "").strip()
701
  cleaned = re.sub(r"^```(?:markdown|md)?\s*|\s*```$", "", cleaned, flags=re.IGNORECASE)
702
- cleaned = re.sub(r"\s*\[(?:Source\s+\d+(?:\s*,\s*Source\s+\d+)*)\]", "", cleaned, flags=re.IGNORECASE)
 
 
703
  cleaned = re.sub(
704
  r"^(?:based on the provided context[,:\s-]*|from the provided context[,:\s-]*)",
705
  "",
@@ -768,6 +764,44 @@ Do not leave the answer unfinished.
768
  return True
769
  return False
770
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
771
  @staticmethod
772
  def _estimate_confidence(sources: List[dict]) -> str:
773
  if not sources:
@@ -1693,4 +1727,4 @@ Do not leave the answer unfinished.
1693
  "content": chunk["content"],
1694
  "searchable_text": chunk["searchable_text"],
1695
  "metadata_json": chunk.get("metadata_json") or {},
1696
- }
 
497
  7. Keep the answer complete. Do not stop mid-sentence.
498
  8. Use short sections or bullets only when they genuinely help readability.
499
  9. Do not leave unfinished headings, dangling bullets, or trailing markdown markers like #, ##, or ###.
500
+ 10. Back up factual claims with inline citations. After a sentence or clause that relies on a specific source, add its number in brackets, e.g. [1] or [2][3] if multiple sources support it. Use only the source numbers given above (Source 1, Source 2, ...) and never invent a number.
501
  11. If you cannot answer the question using the provided context, say: "I cannot find sufficient evidence in the codebase to answer this question."
502
  12. Prefer the most canonical source files for API and implementation questions, such as package exports, core modules, and session/query code, over tutorial prose when they disagree in specificity.
503
  13. Keep the answer tight. Lead with the direct answer, then add only the most important supporting detail.
 
560
  )
561
 
562
  answer_text = self._finalize_answer(answer_text)
563
+ answer_text, citations = self._attach_citations(answer_text, sources)
564
  confidence = self._estimate_confidence(sources)
565
  summary = " ".join(answer_text.split())[:160] if answer_text else ""
 
 
 
 
 
 
 
566
 
567
  return {
568
  "answer": answer_text,
 
693
  def _normalize_markdown_answer(raw_text: str) -> str:
694
  cleaned = (raw_text or "").strip()
695
  cleaned = re.sub(r"^```(?:markdown|md)?\s*|\s*```$", "", cleaned, flags=re.IGNORECASE)
696
+ cleaned = re.sub(
697
+ r"\[Source\s+(\d+)\]", r"[\1]", cleaned, flags=re.IGNORECASE
698
+ )
699
  cleaned = re.sub(
700
  r"^(?:based on the provided context[,:\s-]*|from the provided context[,:\s-]*)",
701
  "",
 
764
  return True
765
  return False
766
 
767
+ @staticmethod
768
+ def _attach_citations(answer_text: str, sources: List[dict]) -> tuple[str, List[dict]]:
769
+ max_source = len(sources)
770
+ if max_source == 0 or not answer_text:
771
+ return answer_text, []
772
+
773
+ cited_numbers = set()
774
+
775
+ def _keep_or_drop(match: "re.Match") -> str:
776
+ number = int(match.group(1))
777
+ if 1 <= number <= max_source:
778
+ cited_numbers.add(number)
779
+ return match.group(0)
780
+ # Drop citation markers that don't correspond to a real source.
781
+ return ""
782
+
783
+ cleaned_text = re.sub(r"\[(\d+)\]", _keep_or_drop, answer_text)
784
+ cleaned_text = re.sub(r"[ \t]+([.,;:!?])", r"\1", cleaned_text).strip()
785
+
786
+ # If the model didn't cite anything inline, fall back to listing every
787
+ # retrieved source so the response is still grounded in a traceable way.
788
+ numbers_to_cite = cited_numbers if cited_numbers else set(range(1, max_source + 1))
789
+
790
+ citations = []
791
+ for index in sorted(numbers_to_cite):
792
+ source = sources[index - 1]
793
+ citations.append(
794
+ {
795
+ "source": index,
796
+ "file_path": source["file_path"],
797
+ "symbol_name": source.get("symbol_name"),
798
+ "line_start": source.get("line_start"),
799
+ "line_end": source.get("line_end"),
800
+ "location": f"{source['file_path']}:{source.get('line_start')}-{source.get('line_end')}",
801
+ }
802
+ )
803
+ return cleaned_text, citations
804
+
805
  @staticmethod
806
  def _estimate_confidence(sources: List[dict]) -> str:
807
  if not sources:
 
1727
  "content": chunk["content"],
1728
  "searchable_text": chunk["searchable_text"],
1729
  "metadata_json": chunk.get("metadata_json") or {},
1730
+ }