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.
- {gaard_core-0.2.2 → gaard_core-0.2.4}/PKG-INFO +2 -2
- {gaard_core-0.2.2 → gaard_core-0.2.4}/pyproject.toml +2 -2
- gaard_core-0.2.4/src/gaard_core/conversation_context/__init__.py +11 -0
- gaard_core-0.2.4/src/gaard_core/conversation_context/llm_classifier.py +124 -0
- gaard_core-0.2.4/src/gaard_core/conversation_context/mock_classifier.py +142 -0
- gaard_core-0.2.4/src/gaard_core/prompt_compiler/conversation_context_prompt.py +68 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/sql_generation_prompt.py +4 -3
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_pipeline/models.py +22 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/sql_validator/select_only.py +9 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core.egg-info/PKG-INFO +2 -2
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core.egg-info/SOURCES.txt +5 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core.egg-info/requires.txt +1 -1
- gaard_core-0.2.4/tests/test_conversation_context_classifier.py +70 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_sql_generation_prompt_compiler.py +2 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_sql_validator.py +9 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/README.md +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/setup.cfg +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/__init__.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/errors.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/execution/__init__.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/execution/mock_executor.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/json_utils.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/llm_output.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/__init__.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/intent_classification_prompt.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/models.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/result_classification_prompt.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/result_interpretation_prompt.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/schema_formatter.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/py.typed +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_intent/__init__.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_intent/llm_classifier.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_intent/mock_classifier.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_pipeline/__init__.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_pipeline/llm_sql_generator.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_pipeline/mock_sql_generator.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/query_pipeline/pipeline.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/result_classifier/__init__.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/result_classifier/llm_classifier.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/result_classifier/mock_classifier.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/result_interpreter/__init__.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/result_interpreter/llm_interpreter.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/result_interpreter/mock_interpreter.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/schema/__init__.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/schema/cache.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/schema/context.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/schema/models.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/sql_validator/__init__.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core.egg-info/dependency_links.txt +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core.egg-info/top_level.txt +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_json_utils.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_llm_output.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_llm_query_intent_classifier.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_llm_result_classifier.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_llm_result_interpreter.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_llm_sql_generator.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_query_pipeline.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_result_classification_prompt_compiler.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_result_interpretation_prompt_compiler.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_schema_context_cache.py +0 -0
- {gaard_core-0.2.2 → gaard_core-0.2.4}/tests/test_schema_context_service.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
"""
|
{gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/sql_generation_prompt.py
RENAMED
|
@@ -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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/intent_classification_prompt.py
RENAMED
|
File without changes
|
|
File without changes
|
{gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/result_classification_prompt.py
RENAMED
|
File without changes
|
{gaard_core-0.2.2 → gaard_core-0.2.4}/src/gaard_core/prompt_compiler/result_interpretation_prompt.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|