arango-query-core 0.2.6__tar.gz → 0.2.8__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 (52) hide show
  1. arango_query_core-0.2.8/.claude/settings.json +8 -0
  2. arango_query_core-0.2.8/.claude/skills/pr-review/SKILL.md +156 -0
  3. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/CHANGELOG.md +47 -0
  4. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/PKG-INFO +2 -1
  5. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/README.md +1 -0
  6. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/nl/__init__.py +5 -0
  7. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/nl/engine.py +39 -6
  8. arango_query_core-0.2.8/arango_query_core/nl/host.py +223 -0
  9. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/nl/postconditions.py +7 -5
  10. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/docs/PRD.md +2 -1
  11. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/pyproject.toml +1 -1
  12. arango_query_core-0.2.8/tests/fixtures/nl_system_prompts_0_2_6.json +12 -0
  13. arango_query_core-0.2.8/tests/test_nl_engine_host_extensions.py +247 -0
  14. arango_query_core-0.2.8/tests/test_nl_engine_request_context.py +142 -0
  15. arango_query_core-0.2.8/tests/test_nl_host.py +235 -0
  16. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_nl_postconditions.py +25 -0
  17. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/.cursor/rules/fix-upstream-dont-fork.mdc +0 -0
  18. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/.github/workflows/ci.yml +0 -0
  19. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/.github/workflows/publish.yml +0 -0
  20. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/.gitignore +0 -0
  21. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/LICENSE +0 -0
  22. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/__init__.py +0 -0
  23. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/aql.py +0 -0
  24. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/errors.py +0 -0
  25. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/exec.py +0 -0
  26. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/extensions.py +0 -0
  27. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/mapping.py +0 -0
  28. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/nl/fewshot.py +0 -0
  29. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/nl/grounding.py +0 -0
  30. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/nl/pathindex.py +0 -0
  31. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/nl/providers.py +0 -0
  32. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/nl/seams.py +0 -0
  33. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/nl/synthbank.py +0 -0
  34. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/owl_rdflib.py +0 -0
  35. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/owl_turtle.py +0 -0
  36. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/arango_query_core/py.typed +0 -0
  37. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/fixtures/mappings/pg.owl.ttl +0 -0
  38. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_mapping_helpers.py +0 -0
  39. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_mapping_property_encoding.py +0 -0
  40. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_mapping_resolver_normalization.py +0 -0
  41. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_mapping_supertypes.py +0 -0
  42. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_mapping_vci_detection.py +0 -0
  43. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_nl_engine.py +0 -0
  44. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_nl_engine_extraction_and_resilience.py +0 -0
  45. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_nl_fewshot.py +0 -0
  46. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_nl_grounding.py +0 -0
  47. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_nl_pathindex.py +0 -0
  48. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_nl_providers.py +0 -0
  49. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_nl_synthbank.py +0 -0
  50. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_owl_rdflib.py +0 -0
  51. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_owl_turtle_roundtrip.py +0 -0
  52. {arango_query_core-0.2.6 → arango_query_core-0.2.8}/tests/test_upstream_markers.py +0 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(gh run *)",
