compass-kb-validation 2.4.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.
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/PKG-INFO +1 -1
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/compass_kb_validation.egg-info/PKG-INFO +1 -1
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/discover_and_validate.py +9 -1
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/__init__.py +1 -1
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/cli.py +32 -11
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/config/__init__.py +3 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/grounding.py +167 -19
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/profiler.py +14 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/providers/vectorstore.py +29 -1
- compass_kb_validation-2.6.0/ingestion_validation/remediation.py +154 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/report/__init__.py +219 -19
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/report/_report_css.py +5 -3
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/report/_report_js.py +5 -5
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/trend_tracker.py +16 -4
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/redis_chunks.py +4 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_discover_cli.py +24 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_grounding.py +100 -0
- compass_kb_validation-2.6.0/tests/test_remediation.py +42 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_reporting.py +126 -1
- compass_kb_validation-2.4.0/ingestion_validation/remediation.py +0 -77
- compass_kb_validation-2.4.0/tests/test_remediation.py +0 -25
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/LICENSE +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/README.md +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/compass_kb_validation.egg-info/SOURCES.txt +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/compass_kb_validation.egg-info/entry_points.txt +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/compass_kb_validation.egg-info/requires.txt +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/compass_kb_validation.egg-info/top_level.txt +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/__main__.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/ask.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/baseline.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/chunk_coherence.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/config_lint.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/content_inspect.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/corpus.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/corpus_health.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/diagnostics.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/embedding_drift.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/eval_interop.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/init_wizard.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/live_dashboard.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/mcp_server.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/models/__init__.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/notifications.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/pipeline/__init__.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/provenance.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/providers/__init__.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/providers/embedding.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/providers/graph.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/providers/source.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/providers/storage.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/report/analysis.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/report/export.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/report/shell.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/retrieval_analysis.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/utils/__init__.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/__init__.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/api_upload.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/base.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/base_sparql.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/cross_stage.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/graphdb_metadata.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/neptune_metadata.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/registry.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/retrieval_quality.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/validators/s3_storage.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/profile_kb.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/pyproject.toml +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/run_validation.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/setup.cfg +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_alt_adapters.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_analysis_report.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_api_upload.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_ask.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_baseline.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_benchmark.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_check_catalog.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_cli.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_config.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_config_lint.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_content_inspect.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_corpus.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_corpus_health.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_coverage.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_cross_stage.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_diagnostics.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_discovery_parallel.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_embedding_drift.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_eval_interop.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_gate.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_graph_transport.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_graph_validators.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_init_wizard.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_manifest.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_mcp.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_migration.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_models.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_neptune_bugfixes.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_neptune_bugfixes2.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_notifications.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_pipeline.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_profiler.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_providers.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_redis_chunks.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_registry.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_retrieval_assertions.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_retrieval_quality.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_run_cli.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_sources.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_storage_validator.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_trends.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_universal_concerns.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_utils.py +0 -0
- {compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/tests/test_vectorstore_filters.py +0 -0
|
@@ -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
|
-
|
|
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")
|
|
@@ -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",
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
{compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/config/__init__.py
RENAMED
|
@@ -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
|
{compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/grounding.py
RENAMED
|
@@ -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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
220
|
-
|
|
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
|
|
261
|
-
"
|
|
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
|
-
"-
|
|
332
|
-
" individual
|
|
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.")
|
{compass_kb_validation-2.4.0 → compass_kb_validation-2.6.0}/ingestion_validation/profiler.py
RENAMED
|
@@ -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",
|
|
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"]
|