From c81e372c493ecad67331e5d67e8e7e5ca11bcc8b Mon Sep 17 00:00:00 2001 From: Robert Rapp Date: Tue, 13 Oct 2026 01:39:49 +0200 Subject: [PATCH 1/6] =?UTF-8?q?sites:=20Inventar=20und=20Zielbild=20an=20e?= =?UTF-8?q?chte=20Knoten=20(pve44=E2=80=93pve61)=20angepasst;=20ADR-0001/0?= =?UTF-8?q?002/0006=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aufbauplan Task 0.4: - inventory.yaml: pve44/46/48/52/61 und GODE-PC-05/06 (Ada, Windows, cpu_roles: false) - proxmox.yaml: eine GPU-VM pro Knoten mit allen Karten (D2), local-lvm/tank (D1 B), 192.168.100.0/24 ohne VLAN, Gast-IPs .220–.239 (ANNAHME bis Task 0.2) - registry.yaml/prometheus.yml: ops-Labels auf gpu-pve44/46/48, Ada ohne pve_node/vmid - tofu: dynamische hostpci, Speicher je Gast; Ansible: pve_gpu_hosts/pve_kiosk, Replikation auf mehrere Ziele - Placement: cpu_roles=false schließt Arbeitsplätze/Kiosk von CPU-Rollen aus - PLAN §1.3/§1.4/§9 und ADR-Nachträge (D1 entschieden, D2/D3 Bestätigung offen) Co-Authored-By: Claude Opus 5.5 --- core/src/knaster/inventory/placement.py | 2 +- core/src/knaster/inventory/profile.py | 1 + core/tests/test_artifacts.py | 34 +++++--- core/tests/test_inventory_validate.py | 45 +++++++++- deploy/prometheus/prometheus.yml | 66 +++++++-------- docs/PLAN.md | 51 ++++++++---- docs/adr/0001-gpu-vms-infra-lxc.md | 14 +++- docs/adr/0002-storage-zfs-kein-ceph.md | 12 ++- .../0006-legacy-gpus-llamacpp-tts-auf-ada.md | 8 +- docs/spec/SPEC-inventory.md | 2 +- .../2026-10-12-aufbau-cluster-knaster.md | 13 +-- platform/proxmox/README.md | 4 +- platform/proxmox/ansible/inventory.yml | 39 +++++---- platform/proxmox/ansible/playbooks/site.yml | 7 +- .../ansible/roles/pve-host/tasks/main.yml | 25 ++++-- platform/proxmox/tofu/main.tf | 39 +++++---- platform/proxmox/tofu/outputs.tf | 4 +- sites/knaster/inventory.yaml | 83 +++++++++++-------- sites/knaster/proxmox.yaml | 55 +++++++----- sites/knaster/registry.yaml | 29 ++++--- 20 files changed, 343 insertions(+), 190 deletions(-) diff --git a/core/src/knaster/inventory/placement.py b/core/src/knaster/inventory/placement.py index cc630ec..cdd257e 100644 --- a/core/src/knaster/inventory/placement.py +++ b/core/src/knaster/inventory/placement.py @@ -182,7 +182,7 @@ def plan(nodes: list[NodeProfile], roles: list[RoleSpec], models: list[ModelSpec missing = role.count - placed if missing and (not role.needs.gpu or role.cpu_fallback): mode: Literal["cpu", "cpu_fallback"] = "cpu" if not role.needs.gpu else "cpu_fallback" - candidates = [n for n in nodes if n.memory_gb >= role.needs.min_ram_gb] + candidates = [n for n in nodes if n.cpu_roles and n.memory_gb >= role.needs.min_ram_gb] # bevorzugt Nodes ohne GPU-Rollen, dann wenig belastete, dann viel RAM gpu_roles = {a.node for a in result.assignments if a.mode == "gpu"} candidates.sort(key=lambda n: (n.name in gpu_roles, cpu_load[n.name], -n.memory_gb, n.name)) diff --git a/core/src/knaster/inventory/profile.py b/core/src/knaster/inventory/profile.py index 41f254c..4908d8c 100644 --- a/core/src/knaster/inventory/profile.py +++ b/core/src/knaster/inventory/profile.py @@ -43,6 +43,7 @@ class NodeProfile(BaseModel): disks: list[Disk] = Field(default_factory=list) gpus: list[GPU] = Field(default_factory=list) labels: dict[str, str] = Field(default_factory=dict) + cpu_roles: bool = True # False: Arbeitsplatz/Kiosk – keine Infra- oder CPU-Fallback-Rollen, nur eigene GPUs NVIDIA_SMI_QUERY = ["nvidia-smi", "--query-gpu=index,name,memory.total,compute_cap,driver_version", diff --git a/core/tests/test_artifacts.py b/core/tests/test_artifacts.py index 86dd906..7521a49 100644 --- a/core/tests/test_artifacts.py +++ b/core/tests/test_artifacts.py @@ -24,7 +24,7 @@ def test_healer_labels_available_on_alerts(): """Labels, die Healer-Regeln brauchen, müssen von Metrik oder Scrape-Target kommen.""" prom = yaml.safe_load((DEPLOY / "prometheus/prometheus.yml").read_text()) jobs = {j["job_name"]: j for j in prom["scrape_configs"]} - for job in ("node", "gpu", "vllm"): + for job in ("node", "gpu", "llamacpp"): for sc in jobs[job]["static_configs"]: assert {"slot", "pve_node", "vmid"} <= set(sc["labels"]), (job, sc) from knaster.canary import OPS_LABELS @@ -46,13 +46,17 @@ def test_compose_files_parse(role): def test_ops_labels_consistent_between_registry_and_prometheus(): + """Das erste Scrape-Ziel eines Slots trägt dieselben ops-Labels wie die Registry (Healer-Ziel).""" reg = yaml.safe_load((REPO_ROOT / "sites/knaster/registry.yaml").read_text()) prom = yaml.safe_load((DEPLOY / "prometheus/prometheus.yml").read_text()) - vllm = {sc["labels"]["slot"]: sc["labels"] for sc in - next(j for j in prom["scrape_configs"] if j["job_name"] == "vllm")["static_configs"]} - for slot, labels in vllm.items(): + first: dict[str, dict] = {} + for sc in next(j for j in prom["scrape_configs"] if j["job_name"] == "llamacpp")["static_configs"]: + first.setdefault(sc["labels"]["slot"], sc["labels"]) + assert first + for slot, labels in first.items(): ops = reg["slots"][slot]["ops"] - assert ops["vmid"] == labels["vmid"] and ops["container"] == labels["container"] + assert {k: ops[k] for k in ("docker_host", "container", "pve_node", "vmid")} == \ + {k: labels[k] for k in ("docker_host", "container", "pve_node", "vmid")}, slot WORKFLOWS = sorted((REPO_ROOT / "n8n/workflows").glob("*.json")) @@ -160,19 +164,27 @@ def test_proxmox_site_consistent_with_registry_and_prometheus(): reg = yaml.safe_load((REPO_ROOT / "sites/knaster/registry.yaml").read_text()) vms = site["vms"] for vm in vms.values(): - assert vm["gpu"] in site["pci_mappings"] - assert site["pci_mappings"][vm["gpu"]]["node"] == vm["node"] # Mapping liegt auf dem Host der VM + for gpu in vm["gpus"]: + assert gpu in site["pci_mappings"] + assert site["pci_mappings"][gpu]["node"] == vm["node"] # Mapping liegt auf dem Host der VM assert vm["driver_generation"] in {"legacy", "modern"} - gpus = [vm["gpu"] for vm in vms.values()] + assert vm["node"] in site["nodes"] and site["nodes"][vm["node"]]["gpu_host"] + gpus = [g for vm in vms.values() for g in vm["gpus"]] assert len(gpus) == len(set(gpus)) # keine GPU doppelt durchgereicht + assert set(gpus) == set(site["pci_mappings"]) # jede gemappte Karte steckt in einer VM by_vmid = {str(vm["vmid"]): (name, vm) for name, vm in {**vms, **site["containers"]}.items()} for slot, spec in reg["slots"].items(): - ops = spec.get("ops") - if not ops: + ops = spec.get("ops") or {} + if "vmid" not in ops: # z. B. Ada-Worker unter Windows (D3), kein Proxmox-Gast + assert "pve_node" not in ops, slot continue name, vm = by_vmid[ops["vmid"]] assert vm["node"] == ops["pve_node"], slot - assert ops["docker_host"].startswith(name.split("-")[0]), slot + assert ops["docker_host"] == name, slot + for c in site["containers"].values(): + if c.get("ha"): + assert site["nodes"][c["node"]]["zfs_tank"] and c["storage"] == "tank" + assert all(site["nodes"][t]["zfs_tank"] for t in c["replicate_to"]) vmids = [str(v["vmid"]) for v in vms.values()] + [str(c["vmid"]) for c in site["containers"].values()] assert len(vmids) == len(set(vmids)) # vLLM nur auf modernen Karten diff --git a/core/tests/test_inventory_validate.py b/core/tests/test_inventory_validate.py index b631dba..0f1c281 100644 --- a/core/tests/test_inventory_validate.py +++ b/core/tests/test_inventory_validate.py @@ -28,29 +28,70 @@ def test_parsers(): assert disks[0].size_gb == 256.1 and not disks[0].rotational +PVE_GPU_HOSTS = {"pve44", "pve46", "pve48", "pve52"} + + +def test_site_knaster_matches_real_nodes(): + """Aufbauplan Task 0.4: Site-Dateien beschreiben die echten Knoten, nicht mehr die Annahmen pve-ada/pve1–4.""" + import ipaddress + + from knaster.util import load_yaml + + nodes = {n.name: n for n in load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml")} + assert set(nodes) == {"pve44", "pve46", "pve48", "pve52", "pve61", "gode-pc-05", "gode-pc-06"} + pve = load_yaml(REPO_ROOT / "sites/knaster/proxmox.yaml") + old_net = ipaddress.ip_network("10.20.0.0/24") + ips = [] + for section in ("vms", "containers"): + ips += [ipaddress.ip_interface(g["ip"]).ip for g in pve[section].values()] + ips += [ipaddress.ip_address(pve["cluster"][k]) for k in ("gateway", "dns")] + assert not [ip for ip in ips if ip in old_net] + assert {m["node"] for m in pve["pci_mappings"].values()} <= PVE_GPU_HOSTS + assert {v["node"] for v in pve["vms"].values()} <= PVE_GPU_HOSTS + # eine GPU-VM pro GPU-Knoten mit allen Karten des Knotens (D2) + assert sorted(v["node"] for v in pve["vms"].values()) == sorted(PVE_GPU_HOSTS) + for vm in pve["vms"].values(): + mapped = {name for name, m in pve["pci_mappings"].items() if m["node"] == vm["node"]} + assert set(vm["gpus"]) == mapped + gpus_inv = [g for g in nodes[vm["node"]].gpus] + assert len(vm["gpus"]) == len(gpus_inv) + # Ada-Rechner bleiben Windows-Arbeitsplätze (D3): keine Infra-/CPU-Rollen + assert not nodes["gode-pc-05"].cpu_roles and not nodes["pve61"].cpu_roles + + def test_placement_knaster(): roles, models = load_catalog(REPO_ROOT / "catalog") p = plan(load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml"), roles, models) by_role = {} for a in p.assignments: by_role.setdefault(a.role, []).append(a) + assert {by_role["gpu-main"][0].node, by_role["gpu-flex"][0].node} == {"gode-pc-05", "gode-pc-06"} assert by_role["gpu-main"][0].gpu_name.startswith("NVIDIA RTX 4000 Ada") assert "qwen3-14b-awq" in by_role["gpu-main"][0].models_fitting embed_nodes = {a.node for a in by_role["gpu-embed"]} assert len(embed_nodes) == 2 # Anti-Affinität assert all("P2000" in a.gpu_name for a in by_role["gpu-embed"] + by_role["gpu-small"]) - assert by_role["infra"][0].mode == "cpu" + assert by_role["infra"][0].mode == "cpu" and by_role["infra"][0].node in PVE_GPU_HOSTS assert not p.unplaced # M4000 bewusst außer Betrieb (ADR-0006): weder frei noch belegt assert p.free_gpus == [] assert len(p.disabled_gpus) == 4 and all("M4000" in g for g in p.disabled_gpus) tts = {a.role: a for a in p.assignments if a.role.startswith("tts")} - assert tts["tts-hq"].mode == "shared" and tts["tts-hq"].node == "pve-ada" + assert tts["tts-hq"].mode == "shared" and tts["tts-hq"].node == by_role["gpu-flex"][0].node assert "qwen3-tts-1.7b" in tts["tts-hq"].models_fitting assert tts["tts-cpu"].node == by_role["infra"][0].node assert tts["tts-cpu"].models_fitting == ["piper-de-thorsten-medium"] +def test_nodes_without_cpu_roles_get_no_infra(): + """Arbeitsplätze und Kiosk nehmen keine CPU-Rollen, auch wenn sie den meisten RAM haben.""" + roles, models = load_catalog(REPO_ROOT / "catalog") + nodes = [NodeProfile(name="workstation", memory_gb=64, cpu_roles=False), + NodeProfile(name="server", memory_gb=32)] + p = plan(nodes, roles, models) + assert {a.node for a in p.assignments if a.mode in ("cpu", "cpu_fallback")} == {"server"} + + def test_placement_other_cluster(): roles, models = load_catalog(REPO_ROOT / "catalog") p = plan(load_nodes(REPO_ROOT / "sites/example-single-gpu/inventory.yaml"), roles, models) diff --git a/deploy/prometheus/prometheus.yml b/deploy/prometheus/prometheus.yml index 31ba743..cb1cd79 100644 --- a/deploy/prometheus/prometheus.yml +++ b/deploy/prometheus/prometheus.yml @@ -1,5 +1,8 @@ # Scrape-Ziele. Die Labels an den GPU-Zielen (slot, docker_host, container, pve_node, vmid) landen in den -# Alerts und damit beim Healer. Hostnamen = DNS-Namen der VMs (ANNAHME bis Deployment). +# Alerts und damit beim Healer. IP-Literale aus sites/knaster/proxmox.yaml (Global Constraints, Aufbauplan). +# ANNAHME bis Task 0.2 (Gast-IPs) und Task 3.3 (welcher Slot auf welcher P2000 läuft). +# Ada-Worker (GODE-PC-05/06, Windows, D3) werden nicht gescrapt: ihre Verfügbarkeit meldet die +# Worker-Registry v2 (Task 6.4), der Canary-Prober des Routers prüft die Slots zusätzlich. global: scrape_interval: 30s evaluation_interval: 30s @@ -15,52 +18,45 @@ scrape_configs: metrics_path: /metrics static_configs: [{targets: ["router:8080"]}] - - job_name: vllm - static_configs: - - targets: ["gpu-main:8000"] - labels: {slot: tier1-general, docker_host: gpu-main, container: vllm, pve_node: pve-ada, vmid: "201"} - - targets: ["gpu-flex:8000"] - labels: {slot: tier1-flex, docker_host: gpu-flex, container: vllm, pve_node: pve-ada, vmid: "202"} - + # Erstes Ziel je Slot = Healer-Ziel in sites/knaster/registry.yaml (ops) - job_name: llamacpp static_configs: - - targets: ["gpu-embed-1:8081"] - labels: {slot: embed-default, docker_host: gpu-embed-1, container: llamacpp-embed, pve_node: pve3, vmid: "221"} - - targets: ["gpu-embed-1:8082"] - labels: {slot: rerank-default, docker_host: gpu-embed-1, container: llamacpp-rerank, pve_node: pve3, vmid: "221"} - - targets: ["gpu-small:8080"] - labels: {slot: verifier-small, docker_host: gpu-small, container: llamacpp-chat, pve_node: pve3, vmid: "231"} + - targets: ["192.168.100.224:8081"] + labels: {slot: embed-default, docker_host: gpu-pve44, container: llamacpp-embed, pve_node: pve44, vmid: "244"} + - targets: ["192.168.100.226:8081"] + labels: {slot: embed-default, docker_host: gpu-pve46, container: llamacpp-embed, pve_node: pve46, vmid: "246"} + - targets: ["192.168.100.224:8082"] + labels: {slot: rerank-default, docker_host: gpu-pve44, container: llamacpp-rerank, pve_node: pve44, vmid: "244"} + - targets: ["192.168.100.228:8080"] + labels: {slot: verifier-small, docker_host: gpu-pve48, container: llamacpp-chat, pve_node: pve48, vmid: "248"} - job_name: gpu static_configs: - - targets: ["gpu-main:9835"] - labels: {slot: tier1-general, pve_node: pve-ada, vmid: "201"} - - targets: ["gpu-flex:9835"] - labels: {slot: tier1-flex, pve_node: pve-ada, vmid: "202"} - - targets: ["gpu-embed-1:9835"] - labels: {slot: embed-default, pve_node: pve3, vmid: "221"} - - targets: ["gpu-embed-2:9835"] - labels: {slot: embed-default, pve_node: pve4, vmid: "222"} - - targets: ["gpu-small:9835"] - labels: {slot: verifier-small, pve_node: pve3, vmid: "231"} + - targets: ["192.168.100.224:9835"] + labels: {slot: embed-default, pve_node: pve44, vmid: "244"} + - targets: ["192.168.100.226:9835"] + labels: {slot: embed-default, pve_node: pve46, vmid: "246"} + - targets: ["192.168.100.228:9835"] + labels: {slot: verifier-small, pve_node: pve48, vmid: "248"} + - targets: ["192.168.100.232:9835"] + labels: {slot: "", pve_node: pve52, vmid: "252"} # nur M4000, außer Betrieb bis Task 3.3 - job_name: node # Labels werden für GpuXidError → Healer (VM-Neustart) gebraucht static_configs: - - targets: ["gpu-main:9100"] - labels: {slot: tier1-general, pve_node: pve-ada, vmid: "201"} - - targets: ["gpu-flex:9100"] - labels: {slot: tier1-flex, pve_node: pve-ada, vmid: "202"} - - targets: ["gpu-embed-1:9100"] - labels: {slot: embed-default, pve_node: pve3, vmid: "221"} - - targets: ["gpu-embed-2:9100"] - labels: {slot: embed-default, pve_node: pve4, vmid: "222"} - - targets: ["gpu-small:9100"] - labels: {slot: verifier-small, pve_node: pve3, vmid: "231"} + - targets: ["192.168.100.224:9100"] + labels: {slot: embed-default, pve_node: pve44, vmid: "244"} + - targets: ["192.168.100.226:9100"] + labels: {slot: embed-default, pve_node: pve46, vmid: "246"} + - targets: ["192.168.100.228:9100"] + labels: {slot: verifier-small, pve_node: pve48, vmid: "248"} + - targets: ["192.168.100.232:9100"] + labels: {slot: "", pve_node: pve52, vmid: "252"} - job_name: proxmox metrics_path: /pve params: {module: [default]} - static_configs: [{targets: ["pve1", "pve2", "pve3", "pve4", "pve-ada"]}] + static_configs: + - targets: ["192.168.100.210", "192.168.100.209", "192.168.100.212", "192.168.100.207", "192.168.100.214"] relabel_configs: - {source_labels: [__address__], target_label: __param_target} - {source_labels: [__param_target], target_label: instance} diff --git a/docs/PLAN.md b/docs/PLAN.md index 782dc73..4283765 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -33,6 +33,7 @@ Ziel: Ein starkes LLM (OpenRouter) erarbeitet neue Aufgaben einmalig und bricht - **GPU-Nodes als VM.** Grund: Die alten Karten (Maxwell M4000, Pascal P2000) brauchen fest gepinnte Treiber mit CUDA 12.x, die Ada-Karten profitieren von aktuellen Treibern. Mit VMs lebt beides sauber getrennt auf einem Cluster. Treiber-Updates werden per Snapshot rückrollbar. - **CPU-Infrastruktur als LXC oder leichte VM** (n8n, Postgres, LiteLLM, Monitoring). Das spart RAM auf den i7-Nodes. - Innerhalb der VMs bzw. LXCs läuft Docker Compose, ein Compose-Projekt pro Rolle. +- **Stand Aufbauplan (D2):** eine GPU-VM pro Knoten mit allen Karten des Knotens statt einer VM pro Rolle. Im Cluster stecken nur noch Maxwell und Pascal, die Ada-Rechner bleiben Windows-Arbeitsplätze (ADR-0001 und ADR-0006, jeweils Nachtrag). ### 1.2 Was Proxmox zusätzlich ermöglicht @@ -47,30 +48,35 @@ Ziel: Ein starkes LLM (OpenRouter) erarbeitet neue Aufgaben einmalig und bricht ### 1.3 Rollenverteilung (aktuelle Hardware) +Stand: Aufbauplan vom 12.10.2026 (`docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md`). Die Knoten stehen in `sites/knaster/inventory.yaml`. Welche P2000 welche Rolle bekommt, legt erst der Benchmark-Tag fest (Task 3.3). + | Rolle | Hardware | Engine | Aufgabe | |---|---|---|---| -| `gpu-main` | RTX 4000 Ada #1 (20 GB) | vLLM, Multi-LoRA | Slot `tier1-general` plus Skill-Adapter | -| `gpu-flex` | RTX 4000 Ada #2 (20 GB) | vLLM / Unsloth | tagsüber zweiter Slot oder Reserve, nachts LoRA-Training | -| `gpu-embed` | 2× Quadro P2000 (5 GB) | llama.cpp (Embedding/Rerank) | `embed-default`, `rerank-default`, redundant auf zwei Hosts | -| `gpu-small` | Quadro P2000 (5 GB) | llama.cpp | `verifier-small`, `signature-extractor` (3–4B) | -| – | 4× Quadro M4000 (8 GB) | – | **vorerst außer Betrieb** (ADR-0006), nur nach Benchmark für llama.cpp-Rollen | -| `tts-hq` | teilt RTX 4000 Ada #2 | PyTorch | `tts-de-hq`: Qwen3-TTS 1.7B (ADR-0006) | -| `tts-cpu` | Infra-LXC (CPU) | Piper (ONNX) | `tts-de`: schnelle Sprachausgabe, Fallback | -| `infra` | i7 6./7. Gen, 32 GB | Docker | n8n, LiteLLM, Postgres+pgvector, Redis, Router, Healer, Langfuse, Prometheus/Grafana | -| `strong` | extern | OpenRouter | `tier0-strong`, Skill-Autor (Hermes) | +| `gpu-main` | RTX 4000 Ada (20 GB) in GODE-PC-05, Windows | llama-server über Worker-Registry v2 (D3); vLLM/Multi-LoRA erst ab Phase 9 | Slot `tier1-general`, wenn der Worker online ist, sonst tier0 | +| `gpu-flex` | RTX 4000 Ada (20 GB) in GODE-PC-06, Windows | wie `gpu-main` | `tier1-flex`; Training erst ab Phase 9 | +| `gpu-embed` | 2× Quadro P2000 (5 GB) in `gpu-pve44` und `gpu-pve46` | llama.cpp (Embedding/Rerank) | `embed-default`, `rerank-default`, redundant auf zwei Knoten | +| `gpu-small` | Quadro P2000 (5 GB) in `gpu-pve48` | llama.cpp | `verifier-small`, `signature-extractor` (3–4B) | +| – | 4× Quadro M4000 (8 GB), je eine in pve44/46/48/52 | – | **vorerst außer Betrieb** (ADR-0006), werden aber an die GPU-VM durchgereicht; nach dem Benchmark nur für llama.cpp-Rollen | +| `tts-hq` | teilt die Ada in GODE-PC-06 | PyTorch | `tts-de-hq`: Qwen3-TTS 1.7B, erst ab Phase 9 (WSL2, D3) | +| `tts-cpu` | Infra-LXC (CPU) | Piper (ONNX) | `tts-de`: Sprachausgabe, vorerst einziger TTS-Slot | +| `infra` | LXC `infra` auf pve44 (`tank`, HA nach pve52/pve48) | Docker | Router, Postgres+pgvector, Healer, Prometheus/Grafana/Alertmanager; **kein eigener LiteLLM** (D4) | +| `ops` | LXC auf pve46 | – | Ansible, OpenTofu, Repo-Clone | +| `strong` | extern | OpenRouter **über den LiteLLM mit PSE auf appsrv08** | `tier0-strong`, Skill-Autor (Hermes) | + +pve61 (Surface Book, 8 GB) nimmt keine Gäste auf und zeigt nur das Grafana-Dashboard als Kiosk. n8n läuft weiter auf n8n.mindboost.app, LiteLLM auf appsrv08 (D4). Wichtig: Auf Maxwell und Pascal läuft kein vLLM (das braucht Compute Capability ≥ 7.0), und CUDA 13 unterstützt sie nicht mehr. Daher auf diesen Nodes Treiber und Images pinnen. Auch PyTorch hat dort keine Zukunft mehr (2.14 ist die letzte Version mit CUDA-12-Wheels). Deshalb laufen auf alten Karten nur llama.cpp-Workloads (ADR-0006). -### 1.4 Netzwerk und Storage (entschieden, siehe ADR-0002) +### 1.4 Netzwerk und Storage (entschieden, siehe ADR-0002 mit Nachtrag D1) -Ausgangslage: **1 GbE** zwischen den Nodes, pro physischer Maschine **2–3 SSDs mit 128–256 GB**. +Ausgangslage: **1 GbE**, flaches Netz `192.168.100.0/24`. Die Knoten sind mit ext4 und LVM-thin auf einer System-SSD installiert, kein ZFS. - **Kein Ceph.** Ceph braucht für brauchbare Latenz und Rebuild-Zeiten mindestens 10 GbE und mehr Platz pro OSD. Mit 1 GbE und kleinen SSDs würde jeder Schreibvorgang über das Netz gebremst und ein Rebuild das Netz tagelang belegen. -- **ZFS lokal pro Node.** SSD 1 trägt das Proxmox-System. SSD 2 und 3 bilden, wo vorhanden, einen ZFS-Mirror `tank` für VM-Disks. Auf Nodes mit nur zwei SSDs ist `tank` eine Einzel-SSD, die über Replikation abgesichert wird. -- **ZFS-Replikation** (Proxmox-Job, alle 15 Min.) für die Infra-VMs auf einen zweiten Node. Zusammen mit Proxmox HA startet die VM nach einem Node-Ausfall dort mit maximal 15 Min. altem Stand. Für Postgres wird das zusätzlich durch PBS-Backups und WAL-Archivierung abgefedert. -- **Modelle liegen lokal** auf der GPU-VM, in einem eigenen Disk-Image (`models`, nicht repliziert, nicht gesichert). Quelle ist Hugging Face oder ein optionaler **Modell-Mirror** auf einem Infra-Node (HTTP/NFS, nur lesend). 10 GB über 1 GbE dauern etwa 90 s, das ist für Modellwechsel unkritisch. -- **Proxmox Backup Server** als VM oder LXC auf dem Node mit dem meisten freien SSD-Platz, oder besser auf einer externen Platte bzw. einem NAS. Gesichert werden Postgres, Konfiguration und Datensätze, keine Modellgewichte. -- **Netztrennung per VLAN** (Proxmox SDN): `mgmt` (Proxmox, PBS), `svc` (Dienste untereinander), `ext` (Zugriff von außen auf n8n und Router). Ohne Bandbreitengewinn, aber Replikation und Backups lassen sich zeitlich steuern, damit sie den Inferenz-Traffic nicht stören. +- **Variante B (D1, entschieden 12.10.):** Die System-SSD bleibt (`local`, `local-lvm` für die GPU-VMs). Auf pve44, pve48 und pve52 wird die zweite SSD nach Freigabe zur Einzel-SSD `tank`. pve46 und pve61 bleiben ohne ZFS. Der ARC ist auf 4 GiB begrenzt. +- **ZFS-Replikation** (Proxmox-Job, alle 15 Min.) für HA-Gäste (`infra`) zwischen den `tank`-Knoten. Zusammen mit Proxmox HA startet der Gast nach einem Knotenausfall mit höchstens 15 Min. altem Stand. Für Postgres wird das zusätzlich durch PBS-Backups und WAL-Archivierung abgefedert. +- **Modelle liegen lokal** auf der GPU-VM, in einem eigenen Disk-Image (`models`, 60 GB, nicht repliziert, nicht gesichert). Quelle ist Hugging Face oder ein optionaler **Modell-Mirror** auf einem Infra-Node (HTTP/NFS, nur lesend). 10 GB über 1 GbE dauern etwa 90 s, das ist für Modellwechsel unkritisch. +- **Proxmox Backup Server** als VM `pbs` auf pve52, Datastore auf einem externen Datenträger oder NAS (Task 5.1). Gesichert werden Postgres, Konfiguration und Datensätze, keine Modellgewichte. +- **Netz (D5):** Die Knoten behalten .207–.214. Gäste bekommen feste IPs aus einem Block außerhalb des DHCP-Bereichs, vorgeschlagen ist `.220–.239` (Prüfung in Task 0.2). Konfigurationen nutzen IP-Literale, keine DNS-Namen. **VLANs** (`mgmt`, `svc`, `ext`) kommen erst später, wenn der Switch das zulässt. **Speicherbudget pro Rolle (Richtwerte):** @@ -80,7 +86,7 @@ Ausgangslage: **1 GbE** zwischen den Nodes, pro physischer Maschine **2–3 SSDs | `gpu-embed` / `gpu-small` | 15–25 GB | Embedding-, Rerank- und kleine GGUF-Modelle (je 0,5–3 GB), Images | | `infra` | 40–80 GB | Postgres (Fälle, Traces), Prometheus (15 Tage Retention), Langfuse | -Die genaue SSD-Verteilung wird beim Inventarlauf erfasst (`sites/knaster/inventory.yaml`). +Die SSD-Verteilung steht in `sites/knaster/inventory.yaml` (Bestandsaufnahme vom 12.10.). --- @@ -303,10 +309,19 @@ Die Portabilität beruht also auf den **Daten und der Pipeline**, nicht auf Gewi - Infrastruktur heilt deterministisch, ohne LLM-Entscheidungen (ADR-0005) - Alte GPUs nur llama.cpp, M4000 vorerst aus, TTS mit Piper (CPU) und Qwen3-TTS (Ada) (ADR-0006) +### Entscheidungen 2026-10 (Aufbauplan) +| # | Thema | Stand | +|---|---|---| +| D1 | Speicher: Variante B, keine Neuinstallation, `tank` auf pve44/48/52 | **entschieden** 12.10. Gelöscht wird erst nach ausdrücklicher Freigabe der alten Windows-Platten | +| D2 | Eine Ubuntu-VM pro GPU-Knoten mit allen Karten (ADR-0001, Nachtrag) | empfohlen, Bestätigung offen (Task 0.3) | +| D3 | Ada-Rechner bleiben Windows, Anbindung über die Worker-Registry v2 (ADR-0006, Nachtrag) | empfohlen, Bestätigung offen | +| D4 | Kein eigener LiteLLM, der Router nutzt den LiteLLM mit PSE auf appsrv08 | empfohlen, Bestätigung offen | +| D5 | Knoten-IPs bleiben, Gäste aus `.220–.239`, kein VLAN | empfohlen, Bestätigung offen. Der IP-Block wird in Task 0.2 geprüft | + ### Offen 1. Erstes Basismodell für `tier1-general` (8–14B, gut quantisierbar, LoRA-freundlich, gutes Deutsch). Entscheidung per Kalibrierungslauf. 2. Kommen die M4000 zurück? `llama-bench` plus Strommessung am Deployment-Tag (bis dahin außer Betrieb, ADR-0006) 3. Verifier-Strategie pro Aufgabentyp: Code-Checks, Schema, kleines Judge-Modell, Stichproben durch das starke Modell 4. Schwellwerte: Graduierung (Start 90 %), Fallzahl fürs LoRA-Training (Start 300), Canary-Anteil (Start 10 %) 5. Kanal für den Pflege-Agenten: n8n Chat, Telegram oder Matrix -6. Standort des Proxmox Backup Servers (intern auf SSD oder extern) +6. Ziel-Speicher des Proxmox Backup Servers (Aufbauplan Task 5.1, Empfehlung: externer Datenträger oder NAS) diff --git a/docs/adr/0001-gpu-vms-infra-lxc.md b/docs/adr/0001-gpu-vms-infra-lxc.md index a9bc3b0..47e2e0c 100644 --- a/docs/adr/0001-gpu-vms-infra-lxc.md +++ b/docs/adr/0001-gpu-vms-infra-lxc.md @@ -1,6 +1,6 @@ # ADR-0001: GPU-Nodes als VM, Infrastruktur als LXC -- Status: angenommen +- Status: angenommen, ergänzt am 2026-10-13 (Nachtrag D2, Bestätigung offen) - Datum: 2026-10-11 ## Kontext @@ -15,3 +15,15 @@ Im Cluster stecken GPUs aus drei Generationen: Maxwell (M4000), Pascal (P2000) u - Der VM-RAM ist bei Passthrough vollständig reserviert. Er wird deshalb knapp bemessen: llama.cpp-VMs 6–8 GB, vLLM-VMs 24–32 GB. - Keine Live-Migration für GPU-VMs. Bei einem Ausfall springt der Router auf einen anderen Slot oder auf Stufe 0. - Treiber-Updates laufen pro VM, abgesichert durch einen Snapshot. + +## Nachtrag 2026-10-13: eine GPU-VM pro Knoten (Aufbauplan D2, Bestätigung offen) +Die echte Hardware (Aufbauplan, Ausgangslage) ändert die Voraussetzungen: +- Im Proxmox-Cluster stecken nur Maxwell und Pascal (pve44, pve46, pve48: M4000 + P2000, pve52: nur M4000). Die beiden RTX 4000 Ada sitzen in Windows-Arbeitsplätzen außerhalb des Clusters (D3, ADR-0006). Das Argument „gemischte Generationen in einem Host“ entfällt. +- Die Knoten haben 32 GB RAM. Eine VM pro Rolle würde den RAM mehrfach fest reservieren. + +Deshalb gilt, sobald D2 bestätigt ist: +- **Pro GPU-Knoten eine Ubuntu-24.04-VM** (`gpu-pve44` … `gpu-pve52`, vmid 244–252) mit **allen** Karten des Knotens, 12 GB RAM fest, R580 (`driver_generation: legacy`). Darin laufen die Rollen als Docker-Compose-Dienste. +- Resource Mappings heißen `gpu--` (z. B. `gpu-p2000-pve44`). +- Ausgeschaltete Karten (M4000) werden trotzdem durchgereicht. So braucht ihr Wiedereinsatz nach dem Benchmark keine Änderung an der VM. +- Infra bleibt LXC (`infra`, vmid 101), dazu kommt ein kleiner `ops`-LXC (vmid 100) für Ansible und OpenTofu. +- Die oben genannten RAM-Richtwerte für vLLM-VMs entfallen. Das RAM-Budget je Knoten steht im Aufbauplan. diff --git a/docs/adr/0002-storage-zfs-kein-ceph.md b/docs/adr/0002-storage-zfs-kein-ceph.md index 473c29e..3c27e3b 100644 --- a/docs/adr/0002-storage-zfs-kein-ceph.md +++ b/docs/adr/0002-storage-zfs-kein-ceph.md @@ -1,6 +1,6 @@ # ADR-0002: ZFS lokal + Replikation statt Ceph -- Status: angenommen +- Status: angenommen, ergänzt am 2026-10-13 (Nachtrag D1, entschieden) - Datum: 2026-10-11 ## Kontext @@ -16,3 +16,13 @@ Die Nodes sind mit **1 GbE** verbunden. Jede physische Maschine hat 2–3 SSDs m - Bei einem Node-Ausfall gehen höchstens 15 Min. Daten der Infra-VMs verloren. Postgres sichert sich zusätzlich über WAL-Archivierung bzw. PBS. - Modellwechsel verursachen Downloads (10 GB ≈ 90 s über 1 GbE). Das ist akzeptabel. - Die Speicherbudgets pro Rolle stehen in PLAN.md, Abschnitt 1.4. + +## Nachtrag 2026-10-13: Variante B ohne Neuinstallation (Aufbauplan D1, entschieden am 12.10.) +Die Knoten sind bereits mit ext4 und LVM-thin auf der System-SSD installiert. Entschieden ist **Variante B**: +- Die System-SSD bleibt, wie sie ist (`local` und `local-lvm`). Die GPU-VMs liegen auf `local-lvm`. +- Auf **pve44, pve48 und pve52** wird die zweite SSD (bisher altes Windows) nach ausdrücklicher Freigabe remote zur Einzel-SSD `tank` (`ashift=12`, `lz4`). Auf pve48 bleibt `sdc` unberührt. +- **pve46** (eine Platte) und **pve61** (Kiosk) bekommen kein ZFS. Dort laufen nur Gäste ohne HA (GPU-VM, `ops`). +- HA-Gäste (`infra`) liegen auf `tank` und werden alle 15 Min. auf die anderen `tank`-Knoten repliziert. +- Der ZFS-ARC wird auf 4 GiB begrenzt. +- Einen Spiegel für die Systemplatte gibt es nicht. Fällt eine System-SSD aus, wird der Knoten neu installiert, die HA-Gäste laufen repliziert weiter. +- Variante A (Neuinstallation mit ZFS-RAID1 vor Ort) lässt sich später pro Knoten nachholen. diff --git a/docs/adr/0006-legacy-gpus-llamacpp-tts-auf-ada.md b/docs/adr/0006-legacy-gpus-llamacpp-tts-auf-ada.md index 2a913b3..0ecbd9c 100644 --- a/docs/adr/0006-legacy-gpus-llamacpp-tts-auf-ada.md +++ b/docs/adr/0006-legacy-gpus-llamacpp-tts-auf-ada.md @@ -1,6 +1,6 @@ # ADR-0006: Alte GPUs nur für llama.cpp, M4000 vorerst außer Betrieb, TTS auf Ada und CPU -- Status: angenommen +- Status: angenommen, ergänzt am 2026-10-13 (Nachtrag D3, Bestätigung offen) - Datum: 2026-10-12 ## Kontext @@ -20,3 +20,9 @@ ## Folgen - Bei `gpu-flex` muss sich der Lifecycle die Karte mit TTS teilen. Während des Trainings gehen sowohl `tier1-flex` als auch `tts-de-hq` auf `unavailable`, und LiteLLM fällt auf `tts-de` zurück. - Die tatsächlichen RTF-Werte misst `knaster bench tts` beim Deployment. Sie ersetzen die Schätzungen im Katalog. + +## Nachtrag 2026-10-13: Ada-Rechner bleiben Windows (Aufbauplan D3, Bestätigung offen) +Die beiden RTX 4000 Ada stecken in **GODE-PC-05 und GODE-PC-06** (je eine Karte, 64 GB RAM). Das sind Windows-Arbeitsplätze, tagsüber haben Mitarbeitende Vorrang. Sie laufen schon heute als LLM-Worker (`LocalLLMWorker.ps1`, llama-server mit CUDA). +- Die Slots `tier1-general` und `tier1-flex` lösen künftig über die **Worker-Registry v2** auf einen Worker auf, der gerade online ist. Ist keiner online, geht die Anfrage an tier0 über den PSE-LiteLLM (Aufbauplan Task 6.4). Bis dahin behalten sie in der Registry ihre Engine. +- **vLLM, Multi-LoRA, QLoRA und Qwen3-TTS (`tts-de-hq`)** laufen unter Windows nicht nativ. Sie kommen erst mit einer späteren Entscheidung über WSL2 bzw. nächtliches vLLM (Phase 9). Bis dahin ist `tts-de` (Piper, CPU) der einzige aktive TTS-Slot. +- Im Inventar stehen die Rechner mit `cpu_roles: false`. Das Placement verplant dort nur die GPU und nie Infra- oder CPU-Fallback-Rollen. diff --git a/docs/spec/SPEC-inventory.md b/docs/spec/SPEC-inventory.md index d3fed4a..ea81f69 100644 --- a/docs/spec/SPEC-inventory.md +++ b/docs/spec/SPEC-inventory.md @@ -29,7 +29,7 @@ Deterministisch: - `prefer: largest` wählt die GPU mit dem meisten VRAM (für LLM-Rollen). - `prefer: smallest_fitting` wählt die kleinste passende GPU (für Embedding und Rerank). So bleiben große Karten frei. 3. Rollen mit `share_with: ` teilen sich die GPU (bzw. bei `gpu: false` den Node) einer bereits platzierten Rolle. Ein Beispiel ist TTS auf `gpu-flex`. -4. Rollen mit `gpu: false` bzw. `cpu_fallback: true` werden ohne passende GPU den CPU-Nodes zugewiesen, sortiert nach RAM. +4. Rollen mit `gpu: false` bzw. `cpu_fallback: true` werden ohne passende GPU den CPU-Nodes zugewiesen, sortiert nach RAM. Nodes mit `cpu_roles: false` (Arbeitsplätze, Kiosk) sind davon ausgenommen. Dort wird nur die eigene GPU verplant. 5. Pro zugewiesener Rolle werden die Katalogmodelle gelistet, die passen (Engine, VRAM, CC). 6. Ergebnis: `assignments[{role, node, gpu_index?, models_fitting[]}]` und `unplaced[{role, reason}]`. diff --git a/docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md b/docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md index eb85949..0341e16 100644 --- a/docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md +++ b/docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md @@ -138,14 +138,15 @@ Ersetzt PLAN §8 „0 Fundament“ (Teil Inventar) und IMPLEMENTATION „Gemeins **Interfaces:** - Produces: Proxmox-Knoten `pve44`, `pve46`, `pve48`, `pve52`, `pve61`, dazu die Inventar-Knoten `gode-pc-05` und `gode-pc-06` (Ada, ohne Proxmox, nur im Inventar). PCI-Mappings `gpu-m4000-pve44`, `gpu-p2000-pve44`, `gpu-m4000-pve46`, `gpu-p2000-pve46`, `gpu-m4000-pve52`, `gpu-m4000-pve48`, `gpu-p2000-pve48`. GPU-VMs `gpu-pve44` (vmid 244), `gpu-pve46` (246), `gpu-pve48` (248), `gpu-pve52` (252). Infra-LXC `infra` (vmid 101). Ansible-Gruppen `pve_all`, `pve_gpu_hosts`, `pve_kiosk`. -- [ ] **Step 1: Failing test.** In `test_inventory_validate.py` den Test `test_site_knaster_matches_real_nodes` anlegen: +- [x] **Step 1: Failing test.** In `test_inventory_validate.py` den Test `test_site_knaster_matches_real_nodes` anlegen: - Die Inventar-Knoten sind genau `{pve44, pve46, pve48, pve52, pve61, gode-pc-05, gode-pc-06}`. - `proxmox.yaml` enthält keine IP aus `10.20.0.0/24`. - Jedes `pci_mappings[*].node` und jedes `vms[*].node` liegt in `{pve44, pve46, pve48, pve52}`. -- [ ] **Step 2:** `make test` → FAIL (alte Annahmen `pve-ada`, `pve1`–`pve4`). -- [ ] **Step 3:** Site-Dateien umschreiben. Werte für IDs und Pfade aus `lspci -nn` und dem Healthcheck: M4000 `10de:13f1` auf `0000:01:00`, P2000 `10de:1c30` auf `0000:02:00`. Gateway `192.168.100.1`, DNS `192.168.100.120`, keine `vlan_svc`, Gast-IPs aus D5. Die M4000 bleiben `disabled: true` bis zum Benchmark-Tag (Task 3.3). Die Ada-Slots (`tier1-general`, `tier1-flex`, `tts-de-hq`) behalten vorerst ihre Engine. In `registry.yaml` entfallen bei ihnen nur `pve_node` und `vmid`. Die Umstellung auf die Worker-Registry folgt in Task 6.4. -- [ ] **Step 4:** `make test && make validate` → PASS. `knaster placement plan --inventory sites/knaster/inventory.yaml` liefert eine Zuordnung ohne Fehler. -- [ ] **Step 5:** Commit `sites: Inventar und Zielbild an echte Knoten (pve44–pve61) angepasst; ADR-0001/0002/0006 ergänzt`. +- [x] **Step 2:** `make test` → FAIL (alte Annahmen `pve-ada`, `pve1`–`pve4`). +- [x] **Step 3:** Site-Dateien umschreiben. Werte für IDs und Pfade aus `lspci -nn` und dem Healthcheck: M4000 `10de:13f1` auf `0000:01:00`, P2000 `10de:1c30` auf `0000:02:00`. Gateway `192.168.100.1`, DNS `192.168.100.120`, keine `vlan_svc`, Gast-IPs aus D5. Die M4000 bleiben `disabled: true` bis zum Benchmark-Tag (Task 3.3). Die Ada-Slots (`tier1-general`, `tier1-flex`, `tts-de-hq`) behalten vorerst ihre Engine. In `registry.yaml` entfallen bei ihnen nur `pve_node` und `vmid`. Die Umstellung auf die Worker-Registry folgt in Task 6.4. +- [x] **Step 4:** `make test && make validate` → PASS. `knaster placement plan --inventory sites/knaster/inventory.yaml` liefert eine Zuordnung ohne Fehler. +- [x] **Step 5:** Commit `sites: Inventar und Zielbild an echte Knoten (pve44–pve61) angepasst; ADR-0001/0002/0006 ergänzt`. +- Ergebnis (13.10.): Zusätzlich angepasst wurden `platform/proxmox/tofu` (mehrere Karten pro VM, Speicher je Gast, `vlan_svc: null`), `playbooks/site.yml` (`pve-host` nur für `pve_gpu_hosts`), die Replikation in `pve-host` (mehrere Ziele) und `deploy/prometheus/prometheus.yml` (IP-Literale, keine Ada-Ziele). Neues Inventarfeld `cpu_roles: false` für die Ada-Rechner und pve61. Damit verplant das Placement dort keine Infra- und keine CPU-Rollen. Hinweis: Das Placement schlägt `infra` auf pve52 vor, weil dort keine Karte aktiv ist. Laut RAM-Budget steht `infra` auf pve44, so ist es in `proxmox.yaml` eingetragen. Nicht mit `tofu validate` und `ansible-lint` geprüft, beides ist lokal nicht installiert. Das wird im ops-LXC nachgeholt (Task 3.1). --- @@ -234,7 +235,7 @@ Ersetzt GPU-Knoten 3 (Teil Cluster). Mit 5 Stimmen ist **kein QDevice** nötig. **Wo:** remote · **Files:** Modify `platform/proxmox/ansible/inventory.yml`, `platform/proxmox/ansible/playbooks/site.yml`, Create `platform/proxmox/README.md` (Abschnitt ops-LXC) -- [ ] LXC `ops` (Debian 13, vmid 100, auf pve44, IP aus D5) mit `ansible-core`, `opentofu`, `git` und dem Repo-Clone. Ansible läuft nicht auf dem Windows-Laptop. +- [ ] LXC `ops` (Debian 13, vmid 100, auf pve46 laut RAM-Budget, IP aus D5) mit `ansible-core`, `opentofu`, `git` und dem Repo-Clone. Ansible läuft nicht auf dem Windows-Laptop. - [ ] `site.yml`: `pve-host` nur für `pve_gpu_hosts` (pve44, pve46, pve48, pve52). pve61 steht in `pve_kiosk` ohne VFIO und Blacklist. - [ ] Rolle `pve-host` ergänzen: `/etc/modprobe.d/vfio.conf` mit `options vfio-pci ids=` und `softdep nvidia pre: vfio-pci`. Die Liste enthält die Grafik- **und** die Audio-Funktionen der Karten. Die IDs aus `lspci -nn -s 01:00` und `lspci -nn -s 02:00` auf dem Knoten lesen, nicht aus dem Gedächtnis eintragen; bekannt sind bisher nur die VGA-IDs `10de:13f1` und `10de:1c30`. - [ ] `ansible-playbook playbooks/site.yml --limit pve_gpu_hosts --check`, danach der echte Lauf. Knoten **einzeln** neu starten. diff --git a/platform/proxmox/README.md b/platform/proxmox/README.md index 7202c19..34426dd 100644 --- a/platform/proxmox/README.md +++ b/platform/proxmox/README.md @@ -7,8 +7,8 @@ Proxmox-spezifische Schicht (austauschbar, siehe PLAN §7.2). Eingabe ist immer ## Ablauf 1. **Inventar:** auf jedem Node `knaster inventory collect` ausführen und `sites/knaster/inventory.yaml` ersetzen. Danach `knaster placement plan --inventory sites/knaster/inventory.yaml` und die Ergebnisse in `proxmox.yaml` übertragen (PCI-Pfade über `lspci -nn | grep -i nvidia`, IOMMU-Gruppen). -2. **Hosts vorbereiten:** `ansible-playbook playbooks/site.yml --limit pve_hosts`. Setzt IOMMU und VFIO und sperrt die NVIDIA-Treiber auf dem Host. Danach die Hosts neu starten. Der ZFS-Pool `tank` wird **nicht** automatisch angelegt (Datenverlustgefahr), die Rolle gibt dafür den Befehl aus. -3. **Template:** pro Node ein Ubuntu-24.04-Cloud-Image als Template mit VMID 9000. +2. **Hosts vorbereiten:** `ansible-playbook playbooks/site.yml --limit pve_gpu_hosts`. Setzt IOMMU und VFIO und sperrt die NVIDIA-Treiber, nur auf GPU-Knoten (pve61 bleibt unberührt, Kiosk). Danach die Hosts neu starten. Der ZFS-Pool `tank` wird **nicht** automatisch angelegt (Datenverlustgefahr), die Rolle gibt dafür den Befehl aus. +3. **Template:** pro GPU-Knoten ein Ubuntu-24.04-Cloud-Image als Template mit VMID 9000. 4. **VMs/LXC:** `cd tofu && tofu init && tofu plan -var proxmox_api_token=...` und danach `tofu apply`. 5. **Gäste:** `ansible-playbook playbooks/site.yml --limit gpu_vms,infra`. Installiert die Treiber pro Generation gepinnt (legacy/modern), das Container-Toolkit, Docker und den Xid-Exporter. 6. **Dienste:** `deploy/compose/` auf den jeweiligen Gästen starten. diff --git a/platform/proxmox/ansible/inventory.yml b/platform/proxmox/ansible/inventory.yml index f68999c..e1e52e9 100644 --- a/platform/proxmox/ansible/inventory.yml +++ b/platform/proxmox/ansible/inventory.yml @@ -1,29 +1,40 @@ -# ANNAHME bis Deployment – Hosts und IPs aus sites/knaster/proxmox.yaml +# Hosts und IPs aus sites/knaster/proxmox.yaml (Aufbauplan Task 0.4). Ansible läuft im ops-LXC (Task 3.1). +# Gast-IPs: ANNAHME bis Task 0.2 (DHCP-Ausschluss .220–.239). all: vars: knaster_site: knaster children: - pve_hosts: - hosts: - pve-ada: {} - pve1: {} - pve2: {} - pve3: {} - pve4: {} + pve_all: + children: + pve_gpu_hosts: + hosts: + pve44: {ansible_host: 192.168.100.210} + pve46: {ansible_host: 192.168.100.209} + pve48: {ansible_host: 192.168.100.212} + pve52: {ansible_host: 192.168.100.207} + pve_kiosk: # keine VFIO-/NVIDIA-Sperre, iGPU bleibt beim Host (Kiosk) + hosts: + pve61: {ansible_host: 192.168.100.214} vars: ansible_user: root gpu_vms: hosts: - gpu-main: {ansible_host: 10.20.0.201, driver_generation: modern, knaster_role: gpu-vllm} - gpu-flex: {ansible_host: 10.20.0.202, driver_generation: modern, knaster_role: gpu-vllm} - gpu-embed-1: {ansible_host: 10.20.0.221, driver_generation: legacy, knaster_role: gpu-llamacpp, llamacpp_profile: embed} - gpu-embed-2: {ansible_host: 10.20.0.222, driver_generation: legacy, knaster_role: gpu-llamacpp, llamacpp_profile: embed} - gpu-small: {ansible_host: 10.20.0.231, driver_generation: legacy, knaster_role: gpu-llamacpp, llamacpp_profile: chat} + gpu-pve44: {ansible_host: 192.168.100.224} + gpu-pve46: {ansible_host: 192.168.100.226} + gpu-pve48: {ansible_host: 192.168.100.228} + gpu-pve52: {ansible_host: 192.168.100.232} vars: ansible_user: knaster ansible_become: true + driver_generation: legacy # R580, Maxwell/Pascal + knaster_role: gpu-llamacpp infra: hosts: - infra: {ansible_host: 10.20.0.101} + infra: {ansible_host: 192.168.100.221} + vars: + ansible_user: root + ops: + hosts: + ops: {ansible_host: 192.168.100.220} vars: ansible_user: root diff --git a/platform/proxmox/ansible/playbooks/site.yml b/platform/proxmox/ansible/playbooks/site.yml index e41266e..cc35419 100644 --- a/platform/proxmox/ansible/playbooks/site.yml +++ b/platform/proxmox/ansible/playbooks/site.yml @@ -1,6 +1,7 @@ -# Reihenfolge: Hosts vorbereiten → (tofu apply) → GPU-VMs → Infra -- name: Proxmox-Hosts (IOMMU, VFIO, ZFS-Replikation) - hosts: pve_hosts +# Reihenfolge: GPU-Knoten vorbereiten → (tofu apply) → GPU-VMs → Infra +# pve61 (pve_kiosk) bekommt bewusst keine Host-Rolle: keine VFIO-Bindung, keine NVIDIA-Sperre (Review Focus 5). +- name: Proxmox-GPU-Knoten (IOMMU, VFIO, ZFS-Replikation) + hosts: pve_gpu_hosts roles: [pve-host] - name: GPU-VMs (Treiber gepinnt, Container-Toolkit, Docker, Xid-Exporter) diff --git a/platform/proxmox/ansible/roles/pve-host/tasks/main.yml b/platform/proxmox/ansible/roles/pve-host/tasks/main.yml index 70246b6..8aed32a 100644 --- a/platform/proxmox/ansible/roles/pve-host/tasks/main.yml +++ b/platform/proxmox/ansible/roles/pve-host/tasks/main.yml @@ -50,21 +50,28 @@ - name: Hinweis, falls tank fehlt (Pool wird bewusst NICHT automatisch angelegt – Datenverlustgefahr) ansible.builtin.debug: - msg: "Auf {{ inventory_hostname }} fehlt der ZFS-Pool 'tank'. Anlegen z. B.: zpool create -o ashift=12 tank mirror /dev/disk/by-id/ /dev/disk/by-id/ && pvesm add zfspool tank -pool tank" - when: tank.rc != 0 + msg: >- + Auf {{ inventory_hostname }} fehlt der ZFS-Pool 'tank'. Nach Freigabe (D1) und Prüfung mit + `lsblk -o NAME,SIZE,FSTYPE,LABEL /dev/sdb`: zpool create -o ashift=12 -O compression=lz4 -O atime=off + tank /dev/disk/by-id/ && pvesm add zfspool tank --pool tank --content images,rootdir + when: + - tank.rc != 0 + - knaster_pve.nodes[inventory_hostname].zfs_tank | default(false) - name: Bestehende Replikationsjobs lesen ansible.builtin.command: pvesr list register: pvesr_jobs changed_when: false -- name: ZFS-Replikation für Infra-Gäste +# Ein Job pro Ziel: -0, -1, … (Reihenfolge aus replicate_to) +- name: ZFS-Replikation für HA-Gäste ansible.builtin.command: > - pvesr create-local-job {{ item.value.vmid }}-0 {{ item.value.replicate_to }} - --schedule '{{ knaster_pve.replication_schedule }}' --comment 'knaster {{ item.key }}' - loop: "{{ knaster_pve.containers | dict2items }}" + pvesr create-local-job {{ item.0.value.vmid }}-{{ idx }} {{ item.1 }} + --schedule '{{ knaster_pve.replication_schedule }}' --comment 'knaster {{ item.0.key }}' + loop: "{{ knaster_pve.containers | dict2items | subelements('value.replicate_to', skip_missing=true) }}" + vars: + idx: "{{ item.0.value.replicate_to.index(item.1) }}" when: - - item.value.node == inventory_hostname - - item.value.replicate_to is defined - - (item.value.vmid | string) + '-0' not in pvesr_jobs.stdout + - item.0.value.node == inventory_hostname + - ((item.0.value.vmid | string) + '-' + (idx | string)) not in pvesr_jobs.stdout changed_when: true diff --git a/platform/proxmox/tofu/main.tf b/platform/proxmox/tofu/main.tf index a8cc40f..ce3d473 100644 --- a/platform/proxmox/tofu/main.tf +++ b/platform/proxmox/tofu/main.tf @@ -41,12 +41,12 @@ resource "proxmox_virtual_environment_vm" "gpu" { } efi_disk { - datastore_id = local.site.cluster.storage_vm + datastore_id = try(each.value.storage, local.site.cluster.storage_default) type = "4m" } disk { - datastore_id = local.site.cluster.storage_vm + datastore_id = try(each.value.storage, local.site.cluster.storage_default) interface = "scsi0" size = each.value.disk_gb discard = "on" @@ -55,7 +55,7 @@ resource "proxmox_virtual_environment_vm" "gpu" { # Modelle: eigenes Disk-Image, nicht repliziert, nicht gesichert (ADR-0002) disk { - datastore_id = local.site.cluster.storage_vm + datastore_id = try(each.value.storage, local.site.cluster.storage_default) interface = "scsi1" size = each.value.models_disk_gb discard = "on" @@ -64,20 +64,24 @@ resource "proxmox_virtual_environment_vm" "gpu" { replicate = false } - hostpci { - device = "hostpci0" - mapping = proxmox_virtual_environment_hardware_mapping_pci.gpu[each.value.gpu].name - pcie = true - rombar = true + # Alle Karten des Knotens in eine VM (D2); Reihenfolge = Index in der VM + dynamic "hostpci" { + for_each = each.value.gpus + content { + device = "hostpci${hostpci.key}" + mapping = proxmox_virtual_environment_hardware_mapping_pci.gpu[hostpci.value].name + pcie = true + rombar = true + } } network_device { bridge = local.site.cluster.bridge - vlan_id = local.site.cluster.vlan_svc + vlan_id = local.site.cluster.vlan_svc # null = flaches Netz } initialization { - datastore_id = local.site.cluster.storage_vm + datastore_id = try(each.value.storage, local.site.cluster.storage_default) ip_config { ipv4 { address = each.value.ip @@ -94,16 +98,16 @@ resource "proxmox_virtual_environment_vm" "gpu" { } } -resource "proxmox_virtual_environment_container" "infra" { +resource "proxmox_virtual_environment_container" "lxc" { for_each = local.site.containers node_name = each.value.node vm_id = each.value.vmid - tags = ["knaster", "infra"] + tags = ["knaster", each.key] unprivileged = true start_on_boot = true features { - nesting = true # Docker im LXC + nesting = try(each.value.nesting, true) # Docker im LXC } cpu { @@ -115,12 +119,12 @@ resource "proxmox_virtual_environment_container" "infra" { } disk { - datastore_id = local.site.cluster.storage_vm + datastore_id = try(each.value.storage, local.site.cluster.storage_default) size = each.value.disk_gb } operating_system { - template_file_id = "${local.site.cluster.storage_iso}:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst" + template_file_id = local.site.cluster.lxc_template type = "debian" } @@ -132,6 +136,9 @@ resource "proxmox_virtual_environment_container" "infra" { initialization { hostname = each.key + dns { + servers = [local.site.cluster.dns] + } ip_config { ipv4 { address = each.value.ip @@ -149,5 +156,5 @@ resource "proxmox_virtual_environment_haresource" "infra" { resource_id = "ct:${each.value.vmid}" state = "started" comment = "knaster ${each.key}" - depends_on = [proxmox_virtual_environment_container.infra] + depends_on = [proxmox_virtual_environment_container.lxc] } diff --git a/platform/proxmox/tofu/outputs.tf b/platform/proxmox/tofu/outputs.tf index cc906b2..04ac9a7 100644 --- a/platform/proxmox/tofu/outputs.tf +++ b/platform/proxmox/tofu/outputs.tf @@ -2,6 +2,6 @@ output "gpu_vms" { value = { for k, v in proxmox_virtual_environment_vm.gpu : k => { node = v.node_name, vmid = v.vm_id } } } -output "infra_containers" { - value = { for k, v in proxmox_virtual_environment_container.infra : k => { node = v.node_name, vmid = v.vm_id } } +output "containers" { + value = { for k, v in proxmox_virtual_environment_container.lxc : k => { node = v.node_name, vmid = v.vm_id } } } diff --git a/sites/knaster/inventory.yaml b/sites/knaster/inventory.yaml index 8d63107..6c15972 100644 --- a/sites/knaster/inventory.yaml +++ b/sites/knaster/inventory.yaml @@ -1,38 +1,55 @@ -# Inventar des Clusters "knaster". -# ANNAHME: Die Verteilung der GPUs und SSDs auf die Hosts ist noch nicht erfasst. Bitte beim -# gemeinsamen Deployment mit `knaster inventory collect` auf jedem Node ersetzen. +# Inventar des Clusters "knaster" – Stand 12.10.2026 (Aufbauplan docs/superpowers/plans/2026-10-12-…, Ausgangslage). +# Quelle: Healthcheck 08.10. + Inventar pve48 12.10. + Angaben des Nutzers. Beim Bootstrap mit +# `knaster inventory collect` gegenprüfen; Abweichungen hier eintragen. +# Speicher: Variante B (D1) – sda = System (local + local-lvm), sdb wird nach Freigabe zum ZFS-Pool `tank`. nodes: - - name: pve-ada - cpu: {model: "unbekannt", cores: 6, threads: 12} + - name: pve44 # 192.168.100.210, ThinkStation P310 + cpu: {model: "Intel Core i7-6700", cores: 4, threads: 8} + memory_gb: 32 + disks: [{name: sda, size_gb: 256}, {name: sdb, size_gb: 256}] # sdb: altes Windows (NTFS) → tank + gpus: + - {index: 0, name: "Quadro M4000", memory_gb: 8, compute_cap: 5.2, disabled: true, note: "außer Betrieb bis Benchmark-Tag (Task 3.3, ADR-0006)"} + - {index: 1, name: "Quadro P2000", memory_gb: 5, compute_cap: 6.1} + - name: pve46 # 192.168.100.209, ThinkStation P310, Legacy-Boot, eine Platte (kein ZFS) + cpu: {model: "Intel Core i7-6700", cores: 4, threads: 8} + memory_gb: 32 + disks: [{name: sda, size_gb: 256}] + gpus: + - {index: 0, name: "Quadro M4000", memory_gb: 8, compute_cap: 5.2, disabled: true, note: "außer Betrieb bis Benchmark-Tag (Task 3.3, ADR-0006)"} + - {index: 1, name: "Quadro P2000", memory_gb: 5, compute_cap: 6.1} + - name: pve48 # 192.168.100.212, ThinkStation P310, Legacy-Boot + cpu: {model: "Intel Core i7-6700", cores: 4, threads: 8} + memory_gb: 32 + disks: [{name: sda, size_gb: 256}, {name: sdb, size_gb: 256}, {name: sdc, size_gb: 128}] # sdb → tank, sdc bleibt unberührt + gpus: + - {index: 0, name: "Quadro M4000", memory_gb: 8, compute_cap: 5.2, disabled: true, note: "außer Betrieb bis Benchmark-Tag (Task 3.3, ADR-0006)"} + - {index: 1, name: "Quadro P2000", memory_gb: 5, compute_cap: 6.1} + - name: pve52 # 192.168.100.207, ThinkStation P320, nur M4000 verbaut + cpu: {model: "Intel Core i7-7700", cores: 4, threads: 8} + memory_gb: 32 + disks: [{name: sda, size_gb: 256}, {name: sdb, size_gb: 256}] # sdb: altes Windows (BitLocker) → tank + gpus: + - {index: 0, name: "Quadro M4000", memory_gb: 8, compute_cap: 5.2, disabled: true, note: "außer Betrieb bis Benchmark-Tag (Task 3.3, ADR-0006)"} + - name: pve61 # 192.168.100.214, Surface Book, LAN über USB-NIC im Dock – nur Kiosk + cpu: {model: "Intel Core i5-8350U", cores: 4, threads: 8} + memory_gb: 8 + disks: [{name: nvme0n1, size_gb: 256}] + cpu_roles: false + labels: {platform: proxmox, purpose: kiosk} + + # Außerhalb des Proxmox-Clusters (D3): Windows-Arbeitsplätze, tagsüber haben Mitarbeitende Vorrang. + # Leistung kommt über die Worker-Registry v2 (LocalLLMWorker.ps1, llama-server). Nur die eigene GPU wird verplant. + - name: gode-pc-05 + cpu: {model: "unbekannt (Task 0.1 e2)", cores: 0, threads: 0} memory_gb: 64 - disks: [{name: sda, size_gb: 256}, {name: sdb, size_gb: 256}] + cpu_roles: false + labels: {platform: windows, worker: worker-registry-v2} gpus: - {index: 0, name: "NVIDIA RTX 4000 Ada Generation", memory_gb: 20, compute_cap: 8.9} - - {index: 1, name: "NVIDIA RTX 4000 Ada Generation", memory_gb: 20, compute_cap: 8.9} - - name: pve1 - cpu: {model: "Intel Core i7 (6. Gen) 3.4 GHz", cores: 4, threads: 8} - memory_gb: 32 - disks: [{name: sda, size_gb: 128}, {name: sdb, size_gb: 256}] + - name: gode-pc-06 + cpu: {model: "unbekannt (Task 0.1 e2)", cores: 0, threads: 0} + memory_gb: 64 + cpu_roles: false + labels: {platform: windows, worker: worker-registry-v2} gpus: - - {index: 0, name: "Quadro M4000", memory_gb: 8, compute_cap: 5.2, disabled: true, note: "außer Betrieb bis llama-bench + Strommessung (ADR-0006)"} - - {index: 1, name: "Quadro M4000", memory_gb: 8, compute_cap: 5.2, disabled: true, note: "außer Betrieb bis llama-bench + Strommessung (ADR-0006)"} - - name: pve2 - cpu: {model: "Intel Core i7 (7. Gen) 3.6 GHz", cores: 4, threads: 8} - memory_gb: 32 - disks: [{name: sda, size_gb: 128}, {name: sdb, size_gb: 256}, {name: sdc, size_gb: 256}] - gpus: - - {index: 0, name: "Quadro M4000", memory_gb: 8, compute_cap: 5.2, disabled: true, note: "außer Betrieb bis llama-bench + Strommessung (ADR-0006)"} - - {index: 1, name: "Quadro M4000", memory_gb: 8, compute_cap: 5.2, disabled: true, note: "außer Betrieb bis llama-bench + Strommessung (ADR-0006)"} - - name: pve3 - cpu: {model: "Intel Core i7 (7. Gen) 3.6 GHz", cores: 4, threads: 8} - memory_gb: 32 - disks: [{name: sda, size_gb: 128}, {name: sdb, size_gb: 256}] - gpus: - - {index: 0, name: "Quadro P2000", memory_gb: 5, compute_cap: 6.1} - - {index: 1, name: "Quadro P2000", memory_gb: 5, compute_cap: 6.1} - - name: pve4 - cpu: {model: "Intel Core i7 (7. Gen) 3.6 GHz", cores: 4, threads: 8} - memory_gb: 32 - disks: [{name: sda, size_gb: 128}, {name: sdb, size_gb: 256}, {name: sdc, size_gb: 256}] - gpus: - - {index: 0, name: "Quadro P2000", memory_gb: 5, compute_cap: 6.1} + - {index: 0, name: "NVIDIA RTX 4000 Ada Generation", memory_gb: 20, compute_cap: 8.9} diff --git a/sites/knaster/proxmox.yaml b/sites/knaster/proxmox.yaml index bd1a56a..eefeac2 100644 --- a/sites/knaster/proxmox.yaml +++ b/sites/knaster/proxmox.yaml @@ -1,31 +1,44 @@ # Proxmox-Zielbild für "knaster" (Eingabe für platform/proxmox/tofu und ansible). -# ANNAHMEN (Hosts, VM-IDs, IPs, PCI-Adressen) bis zum Inventarlauf – siehe docs/IMPLEMENTATION.md. +# Grundlage: Aufbauplan docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md (D1 B, D2, D5). +# ANNAHME bis Task 0.2: Gast-IPs aus dem vorgeschlagenen Block .220–.239 (DHCP-Ausschluss noch nicht geprüft). +# ANNAHME bis Task 3.1: PCI-Pfade/IOMMU-Gruppen wie auf pve48 geprüft; auf jedem Knoten mit `lspci -nn` bestätigen. cluster: - endpoint: https://pve1:8006/ - storage_vm: tank # ZFS-Pool auf SSD 2+3 (ADR-0002) + endpoint: https://192.168.100.210:8006/ # pve44, IP-Literal (Global Constraints) + storage_default: local-lvm # D1 B: GPU-VMs auf der System-SSD; HA-Gäste auf `tank` (pve44/48/52) storage_iso: local - template_vmid: 9000 # Ubuntu 24.04 Cloud-Image-Template (pro Node vorhanden) + template_vmid: 9000 # Ubuntu 24.04 Cloud-Image-Template (pro GPU-Knoten vorhanden) + lxc_template: "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst" # ANNAHME: Dateiname nach `pveam update` prüfen bridge: vmbr0 - vlan_svc: 20 - gateway: 10.20.0.1 - dns: 10.20.0.1 + vlan_svc: null # flaches Netz, vorerst kein VLAN (D5) + gateway: 192.168.100.1 + dns: 192.168.100.120 ssh_public_key_file: ~/.ssh/id_ed25519.pub -pci_mappings: # Proxmox Resource Mappings (Datacenter → Resource Mappings → PCI) - gpu-ada-1: {node: pve-ada, path: "0000:01:00", id: "10de:27b2", subsystem_id: "", iommu_group: 0} - gpu-ada-2: {node: pve-ada, path: "0000:02:00", id: "10de:27b2", subsystem_id: "", iommu_group: 0} - gpu-p2000-1: {node: pve3, path: "0000:01:00", id: "10de:1c30", subsystem_id: "", iommu_group: 0} - gpu-p2000-2: {node: pve3, path: "0000:02:00", id: "10de:1c30", subsystem_id: "", iommu_group: 0} - gpu-p2000-3: {node: pve4, path: "0000:01:00", id: "10de:1c30", subsystem_id: "", iommu_group: 0} +nodes: # Proxmox-Knoten (Cluster-Mitglieder), Ansible-Gruppen siehe platform/proxmox/ansible + pve44: {ip: 192.168.100.210, gpu_host: true, zfs_tank: true} + pve46: {ip: 192.168.100.209, gpu_host: true, zfs_tank: false} # eine Platte + pve48: {ip: 192.168.100.212, gpu_host: true, zfs_tank: true} + pve52: {ip: 192.168.100.207, gpu_host: true, zfs_tank: true} + pve61: {ip: 192.168.100.214, gpu_host: false, zfs_tank: false} # Surface, Kiosk, keine Gäste -vms: # GPU-Rollen als VM (ADR-0001) - gpu-main: {node: pve-ada, vmid: 201, cores: 6, memory_mb: 28672, disk_gb: 40, models_disk_gb: 100, gpu: gpu-ada-1, ip: 10.20.0.201/24, role: gpu-vllm, driver_generation: modern} - gpu-flex: {node: pve-ada, vmid: 202, cores: 6, memory_mb: 28672, disk_gb: 40, models_disk_gb: 100, gpu: gpu-ada-2, ip: 10.20.0.202/24, role: gpu-vllm, driver_generation: modern} - gpu-embed-1: {node: pve3, vmid: 221, cores: 2, memory_mb: 6144, disk_gb: 30, models_disk_gb: 20, gpu: gpu-p2000-1, ip: 10.20.0.221/24, role: gpu-llamacpp, profile: embed, driver_generation: legacy} - gpu-embed-2: {node: pve4, vmid: 222, cores: 2, memory_mb: 6144, disk_gb: 30, models_disk_gb: 20, gpu: gpu-p2000-3, ip: 10.20.0.222/24, role: gpu-llamacpp, profile: embed, driver_generation: legacy} - gpu-small: {node: pve3, vmid: 231, cores: 2, memory_mb: 8192, disk_gb: 30, models_disk_gb: 20, gpu: gpu-p2000-2, ip: 10.20.0.231/24, role: gpu-llamacpp, profile: chat, driver_generation: legacy} +pci_mappings: # Datacenter → Resource Mappings → PCI; Pfad ohne Funktion = Grafik + Audio + gpu-m4000-pve44: {node: pve44, path: "0000:01:00", id: "10de:13f1", subsystem_id: "", iommu_group: 1} + gpu-p2000-pve44: {node: pve44, path: "0000:02:00", id: "10de:1c30", subsystem_id: "", iommu_group: 8} + gpu-m4000-pve46: {node: pve46, path: "0000:01:00", id: "10de:13f1", subsystem_id: "", iommu_group: 1} + gpu-p2000-pve46: {node: pve46, path: "0000:02:00", id: "10de:1c30", subsystem_id: "", iommu_group: 8} + gpu-m4000-pve48: {node: pve48, path: "0000:01:00", id: "10de:13f1", subsystem_id: "", iommu_group: 1} + gpu-p2000-pve48: {node: pve48, path: "0000:02:00", id: "10de:1c30", subsystem_id: "", iommu_group: 8} + gpu-m4000-pve52: {node: pve52, path: "0000:01:00", id: "10de:13f1", subsystem_id: "", iommu_group: 1} -containers: # Infrastruktur als LXC (ADR-0001), HA + ZFS-Replikation (ADR-0002) - infra: {node: pve1, vmid: 101, cores: 4, memory_mb: 12288, disk_gb: 60, ip: 10.20.0.101/24, ha: true, replicate_to: pve2} +vms: # eine GPU-VM pro Knoten mit allen Karten (D2), 12 GB fest (RAM-Budget) + gpu-pve44: {node: pve44, vmid: 244, cores: 4, memory_mb: 12288, disk_gb: 40, models_disk_gb: 60, gpus: [gpu-m4000-pve44, gpu-p2000-pve44], ip: 192.168.100.224/24, role: gpu-llamacpp, driver_generation: legacy} + gpu-pve46: {node: pve46, vmid: 246, cores: 4, memory_mb: 12288, disk_gb: 40, models_disk_gb: 60, gpus: [gpu-m4000-pve46, gpu-p2000-pve46], ip: 192.168.100.226/24, role: gpu-llamacpp, driver_generation: legacy} + gpu-pve48: {node: pve48, vmid: 248, cores: 4, memory_mb: 12288, disk_gb: 40, models_disk_gb: 60, gpus: [gpu-m4000-pve48, gpu-p2000-pve48], ip: 192.168.100.228/24, role: gpu-llamacpp, driver_generation: legacy} + gpu-pve52: {node: pve52, vmid: 252, cores: 4, memory_mb: 12288, disk_gb: 40, models_disk_gb: 60, gpus: [gpu-m4000-pve52], ip: 192.168.100.232/24, role: gpu-llamacpp, driver_generation: legacy} +containers: # LXC (Container zuerst); HA + Replikation nur auf `tank`-Knoten (Task 2.2) + ops: {node: pve46, vmid: 100, cores: 2, memory_mb: 1024, disk_gb: 16, ip: 192.168.100.220/24, storage: local-lvm, nesting: false} # Ansible/OpenTofu, kein HA (RAM-Budget) + infra: {node: pve44, vmid: 101, cores: 4, memory_mb: 8192, disk_gb: 60, ip: 192.168.100.221/24, storage: tank, nesting: true, ha: true, replicate_to: [pve52, pve48]} + +# Später, eigene Tasks: pbs (VM 110, pve52, Task 5.1, .222), grp-test (LXC, pve48 auf tank, Phase 8, .223). replication_schedule: "*/15" diff --git a/sites/knaster/registry.yaml b/sites/knaster/registry.yaml index d34751d..662b22d 100644 --- a/sites/knaster/registry.yaml +++ b/sites/knaster/registry.yaml @@ -1,6 +1,9 @@ # Registry für den Cluster "knaster" (SPEC-core §1). # Clients sprechen nur Slot-Namen an; litellm_model ist der Alias in deploy/litellm/config.yaml. # ops: Labels für den Healer (Portainer-Endpoint, Container, Proxmox-Node, VM-ID). +# Stand Aufbauplan Task 0.4: P2000-Slots in den GPU-VMs gpu-pve44/46/48 (Zuordnung ANNAHME bis Benchmark-Tag, +# Task 3.3). Ada-Slots laufen auf Windows-Arbeitsplätzen (D3) ohne Proxmox; Umstellung auf die +# Worker-Registry v2 folgt in Task 6.4, bis dahin behalten sie ihre Engine. slots: tier0-strong: tier: 0 @@ -15,37 +18,37 @@ slots: tier: 1 device: gpu engine: vllm - node: gpu-main # RTX 4000 Ada #1 + node: gpu-main # RTX 4000 Ada, GODE-PC-05 (Windows, D3) litellm_model: tier1-general base_model: qwen3-14b-awq # Startkandidat, Entscheidung per Kalibrierungslauf max_context: 32768 effective_context: 16384 supports_lora: true fallbacks: [tier1-flex] - ops: {docker_host: gpu-main, container: vllm, pve_node: pve-ada, vmid: "201"} # VM-IDs/Hosts: ANNAHME bis Deployment + ops: {docker_host: gpu-main, container: vllm} # kein Proxmox (D3); bis Task 6.4 tier1-flex: tier: 1 device: gpu engine: vllm - node: gpu-flex # RTX 4000 Ada #2 (nachts Training → unavailable) + node: gpu-flex # RTX 4000 Ada, GODE-PC-06 (Windows, D3) litellm_model: tier1-flex base_model: qwen3-14b-awq max_context: 32768 effective_context: 16384 supports_lora: true - ops: {docker_host: gpu-flex, container: vllm, pve_node: pve-ada, vmid: "202"} # VM-IDs/Hosts: ANNAHME bis Deployment + ops: {docker_host: gpu-flex, container: vllm} # kein Proxmox (D3); bis Task 6.4 verifier-small: tier: 1 device: gpu engine: llamacpp - node: gpu-small # Quadro M4000 (Nutzen per Benchmark prüfen) + node: gpu-small # Quadro P2000 in gpu-pve48 litellm_model: verifier-small base_model: qwen3-4b-q4km max_context: 8192 effective_context: 6144 - ops: {docker_host: gpu-small, container: llamacpp-chat, pve_node: pve3, vmid: "231"} # VM-IDs/Hosts: ANNAHME bis Deployment + ops: {docker_host: gpu-pve48, container: llamacpp-chat, pve_node: pve48, vmid: "248"} # ANNAHME bis Task 3.3 signature-extractor: tier: 1 @@ -56,18 +59,18 @@ slots: base_model: qwen3-4b-q4km max_context: 8192 effective_context: 6144 - ops: {docker_host: gpu-small, container: llamacpp-chat, pve_node: pve3, vmid: "231"} # VM-IDs/Hosts: ANNAHME bis Deployment + ops: {docker_host: gpu-pve48, container: llamacpp-chat, pve_node: pve48, vmid: "248"} # ANNAHME bis Task 3.3 embed-default: tier: 1 device: gpu engine: llamacpp - node: gpu-embed # Quadro P2000 + node: gpu-embed # Quadro P2000, redundant in gpu-pve44 und gpu-pve46 litellm_model: embed-default base_model: qwen3-embedding-0.6b-q8 max_context: 8192 kind: embedding - ops: {docker_host: gpu-embed-1, container: llamacpp-embed, pve_node: pve3, vmid: "221"} # VM-IDs/Hosts: ANNAHME bis Deployment + ops: {docker_host: gpu-pve44, container: llamacpp-embed, pve_node: pve44, vmid: "244"} # ANNAHME bis Task 3.3 rerank-default: tier: 1 @@ -78,7 +81,7 @@ slots: base_model: bge-reranker-v2-m3-q8 max_context: 8192 kind: rerank - ops: {docker_host: gpu-embed-1, container: llamacpp-rerank, pve_node: pve3, vmid: "221"} # VM-IDs/Hosts: ANNAHME bis Deployment + ops: {docker_host: gpu-pve44, container: llamacpp-rerank, pve_node: pve44, vmid: "244"} # ANNAHME bis Task 3.3 # --- Sprachausgabe (ADR-0006) --- tts-de: @@ -90,17 +93,17 @@ slots: base_model: piper-de-thorsten-medium max_context: 5000 # Zeichen pro Anfrage (TTS-Dienst-Limit) kind: tts - ops: {docker_host: infra, container: tts-piper, pve_node: pve1, vmid: "101"} # ANNAHME bis Deployment + ops: {docker_host: infra, container: tts-piper, pve_node: pve44, vmid: "101"} # HA: kann nach pve52/pve48 wandern tts-de-hq: tier: 1 device: gpu engine: pytorch - node: gpu-flex # teilt sich die Ada mit tier1-flex; während Training unavailable + node: gpu-flex # teilt sich die Ada mit tier1-flex; unter Windows erst ab Phase 9 (WSL2, D3) litellm_model: tts-de-hq base_model: qwen3-tts-1.7b max_context: 5000 kind: tts - ops: {docker_host: gpu-flex, container: qwen-tts, pve_node: pve-ada, vmid: "202"} # ANNAHME bis Deployment + ops: {docker_host: gpu-flex, container: qwen-tts} # kein Proxmox (D3) adapters: [] From fdaa5a5694bcd90acc640797012626bc55e88df0 Mon Sep 17 00:00:00 2001 From: Robert Rapp Date: Tue, 13 Oct 2026 02:05:10 +0200 Subject: [PATCH 2/6] =?UTF-8?q?docs:=20Referenz=20der=20n8n-Wissensdatenba?= =?UTF-8?q?nk=20KNOW/KGW=20(Tabellen,=20Zugriffe,=20Auff=C3=A4lligkeiten)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aus den Workflow-Definitionen abgeleitet (n8n-API, nur lesend, keine DB-Inhalte): 33 Tabellen im Schema knowledge, welche Workflows über welche Operation schreiben und lesen, KGW-Freigaben, Aufrufe unbekannter Operationen (CANCEL, KAN-Learn, IN-Sync) und Bezug zu Task 6.4 (find_active_llm_workers über KGW). Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + docs/reference/KNOW-schema.md | 565 ++++++++++++++++++++++++++++++++++ 2 files changed, 566 insertions(+) create mode 100644 docs/reference/KNOW-schema.md diff --git a/README.md b/README.md index 27d4d4d..ef226dd 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 | +| [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 | | [platform/proxmox/README.md](platform/proxmox/README.md) | Proxmox-Ablauf (OpenTofu + Ansible) | diff --git a/docs/reference/KNOW-schema.md b/docs/reference/KNOW-schema.md new file mode 100644 index 0000000..d125b4f --- /dev/null +++ b/docs/reference/KNOW-schema.md @@ -0,0 +1,565 @@ +# KNOW: Tabellen und Zugriffe (n8n, Schema `knowledge`) + +Stand: 13.10.2026. Abgeleitet aus den Workflow-Definitionen auf n8n.mindboost.app, gelesen über die n8n-API. Inhalte der Datenbank wurden nicht gelesen. +Interaktiver Graph: https://claude.ai/artifact/GP8FfU8M6Wk9hPRmJuk96h (privat, Freigabe über das Share-Menü) + +## Aufbau + +- **KNOW - Knowledge Base Gateway** ist ein Sub-Workflow mit 80 fest benannten Operationen. Jede Operation ist auf eine SQL-Vorlage abgebildet, freies SQL gibt es nicht. Unbekannte Operationen lehnt KNOW mit `Nicht erlaubte KNOW operation` ab. Die Tabellen legt `setup_schema` an (Postgres, Erweiterung `pg_trgm`). +- **KGW - KNOW Query Gateway**: Eingang für externe Aufrufer (Hermes, Skripte) über `POST /webhook/know-query`. + - Authentifizierung: HMAC-SHA256 mit `X-KGW-Timestamp` und `X-KGW-Signature` (Schlüssel in `KGW_HMAC_KEY`), Zeitfenster 5 Min. + - Erlaubt sind 26 Operationen: alle Lese-Operationen plus Lern-Schreibzugriffe. + - Gesperrt sind `setup_schema` sowie die Operationen für BSB, KAF-Bestätigung, Kanzlei, Belege, Mailbox-Grants und LLM-Jobs. +- **Direktes SQL an KNOW vorbei**: + - `CAT - Postfach Pipeline` aktualisiert `workflow_hints` (`notified_at`, `pending_notification`). + - `ASP - Absender-Report` liest `contacts`. + +## Auffälligkeiten + +1. **`CANCEL - Unsubscribe Link`** (aktiv) ruft in 4 Knoten `log_workflow_decision` auf. KNOW kennt diese Operation nicht und lehnt ab. Weil „Continue on Fail“ gesetzt ist, läuft der Workflow weiter, aber es wird nichts protokolliert. +2. **`KAN-Learn - Kanbau Pattern Learning`** (aktiv) ruft `upsert_kanbau_project_patterns` auf. Auch diese Operation kennt KNOW nicht. Der Knoten hat keine Fehlerbehandlung, die Ausführung bricht dort ab. +3. **`IN - Sicherheitsregeln Source Sync`** (inaktiv) ruft `upsert_input_validation_rule` und `upsert_input_validation_source_snapshot` auf. Beide gibt es in KNOW nicht. +4. **Kein aktiver Workflow schreibt über KNOW** in: `contact_aliases`, `contact_project_relations`, `projects`, `project_folders`, `property_development_cases`, `property_development_documents`, `place_enrichments`, `workflow_decisions`, `workflow_hint_config`, `agent_capabilities`, `agent_context_documents`, `agent_execution_log`. Möglich sind Schreibzugriffe von außerhalb von n8n. +5. **`document_extractions`** wird nur von außen über KGW beschrieben. +6. **Löschfristen:** Für Klartext-Felder (`llm_jobs.prompt` und `.result`, `document_extractions.extracted_text`, `property_development_cases.meeting_transcripts`, `agent_context_documents.content`) ist keine Bereinigung erkennbar. Es gibt nur `purge_*`-Operationen für Absender-Beobachtungen und Aliase. Bei `browser_sessions.session_data` ist am Schema nicht erkennbar, ob es verschlüsselt ist. + +## Bezug zu knaster + +- **Task 6.4:** `find_active_llm_workers` ist über KGW von außen erlaubt. Die Operation liefert `mac, host, model, tier, tier_rank, status, endpoint, last_seen_at, age_seconds` und die Zahl wartender Jobs. Der Router kann aktive Worker damit ohne neuen Webhook lesen, braucht dafür aber den HMAC-Schlüssel. +- **Gleiche Idee wie die Skill-Graduierung:** `workflow_hint_config.min_observations_for_skip_ai` lässt die KI weg, sobald ein Muster oft genug beobachtet wurde. Aus `workflow_decisions` (Vorschlag vs. tatsächliche Entscheidung) ließen sich Präferenzpaare gewinnen. + +## Tabellen + +### Mail und Absender + +#### `mail_events` + +Eine Zeile pro verarbeiteter Mail: Kategorie, zugeordnetes Projekt, Status. + +**Sensibel:** Absender und Betreff jeder verarbeiteten Mail. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | BCAT - Historische Mail-Analyse (inaktiv): `store_mail_event`
CAT - Postfach Pipeline: `store_mail_event`
MPA - Projekt-Ablage: `store_mail_event` | +| liest | KGW - KNOW Query Gateway: `find_mail_events, get_mail_events_summary, get_sender_profile`
MPA - Projekt-Ablage: `find_mail_events` | + +
11 Spalten + +`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 + +
+ +#### `sender_observations` + +Absender-Profil je Postfach und Zeitfenster. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | ASP - Absender-Profil Backfill: `upsert_sender_observation` | +| liest | KGW - KNOW Query Gateway: `get_sender_profile, list_sender_domains` | + +
25 Spalten + +`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 + +
+ +#### `platform_domains` + +Bekannte Plattform-Domains. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | ASP - Absender-Profil Backfill: `upsert_platform_domain` | +| liest | ASP - Absender-Profil Backfill: `find_platform_domain`
HMB - Postfach Backfill (inaktiv): `find_platform_domain`
KGW - KNOW Query Gateway: `get_sender_profile, list_sender_domains` | + +
9 Spalten + +`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 + +
+ +#### `mailbox_grants` + +Zugriffsrechte auf Postfächer (delegierter Consent). + +**Sensibel:** OAuth-Tokens, verschlüsselt (AES-256-GCM). + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | MPA - Delegated Consent: `upsert_mailbox_grant`
MPA - Get Mailbox Access: `upsert_mailbox_grant` | +| liest | MPA - Get Mailbox Access: `get_mailbox_grant`
MPA - Projekt-Ablage: `list_active_mailbox_grants` | + +
13 Spalten + +`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 + +
+ +### Kontakte und Projekte (KanBau) + +#### `contacts` + +Kontakte (Firmen, Personen). + +**Sensibel:** Personenbezogen: E-Mail, Telefon, Steuer-Nr., USt-IdNr. (IBAN nur als Hash). + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | ASP - Absender-Profil Backfill: `upsert_contact`
BCAT - Historische Mail-Analyse (inaktiv): `update_sender_relevance`
KGW - KNOW Query Gateway: `update_sender_relevance, upsert_contact` | +| liest | ASP - Absender-Report: `SELECT (Report)`
CAT - Postfach Pipeline: `find_workflow_hints`
KGW - KNOW Query Gateway: `find_contact_candidates, find_workflow_hints, get_sender_profile`
OR - PDF and E-Rechnung Extraction (inaktiv): `find_workflow_hints` | + +
24 Spalten + +`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 + +
+ +#### `contact_aliases` + +Schreibweisen von Kontakten. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | – | +| liest | KGW - KNOW Query Gateway: `find_contact_candidates` | + +
11 Spalten + +`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 + +
+ +#### `contact_project_relations` + +Kontakt ↔ Projekt. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | – | +| liest | – | + +
12 Spalten + +`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 + +
+ +#### `projects` + +KanBau-Projekte mit Adresse. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | – | +| liest | KGW - KNOW Query Gateway: `find_project_candidates, get_sender_profile`
NEX - Teams Chat Agent: `find_project_candidates` | + +
17 Spalten + +`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 + +
+ +#### `project_aliases` + +Aliase und gelernte Tokens zu Projekten. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | KGW - KNOW Query Gateway: `purge_spf_aliases, upsert_project_alias`
MPA - Projekt-Ablage: `reject_project_alias, upsert_project_alias, upsert_project_alias_tokens` | +| liest | KGW - KNOW Query Gateway: `find_project_candidates, get_sender_profile, list_sender_domains`
NEX - Teams Chat Agent: `find_project_candidates`
PRO - Project Assignment: `find_project_by_alias` | + +
13 Spalten + +`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 + +
+ +#### `project_events` + +Ereignisse aus Mails (Verzögerung, Mangel, Termin, Kosten). + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | MPA - Projekt-Ablage: `upsert_project_event` | +| liest | – | + +
15 Spalten + +`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 + +
+ +#### `project_folders` + +SharePoint-Ordner je Projekt. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | – | +| liest | – | + +
10 Spalten + +`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 + +
+ +#### `project_companies` + +Kürzel → Gesellschaft. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | RAR - Rechnungsanschrift pruefen: `record_invoice_company` | +| liest | RAR - Rechnungsanschrift pruefen: `get_project_company` | + +
8 Spalten + +`kuerzel` TEXT, `gesellschaft` TEXT, `quelle` TEXT, `bestaetigt` BOOLEAN, `baustelle` TEXT, `evidence_json` JSONB, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ + +
+ +#### `invoice_company_observations` + +Gesellschaft aus Rechnungen beobachtet. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | RAR - Rechnungsanschrift pruefen: `record_invoice_company` | +| liest | RAR - Rechnungsanschrift pruefen: `get_project_company` | + +
8 Spalten + +`message_id` TEXT, `file_name` TEXT, `kuerzel` TEXT, `gesellschaft` TEXT, `mailbox` TEXT, `source_workflow` TEXT, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ + +
+ +### Dokumente + +#### `document_extractions` + +Extraktionen aus Anhängen. + +**Sensibel:** Extrahierter Volltext von Anhängen. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | KGW - KNOW Query Gateway: `store_document_extraction` | +| liest | – | + +
17 Spalten + +`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 + +
+ +### Ankauf und Projektentwicklung (KAF) + +#### `property_development_cases` + +Ankaufsfälle (~70 Felder). + +**Sensibel:** Kaufpreise, Provisionen und Besprechungsmitschriften. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | – | +| liest | KGW - KNOW Query Gateway: `find_cases_by_deadline, find_cases_missing_documents, find_cases_needing_next_action, find_cases_with_offer_status, find_property_development_candidates, summarize_property_development_case` | + +
73 Spalten + +`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 + +
+ +#### `property_development_documents` + +Dokumente je Ankaufsfall. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | – | +| liest | KGW - KNOW Query Gateway: `summarize_property_development_case` | + +
20 Spalten + +`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 + +
+ +#### `kaf_review_snapshots` + +Prüfstände von Kaufverträgen. + +**Sensibel:** Kaufvertragsfelder und Prüfer. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | KAF - Bestätigung Webhook: `complete_kaf_review`
KAF - Kaufvertrag Erkennung und Extraktion: `store_kaf_review_snapshot` | +| liest | KAF - Bestätigung Webhook: `get_kaf_review_snapshot` | + +
22 Spalten + +`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 + +
+ +#### `kanzlei_field_patterns` + +Feldmuster je Notarkanzlei. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | KAF - Kaufvertrag Erkennung und Extraktion: `upsert_kanzlei_field_patterns` | +| liest | KAF - Kaufvertrag Erkennung und Extraktion: `get_kanzlei_field_patterns` | + +
4 Spalten + +`kanzlei_key` TEXT, `kanzlei_display` TEXT, `patterns` JSONB, `updated_at` TIMESTAMPTZ + +
+ +#### `kanzlei_project_relations` + +Kanzlei ↔ Projekt. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | KAF - Kaufvertrag Erkennung und Extraktion: `upsert_kanzlei_project_relation` | +| liest | – | + +
6 Spalten + +`relation_key` TEXT, `kanzlei_key` TEXT, `project_key` TEXT, `project_name` TEXT, `notar_name` TEXT, `updated_at` TIMESTAMPTZ + +
+ +#### `place_enrichments` + +Adresse → Flurstück, Koordinaten. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | – | +| liest | – | + +
15 Spalten + +`id` BIGSERIAL, `place_key` TEXT, `input_address` TEXT, `match_address` TEXT, `flurstueck` TEXT, `lat` DOUBLE, `lon` DOUBLE, `bundesland` TEXT, `enrichment_json` JSONB, `provenance_json` JSONB, `quality_status` TEXT, `confidence` DOUBLE, `source_workflow` TEXT, `created_at` TIMESTAMPTZ, `refreshed_at` TIMESTAMPTZ + +
+ +### Buchhaltung + +#### `bel_processed_belege` + +Verarbeitete SEPA-Belege. + +**Sensibel:** Bank, Konto-Referenz und Beträge. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | BEL - SEPA-Beleg-Verteilung: `confirm_beleg_uploaded, reserve_beleg` | +| liest | – | + +
15 Spalten + +`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 + +
+ +### Lernen und Workflow-Gedächtnis + +#### `workflow_decisions` + +Vorschlag vs. tatsächliche Entscheidung. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | – | +| liest | – | + +
13 Spalten + +`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 + +
+ +#### `workflow_hints` + +Gelernte Muster mit Bestätigung. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | ASP - Absender-Profil Backfill: `backfill_workflow_hint`
CAT - Postfach Pipeline: `UPDATE … SET notified_at, pending_notification, upsert_workflow_hint`
KAN - Kanbau API Gateway: `upsert_workflow_hint`
KGW - KNOW Query Gateway: `promote_workflow_hint, reject_workflow_hint, upsert_workflow_hint`
KNOW - Hint Confirmation Webhook: `promote_workflow_hint, reject_workflow_hint`
OR - PDF and E-Rechnung Extraction (inaktiv): `upsert_workflow_hint` | +| liest | CAT - Postfach Pipeline: `find_workflow_hints, get_pending_hint_proposals`
KGW - KNOW Query Gateway: `find_workflow_hints, get_pending_hint_proposals, get_sender_profile, summarize_workflow_lessons`
OR - PDF and E-Rechnung Extraction (inaktiv): `find_workflow_hints` | + +
15 Spalten + +`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 + +
+ +#### `workflow_hint_config` + +Ab wann die KI übersprungen wird. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | – | +| liest | BCAT - Historische Mail-Analyse (inaktiv): `update_sender_relevance`
CAT - Postfach Pipeline: `find_workflow_hints, get_pending_hint_proposals, upsert_workflow_hint`
KAN - Kanbau API Gateway: `upsert_workflow_hint`
KGW - KNOW Query Gateway: `find_workflow_hints, get_hint_config, get_pending_hint_proposals, summarize_workflow_lessons, update_sender_relevance, upsert_workflow_hint`
OR - PDF and E-Rechnung Extraction (inaktiv): `find_workflow_hints, upsert_workflow_hint` | + +
10 Spalten + +`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 + +
+ +#### `kanbau_method_examples` + +Beispiele je KanBau-API-Methode. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | KAN - Kanbau API Gateway: `upsert_kanbau_method_example` | +| liest | KGW - KNOW Query Gateway: `get_kanbau_method_example` | + +
7 Spalten + +`method_key` TEXT, `swagger_ref` TEXT, `example_request` JSONB, `example_response` JSONB, `validated_at` TIMESTAMPTZ, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ + +
+ +### Agent + +#### `agent_capabilities` + +Fähigkeiten eines Agenten. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | – | +| liest | – | + +
14 Spalten + +`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 + +
+ +#### `agent_context_documents` + +Kontextdokumente für Agenten. + +**Sensibel:** Volltext eingelesener Dokumente. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | – | +| liest | – | + +
8 Spalten + +`id` BIGSERIAL, `scope_key` TEXT, `source_path` TEXT, `document_type` TEXT, `title` TEXT, `content` TEXT, `content_hash` TEXT, `last_ingested_at` TIMESTAMPTZ + +
+ +#### `agent_execution_log` + +Protokoll der Agent-Aufrufe. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | – | +| liest | – | + +
10 Spalten + +`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 + +
+ +### Browser-Automation (BSB) + +#### `session_providers` + +Login-Abläufe für Browser-Automation. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | BSB - Discover Session: `upsert_session_provider` | +| liest | BSB - Get Session: `get_session_provider` | + +
10 Spalten + +`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 + +
+ +#### `browser_sessions` + +Gespeicherte Browser-Sessions. + +**Sensibel:** Session-Daten als JSON; am Schema nicht erkennbar, ob verschlüsselt. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | BSB - Execute Login: `upsert_browser_session` | +| liest | BSB - Get Session: `get_browser_session` | + +
5 Spalten + +`provider_id` TEXT, `session_data` JSONB, `expires_at` TIMESTAMPTZ, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ + +
+ +### LLM-Worker + +#### `llm_workers` + +Registrierte lokale LLM-Worker. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | LLM - Job Poll: `claim_llm_job`
LLM - Worker Registry: `update_llm_worker_status, upsert_llm_worker` | +| liest | KGW - KNOW Query Gateway: `find_active_llm_workers`
LLM - Router: `find_active_llm_workers` | + +
12 Spalten + +`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 + +
+ +#### `llm_worker_blocklist` + +Gesperrte Worker. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | LLM - Worker Registry: `blocklist_llm_worker` | +| liest | – | + +
6 Spalten + +`id` BIGSERIAL, `mac` TEXT, `host` TEXT, `reason` TEXT, `hardware_json` JSONB, `blocked_at` TIMESTAMPTZ + +
+ +#### `llm_jobs` + +Job-Board für lokale LLMs. + +**Sensibel:** Prompt und Ergebnis im Klartext. + +| Zugriff | Workflows und Operationen | +|---|---| +| schreibt | LLM - Job Poll: `claim_llm_job`
LLM - Job Submit: `enqueue_llm_job`
LLM - Result Handler: `complete_llm_job`
LLM - Router: `cancel_llm_job, enqueue_llm_job` | +| liest | KGW - KNOW Query Gateway: `find_active_llm_workers`
LLM - Router: `find_active_llm_workers, get_llm_job` | + +
16 Spalten + +`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 + +
+ From d0f1d31918bcfc719d73ae3a50736f042154cc21 Mon Sep 17 00:00:00 2001 From: Robert Rapp Date: Tue, 13 Oct 2026 02:15:48 +0200 Subject: [PATCH 3/6] =?UTF-8?q?docs:=20KNOW-Befunde=20B1=E2=80=93B9=20fest?= =?UTF-8?q?gehalten,=20Ontologie=20als=20OWL/Turtle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KNOW-befunde.md: Aufrufe unbekannter Operationen (CANCEL, KAN-Learn, IN-Sync), direktes SQL in CAT, 12 Tabellen ohne aktiven Schreibweg mit Ursache (keine Operation / kein Aufrufer / Seed), DDL im KAF-Bestätigungspfad, Lösch-Operation über KGW, fehlende Löschfristen; jeweils Beleg, Folge, Vorschlag - know-ontology.ttl: 49 Klassen, 66 Beziehungen, 39 Individuen (rdflib-geprüft) Co-Authored-By: Claude Opus 5.5 --- docs/reference/KNOW-befunde.md | 99 ++ docs/reference/KNOW-schema.md | 8 +- docs/reference/know-ontology.ttl | 1615 ++++++++++++++++++++++++++++++ 3 files changed, 1720 insertions(+), 2 deletions(-) create mode 100644 docs/reference/KNOW-befunde.md create mode 100644 docs/reference/know-ontology.ttl diff --git a/docs/reference/KNOW-befunde.md b/docs/reference/KNOW-befunde.md new file mode 100644 index 0000000..73254d6 --- /dev/null +++ b/docs/reference/KNOW-befunde.md @@ -0,0 +1,99 @@ +# KNOW: Befunde aus der Analyse vom 13.10.2026 + +Grundlage sind die Workflow-Definitionen auf n8n.mindboost.app, nur lesend über die n8n-API abgerufen. Ausgewertet wurde der Code von KNOW, KGW und allen aufrufenden Workflows. Inhalte der Datenbank wurden nicht gelesen. Für `CANCEL - Unsubscribe Link` und `KAN-Learn - Kanbau Pattern Learning` liefert die API keine gespeicherten Ausführungen, ein Nachweis aus der Laufzeit fehlt deshalb. + +- Ü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)) + +Status aller Befunde: **offen**. In n8n wurde nichts geändert. + +| # | Befund | Schwere | +|---|---|---| +| B1 | CANCEL protokolliert über eine Operation, die es nicht gibt | mittel | +| B2 | KAN-Learn speichert über eine Operation, die es nicht gibt | mittel | +| B3 | IN-Sync ruft zwei nicht vorhandene Operationen auf (inaktiv) | niedrig | +| B4 | CAT schreibt per eigenem SQL an KNOW vorbei | niedrig | +| B5 | 12 Tabellen ohne aktiven Schreibweg, mit drei Ursachen | hoch (Datenbasis unklar) | +| B6 | KAF-Bestätigung führt bei jedem Aufruf DDL aus und setzt einen festen Bestätiger | mittel | +| B7 | Anhang-Extraktionen kommen nur von außen | niedrig | +| B8 | KGW erlaubt externen Aufrufern eine Lösch-Operation | niedrig | +| B9 | Keine Löschfristen für Klartext-Felder | mittel (Datenschutz) | + +## 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“. +- **Folge:** Der Workflow läuft durch, aber kein Abmeldevorgang wird protokolliert, und niemand merkt es. +- **Vorschlag:** auf die vorhandene Operation `record_workflow_decision` umstellen. Sie schreibt in `workflow_decisions`, das heute leer bleibt (B5b). Vorher die Felder des Payloads abgleichen. + +## B2 – KAN-Learn speichert nicht +- **Beleg:** `KAN-Learn - Kanbau Pattern Learning` (aktiv) ruft im Knoten `KNOW: Save Patterns` `upsert_kanbau_project_patterns` auf. Die Operation gibt es in KNOW nicht, der Knoten hat keine Fehlerbehandlung. +- **Folge:** Jede Ausführung bricht an dieser Stelle ab. KAN-Learn wird von `KAN - Kanbau API Gateway` aufgerufen. +- **Vorschlag:** In der Ausführungsliste von n8n prüfen, ob und wie oft der Workflow läuft. Dann entweder die Operation in KNOW ergänzen (Zieltabelle festlegen) oder den Aufruf entfernen. + +## B3 – IN-Sync ruft Operationen auf, die fehlen +- **Beleg:** `IN - Sicherheitsregeln Source Sync` (inaktiv) ruft `upsert_input_validation_rule` und `upsert_input_validation_source_snapshot` auf. Beide fehlen in KNOW, ebenso eine passende Tabelle. +- **Vorschlag:** vor einer Aktivierung in KNOW ergänzen. + +## B4 – CAT schreibt direkt in `workflow_hints` +- **Beleg:** Ein Postgres-Knoten in `CAT - Postfach Pipeline` führt `UPDATE knowledge.workflow_hints SET notified_at = now(), pending_notification = false WHERE hint_key = '{{ $json.hint_key }}'` aus. +- **Folge:** + - KNOW ist nicht mehr der einzige Schreibweg. + - Der Wert wird ohne Escaping direkt in das SQL eingesetzt. Ein Hochkomma in `hint_key` bricht die Abfrage. +- **Vorschlag:** eine KNOW-Operation dafür anlegen, z. B. `mark_hint_notified`. + +## B5 – Tabellen ohne aktiven Schreibweg +Kein aktiver Workflow schreibt diese 12 Tabellen über KNOW. Die Ursachen sind verschieden: + +**a) KNOW hat gar keine Schreib-Operation.** Gefüllt werden können diese Tabellen nur mit direktem Zugang zur Datenbank: +| Tabelle | Wer liest | Bemerkung | +|---|---|---| +| `projects` | `NEX - Teams Chat Agent`, extern über KGW (`find_project_candidates`, `get_sender_profile`) | Ist die Tabelle leer, findet die Projektsuche nur Treffer über `project_aliases`. **Zuerst prüfen**: `SELECT count(*), max(last_synced_at) FROM knowledge.projects;` | +| `contact_aliases` | extern über KGW (`find_contact_candidates`) | | +| `contact_project_relations` | niemand | | +| `agent_capabilities`, `agent_context_documents`, `agent_execution_log` | niemand | vermutlich Altlast oder außerhalb von n8n genutzt | + +**b) Die Operation gibt es, aber kein Workflow ruft sie auf:** +| Tabelle | vorhandene Operationen | Wer liest | +|---|---|---| +| `property_development_cases` | `upsert_property_development_case`, `sync_property_development_folder_snapshot`, `store_kaf_confirmation_snapshot`, `claim_/complete_kaf_confirmation_create` | extern über KGW (6 Abfragen für Fristen, Angebote, fehlende Unterlagen) | +| `property_development_documents` | `upsert_property_development_document` | extern über KGW (`summarize_property_development_case`) | +| `project_folders` | `upsert_project_folder` | niemand | +| `place_enrichments` | `upsert_place_enrichment` | niemand (`MAP - Maps/Geo Gateway` nutzt KNOW nicht) | +| `workflow_decisions` | `record_workflow_decision` | niemand (siehe B1) | + +Die Abfragen zu Ankaufsfällen über KGW (Hermes) liefern also nur Daten, die einmal anders hineingekommen sind. Über KGW selbst dürfen diese Tabellen nicht beschrieben werden. + +**c) Gewollt:** `workflow_hint_config` wird von `setup_schema` mit 9 Kategorien befüllt (u. a. `mahnung`, `zahlungsaufforderung`, `newsletter_werbung`) und danach nur noch gelesen. + +**Nächster Schritt:** einmal die Zeilenzahlen aller Tabellen und das jüngste `updated_at` abfragen. Erst dann ist klar, ob die Tabellen leer sind oder von außerhalb von n8n befüllt werden. + +## B6 – KAF-Bestätigung: DDL im Anfragepfad, fester Bestätiger +- **Beleg:** Ruft jemand `upsert_property_development_case` mit `quality_status: confirmed` auf, führt KNOW einen Sonderweg aus (`Build KAF Case Confirmation SQL`). Dieser Sonderweg macht Folgendes: + - Er führt bei **jedem** Aufruf `ALTER TABLE … ADD COLUMN IF NOT EXISTS confirmed_by / confirmed_at` aus. + - Er setzt `confirmed_by` ohne Angabe auf `r.rapp@mygodel.de`. +- **Folge:** + - Die beiden Spalten fehlen in `setup_schema`. + - DDL im Anfragepfad braucht eine exklusive Tabellensperre. + - Bestätigungen ohne Absender werden einer bestimmten Person zugeschrieben. + - Heute ruft kein Workflow diesen Weg auf (B5b). +- **Vorschlag:** die Spalten in `setup_schema` aufnehmen, das `ALTER TABLE` entfernen und `confirmed_by` zur Pflichtangabe machen. + +## B7 – `document_extractions` nur von außen +- **Beleg:** `store_document_extraction` ruft kein n8n-Workflow auf, auch `DOC - Dokumenten-Analyse` nicht. Geschrieben wird nur extern über KGW. +- **Vorschlag:** klären, ob DOC die Extraktionen speichern soll. Sie wären eine gute Quelle für Fälle bzw. Trainingsdaten in knaster. + +## B8 – Lösch-Operation über KGW +- **Beleg:** Die Freigabeliste von KGW enthält `purge_spf_aliases`, ein `DELETE` auf `project_aliases`. +- **Vorschlag:** Prüfen, ob externe Aufrufer (Hermes, Skripte) das wirklich brauchen. Sonst streichen. + +## B9 – Löschfristen +- **Beleg:** Folgende Felder speichern Klartext, eine Bereinigung ist nicht erkennbar: + - `llm_jobs.prompt` und `.result` + - `document_extractions.extracted_text` + - `property_development_cases.meeting_transcripts` + - `agent_context_documents.content` + - `mail_events` (Absender, Betreff) + + `purge_*` gibt es nur für Absender-Beobachtungen und Aliase. Bei `browser_sessions.session_data` ist am Schema nicht erkennbar, ob verschlüsselt wird. +- **Vorschlag:** Aufbewahrungsfristen festlegen, z. B. `llm_jobs` nach Abschluss nach 30 Tagen leeren. `browser_sessions` im Code von BSB prüfen. diff --git a/docs/reference/KNOW-schema.md b/docs/reference/KNOW-schema.md index d125b4f..dd8b2d6 100644 --- a/docs/reference/KNOW-schema.md +++ b/docs/reference/KNOW-schema.md @@ -1,7 +1,11 @@ # KNOW: Tabellen und Zugriffe (n8n, Schema `knowledge`) Stand: 13.10.2026. Abgeleitet aus den Workflow-Definitionen auf n8n.mindboost.app, gelesen über die n8n-API. Inhalte der Datenbank wurden nicht gelesen. -Interaktiver Graph: https://claude.ai/artifact/GP8FfU8M6Wk9hPRmJuk96h (privat, Freigabe über das Share-Menü) +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)) + +Befunde mit Belegen, Folgen und Vorschlägen: [KNOW-befunde.md](KNOW-befunde.md) (B1–B9) ## Aufbau @@ -14,7 +18,7 @@ Interaktiver Graph: https://claude.ai/artifact/GP8FfU8M6Wk9hPRmJuk96h (privat, F - `CAT - Postfach Pipeline` aktualisiert `workflow_hints` (`notified_at`, `pending_notification`). - `ASP - Absender-Report` liest `contacts`. -## Auffälligkeiten +## Auffälligkeiten (Kurzfassung, ausführlich in [KNOW-befunde.md](KNOW-befunde.md)) 1. **`CANCEL - Unsubscribe Link`** (aktiv) ruft in 4 Knoten `log_workflow_decision` auf. KNOW kennt diese Operation nicht und lehnt ab. Weil „Continue on Fail“ gesetzt ist, läuft der Workflow weiter, aber es wird nichts protokolliert. 2. **`KAN-Learn - Kanbau Pattern Learning`** (aktiv) ruft `upsert_kanbau_project_patterns` auf. Auch diese Operation kennt KNOW nicht. Der Knoten hat keine Fehlerbehandlung, die Ausführung bricht dort ab. diff --git a/docs/reference/know-ontology.ttl b/docs/reference/know-ontology.ttl new file mode 100644 index 0000000..2c0bc41 --- /dev/null +++ b/docs/reference/know-ontology.ttl @@ -0,0 +1,1615 @@ +@prefix know: . +@prefix owl: . +@prefix rdf: . +@prefix rdfs: . +@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 . + +know:quelleTabelle a owl:AnnotationProperty . +know:viaSpalte a owl:AnnotationProperty . + +know:Postfach a owl:Class ; + rdfs:label "Postfach"@de ; + rdfs:comment "Ein Microsoft-365-Postfach, das die Workflows lesen. Es gibt keine eigene Tabelle, das Postfach steckt in der Spalte mailbox mehrerer Tabellen."@de . + +know:Mail a owl:Class ; + rdfs:label "Mail"@de ; + rdfs:comment "Eine eingegangene und klassifizierte Mail."@de ; + know:quelleTabelle "mail_events" . + +know:Absender a owl:Class ; + rdfs:label "Absender"@de ; + rdfs:comment "Absenderadresse, beobachtet in einem Postfach über ein Zeitfenster (Profil mit Kategorien, Junk-Anteil, Abmelde-Link)."@de ; + know:quelleTabelle "sender_observations" . + +know:Domain a owl:Class ; + rdfs:label "Domain"@de ; + rdfs:comment "Registrierte Domain eines Absenders; bekannte Plattformen sind eigens markiert."@de ; + know:quelleTabelle "platform_domains" . + +know:Postfachzugriff a owl:Class ; + rdfs:label "Postfachzugriff"@de ; + rdfs:comment "Delegierter Zugriff auf ein Postfach (OAuth-Token, verschlüsselt gespeichert)."@de ; + know:quelleTabelle "mailbox_grants" . + +know:MailKategorie a owl:Class ; + rdfs:label "Mail-Kategorie"@de ; + rdfs:comment "Fachliche Kategorie einer Mail mit Regeln: ab wie vielen Beobachtungen die KI übersprungen wird, Priorität, Modellwahl, Safety-Floor."@de ; + know:quelleTabelle "workflow_hint_config" . + +know:Projekt a owl:Class ; + rdfs:label "Projekt"@de ; + rdfs:comment "Bauprojekt aus KanBau mit Adresse und Kürzel."@de ; + know:quelleTabelle "projects" . + +know:ProjektAlias a owl:Class ; + rdfs:label "Projekt-Alias"@de ; + rdfs:comment "Andere Bezeichnung für ein Projekt: Adresse, Kürzel oder ein aus Mails gelerntes Token."@de ; + know:quelleTabelle "project_aliases" . + +know:Kontakt a owl:Class ; + rdfs:label "Kontakt"@de ; + rdfs:comment "Firma oder Person mit Kontaktdaten, Steuernummern und Relevanz-Score."@de ; + know:quelleTabelle "contacts" . + +know:KontaktAlias a owl:Class ; + rdfs:label "Kontakt-Alias"@de ; + rdfs:comment "Andere Schreibweise eines Kontakts."@de ; + know:quelleTabelle "contact_aliases" . + +know:Projektbeteiligung a owl:Class ; + rdfs:label "Projektbeteiligung"@de ; + rdfs:comment "Rolle eines Kontakts in einem Projekt, mit Gültigkeitszeitraum (reifizierte Beziehung)."@de ; + know:quelleTabelle "contact_project_relations" . + +know:Projektereignis a owl:Class ; + rdfs:label "Projektereignis"@de ; + rdfs:comment "Aus einer Mail erkanntes Ereignis zu einem Projekt (Verzögerung, Mangel, Termin, Kosten, Info) mit Dringlichkeit."@de ; + know:quelleTabelle "project_events" . + +know:Ereignisart a owl:Class ; + rdfs:label "Ereignisart"@de ; + rdfs:comment "Art eines Projektereignisses. Als CHECK-Constraint der Spalte project_events.category festgelegt."@de . + +know:Projektordner a owl:Class ; + rdfs:label "Projektordner"@de ; + rdfs:comment "SharePoint-Ordner eines Projekts."@de ; + know:quelleTabelle "project_folders" . + +know:Projektkuerzel a owl:Class ; + rdfs:label "Projektkürzel"@de ; + rdfs:comment "Kürzel einer Baustelle mit der zuständigen Gesellschaft."@de ; + know:quelleTabelle "project_companies" . + +know:Gesellschaft a owl:Class ; + rdfs:label "Gesellschaft"@de ; + rdfs:comment "Rechtsträger, dem Projekte und Rechnungen zugeordnet werden. Als CHECK-Constraint festgelegt."@de . + +know:Rechnungsbeobachtung a owl:Class ; + rdfs:label "Rechnungsbeobachtung"@de ; + rdfs:comment "In einer Rechnung (Mail-Anhang) beobachtete Zuordnung Kürzel → Gesellschaft."@de ; + know:quelleTabelle "invoice_company_observations" . + +know:Dokument a owl:Class ; + rdfs:label "Dokument"@de ; + rdfs:comment "Oberbegriff für alle gespeicherten Dokumente und Extraktionen (abstrakt)."@de . + +know:AnhangExtraktion a owl:Class ; + rdfs:label "Anhang-Extraktion"@de ; + rdfs:comment "Text und strukturierte Felder, die aus einem Mail-Anhang gelesen wurden."@de ; + know:quelleTabelle "document_extractions" ; + rdfs:subClassOf know:Dokument . + +know:Ankaufsfall a owl:Class ; + rdfs:label "Ankaufsfall"@de ; + rdfs:comment "Grundstück oder Objekt in der Ankaufsprüfung: Pipeline-Status, Ergebnis, Fristen, Angebote, Unterlagen, Risiko."@de ; + know:quelleTabelle "property_development_cases" . + +know:Falldokument a owl:Class ; + rdfs:label "Falldokument"@de ; + rdfs:comment "Unterlage zu einem Ankaufsfall aus SharePoint, mit Dokumenttyp und Versionierung."@de ; + know:quelleTabelle "property_development_documents" ; + rdfs:subClassOf know:Dokument . + +know:Dokumenttyp a owl:Class ; + rdfs:label "Dokumenttyp"@de ; + rdfs:comment "Typ eines Falldokuments, im KNOW-Code als feste Liste hinterlegt."@de . + +know:Kaufvertragspruefung a owl:Class ; + rdfs:label "Kaufvertragsprüfung"@de ; + rdfs:comment "Prüfstand eines Kaufvertrags: extrahierte Felder, Einheiten, Freigabe durch eine Mitarbeiterin oder einen Mitarbeiter."@de ; + know:quelleTabelle "kaf_review_snapshots" . + +know:Notarkanzlei a owl:Class ; + rdfs:label "Notarkanzlei"@de ; + rdfs:comment "Notarkanzlei mit gelernten Feldmustern für das Auslesen von Kaufverträgen."@de ; + know:quelleTabelle "kanzlei_field_patterns" . + +know:Kanzleimandat a owl:Class ; + rdfs:label "Kanzleimandat"@de ; + rdfs:comment "Zuordnung Kanzlei ↔ Projekt mit Notarname (reifizierte Beziehung)."@de ; + know:quelleTabelle "kanzlei_project_relations" . + +know:Ort a owl:Class ; + rdfs:label "Ort / Flurstück"@de ; + rdfs:comment "Geokodierte Adresse mit Flurstück und Bundesland."@de ; + know:quelleTabelle "place_enrichments" . + +know:KanbauKaufvertrag a owl:Class ; + rdfs:label "KanBau-Kaufvertrag"@de ; + rdfs:comment "Kaufvertrag, der nach Bestätigung in KanBau angelegt wird (created_saleagr_id)."@de . + +know:Beleg a owl:Class ; + rdfs:label "Beleg"@de ; + rdfs:comment "Verarbeiteter SEPA-Beleg mit Betrag, reserviert und dann hochgeladen."@de ; + know:quelleTabelle "bel_processed_belege" . + +know:Bankkonto a owl:Class ; + rdfs:label "Bankkonto"@de ; + rdfs:comment "Konto, zu dem ein Beleg gehört (bank + account_ref)."@de . + +know:Workflow a owl:Class ; + rdfs:label "n8n-Workflow"@de ; + rdfs:comment "Ein n8n-Workflow, der entscheidet, lernt oder aufgerufen wird. Steht nur als Name in Spalten."@de . + +know:Entscheidung a owl:Class ; + rdfs:label "Entscheidung"@de ; + rdfs:comment "Vorschlag eines Workflows und die tatsächliche Entscheidung mit Korrekturgrund."@de ; + know:quelleTabelle "workflow_decisions" . + +know:Referenzobjekt a owl:Class ; + rdfs:label "Referenzobjekt"@de ; + rdfs:comment "Objekt, auf das sich eine Entscheidung bezieht. Polymorph über reference_type und reference_id, die Typen legt das Schema nicht fest."@de . + +know:Hinweis a owl:Class ; + rdfs:label "Hinweis (gelerntes Muster)"@de ; + rdfs:comment "Gelerntes Muster (Schlüssel und Wert → vorgeschlagene Aktion) mit Zähler, Konfidenz und Bestätigung."@de ; + know:quelleTabelle "workflow_hints" . + +know:KanbauApiMethode a owl:Class ; + rdfs:label "KanBau-API-Methode"@de ; + rdfs:comment "Validiertes Request/Response-Beispiel einer KanBau-API-Methode."@de ; + know:quelleTabelle "kanbau_method_examples" . + +know:Mitarbeiter a owl:Class ; + rdfs:label "Mitarbeiter:in"@de ; + rdfs:comment "Person im Unternehmen (@mygodel.de), die prüft, bestätigt oder bearbeitet. Steht nur als Name oder Adresse in Spalten."@de . + +know:AgentBereich a owl:Class ; + rdfs:label "Agent-Bereich"@de ; + rdfs:comment "Einsatzbereich eines Agenten (scope_key)."@de . + +know:Faehigkeit a owl:Class ; + rdfs:label "Fähigkeit"@de ; + rdfs:comment "Was ein Agent tun darf: Beispiel-Intents, Workflow, Risiko, Freigabepflicht."@de ; + know:quelleTabelle "agent_capabilities" . + +know:Kontextdokument a owl:Class ; + rdfs:label "Kontextdokument"@de ; + rdfs:comment "Eingelesenes Dokument als Wissen für einen Agenten."@de ; + know:quelleTabelle "agent_context_documents" ; + rdfs:subClassOf know:Dokument . + +know:AgentAusfuehrung a owl:Class ; + rdfs:label "Agent-Ausführung"@de ; + rdfs:comment "Protokoll einer Agent-Anfrage mit gewählter Fähigkeit und Ergebnis."@de ; + know:quelleTabelle "agent_execution_log" . + +know:LoginAnbieter a owl:Class ; + rdfs:label "Login-Anbieter"@de ; + rdfs:comment "Webanwendung mit hinterlegtem Login-Ablauf für die Browser-Automation."@de ; + know:quelleTabelle "session_providers" . + +know:BrowserSession a owl:Class ; + rdfs:label "Browser-Session"@de ; + rdfs:comment "Gespeicherte Sitzung bei einem Login-Anbieter mit Ablaufzeit."@de ; + know:quelleTabelle "browser_sessions" . + +know:LlmWorker a owl:Class ; + rdfs:label "LLM-Worker"@de ; + rdfs:comment "Lokaler Rechner mit LLM, der sich per Heartbeat meldet (optional mit OpenAI-Endpoint)."@de ; + know:quelleTabelle "llm_workers" . + +know:WorkerSperre a owl:Class ; + rdfs:label "Worker-Sperre"@de ; + rdfs:comment "Gesperrter Worker (unsupported)."@de ; + know:quelleTabelle "llm_worker_blocklist" . + +know:LlmJob a owl:Class ; + rdfs:label "LLM-Job"@de ; + rdfs:comment "Auftrag im Job-Board: Prompt, Mindest-Tier, Ergebnis, Dauer."@de ; + know:quelleTabelle "llm_jobs" . + +know:Tier a owl:Class ; + rdfs:label "Tier"@de ; + rdfs:comment "Leistungsstufe eines Workers bzw. Mindeststufe eines Jobs."@de . + +know:Modell a owl:Class ; + rdfs:label "Modell"@de ; + rdfs:comment "LLM-Modell, das ein Worker betreibt. Erlaubte Werte stehen in der Worker Registry."@de . + +know:KanBau a owl:Class ; + rdfs:label "KanBau"@de ; + rdfs:comment "Externes Bau- und Projektverwaltungssystem (Projekte, Adressen, Kaufverträge)."@de . + +know:SharePoint a owl:Class ; + rdfs:label "SharePoint"@de ; + rdfs:comment "Ablage für Projekt-, Fall- und Belegdokumente."@de . + +know:Microsoft365 a owl:Class ; + rdfs:label "Microsoft 365"@de ; + rdfs:comment "Postfächer und delegierter Zugriff (provider microsoft)."@de . + +know:empfangenIn a owl:ObjectProperty ; + rdfs:domain know:Mail ; + rdfs:range know:Postfach ; + know:viaSpalte "Mail→Postfach: mail_events.mailbox"@de . + +know:gesendetVon a owl:ObjectProperty ; + rdfs:domain know:Mail ; + rdfs:range know:Absender ; + know:viaSpalte "Mail→Absender: mail_events.sender = sender_observations.email"@de . + +know:hatKategorie a owl:ObjectProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Mail know:Hinweis ) ] ; + rdfs:range know:MailKategorie ; + know:viaSpalte "Mail→MailKategorie: mail_events.category = workflow_hint_config.config_key"@de ; + know:viaSpalte "Hinweis→MailKategorie: workflow_hints.hint_type = config_key (JOIN)"@de . + +know:zugeordnetZu a owl:ObjectProperty ; + rdfs:domain know:Mail ; + rdfs:range know:Projekt ; + know:viaSpalte "Mail→Projekt: mail_events.assigned_project_key"@de . + +know:beobachtetIn a owl:ObjectProperty ; + rdfs:domain know:Absender ; + rdfs:range know:Postfach ; + know:viaSpalte "Absender→Postfach: sender_observations.mailbox"@de . + +know:gehoertZuDomain a owl:ObjectProperty ; + rdfs:domain know:Absender ; + rdfs:range know:Domain ; + know:viaSpalte "Absender→Domain: sender_observations.registered_domain (JOIN)"@de . + +know:entsprichtKontakt a owl:ObjectProperty ; + rdfs:domain know:Absender ; + rdfs:range know:Kontakt ; + know:viaSpalte "Absender→Kontakt: E-Mail-Adresse (contacts.email)"@de . + +know:gewaehrtFuer a owl:ObjectProperty ; + rdfs:domain know:Postfachzugriff ; + rdfs:range know:Postfach ; + know:viaSpalte "Postfachzugriff→Postfach: mailbox_grants.grant_key"@de . + +know:liegtIn a owl:ObjectProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Postfach know:Projektordner know:Falldokument know:KanbauKaufvertrag know:Ankaufsfall ) ] ; + rdfs:range [ a owl:Class ; owl:unionOf ( know:Microsoft365 know:SharePoint know:KanBau ) ] ; + know:viaSpalte "Postfach→Microsoft365: mailbox_grants.provider = 'microsoft'"@de ; + know:viaSpalte "Projektordner→SharePoint: project_folders.drive_id, folder_item_id"@de ; + know:viaSpalte "Falldokument→SharePoint: sharepoint_item_id, file_path"@de ; + know:viaSpalte "KanbauKaufvertrag→KanBau: created_saleagr_url"@de ; + know:viaSpalte "Ankaufsfall→SharePoint: folder_path, sharepoint_json"@de . + +know:ausMail a owl:ObjectProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:AnhangExtraktion know:Projektereignis know:Rechnungsbeobachtung ) ] ; + rdfs:range know:Mail ; + know:viaSpalte "AnhangExtraktion→Mail: document_extractions.message_id"@de ; + know:viaSpalte "Projektereignis→Mail: project_events.message_id"@de ; + know:viaSpalte "Rechnungsbeobachtung→Mail: invoice_company_observations.message_id"@de . + +know:vonKontakt a owl:ObjectProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:AnhangExtraktion know:Projektbeteiligung ) ] ; + rdfs:range know:Kontakt ; + know:viaSpalte "AnhangExtraktion→Kontakt: document_extractions.sender_contact_key"@de ; + know:viaSpalte "Projektbeteiligung→Kontakt: contact_project_relations.contact_key"@de . + +know:betrifftProjekt a owl:ObjectProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:AnhangExtraktion know:Projektbeteiligung know:Projektereignis know:Projektordner know:Kanzleimandat ) ] ; + rdfs:range know:Projekt ; + know:viaSpalte "AnhangExtraktion→Projekt: document_extractions.project_key"@de ; + know:viaSpalte "Projektbeteiligung→Projekt: contact_project_relations.project_key"@de ; + know:viaSpalte "Projektereignis→Projekt: project_events.project_key"@de ; + know:viaSpalte "Projektordner→Projekt: project_folders.project_key"@de ; + know:viaSpalte "Kanzleimandat→Projekt: kanzlei_project_relations.project_key"@de . + +know:bezeichnet a owl:ObjectProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:ProjektAlias know:KontaktAlias ) ] ; + rdfs:range [ a owl:Class ; owl:unionOf ( know:Projekt know:Kontakt ) ] ; + know:viaSpalte "ProjektAlias→Projekt: project_aliases.project_key (JOIN)"@de ; + know:viaSpalte "KontaktAlias→Kontakt: contact_aliases.contact_key"@de . + +know:stammtAus a owl:ObjectProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Projekt know:Kontakt ) ] ; + rdfs:range know:KanBau ; + know:viaSpalte "Projekt→KanBau: projects.kanbau_project_id"@de ; + know:viaSpalte "Kontakt→KanBau: contacts.kanbau_address_id"@de . + +know:hatArt a owl:ObjectProperty ; + rdfs:domain know:Projektereignis ; + rdfs:range know:Ereignisart ; + know:viaSpalte "Projektereignis→Ereignisart: project_events.category"@de . + +know:abgelegtIn a owl:ObjectProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Projektereignis know:Beleg ) ] ; + rdfs:range know:SharePoint ; + know:viaSpalte "Projektereignis→SharePoint: project_events.sharepoint_path"@de ; + know:viaSpalte "Beleg→SharePoint: bel_processed_belege.sharepoint_path"@de . + +know:gehoertZu a owl:ObjectProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Projektkuerzel know:Rechnungsbeobachtung know:Faehigkeit know:Kontextdokument ) ] ; + rdfs:range [ a owl:Class ; owl:unionOf ( know:Gesellschaft know:AgentBereich ) ] ; + know:viaSpalte "Projektkuerzel→Gesellschaft: project_companies.gesellschaft"@de ; + know:viaSpalte "Rechnungsbeobachtung→Gesellschaft: invoice_company_observations.gesellschaft"@de ; + know:viaSpalte "Faehigkeit→AgentBereich: agent_capabilities.scope_key"@de ; + know:viaSpalte "Kontextdokument→AgentBereich: agent_context_documents.scope_key"@de . + +know:nenntKuerzel a owl:ObjectProperty ; + rdfs:domain know:Rechnungsbeobachtung ; + rdfs:range know:Projektkuerzel ; + know:viaSpalte "Rechnungsbeobachtung→Projektkuerzel: invoice_company_observations.kuerzel (JOIN)"@de . + +know:hatDokument a owl:ObjectProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range know:Falldokument ; + know:viaSpalte "Ankaufsfall→Falldokument: property_development_documents.case_key (JOIN)"@de . + +know:ersetzt a owl:ObjectProperty ; + rdfs:domain know:Falldokument ; + rdfs:range know:Falldokument ; + know:viaSpalte "Falldokument→Falldokument: supersedes_document_key"@de . + +know:hatTyp a owl:ObjectProperty ; + rdfs:domain know:Falldokument ; + rdfs:range know:Dokumenttyp ; + know:viaSpalte "Falldokument→Dokumenttyp: property_development_documents.document_type"@de . + +know:hatPruefung a owl:ObjectProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range know:Kaufvertragspruefung ; + know:viaSpalte "Ankaufsfall→Kaufvertragspruefung: kaf_review_snapshots.case_key"@de . + +know:wirdZuProjekt a owl:ObjectProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range know:Projekt ; + know:viaSpalte "Ankaufsfall→Projekt: selected_project_id (KanBau-Projekt-ID)"@de . + +know:erzeugt a owl:ObjectProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range know:KanbauKaufvertrag ; + know:viaSpalte "Ankaufsfall→KanbauKaufvertrag: created_saleagr_id, created_saleagr_url"@de . + +know:liegtAn a owl:ObjectProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Ankaufsfall know:Projekt ) ] ; + rdfs:range know:Ort ; + know:viaSpalte "Ankaufsfall→Ort: street_address, location_city ↔ place_enrichments.input_address"@de ; + know:viaSpalte "Projekt→Ort: full_address ↔ place_enrichments.input_address"@de . + +know:bearbeitetVon a owl:ObjectProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Ankaufsfall know:LlmJob ) ] ; + rdfs:range [ a owl:Class ; owl:unionOf ( know:Mitarbeiter know:LlmWorker ) ] ; + know:viaSpalte "Ankaufsfall→Mitarbeiter: assigned_to, project_lead"@de ; + know:viaSpalte "LlmJob→LlmWorker: llm_jobs.assigned_to = llm_workers.host"@de . + +know:vonKanzlei a owl:ObjectProperty ; + rdfs:domain know:Kanzleimandat ; + rdfs:range know:Notarkanzlei ; + know:viaSpalte "Kanzleimandat→Notarkanzlei: kanzlei_project_relations.kanzlei_key"@de . + +know:geprueftVon a owl:ObjectProperty ; + rdfs:domain know:Kaufvertragspruefung ; + rdfs:range know:Mitarbeiter ; + know:viaSpalte "Kaufvertragspruefung→Mitarbeiter: reviewed_by, review_to (@mygodel.de)"@de . + +know:gehoertZuKonto a owl:ObjectProperty ; + rdfs:domain know:Beleg ; + rdfs:range know:Bankkonto ; + know:viaSpalte "Beleg→Bankkonto: bel_processed_belege.bank, account_ref"@de . + +know:getroffenVon a owl:ObjectProperty ; + rdfs:domain know:Entscheidung ; + rdfs:range know:Workflow ; + know:viaSpalte "Entscheidung→Workflow: workflow_decisions.workflow"@de . + +know:betrifft a owl:ObjectProperty ; + rdfs:domain know:Entscheidung ; + rdfs:range know:Referenzobjekt ; + know:viaSpalte "Entscheidung→Referenzobjekt: reference_type + reference_id"@de . + +know:gelerntIn a owl:ObjectProperty ; + rdfs:domain know:Hinweis ; + rdfs:range know:Workflow ; + know:viaSpalte "Hinweis→Workflow: workflow_hints.workflow"@de . + +know:betrifftKontakt a owl:ObjectProperty ; + rdfs:domain know:Hinweis ; + rdfs:range know:Kontakt ; + know:viaSpalte "Hinweis→Kontakt: workflow_hints.pattern_key = contact_key (JOIN)"@de . + +know:bestaetigtVon a owl:ObjectProperty ; + rdfs:domain know:Hinweis ; + rdfs:range know:Mitarbeiter ; + know:viaSpalte "Hinweis→Mitarbeiter: workflow_hints.confirmed_by"@de . + +know:beschreibt a owl:ObjectProperty ; + rdfs:domain know:KanbauApiMethode ; + rdfs:range know:KanBau ; + know:viaSpalte "KanbauApiMethode→KanBau: method_key, swagger_ref"@de . + +know:ruftAuf a owl:ObjectProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Faehigkeit know:AgentAusfuehrung ) ] ; + rdfs:range know:Workflow ; + know:viaSpalte "Faehigkeit→Workflow: agent_capabilities.workflow_name"@de ; + know:viaSpalte "AgentAusfuehrung→Workflow: agent_execution_log.workflow_called"@de . + +know:imBereich a owl:ObjectProperty ; + rdfs:domain know:AgentAusfuehrung ; + rdfs:range know:AgentBereich ; + know:viaSpalte "AgentAusfuehrung→AgentBereich: agent_execution_log.agent_scope"@de . + +know:waehlt a owl:ObjectProperty ; + rdfs:domain know:AgentAusfuehrung ; + rdfs:range know:Faehigkeit ; + know:viaSpalte "AgentAusfuehrung→Faehigkeit: agent_execution_log.selected_capability"@de . + +know:fuerAnbieter a owl:ObjectProperty ; + rdfs:domain know:BrowserSession ; + rdfs:range know:LoginAnbieter ; + know:viaSpalte "BrowserSession→LoginAnbieter: browser_sessions.provider_id"@de . + +know:meldetAnBei a owl:ObjectProperty ; + rdfs:domain know:LoginAnbieter ; + rdfs:range know:KanBau ; + know:viaSpalte "LoginAnbieter→KanBau: über BSB - Get Session für das KanBau-Gateway"@de . + +know:verlangtTier a owl:ObjectProperty ; + rdfs:domain know:LlmJob ; + rdfs:range know:Tier ; + know:viaSpalte "LlmJob→Tier: llm_jobs.min_tier"@de . + +know:nutzteModell a owl:ObjectProperty ; + rdfs:domain know:LlmJob ; + rdfs:range know:Modell ; + know:viaSpalte "LlmJob→Modell: llm_jobs.model"@de . + +know:hatTier a owl:ObjectProperty ; + rdfs:domain know:LlmWorker ; + rdfs:range know:Tier ; + know:viaSpalte "LlmWorker→Tier: llm_workers.tier"@de . + +know:betreibt a owl:ObjectProperty ; + rdfs:domain know:LlmWorker ; + rdfs:range know:Modell ; + know:viaSpalte "LlmWorker→Modell: llm_workers.model"@de . + +know:sperrt a owl:ObjectProperty ; + rdfs:domain know:WorkerSperre ; + rdfs:range know:LlmWorker ; + know:viaSpalte "WorkerSperre→LlmWorker: llm_worker_blocklist.mac"@de . + +know:absender_typ a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:string . + +know:address_json a owl:DatatypeProperty ; + rdfs:domain know:Kontakt ; + rdfs:range rdf:JSON . + +know:affected_unit a owl:DatatypeProperty ; + rdfs:domain know:Projektereignis ; + rdfs:range xsd:string . + +know:alias_key a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:ProjektAlias know:KontaktAlias ) ] ; + rdfs:range xsd:string . + +know:alias_type a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:ProjektAlias know:KontaktAlias ) ] ; + rdfs:range xsd:string . + +know:alias_value a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:ProjektAlias know:KontaktAlias ) ] ; + rdfs:range xsd:string . + +know:amount_cents a owl:DatatypeProperty ; + rdfs:domain know:Beleg ; + rdfs:range xsd:integer . + +know:apartment_count a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:integer . + +know:artifacts_json a owl:DatatypeProperty ; + rdfs:domain know:Kaufvertragspruefung ; + rdfs:range rdf:JSON . + +know:asset_type a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:assigned_at a owl:DatatypeProperty ; + rdfs:domain know:LlmJob ; + rdfs:range xsd:dateTime . + +know:attachment_id a owl:DatatypeProperty ; + rdfs:domain know:AnhangExtraktion ; + rdfs:range xsd:string . + +know:auth_type a owl:DatatypeProperty ; + rdfs:domain know:LoginAnbieter ; + rdfs:range xsd:string . + +know:author_or_sender a owl:DatatypeProperty ; + rdfs:domain know:Falldokument ; + rdfs:range xsd:string . + +know:average_price_per_sqm_eur a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:decimal . + +know:avg_confidence a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:decimal . + +know:baustelle a owl:DatatypeProperty ; + rdfs:domain know:Projektkuerzel ; + rdfs:range xsd:string . + +know:beleg_hash a owl:DatatypeProperty ; + rdfs:domain know:Beleg ; + rdfs:range xsd:string . + +know:bestaetigt a owl:DatatypeProperty ; + rdfs:domain know:Projektkuerzel ; + rdfs:range xsd:boolean . + +know:blocked_at a owl:DatatypeProperty ; + rdfs:domain know:WorkerSperre ; + rdfs:range xsd:dateTime . + +know:bplan_document_present a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:boolean . + +know:building_permit_present a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:boolean . + +know:bundesland a owl:DatatypeProperty ; + rdfs:domain know:Ort ; + rdfs:range xsd:string . + +know:business_relevance_score a owl:DatatypeProperty ; + rdfs:domain know:Kontakt ; + rdfs:range xsd:decimal . + +know:capability_key a owl:DatatypeProperty ; + rdfs:domain know:Faehigkeit ; + rdfs:range xsd:string . + +know:case_code a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:case_key a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Falldokument know:Kaufvertragspruefung ) ] ; + rdfs:range xsd:string . + +know:case_opened_at a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:date . + +know:case_readiness a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:category_histogram a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range rdf:JSON . + +know:city a owl:DatatypeProperty ; + rdfs:domain know:Projekt ; + rdfs:range xsd:string . + +know:commission_including_vat a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:company_context a owl:DatatypeProperty ; + rdfs:domain know:Projekt ; + rdfs:range xsd:string . + +know:completed_at a owl:DatatypeProperty ; + rdfs:domain know:LlmJob ; + rdfs:range xsd:dateTime . + +know:confidence a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:ProjektAlias know:KontaktAlias know:Projektbeteiligung know:AnhangExtraktion know:Ankaufsfall know:Falldokument know:Ort know:Entscheidung know:Hinweis ) ] ; + rdfs:range xsd:decimal . + +know:config_key a owl:DatatypeProperty ; + rdfs:domain know:MailKategorie ; + rdfs:range xsd:string . + +know:confirmation_expires_at a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Ankaufsfall know:Kaufvertragspruefung ) ] ; + rdfs:range xsd:dateTime . + +know:confirmation_request_id a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:confirmation_status a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:confirmation_token_hash a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Ankaufsfall know:Kaufvertragspruefung ) ] ; + rdfs:range xsd:string . + +know:confirmed_at a owl:DatatypeProperty ; + rdfs:domain know:Hinweis ; + rdfs:range xsd:dateTime . + +know:contact_broker a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:contact_key a owl:DatatypeProperty ; + rdfs:domain know:Kontakt ; + rdfs:range xsd:string . + +know:contact_type a owl:DatatypeProperty ; + rdfs:domain know:Kontakt ; + rdfs:range xsd:string . + +know:content a owl:DatatypeProperty ; + rdfs:domain know:Kontextdokument ; + rdfs:range xsd:string . + +know:content_hash a owl:DatatypeProperty ; + rdfs:domain know:Kontextdokument ; + rdfs:range xsd:string . + +know:content_type a owl:DatatypeProperty ; + rdfs:domain know:AnhangExtraktion ; + rdfs:range xsd:string . + +know:correction_reason a owl:DatatypeProperty ; + rdfs:domain know:Entscheidung ; + rdfs:range xsd:string . + +know:credential_key a owl:DatatypeProperty ; + rdfs:domain know:LoginAnbieter ; + rdfs:range xsd:string . + +know:deadline_is_overdue a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:boolean . + +know:decision_type a owl:DatatypeProperty ; + rdfs:domain know:Entscheidung ; + rdfs:range xsd:string . + +know:delivery_mode a owl:DatatypeProperty ; + rdfs:domain know:Kaufvertragspruefung ; + rdfs:range xsd:string . + +know:description a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:MailKategorie know:Faehigkeit ) ] ; + rdfs:range xsd:string . + +know:detected_addresses_json a owl:DatatypeProperty ; + rdfs:domain know:AnhangExtraktion ; + rdfs:range rdf:JSON . + +know:detected_contacts_json a owl:DatatypeProperty ; + rdfs:domain know:AnhangExtraktion ; + rdfs:range rdf:JSON . + +know:detected_documents a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range rdf:JSON . + +know:development_plan_available a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:boolean . + +know:display_name a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Absender know:Domain know:Kontakt know:LoginAnbieter ) ] ; + rdfs:range xsd:string . + +know:distribution_json a owl:DatatypeProperty ; + rdfs:domain know:Kaufvertragspruefung ; + rdfs:range rdf:JSON . + +know:document_count a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:integer . + +know:document_date a owl:DatatypeProperty ; + rdfs:domain know:Falldokument ; + rdfs:range xsd:date . + +know:document_key a owl:DatatypeProperty ; + rdfs:domain know:Falldokument ; + rdfs:range xsd:string . + +know:document_type a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:AnhangExtraktion know:Kontextdokument ) ] ; + rdfs:range xsd:string . + +know:dominant_category a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:string . + +know:dominant_share a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:decimal . + +know:duration_ms a owl:DatatypeProperty ; + rdfs:domain know:LlmJob ; + rdfs:range xsd:integer . + +know:email a owl:DatatypeProperty ; + rdfs:domain know:Kontakt ; + rdfs:range xsd:string . + +know:enabled a owl:DatatypeProperty ; + rdfs:domain know:Faehigkeit ; + rdfs:range xsd:boolean . + +know:endpoint a owl:DatatypeProperty ; + rdfs:domain know:LlmWorker ; + rdfs:range rdf:JSON . + +know:enrichment_json a owl:DatatypeProperty ; + rdfs:domain know:Ort ; + rdfs:range rdf:JSON . + +know:error_message a owl:DatatypeProperty ; + rdfs:domain know:LlmJob ; + rdfs:range xsd:string . + +know:errors_json a owl:DatatypeProperty ; + rdfs:domain know:AgentAusfuehrung ; + rdfs:range rdf:JSON . + +know:event_date a owl:DatatypeProperty ; + rdfs:domain know:Projektereignis ; + rdfs:range xsd:dateTime . + +know:evidence_json a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Domain know:ProjektAlias know:KontaktAlias know:Projektbeteiligung know:Projektkuerzel ) ] ; + rdfs:range rdf:JSON . + +know:example_request a owl:DatatypeProperty ; + rdfs:domain know:KanbauApiMethode ; + rdfs:range rdf:JSON . + +know:example_response a owl:DatatypeProperty ; + rdfs:domain know:KanbauApiMethode ; + rdfs:range rdf:JSON . + +know:expires_at a owl:DatatypeProperty ; + rdfs:domain know:BrowserSession ; + rdfs:range xsd:dateTime . + +know:expires_hint a owl:DatatypeProperty ; + rdfs:domain know:Postfachzugriff ; + rdfs:range xsd:dateTime . + +know:export_ref a owl:DatatypeProperty ; + rdfs:domain know:Beleg ; + rdfs:range xsd:string . + +know:extracted_fields_json a owl:DatatypeProperty ; + rdfs:domain know:Falldokument ; + rdfs:range rdf:JSON . + +know:extracted_summary a owl:DatatypeProperty ; + rdfs:domain know:Falldokument ; + rdfs:range xsd:string . + +know:extracted_text a owl:DatatypeProperty ; + rdfs:domain know:AnhangExtraktion ; + rdfs:range xsd:string . + +know:extraction_source a owl:DatatypeProperty ; + rdfs:domain know:AnhangExtraktion ; + rdfs:range xsd:string . + +know:fields_json a owl:DatatypeProperty ; + rdfs:domain know:Kaufvertragspruefung ; + rdfs:range rdf:JSON . + +know:file_name a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Rechnungsbeobachtung know:AnhangExtraktion know:Falldokument ) ] ; + rdfs:range xsd:string . + +know:final_decision a owl:DatatypeProperty ; + rdfs:domain know:Entscheidung ; + rdfs:range rdf:JSON . + +know:first_mail_at a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:dateTime . + +know:first_seen_at a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Domain know:Kontakt know:Ankaufsfall ) ] ; + rdfs:range xsd:dateTime . + +know:flurstueck a owl:DatatypeProperty ; + rdfs:domain know:Ort ; + rdfs:range xsd:string . + +know:folder_name a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Projektordner know:Ankaufsfall ) ] ; + rdfs:range xsd:string . + +know:folder_path a owl:DatatypeProperty ; + rdfs:domain know:Projektordner ; + rdfs:range xsd:string . + +know:folder_segment a owl:DatatypeProperty ; + rdfs:domain know:Falldokument ; + rdfs:range xsd:string . + +know:folder_status_source a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:godel_internal a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:boolean . + +know:godel_offer a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:hardware_json a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:LlmWorker know:WorkerSperre ) ] ; + rdfs:range rdf:JSON . + +know:hint_key a owl:DatatypeProperty ; + rdfs:domain know:Hinweis ; + rdfs:range xsd:string . + +know:host a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:LlmWorker know:WorkerSperre ) ] ; + rdfs:range xsd:string . + +know:iban_hash a owl:DatatypeProperty ; + rdfs:domain know:Kontakt ; + rdfs:range xsd:string . + +know:input_address a owl:DatatypeProperty ; + rdfs:domain know:Ort ; + rdfs:range xsd:string . + +know:inputs_json a owl:DatatypeProperty ; + rdfs:domain know:AgentAusfuehrung ; + rdfs:range rdf:JSON . + +know:intent_examples a owl:DatatypeProperty ; + rdfs:domain know:Faehigkeit ; + rdfs:range rdf:JSON . + +know:is_current_version a owl:DatatypeProperty ; + rdfs:domain know:Falldokument ; + rdfs:range xsd:boolean . + +know:job_id a owl:DatatypeProperty ; + rdfs:domain know:LlmJob ; + rdfs:range xsd:string . + +know:junk_count a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:integer . + +know:just_one_week_more a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:boolean . + +know:kanbau_project_id a owl:DatatypeProperty ; + rdfs:domain know:ProjektAlias ; + rdfs:range xsd:string . + +know:kanzlei_display a owl:DatatypeProperty ; + rdfs:domain know:Notarkanzlei ; + rdfs:range xsd:string . + +know:kanzlei_key a owl:DatatypeProperty ; + rdfs:domain know:Notarkanzlei ; + rdfs:range xsd:string . + +know:kuerzel a owl:DatatypeProperty ; + rdfs:domain know:Projektkuerzel ; + rdfs:range xsd:string . + +know:last_document_at a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:date . + +know:last_ingested_at a owl:DatatypeProperty ; + rdfs:domain know:Kontextdokument ; + rdfs:range xsd:dateTime . + +know:last_mail_at a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:dateTime . + +know:last_offer_at a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:date . + +know:last_processed_at a owl:DatatypeProperty ; + rdfs:domain know:Mail ; + rdfs:range xsd:dateTime . + +know:last_seen_at a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Domain know:Kontakt know:Ankaufsfall know:Beleg know:LlmWorker ) ] ; + rdfs:range xsd:dateTime . + +know:last_synced_at a owl:DatatypeProperty ; + rdfs:domain know:Projekt ; + rdfs:range xsd:dateTime . + +know:lat a owl:DatatypeProperty ; + rdfs:domain know:Ort ; + rdfs:range xsd:double . + +know:legal_name a owl:DatatypeProperty ; + rdfs:domain know:Kontakt ; + rdfs:range xsd:string . + +know:location_district a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:login_url a owl:DatatypeProperty ; + rdfs:domain know:LoginAnbieter ; + rdfs:range xsd:string . + +know:loi_present a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:boolean . + +know:lon a owl:DatatypeProperty ; + rdfs:domain know:Ort ; + rdfs:range xsd:double . + +know:mac a owl:DatatypeProperty ; + rdfs:domain know:LlmWorker ; + rdfs:range xsd:string . + +know:mail_art a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:string . + +know:mail_count a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:integer . + +know:mailbox a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Projektereignis know:Rechnungsbeobachtung know:AnhangExtraktion ) ] ; + rdfs:range xsd:string . + +know:match_address a owl:DatatypeProperty ; + rdfs:domain know:Ort ; + rdfs:range xsd:string . + +know:max_session_age_minutes a owl:DatatypeProperty ; + rdfs:domain know:LoginAnbieter ; + rdfs:range xsd:integer . + +know:meeting_transcripts a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:message_id a owl:DatatypeProperty ; + rdfs:domain know:Mail ; + rdfs:range xsd:string . + +know:min_observations_for_skip_ai a owl:DatatypeProperty ; + rdfs:domain know:MailKategorie ; + rdfs:range xsd:integer . + +know:min_tier_rank a owl:DatatypeProperty ; + rdfs:domain know:LlmJob ; + rdfs:range xsd:integer . + +know:missing_documents a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range rdf:JSON . + +know:name a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:nda_present a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:boolean . + +know:next_action a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:next_action_due_at a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:date . + +know:normalized_address a owl:DatatypeProperty ; + rdfs:domain know:Projekt ; + rdfs:range xsd:string . + +know:normalized_name a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Kontakt know:Ankaufsfall ) ] ; + rdfs:range xsd:string . + +know:normalized_value a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:ProjektAlias know:KontaktAlias ) ] ; + rdfs:range xsd:string . + +know:notar_name a owl:DatatypeProperty ; + rdfs:domain know:Kanzleimandat ; + rdfs:range xsd:string . + +know:notes a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Ankaufsfall know:LoginAnbieter ) ] ; + rdfs:range xsd:string . + +know:notified_at a owl:DatatypeProperty ; + rdfs:domain know:Hinweis ; + rdfs:range xsd:dateTime . + +know:observation_count a owl:DatatypeProperty ; + rdfs:domain know:Hinweis ; + rdfs:range xsd:integer . + +know:observation_key a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:string . + +know:offer_count a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:integer . + +know:offer_deadline a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:date . + +know:offer_submitted_at a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:date . + +know:original_json a owl:DatatypeProperty ; + rdfs:domain know:Kaufvertragspruefung ; + rdfs:range rdf:JSON . + +know:original_purchase_price_eur a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:decimal . + +know:outcome_status a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:outlook_category a owl:DatatypeProperty ; + rdfs:domain know:MailKategorie ; + rdfs:range xsd:string . + +know:pattern_value a owl:DatatypeProperty ; + rdfs:domain know:Hinweis ; + rdfs:range xsd:string . + +know:patterns a owl:DatatypeProperty ; + rdfs:domain know:Notarkanzlei ; + rdfs:range rdf:JSON . + +know:pending_notification a owl:DatatypeProperty ; + rdfs:domain know:Hinweis ; + rdfs:range xsd:boolean . + +know:phone a owl:DatatypeProperty ; + rdfs:domain know:Kontakt ; + rdfs:range xsd:string . + +know:pipeline_status a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:place_key a owl:DatatypeProperty ; + rdfs:domain know:Ort ; + rdfs:range xsd:string . + +know:planning a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:preferred_model a owl:DatatypeProperty ; + rdfs:domain know:MailKategorie ; + rdfs:range xsd:string . + +know:priority_level a owl:DatatypeProperty ; + rdfs:domain know:MailKategorie ; + rdfs:range xsd:string . + +know:process_type a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:processing_status a owl:DatatypeProperty ; + rdfs:domain know:Falldokument ; + rdfs:range xsd:string . + +know:project_key a owl:DatatypeProperty ; + rdfs:domain know:Projekt ; + rdfs:range xsd:string . + +know:project_name a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Projekt know:Kanzleimandat ) ] ; + rdfs:range xsd:string . + +know:project_number a owl:DatatypeProperty ; + rdfs:domain know:Projekt ; + rdfs:range xsd:string . + +know:prompt a owl:DatatypeProperty ; + rdfs:domain know:LlmJob ; + rdfs:range xsd:string . + +know:property_size_sqm a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:decimal . + +know:proposed_decision a owl:DatatypeProperty ; + rdfs:domain know:Entscheidung ; + rdfs:range rdf:JSON . + +know:provenance_json a owl:DatatypeProperty ; + rdfs:domain know:Ort ; + rdfs:range rdf:JSON . + +know:provider a owl:DatatypeProperty ; + rdfs:domain know:Postfachzugriff ; + rdfs:range xsd:string . + +know:provider_id a owl:DatatypeProperty ; + rdfs:domain know:LoginAnbieter ; + rdfs:range xsd:string . + +know:purchase_offer_present a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:boolean . + +know:quality_status a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:ProjektAlias know:Kontakt know:KontaktAlias know:Projektbeteiligung know:Projektereignis know:Ankaufsfall know:Ort know:Entscheidung know:Hinweis ) ] ; + rdfs:range xsd:string . + +know:quelle a owl:DatatypeProperty ; + rdfs:domain know:Projektkuerzel ; + rdfs:range xsd:string . + +know:raw_evidence_json a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Kontakt know:Ankaufsfall know:Beleg ) ] ; + rdfs:range rdf:JSON . + +know:raw_kanbau_json a owl:DatatypeProperty ; + rdfs:domain know:Projekt ; + rdfs:range rdf:JSON . + +know:raw_signals a owl:DatatypeProperty ; + rdfs:domain know:Mail ; + rdfs:range rdf:JSON . + +know:read_or_write a owl:DatatypeProperty ; + rdfs:domain know:Faehigkeit ; + rdfs:range xsd:string . + +know:readiness_json a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range rdf:JSON . + +know:reason a owl:DatatypeProperty ; + rdfs:domain know:WorkerSperre ; + rdfs:range xsd:string . + +know:received_at a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Mail know:Falldokument ) ] ; + rdfs:range xsd:dateTime . + +know:refreshed_at a owl:DatatypeProperty ; + rdfs:domain know:Ort ; + rdfs:range xsd:dateTime . + +know:registered_at a owl:DatatypeProperty ; + rdfs:domain know:LlmWorker ; + rdfs:range xsd:dateTime . + +know:registered_domain a owl:DatatypeProperty ; + rdfs:domain know:Domain ; + rdfs:range xsd:string . + +know:relation_key a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Projektbeteiligung know:Kanzleimandat ) ] ; + rdfs:range xsd:string . + +know:relation_type a owl:DatatypeProperty ; + rdfs:domain know:Projektbeteiligung ; + rdfs:range xsd:string . + +know:relevance_weight a owl:DatatypeProperty ; + rdfs:domain know:MailKategorie ; + rdfs:range xsd:decimal . + +know:request_id a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:AnhangExtraktion know:Kaufvertragspruefung know:Beleg know:Entscheidung know:AgentAusfuehrung ) ] ; + rdfs:range xsd:string . + +know:requires_action a owl:DatatypeProperty ; + rdfs:domain know:MailKategorie ; + rdfs:range xsd:boolean . + +know:requires_approval a owl:DatatypeProperty ; + rdfs:domain know:Faehigkeit ; + rdfs:range xsd:boolean . + +know:reserved_at a owl:DatatypeProperty ; + rdfs:domain know:Beleg ; + rdfs:range xsd:dateTime . + +know:result a owl:DatatypeProperty ; + rdfs:domain know:LlmJob ; + rdfs:range xsd:string . + +know:review_comment a owl:DatatypeProperty ; + rdfs:domain know:Kaufvertragspruefung ; + rdfs:range xsd:string . + +know:review_identity_source a owl:DatatypeProperty ; + rdfs:domain know:Kaufvertragspruefung ; + rdfs:range xsd:string . + +know:review_result a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:review_schema_version a owl:DatatypeProperty ; + rdfs:domain know:Kaufvertragspruefung ; + rdfs:range xsd:string . + +know:review_status a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Ankaufsfall know:Kaufvertragspruefung ) ] ; + rdfs:range xsd:string . + +know:review_submission_hash a owl:DatatypeProperty ; + rdfs:domain know:Kaufvertragspruefung ; + rdfs:range xsd:string . + +know:reviewed_at a owl:DatatypeProperty ; + rdfs:domain know:Kaufvertragspruefung ; + rdfs:range xsd:dateTime . + +know:reviewed_values_json a owl:DatatypeProperty ; + rdfs:domain know:Kaufvertragspruefung ; + rdfs:range rdf:JSON . + +know:risk_level a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Ankaufsfall know:Faehigkeit ) ] ; + rdfs:range xsd:string . + +know:risk_summary a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:safety_floor a owl:DatatypeProperty ; + rdfs:domain know:MailKategorie ; + rdfs:range xsd:boolean . + +know:scope a owl:DatatypeProperty ; + rdfs:domain know:Postfachzugriff ; + rdfs:range xsd:string . + +know:sender a owl:DatatypeProperty ; + rdfs:domain know:Projektereignis ; + rdfs:range xsd:string . + +know:session_data a owl:DatatypeProperty ; + rdfs:domain know:BrowserSession ; + rdfs:range rdf:JSON . + +know:short_code a owl:DatatypeProperty ; + rdfs:domain know:Projekt ; + rdfs:range xsd:string . + +know:source a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Absender know:Domain know:Kontakt ) ] ; + rdfs:range xsd:string . + +know:source_channel a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:source_confidence a owl:DatatypeProperty ; + rdfs:domain know:Kontakt ; + rdfs:range xsd:decimal . + +know:source_party a owl:DatatypeProperty ; + rdfs:domain know:Falldokument ; + rdfs:range xsd:string . + +know:source_path a owl:DatatypeProperty ; + rdfs:domain know:Kontextdokument ; + rdfs:range xsd:string . + +know:source_system a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:string . + +know:source_workflow a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Postfachzugriff know:ProjektAlias know:KontaktAlias know:Projektbeteiligung know:Projektereignis know:Projektordner know:Rechnungsbeobachtung know:Ort ) ] ; + rdfs:range xsd:string . + +know:start_date a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:date . + +know:status a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Domain know:Postfachzugriff know:Projekt know:Ankaufsfall know:Beleg know:AgentAusfuehrung know:LlmWorker know:LlmJob ) ] ; + rdfs:range xsd:string . + +know:steps_json a owl:DatatypeProperty ; + rdfs:domain know:LoginAnbieter ; + rdfs:range rdf:JSON . + +know:street a owl:DatatypeProperty ; + rdfs:domain know:Projekt ; + rdfs:range xsd:string . + +know:structured_json a owl:DatatypeProperty ; + rdfs:domain know:AnhangExtraktion ; + rdfs:range rdf:JSON . + +know:subject a owl:DatatypeProperty ; + rdfs:domain know:Mail ; + rdfs:range xsd:string . + +know:subjects_sample a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range rdf:JSON . + +know:suggested_action a owl:DatatypeProperty ; + rdfs:domain know:Hinweis ; + rdfs:range rdf:JSON . + +know:summary a owl:DatatypeProperty ; + rdfs:domain know:Projektereignis ; + rdfs:range xsd:string . + +know:tax_id a owl:DatatypeProperty ; + rdfs:domain know:Kontakt ; + rdfs:range xsd:string . + +know:tier a owl:DatatypeProperty ; + rdfs:domain know:LlmJob ; + rdfs:range xsd:string . + +know:tier_rank a owl:DatatypeProperty ; + rdfs:domain know:LlmWorker ; + rdfs:range xsd:integer . + +know:title a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:Faehigkeit know:Kontextdokument ) ] ; + rdfs:range xsd:string . + +know:token_alg a owl:DatatypeProperty ; + rdfs:domain know:Postfachzugriff ; + rdfs:range xsd:string . + +know:token_ciphertext a owl:DatatypeProperty ; + rdfs:domain know:Postfachzugriff ; + rdfs:range xsd:string . + +know:token_iv a owl:DatatypeProperty ; + rdfs:domain know:Postfachzugriff ; + rdfs:range xsd:string . + +know:token_tag a owl:DatatypeProperty ; + rdfs:domain know:Postfachzugriff ; + rdfs:range xsd:string . + +know:units_json a owl:DatatypeProperty ; + rdfs:domain know:Kaufvertragspruefung ; + rdfs:range rdf:JSON . + +know:unsubscribe_mailto a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:string . + +know:unsubscribe_one_click a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:boolean . + +know:unsubscribe_url a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:string . + +know:uploaded_at a owl:DatatypeProperty ; + rdfs:domain know:Beleg ; + rdfs:range xsd:dateTime . + +know:urgency a owl:DatatypeProperty ; + rdfs:domain know:Projektereignis ; + rdfs:range xsd:string . + +know:user_request a owl:DatatypeProperty ; + rdfs:domain know:AgentAusfuehrung ; + rdfs:range xsd:string . + +know:valid_from a owl:DatatypeProperty ; + rdfs:domain know:Projektbeteiligung ; + rdfs:range xsd:date . + +know:valid_to a owl:DatatypeProperty ; + rdfs:domain know:Projektbeteiligung ; + rdfs:range xsd:date . + +know:validated_at a owl:DatatypeProperty ; + rdfs:domain know:KanbauApiMethode ; + rdfs:range xsd:dateTime . + +know:vat_id a owl:DatatypeProperty ; + rdfs:domain know:Kontakt ; + rdfs:range xsd:string . + +know:verified_at a owl:DatatypeProperty ; + rdfs:domain [ a owl:Class ; owl:unionOf ( know:ProjektAlias know:Kontakt know:Projektordner know:Entscheidung ) ] ; + rdfs:range xsd:dateTime . + +know:website a owl:DatatypeProperty ; + rdfs:domain know:Kontakt ; + rdfs:range xsd:string . + +know:window_from a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:dateTime . + +know:window_to a owl:DatatypeProperty ; + rdfs:domain know:Absender ; + rdfs:range xsd:dateTime . + +know:wirtschaftlichkeit_present a owl:DatatypeProperty ; + rdfs:domain know:Ankaufsfall ; + rdfs:range xsd:boolean . + +know:workflow_operation a owl:DatatypeProperty ; + rdfs:domain know:Faehigkeit ; + rdfs:range xsd:string . + +know:workflow_status a owl:DatatypeProperty ; + rdfs:domain know:Mail ; + rdfs:range xsd:string . + +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:Gesellschaft_PplusB_KG a owl:NamedIndividual, know:Gesellschaft ; + rdfs:label "P+B KG" . + +know:MailKategorie_mahnung a owl:NamedIndividual, know:MailKategorie ; + rdfs:label "mahnung" . + +know:MailKategorie_zahlungsaufforderung a owl:NamedIndividual, know:MailKategorie ; + rdfs:label "zahlungsaufforderung" . + +know:MailKategorie_buchhaltung a owl:NamedIndividual, know:MailKategorie ; + rdfs:label "buchhaltung" . + +know:MailKategorie_mangel a owl:NamedIndividual, know:MailKategorie ; + rdfs:label "mangel" . + +know:MailKategorie_newsletter_werbung a owl:NamedIndividual, know:MailKategorie ; + rdfs:label "newsletter_werbung" . + +know:MailKategorie_plattformen a owl:NamedIndividual, know:MailKategorie ; + rdfs:label "plattformen" . + +know:MailKategorie_versand_bestellungen a owl:NamedIndividual, know:MailKategorie ; + rdfs:label "versand_bestellungen" . + +know:MailKategorie_sonstiges a owl:NamedIndividual, know:MailKategorie ; + rdfs:label "sonstiges" . + +know:MailKategorie_default a owl:NamedIndividual, know:MailKategorie ; + rdfs:label "default" . + +know:Tier_basic a owl:NamedIndividual, know:Tier ; + rdfs:label "basic" . + +know:Tier_standard a owl:NamedIndividual, know:Tier ; + rdfs:label "standard" . + +know:Tier_capable a owl:NamedIndividual, know:Tier ; + rdfs:label "capable" . + +know:Tier_expert a owl:NamedIndividual, know:Tier ; + rdfs:label "expert" . + +know:Ereignisart_verzoegerung a owl:NamedIndividual, know:Ereignisart ; + rdfs:label "verzoegerung" . + +know:Ereignisart_mangel a owl:NamedIndividual, know:Ereignisart ; + rdfs:label "mangel" . + +know:Ereignisart_termin a owl:NamedIndividual, know:Ereignisart ; + rdfs:label "termin" . + +know:Ereignisart_kosten a owl:NamedIndividual, know:Ereignisart ; + rdfs:label "kosten" . + +know:Ereignisart_info a owl:NamedIndividual, know:Ereignisart ; + rdfs:label "info" . + +know:Dokumenttyp_expose a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "expose" . + +know:Dokumenttyp_bplan a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "bplan" . + +know:Dokumenttyp_wirtschaftlichkeitsberechnung a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "wirtschaftlichkeitsberechnung" . + +know:Dokumenttyp_kaufangebot a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "kaufangebot" . + +know:Dokumenttyp_loi a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "loi" . + +know:Dokumenttyp_nda a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "nda" . + +know:Dokumenttyp_vertraulichkeitserklaerung a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "vertraulichkeitserklaerung" . + +know:Dokumenttyp_telefonnotiz a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "telefonnotiz" . + +know:Dokumenttyp_mail a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "mail" . + +know:Dokumenttyp_lageplan a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "lageplan" . + +know:Dokumenttyp_kataster a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "kataster" . + +know:Dokumenttyp_grundbuch a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "grundbuch" . + +know:Dokumenttyp_baugenehmigung a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "baugenehmigung" . + +know:Dokumenttyp_gutachten a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "gutachten" . + +know:Dokumenttyp_pruefplan a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "pruefplan" . + +know:Dokumenttyp_fotos a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "fotos" . + +know:Dokumenttyp_wettbewerbsunterlagen a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "wettbewerbsunterlagen" . + +know:Dokumenttyp_vertrag_notar a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "vertrag_notar" . + +know:Dokumenttyp_unknown a owl:NamedIndividual, know:Dokumenttyp ; + rdfs:label "unknown" . From d9d8d4e38be49693987f09e483b5d94e04ec6d7e Mon Sep 17 00:00:00 2001 From: Robert Rapp Date: Tue, 13 Oct 2026 02:21:07 +0200 Subject: [PATCH 4/6] docs: KNOW-Befund B10 (Absender-Report liest eingefrorene Statistik aus contacts) Co-Authored-By: Claude Opus 5.5 --- docs/reference/KNOW-befunde.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/reference/KNOW-befunde.md b/docs/reference/KNOW-befunde.md index 73254d6..b254456 100644 --- a/docs/reference/KNOW-befunde.md +++ b/docs/reference/KNOW-befunde.md @@ -20,6 +20,7 @@ Status aller Befunde: **offen**. In n8n wurde nichts geändert. | B7 | Anhang-Extraktionen kommen nur von außen | niedrig | | B8 | KGW erlaubt externen Aufrufern eine Lösch-Operation | niedrig | | B9 | Keine Löschfristen für Klartext-Felder | mittel (Datenschutz) | +| B10 | Absender-Report zeigt eingefrorene Statistik | mittel | ## 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“. @@ -97,3 +98,12 @@ Die Abfragen zu Ankaufsfällen über KGW (Hermes) liefern also nur Daten, die ei `purge_*` gibt es nur für Absender-Beobachtungen und Aliase. Bei `browser_sessions.session_data` ist am Schema nicht erkennbar, ob verschlüsselt wird. - **Vorschlag:** Aufbewahrungsfristen festlegen, z. B. `llm_jobs` nach Abschluss nach 30 Tagen leeren. `browser_sessions` im Code von BSB prüfen. + +## B10 – Absender-Report zeigt eingefrorene Statistik +- **Beleg:** + - `ASP - Absender-Profil Backfill` schreibt die Statistik eines Absenders (`mail_count`, `dominant_category`, `avg_confidence` …) per `upsert_contact` in `contacts.raw_evidence_json`. + - `upsert_contact` aktualisiert bei einem vorhandenen Kontakt nur `display_name`, `normalized_name`, `last_seen_at` und `updated_at`, **nicht** `raw_evidence_json`. + - `ASP - Absender-Report` liest Kategorie und Mail-Anzahl per eigenem SQL aus genau diesem Feld. +- **Folge:** Der Report zeigt für jeden Absender die Werte vom ersten Backfill, auch wenn sich die Lage seitdem geändert hat. Die aktuellen Werte stehen in `sender_observations`, dort schreibt derselbe Backfill per `upsert_sender_observation` mit. +- **Ursache (ontologisch):** Das Absender-Profil gehört zur Klasse *Absender*, der Kontakt ist das fachliche Gegenüber. Beide sind über die E-Mail-Adresse verbunden. Liegt die Statistik zusätzlich im Kontakt, entsteht eine zweite Quelle, und die veraltet. +- **Vorschlag:** Den Report auf `sender_observations` umstellen und den Kontakt nur noch über `email` dazuholen. Die Statistik aus `upsert_contact` herausnehmen. From 525b29aa6d16b00f4f3179c4f4321b32aa9d8b39 Mon Sep 17 00:00:00 2001 From: Robert Rapp Date: Tue, 13 Oct 2026 02:34:42 +0200 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20Vollanalyse=20der=20KNOW-Datenhaltu?= =?UTF-8?q?ng,=20Befunde=20B11=E2=80=93B30?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier Ebenen (Schema, SQL der 80 Operationen, Payloads der Aufrufer, Ablagen außerhalb von KNOW), jeder Befund am Code nachgeprüft: keine Fremdschlüssel, zwei Kontakt-Schlüsselschemata, zwei Projekt-Kennungen, zu enge/fehlende Eindeutigkeit, Upserts frieren Fachdaten ein, veraltende abgeleitete Werte, nie geschriebene Felder, Kategorie-Vokabulare (Safety-Floor über CAT unerreichbar), uneinheitliche Normalisierung, Schema-Drift, gemeinsamer DB-Zugang, staticData, unverschlüsselte Browser-Sessions, LLM-Aufrufe am PSE-LiteLLM vorbei. Schema-Referenz und Ontologie um fehlende Spalten ergänzt. Co-Authored-By: Claude Opus 5.5 --- docs/reference/KNOW-befunde.md | 213 ++++++++++++++++++++++++++++++- docs/reference/KNOW-schema.md | 6 +- docs/reference/know-ontology.ttl | 12 +- 3 files changed, 222 insertions(+), 9 deletions(-) diff --git a/docs/reference/KNOW-befunde.md b/docs/reference/KNOW-befunde.md index b254456..8a60b34 100644 --- a/docs/reference/KNOW-befunde.md +++ b/docs/reference/KNOW-befunde.md @@ -1,6 +1,12 @@ -# KNOW: Befunde aus der Analyse vom 13.10.2026 +# KNOW: Befunde zur Datenhaltung (Analyse vom 13.10.2026) -Grundlage sind die Workflow-Definitionen auf n8n.mindboost.app, nur lesend über die n8n-API abgerufen. Ausgewertet wurde der Code von KNOW, KGW und allen aufrufenden Workflows. Inhalte der Datenbank wurden nicht gelesen. Für `CANCEL - Unsubscribe Link` und `KAN-Learn - Kanbau Pattern Learning` liefert die API keine gespeicherten Ausführungen, ein Nachweis aus der Laufzeit fehlt deshalb. +Grundlage sind die Workflow-Definitionen auf n8n.mindboost.app, nur lesend über die n8n-API abgerufen. Ausgewertet wurde der Code von KNOW, KGW und allen aufrufenden Workflows. B11–B30 stammen aus der Vollanalyse in vier Ebenen: +1. Schema: Schlüssel, Indizes, Typen, nachträgliche Spalten. +2. SQL der 80 Operationen: Was wird geschrieben, was bei Konflikten nicht aktualisiert, was nie befüllt. +3. Abgleich mit den Payloads der Aufrufer. +4. Datenablagen außerhalb von KNOW: staticData, direkte Verbindungen, LLM-Aufrufe. + +Jeder Befund wurde am Code nachgeprüft, Artefakte der automatischen Auswertung sind aussortiert. Inhalte der Datenbank wurden nicht gelesen. Für `CANCEL - Unsubscribe Link` und `KAN-Learn - Kanbau Pattern Learning` liefert die API keine gespeicherten Ausführungen, ein Nachweis aus der Laufzeit fehlt deshalb. - Übersicht der Tabellen: [KNOW-schema.md](KNOW-schema.md) - Interaktive Ansichten (privat): @@ -21,6 +27,26 @@ Status aller Befunde: **offen**. In n8n wurde nichts geändert. | B8 | KGW erlaubt externen Aufrufern eine Lösch-Operation | niedrig | | B9 | Keine Löschfristen für Klartext-Felder | mittel (Datenschutz) | | B10 | Absender-Report zeigt eingefrorene Statistik | mittel | +| B11 | Kein einziger Fremdschlüssel; alle Verweise sind freier Text | hoch | +| B12 | Zwei Schlüsselschemata für Kontakte → Dubletten, Join findet nichts | hoch | +| B13 | Zwei Projekt-Kennungen, `projects` leer, Projekte kommen live aus KanBau | hoch | +| B14 | Zu enge Eindeutigkeit: Ereignisse und Prüfungen werden überschrieben bzw. verworfen | hoch | +| B15 | Fehlende Eindeutigkeit: jede Extraktion erzeugt eine neue Zeile | mittel | +| B16 | Drei Tabellen ohne Primärschlüssel, doppelte Indizes | niedrig | +| B17 | Upserts frieren Fachdaten ein (Kontakte, Ankaufsfälle, Falldokumente) | hoch | +| B18 | Gespeicherte abgeleitete Werte veralten (`deadline_is_overdue` u. a.) | mittel | +| B19 | Felder, die nie geschrieben werden (`confidence`, `confirmed_by`, `verified_at`) | mittel | +| B20 | Kategorien in mehreren Vokabularen; Safety-Floor-Kategorien über CAT unerreichbar | hoch | +| B21 | Postfach und Absender uneinheitlich normalisiert | mittel | +| B22 | Zwei Listen für Dokumenttypen | niedrig | +| B23 | Sechs Statusfelder rund um den Ankaufsfall | mittel | +| B24 | Schema-Drift: 45 nachgezogene Spalten, Laufzeit-DDL, keine Versionierung | mittel | +| B25 | Uneinheitliche Typen und Benennung (Geld als Text, Cent vs. Euro, deutsch/englisch) | niedrig | +| B26 | Fehlende Indizes auf Such- und Join-Spalten | niedrig | +| B27 | Ein Datenbankzugang für alles, geteilt mit dem Chat-Gedächtnis | mittel | +| 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) | ## 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“. @@ -96,8 +122,8 @@ Die Abfragen zu Ankaufsfällen über KGW (Hermes) liefern also nur Daten, die ei - `agent_context_documents.content` - `mail_events` (Absender, Betreff) - `purge_*` gibt es nur für Absender-Beobachtungen und Aliase. Bei `browser_sessions.session_data` ist am Schema nicht erkennbar, ob verschlüsselt wird. -- **Vorschlag:** Aufbewahrungsfristen festlegen, z. B. `llm_jobs` nach Abschluss nach 30 Tagen leeren. `browser_sessions` im Code von BSB prüfen. + `purge_*` gibt es nur für Absender-Beobachtungen und Aliase. `browser_sessions.session_data` ist unverschlüsselt, siehe B29. +- **Vorschlag:** Aufbewahrungsfristen festlegen, z. B. `llm_jobs` nach Abschluss nach 30 Tagen leeren. Zu `browser_sessions` siehe B29. ## B10 – Absender-Report zeigt eingefrorene Statistik - **Beleg:** @@ -107,3 +133,182 @@ Die Abfragen zu Ankaufsfällen über KGW (Hermes) liefern also nur Daten, die ei - **Folge:** Der Report zeigt für jeden Absender die Werte vom ersten Backfill, auch wenn sich die Lage seitdem geändert hat. Die aktuellen Werte stehen in `sender_observations`, dort schreibt derselbe Backfill per `upsert_sender_observation` mit. - **Ursache (ontologisch):** Das Absender-Profil gehört zur Klasse *Absender*, der Kontakt ist das fachliche Gegenüber. Beide sind über die E-Mail-Adresse verbunden. Liegt die Statistik zusätzlich im Kontakt, entsteht eine zweite Quelle, und die veraltet. - **Vorschlag:** Den Report auf `sender_observations` umstellen und den Kontakt nur noch über `email` dazuholen. Die Statistik aus `upsert_contact` herausnehmen. + +--- + +# Vollanalyse: B11–B30 + +## A. Integrität und Schlüssel + +### B11 – Keine Fremdschlüssel +- **Beleg:** `setup_schema` enthält kein einziges `REFERENCES`. + - Alle Verweise (`project_key`, `contact_key`, `case_key`, `message_id`, `kuerzel`, `mac` …) sind freier Text ohne Prüfung. + - Auch Löschungen (`purge_*`, `reject_project_alias`) räumen abhängige Zeilen nicht auf. +- **Folge:** Verwaiste und ungültige Verweise sind jederzeit möglich, und es fällt nicht auf. Besonders betroffen ist `project_key`: Es wird in 7 Tabellen benutzt, aber `projects` wird nie befüllt (B13). Kein einziger Projektverweis lässt sich gegen eine Projekttabelle prüfen. +- **Vorschlag:** + - Für die Kernbezüge Fremdschlüssel einführen, zuerst `NOT VALID`, dann nach einer Bereinigung validieren: Falldokument → Ankaufsfall, Alias → Kontakt, Beteiligung → Kontakt und Projekt. + - Für `project_key` erst B13 lösen. + +### B12 – Zwei Schlüsselschemata für Kontakte +- **Beleg:** + - `upsert_contact` bildet den Schlüssel standardmäßig als `normalisierter Name|E-Mail`. Kein Aufrufer übergibt einen eigenen Schlüssel. + - `update_sender_relevance` legt Kontakte mit dem Schlüssel `E-Mail` an, mit der E-Mail-Adresse als Anzeigename. + - `find_workflow_hints` verknüpft `workflow_hints.pattern_key` (Absenderadresse) mit `contacts.contact_key`. +- **Folge:** + - Ein Absender kann zweimal als Kontakt existieren. + - Der Relevanz-Score landet auf dem Schattenkontakt mit dem E-Mail-Schlüssel. + - Die vom Backfill angelegten Kontakte („Name|E-Mail“) findet der Join in `find_workflow_hints` nie. + - Ändert sich der Anzeigename, entsteht ein neuer Kontakt. +- **Vorschlag:** einen Schlüssel festlegen, z. B. die kleingeschriebene E-Mail für Personen und Absender, Namen nur noch als Attribut bzw. Alias. Bestehende Dubletten über `email` zusammenführen. + +### B13 – Zwei Projekt-Kennungen, `projects` leer +- **Beleg:** + - Verweise nutzen `project_key`. + - Der Ankaufsfall speichert dagegen `selected_project_id`, eine `project_id` aus der KAF-Bestätigung. + - `projects` und `project_aliases` führen zusätzlich `kanbau_project_id`. + - Keine KNOW-Operation schreibt `projects` (B5a). + - `PRO - Project Assignment` fragt Projekte live bei `mygodel.kanbau.com` ab. +- **Folge:** Es gibt keine Stelle, an der `project_key` und die KanBau-ID verlässlich zusammengehören. Lesende Abfragen auf `projects` (NEX-Teams-Agent, `find_project_candidates`) finden vermutlich nur Treffer über Aliase. +- **Vorschlag:** einen Abgleich KanBau → `projects` anlegen (z. B. nachts, über das vorhandene KAN-Gateway) mit `kanbau_project_id` als festem Schlüssel. `project_key` daraus ableiten oder ersetzen. + +### B14 – Zu enge Eindeutigkeit +- **`project_events`:** `UNIQUE(message_id)` mit `ON CONFLICT (message_id) DO UPDATE SET project_key, category, summary …`. + - Meldet eine Mail zwei Ereignisse oder betrifft sie zwei Projekte, überschreibt das zweite das erste. + - Vorschlag: Schlüssel `(message_id, project_key, category)`. +- **`kaf_review_snapshots`:** `UNIQUE(case_key)` mit Aktualisierung nur bei `review_status='pending'`. + - Nach einer Freigabe kann für denselben Fall kein neuer Vertragsstand gespeichert werden. Der Insert läuft ohne Fehler ins Leere. + - Vorschlag: eine Version pro Prüfung (`case_key, version`). +- **`mail_events`:** `UNIQUE(message_id)` ohne Postfach. + - Das ist unkritisch, solange die Graph-IDs pro Postfach verschieden sind. Bei Internet-Message-IDs würde eine Mail an zwei Postfächer zu einer Zeile. + - Prüfen, welche ID CAT und MPA übergeben. + +### B15 – Fehlende Eindeutigkeit bei Extraktionen +- **Beleg:** `document_extractions` hat keinen eindeutigen Schlüssel, `store_document_extraction` macht ein reines `INSERT`. +- **Folge:** Jede erneute Verarbeitung desselben Anhangs erzeugt eine weitere Zeile, und es ist nicht festgelegt, welche gilt. +- **Vorschlag:** `UNIQUE(message_id, attachment_id)` mit Upsert. + +### B16 – Tabellen ohne Primärschlüssel +- **Beleg:** `workflow_hints`, `workflow_hint_config` und `kanbau_method_examples` haben nur `UNIQUE`, keinen `PRIMARY KEY`. Auf `hint_key` und `method_key` liegt zusätzlich ein zweiter, überflüssiger Index. +- **Vorschlag:** den eindeutigen Schlüssel zum Primärschlüssel machen und die doppelten Indizes entfernen. + +## B. Aktualität + +### B17 – Upserts frieren Fachdaten ein +Bei einem vorhandenen Datensatz aktualisieren mehrere Upserts nur wenige Spalten. Alles andere behält den Wert vom ersten Insert: +- **`upsert_contact`:** aktualisiert nur Name, `last_seen_at` und `updated_at`. **E-Mail, Telefon, Website, Steuer-Nr., USt-IdNr., IBAN-Hash, Adresse, Kontakttyp, Qualitätsstatus und Belege** werden nie korrigiert. B10 ist ein Spezialfall davon. +- **`upsert_property_development_case`:** aktualisiert nur Name, die drei Statusfelder, `sharepoint_json` und `raw_evidence_json`. **Rund 50 Fachfelder bleiben eingefroren**, darunter Angebotsfrist, Kaufpreis, Fläche, `next_action`, `next_action_due_at`, Risiko, Unterlagen-Flags und Mitschriften. +- **`upsert_property_development_document`:** `is_current_version` und `supersedes_document_key` werden nie aktualisiert. Ein altes Dokument bleibt „aktuell“, die **Versionierung funktioniert nicht**. Ebenso bleiben Zusammenfassung, Absender und Dokumentdatum stehen. +- **Vorschlag:** In den Upserts alle fachlichen Felder mit `COALESCE(EXCLUDED.x, tabelle.x)` aktualisieren. Felder, die bewusst nur einmal gesetzt werden, im Code kennzeichnen. + +### B18 – Gespeicherte abgeleitete Werte veralten +- **Beleg:** + - `deadline_is_overdue` ist eine gespeicherte Spalte. `find_cases_by_deadline` liest sie, sie lässt sich aber aus `offer_deadline` und dem heutigen Datum berechnen. + - `document_count`, `missing_documents` und `detected_documents` werden nur von `sync_property_development_folder_snapshot` gepflegt, und das ruft niemand auf (B5b). +- **Folge:** „Überfällig“ und „fehlende Unterlagen“ zeigen den Stand vom Einfügen. +- **Vorschlag:** `deadline_is_overdue` zur Laufzeit berechnen (View oder Ausdruck in der Abfrage). Die Zähler aus `property_development_documents` ableiten. + +### B19 – Felder, die nie geschrieben werden +- **`workflow_hints.confidence`:** Keine Operation schreibt das Feld. + - CAT sendet die Konfidenz, aber innerhalb von `suggested_action`. + - `find_workflow_hints` sortiert nach der Spalte, die immer 0 ist. Die Reihenfolge der Hinweise ist damit zufällig. +- **`workflow_hints.confirmed_by`:** `promote_workflow_hint` setzt Status und Zeit, aber nicht die Person. Wer einen Hinweis über den Bestätigungslink freigegeben hat, wird nicht gespeichert. +- **`contacts.verified_at`, `project_aliases.verified_at`:** Keine Operation setzt sie. Das Bestätigungsmodell (`quality_status` + `verified_at`) ist nur halb umgesetzt. +- **Vorschlag:** `confidence` aus `suggested_action` in die Spalte übernehmen (auch rückwirkend). `confirmed_by` aus dem Bestätigungs-Webhook mitgeben. + +## C. Vokabular und Normalisierung + +### B20 – Kategorien in mehreren Vokabularen +- **Beleg:** Dieselbe Idee „Kategorie einer Mail“ existiert in mindestens sechs Listen: + - **CAT-Zuordnung:** 8 Werte, darunter `bauvorhaben` und `projekt`. + - **`workflow_hint_config.config_key`:** 9 Werte, darunter `mahnung`, `zahlungsaufforderung` und `default`. + - **`outlook_category`:** Anzeigenamen in Outlook. + - **`sender_observations.dominant_category`:** vom Absender-Backfill vergeben. + - **`project_events.category`:** 5 feste Werte. + - **`mail_events.category`:** frei. MPA schreibt hier den Workflow-Namen `"MPA"`. +- **Folge:** + - CAT erzeugt `bauvorhaben` und `projekt`, die Konfiguration kennt sie nicht, also greift `default`. + - Umgekehrt erzeugt CAT nie `mahnung` oder `zahlungsaufforderung`. **Die Regeln mit Safety-Floor („immer manuell prüfen“) greifen bei Hinweisen aus CAT nie.** + - Auswertungen nach Kategorie mischen fachliche Kategorien mit dem Workflow-Namen „MPA“. +- **Vorschlag:** + - Ein Vokabular als Tabelle bzw. SKOS-Konzeptschema in der Ontologie, mit Abbildung der Outlook-Namen. + - CAT um Mahnung und Zahlungsaufforderung erweitern. + - MPA schreibt seinen Status in `workflow_status`, nicht in `category`. + +### B21 – Postfach und Absender uneinheitlich normalisiert +- **Postfach:** CAT setzt in `store_mail_event` fest `info@mygodel.de`, im CAT-Code kommt aber auch `r.rapp@mygodel.de` vor. MPA übergibt den `grant_key`. Der Absender-Backfill schreibt das Postfach kleingeschrieben. +- **Absender:** CAT schreibt ihn klein, MPA übergibt ihn unverändert. `sender_observations.email` ist immer klein. +- **Folge:** Die weichen Bezüge Mail ↔ Absender ↔ Kontakt (Ontologie) treffen nur teilweise. Steht ein falsches Postfach fest im Code, werden Mails dem falschen Postfach zugeordnet. +- **Vorschlag:** E-Mail-Adressen und Postfächer **in KNOW** normalisieren (kleinschreiben, Anzeigename abtrennen), nicht in jedem Aufrufer. In CAT das Postfach aus dem Trigger übernehmen. + +### B22 – Zwei Listen für Dokumenttypen +- **Beleg:** `property_development_documents.document_type` hat 19 feste Werte. `document_extractions.document_type` ist frei, mit Standardwert `unknown`. +- **Vorschlag:** eine gemeinsame Liste (Ontologie-Klasse *Dokumenttyp*). + +### B23 – Sechs Statusfelder rund um den Ankaufsfall +- **Beleg:** `status` (alt), `pipeline_status`, `outcome_status`, `review_status`, `confirmation_status` und `quality_status`. Dazu kommt die zweite Bestätigungslogik aus B6, die `quality_status='confirmed'` setzt, während der Token-Ablauf `confirmation_status` nutzt. +- **Folge:** Es ist nicht festgelegt, welches Feld „bestätigt“ bzw. „abgeschlossen“ bedeutet. +- **Vorschlag:** `status` (alt) aus `pipeline_status` und `outcome_status` ableiten oder entfernen. Eine Bestätigungslogik festlegen. + +## D. Schema-Pflege + +### B24 – Schema-Drift +- **Beleg:** + - 45 Spalten werden per `ALTER TABLE … ADD COLUMN IF NOT EXISTS` nachgezogen, davon 41 in `property_development_cases`. Bei `kanzlei_field_patterns` doppelt mit unterschiedlichem Standardwert (`'[]'` bzw. `'[]'::jsonb`). + - Dazu kommt Laufzeit-DDL in einer Anfrage (B6). + - Es gibt keine Schema-Version und keine Migrationshistorie. +- **Folge:** Welche Spalten eine Instanz hat, hängt davon ab, wann `setup_schema` zuletzt lief und ob der KAF-Sonderweg je ausgeführt wurde. +- **Vorschlag:** versionierte Migrationen (Tabelle `schema_migrations`), `setup_schema` nur noch als Migrationslauf. + +### B25 – Uneinheitliche Typen und Benennung +- **Geld:** als Text (`commission_including_vat`, `godel_offer`), Beträge mal in Cent (`amount_cents BIGINT`), mal in Euro (`*_eur NUMERIC`). +- **Konfidenz:** mal `NUMERIC`, mal `DOUBLE PRECISION`. +- **Spaltennamen:** deutsch und englisch gemischt (`kuerzel`, `gesellschaft`, `quelle`, `bestaetigt`, `baustelle`, `absender_typ`, `mail_art`). +- **Zeitstempel:** `updated_at` fehlt u. a. in `project_aliases`, `contact_aliases`, `document_extractions` und `mail_events`. +- **Vorschlag:** bei der nächsten Migration vereinheitlichen. Niedrige Priorität, aber Voraussetzung für generierte Schemas (Ontologie → JSON-Schema). + +### B26 – Fehlende Indizes +- **Beleg:** Ohne Index sind u. a.: + - `contacts.email` (Abgleich mit Absendern) + - `mail_events.sender`, `received_at`, `category`, `assigned_project_key` (`find_mail_events` filtert und sortiert danach) + - `document_extractions.message_id` und `project_key` + - `sender_observations.email` (`get_sender_profile`) + - `contact_project_relations` +- **Vorschlag:** Indizes auf diese Spalten. Unkritisch, solange die Tabellen klein sind. + +## E. Ablage und Datenfluss außerhalb von KNOW + +### B27 – Ein Datenbankzugang für alles +- **Beleg:** KNOW, die direkten SQL-Knoten in CAT und ASP, der Postgres-Healthcheck und der Credentials-Watchdog nutzen dasselbe Credential **„n8n Postgres memory account“**. Darüber läuft auch das Chat-Gedächtnis. +- **Folge:** Jeder Workflow mit diesem Credential kann jede Tabelle lesen, ändern und löschen. Die Freigabeliste in KNOW und KGW lässt sich so umgehen (B4 ist ein Beispiel). +- **Vorschlag:** eigene DB-Rolle für KNOW mit Rechten nur auf `knowledge`, getrennt vom Chat-Gedächtnis. Direkte SQL-Knoten auf eine Rolle nur mit Leserechten umstellen oder durch KNOW-Operationen ersetzen. + +### B28 – Zustand in staticData +- **Beleg:** 9 Workflows speichern Zustand in n8n-staticData: + - CANCEL (`successful_unsubscribes`, wegen B1 die **einzige** Aufzeichnung der Abmeldungen) + - BEL (`bel_alerts`, `bel_setup_sent`) + - KJW (`nextLink`) + - TDO (`manual_enrichment`, `etag`) + - MCR (`last_reported_sha`) + - MPA (Token-Cache, verschlüsselt, in Ordnung) + - ASP-Scheduler, KBT und KBJ (inaktiv) +- **Folge:** Dieser Zustand ist in KNOW unsichtbar, hat keine Historie und geht bei einem Neuimport des Workflows verloren. +- **Vorschlag:** Fachlich relevante Zustände (CANCEL, BEL) nach KNOW verlagern. Technische Cursor (KJW, MCR) dürfen bleiben. + +### B29 – Browser-Sessions unverschlüsselt +- **Beleg:** `BSB - Execute Login` speichert `session_data: { cookies, headers }` über `upsert_browser_session` als JSON im Klartext. +- **Folge:** Gültige Sitzungs-Cookies (u. a. für KanBau) liegen lesbar in der Datenbank. Jeder mit dem gemeinsamen Credential (B27) kann sie übernehmen. Zum Vergleich: Die Postfach-Tokens sind mit AES-256-GCM verschlüsselt, auch im Cache. +- **Vorschlag:** `session_data` genauso verschlüsseln wie `mailbox_grants`. Die Lebensdauer kurz halten (Standard 60 Min. ist gut). + +### B30 – LLM-Aufrufe am PSE-LiteLLM vorbei +- **Beleg (direkte HTTP-Aufrufe, sicher am LiteLLM vorbei):** + - `KAF - Kaufvertrag Erkennung und Extraktion` (aktiv) schickt den **Kaufvertragstext** (`pdf_text`) direkt an `openrouter.ai`, ohne PSE-Aufruf. + - Ebenso direkt gehen `KAN - Kanbau API Gateway` (Intent-Erkennung), `BSB - Discover Session` (Login-Seiten an `api.anthropic.com`), `CDW` (Prüfaufruf) und `KSUM` (inaktiv). +- **Nicht entscheidbar:** Die OpenAI-Knoten in ASP, DOC, MAIL, CAT und OR nutzen das Credential `openAiApi`. Ob es auf den LiteLLM mit PSE zeigt, ist ohne Einsicht in das Credential nicht erkennbar. ASP ruft dabei kein PSE auf. +- **Folge:** Widerspricht der Vorgabe im Aufbauplan („Starke Modelle nur über den LiteLLM mit PSE“). Kaufverträge enthalten Namen, Anschriften und Preise. +- **Vorschlag:** KAF, KAN und BSB auf den LiteLLM mit PSE umstellen. Das Ziel des Credentials `openAiApi` prüfen. + +## Geprüft, ohne Befund +- Postfach-Tokens (`mailbox_grants`) und der Token-Cache in MPA sind mit AES-256-GCM verschlüsselt. +- 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). diff --git a/docs/reference/KNOW-schema.md b/docs/reference/KNOW-schema.md index dd8b2d6..fd7ed98 100644 --- a/docs/reference/KNOW-schema.md +++ b/docs/reference/KNOW-schema.md @@ -5,7 +5,7 @@ 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)) -Befunde mit Belegen, Folgen und Vorschlägen: [KNOW-befunde.md](KNOW-befunde.md) (B1–B9) +Befunde mit Belegen, Folgen und Vorschlägen: [KNOW-befunde.md](KNOW-befunde.md) (B1–B30) ## Aufbau @@ -273,7 +273,7 @@ Ankaufsfälle (~70 Felder).
73 Spalten -`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 +`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, `confirmed_by` TEXT (Laufzeit-DDL, B6), `confirmed_at` TIMESTAMPTZ (Laufzeit-DDL, B6)
@@ -386,7 +386,7 @@ Vorschlag vs. tatsächliche Entscheidung.
13 Spalten -`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 +`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
diff --git a/docs/reference/know-ontology.ttl b/docs/reference/know-ontology.ttl index 2c0bc41..3a31375 100644 --- a/docs/reference/know-ontology.ttl +++ b/docs/reference/know-ontology.ttl @@ -650,8 +650,12 @@ know:confirmation_token_hash a owl:DatatypeProperty ; rdfs:range xsd:string . know:confirmed_at a owl:DatatypeProperty ; - rdfs:domain know:Hinweis ; - rdfs:range xsd:dateTime . + 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 . know:contact_broker a owl:DatatypeProperty ; rdfs:domain know:Ankaufsfall ; @@ -689,6 +693,10 @@ know:deadline_is_overdue a owl:DatatypeProperty ; rdfs:domain know:Ankaufsfall ; rdfs:range xsd:boolean . +know:decided_by a owl:DatatypeProperty ; + rdfs:domain know:Entscheidung ; + rdfs:range xsd:string . + know:decision_type a owl:DatatypeProperty ; rdfs:domain know:Entscheidung ; rdfs:range xsd:string . From 9442cc49d1b587945c22a86ca62f5094321e69c0 Mon Sep 17 00:00:00 2001 From: Robert Rapp Date: Tue, 13 Oct 2026 02:54:49 +0200 Subject: [PATCH 6/6] 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

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