gaard-core 0.2.4__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 (63) hide show
  1. {gaard_core-0.2.4 → gaard_core-0.2.7}/PKG-INFO +2 -2
  2. {gaard_core-0.2.4 → gaard_core-0.2.7}/pyproject.toml +2 -2
  3. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/conversation_context/llm_classifier.py +65 -3
  4. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/conversation_context/mock_classifier.py +4 -0
  5. gaard_core-0.2.7/src/gaard_core/prompt_compiler/conversation_context_prompt.py +121 -0
  6. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/query_pipeline/models.py +2 -0
  7. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core.egg-info/PKG-INFO +2 -2
  8. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core.egg-info/requires.txt +1 -1
  9. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_conversation_context_classifier.py +55 -0
  10. gaard_core-0.2.4/src/gaard_core/prompt_compiler/conversation_context_prompt.py +0 -68
  11. {gaard_core-0.2.4 → gaard_core-0.2.7}/README.md +0 -0
  12. {gaard_core-0.2.4 → gaard_core-0.2.7}/setup.cfg +0 -0
  13. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/__init__.py +0 -0
  14. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/conversation_context/__init__.py +0 -0
  15. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/errors.py +0 -0
  16. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/execution/__init__.py +0 -0
  17. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/execution/mock_executor.py +0 -0
  18. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/json_utils.py +0 -0
  19. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/llm_output.py +0 -0
  20. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/prompt_compiler/__init__.py +0 -0
  21. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/prompt_compiler/intent_classification_prompt.py +0 -0
  22. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/prompt_compiler/models.py +0 -0
  23. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/prompt_compiler/result_classification_prompt.py +0 -0
  24. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/prompt_compiler/result_interpretation_prompt.py +0 -0
  25. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/prompt_compiler/schema_formatter.py +0 -0
  26. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/prompt_compiler/sql_generation_prompt.py +0 -0
  27. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/py.typed +0 -0
  28. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/query_intent/__init__.py +0 -0
  29. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/query_intent/llm_classifier.py +0 -0
  30. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/query_intent/mock_classifier.py +0 -0
  31. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/query_pipeline/__init__.py +0 -0
  32. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/query_pipeline/llm_sql_generator.py +0 -0
  33. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/query_pipeline/mock_sql_generator.py +0 -0
  34. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/query_pipeline/pipeline.py +0 -0
  35. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/result_classifier/__init__.py +0 -0
  36. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/result_classifier/llm_classifier.py +0 -0
  37. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/result_classifier/mock_classifier.py +0 -0
  38. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/result_interpreter/__init__.py +0 -0
  39. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/result_interpreter/llm_interpreter.py +0 -0
  40. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/result_interpreter/mock_interpreter.py +0 -0
  41. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/schema/__init__.py +0 -0
  42. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/schema/cache.py +0 -0
  43. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/schema/context.py +0 -0
  44. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/schema/models.py +0 -0
  45. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/sql_validator/__init__.py +0 -0
  46. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core/sql_validator/select_only.py +0 -0
  47. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core.egg-info/SOURCES.txt +0 -0
  48. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core.egg-info/dependency_links.txt +0 -0
  49. {gaard_core-0.2.4 → gaard_core-0.2.7}/src/gaard_core.egg-info/top_level.txt +0 -0
  50. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_json_utils.py +0 -0
  51. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_llm_output.py +0 -0
  52. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_llm_query_intent_classifier.py +0 -0
  53. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_llm_result_classifier.py +0 -0
  54. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_llm_result_interpreter.py +0 -0
  55. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_llm_sql_generator.py +0 -0
  56. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_query_pipeline.py +0 -0
  57. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_result_classification_prompt_compiler.py +0 -0
  58. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_result_interpretation_prompt_compiler.py +0 -0
  59. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_schema_context_cache.py +0 -0
  60. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_schema_context_service.py +0 -0
  61. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_schema_prompt_formatter.py +0 -0
  62. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_sql_generation_prompt_compiler.py +0 -0
  63. {gaard_core-0.2.4 → gaard_core-0.2.7}/tests/test_sql_validator.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-core
