text2cypher 0.1.0__py3-none-any.whl
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.
- text2cypher/__init__.py +47 -0
- text2cypher/_config.py +51 -0
- text2cypher/_errors.py +5 -0
- text2cypher/_execution.py +25 -0
- text2cypher/_generation/__init__.py +8 -0
- text2cypher/_generation/extract.py +22 -0
- text2cypher/_generation/generate.py +51 -0
- text2cypher/_generation/problems.py +193 -0
- text2cypher/_generation/prompts.py +141 -0
- text2cypher/_pipeline.py +86 -0
- text2cypher/_results.py +91 -0
- text2cypher/_schema_source.py +60 -0
- text2cypher/_validation/__init__.py +15 -0
- text2cypher/_validation/issues.py +100 -0
- text2cypher/_validation/validator.py +106 -0
- text2cypher/_workflow.py +99 -0
- text2cypher/llm/__init__.py +5 -0
- text2cypher/llm/_base.py +43 -0
- text2cypher/llm/_fake.py +47 -0
- text2cypher/llm/_openai.py +59 -0
- text2cypher/py.typed +0 -0
- text2cypher/schema/__init__.py +14 -0
- text2cypher/schema/_filter.py +89 -0
- text2cypher/schema/_from_snapshot.py +84 -0
- text2cypher/schema/_model.py +81 -0
- text2cypher/schema/_render.py +55 -0
- text2cypher/schema/_stopwords.py +81 -0
- text2cypher-0.1.0.dist-info/METADATA +124 -0
- text2cypher-0.1.0.dist-info/RECORD +31 -0
- text2cypher-0.1.0.dist-info/WHEEL +4 -0
- text2cypher-0.1.0.dist-info/licenses/LICENSE +202 -0
text2cypher/_results.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""What came of one question: the result, its attempts and what a query returned."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from text2cypher._validation import ValidationResult
|
|
7
|
+
from text2cypher.llm import Usage
|
|
8
|
+
|
|
9
|
+
type SchemaScope = Literal["filtered", "full"]
|
|
10
|
+
type Status = Literal["valid", "unanswerable", "failed"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Attempt:
|
|
15
|
+
"""One query the LLM wrote, how it was validated, and what writing it cost.
|
|
16
|
+
|
|
17
|
+
``validation`` is ``None`` when the query is empty (the LLM declined), which is
|
|
18
|
+
not validated.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
query: The query, empty if the LLM declined.
|
|
22
|
+
validation: How the query was validated, or ``None`` for an empty query.
|
|
23
|
+
schema_scope: The schema the query was written for: ``"filtered"`` or ``"full"``.
|
|
24
|
+
usage: The tokens the call that wrote the query used, if the client reported them.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
query: str
|
|
28
|
+
validation: ValidationResult | None
|
|
29
|
+
schema_scope: SchemaScope
|
|
30
|
+
usage: Usage | None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class Records:
|
|
35
|
+
"""What a query returned: the names of its columns, and its rows.
|
|
36
|
+
|
|
37
|
+
Values are the client's own: nodes, relationships and paths are ``falkordb``
|
|
38
|
+
objects.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
columns: The names of the columns, as the query returns them.
|
|
42
|
+
rows: The rows, one tuple of values per row.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
columns: tuple[str, ...]
|
|
46
|
+
rows: tuple[tuple[Any, ...], ...]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class Result:
|
|
51
|
+
"""What came of one question: every attempt, in order, and how it ended.
|
|
52
|
+
|
|
53
|
+
``valid``: the last query passed validation. ``unanswerable``: the LLM declined,
|
|
54
|
+
so there is no query. ``failed``: the corrections ran out; the last attempt has
|
|
55
|
+
the last query and its issues.
|
|
56
|
+
|
|
57
|
+
When execution is on and the status is ``valid``, ``records`` has what the query
|
|
58
|
+
returned, or ``execution_error`` says why FalkorDB did not run it (the status
|
|
59
|
+
stays ``valid``: the query passed validation).
|
|
60
|
+
|
|
61
|
+
A result is hashable unless it holds ``records``, whose values are the client's own
|
|
62
|
+
objects.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
question: The question that was asked.
|
|
66
|
+
status: How it ended: ``"valid"``, ``"unanswerable"`` or ``"failed"``.
|
|
67
|
+
attempts: Every query the LLM wrote, in order, with its validation, schema and usage.
|
|
68
|
+
records: What the final query returned, when it was executed and ran.
|
|
69
|
+
execution_error: Why the final query did not run, when it was executed and failed.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
question: str
|
|
73
|
+
status: Status
|
|
74
|
+
attempts: tuple[Attempt, ...]
|
|
75
|
+
records: Records | None = None
|
|
76
|
+
execution_error: str | None = None
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def query(self) -> str:
|
|
80
|
+
"""The last query, empty if the LLM declined."""
|
|
81
|
+
return self.attempts[-1].query
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def usage(self) -> Usage | None:
|
|
85
|
+
"""The tokens of all attempts, or ``None`` if any attempt did not report them."""
|
|
86
|
+
total = Usage(0, 0)
|
|
87
|
+
for attempt in self.attempts:
|
|
88
|
+
if attempt.usage is None:
|
|
89
|
+
return None
|
|
90
|
+
total += attempt.usage
|
|
91
|
+
return total
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Where the snapshot of a graph comes from: a new capture, or one that is kept."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
|
|
6
|
+
from cypher_guardrail import AsyncLiveSnapshot, Snapshot, SnapshotSource
|
|
7
|
+
from falkordb.asyncio import FalkorDB
|
|
8
|
+
|
|
9
|
+
from text2cypher._errors import GraphNotFoundError
|
|
10
|
+
|
|
11
|
+
# A live snapshot refreshes itself in the background after this long; ours is refreshed by
|
|
12
|
+
# ``SchemaSource.read`` when it is due, so it must never be due on its own.
|
|
13
|
+
_NEVER = timedelta(days=36500)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SchemaSource:
|
|
17
|
+
"""Reads the snapshot of a graph that the prompt and the validators are made from.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
client: A ``falkordb.asyncio`` client.
|
|
21
|
+
refresh_seconds: ``None`` captures a new snapshot on every read. A number keeps one
|
|
22
|
+
per graph and, when that many seconds have passed since it was checked, checks
|
|
23
|
+
it again: a few cheap queries, and a new capture only if the graph changed.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, client: FalkorDB, refresh_seconds: float | None) -> None:
|
|
27
|
+
self._client = client
|
|
28
|
+
self._refresh_seconds = refresh_seconds
|
|
29
|
+
# graph name -> its live snapshot and when it was last checked (monotonic seconds)
|
|
30
|
+
self._live: dict[str, tuple[AsyncLiveSnapshot, float]] = {}
|
|
31
|
+
|
|
32
|
+
async def read(self, graph: str) -> tuple[Snapshot, SnapshotSource]:
|
|
33
|
+
"""The graph's snapshot, and the source the validators should use.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
GraphNotFoundError: If the graph does not exist, or has no labelled nodes or
|
|
37
|
+
relationships. FalkorDB has no graph without data, so this is a missing
|
|
38
|
+
graph or a typo. Nothing is kept for it, so one created later is found.
|
|
39
|
+
"""
|
|
40
|
+
snapshot, source = await self._snapshot(graph)
|
|
41
|
+
if not snapshot.labels and not snapshot.relationship_types:
|
|
42
|
+
self._live.pop(graph, None)
|
|
43
|
+
raise GraphNotFoundError(
|
|
44
|
+
f"The graph {graph!r} does not exist, or has no labelled nodes or relationships"
|
|
45
|
+
)
|
|
46
|
+
return snapshot, source
|
|
47
|
+
|
|
48
|
+
async def _snapshot(self, graph: str) -> tuple[Snapshot, SnapshotSource]:
|
|
49
|
+
if self._refresh_seconds is None:
|
|
50
|
+
snapshot = await Snapshot.capture_async(self._client, graph, types=True)
|
|
51
|
+
return snapshot, snapshot
|
|
52
|
+
live, checked = self._live.get(graph) or (
|
|
53
|
+
AsyncLiveSnapshot(self._client, graph, refresh_every=_NEVER, types=True),
|
|
54
|
+
-float("inf"),
|
|
55
|
+
)
|
|
56
|
+
if time.monotonic() - checked >= self._refresh_seconds:
|
|
57
|
+
await live.refresh()
|
|
58
|
+
checked = time.monotonic()
|
|
59
|
+
self._live[graph] = (live, checked)
|
|
60
|
+
return live.current(), live
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from text2cypher._validation.issues import (
|
|
2
|
+
Issue,
|
|
3
|
+
Source,
|
|
4
|
+
ValidationResult,
|
|
5
|
+
drop_repeated_property_issues,
|
|
6
|
+
)
|
|
7
|
+
from text2cypher._validation.validator import QueryValidator
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Issue",
|
|
11
|
+
"QueryValidator",
|
|
12
|
+
"Source",
|
|
13
|
+
"ValidationResult",
|
|
14
|
+
"drop_repeated_property_issues",
|
|
15
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The problems a validator found in a query, and how overlapping ones are merged."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Literal, cast
|
|
6
|
+
|
|
7
|
+
from cypher_guardrail.issues import (
|
|
8
|
+
INVALID_PROPERTY_ACCESS,
|
|
9
|
+
UNKNOWN_LABEL,
|
|
10
|
+
UNKNOWN_REL_TYPE,
|
|
11
|
+
)
|
|
12
|
+
from cypher_guardrail.issues import Issue as GuardrailIssue
|
|
13
|
+
|
|
14
|
+
type Source = Literal["syntax", "schema", "properties"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Issue:
|
|
19
|
+
"""A problem found by one of the cypher-guardrail validators.
|
|
20
|
+
|
|
21
|
+
``raw`` is cypher-guardrail's own typed issue: read its structured fields
|
|
22
|
+
(``label``, ``suggested_path``, ...) and never parse ``description``. It is a dict,
|
|
23
|
+
so it is left out of the hash (equal issues still have equal hashes).
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
source: The validator that found it: ``"syntax"``, ``"schema"`` or ``"properties"``.
|
|
27
|
+
raw: cypher-guardrail's issue: a ``code``, a ``description`` and fields that depend
|
|
28
|
+
on the code.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
source: Source
|
|
32
|
+
raw: GuardrailIssue = field(hash=False)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def fields(self) -> Mapping[str, Any]:
|
|
36
|
+
"""``raw`` as a plain mapping, for reading the fields that depend on the code."""
|
|
37
|
+
return cast("Mapping[str, Any]", self.raw)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def code(self) -> str:
|
|
41
|
+
"""The issue's code, e.g. ``cypher_guardrail.Warning.UnknownLabel``."""
|
|
42
|
+
return self.raw["code"]
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def description(self) -> str:
|
|
46
|
+
"""The issue in words, for people; read the fields of ``raw`` instead of parsing it."""
|
|
47
|
+
return self.raw["description"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class ValidationResult:
|
|
52
|
+
"""The outcome of validating one query against a graph.
|
|
53
|
+
|
|
54
|
+
A score is ``None`` when it carries no information: the properties score when
|
|
55
|
+
the query reads no property, and either score when the syntax is invalid and
|
|
56
|
+
that validator found nothing. Validity never depends on the scores.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
issues: Every issue found, in the order syntax, schema, properties.
|
|
60
|
+
syntax_ok: Whether FalkorDB accepts the query.
|
|
61
|
+
schema_score: The schema validator's score, 0 to 1, or ``None``.
|
|
62
|
+
properties_score: The properties validator's score, 0 to 1, or ``None``.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
issues: tuple[Issue, ...]
|
|
66
|
+
syntax_ok: bool
|
|
67
|
+
schema_score: float | None
|
|
68
|
+
properties_score: float | None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def valid(self) -> bool:
|
|
72
|
+
"""Whether the syntax is fine and no validator reported an issue."""
|
|
73
|
+
return self.syntax_ok and not self.issues
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def drop_repeated_property_issues(issues: list[Issue]) -> list[Issue]:
|
|
77
|
+
"""Drop property issues about a label or relationship type already reported unknown.
|
|
78
|
+
|
|
79
|
+
cypher-guardrail reports a property of an unknown label, or of an unknown
|
|
80
|
+
relationship type, as missing too, so one root cause would otherwise appear twice.
|
|
81
|
+
A property issue says whether its name is a node label or a relationship type, so a
|
|
82
|
+
label and a relationship type that share a name are not mixed up.
|
|
83
|
+
"""
|
|
84
|
+
unknown = {
|
|
85
|
+
("node", issue.raw["label"])
|
|
86
|
+
for issue in issues
|
|
87
|
+
if issue.raw["code"] == UNKNOWN_LABEL
|
|
88
|
+
} | {
|
|
89
|
+
("relationship", issue.raw["relationship_type"])
|
|
90
|
+
for issue in issues
|
|
91
|
+
if issue.raw["code"] == UNKNOWN_REL_TYPE
|
|
92
|
+
}
|
|
93
|
+
return [
|
|
94
|
+
issue
|
|
95
|
+
for issue in issues
|
|
96
|
+
if not (
|
|
97
|
+
issue.raw["code"] == INVALID_PROPERTY_ACCESS
|
|
98
|
+
and (issue.raw["kind"], issue.raw["name"]) in unknown
|
|
99
|
+
)
|
|
100
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Validate a Cypher query against a FalkorDB graph with cypher-guardrail."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from cypher_guardrail import (
|
|
6
|
+
AsyncPropertiesValidator,
|
|
7
|
+
AsyncSchemaValidator,
|
|
8
|
+
AsyncSyntaxValidator,
|
|
9
|
+
SnapshotSource,
|
|
10
|
+
)
|
|
11
|
+
from cypher_guardrail import GraphNotFoundError as GuardrailGraphNotFoundError
|
|
12
|
+
from cypher_guardrail.backend import get_async_backend
|
|
13
|
+
from falkordb.asyncio import FalkorDB
|
|
14
|
+
from falkordb.asyncio.graph import AsyncGraph
|
|
15
|
+
|
|
16
|
+
from text2cypher._errors import GraphNotFoundError
|
|
17
|
+
from text2cypher._validation.issues import (
|
|
18
|
+
Issue,
|
|
19
|
+
Source,
|
|
20
|
+
ValidationResult,
|
|
21
|
+
drop_repeated_property_issues,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class QueryValidator:
|
|
26
|
+
"""Runs cypher-guardrail's syntax, schema and properties validators on a query.
|
|
27
|
+
|
|
28
|
+
All three always run, even when the syntax is invalid: they can still report
|
|
29
|
+
real problems elsewhere in the query. The query is valid when the syntax is ok
|
|
30
|
+
and no validator reported an issue; scores are never used to decide that. A
|
|
31
|
+
query that can write to the graph is never valid (cypher-guardrail's default).
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
client: A ``falkordb.asyncio`` client, or one of its graphs. The client is
|
|
35
|
+
bound to one event loop, so use one validator per loop.
|
|
36
|
+
max_concurrency: The most requests in flight at once.
|
|
37
|
+
snapshot: A snapshot (or a live one) of the graph. The validators then answer
|
|
38
|
+
from it and ask the database only to plan the query and to confirm a problem
|
|
39
|
+
before reporting it, so a query that is fine costs one request. The answers are
|
|
40
|
+
the same as without it, unless the graph changed since the snapshot.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
client: FalkorDB | AsyncGraph,
|
|
46
|
+
*,
|
|
47
|
+
max_concurrency: int = 8,
|
|
48
|
+
snapshot: SnapshotSource | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
# One backend shared by the validators: one snapshot cache, one concurrency limit
|
|
51
|
+
self._backend = get_async_backend(client, max_concurrency, snapshot=snapshot)
|
|
52
|
+
self._syntax = AsyncSyntaxValidator(self._backend)
|
|
53
|
+
self._schema = AsyncSchemaValidator(self._backend)
|
|
54
|
+
self._properties = AsyncPropertiesValidator(self._backend)
|
|
55
|
+
|
|
56
|
+
async def validate(self, query: str, graph: str | None = None) -> ValidationResult:
|
|
57
|
+
"""Validate ``query`` against ``graph`` (optional when given a graph).
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ValueError: If there is no graph to validate against.
|
|
61
|
+
GraphNotFoundError: If the graph does not exist.
|
|
62
|
+
"""
|
|
63
|
+
self._backend.check_database(graph)
|
|
64
|
+
try:
|
|
65
|
+
with self._backend.snapshot():
|
|
66
|
+
try:
|
|
67
|
+
# Unlike gather, a failure cancels the other validators before the
|
|
68
|
+
# snapshot is released
|
|
69
|
+
async with asyncio.TaskGroup() as group:
|
|
70
|
+
syntax = group.create_task(self._syntax.validate(query, graph))
|
|
71
|
+
schema = group.create_task(self._schema.validate(query, graph))
|
|
72
|
+
properties = group.create_task(
|
|
73
|
+
self._properties.validate(query, graph)
|
|
74
|
+
)
|
|
75
|
+
except ExceptionGroup as failures:
|
|
76
|
+
# Callers should see the validator's own error, not a group
|
|
77
|
+
raise failures.exceptions[0] from None
|
|
78
|
+
except GuardrailGraphNotFoundError as error:
|
|
79
|
+
# The graph can go away after its schema was read
|
|
80
|
+
raise GraphNotFoundError(str(error)) from error
|
|
81
|
+
syntax_ok, syntax_issues = syntax.result()
|
|
82
|
+
schema_score, schema_issues = schema.result()
|
|
83
|
+
properties_score, properties_issues = properties.result()
|
|
84
|
+
|
|
85
|
+
issues = [
|
|
86
|
+
*_wrap("syntax", syntax_issues),
|
|
87
|
+
*_wrap("schema", schema_issues),
|
|
88
|
+
*_wrap("properties", properties_issues),
|
|
89
|
+
]
|
|
90
|
+
return ValidationResult(
|
|
91
|
+
issues=tuple(drop_repeated_property_issues(issues)),
|
|
92
|
+
syntax_ok=syntax_ok,
|
|
93
|
+
schema_score=_meaningful(schema_score, syntax_ok, schema_issues),
|
|
94
|
+
properties_score=_meaningful(
|
|
95
|
+
properties_score, syntax_ok, properties_issues
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _wrap(source: Source, raw_issues: list) -> list[Issue]:
|
|
101
|
+
return [Issue(source=source, raw=raw) for raw in raw_issues]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _meaningful(score: float | None, syntax_ok: bool, raw_issues: list) -> float | None:
|
|
105
|
+
"""Drop a score from a validator that found nothing in an invalid query."""
|
|
106
|
+
return score if syntax_ok or raw_issues else None
|
text2cypher/_workflow.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""The loop: generate a query, validate it, correct it, and escalate the schema."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from text2cypher._config import Config
|
|
7
|
+
from text2cypher._generation import Generation, correct_cypher, generate_cypher
|
|
8
|
+
from text2cypher._results import Attempt, Result, SchemaScope
|
|
9
|
+
from text2cypher._validation import Issue, ValidationResult
|
|
10
|
+
from text2cypher.llm import AsyncLLMClient
|
|
11
|
+
from text2cypher.schema import GraphSchema, SchemaFilter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class _Failed:
|
|
16
|
+
"""A query that did not pass validation, and why, to be corrected."""
|
|
17
|
+
|
|
18
|
+
query: str
|
|
19
|
+
issues: tuple[Issue, ...]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def run_workflow(
|
|
23
|
+
question: str,
|
|
24
|
+
schema: GraphSchema,
|
|
25
|
+
*,
|
|
26
|
+
llm: AsyncLLMClient,
|
|
27
|
+
validate: Callable[[str], Awaitable[ValidationResult]],
|
|
28
|
+
config: Config,
|
|
29
|
+
schema_filter: SchemaFilter | None = None,
|
|
30
|
+
) -> Result:
|
|
31
|
+
"""Answer ``question`` with a query that passes ``validate``, if one can be found.
|
|
32
|
+
|
|
33
|
+
The first query and the first ``config.attempts_w_filtered`` corrections use the
|
|
34
|
+
filtered schema, the later corrections the full one, until ``config.total_attempts``
|
|
35
|
+
corrections have been made. Without a ``schema_filter``, or when it keeps the whole
|
|
36
|
+
schema, there is no filtered schema and every attempt uses the full one.
|
|
37
|
+
|
|
38
|
+
An empty query is the LLM declining. With the filtered schema that may be the
|
|
39
|
+
filter's doing, so the same request is made again with the full schema, once and
|
|
40
|
+
without costing a correction. Declining on the full schema ends the loop.
|
|
41
|
+
"""
|
|
42
|
+
filtered = None if schema_filter is None else schema_filter(schema, question)
|
|
43
|
+
if filtered == schema:
|
|
44
|
+
filtered = None
|
|
45
|
+
attempts: list[Attempt] = []
|
|
46
|
+
# What the next request corrects; None for the first query
|
|
47
|
+
failed: _Failed | None = None
|
|
48
|
+
corrections = 0
|
|
49
|
+
# The LLM declined on the filtered schema, which is no longer used
|
|
50
|
+
escalated = False
|
|
51
|
+
|
|
52
|
+
while True:
|
|
53
|
+
scope, used = _choose_schema(
|
|
54
|
+
schema, filtered, config, corrections=corrections, escalated=escalated
|
|
55
|
+
)
|
|
56
|
+
generation = await _write(llm, question, used, failed)
|
|
57
|
+
if not generation.query:
|
|
58
|
+
attempts.append(Attempt("", None, scope, generation.usage))
|
|
59
|
+
if scope == "full":
|
|
60
|
+
return Result(question, "unanswerable", tuple(attempts))
|
|
61
|
+
escalated = True
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
validation = await validate(generation.query)
|
|
65
|
+
attempts.append(Attempt(generation.query, validation, scope, generation.usage))
|
|
66
|
+
if validation.valid:
|
|
67
|
+
return Result(question, "valid", tuple(attempts))
|
|
68
|
+
if corrections == config.total_attempts:
|
|
69
|
+
return Result(question, "failed", tuple(attempts))
|
|
70
|
+
corrections += 1
|
|
71
|
+
failed = _Failed(generation.query, validation.issues)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _choose_schema(
|
|
75
|
+
schema: GraphSchema,
|
|
76
|
+
filtered: GraphSchema | None,
|
|
77
|
+
config: Config,
|
|
78
|
+
*,
|
|
79
|
+
corrections: int,
|
|
80
|
+
escalated: bool,
|
|
81
|
+
) -> tuple[SchemaScope, GraphSchema]:
|
|
82
|
+
"""The filtered schema while there is one, the budget for it lasts and the LLM has
|
|
83
|
+
not declined with it; the full schema otherwise."""
|
|
84
|
+
if (
|
|
85
|
+
filtered is not None
|
|
86
|
+
and not escalated
|
|
87
|
+
and corrections <= config.attempts_w_filtered
|
|
88
|
+
):
|
|
89
|
+
return "filtered", filtered
|
|
90
|
+
return "full", schema
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _write(
|
|
94
|
+
llm: AsyncLLMClient, question: str, schema: GraphSchema, failed: _Failed | None
|
|
95
|
+
) -> Generation:
|
|
96
|
+
"""The first query, or a correction of the one that failed."""
|
|
97
|
+
if failed is None:
|
|
98
|
+
return await generate_cypher(llm, schema, question)
|
|
99
|
+
return await correct_cypher(llm, schema, question, failed.query, failed.issues)
|
text2cypher/llm/_base.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""What the rest of the library needs from an LLM."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import NamedTuple, Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class Usage:
|
|
9
|
+
"""The tokens one LLM call used; ``+`` adds two.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
input_tokens: Tokens in the messages sent.
|
|
13
|
+
output_tokens: Tokens in the reply.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
input_tokens: int
|
|
17
|
+
output_tokens: int
|
|
18
|
+
|
|
19
|
+
def __add__(self, other: "Usage") -> "Usage":
|
|
20
|
+
return Usage(
|
|
21
|
+
self.input_tokens + other.input_tokens,
|
|
22
|
+
self.output_tokens + other.output_tokens,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Completion(NamedTuple):
|
|
27
|
+
"""One reply: its text, and the tokens it used when the provider says so.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
text: The reply, with the query in a fenced block (an empty block declines).
|
|
31
|
+
usage: The tokens the call used, or ``None`` if they are not known.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
text: str
|
|
35
|
+
usage: Usage | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncLLMClient(Protocol):
|
|
39
|
+
"""A stateless chat model: every call is one system and one user message."""
|
|
40
|
+
|
|
41
|
+
async def complete(self, system: str, user: str) -> Completion:
|
|
42
|
+
"""Ask the model, with a system message and a user message, and return its reply."""
|
|
43
|
+
...
|
text2cypher/llm/_fake.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""A scripted LLM for tests."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import NamedTuple
|
|
5
|
+
|
|
6
|
+
from text2cypher.llm._base import Completion
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Call(NamedTuple):
|
|
10
|
+
"""What an LLM was asked once.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
system: The system message.
|
|
14
|
+
user: The user message.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
system: str
|
|
18
|
+
user: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FakeLLM:
|
|
22
|
+
"""Answers with the given replies in order, and records every call.
|
|
23
|
+
|
|
24
|
+
A reply is a ``Completion``, or a plain string for one without usage.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
calls: What it was asked, in order.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, replies: Iterable[str | Completion]) -> None:
|
|
31
|
+
self._replies = iter(replies)
|
|
32
|
+
self.calls: list[Call] = []
|
|
33
|
+
|
|
34
|
+
async def complete(self, system: str, user: str) -> Completion:
|
|
35
|
+
"""Record the call and return the next reply.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
RuntimeError: If every reply has been used.
|
|
39
|
+
"""
|
|
40
|
+
self.calls.append(Call(system, user))
|
|
41
|
+
try:
|
|
42
|
+
reply = next(self._replies)
|
|
43
|
+
except StopIteration:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"FakeLLM has no reply left for call {len(self.calls)}"
|
|
46
|
+
) from None
|
|
47
|
+
return reply if isinstance(reply, Completion) else Completion(reply, None)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""An LLM client for OpenAI's Chat Completions API (and compatible servers)."""
|
|
2
|
+
|
|
3
|
+
from openai import AsyncOpenAI, omit
|
|
4
|
+
from openai.types.chat import ChatCompletionMessageParam
|
|
5
|
+
|
|
6
|
+
from text2cypher.llm._base import Completion, Usage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OpenAIClient:
|
|
10
|
+
"""Asks an OpenAI chat model, one system and one user message per call.
|
|
11
|
+
|
|
12
|
+
A model that has no system role (some served by compatible servers) needs
|
|
13
|
+
``system_role=False``: the system text then goes first in the one user message.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
model: The model to ask. There is no default: the right one depends on the
|
|
17
|
+
account and on the schema being prompted.
|
|
18
|
+
client: The ``AsyncOpenAI`` to use, e.g. one with another ``base_url`` for a
|
|
19
|
+
compatible server. By default one is made from the environment.
|
|
20
|
+
temperature: ``None`` leaves it out, for models that only accept their own.
|
|
21
|
+
system_role: ``False`` sends one user message, the system text followed by the
|
|
22
|
+
user text, for a model that has no system role.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
model: str,
|
|
28
|
+
*,
|
|
29
|
+
client: AsyncOpenAI | None = None,
|
|
30
|
+
temperature: float | None = 0,
|
|
31
|
+
system_role: bool = True,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._model = model
|
|
34
|
+
self._client = client or AsyncOpenAI()
|
|
35
|
+
self._temperature = omit if temperature is None else temperature
|
|
36
|
+
self._system_role = system_role
|
|
37
|
+
|
|
38
|
+
async def complete(self, system: str, user: str) -> Completion:
|
|
39
|
+
"""Ask the model, and return its reply and, when the API reports it, the tokens used."""
|
|
40
|
+
response = await self._client.chat.completions.create(
|
|
41
|
+
model=self._model,
|
|
42
|
+
messages=self._messages(system, user),
|
|
43
|
+
temperature=self._temperature,
|
|
44
|
+
)
|
|
45
|
+
used = response.usage
|
|
46
|
+
return Completion(
|
|
47
|
+
text=response.choices[0].message.content or "",
|
|
48
|
+
usage=None
|
|
49
|
+
if used is None
|
|
50
|
+
else Usage(used.prompt_tokens, used.completion_tokens),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def _messages(self, system: str, user: str) -> list[ChatCompletionMessageParam]:
|
|
54
|
+
if self._system_role:
|
|
55
|
+
return [
|
|
56
|
+
{"role": "system", "content": system},
|
|
57
|
+
{"role": "user", "content": user},
|
|
58
|
+
]
|
|
59
|
+
return [{"role": "user", "content": f"{system}\n\n{user}"}]
|
text2cypher/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from text2cypher.schema._filter import SchemaFilter, filter_schema
|
|
2
|
+
from text2cypher.schema._from_snapshot import schema_from_snapshot
|
|
3
|
+
from text2cypher.schema._model import GraphSchema, Index, Path
|
|
4
|
+
from text2cypher.schema._render import render_schema
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"GraphSchema",
|
|
8
|
+
"Index",
|
|
9
|
+
"Path",
|
|
10
|
+
"SchemaFilter",
|
|
11
|
+
"filter_schema",
|
|
12
|
+
"render_schema",
|
|
13
|
+
"schema_from_snapshot",
|
|
14
|
+
]
|