RICS / app /config.py
StormShadow308's picture
Add demo documentation and Docker setup for v2 report generation system
aad7814
Raw
History Blame Contribute Delete
32 kB
"""Application configuration loaded from environment variables or a .env file."""
import os
import tempfile
from pathlib import Path
from typing import Self
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Central settings object.
All values can be overridden via environment variables or a ``.env`` file
in the project root. See ``.env.example`` for the full reference.
"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# ── LLM ──────────────────────────────────────────────────────────────────
openai_api_key: str = Field(default="", description="OpenAI API key")
embedding_model: str = Field(default="text-embedding-3-small", description="OpenAI embedding model name (legacy, only used if prefer_local_embeddings is False)")
local_embedding_model: str = Field(
default="all-MiniLM-L6-v2",
description="sentence-transformers model used for free, local embeddings (preferred default)",
)
prefer_local_embeddings: bool = Field(
default=True,
description=(
"When True (default), always use the free HuggingFace sentence-transformers model "
"for embeddings even if OPENAI_API_KEY is set. Set to False to fall back to OpenAI "
"embeddings (incurs API cost)."
),
)
chat_model: str = Field(default="gpt-4o-mini")
inspector_body_model: str = Field(
default="gpt-4o",
description=(
"Primary model for inspector-loop body drafting (submit_inspection_section). "
"Kept separate from chat_model so utility passes can stay on cheaper models."
),
)
# ── Vector store (FAISS default; optional Qdrant for async pipeline) ─────
faiss_index_path: str = Field(
default_factory=lambda: str(Path.home() / ".report_genius" / "faiss_index"),
description="Directory for persisted FAISS index files (index.faiss, etc.)",
)
vectorstore_backend: str = Field(
default="faiss",
description="Vector store backend: 'faiss' (local) or 'qdrant' (async retrieval path).",
)
qdrant_url: str = Field(
default="http://localhost:6333",
description="Qdrant HTTP endpoint when vectorstore_backend=qdrant.",
)
qdrant_api_key: str | None = Field(
default=None,
description="Optional Qdrant API key.",
)
qdrant_collection: str = Field(
default="rics_chunks",
description="Main Qdrant collection for tenant-scoped chunks.",
)
qdrant_cache_collection: str = Field(
default="rics_semantic_cache",
description="Qdrant collection for semantic retrieval cache entries.",
)
enable_hybrid_retrieval: bool = Field(
default=True,
description="When true with VECTORSTORE_BACKEND=qdrant, merge vector + BM25 via RRF.",
)
semantic_cache_enabled: bool = Field(
default=True,
description="When true with VECTORSTORE_BACKEND=qdrant, cache retrieval results by query embedding.",
)
semantic_cache_ttl_hours: int = Field(
default=24,
ge=1,
le=168,
description="TTL for semantic cache entries (hours).",
)
semantic_cache_similarity_threshold: float = Field(
default=0.92,
ge=0.5,
le=1.0,
description="Minimum cosine similarity for a semantic cache hit.",
)
# ── Database ─────────────────────────────────────────────────────────────
database_url: str = Field(default="sqlite+aiosqlite:///./dev.db")
# ── File storage ─────────────────────────────────────────────────────────
upload_dir: Path = Field(
default_factory=lambda: Path.home() / ".report_genius" / "uploads"
)
max_single_upload_bytes: int = Field(
default=50 * 1024 * 1024,
ge=1_048_576,
le=500 * 1024 * 1024,
description="Max size per regular .docx/.pdf upload (bytes)",
)
max_archive_upload_bytes: int = Field(
default=500 * 1024 * 1024,
ge=10 * 1024 * 1024,
le=5 * 1024 * 1024 * 1024,
description="Max compressed size for a .zip in batch uploads (bytes)",
)
max_upload_batch_files: int = Field(
default=2000,
ge=1,
le=100_000,
description="Max logical documents per batch request after expanding ZIPs",
)
max_zip_members: int = Field(
default=50_000,
ge=1,
le=500_000,
description="Max file entries inside one ZIP",
)
max_zip_uncompressed_bytes: int = Field(
default=5 * 1024 * 1024 * 1024,
ge=100 * 1024 * 1024,
description="Max declared uncompressed total size for one ZIP",
)
max_concurrent_ingests: int = Field(
default=4,
ge=1,
le=64,
description=(
"Parallel ingestion workers. FAISS uses one process-wide index guarded by a lock; "
"raising this mainly increases how many files parse/embed in parallel before indexing."
),
)
max_batch_status_document_ids: int = Field(
default=10_000,
ge=100,
le=100_000,
description="Max document IDs per /documents/batch-status call",
)
documents_list_max_limit: int = Field(
default=500,
ge=10,
le=2000,
description="Max rows returned by GET /documents",
)
# ── Cache ─────────────────────────────────────────────────────────────────
cache_dir: Path = Field(
default_factory=lambda: Path(tempfile.gettempdir()) / "section_cache"
)
# ── Report section photos ────────────────────────────────────────────────
max_section_photo_bytes: int = Field(
default=8 * 1024 * 1024,
ge=256 * 1024,
le=50 * 1024 * 1024,
description="Max size per uploaded section photo (bytes).",
)
max_section_photos_per_section: int = Field(
default=5,
ge=0,
le=50,
description="Max number of photos stored per report section.",
)
max_section_photos_for_ai: int = Field(
default=2,
ge=0,
le=10,
description="Max photos per section that may be ticked for AI vision analysis during generation.",
)
section_photo_vision_enabled: bool = Field(
default=True,
description=(
"If true and OPENAI_API_KEY is set, the server may analyze uploaded photos with a vision-capable model "
"to produce additional observations for generation."
),
)
section_photo_vision_batch_size: int = Field(
default=2,
ge=1,
le=20,
description=(
"Max images per vision API request (matches default AI selection cap; only selected photos are sent)."
),
)
section_photo_vision_max_observations: int = Field(
default=48,
ge=12,
le=120,
description="Upper cap on merged bullet observations after analyzing all photos for a section.",
)
section_photo_policy_data_driven: bool = Field(
default=True,
description=(
"If true, the server scans each tenant's completed PDF/DOCX uploads (the same indexed files used for "
"tenant RAG) for embedded images per RICS section. No letter-based heuristic; knowledge_base_dirs is "
"not used for this product feature."
),
)
section_photo_policy_cache_seconds: int = Field(
default=6 * 60 * 60,
ge=60,
le=7 * 24 * 60 * 60,
description="TTL for cached photo-policy corpus statistics (seconds).",
)
section_photo_policy_min_examples: int = Field(
default=3,
ge=1,
le=1_000_000,
description=(
"Minimum number of tenant indexed uploads in which a RICS section heading must appear before "
"photo-upload rules are inferred for that section."
),
)
section_photo_policy_tenant_consensus_ratio: float = Field(
default=1.0,
ge=0.0,
le=1.0,
description=(
"Among tenant uploads where a section heading is detected, minimum fraction that must also contain "
"an image in that section before enabling photo upload (1.0 = every such upload)."
),
)
section_photo_policy_overrides_json: str = Field(
default="",
description=(
"Optional JSON object mapping section_code (e.g. \"E2\") to REQUIRES_IMAGE, OPTIONAL_IMAGE, "
"or NO_IMAGE_NEEDED. When set for a section, it replaces tenant-library-derived policy for that code."
),
)
# ── Generation limits ────────────────────────────────────────────────────
max_context_tokens: int = Field(default=400, ge=100, le=2000)
max_output_tokens: int = Field(default=300, ge=50, le=1000)
retrieval_top_k: int = Field(default=10, ge=1, le=50)
rerank_top_n: int = Field(default=3, ge=1, le=10)
rag_doc_context_max_chunks: int = Field(
default=6,
ge=0,
le=24,
description="Total cap for document-level RAG excerpts (whole-PDF narrative).",
)
rag_doc_chunks_primary: int = Field(
default=4,
ge=0,
le=16,
description="Max document-level chunks taken from the report's primary survey PDF.",
)
rag_doc_chunks_per_reference: int = Field(
default=2,
ge=0,
le=8,
description="Max document-level chunks per exemplar/reference PDF (e.g. past RICS report).",
)
hierarchical_rag_enabled: bool = Field(
default=True,
description=(
"Use coarse→fine retrieval (document → section → paragraph) when the index "
"contains hierarchy_level metadata; falls back to flat retrieval if needed."
),
)
hierarchical_k_document: int = Field(default=4, ge=0, le=16)
hierarchical_k_section: int = Field(default=8, ge=0, le=32)
hierarchical_k_paragraph_pool: int = Field(default=36, ge=4, le=120)
rics_exemplar_document_ids: str = Field(
default="",
description=(
"Optional comma-separated upload UUIDs (e.g. Behrang / template RICS PDFs) "
"always considered as reference documents for hierarchical routing."
),
)
# ── Knowledge base (local standards corpus, optional) ────────────────────
knowledge_base_enabled: bool = Field(
default=True,
description=(
"If true, the server can index local RICS standards/exemplar documents "
"from knowledge_base_dirs into the vector store under a reserved tenant."
),
)
knowledge_base_tenant_id: str = Field(
default="__rics_kb__",
description="Reserved tenant_id used for the local knowledge base corpus.",
)
knowledge_base_dirs: str = Field(
default="Behrang RICS Documents,RAW Context",
description=(
"Comma-separated folder names/paths (relative to repo root or absolute) "
"to scan for local standards/exemplar PDFs/DOCX."
),
)
rics_standard_paragraphs_docx: str = Field(
default="HB-BS STANDARD PARAS v6 Sept 2015.doc",
description=(
"Path to the master standard-paragraphs Word file (repo-relative or absolute): "
".docx / .docm parsed directly; legacy .doc converted via pandoc, LibreOffice, or "
"Microsoft Word+pywin32 (Windows). When non-empty and resolved, **only** this file loads. "
"If the path is missing, the basename is also tried at the repo root. "
"When empty, discovery uses standard_paragraphs_docx_globs under knowledge_base_dirs."
),
)
standard_paragraphs_docx_globs: str = Field(
default=(
"*standard*paragraph*.docx,*Standard*Paragraph*.docx,"
"*HB-BS*STANDARD*PARAS*.doc,*HB-BS*STANDARD*PARAS*.docx"
),
description=(
"Used only when rics_standard_paragraphs_docx is empty: comma-separated filename glob patterns "
"(case-insensitive) scanned under each knowledge_base_dir root for .docx / .docm / .doc."
),
)
survey_level_corpus_cache_seconds: int = Field(
default=6 * 60 * 60,
ge=60,
le=7 * 24 * 60 * 60,
description="TTL for cached survey-tier corpus profiles built from knowledge_base_dirs (seconds).",
)
chunk_size: int = Field(default=500, ge=100, le=2000)
# ── Citation-grounded extraction (anti-hallucination layer) ──────────────
enable_citation_extraction: bool = Field(
default=True,
description=(
"Run schema-constrained, citation-grounded extraction + contradiction "
"audit alongside generation. Findings unsupported by retrieved source "
"spans are dropped and surfaced as a per-section audit (confidence, "
"contradictions, dropped claims). Adds one deterministic LLM call per "
"section; degrades to a no-op when no OpenAI key is configured."
),
)
extraction_max_tokens: int = Field(
default=1500,
ge=256,
le=8000,
description="Max output tokens for the deterministic extraction call.",
)
rag_use_full_tenant_library: bool = Field(
default=True,
description=(
"When true, report generation retrieves from ALL of the tenant's "
"ingested report-source documents (old + new uploads), not only the "
"file attached when the report was created. The report's own upload "
"is still prioritised. style_corpus (past completed reports) remain "
"excluded from factual retrieval, and tenant isolation is preserved. "
"Set false to restore strict per-report document isolation."
),
)
max_bullets_per_section: int = Field(
default=800,
ge=20,
le=5000,
description=(
"Max inspector note lines passed to expansion, retrieval query, and generation per section. "
"Raise for very long field-note dumps; lower only if prompts hit model context limits."
),
)
notes_prompt_token_budget: int = Field(
default=3500,
ge=400,
le=12000,
description="Token budget reserved for RAW NOTES in the generate prompt (separate from RAG snippet budget).",
)
chunk_overlap: int = Field(default=75, ge=0, le=200)
# ── Autonomous RICS inspector (OpenAI tool-calling) ───────────────────────
inspector_tool_agent: bool = Field(
default=True,
description=(
"When true and OPENAI_API_KEY is set, agentic section generation uses an OpenAI "
"tool-calling loop so the model chooses retrieval/similarity tools; otherwise the "
"legacy fixed pipeline runs (including mock adapter in tests)."
),
)
inspector_max_tool_rounds: int = Field(
default=12,
ge=3,
le=40,
description="Max assistant turns (each may include multiple tool calls) for the inspector agent.",
)
# ── Generation pipeline selection ─────────────────────────────────────────
primary_generate_pipeline: str = Field(
default="standard",
description=(
"Which pipeline powers POST /reports/{report_id}/generate for mode=generate. "
"'standard' (default) = fixed RAG generator: expand notes β†’ retrieve β†’ LLM adapt. "
"'agentic' = inspector HeadAgent first (tool-calling when live), then fall back to standard on error."
),
)
# ── Notes-only generation (anti-leak) ────────────────────────────────────
notes_only_generation: bool = Field(
default=True,
description=(
"When true, generated report content must be grounded ONLY in the user-supplied bullets/draft text "
"(uploaded messy notes). Tenant RAG / exemplar documents may be used as *reference-only* guidance to "
"help interpret messy notes and follow structure/tone, but are not allowed to contribute property-specific "
"facts. In this mode, provenance/citations are suppressed so no RAG content is exposed to the user."
),
)
agentic_inspector_when_notes_only: bool = Field(
default=False,
description=(
"When true with notes_only_generation, POST /generate still uses the inspector HeadAgent "
"(tool-calling + Phase 2 speculation when enabled). Default false keeps the standard fixed pipeline "
"for notes-only anti-leak behaviour."
),
)
enable_unverified_term_flagging: bool = Field(
default=True,
description=(
"When true, surveyor notes are pre-processed before the LLM sees them: known "
"transcription typos are normalised and unrecognised technical terms (e.g. "
"'astratin support') are wrapped in [UNVERIFIED_TERM: ...] so the model emits a "
"'surveyor to confirm terminology' placeholder instead of publishing the artefact."
),
)
standard_paragraphs_shape_wording: bool = Field(
default=True,
description=(
"Hybrid generation: the firm's OWN per-section standard paragraphs drive the "
"wording/structure of the output at EVERY AI-involvement tier (not only assembly), "
"while the surveyor notes drive all property-specific facts. The firm master is a "
"template (placeholders like <text> and option brackets, no real property data), so "
"it is exempt from notes-only redaction. RAG uploads from other reports remain "
"reference-only and redacted, preserving the anti-leak guarantee."
),
)
# ── LLM compliance validation (generate mode) ────────────────────────────
llm_section_validator_enabled: bool = Field(
default=False,
description=(
"If true and OPENAI_API_KEY is set, run an additional LLM-based compliance validator after "
"generation (survey-level behavioural checks). On FAIL, regenerate once with feedback. "
"Deterministic guards (non-invention + heuristic tier validator) still run regardless."
),
)
llm_section_validator_max_retries: int = Field(
default=1,
ge=0,
le=3,
description="Max regenerate attempts when LLM validator returns FAIL.",
)
# ── Async pipeline / concurrency guards (latency optimisation) ─────────
enable_async_pipeline: bool = Field(
default=False,
description=(
"When true, use the async LLM pipeline (async OpenAI calls, parallel "
"multi-section generation, async retrieval when Qdrant is enabled)."
),
)
max_concurrent_llm_calls: int = Field(
default=10,
ge=1,
le=2000,
description=(
"Global upper bound on concurrent in-flight LLM calls to avoid "
"rate-limit storms. Throttles async OpenAI paths (async pipeline, inspector loop, "
"and throttled chat completions) with structured cache_hit logging."
),
)
section_generation_concurrency: int = Field(
default=6,
ge=1,
le=32,
description=(
"Max report sections generated concurrently within a single multi-section job. "
"Section work is dominated by I/O-bound LLM calls, so concurrency cuts wall-clock "
"time roughly linearly until max_concurrent_llm_calls (or the provider rate limit) "
"becomes the bottleneck. Set to 1 to force fully sequential generation."
),
)
max_coverage_retries: int = Field(
default=3,
ge=0,
le=5,
description=(
"Max regeneration attempts when a section drops raw-notes coverage below "
"threshold. Intermediate attempts use the cheap regex grounding pass; the "
"full LLM grounding pass runs once on the winning draft."
),
)
# ── Phase 2: speculation, prompt cache, durable workflows ────────────────
enable_speculative_executor: bool = Field(
default=False,
description=(
"When true with enable_async_pipeline, prefetch inspector tools using "
"PatternRegistry before the LLM requests them."
),
)
speculative_context_window: int = Field(default=3, ge=1, le=12)
speculative_probability_threshold: float = Field(default=0.75, ge=0.5, le=1.0)
speculative_learn_min_observations: int = Field(
default=5,
ge=3,
le=100,
description=(
"Minimum times a context→tool sequence must be observed before "
"PatternRegistry auto-registers a learned speculative pattern."
),
)
allow_sqlite_parallel_sections: bool = Field(
default=True,
description=(
"Allow parallel multi-section generation against SQLite (uses WAL + busy_timeout). "
"Set false if you see 'database is locked' under heavy parallel writes."
),
)
enable_parallel_section_generation: bool = Field(
default=True,
description=(
"When true, multi-section generate/proofread/enhance jobs run sections "
"concurrently (bounded by max_parallel_sections). Does not require "
"ENABLE_ASYNC_PIPELINE."
),
)
max_parallel_sections: int = Field(
default=6,
ge=1,
le=32,
description="Max sections processed in parallel during a multi-section job.",
)
notes_coverage_max_retries: int = Field(
default=1,
ge=0,
le=5,
description=(
"Extra LLM attempts when raw-notes coverage is below threshold "
"(0 disables coverage retries)."
),
)
notes_expansion_skip_llm_max_bullets: int = Field(
default=4,
ge=0,
le=20,
description=(
"When a section has at most this many bullets, use rule-based notes "
"expansion instead of an LLM call."
),
)
skip_llm_grounding_when_notes_only: bool = Field(
default=True,
description=(
"In notes-only mode, skip the extra LLM grounding pass after generation "
"(regex grounding still runs)."
),
)
skip_compliance_retries_when_notes_only: bool = Field(
default=True,
description=(
"In notes-only mode, skip identity/tier validation LLM retries "
"(saves 1–2 calls per section)."
),
)
enable_prompt_caching: bool = Field(
default=False,
description=(
"When true with enable_async_pipeline, stabilise system prefixes and "
"pass OpenAI prompt_cache_key; log cache_hit_rate from usage."
),
)
prompt_cache_min_system_tokens: int = Field(
default=1024,
ge=512,
le=8192,
description="Minimum system-prompt tokens to target OpenAI automatic prefix caching.",
)
enable_temporal_workflow: bool = Field(
default=False,
description=(
"When true, POST /generate starts a Temporal ReportGenerationWorkflow "
"instead of an in-process asyncio task (requires temporal-worker)."
),
)
temporal_host: str = Field(
default="localhost:7233",
description="Temporal frontend gRPC address (host:port).",
)
temporal_namespace: str = Field(default="default")
temporal_task_queue: str = Field(default="reports")
generation_timeout_seconds: int = Field(
default=3600,
ge=300,
le=24 * 60 * 60,
description="Reports in generating longer than this are marked failed by the sweeper.",
)
generation_stale_sweep_seconds: int = Field(
default=120,
ge=0,
le=3600,
description="Interval for stale generating-report sweeper (0 disables).",
)
# ── Phase 3: Redis coordination (rate limits + job queue) ───────────────
redis_url: str = Field(
default="",
description=(
"Redis URL for distributed rate limiting and optional generation job queue. "
"Example: redis://localhost:6379/0"
),
)
enable_job_queue: bool = Field(
default=False,
description=(
"When true with redis_url set, POST /generate and /agentic/generate enqueue "
"work to Redis for jobs_worker.py instead of in-process asyncio tasks."
),
)
job_queue_key: str = Field(
default="rics:jobs:generation",
description="Redis list key for generation job payloads (JSON).",
)
job_queue_block_seconds: int = Field(
default=5,
ge=1,
le=60,
description="BRPOP timeout for the jobs worker loop.",
)
job_queue_max_concurrent: int = Field(
default=2,
ge=1,
le=32,
description="Max generation jobs processed in parallel per jobs worker process.",
)
ingest_timeout_seconds: int = Field(
default=900,
ge=30,
le=24 * 60 * 60,
description=(
"Max time allowed for a single document ingest (parse/split/embed/index). "
"Stale 'processing' documents beyond this are marked failed so the UI can continue."
),
)
index_repair_on_startup: bool = Field(
default=True,
description=(
"When true, after startup the server re-queues DB rows marked complete but "
"missing from the FAISS index (e.g. after index corruption or dimension reset)."
),
)
index_repair_startup_limit: int = Field(
default=8,
ge=0,
le=500,
description=(
"Max documents to auto-repair on startup (0 = unlimited). Caps background "
"re-ingestion so new user uploads are not stuck behind a full historical backlog."
),
)
index_repair_after_kb: bool = Field(
default=True,
description=(
"When true, defer startup index repair until the knowledge-base upsert "
"finishes so repair does not race KB reindex or duplicate work."
),
)
knowledge_base_skip_unchanged: bool = Field(
default=True,
description=(
"When true, skip re-indexing KB files whose mtime+size match the last "
"successful ingest (manifest beside the FAISS index)."
),
)
# ── RAG upload sanitisation (PII/confidential stripping before indexing) ─
enable_rag_upload_sanitisation: bool = Field(
default=True,
description=(
"When true, tenant uploads are sanitised (LLM + regex fallback) before "
"text is embedded into the vector index for style/RAG retrieval. On-disk files are not rewritten."
),
)
rag_sanitisation_skip_kb_tenant: bool = Field(
default=True,
description="Skip sanitisation for the reserved knowledge-base tenant (e.g. __rics_kb__).",
)
rag_sanitisation_chunk_chars: int = Field(
default=12_000,
ge=2_000,
le=50_000,
description="Max characters per LLM sanitisation call for long reports.",
)
rag_sanitisation_max_output_tokens: int = Field(
default=4096,
ge=512,
le=16_384,
description="Max tokens per sanitisation LLM response chunk.",
)
rag_sanitisation_fail_closed: bool = Field(
default=True,
description=(
"If true, ingestion/runtime RAG sync fails when sanitisation would store empty text. "
"When false, regex fallback is used even when the LLM returns nothing."
),
)
# ── Rate limiting ────────────────────────────────────────────────────────
rate_limit_generate_rpm: int = Field(
default=20,
ge=1,
le=10_000,
description="Max POST /generate requests per tenant per minute",
)
rate_limit_read_rpm: int = Field(
default=120,
ge=1,
le=10_000,
description="Max read-endpoint requests per tenant per minute",
)
# ── Security ─────────────────────────────────────────────────────────────
tenant_secret_key: str = Field(default="dev-secret-change-me")
auth_token_ttl_seconds: int = Field(
default=7 * 24 * 3600,
ge=300,
le=90 * 24 * 3600,
description="Lifetime of an issued tenant bearer token, in seconds (default 7 days).",
)
dev_mode: bool = Field(default=False)
# CORS: list specific origins in production, e.g. ["https://app.example.com"]
# The wildcard ["*"] is safe here because allow_credentials=False in main.py
allowed_origins: list[str] = Field(default=["*"])
@model_validator(mode="after")
def _sync_openai_key_from_process_env(self) -> Self:
"""HF Space runtime tweaks (secrets + sensible free-tier defaults)."""
if not os.environ.get("SPACE_ID"):
return self
env_key = (os.environ.get("OPENAI_API_KEY") or "").strip()
if env_key:
self.openai_api_key = env_key
# RAG upload sanitisation runs one LLM call per PDF page before indexing.
# On HF CPU Spaces that turns a ~30s ingest into 5+ minutes per file.
# Opt in explicitly via ENABLE_RAG_UPLOAD_SANITISATION=true; style_corpus
# uploads still force sanitisation regardless of this flag.
if "ENABLE_RAG_UPLOAD_SANITISATION" not in os.environ:
self.enable_rag_upload_sanitisation = False
if "MAX_CONCURRENT_INGESTS" not in os.environ:
self.max_concurrent_ingests = min(int(self.max_concurrent_ingests), 2)
# Stable token signing across Space restarts (must match Space secret).
env_secret = (os.environ.get("TENANT_SECRET_KEY") or "").strip()
if env_secret and env_secret not in {"", "dev-secret-change-me", "change-me-in-production"}:
self.tenant_secret_key = env_secret
return self
settings = Settings()
def effective_openai_api_key() -> str:
"""Resolved API key from settings and process env (HF secrets use the latter)."""
return (settings.openai_api_key or os.environ.get("OPENAI_API_KEY") or "").strip()
def get_settings() -> Settings:
"""Return the application-wide settings singleton."""
return settings