gaard-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2174 @@
1
+ import json
2
+ import re
3
+ from datetime import datetime
4
+ from typing import Any, Annotated
5
+
6
+ from fastapi import APIRouter, Depends, Header, HTTPException, status
7
+ from gaard_connectors.sqlalchemy.executor import SQLAlchemyQueryExecutor
8
+ from gaard_core.errors import (
9
+ ConfigurationError,
10
+ LlmProviderError,
11
+ QueryExecutionError,
12
+ SqlValidationError,
13
+ )
14
+ from gaard_core.query_pipeline.models import (
15
+ OutputClassification,
16
+ QueryRequest,
17
+ QueryResponse,
18
+ QueryResult,
19
+ )
20
+ from gaard_core.sql_validator.select_only import SelectOnlySqlValidator
21
+ from pydantic import BaseModel, Field
22
+ from sqlalchemy import select
23
+ from sqlalchemy.exc import SQLAlchemyError
24
+ from sqlalchemy.orm import Session
25
+
26
+ from gaard_api.admin.database import get_session
27
+ from gaard_api.admin.models import (
28
+ AdminSession,
29
+ AdminUser,
30
+ BusinessLogicSuggestion,
31
+ DatasourceConnector,
32
+ DatasourceSchemaCache,
33
+ OverviewWidget,
34
+ PromptTemplate,
35
+ )
36
+ from gaard_api.admin.security import (
37
+ create_session_token,
38
+ hash_password,
39
+ hash_token,
40
+ verify_password,
41
+ )
42
+ from gaard_api.admin.services import (
43
+ BUSINESS_LOGIC_STATUS_ACTIVE,
44
+ BUSINESS_LOGIC_STATUS_PENDING,
45
+ OVERVIEW_WIDGET_RESULT_DATA,
46
+ OVERVIEW_WIDGET_RESULT_INTERPRETATION,
47
+ OVERVIEW_WIDGET_SCALAR,
48
+ OVERVIEW_WIDGET_TABLE,
49
+ OVERVIEW_WIDGET_TIMESERIES,
50
+ apply_data_query_audit_retention,
51
+ coerce_data_query_audit_type,
52
+ delete_business_logic_suggestion,
53
+ get_active_datasource_connector,
54
+ get_business_logic_suggestion,
55
+ get_data_query_audit_retention_days,
56
+ get_data_query_audit_type,
57
+ get_datasource_connector,
58
+ get_datasource_connector_by_key,
59
+ get_or_create_datasource_schema_cache,
60
+ get_datasource_schema_cache,
61
+ get_governance_policy_config,
62
+ get_governance_policy_sources,
63
+ get_llm_config_sources,
64
+ get_llm_runtime_config,
65
+ get_query_runtime_config,
66
+ get_overview_widget,
67
+ get_prompt_template,
68
+ get_setting,
69
+ introspect_datasource_connector,
70
+ is_system_datasource_connector,
71
+ json_loads,
72
+ list_admin_audit_logs,
73
+ list_all_overview_widgets,
74
+ list_business_logic_suggestions,
75
+ list_data_query_audit_logs,
76
+ list_datasource_connectors,
77
+ list_overview_widgets,
78
+ list_prompt_templates,
79
+ learn_business_logic_from_sql_error,
80
+ mask_database_url,
81
+ record_admin_audit,
82
+ record_data_query_audit,
83
+ record_data_query_sql_error_audit,
84
+ selected_schema_from_cache,
85
+ set_business_logic_suggestion_enabled,
86
+ set_governance_policy_config,
87
+ set_llm_runtime_config,
88
+ set_query_runtime_config,
89
+ set_setting,
90
+ set_active_datasource_connector,
91
+ test_datasource_connection,
92
+ update_business_logic_suggestion_content,
93
+ update_schema_table_settings,
94
+ validate_datasource_url,
95
+ )
96
+ from gaard_api.api.v1.schema import get_schema_cache_key
97
+ from gaard_api.core.schema_cache import schema_context_cache
98
+ from gaard_api.core.settings import settings
99
+
100
+ router = APIRouter()
101
+
102
+
103
+ class LoginRequest(BaseModel):
104
+ username: str = Field(min_length=1)
105
+ password: str = Field(min_length=1)
106
+
107
+
108
+ class LoginResponse(BaseModel):
109
+ token: str
110
+ username: str
111
+ must_change_password: bool
112
+
113
+
114
+ class ChangePasswordRequest(BaseModel):
115
+ current_password: str = Field(min_length=1)
116
+ new_password: str = Field(min_length=8)
117
+
118
+
119
+ class MeResponse(BaseModel):
120
+ username: str
121
+ must_change_password: bool
122
+
123
+
124
+ class PromptUpdateRequest(BaseModel):
125
+ name: str = Field(min_length=1)
126
+ description: str = ""
127
+ system_prompt: str = Field(min_length=1)
128
+ user_prompt_template: str = Field(min_length=1)
129
+ active: bool = True
130
+
131
+
132
+ class AuditSettingsRequest(BaseModel):
133
+ data_query_retention_days: int = Field(ge=1, le=3650)
134
+
135
+
136
+ class SchemaCacheSettingsRequest(BaseModel):
137
+ ttl_seconds: int = Field(ge=1, le=86_400)
138
+
139
+
140
+ class LlmConfigRequest(BaseModel):
141
+ provider: str = Field(min_length=1)
142
+ base_url: str = Field(min_length=1)
143
+ api_key: str | None = None
144
+ clear_api_key: bool = False
145
+ model: str = Field(min_length=1)
146
+ timeout_seconds: int | None = Field(default=None, ge=1, le=600)
147
+ extra_body: dict[str, Any] = Field(default_factory=dict)
148
+ intent_classification_mode: str | None = Field(
149
+ default=None,
150
+ pattern=r"^(auto|llm)$",
151
+ )
152
+ sql_generation_mode: str | None = Field(
153
+ default=None,
154
+ pattern=r"^llm$",
155
+ )
156
+ result_interpretation_mode: str | None = Field(
157
+ default=None,
158
+ pattern=r"^llm$",
159
+ )
160
+ output_classification_mode: str | None = Field(
161
+ default=None,
162
+ pattern=r"^(auto|llm)$",
163
+ )
164
+ investigation_mode: str | None = Field(
165
+ default=None,
166
+ pattern=r"^llm$",
167
+ )
168
+ investigation_ambiguity_mode: str | None = Field(
169
+ default=None,
170
+ pattern=r"^(clarify|safe_aggregate)$",
171
+ )
172
+ query_max_rows: int | None = Field(default=None, ge=1, le=100_000)
173
+ query_timeout_seconds: int | None = Field(
174
+ default=None,
175
+ ge=1,
176
+ le=3_600,
177
+ )
178
+
179
+
180
+ class GovernancePolicyRequest(BaseModel):
181
+ final_answer: dict[str, Any] = Field(default_factory=dict)
182
+ sql: dict[str, Any] = Field(default_factory=dict)
183
+ privacy: dict[str, Any] = Field(default_factory=dict)
184
+ pii_column_names: dict[str, list[str]] | list[str] = Field(default_factory=dict)
185
+
186
+
187
+ class OverviewWidgetUpdateRequest(BaseModel):
188
+ label: str = Field(min_length=1, max_length=255)
189
+ widget_type: str = Field(pattern=r"^(scalar|timeseries|table)$")
190
+ datasource_key: str = Field(min_length=1, max_length=255)
191
+ question: str = Field(min_length=1)
192
+ result_mode: str = Field(default=OVERVIEW_WIDGET_RESULT_DATA, pattern=r"^(data|interpretation)$")
193
+ position: int | None = Field(default=None, ge=10)
194
+ grid_width: int | None = Field(default=None, ge=1, le=4)
195
+ active: bool | None = None
196
+
197
+
198
+ class OverviewWidgetCreateRequest(OverviewWidgetUpdateRequest):
199
+ widget_key: str = Field(min_length=1, max_length=255, pattern=r"^[a-zA-Z0-9_-]+$")
200
+ position: int = Field(default=100, ge=10)
201
+ grid_width: int | None = Field(default=None, ge=1, le=4)
202
+ active: bool = True
203
+
204
+
205
+ class OverviewWidgetStateRequest(BaseModel):
206
+ active: bool
207
+ position: int | None = Field(default=None, ge=10)
208
+ grid_width: int | None = Field(default=None, ge=1, le=4)
209
+
210
+
211
+ class OverviewWidgetFromQueryRequest(BaseModel):
212
+ label: str = Field(min_length=1, max_length=255)
213
+ widget_type: str = Field(default=OVERVIEW_WIDGET_TABLE, pattern=r"^(scalar|timeseries|table)$")
214
+ datasource_key: str = Field(default="default", min_length=1, max_length=255)
215
+ question: str = Field(min_length=1)
216
+ sql: str = Field(min_length=1)
217
+ result_mode: str = Field(default=OVERVIEW_WIDGET_RESULT_DATA, pattern=r"^(data|interpretation)$")
218
+
219
+
220
+ class DatasourceConnectorRequest(BaseModel):
221
+ connector_key: str = Field(min_length=1, pattern=r"^[a-zA-Z0-9_-]+$")
222
+ name: str = Field(min_length=1)
223
+ database_type: str = Field(pattern=r"^(sqlite|postgresql|mysql)$")
224
+ database_url: str = Field(min_length=1)
225
+ sql_dialect: str = Field(pattern=r"^(sqlite|postgres|mysql)$")
226
+ active: bool = False
227
+
228
+
229
+ class DatasourceConnectorUpdateRequest(BaseModel):
230
+ name: str = Field(min_length=1)
231
+ database_type: str = Field(pattern=r"^(sqlite|postgresql|mysql)$")
232
+ database_url: str = Field(min_length=1)
233
+ sql_dialect: str = Field(pattern=r"^(sqlite|postgres|mysql)$")
234
+ active: bool = False
235
+
236
+
237
+ class DatasourceConnectionTestRequest(BaseModel):
238
+ database_type: str = Field(pattern=r"^(sqlite|postgresql|mysql)$")
239
+ database_url: str = Field(min_length=1)
240
+
241
+
242
+ class DatasourceSchemaTableSettingsRequest(BaseModel):
243
+ tables: dict[str, dict[str, Any]]
244
+
245
+
246
+ class BusinessLogicSuggestionUpdateRequest(BaseModel):
247
+ enabled: bool | None = None
248
+ title: str | None = Field(default=None, min_length=1)
249
+ rule_text: str | None = Field(default=None, min_length=1)
250
+
251
+
252
+ def serialize_datetime(value: datetime) -> str:
253
+ return value.isoformat()
254
+
255
+
256
+ def get_runtime_schema_cache_key(session: Session) -> str:
257
+ connector = get_active_datasource_connector(session)
258
+
259
+ if connector is None:
260
+ return get_schema_cache_key()
261
+
262
+ return get_schema_cache_key(connector.database_url, connector.sql_dialect)
263
+
264
+
265
+ def serialize_prompt(prompt: PromptTemplate) -> dict[str, Any]:
266
+ return {
267
+ "prompt_key": prompt.prompt_key,
268
+ "name": prompt.name,
269
+ "description": prompt.description,
270
+ "system_prompt": prompt.system_prompt,
271
+ "user_prompt_template": prompt.user_prompt_template,
272
+ "version": prompt.version,
273
+ "active": prompt.active,
274
+ "updated_by": prompt.updated_by,
275
+ "updated_at": serialize_datetime(prompt.updated_at),
276
+ }
277
+
278
+
279
+ def serialize_datasource(connector: DatasourceConnector) -> dict[str, Any]:
280
+ return {
281
+ "id": connector.id,
282
+ "connector_key": connector.connector_key,
283
+ "name": connector.name,
284
+ "database_type": connector.database_type,
285
+ "database_url": connector.database_url,
286
+ "masked_database_url": mask_database_url(connector.database_url),
287
+ "sql_dialect": connector.sql_dialect,
288
+ "active": connector.active,
289
+ "system_managed": is_system_datasource_connector(connector),
290
+ "updated_by": connector.updated_by,
291
+ "updated_at": serialize_datetime(connector.updated_at),
292
+ }
293
+
294
+
295
+ def serialize_datasource_schema(cache: DatasourceSchemaCache) -> dict[str, Any]:
296
+ return {
297
+ "schema": selected_schema_from_cache(cache).model_dump(),
298
+ "raw_schema": json_loads(cache.schema_json),
299
+ "table_settings": json_loads(cache.table_settings_json),
300
+ "formatted_schema": cache.formatted_schema,
301
+ "introspected_at": serialize_datetime(cache.introspected_at),
302
+ "updated_by": cache.updated_by,
303
+ }
304
+
305
+
306
+ def serialize_llm_config(session: Session) -> dict[str, Any]:
307
+ llm_config = get_llm_runtime_config(session)
308
+ query_config = get_query_runtime_config(session)
309
+ api_key_configured = bool(llm_config.api_key and llm_config.api_key != "change-me")
310
+
311
+ return {
312
+ "provider": llm_config.provider,
313
+ "base_url": llm_config.base_url,
314
+ "api_key_configured": api_key_configured,
315
+ "api_key_preview": mask_secret(llm_config.api_key) if api_key_configured else None,
316
+ "model": llm_config.model,
317
+ "timeout_seconds": llm_config.timeout_seconds,
318
+ "extra_body": llm_config.extra_body,
319
+ "extra_body_json": json_dumps_pretty(llm_config.extra_body),
320
+ "intent_classification_mode": query_config.intent_classification_mode,
321
+ "sql_generation_mode": query_config.sql_generation_mode,
322
+ "result_interpretation_mode": query_config.result_interpretation_mode,
323
+ "output_classification_mode": query_config.output_classification_mode,
324
+ "investigation_mode": query_config.investigation_mode,
325
+ "investigation_ambiguity_mode": query_config.investigation_ambiguity_mode,
326
+ "query_max_rows": query_config.query_max_rows,
327
+ "query_timeout_seconds": query_config.query_timeout_seconds,
328
+ "sources": get_llm_config_sources(session),
329
+ }
330
+
331
+
332
+ def mask_secret(value: str) -> str:
333
+ if len(value) <= 4:
334
+ return "****"
335
+ return f"****{value[-4:]}"
336
+
337
+
338
+ def serialize_governance_policy(session: Session) -> dict[str, Any]:
339
+ config = get_governance_policy_config(session)
340
+
341
+ return {
342
+ **config,
343
+ "governance_policy_json": json_dumps_pretty(config),
344
+ "sources": get_governance_policy_sources(session),
345
+ }
346
+
347
+
348
+ def serialize_overview_widget_config(widget: OverviewWidget) -> dict[str, Any]:
349
+ return {
350
+ "widget_key": widget.widget_key,
351
+ "label": widget.label,
352
+ "widget_type": widget.widget_type,
353
+ "datasource_key": widget.datasource_key,
354
+ "question": widget.question,
355
+ "sql": widget.sql,
356
+ "result_mode": normalize_overview_widget_result_mode(widget.result_mode),
357
+ "position": widget.position,
358
+ "grid_width": normalize_overview_widget_grid_width(
359
+ widget.widget_type,
360
+ widget.grid_width,
361
+ ),
362
+ "active": widget.active,
363
+ "updated_by": widget.updated_by,
364
+ "updated_at": serialize_datetime(widget.updated_at),
365
+ }
366
+
367
+
368
+ def normalize_overview_widget_grid_width(
369
+ widget_type: str,
370
+ grid_width: int | None,
371
+ ) -> int:
372
+ if grid_width is None:
373
+ return 4 if widget_type in {OVERVIEW_WIDGET_TABLE, OVERVIEW_WIDGET_TIMESERIES} else 1
374
+
375
+ return max(1, min(4, int(grid_width)))
376
+
377
+
378
+ def normalize_overview_widget_result_mode(value: str | None) -> str:
379
+ if value == OVERVIEW_WIDGET_RESULT_INTERPRETATION:
380
+ return OVERVIEW_WIDGET_RESULT_INTERPRETATION
381
+
382
+ return OVERVIEW_WIDGET_RESULT_DATA
383
+
384
+
385
+ def build_client_widget_key(session: Session, label: str, question: str) -> str:
386
+ seed = label.strip() or question.strip() or "query_widget"
387
+ normalized = re.sub(r"[^a-z0-9_-]+", "_", seed.lower()).strip("_-")
388
+ normalized = normalized[:48].strip("_-") or "query_widget"
389
+ base_key = f"client_{normalized}"
390
+ widget_key = base_key
391
+ suffix = 2
392
+
393
+ while get_overview_widget(session, widget_key) is not None:
394
+ suffix_text = f"_{suffix}"
395
+ widget_key = f"{base_key[:255 - len(suffix_text)]}{suffix_text}"
396
+ suffix += 1
397
+
398
+ return widget_key
399
+
400
+
401
+ def serialize_overview_widget(
402
+ session: Session,
403
+ widget: OverviewWidget,
404
+ ) -> dict[str, Any]:
405
+ return {
406
+ **serialize_overview_widget_config(widget),
407
+ "result": execute_overview_widget(session, widget),
408
+ }
409
+
410
+
411
+ def execute_overview_widget(
412
+ session: Session,
413
+ widget: OverviewWidget,
414
+ ) -> dict[str, Any]:
415
+ connector = get_datasource_connector_by_key(session, widget.datasource_key)
416
+
417
+ if connector is None:
418
+ return widget_error(f"Datasource '{widget.datasource_key}' does not exist.")
419
+
420
+ if not widget.sql.strip():
421
+ return widget_error("Widget SQL has not been generated yet.")
422
+
423
+ try:
424
+ result = execute_overview_sql(session, connector, widget.sql)
425
+ payload = validate_overview_widget_result(widget, result)
426
+ payload["result_mode"] = normalize_overview_widget_result_mode(widget.result_mode)
427
+
428
+ if payload["result_mode"] == OVERVIEW_WIDGET_RESULT_INTERPRETATION:
429
+ interpretation = interpret_overview_widget_result(connector, widget, result)
430
+ payload["answer"] = interpretation
431
+ payload["interpretation"] = interpretation
432
+ payload["value"] = interpretation
433
+
434
+ return payload
435
+ except QueryExecutionError as exc:
436
+ return widget_error(exc.message, sql=exc.sql)
437
+ except (
438
+ ConfigurationError,
439
+ LlmProviderError,
440
+ SQLAlchemyError,
441
+ SqlValidationError,
442
+ ValueError,
443
+ TypeError,
444
+ ) as exc:
445
+ return widget_error(str(exc), sql=widget.sql)
446
+
447
+
448
+ def interpret_overview_widget_result(
449
+ connector: DatasourceConnector,
450
+ widget: OverviewWidget,
451
+ result: QueryResult,
452
+ ) -> str:
453
+ from gaard_api.api.v1.query import create_result_interpreter
454
+
455
+ query_request = build_overview_widget_query_request(
456
+ connector=connector,
457
+ widget_key=widget.widget_key,
458
+ question=widget.question,
459
+ )
460
+
461
+ return create_result_interpreter().interpret(
462
+ request=query_request,
463
+ result=result,
464
+ sql=widget.sql,
465
+ )
466
+
467
+
468
+ def execute_overview_sql(
469
+ session: Session,
470
+ connector: DatasourceConnector,
471
+ sql: str,
472
+ ) -> QueryResult:
473
+ SelectOnlySqlValidator(dialect=connector.sql_dialect).validate(sql)
474
+
475
+ return SQLAlchemyQueryExecutor(
476
+ database_url=connector.database_url,
477
+ max_rows=get_query_runtime_config(session).query_max_rows,
478
+ ).execute(sql)
479
+
480
+
481
+ def generate_overview_widget_sql(
482
+ session: Session,
483
+ connector: DatasourceConnector,
484
+ query_request: QueryRequest,
485
+ actor: str,
486
+ ) -> str:
487
+ schema_cache = get_or_create_datasource_schema_cache(session, connector, actor)
488
+ session.commit()
489
+
490
+ from gaard_api.api.v1.query import create_sql_generator
491
+
492
+ generated_sql = create_sql_generator((connector, schema_cache)).generate(query_request)
493
+ SelectOnlySqlValidator(dialect=connector.sql_dialect).validate(generated_sql.sql)
494
+
495
+ return generated_sql.sql
496
+
497
+
498
+ def build_overview_widget_query_request(
499
+ connector: DatasourceConnector,
500
+ widget_key: str,
501
+ question: str,
502
+ ) -> QueryRequest:
503
+ return QueryRequest(
504
+ question=question,
505
+ datasource_id=connector.connector_key,
506
+ user_id=f"overview-widget-config:{widget_key}",
507
+ )
508
+
509
+
510
+ def record_overview_widget_query_audit(
511
+ query_request: QueryRequest,
512
+ widget: OverviewWidget,
513
+ generated_sql: str,
514
+ actor: str,
515
+ ) -> None:
516
+ record_data_query_audit(
517
+ query_request,
518
+ QueryResponse(
519
+ question=query_request.question,
520
+ answer="Generated SQL for overview widget source.",
521
+ sql=generated_sql,
522
+ rows=[],
523
+ metadata={
524
+ "actor": actor,
525
+ "operation": "overview_widget.update",
526
+ "widget_key": widget.widget_key,
527
+ "widget_type": widget.widget_type,
528
+ "result_mode": normalize_overview_widget_result_mode(widget.result_mode),
529
+ },
530
+ ),
531
+ )
532
+
533
+
534
+ def record_overview_widget_sql_error_audit(
535
+ query_request: QueryRequest,
536
+ connector: DatasourceConnector,
537
+ sql: str,
538
+ error_code: str,
539
+ error_message: str,
540
+ error_detail: str,
541
+ actor: str,
542
+ ) -> None:
543
+ audit_log = record_data_query_sql_error_audit(
544
+ request=query_request,
545
+ sql=sql,
546
+ error_code=error_code,
547
+ error_message=error_message,
548
+ error_detail=error_detail,
549
+ )
550
+ learn_business_logic_from_sql_error(
551
+ connector_id=connector.id,
552
+ audit_id=audit_log.id if audit_log is not None else None,
553
+ actor=actor,
554
+ )
555
+
556
+
557
+ def validate_overview_widget_result(
558
+ widget: OverviewWidget,
559
+ result: QueryResult,
560
+ ) -> dict[str, Any]:
561
+ rows = result.rows
562
+ columns = result.columns
563
+
564
+ if widget.widget_type == OVERVIEW_WIDGET_SCALAR:
565
+ if len(rows) != 1 or len(columns) != 1:
566
+ raise ValueError("Scalar widgets must return exactly one row and one column.")
567
+
568
+ value = rows[0][columns[0]]
569
+ return {
570
+ "status": "ok",
571
+ "value": value,
572
+ "columns": columns,
573
+ "rows": rows,
574
+ "answer": json.dumps(rows[0], ensure_ascii=False, default=str),
575
+ "sql": widget.sql,
576
+ }
577
+
578
+ if widget.widget_type == OVERVIEW_WIDGET_TIMESERIES:
579
+ if rows:
580
+ validate_timeseries_rows(columns, rows)
581
+
582
+ return {
583
+ "status": "ok",
584
+ "columns": columns,
585
+ "rows": rows,
586
+ "answer": json.dumps(rows, ensure_ascii=False, default=str),
587
+ "sql": widget.sql,
588
+ }
589
+
590
+ if widget.widget_type == OVERVIEW_WIDGET_TABLE:
591
+ if not columns and rows:
592
+ raise ValueError("Table widgets must return named columns.")
593
+
594
+ return {
595
+ "status": "ok",
596
+ "columns": columns,
597
+ "rows": rows,
598
+ "answer": json.dumps(rows, ensure_ascii=False, default=str),
599
+ "sql": widget.sql,
600
+ }
601
+
602
+ raise ValueError(f"Unsupported widget type: {widget.widget_type}")
603
+
604
+
605
+ def validate_timeseries_rows(
606
+ columns: list[str],
607
+ rows: list[dict[str, Any]],
608
+ ) -> None:
609
+ if len(columns) < 2:
610
+ raise ValueError("Time-series widgets must return at least two columns.")
611
+
612
+ date_column = columns[0]
613
+
614
+ for row in rows:
615
+ if not is_date_like(row.get(date_column)):
616
+ raise ValueError("The first time-series column must contain date values.")
617
+
618
+ if len(columns) == 3 and all(is_number_like(row.get(columns[2])) for row in rows):
619
+ return
620
+
621
+ for row in rows:
622
+ values = [row.get(column) for column in columns[1:]]
623
+
624
+ if not values or not all(is_number_like(value) for value in values):
625
+ raise ValueError(
626
+ "Time-series widgets must return numeric value columns, or "
627
+ "date/category/value columns."
628
+ )
629
+
630
+
631
+ def is_date_like(value: Any) -> bool:
632
+ if isinstance(value, datetime):
633
+ return True
634
+
635
+ if not isinstance(value, str) or not value.strip():
636
+ return False
637
+
638
+ normalized = value.strip().replace("Z", "+00:00")
639
+
640
+ try:
641
+ datetime.fromisoformat(normalized)
642
+ return True
643
+ except ValueError:
644
+ return False
645
+
646
+
647
+ def is_number_like(value: Any) -> bool:
648
+ if isinstance(value, bool) or value is None:
649
+ return False
650
+
651
+ if isinstance(value, int | float):
652
+ return True
653
+
654
+ if isinstance(value, str):
655
+ try:
656
+ float(value)
657
+ return True
658
+ except ValueError:
659
+ return False
660
+
661
+ return False
662
+
663
+
664
+ def widget_error(message: str, sql: str = "") -> dict[str, Any]:
665
+ return {
666
+ "status": "error",
667
+ "error": message,
668
+ "sql": sql,
669
+ "columns": [],
670
+ "rows": [],
671
+ }
672
+
673
+
674
+ def json_dumps_pretty(value: dict[str, Any]) -> str:
675
+ return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
676
+
677
+
678
+ def serialize_business_logic_suggestion(
679
+ suggestion: BusinessLogicSuggestion,
680
+ ) -> dict[str, Any]:
681
+ return {
682
+ "id": suggestion.id,
683
+ "connector_id": suggestion.connector_id,
684
+ "source_audit_id": suggestion.source_audit_id,
685
+ "status": suggestion.status,
686
+ "safety": suggestion.safety,
687
+ "enabled": suggestion.enabled,
688
+ "error_category": suggestion.error_category,
689
+ "title": suggestion.title,
690
+ "rule_text": suggestion.rule_text,
691
+ "terms": json_loads(suggestion.terms_json),
692
+ "join_hints": json_loads(suggestion.join_hints_json),
693
+ "failed_identifier": suggestion.failed_identifier,
694
+ "repaired_identifier": suggestion.repaired_identifier,
695
+ "confidence": suggestion.confidence,
696
+ "created_at": serialize_datetime(suggestion.created_at),
697
+ "updated_at": serialize_datetime(suggestion.updated_at),
698
+ "updated_by": suggestion.updated_by,
699
+ }
700
+
701
+
702
+ def get_authorization_token(authorization: str | None) -> str:
703
+ if authorization is None:
704
+ raise HTTPException(
705
+ status_code=status.HTTP_401_UNAUTHORIZED,
706
+ detail="Missing Authorization header.",
707
+ )
708
+
709
+ scheme, _, token = authorization.partition(" ")
710
+
711
+ if scheme.lower() != "bearer" or not token:
712
+ raise HTTPException(
713
+ status_code=status.HTTP_401_UNAUTHORIZED,
714
+ detail="Invalid Authorization header.",
715
+ )
716
+
717
+ return token
718
+
719
+
720
+ def get_current_admin_allow_password_change(
721
+ authorization: Annotated[str | None, Header()] = None,
722
+ session: Session = Depends(get_session),
723
+ ) -> AdminUser:
724
+ token = get_authorization_token(authorization)
725
+ token_hash = hash_token(token)
726
+
727
+ admin_session = session.scalar(
728
+ select(AdminSession).where(AdminSession.token_hash == token_hash)
729
+ )
730
+
731
+ if admin_session is None:
732
+ raise HTTPException(
733
+ status_code=status.HTTP_401_UNAUTHORIZED,
734
+ detail="Invalid admin session.",
735
+ )
736
+
737
+ user = session.get(AdminUser, admin_session.user_id)
738
+
739
+ if user is None:
740
+ raise HTTPException(
741
+ status_code=status.HTTP_401_UNAUTHORIZED,
742
+ detail="Invalid admin session.",
743
+ )
744
+
745
+ return user
746
+
747
+
748
+ def get_current_admin(
749
+ user: AdminUser = Depends(get_current_admin_allow_password_change),
750
+ ) -> AdminUser:
751
+ if user.must_change_password:
752
+ raise HTTPException(
753
+ status_code=status.HTTP_403_FORBIDDEN,
754
+ detail="Password change is required before using the admin portal.",
755
+ )
756
+
757
+ return user
758
+
759
+
760
+ @router.post("/auth/login", response_model=LoginResponse)
761
+ def login(request: LoginRequest, session: Session = Depends(get_session)) -> LoginResponse:
762
+ user = session.scalar(select(AdminUser).where(AdminUser.username == request.username))
763
+
764
+ if user is None or not verify_password(request.password, user.password_hash):
765
+ raise HTTPException(
766
+ status_code=status.HTTP_401_UNAUTHORIZED,
767
+ detail="Invalid username or password.",
768
+ )
769
+
770
+ token = create_session_token()
771
+ session.add(AdminSession(token_hash=hash_token(token), user_id=user.id))
772
+ record_admin_audit(
773
+ session=session,
774
+ actor=user.username,
775
+ action="auth.login",
776
+ resource_type="admin_user",
777
+ resource_id=user.username,
778
+ )
779
+ session.commit()
780
+
781
+ return LoginResponse(
782
+ token=token,
783
+ username=user.username,
784
+ must_change_password=user.must_change_password,
785
+ )
786
+
787
+
788
+ @router.get("/me", response_model=MeResponse)
789
+ def get_me(
790
+ user: AdminUser = Depends(get_current_admin_allow_password_change),
791
+ ) -> MeResponse:
792
+ return MeResponse(
793
+ username=user.username,
794
+ must_change_password=user.must_change_password,
795
+ )
796
+
797
+
798
+ @router.post("/auth/change-password", response_model=MeResponse)
799
+ def change_password(
800
+ request: ChangePasswordRequest,
801
+ user: AdminUser = Depends(get_current_admin_allow_password_change),
802
+ session: Session = Depends(get_session),
803
+ ) -> MeResponse:
804
+ user = session.merge(user)
805
+
806
+ if not verify_password(request.current_password, user.password_hash):
807
+ raise HTTPException(
808
+ status_code=status.HTTP_400_BAD_REQUEST,
809
+ detail="Current password is invalid.",
810
+ )
811
+
812
+ user.password_hash = hash_password(request.new_password)
813
+ user.must_change_password = False
814
+ record_admin_audit(
815
+ session=session,
816
+ actor=user.username,
817
+ action="auth.change_password",
818
+ resource_type="admin_user",
819
+ resource_id=user.username,
820
+ )
821
+ session.commit()
822
+
823
+ return MeResponse(username=user.username, must_change_password=False)
824
+
825
+
826
+ @router.get("/overview")
827
+ def get_overview(
828
+ user: AdminUser = Depends(get_current_admin),
829
+ session: Session = Depends(get_session),
830
+ ) -> dict[str, Any]:
831
+ prompts = list_prompt_templates(session)
832
+ retention_days = get_data_query_audit_retention_days(session)
833
+ widgets = [
834
+ serialize_overview_widget(session, widget)
835
+ for widget in list_overview_widgets(session)
836
+ ]
837
+
838
+ return {
839
+ "admin": {
840
+ "username": user.username,
841
+ },
842
+ "license": {
843
+ "edition": "community",
844
+ "status": "active",
845
+ },
846
+ "prompts_count": len(prompts),
847
+ "data_query_audit_retention_days": retention_days,
848
+ "schema_cache_ttl_seconds": schema_context_cache.ttl_seconds,
849
+ "schema_cache_key": get_runtime_schema_cache_key(session),
850
+ "datasources": [
851
+ {
852
+ "connector_key": connector.connector_key,
853
+ "name": connector.name,
854
+ "database_type": connector.database_type,
855
+ "masked_database_url": mask_database_url(connector.database_url),
856
+ "active": connector.active,
857
+ }
858
+ for connector in list_datasource_connectors(session)
859
+ ],
860
+ "widgets": widgets,
861
+ "info_widgets": [
862
+ widget
863
+ for widget in widgets
864
+ if widget["widget_type"] == OVERVIEW_WIDGET_SCALAR
865
+ ][:4],
866
+ "runtime_widget": next(
867
+ (
868
+ widget
869
+ for widget in widgets
870
+ if widget["widget_type"] == OVERVIEW_WIDGET_TIMESERIES
871
+ ),
872
+ None,
873
+ ),
874
+ "table_widgets": [
875
+ widget
876
+ for widget in widgets
877
+ if widget["widget_type"] == OVERVIEW_WIDGET_TABLE
878
+ ],
879
+ }
880
+
881
+
882
+ @router.get("/overview/widgets")
883
+ def get_overview_widget_configs(
884
+ user: AdminUser = Depends(get_current_admin),
885
+ session: Session = Depends(get_session),
886
+ ) -> dict[str, Any]:
887
+ return {
888
+ "items": [
889
+ serialize_overview_widget_config(widget)
890
+ for widget in list_all_overview_widgets(session)
891
+ ],
892
+ "datasources": [
893
+ {
894
+ "connector_key": connector.connector_key,
895
+ "name": connector.name,
896
+ "database_type": connector.database_type,
897
+ "masked_database_url": mask_database_url(connector.database_url),
898
+ "active": connector.active,
899
+ }
900
+ for connector in list_datasource_connectors(session)
901
+ ],
902
+ }
903
+
904
+
905
+ @router.post("/overview/widgets")
906
+ def create_overview_widget(
907
+ request: OverviewWidgetCreateRequest,
908
+ user: AdminUser = Depends(get_current_admin),
909
+ session: Session = Depends(get_session),
910
+ ) -> dict[str, Any]:
911
+ if get_overview_widget(session, request.widget_key) is not None:
912
+ raise HTTPException(
913
+ status_code=status.HTTP_409_CONFLICT,
914
+ detail="Overview widget already exists.",
915
+ )
916
+
917
+ datasource = get_datasource_connector_by_key(session, request.datasource_key)
918
+
919
+ if datasource is None:
920
+ raise HTTPException(
921
+ status_code=status.HTTP_400_BAD_REQUEST,
922
+ detail="Datasource does not exist.",
923
+ )
924
+
925
+ query_request = build_overview_widget_query_request(
926
+ connector=datasource,
927
+ widget_key=request.widget_key,
928
+ question=request.question,
929
+ )
930
+ generated_sql = ""
931
+ next_grid_width = normalize_overview_widget_grid_width(
932
+ request.widget_type,
933
+ request.grid_width,
934
+ )
935
+ next_result_mode = normalize_overview_widget_result_mode(request.result_mode)
936
+
937
+ try:
938
+ generated_sql = generate_overview_widget_sql(
939
+ session=session,
940
+ connector=datasource,
941
+ query_request=query_request,
942
+ actor=user.username,
943
+ )
944
+ widget = OverviewWidget(
945
+ widget_key=request.widget_key,
946
+ label=request.label,
947
+ widget_type=request.widget_type,
948
+ datasource_key=request.datasource_key,
949
+ question=request.question,
950
+ sql=generated_sql,
951
+ result_mode=next_result_mode,
952
+ position=request.position,
953
+ grid_width=next_grid_width,
954
+ active=request.active,
955
+ updated_by=user.username,
956
+ )
957
+ validate_overview_widget_result(
958
+ widget,
959
+ execute_overview_sql(session, datasource, generated_sql),
960
+ )
961
+ except QueryExecutionError as exc:
962
+ record_overview_widget_sql_error_audit(
963
+ query_request=query_request,
964
+ connector=datasource,
965
+ sql=exc.sql or generated_sql,
966
+ error_code=exc.code,
967
+ error_message=exc.message,
968
+ error_detail=exc.error_detail,
969
+ actor=user.username,
970
+ )
971
+ session.rollback()
972
+ raise HTTPException(
973
+ status_code=status.HTTP_400_BAD_REQUEST,
974
+ detail=exc.message,
975
+ ) from exc
976
+ except (
977
+ ConfigurationError,
978
+ SQLAlchemyError,
979
+ SqlValidationError,
980
+ ValueError,
981
+ TypeError,
982
+ ) as exc:
983
+ session.rollback()
984
+ raise HTTPException(
985
+ status_code=status.HTTP_400_BAD_REQUEST,
986
+ detail=str(exc),
987
+ ) from exc
988
+
989
+ session.add(widget)
990
+ record_admin_audit(
991
+ session=session,
992
+ actor=user.username,
993
+ action="overview_widget.create",
994
+ resource_type="overview_widget",
995
+ resource_id=widget.widget_key,
996
+ details={
997
+ "label": widget.label,
998
+ "widget_type": widget.widget_type,
999
+ "datasource_key": widget.datasource_key,
1000
+ "result_mode": widget.result_mode,
1001
+ "position": widget.position,
1002
+ "grid_width": widget.grid_width,
1003
+ "active": widget.active,
1004
+ },
1005
+ )
1006
+ session.commit()
1007
+ record_overview_widget_query_audit(
1008
+ query_request=query_request,
1009
+ widget=widget,
1010
+ generated_sql=generated_sql,
1011
+ actor=user.username,
1012
+ )
1013
+
1014
+ return {
1015
+ "item": serialize_overview_widget(session, widget),
1016
+ }
1017
+
1018
+
1019
+ @router.post("/overview/widgets/from-query")
1020
+ def create_overview_widget_from_query(
1021
+ request: OverviewWidgetFromQueryRequest,
1022
+ session: Session = Depends(get_session),
1023
+ ) -> dict[str, Any]:
1024
+ datasource = get_datasource_connector_by_key(session, request.datasource_key)
1025
+
1026
+ if datasource is None:
1027
+ raise HTTPException(
1028
+ status_code=status.HTTP_400_BAD_REQUEST,
1029
+ detail="Datasource does not exist.",
1030
+ )
1031
+
1032
+ widget_key = build_client_widget_key(session, request.label, request.question)
1033
+ grid_width = normalize_overview_widget_grid_width(
1034
+ request.widget_type,
1035
+ None,
1036
+ )
1037
+ result_mode = normalize_overview_widget_result_mode(request.result_mode)
1038
+ widget = OverviewWidget(
1039
+ widget_key=widget_key,
1040
+ label=request.label,
1041
+ widget_type=request.widget_type,
1042
+ datasource_key=request.datasource_key,
1043
+ question=request.question,
1044
+ sql=request.sql,
1045
+ result_mode=result_mode,
1046
+ position=100,
1047
+ grid_width=grid_width,
1048
+ active=False,
1049
+ updated_by="client",
1050
+ )
1051
+
1052
+ try:
1053
+ validate_overview_widget_result(
1054
+ widget,
1055
+ execute_overview_sql(session, datasource, request.sql),
1056
+ )
1057
+ except QueryExecutionError as exc:
1058
+ session.rollback()
1059
+ raise HTTPException(
1060
+ status_code=status.HTTP_400_BAD_REQUEST,
1061
+ detail=exc.message,
1062
+ ) from exc
1063
+ except (
1064
+ ConfigurationError,
1065
+ SQLAlchemyError,
1066
+ SqlValidationError,
1067
+ ValueError,
1068
+ TypeError,
1069
+ ) as exc:
1070
+ session.rollback()
1071
+ raise HTTPException(
1072
+ status_code=status.HTTP_400_BAD_REQUEST,
1073
+ detail=str(exc),
1074
+ ) from exc
1075
+
1076
+ session.add(widget)
1077
+ record_admin_audit(
1078
+ session=session,
1079
+ actor="client",
1080
+ action="overview_widget.create_from_query",
1081
+ resource_type="overview_widget",
1082
+ resource_id=widget.widget_key,
1083
+ details={
1084
+ "label": widget.label,
1085
+ "widget_type": widget.widget_type,
1086
+ "datasource_key": widget.datasource_key,
1087
+ "result_mode": widget.result_mode,
1088
+ "active": widget.active,
1089
+ },
1090
+ )
1091
+ session.commit()
1092
+
1093
+ return {
1094
+ "item": serialize_overview_widget_config(widget),
1095
+ }
1096
+
1097
+
1098
+ @router.put("/overview/widgets/{widget_key}")
1099
+ def update_overview_widget(
1100
+ widget_key: str,
1101
+ request: OverviewWidgetUpdateRequest,
1102
+ user: AdminUser = Depends(get_current_admin),
1103
+ session: Session = Depends(get_session),
1104
+ ) -> dict[str, Any]:
1105
+ widget = get_overview_widget(session, widget_key)
1106
+
1107
+ if widget is None:
1108
+ raise HTTPException(
1109
+ status_code=status.HTTP_404_NOT_FOUND,
1110
+ detail="Overview widget not found.",
1111
+ )
1112
+
1113
+ datasource = get_datasource_connector_by_key(session, request.datasource_key)
1114
+
1115
+ if datasource is None:
1116
+ raise HTTPException(
1117
+ status_code=status.HTTP_400_BAD_REQUEST,
1118
+ detail="Datasource does not exist.",
1119
+ )
1120
+
1121
+ query_request = build_overview_widget_query_request(
1122
+ connector=datasource,
1123
+ widget_key=widget.widget_key,
1124
+ question=request.question,
1125
+ )
1126
+ generated_sql = ""
1127
+ next_position = request.position if request.position is not None else widget.position
1128
+ next_grid_width = normalize_overview_widget_grid_width(
1129
+ request.widget_type,
1130
+ request.grid_width if request.grid_width is not None else widget.grid_width,
1131
+ )
1132
+ next_result_mode = normalize_overview_widget_result_mode(request.result_mode)
1133
+ next_active = request.active if request.active is not None else widget.active
1134
+
1135
+ try:
1136
+ generated_sql = generate_overview_widget_sql(
1137
+ session=session,
1138
+ connector=datasource,
1139
+ query_request=query_request,
1140
+ actor=user.username,
1141
+ )
1142
+ probe_widget = OverviewWidget(
1143
+ widget_key=widget.widget_key,
1144
+ label=request.label,
1145
+ widget_type=request.widget_type,
1146
+ datasource_key=request.datasource_key,
1147
+ question=request.question,
1148
+ sql=generated_sql,
1149
+ result_mode=next_result_mode,
1150
+ position=next_position,
1151
+ grid_width=next_grid_width,
1152
+ active=next_active,
1153
+ )
1154
+ validate_overview_widget_result(
1155
+ probe_widget,
1156
+ execute_overview_sql(session, datasource, generated_sql),
1157
+ )
1158
+ except QueryExecutionError as exc:
1159
+ record_overview_widget_sql_error_audit(
1160
+ query_request=query_request,
1161
+ connector=datasource,
1162
+ sql=exc.sql or generated_sql,
1163
+ error_code=exc.code,
1164
+ error_message=exc.message,
1165
+ error_detail=exc.error_detail,
1166
+ actor=user.username,
1167
+ )
1168
+ session.rollback()
1169
+ raise HTTPException(
1170
+ status_code=status.HTTP_400_BAD_REQUEST,
1171
+ detail=exc.message,
1172
+ ) from exc
1173
+ except (
1174
+ ConfigurationError,
1175
+ SQLAlchemyError,
1176
+ SqlValidationError,
1177
+ ValueError,
1178
+ TypeError,
1179
+ ) as exc:
1180
+ session.rollback()
1181
+ raise HTTPException(
1182
+ status_code=status.HTTP_400_BAD_REQUEST,
1183
+ detail=str(exc),
1184
+ ) from exc
1185
+
1186
+ widget.label = request.label
1187
+ widget.widget_type = request.widget_type
1188
+ widget.datasource_key = request.datasource_key
1189
+ widget.question = request.question
1190
+ widget.sql = generated_sql
1191
+ widget.result_mode = next_result_mode
1192
+ widget.position = next_position
1193
+ widget.grid_width = next_grid_width
1194
+ widget.active = next_active
1195
+ widget.updated_by = user.username
1196
+
1197
+ record_admin_audit(
1198
+ session=session,
1199
+ actor=user.username,
1200
+ action="overview_widget.update",
1201
+ resource_type="overview_widget",
1202
+ resource_id=widget.widget_key,
1203
+ details={
1204
+ "label": widget.label,
1205
+ "widget_type": widget.widget_type,
1206
+ "datasource_key": widget.datasource_key,
1207
+ "result_mode": widget.result_mode,
1208
+ "position": widget.position,
1209
+ "grid_width": widget.grid_width,
1210
+ "active": widget.active,
1211
+ },
1212
+ )
1213
+ session.commit()
1214
+ record_overview_widget_query_audit(
1215
+ query_request=query_request,
1216
+ widget=widget,
1217
+ generated_sql=generated_sql,
1218
+ actor=user.username,
1219
+ )
1220
+
1221
+ return {
1222
+ "item": serialize_overview_widget(session, widget),
1223
+ }
1224
+
1225
+
1226
+ @router.patch("/overview/widgets/{widget_key}/state")
1227
+ def update_overview_widget_state(
1228
+ widget_key: str,
1229
+ request: OverviewWidgetStateRequest,
1230
+ user: AdminUser = Depends(get_current_admin),
1231
+ session: Session = Depends(get_session),
1232
+ ) -> dict[str, Any]:
1233
+ widget = get_overview_widget(session, widget_key)
1234
+
1235
+ if widget is None:
1236
+ raise HTTPException(
1237
+ status_code=status.HTTP_404_NOT_FOUND,
1238
+ detail="Overview widget not found.",
1239
+ )
1240
+
1241
+ widget.active = request.active
1242
+
1243
+ if request.position is not None:
1244
+ widget.position = request.position
1245
+
1246
+ if request.grid_width is not None:
1247
+ widget.grid_width = normalize_overview_widget_grid_width(
1248
+ widget.widget_type,
1249
+ request.grid_width,
1250
+ )
1251
+
1252
+ widget.updated_by = user.username
1253
+ record_admin_audit(
1254
+ session=session,
1255
+ actor=user.username,
1256
+ action="overview_widget.state",
1257
+ resource_type="overview_widget",
1258
+ resource_id=widget.widget_key,
1259
+ details={
1260
+ "position": widget.position,
1261
+ "grid_width": widget.grid_width,
1262
+ "active": widget.active,
1263
+ },
1264
+ )
1265
+ session.commit()
1266
+
1267
+ return {
1268
+ "item": serialize_overview_widget_config(widget),
1269
+ }
1270
+
1271
+
1272
+ @router.delete("/overview/widgets/{widget_key}")
1273
+ def delete_overview_widget(
1274
+ widget_key: str,
1275
+ user: AdminUser = Depends(get_current_admin),
1276
+ session: Session = Depends(get_session),
1277
+ ) -> dict[str, Any]:
1278
+ widget = get_overview_widget(session, widget_key)
1279
+
1280
+ if widget is None:
1281
+ raise HTTPException(
1282
+ status_code=status.HTTP_404_NOT_FOUND,
1283
+ detail="Overview widget not found.",
1284
+ )
1285
+
1286
+ record_admin_audit(
1287
+ session=session,
1288
+ actor=user.username,
1289
+ action="overview_widget.delete",
1290
+ resource_type="overview_widget",
1291
+ resource_id=widget.widget_key,
1292
+ details={
1293
+ "label": widget.label,
1294
+ "widget_type": widget.widget_type,
1295
+ "datasource_key": widget.datasource_key,
1296
+ },
1297
+ )
1298
+ session.delete(widget)
1299
+ session.commit()
1300
+
1301
+ return {
1302
+ "status": "deleted",
1303
+ "widget_key": widget_key,
1304
+ }
1305
+
1306
+
1307
+ @router.get("/audit/data-queries")
1308
+ def get_data_query_audit(
1309
+ limit: int = 100,
1310
+ audit_type: str | None = None,
1311
+ output_classification: str | None = None,
1312
+ sql_contains: str | None = None,
1313
+ user: AdminUser = Depends(get_current_admin),
1314
+ session: Session = Depends(get_session),
1315
+ ) -> dict[str, Any]:
1316
+ try:
1317
+ parsed_audit_type = coerce_data_query_audit_type(audit_type) if audit_type else None
1318
+ except ValueError as exc:
1319
+ raise HTTPException(
1320
+ status_code=status.HTTP_400_BAD_REQUEST,
1321
+ detail="Unsupported data query audit type.",
1322
+ ) from exc
1323
+
1324
+ try:
1325
+ parsed_output_classification = (
1326
+ OutputClassification(output_classification)
1327
+ if output_classification
1328
+ else None
1329
+ )
1330
+ except ValueError as exc:
1331
+ raise HTTPException(
1332
+ status_code=status.HTTP_400_BAD_REQUEST,
1333
+ detail="Unsupported output classification.",
1334
+ ) from exc
1335
+
1336
+ logs = list_data_query_audit_logs(
1337
+ session,
1338
+ limit=min(limit, 500),
1339
+ audit_type=parsed_audit_type,
1340
+ output_classification=parsed_output_classification,
1341
+ sql_contains=sql_contains,
1342
+ )
1343
+ session.commit()
1344
+
1345
+ return {
1346
+ "items": [
1347
+ {
1348
+ "id": item.id,
1349
+ "audit_type": get_data_query_audit_type(item),
1350
+ "occurred_at": serialize_datetime(item.occurred_at),
1351
+ "user_id": item.user_id,
1352
+ "datasource_id": item.datasource_id,
1353
+ "question": item.question,
1354
+ "answer": item.answer,
1355
+ "sql": item.sql,
1356
+ "output_classification": item.output_classification,
1357
+ "metadata": json_loads(item.metadata_json),
1358
+ }
1359
+ for item in logs
1360
+ ],
1361
+ "viewer": user.username,
1362
+ }
1363
+
1364
+
1365
+ @router.get("/audit/admin-events")
1366
+ def get_admin_audit(
1367
+ limit: int = 100,
1368
+ user: AdminUser = Depends(get_current_admin),
1369
+ session: Session = Depends(get_session),
1370
+ ) -> dict[str, Any]:
1371
+ logs = list_admin_audit_logs(session, limit=min(limit, 500))
1372
+
1373
+ return {
1374
+ "items": [
1375
+ {
1376
+ "id": item.id,
1377
+ "occurred_at": serialize_datetime(item.occurred_at),
1378
+ "actor": item.actor,
1379
+ "action": item.action,
1380
+ "resource_type": item.resource_type,
1381
+ "resource_id": item.resource_id,
1382
+ "details": json_loads(item.details_json),
1383
+ }
1384
+ for item in logs
1385
+ ],
1386
+ "viewer": user.username,
1387
+ }
1388
+
1389
+
1390
+ @router.get("/audit/settings")
1391
+ def get_audit_settings(
1392
+ user: AdminUser = Depends(get_current_admin),
1393
+ session: Session = Depends(get_session),
1394
+ ) -> dict[str, Any]:
1395
+ return {
1396
+ "data_query_retention_days": get_data_query_audit_retention_days(session),
1397
+ "viewer": user.username,
1398
+ }
1399
+
1400
+
1401
+ @router.put("/audit/settings")
1402
+ def update_audit_settings(
1403
+ request: AuditSettingsRequest,
1404
+ user: AdminUser = Depends(get_current_admin),
1405
+ session: Session = Depends(get_session),
1406
+ ) -> dict[str, Any]:
1407
+ set_setting(
1408
+ session,
1409
+ "data_query_audit_retention_days",
1410
+ str(request.data_query_retention_days),
1411
+ user.username,
1412
+ )
1413
+ apply_data_query_audit_retention(session)
1414
+ record_admin_audit(
1415
+ session=session,
1416
+ actor=user.username,
1417
+ action="audit.retention.update",
1418
+ resource_type="admin_setting",
1419
+ resource_id="data_query_audit_retention_days",
1420
+ details={"data_query_retention_days": request.data_query_retention_days},
1421
+ )
1422
+ session.commit()
1423
+
1424
+ return {
1425
+ "data_query_retention_days": request.data_query_retention_days,
1426
+ }
1427
+
1428
+
1429
+ @router.get("/prompts")
1430
+ def get_prompts(
1431
+ user: AdminUser = Depends(get_current_admin),
1432
+ session: Session = Depends(get_session),
1433
+ ) -> dict[str, Any]:
1434
+ return {
1435
+ "items": [serialize_prompt(prompt) for prompt in list_prompt_templates(session)],
1436
+ "viewer": user.username,
1437
+ }
1438
+
1439
+
1440
+ @router.get("/prompts/{prompt_key}")
1441
+ def get_prompt(
1442
+ prompt_key: str,
1443
+ user: AdminUser = Depends(get_current_admin),
1444
+ session: Session = Depends(get_session),
1445
+ ) -> dict[str, Any]:
1446
+ prompt = get_prompt_template(session, prompt_key)
1447
+
1448
+ if prompt is None:
1449
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Prompt not found.")
1450
+
1451
+ return {
1452
+ "item": serialize_prompt(prompt),
1453
+ "viewer": user.username,
1454
+ }
1455
+
1456
+
1457
+ @router.put("/prompts/{prompt_key}")
1458
+ def update_prompt(
1459
+ prompt_key: str,
1460
+ request: PromptUpdateRequest,
1461
+ user: AdminUser = Depends(get_current_admin),
1462
+ session: Session = Depends(get_session),
1463
+ ) -> dict[str, Any]:
1464
+ prompt = get_prompt_template(session, prompt_key)
1465
+
1466
+ if prompt is None:
1467
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Prompt not found.")
1468
+
1469
+ prompt.name = request.name
1470
+ prompt.description = request.description
1471
+ prompt.system_prompt = request.system_prompt
1472
+ prompt.user_prompt_template = request.user_prompt_template
1473
+ prompt.active = request.active
1474
+ prompt.version += 1
1475
+ prompt.updated_by = user.username
1476
+ record_admin_audit(
1477
+ session=session,
1478
+ actor=user.username,
1479
+ action="prompt.update",
1480
+ resource_type="prompt_template",
1481
+ resource_id=prompt.prompt_key,
1482
+ details={"version": prompt.version},
1483
+ )
1484
+ session.commit()
1485
+
1486
+ return {
1487
+ "item": serialize_prompt(prompt),
1488
+ }
1489
+
1490
+
1491
+ @router.get("/datasources")
1492
+ def get_datasources(
1493
+ user: AdminUser = Depends(get_current_admin),
1494
+ session: Session = Depends(get_session),
1495
+ ) -> dict[str, Any]:
1496
+ return {
1497
+ "items": [
1498
+ serialize_datasource(connector)
1499
+ for connector in list_datasource_connectors(session)
1500
+ ],
1501
+ "viewer": user.username,
1502
+ }
1503
+
1504
+
1505
+ @router.post("/datasources")
1506
+ def create_datasource(
1507
+ request: DatasourceConnectorRequest,
1508
+ user: AdminUser = Depends(get_current_admin),
1509
+ session: Session = Depends(get_session),
1510
+ ) -> dict[str, Any]:
1511
+ if request.connector_key == "metadata-db":
1512
+ raise HTTPException(
1513
+ status_code=status.HTTP_400_BAD_REQUEST,
1514
+ detail="The metadata datasource is managed by GAARD.",
1515
+ )
1516
+
1517
+ try:
1518
+ validate_datasource_url(request.database_type, request.database_url)
1519
+ except ValueError as exc:
1520
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
1521
+
1522
+ existing = session.scalar(
1523
+ select(DatasourceConnector).where(
1524
+ DatasourceConnector.connector_key == request.connector_key
1525
+ )
1526
+ )
1527
+
1528
+ if existing is not None:
1529
+ raise HTTPException(
1530
+ status_code=status.HTTP_409_CONFLICT,
1531
+ detail="Datasource connector key already exists.",
1532
+ )
1533
+
1534
+ connector = DatasourceConnector(
1535
+ connector_key=request.connector_key,
1536
+ name=request.name,
1537
+ database_type=request.database_type,
1538
+ database_url=request.database_url,
1539
+ sql_dialect=request.sql_dialect,
1540
+ active=request.active,
1541
+ updated_by=user.username,
1542
+ )
1543
+ session.add(connector)
1544
+ session.flush()
1545
+
1546
+ if request.active:
1547
+ set_active_datasource_connector(session, connector, user.username)
1548
+
1549
+ record_admin_audit(
1550
+ session=session,
1551
+ actor=user.username,
1552
+ action="datasource.create",
1553
+ resource_type="datasource_connector",
1554
+ resource_id=connector.connector_key,
1555
+ details={
1556
+ "database_type": connector.database_type,
1557
+ "active": connector.active,
1558
+ },
1559
+ )
1560
+ session.commit()
1561
+
1562
+ return {"item": serialize_datasource(connector)}
1563
+
1564
+
1565
+ @router.put("/datasources/{connector_id}")
1566
+ def update_datasource(
1567
+ connector_id: int,
1568
+ request: DatasourceConnectorUpdateRequest,
1569
+ user: AdminUser = Depends(get_current_admin),
1570
+ session: Session = Depends(get_session),
1571
+ ) -> dict[str, Any]:
1572
+ connector = get_datasource_connector(session, connector_id)
1573
+
1574
+ if connector is None:
1575
+ raise HTTPException(
1576
+ status_code=status.HTTP_404_NOT_FOUND,
1577
+ detail="Datasource connector not found.",
1578
+ )
1579
+
1580
+ if is_system_datasource_connector(connector):
1581
+ raise HTTPException(
1582
+ status_code=status.HTTP_400_BAD_REQUEST,
1583
+ detail="The metadata datasource is managed by GAARD.",
1584
+ )
1585
+
1586
+ try:
1587
+ validate_datasource_url(request.database_type, request.database_url)
1588
+ except ValueError as exc:
1589
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
1590
+
1591
+ connector.name = request.name
1592
+ connector.database_type = request.database_type
1593
+ connector.database_url = request.database_url
1594
+ connector.sql_dialect = request.sql_dialect
1595
+ connector.active = request.active
1596
+ connector.updated_by = user.username
1597
+
1598
+ if request.active:
1599
+ set_active_datasource_connector(session, connector, user.username)
1600
+
1601
+ record_admin_audit(
1602
+ session=session,
1603
+ actor=user.username,
1604
+ action="datasource.update",
1605
+ resource_type="datasource_connector",
1606
+ resource_id=connector.connector_key,
1607
+ details={
1608
+ "database_type": connector.database_type,
1609
+ "active": connector.active,
1610
+ },
1611
+ )
1612
+ session.commit()
1613
+
1614
+ return {"item": serialize_datasource(connector)}
1615
+
1616
+
1617
+ @router.post("/datasources/{connector_id}/activate")
1618
+ def activate_datasource(
1619
+ connector_id: int,
1620
+ user: AdminUser = Depends(get_current_admin),
1621
+ session: Session = Depends(get_session),
1622
+ ) -> dict[str, Any]:
1623
+ connector = get_datasource_connector(session, connector_id)
1624
+
1625
+ if connector is None:
1626
+ raise HTTPException(
1627
+ status_code=status.HTTP_404_NOT_FOUND,
1628
+ detail="Datasource connector not found.",
1629
+ )
1630
+
1631
+ if is_system_datasource_connector(connector):
1632
+ raise HTTPException(
1633
+ status_code=status.HTTP_400_BAD_REQUEST,
1634
+ detail="The metadata datasource cannot be activated as the query datasource.",
1635
+ )
1636
+
1637
+ set_active_datasource_connector(session, connector, user.username)
1638
+ record_admin_audit(
1639
+ session=session,
1640
+ actor=user.username,
1641
+ action="datasource.activate",
1642
+ resource_type="datasource_connector",
1643
+ resource_id=connector.connector_key,
1644
+ )
1645
+ session.commit()
1646
+
1647
+ return {"item": serialize_datasource(connector)}
1648
+
1649
+
1650
+ @router.post("/datasources/{connector_id}/test")
1651
+ def test_datasource(
1652
+ connector_id: int,
1653
+ user: AdminUser = Depends(get_current_admin),
1654
+ session: Session = Depends(get_session),
1655
+ ) -> dict[str, Any]:
1656
+ connector = get_datasource_connector(session, connector_id)
1657
+
1658
+ if connector is None:
1659
+ raise HTTPException(
1660
+ status_code=status.HTTP_404_NOT_FOUND,
1661
+ detail="Datasource connector not found.",
1662
+ )
1663
+
1664
+ try:
1665
+ test_datasource_connection(connector)
1666
+ except Exception as exc:
1667
+ record_admin_audit(
1668
+ session=session,
1669
+ actor=user.username,
1670
+ action="datasource.test_failed",
1671
+ resource_type="datasource_connector",
1672
+ resource_id=connector.connector_key,
1673
+ details={"error": str(exc)},
1674
+ )
1675
+ session.commit()
1676
+ raise HTTPException(
1677
+ status_code=status.HTTP_400_BAD_REQUEST,
1678
+ detail=f"Connection test failed: {exc}",
1679
+ ) from exc
1680
+
1681
+ record_admin_audit(
1682
+ session=session,
1683
+ actor=user.username,
1684
+ action="datasource.test",
1685
+ resource_type="datasource_connector",
1686
+ resource_id=connector.connector_key,
1687
+ )
1688
+ session.commit()
1689
+
1690
+ return {"status": "ok"}
1691
+
1692
+
1693
+ @router.post("/datasources/test")
1694
+ def test_datasource_from_request(
1695
+ request: DatasourceConnectionTestRequest,
1696
+ ) -> dict[str, Any]:
1697
+ validate_datasource_url(request.database_type, request.database_url)
1698
+ connector = DatasourceConnector(
1699
+ connector_key="__preview__",
1700
+ name="__preview__",
1701
+ database_type=request.database_type,
1702
+ database_url=request.database_url,
1703
+ sql_dialect=request.database_type if request.database_type != "postgresql" else "postgres",
1704
+ active=False,
1705
+ )
1706
+ test_datasource_connection(connector)
1707
+ return {"status": "ok"}
1708
+
1709
+
1710
+ @router.post("/datasources/{connector_id}/introspect")
1711
+ def introspect_datasource(
1712
+ connector_id: int,
1713
+ user: AdminUser = Depends(get_current_admin),
1714
+ session: Session = Depends(get_session),
1715
+ ) -> dict[str, Any]:
1716
+ connector = get_datasource_connector(session, connector_id)
1717
+
1718
+ if connector is None:
1719
+ raise HTTPException(
1720
+ status_code=status.HTTP_404_NOT_FOUND,
1721
+ detail="Datasource connector not found.",
1722
+ )
1723
+
1724
+ try:
1725
+ cache = introspect_datasource_connector(session, connector, user.username)
1726
+ except Exception as exc:
1727
+ raise HTTPException(
1728
+ status_code=status.HTTP_400_BAD_REQUEST,
1729
+ detail=f"Schema introspection failed: {exc}",
1730
+ ) from exc
1731
+
1732
+ record_admin_audit(
1733
+ session=session,
1734
+ actor=user.username,
1735
+ action="datasource.introspect",
1736
+ resource_type="datasource_connector",
1737
+ resource_id=connector.connector_key,
1738
+ )
1739
+ session.commit()
1740
+
1741
+ return {"item": serialize_datasource_schema(cache)}
1742
+
1743
+
1744
+ @router.get("/datasources/{connector_id}/schema")
1745
+ def get_datasource_schema(
1746
+ connector_id: int,
1747
+ user: AdminUser = Depends(get_current_admin),
1748
+ session: Session = Depends(get_session),
1749
+ ) -> dict[str, Any]:
1750
+ connector = get_datasource_connector(session, connector_id)
1751
+
1752
+ if connector is None:
1753
+ raise HTTPException(
1754
+ status_code=status.HTTP_404_NOT_FOUND,
1755
+ detail="Datasource connector not found.",
1756
+ )
1757
+
1758
+ cache = get_datasource_schema_cache(session, connector.id)
1759
+
1760
+ if cache is None:
1761
+ cache = introspect_datasource_connector(session, connector, user.username)
1762
+ session.commit()
1763
+
1764
+ return {
1765
+ "item": serialize_datasource_schema(cache),
1766
+ "viewer": user.username,
1767
+ }
1768
+
1769
+
1770
+ @router.put("/datasources/{connector_id}/schema/tables")
1771
+ def update_datasource_schema_tables(
1772
+ connector_id: int,
1773
+ request: DatasourceSchemaTableSettingsRequest,
1774
+ user: AdminUser = Depends(get_current_admin),
1775
+ session: Session = Depends(get_session),
1776
+ ) -> dict[str, Any]:
1777
+ connector = get_datasource_connector(session, connector_id)
1778
+
1779
+ if connector is None:
1780
+ raise HTTPException(
1781
+ status_code=status.HTTP_404_NOT_FOUND,
1782
+ detail="Datasource connector not found.",
1783
+ )
1784
+
1785
+ cache = get_datasource_schema_cache(session, connector.id)
1786
+
1787
+ if cache is None:
1788
+ cache = introspect_datasource_connector(session, connector, user.username)
1789
+
1790
+ cache = update_schema_table_settings(
1791
+ session=session,
1792
+ cache=cache,
1793
+ table_settings={"tables": request.tables},
1794
+ actor=user.username,
1795
+ )
1796
+ record_admin_audit(
1797
+ session=session,
1798
+ actor=user.username,
1799
+ action="datasource.schema.update",
1800
+ resource_type="datasource_connector",
1801
+ resource_id=connector.connector_key,
1802
+ details={"tables": list(request.tables.keys())},
1803
+ )
1804
+ session.commit()
1805
+
1806
+ return {"item": serialize_datasource_schema(cache)}
1807
+
1808
+
1809
+ @router.get("/business-logic-suggestions")
1810
+ def get_business_logic_suggestions(
1811
+ connector_id: int | None = None,
1812
+ user: AdminUser = Depends(get_current_admin),
1813
+ session: Session = Depends(get_session),
1814
+ ) -> dict[str, Any]:
1815
+ connector = (
1816
+ get_datasource_connector(session, connector_id)
1817
+ if connector_id is not None
1818
+ else get_active_datasource_connector(session)
1819
+ )
1820
+
1821
+ if connector is None:
1822
+ raise HTTPException(
1823
+ status_code=status.HTTP_404_NOT_FOUND,
1824
+ detail="Datasource connector not found.",
1825
+ )
1826
+
1827
+ return {
1828
+ "datasource": serialize_datasource(connector),
1829
+ "items": [
1830
+ serialize_business_logic_suggestion(suggestion)
1831
+ for suggestion in list_business_logic_suggestions(session, connector.id)
1832
+ ],
1833
+ "statuses": [BUSINESS_LOGIC_STATUS_PENDING, BUSINESS_LOGIC_STATUS_ACTIVE],
1834
+ "viewer": user.username,
1835
+ }
1836
+
1837
+
1838
+ @router.put("/business-logic-suggestions/{suggestion_id}")
1839
+ def update_business_logic_suggestion(
1840
+ suggestion_id: int,
1841
+ request: BusinessLogicSuggestionUpdateRequest,
1842
+ user: AdminUser = Depends(get_current_admin),
1843
+ session: Session = Depends(get_session),
1844
+ ) -> dict[str, Any]:
1845
+ suggestion = get_business_logic_suggestion(session, suggestion_id)
1846
+
1847
+ if suggestion is None:
1848
+ raise HTTPException(
1849
+ status_code=status.HTTP_404_NOT_FOUND,
1850
+ detail="Business logic suggestion not found.",
1851
+ )
1852
+
1853
+ if request.title is not None and not request.title.strip():
1854
+ raise HTTPException(
1855
+ status_code=status.HTTP_400_BAD_REQUEST,
1856
+ detail="Business logic suggestion title cannot be empty.",
1857
+ )
1858
+
1859
+ if request.rule_text is not None and not request.rule_text.strip():
1860
+ raise HTTPException(
1861
+ status_code=status.HTTP_400_BAD_REQUEST,
1862
+ detail="Business logic suggestion rule text cannot be empty.",
1863
+ )
1864
+
1865
+ if request.enabled is not None:
1866
+ suggestion = set_business_logic_suggestion_enabled(
1867
+ session=session,
1868
+ suggestion=suggestion,
1869
+ enabled=request.enabled,
1870
+ actor=user.username,
1871
+ )
1872
+
1873
+ if request.title is not None or request.rule_text is not None:
1874
+ suggestion = update_business_logic_suggestion_content(
1875
+ suggestion=suggestion,
1876
+ title=request.title,
1877
+ rule_text=request.rule_text,
1878
+ actor=user.username,
1879
+ )
1880
+
1881
+ record_admin_audit(
1882
+ session=session,
1883
+ actor=user.username,
1884
+ action="business_logic_suggestion.update",
1885
+ resource_type="business_logic_suggestion",
1886
+ resource_id=str(suggestion.id),
1887
+ details={
1888
+ "enabled": suggestion.enabled,
1889
+ "status": suggestion.status,
1890
+ "content_updated": request.title is not None or request.rule_text is not None,
1891
+ "connector_id": suggestion.connector_id,
1892
+ },
1893
+ )
1894
+ session.commit()
1895
+
1896
+ return {"item": serialize_business_logic_suggestion(suggestion)}
1897
+
1898
+
1899
+ @router.delete("/business-logic-suggestions/{suggestion_id}")
1900
+ def remove_business_logic_suggestion(
1901
+ suggestion_id: int,
1902
+ user: AdminUser = Depends(get_current_admin),
1903
+ session: Session = Depends(get_session),
1904
+ ) -> dict[str, Any]:
1905
+ suggestion = get_business_logic_suggestion(session, suggestion_id)
1906
+
1907
+ if suggestion is None:
1908
+ raise HTTPException(
1909
+ status_code=status.HTTP_404_NOT_FOUND,
1910
+ detail="Business logic suggestion not found.",
1911
+ )
1912
+
1913
+ connector_id = suggestion.connector_id
1914
+ delete_business_logic_suggestion(session, suggestion)
1915
+ record_admin_audit(
1916
+ session=session,
1917
+ actor=user.username,
1918
+ action="business_logic_suggestion.delete",
1919
+ resource_type="business_logic_suggestion",
1920
+ resource_id=str(suggestion_id),
1921
+ details={"connector_id": connector_id},
1922
+ )
1923
+ session.commit()
1924
+
1925
+ return {"status": "deleted"}
1926
+
1927
+
1928
+ @router.get("/llm-config")
1929
+ def get_llm_config(
1930
+ user: AdminUser = Depends(get_current_admin),
1931
+ session: Session = Depends(get_session),
1932
+ ) -> dict[str, Any]:
1933
+ return {
1934
+ "item": serialize_llm_config(session),
1935
+ "viewer": user.username,
1936
+ }
1937
+
1938
+
1939
+ @router.put("/llm-config")
1940
+ def update_llm_config(
1941
+ request: LlmConfigRequest,
1942
+ user: AdminUser = Depends(get_current_admin),
1943
+ session: Session = Depends(get_session),
1944
+ ) -> dict[str, Any]:
1945
+ if request.provider != "openai-compatible":
1946
+ raise HTTPException(
1947
+ status_code=status.HTTP_400_BAD_REQUEST,
1948
+ detail="Only openai-compatible LLM provider is supported.",
1949
+ )
1950
+
1951
+ current_llm_config = get_llm_runtime_config(session)
1952
+ current_query_config = get_query_runtime_config(session)
1953
+ timeout_seconds = request.timeout_seconds or current_llm_config.timeout_seconds
1954
+ intent_classification_mode = (
1955
+ request.intent_classification_mode
1956
+ or current_query_config.intent_classification_mode
1957
+ )
1958
+ sql_generation_mode = request.sql_generation_mode or current_query_config.sql_generation_mode
1959
+ result_interpretation_mode = (
1960
+ request.result_interpretation_mode
1961
+ or current_query_config.result_interpretation_mode
1962
+ )
1963
+ output_classification_mode = (
1964
+ request.output_classification_mode
1965
+ or current_query_config.output_classification_mode
1966
+ )
1967
+ investigation_mode = request.investigation_mode or current_query_config.investigation_mode
1968
+ investigation_ambiguity_mode = (
1969
+ request.investigation_ambiguity_mode
1970
+ or current_query_config.investigation_ambiguity_mode
1971
+ )
1972
+ query_max_rows = request.query_max_rows or current_query_config.query_max_rows
1973
+ query_timeout_seconds = (
1974
+ request.query_timeout_seconds
1975
+ or current_query_config.query_timeout_seconds
1976
+ )
1977
+ api_key = None
1978
+ if request.clear_api_key:
1979
+ api_key = "change-me"
1980
+ elif request.api_key is not None and request.api_key.strip():
1981
+ api_key = request.api_key.strip()
1982
+
1983
+ set_llm_runtime_config(
1984
+ session=session,
1985
+ provider=request.provider,
1986
+ base_url=request.base_url,
1987
+ api_key=api_key,
1988
+ model=request.model,
1989
+ timeout_seconds=timeout_seconds,
1990
+ extra_body=request.extra_body,
1991
+ actor=user.username,
1992
+ )
1993
+ set_query_runtime_config(
1994
+ session=session,
1995
+ intent_classification_mode=intent_classification_mode,
1996
+ sql_generation_mode=sql_generation_mode,
1997
+ result_interpretation_mode=result_interpretation_mode,
1998
+ output_classification_mode=output_classification_mode,
1999
+ investigation_mode=investigation_mode,
2000
+ investigation_ambiguity_mode=investigation_ambiguity_mode,
2001
+ query_max_rows=query_max_rows,
2002
+ query_timeout_seconds=query_timeout_seconds,
2003
+ actor=user.username,
2004
+ )
2005
+ record_admin_audit(
2006
+ session=session,
2007
+ actor=user.username,
2008
+ action="llm_config.update",
2009
+ resource_type="admin_setting",
2010
+ resource_id="llm_config",
2011
+ details={
2012
+ "provider": request.provider,
2013
+ "base_url": request.base_url,
2014
+ "model": request.model,
2015
+ "timeout_seconds": timeout_seconds,
2016
+ "extra_body": request.extra_body,
2017
+ "intent_classification_mode": intent_classification_mode,
2018
+ "sql_generation_mode": sql_generation_mode,
2019
+ "result_interpretation_mode": result_interpretation_mode,
2020
+ "output_classification_mode": output_classification_mode,
2021
+ "investigation_mode": investigation_mode,
2022
+ "investigation_ambiguity_mode": investigation_ambiguity_mode,
2023
+ "query_max_rows": query_max_rows,
2024
+ "query_timeout_seconds": query_timeout_seconds,
2025
+ },
2026
+ )
2027
+ session.commit()
2028
+
2029
+ return {"item": serialize_llm_config(session)}
2030
+
2031
+
2032
+ @router.get("/governance-policy")
2033
+ def get_governance_policy(
2034
+ user: AdminUser = Depends(get_current_admin),
2035
+ session: Session = Depends(get_session),
2036
+ ) -> dict[str, Any]:
2037
+ return {
2038
+ "item": serialize_governance_policy(session),
2039
+ "viewer": user.username,
2040
+ }
2041
+
2042
+
2043
+ @router.put("/governance-policy")
2044
+ def update_governance_policy(
2045
+ request: GovernancePolicyRequest,
2046
+ user: AdminUser = Depends(get_current_admin),
2047
+ session: Session = Depends(get_session),
2048
+ ) -> dict[str, Any]:
2049
+ try:
2050
+ config = set_governance_policy_config(
2051
+ session=session,
2052
+ config=request.model_dump(mode="json"),
2053
+ actor=user.username,
2054
+ )
2055
+ except ValueError as exc:
2056
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
2057
+
2058
+ record_admin_audit(
2059
+ session=session,
2060
+ actor=user.username,
2061
+ action="governance_policy.update",
2062
+ resource_type="admin_setting",
2063
+ resource_id="governance_policy",
2064
+ details=config,
2065
+ )
2066
+ session.commit()
2067
+
2068
+ return {"item": serialize_governance_policy(session)}
2069
+
2070
+
2071
+ @router.get("/schema-cache")
2072
+ def get_schema_cache_settings(
2073
+ user: AdminUser = Depends(get_current_admin),
2074
+ session: Session = Depends(get_session),
2075
+ ) -> dict[str, Any]:
2076
+ configured_ttl = int(
2077
+ get_setting(
2078
+ session,
2079
+ "schema_cache_ttl_seconds",
2080
+ str(settings.gaard_schema_cache_ttl_seconds),
2081
+ )
2082
+ )
2083
+
2084
+ return {
2085
+ "ttl_seconds": configured_ttl,
2086
+ "runtime_ttl_seconds": schema_context_cache.ttl_seconds,
2087
+ "cache_key": get_runtime_schema_cache_key(session),
2088
+ "viewer": user.username,
2089
+ }
2090
+
2091
+
2092
+ @router.put("/schema-cache")
2093
+ def update_schema_cache_settings(
2094
+ request: SchemaCacheSettingsRequest,
2095
+ user: AdminUser = Depends(get_current_admin),
2096
+ session: Session = Depends(get_session),
2097
+ ) -> dict[str, Any]:
2098
+ schema_context_cache.ttl_seconds = request.ttl_seconds
2099
+ set_setting(session, "schema_cache_ttl_seconds", str(request.ttl_seconds), user.username)
2100
+ record_admin_audit(
2101
+ session=session,
2102
+ actor=user.username,
2103
+ action="schema_cache.ttl.update",
2104
+ resource_type="admin_setting",
2105
+ resource_id="schema_cache_ttl_seconds",
2106
+ details={"ttl_seconds": request.ttl_seconds},
2107
+ )
2108
+ session.commit()
2109
+
2110
+ return {
2111
+ "ttl_seconds": request.ttl_seconds,
2112
+ "runtime_ttl_seconds": schema_context_cache.ttl_seconds,
2113
+ }
2114
+
2115
+
2116
+ @router.post("/schema-cache/invalidate")
2117
+ def invalidate_schema_cache(
2118
+ user: AdminUser = Depends(get_current_admin),
2119
+ session: Session = Depends(get_session),
2120
+ ) -> dict[str, Any]:
2121
+ cache_key = get_runtime_schema_cache_key(session)
2122
+ schema_context_cache.invalidate(cache_key)
2123
+ record_admin_audit(
2124
+ session=session,
2125
+ actor=user.username,
2126
+ action="schema_cache.invalidate",
2127
+ resource_type="schema_cache",
2128
+ resource_id=cache_key,
2129
+ )
2130
+ session.commit()
2131
+
2132
+ return {
2133
+ "status": "invalidated",
2134
+ "cache_key": cache_key,
2135
+ }
2136
+
2137
+
2138
+ @router.get("/integrations")
2139
+ def get_integration_stubs(user: AdminUser = Depends(get_current_admin)) -> dict[str, Any]:
2140
+ return {
2141
+ "items": [
2142
+ {
2143
+ "key": "freeipa",
2144
+ "name": "FreeIPA identity connector",
2145
+ "status": "planned",
2146
+ },
2147
+ {
2148
+ "key": "sql_validation_rules",
2149
+ "name": "SQL validation rules",
2150
+ "status": "planned",
2151
+ },
2152
+ {
2153
+ "key": "result_interpretation_policies",
2154
+ "name": "Result interpretation policies",
2155
+ "status": "planned",
2156
+ },
2157
+ ],
2158
+ "viewer": user.username,
2159
+ }
2160
+
2161
+
2162
+ @router.get("/license")
2163
+ def get_license(user: AdminUser = Depends(get_current_admin)) -> dict[str, Any]:
2164
+ return {
2165
+ "edition": "community",
2166
+ "status": "active",
2167
+ "managed_features": {
2168
+ "freeipa": False,
2169
+ "datasource_connectors": False,
2170
+ "sql_validation_rules": False,
2171
+ "result_interpretation_policies": False,
2172
+ },
2173
+ "viewer": user.username,
2174
+ }