compass-kb-validation 2.5.0__tar.gz → 2.6.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 (114) hide show
  1. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/PKG-INFO +1 -1
  2. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/compass_kb_validation.egg-info/PKG-INFO +1 -1
  3. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/discover_and_validate.py +9 -1
  4. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/__init__.py +1 -1
  5. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/cli.py +32 -11
  6. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/config/__init__.py +3 -0
  7. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/grounding.py +167 -19
  8. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/profiler.py +14 -0
  9. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/providers/vectorstore.py +29 -1
  10. compass_kb_validation-2.6.0/ingestion_validation/remediation.py +154 -0
  11. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/report/__init__.py +65 -0
  12. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/report/_report_js.py +5 -5
  13. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/redis_chunks.py +4 -0
  14. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_discover_cli.py +24 -0
  15. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_grounding.py +100 -0
  16. compass_kb_validation-2.6.0/tests/test_remediation.py +42 -0
  17. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_reporting.py +22 -0
  18. compass_kb_validation-2.5.0/ingestion_validation/remediation.py +0 -77
  19. compass_kb_validation-2.5.0/tests/test_remediation.py +0 -25
  20. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/LICENSE +0 -0
  21. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/README.md +0 -0
  22. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/compass_kb_validation.egg-info/SOURCES.txt +0 -0
  23. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
  24. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/compass_kb_validation.egg-info/entry_points.txt +0 -0
  25. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/compass_kb_validation.egg-info/requires.txt +0 -0
  26. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/compass_kb_validation.egg-info/top_level.txt +0 -0
  27. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/__main__.py +0 -0
  28. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/ask.py +0 -0
  29. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/baseline.py +0 -0
  30. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/chunk_coherence.py +0 -0
  31. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/config_lint.py +0 -0
  32. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/content_inspect.py +0 -0
  33. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/corpus.py +0 -0
  34. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/corpus_health.py +0 -0
  35. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/diagnostics.py +0 -0
  36. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/embedding_drift.py +0 -0
  37. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/eval_interop.py +0 -0
  38. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/init_wizard.py +0 -0
  39. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/live_dashboard.py +0 -0
  40. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/mcp_server.py +0 -0
  41. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/models/__init__.py +0 -0
  42. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/notifications.py +0 -0
  43. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/pipeline/__init__.py +0 -0
  44. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/provenance.py +0 -0
  45. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/providers/__init__.py +0 -0
  46. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/providers/embedding.py +0 -0
  47. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/providers/graph.py +0 -0
  48. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/providers/source.py +0 -0
  49. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/providers/storage.py +0 -0
  50. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/report/_report_css.py +0 -0
  51. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/report/analysis.py +0 -0
  52. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/report/export.py +0 -0
  53. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/report/shell.py +0 -0
  54. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/retrieval_analysis.py +0 -0
  55. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/trend_tracker.py +0 -0
  56. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/utils/__init__.py +0 -0
  57. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/__init__.py +0 -0
  58. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/api_upload.py +0 -0
  59. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/base.py +0 -0
  60. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/base_sparql.py +0 -0
  61. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/cross_stage.py +0 -0
  62. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/graphdb_metadata.py +0 -0
  63. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/neptune_metadata.py +0 -0
  64. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/registry.py +0 -0
  65. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/retrieval_quality.py +0 -0
  66. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/s3_storage.py +0 -0
  67. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/profile_kb.py +0 -0
  68. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/pyproject.toml +0 -0
  69. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/run_validation.py +0 -0
  70. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/setup.cfg +0 -0
  71. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_alt_adapters.py +0 -0
  72. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_analysis_report.py +0 -0
  73. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_api_upload.py +0 -0
  74. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_ask.py +0 -0
  75. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_baseline.py +0 -0
  76. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_benchmark.py +0 -0
  77. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_check_catalog.py +0 -0
  78. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_cli.py +0 -0
  79. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_config.py +0 -0
  80. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_config_lint.py +0 -0
  81. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_content_inspect.py +0 -0
  82. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_corpus.py +0 -0
  83. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_corpus_health.py +0 -0
  84. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_coverage.py +0 -0
  85. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_cross_stage.py +0 -0
  86. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_diagnostics.py +0 -0
  87. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_discovery_parallel.py +0 -0
  88. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_embedding_drift.py +0 -0
  89. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_eval_interop.py +0 -0
  90. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_gate.py +0 -0
  91. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_graph_transport.py +0 -0
  92. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_graph_validators.py +0 -0
  93. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_init_wizard.py +0 -0
  94. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_manifest.py +0 -0
  95. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_mcp.py +0 -0
  96. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_migration.py +0 -0
  97. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_models.py +0 -0
  98. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_neptune_bugfixes.py +0 -0
  99. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_neptune_bugfixes2.py +0 -0
  100. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_notifications.py +0 -0
  101. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_pipeline.py +0 -0
  102. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_profiler.py +0 -0
  103. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_providers.py +0 -0
  104. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_redis_chunks.py +0 -0
  105. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_registry.py +0 -0
  106. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_retrieval_assertions.py +0 -0
  107. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_retrieval_quality.py +0 -0
  108. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_run_cli.py +0 -0
  109. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_sources.py +0 -0
  110. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_storage_validator.py +0 -0
  111. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_trends.py +0 -0
  112. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_universal_concerns.py +0 -0
  113. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.0}/tests/test_utils.py +0 -0
  114. {compass_kb_validation-2.5.0 → compass_kb_validation-2.6.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.5.0
3
+ Version: 2.6.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.5.0
3
+ Version: 2.6.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
@@ -138,7 +138,15 @@ def _resolve_skip_steps(settings, *, include_graphdb: bool, include_retrieval: b
138
138
  (BUG-22). ``--keep s3_storage`` overrides the auto-skip.
139
139
  """
140
140
  skip = ["API Upload"]
141
- if not include_graphdb:
141
+ # Graph validation runs by default when a graph block is actually configured (a
142
+ # sparql_query or an endpoint) - a configured neptune:/graphdb: block should not be
143
+ # silently ignored just because --include-graphdb was not passed. Opt out with
144
+ # --skip neptune_metadata (or graphdb_metadata). --include-graphdb still forces it on.
145
+ nep, gdb = settings.neptune, settings.graphdb
146
+ graph_configured = bool(
147
+ getattr(nep, "sparql_query", "") or getattr(nep, "endpoint", "")
148
+ or getattr(gdb, "sparql_query", "") or getattr(gdb, "base_url", ""))
149
+ if not (include_graphdb or graph_configured):
142
150
  skip += ["GraphDB Metadata", "Neptune Metadata"]
143
151
  if not include_retrieval:
144
152
  skip.append("Retrieval Quality")
@@ -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.5.0"
8
+ __version__ = "2.6.0"
@@ -278,7 +278,8 @@ def cmd_lint(argv: list[str]) -> int:
278
278
  "predicates, missing helper-query placeholders, ...).")
279
279
  p.add_argument("--project", help="Project name (config/projects/<name>.yaml).")
280
280
  p.add_argument("--env", default="dev", help="Environment (default: dev).")
281
- p.add_argument("--strict", action="store_true", help="Exit non-zero on warnings too, not just errors.")
281
+ p.add_argument("--strict", action="store_true",
282
+ help="Exit non-zero on warnings AND advisories too, not just errors.")
282
283
  args = p.parse_args(argv)
283
284
 
284
285
  from ingestion_validation.config import load_settings, project_config_file
@@ -297,8 +298,10 @@ def cmd_lint(argv: list[str]) -> int:
297
298
  print(f" compass lint - project={args.project or '(none)'} env={args.env}")
298
299
  print(format_findings(findings))
299
300
  errors = sum(1 for lv, _ in findings if lv == "error")
300
- warns = sum(1 for lv, _ in findings if lv == "warn")
301
- return 1 if (errors or (args.strict and warns)) else 0
301
+ # --strict escalates everything non-error (warnings AND info advisories) - a leftover
302
+ # skip_steps entry is an "info" advisory, and a strict CI gate should still catch it.
303
+ non_error = sum(1 for lv, _ in findings if lv != "error")
304
+ return 1 if (errors or (args.strict and non_error)) else 0
302
305
 
303
306
 
304
307
  # --------------------------------------------------------------------------- #
@@ -355,15 +358,17 @@ def cmd_doctor(argv: list[str]) -> int:
355
358
  help="On failure, diagnose why: DNS resolution, TCP reachability, TLS certificate.")
356
359
  args = p.parse_args(argv)
357
360
 
358
- if args.all_projects:
359
- return _doctor_all_projects(args)
360
-
361
+ # Load .env for BOTH paths - --all-projects previously ran before this and fell back to
362
+ # localhost because credentials from .env were never read.
361
363
  try:
362
364
  from dotenv import load_dotenv
363
365
  load_dotenv()
364
366
  except Exception: # noqa: BLE001 - optional
365
367
  pass
366
368
 
369
+ if args.all_projects:
370
+ return _doctor_all_projects(args)
371
+
367
372
  from ingestion_validation.config import find_project_root, load_settings, validate_config
368
373
 
369
374
  try:
@@ -1311,8 +1316,10 @@ def cmd_grounding_audit(argv: list[str]) -> int:
1311
1316
  p.add_argument("--project")
1312
1317
  p.add_argument("--env", default="")
1313
1318
  g = p.add_mutually_exclusive_group(required=True)
1314
- g.add_argument("--eval-result", metavar="FILE", help="One eval record as JSON.")
1315
- g.add_argument("--eval-results", metavar="FILE", help="JSONL of eval records (one per line).")
1319
+ g.add_argument("--eval-result", metavar="FILE", help="One eval record as a single JSON object.")
1320
+ g.add_argument("--eval-results", metavar="FILE",
1321
+ help="Batch: a JSONL file (one JSON object per LINE, not a JSON array). "
1322
+ "`compass ask --format eval` emits a compatible file.")
1316
1323
  p.add_argument("--format", choices=["markdown", "json"], default="markdown")
1317
1324
  p.add_argument("--output", help="Write the report to a file (default: stdout).")
1318
1325
  p.add_argument("--fail-on-fabrication", action="store_true",
@@ -1335,11 +1342,25 @@ def cmd_grounding_audit(argv: list[str]) -> int:
1335
1342
  records: list[dict] = []
1336
1343
  try:
1337
1344
  if args.eval_result:
1338
- records = [_json.loads(Path(args.eval_result).read_text(encoding="utf-8"))]
1345
+ one = _json.loads(Path(args.eval_result).read_text(encoding="utf-8"))
1346
+ if isinstance(one, list):
1347
+ print(" --eval-result expects a single JSON object, but got an array. "
1348
+ "Use --eval-results for a batch (JSONL: one object per line).")
1349
+ return 2
1350
+ if not isinstance(one, dict):
1351
+ print(" --eval-result must be a single JSON object.")
1352
+ return 2
1353
+ records = [one]
1339
1354
  else:
1340
- for line in Path(args.eval_results).read_text(encoding="utf-8").splitlines():
1341
- if line.strip():
1355
+ for n, line in enumerate(Path(args.eval_results).read_text(encoding="utf-8").splitlines(), 1):
1356
+ if not line.strip():
1357
+ continue
1358
+ try:
1342
1359
  records.append(_json.loads(line))
1360
+ except Exception: # noqa: BLE001 - point at the offending line, not char 1
1361
+ print(f" --eval-results is JSONL (one JSON object per line); line {n} is not valid JSON. "
1362
+ "A JSON array is not accepted here - see `compass ask --format eval`.")
1363
+ return 2
1343
1364
  except Exception as exc: # noqa: BLE001 - clean message, never a traceback
1344
1365
  print(f" Could not read eval input: {exc}")
1345
1366
  return 2
@@ -612,6 +612,9 @@ class EvalConfig:
612
612
  # Extra query-intent regexes, merged with the built-in defaults (how many / count / by
613
613
  # type / list all / most recent ...). Lets a domain add its own phrasings.
614
614
  structured_query_patterns: list[str] = field(default_factory=list)
615
+ # Index field holding the source document name, so grounding-audit can verify a cited
616
+ # document actually exists in the KB.
617
+ gt_doc_name_field: str = "file_name"
615
618
 
616
619
 
617
620
  @dataclass
@@ -99,9 +99,15 @@ def extract_numeric_claims(response: str) -> list[NumericClaim]:
99
99
  if nums:
100
100
  _add(label, nums[-1], "table")
101
101
 
102
- # Inline "N documents / docs / files / records / PDFs".
103
- for m in re.finditer(r"\*{0,2}([\d,]+)\*{0,2}\s+(?:total\s+)?(?:documents?|docs|files?|records?|pdfs?)\b",
104
- text, flags=re.IGNORECASE):
102
+ _noun = r"(?:documents?|docs|files?|records?|pdfs?|items?)"
103
+ # Inline "N <TYPE> documents" - capture the type label ("59 WIU documents" -> WIU=59).
104
+ # The type token is a short UPPER/CamelCase word, so plain "24 documents" stays "total".
105
+ for m in re.finditer(rf"\*{{0,2}}([\d,]+)\*{{0,2}}\s+([A-Z][A-Za-z0-9]{{1,14}})\s+{_noun}\b", text):
106
+ label = m.group(2)
107
+ if label.lower() not in ("total", "the", "these", "those", "all", "some"):
108
+ _add(label, int(m.group(1).replace(",", "")), "inline")
109
+ # Inline "N documents / docs / ..." with no type -> total.
110
+ for m in re.finditer(rf"\*{{0,2}}([\d,]+)\*{{0,2}}\s+(?:total\s+)?{_noun}\b", text, flags=re.IGNORECASE):
105
111
  _add("total", int(m.group(1).replace(",", "")), "inline")
106
112
 
107
113
  return claims
@@ -132,24 +138,41 @@ def verify_structured_claims(adapter: Any, claims: list[NumericClaim],
132
138
  return [ClaimVerdict(c.label, c.count, None, False, False, "backend cannot aggregate")
133
139
  for c in claims], meta
134
140
  if not aggregatable_fields:
141
+ # Don't dead-end: name the index's own fields so the user knows exactly what to
142
+ # configure, instead of a generic "not configured".
143
+ candidates = ""
144
+ try:
145
+ fields = (adapter.index_info() or {}).get("fields", {}) if hasattr(adapter, "index_info") else {}
146
+ tags = [n for n, t in fields.items() if str(t).upper() in ("TAG", "TEXT")]
147
+ if tags:
148
+ candidates = (" Fields available to group by: " + ", ".join(sorted(tags)[:12])
149
+ + ". Example: eval.aggregatable_fields: [{field: <a TAG field>, "
150
+ "count_by: <doc-identity field>}].")
151
+ except Exception: # noqa: BLE001 - schema unavailable -> generic message
152
+ candidates = ""
135
153
  meta["reason"] = ("no eval.aggregatable_fields configured - declare the group-by and "
136
- "doc-identity fields to verify count claims.")
154
+ "doc-identity fields to verify count claims." + candidates)
137
155
  return [ClaimVerdict(c.label, c.count, None, False, False, "no aggregatable fields configured")
138
156
  for c in claims], meta
139
157
 
140
158
  # Build a value -> count map across every configured aggregation, plus an overall
141
- # total (distinct documents) for "total" claims.
159
+ # total (distinct documents) for "total" claims. The total is the distinct-document
160
+ # count of the FIRST grouping only - summing across every spec double-counted the same
161
+ # documents (a 2-spec config reported 2x the real total).
142
162
  value_counts: dict[str, int] = {}
143
163
  total_docs = 0
144
- for spec in aggregatable_fields:
164
+ for i, spec in enumerate(aggregatable_fields):
145
165
  gfield = spec.get("field", "")
146
- dfield = spec.get("distinct_by") or None
166
+ # `count_by` (the original config spelling) and `distinct_by` are both accepted;
167
+ # without a distinct field COUNT_DISTINCTISH degrades to a raw chunk COUNT.
168
+ dfield = spec.get("distinct_by") or spec.get("count_by") or None
147
169
  counts = adapter.aggregate_count(gfield, dfield) if gfield else None
148
170
  if counts:
149
171
  meta["aggregated"] = True
150
172
  for k, v in counts.items():
151
173
  value_counts[str(k).lower()] = value_counts.get(str(k).lower(), 0) + int(v)
152
- total_docs += int(v)
174
+ if i == 0:
175
+ total_docs = sum(int(v) for v in counts.values())
153
176
  if not meta["aggregated"]:
154
177
  meta["reason"] = "aggregation returned nothing (empty index, or the configured fields are absent)."
155
178
 
@@ -174,6 +197,89 @@ def verify_structured_claims(adapter: Any, claims: list[NumericClaim],
174
197
  return verdicts, meta
175
198
 
176
199
 
200
+ _SEV_ORDER = {"INFO": 0, "LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
201
+
202
+
203
+ def _score_and_target(v: Any) -> tuple:
204
+ """A metric value may be {'score':..,'target':..} or a bare number. Return (score, target)."""
205
+ if isinstance(v, dict):
206
+ return v.get("score"), v.get("target", 0.7)
207
+ if isinstance(v, (int, float)):
208
+ return v, 0.7
209
+ return None, None
210
+
211
+
212
+ def semantic_triage(response: str, context_empty: bool, scores: dict) -> tuple[list[dict], str]:
213
+ """Deterministic semantic signals + a severity, WITHOUT extracting or judging claims.
214
+
215
+ Everything here is a fact already in hand: the eval's own scores, and whether retrieval
216
+ was empty while the model still answered. No LLM, no quality verdict - just turning
217
+ signals the eval produced into an honest severity, so a semantic case with all-zero
218
+ scores stops reading as "LOW / nothing to see here".
219
+ """
220
+ signals: list[dict] = []
221
+ sev = "INFO"
222
+
223
+ def bump(level: str) -> None:
224
+ nonlocal sev
225
+ if _SEV_ORDER[level] > _SEV_ORDER[sev]:
226
+ sev = level
227
+
228
+ failed = []
229
+ for name, v in (scores or {}).items():
230
+ score, target = _score_and_target(v)
231
+ if score is None:
232
+ continue
233
+ if score < (target if isinstance(target, (int, float)) else 0.7):
234
+ failed.append((name, score))
235
+ low = name.lower()
236
+ if "guardrail" in low or "safety" in low:
237
+ signals.append({"label": "Guardrail/safety metric failed", "severity": "HIGH",
238
+ "detail": f"{name} = {score}"})
239
+ bump("HIGH")
240
+ elif ("ground" in low or "faithful" in low) and score < 0.2:
241
+ signals.append({"label": "Groundedness metric very low", "severity": "HIGH",
242
+ "detail": f"{name} = {score} (target {target})"})
243
+ bump("HIGH")
244
+
245
+ if context_empty and len((response or "").strip()) > 40:
246
+ signals.append({"label": "Answered with empty retrieval", "severity": "HIGH",
247
+ "detail": "retrieval returned nothing, yet the model produced a substantive answer"})
248
+ bump("HIGH")
249
+
250
+ if failed and _SEV_ORDER[sev] < _SEV_ORDER["MEDIUM"]:
251
+ signals.append({"label": f"{len(failed)} eval metric(s) below target", "severity": "MEDIUM",
252
+ "detail": ", ".join(f"{n}={s}" for n, s in failed[:4])})
253
+ bump("MEDIUM")
254
+
255
+ return signals, sev
256
+
257
+
258
+ _CITATION_RE = re.compile(r"\*\*([^*]+\.(?:pdf|docx|pptx|txt|md))\*\*", re.IGNORECASE)
259
+
260
+
261
+ def verify_citations(response: str, adapter: Any, doc_name_field: str) -> list[dict]:
262
+ """Deterministic: does each document the response CITES actually exist in the KB?
263
+
264
+ A fabricated source reference is one of the clearest grounding failures. Uses the
265
+ adapter's text search on the configured doc-name field; skips silently if the backend
266
+ can't search (never a false positive).
267
+ """
268
+ signals: list[dict] = []
269
+ search = getattr(adapter, "text_search", None)
270
+ if not callable(search) or not doc_name_field:
271
+ return signals
272
+ for doc in sorted(set(_CITATION_RE.findall(response or ""))):
273
+ try:
274
+ hits = search(doc_name_field, doc, limit=1)
275
+ except Exception: # noqa: BLE001 - search unavailable -> don't assert absence
276
+ return signals
277
+ if not hits:
278
+ signals.append({"label": "Cited document not found in KB", "severity": "HIGH",
279
+ "detail": doc})
280
+ return signals
281
+
282
+
177
283
  def _is_empty_context(ctx: Any) -> bool:
178
284
  """True when the eval's retrieved context is effectively empty (null/blank fields)."""
179
285
  if not ctx:
@@ -191,6 +297,8 @@ class GroundingResult:
191
297
  claim_verdicts: list[ClaimVerdict] = field(default_factory=list)
192
298
  context_empty: bool = True
193
299
  eval_scores: dict = field(default_factory=dict)
300
+ signals: list[dict] = field(default_factory=list) # deterministic semantic-path findings
301
+ signal_severity: str = "INFO" # severity from the signals above
194
302
  meta: dict = field(default_factory=dict)
195
303
 
196
304
  @property
@@ -210,14 +318,26 @@ class GroundingResult:
210
318
  return "FABRICATION: the model's counts disagree with the KB despite retrieved context."
211
319
  if self.query_type == "structured" and self.verified_claims and not self.wrong_claims:
212
320
  return "GROUNDED: every verifiable count matches the KB."
213
- if not self.verified_claims:
214
- return "UNVERIFIED: no count claim could be checked (see notes)."
215
- return "MIXED: some counts match, others do not."
321
+ # Semantic path: report the strongest deterministic signal.
322
+ if self.signals:
323
+ if any("not found in KB" in s["label"] for s in self.signals):
324
+ return "FABRICATED SOURCE: the response cites a document not present in the KB."
325
+ if any("empty retrieval" in s["label"].lower() for s in self.signals):
326
+ return ("UNGROUNDED: retrieval returned nothing, so the answer came from the model, "
327
+ "not the KB.")
328
+ return "AT RISK: the eval's own scores indicate a grounding problem (see signals)."
329
+ if self.query_type == "structured" and not self.verified_claims:
330
+ return "UNVERIFIED: no count claim could be extracted from the response (see notes)."
331
+ return "NO SIGNAL: no deterministic grounding problem detected on this case."
216
332
 
217
333
  def severity(self) -> str:
218
334
  if self.wrong_claims:
219
- return "HIGH" # confident wrong data reaching users
220
- if not self.verified_claims:
335
+ return "HIGH" # confident wrong count reaching users
336
+ # Semantic severity comes from the deterministic signals (eval scores, empty
337
+ # retrieval, fabricated citation) - never a flat LOW that hides a 0.00-score case.
338
+ if _SEV_ORDER.get(self.signal_severity, 0) > 0:
339
+ return self.signal_severity
340
+ if self.query_type == "structured" and not self.verified_claims:
221
341
  return "LOW"
222
342
  return "INFO"
223
343
 
@@ -249,16 +369,24 @@ def run_audit(eval_record: dict, adapter: Any, eval_cfg: Any) -> GroundingResult
249
369
  result = GroundingResult(test_id=test_id, query=query, query_type=qtype, intents=intents,
250
370
  context_empty=_is_empty_context(context),
251
371
  eval_scores=scores if isinstance(scores, dict) else {})
372
+ # Deterministic semantic triage runs on EVERY case: eval-score severity, empty-retrieval,
373
+ # and cited-document existence are facts regardless of query type, so a semantic case with
374
+ # all-zero scores no longer reads as "LOW / nothing to see here".
375
+ sig, sig_sev = semantic_triage(response, result.context_empty, result.eval_scores)
376
+ cites = verify_citations(response, adapter, getattr(eval_cfg, "gt_doc_name_field", "file_name"))
377
+ result.signals = sig + cites
378
+ result.signal_severity = max([sig_sev] + [s["severity"] for s in cites],
379
+ key=lambda x: _SEV_ORDER.get(x, 0))
380
+
252
381
  if qtype == "structured":
253
382
  claims = extract_numeric_claims(response)
254
383
  result.claim_verdicts, result.meta = verify_structured_claims(
255
384
  adapter, claims, getattr(eval_cfg, "aggregatable_fields", []) or [])
256
385
  else:
257
- # Semantic path (free-text claim verification) is a separate, measurement-gated
258
- # mode; for now say so rather than run a fragile extractor and imply a verdict.
259
386
  result.meta = {"semantic": True,
260
- "note": "Semantic claim verification is not yet enabled; only the "
261
- "retrieved-context check ran."}
387
+ "note": "Semantic path: deterministic signals only (eval-score severity, "
388
+ "empty-retrieval, cited-document existence). Per-claim entailment "
389
+ "is a separate mode."}
262
390
  return result
263
391
 
264
392
 
@@ -293,6 +421,15 @@ def render_report(result: GroundingResult, *, project: str = "") -> str:
293
421
  L.append(f"| {metric} | {score} | {target} | {status} |")
294
422
  L.append("")
295
423
 
424
+ if result.signals:
425
+ L.append("### Deterministic signals")
426
+ L.append("")
427
+ L.append("| Signal | Severity | Detail |")
428
+ L.append("|---|---|---|")
429
+ for s in result.signals:
430
+ L.append(f"| {s['label']} | {s['severity']} | {s['detail']} |")
431
+ L.append("")
432
+
296
433
  if result.claim_verdicts:
297
434
  L.append("### Claim verification against the KB")
298
435
  L.append("")
@@ -326,10 +463,21 @@ def render_report(result: GroundingResult, *, project: str = "") -> str:
326
463
  " these exactly), not semantic search.",
327
464
  "- On **empty retrieval, refuse or flag uncertainty** rather than letting the model fill the gap.",
328
465
  ]
