gaard-api 0.2.7__tar.gz → 0.2.8__tar.gz

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.
Files changed (72) hide show
  1. {gaard_api-0.2.7/src/gaard_api.egg-info → gaard_api-0.2.8}/PKG-INFO +5 -5
  2. {gaard_api-0.2.7 → gaard_api-0.2.8}/pyproject.toml +5 -5
  3. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/admin/database.py +186 -0
  4. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/admin/defaults.py +41 -0
  5. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/admin/models.py +47 -1
  6. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/admin/prompt_runtime.py +49 -0
  7. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/admin/services.py +1 -0
  8. gaard_api-0.2.8/src/gaard_api/admin-web/assets/identity.js +109 -0
  9. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/admin-web/assets/main.js +437 -117
  10. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/admin-web/assets/styles.css +310 -50
  11. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/api/v1/admin.py +610 -42
  12. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/api/v1/analysis.py +1 -1
  13. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/api/v1/dashboards.py +39 -36
  14. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/api/v1/query.py +377 -56
  15. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/api_registry.py +30 -0
  16. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/auth_dependencies.py +50 -0
  17. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/auth_hooks.py +15 -0
  18. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/conversations.py +5 -4
  19. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/core/settings.py +1 -1
  20. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/example_database.py +43 -33
  21. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/main.py +1 -1
  22. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/package_updates.py +1 -1
  23. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/query_hooks.py +90 -1
  24. {gaard_api-0.2.7 → gaard_api-0.2.8/src/gaard_api.egg-info}/PKG-INFO +5 -5
  25. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api.egg-info/SOURCES.txt +1 -0
  26. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api.egg-info/requires.txt +4 -4
  27. {gaard_api-0.2.7 → gaard_api-0.2.8}/tests/test_admin_api.py +586 -84
  28. {gaard_api-0.2.7 → gaard_api-0.2.8}/tests/test_api_registry.py +15 -0
  29. {gaard_api-0.2.7 → gaard_api-0.2.8}/tests/test_license_api.py +13 -0
  30. {gaard_api-0.2.7 → gaard_api-0.2.8}/tests/test_prompt_runtime.py +38 -0
  31. {gaard_api-0.2.7 → gaard_api-0.2.8}/tests/test_server_cli.py +9 -5
  32. {gaard_api-0.2.7 → gaard_api-0.2.8}/tests/test_settings.py +1 -0
  33. {gaard_api-0.2.7 → gaard_api-0.2.8}/MANIFEST.in +0 -0
  34. {gaard_api-0.2.7 → gaard_api-0.2.8}/README.md +0 -0
  35. {gaard_api-0.2.7 → gaard_api-0.2.8}/setup.cfg +0 -0
  36. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/__init__.py +0 -0
  37. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/admin/__init__.py +0 -0
  38. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/admin/security.py +0 -0
  39. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
  40. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
  41. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
  42. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/admin-web/index.html +0 -0
  43. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/admin-web/package.json +0 -0
  44. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/api/__init__.py +0 -0
  45. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/api/v1/__init__.py +0 -0
  46. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/api/v1/prompts.py +0 -0
  47. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/api/v1/schema.py +0 -0
  48. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/cli.py +0 -0
  49. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/cli_commands.py +0 -0
  50. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/core/__init__.py +0 -0
  51. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/core/error_handlers.py +0 -0
  52. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/core/schema_cache.py +0 -0
  53. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/example_data/__init__.py +0 -0
  54. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  55. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  56. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  57. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/extension_services.py +0 -0
  58. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/extensions.py +0 -0
  59. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/license.py +0 -0
  60. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/py.typed +0 -0
  61. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/server_cli.py +0 -0
  62. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/siem.py +0 -0
  63. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api/tls_http.py +0 -0
  64. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  65. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api.egg-info/entry_points.txt +0 -0
  66. {gaard_api-0.2.7 → gaard_api-0.2.8}/src/gaard_api.egg-info/top_level.txt +0 -0
  67. {gaard_api-0.2.7 → gaard_api-0.2.8}/tests/test_analysis_api.py +0 -0
  68. {gaard_api-0.2.7 → gaard_api-0.2.8}/tests/test_conversation_api.py +0 -0
  69. {gaard_api-0.2.7 → gaard_api-0.2.8}/tests/test_error_handlers.py +0 -0
  70. {gaard_api-0.2.7 → gaard_api-0.2.8}/tests/test_license_service.py +0 -0
  71. {gaard_api-0.2.7 → gaard_api-0.2.8}/tests/test_query_error_messages.py +0 -0
  72. {gaard_api-0.2.7 → gaard_api-0.2.8}/tests/test_tls_http.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.2.7
