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