compass-kb-validation 3.2.0__tar.gz → 3.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/PKG-INFO +1 -1
  2. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/__init__.py +1 -1
  3. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/citation.py +17 -0
  4. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/cli.py +25 -4
  5. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/corpus_health.py +11 -0
  6. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/eval_interop.py +36 -0
  7. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/grounding.py +14 -2
  8. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/providers/vectorstore.py +12 -1
  9. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass_kb_validation.egg-info/PKG-INFO +1 -1
  10. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/discover_and_validate.py +2 -1
  11. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/profile_kb.py +5 -1
  12. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/run_validation.py +2 -1
  13. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_citation.py +29 -0
  14. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_corpus_health.py +12 -0
  15. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_eval_interop.py +16 -0
  16. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_grounding.py +19 -0
  17. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_vectorstore_filters.py +14 -0
  18. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/LICENSE +0 -0
  19. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/README.md +0 -0
  20. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/__main__.py +0 -0
  21. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/ask.py +0 -0
  22. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/baseline.py +0 -0
  23. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/chunk_coherence.py +0 -0
  24. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/config/__init__.py +0 -0
  25. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/config_lint.py +0 -0
  26. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/content_inspect.py +0 -0
  27. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/corpus.py +0 -0
  28. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/diagnostics.py +0 -0
  29. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/embedding_drift.py +0 -0
  30. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/evidence.py +0 -0
  31. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/init_wizard.py +0 -0
  32. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/live_dashboard.py +0 -0
  33. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/mcp_server.py +0 -0
  34. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/models/__init__.py +0 -0
  35. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/notifications.py +0 -0
  36. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/pipeline/__init__.py +0 -0
  37. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/profiler.py +0 -0
  38. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/provenance.py +0 -0
  39. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/providers/__init__.py +0 -0
  40. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/providers/embedding.py +0 -0
  41. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/providers/graph.py +0 -0
  42. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/providers/source.py +0 -0
  43. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/providers/storage.py +0 -0
  44. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/remediation.py +0 -0
  45. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/report/__init__.py +0 -0
  46. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/report/_report_css.py +0 -0
  47. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/report/_report_js.py +0 -0
  48. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/report/analysis.py +0 -0
  49. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/report/audit.py +0 -0
  50. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/report/export.py +0 -0
  51. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/report/shell.py +0 -0
  52. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/retrieval_analysis.py +0 -0
  53. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/text_probes.py +0 -0
  54. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/trend_tracker.py +0 -0
  55. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/utils/__init__.py +0 -0
  56. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/validators/__init__.py +0 -0
  57. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/validators/api_upload.py +0 -0
  58. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/validators/base.py +0 -0
  59. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/validators/base_sparql.py +0 -0
  60. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/validators/cross_stage.py +0 -0
  61. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/validators/graphdb_metadata.py +0 -0
  62. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/validators/neptune_metadata.py +0 -0
  63. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/validators/redis_chunks.py +0 -0
  64. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/validators/registry.py +0 -0
  65. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/validators/retrieval_quality.py +0 -0
  66. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass/validators/s3_storage.py +0 -0
  67. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass_kb_validation.egg-info/SOURCES.txt +0 -0
  68. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
  69. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass_kb_validation.egg-info/entry_points.txt +0 -0
  70. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass_kb_validation.egg-info/requires.txt +0 -0
  71. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/compass_kb_validation.egg-info/top_level.txt +0 -0
  72. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/ingestion_validation/__init__.py +0 -0
  73. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/pyproject.toml +0 -0
  74. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/setup.cfg +0 -0
  75. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_alt_adapters.py +0 -0
  76. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_analysis_report.py +0 -0
  77. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_api_upload.py +0 -0
  78. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_ask.py +0 -0
  79. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_baseline.py +0 -0
  80. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_benchmark.py +0 -0
  81. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_check_catalog.py +0 -0
  82. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_cli.py +0 -0
  83. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_compat_shim.py +0 -0
  84. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_config.py +0 -0
  85. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_config_lint.py +0 -0
  86. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_content_inspect.py +0 -0
  87. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_corpus.py +0 -0
  88. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_coverage.py +0 -0
  89. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_cross_stage.py +0 -0
  90. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_diagnostics.py +0 -0
  91. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_discover_cli.py +0 -0
  92. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_discovery_parallel.py +0 -0
  93. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_embedding_drift.py +0 -0
  94. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_evidence.py +0 -0
  95. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_gate.py +0 -0
  96. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_graph_transport.py +0 -0
  97. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_graph_validators.py +0 -0
  98. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_init_wizard.py +0 -0
  99. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_manifest.py +0 -0
  100. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_mcp.py +0 -0
  101. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_migration.py +0 -0
  102. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_models.py +0 -0
  103. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_neptune_bugfixes.py +0 -0
  104. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_neptune_bugfixes2.py +0 -0
  105. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_notifications.py +0 -0
  106. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_pipeline.py +0 -0
  107. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_profiler.py +0 -0
  108. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_providers.py +0 -0
  109. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_redis_chunks.py +0 -0
  110. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_registry.py +0 -0
  111. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_remediation.py +0 -0
  112. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_reporting.py +0 -0
  113. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_retrieval_assertions.py +0 -0
  114. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_retrieval_quality.py +0 -0
  115. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_review_hardening.py +0 -0
  116. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_run_cli.py +0 -0
  117. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_sources.py +0 -0
  118. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_storage_validator.py +0 -0
  119. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_trends.py +0 -0
  120. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_universal_concerns.py +0 -0
  121. {compass_kb_validation-3.2.0 → compass_kb_validation-3.2.1}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compass-kb-validation
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: Knowledge-base readiness validation for RAG & AI agents — config-driven, deterministic pipeline integrity checks.
5
5
  Author: COMPASS