466
+ elif result.query_type == "structured" and not result.verified_claims:
467
+ # Was misreported as "no action indicated" - but NOTHING was checked. Say so.
468
+ L += [
469
+ "- **Could not extract a verifiable count from the response** - the claim (if any) was",
470
+ " not in a recognised form. Review manually; the count was NOT confirmed against the KB.",
471
+ ]
472
+ elif any(s["severity"] in ("HIGH", "CRITICAL") for s in result.signals):
473
+ L += [
474
+ "- Deterministic signals flag a grounding problem (see the signals table). On **empty",
475
+ " retrieval, refuse or flag uncertainty**; verify any cited source exists before citing it.",
476
+ ]
329
477
  elif result.query_type == "semantic":
330
478
  L += [
331
- "- This is a topic query; run the semantic grounding mode (when enabled) to verify",
332
- " individual claims against the KB.",
479
+ "- No deterministic grounding problem detected. Per-claim entailment (a separate mode)",
480
+ " would verify individual statements against the KB.",
333
481
  ]
334
482
  else:
335
483
  L.append("- Counts verified against the KB; no action indicated for this case.")
@@ -468,6 +468,20 @@ def emit_project_config(profiles: dict, *, project: str, env: str = "dev") -> st
468
468
  if exts:
469
469
  lines.append(f" supported_extensions: [{', '.join(repr(e) for e in exts)}]")
