gaard-api 0.2.6__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.
- {gaard_api-0.2.6/src/gaard_api.egg-info → gaard_api-0.2.8}/PKG-INFO +5 -5
- {gaard_api-0.2.6 → gaard_api-0.2.8}/pyproject.toml +5 -5
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/admin/database.py +186 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/admin/defaults.py +120 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/admin/models.py +47 -1
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/admin/prompt_runtime.py +123 -1
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/admin/services.py +1 -0
- gaard_api-0.2.8/src/gaard_api/admin-web/assets/identity.js +109 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/admin-web/assets/main.js +437 -117
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/admin-web/assets/styles.css +310 -50
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/api/v1/admin.py +610 -42
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/api/v1/analysis.py +1 -1
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/api/v1/dashboards.py +72 -12
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/api/v1/query.py +407 -69
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/api_registry.py +30 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/auth_dependencies.py +50 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/auth_hooks.py +15 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/conversations.py +13 -4
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/core/settings.py +1 -1
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/example_database.py +43 -33
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/main.py +1 -1
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/package_updates.py +1 -1
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/query_hooks.py +90 -1
- {gaard_api-0.2.6 → gaard_api-0.2.8/src/gaard_api.egg-info}/PKG-INFO +5 -5
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api.egg-info/SOURCES.txt +1 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api.egg-info/requires.txt +4 -4
- {gaard_api-0.2.6 → gaard_api-0.2.8}/tests/test_admin_api.py +640 -74
- {gaard_api-0.2.6 → gaard_api-0.2.8}/tests/test_api_registry.py +15 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/tests/test_conversation_api.py +77 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/tests/test_license_api.py +13 -0
- gaard_api-0.2.8/tests/test_prompt_runtime.py +157 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/tests/test_server_cli.py +9 -5
- {gaard_api-0.2.6 → gaard_api-0.2.8}/tests/test_settings.py +1 -0
- gaard_api-0.2.6/tests/test_prompt_runtime.py +0 -80
- {gaard_api-0.2.6 → gaard_api-0.2.8}/MANIFEST.in +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/README.md +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/setup.cfg +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/__init__.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/admin/__init__.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/admin/security.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/admin-web/index.html +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/admin-web/package.json +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/api/__init__.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/api/v1/__init__.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/api/v1/prompts.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/api/v1/schema.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/cli.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/cli_commands.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/core/__init__.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/core/error_handlers.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/core/schema_cache.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/example_data/__init__.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/extension_services.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/extensions.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/license.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/py.typed +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/server_cli.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/siem.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api/tls_http.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api.egg-info/dependency_links.txt +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api.egg-info/entry_points.txt +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/src/gaard_api.egg-info/top_level.txt +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/tests/test_analysis_api.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/tests/test_error_handlers.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/tests/test_license_service.py +0 -0
- {gaard_api-0.2.6 → gaard_api-0.2.8}/tests/test_query_error_messages.py +0 -0
- {gaard_api-0.2.6 → 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.
|
|
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.
|
|
8
|
-
Requires-Dist: gaard-connectors==0.2.
|
|
9
|
-
Requires-Dist: gaard-llm==0.2.
|
|
10
|
-
Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.
|
|
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
|
+
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.
|
|
14
|
-
"gaard-connectors==0.2.
|
|
15
|
-
"gaard-llm==0.2.
|
|
16
|
-
"gaard-plugin-api>=0.2.
|
|
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
|
|
@@ -86,6 +86,75 @@ Return one JSON object with:
|
|
|
86
86
|
- reason: short explanation
|
|
87
87
|
"""
|
|
88
88
|
|
|
89
|
+
DEFAULT_CONVERSATION_CONTEXT_CLASSIFICATION_SYSTEM_PROMPT = """You are GAARD Conversation Context Classification.
|
|
90
|
+
|
|
91
|
+
Your task is to decide whether the current user data question (turn t) is a
|
|
92
|
+
logical continuation of the recent conversation turns (t-2 and t-1).
|
|
93
|
+
|
|
94
|
+
Use the previous question-answer pairs as conversation evidence. Do not classify
|
|
95
|
+
by rigid prefix or keyword rules. Decide semantically whether turn t depends on,
|
|
96
|
+
compares with, narrows, broadens, or otherwise continues the immediately
|
|
97
|
+
preceding analytical thread.
|
|
98
|
+
|
|
99
|
+
Allowed decisions:
|
|
100
|
+
- new_topic: answer "no" to logical continuation. The question starts a new analytical thread.
|
|
101
|
+
- follow_up: answer "yes" to logical continuation. The question continues the
|
|
102
|
+
recent thread and can be executed safely.
|
|
103
|
+
- ambiguous: the question appears to continue the thread, but required entities,
|
|
104
|
+
filters, date ranges, or datasource scope cannot be inferred safely.
|
|
105
|
+
|
|
106
|
+
Decision rules:
|
|
107
|
+
1. First answer the yes/no question: is turn t a logical continuation of t-1/t-2?
|
|
108
|
+
2. A question can be a logical continuation even when it is already
|
|
109
|
+
self-contained. In that case use follow_up, set
|
|
110
|
+
current_question_is_standalone to true, and set standalone_question to the
|
|
111
|
+
current question.
|
|
112
|
+
3. If the answer is no, use new_topic and set standalone_question to the current question.
|
|
113
|
+
4. If the answer is yes and the current question is elliptical, rewrite it as a
|
|
114
|
+
standalone data question using t-1/t-2.
|
|
115
|
+
5. Use ambiguous only when the answer is yes but the continuation cannot be
|
|
116
|
+
rewritten or executed safely without asking the user.
|
|
117
|
+
6. Treat detail/projection requests about the previous result as follow_up when
|
|
118
|
+
the previous turn defines the result set. If the previous question counted,
|
|
119
|
+
grouped, or filtered records and the user now asks for descriptions, names,
|
|
120
|
+
statuses, fields, details, or values for those same records, rewrite by
|
|
121
|
+
preserving the previous filters/date range/datasource and changing only the
|
|
122
|
+
returned fields.
|
|
123
|
+
7. Do not mark a projection/detail follow-up ambiguous merely because the
|
|
124
|
+
previous answer did not expose row ids. The previous standalone question and
|
|
125
|
+
SQL are enough context for the next SQL generation step.
|
|
126
|
+
8. Never include rows or sensitive data in the standalone question.
|
|
127
|
+
|
|
128
|
+
Output rules:
|
|
129
|
+
- Return only a JSON object.
|
|
130
|
+
- Do not include markdown.
|
|
131
|
+
- Do not include reasoning outside the JSON.
|
|
132
|
+
- Do not include <think> blocks.
|
|
133
|
+
- Use exactly this JSON shape:
|
|
134
|
+
{
|
|
135
|
+
"is_continuation": false,
|
|
136
|
+
"decision": "new_topic",
|
|
137
|
+
"current_question_is_standalone": true,
|
|
138
|
+
"confidence": 0.0,
|
|
139
|
+
"standalone_question": "rewritten or current question",
|
|
140
|
+
"reason": "short reason"
|
|
141
|
+
}
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
DEFAULT_CONVERSATION_CONTEXT_CLASSIFICATION_USER_PROMPT = """Decide whether turn t is a logical continuation of turns t-2 and t-1.
|
|
145
|
+
|
|
146
|
+
Input JSON:
|
|
147
|
+
{payload}
|
|
148
|
+
|
|
149
|
+
Return one JSON object with:
|
|
150
|
+
- is_continuation: boolean yes/no answer to the logical-continuation question
|
|
151
|
+
- decision: one of new_topic, follow_up, ambiguous
|
|
152
|
+
- current_question_is_standalone: boolean
|
|
153
|
+
- confidence: number from 0 to 1
|
|
154
|
+
- standalone_question: required for follow_up and new_topic; empty only for ambiguous
|
|
155
|
+
- reason: short explanation
|
|
156
|
+
"""
|
|
157
|
+
|
|
89
158
|
DEFAULT_RESULT_INTERPRETATION_SYSTEM_PROMPT = """You are GAARD Data Result Interpreter.
|
|
90
159
|
|
|
91
160
|
Your task is to explain SQL query results to the user.
|
|
@@ -149,6 +218,40 @@ Return exactly one of:
|
|
|
149
218
|
personal_data, sensitive_data, technical_data, neutral_data, unknown
|
|
150
219
|
"""
|
|
151
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
|
+
|
|
152
255
|
DEFAULT_GOVERNANCE_POLICY_CONFIG = {
|
|
153
256
|
"final_answer": {
|
|
154
257
|
"record_level_pii_allowed": False,
|
|
@@ -187,6 +290,16 @@ DEFAULT_PROMPTS = [
|
|
|
187
290
|
"system_prompt": DEFAULT_SQL_GENERATION_SYSTEM_PROMPT,
|
|
188
291
|
"user_prompt_template": DEFAULT_SQL_GENERATION_USER_PROMPT,
|
|
189
292
|
},
|
|
293
|
+
{
|
|
294
|
+
"prompt_key": "conversation_context_classification",
|
|
295
|
+
"name": "Conversation context classification",
|
|
296
|
+
"description": (
|
|
297
|
+
"Decides whether the current question is a logical continuation "
|
|
298
|
+
"of recent conversation turns."
|
|
299
|
+
),
|
|
300
|
+
"system_prompt": DEFAULT_CONVERSATION_CONTEXT_CLASSIFICATION_SYSTEM_PROMPT,
|
|
301
|
+
"user_prompt_template": DEFAULT_CONVERSATION_CONTEXT_CLASSIFICATION_USER_PROMPT,
|
|
302
|
+
},
|
|
190
303
|
{
|
|
191
304
|
"prompt_key": "result_interpretation",
|
|
192
305
|
"name": "Result interpretation",
|
|
@@ -201,4 +314,11 @@ DEFAULT_PROMPTS = [
|
|
|
201
314
|
"system_prompt": DEFAULT_RESULT_CLASSIFICATION_SYSTEM_PROMPT,
|
|
202
315
|
"user_prompt_template": DEFAULT_RESULT_CLASSIFICATION_USER_PROMPT,
|
|
203
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
|
+
},
|
|
204
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),
|
|
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
|
|