compass-kb-validation 2.7.0__tar.gz → 2.8.0__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 (118) hide show
  1. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/PKG-INFO +1 -1
  2. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/compass_kb_validation.egg-info/PKG-INFO +1 -1
  3. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/compass_kb_validation.egg-info/SOURCES.txt +3 -0
  4. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/__init__.py +1 -1
  5. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/cli.py +133 -15
  6. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/config/__init__.py +20 -1
  7. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/corpus_health.py +54 -4
  8. compass_kb_validation-2.8.0/ingestion_validation/evidence.py +358 -0
  9. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/grounding.py +50 -2
  10. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/report/__init__.py +148 -125
  11. compass_kb_validation-2.8.0/ingestion_validation/text_probes.py +57 -0
  12. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/validators/retrieval_quality.py +4 -28
  13. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_citation.py +28 -0
  14. compass_kb_validation-2.8.0/tests/test_evidence.py +167 -0
  15. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_reporting.py +16 -16
  16. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_review_hardening.py +34 -1
  17. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/LICENSE +0 -0
  18. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/README.md +0 -0
  19. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
  20. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/compass_kb_validation.egg-info/entry_points.txt +0 -0
  21. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/compass_kb_validation.egg-info/requires.txt +0 -0
  22. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/compass_kb_validation.egg-info/top_level.txt +0 -0
  23. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/discover_and_validate.py +0 -0
  24. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/__main__.py +0 -0
  25. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/ask.py +0 -0
  26. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/baseline.py +0 -0
  27. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/chunk_coherence.py +0 -0
  28. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/citation.py +0 -0
  29. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/config_lint.py +0 -0
  30. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/content_inspect.py +0 -0
  31. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/corpus.py +0 -0
  32. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/diagnostics.py +0 -0
  33. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/embedding_drift.py +0 -0
  34. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/eval_interop.py +0 -0
  35. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/init_wizard.py +0 -0
  36. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/live_dashboard.py +0 -0
  37. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/mcp_server.py +0 -0
  38. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/models/__init__.py +0 -0
  39. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/notifications.py +0 -0
  40. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/pipeline/__init__.py +0 -0
  41. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/profiler.py +0 -0
  42. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/provenance.py +0 -0
  43. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/providers/__init__.py +0 -0
  44. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/providers/embedding.py +0 -0
  45. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/providers/graph.py +0 -0
  46. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/providers/source.py +0 -0
  47. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/providers/storage.py +0 -0
  48. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/providers/vectorstore.py +0 -0
  49. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/remediation.py +0 -0
  50. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/report/_report_css.py +0 -0
  51. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/report/_report_js.py +0 -0
  52. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/report/analysis.py +0 -0
  53. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/report/export.py +0 -0
  54. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/report/shell.py +0 -0
  55. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/retrieval_analysis.py +0 -0
  56. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/trend_tracker.py +0 -0
  57. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/utils/__init__.py +0 -0
  58. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/validators/__init__.py +0 -0
  59. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/validators/api_upload.py +0 -0
  60. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/validators/base.py +0 -0
  61. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/validators/base_sparql.py +0 -0
  62. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/validators/cross_stage.py +0 -0
  63. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/validators/graphdb_metadata.py +0 -0
  64. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/validators/neptune_metadata.py +0 -0
  65. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/validators/redis_chunks.py +0 -0
  66. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/validators/registry.py +0 -0
  67. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/ingestion_validation/validators/s3_storage.py +0 -0
  68. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/profile_kb.py +0 -0
  69. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/pyproject.toml +0 -0
  70. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/run_validation.py +0 -0
  71. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/setup.cfg +0 -0
  72. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_alt_adapters.py +0 -0
  73. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_analysis_report.py +0 -0
  74. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_api_upload.py +0 -0
  75. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_ask.py +0 -0
  76. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_baseline.py +0 -0
  77. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_benchmark.py +0 -0
  78. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_check_catalog.py +0 -0
  79. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_cli.py +0 -0
  80. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_config.py +0 -0
  81. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_config_lint.py +0 -0
  82. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_content_inspect.py +0 -0
  83. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_corpus.py +0 -0
  84. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_corpus_health.py +0 -0
  85. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_coverage.py +0 -0
  86. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_cross_stage.py +0 -0
  87. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_diagnostics.py +0 -0
  88. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_discover_cli.py +0 -0
  89. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_discovery_parallel.py +0 -0
  90. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_embedding_drift.py +0 -0
  91. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_eval_interop.py +0 -0
  92. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_gate.py +0 -0
  93. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_graph_transport.py +0 -0
  94. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_graph_validators.py +0 -0
  95. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_grounding.py +0 -0
  96. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_init_wizard.py +0 -0
  97. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_manifest.py +0 -0
  98. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_mcp.py +0 -0
  99. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_migration.py +0 -0
  100. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_models.py +0 -0
  101. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_neptune_bugfixes.py +0 -0
  102. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_neptune_bugfixes2.py +0 -0
  103. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_notifications.py +0 -0
  104. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_pipeline.py +0 -0
  105. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_profiler.py +0 -0
  106. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_providers.py +0 -0
  107. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_redis_chunks.py +0 -0
  108. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_registry.py +0 -0
  109. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_remediation.py +0 -0
  110. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_retrieval_assertions.py +0 -0
  111. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_retrieval_quality.py +0 -0
  112. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_run_cli.py +0 -0
  113. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_sources.py +0 -0
  114. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_storage_validator.py +0 -0
  115. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_trends.py +0 -0
  116. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_universal_concerns.py +0 -0
  117. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_utils.py +0 -0
  118. {compass_kb_validation-2.7.0 → compass_kb_validation-2.8.0}/tests/test_vectorstore_filters.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compass-kb-validation