6
6
  License: MIT
@@ -5,4 +5,4 @@ end-to-end: API upload -> object storage -> knowledge graph -> vector store ->
5
5
  retrieval quality, plus cross-store consistency.
6
6
  """
7
7
 
8
- __version__ = "3.2.0"
8
+ __version__ = "3.2.1"
@@ -245,8 +245,16 @@ def _claim_before(text: str, idx: int) -> str:
245
245
  """The statement a citation supports: the text preceding it, back to the last real
246
246
  sentence break. Excludes the citation itself and anything after it."""
247
247
  start = 0
248
+ prev_start = 0
248
249
  for m in _SENT_BOUND.finditer(text[:idx]):
250
+ prev_start = start
249
251
  start = m.end()
252
+ # If the last sentence boundary sits right where the citation begins, that period is the
253
+ # TERMINATOR of the claim sentence (the standard "...claim. (SOURCE, p. N)" format), not a
254
+ # separator inside the claim - so text[start:idx] would be empty and the citation would be
255
+ # silently dropped. Fall back one boundary to capture the claim sentence itself.
256
+ if start >= idx - 2:
257
+ start = prev_start
250
258
  nl = text.rfind("\n", 0, idx)
251
259
  if nl + 1 > start:
252
260
  start = nl + 1
@@ -642,6 +650,15 @@ def audit_citations(response: str, eval_chunks: Optional[list[dict]], adapter: A
642
650
  summary = summarize(verdicts, response, l1_ran, l2_ran)
643
651
  if l1_ran:
644
652
  summary.update(context_utilization(citations, eval_chunks or [], cfg))
653
+ # DEFECT-003 safety net: if the response clearly carries citation-like patterns but none
654
+ # were extracted, say so rather than reporting a silent total_citations=0 that reads as
655
+ # "no citations exist" (which was the DEFECT-001 failure mode).
656
+ if not citations and response:
657
+ raw = len(_PAREN_CITE.findall(response))
658
+ if raw:
659
+ summary["diagnostic"] = (
660
+ f"{raw} citation-like pattern(s) matched by regex but 0 were extracted - "
661
+ "likely a claim-boundary parsing issue, not an absence of citations.")
645
662
  return {"verdicts": verdicts, "l1_ran": l1_ran, "l2_ran": l2_ran, "summary": summary}
646
663
 
647
664
 
@@ -1203,6 +1203,22 @@ def cmd_compare(argv: list[str]) -> int:
1203
1203
  if not cases:
1204
1204
  print(f" No usable cases in {args.ground_truth} "
1205
1205
  f"(check the eval.gt_* field paths / variant filter in your config).")
1206
+ # Say WHICH paths were tried and what resolved on the first record, so the user can see
1207
+ # the actual mismatch instead of guessing (DEFECT-007).
1208
+ from compass.eval_interop import diagnose_paths
1209
+ diag = diagnose_paths(args.ground_truth, {
1210
+ "gt_question_path": ev.gt_question_path,
1211
+ "gt_chunks_path": ev.gt_chunks_path,
1212
+ "gt_filters_path": ev.gt_filters_path,
1213
+ "gt_variant_path": ev.gt_variant_path,
1214
+ })
1215
+ if diag:
1216
+ print(" Configured paths on the first record:")
1217
+ for line in diag:
1218
+ print(line)
1219
+ if variant_filter:
1220
+ print(f" variant filter: {', '.join(variant_filter)} "
1221
+ f"(via gt_variant_path=\"{ev.gt_variant_path or '(unset)'}\")")
1206
1222
  return 2
1207
1223
 
1208
1224
  # Ground-truth cases carry production's own metadata filters. If the backend cannot
@@ -1214,7 +1230,9 @@ def cmd_compare(argv: list[str]) -> int:
1214
1230
  file=sys.stderr)
1215
1231
 
1216
1232
  vnote = f" (variant filter: {', '.join(variant_filter)})" if variant_filter else ""
1217
- print(f" Comparing compass retrieval against {len(cases)} ground-truth case(s){vnote} ...")
1233
+ # Progress goes to stderr under --json so stdout stays a clean, pipeable JSON document.
1234
+ print(f" Comparing compass retrieval against {len(cases)} ground-truth case(s){vnote} ...",
1235
+ file=sys.stderr if args.json else sys.stdout)
1218
1236
  try:
1219
1237
  payload = comparison_report(settings, cases, top_k=args.top_k or None,
1220
1238
  skip_empty=not args.include_empty)
@@ -1312,7 +1330,8 @@ def cmd_evidence(argv: list[str]) -> int:
1312
1330
  return 2
1313
1331
  limit = 1 if args.quick else (args.limit or settings.evidence.sample_size)
1314
1332
 
1315
- print(f" compass evidence - project={args.project or '(none)'} checks={checks} limit={limit}")
1333
+ print(f" compass evidence - project={args.project or '(none)'} checks={checks} limit={limit}",
1334
+ file=sys.stderr if args.format == "json" else sys.stdout)
1316
1335
  try:
1317
1336
  payload = ev.gather_evidence(settings, checks, document_filter=args.document, limit=limit)
1318
1337
  except Exception as exc: # noqa: BLE001 - unreachable/misconfigured backend -> clean message
@@ -1383,7 +1402,8 @@ def cmd_quality(argv: list[str]) -> int:
1383
1402
  except Exception as exc: # noqa: BLE001 - malformed/empty project YAML -> clean message, not a traceback
1384
1403
  print(f" Config error: {exc}")
1385
1404
  return 2
1386
- print(f" Sampling up to {args.sample} chunks for quality signals ...")
1405
+ print(f" Sampling up to {args.sample} chunks for quality signals ...",
1406
+ file=sys.stderr if args.json else sys.stdout)
1387
1407
  try:
1388
1408
  payload = quality_report(settings, sample=args.sample, min_chars=args.min_chars)
1389
1409
  except Exception as exc: # noqa: BLE001 - clean message, never a traceback
@@ -1428,7 +1448,8 @@ def cmd_freshness(argv: list[str]) -> int:
1428
1448
  except Exception as exc: # noqa: BLE001 - malformed/empty project YAML -> clean message, not a traceback
1429
1449
  print(f" Config error: {exc}")
1430
1450
  return 2
1431
- print(f" Checking source freshness for up to {args.sample} document(s) ...")
1451
+ print(f" Checking source freshness for up to {args.sample} document(s) ...",
1452
+ file=sys.stderr if args.json else sys.stdout)
1432
1453
  try:
1433
1454
  payload = freshness_report(settings, sample=args.sample, stale_after_days=args.stale_after)
1434
1455
  except Exception as exc: # noqa: BLE001 - clean message, never a traceback
@@ -261,6 +261,17 @@ def freshness_report(settings: Settings, *, sample: int = 500, stale_after_days:
261
261
  f"{without_key} of {len(seen_docs)} sampled document(s) carry no source key, so "
262
262
  f"their source could not be checked - likely a missing s3_key field (prefix/field "
263
263
  f"mismatch) or sources in a different store, not necessarily orphans.")
264
+ # A near-total orphan rate almost never means every source was deleted - it points at a
265
+ # systemic mismatch (the chunk s3_key does not match where objects actually live: wrong
266
+ # bucket, or a prefix baked into the key that the store does not use). Flag it so the user
267
+ # checks configuration before "reconciling" hundreds of false orphans (DEFECT-006).
268
+ if checked and len(orphans) / checked > 0.8:
269
+ result["warning"] = (
270
+ f"{len(orphans)}/{checked} sampled sources ({round(100 * len(orphans) / checked)}%) "
271
+ "look orphaned - a rate this high usually means a key mismatch (wrong bucket, or a "
272
+ "prefix in the chunk s3_key that the object store does not expect), not that the "
273
+ "sources are actually gone. Verify s3.bucket_name and the s3_key values before "
274
+ "reconciling.")
264
275
  return result
265
276
 
266
277
 
@@ -32,6 +32,42 @@ def get_path(record: Any, dotted: str) -> Any:
32
32
  return cur
33
33
 
34
34
 
35
+ def diagnose_paths(path: str, gt_paths: dict) -> list[str]:
36
+ """Explain why a ground-truth file yielded no usable cases: for each configured gt_* path,
37
+ report whether it resolves on the first record and what type/size was found. Deterministic,
38
+ read-only, best-effort - returns human-readable lines (empty list if the file is unreadable).
39
+ """
40
+ import json
41
+
42
+ try:
43
+ with open(path, encoding="utf-8") as f:
44
+ head = f.read(2_000_000)
45
+ data = json.loads(head)
46
+ except (OSError, ValueError):
47
+ # Maybe JSONL: try the first line.
48
+ try:
49
+ with open(path, encoding="utf-8") as f:
50
+ data = [json.loads(f.readline())]
51
+ except (OSError, ValueError):
52
+ return []
53
+ rec = data[0] if isinstance(data, list) and data else (data if isinstance(data, dict) else None)
54
+ if not isinstance(rec, dict):
55
+ return ["first record is not an object - expected a JSON array (or JSONL) of case objects"]
56
+ lines = []
57
+ for label, dotted in gt_paths.items():
58
+ if not dotted:
59
+ lines.append(f" {label}: (unset)")
60
+ continue
61
+ val = get_path(rec, dotted)
62
+ if val is None:
63
+ lines.append(f" {label}: \"{dotted}\" -> NOT FOUND")
64
+ elif isinstance(val, list):
65
+ lines.append(f" {label}: \"{dotted}\" -> found (list, {len(val)} item(s))")
66
+ else:
67
+ lines.append(f" {label}: \"{dotted}\" -> found ({type(val).__name__})")
68
+ return lines
69
+
70
+
35
71
  def normalize_filters(raw: Any) -> dict:
36
72
  """Parse a filters value into ``{field: "a|b"}`` (compass filter form).