3
- Version: 0.2.4
3
+ Version: 0.2.7
4
4
  Summary: Core GAARD query pipeline, prompt compiler, policies and SQL validation
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.4
7
+ Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.7
8
8
  Requires-Dist: pydantic>=2.7.0
9
9
  Requires-Dist: sqlglot>=25.0.0
10
10
  Provides-Extra: dev
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gaard-core"
7
- version = "0.2.4"
7
+ version = "0.2.7"
8
8
  description = "Core GAARD query pipeline, prompt compiler, policies and SQL validation"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
  dependencies = [
12
- "gaard-plugin-api>=0.2.4,<0.3.0",
12
+ "gaard-plugin-api>=0.2.7,<0.3.0",
13
13
  "pydantic>=2.7.0",
14
14
  "sqlglot>=25.0.0",
15
15
  ]
@@ -58,7 +58,32 @@ class LlmConversationContextClassifier:
58
58
  )
59
59
  )
60
60
 
61
- return parse_conversation_context_classification(response.content)
61
+ classification = parse_conversation_context_classification(response.content)
62
+ if (
63
+ classification.decision == ConversationContextDecision.NEW_TOPIC
64
+ and not classification.standalone_question
65
+ ):
66
+ classification = classification.model_copy(
67
+ update={"standalone_question": request.question}
68
+ )
69
+ if (
70
+ classification.decision == ConversationContextDecision.FOLLOW_UP
71
+ and not classification.standalone_question
72
+ and classification.model_response.get("current_question_is_standalone") is True
73
+ ):
74
+ classification = classification.model_copy(
75
+ update={"standalone_question": request.question}
76
+ )
77
+ return classification.model_copy(
78
+ update={
79
+ "prompt": {
80
+ "system_prompt": compiled_prompt.system_prompt,
81
+ "user_prompt": compiled_prompt.user_prompt,
82
+ "metadata": compiled_prompt.metadata,
83
+ },
84
+ "source": "llm",
85
+ }
86
+ )
62
87
 
63
88
 
