ci / test (push) Successful in 27s
- 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>
149 lines
6.3 KiB
Python
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
|