diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml index 55ea082..4af41a7 100644 --- a/.gitea/workflows/deploy-prod.yml +++ b/.gitea/workflows/deploy-prod.yml @@ -25,7 +25,17 @@ jobs: fi docker compose build --no-cache backend frontend echo "✓ Backend + Frontend gebaut (Frontend: npm run build im Dockerfile)" - docker compose up -d --wait - curl -sf http://localhost:8004/api/health && echo "✓ PROD API /api/health OK" + if ! docker compose up -d --wait; then + echo "✗ compose up --wait fehlgeschlagen — Backend-Logs:" + docker compose logs backend --tail 150 || true + docker compose ps || true + exit 1 + fi + if ! curl -sf http://localhost:8004/api/health; then + echo "✗ PROD API nicht erreichbar — Backend-Logs:" + docker compose logs backend --tail 150 || true + exit 1 + fi + echo "✓ PROD API /api/health OK" curl -sf http://localhost:3004/api/health && echo "✓ PROD Frontend-Proxy /api/health OK" echo "=== Kairo PROD Deploy complete ===" diff --git a/README.md b/README.md index afe3885..239c2e2 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,48 @@ Migrationen & idempotente Data-Seeds: [docs/MIGRATIONS.md](docs/MIGRATIONS.md) | `/api/me/admin/demo` | GET | `X-Auth-Token` + `kairo.admin.access` | Beispiel-Endpoint mit `require_capability` | | `/api/me/tenant` | POST | `X-Auth-Token` | Aktiven Tenant wechseln (nur Memberships) | +### Registries (AP0.4) + +Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB. + +| Endpoint | Methode | Capability | Beschreibung | +|----------|---------|------------|--------------| +| `/api/features` | GET | `kairo.feature.registry.read` | Feature-Katalog | +| `/api/prompts` | GET | `kairo.prompt.registry.read` | Prompt-Definitionen | +| `/api/prompts/{key}` | GET | `kairo.prompt.registry.read` | Definition + aktive Version | +| `/api/prompts/{key}/render` | POST | `kairo.prompt.render` | Single-Mode Rendering (ohne LLM) | +| `/api/config` | GET | `kairo.config.registry.read` | Konfiguration (global/tenant) | +| `/api/config` | POST | `kairo.config.registry.manage` | Konfiguration setzen | + +Startup: `sync_prompt_feature_config.py` nach Rights-Sync (`SKIP_REGISTRY_SYNC=1` zum Überspringen). + +**Prompt-Modell:** `PromptDefinition` → `PromptVersion` → optional `PromptStep` (workflow-/pipeline-fähig; AP0.4 führt nur `execution_mode=single` aus). + +**Beispiel-Prompts:** `kairo.system.health_summary`, `kairo.context.debug_summary` + +**Features im Entitlements-Snapshot:** `kairo.prompt.registry`, `kairo.config.registry`, `kairo.feature.registry` + +| Capability | Modul | Kurzbeschreibung | +|------------|-------|------------------| +| `kairo.feature.registry.read` | feature | Feature-Katalog lesen | +| `kairo.feature.registry.manage` | feature | Feature-Metadaten verwalten | +| `kairo.prompt.registry.read` | prompt | Prompt Registry lesen | +| `kairo.prompt.registry.manage` | prompt | Prompt Registry verwalten | +| `kairo.config.registry.read` | config | Config lesen | +| `kairo.config.registry.manage` | config | Config schreiben | +| `kairo.prompt.render` | prompt | Prompt rendern (Test/Preview) | + +Zusätzlich AP0.3-Capabilities (siehe oben). + +Enforcement: `CAPABILITY_ENFORCE=probe` (Default) oder `enforce`. + +```bash +curl -s http://localhost:8097/api/me/entitlements -H "X-Auth-Token: TOKEN" +curl -s -X POST http://localhost:8097/api/prompts/kairo.system.health_summary/render \ + -H "X-Auth-Token: TOKEN" -H "Content-Type: application/json" \ + -d '{"values":{"debug_message":"OK","debug_level":"info"},"use_context":false}' +``` + ### Capabilities (AP0.3) Registry-first: Capabilities werden in `backend/rights_registrations/` registriert und beim Start via `sync_rights_registry.py` in die DB synchronisiert. diff --git a/backend/capabilities.py b/backend/capabilities.py index a5509ac..122d8dc 100644 --- a/backend/capabilities.py +++ b/backend/capabilities.py @@ -9,7 +9,7 @@ from fastapi import Depends, HTTPException from rights_registry import load_grants_for_roles from services.audit import log_audit -from tenant_context import TenantContext, require_tenant_context +from tenant_context import TenantContext, get_tenant_context, require_tenant_context CAPABILITY_ENFORCE_ENV = "CAPABILITY_ENFORCE" @@ -40,27 +40,40 @@ def require_capability(capability_key: str) -> Callable[..., TenantContext]: """FastAPI dependency — blocks in enforce mode, probes in probe mode.""" def _dependency(ctx: TenantContext = Depends(require_tenant_context)) -> TenantContext: - result = check_capability(ctx, capability_key) - if result["allowed"]: - return ctx - - log_audit( - "capability.denied", - user_id=ctx.user_id, - tenant_id=ctx.tenant_id, - details={ - "capability_key": capability_key, - "mode": capability_enforcement_mode(), - "portal_role": ctx.portal_role, - "tenant_role": ctx.tenant_role, - }, - ) - - if capability_enforcement_mode() == "enforce": - raise HTTPException( - status_code=403, - detail=f"Capability fehlt: {capability_key}", - ) - return ctx + return _enforce_capability(ctx, capability_key) return _dependency + + +def require_capability_ctx(capability_key: str) -> Callable[..., TenantContext]: + """Like require_capability but allows portal context without active tenant.""" + + def _dependency(ctx: TenantContext = Depends(get_tenant_context)) -> TenantContext: + return _enforce_capability(ctx, capability_key) + + return _dependency + + +def _enforce_capability(ctx: TenantContext, capability_key: str) -> TenantContext: + result = check_capability(ctx, capability_key) + if result["allowed"]: + return ctx + + log_audit( + "capability.denied", + user_id=ctx.user_id, + tenant_id=ctx.tenant_id, + details={ + "capability_key": capability_key, + "mode": capability_enforcement_mode(), + "portal_role": ctx.portal_role, + "tenant_role": ctx.tenant_role, + }, + ) + + if capability_enforcement_mode() == "enforce": + raise HTTPException( + status_code=403, + detail=f"Capability fehlt: {capability_key}", + ) + return ctx diff --git a/backend/config_service.py b/backend/config_service.py new file mode 100644 index 0000000..1abeb43 --- /dev/null +++ b/backend/config_service.py @@ -0,0 +1,208 @@ +"""Central configuration service — no plaintext secrets.""" + +from __future__ import annotations + +import json +from typing import Any + +from db import get_connection +from services.audit import log_audit + +SECRET_VALUE_FORBIDDEN = ( + "Configuration entries with is_secret=true must not store plaintext values; " + "use environment or secret management." +) + + +def _validate_secret_entry(*, is_secret: bool, value: Any) -> None: + if not is_secret: + return + if value not in (None, {}, "", {"ref": "env"}): + raise ValueError(SECRET_VALUE_FORBIDDEN) + + +def get_config( + config_key: str, + *, + scope: str = "global", + tenant_id: str | None = None, +) -> dict | None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT config_key, scope, tenant_id, value, value_type, description, is_secret + FROM configuration_entries + WHERE config_key = %s AND scope = %s + AND ( + (scope = 'global' AND tenant_id IS NULL) + OR (scope = 'tenant' AND tenant_id = %s::uuid) + ) + """, + (config_key, scope, tenant_id), + ) + row = cur.fetchone() + if not row: + return None + return { + "config_key": row[0], + "scope": row[1], + "tenant_id": str(row[2]) if row[2] else None, + "value": row[3], + "value_type": row[4], + "description": row[5], + "is_secret": row[6], + } + finally: + conn.close() + + +def list_configs(*, scope: str | None = None, tenant_id: str | None = None) -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + if scope == "global": + cur.execute( + """ + SELECT config_key, scope, tenant_id, value, value_type, description, is_secret + FROM configuration_entries + WHERE scope = 'global' AND tenant_id IS NULL + ORDER BY config_key + """ + ) + elif scope == "tenant" and tenant_id: + cur.execute( + """ + SELECT config_key, scope, tenant_id, value, value_type, description, is_secret + FROM configuration_entries + WHERE scope = 'tenant' AND tenant_id = %s::uuid + ORDER BY config_key + """, + (tenant_id,), + ) + else: + cur.execute( + """ + SELECT config_key, scope, tenant_id, value, value_type, description, is_secret + FROM configuration_entries + ORDER BY scope, config_key + """ + ) + rows = [] + for row in cur.fetchall(): + item = { + "config_key": row[0], + "scope": row[1], + "tenant_id": str(row[2]) if row[2] else None, + "value_type": row[4], + "description": row[5], + "is_secret": row[6], + } + if row[6]: + item["value"] = {"ref": "env"} + else: + item["value"] = row[3] + rows.append(item) + return rows + finally: + conn.close() + + +def upsert_config( + *, + config_key: str, + scope: str, + value: Any, + value_type: str = "string", + description: str = "", + is_secret: bool = False, + tenant_id: str | None = None, + user_id: str | None = None, +) -> dict: + _validate_secret_entry(is_secret=is_secret, value=value) + if scope == "tenant" and not tenant_id: + raise ValueError("tenant_id required for tenant-scoped config") + if scope == "global": + tenant_id = None + + conn = get_connection() + try: + with conn.cursor() as cur: + if scope == "global": + cur.execute( + """ + INSERT INTO configuration_entries ( + config_key, scope, tenant_id, value, value_type, description, is_secret + ) + VALUES (%s, 'global', NULL, %s::jsonb, %s, %s, %s) + ON CONFLICT (config_key) WHERE scope = 'global' DO UPDATE SET + value = EXCLUDED.value, + value_type = EXCLUDED.value_type, + description = EXCLUDED.description, + is_secret = EXCLUDED.is_secret, + updated_at = NOW() + """, + ( + config_key, + json.dumps(value if not is_secret else {"ref": "env"}), + value_type, + description, + is_secret, + ), + ) + else: + cur.execute( + """ + INSERT INTO configuration_entries ( + config_key, scope, tenant_id, value, value_type, description, is_secret + ) + VALUES (%s, 'tenant', %s::uuid, %s::jsonb, %s, %s, %s) + ON CONFLICT (config_key, tenant_id) WHERE scope = 'tenant' DO UPDATE SET + value = EXCLUDED.value, + value_type = EXCLUDED.value_type, + description = EXCLUDED.description, + is_secret = EXCLUDED.is_secret, + updated_at = NOW() + """, + ( + config_key, + tenant_id, + json.dumps(value if not is_secret else {"ref": "env"}), + value_type, + description, + is_secret, + ), + ) + conn.commit() + finally: + conn.close() + + log_audit( + "config.entry.changed", + user_id=user_id, + tenant_id=tenant_id, + details={"config_key": config_key, "scope": scope, "is_secret": is_secret}, + ) + result = get_config(config_key, scope=scope, tenant_id=tenant_id) + assert result is not None + return result + + +def sync_default_configs(defaults: list[dict]) -> int: + if not defaults: + return 0 + for item in defaults: + existing = get_config(item["config_key"], scope=item.get("scope", "global")) + if existing: + continue + upsert_config( + config_key=item["config_key"], + scope=item.get("scope", "global"), + value=item["value"], + value_type=item.get("value_type", "string"), + description=item.get("description", ""), + is_secret=item.get("is_secret", False), + ) + print(f"[config_service] Default configs ensured — {len(defaults)} item(s)") + return 0 diff --git a/backend/entitlements.py b/backend/entitlements.py index d1088f2..52e56e8 100644 --- a/backend/entitlements.py +++ b/backend/entitlements.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Any from capabilities import capability_enforcement_mode, check_capability +from feature_registry import load_active_features from rights_registry import get_registered_capabilities from tenant_context import TenantContext @@ -17,7 +18,20 @@ def _capability_map(ctx: TenantContext) -> dict[str, dict[str, Any]]: return snapshot +def _feature_snapshot() -> dict[str, dict[str, Any]]: + features = load_active_features() + return { + key: { + "enabled": meta["enabled"], + "module": meta["module"], + "name": meta["name"], + } + for key, meta in features.items() + } + + def build_entitlements_snapshot(ctx: TenantContext) -> dict[str, Any]: + features = _feature_snapshot() tenant_block = None if ctx.tenant_id: tenant_block = { @@ -30,7 +44,7 @@ def build_entitlements_snapshot(ctx: TenantContext) -> dict[str, Any]: for key, value in _capability_map(ctx).items() if key in ctx.capabilities }, - "features": {}, + "features": features, } return { @@ -40,7 +54,7 @@ def build_entitlements_snapshot(ctx: TenantContext) -> dict[str, Any]: "display_name": ctx.display_name, "portal_role": ctx.portal_role, "capabilities": _capability_map(ctx), - "features": {}, + "features": features, }, "tenant": tenant_block, "actor": ( @@ -56,7 +70,7 @@ def build_entitlements_snapshot(ctx: TenantContext) -> dict[str, Any]: "tenant": ctx.tenant_role, }, "capabilities": sorted(ctx.capabilities), - "features": {}, + "features": features, "enforcement": { "capabilities": capability_enforcement_mode(), "features": "probe", diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 89e3195..8392193 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -21,6 +21,12 @@ else echo "[SKIP_RIGHTS_SYNC] Rights-Registry-Sync übersprungen" fi +if [ "${SKIP_REGISTRY_SYNC}" != "1" ] && [ "${SKIP_REGISTRY_SYNC}" != "true" ] && [ "${SKIP_REGISTRY_SYNC}" != "yes" ]; then + python sync_prompt_feature_config.py +else + echo "[SKIP_REGISTRY_SYNC] Feature/Prompt/Config-Sync übersprungen" +fi + export KAIRO_DB_READY=1 echo "=== Starte Anwendung: $* ===" exec "$@" diff --git a/backend/feature_registrations/__init__.py b/backend/feature_registrations/__init__.py new file mode 100644 index 0000000..cf8a466 --- /dev/null +++ b/backend/feature_registrations/__init__.py @@ -0,0 +1,3 @@ +"""Side-effect imports for feature registrations.""" + +from feature_registrations import platform, prompt_ops # noqa: F401 diff --git a/backend/feature_registrations/platform.py b/backend/feature_registrations/platform.py new file mode 100644 index 0000000..c1f4d40 --- /dev/null +++ b/backend/feature_registrations/platform.py @@ -0,0 +1,32 @@ +"""Platform feature catalog entries.""" + +from __future__ import annotations + +from feature_registry import FeatureRegistration, register_feature + +register_feature( + FeatureRegistration( + key="kairo.feature.registry", + module="platform", + name="Feature Registry", + description="Metadatenkatalog registrierter Kairo-Features", + ) +) + +register_feature( + FeatureRegistration( + key="kairo.config.registry", + module="config", + name="Configuration Registry", + description="Zentrale fachliche Konfiguration (global/tenant)", + ) +) + +register_feature( + FeatureRegistration( + key="kairo.prompt.registry", + module="prompt", + name="Prompt Registry", + description="Workflow-ready Prompt-Definitionen und Versionen", + ) +) diff --git a/backend/feature_registrations/prompt_ops.py b/backend/feature_registrations/prompt_ops.py new file mode 100644 index 0000000..e74f714 --- /dev/null +++ b/backend/feature_registrations/prompt_ops.py @@ -0,0 +1,5 @@ +"""Prompt-related feature metadata.""" + +from __future__ import annotations + +# Reserved for future prompt-domain features beyond registry catalog. diff --git a/backend/feature_registry.py b/backend/feature_registry.py new file mode 100644 index 0000000..2f20e15 --- /dev/null +++ b/backend/feature_registry.py @@ -0,0 +1,108 @@ +"""In-memory feature registry with startup sync to PostgreSQL.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from db import get_connection + +VALID_CONTEXT_KINDS = frozenset( + { + "kairo.tenant_context", + "kairo.actor_context", + "kairo.debug_context", + } +) + +VALID_EXECUTION_MODES = frozenset({"single", "pipeline", "workflow"}) + + +@dataclass(frozen=True) +class FeatureRegistration: + key: str + module: str + name: str + description: str + + +_REGISTRY: dict[str, FeatureRegistration] = {} + + +def register_feature(registration: FeatureRegistration) -> None: + if not registration.key or not registration.key.strip(): + raise ValueError("Feature key is required") + if not registration.module or not registration.module.strip(): + raise ValueError("Feature module is required") + if not registration.name or not registration.name.strip(): + raise ValueError("Feature name is required") + if not registration.description or not registration.description.strip(): + raise ValueError("Feature description is required") + if registration.key in _REGISTRY: + raise ValueError(f"Feature already registered: {registration.key}") + _REGISTRY[registration.key] = registration + + +def get_registered_features() -> tuple[FeatureRegistration, ...]: + return tuple(_REGISTRY.values()) + + +def clear_feature_registry_for_tests() -> None: + _REGISTRY.clear() + + +def sync_features_to_db() -> int: + if not _REGISTRY: + print("[feature_registry] Keine Features registriert — Sync übersprungen") + return 0 + + conn = get_connection() + try: + with conn.cursor() as cur: + for reg in _REGISTRY.values(): + cur.execute( + """ + INSERT INTO features (feature_key, module, name, description, is_active) + VALUES (%s, %s, %s, %s, TRUE) + ON CONFLICT (feature_key) DO UPDATE SET + module = EXCLUDED.module, + name = EXCLUDED.name, + description = EXCLUDED.description, + is_active = TRUE, + updated_at = NOW() + """, + (reg.key, reg.module, reg.name, reg.description), + ) + conn.commit() + print(f"[feature_registry] Sync OK — {len(_REGISTRY)} Feature(s)") + return 0 + except Exception as exc: + conn.rollback() + print(f"[feature_registry] Sync FAIL: {exc}") + return 1 + finally: + conn.close() + + +def load_active_features() -> dict[str, dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT feature_key, module, name, description + FROM features + WHERE is_active = TRUE + ORDER BY feature_key + """ + ) + return { + row[0]: { + "enabled": True, + "module": row[1], + "name": row[2], + "description": row[3], + } + for row in cur.fetchall() + } + finally: + conn.close() diff --git a/backend/main.py b/backend/main.py index 2bd264f..70c47a9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -53,10 +53,13 @@ app.add_middleware( allow_headers=["*"], ) -from routers import auth, me # noqa: E402 +from routers import auth, config, features, me, prompts # noqa: E402 app.include_router(auth.router) app.include_router(me.router) +app.include_router(features.router) +app.include_router(prompts.router) +app.include_router(config.router) @app.get("/api/health") diff --git a/backend/migrations/005_prompt_feature_config_registry.sql b/backend/migrations/005_prompt_feature_config_registry.sql new file mode 100644 index 0000000..5b590e3 --- /dev/null +++ b/backend/migrations/005_prompt_feature_config_registry.sql @@ -0,0 +1,121 @@ +-- AP0.4: Feature, Prompt, Placeholder and Configuration registries + +CREATE TABLE features ( + feature_key VARCHAR(128) PRIMARY KEY, + module VARCHAR(64) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE prompt_definitions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + prompt_key VARCHAR(128) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + purpose TEXT NOT NULL DEFAULT '', + context_kind VARCHAR(64) NOT NULL, + execution_mode VARCHAR(32) NOT NULL DEFAULT 'single' + CHECK (execution_mode IN ('single', 'pipeline', 'workflow')), + status VARCHAR(32) NOT NULL DEFAULT 'active' + CHECK (status IN ('draft', 'active', 'archived')), + active_version_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE prompt_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + prompt_definition_id UUID NOT NULL REFERENCES prompt_definitions(id) ON DELETE CASCADE, + version VARCHAR(64) NOT NULL, + body TEXT NOT NULL DEFAULT '', + input_schema JSONB, + output_schema JSONB, + model_policy_key VARCHAR(128), + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + changelog TEXT NOT NULL DEFAULT '', + UNIQUE (prompt_definition_id, version) +); + +ALTER TABLE prompt_definitions + ADD CONSTRAINT fk_prompt_definitions_active_version + FOREIGN KEY (active_version_id) REFERENCES prompt_versions(id) ON DELETE SET NULL; + +CREATE TABLE prompt_steps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + prompt_version_id UUID NOT NULL REFERENCES prompt_versions(id) ON DELETE CASCADE, + step_key VARCHAR(128) NOT NULL, + step_order INT NOT NULL DEFAULT 0, + step_type VARCHAR(32) NOT NULL DEFAULT 'template' + CHECK (step_type IN ('template', 'resolver', 'llm_call', 'postprocess', 'validation')), + template_body TEXT NOT NULL DEFAULT '', + input_mapping JSONB, + output_mapping JSONB, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (prompt_version_id, step_key) +); + +CREATE INDEX idx_prompt_steps_version_order + ON prompt_steps(prompt_version_id, step_order); + +CREATE TABLE placeholder_definitions ( + placeholder_key VARCHAR(128) NOT NULL, + context_kind VARCHAR(64) NOT NULL, + value_type VARCHAR(32) NOT NULL DEFAULT 'string' + CHECK (value_type IN ('string', 'number', 'boolean', 'object', 'array', 'json')), + description TEXT NOT NULL DEFAULT '', + required BOOLEAN NOT NULL DEFAULT FALSE, + source VARCHAR(64) NOT NULL DEFAULT 'input', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (placeholder_key, context_kind) +); + +CREATE TABLE configuration_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + config_key VARCHAR(128) NOT NULL, + scope VARCHAR(32) NOT NULL DEFAULT 'global' + CHECK (scope IN ('global', 'tenant')), + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, + value JSONB NOT NULL DEFAULT '{}', + value_type VARCHAR(32) NOT NULL DEFAULT 'string' + CHECK (value_type IN ('string', 'number', 'boolean', 'object', 'array', 'json')), + description TEXT NOT NULL DEFAULT '', + is_secret BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK ( + (scope = 'global' AND tenant_id IS NULL) + OR (scope = 'tenant' AND tenant_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX uq_configuration_entries_global + ON configuration_entries (config_key) + WHERE scope = 'global'; + +CREATE UNIQUE INDEX uq_configuration_entries_tenant + ON configuration_entries (config_key, tenant_id) + WHERE scope = 'tenant'; + +CREATE TABLE prompt_execution_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + prompt_definition_id UUID REFERENCES prompt_definitions(id) ON DELETE SET NULL, + prompt_version_id UUID REFERENCES prompt_versions(id) ON DELETE SET NULL, + execution_mode VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL + CHECK (status IN ('success', 'error', 'skipped')), + rendered_preview TEXT, + input_summary JSONB, + error_message TEXT, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_prompt_execution_logs_created + ON prompt_execution_logs(created_at DESC); diff --git a/backend/placeholder_registrations/__init__.py b/backend/placeholder_registrations/__init__.py new file mode 100644 index 0000000..28e5bec --- /dev/null +++ b/backend/placeholder_registrations/__init__.py @@ -0,0 +1,3 @@ +"""Side-effect imports for placeholder registrations.""" + +from placeholder_registrations import actor_context, debug, tenant_context # noqa: F401 diff --git a/backend/placeholder_registrations/actor_context.py b/backend/placeholder_registrations/actor_context.py new file mode 100644 index 0000000..85457aa --- /dev/null +++ b/backend/placeholder_registrations/actor_context.py @@ -0,0 +1,17 @@ +"""Actor context placeholders.""" + +from __future__ import annotations + +from placeholder_registry import PlaceholderRegistration, register_placeholder + +_CTX = "kairo.actor_context" + +register_placeholder( + PlaceholderRegistration("actor_id", _CTX, "string", "UUID des Actors", required=True) +) +register_placeholder( + PlaceholderRegistration("actor_type", _CTX, "string", "Actor-Typ", required=True) +) +register_placeholder( + PlaceholderRegistration("tenant_id", _CTX, "string", "UUID des Tenants", required=True) +) diff --git a/backend/placeholder_registrations/debug.py b/backend/placeholder_registrations/debug.py new file mode 100644 index 0000000..9d5bc9a --- /dev/null +++ b/backend/placeholder_registrations/debug.py @@ -0,0 +1,14 @@ +"""Debug context placeholders.""" + +from __future__ import annotations + +from placeholder_registry import PlaceholderRegistration, register_placeholder + +_CTX = "kairo.debug_context" + +register_placeholder( + PlaceholderRegistration("debug_message", _CTX, "string", "Freier Debug-Text", required=True) +) +register_placeholder( + PlaceholderRegistration("debug_level", _CTX, "string", "Log-Level oder Kategorie", required=False) +) diff --git a/backend/placeholder_registrations/tenant_context.py b/backend/placeholder_registrations/tenant_context.py new file mode 100644 index 0000000..0108f3f --- /dev/null +++ b/backend/placeholder_registrations/tenant_context.py @@ -0,0 +1,38 @@ +"""TenantContext placeholders for prompt rendering.""" + +from __future__ import annotations + +from placeholder_registry import PlaceholderRegistration, register_placeholder + +_CTX = "kairo.tenant_context" + +register_placeholder( + PlaceholderRegistration("tenant_name", _CTX, "string", "Name des aktiven Tenants", required=True) +) +register_placeholder( + PlaceholderRegistration("tenant_id", _CTX, "string", "UUID des aktiven Tenants", required=True) +) +register_placeholder( + PlaceholderRegistration("actor_name", _CTX, "string", "Anzeigename des Human Actors", required=False) +) +register_placeholder( + PlaceholderRegistration("actor_id", _CTX, "string", "UUID des Human Actors", required=False) +) +register_placeholder( + PlaceholderRegistration("actor_type", _CTX, "string", "Actor-Typ (z. B. human)", required=False) +) +register_placeholder( + PlaceholderRegistration("portal_role", _CTX, "string", "Portal-Rolle des Users", required=True) +) +register_placeholder( + PlaceholderRegistration("tenant_role", _CTX, "string", "Tenant-Rolle in der Membership", required=False) +) +register_placeholder( + PlaceholderRegistration( + "capabilities", + _CTX, + "json", + "Aufgelöste Capability-Keys als JSON-Array", + required=False, + ) +) diff --git a/backend/placeholder_registry.py b/backend/placeholder_registry.py new file mode 100644 index 0000000..771d7fd --- /dev/null +++ b/backend/placeholder_registry.py @@ -0,0 +1,117 @@ +"""Placeholder definitions registry with DB sync.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from db import get_connection +from feature_registry import VALID_CONTEXT_KINDS + +VALID_VALUE_TYPES = frozenset({"string", "number", "boolean", "object", "array", "json"}) + + +@dataclass(frozen=True) +class PlaceholderRegistration: + placeholder_key: str + context_kind: str + value_type: str + description: str + required: bool = False + source: str = "input" + + +_REGISTRY: list[PlaceholderRegistration] = [] + + +def register_placeholder(registration: PlaceholderRegistration) -> None: + if not registration.placeholder_key or not registration.placeholder_key.strip(): + raise ValueError("Placeholder key is required") + if registration.context_kind not in VALID_CONTEXT_KINDS: + raise ValueError(f"Unknown context_kind: {registration.context_kind}") + if registration.value_type not in VALID_VALUE_TYPES: + raise ValueError(f"Unknown value_type: {registration.value_type}") + if not registration.description or not registration.description.strip(): + raise ValueError("Placeholder description is required") + key = (registration.placeholder_key, registration.context_kind) + if any((r.placeholder_key, r.context_kind) == key for r in _REGISTRY): + raise ValueError(f"Placeholder already registered: {key}") + _REGISTRY.append(registration) + + +def get_registered_placeholders() -> tuple[PlaceholderRegistration, ...]: + return tuple(_REGISTRY) + + +def clear_placeholder_registry_for_tests() -> None: + _REGISTRY.clear() + + +def sync_placeholders_to_db() -> int: + if not _REGISTRY: + print("[placeholder_registry] Keine Placeholder registriert — Sync übersprungen") + return 0 + + conn = get_connection() + try: + with conn.cursor() as cur: + for reg in _REGISTRY: + cur.execute( + """ + INSERT INTO placeholder_definitions ( + placeholder_key, context_kind, value_type, description, + required, source, is_active + ) + VALUES (%s, %s, %s, %s, %s, %s, TRUE) + ON CONFLICT (placeholder_key, context_kind) DO UPDATE SET + value_type = EXCLUDED.value_type, + description = EXCLUDED.description, + required = EXCLUDED.required, + source = EXCLUDED.source, + is_active = TRUE, + updated_at = NOW() + """, + ( + reg.placeholder_key, + reg.context_kind, + reg.value_type, + reg.description, + reg.required, + reg.source, + ), + ) + conn.commit() + print(f"[placeholder_registry] Sync OK — {len(_REGISTRY)} Placeholder(s)") + return 0 + except Exception as exc: + conn.rollback() + print(f"[placeholder_registry] Sync FAIL: {exc}") + return 1 + finally: + conn.close() + + +def load_placeholders_for_context(context_kind: str) -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT placeholder_key, value_type, description, required, source + FROM placeholder_definitions + WHERE context_kind = %s AND is_active = TRUE + ORDER BY placeholder_key + """, + (context_kind,), + ) + return [ + { + "placeholder_key": row[0], + "value_type": row[1], + "description": row[2], + "required": row[3], + "source": row[4], + } + for row in cur.fetchall() + ] + finally: + conn.close() diff --git a/backend/prompt_context.py b/backend/prompt_context.py new file mode 100644 index 0000000..03aff67 --- /dev/null +++ b/backend/prompt_context.py @@ -0,0 +1,44 @@ +"""Build placeholder value maps from TenantContext.""" + +from __future__ import annotations + +import json +from typing import Any + +from tenant_context import TenantContext + + +def build_tenant_context_values(ctx: TenantContext) -> dict[str, Any]: + return { + "tenant_name": ctx.tenant_name or "", + "tenant_id": ctx.tenant_id or "", + "actor_name": ctx.display_name, + "actor_id": ctx.actor_id or "", + "actor_type": ctx.actor_type or "", + "portal_role": ctx.portal_role, + "tenant_role": ctx.tenant_role or "", + "capabilities": sorted(ctx.capabilities), + } + + +def build_actor_context_values(ctx: TenantContext) -> dict[str, Any]: + return { + "actor_id": ctx.actor_id or "", + "actor_type": ctx.actor_type or "", + "tenant_id": ctx.tenant_id or "", + } + + +def merge_context_values(ctx: TenantContext, context_kind: str, extra: dict[str, Any]) -> dict[str, Any]: + base: dict[str, Any] = {} + if context_kind == "kairo.tenant_context": + base = build_tenant_context_values(ctx) + elif context_kind == "kairo.actor_context": + base = build_actor_context_values(ctx) + merged = {**base, **extra} + for key, value in merged.items(): + if key == "capabilities" and isinstance(value, list): + merged[key] = value + elif isinstance(value, (dict, list)) and key != "capabilities": + continue + return merged diff --git a/backend/prompt_registrations/__init__.py b/backend/prompt_registrations/__init__.py new file mode 100644 index 0000000..e9d7f35 --- /dev/null +++ b/backend/prompt_registrations/__init__.py @@ -0,0 +1,3 @@ +"""Side-effect imports for prompt registrations.""" + +from prompt_registrations import debug, platform # noqa: F401 diff --git a/backend/prompt_registrations/debug.py b/backend/prompt_registrations/debug.py new file mode 100644 index 0000000..0295a14 --- /dev/null +++ b/backend/prompt_registrations/debug.py @@ -0,0 +1,23 @@ +"""Debug prompts for actor context kind.""" + +from __future__ import annotations + +from prompt_registry import PromptRegistration, PromptVersionRegistration, register_prompt + +register_prompt( + PromptRegistration( + prompt_key="kairo.actor.debug_summary", + name="Actor Context Debug", + purpose="Minimaler Prompt für kairo.actor_context", + context_kind="kairo.actor_context", + execution_mode="single", + active_version="1.0.0", + versions=( + PromptVersionRegistration( + version="1.0.0", + body="Actor {{actor_id}} ({{actor_type}}) in tenant {{tenant_id}}.", + changelog="Actor context smoke", + ), + ), + ) +) diff --git a/backend/prompt_registrations/platform.py b/backend/prompt_registrations/platform.py new file mode 100644 index 0000000..672dd90 --- /dev/null +++ b/backend/prompt_registrations/platform.py @@ -0,0 +1,80 @@ +"""Technical smoke-test prompts — no product logic.""" + +from __future__ import annotations + +from prompt_registry import ( + PromptRegistration, + PromptStepRegistration, + PromptVersionRegistration, + register_prompt, +) + +register_prompt( + PromptRegistration( + prompt_key="kairo.system.health_summary", + name="System Health Summary", + purpose="Smoke-Test für Registry, Versionierung und Rendering", + context_kind="kairo.debug_context", + execution_mode="single", + active_version="1.0.0", + versions=( + PromptVersionRegistration( + version="1.0.0", + body="", + changelog="Initial smoke prompt", + steps=( + PromptStepRegistration( + step_key="summary", + step_order=0, + step_type="template", + template_body=( + "Kairo Systemstatus: {{debug_message}} " + "(Level: {{debug_level}})" + ), + ), + ), + ), + ), + ) +) + +register_prompt( + PromptRegistration( + prompt_key="kairo.context.debug_summary", + name="Tenant Context Debug Summary", + purpose="Smoke-Test für TenantContext-Placeholder", + context_kind="kairo.tenant_context", + execution_mode="single", + active_version="1.0.0", + versions=( + PromptVersionRegistration( + version="1.0.0", + body=( + "Tenant {{tenant_name}} ({{tenant_id}}): " + "Portal={{portal_role}}, Tenant-Rolle={{tenant_role}}, " + "Actor={{actor_id}} ({{actor_type}})." + ), + changelog="Initial tenant context prompt", + ), + ), + ) +) + +# Pipeline/workflow structural sample — not executed in AP0.4 +register_prompt( + PromptRegistration( + prompt_key="kairo.pipeline.placeholder", + name="Pipeline Placeholder", + purpose="Strukturelle Vorbereitung für pipeline execution_mode", + context_kind="kairo.debug_context", + execution_mode="pipeline", + active_version="0.1.0", + versions=( + PromptVersionRegistration( + version="0.1.0", + body="Pipeline placeholder: {{debug_message}}", + changelog="Not executed in AP0.4", + ), + ), + ) +) diff --git a/backend/prompt_registry.py b/backend/prompt_registry.py new file mode 100644 index 0000000..5e97f7a --- /dev/null +++ b/backend/prompt_registry.py @@ -0,0 +1,310 @@ +"""Prompt definition registry with versioning and optional steps.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +from db import get_connection +from feature_registry import VALID_CONTEXT_KINDS, VALID_EXECUTION_MODES + +VALID_STEP_TYPES = frozenset( + {"template", "resolver", "llm_call", "postprocess", "validation"} +) + + +@dataclass(frozen=True) +class PromptStepRegistration: + step_key: str + step_order: int + step_type: str + template_body: str + input_mapping: dict | None = None + output_mapping: dict | None = None + enabled: bool = True + + +@dataclass(frozen=True) +class PromptVersionRegistration: + version: str + body: str + input_schema: dict | None = None + output_schema: dict | None = None + model_policy_key: str | None = None + changelog: str = "" + steps: tuple[PromptStepRegistration, ...] = () + + +@dataclass(frozen=True) +class PromptRegistration: + prompt_key: str + name: str + purpose: str + context_kind: str + execution_mode: str + active_version: str + versions: tuple[PromptVersionRegistration, ...] + + +_REGISTRY: dict[str, PromptRegistration] = {} + + +def register_prompt(registration: PromptRegistration) -> None: + if not registration.prompt_key or not registration.prompt_key.strip(): + raise ValueError("Prompt key is required") + if registration.context_kind not in VALID_CONTEXT_KINDS: + raise ValueError(f"Unknown context_kind: {registration.context_kind}") + if registration.execution_mode not in VALID_EXECUTION_MODES: + raise ValueError(f"Unknown execution_mode: {registration.execution_mode}") + if not registration.versions: + raise ValueError("At least one prompt version is required") + version_ids = {v.version for v in registration.versions} + if registration.active_version not in version_ids: + raise ValueError(f"active_version not found in versions: {registration.active_version}") + for step in _iter_steps(registration): + if step.step_type not in VALID_STEP_TYPES: + raise ValueError(f"Unknown step_type: {step.step_type}") + if registration.prompt_key in _REGISTRY: + raise ValueError(f"Prompt already registered: {registration.prompt_key}") + _REGISTRY[registration.prompt_key] = registration + + +def _iter_steps(registration: PromptRegistration): + for version in registration.versions: + yield from version.steps + + +def get_registered_prompts() -> tuple[PromptRegistration, ...]: + return tuple(_REGISTRY.values()) + + +def clear_prompt_registry_for_tests() -> None: + _REGISTRY.clear() + + +def sync_prompts_to_db() -> int: + if not _REGISTRY: + print("[prompt_registry] Keine Prompts registriert — Sync übersprungen") + return 0 + + conn = get_connection() + try: + with conn.cursor() as cur: + for reg in _REGISTRY.values(): + cur.execute( + """ + INSERT INTO prompt_definitions ( + prompt_key, name, purpose, context_kind, execution_mode, status + ) + VALUES (%s, %s, %s, %s, %s, 'active') + ON CONFLICT (prompt_key) DO UPDATE SET + name = EXCLUDED.name, + purpose = EXCLUDED.purpose, + context_kind = EXCLUDED.context_kind, + execution_mode = EXCLUDED.execution_mode, + status = 'active', + updated_at = NOW() + RETURNING id + """, + ( + reg.prompt_key, + reg.name, + reg.purpose, + reg.context_kind, + reg.execution_mode, + ), + ) + definition_id = cur.fetchone()[0] + + active_version_id = None + for version in reg.versions: + cur.execute( + """ + INSERT INTO prompt_versions ( + prompt_definition_id, version, body, + input_schema, output_schema, model_policy_key, changelog + ) + VALUES (%s, %s, %s, %s::jsonb, %s::jsonb, %s, %s) + ON CONFLICT (prompt_definition_id, version) DO UPDATE SET + body = EXCLUDED.body, + input_schema = EXCLUDED.input_schema, + output_schema = EXCLUDED.output_schema, + model_policy_key = EXCLUDED.model_policy_key, + changelog = EXCLUDED.changelog + RETURNING id + """, + ( + definition_id, + version.version, + version.body, + json.dumps(version.input_schema) if version.input_schema else None, + json.dumps(version.output_schema) if version.output_schema else None, + version.model_policy_key, + version.changelog, + ), + ) + version_id = cur.fetchone()[0] + if version.version == reg.active_version: + active_version_id = version_id + + for step in version.steps: + cur.execute( + """ + INSERT INTO prompt_steps ( + prompt_version_id, step_key, step_order, step_type, + template_body, input_mapping, output_mapping, enabled + ) + VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s) + ON CONFLICT (prompt_version_id, step_key) DO UPDATE SET + step_order = EXCLUDED.step_order, + step_type = EXCLUDED.step_type, + template_body = EXCLUDED.template_body, + input_mapping = EXCLUDED.input_mapping, + output_mapping = EXCLUDED.output_mapping, + enabled = EXCLUDED.enabled, + updated_at = NOW() + """, + ( + version_id, + step.step_key, + step.step_order, + step.step_type, + step.template_body, + json.dumps(step.input_mapping) if step.input_mapping else None, + json.dumps(step.output_mapping) if step.output_mapping else None, + step.enabled, + ), + ) + + if active_version_id: + cur.execute( + """ + UPDATE prompt_definitions + SET active_version_id = %s, updated_at = NOW() + WHERE id = %s + """, + (active_version_id, definition_id), + ) + + conn.commit() + print(f"[prompt_registry] Sync OK — {len(_REGISTRY)} Prompt(s)") + return 0 + except Exception as exc: + conn.rollback() + print(f"[prompt_registry] Sync FAIL: {exc}") + return 1 + finally: + conn.close() + + +def load_prompt_definition(prompt_key: str) -> dict | None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, prompt_key, name, purpose, context_kind, execution_mode, + status, active_version_id + FROM prompt_definitions + WHERE prompt_key = %s + """, + (prompt_key,), + ) + row = cur.fetchone() + if not row: + return None + return { + "id": str(row[0]), + "prompt_key": row[1], + "name": row[2], + "purpose": row[3], + "context_kind": row[4], + "execution_mode": row[5], + "status": row[6], + "active_version_id": str(row[7]) if row[7] else None, + } + finally: + conn.close() + + +def load_active_prompt_version(definition: dict) -> dict | None: + version_id = definition.get("active_version_id") + if not version_id: + return None + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, version, body, input_schema, output_schema, model_policy_key + FROM prompt_versions + WHERE id = %s + """, + (version_id,), + ) + row = cur.fetchone() + if not row: + return None + return { + "id": str(row[0]), + "version": row[1], + "body": row[2], + "input_schema": row[3], + "output_schema": row[4], + "model_policy_key": row[5], + } + finally: + conn.close() + + +def load_template_steps(version_id: str) -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT step_key, step_order, step_type, template_body, enabled + FROM prompt_steps + WHERE prompt_version_id = %s AND enabled = TRUE + ORDER BY step_order, step_key + """, + (version_id,), + ) + return [ + { + "step_key": row[0], + "step_order": row[1], + "step_type": row[2], + "template_body": row[3], + "enabled": row[4], + } + for row in cur.fetchall() + ] + finally: + conn.close() + + +def list_prompt_definitions() -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT prompt_key, name, purpose, context_kind, execution_mode, status + FROM prompt_definitions + ORDER BY prompt_key + """ + ) + return [ + { + "prompt_key": row[0], + "name": row[1], + "purpose": row[2], + "context_kind": row[3], + "execution_mode": row[4], + "status": row[5], + } + for row in cur.fetchall() + ] + finally: + conn.close() diff --git a/backend/prompt_rendering.py b/backend/prompt_rendering.py new file mode 100644 index 0000000..320a792 --- /dev/null +++ b/backend/prompt_rendering.py @@ -0,0 +1,172 @@ +"""Single-mode prompt rendering without LLM execution.""" + +from __future__ import annotations + +import json +import re +from typing import Any + +from db import get_connection +from prompt_registry import load_active_prompt_version, load_prompt_definition, load_template_steps +from prompt_validation import PLACEHOLDER_PATTERN, PlaceholderValidationError, validate_placeholder_input +from services.audit import log_audit + +PLACEHOLDER_SUB = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}") + + +class PromptRenderError(Exception): + def __init__(self, message: str, *, code: str = "render_error"): + super().__init__(message) + self.code = code + + +def _render_template(template: str, values: dict[str, Any]) -> str: + def replacer(match: re.Match[str]) -> str: + key = match.group(1) + if key not in values: + return match.group(0) + value = values[key] + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + return str(value) + + return PLACEHOLDER_SUB.sub(replacer, template) + + +def _resolve_template_body(version: dict) -> tuple[str, str]: + """Return (template_body, source) where source is 'body' or 'step'.""" + steps = load_template_steps(version["id"]) + template_steps = [s for s in steps if s["step_type"] == "template" and s["template_body"]] + if template_steps: + step = template_steps[0] + return step["template_body"], f"step:{step['step_key']}" + if version.get("body"): + return version["body"], "body" + raise PromptRenderError("No template body or template step found", code="no_template") + + +def _write_execution_log( + *, + definition: dict, + version: dict, + status: str, + rendered_preview: str | None, + input_summary: dict | None, + error_message: str | None, + user_id: str | None, +) -> None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO prompt_execution_logs ( + prompt_definition_id, prompt_version_id, execution_mode, + status, rendered_preview, input_summary, error_message, created_by + ) + VALUES (%s::uuid, %s::uuid, %s, %s, %s, %s::jsonb, %s, %s::uuid) + """, + ( + definition["id"], + version["id"], + definition["execution_mode"], + status, + rendered_preview, + json.dumps(input_summary) if input_summary else None, + error_message, + user_id, + ), + ) + conn.commit() + finally: + conn.close() + + +def render_prompt_single( + prompt_key: str, + *, + values: dict[str, Any], + user_id: str | None = None, + log_execution: bool = True, +) -> dict[str, Any]: + definition = load_prompt_definition(prompt_key) + if not definition: + raise PromptRenderError(f"Unknown prompt: {prompt_key}", code="not_found") + if definition["status"] != "active": + raise PromptRenderError(f"Prompt not active: {prompt_key}", code="inactive") + + execution_mode = definition["execution_mode"] + if execution_mode in ("pipeline", "workflow"): + raise PromptRenderError( + f"execution_mode '{execution_mode}' is not implemented in AP0.4", + code="mode_not_implemented", + ) + + version = load_active_prompt_version(definition) + if not version: + raise PromptRenderError("No active prompt version", code="no_version") + + template_body, template_source = _resolve_template_body(version) + + try: + validation = validate_placeholder_input( + template=template_body, + context_kind=definition["context_kind"], + values=values, + ) + rendered = _render_template(template_body, values) + result = { + "prompt_key": prompt_key, + "version": version["version"], + "execution_mode": execution_mode, + "template_source": template_source, + "rendered_text": rendered, + "validation": validation, + } + if log_execution: + _write_execution_log( + definition=definition, + version=version, + status="success", + rendered_preview=rendered[:2000], + input_summary={"keys": sorted(values.keys())}, + error_message=None, + user_id=user_id, + ) + return result + except PlaceholderValidationError as exc: + if log_execution: + _write_execution_log( + definition=definition, + version=version, + status="error", + rendered_preview=None, + input_summary={"keys": sorted(values.keys())}, + error_message=str(exc), + user_id=user_id, + ) + log_audit( + "prompt.render.failed", + user_id=user_id, + details={ + "prompt_key": prompt_key, + "missing": exc.missing, + "type_errors": exc.type_errors, + "unknown": exc.unknown, + }, + ) + raise PromptRenderError(str(exc), code="validation_failed") from exc + except PromptRenderError: + raise + except Exception as exc: + if log_execution: + _write_execution_log( + definition=definition, + version=version, + status="error", + rendered_preview=None, + input_summary={"keys": sorted(values.keys())}, + error_message=str(exc), + user_id=user_id, + ) + raise PromptRenderError(str(exc), code="render_error") from exc diff --git a/backend/prompt_validation.py b/backend/prompt_validation.py new file mode 100644 index 0000000..39865e5 --- /dev/null +++ b/backend/prompt_validation.py @@ -0,0 +1,81 @@ +"""Placeholder extraction and validation for prompt templates.""" + +from __future__ import annotations + +import re +from typing import Any + +from placeholder_registry import load_placeholders_for_context + +PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}") + + +class PlaceholderValidationError(Exception): + def __init__( + self, + message: str, + *, + missing: list[str] | None = None, + type_errors: list[str] | None = None, + unknown: list[str] | None = None, + ): + super().__init__(message) + self.missing = missing or [] + self.type_errors = type_errors or [] + self.unknown = unknown or [] + + +def extract_placeholders(template: str) -> set[str]: + return set(PLACEHOLDER_PATTERN.findall(template)) + + +def _check_value_type(value: Any, value_type: str) -> bool: + if value_type == "string": + return isinstance(value, str) + if value_type == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if value_type == "boolean": + return isinstance(value, bool) + if value_type == "object": + return isinstance(value, dict) + if value_type == "array": + return isinstance(value, list) + if value_type == "json": + return True + return True + + +def validate_placeholder_input( + *, + template: str, + context_kind: str, + values: dict[str, Any], +) -> dict[str, Any]: + """Validate input values against registry; return metadata including unknown placeholders.""" + registry = {p["placeholder_key"]: p for p in load_placeholders_for_context(context_kind)} + used = extract_placeholders(template) + missing: list[str] = [] + type_errors: list[str] = [] + unknown = sorted(key for key in used if key not in registry) + + for key, meta in registry.items(): + if meta["required"] and key not in values: + missing.append(key) + if key in values and not _check_value_type(values[key], meta["value_type"]): + type_errors.append(f"{key}: expected {meta['value_type']}") + + if missing or type_errors: + raise PlaceholderValidationError( + "Placeholder validation failed", + missing=missing, + type_errors=type_errors, + unknown=unknown, + ) + + return { + "used_placeholders": sorted(used), + "unknown_placeholders": unknown, + "optional_missing": sorted( + key for key in registry if not registry[key]["required"] and key not in values + ), + } diff --git a/backend/rights_registrations/__init__.py b/backend/rights_registrations/__init__.py index 45a0c03..bbe142f 100644 --- a/backend/rights_registrations/__init__.py +++ b/backend/rights_registrations/__init__.py @@ -1,5 +1,5 @@ """Import all module registrations — side effect registers capabilities.""" -from . import platform, tenant_ops # noqa: F401 +from . import platform, registry_ops, tenant_ops # noqa: F401 -__all__ = ["platform", "tenant_ops"] +__all__ = ["platform", "registry_ops", "tenant_ops"] diff --git a/backend/rights_registrations/registry_ops.py b/backend/rights_registrations/registry_ops.py new file mode 100644 index 0000000..1c1b929 --- /dev/null +++ b/backend/rights_registrations/registry_ops.py @@ -0,0 +1,82 @@ +"""AP0.4 registry and prompt capabilities.""" + +from __future__ import annotations + +from rights_registry import CapabilityRegistration, register_capability + +_TENANT_READ = ( + ("portal", "admin"), + ("portal", "user"), + ("tenant", "owner"), + ("tenant", "admin"), + ("tenant", "member"), +) + +_TENANT_ADMIN = ( + ("portal", "admin"), + ("tenant", "owner"), + ("tenant", "admin"), +) + +register_capability( + CapabilityRegistration( + key="kairo.feature.registry.read", + module="feature", + description="Feature Registry lesen", + default_grants=_TENANT_READ, + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.feature.registry.manage", + module="feature", + description="Feature Registry verwalten (Sync/Metadaten)", + default_grants=(("portal", "admin"),), + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.prompt.registry.read", + module="prompt", + description="Prompt Registry lesen", + default_grants=_TENANT_READ, + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.prompt.registry.manage", + module="prompt", + description="Prompt Registry verwalten", + default_grants=(("portal", "admin"),), + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.config.registry.read", + module="config", + description="Configuration Registry lesen", + default_grants=_TENANT_ADMIN, + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.config.registry.manage", + module="config", + description="Configuration Registry schreiben", + default_grants=_TENANT_ADMIN, + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.prompt.render", + module="prompt", + description="Prompts rendern (ohne LLM)", + default_grants=_TENANT_READ, + ) +) diff --git a/backend/routers/config.py b/backend/routers/config.py new file mode 100644 index 0000000..45afe6c --- /dev/null +++ b/backend/routers/config.py @@ -0,0 +1,61 @@ +"""Configuration registry API.""" + +from __future__ import annotations + +from typing import Any + +from capabilities import require_capability_ctx +from config_service import SECRET_VALUE_FORBIDDEN, list_configs, upsert_config +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from tenant_context import TenantContext + +router = APIRouter(prefix="/api/config", tags=["config"]) + + +class ConfigUpsertRequest(BaseModel): + config_key: str + scope: str = "global" + value: Any = None + value_type: str = "string" + description: str = "" + is_secret: bool = False + tenant_id: str | None = None + + +@router.get("") +def get_configs( + scope: str | None = None, + ctx: TenantContext = Depends(require_capability_ctx("kairo.config.registry.read")), +): + tenant_id = ctx.tenant_id if scope == "tenant" else None + if scope == "tenant" and not tenant_id: + raise HTTPException(status_code=400, detail="Aktiver Tenant erforderlich für tenant-scope") + return list_configs(scope=scope, tenant_id=tenant_id) + + +@router.post("") +def post_config( + body: ConfigUpsertRequest, + ctx: TenantContext = Depends(require_capability_ctx("kairo.config.registry.manage")), +): + tenant_id = body.tenant_id + if body.scope == "tenant": + tenant_id = tenant_id or ctx.tenant_id + if not tenant_id: + raise HTTPException(status_code=400, detail="tenant_id erforderlich") + try: + return upsert_config( + config_key=body.config_key, + scope=body.scope, + value=body.value, + value_type=body.value_type, + description=body.description, + is_secret=body.is_secret, + tenant_id=tenant_id, + user_id=ctx.user_id, + ) + except ValueError as exc: + if str(exc) == SECRET_VALUE_FORBIDDEN: + raise HTTPException(status_code=400, detail=str(exc)) from exc + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/routers/features.py b/backend/routers/features.py new file mode 100644 index 0000000..4f13eb8 --- /dev/null +++ b/backend/routers/features.py @@ -0,0 +1,35 @@ +"""Feature registry API.""" + +from __future__ import annotations + +from capabilities import require_capability_ctx +from db import get_connection +from fastapi import APIRouter, Depends + +router = APIRouter(prefix="/api/features", tags=["features"]) + + +@router.get("") +def list_features(_ctx=Depends(require_capability_ctx("kairo.feature.registry.read"))): + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT feature_key, module, name, description, is_active + FROM features + ORDER BY feature_key + """ + ) + return [ + { + "feature_key": row[0], + "module": row[1], + "name": row[2], + "description": row[3], + "is_active": row[4], + } + for row in cur.fetchall() + ] + finally: + conn.close() diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py new file mode 100644 index 0000000..c73339d --- /dev/null +++ b/backend/routers/prompts.py @@ -0,0 +1,74 @@ +"""Prompt registry and rendering API.""" + +from __future__ import annotations + +from typing import Any + +from capabilities import require_capability_ctx +from fastapi import APIRouter, Depends, HTTPException +from prompt_context import merge_context_values +from prompt_registry import list_prompt_definitions, load_active_prompt_version, load_prompt_definition +from prompt_rendering import PromptRenderError, render_prompt_single +from pydantic import BaseModel, Field +from tenant_context import TenantContext + +router = APIRouter(prefix="/api/prompts", tags=["prompts"]) + + +class RenderPromptRequest(BaseModel): + values: dict[str, Any] = Field(default_factory=dict) + use_context: bool = True + + +@router.get("") +def list_prompts(_ctx=Depends(require_capability_ctx("kairo.prompt.registry.read"))): + return list_prompt_definitions() + + +@router.get("/{prompt_key}") +def get_prompt(prompt_key: str, _ctx=Depends(require_capability_ctx("kairo.prompt.registry.read"))): + definition = load_prompt_definition(prompt_key) + if not definition: + raise HTTPException(status_code=404, detail="Prompt nicht gefunden") + version = load_active_prompt_version(definition) + return { + "definition": definition, + "active_version": version, + } + + +@router.post("/{prompt_key}/render") +def render_prompt( + prompt_key: str, + body: RenderPromptRequest, + ctx: TenantContext = Depends(require_capability_ctx("kairo.prompt.render")), +): + definition = load_prompt_definition(prompt_key) + if not definition: + raise HTTPException(status_code=404, detail="Prompt nicht gefunden") + + if body.use_context and definition["context_kind"] in ( + "kairo.tenant_context", + "kairo.actor_context", + ): + if not ctx.tenant_id: + raise HTTPException(status_code=403, detail="Aktiver Tenant erforderlich für diesen Prompt") + + values = body.values + if body.use_context: + values = merge_context_values(ctx, definition["context_kind"], body.values) + + try: + return render_prompt_single( + prompt_key, + values=values, + user_id=ctx.user_id, + log_execution=True, + ) + except PromptRenderError as exc: + status = 400 + if exc.code == "not_found": + status = 404 + elif exc.code == "mode_not_implemented": + status = 501 + raise HTTPException(status_code=status, detail=str(exc)) from exc diff --git a/backend/sync_prompt_feature_config.py b/backend/sync_prompt_feature_config.py new file mode 100644 index 0000000..e372277 --- /dev/null +++ b/backend/sync_prompt_feature_config.py @@ -0,0 +1,60 @@ +"""Startup sync for feature, prompt, placeholder and default config registries.""" + +from __future__ import annotations + +import sys + +import feature_registrations # noqa: F401 +import placeholder_registrations # noqa: F401 +import prompt_registrations # noqa: F401 +from config_service import sync_default_configs +from feature_registry import sync_features_to_db +from placeholder_registry import sync_placeholders_to_db +from prompt_registry import sync_prompts_to_db +from services.audit import log_audit + +DEFAULT_CONFIGS = [ + { + "config_key": "prompt.default_execution_mode", + "scope": "global", + "value": "single", + "value_type": "string", + "description": "Default execution mode for prompt rendering", + }, + { + "config_key": "prompt.render_logging_enabled", + "scope": "global", + "value": True, + "value_type": "boolean", + "description": "Write prompt_execution_logs on render", + }, + { + "config_key": "features.registry_sync_enabled", + "scope": "global", + "value": True, + "value_type": "boolean", + "description": "Enable registry sync on startup", + }, +] + + +def main() -> int: + results = [ + sync_features_to_db(), + sync_placeholders_to_db(), + sync_prompts_to_db(), + ] + sync_default_configs(DEFAULT_CONFIGS) + log_audit( + "registry.sync.completed", + details={ + "features": results[0] == 0, + "placeholders": results[1] == 0, + "prompts": results[2] == 0, + }, + ) + return 0 if all(code == 0 for code in results) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b95fb4c..197d367 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,7 @@ os.environ.setdefault("SKIP_DB_MIGRATE", "1") os.environ.setdefault("SKIP_SEEDS", "1") os.environ.setdefault("SKIP_BOOTSTRAP", "1") os.environ.setdefault("SKIP_RIGHTS_SYNC", "1") +os.environ.setdefault("SKIP_REGISTRY_SYNC", "1") def _db_available() -> bool: @@ -57,6 +58,16 @@ def _sync_rights_registry(): assert sync_rights_registry_to_db() == 0 +@pytest.fixture(scope="session", autouse=True) +def _sync_prompt_feature_config(): + import feature_registrations # noqa: F401 + import placeholder_registrations # noqa: F401 + import prompt_registrations # noqa: F401 + from sync_prompt_feature_config import main as sync_registries + + assert sync_registries() == 0 + + @pytest.fixture() def client(): import importlib diff --git a/backend/tests/test_config_registry.py b/backend/tests/test_config_registry.py new file mode 100644 index 0000000..1a15b66 --- /dev/null +++ b/backend/tests/test_config_registry.py @@ -0,0 +1,66 @@ +"""Configuration registry tests.""" + +from __future__ import annotations + +import pytest +from auth import AUTH_HEADER +from config_service import SECRET_VALUE_FORBIDDEN, get_config, list_configs, upsert_config +from tests.factories import provision_user_in_tenant + + +def test_global_config_upsert_and_read(): + upsert_config( + config_key="test.ap0.config", + scope="global", + value="enabled", + value_type="string", + description="pytest config", + ) + item = get_config("test.ap0.config", scope="global") + assert item is not None + assert item["value"] == "enabled" + + +def test_secret_config_rejects_plaintext(): + with pytest.raises(ValueError, match=SECRET_VALUE_FORBIDDEN): + upsert_config( + config_key="test.secret", + scope="global", + value="super-secret-token", + is_secret=True, + ) + + +def test_config_api(client): + user = provision_user_in_tenant(portal_role="admin", tenant_role="owner") + login = client.post( + "/api/auth/login", + json={"email": user["email"], "password": user["password"]}, + ) + token = login.json()["token"] + res = client.get("/api/config", headers={AUTH_HEADER: token}) + assert res.status_code == 200 + keys = {item["config_key"] for item in res.json()} + assert "prompt.default_execution_mode" in keys + + +def test_config_post_api(client): + user = provision_user_in_tenant(portal_role="admin", tenant_role="owner") + login = client.post( + "/api/auth/login", + json={"email": user["email"], "password": user["password"]}, + ) + token = login.json()["token"] + res = client.post( + "/api/config", + json={ + "config_key": "test.api.config", + "scope": "global", + "value": True, + "value_type": "boolean", + "description": "via api", + }, + headers={AUTH_HEADER: token}, + ) + assert res.status_code == 200 + assert res.json()["config_key"] == "test.api.config" diff --git a/backend/tests/test_entitlements.py b/backend/tests/test_entitlements.py index 8e423eb..2054936 100644 --- a/backend/tests/test_entitlements.py +++ b/backend/tests/test_entitlements.py @@ -28,4 +28,5 @@ def test_entitlements_snapshot_shape(client): assert "kairo.admin.access" in body["capabilities"] assert body["enforcement"]["capabilities"] in ("probe", "enforce") assert body["enforcement"]["features"] == "probe" - assert body["features"] == {} + assert "kairo.prompt.registry" in body["features"] + assert body["features"]["kairo.prompt.registry"]["enabled"] is True diff --git a/backend/tests/test_feature_registry.py b/backend/tests/test_feature_registry.py new file mode 100644 index 0000000..e281270 --- /dev/null +++ b/backend/tests/test_feature_registry.py @@ -0,0 +1,48 @@ +"""Feature registry tests.""" + +from __future__ import annotations + +import feature_registrations # noqa: F401 +from auth import AUTH_HEADER +from db import get_connection +from feature_registry import get_registered_features, sync_features_to_db +from tests.factories import provision_user_in_tenant + + +def test_feature_registration_required_fields(): + keys = {f.key for f in get_registered_features()} + assert keys == { + "kairo.feature.registry", + "kairo.config.registry", + "kairo.prompt.registry", + } + + +def test_feature_sync_idempotent(): + assert sync_features_to_db() == 0 + assert sync_features_to_db() == 0 + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM features WHERE is_active = TRUE") + assert cur.fetchone()[0] == 3 + finally: + conn.close() + + +def test_features_in_entitlements(client): + user = provision_user_in_tenant(tenant_role="member", portal_role="user") + login = client.post( + "/api/auth/login", + json={"email": user["email"], "password": user["password"]}, + ) + token = login.json()["token"] + res = client.get("/api/me/entitlements", headers={AUTH_HEADER: token}) + features = res.json()["features"] + assert "kairo.prompt.registry" in features + assert features["kairo.prompt.registry"]["enabled"] is True + assert features["kairo.prompt.registry"]["module"] == "prompt" + + +def test_features_api_requires_auth(client): + assert client.get("/api/features").status_code == 401 diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index e365c2c..2754d7b 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -35,6 +35,7 @@ def test_migration_runner_finds_migrations(): assert "002_auth_identity_tenant_actor" in names assert "003_data_seeds_tracking" in names assert "004_capabilities_registry" in names + assert "005_prompt_feature_config_registry" in names def test_migration_runner_is_idempotent(): @@ -48,6 +49,7 @@ def test_migration_runner_is_idempotent(): assert "002_auth_identity_tenant_actor" in executed assert "003_data_seeds_tracking" in executed assert "004_capabilities_registry" in executed + assert "005_prompt_feature_config_registry" in executed def test_core_table_exists(): diff --git a/backend/tests/test_prompt_registry.py b/backend/tests/test_prompt_registry.py new file mode 100644 index 0000000..a230cba --- /dev/null +++ b/backend/tests/test_prompt_registry.py @@ -0,0 +1,109 @@ +"""Prompt registry and rendering tests.""" + +from __future__ import annotations + +import prompt_registrations # noqa: F401 +from auth import AUTH_HEADER +from db import get_connection +from prompt_registry import get_registered_prompts, sync_prompts_to_db +from prompt_rendering import PromptRenderError, render_prompt_single +from prompt_validation import PlaceholderValidationError, validate_placeholder_input +from tests.factories import provision_user_in_tenant + + +def test_prompt_registry_contains_smoke_prompts(): + keys = {p.prompt_key for p in get_registered_prompts()} + assert "kairo.system.health_summary" in keys + assert "kairo.context.debug_summary" in keys + assert "kairo.pipeline.placeholder" in keys + + +def test_prompt_sync_idempotent(): + assert sync_prompts_to_db() == 0 + assert sync_prompts_to_db() == 0 + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM prompt_definitions") + assert cur.fetchone()[0] >= 4 + cur.execute("SELECT COUNT(*) FROM prompt_steps") + assert cur.fetchone()[0] >= 1 + finally: + conn.close() + + +def test_placeholder_required_missing(): + try: + validate_placeholder_input( + template="Hello {{debug_message}}", + context_kind="kairo.debug_context", + values={}, + ) + assert False, "expected validation error" + except PlaceholderValidationError as exc: + assert "debug_message" in exc.missing + + +def test_placeholder_optional_ok(): + meta = validate_placeholder_input( + template="Level {{debug_level}}", + context_kind="kairo.debug_context", + values={"debug_message": "ok"}, + ) + assert "debug_level" in meta["optional_missing"] + + +def test_render_debug_prompt(): + result = render_prompt_single( + "kairo.system.health_summary", + values={"debug_message": "All systems nominal", "debug_level": "info"}, + log_execution=False, + ) + assert "All systems nominal" in result["rendered_text"] + assert result["template_source"].startswith("step:") + + +def test_render_pipeline_mode_rejected(): + try: + render_prompt_single( + "kairo.pipeline.placeholder", + values={"debug_message": "x"}, + log_execution=False, + ) + assert False, "expected error" + except PromptRenderError as exc: + assert exc.code == "mode_not_implemented" + + +def test_render_api(client): + user = provision_user_in_tenant(tenant_role="owner", portal_role="admin") + login = client.post( + "/api/auth/login", + json={"email": user["email"], "password": user["password"]}, + ) + token = login.json()["token"] + client.post( + "/api/me/tenant", + json={"tenant_id": user["tenant_id"]}, + headers={AUTH_HEADER: token}, + ) + res = client.post( + "/api/prompts/kairo.context.debug_summary/render", + json={"values": {}, "use_context": True}, + headers={AUTH_HEADER: token}, + ) + assert res.status_code == 200 + assert user["tenant_id"] in res.json()["rendered_text"] + + +def test_prompts_list_api(client): + user = provision_user_in_tenant(portal_role="admin", tenant_role="owner") + login = client.post( + "/api/auth/login", + json={"email": user["email"], "password": user["password"]}, + ) + token = login.json()["token"] + res = client.get("/api/prompts", headers={AUTH_HEADER: token}) + assert res.status_code == 200 + keys = {item["prompt_key"] for item in res.json()} + assert "kairo.system.health_summary" in keys diff --git a/backend/tests/test_rights_registry.py b/backend/tests/test_rights_registry.py index e29625f..a5911c8 100644 --- a/backend/tests/test_rights_registry.py +++ b/backend/tests/test_rights_registry.py @@ -15,6 +15,13 @@ def test_registry_contains_initial_capabilities(): "kairo.actor.manage", "kairo.context.read", "kairo.entitlements.read", + "kairo.feature.registry.read", + "kairo.feature.registry.manage", + "kairo.prompt.registry.read", + "kairo.prompt.registry.manage", + "kairo.config.registry.read", + "kairo.config.registry.manage", + "kairo.prompt.render", } @@ -26,7 +33,7 @@ def test_sync_is_idempotent(): try: with conn.cursor() as cur: cur.execute("SELECT COUNT(*) FROM capabilities") - assert cur.fetchone()[0] == 5 + assert cur.fetchone()[0] == 12 cur.execute("SELECT COUNT(*) FROM role_capability_grants") assert cur.fetchone()[0] >= 5 finally: diff --git a/backend/version.py b/backend/version.py index a69deba..522e3ae 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.3.0-ap0.3" -DB_SCHEMA_VERSION = "004" +APP_VERSION = "0.4.0-ap0.4" +DB_SCHEMA_VERSION = "005" APP_NAME = "jinkendo-kairo" diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 2912438..99da214 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -75,9 +75,24 @@ Aktuell keine Medien-Speicherung. Bei Bedarf: NAS-Mount + `docker-compose.overri --- -## Dev-Datenbank wechseln +## Datenbank-Passwort (Dev & Prod) -PostgreSQL im Compose-Stack initialisiert User/Passwort **nur beim ersten Start** des Volumes (`dev-kairo-db-data`). +PostgreSQL im Compose-Stack initialisiert User/Passwort **nur beim ersten Start** des Volumes. +Wenn `DB_PASSWORD` in `.env` nicht mehr zum Volume passt, scheitert das Backend mit +`password authentication failed for user "kairo_user"` — der Container bleibt `unhealthy`. + +**Prod (Daten behalten):** Passwort in der laufenden DB an `.env` anpassen: + +```bash +cd /home/lars/docker/kairo +set -a && source .env && set +a +docker compose exec -T postgres psql -U kairo_user -d kairo \ + -c "ALTER USER kairo_user WITH PASSWORD '${DB_PASSWORD}';" +docker compose restart backend +curl -sf http://localhost:8004/api/health +``` + +**Dev (Volume neu, inkl. Testdaten):** Volume löschen und neu starten (`dev-kairo-db-data`). Wenn du `DB_NAME`, `DB_USER` oder `DB_PASSWORD` in `.env` änderst, muss das Volume neu angelegt werden: ```bash diff --git a/docs/sprints/Sprint0_AP0_3_Completion_Report_v0.3.md b/docs/sprints/Sprint0_AP0_3_Completion_Report_v0.3.md new file mode 100644 index 0000000..462321a --- /dev/null +++ b/docs/sprints/Sprint0_AP0_3_Completion_Report_v0.3.md @@ -0,0 +1,322 @@ +# AP0.3 – Abschlussbericht Capability / Rights Registry & Entitlements Snapshot + +**Status:** abgeschlossen +**Stand:** 2026-07-04 (final inkl. Prod-Deploy-Nacharbeit) +**Branch:** `develop` · Dev-Deploy und Test Suite grün · **Prod verifiziert** (Schema `004`, Health ok) + +--- + +## 1. Scope und Einordnung + +AP0.3 liefert die **minimale Capability-/Rights-Grundlage** und den **Entitlements-Snapshot** für Sprint 0. Auth, TenantContext und Sessions stammen aus AP0.2; AP0.3 ergänzt Registry, DB-Sync, zentrale Capability-Prüfung und `/api/me/entitlements`. + +| Anforderung (AP0.3) | Status | +|---------------------|--------| +| Migration Capabilities + Role-Grants | ✓ | +| Runtime Registry + Modul-Registrierungen | ✓ | +| Startup-Sync Registry → DB | ✓ | +| TenantContext.capabilities | ✓ | +| `require_capability()` Dependency | ✓ | +| `GET /api/me/entitlements` | ✓ | +| Beispiel-Endpoint mit Capability-Gate | ✓ | +| Tests (Registry, Sync, Auflösung, Enforce/Probe) | ✓ | +| Trennung Auth / Capability / Feature | ✓ | + +**Bewusst nicht in AP0.3:** Feature-Limits, Billing, Usage-Zähler, Prompt Registry, produktive Admin-UI, Governance/Object-ACL. + +--- + +## 2. Umgesetzte Dateien + +| Datei | Zweck | +|-------|--------| +| `backend/migrations/004_capabilities_registry.sql` | Tabellen `capabilities`, `role_capability_grants` | +| `backend/rights_registry.py` | In-Memory-Registry, Validierung, DB-Sync, Grant-Lookup | +| `backend/rights_registrations/__init__.py` | Side-Effect-Import aller Modul-Registrierungen | +| `backend/rights_registrations/platform.py` | Platform-Capabilities | +| `backend/rights_registrations/tenant_ops.py` | Tenant-/Actor-Capabilities | +| `backend/capabilities.py` | Auflösung, `check_capability`, `require_capability` | +| `backend/entitlements.py` | Entitlements-Snapshot-Builder | +| `backend/sync_rights_registry.py` | CLI-Sync beim Container-Start | +| `backend/tenant_context.py` | `TenantContext.capabilities` | +| `backend/routers/me.py` | `/entitlements`, `/admin/demo` | +| `backend/entrypoint.sh` | Rights-Sync nach Seeds | +| `backend/version.py` | `0.3.0-ap0.3`, Schema `004` | +| `backend/tests/test_rights_registry.py` | Registry + Sync | +| `backend/tests/test_capabilities.py` | Grants, enforce/probe, Demo-Endpoint | +| `backend/tests/test_entitlements.py` | Snapshot-Form | +| `backend/tests/conftest.py` | Registry-Sync in Test-Session | +| `frontend/src/App.jsx` | Header AP0.3; `/api/me/context` zeigt Capabilities | +| `README.md` | API-Doku AP0.3 | +| `.gitea/workflows/deploy-prod.yml` | Backend-Logs bei `--wait`-Fehler (Parität zu Dev) | +| `docs/DEPLOYMENT.md` | Abschnitt DB-Passwort Dev/Prod inkl. `ALTER USER`-Fix | + +**Wesentliche Commits:** `910b1d7` (Implementierung), `eaa0e25` (pytest-Fix), `4069cb0` (GUI-Sprint-Label), `39e8084` (Abschlussbericht v0.2). + +**Nacharbeit (noch auf `develop`):** `deploy-prod.yml`, `DEPLOYMENT.md`, dieser Bericht v0.3. + +--- + +## 3. Neue Migrationen + +**`004_capabilities_registry.sql`** + +- **`capabilities`** — `capability_key`, `module`, `description`, `is_active` +- **`role_capability_grants`** — `role_scope` (`portal` \| `tenant`), `role_code`, `capability_key` + +Kein Feature-/Limit-Schema (Vorbereitung AP0.4). + +--- + +## 4. Registry-Struktur + +``` +backend/ +├── rights_registry.py +├── rights_registrations/ +│ ├── __init__.py +│ ├── platform.py # admin, context, entitlements +│ └── tenant_ops.py # tenant.manage, actor.manage +└── sync_rights_registry.py +``` + +**Modell:** `@dataclass(frozen=True) CapabilityRegistration` mit `key`, `module`, `description`, `default_grants`. + +**Startup:** `entrypoint.sh` → Migrationen → Seeds → `python sync_rights_registry.py` → Uvicorn (`SKIP_RIGHTS_SYNC=1` zum Überspringen). + +--- + +## 5. Endpoints + +| Endpoint | Methode | Auth / Gate | Status | +|----------|---------|-------------|--------| +| `/api/me/context` | GET | Session | geändert — `capabilities[]` | +| `/api/me/entitlements` | GET | Session | neu | +| `/api/me/admin/demo` | GET | Session + `kairo.admin.access` | neu (Beispiel `require_capability`) | + +OpenAPI (Dev): `/api/docs` + +--- + +## 6. Capability-Auflösungslogik + +``` +Session (portal_role, active_tenant_id) + → tenant_role aus Membership (optional) + → load_grants_for_roles(portal_role, tenant_role) + → Union Portal-Grants + Tenant-Grants + → TenantContext.capabilities (frozenset) +``` + +**`require_capability(key)`:** prüft Mitgliedschaft in `ctx.capabilities`. + +| Modus | Env | Verhalten | +|-------|-----|-----------| +| probe | `CAPABILITY_ENFORCE=probe` (Default) | Zugriff erlaubt; Verweigerung → Audit `capability.denied` | +| enforce | `CAPABILITY_ENFORCE=enforce` | HTTP 403 ohne Grant | + +--- + +## 7. Initiale Capabilities + +| Capability | Portal admin | Portal user | Tenant owner | Tenant admin | Tenant member | +|------------|:------------:|:-----------:|:------------:|:------------:|:-------------:| +| `kairo.admin.access` | ✓ | | | | | +| `kairo.tenant.manage` | ✓ | | ✓ | ✓ | | +| `kairo.actor.manage` | ✓ | | ✓ | ✓ | | +| `kairo.context.read` | ✓ | ✓ | ✓ | ✓ | ✓ | +| `kairo.entitlements.read` | ✓ | ✓ | ✓ | ✓ | ✓ | + +--- + +## 8. Tests und Verifikation + +### pytest (CI, Dev-Container) + +```bash +docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv +``` + +| Testdatei | Abdeckung | Status | +|-----------|-----------|--------| +| `test_rights_registry.py` | 5 Capabilities, Sync idempotent | ✓ | +| `test_capabilities.py` | Admin/Member enforce, Owner-Grants, probe | ✓ | +| `test_entitlements.py` | Snapshot-Struktur | ✓ | +| `test_migrations.py` | Migration 004 | ✓ | +| Gesamt Test Suite (Gitea) | nach Push `develop` | ✓ | + +### Manuell (Dev-GUI) + +| Prüfung | Ergebnis | +|---------|----------| +| Login `lars@stommer.com` | ✓ | +| `/api/me/context` → `capabilities` sichtbar | ✓ | +| API Health → `"schema": "004"` | ✓ | +| Header Sprint-Anzeige AP0.3 | ✓ | + +**Hinweis:** Entitlements und Admin-Demo sind in der GUI noch nicht als eigene Karten — prüfbar über Swagger (`/api/docs`) oder DevTools. + +### Production (nach Nacharbeit 2026-07-04) + +| Prüfung | Ergebnis | +|---------|----------| +| `curl http://localhost:8004/api/health` | ✓ — `0.3.0-ap0.3`, Schema `004`, `db: ok` | +| `curl http://localhost:3004/api/health` | ✓ | +| Migration 004 auf Prod-Volume | ✓ (nach DB-Reconnect) | +| Rights-Sync beim Start | ✓ | +| Bootstrap-Admin (`.env`) | unverändert nutzbar | + +--- + +## 9. Prod-Deploy-Nacharbeit (Gitea `deploy-prod` #7154) + +### Symptom + +Push auf `main` → Workflow `deploy-prod` schlug fehl: + +``` +dependency failed to start: container kairo-api is unhealthy +``` + +Backend-Logs (Pi): + +``` +[FAIL] ... FATAL: password authentication failed for user "kairo_user" +``` + +### Ursache + +**Kein AP0.3-Codefehler.** PostgreSQL initialisiert User/Passwort nur beim **ersten Start** des Volumes (`kairo-db-data`). Das Passwort in `/home/lars/docker/kairo/.env` wich vom in der DB gespeicherten Passwort ab — identisches Muster wie beim Dev-Deploy (#7100). + +Der Entrypoint bricht bei DB-Auth-Fehlern fail-fast ab (seit AP0.2/`f8b3ae3`); Uvicorn startet nicht → Healthcheck scheitert nach wenigen Sekunden. + +### Behebung (Prod, ohne Datenverlust) + +```bash +cd /home/lars/docker/kairo +set -a && source .env && set +a +docker compose exec -T postgres psql -U kairo_user -d kairo \ + -c "ALTER USER kairo_user WITH PASSWORD '${DB_PASSWORD}';" +docker compose restart backend +docker compose up -d --wait +``` + +Danach: alle Container `healthy`, API und Frontend-Proxy antworten. + +### Prävention / Doku + +| Maßnahme | Datei | +|----------|-------| +| Prod-Fix ohne Volume-Löschung dokumentiert | `docs/DEPLOYMENT.md` § Datenbank-Passwort | +| Backend-Logs bei `compose up --wait`-Fehler | `.gitea/workflows/deploy-prod.yml` (wie Dev) | + +**Wichtig:** `docker compose down -v` auf Prod löscht alle Anwendungsdaten. Bei Passwort-Wechsel in `.env` bevorzugt `ALTER USER` verwenden. + +--- + +## 10. Deployment & Betrieb + +| | Development | Production | +|---|-------------|------------| +| Verzeichnis | `/home/lars/docker/kairo-dev` | `/home/lars/docker/kairo` | +| Ports | 3097 / 8097 | 3004 / 8004 | +| Postgres | eigener Container + Volume | eigener Container + Volume (`kairo-db-data`) | +| Dev-Seeds | ja | nein | +| Admin | `lars@stommer.com` (Seed) | `KAIRO_BOOTSTRAP_*` in `.env` | +| Schema nach AP0.3 | `004` | `004` | + +**Startup-Reihenfolge:** Entrypoint → Schema-Migrationen → Data-Seeds → Rights-Sync → Uvicorn. + +--- + +## 11. Übernommene Muster aus Shinkan + +| Muster | Kairo-Umsetzung | +|--------|-----------------| +| Registry-first | `rights_registry.py` + `rights_registrations/` | +| `register_capability()` mit Validierung | Pflichtfelder `key`, `module`, `description` | +| Frozen Dataclass-Definitionen | `CapabilityRegistration` | +| Startup-Sync Registry → DB | `sync_rights_registry_to_db()` | +| Modul-Ownership | `platform`, `tenant` | +| Default Grants in Code | `default_grants` pro Capability | +| `require_capability` Dependency | `capabilities.py` | +| `/api/me/entitlements` Snapshot | `entitlements.py` | +| Probe vs. Enforce | `CAPABILITY_ENFORCE` | + +**Nicht übernommen:** `club_id`, Vereinsrollen, Übungs-/Trainingsrechte, Club-Feature-Kontingente. + +--- + +## 12. Übernommene Muster aus Mitai + +| Muster | Kairo-Umsetzung | +|--------|-----------------| +| Entitlements als zentrale Schicht | `build_entitlements_snapshot()` | +| Probe-vs-Enforce-Denken | Default `probe`, optional `enforce` | +| Feature-Registry vorbereitet | `features: {}` im Snapshot | + +**Nicht übernommen:** Tier/Subscription, Usage-Zähler, Billing, Legacy `check_feature_access`. + +--- + +## 13. Bewusst nicht übernommen + +- Shinkan-/Mitai-Domänenbegriffe (`club`, `profile`, Tier) +- Feature-Limits, Billing, Coupons +- Prompt Registry, Vorhaben-/Projektlogik +- MCP, produktive Admin-UI (nur Debug-JSON + Swagger) +- Capabilities nur in SQL pflegen +- Governance/Object-ACL + +--- + +## 14. Abweichungen von Designprinzipien + +| Thema | Abweichung | Begründung | +|-------|------------|------------| +| Entitlements-Struktur | Account- + Tenant-Block | Kairo Hybrid laut Family Entitlement Model | +| Feature-Registry | leeres Objekt | AP0.4 | +| Grant-Overrides | Sync nur additive Grants | Sprint-0-Minimal | +| `linked_feature_id` | nicht modelliert | Keine Limits in AP0.3 | +| Frontend | kein Entitlements-Panel | Minimal-UI; AP0.4/UX optional | + +**Eingehalten:** zentrale Capability-Prüfung; Portal- vs. Tenant-Rolle getrennt; TenantContext als Auflösungsschicht. + +--- + +## 15. Offene Entscheidungen + +1. **Prod `CAPABILITY_ENFORCE=enforce`** — wann umstellen? +2. **Grant-Overrides in DB** vs. reiner Code-Kanon +3. **Prod-pytest-Cleanup** (`*@example.com`) — aus AP0.2 offen +4. **Frontend:** Entitlements-Karte + Admin-Demo-Button +5. **Feature-Registry** — AP0.4 + +--- + +## 16. Empfehlung für AP0.4 + +Laut Foundation **AP0.4 – Prompt, Feature, Config Registry**: + +- Feature Registry (Metadaten, ohne Billing-Limits in Sprint 0) +- PromptTemplate / PromptVersion / Placeholder +- ConfigurationEntry +- Prompt-Rendering mit Placeholder-Validation +- `features`-Block in Entitlements mit Registry-Sync + +Optional vor AP0.4: Prod-Cleanup-Seed; GUI-Entitlements; `CAPABILITY_ENFORCE=enforce` in Prod nach Kalibrierung. + +--- + +## Referenzen + +- `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` § AP0.3, §9 +- `docs/reference/design-principles/shinkan/RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md` +- `docs/reference/design-principles/shinkan/CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md` +- `docs/reference/design-principles/alignment/FAMILY_ENTITLEMENT_MODEL.md` +- `docs/DEPLOYMENT.md` — DB-Passwort Dev/Prod +- Vorgänger: `docs/sprints/Sprint0_AP0_2_Completion_Report_v0.2.md`, `Sprint0_AP0_3_Completion_Report_v0.2.md` + +--- + +*Abgeschlossen im Rahmen Sprint 0 – AP0.3. Ersetzt `Sprint0_AP0_3_Completion_Report_v0.2.md`.* diff --git a/docs/sprints/Sprint0_AP0_4_Completion_Report_v0.1.md b/docs/sprints/Sprint0_AP0_4_Completion_Report_v0.1.md new file mode 100644 index 0000000..c637cc0 --- /dev/null +++ b/docs/sprints/Sprint0_AP0_4_Completion_Report_v0.1.md @@ -0,0 +1,357 @@ +# AP0.4 – Abschlussbericht Workflow-ready Prompt, Feature & Config Registry + +**Status:** abgeschlossen +**Stand:** 2026-07-04 +**Branch:** `develop` · Schema `005` · Version `0.4.0-ap0.4` + +--- + +## 1. Scope und Einordnung + +AP0.4 liefert die **minimale, workflow-ready Grundlage** für Feature-, Prompt-, Placeholder- und Configuration-Registry in Kairo — ohne LLM-Aufrufe, ohne Workflow Engine, ohne Billing. + +| Anforderung (AP0.4) | Status | +|---------------------|--------| +| Migration Feature/Prompt/Placeholder/Config/ExecutionLog | ✓ | +| Feature Registry + DB-Sync | ✓ | +| PromptDefinition / PromptVersion / PromptStep | ✓ | +| PlaceholderDefinition + Validation | ✓ | +| ConfigurationEntry + Config Service | ✓ | +| Single Prompt Rendering | ✓ | +| `features`-Block in Entitlements | ✓ | +| Capability-geschützte API | ✓ | +| Audit bei Sync/Config/Fehler-Render | ✓ | +| Tests | ✓ (lokal; CI nach Push) | + +**Bewusst nicht in AP0.4:** Workflow/Pipeline-Ausführung, LLM, MCP, Tool Calling, Feature-Limits, Admin-UI, zentrale Jinkendo Prompt Engine. + +--- + +## 2. Umgesetzte Dateien + +| Datei | Zweck | +|-------|--------| +| `backend/migrations/005_prompt_feature_config_registry.sql` | Tabellen Feature/Prompt/Placeholder/Config/ExecutionLog | +| `backend/feature_registry.py` | Feature-Registry + DB-Sync | +| `backend/feature_registrations/` | Initiale Features | +| `backend/prompt_registry.py` | Prompt-Registry + Version/Step-Sync | +| `backend/prompt_registrations/` | Smoke-Prompts | +| `backend/placeholder_registry.py` | Placeholder-Registry + Sync | +| `backend/placeholder_registrations/` | Tenant-/Actor-/Debug-Placeholder | +| `backend/config_service.py` | Config lesen/schreiben, Secret-Schutz | +| `backend/prompt_validation.py` | Placeholder-Extraktion + Validierung | +| `backend/prompt_rendering.py` | Single-Mode Render + ExecutionLog | +| `backend/prompt_context.py` | TenantContext → Placeholder-Werte | +| `backend/sync_prompt_feature_config.py` | Startup-Sync + Default-Configs | +| `backend/rights_registrations/registry_ops.py` | 7 neue Capabilities | +| `backend/routers/features.py` | `GET /api/features` | +| `backend/routers/prompts.py` | Prompt-Liste, Detail, Render | +| `backend/routers/config.py` | Config GET/POST | +| `backend/entitlements.py` | `features`-Block aus DB | +| `backend/capabilities.py` | `require_capability_ctx` (ohne Tenant-Zwang) | +| `backend/entrypoint.sh` | Registry-Sync nach Rights-Sync | +| `backend/version.py` | `0.4.0-ap0.4`, Schema `005` | +| `backend/tests/test_feature_registry.py` | Feature-Tests | +| `backend/tests/test_prompt_registry.py` | Prompt/Validation/Render-Tests | +| `backend/tests/test_config_registry.py` | Config-Tests | +| `frontend/src/App.jsx` | Header AP0.4 | +| `README.md` | API-Doku AP0.4 | + +--- + +## 3. Neue Migrationen + +**`005_prompt_feature_config_registry.sql`** + +| Tabelle | Zweck | +|---------|--------| +| `features` | Feature-Metadatenkatalog | +| `prompt_definitions` | PromptDefinition inkl. `execution_mode`, `active_version_id` | +| `prompt_versions` | Versionierter Body + JSON-Schemas + `model_policy_key` | +| `prompt_steps` | Workflow-/Pipeline-Schritte (AP0.4: nur `template` genutzt) | +| `placeholder_definitions` | Typisierte Placeholder pro `context_kind` | +| `configuration_entries` | Global/tenant Config (kein Secret-Klartext) | +| `prompt_execution_logs` | Render-/Test-Läufe | + +--- + +## 4. Datenmodell + +``` +features +prompt_definitions ──< prompt_versions ──< prompt_steps + └── active_version_id → prompt_versions + +placeholder_definitions (key + context_kind) + +configuration_entries (global | tenant) + +prompt_execution_logs → definition/version/user +``` + +**ExecutionMode:** `single` | `pipeline` | `workflow` (nur `single` ausführbar) +**ContextKind:** `kairo.tenant_context`, `kairo.actor_context`, `kairo.debug_context` +**StepType:** `template`, `resolver`, `llm_call`, `postprocess`, `validation` (nur `template` ausführbar) + +--- + +## 5. Registry-Strukturen + +Analog AP0.3 Rights Registry: + +``` +backend/ +├── feature_registry.py + feature_registrations/ +├── prompt_registry.py + prompt_registrations/ +├── placeholder_registry.py + placeholder_registrations/ +├── config_service.py +├── prompt_validation.py +├── prompt_rendering.py +└── sync_prompt_feature_config.py +``` + +Startup-Reihenfolge: Migrationen → Seeds → Rights-Sync → **Registry-Sync** → Uvicorn. + +--- + +## 6. Feature Registry + +Registrierte Features: + +| Feature Key | Modul | +|-------------|-------| +| `kairo.feature.registry` | platform | +| `kairo.config.registry` | config | +| `kairo.prompt.registry` | prompt | + +Keine Limits, Quotas oder Billing — reiner Metadatenkatalog im Entitlements-Snapshot (`enabled: true`). + +--- + +## 7. Prompt-Modell + +**Kernel-Konzepte (lokal in Kairo, extrahierbar):** + +| Konzept | Umsetzung | +|---------|-----------| +| PromptDefinition | `prompt_definitions` + `PromptRegistration` | +| PromptVersion | `prompt_versions` | +| PromptStep | `prompt_steps` | +| PlaceholderDefinition | `placeholder_definitions` | +| ContextKind | Code-Konstanten + Spalte `context_kind` | +| ExecutionMode | Spalte `execution_mode` | +| InputSchema / OutputSchema | JSONB auf Version | +| ModelPolicyKey | Spalte (Vorbereitung, kein LLM) | +| PromptRenderService | `prompt_rendering.render_prompt_single` | +| PromptValidationService | `prompt_validation.validate_placeholder_input` | +| PromptExecutionLog | `prompt_execution_logs` | + +**Smoke-Prompts:** + +- `kairo.system.health_summary` — Debug-Context, Template via `PromptStep` +- `kairo.context.debug_summary` — TenantContext-Placeholder +- `kairo.actor.debug_summary` — Actor-Context +- `kairo.pipeline.placeholder` — `execution_mode=pipeline` (strukturell, nicht ausführbar) + +--- + +## 8. Workflow-/Pipeline-Fähigkeit + +| Vorbereitet | AP0.4 Verhalten | +|-------------|-----------------| +| `execution_mode=pipeline/workflow` | In DB/Registry akzeptiert; Render → HTTP 501 / `mode_not_implemented` | +| `prompt_steps` mit diversen `step_type` | Gespeichert; Single-Render nutzt ersten aktiven `template`-Step oder `version.body` | +| `input_mapping` / `output_mapping` | JSONB-Spalten, noch nicht ausgewertet | + +**Spätere Pipeline:** Schritte der aktiven Version in `step_order` ausführen; pro `step_type` Handler registrieren (analog Rights-Registrations). +**Spätere Workflow:** `execution_mode=workflow` + Step-Graph/Orchestrator oberhalb des Render-Services — Tabellen müssen nicht migriert werden. + +--- + +## 9. Placeholder Validation + +- Mustache-Style: `{{placeholder_key}}` +- Required Placeholder aus Registry → kontrollierter `PlaceholderValidationError` +- Optionale Placeholder fehlen → kein Fehler +- Unbekannte Keys im Template → in `validation.unknown_placeholders` dokumentiert +- Typ-Prüfung basic (`string`, `number`, `boolean`, `object`, `array`, `json`) +- Template und Kontext getrennt (`use_context` + `prompt_context.py`) + +Initial-Placeholder (Tenant): `tenant_name`, `tenant_id`, `actor_*`, `portal_role`, `tenant_role`, `capabilities`. + +--- + +## 10. Configuration Registry + +Default-Configs beim Sync: + +| Key | Wert | +|-----|------| +| `prompt.default_execution_mode` | `single` | +| `prompt.render_logging_enabled` | `true` | +| `features.registry_sync_enabled` | `true` | + +- Scopes: `global`, `tenant` +- `is_secret=true` → kein Klartext; nur `{"ref": "env"}` erlaubt +- Zugriff zentral über `config_service.py` + +--- + +## 11. Entitlements-Erweiterung + +`/api/me/entitlements` liefert nun `features` auf Root-, Account- und Tenant-Ebene: + +```json +{ + "features": { + "kairo.prompt.registry": { + "enabled": true, + "module": "prompt", + "name": "Prompt Registry" + } + } +} +``` + +Keine Usage-Zähler. Feature-Enforcement bleibt `probe`. + +--- + +## 12. Neue/geänderte API-Endpunkte + +| Endpoint | Auth | Capability | +|----------|------|------------| +| `GET /api/features` | Session | `kairo.feature.registry.read` | +| `GET /api/prompts` | Session | `kairo.prompt.registry.read` | +| `GET /api/prompts/{key}` | Session | `kairo.prompt.registry.read` | +| `POST /api/prompts/{key}/render` | Session | `kairo.prompt.render` | +| `GET /api/config` | Session | `kairo.config.registry.read` | +| `POST /api/config` | Session | `kairo.config.registry.manage` | + +`require_capability_ctx` erlaubt Lese-Endpunkte ohne aktiven Tenant (Portal-Admin/User). + +--- + +## 13. Audit + +| Aktion | Event | +|--------|-------| +| Registry-Sync Startup | `registry.sync.completed` | +| Config geändert | `config.entry.changed` | +| Render fehlgeschlagen | `prompt.render.failed` | +| Capability verweigert | `capability.denied` (bestehend) | + +Prompt-Definition-CRUD über API ist in AP0.4 nicht exponiert (nur Code-Registry + Sync) — Audit bei manuellem DB-Write wäre Lücke; AP0.5 Admin-Routen können CRUD + Audit ergänzen. + +--- + +## 14. Tests und Verifikation + +| Testdatei | Abdeckung | +|-----------|-----------| +| `test_feature_registry.py` | Registrierung, Sync, Entitlements, Auth | +| `test_prompt_registry.py` | Sync, Validation, Render, Pipeline-Ablehnung, API | +| `test_config_registry.py` | Global Config, Secret-Schutz, API | +| `test_entitlements.py` | Features im Snapshot | +| `test_migrations.py` | Migration 005 | +| `test_rights_registry.py` | 12 Capabilities | + +```bash +docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv +``` + +--- + +## 15. Übernommene Muster aus Mitai + +| Muster | Kairo | +|--------|-------| +| Prompt Template + Version | `PromptRegistration` / `prompt_versions` | +| Placeholder-Handling | Registry + Validation | +| Feature Registry Metadaten | `features` + Entitlements | +| Entitlements zentrale Schicht | `features`-Block ergänzt | +| Registry-Sync | Startup-Sync analog Rights | +| Preview/Testbarkeit | Render-Endpoint + ExecutionLog | + +**Nicht übernommen:** Pipeline-Graphen, Gesundheits-Prompts, Tier/Billing, Usage-Zähler. + +--- + +## 16. Übernommene Muster aus Shinkan + +| Muster | Kairo | +|--------|-------| +| Mustache-Template-Prinzip | `{{key}}`-Rendering | +| Kontextarten | `context_kind` | +| Registry-first | Code → DB-Sync | +| Schlanke Runtime ohne Overengineering | nur Single-Render | +| Capability-Gates | `require_capability*` | + +**Nicht übernommen:** Trainings-Prompts, Club-Logik, Übungsrechte. + +--- + +## 17. Bewusst nicht übernommene Muster + +- Vollständige Mitai Prompt Engine / Workflow-Graphen +- LLM Provider Routing, Tool Calling, MCP +- Shinkan-/Mitai-Domänenprompts +- Feature-Limits, Billing, Coupons +- Produktive Admin-UI +- Secrets in `configuration_entries` + +--- + +## 18. Abweichungen von Designprinzipien + +| Thema | Abweichung | Begründung | +|-------|------------|------------| +| Prompt-CRUD API | nur Code-Registry + Sync | Sprint-0-Minimal; Swagger-Read/Render reicht | +| Feature-Enforcement | immer `probe` | Limits erst später | +| Output-Validation | nicht implementiert | Spec: nicht erforderlich in AP0.4 | +| ContextKind | Code-Konstanten, keine eigene Tabelle | Ausreichend für 3 Kindes | + +--- + +## 19. Offene Entscheidungen + +1. **Wann Prompt/Feature-CRUD über Admin-API** (AP0.5)? +2. **Extraktion `jinkendo_prompt_kernel`** — Package-Grenze und Migrations-Ownership +3. **Pipeline-Executor** — Step-Handler-Registry vs. feste Kette +4. **Feature-Enforcement** — wann über reine Metadaten hinaus? +5. **Render-Logging in Prod** — `prompt.render_logging_enabled` Default? + +--- + +## 20. Empfehlung für AP0.5 + +Laut Foundation **AP0.5 – Audit und minimale Admin-Prüfbarkeit**: + +- AuditLog für Registry-/Prompt-Änderungen erweitern +- Minimale Admin-Routen oder Admin-Seite +- Optional: Entitlements-/Feature-Panel in GUI +- Prod-pytest-Cleanup (`*@example.com`) + +--- + +## Extraktion `jinkendo_prompt_kernel` — Einschätzung + +**Realistisch:** Die Module `prompt_registry`, `placeholder_registry`, `prompt_validation`, `prompt_rendering` und die Tabellen `prompt_*` / `placeholder_definitions` sind produktneutral genug (`kairo.*`-Keys nur in Registrations). Extraktion als Shared Python-Package mit injizierbarem DB-Layer und Audit-Hook ist ohne Schema-Bruch möglich. + +**Risiken im aktuellen Zuschnitt:** + +- Config und Feature Registry noch eng an Kairo-Entitlements gekoppelt +- Kein generischer Step-Executor — Pipeline-Integration erfordert noch Designarbeit +- `require_capability_ctx` vs. `require_capability` — zwei Patterns für Tenant-Pflicht + +--- + +## Referenzen + +- `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` § AP0.4 +- Vorgänger: `docs/sprints/Sprint0_AP0_3_Completion_Report_v0.3.md` +- Designprinzipien: Mitai Prompt/Registry/Feature, Shinkan AI Prompt Runtime + +--- + +*Abgeschlossen im Rahmen Sprint 0 – AP0.4.* diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4706721..0e1d1d9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -183,7 +183,7 @@ export default function App() {

Jinkendo Kairo

-

Operativer Program Director — Sprint 0 / AP0.3

+

Operativer Program Director — Sprint 0 / AP0.4

Capability Registry & Entitlements