compass-kb-validation 3.1.0__tar.gz → 3.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/PKG-INFO +1 -1
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/__init__.py +1 -1
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/citation.py +17 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/cli.py +32 -4
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/config/__init__.py +8 -1
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/corpus_health.py +11 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/eval_interop.py +36 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/grounding.py +14 -2
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/providers/vectorstore.py +12 -1
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/report/__init__.py +65 -11
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass_kb_validation.egg-info/PKG-INFO +1 -1
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/discover_and_validate.py +14 -3
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/profile_kb.py +5 -1
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/run_validation.py +3 -2
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_citation.py +29 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_config.py +16 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_corpus_health.py +12 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_eval_interop.py +16 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_grounding.py +19 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_reporting.py +43 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_vectorstore_filters.py +14 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/LICENSE +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/README.md +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/__main__.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/ask.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/baseline.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/chunk_coherence.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/config_lint.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/content_inspect.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/corpus.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/diagnostics.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/embedding_drift.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/evidence.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/init_wizard.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/live_dashboard.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/mcp_server.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/models/__init__.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/notifications.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/pipeline/__init__.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/profiler.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/provenance.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/providers/__init__.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/providers/embedding.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/providers/graph.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/providers/source.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/providers/storage.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/remediation.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/report/_report_css.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/report/_report_js.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/report/analysis.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/report/audit.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/report/export.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/report/shell.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/retrieval_analysis.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/text_probes.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/trend_tracker.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/utils/__init__.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/__init__.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/api_upload.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/base.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/base_sparql.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/cross_stage.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/graphdb_metadata.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/neptune_metadata.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/redis_chunks.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/registry.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/retrieval_quality.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/s3_storage.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass_kb_validation.egg-info/SOURCES.txt +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass_kb_validation.egg-info/entry_points.txt +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass_kb_validation.egg-info/requires.txt +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass_kb_validation.egg-info/top_level.txt +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/ingestion_validation/__init__.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/pyproject.toml +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/setup.cfg +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_alt_adapters.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_analysis_report.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_api_upload.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_ask.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_baseline.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_benchmark.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_check_catalog.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_cli.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_compat_shim.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_config_lint.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_content_inspect.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_corpus.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_coverage.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_cross_stage.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_diagnostics.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_discover_cli.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_discovery_parallel.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_embedding_drift.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_evidence.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_gate.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_graph_transport.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_graph_validators.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_init_wizard.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_manifest.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_mcp.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_migration.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_models.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_neptune_bugfixes.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_neptune_bugfixes2.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_notifications.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_pipeline.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_profiler.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_providers.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_redis_chunks.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_registry.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_remediation.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_retrieval_assertions.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_retrieval_quality.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_review_hardening.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_run_cli.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_sources.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_storage_validator.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_trends.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_universal_concerns.py +0 -0
- {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_utils.py +0 -0
|
@@ -245,8 +245,16 @@ def _claim_before(text: str, idx: int) -> str:
|
|
|
245
245
|
"""The statement a citation supports: the text preceding it, back to the last real
|
|
246
246
|
sentence break. Excludes the citation itself and anything after it."""
|
|
247
247
|
start = 0
|
|
248
|
+
prev_start = 0
|
|
248
249
|
for m in _SENT_BOUND.finditer(text[:idx]):
|
|
250
|
+
prev_start = start
|
|
249
251
|
start = m.end()
|
|
252
|
+
# If the last sentence boundary sits right where the citation begins, that period is the
|
|
253
|
+
# TERMINATOR of the claim sentence (the standard "...claim. (SOURCE, p. N)" format), not a
|
|
254
|
+
# separator inside the claim - so text[start:idx] would be empty and the citation would be
|
|
255
|
+
# silently dropped. Fall back one boundary to capture the claim sentence itself.
|
|
256
|
+
if start >= idx - 2:
|
|
257
|
+
start = prev_start
|
|
250
258
|
nl = text.rfind("\n", 0, idx)
|
|
251
259
|
if nl + 1 > start:
|
|
252
260
|
start = nl + 1
|
|
@@ -642,6 +650,15 @@ def audit_citations(response: str, eval_chunks: Optional[list[dict]], adapter: A
|
|
|
642
650
|
summary = summarize(verdicts, response, l1_ran, l2_ran)
|
|
643
651
|
if l1_ran:
|
|
644
652
|
summary.update(context_utilization(citations, eval_chunks or [], cfg))
|
|
653
|
+
# DEFECT-003 safety net: if the response clearly carries citation-like patterns but none
|
|
654
|
+
# were extracted, say so rather than reporting a silent total_citations=0 that reads as
|
|
655
|
+
# "no citations exist" (which was the DEFECT-001 failure mode).
|
|
656
|
+
if not citations and response:
|
|
657
|
+
raw = len(_PAREN_CITE.findall(response))
|
|
658
|
+
if raw:
|
|
659
|
+
summary["diagnostic"] = (
|
|
660
|
+
f"{raw} citation-like pattern(s) matched by regex but 0 were extracted - "
|
|
661
|
+
"likely a claim-boundary parsing issue, not an absence of citations.")
|
|
645
662
|
return {"verdicts": verdicts, "l1_ran": l1_ran, "l2_ran": l2_ran, "summary": summary}
|
|
646
663
|
|
|
647
664
|
|
|
@@ -36,6 +36,13 @@ pipeline:
|
|
|
36
36
|
critical_steps: []
|
|
37
37
|
readiness_threshold: 1.0 # READY when document pass-rate >= this (e.g. 0.99)
|
|
38
38
|
|
|
39
|
+
# Advisory checks you have ACCEPTED as expected for this corpus (e.g. an insurance KB where
|
|
40
|
+
# near-duplicate boilerplate is normal). They drop out of the hygiene / Platform Health scores
|
|
41
|
+
# but are still counted and shown ("N suppressed"). Only advisory checks - a hard gating failure
|
|
42
|
+
# is never suppressed. Ad-hoc equivalent: `compass discover --suppress "Near-duplicate embeddings"`.
|
|
43
|
+
# suppressions:
|
|
44
|
+
# - "Near-duplicate embeddings"
|
|
45
|
+
|
|
39
46
|
environments:
|
|
40
47
|
dev:
|
|
41
48
|
redis:
|
|
@@ -1196,6 +1203,22 @@ def cmd_compare(argv: list[str]) -> int:
|
|
|
1196
1203
|
if not cases:
|
|
1197
1204
|
print(f" No usable cases in {args.ground_truth} "
|
|
1198
1205
|
f"(check the eval.gt_* field paths / variant filter in your config).")
|
|
1206
|
+
# Say WHICH paths were tried and what resolved on the first record, so the user can see
|
|
1207
|
+
# the actual mismatch instead of guessing (DEFECT-007).
|
|
1208
|
+
from compass.eval_interop import diagnose_paths
|
|
1209
|
+
diag = diagnose_paths(args.ground_truth, {
|
|
1210
|
+
"gt_question_path": ev.gt_question_path,
|
|
1211
|
+
"gt_chunks_path": ev.gt_chunks_path,
|
|
1212
|
+
"gt_filters_path": ev.gt_filters_path,
|
|
1213
|
+
"gt_variant_path": ev.gt_variant_path,
|
|
1214
|
+
})
|
|
1215
|
+
if diag:
|
|
1216
|
+
print(" Configured paths on the first record:")
|
|
1217
|
+
for line in diag:
|
|
1218
|
+
print(line)
|
|
1219
|
+
if variant_filter:
|
|
1220
|
+
print(f" variant filter: {', '.join(variant_filter)} "
|
|
1221
|
+
f"(via gt_variant_path=\"{ev.gt_variant_path or '(unset)'}\")")
|
|
1199
1222
|
return 2
|
|
1200
1223
|
|
|
1201
1224
|
# Ground-truth cases carry production's own metadata filters. If the backend cannot
|
|
@@ -1207,7 +1230,9 @@ def cmd_compare(argv: list[str]) -> int:
|
|
|
1207
1230
|
file=sys.stderr)
|
|
1208
1231
|
|
|
1209
1232
|
vnote = f" (variant filter: {', '.join(variant_filter)})" if variant_filter else ""
|
|
1210
|
-
|
|
1233
|
+
# Progress goes to stderr under --json so stdout stays a clean, pipeable JSON document.
|
|
1234
|
+
print(f" Comparing compass retrieval against {len(cases)} ground-truth case(s){vnote} ...",
|
|
1235
|
+
file=sys.stderr if args.json else sys.stdout)
|
|
1211
1236
|
try:
|
|
1212
1237
|
payload = comparison_report(settings, cases, top_k=args.top_k or None,
|
|
1213
1238
|
skip_empty=not args.include_empty)
|
|
@@ -1305,7 +1330,8 @@ def cmd_evidence(argv: list[str]) -> int:
|
|
|
1305
1330
|
return 2
|
|
1306
1331
|
limit = 1 if args.quick else (args.limit or settings.evidence.sample_size)
|
|
1307
1332
|
|
|
1308
|
-
print(f" compass evidence - project={args.project or '(none)'} checks={checks} limit={limit}"
|
|
1333
|
+
print(f" compass evidence - project={args.project or '(none)'} checks={checks} limit={limit}",
|
|
1334
|
+
file=sys.stderr if args.format == "json" else sys.stdout)
|
|
1309
1335
|
try:
|
|
1310
1336
|
payload = ev.gather_evidence(settings, checks, document_filter=args.document, limit=limit)
|
|
1311
1337
|
except Exception as exc: # noqa: BLE001 - unreachable/misconfigured backend -> clean message
|
|
@@ -1376,7 +1402,8 @@ def cmd_quality(argv: list[str]) -> int:
|
|
|
1376
1402
|
except Exception as exc: # noqa: BLE001 - malformed/empty project YAML -> clean message, not a traceback
|
|
1377
1403
|
print(f" Config error: {exc}")
|
|
1378
1404
|
return 2
|
|
1379
|
-
print(f" Sampling up to {args.sample} chunks for quality signals ..."
|
|
1405
|
+
print(f" Sampling up to {args.sample} chunks for quality signals ...",
|
|
1406
|
+
file=sys.stderr if args.json else sys.stdout)
|
|
1380
1407
|
try:
|
|
1381
1408
|
payload = quality_report(settings, sample=args.sample, min_chars=args.min_chars)
|
|
1382
1409
|
except Exception as exc: # noqa: BLE001 - clean message, never a traceback
|
|
@@ -1421,7 +1448,8 @@ def cmd_freshness(argv: list[str]) -> int:
|
|
|
1421
1448
|
except Exception as exc: # noqa: BLE001 - malformed/empty project YAML -> clean message, not a traceback
|
|
1422
1449
|
print(f" Config error: {exc}")
|
|
1423
1450
|
return 2
|
|
1424
|
-
print(f" Checking source freshness for up to {args.sample} document(s) ..."
|
|
1451
|
+
print(f" Checking source freshness for up to {args.sample} document(s) ...",
|
|
1452
|
+
file=sys.stderr if args.json else sys.stdout)
|
|
1425
1453
|
try:
|
|
1426
1454
|
payload = freshness_report(settings, sample=args.sample, stale_after_days=args.stale_after)
|
|
1427
1455
|
except Exception as exc: # noqa: BLE001 - clean message, never a traceback
|
|
@@ -750,6 +750,12 @@ class Settings:
|
|
|
750
750
|
display_name: str = ""
|
|
751
751
|
environment: str = "dev"
|
|
752
752
|
skip_steps: list[str] = field(default_factory=list)
|
|
753
|
+
# Advisory check labels the project has ACCEPTED as expected for this corpus (e.g. an
|
|
754
|
+
# insurance KB where near-duplicate boilerplate is normal). Suppressed advisories are
|
|
755
|
+
# excluded from the hygiene / Platform Health scores but still counted and shown ("N
|
|
756
|
+
# suppressed"). Only advisory (warn) checks can be suppressed - a hard gating failure
|
|
757
|
+
# always counts, so a known-issue can never mask a real failure.
|
|
758
|
+
suppressions: list[str] = field(default_factory=list)
|
|
753
759
|
# Advisories raised while loading (e.g. an unrecognized top-level block that was
|
|
754
760
|
# ignored). Surfaced by validate_config so a silently-dropped key is never invisible.
|
|
755
761
|
config_warnings: list[str] = field(default_factory=list)
|
|
@@ -760,7 +766,7 @@ class Settings:
|
|
|
760
766
|
# --------------------------------------------------------------------------- #
|
|
761
767
|
# Non-service top-level keys that are legitimately not a service block.
|
|
762
768
|
_META_KEYS = frozenset({"default_env", "display_name", "environments", "skip_steps",
|
|
763
|
-
"pipeline_dependencies"})
|
|
769
|
+
"pipeline_dependencies", "suppressions"})
|
|
764
770
|
# The service-config blocks that may appear at the top level or under an environment.
|
|
765
771
|
# Single source of truth: the flatten loop AND the unknown-key check both read this, so a
|
|
766
772
|
# new block can never be added to one and forgotten in the other (which is exactly how
|
|
@@ -903,6 +909,7 @@ def load_settings(
|
|
|
903
909
|
display_name=merged.get("display_name", ""),
|
|
904
910
|
environment=resolved_env,
|
|
905
911
|
skip_steps=list(merged.get("skip_steps", [])),
|
|
912
|
+
suppressions=list(merged.get("suppressions", [])),
|
|
906
913
|
)
|
|
907
914
|
# The embedding block's declared dimension IS the index's expected dimension. Cross-fill
|
|
908
915
|
# it into the vector-store check when that check has no explicit value, so a project
|
|
@@ -261,6 +261,17 @@ def freshness_report(settings: Settings, *, sample: int = 500, stale_after_days:
|
|
|
261
261
|
f"{without_key} of {len(seen_docs)} sampled document(s) carry no source key, so "
|
|
262
262
|
f"their source could not be checked - likely a missing s3_key field (prefix/field "
|
|
263
263
|
f"mismatch) or sources in a different store, not necessarily orphans.")
|
|
264
|
+
# A near-total orphan rate almost never means every source was deleted - it points at a
|
|
265
|
+
# systemic mismatch (the chunk s3_key does not match where objects actually live: wrong
|
|
266
|
+
# bucket, or a prefix baked into the key that the store does not use). Flag it so the user
|
|
267
|
+
# checks configuration before "reconciling" hundreds of false orphans (DEFECT-006).
|
|
268
|
+
if checked and len(orphans) / checked > 0.8:
|
|
269
|
+
result["warning"] = (
|
|
270
|
+
f"{len(orphans)}/{checked} sampled sources ({round(100 * len(orphans) / checked)}%) "
|
|
271
|
+
"look orphaned - a rate this high usually means a key mismatch (wrong bucket, or a "
|
|
272
|
+
"prefix in the chunk s3_key that the object store does not expect), not that the "
|
|
273
|
+
"sources are actually gone. Verify s3.bucket_name and the s3_key values before "
|
|
274
|
+
"reconciling.")
|
|
264
275
|
return result
|
|
265
276
|
|
|
266
277
|
|
|
@@ -32,6 +32,42 @@ def get_path(record: Any, dotted: str) -> Any:
|
|
|
32
32
|
return cur
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def diagnose_paths(path: str, gt_paths: dict) -> list[str]:
|
|
36
|
+
"""Explain why a ground-truth file yielded no usable cases: for each configured gt_* path,
|
|
37
|
+
report whether it resolves on the first record and what type/size was found. Deterministic,
|
|
38
|
+
read-only, best-effort - returns human-readable lines (empty list if the file is unreadable).
|
|
39
|
+
"""
|
|
40
|
+
import json
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
with open(path, encoding="utf-8") as f:
|
|
44
|
+
head = f.read(2_000_000)
|
|
45
|
+
data = json.loads(head)
|
|
46
|
+
except (OSError, ValueError):
|
|
47
|
+
# Maybe JSONL: try the first line.
|
|
48
|
+
try:
|
|
49
|
+
with open(path, encoding="utf-8") as f:
|
|
50
|
+
data = [json.loads(f.readline())]
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
return []
|
|
53
|
+
rec = data[0] if isinstance(data, list) and data else (data if isinstance(data, dict) else None)
|
|
54
|
+
if not isinstance(rec, dict):
|
|
55
|
+
return ["first record is not an object - expected a JSON array (or JSONL) of case objects"]
|
|
56
|
+
lines = []
|
|
57
|
+
for label, dotted in gt_paths.items():
|
|
58
|
+
if not dotted:
|
|
59
|
+
lines.append(f" {label}: (unset)")
|
|
60
|
+
continue
|
|
61
|
+
val = get_path(rec, dotted)
|
|
62
|
+
if val is None:
|
|
63
|
+
lines.append(f" {label}: \"{dotted}\" -> NOT FOUND")
|
|
64
|
+
elif isinstance(val, list):
|
|
65
|
+
lines.append(f" {label}: \"{dotted}\" -> found (list, {len(val)} item(s))")
|
|
66
|
+
else:
|
|
67
|
+
lines.append(f" {label}: \"{dotted}\" -> found ({type(val).__name__})")
|
|
68
|
+
return lines
|
|
69
|
+
|
|
70
|
+
|
|
35
71
|
def normalize_filters(raw: Any) -> dict:
|
|
36
72
|
"""Parse a filters value into ``{field: "a|b"}`` (compass filter form).
|
|
37
73
|
|
|
@@ -411,9 +411,21 @@ def run_audit(eval_record: dict, adapter: Any, eval_cfg: Any, doc_exists: Any =
|
|
|
411
411
|
scores = get_path(eval_record, getattr(eval_cfg, "gt_scores_path", "scores")) or {}
|
|
412
412
|
test_id = str(eval_record.get("test_id") or eval_record.get("id") or "")
|
|
413
413
|
|
|
414
|
+
# Retrieval is empty only if BOTH the configured context path AND the retrieved chunks are
|
|
415
|
+
# empty. The default gt_context_path (and gt_chunks_path) do not fit every eval schema, so
|
|
416
|
+
# keying "empty retrieval" off one path alone raised a false HIGH signal on records that
|
|
417
|
+
# clearly had chunks (DEFECT-002). Cross-check the configured chunk path plus the common
|
|
418
|
+
# nested locations so the emptiness verdict is schema-agnostic.
|
|
419
|
+
eval_chunks = get_path(eval_record, getattr(eval_cfg, "gt_chunks_path", "context.chunks"))
|
|
420
|
+
_chunk_candidates = (eval_chunks,
|
|
421
|
+
get_path(eval_record, "context.chunks"),
|
|
422
|
+
get_path(eval_record, "chunks"))
|
|
423
|
+
has_chunks = any(isinstance(c, list) and len(c) > 0 for c in _chunk_candidates)
|
|
424
|
+
context_empty = _is_empty_context(context) and not has_chunks
|
|
425
|
+
|
|
414
426
|
qtype, intents = classify_query(query, getattr(eval_cfg, "structured_query_patterns", []))
|
|
415
427
|
result = GroundingResult(test_id=test_id, query=query, query_type=qtype, intents=intents,
|
|
416
|
-
context_empty=
|
|
428
|
+
context_empty=context_empty,
|
|
417
429
|
eval_scores=scores if isinstance(scores, dict) else {})
|
|
418
430
|
# Record which fields were auto-detected (vs configured), and whether this is a
|
|
419
431
|
# retrieval-only case (no generated answer) - so the CLI can explain "0 citations" honestly.
|
|
@@ -429,7 +441,7 @@ def run_audit(eval_record: dict, adapter: Any, eval_cfg: Any, doc_exists: Any =
|
|
|
429
441
|
# Citation audit: verify each cited claim at its cited location (Level 1 against the eval's
|
|
430
442
|
# retrieved chunks when present, Level 2 against the live KB). The "cited document absent"
|
|
431
443
|
# signal is derived from it, so citation extraction lives in exactly one place.
|
|
432
|
-
eval_chunks
|
|
444
|
+
# (eval_chunks was resolved above for the empty-retrieval cross-check.)
|
|
433
445
|
result.citations = citation.audit_citations(
|
|
434
446
|
response, eval_chunks if isinstance(eval_chunks, list) else None, adapter, eval_cfg,
|
|
435
447
|
extra_patterns=getattr(eval_cfg, "citation_patterns", None), doc_exists=doc_exists)
|
{compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/providers/vectorstore.py
RENAMED
|
@@ -205,6 +205,15 @@ class RedisVectorStoreAdapter(VectorStoreAdapter):
|
|
|
205
205
|
special = r"""\.+*?[]{}<>()@!#$%^&-=~'"`:;,/| """
|
|
206
206
|
return "".join(f"\\{ch}" if ch in special else ch for ch in value)
|
|
207
207
|
|
|
208
|
+
@staticmethod
|
|
209
|
+
def escape_text(value: str) -> str:
|
|
210
|
+
"""Escape RediSearch query-parser metacharacters for a TEXT-field query while
|
|
211
|
+
PRESERVING spaces as token separators. TAG escaping (escape_tag) backslash-escapes
|
|
212
|
+
spaces, which collapses a multi-word value into a single unmatchable token, so every
|
|
213
|
+
multi-word TEXT search silently returned nothing (DEFECT-008)."""
|
|
214
|
+
special = r"""\+*?[]{}<>()@!#$%^&-=~'"`:;,/|"""
|
|
215
|
+
return "".join(f"\\{ch}" if ch in special else ch for ch in value)
|
|
216
|
+
|
|
208
217
|
def _id_query(self, search_value: str) -> str:
|
|
209
218
|
field = self.config.document_id_field
|
|
210
219
|
if self.config.document_id_field_type == "TAG":
|
|
@@ -305,7 +314,9 @@ class RedisVectorStoreAdapter(VectorStoreAdapter):
|
|
|
305
314
|
return self._parse_search(raw, keep_binary_fields=(emb,))
|
|
306
315
|
|
|
307
316
|
def text_search(self, field: str, value: str, limit: int = 10) -> list[dict]:
|
|
308
|
-
|
|
317
|
+
# TEXT-field query: escape parser metacharacters but keep spaces as token separators
|
|
318
|
+
# so a multi-word value matches its tokens (was TAG-escaped -> matched nothing).
|
|
319
|
+
escaped = self.escape_text(value)
|
|
309
320
|
query = f"@{field}:({escaped})"
|
|
310
321
|
try:
|
|
311
322
|
raw = self.client.execute_command(
|
|
@@ -317,7 +317,8 @@ def _result_payload(name: str, r: PipelineResult) -> dict:
|
|
|
317
317
|
}
|
|
318
318
|
|
|
319
319
|
|
|
320
|
-
def _aggregate(cases: list[dict], hide_skipped: bool = True
|
|
320
|
+
def _aggregate(cases: list[dict], hide_skipped: bool = True,
|
|
321
|
+
suppress: set[str] | None = None) -> dict:
|
|
321
322
|
total = len(cases)
|
|
322
323
|
passed = sum(1 for c in cases if c["overall"] == "PASSED")
|
|
323
324
|
failed = sum(1 for c in cases if c["overall"] == "FAILED")
|
|
@@ -330,6 +331,12 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
|
|
|
330
331
|
# check is either a HARD failure (fails the step) or an ADVISORY warning (does not).
|
|
331
332
|
# Neutral checks (the backend/config can't evaluate them) are counted separately.
|
|
332
333
|
checks_hard = checks_advisory = checks_neutral = 0
|
|
334
|
+
# Advisory checks a project has explicitly accepted (config `suppressions`). They are
|
|
335
|
+
# excluded from scoring - like neutral checks - but tallied and shown, never hidden. HARD
|
|
336
|
+
# (gating) checks can NEVER be suppressed, so an accepted known-issue can't mask a failure.
|
|
337
|
+
checks_suppressed = 0
|
|
338
|
+
suppressed: Counter = Counter()
|
|
339
|
+
suppress = suppress or set()
|
|
333
340
|
cat = {name: {"total": 0, "passed": 0, "adv_total": 0, "adv_passed": 0} for name, _ in _CAT_ORDER}
|
|
334
341
|
# Which checks fail across the MOST documents. One systemic cause usually shows up as
|
|
335
342
|
# one label repeated over many documents, so ranking by document count points at the
|
|
@@ -369,10 +376,19 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
|
|
|
369
376
|
if ch.get("supported", True) is False:
|
|
370
377
|
checks_neutral += 1 # neutral — backend can't evaluate this check
|
|
371
378
|
continue
|
|
372
|
-
|
|
379
|
+
_label = ch.get("label", "")
|
|
380
|
+
_sev = "warn" if str(ch.get("severity", "hard")).lower() == "warn" else "hard"
|
|
381
|
+
# Suppressed = an ADVISORY check the project accepted as expected (e.g. known
|
|
382
|
+
# boilerplate near-duplicates). Excluded from scoring but counted + shown.
|
|
383
|
+
# Only advisory can be suppressed - a hard/gating check ignores the list.
|
|
384
|
+
if _sev == "warn" and _label in suppress:
|
|
385
|
+
checks_suppressed += 1
|
|
386
|
+
suppressed[_label] += 1
|
|
387
|
+
continue
|
|
388
|
+
k = _categorise(_label, step_name)
|
|
373
389
|
cat[k]["total"] += 1
|
|
374
390
|
ch_passed = bool(ch.get("passed"))
|
|
375
|
-
sev =
|
|
391
|
+
sev = _sev
|
|
376
392
|
# Hygiene tracks ADVISORY (WARN) checks only - the quality BEYOND the
|
|
377
393
|
# integrity gate. HARD checks passing is what READY already reports, so
|
|
378
394
|
# counting them would just pull hygiene toward 100 and hide the signal.
|
|
@@ -387,9 +403,17 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
|
|
|
387
403
|
checks_advisory += 1
|
|
388
404
|
else:
|
|
389
405
|
checks_hard += 1
|
|
390
|
-
label =
|
|
391
|
-
rec = offenders.setdefault(
|
|
406
|
+
label = _label
|
|
407
|
+
rec = offenders.setdefault(
|
|
408
|
+
label, {"label": label, "docs": 0, "severity": sev, "step": step_name, "detail": ""})
|
|
392
409
|
rec["docs"] += 1
|
|
410
|
+
# Keep one concrete, measured example (the check's own detail, e.g.
|
|
411
|
+
# "4200 (~3000 expected)") so the Action Plan can show a specific number
|
|
412
|
+
# instead of a generic cause. First non-empty wins - deterministic.
|
|
413
|
+
if not rec["detail"]:
|
|
414
|
+
_dv = str(ch.get("detail") or "").strip()
|
|
415
|
+
if _dv:
|
|
416
|
+
rec["detail"] = _dv
|
|
393
417
|
# a label is HARD overall if it ever gates
|
|
394
418
|
if sev == "hard":
|
|
395
419
|
rec["severity"] = "hard"
|
|
@@ -433,6 +457,11 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
|
|
|
433
457
|
# rests on less than the full pipeline.
|
|
434
458
|
"docs_incomplete": docs_incomplete,
|
|
435
459
|
"hygiene": hygiene,
|
|
460
|
+
# Accepted advisory issues (config `suppressions`): count + per-label tally, shown
|
|
461
|
+
# so a suppressed warning is disclosed, never silently dropped.
|
|
462
|
+
"checks_suppressed": checks_suppressed,
|
|
463
|
+
"suppressed": [{"label": lbl, "docs": n} for lbl, n in
|
|
464
|
+
sorted(suppressed.items(), key=lambda kv: (-kv[1], kv[0]))],
|
|
436
465
|
}
|
|
437
466
|
|
|
438
467
|
|
|
@@ -444,15 +473,16 @@ _HYGIENE_TIERS = (
|
|
|
444
473
|
)
|
|
445
474
|
|
|
446
475
|
|
|
447
|
-
def hygiene_from_results(results: list[dict]) -> dict | None:
|
|
476
|
+
def hygiene_from_results(results: list[dict], suppress: list[str] | None = None) -> dict | None:
|
|
448
477
|
"""Public: compute the KB Hygiene score from a list of ``{name, result}`` items.
|
|
449
478
|
|
|
450
479
|
Same deterministic scoring the report uses, exposed so the CLI can gate on it
|
|
451
|
-
(``--quality-threshold``) without rendering HTML.
|
|
452
|
-
advisory
|
|
480
|
+
(``--quality-threshold``) without rendering HTML. ``suppress`` accepts the same accepted
|
|
481
|
+
advisory labels as the report, so the gate and the report agree. Returns None if there
|
|
482
|
+
are no advisory checks to score.
|
|
453
483
|
"""
|
|
454
484
|
cases = [_result_payload(item.get("name", item["result"].file_name), item["result"]) for item in results]
|
|
455
|
-
return _aggregate(cases).get("hygiene")
|
|
485
|
+
return _aggregate(cases, suppress=set(suppress or [])).get("hygiene")
|
|
456
486
|
|
|
457
487
|
|
|
458
488
|
def _hygiene_score(cat: dict) -> dict | None:
|
|
@@ -693,6 +723,12 @@ def _exec_summary(agg: dict, corpus: dict | None = None, cases: list[dict] | Non
|
|
|
693
723
|
f'these documents - see Coverage">{agg["docs_incomplete"]} not fully validated</span>')
|
|
694
724
|
if agg.get("checks_advisory"):
|
|
695
725
|
chips.append(f'<span class="hero-chip">{agg["checks_advisory"]} advisory</span>')
|
|
726
|
+
if agg.get("checks_suppressed"):
|
|
727
|
+
# Accepted known-issues: disclosed, never hidden. Tooltip names what was suppressed.
|
|
728
|
+
acc = ", ".join(f'{s["label"]} (x{s["docs"]})' for s in agg.get("suppressed", [])[:8])
|
|
729
|
+
chips.append(f'<span class="hero-chip" data-tip="Accepted advisory issues (config '
|
|
730
|
+
f'suppressions) - excluded from hygiene & health, never from the gate: '
|
|
731
|
+
f'{_esc(acc)}">{agg["checks_suppressed"]} suppressed</span>')
|
|
696
732
|
chips_html = f'<div class="hero-chips">{"".join(chips)}</div>' if chips else ""
|
|
697
733
|
t = max(agg["total"], 1)
|
|
698
734
|
seg = ""
|
|
@@ -1064,8 +1100,14 @@ def _action_plan_section(agg: dict) -> str:
|
|
|
1064
1100
|
# cards below; a pointer here makes the remainder explicit rather than silently dropped.
|
|
1065
1101
|
cards = ""
|
|
1066
1102
|
for i, g in enumerate(ranked[:_ACTION_CAP], 1):
|
|
1103
|
+
ranked_checks = sorted(g["checks"], key=lambda o: -o["docs"])
|
|
1067
1104
|
evidence = ", ".join(f'{_esc(o["label"])} {round(100 * o["docs"] / g["total"])}%'
|
|
1068
|
-
for o in
|
|
1105
|
+
for o in ranked_checks[:3])
|
|
1106
|
+
# A concrete measured example from the worst check (e.g. "4200 (~3000 expected)"),
|
|
1107
|
+
# so Observed carries a specific number - the "why" - not just which check failed.
|
|
1108
|
+
example = next((o.get("detail") for o in ranked_checks if o.get("detail")), "")
|
|
1109
|
+
if example:
|
|
1110
|
+
evidence += f' <span class="check-detail">e.g. {_esc(example)}</span>'
|
|
1069
1111
|
# A concise, category-specific INSTRUCTION here - the diagnosis now lives in the
|
|
1070
1112
|
# Observed/Likely/Possible tiers, so the fix line is just "what to do" and never
|
|
1071
1113
|
# repeats a cause. The verbose per-check hint stays in the detailed cards below.
|
|
@@ -1687,6 +1729,16 @@ def _glossary_section(agg: dict | None = None, corpus: dict | None = None, mode:
|
|
|
1687
1729
|
'</dl>'
|
|
1688
1730
|
'<dd style="margin-top:8px">A document is PASSED only when every non-skipped step passes. Critical-step failures halt remaining steps.</dd>')
|
|
1689
1731
|
|
|
1732
|
+
# Suppressed: only when this run actually accepted advisory issues.
|
|
1733
|
+
if agg is not None and agg.get("checks_suppressed"):
|
|
1734
|
+
section("Suppressed (accepted)", dl([
|
|
1735
|
+
("Suppressed", "An advisory check the project accepted as expected for this corpus "
|
|
1736
|
+
"(config `suppressions` / `--suppress`) - e.g. near-duplicate boilerplate in policy "
|
|
1737
|
+
"documents. Excluded from the hygiene and Platform Health scores but still counted "
|
|
1738
|
+
"and shown as 'N suppressed'. Only advisory checks can be suppressed; a hard gating "
|
|
1739
|
+
"failure is never suppressed, so an accepted known-issue can never mask a real one."),
|
|
1740
|
+
]))
|
|
1741
|
+
|
|
1690
1742
|
# Readiness Verdict: only when a corpus supplied one.
|
|
1691
1743
|
if corpus is not None:
|
|
1692
1744
|
section("Readiness Verdict",
|
|
@@ -2057,6 +2109,7 @@ def generate_batch_dashboard(
|
|
|
2057
2109
|
wall_seconds: float | None = None,
|
|
2058
2110
|
concurrency: int | None = None,
|
|
2059
2111
|
evidence: dict | None = None,
|
|
2112
|
+
suppress: list[str] | None = None,
|
|
2060
2113
|
) -> str:
|
|
2061
2114
|
"""Generate a self-contained, interactive HTML dashboard. Returns abs path.
|
|
2062
2115
|
|
|
@@ -2076,7 +2129,8 @@ def generate_batch_dashboard(
|
|
|
2076
2129
|
_ICON = {s.name: s.icon for s in registered_steps()}
|
|
2077
2130
|
|
|
2078
2131
|
cases = [_result_payload(item.get("name", item["result"].file_name), item["result"]) for item in results]
|
|
2079
|
-
|
|
2132
|
+
# computed on FULL data, before any trim; suppressions accepted from config
|
|
2133
|
+
agg = _aggregate(cases, hide_skipped=hide_skipped, suppress=set(suppress or []))
|
|
2080
2134
|
# Wall clock vs compute time: agg["duration"] is the SUM of per-document durations, so
|
|
2081
2135
|
# under --parallel it far exceeds the elapsed time (a 502-doc run summed 1319s but
|
|
2082
2136
|
# finished in 329s). Carry the real wall clock and concurrency so the summary can show
|
|
@@ -26,7 +26,8 @@ EXIT_OK, EXIT_GATE_FAILED, EXIT_CONFIG = 0, 1, 2
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def parse_args(argv=None) -> argparse.Namespace:
|
|
29
|
-
p = argparse.ArgumentParser(
|
|
29
|
+
p = argparse.ArgumentParser(prog="compass discover",
|
|
30
|
+
description="Discover storage documents and validate vector chunks")
|
|
30
31
|
p.add_argument("--project", help="Project name (required unless --list-checks)")
|
|
31
32
|
p.add_argument("--list-checks", action="store_true",
|
|
32
33
|
help="Print the vector-store check catalogue (number, name, default severity tier) and exit.")
|
|
@@ -90,6 +91,11 @@ def parse_args(argv=None) -> argparse.Namespace:
|
|
|
90
91
|
"--disable-check chunk_count near_duplicate_embeddings.")
|
|
91
92
|
p.add_argument("--severity", nargs="*", default=[], metavar="NAME=TIER",
|
|
92
93
|
help="Override a check's severity, e.g. --severity duplicate_detection=hard.")
|
|
94
|
+
p.add_argument("--suppress", nargs="*", default=[], metavar="CHECK",
|
|
95
|
+
help="Accept an ADVISORY check as expected for this corpus, e.g. --suppress "
|
|
96
|
+
"'Near-duplicate embeddings'. Excluded from the hygiene / Platform Health "
|
|
97
|
+
"scores but still counted and shown ('N suppressed'). Adds to config "
|
|
98
|
+
"suppressions. Only advisory checks - a hard gating failure is never suppressed.")
|
|
93
99
|
# --- baselines + drift (continuous gate) ---
|
|
94
100
|
p.add_argument("--save-baseline", nargs="?", const="", default=None, metavar="PATH",
|
|
95
101
|
help="Snapshot this run as a baseline (default: baselines/<project>.json).")
|
|
@@ -469,6 +475,11 @@ def main(argv=None) -> int:
|
|
|
469
475
|
print(f" Config error: {exc}")
|
|
470
476
|
return EXIT_CONFIG
|
|
471
477
|
|
|
478
|
+
# Accepted advisory issues = config suppressions + any ad-hoc --suppress (deduped, order-kept).
|
|
479
|
+
# Threaded into BOTH the hygiene gate and the report so the score and the HTML agree.
|
|
480
|
+
suppress = list(dict.fromkeys(
|
|
481
|
+
list(getattr(settings, "suppressions", []) or []) + list(args.suppress or [])))
|
|
482
|
+
|
|
472
483
|
# Discovery validates the END STATE of an already-ingested corpus. Ingestion-stage
|
|
473
484
|
# steps (upload, storage) already happened, so they must never HALT the run before
|
|
474
485
|
# the chunk/retrieval checks that are the point. Strip them from critical_steps.
|
|
@@ -596,7 +607,7 @@ def main(argv=None) -> int:
|
|
|
596
607
|
print(f" evidence skipped: {exc}")
|
|
597
608
|
|
|
598
609
|
if args.output_format in ("html", "all"):
|
|
599
|
-
print(f"\n HTML report: {generate_batch_dashboard(results, base + '.html', project=args.project, environment=args.env, run_id=run_id, trends=trend_rows, corpus=corpus, mode='Discovery', profile=profile_data, wall_seconds=wall_seconds, concurrency=concurrency, evidence=evidence_by_doc)}")
|
|
610
|
+
print(f"\n HTML report: {generate_batch_dashboard(results, base + '.html', project=args.project, environment=args.env, run_id=run_id, trends=trend_rows, corpus=corpus, mode='Discovery', profile=profile_data, wall_seconds=wall_seconds, concurrency=concurrency, evidence=evidence_by_doc, suppress=suppress)}")
|
|
600
611
|
if args.output_format in ("json", "all"):
|
|
601
612
|
print(f" JSON report: {export_json(results, base + '.json')}")
|
|
602
613
|
if args.output_format in ("junit", "all"):
|
|
@@ -617,7 +628,7 @@ def main(argv=None) -> int:
|
|
|
617
628
|
# Best-effort context - a failure to score it must not crash the gate decision.
|
|
618
629
|
from compass.report import hygiene_from_results
|
|
619
630
|
try:
|
|
620
|
-
hygiene = hygiene_from_results(results)
|
|
631
|
+
hygiene = hygiene_from_results(results, suppress=suppress)
|
|
621
632
|
except Exception: # noqa: BLE001 - hygiene is advisory; never break the run over it
|
|
622
633
|
hygiene = None
|
|
623
634
|
if hygiene:
|
|
@@ -27,7 +27,11 @@ EXIT_OK, EXIT_NONE_REACHABLE, EXIT_CONFIG = 0, 1, 2
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def parse_args(argv=None) -> argparse.Namespace:
|
|
30
|
-
|
|
30
|
+
# Explicit prog: when dispatched via `compass profile` / `python -m compass profile`, the
|
|
31
|
+
# command name is stripped and sys.argv[0] can be None, which argparse's default
|
|
32
|
+
# prog=basename(sys.argv[0]) chokes on (TypeError on None). Naming it avoids that.
|
|
33
|
+
p = argparse.ArgumentParser(prog="compass profile",
|
|
34
|
+
description="Profile pipeline stages and suggest a COMPASS config")
|
|
31
35
|
p.add_argument("--project", required=True, help="Project name")
|
|
32
36
|
p.add_argument("--env", default="dev", help="Environment")
|
|
33
37
|
p.add_argument("--stage", choices=["storage", "graph", "vector", "all"], default="all",
|
|
@@ -29,7 +29,8 @@ EXIT_OK, EXIT_FAILURES, EXIT_CONFIG = 0, 1, 2
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def parse_args(argv=None) -> argparse.Namespace:
|
|
32
|
-
p = argparse.ArgumentParser(
|
|
32
|
+
p = argparse.ArgumentParser(prog="compass validate",
|
|
33
|
+
description="COMPASS - AI knowledge base validation")
|
|
33
34
|
p.add_argument("--project", required=True, help="Project name (loads config/projects/<name>.yaml)")
|
|
34
35
|
p.add_argument("--env", default=None, help="Environment: dev, staging, prod")
|
|
35
36
|
p.add_argument("--file", help="Validate a single file (overrides test_data)")
|
|
@@ -228,7 +229,7 @@ def main(argv=None) -> int:
|
|
|
228
229
|
report_base = f"reports/{args.project}-report"
|
|
229
230
|
fmt = args.output_format
|
|
230
231
|
if fmt in ("html", "all"):
|
|
231
|
-
print(f" HTML report: {generate_batch_dashboard(results, report_base + '.html', project=args.project, environment=settings.environment, run_id=run_id, trends=trend_rows, corpus=corpus, mode='Live Ingestion')}")
|
|
232
|
+
print(f" HTML report: {generate_batch_dashboard(results, report_base + '.html', project=args.project, environment=settings.environment, run_id=run_id, trends=trend_rows, corpus=corpus, mode='Live Ingestion', suppress=list(getattr(settings, 'suppressions', []) or []))}")
|
|
232
233
|
if fmt in ("json", "all"):
|
|
233
234
|
print(f" JSON report: {export_json(results, report_base + '.json')}")
|
|
234
235
|
if fmt in ("junit", "all"):
|
|
@@ -443,3 +443,32 @@ def test_report_renders_citation_section():
|
|
|
443
443
|
assert "Citation audit" in md and "Generation hallucination" in md
|
|
444
444
|
assert "lexical" in md.lower() # honest-boundary note present
|
|
445
445
|
assert "Where to fix" in md and "**Generation**" in md # per-fault remediation present
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def test_citation_after_sentence_period_is_extracted():
|
|
449
|
+
# DEFECT-001: the standard "...claim. (SOURCE, p. N)" format (citation right after the
|
|
450
|
+
# sentence-ending period) must extract the claim, not silently drop the citation.
|
|
451
|
+
text = ("Growth is expected to stay robust, supported by resilient demand. "
|
|
452
|
+
"(SOURCE, June 2026, p. 1) Earnings are projected to grow.")
|
|
453
|
+
cites = citation.extract_citations(text)
|
|
454
|
+
assert len(cites) == 1
|
|
455
|
+
assert cites[0].claim.endswith("resilient demand.") # the claim sentence, not empty
|
|
456
|
+
# multiple sentences: the claim is the LAST sentence before the citation, not the whole text
|
|
457
|
+
multi = citation.extract_citations("First point here. Second claim with detail. "
|
|
458
|
+
"(SOURCE, 2026, p. 2) Next.")
|
|
459
|
+
assert multi and multi[0].claim == "Second claim with detail."
|
|
460
|
+
# a mid-sentence citation still resolves to the preceding text
|
|
461
|
+
mid = citation.extract_citations("Earnings grew 12% (SOURCE, 2026, p. 3) which beat.")
|
|
462
|
+
assert mid and mid[0].claim == "Earnings grew 12%"
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def test_diagnostic_when_pattern_matches_but_nothing_extracted():
|
|
466
|
+
# DEFECT-003: a citation-like pattern with no supporting claim (extraction yields 0) must
|
|
467
|
+
# surface a diagnostic, not a silent total_citations=0 that reads as "no citations exist".
|
|
468
|
+
from types import SimpleNamespace
|
|
469
|
+
cfg = SimpleNamespace(citation_patterns=[], citation_page_tolerance=0,
|
|
470
|
+
citation_verify_entities=False,
|
|
471
|
+
gt_chunk_doc_field="document_title", gt_chunk_page_field="page_start")
|
|
472
|
+
res = citation.audit_citations("(SOURCE, 2026, p. 1)", None, None, cfg)
|
|
473
|
+
assert res["summary"]["total_citations"] == 0
|
|
474
|
+
assert "matched by regex but 0 were extracted" in res["summary"].get("diagnostic", "")
|
|
@@ -110,6 +110,22 @@ class TestLoadSettings:
|
|
|
110
110
|
assert s.redis.search_index == "demo_index" # overridden
|
|
111
111
|
assert s.redis.expected_embedding_dim == 1536 # inherited from base
|
|
112
112
|
|
|
113
|
+
def test_suppressions_load_from_project_yaml(self, tmp_path):
|
|
114
|
+
cfg_dir = tmp_path / "config"
|
|
115
|
+
(cfg_dir / "projects").mkdir(parents=True)
|
|
116
|
+
(cfg_dir / "base.yaml").write_text("default_env: dev\n")
|
|
117
|
+
(cfg_dir / "projects" / "demo.yaml").write_text(
|
|
118
|
+
textwrap.dedent(
|
|
119
|
+
"""
|
|
120
|
+
suppressions:
|
|
121
|
+
- "Near-duplicate embeddings"
|
|
122
|
+
- "Duplicate text chunks"
|
|
123
|
+
"""
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
s = load_settings(project="demo", env="dev", config_dir=cfg_dir)
|
|
127
|
+
assert s.suppressions == ["Near-duplicate embeddings", "Duplicate text chunks"]
|
|
128
|
+
|
|
113
129
|
def test_environment_selection(self, tmp_path):
|
|
114
130
|
cfg_dir = tmp_path / "config"
|
|
115
131
|
cfg_dir.mkdir()
|
|
@@ -102,3 +102,15 @@ def test_cmd_freshness_end_to_end(monkeypatch, capsys):
|
|
|
102
102
|
if cat == "vectorstore" else store)
|
|
103
103
|
assert cli.cmd_freshness([]) == 0
|
|
104
104
|
assert "Freshness" in capsys.readouterr().out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_freshness_warns_on_near_total_orphan_rate():
|
|
108
|
+
# DEFECT-006: a ~100% orphan rate almost always means a key/bucket mismatch, not that every
|
|
109
|
+
# source was deleted. Flag it so the user checks config before "reconciling" false orphans.
|
|
110
|
+
now = datetime(2026, 7, 10, tzinfo=timezone.utc)
|
|
111
|
+
chunks = [make_chunk(f"doc{i}", 0, s3_key=f"missing{i}.pdf") for i in range(5)]
|
|
112
|
+
store = FakeStorageAdapter(objects={}) # nothing found -> all orphaned
|
|
113
|
+
rep = freshness_report(Settings(), sample=10, now=now,
|
|
114
|
+
vectorstore=FakeVectorStoreAdapter(chunks=chunks), storage=store)
|
|
115
|
+
assert rep["orphan_count"] == 5
|
|
116
|
+
assert "warning" in rep and "key mismatch" in rep["warning"]
|
|
@@ -206,3 +206,19 @@ def test_cmd_compare_end_to_end(monkeypatch, tmp_path, capsys):
|
|
|
206
206
|
assert cli.cmd_compare(["--ground-truth", str(gt)]) == 0
|
|
207
207
|
out = capsys.readouterr().out
|
|
208
208
|
assert "Retrieval comparison" in out and "VERDICT" in out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_diagnose_paths_reports_found_and_missing(tmp_path):
|
|
212
|
+
# DEFECT-007: when compare finds no usable cases, say which configured paths resolved on the
|
|
213
|
+
# first record and which did not, instead of a generic "check your config".
|
|
214
|
+
from compass.eval_interop import diagnose_paths
|
|
215
|
+
p = tmp_path / "gt.json"
|
|
216
|
+
p.write_text(json.dumps([{"input": "q", "context": {"chunks": [{"text": "a"}, {"text": "b"}]}}]))
|
|
217
|
+
lines = "\n".join(diagnose_paths(str(p), {
|
|
218
|
+
"gt_question_path": "question", # absent
|
|
219
|
+
"gt_chunks_path": "context.chunks", # present, 2 items
|
|
220
|
+
"gt_variant_path": "", # unset
|
|
221
|
+
}))
|
|
222
|
+
assert 'gt_question_path: "question" -> NOT FOUND' in lines
|
|
223
|
+
assert 'gt_chunks_path: "context.chunks" -> found (list, 2 item(s))' in lines
|
|
224
|
+
assert "gt_variant_path: (unset)" in lines
|
|
@@ -263,3 +263,22 @@ def test_fabrication_gate_covers_semantic_fabricated_source():
|
|
|
263
263
|
r2 = run_audit({"input": "outlook?", "response": "Cautious.", "context.retrieved": "cautious given rates",
|
|
264
264
|
"scores": {"Groundedness": {"score": 0.9, "target": 0.7}}}, KB(), _cfg(gt_context_path="context.retrieved"))
|
|
265
265
|
assert r2.has_fabrication is False
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_context_not_empty_when_chunks_present_defect_002():
|
|
269
|
+
# DEFECT-002: chunks nested under context.chunks must not read as "empty retrieval" just
|
|
270
|
+
# because the configured gt_context_path does not resolve for this eval schema.
|
|
271
|
+
from compass.config import Settings
|
|
272
|
+
|
|
273
|
+
class _Ad:
|
|
274
|
+
def text_search(self, *a, **k):
|
|
275
|
+
return []
|
|
276
|
+
def get_by_id(self, *a, **k):
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
ev = Settings().eval
|
|
280
|
+
with_chunks = {"input": "q", "output": "answer",
|
|
281
|
+
"context": {"type": "knowledge_retrieval", "chunks": [{"text": "a"}, {"text": "b"}]}}
|
|
282
|
+
assert run_audit(with_chunks, _Ad(), ev).context_empty is False
|
|
283
|
+
truly_empty = {"input": "q", "output": "answer", "context": {}}
|
|
284
|
+
assert run_audit(truly_empty, _Ad(), ev).context_empty is True
|
|
@@ -632,6 +632,49 @@ def test_engineering_wins_are_check_backed_and_specific():
|
|
|
632
632
|
for w in report_mod._engineering_wins({**agg, "failed": 3}))
|
|
633
633
|
|
|
634
634
|
|
|
635
|
+
def test_advisory_suppression_excludes_from_scoring_but_discloses():
|
|
636
|
+
# An accepted advisory (config `suppressions`) drops out of hygiene / offenders but is
|
|
637
|
+
# still counted and listed - never silently hidden.
|
|
638
|
+
r = PipelineResult(document_id="d", file_name="d.pdf")
|
|
639
|
+
r.add_step(StepResult("Redis Chunks", StepStatus.PASSED, "ok", checks=[
|
|
640
|
+
{"label": "Near-duplicate embeddings", "passed": False, "severity": "warn"},
|
|
641
|
+
{"label": "Chunk boundaries clean", "passed": True, "severity": "warn"},
|
|
642
|
+
]))
|
|
643
|
+
cases = [report_mod._result_payload("d.pdf", r)]
|
|
644
|
+
base = report_mod._aggregate(cases)
|
|
645
|
+
supp = report_mod._aggregate(cases, suppress={"Near-duplicate embeddings"})
|
|
646
|
+
assert any(o["label"] == "Near-duplicate embeddings" for o in base["offenders"])
|
|
647
|
+
assert not any(o["label"] == "Near-duplicate embeddings" for o in supp["offenders"])
|
|
648
|
+
assert supp["checks_suppressed"] == 1
|
|
649
|
+
assert supp["suppressed"] == [{"label": "Near-duplicate embeddings", "docs": 1}]
|
|
650
|
+
# suppressing the failing advisory lifts hygiene (only the clean advisory remains scored)
|
|
651
|
+
assert supp["hygiene"]["score"] > base["hygiene"]["score"]
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def test_hard_check_is_never_suppressed():
|
|
655
|
+
# A gating failure must count even if its label is in the suppress list - a known-issue
|
|
656
|
+
# can never mask a real failure (the integrity mandate).
|
|
657
|
+
r = PipelineResult(document_id="d", file_name="d.pdf")
|
|
658
|
+
r.add_step(StepResult("Redis Chunks", StepStatus.FAILED, "bad",
|
|
659
|
+
checks=[{"label": "Chunks exist", "passed": False, "severity": "hard"}]))
|
|
660
|
+
agg = report_mod._aggregate([report_mod._result_payload("d.pdf", r)], suppress={"Chunks exist"})
|
|
661
|
+
assert agg["checks_suppressed"] == 0
|
|
662
|
+
assert any(o["label"] == "Chunks exist" for o in agg["offenders"])
|
|
663
|
+
assert agg["checks_hard"] == 1
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def test_action_plan_surfaces_a_measured_detail():
|
|
667
|
+
# Specific RCA: the Action Plan's Observed line carries a concrete measured example from
|
|
668
|
+
# the check's own detail, not just which check failed.
|
|
669
|
+
r = PipelineResult(document_id="d", file_name="d.pdf")
|
|
670
|
+
r.add_step(StepResult("Redis Chunks", StepStatus.PASSED, "ok", checks=[
|
|
671
|
+
{"label": "Chunk count in range", "passed": False, "severity": "warn",
|
|
672
|
+
"detail": "4200 (~3000 expected)"}]))
|
|
673
|
+
agg = report_mod._aggregate([report_mod._result_payload("d.pdf", r)])
|
|
674
|
+
html = report_mod._action_plan_section(agg)
|
|
675
|
+
assert "4200 (~3000 expected)" in html # the measured number, surfaced
|
|
676
|
+
|
|
677
|
+
|
|
635
678
|
def test_glossary_is_run_driven_not_a_fixed_catalogue():
|
|
636
679
|
# A run with retrieval + chunk findings: the glossary explains exactly what appeared -
|
|
637
680
|
# its categories, Platform Health, the Action Plan tiers, and only the mode that ran -
|
{compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_vectorstore_filters.py
RENAMED
|
@@ -83,6 +83,20 @@ def test_redis_builds_hybrid_tag_prefilter():
|
|
|
83
83
|
assert "=>[KNN 5 @text_embedding $vec AS __score]" in client.query
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
def test_text_search_preserves_spaces_for_multiword_queries():
|
|
87
|
+
# DEFECT-008: a multi-word TEXT search must keep spaces as token separators. TAG-escaping
|
|
88
|
+
# backslash-escaped them, collapsing the value into one unmatchable token so every
|
|
89
|
+
# multi-word search silently returned nothing.
|
|
90
|
+
client = _FakeRedis()
|
|
91
|
+
a = RedisVectorStoreAdapter(_cfg("redis"), client=client)
|
|
92
|
+
a.text_search("text", "Growth is robust", 5)
|
|
93
|
+
assert client.query == "@text:(Growth is robust)" # spaces intact
|
|
94
|
+
assert "\\ " not in client.query # NOT the broken TAG-escaped form
|
|
95
|
+
# single words are unchanged; query-parser metacharacters are still escaped
|
|
96
|
+
assert RedisVectorStoreAdapter.escape_text("ACWI") == "ACWI"
|
|
97
|
+
assert "\\(" in RedisVectorStoreAdapter.escape_text("S&P (est)")
|
|
98
|
+
|
|
99
|
+
|
|
86
100
|
# --------------------------------------------------------------------------- #
|
|
87
101
|
# Qdrant - Filter(must=[FieldCondition(MatchValue|MatchAny)])
|
|
88
102
|
# --------------------------------------------------------------------------- #
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/api_upload.py
RENAMED
|
File without changes
|
|
File without changes
|
{compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/base_sparql.py
RENAMED
|
File without changes
|
{compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/cross_stage.py
RENAMED
|
File without changes
|
{compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/graphdb_metadata.py
RENAMED
|
File without changes
|
{compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/neptune_metadata.py
RENAMED
|
File without changes
|
{compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/redis_chunks.py
RENAMED
|
File without changes
|
|
File without changes
|
{compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/retrieval_quality.py
RENAMED
|
File without changes
|
{compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/compass/validators/s3_storage.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/ingestion_validation/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_discovery_parallel.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_retrieval_assertions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{compass_kb_validation-3.1.0 → compass_kb_validation-3.2.1}/tests/test_universal_concerns.py
RENAMED
|
File without changes
|
|
File without changes
|