37
73
 
@@ -411,9 +411,21 @@ def run_audit(eval_record: dict, adapter: Any, eval_cfg: Any, doc_exists: Any =
411
411
  scores = get_path(eval_record, getattr(eval_cfg, "gt_scores_path", "scores")) or {}
412
412
  test_id = str(eval_record.get("test_id") or eval_record.get("id") or "")
413
413
 
414
+ # Retrieval is empty only if BOTH the configured context path AND the retrieved chunks are
415
+ # empty. The default gt_context_path (and gt_chunks_path) do not fit every eval schema, so
416
+ # keying "empty retrieval" off one path alone raised a false HIGH signal on records that
417
+ # clearly had chunks (DEFECT-002). Cross-check the configured chunk path plus the common
418
+ # nested locations so the emptiness verdict is schema-agnostic.
419
+ eval_chunks = get_path(eval_record, getattr(eval_cfg, "gt_chunks_path", "context.chunks"))
420
+ _chunk_candidates = (eval_chunks,
421
+ get_path(eval_record, "context.chunks"),
422
+ get_path(eval_record, "chunks"))
423
+ has_chunks = any(isinstance(c, list) and len(c) > 0 for c in _chunk_candidates)
424
+ context_empty = _is_empty_context(context) and not has_chunks
425
+
414
426
  qtype, intents = classify_query(query, getattr(eval_cfg, "structured_query_patterns", []))
415
427
  result = GroundingResult(test_id=test_id, query=query, query_type=qtype, intents=intents,
416
- context_empty=_is_empty_context(context),
428
+ context_empty=context_empty,
417
429
  eval_scores=scores if isinstance(scores, dict) else {})
418
430
  # Record which fields were auto-detected (vs configured), and whether this is a
419
431
  # retrieval-only case (no generated answer) - so the CLI can explain "0 citations" honestly.
@@ -429,7 +441,7 @@ def run_audit(eval_record: dict, adapter: Any, eval_cfg: Any, doc_exists: Any =
429
441
  # Citation audit: verify each cited claim at its cited location (Level 1 against the eval's
430
442
  # retrieved chunks when present, Level 2 against the live KB). The "cited document absent"
431
443
  # signal is derived from it, so citation extraction lives in exactly one place.
432
- eval_chunks = get_path(eval_record, getattr(eval_cfg, "gt_chunks_path", "context.chunks"))
444
+ # (eval_chunks was resolved above for the empty-retrieval cross-check.)
433
445
  result.citations = citation.audit_citations(
434
446
  response, eval_chunks if isinstance(eval_chunks, list) else None, adapter, eval_cfg,
435
447
  extra_patterns=getattr(eval_cfg, "citation_patterns", None), doc_exists=doc_exists)
@@ -205,6 +205,15 @@ class RedisVectorStoreAdapter(VectorStoreAdapter):
205
205
  special = r"""\.+*?[]{}<>()@!#$%^&-=~'"`:;,/| """
206
206
  return "".join(f"\\{ch}" if ch in special else ch for ch in value)
207
207
 
208
+ @staticmethod
209
+ def escape_text(value: str) -> str:
210
+ """Escape RediSearch query-parser metacharacters for a TEXT-field query while
211
+ PRESERVING spaces as token separators. TAG escaping (escape_tag) backslash-escapes
212
+ spaces, which collapses a multi-word value into a single unmatchable token, so every
213
+ multi-word TEXT search silently returned nothing (DEFECT-008)."""
214
+ special = r"""\+*?[]{}<>()@!#$%^&-=~'"`:;,/|"""
215
+ return "".join(f"\\{ch}" if ch in special else ch for ch in value)
216
+
208
217
  def _id_query(self, search_value: str) -> str:
209
218
  field = self.config.document_id_field
210
219
  if self.config.document_id_field_type == "TAG":
@@ -305,7 +314,9 @@ class RedisVectorStoreAdapter(VectorStoreAdapter):
305
314
  return self._parse_search(raw, keep_binary_fields=(emb,))
306
315
 
307
316
  def text_search(self, field: str, value: str, limit: int = 10) -> list[dict]:
308
- escaped = self.escape_tag(value)
317
+ # TEXT-field query: escape parser metacharacters but keep spaces as token separators
318
+ # so a multi-word value matches its tokens (was TAG-escaped -> matched nothing).
319
+ escaped = self.escape_text(value)
309
320
  query = f"@{field}:({escaped})"
310
321
  try:
311
322
  raw = self.client.execute_command(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compass-kb-validation
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: Knowledge-base readiness validation for RAG & AI agents — config-driven, deterministic pipeline integrity checks.
5
5
  Author: COMPASS
6
6
  License: MIT
@@ -26,7 +26,8 @@ EXIT_OK, EXIT_GATE_FAILED, EXIT_CONFIG = 0, 1, 2
26
26
 
27
27
 
28
28
  def parse_args(argv=None) -> argparse.Namespace:
29
- p = argparse.ArgumentParser(description="Discover storage documents and validate vector chunks")
29
+ p = argparse.ArgumentParser(prog="compass discover",
30
+ description="Discover storage documents and validate vector chunks")
30
31
  p.add_argument("--project", help="Project name (required unless --list-checks)")
31
32
  p.add_argument("--list-checks", action="store_true",
32
33
  help="Print the vector-store check catalogue (number, name, default severity tier) and exit.")
@@ -27,7 +27,11 @@ EXIT_OK, EXIT_NONE_REACHABLE, EXIT_CONFIG = 0, 1, 2
27
27
 
28
28
 
29
29
  def parse_args(argv=None) -> argparse.Namespace:
30
- p = argparse.ArgumentParser(description="Profile pipeline stages and suggest a COMPASS config")
30
+ # Explicit prog: when dispatched via `compass profile` / `python -m compass profile`, the
31
+ # command name is stripped and sys.argv[0] can be None, which argparse's default
32
+ # prog=basename(sys.argv[0]) chokes on (TypeError on None). Naming it avoids that.
33
+ p = argparse.ArgumentParser(prog="compass profile",
34
+ description="Profile pipeline stages and suggest a COMPASS config")
31
35
  p.add_argument("--project", required=True, help="Project name")
32
36
  p.add_argument("--env", default="dev", help="Environment")
33
37
  p.add_argument("--stage", choices=["storage", "graph", "vector", "all"], default="all",
@@ -29,7 +29,8 @@ EXIT_OK, EXIT_FAILURES, EXIT_CONFIG = 0, 1, 2
29
29
 
30
30
 
31
31
  def parse_args(argv=None) -> argparse.Namespace:
32
- p = argparse.ArgumentParser(description="COMPASS — AI knowledge base validation")
32
+ p = argparse.ArgumentParser(prog="compass validate",
33
+ description="COMPASS - AI knowledge base validation")
33
34
  p.add_argument("--project", required=True, help="Project name (loads config/projects/<name>.yaml)")
34
35
  p.add_argument("--env", default=None, help="Environment: dev, staging, prod")
35
36
  p.add_argument("--file", help="Validate a single file (overrides test_data)")
@@ -443,3 +443,32 @@ def test_report_renders_citation_section():
443
443
  assert "Citation audit" in md and "Generation hallucination" in md
444
444
  assert "lexical" in md.lower() # honest-boundary note present
445
445
  assert "Where to fix" in md and "**Generation**" in md # per-fault remediation present
446
+
447
+
448
+ def test_citation_after_sentence_period_is_extracted():
449
+ # DEFECT-001: the standard "...claim. (SOURCE, p. N)" format (citation right after the
450
+ # sentence-ending period) must extract the claim, not silently drop the citation.
451
+ text = ("Growth is expected to stay robust, supported by resilient demand. "
452
+ "(SOURCE, June 2026, p. 1) Earnings are projected to grow.")
453
+ cites = citation.extract_citations(text)
454
+ assert len(cites) == 1
455
+ assert cites[0].claim.endswith("resilient demand.") # the claim sentence, not empty
456
+ # multiple sentences: the claim is the LAST sentence before the citation, not the whole text
457
+ multi = citation.extract_citations("First point here. Second claim with detail. "
458
+ "(SOURCE, 2026, p. 2) Next.")
459
+ assert multi and multi[0].claim == "Second claim with detail."
460
+ # a mid-sentence citation still resolves to the preceding text
461
+ mid = citation.extract_citations("Earnings grew 12% (SOURCE, 2026, p. 3) which beat.")
462
+ assert mid and mid[0].claim == "Earnings grew 12%"
463
+
464
+
465
+ def test_diagnostic_when_pattern_matches_but_nothing_extracted():
466
+ # DEFECT-003: a citation-like pattern with no supporting claim (extraction yields 0) must
467
+ # surface a diagnostic, not a silent total_citations=0 that reads as "no citations exist".
468
+ from types import SimpleNamespace
469
+ cfg = SimpleNamespace(citation_patterns=[], citation_page_tolerance=0,
470
+ citation_verify_entities=False,
471
+ gt_chunk_doc_field="document_title", gt_chunk_page_field="page_start")
472
+ res = citation.audit_citations("(SOURCE, 2026, p. 1)", None, None, cfg)
473
+ assert res["summary"]["total_citations"] == 0
474
+ assert "matched by regex but 0 were extracted" in res["summary"].get("diagnostic", "")
@@ -102,3 +102,15 @@ def test_cmd_freshness_end_to_end(monkeypatch, capsys):
102
102
  if cat == "vectorstore" else store)
103
103
  assert cli.cmd_freshness([]) == 0
104
104
  assert "Freshness" in capsys.readouterr().out
105
+
106
+
107
+ def test_freshness_warns_on_near_total_orphan_rate():
108
+ # DEFECT-006: a ~100% orphan rate almost always means a key/bucket mismatch, not that every
109
+ # source was deleted. Flag it so the user checks config before "reconciling" false orphans.
110
+ now = datetime(2026, 7, 10, tzinfo=timezone.utc)
111
+ chunks = [make_chunk(f"doc{i}", 0, s3_key=f"missing{i}.pdf") for i in range(5)]
112
+ store = FakeStorageAdapter(objects={}) # nothing found -> all orphaned
113
+ rep = freshness_report(Settings(), sample=10, now=now,
114
+ vectorstore=FakeVectorStoreAdapter(chunks=chunks), storage=store)
115
+ assert rep["orphan_count"] == 5
116
+ assert "warning" in rep and "key mismatch" in rep["warning"]
@@ -206,3 +206,19 @@ def test_cmd_compare_end_to_end(monkeypatch, tmp_path, capsys):
206
206
  assert cli.cmd_compare(["--ground-truth", str(gt)]) == 0
207
207
  out = capsys.readouterr().out
208
208
  assert "Retrieval comparison" in out and "VERDICT" in out
209
+
210
+
211
+ def test_diagnose_paths_reports_found_and_missing(tmp_path):
212
+ # DEFECT-007: when compare finds no usable cases, say which configured paths resolved on the
213
+ # first record and which did not, instead of a generic "check your config".
214
+ from compass.eval_interop import diagnose_paths
215
+ p = tmp_path / "gt.json"
216
+ p.write_text(json.dumps([{"input": "q", "context": {"chunks": [{"text": "a"}, {"text": "b"}]}}]))
217
+ lines = "\n".join(diagnose_paths(str(p), {
218
+ "gt_question_path": "question", # absent
219
+ "gt_chunks_path": "context.chunks", # present, 2 items
220
+ "gt_variant_path": "", # unset
221
+ }))
222
+ assert 'gt_question_path: "question" -> NOT FOUND' in lines
223
+ assert 'gt_chunks_path: "context.chunks" -> found (list, 2 item(s))' in lines
224
+ assert "gt_variant_path: (unset)" in lines
@@ -263,3 +263,22 @@ def test_fabrication_gate_covers_semantic_fabricated_source():
263
263
  r2 = run_audit({"input": "outlook?", "response": "Cautious.", "context.retrieved": "cautious given rates",
264
264
  "scores": {"Groundedness": {"score": 0.9, "target": 0.7}}}, KB(), _cfg(gt_context_path="context.retrieved"))
265
265
  assert r2.has_fabrication is False
266
+
267
+
268
+ def test_context_not_empty_when_chunks_present_defect_002():
269
+ # DEFECT-002: chunks nested under context.chunks must not read as "empty retrieval" just
270
+ # because the configured gt_context_path does not resolve for this eval schema.
271
+ from compass.config import Settings
272
+
273
+ class _Ad:
274
+ def text_search(self, *a, **k):
275
+ return []
276
+ def get_by_id(self, *a, **k):
277
+ return None
278
+
279
+ ev = Settings().eval
280
+ with_chunks = {"input": "q", "output": "answer",
281
+ "context": {"type": "knowledge_retrieval", "chunks": [{"text": "a"}, {"text": "b"}]}}
282
+ assert run_audit(with_chunks, _Ad(), ev).context_empty is False
283
+ truly_empty = {"input": "q", "output": "answer", "context": {}}
284
+ assert run_audit(truly_empty, _Ad(), ev).context_empty is True
@@ -83,6 +83,20 @@ def test_redis_builds_hybrid_tag_prefilter():
83
83
  assert "=>[KNN 5 @text_embedding $vec AS __score]" in client.query
84
84
 
85
85
 
86
+ def test_text_search_preserves_spaces_for_multiword_queries():
87
+ # DEFECT-008: a multi-word TEXT search must keep spaces as token separators. TAG-escaping
88
+ # backslash-escaped them, collapsing the value into one unmatchable token so every
89
+ # multi-word search silently returned nothing.
90
+ client = _FakeRedis()
91
+ a = RedisVectorStoreAdapter(_cfg("redis"), client=client)
92
+ a.text_search("text", "Growth is robust", 5)
93
+ assert client.query == "@text:(Growth is robust)" # spaces intact
94
+ assert "\\ " not in client.query # NOT the broken TAG-escaped form
95
+ # single words are unchanged; query-parser metacharacters are still escaped
96
+ assert RedisVectorStoreAdapter.escape_text("ACWI") == "ACWI"
97
+ assert "\\(" in RedisVectorStoreAdapter.escape_text("S&P (est)")
98
+
99
+
86
100
  # --------------------------------------------------------------------------- #
87
101
  # Qdrant - Filter(must=[FieldCondition(MatchValue|MatchAny)])
88
102
  # --------------------------------------------------------------------------- #