text2sql-engine 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,131 @@
1
+ """Provider-agnostic LLM client layer.
2
+
3
+ An :class:`LLMProvider` turns a prompt into text. Two entry points:
4
+
5
+ * :meth:`complete` — free-form text. Used by Stage 2.
6
+ * :meth:`complete_json` — output forced to a JSON object that matches a given
7
+ JSON schema. Used by Stage 1. Providers with native structured output
8
+ (``response_format`` / tool-calling) should use it. The base class gives a
9
+ plain-text fallback for the rest.
10
+
11
+ Adding a provider is one subclass plus the ``@register_provider`` decorator.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from abc import ABC, abstractmethod
18
+ from typing import Any, Callable
19
+
20
+ from ..logging_utils import get_logger
21
+ from ..types import LLMResponse
22
+
23
+ logger = get_logger("providers")
24
+
25
+
26
+ class LLMProvider(ABC):
27
+ """Base class for all providers.
28
+
29
+ Args:
30
+ model: Model id for this instance.
31
+ api_key: Secret key. Never logged.
32
+ base_url: Optional endpoint override, for OpenAI-compatible gateways.
33
+ timeout: Request timeout in seconds.
34
+ """
35
+
36
+ #: Registry name. Set by ``@register_provider``.
37
+ name: str = ""
38
+
39
+ def __init__(
40
+ self,
41
+ model: str,
42
+ *,
43
+ api_key: str | None = None,
44
+ base_url: str | None = None,
45
+ timeout: float = 60.0,
46
+ ) -> None:
47
+ self.model = model
48
+ self._api_key = api_key
49
+ self._base_url = base_url
50
+ self._timeout = timeout
51
+
52
+ @abstractmethod
53
+ def complete(
54
+ self,
55
+ *,
56
+ system: str,
57
+ user: str,
58
+ temperature: float,
59
+ top_p: float,
60
+ max_tokens: int,
61
+ ) -> LLMResponse:
62
+ """Return a free-form text completion."""
63
+ raise NotImplementedError
64
+
65
+ def complete_json(
66
+ self,
67
+ *,
68
+ system: str,
69
+ user: str,
70
+ json_schema: dict[str, Any],
71
+ schema_name: str,
72
+ temperature: float,
73
+ top_p: float,
74
+ max_tokens: int,
75
+ ) -> LLMResponse:
76
+ """Return a completion forced to a JSON object.
77
+
78
+ Default: append the JSON schema to the prompt and rely on text parsing.
79
+ Providers with native structured output should override this for a
80
+ stronger guarantee.
81
+ """
82
+ augmented_user = (
83
+ f"{user}\n\n"
84
+ f"Respond with a single JSON object that matches this JSON schema. "
85
+ f"Do not wrap it in markdown fences and do not add any comment.\n"
86
+ f"JSON schema:\n{json.dumps(json_schema)}"
87
+ )
88
+ return self.complete(
89
+ system=system,
90
+ user=augmented_user,
91
+ temperature=temperature,
92
+ top_p=top_p,
93
+ max_tokens=max_tokens,
94
+ )
95
+
96
+
97
+ # Registry / factory.
98
+ _REGISTRY: dict[str, type[LLMProvider]] = {}
99
+
100
+
101
+ def register_provider(name: str) -> Callable[[type[LLMProvider]], type[LLMProvider]]:
102
+ """Class decorator. Registers a provider under ``name``."""
103
+
104
+ def decorator(cls: type[LLMProvider]) -> type[LLMProvider]:
105
+ cls.name = name
106
+ _REGISTRY[name.lower()] = cls
107
+ return cls
108
+
109
+ return decorator
110
+
111
+
112
+ def registered_providers() -> list[str]:
113
+ """Return the registered provider names, sorted."""
114
+ return sorted(_REGISTRY)
115
+
116
+
117
+ def get_provider_class(name: str) -> type[LLMProvider]:
118
+ """Look up a provider class by name.
119
+
120
+ Raises:
121
+ ProviderError: If no provider is registered under ``name``.
122
+ """
123
+ from ..errors import ProviderError
124
+
125
+ cls = _REGISTRY.get(name.lower())
126
+ if cls is None:
127
+ raise ProviderError(
128
+ f"Unknown provider '{name}'. Registered providers: "
129
+ f"{', '.join(registered_providers()) or '(none)'}."
130
+ )
131
+ return cls
@@ -0,0 +1,125 @@
1
+ """OpenAI-compatible provider.
2
+
3
+ Works with the OpenAI API and any OpenAI-compatible gateway (via ``base_url``).
4
+ Uses ``response_format`` JSON-schema mode for structured output when available.
5
+ The ``openai`` SDK is imported lazily, so the library imports without it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from ..errors import ProviderError
13
+ from ..logging_utils import get_logger
14
+ from ..types import LLMResponse, TokenUsage
15
+ from .base import LLMProvider, register_provider
16
+
17
+ logger = get_logger("providers.openai")
18
+
19
+
20
+ @register_provider("openai")
21
+ class OpenAIProvider(LLMProvider):
22
+ """Provider backed by the OpenAI Chat Completions API."""
23
+
24
+ def _client(self) -> Any:
25
+ try:
26
+ from openai import OpenAI
27
+ except ImportError as exc: # pragma: no cover - dependency guard
28
+ raise ProviderError(
29
+ "The 'openai' package is required for the OpenAI provider. "
30
+ "Install it with 'pip install openai'."
31
+ ) from exc
32
+ # base_url=None lets the SDK use its default endpoint.
33
+ return OpenAI(api_key=self._api_key, base_url=self._base_url, timeout=self._timeout)
34
+
35
+ @staticmethod
36
+ def _usage(response: Any) -> TokenUsage:
37
+ usage = getattr(response, "usage", None)
38
+ if usage is None:
39
+ return TokenUsage()
40
+ return TokenUsage(
41
+ prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0,
42
+ completion_tokens=getattr(usage, "completion_tokens", 0) or 0,
43
+ total_tokens=getattr(usage, "total_tokens", 0) or 0,
44
+ )
45
+
46
+ def complete(
47
+ self,
48
+ *,
49
+ system: str,
50
+ user: str,
51
+ temperature: float,
52
+ top_p: float,
53
+ max_tokens: int,
54
+ ) -> LLMResponse:
55
+ client = self._client()
56
+ logger.debug("OpenAI completion request (model=%s)", self.model)
57
+ try:
58
+ response = client.chat.completions.create(
59
+ model=self.model,
60
+ messages=[
61
+ {"role": "system", "content": system},
62
+ {"role": "user", "content": user},
63
+ ],
64
+ temperature=temperature,
65
+ top_p=top_p,
66
+ max_tokens=max_tokens,
67
+ )
68
+ except Exception as exc: # noqa: BLE001 - normalize SDK errors
69
+ raise ProviderError(f"OpenAI request failed: {exc}") from exc
70
+
71
+ text = response.choices[0].message.content or ""
72
+ return LLMResponse(text=text, usage=self._usage(response), model=self.model, raw=response)
73
+
74
+ def complete_json(
75
+ self,
76
+ *,
77
+ system: str,
78
+ user: str,
79
+ json_schema: dict[str, Any],
80
+ schema_name: str,
81
+ temperature: float,
82
+ top_p: float,
83
+ max_tokens: int,
84
+ ) -> LLMResponse:
85
+ client = self._client()
86
+ response_format = {
87
+ "type": "json_schema",
88
+ "json_schema": {
89
+ "name": schema_name,
90
+ "schema": json_schema,
91
+ "strict": True,
92
+ },
93
+ }
94
+ logger.debug("OpenAI structured request (model=%s)", self.model)
95
+ try:
96
+ response = client.chat.completions.create(
97
+ model=self.model,
98
+ messages=[
99
+ {"role": "system", "content": system},
100
+ {"role": "user", "content": user},
101
+ ],
102
+ temperature=temperature,
103
+ top_p=top_p,
104
+ max_tokens=max_tokens,
105
+ response_format=response_format,
106
+ )
107
+ except Exception as exc: # noqa: BLE001
108
+ # Some OpenAI-compatible gateways do not support json_schema mode.
109
+ # Fall back to schema-in-prompt + text parsing.
110
+ logger.warning(
111
+ "OpenAI structured output failed (%s). Falling back to text parsing.",
112
+ exc,
113
+ )
114
+ return super().complete_json(
115
+ system=system,
116
+ user=user,
117
+ json_schema=json_schema,
118
+ schema_name=schema_name,
119
+ temperature=temperature,
120
+ top_p=top_p,
121
+ max_tokens=max_tokens,
122
+ )
123
+
124
+ text = response.choices[0].message.content or ""
125
+ return LLMResponse(text=text, usage=self._usage(response), model=self.model, raw=response)
@@ -0,0 +1,38 @@
1
+ """Factory that builds a provider from a :class:`ProviderSelection`.
2
+
3
+ Importing this module registers the built-in providers as a side effect.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from ..config import ProviderSelection
9
+ from .base import LLMProvider, get_provider_class, registered_providers
10
+
11
+ # Importing these runs their @register_provider decorators.
12
+ from . import anthropic_provider as _anthropic # noqa: F401
13
+ from . import openai_provider as _openai # noqa: F401
14
+
15
+
16
+ def build_provider(selection: ProviderSelection, *, timeout: float) -> LLMProvider:
17
+ """Build the provider described by ``selection``.
18
+
19
+ Args:
20
+ selection: Provider/model/base_url/api_key from ``.env``.
21
+ timeout: Request timeout in seconds, from the stage config.
22
+
23
+ Returns:
24
+ A ready :class:`LLMProvider`.
25
+
26
+ Raises:
27
+ ProviderError: If the provider name is not registered.
28
+ """
29
+ cls = get_provider_class(selection.provider)
30
+ return cls(
31
+ model=selection.model,
32
+ api_key=selection.api_key,
33
+ base_url=selection.base_url,
34
+ timeout=timeout,
35
+ )
36
+
37
+
38
+ __all__ = ["build_provider", "registered_providers"]
text2sql/py.typed ADDED
File without changes
@@ -0,0 +1,18 @@
1
+ """Schema models and providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .file_provider import FileSchemaProvider
6
+ from .models import Column, ForeignKey, Schema, Table
7
+ from .provider import SchemaProvider
8
+ from .serialize import schema_to_prompt
9
+
10
+ __all__ = [
11
+ "Column",
12
+ "ForeignKey",
13
+ "Schema",
14
+ "Table",
15
+ "SchemaProvider",
16
+ "FileSchemaProvider",
17
+ "schema_to_prompt",
18
+ ]
@@ -0,0 +1,140 @@
1
+ """File-backed SchemaProvider for JSON and YAML.
2
+
3
+ The file format (documented in ``examples/schema.yaml``) is::
4
+
5
+ name: my_database # optional
6
+ tables:
7
+ - name: customers
8
+ description: "..."
9
+ columns:
10
+ - name: id
11
+ type: integer
12
+ description: "..."
13
+ primary_key: true
14
+ sample_values: [1, 2, 3]
15
+ foreign_keys:
16
+ - column: category_id
17
+ references_table: categories
18
+ references_column: id
19
+
20
+ JSON uses the same structure. Format is picked from the file extension.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from ..errors import SchemaError
30
+ from .models import Column, ForeignKey, Schema, Table
31
+ from .provider import SchemaProvider
32
+
33
+
34
+ class FileSchemaProvider(SchemaProvider):
35
+ """Load schema metadata from a JSON or YAML file.
36
+
37
+ Format comes from the extension (``.json``, ``.yaml``, ``.yml``).
38
+
39
+ Args:
40
+ path: Path to the metadata file.
41
+ """
42
+
43
+ def __init__(self, path: str | Path) -> None:
44
+ self._path = Path(path).expanduser()
45
+
46
+ def load(self) -> Schema:
47
+ if not self._path.is_file():
48
+ raise SchemaError(f"Schema file not found: '{self._path}'.")
49
+
50
+ raw_text = self._path.read_text(encoding="utf-8")
51
+ suffix = self._path.suffix.lower()
52
+
53
+ try:
54
+ if suffix in (".yaml", ".yml"):
55
+ data = self._parse_yaml(raw_text)
56
+ elif suffix == ".json":
57
+ data = json.loads(raw_text)
58
+ else:
59
+ raise SchemaError(
60
+ f"Unsupported schema file extension '{suffix}'. "
61
+ f"Use .json, .yaml, or .yml."
62
+ )
63
+ except SchemaError:
64
+ raise
65
+ except Exception as exc: # noqa: BLE001 - normalize parser errors
66
+ raise SchemaError(f"Failed to parse schema file '{self._path}': {exc}") from exc
67
+
68
+ return self._build_schema(data)
69
+
70
+ @staticmethod
71
+ def _parse_yaml(text: str) -> Any:
72
+ try:
73
+ import yaml
74
+ except ImportError as exc: # pragma: no cover - dependency guard
75
+ raise SchemaError(
76
+ "PyYAML is required to load YAML schema files. "
77
+ "Install it with 'pip install PyYAML'."
78
+ ) from exc
79
+ return yaml.safe_load(text)
80
+
81
+ @staticmethod
82
+ def _build_schema(data: Any) -> Schema:
83
+ if not isinstance(data, dict):
84
+ raise SchemaError("Schema root must be a mapping/object.")
85
+
86
+ raw_tables = data.get("tables")
87
+ if not isinstance(raw_tables, list) or not raw_tables:
88
+ raise SchemaError("Schema must have a non-empty 'tables' list.")
89
+
90
+ tables: list[Table] = []
91
+ for entry in raw_tables:
92
+ if not isinstance(entry, dict):
93
+ raise SchemaError("Each table entry must be a mapping/object.")
94
+ name = entry.get("name")
95
+ if not name:
96
+ raise SchemaError("Every table needs a 'name'.")
97
+
98
+ columns: list[Column] = []
99
+ for col in entry.get("columns", []) or []:
100
+ if not isinstance(col, dict) or not col.get("name"):
101
+ raise SchemaError(
102
+ f"Invalid column in table '{name}': every column needs a 'name'."
103
+ )
104
+ columns.append(
105
+ Column(
106
+ name=str(col["name"]),
107
+ type=str(col.get("type", "")),
108
+ description=str(col.get("description", "")),
109
+ primary_key=bool(col.get("primary_key", False)),
110
+ sample_values=list(col.get("sample_values", []) or []),
111
+ )
112
+ )
113
+
114
+ foreign_keys: list[ForeignKey] = []
115
+ for fk in entry.get("foreign_keys", []) or []:
116
+ if not isinstance(fk, dict):
117
+ raise SchemaError(f"Invalid foreign_key in table '{name}'.")
118
+ try:
119
+ foreign_keys.append(
120
+ ForeignKey(
121
+ column=str(fk["column"]),
122
+ references_table=str(fk["references_table"]),
123
+ references_column=str(fk["references_column"]),
124
+ )
125
+ )
126
+ except KeyError as exc:
127
+ raise SchemaError(
128
+ f"Foreign key in table '{name}' is missing field {exc}."
129
+ ) from exc
130
+
131
+ tables.append(
132
+ Table(
133
+ name=str(name),
134
+ description=str(entry.get("description", "")),
135
+ columns=columns,
136
+ foreign_keys=foreign_keys,
137
+ )
138
+ )
139
+
140
+ return Schema(name=str(data.get("name", "")), tables=tables)
@@ -0,0 +1,84 @@
1
+ """Schema metadata models.
2
+
3
+ Schema-agnostic containers. They describe any relational schema, loaded from a
4
+ file or from live introspection. Nothing here is tied to one database product.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+
13
+ @dataclass
14
+ class Column:
15
+ """A column in a table."""
16
+
17
+ name: str
18
+ type: str
19
+ description: str = ""
20
+ primary_key: bool = False
21
+ sample_values: list[Any] = field(default_factory=list)
22
+
23
+
24
+ @dataclass
25
+ class ForeignKey:
26
+ """A foreign key: a column pointing to another table's column."""
27
+
28
+ column: str
29
+ references_table: str
30
+ references_column: str
31
+
32
+
33
+ @dataclass
34
+ class Table:
35
+ """A table with its columns, primary keys, and foreign keys."""
36
+
37
+ name: str
38
+ description: str = ""
39
+ columns: list[Column] = field(default_factory=list)
40
+ foreign_keys: list[ForeignKey] = field(default_factory=list)
41
+
42
+ @property
43
+ def primary_keys(self) -> list[str]:
44
+ """Names of the primary-key columns."""
45
+ return [c.name for c in self.columns if c.primary_key]
46
+
47
+ def column(self, name: str) -> Column | None:
48
+ """Return the named column, or ``None``. Case-insensitive."""
49
+ lowered = name.lower()
50
+ for col in self.columns:
51
+ if col.name.lower() == lowered:
52
+ return col
53
+ return None
54
+
55
+
56
+ @dataclass
57
+ class Schema:
58
+ """A full schema: a list of tables."""
59
+
60
+ tables: list[Table] = field(default_factory=list)
61
+ name: str = ""
62
+
63
+ def table(self, name: str) -> Table | None:
64
+ """Return the named table, or ``None``. Case-insensitive."""
65
+ lowered = name.lower()
66
+ for tbl in self.tables:
67
+ if tbl.name.lower() == lowered:
68
+ return tbl
69
+ return None
70
+
71
+ @property
72
+ def table_names(self) -> list[str]:
73
+ return [t.name for t in self.tables]
74
+
75
+ def subset(self, table_names: list[str]) -> "Schema":
76
+ """Return a new Schema with only the named tables.
77
+
78
+ Matches names case-insensitively. Unknown names are skipped.
79
+ """
80
+ wanted = {n.lower() for n in table_names}
81
+ return Schema(
82
+ name=self.name,
83
+ tables=[t for t in self.tables if t.name.lower() in wanted],
84
+ )
@@ -0,0 +1,30 @@
1
+ """SchemaProvider.
2
+
3
+ This is the seam that keeps the pipeline schema-agnostic. The default loads
4
+ from a file (see :mod:`.file_provider`). A caller can inject any subclass
5
+ instead, for example one that reads a live database, as long as it returns a
6
+ :class:`~text2sql.schema.models.Schema`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+
13
+ from .models import Schema
14
+
15
+
16
+ class SchemaProvider(ABC):
17
+ """Gives schema metadata to the pipeline.
18
+
19
+ Subclasses return a full :class:`Schema`. The pipeline does not care where
20
+ it comes from.
21
+ """
22
+
23
+ @abstractmethod
24
+ def load(self) -> Schema:
25
+ """Load and return the schema.
26
+
27
+ Raises:
28
+ SchemaError: If the schema cannot be built or is invalid.
29
+ """
30
+ raise NotImplementedError
@@ -0,0 +1,55 @@
1
+ """Render a :class:`Schema` to text for LLM prompts.
2
+
3
+ Kept apart from the models so prompt formatting can change without touching the
4
+ data classes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .models import Schema
10
+
11
+
12
+ def schema_to_prompt(schema: Schema, *, max_sample_values: int = 5) -> str:
13
+ """Render a schema as text for humans and LLMs.
14
+
15
+ Includes table descriptions, columns (type + description), primary keys,
16
+ foreign keys, and a capped list of sample values.
17
+
18
+ Args:
19
+ schema: The schema to render.
20
+ max_sample_values: Max sample values shown per column.
21
+ """
22
+ lines: list[str] = []
23
+ if schema.name:
24
+ lines.append(f"Database: {schema.name}")
25
+ lines.append("")
26
+
27
+ for table in schema.tables:
28
+ lines.append(f"Table: {table.name}")
29
+ if table.description:
30
+ lines.append(f" Description: {table.description}")
31
+ pks = table.primary_keys
32
+ if pks:
33
+ lines.append(f" Primary key: {', '.join(pks)}")
34
+
35
+ lines.append(" Columns:")
36
+ for col in table.columns:
37
+ parts = [f" - {col.name} ({col.type})"]
38
+ if col.description:
39
+ parts.append(f": {col.description}")
40
+ lines.append("".join(parts))
41
+ if col.sample_values:
42
+ sample = col.sample_values[:max_sample_values]
43
+ rendered = ", ".join(repr(v) for v in sample)
44
+ lines.append(f" samples: {rendered}")
45
+
46
+ if table.foreign_keys:
47
+ lines.append(" Foreign keys:")
48
+ for fk in table.foreign_keys:
49
+ lines.append(
50
+ f" - {table.name}.{fk.column} -> "
51
+ f"{fk.references_table}.{fk.references_column}"
52
+ )
53
+ lines.append("")
54
+
55
+ return "\n".join(lines).rstrip() + "\n"