Coverage for src / quber / settings / __init__.py: 99%

102 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-09-23 22:14 -0400

1"""Centralized configuration: one Pydantic ``Settings`` tree (QUE-228). 

2 

3Replaces the fragmented config layers (the dead ``utils.config.Config`` plus 

4~dozen scattered ``os.environ`` reads) with a single ``BaseSettings`` object 

5composed of nested domain groups. Modules read config via ``get_settings()`` 

6(a cached singleton) instead of touching the environment directly. 

7 

8Design notes: 

9 

10- Each group is a ``BaseSettings`` subclass (not a plain ``BaseModel``) so it 

11 loads the *flat* legacy env names — ``ANTHROPIC_API_KEY``, ``POSTGRES_*``, 

12 etc. — natively via per-field ``validation_alias``. A plain nested 

13 ``BaseModel`` would only populate through an ``env_nested_delimiter`` prefix 

14 (``LLM__ANTHROPIC_API_KEY``), which would break every existing ``.env``. 

15- Renamed vars keep their old name as an ``AliasChoices`` fallback so a 

16 transition window exists (e.g. ``LOGFIRE_TOKEN`` -> ``PYDANTIC_LOGFIRE_TOKEN``). 

17- ``Settings()`` construction performs no network or credential calls, so it is 

18 safe to call at package import (``files.cache.init_s3_cache`` does, via 

19 ``quber/__init__.py``). 

20- AWS credentials are intentionally absent: they stay on the boto3 default 

21 credential chain (env / ``~/.aws`` / IAM role). Only non-secret AWS knobs 

22 (S3 cache dir, TTL, region) live here. 

23""" 

24 

25from __future__ import annotations 

26 

27from functools import lru_cache 

28from pathlib import Path 

29from typing import Annotated, Any, Literal, Optional 

30 

31from pydantic import AliasChoices, BeforeValidator, Field 

32from pydantic_settings import BaseSettings, SettingsConfigDict 

33 

34# Shared config: read `.env` (relative to CWD), ignore unknown env vars, 

35# case-insensitive env matching. Each group reuses this so they all load the 

36# same `.env` and flat environment. 

37BASE_CONFIG = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False) 

38 

39# Truthy token set matching the legacy `langsmith_tracer.truthy()` helper this 

40# package replaces. Pydantic's native bool parser would raise on an empty or 

41# unrecognized value (e.g. `TRACE_TO_LANGSMITH=`), whereas the old code treated 

42# anything outside this set as False and never errored. `LegacyBool` preserves 

43# that exact truth table so no `.env` that worked before silently breaks. 

44TRUTHY_TOKENS = {"1", "true", "yes", "on"} 

45 

46 

47def coerce_legacy_bool(value: Any) -> Any: 

48 if isinstance(value, str): 

49 return value.strip().lower() in TRUTHY_TOKENS 

50 if value is None: 

51 return False 

52 return value 

53 

54 

55LegacyBool = Annotated[bool, BeforeValidator(coerce_legacy_bool)] 

56 

57# The model the extraction agents fall back to when neither an explicit model 

58# nor QUBER_LLM_MODEL/ANTHROPIC_MODEL is set. One project-wide default so the 

59# pinned model lives in a single place rather than being repeated in every 

60# agent. A dated id (not a rolling alias) keeps extraction reproducible run to 

61# run, the same reason the structural agents pin temperature 0. 

62DEFAULT_LLM_MODEL = "claude-haiku-4-5-20251001" 

63 

64 

65class LLMSettings(BaseSettings): 

66 """LLM model selection, Anthropic/OpenAI credentials, per-agent backends.""" 

67 

68 model_config = BASE_CONFIG 

69 

70 model: Optional[str] = Field( 

71 default=None, 

72 validation_alias=AliasChoices("QUBER_LLM_MODEL", "ANTHROPIC_MODEL"), 

73 ) 