3
+ Version: 0.2.8
4
4
  Summary: GAARD backend web services providing admin interface
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: gaard-core==0.2.7
8
- Requires-Dist: gaard-connectors==0.2.7
9
- Requires-Dist: gaard-llm==0.2.7
10
- Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.7
7
+ Requires-Dist: gaard-core==0.2.8
8
+ Requires-Dist: gaard-connectors==0.2.8
9
+ Requires-Dist: gaard-llm==0.2.8
10
+ Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.8
11
11
  Requires-Dist: fastapi>=0.111.0
12
12
  Requires-Dist: uvicorn[standard]>=0.30.0
13
13
  Requires-Dist: pydantic>=2.7.0
@@ -4,16 +4,16 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gaard-api"
7
- version = "0.2.7"
7
+ version = "0.2.8"
8
8
  description = "GAARD backend web services providing admin interface"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
 
12
12
  dependencies = [
13
- "gaard-core==0.2.7",
14
- "gaard-connectors==0.2.7",
15
- "gaard-llm==0.2.7",
16
- "gaard-plugin-api>=0.2.7,<0.3.0",
13
+ "gaard-core==0.2.8",
14
+ "gaard-connectors==0.2.8",
15
+ "gaard-llm==0.2.8",
16
+ "gaard-plugin-api>=0.2.8,<0.3.0",
17
17
  "fastapi>=0.111.0",
18
18
  "uvicorn[standard]>=0.30.0",
19
19
  "pydantic>=2.7.0",
@@ -17,7 +17,10 @@ from gaard_api.admin.models import (
17
17
  DataQueryAuditType,
18
18
  DatasourceConnector,
19
19
  OverviewWidget,
20
+ OverviewWidgetTag,
20
21
  PromptTemplate,
22
+ UserSavedMetric,
23
+ WidgetTag,
21
24
  )
22
25
  from gaard_api.admin.security import hash_password
23
26
  from gaard_api.core.settings import settings
@@ -75,6 +78,7 @@ def init_metadata_store() -> None:
75
78
  engine = get_engine()
76
79
  Base.metadata.create_all(engine)
77
80
  ensure_admin_session_schema(engine)
81
+ ensure_admin_user_schema(engine)
78
82
  ensure_data_query_audit_schema(engine)
79
83
  ensure_overview_widget_schema(engine)
80
84
 
@@ -88,6 +92,7 @@ def init_metadata_store() -> None:
88
92
  seed_prompts(session)
89
93
  seed_datasource_connectors(session)
90
94
  seed_overview_widgets(session)
95
+ backfill_overview_widget_tags(session)
91
96
  backfill_data_query_audit_types(session)
92
97
  session.commit()
93
98
 
@@ -107,6 +112,142 @@ def seed_admin_user(session: Session) -> None:
107
112
  )
108
113
 
109
114
 
