arango-query-core 0.2.3__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 (44) hide show
  1. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/CHANGELOG.md +31 -0
  2. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/PKG-INFO +1 -1
  3. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/nl/engine.py +69 -7
  4. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/nl/seams.py +9 -0
  5. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/docs/PRD.md +2 -2
  6. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/pyproject.toml +1 -1
  7. arango_query_core-0.2.4/tests/test_nl_engine_extraction_and_resilience.py +165 -0
  8. arango_query_core-0.2.3/.claude/settings.json +0 -8
  9. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/.cursor/rules/fix-upstream-dont-fork.mdc +0 -0
  10. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/.github/workflows/ci.yml +0 -0
  11. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/.github/workflows/publish.yml +0 -0
  12. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/.gitignore +0 -0
  13. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/LICENSE +0 -0
  14. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/README.md +0 -0
  15. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/__init__.py +0 -0
  16. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/aql.py +0 -0
  17. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/errors.py +0 -0
  18. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/exec.py +0 -0
  19. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/extensions.py +0 -0
  20. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/mapping.py +0 -0
  21. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/nl/__init__.py +0 -0
  22. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/nl/fewshot.py +0 -0
  23. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/nl/grounding.py +0 -0
  24. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/nl/pathindex.py +0 -0
  25. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/nl/postconditions.py +0 -0
  26. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/nl/providers.py +0 -0
  27. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/nl/synthbank.py +0 -0
  28. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/owl_rdflib.py +0 -0
  29. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/owl_turtle.py +0 -0
  30. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/arango_query_core/py.typed +0 -0
  31. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/tests/fixtures/mappings/pg.owl.ttl +0 -0
  32. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/tests/test_mapping_helpers.py +0 -0
  33. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/tests/test_mapping_resolver_normalization.py +0 -0
  34. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/tests/test_mapping_vci_detection.py +0 -0
  35. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/tests/test_nl_engine.py +0 -0
  36. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/tests/test_nl_fewshot.py +0 -0
  37. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/tests/test_nl_grounding.py +0 -0
  38. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/tests/test_nl_pathindex.py +0 -0
  39. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/tests/test_nl_postconditions.py +0 -0
  40. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/tests/test_nl_providers.py +0 -0
  41. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/tests/test_nl_synthbank.py +0 -0
  42. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/tests/test_owl_rdflib.py +0 -0
  43. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/tests/test_owl_turtle_roundtrip.py +0 -0
  44. {arango_query_core-0.2.3 → arango_query_core-0.2.4}/tests/test_upstream_markers.py +0 -0
@@ -6,6 +6,37 @@ 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
+
9
40
  ## [0.2.3] - 2026-10-21
10
41
 
11
42
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arango-query-core
3
- Version: 0.2.3
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,6 +35,8 @@ 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:
@@ -58,6 +62,8 @@ class NLResult:
58
62
  guardrail: GuardrailVerdict | None = None
59
63
  validation: ValidationResult | None = None
60
64
  postcondition: PostconditionViolation | None = None
65
+ provider_errors: int = 0
66
+ """Provider calls that raised and were retried (``retry_provider_errors``)."""
61
67
 
62
68
 
63
69
  @dataclass
@@ -91,11 +97,26 @@ class NLQueryEngine:
91
97
  guardrail_context: dict[str, Any] = field(default_factory=dict)
92
98
  postconditions: Sequence[Postcondition] = ()
93
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*.
94
109
 
95
- def generate(self, question: str, *, schema_context: str = "") -> NLResult:
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
+ """
96
116
  result = NLResult()
97
117
  system = self._system_prompt(question, schema_context)
98
- user = question
118
+ user = f"{question}\n\n{user_hint}" if user_hint else question
119
+ last_provider_error: Exception | None = None
99
120
  failure: ValidationResult | None = None
100
121
  violation: PostconditionViolation | None = None
101
122
  candidate = ""
@@ -104,7 +125,7 @@ class NLQueryEngine:
104
125
  fallback: tuple[str, ValidationResult, PostconditionViolation] | None = None
105
126
 
106
127
  for attempt in range(self.max_retries + 1):
107
- if attempt > 0:
128
+ if attempt > 0 and failure is not None:
108
129
  # A postcondition rejection carries its own correction text;
109
130
  # otherwise fall back to the adapter's syntactic repair hint.
110
131
  if violation is not None:
@@ -119,11 +140,26 @@ class NLQueryEngine:
119
140
  f"Problem: {hint}\n"
120
141
  f"Return a corrected query only."
121
142
  )
143
+ if attempt > 0:
122
144
  result.retries = attempt
123
- content, usage = self.provider.generate(system, user)
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
124
160
  for key in _USAGE_KEYS:
125
161
  setattr(result, key, getattr(result, key) + int(usage.get(key, 0) or 0))
126
- candidate = _strip_code_fence(content, self.adapter.language)
162
+ candidate = self._normalize(_strip_code_fence(content, self.adapter.language))
127
163
  failure = self.adapter.validate(candidate)
128
164
  result.validation = failure
129
165
  if not failure.ok:
@@ -156,6 +192,9 @@ class NLQueryEngine:
156
192
  candidate, result.validation, result.postcondition = fallback
157
193
  failure, violation = result.validation, None
158
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
159
198
  if failure is None or not failure.ok:
160
199
  result.error = failure.error if failure else "provider returned nothing"
161
200
  return result
@@ -176,6 +215,21 @@ class NLQueryEngine:
176
215
  result.ok = True
177
216
  return result
178
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
+
179
233
  def _system_prompt(self, question: str, schema_context: str) -> str:
180
234
  sections = [self.adapter.grammar_prompt_section(schema_context)]
181
235
  index = self.adapter.few_shot_index()
@@ -208,14 +262,22 @@ class NLQueryEngine:
208
262
  return "\n\n".join(s for s in sections if s)
209
263
 
210
264
 
265
+ _FENCED_BLOCK = re.compile(r"```[^\n`]*\n(.*?)```", re.DOTALL)
266
+
267
+
211
268
  def _strip_code_fence(content: str, language: str) -> str:
212
269
  """Extract the query from a fenced code block, tolerating bare text.
213
270
 
214
271
  LLMs frequently wrap the answer in ```<lang> fences despite the
215
- output-format contract; accept fenced, language-tagged-fenced, and
216
- bare responses uniformly so the validator sees only query text.
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.
217
276
  """
218
277
  text = content.strip()
278
+ block = _FENCED_BLOCK.search(text)
279
+ if block is not None:
280
+ return block.group(1).strip()
219
281
  if not text.startswith("```"):
220
282
  return text
221
283
  first_newline = text.find("\n")
@@ -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
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arango-query-core"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  description = "Shared conceptual-model and NL substrate for ArangoDB query transpilers (arango-cypher-py, arango-sparql-py)"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -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]
@@ -1,8 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(gh run *)",
5
- "Bash(git worktree *)"
6
- ]
7
- }
8
- }