74 anthropic_api_key: Optional[str] = Field(default=None, validation_alias="ANTHROPIC_API_KEY") 

75 anthropic_auth_token: Optional[str] = Field(default=None, validation_alias="ANTHROPIC_AUTH_TOKEN") 

76 # Shared with the embeddings layer; defined here once (single source of truth). 

77 openai_api_key: Optional[str] = Field(default=None, validation_alias="OPENAI_API_KEY") 

78 

79 # Per-agent backend selectors. Kept as separate fields (not collapsed to one) 

80 # so each agent retains its independent override, matching today's behavior. 

81 llm_backend: str = Field(default="api", validation_alias="QUBER_LLM_BACKEND") 

82 unifier_backend: str = Field(default="api", validation_alias="QUBER_UNIFIER_BACKEND") 

83 classifier_backend: str = Field(default="api", validation_alias="QUBER_CLASSIFIER_BACKEND") 

84 detector_backend: str = Field(default="api", validation_alias="QUBER_DETECTOR_BACKEND") 

85 grid_locator_backend: str = Field(default="api", validation_alias="QUBER_GRID_LOCATOR_BACKEND") 

86 cell_reader_backend: str = Field(default="api", validation_alias="QUBER_CELL_READER_BACKEND") 

87 completeness_backend: str = Field(default="text", validation_alias="QUBER_COMPLETENESS_BACKEND") 

88 # The capture advisor recommends a Camelot retry adjustment when a 

89 # capture drop is detected (values in the region text layer missing from 

90 # the grid). `off` disables the repair loop entirely. 

91 capture_advisor_backend: str = Field(default="api", validation_alias="QUBER_CAPTURE_ADVISOR_BACKEND") 

92 # The status inspector verifies each unboxed cell's proposed condition 

93 # against the table image and downgrades whatever it cannot positively 

94 # confirm to `unverified`. `off` disables inspection; the proposed 

95 # statuses are then recorded as-is. 

96 status_inspector_backend: str = Field(default="api", validation_alias="QUBER_STATUS_INSPECTOR_BACKEND") 

97 # The figure corrector names the text left on a page that belongs to a figure 

98 # the scan has already read — axis ticks, gridline labels — so it is removed 

99 # rather than indexed beside the reading that supersedes it. `off` removes 

100 # nothing and keeps every fragment. 

101 figure_correction_backend: str = Field(default="api", validation_alias="QUBER_FIGURE_CORRECTION_BACKEND") 

102 # The figure-value readers give every value printed in a figure a second, 

103 # independent read against the page image and the scan's prose, so the two 

104 # can be tied cell by cell. `off` skips the figure-value reconciliation 

105 # pass entirely. 

106 figure_values_backend: str = Field(default="api", validation_alias="QUBER_FIGURE_VALUES_BACKEND") 

107 

108 # The grid locator reads a labelled grid off the page image — a vision task 

109 # the smaller model resolves at too coarse a fidelity (it under-flags the 

110 # row-label stub column and wobbles a row at stacked boundaries), so it runs 

111 # on a stronger model than the rest of the extraction agents. Overridable; 

112 # falls back to the project-wide model when unset. 

113 grid_locator_model: Optional[str] = Field( 

114 default="claude-sonnet-5", validation_alias="QUBER_GRID_LOCATOR_MODEL" 

115 ) 

116 

117 # The structure-correction (vet) agent's own model. That agent carries the 

118 # most compliance-sensitive output in the pipeline — footnote marker 

119 # catalogues, quoted carrying cells, footnote/section judgements — and the 

120 # smaller model intermittently omits whole marks lists on real filings. 

121 # Overridable per the grid-locator pattern; falls back to the project-wide 

122 # model when unset. Validated on a real filing: the smaller model emitted 

123 # no marks list at all on some tables; the stronger model emitted them 

124 # complete with identical results whether scoped here or run pipeline-wide. 

