ontology: Ontologie als Quelle im Repo, Abgleich mit n8n/KNOW in der Service-Routine
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>
This commit is contained in:
2026-10-13 02:54:49 +02:00
co-authored by Claude Opus 5.5
parent 525b29aa6d
commit 9442cc49d1
29 changed files with 3675 additions and 96 deletions
+1 -1
View File
@@ -22,6 +22,6 @@ PROXMOX_TOKEN_ID=knaster@pve!healer
PROXMOX_TOKEN_SECRET= PROXMOX_TOKEN_SECRET=
NOTIFY_WEBHOOK_URL= NOTIFY_WEBHOOK_URL=
# n8n (nur für Import/Tests) # n8n: Import/Tests und Ontologie-Abgleich (Router liest Workflows nur, GET /api/v1/workflows)
N8N_HOST= N8N_HOST=
N8N_API_KEY= N8N_API_KEY=
+1 -1
View File
@@ -13,7 +13,7 @@ test:
lint: lint:
$(VENV)/bin/ruff check core $(VENV)/bin/ruff check core
$(VENV)/bin/yamllint -s -c .yamllint.yml config catalog sites deploy/prometheus deploy/alertmanager deploy/litellm deploy/compose platform $(VENV)/bin/yamllint -s -c .yamllint.yml config catalog sites deploy/prometheus deploy/alertmanager deploy/litellm deploy/compose platform ontology
validate: validate:
$(VENV)/bin/knaster validate --root . $(VENV)/bin/knaster validate --root .
+1
View File
@@ -20,6 +20,7 @@ Anfrage → n8n → Router-Service ──▶ Stufe 3 Embedding-Classifier (CPU)
| [docs/spec/](docs/spec/) | Spezifikationen der Komponenten | | [docs/spec/](docs/spec/) | Spezifikationen der Komponenten |
| [docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md](docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md) | **Aufbauplan auf echter Hardware**: Phasen, Entscheidungen D1–D5, Zuordnung zu Planner Epic 7 | | [docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md](docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md) | **Aufbauplan auf echter Hardware**: Phasen, Entscheidungen D1–D5, Zuordnung zu Planner Epic 7 |
| [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md) | Arbeitspakete, Teststrategie, Stand der Verifikation | | [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md) | Arbeitspakete, Teststrategie, Stand der Verifikation |
| [ontology/know.yaml](ontology/know.yaml) | **Ontologie der Datenhaltung** (Quelle); Pflege und Abgleich in [SPEC-ontology](docs/spec/SPEC-ontology.md) |
| [docs/reference/KNOW-schema.md](docs/reference/KNOW-schema.md) | Bestehende n8n-Wissensdatenbank KNOW/KGW: Tabellen, wer schreibt und liest, Auffälligkeiten | | [docs/reference/KNOW-schema.md](docs/reference/KNOW-schema.md) | Bestehende n8n-Wissensdatenbank KNOW/KGW: Tabellen, wer schreibt und liest, Auffälligkeiten |
| [docs/BETRIEB.md](docs/BETRIEB.md) | Skill-Lebenszyklus, Rolle von Hermes, Pflege per Prompt, Dry-Run aufheben, Rollback | | [docs/BETRIEB.md](docs/BETRIEB.md) | Skill-Lebenszyklus, Rolle von Hermes, Pflege per Prompt, Dry-Run aufheben, Rollback |
| [n8n/README.md](n8n/README.md) | Workflows und Import | | [n8n/README.md](n8n/README.md) | Workflows und Import |
+1
View File
@@ -18,3 +18,4 @@ actions:
promote_candidate: {mode: approve, risk: high} promote_candidate: {mode: approve, risk: high}
rollback_candidate: {mode: approve, risk: medium} rollback_candidate: {mode: approve, risk: medium}
reboot_host: {mode: approve, risk: high, window: "02:00-05:00"} reboot_host: {mode: approve, risk: high, window: "02:00-05:00"}
ontology_change: {mode: approve, risk: low}
+107 -1
View File
@@ -1,4 +1,4 @@
"""Kommandozeile: serve, validate, inventory, placement, registry export.""" """Kommandozeile: serve, validate, inventory, placement, registry export, ontology."""
from __future__ import annotations from __future__ import annotations
@@ -47,8 +47,28 @@ def main(argv: list[str] | None = None) -> int:
reg_sub = p_reg.add_subparsers(dest="reg_cmd", required=True) reg_sub = p_reg.add_subparsers(dest="reg_cmd", required=True)
reg_sub.add_parser("export", help="effektiven Stand (inkl. Laufzeitzustand) als YAML ausgeben") reg_sub.add_parser("export", help="effektiven Stand (inkl. Laufzeitzustand) als YAML ausgeben")
p_onto = sub.add_parser("ontology", help="Ontologie der Datenhaltung (SPEC-ontology)")
p_onto.add_argument("--path", default=None, help="Ontologie-Datei (Standard: ontology/know.yaml)")
onto_sub = p_onto.add_subparsers(dest="onto_cmd", required=True)
onto_sub.add_parser("validate", help="Modell prüfen")
p_exp = onto_sub.add_parser("export", help="Turtle, Graph-JSON und/oder HTML-Ansicht erzeugen")
p_exp.add_argument("--ttl")
p_exp.add_argument("--graph")
p_exp.add_argument("--html")
p_exp.add_argument("--template", default=None, help="Standard: viewer.html.tmpl neben der Ontologie")
p_snap = onto_sub.add_parser("snapshot", help="n8n-Workflows lesen und als JSON speichern (nur GET)")
p_snap.add_argument("--out", required=True)
p_chk = onto_sub.add_parser("check", help="Ontologie mit der Datenhaltung in n8n abgleichen")
p_chk.add_argument("--snapshot", help="Workflows aus Datei statt aus der n8n-API")
p_chk.add_argument("--json", action="store_true")
p_app = onto_sub.add_parser("apply", help="freigegebene Entscheidungen in die Ontologie übernehmen")
p_app.add_argument("--dry-run", action="store_true")
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.cmd == "ontology":
return _ontology(args)
if args.cmd == "serve": if args.cmd == "serve":
import uvicorn import uvicorn
@@ -104,5 +124,91 @@ def main(argv: list[str] | None = None) -> int:
return 2 return 2
def _ontology(args: argparse.Namespace) -> int:
import json
from .ontology import (
Ontology,
apply_decisions,
build_snapshot,
check,
fetch_workflows,
to_graph,
to_html,
to_turtle,
)
from .settings import Settings
settings = Settings.from_env()
path = Path(args.path) if args.path else settings.ontology_path
onto = Ontology.from_file(path)
if args.onto_cmd == "validate":
errors = onto.validate_model()
for e in errors:
print(f"ERROR {e}")
print(f"{len(onto.classes)} Klassen, {len(onto.relations)} Beziehungen, {len(onto.vocabularies)} Vokabulare, "
f"{len(errors)} Fehler")
return 1 if errors else 0
if args.onto_cmd == "export":
if args.ttl:
Path(args.ttl).write_text(to_turtle(onto), encoding="utf-8")
if args.graph:
Path(args.graph).write_text(json.dumps(to_graph(onto), ensure_ascii=False, indent=1), encoding="utf-8")
if args.html:
template = Path(args.template) if args.template else path.parent / "viewer.html.tmpl"
Path(args.html).write_text(to_html(onto, template), encoding="utf-8")
return 0
if args.onto_cmd in {"snapshot", "check"}:
if getattr(args, "snapshot", None):
data = json.loads(Path(args.snapshot).read_text(encoding="utf-8"))
workflows = data.get("data", data) if isinstance(data, dict) else data
else:
if not (settings.n8n_host and settings.n8n_api_key):
print("N8N_HOST und N8N_API_KEY fehlen (oder --snapshot angeben)", file=sys.stderr)
return 2
workflows = fetch_workflows(settings.n8n_host, settings.n8n_api_key)
if args.onto_cmd == "snapshot":
Path(args.out).write_text(json.dumps({"data": workflows}, ensure_ascii=False), encoding="utf-8")
print(f"{len(workflows)} Workflows gespeichert in {args.out} (enthält Workflow-Code, nicht committen)")
return 0
from .store import Store
decided = {d["key"] for d in Store(settings.db_url).list_ontology_decisions()}
findings = check(onto, build_snapshot(workflows), decided=decided)
if args.json:
print(json.dumps([f.model_dump() for f in findings], ensure_ascii=False, indent=1))
else:
for f in findings:
mark = "→ Vorschlag" if f.change else ""
print(f"{f.severity.upper():7} {f.summary} {mark}".rstrip())
print(f"{len(findings)} Befunde, davon {sum(1 for f in findings if f.change)} Änderungsvorschläge")
return 1 if any(f.severity == "high" for f in findings) else 0
if args.onto_cmd == "apply":
from .store import Store
store = Store(settings.db_url)
pending = store.list_ontology_decisions(applied=False)
applied, messages = apply_decisions(onto, pending)
for m in messages:
print(m)
errors = onto.validate_model()
if errors:
print("Ontologie wäre ungültig, nichts gespeichert:\n" + "\n".join(errors), file=sys.stderr)
return 1
if applied and not args.dry_run:
header = "".join(line + "\n" for line in path.read_text(encoding="utf-8").splitlines()
if line.startswith("#"))
onto.save(path, header)
store.mark_ontology_applied(applied)
print(f"{len(applied)} Entscheidungen übernommen, Version {onto.version}. "
"Danach `knaster ontology export` und committen.")
return 0
return 2
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())
+11
View File
@@ -101,6 +101,17 @@ class ActionExecutor:
self.registry.set_calibrated_context(slot, value) self.registry.set_calibrated_context(slot, value)
return f"Slot {slot}: effektiver Kontext = {value} Tokens" return f"Slot {slot}: effektiver Kontext = {value} Tokens"
# --- Ontologie --------------------------------------------------------------------------
def _do_ontology_change(self, key: str, type: str, decision: str = "accept", decided_by: str = "human",
**params: Any) -> str:
if decision not in {"accept", "map"}:
raise ValueError(f"Entscheidung {decision} unbekannt")
if decision == "map" and not params.get("concept"):
raise ValueError("Zuordnung braucht ein Konzept")
self.store.set_ontology_decision(key, decision, {"type": type, **params}, decided_by)
target = f" → {params['concept']}" if decision == "map" else ""
return f"Entscheidung gespeichert ({decision}{target}); in die Ontologie mit `knaster ontology apply`"
# --- Lifecycle -------------------------------------------------------------------------- # --- Lifecycle --------------------------------------------------------------------------
def _do_create_candidate(self, kind: str, slot: str, target: dict[str, Any], **_: Any) -> str: def _do_create_candidate(self, kind: str, slot: str, target: dict[str, Any], **_: Any) -> str:
cid = self.lifecycle.create(kind, slot, target) # type: ignore[arg-type] cid = self.lifecycle.create(kind, slot, target) # type: ignore[arg-type]
+39 -2
View File
@@ -35,6 +35,7 @@ class MaintenanceConfig(BaseModel):
adapter_min_cases: int = 300 adapter_min_cases: int = 300
adapter_target_rate: float = 0.90 adapter_target_rate: float = 0.90
lookback_days: float = 7.0 lookback_days: float = 7.0
ontology_max_findings: int = 15
@classmethod @classmethod
def from_file(cls, path: str | Path) -> MaintenanceConfig: def from_file(cls, path: str | Path) -> MaintenanceConfig:
@@ -83,7 +84,8 @@ HELP = """Befehle:
- status · hilfe · routine · vorschläge - status · hilfe · routine · vorschläge
- genehmige P-3 [P-5 …] · genehmige alle mit risiko niedrig · lehne P-3 ab: <Grund> - genehmige P-3 [P-5 …] · genehmige alle mit risiko niedrig · lehne P-3 ab: <Grund>
- wechsle <slot> auf <basismodell> · trainiere adapter für <cluster> · rollback <slot> - wechsle <slot> auf <basismodell> · trainiere adapter für <cluster> · rollback <slot>
- stufe <skill> zurück · markiere <slot> als ok|degraded|unavailable""" - stufe <skill> zurück · markiere <slot> als ok|degraded|unavailable
- ordne P-7 <konzept> zu (Vokabel-Vorschlag einem vorhandenen Konzept zuordnen)"""
class LLMIntentMapper: class LLMIntentMapper:
@@ -120,6 +122,7 @@ COMMANDS: list[tuple[str, re.Pattern[str]]] = [
("rollback", r"^rollback\s+(?P<slot>[\w.-]+)$"), ("rollback", r"^rollback\s+(?P<slot>[\w.-]+)$"),
("demote", r"^(stufe\s+(?P<skill>[\w.-]+)\s+zurück|degradiere\s+(?P<skill2>[\w.-]+))$"), ("demote", r"^(stufe\s+(?P<skill>[\w.-]+)\s+zurück|degradiere\s+(?P<skill2>[\w.-]+))$"),
("mark", r"^markiere\s+(?P<slot>[\w.-]+)\s+(als\s+)?(?P<status>ok|degraded|unavailable)$"), ("mark", r"^markiere\s+(?P<slot>[\w.-]+)\s+(als\s+)?(?P<status>ok|degraded|unavailable)$"),
("map_concept", rf"^ordne\s+(?P<id>{_ID})\s+(dem\s+konzept\s+)?(?P<concept>[\w.-]+)\s+zu$"),
("confirm", r"^(ja|yes|j|ok)$"), ("confirm", r"^(ja|yes|j|ok)$"),
("cancel", r"^(nein|no|n|abbrechen)$"), ("cancel", r"^(nein|no|n|abbrechen)$"),
] ]
@@ -143,6 +146,7 @@ class MaintenanceAgent:
def __init__(self, *, store: Store, registry: Registry, library: SkillLibrary, index: SkillIndex, def __init__(self, *, store: Store, registry: Registry, library: SkillLibrary, index: SkillIndex,
policy: Policy, actions: ActionExecutor, lifecycle: Lifecycle, policy: Policy, actions: ActionExecutor, lifecycle: Lifecycle,
config: MaintenanceConfig | None = None, intent_mapper: IntentMapper | None = None, config: MaintenanceConfig | None = None, intent_mapper: IntentMapper | None = None,
ontology_check: Callable[[], list[Any]] | None = None,
clock: Callable[[], float] = now, wallclock: Callable[[], datetime] = datetime.now): clock: Callable[[], float] = now, wallclock: Callable[[], datetime] = datetime.now):
self.store = store self.store = store
self.registry = registry self.registry = registry
@@ -153,6 +157,7 @@ class MaintenanceAgent:
self.lifecycle = lifecycle self.lifecycle = lifecycle
self.config = config or MaintenanceConfig() self.config = config or MaintenanceConfig()
self.intent_mapper = intent_mapper self.intent_mapper = intent_mapper
self.ontology_check = ontology_check
self.clock = clock self.clock = clock
self.wallclock = wallclock self.wallclock = wallclock
self._pending: dict[str, str] = {} # Sitzung → zu bestätigender Befehl self._pending: dict[str, str] = {} # Sitzung → zu bestätigender Befehl
@@ -197,8 +202,9 @@ class MaintenanceAgent:
def _execute(self, seq: int) -> Proposal: def _execute(self, seq: int) -> Proposal:
p = self._load(seq) p = self._load(seq)
params = {**p.params, "decided_by": p.decided_by} if p.kind == "ontology_change" else p.params
try: try:
result = self.actions.execute(p.kind, p.params) result = self.actions.execute(p.kind, params)
self.store.update_proposal(seq, state="executed", result=result) self.store.update_proposal(seq, state="executed", result=result)
except ActionError as exc: except ActionError as exc:
self.store.update_proposal(seq, state="failed", result={"ok": False, "message": str(exc)}) self.store.update_proposal(seq, state="failed", result={"ok": False, "message": str(exc)})
@@ -212,6 +218,9 @@ class MaintenanceAgent:
result={"message": reason or "abgelehnt"}) result={"message": reason or "abgelehnt"})
if p.kind == "promote_candidate": if p.kind == "promote_candidate":
self.lifecycle.reject(p.params["candidate"], f"abgelehnt: {reason}") self.lifecycle.reject(p.params["candidate"], f"abgelehnt: {reason}")
if p.kind == "ontology_change":
params = {k: v for k, v in p.params.items() if k != "key"}
self.store.set_ontology_decision(p.params["key"], "reject", params, by)
return self._load(seq) return self._load(seq)
def run_deferred(self) -> list[Proposal]: def run_deferred(self) -> list[Proposal]:
@@ -318,6 +327,24 @@ class MaintenanceAgent:
f"{cand['kind']} für {cand['slot']} übernehmen ({cand['target']})", f"{cand['kind']} für {cand['slot']} übernehmen ({cand['target']})",
reason="alle Prüfstufen bestanden", source="lifecycle")) reason="alle Prüfstufen bestanden", source="lifecycle"))
# 8. Ontologie ↔ Datenhaltung (SPEC-ontology §3)
if self.ontology_check is not None:
try:
ontology_findings = self.ontology_check()
except Exception as exc: # noqa: BLE001 – externe Quelle (n8n-API) darf die Routine nicht abbrechen
report.findings.append(f"Ontologie-Abgleich nicht möglich: {type(exc).__name__}: {exc}")
ontology_findings = []
violations = [f for f in ontology_findings if not f.change]
for f in violations[:c.ontology_max_findings]:
report.findings.append(f"Ontologie [{f.severity}]: {f.summary}")
if len(violations) > c.ontology_max_findings:
report.findings.append(f"Ontologie: {len(violations) - c.ontology_max_findings} weitere Befunde "
"(knaster ontology check)")
for f in ontology_findings:
if f.change:
add(self.propose("ontology_change", {"key": f.key, **f.change}, f.summary, reason=f.detail,
dedup_key=f"ontology:{f.key}"))
for p in self.run_deferred(): for p in self.run_deferred():
report.auto_executed.append(f"{p.id} im Wartungsfenster ausgeführt: {p.summary}") report.auto_executed.append(f"{p.id} im Wartungsfenster ausgeführt: {p.summary}")
@@ -446,3 +473,13 @@ class MaintenanceAgent:
return self._new("mark_slot", {"slot": args["slot"], "status": args["status"].lower(), return self._new("mark_slot", {"slot": args["slot"], "status": args["status"].lower(),
"reason": f"Auftrag von {user}", "set_by": user}, "reason": f"Auftrag von {user}", "set_by": user},
f"Slot {args['slot']} als {args['status'].lower()} markieren", user) f"Slot {args['slot']} als {args['status'].lower()} markieren", user)
def _cmd_map_concept(self, args: dict[str, str], user: str) -> CommandReply:
p = self._load(_seq(args["id"]))
if p.kind != "ontology_change" or p.params.get("type") != "vocab_value":
raise ValueError(f"{p.id} ist kein Vokabel-Vorschlag")
if p.state != "open":
raise ValueError(f"{p.id} ist {p.state}")
self.store.update_proposal(p.seq, params={**p.params, "decision": "map", "concept": args["concept"]})
done = self.approve(p.seq, by=user)
return CommandReply(text=self._decided_text(done), proposals=[done])
+10
View File
@@ -0,0 +1,10 @@
"""Ontologie der Datenhaltung: Modell, Export, Abgleich mit n8n, Übernahme freigegebener Entscheidungen."""
from .apply import apply_decisions
from .check import OntologyFinding, check
from .export import to_graph, to_html, to_turtle
from .model import Ontology
from .n8n import Snapshot, build_snapshot, fetch_workflows
__all__ = ["Ontology", "OntologyFinding", "Snapshot", "apply_decisions", "build_snapshot", "check",
"fetch_workflows", "to_graph", "to_html", "to_turtle"]
+78
View File
@@ -0,0 +1,78 @@
"""Freigegebene Entscheidungen in die Ontologie übernehmen (`knaster ontology apply`)."""
from __future__ import annotations
import re
from typing import Any
from .model import Concept, Ontology, OntologyClass
NEW_MODULE = "neu"
def _slug(value: str) -> str:
text = value.strip().lower()
for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss"), ("+", "_"), ("&", "_")):
text = text.replace(a, b)
return re.sub(r"_+", "_", re.sub(r"[^a-z0-9]+", "_", text)).strip("_") or "wert"
def _class_id(table: str) -> str:
return "".join(part.capitalize() for part in table.split("_"))
def apply_decision(onto: Ontology, key: str, decision: str, params: dict[str, Any]) -> str:
"""Eine Entscheidung anwenden. decision ∈ {accept, map, reject}. Gibt eine Beschreibung zurück."""
change = params.get("type")
if decision == "reject":
if change == "vocab_value":
v = onto.vocabularies[params["vocabulary"]]
if not v.is_rejected(params["value"]):
v.rejected.append(params["value"])
return f"{v.label}: „{params['value']}“ verworfen"
if key not in onto.ignore:
onto.ignore.append(key)
return f"{key} wird künftig ignoriert"
if change == "vocab_value":
v = onto.vocabularies[params["vocabulary"]]
value = params["value"]
if decision == "map":
target = v.resolve(params["concept"])
if target is None:
raise KeyError(f"Konzept {params['concept']} gibt es in {v.label} nicht")
if value.lower() not in (a.lower() for a in v.concepts[target].alt):
v.concepts[target].alt.append(value)
return f"{v.label}: „{value}“ → {target}"
cid = _slug(value)
if cid in v.concepts:
raise ValueError(f"{v.label}: Konzept {cid} existiert bereits")
v.concepts[cid] = Concept(label=value if value != cid else "")
return f"{v.label}: neues Konzept {cid}"
if change == "add_attribute":
onto.classes[params["class"]].attributes[params["column"]] = params["sql_type"]
return f"{params['class']}: Attribut {params['column']} ({params['sql_type']})"
if change == "add_class":
cid = _class_id(params["table"])
if cid in onto.classes:
raise ValueError(f"Klasse {cid} existiert bereits")
onto.modules.setdefault(NEW_MODULE, "Noch nicht zugeordnet")
onto.classes[cid] = OntologyClass(label=params["table"], module=NEW_MODULE, table=params["table"],
definition="Automatisch aufgenommen; Definition und Beziehungen ergänzen.",
attributes=dict(params.get("attributes") or {}))
return f"neue Klasse {cid} für Tabelle {params['table']}"
raise ValueError(f"Unbekannte Änderung {change}")
def apply_decisions(onto: Ontology, decisions: list[dict[str, Any]]) -> tuple[list[str], list[str]]:
"""→ (übernommene Schlüssel, Meldungen). Fehlerhafte Entscheidungen bleiben offen und erscheinen als Meldung."""
applied, messages = [], []
for d in decisions:
try:
messages.append(apply_decision(onto, d["key"], d["decision"], d.get("params") or {}))
applied.append(d["key"])
except (KeyError, ValueError) as exc:
messages.append(f"{d['key']}: nicht übernommen ({exc})")
if applied:
onto.version += 1
return applied, messages
+177
View File
@@ -0,0 +1,177 @@
"""Abgleich Ontologie ↔ tatsächliche Datenhaltung in n8n (SPEC-ontology §3).
Zwei Arten von Ergebnissen:
- Befunde mit `change`: die Ontologie sollte wachsen (neuer Vokabelwert, neue Spalte, neue Tabelle).
Daraus werden Vorschläge zur Freigabe.
- Befunde ohne `change`: die Datenhaltung verstößt gegen die Ontologie bzw. gegen KNOW
(unbekannte Operation, SQL an KNOW vorbei, Konzept ohne Erzeuger, Tabelle ohne Schreiber).
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any, Literal
from pydantic import BaseModel
from .model import Ontology
from .n8n import Snapshot, is_closed, literal_values, value_segments
Severity = Literal["info", "low", "medium", "high"]
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}
class OntologyFinding(BaseModel):
key: str
kind: str
severity: Severity
summary: str
detail: str = ""
change: dict[str, Any] | None = None
class _Obs:
"""Beobachtete Werte eines Vokabulars: Wert → Quellen, dazu welche Quellen nur feste Werte liefern."""
def __init__(self) -> None:
self.values: dict[str, set[str]] = {}
self.closed: set[str] = set() # Quellen mit geschlossener Wertemenge (aktiv)
self.open: set[str] = set() # Quellen mit freien Werten (aktiv)
self.produced: set[str] = set() # Werte aus aktiven Quellen
def add(self, value: str, source: str, active: bool) -> None:
self.values.setdefault(value, set()).add(source)
if active:
self.produced.add(value)
def _vocab_observations(onto: Ontology, snap: Snapshot) -> dict[str, _Obs]:
assert snap.know is not None
by_column = onto.vocab_columns()
obs: dict[str, _Obs] = {}
for call in snap.calls:
if not call.operation:
continue
for table, cols in snap.know.writes.get(call.operation, {}).items():
for col in cols:
ref = f"{table}.{col}"
for vid in by_column.get(ref, []):
o = obs.setdefault(vid, _Obs())
label = f"{call.workflow}{'' if call.active else ' (inaktiv)'}"
for value in snap.know.assigned.get(call.operation, {}).get(ref, set()):
o.add(value, f"KNOW {call.operation} (aufgerufen von {label})", call.active)
if call.inferred or not call.payload:
continue
values = literal_values(call.payload, col)
for value in values:
o.add(value, f"{label} → {call.operation}.{col}", call.active)
if call.active and value_segments(call.payload, col):
(o.closed if is_closed(call.payload, col) else o.open).add(call.workflow)
for ref, values in snap.know.seeds.items():
for vid in by_column.get(ref, []):
for value in values:
obs.setdefault(vid, _Obs()).add(value, f"KNOW-Seed {ref}", False)
return obs
def check(onto: Ontology, snap: Snapshot, decided: Iterable[str] = ()) -> list[OntologyFinding]:
skip = set(decided) | set(onto.ignore)
out: list[OntologyFinding] = []
know = snap.know
if know is None:
return [OntologyFinding(key="know:missing", kind="know_missing", severity="high",
summary="KNOW-Workflow nicht gefunden (Code mit `const allowed` und Schema)")]
# 1. Schema ↔ Ontologie
mapped = onto.tables()
for table, cols in sorted(know.schema.items()):
cid = mapped.get(table)
if cid is None:
out.append(OntologyFinding(
key=f"table:{table}", kind="unmapped_table", severity="medium",
summary=f"Tabelle {table} ({len(cols)} Spalten) fehlt in der Ontologie",
detail="Als neue Klasse aufnehmen; Modul, Definition und Beziehungen danach im Repo ergänzen.",
change={"type": "add_class", "table": table, "attributes": dict(cols)}))
continue
attrs = onto.classes[cid].attributes
for col, typ in cols.items():
if col not in attrs:
out.append(OntologyFinding(
key=f"column:{table}.{col}", kind="unmapped_column", severity="low",
summary=f"Neue Spalte {table}.{col} ({typ}) – als Attribut von {cid} aufnehmen?",
change={"type": "add_attribute", "class": cid, "column": col, "sql_type": typ}))
for col in attrs:
if col not in cols:
out.append(OntologyFinding(key=f"gone:{table}.{col}", kind="missing_column", severity="low",
summary=f"Attribut {cid}.{col} gibt es in KNOW nicht (mehr)"))
for cid, c in onto.classes.items():
if c.table and c.table not in know.schema:
out.append(OntologyFinding(key=f"gone:{c.table}", kind="missing_table", severity="medium",
summary=f"Klasse {cid} verweist auf Tabelle {c.table}, die KNOW nicht anlegt"))
# 2. Aufrufe unbekannter Operationen (pro Workflow und Operation zusammengefasst)
unknown: dict[tuple[str, str], list[str]] = {}
active_of: dict[str, bool] = {}
for call in snap.calls:
if call.operation and not call.inferred and call.operation not in know.allowed:
unknown.setdefault((call.workflow, call.operation), []).append(call.node)
active_of[call.workflow] = call.active
for (wf, op), nodes in sorted(unknown.items()):
out.append(OntologyFinding(
key=f"op:{wf}:{op}", kind="unknown_operation", severity="high" if active_of[wf] else "low",
summary=f"{wf}{'' if active_of[wf] else ' (inaktiv)'} ruft `{op}` auf, die Operation gibt es in KNOW nicht",
detail=f"Knoten: {', '.join(sorted(set(nodes)))}. KNOW lehnt mit „Nicht erlaubte KNOW operation“ ab."))
# 3. SQL an KNOW vorbei
for d in snap.direct_sql:
out.append(OntologyFinding(
key=f"sql:{d.workflow}:{d.node}", kind="direct_sql",
severity=("medium" if d.writes else "low") if d.active else "info",
summary=f"{d.workflow} {'schreibt' if d.writes else 'liest'} per eigenem SQL in "
f"{', '.join(sorted(d.tables))} (an KNOW vorbei)",
detail=f"Knoten „{d.node}“"))
# 4. Vokabulare: neue Werte und Konzepte, die keine feste Zuordnung erzeugt
obs = _vocab_observations(onto, snap)
for vid, o in sorted(obs.items()):
v = onto.vocabularies[vid]
for value, sources in sorted(o.values.items()):
if v.resolve(value) or v.is_rejected(value):
continue
only_inactive = all("(inaktiv)" in s_ for s_ in sources)
out.append(OntologyFinding(
key=f"vocab:{vid}:{value.lower()}", kind="vocab_value", severity="low" if only_inactive else "medium",
summary=f"{v.label}: neuer Wert „{value}“",
detail="Quelle: " + "; ".join(sorted(sources)) + ". Als Konzept aufnehmen, einem Konzept "
"zuordnen („ordne P-x <konzept> zu“) oder verwerfen.",
change={"type": "vocab_value", "vocabulary": vid, "value": value, "sources": sorted(sources)}))
if o.closed:
produced = {v.resolve(x) for x in o.produced}
missing = [cid for cid, c in v.concepts.items() if cid not in produced and not c.technical]
if missing:
extra = f"; freie Werte kommen zusätzlich aus {', '.join(sorted(o.open))}" if o.open else ""
out.append(OntologyFinding(
key=f"unused:{vid}", kind="concept_without_producer", severity="info",
summary=f"{v.label}: {', '.join(missing)} erzeugt keine feste Zuordnung "
f"(geprüft: {', '.join(sorted(o.closed))}{extra})"))
# 5. Tabellenklassen ohne aktiven Schreibweg
active_ops = {c.operation for c in snap.calls if c.active and c.operation} # inkl. abgeleiteter
for cid, c in onto.classes.items():
if not c.table or c.table not in know.schema:
continue
writers = {op for op, tables in know.writes.items() if c.table in tables and op != "setup_schema"}
seeded = any(ref.startswith(f"{c.table}.") for ref in know.seeds)
if not writers and seeded:
continue # Konfigurationstabelle, die setup_schema befüllt
if not writers:
out.append(OntologyFinding(key=f"nowriter:{c.table}", kind="no_write_operation", severity="info",
summary=f"{cid} ({c.table}): KNOW hat keine Schreib-Operation"))
elif not writers & active_ops:
out.append(OntologyFinding(key=f"nowriter:{c.table}", kind="no_active_writer", severity="info",
summary=f"{cid} ({c.table}): kein aktiver Workflow ruft "
f"{', '.join(sorted(writers))} auf"))
out = [f for f in out if f.key not in skip]
out.sort(key=lambda f: (SEVERITY_ORDER[f.severity], f.kind, f.key))
return out
+133
View File
@@ -0,0 +1,133 @@
"""Erzeugte Artefakte aus der Ontologie: OWL/Turtle, Graph-JSON für die Ansicht, eigenständige HTML-Seite."""
from __future__ import annotations
import json
import re
from datetime import date
from pathlib import Path
from typing import Any
from .model import Ontology
XSD = {"TEXT": "xsd:string", "NUMERIC": "xsd:decimal", "INTEGER": "xsd:integer", "BIGINT": "xsd:integer",
"SMALLINT": "xsd:integer", "BIGSERIAL": "xsd:integer", "BOOLEAN": "xsd:boolean", "DATE": "xsd:date",
"TIMESTAMPTZ": "xsd:dateTime", "JSONB": "rdf:JSON", "DOUBLE PRECISION": "xsd:double"}
BOOKKEEPING = {"id", "created_at", "updated_at"}
def _lit(text: str) -> str:
return '"' + text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ") + '"@de'
def _local(text: str) -> str:
text = (text.replace("+", "plus").replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
.replace("Ä", "Ae").replace("Ö", "Oe").replace("Ü", "Ue").replace("ß", "ss"))
return re.sub(r"[^A-Za-z0-9_]", "_", text)
def _union(ids: list[str], prefix: str) -> str:
if len(ids) == 1:
return f"{prefix}:{ids[0]}"
return "[ a owl:Class ; owl:unionOf ( " + " ".join(f"{prefix}:{i}" for i in ids) + " ) ]"
def relation_columns(onto: Ontology, cid: str) -> set[str]:
"""Spalten einer Tabellenklasse, über die eine Beziehung läuft (keine Attribute im engeren Sinn)."""
c = onto.classes[cid]
cols: set[str] = set()
for r in onto.relations:
if r.source == cid and c.table:
cols |= set(re.findall(r"\b([a-z_]+)\b", r.via.split("=")[0])) & set(c.attributes)
return cols
def to_turtle(onto: Ontology) -> str:
p = onto.id
out = [f"@prefix {p}: <{onto.namespace}> .", "@prefix owl: <http://www.w3.org/2002/07/owl#> .",
"@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .",
"@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .",
"@prefix skos: <http://www.w3.org/2004/02/skos/core#> .",
"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .", "",
f"<{onto.namespace.rstrip('#/')}> a owl:Ontology ;",
f" rdfs:label {_lit(onto.label or onto.id)} ;",
f' owl:versionInfo "{onto.version}" .', "",
f"{p}:quelleTabelle a owl:AnnotationProperty .", f"{p}:viaSpalte a owl:AnnotationProperty .", ""]
for cid, c in onto.classes.items():
lines = [f"{p}:{cid} a owl:Class", f" rdfs:label {_lit(c.label)}"]
if c.definition:
lines.append(f" rdfs:comment {_lit(c.definition)}")
if c.table:
lines.append(f' {p}:quelleTabelle "{c.table}"')
if c.subclass_of:
lines.append(f" rdfs:subClassOf {p}:{c.subclass_of}")
out.append(" ;\n".join(lines) + " .\n")
props: dict[str, dict[str, list[str]]] = {}
for r in onto.relations:
e = props.setdefault(r.name, {"dom": [], "rng": [], "via": []})
for k, v in (("dom", r.source), ("rng", r.target)):
if v not in e[k]:
e[k].append(v)
e["via"].append(f"{r.source}→{r.target}: {r.via}" if r.via else f"{r.source}→{r.target}")
for name, e in props.items():
out.append(f"{p}:{name} a owl:ObjectProperty ;\n rdfs:domain {_union(e['dom'], p)} ;\n"
f" rdfs:range {_union(e['rng'], p)} ;\n"
+ " ;\n".join(f" {p}:viaSpalte {_lit(v)}" for v in e["via"]) + " .\n")
dprops: dict[str, dict[str, Any]] = {}
for cid, c in onto.classes.items():
skip = relation_columns(onto, cid) | BOOKKEEPING
for col, typ in c.attributes.items():
if col in skip:
continue
d = dprops.setdefault(col, {"dom": [], "rng": XSD.get(typ.split("(")[0].strip(), "xsd:string")})
d["dom"].append(cid)
for col, d in sorted(dprops.items()):
out.append(f"{p}:{_local(col)} a owl:DatatypeProperty ;\n rdfs:domain {_union(d['dom'], p)} ;\n"
f" rdfs:range {d['rng']} .\n")
for vid, v in onto.vocabularies.items():
scheme = f"{p}:{_local(vid)}Schema"
out.append(f"{scheme} a skos:ConceptScheme ;\n rdfs:label {_lit(v.label)} .\n")
for cid, c in v.concepts.items():
types = f"skos:Concept, {p}:{v.class_}" if v.class_ else "skos:Concept"
lines = [f"{p}:{_local(vid)}_{_local(cid)} a {types}", f" skos:inScheme {scheme}",
f" skos:notation \"{cid}\"", f" skos:prefLabel {_lit(c.label or cid)}"]
lines += [f" skos:altLabel {_lit(a)}" for a in c.alt]
if c.note:
lines.append(f" skos:scopeNote {_lit(c.note)}")
out.append(" ;\n".join(lines) + " .\n")
return "\n".join(out)
def to_graph(onto: Ontology, generated: str | None = None) -> dict[str, Any]:
"""Datenformat der Ansicht (ontology/viewer.html.tmpl)."""
enums: dict[str, dict[str, list[str]]] = {}
individuals: dict[str, list[str]] = {}
for v in onto.vocabularies.values():
values = [c.label or cid for cid, c in v.concepts.items()]
if v.class_:
individuals.setdefault(v.class_, []).extend(x for x in values if x not in individuals.get(v.class_, []))
else:
for ref in v.columns:
table, _, col = ref.partition(".")
owner = onto.class_for_table(table)
if owner:
enums.setdefault(owner, {})[col] = list(v.concepts)
classes = []
for cid, c in onto.classes.items():
skip = relation_columns(onto, cid) | BOOKKEEPING
classes.append({"id": cid, "label": c.label, "module": c.module, "table": c.table, "def": c.definition,
"key": c.key or "–", "attrs": [[k, t] for k, t in c.attributes.items() if k not in skip],
"individuals": individuals.get(cid, []), "enums": enums.get(cid, {}), "sens": c.sensitive,
"abstract": c.abstract})
relations = [r.model_dump() for r in onto.relations]
relations += [{"source": cid, "name": "subClassOf", "target": c.subclass_of, "via": "", "kind": "subclass"}
for cid, c in onto.classes.items() if c.subclass_of]
return {"classes": classes, "relations": relations, "version": onto.version,
"generated": generated or date.today().isoformat()}
def to_html(onto: Ontology, template: str | Path, generated: str | None = None) -> str:
tmpl = Path(template).read_text(encoding="utf-8")
data = json.dumps(to_graph(onto, generated), ensure_ascii=False).replace("</", "<\\/")
ttl = json.dumps(to_turtle(onto), ensure_ascii=False).replace("</", "<\\/")
return tmpl.replace("__DATA__", data).replace("__TTL__", ttl)
+148
View File
@@ -0,0 +1,148 @@
"""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
+325
View File
@@ -0,0 +1,325 @@
"""Momentaufnahme der n8n-Datenhaltung: KNOW-Spezifikation, Aufrufe, direktes SQL (nur lesend).
KNOW ist ein n8n-Sub-Workflow, der feste Operationen auf SQL-Vorlagen abbildet. Seine Spezifikation
(erlaubte Operationen, Schema, geschriebene Spalten) wird aus dem Code des Workflows gelesen, nicht aus der DB.
"""
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass, field
from typing import Any
import httpx
_ALLOWED = re.compile(r"const allowed\s*=\s*\[([^\]]+)\]")
_OP = re.compile(r"""if\s*\(\s*operation\s*===\s*['"]([a-z_0-9]+)['"]""")
_INSERT = re.compile(r"INSERT INTO \$\{t\.(\w+)\}(?:\s+AS\s+\w+)?\s*\(")
_UPDATE = re.compile(r"UPDATE \$\{t\.(\w+)\}(?:\s+AS)?(?:\s+\w+)?\s+SET\s")
_DELETE = re.compile(r"DELETE FROM \$\{t\.(\w+)\}")
_WRITE_SQL = re.compile(r"\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\b", re.IGNORECASE)
@dataclass
class KnowSpec:
workflow_id: str
workflow_name: str
allowed: set[str] = field(default_factory=set)
schema: dict[str, dict[str, str]] = field(default_factory=dict) # Tabelle → Spalte → Typ
writes: dict[str, dict[str, set[str]]] = field(default_factory=dict) # Operation → Tabelle → Spalten
seeds: dict[str, set[str]] = field(default_factory=dict) # "tabelle.spalte" → Literalwerte aus Seed-INSERTs
assigned: dict[str, dict[str, set[str]]] = field(default_factory=dict) # Operation → "tab.spalte" → Literale
@dataclass
class KnowCall:
workflow: str
active: bool
node: str
operation: str | None # None = erst zur Laufzeit bestimmt
payload: str = ""
inferred: bool = False # Operation aus Literalen im Workflow abgeleitet (Laufzeit-Operation)
@dataclass
class DirectSql:
workflow: str
active: bool
node: str
query: str
tables: set[str]
writes: bool
@dataclass
class Snapshot:
know: KnowSpec | None
calls: list[KnowCall]
direct_sql: list[DirectSql]
workflow_count: int
taken_at: float = field(default_factory=time.time)
# --- Hilfsfunktionen für JS/SQL-Text ------------------------------------------------------------
def _balanced(text: str, start: int) -> tuple[str, int]:
"""text[start] ist eine öffnende Klammer → (Inhalt, Index der schließenden Klammer)."""
depth = 0
for i in range(start, len(text)):
if text[i] in "([{":
depth += 1
elif text[i] in ")]}":
depth -= 1
if depth == 0:
return text[start + 1:i], i
return text[start + 1:], len(text)
def _split_top(text: str) -> list[str]:
parts, depth, cur, quote = [], 0, "", ""
for ch in text:
if quote:
cur += ch
if ch == quote:
quote = ""
continue
if ch in "'\"`":
quote = ch
elif ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
if ch == "," and depth == 0:
parts.append(cur)
cur = ""
else:
cur += ch
if cur.strip():
parts.append(cur)
return parts
def _column_type(definition: str) -> str:
words = definition.split()
if len(words) < 2:
return "TEXT"
return "DOUBLE PRECISION" if words[1].upper() == "DOUBLE" else words[1].upper()
def _take_sets(writes: dict[str, set[str]], assigned: dict[str, set[str]], table: str, setp: str) -> None:
"""SET-Klausel auswerten: geschriebene Spalten und dabei gesetzte Literale merken."""
for part in _split_top(setp):
if "=" not in part:
continue
col, _, expr = part.partition("=")
col = re.sub(r"^\w+\.", "", col.strip())
writes.setdefault(table, set()).add(col)
lits = {x for x in re.findall(r"'([^'$]*)'", expr) if x and "{" not in x}
if lits:
assigned.setdefault(f"{table}.{col}", set()).update(lits)
def parse_know_code(code: str, workflow_id: str = "", workflow_name: str = "") -> KnowSpec:
spec = KnowSpec(workflow_id=workflow_id, workflow_name=workflow_name)
m = _ALLOWED.search(code)
if m:
spec.allowed = set(re.findall(r"['\"]([a-z_0-9]+)['\"]", m.group(1)))
for m in re.finditer(r"CREATE TABLE IF NOT EXISTS \$\{t\.(\w+)\}\s*\(", code):
body, _ = _balanced(code, m.end() - 1)
cols = spec.schema.setdefault(m.group(1), {})
for part in _split_top(body):
part = " ".join(part.split())
if not part or re.match(r"^(UNIQUE|PRIMARY KEY|CONSTRAINT|CHECK)\b", part, re.IGNORECASE):
continue
cols.setdefault(part.split()[0], _column_type(part))
for m in re.finditer(r"ALTER TABLE \$\{t\.(\w+)\}([^;`]*)", code):
for a in re.finditer(r"ADD COLUMN IF NOT EXISTS\s+(\w+)\s+([^,]+)", m.group(2)):
spec.schema.setdefault(m.group(1), {}).setdefault(a.group(1), _column_type("x " + a.group(2)))
starts = [(m.start(), m.group(1)) for m in _OP.finditer(code)]
for i, (pos, op) in enumerate(starts):
block = code[pos:starts[i + 1][0] if i + 1 < len(starts) else len(code)]
writes = spec.writes.setdefault(op, {})
assigned = spec.assigned.setdefault(op, {})
for m in _INSERT.finditer(block):
inner, end = _balanced(block, m.end() - 1)
vm = re.match(r"\s*VALUES\s*\(", block[end + 1:end + 40])
if vm and "${" not in _balanced(block, end + 1 + vm.end() - 1)[0]:
continue # reines Literal-INSERT = Seed aus setup_schema, keine Schreiboperation von `op`
writes.setdefault(m.group(1), set()).update(c.strip() for c in inner.split(",") if c.strip())
rest = block[end:end + 6000]
conflict = re.search(r"ON CONFLICT\s*\([^)]*\)\s*DO\s+UPDATE SET\s", rest)
if conflict:
tail = rest[conflict.end():]
stop = re.search(r"\sRETURNING\s|\sWHERE\s|;|`", tail)
_take_sets(writes, assigned, m.group(1), tail[:stop.start()] if stop else tail)
for m in _UPDATE.finditer(block):
tail = block[m.end():m.end() + 6000]
stop = re.search(r"\sWHERE\s|\sRETURNING\s|\sFROM\s|;|`", tail)
_take_sets(writes, assigned, m.group(1), tail[:stop.start()] if stop else tail)
for m in _DELETE.finditer(block):
writes.setdefault(m.group(1), set())
# Literalwerte in INSERTs (Seeds wie die Hinweis-Konfiguration, feste Werte wie alias_type 'learned')
for m in _INSERT.finditer(code):
inner, end = _balanced(code, m.end() - 1)
cols = [c.strip() for c in inner.split(",")]
vm = re.match(r"\s*VALUES\s*\(", code[end + 1:end + 40])
if not vm:
continue
values, _ = _balanced(code, end + 1 + vm.end() - 1)
for col, val in zip(cols, _split_top(values), strict=False):
lit = re.fullmatch(r"\s*'([^'$]*)'\s*", val)
if lit and lit.group(1):
spec.seeds.setdefault(f"{m.group(1)}.{col}", set()).add(lit.group(1))
return spec
def value_segments(expression: str, key: str) -> list[str]:
"""Ausdrücke, die `key` auf der obersten Ebene des Payload-Objekts zugewiesen werden.
Verschachtelte Schlüssel (z. B. `raw_signals: { category: … }`) gehören zu einer JSON-Spalte, nicht zu `key`.
"""
anchor = expression.find("JSON.stringify(")
brace = expression.find("{", anchor if anchor >= 0 else 0)
while brace >= 0 and expression[brace + 1:brace + 2] == "{": # n8n-Ausdruck „={{ … }}“ überspringen
brace = expression.find("{", brace + 2)
if brace < 0:
return []
body, _ = _balanced(expression, brace)
out = []
for part in _split_top(body):
m = re.match(rf"\s*['\"]?{re.escape(key)}['\"]?\s*:", part)
if m:
out.append(part[m.end():])
return out
def literal_values(expression: str, key: str) -> set[str]:
"""Literale, die ein Payload-Ausdruck für `key` liefern kann.
Erkennt `key: 'x'`, `key: a || 'x'` und Zuordnungstabellen `key: ({'Anzeige': 'wert', …}[expr] || 'rest')`.
Literale in Knotenverweisen wie `$('Knoten')` zählen nicht.
"""
found: set[str] = set()
for seg in value_segments(expression, key):
seg = re.sub(r"\$\(\s*(['\"]).*?\1\s*\)", "", seg) # $('Knoten') entfernen
pairs = re.findall(r"(['\"])([^'\"]*)\1\s*:\s*(['\"])([^'\"]*)\3", seg)
found |= {p[3] for p in pairs}
seg = re.sub(r"(['\"])[^'\"]*\1\s*:\s*(['\"])[^'\"]*\2", "", seg)
for lit in re.findall(r"(['\"])([^'\"]*)\1(?!\s*:)", seg):
if lit[1] and not re.search(r"[{}$]", lit[1]):
found.add(lit[1])
return found
def is_closed(expression: str, key: str) -> bool:
"""True, wenn `key` nur feste Werte annehmen kann (Literal oder Zuordnungstabelle mit Literal-Rückfall)."""
segments = value_segments(expression, key)
if not segments:
return False
for seg in segments:
seg = re.sub(r"(['\"])(?:(?!\1).)*\1", "L", seg) # Literale
while True: # Zuordnungstabelle {L:L, …}[…] und Klammern entfernen
new = re.sub(r"\{[L:,\s]*\}\s*\[[^\[\]]*\]", "L", seg)
new = re.sub(r"\(\s*([L|\s]*)\s*\)", r"\1", new)
if new == seg:
break
seg = new
if not (re.fullmatch(r"[L|\s]+", seg) and "L" in seg):
return False
return True
# --- Workflows auswerten -----------------------------------------------------------------------
def find_know(workflows: list[dict[str, Any]]) -> KnowSpec | None:
for wf in workflows:
for node in wf.get("nodes", []):
code = (node.get("parameters") or {}).get("jsCode") or ""
if "const allowed" in code and "CREATE TABLE IF NOT EXISTS" in code:
return parse_know_code(code, wf.get("id", ""), wf.get("name", ""))
return None
def _inputs(node: dict[str, Any]) -> dict[str, Any]:
return ((node.get("parameters") or {}).get("workflowInputs") or {}).get("value") or {}
def extract_calls(workflows: list[dict[str, Any]], know_id: str, allowed: set[str] | None = None) -> list[KnowCall]:
calls = []
for wf in workflows:
if wf.get("id") == know_id:
continue
for node in wf.get("nodes", []):
if not node.get("type", "").endswith("executeWorkflow"):
continue
if know_id not in json.dumps(node.get("parameters") or {}):
continue
values = _inputs(node)
op = str(values.get("operation", "")).strip()
literal = op if re.fullmatch(r"[a-z_0-9]+", op) else None
if literal is None and allowed:
# Operation wird zur Laufzeit gesetzt: Kandidaten sind bekannte Operationen, die im Workflow als
# Literal vorkommen (z. B. in einem Code-Knoten davor)
text = json.dumps(wf.get("nodes", []))
for cand in sorted(o for o in allowed if re.search(rf"""['"\\]{o}['"\\]""", text)):
calls.append(KnowCall(wf.get("name", ""), bool(wf.get("active")), node.get("name", ""), cand,
"", inferred=True))
continue
calls.append(KnowCall(wf.get("name", ""), bool(wf.get("active")), node.get("name", ""), literal,
str(values.get("payload_json", ""))))
return calls
def extract_direct_sql(workflows: list[dict[str, Any]], know_id: str,
schema_name: str = "knowledge") -> list[DirectSql]:
out = []
ref = re.compile(rf"""["']?{re.escape(schema_name)}["']?\s*\.\s*["']?(\w+)""", re.IGNORECASE)
for wf in workflows:
if wf.get("id") == know_id:
continue
for node in wf.get("nodes", []):
if not node.get("type", "").endswith(".postgres"):
continue
query = str((node.get("parameters") or {}).get("query", ""))
tables = set(ref.findall(query))
if not tables:
continue
out.append(DirectSql(wf.get("name", ""), bool(wf.get("active")), node.get("name", ""), query, tables,
bool(_WRITE_SQL.search(query))))
return out
def build_snapshot(workflows: list[dict[str, Any]]) -> Snapshot:
know = find_know(workflows)
know_id = know.workflow_id if know else ""
return Snapshot(know=know, calls=extract_calls(workflows, know_id, know.allowed) if know else [],
direct_sql=extract_direct_sql(workflows, know_id), workflow_count=len(workflows))
def fetch_workflows(host: str, api_key: str, *, client: httpx.Client | None = None,
timeout: float = 30.0) -> list[dict[str, Any]]:
"""Alle Workflows über die n8n-API lesen (nur GET)."""
base = host if host.startswith("http") else f"https://{host}"
base = base.rstrip("/")
own = client is None
client = client or httpx.Client(timeout=timeout)
try:
out: list[dict[str, Any]] = []
cursor = None
while True:
params: dict[str, Any] = {"limit": 250}
if cursor:
params["cursor"] = cursor
r = client.get(f"{base}/api/v1/workflows", params=params, headers={"X-N8N-API-KEY": api_key})
r.raise_for_status()
body = r.json()
out.extend(body.get("data", []))
cursor = body.get("nextCursor")
if not cursor:
return out
finally:
if own:
client.close()
+8
View File
@@ -38,6 +38,10 @@ class Settings:
webhook_auth: str = "" # Wert des Authorization-Headers für ausgehende Webhooks (n8n Header Auth) webhook_auth: str = "" # Wert des Authorization-Headers für ausgehende Webhooks (n8n Header Auth)
request_timeout: float = 120.0 request_timeout: float = 120.0
canary_interval: float = 60.0 # 0 = Prober aus canary_interval: float = 60.0 # 0 = Prober aus
ontology_path: Path = field(default_factory=lambda: REPO_ROOT / "ontology" / "know.yaml")
ontology_snapshot: str = "" # Datei mit n8n-Workflows (JSON) statt Live-Abruf, z. B. für Tests/offline
n8n_host: str = "" # nur lesend: GET /api/v1/workflows für den Ontologie-Abgleich
n8n_api_key: str = ""
@classmethod @classmethod
def from_env(cls) -> Settings: def from_env(cls) -> Settings:
@@ -65,4 +69,8 @@ class Settings:
webhook_auth=env.get("KNASTER_WEBHOOK_AUTH", ""), webhook_auth=env.get("KNASTER_WEBHOOK_AUTH", ""),
request_timeout=float(env.get("KNASTER_REQUEST_TIMEOUT", base.request_timeout)), request_timeout=float(env.get("KNASTER_REQUEST_TIMEOUT", base.request_timeout)),
canary_interval=float(env.get("KNASTER_CANARY_INTERVAL", base.canary_interval)), canary_interval=float(env.get("KNASTER_CANARY_INTERVAL", base.canary_interval)),
ontology_path=Path(env.get("KNASTER_ONTOLOGY", base.ontology_path)),
ontology_snapshot=env.get("KNASTER_ONTOLOGY_SNAPSHOT", ""),
n8n_host=env.get("N8N_HOST", ""),
n8n_api_key=env.get("N8N_API_KEY", ""),
) )
+32
View File
@@ -144,6 +144,18 @@ healer_state = Table(
Column("updated_at", Float), Column("updated_at", Float),
) )
ontology_decisions = Table(
"ontology_decisions",
metadata,
Column("key", String(300), primary_key=True),
Column("decision", String(20), nullable=False), # accept | map | reject
Column("params", JSON),
Column("decided_by", String(100)),
Column("decided_at", Float),
Column("applied_at", Float),
Column("updated_at", Float),
)
events = Table( events = Table(
"events", "events",
metadata, metadata,
@@ -339,6 +351,26 @@ class Store:
with self.engine.connect() as conn: with self.engine.connect() as conn:
return [dict(r._mapping) for r in conn.execute(q)] return [dict(r._mapping) for r in conn.execute(q)]
# --- Ontologie-Entscheidungen ----------------------------------------------------------
def set_ontology_decision(self, key: str, decision: str, params: dict[str, Any], decided_by: str) -> None:
self._upsert(ontology_decisions, "key", key, {"decision": decision, "params": params,
"decided_by": decided_by, "decided_at": now(),
"applied_at": None})
def list_ontology_decisions(self, *, applied: bool | None = None) -> list[dict[str, Any]]:
q = select(ontology_decisions).order_by(ontology_decisions.c.decided_at)
if applied is True:
q = q.where(ontology_decisions.c.applied_at.is_not(None))
elif applied is False:
q = q.where(ontology_decisions.c.applied_at.is_(None))
with self.engine.connect() as conn:
return [dict(r._mapping) for r in conn.execute(q)]
def mark_ontology_applied(self, keys: list[str]) -> None:
with self.engine.begin() as conn:
conn.execute(update(ontology_decisions).where(ontology_decisions.c.key.in_(keys))
.values(applied_at=now()))
# --- Healer-Zustand -------------------------------------------------------------------- # --- Healer-Zustand --------------------------------------------------------------------
def get_healer_state(self, key: str) -> dict[str, Any] | None: def get_healer_state(self, key: str) -> dict[str, Any] | None:
row = self._get(healer_state, "key", key) row = self._get(healer_state, "key", key)
+35 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
@@ -18,6 +19,7 @@ from .llm import ChatClient, OpenAIChatClient
from .maintenance.actions import ActionExecutor from .maintenance.actions import ActionExecutor
from .maintenance.agent import IntentMapper, LLMIntentMapper, MaintenanceAgent, MaintenanceConfig from .maintenance.agent import IntentMapper, LLMIntentMapper, MaintenanceAgent, MaintenanceConfig
from .metrics import Metrics from .metrics import Metrics
from .ontology import Ontology, OntologyFinding, build_snapshot, check, fetch_workflows
from .policy import Policy from .policy import Policy
from .registry import Registry from .registry import Registry
from .router import Router from .router import Router
@@ -60,6 +62,35 @@ def _optional(path: Path, loader: Callable, default): # type: ignore[no-untyped
return loader(path) if path.exists() else default return loader(path) if path.exists() else default
def ontology_source_for(settings: Settings) -> Callable[[], list[dict]] | None:
"""Woher die n8n-Workflows für den Ontologie-Abgleich kommen: Datei (offline) oder n8n-API (nur lesend)."""
if settings.ontology_snapshot:
path = Path(settings.ontology_snapshot)
def from_file() -> list[dict]:
data = json.loads(path.read_text(encoding="utf-8"))
return data.get("data", data) if isinstance(data, dict) else data
return from_file
if settings.n8n_host and settings.n8n_api_key:
return lambda: fetch_workflows(settings.n8n_host, settings.n8n_api_key, timeout=settings.request_timeout)
return None
def ontology_checker(settings: Settings, store: Store,
source: Callable[[], list[dict]] | None) -> Callable[[], list[OntologyFinding]] | None:
source = source or ontology_source_for(settings)
if source is None or not settings.ontology_path.exists():
return None
def run() -> list[OntologyFinding]:
onto = Ontology.from_file(settings.ontology_path) # bei jedem Lauf neu: Änderungen im Repo zählen sofort
decided = {d["key"] for d in store.list_ontology_decisions()}
return check(onto, build_snapshot(source()), decided=decided)
return run
def build_system( def build_system(
settings: Settings, settings: Settings,
*, *,
@@ -70,6 +101,7 @@ def build_system(
notifier: Notifier | None = None, notifier: Notifier | None = None,
jobs: JobRunner | None = None, jobs: JobRunner | None = None,
intent_mapper: IntentMapper | None = None, intent_mapper: IntentMapper | None = None,
ontology_source: Callable[[], list[dict]] | None = None,
clock: Callable[[], float] = now, clock: Callable[[], float] = now,
wallclock: Callable[[], datetime] = datetime.now, wallclock: Callable[[], datetime] = datetime.now,
) -> System: ) -> System:
@@ -117,7 +149,9 @@ def build_system(
intent_mapper = LLMIntentMapper(chat, registry.slot("tier1-general").spec.litellm_model) intent_mapper = LLMIntentMapper(chat, registry.slot("tier1-general").spec.litellm_model)
maintenance = MaintenanceAgent(store=store, registry=registry, library=library, index=router.index, maintenance = MaintenanceAgent(store=store, registry=registry, library=library, index=router.index,
policy=policy, actions=actions, lifecycle=lifecycle, config=maint_cfg, policy=policy, actions=actions, lifecycle=lifecycle, config=maint_cfg,
intent_mapper=intent_mapper, clock=clock, wallclock=wallclock) intent_mapper=intent_mapper,
ontology_check=ontology_checker(settings, store, ontology_source),
clock=clock, wallclock=wallclock)
healer = Healer(healer_cfg, store, actions, policy, propose=maintenance.propose, clock=clock) healer = Healer(healer_cfg, store, actions, policy, propose=maintenance.propose, clock=clock)
prober = CanaryProber(registry, chat, embedder_for={settings.embed_model: embedder}, prober = CanaryProber(registry, chat, embedder_for={settings.embed_model: embedder},
metrics_registry=metrics.registry) metrics_registry=metrics.registry)
+15
View File
@@ -87,6 +87,21 @@ def validate_repo(root: str | Path, site: str = "knaster") -> list[tuple[str, st
if kind not in known: if kind not in known:
err(f"Policy: unbekannte Aktion {kind}") err(f"Policy: unbekannte Aktion {kind}")
onto_path = root / "ontology" / "know.yaml"
if onto_path.exists():
from .ontology import Ontology, to_turtle
try:
onto = Ontology.from_file(onto_path)
except (ValidationError, ValueError) as exc:
err(f"Ontologie: {exc}")
else:
for e in onto.validate_model():
err(f"Ontologie: {e}")
ttl = onto_path.with_suffix(".ttl")
if not ttl.exists() or ttl.read_text(encoding="utf-8") != to_turtle(onto):
warn(f"Ontologie: {ttl.name} ist nicht aktuell (knaster ontology export --ttl {ttl})")
if (root / "catalog").exists(): if (root / "catalog").exists():
roles, _ = load_catalog(root / "catalog") roles, _ = load_catalog(root / "catalog")
for role in roles: for role in roles:
+2 -1
View File
@@ -212,7 +212,8 @@ def make_system(tmp_path, skills_dir, fake_chat, clock):
(site / "registry.yaml").write_text(yaml.safe_dump(REGISTRY)) (site / "registry.yaml").write_text(yaml.safe_dump(REGISTRY))
settings = Settings(db_url="sqlite://", site_dir=site, skills_dir=skills_dir, settings = Settings(db_url="sqlite://", site_dir=site, skills_dir=skills_dir,
config_dir=overrides.pop("config_dir", REPO_ROOT / "config"), config_dir=overrides.pop("config_dir", REPO_ROOT / "config"),
api_token=overrides.pop("api_token", "secret")) api_token=overrides.pop("api_token", "secret"),
ontology_path=overrides.pop("ontology_path", tmp_path / "keine-ontologie.yaml"))
kw = dict(embedder=HashingEmbedder(), chat=fake_chat, jobs=LoggingJobRunner(), notifier=DryRunDriver(), kw = dict(embedder=HashingEmbedder(), chat=fake_chat, jobs=LoggingJobRunner(), notifier=DryRunDriver(),
clock=clock) clock=clock)
kw.update(overrides) kw.update(overrides)
+267
View File
@@ -0,0 +1,267 @@
"""Ontologie: Modell, Export, Abgleich mit n8n (synthetische Workflows), Übernahme, Service-Routine."""
import json
import pytest
from knaster.ontology import Ontology, apply_decisions, build_snapshot, check, to_graph, to_turtle
from knaster.ontology.n8n import is_closed, literal_values, parse_know_code
from knaster.settings import REPO_ROOT
KNOW_CODE = r"""
const allowed=['setup_schema','store_mail_event','upsert_hint','promote_hint','find_mail'];
if(operation==='setup_schema'){ query=`CREATE TABLE IF NOT EXISTS ${t.mail_events} (id BIGSERIAL PRIMARY KEY,
message_id TEXT UNIQUE NOT NULL, category TEXT, sender TEXT);
CREATE TABLE IF NOT EXISTS ${t.hints} (hint_key TEXT UNIQUE NOT NULL, hint_type TEXT NOT NULL,
quality_status TEXT NOT NULL DEFAULT 'observed');
CREATE TABLE IF NOT EXISTS ${t.config} (config_key TEXT UNIQUE NOT NULL, weight NUMERIC);
CREATE TABLE IF NOT EXISTS ${t.orphans} (id BIGSERIAL PRIMARY KEY, x TEXT);
ALTER TABLE ${t.mail_events} ADD COLUMN IF NOT EXISTS received_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS mood TEXT;
INSERT INTO ${t.config} (config_key, weight) VALUES ('mahnung', 3) ON CONFLICT (config_key) DO NOTHING;
INSERT INTO ${t.config} (config_key, weight) VALUES ('default', 0) ON CONFLICT (config_key) DO NOTHING;`;}
if(operation==='store_mail_event'){ query=`INSERT INTO ${t.mail_events} (message_id,category,sender)
VALUES (${sqlString(payload.message_id)},${sqlString(payload.category)},${sqlString(payload.sender)})
ON CONFLICT (message_id) DO UPDATE SET category=EXCLUDED.category RETURNING *`;}
if(operation==='upsert_hint'){ query=`INSERT INTO ${t.hints} (hint_key,hint_type) VALUES (${k},${ht})
ON CONFLICT (hint_key) DO UPDATE SET quality_status=CASE WHEN x THEN 'proposed' ELSE ${t.hints}.quality_status END`;}
if(operation==='promote_hint'){ query=`UPDATE ${t.hints} SET quality_status='confirmed' WHERE hint_key=${k}`;}
if(operation==='find_mail'){ query=`SELECT * FROM ${t.mail_events}`;}
"""
ONTO = {
"id": "t", "namespace": "urn:test#", "modules": {"m": "Mail"},
"classes": {
"Mail": {"label": "Mail", "module": "m", "table": "mail_events", "key": "message_id",
"attributes": {"id": "BIGSERIAL", "message_id": "TEXT", "category": "TEXT", "sender": "TEXT",
"received_at": "TIMESTAMPTZ"}},
"Hinweis": {"label": "Hinweis", "module": "m", "table": "hints",
"attributes": {"hint_key": "TEXT", "hint_type": "TEXT", "quality_status": "TEXT"}},
"Kategorie": {"label": "Kategorie", "module": "m", "table": "config",
"attributes": {"config_key": "TEXT", "weight": "NUMERIC"}},
"Alt": {"label": "Alt", "module": "m", "table": "removed_table", "attributes": {"a": "TEXT"}},
},
"relations": [{"source": "Hinweis", "name": "hatKategorie", "target": "Kategorie", "via": "hint_type"}],
"vocabularies": {
"Kat": {"label": "Kategorie", "class": "Kategorie",
"columns": ["mail_events.category", "hints.hint_type", "config.config_key"],
"concepts": {"mahnung": {"label": "Mahnung"}, "rechnung": {"label": "Rechnung", "alt": ["Buchhaltung"]},
"sonstiges": {}, "default": {"technical": True}}},
"QS": {"label": "Qualität", "columns": ["hints.quality_status"],
"concepts": {"observed": {}, "proposed": {}, "confirmed": {}, "rejected": {}}},
},
}
CAT_PAYLOAD = ("={{ JSON.stringify({ message_id: $json.id, sender: ($('Vorfilter').item.json.from||'').toLowerCase(), "
"category: ({'Buchhaltung':'rechnung','Bauvorhaben':'bauvorhaben','Sonstiges':'sonstiges'}"
"[$json.output?.category]||'sonstiges'), raw_signals: { category: 'x' } }) }}")
def _call(name, op, payload="", node="KNOW"):
return {"name": node, "type": "n8n-nodes-base.executeWorkflow",
"parameters": {"workflowId": {"value": "know1"},
"workflowInputs": {"value": {"operation": op, "payload_json": payload}}}}
WORKFLOWS = [
{"id": "know1", "name": "KNOW", "active": True,
"nodes": [{"name": "Normalize", "type": "n8n-nodes-base.code", "parameters": {"jsCode": KNOW_CODE}}]},
{"id": "w1", "name": "CAT", "active": True, "nodes": [
_call("CAT", "store_mail_event", CAT_PAYLOAD),
{"name": "Mark", "type": "n8n-nodes-base.postgres",
"parameters": {"query": "UPDATE knowledge.hints SET x=1 WHERE hint_key='{{ $json.k }}'"}}]},
{"id": "w2", "name": "MPA", "active": True, "nodes": [
_call("MPA", "store_mail_event", '={{ JSON.stringify({ message_id: $json.id, category: "MPA" }) }}'),
_call("MPA", "log_decision", "{}", node="Log1"), _call("MPA", "log_decision", "{}", node="Log2")]},
{"id": "w3", "name": "Dyn", "active": True, "nodes": [
{"name": "Build", "type": "n8n-nodes-base.code", "parameters": {"jsCode": "return {operation: 'upsert_hint'}"}},
_call("Dyn", "={{ $json.operation }}")]},
{"id": "w4", "name": "Old", "active": False, "nodes": [
_call("Old", "store_mail_event", "={{ JSON.stringify({ category: 'legacy_kat' }) }}")]},
]
@pytest.fixture
def onto():
return Ontology(**json.loads(json.dumps(ONTO)))
def test_repo_ontology_is_valid_and_turtle_current():
path = REPO_ROOT / "ontology" / "know.yaml"
o = Ontology.from_file(path)
assert o.validate_model() == []
assert len(o.classes) >= 49 and "MailKategorie" in o.vocabularies
assert (REPO_ROOT / "ontology" / "know.ttl").read_text(encoding="utf-8") == to_turtle(o)
def test_model_validation_finds_errors(onto):
onto.relations.append(onto.relations[0].model_copy(update={"target": "Gibtsnicht"}))
onto.vocabularies["Kat"].concepts["sonstiges"].alt = ["Mahnung"]
onto.vocabularies["Kat"].columns.append("mail_events.fehlt")
onto.vocabularies["Kat"].rejected = ["rechnung"]
errors = "\n".join(onto.validate_model())
assert "Gibtsnicht" in errors and "„Mahnung“" in errors and "mail_events.fehlt" in errors
assert "zugleich verworfen" in errors
def test_vocabulary_resolve(onto):
v = onto.vocabularies["Kat"]
assert v.resolve("Buchhaltung") == "rechnung" and v.resolve("MAHNUNG") == "mahnung" and v.resolve("x") is None
def test_exports(onto):
ttl = to_turtle(onto)
assert "t:Mail a owl:Class" in ttl and "skos:altLabel \"Buchhaltung\"@de" in ttl
assert "t:hatKategorie a owl:ObjectProperty" in ttl
g = to_graph(onto, generated="2026-10-13")
mail = next(c for c in g["classes"] if c["id"] == "Mail")
assert "id" not in [a[0] for a in mail["attrs"]]
hint = next(c for c in g["classes"] if c["id"] == "Hinweis")
assert hint["enums"]["quality_status"] == ["observed", "proposed", "confirmed", "rejected"]
assert "Mahnung" in next(c for c in g["classes"] if c["id"] == "Kategorie")["individuals"]
def test_parse_know_code():
spec = parse_know_code(KNOW_CODE)
assert spec.allowed == {"setup_schema", "store_mail_event", "upsert_hint", "promote_hint", "find_mail"}
assert set(spec.schema["mail_events"]) == {"id", "message_id", "category", "sender", "received_at", "mood"}
assert spec.writes["store_mail_event"]["mail_events"] == {"message_id", "category", "sender"}
assert spec.writes["promote_hint"]["hints"] == {"quality_status"}
assert spec.seeds["config.config_key"] == {"mahnung", "default"}
assert "config" not in spec.writes.get("setup_schema", {}) # Seeds gehören zu keiner Operation
assert spec.assigned["upsert_hint"]["hints.quality_status"] == {"proposed"}
assert spec.assigned["promote_hint"]["hints.quality_status"] == {"confirmed"}
@pytest.mark.parametrize("expr,key,values,closed", [
(CAT_PAYLOAD, "category", {"rechnung", "bauvorhaben", "sonstiges"}, True),
(CAT_PAYLOAD, "sender", set(), False),
('{ category: "MPA", x: 1 }', "category", {"MPA"}, True),
("{ category: $json.cat || 'info' }", "category", {"info"}, False),
("={{ JSON.stringify({ x: { category: 'tief' } }) }}", "category", set(), False),
])
def test_literal_values_and_closed(expr, key, values, closed):
assert literal_values(expr, key) == values
assert is_closed(expr, key) is closed
def test_check_against_synthetic_n8n(onto):
snap = build_snapshot(WORKFLOWS)
assert snap.know and snap.know.workflow_id == "know1"
dyn = [c for c in snap.calls if c.workflow == "Dyn"]
assert [(c.operation, c.inferred) for c in dyn] == [("upsert_hint", True)]
findings = check(onto, snap)
by_kind = {}
for f in findings:
by_kind.setdefault(f.kind, []).append(f)
# unbekannte Operation: zwei Knoten → ein Befund
[unknown] = by_kind["unknown_operation"]
assert unknown.severity == "high" and "Log1, Log2" in unknown.detail
[sql] = by_kind["direct_sql"]
assert sql.severity == "medium" and "hints" in sql.summary
vocab = {f.change["value"]: f for f in by_kind["vocab_value"]}
assert set(vocab) == {"bauvorhaben", "MPA", "legacy_kat"}
assert vocab["legacy_kat"].severity == "low" and vocab["MPA"].severity == "medium"
# mahnung wird von keiner festen Zuordnung erzeugt, default ist technisch
[unused] = [f for f in by_kind["concept_without_producer"] if f.key == "unused:Kat"]
assert "mahnung" in unused.summary and "default" not in unused.summary
# Qualitätsstatus: proposed/confirmed setzt KNOW selbst (upsert_hint ist aktiv abgeleitet, promote_hint nicht)
qs = [f for f in by_kind.get("concept_without_producer", []) if f.key == "unused:QS"]
assert not qs # keine feste Zuordnung aus Workflows → keine Aussage
assert {f.change["column"] for f in by_kind["unmapped_column"]} == {"mood"}
assert by_kind["unmapped_table"][0].change["table"] == "orphans"
assert by_kind["missing_table"][0].key == "gone:removed_table"
assert "no_write_operation" not in by_kind # orphans ist keiner Klasse zugeordnet, config ist Seed-Tabelle
assert "nowriter:config" not in {f.key for f in findings} # Seed-Tabelle
# entschiedene bzw. ignorierte Schlüssel verschwinden
onto.ignore.append("table:orphans")
keys = {f.key for f in check(onto, snap, decided={"vocab:Kat:mpa"})}
assert "vocab:Kat:mpa" not in keys and "table:orphans" not in keys
def test_apply_decisions(onto):
decisions = [
{"key": "vocab:Kat:bauvorhaben", "decision": "accept",
"params": {"type": "vocab_value", "vocabulary": "Kat", "value": "Bauvorhaben"}},
{"key": "vocab:Kat:buchhaltg", "decision": "map",
"params": {"type": "vocab_value", "vocabulary": "Kat", "value": "Buchhaltg", "concept": "rechnung"}},
{"key": "vocab:Kat:mpa", "decision": "reject", "params": {"type": "vocab_value", "vocabulary": "Kat",
"value": "MPA"}},
{"key": "column:mail_events.mood", "decision": "accept",
"params": {"type": "add_attribute", "class": "Mail", "column": "mood", "sql_type": "TEXT"}},
{"key": "table:orphans", "decision": "accept",
"params": {"type": "add_class", "table": "orphans", "attributes": {"x": "TEXT"}}},
{"key": "sql:CAT:Mark", "decision": "reject", "params": {"type": "x"}},
{"key": "vocab:Kat:kaputt", "decision": "map",
"params": {"type": "vocab_value", "vocabulary": "Kat", "value": "k", "concept": "gibtsnicht"}},
]
applied, messages = apply_decisions(onto, decisions)
v = onto.vocabularies["Kat"]
assert v.resolve("Bauvorhaben") == "bauvorhaben" and v.resolve("Buchhaltg") == "rechnung"
assert v.is_rejected("mpa") and "mood" in onto.classes["Mail"].attributes
assert onto.classes["Orphans"].module == "neu" and "sql:CAT:Mark" in onto.ignore
assert "vocab:Kat:kaputt" not in applied and any("nicht übernommen" in m for m in messages)
assert onto.version == 2 and onto.validate_model() == []
def test_routine_proposes_and_remembers_decisions(make_system, tmp_path, onto):
path = tmp_path / "know.yaml"
onto.save(path)
sysm = make_system(ontology_path=path, ontology_source=lambda: WORKFLOWS)
m = sysm.maintenance
report = m.run_routine()
assert any("log_decision" in f for f in report.findings)
vocab = {p.params["value"]: p for p in report.proposals if p.kind == "ontology_change"
and p.params["type"] == "vocab_value"}
assert {"bauvorhaben", "MPA", "legacy_kat"} <= set(vocab)
assert all(p.state == "open" for p in vocab.values()) # Ontologie-Änderungen nie automatisch
# zuordnen, ablehnen, aufnehmen
assert "executed" in m.handle_command(f"ordne {vocab['legacy_kat'].id} sonstiges zu", user="robert").text
assert "rejected" in m.handle_command(f"lehne {vocab['MPA'].id} ab: Workflow-Name, keine Kategorie").text
assert "executed" in m.handle_command(f"genehmige {vocab['bauvorhaben'].id}").text
assert "Fehler" in m.handle_command("ordne P-999 sonstiges zu").text
decisions = {d["key"]: d for d in sysm.store.list_ontology_decisions()}
assert decisions["vocab:Kat:legacy_kat"]["decision"] == "map"
assert decisions["vocab:Kat:legacy_kat"]["decided_by"] == "robert"
assert decisions["vocab:Kat:mpa"]["decision"] == "reject"
# zweiter Lauf: entschiedene Werte kommen nicht wieder
again = m.run_routine()
values = {p.params.get("value") for p in again.proposals if p.kind == "ontology_change"}
assert not values & {"bauvorhaben", "MPA", "legacy_kat"}
# Übernahme in die Datei
pending = sysm.store.list_ontology_decisions(applied=False)
applied, _ = apply_decisions(onto, pending)
assert set(applied) == {"vocab:Kat:legacy_kat", "vocab:Kat:mpa", "vocab:Kat:bauvorhaben"}
assert onto.vocabularies["Kat"].resolve("legacy_kat") == "sonstiges"
def test_routine_survives_unreachable_n8n(make_system, tmp_path, onto):
path = tmp_path / "know.yaml"
onto.save(path)
def broken():
raise ConnectionError("n8n nicht erreichbar")
report = make_system(ontology_path=path, ontology_source=broken).maintenance.run_routine()
assert any("Ontologie-Abgleich nicht möglich" in f for f in report.findings)
def test_cli_check_with_snapshot(tmp_path, onto, monkeypatch, capsys):
from knaster.cli import main
path = tmp_path / "know.yaml"
onto.save(path)
snap = tmp_path / "wf.json"
snap.write_text(json.dumps({"data": WORKFLOWS}))
monkeypatch.setenv("KNASTER_DB_URL", "sqlite://")
assert main(["ontology", "--path", str(path), "check", "--snapshot", str(snap)]) == 1 # high-Befund
out = capsys.readouterr().out
assert "log_decision" in out and "Änderungsvorschläge" in out
assert main(["ontology", "--path", str(path), "validate"]) == 0
html = tmp_path / "o.html"
assert main(["ontology", "--path", str(path), "export", "--html", str(html),
"--template", str(REPO_ROOT / "ontology" / "viewer.html.tmpl")]) == 0
assert "__DATA__" not in html.read_text() and "owl:Class" in html.read_text()
+3
View File
@@ -16,3 +16,6 @@ PROXMOX_URL=
PROXMOX_TOKEN_ID= PROXMOX_TOKEN_ID=
PROXMOX_TOKEN_SECRET= PROXMOX_TOKEN_SECRET=
KNASTER_WEBHOOK_AUTH=Bearer change-me KNASTER_WEBHOOK_AUTH=Bearer change-me
# Ontologie-Abgleich (nur lesend: GET /api/v1/workflows); leer = Abgleich aus
N8N_HOST=
N8N_API_KEY=
+2
View File
@@ -13,10 +13,12 @@ COPY config config
COPY catalog catalog COPY catalog catalog
COPY skills skills COPY skills skills
COPY sites sites COPY sites sites
COPY ontology ontology
ENV KNASTER_CONFIG_DIR=/app/config \ ENV KNASTER_CONFIG_DIR=/app/config \
KNASTER_SKILLS_DIR=/app/skills \ KNASTER_SKILLS_DIR=/app/skills \
KNASTER_SITE_DIR=/app/sites/knaster \ KNASTER_SITE_DIR=/app/sites/knaster \
KNASTER_ONTOLOGY=/app/ontology/know.yaml \
KNASTER_DRY_RUN=true KNASTER_DRY_RUN=true
RUN useradd --system --uid 10001 knaster RUN useradd --system --uid 10001 knaster
+30
View File
@@ -71,3 +71,33 @@ Freitext, der keinem Befehl entspricht, bildet ein kleines Modell auf einen Befe
| Skill-Stufe | Prompt `stufe <skill> zurück` bzw. Git-Revert der SKILL.md | | Skill-Stufe | Prompt `stufe <skill> zurück` bzw. Git-Revert der SKILL.md |
| Treiber/Engine | Proxmox-Snapshot der GPU-VM (vor jedem Update anlegen) | | Treiber/Engine | Proxmox-Snapshot der GPU-VM (vor jedem Update anlegen) |
| Konfiguration | Git-Revert und Neustart des Router-Containers | | Konfiguration | Git-Revert und Neustart des Router-Containers |
## 6. Ontologie pflegen
Die Ontologie (`ontology/know.yaml`) beschreibt die Begriffe der Datenhaltung. Details in `docs/spec/SPEC-ontology.md`.
- **Kern** (Klassen, Beziehungen) ändert sich nur per Commit.
- **Vokabulare** wachsen über die Service-Routine. Sie gleicht n8n nur lesend mit der Ontologie ab und schlägt neue Werte vor:
```
> routine
Befunde:
- Ontologie [high]: KAN - Kanbau API Gateway ruft `get_kanbau_project_patterns` auf, die Operation gibt es in KNOW nicht
Offene Vorschläge:
- P-21 [low] Mail-Kategorie: neuer Wert „bauvorhaben“
- P-22 [low] Mail-Kategorie: neuer Wert „MPA“
> genehmige P-21 → neues Konzept
> ordne P-21 sonstiges zu → „bauvorhaben“ wird Schreibweise von „sonstiges“
> lehne P-22 ab: Workflow-Name → Wert wird verworfen und nicht wieder vorgeschlagen
```
Danach im Repo übernehmen, z. B. wöchentlich:
```
knaster ontology apply # Entscheidungen → know.yaml, Version +1
knaster ontology export --ttl ontology/know.ttl
git commit -am "ontology: Entscheidungen übernommen"
```
Ohne `N8N_HOST`/`N8N_API_KEY` (oder `KNASTER_ONTOLOGY_SNAPSHOT`) ist der Abgleich aus. Fällt n8n aus, läuft die Routine weiter und meldet das.
+3 -1
View File
@@ -18,6 +18,7 @@ Vorgehen pro Baustein: **Spec → Implementierung → Tests → Commit**. Bis zu
| 10 | Plattform | ADR-0001/0002 | OpenTofu (bpg/proxmox), Ansible-Rollen (IOMMU/VFIO, ZFS-Replikation, Treiber-Pinning, Xid-Exporter) | ✅ geschrieben, **nicht** gegen echten Cluster/`tofu validate` geprüft | | 10 | Plattform | ADR-0001/0002 | OpenTofu (bpg/proxmox), Ansible-Rollen (IOMMU/VFIO, ZFS-Replikation, Treiber-Pinning, Xid-Exporter) | ✅ geschrieben, **nicht** gegen echten Cluster/`tofu validate` geprüft |
| 11 | CI | – | Gitea Actions: Tests, Lint, Konfig-Validierung (Runner `gitrunner-knaster`) | ✅ | | 11 | CI | – | Gitea Actions: Tests, Lint, Konfig-Validierung (Runner `gitrunner-knaster`) | ✅ |
| 12 | TTS + Legacy-GPUs | ADR-0006 | Engine-Grenzen im Katalog, deaktivierte GPUs, geteilte GPU-Rollen, TTS-Slots, Piper-Dienst, TTS-Canary, `knaster bench tts` | ✅ | | 12 | TTS + Legacy-GPUs | ADR-0006 | Engine-Grenzen im Katalog, deaktivierte GPUs, geteilte GPU-Rollen, TTS-Slots, Piper-Dienst, TTS-Canary, `knaster bench tts` | ✅ |
| 13 | Ontologie der Datenhaltung | SPEC-ontology | `ontology/know.yaml` als Quelle, Export (Turtle/SKOS, Graph, Ansicht), Abgleich mit n8n/KNOW (nur lesend), Vorschläge `ontology_change` in der Service-Routine, `knaster ontology apply` | ✅ gegen echten n8n-Export geprüft (24 Befunde, 4 Vokabel-Vorschläge) |
## Teststrategie ## Teststrategie
@@ -30,11 +31,12 @@ Vorgehen pro Baustein: **Spec → Implementierung → Tests → Commit**. Bis zu
| Was | Wie geprüft | | Was | Wie geprüft |
|---|---| |---|---|
| Kernlogik (Router, Eskalation, Verifier, Pflege-Agent, Healer, Lifecycle, Placement) | 136 Unit- und Integrationstests (`make test`) | | Kernlogik (Router, Eskalation, Verifier, Pflege-Agent, Healer, Lifecycle, Placement) | 152 Unit- und Integrationstests (`make test`) |
| Konfiguration (Registry ↔ Skills ↔ LiteLLM ↔ Healer ↔ Policy ↔ Katalog) | `knaster validate` | | Konfiguration (Registry ↔ Skills ↔ LiteLLM ↔ Healer ↔ Policy ↔ Katalog) | `knaster validate` |
| Compose-Dateien | `docker compose config` (`make compose-check`) | | Compose-Dateien | `docker compose config` (`make compose-check`) |
| Prometheus/Alertmanager/n8n-JSON | Strukturtests (`core/tests/test_artifacts.py`) | | Prometheus/Alertmanager/n8n-JSON | Strukturtests (`core/tests/test_artifacts.py`) |
| n8n, Portainer | nur lesender Erreichbarkeitstest, nichts deployt | | n8n, Portainer | nur lesender Erreichbarkeitstest, nichts deployt |
| Ontologie ↔ n8n | `knaster ontology check` gegen einen lesend abgerufenen Export aller Workflows (13.10.); Tests mit synthetischen Workflows |
| OpenTofu, Ansible | nur YAML/Syntax. `tofu validate` und `ansible-lint` stehen noch aus | | OpenTofu, Ansible | nur YAML/Syntax. `tofu validate` und `ansible-lint` stehen noch aus |
## Offene Punkte vor dem Deployment ## Offene Punkte vor dem Deployment
+25 -1
View File
@@ -11,7 +11,7 @@ Jeder Befund wurde am Code nachgeprüft, Artefakte der automatischen Auswertung
- Übersicht der Tabellen: [KNOW-schema.md](KNOW-schema.md) - Übersicht der Tabellen: [KNOW-schema.md](KNOW-schema.md)
- Interaktive Ansichten (privat): - Interaktive Ansichten (privat):
- Datenfluss: https://claude.ai/artifact/GP8FfU8M6Wk9hPRmJuk96h - Datenfluss: https://claude.ai/artifact/GP8FfU8M6Wk9hPRmJuk96h
- Ontologie: https://claude.ai/artifact/R7zxBJsCKkkCWdCcztLFUT (OWL/Turtle: [know-ontology.ttl](know-ontology.ttl)) - Ontologie: https://claude.ai/artifact/R7zxBJsCKkkCWdCcztLFUT (Quelle: [ontology/know.yaml](../../ontology/know.yaml))
Status aller Befunde: **offen**. In n8n wurde nichts geändert. Status aller Befunde: **offen**. In n8n wurde nichts geändert.
@@ -47,6 +47,9 @@ Status aller Befunde: **offen**. In n8n wurde nichts geändert.
| B28 | Zustand in staticData von 9 Workflows, außerhalb von KNOW | mittel | | B28 | Zustand in staticData von 9 Workflows, außerhalb von KNOW | mittel |
| B29 | Browser-Sessions (Cookies, Header) unverschlüsselt gespeichert | hoch (Sicherheit) | | B29 | Browser-Sessions (Cookies, Header) unverschlüsselt gespeichert | hoch (Sicherheit) |
| B30 | LLM-Aufrufe am PSE-LiteLLM vorbei, u. a. Kaufvertragstexte | hoch (Datenschutz) | | B30 | LLM-Aufrufe am PSE-LiteLLM vorbei, u. a. Kaufvertragstexte | hoch (Datenschutz) |
| B31 | Zwei Wörter für „bestätigt“: `verified` vs. `confirmed` | niedrig |
| B32 | KAN-Gateway ruft `get_kanbau_project_patterns` auf, die es nicht gibt | hoch |
| B33 | MPA speichert jedes Projektereignis als `info`/`normal` | hoch |
## B1 – CANCEL protokolliert ins Leere ## B1 – CANCEL protokolliert ins Leere
- **Beleg:** `CANCEL - Unsubscribe Link` (aktiv) ruft in 4 Knoten (`KNOW: Log Erfolg`, `… Fehler`, `… Duplikat-Erfolg`, `… Dry Run`) `log_workflow_decision` auf. Diese Operation steht nicht in der Liste von KNOW, KNOW wirft `Nicht erlaubte KNOW operation`. Alle 4 Knoten haben „Continue on Fail“. - **Beleg:** `CANCEL - Unsubscribe Link` (aktiv) ruft in 4 Knoten (`KNOW: Log Erfolg`, `… Fehler`, `… Duplikat-Erfolg`, `… Dry Run`) `log_workflow_decision` auf. Diese Operation steht nicht in der Liste von KNOW, KNOW wirft `Nicht erlaubte KNOW operation`. Alle 4 Knoten haben „Continue on Fail“.
@@ -312,3 +315,24 @@ Bei einem vorhandenen Datensatz aktualisieren mehrere Upserts nur wenige Spalten
- KNOW maskiert alle Werte über `sqlString`/`sqlJson`. Bei den Operationen gibt es keine offene String-Verkettung. Die Ausnahme ist der direkte SQL-Knoten in CAT (B4). - KNOW maskiert alle Werte über `sqlString`/`sqlJson`. Bei den Operationen gibt es keine offene String-Verkettung. Die Ausnahme ist der direkte SQL-Knoten in CAT (B4).
- KGW prüft HMAC und Zeitfenster und lässt nur eine feste Liste von Operationen zu. - KGW prüft HMAC und Zeitfenster und lässt nur eine feste Liste von Operationen zu.
- Die vermeintlich fehlende Spalte `workflow_decisions.decided_by` existiert (per `ALTER` ergänzt). - Die vermeintlich fehlende Spalte `workflow_decisions.decided_by` existiert (per `ALTER` ergänzt).
---
# Gefunden mit `knaster ontology check` (13.10.)
Diese Befunde hat der automatische Abgleich gefunden, nachdem die Ontologie im Repo als Quelle angelegt war (SPEC-ontology).
### B31 – Zwei Wörter für „bestätigt“
- **Beleg:** `record_workflow_decision` setzt `quality_status` auf `verified`. Alle anderen Tabellen und Operationen nutzen `confirmed`.
- **Folge:** Auswertungen über `quality_status = 'confirmed'` übersehen bestätigte Entscheidungen.
- **Stand in der Ontologie:** `verified` ist als alternative Schreibweise von `confirmed` im Vokabular *Qualitätsstatus* eingetragen. Vorschlag: in KNOW auf `confirmed` vereinheitlichen.
### B32 – KAN-Gateway ruft eine Operation auf, die es nicht gibt
- **Beleg:** `KAN - Kanbau API Gateway` (aktiv) ruft `get_kanbau_project_patterns` auf. Die Operation steht nicht in der Liste von KNOW. Das Gegenstück `upsert_kanbau_project_patterns` in KAN-Learn fehlt ebenso (B2).
- **Folge:** Gelernte Projektmuster für KanBau werden weder gespeichert noch gelesen. Je nach Fehlerbehandlung des Knotens bricht das Gateway an dieser Stelle ab.
- **Vorschlag:** die beiden Operationen samt Zieltabelle in KNOW anlegen (und dann in der Ontologie aufnehmen) oder die Aufrufe entfernen.
### B33 – Projektereignisse ohne Einordnung
- **Beleg:** `MPA - Projekt-Ablage` ruft `upsert_project_event` mit den festen Werten `category: "info"` und `urgency: "normal"` auf.
- **Folge:** Die Ereignisarten `verzoegerung`, `mangel`, `termin` und `kosten` (CHECK-Constraint der Tabelle) kommen nie vor, ebenso keine Dringlichkeit `high`. Eine Auswertung „Mängel pro Projekt“ ist mit diesen Daten nicht möglich.
- **Vorschlag:** Kategorie und Dringlichkeit aus der Mail-Analyse übernehmen (MAIL bzw. CAT kennen „Mangel“) oder ein kleines Modell bzw. einen knaster-Skill einordnen lassen.
+2 -2
View File
@@ -3,9 +3,9 @@
Stand: 13.10.2026. Abgeleitet aus den Workflow-Definitionen auf n8n.mindboost.app, gelesen über die n8n-API. Inhalte der Datenbank wurden nicht gelesen. Stand: 13.10.2026. Abgeleitet aus den Workflow-Definitionen auf n8n.mindboost.app, gelesen über die n8n-API. Inhalte der Datenbank wurden nicht gelesen.
Interaktive Ansichten (privat, Freigabe über das Share-Menü): Interaktive Ansichten (privat, Freigabe über das Share-Menü):
- Datenfluss: https://claude.ai/artifact/GP8FfU8M6Wk9hPRmJuk96h - Datenfluss: https://claude.ai/artifact/GP8FfU8M6Wk9hPRmJuk96h
- Ontologie: https://claude.ai/artifact/R7zxBJsCKkkCWdCcztLFUT (als OWL/Turtle: [know-ontology.ttl](know-ontology.ttl)) - Ontologie: https://claude.ai/artifact/R7zxBJsCKkkCWdCcztLFUT (Quelle: [ontology/know.yaml](../../ontology/know.yaml), Turtle: [ontology/know.ttl](../../ontology/know.ttl))
Befunde mit Belegen, Folgen und Vorschlägen: [KNOW-befunde.md](KNOW-befunde.md) (B1–B30) Befunde mit Belegen, Folgen und Vorschlägen: [KNOW-befunde.md](KNOW-befunde.md) (B1–B33)
## Aufbau ## Aufbau
+100
View File
@@ -0,0 +1,100 @@
# SPEC-ontology: Ontologie der Datenhaltung
Paket: `knaster.ontology`. Quelle: `ontology/know.yaml`. CLI: `knaster ontology …`.
Die Ontologie beschreibt, *was* in der Datenhaltung steht (Projekt, Kontakt, Ankaufsfall, Mail …) und wie die Begriffe zusammenhängen. Tabellen sind nur die Ablage dahinter. Sie ist die eine Quelle der Wahrheit; Turtle, Graph und Ansicht werden daraus erzeugt, nie von Hand gepflegt.
## 1. Drei Ebenen, drei Pflegeregeln
| Ebene | Inhalt in `know.yaml` | Wie sie sich ändert |
|---|---|---|
| Kern | `classes`, `relations`, `modules` | bewusst, per Commit (Review wie Code) |
| Vokabulare | `vocabularies.*.concepts`, `alt`, `rejected` | wachsen über Vorschläge der Service-Routine → Freigabe → `knaster ontology apply` |
| Instanzen | – (stehen in den Daten) | automatisch; unsichere Verknüpfungen später ebenfalls über Vorschläge |
Ohne diese Regeln zerfällt ein Vokabular wieder in mehrere Listen (KNOW-Befund B20). Deshalb legt nie ein Werkzeug oder LLM selbstständig Klassen oder Konzepte an; es schlägt sie vor.
## 2. Modell (`know.yaml`)
```yaml
id: know
version: 3 # steigt bei jeder Übernahme von Entscheidungen
namespace: urn:mygodel:know#
modules: {mail: Mail & Absender, …}
classes:
Mail:
label: Mail
module: mail
table: mail_events # fehlt = implizite Klasse (nur Wert in Spalten) oder externes System
key: message_id
definition: …
sensitive: … # steuert Datenschutz-Regeln (geplant: PSE/lokal)
subclass_of: Dokument # optional
attributes: {message_id: TEXT, …} # Spalten der Tabelle, Abbild des KNOW-Schemas
relations:
- {source: Mail, name: zugeordnetZu, target: Projekt, via: mail_events.assigned_project_key, kind: key}
# kind: key (gemeinsamer Schlüssel) | soft (Wertabgleich) | external (ID in KanBau/SharePoint/M365)
vocabularies:
MailKategorie:
label: Mail-Kategorie
class: MailKategorie # optional: Konzepte sind Ausprägungen dieser Klasse
columns: [mail_events.category, workflow_hints.hint_type, workflow_hint_config.config_key]
concepts:
buchhaltung: {label: Rechnungen, alt: [Buchhaltung]}
default: {label: Standard, technical: true}
rejected: [MPA] # bewusst kein Konzept
ignore: [sql:ASP - Absender-Report:…] # bewusst hingenommene Befunde
```
`Ontology.validate_model()` prüft: Module, Oberklassen und Beziehungsenden existieren; jede Tabelle gehört zu höchstens einer Klasse; Vokabel-Spalten existieren als Attribut; kein Name zeigt auf zwei Konzepte; kein verworfener Wert ist zugleich Konzept. `knaster validate` prüft das in der CI und warnt, wenn `know.ttl` nicht zur YAML passt.
## 3. Abgleich mit n8n (`check`)
Eingabe ist eine Momentaufnahme der n8n-Workflows (`build_snapshot`, nur GET `/api/v1/workflows` bzw. eine JSON-Datei):
- **KNOW-Spezifikation** aus dem Code des KNOW-Workflows: erlaubte Operationen (`const allowed`), Schema (`CREATE TABLE`, `ALTER … ADD COLUMN`), pro Operation geschriebene Spalten und dabei gesetzte Literale, Seeds (reine Literal-INSERTs).
- **Aufrufe** von KNOW mit Operation und Payload-Ausdruck. Wird die Operation erst zur Laufzeit gesetzt, werden Kandidaten aus den Literalen des Workflows abgeleitet (`inferred`).
- **Direktes SQL**: Postgres-Knoten außerhalb von KNOW, die das Schema `knowledge` ansprechen.
Ergebnisse (`OntologyFinding`, sortiert nach Schwere):
| Art | Schwere | Vorschlag? |
|---|---|---|
| `unmapped_table` – Tabelle ohne Klasse | medium | ja: `add_class` |
| `unmapped_column` – Spalte ohne Attribut | low | ja: `add_attribute` |
| `vocab_value` – Wert in einer Vokabel-Spalte, der kein Konzept ist | medium (low, wenn nur aus inaktiven Workflows) | ja: aufnehmen, zuordnen oder verwerfen |
| `unknown_operation` – Aufruf einer Operation, die KNOW nicht kennt | high (aktiv) / low | nein, Befund |
| `direct_sql` – SQL an KNOW vorbei | medium (schreibend) / low | nein |
| `missing_table` / `missing_column` – Ontologie verweist auf etwas, das KNOW nicht anlegt | medium / low | nein |
| `concept_without_producer` – Konzept, das keine *feste* Zuordnung erzeugt | info | nein |
| `no_write_operation` / `no_active_writer` – Klasse ohne Schreibweg | info | nein |
Werte werden nur aus Schlüsseln der obersten Payload-Ebene gelesen (verschachtelte JSON-Felder gehören zu einer JSON-Spalte). „Fest“ heißt: Literal oder Zuordnungstabelle mit Literal-Rückfall; bei freien Werten (LLM-Ausgabe) trifft die Prüfung keine Aussage. Entschiedene Schlüssel (Tabelle `ontology_decisions`) und `ignore` werden übersprungen.
## 4. Service-Routine und Freigabe
Schritt 8 der Routine (`MaintenanceAgent.run_routine`) ruft den Abgleich auf, wenn `ontology_path` existiert und eine Quelle konfiguriert ist (`KNASTER_ONTOLOGY_SNAPSHOT` oder `N8N_HOST` + `N8N_API_KEY`):
- Befunde ohne Vorschlag erscheinen als Zeilen „Ontologie [schwere]: …“ (höchstens `ontology_max_findings`, Standard 15).
- Befunde mit Vorschlag werden zu Vorschlägen der Art `ontology_change` (Richtlinie: immer Freigabe, Risiko niedrig), dedupliziert über `ontology:<key>`.
- `genehmige P-x` → Entscheidung `accept`; `ordne P-x <konzept> zu` → `map` (Wert wird alternative Schreibweise des Konzepts); `lehne P-x ab: …` → `reject` (Wert landet in `rejected` bzw. der Schlüssel in `ignore`).
- Entscheidungen liegen in `ontology_decisions`; ein entschiedener Schlüssel wird nicht erneut vorgeschlagen.
- `knaster ontology apply` schreibt offene Entscheidungen in `know.yaml`, erhöht `version`, prüft das Ergebnis und markiert die Entscheidungen als übernommen. Danach `knaster ontology export --ttl ontology/know.ttl` und committen.
Fällt n8n aus, meldet die Routine „Ontologie-Abgleich nicht möglich“ und läuft weiter.
## 5. CLI
| Befehl | Zweck |
|---|---|
| `knaster ontology validate` | Modell prüfen |
| `knaster ontology export --ttl F --graph F --html F` | Turtle (OWL + SKOS), Graph-JSON, eigenständige Ansicht (`ontology/viewer.html.tmpl`) |
| `knaster ontology snapshot --out F` | Workflows lesen und speichern (enthält Workflow-Code, nicht committen) |
| `knaster ontology check [--snapshot F] [--json]` | Abgleich; Exit 1 bei Befunden der Schwere high |
| `knaster ontology apply [--dry-run]` | freigegebene Entscheidungen übernehmen |
## 6. Grenzen und nächste Schritte
- Die Prüfung liest Code, keine Daten: ob Tabellen tatsächlich leer sind, zeigt erst eine Zählung in der Datenbank.
- Weiche Bezüge (z. B. Absender ↔ Kontakt) werden noch nicht automatisch als Verknüpfungsvorschläge erzeugt; geplant über Embeddings mit Schwelle (Instanz-Ebene).
- Aus der Ontologie sollen erzeugt werden: Sichten pro Klasse („Projekt 360°“), JSON-Schemas für Skill-Ausgaben, Werkzeugkatalog für Hermes/NEX, Datenschutz-Regeln aus `sensitive`.
@@ -2,11 +2,12 @@
@prefix owl: <http://www.w3.org/2002/07/owl#> . @prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> . @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> . @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> . @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
<urn:mygodel:know> a owl:Ontology ; <urn:mygodel:know> a owl:Ontology ;
rdfs:label "KNOW-Ontologie"@de ; rdfs:label "KNOW-Ontologie"@de ;
rdfs:comment "Abgeleitet aus dem Postgres-Schema knowledge (n8n KNOW, Stand 2026-10-13). Spalten, über die eine Beziehung läuft, stehen in know:viaSpalte."@de . owl:versionInfo "1" .
know:quelleTabelle a owl:AnnotationProperty . know:quelleTabelle a owl:AnnotationProperty .
know:viaSpalte a owl:AnnotationProperty . know:viaSpalte a owl:AnnotationProperty .
@@ -650,12 +651,8 @@ know:confirmation_token_hash a owl:DatatypeProperty ;
rdfs:range xsd:string . rdfs:range xsd:string .
know:confirmed_at a owl:DatatypeProperty ; know:confirmed_at a owl:DatatypeProperty ;
rdfs:domain [ a owl:Class ; owl:unionOf ( know:Ankaufsfall know:Hinweis ) ] ; rdfs:domain know:Hinweis ;
rdfs:range xsd:string . rdfs:range xsd:dateTime .
know:confirmed_by a owl:DatatypeProperty ;
rdfs:domain know:Ankaufsfall ;
rdfs:range xsd:string .
know:contact_broker a owl:DatatypeProperty ; know:contact_broker a owl:DatatypeProperty ;
rdfs:domain know:Ankaufsfall ; rdfs:domain know:Ankaufsfall ;
@@ -1505,119 +1502,405 @@ know:zip a owl:DatatypeProperty ;
rdfs:domain know:Projekt ; rdfs:domain know:Projekt ;
rdfs:range xsd:string . rdfs:range xsd:string .
know:Gesellschaft_P_GmbH a owl:NamedIndividual, know:Gesellschaft ; know:MailKategorieSchema a skos:ConceptScheme ;
rdfs:label "P-GmbH" . rdfs:label "Mail-Kategorie"@de .
know:Gesellschaft_PplusB_KG a owl:NamedIndividual, know:Gesellschaft ; know:MailKategorie_mahnung a skos:Concept, know:MailKategorie ;
rdfs:label "P+B KG" . skos:inScheme know:MailKategorieSchema ;
skos:notation "mahnung" ;
skos:prefLabel "Mahnung"@de .
know:MailKategorie_mahnung a owl:NamedIndividual, know:MailKategorie ; know:MailKategorie_zahlungsaufforderung a skos:Concept, know:MailKategorie ;
rdfs:label "mahnung" . skos:inScheme know:MailKategorieSchema ;
skos:notation "zahlungsaufforderung" ;
skos:prefLabel "Zahlungsaufforderung"@de .
know:MailKategorie_zahlungsaufforderung a owl:NamedIndividual, know:MailKategorie ; know:MailKategorie_buchhaltung a skos:Concept, know:MailKategorie ;
rdfs:label "zahlungsaufforderung" . skos:inScheme know:MailKategorieSchema ;
skos:notation "buchhaltung" ;
skos:prefLabel "Rechnungen"@de ;
skos:altLabel "Buchhaltung"@de .
know:MailKategorie_buchhaltung a owl:NamedIndividual, know:MailKategorie ; know:MailKategorie_mangel a skos:Concept, know:MailKategorie ;
rdfs:label "buchhaltung" . skos:inScheme know:MailKategorieSchema ;
skos:notation "mangel" ;
skos:prefLabel "Mangel"@de .
know:MailKategorie_mangel a owl:NamedIndividual, know:MailKategorie ; know:MailKategorie_newsletter_werbung a skos:Concept, know:MailKategorie ;
rdfs:label "mangel" . skos:inScheme know:MailKategorieSchema ;
skos:notation "newsletter_werbung" ;
skos:prefLabel "Newsletter"@de ;
skos:altLabel "Newsletter und Werbung"@de .
know:MailKategorie_newsletter_werbung a owl:NamedIndividual, know:MailKategorie ; know:MailKategorie_plattformen a skos:Concept, know:MailKategorie ;
rdfs:label "newsletter_werbung" . skos:inScheme know:MailKategorieSchema ;
skos:notation "plattformen" ;
skos:prefLabel "Plattformen"@de .
know:MailKategorie_plattformen a owl:NamedIndividual, know:MailKategorie ; know:MailKategorie_versand_bestellungen a skos:Concept, know:MailKategorie ;
rdfs:label "plattformen" . skos:inScheme know:MailKategorieSchema ;
skos:notation "versand_bestellungen" ;
skos:prefLabel "Versand"@de ;
skos:altLabel "Versand und Bestellungen"@de .
know:MailKategorie_versand_bestellungen a owl:NamedIndividual, know:MailKategorie ; know:MailKategorie_sonstiges a skos:Concept, know:MailKategorie ;
rdfs:label "versand_bestellungen" . skos:inScheme know:MailKategorieSchema ;
skos:notation "sonstiges" ;
skos:prefLabel "Sonstige"@de ;
skos:altLabel "Sonstiges"@de .
know:MailKategorie_sonstiges a owl:NamedIndividual, know:MailKategorie ; know:MailKategorie_default a skos:Concept, know:MailKategorie ;
rdfs:label "sonstiges" . skos:inScheme know:MailKategorieSchema ;
skos:notation "default" ;
skos:prefLabel "Standard"@de ;
skos:scopeNote "technischer Rückfall der Hinweis-Konfiguration, keine fachliche Kategorie"@de .
know:MailKategorie_default a owl:NamedIndividual, know:MailKategorie ; know:EreignisartSchema a skos:ConceptScheme ;
rdfs:label "default" . rdfs:label "Ereignisart"@de .
know:Tier_basic a owl:NamedIndividual, know:Tier ; know:Ereignisart_verzoegerung a skos:Concept, know:Ereignisart ;
rdfs:label "basic" . skos:inScheme know:EreignisartSchema ;
skos:notation "verzoegerung" ;
skos:prefLabel "Verzögerung"@de .
know:Tier_standard a owl:NamedIndividual, know:Tier ; know:Ereignisart_mangel a skos:Concept, know:Ereignisart ;
rdfs:label "standard" . skos:inScheme know:EreignisartSchema ;
skos:notation "mangel" ;
skos:prefLabel "Mangel"@de .
know:Tier_capable a owl:NamedIndividual, know:Tier ; know:Ereignisart_termin a skos:Concept, know:Ereignisart ;
rdfs:label "capable" . skos:inScheme know:EreignisartSchema ;
skos:notation "termin" ;
skos:prefLabel "Termin"@de .
know:Tier_expert a owl:NamedIndividual, know:Tier ; know:Ereignisart_kosten a skos:Concept, know:Ereignisart ;
rdfs:label "expert" . skos:inScheme know:EreignisartSchema ;
skos:notation "kosten" ;
skos:prefLabel "Kosten"@de .
know:Ereignisart_verzoegerung a owl:NamedIndividual, know:Ereignisart ; know:Ereignisart_info a skos:Concept, know:Ereignisart ;
rdfs:label "verzoegerung" . skos:inScheme know:EreignisartSchema ;
skos:notation "info" ;
skos:prefLabel "Info"@de .
know:Ereignisart_mangel a owl:NamedIndividual, know:Ereignisart ; know:DokumenttypSchema a skos:ConceptScheme ;
rdfs:label "mangel" . rdfs:label "Dokumenttyp"@de .
know:Ereignisart_termin a owl:NamedIndividual, know:Ereignisart ; know:Dokumenttyp_expose a skos:Concept, know:Dokumenttyp ;
rdfs:label "termin" . skos:inScheme know:DokumenttypSchema ;
skos:notation "expose" ;
skos:prefLabel "expose"@de .
know:Ereignisart_kosten a owl:NamedIndividual, know:Ereignisart ; know:Dokumenttyp_bplan a skos:Concept, know:Dokumenttyp ;
rdfs:label "kosten" . skos:inScheme know:DokumenttypSchema ;
skos:notation "bplan" ;
skos:prefLabel "bplan"@de .
know:Ereignisart_info a owl:NamedIndividual, know:Ereignisart ; know:Dokumenttyp_wirtschaftlichkeitsberechnung a skos:Concept, know:Dokumenttyp ;
rdfs:label "info" . skos:inScheme know:DokumenttypSchema ;
skos:notation "wirtschaftlichkeitsberechnung" ;
skos:prefLabel "wirtschaftlichkeitsberechnung"@de .
know:Dokumenttyp_expose a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_kaufangebot a skos:Concept, know:Dokumenttyp ;
rdfs:label "expose" . skos:inScheme know:DokumenttypSchema ;
skos:notation "kaufangebot" ;
skos:prefLabel "kaufangebot"@de .
know:Dokumenttyp_bplan a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_loi a skos:Concept, know:Dokumenttyp ;
rdfs:label "bplan" . skos:inScheme know:DokumenttypSchema ;
skos:notation "loi" ;
skos:prefLabel "loi"@de .
know:Dokumenttyp_wirtschaftlichkeitsberechnung a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_nda a skos:Concept, know:Dokumenttyp ;
rdfs:label "wirtschaftlichkeitsberechnung" . skos:inScheme know:DokumenttypSchema ;
skos:notation "nda" ;
skos:prefLabel "nda"@de .
know:Dokumenttyp_kaufangebot a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_vertraulichkeitserklaerung a skos:Concept, know:Dokumenttyp ;
rdfs:label "kaufangebot" . skos:inScheme know:DokumenttypSchema ;
skos:notation "vertraulichkeitserklaerung" ;
skos:prefLabel "vertraulichkeitserklaerung"@de .
know:Dokumenttyp_loi a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_telefonnotiz a skos:Concept, know:Dokumenttyp ;
rdfs:label "loi" . skos:inScheme know:DokumenttypSchema ;
skos:notation "telefonnotiz" ;
skos:prefLabel "telefonnotiz"@de .
know:Dokumenttyp_nda a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_mail a skos:Concept, know:Dokumenttyp ;
rdfs:label "nda" . skos:inScheme know:DokumenttypSchema ;
skos:notation "mail" ;
skos:prefLabel "mail"@de .
know:Dokumenttyp_vertraulichkeitserklaerung a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_lageplan a skos:Concept, know:Dokumenttyp ;
rdfs:label "vertraulichkeitserklaerung" . skos:inScheme know:DokumenttypSchema ;
skos:notation "lageplan" ;
skos:prefLabel "lageplan"@de .
know:Dokumenttyp_telefonnotiz a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_kataster a skos:Concept, know:Dokumenttyp ;
rdfs:label "telefonnotiz" . skos:inScheme know:DokumenttypSchema ;
skos:notation "kataster" ;
skos:prefLabel "kataster"@de .
know:Dokumenttyp_mail a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_grundbuch a skos:Concept, know:Dokumenttyp ;
rdfs:label "mail" . skos:inScheme know:DokumenttypSchema ;
skos:notation "grundbuch" ;
skos:prefLabel "grundbuch"@de .
know:Dokumenttyp_lageplan a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_baugenehmigung a skos:Concept, know:Dokumenttyp ;
rdfs:label "lageplan" . skos:inScheme know:DokumenttypSchema ;
skos:notation "baugenehmigung" ;
skos:prefLabel "baugenehmigung"@de .
know:Dokumenttyp_kataster a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_gutachten a skos:Concept, know:Dokumenttyp ;
rdfs:label "kataster" . skos:inScheme know:DokumenttypSchema ;
skos:notation "gutachten" ;
skos:prefLabel "gutachten"@de .
know:Dokumenttyp_grundbuch a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_pruefplan a skos:Concept, know:Dokumenttyp ;
rdfs:label "grundbuch" . skos:inScheme know:DokumenttypSchema ;
skos:notation "pruefplan" ;
skos:prefLabel "pruefplan"@de .
know:Dokumenttyp_baugenehmigung a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_fotos a skos:Concept, know:Dokumenttyp ;
rdfs:label "baugenehmigung" . skos:inScheme know:DokumenttypSchema ;
skos:notation "fotos" ;
skos:prefLabel "fotos"@de .
know:Dokumenttyp_gutachten a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_wettbewerbsunterlagen a skos:Concept, know:Dokumenttyp ;
rdfs:label "gutachten" . skos:inScheme know:DokumenttypSchema ;
skos:notation "wettbewerbsunterlagen" ;
skos:prefLabel "wettbewerbsunterlagen"@de .
know:Dokumenttyp_pruefplan a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_vertrag_notar a skos:Concept, know:Dokumenttyp ;
rdfs:label "pruefplan" . skos:inScheme know:DokumenttypSchema ;
skos:notation "vertrag_notar" ;
skos:prefLabel "vertrag_notar"@de .
know:Dokumenttyp_fotos a owl:NamedIndividual, know:Dokumenttyp ; know:Dokumenttyp_unknown a skos:Concept, know:Dokumenttyp ;
rdfs:label "fotos" . skos:inScheme know:DokumenttypSchema ;
skos:notation "unknown" ;
skos:prefLabel "unknown"@de .
know:Dokumenttyp_wettbewerbsunterlagen a owl:NamedIndividual, know:Dokumenttyp ; know:TierSchema a skos:ConceptScheme ;
rdfs:label "wettbewerbsunterlagen" . rdfs:label "Tier"@de .
know:Dokumenttyp_vertrag_notar a owl:NamedIndividual, know:Dokumenttyp ; know:Tier_basic a skos:Concept, know:Tier ;
rdfs:label "vertrag_notar" . skos:inScheme know:TierSchema ;
skos:notation "basic" ;
skos:prefLabel "basic"@de .
know:Dokumenttyp_unknown a owl:NamedIndividual, know:Dokumenttyp ; know:Tier_standard a skos:Concept, know:Tier ;
rdfs:label "unknown" . skos:inScheme know:TierSchema ;
skos:notation "standard" ;
skos:prefLabel "standard"@de .
know:Tier_capable a skos:Concept, know:Tier ;
skos:inScheme know:TierSchema ;
skos:notation "capable" ;
skos:prefLabel "capable"@de .
know:Tier_expert a skos:Concept, know:Tier ;
skos:inScheme know:TierSchema ;
skos:notation "expert" ;
skos:prefLabel "expert"@de .
know:GesellschaftSchema a skos:ConceptScheme ;
rdfs:label "Gesellschaft"@de .
know:Gesellschaft_p_gmbh a skos:Concept, know:Gesellschaft ;
skos:inScheme know:GesellschaftSchema ;
skos:notation "p_gmbh" ;
skos:prefLabel "P-GmbH"@de .
know:Gesellschaft_p_b_kg a skos:Concept, know:Gesellschaft ;
skos:inScheme know:GesellschaftSchema ;
skos:notation "p_b_kg" ;
skos:prefLabel "P+B KG"@de .
know:PipelinestatusSchema a skos:ConceptScheme ;
rdfs:label "Pipeline-Status (Ankauf)"@de .
know:Pipelinestatus_vorab_check a skos:Concept ;
skos:inScheme know:PipelinestatusSchema ;
skos:notation "vorab_check" ;
skos:prefLabel "vorab_check"@de .
know:Pipelinestatus_vorpruefung a skos:Concept ;
skos:inScheme know:PipelinestatusSchema ;
skos:notation "vorpruefung" ;
skos:prefLabel "vorpruefung"@de .
know:Pipelinestatus_in_pruefung a skos:Concept ;
skos:inScheme know:PipelinestatusSchema ;
skos:notation "in_pruefung" ;
skos:prefLabel "in_pruefung"@de .
know:Pipelinestatus_angebot_in_vorbereitung a skos:Concept ;
skos:inScheme know:PipelinestatusSchema ;
skos:notation "angebot_in_vorbereitung" ;
skos:prefLabel "angebot_in_vorbereitung"@de .
know:Pipelinestatus_angebot_abgegeben a skos:Concept ;
skos:inScheme know:PipelinestatusSchema ;
skos:notation "angebot_abgegeben" ;
skos:prefLabel "angebot_abgegeben"@de .
know:Pipelinestatus_verhandlung a skos:Concept ;
skos:inScheme know:PipelinestatusSchema ;
skos:notation "verhandlung" ;
skos:prefLabel "verhandlung"@de .
know:Pipelinestatus_wettbewerb_aktuell a skos:Concept ;
skos:inScheme know:PipelinestatusSchema ;
skos:notation "wettbewerb_aktuell" ;
skos:prefLabel "wettbewerb_aktuell"@de .
know:Pipelinestatus_projektierung a skos:Concept ;
skos:inScheme know:PipelinestatusSchema ;
skos:notation "projektierung" ;
skos:prefLabel "projektierung"@de .
know:Pipelinestatus_planung a skos:Concept ;
skos:inScheme know:PipelinestatusSchema ;
skos:notation "planung" ;
skos:prefLabel "planung"@de .
know:Pipelinestatus_abgeschlossen a skos:Concept ;
skos:inScheme know:PipelinestatusSchema ;
skos:notation "abgeschlossen" ;
skos:prefLabel "abgeschlossen"@de .
know:ErgebnisstatusSchema a skos:ConceptScheme ;
rdfs:label "Ergebnis (Ankauf)"@de .
know:Ergebnisstatus_offen a skos:Concept ;
skos:inScheme know:ErgebnisstatusSchema ;
skos:notation "offen" ;
skos:prefLabel "offen"@de .
know:Ergebnisstatus_negativ_nicht_weiterverfolgt a skos:Concept ;
skos:inScheme know:ErgebnisstatusSchema ;
skos:notation "negativ_nicht_weiterverfolgt" ;
skos:prefLabel "negativ_nicht_weiterverfolgt"@de .
know:Ergebnisstatus_positiv_angeboten_nicht_zustande a skos:Concept ;
skos:inScheme know:ErgebnisstatusSchema ;
skos:notation "positiv_angeboten_nicht_zustande" ;
skos:prefLabel "positiv_angeboten_nicht_zustande"@de .
know:Ergebnisstatus_positiv_angeboten_zustande a skos:Concept ;
skos:inScheme know:ErgebnisstatusSchema ;
skos:notation "positiv_angeboten_zustande" ;
skos:prefLabel "positiv_angeboten_zustande"@de .
know:Ergebnisstatus_wettbewerb_erledigt a skos:Concept ;
skos:inScheme know:ErgebnisstatusSchema ;
skos:notation "wettbewerb_erledigt" ;
skos:prefLabel "wettbewerb_erledigt"@de .
know:Ergebnisstatus_wettbewerb_nicht_teilgenommen a skos:Concept ;
skos:inScheme know:ErgebnisstatusSchema ;
skos:notation "wettbewerb_nicht_teilgenommen" ;
skos:prefLabel "wettbewerb_nicht_teilgenommen"@de .
know:Ergebnisstatus_wettbewerb_nicht_zugelassen a skos:Concept ;
skos:inScheme know:ErgebnisstatusSchema ;
skos:notation "wettbewerb_nicht_zugelassen" ;
skos:prefLabel "wettbewerb_nicht_zugelassen"@de .
know:Ergebnisstatus_ohne_abschluss_erledigt a skos:Concept ;
skos:inScheme know:ErgebnisstatusSchema ;
skos:notation "ohne_abschluss_erledigt" ;
skos:prefLabel "ohne_abschluss_erledigt"@de .
know:DringlichkeitSchema a skos:ConceptScheme ;
rdfs:label "Dringlichkeit"@de .
know:Dringlichkeit_low a skos:Concept ;
skos:inScheme know:DringlichkeitSchema ;
skos:notation "low" ;
skos:prefLabel "low"@de .
know:Dringlichkeit_normal a skos:Concept ;
skos:inScheme know:DringlichkeitSchema ;
skos:notation "normal" ;
skos:prefLabel "normal"@de .
know:Dringlichkeit_high a skos:Concept ;
skos:inScheme know:DringlichkeitSchema ;
skos:notation "high" ;
skos:prefLabel "high"@de .
know:PruefstatusSchema a skos:ConceptScheme ;
rdfs:label "Prüfstatus (Kaufvertrag)"@de .
know:Pruefstatus_pending a skos:Concept ;
skos:inScheme know:PruefstatusSchema ;
skos:notation "pending" ;
skos:prefLabel "pending"@de .
know:Pruefstatus_approved a skos:Concept ;
skos:inScheme know:PruefstatusSchema ;
skos:notation "approved" ;
skos:prefLabel "approved"@de .
know:Pruefstatus_manual_review a skos:Concept ;
skos:inScheme know:PruefstatusSchema ;
skos:notation "manual_review" ;
skos:prefLabel "manual_review"@de .
know:ZugriffsstatusSchema a skos:ConceptScheme ;
rdfs:label "Status Postfachzugriff"@de .
know:Zugriffsstatus_active a skos:Concept ;
skos:inScheme know:ZugriffsstatusSchema ;
skos:notation "active" ;
skos:prefLabel "active"@de .
know:Zugriffsstatus_revoked a skos:Concept ;
skos:inScheme know:ZugriffsstatusSchema ;
skos:notation "revoked" ;
skos:prefLabel "revoked"@de .
know:Zugriffsstatus_needs_reconsent a skos:Concept ;
skos:inScheme know:ZugriffsstatusSchema ;
skos:notation "needs_reconsent" ;
skos:prefLabel "needs_reconsent"@de .
know:BelegstatusSchema a skos:ConceptScheme ;
rdfs:label "Belegstatus"@de .
know:Belegstatus_reserved a skos:Concept ;
skos:inScheme know:BelegstatusSchema ;
skos:notation "reserved" ;
skos:prefLabel "reserved"@de .
know:Belegstatus_uploaded a skos:Concept ;
skos:inScheme know:BelegstatusSchema ;
skos:notation "uploaded" ;
skos:prefLabel "uploaded"@de .
know:QualitaetsstatusSchema a skos:ConceptScheme ;
rdfs:label "Qualitätsstatus"@de .
know:Qualitaetsstatus_observed a skos:Concept ;
skos:inScheme know:QualitaetsstatusSchema ;
skos:notation "observed" ;
skos:prefLabel "beobachtet"@de .
know:Qualitaetsstatus_proposed a skos:Concept ;
skos:inScheme know:QualitaetsstatusSchema ;
skos:notation "proposed" ;
skos:prefLabel "vorgeschlagen"@de .
know:Qualitaetsstatus_confirmed a skos:Concept ;
skos:inScheme know:QualitaetsstatusSchema ;
skos:notation "confirmed" ;
skos:prefLabel "bestätigt"@de ;
skos:altLabel "verified"@de ;
skos:scopeNote "workflow_decisions schreibt 'verified' statt 'confirmed' (KNOW-Befund B31)"@de .
know:Qualitaetsstatus_rejected a skos:Concept ;
skos:inScheme know:QualitaetsstatusSchema ;
skos:notation "rejected" ;
skos:prefLabel "verworfen"@de .
+1292
View File
File diff suppressed because it is too large Load Diff
+459
View File
@@ -0,0 +1,459 @@
<title>KNOW Ontologie</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@500;600&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,500&display=swap">
<style>
/* Layout: Begriffslandkarte – Klassen als beschriftete Kästen, Bereiche als Cluster; rechts das Register mit Definitionen und Turtle */
:root {
--bg: #f3f4f1;
--surface: #ffffff;
--fg: #1d2421;
--muted: #5f6b66;
--line: #d5dad6;
--edge: #8f9a94;
--key: #2e3a35;
--soft: #7b6a3f;
--ext: #5b7682;
--sys-bg: #1d2421;
--sys-fg: #f3f4f1;
--focus: #3f6f9f;
--warn: #b42318;
--d-mail: #3f7cac;
--d-kanbau: #2f8f6b;
--d-docs: #6d6a9e;
--d-kaf: #b8742a;
--d-finance: #8a5a44;
--d-learning: #7a8c2e;
--d-agent: #9b4f8f;
--d-bsb: #5b7682;
--d-llm: #c4471b;
--d-system: #1d2421;
--font-ui: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
--font-head: "IBM Plex Sans Condensed", "IBM Plex Sans", "Arial Narrow", system-ui, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #121615; --surface: #1a201e; --fg: #e3e8e5; --muted: #98a59f; --line: #2c3532; --edge: #5f6c66;
--key: #c9d2cd; --soft: #d2b56e; --ext: #8fb0bd; --sys-bg: #e3e8e5; --sys-fg: #121615; --focus: #78a9d6; --warn: #ff8a7a;
--d-mail: #6fa8d6; --d-kanbau: #5cc29b; --d-docs: #a29fd6; --d-kaf: #e0a35a; --d-finance: #c49176;
--d-learning: #b3c65a; --d-agent: #d283c4; --d-bsb: #8fb0bd; --d-llm: #f0784a; --d-system: #e3e8e5;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #121615; --surface: #1a201e; --fg: #e3e8e5; --muted: #98a59f; --line: #2c3532; --edge: #5f6c66;
--key: #c9d2cd; --soft: #d2b56e; --ext: #8fb0bd; --sys-bg: #e3e8e5; --sys-fg: #121615; --focus: #78a9d6; --warn: #ff8a7a;
--d-mail: #6fa8d6; --d-kanbau: #5cc29b; --d-docs: #a29fd6; --d-kaf: #e0a35a; --d-finance: #c49176;
--d-learning: #b3c65a; --d-agent: #d283c4; --d-bsb: #8fb0bd; --d-llm: #f0784a; --d-system: #e3e8e5;
color-scheme: dark;
}
html, body { height: 100%; }
body { background: var(--bg); color: var(--fg); font-family: var(--font-ui); font-size: 14px; }
.app { height: 100%; display: grid; grid-template-rows: auto 1fr; }
header { display: flex; flex-wrap: wrap; gap: 10px 20px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--surface); }
.brand { display: flex; flex-direction: column; gap: 2px; min-width: 0; margin-right: auto; }
h1 { margin: 0; font-family: var(--font-head); font-weight: 600; font-size: 1.35rem; letter-spacing: .01em; text-wrap: balance; }
.brand p { margin: 0; color: var(--muted); font-size: .82rem; }
.controls { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; }
#q { font: inherit; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); width: 15rem; max-width: 100%; }
.toggle { display: inline-flex; align-items: center; gap: 6px; font-size: .85rem; color: var(--muted); cursor: pointer; user-select: none; }
.toggle input { accent-color: var(--focus); }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
main { display: grid; grid-template-columns: minmax(0, 1fr) 400px; min-height: 0; }
.stage { position: relative; min-width: 0; min-height: 0; }
svg#graph { width: 100%; height: 100%; display: block; cursor: grab; touch-action: none; }
.legend { position: absolute; left: 12px; bottom: 12px; right: 12px; display: flex; flex-wrap: wrap; gap: 6px; pointer-events: none; }
.legend > * { pointer-events: auto; }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface); font-size: .78rem; color: var(--fg); cursor: pointer; font-family: var(--font-ui); }
.chip[aria-pressed="true"] { border-color: var(--fg); }
.sw { width: 10px; height: 10px; border-radius: 2px; display: inline-block; flex: none; }
.edgekey { display: inline-flex; gap: 12px; flex-wrap: wrap; padding: 4px 10px; border-radius: 6px; background: var(--surface); border: 1px solid var(--line); font-size: .78rem; color: var(--muted); }
.edgekey span { display: inline-flex; align-items: center; gap: 6px; }
.edgekey i { display: inline-block; width: 22px; height: 0; border-top: 2px solid; }
aside { border-left: 1px solid var(--line); background: var(--surface); overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 14px; min-width: 0; }
aside h2 { margin: 0; font-family: var(--font-head); font-weight: 600; font-size: 1.2rem; overflow-wrap: anywhere; }
aside h3 { margin: 0 0 6px; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); font-weight: 600; }
aside p { margin: 0; line-height: 1.5; }
.mono { font-family: var(--font-mono); font-size: .85em; }
.meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.tag { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: .75rem; border: 1px solid var(--line); color: var(--muted); }
.sensbox { border-left: 3px solid var(--d-kaf); padding: 6px 10px; background: var(--bg); border-radius: 4px; }
ul.rel { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
ul.rel li { display: grid; gap: 1px; }
.prop { font-family: var(--font-mono); font-size: .8rem; color: var(--muted); }
button.link { all: unset; cursor: pointer; color: var(--fg); font-weight: 500; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 3px; overflow-wrap: anywhere; }
button.link:hover { text-decoration-color: currentColor; }
button.link:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 2px; }
.via { color: var(--muted); font-family: var(--font-mono); font-size: .74rem; overflow-wrap: anywhere; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.stat { background: var(--bg); border-radius: 6px; padding: 8px 10px; display: grid; gap: 2px; }
.stat b { font-size: 1.3rem; font-family: var(--font-head); font-variant-numeric: tabular-nums; }
.stat span { font-size: .75rem; color: var(--muted); }
.vals { display: flex; flex-wrap: wrap; gap: 4px; }
.vals span { font-family: var(--font-mono); font-size: .74rem; padding: 1px 6px; border-radius: 4px; background: var(--bg); }
table.cols { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: .76rem; }
table.cols td { padding: 2px 0; border-bottom: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; }
table.cols td:last-child { color: var(--muted); text-align: right; padding-left: 8px; white-space: nowrap; }
details summary { cursor: pointer; color: var(--muted); font-size: .8rem; }
pre.ttl { margin: 8px 0 0; max-height: 340px; overflow: auto; background: var(--bg); padding: 10px; border-radius: 6px; font-family: var(--font-mono); font-size: .72rem; line-height: 1.45; white-space: pre; }
.btn { font: inherit; font-size: .8rem; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); cursor: pointer; }
.src { color: var(--muted); font-size: .75rem; line-height: 1.45; border-top: 1px solid var(--line); padding-top: 10px; }
.back { align-self: flex-start; font-size: .8rem; }
dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0; }
dl.kv dt { color: var(--muted); font-size: .8rem; }
dl.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
/* SVG */
.cls { cursor: pointer; }
.cls rect.box { fill: var(--surface); stroke-width: 1.6; }
.cls.implicit rect.box { stroke-dasharray: 4 3; }
.cls.system rect.box { fill: var(--sys-bg); stroke: var(--sys-bg); }
.cls.system text { fill: var(--sys-fg); }
.cls text { font-family: var(--font-ui); font-size: 11.5px; font-weight: 500; fill: var(--fg); pointer-events: none; }
.cls.abstract text { font-style: italic; }
.cls rect.dot { stroke: none; }
.cls.sel rect.box { stroke: var(--focus); stroke-width: 3; }
.cls.match text { fill: var(--focus); font-weight: 600; }
.ind circle { fill: var(--surface); stroke: var(--muted); stroke-width: 1.2; }
.ind text { font-family: var(--font-mono); font-size: 9.5px; fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: 3px; pointer-events: none; }
.rel path { fill: none; stroke-width: 1.4; }
.rel.key path { stroke: var(--key); }
.rel.soft path { stroke: var(--soft); stroke-dasharray: 5 3; }
.rel.external path { stroke: var(--ext); stroke-dasharray: 1 3; stroke-linecap: round; stroke-width: 1.8; }
.rel.subclass path { stroke: var(--edge); stroke-width: 1.6; }
.rel.instance path { stroke: var(--edge); stroke-width: .8; opacity: .7; }
.rel text { font-family: var(--font-mono); font-size: 9.5px; fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: 3.5px; stroke-linejoin: round; pointer-events: none; }
svg.nolabels .rel text { display: none; }
svg.focus .cls:not(.hl), svg.focus .ind:not(.hl) { opacity: .15; }
svg.focus .rel:not(.hl) { opacity: .06; }
.mk-key { fill: var(--key); } .mk-soft { fill: var(--soft); } .mk-ext { fill: var(--ext); } .mk-sub { fill: var(--bg); stroke: var(--edge); stroke-width: 1.2; }
@media (max-width: 860px) {
main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(440px, 64vh) auto; }
aside { border-left: 0; border-top: 1px solid var(--line); overflow: visible; }
.app { height: auto; min-height: 100%; }
#q { width: 100%; }
.controls { width: 100%; }
}
</style>
<div class="app">
<header>
<div class="brand">
<h1>KNOW Ontologie</h1>
<p>Begriffe der Wissensdatenbank <span class="mono">knowledge</span> und wie sie zusammenhängen, abgeleitet aus Schema und Joins</p>
</div>
<div class="controls">
<input id="q" type="search" placeholder="Begriff suchen" aria-label="Begriff suchen" autocomplete="off">
<label class="toggle"><input type="checkbox" id="t-labels" checked> Beziehungsnamen</label>
<label class="toggle"><input type="checkbox" id="t-soft" checked> Weiche Bezüge</label>
<label class="toggle"><input type="checkbox" id="t-ext" checked> Externe Systeme</label>
<label class="toggle"><input type="checkbox" id="t-ind"> Ausprägungen</label>
</div>
</header>
<main>
<div class="stage">
<svg id="graph" role="img" aria-label="Ontologie als Netz aus Klassen und Beziehungen"></svg>
<div class="legend" id="legend"></div>
</div>
<aside id="panel" aria-live="polite"></aside>
</main>
</div>
<script type="application/json" id="data">__DATA__</script>
<script type="application/json" id="ttl">__TTL__</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
<script>
(function () {
const DATA = JSON.parse(document.getElementById('data').textContent);
const TTL = JSON.parse(document.getElementById('ttl').textContent);
const MODULES = {
mail: 'Mail & Absender', kanbau: 'Kontakte & Projekte', docs: 'Dokumente', kaf: 'Ankauf (KAF)', finance: 'Buchhaltung',
learning: 'Lernen', agent: 'Agent', bsb: 'Browser-Sessions', llm: 'LLM-Worker', system: 'Externe Systeme'
};
const KIND = { key: 'Schlüsselbezug', soft: 'weicher Bezug (Wertabgleich)', external: 'Verweis auf externes System', subclass: 'Unterklasse von', instance: 'Ausprägung von' };
const css = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
const modColor = m => css('--d-' + m);
const classes = DATA.classes.map(c => Object.assign({ kind: 'class' }, c));
const byId = new Map(classes.map(c => [c.id, c]));
const inds = [];
classes.forEach(c => c.individuals.forEach(v => inds.push({ id: c.id + '::' + v, label: v, kind: 'ind', cls: c.id, module: c.module })));
inds.forEach(i => byId.set(i.id, i));
const rels = DATA.relations.map(r => Object.assign({}, r));
inds.forEach(i => rels.push({ source: i.id, target: i.cls, name: 'a', via: '', kind: 'instance' }));
const allNodes = classes.concat(inds);
const state = { labels: true, soft: true, ext: true, ind: false, selected: null, module: null, query: '' };
const svg = d3.select('#graph');
const defs = svg.append('defs');
[['key', 'mk-key'], ['soft', 'mk-soft'], ['ext', 'mk-ext']].forEach(([k, cls]) => {
defs.append('marker').attr('id', 'm-' + k).attr('viewBox', '0 -4 8 8').attr('refX', 7).attr('refY', 0)
.attr('markerWidth', 7).attr('markerHeight', 7).attr('orient', 'auto')
.append('path').attr('d', 'M0,-4L8,0L0,4Z').attr('class', cls);
});
defs.append('marker').attr('id', 'm-sub').attr('viewBox', '0 -6 12 12').attr('refX', 11).attr('refY', 0)
.attr('markerWidth', 11).attr('markerHeight', 11).attr('orient', 'auto').attr('markerUnits', 'userSpaceOnUse')
.append('path').attr('d', 'M0,-6L11,0L0,6Z').attr('class', 'mk-sub');
const root = svg.append('g');
const gRels = root.append('g');
const gNodes = root.append('g');
const zoom = d3.zoom().scaleExtent([0.2, 3.5]).on('zoom', e => root.attr('transform', e.transform));
svg.call(zoom).on('dblclick.zoom', null);
svg.on('click', e => { if (e.target === svg.node()) select(null); });
// Textbreite messen, damit Kästen zur Beschriftung passen
const measure = (() => { const c = document.createElement('canvas').getContext('2d'); return (t, f) => { c.font = f; return c.measureText(t).width; }; })();
classes.forEach(c => { c.w = Math.max(54, measure(c.label, '500 11.5px "IBM Plex Sans", system-ui') + 26); c.h = 26; });
inds.forEach(i => { i.w = 8; i.h = 8; });
const modKeys = Object.keys(MODULES);
const anchors = {};
modKeys.forEach((m, i) => {
if (m === 'system') { anchors[m] = { x: 0, y: 0 }; return; }
const n = modKeys.length - 1, a = (i / n) * Math.PI * 2 - Math.PI / 2;
anchors[m] = { x: Math.cos(a) * 640, y: Math.sin(a) * 440 };
});
const sim = d3.forceSimulation()
.force('link', d3.forceLink().id(d => d.id).distance(l => l.kind === 'instance' ? 34 : l.kind === 'external' ? 200 : 140).strength(l => l.kind === 'instance' ? 0.9 : l.kind === 'external' ? 0.08 : 0.3))
.force('charge', d3.forceManyBody().strength(d => d.kind === 'ind' ? -40 : -560))
.force('collide', d3.forceCollide(d => d.kind === 'ind' ? 10 : d.w / 2 + 14))
.force('x', d3.forceX(d => anchors[d.module].x).strength(d => d.kind === 'ind' ? 0.01 : 0.12))
.force('y', d3.forceY(d => anchors[d.module].y).strength(d => d.kind === 'ind' ? 0.01 : 0.12))
.stop();
function visible() {
const ns = allNodes.filter(n => {
if (n.kind === 'ind') return state.ind;
if (n.module === 'system') return state.ext;
return true;
});
const ids = new Set(ns.map(n => n.id));
const ls = rels.filter(r => {
const s = typeof r.source === 'object' ? r.source.id : r.source, t = typeof r.target === 'object' ? r.target.id : r.target;
if (!ids.has(s) || !ids.has(t)) return false;
if (r.kind === 'soft') return state.soft;
return true;
});
return { ns, ls };
}
let relSel, nodeSel;
function render(initial) {
const { ns, ls } = visible();
sim.nodes(ns); sim.force('link').links(ls);
relSel = gRels.selectAll('g.rel').data(ls, r => r.source.id + '|' + r.name + '|' + r.target.id).join(enter => {
const g = enter.append('g');
g.append('path');
g.append('text').attr('text-anchor', 'middle').attr('dy', -3);
g.append('title');
return g;
});
relSel.attr('class', r => 'rel ' + r.kind);
relSel.select('path').attr('marker-end', r => r.kind === 'key' ? 'url(#m-key)' : r.kind === 'soft' ? 'url(#m-soft)' : r.kind === 'external' ? 'url(#m-ext)' : r.kind === 'subclass' ? 'url(#m-sub)' : null);
relSel.select('text').text(r => r.kind === 'instance' ? '' : r.kind === 'subclass' ? 'ist ein' : r.name);
relSel.select('title').text(r => r.kind === 'instance' ? `${r.source.label} ist Ausprägung von ${r.target.label}` : `${r.source.label} ${r.kind === 'subclass' ? 'ist Unterklasse von' : r.name} ${r.target.label}\n${KIND[r.kind]}${r.via ? '\nüber ' + r.via : ''}`);
nodeSel = gNodes.selectAll('g.node').data(ns, d => d.id).join(enter => {
const g = enter.append('g').attr('tabindex', 0).attr('role', 'button')
.attr('aria-label', d => (d.kind === 'ind' ? 'Ausprägung ' : 'Klasse ') + d.label);
g.each(function (d) {
const s = d3.select(this);
if (d.kind === 'ind') {
s.append('circle').attr('r', 4);
s.append('text').attr('x', 7).attr('y', 3).text(d.label);
} else {
s.append('rect').attr('class', 'box').attr('x', -d.w / 2).attr('y', -d.h / 2).attr('width', d.w).attr('height', d.h).attr('rx', 13);
if (d.module !== 'system') s.append('rect').attr('class', 'dot').attr('x', -d.w / 2 + 9).attr('y', -4).attr('width', 8).attr('height', 8).attr('rx', 2);
s.append('text').attr('x', d.module === 'system' ? 0 : 6).attr('y', 4).attr('text-anchor', 'middle').text(d.label);
}
});
g.on('click', (e, d) => { e.stopPropagation(); select(d.kind === 'ind' ? d.cls : d.id, false); })
.on('keydown', (e, d) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); select(d.kind === 'ind' ? d.cls : d.id); } })
.on('mouseenter', (e, d) => hover(d.kind === 'ind' ? d.cls : d.id)).on('mouseleave', () => hover(null))
.call(d3.drag()
.on('start', (e, d) => { if (!e.active) sim.alphaTarget(0.2).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
.on('end', (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }));
return g;
});
nodeSel.attr('class', d => d.kind === 'ind' ? 'node ind' : ['node', 'cls', d.table ? '' : 'implicit', d.module === 'system' ? 'system' : '', d.abstract ? 'abstract' : ''].join(' '));
recolor();
const n = initial ? 360 : 120;
sim.alpha(initial ? 1 : 0.6); for (let i = 0; i < n; i++) sim.tick();
draw(); sim.on('tick', draw);
applyHighlight();
}
function recolor() {
if (!nodeSel) return;
nodeSel.select('rect.box').attr('stroke', d => d.module === 'system' ? null : modColor(d.module));
nodeSel.select('rect.dot').attr('fill', d => modColor(d.module));
}
// Kante am Kastenrand enden lassen
function edgePoint(n, tx, ty) {
const dx = tx - n.x, dy = ty - n.y;
if (n.kind === 'ind') { const l = Math.hypot(dx, dy) || 1; return [n.x + dx / l * 5, n.y + dy / l * 5]; }
const hw = n.w / 2 + 2, hh = n.h / 2 + 2;
const s = Math.min(Math.abs(dx) > 1e-6 ? hw / Math.abs(dx) : Infinity, Math.abs(dy) > 1e-6 ? hh / Math.abs(dy) : Infinity);
return [n.x + dx * s, n.y + dy * s];
}
function draw() {
relSel.each(function (r) {
const g = d3.select(this);
if (r.source === r.target) {
const n = r.source, sx = n.x + n.w / 4, sy = n.y - n.h / 2 - 2, ex = n.x + n.w / 2 + 2, ey = n.y;
g.select('path').attr('d', `M${sx},${sy}C${sx},${sy - 40} ${ex + 44},${ey} ${ex},${ey}`);
g.select('text').attr('x', ex + 18).attr('y', sy - 22);
return;
}
const [x1, y1] = edgePoint(r.source, r.target.x, r.target.y);
const [x2, y2] = edgePoint(r.target, r.source.x, r.source.y);
g.select('path').attr('d', `M${x1},${y1}L${x2},${y2}`);
g.select('text').attr('x', (x1 + x2) / 2).attr('y', (y1 + y2) / 2);
});
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
}
function fit() {
const box = root.node().getBBox(); const w = svg.node().clientWidth, h = svg.node().clientHeight;
if (!box.width || !w || !h) return;
const k = Math.min(1.4, 0.94 / Math.max(box.width / w, box.height / (h - 60)));
svg.call(zoom.transform, d3.zoomIdentity.translate(w / 2 - k * (box.x + box.width / 2), (h - 50) / 2 - k * (box.y + box.height / 2)).scale(k));
}
let hoverId = null;
function hover(id) { hoverId = id; applyHighlight(); }
function applyHighlight() {
const f = hoverId || state.selected;
let ns = null, ls = null;
if (f) {
ns = new Set([f]); ls = new Set();
relSel.each(r => { if (r.source.id === f || r.target.id === f) { ns.add(r.source.id); ns.add(r.target.id); ls.add(r); } });
} else if (state.module) {
ns = new Set(allNodes.filter(n => n.module === state.module).map(n => n.id)); ls = new Set();
relSel.each(r => { if (r.source.module === state.module || r.target.module === state.module) { ls.add(r); ns.add(r.source.id); ns.add(r.target.id); } });
}
svg.classed('focus', !!ns);
nodeSel.classed('hl', d => !!ns && ns.has(d.id)).classed('sel', d => d.id === state.selected)
.classed('match', d => !!state.query && d.kind !== 'ind' && (d.label.toLowerCase().includes(state.query) || (d.table || '').includes(state.query)));
relSel.classed('hl', r => !!ls && ls.has(r));
}
// ---------- Panel ----------
const panel = document.getElementById('panel');
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const btn = id => `<button class="link" data-id="${esc(id)}">${esc(byId.get(id).label)}</button>`;
const idOf = x => typeof x === 'object' ? x.id : x;
function relItems(list, dir) {
if (!list.length) return '<p class="via">keine</p>';
return '<ul class="rel">' + list.map(r => {
const other = dir === 'out' ? idOf(r.target) : idOf(r.source);
const name = r.kind === 'subclass' ? 'ist ein' : r.name;
const line = dir === 'out' ? `<span class="prop">${esc(name)} →</span> ${btn(other)}` : `${btn(other)} <span class="prop">${esc(name)} →</span>`;
return `<li><span>${line}</span><span class="via">${esc(KIND[r.kind])}${r.via ? ' · ' + esc(r.via) : ''}</span></li>`;
}).join('') + '</ul>';
}
function overview() {
const implicit = classes.filter(c => !c.table && c.module !== 'system').length;
const nRel = DATA.relations.filter(r => r.kind !== 'subclass').length;
panel.innerHTML = `
<div class="stats">
<div class="stat"><b>${classes.length}</b><span>Klassen, davon ${implicit} implizit</span></div>
<div class="stat"><b>${nRel}</b><span>Beziehungen</span></div>
<div class="stat"><b>${inds.length}</b><span>Ausprägungen</span></div>
</div>
<p>Jede der ${classes.filter(c => c.table).length} Tabellen ist eine Klasse. Dazu kommen <b>implizite Klassen</b> (gestrichelt): Begriffe ohne eigene Tabelle, die nur als Wert in Spalten vorkommen, etwa Postfach, n8n-Workflow, Gesellschaft oder Tier. Die Beziehungen stammen aus Schlüsselspalten und den Joins im KNOW-Code.</p>
<section><h3>Lesart</h3><dl class="kv">
<dt>Schlüsselbezug</dt><dd>gemeinsamer Schlüssel, z. B. <span class="mono">project_key</span></dd>
<dt>weicher Bezug</dt><dd>Abgleich über einen Wert wie E-Mail oder Adresse, ohne festen Schlüssel</dd>
<dt>externer Verweis</dt><dd>ID oder Pfad in KanBau, SharePoint oder Microsoft 365</dd>
<dt>ist ein</dt><dd>Unterklasse, z. B. Falldokument ist ein Dokument</dd>
</dl></section>
<section><h3>Auffällig</h3><p>${btn('Mail')} ist der zentrale Eingang: Absender, Anhang-Extraktion, Projektereignis und Rechnungsbeobachtung hängen an ihr. ${btn('Projekt')} ist der zentrale Bezugspunkt. ${btn('Ankaufsfall')} soll über <span class="mono">selected_project_id</span> zum Projekt werden; laut Befund B5 schreibt aber kein Workflow Ankaufsfälle.</p></section>
<section><h3>OWL / Turtle</h3>
<p>Dieselbe Ontologie als Turtle-Datei, mit Klassen, Objekt- und Datentyp-Properties und Ausprägungen. Quelle im Repo ist <span class="mono">ontology/know.yaml</span>, daraus erzeugt <span class="mono">ontology/know.ttl</span>.</p>
<div class="meta" style="margin-top:8px"><button class="btn" id="copy">Turtle kopieren</button><span class="via" id="copied" role="status"></span></div>
<details><summary>Turtle anzeigen</summary><pre class="ttl" id="ttlpre"></pre></details>
</section>
<p class="src">Erzeugt aus ontology/know.yaml (Version ${esc(DATA.version)}) am ${esc(DATA.generated)}. Grundlage: Postgres-Schema und SQL-Vorlagen aus dem n8n-Workflow „KNOW - Knowledge Base Gateway“. Datenbankinhalte wurden nicht gelesen. Die Namen der Beziehungen und die impliziten Klassen sind eine Modellierung aus diesen Quellen und nicht im Schema festgelegt.</p>`;
document.getElementById('ttlpre').textContent = TTL;
const status = document.getElementById('copied');
document.getElementById('copy').addEventListener('click', () => {
const done = () => { status.textContent = 'Kopiert'; };
const fallback = () => { const d = panel.querySelector('details'); d.open = true; const r = document.createRange(); r.selectNodeContents(document.getElementById('ttlpre')); const s = getSelection(); s.removeAllRanges(); s.addRange(r); status.textContent = 'Markiert, mit Strg/Cmd+C kopieren'; };
try { navigator.clipboard.writeText(TTL).then(done, fallback); } catch (e) { fallback(); }
});
}
function detail(id) {
const c = byId.get(id);
const out = rels.filter(r => idOf(r.source) === id && r.kind !== 'instance');
const inc = rels.filter(r => idOf(r.target) === id && r.kind !== 'instance');
const enums = Object.entries(c.enums || {});
panel.innerHTML = `<button class="link back" data-id="">← Überblick</button>
<h2>${esc(c.label)}</h2>
<div class="meta"><span class="chip" style="cursor:default"><span class="sw" style="background:var(--d-${c.module})"></span>${esc(MODULES[c.module])}</span>
${c.table ? `<span class="tag mono">${esc(c.table)}</span>` : c.module === 'system' ? '<span class="tag">externes System</span>' : '<span class="tag">implizit, ohne eigene Tabelle</span>'}
${c.abstract ? '<span class="tag">abstrakt</span>' : ''}</div>
<p>${esc(c.def)}</p>
<dl class="kv"><dt>Schlüssel</dt><dd class="mono">${esc(c.key)}</dd><dt>OWL</dt><dd class="mono">know:${esc(c.id)}</dd></dl>
${c.sens ? `<div class="sensbox"><h3>Sensible Inhalte</h3><p>${esc(c.sens)}</p></div>` : ''}
<section><h3>Beziehungen von hier</h3>${relItems(out, 'out')}</section>
<section><h3>Beziehungen hierher</h3>${relItems(inc, 'in')}</section>
${c.individuals.length ? `<section><h3>Ausprägungen</h3><div class="vals">${c.individuals.map(v => `<span>${esc(v)}</span>`).join('')}</div></section>` : ''}
${enums.map(([k, v]) => `<section><h3>Werte von ${esc(k)}</h3><div class="vals">${v.map(x => `<span>${esc(x)}</span>`).join('')}</div></section>`).join('')}
${c.attrs.length ? `<section><details><summary>${c.attrs.length} Attribute (Datentyp-Properties)</summary><table class="cols">${c.attrs.map(a => `<tr><td>${esc(a[0])}</td><td>${esc(a[1])}</td></tr>`).join('')}</table></details></section>` : ''}`;
}
panel.addEventListener('click', e => { const b = e.target.closest('button[data-id]'); if (b) select(b.getAttribute('data-id') || null, true); });
function select(id, center) {
state.selected = id; state.module = null; updateChips();
if (id) detail(id); else overview();
applyHighlight();
if (id && center) { const n = byId.get(id); if (n && n.x != null) svg.transition().duration(matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 450).call(zoom.translateTo, n.x, n.y); }
}
// ---------- Legende und Steuerung ----------
const legend = document.getElementById('legend');
legend.innerHTML = modKeys.filter(m => m !== 'system').map(m => `<button class="chip" data-mod="${m}" aria-pressed="false"><span class="sw" style="background:var(--d-${m})"></span>${MODULES[m]}</button>`).join('') +
`<span class="edgekey"><span><i style="border-color:var(--key)"></i>Schlüssel</span><span><i style="border-color:var(--soft);border-top-style:dashed"></i>weich</span><span><i style="border-color:var(--ext);border-top-style:dotted"></i>extern</span><span><i style="border-color:var(--edge)"></i>▷ ist ein</span></span>`;
legend.addEventListener('click', e => {
const b = e.target.closest('[data-mod]'); if (!b) return;
const m = b.getAttribute('data-mod'); state.module = state.module === m ? null : m; state.selected = null; overview(); updateChips(); applyHighlight();
});
function updateChips() { legend.querySelectorAll('[data-mod]').forEach(b => b.setAttribute('aria-pressed', String(b.getAttribute('data-mod') === state.module))); }
[['t-labels', 'labels'], ['t-soft', 'soft'], ['t-ext', 'ext'], ['t-ind', 'ind']].forEach(([id, key]) => {
const el = document.getElementById(id);
try { const v = localStorage.getItem('know-onto:' + key); if (v !== null) el.checked = v === '1'; } catch (e) {}
state[key] = el.checked;
el.addEventListener('change', () => {
state[key] = el.checked;
try { localStorage.setItem('know-onto:' + key, el.checked ? '1' : '0'); } catch (e) {}
if (key === 'labels') { svg.classed('nolabels', !state.labels); return; }
if (state.selected && !visible().ns.some(n => n.id === state.selected)) select(null);
render(false);
});
});
svg.classed('nolabels', !state.labels);
const q = document.getElementById('q');
q.addEventListener('input', () => { state.query = q.value.trim().toLowerCase(); applyHighlight(); });
q.addEventListener('keydown', e => {
if (e.key !== 'Enter' || !state.query) return;
const hit = classes.find(c => c.label.toLowerCase().includes(state.query) || (c.table || '').includes(state.query));
if (hit && visible().ns.includes(hit)) select(hit.id, true);
});
const mq = matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener && mq.addEventListener('change', recolor);
new MutationObserver(recolor).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
render(true);
overview();
requestAnimationFrame(fit);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => {
classes.forEach(c => { c.w = Math.max(54, measure(c.label, '500 11.5px "IBM Plex Sans", system-ui') + 26); });
nodeSel.select('rect.box').attr('x', d => -d.w / 2).attr('width', d => d.w);
nodeSel.select('rect.dot').attr('x', d => -d.w / 2 + 9);
draw();
});
window.addEventListener('resize', () => requestAnimationFrame(fit));
})();
</script>