Implement OpenRouter Model Support in AI Prompt System
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
- Added `openrouter_model` field to the `ai_prompts` table, allowing for optional model overrides per prompt. - Updated the `exercise_ai` module to utilize the effective OpenRouter model based on prompt-specific settings, enhancing flexibility in AI interactions. - Enhanced the admin interface to support OpenRouter model configuration for prompts, improving usability for Superadmins. - Incremented application version to 0.8.161 and updated changelog to reflect these changes, including migration details and new functionality.
This commit is contained in:
parent
0551bb3d80
commit
93b8d09d05
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
**Ist-Stand API (Superadmin):**
|
||||
- `GET /api/admin/ai-prompts`, `GET /api/admin/ai-prompts/{id}`, `PUT …`, `POST …/preview`, `POST …/reset-template`, `GET /api/admin/ai-prompts/catalog/placeholders`
|
||||
- Spalte **`openrouter_model`** (Migration **070**): Optional pro Prompt-Zeile; OpenRouter **`model`**-Parameter; **`NULL`/leer ⇒ `OPENROUTER_MODEL`** aus der Umgebung.
|
||||
|
||||
**Autor:** Claude Code
|
||||
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ flowchart LR
|
|||
|-------|--------|
|
||||
| **P0** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI über Laufzeit. |
|
||||
| **P1 (teilweise)** | `load_and_render_ai_prompt`, `AiPromptUnavailableError`, `render_ai_prompt_template_for_row`; **Pydantic** `ExerciseFormAiPromptContext` / `resolve_exercise_form_variables` in `ai_prompt_job` (Admin-Vorschau + gemeinsame Schnittstelle). Nächster Schritt: `POST /exercises/ai/suggest` kann dasselbe Kontextmodell nutzen; optionale `execute_*`-Fassade fuer OpenRouter-Schritt. |
|
||||
| **P2** | Versionierung oder Audit-Spalten; optionale Modell-/Temperatur-Overrides pro Slug in DB oder Config-Tabelle. |
|
||||
| **P2** | Versionierung oder Audit-Spalten; **teilweise:** optionales OpenRouter-Modell pro Zeile (`openrouter_model`, Migration 070, Fallback `OPENROUTER_MODEL`); weitere Overrides (Temperatur) offen. |
|
||||
| **P3** | Composition/Segmente (JSON Schema Version 1) + UI nur für komplexe Slugs. |
|
||||
| **P4** | Erste Planungs-/Rahmen-Slugs mit dedizierten Buildern und Token-Budget-Strategien. |
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
|
|||
|
||||
OPENROUTER_API_KEY=your_api_key_here
|
||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4
|
||||
# Standard-OpenRouter-Modell (alle Aufrufe). Optional pro Prompt in ai_prompts.openrouter_model
|
||||
# ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“).
|
||||
|
||||
# Übungs-KI (Docker): ohne Eintrag im compose „environment:“ landet keine .env-Zeile im Container.
|
||||
# Hier ist SHINKAN_AI_DEBUG in docker-compose*.yml angebunden — 1 = ausführliche WARN-Logs (exercise_ai, openrouter).
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ class ExerciseFormAiPromptContext(BaseModel):
|
|||
"""
|
||||
Eingabe fuer Uebungsbezogene Prompts (Kurzfassung / Skill-JSON-Vorschlag).
|
||||
Entspricht fachlich dem Preview-Body unter /api/admin/ai-prompts/*/preview.
|
||||
|
||||
Abgrenzung: POST /exercises/ai/suggest nutzt ExerciseAiSuggestBody mit include_summary /
|
||||
include_skills usw.; dieses Modell bildet nur die gemeinsamen Formularfelder — wie die
|
||||
Admin-Vorschau-Body (AiPromptPreviewBody).
|
||||
"""
|
||||
|
||||
title: Optional[str] = ""
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
|
|||
if active_only:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT slug, display_name, template, output_format, active
|
||||
SELECT slug, display_name, template, output_format, active, openrouter_model
|
||||
FROM ai_prompts
|
||||
WHERE slug = %s AND active = true
|
||||
""",
|
||||
|
|
@ -54,7 +54,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
|
|||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT slug, display_name, template, output_format, active
|
||||
SELECT slug, display_name, template, output_format, active, openrouter_model
|
||||
FROM ai_prompts
|
||||
WHERE slug = %s
|
||||
""",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,13 @@ from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence,
|
|||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
|
||||
from openrouter_chat import (
|
||||
OpenRouterError,
|
||||
default_openrouter_model_id,
|
||||
effective_openrouter_model_for_prompt_row,
|
||||
normalize_openrouter_env,
|
||||
openrouter_chat_completion,
|
||||
)
|
||||
|
||||
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||
|
||||
|
|
@ -663,14 +669,14 @@ def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]:
|
|||
return out[:5]
|
||||
|
||||
|
||||
def _require_openrouter() -> Tuple[str, str]:
|
||||
key, model = normalize_openrouter_env()
|
||||
def _require_openrouter_key() -> str:
|
||||
key, _ = normalize_openrouter_env()
|
||||
if not key:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).",
|
||||
)
|
||||
return key, model
|
||||
return key
|
||||
|
||||
|
||||
def run_exercise_ai_suggestion(
|
||||
|
|
@ -684,7 +690,7 @@ def run_exercise_ai_suggestion(
|
|||
want_summary: bool,
|
||||
want_skills: bool,
|
||||
) -> Dict[str, Any]:
|
||||
key, model = _require_openrouter()
|
||||
key = _require_openrouter_key()
|
||||
|
||||
g_plain = strip_html_to_plain(goal)
|
||||
e_plain = strip_html_to_plain(execution)
|
||||
|
|
@ -697,7 +703,8 @@ def run_exercise_ai_suggestion(
|
|||
t_title = (title or "").strip()
|
||||
focus = (focus_area_hint or "").strip()
|
||||
|
||||
result: Dict[str, Any] = {"model": model}
|
||||
result: Dict[str, Any] = {}
|
||||
models_by_slug: Dict[str, str] = {}
|
||||
|
||||
if _ai_debug_on():
|
||||
fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context))
|
||||
|
|
@ -733,6 +740,8 @@ def run_exercise_ai_suggestion(
|
|||
status_code=503,
|
||||
detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.",
|
||||
) from None
|
||||
model_summary = effective_openrouter_model_for_prompt_row(prow)
|
||||
models_by_slug["exercise_summary"] = model_summary
|
||||
prompt = rendered.text
|
||||
if _ai_debug_on():
|
||||
_LOGGER.warning(
|
||||
|
|
@ -741,7 +750,7 @@ def run_exercise_ai_suggestion(
|
|||
len(rendered.placeholders_remaining),
|
||||
)
|
||||
try:
|
||||
raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt)
|
||||
raw = openrouter_chat_completion(api_key=key, model=model_summary, user_content=prompt)
|
||||
except OpenRouterError as e:
|
||||
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
|
||||
if _ai_debug_on():
|
||||
|
|
@ -754,7 +763,7 @@ def run_exercise_ai_suggestion(
|
|||
)
|
||||
if len(text) > _MAX_SUMMARY_CHARS:
|
||||
text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "…"
|
||||
result["summary"] = {"text": text, "ai_generated": True, "model": model}
|
||||
result["summary"] = {"text": text, "ai_generated": True, "model": model_summary}
|
||||
|
||||
if want_skills:
|
||||
try:
|
||||
|
|
@ -776,6 +785,8 @@ def run_exercise_ai_suggestion(
|
|||
status_code=503,
|
||||
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
|
||||
) from None
|
||||
model_skills = effective_openrouter_model_for_prompt_row(srow)
|
||||
models_by_slug["exercise_skill_suggestions"] = model_skills
|
||||
prompt = rendered.text
|
||||
if _ai_debug_on():
|
||||
_LOGGER.warning(
|
||||
|
|
@ -791,7 +802,7 @@ def run_exercise_ai_suggestion(
|
|||
try:
|
||||
raw = openrouter_chat_completion(
|
||||
api_key=key,
|
||||
model=model,
|
||||
model=model_skills,
|
||||
user_content=prompt,
|
||||
system_content=sys_hint,
|
||||
temperature=0.15,
|
||||
|
|
@ -829,6 +840,14 @@ def run_exercise_ai_suggestion(
|
|||
|
||||
result["skills"] = skills
|
||||
|
||||
result["models_by_slug"] = models_by_slug
|
||||
if want_skills:
|
||||
result["model"] = models_by_slug["exercise_skill_suggestions"]
|
||||
elif want_summary:
|
||||
result["model"] = models_by_slug["exercise_summary"]
|
||||
else:
|
||||
result["model"] = default_openrouter_model_id()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
|
|
|||
7
backend/migrations/070_ai_prompts_openrouter_model.sql
Normal file
7
backend/migrations/070_ai_prompts_openrouter_model.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
-- Migration 070: optionales OpenRouter-Modell pro Prompt-Zeile
|
||||
-- Leer/NULL → Umgebungsvariable OPENROUTER_MODEL (wie bisher).
|
||||
|
||||
ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS openrouter_model VARCHAR(200);
|
||||
|
||||
COMMENT ON COLUMN ai_prompts.openrouter_model IS
|
||||
'Optional: OpenRouter model id (z.B. anthropic/claude-3.5-haiku); NULL = OPENROUTER_MODEL aus Env';
|
||||
|
|
@ -6,7 +6,7 @@ from __future__ import annotations
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -203,3 +203,22 @@ def normalize_openrouter_env() -> tuple[str, str]:
|
|||
key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
|
||||
model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip()
|
||||
return key, model
|
||||
|
||||
|
||||
def default_openrouter_model_id() -> str:
|
||||
"""Standard-Modell aus OPENROUTER_MODEL (ohne API-Key zu pruefen)."""
|
||||
_, model = normalize_openrouter_env()
|
||||
return model
|
||||
|
||||
|
||||
def effective_openrouter_model_for_prompt_row(row: Optional[Mapping[str, Any]]) -> str:
|
||||
"""
|
||||
Pro-Prompt-Override in ai_prompts.openrouter_model, sonst Env-Default.
|
||||
|
||||
`row` kann eine partial Row aus load_ai_prompt_row sein (Felder slug, openrouter_model, …).
|
||||
"""
|
||||
if row:
|
||||
custom = str(row.get("openrouter_model") or "").strip()
|
||||
if custom:
|
||||
return custom
|
||||
return default_openrouter_model_id()
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ def _fetch_prompt_any(cur, prompt_id: int) -> Dict[str, Any]:
|
|||
cur.execute(
|
||||
"""
|
||||
SELECT id, slug, display_name, description, template, category, output_format,
|
||||
output_schema, is_system_default, default_template,
|
||||
output_schema, is_system_default, default_template, openrouter_model,
|
||||
active, sort_order, created_at, updated_at
|
||||
FROM ai_prompts WHERE id = %s
|
||||
""",
|
||||
|
|
@ -57,6 +57,7 @@ class AiPromptUpdateBody(BaseModel):
|
|||
active: Optional[bool] = None
|
||||
display_name: Optional[str] = Field(None, max_length=200)
|
||||
description: Optional[str] = Field(None, max_length=8000)
|
||||
openrouter_model: Optional[str] = Field(None, max_length=200)
|
||||
|
||||
|
||||
class AiPromptPreviewFocus(BaseModel):
|
||||
|
|
@ -86,7 +87,7 @@ def list_ai_prompts(session: dict = Depends(_require_superadmin)):
|
|||
cur.execute(
|
||||
"""
|
||||
SELECT id, slug, display_name, description, category, output_format, active,
|
||||
sort_order, is_system_default, default_template
|
||||
sort_order, is_system_default, default_template, openrouter_model
|
||||
FROM ai_prompts
|
||||
ORDER BY sort_order ASC NULLS LAST, id ASC
|
||||
"""
|
||||
|
|
@ -150,16 +151,25 @@ def update_ai_prompt(
|
|||
next_desc = body.description if body.description is not None else old.get("description") or ""
|
||||
next_desc = (next_desc or "").strip()
|
||||
|
||||
next_openrouter = old.get("openrouter_model")
|
||||
if body.openrouter_model is not None:
|
||||
cand = body.openrouter_model.strip() if isinstance(body.openrouter_model, str) else ""
|
||||
if any(c in cand for c in ("\r", "\n", "\t")):
|
||||
raise HTTPException(status_code=400, detail="openrouter_model: keine Steuerzeichen erlaubt.")
|
||||
next_openrouter = cand or None
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE ai_prompts
|
||||
SET template = %s, active = %s, display_name = %s, description = %s, updated_at = NOW()
|
||||
SET template = %s, active = %s, display_name = %s, description = %s,
|
||||
openrouter_model = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING id, slug, display_name, description, template, category, output_format,
|
||||
output_schema, is_system_default, default_template, active, sort_order,
|
||||
output_schema, is_system_default, default_template, openrouter_model,
|
||||
active, sort_order,
|
||||
created_at, updated_at
|
||||
""",
|
||||
(next_template, next_active, next_name, next_desc, prompt_id),
|
||||
(next_template, next_active, next_name, next_desc, next_openrouter, prompt_id),
|
||||
)
|
||||
row = dict(cur.fetchone())
|
||||
conn.commit()
|
||||
|
|
@ -188,7 +198,8 @@ def reset_ai_prompt_template(prompt_id: int, session: dict = Depends(_require_su
|
|||
SET template = default_template, updated_at = NOW()
|
||||
WHERE id = %s AND default_template IS NOT NULL
|
||||
RETURNING id, slug, display_name, description, template, category, output_format,
|
||||
output_schema, is_system_default, default_template, active, sort_order,
|
||||
output_schema, is_system_default, default_template, openrouter_model,
|
||||
active, sort_order,
|
||||
created_at, updated_at
|
||||
""",
|
||||
(prompt_id,),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.160"
|
||||
BUILD_DATE = "2026-05-30"
|
||||
DB_SCHEMA_VERSION = "20260530069"
|
||||
APP_VERSION = "0.8.161"
|
||||
BUILD_DATE = "2026-05-31"
|
||||
DB_SCHEMA_VERSION = "20260531070"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
|
|
@ -19,14 +19,14 @@ MODULE_VERSIONS = {
|
|||
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
|
||||
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
||||
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
|
||||
"admin_ai_prompts": "1.0.2", # Preview: ai_prompt_job + render_ai_prompt_template_for_row
|
||||
"admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
|
||||
"ai_prompt_job": "0.1.0", # ExerciseFormAiPromptContext, resolve_exercise_form_variables — P1 Kontext-Schnittstelle
|
||||
"ai_prompt_runtime": "0.2.0", # load_and_render_ai_prompt, AiPromptUnavailableError, render_ai_prompt_template_for_row
|
||||
"groups": "0.1.0",
|
||||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.31.2", # exercise_ai: load_and_render_ai_prompt statt getrennt load+render
|
||||
"exercises": "2.31.3", # exercise_ai: OpenRouter-Modell pro Prompt-Slug; Response models_by_slug
|
||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||
|
|
@ -41,6 +41,15 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.161",
|
||||
"date": "2026-05-31",
|
||||
"changes": [
|
||||
"Migration 070: ai_prompts.openrouter_model (optional je Prompt; Fallback OPENROUTER_MODEL).",
|
||||
"exercise_ai: effektives OpenRouter-Modell pro Slug; API-Response models_by_slug + model (Skills bevorzugt).",
|
||||
"Superadmin „KI Prompts“: OpenRouter-Modell speicherbar.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.160",
|
||||
"date": "2026-05-30",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export default function AdminAiPromptsPage() {
|
|||
const [draftName, setDraftName] = useState('')
|
||||
const [draftDesc, setDraftDesc] = useState('')
|
||||
const [draftTemplate, setDraftTemplate] = useState('')
|
||||
const [draftOpenrouterModel, setDraftOpenrouterModel] = useState('')
|
||||
const [draftActive, setDraftActive] = useState(true)
|
||||
|
||||
const [pvTitle, setPvTitle] = useState('Testübung')
|
||||
|
|
@ -74,6 +75,9 @@ export default function AdminAiPromptsPage() {
|
|||
setDraftName(d.display_name || '')
|
||||
setDraftDesc(d.description || '')
|
||||
setDraftTemplate(d.template || '')
|
||||
setDraftOpenrouterModel(
|
||||
typeof d.openrouter_model === 'string' ? d.openrouter_model : ''
|
||||
)
|
||||
setDraftActive(!!d.active)
|
||||
setPvPreview(null)
|
||||
}
|
||||
|
|
@ -96,6 +100,7 @@ export default function AdminAiPromptsPage() {
|
|||
display_name: draftName,
|
||||
description: draftDesc,
|
||||
active: draftActive,
|
||||
openrouter_model: draftOpenrouterModel.trim(),
|
||||
})
|
||||
await loadList()
|
||||
const nd = await api.getAdminAiPrompt(detail.id)
|
||||
|
|
@ -201,6 +206,11 @@ export default function AdminAiPromptsPage() {
|
|||
inaktiv
|
||||
</span>
|
||||
) : null}
|
||||
{p.openrouter_model ? (
|
||||
<span style={{ fontSize: 11, color: 'var(--text3)' }} title="OpenRouter-Modell für diesen Prompt">
|
||||
Model: <code>{p.openrouter_model}</code>
|
||||
</span>
|
||||
) : null}
|
||||
{p.is_modified ? <span style={{ fontSize: 11 }}>(von Referenz abweichend)</span> : null}
|
||||
</button>
|
||||
</li>
|
||||
|
|
@ -231,6 +241,17 @@ export default function AdminAiPromptsPage() {
|
|||
onChange={(e) => setDraftDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">OpenRouter-Modell (optional)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="Leer = Server-OPENROUTER_MODEL · z.B. anthropic/claude-3.5-haiku"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={draftOpenrouterModel}
|
||||
onChange={(e) => setDraftOpenrouterModel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<input type="checkbox" checked={draftActive} onChange={(e) => setDraftActive(e.target.checked)} />
|
||||
Aktiv
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user