RandomZ / app /config.py
StormShadow308's picture
Fix HF startup when OPENAI_API_KEY secret is missing or only in Space env.
970bad4
Raw
History Blame Contribute Delete
28.2 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=8,
ge=0,
le=50,
description="Max number of photos stored per report section.",
)
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=6,
ge=1,
le=20,
description=(
"Max images per vision API request; all uploaded section photos are covered by sequential batches."
),
)
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_vision_model: str = Field(
default="",
description=(
"OpenAI model for section photo vision analysis. "
"When empty, uses chat_model if set, otherwise gpt-4o."
),
)
section_photo_analyze_on_upload: bool = Field(
default=True,
description=(
"When true and OPENAI_API_KEY is set, run vision analysis after section photo upload "
"(cached per section) so generate does not wait on first vision call."
),
)
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="Behrang RICS Documents/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)
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="agentic",
description=(
"Which pipeline powers POST /reports/{report_id}/generate for mode=generate. "
"'agentic' = use the inspector HeadAgent first (tool-calling when live), then fall back to the standard "
"fixed RAG generator on error. "
"'standard' = always use the fixed RAG generator."
),
)
# ── 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=True,
description=(
"When true with notes_only_generation, POST /generate still uses the inspector HeadAgent "
"(tool-calling, photo vision enrichment, Phase 2 speculation when enabled). "
"Set false only if you need the lighter standard notes-only pipeline."
),
)
# ── 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.",
)
# ── Backend scale profile (AI Phase 3 flags + job queue; not for HF Spaces) ─
scale_optimization_profile: bool = Field(
default=False,
description=(
"When true (non-HF), enables AI Phase 3 flags (async pipeline, speculation, "
"prompt cache) and ENABLE_JOB_QUEUE when REDIS_URL is set. "
"See docs/AI_FEATURES_PHASES.md. Use with docker compose --profile redis --profile jobs."
),
)
# ── 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."
),
)
# ── AI Phase 3 + backend Temporal (speculation/prompt cache are AI; Temporal is not) ─
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=False,
description=(
"Allow parallel multi-section generation against SQLite. Default false "
"because SQLite serializes writers and can cause database locked errors."
),
)
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_sla_seconds: int = Field(
default=600,
ge=120,
le=3600,
description=(
"Product target: full multi-section report generation should finish within this "
"wall-clock budget when async parallel sections are enabled (default 10 minutes)."
),
)
generation_timeout_seconds: int = Field(
default=720,
ge=300,
le=24 * 60 * 60,
description=(
"Hard limit: reports in generating longer than this are marked failed by the sweeper. "
"Defaults to generation_sla_seconds + 2 minutes grace."
),
)
generation_stale_sweep_seconds: int = Field(
default=120,
ge=0,
le=3600,
description="Interval for stale generating-report sweeper (0 disables).",
)
# ── Backend: Redis (rate limits + job queue β€” not an AI phase) ───────────
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."
),
)
# ── Production / HF Spaces (prioritise user-facing AI over dev infra) ─────
production_ai_profile: bool = Field(
default=False,
description=(
"When true (or when running on Hugging Face Spaces via SPACE_ID), ingest-time "
"LLM sanitisation is disabled (regex redaction still runs when "
"ENABLE_RAG_UPLOAD_SANITISATION=true) so OpenAI quota is reserved for generation, "
"inspector, and photo vision."
),
)
# ── Personalised style RAG (private tenant library; not OpenAI fine-tuning) ─
personalised_style_rag_enabled: bool = Field(
default=True,
description=(
"When true: uploads are sanitised and indexed per tenant; generation retrieves "
"only from that tenant's ingested report library (not shared KB). KB/style fallback "
"applies only before the tenant has completed uploads."
),
)
# ── RAG upload sanitisation (PII/confidential stripping before indexing) ─
enable_rag_upload_sanitisation: bool = Field(
default=True,
description=(
"When true, tenant uploads are sanitised before embedding into the vector index. "
"Use RAG_SANITISATION_USE_LLM=true for LLM chunks (dev/staging); regex-only is faster "
"and does not compete with report generation. On-disk files are not rewritten."
),
)
rag_sanitisation_use_llm: bool = Field(
default=False,
description=(
"When true with ENABLE_RAG_UPLOAD_SANITISATION, run LLM sanitisation per chunk. "
"When false, regex-only redaction (recommended for production/HF)."
),
)
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=20_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")
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 secrets inject OPENAI_API_KEY at runtime (not in the image)."""
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
return self
@model_validator(mode="after")
def _apply_deployment_profile(self) -> Self:
"""HF Spaces and production_ai_profile reserve OpenAI for report AI features."""
on_hf_space = bool(os.environ.get("SPACE_ID"))
if self.personalised_style_rag_enabled:
self.enable_rag_upload_sanitisation = True
if self.production_ai_profile or on_hf_space:
# Regex sanitisation stays on (privacy); only disable per-chunk LLM ingest calls.
self.rag_sanitisation_use_llm = False
# Meet ~10m full-report SLA: parallel multi-section + async OpenAI (no Redis required).
if not self.scale_optimization_profile:
self.enable_async_pipeline = True
self.allow_sqlite_parallel_sections = True
if self.generation_timeout_seconds < self.generation_sla_seconds + 60:
self.generation_timeout_seconds = int(self.generation_sla_seconds) + 120
if self.scale_optimization_profile and not on_hf_space:
self.enable_async_pipeline = True
self.enable_speculative_executor = True
self.enable_prompt_caching = True
self.allow_sqlite_parallel_sections = True
if (self.redis_url or "").strip():
self.enable_job_queue = True
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