Ap04 #5
|
|
@ -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 ==="
|
||||
|
|
|
|||
42
README.md
42
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.
|
||||
|
|
|
|||
|
|
@ -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,6 +40,21 @@ 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:
|
||||
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
|
||||
|
|
@ -62,5 +77,3 @@ def require_capability(capability_key: str) -> Callable[..., TenantContext]:
|
|||
detail=f"Capability fehlt: {capability_key}",
|
||||
)
|
||||
return ctx
|
||||
|
||||
return _dependency
|
||||
|
|
|
|||
208
backend/config_service.py
Normal file
208
backend/config_service.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
3
backend/feature_registrations/__init__.py
Normal file
3
backend/feature_registrations/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Side-effect imports for feature registrations."""
|
||||
|
||||
from feature_registrations import platform, prompt_ops # noqa: F401
|
||||
32
backend/feature_registrations/platform.py
Normal file
32
backend/feature_registrations/platform.py
Normal file
|
|
@ -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",
|
||||
)
|
||||
)
|
||||
5
backend/feature_registrations/prompt_ops.py
Normal file
5
backend/feature_registrations/prompt_ops.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Prompt-related feature metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Reserved for future prompt-domain features beyond registry catalog.
|
||||
108
backend/feature_registry.py
Normal file
108
backend/feature_registry.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
121
backend/migrations/005_prompt_feature_config_registry.sql
Normal file
121
backend/migrations/005_prompt_feature_config_registry.sql
Normal file
|
|
@ -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);
|
||||
3
backend/placeholder_registrations/__init__.py
Normal file
3
backend/placeholder_registrations/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Side-effect imports for placeholder registrations."""
|
||||
|
||||
from placeholder_registrations import actor_context, debug, tenant_context # noqa: F401
|
||||
17
backend/placeholder_registrations/actor_context.py
Normal file
17
backend/placeholder_registrations/actor_context.py
Normal file
|
|
@ -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)
|
||||
)
|
||||
14
backend/placeholder_registrations/debug.py
Normal file
14
backend/placeholder_registrations/debug.py
Normal file
|
|
@ -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)
|
||||
)
|
||||
38
backend/placeholder_registrations/tenant_context.py
Normal file
38
backend/placeholder_registrations/tenant_context.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
117
backend/placeholder_registry.py
Normal file
117
backend/placeholder_registry.py
Normal file
|
|
@ -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()
|
||||
44
backend/prompt_context.py
Normal file
44
backend/prompt_context.py
Normal file
|
|
@ -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
|
||||
3
backend/prompt_registrations/__init__.py
Normal file
3
backend/prompt_registrations/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Side-effect imports for prompt registrations."""
|
||||
|
||||
from prompt_registrations import debug, platform # noqa: F401
|
||||
23
backend/prompt_registrations/debug.py
Normal file
23
backend/prompt_registrations/debug.py
Normal file
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
80
backend/prompt_registrations/platform.py
Normal file
80
backend/prompt_registrations/platform.py
Normal file
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
310
backend/prompt_registry.py
Normal file
310
backend/prompt_registry.py
Normal file
|
|
@ -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()
|
||||
172
backend/prompt_rendering.py
Normal file
172
backend/prompt_rendering.py
Normal file
|
|
@ -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
|
||||
81
backend/prompt_validation.py
Normal file
81
backend/prompt_validation.py
Normal file
|
|
@ -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
|
||||
),
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
82
backend/rights_registrations/registry_ops.py
Normal file
82
backend/rights_registrations/registry_ops.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
61
backend/routers/config.py
Normal file
61
backend/routers/config.py
Normal file
|
|
@ -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
|
||||
35
backend/routers/features.py
Normal file
35
backend/routers/features.py
Normal file
|
|
@ -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()
|
||||
74
backend/routers/prompts.py
Normal file
74
backend/routers/prompts.py
Normal file
|
|
@ -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
|
||||
60
backend/sync_prompt_feature_config.py
Normal file
60
backend/sync_prompt_feature_config.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
66
backend/tests/test_config_registry.py
Normal file
66
backend/tests/test_config_registry.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
48
backend/tests/test_feature_registry.py
Normal file
48
backend/tests/test_feature_registry.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
109
backend/tests/test_prompt_registry.py
Normal file
109
backend/tests/test_prompt_registry.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
322
docs/sprints/Sprint0_AP0_3_Completion_Report_v0.3.md
Normal file
322
docs/sprints/Sprint0_AP0_3_Completion_Report_v0.3.md
Normal file
|
|
@ -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`.*
|
||||
357
docs/sprints/Sprint0_AP0_4_Completion_Report_v0.1.md
Normal file
357
docs/sprints/Sprint0_AP0_4_Completion_Report_v0.1.md
Normal file
|
|
@ -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.*
|
||||
|
|
@ -183,7 +183,7 @@ export default function App() {
|
|||
<main className="shell">
|
||||
<header className="hero">
|
||||
<h1>Jinkendo Kairo</h1>
|
||||
<p>Operativer Program Director — Sprint 0 / AP0.3</p>
|
||||
<p>Operativer Program Director — Sprint 0 / AP0.4</p>
|
||||
<p className="muted">Capability Registry & Entitlements</p>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user