Spaces:
Sleeping
Sleeping
| """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=["*"]) | |
| 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 | |
| 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 | |