Spaces:
Running
Running
Download backend/config.py from samruddhisadar/gradeops: direct link, hf CLI and curl.
- Browser
- Download file 9.13 kB
-
https://huggingface.co/spaces/samruddhisadar/gradeops/resolve/main/backend/config.py
- Command line
-
hf download hf://spaces/samruddhisadar/gradeops/backend/config.py
-
curl -L -o config.py https://huggingface.co/spaces/samruddhisadar/gradeops/resolve/main/backend/config.py
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, | |
| ) | |
| 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 | |
| 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')}", | |
| } | |
| 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() | |