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.
Files changed (71) hide show
  1. {gaard_api-0.2.5/src/gaard_api.egg-info → gaard_api-0.2.7}/PKG-INFO +5 -5
  2. {gaard_api-0.2.5 → gaard_api-0.2.7}/pyproject.toml +5 -5
  3. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin/defaults.py +79 -0
  4. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin/prompt_runtime.py +74 -1
  5. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/v1/admin.py +98 -0
  6. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/v1/dashboards.py +134 -1
  7. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/v1/query.py +30 -13
  8. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/conversations.py +8 -0
  9. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/main.py +1 -1
  10. {gaard_api-0.2.5 → gaard_api-0.2.7/src/gaard_api.egg-info}/PKG-INFO +5 -5
  11. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api.egg-info/requires.txt +4 -4
  12. {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_admin_api.py +164 -1
  13. {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_conversation_api.py +77 -0
  14. {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_prompt_runtime.py +39 -0
  15. {gaard_api-0.2.5 → gaard_api-0.2.7}/MANIFEST.in +0 -0
  16. {gaard_api-0.2.5 → gaard_api-0.2.7}/README.md +0 -0
  17. {gaard_api-0.2.5 → gaard_api-0.2.7}/setup.cfg +0 -0
  18. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/__init__.py +0 -0
  19. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin/__init__.py +0 -0
  20. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin/database.py +0 -0
  21. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin/models.py +0 -0
  22. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin/security.py +0 -0
  23. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin/services.py +0 -0
  24. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
  25. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/main.js +0 -0
  26. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/styles.css +0 -0
  27. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
  28. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
  29. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/index.html +0 -0
  30. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/admin-web/package.json +0 -0
  31. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/__init__.py +0 -0
  32. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/v1/__init__.py +0 -0
  33. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/v1/analysis.py +0 -0
  34. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/v1/prompts.py +0 -0
  35. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api/v1/schema.py +0 -0
  36. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/api_registry.py +0 -0
  37. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/auth_dependencies.py +0 -0
  38. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/auth_hooks.py +0 -0
  39. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/cli.py +0 -0
  40. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/cli_commands.py +0 -0
  41. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/core/__init__.py +0 -0
  42. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/core/error_handlers.py +0 -0
  43. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/core/schema_cache.py +0 -0
  44. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/core/settings.py +0 -0
  45. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/example_data/__init__.py +0 -0
  46. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  47. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  48. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  49. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/example_database.py +0 -0
  50. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/extension_services.py +0 -0
  51. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/extensions.py +0 -0
  52. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/license.py +0 -0
  53. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/package_updates.py +0 -0
  54. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/py.typed +0 -0
  55. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/query_hooks.py +0 -0
  56. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/server_cli.py +0 -0
  57. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/siem.py +0 -0
  58. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api/tls_http.py +0 -0
  59. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api.egg-info/SOURCES.txt +0 -0
  60. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  61. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api.egg-info/entry_points.txt +0 -0
  62. {gaard_api-0.2.5 → gaard_api-0.2.7}/src/gaard_api.egg-info/top_level.txt +0 -0
  63. {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_analysis_api.py +0 -0
  64. {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_api_registry.py +0 -0
  65. {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_error_handlers.py +0 -0
  66. {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_license_api.py +0 -0
  67. {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_license_service.py +0 -0
  68. {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_query_error_messages.py +0 -0
  69. {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_server_cli.py +0 -0
  70. {gaard_api-0.2.5 → gaard_api-0.2.7}/tests/test_settings.py +0 -0
  71. {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.5
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.4
8
- Requires-Dist: gaard-connectors==0.2.4
9
- Requires-Dist: gaard-llm==0.2.4
10
- Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.4
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.5"
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.4",
14
- "gaard-connectors==0.2.4",
15
- "gaard-llm==0.2.4",
16
- "gaard-plugin-api>=0.2.4,<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
  ):
@@ -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": [serialize_saved_metric(session, metric) for metric in metrics],
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,
@@ -29,7 +29,7 @@ async def lifespan(app: FastAPI):
29
29
 
30
30
  app = FastAPI(
31
31
  title="GAARD API",
32
- version="0.2.5",
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.5
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.4
8
- Requires-Dist: gaard-connectors==0.2.4
9
- Requires-Dist: gaard-llm==0.2.4
10
- Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.4
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.4
2
- gaard-connectors==0.2.4
3
- gaard-llm==0.2.4
4
- gaard-plugin-api<0.3.0,>=0.2.4
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
@@ -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
- 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
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