Compare commits

...
8 Commits
Author SHA1 Message Date
Robert RappandClaude Opus 5.5 4a1bda3409 Merge main in plan/aufbau-2026-10-12: KNOW-Analyse, Ontologie, cpu_roles
ci / test (push) Failing after 22s
main (bis 13.10.) enthielt eine parallele Fassung von Task 0.4 (c81e372) sowie
KNOW-Befunde und die Ontologie. Konflikte zugunsten des Plan-Branches gelöst
(ausgerollter Stand); übernommen aus main: Ontologie (core/src/knaster/ontology,
ontology/, Tests, Router-Image), KNOW-Doku, cpu_roles im Placement samt
Kennzeichnung von pve61 und gode-pc-05/06 im Inventar und Test.
infra-stack kopiert jetzt auch ontology/ nach infra (Router-Build braucht es).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:23:22 +02:00
rorappandClaude Opus 5.5 9442cc49d1 ontology: Ontologie als Quelle im Repo, Abgleich mit n8n/KNOW in der Service-Routine
ci / test (push) Successful in 27s
- ontology/know.yaml: 49 Klassen, 66 Beziehungen, 12 Vokabulare (72 Konzepte);
  erzeugt: know.ttl (OWL + SKOS), Ansicht aus viewer.html.tmpl
- knaster.ontology: Modell + Prüfung, Export, Momentaufnahme der n8n-Workflows
  (nur GET; KNOW-Spezifikation aus dem Workflow-Code, Aufrufe inkl. Laufzeit-
  Operationen, direktes SQL), Abgleich, Übernahme von Entscheidungen
- Service-Routine Schritt 8: Verstöße als Befunde, Ontologie-Änderungen als
  Vorschläge ontology_change (immer Freigabe); „ordne P-x <konzept> zu“;
  Entscheidungen in ontology_decisions, `knaster ontology apply` schreibt sie
  in die YAML (Version +1)
- CLI: knaster ontology validate|export|snapshot|check|apply; knaster validate
  prüft Modell und Aktualität der Turtle-Datei
- Gegen echten n8n-Export geprüft: neue Befunde B31–B33 (verified/confirmed,
  KAN ruft unbekannte Operation, MPA ordnet Projektereignisse nie ein)
