Make the first RAG slice executable without risking production data
Some checks failed
verify / verify (push) Has been cancelled
Some checks failed
verify / verify (push) Has been cancelled
The Stage 1 foundation now proves provider contracts with mocks and validates PostgreSQL/pgvector ingestion, approval binding, retrieval, reranking, and idempotency using only synthetic data. Live Bailian validation remains gated on rotating the exposed key. Constraint: The key shown in chat is compromised and cannot be used or committed Rejected: Mark Stage 1 complete from mock and offline results | real three-model smoke is still required Confidence: high Scope-risk: moderate Reversibility: clean Directive: Do not enable real-data ingestion until Stage 3 cloud approval and outbound manifest controls are enforced end to end Tested: make verify; 41 pytest tests; strict mypy; Ruff; Compose config; pinned image build; empty-volume migration; role denial; two idempotent 20-vector seeds; database restart persistence Not-tested: Live Bailian calls require a newly rotated key; React product UI is not implemented
This commit is contained in:
128
backend/app/core/config.py
Normal file
128
backend/app/core/config.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""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)
|
||||
|
||||
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("/")
|
||||
|
||||
@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()
|
||||
Reference in New Issue
Block a user