Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
60 KiB
JSON

{"repo": "balizero1987/ayo", "files": {"backend/services/llm_clients/deepseek_client.py": {"23cd129904123772abdfe94e41758c4e74cb2b80": {"t": 1785745440.041311, "n": 6588, "h": "734ad4a71f2313f4", "c": "\"\"\"\nDeepSeek V3 Client - Cheapest LLM API fallback\n\nPricing: $0.27/1M input, $1.10/1M output (December 2025)\nAPI: OpenAI-compatible\n\nFallback chain: Gemini 2.0 Flash → DeepSeek V3 → OpenRouter (free models)\n\"\"\"\n\nimport logging\nfrom collections.abc import AsyncGenerator\nfrom dataclasses import dataclass\n\nimport httpx\n\nfrom app.core.config import settings\n\nlogger = logging.getLogger(__name__)\n\n\n@dataclass\nclass DeepSeekResponse:\n \"\"\"Response from DeepSeek API\"\"\"\n\n content: str\n model_name: str\n input_tokens: int\n output_tokens: int\n finish_reason: str\n\n\nclass DeepSeekClient:\n \"\"\"\n DeepSeek V3 Client - Cheapest available LLM API.\n\n Pricing (December 2025):\n - Input: $0.27 per 1M tokens\n - Output: $1.10 per 1M tokens\n - Context: 64k tokens\n\n Use as fallback when Gemini quota is exhausted.\n \"\"\"\n\n BASE_URL = \"https://api.deepseek.com/v1\"\n DEFAULT_MODEL = \"deepseek-chat\" # DeepSeek V3\n\n def __init__(self, api_key: str | None = None):\n \"\"\"\n Initialize DeepSeek client.\n\n Args:\n api_key: DeepSeek API key (defaults to settings.deepseek_api_key)\n \"\"\"\n self.api_key = api_key or settings.deepseek_api_key\n if not self.api_key:\n logger.warning(\"⚠️ DeepSeek API key not set. Set DEEPSEEK_API_KEY env var.\")\n\n @property\n def is_available(self) -> bool:\n \"\"\"Check if DeepSeek is configured and available\"\"\"\n return bool(self.api_key)\n\n async def complete(\n self,\n messages: list[dict],\n model: str = DEFAULT_MODEL,\n temperature: float = 0.7,\n max_tokens: int = 8192,\n ) -> DeepSeekResponse:\n \"\"\"\n Generate completion from DeepSeek.\n\n Args:\n messages: List of messages in OpenAI format\n model: Model to use (default: deepseek-chat)\n temperature: Sampling temperature (0.0-2.0)\n max_tokens: Maximum tokens to generate\n\n Returns:\n DeepSeekResponse with content and metadata\n \"\"\"\n if not self.api_key:\n raise RuntimeError(\"DeepSeek API key not configured\")\n\n async with httpx.AsyncClient(timeout=60.0) as client:\n response = await client.post(\n f\"{self.BASE_URL}/chat/completions\",\n headers={\n \"Authorization\": f\"Bearer {self.api_key}\",\n \"Content-Type\": \"application/json\",\n },\n json={\n \"model\": model,\n \"messages\": messages,\n \"temperature\": temperature,\n \"max_tokens\": max_tokens,\n },\n )\n\n if response.status_code != 200:\n error_text = response.text\n logger.error(f\"DeepSeek API error: {response.status_code} - {error_text}\")\n raise RuntimeError(f\"DeepSeek API error: {response.status_code}\")\n\n data = response.json()\n\n return DeepSeekResponse(\n content=data[\"choices\"][0][\"message\"][\"content\"],\n model_name=data.get(\"model\", model),\n input_tokens=data.get(\"usage\", {}).get(\"prompt_tokens\", 0),\n output_tokens=data.get(\"usage\", {}).get(\"completion_tokens\", 0),\n finish_reason=data[\"choices\"][0].get(\"finish_reason\", \"stop\"),\n )\n\n async def complete_stream(\n self,\n messages: list[dict],\n model: str = DEFAULT_MODEL,\n temperature: float = 0.7,\n max_tokens: int = 8192,\n ) -> AsyncGenerator[str, None]:\n \"\"\"\n Stream completion from DeepSeek.\n\n Args:\n messages: List of messages in OpenAI format\n model: Model to use\n temperature: Sampling temperature\n max_tokens: Maximum tokens to generate\n\n Yields:\n Text chunks as they are generated\n \"\"\"\n if not self.api_key:\n raise RuntimeError(\"DeepSeek API key not configured\")\n\n async with (\n httpx.AsyncClient(timeout=120.0) as client,\n client.stream(\n \"POST\",\n f\"{self.BASE_URL}/chat/completions\",\n headers={\n \"Authorization\": f\"Bearer {self.api_key}\",\n \"Content-Type\": \"application/json\",\n },\n json={\n \"model\": model,\n \"messages\": messages,\n \"temperature\": temperature,\n \"max_tokens\": max_tokens,\n \"stream\": True,\n },\n ) as response,\n ):\n if response.status_code != 200:\n error_text = await response.aread()\n logger.error(f\"DeepSeek streaming error: {response.status_code}\")\n raise RuntimeError(f\"DeepSeek API error: {response.status_code}\")\n\n async for line in response.aiter_lines():\n if line.startswith(\"data: \"):\n data_str = line[6:]\n if data_str == \"[DONE]\":\n break\n\n try:\n import json\n\n data = json.loads(data_str)\n delta = data.get(\"choices\", [{}])[0].get(\"delta\", {})\n content = delta.get(\"content\", \"\")\n if content:\n yield content\n except Exception as e:\n logger.warning(f\"Failed to parse DeepSeek stream: {e}\")\n continue\n\n\ndeepseek_client = DeepSeekClient()\n\n\nif __name__ == \"__main__\":\n import asyncio\n\n async def test():\n logger.info(\"🚀 Testing DeepSeek V3 Client...\")\n logger.info(f\" API Key: {'✅ Set' if settings.deepseek_api_key else '❌ Not set'}\")\n\n if not settings.deepseek_api_key:\n logger.info(\"Set DEEPSEEK_API_KEY to test\")\n return\n\n client = DeepSeekClient()\n\n messages = [\n {\"role\": \"system\", \"content\": \"You are a helpful assistant.\"},\n {\"role\": \"user\", \"content\": \"What is 2+2? Answer in one word.\"},\n ]\n\n try:\n result = await client.complete(messages)\n logger.info(f\"✅ Response: {result.content}\")\n logger.info(f\" Model: {result.model_name}\")\n logger.info(f\" Tokens: {result.input_tokens} in, {result.output_tokens} out\")\n except Exception as e:\n logger.error(f\"❌ Error: {e}\")\n\n asyncio.run(test())\n"}}, "tests/api/test_search_service_coverage_95.py": {"23cd129904123772abdfe94e41758c4e74cb2b80": {"t": 1786068243.9405675, "n": 23852, "h": "688e109b482a49f9", "c": "\"\"\"\nAPI Tests for SearchService - Coverage 95% Target\nTests SearchService methods via API endpoints\n\nCoverage:\n- search method (via oracle_universal endpoint)\n- search_with_conflict_resolution method\n- _build_search_filter method\n\"\"\"\n\nimport os\nimport sys\nfrom pathlib import Path\nfrom unittest.mock import AsyncMock, MagicMock\n\nimport pytest\n\n# Set required environment variables BEFORE any imports\nos.environ.setdefault(\"ENVIRONMENT\", \"development\")\nos.environ.setdefault(\"JWT_SECRET_KEY\", \"test_jwt_secret_key_for_testing_only_min_32_chars\")\nos.environ.setdefault(\"JWT_ALGORITHM\", \"HS256\")\nos.environ.setdefault(\"API_KEYS\", \"test_api_key_1,test_api_key_2\")\nos.environ.setdefault(\"OPENAI_API_KEY\", \"sk-REDACTED\")\nos.environ.setdefault(\"GOOGLE_API_KEY\", \"test_google_api_key_for_testing\")\nos.environ.setdefault(\"QDRANT_URL\", \"http://localhost:6333\")\nos.environ.setdefault(\"DATABASE_URL\", \"postgresql://test:test@localhost:5432/test\")\nos.environ.setdefault(\"DEEPSEEK_API_KEY\", \"test_deepseek_api_key_for_testing\")\nos.environ.setdefault(\"REDIS_URL\", \"redis://localhost:6379\")\nos.environ.setdefault(\"WHATSAPP_VERIFY_TOKEN\", \"test_whatsapp_verify_token\")\nos.environ.setdefault(\"INSTAGRAM_VERIFY_TOKEN\", \"test_instagram_verify_token\")\n\n# Add backend directory to Python path\nbackend_path = Path(__file__).parent.parent.parent / \"backend\"\nif str(backend_path) not in sys.path:\n sys.path.insert(0, str(backend_path))\n\n\nclass TestSearchService:\n \"\"\"Test SearchService methods\"\"\"\n\n @pytest.mark.asyncio\n async def test_search_basic(self):\n \"\"\"Test basic search method\"\"\"\n from backend.services.conflict_resolver import ConflictResolver\n from backend.services.cultural_insights_service import CulturalInsightsService\n from backend.services.search_service import SearchService\n\n # Mock dependencies\n mock_collection_manager = MagicMock()\n mock_vector_db = MagicMock()\n mock_vector_db.search = AsyncMock(\n return_value={\n \"documents\": [[\"Document 1\", \"Document 2\"]],\n \"distances\": [[0.1, 0.2]],\n \"metadatas\": [[{\"title\": \"Doc 1\"}, {\"title\": \"Doc 2\"}]],\n \"ids\": [[\"id1\", \"id2\"]],\n }\n )\n mock_collection_manager.get_collection = MagicMock(return_value=mock_vector_db)\n\n mock_query_router = MagicMock()\n mock_query_router.route_query = MagicMock(\n return_value={\"collection_name\": \"visa_oracle\", \"confidence\": 0.8, \"fallbacks\": []}\n )\n\n mock_embedder = MagicMock()\n mock_embedder.generate_query_embedding = MagicMock(return_value=[0.1] * 384)\n\n service = SearchService(\n collection_manager=mock_collection_manager,\n conflict_resolver=ConflictResolver(),\n cultural_insights=CulturalInsightsService(\n collection_manager=mock_collection_manager, embedder=mock_embedder\n ),\n query_router=mock_query_router,\n )\n service.embedder = mock_embedder\n\n result = await service.search(query=\"visa question\", user_level=2, limit=5)\n\n assert \"query\" in result\n assert \"results\" in result\n assert \"collection_used\" in result\n assert result[\"query\"] == \"visa question\"\n\n @pytest.mark.asyncio\n async def test_search_with_reranking(self):\n \"\"\"Test search_with_reranking method\"\"\"\n from unittest.mock import Mock\n\n from backend.services.conflict_resolver import ConflictResolver\n from backend.services.cultural_insights_service import CulturalInsightsService\n from backend.services.search_service import SearchService\n\n mock_collection_manager = MagicMock()\n mock_vector_db = MagicMock()\n mock_vector_db.search = AsyncMock(\n return_value={\n \"documents\": [\"Document 1\", \"Document 2\", \"Document 3\"],\n \"distances\": [0.1, 0.2, 0.3],\n \"metadatas\": [{\"title\": \"Doc 1\"}, {\"title\": \"Doc 2\"}, {\"title\": \"Doc 3\"}],\n \"ids\": [\"id1\", \"id2\", \"id3\"],\n }\n )\n mock_collection_manager.get_collection = MagicMock(return_value=mock_vector_db)\n\n mock_query_router = MagicMock()\n mock_query_router.route_query = MagicMock(\n return_value={\"collection_name\": \"visa_oracle\", \"confidence\": 0.8, \"is_pricing\": False}\n )\n\n mock_embedder = MagicMock()\n mock_embedder.generate_query_embedding = MagicMock(return_value=[0.1] * 384)\n\n service = SearchService(\n collection_manager=mock_collection_manager,\n conflict_resolver=ConflictResolver(),\n cultural_insights=CulturalInsightsService(\n collection_manager=mock_collection_manager, embedder=mock_embedder\n ),\n query_router=mock_query_router,\n )\n service.embedder = mock_embedder\n\n # Mock reranker\n mock_reranker = Mock()\n mock_reranker.enabled = True\n mock_reranker.rerank = AsyncMock(\n return_value=[\n {\"text\": \"Document 2\", \"score\": 0.95, \"metadata\": {\"title\": \"Doc 2\"}},\n {\"text\": \"Document 1\", \"score\": 0.90, \"metadata\": {\"title\": \"Doc 1\"}},\n ]\n )\n service._reranker = mock_reranker\n\n result = await service.search_with_reranking(query=\"visa question\", user_level=2, limit=2)\n\n assert \"query\" in result\n assert \"results\" in result\n assert result.get(\"reranked\") is True\n assert len(result[\"results\"]) <= 2\n mock_reranker.rerank.assert_called_once()\n\n @pytest.mark.asyncio\n async def test_search_filters_enabled(self):\n \"\"\"Test that filters are enabled by default (no NUCLEAR OVERRIDE)\"\"\"\n from backend.services.conflict_resolver import ConflictResolver\n from backend.services.cultural_insights_service import CulturalInsightsService\n from backend.services.search_service import SearchService\n\n captured_filter = None\n\n async def capture_search(query_embedding, filter=None, limit=5):\n nonlocal captured_filter\n captured_filter = filter\n return {\n \"documents\": [\"Test doc\"],\n \"metadatas\": [{}],\n \"distances\": [0.1],\n \"ids\": [\"doc1\"],\n }\n\n mock_collection_manager = MagicMock()\n mock_vector_db = MagicMock()\n mock_vector_db.search = capture_search\n mock_collection_manager.get_collection = MagicMock(return_value=mock_vector_db)\n\n mock_query_router = MagicMock()\n mock_query_router.route_query = MagicMock(\n return_value={\n \"collection_name\": \"zantara_books\",\n \"collections\": [\"zantara_books\"],\n \"confidence\": 1.0,\n \"is_pricing\": False,\n }\n )\n\n mock_embedder = MagicMock()\n mock_embedder.generate_query_embedding = MagicMock(return_value=[0.1] * 384)\n\n service = SearchService(\n collection_manager=mock_collection_manager,\n conflict_resolver=ConflictResolver(),\n cultural_insights=CulturalInsightsService(\n collection_manager=mock_collection_manager, embedder=mock_embedder\n ),\n query_router=mock_query_router,\n )\n service.embedder = mock_embedder\n\n await service.search(query=\"test\", user_level=2, limit=5)\n\n # Verify search was called (filter may be None for non-tier collections, but shouldn't be forced None)\n assert mock_vector_db.search.called\n\n @pytest.mark.asyncio\n async def test_search_with_collection_override(self):\n \"\"\"Test search with collection override\"\"\"\n from backend.services.conflict_resolver import ConflictResolver\n from backend.services.cultural_insights_service import CulturalInsightsService\n from backend.services.search_service import SearchService\n\n mock_collection_manager = MagicMock()\n mock_vector_db = MagicMock()\n mock_vector_db.search = AsyncMock(\n return_value={\n \"documents\": [[\"Document 1\"]],\n \"distances\": [[0.1]],\n \"metadatas\": [[{\"title\": \"Doc 1\"}]],\n \"ids\": [[\"id1\"]],\n }\n )\n mock_collection_manager.get_collection = MagicMock(return_value=mock_vector_db)\n\n mock_query_router = MagicMock()\n mock_query_router.route_query = MagicMock(\n return_value={\"collection_name\": \"tax_genius\", \"confidence\": 0.8, \"fallbacks\": []}\n )\n\n mock_embedder = MagicMock()\n mock_embedder.generate_query_embedding = MagicMock(return_value=[0.1] * 384)\n\n service = SearchService(\n collection_manager=mock_collection_manager,\n conflict_resolver=ConflictResolver(),\n cultural_insights=CulturalInsightsService(\n collection_manager=mock_collection_manager, embedder=mock_embedder\n ),\n query_router=mock_query_router,\n )\n service.embedder = mock_embedder\n\n result = await service.search(\n query=\"tax question\", user_level=2, limit=5, collection_override=\"tax_genius\"\n )\n\n assert result[\"collection_used\"] == \"tax_genius\"\n\n @pytest.mark.asyncio\n async def test_search_with_tier_filter(self):\n \"\"\"Test search with tier filter\"\"\"\n from backend.app.models import TierLevel\n from backend.services.conflict_resolver import ConflictResolver\n from backend.services.cultural_insights_service import CulturalInsightsService\n from backend.services.search_service import SearchService\n\n mock_collection_manager = MagicMock()\n mock_vector_db = MagicMock()\n mock_vector_db.search = AsyncMock(\n return_value={\n \"documents\": [[\"Document 1\"]],\n \"distances\": [[0.1]],\n \"metadatas\": [[{\"tier\": \"S\", \"title\": \"Doc 1\"}]],\n \"ids\": [[\"id1\"]],\n }\n )\n mock_collection_manager.get_collection = MagicMock(return_value=mock_vector_db)\n\n mock_query_router = MagicMock()\n mock_query_router.route_query = MagicMock(\n return_value={\"collection_name\": \"zantara_books\", \"confidence\": 0.8, \"fallbacks\": []}\n )\n\n mock_embedder = MagicMock()\n mock_embedder.generate_query_embedding = MagicMock(return_value=[0.1] * 384)\n\n service = SearchService(\n collection_manager=mock_collection_manager,\n conflict_resolver=ConflictResolver(),\n cultural_insights=CulturalInsightsService(\n collection_manager=mock_collection_manager, embedder=mock_embedder\n ),\n query_router=mock_query_router,\n )\n service.embedder = mock_embedder\n\n result = await service.search(\n query=\"philosophy question\",\n user_level=2,\n limit=5,\n tier_filter=[TierLevel.S, TierLevel.A],\n )\n\n assert \"allowed_tiers\" in result\n assert len(result[\"allowed_tiers\"]) > 0\n\n @pytest.mark.asyncio\n async def test_search_with_conflict_resolution(self):\n \"\"\"Test search_with_conflict_resolution method\"\"\"\n from backend.services.conflict_resolver import ConflictResolver\n from backend.services.cultural_insights_service import CulturalInsightsService\n from backend.services.search_service import SearchService\n\n mock_collection_manager = MagicMock()\n mock_vector_db = MagicMock()\n mock_vector_db.search = AsyncMock(\n return_value={\n \"documents\": [[\"Document 1\"]],\n \"distances\": [[0.1]],\n \"metadatas\": [[{\"title\": \"Doc 1\"}]],\n \"ids\": [[\"id1\"]],\n }\n )\n mock_collection_manager.get_collection = MagicMock(return_value=mock_vector_db)\n\n mock_query_router = MagicMock()\n mock_query_router.route_query = MagicMock(\n return_value={\n \"collection_name\": \"visa_oracle\",\n \"confidence\": 0.5,\n \"fallbacks\": [\"kbli_eye\"],\n \"collections\": [\"visa_oracle\", \"kbli_eye\"],\n }\n )\n\n mock_embedder = MagicMock()\n mock_embedder.generate_query_embedding = MagicMock(return_value=[0.1] * 384)\n\n service = SearchService(\n collection_manager=mock_collection_manager,\n conflict_resolver=ConflictResolver(),\n cultural_insights=CulturalInsightsService(\n collection_manager=mock_collection_manager, embedder=mock_embedder\n ),\n query_router=mock_query_router,\n )\n service.embedder = mock_embedder\n\n result = await service.search_with_conflict_resolution(\n query=\"ambiguous question\", user_level=2, limit=5, enable_fallbacks=True\n )\n\n assert \"results\" in result\n assert \"conflict_resolution\" in result or \"metadata\" in result\n\n @pytest.mark.asyncio\n async def test_search_with_conflict_resolution_no_fallbacks(self):\n \"\"\"Test search_with_conflict_resolution without fallbacks\"\"\"\n from backend.services.conflict_resolver import ConflictResolver\n from backend.services.cultural_insights_service import CulturalInsightsService\n from backend.services.search_service import SearchService\n\n mock_collection_manager = MagicMock()\n mock_vector_db = MagicMock()\n mock_vector_db.search = AsyncMock(\n return_value={\n \"documents\": [[\"Document 1\"]],\n \"distances\": [[0.1]],\n \"metadatas\": [[{\"title\": \"Doc 1\"}]],\n \"ids\": [[\"id1\"]],\n }\n )\n mock_collection_manager.get_collection = MagicMock(return_value=mock_vector_db)\n\n mock_query_router = MagicMock()\n mock_query_router.route_query = MagicMock(\n return_value={\n \"collection_name\": \"visa_oracle\",\n \"confidence\": 0.8,\n \"fallbacks\": [],\n \"collections\": [\"visa_oracle\"],\n }\n )\n\n mock_embedder = MagicMock()\n mock_embedder.generate_query_embedding = MagicMock(return_value=[0.1] * 384)\n\n service = SearchService(\n collection_manager=mock_collection_manager,\n conflict_resolver=ConflictResolver(),\n cultural_insights=CulturalInsightsService(\n collection_manager=mock_collection_manager, embedder=mock_embedder\n ),\n query_router=mock_query_router,\n )\n service.embedder = mock_embedder\n\n result = await service.search_with_conflict_resolution(\n query=\"visa question\", user_level=2, limit=5, enable_fallbacks=False\n )\n\n assert \"results\" in result\n\n @pytest.mark.asyncio\n async def test_search_error_handling(self):\n \"\"\"Test search error handling\"\"\"\n from backend.services.conflict_resolver import ConflictResolver\n from backend.services.cultural_insights_service import CulturalInsightsService\n from backend.services.search_service import SearchService\n\n mock_collection_manager = MagicMock()\n mock_collection_manager.get_collection = MagicMock(return_value=None)\n\n mock_query_router = MagicMock()\n mock_query_router.route_query = MagicMock(\n return_value={\n \"collection_name\": \"unknown_collection\",\n \"confidence\": 0.8,\n \"fallbacks\": [],\n }\n )\n\n mock_embedder = MagicMock()\n mock_embedder.generate_query_embedding = MagicMock(return_value=[0.1] * 384)\n\n service = SearchService(\n collection_manager=mock_collection_manager,\n conflict_resolver=ConflictResolver(),\n cultural_insights=CulturalInsightsService(\n collection_manager=mock_collection_manager, embedder=mock_embedder\n ),\n query_router=mock_query_router,\n )\n service.embedder = mock_embedder\n\n # Should handle missing collection gracefully\n with pytest.raises((ValueError, Exception)):\n await service.search(query=\"test\", user_level=2, limit=5)\n\n def test_build_search_filter_with_tier(self):\n \"\"\"Test _build_search_filter with tier filter\"\"\"\n from backend.services.collection_manager import CollectionManager\n from backend.services.conflict_resolver import ConflictResolver\n from backend.services.cultural_insights_service import CulturalInsightsService\n from backend.services.search_service import SearchService\n\n mock_embedder = MagicMock()\n service = SearchService(\n collection_manager=CollectionManager(),\n conflict_resolver=ConflictResolver(),\n cultural_insights=CulturalInsightsService(\n collection_manager=CollectionManager(), embedder=mock_embedder\n ),\n )\n\n tier_filter = {\"tier\": {\"$in\": [\"S\", \"A\"]}}\n result = service._build_search_filter(tier_filter=tier_filter, exclude_repealed=True)\n\n assert result is not None\n assert \"tier\" in result\n assert \"status_vigensi\" in result or \"$and\" in result or \"$or\" in result\n\n def test_build_search_filter_exclude_repealed(self):\n \"\"\"Test _build_search_filter excludes repealed laws\"\"\"\n from backend.services.collection_manager import CollectionManager\n from backend.services.conflict_resolver import ConflictResolver\n from backend.services.cultural_insights_service import CulturalInsightsService\n from backend.services.search_service import SearchService\n\n mock_embedder = MagicMock()\n service = SearchService(\n collection_manager=CollectionManager(),\n conflict_resolver=ConflictResolver(),\n cultural_insights=CulturalInsightsService(\n collection_manager=CollectionManager(), embedder=mock_embedder\n ),\n )\n\n result = service._build_search_filter(tier_filter=None, exclude_repealed=True)\n\n assert result is not None\n # Should exclude \"dicabut\" status\n assert \"status_vigensi\" in result or \"$and\" in result or \"$or\" in result\n\n def test_build_search_filter_include_repealed(self):\n \"\"\"Test _build_search_filter includes repealed laws when requested\"\"\"\n from backend.services.collection_manager import CollectionManager\n from backend.services.conflict_resolver import ConflictResolver\n from backend.services.cultural_insights_service import CulturalInsightsService\n from backend.services.search_service import SearchService\n\n mock_embedder = MagicMock()\n service = SearchService(\n collection_manager=CollectionManager(),\n conflict_resolver=ConflictResolver(),\n cultural_insights=CulturalInsightsService(\n collection_manager=CollectionManager(), embedder=mock_embedder\n ),\n )\n\n result = service._build_search_filter(tier_filter=None, exclude_repealed=False)\n\n # When exclude_repealed=False, should not have status_vigensi filter\n # or should allow all statuses\n assert result is None or \"status_vigensi\" not in result or \"$in\" in str(result)\n\n def test_build_search_filter_no_tier(self):\n \"\"\"Test _build_search_filter without tier filter\"\"\"\n from backend.services.collection_manager import CollectionManager\n from backend.services.conflict_resolver import ConflictResolver\n from backend.services.cultural_insights_service import CulturalInsightsService\n from backend.services.search_service import SearchService\n\n mock_embedder = MagicMock()\n service = SearchService(\n collection_manager=CollectionManager(),\n conflict_resolver=ConflictResolver(),\n cultural_insights=CulturalInsightsService(\n collection_manager=CollectionManager(), embedder=mock_embedder\n ),\n )\n\n result = service._build_search_filter(tier_filter=None, exclude_repealed=True)\n\n # Should still exclude repealed even without tier filter\n assert result is None or \"status_vigensi\" in result or \"$and\" in result\n\n @pytest.mark.asyncio\n async def test_search_user_level_0(self):\n \"\"\"Test search with user_level 0 (S tier only)\"\"\"\n from backend.services.conflict_resolver import ConflictResolver\n from backend.services.cultural_insights_service import CulturalInsightsService\n from backend.services.search_service import SearchService\n\n mock_collection_manager = MagicMock()\n mock_vector_db = MagicMock()\n mock_vector_db.search = AsyncMock(\n return_value={\n \"documents\": [[\"Document 1\"]],\n \"distances\": [[0.1]],\n \"metadatas\": [[{\"tier\": \"S\", \"title\": \"Doc 1\"}]],\n \"ids\": [[\"id1\"]],\n }\n )\n mock_collection_manager.get_collection = MagicMock(return_value=mock_vector_db)\n\n mock_query_router = MagicMock()\n mock_query_router.route_query = MagicMock(\n return_value={\"collection_name\": \"zantara_books\", \"confidence\": 0.8, \"fallbacks\": []}\n )\n\n mock_embedder = MagicMock()\n mock_embedder.generate_query_embedding = MagicMock(return_value=[0.1] * 384)\n\n service = SearchService(\n collection_manager=mock_collection_manager,\n conflict_resolver=ConflictResolver(),\n cultural_insights=CulturalInsightsService(\n collection_manager=mock_collection_manager, embedder=mock_embedder\n ),\n query_router=mock_query_router,\n )\n service.embedder = mock_embedder\n\n result = await service.search(query=\"test\", user_level=0, limit=5)\n\n assert result[\"user_level\"] == 0\n assert \"S\" in result[\"allowed_tiers\"] or len(result[\"allowed_tiers\"]) == 0\n\n @pytest.mark.asyncio\n async def test_search_user_level_3(self):\n \"\"\"Test search with user_level 3 (all tiers)\"\"\"\n from backend.services.conflict_resolver import ConflictResolver\n from backend.services.cultural_insights_service import CulturalInsightsService\n from backend.services.search_service import SearchService\n\n mock_collection_manager = MagicMock()\n mock_vector_db = MagicMock()\n mock_vector_db.search = AsyncMock(\n return_value={\n \"documents\": [[\"Document 1\"]],\n \"distances\": [[0.1]],\n \"metadatas\": [[{\"tier\": \"D\", \"title\": \"Doc 1\"}]],\n \"ids\": [[\"id1\"]],\n }\n )\n mock_collection_manager.get_collection = MagicMock(return_value=mock_vector_db)\n\n mock_query_router = MagicMock()\n mock_query_router.route_query = MagicMock(\n return_value={\"collection_name\": \"zantara_books\", \"confidence\": 0.8, \"fallbacks\": []}\n )\n\n mock_embedder = MagicMock()\n mock_embedder.generate_query_embedding = MagicMock(return_value=[0.1] * 384)\n\n service = SearchService(\n collection_manager=mock_collection_manager,\n conflict_resolver=ConflictResolver(),\n cultural_insights=CulturalInsightsService(\n collection_manager=mock_collection_manager, embedder=mock_embedder\n ),\n query_router=mock_query_router,\n )\n service.embedder = mock_embedder\n\n result = await service.search(query=\"test\", user_level=3, limit=5)\n\n assert result[\"user_level\"] == 3\n # Level 3 should have access to all tiers\n assert len(result[\"allowed_tiers\"]) >= 4 # S, A, B, C, D\n"}}, "backend/app/core/config.py": {"23cd129904123772abdfe94e41758c4e74cb2b80": {"t": 1786068517.0809603, "n": 27500, "h": "e306dfe3ee449f31", "c": "\"\"\"\nNUZANTARA PRIME - Centralized Configuration\nAll environment variables centralized using pydantic-settings\n\"\"\"\n\nimport logging\nimport os\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings\n\n\nclass Settings(BaseSettings):\n \"\"\"Application settings from environment variables - Prime Standard\"\"\"\n\n # ========================================\n # PROJECT CONFIGURATION\n # ========================================\n PROJECT_NAME: str = \"Nuzantara Prime\"\n COMPANY_NAME: str = \"Bali Zero\"\n COMPANY_LOCATION: str = \"Jalan Semer, Kerobokan, Bali\"\n COMPANY_SERVICE_DOMAIN: str = \"Visas, Business Setup, Tax, Legal matters in Indonesia\"\n SUPPORT_EMAIL: str = \"info@balizero.com\"\n SUPPORT_WHATSAPP: str = \"+62 813 3805 1876\"\n API_V1_STR: str = \"/api/v1\"\n environment: str = \"development\" # Set via ENVIRONMENT env var (production/development)\n\n # ========================================\n # EMBEDDINGS CONFIGURATION\n # ========================================\n embedding_provider: str = \"openai\" # OpenAI for production (1536-dim)\n openai_api_key: str | None = Field(\n default=None,\n description=\"OpenAI API key - REQUIRED for embeddings. Set via OPENAI_API_KEY env var\",\n )\n google_api_key: str | None = None # Set via GOOGLE_API_KEY env var (for Gemini AI)\n google_credentials_json: str | None = Field(\n default=None,\n description=\"Google Service Account JSON credentials for Vertex AI. Set via GOOGLE_CREDENTIALS_JSON or GEMINI_SA_TOKEN env var\",\n )\n gemini_sa_token: str | None = None # Alias for GEMINI_SA_TOKEN env var\n\n @field_validator(\"google_credentials_json\", mode=\"before\")\n @classmethod\n def resolve_google_credentials(cls, v):\n \"\"\"Resolve Google credentials from multiple env vars.\"\"\"\n import os\n if v:\n return v\n # Check GEMINI_SA_TOKEN as fallback\n gemini_token = os.environ.get(\"GEMINI_SA_TOKEN\")\n if gemini_token:\n return gemini_token\n return None\n google_imagen_api_key: str | None = (\n None # Set via GOOGLE_IMAGEN_API_KEY env var (for Imagen image generation)\n )\n google_ai_studio_key: str | None = (\n None # Set via GOOGLE_AI_STUDIO_KEY env var (Google AI Studio with Ultra tier)\n )\n imagineart_api_key: str | None = None # Set via IMAGINEART_API_KEY env var (for ImagineArt)\n stability_api_key: str | None = None # Set via STABILITY_API_KEY env var (for Stability AI)\n brave_api_key: str | None = None # Set via BRAVE_API_KEY env var (for Brave Web Search)\n tavily_api_key: str | None = None # Set via TAVILY_API_KEY env var (for Tavily AI Search - preferred)\n embedding_model: str = \"text-embedding-3-small\"\n embedding_dimensions: int = 1536 # Matches migrated collections\n\n @field_validator(\"openai_api_key\", mode=\"before\")\n @classmethod\n def validate_openai_api_key(cls, v):\n \"\"\"Validate OpenAI API key - warn if missing in production\"\"\"\n env = os.getenv(\"ENVIRONMENT\", \"development\")\n is_production = env.lower() == \"production\"\n\n if not v:\n if is_production:\n import warnings\n\n warnings.warn(\n \"OPENAI_API_KEY not set in production. Embeddings will fail.\",\n UserWarning,\n stacklevel=2,\n )\n else:\n logger = logging.getLogger(__name__)\n logger.warning(\n \"⚠️ OPENAI_API_KEY not set - embeddings may fail. \"\n \"Set OPENAI_API_KEY env var for production.\"\n )\n elif v and not v.startswith(\"sk-\"):\n logger = logging.getLogger(__name__)\n logger.warning(\"⚠️ OPENAI_API_KEY format may be invalid (should start with 'sk-')\")\n\n return v\n\n @field_validator(\"embedding_dimensions\", mode=\"before\")\n @classmethod\n def set_dimensions_from_provider(cls, _v, info):\n \"\"\"Automatically set embedding dimensions based on provider\"\"\"\n provider = info.data.get(\"embedding_provider\", \"openai\")\n if provider == \"openai\":\n return 1536 # OpenAI text-embedding-3-small\n return 384 # sentence-transformers fallback\n\n # ========================================\n # ZANTARA AI CONFIGURATION (PRIMARY)\n # ========================================\n zantara_ai_model: str = \"gpt-4o-mini\" # Set via ZANTARA_AI_MODEL\n zantara_ai_cost_input: float = 0.15 # Cost per 1M input tokens (GPT-4o-mini)\n zantara_ai_cost_output: float = 0.60 # Cost per 1M output tokens (GPT-4o-mini)\n openrouter_api_key: str | None = None # Set via OPENROUTER_API_KEY env var (free AI fallback)\n deepseek_api_key: str | None = Field(default=None, description=\"DeepSeek API Key\")\n\n # ========================================\n # QDRANT VECTOR DATABASE\n # ========================================\n qdrant_url: str = Field(\n default=\"http://localhost:6333\",\n description=\"Qdrant URL - Set via QDRANT_URL env var (default: localhost for development)\",\n )\n\n @field_validator(\"qdrant_url\")\n @classmethod\n def validate_qdrant_url(cls, v):\n \"\"\"Validate Qdrant URL format - fail in production if invalid\"\"\"\n env = os.getenv(\"ENVIRONMENT\", \"development\")\n is_production = env.lower() == \"production\"\n\n if not v:\n if is_production:\n raise ValueError(\n \"QDRANT_URL must be set in production. \"\n \"Set QDRANT_URL env var to your Qdrant instance URL.\"\n )\n logger = logging.getLogger(__name__)\n logger.warning(\n \"⚠️ QDRANT_URL not set - using default localhost. \"\n \"Set QDRANT_URL env var for production.\"\n )\n return \"http://localhost:6333\"\n\n if not v.startswith((\"http://\", \"https://\")):\n raise ValueError(\"QDRANT_URL must be a valid HTTP(S) URL\")\n\n return v\n\n qdrant_api_key: str | None = None # Set via QDRANT_API_KEY env var\n qdrant_collection_name: str = \"knowledge_base\"\n\n # ========================================\n # CHUNKING CONFIGURATION\n # ========================================\n chunk_size: int = 500\n chunk_overlap: int = 50\n max_chunks_per_book: int = 1000\n\n # ========================================\n # API CONFIGURATION\n # ========================================\n api_host: str = \"0.0.0.0\"\n api_port: int = 8080 # Use PORT env var (default 8080 for Fly.io)\n api_reload: bool = True\n\n # ========================================\n # TIMEOUT CONFIGURATION (Centralized)\n # ========================================\n timeout_default: float = 30.0 # Default timeout for API calls\n timeout_ai_response: float = 60.0 # AI response timeout\n timeout_rag_query: float = 10.0 # RAG query timeout\n timeout_tool_execution: float = 30.0 # Tool execution timeout\n timeout_streaming: float = 120.0 # Streaming timeout\n timeout_internal_api: float = 5.0 # Internal API calls timeout\n latency_alert_threshold_ms: float = 20000.0 # Alert if request takes longer than 20s\n\n # ========================================\n # RERANKER CONFIGURATION\n # ========================================\n enable_reranker: bool = False # DISABLED: Saves ~5GB Docker image size\n reranker_model: str = \"cross-encoder/ms-marco-MiniLM-L-6-v2\"\n reranker_top_k: int = 5\n reranker_latency_target_ms: float = 50.0\n reranker_cache_enabled: bool = True\n reranker_cache_size: int = 1000\n reranker_batch_enabled: bool = True\n reranker_audit_enabled: bool = True\n reranker_rate_limit_per_minute: int = 100\n reranker_rate_limit_per_hour: int = 1000\n reranker_overfetch_count: int = 20\n reranker_return_count: int = 5\n\n # ZeroEntropy Re-ranker Configuration (External API)\n zerank_api_key: str | None = Field(\n default=None, description=\"ZeroEntropy API Key - Set via ZERANK_API_KEY env var\"\n )\n zerank_api_url: str = Field(\n default=\"https://api.zeroentropy.dev/v1/models/rerank\",\n description=\"ZeroEntropy Re-ranker API URL - Set via ZERANK_API_URL env var\",\n )\n\n # ========================================\n # SEARCH CONFIGURATION\n # ========================================\n search_enable_filters: bool = Field(\n default=False,\n description=\"Enable tier/exclude_repealed filters in SearchService.search() by default. \"\n \"Set via SEARCH_ENABLE_FILTERS env var. When False (default), filters are disabled \"\n \"for backward compatibility with chat path. Individual calls can override via apply_filters parameter.\",\n )\n\n # ========================================\n # BM25 HYBRID SEARCH CONFIGURATION\n # ========================================\n enable_bm25: bool = Field(\n default=True,\n description=\"Enable BM25 sparse vectors for hybrid search. Set via ENABLE_BM25 env var.\",\n )\n bm25_vocab_size: int = Field(\n default=30000,\n description=\"Hash space size for BM25 token IDs. Set via BM25_VOCAB_SIZE env var.\",\n )\n bm25_k1: float = Field(\n default=1.5,\n description=\"BM25 term frequency saturation parameter. Set via BM25_K1 env var.\",\n )\n bm25_b: float = Field(\n default=0.75,\n description=\"BM25 document length normalization parameter. Set via BM25_B env var.\",\n )\n hybrid_dense_weight: float = Field(\n default=0.7,\n description=\"Weight for dense vectors in hybrid search (0-1). Set via HYBRID_DENSE_WEIGHT env var.\",\n )\n hybrid_sparse_weight: float = Field(\n default=0.3,\n description=\"Weight for sparse vectors in hybrid search (0-1). Set via HYBRID_SPARSE_WEIGHT env var.\",\n )\n\n # ========================================\n # LOGGING CONFIGURATION\n # ========================================\n log_level: str = \"INFO\"\n debug_mode: bool = Field(\n default=False,\n description=\"Enable debug mode (set via DEBUG_MODE env var). Disables in production.\",\n )\n\n @field_validator(\"debug_mode\", mode=\"before\")\n @classmethod\n def validate_debug_mode(cls, v):\n \"\"\"Validate debug mode - disable in production\"\"\"\n env = os.getenv(\"ENVIRONMENT\", \"development\")\n is_production = env.lower() == \"production\"\n\n if is_production and v:\n logger = logging.getLogger(__name__)\n logger.warning(\"⚠️ DEBUG_MODE cannot be enabled in production. Forcing to False.\")\n return False\n\n return bool(v) if v is not None else False\n\n log_file: str = \"./data/zantara_rag.log\"\n\n # ========================================\n # OPENTELEMETRY / JAEGER TRACING\n # ========================================\n otel_enabled: bool = Field(\n default=False,\n description=\"Enable OpenTelemetry tracing. Set via OTEL_ENABLED env var.\",\n )\n otel_exporter_endpoint: str = Field(\n default=\"http://localhost:4317\",\n description=(\n \"OTLP exporter endpoint for Jaeger. Set via OTEL_EXPORTER_ENDPOINT env var. \"\n \"Use http://localhost:4317 for local dev, http://jaeger:4317 for Docker.\"\n ),\n )\n otel_service_name: str = Field(\n default=\"nuzantara-backend\",\n description=\"Service name for tracing. Set via OTEL_SERVICE_NAME env var.\",\n )\n otel_exporter_headers: str | None = Field(\n default=None,\n description=(\n \"OTLP exporter headers for authentication. Set via OTEL_EXPORTER_HEADERS env var. \"\n \"Format: 'Authorization=Basic <token>' for Grafana Cloud.\"\n ),\n )\n\n # ========================================\n # DATA DIRECTORIES\n # ========================================\n raw_books_dir: str = \"./data/raw_books\"\n processed_dir: str = \"./data/processed\"\n batch_size: int = 10\n\n # ========================================\n # TIER OVERRIDES (Optional)\n # ========================================\n tier_overrides: str | None = None\n\n # ========================================\n # DATABASE CONFIGURATION\n # ========================================\n database_url: str | None = None # Set via DATABASE_URL env var\n\n @field_validator(\"database_url\", mode=\"before\")\n @classmethod\n def validate_database_url(cls, v):\n \"\"\"Validate database URL - warn if missing in production\"\"\"\n env = os.getenv(\"ENVIRONMENT\", \"development\")\n is_production = env.lower() == \"production\"\n\n if not v and is_production:\n # In production, database is usually required but some services might work without it\n # So we warn but don't fail\n import warnings\n\n warnings.warn(\n \"DATABASE_URL not set in production. Some features may be unavailable.\",\n UserWarning,\n stacklevel=2,\n )\n\n # Fix for SQLAlchemy: replace postgres:// with postgresql://\n # Fly.io/Heroku often use postgres:// which is deprecated/unsupported in SQLAlchemy 1.4+\n if v and v.startswith(\"postgres://\"):\n v = v.replace(\"postgres://\", \"postgresql://\", 1)\n\n return v\n\n # ========================================\n # REDIS CONFIGURATION\n # ========================================\n redis_url: str | None = None # Set via REDIS_URL env var\n\n # ========================================\n # AUTHENTICATION CONFIGURATION\n # ========================================\n jwt_secret_key: str | None = Field(\n default=None,\n description=(\n \"JWT secret key - REQUIRED: Set via JWT_SECRET_KEY env var. \"\n \"SECURITY: Default dev key only allowed in development mode. \"\n \"Production will fail if not explicitly set.\"\n ),\n )\n jwt_algorithm: str = \"HS256\"\n jwt_access_token_expire_hours: int = 24\n\n @field_validator(\"jwt_secret_key\", mode=\"before\")\n @classmethod\n def validate_jwt_secret(cls, v):\n \"\"\"\n Validate JWT secret key - fail in production if not set\n\n SECURITY: Default dev key is only provided in development mode.\n Production requires explicit JWT_SECRET_KEY environment variable.\n \"\"\"\n env = os.getenv(\"ENVIRONMENT\", \"development\")\n is_production = env.lower() == \"production\"\n\n if not v:\n if is_production:\n raise ValueError(\n \"SECURITY ERROR: JWT_SECRET_KEY must be set in production environment. \"\n \"Set ENVIRONMENT=production and provide JWT_SECRET_KEY secret.\"\n )\n # SECURITY: Only allow default dev key in development/testing\n # This prevents accidental use of weak keys in production\n logger = logging.getLogger(__name__)\n logger.warning(\n \"⚠️ Using default JWT secret key for development. \"\n \"Set JWT_SECRET_KEY env var for production.\"\n )\n return \"zantara_dev_secret_key_change_in_production_min_32_chars\"\n\n if len(v) < 32:\n raise ValueError(\"JWT_SECRET_KEY must be at least 32 characters\")\n\n if is_production and v == \"zantara_dev_secret_key_change_in_production_min_32_chars\":\n raise ValueError(\n \"SECURITY ERROR: Cannot use default JWT secret key in production. \"\n \"Set JWT_SECRET_KEY to a secure random string (min 32 chars).\"\n )\n\n return v\n\n # ========================================\n # API KEY AUTHENTICATION CONFIGURATION\n # ========================================\n api_keys: str | None = Field(\n default=None,\n description=(\n \"API keys - REQUIRED: Set via API_KEYS env var (comma-separated). \"\n \"SECURITY: Default dev key only allowed in development mode. \"\n \"Production will fail if not explicitly set.\"\n ),\n )\n\n @field_validator(\"api_keys\", mode=\"before\")\n @classmethod\n def validate_api_keys(cls, v):\n \"\"\"\n Validate API keys - fail in production if using default\n\n SECURITY: Default dev key is only provided in development mode.\n Production requires explicit API_KEYS environment variable.\n \"\"\"\n env = os.getenv(\"ENVIRONMENT\", \"development\")\n is_production = env.lower() == \"production\"\n\n if not v:\n if is_production:\n raise ValueError(\n \"SECURITY ERROR: API_KEYS must be set in production environment. \"\n \"Provide comma-separated list of secure API keys.\"\n )\n # SECURITY: Only allow default dev key in development/testing\n logger = logging.getLogger(__name__)\n logger.warning(\n \"⚠️ Using default API key for development. Set API_KEYS env var for production.\"\n )\n return \"dev_api_key_for_testing_only\"\n\n if is_production and v == \"dev_api_key_for_testing_only\":\n raise ValueError(\n \"SECURITY ERROR: Cannot use default API key in production. \"\n \"Set API_KEYS to secure comma-separated keys.\"\n )\n\n return v\n\n api_auth_enabled: bool = True\n api_auth_bypass_db: bool = False # Must be False to enable JWT auth\n\n # ========================================\n # COOKIE AUTHENTICATION CONFIGURATION\n # ========================================\n cookie_domain: str | None = Field(\n default=None,\n description=(\n \"Cookie domain for cross-subdomain auth (e.g., '.balizero.com'). \"\n \"Set via COOKIE_DOMAIN env var. None for localhost.\"\n ),\n )\n cookie_secure: bool = Field(\n default=True,\n description=\"Use Secure flag for cookies (HTTPS only). Set via COOKIE_SECURE env var.\",\n )\n csrf_enabled: bool = Field(\n default=True,\n description=\"Enable CSRF protection for state-changing requests. Set via CSRF_ENABLED env var.\",\n )\n\n # ========================================\n # LEGACY REMOVED: TypeScript Backend Integration\n # All handlers migrated to Python services (GmailService, CalendarService, etc.)\n # ========================================\n\n # ========================================\n # CORS CONFIGURATION\n # ========================================\n zantara_allowed_origins: str | None = (\n None # Comma-separated list, set via ZANTARA_ALLOWED_ORIGINS\n )\n\n # ========================================\n # FEATURE FLAGS\n # ========================================\n enable_skill_detection: bool = False # Set via ENABLE_SKILL_DETECTION env var\n enable_collective_memory: bool = False # Set via ENABLE_COLLECTIVE_MEMORY env var\n enable_advanced_analytics: bool = False # Set via ENABLE_ADVANCED_ANALYTICS env var\n enable_tool_execution: bool = False # Set via ENABLE_TOOL_EXECUTION env var\n\n # ========================================\n # NOTIFICATION SERVICES\n # ========================================\n sendgrid_api_key: str | None = None # Set via SENDGRID_API_KEY env var\n smtp_host: str | None = None # Set via SMTP_HOST env var\n smtp_port: int = Field(default=587, description=\"SMTP port (default: 587)\")\n smtp_user: str | None = None # Set via SMTP_USER env var\n smtp_password: str | None = None # Set via SMTP_PASSWORD env var\n smtp_use_tls: bool = Field(default=True, description=\"Use TLS for SMTP (default: True)\")\n smtp_from: str | None = None # Set via SMTP_FROM env var (sender email address)\n twilio_account_sid: str | None = None # Set via TWILIO_ACCOUNT_SID env var\n twilio_auth_token: str | None = None # Set via TWILIO_AUTH_TOKEN env var\n twilio_whatsapp_number: str | None = None # Set via TWILIO_WHATSAPP_NUMBER env var\n twilio_phone_number: str | None = None # Set via TWILIO_PHONE_NUMBER env var (for SMS)\n slack_webhook_url: str | None = None # Set via SLACK_WEBHOOK_URL env var\n discord_webhook_url: str | None = None # Set via DISCORD_WEBHOOK_URL env var\n\n # ========================================\n # META WHATSAPP CONFIGURATION\n # ========================================\n whatsapp_verify_token: str = Field(\n default=\"dev_whatsapp_token_for_testing\",\n description=(\n \"WhatsApp webhook verify token - Set via WHATSAPP_VERIFY_TOKEN env var \"\n \"(default: dev token for testing only)\"\n ),\n )\n\n @field_validator(\"whatsapp_verify_token\", mode=\"before\")\n @classmethod\n def validate_whatsapp_token(cls, v):\n \"\"\"Validate WhatsApp token - warn in production if using default\"\"\"\n env = os.getenv(\"ENVIRONMENT\", \"development\")\n is_production = env.lower() == \"production\"\n\n if not v:\n if is_production:\n raise ValueError(\n \"WHATSAPP_VERIFY_TOKEN must be set in production. \"\n \"Provide a secure random token.\"\n )\n return \"dev_whatsapp_token_for_testing\"\n\n if is_production and v == \"dev_whatsapp_token_for_testing\":\n raise ValueError(\n \"Cannot use default WhatsApp verify token in production. \"\n \"Set WHATSAPP_VERIFY_TOKEN to a secure random string.\"\n )\n\n return v\n\n whatsapp_access_token: str | None = None # Set via WHATSAPP_ACCESS_TOKEN env var\n whatsapp_phone_number_id: str | None = None # Set via WHATSAPP_PHONE_NUMBER_ID env var\n whatsapp_business_account_id: str | None = None # Set via WHATSAPP_BUSINESS_ACCOUNT_ID env var\n\n # ========================================\n # META INSTAGRAM CONFIGURATION\n # ========================================\n instagram_verify_token: str = Field(\n default=\"dev_instagram_token_for_testing\",\n description=(\n \"Instagram webhook verify token - Set via INSTAGRAM_VERIFY_TOKEN env var \"\n \"(default: dev token for testing only)\"\n ),\n )\n\n @field_validator(\"instagram_verify_token\", mode=\"before\")\n @classmethod\n def validate_instagram_token(cls, v):\n \"\"\"Validate Instagram token - warn in production if using default\"\"\"\n env = os.getenv(\"ENVIRONMENT\", \"development\")\n is_production = env.lower() == \"production\"\n\n if not v:\n if is_production:\n raise ValueError(\n \"INSTAGRAM_VERIFY_TOKEN must be set in production. \"\n \"Provide a secure random token.\"\n )\n return \"dev_instagram_token_for_testing\"\n\n if is_production and v == \"dev_instagram_token_for_testing\":\n raise ValueError(\n \"Cannot use default Instagram verify token in production. \"\n \"Set INSTAGRAM_VERIFY_TOKEN to a secure random string.\"\n )\n\n return v\n\n instagram_access_token: str | None = None # Set via INSTAGRAM_ACCESS_TOKEN env var\n instagram_account_id: str | None = None # Set via INSTAGRAM_ACCOUNT_ID env var\n\n # ========================================\n # ZOHO EMAIL INTEGRATION\n # ========================================\n zoho_client_id: str | None = None # Set via ZOHO_CLIENT_ID env var\n zoho_client_secret: str | None = None # Set via ZOHO_CLIENT_SECRET env var\n zoho_redirect_uri: str = Field(\n default=\"https://nuzantara-rag.fly.dev/api/integrations/zoho/callback\",\n description=\"Zoho OAuth redirect URI (set via ZOHO_REDIRECT_URI env var)\",\n )\n frontend_url: str = Field(\n default=\"https://zantara.balizero.com\",\n description=\"Frontend URL for OAuth redirects (set via FRONTEND_URL env var)\",\n )\n zoho_api_domain: str = Field(\n default=\"https://mail.zoho.com\",\n description=\"Zoho Mail API domain - regional (set via ZOHO_API_DOMAIN env var)\",\n )\n zoho_accounts_url: str = Field(\n default=\"https://accounts.zoho.com\",\n description=\"Zoho Accounts URL for OAuth (set via ZOHO_ACCOUNTS_URL env var)\",\n )\n\n # ========================================\n # ORACLE CONFIGURATION\n # ========================================\n zantara_oracle_url: str = Field(\n default=\"http://localhost:11434/api/generate\",\n description=\"ZANTARA Oracle API URL (set via ZANTARA_ORACLE_URL env var)\",\n )\n\n # Development origins (for local testing)\n dev_origins: str = Field(\n default=(\n \"http://localhost:4173,http://127.0.0.1:4173,\"\n \"http://localhost:3000,http://127.0.0.1:3000\"\n ),\n description=(\n \"Comma-separated list of development origins for CORS (set via DEV_ORIGINS env var)\"\n ),\n )\n oracle_api_key: str | None = None # Set via ORACLE_API_KEY env var\n\n # ========================================\n # ADMIN CONFIGURATION\n # ========================================\n admin_api_key: str | None = Field(\n None,\n description=\"Admin API key for plugin reload and admin endpoints (set via ADMIN_API_KEY env var)\",\n )\n\n # ========================================\n # GOOGLE SERVICES CONFIGURATION\n # ========================================\n # google_api_key: str | None = None # Already defined in EMBEDDINGS CONFIGURATION\n google_credentials_json: str | None = None # Set via GOOGLE_CREDENTIALS_JSON env var\n\n # ========================================\n # GOOGLE DRIVE OAUTH CONFIGURATION\n # ========================================\n google_drive_client_id: str | None = None # Set via GOOGLE_DRIVE_CLIENT_ID env var\n google_drive_client_secret: str | None = None # Set via GOOGLE_DRIVE_CLIENT_SECRET env var\n google_drive_redirect_uri: str = \"https://nuzantara-rag.fly.dev/integrations/google-drive/callback\"\n google_drive_root_folder_id: str | None = None # Set via GOOGLE_DRIVE_ROOT_FOLDER_ID env var (team root folder)\n hf_api_key: str | None = None # Set via HF_API_KEY env var\n\n # ========================================\n # JAKSEL AI PERSONALITY SYSTEM\n # ========================================\n jaksel_oracle_url: str | None = None # Set via JAKSEL_ORACLE_URL env var\n jaksel_tunnel_url: str | None = None # Set via JAKSEL_TUNNEL_URL env var\n jaksel_enabled: bool = True # Feature flag (set via JAKSEL_ENABLED env var)\n jaksel_local_url: str = \"http://127.0.0.1:11434\" # Local development only\n\n # ========================================\n # FLY.IO DEPLOYMENT\n # ========================================\n fly_app_name: str | None = None # Set via FLY_APP_NAME env var\n fly_region: str | None = None # Set via FLY_REGION env var\n hostname: str | None = None # Set via HOSTNAME env var\n port: int = 8080 # Set via PORT env var (Fly.io default)\n\n # ========================================\n # SERVICE CONFIGURATION\n # ========================================\n service_name: str = \"nuzantara-rag\" # Set via SERVICE_NAME env var\n\n class Config:\n # Load .env file for local development, but allow env vars to override\n # In production (Fly.io), use environment variables/secrets only\n env_file = \".env\"\n env_file_encoding = \"utf-8\"\n case_sensitive = False\n extra = \"ignore\" # Ignore extra fields\n\n\n# Global settings instance\nsettings = Settings()\n"}}}}