125 vet_model: Optional[str] = Field(default="claude-sonnet-5", validation_alias="QUBER_VET_MODEL") 

126 

127 # The figure corrector's own model, pinned for the same reason as the two 

128 # above. Asked which text on a page belongs to a figure the scan has read, 

129 # the smaller model deleted a four-line annotation about rate sensitivity 

130 # while its own stated reason called it an annotation, and missed seven of a 

131 # page's forty axis tick labels. The stronger model returned all forty ticks 

132 # and left the annotation alone, identically across three runs. 

133 figure_correction_model: Optional[str] = Field( 

134 default="claude-sonnet-5", validation_alias="QUBER_FIGURE_CORRECTION_MODEL" 

135 ) 

136 

137 # The local figure-value reader's own model. Reading every number printed 

138 # inside a chart off the page image is a vision task, pinned to the 

139 # stronger model for the same reason as the figure corrector. Overridable; 

140 # falls back to the project-wide model when unset. 

141 figure_values_model: Optional[str] = Field( 

142 default="claude-sonnet-5", validation_alias="QUBER_FIGURE_VALUES_MODEL" 

143 ) 

144 

145 

146class DBSettings(BaseSettings): 

147 """Postgres connection parameters for the RAG store.""" 

148 

149 model_config = BASE_CONFIG 

150 

151 user: str = Field(default="quber", validation_alias="POSTGRES_USER") 

152 password: str = Field(default="quber_dev", validation_alias="POSTGRES_PASSWORD") 

153 host: str = Field(default="localhost", validation_alias="POSTGRES_HOST") 

154 port: int = Field(default=5432, validation_alias="POSTGRES_PORT") 

155 db: str = Field(default="quber_rag", validation_alias="POSTGRES_DB") 

156 

157 

158class S3CacheSettings(BaseSettings): 

159 """Non-secret S3 ingestion-cache knobs. Credentials stay on the boto3 chain.""" 

160 

161 model_config = BASE_CONFIG 

162 

163 cache_dir: Path = Field(default=Path(".cache/s3"), validation_alias="QUBER_S3_CACHE_DIR") 

164 ttl_seconds: float = Field(default=5 * 86400, validation_alias="QUBER_S3_CACHE_TTL") 

165 region: Optional[str] = Field( 

166 default=None, 

167 validation_alias=AliasChoices("QUBER_S3_REGION", "AWS_REGION"), 

168 ) 

169 

170 

171class EmbeddingSettings(BaseSettings): 

172 """Embedding provider/device selection. OpenAI key lives in ``LLMSettings``.""" 

173 

174 model_config = BASE_CONFIG 

175 

176 provider: str = Field(default="local", validation_alias="EMBEDDING_PROVIDER") 

177 device: str = Field(default="cuda", validation_alias="EMBEDDING_DEVICE") 

178 

179 

180class LandingSettings(BaseSettings): 

181 """Landing.AI ADE parse credentials for the standalone extraction path.""" 

182 

183 model_config = BASE_CONFIG 

184 

185 # ADE_API_KEY is our .env name; VISION_AGENT_API_KEY is the name the 

186 # landingai-ade SDK itself reads, accepted so an SDK-style environment 

187 # also works. 

188 api_key: Optional[str] = Field( 

189 default=None, 

190 validation_alias=AliasChoices("ADE_API_KEY", "VISION_AGENT_API_KEY"), 

191 ) 

192 

193 

194class TypeSafeSettings(BaseSettings): 

195 """TypeSafe scoring API: credential, pinned model, the chunk grade cut, and 

196 how hard the playground's ranker drives it.""" 

197 

198 model_config = BASE_CONFIG 

199 

200 # TYPESAFE_API_KEY is the SDK's own variable name. The key is still passed 

201 # explicitly from here into the SDK client, so a missing key is reported by 

202 # settings the way a missing Anthropic key is, not by the SDK at the first 

203 # request. 

204 api_key: Optional[str] = Field(default=None, validation_alias="TYPESAFE_API_KEY") 