115
+ def ensure_admin_user_schema(engine: Engine) -> None:
116
+ columns = {column["name"] for column in inspect(engine).get_columns("admin_users")}
117
+ additions = {
118
+ "display_name": "ALTER TABLE admin_users ADD COLUMN display_name VARCHAR(255) NOT NULL DEFAULT ''",
119
+ "auth_provider": "ALTER TABLE admin_users ADD COLUMN auth_provider VARCHAR(255) NOT NULL DEFAULT 'local'",
120
+ "is_provisioned": "ALTER TABLE admin_users ADD COLUMN is_provisioned BOOLEAN NOT NULL DEFAULT 0",
121
+ }
122
+ with engine.begin() as connection:
123
+ for name, sql in additions.items():
124
+ if name not in columns:
125
+ connection.execute(text(sql))
126
+ if engine.dialect.name == "sqlite" and (
127
+ admin_user_has_global_username_constraint(engine)
128
+ or not admin_user_has_provider_username_constraint(engine)
129
+ ):
130
+ rebuild_sqlite_admin_users(connection)
131
+ else:
132
+ drop_global_admin_username_constraint(connection, engine)
133
+ normalize_external_admin_usernames(connection, engine.dialect.name)
134
+ ensure_admin_user_provider_username_constraint(connection, engine)
135
+
136
+
137
+ def admin_user_has_global_username_constraint(engine: Engine) -> bool:
138
+ return ["username"] in admin_user_unique_column_sets(engine)
139
+
140
+
141
+ def admin_user_has_provider_username_constraint(engine: Engine) -> bool:
142
+ return ["auth_provider", "username"] in admin_user_unique_column_sets(engine)
143
+
144
+
145
+ def admin_user_unique_column_sets(engine: Engine) -> list[list[str]]:
146
+ if engine.dialect.name == "sqlite":
147
+ with engine.connect() as connection:
148
+ return [
149
+ [row[2] for row in connection.execute(text(f"PRAGMA index_info({index_name!r})"))]
150
+ for _sequence, index_name, is_unique, *_rest in connection.execute(
151
+ text("PRAGMA index_list('admin_users')")
152
+ )
153
+ if is_unique
154
+ ]
155
+
156
+ inspector = inspect(engine)
157
+ return [
158
+ cast(list[str], constraint_column_names)
159
+ for constraint in inspector.get_unique_constraints("admin_users")
160
+ if isinstance((constraint_column_names := constraint.get("column_names")), list)
161
+ and all(isinstance(column_name, str) for column_name in constraint_column_names)
162
+ ] + [
163
+ cast(list[str], index_column_names)
164
+ for index in inspector.get_indexes("admin_users")
165
+ if index.get("unique")
166
+ and isinstance((index_column_names := index.get("column_names")), list)
167
+ and all(isinstance(column_name, str) for column_name in index_column_names)
168
+ ]
169
+
170
+
171
+ def rebuild_sqlite_admin_users(connection) -> None:
172
+ connection.execute(text("""
173
+ CREATE TABLE admin_users__migrated (
174
+ id INTEGER NOT NULL PRIMARY KEY,
175
+ username VARCHAR(255) NOT NULL,
176
+ display_name VARCHAR(255) NOT NULL DEFAULT '',
177
+ auth_provider VARCHAR(255) NOT NULL DEFAULT 'local',
178
+ password_hash TEXT NOT NULL,
179
+ must_change_password BOOLEAN NOT NULL,
180
+ is_provisioned BOOLEAN NOT NULL DEFAULT 0,
181
+ created_at DATETIME NOT NULL,
182
+ updated_at DATETIME NOT NULL,
183
+ CONSTRAINT uq_admin_users_auth_provider_username UNIQUE (auth_provider, username)
184
+ )
185
+ """))
186
+ connection.execute(text("""
187
+ INSERT INTO admin_users__migrated
188
+ (id, username, display_name, auth_provider, password_hash, must_change_password, is_provisioned, created_at, updated_at)
189
+ SELECT
190
+ id,
191
+ CASE WHEN password_hash = 'external$disabled' AND instr(username, ':') > 0
192
+ AND (auth_provider = 'local' OR substr(username, 1, instr(username, ':') - 1) = auth_provider)
193
+ THEN substr(username, instr(username, ':') + 1) ELSE username END,
194
+ display_name,
195
+ CASE WHEN auth_provider = 'local' AND password_hash = 'external$disabled' AND instr(username, ':') > 0
196
+ THEN substr(username, 1, instr(username, ':') - 1) ELSE auth_provider END,
197
+ password_hash, must_change_password, 0, created_at, updated_at
198
+ FROM admin_users
199
+ """))
200
+ connection.execute(text("DROP TABLE admin_users"))
201
+ connection.execute(text("ALTER TABLE admin_users__migrated RENAME TO admin_users"))
202
+ connection.execute(text("CREATE INDEX ix_admin_users_username ON admin_users (username)"))
203
+ connection.execute(text("CREATE INDEX ix_admin_users_auth_provider ON admin_users (auth_provider)"))
204
+
205
+
206
+ def drop_global_admin_username_constraint(connection, engine: Engine) -> None:
207
+ if engine.dialect.name != "postgresql":
208
+ return
209
+ inspector = inspect(engine)
210
+ quote = connection.dialect.identifier_preparer.quote
211
+ for constraint in inspector.get_unique_constraints("admin_users"):
212
+ if constraint.get("column_names") == ["username"]:
213
+ connection.execute(text(f"ALTER TABLE admin_users DROP CONSTRAINT {quote(constraint['name'])}"))
214
+
215
+
216
+ def normalize_external_admin_usernames(connection, dialect_name: str) -> None:
217
+ if dialect_name == "sqlite":
218
+ username = "substr(username, instr(username, ':') + 1)"
219
+ provider = "substr(username, 1, instr(username, ':') - 1)"
220
+ has_prefix = "instr(username, ':') > 0"
221
+ prefix_matches_provider = "substr(username, 1, instr(username, ':') - 1) = auth_provider"
222
+ elif dialect_name == "postgresql":
223
+ username = "substring(username from position(':' in username) + 1)"
224
+ provider = "substring(username from 1 for position(':' in username) - 1)"
225
+ has_prefix = "position(':' in username) > 0"
226
+ prefix_matches_provider = "substring(username from 1 for position(':' in username) - 1) = auth_provider"
227
+ else:
228
+ return
229
+ connection.execute(text(
230
+ "UPDATE admin_users "
231
+ f"SET username = {username}, auth_provider = CASE WHEN auth_provider = 'local' THEN {provider} ELSE auth_provider END "
232
+ "WHERE password_hash = 'external$disabled' "
233
+ f"AND {has_prefix} AND (auth_provider = 'local' OR {prefix_matches_provider})"
234
+ ))
235
+
236
+
237
+ def ensure_admin_user_provider_username_constraint(connection, engine: Engine) -> None:
238
+ if engine.dialect.name != "postgresql":
239
+ return
240
+ constraints = inspect(engine).get_unique_constraints("admin_users")
241
+ if not any(
242
+ constraint.get("column_names") == ["auth_provider", "username"]
243
+ for constraint in constraints
244
+ ):
245
+ connection.execute(text(
246
+ "ALTER TABLE admin_users ADD CONSTRAINT uq_admin_users_auth_provider_username "
247
+ "UNIQUE (auth_provider, username)"
248
+ ))
249
+
250
+
110
251
  def seed_settings(session: Session) -> None:
111
252
  defaults = {
112
253
  "gaard_intent_classification_mode": settings.gaard_intent_classification_mode,
@@ -278,6 +419,7 @@ def seed_overview_widgets(session: Session) -> None:
278
419
  "result_mode": "data",
279
420
  "position": 10,
280
421
  "grid_width": 1,
422
+ "grid_height": 2,
281
423
  },
282
424
  {
283
425
  "widget_key": "audit_retention",
@@ -296,6 +438,7 @@ def seed_overview_widgets(session: Session) -> None:
296
438
  "result_mode": "data",
297
439
  "position": 20,
298
440
  "grid_width": 1,
441
+ "grid_height": 2,
299
442
  },
300
443
  {
301
444
  "widget_key": "schema_cache_ttl",
@@ -314,6 +457,7 @@ def seed_overview_widgets(session: Session) -> None:
314
457
  "result_mode": "data",
315
458
  "position": 30,
316
459
  "grid_width": 1,
460
+ "grid_height": 2,
317
461
  },
318
462
  {
319
463
  "widget_key": "license_edition",
@@ -328,6 +472,7 @@ def seed_overview_widgets(session: Session) -> None:
328
472
  "result_mode": "data",
329
473
  "position": 40,
330
474
  "grid_width": 1,
475
+ "grid_height": 2,
331
476
  },
332
477
  {
333
478
  "widget_key": "runtime_daily_queries",
@@ -343,6 +488,7 @@ def seed_overview_widgets(session: Session) -> None:
343
488
  "result_mode": "data",
344
489
  "position": 250,
345
490
  "grid_width": 12,
491
+ "grid_height": 4,
346
492
  "active": False,
347
493
  },
348
494
  {
@@ -362,6 +508,7 @@ def seed_overview_widgets(session: Session) -> None:
362
508
  "result_mode": "data",
363
509
  "position": 130,
364
510
  "grid_width": 12,
511
+ "grid_height": 4,
365
512
  },
366
513
  ]
367
514
 
@@ -516,6 +663,18 @@ def ensure_overview_widget_schema(engine: Engine) -> None:
516
663
  text("ALTER TABLE overview_widgets ADD COLUMN grid_width INTEGER DEFAULT 1")
517
664
  )
518
665
 
666
+ if "grid_height" not in columns:
667
+ with engine.begin() as connection:
668
+ connection.execute(
669
+ text("ALTER TABLE overview_widgets ADD COLUMN grid_height INTEGER DEFAULT 2")
670
+ )
671
+ connection.execute(
672
+ text(
673
+ "UPDATE overview_widgets SET grid_height = 4 "
674
+ "WHERE widget_type <> 'scalar'"
675
+ )
676
+ )
677
+
519
678
  if "result_mode" not in columns:
520
679
  with engine.begin() as connection:
521
680
  connection.execute(
@@ -525,6 +684,33 @@ def ensure_overview_widget_schema(engine: Engine) -> None:
525
684
  )
526
685
  )
527
686
 
687
+ def backfill_overview_widget_tags(session: Session) -> None:
688
+ """Give legacy widgets public and saved-metric owner tags."""
689
+ if session.scalar(select(WidgetTag).where(WidgetTag.name == "public")) is None:
690
+ session.add(WidgetTag(name="public"))
691
+ session.flush()
692
+ widget_ids_with_tags = set(session.scalars(select(OverviewWidgetTag.widget_id)))
693
+ for widget_id in session.scalars(select(OverviewWidget.id)):
694
+ if widget_id not in widget_ids_with_tags:
695
+ session.add(OverviewWidgetTag(widget_id=widget_id, tag_name="public"))
696
+
697
+ existing_assignments = set(
698
+ session.execute(select(OverviewWidgetTag.widget_id, OverviewWidgetTag.tag_name))
699
+ )
700
+ for widget_id, owner_username in session.execute(
701
+ select(OverviewWidget.id, UserSavedMetric.owner_username).join(
702
+ UserSavedMetric,
703
+ UserSavedMetric.widget_key == OverviewWidget.widget_key,
704
+ )
705
+ ):
706
+ if not owner_username:
707
+ continue
708
+ if session.get(WidgetTag, owner_username) is None:
709
+ session.add(WidgetTag(name=owner_username))
710
+ session.flush()
711
+ if (widget_id, owner_username) not in existing_assignments:
712
+ session.add(OverviewWidgetTag(widget_id=widget_id, tag_name=owner_username))
713
+
528
714
 
