130 lines
5.3 KiB
Python
130 lines
5.3 KiB
Python
"""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()
|