205 model: str = Field(default="jev-1.13.0", validation_alias="QUBER_TYPESAFE_MODEL") 

206 # Chunks whose position on the 0 to 3 rubric is below this do not reach the 

207 # answer model. 1.5 is the midpoint between "same topic" and "supports"; 

208 # the default sits just under it so a chunk Jev is nearly sure supports the 

209 # item is kept. 

210 grade_cut: float = Field(default=1.4, validation_alias="QUBER_TYPESAFE_GRADE_CUT") 

211 # Calls in flight at once. 32 is the highest value measured, with no 

212 # throttling from the API. 

213 concurrency: int = Field(default=32, validation_alias="QUBER_TYPESAFE_CONCURRENCY") 

214 # The SDK's own default, made explicit: retries after the first attempt on 

215 # 429, 5xx, connection and timeout errors. 

216 max_retries: int = Field(default=2, validation_alias="QUBER_TYPESAFE_MAX_RETRIES") 

217 timeout_seconds: float = Field(default=60.0, validation_alias="QUBER_TYPESAFE_TIMEOUT_SECONDS") 

218 

219 

220class PlaygroundSettings(BaseSettings): 

221 """The answering playground: where its local corpus lives, how many 

222 ingests may run at once, and which step ranks retrieved chunks.""" 

223 

224 model_config = BASE_CONFIG 

225 

226 # Staged PDFs, parse artifacts and uploads. Relative paths resolve against 

227 # the server's working directory, the same convention as the S3 cache dir. 

228 data_dir: Path = Field(default=Path("data/playground"), validation_alias="QUBER_PLAYGROUND_DATA_DIR") 

229 # How many uploads run their pipelines at once. Every stage is a 

230 # subprocess, so two concurrent extractions are two `quber fuse` processes 

231 # sharing one CUDA device, each also making model calls. Two overlaps the 

232 # non-GPU parts of a run without saturating the device; one is for a 

233 # machine that cannot spare the contention. 

234 upload_concurrency: int = Field(default=2, validation_alias="QUBER_PLAYGROUND_UPLOAD_CONCURRENCY") 

235 # The durable home of every document's files when the playground is 

236 # hosted: an s3:// prefix holding one <doc_key>/ prefix per document, to 

237 # which each stage's outputs are copied and from which a served PDF is 

238 # fetched when the local copy is gone. Unset, the local data_dir is the 

239 # only copy, which is the developer host's mode. 

240 artifacts_uri: Optional[str] = Field(default=None, validation_alias="QUBER_PLAYGROUND_ARTIFACTS_URI") 

241 # The RunPod serverless endpoint the hosted playground sends its GPU 

242 # stages to, the docling parse and the chunk embedding, with the key that 

243 # authorizes the submission. Unset, both run on this host. 

244 runpod_endpoint_id: Optional[str] = Field(default=None, validation_alias="RUNPOD_ENDPOINT_ID") 

245 runpod_api_key: Optional[str] = Field(default=None, validation_alias="RUNPOD_API_KEY") 

246 # Where the hosted playground reports signed-in activity: a CloudWatch 

247 # namespace for one metric, the count of requests that carried a valid 

248 # session. The idle alarm watches it, because the public hostname's 

249 # load-balancer request count never reaches zero. Unset, nothing is sent. 

250 activity_namespace: Optional[str] = Field( 

251 default=None, validation_alias="QUBER_PLAYGROUND_ACTIVITY_NAMESPACE" 

252 ) 

253 # The sign-in the hosted playground asks for: the WorkOS environment's API 

254 # key and client ID, the one organization whose members may enter, and the 

255 # secret that signs the session cookie. The four are set together; all 

256 # unset disables the sign-in, which is the developer host's mode, and a 

257 # partial set is refused at startup rather than silently running open. 

258 workos_api_key: Optional[str] = Field(default=None, validation_alias="WORKOS_API_KEY") 