470
470
 
471
+ # Suggest an eval block so `compass grounding-audit` can verify count claims out of the
472
+ # box: a low-cardinality field (a doc type/category) grouped by the doc-identity field.
473
+ if vector.get("reachable"):
474
+ cands = vector.get("id_candidates") or []
475
+ group = next((c["field"] for c in cands if str(c.get("kind", "")).startswith("low-cardinality")), "")
476
+ ident = next((c["field"] for c in cands if str(c.get("kind", "")).startswith("identity")),
477
+ vector.get("chosen_id_field") or "")
478
+ if group and ident:
479
+ lines += [
480
+ " eval:",
481
+ " # For `compass grounding-audit` count verification (detected fields):",
482
+ f" aggregatable_fields: [{{field: {group}, count_by: {ident}}}]",
483
+ ]
484
+
471
485
  return "\n".join(lines) + "\n"
472
486
 
473
487
 
@@ -102,6 +102,15 @@ class VectorStoreAdapter(ABC):
102
102
  """Return the master-metadata record for ``doc_id`` if the store has one."""
103
103
  return None
104
104
 
105
+ def has_master_metadata_index(self) -> bool:
106
+ """Whether a master-metadata index actually exists in this store.
107
+
108
+ Distinguishes "this KB has no master-metadata index" (the check is N/A) from "the
109
+ index exists but this document is missing from it" (a real gap). Base returns True
110
+ so backends without the concept are unaffected; the Redis adapter probes.
111
+ """
112
+ return True
113
+
105
114
  def distinct_doc_ids(self, limit: int = 1000) -> list[str]:
