arango-query-core 0.2.3__tar.gz → 0.2.5__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.3 → arango_query_core-0.2.5}/CHANGELOG.md +57 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/PKG-INFO +1 -1
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/__init__.py +2 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/mapping.py +100 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/nl/engine.py +69 -7
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/nl/seams.py +9 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/owl_turtle.py +30 -6
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/docs/PRD.md +21 -3
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/pyproject.toml +1 -1
- arango_query_core-0.2.5/tests/test_mapping_supertypes.py +190 -0
- arango_query_core-0.2.5/tests/test_nl_engine_extraction_and_resilience.py +165 -0
- arango_query_core-0.2.3/.claude/settings.json +0 -8
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/.cursor/rules/fix-upstream-dont-fork.mdc +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/.github/workflows/ci.yml +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/.github/workflows/publish.yml +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/.gitignore +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/LICENSE +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/README.md +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/aql.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/errors.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/exec.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/extensions.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/nl/__init__.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/nl/fewshot.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/nl/grounding.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/nl/pathindex.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/nl/postconditions.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/nl/providers.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/nl/synthbank.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/owl_rdflib.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/arango_query_core/py.typed +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/tests/fixtures/mappings/pg.owl.ttl +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/tests/test_mapping_helpers.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/tests/test_mapping_resolver_normalization.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/tests/test_mapping_vci_detection.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/tests/test_nl_engine.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/tests/test_nl_fewshot.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/tests/test_nl_grounding.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/tests/test_nl_pathindex.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/tests/test_nl_postconditions.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/tests/test_nl_providers.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/tests/test_nl_synthbank.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/tests/test_owl_rdflib.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/tests/test_owl_turtle_roundtrip.py +0 -0
- {arango_query_core-0.2.3 → arango_query_core-0.2.5}/tests/test_upstream_markers.py +0 -0
|
@@ -6,6 +6,63 @@ 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.5] - 2026-10-26
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Supertypes (label hierarchies).** A supertype is a label that stands for
|
|
14
|
+
other labels, such as LDBC's `Message`, which is every `Post` and every
|
|
15
|
+
`Comment`. It is mapped with one of two new entity styles,
|
|
16
|
+
`SUPERTYPE_STYLES`:
|
|
17
|
+
- `MULTI_LABEL`: the members share a collection and list their labels in
|
|
18
|
+
an array field (`labelsField`, `labelValue`);
|
|
19
|
+
- `UNION`: the members have their own collections (`members`).
|
|
20
|
+
- **New resolver methods.** `MappingResolver.is_supertype()` and
|
|
21
|
+
`supertype_members()`. The latter flattens nested supertypes and refuses
|
|
22
|
+
unknown members and cycles with `INVALID_MAPPING`. A supertype without
|
|
23
|
+
properties of its own has its members' properties.
|
|
24
|
+
- **`edge_constrains_target`** treats a supertype target as guaranteed when
|
|
25
|
+
every class an edge endpoint can be is a member: `HAS_CREATOR`'s domain
|
|
26
|
+
`[Post, Comment]` guarantees `Message`.
|
|
27
|
+
- **OWL/Turtle** writes and reads the hierarchy as `rdfs:subClassOf`.
|
|
28
|
+
`turtle_to_mapping` no longer invents a `collectionName` for a `UNION`
|
|
29
|
+
supertype. The rdflib parser does not read `rdfs:subClassOf` yet.
|
|
30
|
+
|
|
31
|
+
The change is additive: mappings without the new styles resolve exactly as
|
|
32
|
+
before. A consumer that switches on `style` and does not handle the new
|
|
33
|
+
values rejects them rather than misreading them.
|
|
34
|
+
|
|
35
|
+
## [0.2.4] - 2026-10-21
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- `QueryLanguageAdapter` may define `normalize(query) -> str`, an optional seam
|
|
40
|
+
`NLQueryEngine` calls on every candidate after the fence is stripped and
|
|
41
|
+
before `validate`. Use it for a deterministic repair that would otherwise
|
|
42
|
+
cost an LLM retry; nl2cypher uses it to rewrite labels the schema does not
|
|
43
|
+
have. What it returns is validated, quoted in retries and returned. A
|
|
44
|
+
`normalize` that raises is logged and the candidate kept. It is not a
|
|
45
|
+
protocol member, so existing adapters are unaffected. #8
|
|
46
|
+
- `NLQueryEngine.generate(..., user_hint="")` appends a caller hint to the
|
|
47
|
+
first attempt's user message only, for example "your previous query
|
|
48
|
+
failed with …" when re-asking. It leaves the system prompt (the cacheable
|
|
49
|
+
prefix), few-shot retrieval and postcondition context untouched. #10
|
|
50
|
+
- `NLQueryEngine(retry_provider_errors=True)`: a provider call that raises (a
|
|
51
|
+
timeout, a 5xx) uses one attempt of `max_retries`, and the loop goes on.
|
|
52
|
+
`NLResult.provider_errors` counts them. If no attempt returned a response,
|
|
53
|
+
the last exception is re-raised. The default (`False`) keeps today's
|
|
54
|
+
behaviour: the first exception propagates. #9
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- The engine finds the query in the first fenced block anywhere in the
|
|
59
|
+
response. It used to strip a fence only when the response started with
|
|
60
|
+
one, so "Here is the query: ```…```" failed validation and spent a retry.
|
|
61
|
+
A response that opens a fence without closing it yields what follows the
|
|
62
|
+
fence, and a response with no fence is returned as is, as before. When a
|
|
63
|
+
response has two fenced blocks, the first is taken; it used to be
|
|
64
|
+
everything from the first opening fence to the last closing one. #7
|
|
65
|
+
|
|
9
66
|
## [0.2.3] - 2026-10-21
|
|
10
67
|
|
|
11
68
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: arango-query-core
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
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
|
|
@@ -4,6 +4,7 @@ from .exec import AqlExecutor, explain_aql, safe_execute
|
|
|
4
4
|
from .extensions import ExtensionPolicy, ExtensionRegistry
|
|
5
5
|
from .mapping import (
|
|
6
6
|
COLLECTION_NAME_RE,
|
|
7
|
+
SUPERTYPE_STYLES,
|
|
7
8
|
IndexInfo,
|
|
8
9
|
MappingBundle,
|
|
9
10
|
MappingResolver,
|
|
@@ -23,6 +24,7 @@ except ImportError:
|
|
|
23
24
|
|
|
24
25
|
__all__ = [
|
|
25
26
|
"COLLECTION_NAME_RE",
|
|
27
|
+
"SUPERTYPE_STYLES",
|
|
26
28
|
"AqlExecutor",
|
|
27
29
|
"AqlFragment",
|
|
28
30
|
"AqlQuery",
|
|
@@ -10,6 +10,28 @@ from .errors import CoreError
|
|
|
10
10
|
|
|
11
11
|
JsonObj = dict[str, Any]
|
|
12
12
|
|
|
13
|
+
#: Entity mapping styles for a *supertype*: a label whose instances are the
|
|
14
|
+
#: instances of other labels (LDBC's ``Message`` is every ``Post`` and every
|
|
15
|
+
#: ``Comment``).
|
|
16
|
+
#:
|
|
17
|
+
#: * ``MULTI_LABEL`` — the members share one collection and every document
|
|
18
|
+
#: lists all its labels in an array field::
|
|
19
|
+
#:
|
|
20
|
+
#: {"style": "MULTI_LABEL", "collectionName": "nodes",
|
|
21
|
+
#: "labelsField": "labels", "labelValue": "Message"}
|
|
22
|
+
#:
|
|
23
|
+
#: matches documents of ``nodes`` whose ``labels`` contain ``"Message"``.
|
|
24
|
+
#: * ``UNION`` — the members live in their own collections::
|
|
25
|
+
#:
|
|
26
|
+
#: {"style": "UNION", "members": ["Post", "Comment"]}
|
|
27
|
+
#:
|
|
28
|
+
#: Members come from the physical ``members`` list or, failing that, the
|
|
29
|
+
#: conceptual entity's ``subtypes``. They must be mapped labels; a member may
|
|
30
|
+
#: itself be a supertype (members are flattened). Consumers that switch on
|
|
31
|
+
#: ``style`` and do not know these values fail closed, which is why supertypes
|
|
32
|
+
#: get styles of their own rather than extra fields on ``LABEL``/``COLLECTION``.
|
|
33
|
+
SUPERTYPE_STYLES = frozenset({"MULTI_LABEL", "UNION"})
|
|
34
|
+
|
|
13
35
|
|
|
14
36
|
# ArangoDB collection-name grammar (per the official docs):
|
|
15
37
|
# * starts with a letter or underscore
|
|
@@ -391,6 +413,13 @@ class MappingResolver:
|
|
|
391
413
|
return {}
|
|
392
414
|
|
|
393
415
|
props_raw = mapping.get("properties", {})
|
|
416
|
+
if not props_raw and mapping.get("style") in SUPERTYPE_STYLES:
|
|
417
|
+
# A supertype without its own property list has its members' properties.
|
|
418
|
+
merged: dict[str, PropertyInfo] = {}
|
|
419
|
+
for member in self.supertype_members(label_or_type):
|
|
420
|
+
for name, info in self.resolve_properties(member).items():
|
|
421
|
+
merged.setdefault(name, info)
|
|
422
|
+
return merged
|
|
394
423
|
if not isinstance(props_raw, dict):
|
|
395
424
|
return {}
|
|
396
425
|
|
|
@@ -518,7 +547,16 @@ class MappingResolver:
|
|
|
518
547
|
be in the set **and** every class in the union must map to the same
|
|
519
548
|
physical collection (otherwise multiple collections are reachable and
|
|
520
549
|
IS_SAME_COLLECTION is still needed).
|
|
550
|
+
|
|
551
|
+
A supertype target is guaranteed when every class the endpoint can be
|
|
552
|
+
is one of its members: ``HAS_CREATOR``'s domain ``[Post, Comment]``
|
|
553
|
+
guarantees ``Message``.
|
|
521
554
|
"""
|
|
555
|
+
if self.is_supertype(target_label):
|
|
556
|
+
classes = [endpoint] if isinstance(endpoint, str) else list(endpoint or [])
|
|
557
|
+
members = set(self.supertype_members(target_label))
|
|
558
|
+
return bool(classes) and all(c in members for c in classes)
|
|
559
|
+
|
|
522
560
|
if isinstance(endpoint, str):
|
|
523
561
|
return endpoint == target_label
|
|
524
562
|
|
|
@@ -645,6 +683,68 @@ class MappingResolver:
|
|
|
645
683
|
"""Check if any index on the relationship's edge collection has VCI enabled."""
|
|
646
684
|
return any(idx.vci for idx in self.resolve_indexes(rel_type))
|
|
647
685
|
|
|
686
|
+
# ------------------------------------------------------------------
|
|
687
|
+
# Supertypes (label hierarchies)
|
|
688
|
+
# ------------------------------------------------------------------
|
|
689
|
+
|
|
690
|
+
def is_supertype(self, label: str) -> bool:
|
|
691
|
+
"""True when *label* is mapped with a supertype style (see :data:`SUPERTYPE_STYLES`)."""
|
|
692
|
+
try:
|
|
693
|
+
return self.resolve_entity(label).get("style") in SUPERTYPE_STYLES
|
|
694
|
+
except CoreError:
|
|
695
|
+
return False
|
|
696
|
+
|
|
697
|
+
def _declared_members(self, label: str, emap: JsonObj) -> list[str]:
|
|
698
|
+
members = emap.get("members")
|
|
699
|
+
if members is None:
|
|
700
|
+
for ent in self.bundle.conceptual_schema.get("entities", []) or []:
|
|
701
|
+
if isinstance(ent, dict) and ent.get("name") == label:
|
|
702
|
+
members = ent.get("subtypes")
|
|
703
|
+
break
|
|
704
|
+
if not isinstance(members, list) or not members or not all(isinstance(m, str) and m for m in members):
|
|
705
|
+
raise CoreError(
|
|
706
|
+
f"Supertype {label!r} declares no members: give the physical mapping a 'members' list "
|
|
707
|
+
f"or the conceptual entity a 'subtypes' list",
|
|
708
|
+
code="INVALID_MAPPING",
|
|
709
|
+
)
|
|
710
|
+
return members
|
|
711
|
+
|
|
712
|
+
def supertype_members(self, label: str) -> list[str]:
|
|
713
|
+
"""The concrete (non-supertype) labels a supertype stands for, in declaration order.
|
|
714
|
+
|
|
715
|
+
Nested supertypes are flattened. Raises ``INVALID_MAPPING`` for a
|
|
716
|
+
member that is not mapped or a cycle; raises ``MAPPING_NOT_FOUND`` if
|
|
717
|
+
*label* is not mapped, and ``INVALID_MAPPING`` if it is not a supertype.
|
|
718
|
+
"""
|
|
719
|
+
out: list[str] = []
|
|
720
|
+
|
|
721
|
+
def visit(name: str, path: tuple[str, ...]) -> None:
|
|
722
|
+
if name in path:
|
|
723
|
+
raise CoreError(
|
|
724
|
+
f"Supertype cycle: {' -> '.join((*path, name))}",
|
|
725
|
+
code="INVALID_MAPPING",
|
|
726
|
+
)
|
|
727
|
+
try:
|
|
728
|
+
emap = self.resolve_entity(name)
|
|
729
|
+
except CoreError as e:
|
|
730
|
+
if path:
|
|
731
|
+
raise CoreError(
|
|
732
|
+
f"Supertype {path[-1]!r} lists member {name!r}, which is not mapped",
|
|
733
|
+
code="INVALID_MAPPING",
|
|
734
|
+
) from e
|
|
735
|
+
raise
|
|
736
|
+
if emap.get("style") not in SUPERTYPE_STYLES:
|
|
737
|
+
if not path:
|
|
738
|
+
raise CoreError(f"{name!r} is not a supertype", code="INVALID_MAPPING")
|
|
739
|
+
if name not in out:
|
|
740
|
+
out.append(name)
|
|
741
|
+
return
|
|
742
|
+
for member in self._declared_members(name, emap):
|
|
743
|
+
visit(member, (*path, name))
|
|
744
|
+
|
|
745
|
+
visit(label, ())
|
|
746
|
+
return out
|
|
747
|
+
|
|
648
748
|
def all_entity_labels(self) -> list[str]:
|
|
649
749
|
"""Return all entity type labels defined in the mapping."""
|
|
650
750
|
entities = self.bundle.physical_mapping.get("entities", {})
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
216
|
-
|
|
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
|
|
@@ -62,6 +62,13 @@ def mapping_to_turtle(bundle: MappingBundle) -> str:
|
|
|
62
62
|
lines.append(f':{name} phys:typeField "{tf}" .')
|
|
63
63
|
if tv:
|
|
64
64
|
lines.append(f':{name} phys:typeValue "{tv}" .')
|
|
65
|
+
if style == "MULTI_LABEL":
|
|
66
|
+
for annot in ("labelsField", "labelValue"):
|
|
67
|
+
if emap.get(annot):
|
|
68
|
+
lines.append(f':{name} phys:{annot} "{emap[annot]}" .')
|
|
69
|
+
# A supertype's members are its OWL subclasses.
|
|
70
|
+
for member in emap.get("members") or entity_def.get("subtypes") or []:
|
|
71
|
+
lines.append(f":{member} rdfs:subClassOf :{name} .")
|
|
65
72
|
|
|
66
73
|
for prop in entity_def.get("properties", []):
|
|
67
74
|
pname = prop.get("name", "")
|
|
@@ -127,6 +134,7 @@ def turtle_to_mapping(turtle: str) -> MappingBundle:
|
|
|
127
134
|
obj_props: dict[str, dict[str, str]] = {}
|
|
128
135
|
data_props: list[dict[str, Any]] = []
|
|
129
136
|
annotations: dict[str, dict[str, str]] = {}
|
|
137
|
+
subclasses: dict[str, list[str]] = {} # supertype -> member classes (rdfs:subClassOf)
|
|
130
138
|
|
|
131
139
|
current_subject: str | None = None
|
|
132
140
|
current_kind: str | None = None # "class", "objprop", "dataprop"
|
|
@@ -150,6 +158,11 @@ def turtle_to_mapping(turtle: str) -> MappingBundle:
|
|
|
150
158
|
obj_props.setdefault(current_subject, {})
|
|
151
159
|
continue
|
|
152
160
|
|
|
161
|
+
m_sub = re.match(r":(\w+)\s+rdfs:subClassOf\s+:(\w+)\s*\.", stripped)
|
|
162
|
+
if m_sub:
|
|
163
|
+
subclasses.setdefault(m_sub.group(2), []).append(m_sub.group(1))
|
|
164
|
+
continue
|
|
165
|
+
|
|
153
166
|
m_dataprop = re.match(r":(\w+)\s+a\s+owl:DatatypeProperty", stripped)
|
|
154
167
|
if m_dataprop:
|
|
155
168
|
current_subject = m_dataprop.group(1)
|
|
@@ -188,6 +201,8 @@ def turtle_to_mapping(turtle: str) -> MappingBundle:
|
|
|
188
201
|
"typeValue",
|
|
189
202
|
"edgeCollectionName",
|
|
190
203
|
"attributeName",
|
|
204
|
+
"labelsField",
|
|
205
|
+
"labelValue",
|
|
191
206
|
):
|
|
192
207
|
m_a = re.match(rf':(\w+)\s+phys:{annot}\s+"([^"]+)"', stripped)
|
|
193
208
|
if m_a:
|
|
@@ -200,15 +215,24 @@ def turtle_to_mapping(turtle: str) -> MappingBundle:
|
|
|
200
215
|
for name in classes:
|
|
201
216
|
own = [dp for dp in data_props if name in dp["domains"]]
|
|
202
217
|
props = [{"name": dp["name"], "type": _from_xsd_type(dp.get("range", "xsd:string"))} for dp in own]
|
|
203
|
-
|
|
218
|
+
cs_entry: dict[str, Any] = {"name": name, "labels": [name], "properties": props}
|
|
219
|
+
if subclasses.get(name):
|
|
220
|
+
cs_entry["subtypes"] = list(subclasses[name])
|
|
221
|
+
entities_cs.append(cs_entry)
|
|
204
222
|
ann = annotations.get(name, {})
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
223
|
+
style = ann.get("mappingStyle", "COLLECTION")
|
|
224
|
+
pm_entry: dict[str, Any] = {"style": style}
|
|
225
|
+
if style == "UNION":
|
|
226
|
+
# A UNION supertype has no collection of its own; its members are its subclasses.
|
|
227
|
+
pm_entry["members"] = list(subclasses.get(name, []))
|
|
228
|
+
else:
|
|
229
|
+
pm_entry["collectionName"] = ann.get("collectionName", name.lower() + "s")
|
|
230
|
+
if style == "LABEL":
|
|
210
231
|
pm_entry["typeField"] = ann.get("typeField", "type")
|
|
211
232
|
pm_entry["typeValue"] = ann.get("typeValue", name)
|
|
233
|
+
if style == "MULTI_LABEL":
|
|
234
|
+
pm_entry["labelsField"] = ann.get("labelsField", "labels")
|
|
235
|
+
pm_entry["labelValue"] = ann.get("labelValue", name)
|
|
212
236
|
fields = {
|
|
213
237
|
dp["name"]: {"field": annotations[dp["name"]]["attributeName"]}
|
|
214
238
|
for dp in own
|
|
@@ -116,20 +116,20 @@ to prevent a three-way fork.
|
|
|
116
116
|
|
|
117
117
|
| Module / subpackage | Purpose |
|
|
118
118
|
| --- | --- |
|
|
119
|
-
| `mapping` | `MappingBundle`, `MappingResolver`, `MappingSource`, `PropertyInfo`, `IndexInfo`, `RelationshipStats`; `mapping_from_wire_dict`, `mapping_hash`; collection-name grammar guard (`COLLECTION_NAME_RE`, `is_valid_collection_name`) |
|
|
119
|
+
| `mapping` | `MappingBundle`, `MappingResolver` (incl. supertypes: `SUPERTYPE_STYLES`, `is_supertype`, `supertype_members`), `MappingSource`, `PropertyInfo`, `IndexInfo`, `RelationshipStats`; `mapping_from_wire_dict`, `mapping_hash`; collection-name grammar guard (`COLLECTION_NAME_RE`, `is_valid_collection_name`) |
|
|
120
120
|
| `owl_turtle` / `owl_rdflib` | OWL/Turtle ⇄ MappingBundle round-trip; rdflib parser behind the `owl` extra (import degrades to `None` without it) |
|
|
121
121
|
| `aql` | `AqlFragment`, `AqlQuery` value types |
|
|
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
|
|
|
@@ -149,6 +149,24 @@ to prevent a three-way fork.
|
|
|
149
149
|
- **R5 — Deterministic contract types.** `mapping_hash` over a bundle
|
|
150
150
|
is stable across processes (canonical JSON) so consumers can use it
|
|
151
151
|
for cache keys and change detection.
|
|
152
|
+
- **R7 — Label hierarchies (supertypes).** A supertype is a label whose
|
|
153
|
+
instances are the instances of other labels (LDBC's `Message` is every
|
|
154
|
+
`Post` and every `Comment`).
|
|
155
|
+
- **Styles.** It is mapped with one of two entity styles (`SUPERTYPE_STYLES`):
|
|
156
|
+
- `MULTI_LABEL`: the members share one collection, and documents list
|
|
157
|
+
their labels in an array field (`labelsField`, `labelValue`).
|
|
158
|
+
- `UNION`: the members have collections of their own (`members`).
|
|
159
|
+
- **Members** come from the physical `members` list or the conceptual
|
|
160
|
+
entity's `subtypes`.
|
|
161
|
+
- **Resolver.** `MappingResolver.supertype_members()` flattens nested
|
|
162
|
+
supertypes, and refuses unknown members and cycles (`INVALID_MAPPING`).
|
|
163
|
+
A supertype without properties of its own has its members' properties.
|
|
164
|
+
`edge_constrains_target` treats a supertype target as guaranteed when
|
|
165
|
+
every class an edge endpoint can be is a member.
|
|
166
|
+
- **New styles fail closed.** Supertypes get styles of their own, so a
|
|
167
|
+
consumer that does not handle them rejects the mapping instead of
|
|
168
|
+
misreading it.
|
|
169
|
+
- **OWL.** OWL/Turtle round-trips the hierarchy as `rdfs:subClassOf`.
|
|
152
170
|
- **R6 — Quality gates.** ruff (lint + format), mypy, and pytest run in
|
|
153
171
|
CI on every push/PR; all three must pass before a release is tagged.
|
|
154
172
|
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Supertypes (label hierarchies): the MULTI_LABEL and UNION entity styles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from arango_query_core import SUPERTYPE_STYLES, CoreError, MappingBundle, MappingResolver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _bundle(
|
|
11
|
+
entities: dict, relationships: dict | None = None, conceptual: dict | None = None
|
|
12
|
+
) -> MappingBundle:
|
|
13
|
+
return MappingBundle(
|
|
14
|
+
conceptual_schema=conceptual or {"entities": [], "relationships": []},
|
|
15
|
+
physical_mapping={"entities": entities, "relationships": relationships or {}},
|
|
16
|
+
metadata={},
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_POST = {
|
|
21
|
+
"collectionName": "post",
|
|
22
|
+
"style": "COLLECTION",
|
|
23
|
+
"properties": {"id": {"field": "id"}, "imageFile": {}},
|
|
24
|
+
}
|
|
25
|
+
_COMMENT = {
|
|
26
|
+
"collectionName": "comment",
|
|
27
|
+
"style": "COLLECTION",
|
|
28
|
+
"properties": {"id": {"field": "id"}, "length": {}},
|
|
29
|
+
}
|
|
30
|
+
_HAS_CREATOR = {
|
|
31
|
+
"edgeCollectionName": "has_creator",
|
|
32
|
+
"style": "DEDICATED_COLLECTION",
|
|
33
|
+
"domain": ["Post", "Comment"],
|
|
34
|
+
"range": "Person",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _pg(**extra) -> MappingResolver:
|
|
39
|
+
return MappingResolver(
|
|
40
|
+
_bundle(
|
|
41
|
+
{
|
|
42
|
+
"Post": _POST,
|
|
43
|
+
"Comment": _COMMENT,
|
|
44
|
+
"Person": {"collectionName": "person", "style": "COLLECTION"},
|
|
45
|
+
"Message": {"style": "UNION", "members": ["Post", "Comment"]},
|
|
46
|
+
**extra,
|
|
47
|
+
},
|
|
48
|
+
{"HAS_CREATOR": _HAS_CREATOR},
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_both_styles_are_supertype_styles():
|
|
54
|
+
assert {"MULTI_LABEL", "UNION"} == SUPERTYPE_STYLES
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_union_members_and_is_supertype():
|
|
58
|
+
r = _pg()
|
|
59
|
+
assert r.is_supertype("Message")
|
|
60
|
+
assert not r.is_supertype("Post")
|
|
61
|
+
assert not r.is_supertype("Nowhere")
|
|
62
|
+
assert r.supertype_members("Message") == ["Post", "Comment"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_members_may_come_from_the_conceptual_subtypes():
|
|
66
|
+
r = MappingResolver(
|
|
67
|
+
_bundle(
|
|
68
|
+
{
|
|
69
|
+
"Post": {
|
|
70
|
+
"collectionName": "nodes",
|
|
71
|
+
"style": "LABEL",
|
|
72
|
+
"typeField": "type",
|
|
73
|
+
"typeValue": "Post",
|
|
74
|
+
},
|
|
75
|
+
"Comment": {
|
|
76
|
+
"collectionName": "nodes",
|
|
77
|
+
"style": "LABEL",
|
|
78
|
+
"typeField": "type",
|
|
79
|
+
"typeValue": "Comment",
|
|
80
|
+
},
|
|
81
|
+
"Message": {
|
|
82
|
+
"style": "MULTI_LABEL",
|
|
83
|
+
"collectionName": "nodes",
|
|
84
|
+
"labelsField": "labels",
|
|
85
|
+
"labelValue": "Message",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
conceptual={"entities": [{"name": "Message", "subtypes": ["Post", "Comment"]}]},
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
assert r.supertype_members("Message") == ["Post", "Comment"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_nested_supertypes_are_flattened_without_duplicates():
|
|
95
|
+
r = _pg(
|
|
96
|
+
Reply={"style": "UNION", "members": ["Comment"]},
|
|
97
|
+
Content={"style": "UNION", "members": ["Message", "Reply", "Post"]},
|
|
98
|
+
)
|
|
99
|
+
assert r.supertype_members("Content") == ["Post", "Comment"]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.parametrize(
|
|
103
|
+
"extra,needle",
|
|
104
|
+
[
|
|
105
|
+
({"Message": {"style": "UNION", "members": ["Post", "Ghost"]}}, "'Ghost', which is not mapped"),
|
|
106
|
+
({"Message": {"style": "UNION"}}, "declares no members"),
|
|
107
|
+
(
|
|
108
|
+
{
|
|
109
|
+
"Message": {"style": "UNION", "members": ["Thread"]},
|
|
110
|
+
"Thread": {"style": "UNION", "members": ["Message"]},
|
|
111
|
+
},
|
|
112
|
+
"cycle",
|
|
113
|
+
),
|
|
114
|
+
],
|
|
115
|
+
)
|
|
116
|
+
def test_invalid_hierarchies_are_refused(extra, needle):
|
|
117
|
+
r = _pg(**extra)
|
|
118
|
+
with pytest.raises(CoreError) as exc:
|
|
119
|
+
r.supertype_members("Message")
|
|
120
|
+
assert exc.value.code == "INVALID_MAPPING"
|
|
121
|
+
assert needle in str(exc.value)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_a_concrete_label_is_not_a_supertype():
|
|
125
|
+
with pytest.raises(CoreError, match="is not a supertype"):
|
|
126
|
+
_pg().supertype_members("Post")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_supertype_without_properties_has_its_members_properties():
|
|
130
|
+
props = _pg().resolve_properties("Message")
|
|
131
|
+
assert set(props) == {"id", "imageFile", "length"}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_an_edge_whose_endpoints_are_all_members_guarantees_the_supertype():
|
|
135
|
+
r = _pg()
|
|
136
|
+
assert r.edge_constrains_target("HAS_CREATOR", "Message", "INBOUND")
|
|
137
|
+
assert r.edge_constrains_target("HAS_CREATOR", "Person", "OUTBOUND")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_an_edge_that_can_reach_a_non_member_does_not():
|
|
141
|
+
r = _pg(Forum={"collectionName": "forum", "style": "COLLECTION"})
|
|
142
|
+
r.bundle.physical_mapping["relationships"]["HAS_TAG"] = {
|
|
143
|
+
"edgeCollectionName": "has_tag",
|
|
144
|
+
"style": "DEDICATED_COLLECTION",
|
|
145
|
+
"domain": ["Forum", "Post", "Comment"],
|
|
146
|
+
"range": "Tag",
|
|
147
|
+
}
|
|
148
|
+
assert not r.edge_constrains_target("HAS_TAG", "Message", "INBOUND")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_existing_mappings_are_unaffected():
|
|
152
|
+
r = MappingResolver(_bundle({"Post": _POST}))
|
|
153
|
+
assert not r.is_supertype("Post")
|
|
154
|
+
assert set(r.resolve_properties("Post")) == {"id", "imageFile"}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_union_and_multi_label_round_trip_through_owl_as_subclasses():
|
|
158
|
+
from arango_query_core import mapping_to_turtle, turtle_to_mapping
|
|
159
|
+
|
|
160
|
+
bundle = _bundle(
|
|
161
|
+
{
|
|
162
|
+
"Post": {"collectionName": "post", "style": "COLLECTION"},
|
|
163
|
+
"Comment": {"collectionName": "comment", "style": "COLLECTION"},
|
|
164
|
+
"Message": {"style": "UNION", "members": ["Post", "Comment"]},
|
|
165
|
+
"Thing": {
|
|
166
|
+
"style": "MULTI_LABEL",
|
|
167
|
+
"collectionName": "nodes",
|
|
168
|
+
"labelsField": "labels",
|
|
169
|
+
"labelValue": "Thing",
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
conceptual={
|
|
173
|
+
"entities": [
|
|
174
|
+
{"name": "Post"},
|
|
175
|
+
{"name": "Comment"},
|
|
176
|
+
{"name": "Message"},
|
|
177
|
+
{"name": "Thing", "subtypes": ["Post"]},
|
|
178
|
+
],
|
|
179
|
+
"relationships": [],
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
turtle = mapping_to_turtle(bundle)
|
|
183
|
+
assert ":Post rdfs:subClassOf :Message ." in turtle
|
|
184
|
+
back = turtle_to_mapping(turtle)
|
|
185
|
+
pm = back.physical_mapping["entities"]
|
|
186
|
+
assert pm["Message"] == {"style": "UNION", "members": ["Post", "Comment"]}
|
|
187
|
+
assert pm["Thing"]["labelsField"] == "labels" and pm["Thing"]["labelValue"] == "Thing"
|
|
188
|
+
r = MappingResolver(back)
|
|
189
|
+
assert r.supertype_members("Message") == ["Post", "Comment"]
|
|
190
|
+
assert r.supertype_members("Thing") == ["Post"]
|
|
@@ -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]
|
{arango_query_core-0.2.3 → arango_query_core-0.2.5}/.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
|
{arango_query_core-0.2.3 → arango_query_core-0.2.5}/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
|
|
File without changes
|