5
+ "Bash(git worktree *)"
6
+ ]
7
+ }
8
+ }
@@ -0,0 +1,156 @@
1
+ ---
2
+ name: pr-review
3
+ description: Review an open arango-query-core pull request end to end — independently verify mergeability and the full CI gate (pytest, ruff, mypy) in an isolated worktree, trace the diff for correctness, and report a clear merge/no-merge recommendation. Use when asked to look at, review, or check a PR in this repo, or before merging one.
4
+ ---
5
+
6
+ # PR review (arango-query-core)
7
+
8
+ ## Invocation
9
+ `/pr-review <PR number>` — or just ask to look at / review a PR.
10
+
11
+ ## Purpose
12
+ Don't trust GitHub's green checkmark or a skim of the diff alone. Independently
13
+ rebuild the PR's test/lint/type gate in a scratch worktree, trace the tricky
14
+ logic by hand against the new tests, and only then recommend merging. This repo
15
+ has hit real, silent failure modes (see **Gotchas** below) that a surface-level
16
+ review misses.
17
+
18
+ ## Repo shape (read this first)
19
+ - **Primary repo:** `arango-solutions-org/arango-query-core` — PRs and issues
20
+ live here. **Secondary/mirror:** `ArthurKeen/arango-query-core` — code-only,
21
+ kept in sync by a dual-push `origin` remote; it does not carry its own PRs.
22
+ Always run `gh pr` / `gh issue` commands against the primary unless told
23
+ otherwise. (Confirm which is primary — it has changed before.)
24
+ - **Release gate:** PyPI publishing is pinned-artifact (CC-9): every merge to
25
+ `main` is a potential version bump. If the PR's `CHANGELOG.md` entry lands
26
+ under a version header that's already tagged and published, that's a bug in
27
+ the PR (or in a prior release) worth flagging, not something to wave through.
28
+
29
+ ## Protocol
30
+
31
+ ### 1. Pull the PR's shape
32
+ ```bash
33
+ gh pr view <N> --repo arango-solutions-org/arango-query-core \
34
+ --json title,body,author,headRefName,baseRefName,commits,additions,deletions,changedFiles,mergeable,statusCheckRollup
35
+ gh pr diff <N> --repo arango-solutions-org/arango-query-core
36
+ ```
37
+ Read the PR body as the author's own claim about scope and evidence (eval
38
+ numbers, measured deltas) — you're about to check whether the code backs it up.
39
+
40
+ ### 2. Independently verify mergeability
41
+ Don't trust the `mergeable` field alone — it can read `UNKNOWN` right after a
42
+ push, and (per the gotchas below) a corrupted commit graph can make GitHub
43
+ report `CONFLICTING` even when the content is actually fine, or vice versa.
44
+ Cross-check with a real history-aware merge:
45
+ ```bash
46
+ git fetch origin
47
+ git merge-tree --write-tree origin/main origin/<head-branch>
48
+ echo "exit: $?" # 0 and a single tree id = clean; conflict markers = real conflict
49
+ ```
50
+ Use the **modern 3-ref-aware form** (`--write-tree`), never the bare legacy
51
+ `git merge-tree <a> <b>` — it ignores actual commit ancestry and reports false
52
+ conflicts for a branch that already contains `main`.
53
+
54
+ ### 3. Rebuild the CI gate in an isolated worktree
55
+ Never run this against your own working tree (it may have unrelated local
56
+ changes, and installing a PR's deps into your main venv is how versions
57
+ drift). Mirror `.github/workflows/ci.yml` exactly:
58
+ ```bash
59
+ git worktree add /tmp/pr<N>-check origin/<head-branch> --detach
60
+ cd /tmp/pr<N>-check
61
+ python3 -m venv .venv-check && source .venv-check/bin/activate
62
+ pip install -q -e ".[dev]"
63
+
64
+ pytest -q # mirrors: pytest -q --tb=short
65
+ ruff check .
66
+ ruff format --check .
67
+ mypy # mirrors: mypy (files = ["arango_query_core"])
68
+ ```
69
+ All four must be clean. Note the test count and compare it to `main`'s —
70
+ a PR that doesn't grow the count for a new feature is under-tested.
71
+
72
+ Clean up when done:
73
+ ```bash
74
+ cd /Users/arthurkeen/code/arango-query-core
75
+ git worktree remove /tmp/pr<N>-check --force
76
+ ```
77
+
78
+ ### 4. Trace the diff, don't just skim it
79
+ For anything touching control flow (retry loops, fallback logic, ordering
80
+ rules), hand-trace at least the two or three trickiest test cases against the
81
+ actual code — don't assume the tests passing proves the stated semantics.
82
+ Specifically check:
83
+ - **Scope honesty** — does the PR body list what's *not* included? A PR that
84
+ quietly expands scope beyond its own description is a flag, not a bonus.
85
+ - **Protocol/seam changes** — `QueryLanguageAdapter` seams are called
86
+ *unconditionally* by `NLQueryEngine` (no `hasattr` guard). A new seam means
87
+ every existing `FakeAdapter` in the test suite needs a stub for it, or the
88
+ merged tree will crash at prompt-build time. Grep for adapter classes in
89
+ `tests/` and confirm each implements every current seam.
90
+ - **CHANGELOG placement** — does the entry land under a version header that's
91
+ already tagged (`git tag -l`) and already on PyPI? If so, the version is
92
+ effectively lying about its own contents (see Repo shape above) — flag it
93
+ even though it's not this PR's fault to fix.
94
+
95
+ ### 5. Report
96
+ State plainly: what the PR does, the hand-traced correctness notes, the
97
+ independent verification results (test count, ruff, mypy, merge-tree exit
98
+ code, CI check conclusions), any scope/changelog issues found, and an explicit
99
+ recommend-or-not. Don't hedge if everything checks out — say so and recommend
100
+ merging.
101
+
102
+ ### 6. If asked to merge
103
+ ```bash
104
+ gh pr merge <N> --repo arango-solutions-org/arango-query-core --merge --delete-branch
105
+ ```
106
+ Re-check `mergeable`/CI immediately before merging — state can change between
107
+ review and merge. After merging, **sync manually** — merging on GitHub does
108
+ not update your local clone or the mirror:
109
+ ```bash
110
+ git fetch origin && git checkout main && git merge --ff-only origin/main
111
+ git push origin main # dual-push: lands on both primary and mirror
112
+ ```
113
+ `--delete-branch` only deletes the branch on the repo the PR lives in
114
+ (primary). If the branch was ever pushed to the mirror too, delete it there
115
+ explicitly: `git push https://github.com/ArthurKeen/arango-query-core.git --delete <branch>`.
116
+
117
+ ## Gotchas (hard-won, don't re-learn these)
118
+
119
+ - **Never `git stash` mid-merge-conflict-resolution**, even for an unrelated
120
+ side-check (like "did this formatting issue pre-exist on main?"). Stashing
121
+ while `MERGE_HEAD` is set but conflicts are already resolved-and-staged
122
+ silently drops the merge state — the commit you make afterward looks fine
123
+ (correct file content) but has only **one parent**, so Git and GitHub don't
124
+ actually see it as containing `main`. Symptom: `mergeable` stays
125
+ `CONFLICTING` after a resolution that looks complete. Fix: check
126
+ `git log --format='%H %P' <commit>` — a merge commit must show two parents.
127
+ If it shows one, `git reset --hard` to before the merge and redo it cleanly
128
+ with no stash in between, then verify parent count *before* pushing.
129
+ - **A clean-looking commit can still get zero CI runs.** Twice now, a push
130
+ (once a corrupted single-parent "merge", once a plain version-bump commit
131
+ plus its tag) registered **no check-suite at all** — not even third-party
132
+ apps like GitGuardian. This isn't a code problem; it's GitHub event-delivery
133
+ flakiness. Verify with
134
+ `gh api repos/<repo>/commits/<sha>/check-suites --jq '.check_suites'`
135
+ before concluding anything is broken. A delete-and-recreate of a tag
136
+ sometimes forces redelivery; it isn't guaranteed to. Don't block a release
137
+ on it — fall back to the proven manual path (below) rather than debug
138
+ GitHub's webhook plumbing indefinitely.
139
+ - **PyPI publish fallback.** The `publish.yml` workflow uses trusted
140
+ publishing (OIDC, no token) gated to `github.repository ==
141
+ 'ArthurKeen/arango-query-core'` and only fires on a `v*` tag push. If it
142
+ doesn't fire (see above) or you need to publish immediately: build and
143
+ check locally, then upload with the token in the gitignored `.env`:
144
+ ```bash
145
+ python -m build --outdir <scratch>/dist
146
+ twine check <scratch>/dist/*
147
+ TWINE_USERNAME=__token__ TWINE_PASSWORD=$(grep '^PYPI_TOKEN=' .env | cut -d= -f2) \
148
+ twine upload --non-interactive <scratch>/dist/*
149
+ ```
150
+ If the workflow does fire later for a version already manually published,
151
+ it will just hit a harmless `400 File already exists` on the duplicate
152
+ filename — not a problem, and actually confirms trusted publishing works.
153
+ - **PyPI's project JSON can lag a few seconds after upload** — `info.version`
154
+ may show the previous release momentarily. Re-check, or check
155
+ `'<version>' in releases` / hit the version's own release-page URL directly,
156
+ before concluding an upload failed.
@@ -6,6 +6,53 @@ 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.7] - 2026-11-06
10
+
11
+ ### Added
12
+
13
+ - **Host extensions** (`arango_query_core.nl.host`). A host whose engine
14
+ accepts more than the target language can now say so without rewriting
15
+ the adapter's grammar section. `HostExtensions` declares:
16
+ - `predicates`: `HostPredicate`, a predicate the host's engine accepts
17
+ (such as a registered text match), the `Class.property` names it may be
18
+ used on, what not to write instead, and where the engine refuses it;
19
+ - `rules`: the host's own limits that are not about one property, one
20
+ sentence each;
21
+ - `properties`: `PropertyFact`, per-property facts (`type`, `required`,
22
+ `orderable`, `description`; names follow `PropertyInfo`).
23
+
24
+ `host_prompt_section()` renders them as one section that adds to the
25
+ grammar and says that everything the grammar forbids stays forbidden.
26
+ Host prose is collapsed to one line, capped at 600 characters, and has
27
+ runs of `#` collapsed, so it cannot open a prompt section or carry the
28
+ `## Examples` cache marker. Names are validated when built. All four
29
+ names are exported from `arango_query_core.nl`.
30
+ - **`NLQueryEngine.host_extensions`**, `None` by default. The section goes
31
+ straight after the grammar section, inside the cacheable prefix, before
32
+ `## Examples`. With `None`, or a declaration that renders nothing, the
33
+ system prompt is byte-identical to 0.2.6's.
34
+
35
+ ### Fixed
36
+
37
+ - The engine comment and the `nl.postconditions` docstrings claimed that
38
+ postcondition prompt sections stay inside the cacheable prefix. They are
39
+ appended last, so when a `## Examples` section is present they fall after
40
+ `split_system_for_anthropic_cache`'s split. Comments only; behaviour is
41
+ unchanged.
42
+
43
+ ## [0.2.8] - 2026-11-09
44
+
45
+ ### Added
46
+
47
+ - **`NLQueryEngine.generate(context=...)`**, `""` by default. A fact about one
48
+ request that the answer depends on, such as the date that "this quarter" or
49
+ "the last 30 days" is counted from. It follows the question in the user
50
+ message of **every** attempt, repairs included, where `user_hint` reaches the
51
+ first attempt only. It is cleaned by the host-prose rule (one line, runs of
52
+ `#` collapsed, at most 600 characters) and never reaches the system prompt,
53
+ retrieval or `PostconditionContext.question`. Empty, or empty once cleaned,
54
+ every request is byte-identical to before.
55
+
9
56
  ## [0.2.6] - 2026-10-29
