gaard-api 0.2.6__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.
Files changed (71) hide show
  1. {gaard_api-0.2.6/src/gaard_api.egg-info → gaard_api-0.2.7}/PKG-INFO +5 -5
  2. {gaard_api-0.2.6 → gaard_api-0.2.7}/pyproject.toml +5 -5
  3. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/admin/defaults.py +79 -0
  4. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/admin/prompt_runtime.py +74 -1
  5. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/api/v1/dashboards.py +57 -0
  6. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/api/v1/query.py +30 -13
  7. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/conversations.py +8 -0
  8. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/main.py +1 -1
  9. {gaard_api-0.2.6 → gaard_api-0.2.7/src/gaard_api.egg-info}/PKG-INFO +5 -5
  10. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api.egg-info/requires.txt +4 -4
  11. {gaard_api-0.2.6 → gaard_api-0.2.7}/tests/test_admin_api.py +65 -1
  12. {gaard_api-0.2.6 → gaard_api-0.2.7}/tests/test_conversation_api.py +77 -0
  13. {gaard_api-0.2.6 → gaard_api-0.2.7}/tests/test_prompt_runtime.py +39 -0
  14. {gaard_api-0.2.6 → gaard_api-0.2.7}/MANIFEST.in +0 -0
  15. {gaard_api-0.2.6 → gaard_api-0.2.7}/README.md +0 -0
  16. {gaard_api-0.2.6 → gaard_api-0.2.7}/setup.cfg +0 -0
  17. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/__init__.py +0 -0
  18. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/admin/__init__.py +0 -0
  19. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/admin/database.py +0 -0
  20. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/admin/models.py +0 -0
  21. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/admin/security.py +0 -0
  22. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/admin/services.py +0 -0
  23. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
  24. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/main.js +0 -0
  25. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/styles.css +0 -0
  26. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
  27. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
  28. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/admin-web/index.html +0 -0
  29. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/admin-web/package.json +0 -0
  30. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/api/__init__.py +0 -0
  31. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/api/v1/__init__.py +0 -0
  32. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/api/v1/admin.py +0 -0
  33. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/api/v1/analysis.py +0 -0
  34. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/api/v1/prompts.py +0 -0
  35. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/api/v1/schema.py +0 -0
  36. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/api_registry.py +0 -0
  37. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/auth_dependencies.py +0 -0
  38. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/auth_hooks.py +0 -0
  39. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/cli.py +0 -0
  40. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/cli_commands.py +0 -0
  41. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/core/__init__.py +0 -0
  42. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/core/error_handlers.py +0 -0
  43. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/core/schema_cache.py +0 -0
  44. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/core/settings.py +0 -0
  45. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/example_data/__init__.py +0 -0
  46. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  47. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  48. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  49. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/example_database.py +0 -0
  50. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/extension_services.py +0 -0
  51. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/extensions.py +0 -0
  52. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/license.py +0 -0
  53. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/package_updates.py +0 -0
  54. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/py.typed +0 -0
  55. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/query_hooks.py +0 -0
  56. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/server_cli.py +0 -0
  57. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/siem.py +0 -0
  58. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api/tls_http.py +0 -0
  59. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api.egg-info/SOURCES.txt +0 -0
  60. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  61. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api.egg-info/entry_points.txt +0 -0
  62. {gaard_api-0.2.6 → gaard_api-0.2.7}/src/gaard_api.egg-info/top_level.txt +0 -0
  63. {gaard_api-0.2.6 → gaard_api-0.2.7}/tests/test_analysis_api.py +0 -0
  64. {gaard_api-0.2.6 → gaard_api-0.2.7}/tests/test_api_registry.py +0 -0
  65. {gaard_api-0.2.6 → gaard_api-0.2.7}/tests/test_error_handlers.py +0 -0
  66. {gaard_api-0.2.6 → gaard_api-0.2.7}/tests/test_license_api.py +0 -0
  67. {gaard_api-0.2.6 → gaard_api-0.2.7}/tests/test_license_service.py +0 -0
  68. {gaard_api-0.2.6 → gaard_api-0.2.7}/tests/test_query_error_messages.py +0 -0
  69. {gaard_api-0.2.6 → gaard_api-0.2.7}/tests/test_server_cli.py +0 -0
  70. {gaard_api-0.2.6 → gaard_api-0.2.7}/tests/test_settings.py +0 -0
  71. {gaard_api-0.2.6 → 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.6
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.6
8
- Requires-Dist: gaard-connectors==0.2.6
9
- Requires-Dist: gaard-llm==0.2.6
10
- Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.6
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.6"
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.6",
14
- "gaard-connectors==0.2.6",
15
- "gaard-llm==0.2.6",
16
- "gaard-plugin-api>=0.2.6,<0.3.0",
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 QueryRequest, QueryResult
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
  ):
@@ -232,6 +232,19 @@ def get_saved_metric_for_owner(
232
232
  )
233
233
 
234
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
+
235
248
  def list_saved_metrics_for_owner(
236
249
  session: Session,
237
250
  owner_user_id: str,
@@ -360,6 +373,50 @@ def update_saved_dashboard_metric(
360
373
  return {"item": serialize_saved_metric(session, metric, include_result=False)}
361
374
 
362
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
+
363
420
  @router.post("/dashboards")
364
421
  def create_dashboard(
365
422
  request: DashboardCreateRequest,
@@ -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,
@@ -29,7 +29,7 @@ async def lifespan(app: FastAPI):
29
29
 
30
30
  app = FastAPI(
31
31
  title="GAARD API",
32
- version="0.2.6",
32
+ version="0.2.7",
33
33
  description="Self-hosted AI SQL Gateway for governed natural-language access to relational data.",
34
34
  lifespan=lifespan,
35
35
  )
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.2.6
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.6
8
- Requires-Dist: gaard-connectors==0.2.6
9
- Requires-Dist: gaard-llm==0.2.6
10
- Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.6
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
@@ -1,7 +1,7 @@
1
- gaard-core==0.2.6
2
- gaard-connectors==0.2.6
3
- gaard-llm==0.2.6
4
- gaard-plugin-api<0.3.0,>=0.2.6
1
+ gaard-core==0.2.7
2
+ gaard-connectors==0.2.7
3
+ gaard-llm==0.2.7
4
+ gaard-plugin-api<0.3.0,>=0.2.7
5
5
  fastapi>=0.111.0
6
6
  uvicorn[standard]>=0.30.0
7
7
  pydantic>=2.7.0
@@ -486,6 +486,68 @@ def test_dashboards_are_scoped_to_authenticated_user(admin_client: TestClient) -
486
486
  "h": 3,
487
487
  }
488
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
+
489
551
  admin_list = admin_client.get("/api/v1/dashboards", headers=admin_headers)
490
552
  analyst_list = admin_client.get("/api/v1/dashboards", headers=analyst_headers)
491
553
 
@@ -754,7 +816,9 @@ def test_admin_first_login_requires_password_change(admin_client: TestClient) ->
754
816
  )
755
817
 
756
818
  assert prompts_response.status_code == 200
757
- assert len(prompts_response.json()["items"]) >= 2
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
758
822
 
759
823
 
760
824
  def test_prompt_update_creates_admin_audit_event(admin_client: TestClient) -> 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