Files
Business-Management/backend/app/routers/settings.py
T
curo1305 1d01cc3b0e Add per-service system prompts with AI Settings tab view
Each feature service owns its system prompt in its config JSON on the
shared volume. The AI Settings page now has General and System Prompts
tabs — admins can view and edit any service's prompts at runtime with
changes taking effect within 30 s (config cache TTL).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:11:40 +02:00

179 lines
5.8 KiB
Python

"""
Admin-only settings API for per-service runtime configuration.
All endpoints require the caller to be an admin (Depends(get_current_admin)).
Config files live on the shared app_config volume (/config/).
"""
import asyncio
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.core.app_config import (
SYSTEM_PROMPT_SERVICES,
_merge_api_key,
load_ai_service_config,
load_ai_service_config_masked,
load_all_system_prompts,
load_doc_service_config,
load_doc_service_config_masked,
save_ai_service_config,
save_doc_service_config,
save_service_system_prompts,
)
from app.core.config import settings
from app.deps import get_current_admin
from app.models.user import User
router = APIRouter()
# ── Pydantic request bodies ────────────────────────────────────────────────────
class AIProviderUpdate(BaseModel):
provider: str
anthropic_api_key: str = ""
anthropic_model: str = ""
ollama_base_url: str = ""
ollama_model: str = ""
ollama_api_key: str = ""
lmstudio_base_url: str = ""
lmstudio_model: str = ""
lmstudio_api_key: str = ""
class LimitsUpdate(BaseModel):
max_pdf_mb: int
class SystemPromptUpdate(BaseModel):
system: str
user_template: str
# ── AI settings ────────────────────────────────────────────────────────────────
@router.get("/ai")
async def get_ai_settings(
_: User = Depends(get_current_admin),
) -> dict:
return load_ai_service_config_masked()
@router.patch("/ai")
async def update_ai_settings(
body: AIProviderUpdate,
_: User = Depends(get_current_admin),
) -> dict:
valid_providers = ("anthropic", "ollama", "lmstudio")
if body.provider not in valid_providers:
raise HTTPException(status_code=422, detail=f"provider must be one of {valid_providers}")
config = load_ai_service_config()
config.provider = body.provider
# Anthropic
if body.anthropic_api_key:
config.anthropic.api_key = _merge_api_key(
body.anthropic_api_key, config.anthropic.api_key
)
if body.anthropic_model:
config.anthropic.model = body.anthropic_model
# Ollama
if body.ollama_base_url:
config.ollama.base_url = body.ollama_base_url
if body.ollama_model:
config.ollama.model = body.ollama_model
if body.ollama_api_key:
config.ollama.api_key = _merge_api_key(body.ollama_api_key, config.ollama.api_key)
# LM Studio
if body.lmstudio_base_url:
config.lmstudio.base_url = body.lmstudio_base_url
if body.lmstudio_model:
config.lmstudio.model = body.lmstudio_model
if body.lmstudio_api_key:
config.lmstudio.api_key = _merge_api_key(
body.lmstudio_api_key, config.lmstudio.api_key
)
await asyncio.to_thread(save_ai_service_config, config)
return load_ai_service_config_masked()
@router.post("/ai/test")
async def test_ai_connection(
_: User = Depends(get_current_admin),
) -> dict:
"""Proxy a minimal chat request to ai-service to verify the connection."""
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{settings.AI_SERVICE_URL}/chat",
json={
"messages": [{"role": "user", "content": "Reply with: ok"}],
"max_tokens": 16,
"temperature": 0,
},
)
if resp.status_code == 200:
data = resp.json()
return {"ok": True, "provider": data.get("provider"), "response": data.get("content")}
return {"ok": False, "error": f"ai-service returned {resp.status_code}: {resp.text[:200]}"}
except Exception as exc:
return {"ok": False, "error": str(exc)}
# ── Document limits ────────────────────────────────────────────────────────────
@router.get("/documents/limits")
async def get_documents_limits(
_: User = Depends(get_current_admin),
) -> dict:
return load_doc_service_config_masked()
@router.patch("/documents/limits")
async def update_documents_limits(
body: LimitsUpdate,
_: User = Depends(get_current_admin),
) -> dict:
if body.max_pdf_mb < 1 or body.max_pdf_mb > 200:
raise HTTPException(status_code=422, detail="max_pdf_mb must be between 1 and 200")
config = load_doc_service_config()
config.documents.max_pdf_bytes = body.max_pdf_mb * 1024 * 1024
await asyncio.to_thread(save_doc_service_config, config)
return load_doc_service_config_masked()
# ── System prompts ─────────────────────────────────────────────────────────────
@router.get("/system-prompts")
async def get_system_prompts(
_: User = Depends(get_current_admin),
) -> dict:
"""Return all editable system prompts, keyed by service id."""
return await asyncio.to_thread(load_all_system_prompts)
@router.patch("/system-prompts/{service_id}")
async def update_system_prompt(
service_id: str,
body: SystemPromptUpdate,
_: User = Depends(get_current_admin),
) -> dict:
"""Update the system prompts for a single service."""
if service_id not in SYSTEM_PROMPT_SERVICES:
raise HTTPException(status_code=404, detail=f"No system prompts registered for {service_id!r}")
await asyncio.to_thread(
save_service_system_prompts, service_id, body.system, body.user_template
)
return await asyncio.to_thread(load_all_system_prompts)