"""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 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