10
57
 
11
58
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arango-query-core
3
- Version: 0.2.6
3
+ Version: 0.2.8
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
@@ -81,6 +81,7 @@ fabric pin one artifact instead of forking the same code three ways.
81
81
  | `arango_query_core.nl` | Language-agnostic NL→query engine: LLM providers (OpenAI / OpenRouter / Anthropic with prompt caching), BM25/dense few-shot retrieval, caller-supplied semantic postconditions, and the generate→validate→repair loop |
82
82
  | `arango_query_core.nl.seams` | `QueryLanguageAdapter` — the eight language-specific seams an adapter implements (grammar prompt, corpus, validator, repair rules, guardrails, entity/predicate/path grounding) |
83
83
  | `arango_query_core.nl.grounding` / `pathindex` | Entity, predicate, and relationship-path grounding retrieval (seams 6-8) — inject known IDs, schema conventions, and navigation hints into the prompt |
84
+ | `arango_query_core.nl.host` | `HostExtensions` — what the host's engine accepts beyond the target language (registered predicates, per-property facts, its own limits), rendered after the grammar section via `NLQueryEngine(host_extensions=...)` |
84
85
  | `arango_query_core.nl.postconditions` | Caller-supplied semantic invariants enforced through the same generate→validate→retry budget, failing closed on exhaustion |
