gaard-api 0.2.5__tar.gz → 0.2.7__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.5/src/gaard_api.egg-info → gaard_api-0.2.7}/PKG-INFO +5 -5
- {gaard_api-0.2.5 → gaard_api-0.2.7}/pyproject.toml +5 -5
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin/defaults.py +79 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin/prompt_runtime.py +74 -1
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/v1/admin.py +98 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/v1/dashboards.py +134 -1
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/v1/query.py +30 -13
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/conversations.py +8 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/main.py +1 -1
- {gaard_api-0.2.5 → gaard_api-0.2.7/src/gaard_api.egg-info}/PKG-INFO +5 -5
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api.egg-info/requires.txt +4 -4
- {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_admin_api.py +164 -1
- {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_conversation_api.py +77 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_prompt_runtime.py +39 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/MANIFEST.in +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/README.md +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/setup.cfg +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/__init__.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin/__init__.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin/database.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin/models.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin/security.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin/services.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/main.js +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/styles.css +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/index.html +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/package.json +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/__init__.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/v1/__init__.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/v1/analysis.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/v1/prompts.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/v1/schema.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api_registry.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/auth_dependencies.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/auth_hooks.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/cli.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/cli_commands.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/core/__init__.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/core/error_handlers.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/core/schema_cache.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/core/settings.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/example_data/__init__.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/example_database.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/extension_services.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/extensions.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/license.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/package_updates.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/py.typed +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/query_hooks.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/server_cli.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/siem.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/tls_http.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api.egg-info/SOURCES.txt +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api.egg-info/dependency_links.txt +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api.egg-info/entry_points.txt +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api.egg-info/top_level.txt +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_analysis_api.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_api_registry.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_error_handlers.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_license_api.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_license_service.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_query_error_messages.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_server_cli.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_settings.py +0 -0
- {gaard_api-0.2.5 → gaard_api-0.2.7}/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.7
|
|
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.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
|
|
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.7"
|
|
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.7",
|
|
14
|
+
"gaard-connectors==0.2.7",
|
|
15
|
+
"gaard-llm==0.2.7",
|
|
16
|
+
"gaard-plugin-api>=0.2.7,<0.3.0",
|
|
17
17
|
"fastapi>=0.111.0",
|
|
18
18
|
"uvicorn[standard]>=0.30.0",
|
|
19
19
|
"pydantic>=2.7.0",
|
|
@@ -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.
|
|
@@ -187,6 +256,16 @@ DEFAULT_PROMPTS = [
|
|
|
187
256
|
"system_prompt": DEFAULT_SQL_GENERATION_SYSTEM_PROMPT,
|
|
188
257
|
"user_prompt_template": DEFAULT_SQL_GENERATION_USER_PROMPT,
|
|
189
258
|
},
|
|
259
|
+
{
|
|
260
|
+
"prompt_key": "conversation_context_classification",
|
|
261
|
+
"name": "Conversation context classification",
|
|
262
|
+
"description": (
|
|
263
|
+
"Decides whether the current question is a logical continuation "
|
|
264
|
+
"of recent conversation turns."
|
|
265
|
+
),
|
|
266
|
+
"system_prompt": DEFAULT_CONVERSATION_CONTEXT_CLASSIFICATION_SYSTEM_PROMPT,
|
|
267
|
+
"user_prompt_template": DEFAULT_CONVERSATION_CONTEXT_CLASSIFICATION_USER_PROMPT,
|
|
268
|
+
},
|
|
190
269
|
{
|
|
191
270
|
"prompt_key": "result_interpretation",
|
|
192
271
|
"name": "Result interpretation",
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
1
3
|
from gaard_core.errors import ConfigurationError
|
|
2
4
|
from gaard_core.json_utils import json_dumps
|
|
3
5
|
from gaard_core.prompt_compiler.models import CompiledPrompt, SqlGenerationPromptRequest
|
|
4
6
|
from gaard_core.prompt_compiler.schema_formatter import SchemaPromptFormatter
|
|
5
|
-
from gaard_core.query_pipeline.models import
|
|
7
|
+
from gaard_core.query_pipeline.models import (
|
|
8
|
+
ConversationContextDecision,
|
|
9
|
+
QueryRequest,
|
|
10
|
+
QueryResult,
|
|
11
|
+
)
|
|
6
12
|
|
|
7
13
|
from gaard_api.admin.models import PromptTemplate
|
|
8
14
|
from gaard_api.admin.services import get_active_prompt_template_safe
|
|
@@ -85,6 +91,62 @@ class MetadataIntentClassificationPromptCompiler:
|
|
|
85
91
|
)
|
|
86
92
|
|
|
87
93
|
|
|
94
|
+
class MetadataConversationContextPromptCompiler:
|
|
95
|
+
def __init__(self, prompt_template: PromptTemplate) -> None:
|
|
96
|
+
self.prompt_template = prompt_template
|
|
97
|
+
|
|
98
|
+
def compile(
|
|
99
|
+
self,
|
|
100
|
+
request: QueryRequest,
|
|
101
|
+
conversation_context: dict[str, Any],
|
|
102
|
+
) -> CompiledPrompt:
|
|
103
|
+
recent_turns = self._recent_turns(conversation_context)
|
|
104
|
+
payload = {
|
|
105
|
+
"turn_t_minus_2": recent_turns[0] if len(recent_turns) == 2 else {},
|
|
106
|
+
"turn_t_minus_1": recent_turns[-1] if recent_turns else {},
|
|
107
|
+
"turn_t": {
|
|
108
|
+
"question": request.question,
|
|
109
|
+
"datasource_id": request.datasource_id,
|
|
110
|
+
"datasource_ids": request.datasource_ids,
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
payload_json = json_dumps(payload, ensure_ascii=False, indent=2)
|
|
114
|
+
|
|
115
|
+
return CompiledPrompt(
|
|
116
|
+
system_prompt=self.prompt_template.system_prompt,
|
|
117
|
+
user_prompt=self.prompt_template.user_prompt_template.format(
|
|
118
|
+
payload=payload_json,
|
|
119
|
+
question=request.question,
|
|
120
|
+
datasource_id=request.datasource_id,
|
|
121
|
+
datasource_ids=json_dumps(request.datasource_ids, ensure_ascii=False),
|
|
122
|
+
),
|
|
123
|
+
metadata={
|
|
124
|
+
"allowed_decisions": [item.value for item in ConversationContextDecision],
|
|
125
|
+
"decision_task": "logical_continuation_yes_no",
|
|
126
|
+
"prompt_key": self.prompt_template.prompt_key,
|
|
127
|
+
"prompt_version": self.prompt_template.version,
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _recent_turns(self, conversation_context: dict[str, Any]) -> list[dict[str, Any]]:
|
|
132
|
+
turns = [
|
|
133
|
+
turn for turn in conversation_context.get("turns", []) if isinstance(turn, dict)
|
|
134
|
+
][-2:]
|
|
135
|
+
labels = ["t-2", "t-1"] if len(turns) == 2 else ["t-1"]
|
|
136
|
+
return [
|
|
137
|
+
{
|
|
138
|
+
"label": label,
|
|
139
|
+
"question": str(turn.get("question") or ""),
|
|
140
|
+
"standalone_question": str(turn.get("standalone_question") or ""),
|
|
141
|
+
"answer": str(turn.get("answer") or ""),
|
|
142
|
+
"sql": str(turn.get("sql") or ""),
|
|
143
|
+
"context_decision": str(turn.get("context_decision") or ""),
|
|
144
|
+
"context_reason": str(turn.get("context_reason") or ""),
|
|
145
|
+
}
|
|
146
|
+
for label, turn in zip(labels, turns, strict=False)
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
88
150
|
class MetadataResultInterpretationPromptCompiler:
|
|
89
151
|
def __init__(self, prompt_template: PromptTemplate) -> None:
|
|
90
152
|
self.prompt_template = prompt_template
|
|
@@ -170,6 +232,17 @@ def get_intent_classification_prompt_compiler() -> (
|
|
|
170
232
|
return MetadataIntentClassificationPromptCompiler(prompt_template=prompt_template)
|
|
171
233
|
|
|
172
234
|
|
|
235
|
+
def get_conversation_context_prompt_compiler() -> (
|
|
236
|
+
MetadataConversationContextPromptCompiler | None
|
|
237
|
+
):
|
|
238
|
+
prompt_template = get_active_prompt_template_safe("conversation_context_classification")
|
|
239
|
+
|
|
240
|
+
if prompt_template is None:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
return MetadataConversationContextPromptCompiler(prompt_template=prompt_template)
|
|
244
|
+
|
|
245
|
+
|
|
173
246
|
def get_result_interpretation_prompt_compiler() -> (
|
|
174
247
|
MetadataResultInterpretationPromptCompiler | None
|
|
175
248
|
):
|
|
@@ -13,6 +13,7 @@ from gaard_core.errors import (
|
|
|
13
13
|
QueryExecutionError,
|
|
14
14
|
SqlValidationError,
|
|
15
15
|
)
|
|
16
|
+
from gaard_core.llm_output import remove_thinking_blocks
|
|
16
17
|
from gaard_core.query_pipeline.models import (
|
|
17
18
|
OutputClassification,
|
|
18
19
|
QueryRequest,
|
|
@@ -21,6 +22,8 @@ from gaard_core.query_pipeline.models import (
|
|
|
21
22
|
)
|
|
22
23
|
from gaard_core.sql_validator.select_only import SelectOnlySqlValidator
|
|
23
24
|
from gaard_connectors import ConnectorRegistryError
|
|
25
|
+
from gaard_llm.openai_compatible.client import OpenAICompatibleClient
|
|
26
|
+
from gaard_llm.providers.models import ChatCompletionRequest, ChatMessage
|
|
24
27
|
from gaard_plugin_api import ExtensionRecord
|
|
25
28
|
from pydantic import BaseModel, Field
|
|
26
29
|
from sqlalchemy import select
|
|
@@ -251,6 +254,11 @@ class OverviewWidgetFromQueryRequest(BaseModel):
|
|
|
251
254
|
)
|
|
252
255
|
|
|
253
256
|
|
|
257
|
+
class OverviewWidgetTitleSuggestionRequest(BaseModel):
|
|
258
|
+
question: str = Field(min_length=1)
|
|
259
|
+
sql: str | None = None
|
|
260
|
+
|
|
261
|
+
|
|
254
262
|
class DatasourceConnectorRequest(BaseModel):
|
|
255
263
|
connector_key: str = Field(min_length=1, pattern=r"^[a-zA-Z0-9_-]+$")
|
|
256
264
|
name: str = Field(min_length=1)
|
|
@@ -503,6 +511,79 @@ def build_client_widget_key(session: Session, label: str, question: str) -> str:
|
|
|
503
511
|
return widget_key
|
|
504
512
|
|
|
505
513
|
|
|
514
|
+
def normalize_metric_title(value: str, fallback: str) -> str:
|
|
515
|
+
cleaned = remove_thinking_blocks(value).strip()
|
|
516
|
+
|
|
517
|
+
if cleaned.startswith("```"):
|
|
518
|
+
cleaned = cleaned.removeprefix("```").strip()
|
|
519
|
+
if cleaned.startswith("text"):
|
|
520
|
+
cleaned = cleaned.removeprefix("text").strip()
|
|
521
|
+
if cleaned.endswith("```"):
|
|
522
|
+
cleaned = cleaned.removesuffix("```").strip()
|
|
523
|
+
|
|
524
|
+
cleaned = cleaned.strip().strip('"').strip("'")
|
|
525
|
+
cleaned = re.sub(r"\s+", " ", cleaned)
|
|
526
|
+
cleaned = cleaned.rstrip(".:;-")
|
|
527
|
+
if not cleaned:
|
|
528
|
+
cleaned = fallback.strip()
|
|
529
|
+
|
|
530
|
+
return cleaned[:80].strip() or "Saved Metric"
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def fallback_metric_title(question: str) -> str:
|
|
534
|
+
normalized = re.sub(r"\s+", " ", question.strip())
|
|
535
|
+
normalized = normalized.rstrip("?!.:;-")
|
|
536
|
+
if not normalized:
|
|
537
|
+
return "Saved Metric"
|
|
538
|
+
|
|
539
|
+
return normalized[0].upper() + normalized[1:80]
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def suggest_metric_title_with_llm(session: Session, request: OverviewWidgetTitleSuggestionRequest) -> str:
|
|
543
|
+
llm_config = get_llm_runtime_config(session)
|
|
544
|
+
if llm_config.provider != "openai-compatible":
|
|
545
|
+
raise ConfigurationError(f"Unsupported GAARD_LLM_PROVIDER: {llm_config.provider}")
|
|
546
|
+
if not llm_config.api_key or llm_config.api_key == "change-me":
|
|
547
|
+
raise ConfigurationError("GAARD_LLM_API_KEY must be configured to suggest metric titles.")
|
|
548
|
+
|
|
549
|
+
client = OpenAICompatibleClient(
|
|
550
|
+
base_url=llm_config.base_url,
|
|
551
|
+
api_key=llm_config.api_key,
|
|
552
|
+
timeout_seconds=llm_config.timeout_seconds,
|
|
553
|
+
)
|
|
554
|
+
fallback = fallback_metric_title(request.question)
|
|
555
|
+
response = client.create_chat_completion(
|
|
556
|
+
ChatCompletionRequest(
|
|
557
|
+
model=llm_config.model,
|
|
558
|
+
temperature=0.0,
|
|
559
|
+
extra_body=llm_config.extra_body,
|
|
560
|
+
messages=[
|
|
561
|
+
ChatMessage(
|
|
562
|
+
role="system",
|
|
563
|
+
content=(
|
|
564
|
+
"You write short human-friendly metric names for dashboard widgets. "
|
|
565
|
+
"Return only the metric name, without quotes, markdown, explanations, "
|
|
566
|
+
"or trailing punctuation. Use the same language as the user question. "
|
|
567
|
+
"Prefer 3 to 8 words and keep it under 80 characters."
|
|
568
|
+
),
|
|
569
|
+
),
|
|
570
|
+
ChatMessage(
|
|
571
|
+
role="user",
|
|
572
|
+
content=(
|
|
573
|
+
"User question:\n"
|
|
574
|
+
f"{request.question.strip()}\n\n"
|
|
575
|
+
"Generated SQL, if useful:\n"
|
|
576
|
+
f"{(request.sql or '').strip()}\n\n"
|
|
577
|
+
"Return the metric name only."
|
|
578
|
+
),
|
|
579
|
+
),
|
|
580
|
+
],
|
|
581
|
+
)
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
return normalize_metric_title(response.content, fallback)
|
|
585
|
+
|
|
586
|
+
|
|
506
587
|
def build_client_excel_datasource_key(session: Session, filename: str) -> str:
|
|
507
588
|
stem = Path(filename).stem or "excel"
|
|
508
589
|
normalized = re.sub(r"[^a-z0-9_-]+", "_", stem.lower()).strip("_-") or "excel"
|
|
@@ -1416,6 +1497,23 @@ def create_overview_widget_from_query(
|
|
|
1416
1497
|
}
|
|
1417
1498
|
|
|
1418
1499
|
|
|
1500
|
+
@router.post("/overview/widgets/title-suggestion")
|
|
1501
|
+
def suggest_overview_widget_title(
|
|
1502
|
+
request: OverviewWidgetTitleSuggestionRequest,
|
|
1503
|
+
_principal: AuthenticatedSession = Depends(get_current_api_user),
|
|
1504
|
+
session: Session = Depends(get_session),
|
|
1505
|
+
) -> dict[str, Any]:
|
|
1506
|
+
try:
|
|
1507
|
+
title = suggest_metric_title_with_llm(session, request)
|
|
1508
|
+
except (ConfigurationError, LlmProviderError, ValueError) as exc:
|
|
1509
|
+
raise HTTPException(
|
|
1510
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1511
|
+
detail=str(exc),
|
|
1512
|
+
) from exc
|
|
1513
|
+
|
|
1514
|
+
return {"title": title}
|
|
1515
|
+
|
|
1516
|
+
|
|
1419
1517
|
@router.put("/overview/widgets/{widget_key}")
|
|
1420
1518
|
def update_overview_widget(
|
|
1421
1519
|
widget_key: str,
|
|
@@ -48,6 +48,11 @@ class DashboardCreateRequest(BaseModel):
|
|
|
48
48
|
description: str = Field(default="", max_length=2_000)
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
class DashboardUpdateRequest(BaseModel):
|
|
52
|
+
name: str = Field(min_length=1, max_length=255)
|
|
53
|
+
description: str = Field(default="", max_length=2_000)
|
|
54
|
+
|
|
55
|
+
|
|
51
56
|
class ActiveDashboardRequest(BaseModel):
|
|
52
57
|
dashboard_id: str = Field(min_length=1, max_length=64)
|
|
53
58
|
|
|
@@ -58,6 +63,10 @@ class DashboardWidgetCreateRequest(BaseModel):
|
|
|
58
63
|
visualization_type: str = Field(pattern=r"^(number|bar|stacked_bar|line|multi_line|pie|area|table)$")
|
|
59
64
|
|
|
60
65
|
|
|
66
|
+
class SavedMetricUpdateRequest(BaseModel):
|
|
67
|
+
label: str = Field(min_length=1, max_length=255)
|
|
68
|
+
|
|
69
|
+
|
|
61
70
|
class DashboardWidgetLayoutItem(BaseModel):
|
|
62
71
|
widget_id: str = Field(min_length=1, max_length=64)
|
|
63
72
|
x: int = Field(ge=0, le=11)
|
|
@@ -223,6 +232,19 @@ def get_saved_metric_for_owner(
|
|
|
223
232
|
)
|
|
224
233
|
|
|
225
234
|
|
|
235
|
+
def get_saved_metric_link_for_owner(
|
|
236
|
+
session: Session,
|
|
237
|
+
widget_key: str,
|
|
238
|
+
owner_user_id: str,
|
|
239
|
+
) -> UserSavedMetric | None:
|
|
240
|
+
return session.scalar(
|
|
241
|
+
select(UserSavedMetric).where(
|
|
242
|
+
UserSavedMetric.widget_key == widget_key,
|
|
243
|
+
UserSavedMetric.owner_user_id == owner_user_id,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
226
248
|
def list_saved_metrics_for_owner(
|
|
227
249
|
session: Session,
|
|
228
250
|
owner_user_id: str,
|
|
@@ -308,16 +330,93 @@ def list_dashboards(
|
|
|
308
330
|
|
|
309
331
|
@router.get("/dashboards/metrics")
|
|
310
332
|
def list_saved_dashboard_metrics(
|
|
333
|
+
include_result: bool = True,
|
|
311
334
|
principal: AuthenticatedSession = Depends(get_current_api_user),
|
|
312
335
|
session: Session = Depends(get_session),
|
|
313
336
|
) -> dict[str, Any]:
|
|
314
337
|
metrics = list_saved_metrics_for_owner(session, dashboard_owner_user_id(principal))
|
|
315
338
|
return {
|
|
316
|
-
"items": [
|
|
339
|
+
"items": [
|
|
340
|
+
serialize_saved_metric(session, metric, include_result=include_result)
|
|
341
|
+
for metric in metrics
|
|
342
|
+
],
|
|
317
343
|
"viewer": dashboard_owner_username(principal),
|
|
318
344
|
}
|
|
319
345
|
|
|
320
346
|
|
|
347
|
+
@router.patch("/dashboards/metrics/{widget_key}")
|
|
348
|
+
def update_saved_dashboard_metric(
|
|
349
|
+
widget_key: str,
|
|
350
|
+
request: SavedMetricUpdateRequest,
|
|
351
|
+
principal: AuthenticatedSession = Depends(get_current_api_user),
|
|
352
|
+
session: Session = Depends(get_session),
|
|
353
|
+
) -> dict[str, Any]:
|
|
354
|
+
owner_user_id = dashboard_owner_user_id(principal)
|
|
355
|
+
metric = get_saved_metric_for_owner(session, widget_key, owner_user_id)
|
|
356
|
+
if metric is None:
|
|
357
|
+
raise HTTPException(
|
|
358
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
359
|
+
detail="Saved metric not found.",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
label = request.label.strip()
|
|
363
|
+
if not label:
|
|
364
|
+
raise HTTPException(
|
|
365
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
366
|
+
detail="Metric name is required.",
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
metric.label = label
|
|
370
|
+
metric.updated_by = dashboard_owner_username(principal)
|
|
371
|
+
session.commit()
|
|
372
|
+
|
|
373
|
+
return {"item": serialize_saved_metric(session, metric, include_result=False)}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@router.delete("/dashboards/metrics/{widget_key}")
|
|
377
|
+
def delete_saved_dashboard_metric(
|
|
378
|
+
widget_key: str,
|
|
379
|
+
principal: AuthenticatedSession = Depends(get_current_api_user),
|
|
380
|
+
session: Session = Depends(get_session),
|
|
381
|
+
) -> dict[str, Any]:
|
|
382
|
+
owner_user_id = dashboard_owner_user_id(principal)
|
|
383
|
+
metric = get_saved_metric_for_owner(session, widget_key, owner_user_id)
|
|
384
|
+
metric_link = get_saved_metric_link_for_owner(session, widget_key, owner_user_id)
|
|
385
|
+
if metric is None or metric_link is None:
|
|
386
|
+
raise HTTPException(
|
|
387
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
388
|
+
detail="Saved metric not found.",
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
dashboard_widgets = list(
|
|
392
|
+
session.scalars(
|
|
393
|
+
select(DashboardWidget).where(
|
|
394
|
+
DashboardWidget.metric_widget_key == widget_key,
|
|
395
|
+
DashboardWidget.owner_user_id == owner_user_id,
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
for widget in dashboard_widgets:
|
|
400
|
+
session.delete(widget)
|
|
401
|
+
|
|
402
|
+
session.delete(metric_link)
|
|
403
|
+
session.flush()
|
|
404
|
+
|
|
405
|
+
remaining_links = session.scalar(
|
|
406
|
+
select(UserSavedMetric.id).where(UserSavedMetric.widget_key == widget_key)
|
|
407
|
+
)
|
|
408
|
+
if remaining_links is None:
|
|
409
|
+
session.delete(metric)
|
|
410
|
+
|
|
411
|
+
session.commit()
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
"status": "deleted",
|
|
415
|
+
"widget_key": widget_key,
|
|
416
|
+
"removed_dashboard_widgets": len(dashboard_widgets),
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
321
420
|
@router.post("/dashboards")
|
|
322
421
|
def create_dashboard(
|
|
323
422
|
request: DashboardCreateRequest,
|
|
@@ -378,6 +477,40 @@ def set_active_dashboard(
|
|
|
378
477
|
}
|
|
379
478
|
|
|
380
479
|
|
|
480
|
+
@router.put("/dashboards/{dashboard_id}")
|
|
481
|
+
def update_dashboard(
|
|
482
|
+
dashboard_id: str,
|
|
483
|
+
request: DashboardUpdateRequest,
|
|
484
|
+
principal: AuthenticatedSession = Depends(get_current_api_user),
|
|
485
|
+
session: Session = Depends(get_session),
|
|
486
|
+
) -> dict[str, Any]:
|
|
487
|
+
dashboard = get_dashboard_for_owner(
|
|
488
|
+
session,
|
|
489
|
+
dashboard_id,
|
|
490
|
+
dashboard_owner_user_id(principal),
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
if dashboard is None:
|
|
494
|
+
raise HTTPException(
|
|
495
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
496
|
+
detail="Dashboard not found.",
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
name = request.name.strip()
|
|
500
|
+
if not name:
|
|
501
|
+
raise HTTPException(
|
|
502
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
503
|
+
detail="Dashboard name is required.",
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
dashboard.name = name
|
|
507
|
+
dashboard.description = request.description.strip()
|
|
508
|
+
dashboard.owner_username = dashboard_owner_username(principal)
|
|
509
|
+
session.commit()
|
|
510
|
+
|
|
511
|
+
return {"item": serialize_dashboard(dashboard)}
|
|
512
|
+
|
|
513
|
+
|
|
381
514
|
@router.get("/dashboards/{dashboard_id}/widgets")
|
|
382
515
|
def list_widgets_for_dashboard(
|
|
383
516
|
dashboard_id: str,
|
|
@@ -38,6 +38,7 @@ from gaard_core.sql_validator.select_only import SelectOnlySqlValidator
|
|
|
38
38
|
from gaard_llm.openai_compatible.client import OpenAICompatibleClient
|
|
39
39
|
|
|
40
40
|
from gaard_api.admin.prompt_runtime import (
|
|
41
|
+
get_conversation_context_prompt_compiler,
|
|
41
42
|
get_intent_classification_prompt_compiler,
|
|
42
43
|
get_result_classification_prompt_compiler,
|
|
43
44
|
get_result_interpretation_prompt_compiler,
|
|
@@ -230,6 +231,7 @@ def create_conversation_context_classifier(
|
|
|
230
231
|
client=create_llm_client(llm_config),
|
|
231
232
|
model=llm_config.model,
|
|
232
233
|
extra_body=llm_config.extra_body,
|
|
234
|
+
prompt_compiler=get_conversation_context_prompt_compiler(),
|
|
233
235
|
)
|
|
234
236
|
|
|
235
237
|
raise ConfigurationError(
|
|
@@ -799,20 +801,21 @@ def resolve_request_conversation(
|
|
|
799
801
|
),
|
|
800
802
|
)
|
|
801
803
|
|
|
802
|
-
deterministic = deterministic_follow_up_classification(request, context)
|
|
803
|
-
if deterministic is not None:
|
|
804
|
-
return (
|
|
805
|
-
conversation,
|
|
806
|
-
deterministic,
|
|
807
|
-
request.model_copy(
|
|
808
|
-
update={
|
|
809
|
-
"question": deterministic.standalone_question,
|
|
810
|
-
"conversation_id": conversation.conversation_id,
|
|
811
|
-
}
|
|
812
|
-
),
|
|
813
|
-
)
|
|
814
|
-
|
|
815
804
|
context_mode = resolve_intent_classification_mode()
|
|
805
|
+
if context_mode != "llm":
|
|
806
|
+
deterministic = deterministic_follow_up_classification(request, context)
|
|
807
|
+
if deterministic is not None:
|
|
808
|
+
return (
|
|
809
|
+
conversation,
|
|
810
|
+
deterministic,
|
|
811
|
+
request.model_copy(
|
|
812
|
+
update={
|
|
813
|
+
"question": deterministic.standalone_question,
|
|
814
|
+
"conversation_id": conversation.conversation_id,
|
|
815
|
+
}
|
|
816
|
+
),
|
|
817
|
+
)
|
|
818
|
+
|
|
816
819
|
llm_config = get_llm_runtime_config_safe() if context_mode == "llm" else None
|
|
817
820
|
classification = create_conversation_context_classifier(llm_config).classify(
|
|
818
821
|
request,
|
|
@@ -831,6 +834,8 @@ def resolve_request_conversation(
|
|
|
831
834
|
request.question,
|
|
832
835
|
standalone_question,
|
|
833
836
|
context,
|
|
837
|
+
classification,
|
|
838
|
+
allow_deterministic_fallback=context_mode != "llm",
|
|
834
839
|
)
|
|
835
840
|
if guard_rewrite is None:
|
|
836
841
|
classification = classification.model_copy(
|
|
@@ -897,6 +902,7 @@ def deterministic_follow_up_classification(
|
|
|
897
902
|
"and change only the requested output fields."
|
|
898
903
|
),
|
|
899
904
|
model_response=projected_result,
|
|
905
|
+
source="deterministic",
|
|
900
906
|
)
|
|
901
907
|
|
|
902
908
|
if is_open_only_command(question):
|
|
@@ -911,6 +917,7 @@ def deterministic_follow_up_classification(
|
|
|
911
917
|
standalone_question=filter_rewrite["standalone_question"],
|
|
912
918
|
reason="Deterministic rewrite: preserve previous topic and add open-status filter.",
|
|
913
919
|
model_response=filter_rewrite,
|
|
920
|
+
source="deterministic",
|
|
914
921
|
)
|
|
915
922
|
|
|
916
923
|
previous_period = rewrite_previous_period_follow_up(previous, previous_question, question)
|
|
@@ -921,6 +928,7 @@ def deterministic_follow_up_classification(
|
|
|
921
928
|
standalone_question=previous_period["standalone_question"],
|
|
922
929
|
reason="Deterministic rewrite: replace current time period with previous period.",
|
|
923
930
|
model_response=previous_period,
|
|
931
|
+
source="deterministic",
|
|
924
932
|
)
|
|
925
933
|
|
|
926
934
|
return None
|
|
@@ -930,6 +938,9 @@ def guard_follow_up_rewrite(
|
|
|
930
938
|
original_question: str,
|
|
931
939
|
standalone_question: str,
|
|
932
940
|
context: dict[str, Any],
|
|
941
|
+
classification: ConversationContextClassification,
|
|
942
|
+
*,
|
|
943
|
+
allow_deterministic_fallback: bool,
|
|
933
944
|
) -> str | None:
|
|
934
945
|
original = normalize_question_text(original_question)
|
|
935
946
|
standalone = normalize_question_text(standalone_question)
|
|
@@ -944,6 +955,10 @@ def guard_follow_up_rewrite(
|
|
|
944
955
|
)
|
|
945
956
|
|
|
946
957
|
if rewrite_is_too_close_to_original(original, standalone):
|
|
958
|
+
if classification.model_response.get("current_question_is_standalone") is True:
|
|
959
|
+
return original_question.strip()
|
|
960
|
+
if not allow_deterministic_fallback:
|
|
961
|
+
return None
|
|
947
962
|
deterministic = deterministic_follow_up_classification(
|
|
948
963
|
QueryRequest(question=original_question),
|
|
949
964
|
context,
|
|
@@ -955,6 +970,8 @@ def guard_follow_up_rewrite(
|
|
|
955
970
|
if is_open_only_command(original) and not carries_previous_context(
|
|
956
971
|
standalone, previous_question
|
|
957
972
|
):
|
|
973
|
+
if not allow_deterministic_fallback:
|
|
974
|
+
return None
|
|
958
975
|
deterministic = deterministic_follow_up_classification(
|
|
959
976
|
QueryRequest(question=original_question),
|
|
960
977
|
context,
|
|
@@ -210,6 +210,9 @@ def build_conversation_metadata(
|
|
|
210
210
|
"standalone_question": classification.standalone_question,
|
|
211
211
|
"confidence": classification.confidence,
|
|
212
212
|
"context_reason": classification.reason,
|
|
213
|
+
"context_source": classification.source,
|
|
214
|
+
"context_model_response": classification.model_response,
|
|
215
|
+
"context_prompt": classification.prompt,
|
|
213
216
|
}
|
|
214
217
|
|
|
215
218
|
|
|
@@ -221,6 +224,7 @@ def new_topic_classification(
|
|
|
221
224
|
confidence=confidence,
|
|
222
225
|
standalone_question=question,
|
|
223
226
|
reason="Started a new conversation context.",
|
|
227
|
+
source="system",
|
|
224
228
|
)
|
|
225
229
|
|
|
226
230
|
|
|
@@ -327,6 +331,10 @@ def conversation_turn_metadata(
|
|
|
327
331
|
compact = compact_response_metadata(metadata)
|
|
328
332
|
compact["context_reason"] = context_classification.reason
|
|
329
333
|
compact["context_model_response"] = context_classification.model_response
|
|
334
|
+
if context_classification.prompt:
|
|
335
|
+
compact["context_prompt"] = context_classification.prompt
|
|
336
|
+
if context_classification.source:
|
|
337
|
+
compact["context_source"] = context_classification.source
|
|
330
338
|
compact["working_context"] = build_working_context(
|
|
331
339
|
original_question=original_question,
|
|
332
340
|
standalone_question=standalone_question,
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gaard-api
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
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.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
|
|
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
|
|
@@ -400,6 +400,30 @@ def test_dashboards_are_scoped_to_authenticated_user(admin_client: TestClient) -
|
|
|
400
400
|
assert metrics_response.json()["items"][0]["widget_key"] == "admin_saved_metric"
|
|
401
401
|
assert metrics_response.json()["items"][0]["result"]["status"] == "ok"
|
|
402
402
|
|
|
403
|
+
lightweight_metrics_response = admin_client.get(
|
|
404
|
+
"/api/v1/dashboards/metrics?include_result=false",
|
|
405
|
+
headers=admin_headers,
|
|
406
|
+
)
|
|
407
|
+
assert lightweight_metrics_response.status_code == 200
|
|
408
|
+
assert lightweight_metrics_response.json()["items"][0]["widget_key"] == "admin_saved_metric"
|
|
409
|
+
assert "result" not in lightweight_metrics_response.json()["items"][0]
|
|
410
|
+
|
|
411
|
+
rename_metric_response = admin_client.patch(
|
|
412
|
+
"/api/v1/dashboards/metrics/admin_saved_metric",
|
|
413
|
+
headers=admin_headers,
|
|
414
|
+
json={"label": "Updated prompt count"},
|
|
415
|
+
)
|
|
416
|
+
assert rename_metric_response.status_code == 200
|
|
417
|
+
assert rename_metric_response.json()["item"]["label"] == "Updated prompt count"
|
|
418
|
+
assert "result" not in rename_metric_response.json()["item"]
|
|
419
|
+
|
|
420
|
+
cross_rename_metric_response = admin_client.patch(
|
|
421
|
+
"/api/v1/dashboards/metrics/admin_saved_metric",
|
|
422
|
+
headers=analyst_headers,
|
|
423
|
+
json={"label": "Cross user rename"},
|
|
424
|
+
)
|
|
425
|
+
assert cross_rename_metric_response.status_code == 404
|
|
426
|
+
|
|
403
427
|
dashboard_widget_response = admin_client.post(
|
|
404
428
|
f"/api/v1/dashboards/{admin_dashboard['id']}/widgets",
|
|
405
429
|
headers=admin_headers,
|
|
@@ -462,6 +486,68 @@ def test_dashboards_are_scoped_to_authenticated_user(admin_client: TestClient) -
|
|
|
462
486
|
"h": 3,
|
|
463
487
|
}
|
|
464
488
|
|
|
489
|
+
cross_delete_metric_response = admin_client.delete(
|
|
490
|
+
"/api/v1/dashboards/metrics/admin_saved_metric",
|
|
491
|
+
headers=analyst_headers,
|
|
492
|
+
)
|
|
493
|
+
assert cross_delete_metric_response.status_code == 404
|
|
494
|
+
|
|
495
|
+
delete_metric_response = admin_client.delete(
|
|
496
|
+
"/api/v1/dashboards/metrics/admin_saved_metric",
|
|
497
|
+
headers=admin_headers,
|
|
498
|
+
)
|
|
499
|
+
assert delete_metric_response.status_code == 200
|
|
500
|
+
assert delete_metric_response.json() == {
|
|
501
|
+
"status": "deleted",
|
|
502
|
+
"widget_key": "admin_saved_metric",
|
|
503
|
+
"removed_dashboard_widgets": 1,
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
deleted_metric_list = admin_client.get(
|
|
507
|
+
"/api/v1/dashboards/metrics",
|
|
508
|
+
headers=admin_headers,
|
|
509
|
+
)
|
|
510
|
+
assert deleted_metric_list.status_code == 200
|
|
511
|
+
assert all(
|
|
512
|
+
item["widget_key"] != "admin_saved_metric"
|
|
513
|
+
for item in deleted_metric_list.json()["items"]
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
widgets_after_metric_delete = admin_client.get(
|
|
517
|
+
f"/api/v1/dashboards/{admin_dashboard['id']}/widgets",
|
|
518
|
+
headers=admin_headers,
|
|
519
|
+
)
|
|
520
|
+
assert widgets_after_metric_delete.status_code == 200
|
|
521
|
+
assert widgets_after_metric_delete.json()["items"] == []
|
|
522
|
+
|
|
523
|
+
with create_session() as session:
|
|
524
|
+
assert (
|
|
525
|
+
session.scalar(
|
|
526
|
+
select(UserSavedMetric).where(
|
|
527
|
+
UserSavedMetric.owner_user_id == "1",
|
|
528
|
+
UserSavedMetric.widget_key == "admin_saved_metric",
|
|
529
|
+
)
|
|
530
|
+
)
|
|
531
|
+
is None
|
|
532
|
+
)
|
|
533
|
+
assert (
|
|
534
|
+
session.scalar(
|
|
535
|
+
select(DashboardWidget).where(
|
|
536
|
+
DashboardWidget.owner_user_id == "1",
|
|
537
|
+
DashboardWidget.metric_widget_key == "admin_saved_metric",
|
|
538
|
+
)
|
|
539
|
+
)
|
|
540
|
+
is None
|
|
541
|
+
)
|
|
542
|
+
assert (
|
|
543
|
+
session.scalar(
|
|
544
|
+
select(OverviewWidget).where(
|
|
545
|
+
OverviewWidget.widget_key == "admin_saved_metric",
|
|
546
|
+
)
|
|
547
|
+
)
|
|
548
|
+
is None
|
|
549
|
+
)
|
|
550
|
+
|
|
465
551
|
admin_list = admin_client.get("/api/v1/dashboards", headers=admin_headers)
|
|
466
552
|
analyst_list = admin_client.get("/api/v1/dashboards", headers=analyst_headers)
|
|
467
553
|
|
|
@@ -483,6 +569,30 @@ def test_dashboards_are_scoped_to_authenticated_user(admin_client: TestClient) -
|
|
|
483
569
|
)
|
|
484
570
|
assert cross_delete.status_code == 404
|
|
485
571
|
|
|
572
|
+
update_dashboard_response = admin_client.put(
|
|
573
|
+
f"/api/v1/dashboards/{admin_dashboard['id']}",
|
|
574
|
+
headers=admin_headers,
|
|
575
|
+
json={
|
|
576
|
+
"name": "Operations renamed",
|
|
577
|
+
"description": "Updated daily operational dashboard.",
|
|
578
|
+
},
|
|
579
|
+
)
|
|
580
|
+
assert update_dashboard_response.status_code == 200
|
|
581
|
+
assert update_dashboard_response.json()["item"]["name"] == "Operations renamed"
|
|
582
|
+
assert update_dashboard_response.json()["item"]["description"] == (
|
|
583
|
+
"Updated daily operational dashboard."
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
cross_update_dashboard_response = admin_client.put(
|
|
587
|
+
f"/api/v1/dashboards/{analyst_dashboard['id']}",
|
|
588
|
+
headers=admin_headers,
|
|
589
|
+
json={
|
|
590
|
+
"name": "Cross user rename",
|
|
591
|
+
"description": "Should not be allowed.",
|
|
592
|
+
},
|
|
593
|
+
)
|
|
594
|
+
assert cross_update_dashboard_response.status_code == 404
|
|
595
|
+
|
|
486
596
|
admin_dashboards = admin_client.get(
|
|
487
597
|
"/api/v1/admin/dashboards",
|
|
488
598
|
headers=admin_headers,
|
|
@@ -706,7 +816,9 @@ def test_admin_first_login_requires_password_change(admin_client: TestClient) ->
|
|
|
706
816
|
)
|
|
707
817
|
|
|
708
818
|
assert prompts_response.status_code == 200
|
|
709
|
-
|
|
819
|
+
prompt_keys = {item["prompt_key"] for item in prompts_response.json()["items"]}
|
|
820
|
+
assert len(prompt_keys) >= 2
|
|
821
|
+
assert "conversation_context_classification" in prompt_keys
|
|
710
822
|
|
|
711
823
|
|
|
712
824
|
def test_prompt_update_creates_admin_audit_event(admin_client: TestClient) -> None:
|
|
@@ -1432,6 +1544,57 @@ def test_overview_widget_can_be_saved_from_query_and_deleted(
|
|
|
1432
1544
|
)
|
|
1433
1545
|
|
|
1434
1546
|
|
|
1547
|
+
def test_overview_widget_title_suggestion_uses_llm(
|
|
1548
|
+
admin_client: TestClient,
|
|
1549
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1550
|
+
) -> None:
|
|
1551
|
+
headers = auth_headers(admin_client)
|
|
1552
|
+
|
|
1553
|
+
with create_session() as session:
|
|
1554
|
+
set_setting(session, "gaard_llm_api_key", "test-key", "test")
|
|
1555
|
+
set_setting(session, "gaard_llm_model", "title-model", "test")
|
|
1556
|
+
session.commit()
|
|
1557
|
+
|
|
1558
|
+
class FakeOpenAICompatibleClient:
|
|
1559
|
+
init_kwargs: dict | None = None
|
|
1560
|
+
requests = []
|
|
1561
|
+
|
|
1562
|
+
def __init__(self, **kwargs) -> None:
|
|
1563
|
+
self.__class__.init_kwargs = kwargs
|
|
1564
|
+
|
|
1565
|
+
def create_chat_completion(self, request):
|
|
1566
|
+
self.__class__.requests.append(request)
|
|
1567
|
+
return ChatCompletionResponse(
|
|
1568
|
+
content="```text\nDoctors by Specialty.\n```",
|
|
1569
|
+
model=request.model,
|
|
1570
|
+
)
|
|
1571
|
+
|
|
1572
|
+
monkeypatch.setattr(
|
|
1573
|
+
"gaard_api.api.v1.admin.OpenAICompatibleClient",
|
|
1574
|
+
FakeOpenAICompatibleClient,
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
response = admin_client.post(
|
|
1578
|
+
"/api/v1/admin/overview/widgets/title-suggestion",
|
|
1579
|
+
headers=headers,
|
|
1580
|
+
json={
|
|
1581
|
+
"question": "How many doctors are there by specialty?",
|
|
1582
|
+
"sql": "SELECT specialization, COUNT(*) FROM doctors GROUP BY specialization",
|
|
1583
|
+
},
|
|
1584
|
+
)
|
|
1585
|
+
|
|
1586
|
+
assert response.status_code == 200
|
|
1587
|
+
assert response.json() == {"title": "Doctors by Specialty"}
|
|
1588
|
+
assert FakeOpenAICompatibleClient.init_kwargs is not None
|
|
1589
|
+
assert FakeOpenAICompatibleClient.init_kwargs["api_key"] == "test-key"
|
|
1590
|
+
request = FakeOpenAICompatibleClient.requests[0]
|
|
1591
|
+
assert request.model == "title-model"
|
|
1592
|
+
assert request.temperature == 0.0
|
|
1593
|
+
assert request.messages[0].role == "system"
|
|
1594
|
+
assert "same language as the user question" in request.messages[0].content
|
|
1595
|
+
assert "How many doctors" in request.messages[1].content
|
|
1596
|
+
|
|
1597
|
+
|
|
1435
1598
|
def test_overview_widget_from_query_strips_datasource_qualifier(
|
|
1436
1599
|
admin_client: TestClient,
|
|
1437
1600
|
) -> None:
|
|
@@ -441,6 +441,83 @@ def test_query_follow_up_guard_rejects_unrewritten_classifier_output(
|
|
|
441
441
|
assert payload["metadata"]["conversation"]["context_decision"] == "ambiguous"
|
|
442
442
|
|
|
443
443
|
|
|
444
|
+
def test_query_accepts_llm_follow_up_when_current_question_is_standalone(
|
|
445
|
+
conversation_client: TestClient,
|
|
446
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
447
|
+
) -> None:
|
|
448
|
+
headers = auth_headers(conversation_client)
|
|
449
|
+
first = conversation_client.post(
|
|
450
|
+
"/api/v1/query",
|
|
451
|
+
headers=headers,
|
|
452
|
+
json={
|
|
453
|
+
"question": "ilu pacjentów było przyjętych tydzień temu",
|
|
454
|
+
"user_id": "alice",
|
|
455
|
+
},
|
|
456
|
+
)
|
|
457
|
+
assert first.status_code == 200
|
|
458
|
+
conversation_id = first.json()["metadata"]["conversation"]["id"]
|
|
459
|
+
|
|
460
|
+
second = conversation_client.post(
|
|
461
|
+
"/api/v1/query",
|
|
462
|
+
headers=headers,
|
|
463
|
+
json={
|
|
464
|
+
"question": "a dwa tygodnie temu?",
|
|
465
|
+
"user_id": "alice",
|
|
466
|
+
"conversation_id": conversation_id,
|
|
467
|
+
},
|
|
468
|
+
)
|
|
469
|
+
assert second.status_code == 200
|
|
470
|
+
|
|
471
|
+
class StandaloneContinuationClassifier:
|
|
472
|
+
def classify(self, request, context):
|
|
473
|
+
return ConversationContextClassification(
|
|
474
|
+
decision=ConversationContextDecision.FOLLOW_UP,
|
|
475
|
+
confidence=0.92,
|
|
476
|
+
standalone_question=request.question,
|
|
477
|
+
reason="LLM classified this as a logical continuation with a standalone question.",
|
|
478
|
+
model_response={
|
|
479
|
+
"is_continuation": True,
|
|
480
|
+
"current_question_is_standalone": True,
|
|
481
|
+
},
|
|
482
|
+
prompt={
|
|
483
|
+
"system_prompt": "Decide whether turn t is a logical continuation.",
|
|
484
|
+
"user_prompt": "turn_t_minus_1 + turn_t",
|
|
485
|
+
"metadata": {"decision_task": "logical_continuation_yes_no"},
|
|
486
|
+
},
|
|
487
|
+
source="llm",
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
monkeypatch.setattr(
|
|
491
|
+
query_module,
|
|
492
|
+
"create_conversation_context_classifier",
|
|
493
|
+
lambda _llm_config=None: StandaloneContinuationClassifier(),
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
third = conversation_client.post(
|
|
497
|
+
"/api/v1/query",
|
|
498
|
+
headers=headers,
|
|
499
|
+
json={
|
|
500
|
+
"question": "ilu pacjentów przyjęto w tym tygodniu",
|
|
501
|
+
"user_id": "alice",
|
|
502
|
+
"conversation_id": conversation_id,
|
|
503
|
+
},
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
assert third.status_code == 200
|
|
507
|
+
payload = third.json()
|
|
508
|
+
conversation = payload["metadata"]["conversation"]
|
|
509
|
+
assert payload["sql"]
|
|
510
|
+
assert payload["metadata"].get("blocked") is not True
|
|
511
|
+
assert "Potrzebuję doprecyzowania" not in payload["answer"]
|
|
512
|
+
assert conversation["context_decision"] == "follow_up"
|
|
513
|
+
assert conversation["standalone_question"] == "ilu pacjentów przyjęto w tym tygodniu"
|
|
514
|
+
assert conversation["context_source"] == "llm"
|
|
515
|
+
assert conversation["context_model_response"]["is_continuation"] is True
|
|
516
|
+
assert conversation["context_prompt"]["metadata"]["decision_task"] == (
|
|
517
|
+
"logical_continuation_yes_no"
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
444
521
|
def test_query_turn_metadata_keeps_context_reason_and_working_context(
|
|
445
522
|
conversation_client: TestClient,
|
|
446
523
|
) -> None:
|
|
@@ -4,6 +4,7 @@ from gaard_core.query_pipeline.models import QueryRequest, QueryResult
|
|
|
4
4
|
|
|
5
5
|
from gaard_api.admin.models import PromptTemplate
|
|
6
6
|
from gaard_api.admin.prompt_runtime import (
|
|
7
|
+
MetadataConversationContextPromptCompiler,
|
|
7
8
|
MetadataIntentClassificationPromptCompiler,
|
|
8
9
|
MetadataResultClassificationPromptCompiler,
|
|
9
10
|
MetadataResultInterpretationPromptCompiler,
|
|
@@ -59,6 +60,44 @@ def test_metadata_intent_classification_prompt_compiler_serializes_question() ->
|
|
|
59
60
|
assert compiled.metadata["prompt_key"] == "intent_classification"
|
|
60
61
|
|
|
61
62
|
|
|
63
|
+
def test_metadata_conversation_context_prompt_compiler_serializes_recent_turns() -> None:
|
|
64
|
+
compiler = MetadataConversationContextPromptCompiler(
|
|
65
|
+
prompt_template=PromptTemplate(
|
|
66
|
+
prompt_key="conversation_context_classification",
|
|
67
|
+
name="Conversation context classification",
|
|
68
|
+
system_prompt="system",
|
|
69
|
+
user_prompt_template="{payload}\n{question}",
|
|
70
|
+
version=3,
|
|
71
|
+
active=True,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
compiled = compiler.compile(
|
|
76
|
+
request=QueryRequest(question="ilu pacjentów przyjęto w tym tygodniu"),
|
|
77
|
+
conversation_context={
|
|
78
|
+
"turns": [
|
|
79
|
+
{
|
|
80
|
+
"question": "ilu pacjentów było przyjętych tydzień temu",
|
|
81
|
+
"answer": "12",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"question": "a dwa tygodnie temu?",
|
|
85
|
+
"standalone_question": "ilu pacjentów było przyjętych dwa tygodnie temu",
|
|
86
|
+
"answer": "9",
|
|
87
|
+
},
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
assert '"turn_t_minus_2"' in compiled.user_prompt
|
|
93
|
+
assert '"turn_t_minus_1"' in compiled.user_prompt
|
|
94
|
+
assert '"turn_t"' in compiled.user_prompt
|
|
95
|
+
assert "ilu pacjentów przyjęto w tym tygodniu" in compiled.user_prompt
|
|
96
|
+
assert compiled.metadata["prompt_key"] == "conversation_context_classification"
|
|
97
|
+
assert compiled.metadata["prompt_version"] == 3
|
|
98
|
+
assert compiled.metadata["decision_task"] == "logical_continuation_yes_no"
|
|
99
|
+
|
|
100
|
+
|
|
62
101
|
def test_metadata_result_classification_prompt_compiler_serializes_answer() -> None:
|
|
63
102
|
compiler = MetadataResultClassificationPromptCompiler(
|
|
64
103
|
prompt_template=PromptTemplate(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|