64
89
  def parse_conversation_context_classification(
@@ -80,8 +105,12 @@ def parse_conversation_context_classification(
80
105
  if not isinstance(payload, dict):
81
106
  return ConversationContextClassification()
82
107
 
108
+ decision = parse_conversation_context_decision(
109
+ payload.get("decision"),
110
+ continuation_value=payload.get("is_continuation"),
111
+ )
83
112
  return ConversationContextClassification(
84
- decision=parse_conversation_context_decision(payload.get("decision")),
113
+ decision=decision,
85
114
  confidence=parse_confidence(payload.get("confidence")),
86
115
  standalone_question=str(payload.get("standalone_question") or "").strip(),
87
116
  reason=str(payload.get("reason") or ""),
@@ -89,15 +118,30 @@ def parse_conversation_context_classification(
89
118
  )
90
119
 
91
120
 
92
- def parse_conversation_context_decision(value: object) -> ConversationContextDecision:
121
+ def parse_conversation_context_decision(
122
+ value: object,
123
+ *,
124
+ continuation_value: object | None = None,
125
+ ) -> ConversationContextDecision:
126
+ if value is None:
127
+ continuation_decision = parse_continuation_decision(continuation_value)
128
+ if continuation_decision is not None:
129
+ return continuation_decision
130
+
93
131
  if not isinstance(value, str):
94
132
  return ConversationContextDecision.AMBIGUOUS
95
133
 
96
134
  normalized = value.strip().lower().replace(" ", "_").replace("-", "_")
97
135
  aliases = {
136
+ "no": ConversationContextDecision.NEW_TOPIC,
137
+ "nie": ConversationContextDecision.NEW_TOPIC,
138
+ "false": ConversationContextDecision.NEW_TOPIC,
98
139
  "new": ConversationContextDecision.NEW_TOPIC,
99
140
  "new_question": ConversationContextDecision.NEW_TOPIC,
100
141
  "newtopic": ConversationContextDecision.NEW_TOPIC,
142
+ "yes": ConversationContextDecision.FOLLOW_UP,
143
+ "tak": ConversationContextDecision.FOLLOW_UP,
144
+ "true": ConversationContextDecision.FOLLOW_UP,
101
145
  "continue": ConversationContextDecision.FOLLOW_UP,
102
146
  "continuation": ConversationContextDecision.FOLLOW_UP,
103
147
  "followup": ConversationContextDecision.FOLLOW_UP,
@@ -115,6 +159,24 @@ def parse_conversation_context_decision(value: object) -> ConversationContextDec
115
159
  return ConversationContextDecision.AMBIGUOUS
116
160
 
117
161
 
162
+ def parse_continuation_decision(value: object) -> ConversationContextDecision | None:
163
+ if isinstance(value, bool):
164
+ return (
165
+ ConversationContextDecision.FOLLOW_UP
166
+ if value
167
+ else ConversationContextDecision.NEW_TOPIC
168
+ )
169
+ if not isinstance(value, str):
170
+ return None
171
+
172
+ normalized = value.strip().lower()
173
+ if normalized in {"yes", "y", "true", "tak", "t"}:
174
+ return ConversationContextDecision.FOLLOW_UP
175
+ if normalized in {"no", "n", "false", "nie"}:
176
+ return ConversationContextDecision.NEW_TOPIC
177
+ return None
178
+
179
+
118
180
  def parse_confidence(value: object) -> float:
119
181
  try:
120
182
  confidence = float(cast(Any, value))
@@ -73,6 +73,7 @@ class MockConversationContextClassifier:
73
73
  confidence=1.0,
74
74
  standalone_question=request.question,
75
75
  reason="No previous turns are available.",
76
+ source="mock",
76
77
  )
77
78
 
78
79
  question = request.question.strip()
@@ -85,6 +86,7 @@ class MockConversationContextClassifier:
85
86
  confidence=0.8,
86
87
  standalone_question=standalone,
87
88
  reason="The question appears to continue the previous data question.",
89
+ source="mock",
88
90
  )
89
91
 
90
92
  if normalized in AMBIGUOUS_TERMS or re.fullmatch(
@@ -95,6 +97,7 @@ class MockConversationContextClassifier:
95
97
  confidence=0.45,
96
98
  standalone_question="",
97
99
  reason="The question is too short to safely resolve from context.",
100
+ source="mock",
98
101
  )
99
102
 
100
103
  return ConversationContextClassification(
@@ -102,6 +105,7 @@ class MockConversationContextClassifier:
102
105
  confidence=0.9,
103
106
  standalone_question=question,
104
107
  reason="The question is self-contained.",
108
+ source="mock",
105
109
  )
106
110
 
107
111
 
@@ -0,0 +1,121 @@
1
+ from typing import Any
2
+
3
+ from gaard_core.json_utils import json_dumps
4
+ from gaard_core.prompt_compiler.models import CompiledPrompt
5
+ from gaard_core.query_pipeline.models import ConversationContextDecision, QueryRequest
6
+
7
+
8
+ class ConversationContextPromptCompiler:
9
+ def compile(
10
+ self,
11
+ request: QueryRequest,
12
+ conversation_context: dict[str, Any],
13
+ ) -> CompiledPrompt:
14
+ recent_turns = self._recent_turns(conversation_context)
15
+ payload = {
16
+ "turn_t_minus_2": recent_turns[0] if len(recent_turns) == 2 else {},
17
+ "turn_t_minus_1": recent_turns[-1] if recent_turns else {},
18
+ "turn_t": {
19
+ "question": request.question,
20
+ "datasource_id": request.datasource_id,
21
+ "datasource_ids": request.datasource_ids,
22
+ },
23
+ }
24
+
25
+ return CompiledPrompt(
26
+ system_prompt=self._build_system_prompt(),
27
+ user_prompt=self._build_user_prompt(payload),
28
+ metadata={
29
+ "allowed_decisions": [item.value for item in ConversationContextDecision],
30
+ "decision_task": "logical_continuation_yes_no",
31
+ },
32
+ )
33
+
34
+ def _recent_turns(self, conversation_context: dict[str, Any]) -> list[dict[str, Any]]:
35
+ turns = [
36
+ turn for turn in conversation_context.get("turns", []) if isinstance(turn, dict)
37
+ ][-2:]
38
+ labels = ["t-2", "t-1"] if len(turns) == 2 else ["t-1"]
39
+ return [
40
+ {
41
+ "label": label,
42
+ "question": str(turn.get("question") or ""),
43
+ "standalone_question": str(turn.get("standalone_question") or ""),
44
+ "answer": str(turn.get("answer") or ""),
45
+ "sql": str(turn.get("sql") or ""),
46
+ "context_decision": str(turn.get("context_decision") or ""),
47
+ "context_reason": str(turn.get("context_reason") or ""),
48
+ }
49
+ for label, turn in zip(labels, turns, strict=False)
50
+ ]
51
+
52
+ def _build_system_prompt(self) -> str:
53
+ return """You are GAARD Conversation Context Classification.
54
+
55
+ Your task is to decide whether the current user data question (turn t) is a
56
+ logical continuation of the recent conversation turns (t-2 and t-1).
57
+
58
+ Use the previous question-answer pairs as conversation evidence. Do not classify
59
+ by rigid prefix or keyword rules. Decide semantically whether turn t depends on,
60
+ compares with, narrows, broadens, or otherwise continues the immediately
61
+ preceding analytical thread.
62
+
63
+ Allowed decisions:
64
+ - new_topic: answer "no" to logical continuation. The question starts a new analytical thread.
65
+ - follow_up: answer "yes" to logical continuation. The question continues the
66
+ recent thread and can be executed safely.
67
+ - ambiguous: the question appears to continue the thread, but required entities,
68
+ filters, date ranges, or datasource scope cannot be inferred safely.
69
+
70
+ Decision rules:
71
+ 1. First answer the yes/no question: is turn t a logical continuation of t-1/t-2?
72
+ 2. A question can be a logical continuation even when it is already
73
+ self-contained. In that case use follow_up, set
74
+ current_question_is_standalone to true, and set standalone_question to the
75
+ current question.
76
+ 3. If the answer is no, use new_topic and set standalone_question to the current question.
77
+ 4. If the answer is yes and the current question is elliptical, rewrite it as a
78
+ standalone data question using t-1/t-2.
79
+ 5. Use ambiguous only when the answer is yes but the continuation cannot be
80
+ rewritten or executed safely without asking the user.
81
+ 6. Treat detail/projection requests about the previous result as follow_up when
82
+ the previous turn defines the result set. If the previous question counted,
83
+ grouped, or filtered records and the user now asks for descriptions, names,
84
+ statuses, fields, details, or values for those same records, rewrite by
85
+ preserving the previous filters/date range/datasource and changing only the
86
+ returned fields.
87
+ 7. Do not mark a projection/detail follow-up ambiguous merely because the
88
+ previous answer did not expose row ids. The previous standalone question and
89
+ SQL are enough context for the next SQL generation step.
90
+ 8. Never include rows or sensitive data in the standalone question.
91
+
92
+ Output rules:
93
+ - Return only a JSON object.
94
+ - Do not include markdown.
95
+ - Do not include reasoning outside the JSON.
96
+ - Do not include <think> blocks.
97
+ - Use exactly this JSON shape:
98
+ {
99
+ "is_continuation": false,
100
+ "decision": "new_topic",
101
+ "current_question_is_standalone": true,
102
+ "confidence": 0.0,
103
+ "standalone_question": "rewritten or current question",
104
+ "reason": "short reason"
105
+ }
106
+ """
107
+
108
+ def _build_user_prompt(self, payload: dict[str, Any]) -> str:
109
+ return f"""Decide whether turn t is a logical continuation of turns t-2 and t-1.
110
+
111
+ Input JSON:
112
+ {json_dumps(payload, ensure_ascii=False, indent=2)}
113
+
114
+ Return one JSON object with:
115
+ - is_continuation: boolean yes/no answer to the logical-continuation question
116
+ - decision: one of {", ".join(item.value for item in ConversationContextDecision)}
117
+ - current_question_is_standalone: boolean
118
+ - confidence: number from 0 to 1
119
+ - standalone_question: required for follow_up and new_topic; empty only for ambiguous
120
+ - reason: short explanation
121
+ """
@@ -44,6 +44,8 @@ class ConversationContextClassification(BaseModel):
44
44
  standalone_question: str = ""
45
45
  reason: str = ""
46
46
  model_response: dict[str, Any] = Field(default_factory=dict)
47
+ prompt: dict[str, Any] = Field(default_factory=dict)
48
+ source: str = ""
47
49
 
48
50
 
49
51
  class QueryRequest(BaseModel):
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-core
3
- Version: 0.2.4
3
+ Version: 0.2.7
4
4
  Summary: Core GAARD query pipeline, prompt compiler, policies and SQL validation
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.4
7
+ Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.7
8
8
  Requires-Dist: pydantic>=2.7.0
9
9
  Requires-Dist: sqlglot>=25.0.0
10
10
  Provides-Extra: dev
@@ -1,4 +1,4 @@
1
- gaard-plugin-api<0.3.0,>=0.2.4
1
+ gaard-plugin-api<0.3.0,>=0.2.7
2
2
  pydantic>=2.7.0
3
3
  sqlglot>=25.0.0
4
4
 
@@ -1,8 +1,10 @@
1
1
  from gaard_core.conversation_context.llm_classifier import (
2
+ LlmConversationContextClassifier,
2
3
  parse_conversation_context_classification,
3
4
  )
4
5
  from gaard_core.conversation_context.mock_classifier import MockConversationContextClassifier
5
6
  from gaard_core.query_pipeline.models import ConversationContextDecision, QueryRequest
7
+ from gaard_llm.providers.models import ChatCompletionResponse
6
8
 
7
9
 
8
10
  def test_parse_conversation_context_classification_handles_aliases_and_invalid_values() -> None:
@@ -17,6 +19,59 @@ def test_parse_conversation_context_classification_handles_aliases_and_invalid_v
17
19
  parse_conversation_context_classification('{"decision":"surprising"}').decision
18
20
  == ConversationContextDecision.AMBIGUOUS
19
21
  )
22
+ assert (
23
+ parse_conversation_context_classification('{"is_continuation":false}').decision
24
+ == ConversationContextDecision.NEW_TOPIC
25
+ )
26
+ assert (
27
+ parse_conversation_context_classification('{"is_continuation":true}').decision
28
+ == ConversationContextDecision.FOLLOW_UP
29
+ )
30
+
31
+
32
+ def test_llm_conversation_context_classifier_exposes_prompt_and_standalone_follow_up() -> None:
33
+ class FakeClient:
34
+ def __init__(self) -> None:
35
+ self.request = None
36
+
37
+ def create_chat_completion(self, request):
38
+ self.request = request
39
+ return ChatCompletionResponse(
40
+ content=(
41
+ '{"is_continuation":true,"decision":"follow_up",'
42
+ '"current_question_is_standalone":true,"confidence":0.91,'
43
+ '"standalone_question":"","reason":"Same metric, new period."}'
44
+ )
45
+ )
46
+
47
+ client = FakeClient()
48
+ classifier = LlmConversationContextClassifier(
49
+ client=client,
50
+ model="test-model",
51
+ )
52
+
53
+ classification = classifier.classify(
54
+ QueryRequest(question="ilu pacjentów przyjęto w tym tygodniu"),
55
+ {
56
+ "turns": [
57
+ {
58
+ "question": "ilu pacjentów było przyjętych tydzień temu",
59
+ "answer": "12",
60
+ },
61
+ {
62
+ "question": "a dwa tygodnie temu?",
63
+ "standalone_question": "ilu pacjentów było przyjętych dwa tygodnie temu",
64
+ "answer": "9",
65
+ },
66
+ ]
67
+ },
68
+ )
69
+
70
+ assert classification.decision == ConversationContextDecision.FOLLOW_UP
71
+ assert classification.standalone_question == "ilu pacjentów przyjęto w tym tygodniu"
72
+ assert classification.source == "llm"
73
+ assert "turn_t_minus_1" in classification.prompt["user_prompt"]
74
+ assert "logical continuation" in classification.prompt["system_prompt"]
20
75
 
21
76
 
22
77
  def test_mock_conversation_context_classifier_rewrites_simple_follow_up() -> None:
@@ -1,68 +0,0 @@
1
- from typing import Any
2
-
3
- from gaard_core.json_utils import json_dumps
4
- from gaard_core.prompt_compiler.models import CompiledPrompt
5
- from gaard_core.query_pipeline.models import ConversationContextDecision, QueryRequest
6
-
7
-
8
- class ConversationContextPromptCompiler:
9
- def compile(
10
- self,
11
- request: QueryRequest,
12
- conversation_context: dict[str, Any],
13
- ) -> CompiledPrompt:
14
- payload = {
15
- "question": request.question,
16
- "datasource_id": request.datasource_id,
17
- "datasource_ids": request.datasource_ids,
18
- "conversation_context": conversation_context,
19
- }
20
-
21
- return CompiledPrompt(
22
- system_prompt=self._build_system_prompt(),
23
- user_prompt=self._build_user_prompt(payload),
24
- metadata={
25
- "allowed_decisions": [item.value for item in ConversationContextDecision],
26
- },
27
- )
28
-
29
- def _build_system_prompt(self) -> str:
30
- return """You are GAARD Conversation Context Classification.
31
-
32
- Your task is to decide whether a user's new data question starts a new topic, continues the current conversation, or is too ambiguous to safely continue.
33
-
34
- Allowed decisions:
35
- - new_topic: the question stands on its own or changes topic.
36
- - follow_up: the question clearly depends on previous turns and can be rewritten as a standalone data question.
37
- - ambiguous: the question appears to depend on prior context but cannot be rewritten safely.
38
-
39
- Decision rules:
40
- 1. Use follow_up only when the current question can be resolved from the supplied compact conversation context.
41
- 2. Use new_topic when the question is already self-contained.
42
- 3. Use ambiguous instead of guessing when key entities, metrics, filters, date ranges, or datasource scope cannot be inferred.
43
- 4. Treat detail/projection requests about the previous result as follow_up when the previous turn defines the result set. If the previous question counted, grouped, or filtered records and the user now asks for descriptions, names, statuses, fields, details, or values for those same records, rewrite by preserving the previous filters/date range/datasource and changing only the returned fields.
44
- 5. Do not mark a projection/detail follow-up ambiguous merely because the previous answer did not expose row ids. The previous standalone question and SQL are enough context for the next SQL generation step.
45
- 6. Use ambiguous when the user asks for one specific record but the context indicates multiple records and no selector is provided.
46
- 7. Never include rows or sensitive data in the standalone question.
47
-
48
- Output rules:
49
- - Return only a JSON object.
50
- - Do not include markdown.
51
- - Do not include reasoning outside the JSON.
52
- - Do not include <think> blocks.
53
- - Use exactly this JSON shape:
54
- {"decision":"new_topic","confidence":0.0,"standalone_question":"rewritten question or empty","reason":"short reason"}
55
- """
56
-
57
- def _build_user_prompt(self, payload: dict[str, Any]) -> str:
58
- return f"""Classify this new user question against the compact conversation context.
59
-
60
- Input JSON:
61
- {json_dumps(payload, ensure_ascii=False, indent=2)}
62
-
63
- Return one JSON object with:
64
- - decision: one of {", ".join(item.value for item in ConversationContextDecision)}
65
- - confidence: number from 0 to 1
66
- - standalone_question: required for follow_up, otherwise empty
67
- - reason: short explanation
68
- """
File without changes
File without changes