85
86
  | `arango_query_core.nl.synthbank` | Ontology-agnostic query-shape template catalog for synthetic few-shot bank generation |
86
87
 
@@ -20,6 +20,7 @@ fabric pin one artifact instead of forking the same code three ways.
20
20
  | `arango_query_core.nl` | Language-agnostic NL→query engine: LLM providers (OpenAI / OpenRouter / Anthropic with prompt caching), BM25/dense few-shot retrieval, caller-supplied semantic postconditions, and the generate→validate→repair loop |
21
21
  | `arango_query_core.nl.seams` | `QueryLanguageAdapter` — the eight language-specific seams an adapter implements (grammar prompt, corpus, validator, repair rules, guardrails, entity/predicate/path grounding) |
22
22
  | `arango_query_core.nl.grounding` / `pathindex` | Entity, predicate, and relationship-path grounding retrieval (seams 6-8) — inject known IDs, schema conventions, and navigation hints into the prompt |
23
+ | `arango_query_core.nl.host` | `HostExtensions` — what the host's engine accepts beyond the target language (registered predicates, per-property facts, its own limits), rendered after the grammar section via `NLQueryEngine(host_extensions=...)` |
23
24
  | `arango_query_core.nl.postconditions` | Caller-supplied semantic invariants enforced through the same generate→validate→retry budget, failing closed on exhaustion |
24
25
  | `arango_query_core.nl.synthbank` | Ontology-agnostic query-shape template catalog for synthetic few-shot bank generation |
25
26
 
