feat: migrate app_config volume to storage-service config bucket (Phase 3)
All JSON config files (AI settings, doc settings, appearance, themes) now live in the 'config' bucket of storage-service instead of a shared Docker volume. - backend/core/config_storage.py: new async HTTP helpers for config bucket r/w - backend/core/app_config.py: fully async rewrite; all load_*/save_*/seed_* functions use config_storage instead of filesystem - backend/routers/settings.py: all asyncio.to_thread() wrappers removed; direct await calls throughout; update_theme reads via load_theme_by_id() - backend/main.py: await seed_builtin_themes() directly (no to_thread) - ai-service: remove CONFIG_PATH, add STORAGE_SERVICE_URL; config_reader now fetches from storage-service via httpx - doc-service: config_reader rewritten to fetch/write via storage-service - docker-compose: remove app_config volume; add storage-service depends_on for ai-service; remove DATA_DIR and CONFIG_PATH from doc-service Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,25 @@
|
|||||||
"""
|
"""
|
||||||
Per-service runtime config helpers.
|
Per-service runtime config helpers.
|
||||||
|
|
||||||
Config files live on the shared `app_config` Docker volume at /config/.
|
All config files are stored in the 'config' bucket of the storage-service.
|
||||||
Each service has its own JSON file.
|
Every function is async — callers must await them.
|
||||||
|
|
||||||
Atomic write pattern: write to .tmp in same dir, then os.replace() so
|
Key layout in the config bucket:
|
||||||
services never read a partial file.
|
ai_service_config.json
|
||||||
|
doc_service_config.json
|
||||||
|
appearance_config.json
|
||||||
|
themes/{id}.json
|
||||||
"""
|
"""
|
||||||
import copy
|
import copy
|
||||||
import json
|
import logging
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from copy import deepcopy
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
_CONFIG_DIR = Path(os.environ.get("APP_CONFIG_DIR", "/config"))
|
from app.core import config_storage
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── AI service config schemas ──────────────────────────────────────────────────
|
# ── AI service config schemas ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -108,59 +112,50 @@ def _mask_ai_config(data: dict) -> dict:
|
|||||||
|
|
||||||
# ── Load / Save ────────────────────────────────────────────────────────────────
|
# ── Load / Save ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _config_path(service: str) -> Path:
|
async def load_service_config(service: str) -> dict:
|
||||||
return _CONFIG_DIR / f"{service}_config.json"
|
data = await config_storage.read_json(f"{service}_config.json")
|
||||||
|
if data is None:
|
||||||
|
|
||||||
def load_service_config(service: str) -> dict:
|
|
||||||
path = _config_path(service)
|
|
||||||
if not path.exists():
|
|
||||||
if service == "ai_service":
|
if service == "ai_service":
|
||||||
return AIServiceConfig().model_dump()
|
return AIServiceConfig().model_dump()
|
||||||
if service == "doc_service":
|
if service == "doc_service":
|
||||||
return DocServiceConfig().model_dump()
|
return DocServiceConfig().model_dump()
|
||||||
return {}
|
return {}
|
||||||
with path.open() as f:
|
return data
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def save_service_config(service: str, data: dict) -> None:
|
async def save_service_config(service: str, data: dict) -> None:
|
||||||
path = _config_path(service)
|
await config_storage.write_json(f"{service}_config.json", data)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = path.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(data, indent=2))
|
|
||||||
os.replace(tmp, path)
|
|
||||||
|
|
||||||
|
|
||||||
# AI service helpers
|
# AI service helpers
|
||||||
|
|
||||||
def load_ai_service_config() -> AIServiceConfig:
|
async def load_ai_service_config() -> AIServiceConfig:
|
||||||
raw = load_service_config("ai_service")
|
raw = await load_service_config("ai_service")
|
||||||
return AIServiceConfig.model_validate(raw)
|
return AIServiceConfig.model_validate(raw)
|
||||||
|
|
||||||
|
|
||||||
def save_ai_service_config(config: AIServiceConfig) -> None:
|
async def save_ai_service_config(config: AIServiceConfig) -> None:
|
||||||
save_service_config("ai_service", config.model_dump())
|
await save_service_config("ai_service", config.model_dump())
|
||||||
|
|
||||||
|
|
||||||
def load_ai_service_config_masked() -> dict:
|
async def load_ai_service_config_masked() -> dict:
|
||||||
raw = load_service_config("ai_service")
|
raw = await load_service_config("ai_service")
|
||||||
return _mask_ai_config(raw)
|
return _mask_ai_config(raw)
|
||||||
|
|
||||||
|
|
||||||
# Doc service helpers
|
# Doc service helpers
|
||||||
|
|
||||||
def load_doc_service_config() -> DocServiceConfig:
|
async def load_doc_service_config() -> DocServiceConfig:
|
||||||
raw = load_service_config("doc_service")
|
raw = await load_service_config("doc_service")
|
||||||
return DocServiceConfig.model_validate(raw)
|
return DocServiceConfig.model_validate(raw)
|
||||||
|
|
||||||
|
|
||||||
def save_doc_service_config(config: DocServiceConfig) -> None:
|
async def save_doc_service_config(config: DocServiceConfig) -> None:
|
||||||
save_service_config("doc_service", config.model_dump())
|
await save_service_config("doc_service", config.model_dump())
|
||||||
|
|
||||||
|
|
||||||
def load_doc_service_config_masked() -> dict:
|
async def load_doc_service_config_masked() -> dict:
|
||||||
return load_service_config("doc_service")
|
return await load_service_config("doc_service")
|
||||||
|
|
||||||
|
|
||||||
def _merge_api_key(new_key: str, existing_key: str) -> str:
|
def _merge_api_key(new_key: str, existing_key: str) -> str:
|
||||||
@@ -172,18 +167,16 @@ def _merge_api_key(new_key: str, existing_key: str) -> str:
|
|||||||
|
|
||||||
# ── System prompts helpers ─────────────────────────────────────────────────────
|
# ── System prompts helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Registry of all services that have editable system prompts.
|
|
||||||
# key = service identifier, value = human-readable label
|
|
||||||
SYSTEM_PROMPT_SERVICES: dict[str, str] = {
|
SYSTEM_PROMPT_SERVICES: dict[str, str] = {
|
||||||
"doc_service": "Document Service",
|
"doc_service": "Document Service",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def load_all_system_prompts() -> dict:
|
async def load_all_system_prompts() -> dict:
|
||||||
"""Return {service_id: {label, system, user_template, default_system, default_user_template}}."""
|
"""Return {service_id: {label, system, user_template, default_system, default_user_template}}."""
|
||||||
result: dict = {}
|
result: dict = {}
|
||||||
for service_id, label in SYSTEM_PROMPT_SERVICES.items():
|
for service_id, label in SYSTEM_PROMPT_SERVICES.items():
|
||||||
config = load_service_config(service_id)
|
config = await load_service_config(service_id)
|
||||||
prompts = config.get("system_prompts", {})
|
prompts = config.get("system_prompts", {})
|
||||||
defaults = _get_service_prompt_defaults(service_id)
|
defaults = _get_service_prompt_defaults(service_id)
|
||||||
result[service_id] = {
|
result[service_id] = {
|
||||||
@@ -196,15 +189,14 @@ def load_all_system_prompts() -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def save_service_system_prompts(service_id: str, system: str, user_template: str) -> None:
|
async def save_service_system_prompts(service_id: str, system: str, user_template: str) -> None:
|
||||||
"""Persist updated system prompts into the service's config file."""
|
|
||||||
if service_id not in SYSTEM_PROMPT_SERVICES:
|
if service_id not in SYSTEM_PROMPT_SERVICES:
|
||||||
raise ValueError(f"Unknown service: {service_id!r}")
|
raise ValueError(f"Unknown service: {service_id!r}")
|
||||||
config = load_service_config(service_id)
|
config = await load_service_config(service_id)
|
||||||
config.setdefault("system_prompts", {})
|
config.setdefault("system_prompts", {})
|
||||||
config["system_prompts"]["system"] = system
|
config["system_prompts"]["system"] = system
|
||||||
config["system_prompts"]["user_template"] = user_template
|
config["system_prompts"]["user_template"] = user_template
|
||||||
save_service_config(service_id, config)
|
await save_service_config(service_id, config)
|
||||||
|
|
||||||
|
|
||||||
def _get_service_prompt_defaults(service_id: str) -> dict:
|
def _get_service_prompt_defaults(service_id: str) -> dict:
|
||||||
@@ -221,26 +213,19 @@ class AppearanceConfig(BaseModel):
|
|||||||
default_mode: str = "system"
|
default_mode: str = "system"
|
||||||
|
|
||||||
|
|
||||||
def load_appearance_config() -> AppearanceConfig:
|
async def load_appearance_config() -> AppearanceConfig:
|
||||||
path = _CONFIG_DIR / "appearance_config.json"
|
data = await config_storage.read_json("appearance_config.json")
|
||||||
if not path.exists():
|
if data is None:
|
||||||
return AppearanceConfig()
|
return AppearanceConfig()
|
||||||
with path.open() as f:
|
return AppearanceConfig.model_validate(data)
|
||||||
return AppearanceConfig.model_validate(json.load(f))
|
|
||||||
|
|
||||||
|
|
||||||
def save_appearance_config(config: AppearanceConfig) -> None:
|
async def save_appearance_config(config: AppearanceConfig) -> None:
|
||||||
path = _CONFIG_DIR / "appearance_config.json"
|
await config_storage.write_json("appearance_config.json", config.model_dump())
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = path.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(config.model_dump(), indent=2))
|
|
||||||
os.replace(tmp, path)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Theme file management ──────────────────────────────────────────────────────
|
# ── Theme file management ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
_THEMES_DIR = _CONFIG_DIR / "themes"
|
|
||||||
|
|
||||||
# 9 required colour tokens per mode
|
# 9 required colour tokens per mode
|
||||||
_REQUIRED_TOKENS = frozenset({
|
_REQUIRED_TOKENS = frozenset({
|
||||||
"primary", "primary_hover", "accent", "accent_hover",
|
"primary", "primary_hover", "accent", "accent_hover",
|
||||||
@@ -361,36 +346,57 @@ _BUILTIN_THEMES: list[dict] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def seed_builtin_themes() -> None:
|
async def seed_builtin_themes() -> None:
|
||||||
"""Create /config/themes/ and write built-in theme files if missing."""
|
"""Write built-in theme files to storage-service if they are not already there."""
|
||||||
_THEMES_DIR.mkdir(parents=True, exist_ok=True)
|
existing_keys = await config_storage.list_keys(prefix="themes/")
|
||||||
|
existing_ids = {k.removeprefix("themes/").removesuffix(".json") for k in existing_keys}
|
||||||
for theme in _BUILTIN_THEMES:
|
for theme in _BUILTIN_THEMES:
|
||||||
path = _THEMES_DIR / f"{theme['id']}.json"
|
if theme["id"] not in existing_ids:
|
||||||
if not path.exists():
|
await config_storage.write_json(f"themes/{theme['id']}.json", theme)
|
||||||
path.write_text(json.dumps(theme, indent=2))
|
logger.info("Built-in themes seeded (%d themes)", len(_BUILTIN_THEMES))
|
||||||
|
|
||||||
|
|
||||||
def load_all_themes() -> list[dict]:
|
async def load_all_themes() -> list[dict]:
|
||||||
"""Return all themes from /config/themes/*.json, built-ins first."""
|
"""Return all themes from storage-service, built-ins first then custom by label."""
|
||||||
if not _THEMES_DIR.exists():
|
keys = await config_storage.list_keys(prefix="themes/")
|
||||||
seed_builtin_themes()
|
themes: list[dict] = []
|
||||||
themes = []
|
for key in keys:
|
||||||
for f in sorted(_THEMES_DIR.glob("*.json")):
|
data = await config_storage.read_json(key)
|
||||||
try:
|
if data:
|
||||||
themes.append(json.loads(f.read_text()))
|
themes.append(data)
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
pass
|
|
||||||
# Sort: built-ins first (preserving their original order), then custom by label
|
|
||||||
builtin_ids = [t["id"] for t in _BUILTIN_THEMES]
|
builtin_ids = [t["id"] for t in _BUILTIN_THEMES]
|
||||||
|
|
||||||
def sort_key(t: dict) -> tuple:
|
def sort_key(t: dict) -> tuple:
|
||||||
tid = t.get("id", "")
|
tid = t.get("id", "")
|
||||||
try:
|
try:
|
||||||
return (0, builtin_ids.index(tid))
|
return (0, builtin_ids.index(tid))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return (1, t.get("label", tid).lower())
|
return (1, t.get("label", tid).lower())
|
||||||
|
|
||||||
return sorted(themes, key=sort_key)
|
return sorted(themes, key=sort_key)
|
||||||
|
|
||||||
|
|
||||||
|
async def load_theme_by_id(theme_id: str) -> dict | None:
|
||||||
|
"""Return a single theme dict, or None if not found."""
|
||||||
|
return await config_storage.read_json(f"themes/{theme_id}.json")
|
||||||
|
|
||||||
|
|
||||||
|
async def save_theme(theme: dict) -> None:
|
||||||
|
"""Write a theme to storage-service."""
|
||||||
|
await config_storage.write_json(f"themes/{theme['id']}.json", theme)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_theme(theme_id: str) -> None:
|
||||||
|
"""Delete a custom theme. Raises ValueError for built-ins, KeyError if not found."""
|
||||||
|
data = await config_storage.read_json(f"themes/{theme_id}.json")
|
||||||
|
if data is None:
|
||||||
|
raise FileNotFoundError(theme_id)
|
||||||
|
if data.get("builtin"):
|
||||||
|
raise ValueError("Cannot delete a built-in theme")
|
||||||
|
await config_storage.delete_key(f"themes/{theme_id}.json")
|
||||||
|
|
||||||
|
|
||||||
def validate_theme_tokens(colors: dict) -> list[str]:
|
def validate_theme_tokens(colors: dict) -> list[str]:
|
||||||
"""Return a list of validation error messages, empty if valid."""
|
"""Return a list of validation error messages, empty if valid."""
|
||||||
errors = []
|
errors = []
|
||||||
@@ -401,23 +407,3 @@ def validate_theme_tokens(colors: dict) -> list[str]:
|
|||||||
if key in _REQUIRED_TOKENS and not _RGB_RE.match(str(val)):
|
if key in _REQUIRED_TOKENS and not _RGB_RE.match(str(val)):
|
||||||
errors.append(f"Token '{key}' must be an RGB triplet like '37 99 235', got: {val!r}")
|
errors.append(f"Token '{key}' must be an RGB triplet like '37 99 235', got: {val!r}")
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def save_theme(theme: dict) -> None:
|
|
||||||
"""Write a theme file atomically."""
|
|
||||||
_THEMES_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
path = _THEMES_DIR / f"{theme['id']}.json"
|
|
||||||
tmp = path.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(theme, indent=2))
|
|
||||||
os.replace(tmp, path)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_theme(theme_id: str) -> None:
|
|
||||||
"""Delete a custom theme file. Raises ValueError for built-ins, FileNotFoundError if missing."""
|
|
||||||
path = _THEMES_DIR / f"{theme_id}.json"
|
|
||||||
if not path.exists():
|
|
||||||
raise FileNotFoundError(theme_id)
|
|
||||||
data = json.loads(path.read_text())
|
|
||||||
if data.get("builtin"):
|
|
||||||
raise ValueError("Cannot delete a built-in theme")
|
|
||||||
path.unlink()
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Async HTTP client for the 'config' bucket in storage-service.
|
||||||
|
|
||||||
|
All JSON config files (AI settings, doc settings, appearance, themes, …) are stored
|
||||||
|
in the 'config' bucket under the storage-service. This module provides thin
|
||||||
|
async helpers so app_config.py does not depend on the filesystem at all.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BUCKET = "config"
|
||||||
|
_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def _url(key: str) -> str:
|
||||||
|
return f"{settings.STORAGE_SERVICE_URL}/objects/{_BUCKET}/{key}"
|
||||||
|
|
||||||
|
|
||||||
|
async def read_json(key: str) -> dict | None:
|
||||||
|
"""Return parsed JSON from the config bucket, or None if the key does not exist."""
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
resp = await client.get(_url(key))
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return None
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def write_json(key: str, data: dict) -> None:
|
||||||
|
"""Serialise *data* to JSON and PUT it into the config bucket."""
|
||||||
|
payload = json.dumps(data, indent=2).encode()
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
resp = await client.put(
|
||||||
|
_url(key),
|
||||||
|
content=payload,
|
||||||
|
headers={"Content-Type": "application/octet-stream"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_key(key: str) -> None:
|
||||||
|
"""Delete a key from the config bucket. No-op if it does not exist."""
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
resp = await client.delete(_url(key))
|
||||||
|
if resp.status_code not in (204, 404):
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_keys(prefix: str = "") -> list[str]:
|
||||||
|
"""List all keys in the config bucket, optionally filtered by *prefix*."""
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
resp = await client.get(f"{settings.STORAGE_SERVICE_URL}/objects/{_BUCKET}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
keys: list[str] = resp.json().get("keys", [])
|
||||||
|
if prefix:
|
||||||
|
keys = [k for k in keys if k.startswith(prefix)]
|
||||||
|
return keys
|
||||||
+1
-1
@@ -15,7 +15,7 @@ from app.services.service_health import check_all, health_check_loop, register_s
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await asyncio.to_thread(seed_builtin_themes)
|
await seed_builtin_themes()
|
||||||
register_services(
|
register_services(
|
||||||
doc_service_url=settings.DOC_SERVICE_URL,
|
doc_service_url=settings.DOC_SERVICE_URL,
|
||||||
ai_service_url=settings.AI_SERVICE_URL,
|
ai_service_url=settings.AI_SERVICE_URL,
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
Admin-only settings API for per-service runtime configuration.
|
Admin-only settings API for per-service runtime configuration.
|
||||||
|
|
||||||
All endpoints require the caller to be an admin (Depends(get_current_admin)).
|
All endpoints require the caller to be an admin (Depends(get_current_admin)).
|
||||||
Config files live on the shared app_config volume (/config/).
|
Config files are stored in the 'config' bucket of storage-service.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import re as _re
|
||||||
import json
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
@@ -21,10 +20,11 @@ from app.core.app_config import (
|
|||||||
load_all_system_prompts,
|
load_all_system_prompts,
|
||||||
load_all_themes,
|
load_all_themes,
|
||||||
load_appearance_config,
|
load_appearance_config,
|
||||||
save_appearance_config,
|
|
||||||
load_doc_service_config,
|
load_doc_service_config,
|
||||||
load_doc_service_config_masked,
|
load_doc_service_config_masked,
|
||||||
|
load_theme_by_id,
|
||||||
save_ai_service_config,
|
save_ai_service_config,
|
||||||
|
save_appearance_config,
|
||||||
save_doc_service_config,
|
save_doc_service_config,
|
||||||
save_service_system_prompts,
|
save_service_system_prompts,
|
||||||
save_theme,
|
save_theme,
|
||||||
@@ -36,6 +36,8 @@ from app.models.user import User
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
_THEME_ID_RE = _re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||||
|
|
||||||
|
|
||||||
# ── Pydantic request bodies ────────────────────────────────────────────────────
|
# ── Pydantic request bodies ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -98,7 +100,7 @@ class ThemeUpdate(BaseModel):
|
|||||||
async def get_ai_settings(
|
async def get_ai_settings(
|
||||||
_: User = Depends(get_service_admin("ai-service")),
|
_: User = Depends(get_service_admin("ai-service")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return load_ai_service_config_masked()
|
return await load_ai_service_config_masked()
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/ai")
|
@router.patch("/ai")
|
||||||
@@ -110,7 +112,7 @@ async def update_ai_settings(
|
|||||||
if body.provider not in valid_providers:
|
if body.provider not in valid_providers:
|
||||||
raise HTTPException(status_code=422, detail=f"provider must be one of {valid_providers}")
|
raise HTTPException(status_code=422, detail=f"provider must be one of {valid_providers}")
|
||||||
|
|
||||||
config = load_ai_service_config()
|
config = await load_ai_service_config()
|
||||||
config.provider = body.provider
|
config.provider = body.provider
|
||||||
|
|
||||||
# Anthropic
|
# Anthropic
|
||||||
@@ -139,8 +141,8 @@ async def update_ai_settings(
|
|||||||
body.lmstudio_api_key, config.lmstudio.api_key
|
body.lmstudio_api_key, config.lmstudio.api_key
|
||||||
)
|
)
|
||||||
|
|
||||||
await asyncio.to_thread(save_ai_service_config, config)
|
await save_ai_service_config(config)
|
||||||
return load_ai_service_config_masked()
|
return await load_ai_service_config_masked()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ai/test")
|
@router.post("/ai/test")
|
||||||
@@ -173,7 +175,7 @@ async def test_ai_connection(
|
|||||||
async def get_documents_limits(
|
async def get_documents_limits(
|
||||||
_: User = Depends(get_service_admin("doc-service")),
|
_: User = Depends(get_service_admin("doc-service")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return load_doc_service_config_masked()
|
return await load_doc_service_config_masked()
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/documents/limits")
|
@router.patch("/documents/limits")
|
||||||
@@ -184,10 +186,10 @@ async def update_documents_limits(
|
|||||||
if body.max_pdf_mb < 1 or body.max_pdf_mb > 200:
|
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")
|
raise HTTPException(status_code=422, detail="max_pdf_mb must be between 1 and 200")
|
||||||
|
|
||||||
config = load_doc_service_config()
|
config = await load_doc_service_config()
|
||||||
config.documents.max_pdf_bytes = body.max_pdf_mb * 1024 * 1024
|
config.documents.max_pdf_bytes = body.max_pdf_mb * 1024 * 1024
|
||||||
await asyncio.to_thread(save_doc_service_config, config)
|
await save_doc_service_config(config)
|
||||||
return load_doc_service_config_masked()
|
return await load_doc_service_config_masked()
|
||||||
|
|
||||||
|
|
||||||
# ── System prompts ─────────────────────────────────────────────────────────────
|
# ── System prompts ─────────────────────────────────────────────────────────────
|
||||||
@@ -197,8 +199,7 @@ async def update_documents_limits(
|
|||||||
async def get_system_prompts(
|
async def get_system_prompts(
|
||||||
_: User = Depends(get_service_admin("ai-service")),
|
_: User = Depends(get_service_admin("ai-service")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return all editable system prompts, keyed by service id."""
|
return await load_all_system_prompts()
|
||||||
return await asyncio.to_thread(load_all_system_prompts)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/system-prompts/{service_id}")
|
@router.patch("/system-prompts/{service_id}")
|
||||||
@@ -207,26 +208,20 @@ async def update_system_prompt(
|
|||||||
body: SystemPromptUpdate,
|
body: SystemPromptUpdate,
|
||||||
_: User = Depends(get_service_admin("ai-service")),
|
_: User = Depends(get_service_admin("ai-service")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update the system prompts for a single service."""
|
|
||||||
if service_id not in SYSTEM_PROMPT_SERVICES:
|
if service_id not in SYSTEM_PROMPT_SERVICES:
|
||||||
raise HTTPException(status_code=404, detail=f"No system prompts registered for {service_id!r}")
|
raise HTTPException(status_code=404, detail=f"No system prompts registered for {service_id!r}")
|
||||||
await asyncio.to_thread(
|
await save_service_system_prompts(service_id, body.system, body.user_template)
|
||||||
save_service_system_prompts, service_id, body.system, body.user_template
|
return await load_all_system_prompts()
|
||||||
)
|
|
||||||
return await asyncio.to_thread(load_all_system_prompts)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Appearance (global default — auth read, admin write) ───────────────────────
|
# ── Appearance (global default — auth read, admin write) ───────────────────────
|
||||||
|
|
||||||
import re as _re
|
|
||||||
_THEME_ID_RE = _re.compile(r"^[a-z0-9_-]{1,64}$")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/appearance")
|
@router.get("/appearance")
|
||||||
async def get_appearance(
|
async def get_appearance(
|
||||||
_: User = Depends(get_current_user),
|
_: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
config = await asyncio.to_thread(load_appearance_config)
|
config = await load_appearance_config()
|
||||||
return config.model_dump()
|
return config.model_dump()
|
||||||
|
|
||||||
|
|
||||||
@@ -237,12 +232,12 @@ async def update_appearance(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
if body.default_mode not in ("light", "dark", "system"):
|
if body.default_mode not in ("light", "dark", "system"):
|
||||||
raise HTTPException(status_code=422, detail="default_mode must be 'light', 'dark', or 'system'")
|
raise HTTPException(status_code=422, detail="default_mode must be 'light', 'dark', or 'system'")
|
||||||
themes = await asyncio.to_thread(load_all_themes)
|
themes = await load_all_themes()
|
||||||
theme_ids = {t["id"] for t in themes}
|
theme_ids = {t["id"] for t in themes}
|
||||||
if body.theme not in theme_ids:
|
if body.theme not in theme_ids:
|
||||||
raise HTTPException(status_code=422, detail=f"Unknown theme: {body.theme!r}")
|
raise HTTPException(status_code=422, detail=f"Unknown theme: {body.theme!r}")
|
||||||
config = AppearanceConfig(theme=body.theme, default_mode=body.default_mode)
|
config = AppearanceConfig(theme=body.theme, default_mode=body.default_mode)
|
||||||
await asyncio.to_thread(save_appearance_config, config)
|
await save_appearance_config(config)
|
||||||
return config.model_dump()
|
return config.model_dump()
|
||||||
|
|
||||||
|
|
||||||
@@ -253,7 +248,7 @@ async def update_appearance(
|
|||||||
async def list_themes(
|
async def list_themes(
|
||||||
_: User = Depends(get_current_user),
|
_: User = Depends(get_current_user),
|
||||||
) -> list:
|
) -> list:
|
||||||
return await asyncio.to_thread(load_all_themes)
|
return await load_all_themes()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/themes", status_code=201)
|
@router.post("/themes", status_code=201)
|
||||||
@@ -263,7 +258,7 @@ async def create_theme(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
if not _THEME_ID_RE.match(body.id):
|
if not _THEME_ID_RE.match(body.id):
|
||||||
raise HTTPException(status_code=422, detail="Theme ID must match [a-z0-9_-]{1,64}")
|
raise HTTPException(status_code=422, detail="Theme ID must match [a-z0-9_-]{1,64}")
|
||||||
existing = {t["id"] for t in await asyncio.to_thread(load_all_themes)}
|
existing = {t["id"] for t in await load_all_themes()}
|
||||||
if body.id in existing:
|
if body.id in existing:
|
||||||
raise HTTPException(status_code=400, detail=f"Theme ID already in use: {body.id!r}")
|
raise HTTPException(status_code=400, detail=f"Theme ID already in use: {body.id!r}")
|
||||||
light = body.light.model_dump()
|
light = body.light.model_dump()
|
||||||
@@ -273,7 +268,7 @@ async def create_theme(
|
|||||||
if errors:
|
if errors:
|
||||||
raise HTTPException(status_code=422, detail=f"{mode}: {'; '.join(errors)}")
|
raise HTTPException(status_code=422, detail=f"{mode}: {'; '.join(errors)}")
|
||||||
theme = {"id": body.id, "label": body.label, "builtin": False, "light": light, "dark": dark}
|
theme = {"id": body.id, "label": body.label, "builtin": False, "light": light, "dark": dark}
|
||||||
await asyncio.to_thread(save_theme, theme)
|
await save_theme(theme)
|
||||||
return theme
|
return theme
|
||||||
|
|
||||||
|
|
||||||
@@ -283,11 +278,9 @@ async def update_theme(
|
|||||||
body: ThemeUpdate,
|
body: ThemeUpdate,
|
||||||
_: User = Depends(get_current_admin),
|
_: User = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
from app.core.app_config import _THEMES_DIR
|
theme = await load_theme_by_id(theme_id)
|
||||||
path = _THEMES_DIR / f"{theme_id}.json"
|
if theme is None:
|
||||||
if not path.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="Theme not found")
|
raise HTTPException(status_code=404, detail="Theme not found")
|
||||||
theme = json.loads(path.read_text())
|
|
||||||
if theme.get("builtin"):
|
if theme.get("builtin"):
|
||||||
raise HTTPException(status_code=400, detail="Cannot edit a built-in theme")
|
raise HTTPException(status_code=400, detail="Cannot edit a built-in theme")
|
||||||
if body.label is not None:
|
if body.label is not None:
|
||||||
@@ -304,7 +297,7 @@ async def update_theme(
|
|||||||
if errors:
|
if errors:
|
||||||
raise HTTPException(status_code=422, detail=f"dark: {'; '.join(errors)}")
|
raise HTTPException(status_code=422, detail=f"dark: {'; '.join(errors)}")
|
||||||
theme["dark"] = dark
|
theme["dark"] = dark
|
||||||
await asyncio.to_thread(save_theme, theme)
|
await save_theme(theme)
|
||||||
return theme
|
return theme
|
||||||
|
|
||||||
|
|
||||||
@@ -314,7 +307,7 @@ async def remove_theme(
|
|||||||
_: User = Depends(get_current_admin),
|
_: User = Depends(get_current_admin),
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(delete_theme, theme_id)
|
await delete_theme(theme_id)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=404, detail="Theme not found")
|
raise HTTPException(status_code=404, detail="Theme not found")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
+5
-12
@@ -54,8 +54,6 @@ services:
|
|||||||
DOC_SERVICE_URL: http://doc-service:8001
|
DOC_SERVICE_URL: http://doc-service:8001
|
||||||
AI_SERVICE_URL: http://ai-service:8010
|
AI_SERVICE_URL: http://ai-service:8010
|
||||||
STORAGE_SERVICE_URL: http://storage-service:8020
|
STORAGE_SERVICE_URL: http://storage-service:8020
|
||||||
volumes:
|
|
||||||
- app_config:/config
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -73,9 +71,10 @@ services:
|
|||||||
user: "1001:1001"
|
user: "1001:1001"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
CONFIG_PATH: /config/ai_service_config.json
|
STORAGE_SERVICE_URL: http://storage-service:8020
|
||||||
volumes:
|
depends_on:
|
||||||
- app_config:/config
|
storage-service:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- backend-net
|
- backend-net
|
||||||
|
|
||||||
@@ -89,14 +88,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-destroying_sap}
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-destroying_sap}
|
||||||
DATA_DIR: /data/documents
|
|
||||||
CONFIG_PATH: /config/doc_service_config.json
|
|
||||||
AI_SERVICE_URL: http://ai-service:8010
|
AI_SERVICE_URL: http://ai-service:8010
|
||||||
STORAGE_SERVICE_URL: http://storage-service:8020
|
STORAGE_SERVICE_URL: http://storage-service:8020
|
||||||
volumes:
|
volumes:
|
||||||
- doc_data:/data/documents
|
|
||||||
- watch_data:/data/watch
|
- watch_data:/data/watch
|
||||||
- app_config:/config
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -125,10 +120,8 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
storage_data: # All file/blob storage — managed by storage-service
|
storage_data: # All file/blob storage — managed by storage-service (documents + config)
|
||||||
doc_data: # PDF files persisted across restarts (to be removed after Phase 2 migration)
|
|
||||||
watch_data: # Watch directory — bind-mount your NAS/Nextcloud here via docker-compose.override.yml
|
watch_data: # Watch directory — bind-mount your NAS/Nextcloud here via docker-compose.override.yml
|
||||||
app_config: # Per-service runtime config JSON files (to be removed after Phase 3 migration)
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
# backend-net: db ↔ backend ↔ doc-service. No host ports bound.
|
# backend-net: db ↔ backend ↔ doc-service. No host ports bound.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings
|
|||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
PROJECT_NAME: str = "ai-service"
|
PROJECT_NAME: str = "ai-service"
|
||||||
CONFIG_PATH: str = "/config/ai_service_config.json"
|
STORAGE_SERVICE_URL: str = "http://storage-service:8020"
|
||||||
|
|
||||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Reads ai_service_config.json from the shared config volume.
|
Reads ai_service_config.json from the storage-service config bucket.
|
||||||
30-second TTL cache + env var overrides (dev credentials stay out of git).
|
30-second TTL cache + env var overrides (dev credentials stay out of git).
|
||||||
|
|
||||||
Env var overrides (all optional):
|
Env var overrides (all optional):
|
||||||
@@ -8,15 +8,17 @@ Env var overrides (all optional):
|
|||||||
OLLAMA_BASE_URL, OLLAMA_MODEL, OLLAMA_API_KEY
|
OLLAMA_BASE_URL, OLLAMA_MODEL, OLLAMA_API_KEY
|
||||||
ANTHROPIC_API_KEY, ANTHROPIC_MODEL
|
ANTHROPIC_API_KEY, ANTHROPIC_MODEL
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_CONFIG_KEY = "ai_service_config.json"
|
||||||
|
|
||||||
_DEFAULT_CONFIG: dict = {
|
_DEFAULT_CONFIG: dict = {
|
||||||
"provider": "lmstudio",
|
"provider": "lmstudio",
|
||||||
"timeout_seconds": 60,
|
"timeout_seconds": 60,
|
||||||
@@ -31,12 +33,18 @@ _cache_at: float = 0.0
|
|||||||
_CACHE_TTL = 30.0
|
_CACHE_TTL = 30.0
|
||||||
|
|
||||||
|
|
||||||
def _read_config_sync() -> dict:
|
def _storage_url() -> str:
|
||||||
path = Path(settings.CONFIG_PATH)
|
return f"{settings.STORAGE_SERVICE_URL}/objects/config/{_CONFIG_KEY}"
|
||||||
if not path.exists():
|
|
||||||
return _apply_env_overrides(deepcopy(_DEFAULT_CONFIG))
|
|
||||||
with open(path) as f:
|
async def _fetch_config() -> dict:
|
||||||
return _apply_env_overrides(json.load(f))
|
"""Fetch config from storage-service. Returns defaults if not found."""
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(_storage_url())
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return deepcopy(_DEFAULT_CONFIG)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
def _apply_env_overrides(config: dict) -> dict:
|
def _apply_env_overrides(config: dict) -> dict:
|
||||||
@@ -75,7 +83,8 @@ async def load_ai_config() -> dict:
|
|||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if _cache is not None and (now - _cache_at) < _CACHE_TTL:
|
if _cache is not None and (now - _cache_at) < _CACHE_TTL:
|
||||||
return _cache
|
return _cache
|
||||||
data = await asyncio.to_thread(_read_config_sync)
|
raw = await _fetch_config()
|
||||||
|
data = _apply_env_overrides(raw)
|
||||||
_cache = data
|
_cache = data
|
||||||
_cache_at = now
|
_cache_at = now
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
Reads doc_service_config.json from the shared config volume.
|
Reads doc_service_config.json from the storage-service config bucket.
|
||||||
30-second TTL cache + env var overrides.
|
30-second TTL cache + env var overrides.
|
||||||
|
|
||||||
Env var overrides (all optional):
|
Env var overrides (all optional):
|
||||||
DOC_MAX_PDF_MB — max upload size in megabytes (e.g. "50")
|
DOC_MAX_PDF_MB — max upload size in megabytes (e.g. "50")
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_CONFIG_KEY = "doc_service_config.json"
|
||||||
|
|
||||||
_DEFAULT_STORAGE_CONFIG: dict = {
|
_DEFAULT_STORAGE_CONFIG: dict = {
|
||||||
"watch_enabled": False,
|
"watch_enabled": False,
|
||||||
"watch_path": "/data/watch",
|
"watch_path": "/data/watch",
|
||||||
@@ -63,33 +64,30 @@ _cache_at: float = 0.0
|
|||||||
_CACHE_TTL = 30.0
|
_CACHE_TTL = 30.0
|
||||||
|
|
||||||
|
|
||||||
def _read_config_sync() -> dict:
|
def _storage_url() -> str:
|
||||||
path = Path(settings.CONFIG_PATH)
|
return f"{settings.STORAGE_SERVICE_URL}/objects/config/{_CONFIG_KEY}"
|
||||||
if not path.exists():
|
|
||||||
base = deepcopy(_DEFAULT_CONFIG)
|
|
||||||
else:
|
|
||||||
with open(path) as f:
|
|
||||||
base = json.load(f)
|
|
||||||
return _apply_env_overrides(base)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_config_sync_raw() -> dict:
|
async def _fetch_config() -> dict:
|
||||||
"""Read without env overrides — used when we need to write back to disk."""
|
"""Fetch config from storage-service. Returns defaults if not found."""
|
||||||
path = Path(settings.CONFIG_PATH)
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
if not path.exists():
|
resp = await client.get(_storage_url())
|
||||||
return deepcopy(_DEFAULT_CONFIG)
|
if resp.status_code == 404:
|
||||||
with open(path) as f:
|
return deepcopy(_DEFAULT_CONFIG)
|
||||||
return json.load(f)
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
def _write_config_sync(config: dict) -> None:
|
async def _write_config(data: dict) -> None:
|
||||||
"""Atomically write config JSON to disk."""
|
import json
|
||||||
path = Path(settings.CONFIG_PATH)
|
payload = json.dumps(data, indent=2).encode()
|
||||||
tmp = path.with_suffix(".tmp")
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
tmp.parent.mkdir(parents=True, exist_ok=True)
|
resp = await client.put(
|
||||||
with open(tmp, "w") as f:
|
_storage_url(),
|
||||||
json.dump(config, f, indent=2)
|
content=payload,
|
||||||
os.replace(tmp, path)
|
headers={"Content-Type": "application/octet-stream"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
def _apply_env_overrides(config: dict) -> dict:
|
def _apply_env_overrides(config: dict) -> dict:
|
||||||
@@ -108,7 +106,8 @@ async def load_doc_config() -> dict:
|
|||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if _cache is not None and (now - _cache_at) < _CACHE_TTL:
|
if _cache is not None and (now - _cache_at) < _CACHE_TTL:
|
||||||
return _cache
|
return _cache
|
||||||
data = await asyncio.to_thread(_read_config_sync)
|
raw = await _fetch_config()
|
||||||
|
data = _apply_env_overrides(raw)
|
||||||
_cache = data
|
_cache = data
|
||||||
_cache_at = now
|
_cache_at = now
|
||||||
return data
|
return data
|
||||||
@@ -123,11 +122,10 @@ async def get_storage_config() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def save_storage_config(data: dict) -> None:
|
async def save_storage_config(data: dict) -> None:
|
||||||
"""Merge data into the storage config block and persist to disk."""
|
"""Merge data into the storage config block and persist to storage-service."""
|
||||||
global _cache, _cache_at
|
global _cache, _cache_at
|
||||||
raw = await asyncio.to_thread(_read_config_sync_raw)
|
raw = await _fetch_config()
|
||||||
raw.setdefault("storage", {}).update(data)
|
raw.setdefault("storage", {}).update(data)
|
||||||
await asyncio.to_thread(_write_config_sync, raw)
|
await _write_config(raw)
|
||||||
# Invalidate cache so next read picks up the new values
|
|
||||||
_cache = None
|
_cache = None
|
||||||
_cache_at = 0.0
|
_cache_at = 0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user