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.
@@ -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
@@ -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)
@@ -0,0 +1,5 @@
1
+ from text2cypher.llm._base import AsyncLLMClient, Completion, Usage
2
+ from text2cypher.llm._fake import Call, FakeLLM
3
+ from text2cypher.llm._openai import OpenAIClient
4
+
5
+ __all__ = ["AsyncLLMClient", "Call", "Completion", "FakeLLM", "OpenAIClient", "Usage"]
@@ -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
+ ...
@@ -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
+ ]