3
- Version: 2.7.0
3
+ Version: 2.8.0
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compass-kb-validation
3
- Version: 2.7.0
3
+ Version: 2.8.0
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
@@ -24,6 +24,7 @@ ingestion_validation/corpus_health.py
24
24
  ingestion_validation/diagnostics.py
25
25
  ingestion_validation/embedding_drift.py
26
26
  ingestion_validation/eval_interop.py
27
+ ingestion_validation/evidence.py
27
28
  ingestion_validation/grounding.py
28
29
  ingestion_validation/init_wizard.py
29
30
  ingestion_validation/live_dashboard.py
@@ -33,6 +34,7 @@ ingestion_validation/profiler.py
33
34
  ingestion_validation/provenance.py
34
35
  ingestion_validation/remediation.py
35
36
  ingestion_validation/retrieval_analysis.py
37
+ ingestion_validation/text_probes.py
36
38
  ingestion_validation/trend_tracker.py
37
39
  ingestion_validation/config/__init__.py
38
40
  ingestion_validation/models/__init__.py
@@ -82,6 +84,7 @@ tests/test_discover_cli.py
82
84
  tests/test_discovery_parallel.py
83
85
  tests/test_embedding_drift.py
84
86
  tests/test_eval_interop.py
87
+ tests/test_evidence.py
85
88
  tests/test_gate.py
86
89
  tests/test_graph_transport.py
87
90
  tests/test_graph_validators.py
@@ -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__ = "2.7.0"
8
+ __version__ = "2.8.0"
@@ -1251,6 +1251,91 @@ def _health_env(args) -> None:
1251
1251
  pass
1252
1252
 
1253
1253
 
