gaard-core 0.2.2__tar.gz → 0.2.4__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 (62) hide show
  1. {gaard_core-0.2.2 → gaard_core-0.2.4}/PKG-INFO +2 -2
  2. {gaard_core-0.2.2 → gaard_core-0.2.4}/pyproject.toml +2 -2
  3. gaard_core-0.2.4/src/gaard_core/conversation_context/__init__.py +11 -0
  4. gaard_core-0.2.4/src/gaard_core/conversation_context/llm_classifier.py +124 -0
  5. gaard_core-0.2.4/src/gaard_core/conversation_context/mock_classifier.py +142 -0
  6. gaard_core-0.2.4/src/gaard_core/prompt_compiler/conversation_context_prompt.py +68 -0
  7. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/sql_generation_prompt.py +4 -3
  8. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_pipeline/models.py +22 -0
  9. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/sql_validator/select_only.py +9 -0
  10. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core.egg-info/PKG-INFO +2 -2
  11. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core.egg-info/SOURCES.txt +5 -0
  12. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core.egg-info/requires.txt +1 -1
  13. gaard_core-0.2.4/tests/test_conversation_context_classifier.py +70 -0
  14. {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_sql_generation_prompt_compiler.py +2 -0
  15. {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_sql_validator.py +9 -0
  16. {gaard_core-0.2.2 → gaard_core-0.2.4}/README.md +0 -0
  17. {gaard_core-0.2.2 → gaard_core-0.2.4}/setup.cfg +0 -0
  18. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/__init__.py +0 -0
  19. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/errors.py +0 -0
  20. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/execution/__init__.py +0 -0
  21. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/execution/mock_executor.py +0 -0
  22. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/json_utils.py +0 -0
  23. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/llm_output.py +0 -0
  24. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/__init__.py +0 -0
  25. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/intent_classification_prompt.py +0 -0
  26. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/models.py +0 -0
  27. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/result_classification_prompt.py +0 -0
  28. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/result_interpretation_prompt.py +0 -0
  29. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/schema_formatter.py +0 -0
  30. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/py.typed +0 -0
  31. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_intent/__init__.py +0 -0
  32. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_intent/llm_classifier.py +0 -0
  33. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_intent/mock_classifier.py +0 -0
  34. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_pipeline/__init__.py +0 -0
  35. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_pipeline/llm_sql_generator.py +0 -0
  36. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_pipeline/mock_sql_generator.py +0 -0
  37. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_pipeline/pipeline.py +0 -0
  38. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/result_classifier/__init__.py +0 -0
  39. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/result_classifier/llm_classifier.py +0 -0
  40. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/result_classifier/mock_classifier.py +0 -0
  41. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/result_interpreter/__init__.py +0 -0
  42. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/result_interpreter/llm_interpreter.py +0 -0
  43. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/result_interpreter/mock_interpreter.py +0 -0
  44. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/schema/__init__.py +0 -0
  45. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/schema/cache.py +0 -0
  46. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/schema/context.py +0 -0
  47. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/schema/models.py +0 -0
  48. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/sql_validator/__init__.py +0 -0
  49. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core.egg-info/dependency_links.txt +0 -0
  50. {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core.egg-info/top_level.txt +0 -0
  51. {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_json_utils.py +0 -0
  52. {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_llm_output.py +0 -0
  53. {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_llm_query_intent_classifier.py +0 -0
  54. {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_llm_result_classifier.py +0 -0
  55. {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_llm_result_interpreter.py +0 -0
  56. {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_llm_sql_generator.py +0 -0
  57. {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_query_pipeline.py +0 -0
  58. {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_result_classification_prompt_compiler.py +0 -0
  59. {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_result_interpretation_prompt_compiler.py +0 -0
  60. {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_schema_context_cache.py +0 -0
  61. {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_schema_context_service.py +0 -0
  62. {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_schema_prompt_formatter.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-core
3
- Version: 0.2.2
3
+ Version: 0.2.4
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.2
7
+ Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.4
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.2"
7
+ version = "0.2.4"
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.2,<0.3.0",
12
+ "gaard-plugin-api>=0.2.4,<0.3.0",
13
13
  "pydantic>=2.7.0",
14
14
  "sqlglot>=25.0.0",
15
15
  ]
@@ -0,0 +1,11 @@
1
+ from gaard_core.conversation_context.llm_classifier import (
2
+ LlmConversationContextClassifier,
3
+ parse_conversation_context_classification,
4
+ )
5
+ from gaard_core.conversation_context.mock_classifier import MockConversationContextClassifier
6
+
7
+ __all__ = [
8
+ "LlmConversationContextClassifier",
9
+ "MockConversationContextClassifier",
10
+ "parse_conversation_context_classification",
11
+ ]
@@ -0,0 +1,124 @@
1
+ import json
2
+ from typing import Any, Protocol, cast
3
+
4
+ from gaard_core.llm_output import remove_thinking_blocks
5
+ from gaard_core.prompt_compiler.conversation_context_prompt import (
6
+ ConversationContextPromptCompiler,
7
+ )
8
+ from gaard_core.prompt_compiler.models import CompiledPrompt
9
+ from gaard_core.query_pipeline.models import (
10
+ ConversationContextClassification,
11
+ ConversationContextDecision,
12
+ QueryRequest,
13
+ )
14
+ from gaard_llm.openai_compatible.client import OpenAICompatibleClient
15
+ from gaard_llm.providers.models import ChatCompletionRequest, ChatMessage
16
+
17
+
18
+ class ConversationContextPromptCompilerProtocol(Protocol):
19
+ def compile(
20
+ self,
21
+ request: QueryRequest,
22
+ conversation_context: dict[str, Any],
23
+ ) -> CompiledPrompt:
24
+ pass
25
+
26
+
27
+ class LlmConversationContextClassifier:
28
+ def __init__(
29
+ self,
30
+ client: OpenAICompatibleClient,
31
+ model: str,
32
+ extra_body: dict[str, Any] | None = None,
33
+ prompt_compiler: ConversationContextPromptCompilerProtocol | None = None,
34
+ ) -> None:
35
+ self.client = client
36
+ self.model = model
37
+ self.extra_body = extra_body or {}
38
+ self.prompt_compiler = prompt_compiler or ConversationContextPromptCompiler()
39
+
40
+ def classify(
41
+ self,
42
+ request: QueryRequest,
43
+ conversation_context: dict[str, Any],
44
+ ) -> ConversationContextClassification:
45
+ compiled_prompt = self.prompt_compiler.compile(
46
+ request=request,
47
+ conversation_context=conversation_context,
48
+ )
49
+ response = self.client.create_chat_completion(
50
+ ChatCompletionRequest(
51
+ model=self.model,
52
+ temperature=0.0,
53
+ extra_body=self.extra_body,
54
+ messages=[
55
+ ChatMessage(role="system", content=compiled_prompt.system_prompt),
56
+ ChatMessage(role="user", content=compiled_prompt.user_prompt),
57
+ ],
58
+ )
59
+ )
60
+
61
+ return parse_conversation_context_classification(response.content)
62
+
63
+
64
+ def parse_conversation_context_classification(
65
+ value: str,
66
+ ) -> ConversationContextClassification:
67
+ cleaned = remove_thinking_blocks(value).strip()
68
+ if cleaned.startswith("```json"):
69
+ cleaned = cleaned.removeprefix("```json").strip()
70
+ if cleaned.startswith("```"):
71
+ cleaned = cleaned.removeprefix("```").strip()
72
+ if cleaned.endswith("```"):
73
+ cleaned = cleaned.removesuffix("```").strip()
74
+
75
+ try:
76
+ payload = json.loads(cleaned)
77
+ except json.JSONDecodeError:
78
+ payload = {"decision": cleaned}
79
+
80
+ if not isinstance(payload, dict):
81
+ return ConversationContextClassification()
82
+
83
+ return ConversationContextClassification(
84
+ decision=parse_conversation_context_decision(payload.get("decision")),
85
+ confidence=parse_confidence(payload.get("confidence")),
86
+ standalone_question=str(payload.get("standalone_question") or "").strip(),
87
+ reason=str(payload.get("reason") or ""),
88
+ model_response=payload,
89
+ )
90
+
91
+
92
+ def parse_conversation_context_decision(value: object) -> ConversationContextDecision:
93
+ if not isinstance(value, str):
94
+ return ConversationContextDecision.AMBIGUOUS
95
+
96
+ normalized = value.strip().lower().replace(" ", "_").replace("-", "_")
97
+ aliases = {
98
+ "new": ConversationContextDecision.NEW_TOPIC,
99
+ "new_question": ConversationContextDecision.NEW_TOPIC,
100
+ "newtopic": ConversationContextDecision.NEW_TOPIC,
101
+ "continue": ConversationContextDecision.FOLLOW_UP,
102
+ "continuation": ConversationContextDecision.FOLLOW_UP,
103
+ "followup": ConversationContextDecision.FOLLOW_UP,
104
+ "follow_up_question": ConversationContextDecision.FOLLOW_UP,
105
+ "unclear": ConversationContextDecision.AMBIGUOUS,
106
+ "needs_clarification": ConversationContextDecision.AMBIGUOUS,
107
+ }
108
+ if normalized in aliases:
109
+ return aliases[normalized]
110
+
111
+ for item in ConversationContextDecision:
112
+ if normalized == item.value:
113
+ return item
114
+
115
+ return ConversationContextDecision.AMBIGUOUS
116
+
117
+
118
+ def parse_confidence(value: object) -> float:
119
+ try:
120
+ confidence = float(cast(Any, value))
121
+ except (TypeError, ValueError):
122
+ return 0.0
123
+
124
+ return max(0.0, min(1.0, confidence))
@@ -0,0 +1,142 @@
1
+ import re
2
+ from typing import Any
3
+
4
+ from gaard_core.query_pipeline.models import (
5
+ ConversationContextClassification,
6
+ ConversationContextDecision,
7
+ QueryRequest,
8
+ )
9
+
10
+
11
+ FOLLOW_UP_PREFIXES = (
12
+ "a ",
13
+ "and ",
14
+ "oraz ",
15
+ "to samo",
16
+ "tak samo",
17
+ "dla ",
18
+ "w ",
19
+ "za ",
20
+ "porównaj",
21
+ "porownaj",
22
+ "compare",
23
+ "same",
24
+ )
25
+
26
+ AMBIGUOUS_TERMS = (
27
+ "to",
28
+ "tego",
29
+ "tamto",
30
+ "tamte",
31
+ "them",
32
+ "it",
33
+ "that",
34
+ )
35
+
36
+ RESULT_REFERENCE_TERMS = (
37
+ "ich",
38
+ "nich",
39
+ "tych",
40
+ "te",
41
+ "tej",
42
+ "tego",
43
+ "their",
44
+ "them",
45
+ "these",
46
+ "those",
47
+ )
48
+
49
+ PROJECTION_COMMAND_PREFIXES = (
50
+ "podaj ",
51
+ "pokaż ",
52
+ "pokaz ",
53
+ "wypisz ",
54
+ "wyświetl ",
55
+ "wyswietl ",
56
+ "show ",
57
+ "list ",
58
+ "give ",
59
+ "return ",
60
+ )
61
+
62
+
63
+ class MockConversationContextClassifier:
64
+ def classify(
65
+ self,
66
+ request: QueryRequest,
67
+ conversation_context: dict[str, Any],
68
+ ) -> ConversationContextClassification:
69
+ turns = conversation_context.get("turns") or []
70
+ if not turns:
71
+ return ConversationContextClassification(
72
+ decision=ConversationContextDecision.NEW_TOPIC,
73
+ confidence=1.0,
74
+ standalone_question=request.question,
75
+ reason="No previous turns are available.",
76
+ )
77
+
78
+ question = request.question.strip()
79
+ normalized = question.lower()
80
+ if looks_like_follow_up(normalized) or looks_like_projection_follow_up(normalized):
81
+ previous = latest_question(turns)
82
+ standalone = combine_follow_up(previous, question)
83
+ return ConversationContextClassification(
84
+ decision=ConversationContextDecision.FOLLOW_UP,
85
+ confidence=0.8,
86
+ standalone_question=standalone,
87
+ reason="The question appears to continue the previous data question.",
88
+ )
89
+
90
+ if normalized in AMBIGUOUS_TERMS or re.fullmatch(
91
+ r"(a )?(co|what|why|dlaczego)\??", normalized
92
+ ):
93
+ return ConversationContextClassification(
94
+ decision=ConversationContextDecision.AMBIGUOUS,
95
+ confidence=0.45,
96
+ standalone_question="",
97
+ reason="The question is too short to safely resolve from context.",
98
+ )
99
+
100
+ return ConversationContextClassification(
101
+ decision=ConversationContextDecision.NEW_TOPIC,
102
+ confidence=0.9,
103
+ standalone_question=question,
104
+ reason="The question is self-contained.",
105
+ )
106
+
107
+
108
+ def looks_like_follow_up(normalized_question: str) -> bool:
109
+ return any(normalized_question.startswith(prefix) for prefix in FOLLOW_UP_PREFIXES)
110
+
111
+
112
+ def looks_like_projection_follow_up(normalized_question: str) -> bool:
113
+ if not any(normalized_question.startswith(prefix) for prefix in PROJECTION_COMMAND_PREFIXES):
114
+ return False
115
+
116
+ tokens = set(re.findall(r"[\wąćęłńóśźż]+", normalized_question))
117
+ return bool(tokens & set(RESULT_REFERENCE_TERMS))
118
+
119
+
120
+ def latest_question(turns: list[Any]) -> str:
121
+ for turn in reversed(turns):
122
+ if not isinstance(turn, dict):
123
+ continue
124
+ question = str(
125
+ turn.get("standalone_question")
126
+ or turn.get("question")
127
+ or turn.get("original_question")
128
+ or ""
129
+ ).strip()
130
+ if question:
131
+ return question
132
+
133
+ return ""
134
+
135
+
136
+ def combine_follow_up(previous_question: str, question: str) -> str:
137
+ previous = previous_question.strip().rstrip(".?")
138
+ current = question.strip().rstrip(".?")
139
+ if not previous:
140
+ return question.strip()
141
+
142
+ return f"{previous}; kontynuacja: {current}?"
@@ -0,0 +1,68 @@
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
+ """
@@ -40,9 +40,7 @@ class SqlGenerationPromptCompiler:
40
40
  return request.formatted_schema
41
41
 
42
42
  if request.database_schema is None:
43
- raise ConfigurationError(
44
- "Either database_schema or formatted_schema must be provided."
45
- )
43
+ raise ConfigurationError("Either database_schema or formatted_schema must be provided.")
46
44
 
47
45
  return self.schema_formatter.format(request.database_schema)
48
46
 
@@ -85,11 +83,14 @@ Query construction rules:
85
83
  11. When the query uses table aliases, use those aliases consistently and do not mix aliased and unaliased table references.
86
84
  12. Do not use unqualified column names in joins or multi-table queries.
87
85
  13. If the question is ambiguous, choose the most likely interpretation based on the schema, column names, descriptions and data rules.
86
+ 14. Do not use bind parameters, placeholders, variables, or prepared-statement markers such as :name, ?, $1, @name, or %(name)s.
87
+ 15. When dates or dynamic ranges are needed, express them directly with {dialect} SQL functions or literal values so the SQL is executable without any external parameter binding.
88
88
 
89
89
  Output contract:
90
90
  - Return exactly one SQL SELECT statement.
91
91
  - The first non-whitespace token must be SELECT or WITH.
92
92
  - The final output must be executable SQL only.
93
+ - The final SQL must be self-contained and executable as-is.
93
94
  """
94
95
 
95
96
  def _build_user_prompt(self, schema: str, question: str) -> str:
@@ -26,12 +26,34 @@ class QueryIntentClassification(BaseModel):
26
26
  model_response: dict[str, Any] = Field(default_factory=dict)
27
27
 
28
28
 
29
+ class ContextMode(StrEnum):
30
+ AUTO = "auto"
31
+ NEW = "new"
32
+ OFF = "off"
33
+
34
+
35
+ class ConversationContextDecision(StrEnum):
36
+ NEW_TOPIC = "new_topic"
37
+ FOLLOW_UP = "follow_up"
38
+ AMBIGUOUS = "ambiguous"
39
+
40
+
41
+ class ConversationContextClassification(BaseModel):
42
+ decision: ConversationContextDecision = ConversationContextDecision.NEW_TOPIC
43
+ confidence: float = 0.0
44
+ standalone_question: str = ""
45
+ reason: str = ""
46
+ model_response: dict[str, Any] = Field(default_factory=dict)
47
+
48
+
29
49
  class QueryRequest(BaseModel):
30
50
  question: str = Field(min_length=1)
31
51
  datasource_id: str = "default"
32
52
  datasource_ids: list[str] = Field(default_factory=list)
33
53
  user_id: str = "local-admin"
34
54
  interpret: bool = True
55
+ conversation_id: str | None = None
56
+ context_mode: ContextMode = ContextMode.AUTO
35
57
 
36
58
 
37
59
  class GeneratedSql(BaseModel):
@@ -35,3 +35,12 @@ class SelectOnlySqlValidator:
35
35
  for node in statement.walk():
36
36
  if isinstance(node, forbidden_expressions):
37
37
  raise SqlValidationError(f"DDL and DML statements are not allowed. {sql}")
38
+ if isinstance(node, (exp.Placeholder, exp.Parameter)):
39
+ raise SqlValidationError(
40
+ f"SQL bind parameters are not allowed. SQL: {sql}",
41
+ sql=sql,
42
+ metadata={
43
+ "primary_error_category": "sql.validation.bind_parameter",
44
+ "error_categories": ["sql.validation.bind_parameter"],
45
+ },
46
+ )
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-core
3
- Version: 0.2.2
3
+ Version: 0.2.4
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.2
7
+ Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.4
8
8
  Requires-Dist: pydantic>=2.7.0
9
9
  Requires-Dist: sqlglot>=25.0.0
10
10
  Provides-Extra: dev
@@ -10,9 +10,13 @@ src/gaard_core.egg-info/SOURCES.txt
10
10
  src/gaard_core.egg-info/dependency_links.txt
11
11
  src/gaard_core.egg-info/requires.txt
12
12
  src/gaard_core.egg-info/top_level.txt
13
+ src/gaard_core/conversation_context/__init__.py
14
+ src/gaard_core/conversation_context/llm_classifier.py
15
+ src/gaard_core/conversation_context/mock_classifier.py
13
16
  src/gaard_core/execution/__init__.py
14
17
  src/gaard_core/execution/mock_executor.py
15
18
  src/gaard_core/prompt_compiler/__init__.py
19
+ src/gaard_core/prompt_compiler/conversation_context_prompt.py
16
20
  src/gaard_core/prompt_compiler/intent_classification_prompt.py
17
21
  src/gaard_core/prompt_compiler/models.py
18
22
  src/gaard_core/prompt_compiler/result_classification_prompt.py
@@ -39,6 +43,7 @@ src/gaard_core/schema/context.py
39
43
  src/gaard_core/schema/models.py
40
44
  src/gaard_core/sql_validator/__init__.py
41
45
  src/gaard_core/sql_validator/select_only.py
46
+ tests/test_conversation_context_classifier.py
42
47
  tests/test_json_utils.py
43
48
  tests/test_llm_output.py
44
49
  tests/test_llm_query_intent_classifier.py
@@ -1,4 +1,4 @@
1
- gaard-plugin-api<0.3.0,>=0.2.2
1
+ gaard-plugin-api<0.3.0,>=0.2.4
2
2
  pydantic>=2.7.0
3
3
  sqlglot>=25.0.0
4
4
 
@@ -0,0 +1,70 @@
1
+ from gaard_core.conversation_context.llm_classifier import (
2
+ parse_conversation_context_classification,
3
+ )
4
+ from gaard_core.conversation_context.mock_classifier import MockConversationContextClassifier
5
+ from gaard_core.query_pipeline.models import ConversationContextDecision, QueryRequest
6
+
7
+
8
+ def test_parse_conversation_context_classification_handles_aliases_and_invalid_values() -> None:
9
+ assert (
10
+ parse_conversation_context_classification(
11
+ '<think>hidden</think>{"decision":"followup","confidence":0.8,'
12
+ '"standalone_question":"How many patients in May?"}'
13
+ ).decision
14
+ == ConversationContextDecision.FOLLOW_UP
15
+ )
16
+ assert (
17
+ parse_conversation_context_classification('{"decision":"surprising"}').decision
18
+ == ConversationContextDecision.AMBIGUOUS
19
+ )
20
+
21
+
22
+ def test_mock_conversation_context_classifier_rewrites_simple_follow_up() -> None:
23
+ classifier = MockConversationContextClassifier()
24
+
25
+ classification = classifier.classify(
26
+ QueryRequest(question="a w maju?"),
27
+ {
28
+ "turns": [
29
+ {
30
+ "question": "Jaka była sprzedaż w czerwcu według regionów?",
31
+ "standalone_question": "Jaka była sprzedaż w czerwcu według regionów?",
32
+ }
33
+ ]
34
+ },
35
+ )
36
+
37
+ assert classification.decision == ConversationContextDecision.FOLLOW_UP
38
+ assert "czerwcu" in classification.standalone_question
39
+ assert "maju" in classification.standalone_question
40
+
41
+
42
+ def test_mock_conversation_context_classifier_rewrites_projection_follow_up() -> None:
43
+ classifier = MockConversationContextClassifier()
44
+
45
+ classification = classifier.classify(
46
+ QueryRequest(question="show their names"),
47
+ {
48
+ "turns": [
49
+ {
50
+ "question": "How many active patients are there?",
51
+ "standalone_question": "How many active patients are there?",
52
+ }
53
+ ]
54
+ },
55
+ )
56
+
57
+ assert classification.decision == ConversationContextDecision.FOLLOW_UP
58
+ assert "active patients" in classification.standalone_question
59
+ assert "show their names" in classification.standalone_question
60
+
61
+
62
+ def test_mock_conversation_context_classifier_marks_short_reference_ambiguous() -> None:
63
+ classifier = MockConversationContextClassifier()
64
+
65
+ classification = classifier.classify(
66
+ QueryRequest(question="to"),
67
+ {"turns": [{"question": "How many active patients are there?"}]},
68
+ )
69
+
70
+ assert classification.decision == ConversationContextDecision.AMBIGUOUS
@@ -31,6 +31,8 @@ def test_sql_generation_prompt_compiler_builds_prompt_with_rules_schema_and_ques
31
31
  assert "every table must have a short, stable alias" in compiled.system_prompt
32
32
  assert "every column reference must be qualified" in compiled.system_prompt
33
33
  assert "Do not use unqualified column names in joins" in compiled.system_prompt
34
+ assert "Do not use bind parameters" in compiled.system_prompt
35
+ assert "executable without any external parameter binding" in compiled.system_prompt
34
36
 
35
37
  assert "Table: patients" in compiled.user_prompt
36
38
  assert "- status: TEXT (not null)" in compiled.user_prompt
@@ -40,3 +40,12 @@ def test_validator_rejects_multiple_statements() -> None:
40
40
 
41
41
  with pytest.raises(SqlValidationError):
42
42
  validator.validate("SELECT * FROM patients; SELECT * FROM users")
43
+
44
+
45
+ def test_validator_rejects_bind_parameters() -> None:
46
+ validator = SelectOnlySqlValidator(dialect="mysql")
47
+
48
+ with pytest.raises(SqlValidationError) as exc_info:
49
+ validator.validate("SELECT * FROM `lead` WHERE source_id = :source_id")
50
+
51
+ assert exc_info.value.metadata["primary_error_category"] == ("sql.validation.bind_parameter")
File without changes
File without changes