Files
RAG/backend/app/core/config.py
YoVinchen 75592af33a
Some checks failed
verify / verify (push) Has been cancelled
Isolate cloud model access before enabling product RAG workflows
The API and ingestion tools now use a fixed internal model gateway while
governed profiles, embedding cache assignments, traceable citations, and
stable API errors establish the boundaries required by later workflows.

Constraint: The current Alibaba Cloud workspace rejects all three live model calls with authentication failures
Rejected: Give the API or seed tools the Bailian key and direct egress | combines database access, cloud credentials, and public network access
Rejected: Mix offline and Bailian vectors in one demo namespace | makes profile activation and retrieval ambiguous
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep Bailian credentials and egress exclusive to model-gateway and create a new immutable profile hash for any embedding identity change
Tested: make verify; 121 backend tests; 14 frontend tests; fresh and populated Alembic upgrade-downgrade-upgrade; two idempotent offline seeds; Docker health and HTTP retrieval; isolated provider smoke
Not-tested: Successful live Bailian responses because the supplied workspace credential currently fails authentication
2026-07-13 04:09:06 +08:00

162 lines
6.2 KiB
Python

"""Typed runtime configuration loaded from non-secret environment values."""
from functools import lru_cache
from pathlib import Path
from typing import Literal, Self
from urllib.parse import urlsplit
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import URL
from app.core.secrets import read_secret_file
class Settings(BaseSettings):
"""Application settings; secret values stay in mounted files."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
app_env: Literal["development", "test", "production"] = "development"
app_name: str = "geological-rag"
app_secret_key_file: Path = Path("/run/secrets/app_secret_key")
postgres_host: str = "db"
postgres_port: int = Field(default=5432, ge=1, le=65535)
postgres_db: str = "geological_rag"
postgres_user: str = "geological_rag_app"
postgres_password_file: Path = Path("/run/secrets/postgres_app_password")
upload_root: Path = Path("/data/uploads")
max_upload_mb: int = Field(default=100, ge=1, le=2048)
model_gateway_base_url: str = "http://model-gateway:8000"
model_gateway_token_file: Path = Path("/run/secrets/model_gateway_api_token")
model_gateway_caller: Literal["api", "worker"] = "api"
model_gateway_timeout_seconds: float = Field(default=120, gt=0, le=600)
bailian_openai_base_url: str = (
"https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
)
bailian_native_base_url: str = "https://<workspace-id>.cn-beijing.maas.aliyuncs.com/api/v1"
bailian_rerank_base_url: str = (
"https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
)
dashscope_api_key_file: Path = Path("/run/secrets/bailian_api_key")
embedding_model: str = "text-embedding-v4"
embedding_dimension: Literal[1024] = 1024
rerank_model: str = "qwen3-rerank"
llm_model: str = "deepseek-v4-flash"
chunk_target_tokens: int = Field(default=512, ge=120, le=800)
chunk_max_tokens: int = Field(default=800, ge=120, le=4000)
chunk_overlap_tokens: int = Field(default=64, ge=0, le=256)
vector_top_k: int = Field(default=50, ge=1, le=500)
rerank_top_n: int = Field(default=10, ge=1, le=500)
context_top_n: int = Field(default=8, ge=1, le=50)
max_context_tokens: int = Field(default=10_000, ge=1, le=100_000)
model_timeout_seconds: float = Field(default=90, gt=0, le=600)
model_max_retries: int = Field(default=3, ge=0, le=10)
model_max_concurrency: int = Field(default=4, ge=1, le=100)
worker_capabilities: str = "document_parse,embedding,rerank,evaluation"
@field_validator(
"bailian_openai_base_url",
"bailian_native_base_url",
"bailian_rerank_base_url",
)
@classmethod
def normalize_base_url(cls, value: str) -> str:
return value.rstrip("/")
@field_validator("model_gateway_base_url")
@classmethod
def validate_model_gateway_base_url(cls, value: str) -> str:
normalized = value.rstrip("/")
parsed = urlsplit(normalized)
if (
parsed.scheme != "http"
or parsed.hostname != "model-gateway"
or parsed.port != 8000
or parsed.path not in ("", "/")
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise ValueError("MODEL_GATEWAY_BASE_URL must be the fixed internal service URL")
return normalized
@field_validator("embedding_dimension", mode="before")
@classmethod
def parse_embedding_dimension(cls, value: object) -> int:
"""Accept Compose's string representation without widening the contract."""
if value == "1024" or (
isinstance(value, int) and not isinstance(value, bool) and value == 1024
):
return 1024
raise ValueError("EMBEDDING_DIMENSION must be exactly 1024")
@model_validator(mode="after")
def validate_rag_limits(self) -> Self:
if self.chunk_overlap_tokens >= self.chunk_target_tokens:
raise ValueError("CHUNK_OVERLAP_TOKENS must be smaller than CHUNK_TARGET_TOKENS")
if self.chunk_target_tokens > self.chunk_max_tokens:
raise ValueError("CHUNK_TARGET_TOKENS must not exceed CHUNK_MAX_TOKENS")
if self.context_top_n > self.rerank_top_n:
raise ValueError("CONTEXT_TOP_N must not exceed RERANK_TOP_N")
if self.rerank_top_n > self.vector_top_k:
raise ValueError("RERANK_TOP_N must not exceed VECTOR_TOP_K")
return self
def bailian_api_key(self) -> str:
self.validate_live_bailian_endpoints()
return read_secret_file(self.dashscope_api_key_file)
def validate_live_bailian_endpoints(self) -> str:
endpoints = {
self.bailian_openai_base_url: "/compatible-mode/v1",
self.bailian_rerank_base_url: "/compatible-api/v1",
}
hosts: set[str] = set()
for endpoint, expected_path in endpoints.items():
parsed = urlsplit(endpoint)
host = parsed.hostname or ""
if (
parsed.scheme != "https"
or parsed.path.rstrip("/") != expected_path
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or "<" in endpoint
or not host.endswith(".cn-beijing.maas.aliyuncs.com")
):
raise ValueError("Bailian endpoint is not an approved Beijing MaaS URL")
hosts.add(host)
if len(hosts) != 1:
raise ValueError("Bailian endpoints must use the same workspace host")
return hosts.pop()
def database_url(self) -> URL:
return URL.create(
drivername="postgresql+psycopg",
username=self.postgres_user,
password=read_secret_file(self.postgres_password_file),
host=self.postgres_host,
port=self.postgres_port,
database=self.postgres_db,
)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()