@@ -9,6 +9,7 @@ enters exclusively through :class:`~arango_query_core.nl.seams.QueryLanguageAdap
9
9
  from .engine import NLQueryEngine, NLResult
10
10
  from .fewshot import BM25Retriever, DenseRetriever, FewShotIndex, Retriever, cached_few_shot_index
11
11
  from .grounding import GroundedEntity, GroundedPredicate, LabelIndex, PredicateIndex
12
+ from .host import HostExtensions, HostPredicate, PropertyFact, host_prompt_section
12
13
  from .pathindex import ClassPath, ClassPathIndex
13
14
  from .postconditions import (
14
15
  Postcondition,
@@ -38,6 +39,8 @@ __all__ = [
38
39
  "GroundedEntity",
39
40
  "GroundedPredicate",
40
41
  "GuardrailVerdict",
42
+ "HostExtensions",
43
+ "HostPredicate",
41
44
  "LabelIndex",
42
45
  "LLMProvider",
43
46
  "NLQueryEngine",
@@ -48,6 +51,7 @@ __all__ = [
48
51
  "PostconditionContext",
49
52
  "PostconditionViolation",
50
53
  "PredicateIndex",
54
+ "PropertyFact",
51
55
  "QueryLanguageAdapter",
52
56
  "Retriever",
53
57
  "SHAPE_CATALOG",
@@ -55,6 +59,7 @@ __all__ = [
55
59
  "ValidationResult",
56
60
  "cached_few_shot_index",
57
61
  "get_llm_provider",
62
+ "host_prompt_section",
58
63
  "postcondition_prompt_sections",
59
64
  "run_postconditions",
60
65
  "split_system_for_anthropic_cache",
@@ -23,6 +23,7 @@ from collections.abc import Sequence
23
23
  from dataclasses import dataclass, field
24
24
  from typing import Any
25
25
 
26
+ from .host import HostExtensions, _clean, host_prompt_section
26
27
  from .postconditions import (
27
28
  Postcondition,
28
29
  PostconditionContext,
@@ -85,6 +86,11 @@ class NLQueryEngine:
85
86
  adapter's path index (seam 8) and rendered AFTER the predicate
86
87
  section — same post-cache-boundary discipline; the adapter resolves
87
88
  the anchor/target identifiers from its own seam-6/7 state (D-02).
89
+
90
+ ``host_extensions`` (:mod:`~arango_query_core.nl.host`) is rendered straight
91
+ after the grammar section, before ``## Examples``, so it is part of the
92
+ cacheable prefix. Caller postconditions' prompt sections come last; when a
93
+ ``## Examples`` section is present they therefore fall after the cache split.
88
94
  """
89
95
 
90
96
  provider: LLMProvider
@@ -103,8 +109,20 @@ class NLQueryEngine:
103
109
  on :attr:`NLResult.provider_errors`. If no attempt ever returned a
104
110
  response, the last exception is re-raised. False (the default) lets the
105
111
  first exception propagate, as before."""
112
+ host_extensions: HostExtensions | None = None
113
+ """What the host's engine accepts beyond the target language (:mod:`.host`),
114
+ rendered straight after the grammar section, inside the cacheable prefix.
115
+ ``None`` or a declaration that renders nothing leaves the system prompt
116
+ byte-identical."""
106
117
 
107
- def generate(self, question: str, *, schema_context: str = "", user_hint: str = "") -> NLResult:
118
+ def generate(
119
+ self,
120
+ question: str,
121
+ *,
122
+ schema_context: str = "",
123
+ user_hint: str = "",
124
+ context: str = "",
125
+ ) -> NLResult:
108
126
  """Generate a query for *question*.
109
127
 
110
128
  *user_hint* is appended to the **first** attempt's user message only —
@@ -112,10 +130,21 @@ class NLQueryEngine:
112
130
  failure. It leaves the system prompt (the cacheable prefix), few-shot
113
131
  retrieval and postcondition context untouched; retries carry their own
114
132
  "previous query was rejected" text.
133
+
134
+ *context* follows the question on **every** attempt, repairs included: a
135
+ fact about this request that the answer depends on, such as the date that
136
+ relative words ("this quarter", "the last 30 days") are counted from. It is
137
+ reduced to one line of at most 600 characters by the rule :mod:`.host`
138
+ applies to host prose, and it never reaches the system prompt, retrieval
139
+ or :attr:`PostconditionContext.question`, so a check that reads the
140
+ asker's words never reads it. The engine gives it no meaning. Empty, or
141
+ empty once cleaned, means a byte-identical request.
115
142
  """
116
143
  result = NLResult()
117
144
  system = self._system_prompt(question, schema_context)
118
- user = f"{question}\n\n{user_hint}" if user_hint else question
145
+ note = _clean(context)
146
+ asked = f"{question}\n\n{note}" if note else question
147
+ user = f"{asked}\n\n{user_hint}" if user_hint else asked
119
148
  last_provider_error: Exception | None = None
120
149
  failure: ValidationResult | None = None
121
150
  violation: PostconditionViolation | None = None
@@ -134,7 +163,7 @@ class NLQueryEngine:
134
163
  assert failure is not None
135
164
  hint = self.adapter.repair_hint(candidate, failure) or failure.error
136
165
  user = (
137
- f"{question}\n\n"
166
+ f"{asked}\n\n"
138
167
  f"Your previous {self.adapter.language} query was rejected:\n"
139
168
  f"```{self.adapter.language}\n{candidate}\n```\n"
140
169
  f"Problem: {hint}\n"
@@ -232,6 +261,9 @@ class NLQueryEngine:
232
261
 
233
262
  def _system_prompt(self, question: str, schema_context: str) -> str:
234
263
  sections = [self.adapter.grammar_prompt_section(schema_context)]
264
+ # Static per engine, so it sits with the grammar, before the ``## Examples``
265
+ # cache boundary. ``""`` when nothing is declared, and the join drops it.
266
+ sections.append(host_prompt_section(self.host_extensions))
235
267
  index = self.adapter.few_shot_index()
236
268
  if index is not None:
237
269
  examples = index.format_prompt_section(
@@ -255,9 +287,10 @@ class NLQueryEngine:
255
287
  if path_block:
256
288
  sections.append(path_block)
257
289
  # Caller postconditions announce their invariants up front so the model
258
- # aims to satisfy them, not just get corrected after breaking one. These
259
- # are static per call (no per-question content), so they stay inside the
260
- # cacheable prefix rather than after the few-shot cache boundary.
290
+ # aims to satisfy them, not just get corrected after breaking one. They are
291
+ # static per call but appended last: when an ``## Examples`` section is
292
+ # present, split_system_for_anthropic_cache splits there and these land in
293
+ # the uncached suffix; without one, the whole prompt is a single cached block.
261
294
  sections.extend(postcondition_prompt_sections(self.postconditions))
262
295
  return "\n\n".join(s for s in sections if s)
263
296
 
@@ -0,0 +1,223 @@
1
+ """What a host's engine accepts beyond the target language, told to the NL engine.
2
+
3
+ A host may run the generated query on an engine that accepts more than the language
4
+ the adapter teaches: a registered predicate such as a text match, per-property facts
5
+ (a datatype, whether it may be sorted, whether it is always present), and limits of
6
+ its own. Seam 1 (``grammar_prompt_section``) is adapter-owned and whole, so without
7
+ this module the only way to say any of that is to rewrite the grammar.
8
+
9
+ * :class:`HostPredicate` — a predicate the host's engine accepts in addition to the
10
+ language, with the properties it may be used on and what not to write instead.
11
+ * :class:`PropertyFact` — facts about one property that change which queries succeed.
12
+ Field names follow :class:`arango_query_core.mapping.PropertyInfo`.
13
+ * :class:`HostExtensions` — the declaration: predicates, rules (the host's limits that
14
+ are not about one property, one sentence each) and property facts.
15
+ * :func:`host_prompt_section` — the system-prompt section; ``""`` when nothing is
16
+ declared. :class:`~arango_query_core.nl.engine.NLQueryEngine` renders it straight
17
+ after the grammar section, inside the cacheable prefix, when its
18
+ ``host_extensions`` field is set.
19
+
20
+ Rules this module holds:
21
+
22
+ * **Nothing supplied, nothing changed.** ``None``, an empty declaration, a predicate
23
+ offered on no property, a fact that declares nothing and a blank rule all render
24
+ ``""``, and the engine's system prompt is then byte-identical to one built without
25
+ the field.
26
+ * **It adds to the grammar and never lifts its rules.** One fixed sentence,
27
+ :data:`HOST_RULE`, says so in the section itself.
28
+ * **Host strings cannot open a prompt section.** Every piece of host prose is
29
+ collapsed to one line, capped at :data:`LINE_MAX` characters, and has its runs of
30
+ ``#`` collapsed to one, so it can neither start a heading nor carry the
31
+ ``## Examples`` marker that
32
+ :func:`~arango_query_core.nl.providers.split_system_for_anthropic_cache` splits at.
33
+ * **Names are validated when built.** Predicate names, namespaces and
34
+ ``Class.property`` entries that are not plain names raise :class:`ValueError`, so
35
+ they never reach the prompt.
36
+
37
+ Checking that the model *used* a declared predicate is language-specific and stays
38
+ with the adapter or a host :class:`~arango_query_core.nl.postconditions.Postcondition`.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import re
44
+ from dataclasses import dataclass
45
+
46
+ from .grounding import _CONTROL_CHARS_RE, _WHITESPACE_RUN_RE
47
+
48
+ __all__ = [
49
+ "FACTS_HEADER",
50
+ "HOST_HEADER",
51
+ "HOST_RULE",
52
+ "LINE_MAX",
53
+ "RULES_HEADER",
54
+ "HostExtensions",
55
+ "HostPredicate",
56
+ "PropertyFact",
57
+ "host_prompt_section",
58
+ ]
59
+
60
+ HOST_HEADER = "## What this engine accepts beyond the rules above"
61
+ HOST_RULE = (
62
+ "These predicates are allowed in addition to the rules above, and only on the "
63
+ "properties listed for each. Everything the rules above forbid stays forbidden."
64
+ )
65
+ """The one fixed sentence: the section adds to the grammar, never overrides it."""
66
+ RULES_HEADER = "## Limits this engine adds to the rules above"
67
+ FACTS_HEADER = "## Facts about properties"
68
+
69
+ LINE_MAX = 600
70
+ """Cap on one piece of host prose. Host prose is a sentence or two, so the cap is
71
+ larger than a grounding label's (200), which cut a predicate's meaning short."""
72
+
73
+ _NAME_RE = re.compile(r"^[A-Za-z_][\w-]*(?::[A-Za-z_][\w-]*)?$")
74
+ """A plain or prefixed name, e.g. ``ex:textMatch``."""
75
+ _PROPERTY_RE = re.compile(r"^[A-Za-z_][\w-]*\.[A-Za-z_][\w-]*$")
76
+ """A ``Class.property`` name."""
77
+ _IRI_FORBIDDEN = frozenset('<>"{}|^`\\ \t\r\n')
78
+ _HASH_RUN_RE = re.compile(r"#{2,}")
79
+
80
+
81
+ def _clean(text: str) -> str:
82
+ """One line of host prose that cannot open a prompt section.
83
+
84
+ Control characters and whitespace runs collapse to one space (the rule
85
+ ``grounding._sanitize_label`` applies to labels), runs of ``#`` collapse to one
86
+ so no heading or ``## Examples`` marker survives, and the result is capped at
87
+ :data:`LINE_MAX`. Clean single-line prose without ``##`` passes through unchanged.
88
+ """
89
+ cleaned = _WHITESPACE_RUN_RE.sub(" ", _CONTROL_CHARS_RE.sub(" ", text))
90
+ return _HASH_RUN_RE.sub("#", cleaned).strip()[:LINE_MAX].rstrip()
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class HostPredicate:
95
+ """A predicate the host's engine accepts in addition to the language."""
96
+
97
+ name: str
98
+ """As the model writes it, e.g. ``ex:textMatch``."""
99
+ iri: str
100
+ """The namespace the name's prefix stands for, for the PREFIX line; ``""`` when the
101
+ name has no prefix."""
102
+ meaning: str
103
+ """One sentence, in the host's words."""
104
+ usage: str
105
+ """One example in the target language. It may span lines; each line is cleaned."""
106
+ applies_to: tuple[str, ...] = ()
107
+ """``Class.property`` names it may be used on. Empty means it is offered nowhere,
108
+ and a predicate offered nowhere is left out of the prompt."""
109
+ instead_of: str = ""
110
+ """What not to write instead, e.g. "an exact = comparison on these properties"."""
111
+ restrictions: tuple[str, ...] = ()
112
+ """Where the host's engine refuses it, one short clause each, e.g. "inside OPTIONAL"."""
113
+
114
+ def __post_init__(self) -> None:
115
+ if not _NAME_RE.match(self.name):
116
+ raise ValueError(f"host predicate name {self.name!r} is not a plain or prefixed name")
117
+ if ":" in self.name and not self.iri:
118
+ raise ValueError(f"host predicate {self.name!r} has a prefix but no namespace iri")
119
+ if any(ch in _IRI_FORBIDDEN for ch in self.iri):
120
+ raise ValueError(f"host predicate {self.name!r}: namespace iri {self.iri!r} is malformed")
121
+ bad = [p for p in self.applies_to if not _PROPERTY_RE.match(p)]
122
+ if bad:
123
+ raise ValueError(f"host predicate {self.name!r}: not Class.property names: {bad}")
124
+ if not _clean(self.meaning) or not _clean(self.usage):
125
+ raise ValueError(f"host predicate {self.name!r} needs a meaning and a usage example")
126
+
127
+ def prompt_lines(self) -> list[str]:
128
+ """The predicate's lines in the section."""
129
+ prefix = self.name.split(":", 1)[0] if ":" in self.name else ""
130
+ lines = [f"- {self.name} — {_clean(self.meaning)}"]
131
+ if prefix:
132
+ lines.append(f" Declare it with: PREFIX {prefix}: <{self.iri}>")
133
+ lines.append(f" Use it only on: {', '.join(self.applies_to)}")
134
+ instead_of = _clean(self.instead_of)
135
+ if instead_of:
136
+ lines.append(f" On those properties use it instead of {instead_of}.")
137
+ restrictions = [r for r in map(_clean, self.restrictions) if r]
138
+ if restrictions:
139
+ lines.append(" Refused: " + "; ".join(restrictions) + ".")
140
+ lines.append(" Example:")
141
+ lines += [f" {row}" for row in map(_clean, self.usage.splitlines()) if row]
142
+ return lines
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class PropertyFact:
147
+ """Facts about one property. ``None`` or ``""`` means "not declared" and renders nothing."""
148
+
149
+ entity: str
150
+ property: str
151
+ type: str = ""
152
+ """The value's datatype in the host's words, e.g. ``date``."""
153
+ required: bool | None = None
154
+ """Whether every entity has the property."""
155
+ description: str = ""
156
+ """What the property means."""
157
+ orderable: bool | None = None
158
+ """Whether the host's engine may sort or deduplicate on it."""
159
+
160
+ def __post_init__(self) -> None:
161
+ if not _PROPERTY_RE.match(f"{self.entity}.{self.property}"):
162
+ raise ValueError(f"property fact {self.entity!r}.{self.property!r} is not a Class.property name")
163
+
164
+ def line(self) -> str:
165
+ """The fact's line in the section; ``""`` when it declares nothing."""
166
+ parts = []
167
+ type_ = _clean(self.type)
168
+ if type_:
169
+ parts.append(f"type {type_}")
170
+ if self.required is not None:
171
+ parts.append("always present" if self.required else "may be missing")
172
+ if self.orderable is not None:
173
+ parts.append(
174
+ "may be sorted or deduplicated" if self.orderable else "may not be sorted or deduplicated"
175
+ )
176
+ description = _clean(self.description)
177
+ if description:
178
+ parts.append(description)
179
+ return f"- {self.entity}.{self.property}: " + "; ".join(parts) if parts else ""
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class HostExtensions:
184
+ """What the host's engine accepts beyond the target language."""
185
+
186
+ predicates: tuple[HostPredicate, ...] = ()
187
+ properties: tuple[PropertyFact, ...] = ()
188
+ rules: tuple[str, ...] = ()
189
+ """Limits the host's engine adds that are not about one property, one sentence each."""
190
+
191
+ @property
192
+ def empty(self) -> bool:
193
+ """True when nothing would render."""
194
+ return (
195
+ not any(p.applies_to for p in self.predicates)
196
+ and not any(f.line() for f in self.properties)
197
+ and not any(_clean(r) for r in self.rules)
198
+ )
199
+
200
+
201
+ def host_prompt_section(ext: HostExtensions | None) -> str:
202
+ """The system-prompt section for *ext*; ``""`` when it declares nothing that renders.
203
+
204
+ Blocks, each present only when it has content: the offered predicates under
205
+ :data:`HOST_HEADER` with :data:`HOST_RULE`, then the rules under
206
+ :data:`RULES_HEADER`, then the property facts under :data:`FACTS_HEADER`.
207
+ """
208
+ if ext is None or ext.empty:
209
+ return ""
210
+ blocks: list[str] = []
211
+ offered = [p for p in ext.predicates if p.applies_to]
212
+ if offered:
213
+ lines = [HOST_HEADER, HOST_RULE]
214
+ for pred in offered:
215
+ lines += pred.prompt_lines()
216
+ blocks.append("\n".join(lines))
217
+ rules = [f"- {r}" for r in map(_clean, ext.rules) if r]
218
+ if rules:
219
+ blocks.append("\n".join([RULES_HEADER, *rules]))
220
+ facts = [line for line in (f.line() for f in ext.properties) if line]
221
+ if facts:
222
+ blocks.append("\n".join([FACTS_HEADER, *facts]))
223
+ return "\n\n".join(blocks)
@@ -12,8 +12,8 @@ a required filter. No syntactic check sees it and no human sees it in a demo.
12
12
 
