From 9442cc49d1b587945c22a86ca62f5094321e69c0 Mon Sep 17 00:00:00 2001 From: Robert Rapp Date: Tue, 13 Oct 2026 02:54:49 +0200 Subject: [PATCH] ontology: Ontologie als Quelle im Repo, Abgleich mit n8n/KNOW in der Service-Routine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 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 --- .env.example | 2 +- Makefile | 2 +- README.md | 1 + config/policy.yaml | 1 + core/src/knaster/cli.py | 108 +- core/src/knaster/maintenance/actions.py | 11 + core/src/knaster/maintenance/agent.py | 41 +- core/src/knaster/ontology/__init__.py | 10 + core/src/knaster/ontology/apply.py | 78 + core/src/knaster/ontology/check.py | 177 +++ core/src/knaster/ontology/export.py | 133 ++ core/src/knaster/ontology/model.py | 148 ++ core/src/knaster/ontology/n8n.py | 325 +++++ core/src/knaster/settings.py | 8 + core/src/knaster/store.py | 32 + core/src/knaster/system.py | 36 +- core/src/knaster/validate.py | 15 + core/tests/conftest.py | 3 +- core/tests/test_ontology.py | 267 ++++ deploy/compose/infra/.env.example | 3 + deploy/docker/router.Dockerfile | 2 + docs/BETRIEB.md | 30 + docs/IMPLEMENTATION.md | 4 +- docs/reference/KNOW-befunde.md | 26 +- docs/reference/KNOW-schema.md | 4 +- docs/spec/SPEC-ontology.md | 100 ++ .../know-ontology.ttl => ontology/know.ttl | 453 ++++-- ontology/know.yaml | 1292 +++++++++++++++++ ontology/viewer.html.tmpl | 459 ++++++ 29 files changed, 3675 insertions(+), 96 deletions(-) create mode 100644 core/src/knaster/ontology/__init__.py create mode 100644 core/src/knaster/ontology/apply.py create mode 100644 core/src/knaster/ontology/check.py create mode 100644 core/src/knaster/ontology/export.py create mode 100644 core/src/knaster/ontology/model.py create mode 100644 core/src/knaster/ontology/n8n.py create mode 100644 core/tests/test_ontology.py create mode 100644 docs/spec/SPEC-ontology.md rename docs/reference/know-ontology.ttl => ontology/know.ttl (78%) create mode 100644 ontology/know.yaml create mode 100644 ontology/viewer.html.tmpl diff --git a/.env.example b/.env.example index e2cc446..e039684 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/Makefile b/Makefile index 06bb438..f767437 100644 --- a/Makefile +++ b/Makefile @@ -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 . diff --git a/README.md b/README.md index ef226dd..4bc310f 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/config/policy.yaml b/config/policy.yaml index b68f3b2..ef72d51 100644 --- a/config/policy.yaml +++ b/config/policy.yaml @@ -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} diff --git a/core/src/knaster/cli.py b/core/src/knaster/cli.py index ca997da..2265714 100644 --- a/core/src/knaster/cli.py +++ b/core/src/knaster/cli.py @@ -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()) diff --git a/core/src/knaster/maintenance/actions.py b/core/src/knaster/maintenance/actions.py index cfc7d9e..e9ce849 100644 --- a/core/src/knaster/maintenance/actions.py +++ b/core/src/knaster/maintenance/actions.py @@ -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] diff --git a/core/src/knaster/maintenance/agent.py b/core/src/knaster/maintenance/agent.py index a6f4a17..aaa3fde 100644 --- a/core/src/knaster/maintenance/agent.py +++ b/core/src/knaster/maintenance/agent.py @@ -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: - wechsle auf · trainiere adapter für · rollback -- stufe zurück · markiere als ok|degraded|unavailable""" +- stufe zurück · markiere als ok|degraded|unavailable +- ordne P-7 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[\w.-]+)$"), ("demote", r"^(stufe\s+(?P[\w.-]+)\s+zurück|degradiere\s+(?P[\w.-]+))$"), ("mark", r"^markiere\s+(?P[\w.-]+)\s+(als\s+)?(?Pok|degraded|unavailable)$"), + ("map_concept", rf"^ordne\s+(?P{_ID})\s+(dem\s+konzept\s+)?(?P[\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]) diff --git a/core/src/knaster/ontology/__init__.py b/core/src/knaster/ontology/__init__.py new file mode 100644 index 0000000..a8fd5e6 --- /dev/null +++ b/core/src/knaster/ontology/__init__.py @@ -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"] diff --git a/core/src/knaster/ontology/apply.py b/core/src/knaster/ontology/apply.py new file mode 100644 index 0000000..7545ff8 --- /dev/null +++ b/core/src/knaster/ontology/apply.py @@ -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 diff --git a/core/src/knaster/ontology/check.py b/core/src/knaster/ontology/check.py new file mode 100644 index 0000000..82ec3c5 --- /dev/null +++ b/core/src/knaster/ontology/check.py @@ -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 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 diff --git a/core/src/knaster/ontology/export.py b/core/src/knaster/ontology/export.py new file mode 100644 index 0000000..1d8323d --- /dev/null +++ b/core/src/knaster/ontology/export.py @@ -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: .", + "@prefix rdf: .", + "@prefix rdfs: .", + "@prefix skos: .", + "@prefix xsd: .", "", + 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(" 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 diff --git a/core/src/knaster/ontology/n8n.py b/core/src/knaster/ontology/n8n.py new file mode 100644 index 0000000..709c01b --- /dev/null +++ b/core/src/knaster/ontology/n8n.py @@ -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() diff --git a/core/src/knaster/settings.py b/core/src/knaster/settings.py index 02cbc35..b635045 100644 --- a/core/src/knaster/settings.py +++ b/core/src/knaster/settings.py @@ -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", ""), ) diff --git a/core/src/knaster/store.py b/core/src/knaster/store.py index 691fe37..6d705b1 100644 --- a/core/src/knaster/store.py +++ b/core/src/knaster/store.py @@ -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) diff --git a/core/src/knaster/system.py b/core/src/knaster/system.py index 14178b7..c0ce4d4 100644 --- a/core/src/knaster/system.py +++ b/core/src/knaster/system.py @@ -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) diff --git a/core/src/knaster/validate.py b/core/src/knaster/validate.py index c328134..1ba8f10 100644 --- a/core/src/knaster/validate.py +++ b/core/src/knaster/validate.py @@ -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: diff --git a/core/tests/conftest.py b/core/tests/conftest.py index 24df380..c9a4220 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -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) diff --git a/core/tests/test_ontology.py b/core/tests/test_ontology.py new file mode 100644 index 0000000..292c2ed --- /dev/null +++ b/core/tests/test_ontology.py @@ -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() diff --git a/deploy/compose/infra/.env.example b/deploy/compose/infra/.env.example index 561d113..d139e8c 100644 --- a/deploy/compose/infra/.env.example +++ b/deploy/compose/infra/.env.example @@ -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= diff --git a/deploy/docker/router.Dockerfile b/deploy/docker/router.Dockerfile index 42b3799..cba8af7 100644 --- a/deploy/docker/router.Dockerfile +++ b/deploy/docker/router.Dockerfile @@ -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 diff --git a/docs/BETRIEB.md b/docs/BETRIEB.md index 2ae9f7d..233ae4a 100644 --- a/docs/BETRIEB.md +++ b/docs/BETRIEB.md @@ -71,3 +71,33 @@ Freitext, der keinem Befehl entspricht, bildet ein kleines Modell auf einen Befe | Skill-Stufe | Prompt `stufe 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. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 033ee92..3ba03b2 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -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 diff --git a/docs/reference/KNOW-befunde.md b/docs/reference/KNOW-befunde.md index 8a60b34..f4fcb2a 100644 --- a/docs/reference/KNOW-befunde.md +++ b/docs/reference/KNOW-befunde.md @@ -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. diff --git a/docs/reference/KNOW-schema.md b/docs/reference/KNOW-schema.md index fd7ed98..6c99556 100644 --- a/docs/reference/KNOW-schema.md +++ b/docs/reference/KNOW-schema.md @@ -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 diff --git a/docs/spec/SPEC-ontology.md b/docs/spec/SPEC-ontology.md new file mode 100644 index 0000000..11df162 --- /dev/null +++ b/docs/spec/SPEC-ontology.md @@ -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:`. +- `genehmige P-x` → Entscheidung `accept`; `ordne P-x 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`. diff --git a/docs/reference/know-ontology.ttl b/ontology/know.ttl similarity index 78% rename from docs/reference/know-ontology.ttl rename to ontology/know.ttl index 3a31375..b58a277 100644 --- a/docs/reference/know-ontology.ttl +++ b/ontology/know.ttl @@ -2,11 +2,12 @@ @prefix owl: . @prefix rdf: . @prefix rdfs: . +@prefix skos: . @prefix xsd: . 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 . diff --git a/ontology/know.yaml b/ontology/know.yaml new file mode 100644 index 0000000..115988c --- /dev/null +++ b/ontology/know.yaml @@ -0,0 +1,1292 @@ +# KNOW-Ontologie: Quelle der Wahrheit für Begriffe, Beziehungen und Vokabulare der n8n-Wissensdatenbank. +# Pflege: SPEC-ontology.md. Kern (classes, relations) per Commit; Vokabulare wachsen über Vorschläge der +# Service-Routine und `knaster ontology apply`. Erzeugt: Turtle, Graph, Webseite (`knaster ontology export`). +id: know +version: 1 +label: KNOW-Ontologie +namespace: urn:mygodel:know# +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 +classes: + Postfach: + label: Postfach + module: mail + definition: Ein Microsoft-365-Postfach, das die Workflows lesen. Es gibt keine eigene Tabelle, das Postfach steckt in + der Spalte mailbox mehrerer Tabellen. + key: mailbox + Mail: + label: Mail + module: mail + definition: Eine eingegangene und klassifizierte Mail. + table: mail_events + key: message_id + sensitive: Absender und Betreff jeder verarbeiteten Mail + attributes: + id: BIGSERIAL + message_id: TEXT + mailbox: TEXT + sender: TEXT + subject: TEXT + received_at: TIMESTAMPTZ + category: TEXT + assigned_project_key: TEXT + workflow_status: TEXT + raw_signals: JSONB + last_processed_at: TIMESTAMPTZ + Absender: + label: Absender + module: mail + definition: Absenderadresse, beobachtet in einem Postfach über ein Zeitfenster (Profil mit Kategorien, Junk-Anteil, Abmelde-Link). + table: sender_observations + key: observation_key + attributes: + id: BIGSERIAL + observation_key: TEXT + mailbox: TEXT + email: TEXT + registered_domain: TEXT + display_name: TEXT + window_from: TIMESTAMPTZ + window_to: TIMESTAMPTZ + mail_count: INTEGER + junk_count: INTEGER + category_histogram: JSONB + dominant_category: TEXT + dominant_share: NUMERIC + avg_confidence: NUMERIC + absender_typ: TEXT + mail_art: TEXT + unsubscribe_url: TEXT + unsubscribe_mailto: TEXT + unsubscribe_one_click: BOOLEAN + first_mail_at: TIMESTAMPTZ + last_mail_at: TIMESTAMPTZ + subjects_sample: JSONB + source: TEXT + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + Domain: + label: Domain + module: mail + definition: Registrierte Domain eines Absenders; bekannte Plattformen sind eigens markiert. + table: platform_domains + key: registered_domain + attributes: + id: BIGSERIAL + registered_domain: TEXT + display_name: TEXT + status: TEXT + source: TEXT + evidence_json: JSONB + first_seen_at: TIMESTAMPTZ + last_seen_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + Postfachzugriff: + label: Postfachzugriff + module: mail + definition: Delegierter Zugriff auf ein Postfach (OAuth-Token, verschlüsselt gespeichert). + table: mailbox_grants + key: grant_key + provider + sensitive: OAuth-Tokens, verschlüsselt (AES-256-GCM) + attributes: + id: BIGSERIAL + grant_key: TEXT + provider: TEXT + scope: TEXT + token_ciphertext: TEXT + token_iv: TEXT + token_tag: TEXT + token_alg: TEXT + expires_hint: TIMESTAMPTZ + status: TEXT + source_workflow: TEXT + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + MailKategorie: + label: Mail-Kategorie + module: mail + definition: 'Fachliche Kategorie einer Mail mit Regeln: ab wie vielen Beobachtungen die KI übersprungen wird, Priorität, + Modellwahl, Safety-Floor.' + table: workflow_hint_config + key: config_key + attributes: + config_key: TEXT + min_observations_for_skip_ai: INTEGER + priority_level: TEXT + preferred_model: TEXT + safety_floor: BOOLEAN + relevance_weight: NUMERIC + outlook_category: TEXT + requires_action: BOOLEAN + description: TEXT + updated_at: TIMESTAMPTZ + Projekt: + label: Projekt + module: kanbau + definition: Bauprojekt aus KanBau mit Adresse und Kürzel. + table: projects + key: project_key + attributes: + id: BIGSERIAL + project_key: TEXT + kanbau_project_id: TEXT + project_number: TEXT + project_name: TEXT + short_code: TEXT + company_context: TEXT + street: TEXT + zip: TEXT + city: TEXT + full_address: TEXT + normalized_address: TEXT + status: TEXT + raw_kanbau_json: JSONB + last_synced_at: TIMESTAMPTZ + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + ProjektAlias: + label: Projekt-Alias + module: kanbau + definition: 'Andere Bezeichnung für ein Projekt: Adresse, Kürzel oder ein aus Mails gelerntes Token.' + table: project_aliases + key: alias_key + attributes: + id: BIGSERIAL + alias_key: TEXT + project_key: TEXT + kanbau_project_id: TEXT + alias_type: TEXT + alias_value: TEXT + normalized_value: TEXT + source_workflow: TEXT + confidence: NUMERIC + evidence_json: JSONB + quality_status: TEXT + verified_at: TIMESTAMPTZ + created_at: TIMESTAMPTZ + Kontakt: + label: Kontakt + module: kanbau + definition: Firma oder Person mit Kontaktdaten, Steuernummern und Relevanz-Score. + table: contacts + key: contact_key + sensitive: 'Personenbezogen: E-Mail, Telefon, Steuer-Nr., USt-IdNr. (IBAN nur als Hash)' + attributes: + id: BIGSERIAL + contact_key: TEXT + contact_type: TEXT + display_name: TEXT + legal_name: TEXT + normalized_name: TEXT + email: TEXT + phone: TEXT + website: TEXT + tax_id: TEXT + vat_id: TEXT + iban_hash: TEXT + address_json: JSONB + source: TEXT + source_confidence: NUMERIC + kanbau_address_id: TEXT + raw_evidence_json: JSONB + quality_status: TEXT + first_seen_at: TIMESTAMPTZ + last_seen_at: TIMESTAMPTZ + verified_at: TIMESTAMPTZ + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + business_relevance_score: NUMERIC + KontaktAlias: + label: Kontakt-Alias + module: kanbau + definition: Andere Schreibweise eines Kontakts. + table: contact_aliases + key: alias_key + attributes: + id: BIGSERIAL + alias_key: TEXT + contact_key: TEXT + alias_type: TEXT + alias_value: TEXT + normalized_value: TEXT + source_workflow: TEXT + confidence: NUMERIC + evidence_json: JSONB + quality_status: TEXT + created_at: TIMESTAMPTZ + Projektbeteiligung: + label: Projektbeteiligung + module: kanbau + definition: Rolle eines Kontakts in einem Projekt, mit Gültigkeitszeitraum (reifizierte Beziehung). + table: contact_project_relations + key: relation_key + attributes: + id: BIGSERIAL + relation_key: TEXT + contact_key: TEXT + project_key: TEXT + relation_type: TEXT + confidence: NUMERIC + source_workflow: TEXT + evidence_json: JSONB + valid_from: DATE + valid_to: DATE + quality_status: TEXT + created_at: TIMESTAMPTZ + Projektereignis: + label: Projektereignis + module: kanbau + definition: Aus einer Mail erkanntes Ereignis zu einem Projekt (Verzögerung, Mangel, Termin, Kosten, Info) mit Dringlichkeit. + table: project_events + key: message_id + attributes: + id: BIGSERIAL + message_id: TEXT + project_key: TEXT + mailbox: TEXT + sender: TEXT + event_date: TIMESTAMPTZ + category: TEXT + affected_unit: TEXT + urgency: TEXT + summary: TEXT + sharepoint_path: TEXT + quality_status: TEXT + source_workflow: TEXT + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + Ereignisart: + label: Ereignisart + module: kanbau + definition: Art eines Projektereignisses. Als CHECK-Constraint der Spalte project_events.category festgelegt. + key: category + Projektordner: + label: Projektordner + module: kanbau + definition: SharePoint-Ordner eines Projekts. + table: project_folders + key: project_key + drive_id + attributes: + id: BIGSERIAL + project_key: TEXT + drive_id: TEXT + folder_item_id: TEXT + folder_path: TEXT + folder_name: TEXT + verified_at: TIMESTAMPTZ + source_workflow: TEXT + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + Projektkuerzel: + label: Projektkürzel + module: kanbau + definition: Kürzel einer Baustelle mit der zuständigen Gesellschaft. + table: project_companies + key: kuerzel + attributes: + kuerzel: TEXT + gesellschaft: TEXT + quelle: TEXT + bestaetigt: BOOLEAN + baustelle: TEXT + evidence_json: JSONB + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + Gesellschaft: + label: Gesellschaft + module: kanbau + definition: Rechtsträger, dem Projekte und Rechnungen zugeordnet werden. Als CHECK-Constraint festgelegt. + key: gesellschaft + Rechnungsbeobachtung: + label: Rechnungsbeobachtung + module: kanbau + definition: In einer Rechnung (Mail-Anhang) beobachtete Zuordnung Kürzel → Gesellschaft. + table: invoice_company_observations + key: message_id + file_name + attributes: + message_id: TEXT + file_name: TEXT + kuerzel: TEXT + gesellschaft: TEXT + mailbox: TEXT + source_workflow: TEXT + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + Dokument: + label: Dokument + module: docs + definition: Oberbegriff für alle gespeicherten Dokumente und Extraktionen (abstrakt). + abstract: true + AnhangExtraktion: + label: Anhang-Extraktion + module: docs + definition: Text und strukturierte Felder, die aus einem Mail-Anhang gelesen wurden. + table: document_extractions + key: id + sensitive: Extrahierter Volltext von Anhängen + subclass_of: Dokument + attributes: + id: BIGSERIAL + request_id: TEXT + message_id: TEXT + mailbox: TEXT + attachment_id: TEXT + file_name: TEXT + content_type: TEXT + extraction_source: TEXT + document_type: TEXT + sender_contact_key: TEXT + project_key: TEXT + extracted_text: TEXT + structured_json: JSONB + detected_addresses_json: JSONB + detected_contacts_json: JSONB + confidence: NUMERIC + created_at: TIMESTAMPTZ + Ankaufsfall: + label: Ankaufsfall + module: kaf + definition: 'Grundstück oder Objekt in der Ankaufsprüfung: Pipeline-Status, Ergebnis, Fristen, Angebote, Unterlagen, Risiko.' + table: property_development_cases + key: case_key + sensitive: Kaufpreise, Provisionen und Besprechungsmitschriften + attributes: + id: BIGSERIAL + case_key: TEXT + name: TEXT + normalized_name: TEXT + status: TEXT + pipeline_status: TEXT + outcome_status: TEXT + case_code: TEXT + case_opened_at: DATE + folder_name: TEXT + folder_path: TEXT + folder_status_source: TEXT + source_channel: TEXT + location_city: TEXT + location_district: TEXT + street_address: TEXT + assigned_to: TEXT + asset_type: TEXT + process_type: TEXT + notes: TEXT + property_size_sqm: NUMERIC + original_purchase_price_eur: NUMERIC + average_price_per_sqm_eur: NUMERIC + commission_including_vat: TEXT + apartment_count: INTEGER + development_plan_available: BOOLEAN + godel_internal: BOOLEAN + contact_broker: TEXT + start_date: DATE + offer_deadline: DATE + offer_submitted_at: DATE + last_offer_at: DATE + offer_count: INTEGER + review_status: TEXT + review_result: TEXT + meeting_transcripts: TEXT + just_one_week_more: BOOLEAN + deadline_is_overdue: BOOLEAN + godel_offer: TEXT + project_lead: TEXT + planning: TEXT + nda_present: BOOLEAN + loi_present: BOOLEAN + bplan_document_present: BOOLEAN + wirtschaftlichkeit_present: BOOLEAN + purchase_offer_present: BOOLEAN + building_permit_present: BOOLEAN + risk_summary: TEXT + missing_documents: JSONB + next_action: TEXT + next_action_due_at: DATE + last_document_at: DATE + document_count: INTEGER + case_readiness: TEXT + detected_documents: JSONB + risk_level: TEXT + readiness_json: JSONB + source_system: TEXT + sharepoint_json: JSONB + raw_evidence_json: JSONB + quality_status: TEXT + confidence: NUMERIC + first_seen_at: TIMESTAMPTZ + last_seen_at: TIMESTAMPTZ + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + confirmation_token_hash: TEXT + confirmation_status: TEXT + confirmation_expires_at: TIMESTAMPTZ + selected_project_id: TEXT + created_saleagr_id: TEXT + created_saleagr_url: TEXT + confirmation_request_id: TEXT + Falldokument: + label: Falldokument + module: kaf + definition: Unterlage zu einem Ankaufsfall aus SharePoint, mit Dokumenttyp und Versionierung. + table: property_development_documents + key: document_key + subclass_of: Dokument + attributes: + id: BIGSERIAL + case_key: TEXT + document_key: TEXT + sharepoint_item_id: TEXT + file_name: TEXT + file_path: TEXT + folder_segment: TEXT + document_type: TEXT + document_date: DATE + received_at: DATE + author_or_sender: TEXT + source_party: TEXT + extracted_summary: TEXT + extracted_fields_json: JSONB + confidence: NUMERIC + is_current_version: BOOLEAN + supersedes_document_key: TEXT + processing_status: TEXT + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + Dokumenttyp: + label: Dokumenttyp + module: kaf + definition: Typ eines Falldokuments, im KNOW-Code als feste Liste hinterlegt. + key: document_type + Kaufvertragspruefung: + label: Kaufvertragsprüfung + module: kaf + definition: 'Prüfstand eines Kaufvertrags: extrahierte Felder, Einheiten, Freigabe durch eine Mitarbeiterin oder einen + Mitarbeiter.' + table: kaf_review_snapshots + key: case_key + sensitive: Kaufvertragsfelder und Prüfer + attributes: + id: BIGSERIAL + case_key: TEXT + review_schema_version: TEXT + confirmation_token_hash: TEXT + confirmation_expires_at: TIMESTAMPTZ + review_status: TEXT + request_id: TEXT + review_to: TEXT + delivery_mode: TEXT + original_json: JSONB + distribution_json: JSONB + fields_json: JSONB + units_json: JSONB + artifacts_json: JSONB + reviewed_values_json: JSONB + review_comment: TEXT + reviewed_by: TEXT + review_identity_source: TEXT + review_submission_hash: TEXT + reviewed_at: TIMESTAMPTZ + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + Notarkanzlei: + label: Notarkanzlei + module: kaf + definition: Notarkanzlei mit gelernten Feldmustern für das Auslesen von Kaufverträgen. + table: kanzlei_field_patterns + key: kanzlei_key + attributes: + kanzlei_key: TEXT + kanzlei_display: TEXT + patterns: JSONB + updated_at: TIMESTAMPTZ + Kanzleimandat: + label: Kanzleimandat + module: kaf + definition: Zuordnung Kanzlei ↔ Projekt mit Notarname (reifizierte Beziehung). + table: kanzlei_project_relations + key: relation_key + attributes: + relation_key: TEXT + kanzlei_key: TEXT + project_key: TEXT + project_name: TEXT + notar_name: TEXT + updated_at: TIMESTAMPTZ + Ort: + label: Ort / Flurstück + module: kaf + definition: Geokodierte Adresse mit Flurstück und Bundesland. + table: place_enrichments + key: place_key + attributes: + id: BIGSERIAL + place_key: TEXT + input_address: TEXT + match_address: TEXT + flurstueck: TEXT + lat: DOUBLE PRECISION + lon: DOUBLE PRECISION + bundesland: TEXT + enrichment_json: JSONB + provenance_json: JSONB + quality_status: TEXT + confidence: DOUBLE PRECISION + source_workflow: TEXT + created_at: TIMESTAMPTZ + refreshed_at: TIMESTAMPTZ + KanbauKaufvertrag: + label: KanBau-Kaufvertrag + module: kaf + definition: Kaufvertrag, der nach Bestätigung in KanBau angelegt wird (created_saleagr_id). + key: created_saleagr_id + Beleg: + label: Beleg + module: finance + definition: Verarbeiteter SEPA-Beleg mit Betrag, reserviert und dann hochgeladen. + table: bel_processed_belege + key: beleg_hash + sensitive: Bank, Konto-Referenz und Beträge + attributes: + id: BIGSERIAL + beleg_hash: TEXT + status: TEXT + bank: TEXT + account_ref: TEXT + amount_cents: BIGINT + sharepoint_path: TEXT + export_ref: TEXT + request_id: TEXT + raw_evidence_json: JSONB + reserved_at: TIMESTAMPTZ + uploaded_at: TIMESTAMPTZ + last_seen_at: TIMESTAMPTZ + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + Bankkonto: + label: Bankkonto + module: finance + definition: Konto, zu dem ein Beleg gehört (bank + account_ref). + key: account_ref + Workflow: + label: n8n-Workflow + module: learning + definition: Ein n8n-Workflow, der entscheidet, lernt oder aufgerufen wird. Steht nur als Name in Spalten. + key: workflow + Entscheidung: + label: Entscheidung + module: learning + definition: Vorschlag eines Workflows und die tatsächliche Entscheidung mit Korrekturgrund. + table: workflow_decisions + key: id + attributes: + id: BIGSERIAL + request_id: TEXT + workflow: TEXT + reference_type: TEXT + reference_id: TEXT + proposed_decision: JSONB + final_decision: JSONB + confidence: NUMERIC + correction_reason: TEXT + quality_status: TEXT + created_at: TIMESTAMPTZ + verified_at: TIMESTAMPTZ + decision_type: TEXT + decided_by: TEXT + updated_at: TIMESTAMPTZ + Referenzobjekt: + label: Referenzobjekt + module: learning + definition: Objekt, auf das sich eine Entscheidung bezieht. Polymorph über reference_type und reference_id, die Typen + legt das Schema nicht fest. + key: reference_type + reference_id + Hinweis: + label: Hinweis (gelerntes Muster) + module: learning + definition: Gelerntes Muster (Schlüssel und Wert → vorgeschlagene Aktion) mit Zähler, Konfidenz und Bestätigung. + table: workflow_hints + key: hint_key + attributes: + hint_key: TEXT + workflow: TEXT + hint_type: TEXT + pattern_key: TEXT + pattern_value: TEXT + suggested_action: JSONB + observation_count: INTEGER + confidence: NUMERIC + quality_status: TEXT + pending_notification: BOOLEAN + notified_at: TIMESTAMPTZ + confirmed_at: TIMESTAMPTZ + confirmed_by: TEXT + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + KanbauApiMethode: + label: KanBau-API-Methode + module: learning + definition: Validiertes Request/Response-Beispiel einer KanBau-API-Methode. + table: kanbau_method_examples + key: method_key + attributes: + method_key: TEXT + swagger_ref: TEXT + example_request: JSONB + example_response: JSONB + validated_at: TIMESTAMPTZ + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + Mitarbeiter: + label: Mitarbeiter:in + module: learning + definition: Person im Unternehmen (@mygodel.de), die prüft, bestätigt oder bearbeitet. Steht nur als Name oder Adresse + in Spalten. + key: E-Mail + AgentBereich: + label: Agent-Bereich + module: agent + definition: Einsatzbereich eines Agenten (scope_key). + key: scope_key + Faehigkeit: + label: Fähigkeit + module: agent + definition: 'Was ein Agent tun darf: Beispiel-Intents, Workflow, Risiko, Freigabepflicht.' + table: agent_capabilities + key: scope_key + capability_key + attributes: + id: BIGSERIAL + scope_key: TEXT + capability_key: TEXT + title: TEXT + description: TEXT + intent_examples: JSONB + workflow_name: TEXT + workflow_operation: TEXT + read_or_write: TEXT + risk_level: TEXT + requires_approval: BOOLEAN + enabled: BOOLEAN + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + Kontextdokument: + label: Kontextdokument + module: agent + definition: Eingelesenes Dokument als Wissen für einen Agenten. + table: agent_context_documents + key: scope_key + source_path + content_hash + sensitive: Volltext eingelesener Dokumente + subclass_of: Dokument + attributes: + id: BIGSERIAL + scope_key: TEXT + source_path: TEXT + document_type: TEXT + title: TEXT + content: TEXT + content_hash: TEXT + last_ingested_at: TIMESTAMPTZ + AgentAusfuehrung: + label: Agent-Ausführung + module: agent + definition: Protokoll einer Agent-Anfrage mit gewählter Fähigkeit und Ergebnis. + table: agent_execution_log + key: id + attributes: + id: BIGSERIAL + request_id: TEXT + agent_scope: TEXT + user_request: TEXT + selected_capability: TEXT + workflow_called: TEXT + inputs_json: JSONB + status: TEXT + errors_json: JSONB + created_at: TIMESTAMPTZ + LoginAnbieter: + label: Login-Anbieter + module: bsb + definition: Webanwendung mit hinterlegtem Login-Ablauf für die Browser-Automation. + table: session_providers + key: provider_id + attributes: + provider_id: TEXT + display_name: TEXT + login_url: TEXT + auth_type: TEXT + steps_json: JSONB + credential_key: TEXT + max_session_age_minutes: INTEGER + notes: TEXT + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + BrowserSession: + label: Browser-Session + module: bsb + definition: Gespeicherte Sitzung bei einem Login-Anbieter mit Ablaufzeit. + table: browser_sessions + key: provider_id + sensitive: Session-Daten als JSON; am Schema nicht erkennbar, ob verschlüsselt + attributes: + provider_id: TEXT + session_data: JSONB + expires_at: TIMESTAMPTZ + created_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + LlmWorker: + label: LLM-Worker + module: llm + definition: Lokaler Rechner mit LLM, der sich per Heartbeat meldet (optional mit OpenAI-Endpoint). + table: llm_workers + key: mac + attributes: + id: BIGSERIAL + mac: TEXT + host: TEXT + model: TEXT + tier: TEXT + tier_rank: SMALLINT + status: TEXT + hardware_json: JSONB + last_seen_at: TIMESTAMPTZ + registered_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + endpoint: JSONB + WorkerSperre: + label: Worker-Sperre + module: llm + definition: Gesperrter Worker (unsupported). + table: llm_worker_blocklist + key: mac + attributes: + id: BIGSERIAL + mac: TEXT + host: TEXT + reason: TEXT + hardware_json: JSONB + blocked_at: TIMESTAMPTZ + LlmJob: + label: LLM-Job + module: llm + definition: 'Auftrag im Job-Board: Prompt, Mindest-Tier, Ergebnis, Dauer.' + table: llm_jobs + key: job_id + sensitive: Prompt und Ergebnis im Klartext + attributes: + id: BIGSERIAL + job_id: TEXT + prompt: TEXT + min_tier: TEXT + min_tier_rank: SMALLINT + status: TEXT + assigned_to: TEXT + model: TEXT + tier: TEXT + result: TEXT + duration_ms: INTEGER + error_message: TEXT + created_at: TIMESTAMPTZ + assigned_at: TIMESTAMPTZ + completed_at: TIMESTAMPTZ + updated_at: TIMESTAMPTZ + Tier: + label: Tier + module: llm + definition: Leistungsstufe eines Workers bzw. Mindeststufe eines Jobs. + key: tier + Modell: + label: Modell + module: llm + definition: LLM-Modell, das ein Worker betreibt. Erlaubte Werte stehen in der Worker Registry. + key: model + KanBau: + label: KanBau + module: system + definition: Externes Bau- und Projektverwaltungssystem (Projekte, Adressen, Kaufverträge). + SharePoint: + label: SharePoint + module: system + definition: Ablage für Projekt-, Fall- und Belegdokumente. + Microsoft365: + label: Microsoft 365 + module: system + definition: Postfächer und delegierter Zugriff (provider microsoft). +relations: +- source: Mail + name: empfangenIn + target: Postfach + via: mail_events.mailbox +- source: Mail + name: gesendetVon + target: Absender + via: mail_events.sender = sender_observations.email + kind: soft +- source: Mail + name: hatKategorie + target: MailKategorie + via: mail_events.category = workflow_hint_config.config_key + kind: soft +- source: Mail + name: zugeordnetZu + target: Projekt + via: mail_events.assigned_project_key +- source: Absender + name: beobachtetIn + target: Postfach + via: sender_observations.mailbox +- source: Absender + name: gehoertZuDomain + target: Domain + via: sender_observations.registered_domain (JOIN) +- source: Absender + name: entsprichtKontakt + target: Kontakt + via: E-Mail-Adresse (contacts.email) + kind: soft +- source: Postfachzugriff + name: gewaehrtFuer + target: Postfach + via: mailbox_grants.grant_key + kind: soft +- source: Postfach + name: liegtIn + target: Microsoft365 + via: mailbox_grants.provider = 'microsoft' + kind: external +- source: AnhangExtraktion + name: ausMail + target: Mail + via: document_extractions.message_id +- source: AnhangExtraktion + name: vonKontakt + target: Kontakt + via: document_extractions.sender_contact_key +- source: AnhangExtraktion + name: betrifftProjekt + target: Projekt + via: document_extractions.project_key +- source: ProjektAlias + name: bezeichnet + target: Projekt + via: project_aliases.project_key (JOIN) +- source: KontaktAlias + name: bezeichnet + target: Kontakt + via: contact_aliases.contact_key +- source: Projektbeteiligung + name: vonKontakt + target: Kontakt + via: contact_project_relations.contact_key +- source: Projektbeteiligung + name: betrifftProjekt + target: Projekt + via: contact_project_relations.project_key +- source: Projekt + name: stammtAus + target: KanBau + via: projects.kanbau_project_id + kind: external +- source: Kontakt + name: stammtAus + target: KanBau + via: contacts.kanbau_address_id + kind: external +- source: Projektereignis + name: ausMail + target: Mail + via: project_events.message_id +- source: Projektereignis + name: betrifftProjekt + target: Projekt + via: project_events.project_key +- source: Projektereignis + name: hatArt + target: Ereignisart + via: project_events.category +- source: Projektereignis + name: abgelegtIn + target: SharePoint + via: project_events.sharepoint_path + kind: external +- source: Projektordner + name: betrifftProjekt + target: Projekt + via: project_folders.project_key +- source: Projektordner + name: liegtIn + target: SharePoint + via: project_folders.drive_id, folder_item_id + kind: external +- source: Projektkuerzel + name: gehoertZu + target: Gesellschaft + via: project_companies.gesellschaft +- source: Rechnungsbeobachtung + name: ausMail + target: Mail + via: invoice_company_observations.message_id +- source: Rechnungsbeobachtung + name: nenntKuerzel + target: Projektkuerzel + via: invoice_company_observations.kuerzel (JOIN) +- source: Rechnungsbeobachtung + name: gehoertZu + target: Gesellschaft + via: invoice_company_observations.gesellschaft +- source: Ankaufsfall + name: hatDokument + target: Falldokument + via: property_development_documents.case_key (JOIN) +- source: Falldokument + name: ersetzt + target: Falldokument + via: supersedes_document_key +- source: Falldokument + name: hatTyp + target: Dokumenttyp + via: property_development_documents.document_type +- source: Falldokument + name: liegtIn + target: SharePoint + via: sharepoint_item_id, file_path + kind: external +- source: Ankaufsfall + name: hatPruefung + target: Kaufvertragspruefung + via: kaf_review_snapshots.case_key +- source: Ankaufsfall + name: wirdZuProjekt + target: Projekt + via: selected_project_id (KanBau-Projekt-ID) +- source: Ankaufsfall + name: erzeugt + target: KanbauKaufvertrag + via: created_saleagr_id, created_saleagr_url +- source: KanbauKaufvertrag + name: liegtIn + target: KanBau + via: created_saleagr_url + kind: external +- source: Ankaufsfall + name: liegtAn + target: Ort + via: street_address, location_city ↔ place_enrichments.input_address + kind: soft +- source: Ankaufsfall + name: liegtIn + target: SharePoint + via: folder_path, sharepoint_json + kind: external +- source: Ankaufsfall + name: bearbeitetVon + target: Mitarbeiter + via: assigned_to, project_lead + kind: soft +- source: Projekt + name: liegtAn + target: Ort + via: full_address ↔ place_enrichments.input_address + kind: soft +- source: Kanzleimandat + name: vonKanzlei + target: Notarkanzlei + via: kanzlei_project_relations.kanzlei_key +- source: Kanzleimandat + name: betrifftProjekt + target: Projekt + via: kanzlei_project_relations.project_key +- source: Kaufvertragspruefung + name: geprueftVon + target: Mitarbeiter + via: reviewed_by, review_to (@mygodel.de) + kind: soft +- source: Beleg + name: gehoertZuKonto + target: Bankkonto + via: bel_processed_belege.bank, account_ref +- source: Beleg + name: abgelegtIn + target: SharePoint + via: bel_processed_belege.sharepoint_path + kind: external +- source: Entscheidung + name: getroffenVon + target: Workflow + via: workflow_decisions.workflow +- source: Entscheidung + name: betrifft + target: Referenzobjekt + via: reference_type + reference_id + kind: soft +- source: Hinweis + name: gelerntIn + target: Workflow + via: workflow_hints.workflow +- source: Hinweis + name: hatKategorie + target: MailKategorie + via: workflow_hints.hint_type = config_key (JOIN) +- source: Hinweis + name: betrifftKontakt + target: Kontakt + via: workflow_hints.pattern_key = contact_key (JOIN) +- source: Hinweis + name: bestaetigtVon + target: Mitarbeiter + via: workflow_hints.confirmed_by + kind: soft +- source: KanbauApiMethode + name: beschreibt + target: KanBau + via: method_key, swagger_ref + kind: external +- source: Faehigkeit + name: gehoertZu + target: AgentBereich + via: agent_capabilities.scope_key +- source: Faehigkeit + name: ruftAuf + target: Workflow + via: agent_capabilities.workflow_name +- source: Kontextdokument + name: gehoertZu + target: AgentBereich + via: agent_context_documents.scope_key +- source: AgentAusfuehrung + name: imBereich + target: AgentBereich + via: agent_execution_log.agent_scope +- source: AgentAusfuehrung + name: waehlt + target: Faehigkeit + via: agent_execution_log.selected_capability +- source: AgentAusfuehrung + name: ruftAuf + target: Workflow + via: agent_execution_log.workflow_called +- source: BrowserSession + name: fuerAnbieter + target: LoginAnbieter + via: browser_sessions.provider_id +- source: LoginAnbieter + name: meldetAnBei + target: KanBau + via: über BSB - Get Session für das KanBau-Gateway + kind: soft +- source: LlmJob + name: bearbeitetVon + target: LlmWorker + via: llm_jobs.assigned_to = llm_workers.host +- source: LlmJob + name: verlangtTier + target: Tier + via: llm_jobs.min_tier +- source: LlmJob + name: nutzteModell + target: Modell + via: llm_jobs.model +- source: LlmWorker + name: hatTier + target: Tier + via: llm_workers.tier +- source: LlmWorker + name: betreibt + target: Modell + via: llm_workers.model +- source: WorkerSperre + name: sperrt + target: LlmWorker + via: llm_worker_blocklist.mac +vocabularies: + MailKategorie: + label: Mail-Kategorie + class: MailKategorie + columns: + - mail_events.category + - workflow_hints.hint_type + - workflow_hint_config.config_key + concepts: + mahnung: + label: Mahnung + zahlungsaufforderung: + label: Zahlungsaufforderung + buchhaltung: + label: Rechnungen + alt: + - Buchhaltung + mangel: + label: Mangel + newsletter_werbung: + label: Newsletter + alt: + - Newsletter und Werbung + plattformen: + label: Plattformen + versand_bestellungen: + label: Versand + alt: + - Versand und Bestellungen + sonstiges: + label: Sonstige + alt: + - Sonstiges + default: + label: Standard + note: technischer Rückfall der Hinweis-Konfiguration, keine fachliche Kategorie + technical: true + Ereignisart: + label: Ereignisart + class: Ereignisart + columns: + - project_events.category + concepts: + verzoegerung: + label: Verzögerung + mangel: + label: Mangel + termin: + label: Termin + kosten: + label: Kosten + info: + label: Info + Dokumenttyp: + label: Dokumenttyp + class: Dokumenttyp + columns: + - property_development_documents.document_type + - document_extractions.document_type + concepts: + expose: {} + bplan: {} + wirtschaftlichkeitsberechnung: {} + kaufangebot: {} + loi: {} + nda: {} + vertraulichkeitserklaerung: {} + telefonnotiz: {} + mail: {} + lageplan: {} + kataster: {} + grundbuch: {} + baugenehmigung: {} + gutachten: {} + pruefplan: {} + fotos: {} + wettbewerbsunterlagen: {} + vertrag_notar: {} + unknown: {} + Tier: + label: Tier + class: Tier + columns: + - llm_workers.tier + - llm_jobs.min_tier + - llm_jobs.tier + concepts: + basic: {} + standard: {} + capable: {} + expert: {} + Gesellschaft: + label: Gesellschaft + class: Gesellschaft + columns: + - project_companies.gesellschaft + - invoice_company_observations.gesellschaft + concepts: + p_gmbh: + label: P-GmbH + p_b_kg: + label: P+B KG + Pipelinestatus: + label: Pipeline-Status (Ankauf) + columns: + - property_development_cases.pipeline_status + concepts: + vorab_check: {} + vorpruefung: {} + in_pruefung: {} + angebot_in_vorbereitung: {} + angebot_abgegeben: {} + verhandlung: {} + wettbewerb_aktuell: {} + projektierung: {} + planung: {} + abgeschlossen: {} + Ergebnisstatus: + label: Ergebnis (Ankauf) + columns: + - property_development_cases.outcome_status + concepts: + offen: {} + negativ_nicht_weiterverfolgt: {} + positiv_angeboten_nicht_zustande: {} + positiv_angeboten_zustande: {} + wettbewerb_erledigt: {} + wettbewerb_nicht_teilgenommen: {} + wettbewerb_nicht_zugelassen: {} + ohne_abschluss_erledigt: {} + Dringlichkeit: + label: Dringlichkeit + columns: + - project_events.urgency + concepts: + low: {} + normal: {} + high: {} + Pruefstatus: + label: Prüfstatus (Kaufvertrag) + columns: + - kaf_review_snapshots.review_status + concepts: + pending: {} + approved: {} + manual_review: {} + Zugriffsstatus: + label: Status Postfachzugriff + columns: + - mailbox_grants.status + concepts: + active: {} + revoked: {} + needs_reconsent: {} + Belegstatus: + label: Belegstatus + columns: + - bel_processed_belege.status + concepts: + reserved: {} + uploaded: {} + Qualitaetsstatus: + label: Qualitätsstatus + columns: + - project_aliases.quality_status + - contacts.quality_status + - contact_aliases.quality_status + - contact_project_relations.quality_status + - workflow_decisions.quality_status + - property_development_cases.quality_status + - workflow_hints.quality_status + - place_enrichments.quality_status + - project_events.quality_status + concepts: + observed: + label: beobachtet + proposed: + label: vorgeschlagen + confirmed: + label: bestätigt + alt: + - verified + note: workflow_decisions schreibt 'verified' statt 'confirmed' (KNOW-Befund B31) + rejected: + label: verworfen diff --git a/ontology/viewer.html.tmpl b/ontology/viewer.html.tmpl new file mode 100644 index 0000000..e97d1e3 --- /dev/null +++ b/ontology/viewer.html.tmpl @@ -0,0 +1,459 @@ +KNOW Ontologie + + + + + +
+
+
+

KNOW Ontologie

+

Begriffe der Wissensdatenbank knowledge und wie sie zusammenhängen, abgeleitet aus Schema und Joins

+
+
+ + + + + +
+
+
+
+ +
+
+ +
+
+ + + + +