259 workos_client_id: Optional[str] = Field(default=None, validation_alias="WORKOS_CLIENT_ID") 

260 workos_organization_id: Optional[str] = Field(default=None, validation_alias="WORKOS_ORGANIZATION_ID") 

261 session_secret: Optional[str] = Field(default=None, validation_alias="PLAYGROUND_SESSION_SECRET") 

262 # Which step orders retrieved chunks before the answer model reads them: 

263 # the Haiku selection agent over a window of the fused ranking, or Jev 

264 # over the whole of it. A backend setting only; it never reaches a client 

265 # payload or the UI. 

266 ranker: Literal["haiku", "jev"] = Field(default="haiku", validation_alias="QUBER_PLAYGROUND_RANKER") 

267 # Questions a batch run answers at once. Each one holds an answer-model 

268 # call, and the provider returns 529 under load (observed 2026-07-29 

269 # during a sweep), so the default is deliberate. 

270 question_concurrency: int = Field(default=5, validation_alias="QUBER_PLAYGROUND_QUESTION_CONCURRENCY") 

271 

272 

273class ObservabilitySettings(BaseSettings): 

274 """Logfire + LangSmith tracing configuration.""" 

275 

276 model_config = BASE_CONFIG 

277 

278 logfire_token: Optional[str] = Field( 

279 default=None, 

280 validation_alias=AliasChoices("PYDANTIC_LOGFIRE_TOKEN", "LOGFIRE_TOKEN"), 

281 ) 

282 enable_logfire: LegacyBool = Field(default=True, validation_alias="QUBER_ENABLE_LOGFIRE") 

283 trace_to_langsmith: LegacyBool = Field(default=False, validation_alias="TRACE_TO_LANGSMITH") 

284 langsmith_api_key: Optional[str] = Field(default=None, validation_alias="CC_LANGSMITH_API_KEY") 

285 langsmith_project: str = Field(default="default", validation_alias="CC_LANGSMITH_PROJECT") 

286 langsmith_debug: LegacyBool = Field(default=False, validation_alias="CC_LANGSMITH_DEBUG") 

287 

288 

289class Settings(BaseSettings): 

290 """Root settings object. Access via :func:`get_settings`.""" 

291 

292 model_config = BASE_CONFIG 

293 

294 log_level: str = Field(default="INFO", validation_alias="QUBER_LOG_LEVEL") 

295 max_concurrent_tables: int = Field(default=5, validation_alias="QUBER_MAX_CONCURRENT_TABLES") 

296 

297 llm: LLMSettings = Field(default_factory=LLMSettings) 

298 db: DBSettings = Field(default_factory=DBSettings) 

299 s3_cache: S3CacheSettings = Field(default_factory=S3CacheSettings) 

300 embeddings: EmbeddingSettings = Field(default_factory=EmbeddingSettings) 

301 landing: LandingSettings = Field(default_factory=LandingSettings) 

302 typesafe: TypeSafeSettings = Field(default_factory=TypeSafeSettings) 

303 playground: PlaygroundSettings = Field(default_factory=PlaygroundSettings) 

304 obs: ObservabilitySettings = Field(default_factory=ObservabilitySettings) 

305 

306 

307@lru_cache 

308def get_settings() -> Settings: 

309 """Return the process-wide cached :class:`Settings` singleton. 

310 

311 Construction reads ``.env`` and the environment only — no network or 

312 credential resolution — so this is safe to call at import time. 

313 """ 

314 return Settings() 

315 

316 

317__all__ = [ 

318 "DEFAULT_LLM_MODEL", 

319 "DBSettings", 

320 "EmbeddingSettings", 

321 "LLMSettings", 

322 "LandingSettings", 

323 "ObservabilitySettings", 

324 "PlaygroundSettings", 

325 "S3CacheSettings", 

326 "Settings", 

327 "TypeSafeSettings", 

328 "get_settings", 

329]