1
0
Fork 0

feat(ingest): I5 — SQL D7-speil, bygget fra commons-spec alene

Speiler MAF I4 fra shared/ingest-spec.md alene: manifest → SQL-konnektor →
materialisert OKF-bundle, byte-identisk med den delte golden-fasiten. Gaten
I4→I5 verifisert løst mot ground truth (MAF-commits d7e5f2f/4f45fe6/1b7612b)
før arbeidet startet. Spec byte-identisk delt, ingen spec-endring (I4).

- ingest.py: SqlSource (type: sql, id, connection_ref); ManifestContract.source
  er nå diskriminert union FileSource | SqlSource på type (http/ukjent tag →
  fail-fast). _render_sql_cell (§5 typed: NULL→"", int→decimal, float→korteste
  round-trip, str→verbatim m/ delt _escape_cell, annet→fail — aldri stille
  coercion). _resolve_connection_ref (env-oppslag §4/§8, usatt → fail-fast).
  _read_sql (read-only sqlite file:?mode=ro, ett SELECT, max_rows §8).
  _read_extraction dispatcher på source.type; materialisering/index/replacement
  uendret fra I3.
- examples/ingest-golden-sql/: repo-lokal golden (byte-frossen kopi av I4s fasit).
- Speiltester (I4s load-bearing-sett, gjennom SQL-konnektoren, detach-bevist røde):
  sql-golden byte-fasit + mutasjonskontroller · typed-cell/NULL (NY §I5-søm) ·
  provenance/navigability/verdict-reservasjon/re-ingest-safety · SqlSource-kontrakt/
  typed-rendering/connection_ref/max_rows/read-only · spec-integritet utvidet med
  connection_ref. Stale type:"sql"-avvisningscase erstattet (sql er gyldig post-I5).
- docs/2026-07-04-I5-brief.md: brief + premiss-verifisering.

Suite 265 passed uten nøkkel/nettverk (239 + 26 nye) · ruff + mypy --strict rene.

[skip-docs] README + docs/extending.md er bevisst utsatt til I7 per sesjonsplan
(programmet batcher ingest-doc der, avgrenset til det D7 faktisk har — CSV + SQL nå,
HTTP/MCP kun pekere). Dokumentert i docs/2026-07-04-I5-brief.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MM6BWb1hWmJZuXFZ7rjxT
This commit is contained in:
Kjell Tore Guttormsen 2026-07-04 07:18:59 +02:00
commit 32640deffc
13 changed files with 615 additions and 9 deletions

View file