13
13
  A postcondition is a caller-supplied invariant. It rides the *same* retry
14
14
  budget as parse and translate: the model is told the rule up front (via
15
- :meth:`Postcondition.prompt_section`, rendered once into the cacheable system
16
- prefix), and a violation is fed back into the next prompt exactly like a
15
+ :meth:`Postcondition.prompt_section`, rendered once at the end of the system
16
+ prompt), 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
18
  than just reject it, and fails closed when the budget is spent — unless a
19
19
  candidate drew only a *soft* violation (``PostconditionViolation.soft``), in
@@ -167,9 +167,11 @@ def postcondition_prompt_sections(
167
167
  ) -> list[str]:
168
168
  """Collect non-empty ``prompt_section()`` output, in caller order.
169
169
 
170
- Rendered once into the cacheable system prefix by the engine, so a check
171
- that raises here (a broken ``prompt_section``) is skipped rather than
172
- aborting the whole generation.
170
+ Rendered once, at the end of the system prompt, by the engine. When the
171
+ prompt has an ``## Examples`` section the sections fall after
172
+ :func:`~arango_query_core.nl.providers.split_system_for_anthropic_cache`'s
173
+ split, in the uncached suffix. A check that raises here (a broken
174
+ ``prompt_section``) is skipped rather than aborting the whole generation.
173
175
  """
174
176
  if not postconditions:
175
177
  return []
@@ -122,10 +122,11 @@ 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`. 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. |
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. `generate(context=...)` places a per-request fact (such as the date relative words are counted from) after the question on every attempt, repairs included, cleaned to one line of at most 600 characters; it never reaches the system prompt, retrieval or postconditions, and empty means a byte-identical request. 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. An optional `host_extensions` renders `nl.host`'s section straight after the grammar section, inside the cacheable prefix; unset or empty, the system prompt is unchanged. |
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
+ | `nl.host` | `HostExtensions` (`HostPredicate`, `PropertyFact`, `rules`) and `host_prompt_section` — what a host's engine accepts beyond the target language (a registered predicate, per-property facts, its own limits), rendered as one system-prompt section that adds to the grammar and never lifts its rules. Host prose is one line, capped, and cannot open a section or carry the `## Examples` cache marker |
129
130
  | `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
131
  | `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
132
  | `nl.synthbank` | `SHAPE_CATALOG`/`ShapeTemplate` — 9 ontology-agnostic compositional query-shape templates for synthetic few-shot bank generation |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arango-query-core"
3
- version = "0.2.6"
3
+ version = "0.2.8"
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" }