gradeops / backend /config.py
samsadar236
fix: restore config.py corrupted by echo command
449528e
Raw History Blame Contribute Delete
9.13 kB
"""Centralized configuration loaded from environment variables.
Loading order (first non-empty wins):
1. Shell environment variable (e.g. `$env:GOOGLE_API_KEY=...`)
2. Values in `gradeops/.env` (loaded via python-dotenv from an ABSOLUTE
path resolved from this file's location — robust to whatever CWD
uvicorn was launched with)
3. Class-level defaults below
Lane A (deterministic) + provider split live here:
- grader_temperature defaults to 0.0 so the pipeline is reproducible.
- Per-role routing (vision/text/critic) lets the vision node stay on
Gemini while the text and critic nodes run on Groq. This moves three
of four calls off the tight Gemini free-tier quota and enables a
cross-model critic (different model for scorer vs critic) to remove
self-preference bias.
"""
from pathlib import Path
from dotenv import load_dotenv
from pydantic_settings import BaseSettings, SettingsConfigDict
# backend/config.py → ../ → gradeops/ → .env (absolute path, CWD-independent)
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
_ENV_FILE = _PROJECT_ROOT / ".env"
if _ENV_FILE.exists():
load_dotenv(_ENV_FILE, override=False)
class Settings(BaseSettings):
# --- Legacy single-provider switch --------------------------------
# Retained for backward compatibility and for the /health readout.
# The per-role fields below (vision_provider / text_provider /
# critic_provider) supersede this for actual routing. To run the whole
# pipeline on one provider, set all three per-role fields to it.
llm_provider: str = "google"
# --- Determinism (Lane A) -----------------------------------------
# 0.0 = deterministic/reproducible. Hosted models are not bit-identical
# even at 0 (MoE routing, batching), so measure real variance rather
# than assuming it is exactly zero.
grader_temperature: float = 0.0
# --- Per-role provider routing (Lane A provider split) ------------
# 'vision' handles the Extractor and OCR — it MUST be a vision-capable
# provider (google or anthropic). 'text' handles Scorer + Justifier.
# 'critic' is separate so it can run cross-model (item 2 / T1.3).
vision_provider: str = "google" # Extractor + OCR
text_provider: str = "google" # Scorer + Justifier (model A)
critic_provider: str = "google" # Critic (model B)
# --- Google Gemini (vision) ---------------------------------------
# 2.5-flash-lite has the highest free-tier quota of any vision-capable
# Gemini (about 15 RPM / 1000 RPD) — picked for reliability over peak
# quality. Verify current limits at ai.google.dev/gemini-api/docs/rate-limits.
google_api_key: str = ""
grader_model_google: str = "gemini-2.5-flash-lite"
# --- Groq (free, fast; text + critic roles) -----------------------
# Groq's free tier is far more generous per-minute than Gemini's and
# returns in well under a second. Confirm current model ids at
# console.groq.com/docs/models — they change.
#
# Cross-model critic (item 2): keep critic_model_groq DIFFERENT from
# grader_model_groq to stop the judge from favouring its own outputs.
# Both default to the same strong model so the pipeline works out of
# the box; flip critic_model_groq to a second model (or set
# critic_provider="google") once you have confirmed a second id.
groq_api_key: str = ""
grader_model_groq: str = "llama-3.3-70b-versatile" # scorer / justifier
critic_model_groq: str = "llama-3.3-70b-versatile" # critic (see note above)
# --- Anthropic Claude (paid; optional quality comparison) ---------
anthropic_api_key: str = ""
grader_model_anthropic: str = "claude-sonnet-4-20250514"
# --- Database ------------------------------------------------------
# SQLite by default. The whole pipeline is ORM (SQLAlchemy), so pointing
# DATABASE_URL at a Postgres URL swaps the backend with no code change.
# On Hugging Face Spaces the filesystem is ephemeral: a SQLite file
# written at runtime is wiped on restart. For the demo, bake a
# pre-graded gradeops.db into the image (COPY in the Dockerfile).
database_url: str = "sqlite:///./gradeops.db"
# --- OCR routing ---------------------------------------------------
ocr_backend: str = "hosted"
# --- Storage -------------------------------------------------------
storage_root: str = "./storage"
# --- Grader behavior -----------------------------------------------
# Lane A: single pass. Determinism means extra passes would be
# identical, so multi-pass variance is not a useful confidence signal
# here (confidence comes from HHEM + rule check + critic + flags).
grader_num_passes: int = 1
grader_critic_retry: int = 0 # set to 1 to enable critic→scorer retry
# --- Rate-limit throttling -----------------------------------------
# Seconds between successive LLM calls. Gemini free tier is about
# 15 RPM, so the safe floor for Gemini calls is ~4s. Groq tolerates
# much faster; if the vision node is the only Gemini caller you can
# lower this, but keep a cushion for the vision path.
llm_min_gap_seconds: float = 4.5
# --- Plagiarism ----------------------------------------------------
plagiarism_threshold: float = 0.82
embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
# --- Grounding check: HHEM-2.1-Open (deterministic, CPU, no API) ---
# A small faithfulness classifier that scores how well the justification
# is supported by the extracted claims (0..1). Runs before the LLM Critic
# in the cascade so the Critic only fires on borderline cases. First use
# downloads the model (~a few hundred MB) from Hugging Face, then runs
# offline on CPU. torch+transformers already arrive via sentence-transformers.
# Calibrate the thresholds on the eval set (Phase 3); the defaults are a
# starting point, not a tuned value.
hhem_enabled: bool = True
hhem_model: str = "vectara/hallucination_evaluation_model"
hhem_high_threshold: float = 0.7 # >= this: confident grounded -> skip Critic
hhem_low_threshold: float = 0.4 # < this: likely ungrounded -> Critic
hhem_max_chars: int = 4000 # truncate long inputs to keep CPU fast
# --- Versioning ----------------------------------------------------
prompt_version: str = "v1.0"
schema_version: str = "v1.0"
model_config = SettingsConfigDict(
env_file=str(_ENV_FILE) if _ENV_FILE.exists() else None,
env_file_encoding="utf-8-sig",
extra="ignore",
case_sensitive=False,
)
@property
def storage_path(self) -> Path:
p = Path(self.storage_root)
(p / "pdfs").mkdir(parents=True, exist_ok=True)
(p / "pages").mkdir(parents=True, exist_ok=True)
(p / "crops").mkdir(parents=True, exist_ok=True)
return p
# --- Per-role resolution ------------------------------------------
def provider_for(self, role: str) -> str:
"""Which provider serves a given role: 'vision' | 'text' | 'critic'."""
role = (role or "text").lower()
if role == "vision":
return (self.vision_provider or "google").lower()
if role == "critic":
return (self.critic_provider or self.text_provider or "groq").lower()
return (self.text_provider or "groq").lower()
def model_for(self, role: str) -> str:
"""Model id for a given role, resolved against its provider."""
provider = self.provider_for(role)
if provider == "google":
return self.grader_model_google
if provider == "anthropic":
return self.grader_model_anthropic
if provider == "groq":
return self.critic_model_groq if role == "critic" else self.grader_model_groq
# Unknown provider: fall back to the Gemini model so vision still works.
return self.grader_model_google
@property
def effective_models(self) -> dict:
"""The models actually in use per role (for audit + /health)."""
return {
"vision": f"{self.provider_for('vision')}:{self.model_for('vision')}",
"scorer": f"{self.provider_for('text')}:{self.model_for('text')}",
"critic": f"{self.provider_for('critic')}:{self.model_for('critic')}",
}
@property
def grader_model(self) -> str:
"""Compact, audit-friendly description of the effective pipeline.
Kept as a single string so existing audit rows and the /health
endpoint (which stamp settings.grader_model) stay valid, while
now reflecting the provider split, cross-model critic, and temp.
"""
m = self.effective_models
if m["scorer"] == m["critic"]:
return f"scorer/critic={m['scorer']}; vision={m['vision']}; temp={self.grader_temperature}"
return (
f"scorer={m['scorer']}; critic={m['critic']}; "
f"vision={m['vision']}; temp={self.grader_temperature}"
)
settings = Settings()