106
115
  """Enumerate distinct document ids present in the index.
107
116
 
@@ -341,6 +350,21 @@ class RedisVectorStoreAdapter(VectorStoreAdapter):
341
350
  return []
342
351
  return self._parse_search(raw)
343
352
 
353
+ def has_master_metadata_index(self) -> bool:
354
+ idx = self.config.master_metadata_index
355
+ if not idx:
356
+ return False
357
+ cached = getattr(self, "_master_idx_exists", None)
358
+ if cached is not None:
359
+ return cached
360
+ try:
361
+ self.client.execute_command("FT.INFO", idx)
362
+ exists = True
363
+ except Exception: # noqa: BLE001 - no such index -> the KB doesn't use master metadata
364
+ exists = False
365
+ self._master_idx_exists = exists
366
+ return exists
367
+
344
368
  def master_metadata(self, doc_id: str) -> Optional[dict]:
345
369
  idx = self.config.master_metadata_index
346
370
  if not idx:
@@ -412,8 +436,12 @@ class RedisVectorStoreAdapter(VectorStoreAdapter):
412
436
  def aggregate_count(self, group_field, distinct_field=None, limit=100000):
413
437
  if not group_field:
414
438
  return None
439
+ # LOAD both fields: COUNT_DISTINCTISH @distinct can only count what is loaded, so
440
+ # loading just the group field silently degraded distinct-doc counts into raw chunk
441
+ # counts.
442
+ load = [f"@{group_field}"] + ([f"@{distinct_field}"] if distinct_field else [])
415
443
  cmd = ["FT.AGGREGATE", self.config.search_index, "*",
416
- "LOAD", "1", f"@{group_field}",
444
+ "LOAD", str(len(load)), *load,
417
445
  "GROUPBY", "1", f"@{group_field}"]
418
446
  if distinct_field:
419
447
  cmd += ["REDUCE", "COUNT_DISTINCTISH", "1", f"@{distinct_field}", "AS", "__count__"]
@@ -0,0 +1,154 @@
1
+ """Deterministic remediation hints.
2
+
3
+ For a failed check label, return a short, actionable note: what the failure
4
+ usually means and where in the pipeline to look. Pattern-matched, no LLM, no
5
+ config - purely a lookup so the report can turn a diagnostic ("chunk check
6
+ failed") into something a team can act on ("likely a re-embed gap; check the
7
+ ingestion worker"). Hints are advisory; they never affect pass/fail.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ # Ordered most-specific first; the first matching pattern wins. Keep specific/stage-scoped
15
+ # patterns above the broad ones, so e.g. a Redis "master metadata" check does not fall into
16
+ # a graph hint.
17
+ _HINTS: list[tuple[str, str]] = [
18
+ # ---- Vector store (chunks / embeddings) ------------------------------------------
19
+ (r"non.?empty|empty text",
20
+ "Chunks have empty text: the parser produced blank content or the text field was not populated. "
21
+ "Check the document extractor and the configured text field."),
22
+ (r"chunks exist|no chunks|chunk count",
23
+ "No/too-few chunks for this document: the embedding+indexing job likely never ran, "
24
+ "failed partway, or wrote to a different index. Check the ingestion worker and the configured index name."),
25
+ (r"l2 normal|embedding.*normal|normaliz",
26
+ "Embeddings are not normalised to match ingestion: the stored vectors' norm differs from what the "
27
+ "index/query path expects. Normalise at embed time, or set the expected normalisation to match the store."),
28
+ (r"embedding (validity|status)|zero|nan|inf",
29
+ "Degenerate or missing embeddings (zero/NaN vectors, or not written): the embedding model returned "
30
+ "bad output or the vectors were not stored. Re-run embedding for this document and verify the output."),
31
+ (r"vector dimension|embedding dimension|dim ",
32
+ "Embedding dimension mismatch: the model's output dimension differs from the index. "
33
+ "Align expected_embedding_dim with the embedding model and recreate the index if needed."),
34
+ (r"distance metric",
35
+ "Index distance metric differs from config: recreate the index with the expected metric, "
36
+ "or update expected_distance_metric to match the store."),
37
+ (r"correct document id|cross-document contamin",
38
+ "Chunks carry the wrong document id: ingestion wrote this document's chunks under another document's "
39
+ "key (or vice versa). Check the id field the chunker stamps on each chunk."),
40
+ (r"near-duplicate|no exact duplicate|repeated boilerplate|header|footer",
41
+ "Duplicate / boilerplate chunks: identical vectors or repeated disclaimer/header-footer text bloat the "
42
+ "index and skew retrieval. De-duplicate at ingestion and strip boilerplate before chunking."),
43
+ (r"chunk size|overlap|ordering|positional|section|page|boundaries|coherence|structure check",
44
+ "Chunk structure is off: the chunker's size/overlap/boundaries differ from config, or chunks were "
45
+ "written out of order / with gaps. Tune the chunking step's size and overlap parameters."),
46
+ (r"fidelity|full coverage|coverage of source|table/figure",
47
+ "Chunk text does not faithfully cover the source: extraction lost or garbled content (often tables/"
48
+ "figures). Inspect the document parser/extractor."),
49
+ (r"master metadata",
50
+ "The master-metadata record is missing for this document: the master-metadata write step did not run, "
51
+ "or the master index name is wrong. Check that step and redis.master_metadata_index."),
52
+ (r"metadata consistency",
53
+ "A document's metadata disagrees across its own chunks: the ingestion wrote inconsistent field values "
54
+ "per chunk. Fix the field at the chunk-write step so every chunk of a document agrees."),
55
+ (r"required fields|index schema|handles wildcard|required field",
56
+ "Index schema / required field problem: a declared field is missing, the wrong type, or the index does "
57
+ "not answer a basic query. Recreate the index with the expected field definitions."),
58
+ (r"semantic retrieval|filter.*vector|hybrid",
59
+ "The document is not retrievable via KNN as expected: the stored vector does not surface for its own "
60
+ "content, or the filter field is not indexed. Verify the embedding path and the filter field's index."),
61
+ (r"all chunks validated",
62
+ "This document has more chunks than the fetch cap, so only a prefix was validated. Raise "
63
+ "redis.max_chunks_per_document to cover the whole document."),
64
+
65
+ # ---- Knowledge graph -------------------------------------------------------------
66
+ (r"document found in graph",
67
+ "No graph node for this document: the graph-write step never created it, or wrote a different subject "
68
+ "id than the pipeline uses. Check the graph-write stage and the id/subject it emits."),
69
+ (r"required predicate|predicates present",
70
+ "Expected predicates are missing in the graph: the metadata-write step failed or used different "
71
+ "predicate names. Check the graph-write stage and the configured required_predicates."),
72
+ (r"identity is unique|document identity",
73
+ "Duplicate document identity in the graph: two subjects share the same id. De-duplicate the graph "
74
+ "write, or fix the id the ingestion assigns."),
75
+ (r"rdf type",
76
+ "Wrong or missing rdf:type on the document node: the graph-write step did not type the node as expected. "
77
+ "Check the typing in the graph-write stage."),
78
+ (r"namespace consistency|predicate namespace",
79
+ "Mixed predicate namespaces in the graph: some triples use a different ontology prefix. Align the "
80
+ "graph-write to one namespace (see the configured ontology_prefix/namespace)."),
81
+ (r"linked entities|dangling",
82
+ "A referenced entity does not exist in the graph (a dangling link): the linking step ran before its "
83
+ "target was written, or wrote the wrong id. Check ingestion ordering and the link target ids."),
84
+ (r"orphan",
85
+ "The document node has no edges (orphan): the relationship/linking step did not run for this document. "
86
+ "Check the graph-linking stage."),
87
+ (r"older versions superseded|supersed",
88
+ "A superseded version is not marked as such: the version/supersede predicate was not updated when a "
89
+ "newer document arrived. Update it in the graph-write step."),
90
+ (r"storage reference|storage key|storage ref",
91
+ "The graph's storage reference (key/eTag) does not match the object store: storage and graph were "
92
+ "written from different runs. Re-ingest so the graph points at the current object."),
93
+ (r"graph (document id|name|metadata) matches|classification matches|name matches source",
94
+ "A graph value disagrees with the pipeline/source (id, name, classification, or metadata): the graph "
95
+ "was written from a stale or different source. Re-sync the graph write with the source of truth."),
96
+ (r"date (field )?format|email (field )?format|field rule",
97
+ "A metadata field is malformed: fix the field formatting at the source/ingestion step "
98
+ "(e.g. ISO-8601 dates, valid email)."),
99
+
100
+ # ---- Cross-stage consistency -----------------------------------------------------
101
+ (r"present in every.*store|present in all",
102
+ "The document exists in some stores but not all: a stage's ingestion (storage, graph, or vector) did "
103
+ "not run for it. Re-run the missing stage so all stores agree."),
104
+ (r"shared metadata agrees|agree.*across|across stores",
105
+ "The same field has different values across storage/graph/index for this document: a partial or stale "
106
+ "re-ingest left the stores out of sync. Re-ingest to realign the shared fields."),
107
+ (r"access-control|acl|confidential",
108
+ "Access-control/confidentiality metadata was not propagated to the vector store: the ingestion did not "
109
+ "carry it onto the chunks. Add the field to the chunk-write so filtered retrieval can enforce it."),
110
+ (r"e_tag|etag|md5|integrity|matches s3|cross-system|docid",
111
+ "Cross-system id/hash mismatch: the same document differs between stages (storage vs graph vs index). "
112
+ "A re-upload or partial ingestion likely left stale copies; re-ingest to realign."),
113
+
114
+ # ---- Retrieval quality -----------------------------------------------------------
115
+ (r"embedding-model match|declared vs stored",
116
+ "Stored vectors were embedded with a different model than the one declared/serving: retrieval is "
117
+ "silently wrong. Re-embed the corpus with the declared model, or correct the declared model."),
118
+ (r"exact-term retrieval|keyword-index completeness|findable lexically",
119
+ "Chunks are not findable by exact keyword: the text field is not indexed for lexical search. Enable "
120
+ "hybrid (keyword + vector) retrieval, or index the text field for full-text search."),
121
+ (r"negative retrieval|off-topic",
122
+ "An off-topic query still scored high: embeddings are too generic or the index is polluted by "
123
+ "boilerplate. Improve chunk quality and strip boilerplate so unrelated queries do not match."),
124
+ (r"recall|mrr|paraphrase|boundary|empty query",
125
+ "The document is not retrievable as expected for real queries: embeddings may not match the query "
126
+ "path, or the filter field is not indexed. Verify the retrieval/query configuration."),
127
+
128
+ # ---- Ingestion API / storage / connectivity --------------------------------------
129
+ (r"http status|graphql|success status|valid json|api ",
130
+ "The ingestion API rejected or failed the upload: check the endpoint URL, auth, and the mutation/request shape."),
131
+ (r"upload latency",
132
+ "The upload exceeded its latency budget: the ingestion API is slow or overloaded; check service health."),
133
+ (r"source content|extractable text|structure \+ extractable",
134
+ "The source document has little or no extractable text (often an image-only/scanned PDF) or a corrupt "
135
+ "structure: downstream chunks will be empty or garbled. OCR the source or replace it with a text-bearing version."),
136
+ (r"file exists|content type|content-type|object|size|last ?modified|pdf",
137
+ "The object is missing or wrong in storage: the upload did not persist correctly "
138
+ "(wrong key/prefix, empty body, or wrong content type). Check the storage-write step."),
139
+ (r"reachable|unreachable|connect",
140
+ "Backend unreachable: check the endpoint, network, and credentials for this service."),
141
+ ]
142
+
143
+ _COMPILED: list[tuple[re.Pattern, str]] = [(re.compile(p, re.IGNORECASE), h) for p, h in _HINTS]
144
+
145
+
146
+ def hint_for(label: str) -> str | None:
147
+ """Return a remediation hint for a check label, or None if none matches."""
148
+ for rx, hint in _COMPILED:
149
+ if rx.search(label or ""):
150
+ return hint
151
+ return None
152
+
153
+
154
+ __all__ = ["hint_for"]