Files
cluster-knaster/core/src/knaster/ontology/model.py
T
rorappandClaude Opus 5.5 9442cc49d1
ci / test (push) Successful in 27s
ontology: Ontologie als Quelle im Repo, Abgleich mit n8n/KNOW in der Service-Routine
- ontology/know.yaml: 49 Klassen, 66 Beziehungen, 12 Vokabulare (72 Konzepte);
  erzeugt: know.ttl (OWL + SKOS), Ansicht aus viewer.html.tmpl
- knaster.ontology: Modell + Prüfung, Export, Momentaufnahme der n8n-Workflows
  (nur GET; KNOW-Spezifikation aus dem Workflow-Code, Aufrufe inkl. Laufzeit-
  Operationen, direktes SQL), Abgleich, Übernahme von Entscheidungen
- Service-Routine Schritt 8: Verstöße als Befunde, Ontologie-Änderungen als
  Vorschläge ontology_change (immer Freigabe); „ordne P-x <konzept> zu“;
  Entscheidungen in ontology_decisions, `knaster ontology apply` schreibt sie
  in die YAML (Version +1)
- CLI: knaster ontology validate|export|snapshot|check|apply; knaster validate
  prüft Modell und Aktualität der Turtle-Datei
- Gegen echten n8n-Export geprüft: neue Befunde B31–B33 (verified/confirmed,
  KAN ruft unbekannte Operation, MPA ordnet Projektereignisse nie ein)
- SPEC-ontology, BETRIEB §6, Router-Image enthält die Ontologie

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:54:49 +02:00

149 lines
6.3 KiB
Python

"""Ontologie als Quelle der Wahrheit: Kern (Klassen, Beziehungen), Vokabulare, Abbildung auf Tabellen.
Drei Ebenen mit unterschiedlicher Pflege (SPEC-ontology §1):
- Kern: bewusst gepflegt, Änderungen per Commit.
- Vokabulare: wachsen über Vorschläge aus der Service-Routine (beobachtet → vorgeschlagen → bestätigt).
- Instanzen: leben in den Daten, nicht in dieser Datei.
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, Field
from ..util import load_yaml
RelationKind = Literal["key", "soft", "external", "subclass"]
class OntologyClass(BaseModel):
label: str
module: str
definition: str = ""
table: str | None = None # None = implizit (nur als Wert in Spalten) oder externes System
key: str = ""
sensitive: str = ""
abstract: bool = False
subclass_of: str | None = None
attributes: dict[str, str] = Field(default_factory=dict) # Spalte → SQL-Typ (Tabellenklassen)
class Relation(BaseModel):
source: str
name: str
target: str
via: str = ""
kind: RelationKind = "key"
class Concept(BaseModel):
label: str = ""
alt: list[str] = Field(default_factory=list) # weitere Schreibweisen, die auf dieses Konzept zeigen
note: str = ""
technical: bool = False # kein fachlicher Wert (z. B. Rückfall „default“); zählt nicht als „fehlt“
class Vocabulary(BaseModel):
label: str
class_: str | None = Field(default=None, alias="class")
columns: list[str] = Field(default_factory=list) # "tabelle.spalte", in denen Werte dieses Vokabulars stehen
concepts: dict[str, Concept] = Field(default_factory=dict)
rejected: list[str] = Field(default_factory=list) # bewusst verworfene Werte (kein Konzept)
model_config = {"populate_by_name": True}
def resolve(self, value: str) -> str | None:
"""Wert → Konzept-ID (über ID, Label oder alternative Schreibweise), sonst None."""
v = value.strip().lower()
for cid, c in self.concepts.items():
if v == cid.lower() or v == c.label.lower() or v in (a.lower() for a in c.alt):
return cid
return None
def is_rejected(self, value: str) -> bool:
return value.strip().lower() in (r.lower() for r in self.rejected)
class Ontology(BaseModel):
id: str = "know"
label: str = ""
namespace: str = "urn:example:onto#"
version: int = 1
modules: dict[str, str] = Field(default_factory=dict)
classes: dict[str, OntologyClass] = Field(default_factory=dict)
relations: list[Relation] = Field(default_factory=list)
vocabularies: dict[str, Vocabulary] = Field(default_factory=dict)
ignore: list[str] = Field(default_factory=list) # bewusst hingenommene Befunde (Schlüssel aus `check`)
@classmethod
def from_file(cls, path: str | Path) -> Ontology:
return cls(**load_yaml(path))
def save(self, path: str | Path, header: str = "") -> None:
data = {"id": self.id, "version": self.version, **self.model_dump(by_alias=True, exclude_defaults=True)}
text = yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=120)
Path(path).write_text(header + text, encoding="utf-8")
# --- Abfragen ---------------------------------------------------------------------------
def class_for_table(self, table: str) -> str | None:
return next((cid for cid, c in self.classes.items() if c.table == table), None)
def tables(self) -> dict[str, str]:
return {c.table: cid for cid, c in self.classes.items() if c.table}
def vocabularies_for(self, table: str, column: str) -> list[str]:
ref = f"{table}.{column}"
return [vid for vid, v in self.vocabularies.items() if ref in v.columns]
def vocab_columns(self) -> dict[str, list[str]]:
out: dict[str, list[str]] = {}
for vid, v in self.vocabularies.items():
for ref in v.columns:
out.setdefault(ref, []).append(vid)
return out
# --- Prüfung ----------------------------------------------------------------------------
def validate_model(self) -> list[str]:
errors: list[str] = []
for cid, c in self.classes.items():
if c.module not in self.modules:
errors.append(f"Klasse {cid}: Modul {c.module} unbekannt")
if c.subclass_of and c.subclass_of not in self.classes:
errors.append(f"Klasse {cid}: Oberklasse {c.subclass_of} unbekannt")
if c.attributes and not c.table:
errors.append(f"Klasse {cid}: Attribute ohne Tabelle")
seen_tables: dict[str, str] = {}
for cid, c in self.classes.items():
if c.table:
if c.table in seen_tables:
errors.append(f"Tabelle {c.table} ist {seen_tables[c.table]} und {cid} zugeordnet")
seen_tables[c.table] = cid
for r in self.relations:
for end in (r.source, r.target):
if end not in self.classes:
errors.append(f"Beziehung {r.source} {r.name} {r.target}: Klasse {end} unbekannt")
for vid, v in self.vocabularies.items():
if v.class_ and v.class_ not in self.classes:
errors.append(f"Vokabular {vid}: Klasse {v.class_} unbekannt")
for ref in v.columns:
table, _, column = ref.partition(".")
owner = self.class_for_table(table)
if owner is None:
errors.append(f"Vokabular {vid}: Tabelle {table} keiner Klasse zugeordnet")
elif column not in self.classes[owner].attributes:
errors.append(f"Vokabular {vid}: Spalte {ref} fehlt in Klasse {owner}")
labels: dict[str, str] = {}
for cid, c in v.concepts.items():
for name in [cid, c.label, *c.alt]:
key = name.strip().lower()
if key and key in labels and labels[key] != cid:
errors.append(f"Vokabular {vid}: „{name}“ zeigt auf {labels[key]} und {cid}")
labels.setdefault(key, cid)
for r in v.rejected:
if v.resolve(r):
errors.append(f"Vokabular {vid}: „{r}“ ist zugleich verworfen und Konzept {v.resolve(r)}")
return errors