529
715
  def reset_metadata_store_for_tests() -> None:
530
716
  global _engine, _engine_url, _session_factory
@@ -218,6 +218,40 @@ Return exactly one of:
218
218
  personal_data, sensitive_data, technical_data, neutral_data, unknown
219
219
  """
220
220
 
221
+ DEFAULT_ANSWER_EXPLANATION_SYSTEM_PROMPT = """You are GAARD Answer Explanation.
222
+
223
+ Your task is to explain why a specific SQL query was generated for the user's data question.
224
+
225
+ Use the provided:
226
+ - original user question,
227
+ - generated SQL,
228
+ - interpreted answer and raw result rows,
229
+ - inference metadata,
230
+ - prompt metadata,
231
+ - approved business logic and schema/rule context.
232
+
233
+ Rules:
234
+ 1. Answer in the same language as the user's question.
235
+ 2. Explain the logical mapping from the question to the SQL.
236
+ 3. Mention the selected tables, columns, joins, filters, time ranges, grouping,
237
+ aggregation, ordering and limits when they are relevant.
238
+ 4. If business logic affected the SQL, describe that rule in plain language.
239
+ 5. Distinguish explicit evidence from assumptions.
240
+ 6. Do not invent tables, columns, rules, filters or results that are not present in the input.
241
+ 7. Do not reveal hidden chain-of-thought.
242
+ 8. Do not include <think> blocks.
243
+ 9. Do not use markdown tables.
244
+ 10. Return only the final explanation.
245
+ """
246
+
247
+ DEFAULT_ANSWER_EXPLANATION_USER_PROMPT = """Explain why this SQL was generated for the user's question.
248
+
249
+ Input JSON:
250
+ {payload}
251
+
252
+ Return only the final explanation.
253
+ """
254
+
221
255
  DEFAULT_GOVERNANCE_POLICY_CONFIG = {
222
256
  "final_answer": {
223
257
  "record_level_pii_allowed": False,
@@ -280,4 +314,11 @@ DEFAULT_PROMPTS = [
280
314
  "system_prompt": DEFAULT_RESULT_CLASSIFICATION_SYSTEM_PROMPT,
281
315
  "user_prompt_template": DEFAULT_RESULT_CLASSIFICATION_USER_PROMPT,
282
316
  },
317
+ {
318
+ "prompt_key": "answer_explanation",
319
+ "name": "Answer explanation",
320
+ "description": "Explains why a generated SQL query answered a user question.",
321
+ "system_prompt": DEFAULT_ANSWER_EXPLANATION_SYSTEM_PROMPT,
322
+ "user_prompt_template": DEFAULT_ANSWER_EXPLANATION_USER_PROMPT,
323
+ },
283
324
  ]
@@ -6,6 +6,7 @@ from sqlalchemy import (
6
6
  DateTime,
7
7
  Enum as SAEnum,
8
8
  Float,
9
+ ForeignKey,
9
10
  Integer,
10
11
  String,
11
12
  Text,
@@ -26,11 +27,19 @@ class Base(DeclarativeBase):
26
27
 
27
28
  class AdminUser(Base):
28
29
  __tablename__ = "admin_users"
30
+ __table_args__ = (
31
+ UniqueConstraint("auth_provider", "username", name="uq_admin_users_auth_provider_username"),
32
+ )
29
33
 
30
34
  id: Mapped[int] = mapped_column(Integer, primary_key=True)
31
- username: Mapped[str] = mapped_column(String(255), unique=True, index=True)
35
+ username: Mapped[str] = mapped_column(String(255), index=True)
36
+ display_name: Mapped[str] = mapped_column(String(255), default="")
37
+ auth_provider: Mapped[str] = mapped_column(String(255), index=True, default="local")
32
38
  password_hash: Mapped[str] = mapped_column(Text)
33
39
  must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
40
+ # Provider refreshes create provisional records so they can be assigned before login.
41
+ # The provider may safely remove only records which remain provisional.
42
+ is_provisioned: Mapped[bool] = mapped_column(Boolean, default=False)
34
43
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
35
44
  updated_at: Mapped[datetime] = mapped_column(
36
45
  DateTime(timezone=True),
@@ -180,6 +189,22 @@ class DashboardUserState(Base):
180
189
  )
181
190
 
182
191
 
192
+ class UserDatasourceSelection(Base):
193
+ """Client datasource selections, scoped to one authenticated user."""
194
+
195
+ __tablename__ = "user_datasource_selections"
196
+
197
+ owner_user_id: Mapped[str] = mapped_column(String(255), primary_key=True)
198
+ owner_username: Mapped[str] = mapped_column(String(255), index=True, default="")
199
+ datasource_ids_json: Mapped[str] = mapped_column(Text, default="[]")
200
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
201
+ updated_at: Mapped[datetime] = mapped_column(
202
+ DateTime(timezone=True),
203
+ default=utc_now,
204
+ onupdate=utc_now,
205
+ )
206
+
207
+
183
208
  class UserSavedMetric(Base):
184
209
  __tablename__ = "user_saved_metrics"
185
210
  __table_args__ = (
@@ -303,6 +328,7 @@ class OverviewWidget(Base):
303
328
  result_mode: Mapped[str] = mapped_column(String(50), default="data")
304
329
  position: Mapped[int] = mapped_column(Integer, default=100)
305
330
  grid_width: Mapped[int] = mapped_column(Integer, default=1)
331
+ grid_height: Mapped[int] = mapped_column(Integer, default=2)
306
332
  active: Mapped[bool] = mapped_column(Boolean, default=True)
307
333
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
308
334
  updated_at: Mapped[datetime] = mapped_column(
@@ -313,6 +339,26 @@ class OverviewWidget(Base):
313
339
  updated_by: Mapped[str] = mapped_column(String(255), default="system")
314
340
 
315
341
 
342
+ class WidgetTag(Base):
343
+ """Permanent catalogue of every tag that has been used on a widget."""
344
+
345
+ __tablename__ = "widget_tags"
346
+
347
+ name: Mapped[str] = mapped_column(String(255), primary_key=True)
348
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
349
+
350
+
351
+ class OverviewWidgetTag(Base):
352
+ __tablename__ = "overview_widget_tags"
353
+
354
+ widget_id: Mapped[int] = mapped_column(
355
+ Integer, ForeignKey("overview_widgets.id", ondelete="CASCADE"), primary_key=True
356
+ )
357
+ tag_name: Mapped[str] = mapped_column(
358
+ String(255), ForeignKey("widget_tags.name"), primary_key=True
359
+ )
360
+
361
+
316
362
  class BusinessLogicSuggestion(Base):
317
363
  __tablename__ = "business_logic_suggestions"
318
364
 
@@ -212,6 +212,44 @@ class MetadataResultClassificationPromptCompiler:
212
212
  )
213
213
 
214
214
 
215
+ class MetadataAnswerExplanationPromptCompiler:
216
+ def __init__(self, prompt_template: PromptTemplate) -> None:
217
+ self.prompt_template = prompt_template
218
+
219
+ def compile(self, payload: dict[str, Any]) -> CompiledPrompt:
220
+ payload_json = json_dumps(payload, ensure_ascii=False, indent=2)
221
+ metadata = payload.get("metadata")
222
+ inference_metadata = payload.get("inference_metadata")
223
+ prompt_metadata = payload.get("prompt_metadata")
224
+
225
+ return CompiledPrompt(
226
+ system_prompt=self.prompt_template.system_prompt,
227
+ user_prompt=self.prompt_template.user_prompt_template.format(
228
+ payload=payload_json,
229
+ question=str(payload.get("question") or ""),
230
+ sql=str(payload.get("sql") or ""),
231
+ answer=str(payload.get("answer") or ""),
232
+ metadata=json_dumps(metadata, ensure_ascii=False),
233
+ inference_metadata=json_dumps(inference_metadata, ensure_ascii=False),
234
+ prompt_metadata=json_dumps(prompt_metadata, ensure_ascii=False),
235
+ business_logic=str(payload.get("business_logic") or ""),
236
+ ),
237
+ metadata={
238
+ "prompt_key": self.prompt_template.prompt_key,
239
+ "prompt_version": self.prompt_template.version,
240
+ "has_sql": bool(str(payload.get("sql") or "").strip()),
241
+ "has_result_rows": bool(
242
+ (payload.get("result") or {}).get("rows")
243
+ if isinstance(payload.get("result"), dict)
244
+ else False
245
+ ),
246
+ "has_business_logic": bool(
247
+ str(payload.get("business_logic") or "").strip()
248
+ ),
249
+ },
250
+ )
251
+
252
+
215
253
  def get_sql_generation_prompt_compiler() -> MetadataSqlGenerationPromptCompiler | None:
216
254
  prompt_template = get_active_prompt_template_safe("sql_generation")
217
255
 
@@ -263,3 +301,14 @@ def get_result_classification_prompt_compiler() -> (
263
301
  return None
264
302
 
265
303
  return MetadataResultClassificationPromptCompiler(prompt_template=prompt_template)
304
+
305
+
306
+ def get_answer_explanation_prompt_compiler() -> (
307
+ MetadataAnswerExplanationPromptCompiler | None
308
+ ):
309
+ prompt_template = get_active_prompt_template_safe("answer_explanation")
310
+
311
+ if prompt_template is None:
312
+ return None
313
+
314
+ return MetadataAnswerExplanationPromptCompiler(prompt_template=prompt_template)
@@ -2367,6 +2367,7 @@ def get_datasource_schema_contexts_safe(
2367
2367
  for datasource_id in datasource_ids
2368
2368
  if (connector := get_datasource_connector_by_key(session, datasource_id))
2369
2369
  is not None
2370
+ and connector.active
2370
2371
  and not is_system_datasource_connector(connector)
2371
2372
  ]
2372
2373
  else:
@@ -0,0 +1,109 @@
1
+ export function createIdentityModule({ api, escapeHtml, state, render, setMessage }) {
2
+ let selected = null;
3
+ let paneWidth = 420;
4
+ let loaded = false;
5
+ let editAction = null;
6
+ let refreshOnAttach = false;
7
+ let pageResizeObserver = null;
8
+
9
+ function providerSpecific(user) {
10
+ const entries = Object.entries(user.attributes || {});
11
+ if (!entries.length) return "";
12
+ return `<h3>Provider specific</h3><table class="identity-provider-table"><tbody>${entries.map(([key, value]) => `<tr><th>${escapeHtml(key)}</th><td>${escapeHtml(Array.isArray(value) ? value.join(", ") : value)}</td></tr>`).join("")}</tbody></table>`;
13
+ }
14
+
15
+ function actionButton(action, label, enabled) {
16
+ return `<button type="button" data-identity-action="${action}" ${enabled ? "" : "disabled"}>${label}</button>`;
17
+ }
18
+
19
+ function userDashboards(user) {
20
+ const dashboards = user.dashboards || [];
21
+ if (!dashboards.length) return `<h3>Dashboards</h3><p class="identity-empty">No dashboards created.</p>`;
22
+ return `<h3>Dashboards</h3><table class="identity-provider-table"><thead><tr><th>Name</th><th>Last updated</th></tr></thead><tbody>${dashboards.map((dashboard) => `<tr><td><strong>${escapeHtml(dashboard.name || "Untitled dashboard")}</strong>${dashboard.description ? `<br><span class="identity-dashboard-description">${escapeHtml(dashboard.description)}</span>` : ""}</td><td>${escapeHtml(dashboard.updated_at || "-")}</td></tr>`).join("")}</tbody></table>`;
23
+ }
24
+
25
+ function renderPane(user) {
26
+ const editable = Boolean(user.editable_name || user.editable_password);
27
+ return `<aside class="identity-pane" style="width:${paneWidth}px">
28
+ <div class="identity-resizer" id="identity-resizer" title="Drag to resize"></div>
29
+ <header><div><h2>${escapeHtml(user.name || user.username)}</h2><p>${escapeHtml(user.provider)}</p></div><button id="identity-close" type="button" aria-label="Close">×</button></header>
30
+ <div class="identity-pane-content">
31
+ <dl class="identity-basics"><dt>Name</dt><dd>${escapeHtml(user.name || user.username)}</dd><dt>Username</dt><dd>${escapeHtml(user.username)}${user.overshadowed ? ` <span class="identity-warning" title="Overshadowed by ${escapeHtml(user.overshadowed_by?.username || "another user")} from ${escapeHtml(user.overshadowed_by?.provider || "another provider")}">⚠️</span>` : ""}</dd></dl>
32
+ <div class="identity-actions">${actionButton("username", "Change username", editable)}${actionButton("password", "Change password", Boolean(user.editable_password))}</div>
33
+ ${userDashboards(user)}
34
+ ${providerSpecific(user)}
35
+ </div>
36
+ </aside>`;
37
+ }
38
+
39
+ function renderEditModal() {
40
+ if (!editAction || !selected) return "";
41
+ const isPassword = editAction === "password";
42
+ const label = isPassword ? "New password" : "New username";
43
+ const value = isPassword ? "" : escapeHtml(selected.username);
44
+ return `<div class="modal-backdrop" data-identity-modal-backdrop><section class="modal-panel modal-panel-small" role="dialog" aria-modal="true" aria-labelledby="identity-modal-title"><div class="modal-header"><div><h2 id="identity-modal-title">${isPassword ? "Change password" : "Change username"}</h2><p>${escapeHtml(selected.name || selected.username)}</p></div></div><form id="identity-edit-modal-form" class="form-grid"><label>${label}<input name="value" ${isPassword ? "type=\"password\" minlength=\"8\" autocomplete=\"new-password\"" : "autocomplete=\"username\""} value="${value}" required /></label><div class="form-actions"><button type="button" data-identity-modal-cancel>Cancel</button><button class="primary" type="submit">Apply now</button></div></form></section></div>`;
45
+ }
46
+
47
+ function renderIdentity() {
48
+ const rows = (state.identities || []).map((user) => `<tr class="identity-table-row${selected?.id === user.id ? " selected" : ""}" data-identity-id="${escapeHtml(user.id)}"><td>${escapeHtml(user.name || user.username)}</td><td>${escapeHtml(user.username)}${user.overshadowed ? ` <span class="identity-warning" title="Overshadowed by ${escapeHtml(user.overshadowed_by?.username || "another user")} from ${escapeHtml(user.overshadowed_by?.provider || "another provider")}">⚠️</span>` : ""}</td><td>${escapeHtml(user.role)}</td><td>${escapeHtml(user.provider)}</td></tr>`).join("");
49
+ return `<section class="panel identities-page"><div class="panel-header"><h2>Identities</h2><button id="identity-refresh" type="button">Refresh</button></div><div class="table-wrap"><table><thead><tr><th>Name</th><th>Username</th><th>Role</th><th>Provider</th></tr></thead><tbody>${rows || `<tr><td colspan="4">No users found.</td></tr>`}</tbody></table></div>${selected ? renderPane(selected) : ""}</section>${renderEditModal()}`;
50
+ }
51
+
52
+ function clampPaneWidth() {
53
+ const page = document.querySelector(".identities-page");
54
+ const pane = document.querySelector(".identity-pane");
55
+ if (!page || !pane) return;
56
+ const maximum = Math.max(320, Math.floor(page.getBoundingClientRect().width));
57
+ paneWidth = Math.min(paneWidth, maximum);
58
+ pane.style.width = `${paneWidth}px`;
59
+ }
60
+
61
+ function observePageWidth() {
62
+ const page = document.querySelector(".identities-page");
63
+ if (!page || !window.ResizeObserver) return;
64
+ pageResizeObserver?.disconnect();
65
+ pageResizeObserver = new ResizeObserver(clampPaneWidth);
66
+ pageResizeObserver.observe(page);
67
+ }
68
+
69
+ async function load(refresh = false, refreshAfterLoad = false) {
70
+ try {
71
+ state.identities = (await api(`/api/v1/admin/identities${refresh ? "?refresh=true" : ""}`)).items || [];
72
+ if (selected) selected = state.identities.find((item) => item.id === selected.id) || null;
73
+ loaded = true;
74
+ render();
75
+ if (refreshAfterLoad) void load(true);
76
+ } catch (error) { setMessage("error", error.message); }
77
+ }
78
+
79
+ function attachResize() {
80
+ clampPaneWidth(); observePageWidth();
81
+ document.querySelector("#identity-resizer")?.addEventListener("pointerdown", (event) => {
82
+ event.preventDefault(); const startX = event.clientX; const startWidth = paneWidth;
83
+ const move = (moveEvent) => { const page = document.querySelector(".identities-page"); const maximum = Math.max(320, Math.floor(page?.getBoundingClientRect().width || 320)); paneWidth = Math.min(maximum, Math.max(320, startWidth + startX - moveEvent.clientX)); clampPaneWidth(); };
84
+ const stop = () => { window.removeEventListener("pointermove", move); };
85
+ window.addEventListener("pointermove", move); window.addEventListener("pointerup", stop, { once: true });
86
+ });
87
+ }
88
+
89
+ function attachModal() {
90
+ document.querySelector("[data-identity-modal-cancel]")?.addEventListener("click", () => { editAction = null; render(); });
91
+ document.querySelector("[data-identity-modal-backdrop]")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) { editAction = null; render(); } });
92
+ document.querySelector("#identity-edit-modal-form")?.addEventListener("submit", async (event) => { event.preventDefault(); const value = String(new FormData(event.currentTarget).get("value") || ""); const body = editAction === "password" ? { new_password: value } : { username: value }; try { await api(`/api/v1/admin/identities/${encodeURIComponent(selected.id)}`, { method: "PATCH", body: JSON.stringify(body) }); editAction = null; setMessage("success", "Identity updated. The affected user has been signed out."); await load(); } catch (error) { setMessage("error", error.message); } });
93
+ }
94
+
95
+ function attach() {
96
+ document.querySelector("#identity-refresh")?.addEventListener("click", () => void load(true));
97
+ document.querySelectorAll("[data-identity-id]").forEach((row) => row.addEventListener("click", () => { selected = state.identities.find((item) => item.id === row.dataset.identityId) || null; render(); }));
98
+ document.querySelector("#identity-close")?.addEventListener("click", () => { selected = null; render(); });
99
+ document.querySelectorAll("[data-identity-action]").forEach((button) => button.addEventListener("click", () => { editAction = button.dataset.identityAction; render(); }));
100
+ attachResize(); attachModal();
101
+ if (!loaded || refreshOnAttach) {
102
+ const loadFromCacheFirst = (state.identities || []).length === 0;
103
+ refreshOnAttach = false;
104
+ void load(!loadFromCacheFirst, loadFromCacheFirst);
105
+ }
106
+ }
107
+ function activate() { refreshOnAttach = true; }
108
+ return { render: renderIdentity, attach, activate };
109
+ }