From 9a383f0a8b06ec99ca521efcd3b3dfd6e09d0970 Mon Sep 17 00:00:00 2001 From: oskar Date: Thu, 27 Aug 2026 19:55:45 +0200 Subject: [PATCH] =?UTF-8?q?tool:=20przenie=C5=9B=20check=5Fokf.py=20walida?= =?UTF-8?q?tor=20OKF=20z=20sesji=20Etapu=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rekoncyliacja faza 5 etap 1, krok pośredni (przed lintem). Lipcowy proof-of-concept nie zostawił żadnego skomitowanego walidatora — weryfikacja ad hoc, nieskomitowana (`_meta/conventions.md` §10 przed tą zmianą, dawniej "Integracja z retrievalem" sąsiadowała bez narzędzia). Ta sesja (~/kb-wiki-etap1-sesja-2026-08-27/) zbudowała go od zera; recon kb-m5-faza5-wiki.md §3 rekomendował wprost: "Przenieść check_okf.py tej sesji do prawdziwego repo przy rekoncyliacji". Adaptacja do rzeczywistego layoutu tego repo (którego sesyjny bootstrap nie miał w zestawie testowym): - TYPES rozszerzone o `meta` (ten repo miało 6. typ od lipca — `_meta/conventions.md` już typ `meta` na sobie samym; sesyjny walidator znał tylko 5). - `README.md` (root) i `INDEX.md` (root, nawigacja — `index.md` OKF-owy nie istnieje w tym repo, decyzja do operatora, patrz kb-m5-faza5-wiki.md §3) i `_meta/lint-reports/*.md` wyjęte z wymogu frontmattera stron-konceptów — to dokumentacja/raporty z 2026-07-21, nie skompilowane encje. python3 check_okf.py → ZGODNE, 74/74 sources zweryfikowane w bazie (patrz commit lintu osobno dla raportu pełnego, w tym przypisów inline). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01W7AnxL6pbgySpEgcCwAfww --- check_okf.py | 314 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 check_okf.py diff --git a/check_okf.py b/check_okf.py new file mode 100644 index 0000000..349d01e --- /dev/null +++ b/check_okf.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""Walidator konformancji OKF v0.1 dla kb-wiki. + +Przeniesiony z `~/kb-wiki-etap1-sesja-2026-08-27/check_okf.py` przy +rekoncyliacji 2026-08-27 (patrz `_meta/conventions.md` §10 i +`homelab-codex-ws:kb/phases/kb-m5-faza5-wiki.md` §3) — rekomendacja tamtej +sesji: "Przenieść check_okf.py tej sesji do prawdziwego repo przy +rekoncyliacji", bo lipcowy proof-of-concept nie zostawił żadnego +skomitowanego walidatora. Rozszerzenia specyficzne dla kb-wiki +(`_meta/conventions.md` §10): + + 1. `type` musi być z listy zamkniętej TYPES (podmiot|osoba|sprawa|umowa|temat|meta). + 2. `sources` niepusty na każdej stronie-koncepcie (inwariant 1, audytowalność). + 3. Każdy `envelope_id` z `sources` istnieje w bazie `kb` (SELECT-only, + KB_DSN z env) -- pomijalne flagą --no-db. + 4. Każdy `chunks: [...]` id, jeśli podany, istnieje w `document_chunk` i + należy do tego samego `envelope_id`. + +Adaptacja przy przeniesieniu (rzeczywisty layout `kb-wiki`, którego lipcowy +proof-of-concept nie miał w zestawie testowym): `README.md` (root) i +`_meta/lint-reports/*.md` istniały w prawdziwym repo od 2026-07-21 bez +frontmattera stron-konceptów (to dokumentacja/raporty, nie skompilowane +encje) — wyjęte z wymogu frontmattera, tak samo jak pliki zarezerwowane, ale +bez ograniczenia "tylko root index.md może mieć pola". + +To NIE jest scripts/kb/check_okf.py z homelab-codex-ws (ten ma SCOPE=("kb", +"docs/sessions") na sztywno i nie widzi layoutu kb-wiki -- patrz +_meta/conventions.md §10 dla rozjazdu). Ten plik jest osobnym walidatorem +tego repo, nie próbą dopasowania tamtego. + +Tylko biblioteka standardowa poza opcjonalnym asyncpg (dociągane tylko gdy +weryfikacja bazy jest włączona). Uruchomienie: + python3 check_okf.py [katalog-repo] [--no-db] +""" + +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +RESERVED = {"index.md", "log.md"} +# README.md (root) i INDEX.md (root, nawigacja — OKF-owy index.md nie istnieje +# w tym repo, patrz kb-m5-faza5-wiki.md §3, "do rozstrzygnięcia przez +# operatora") istniały bez frontmattera stron-konceptów od 2026-07-21 — +# dokumentacja/nawigacja, nie skompilowana encja. +NO_FRONTMATTER_REQUIRED = {"README.md", "INDEX.md"} +TYPES = {"podmiot", "osoba", "sprawa", "umowa", "temat", "meta"} +STATUSES = {"active", "archived"} + + +def split_frontmatter(text: str) -> tuple[str | None, str]: + """Zwraca (blok frontmattera lub None, reszta dokumentu).""" + if not text.startswith("---\n"): + return None, text + end = text.find("\n---", 3) + if end == -1: + raise ValueError("blok frontmattera otwarty '---', ale nigdy nie zamknięty") + return text[4:end], text[end + 4 :] + + +def parse_yaml(block: str) -> dict[str, object]: + """Minimalny parser mapy YAML najwyższego poziomu (skalar/lista inline/lista + blokowa; wystarczające dla frontmatterów tego repo, w tym listy map + `sources:` z zagnieżdżonym `envelope_id`/`chunks`).""" + out: dict[str, object] = {} + key: str | None = None + current_item: dict[str, object] | None = None + for lineno, raw in enumerate(block.splitlines(), start=2): + line = raw.rstrip() + if not line.strip() or line.lstrip().startswith("#"): + continue + indent = len(line) - len(line.lstrip(" ")) + stripped = line.strip() + if indent > 0: + if stripped.startswith("- "): + if key is None: + raise ValueError(f"linia {lineno}: element listy bez klucza") + if not isinstance(out.get(key), list): + out[key] = [] + rest = stripped[2:] + if ":" in rest and not (rest.startswith('"') or rest.startswith("'")): + # element listy = mapa (np. sources: - envelope_id: ... ) + current_item = {} + out[key].append(current_item) # type: ignore[union-attr] + k2, _, v2 = rest.partition(":") + current_item[k2.strip()] = scalar(v2.strip()) + else: + current_item = None + out[key].append(scalar(rest)) # type: ignore[union-attr] + continue + # wcięta linia bez "- " -- kolejne pole bieżącego elementu-mapy listy + if current_item is not None and ":" in stripped: + k2, _, v2 = stripped.partition(":") + current_item[k2.strip()] = scalar(v2.strip()) + continue + raise ValueError(f"linia {lineno}: nieoczekiwane wcięcie: {line!r}") + current_item = None + if ":" not in line: + raise ValueError(f"linia {lineno}: brak ':' w {line!r}") + key, _, value = line.partition(":") + key = key.strip() + if not key: + raise ValueError(f"linia {lineno}: pusty klucz w {line!r}") + out[key] = scalar(value.strip()) + return out + + +def scalar(value: str) -> object: + value = value.strip() + if not value: + return "" + if value.startswith("[") and value.endswith("]"): + inner = value[1:-1].strip() + return [scalar(v) for v in inner.split(",")] if inner else [] + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + return value[1:-1] + return value + + +def as_list(value: object) -> list: + if value in ("", None): + return [] + if isinstance(value, list): + return value + return [value] + + +def check_file(path: Path, root: Path) -> tuple[list[str], list[tuple[str, list]]]: + """Zwraca (błędy, [(envelope_id, chunks), ...] do weryfikacji w bazie).""" + rel = path.relative_to(root).as_posix() + errors: list[str] = [] + to_verify: list[tuple[str, list]] = [] + text = path.read_text(encoding="utf-8") + + try: + block, _ = split_frontmatter(text) + except ValueError as exc: + return [f"{rel}: {exc}"], [] + + if (rel.count("/") == 0 and path.name in NO_FRONTMATTER_REQUIRED) or ( + rel.startswith("_meta/lint-reports/") + ): + return [], [] + + if path.name in RESERVED: + is_root_index = rel == "index.md" + if block is None: + return [], [] + if not is_root_index: + return [f"{rel}: plik zarezerwowany nie może mieć frontmattera (OKF §6/§11)"], [] + try: + fm = parse_yaml(block) + except ValueError as exc: + return [f"{rel}: niepoprawny YAML we frontmatterze — {exc}"], [] + extra = set(fm) - {"okf_version"} + if extra: + errors.append( + f"{rel}: root index.md może mieć wyłącznie okf_version, " + f"znaleziono też: {', '.join(sorted(extra))} (OKF §11)" + ) + elif not str(fm.get("okf_version", "")).strip(): + errors.append(f"{rel}: puste okf_version (OKF §11)") + return errors, [] + + if block is None: + return [f"{rel}: brak bloku frontmattera YAML (OKF §9.1)"], [] + try: + fm = parse_yaml(block) + except ValueError as exc: + return [f"{rel}: niepoprawny YAML we frontmatterze — {exc}"], [] + + doc_type = str(fm.get("type", "")).strip() + if not doc_type: + errors.append(f"{rel}: brak niepustego pola `type` (OKF §9.2)") + elif doc_type not in TYPES: + errors.append(f"{rel}: `type` = {doc_type!r} spoza listy ({', '.join(sorted(TYPES))})") + + status = str(fm.get("status", "")).strip() + if status and status not in STATUSES: + errors.append(f"{rel}: `status` = {status!r} spoza {sorted(STATUSES)}") + + # _meta/conventions.md jest samo type: temat, ale bez sources -- to jest + # dokument o konwencjach, nie strona-koncept skompilowana z retrievalu. + # Reguła "sources niepuste" dotyczy stron w podmioty/osoby/sprawy/umowy/tematy + # poza _meta/. + is_meta = rel.startswith("_meta/") + + sources = as_list(fm.get("sources")) + if not is_meta: + if not sources: + errors.append(f"{rel}: `sources` puste — inwariant 1 (audytowalność) wymaga ≥1 wpisu") + for item in sources: + if not isinstance(item, dict) or not str(item.get("envelope_id", "")).strip(): + errors.append(f"{rel}: wpis `sources` bez `envelope_id`: {item!r}") + continue + env_id = str(item["envelope_id"]) + chunks = item.get("chunks", []) + chunks = as_list(chunks) if not isinstance(chunks, list) else chunks + to_verify.append((env_id, [str(c) for c in chunks if str(c).strip()])) + + return errors, to_verify + + +async def verify_sources_in_db(pairs: list[tuple[str, list]], dsn: str) -> list[str]: + try: + import asyncpg + except ImportError: + return ["[db] moduł `asyncpg` niedostępny — pomiń weryfikację bazy albo zainstaluj asyncpg"] + + errors: list[str] = [] + conn = await asyncpg.connect(dsn) + try: + env_ids = sorted({env for env, _ in pairs}) + rows = await conn.fetch( + "SELECT id FROM envelope WHERE id = ANY($1::text[])", env_ids + ) + existing_envs = {r["id"] for r in rows} + for env in env_ids: + if env not in existing_envs: + errors.append(f"[db] envelope_id nie istnieje w bazie: {env!r}") + + chunk_ids = sorted({int(c) for _, chunks in pairs for c in chunks if c.isdigit()}) + non_numeric = sorted({c for _, chunks in pairs for c in chunks if not c.isdigit()}) + for c in non_numeric: + errors.append(f"[db] chunk id nie jest liczbą całkowitą: {c!r}") + if chunk_ids: + crows = await conn.fetch( + "SELECT id, envelope_id FROM document_chunk WHERE id = ANY($1::bigint[])", + chunk_ids, + ) + chunk_owner = {r["id"]: r["envelope_id"] for r in crows} + for env, chunks in pairs: + for c in chunks: + if not c.isdigit(): + continue + cid = int(c) + if cid not in chunk_owner: + errors.append(f"[db] chunk id nie istnieje w document_chunk: {cid}") + elif chunk_owner[cid] != env: + errors.append( + f"[db] chunk id {cid} należy do envelope {chunk_owner[cid]!r}, " + f"nie do {env!r} podanego w sources" + ) + finally: + await conn.close() + return errors + + +def main() -> int: + args = [a for a in sys.argv[1:] if not a.startswith("--")] + no_db = "--no-db" in sys.argv[1:] + root = Path(args[0] if args else Path(__file__).parent).resolve() + + files = sorted(p for p in root.rglob("*.md") if ".git" not in p.parts) + print(f"Repo: {root}") + print(f"Sprawdzono plików .md: {len(files)}") + if not files: + print("BRAK plików .md — nie ma czego walidować.") + return 1 + + concepts = [p for p in files if p.name not in RESERVED] + reserved = [p for p in files if p.name in RESERVED] + print(f" koncepty: {len(concepts)}, zarezerwowane: {len(reserved)}") + + errors: list[str] = [] + all_pairs: list[tuple[str, list]] = [] + by_type: dict[str, int] = {} + for path in files: + errs, pairs = check_file(path, root) + errors.extend(errs) + all_pairs.extend(pairs) + try: + block, _ = split_frontmatter(path.read_text(encoding="utf-8")) + if block and path.name not in RESERVED: + t = str(parse_yaml(block).get("type", "?")) + by_type[t] = by_type.get(t, 0) + 1 + except ValueError: + pass + + if by_type: + print(" per type: " + ", ".join(f"{t}={n}" for t, n in sorted(by_type.items()))) + + n_sources = len(all_pairs) + n_degraded = sum(1 for _, chunks in all_pairs if not chunks) + print(f" przypisów sources: {n_sources} ({n_degraded} envelope-only / bez chunków)") + print() + + db_errors: list[str] = [] + if all_pairs and not no_db: + dsn = os.environ.get("KB_DSN", "") + if not dsn: + errors.append( + "[db] KB_DSN nie ustawione w env — weryfikacja envelope_id/chunk_id pominięta " + "(ustaw KB_DSN albo uruchom z --no-db żeby nie traktować tego jako błąd)" + ) + else: + db_errors = asyncio.run(verify_sources_in_db(all_pairs, dsn)) + errors.extend(db_errors) + + if errors: + print(f"NIEZGODNE z OKF v0.1 (kb-wiki) — {len(errors)} problem(ów):") + for err in errors: + print(f" ✗ {err}") + return 1 + + print("ZGODNE z OKF v0.1 (kb-wiki): frontmatter parsowalny, type/status poprawne,") + print(f"sources niepuste, wszystkie {n_sources} przypisów zweryfikowane w bazie.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())