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.
|
||||
|
||||
Config files live on the shared `app_config` Docker volume at /config/.
|
||||
Each service has its own JSON file.
|
||||
All config files are stored in the 'config' bucket of the storage-service.
|
||||
Every function is async — callers must await them.
|
||||
|
||||
Atomic write pattern: write to .tmp in same dir, then os.replace() so
|
||||
services never read a partial file.
|
||||
Key layout in the config bucket:
|
||||
ai_service_config.json
|
||||
doc_service_config.json
|
||||
appearance_config.json
|
||||
themes/{id}.json
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from copy import deepcopy
|
||||
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -108,59 +112,50 @@ def _mask_ai_config(data: dict) -> dict:
|
||||
|
||||
# ── Load / Save ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _config_path(service: str) -> Path:
|
||||
return _CONFIG_DIR / f"{service}_config.json"
|
||||
|
||||
|
||||
def load_service_config(service: str) -> dict:
|
||||
path = _config_path(service)
|
||||
if not path.exists():
|
||||
async def load_service_config(service: str) -> dict:
|
||||
data = await config_storage.read_json(f"{service}_config.json")
|
||||
if data is None:
|
||||
if service == "ai_service":
|
||||
return AIServiceConfig().model_dump()
|
||||
if service == "doc_service":
|
||||
return DocServiceConfig().model_dump()
|
||||
return {}
|
||||
with path.open() as f:
|
||||
return json.load(f)
|
||||
return data
|
||||
|
||||
|
||||
def save_service_config(service: str, data: dict) -> None:
|
||||
path = _config_path(service)
|
||||
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)
|
||||
async def save_service_config(service: str, data: dict) -> None:
|
||||
await config_storage.write_json(f"{service}_config.json", data)
|
||||
|
||||
|
||||
# AI service helpers
|
||||
|
||||
def load_ai_service_config() -> AIServiceConfig:
|
||||
raw = load_service_config("ai_service")
|
||||
async def load_ai_service_config() -> AIServiceConfig:
|
||||
raw = await load_service_config("ai_service")
|
||||
return AIServiceConfig.model_validate(raw)
|
||||
|
||||
|
||||
def save_ai_service_config(config: AIServiceConfig) -> None:
|
||||
save_service_config("ai_service", config.model_dump())
|
||||
async def save_ai_service_config(config: AIServiceConfig) -> None:
|
||||
await save_service_config("ai_service", config.model_dump())
|
||||
|
||||
|
||||
def load_ai_service_config_masked() -> dict:
|
||||
raw = load_service_config("ai_service")
|
||||
async def load_ai_service_config_masked() -> dict:
|
||||
raw = await load_service_config("ai_service")
|
||||
return _mask_ai_config(raw)
|
||||
|
||||
|
||||
# Doc service helpers
|
||||
|
||||
def load_doc_service_config() -> DocServiceConfig:
|
||||
raw = load_service_config("doc_service")
|
||||
async def load_doc_service_config() -> DocServiceConfig:
|
||||
raw = await load_service_config("doc_service")
|
||||
return DocServiceConfig.model_validate(raw)
|
||||
|
||||
|
||||
def save_doc_service_config(config: DocServiceConfig) -> None:
|
||||
save_service_config("doc_service", config.model_dump())
|
||||
async def save_doc_service_config(config: DocServiceConfig) -> None:
|
||||
await save_service_config("doc_service", config.model_dump())
|
||||
|
||||
|
||||
def load_doc_service_config_masked() -> dict:
|
||||
return load_service_config("doc_service")
|
||||
async def load_doc_service_config_masked() -> dict:
|
||||
return await load_service_config("doc_service")
|
||||
|
||||
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
# Registry of all services that have editable system prompts.
|
||||
# key = service identifier, value = human-readable label
|
||||
SYSTEM_PROMPT_SERVICES: dict[str, str] = {
|
||||
"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}}."""
|
||||
result: dict = {}
|
||||
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", {})
|
||||
defaults = _get_service_prompt_defaults(service_id)
|
||||
result[service_id] = {
|
||||
@@ -196,15 +189,14 @@ def load_all_system_prompts() -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def save_service_system_prompts(service_id: str, system: str, user_template: str) -> None:
|
||||
"""Persist updated system prompts into the service's config file."""
|
||||
async def save_service_system_prompts(service_id: str, system: str, user_template: str) -> None:
|
||||
if service_id not in SYSTEM_PROMPT_SERVICES:
|
||||
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["system_prompts"]["system"] = system
|
||||
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:
|
||||
@@ -221,26 +213,19 @@ class AppearanceConfig(BaseModel):
|
||||
default_mode: str = "system"
|
||||
|
||||
|
||||
def load_appearance_config() -> AppearanceConfig:
|
||||
path = _CONFIG_DIR / "appearance_config.json"
|
||||
if not path.exists():
|
||||
async def load_appearance_config() -> AppearanceConfig:
|
||||
data = await config_storage.read_json("appearance_config.json")
|
||||
if data is None:
|
||||
return AppearanceConfig()
|
||||
with path.open() as f:
|
||||
return AppearanceConfig.model_validate(json.load(f))
|
||||
return AppearanceConfig.model_validate(data)
|
||||
|
||||
|
||||
def save_appearance_config(config: AppearanceConfig) -> None:
|
||||
path = _CONFIG_DIR / "appearance_config.json"
|
||||
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)
|
||||
async def save_appearance_config(config: AppearanceConfig) -> None:
|
||||
await config_storage.write_json("appearance_config.json", config.model_dump())
|
||||
|
||||
|
||||
# ── Theme file management ──────────────────────────────────────────────────────
|
||||
|
||||
_THEMES_DIR = _CONFIG_DIR / "themes"
|
||||
|
||||
# 9 required colour tokens per mode
|
||||
_REQUIRED_TOKENS = frozenset({
|
||||
"primary", "primary_hover", "accent", "accent_hover",
|
||||
@@ -361,36 +346,57 @@ _BUILTIN_THEMES: list[dict] = [
|
||||
]
|
||||
|
||||
|
||||
def seed_builtin_themes() -> None:
|
||||
"""Create /config/themes/ and write built-in theme files if missing."""
|
||||
_THEMES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
async def seed_builtin_themes() -> None:
|
||||
"""Write built-in theme files to storage-service if they are not already there."""
|
||||
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:
|
||||
path = _THEMES_DIR / f"{theme['id']}.json"
|
||||
if not path.exists():
|
||||
path.write_text(json.dumps(theme, indent=2))
|
||||
if theme["id"] not in existing_ids:
|
||||
await config_storage.write_json(f"themes/{theme['id']}.json", theme)
|
||||
logger.info("Built-in themes seeded (%d themes)", len(_BUILTIN_THEMES))
|
||||
|
||||
|
||||
def load_all_themes() -> list[dict]:
|
||||
"""Return all themes from /config/themes/*.json, built-ins first."""
|
||||
if not _THEMES_DIR.exists():
|
||||
seed_builtin_themes()
|
||||
themes = []
|
||||
for f in sorted(_THEMES_DIR.glob("*.json")):
|
||||
try:
|
||||
themes.append(json.loads(f.read_text()))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
# Sort: built-ins first (preserving their original order), then custom by label
|
||||
async def load_all_themes() -> list[dict]:
|
||||
"""Return all themes from storage-service, built-ins first then custom by label."""
|
||||
keys = await config_storage.list_keys(prefix="themes/")
|
||||
themes: list[dict] = []
|
||||
for key in keys:
|
||||
data = await config_storage.read_json(key)
|
||||
if data:
|
||||
themes.append(data)
|
||||
|
||||
builtin_ids = [t["id"] for t in _BUILTIN_THEMES]
|
||||
|
||||
def sort_key(t: dict) -> tuple:
|
||||
tid = t.get("id", "")
|
||||
try:
|
||||
return (0, builtin_ids.index(tid))
|
||||
except ValueError:
|
||||
return (1, t.get("label", tid).lower())
|
||||
|
||||
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]:
|
||||
"""Return a list of validation error messages, empty if valid."""
|
||||
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)):
|
||||
errors.append(f"Token '{key}' must be an RGB triplet like '37 99 235', got: {val!r}")
|
||||
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
|
||||
async def lifespan(app: FastAPI):
|
||||
await asyncio.to_thread(seed_builtin_themes)
|
||||
await seed_builtin_themes()
|
||||
register_services(
|
||||
doc_service_url=settings.DOC_SERVICE_URL,
|
||||
ai_service_url=settings.AI_SERVICE_URL,
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
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/).
|
||||
Config files are stored in the 'config' bucket of storage-service.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import re as _re
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -21,10 +20,11 @@ from app.core.app_config import (
|
||||
load_all_system_prompts,
|
||||
load_all_themes,
|
||||
load_appearance_config,
|
||||
save_appearance_config,
|
||||
load_doc_service_config,
|
||||
load_doc_service_config_masked,
|
||||
load_theme_by_id,
|
||||
save_ai_service_config,
|
||||
save_appearance_config,
|
||||
save_doc_service_config,
|
||||
save_service_system_prompts,
|
||||
save_theme,
|
||||
@@ -36,6 +36,8 @@ from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_THEME_ID_RE = _re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
|
||||
|
||||
# ── Pydantic request bodies ────────────────────────────────────────────────────
|
||||
|
||||
@@ -98,7 +100,7 @@ class ThemeUpdate(BaseModel):
|
||||
async def get_ai_settings(
|
||||
_: User = Depends(get_service_admin("ai-service")),
|
||||
) -> dict:
|
||||
return load_ai_service_config_masked()
|
||||
return await load_ai_service_config_masked()
|
||||
|
||||
|
||||
@router.patch("/ai")
|
||||
@@ -110,7 +112,7 @@ async def update_ai_settings(
|
||||
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 = await load_ai_service_config()
|
||||
config.provider = body.provider
|
||||
|
||||
# Anthropic
|
||||
@@ -139,8 +141,8 @@ async def update_ai_settings(
|
||||
body.lmstudio_api_key, config.lmstudio.api_key
|
||||
)
|
||||
|
||||
await asyncio.to_thread(save_ai_service_config, config)
|
||||
return load_ai_service_config_masked()
|
||||
await save_ai_service_config(config)
|
||||
return await load_ai_service_config_masked()
|
||||
|
||||
|
||||
@router.post("/ai/test")
|
||||
@@ -173,7 +175,7 @@ async def test_ai_connection(
|
||||
async def get_documents_limits(
|
||||
_: User = Depends(get_service_admin("doc-service")),
|
||||
) -> dict:
|
||||
return load_doc_service_config_masked()
|
||||
return await load_doc_service_config_masked()
|
||||
|
||||
|
||||
@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:
|
||||
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
|
||||
await asyncio.to_thread(save_doc_service_config, config)
|
||||
return load_doc_service_config_masked()
|
||||
await save_doc_service_config(config)
|
||||
return await load_doc_service_config_masked()
|
||||
|
||||
|
||||
# ── System prompts ─────────────────────────────────────────────────────────────
|
||||
@@ -197,8 +199,7 @@ async def update_documents_limits(
|
||||
async def get_system_prompts(
|
||||
_: User = Depends(get_service_admin("ai-service")),
|
||||
) -> dict:
|
||||
"""Return all editable system prompts, keyed by service id."""
|
||||
return await asyncio.to_thread(load_all_system_prompts)
|
||||
return await load_all_system_prompts()
|
||||
|
||||
|
||||
@router.patch("/system-prompts/{service_id}")
|
||||
@@ -207,26 +208,20 @@ async def update_system_prompt(
|
||||
body: SystemPromptUpdate,
|
||||
_: User = Depends(get_service_admin("ai-service")),
|
||||
) -> 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)
|
||||
await save_service_system_prompts(service_id, body.system, body.user_template)
|
||||
return await load_all_system_prompts()
|
||||
|
||||
|
||||
# ── 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")
|
||||
async def get_appearance(
|
||||
_: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
config = await asyncio.to_thread(load_appearance_config)
|
||||
config = await load_appearance_config()
|
||||
return config.model_dump()
|
||||
|
||||
|
||||
@@ -237,12 +232,12 @@ async def update_appearance(
|
||||
) -> dict:
|
||||
if body.default_mode not in ("light", "dark", "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}
|
||||
if body.theme not in theme_ids:
|
||||
raise HTTPException(status_code=422, detail=f"Unknown theme: {body.theme!r}")
|
||||
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()
|
||||
|
||||
|
||||
@@ -253,7 +248,7 @@ async def update_appearance(
|
||||
async def list_themes(
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
return await asyncio.to_thread(load_all_themes)
|
||||
return await load_all_themes()
|
||||
|
||||
|
||||
@router.post("/themes", status_code=201)
|
||||
@@ -263,7 +258,7 @@ async def create_theme(
|
||||
) -> dict:
|
||||
if not _THEME_ID_RE.match(body.id):
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail=f"Theme ID already in use: {body.id!r}")
|
||||
light = body.light.model_dump()
|
||||
@@ -273,7 +268,7 @@ async def create_theme(
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail=f"{mode}: {'; '.join(errors)}")
|
||||
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
|
||||
|
||||
|
||||
@@ -283,11 +278,9 @@ async def update_theme(
|
||||
body: ThemeUpdate,
|
||||
_: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
from app.core.app_config import _THEMES_DIR
|
||||
path = _THEMES_DIR / f"{theme_id}.json"
|
||||
if not path.exists():
|
||||
theme = await load_theme_by_id(theme_id)
|
||||
if theme is None:
|
||||
raise HTTPException(status_code=404, detail="Theme not found")
|
||||
theme = json.loads(path.read_text())
|
||||
if theme.get("builtin"):
|
||||
raise HTTPException(status_code=400, detail="Cannot edit a built-in theme")
|
||||
if body.label is not None:
|
||||
@@ -304,7 +297,7 @@ async def update_theme(
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail=f"dark: {'; '.join(errors)}")
|
||||
theme["dark"] = dark
|
||||
await asyncio.to_thread(save_theme, theme)
|
||||
await save_theme(theme)
|
||||
return theme
|
||||
|
||||
|
||||
@@ -314,7 +307,7 @@ async def remove_theme(
|
||||
_: User = Depends(get_current_admin),
|
||||
) -> None:
|
||||
try:
|
||||
await asyncio.to_thread(delete_theme, theme_id)
|
||||
await delete_theme(theme_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Theme not found")
|
||||
except ValueError as exc:
|
||||
|
||||
+5
-12
@@ -54,8 +54,6 @@ services:
|
||||
DOC_SERVICE_URL: http://doc-service:8001
|
||||
AI_SERVICE_URL: http://ai-service:8010
|
||||
STORAGE_SERVICE_URL: http://storage-service:8020
|
||||
volumes:
|
||||
- app_config:/config
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -73,9 +71,10 @@ services:
|
||||
user: "1001:1001"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CONFIG_PATH: /config/ai_service_config.json
|
||||
volumes:
|
||||
- app_config:/config
|
||||
STORAGE_SERVICE_URL: http://storage-service:8020
|
||||
depends_on:
|
||||
storage-service:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- backend-net
|
||||
|
||||
@@ -89,14 +88,10 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
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
|
||||
STORAGE_SERVICE_URL: http://storage-service:8020
|
||||
volumes:
|
||||
- doc_data:/data/documents
|
||||
- watch_data:/data/watch
|
||||
- app_config:/config
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -125,10 +120,8 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
storage_data: # All file/blob storage — managed by storage-service
|
||||
doc_data: # PDF files persisted across restarts (to be removed after Phase 2 migration)
|
||||
storage_data: # All file/blob storage — managed by storage-service (documents + config)
|
||||
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:
|
||||
# backend-net: db ↔ backend ↔ doc-service. No host ports bound.
|
||||
|
||||
@@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
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"}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
Env var overrides (all optional):
|
||||
@@ -8,15 +8,17 @@ Env var overrides (all optional):
|
||||
OLLAMA_BASE_URL, OLLAMA_MODEL, OLLAMA_API_KEY
|
||||
ANTHROPIC_API_KEY, ANTHROPIC_MODEL
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_CONFIG_KEY = "ai_service_config.json"
|
||||
|
||||
_DEFAULT_CONFIG: dict = {
|
||||
"provider": "lmstudio",
|
||||
"timeout_seconds": 60,
|
||||
@@ -31,12 +33,18 @@ _cache_at: float = 0.0
|
||||
_CACHE_TTL = 30.0
|
||||
|
||||
|
||||
def _read_config_sync() -> dict:
|
||||
path = Path(settings.CONFIG_PATH)
|
||||
if not path.exists():
|
||||
return _apply_env_overrides(deepcopy(_DEFAULT_CONFIG))
|
||||
with open(path) as f:
|
||||
return _apply_env_overrides(json.load(f))
|
||||
def _storage_url() -> str:
|
||||
return f"{settings.STORAGE_SERVICE_URL}/objects/config/{_CONFIG_KEY}"
|
||||
|
||||
|
||||
async def _fetch_config() -> dict:
|
||||
"""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:
|
||||
@@ -75,7 +83,8 @@ async def load_ai_config() -> dict:
|
||||
now = time.monotonic()
|
||||
if _cache is not None and (now - _cache_at) < _CACHE_TTL:
|
||||
return _cache
|
||||
data = await asyncio.to_thread(_read_config_sync)
|
||||
raw = await _fetch_config()
|
||||
data = _apply_env_overrides(raw)
|
||||
_cache = data
|
||||
_cache_at = now
|
||||
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.
|
||||
|
||||
Env var overrides (all optional):
|
||||
DOC_MAX_PDF_MB — max upload size in megabytes (e.g. "50")
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_CONFIG_KEY = "doc_service_config.json"
|
||||
|
||||
_DEFAULT_STORAGE_CONFIG: dict = {
|
||||
"watch_enabled": False,
|
||||
"watch_path": "/data/watch",
|
||||
@@ -63,33 +64,30 @@ _cache_at: float = 0.0
|
||||
_CACHE_TTL = 30.0
|
||||
|
||||
|
||||
def _read_config_sync() -> dict:
|
||||
path = Path(settings.CONFIG_PATH)
|
||||
if not path.exists():
|
||||
base = deepcopy(_DEFAULT_CONFIG)
|
||||
else:
|
||||
with open(path) as f:
|
||||
base = json.load(f)
|
||||
return _apply_env_overrides(base)
|
||||
def _storage_url() -> str:
|
||||
return f"{settings.STORAGE_SERVICE_URL}/objects/config/{_CONFIG_KEY}"
|
||||
|
||||
|
||||
def _read_config_sync_raw() -> dict:
|
||||
"""Read without env overrides — used when we need to write back to disk."""
|
||||
path = Path(settings.CONFIG_PATH)
|
||||
if not path.exists():
|
||||
async def _fetch_config() -> dict:
|
||||
"""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)
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _write_config_sync(config: dict) -> None:
|
||||
"""Atomically write config JSON to disk."""
|
||||
path = Path(settings.CONFIG_PATH)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
os.replace(tmp, path)
|
||||
async def _write_config(data: dict) -> None:
|
||||
import json
|
||||
payload = json.dumps(data, indent=2).encode()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.put(
|
||||
_storage_url(),
|
||||
content=payload,
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _apply_env_overrides(config: dict) -> dict:
|
||||
@@ -108,7 +106,8 @@ async def load_doc_config() -> dict:
|
||||
now = time.monotonic()
|
||||
if _cache is not None and (now - _cache_at) < _CACHE_TTL:
|
||||
return _cache
|
||||
data = await asyncio.to_thread(_read_config_sync)
|
||||
raw = await _fetch_config()
|
||||
data = _apply_env_overrides(raw)
|
||||
_cache = data
|
||||
_cache_at = now
|
||||
return data
|
||||
@@ -123,11 +122,10 @@ async def get_storage_config() -> dict:
|
||||
|
||||
|
||||
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
|
||||
raw = await asyncio.to_thread(_read_config_sync_raw)
|
||||
raw = await _fetch_config()
|
||||
raw.setdefault("storage", {}).update(data)
|
||||
await asyncio.to_thread(_write_config_sync, raw)
|
||||
# Invalidate cache so next read picks up the new values
|
||||
await _write_config(raw)
|
||||
_cache = None
|
||||
_cache_at = 0.0
|
||||
|
||||
Reference in New Issue
Block a user