- SPEC-ontology, BETRIEB §6, Router-Image enthält die Ontologie

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:54:49 +02:00
rorappandClaude Opus 5.5 525b29aa6d docs: Vollanalyse der KNOW-Datenhaltung, Befunde B11–B30
ci / test (push) Successful in 28s
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 <noreply@anthropic.com>
2026-10-13 02:34:42 +02:00
rorappandClaude Opus 5.5 d9d8d4e38b docs: KNOW-Befund B10 (Absender-Report liest eingefrorene Statistik aus contacts)
ci / test (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:21:07 +02:00
rorappandClaude Opus 5.5 d0f1d31918 docs: KNOW-Befunde B1–B9 festgehalten, Ontologie als OWL/Turtle
ci / test (push) Successful in 26s
- 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 <noreply@anthropic.com>
2026-10-13 02:15:48 +02:00
rorappandClaude Opus 5.5 fdaa5a5694 docs: Referenz der n8n-Wissensdatenbank KNOW/KGW (Tabellen, Zugriffe, Auffälligkeiten)
ci / test (push) Successful in 25s
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 <noreply@anthropic.com>
2026-10-13 02:05:10 +02:00
rorappandClaude Opus 5.5 c81e372c49 sites: Inventar und Zielbild an echte Knoten (pve44–pve61) angepasst; ADR-0001/0002/0006 ergänzt
ci / test (push) Successful in 25s
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 <noreply@anthropic.com>
2026-10-13 01:39:49 +02:00
rorapp 8cf27b48ce Merge pull request 'Plan/aufbau 2026 10 12' (#1) from plan/aufbau-2026-10-12 into main
ci / test (push) Successful in 27s
Reviewed-on: #1
2026-10-12 14:18:17 +00:00
34 changed files with 6087 additions and 13 deletions
+1 -1
View File
@@ -22,6 +22,6 @@ PROXMOX_TOKEN_ID=knaster@pve!healer
PROXMOX_TOKEN_SECRET= PROXMOX_TOKEN_SECRET=
NOTIFY_WEBHOOK_URL= NOTIFY_WEBHOOK_URL=
# n8n (nur für Import/Tests) # n8n: Import/Tests und Ontologie-Abgleich (Router liest Workflows nur, GET /api/v1/workflows)
N8N_HOST= N8N_HOST=
N8N_API_KEY= N8N_API_KEY=
+1 -1
View File
@@ -13,7 +13,7 @@ test:
lint: lint:
$(VENV)/bin/ruff check core $(VENV)/bin/ruff check core
$(VENV)/bin/yamllint -s -c .yamllint.yml config catalog sites deploy/prometheus deploy/alertmanager deploy/litellm deploy/compose platform $(VENV)/bin/yamllint -s -c .yamllint.yml config catalog sites deploy/prometheus deploy/alertmanager deploy/litellm deploy/compose platform ontology
validate: validate:
$(VENV)/bin/knaster validate --root . $(VENV)/bin/knaster validate --root .
+2
View File
@@ -24,6 +24,8 @@ Anfrage → n8n → Router-Service ──▶ Stufe 3 Embedding-Classifier (CPU)
| [docs/spec/](docs/spec/) | Spezifikationen der Komponenten | | [docs/spec/](docs/spec/) | Spezifikationen der Komponenten |
| [docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md](docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md) | **Aufbauplan auf echter Hardware**: Phasen, Entscheidungen D1–D5, Zuordnung zu Planner Epic 7 | | [docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md](docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md) | **Aufbauplan auf echter Hardware**: Phasen, Entscheidungen D1–D5, Zuordnung zu Planner Epic 7 |
| [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md) | Arbeitspakete, Teststrategie, Stand der Verifikation | | [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md) | Arbeitspakete, Teststrategie, Stand der Verifikation |
| [ontology/know.yaml](ontology/know.yaml) | **Ontologie der Datenhaltung** (Quelle); Pflege und Abgleich in [SPEC-ontology](docs/spec/SPEC-ontology.md) |
| [docs/reference/KNOW-schema.md](docs/reference/KNOW-schema.md) | Bestehende n8n-Wissensdatenbank KNOW/KGW: Tabellen, wer schreibt und liest, Auffälligkeiten |
| [docs/BETRIEB.md](docs/BETRIEB.md) | Skill-Lebenszyklus, Rolle von Hermes, Pflege per Prompt, Dry-Run aufheben, Rollback | | [docs/BETRIEB.md](docs/BETRIEB.md) | Skill-Lebenszyklus, Rolle von Hermes, Pflege per Prompt, Dry-Run aufheben, Rollback |
| [n8n/README.md](n8n/README.md) | Workflows und Import | | [n8n/README.md](n8n/README.md) | Workflows und Import |
| [platform/proxmox/README.md](platform/proxmox/README.md) | Proxmox-Ablauf (OpenTofu + Ansible) | | [platform/proxmox/README.md](platform/proxmox/README.md) | Proxmox-Ablauf (OpenTofu + Ansible) |
+1
View File
@@ -18,3 +18,4 @@ actions:
promote_candidate: {mode: approve, risk: high} promote_candidate: {mode: approve, risk: high}
rollback_candidate: {mode: approve, risk: medium} rollback_candidate: {mode: approve, risk: medium}
reboot_host: {mode: approve, risk: high, window: "02:00-05:00"} reboot_host: {mode: approve, risk: high, window: "02:00-05:00"}
ontology_change: {mode: approve, risk: low}
+107 -1
View File
@@ -1,4 +1,4 @@
"""Kommandozeile: serve, validate, inventory, placement, registry export.""" """Kommandozeile: serve, validate, inventory, placement, registry export, ontology."""
from __future__ import annotations from __future__ import annotations
@@ -47,8 +47,28 @@ def main(argv: list[str] | None = None) -> int:
reg_sub = p_reg.add_subparsers(dest="reg_cmd", required=True) reg_sub = p_reg.add_subparsers(dest="reg_cmd", required=True)
reg_sub.add_parser("export", help="effektiven Stand (inkl. Laufzeitzustand) als YAML ausgeben") reg_sub.add_parser("export", help="effektiven Stand (inkl. Laufzeitzustand) als YAML ausgeben")
p_onto = sub.add_parser("ontology", help="Ontologie der Datenhaltung (SPEC-ontology)")
p_onto.add_argument("--path", default=None, help="Ontologie-Datei (Standard: ontology/know.yaml)")
onto_sub = p_onto.add_subparsers(dest="onto_cmd", required=True)
onto_sub.add_parser("validate", help="Modell prüfen")
p_exp = onto_sub.add_parser("export", help="Turtle, Graph-JSON und/oder HTML-Ansicht erzeugen")
p_exp.add_argument("--ttl")
p_exp.add_argument("--graph")
p_exp.add_argument("--html")
p_exp.add_argument("--template", default=None, help="Standard: viewer.html.tmpl neben der Ontologie")
p_snap = onto_sub.add_parser("snapshot", help="n8n-Workflows lesen und als JSON speichern (nur GET)")
p_snap.add_argument("--out", required=True)
p_chk = onto_sub.add_parser("check", help="Ontologie mit der Datenhaltung in n8n abgleichen")
p_chk.add_argument("--snapshot", help="Workflows aus Datei statt aus der n8n-API")
p_chk.add_argument("--json", action="store_true")
p_app = onto_sub.add_parser("apply", help="freigegebene Entscheidungen in die Ontologie übernehmen")
p_app.add_argument("--dry-run", action="store_true")
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.cmd == "ontology":
return _ontology(args)
if args.cmd == "serve": if args.cmd == "serve":
import uvicorn import uvicorn
@@ -104,5 +124,91 @@ def main(argv: list[str] | None = None) -> int:
return 2 return 2
def _ontology(args: argparse.Namespace) -> int:
import json
from .ontology import (
Ontology,
apply_decisions,
build_snapshot,
check,
fetch_workflows,
to_graph,
to_html,
to_turtle,
)
from .settings import Settings
settings = Settings.from_env()
path = Path(args.path) if args.path else settings.ontology_path
onto = Ontology.from_file(path)
if args.onto_cmd == "validate":
errors = onto.validate_model()
for e in errors:
print(f"ERROR {e}")
print(f"{len(onto.classes)} Klassen, {len(onto.relations)} Beziehungen, {len(onto.vocabularies)} Vokabulare, "
f"{len(errors)} Fehler")
return 1 if errors else 0
if args.onto_cmd == "export":
if args.ttl:
Path(args.ttl).write_text(to_turtle(onto), encoding="utf-8")
if args.graph:
Path(args.graph).write_text(json.dumps(to_graph(onto), ensure_ascii=False, indent=1), encoding="utf-8")
if args.html:
template = Path(args.template) if args.template else path.parent / "viewer.html.tmpl"
Path(args.html).write_text(to_html(onto, template), encoding="utf-8")
return 0
if args.onto_cmd in {"snapshot", "check"}:
if getattr(args, "snapshot", None):
data = json.loads(Path(args.snapshot).read_text(encoding="utf-8"))
workflows = data.get("data", data) if isinstance(data, dict) else data
else:
if not (settings.n8n_host and settings.n8n_api_key):
print("N8N_HOST und N8N_API_KEY fehlen (oder --snapshot angeben)", file=sys.stderr)
return 2
workflows = fetch_workflows(settings.n8n_host, settings.n8n_api_key)
if args.onto_cmd == "snapshot":
Path(args.out).write_text(json.dumps({"data": workflows}, ensure_ascii=False), encoding="utf-8")
print(f"{len(workflows)} Workflows gespeichert in {args.out} (enthält Workflow-Code, nicht committen)")
return 0
from .store import Store
decided = {d["key"] for d in Store(settings.db_url).list_ontology_decisions()}
findings = check(onto, build_snapshot(workflows), decided=decided)
if args.json:
print(json.dumps([f.model_dump() for f in findings], ensure_ascii=False, indent=1))
else:
for f in findings:
mark = "→ Vorschlag" if f.change else ""
print(f"{f.severity.upper():7} {f.summary} {mark}".rstrip())
print(f"{len(findings)} Befunde, davon {sum(1 for f in findings if f.change)} Änderungsvorschläge")
return 1 if any(f.severity == "high" for f in findings) else 0
if args.onto_cmd == "apply":
from .store import Store
store = Store(settings.db_url)
pending = store.list_ontology_decisions(applied=False)
applied, messages = apply_decisions(onto, pending)
for m in messages:
print(m)
errors = onto.validate_model()
if errors:
print("Ontologie wäre ungültig, nichts gespeichert:\n" + "\n".join(errors), file=sys.stderr)
return 1
if applied and not args.dry_run:
header = "".join(line + "\n" for line in path.read_text(encoding="utf-8").splitlines()
if line.startswith("#"))
onto.save(path, header)
store.mark_ontology_applied(applied)
print(f"{len(applied)} Entscheidungen übernommen, Version {onto.version}. "
"Danach `knaster ontology export` und committen.")
return 0
return 2
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())
+1 -1
View File
@@ -182,7 +182,7 @@ def plan(nodes: list[NodeProfile], roles: list[RoleSpec], models: list[ModelSpec
missing = role.count - placed missing = role.count - placed
if missing and (not role.needs.gpu or role.cpu_fallback): 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" 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 # bevorzugt Nodes ohne GPU-Rollen, dann wenig belastete, dann viel RAM
gpu_roles = {a.node for a in result.assignments if a.mode == "gpu"} 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)) candidates.sort(key=lambda n: (n.name in gpu_roles, cpu_load[n.name], -n.memory_gb, n.name))
+1
View File
@@ -43,6 +43,7 @@ class NodeProfile(BaseModel):
disks: list[Disk] = Field(default_factory=list) disks: list[Disk] = Field(default_factory=list)
gpus: list[GPU] = Field(default_factory=list) gpus: list[GPU] = Field(default_factory=list)
labels: dict[str, str] = Field(default_factory=dict) 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", NVIDIA_SMI_QUERY = ["nvidia-smi", "--query-gpu=index,name,memory.total,compute_cap,driver_version",
+11
View File
@@ -101,6 +101,17 @@ class ActionExecutor:
self.registry.set_calibrated_context(slot, value) self.registry.set_calibrated_context(slot, value)
return f"Slot {slot}: effektiver Kontext = {value} Tokens" return f"Slot {slot}: effektiver Kontext = {value} Tokens"
# --- Ontologie --------------------------------------------------------------------------
def _do_ontology_change(self, key: str, type: str, decision: str = "accept", decided_by: str = "human",
**params: Any) -> str:
if decision not in {"accept", "map"}:
raise ValueError(f"Entscheidung {decision} unbekannt")
if decision == "map" and not params.get("concept"):
raise ValueError("Zuordnung braucht ein Konzept")
self.store.set_ontology_decision(key, decision, {"type": type, **params}, decided_by)
target = f" → {params['concept']}" if decision == "map" else ""
return f"Entscheidung gespeichert ({decision}{target}); in die Ontologie mit `knaster ontology apply`"
# --- Lifecycle -------------------------------------------------------------------------- # --- Lifecycle --------------------------------------------------------------------------
def _do_create_candidate(self, kind: str, slot: str, target: dict[str, Any], **_: Any) -> str: def _do_create_candidate(self, kind: str, slot: str, target: dict[str, Any], **_: Any) -> str:
cid = self.lifecycle.create(kind, slot, target) # type: ignore[arg-type] cid = self.lifecycle.create(kind, slot, target) # type: ignore[arg-type]
+39 -2
View File
@@ -35,6 +35,7 @@ class MaintenanceConfig(BaseModel):
adapter_min_cases: int = 300 adapter_min_cases: int = 300
adapter_target_rate: float = 0.90 adapter_target_rate: float = 0.90
lookback_days: float = 7.0 lookback_days: float = 7.0
ontology_max_findings: int = 15
@classmethod @classmethod
def from_file(cls, path: str | Path) -> MaintenanceConfig: def from_file(cls, path: str | Path) -> MaintenanceConfig:
@@ -83,7 +84,8 @@ HELP = """Befehle:
- status · hilfe · routine · vorschläge - status · hilfe · routine · vorschläge
- genehmige P-3 [P-5 …] · genehmige alle mit risiko niedrig · lehne P-3 ab: <Grund> - genehmige P-3 [P-5 …] · genehmige alle mit risiko niedrig · lehne P-3 ab: <Grund>
- wechsle <slot> auf <basismodell> · trainiere adapter für <cluster> · rollback <slot> - wechsle <slot> auf <basismodell> · trainiere adapter für <cluster> · rollback <slot>
- stufe <skill> zurück · markiere <slot> als ok|degraded|unavailable""" - stufe <skill> zurück · markiere <slot> als ok|degraded|unavailable
- ordne P-7 <konzept> zu (Vokabel-Vorschlag einem vorhandenen Konzept zuordnen)"""
class LLMIntentMapper: class LLMIntentMapper:
@@ -120,6 +122,7 @@ COMMANDS: list[tuple[str, re.Pattern[str]]] = [
("rollback", r"^rollback\s+(?P<slot>[\w.-]+)$"), ("rollback", r"^rollback\s+(?P<slot>[\w.-]+)$"),
("demote", r"^(stufe\s+(?P<skill>[\w.-]+)\s+zurück|degradiere\s+(?P<skill2>[\w.-]+))$"), ("demote", r"^(stufe\s+(?P<skill>[\w.-]+)\s+zurück|degradiere\s+(?P<skill2>[\w.-]+))$"),
("mark", r"^markiere\s+(?P<slot>[\w.-]+)\s+(als\s+)?(?P<status>ok|degraded|unavailable)$"), ("mark", r"^markiere\s+(?P<slot>[\w.-]+)\s+(als\s+)?(?P<status>ok|degraded|unavailable)$"),
("map_concept", rf"^ordne\s+(?P<id>{_ID})\s+(dem\s+konzept\s+)?(?P<concept>[\w.-]+)\s+zu$"),
("confirm", r"^(ja|yes|j|ok)$"), ("confirm", r"^(ja|yes|j|ok)$"),
("cancel", r"^(nein|no|n|abbrechen)$"), ("cancel", r"^(nein|no|n|abbrechen)$"),
] ]
@@ -143,6 +146,7 @@ class MaintenanceAgent:
def __init__(self, *, store: Store, registry: Registry, library: SkillLibrary, index: SkillIndex, def __init__(self, *, store: Store, registry: Registry, library: SkillLibrary, index: SkillIndex,
policy: Policy, actions: ActionExecutor, lifecycle: Lifecycle, policy: Policy, actions: ActionExecutor, lifecycle: Lifecycle,
config: MaintenanceConfig | None = None, intent_mapper: IntentMapper | None = None, config: MaintenanceConfig | None = None, intent_mapper: IntentMapper | None = None,
ontology_check: Callable[[], list[Any]] | None = None,
clock: Callable[[], float] = now, wallclock: Callable[[], datetime] = datetime.now): clock: Callable[[], float] = now, wallclock: Callable[[], datetime] = datetime.now):
self.store = store self.store = store
self.registry = registry self.registry = registry
@@ -153,6 +157,7 @@ class MaintenanceAgent:
self.lifecycle = lifecycle self.lifecycle = lifecycle
self.config = config or MaintenanceConfig() self.config = config or MaintenanceConfig()
self.intent_mapper = intent_mapper self.intent_mapper = intent_mapper
self.ontology_check = ontology_check
self.clock = clock self.clock = clock
self.wallclock = wallclock self.wallclock = wallclock
self._pending: dict[str, str] = {} # Sitzung → zu bestätigender Befehl self._pending: dict[str, str] = {} # Sitzung → zu bestätigender Befehl
@@ -197,8 +202,9 @@ class MaintenanceAgent:
def _execute(self, seq: int) -> Proposal: def _execute(self, seq: int) -> Proposal:
p = self._load(seq) p = self._load(seq)
params = {**p.params, "decided_by": p.decided_by} if p.kind == "ontology_change" else p.params
try: try:
result = self.actions.execute(p.kind, p.params) result = self.actions.execute(p.kind, params)
self.store.update_proposal(seq, state="executed", result=result) self.store.update_proposal(seq, state="executed", result=result)
except ActionError as exc: except ActionError as exc:
self.store.update_proposal(seq, state="failed", result={"ok": False, "message": str(exc)}) self.store.update_proposal(seq, state="failed", result={"ok": False, "message": str(exc)})
@@ -212,6 +218,9 @@ class MaintenanceAgent:
result={"message": reason or "abgelehnt"}) result={"message": reason or "abgelehnt"})
if p.kind == "promote_candidate": if p.kind == "promote_candidate":
self.lifecycle.reject(p.params["candidate"], f"abgelehnt: {reason}") self.lifecycle.reject(p.params["candidate"], f"abgelehnt: {reason}")
if p.kind == "ontology_change":
params = {k: v for k, v in p.params.items() if k != "key"}
self.store.set_ontology_decision(p.params["key"], "reject", params, by)
return self._load(seq) return self._load(seq)
def run_deferred(self) -> list[Proposal]: def run_deferred(self) -> list[Proposal]:
@@ -318,6 +327,24 @@ class MaintenanceAgent:
f"{cand['kind']} für {cand['slot']} übernehmen ({cand['target']})", f"{cand['kind']} für {cand['slot']} übernehmen ({cand['target']})",
reason="alle Prüfstufen bestanden", source="lifecycle")) reason="alle Prüfstufen bestanden", source="lifecycle"))
# 8. Ontologie ↔ Datenhaltung (SPEC-ontology §3)
if self.ontology_check is not None:
try:
ontology_findings = self.ontology_check()
except Exception as exc: # noqa: BLE001 – externe Quelle (n8n-API) darf die Routine nicht abbrechen
report.findings.append(f"Ontologie-Abgleich nicht möglich: {type(exc).__name__}: {exc}")
ontology_findings = []
violations = [f for f in ontology_findings if not f.change]
for f in violations[:c.ontology_max_findings]:
report.findings.append(f"Ontologie [{f.severity}]: {f.summary}")
if len(violations) > c.ontology_max_findings:
report.findings.append(f"Ontologie: {len(violations) - c.ontology_max_findings} weitere Befunde "
"(knaster ontology check)")
for f in ontology_findings:
if f.change:
add(self.propose("ontology_change", {"key": f.key, **f.change}, f.summary, reason=f.detail,
dedup_key=f"ontology:{f.key}"))
for p in self.run_deferred(): for p in self.run_deferred():
report.auto_executed.append(f"{p.id} im Wartungsfenster ausgeführt: {p.summary}") report.auto_executed.append(f"{p.id} im Wartungsfenster ausgeführt: {p.summary}")
@@ -446,3 +473,13 @@ class MaintenanceAgent:
return self._new("mark_slot", {"slot": args["slot"], "status": args["status"].lower(), return self._new("mark_slot", {"slot": args["slot"], "status": args["status"].lower(),
"reason": f"Auftrag von {user}", "set_by": user}, "reason": f"Auftrag von {user}", "set_by": user},
f"Slot {args['slot']} als {args['status'].lower()} markieren", user) f"Slot {args['slot']} als {args['status'].lower()} markieren", user)
def _cmd_map_concept(self, args: dict[str, str], user: str) -> CommandReply:
p = self._load(_seq(args["id"]))
if p.kind != "ontology_change" or p.params.get("type") != "vocab_value":
raise ValueError(f"{p.id} ist kein Vokabel-Vorschlag")
if p.state != "open":
raise ValueError(f"{p.id} ist {p.state}")
self.store.update_proposal(p.seq, params={**p.params, "decision": "map", "concept": args["concept"]})
done = self.approve(p.seq, by=user)
return CommandReply(text=self._decided_text(done), proposals=[done])
+10
View File
@@ -0,0 +1,10 @@
"""Ontologie der Datenhaltung: Modell, Export, Abgleich mit n8n, Übernahme freigegebener Entscheidungen."""
from .apply import apply_decisions
from .check import OntologyFinding, check
from .export import to_graph, to_html, to_turtle
from .model import Ontology
from .n8n import Snapshot, build_snapshot, fetch_workflows
__all__ = ["Ontology", "OntologyFinding", "Snapshot", "apply_decisions", "build_snapshot", "check",
"fetch_workflows", "to_graph", "to_html", "to_turtle"]
+78
View File
@@ -0,0 +1,78 @@
"""Freigegebene Entscheidungen in die Ontologie übernehmen (`knaster ontology apply`)."""
from __future__ import annotations
import re
from typing import Any
from .model import Concept, Ontology, OntologyClass
NEW_MODULE = "neu"
def _slug(value: str) -> str:
text = value.strip().lower()
for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss"), ("+", "_"), ("&", "_")):
text = text.replace(a, b)
return re.sub(r"_+", "_", re.sub(r"[^a-z0-9]+", "_", text)).strip("_") or "wert"
def _class_id(table: str) -> str:
return "".join(part.capitalize() for part in table.split("_"))
def apply_decision(onto: Ontology, key: str, decision: str, params: dict[str, Any]) -> str:
"""Eine Entscheidung anwenden. decision ∈ {accept, map, reject}. Gibt eine Beschreibung zurück."""
change = params.get("type")
if decision == "reject":
if change == "vocab_value":
v = onto.vocabularies[params["vocabulary"]]
if not v.is_rejected(params["value"]):
v.rejected.append(params["value"])
return f"{v.label}: „{params['value']}“ verworfen"
if key not in onto.ignore:
onto.ignore.append(key)
return f"{key} wird künftig ignoriert"
if change == "vocab_value":
v = onto.vocabularies[params["vocabulary"]]
value = params["value"]
if decision == "map":
target = v.resolve(params["concept"])
if target is None:
raise KeyError(f"Konzept {params['concept']} gibt es in {v.label} nicht")
if value.lower() not in (a.lower() for a in v.concepts[target].alt):
v.concepts[target].alt.append(value)
return f"{v.label}: „{value}“ → {target}"
cid = _slug(value)
if cid in v.concepts:
raise ValueError(f"{v.label}: Konzept {cid} existiert bereits")
v.concepts[cid] = Concept(label=value if value != cid else "")
return f"{v.label}: neues Konzept {cid}"
if change == "add_attribute":
onto.classes[params["class"]].attributes[params["column"]] = params["sql_type"]
return f"{params['class']}: Attribut {params['column']} ({params['sql_type']})"
if change == "add_class":
cid = _class_id(params["table"])
if cid in onto.classes:
raise ValueError(f"Klasse {cid} existiert bereits")
onto.modules.setdefault(NEW_MODULE, "Noch nicht zugeordnet")
onto.classes[cid] = OntologyClass(label=params["table"], module=NEW_MODULE, table=params["table"],
definition="Automatisch aufgenommen; Definition und Beziehungen ergänzen.",
attributes=dict(params.get("attributes") or {}))
return f"neue Klasse {cid} für Tabelle {params['table']}"
raise ValueError(f"Unbekannte Änderung {change}")
def apply_decisions(onto: Ontology, decisions: list[dict[str, Any]]) -> tuple[list[str], list[str]]:
"""→ (übernommene Schlüssel, Meldungen). Fehlerhafte Entscheidungen bleiben offen und erscheinen als Meldung."""
applied, messages = [], []
for d in decisions:
try:
messages.append(apply_decision(onto, d["key"], d["decision"], d.get("params") or {}))
applied.append(d["key"])
except (KeyError, ValueError) as exc:
messages.append(f"{d['key']}: nicht übernommen ({exc})")
if applied:
onto.version += 1
return applied, messages
+177
View File
@@ -0,0 +1,177 @@
"""Abgleich Ontologie ↔ tatsächliche Datenhaltung in n8n (SPEC-ontology §3).
Zwei Arten von Ergebnissen:
- Befunde mit `change`: die Ontologie sollte wachsen (neuer Vokabelwert, neue Spalte, neue Tabelle).
Daraus werden Vorschläge zur Freigabe.
- Befunde ohne `change`: die Datenhaltung verstößt gegen die Ontologie bzw. gegen KNOW
(unbekannte Operation, SQL an KNOW vorbei, Konzept ohne Erzeuger, Tabelle ohne Schreiber).
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any, Literal
from pydantic import BaseModel
from .model import Ontology
from .n8n import Snapshot, is_closed, literal_values, value_segments
Severity = Literal["info", "low", "medium", "high"]
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}
class OntologyFinding(BaseModel):
key: str
kind: str
severity: Severity
summary: str
detail: str = ""
change: dict[str, Any] | None = None
class _Obs:
"""Beobachtete Werte eines Vokabulars: Wert → Quellen, dazu welche Quellen nur feste Werte liefern."""
def __init__(self) -> None:
self.values: dict[str, set[str]] = {}
self.closed: set[str] = set() # Quellen mit geschlossener Wertemenge (aktiv)
self.open: set[str] = set() # Quellen mit freien Werten (aktiv)
self.produced: set[str] = set() # Werte aus aktiven Quellen
def add(self, value: str, source: str, active: bool) -> None:
self.values.setdefault(value, set()).add(source)
if active:
self.produced.add(value)
def _vocab_observations(onto: Ontology, snap: Snapshot) -> dict[str, _Obs]:
assert snap.know is not None
by_column = onto.vocab_columns()
obs: dict[str, _Obs] = {}
for call in snap.calls:
if not call.operation:
continue
for table, cols in snap.know.writes.get(call.operation, {}).items():
for col in cols:
ref = f"{table}.{col}"
for vid in by_column.get(ref, []):
o = obs.setdefault(vid, _Obs())
label = f"{call.workflow}{'' if call.active else ' (inaktiv)'}"
for value in snap.know.assigned.get(call.operation, {}).get(ref, set()):
o.add(value, f"KNOW {call.operation} (aufgerufen von {label})", call.active)
if call.inferred or not call.payload:
continue
values = literal_values(call.payload, col)
for value in values:
o.add(value, f"{label} → {call.operation}.{col}", call.active)
if call.active and value_segments(call.payload, col):
(o.closed if is_closed(call.payload, col) else o.open).add(call.workflow)
for ref, values in snap.know.seeds.items():
for vid in by_column.get(ref, []):
for value in values:
obs.setdefault(vid, _Obs()).add(value, f"KNOW-Seed {ref}", False)
return obs
def check(onto: Ontology, snap: Snapshot, decided: Iterable[str] = ()) -> list[OntologyFinding]:
skip = set(decided) | set(onto.ignore)
out: list[OntologyFinding] = []
know = snap.know
if know is None:
return [OntologyFinding(key="know:missing", kind="know_missing", severity="high",
summary="KNOW-Workflow nicht gefunden (Code mit `const allowed` und Schema)")]
# 1. Schema ↔ Ontologie
mapped = onto.tables()
for table, cols in sorted(know.schema.items()):
cid = mapped.get(table)
if cid is None:
out.append(OntologyFinding(
key=f"table:{table}", kind="unmapped_table", severity="medium",
summary=f"Tabelle {table} ({len(cols)} Spalten) fehlt in der Ontologie",
detail="Als neue Klasse aufnehmen; Modul, Definition und Beziehungen danach im Repo ergänzen.",
change={"type": "add_class", "table": table, "attributes": dict(cols)}))
continue
attrs = onto.classes[cid].attributes
for col, typ in cols.items():
if col not in attrs:
out.append(OntologyFinding(
key=f"column:{table}.{col}", kind="unmapped_column", severity="low",
summary=f"Neue Spalte {table}.{col} ({typ}) – als Attribut von {cid} aufnehmen?",
change={"type": "add_attribute", "class": cid, "column": col, "sql_type": typ}))
for col in attrs:
if col not in cols:
out.append(OntologyFinding(key=f"gone:{table}.{col}", kind="missing_column", severity="low",
summary=f"Attribut {cid}.{col} gibt es in KNOW nicht (mehr)"))
for cid, c in onto.classes.items():
if c.table and c.table not in know.schema:
out.append(OntologyFinding(key=f"gone:{c.table}", kind="missing_table", severity="medium",
summary=f"Klasse {cid} verweist auf Tabelle {c.table}, die KNOW nicht anlegt"))
# 2. Aufrufe unbekannter Operationen (pro Workflow und Operation zusammengefasst)
unknown: dict[tuple[str, str], list[str]] = {}
active_of: dict[str, bool] = {}
for call in snap.calls:
if call.operation and not call.inferred and call.operation not in know.allowed:
unknown.setdefault((call.workflow, call.operation), []).append(call.node)
active_of[call.workflow] = call.active
for (wf, op), nodes in sorted(unknown.items()):
out.append(OntologyFinding(
key=f"op:{wf}:{op}", kind="unknown_operation", severity="high" if active_of[wf] else "low",
summary=f"{wf}{'' if active_of[wf] else ' (inaktiv)'} ruft `{op}` auf, die Operation gibt es in KNOW nicht",
detail=f"Knoten: {', '.join(sorted(set(nodes)))}. KNOW lehnt mit „Nicht erlaubte KNOW operation“ ab."))
# 3. SQL an KNOW vorbei
for d in snap.direct_sql:
out.append(OntologyFinding(
key=f"sql:{d.workflow}:{d.node}", kind="direct_sql",
severity=("medium" if d.writes else "low") if d.active else "info",
summary=f"{d.workflow} {'schreibt' if d.writes else 'liest'} per eigenem SQL in "
f"{', '.join(sorted(d.tables))} (an KNOW vorbei)",
detail=f"Knoten „{d.node}“"))
# 4. Vokabulare: neue Werte und Konzepte, die keine feste Zuordnung erzeugt
obs = _vocab_observations(onto, snap)
for vid, o in sorted(obs.items()):
v = onto.vocabularies[vid]
for value, sources in sorted(o.values.items()):
if v.resolve(value) or v.is_rejected(value):
continue
only_inactive = all("(inaktiv)" in s_ for s_ in sources)
out.append(OntologyFinding(
key=f"vocab:{vid}:{value.lower()}", kind="vocab_value", severity="low" if only_inactive else "medium",
summary=f"{v.label}: neuer Wert „{value}“",
detail="Quelle: " + "; ".join(sorted(sources)) + ". Als Konzept aufnehmen, einem Konzept "
"zuordnen („ordne P-x <konzept> zu“) oder verwerfen.",
change={"type": "vocab_value", "vocabulary": vid, "value": value, "sources": sorted(sources)}))
if o.closed:
produced = {v.resolve(x) for x in o.produced}
missing = [cid for cid, c in v.concepts.items() if cid not in produced and not c.technical]
if missing:
extra = f"; freie Werte kommen zusätzlich aus {', '.join(sorted(o.open))}" if o.open else ""
out.append(OntologyFinding(
key=f"unused:{vid}", kind="concept_without_producer", severity="info",
summary=f"{v.label}: {', '.join(missing)} erzeugt keine feste Zuordnung "
f"(geprüft: {', '.join(sorted(o.closed))}{extra})"))
# 5. Tabellenklassen ohne aktiven Schreibweg
active_ops = {c.operation for c in snap.calls if c.active and c.operation} # inkl. abgeleiteter
for cid, c in onto.classes.items():
if not c.table or c.table not in know.schema:
continue
writers = {op for op, tables in know.writes.items() if c.table in tables and op != "setup_schema"}
seeded = any(ref.startswith(f"{c.table}.") for ref in know.seeds)
if not writers and seeded:
continue # Konfigurationstabelle, die setup_schema befüllt
if not writers:
out.append(OntologyFinding(key=f"nowriter:{c.table}", kind="no_write_operation", severity="info",
summary=f"{cid} ({c.table}): KNOW hat keine Schreib-Operation"))
elif not writers & active_ops:
out.append(OntologyFinding(key=f"nowriter:{c.table}", kind="no_active_writer", severity="info",
summary=f"{cid} ({c.table}): kein aktiver Workflow ruft "
f"{', '.join(sorted(writers))} auf"))
out = [f for f in out if f.key not in skip]
out.sort(key=lambda f: (SEVERITY_ORDER[f.severity], f.kind, f.key))
return out
+133
View File
@@ -0,0 +1,133 @@
"""Erzeugte Artefakte aus der Ontologie: OWL/Turtle, Graph-JSON für die Ansicht, eigenständige HTML-Seite."""
from __future__ import annotations
import json
import re
from datetime import date
from pathlib import Path
from typing import Any
from .model import Ontology
XSD = {"TEXT": "xsd:string", "NUMERIC": "xsd:decimal", "INTEGER": "xsd:integer", "BIGINT": "xsd:integer",
"SMALLINT": "xsd:integer", "BIGSERIAL": "xsd:integer", "BOOLEAN": "xsd:boolean", "DATE": "xsd:date",
"TIMESTAMPTZ": "xsd:dateTime", "JSONB": "rdf:JSON", "DOUBLE PRECISION": "xsd:double"}
BOOKKEEPING = {"id", "created_at", "updated_at"}
def _lit(text: str) -> str:
return '"' + text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ") + '"@de'
def _local(text: str) -> str:
text = (text.replace("+", "plus").replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
.replace("Ä", "Ae").replace("Ö", "Oe").replace("Ü", "Ue").replace("ß", "ss"))
return re.sub(r"[^A-Za-z0-9_]", "_", text)
def _union(ids: list[str], prefix: str) -> str:
if len(ids) == 1:
return f"{prefix}:{ids[0]}"
return "[ a owl:Class ; owl:unionOf ( " + " ".join(f"{prefix}:{i}" for i in ids) + " ) ]"
def relation_columns(onto: Ontology, cid: str) -> set[str]:
"""Spalten einer Tabellenklasse, über die eine Beziehung läuft (keine Attribute im engeren Sinn)."""
c = onto.classes[cid]
cols: set[str] = set()
for r in onto.relations:
if r.source == cid and c.table:
cols |= set(re.findall(r"\b([a-z_]+)\b", r.via.split("=")[0])) & set(c.attributes)
return cols
def to_turtle(onto: Ontology) -> str:
p = onto.id
out = [f"@prefix {p}: <{onto.namespace}> .", "@prefix owl: <http://www.w3.org/2002/07/owl#> .",
"@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .",
"@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .",
"@prefix skos: <http://www.w3.org/2004/02/skos/core#> .",
"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .", "",
f"<{onto.namespace.rstrip('#/')}> a owl:Ontology ;",
f" rdfs:label {_lit(onto.label or onto.id)} ;",
f' owl:versionInfo "{onto.version}" .', "",
f"{p}:quelleTabelle a owl:AnnotationProperty .", f"{p}:viaSpalte a owl:AnnotationProperty .", ""]
for cid, c in onto.classes.items():
lines = [f"{p}:{cid} a owl:Class", f" rdfs:label {_lit(c.label)}"]
if c.definition:
lines.append(f" rdfs:comment {_lit(c.definition)}")
if c.table:
lines.append(f' {p}:quelleTabelle "{c.table}"')
if c.subclass_of:
lines.append(f" rdfs:subClassOf {p}:{c.subclass_of}")
out.append(" ;\n".join(lines) + " .\n")
props: dict[str, dict[str, list[str]]] = {}
for r in onto.relations:
e = props.setdefault(r.name, {"dom": [], "rng": [], "via": []})
for k, v in (("dom", r.source), ("rng", r.target)):
if v not in e[k]:
e[k].append(v)
e["via"].append(f"{r.source}→{r.target}: {r.via}" if r.via else f"{r.source}→{r.target}")
for name, e in props.items():
out.append(f"{p}:{name} a owl:ObjectProperty ;\n rdfs:domain {_union(e['dom'], p)} ;\n"
f" rdfs:range {_union(e['rng'], p)} ;\n"
+ " ;\n".join(f" {p}:viaSpalte {_lit(v)}" for v in e["via"]) + " .\n")
dprops: dict[str, dict[str, Any]] = {}
for cid, c in onto.classes.items():
skip = relation_columns(onto, cid) | BOOKKEEPING
for col, typ in c.attributes.items():
if col in skip:
continue
d = dprops.setdefault(col, {"dom": [], "rng": XSD.get(typ.split("(")[0].strip(), "xsd:string")})
d["dom"].append(cid)
for col, d in sorted(dprops.items()):
out.append(f"{p}:{_local(col)} a owl:DatatypeProperty ;\n rdfs:domain {_union(d['dom'], p)} ;\n"
f" rdfs:range {d['rng']} .\n")
for vid, v in onto.vocabularies.items():
scheme = f"{p}:{_local(vid)}Schema"
out.append(f"{scheme} a skos:ConceptScheme ;\n rdfs:label {_lit(v.label)} .\n")
for cid, c in v.concepts.items():
types = f"skos:Concept, {p}:{v.class_}" if v.class_ else "skos:Concept"
lines = [f"{p}:{_local(vid)}_{_local(cid)} a {types}", f" skos:inScheme {scheme}",
f" skos:notation \"{cid}\"", f" skos:prefLabel {_lit(c.label or cid)}"]
lines += [f" skos:altLabel {_lit(a)}" for a in c.alt]
if c.note:
lines.append(f" skos:scopeNote {_lit(c.note)}")
out.append(" ;\n".join(lines) + " .\n")
return "\n".join(out)
def to_graph(onto: Ontology, generated: str | None = None) -> dict[str, Any]:
"""Datenformat der Ansicht (ontology/viewer.html.tmpl)."""
enums: dict[str, dict[str, list[str]]] = {}
individuals: dict[str, list[str]] = {}
for v in onto.vocabularies.values():
values = [c.label or cid for cid, c in v.concepts.items()]
if v.class_:
individuals.setdefault(v.class_, []).extend(x for x in values if x not in individuals.get(v.class_, []))
else:
for ref in v.columns:
table, _, col = ref.partition(".")
owner = onto.class_for_table(table)
if owner:
enums.setdefault(owner, {})[col] = list(v.concepts)
classes = []
for cid, c in onto.classes.items():
skip = relation_columns(onto, cid) | BOOKKEEPING
classes.append({"id": cid, "label": c.label, "module": c.module, "table": c.table, "def": c.definition,
"key": c.key or "–", "attrs": [[k, t] for k, t in c.attributes.items() if k not in skip],
"individuals": individuals.get(cid, []), "enums": enums.get(cid, {}), "sens": c.sensitive,
"abstract": c.abstract})
relations = [r.model_dump() for r in onto.relations]
relations += [{"source": cid, "name": "subClassOf", "target": c.subclass_of, "via": "", "kind": "subclass"}
for cid, c in onto.classes.items() if c.subclass_of]
return {"classes": classes, "relations": relations, "version": onto.version,
"generated": generated or date.today().isoformat()}
def to_html(onto: Ontology, template: str | Path, generated: str | None = None) -> str:
tmpl = Path(template).read_text(encoding="utf-8")
data = json.dumps(to_graph(onto, generated), ensure_ascii=False).replace("</", "<\\/")
ttl = json.dumps(to_turtle(onto), ensure_ascii=False).replace("</", "<\\/")
return tmpl.replace("__DATA__", data).replace("__TTL__", ttl)
+148
View File
@@ -0,0 +1,148 @@
"""Ontologie als Quelle der Wahrheit: Kern (Klassen, Beziehungen), Vokabulare, Abbildung auf Tabellen.
Drei Ebenen mit unterschiedlicher Pflege (SPEC-ontology §1):
- Kern: bewusst gepflegt, Änderungen per Commit.
- Vokabulare: wachsen über Vorschläge aus der Service-Routine (beobachtet → vorgeschlagen → bestätigt).
- Instanzen: leben in den Daten, nicht in dieser Datei.
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, Field
from ..util import load_yaml
RelationKind = Literal["key", "soft", "external", "subclass"]
class OntologyClass(BaseModel):
label: str
module: str
definition: str = ""
table: str | None = None # None = implizit (nur als Wert in Spalten) oder externes System
key: str = ""
sensitive: str = ""
abstract: bool = False
subclass_of: str | None = None
attributes: dict[str, str] = Field(default_factory=dict) # Spalte → SQL-Typ (Tabellenklassen)
class Relation(BaseModel):
source: str
name: str
target: str
via: str = ""
kind: RelationKind = "key"
class Concept(BaseModel):
label: str = ""
alt: list[str] = Field(default_factory=list) # weitere Schreibweisen, die auf dieses Konzept zeigen
note: str = ""
technical: bool = False # kein fachlicher Wert (z. B. Rückfall „default“); zählt nicht als „fehlt“
class Vocabulary(BaseModel):
label: str
class_: str | None = Field(default=None, alias="class")
columns: list[str] = Field(default_factory=list) # "tabelle.spalte", in denen Werte dieses Vokabulars stehen
concepts: dict[str, Concept] = Field(default_factory=dict)
rejected: list[str] = Field(default_factory=list) # bewusst verworfene Werte (kein Konzept)
model_config = {"populate_by_name": True}
def resolve(self, value: str) -> str | None:
"""Wert → Konzept-ID (über ID, Label oder alternative Schreibweise), sonst None."""
v = value.strip().lower()
for cid, c in self.concepts.items():
if v == cid.lower() or v == c.label.lower() or v in (a.lower() for a in c.alt):
return cid
return None
def is_rejected(self, value: str) -> bool:
return value.strip().lower() in (r.lower() for r in self.rejected)
class Ontology(BaseModel):
id: str = "know"
label: str = ""
namespace: str = "urn:example:onto#"
version: int = 1
modules: dict[str, str] = Field(default_factory=dict)
classes: dict[str, OntologyClass] = Field(default_factory=dict)
relations: list[Relation] = Field(default_factory=list)
vocabularies: dict[str, Vocabulary] = Field(default_factory=dict)
ignore: list[str] = Field(default_factory=list) # bewusst hingenommene Befunde (Schlüssel aus `check`)
@classmethod
def from_file(cls, path: str | Path) -> Ontology:
return cls(**load_yaml(path))
def save(self, path: str | Path, header: str = "") -> None:
data = {"id": self.id, "version": self.version, **self.model_dump(by_alias=True, exclude_defaults=True)}
text = yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=120)
Path(path).write_text(header + text, encoding="utf-8")
# --- Abfragen ---------------------------------------------------------------------------
def class_for_table(self, table: str) -> str | None:
return next((cid for cid, c in self.classes.items() if c.table == table), None)
def tables(self) -> dict[str, str]:
return {c.table: cid for cid, c in self.classes.items() if c.table}
def vocabularies_for(self, table: str, column: str) -> list[str]:
ref = f"{table}.{column}"
return [vid for vid, v in self.vocabularies.items() if ref in v.columns]
def vocab_columns(self) -> dict[str, list[str]]:
out: dict[str, list[str]] = {}
for vid, v in self.vocabularies.items():
for ref in v.columns:
out.setdefault(ref, []).append(vid)
return out
# --- Prüfung ----------------------------------------------------------------------------
def validate_model(self) -> list[str]:
errors: list[str] = []
for cid, c in self.classes.items():
if c.module not in self.modules:
errors.append(f"Klasse {cid}: Modul {c.module} unbekannt")
if c.subclass_of and c.subclass_of not in self.classes:
errors.append(f"Klasse {cid}: Oberklasse {c.subclass_of} unbekannt")
if c.attributes and not c.table:
errors.append(f"Klasse {cid}: Attribute ohne Tabelle")
seen_tables: dict[str, str] = {}
for cid, c in self.classes.items():
if c.table:
if c.table in seen_tables:
errors.append(f"Tabelle {c.table} ist {seen_tables[c.table]} und {cid} zugeordnet")
seen_tables[c.table] = cid
for r in self.relations:
for end in (r.source, r.target):
if end not in self.classes:
errors.append(f"Beziehung {r.source} {r.name} {r.target}: Klasse {end} unbekannt")
for vid, v in self.vocabularies.items():
if v.class_ and v.class_ not in self.classes:
errors.append(f"Vokabular {vid}: Klasse {v.class_} unbekannt")
for ref in v.columns:
table, _, column = ref.partition(".")
owner = self.class_for_table(table)
if owner is None:
errors.append(f"Vokabular {vid}: Tabelle {table} keiner Klasse zugeordnet")
elif column not in self.classes[owner].attributes:
errors.append(f"Vokabular {vid}: Spalte {ref} fehlt in Klasse {owner}")
labels: dict[str, str] = {}
for cid, c in v.concepts.items():
for name in [cid, c.label, *c.alt]:
key = name.strip().lower()
if key and key in labels and labels[key] != cid:
errors.append(f"Vokabular {vid}: „{name}“ zeigt auf {labels[key]} und {cid}")
labels.setdefault(key, cid)
for r in v.rejected:
if v.resolve(r):
errors.append(f"Vokabular {vid}: „{r}“ ist zugleich verworfen und Konzept {v.resolve(r)}")
return errors
+325
View File
@@ -0,0 +1,325 @@
"""Momentaufnahme der n8n-Datenhaltung: KNOW-Spezifikation, Aufrufe, direktes SQL (nur lesend).
KNOW ist ein n8n-Sub-Workflow, der feste Operationen auf SQL-Vorlagen abbildet. Seine Spezifikation
(erlaubte Operationen, Schema, geschriebene Spalten) wird aus dem Code des Workflows gelesen, nicht aus der DB.
"""
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass, field
from typing import Any
import httpx
_ALLOWED = re.compile(r"const allowed\s*=\s*\[([^\]]+)\]")
_OP = re.compile(r"""if\s*\(\s*operation\s*===\s*['"]([a-z_0-9]+)['"]""")
_INSERT = re.compile(r"INSERT INTO \$\{t\.(\w+)\}(?:\s+AS\s+\w+)?\s*\(")
_UPDATE = re.compile(r"UPDATE \$\{t\.(\w+)\}(?:\s+AS)?(?:\s+\w+)?\s+SET\s")
_DELETE = re.compile(r"DELETE FROM \$\{t\.(\w+)\}")
_WRITE_SQL = re.compile(r"\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\b", re.IGNORECASE)
@dataclass
class KnowSpec:
workflow_id: str
workflow_name: str
allowed: set[str] = field(default_factory=set)
schema: dict[str, dict[str, str]] = field(default_factory=dict) # Tabelle → Spalte → Typ
writes: dict[str, dict[str, set[str]]] = field(default_factory=dict) # Operation → Tabelle → Spalten
seeds: dict[str, set[str]] = field(default_factory=dict) # "tabelle.spalte" → Literalwerte aus Seed-INSERTs
assigned: dict[str, dict[str, set[str]]] = field(default_factory=dict) # Operation → "tab.spalte" → Literale
@dataclass
class KnowCall:
workflow: str
active: bool
node: str
operation: str | None # None = erst zur Laufzeit bestimmt
payload: str = ""
inferred: bool = False # Operation aus Literalen im Workflow abgeleitet (Laufzeit-Operation)
@dataclass
class DirectSql:
workflow: str
active: bool
node: str
query: str
tables: set[str]
writes: bool
@dataclass
class Snapshot:
know: KnowSpec | None
calls: list[KnowCall]
direct_sql: list[DirectSql]
workflow_count: int
taken_at: float = field(default_factory=time.time)
# --- Hilfsfunktionen für JS/SQL-Text ------------------------------------------------------------
def _balanced(text: str, start: int) -> tuple[str, int]:
"""text[start] ist eine öffnende Klammer → (Inhalt, Index der schließenden Klammer)."""
depth = 0
for i in range(start, len(text)):
if text[i] in "([{":
depth += 1
elif text[i] in ")]}":
depth -= 1
if depth == 0:
return text[start + 1:i], i
return text[start + 1:], len(text)
def _split_top(text: str) -> list[str]:
parts, depth, cur, quote = [], 0, "", ""
for ch in text:
if quote:
cur += ch
if ch == quote:
quote = ""
continue
if ch in "'\"`":
quote = ch
elif ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
if ch == "," and depth == 0:
parts.append(cur)
cur = ""
else:
cur += ch
if cur.strip():
parts.append(cur)
return parts
def _column_type(definition: str) -> str:
words = definition.split()
if len(words) < 2:
return "TEXT"
return "DOUBLE PRECISION" if words[1].upper() == "DOUBLE" else words[1].upper()
def _take_sets(writes: dict[str, set[str]], assigned: dict[str, set[str]], table: str, setp: str) -> None:
"""SET-Klausel auswerten: geschriebene Spalten und dabei gesetzte Literale merken."""
for part in _split_top(setp):
if "=" not in part:
continue
col, _, expr = part.partition("=")
col = re.sub(r"^\w+\.", "", col.strip())
writes.setdefault(table, set()).add(col)
lits = {x for x in re.findall(r"'([^'$]*)'", expr) if x and "{" not in x}
if lits:
assigned.setdefault(f"{table}.{col}", set()).update(lits)
def parse_know_code(code: str, workflow_id: str = "", workflow_name: str = "") -> KnowSpec:
spec = KnowSpec(workflow_id=workflow_id, workflow_name=workflow_name)
m = _ALLOWED.search(code)
if m:
spec.allowed = set(re.findall(r"['\"]([a-z_0-9]+)['\"]", m.group(1)))
for m in re.finditer(r"CREATE TABLE IF NOT EXISTS \$\{t\.(\w+)\}\s*\(", code):
body, _ = _balanced(code, m.end() - 1)
cols = spec.schema.setdefault(m.group(1), {})
for part in _split_top(body):
part = " ".join(part.split())
if not part or re.match(r"^(UNIQUE|PRIMARY KEY|CONSTRAINT|CHECK)\b", part, re.IGNORECASE):
continue
cols.setdefault(part.split()[0], _column_type(part))
for m in re.finditer(r"ALTER TABLE \$\{t\.(\w+)\}([^;`]*)", code):
for a in re.finditer(r"ADD COLUMN IF NOT EXISTS\s+(\w+)\s+([^,]+)", m.group(2)):
spec.schema.setdefault(m.group(1), {}).setdefault(a.group(1), _column_type("x " + a.group(2)))
starts = [(m.start(), m.group(1)) for m in _OP.finditer(code)]
for i, (pos, op) in enumerate(starts):
block = code[pos:starts[i + 1][0] if i + 1 < len(starts) else len(code)]
writes = spec.writes.setdefault(op, {})
assigned = spec.assigned.setdefault(op, {})
for m in _INSERT.finditer(block):
inner, end = _balanced(block, m.end() - 1)
vm = re.match(r"\s*VALUES\s*\(", block[end + 1:end + 40])
if vm and "${" not in _balanced(block, end + 1 + vm.end() - 1)[0]:
continue # reines Literal-INSERT = Seed aus setup_schema, keine Schreiboperation von `op`
writes.setdefault(m.group(1), set()).update(c.strip() for c in inner.split(",") if c.strip())
rest = block[end:end + 6000]
conflict = re.search(r"ON CONFLICT\s*\([^)]*\)\s*DO\s+UPDATE SET\s", rest)
if conflict:
tail = rest[conflict.end():]
stop = re.search(r"\sRETURNING\s|\sWHERE\s|;|`", tail)
_take_sets(writes, assigned, m.group(1), tail[:stop.start()] if stop else tail)
for m in _UPDATE.finditer(block):
tail = block[m.end():m.end() + 6000]
stop = re.search(r"\sWHERE\s|\sRETURNING\s|\sFROM\s|;|`", tail)
_take_sets(writes, assigned, m.group(1), tail[:stop.start()] if stop else tail)
for m in _DELETE.finditer(block):
writes.setdefault(m.group(1), set())
# Literalwerte in INSERTs (Seeds wie die Hinweis-Konfiguration, feste Werte wie alias_type 'learned')
for m in _INSERT.finditer(code):
inner, end = _balanced(code, m.end() - 1)
cols = [c.strip() for c in inner.split(",")]
vm = re.match(r"\s*VALUES\s*\(", code[end + 1:end + 40])
if not vm:
continue
values, _ = _balanced(code, end + 1 + vm.end() - 1)
for col, val in zip(cols, _split_top(values), strict=False):
lit = re.fullmatch(r"\s*'([^'$]*)'\s*", val)
if lit and lit.group(1):
spec.seeds.setdefault(f"{m.group(1)}.{col}", set()).add(lit.group(1))
return spec
def value_segments(expression: str, key: str) -> list[str]:
"""Ausdrücke, die `key` auf der obersten Ebene des Payload-Objekts zugewiesen werden.
Verschachtelte Schlüssel (z. B. `raw_signals: { category: … }`) gehören zu einer JSON-Spalte, nicht zu `key`.
"""
anchor = expression.find("JSON.stringify(")
brace = expression.find("{", anchor if anchor >= 0 else 0)
while brace >= 0 and expression[brace + 1:brace + 2] == "{": # n8n-Ausdruck „={{ … }}“ überspringen
brace = expression.find("{", brace + 2)
if brace < 0:
return []
body, _ = _balanced(expression, brace)
out = []
for part in _split_top(body):
m = re.match(rf"\s*['\"]?{re.escape(key)}['\"]?\s*:", part)
if m:
out.append(part[m.end():])
return out
def literal_values(expression: str, key: str) -> set[str]:
"""Literale, die ein Payload-Ausdruck für `key` liefern kann.
Erkennt `key: 'x'`, `key: a || 'x'` und Zuordnungstabellen `key: ({'Anzeige': 'wert', …}[expr] || 'rest')`.
Literale in Knotenverweisen wie `$('Knoten')` zählen nicht.
"""
found: set[str] = set()
for seg in value_segments(expression, key):
seg = re.sub(r"\$\(\s*(['\"]).*?\1\s*\)", "", seg) # $('Knoten') entfernen
pairs = re.findall(r"(['\"])([^'\"]*)\1\s*:\s*(['\"])([^'\"]*)\3", seg)
found |= {p[3] for p in pairs}
seg = re.sub(r"(['\"])[^'\"]*\1\s*:\s*(['\"])[^'\"]*\2", "", seg)
for lit in re.findall(r"(['\"])([^'\"]*)\1(?!\s*:)", seg):
if lit[1] and not re.search(r"[{}$]", lit[1]):
found.add(lit[1])
return found
def is_closed(expression: str, key: str) -> bool:
"""True, wenn `key` nur feste Werte annehmen kann (Literal oder Zuordnungstabelle mit Literal-Rückfall)."""
segments = value_segments(expression, key)
if not segments:
return False
for seg in segments:
seg = re.sub(r"(['\"])(?:(?!\1).)*\1", "L", seg) # Literale
while True: # Zuordnungstabelle {L:L, …}[…] und Klammern entfernen
new = re.sub(r"\{[L:,\s]*\}\s*\[[^\[\]]*\]", "L", seg)
new = re.sub(r"\(\s*([L|\s]*)\s*\)", r"\1", new)
if new == seg:
break
seg = new
if not (re.fullmatch(r"[L|\s]+", seg) and "L" in seg):
return False
return True
# --- Workflows auswerten -----------------------------------------------------------------------
def find_know(workflows: list[dict[str, Any]]) -> KnowSpec | None:
for wf in workflows:
for node in wf.get("nodes", []):
code = (node.get("parameters") or {}).get("jsCode") or ""
if "const allowed" in code and "CREATE TABLE IF NOT EXISTS" in code:
return parse_know_code(code, wf.get("id", ""), wf.get("name", ""))
return None
def _inputs(node: dict[str, Any]) -> dict[str, Any]:
return ((node.get("parameters") or {}).get("workflowInputs") or {}).get("value") or {}
def extract_calls(workflows: list[dict[str, Any]], know_id: str, allowed: set[str] | None = None) -> list[KnowCall]:
calls = []
for wf in workflows:
if wf.get("id") == know_id:
continue
for node in wf.get("nodes", []):
if not node.get("type", "").endswith("executeWorkflow"):
continue
if know_id not in json.dumps(node.get("parameters") or {}):
continue
values = _inputs(node)
op = str(values.get("operation", "")).strip()
literal = op if re.fullmatch(r"[a-z_0-9]+", op) else None
if literal is None and allowed:
# Operation wird zur Laufzeit gesetzt: Kandidaten sind bekannte Operationen, die im Workflow als
# Literal vorkommen (z. B. in einem Code-Knoten davor)
text = json.dumps(wf.get("nodes", []))
for cand in sorted(o for o in allowed if re.search(rf"""['"\\]{o}['"\\]""", text)):
calls.append(KnowCall(wf.get("name", ""), bool(wf.get("active")), node.get("name", ""), cand,
"", inferred=True))
continue
calls.append(KnowCall(wf.get("name", ""), bool(wf.get("active")), node.get("name", ""), literal,
str(values.get("payload_json", ""))))
return calls
def extract_direct_sql(workflows: list[dict[str, Any]], know_id: str,
schema_name: str = "knowledge") -> list[DirectSql]:
out = []
ref = re.compile(rf"""["']?{re.escape(schema_name)}["']?\s*\.\s*["']?(\w+)""", re.IGNORECASE)
for wf in workflows:
if wf.get("id") == know_id:
continue
for node in wf.get("nodes", []):
if not node.get("type", "").endswith(".postgres"):
continue
query = str((node.get("parameters") or {}).get("query", ""))
tables = set(ref.findall(query))
if not tables:
continue
out.append(DirectSql(wf.get("name", ""), bool(wf.get("active")), node.get("name", ""), query, tables,
bool(_WRITE_SQL.search(query))))
return out
def build_snapshot(workflows: list[dict[str, Any]]) -> Snapshot:
know = find_know(workflows)
know_id = know.workflow_id if know else ""
return Snapshot(know=know, calls=extract_calls(workflows, know_id, know.allowed) if know else [],
direct_sql=extract_direct_sql(workflows, know_id), workflow_count=len(workflows))
def fetch_workflows(host: str, api_key: str, *, client: httpx.Client | None = None,
timeout: float = 30.0) -> list[dict[str, Any]]:
"""Alle Workflows über die n8n-API lesen (nur GET)."""
base = host if host.startswith("http") else f"https://{host}"
base = base.rstrip("/")
own = client is None
client = client or httpx.Client(timeout=timeout)
try:
out: list[dict[str, Any]] = []
cursor = None
while True:
params: dict[str, Any] = {"limit": 250}
if cursor:
params["cursor"] = cursor
r = client.get(f"{base}/api/v1/workflows", params=params, headers={"X-N8N-API-KEY": api_key})
r.raise_for_status()
body = r.json()
out.extend(body.get("data", []))
cursor = body.get("nextCursor")
if not cursor:
return out
finally:
if own:
client.close()
+8
View File
@@ -38,6 +38,10 @@ class Settings:
webhook_auth: str = "" # Wert des Authorization-Headers für ausgehende Webhooks (n8n Header Auth) webhook_auth: str = "" # Wert des Authorization-Headers für ausgehende Webhooks (n8n Header Auth)
request_timeout: float = 120.0 request_timeout: float = 120.0
canary_interval: float = 60.0 # 0 = Prober aus canary_interval: float = 60.0 # 0 = Prober aus
ontology_path: Path = field(default_factory=lambda: REPO_ROOT / "ontology" / "know.yaml")
ontology_snapshot: str = "" # Datei mit n8n-Workflows (JSON) statt Live-Abruf, z. B. für Tests/offline
n8n_host: str = "" # nur lesend: GET /api/v1/workflows für den Ontologie-Abgleich
n8n_api_key: str = ""
@classmethod @classmethod
def from_env(cls) -> Settings: def from_env(cls) -> Settings:
@@ -65,4 +69,8 @@ class Settings:
webhook_auth=env.get("KNASTER_WEBHOOK_AUTH", ""), webhook_auth=env.get("KNASTER_WEBHOOK_AUTH", ""),
request_timeout=float(env.get("KNASTER_REQUEST_TIMEOUT", base.request_timeout)), request_timeout=float(env.get("KNASTER_REQUEST_TIMEOUT", base.request_timeout)),
canary_interval=float(env.get("KNASTER_CANARY_INTERVAL", base.canary_interval)), canary_interval=float(env.get("KNASTER_CANARY_INTERVAL", base.canary_interval)),
ontology_path=Path(env.get("KNASTER_ONTOLOGY", base.ontology_path)),
ontology_snapshot=env.get("KNASTER_ONTOLOGY_SNAPSHOT", ""),
n8n_host=env.get("N8N_HOST", ""),
n8n_api_key=env.get("N8N_API_KEY", ""),
) )
+32
View File
@@ -144,6 +144,18 @@ healer_state = Table(
Column("updated_at", Float), Column("updated_at", Float),
) )
ontology_decisions = Table(
"ontology_decisions",
metadata,
Column("key", String(300), primary_key=True),
Column("decision", String(20), nullable=False), # accept | map | reject
Column("params", JSON),
Column("decided_by", String(100)),
Column("decided_at", Float),
Column("applied_at", Float),
Column("updated_at", Float),
)
events = Table( events = Table(
"events", "events",
metadata, metadata,
@@ -339,6 +351,26 @@ class Store:
with self.engine.connect() as conn: with self.engine.connect() as conn:
return [dict(r._mapping) for r in conn.execute(q)] return [dict(r._mapping) for r in conn.execute(q)]
# --- Ontologie-Entscheidungen ----------------------------------------------------------
def set_ontology_decision(self, key: str, decision: str, params: dict[str, Any], decided_by: str) -> None:
self._upsert(ontology_decisions, "key", key, {"decision": decision, "params": params,
"decided_by": decided_by, "decided_at": now(),
"applied_at": None})
def list_ontology_decisions(self, *, applied: bool | None = None) -> list[dict[str, Any]]:
q = select(ontology_decisions).order_by(ontology_decisions.c.decided_at)
if applied is True:
q = q.where(ontology_decisions.c.applied_at.is_not(None))
elif applied is False:
q = q.where(ontology_decisions.c.applied_at.is_(None))
with self.engine.connect() as conn:
return [dict(r._mapping) for r in conn.execute(q)]
def mark_ontology_applied(self, keys: list[str]) -> None:
with self.engine.begin() as conn:
conn.execute(update(ontology_decisions).where(ontology_decisions.c.key.in_(keys))
.values(applied_at=now()))
# --- Healer-Zustand -------------------------------------------------------------------- # --- Healer-Zustand --------------------------------------------------------------------
def get_healer_state(self, key: str) -> dict[str, Any] | None: def get_healer_state(self, key: str) -> dict[str, Any] | None:
row = self._get(healer_state, "key", key) row = self._get(healer_state, "key", key)
+35 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
@@ -18,6 +19,7 @@ from .llm import ChatClient, OpenAIChatClient
from .maintenance.actions import ActionExecutor from .maintenance.actions import ActionExecutor
from .maintenance.agent import IntentMapper, LLMIntentMapper, MaintenanceAgent, MaintenanceConfig from .maintenance.agent import IntentMapper, LLMIntentMapper, MaintenanceAgent, MaintenanceConfig
from .metrics import Metrics from .metrics import Metrics
from .ontology import Ontology, OntologyFinding, build_snapshot, check, fetch_workflows
from .policy import Policy from .policy import Policy
from .registry import Registry from .registry import Registry
from .router import Router from .router import Router
@@ -60,6 +62,35 @@ def _optional(path: Path, loader: Callable, default): # type: ignore[no-untyped
return loader(path) if path.exists() else default return loader(path) if path.exists() else default
def ontology_source_for(settings: Settings) -> Callable[[], list[dict]] | None:
"""Woher die n8n-Workflows für den Ontologie-Abgleich kommen: Datei (offline) oder n8n-API (nur lesend)."""
if settings.ontology_snapshot:
path = Path(settings.ontology_snapshot)
def from_file() -> list[dict]:
data = json.loads(path.read_text(encoding="utf-8"))
return data.get("data", data) if isinstance(data, dict) else data
return from_file
if settings.n8n_host and settings.n8n_api_key:
return lambda: fetch_workflows(settings.n8n_host, settings.n8n_api_key, timeout=settings.request_timeout)
return None
def ontology_checker(settings: Settings, store: Store,
source: Callable[[], list[dict]] | None) -> Callable[[], list[OntologyFinding]] | None:
source = source or ontology_source_for(settings)
if source is None or not settings.ontology_path.exists():
return None
def run() -> list[OntologyFinding]:
onto = Ontology.from_file(settings.ontology_path) # bei jedem Lauf neu: Änderungen im Repo zählen sofort
decided = {d["key"] for d in store.list_ontology_decisions()}
return check(onto, build_snapshot(source()), decided=decided)
return run
def build_system( def build_system(
settings: Settings, settings: Settings,
*, *,
@@ -70,6 +101,7 @@ def build_system(
notifier: Notifier | None = None, notifier: Notifier | None = None,
jobs: JobRunner | None = None, jobs: JobRunner | None = None,
intent_mapper: IntentMapper | None = None, intent_mapper: IntentMapper | None = None,
ontology_source: Callable[[], list[dict]] | None = None,
clock: Callable[[], float] = now, clock: Callable[[], float] = now,
wallclock: Callable[[], datetime] = datetime.now, wallclock: Callable[[], datetime] = datetime.now,
) -> System: ) -> System:
@@ -117,7 +149,9 @@ def build_system(
intent_mapper = LLMIntentMapper(chat, registry.slot("tier1-general").spec.litellm_model) intent_mapper = LLMIntentMapper(chat, registry.slot("tier1-general").spec.litellm_model)
maintenance = MaintenanceAgent(store=store, registry=registry, library=library, index=router.index, maintenance = MaintenanceAgent(store=store, registry=registry, library=library, index=router.index,
policy=policy, actions=actions, lifecycle=lifecycle, config=maint_cfg, policy=policy, actions=actions, lifecycle=lifecycle, config=maint_cfg,
intent_mapper=intent_mapper, clock=clock, wallclock=wallclock) intent_mapper=intent_mapper,
ontology_check=ontology_checker(settings, store, ontology_source),
clock=clock, wallclock=wallclock)
healer = Healer(healer_cfg, store, actions, policy, propose=maintenance.propose, clock=clock) healer = Healer(healer_cfg, store, actions, policy, propose=maintenance.propose, clock=clock)
prober = CanaryProber(registry, chat, embedder_for={settings.embed_model: embedder}, prober = CanaryProber(registry, chat, embedder_for={settings.embed_model: embedder},
metrics_registry=metrics.registry) metrics_registry=metrics.registry)
+15
View File
@@ -87,6 +87,21 @@ def validate_repo(root: str | Path, site: str = "knaster") -> list[tuple[str, st
if kind not in known: if kind not in known:
err(f"Policy: unbekannte Aktion {kind}") err(f"Policy: unbekannte Aktion {kind}")
onto_path = root / "ontology" / "know.yaml"
if onto_path.exists():
from .ontology import Ontology, to_turtle
try:
onto = Ontology.from_file(onto_path)
except (ValidationError, ValueError) as exc:
err(f"Ontologie: {exc}")
else:
for e in onto.validate_model():
err(f"Ontologie: {e}")
ttl = onto_path.with_suffix(".ttl")
if not ttl.exists() or ttl.read_text(encoding="utf-8") != to_turtle(onto):
warn(f"Ontologie: {ttl.name} ist nicht aktuell (knaster ontology export --ttl {ttl})")
if (root / "catalog").exists(): if (root / "catalog").exists():
roles, _ = load_catalog(root / "catalog") roles, _ = load_catalog(root / "catalog")
for role in roles: for role in roles:
+2 -1
View File
@@ -212,7 +212,8 @@ def make_system(tmp_path, skills_dir, fake_chat, clock):
(site / "registry.yaml").write_text(yaml.safe_dump(REGISTRY)) (site / "registry.yaml").write_text(yaml.safe_dump(REGISTRY))
settings = Settings(db_url="sqlite://", site_dir=site, skills_dir=skills_dir, settings = Settings(db_url="sqlite://", site_dir=site, skills_dir=skills_dir,
config_dir=overrides.pop("config_dir", REPO_ROOT / "config"), config_dir=overrides.pop("config_dir", REPO_ROOT / "config"),
api_token=overrides.pop("api_token", "secret")) api_token=overrides.pop("api_token", "secret"),
ontology_path=overrides.pop("ontology_path", tmp_path / "keine-ontologie.yaml"))
kw = dict(embedder=HashingEmbedder(), chat=fake_chat, jobs=LoggingJobRunner(), notifier=DryRunDriver(), kw = dict(embedder=HashingEmbedder(), chat=fake_chat, jobs=LoggingJobRunner(), notifier=DryRunDriver(),
clock=clock) clock=clock)
kw.update(overrides) kw.update(overrides)
+17 -1
View File
@@ -32,18 +32,22 @@ def test_parsers():
assert disks[0].size_gb == 256.1 and not disks[0].rotational assert disks[0].size_gb == 256.1 and not disks[0].rotational
PVE_GPU_HOSTS = {"pve44", "pve46", "pve48", "pve52"}
def test_placement_knaster(): def test_placement_knaster():
roles, models = load_catalog(REPO_ROOT / "catalog") roles, models = load_catalog(REPO_ROOT / "catalog")
p = plan(load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml"), roles, models) p = plan(load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml"), roles, models)
by_role = {} by_role = {}
for a in p.assignments: for a in p.assignments:
by_role.setdefault(a.role, []).append(a) 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 by_role["gpu-main"][0].gpu_name.startswith("NVIDIA RTX 4000 Ada")
assert "qwen3-14b-awq" in by_role["gpu-main"][0].models_fitting assert "qwen3-14b-awq" in by_role["gpu-main"][0].models_fitting
embed_nodes = {a.node for a in by_role["gpu-embed"]} embed_nodes = {a.node for a in by_role["gpu-embed"]}
assert len(embed_nodes) == 2 # Anti-Affinität 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 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 assert not p.unplaced
# M4000 bewusst außer Betrieb (ADR-0006): weder frei noch belegt # M4000 bewusst außer Betrieb (ADR-0006): weder frei noch belegt
assert p.free_gpus == [] assert p.free_gpus == []
@@ -67,6 +71,18 @@ def test_site_knaster_matches_real_nodes():
gpu_nodes = {"pve44", "pve46", "pve48", "pve52"} gpu_nodes = {"pve44", "pve46", "pve48", "pve52"}
assert {m["node"] for m in site["pci_mappings"].values()} <= gpu_nodes assert {m["node"] for m in site["pci_mappings"].values()} <= gpu_nodes
assert {vm["node"] for vm in site["vms"].values()} <= gpu_nodes assert {vm["node"] for vm in site["vms"].values()} <= gpu_nodes
# Ada-Rechner und Kiosk nehmen keine CPU-Rollen (Placement, cpu_roles)
nodes = {n.name: n for n in load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml")}
assert not nodes["gode-pc-05"].cpu_roles and not nodes["gode-pc-06"].cpu_roles and not nodes["pve61"].cpu_roles
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(): def test_placement_other_cluster():
+267
View File
@@ -0,0 +1,267 @@
"""Ontologie: Modell, Export, Abgleich mit n8n (synthetische Workflows), Übernahme, Service-Routine."""
import json
import pytest
from knaster.ontology import Ontology, apply_decisions, build_snapshot, check, to_graph, to_turtle
from knaster.ontology.n8n import is_closed, literal_values, parse_know_code
from knaster.settings import REPO_ROOT
KNOW_CODE = r"""
const allowed=['setup_schema','store_mail_event','upsert_hint','promote_hint','find_mail'];
if(operation==='setup_schema'){ query=`CREATE TABLE IF NOT EXISTS ${t.mail_events} (id BIGSERIAL PRIMARY KEY,
message_id TEXT UNIQUE NOT NULL, category TEXT, sender TEXT);
CREATE TABLE IF NOT EXISTS ${t.hints} (hint_key TEXT UNIQUE NOT NULL, hint_type TEXT NOT NULL,
quality_status TEXT NOT NULL DEFAULT 'observed');
CREATE TABLE IF NOT EXISTS ${t.config} (config_key TEXT UNIQUE NOT NULL, weight NUMERIC);
CREATE TABLE IF NOT EXISTS ${t.orphans} (id BIGSERIAL PRIMARY KEY, x TEXT);
ALTER TABLE ${t.mail_events} ADD COLUMN IF NOT EXISTS received_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS mood TEXT;
INSERT INTO ${t.config} (config_key, weight) VALUES ('mahnung', 3) ON CONFLICT (config_key) DO NOTHING;
INSERT INTO ${t.config} (config_key, weight) VALUES ('default', 0) ON CONFLICT (config_key) DO NOTHING;`;}
if(operation==='store_mail_event'){ query=`INSERT INTO ${t.mail_events} (message_id,category,sender)
VALUES (${sqlString(payload.message_id)},${sqlString(payload.category)},${sqlString(payload.sender)})
ON CONFLICT (message_id) DO UPDATE SET category=EXCLUDED.category RETURNING *`;}
if(operation==='upsert_hint'){ query=`INSERT INTO ${t.hints} (hint_key,hint_type) VALUES (${k},${ht})
ON CONFLICT (hint_key) DO UPDATE SET quality_status=CASE WHEN x THEN 'proposed' ELSE ${t.hints}.quality_status END`;}
if(operation==='promote_hint'){ query=`UPDATE ${t.hints} SET quality_status='confirmed' WHERE hint_key=${k}`;}
if(operation==='find_mail'){ query=`SELECT * FROM ${t.mail_events}`;}
"""
ONTO = {
"id": "t", "namespace": "urn:test#", "modules": {"m": "Mail"},
"classes": {
"Mail": {"label": "Mail", "module": "m", "table": "mail_events", "key": "message_id",
"attributes": {"id": "BIGSERIAL", "message_id": "TEXT", "category": "TEXT", "sender": "TEXT",
"received_at": "TIMESTAMPTZ"}},
"Hinweis": {"label": "Hinweis", "module": "m", "table": "hints",
"attributes": {"hint_key": "TEXT", "hint_type": "TEXT", "quality_status": "TEXT"}},
"Kategorie": {"label": "Kategorie", "module": "m", "table": "config",
"attributes": {"config_key": "TEXT", "weight": "NUMERIC"}},
"Alt": {"label": "Alt", "module": "m", "table": "removed_table", "attributes": {"a": "TEXT"}},
},
"relations": [{"source": "Hinweis", "name": "hatKategorie", "target": "Kategorie", "via": "hint_type"}],
"vocabularies": {
"Kat": {"label": "Kategorie", "class": "Kategorie",
"columns": ["mail_events.category", "hints.hint_type", "config.config_key"],
"concepts": {"mahnung": {"label": "Mahnung"}, "rechnung": {"label": "Rechnung", "alt": ["Buchhaltung"]},
"sonstiges": {}, "default": {"technical": True}}},
"QS": {"label": "Qualität", "columns": ["hints.quality_status"],
"concepts": {"observed": {}, "proposed": {}, "confirmed": {}, "rejected": {}}},
},
}
CAT_PAYLOAD = ("={{ JSON.stringify({ message_id: $json.id, sender: ($('Vorfilter').item.json.from||'').toLowerCase(), "
"category: ({'Buchhaltung':'rechnung','Bauvorhaben':'bauvorhaben','Sonstiges':'sonstiges'}"
"[$json.output?.category]||'sonstiges'), raw_signals: { category: 'x' } }) }}")
def _call(name, op, payload="", node="KNOW"):
return {"name": node, "type": "n8n-nodes-base.executeWorkflow",
"parameters": {"workflowId": {"value": "know1"},
"workflowInputs": {"value": {"operation": op, "payload_json": payload}}}}
WORKFLOWS = [
{"id": "know1", "name": "KNOW", "active": True,
"nodes": [{"name": "Normalize", "type": "n8n-nodes-base.code", "parameters": {"jsCode": KNOW_CODE}}]},
{"id": "w1", "name": "CAT", "active": True, "nodes": [
_call("CAT", "store_mail_event", CAT_PAYLOAD),
{"name": "Mark", "type": "n8n-nodes-base.postgres",
"parameters": {"query": "UPDATE knowledge.hints SET x=1 WHERE hint_key='{{ $json.k }}'"}}]},
{"id": "w2", "name": "MPA", "active": True, "nodes": [
_call("MPA", "store_mail_event", '={{ JSON.stringify({ message_id: $json.id, category: "MPA" }) }}'),
_call("MPA", "log_decision", "{}", node="Log1"), _call("MPA", "log_decision", "{}", node="Log2")]},
{"id": "w3", "name": "Dyn", "active": True, "nodes": [
{"name": "Build", "type": "n8n-nodes-base.code", "parameters": {"jsCode": "return {operation: 'upsert_hint'}"}},
_call("Dyn", "={{ $json.operation }}")]},
{"id": "w4", "name": "Old", "active": False, "nodes": [
_call("Old", "store_mail_event", "={{ JSON.stringify({ category: 'legacy_kat' }) }}")]},
]
@pytest.fixture
def onto():
return Ontology(**json.loads(json.dumps(ONTO)))
def test_repo_ontology_is_valid_and_turtle_current():
path = REPO_ROOT / "ontology" / "know.yaml"
o = Ontology.from_file(path)
assert o.validate_model() == []
assert len(o.classes) >= 49 and "MailKategorie" in o.vocabularies
assert (REPO_ROOT / "ontology" / "know.ttl").read_text(encoding="utf-8") == to_turtle(o)
def test_model_validation_finds_errors(onto):
onto.relations.append(onto.relations[0].model_copy(update={"target": "Gibtsnicht"}))
onto.vocabularies["Kat"].concepts["sonstiges"].alt = ["Mahnung"]
onto.vocabularies["Kat"].columns.append("mail_events.fehlt")
onto.vocabularies["Kat"].rejected = ["rechnung"]
errors = "\n".join(onto.validate_model())
assert "Gibtsnicht" in errors and "„Mahnung“" in errors and "mail_events.fehlt" in errors
assert "zugleich verworfen" in errors
def test_vocabulary_resolve(onto):
v = onto.vocabularies["Kat"]
assert v.resolve("Buchhaltung") == "rechnung" and v.resolve("MAHNUNG") == "mahnung" and v.resolve("x") is None
def test_exports(onto):
ttl = to_turtle(onto)
assert "t:Mail a owl:Class" in ttl and "skos:altLabel \"Buchhaltung\"@de" in ttl
assert "t:hatKategorie a owl:ObjectProperty" in ttl
g = to_graph(onto, generated="2026-10-13")
mail = next(c for c in g["classes"] if c["id"] == "Mail")
assert "id" not in [a[0] for a in mail["attrs"]]
hint = next(c for c in g["classes"] if c["id"] == "Hinweis")
assert hint["enums"]["quality_status"] == ["observed", "proposed", "confirmed", "rejected"]
assert "Mahnung" in next(c for c in g["classes"] if c["id"] == "Kategorie")["individuals"]
def test_parse_know_code():
spec = parse_know_code(KNOW_CODE)
assert spec.allowed == {"setup_schema", "store_mail_event", "upsert_hint", "promote_hint", "find_mail"}
assert set(spec.schema["mail_events"]) == {"id", "message_id", "category", "sender", "received_at", "mood"}
assert spec.writes["store_mail_event"]["mail_events"] == {"message_id", "category", "sender"}
assert spec.writes["promote_hint"]["hints"] == {"quality_status"}
assert spec.seeds["config.config_key"] == {"mahnung", "default"}
assert "config" not in spec.writes.get("setup_schema", {}) # Seeds gehören zu keiner Operation
assert spec.assigned["upsert_hint"]["hints.quality_status"] == {"proposed"}
assert spec.assigned["promote_hint"]["hints.quality_status"] == {"confirmed"}
@pytest.mark.parametrize("expr,key,values,closed", [
(CAT_PAYLOAD, "category", {"rechnung", "bauvorhaben", "sonstiges"}, True),
(CAT_PAYLOAD, "sender", set(), False),
('{ category: "MPA", x: 1 }', "category", {"MPA"}, True),
("{ category: $json.cat || 'info' }", "category", {"info"}, False),
("={{ JSON.stringify({ x: { category: 'tief' } }) }}", "category", set(), False),
])
def test_literal_values_and_closed(expr, key, values, closed):
assert literal_values(expr, key) == values
assert is_closed(expr, key) is closed
def test_check_against_synthetic_n8n(onto):
snap = build_snapshot(WORKFLOWS)
assert snap.know and snap.know.workflow_id == "know1"
dyn = [c for c in snap.calls if c.workflow == "Dyn"]
assert [(c.operation, c.inferred) for c in dyn] == [("upsert_hint", True)]
findings = check(onto, snap)
by_kind = {}
for f in findings:
by_kind.setdefault(f.kind, []).append(f)
# unbekannte Operation: zwei Knoten → ein Befund
[unknown] = by_kind["unknown_operation"]
assert unknown.severity == "high" and "Log1, Log2" in unknown.detail
[sql] = by_kind["direct_sql"]
assert sql.severity == "medium" and "hints" in sql.summary
vocab = {f.change["value"]: f for f in by_kind["vocab_value"]}
assert set(vocab) == {"bauvorhaben", "MPA", "legacy_kat"}
assert vocab["legacy_kat"].severity == "low" and vocab["MPA"].severity == "medium"
# mahnung wird von keiner festen Zuordnung erzeugt, default ist technisch
[unused] = [f for f in by_kind["concept_without_producer"] if f.key == "unused:Kat"]
assert "mahnung" in unused.summary and "default" not in unused.summary
# Qualitätsstatus: proposed/confirmed setzt KNOW selbst (upsert_hint ist aktiv abgeleitet, promote_hint nicht)
qs = [f for f in by_kind.get("concept_without_producer", []) if f.key == "unused:QS"]
assert not qs # keine feste Zuordnung aus Workflows → keine Aussage
assert {f.change["column"] for f in by_kind["unmapped_column"]} == {"mood"}
assert by_kind["unmapped_table"][0].change["table"] == "orphans"
assert by_kind["missing_table"][0].key == "gone:removed_table"
assert "no_write_operation" not in by_kind # orphans ist keiner Klasse zugeordnet, config ist Seed-Tabelle
assert "nowriter:config" not in {f.key for f in findings} # Seed-Tabelle
# entschiedene bzw. ignorierte Schlüssel verschwinden
onto.ignore.append("table:orphans")
keys = {f.key for f in check(onto, snap, decided={"vocab:Kat:mpa"})}
assert "vocab:Kat:mpa" not in keys and "table:orphans" not in keys
def test_apply_decisions(onto):
decisions = [
{"key": "vocab:Kat:bauvorhaben", "decision": "accept",
"params": {"type": "vocab_value", "vocabulary": "Kat", "value": "Bauvorhaben"}},
{"key": "vocab:Kat:buchhaltg", "decision": "map",
"params": {"type": "vocab_value", "vocabulary": "Kat", "value": "Buchhaltg", "concept": "rechnung"}},
{"key": "vocab:Kat:mpa", "decision": "reject", "params": {"type": "vocab_value", "vocabulary": "Kat",
"value": "MPA"}},
{"key": "column:mail_events.mood", "decision": "accept",
"params": {"type": "add_attribute", "class": "Mail", "column": "mood", "sql_type": "TEXT"}},
{"key": "table:orphans", "decision": "accept",
"params": {"type": "add_class", "table": "orphans", "attributes": {"x": "TEXT"}}},
{"key": "sql:CAT:Mark", "decision": "reject", "params": {"type": "x"}},
{"key": "vocab:Kat:kaputt", "decision": "map",
"params": {"type": "vocab_value", "vocabulary": "Kat", "value": "k", "concept": "gibtsnicht"}},
]
applied, messages = apply_decisions(onto, decisions)
v = onto.vocabularies["Kat"]
assert v.resolve("Bauvorhaben") == "bauvorhaben" and v.resolve("Buchhaltg") == "rechnung"
assert v.is_rejected("mpa") and "mood" in onto.classes["Mail"].attributes
assert onto.classes["Orphans"].module == "neu" and "sql:CAT:Mark" in onto.ignore
assert "vocab:Kat:kaputt" not in applied and any("nicht übernommen" in m for m in messages)
assert onto.version == 2 and onto.validate_model() == []
def test_routine_proposes_and_remembers_decisions(make_system, tmp_path, onto):
path = tmp_path / "know.yaml"
onto.save(path)
sysm = make_system(ontology_path=path, ontology_source=lambda: WORKFLOWS)
m = sysm.maintenance
report = m.run_routine()
assert any("log_decision" in f for f in report.findings)
vocab = {p.params["value"]: p for p in report.proposals if p.kind == "ontology_change"
and p.params["type"] == "vocab_value"}
assert {"bauvorhaben", "MPA", "legacy_kat"} <= set(vocab)
assert all(p.state == "open" for p in vocab.values()) # Ontologie-Änderungen nie automatisch
# zuordnen, ablehnen, aufnehmen
assert "executed" in m.handle_command(f"ordne {vocab['legacy_kat'].id} sonstiges zu", user="robert").text
assert "rejected" in m.handle_command(f"lehne {vocab['MPA'].id} ab: Workflow-Name, keine Kategorie").text
assert "executed" in m.handle_command(f"genehmige {vocab['bauvorhaben'].id}").text
assert "Fehler" in m.handle_command("ordne P-999 sonstiges zu").text
decisions = {d["key"]: d for d in sysm.store.list_ontology_decisions()}
assert decisions["vocab:Kat:legacy_kat"]["decision"] == "map"
assert decisions["vocab:Kat:legacy_kat"]["decided_by"] == "robert"
assert decisions["vocab:Kat:mpa"]["decision"] == "reject"
# zweiter Lauf: entschiedene Werte kommen nicht wieder
again = m.run_routine()
values = {p.params.get("value") for p in again.proposals if p.kind == "ontology_change"}
assert not values & {"bauvorhaben", "MPA", "legacy_kat"}
# Übernahme in die Datei
pending = sysm.store.list_ontology_decisions(applied=False)
applied, _ = apply_decisions(onto, pending)
assert set(applied) == {"vocab:Kat:legacy_kat", "vocab:Kat:mpa", "vocab:Kat:bauvorhaben"}
assert onto.vocabularies["Kat"].resolve("legacy_kat") == "sonstiges"
def test_routine_survives_unreachable_n8n(make_system, tmp_path, onto):
path = tmp_path / "know.yaml"
onto.save(path)
def broken():
raise ConnectionError("n8n nicht erreichbar")
report = make_system(ontology_path=path, ontology_source=broken).maintenance.run_routine()
assert any("Ontologie-Abgleich nicht möglich" in f for f in report.findings)
def test_cli_check_with_snapshot(tmp_path, onto, monkeypatch, capsys):
from knaster.cli import main
path = tmp_path / "know.yaml"
onto.save(path)
snap = tmp_path / "wf.json"
snap.write_text(json.dumps({"data": WORKFLOWS}))
monkeypatch.setenv("KNASTER_DB_URL", "sqlite://")
assert main(["ontology", "--path", str(path), "check", "--snapshot", str(snap)]) == 1 # high-Befund
out = capsys.readouterr().out
assert "log_decision" in out and "Änderungsvorschläge" in out
assert main(["ontology", "--path", str(path), "validate"]) == 0
html = tmp_path / "o.html"
assert main(["ontology", "--path", str(path), "export", "--html", str(html),
"--template", str(REPO_ROOT / "ontology" / "viewer.html.tmpl")]) == 0
assert "__DATA__" not in html.read_text() and "owl:Class" in html.read_text()
+2
View File
@@ -13,10 +13,12 @@ COPY config config
COPY catalog catalog COPY catalog catalog
COPY skills skills COPY skills skills
COPY sites sites COPY sites sites
COPY ontology ontology
ENV KNASTER_CONFIG_DIR=/app/config \ ENV KNASTER_CONFIG_DIR=/app/config \
KNASTER_SKILLS_DIR=/app/skills \ KNASTER_SKILLS_DIR=/app/skills \
KNASTER_SITE_DIR=/app/sites/knaster \ KNASTER_SITE_DIR=/app/sites/knaster \
KNASTER_ONTOLOGY=/app/ontology/know.yaml \
KNASTER_DRY_RUN=true KNASTER_DRY_RUN=true
RUN useradd --system --uid 10001 knaster RUN useradd --system --uid 10001 knaster
+3 -1
View File
@@ -18,6 +18,7 @@ Vorgehen pro Baustein: **Spec → Implementierung → Tests → Commit**. Bis zu
| 10 | Plattform | ADR-0001/0002 | OpenTofu (bpg/proxmox), Ansible-Rollen (IOMMU/VFIO, ZFS-Replikation, Treiber-Pinning, Xid-Exporter) | ✅ geschrieben, **nicht** gegen echten Cluster/`tofu validate` geprüft | | 10 | Plattform | ADR-0001/0002 | OpenTofu (bpg/proxmox), Ansible-Rollen (IOMMU/VFIO, ZFS-Replikation, Treiber-Pinning, Xid-Exporter) | ✅ geschrieben, **nicht** gegen echten Cluster/`tofu validate` geprüft |
| 11 | CI | – | Gitea Actions: Tests, Lint, Konfig-Validierung (Runner `gitrunner-knaster`) | ✅ | | 11 | CI | – | Gitea Actions: Tests, Lint, Konfig-Validierung (Runner `gitrunner-knaster`) | ✅ |
| 12 | TTS + Legacy-GPUs | ADR-0006 | Engine-Grenzen im Katalog, deaktivierte GPUs, geteilte GPU-Rollen, TTS-Slots, Piper-Dienst, TTS-Canary, `knaster bench tts` | ✅ | | 12 | TTS + Legacy-GPUs | ADR-0006 | Engine-Grenzen im Katalog, deaktivierte GPUs, geteilte GPU-Rollen, TTS-Slots, Piper-Dienst, TTS-Canary, `knaster bench tts` | ✅ |
| 13 | Ontologie der Datenhaltung | SPEC-ontology | `ontology/know.yaml` als Quelle, Export (Turtle/SKOS, Graph, Ansicht), Abgleich mit n8n/KNOW (nur lesend), Vorschläge `ontology_change` in der Service-Routine, `knaster ontology apply` | ✅ gegen echten n8n-Export geprüft (24 Befunde, 4 Vokabel-Vorschläge) |
## Teststrategie ## Teststrategie
@@ -30,11 +31,12 @@ Vorgehen pro Baustein: **Spec → Implementierung → Tests → Commit**. Bis zu
| Was | Wie geprüft | | Was | Wie geprüft |
|---|---| |---|---|
| Kernlogik (Router, Eskalation, Verifier, Pflege-Agent, Healer, Lifecycle, Placement) | 136 Unit- und Integrationstests (`make test`) | | Kernlogik (Router, Eskalation, Verifier, Pflege-Agent, Healer, Lifecycle, Placement) | 152 Unit- und Integrationstests (`make test`) |
| Konfiguration (Registry ↔ Skills ↔ LiteLLM ↔ Healer ↔ Policy ↔ Katalog) | `knaster validate` | | Konfiguration (Registry ↔ Skills ↔ LiteLLM ↔ Healer ↔ Policy ↔ Katalog) | `knaster validate` |
| Compose-Dateien | `docker compose config` (`make compose-check`) | | Compose-Dateien | `docker compose config` (`make compose-check`) |
| Prometheus/Alertmanager/n8n-JSON | Strukturtests (`core/tests/test_artifacts.py`) | | Prometheus/Alertmanager/n8n-JSON | Strukturtests (`core/tests/test_artifacts.py`) |
| n8n, Portainer | nur lesender Erreichbarkeitstest, nichts deployt | | n8n, Portainer | nur lesender Erreichbarkeitstest, nichts deployt |
| Ontologie ↔ n8n | `knaster ontology check` gegen einen lesend abgerufenen Export aller Workflows (13.10.); Tests mit synthetischen Workflows |
| OpenTofu, Ansible | nur YAML/Syntax. `tofu validate` und `ansible-lint` stehen noch aus | | OpenTofu, Ansible | nur YAML/Syntax. `tofu validate` und `ansible-lint` stehen noch aus |
## Offene Punkte vor dem Deployment ## Offene Punkte vor dem Deployment
+338
View File
@@ -0,0 +1,338 @@
# 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. 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):
- Datenfluss: https://claude.ai/artifact/GP8FfU8M6Wk9hPRmJuk96h
- Ontologie: https://claude.ai/artifact/R7zxBJsCKkkCWdCcztLFUT (Quelle: [ontology/know.yaml](../../ontology/know.yaml))
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) |
| 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) |
| 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“.
- **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. `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:**
- `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.
---
# 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).
---
# 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.
+569
View File
@@ -0,0 +1,569 @@
# 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.
Interaktive Ansichten (privat, Freigabe über das Share-Menü):
- Datenfluss: https://claude.ai/artifact/GP8FfU8M6Wk9hPRmJuk96h
- 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–B33)
## 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 (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.
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`<br>CAT - Postfach Pipeline: `store_mail_event`<br>MPA - Projekt-Ablage: `store_mail_event` |
| liest | KGW - KNOW Query Gateway: `find_mail_events, get_mail_events_summary, get_sender_profile`<br>MPA - Projekt-Ablage: `find_mail_events` |
<details><summary>11 Spalten</summary>
`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
</details>
#### `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` |
<details><summary>25 Spalten</summary>
`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
</details>
#### `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`<br>HMB - Postfach Backfill (inaktiv): `find_platform_domain`<br>KGW - KNOW Query Gateway: `get_sender_profile, list_sender_domains` |
<details><summary>9 Spalten</summary>
`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
</details>
#### `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`<br>MPA - Get Mailbox Access: `upsert_mailbox_grant` |
| liest | MPA - Get Mailbox Access: `get_mailbox_grant`<br>MPA - Projekt-Ablage: `list_active_mailbox_grants` |
<details><summary>13 Spalten</summary>
`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
</details>
### 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`<br>BCAT - Historische Mail-Analyse (inaktiv): `update_sender_relevance`<br>KGW - KNOW Query Gateway: `update_sender_relevance, upsert_contact` |
| liest | ASP - Absender-Report: `SELECT (Report)`<br>CAT - Postfach Pipeline: `find_workflow_hints`<br>KGW - KNOW Query Gateway: `find_contact_candidates, find_workflow_hints, get_sender_profile`<br>OR - PDF and E-Rechnung Extraction (inaktiv): `find_workflow_hints` |
<details><summary>24 Spalten</summary>
`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
</details>
#### `contact_aliases`
Schreibweisen von Kontakten.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | KGW - KNOW Query Gateway: `find_contact_candidates` |
<details><summary>11 Spalten</summary>
`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
</details>
#### `contact_project_relations`
Kontakt ↔ Projekt.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | – |
<details><summary>12 Spalten</summary>
`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
</details>
#### `projects`
KanBau-Projekte mit Adresse.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | KGW - KNOW Query Gateway: `find_project_candidates, get_sender_profile`<br>NEX - Teams Chat Agent: `find_project_candidates` |
<details><summary>17 Spalten</summary>
`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
</details>
#### `project_aliases`
Aliase und gelernte Tokens zu Projekten.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | KGW - KNOW Query Gateway: `purge_spf_aliases, upsert_project_alias`<br>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`<br>NEX - Teams Chat Agent: `find_project_candidates`<br>PRO - Project Assignment: `find_project_by_alias` |
<details><summary>13 Spalten</summary>
`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
</details>
#### `project_events`
Ereignisse aus Mails (Verzögerung, Mangel, Termin, Kosten).
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | MPA - Projekt-Ablage: `upsert_project_event` |
| liest | – |
<details><summary>15 Spalten</summary>
`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
</details>
#### `project_folders`
SharePoint-Ordner je Projekt.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | – |
<details><summary>10 Spalten</summary>
`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
</details>
#### `project_companies`
Kürzel → Gesellschaft.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | RAR - Rechnungsanschrift pruefen: `record_invoice_company` |
| liest | RAR - Rechnungsanschrift pruefen: `get_project_company` |
<details><summary>8 Spalten</summary>
`kuerzel` TEXT, `gesellschaft` TEXT, `quelle` TEXT, `bestaetigt` BOOLEAN, `baustelle` TEXT, `evidence_json` JSONB, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
#### `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` |
<details><summary>8 Spalten</summary>
`message_id` TEXT, `file_name` TEXT, `kuerzel` TEXT, `gesellschaft` TEXT, `mailbox` TEXT, `source_workflow` TEXT, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
### 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 | – |
<details><summary>17 Spalten</summary>
`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
</details>
### 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` |
<details><summary>73 Spalten</summary>
`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)
</details>
#### `property_development_documents`
Dokumente je Ankaufsfall.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | KGW - KNOW Query Gateway: `summarize_property_development_case` |
<details><summary>20 Spalten</summary>
`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
</details>
#### `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`<br>KAF - Kaufvertrag Erkennung und Extraktion: `store_kaf_review_snapshot` |
| liest | KAF - Bestätigung Webhook: `get_kaf_review_snapshot` |
<details><summary>22 Spalten</summary>
`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
</details>
#### `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` |
<details><summary>4 Spalten</summary>
`kanzlei_key` TEXT, `kanzlei_display` TEXT, `patterns` JSONB, `updated_at` TIMESTAMPTZ
</details>
#### `kanzlei_project_relations`
Kanzlei ↔ Projekt.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | KAF - Kaufvertrag Erkennung und Extraktion: `upsert_kanzlei_project_relation` |
| liest | – |
<details><summary>6 Spalten</summary>
`relation_key` TEXT, `kanzlei_key` TEXT, `project_key` TEXT, `project_name` TEXT, `notar_name` TEXT, `updated_at` TIMESTAMPTZ
</details>
#### `place_enrichments`
Adresse → Flurstück, Koordinaten.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | – |
<details><summary>15 Spalten</summary>
`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
</details>
### 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 | – |
<details><summary>15 Spalten</summary>
`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
</details>
### Lernen und Workflow-Gedächtnis
#### `workflow_decisions`
Vorschlag vs. tatsächliche Entscheidung.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | – |
<details><summary>13 Spalten</summary>
`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
</details>
#### `workflow_hints`
Gelernte Muster mit Bestätigung.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | ASP - Absender-Profil Backfill: `backfill_workflow_hint`<br>CAT - Postfach Pipeline: `UPDATE … SET notified_at, pending_notification, upsert_workflow_hint`<br>KAN - Kanbau API Gateway: `upsert_workflow_hint`<br>KGW - KNOW Query Gateway: `promote_workflow_hint, reject_workflow_hint, upsert_workflow_hint`<br>KNOW - Hint Confirmation Webhook: `promote_workflow_hint, reject_workflow_hint`<br>OR - PDF and E-Rechnung Extraction (inaktiv): `upsert_workflow_hint` |
| liest | CAT - Postfach Pipeline: `find_workflow_hints, get_pending_hint_proposals`<br>KGW - KNOW Query Gateway: `find_workflow_hints, get_pending_hint_proposals, get_sender_profile, summarize_workflow_lessons`<br>OR - PDF and E-Rechnung Extraction (inaktiv): `find_workflow_hints` |
<details><summary>15 Spalten</summary>
`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
</details>
#### `workflow_hint_config`
Ab wann die KI übersprungen wird.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | BCAT - Historische Mail-Analyse (inaktiv): `update_sender_relevance`<br>CAT - Postfach Pipeline: `find_workflow_hints, get_pending_hint_proposals, upsert_workflow_hint`<br>KAN - Kanbau API Gateway: `upsert_workflow_hint`<br>KGW - KNOW Query Gateway: `find_workflow_hints, get_hint_config, get_pending_hint_proposals, summarize_workflow_lessons, update_sender_relevance, upsert_workflow_hint`<br>OR - PDF and E-Rechnung Extraction (inaktiv): `find_workflow_hints, upsert_workflow_hint` |
<details><summary>10 Spalten</summary>
`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
</details>
#### `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` |
<details><summary>7 Spalten</summary>
`method_key` TEXT, `swagger_ref` TEXT, `example_request` JSONB, `example_response` JSONB, `validated_at` TIMESTAMPTZ, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
### Agent
#### `agent_capabilities`
Fähigkeiten eines Agenten.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | – |
<details><summary>14 Spalten</summary>
`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
</details>
#### `agent_context_documents`
Kontextdokumente für Agenten.
**Sensibel:** Volltext eingelesener Dokumente.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | – |
<details><summary>8 Spalten</summary>
`id` BIGSERIAL, `scope_key` TEXT, `source_path` TEXT, `document_type` TEXT, `title` TEXT, `content` TEXT, `content_hash` TEXT, `last_ingested_at` TIMESTAMPTZ
</details>
#### `agent_execution_log`
Protokoll der Agent-Aufrufe.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | – |
<details><summary>10 Spalten</summary>
`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
</details>
### 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` |
<details><summary>10 Spalten</summary>
`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
</details>
#### `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` |
<details><summary>5 Spalten</summary>
`provider_id` TEXT, `session_data` JSONB, `expires_at` TIMESTAMPTZ, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
### LLM-Worker
#### `llm_workers`
Registrierte lokale LLM-Worker.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | LLM - Job Poll: `claim_llm_job`<br>LLM - Worker Registry: `update_llm_worker_status, upsert_llm_worker` |
| liest | KGW - KNOW Query Gateway: `find_active_llm_workers`<br>LLM - Router: `find_active_llm_workers` |
<details><summary>12 Spalten</summary>
`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
</details>
#### `llm_worker_blocklist`
Gesperrte Worker.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | LLM - Worker Registry: `blocklist_llm_worker` |
| liest | – |
<details><summary>6 Spalten</summary>
`id` BIGSERIAL, `mac` TEXT, `host` TEXT, `reason` TEXT, `hardware_json` JSONB, `blocked_at` TIMESTAMPTZ
</details>
#### `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`<br>LLM - Job Submit: `enqueue_llm_job`<br>LLM - Result Handler: `complete_llm_job`<br>LLM - Router: `cancel_llm_job, enqueue_llm_job` |
| liest | KGW - KNOW Query Gateway: `find_active_llm_workers`<br>LLM - Router: `find_active_llm_workers, get_llm_job` |
<details><summary>16 Spalten</summary>
`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
</details>
+1 -1
View File
@@ -29,7 +29,7 @@ Deterministisch:
- `prefer: largest` wählt die GPU mit dem meisten VRAM (für LLM-Rollen). - `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. - `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: <rolle>` teilen sich die GPU (bzw. bei `gpu: false` den Node) einer bereits platzierten Rolle. Ein Beispiel ist TTS auf `gpu-flex`. 3. Rollen mit `share_with: <rolle>` 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). 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}]`. 6. Ergebnis: `assignments[{role, node, gpu_index?, models_fitting[]}]` und `unplaced[{role, reason}]`.
+100
View File
@@ -0,0 +1,100 @@
# SPEC-ontology: Ontologie der Datenhaltung
Paket: `knaster.ontology`. Quelle: `ontology/know.yaml`. CLI: `knaster ontology …`.
Die Ontologie beschreibt, *was* in der Datenhaltung steht (Projekt, Kontakt, Ankaufsfall, Mail …) und wie die Begriffe zusammenhängen. Tabellen sind nur die Ablage dahinter. Sie ist die eine Quelle der Wahrheit; Turtle, Graph und Ansicht werden daraus erzeugt, nie von Hand gepflegt.
## 1. Drei Ebenen, drei Pflegeregeln
| Ebene | Inhalt in `know.yaml` | Wie sie sich ändert |
|---|---|---|
| Kern | `classes`, `relations`, `modules` | bewusst, per Commit (Review wie Code) |
| Vokabulare | `vocabularies.*.concepts`, `alt`, `rejected` | wachsen über Vorschläge der Service-Routine → Freigabe → `knaster ontology apply` |
| Instanzen | – (stehen in den Daten) | automatisch; unsichere Verknüpfungen später ebenfalls über Vorschläge |
Ohne diese Regeln zerfällt ein Vokabular wieder in mehrere Listen (KNOW-Befund B20). Deshalb legt nie ein Werkzeug oder LLM selbstständig Klassen oder Konzepte an; es schlägt sie vor.
## 2. Modell (`know.yaml`)
```yaml
id: know
version: 3 # steigt bei jeder Übernahme von Entscheidungen
namespace: urn:mygodel:know#
modules: {mail: Mail & Absender, …}
classes:
Mail:
label: Mail
module: mail
table: mail_events # fehlt = implizite Klasse (nur Wert in Spalten) oder externes System
key: message_id
definition: …
sensitive: … # steuert Datenschutz-Regeln (geplant: PSE/lokal)
subclass_of: Dokument # optional
attributes: {message_id: TEXT, …} # Spalten der Tabelle, Abbild des KNOW-Schemas
relations:
- {source: Mail, name: zugeordnetZu, target: Projekt, via: mail_events.assigned_project_key, kind: key}
# kind: key (gemeinsamer Schlüssel) | soft (Wertabgleich) | external (ID in KanBau/SharePoint/M365)
vocabularies:
MailKategorie:
label: Mail-Kategorie
class: MailKategorie # optional: Konzepte sind Ausprägungen dieser Klasse
columns: [mail_events.category, workflow_hints.hint_type, workflow_hint_config.config_key]
concepts:
buchhaltung: {label: Rechnungen, alt: [Buchhaltung]}
default: {label: Standard, technical: true}
rejected: [MPA] # bewusst kein Konzept
ignore: [sql:ASP - Absender-Report:…] # bewusst hingenommene Befunde
```
`Ontology.validate_model()` prüft: Module, Oberklassen und Beziehungsenden existieren; jede Tabelle gehört zu höchstens einer Klasse; Vokabel-Spalten existieren als Attribut; kein Name zeigt auf zwei Konzepte; kein verworfener Wert ist zugleich Konzept. `knaster validate` prüft das in der CI und warnt, wenn `know.ttl` nicht zur YAML passt.
## 3. Abgleich mit n8n (`check`)
Eingabe ist eine Momentaufnahme der n8n-Workflows (`build_snapshot`, nur GET `/api/v1/workflows` bzw. eine JSON-Datei):
- **KNOW-Spezifikation** aus dem Code des KNOW-Workflows: erlaubte Operationen (`const allowed`), Schema (`CREATE TABLE`, `ALTER … ADD COLUMN`), pro Operation geschriebene Spalten und dabei gesetzte Literale, Seeds (reine Literal-INSERTs).
- **Aufrufe** von KNOW mit Operation und Payload-Ausdruck. Wird die Operation erst zur Laufzeit gesetzt, werden Kandidaten aus den Literalen des Workflows abgeleitet (`inferred`).
- **Direktes SQL**: Postgres-Knoten außerhalb von KNOW, die das Schema `knowledge` ansprechen.
Ergebnisse (`OntologyFinding`, sortiert nach Schwere):
| Art | Schwere | Vorschlag? |
|---|---|---|
| `unmapped_table` – Tabelle ohne Klasse | medium | ja: `add_class` |
| `unmapped_column` – Spalte ohne Attribut | low | ja: `add_attribute` |
| `vocab_value` – Wert in einer Vokabel-Spalte, der kein Konzept ist | medium (low, wenn nur aus inaktiven Workflows) | ja: aufnehmen, zuordnen oder verwerfen |
| `unknown_operation` – Aufruf einer Operation, die KNOW nicht kennt | high (aktiv) / low | nein, Befund |
| `direct_sql` – SQL an KNOW vorbei | medium (schreibend) / low | nein |
| `missing_table` / `missing_column` – Ontologie verweist auf etwas, das KNOW nicht anlegt | medium / low | nein |
| `concept_without_producer` – Konzept, das keine *feste* Zuordnung erzeugt | info | nein |
| `no_write_operation` / `no_active_writer` – Klasse ohne Schreibweg | info | nein |
Werte werden nur aus Schlüsseln der obersten Payload-Ebene gelesen (verschachtelte JSON-Felder gehören zu einer JSON-Spalte). „Fest“ heißt: Literal oder Zuordnungstabelle mit Literal-Rückfall; bei freien Werten (LLM-Ausgabe) trifft die Prüfung keine Aussage. Entschiedene Schlüssel (Tabelle `ontology_decisions`) und `ignore` werden übersprungen.
## 4. Service-Routine und Freigabe
Schritt 8 der Routine (`MaintenanceAgent.run_routine`) ruft den Abgleich auf, wenn `ontology_path` existiert und eine Quelle konfiguriert ist (`KNASTER_ONTOLOGY_SNAPSHOT` oder `N8N_HOST` + `N8N_API_KEY`):
- Befunde ohne Vorschlag erscheinen als Zeilen „Ontologie [schwere]: …“ (höchstens `ontology_max_findings`, Standard 15).
- Befunde mit Vorschlag werden zu Vorschlägen der Art `ontology_change` (Richtlinie: immer Freigabe, Risiko niedrig), dedupliziert über `ontology:<key>`.
- `genehmige P-x` → Entscheidung `accept`; `ordne P-x <konzept> zu` → `map` (Wert wird alternative Schreibweise des Konzepts); `lehne P-x ab: …` → `reject` (Wert landet in `rejected` bzw. der Schlüssel in `ignore`).
- Entscheidungen liegen in `ontology_decisions`; ein entschiedener Schlüssel wird nicht erneut vorgeschlagen.
- `knaster ontology apply` schreibt offene Entscheidungen in `know.yaml`, erhöht `version`, prüft das Ergebnis und markiert die Entscheidungen als übernommen. Danach `knaster ontology export --ttl ontology/know.ttl` und committen.
Fällt n8n aus, meldet die Routine „Ontologie-Abgleich nicht möglich“ und läuft weiter.
## 5. CLI
| Befehl | Zweck |
|---|---|
| `knaster ontology validate` | Modell prüfen |
| `knaster ontology export --ttl F --graph F --html F` | Turtle (OWL + SKOS), Graph-JSON, eigenständige Ansicht (`ontology/viewer.html.tmpl`) |
| `knaster ontology snapshot --out F` | Workflows lesen und speichern (enthält Workflow-Code, nicht committen) |
| `knaster ontology check [--snapshot F] [--json]` | Abgleich; Exit 1 bei Befunden der Schwere high |
| `knaster ontology apply [--dry-run]` | freigegebene Entscheidungen übernehmen |
## 6. Grenzen und nächste Schritte
- Die Prüfung liest Code, keine Daten: ob Tabellen tatsächlich leer sind, zeigt erst eine Zählung in der Datenbank.
- Weiche Bezüge (z. B. Absender ↔ Kontakt) werden noch nicht automatisch als Verknüpfungsvorschläge erzeugt; geplant über Embeddings mit Schwelle (Instanz-Ebene).
- Aus der Ontologie sollen erzeugt werden: Sichten pro Klasse („Projekt 360°“), JSON-Schemas für Skill-Ausgaben, Werkzeugkatalog für Hermes/NEX, Datenschutz-Regeln aus `sensitive`.
+1906
View File
File diff suppressed because it is too large Load Diff
+1292
View File
File diff suppressed because it is too large Load Diff
+459
View File
@@ -0,0 +1,459 @@
<title>KNOW Ontologie</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@500;600&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,500&display=swap">
<style>
/* Layout: Begriffslandkarte – Klassen als beschriftete Kästen, Bereiche als Cluster; rechts das Register mit Definitionen und Turtle */
:root {
--bg: #f3f4f1;
--surface: #ffffff;
--fg: #1d2421;
--muted: #5f6b66;
--line: #d5dad6;
--edge: #8f9a94;
--key: #2e3a35;
--soft: #7b6a3f;
--ext: #5b7682;
--sys-bg: #1d2421;
--sys-fg: #f3f4f1;
--focus: #3f6f9f;
--warn: #b42318;
--d-mail: #3f7cac;
--d-kanbau: #2f8f6b;
--d-docs: #6d6a9e;
--d-kaf: #b8742a;
--d-finance: #8a5a44;
--d-learning: #7a8c2e;
--d-agent: #9b4f8f;
--d-bsb: #5b7682;
--d-llm: #c4471b;
--d-system: #1d2421;
--font-ui: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
--font-head: "IBM Plex Sans Condensed", "IBM Plex Sans", "Arial Narrow", system-ui, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #121615; --surface: #1a201e; --fg: #e3e8e5; --muted: #98a59f; --line: #2c3532; --edge: #5f6c66;
--key: #c9d2cd; --soft: #d2b56e; --ext: #8fb0bd; --sys-bg: #e3e8e5; --sys-fg: #121615; --focus: #78a9d6; --warn: #ff8a7a;
--d-mail: #6fa8d6; --d-kanbau: #5cc29b; --d-docs: #a29fd6; --d-kaf: #e0a35a; --d-finance: #c49176;
--d-learning: #b3c65a; --d-agent: #d283c4; --d-bsb: #8fb0bd; --d-llm: #f0784a; --d-system: #e3e8e5;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #121615; --surface: #1a201e; --fg: #e3e8e5; --muted: #98a59f; --line: #2c3532; --edge: #5f6c66;
--key: #c9d2cd; --soft: #d2b56e; --ext: #8fb0bd; --sys-bg: #e3e8e5; --sys-fg: #121615; --focus: #78a9d6; --warn: #ff8a7a;
--d-mail: #6fa8d6; --d-kanbau: #5cc29b; --d-docs: #a29fd6; --d-kaf: #e0a35a; --d-finance: #c49176;
--d-learning: #b3c65a; --d-agent: #d283c4; --d-bsb: #8fb0bd; --d-llm: #f0784a; --d-system: #e3e8e5;
color-scheme: dark;
}
html, body { height: 100%; }
body { background: var(--bg); color: var(--fg); font-family: var(--font-ui); font-size: 14px; }
.app { height: 100%; display: grid; grid-template-rows: auto 1fr; }
header { display: flex; flex-wrap: wrap; gap: 10px 20px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--surface); }
.brand { display: flex; flex-direction: column; gap: 2px; min-width: 0; margin-right: auto; }
h1 { margin: 0; font-family: var(--font-head); font-weight: 600; font-size: 1.35rem; letter-spacing: .01em; text-wrap: balance; }
.brand p { margin: 0; color: var(--muted); font-size: .82rem; }
.controls { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; }
#q { font: inherit; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); width: 15rem; max-width: 100%; }
.toggle { display: inline-flex; align-items: center; gap: 6px; font-size: .85rem; color: var(--muted); cursor: pointer; user-select: none; }
.toggle input { accent-color: var(--focus); }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
main { display: grid; grid-template-columns: minmax(0, 1fr) 400px; min-height: 0; }
.stage { position: relative; min-width: 0; min-height: 0; }
svg#graph { width: 100%; height: 100%; display: block; cursor: grab; touch-action: none; }
.legend { position: absolute; left: 12px; bottom: 12px; right: 12px; display: flex; flex-wrap: wrap; gap: 6px; pointer-events: none; }
.legend > * { pointer-events: auto; }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface); font-size: .78rem; color: var(--fg); cursor: pointer; font-family: var(--font-ui); }
.chip[aria-pressed="true"] { border-color: var(--fg); }
.sw { width: 10px; height: 10px; border-radius: 2px; display: inline-block; flex: none; }
.edgekey { display: inline-flex; gap: 12px; flex-wrap: wrap; padding: 4px 10px; border-radius: 6px; background: var(--surface); border: 1px solid var(--line); font-size: .78rem; color: var(--muted); }
.edgekey span { display: inline-flex; align-items: center; gap: 6px; }
.edgekey i { display: inline-block; width: 22px; height: 0; border-top: 2px solid; }
aside { border-left: 1px solid var(--line); background: var(--surface); overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 14px; min-width: 0; }
aside h2 { margin: 0; font-family: var(--font-head); font-weight: 600; font-size: 1.2rem; overflow-wrap: anywhere; }
aside h3 { margin: 0 0 6px; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); font-weight: 600; }
aside p { margin: 0; line-height: 1.5; }
.mono { font-family: var(--font-mono); font-size: .85em; }
.meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.tag { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: .75rem; border: 1px solid var(--line); color: var(--muted); }
.sensbox { border-left: 3px solid var(--d-kaf); padding: 6px 10px; background: var(--bg); border-radius: 4px; }
ul.rel { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
ul.rel li { display: grid; gap: 1px; }
.prop { font-family: var(--font-mono); font-size: .8rem; color: var(--muted); }
button.link { all: unset; cursor: pointer; color: var(--fg); font-weight: 500; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 3px; overflow-wrap: anywhere; }
button.link:hover { text-decoration-color: currentColor; }
button.link:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 2px; }
.via { color: var(--muted); font-family: var(--font-mono); font-size: .74rem; overflow-wrap: anywhere; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.stat { background: var(--bg); border-radius: 6px; padding: 8px 10px; display: grid; gap: 2px; }
.stat b { font-size: 1.3rem; font-family: var(--font-head); font-variant-numeric: tabular-nums; }
.stat span { font-size: .75rem; color: var(--muted); }
.vals { display: flex; flex-wrap: wrap; gap: 4px; }
.vals span { font-family: var(--font-mono); font-size: .74rem; padding: 1px 6px; border-radius: 4px; background: var(--bg); }
table.cols { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: .76rem; }
table.cols td { padding: 2px 0; border-bottom: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; }
table.cols td:last-child { color: var(--muted); text-align: right; padding-left: 8px; white-space: nowrap; }
details summary { cursor: pointer; color: var(--muted); font-size: .8rem; }
pre.ttl { margin: 8px 0 0; max-height: 340px; overflow: auto; background: var(--bg); padding: 10px; border-radius: 6px; font-family: var(--font-mono); font-size: .72rem; line-height: 1.45; white-space: pre; }
.btn { font: inherit; font-size: .8rem; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); cursor: pointer; }
.src { color: var(--muted); font-size: .75rem; line-height: 1.45; border-top: 1px solid var(--line); padding-top: 10px; }
.back { align-self: flex-start; font-size: .8rem; }
dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0; }
dl.kv dt { color: var(--muted); font-size: .8rem; }
dl.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
/* SVG */
.cls { cursor: pointer; }
.cls rect.box { fill: var(--surface); stroke-width: 1.6; }
.cls.implicit rect.box { stroke-dasharray: 4 3; }
.cls.system rect.box { fill: var(--sys-bg); stroke: var(--sys-bg); }
.cls.system text { fill: var(--sys-fg); }
.cls text { font-family: var(--font-ui); font-size: 11.5px; font-weight: 500; fill: var(--fg); pointer-events: none; }
.cls.abstract text { font-style: italic; }
.cls rect.dot { stroke: none; }
.cls.sel rect.box { stroke: var(--focus); stroke-width: 3; }
.cls.match text { fill: var(--focus); font-weight: 600; }
.ind circle { fill: var(--surface); stroke: var(--muted); stroke-width: 1.2; }
.ind text { font-family: var(--font-mono); font-size: 9.5px; fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: 3px; pointer-events: none; }
.rel path { fill: none; stroke-width: 1.4; }
.rel.key path { stroke: var(--key); }
.rel.soft path { stroke: var(--soft); stroke-dasharray: 5 3; }
.rel.external path { stroke: var(--ext); stroke-dasharray: 1 3; stroke-linecap: round; stroke-width: 1.8; }
.rel.subclass path { stroke: var(--edge); stroke-width: 1.6; }
.rel.instance path { stroke: var(--edge); stroke-width: .8; opacity: .7; }
.rel text { font-family: var(--font-mono); font-size: 9.5px; fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: 3.5px; stroke-linejoin: round; pointer-events: none; }
svg.nolabels .rel text { display: none; }
svg.focus .cls:not(.hl), svg.focus .ind:not(.hl) { opacity: .15; }
svg.focus .rel:not(.hl) { opacity: .06; }
.mk-key { fill: var(--key); } .mk-soft { fill: var(--soft); } .mk-ext { fill: var(--ext); } .mk-sub { fill: var(--bg); stroke: var(--edge); stroke-width: 1.2; }
@media (max-width: 860px) {
main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(440px, 64vh) auto; }
aside { border-left: 0; border-top: 1px solid var(--line); overflow: visible; }
.app { height: auto; min-height: 100%; }
#q { width: 100%; }
.controls { width: 100%; }
}
</style>
<div class="app">
<header>
<div class="brand">
<h1>KNOW Ontologie</h1>
<p>Begriffe der Wissensdatenbank <span class="mono">knowledge</span> und wie sie zusammenhängen, abgeleitet aus Schema und Joins</p>
</div>
<div class="controls">
<input id="q" type="search" placeholder="Begriff suchen" aria-label="Begriff suchen" autocomplete="off">
<label class="toggle"><input type="checkbox" id="t-labels" checked> Beziehungsnamen</label>
<label class="toggle"><input type="checkbox" id="t-soft" checked> Weiche Bezüge</label>
<label class="toggle"><input type="checkbox" id="t-ext" checked> Externe Systeme</label>
<label class="toggle"><input type="checkbox" id="t-ind"> Ausprägungen</label>
</div>
</header>
<main>
<div class="stage">
<svg id="graph" role="img" aria-label="Ontologie als Netz aus Klassen und Beziehungen"></svg>
<div class="legend" id="legend"></div>
</div>
<aside id="panel" aria-live="polite"></aside>
</main>
</div>
<script type="application/json" id="data">__DATA__</script>
<script type="application/json" id="ttl">__TTL__</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
<script>
(function () {
const DATA = JSON.parse(document.getElementById('data').textContent);
const TTL = JSON.parse(document.getElementById('ttl').textContent);
const MODULES = {
mail: 'Mail & Absender', kanbau: 'Kontakte & Projekte', docs: 'Dokumente', kaf: 'Ankauf (KAF)', finance: 'Buchhaltung',
learning: 'Lernen', agent: 'Agent', bsb: 'Browser-Sessions', llm: 'LLM-Worker', system: 'Externe Systeme'
};
const KIND = { key: 'Schlüsselbezug', soft: 'weicher Bezug (Wertabgleich)', external: 'Verweis auf externes System', subclass: 'Unterklasse von', instance: 'Ausprägung von' };
const css = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
const modColor = m => css('--d-' + m);
const classes = DATA.classes.map(c => Object.assign({ kind: 'class' }, c));
const byId = new Map(classes.map(c => [c.id, c]));
const inds = [];
classes.forEach(c => c.individuals.forEach(v => inds.push({ id: c.id + '::' + v, label: v, kind: 'ind', cls: c.id, module: c.module })));
inds.forEach(i => byId.set(i.id, i));
const rels = DATA.relations.map(r => Object.assign({}, r));
inds.forEach(i => rels.push({ source: i.id, target: i.cls, name: 'a', via: '', kind: 'instance' }));
const allNodes = classes.concat(inds);
const state = { labels: true, soft: true, ext: true, ind: false, selected: null, module: null, query: '' };
const svg = d3.select('#graph');
const defs = svg.append('defs');
[['key', 'mk-key'], ['soft', 'mk-soft'], ['ext', 'mk-ext']].forEach(([k, cls]) => {
defs.append('marker').attr('id', 'm-' + k).attr('viewBox', '0 -4 8 8').attr('refX', 7).attr('refY', 0)
.attr('markerWidth', 7).attr('markerHeight', 7).attr('orient', 'auto')
.append('path').attr('d', 'M0,-4L8,0L0,4Z').attr('class', cls);
});
defs.append('marker').attr('id', 'm-sub').attr('viewBox', '0 -6 12 12').attr('refX', 11).attr('refY', 0)
.attr('markerWidth', 11).attr('markerHeight', 11).attr('orient', 'auto').attr('markerUnits', 'userSpaceOnUse')
.append('path').attr('d', 'M0,-6L11,0L0,6Z').attr('class', 'mk-sub');
const root = svg.append('g');
const gRels = root.append('g');
const gNodes = root.append('g');
const zoom = d3.zoom().scaleExtent([0.2, 3.5]).on('zoom', e => root.attr('transform', e.transform));
svg.call(zoom).on('dblclick.zoom', null);
svg.on('click', e => { if (e.target === svg.node()) select(null); });
// Textbreite messen, damit Kästen zur Beschriftung passen
const measure = (() => { const c = document.createElement('canvas').getContext('2d'); return (t, f) => { c.font = f; return c.measureText(t).width; }; })();
classes.forEach(c => { c.w = Math.max(54, measure(c.label, '500 11.5px "IBM Plex Sans", system-ui') + 26); c.h = 26; });
inds.forEach(i => { i.w = 8; i.h = 8; });
const modKeys = Object.keys(MODULES);
const anchors = {};
modKeys.forEach((m, i) => {
if (m === 'system') { anchors[m] = { x: 0, y: 0 }; return; }
const n = modKeys.length - 1, a = (i / n) * Math.PI * 2 - Math.PI / 2;
anchors[m] = { x: Math.cos(a) * 640, y: Math.sin(a) * 440 };
});
const sim = d3.forceSimulation()
.force('link', d3.forceLink().id(d => d.id).distance(l => l.kind === 'instance' ? 34 : l.kind === 'external' ? 200 : 140).strength(l => l.kind === 'instance' ? 0.9 : l.kind === 'external' ? 0.08 : 0.3))
.force('charge', d3.forceManyBody().strength(d => d.kind === 'ind' ? -40 : -560))
.force('collide', d3.forceCollide(d => d.kind === 'ind' ? 10 : d.w / 2 + 14))
.force('x', d3.forceX(d => anchors[d.module].x).strength(d => d.kind === 'ind' ? 0.01 : 0.12))
.force('y', d3.forceY(d => anchors[d.module].y).strength(d => d.kind === 'ind' ? 0.01 : 0.12))
.stop();
function visible() {
const ns = allNodes.filter(n => {
if (n.kind === 'ind') return state.ind;
if (n.module === 'system') return state.ext;
return true;
});
const ids = new Set(ns.map(n => n.id));
const ls = rels.filter(r => {
const s = typeof r.source === 'object' ? r.source.id : r.source, t = typeof r.target === 'object' ? r.target.id : r.target;
if (!ids.has(s) || !ids.has(t)) return false;
if (r.kind === 'soft') return state.soft;
return true;
});
return { ns, ls };
}
let relSel, nodeSel;
function render(initial) {
const { ns, ls } = visible();
sim.nodes(ns); sim.force('link').links(ls);
relSel = gRels.selectAll('g.rel').data(ls, r => r.source.id + '|' + r.name + '|' + r.target.id).join(enter => {
const g = enter.append('g');
g.append('path');
g.append('text').attr('text-anchor', 'middle').attr('dy', -3);
g.append('title');
return g;
});
relSel.attr('class', r => 'rel ' + r.kind);
relSel.select('path').attr('marker-end', r => r.kind === 'key' ? 'url(#m-key)' : r.kind === 'soft' ? 'url(#m-soft)' : r.kind === 'external' ? 'url(#m-ext)' : r.kind === 'subclass' ? 'url(#m-sub)' : null);
relSel.select('text').text(r => r.kind === 'instance' ? '' : r.kind === 'subclass' ? 'ist ein' : r.name);
relSel.select('title').text(r => r.kind === 'instance' ? `${r.source.label} ist Ausprägung von ${r.target.label}` : `${r.source.label} ${r.kind === 'subclass' ? 'ist Unterklasse von' : r.name} ${r.target.label}\n${KIND[r.kind]}${r.via ? '\nüber ' + r.via : ''}`);
nodeSel = gNodes.selectAll('g.node').data(ns, d => d.id).join(enter => {
const g = enter.append('g').attr('tabindex', 0).attr('role', 'button')
.attr('aria-label', d => (d.kind === 'ind' ? 'Ausprägung ' : 'Klasse ') + d.label);
g.each(function (d) {
const s = d3.select(this);
if (d.kind === 'ind') {
s.append('circle').attr('r', 4);
s.append('text').attr('x', 7).attr('y', 3).text(d.label);
} else {
s.append('rect').attr('class', 'box').attr('x', -d.w / 2).attr('y', -d.h / 2).attr('width', d.w).attr('height', d.h).attr('rx', 13);
if (d.module !== 'system') s.append('rect').attr('class', 'dot').attr('x', -d.w / 2 + 9).attr('y', -4).attr('width', 8).attr('height', 8).attr('rx', 2);
s.append('text').attr('x', d.module === 'system' ? 0 : 6).attr('y', 4).attr('text-anchor', 'middle').text(d.label);
}
});
g.on('click', (e, d) => { e.stopPropagation(); select(d.kind === 'ind' ? d.cls : d.id, false); })
.on('keydown', (e, d) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); select(d.kind === 'ind' ? d.cls : d.id); } })
.on('mouseenter', (e, d) => hover(d.kind === 'ind' ? d.cls : d.id)).on('mouseleave', () => hover(null))
.call(d3.drag()
.on('start', (e, d) => { if (!e.active) sim.alphaTarget(0.2).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
.on('end', (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }));
return g;
});
nodeSel.attr('class', d => d.kind === 'ind' ? 'node ind' : ['node', 'cls', d.table ? '' : 'implicit', d.module === 'system' ? 'system' : '', d.abstract ? 'abstract' : ''].join(' '));
recolor();
const n = initial ? 360 : 120;
sim.alpha(initial ? 1 : 0.6); for (let i = 0; i < n; i++) sim.tick();
draw(); sim.on('tick', draw);
applyHighlight();
}
function recolor() {
if (!nodeSel) return;
nodeSel.select('rect.box').attr('stroke', d => d.module === 'system' ? null : modColor(d.module));
nodeSel.select('rect.dot').attr('fill', d => modColor(d.module));
}
// Kante am Kastenrand enden lassen
function edgePoint(n, tx, ty) {
const dx = tx - n.x, dy = ty - n.y;
if (n.kind === 'ind') { const l = Math.hypot(dx, dy) || 1; return [n.x + dx / l * 5, n.y + dy / l * 5]; }
const hw = n.w / 2 + 2, hh = n.h / 2 + 2;
const s = Math.min(Math.abs(dx) > 1e-6 ? hw / Math.abs(dx) : Infinity, Math.abs(dy) > 1e-6 ? hh / Math.abs(dy) : Infinity);
return [n.x + dx * s, n.y + dy * s];
}
function draw() {
relSel.each(function (r) {
const g = d3.select(this);
if (r.source === r.target) {
const n = r.source, sx = n.x + n.w / 4, sy = n.y - n.h / 2 - 2, ex = n.x + n.w / 2 + 2, ey = n.y;
g.select('path').attr('d', `M${sx},${sy}C${sx},${sy - 40} ${ex + 44},${ey} ${ex},${ey}`);
g.select('text').attr('x', ex + 18).attr('y', sy - 22);
return;
}
const [x1, y1] = edgePoint(r.source, r.target.x, r.target.y);
const [x2, y2] = edgePoint(r.target, r.source.x, r.source.y);
g.select('path').attr('d', `M${x1},${y1}L${x2},${y2}`);
g.select('text').attr('x', (x1 + x2) / 2).attr('y', (y1 + y2) / 2);
});
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
}
function fit() {
const box = root.node().getBBox(); const w = svg.node().clientWidth, h = svg.node().clientHeight;
if (!box.width || !w || !h) return;
const k = Math.min(1.4, 0.94 / Math.max(box.width / w, box.height / (h - 60)));
svg.call(zoom.transform, d3.zoomIdentity.translate(w / 2 - k * (box.x + box.width / 2), (h - 50) / 2 - k * (box.y + box.height / 2)).scale(k));
}
let hoverId = null;
function hover(id) { hoverId = id; applyHighlight(); }
function applyHighlight() {
const f = hoverId || state.selected;
let ns = null, ls = null;
if (f) {
ns = new Set([f]); ls = new Set();
relSel.each(r => { if (r.source.id === f || r.target.id === f) { ns.add(r.source.id); ns.add(r.target.id); ls.add(r); } });
} else if (state.module) {
ns = new Set(allNodes.filter(n => n.module === state.module).map(n => n.id)); ls = new Set();
relSel.each(r => { if (r.source.module === state.module || r.target.module === state.module) { ls.add(r); ns.add(r.source.id); ns.add(r.target.id); } });
}
svg.classed('focus', !!ns);
nodeSel.classed('hl', d => !!ns && ns.has(d.id)).classed('sel', d => d.id === state.selected)
.classed('match', d => !!state.query && d.kind !== 'ind' && (d.label.toLowerCase().includes(state.query) || (d.table || '').includes(state.query)));
relSel.classed('hl', r => !!ls && ls.has(r));
}
// ---------- Panel ----------
const panel = document.getElementById('panel');
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const btn = id => `<button class="link" data-id="${esc(id)}">${esc(byId.get(id).label)}</button>`;
const idOf = x => typeof x === 'object' ? x.id : x;
function relItems(list, dir) {
if (!list.length) return '<p class="via">keine</p>';
return '<ul class="rel">' + list.map(r => {
const other = dir === 'out' ? idOf(r.target) : idOf(r.source);
const name = r.kind === 'subclass' ? 'ist ein' : r.name;
const line = dir === 'out' ? `<span class="prop">${esc(name)} →</span> ${btn(other)}` : `${btn(other)} <span class="prop">${esc(name)} →</span>`;
return `<li><span>${line}</span><span class="via">${esc(KIND[r.kind])}${r.via ? ' · ' + esc(r.via) : ''}</span></li>`;
}).join('') + '</ul>';
}
function overview() {
const implicit = classes.filter(c => !c.table && c.module !== 'system').length;
const nRel = DATA.relations.filter(r => r.kind !== 'subclass').length;
panel.innerHTML = `
<div class="stats">
<div class="stat"><b>${classes.length}</b><span>Klassen, davon ${implicit} implizit</span></div>
<div class="stat"><b>${nRel}</b><span>Beziehungen</span></div>
<div class="stat"><b>${inds.length}</b><span>Ausprägungen</span></div>
</div>
<p>Jede der ${classes.filter(c => c.table).length} Tabellen ist eine Klasse. Dazu kommen <b>implizite Klassen</b> (gestrichelt): Begriffe ohne eigene Tabelle, die nur als Wert in Spalten vorkommen, etwa Postfach, n8n-Workflow, Gesellschaft oder Tier. Die Beziehungen stammen aus Schlüsselspalten und den Joins im KNOW-Code.</p>
<section><h3>Lesart</h3><dl class="kv">
<dt>Schlüsselbezug</dt><dd>gemeinsamer Schlüssel, z. B. <span class="mono">project_key</span></dd>
<dt>weicher Bezug</dt><dd>Abgleich über einen Wert wie E-Mail oder Adresse, ohne festen Schlüssel</dd>
<dt>externer Verweis</dt><dd>ID oder Pfad in KanBau, SharePoint oder Microsoft 365</dd>
<dt>ist ein</dt><dd>Unterklasse, z. B. Falldokument ist ein Dokument</dd>
</dl></section>
<section><h3>Auffällig</h3><p>${btn('Mail')} ist der zentrale Eingang: Absender, Anhang-Extraktion, Projektereignis und Rechnungsbeobachtung hängen an ihr. ${btn('Projekt')} ist der zentrale Bezugspunkt. ${btn('Ankaufsfall')} soll über <span class="mono">selected_project_id</span> zum Projekt werden; laut Befund B5 schreibt aber kein Workflow Ankaufsfälle.</p></section>
<section><h3>OWL / Turtle</h3>
<p>Dieselbe Ontologie als Turtle-Datei, mit Klassen, Objekt- und Datentyp-Properties und Ausprägungen. Quelle im Repo ist <span class="mono">ontology/know.yaml</span>, daraus erzeugt <span class="mono">ontology/know.ttl</span>.</p>
<div class="meta" style="margin-top:8px"><button class="btn" id="copy">Turtle kopieren</button><span class="via" id="copied" role="status"></span></div>
<details><summary>Turtle anzeigen</summary><pre class="ttl" id="ttlpre"></pre></details>
</section>
<p class="src">Erzeugt aus ontology/know.yaml (Version ${esc(DATA.version)}) am ${esc(DATA.generated)}. Grundlage: Postgres-Schema und SQL-Vorlagen aus dem n8n-Workflow „KNOW - Knowledge Base Gateway“. Datenbankinhalte wurden nicht gelesen. Die Namen der Beziehungen und die impliziten Klassen sind eine Modellierung aus diesen Quellen und nicht im Schema festgelegt.</p>`;
document.getElementById('ttlpre').textContent = TTL;
const status = document.getElementById('copied');
document.getElementById('copy').addEventListener('click', () => {
const done = () => { status.textContent = 'Kopiert'; };
const fallback = () => { const d = panel.querySelector('details'); d.open = true; const r = document.createRange(); r.selectNodeContents(document.getElementById('ttlpre')); const s = getSelection(); s.removeAllRanges(); s.addRange(r); status.textContent = 'Markiert, mit Strg/Cmd+C kopieren'; };
try { navigator.clipboard.writeText(TTL).then(done, fallback); } catch (e) { fallback(); }
});
}
function detail(id) {
const c = byId.get(id);
const out = rels.filter(r => idOf(r.source) === id && r.kind !== 'instance');
const inc = rels.filter(r => idOf(r.target) === id && r.kind !== 'instance');
const enums = Object.entries(c.enums || {});
panel.innerHTML = `<button class="link back" data-id="">← Überblick</button>
<h2>${esc(c.label)}</h2>
<div class="meta"><span class="chip" style="cursor:default"><span class="sw" style="background:var(--d-${c.module})"></span>${esc(MODULES[c.module])}</span>
${c.table ? `<span class="tag mono">${esc(c.table)}</span>` : c.module === 'system' ? '<span class="tag">externes System</span>' : '<span class="tag">implizit, ohne eigene Tabelle</span>'}
${c.abstract ? '<span class="tag">abstrakt</span>' : ''}</div>
<p>${esc(c.def)}</p>
<dl class="kv"><dt>Schlüssel</dt><dd class="mono">${esc(c.key)}</dd><dt>OWL</dt><dd class="mono">know:${esc(c.id)}</dd></dl>
${c.sens ? `<div class="sensbox"><h3>Sensible Inhalte</h3><p>${esc(c.sens)}</p></div>` : ''}
<section><h3>Beziehungen von hier</h3>${relItems(out, 'out')}</section>
<section><h3>Beziehungen hierher</h3>${relItems(inc, 'in')}</section>
${c.individuals.length ? `<section><h3>Ausprägungen</h3><div class="vals">${c.individuals.map(v => `<span>${esc(v)}</span>`).join('')}</div></section>` : ''}
${enums.map(([k, v]) => `<section><h3>Werte von ${esc(k)}</h3><div class="vals">${v.map(x => `<span>${esc(x)}</span>`).join('')}</div></section>`).join('')}
${c.attrs.length ? `<section><details><summary>${c.attrs.length} Attribute (Datentyp-Properties)</summary><table class="cols">${c.attrs.map(a => `<tr><td>${esc(a[0])}</td><td>${esc(a[1])}</td></tr>`).join('')}</table></details></section>` : ''}`;
}
panel.addEventListener('click', e => { const b = e.target.closest('button[data-id]'); if (b) select(b.getAttribute('data-id') || null, true); });
function select(id, center) {
state.selected = id; state.module = null; updateChips();
if (id) detail(id); else overview();
applyHighlight();
if (id && center) { const n = byId.get(id); if (n && n.x != null) svg.transition().duration(matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 450).call(zoom.translateTo, n.x, n.y); }
}
// ---------- Legende und Steuerung ----------
const legend = document.getElementById('legend');
legend.innerHTML = modKeys.filter(m => m !== 'system').map(m => `<button class="chip" data-mod="${m}" aria-pressed="false"><span class="sw" style="background:var(--d-${m})"></span>${MODULES[m]}</button>`).join('') +
`<span class="edgekey"><span><i style="border-color:var(--key)"></i>Schlüssel</span><span><i style="border-color:var(--soft);border-top-style:dashed"></i>weich</span><span><i style="border-color:var(--ext);border-top-style:dotted"></i>extern</span><span><i style="border-color:var(--edge)"></i>▷ ist ein</span></span>`;
legend.addEventListener('click', e => {
const b = e.target.closest('[data-mod]'); if (!b) return;
const m = b.getAttribute('data-mod'); state.module = state.module === m ? null : m; state.selected = null; overview(); updateChips(); applyHighlight();
});
function updateChips() { legend.querySelectorAll('[data-mod]').forEach(b => b.setAttribute('aria-pressed', String(b.getAttribute('data-mod') === state.module))); }
[['t-labels', 'labels'], ['t-soft', 'soft'], ['t-ext', 'ext'], ['t-ind', 'ind']].forEach(([id, key]) => {
const el = document.getElementById(id);
try { const v = localStorage.getItem('know-onto:' + key); if (v !== null) el.checked = v === '1'; } catch (e) {}
state[key] = el.checked;
el.addEventListener('change', () => {
state[key] = el.checked;
try { localStorage.setItem('know-onto:' + key, el.checked ? '1' : '0'); } catch (e) {}
if (key === 'labels') { svg.classed('nolabels', !state.labels); return; }
if (state.selected && !visible().ns.some(n => n.id === state.selected)) select(null);
render(false);
});
});
svg.classed('nolabels', !state.labels);
const q = document.getElementById('q');
q.addEventListener('input', () => { state.query = q.value.trim().toLowerCase(); applyHighlight(); });
q.addEventListener('keydown', e => {
if (e.key !== 'Enter' || !state.query) return;
const hit = classes.find(c => c.label.toLowerCase().includes(state.query) || (c.table || '').includes(state.query));
if (hit && visible().ns.includes(hit)) select(hit.id, true);
});
const mq = matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener && mq.addEventListener('change', recolor);
new MutationObserver(recolor).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
render(true);
overview();
requestAnimationFrame(fit);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => {
classes.forEach(c => { c.w = Math.max(54, measure(c.label, '500 11.5px "IBM Plex Sans", system-ui') + 26); });
nodeSel.select('rect.box').attr('x', d => -d.w / 2).attr('width', d => d.w);
nodeSel.select('rect.dot').attr('x', d => -d.w / 2 + 9);
draw();
});
window.addEventListener('resize', () => requestAnimationFrame(fit));
})();
</script>
+2 -2
View File
@@ -7,8 +7,8 @@ Proxmox-spezifische Schicht (austauschbar, siehe PLAN §7.2). Eingabe ist immer
## Ablauf ## 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). 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. 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 Node ein Ubuntu-24.04-Cloud-Image als Template mit VMID 9000. 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`. 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. 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/<rolle>` auf den jeweiligen Gästen starten. 6. **Dienste:** `deploy/compose/<rolle>` auf den jeweiligen Gästen starten.
@@ -37,6 +37,7 @@
- "--include=/config/***" - "--include=/config/***"
- "--include=/catalog/***" - "--include=/catalog/***"
- "--include=/skills/***" - "--include=/skills/***"
- "--include=/ontology/***" # Router-Image kopiert ontology/ (KNASTER_ONTOLOGY)
- "--include=/sites/***" - "--include=/sites/***"
- "--include=/deploy/***" - "--include=/deploy/***"
- "--exclude=*" - "--exclude=*"
+3
View File
@@ -34,14 +34,17 @@ nodes:
cpu: {model: "Intel Core i5-8350U", cores: 4, threads: 8} cpu: {model: "Intel Core i5-8350U", cores: 4, threads: 8}
memory_gb: 8 memory_gb: 8
disks: [{name: nvme0n1, size_gb: 256}] disks: [{name: nvme0n1, size_gb: 256}]
cpu_roles: false # Arbeitsplatz/Kiosk: keine CPU-Rollen (Placement)
gpus: [] gpus: []
- name: gode-pc-05 # Windows, tagsüber Arbeitsplatz; CPU offen (Task 0.1 e2) - name: gode-pc-05 # Windows, tagsüber Arbeitsplatz; CPU offen (Task 0.1 e2)
cpu: {model: "unbekannt"} cpu: {model: "unbekannt"}
memory_gb: 64 memory_gb: 64
cpu_roles: false # Arbeitsplatz/Kiosk: keine CPU-Rollen (Placement)
gpus: gpus:
- {index: 0, name: "NVIDIA RTX 4000 Ada Generation", memory_gb: 20, compute_cap: 8.9} - {index: 0, name: "NVIDIA RTX 4000 Ada Generation", memory_gb: 20, compute_cap: 8.9}
- name: gode-pc-06 # Windows, tagsüber Arbeitsplatz; CPU offen (Task 0.1 e2) - name: gode-pc-06 # Windows, tagsüber Arbeitsplatz; CPU offen (Task 0.1 e2)
cpu: {model: "unbekannt"} cpu: {model: "unbekannt"}
memory_gb: 64 memory_gb: 64
cpu_roles: false # Arbeitsplatz/Kiosk: keine CPU-Rollen (Placement)
gpus: gpus:
- {index: 0, name: "NVIDIA RTX 4000 Ada Generation", memory_gb: 20, compute_cap: 8.9} - {index: 0, name: "NVIDIA RTX 4000 Ada Generation", memory_gb: 20, compute_cap: 8.9}