compass-kb-validation 2.1.0__tar.gz → 2.3.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.3.0}/PKG-INFO +1 -1
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/compass_kb_validation.egg-info/PKG-INFO +1 -1
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/discover_and_validate.py +152 -7
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/__init__.py +1 -1
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/config_lint.py +34 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/corpus_health.py +22 -3
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/profiler.py +73 -1
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/report/__init__.py +286 -16
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/report/_report_css.py +12 -2
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/base.py +4 -1
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_config_lint.py +19 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_corpus_health.py +18 -0
- compass_kb_validation-2.3.0/tests/test_discover_cli.py +192 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_reporting.py +153 -8
- compass_kb_validation-2.1.0/tests/test_discover_cli.py +0 -74
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/LICENSE +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/README.md +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/compass_kb_validation.egg-info/SOURCES.txt +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/compass_kb_validation.egg-info/entry_points.txt +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/compass_kb_validation.egg-info/requires.txt +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/compass_kb_validation.egg-info/top_level.txt +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/__main__.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/ask.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/baseline.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/chunk_coherence.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/cli.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/config/__init__.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/content_inspect.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/corpus.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/diagnostics.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/embedding_drift.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/eval_interop.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/init_wizard.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/live_dashboard.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/mcp_server.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/models/__init__.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/notifications.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/pipeline/__init__.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/provenance.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/providers/__init__.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/providers/embedding.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/providers/graph.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/providers/source.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/providers/storage.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/providers/vectorstore.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/remediation.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/report/_report_js.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/report/analysis.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/report/export.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/report/shell.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/retrieval_analysis.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/trend_tracker.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/utils/__init__.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/__init__.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/api_upload.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/base_sparql.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/cross_stage.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/graphdb_metadata.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/neptune_metadata.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/redis_chunks.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/registry.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/retrieval_quality.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/s3_storage.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/profile_kb.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/pyproject.toml +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/run_validation.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/setup.cfg +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_alt_adapters.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_analysis_report.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_api_upload.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_ask.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_baseline.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_benchmark.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_check_catalog.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_cli.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_config.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_content_inspect.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_corpus.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_coverage.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_cross_stage.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_diagnostics.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_discovery_parallel.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_embedding_drift.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_eval_interop.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_gate.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_graph_transport.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_graph_validators.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_init_wizard.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_manifest.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_mcp.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_migration.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_models.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_neptune_bugfixes.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_neptune_bugfixes2.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_notifications.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_pipeline.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_profiler.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_providers.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_redis_chunks.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_registry.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_remediation.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_retrieval_assertions.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_retrieval_quality.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_run_cli.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_sources.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_storage_validator.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_trends.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_universal_concerns.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_utils.py +0 -0
- {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_vectorstore_filters.py +0 -0
|
@@ -63,6 +63,16 @@ 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.")
|
|
70
|
+
p.add_argument("--suggest-config", nargs="?", const="-", metavar="PATH",
|
|
71
|
+
help="After the run, emit config refinements from what it observed "
|
|
72
|
+
"(document count, chunk-size distribution, checks that never applied). "
|
|
73
|
+
"Prints to stdout, or writes to PATH. Review before merging.")
|
|
74
|
+
p.add_argument("--no-tips", action="store_true",
|
|
75
|
+
help="Suppress the end-of-run tips about relevant unused flags.")
|
|
66
76
|
# --- config overrides (run scenarios without editing YAML; CLI wins over config) ---
|
|
67
77
|
p.add_argument("--readiness-threshold", type=float, metavar="FRAC",
|
|
68
78
|
help="Override corpus readiness cutoff (0-1). READY when pass-rate >= this.")
|
|
@@ -163,15 +173,18 @@ def _normalize_skips(tokens: list[str]) -> list[str]:
|
|
|
163
173
|
|
|
164
174
|
|
|
165
175
|
def gate_failed(passed: int, total: int, verdict: str, fail_under: float | None,
|
|
166
|
-
require_ready: bool
|
|
176
|
+
require_ready: bool, quality_threshold: float | None = None,
|
|
177
|
+
hygiene_score: float | None = None) -> tuple[bool, str]:
|
|
167
178
|
"""Decide whether the readiness gate fails, and why.
|
|
168
179
|
|
|
169
180
|
Returns (failed, reason). The gate is the product's contract with a CI
|
|
170
181
|
pipeline: a non-zero exit blocks promotion of a knowledge base that is not
|
|
171
182
|
retrieval-ready. Strictness is caller-controlled:
|
|
172
|
-
* default
|
|
173
|
-
* --fail-under PCT
|
|
174
|
-
* --require-ready
|
|
183
|
+
* default -- fail if any document failed validation
|
|
184
|
+
* --fail-under PCT -- fail if pass-rate < PCT (tolerates a few failures)
|
|
185
|
+
* --require-ready -- additionally fail if the corpus verdict != READY
|
|
186
|
+
* --quality-threshold N -- additionally fail if KB Hygiene < N (data-plane
|
|
187
|
+
cleanliness, separate from the integrity checks above)
|
|
175
188
|
"""
|
|
176
189
|
rate = (passed / total * 100.0) if total else 100.0
|
|
177
190
|
if fail_under is not None:
|
|
@@ -181,6 +194,8 @@ def gate_failed(passed: int, total: int, verdict: str, fail_under: float | None,
|
|
|
181
194
|
return True, f"{total - passed} document(s) failed validation"
|
|
182
195
|
if require_ready and verdict != "READY":
|
|
183
196
|
return True, f"corpus readiness verdict is {verdict} (READY required)"
|
|
197
|
+
if quality_threshold is not None and hygiene_score is not None and hygiene_score < quality_threshold:
|
|
198
|
+
return True, f"KB hygiene {hygiene_score:g}% < required {quality_threshold:g}%"
|
|
184
199
|
return False, "all gate conditions met"
|
|
185
200
|
|
|
186
201
|
|
|
@@ -227,6 +242,99 @@ def _validate_one_document(pipeline: IngestionPipeline, doc: dict) -> dict:
|
|
|
227
242
|
return {"name": name, "result": result}
|
|
228
243
|
|
|
229
244
|
|
|
245
|
+
_AUTH_HINTS = ("403", "forbidden", "expired", "credential", "token", "signaturedoesnotmatch",
|
|
246
|
+
"invalidclienttokenid", "accessdenied", "unauthor")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _print_tips(args, hygiene: dict | None, saved_baseline: bool) -> None:
|
|
250
|
+
"""End-of-run hints about the most relevant unused flags.
|
|
251
|
+
|
|
252
|
+
Discoverability, not noise: at most three, only when the flag was NOT already used and
|
|
253
|
+
the run's own outcome makes it relevant. Suppress with --no-tips. This is why a partner
|
|
254
|
+
can run for weeks without finding --save-baseline / --unreachable-as / --suggest-config.
|
|
255
|
+
"""
|
|
256
|
+
tips: list[str] = []
|
|
257
|
+
if not saved_baseline:
|
|
258
|
+
tips.append("Save this run as a baseline to catch regressions next time: "
|
|
259
|
+
"--save-baseline baselines/{}.json".format(args.project or "kb"))
|
|
260
|
+
if hygiene and hygiene.get("score", 100) < 90 and args.quality_threshold is None:
|
|
261
|
+
tips.append("Gate CI on data-plane hygiene (not just integrity): "
|
|
262
|
+
"--quality-threshold {}".format(int(hygiene["score"])))
|
|
263
|
+
if not getattr(args, "include_retrieval", False):
|
|
264
|
+
tips.append("Add retrieval-quality checks (can a stored vector be found via KNN?): "
|
|
265
|
+
"--include-retrieval")
|
|
266
|
+
if not args.suggest_config:
|
|
267
|
+
tips.append("Refine your config from what this run observed: --suggest-config")
|
|
268
|
+
if not tips:
|
|
269
|
+
return
|
|
270
|
+
print("\n Tips (--no-tips to silence):")
|
|
271
|
+
for t in tips[:3]:
|
|
272
|
+
print(f" * {t}")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _resolve_expected_documents(raw, project: str):
|
|
276
|
+
"""Resolve the configured expected_documents, supporting ``auto``.
|
|
277
|
+
|
|
278
|
+
An int is used as-is. ``"auto"`` uses the previous run's document count for this
|
|
279
|
+
project as the baseline, so completeness alerts on drift (documents appearing or
|
|
280
|
+
disappearing) without a hand-maintained number. The FIRST run has no prior count, so
|
|
281
|
+
it returns None - the baseline is simply established this run, no false "missing".
|
|
282
|
+
"""
|
|
283
|
+
if isinstance(raw, bool): # guard: bool is an int subclass
|
|
284
|
+
return None
|
|
285
|
+
if isinstance(raw, int):
|
|
286
|
+
return raw
|
|
287
|
+
if isinstance(raw, str) and raw.strip().isdigit():
|
|
288
|
+
return int(raw.strip())
|
|
289
|
+
if isinstance(raw, str) and raw.strip().lower() == "auto":
|
|
290
|
+
try:
|
|
291
|
+
from ingestion_validation.trend_tracker import TrendTracker
|
|
292
|
+
recent = TrendTracker().summary(last_n=1, project=project)
|
|
293
|
+
if recent and recent[0].get("total_cases"):
|
|
294
|
+
return int(recent[0]["total_cases"])
|
|
295
|
+
except Exception: # noqa: BLE001 - no history / unreadable db: establish baseline this run
|
|
296
|
+
return None
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _warn_on_unreachable_backends(results: list[dict]) -> None:
|
|
301
|
+
"""After a run, warn loudly if any step was SKIPPED because its backend was unreachable.
|
|
302
|
+
|
|
303
|
+
An unreachable backend does not gate the run (infrastructure is not a data defect), so
|
|
304
|
+
without this the affected documents are validated on a partial pipeline and the run
|
|
305
|
+
still reports green - the exact silent degradation that let an expired STS token skip
|
|
306
|
+
315 documents unnoticed. Auth-flavoured errors get a credential-refresh hint.
|
|
307
|
+
"""
|
|
308
|
+
by_step: dict[str, dict] = {}
|
|
309
|
+
for item in results:
|
|
310
|
+
for st in getattr(item.get("result"), "steps", []) or []:
|
|
311
|
+
d = getattr(st, "details", {}) or {}
|
|
312
|
+
if d.get("unreachable"):
|
|
313
|
+
rec = by_step.setdefault(st.step_name, {"count": 0, "auth": False, "error": ""})
|
|
314
|
+
rec["count"] += 1
|
|
315
|
+
err = str(d.get("last_error", "") or "")
|
|
316
|
+
if err and not rec["error"]:
|
|
317
|
+
rec["error"] = err
|
|
318
|
+
if any(h in err.lower().replace(" ", "") for h in _AUTH_HINTS):
|
|
319
|
+
rec["auth"] = True
|
|
320
|
+
if not by_step:
|
|
321
|
+
return
|
|
322
|
+
print("\n " + "!" * 68)
|
|
323
|
+
for step, rec in by_step.items():
|
|
324
|
+
print(f" WARNING: {step} was unreachable for {rec['count']} document(s) - they were "
|
|
325
|
+
f"validated WITHOUT it.")
|
|
326
|
+
if rec["auth"]:
|
|
327
|
+
print(" The endpoint rejected the request (auth). Credentials may have "
|
|
328
|
+
"expired mid-run;")
|
|
329
|
+
print(" re-run with fresh credentials (e.g. new STS tokens) to validate "
|
|
330
|
+
"these documents.")
|
|
331
|
+
elif rec["error"]:
|
|
332
|
+
print(f" Last error: {rec['error'][:140]}")
|
|
333
|
+
print(" These steps did not gate the run (set pipeline.unreachable_as: "
|
|
334
|
+
"failed to change that).")
|
|
335
|
+
print(" " + "!" * 68)
|
|
336
|
+
|
|
337
|
+
|
|
230
338
|
def validate_documents(settings, documents: list[dict], skip_steps: list[str],
|
|
231
339
|
parallel: bool = False, workers: int = 4) -> list[dict]:
|
|
232
340
|
total = len(documents)
|
|
@@ -390,21 +498,28 @@ def main(argv=None) -> int:
|
|
|
390
498
|
print(" Note: discovery source is not object storage; skipping the S3 Storage "
|
|
391
499
|
"step (use --keep s3_storage to force it).")
|
|
392
500
|
|
|
501
|
+
import time as _t
|
|
502
|
+
_run_started = _t.perf_counter()
|
|
393
503
|
results = validate_documents(settings, documents, skip_steps=skip_steps,
|
|
394
504
|
parallel=args.parallel, workers=args.workers)
|
|
505
|
+
wall_seconds = _t.perf_counter() - _run_started
|
|
506
|
+
concurrency = args.workers if args.parallel and len(documents) > 1 else 1
|
|
395
507
|
|
|
396
508
|
# Corpus-level knowledge-base readiness (completeness, dedup, coverage).
|
|
397
509
|
from ingestion_validation.corpus import analyze_corpus
|
|
398
510
|
|
|
511
|
+
expected_documents = _resolve_expected_documents(
|
|
512
|
+
settings.discovery.extra.get("expected_documents"), args.project)
|
|
399
513
|
corpus = analyze_corpus(
|
|
400
514
|
documents, results,
|
|
401
|
-
expected_documents=
|
|
515
|
+
expected_documents=expected_documents,
|
|
402
516
|
expected_folders=settings.discovery.extra.get("expected_folders"),
|
|
403
517
|
expected_document_names=settings.discovery.extra.get("expected_document_names"),
|
|
404
518
|
readiness_threshold=settings.pipeline.readiness_threshold,
|
|
405
519
|
)
|
|
406
520
|
print(f" KB readiness: {corpus['verdict']} ({corpus['passed']}/{corpus['total']} passed, "
|
|
407
521
|
f"{corpus['total'] - corpus['passed']} failed)")
|
|
522
|
+
_warn_on_unreachable_backends(results)
|
|
408
523
|
|
|
409
524
|
# Maintain historical trends (stdlib sqlite; on by default) and embed them in the report.
|
|
410
525
|
trend_rows = _record_trends(results, args.project, args.env, run_id, "Discovery", disabled=args.no_trends)
|
|
@@ -413,14 +528,42 @@ def main(argv=None) -> int:
|
|
|
413
528
|
failed = len(results) - passed
|
|
414
529
|
base = f"reports/{args.project}-discovery-report"
|
|
415
530
|
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)}")
|
|
531
|
+
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
532
|
if args.output_format in ("json", "all"):
|
|
418
533
|
print(f" JSON report: {export_json(results, base + '.json')}")
|
|
419
534
|
if args.output_format in ("junit", "all"):
|
|
420
535
|
print(f" JUnit report: {export_junit(results, base + '.xml')}")
|
|
421
536
|
|
|
537
|
+
if args.suggest_config:
|
|
538
|
+
from ingestion_validation.profiler import suggest_config_from_run
|
|
539
|
+
snippet = suggest_config_from_run(results, documents)
|
|
540
|
+
if args.suggest_config == "-":
|
|
541
|
+
print("\n" + snippet)
|
|
542
|
+
else:
|
|
543
|
+
from pathlib import Path as _P
|
|
544
|
+
_P(args.suggest_config).write_text(snippet, encoding="utf-8")
|
|
545
|
+
print(f" Config suggestions written: {args.suggest_config} (review before merging)")
|
|
546
|
+
|
|
547
|
+
# KB Hygiene: data-plane cleanliness (advisory checks), separate from the integrity
|
|
548
|
+
# gate. Printed for every run; gates the build only when --quality-threshold is set.
|
|
549
|
+
# Best-effort context - a failure to score it must not crash the gate decision.
|
|
550
|
+
from ingestion_validation.report import hygiene_from_results
|
|
551
|
+
try:
|
|
552
|
+
hygiene = hygiene_from_results(results)
|
|
553
|
+
except Exception: # noqa: BLE001 - hygiene is advisory; never break the run over it
|
|
554
|
+
hygiene = None
|
|
555
|
+
if hygiene:
|
|
556
|
+
worst = hygiene.get("worst")
|
|
557
|
+
worst_note = (f" | worst: {worst['name']} {worst['rate']:g}%"
|
|
558
|
+
if worst and worst["rate"] < 100 else "")
|
|
559
|
+
print(f" KB hygiene: {hygiene['score']:g}% {hygiene['tier']} "
|
|
560
|
+
f"({hygiene['advisory_passed']}/{hygiene['advisory_total']} advisory checks){worst_note}")
|
|
561
|
+
hygiene_score = hygiene["score"] if hygiene else None
|
|
562
|
+
|
|
422
563
|
failed_gate, reason = gate_failed(passed, len(results), corpus["verdict"],
|
|
423
|
-
args.fail_under, args.require_ready
|
|
564
|
+
args.fail_under, args.require_ready,
|
|
565
|
+
quality_threshold=args.quality_threshold,
|
|
566
|
+
hygiene_score=hygiene_score)
|
|
424
567
|
|
|
425
568
|
# Baselines + drift: snapshot a known-good run and/or diff against one.
|
|
426
569
|
regression = False
|
|
@@ -438,6 +581,8 @@ def main(argv=None) -> int:
|
|
|
438
581
|
reason = f"{len(documents)} documents < s3.min_documents ({min_docs})"
|
|
439
582
|
failed_gate = True
|
|
440
583
|
code = EXIT_GATE_FAILED if (failed_gate or (regression and args.fail_on_regression)) else EXIT_OK
|
|
584
|
+
if not args.no_tips:
|
|
585
|
+
_print_tips(args, hygiene, saved_baseline=args.save_baseline is not None or bool(args.against))
|
|
441
586
|
print("=" * 60)
|
|
442
587
|
print(f" Results: {passed} passed, {failed} failed ({len(results)} total)")
|
|
443
588
|
print(f" Gate: {'FAILED' if failed_gate else 'PASSED'} - {reason}")
|
{compass_kb_validation-2.1.0 → compass_kb_validation-2.3.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.3.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"]:
|
{compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/profiler.py
RENAMED
|
@@ -537,7 +537,79 @@ def apply_profile_to_settings(settings: Any, profiles: dict) -> list[str]:
|
|
|
537
537
|
return applied
|
|
538
538
|
|
|
539
539
|
|
|
540
|
+
def suggest_config_from_run(results: list, documents: list) -> str:
|
|
541
|
+
"""Suggest config refinements from what a validation RUN actually observed.
|
|
542
|
+
|
|
543
|
+
Complements ``emit_project_config`` (pre-run backend introspection) with the things
|
|
544
|
+
only a run reveals: the real document count, the observed chunk-size distribution, and
|
|
545
|
+
the checks that were NEUTRAL on every document - checks that never apply to this KB and
|
|
546
|
+
are pure noise (e.g. section_metadata when nothing carries sections). Returns a review-
|
|
547
|
+
and-merge snippet, never a file to apply blind - a wrong config key would be exactly the
|
|
548
|
+
silent-failure trap COMPASS exists to catch, so these are suggestions with the evidence.
|
|
549
|
+
"""
|
|
550
|
+
import statistics
|
|
551
|
+
|
|
552
|
+
n_docs = len(documents)
|
|
553
|
+
avg_chars: list[int] = []
|
|
554
|
+
# label -> [evaluated_count, neutral_count]; a check neutral everywhere never applies.
|
|
555
|
+
support: dict[str, list[int]] = {}
|
|
556
|
+
for item in results:
|
|
557
|
+
for st in getattr(item.get("result"), "steps", []) or []:
|
|
558
|
+
details = getattr(st, "details", {}) or {}
|
|
559
|
+
cv = details.get("chunk_view")
|
|
560
|
+
if isinstance(cv, dict) and cv.get("avg_chars"):
|
|
561
|
+
avg_chars.append(int(cv["avg_chars"]))
|
|
562
|
+
status = getattr(getattr(st, "status", None), "value", "")
|
|
563
|
+
if status == "SKIPPED":
|
|
564
|
+
continue # a step that did not run says nothing about applicability
|
|
565
|
+
for ch in getattr(st, "checks", []) or []:
|
|
566
|
+
lbl = ch.get("label", "")
|
|
567
|
+
if not lbl:
|
|
568
|
+
continue
|
|
569
|
+
rec = support.setdefault(lbl, [0, 0])
|
|
570
|
+
if ch.get("supported", True) is False:
|
|
571
|
+
rec[1] += 1
|
|
572
|
+
else:
|
|
573
|
+
rec[0] += 1
|
|
574
|
+
|
|
575
|
+
never_applies = sorted(lbl for lbl, (ev, neu) in support.items() if ev == 0 and neu > 0)
|
|
576
|
+
|
|
577
|
+
lines = [
|
|
578
|
+
"# --- compass discover --suggest-config -------------------------------------",
|
|
579
|
+
"# Refinements from THIS run's observations. Review each, then merge into your",
|
|
580
|
+
"# project YAML - nothing here is applied automatically.",
|
|
581
|
+
"",
|
|
582
|
+
"# Documents discovered this run. Pin it to alert on drift (or use 'auto' to",
|
|
583
|
+
"# baseline against the previous run automatically):",
|
|
584
|
+
"discovery:",
|
|
585
|
+
" extra:",
|
|
586
|
+
f" expected_documents: {n_docs}",
|
|
587
|
+
]
|
|
588
|
+
if avg_chars:
|
|
589
|
+
median_size = int(statistics.median(avg_chars))
|
|
590
|
+
lines += [
|
|
591
|
+
"",
|
|
592
|
+
f"# Observed chunk size: median {median_size} chars across {len(avg_chars)} document(s)",
|
|
593
|
+
f"# (range {min(avg_chars)}-{max(avg_chars)}). Set expected bounds if you gate on size:",
|
|
594
|
+
"# redis:",
|
|
595
|
+
f"# chunk_size: {median_size}",
|
|
596
|
+
]
|
|
597
|
+
if never_applies:
|
|
598
|
+
lines += [
|
|
599
|
+
"",
|
|
600
|
+
"# These checks were NOT APPLICABLE to any document this run (neutral every time) -",
|
|
601
|
+
"# the backend/data cannot answer them. Disabling removes them from the tally and",
|
|
602
|
+
"# the report noise. Verify each is genuinely irrelevant before disabling:",
|
|
603
|
+
]
|
|
604
|
+
for lbl in never_applies:
|
|
605
|
+
lines.append(f"# - {lbl}")
|
|
606
|
+
else:
|
|
607
|
+
lines += ["", "# Every check applied to at least one document - nothing to disable."]
|
|
608
|
+
return "\n".join(lines) + "\n"
|
|
609
|
+
|
|
610
|
+
|
|
540
611
|
__all__ = [
|
|
541
612
|
"profile_vectorstore", "profile_storage", "profile_graph", "profile_kb",
|
|
542
|
-
"apply_profile_to_settings", "suggest_config",
|
|
613
|
+
"apply_profile_to_settings", "suggest_config", "emit_project_config",
|
|
614
|
+
"suggest_config_from_run",
|
|
543
615
|
]
|