File size: 9,580 Bytes
027123c
 
19bd037
 
027123c
 
 
 
 
 
 
 
 
 
 
 
 
 
81e5fe7
079782d
 
 
 
81e5fe7
0e02a0f
 
 
 
0721bb4
 
3743cfe
 
 
 
 
 
027123c
 
 
 
 
 
 
d65c41d
 
 
 
 
027123c
268ef5d
 
 
 
 
 
 
 
 
 
 
b68816f
 
 
 
 
 
 
027123c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
079782d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b68816f
 
 
 
 
 
 
7124acf
 
 
 
 
 
 
 
 
 
 
 
027123c
 
 
 
 
 
19bd037
 
027123c
 
 
 
 
 
 
 
2ba0613
 
c7d59cf
2ba0613
 
f282b15
 
 
 
 
 
 
 
 
268ef5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
027123c
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
"""Centralized configuration management using pydantic-settings."""

# import os
# from typing import Optional
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    """Application settings loaded from environment variables."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="allow",
        case_sensitive=False,
    )

    # Feature flags
    # REMOVED 2026-07-02: the former ENABLE_SLOW_PATH flag is gone. `structured_flow`
    # now always runs the analytical slow path (Planner -> TaskRunner -> Assembler), so
    # every structured question persists a report_inputs record (reports no longer
    # depend on flipping a flag). extra="allow" ignores a stale ENABLE_SLOW_PATH in .env.

    # DEPRECATED 2026-06-24: the problem_validated gate was removed (the goal is now
    # user-entered objective + business_questions, no agent validation). This flag no
    # longer has any effect β€” the gate call site in ChatHandler is commented out. Kept
    # to avoid .env churn; remove once no environment references it.
    enable_gate: bool = Field(alias="enable_gate", default=False)

    # Skip init_db() (create_all + startup DDL) on boot. TRUE by default post-dedorch
    # cutover: Go owns the dedorch schema, so Python (consumer-only role) must NOT run
    # init_db β€” its ALTER/index DDL on Go-owned tables fails with InsufficientPrivilege
    # ("must be owner of table rooms"). Set to false only for a local Python-owned DB.
    skip_init_db: bool = Field(alias="SKIP_INIT_DB", default=True)

    # Database
    postgres_connstring: str

    # Redis
    redis_url: str
    redis_prefix: str = "dataeyond-agent-service_"

    # Azure OpenAI - GPT-5.4-mini (map to .env names with double underscores)
    azureai_api_key_54m: str = Field(alias="azureai__api_key__54m", default="")
    azureai_endpoint_url_54m: str = Field(alias="azureai__endpoint__url__54m", default="")
    azureai_deployment_name_54m: str = Field(alias="azureai__deployment__name__54m", default="")
    azureai_api_version_54m: str = Field(alias="azureai__api__version__54m", default="")

    # Azure OpenAI - GPT-6 Luna (trial 2026-11-03), same shape as the quad above.
    azureai_api_key_6l: str = Field(alias="azureai__api_key__6l", default="")
    azureai_endpoint_url_6l: str = Field(alias="azureai__endpoint__url__6l", default="")
    azureai_deployment_name_6l: str = Field(alias="azureai__deployment__name__6l", default="")
    azureai_api_version_6l: str = Field(alias="azureai__api__version__6l", default="")

    # Which quad the chat/planner/report LLM calls use: "54m" (default) or "6l".
    # Switching back is this one variable and a restart β€” the fallback if a model
    # trial does not work out. Any other value reads as "54m".
    llm_profile: str = Field(alias="LLM_PROFILE", default="54m")

    # Azure OpenAI - GPT-5.4-nano (knowledge-extraction pipeline; all four
    # branches route here β€” see KNOWLEDGE_PIPELINE_TODO.md D3)
    azureai_api_key_54n: str = Field(alias="azureai__api_key__54n", default="")
    azureai_endpoint_url_54n: str = Field(alias="azureai__endpoint__url__54n", default="")
    azureai_deployment_name_54n: str = Field(alias="azureai__deployment__name__54n", default="")
    azureai_api_version_54n: str = Field(alias="azureai__api__version__54n", default="")

    # Azure OpenAI - Embeddings
    azureai_api_key_embedding: str = Field(alias="azureai__api_key__embedding", default="")
    azureai_endpoint_url_embedding: str = Field(alias="azureai__endpoint__url__embedding", default="")
    azureai_deployment_name_embedding: str = Field(alias="azureai__deployment__name__embedding", default="")
    azureai_api_version_embedding: str = Field(alias="azureai__api__version__embedding", default="")

    # Azure Document Intelligence
    azureai_docintel_endpoint: str = Field(alias="azureai__docintel__endpoint", default="")
    azureai_docintel_key: str = Field(alias="azureai__docintel__key", default="")

    # Azure Blob Storage
    azureai_blob_sas: str = Field(alias="azureai__blob__sas", default="")
    azureai_container_endpoint: str = Field(alias="azureai__container__endpoint", default="")
    azureai_container_name: str = Field(alias="azureai__container__name", default="")
    azureai_container_account_name: str = Field(alias="azureai__container__account__name", default="")

    # Object storage provider toggle. Mirrors the Go data plane's `storage.provider`
    # (see Orchestrator config: azure_blob | supabase_s3). Blank falls back to azure_blob.
    # Tabular query execution reads the processed Parquet from whichever backend is active.
    storage_provider: str = Field(alias="STORAGE_PROVIDER", default="azure_blob")

    # Supabase S3-compatible object storage (active when storage_provider == "supabase_s3").
    # BRIDGE (Option C): lets Python read the same bucket Go writes to, until Go exposes a
    # source-data endpoint (see docs/proposal_go_source_data_endpoint.md) β€” then this client
    # is removed and Python stops touching storage directly.
    supabase_s3_bucket: str = Field(alias="SUPABASE_S3_BUCKET", default="")
    supabase_s3_endpoint: str = Field(alias="SUPABASE_S3_ENDPOINT", default="")
    supabase_s3_region: str = Field(alias="SUPABASE_S3_REGION", default="")
    supabase_s3_access_key_id: str = Field(alias="SUPABASE_S3_ACCESS_KEY_ID", default="")
    supabase_s3_secret_access_key: str = Field(alias="SUPABASE_S3_SECRET_ACCESS_KEY", default="")

    # Mistral OCR β€” the knowledge-parsing primary backend (no GPU). Read here so the
    # key reaches a deployed HuggingFace Space the same way every other credential
    # does, instead of a bare os.environ lookup that a Space's secret injection can
    # miss. The standalone parser (`py -m src.knowledge_parsing.run`) still falls
    # back to the environment / repo .env β€” see src/knowledge_parsing/mistral_ocr.py.
    mistral_api_key: str = Field(alias="MISTRAL_API_KEY", default="")

    # Which OCR parser `POST /api/v2/document/process` uses (added 2026-09-24):
    # "paddleocr" (default) or "mistral". Server-side only β€” the request carries no
    # parser choice. The offline knowledge pipeline is NOT governed by this; its
    # default stays in src/knowledge_parsing/config.py.
    document_parser_backend: str = Field(alias="DOCUMENT_PARSER_BACKEND", default="paddleocr")
    # PaddleOCR-VL hosted job API (Baidu AI Studio). Names follow the official SDK.
    paddleocr_access_token: str = Field(alias="PADDLEOCR_ACCESS_TOKEN", default="")
    paddleocr_job_url: str = Field(
        alias="PADDLEOCR_JOB_URL", default="https://paddleocr.aistudio-app.com/api/v2/ocr/jobs"
    )
    paddleocr_model: str = Field(alias="PADDLEOCR_MODEL", default="PaddleOCR-VL-1.6")

    # Langfuse
    LANGFUSE_PUBLIC_KEY: str
    LANGFUSE_SECRET_KEY: str
    LANGFUSE_HOST: str

    # MongoDB (for users - existing)
    # emarcal_mongo_endpoint_url: str = Field(alias="emarcal__mongo__endpoint__url", default="")
    # emarcal_buma_mongo_dbname: str = Field(alias="emarcal__buma__mongo__dbname", default="")

    # JWT (for users - existing)
    emarcal_jwt_secret_key: str = Field(alias="emarcal__jwt__secret_key", default="")
    emarcal_jwt_algorithm: str = Field(alias="emarcal__jwt__algorithm", default="HS256")

    # Bcrypt salt (for users - existing)
    emarcal_bcrypt_salt: str = Field(alias="emarcal__bcrypt__salt", default="")

    # DB credential encryption (Fernet key for user-registered database creds)
    dataeyond_db_credential_key: str = Field(
        alias="dataeyond__db__credential__key"
    )

    # Shared service secret for the F-2 gate β€” UNWIRED 2026-07-27 (DEV_PLAN #37). The
    # gate is no longer mounted (see main.py), so this value is read by nothing. Kept
    # commented rather than deleted so the whole feature restores in one place. If a
    # deployment still has `dataeyond__service__secret` in its .env it is simply
    # ignored (extra="allow" on Settings). Restore alongside the main.py dependency.
    # dataeyond_service_secret: str = Field(
    #     alias="dataeyond__service__secret", default=""
    # )

    # The active chat-LLM quad, chosen by `llm_profile`.
    @property
    def _llm_suffix(self) -> str:
        return "6l" if self.llm_profile.strip().lower() == "6l" else "54m"

    @property
    def llm_api_key(self) -> str:
        return getattr(self, f"azureai_api_key_{self._llm_suffix}")

    @property
    def llm_endpoint_url(self) -> str:
        return getattr(self, f"azureai_endpoint_url_{self._llm_suffix}")

    @property
    def llm_deployment_name(self) -> str:
        return getattr(self, f"azureai_deployment_name_{self._llm_suffix}")

    @property
    def llm_api_version(self) -> str:
        return getattr(self, f"azureai_api_version_{self._llm_suffix}")

    # GPT-6 Luna on chat/completions (checked 2026-11-03): function tools β€” every
    # with_structured_output call β€” are refused unless reasoning_effort is "none", and
    # temperature accepts only 0 and 1. None leaves the 5.4-mini request unchanged.
    @property
    def llm_reasoning_effort(self) -> str | None:
        return "none" if self._llm_suffix == "6l" else None

    def llm_temperature(self, wanted: float) -> float:
        """`wanted`, or for Luna the nearer of the two values it accepts."""
        if self._llm_suffix != "6l":
            return wanted
        return 0.0 if wanted < 0.5 else 1.0


# Singleton instance
settings = Settings()