arango-query-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.
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/CHANGELOG.md +46 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/PKG-INFO +1 -1
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/nl/engine.py +90 -10
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/nl/postconditions.py +38 -9
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/nl/seams.py +9 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/docs/PRD.md +3 -3
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/pyproject.toml +1 -1
- arango_query_core-0.2.4/tests/test_nl_engine_extraction_and_resilience.py +165 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/test_nl_postconditions.py +133 -1
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/.cursor/rules/fix-upstream-dont-fork.mdc +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/.github/workflows/ci.yml +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/.github/workflows/publish.yml +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/.gitignore +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/LICENSE +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/README.md +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/__init__.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/aql.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/errors.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/exec.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/extensions.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/mapping.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/nl/__init__.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/nl/fewshot.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/nl/grounding.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/nl/pathindex.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/nl/providers.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/nl/synthbank.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/owl_rdflib.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/owl_turtle.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/arango_query_core/py.typed +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/fixtures/mappings/pg.owl.ttl +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/test_mapping_helpers.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/test_mapping_resolver_normalization.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/test_mapping_vci_detection.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/test_nl_engine.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/test_nl_fewshot.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/test_nl_grounding.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/test_nl_pathindex.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/test_nl_providers.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/test_nl_synthbank.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/test_owl_rdflib.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/test_owl_turtle_roundtrip.py +0 -0
- {arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/test_upstream_markers.py +0 -0
|
@@ -6,6 +6,52 @@ exact versions (contextual-data-fabric CC-9) and re-run their golden /
|
|
|
6
6
|
eval suites on every bump — pre-1.0, **minor** bumps may break the API
|
|
7
7
|
and this file must say so explicitly.
|
|
8
8
|
|
|
9
|
+
## [0.2.4] - 2026-10-21
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `QueryLanguageAdapter` may define `normalize(query) -> str`, an optional seam
|
|
14
|
+
`NLQueryEngine` calls on every candidate after the fence is stripped and
|
|
15
|
+
before `validate`. Use it for a deterministic repair that would otherwise
|
|
16
|
+
cost an LLM retry; nl2cypher uses it to rewrite labels the schema does not
|
|
17
|
+
have. What it returns is validated, quoted in retries and returned. A
|
|
18
|
+
`normalize` that raises is logged and the candidate kept. It is not a
|
|
19
|
+
protocol member, so existing adapters are unaffected. #8
|
|
20
|
+
- `NLQueryEngine.generate(..., user_hint="")` appends a caller hint to the
|
|
21
|
+
first attempt's user message only, for example "your previous query
|
|
22
|
+
failed with …" when re-asking. It leaves the system prompt (the cacheable
|
|
23
|
+
prefix), few-shot retrieval and postcondition context untouched. #10
|
|
24
|
+
- `NLQueryEngine(retry_provider_errors=True)`: a provider call that raises (a
|
|
25
|
+
timeout, a 5xx) uses one attempt of `max_retries`, and the loop goes on.
|
|
26
|
+
`NLResult.provider_errors` counts them. If no attempt returned a response,
|
|
27
|
+
the last exception is re-raised. The default (`False`) keeps today's
|
|
28
|
+
behaviour: the first exception propagates. #9
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- The engine finds the query in the first fenced block anywhere in the
|
|
33
|
+
response. It used to strip a fence only when the response started with
|
|
34
|
+
one, so "Here is the query: ```…```" failed validation and spent a retry.
|
|
35
|
+
A response that opens a fence without closing it yields what follows the
|
|
36
|
+
fence, and a response with no fence is returned as is, as before. When a
|
|
37
|
+
response has two fenced blocks, the first is taken; it used to be
|
|
38
|
+
everything from the first opening fence to the last closing one. #7
|
|
39
|
+
|
|
40
|
+
## [0.2.3] - 2026-10-21
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- `PostconditionViolation.soft` (default `False`): a soft violation is
|
|
45
|
+
retried like any other, but when the retry budget is spent
|
|
46
|
+
`NLQueryEngine.generate` returns the first valid candidate whose only
|
|
47
|
+
objection was soft, instead of failing closed. The candidate still passes
|
|
48
|
+
guardrails, and the violation stays on `NLResult.postcondition` (`ok` True
|
|
49
|
+
with a non-None `postcondition` marks a soft fallback). For checks such as
|
|
50
|
+
"the query returned no rows", where empty is sometimes the right answer.
|
|
51
|
+
`run_postconditions` now returns the first *hard* violation ahead of any
|
|
52
|
+
soft one, so a hard check listed after a soft one still applies. Additive;
|
|
53
|
+
existing checks are unaffected. Part of #4.
|
|
54
|
+
|
|
9
55
|
## [0.2.2] - 2026-10-21
|
|
10
56
|
|
|
11
57
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: arango-query-core
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Shared conceptual-model and NL substrate for ArangoDB query transpilers (arango-cypher-py, arango-sparql-py)
|
|
5
5
|
Project-URL: Repository, https://github.com/ArthurKeen/arango-query-core
|
|
6
6
|
Project-URL: Changelog, https://github.com/ArthurKeen/arango-query-core/blob/main/CHANGELOG.md
|
|
@@ -17,6 +17,8 @@ signature breaks.
|
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
20
22
|
from collections.abc import Sequence
|
|
21
23
|
from dataclasses import dataclass, field
|
|
22
24
|
from typing import Any
|
|
@@ -33,13 +35,18 @@ from .seams import GuardrailVerdict, QueryLanguageAdapter, ValidationResult
|
|
|
33
35
|
|
|
34
36
|
_USAGE_KEYS = ("prompt_tokens", "completion_tokens", "total_tokens", "cached_tokens")
|
|
35
37
|
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
36
40
|
|
|
37
41
|
@dataclass
|
|
38
42
|
class NLResult:
|
|
39
43
|
"""Outcome of one NL→query generation.
|
|
40
44
|
|
|
41
45
|
``query`` is empty when generation failed (``ok`` False); ``error``
|
|
42
|
-
then carries the last validation failure
|
|
46
|
+
then carries the last validation failure, postcondition violation or
|
|
47
|
+
guardrail refusal. ``ok`` True with a non-None ``postcondition`` means a
|
|
48
|
+
soft fallback: the retry budget ran out and ``query`` is the first valid
|
|
49
|
+
candidate whose only objection was soft (``PostconditionViolation.soft``).
|
|
43
50
|
Token fields accumulate across retries so cost accounting sees the
|
|
44
51
|
whole conversation, not the last attempt.
|
|
45
52
|
"""
|
|
@@ -55,6 +62,8 @@ class NLResult:
|
|
|
55
62
|
guardrail: GuardrailVerdict | None = None
|
|
56
63
|
validation: ValidationResult | None = None
|
|
57
64
|
postcondition: PostconditionViolation | None = None
|
|
65
|
+
provider_errors: int = 0
|
|
66
|
+
"""Provider calls that raised and were retried (``retry_provider_errors``)."""
|
|
58
67
|
|
|
59
68
|
|
|
60
69
|
@dataclass
|
|
@@ -88,17 +97,35 @@ class NLQueryEngine:
|
|
|
88
97
|
guardrail_context: dict[str, Any] = field(default_factory=dict)
|
|
89
98
|
postconditions: Sequence[Postcondition] = ()
|
|
90
99
|
postcondition_mapping: Any = None
|
|
100
|
+
retry_provider_errors: bool = False
|
|
101
|
+
"""When True, a provider call that raises (a timeout, a 5xx) uses one
|
|
102
|
+
attempt of the ``max_retries`` budget and the loop goes on; the count is
|
|
103
|
+
on :attr:`NLResult.provider_errors`. If no attempt ever returned a
|
|
104
|
+
response, the last exception is re-raised. False (the default) lets the
|
|
105
|
+
first exception propagate, as before."""
|
|
106
|
+
|
|
107
|
+
def generate(self, question: str, *, schema_context: str = "", user_hint: str = "") -> NLResult:
|
|
108
|
+
"""Generate a query for *question*.
|
|
91
109
|
|
|
92
|
-
|
|
110
|
+
*user_hint* is appended to the **first** attempt's user message only —
|
|
111
|
+
e.g. "your previous query failed with ..." when a caller re-asks after a
|
|
112
|
+
failure. It leaves the system prompt (the cacheable prefix), few-shot
|
|
113
|
+
retrieval and postcondition context untouched; retries carry their own
|
|
114
|
+
"previous query was rejected" text.
|
|
115
|
+
"""
|
|
93
116
|
result = NLResult()
|
|
94
117
|
system = self._system_prompt(question, schema_context)
|
|
95
|
-
user = question
|
|
118
|
+
user = f"{question}\n\n{user_hint}" if user_hint else question
|
|
119
|
+
last_provider_error: Exception | None = None
|
|
96
120
|
failure: ValidationResult | None = None
|
|
97
121
|
violation: PostconditionViolation | None = None
|
|
98
122
|
candidate = ""
|
|
123
|
+
# First valid candidate whose only objection was soft: what the call
|
|
124
|
+
# returns if the budget runs out without a clean candidate.
|
|
125
|
+
fallback: tuple[str, ValidationResult, PostconditionViolation] | None = None
|
|
99
126
|
|
|
100
127
|
for attempt in range(self.max_retries + 1):
|
|
101
|
-
if attempt > 0:
|
|
128
|
+
if attempt > 0 and failure is not None:
|
|
102
129
|
# A postcondition rejection carries its own correction text;
|
|
103
130
|
# otherwise fall back to the adapter's syntactic repair hint.
|
|
104
131
|
if violation is not None:
|
|
@@ -113,11 +140,26 @@ class NLQueryEngine:
|
|
|
113
140
|
f"Problem: {hint}\n"
|
|
114
141
|
f"Return a corrected query only."
|
|
115
142
|
)
|
|
143
|
+
if attempt > 0:
|
|
116
144
|
result.retries = attempt
|
|
117
|
-
|
|
145
|
+
try:
|
|
146
|
+
content, usage = self.provider.generate(system, user)
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
if not self.retry_provider_errors:
|
|
149
|
+
raise
|
|
150
|
+
result.provider_errors += 1
|
|
151
|
+
last_provider_error = exc
|
|
152
|
+
logger.warning(
|
|
153
|
+
"NL provider call failed (attempt %d/%d): %s: %s",
|
|
154
|
+
attempt + 1,
|
|
155
|
+
self.max_retries + 1,
|
|
156
|
+
type(exc).__name__,
|
|
157
|
+
exc,
|
|
158
|
+
)
|
|
159
|
+
continue
|
|
118
160
|
for key in _USAGE_KEYS:
|
|
119
161
|
setattr(result, key, getattr(result, key) + int(usage.get(key, 0) or 0))
|
|
120
|
-
candidate = _strip_code_fence(content, self.adapter.language)
|
|
162
|
+
candidate = self._normalize(_strip_code_fence(content, self.adapter.language))
|
|
121
163
|
failure = self.adapter.validate(candidate)
|
|
122
164
|
result.validation = failure
|
|
123
165
|
if not failure.ok:
|
|
@@ -138,13 +180,28 @@ class NLQueryEngine:
|
|
|
138
180
|
result.postcondition = violation
|
|
139
181
|
if violation is None:
|
|
140
182
|
break
|
|
183
|
+
# Soft only if no check objected as hard (run_postconditions
|
|
184
|
+
# returns a hard violation ahead of any soft one).
|
|
185
|
+
if violation.soft and fallback is None:
|
|
186
|
+
fallback = (candidate, failure, violation)
|
|
187
|
+
else:
|
|
188
|
+
# Budget spent without a clean candidate. If one drew only a soft
|
|
189
|
+
# objection, return it — with the objection on the result — rather
|
|
190
|
+
# than refuse a query that may well be right.
|
|
191
|
+
if fallback is not None:
|
|
192
|
+
candidate, result.validation, result.postcondition = fallback
|
|
193
|
+
failure, violation = result.validation, None
|
|
141
194
|
|
|
195
|
+
if failure is None and last_provider_error is not None:
|
|
196
|
+
# Every attempt raised: nothing to report but the failure itself.
|
|
197
|
+
raise last_provider_error
|
|
142
198
|
if failure is None or not failure.ok:
|
|
143
199
|
result.error = failure.error if failure else "provider returned nothing"
|
|
144
200
|
return result
|
|
145
201
|
if violation is not None:
|
|
146
|
-
# Budget spent on a valid-but-semantically-rejected query
|
|
147
|
-
#
|
|
202
|
+
# Budget spent on a valid-but-semantically-rejected query with no
|
|
203
|
+
# soft-only candidate to fall back on — fail closed rather than
|
|
204
|
+
# return a plausible wrong answer.
|
|
148
205
|
result.error = f"{violation.reason} ({violation.code})"
|
|
149
206
|
return result
|
|
150
207
|
|
|
@@ -158,6 +215,21 @@ class NLQueryEngine:
|
|
|
158
215
|
result.ok = True
|
|
159
216
|
return result
|
|
160
217
|
|
|
218
|
+
def _normalize(self, candidate: str) -> str:
|
|
219
|
+
"""The adapter's optional deterministic repair (``normalize`` seam),
|
|
220
|
+
applied before validation; the result is what is validated, quoted in
|
|
221
|
+
retries and returned. A ``normalize`` that raises is logged and the
|
|
222
|
+
candidate is kept — a broken repair must not abort generation."""
|
|
223
|
+
normalize = getattr(self.adapter, "normalize", None)
|
|
224
|
+
if not callable(normalize):
|
|
225
|
+
return candidate
|
|
226
|
+
try:
|
|
227
|
+
normalized = normalize(candidate)
|
|
228
|
+
except Exception as exc: # noqa: BLE001 - see docstring
|
|
229
|
+
logger.warning("adapter.normalize raised %s: %s; keeping the candidate", type(exc).__name__, exc)
|
|
230
|
+
return candidate
|
|
231
|
+
return normalized if isinstance(normalized, str) else candidate
|
|
232
|
+
|
|
161
233
|
def _system_prompt(self, question: str, schema_context: str) -> str:
|
|
162
234
|
sections = [self.adapter.grammar_prompt_section(schema_context)]
|
|
163
235
|
index = self.adapter.few_shot_index()
|
|
@@ -190,14 +262,22 @@ class NLQueryEngine:
|
|
|
190
262
|
return "\n\n".join(s for s in sections if s)
|
|
191
263
|
|
|
192
264
|
|
|
265
|
+
_FENCED_BLOCK = re.compile(r"```[^\n`]*\n(.*?)```", re.DOTALL)
|
|
266
|
+
|
|
267
|
+
|
|
193
268
|
def _strip_code_fence(content: str, language: str) -> str:
|
|
194
269
|
"""Extract the query from a fenced code block, tolerating bare text.
|
|
195
270
|
|
|
196
271
|
LLMs frequently wrap the answer in ```<lang> fences despite the
|
|
197
|
-
output-format contract
|
|
198
|
-
|
|
272
|
+
output-format contract, and often lead with prose ("Here is the
|
|
273
|
+
query:") or follow with an explanation. The first fenced block wherever
|
|
274
|
+
it appears is the query; a response that only opens a fence yields what
|
|
275
|
+
follows it; a response without a fence is returned as is.
|
|
199
276
|
"""
|
|
200
277
|
text = content.strip()
|
|
278
|
+
block = _FENCED_BLOCK.search(text)
|
|
279
|
+
if block is not None:
|
|
280
|
+
return block.group(1).strip()
|
|
201
281
|
if not text.startswith("```"):
|
|
202
282
|
return text
|
|
203
283
|
first_newline = text.find("\n")
|
|
@@ -15,14 +15,19 @@ budget as parse and translate: the model is told the rule up front (via
|
|
|
15
15
|
:meth:`Postcondition.prompt_section`, rendered once into the cacheable system
|
|
16
16
|
prefix), and a violation is fed back into the next prompt exactly like a
|
|
17
17
|
validation failure — so the loop gets a chance to *correct* the query rather
|
|
18
|
-
than just reject it, and fails closed when the budget is spent
|
|
18
|
+
than just reject it, and fails closed when the budget is spent — unless a
|
|
19
|
+
candidate drew only a *soft* violation (``PostconditionViolation.soft``), in
|
|
20
|
+
which case the first such candidate is returned instead.
|
|
19
21
|
|
|
20
22
|
Semantics (mirrors ``nl2cypher``'s original, language-agnostic here):
|
|
21
23
|
|
|
22
24
|
* Postconditions run **after** the adapter's ``validate`` succeeds — a retry is
|
|
23
25
|
only ever spent on an already-valid query.
|
|
24
|
-
* The **first** violation wins; checks are not accumulated. One clear,
|
|
25
|
-
correction beats a pile of them.
|
|
26
|
+
* The **first hard** violation wins; checks are not accumulated. One clear,
|
|
27
|
+
specific correction beats a pile of them. A *soft* violation does not stop
|
|
28
|
+
the run: the remaining checks still see the query, and any hard violation
|
|
29
|
+
among them takes precedence. Only a query no check objects to as hard can
|
|
30
|
+
become a soft fallback, whatever order the checks are listed in.
|
|
26
31
|
* A check that **raises** is treated as a violation, not an error — a broken
|
|
27
32
|
postcondition must not turn a safety mechanism into an outage. Fail closed.
|
|
28
33
|
* :meth:`Postcondition.prompt_section` renders **once**; it must not vary
|
|
@@ -60,6 +65,19 @@ class PostconditionViolation:
|
|
|
60
65
|
"""How to fix it. Also fed into the retry prompt, after ``reason``."""
|
|
61
66
|
code: str = "postcondition_violation"
|
|
62
67
|
"""Stable identifier for telemetry / assertions, e.g. ``"time_windowed"``."""
|
|
68
|
+
soft: bool = False
|
|
69
|
+
"""A soft violation asks for a retry but does not make the query unusable.
|
|
70
|
+
|
|
71
|
+
Use it for an objection that is sometimes the right answer — e.g. "the query
|
|
72
|
+
returned no rows" when the honest answer may be "none". When the retry
|
|
73
|
+
budget is spent, the engine returns the first valid candidate whose only
|
|
74
|
+
objection was soft (with this violation on ``NLResult.postcondition``)
|
|
75
|
+
instead of failing closed. Hard violations (the default) still fail closed.
|
|
76
|
+
|
|
77
|
+
Only that first soft candidate is offered to the guardrails: if they refuse
|
|
78
|
+
it, the call fails closed with the guardrail's reason, even when a later
|
|
79
|
+
soft candidate would have passed — the conservative reading.
|
|
80
|
+
"""
|
|
63
81
|
|
|
64
82
|
|
|
65
83
|
@dataclass(frozen=True)
|
|
@@ -109,15 +127,22 @@ def run_postconditions(
|
|
|
109
127
|
postconditions: Sequence[Postcondition] | None,
|
|
110
128
|
context: PostconditionContext,
|
|
111
129
|
) -> PostconditionViolation | None:
|
|
112
|
-
"""Run *postconditions* in order, returning the first violation
|
|
130
|
+
"""Run *postconditions* in order, returning the first hard violation, or
|
|
131
|
+
else the first soft one.
|
|
132
|
+
|
|
133
|
+
A soft violation does not stop the run, so a hard check listed after it
|
|
134
|
+
still sees the query; otherwise a query breaking a hard rule could be
|
|
135
|
+
returned as a soft fallback.
|
|
113
136
|
|
|
114
|
-
A check that raises is treated as a violation rather than being
|
|
115
|
-
abort the translation: a broken check must not be able to turn a
|
|
116
|
-
mechanism into an outage, and failing closed is the conservative
|
|
137
|
+
A check that raises is treated as a (hard) violation rather than being
|
|
138
|
+
allowed to abort the translation: a broken check must not be able to turn a
|
|
139
|
+
safety mechanism into an outage, and failing closed is the conservative
|
|
140
|
+
reading.
|
|
117
141
|
"""
|
|
118
142
|
if not postconditions:
|
|
119
143
|
return None
|
|
120
144
|
|
|
145
|
+
first_soft: PostconditionViolation | None = None
|
|
121
146
|
for pc in postconditions:
|
|
122
147
|
code = getattr(pc, "code", pc.__class__.__name__)
|
|
123
148
|
try:
|
|
@@ -128,9 +153,13 @@ def run_postconditions(
|
|
|
128
153
|
reason=f"Postcondition {code!r} raised {type(exc).__name__}: {exc}",
|
|
129
154
|
suggested_hint="Simplify the query; the check could not evaluate it.",
|
|
130
155
|
)
|
|
131
|
-
if violation is
|
|
156
|
+
if violation is None:
|
|
157
|
+
continue
|
|
158
|
+
if not violation.soft:
|
|
132
159
|
return violation
|
|
133
|
-
|
|
160
|
+
if first_soft is None:
|
|
161
|
+
first_soft = violation
|
|
162
|
+
return first_soft
|
|
134
163
|
|
|
135
164
|
|
|
136
165
|
def postcondition_prompt_sections(
|
|
@@ -74,6 +74,15 @@ class QueryLanguageAdapter(Protocol):
|
|
|
74
74
|
``language`` is the lowercase tag used for fenced code blocks and
|
|
75
75
|
telemetry (``"cypher"``, ``"sparql"``). All other members map
|
|
76
76
|
one-to-one onto the numbered seams in the module docstring.
|
|
77
|
+
|
|
78
|
+
**Optional:** ``normalize(query: str) -> str``. When an adapter defines
|
|
79
|
+
it, :class:`~arango_query_core.nl.NLQueryEngine` calls it on every
|
|
80
|
+
candidate, after the code fence is stripped and before :meth:`validate`,
|
|
81
|
+
for a deterministic repair that would otherwise cost an LLM retry (e.g.
|
|
82
|
+
rewriting a label the schema does not have to the one it does). What it
|
|
83
|
+
returns is what is validated, quoted in a retry and returned. It is not a
|
|
84
|
+
protocol member, so existing adapters conform unchanged; a ``normalize``
|
|
85
|
+
that raises is logged and the candidate kept.
|
|
77
86
|
"""
|
|
78
87
|
|
|
79
88
|
language: str
|
|
@@ -122,14 +122,14 @@ to prevent a three-way fork.
|
|
|
122
122
|
| `exec` | `AqlExecutor` protocol, `safe_execute` (bind-var reference checking), `explain_aql` |
|
|
123
123
|
| `extensions` | `ExtensionRegistry` / `ExtensionPolicy` for `arango.*` extension functions |
|
|
124
124
|
| `errors` | `CoreError` shared base |
|
|
125
|
-
| `nl.engine` | `NLQueryEngine` — prompt assembly, few-shot retrieval, provider call, generate→validate→repair loop; `NLResult` |
|
|
125
|
+
| `nl.engine` | `NLQueryEngine` — prompt assembly, few-shot retrieval, provider call, generate→validate→repair loop; `NLResult`. The query is the first fenced block anywhere in a response (prose around it is ignored), else the bare response. An adapter's optional `normalize` seam repairs each candidate before validation. `generate(user_hint=...)` appends a caller hint to the first user message only, leaving the cacheable system prefix alone. With `retry_provider_errors=True`, a provider call that raises uses one attempt of the budget (`NLResult.provider_errors`), and the last error is re-raised only if no attempt returned a response; the default propagates the first error. |
|
|
126
126
|
| `nl.providers` | `LLMProvider` protocol; OpenAI / OpenRouter / Anthropic implementations (Anthropic with prompt-cache system-block splitting); env-driven `get_llm_provider` |
|
|
127
127
|
| `nl.fewshot` | `FewShotIndex` over `(question, query)` pairs (legacy corpus keys accepted); `BM25Retriever` (rank_bm25, `nl` extra) and `DenseRetriever` (sentence-transformers, `dense` extra), selected via `mode="bm25"\|"dense"\|"auto"`; `cached_few_shot_index` memoizes the expensive dense build |
|
|
128
128
|
| `nl.grounding` | Seam 6/7 retrieval: `LabelIndex`/`GroundedEntity` (entity grounding) and `PredicateIndex`/`GroundedPredicate` (predicate/schema-convention grounding, renders IRI local names not labels), sharing one token-substring scorer |
|
|
129
129
|
| `nl.pathindex` | Seam 8: `ClassPathIndex`/`ClassPath` — bounded shortest predicate paths between two seam-6/7-anchored classes over the TBox's class-connectivity graph |
|
|
130
|
-
| `nl.postconditions` | Caller-supplied semantic invariants (`Postcondition`, `PostconditionContext`, `PostconditionViolation`, `run_postconditions`) enforced through the engine's own retry budget; fails closed on exhaustion |
|
|
130
|
+
| `nl.postconditions` | Caller-supplied semantic invariants (`Postcondition`, `PostconditionContext`, `PostconditionViolation`, `run_postconditions`) enforced through the engine's own retry budget; fails closed on exhaustion, unless a valid candidate drew only a soft violation (`PostconditionViolation.soft`); the first such candidate is returned, with the violation on `NLResult.postcondition` |
|
|
131
131
|
| `nl.synthbank` | `SHAPE_CATALOG`/`ShapeTemplate` — 9 ontology-agnostic compositional query-shape templates for synthetic few-shot bank generation |
|
|
132
|
-
| `nl.seams` | `QueryLanguageAdapter` — the eight language-specific seams: (1) grammar prompt section, (2) few-shot corpus, (3) syntax validator, (4) repair rules keyed on validator errors, (5) guardrail checks, (6) entity grounding index, (7) predicate grounding index, (8) relationship-path grounding index. Plus `ValidationResult`, `GuardrailVerdict` |
|
|
132
|
+
| `nl.seams` | `QueryLanguageAdapter` — the eight language-specific seams: (1) grammar prompt section, (2) few-shot corpus, (3) syntax validator, (4) repair rules keyed on validator errors, (5) guardrail checks, (6) entity grounding index, (7) predicate grounding index, (8) relationship-path grounding index. Optional, outside the protocol: `normalize(query) -> str`, a deterministic repair applied before validation. Plus `ValidationResult`, `GuardrailVerdict` |
|
|
133
133
|
|
|
134
134
|
## 5. Requirements
|
|
135
135
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Engine behaviour arango-cypher-py needed to drop its own NL loop
|
|
2
|
+
(arango-solutions-org/arango-cypher#24), one issue each:
|
|
3
|
+
|
|
4
|
+
* #7 — the query is found in a fenced block anywhere in the response;
|
|
5
|
+
* #8 — an adapter's optional ``normalize`` seam repairs a candidate before
|
|
6
|
+
validation;
|
|
7
|
+
* #9 — ``retry_provider_errors`` lets a provider call that raised use the
|
|
8
|
+
retry budget instead of aborting generation;
|
|
9
|
+
* #10 — ``generate(user_hint=...)`` adds a hint to the first user message.
|
|
10
|
+
|
|
11
|
+
Fakes follow ``tests/test_nl_engine.py``: a scripted provider and an adapter
|
|
12
|
+
that accepts any query containing ``SELECT``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
from test_nl_engine import FakeAdapter # tests/ is on sys.path (pytest rootdir import)
|
|
21
|
+
|
|
22
|
+
from arango_query_core.nl import NLQueryEngine
|
|
23
|
+
from arango_query_core.nl.engine import _strip_code_fence
|
|
24
|
+
|
|
25
|
+
GOOD = "SELECT ?s WHERE { ?s ?p ?o }"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ScriptedProvider:
|
|
29
|
+
"""Answers in order; an ``Exception`` in the script is raised instead."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, *answers: str | Exception) -> None:
|
|
32
|
+
self._answers = list(answers)
|
|
33
|
+
self.calls: list[tuple[str, str]] = []
|
|
34
|
+
|
|
35
|
+
def generate(self, system: str, user: str) -> tuple[str, dict[str, int]]:
|
|
36
|
+
self.calls.append((system, user))
|
|
37
|
+
answer = self._answers.pop(0)
|
|
38
|
+
if isinstance(answer, Exception):
|
|
39
|
+
raise answer
|
|
40
|
+
return answer, {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15, "cached_tokens": 0}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --- #7: the fenced query, wherever it is ---------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.parametrize(
|
|
47
|
+
("response", "query"),
|
|
48
|
+
[
|
|
49
|
+
(f"Here is the query:\n```sparql\n{GOOD}\n```\nIt lists every triple.", GOOD),
|
|
50
|
+
(f"```\n{GOOD}\n```", GOOD),
|
|
51
|
+
(f"First:\n```sparql\n{GOOD}\n```\nOr:\n```sparql\nASK {{}}\n```", GOOD),
|
|
52
|
+
(f"```sparql\n{GOOD}", GOOD), # a fence that is never closed
|
|
53
|
+
(GOOD, GOOD), # no fence at all
|
|
54
|
+
],
|
|
55
|
+
ids=["prose-around", "untagged", "first-of-two", "unclosed", "bare"],
|
|
56
|
+
)
|
|
57
|
+
def test_the_query_is_found_wherever_the_fence_is(response: str, query: str) -> None:
|
|
58
|
+
assert _strip_code_fence(response, "sparql") == query
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_prose_before_the_fence_costs_no_retry() -> None:
|
|
62
|
+
provider = ScriptedProvider(f"Sure! Here you go:\n```sparql\n{GOOD}\n```")
|
|
63
|
+
result = NLQueryEngine(provider=provider, adapter=FakeAdapter()).generate("everything")
|
|
64
|
+
assert result.ok and result.query == GOOD and result.retries == 0
|
|
65
|
+
assert len(provider.calls) == 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- #8: the normalize seam ------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class NormalizingAdapter(FakeAdapter):
|
|
72
|
+
"""Repairs a lowercase keyword the validator would reject."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, *, fail: bool = False) -> None:
|
|
75
|
+
super().__init__()
|
|
76
|
+
self.seen: list[str] = []
|
|
77
|
+
self._fail = fail
|
|
78
|
+
|
|
79
|
+
def normalize(self, query: str) -> str:
|
|
80
|
+
self.seen.append(query)
|
|
81
|
+
if self._fail:
|
|
82
|
+
raise ValueError("normalizer bug")
|
|
83
|
+
return query.replace("select", "SELECT")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_a_normalized_candidate_is_what_is_validated_and_returned() -> None:
|
|
87
|
+
adapter = NormalizingAdapter()
|
|
88
|
+
provider = ScriptedProvider("```sparql\nselect ?s WHERE { ?s ?p ?o }\n```")
|
|
89
|
+
result = NLQueryEngine(provider=provider, adapter=adapter).generate("everything")
|
|
90
|
+
assert result.ok and result.query == GOOD and result.retries == 0
|
|
91
|
+
assert adapter.seen == ["select ?s WHERE { ?s ?p ?o }"] # after the fence is stripped
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_a_retry_quotes_the_normalized_candidate() -> None:
|
|
95
|
+
adapter = NormalizingAdapter()
|
|
96
|
+
provider = ScriptedProvider("ask {}", GOOD)
|
|
97
|
+
NLQueryEngine(provider=provider, adapter=adapter, max_retries=1).generate("everything")
|
|
98
|
+
assert "```sparql\nask {}\n```" in provider.calls[1][1]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_a_normalize_that_raises_keeps_the_candidate(caplog: pytest.LogCaptureFixture) -> None:
|
|
102
|
+
provider = ScriptedProvider(GOOD)
|
|
103
|
+
with caplog.at_level(logging.WARNING):
|
|
104
|
+
result = NLQueryEngine(provider=provider, adapter=NormalizingAdapter(fail=True)).generate(
|
|
105
|
+
"everything"
|
|
106
|
+
)
|
|
107
|
+
assert result.ok and result.query == GOOD
|
|
108
|
+
assert "normalizer bug" in caplog.text
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --- #9: provider failures and the retry budget ---------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_by_default_a_provider_failure_propagates() -> None:
|
|
115
|
+
provider = ScriptedProvider(TimeoutError("timed out"), GOOD)
|
|
116
|
+
with pytest.raises(TimeoutError):
|
|
117
|
+
NLQueryEngine(provider=provider, adapter=FakeAdapter()).generate("everything")
|
|
118
|
+
assert len(provider.calls) == 1
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_opted_in_a_failed_call_uses_one_attempt() -> None:
|
|
122
|
+
provider = ScriptedProvider(TimeoutError("timed out"), GOOD)
|
|
123
|
+
engine = NLQueryEngine(provider=provider, adapter=FakeAdapter(), retry_provider_errors=True)
|
|
124
|
+
result = engine.generate("everything")
|
|
125
|
+
assert result.ok and result.query == GOOD
|
|
126
|
+
assert (result.provider_errors, result.retries, result.total_tokens) == (1, 1, 15)
|
|
127
|
+
assert provider.calls[1][1] == "everything" # nothing to repair yet: the plain question again
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_opted_in_every_call_failing_reraises_the_last_error() -> None:
|
|
131
|
+
provider = ScriptedProvider(TimeoutError("first"), ConnectionError("second"), RuntimeError("third"))
|
|
132
|
+
engine = NLQueryEngine(
|
|
133
|
+
provider=provider, adapter=FakeAdapter(), max_retries=2, retry_provider_errors=True
|
|
134
|
+
)
|
|
135
|
+
with pytest.raises(RuntimeError, match="third"):
|
|
136
|
+
engine.generate("everything")
|
|
137
|
+
assert len(provider.calls) == 3
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_opted_in_a_failure_after_a_bad_candidate_reports_that_candidate() -> None:
|
|
141
|
+
provider = ScriptedProvider("ask {}", TimeoutError("timed out"))
|
|
142
|
+
engine = NLQueryEngine(
|
|
143
|
+
provider=provider, adapter=FakeAdapter(), max_retries=1, retry_provider_errors=True
|
|
144
|
+
)
|
|
145
|
+
result = engine.generate("everything")
|
|
146
|
+
assert not result.ok and result.error == "not a SELECT" and result.provider_errors == 1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --- #10: a hint on the first user message ---------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_a_user_hint_rides_on_the_first_message_only() -> None:
|
|
153
|
+
provider = ScriptedProvider("ask {}", GOOD)
|
|
154
|
+
engine = NLQueryEngine(provider=provider, adapter=FakeAdapter(), max_retries=1)
|
|
155
|
+
result = engine.generate("everything", user_hint="Your previous query timed out. Add LIMIT.")
|
|
156
|
+
assert result.ok
|
|
157
|
+
assert provider.calls[0][1] == "everything\n\nYour previous query timed out. Add LIMIT."
|
|
158
|
+
assert "timed out" not in provider.calls[1][1]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_a_user_hint_leaves_the_system_prompt_alone() -> None:
|
|
162
|
+
plain, hinted = ScriptedProvider(GOOD), ScriptedProvider(GOOD)
|
|
163
|
+
NLQueryEngine(provider=plain, adapter=FakeAdapter()).generate("everything")
|
|
164
|
+
NLQueryEngine(provider=hinted, adapter=FakeAdapter()).generate("everything", user_hint="hint")
|
|
165
|
+
assert plain.calls[0][0] == hinted.calls[0][0]
|
|
@@ -4,7 +4,9 @@ Covers the semantics the feature promises: postconditions run only on valid
|
|
|
4
4
|
queries, share the retry budget, feed their correction back into the next
|
|
5
5
|
prompt, fail closed on exhaustion, first-violation-wins, a raising check is a
|
|
6
6
|
violation (not an outage), the rule is announced once in the system prompt,
|
|
7
|
-
and the guardrail gate still runs after.
|
|
7
|
+
and the guardrail gate still runs after. Soft violations: retried the same way,
|
|
8
|
+
returned as a fallback on exhaustion, never ahead of a hard violation whatever
|
|
9
|
+
the check order. All with fakes — no network.
|
|
8
10
|
"""
|
|
9
11
|
|
|
10
12
|
from __future__ import annotations
|
|
@@ -208,3 +210,133 @@ def test_run_postconditions_helper_empty_is_none():
|
|
|
208
210
|
ctx = PostconditionContext(schema_summary="", question="q", attempt=0)
|
|
209
211
|
assert run_postconditions("SELECT 1", None, ctx) is None
|
|
210
212
|
assert run_postconditions("SELECT 1", [], ctx) is None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class NonEmpty:
|
|
216
|
+
"""Soft stand-in for "the query returned no rows": a candidate without
|
|
217
|
+
'WHERE' is treated as empty. Sometimes empty is the right answer, so the
|
|
218
|
+
objection is soft."""
|
|
219
|
+
|
|
220
|
+
code = "non_empty"
|
|
221
|
+
|
|
222
|
+
def prompt_section(self) -> str:
|
|
223
|
+
return ""
|
|
224
|
+
|
|
225
|
+
def check(self, query: str, *, context: PostconditionContext):
|
|
226
|
+
if "WHERE" in query:
|
|
227
|
+
return None
|
|
228
|
+
return PostconditionViolation(
|
|
229
|
+
code=self.code,
|
|
230
|
+
reason="The query returned no rows.",
|
|
231
|
+
suggested_hint="Check literals and labels.",
|
|
232
|
+
soft=True,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_soft_violation_is_retried_like_a_hard_one():
|
|
237
|
+
eng = _engine(["SELECT *", "SELECT * WHERE {}"], [NonEmpty()])
|
|
238
|
+
r = eng.generate("q")
|
|
239
|
+
assert r.ok is True and r.retries == 1
|
|
240
|
+
assert r.query == "SELECT * WHERE {}"
|
|
241
|
+
assert r.postcondition is None
|
|
242
|
+
assert "Check literals and labels." in eng.provider.calls[1][1]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_soft_exhaustion_returns_first_soft_candidate():
|
|
246
|
+
eng = _engine(["SELECT 1", "SELECT 2", "SELECT 3"], [NonEmpty()], max_retries=2)
|
|
247
|
+
r = eng.generate("q")
|
|
248
|
+
assert r.ok is True and r.error == ""
|
|
249
|
+
assert r.retries == 2 # the whole budget was spent first
|
|
250
|
+
assert r.query == "SELECT 1" # the first candidate, not the last
|
|
251
|
+
assert r.postcondition is not None and r.postcondition.soft
|
|
252
|
+
assert r.postcondition.code == "non_empty"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_soft_fallback_survives_a_later_invalid_candidate():
|
|
256
|
+
eng = _engine(["SELECT 1", "nonsense"], [NonEmpty()], max_retries=1)
|
|
257
|
+
r = eng.generate("q")
|
|
258
|
+
assert r.ok is True and r.query == "SELECT 1"
|
|
259
|
+
assert r.validation is not None and r.validation.ok
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_soft_fallback_survives_a_later_hard_violation():
|
|
263
|
+
# Soft-only first candidate (has LIMIT, no WHERE); the second breaks the
|
|
264
|
+
# hard invariant. The soft candidate is the one worth returning.
|
|
265
|
+
eng = _engine(["SELECT * LIMIT 1", "SELECT *"], [NeedsLimit(), NonEmpty()], max_retries=1)
|
|
266
|
+
r = eng.generate("q")
|
|
267
|
+
assert r.ok is True and r.query == "SELECT * LIMIT 1"
|
|
268
|
+
assert r.postcondition is not None and r.postcondition.code == "non_empty"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_hard_violation_still_fails_closed_beside_a_soft_check():
|
|
272
|
+
# Every candidate breaks the hard rule first (first violation wins), so
|
|
273
|
+
# none is ever soft-only: no fallback, fail closed as before.
|
|
274
|
+
eng = _engine(["SELECT *"], [NeedsLimit(), NonEmpty()], max_retries=2)
|
|
275
|
+
r = eng.generate("q")
|
|
276
|
+
assert r.ok is False and r.query == ""
|
|
277
|
+
assert r.postcondition is not None and r.postcondition.code == "needs_limit"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_soft_fallback_still_passes_the_guardrail_gate():
|
|
281
|
+
eng = _engine(["SELECT 1"], [NonEmpty()], allow=False, max_retries=1)
|
|
282
|
+
r = eng.generate("q")
|
|
283
|
+
assert r.ok is False and r.query == ""
|
|
284
|
+
assert "tenant scope" in r.error
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_violation_is_hard_by_default():
|
|
288
|
+
assert PostconditionViolation(reason="r", suggested_hint="h").soft is False
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_hard_check_after_a_soft_one_still_applies():
|
|
292
|
+
# Regression: the soft check used to short-circuit the run, so a query
|
|
293
|
+
# breaking the hard rule listed after it came back ok as a soft fallback.
|
|
294
|
+
eng = _engine(["SELECT *"], [NonEmpty(), NeedsLimit()], max_retries=2)
|
|
295
|
+
r = eng.generate("q")
|
|
296
|
+
assert r.ok is False and r.query == ""
|
|
297
|
+
assert r.postcondition is not None and r.postcondition.code == "needs_limit"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_soft_then_hard_reports_the_hard_correction():
|
|
301
|
+
eng = _engine(["SELECT *", "SELECT * LIMIT 1 WHERE {}"], [NonEmpty(), NeedsLimit()])
|
|
302
|
+
r = eng.generate("q")
|
|
303
|
+
assert r.ok is True and r.retries == 1
|
|
304
|
+
assert "Add LIMIT 100." in eng.provider.calls[1][1] # the hard hint, not the soft one
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_raising_check_after_a_soft_one_still_fails_closed():
|
|
308
|
+
class Boom:
|
|
309
|
+
code = "boom"
|
|
310
|
+
|
|
311
|
+
def prompt_section(self):
|
|
312
|
+
return ""
|
|
313
|
+
|
|
314
|
+
def check(self, query, *, context):
|
|
315
|
+
raise ValueError("kaboom")
|
|
316
|
+
|
|
317
|
+
eng = _engine(["SELECT 1"], [NonEmpty(), Boom()], max_retries=1)
|
|
318
|
+
r = eng.generate("q")
|
|
319
|
+
assert r.ok is False and r.query == ""
|
|
320
|
+
assert r.postcondition is not None and r.postcondition.code == "boom_error"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_soft_fallback_with_no_retries():
|
|
324
|
+
eng = _engine(["SELECT 1"], [NonEmpty()], max_retries=0)
|
|
325
|
+
r = eng.generate("q")
|
|
326
|
+
assert r.ok is True and r.retries == 0 and r.query == "SELECT 1"
|
|
327
|
+
assert r.postcondition is not None and r.postcondition.soft
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_clean_last_candidate_beats_the_soft_fallback():
|
|
331
|
+
eng = _engine(["SELECT 1 LIMIT 1", "SELECT * LIMIT 1 WHERE {}"], [NeedsLimit(), NonEmpty()])
|
|
332
|
+
r = eng.generate("q")
|
|
333
|
+
assert r.ok is True and r.query == "SELECT * LIMIT 1 WHERE {}"
|
|
334
|
+
assert r.postcondition is None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_run_postconditions_prefers_hard_over_earlier_soft():
|
|
338
|
+
ctx = PostconditionContext(schema_summary="", question="q", attempt=0)
|
|
339
|
+
v = run_postconditions("SELECT *", [NonEmpty(), NeedsLimit()], ctx)
|
|
340
|
+
assert v is not None and v.code == "needs_limit" and not v.soft
|
|
341
|
+
v = run_postconditions("SELECT * LIMIT 1", [NonEmpty(), NeedsLimit()], ctx)
|
|
342
|
+
assert v is not None and v.code == "non_empty" and v.soft
|
{arango_query_core-0.2.2 → arango_query_core-0.2.4}/.cursor/rules/fix-upstream-dont-fork.mdc
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
|
{arango_query_core-0.2.2 → arango_query_core-0.2.4}/tests/test_mapping_resolver_normalization.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
|