1254
+ def cmd_evidence(argv: list[str]) -> int:
1255
+ p = argparse.ArgumentParser(
1256
+ prog="compass evidence",
1257
+ description="Produce live, reproducible PROOF of the issues a discovery report flags - the "
1258
+ "actual chunks, queries and scores to attach to a ticket. Read-only, no LLM, no "
1259
+ "live embedding: probes are built from the KB's own chunks and run the same "
1260
+ "checks as validation, so a CONFIRMED verdict is proof of what the report found.")
1261
+ p.add_argument("--project")
1262
+ p.add_argument("--env", default="")
1263
+ p.add_argument("--checks", "-c", default=",".join(evidence_mod_all()),
1264
+ help="Comma-separated: paraphrase,boundary_spanning,exact_term,duplicate_chunks,"
1265
+ "near_duplicate,chunk_boundaries (default: all).")
1266
+ p.add_argument("--document", "-d", default="", help="Only probe documents whose name contains this.")
1267
+ p.add_argument("--limit", "-l", type=int, default=0, help="Max documents to probe (default: config sample_size).")
1268
+ p.add_argument("--quick", action="store_true", help="Probe a single document.")
1269
+ p.add_argument("--format", "-f", choices=["markdown", "json"], default="markdown")
1270
+ p.add_argument("--output", "-o", help="Write to a file (default: stdout).")
1271
+ args = p.parse_args(argv)
1272
+
1273
+ try:
1274
+ from dotenv import load_dotenv
1275
+ for fn in (".env", f".env.{args.env}" if args.env else None):
1276
+ if fn and Path(fn).exists():
1277
+ load_dotenv(fn, override=True)
1278
+ except Exception: # noqa: BLE001 - dotenv optional / file absent
1279
+ pass
1280
+
1281
+ from ingestion_validation import evidence as ev
1282
+ from ingestion_validation.config import load_settings, project_config_file
1283
+
1284
+ if args.project and project_config_file(args.project) is None:
1285
+ print(f" Project '{args.project}' not found (config/projects/{args.project}.yaml).")
1286
+ return 2
1287
+ try:
1288
+ settings = load_settings(project=args.project, env=args.env or None)
1289
+ except Exception as exc: # noqa: BLE001
1290
+ print(f" Config error: {exc}")
1291
+ return 2
1292
+
1293
+ valid = set(ev.ALL_CHECKS)
1294
+ checks = [c.strip() for c in args.checks.split(",") if c.strip() in valid]
1295
+ bad = [c.strip() for c in args.checks.split(",") if c.strip() and c.strip() not in valid]
1296
+ if bad:
1297
+ print(f" Unknown check(s): {', '.join(bad)}. Valid: {', '.join(ev.ALL_CHECKS)}.")
1298
+ return 2
1299
+ if not checks:
1300
+ print(" No valid checks selected.")
1301
+ return 2
1302
+ if args.limit < 0:
1303
+ print(" --limit must be >= 0 (0 uses the configured sample_size).")
1304
+ return 2
1305
+ limit = 1 if args.quick else (args.limit or settings.evidence.sample_size)
1306
+
1307
+ print(f" compass evidence - project={args.project or '(none)'} checks={checks} limit={limit}")
1308
+ try:
1309
+ payload = ev.gather_evidence(settings, checks, document_filter=args.document, limit=limit)
1310
+ except Exception as exc: # noqa: BLE001 - unreachable/misconfigured backend -> clean message
1311
+ print(f" Could not gather evidence: {exc}")
1312
+ return 1
1313
+ if payload.get("error"):
1314
+ print(f" {payload['error']}")
1315
+ return 1
1316
+
1317
+ body = ev.format_json(payload, args.project or "") if args.format == "json" \
1318
+ else ev.format_markdown(payload, args.project or "")
1319
+ if args.output:
1320
+ Path(args.output).parent.mkdir(parents=True, exist_ok=True)
1321
+ Path(args.output).write_text(body + "\n", encoding="utf-8")
1322
+ print(f" Wrote {args.output}")
1323
+ else:
1324
+ print(body)
1325
+ counts: dict[str, int] = {}
1326
+ for r in payload["results"]:
1327
+ counts[r.verdict] = counts.get(r.verdict, 0) + 1
1328
+ print(f" {payload['docs_probed']} doc(s) probed - "
1329
+ f"CONFIRMED {counts.get('CONFIRMED', 0)}, WEAK {counts.get('WEAK', 0)}, "
1330
+ f"not reproduced {counts.get('NOT_REPRODUCED', 0)}, errors {counts.get('ERROR', 0)}")
1331
+ return 0
1332
+
1333
+
1334
+ def evidence_mod_all() -> tuple:
1335
+ from ingestion_validation.evidence import ALL_CHECKS
1336
+ return ALL_CHECKS
1337
+
1338
+
1254
1339
  def cmd_quality(argv: list[str]) -> int:
1255
1340
  p = argparse.ArgumentParser(
1256
1341
  prog="compass quality",
@@ -1475,25 +1560,55 @@ def cmd_grounding_audit(argv: list[str]) -> int:
1475
1560
  else:
1476
1561
  print(body)
1477
1562
 
1478
- # Gate on fabrications in EITHER path: a count that disagrees with the KB, or a cited
1479
- # document absent from it. (Previously only structured count mismatches counted, so a
1480
- # semantic fabricated-source case exited 0 despite being flagged.)
1481
- # Citation audit rollup across cases (deterministic; only counts checkable citations).
1482
- cit_sound = sum(r.citations.get("summary", {}).get("sound", 0) for r in results)
1483
- cit_checkable = sum(r.citations.get("summary", {}).get("checkable", 0) for r in results)
1484
- if cit_checkable:
1485
- faultmix: dict[str, int] = {}
1563
+ if not results:
1564
+ if args.format != "json":
1565
+ print("\n No eval records parsed from the input (empty file or no JSON objects).")
1566
+ return 0
1567
+
1568
+ # Human-readable summary lines go to stdout only for the markdown format - in --format json
1569
+ # they would corrupt the JSON body a downstream parser is reading.
1570
+ if args.format != "json":
1571
+ # If the answer field (or others) had to be auto-detected, say so once - a silent field
1572
+ # mismatch is the #1 reason grounding-audit "finds nothing".
1573
+ auto = {}
1486
1574
  for r in results:
1487
- for k, n in r.citations.get("summary", {}).get("faults", {}).items():
1488
- faultmix[k] = faultmix.get(k, 0) + n
1489
- acc = round(100.0 * cit_sound / cit_checkable, 1)
1490
- print(f"\n Citation audit: {cit_sound}/{cit_checkable} checkable citations grounded ({acc}%).")
1491
- if faultmix:
1492
- print(" faults: " + ", ".join(f"{k} x{n}" for k, n in sorted(faultmix.items())))
1575
+ for kind, info in (r.field_resolution or {}).items():
1576
+ auto.setdefault(kind, info["used"])
1577
+ if auto:
1578
+ mapping = ", ".join(f"{k} <- '{p}'" for k, p in sorted(auto.items()))
1579
+ print(f"\n NOTE: auto-detected eval fields ({mapping}). "
1580
+ "Set eval.gt_question_path / gt_response_path / gt_context_path to pin them.")
1581
+
1582
+ # Citation audit rollup across cases (deterministic; only counts checkable citations).
1583
+ total_cits = sum(r.citations.get("summary", {}).get("total_citations", 0) for r in results)
1584
+ cit_sound = sum(r.citations.get("summary", {}).get("sound", 0) for r in results)
1585
+ cit_checkable = sum(r.citations.get("summary", {}).get("checkable", 0) for r in results)
1586
+ if cit_checkable:
1587
+ faultmix: dict[str, int] = {}
1588
+ for r in results:
1589
+ for k, n in r.citations.get("summary", {}).get("faults", {}).items():
1590
+ faultmix[k] = faultmix.get(k, 0) + n
1591
+ acc = round(100.0 * cit_sound / cit_checkable, 1)
1592
+ print(f"\n Citation audit: {cit_sound}/{cit_checkable} checkable citations grounded ({acc}%).")
1593
+ if faultmix:
1594
+ print(" faults: " + ", ".join(f"{k} x{n}" for k, n in sorted(faultmix.items())))
1595
+ elif total_cits == 0:
1596
+ # 0 citations is either a retrieval-only export (nothing to audit) or, far more often,
1597
+ # the answer field is not mapped - say which, loudly, instead of a silent INFO.
1598
+ if all(r.retrieval_only for r in results):
1599
+ print("\n NOTE: these are retrieval-only cases (no generated answer) - nothing to cite-audit.")
1600
+ else:
1601
+ print("\n WARNING: 0 citations found across "
1602
+ f"{len(results)} case(s). If the answers DO cite sources, the answer field is "
1603
+ "probably not mapped - set eval.gt_response_path to your answer field (e.g. "
1604
+ "'output'/'answer'), and eval.citation_patterns if your citation style is custom.")
1493
1605
 
1606
+ # The fabrication gate drives the exit code regardless of output format (the message is
1607
+ # print-gated above; only the return value matters for --format json).
1494
1608
  fabricated = sum(1 for r in results if r.has_fabrication)
1495
1609
  if fabricated:
1496
- print(f"\n {fabricated}/{len(results)} case(s) contain a claim or citation not backed by the KB.")
1610
+ if args.format != "json":
1611
+ print(f"\n {fabricated}/{len(results)} case(s) contain a claim or citation not backed by the KB.")
1497
1612
  if args.fail_on_fabrication:
1498
1613
  return 1
1499
1614
  return 0
@@ -1552,6 +1667,7 @@ Commands:
1552
1667
  compare compare retrieval against a ground-truth file (precision/recall/Jaccard)
1553
1668
  quality score chunk quality (density, self-containedness, boilerplate) - no LLM
1554
1669
  freshness flag stale sources + orphan documents (source object gone)
1670
+ evidence produce live, reproducible PROOF of flagged issues (chunks + queries) - no LLM
1555
1671
  grounding-audit check an eval's grounding finding against the KB (count claims) - no LLM
1556
1672
  demo generate a sample report from synthetic data (no backend needed)
1557
1673
  init [name] scaffold a project (config + .env.example + marker). -i/--interactive wizard; --graph graph block; --ci workflow
@@ -1597,6 +1713,8 @@ def main(argv: list[str] | None = None) -> int:
1597
1713
  return cmd_compare(rest)
1598
1714
  if cmd == "quality":
1599
1715
  return cmd_quality(rest)
1716
+ if cmd == "evidence":
1717
+ return cmd_evidence(rest)
1600
1718
  if cmd == "grounding-audit":
1601
1719
  return cmd_grounding_audit(rest)
1602
1720
  if cmd == "freshness":
@@ -569,6 +569,22 @@ class CorpusConfig:
569
569
  extra: dict[str, Any] = field(default_factory=dict)
570
570
 
571
571
 
572
+ @dataclass
573
+ class EvidenceConfig:
574
+ """`compass evidence`: how many documents to probe and how strict the confirm thresholds are.
575
+
576
+ Evidence is a read-only proof surface; these only tune sampling/sensitivity, never a
577
+ pass/fail. All deterministic - no LLM, no live embedding.
578
+ """
579
+
580
+ sample_size: int = 10 # documents probed per run (--limit overrides)
581
+ output_dir: str = "reports/evidence"
582
+ # Below this MEAN overlap = paraphrase weakness CONFIRMED. Matches the retrieval-quality
583
+ # validator's own paraphrase threshold, so evidence reproduces what validation flags.
584
+ paraphrase_overlap_threshold: float = 0.3
585
+ near_duplicate_cosine: float = 0.97 # at/above this chunk-embedding cosine = near-duplicate
586
+
587
+
572
588
  @dataclass
573
589
  class EmbeddingConfig:
574
590
  """How to turn a natural-language query into a vector for `compass ask`.
@@ -728,6 +744,7 @@ class Settings:
728
744
  eval: EvalConfig = field(default_factory=EvalConfig)
729
745
  consistency: ConsistencyConfig = field(default_factory=ConsistencyConfig)
730
746
  corpus: CorpusConfig = field(default_factory=CorpusConfig)
747
+ evidence: EvidenceConfig = field(default_factory=EvidenceConfig)
731
748
  # Non-service metadata carried through from config files.
732
749
  project_name: str = ""
733
750
  display_name: str = ""
@@ -749,7 +766,8 @@ _META_KEYS = frozenset({"default_env", "display_name", "environments", "skip_ste
749
766
  # new block can never be added to one and forgotten in the other (which is exactly how
750
767
  # `consistency` came to be silently dropped at the top level).
751
768
  _SERVICE_BLOCKS = ("api", "s3", "graphdb", "redis", "neptune", "pipeline",
752
- "retrieval", "discovery", "embedding", "eval", "consistency", "corpus")
769
+ "retrieval", "discovery", "embedding", "eval", "consistency", "corpus",
770
+ "evidence")
753
771
 
754
772
 
755
773
  def _deep_merge(base: dict, overlay: dict) -> dict:
@@ -880,6 +898,7 @@ def load_settings(
880
898
  eval=_hydrate(EvalConfig, flat.get("eval")),
881
899
  consistency=_hydrate(ConsistencyConfig, flat.get("consistency")),
882
900
  corpus=_hydrate(CorpusConfig, flat.get("corpus")),
901
+ evidence=_hydrate(EvidenceConfig, flat.get("evidence")),
883
902
  project_name=project or "",
884
903
  display_name=merged.get("display_name", ""),
885
904
  environment=resolved_env,
@@ -23,10 +23,24 @@ _SENTENCE_END = tuple(".!?\"')]}")
23
23
  # --------------------------------------------------------------------------- #
24
24
  # Chunk quality (ENH-6)
25
25
  # --------------------------------------------------------------------------- #
26
- def _chunk_quality(text: str, min_chars: int, boilerplate_lines: set[str]) -> dict:
26
+ def _is_tabular(t: str, digit_ratio: float = 0.4) -> bool:
27
+ """Heuristic: the chunk is a table / structured data, where low lexical uniqueness and
28
+ repetition are EXPECTED (repeated column labels, codes, numbers) - not a quality problem.
29
+ ``digit_ratio`` is config-driven (redis.table_digit_ratio_threshold) so a numeric-but-prose
30
+ corpus is not misflagged; prose runs ~2-5% digits, tables far higher."""
31
+ lines = [ln for ln in t.splitlines() if ln.strip()]
32
+ if sum(1 for ln in lines if ln.count("|") >= 2) >= 2:
33
+ return True
34
+ digits = sum(c.isdigit() for c in t)
35
+ return len(t) > 60 and digits / len(t) > digit_ratio
36
+
37
+
38
+ def _chunk_quality(text: str, min_chars: int, boilerplate_lines: set[str],
39
+ digit_ratio: float = 0.4) -> dict:
27
40
  t = (text or "").strip()
28
41
  toks = _WORD.findall(t.lower())
29
42
  unique_ratio = len(set(toks)) / len(toks) if toks else 0.0
43
+ tabular = _is_tabular(t, digit_ratio)
30
44
  too_short = len(t) < min_chars
31
45
  starts_mid = bool(t[:1]) and t[:1].islower() # begins mid-sentence
32
46
  ends_clean = bool(t) and t[-1] in _SENTENCE_END
@@ -43,7 +57,7 @@ def _chunk_quality(text: str, min_chars: int, boilerplate_lines: set[str]) -> di
43
57
  score = round(sum(signals) / len(signals), 3)
44
58
  return {"length": len(t), "unique_ratio": round(unique_ratio, 3), "too_short": too_short,
45
59
  "self_contained": self_contained, "boilerplate_ratio": round(boiler_ratio, 3),
46
- "score": score}
60
+ "tabular": tabular, "score": score}
47
61
 
48
62
 
49
63
  def quality_report(settings: Settings, *, sample: int = 500, min_chars: int = 50,
@@ -62,9 +76,10 @@ def quality_report(settings: Settings, *, sample: int = 500, min_chars: int = 50
62
76
  floor = max(3, int(0.3 * len(chunks)))
63
77
  boilerplate_lines = {ln for ln, n in line_counts.items() if n >= floor}
64
78
 
79
+ digit_ratio = float(getattr(settings.redis, "table_digit_ratio_threshold", 0.4) or 0.4)
65
80
  per: list[dict] = []
66
81
  for c in chunks:
67
- q = _chunk_quality(c.get("text", ""), min_chars, boilerplate_lines)
82
+ q = _chunk_quality(c.get("text", ""), min_chars, boilerplate_lines, digit_ratio)
68
83
  q["key"] = c.get("key", "")
69
84
  q["text"] = (c.get("text", "") or "")[:80]
70
85
  per.append(q)
@@ -77,13 +92,25 @@ def quality_report(settings: Settings, *, sample: int = 500, min_chars: int = 50
77
92
  verdict = ("HEALTHY - chunks are dense and self-contained" if mean_score >= 0.75
78
93
  else "MIXED - a meaningful share of chunks are short/boilerplate/fragmentary" if mean_score >= 0.5
79
94
  else "POOR - many chunks are low-value (short, boilerplate, or mid-sentence)")
95
+ pct_tabular = round(sum(p.get("tabular", False) for p in per) / n, 3)
96
+ # Content-type context: the density signal (unique-ratio) is naturally low for tables and
97
+ # structured data, so a MIXED verdict on a heavily tabular corpus can be expected, not a
98
+ # defect. Say so rather than letting the number read as poor quality.
99
+ content_note = ""
100
+ if pct_tabular >= 0.15:
101
+ content_note = (f"{pct_tabular:.0%} of sampled chunks look tabular/structured; low "
102
+ "lexical uniqueness (information density) is expected for these, so the "
103
+ "density component of the score understates their quality. Judge tabular "
104
+ "content on retrievability, not density.")
80
105
  return {
81
106
  "sample": n, "min_chars": min_chars,
82
107
  "mean_score": mean_score, "mean_unique_ratio": _mean("unique_ratio"),
83
108
  "pct_too_short": round(sum(p["too_short"] for p in per) / n, 3),
84
109
  "pct_not_self_contained": round(sum(not p["self_contained"] for p in per) / n, 3),
110
+ "pct_tabular": pct_tabular,
85
111
  "boilerplate_lines": len(boilerplate_lines),
86
112
  "verdict": verdict,
113
+ "content_note": content_note,
87
114
  "worst": per[: min(15, n)],
88
115
  "per_chunk": per,
89
116
  }
@@ -98,8 +125,10 @@ def format_quality(payload: dict) -> str:
98
125
  f"too-short: {payload['pct_too_short']:.0%} not self-contained: {payload['pct_not_self_contained']:.0%}",
99
126
  f" recurring boilerplate lines: {payload['boilerplate_lines']}",
100
127
  f" VERDICT: {payload['verdict']}",
101
- " lowest-quality chunks:",
102
128
  ]
129
+ if payload.get("content_note"):
130
+ lines.append(f" NOTE: {payload['content_note']}")
131
+ lines.append(" lowest-quality chunks:")
103
132
  for p in payload["worst"]:
104
133
  flags = ",".join(f for f, on in (("short", p["too_short"]),
105
134
  ("mid-sentence", not p["self_contained"]),
@@ -201,6 +230,22 @@ def freshness_report(settings: Settings, *, sample: int = 500, stale_after_days:
201
230
  if orphans:
202
231
  parts.append(f"{len(orphans)} orphan(s) (source object gone)")
203
232
  verdict = "STALE - " + ", ".join(parts)
233
+
234
+ # Actionable next steps, not just a count: "36/36 STALE" is alarming without a fix path.
235
+ remediation: list[str] = []
236
+ if drift:
237
+ remediation.append(
238
+ f"Re-ingest the {len(drift)} drifted document(s) so the KB reflects the current source "
239
+ "(the source changed after it was embedded).")
240
+ if stale:
241
+ remediation.append(
242
+ f"Refresh the {len(stale)} stale source(s): re-run the ingestion pipeline for their "
243
+ "prefix/bucket to re-embed the latest content.")
244
+ if orphans:
245
+ remediation.append(
246
+ f"Reconcile the {len(orphans)} orphan(s) (source object gone): if the deletions were "
247
+ "intentional, delete the corresponding chunks from the index; if not, restore the "
248
+ "source objects and re-ingest.")
204
249
  result = {
205
250
  "documents": checked, "sampled_documents": len(seen_docs),
206
251
  "without_source_key": without_key, "stale_after_days": stale_after_days,
@@ -209,6 +254,7 @@ def freshness_report(settings: Settings, *, sample: int = 500, stale_after_days:
209
254
  "stale": stale[: min(20, len(stale))], "orphans": orphans[: min(20, len(orphans))],
210
255
  "drift": drift[: min(20, len(drift))],
211
256
  "verdict": verdict,
257
+ "remediation": remediation,
212
258
  }
213
259
  if without_key:
214
260
  result["note"] = (
@@ -259,4 +305,8 @@ def format_freshness(payload: dict) -> str:
259
305
  lines.append(" stalest sources:")
260
306
  for s in payload["stale"]:
261
307
  lines.append(f" {s['age_days']}d {s['doc']} ({s['s3_key']})")
308
+ if payload.get("remediation"):
309
+ lines.append(" REMEDIATION:")
310
+ for step in payload["remediation"]:
311
+ lines.append(f" - {step}")
262
312
  return "\n".join(lines)