AP0.4: Feature-, Prompt- und Config-Registry mit Single-Render und Entitlements-Features.
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 18s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 24s
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 18s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 24s
Migration 005, Registry-Sync, Placeholder-Validation, capability-geschuetzte API, Tests und Abschlussbericht v0.1. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f8af47d1d5
commit
53047b6c16
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/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) |
|
| `/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)
|
### Capabilities (AP0.3)
|
||||||
|
|
||||||
Registry-first: Capabilities werden in `backend/rights_registrations/` registriert und beim Start via `sync_rights_registry.py` in die DB synchronisiert.
|
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 rights_registry import load_grants_for_roles
|
||||||
from services.audit import log_audit
|
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"
|
CAPABILITY_ENFORCE_ENV = "CAPABILITY_ENFORCE"
|
||||||
|
|
||||||
|
|
@ -40,27 +40,40 @@ def require_capability(capability_key: str) -> Callable[..., TenantContext]:
|
||||||
"""FastAPI dependency — blocks in enforce mode, probes in probe mode."""
|
"""FastAPI dependency — blocks in enforce mode, probes in probe mode."""
|
||||||
|
|
||||||
def _dependency(ctx: TenantContext = Depends(require_tenant_context)) -> TenantContext:
|
def _dependency(ctx: TenantContext = Depends(require_tenant_context)) -> TenantContext:
|
||||||
result = check_capability(ctx, capability_key)
|
return _enforce_capability(ctx, capability_key)
|
||||||
if result["allowed"]:
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
log_audit(
|
|
||||||
"capability.denied",
|
|
||||||
user_id=ctx.user_id,
|
|
||||||
tenant_id=ctx.tenant_id,
|
|
||||||
details={
|
|
||||||
"capability_key": capability_key,
|
|
||||||
"mode": capability_enforcement_mode(),
|
|
||||||
"portal_role": ctx.portal_role,
|
|
||||||
"tenant_role": ctx.tenant_role,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if capability_enforcement_mode() == "enforce":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Capability fehlt: {capability_key}",
|
|
||||||
)
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
return _dependency
|
return _dependency
|
||||||
|
|
||||||
|
|
||||||
|
def require_capability_ctx(capability_key: str) -> Callable[..., TenantContext]:
|
||||||
|
"""Like require_capability but allows portal context without active tenant."""
|
||||||
|
|
||||||
|
def _dependency(ctx: TenantContext = Depends(get_tenant_context)) -> TenantContext:
|
||||||
|
return _enforce_capability(ctx, capability_key)
|
||||||
|
|
||||||
|
return _dependency
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_capability(ctx: TenantContext, capability_key: str) -> TenantContext:
|
||||||
|
result = check_capability(ctx, capability_key)
|
||||||
|
if result["allowed"]:
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"capability.denied",
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
details={
|
||||||
|
"capability_key": capability_key,
|
||||||
|
"mode": capability_enforcement_mode(),
|
||||||
|
"portal_role": ctx.portal_role,
|
||||||
|
"tenant_role": ctx.tenant_role,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if capability_enforcement_mode() == "enforce":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Capability fehlt: {capability_key}",
|
||||||
|
)
|
||||||
|
return ctx
|
||||||
|
|
|
||||||
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 typing import Any
|
||||||
|
|
||||||
from capabilities import capability_enforcement_mode, check_capability
|
from capabilities import capability_enforcement_mode, check_capability
|
||||||
|
from feature_registry import load_active_features
|
||||||
from rights_registry import get_registered_capabilities
|
from rights_registry import get_registered_capabilities
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
@ -17,7 +18,20 @@ def _capability_map(ctx: TenantContext) -> dict[str, dict[str, Any]]:
|
||||||
return snapshot
|
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]:
|
def build_entitlements_snapshot(ctx: TenantContext) -> dict[str, Any]:
|
||||||
|
features = _feature_snapshot()
|
||||||
tenant_block = None
|
tenant_block = None
|
||||||
if ctx.tenant_id:
|
if ctx.tenant_id:
|
||||||
tenant_block = {
|
tenant_block = {
|
||||||
|
|
@ -30,7 +44,7 @@ def build_entitlements_snapshot(ctx: TenantContext) -> dict[str, Any]:
|
||||||
for key, value in _capability_map(ctx).items()
|
for key, value in _capability_map(ctx).items()
|
||||||
if key in ctx.capabilities
|
if key in ctx.capabilities
|
||||||
},
|
},
|
||||||
"features": {},
|
"features": features,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -40,7 +54,7 @@ def build_entitlements_snapshot(ctx: TenantContext) -> dict[str, Any]:
|
||||||
"display_name": ctx.display_name,
|
"display_name": ctx.display_name,
|
||||||
"portal_role": ctx.portal_role,
|
"portal_role": ctx.portal_role,
|
||||||
"capabilities": _capability_map(ctx),
|
"capabilities": _capability_map(ctx),
|
||||||
"features": {},
|
"features": features,
|
||||||
},
|
},
|
||||||
"tenant": tenant_block,
|
"tenant": tenant_block,
|
||||||
"actor": (
|
"actor": (
|
||||||
|
|
@ -56,7 +70,7 @@ def build_entitlements_snapshot(ctx: TenantContext) -> dict[str, Any]:
|
||||||
"tenant": ctx.tenant_role,
|
"tenant": ctx.tenant_role,
|
||||||
},
|
},
|
||||||
"capabilities": sorted(ctx.capabilities),
|
"capabilities": sorted(ctx.capabilities),
|
||||||
"features": {},
|
"features": features,
|
||||||
"enforcement": {
|
"enforcement": {
|
||||||
"capabilities": capability_enforcement_mode(),
|
"capabilities": capability_enforcement_mode(),
|
||||||
"features": "probe",
|
"features": "probe",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ else
|
||||||
echo "[SKIP_RIGHTS_SYNC] Rights-Registry-Sync übersprungen"
|
echo "[SKIP_RIGHTS_SYNC] Rights-Registry-Sync übersprungen"
|
||||||
fi
|
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
|
export KAIRO_DB_READY=1
|
||||||
echo "=== Starte Anwendung: $* ==="
|
echo "=== Starte Anwendung: $* ==="
|
||||||
exec "$@"
|
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=["*"],
|
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(auth.router)
|
||||||
app.include_router(me.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")
|
@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."""
|
"""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_SEEDS", "1")
|
||||||
os.environ.setdefault("SKIP_BOOTSTRAP", "1")
|
os.environ.setdefault("SKIP_BOOTSTRAP", "1")
|
||||||
os.environ.setdefault("SKIP_RIGHTS_SYNC", "1")
|
os.environ.setdefault("SKIP_RIGHTS_SYNC", "1")
|
||||||
|
os.environ.setdefault("SKIP_REGISTRY_SYNC", "1")
|
||||||
|
|
||||||
|
|
||||||
def _db_available() -> bool:
|
def _db_available() -> bool:
|
||||||
|
|
@ -57,6 +58,16 @@ def _sync_rights_registry():
|
||||||
assert sync_rights_registry_to_db() == 0
|
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()
|
@pytest.fixture()
|
||||||
def client():
|
def client():
|
||||||
import importlib
|
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 "kairo.admin.access" in body["capabilities"]
|
||||||
assert body["enforcement"]["capabilities"] in ("probe", "enforce")
|
assert body["enforcement"]["capabilities"] in ("probe", "enforce")
|
||||||
assert body["enforcement"]["features"] == "probe"
|
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 "002_auth_identity_tenant_actor" in names
|
||||||
assert "003_data_seeds_tracking" in names
|
assert "003_data_seeds_tracking" in names
|
||||||
assert "004_capabilities_registry" in names
|
assert "004_capabilities_registry" in names
|
||||||
|
assert "005_prompt_feature_config_registry" in names
|
||||||
|
|
||||||
|
|
||||||
def test_migration_runner_is_idempotent():
|
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 "002_auth_identity_tenant_actor" in executed
|
||||||
assert "003_data_seeds_tracking" in executed
|
assert "003_data_seeds_tracking" in executed
|
||||||
assert "004_capabilities_registry" in executed
|
assert "004_capabilities_registry" in executed
|
||||||
|
assert "005_prompt_feature_config_registry" in executed
|
||||||
|
|
||||||
|
|
||||||
def test_core_table_exists():
|
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.actor.manage",
|
||||||
"kairo.context.read",
|
"kairo.context.read",
|
||||||
"kairo.entitlements.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:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute("SELECT COUNT(*) FROM capabilities")
|
cur.execute("SELECT COUNT(*) FROM capabilities")
|
||||||
assert cur.fetchone()[0] == 5
|
assert cur.fetchone()[0] == 12
|
||||||
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
|
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
|
||||||
assert cur.fetchone()[0] >= 5
|
assert cur.fetchone()[0] >= 5
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.3.0-ap0.3"
|
APP_VERSION = "0.4.0-ap0.4"
|
||||||
DB_SCHEMA_VERSION = "004"
|
DB_SCHEMA_VERSION = "005"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
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">
|
<main className="shell">
|
||||||
<header className="hero">
|
<header className="hero">
|
||||||
<h1>Jinkendo Kairo</h1>
|
<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>
|
<p className="muted">Capability Registry & Entitlements</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user