ontology: Ontologie als Quelle im Repo, Abgleich mit n8n/KNOW in der Service-Routine
ci / test (push) Successful in 27s
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:
+1
-1
@@ -22,6 +22,6 @@ PROXMOX_TOKEN_ID=knaster@pve!healer
|
||||
PROXMOX_TOKEN_SECRET=
|
||||
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_API_KEY=
|
||||
|
||||
@@ -13,7 +13,7 @@ test:
|
||||
|
||||
lint:
|
||||
$(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:
|
||||
$(VENV)/bin/knaster validate --root .
|
||||
|
||||
@@ -20,6 +20,7 @@ Anfrage → n8n → Router-Service ──▶ Stufe 3 Embedding-Classifier (CPU)
|
||||
| [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/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/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 |
|
||||
|
||||
@@ -18,3 +18,4 @@ actions:
|
||||
promote_candidate: {mode: approve, risk: high}
|
||||
rollback_candidate: {mode: approve, risk: medium}
|
||||
reboot_host: {mode: approve, risk: high, window: "02:00-05:00"}
|
||||
ontology_change: {mode: approve, risk: low}
|
||||
|
||||
+107
-1
@@ -1,4 +1,4 @@
|
||||
"""Kommandozeile: serve, validate, inventory, placement, registry export."""
|
||||
"""Kommandozeile: serve, validate, inventory, placement, registry export, ontology."""
|
||||
|
||||
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.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)
|
||||
|
||||
if args.cmd == "ontology":
|
||||
return _ontology(args)
|
||||
|
||||
if args.cmd == "serve":
|
||||
import uvicorn
|
||||
|
||||
@@ -104,5 +124,91 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -101,6 +101,17 @@ class ActionExecutor:
|
||||
self.registry.set_calibrated_context(slot, value)
|
||||
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 --------------------------------------------------------------------------
|
||||
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]
|
||||
|
||||
@@ -35,6 +35,7 @@ class MaintenanceConfig(BaseModel):
|
||||
adapter_min_cases: int = 300
|
||||
adapter_target_rate: float = 0.90
|
||||
lookback_days: float = 7.0
|
||||
ontology_max_findings: int = 15
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str | Path) -> MaintenanceConfig:
|
||||
@@ -83,7 +84,8 @@ HELP = """Befehle:
|
||||
- status · hilfe · routine · vorschläge
|
||||
- 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>
|
||||
- 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:
|
||||
@@ -120,6 +122,7 @@ COMMANDS: list[tuple[str, re.Pattern[str]]] = [
|
||||
("rollback", r"^rollback\s+(?P<slot>[\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)$"),
|
||||
("map_concept", rf"^ordne\s+(?P<id>{_ID})\s+(dem\s+konzept\s+)?(?P<concept>[\w.-]+)\s+zu$"),
|
||||
("confirm", r"^(ja|yes|j|ok)$"),
|
||||
("cancel", r"^(nein|no|n|abbrechen)$"),
|
||||
]
|
||||
@@ -143,6 +146,7 @@ class MaintenanceAgent:
|
||||
def __init__(self, *, store: Store, registry: Registry, library: SkillLibrary, index: SkillIndex,
|
||||
policy: Policy, actions: ActionExecutor, lifecycle: Lifecycle,
|
||||
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):
|
||||
self.store = store
|
||||
self.registry = registry
|
||||
@@ -153,6 +157,7 @@ class MaintenanceAgent:
|
||||
self.lifecycle = lifecycle
|
||||
self.config = config or MaintenanceConfig()
|
||||
self.intent_mapper = intent_mapper
|
||||
self.ontology_check = ontology_check
|
||||
self.clock = clock
|
||||
self.wallclock = wallclock
|
||||
self._pending: dict[str, str] = {} # Sitzung → zu bestätigender Befehl
|
||||
@@ -197,8 +202,9 @@ class MaintenanceAgent:
|
||||
|
||||
def _execute(self, seq: int) -> Proposal:
|
||||
p = self._load(seq)
|
||||
params = {**p.params, "decided_by": p.decided_by} if p.kind == "ontology_change" else p.params
|
||||
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)
|
||||
except ActionError as 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"})
|
||||
if p.kind == "promote_candidate":
|
||||
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)
|
||||
|
||||
def run_deferred(self) -> list[Proposal]:
|
||||
@@ -318,6 +327,24 @@ class MaintenanceAgent:
|
||||
f"{cand['kind']} für {cand['slot']} übernehmen ({cand['target']})",
|
||||
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():
|
||||
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(),
|
||||
"reason": f"Auftrag von {user}", "set_by": 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])
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -38,6 +38,10 @@ class Settings:
|
||||
webhook_auth: str = "" # Wert des Authorization-Headers für ausgehende Webhooks (n8n Header Auth)
|
||||
request_timeout: float = 120.0
|
||||
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
|
||||
def from_env(cls) -> Settings:
|
||||
@@ -65,4 +69,8 @@ class Settings:
|
||||
webhook_auth=env.get("KNASTER_WEBHOOK_AUTH", ""),
|
||||
request_timeout=float(env.get("KNASTER_REQUEST_TIMEOUT", base.request_timeout)),
|
||||
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", ""),
|
||||
)
|
||||
|
||||
@@ -144,6 +144,18 @@ healer_state = Table(
|
||||
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",
|
||||
metadata,
|
||||
@@ -339,6 +351,26 @@ class Store:
|
||||
with self.engine.connect() as conn:
|
||||
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 --------------------------------------------------------------------
|
||||
def get_healer_state(self, key: str) -> dict[str, Any] | None:
|
||||
row = self._get(healer_state, "key", key)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
@@ -18,6 +19,7 @@ from .llm import ChatClient, OpenAIChatClient
|
||||
from .maintenance.actions import ActionExecutor
|
||||
from .maintenance.agent import IntentMapper, LLMIntentMapper, MaintenanceAgent, MaintenanceConfig
|
||||
from .metrics import Metrics
|
||||
from .ontology import Ontology, OntologyFinding, build_snapshot, check, fetch_workflows
|
||||
from .policy import Policy
|
||||
from .registry import Registry
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
settings: Settings,
|
||||
*,
|
||||
@@ -70,6 +101,7 @@ def build_system(
|
||||
notifier: Notifier | None = None,
|
||||
jobs: JobRunner | None = None,
|
||||
intent_mapper: IntentMapper | None = None,
|
||||
ontology_source: Callable[[], list[dict]] | None = None,
|
||||
clock: Callable[[], float] = now,
|
||||
wallclock: Callable[[], datetime] = datetime.now,
|
||||
) -> System:
|
||||
@@ -117,7 +149,9 @@ def build_system(
|
||||
intent_mapper = LLMIntentMapper(chat, registry.slot("tier1-general").spec.litellm_model)
|
||||
maintenance = MaintenanceAgent(store=store, registry=registry, library=library, index=router.index,
|
||||
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)
|
||||
prober = CanaryProber(registry, chat, embedder_for={settings.embed_model: embedder},
|
||||
metrics_registry=metrics.registry)
|
||||
|
||||
@@ -87,6 +87,21 @@ def validate_repo(root: str | Path, site: str = "knaster") -> list[tuple[str, st
|
||||
if kind not in known:
|
||||
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():
|
||||
roles, _ = load_catalog(root / "catalog")
|
||||
for role in roles:
|
||||
|
||||
@@ -212,7 +212,8 @@ def make_system(tmp_path, skills_dir, fake_chat, clock):
|
||||
(site / "registry.yaml").write_text(yaml.safe_dump(REGISTRY))
|
||||
settings = Settings(db_url="sqlite://", site_dir=site, skills_dir=skills_dir,
|
||||
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(),
|
||||
clock=clock)
|
||||
kw.update(overrides)
|
||||
|
||||
@@ -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()
|
||||
@@ -16,3 +16,6 @@ PROXMOX_URL=
|
||||
PROXMOX_TOKEN_ID=
|
||||
PROXMOX_TOKEN_SECRET=
|
||||
KNASTER_WEBHOOK_AUTH=Bearer change-me
|
||||
# Ontologie-Abgleich (nur lesend: GET /api/v1/workflows); leer = Abgleich aus
|
||||
N8N_HOST=
|
||||
N8N_API_KEY=
|
||||
|
||||
@@ -13,10 +13,12 @@ COPY config config
|
||||
COPY catalog catalog
|
||||
COPY skills skills
|
||||
COPY sites sites
|
||||
COPY ontology ontology
|
||||
|
||||
ENV KNASTER_CONFIG_DIR=/app/config \
|
||||
KNASTER_SKILLS_DIR=/app/skills \
|
||||
KNASTER_SITE_DIR=/app/sites/knaster \
|
||||
KNASTER_ONTOLOGY=/app/ontology/know.yaml \
|
||||
KNASTER_DRY_RUN=true
|
||||
|
||||
RUN useradd --system --uid 10001 knaster
|
||||
|
||||
@@ -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 |
|
||||
| Treiber/Engine | Proxmox-Snapshot der GPU-VM (vor jedem Update anlegen) |
|
||||
| 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.
|
||||
|
||||
@@ -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 |
|
||||
| 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` | ✅ |
|
||||
| 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
|
||||
|
||||
@@ -30,11 +31,12 @@ Vorgehen pro Baustein: **Spec → Implementierung → Tests → Commit**. Bis zu
|
||||
|
||||
| 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` |
|
||||
| Compose-Dateien | `docker compose config` (`make compose-check`) |
|
||||
| Prometheus/Alertmanager/n8n-JSON | Strukturtests (`core/tests/test_artifacts.py`) |
|
||||
| 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 |
|
||||
|
||||
## Offene Punkte vor dem Deployment
|
||||
|
||||
@@ -11,7 +11,7 @@ Jeder Befund wurde am Code nachgeprüft, Artefakte der automatischen Auswertung
|
||||
- Übersicht der Tabellen: [KNOW-schema.md](KNOW-schema.md)
|
||||
- Interaktive Ansichten (privat):
|
||||
- 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.
|
||||
|
||||
@@ -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 |
|
||||
| B29 | Browser-Sessions (Cookies, Header) unverschlüsselt gespeichert | hoch (Sicherheit) |
|
||||
| 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
|
||||
- **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).
|
||||
- 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).
|
||||
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -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.
|
||||
Interaktive Ansichten (privat, Freigabe über das Share-Menü):
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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 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#> .
|
||||
|
||||
<urn:mygodel:know> a owl:Ontology ;
|
||||
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:viaSpalte a owl:AnnotationProperty .
|
||||
@@ -650,12 +651,8 @@ know:confirmation_token_hash a owl:DatatypeProperty ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
know:confirmed_at a owl:DatatypeProperty ;
|
||||
rdfs:domain [ a owl:Class ; owl:unionOf ( know:Ankaufsfall know:Hinweis ) ] ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
know:confirmed_by a owl:DatatypeProperty ;
|
||||
rdfs:domain know:Ankaufsfall ;
|
||||
rdfs:range xsd:string .
|
||||
rdfs:domain know:Hinweis ;
|
||||
rdfs:range xsd:dateTime .
|
||||
|
||||
know:contact_broker a owl:DatatypeProperty ;
|
||||
rdfs:domain know:Ankaufsfall ;
|
||||
@@ -1505,119 +1502,405 @@ know:zip a owl:DatatypeProperty ;
|
||||
rdfs:domain know:Projekt ;
|
||||
rdfs:range xsd:string .
|
||||
|
||||
know:Gesellschaft_P_GmbH a owl:NamedIndividual, know:Gesellschaft ;
|
||||
rdfs:label "P-GmbH" .
|
||||
know:MailKategorieSchema a skos:ConceptScheme ;
|
||||
rdfs:label "Mail-Kategorie"@de .
|
||||
|
||||
know:Gesellschaft_PplusB_KG a owl:NamedIndividual, know:Gesellschaft ;
|
||||
rdfs:label "P+B KG" .
|
||||
know:MailKategorie_mahnung a skos:Concept, know:MailKategorie ;
|
||||
skos:inScheme know:MailKategorieSchema ;
|
||||
skos:notation "mahnung" ;
|
||||
skos:prefLabel "Mahnung"@de .
|
||||
|
||||
know:MailKategorie_mahnung a owl:NamedIndividual, know:MailKategorie ;
|
||||
rdfs:label "mahnung" .
|
||||
know:MailKategorie_zahlungsaufforderung a skos:Concept, know:MailKategorie ;
|
||||
skos:inScheme know:MailKategorieSchema ;
|
||||
skos:notation "zahlungsaufforderung" ;
|
||||
skos:prefLabel "Zahlungsaufforderung"@de .
|
||||
|
||||
know:MailKategorie_zahlungsaufforderung a owl:NamedIndividual, know:MailKategorie ;
|
||||
rdfs:label "zahlungsaufforderung" .
|
||||
know:MailKategorie_buchhaltung a skos:Concept, know:MailKategorie ;
|
||||
skos:inScheme know:MailKategorieSchema ;
|
||||
skos:notation "buchhaltung" ;
|
||||
skos:prefLabel "Rechnungen"@de ;
|
||||
skos:altLabel "Buchhaltung"@de .
|
||||
|
||||
know:MailKategorie_buchhaltung a owl:NamedIndividual, know:MailKategorie ;
|
||||
rdfs:label "buchhaltung" .
|
||||
know:MailKategorie_mangel a skos:Concept, know:MailKategorie ;
|
||||
skos:inScheme know:MailKategorieSchema ;
|
||||
skos:notation "mangel" ;
|
||||
skos:prefLabel "Mangel"@de .
|
||||
|
||||
know:MailKategorie_mangel a owl:NamedIndividual, know:MailKategorie ;
|
||||
rdfs:label "mangel" .
|
||||
know:MailKategorie_newsletter_werbung a skos:Concept, know:MailKategorie ;
|
||||
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 ;
|
||||
rdfs:label "newsletter_werbung" .
|
||||
know:MailKategorie_plattformen a skos:Concept, know:MailKategorie ;
|
||||
skos:inScheme know:MailKategorieSchema ;
|
||||
skos:notation "plattformen" ;
|
||||
skos:prefLabel "Plattformen"@de .
|
||||
|
||||
know:MailKategorie_plattformen a owl:NamedIndividual, know:MailKategorie ;
|
||||
rdfs:label "plattformen" .
|
||||
know:MailKategorie_versand_bestellungen a skos:Concept, know:MailKategorie ;
|
||||
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 ;
|
||||
rdfs:label "versand_bestellungen" .
|
||||
know:MailKategorie_sonstiges a skos:Concept, know:MailKategorie ;
|
||||
skos:inScheme know:MailKategorieSchema ;
|
||||
skos:notation "sonstiges" ;
|
||||
skos:prefLabel "Sonstige"@de ;
|
||||
skos:altLabel "Sonstiges"@de .
|
||||
|
||||
know:MailKategorie_sonstiges a owl:NamedIndividual, know:MailKategorie ;
|
||||
rdfs:label "sonstiges" .
|
||||
know:MailKategorie_default a skos:Concept, know:MailKategorie ;
|
||||
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 ;
|
||||
rdfs:label "default" .
|
||||
know:EreignisartSchema a skos:ConceptScheme ;
|
||||
rdfs:label "Ereignisart"@de .
|
||||
|
||||
know:Tier_basic a owl:NamedIndividual, know:Tier ;
|
||||
rdfs:label "basic" .
|
||||
know:Ereignisart_verzoegerung a skos:Concept, know:Ereignisart ;
|
||||
skos:inScheme know:EreignisartSchema ;
|
||||
skos:notation "verzoegerung" ;
|
||||
skos:prefLabel "Verzögerung"@de .
|
||||
|
||||
know:Tier_standard a owl:NamedIndividual, know:Tier ;
|
||||
rdfs:label "standard" .
|
||||
know:Ereignisart_mangel a skos:Concept, know:Ereignisart ;
|
||||
skos:inScheme know:EreignisartSchema ;
|
||||
skos:notation "mangel" ;
|
||||
skos:prefLabel "Mangel"@de .
|
||||
|
||||
know:Tier_capable a owl:NamedIndividual, know:Tier ;
|
||||
rdfs:label "capable" .
|
||||
know:Ereignisart_termin a skos:Concept, know:Ereignisart ;
|
||||
skos:inScheme know:EreignisartSchema ;
|
||||
skos:notation "termin" ;
|
||||
skos:prefLabel "Termin"@de .
|
||||
|
||||
know:Tier_expert a owl:NamedIndividual, know:Tier ;
|
||||
rdfs:label "expert" .
|
||||
know:Ereignisart_kosten a skos:Concept, know:Ereignisart ;
|
||||
skos:inScheme know:EreignisartSchema ;
|
||||
skos:notation "kosten" ;
|
||||
skos:prefLabel "Kosten"@de .
|
||||
|
||||
know:Ereignisart_verzoegerung a owl:NamedIndividual, know:Ereignisart ;
|
||||
rdfs:label "verzoegerung" .
|
||||
know:Ereignisart_info a skos:Concept, know:Ereignisart ;
|
||||
skos:inScheme know:EreignisartSchema ;
|
||||
skos:notation "info" ;
|
||||
skos:prefLabel "Info"@de .
|
||||
|
||||
know:Ereignisart_mangel a owl:NamedIndividual, know:Ereignisart ;
|
||||
rdfs:label "mangel" .
|
||||
know:DokumenttypSchema a skos:ConceptScheme ;
|
||||
rdfs:label "Dokumenttyp"@de .
|
||||
|
||||
know:Ereignisart_termin a owl:NamedIndividual, know:Ereignisart ;
|
||||
rdfs:label "termin" .
|
||||
know:Dokumenttyp_expose a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "expose" ;
|
||||
skos:prefLabel "expose"@de .
|
||||
|
||||
know:Ereignisart_kosten a owl:NamedIndividual, know:Ereignisart ;
|
||||
rdfs:label "kosten" .
|
||||
know:Dokumenttyp_bplan a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "bplan" ;
|
||||
skos:prefLabel "bplan"@de .
|
||||
|
||||
know:Ereignisart_info a owl:NamedIndividual, know:Ereignisart ;
|
||||
rdfs:label "info" .
|
||||
know:Dokumenttyp_wirtschaftlichkeitsberechnung a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "wirtschaftlichkeitsberechnung" ;
|
||||
skos:prefLabel "wirtschaftlichkeitsberechnung"@de .
|
||||
|
||||
know:Dokumenttyp_expose a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "expose" .
|
||||
know:Dokumenttyp_kaufangebot a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "kaufangebot" ;
|
||||
skos:prefLabel "kaufangebot"@de .
|
||||
|
||||
know:Dokumenttyp_bplan a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "bplan" .
|
||||
know:Dokumenttyp_loi a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "loi" ;
|
||||
skos:prefLabel "loi"@de .
|
||||
|
||||
know:Dokumenttyp_wirtschaftlichkeitsberechnung a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "wirtschaftlichkeitsberechnung" .
|
||||
know:Dokumenttyp_nda a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "nda" ;
|
||||
skos:prefLabel "nda"@de .
|
||||
|
||||
know:Dokumenttyp_kaufangebot a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "kaufangebot" .
|
||||
know:Dokumenttyp_vertraulichkeitserklaerung a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "vertraulichkeitserklaerung" ;
|
||||
skos:prefLabel "vertraulichkeitserklaerung"@de .
|
||||
|
||||
know:Dokumenttyp_loi a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "loi" .
|
||||
know:Dokumenttyp_telefonnotiz a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "telefonnotiz" ;
|
||||
skos:prefLabel "telefonnotiz"@de .
|
||||
|
||||
know:Dokumenttyp_nda a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "nda" .
|
||||
know:Dokumenttyp_mail a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "mail" ;
|
||||
skos:prefLabel "mail"@de .
|
||||
|
||||
know:Dokumenttyp_vertraulichkeitserklaerung a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "vertraulichkeitserklaerung" .
|
||||
know:Dokumenttyp_lageplan a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "lageplan" ;
|
||||
skos:prefLabel "lageplan"@de .
|
||||
|
||||
know:Dokumenttyp_telefonnotiz a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "telefonnotiz" .
|
||||
know:Dokumenttyp_kataster a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "kataster" ;
|
||||
skos:prefLabel "kataster"@de .
|
||||
|
||||
know:Dokumenttyp_mail a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "mail" .
|
||||
know:Dokumenttyp_grundbuch a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "grundbuch" ;
|
||||
skos:prefLabel "grundbuch"@de .
|
||||
|
||||
know:Dokumenttyp_lageplan a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "lageplan" .
|
||||
know:Dokumenttyp_baugenehmigung a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "baugenehmigung" ;
|
||||
skos:prefLabel "baugenehmigung"@de .
|
||||
|
||||
know:Dokumenttyp_kataster a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "kataster" .
|
||||
know:Dokumenttyp_gutachten a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "gutachten" ;
|
||||
skos:prefLabel "gutachten"@de .
|
||||
|
||||
know:Dokumenttyp_grundbuch a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "grundbuch" .
|
||||
know:Dokumenttyp_pruefplan a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "pruefplan" ;
|
||||
skos:prefLabel "pruefplan"@de .
|
||||
|
||||
know:Dokumenttyp_baugenehmigung a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "baugenehmigung" .
|
||||
know:Dokumenttyp_fotos a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "fotos" ;
|
||||
skos:prefLabel "fotos"@de .
|
||||
|
||||
know:Dokumenttyp_gutachten a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "gutachten" .
|
||||
know:Dokumenttyp_wettbewerbsunterlagen a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "wettbewerbsunterlagen" ;
|
||||
skos:prefLabel "wettbewerbsunterlagen"@de .
|
||||
|
||||
know:Dokumenttyp_pruefplan a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "pruefplan" .
|
||||
know:Dokumenttyp_vertrag_notar a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "vertrag_notar" ;
|
||||
skos:prefLabel "vertrag_notar"@de .
|
||||
|
||||
know:Dokumenttyp_fotos a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "fotos" .
|
||||
know:Dokumenttyp_unknown a skos:Concept, know:Dokumenttyp ;
|
||||
skos:inScheme know:DokumenttypSchema ;
|
||||
skos:notation "unknown" ;
|
||||
skos:prefLabel "unknown"@de .
|
||||
|
||||
know:Dokumenttyp_wettbewerbsunterlagen a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "wettbewerbsunterlagen" .
|
||||
know:TierSchema a skos:ConceptScheme ;
|
||||
rdfs:label "Tier"@de .
|
||||
|
||||
know:Dokumenttyp_vertrag_notar a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "vertrag_notar" .
|
||||
know:Tier_basic a skos:Concept, know:Tier ;
|
||||
skos:inScheme know:TierSchema ;
|
||||
skos:notation "basic" ;
|
||||
skos:prefLabel "basic"@de .
|
||||
|
||||
know:Dokumenttyp_unknown a owl:NamedIndividual, know:Dokumenttyp ;
|
||||
rdfs:label "unknown" .
|
||||
know:Tier_standard a skos:Concept, know:Tier ;
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|
||||
Reference in New Issue
Block a user