tool: przenieś check_okf.py walidator OKF z sesji Etapu 1
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7AnxL6pbgySpEgcCwAfww
This commit is contained in:
parent
0a5459b6ab
commit
9a383f0a8b
314
check_okf.py
Normal file
314
check_okf.py
Normal file
|
|
@ -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())
|
||||
Loading…
Reference in a new issue