import os
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import Field
from pydantic_settings import BaseSettings
class DatabaseConfig(BaseSettings):
host: str = "localhost"
port: int = 5432
user: str = "wittyhub"
password: str = "wittyhub_secret"
dbname: str = "wittyhub"
sslmode: str = "disable"
@property
def url(self) -> str:
return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.dbname}"
@property
def sync_url(self) -> str:
return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.dbname}"
class StorageConfig(BaseSettings):
type: str = "local"
local_path: str = "/opt/wittyhub"
github_token: str = ""
class ModelConfig(BaseSettings):
name: str = "deepseek-v4-flash"
base_url: str = "https://api.deepseek.com"
api_key: str = "sk-9767243ba0364f52ab5bb7878ad2d42a"
timeout: float = 30
class CrawlerConfig(BaseSettings):
github_token: str = ""
github_username: str = "git"
max_tags_per_repo: int = 3
class SkillRepoEntry(BaseSettings):
url: str
branch: str | None = None
class SecurityConfig(BaseSettings):
enable_audit: bool = False
skillspector_jenkins_url: str = ""
skillspector_jenkins_user: str = ""
skillspector_jenkins_token: str = ""
class AppConfig(BaseSettings):
host: str = "0.0.0.0"
port: int = 8080
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
class LoggingConfig(BaseSettings):
level: str = "INFO"
format: str = "json"
class AIConfig(BaseSettings):
embedding_model: str = "bge-base-zh-v1.5"
embedding_host: str = "http://localhost:8081"
embedding_dimension: int = 768
enable_semantic_search: bool = True
class Settings(BaseSettings):
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
model: ModelConfig = Field(default_factory=ModelConfig)
crawler: CrawlerConfig = Field(default_factory=CrawlerConfig)
security: SecurityConfig = Field(default_factory=SecurityConfig)
app: AppConfig = Field(default_factory=AppConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
ai: AIConfig = Field(default_factory=AIConfig)
openeuler_repos: list[SkillRepoEntry] = Field(default_factory=list)
personal_repos: list[SkillRepoEntry] = Field(default_factory=list)
enterprise_repos: list[SkillRepoEntry] = Field(default_factory=list)
@classmethod
def from_yaml(cls, path: str | Path) -> "Settings":
path = Path(path)
data: dict[str, Any] = {}
if path.exists():
with open(path) as f:
data = yaml.safe_load(f) or {}
_apply_env_overrides(data)
return cls(
database=DatabaseConfig(**data.get("database", {})),
storage=StorageConfig(**data.get("storage", {})),
model=ModelConfig(**data.get("model", {})),
crawler=CrawlerConfig(**data.get("crawler", {})),
security=SecurityConfig(**data.get("security", {})),
app=AppConfig(**data.get("app", {})),
logging=LoggingConfig(**data.get("logging", {})),
ai=AIConfig(**data.get("ai", {})),
openeuler_repos=data.get("openeuler_repos", []) or [],
personal_repos=data.get("personal_repos", []) or [],
enterprise_repos=data.get("enterprise_repos", []) or [],
)
def _apply_env_overrides(data: dict[str, Any]) -> None:
for key, value in os.environ.items():
if "__" not in key:
continue
section, field = key.split("__", 1)
section = section.strip().lower()
field = field.strip().lower()
if not section or not field:
continue
section_data = data.setdefault(section, {})
if isinstance(section_data, dict):
section_data[field] = _parse_env_value(value)
def _parse_env_value(value: str) -> Any:
if value == "":
return ""
try:
return yaml.safe_load(value)
except yaml.YAMLError:
return value
@lru_cache
def get_settings() -> Settings:
default_config_path = Path(__file__).resolve().parents[2] / "config.yaml"
config_path = os.environ.get("WITTYHUB_CONFIG", str(default_config_path))
return Settings.from_yaml(config_path)