"""Central configuration loader. Reads environment (.env) via pydantic-settings and the pricing policy from config/pricing_rules.yaml. One import point for the whole app. """ from __future__ import annotations from functools import lru_cache from pathlib import Path import yaml from pydantic import BaseModel, Field from pydantic_settings import BaseSettings, SettingsConfigDict # Project root = parent of this config/ directory. ROOT = Path(__file__).resolve().parent.parent RULES_PATH = ROOT / "config" / "pricing_rules.yaml" class PricingRules(BaseModel): """Typed view of pricing_rules.yaml.""" margin_floor: float = 0.25 margin_target: float = 0.30 returns_reserve_pct: float = 0.02 storage_alloc: float = 0.25 worst_case_referral_pct: float = 0.15 charm_ending: float | None = 0.99 price_competitiveness_tolerance: float = 0.05 # Inventory cover PRICING triggers — see the comments in pricing_rules.yaml for why these # are not the COSMOS display bands. low_cover_days: int = 35 high_cover_days: int = 90 # Competitor-driven cascade rules — see the comments in pricing_rules.yaml. # Scoped kill switch for BUYBOX_SUPPRESSED + COMPETITOR_UNDERCUT only. Independent of the # sidebar's global approval pause. competitor_rules_enabled: bool = True competitor_undercut_material_pct: float = 0.03 competitor_premium_material_pct: float = 0.10 competitor_state_max_age_hours: float = 6.0 competitor_sheet_max_age_hours: float | None = 168.0 class Settings(BaseSettings): """Environment-driven settings.""" model_config = SettingsConfigDict( env_file=str(ROOT / ".env"), env_file_encoding="utf-8", extra="ignore" ) # LLM openai_api_key: str | None = None openai_model: str = "gpt-4.1" # fast gate rationale # Deep analysis uses a big reasoning model (gpt-5.1 default; gpt-5-pro for max depth) openai_analysis_model: str = "gpt-5.1" # none | low | medium | high ('minimal' is rejected by gpt-5.1). # The model only EXPLAINS numbers the rules already computed — it never calculates or # decides — so heavy reasoning buys nothing. Measured on one SKU: high 128s, medium 38s, # low 9s. 'low' was also the only setting that respected the 200-word cap and kept the # currency formatting, so it is both the fastest and the most accurate choice here. openai_reasoning_effort: str = "low" # Backends data_backend: str = "cosmos" # cosmos | mock tracker_backend: str = "csv" # csv | gsheets # COSMOS API (primary data source) cosmos_base_url: str = "https://cosmos-api.utopiadeals.com" cosmos_email: str | None = None cosmos_password: str | None = None cosmos_marketplace: str = "AMAZON_USA" cosmos_timeout_seconds: float = 30.0 # Google Sheets google_application_credentials: str | None = None tracker_sheet_id: str | None = None tracker_worksheet: str = "master" # CSV backend tracker_csv_path: str = str(ROOT / "data" / "sample_skus.csv") tracker_csv_out: str = str(ROOT / "data" / "sample_skus_out.csv") audit_log_path: str = str(ROOT / "data" / "audit_log.csv") # Mock Amazon fixtures mock_fixtures_path: str = str(ROOT / "tests" / "fixtures" / "amazon_mock.json") # Apify — public Amazon PDP / Buy Box / offers (fills competitive gap COSMOS lacks) apify_token: str | None = None apify_actor_id: str = "junglee/Amazon-crawler" apify_seller_id: str | None = None # our Amazon merchant id; enables WON vs LOST_PRICE apify_max_offers: int = 10 apify_zip_code: str = "10001" apify_timeout_seconds: float = 300.0 # Cost is per actor RUN (container cold start ~30-45s), not per ASIN. Measured: 3 ASINs # in one run = 46.8s vs ~145s scraped one-by-one. So batch, and cache the raw payload. apify_batch_size: int = 20 # ASINs per actor run apify_cache_ttl_seconds: float = 21600.0 # 6h; 0 disables the cache apify_cache_path: str = str(ROOT / "data" / "apify_cache.json") # Competitor comparison workbook — the sibling scraper's output, currently covering ONE # product line. Leave blank to auto-discover the newest # `Competitor_Price_Comparison_*.xlsx` in the directories below. competitor_sheet_path: str | None = None # Where to look when auto-discovering, newest file wins. Semicolon-separated. competitor_sheet_dirs: str = ";".join([ str(ROOT / "competetor"), str(ROOT.parent / "scraper"), ]) # When a sheet IS loaded, is it the ONLY competitor source? # # True (the default while a single line is under test) means a SKU the sheet does not cover # gets an explicit N/A instead of falling through to a per-ASIN Apify scrape. That keeps the # test honest -- every verdict either rests on the sheet or says it has no competitor data -- # and it stops a stray scrape quietly pricing a SKU the sheet was supposed to govern. # Set False once coverage is broad enough for Apify to be a sensible fallback again. competitor_sheet_only: bool = True @lru_cache(maxsize=1) def get_rules() -> PricingRules: if RULES_PATH.exists(): data = yaml.safe_load(RULES_PATH.read_text(encoding="utf-8")) or {} return PricingRules(**data) return PricingRules() @lru_cache(maxsize=1) def get_settings() -> Settings: return Settings()