Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions bench/ecommerce_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@
from lang2sql.harness.loop import agent_loop
from lang2sql.safety.pipeline import SafetyPipeline
from lang2sql.tenancy.concierge import ContextConcierge
from lang2sql.tools.semantic_federation import FedEntry, _kv_key, _render_effective, _load_all, _resolve_term
from lang2sql.tools.semantic_federation import (
FedEntry,
_kv_key,
_render_effective,
_load_all,
_resolve_term,
)

# Stable IDs for the demo guild and its two channels.
GUILD = "acme-shop"
Expand All @@ -50,7 +56,9 @@ def _finance_identity() -> Identity:
return Identity(user_id="evan", guild_id=GUILD, channel_id=CH_FINANCE)


def _define_term(store: SqliteStore, scope: str, term: str, layer: str, entity: str, definition: str) -> None:
def _define_term(
store: SqliteStore, scope: str, term: str, layer: str, entity: str, definition: str
) -> None:
entry = FedEntry(term=term, layer=layer, entity=entity, definition=definition)
store.kv_set(scope, _kv_key(term, layer, entity), entry.to_json())

Expand Down Expand Up @@ -93,7 +101,9 @@ async def section_1_define_metrics(store: SqliteStore) -> None:

rendered = _render_effective(store, scope, channel_id, ident.user_id)
lines = [l for l in rendered.splitlines() if l.startswith("-")]
print(f"\nEffective layer for #{CH_MARKETING} now holds {len(lines)} definition(s):")
print(
f"\nEffective layer for #{CH_MARKETING} now holds {len(lines)} definition(s):"
)
print(rendered)


Expand All @@ -104,10 +114,22 @@ async def section_2_federation(store: SqliteStore) -> None:
mkt = _marketing_identity()
fin = _finance_identity()

_define_term(store, GUILD, "active_user", "channel", CH_MARKETING,
"user with a login event in the last 30 days")
_define_term(store, GUILD, "active_user", "channel", CH_FINANCE,
"user with an active paid subscription")
_define_term(
store,
GUILD,
"active_user",
"channel",
CH_MARKETING,
"user with a login event in the last 30 days",
)
_define_term(
store,
GUILD,
"active_user",
"channel",
CH_FINANCE,
"user with an active paid subscription",
)

print("Defined 'active_user' independently in two channels.\n")
print("Now resolving the *effective* definition each channel sees")
Expand All @@ -127,9 +149,9 @@ async def section_2_federation(store: SqliteStore) -> None:
print(f" #{CH_MARKETING:<10} active_user → {mkt_def}")
print(f" #{CH_FINANCE:<10} active_user → {fin_def}")

assert mkt_def and fin_def and mkt_def != fin_def, (
f"Federation failed: mkt_def={mkt_def!r}, fin_def={fin_def!r}"
)
assert (
mkt_def and fin_def and mkt_def != fin_def
), f"Federation failed: mkt_def={mkt_def!r}, fin_def={fin_def!r}"
print("\n ✅ Same term, two live definitions, zero conflict.")
print(" Each channel is its own branch in the federation tree;")
print(" neither overwrote the other. (Wren's single MDL cannot do this.)")
Expand Down
12 changes: 9 additions & 3 deletions src/lang2sql/adapters/db/d1_explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ def __init__(
) -> None:
self.account_id = account_id
self.database_id = database_id
self._token = token if token is not None else os.environ.get("CLOUDFLARE_API_TOKEN")
self._token = (
token if token is not None else os.environ.get("CLOUDFLARE_API_TOKEN")
)
self._timeout = timeout
self._transport = transport or self._http_transport

Expand All @@ -59,7 +61,9 @@ async def list_tables(self) -> list[Table]:
async def describe_table(self, name: str) -> Table:
rows = await self._query(f"PRAGMA table_info({_ident(name)})")
cols = [
Column(name=r["name"], type=r["type"] or "", nullable=not bool(r["notnull"]))
Column(
name=r["name"], type=r["type"] or "", nullable=not bool(r["notnull"])
)
for r in rows
]
return Table(name=name, schema="", columns=cols)
Expand All @@ -86,7 +90,9 @@ async def _query(self, sql: str, params: list | None = None) -> list[dict]:

def _http_transport(self, sql: str, params: list) -> dict:
if not self._token:
raise RuntimeError("CLOUDFLARE_API_TOKEN not set (D1 requires an API token)")
raise RuntimeError(
"CLOUDFLARE_API_TOKEN not set (D1 requires an API token)"
)
url = (
f"{_API_ROOT}/accounts/{self.account_id}"
f"/d1/database/{self.database_id}/query"
Expand Down
12 changes: 9 additions & 3 deletions src/lang2sql/adapters/db/dsn_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ def _quote(s: str) -> str:
return quote_plus(s, safe="")


def build_postgresql(*, host: str, port: str, database: str, user: str, password: str) -> ConnectionSpec:
def build_postgresql(
*, host: str, port: str, database: str, user: str, password: str
) -> ConnectionSpec:
# User may paste a full URL (e.g. "host/db?sslmode=require") into the host field.
# Extract just the hostname to avoid corrupting the assembled DSN.
parsed = urlsplit("//" + host)
Expand All @@ -47,7 +49,9 @@ def build_postgresql(*, host: str, port: str, database: str, user: str, password
return ConnectionSpec(dsn=dsn, extras={})


def build_mysql(*, host: str, port: str, database: str, user: str, password: str) -> ConnectionSpec:
def build_mysql(
*, host: str, port: str, database: str, user: str, password: str
) -> ConnectionSpec:
p = int(port) if port else 3306
dsn = f"mysql+pymysql://{_quote(user)}:{_quote(password)}@{host}:{p}/{database}"
return ConnectionSpec(dsn=dsn, extras={})
Expand Down Expand Up @@ -143,7 +147,9 @@ def assemble(db_type: str, fields: dict[str, str]) -> ConnectionSpec:
# Filter to the expected kwargs (modal can hand stray keys safely).
expected = {name for name, *_ in FIELD_SCHEMA[db_type]}
cleaned = {k: (v or "").strip() for k, v in fields.items() if k in expected}
missing = [n for n, _, req, _ in FIELD_SCHEMA[db_type] if req and not cleaned.get(n)]
missing = [
n for n, _, req, _ in FIELD_SCHEMA[db_type] if req and not cleaned.get(n)
]
if missing:
raise ValueError(f"missing required fields: {', '.join(missing)}")
return builder(**cleaned)
2 changes: 1 addition & 1 deletion src/lang2sql/adapters/db/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def build_explorer(

# Normalize bare postgresql:// → postgresql+psycopg:// (psycopg3 is installed).
if scheme == "postgresql":
connection = "postgresql+psycopg" + connection[len("postgresql"):]
connection = "postgresql+psycopg" + connection[len("postgresql") :]

# Anything else is assumed to be a SQLAlchemy URL (driver loaded lazily).
return SqlAlchemyExplorer(connection, schema=schema)
Expand Down
18 changes: 15 additions & 3 deletions src/lang2sql/adapters/db/postgres_explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
columns=[
Column("id", "integer", nullable=False, description="Primary key."),
Column("amount", "numeric", nullable=False, description="Order total."),
Column("status", "text", description="pending | paid | shipped | cancelled."),
Column(
"status", "text", description="pending | paid | shipped | cancelled."
),
Column("created_at", "timestamptz", nullable=False),
],
),
Expand All @@ -36,8 +38,18 @@

_SAMPLES: dict[str, list[dict]] = {
"public.orders": [
{"id": 1, "amount": 49.90, "status": "paid", "created_at": "2026-05-01T10:00:00Z"},
{"id": 2, "amount": 12.00, "status": "pending", "created_at": "2026-05-02T14:30:00Z"},
{
"id": 1,
"amount": 49.90,
"status": "paid",
"created_at": "2026-05-01T10:00:00Z",
},
{
"id": 2,
"amount": 12.00,
"status": "pending",
"created_at": "2026-05-02T14:30:00Z",
},
],
"public.users": [
{"id": 1, "email": "alice@example.com", "created_at": "2026-04-20T08:00:00Z"},
Expand Down
4 changes: 3 additions & 1 deletion src/lang2sql/adapters/db/sqlalchemy_explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ def _list_tables_sync(self) -> list[Table]:
default = insp.default_schema_name
effective = self._schema or default
# Omit schema when it's the connection default so SQL stays unqualified.
display_schema = "" if (not self._schema or self._schema == default) else effective
display_schema = (
"" if (not self._schema or self._schema == default) else effective
)
return [
Table(name=t, schema=display_schema)
for t in insp.get_table_names(schema=self._schema)
Expand Down
4 changes: 3 additions & 1 deletion src/lang2sql/adapters/llm/fake.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ async def complete(
)

# No tools at all → just answer.
return Completion(content="(no tools available) Hello from FakeLLM.", finish_reason="stop")
return Completion(
content="(no tools available) Hello from FakeLLM.", finish_reason="stop"
)


def _demo_args(spec: ToolSpec) -> str:
Expand Down
4 changes: 3 additions & 1 deletion src/lang2sql/adapters/llm/openai_.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
try:
return json.loads(text)
except (ValueError, TypeError) as exc:
raise RuntimeError(f"OpenAI returned non-JSON response: {text[:200]!r}") from exc
raise RuntimeError(
f"OpenAI returned non-JSON response: {text[:200]!r}"
) from exc


def _strip_thinking(text: str) -> str:
Expand Down
10 changes: 3 additions & 7 deletions src/lang2sql/adapters/storage/sqlite_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ def __init__(self, path: str = ":memory:") -> None:
self._create_tables()

def _create_tables(self) -> None:
self._conn.executescript(
"""
self._conn.executescript("""
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor TEXT NOT NULL,
Expand All @@ -55,8 +54,7 @@ def _create_tables(self) -> None:
value TEXT NOT NULL,
PRIMARY KEY (scope, key)
);
"""
)
""")
self._conn.commit()

def close(self) -> None:
Expand Down Expand Up @@ -125,9 +123,7 @@ def kv_set(self, scope: str, key: str, value: str) -> None:
self._conn.commit()

def kv_delete(self, scope: str, key: str) -> None:
self._conn.execute(
"DELETE FROM kv WHERE scope = ? AND key = ?", (scope, key)
)
self._conn.execute("DELETE FROM kv WHERE scope = ? AND key = ?", (scope, key))
self._conn.commit()

@staticmethod
Expand Down
11 changes: 9 additions & 2 deletions src/lang2sql/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
)

__all__ = [
"Identity", "Scope", "ScopeLevel",
"Completion", "Message", "Role", "ToolCall", "ToolResult", "ToolSpec",
"Identity",
"Scope",
"ScopeLevel",
"Completion",
"Message",
"Role",
"ToolCall",
"ToolResult",
"ToolSpec",
]
28 changes: 22 additions & 6 deletions src/lang2sql/core/ports/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,29 @@
from .tool import ToolPort

__all__ = [
"AuditEvent", "AuditPort",
"Column", "ExplorerPort", "Table",
"FrontendPort", "InboundMessage", "OutboundMessage",
"CandidateKind", "DocExtractorPort", "Document", "SemanticCandidate", "SourcePort",
"AuditEvent",
"AuditPort",
"Column",
"ExplorerPort",
"Table",
"FrontendPort",
"InboundMessage",
"OutboundMessage",
"CandidateKind",
"DocExtractorPort",
"Document",
"SemanticCandidate",
"SourcePort",
"LLMPort",
"ExtractorPort", "Fact", "RecallPort", "StorePort",
"SafetyContext", "SafetyDecision", "SafetyLayerPort", "SafetyPipelinePort", "Verdict",
"ExtractorPort",
"Fact",
"RecallPort",
"StorePort",
"SafetyContext",
"SafetyDecision",
"SafetyLayerPort",
"SafetyPipelinePort",
"Verdict",
"SecretsPort",
"ScopeResolverPort",
"SessionStorePort",
Expand Down
11 changes: 5 additions & 6 deletions src/lang2sql/core/ports/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,16 @@

@dataclass
class AuditEvent:
actor: str # user_id
action: str # "run_sql" | "define_metric" | "ingest" | ...
scope: str # session/scope key
actor: str # user_id
action: str # "run_sql" | "define_metric" | "ingest" | ...
scope: str # session/scope key
detail: dict[str, Any] = field(default_factory=dict)
ts: float = 0.0 # epoch seconds; filled by the store if 0
ts: float = 0.0 # epoch seconds; filled by the store if 0


@runtime_checkable
class AuditPort(Protocol):
async def record(self, event: AuditEvent) -> None:
...
async def record(self, event: AuditEvent) -> None: ...

async def query(self, actor: str, limit: int = 20) -> list[AuditEvent]:
"""Recent events for one actor, newest first."""
Expand Down
2 changes: 1 addition & 1 deletion src/lang2sql/core/ports/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class OutboundMessage:
"""Normalised agent output a frontend renders natively."""

text: str
file_bytes: bytes | None = None # e.g. CSV when result > 50 rows
file_bytes: bytes | None = None # e.g. CSV when result > 50 rows
file_name: str | None = None


Expand Down
8 changes: 3 additions & 5 deletions src/lang2sql/core/ports/ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class Document:

name: str
text: str
source_id: str = "" # preserved on resulting semantic entries
source_id: str = "" # preserved on resulting semantic entries


class CandidateKind(str, Enum):
Expand All @@ -43,13 +43,11 @@ class SemanticCandidate:
class SourcePort(Protocol):
"""Where a document comes from (file/URL/Notion/…). Axis 1."""

async def fetch(self, ref: str, blob: bytes | None = None) -> Document:
...
async def fetch(self, ref: str, blob: bytes | None = None) -> Document: ...


@runtime_checkable
class DocExtractorPort(Protocol):
"""How definitions are pulled from a document (LLM/DDL/…). Axis 2."""

async def extract(self, doc: Document) -> list[SemanticCandidate]:
...
async def extract(self, doc: Document) -> list[SemanticCandidate]: ...
10 changes: 5 additions & 5 deletions src/lang2sql/core/ports/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class Fact:
"""A remembered statement, scoped to a user/conversation."""

id: str
owner: str # user_id or scope key
owner: str # user_id or scope key
text: str
source: str = "manual" # "manual" (/remember) | "auto" (v1.5 extractor)
ts: float = 0.0
Expand All @@ -40,8 +40,7 @@ class RecallPort(Protocol):
V1 returns everything; v1.5 filters by keyword, v2 by vector similarity.
"""

async def recall(self, owner: str, query: str, store: StorePort) -> list[Fact]:
...
async def recall(self, owner: str, query: str, store: StorePort) -> list[Fact]: ...


@runtime_checkable
Expand All @@ -52,5 +51,6 @@ class ExtractorPort(Protocol):
yields nothing); v1.5 mines the transcript with an LLM.
"""

async def extract(self, owner: str, transcript: Sequence[Message]) -> list[Fact]:
...
async def extract(
self, owner: str, transcript: Sequence[Message]
) -> list[Fact]: ...
Loading
Loading