@ -4,9 +4,10 @@ An addition IN FRONT of the loop (ingest-spec §1): a deterministic step that co
framework to a real data source and materializes the extract as an OKF knowledge bundle,
which the existing 8-step loop then consumes UNCHANGED. Zero model calls; no network.
D7 scope (I3, mirror of MAF I2): the ``file`` source type (a local CSV catalogue). The
``sql`` and ``http`` source types are later work a manifest naming them is rejected
fail-fast at validation, never silently accepted.
D7 scope (I3I5, mirror of MAF I2/I4): the ``file`` source type (a local CSV catalogue) and
the ``sql`` source type (a local SQL database). The ``http`` source type is a later, OPTIONAL
extension point (§1) a manifest naming it is rejected fail-fast at validation, never
silently accepted.
Contract discipline mirrors ``contracts.py``: the manifest is schema-validated fail-fast
BEFORE any source call (§4), and queries are declarative configuration, never evaluated as
@ -20,10 +21,12 @@ import csv
import hashlib
import json
import logging
import os
import re
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from typing import Annotated, Literal
from pydantic import BaseModel, Field, field_validator
@ -65,6 +68,26 @@ class FileSource(BaseModel):
return value
class SqlSource(BaseModel):
"""A local SQL database (``source.type == "sql"``, ingest-spec §4).
``connection_ref`` is the NAME of a runtime-resolved environment variable whose value is
the database path (or connection string) the secret/location never lives in the manifest
(§4, §8), so the manifest stays versionable and shareable without credentials.
"""
type: Literal["sql"]
id: str
connection_ref: str
@field_validator("id")
@classmethod
def _id_grammar(cls, value: str) -> str:
if not _ID_RE.match(value):
raise ValueError(f"source.id {value!r} must match [a-z0-9][a-z0-9-]*")
return value
class Extraction(BaseModel):
"""One extraction description (ingest-spec §4). ``max_rows`` is a required cap (§8)."""
@ -103,7 +126,7 @@ class ManifestContract(BaseModel):
"""The ingest manifest (§4) — schema-validated fail-fast before any source call."""
manifest_version: Literal[1]
source: FileSource
source: Annotated[FileSource | SqlSource, Field(discriminator="type")]
bundle_summary: str
extractions: list[Extraction] = Field(min_length=1)
@ -174,6 +197,77 @@ def _read_csv(csv_path: Path, max_rows: int) -> list[list[str]]:
return rows
def _render_sql_cell(value: object) -> str:
"""Type a SQL cell to its §5 string form (BEFORE §5 escaping via ``_escape_cell``).
``None`` (SQL NULL) the empty string; ``int`` plain decimal; ``float`` its shortest
round-trip decimal form (Python ``repr``); ``str`` verbatim. Any other value type (a
BLOB, say) MUST fail never a silent coercion (§5).
"""
if value is None:
return ""
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return repr(value)
if isinstance(value, str):
return value
raise ValueError(f"SQL cell of type {type(value).__name__} is not a supported value type (§5)")
def _resolve_connection_ref(connection_ref: str) -> str:
"""Resolve the ``connection_ref`` env var to the database location at run time (§4, §8).
Locations/credentials never live in the manifest the reference is resolved from the
environment. An unset reference fails fast (never a silent empty connection).
"""
dsn = os.environ.get(connection_ref)
if not dsn:
raise ValueError(f"connection_ref {connection_ref!r} is not set in the environment")
return dsn
def _read_sql(dsn: str, query: str, max_rows: int) -> list[list[str]]:
"""Run one read-only SELECT against a local sqlite database (ingest-spec §4, §5, §8).
The connection is opened read-only (§4: the connector SHOULD enforce read-only access), and
sqlite executes exactly ONE statement per ``execute`` call so the single-statement rule is
enforced by the driver. ``max_rows`` is enforced fail-fast on the returned rows (§8).
Returns the column-name header row followed by typed-then-stringified data rows, in source
order (§5); a stable order is the manifest query's responsibility via ORDER BY (§4).
"""
uri = Path(dsn).resolve().as_uri() + "?mode=ro"
connection = sqlite3.connect(uri, uri=True)
try:
cursor = connection.execute(query)
header = [description[0] for description in cursor.description]
data = cursor.fetchall()
finally:
connection.close()
if len(data) > max_rows:
raise ValueError(f"extraction exceeds max_rows: {len(data)} > {max_rows}")
rows: list[list[str]] = [header]
for record in data:
rows.append([_render_sql_cell(value) for value in record])
return rows
def _read_extraction(
source: FileSource | SqlSource, manifest_dir: Path, extraction: Extraction
) -> list[list[str]]:
"""Dispatch to the connector for ``source.type`` — header + data rows in source order (§5).
``file``: resolve the query path within ``root`` (boundary-checked fail-closed, §4) and read
the CSV. ``sql``: resolve ``connection_ref`` from the environment (§4, §8) and run the
read-only SELECT. Both enforce ``max_rows`` fail-fast (§8).
"""
if source.type == "file":
csv_path = _resolve_within(manifest_dir / source.root, extraction.query)
return _read_csv(csv_path, extraction.max_rows)
dsn = _resolve_connection_ref(source.connection_ref)
return _read_sql(dsn, extraction.query, extraction.max_rows)
def _resolve_within(root: Path, relative: str) -> Path:
"""Resolve ``relative`` under ``root``, boundary-checked fail-closed (the OKF path rule)."""
resolved = (root / relative).resolve()
@ -264,7 +358,6 @@ def materialize(manifest_path: Path, bundle_dir: Path, ingested_at: str) -> list
"""
loaded = load_manifest(manifest_path)
source = loaded.contract.source
root = manifest_path.parent / source.root
bundle_dir.mkdir(parents=True, exist_ok=True)
@ -276,8 +369,7 @@ def materialize(manifest_path: Path, bundle_dir: Path, ingested_at: str) -> list
# 2. Write the new set.
generated: list[Path] = []
for extraction in loaded.contract.extractions:
csv_path = _resolve_within(root, extraction.query)
rows = _read_csv(csv_path, extraction.max_rows)
rows = _read_extraction(source, manifest_path.parent, extraction)
_logger.info(
"ingest source=%s extraction=%s rows=%d",
source.id,