Rewrite Settings as native Periscope config.
DeepSeek remains the only live provider; leftover Anthropic env names are coerced or ignored. Skills manifest is resolved across the split tree.
This commit is contained in:
@@ -0,0 +1,288 @@
|
|||||||
|
"""Periscope runtime settings: env, paths, DeepSeek routing, optional seams.
|
||||||
|
|
||||||
|
Auth field names stay on :class:`Settings` because leftover ``routers/auth.py``
|
||||||
|
reads them. This module never mints or rotates secrets.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
from backend.repo_paths import data_dir as locate_data_dir
|
||||||
|
from backend.repo_paths import dependency_root
|
||||||
|
from backend.repo_paths import env_file as locate_env_file
|
||||||
|
from backend.repo_paths import skills_dir as locate_skills_dir
|
||||||
|
from backend.repo_paths import src_root
|
||||||
|
from backend.repo_paths import taxonomy_dir as locate_taxonomy_dir
|
||||||
|
|
||||||
|
_STAGES = (
|
||||||
|
"pintable",
|
||||||
|
"pattern",
|
||||||
|
"specs",
|
||||||
|
"validation",
|
||||||
|
"auto_resolve",
|
||||||
|
"normalize",
|
||||||
|
)
|
||||||
|
|
||||||
|
_DEEPSEEK = "deepseek"
|
||||||
|
_ANTHROPIC = "anthropic"
|
||||||
|
_GEMINI = "gemini"
|
||||||
|
|
||||||
|
|
||||||
|
def _skills_manifest_path() -> Path:
|
||||||
|
candidates = (
|
||||||
|
src_root() / "backend" / "skills_manifest.json",
|
||||||
|
dependency_root() / "backend" / "skills_manifest.json",
|
||||||
|
Path("/app/backend/skills_manifest.json"),
|
||||||
|
Path(__file__).resolve().parent / "skills_manifest.json",
|
||||||
|
)
|
||||||
|
for path in candidates:
|
||||||
|
if path.is_file():
|
||||||
|
return path
|
||||||
|
return candidates[1]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _skills_manifest() -> dict[str, Any]:
|
||||||
|
path = _skills_manifest_path()
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _attr(obj: Any, name: str, default: str = "") -> str:
|
||||||
|
value = getattr(obj, name, default)
|
||||||
|
return value if isinstance(value, str) else default
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Environment-backed knobs for API, worker, and extraction stages."""
|
||||||
|
|
||||||
|
deepseek_api_key: str = ""
|
||||||
|
deepseek_base_url: str = "https://api.deepseek.com"
|
||||||
|
deepseek_model: str = "deepseek-flash"
|
||||||
|
deepseek_vision_model: str = "deepseek-flash"
|
||||||
|
deepseek_thinking: str = "enabled"
|
||||||
|
deepseek_reasoning_effort: str = "high"
|
||||||
|
deepseek_pdf_max_chars: int = 500_000
|
||||||
|
deepseek_pdf_image_pages: int = 32
|
||||||
|
|
||||||
|
model_pintable_deepseek: str = "deepseek-flash"
|
||||||
|
model_pattern_deepseek: str = "deepseek-flash"
|
||||||
|
model_specs_deepseek: str = "deepseek-flash"
|
||||||
|
model_validation_deepseek: str = "deepseek-flash"
|
||||||
|
model_auto_resolve_deepseek: str = "deepseek-flash"
|
||||||
|
model_normalize_deepseek: str = "deepseek-flash"
|
||||||
|
|
||||||
|
# Leftover env names: Anthropic/Gemini are not used as providers.
|
||||||
|
anthropic_api_key: str = ""
|
||||||
|
anthropic_model: str = "claude-sonnet-4-6"
|
||||||
|
model_pintable: str = ""
|
||||||
|
model_pattern: str = ""
|
||||||
|
model_specs: str = ""
|
||||||
|
model_validation: str = "claude-sonnet-4-6"
|
||||||
|
model_auto_resolve: str = "claude-haiku-4-5-20251001"
|
||||||
|
model_normalize: str = "claude-sonnet-4-6"
|
||||||
|
|
||||||
|
gemini_api_key: str = ""
|
||||||
|
gemini_model: str = "gemini-3.1-pro-preview"
|
||||||
|
model_validation_gemini: str = ""
|
||||||
|
model_pintable_gemini: str = ""
|
||||||
|
model_pattern_gemini: str = ""
|
||||||
|
model_specs_gemini: str = ""
|
||||||
|
model_auto_resolve_gemini: str = ""
|
||||||
|
model_normalize_gemini: str = ""
|
||||||
|
|
||||||
|
provider_default: str = _DEEPSEEK
|
||||||
|
provider_pintable: str = ""
|
||||||
|
provider_pattern: str = ""
|
||||||
|
provider_specs: str = ""
|
||||||
|
provider_validation: str = ""
|
||||||
|
provider_auto_resolve: str = ""
|
||||||
|
provider_normalize: str = ""
|
||||||
|
|
||||||
|
fallback_provider_pintable: str = ""
|
||||||
|
fallback_provider_pattern: str = ""
|
||||||
|
fallback_provider_specs: str = ""
|
||||||
|
fallback_provider_validation: str = ""
|
||||||
|
fallback_provider_auto_resolve: str = ""
|
||||||
|
fallback_provider_normalize: str = ""
|
||||||
|
fallback_model_pintable: str = ""
|
||||||
|
fallback_model_pattern: str = ""
|
||||||
|
fallback_model_specs: str = ""
|
||||||
|
fallback_model_validation: str = ""
|
||||||
|
fallback_model_auto_resolve: str = ""
|
||||||
|
fallback_model_normalize: str = ""
|
||||||
|
|
||||||
|
ic_concurrency: int = 6
|
||||||
|
normalize_findings_enabled: bool = True
|
||||||
|
cross_ic_dedup_enabled: bool = True
|
||||||
|
|
||||||
|
data_dir: Path = Field(default_factory=locate_data_dir)
|
||||||
|
taxonomy_dir: Path = Field(default_factory=locate_taxonomy_dir)
|
||||||
|
skills_dir: Path = Field(default_factory=locate_skills_dir)
|
||||||
|
|
||||||
|
gcs_bucket: str = ""
|
||||||
|
|
||||||
|
clerk_secret_key: str = ""
|
||||||
|
clerk_publishable_key: str = ""
|
||||||
|
clerk_jwks_url: str = ""
|
||||||
|
auth_jwt_secret: str = ""
|
||||||
|
auth_admin_emails: str = ""
|
||||||
|
|
||||||
|
digikey_client_id: str = ""
|
||||||
|
digikey_client_secret: str = ""
|
||||||
|
digikey_environment: str = "production"
|
||||||
|
digikey_locale_site: str = "US"
|
||||||
|
digikey_locale_language: str = "en"
|
||||||
|
digikey_locale_currency: str = "USD"
|
||||||
|
|
||||||
|
mouser_api_key: str = ""
|
||||||
|
|
||||||
|
purple_parts_url: str = ""
|
||||||
|
purple_parts_api_key: str = ""
|
||||||
|
|
||||||
|
email_sender: str = ""
|
||||||
|
email_frontend_url: str = ""
|
||||||
|
email_admin_notify: str = ""
|
||||||
|
contact_recipient: str = ""
|
||||||
|
|
||||||
|
stripe_secret_key: str = ""
|
||||||
|
stripe_webhook_secret: str = ""
|
||||||
|
billing_enabled: bool = Field(
|
||||||
|
default_factory=lambda: importlib.util.find_spec(
|
||||||
|
"backend.services.stripe_billing"
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
survey_sheet_id: str = ""
|
||||||
|
|
||||||
|
cors_origins: list[str] = [
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:3000",
|
||||||
|
"http://localhost:18742",
|
||||||
|
"http://127.0.0.1:18742",
|
||||||
|
]
|
||||||
|
|
||||||
|
pipeline_worker_job_name: str = "periscopex-pipeline-worker"
|
||||||
|
pipeline_worker_region: str = "us-central1"
|
||||||
|
pipeline_worker_project: str = ""
|
||||||
|
pipeline_worker_timeout_seconds: int = 3600
|
||||||
|
pipeline_sweeper_stale_seconds: int = 60
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"env_file": str(locate_env_file()),
|
||||||
|
"env_file_encoding": "utf-8",
|
||||||
|
"extra": "ignore",
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def use_stripe(self) -> bool:
|
||||||
|
return bool(self.stripe_secret_key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def use_digikey(self) -> bool:
|
||||||
|
return bool(self.digikey_client_id and self.digikey_client_secret)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def use_mouser(self) -> bool:
|
||||||
|
return bool(self.mouser_api_key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def use_purple_parts(self) -> bool:
|
||||||
|
return bool(self.purple_parts_url and self.purple_parts_api_key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def use_gcs(self) -> bool:
|
||||||
|
return bool(self.gcs_bucket)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def use_clerk(self) -> bool:
|
||||||
|
return bool(self.clerk_secret_key and self.clerk_jwks_url)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def use_local_auth(self) -> bool:
|
||||||
|
return bool(self.auth_jwt_secret) and not self.use_clerk
|
||||||
|
|
||||||
|
@property
|
||||||
|
def use_auth(self) -> bool:
|
||||||
|
return self.use_clerk or self.use_local_auth
|
||||||
|
|
||||||
|
@property
|
||||||
|
def use_email(self) -> bool:
|
||||||
|
return bool(self.email_sender and self.email_frontend_url)
|
||||||
|
|
||||||
|
def provider_for_stage(self, stage: str) -> str:
|
||||||
|
"""Primary LLM vendor for a pipeline stage.
|
||||||
|
|
||||||
|
``PROVIDER_*=anthropic`` is coerced to DeepSeek so leftover env
|
||||||
|
files cannot route extraction or review to Claude.
|
||||||
|
"""
|
||||||
|
chosen = _attr(self, f"provider_{stage}") or self.provider_default
|
||||||
|
if chosen == _ANTHROPIC:
|
||||||
|
return _DEEPSEEK
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
def model_for_stage(self, stage: str) -> str:
|
||||||
|
"""Model id for a stage, following the provider resolved above."""
|
||||||
|
vendor = self.provider_for_stage(stage)
|
||||||
|
if vendor == _GEMINI:
|
||||||
|
return _attr(self, f"model_{stage}_gemini") or self.gemini_model
|
||||||
|
if vendor == _DEEPSEEK:
|
||||||
|
return _attr(self, f"model_{stage}_deepseek") or self.deepseek_model
|
||||||
|
return _attr(self, f"model_{stage}") or self.anthropic_model
|
||||||
|
|
||||||
|
def fallback_for_stage(self, stage: str) -> tuple[str, str] | None:
|
||||||
|
"""Optional retry vendor. Anthropic fallback is treated as unset."""
|
||||||
|
vendor = _attr(self, f"fallback_provider_{stage}")
|
||||||
|
if not vendor or vendor == _ANTHROPIC:
|
||||||
|
return None
|
||||||
|
model = _attr(self, f"fallback_model_{stage}")
|
||||||
|
if not model:
|
||||||
|
model = self.default_model_for_provider(vendor)
|
||||||
|
return (vendor, model)
|
||||||
|
|
||||||
|
def default_model_for_provider(self, provider: str) -> str:
|
||||||
|
if provider == _GEMINI:
|
||||||
|
return self.gemini_model
|
||||||
|
if provider == _DEEPSEEK:
|
||||||
|
return self.deepseek_model
|
||||||
|
return self.anthropic_model
|
||||||
|
|
||||||
|
def has_llm_credentials(self) -> bool:
|
||||||
|
name = self.provider_default
|
||||||
|
if name == _ANTHROPIC:
|
||||||
|
name = _DEEPSEEK
|
||||||
|
if name == _GEMINI:
|
||||||
|
return bool(self.gemini_api_key)
|
||||||
|
return bool(self.deepseek_api_key)
|
||||||
|
|
||||||
|
def get_skill_or_none(self, name: str) -> tuple[str | None, str | None]:
|
||||||
|
entry = _skills_manifest().get(name)
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return None, None
|
||||||
|
return entry.get("skill_id"), entry.get("latest_version")
|
||||||
|
|
||||||
|
def get_default_model_version(self) -> str:
|
||||||
|
value = _skills_manifest().get("default_model_version", "1.0.0")
|
||||||
|
return str(value) if value else "1.0.0"
|
||||||
|
|
||||||
|
def get_skill(self, name: str) -> tuple[str, str]:
|
||||||
|
path = _skills_manifest_path()
|
||||||
|
entry = _skills_manifest().get(name)
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Skill {name!r} is missing from {path}. "
|
||||||
|
"Skills are local SKILL.md files; do not run upload_skills.py."
|
||||||
|
)
|
||||||
|
return str(entry["skill_id"]), str(entry["latest_version"])
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Native Settings lives under periscope/src and keeps DeepSeek routing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import backend.config as config
|
||||||
|
from backend.config import Settings, settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_module_is_src():
|
||||||
|
path = Path(config.__file__).resolve()
|
||||||
|
assert path.name == "config.py"
|
||||||
|
assert "src" in path.parts
|
||||||
|
assert "Native Periscope overlay" not in path.read_text(encoding="utf-8")[:400]
|
||||||
|
|
||||||
|
|
||||||
|
def test_deepseek_defaults_and_anthropic_coercion():
|
||||||
|
assert Settings.model_fields["provider_default"].default == "deepseek"
|
||||||
|
assert Settings.model_fields["deepseek_model"].default == "deepseek-flash"
|
||||||
|
assert Settings.model_fields["model_pintable_deepseek"].default == "deepseek-flash"
|
||||||
|
assert settings.provider_for_stage("pintable") == "deepseek"
|
||||||
|
|
||||||
|
snap = settings.provider_validation
|
||||||
|
try:
|
||||||
|
settings.provider_validation = "anthropic"
|
||||||
|
assert settings.provider_for_stage("validation") == "deepseek"
|
||||||
|
assert "deepseek" in settings.model_for_stage("validation")
|
||||||
|
finally:
|
||||||
|
settings.provider_validation = snap
|
||||||
|
|
||||||
|
|
||||||
|
def test_anthropic_fallback_is_none():
|
||||||
|
snap_p = settings.fallback_provider_validation
|
||||||
|
snap_m = settings.fallback_model_validation
|
||||||
|
try:
|
||||||
|
settings.fallback_provider_validation = "anthropic"
|
||||||
|
settings.fallback_model_validation = "claude-sonnet-4-6"
|
||||||
|
assert settings.fallback_for_stage("validation") is None
|
||||||
|
finally:
|
||||||
|
settings.fallback_provider_validation = snap_p
|
||||||
|
settings.fallback_model_validation = snap_m
|
||||||
|
|
||||||
|
|
||||||
|
def test_skills_manifest_is_readable():
|
||||||
|
version = settings.get_default_model_version()
|
||||||
|
assert version
|
||||||
|
skill_id, latest = settings.get_skill("extract-pintable")
|
||||||
|
assert skill_id.startswith("skill_")
|
||||||
|
assert latest
|
||||||
Reference in New Issue
Block a user