AI-Pricing-Agent/config/settings.py

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()