compass-kb-validation 2.1.0__tar.gz → 2.2.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.1.0 → compass_kb_validation-2.2.0}/PKG-INFO +1 -1
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/compass_kb_validation.egg-info/PKG-INFO +1 -1
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/discover_and_validate.py +108 -7
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/__init__.py +1 -1
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/config_lint.py +34 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/corpus_health.py +22 -3
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/report/__init__.py +279 -14
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/report/_report_css.py +12 -2
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/base.py +4 -1
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_config_lint.py +19 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_corpus_health.py +18 -0
- compass_kb_validation-2.2.0/tests/test_discover_cli.py +146 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_reporting.py +138 -8
- compass_kb_validation-2.1.0/tests/test_discover_cli.py +0 -74
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/LICENSE +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/README.md +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/compass_kb_validation.egg-info/SOURCES.txt +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/compass_kb_validation.egg-info/entry_points.txt +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/compass_kb_validation.egg-info/requires.txt +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/compass_kb_validation.egg-info/top_level.txt +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/__main__.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/ask.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/baseline.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/chunk_coherence.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/cli.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/config/__init__.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/content_inspect.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/corpus.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/diagnostics.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/embedding_drift.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/eval_interop.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/init_wizard.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/live_dashboard.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/mcp_server.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/models/__init__.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/notifications.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/pipeline/__init__.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/profiler.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/provenance.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/providers/__init__.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/providers/embedding.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/providers/graph.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/providers/source.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/providers/storage.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/providers/vectorstore.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/remediation.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/report/_report_js.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/report/analysis.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/report/export.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/report/shell.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/retrieval_analysis.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/trend_tracker.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/utils/__init__.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/__init__.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/api_upload.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/base_sparql.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/cross_stage.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/graphdb_metadata.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/neptune_metadata.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/redis_chunks.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/registry.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/retrieval_quality.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/s3_storage.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/profile_kb.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/pyproject.toml +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/run_validation.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/setup.cfg +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_alt_adapters.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_analysis_report.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_api_upload.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_ask.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_baseline.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_benchmark.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_check_catalog.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_cli.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_config.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_content_inspect.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_corpus.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_coverage.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_cross_stage.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_diagnostics.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_discovery_parallel.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_embedding_drift.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_eval_interop.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_gate.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_graph_transport.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_graph_validators.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_init_wizard.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_manifest.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_mcp.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_migration.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_models.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_neptune_bugfixes.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_neptune_bugfixes2.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_notifications.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_pipeline.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_profiler.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_providers.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_redis_chunks.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_registry.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_remediation.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_retrieval_assertions.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_retrieval_quality.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_run_cli.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_sources.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_storage_validator.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_trends.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_universal_concerns.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_utils.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_vectorstore_filters.py +0 -0
|
@@ -63,6 +63,10 @@ def parse_args(argv=None) -> argparse.Namespace:
|
|
|
63
63
|
"Replaces the default 'fail on any failure' rule.")
|
|
64
64
|
p.add_argument("--require-ready", action="store_true",
|
|
65
65
|
help="Also fail unless the corpus readiness verdict is READY.")
|
|
66
|
+
p.add_argument("--quality-threshold", type=float, metavar="PCT",
|
|
67
|
+
help="Also fail unless the KB Hygiene score is >= PCT (0-100). Hygiene is "
|
|
68
|
+
"the share of advisory data-quality checks that passed - data-plane "
|
|
69
|
+
"cleanliness, separate from the READY integrity gate.")
|
|
66
70
|
# --- config overrides (run scenarios without editing YAML; CLI wins over config) ---
|
|
67
71
|
p.add_argument("--readiness-threshold", type=float, metavar="FRAC",
|
|
68
72
|
help="Override corpus readiness cutoff (0-1). READY when pass-rate >= this.")
|
|
@@ -163,15 +167,18 @@ def _normalize_skips(tokens: list[str]) -> list[str]:
|
|
|
163
167
|
|
|
164
168
|
|
|
165
169
|
def gate_failed(passed: int, total: int, verdict: str, fail_under: float | None,
|
|
166
|
-
require_ready: bool
|
|
170
|
+
require_ready: bool, quality_threshold: float | None = None,
|
|
171
|
+
hygiene_score: float | None = None) -> tuple[bool, str]:
|
|
167
172
|
"""Decide whether the readiness gate fails, and why.
|
|
168
173
|
|
|
169
174
|
Returns (failed, reason). The gate is the product's contract with a CI
|
|
170
175
|
pipeline: a non-zero exit blocks promotion of a knowledge base that is not
|
|
171
176
|
retrieval-ready. Strictness is caller-controlled:
|
|
172
|
-
* default
|
|
173
|
-
* --fail-under PCT
|
|
174
|
-
* --require-ready
|
|
177
|
+
* default -- fail if any document failed validation
|
|
178
|
+
* --fail-under PCT -- fail if pass-rate < PCT (tolerates a few failures)
|
|
179
|
+
* --require-ready -- additionally fail if the corpus verdict != READY
|
|
180
|
+
* --quality-threshold N -- additionally fail if KB Hygiene < N (data-plane
|
|
181
|
+
cleanliness, separate from the integrity checks above)
|
|
175
182
|
"""
|
|
176
183
|
rate = (passed / total * 100.0) if total else 100.0
|
|
177
184
|
if fail_under is not None:
|
|
@@ -181,6 +188,8 @@ def gate_failed(passed: int, total: int, verdict: str, fail_under: float | None,
|
|
|
181
188
|
return True, f"{total - passed} document(s) failed validation"
|
|
182
189
|
if require_ready and verdict != "READY":
|
|
183
190
|
return True, f"corpus readiness verdict is {verdict} (READY required)"
|
|
191
|
+
if quality_threshold is not None and hygiene_score is not None and hygiene_score < quality_threshold:
|
|
192
|
+
return True, f"KB hygiene {hygiene_score:g}% < required {quality_threshold:g}%"
|
|
184
193
|
return False, "all gate conditions met"
|
|
185
194
|
|
|
186
195
|
|
|
@@ -227,6 +236,73 @@ def _validate_one_document(pipeline: IngestionPipeline, doc: dict) -> dict:
|
|
|
227
236
|
return {"name": name, "result": result}
|
|
228
237
|
|
|
229
238
|
|
|
239
|
+
_AUTH_HINTS = ("403", "forbidden", "expired", "credential", "token", "signaturedoesnotmatch",
|
|
240
|
+
"invalidclienttokenid", "accessdenied", "unauthor")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _resolve_expected_documents(raw, project: str):
|
|
244
|
+
"""Resolve the configured expected_documents, supporting ``auto``.
|
|
245
|
+
|
|
246
|
+
An int is used as-is. ``"auto"`` uses the previous run's document count for this
|
|
247
|
+
project as the baseline, so completeness alerts on drift (documents appearing or
|
|
248
|
+
disappearing) without a hand-maintained number. The FIRST run has no prior count, so
|
|
249
|
+
it returns None - the baseline is simply established this run, no false "missing".
|
|
250
|
+
"""
|
|
251
|
+
if isinstance(raw, bool): # guard: bool is an int subclass
|
|
252
|
+
return None
|
|
253
|
+
if isinstance(raw, int):
|
|
254
|
+
return raw
|
|
255
|
+
if isinstance(raw, str) and raw.strip().isdigit():
|
|
256
|
+
return int(raw.strip())
|
|
257
|
+
if isinstance(raw, str) and raw.strip().lower() == "auto":
|
|
258
|
+
try:
|
|
259
|
+
from ingestion_validation.trend_tracker import TrendTracker
|
|
260
|
+
recent = TrendTracker().summary(last_n=1, project=project)
|
|
261
|
+
if recent and recent[0].get("total_cases"):
|
|
262
|
+
return int(recent[0]["total_cases"])
|
|
263
|
+
except Exception: # noqa: BLE001 - no history / unreadable db: establish baseline this run
|
|
264
|
+
return None
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _warn_on_unreachable_backends(results: list[dict]) -> None:
|
|
269
|
+
"""After a run, warn loudly if any step was SKIPPED because its backend was unreachable.
|
|
270
|
+
|
|
271
|
+
An unreachable backend does not gate the run (infrastructure is not a data defect), so
|
|
272
|
+
without this the affected documents are validated on a partial pipeline and the run
|
|
273
|
+
still reports green - the exact silent degradation that let an expired STS token skip
|
|
274
|
+
315 documents unnoticed. Auth-flavoured errors get a credential-refresh hint.
|
|
275
|
+
"""
|
|
276
|
+
by_step: dict[str, dict] = {}
|
|
277
|
+
for item in results:
|
|
278
|
+
for st in getattr(item.get("result"), "steps", []) or []:
|
|
279
|
+
d = getattr(st, "details", {}) or {}
|
|
280
|
+
if d.get("unreachable"):
|
|
281
|
+
rec = by_step.setdefault(st.step_name, {"count": 0, "auth": False, "error": ""})
|
|
282
|
+
rec["count"] += 1
|
|
283
|
+
err = str(d.get("last_error", "") or "")
|
|
284
|
+
if err and not rec["error"]:
|
|
285
|
+
rec["error"] = err
|
|
286
|
+
if any(h in err.lower().replace(" ", "") for h in _AUTH_HINTS):
|
|
287
|
+
rec["auth"] = True
|
|
288
|
+
if not by_step:
|
|
289
|
+
return
|
|
290
|
+
print("\n " + "!" * 68)
|
|
291
|
+
for step, rec in by_step.items():
|
|
292
|
+
print(f" WARNING: {step} was unreachable for {rec['count']} document(s) - they were "
|
|
293
|
+
f"validated WITHOUT it.")
|
|
294
|
+
if rec["auth"]:
|
|
295
|
+
print(" The endpoint rejected the request (auth). Credentials may have "
|
|
296
|
+
"expired mid-run;")
|
|
297
|
+
print(" re-run with fresh credentials (e.g. new STS tokens) to validate "
|
|
298
|
+
"these documents.")
|
|
299
|
+
elif rec["error"]:
|
|
300
|
+
print(f" Last error: {rec['error'][:140]}")
|
|
301
|
+
print(" These steps did not gate the run (set pipeline.unreachable_as: "
|
|
302
|
+
"failed to change that).")
|
|
303
|
+
print(" " + "!" * 68)
|
|
304
|
+
|
|
305
|
+
|
|
230
306
|
def validate_documents(settings, documents: list[dict], skip_steps: list[str],
|
|
231
307
|
parallel: bool = False, workers: int = 4) -> list[dict]:
|
|
232
308
|
total = len(documents)
|
|
@@ -390,21 +466,28 @@ def main(argv=None) -> int:
|
|
|
390
466
|
print(" Note: discovery source is not object storage; skipping the S3 Storage "
|
|
391
467
|
"step (use --keep s3_storage to force it).")
|
|
392
468
|
|
|
469
|
+
import time as _t
|
|
470
|
+
_run_started = _t.perf_counter()
|
|
393
471
|
results = validate_documents(settings, documents, skip_steps=skip_steps,
|
|
394
472
|
parallel=args.parallel, workers=args.workers)
|
|
473
|
+
wall_seconds = _t.perf_counter() - _run_started
|
|
474
|
+
concurrency = args.workers if args.parallel and len(documents) > 1 else 1
|
|
395
475
|
|
|
396
476
|
# Corpus-level knowledge-base readiness (completeness, dedup, coverage).
|
|
397
477
|
from ingestion_validation.corpus import analyze_corpus
|
|
398
478
|
|
|
479
|
+
expected_documents = _resolve_expected_documents(
|
|
480
|
+
settings.discovery.extra.get("expected_documents"), args.project)
|
|
399
481
|
corpus = analyze_corpus(
|
|
400
482
|
documents, results,
|
|
401
|
-
expected_documents=
|
|
483
|
+
expected_documents=expected_documents,
|
|
402
484
|
expected_folders=settings.discovery.extra.get("expected_folders"),
|
|
403
485
|
expected_document_names=settings.discovery.extra.get("expected_document_names"),
|
|
404
486
|
readiness_threshold=settings.pipeline.readiness_threshold,
|
|
405
487
|
)
|
|
406
488
|
print(f" KB readiness: {corpus['verdict']} ({corpus['passed']}/{corpus['total']} passed, "
|
|
407
489
|
f"{corpus['total'] - corpus['passed']} failed)")
|
|
490
|
+
_warn_on_unreachable_backends(results)
|
|
408
491
|
|
|
409
492
|
# Maintain historical trends (stdlib sqlite; on by default) and embed them in the report.
|
|
410
493
|
trend_rows = _record_trends(results, args.project, args.env, run_id, "Discovery", disabled=args.no_trends)
|
|
@@ -413,14 +496,32 @@ def main(argv=None) -> int:
|
|
|
413
496
|
failed = len(results) - passed
|
|
414
497
|
base = f"reports/{args.project}-discovery-report"
|
|
415
498
|
if args.output_format in ("html", "all"):
|
|
416
|
-
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)}")
|
|
499
|
+
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)}")
|
|
417
500
|
if args.output_format in ("json", "all"):
|
|
418
501
|
print(f" JSON report: {export_json(results, base + '.json')}")
|
|
419
502
|
if args.output_format in ("junit", "all"):
|
|
420
503
|
print(f" JUnit report: {export_junit(results, base + '.xml')}")
|
|
421
504
|
|
|
505
|
+
# KB Hygiene: data-plane cleanliness (advisory checks), separate from the integrity
|
|
506
|
+
# gate. Printed for every run; gates the build only when --quality-threshold is set.
|
|
507
|
+
# Best-effort context - a failure to score it must not crash the gate decision.
|
|
508
|
+
from ingestion_validation.report import hygiene_from_results
|
|
509
|
+
try:
|
|
510
|
+
hygiene = hygiene_from_results(results)
|
|
511
|
+
except Exception: # noqa: BLE001 - hygiene is advisory; never break the run over it
|
|
512
|
+
hygiene = None
|
|
513
|
+
if hygiene:
|
|
514
|
+
worst = hygiene.get("worst")
|
|
515
|
+
worst_note = (f" | worst: {worst['name']} {worst['rate']:g}%"
|
|
516
|
+
if worst and worst["rate"] < 100 else "")
|
|
517
|
+
print(f" KB hygiene: {hygiene['score']:g}% {hygiene['tier']} "
|
|
518
|
+
f"({hygiene['advisory_passed']}/{hygiene['advisory_total']} advisory checks){worst_note}")
|
|
519
|
+
hygiene_score = hygiene["score"] if hygiene else None
|
|
520
|
+
|
|
422
521
|
failed_gate, reason = gate_failed(passed, len(results), corpus["verdict"],
|
|
423
|
-
args.fail_under, args.require_ready
|
|
522
|
+
args.fail_under, args.require_ready,
|
|
523
|
+
quality_threshold=args.quality_threshold,
|
|
524
|
+
hygiene_score=hygiene_score)
|
|
424
525
|
|
|
425
526
|
# Baselines + drift: snapshot a known-good run and/or diff against one.
|
|
426
527
|
regression = False
|
{compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/config_lint.py
RENAMED
|
@@ -125,6 +125,39 @@ def _lint_consistency(settings: Any) -> list[Finding]:
|
|
|
125
125
|
return findings
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def _lint_skip_steps(settings: Any) -> list[Finding]:
|
|
129
|
+
"""Flag skip_steps entries that match no registered step.
|
|
130
|
+
|
|
131
|
+
A skip_steps entry naming a step that does not exist is silently ignored at runtime -
|
|
132
|
+
so a typo ("Redis Chunk"), or a step removed in an upgrade (the v2.0 UI Visibility
|
|
133
|
+
step), quietly does nothing and the run is not scoped the way the config says. The
|
|
134
|
+
runtime stays quiet (an unknown skip is harmless there and a per-run warning is just
|
|
135
|
+
noise); lint is where config drift belongs, so it is surfaced here once, with the fix.
|
|
136
|
+
"""
|
|
137
|
+
skip = list(getattr(settings, "skip_steps", []) or [])
|
|
138
|
+
if not skip:
|
|
139
|
+
return []
|
|
140
|
+
try:
|
|
141
|
+
from ingestion_validation.validators.registry import registered_steps
|
|
142
|
+
known = list(registered_steps())
|
|
143
|
+
except Exception: # noqa: BLE001 - registry unavailable; skip this check rather than crash lint
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
def _norm(s: str) -> str:
|
|
147
|
+
return "".join(str(s).lower().split()).replace("_", "").replace("-", "")
|
|
148
|
+
|
|
149
|
+
valid = {_norm(s.name) for s in known} | {_norm(s.key) for s in known}
|
|
150
|
+
findings: list[Finding] = []
|
|
151
|
+
for entry in skip:
|
|
152
|
+
if _norm(entry) not in valid:
|
|
153
|
+
names = ", ".join(sorted(s.name for s in known))
|
|
154
|
+
removed = " (the UI Visibility step was removed in v2.0)" if _norm(entry) == _norm("UI Visibility") else ""
|
|
155
|
+
findings.append(("info",
|
|
156
|
+
f"skip_steps entry '{entry}' matches no registered step{removed}; it is "
|
|
157
|
+
f"silently ignored. Remove it, or use one of: {names}."))
|
|
158
|
+
return findings
|
|
159
|
+
|
|
160
|
+
|
|
128
161
|
def lint_settings(settings: Any) -> list[Finding]:
|
|
129
162
|
"""Run every static config check against a loaded Settings. Never connects anywhere."""
|
|
130
163
|
findings: list[Finding] = []
|
|
@@ -137,6 +170,7 @@ def lint_settings(settings: Any) -> list[Finding]:
|
|
|
137
170
|
findings += _lint_helper_queries(label, cfg)
|
|
138
171
|
findings += _lint_redis(settings)
|
|
139
172
|
findings += _lint_consistency(settings)
|
|
173
|
+
findings += _lint_skip_steps(settings)
|
|
140
174
|
return findings
|
|
141
175
|
|
|
142
176
|
|
{compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/corpus_health.py
RENAMED
|
@@ -129,8 +129,11 @@ def freshness_report(settings: Settings, *, sample: int = 500, stale_after_days:
|
|
|
129
129
|
_INGEST_FIELDS = ("ingested_at", "indexed_at", "embedded_at", "updated_at", "created_at")
|
|
130
130
|
doc_key: dict[str, str] = {}
|
|
131
131
|
doc_ingest: dict[str, datetime] = {}
|
|
132
|
+
seen_docs: set[str] = set() # every distinct document in the sample, source key or not
|
|
132
133
|
for c in vs.sample_chunks(sample):
|
|
133
134
|
doc = str(c.get(id_field) or c.get("document_id") or c.get("key") or "")
|
|
135
|
+
if doc:
|
|
136
|
+
seen_docs.add(doc)
|
|
134
137
|
s3_key = c.get("s3_key") or ""
|
|
135
138
|
if doc and s3_key and doc not in doc_key:
|
|
136
139
|
doc_key[doc] = s3_key
|
|
@@ -182,6 +185,11 @@ def freshness_report(settings: Settings, *, sample: int = 500, stale_after_days:
|
|
|
182
185
|
stale.sort(key=lambda x: -x["age_days"])
|
|
183
186
|
drift.sort(key=lambda x: -x["behind_days"])
|
|
184
187
|
checked = len(doc_key)
|
|
188
|
+
# Documents sampled that carry NO source key were never checked against storage - the
|
|
189
|
+
# gap between "documents in the index" and "documents freshness could verify". Left
|
|
190
|
+
# implicit, it reads as "freshness found only 176 of 502 documents"; it usually means
|
|
191
|
+
# those chunks lack an s3_key (a prefix/field mismatch) or point at a different store.
|
|
192
|
+
without_key = len(seen_docs) - checked
|
|
185
193
|
if not stale and not orphans and not drift:
|
|
186
194
|
verdict = "FRESH - all sampled sources within the window and in sync"
|
|
187
195
|
else:
|
|
@@ -193,14 +201,21 @@ def freshness_report(settings: Settings, *, sample: int = 500, stale_after_days:
|
|
|
193
201
|
if orphans:
|
|
194
202
|
parts.append(f"{len(orphans)} orphan(s) (source object gone)")
|
|
195
203
|
verdict = "STALE - " + ", ".join(parts)
|
|
196
|
-
|
|
197
|
-
"documents": checked, "
|
|
204
|
+
result = {
|
|
205
|
+
"documents": checked, "sampled_documents": len(seen_docs),
|
|
206
|
+
"without_source_key": without_key, "stale_after_days": stale_after_days,
|
|
198
207
|
"stale_count": len(stale), "orphan_count": len(orphans), "drift_count": len(drift),
|
|
199
208
|
"max_age_days": max(ages) if ages else 0, "median_age_days": sorted(ages)[len(ages) // 2] if ages else 0,
|
|
200
209
|
"stale": stale[: min(20, len(stale))], "orphans": orphans[: min(20, len(orphans))],
|
|
201
210
|
"drift": drift[: min(20, len(drift))],
|
|
202
211
|
"verdict": verdict,
|
|
203
212
|
}
|
|
213
|
+
if without_key:
|
|
214
|
+
result["note"] = (
|
|
215
|
+
f"{without_key} of {len(seen_docs)} sampled document(s) carry no source key, so "
|
|
216
|
+
f"their source could not be checked - likely a missing s3_key field (prefix/field "
|
|
217
|
+
f"mismatch) or sources in a different store, not necessarily orphans.")
|
|
218
|
+
return result
|
|
204
219
|
|
|
205
220
|
|
|
206
221
|
def _parse_timestamp(value: Any) -> Optional[datetime]:
|
|
@@ -222,13 +237,17 @@ def _parse_timestamp(value: Any) -> Optional[datetime]:
|
|
|
222
237
|
def format_freshness(payload: dict) -> str:
|
|
223
238
|
if payload.get("error"):
|
|
224
239
|
return f"Freshness: {payload['error']}"
|
|
240
|
+
sampled = payload.get("sampled_documents")
|
|
241
|
+
checked_note = f" of {sampled} sampled" if sampled and sampled != payload["documents"] else ""
|
|
225
242
|
lines = [
|
|
226
|
-
f"Freshness: {payload['documents']} document(s) checked (stale after {payload['stale_after_days']}d)",
|
|
243
|
+
f"Freshness: {payload['documents']} document(s) checked{checked_note} (stale after {payload['stale_after_days']}d)",
|
|
227
244
|
f" stale: {payload['stale_count']} orphans (source missing): {payload['orphan_count']} "
|
|
228
245
|
f"drift (source newer than KB): {payload.get('drift_count', 0)} "
|
|
229
246
|
f"max age: {payload['max_age_days']}d median age: {payload['median_age_days']}d",
|
|
230
247
|
f" VERDICT: {payload['verdict']}",
|
|
231
248
|
]
|
|
249
|
+
if payload.get("note"):
|
|
250
|
+
lines.append(f" NOTE: {payload['note']}")
|
|
232
251
|
if payload.get("drift"):
|
|
233
252
|
lines.append(" content drift (source modified after the KB ingested it):")
|
|
234
253
|
for d in payload["drift"]:
|