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