refactor(ingest): adopt shared llm-ingestion-okf v0.3.1 behind a thin adapter
Door A (manifest -> connector -> deterministic materialization -> index) is no longer implemented here. src/portfolio_optimiser/ingest.py becomes a thin consumer seam over the shared library, git-pinned to v0.3.1 on the same Forgejo channel portfolio-optimiser-claude uses. Net -626/+385; ingest.py 599 -> 145 lines. shared/ingest-spec.md remains the normative spec: the library implements it, it does not replace it. Spec changes continue to go via commons. Acceptance criterion met and proven: all three golden bundles (file/sql/http) are byte-exact before and after, including the idempotence re-run. examples/ and shared/ carry ZERO modifications -- the fasit was not adjusted to fit. The rejection set was verified equivalent, not assumed: all 22 malformations the repo's pydantic models refused are refused by the library, with typed codes (okf_type_reserved, credential_embedded, extraction_id_duplicate, ...). Test rebinding (invariants preserved, vehicle changed): the library has zero runtime dependencies by design, so pydantic is unavailable to it. ManifestV1.model_validate(dict) -> load_manifest_bytes(bytes); ValidationError -> ManifestError; model_fields -> dataclasses.fields; PathSecurityError -> SourceError(path_escape); ValueError -> MaterializationError(ingested_at_invalid). Tests now also pin the refusal `code`, the library's documented stability contract -- a sharper assertion than "some validation error was raised". Two accepted behavioural deltas, recorded rather than silently dropped: - Title whitespace is stored verbatim instead of collapsed at validation, so the frontmatter title and the index label are no longer guaranteed identical for irregular whitespace. Both behaviours are spec-conformant (the spec is SILENT; the old one was a repo-local pinned decision). Queued as a commons-amendment candidate so both stacks pin the same answer. Goldens unaffected. - The section 8 audit log moves to logger llm_ingestion_okf.materialize. Nothing in the repo consumed the old channel. Also: the `type` discriminator is no longer a dataclass field, so the spec cross-check asserts it explicitly -- without that line the swap would have silently narrowed the test. New tests/test_ingest_library_seam.py pins the seam itself: the restated section 5 stamp formula against the stamp the library actually writes (the one place the adapter does not purely delegate, since v0.3.1 exposes no stamp helper), the local-only allow_network default, the list[Path] unwrapping, and a guard that the adapter never regrows local Door A machinery. All four verified RED when detached, as were both golden regressions under a byte-level render mutation. Door A is UNGATED: it calls no guard before writing to disk. Gating untrusted content remains the caller's responsibility (guard wiring still planned). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B4jNN186eVqfe1x5DnTU6r
This commit is contained in:
parent
7ec60618b0
commit
0a11af74a4
9 changed files with 385 additions and 626 deletions
|
|
@ -57,7 +57,16 @@ The role keys (`proposer`, `checker`, `default`) let you assign a distinct model
|
|||
The **ingest layer** (`ingest.py`, spec `shared/ingest-spec.md`) is a second, distinct source
|
||||
seam from the retriever above: one JSON **manifest** per source coupling declares a `source.type`
|
||||
and a list of extractions; `materialize(...)` runs the connector for that type and writes an OKF
|
||||
bundle. The spec ships three source families — `file`/CSV (I2), `sql` (I4), and **`http`** (I6) —
|
||||
bundle.
|
||||
|
||||
> **Where to change it (2026-07-20):** `ingest.py` is a thin adapter — Door A is implemented by
|
||||
> the shared [`llm-ingestion-okf`](https://git.fromaitochitta.com/open/llm-ingestion-okf) library
|
||||
> (git-pinned to `v0.3.1`), so connectors, materialization and index generation improve in ONE
|
||||
> place across every consumer. `shared/ingest-spec.md` remains the normative spec — the library
|
||||
> implements it, it does not replace it, and spec changes go via commons. The API below is
|
||||
> unchanged; only the implementation moved. Note that Door A is **ungated**: it calls no
|
||||
> security guard before writing to disk, so gating untrusted content is the caller's
|
||||
> responsibility (see `docs/plan/2026-07-16-llm-ingestion-guard-inclusion.md`). The spec ships three source families — `file`/CSV (I2), `sql` (I4), and **`http`** (I6) —
|
||||
and `http` is the framework's **worked extension-point example**: it shows exactly how a third,
|
||||
network-transport family plugs into the same connector / materialization / gate contracts.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,8 +19,15 @@ dependencies = [
|
|||
# PuLP 4.0 vil kreve `pip install pulp[cbc]` + COIN_CMD (Fase-migrasjonsnotat).
|
||||
"mcp>=1.28.0", # tynn lokal-mappe MCP-server (Step 7) — GA (resolverte 1.28.0) per Step 1-beslutning
|
||||
"pydantic>=2.11,<3", # IR/validering (B1) — eksplisitt pin til STABIL 2.x, aldri alpha
|
||||
"llm-ingestion-okf", # Door A ingest (§4–§6) — the shared implementation of shared/ingest-spec.md; zero runtime deps, MAF-free (D7)
|
||||
]
|
||||
|
||||
# Distribution channel for the shared ingest library (mirrors portfolio-optimiser-claude,
|
||||
# verified in consumer CI): git pin against the public Forgejo repo — reproducible for every
|
||||
# consumer, uv.lock pins the exact commit behind the tag. Bump the rev on a new library tag.
|
||||
[tool.uv.sources]
|
||||
llm-ingestion-okf = { git = "https://git.fromaitochitta.com/open/llm-ingestion-okf.git", rev = "v0.3.1" }
|
||||
|
||||
# Dev tooling as a PEP 735 dependency-group (uv includes it by default in `uv sync`/`uv run`),
|
||||
# so the documented bare `uv sync` + `uv run pytest` workflow installs it without `--extra`.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -1,474 +1,128 @@
|
|||
"""Ingest layer — deterministic connectors + OKF-bundle materialization (I2 `file`/CSV, I4 `sql`).
|
||||
"""Door A ingest — the consumer seam over the shared ``llm-ingestion-okf`` library.
|
||||
|
||||
Implements the framework-neutral contract in ``shared/ingest-spec.md`` (normative, frozen):
|
||||
one JSON **manifest** per source coupling (§4), schema-validated fail-fast BEFORE any source
|
||||
call; a **connector** executes the manifest's extractions; **materialization** (§5–§7) maps
|
||||
each extraction to an OKF concept file with a provenance frontmatter layer and generates or
|
||||
updates the bundle's ``index.md`` (§6). Data reaches the model ONLY via OKF bundles — ingest
|
||||
runs BEFORE the optimiser loop, which consumes the bundle unchanged (§1–§2; no RAG, no
|
||||
query-time retrieval).
|
||||
An addition IN FRONT of the loop (``shared/ingest-spec.md`` §1): a deterministic step that
|
||||
couples the 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
|
||||
for the ``file``/``sql`` source types.
|
||||
|
||||
Invariants carried by this module (guarded by ``tests/test_ingest_loadbearing.py``):
|
||||
Since the library adoption (2026-07-20) the implementation IS the shared
|
||||
``llm-ingestion-okf`` library (git-pinned to ``v0.3.1``). The spec in ``shared/ingest-spec.md``
|
||||
remains the normative source — the library implements it, it does not replace it — and the
|
||||
repo-local goldens under ``examples/`` remain the fasit (verified byte-exact across all three
|
||||
source types, before and after the swap).
|
||||
|
||||
* **Verdict-layer reservation (§3, unwaivable):** a manifest mapping to ``type: verdict``
|
||||
is rejected at validation (case-insensitive) — the promotion gate is the ONLY path into
|
||||
the verdict layer. Generated filenames use the ``ingest-{id}`` namespace, disjoint from
|
||||
``index.md`` and ``promoted-verdict-*`` by the §4 id grammar.
|
||||
* **MAF-free:** pure stdlib + pydantic — NO ``agent_framework``, NO ``mcp``, and no imports
|
||||
of MAF-coupled project modules (only ``okf``/``retrieval``, both stdlib-pure), so the
|
||||
module is D7-portable like the rest of the context seam.
|
||||
* **Determinism (§5, §10, §11):** explicit required ``ingested_at`` (no wall-clock default),
|
||||
byte-stable rendering, idempotent re-materialization.
|
||||
This module is the ONE place the repo touches the library for Door A. It is deliberately thin:
|
||||
it re-exports the library's typed surface and keeps the historical ``materialize`` signature
|
||||
(keyword-only ``ingested_at``, ``list[Path]`` return) so callers and tests bind to a stable
|
||||
repo-local name rather than to the library's evolving one.
|
||||
|
||||
Decisions on spec-silent points are pinned here and in the golden fixtures (plan Assumptions):
|
||||
CSV cells are text-verbatim; relative ``root`` resolves against the manifest file's directory;
|
||||
CSV files are read ``utf-8-sig`` (a BOM never leaks into the first header cell); header cells
|
||||
are escaped identically to data cells; empty/ragged CSV and missing ``root``/``query`` fail
|
||||
fast as ``IngestError``. For ``sql`` (I4): ``connection_ref`` names an env var whose value is a
|
||||
filesystem path to a sqlite database opened read-only; the §5 numeric rules bite the typed
|
||||
values (``_sql_value_to_text``) — INTEGER→plain decimal, REAL→``repr`` (shortest round-trip),
|
||||
TEXT verbatim, SQL NULL→empty string, BLOB/other→``IngestError`` (never silent coercion). For
|
||||
``http`` (I6): transport is an injectable ``get`` callable (default ``_urllib_get``, the only
|
||||
socket path); ``credential_ref`` names an env secret resolved at run time (Bearer), never from the
|
||||
manifest, never logged, never stamped in frontmatter; the URL is ``base_url`` (one trailing slash
|
||||
stripped) + ``/`` + ``query`` (leading slash stripped); the body is decoded UTF-8, CRLF→LF, and
|
||||
rendered verbatim inside a fenced code block (NOT table-escaped); ``max_rows`` caps the LF-only
|
||||
line count (newline count, not ``str.splitlines()``), fail-fast; a response line beginning with a
|
||||
code fence → ``IngestError`` (cannot be safely fenced — never silent corruption). Network is a
|
||||
per-run ``materialize`` argument (``allow_network``); the manifest cannot grant network (§8).
|
||||
**Gating is the CALL SITE's responsibility (library README, "What is gated today: nothing").**
|
||||
Door A calls no guard function before writing to disk — ``materialize_bundle`` writes what it
|
||||
is given. The repo's own local-only posture still holds at this seam via ``allow_network``
|
||||
(default ``False``): an ``http`` source is refused fail-fast at the library's network gate
|
||||
unless a run explicitly opts in — the manifest can never grant itself network (§8, no silent
|
||||
egress). Untrusted-content scanning remains the separate, still-planned ``llm-ingestion-guard``
|
||||
wiring (see ``docs/plan/2026-07-16-llm-ingestion-guard-inclusion.md``); adopting this library
|
||||
does NOT provide it.
|
||||
|
||||
MAF-free (D7-portable), like the rest of the context seam: the library has zero runtime
|
||||
dependencies and imports no ``agent_framework`` / ``mcp``. Guarded by
|
||||
``tests/test_ingest_loadbearing.py::test_ingest_module_is_maf_free_and_context_layer_pure``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.error import URLError
|
||||
from urllib.parse import quote, urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from llm_ingestion_okf import (
|
||||
Extraction,
|
||||
FileSource,
|
||||
HttpSource,
|
||||
IngestError,
|
||||
IngestResult,
|
||||
Manifest,
|
||||
ManifestError,
|
||||
MaterializationError,
|
||||
NetworkGateError,
|
||||
RenderError,
|
||||
SourceError,
|
||||
SqlSource,
|
||||
materialize_bundle,
|
||||
)
|
||||
from llm_ingestion_okf import (
|
||||
load_manifest as _load_manifest,
|
||||
)
|
||||
from llm_ingestion_okf.connectors import (
|
||||
HttpGet,
|
||||
read_csv,
|
||||
read_http,
|
||||
read_sql,
|
||||
)
|
||||
from llm_ingestion_okf.manifest import generated_filename
|
||||
from llm_ingestion_okf.render import render_fenced_block, render_table
|
||||
|
||||
from portfolio_optimiser import okf
|
||||
from portfolio_optimiser.retrieval import safe_resolve
|
||||
# Two historical PRIVATE names the repo's existing tests bind to, re-exported so those bindings
|
||||
# survive the adoption unchanged. `_urllib_get` backs an identity assertion that the default http
|
||||
# transport is the real socket path rather than a stub (tests/test_ingest_http.py);
|
||||
# `_sql_value_to_text` is exercised directly for the §5 bool/BLOB refusals
|
||||
# (tests/test_ingest_sql.py). Not in `__all__` — they are not part of this module's contract.
|
||||
from llm_ingestion_okf.connectors import urllib_get as _urllib_get # noqa: F401
|
||||
from llm_ingestion_okf.render import sql_value_to_text as _sql_value_to_text # noqa: F401
|
||||
|
||||
_ID_PATTERN = r"^[a-z0-9][a-z0-9-]*$"
|
||||
# §5: ISO-8601 UTC with a Z suffix, validated by regex — datetime.fromisoformat rejects the
|
||||
# Z suffix on Python 3.10 (the repo's version floor), so it is deliberately NOT used.
|
||||
_INGESTED_AT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
||||
#: The repo's historical name for the §4 top-level model. The library calls it ``Manifest`` and
|
||||
#: carries the version in the ``manifest_version`` field (still ``1``); the alias keeps the
|
||||
#: repo-local name stable for callers that bound to it before the library adoption.
|
||||
ManifestV1 = Manifest
|
||||
|
||||
_LOGGER = logging.getLogger("portfolio_optimiser.ingest")
|
||||
|
||||
# §6: index.md is managed by the materializer (and reserved — the extraction-id grammar
|
||||
# keeps generated names disjoint from it by construction).
|
||||
_INDEX_NAME = "index.md"
|
||||
__all__ = [
|
||||
"Extraction",
|
||||
"FileSource",
|
||||
"HttpGet",
|
||||
"HttpSource",
|
||||
"IngestError",
|
||||
"IngestResult",
|
||||
"Manifest",
|
||||
"ManifestError",
|
||||
"ManifestV1",
|
||||
"MaterializationError",
|
||||
"NetworkGateError",
|
||||
"RenderError",
|
||||
"SourceError",
|
||||
"SqlSource",
|
||||
"generated_filename",
|
||||
"load_manifest",
|
||||
"materialize",
|
||||
"materialize_bundle",
|
||||
"read_csv",
|
||||
"read_http",
|
||||
"read_sql",
|
||||
"render_fenced_block",
|
||||
"render_table",
|
||||
]
|
||||
|
||||
|
||||
class IngestError(RuntimeError):
|
||||
"""A materialization-time refusal (cap exceeded, curated collision, malformed source)."""
|
||||
def load_manifest(path: str | Path) -> tuple[Manifest, str]:
|
||||
"""Validate a manifest fail-fast, BEFORE any source access (§4, §9).
|
||||
|
||||
Returns the validated manifest and the §5 provenance stamp ``{stem}@{hash16}`` — the first
|
||||
16 hex chars of SHA-256 over the manifest file's RAW bytes, so every generated file points
|
||||
at the exact manifest version that produced it. Raises ``ManifestError`` without touching
|
||||
any source.
|
||||
|
||||
class FileSource(BaseModel):
|
||||
"""§4 ``type: "file"`` — a local file catalogue; extraction paths resolve inside ``root``."""
|
||||
|
||||
type: Literal["file"]
|
||||
id: str = Field(pattern=_ID_PATTERN)
|
||||
root: str = Field(min_length=1)
|
||||
|
||||
|
||||
class SqlSource(BaseModel):
|
||||
"""§4 ``type: "sql"`` — connection resolved at run time from the env var named here."""
|
||||
|
||||
type: Literal["sql"]
|
||||
id: str = Field(pattern=_ID_PATTERN)
|
||||
connection_ref: str = Field(min_length=1)
|
||||
|
||||
|
||||
class HttpSource(BaseModel):
|
||||
"""§4 ``type: "http"`` — OPTIONAL extension point (connector arrives in I6, not I2)."""
|
||||
|
||||
type: Literal["http"]
|
||||
id: str = Field(pattern=_ID_PATTERN)
|
||||
base_url: str = Field(min_length=1)
|
||||
credential_ref: str | None = None
|
||||
|
||||
@field_validator("base_url")
|
||||
@classmethod
|
||||
def _no_embedded_credentials(cls, value: str) -> str:
|
||||
# §4: base_url MUST NOT embed credentials. Userinfo (user:pw@host) is THE URL
|
||||
# credential-embedding mechanism; query-param token heuristics are out of scope.
|
||||
split = urlsplit(value)
|
||||
if split.username is not None or split.password is not None:
|
||||
raise ValueError(f"base_url must not embed credentials, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
class Extraction(BaseModel):
|
||||
"""§4 extraction: names the generated ``ingest-{id}.md`` file and its query."""
|
||||
|
||||
id: str = Field(pattern=_ID_PATTERN)
|
||||
title: str = Field(min_length=1)
|
||||
query: str = Field(min_length=1)
|
||||
okf_type: str = Field(min_length=1)
|
||||
max_rows: int = Field(gt=0)
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def _single_line_normalized(cls, value: str) -> str:
|
||||
# §4: single-line. Whitespace runs are collapsed so the frontmatter rendering
|
||||
# (okf.render_frontmatter collapses runs) and the index label stay identical.
|
||||
if "\n" in value or "\r" in value:
|
||||
raise ValueError("title must be single-line")
|
||||
normalized = " ".join(value.split())
|
||||
if not normalized:
|
||||
raise ValueError("title must be non-empty")
|
||||
return normalized
|
||||
|
||||
@field_validator("okf_type")
|
||||
@classmethod
|
||||
def _verdict_layer_reserved(cls, value: str) -> str:
|
||||
# §3 (unwaivable): the verdict layer is RESERVED for the promotion gate. Enforced
|
||||
# fail-fast at manifest validation, before any source call.
|
||||
if "\n" in value or "\r" in value:
|
||||
raise ValueError("okf_type must be single-line")
|
||||
if value.strip().lower() == "verdict":
|
||||
raise ValueError(
|
||||
"okf_type 'verdict' is reserved: the promotion gate is the only path into "
|
||||
"the verdict layer (ingest spec §3)"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class ManifestV1(BaseModel):
|
||||
"""§4 top level — this spec defines version 1; queries are configuration, not code."""
|
||||
|
||||
manifest_version: Literal[1]
|
||||
source: FileSource | SqlSource | HttpSource = Field(discriminator="type")
|
||||
bundle_summary: str = Field(min_length=1)
|
||||
extractions: list[Extraction] = Field(min_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _extraction_ids_unique(self) -> ManifestV1:
|
||||
seen: set[str] = set()
|
||||
for extraction in self.extractions:
|
||||
if extraction.id in seen:
|
||||
raise ValueError(f"duplicate extraction id {extraction.id!r}")
|
||||
seen.add(extraction.id)
|
||||
return self
|
||||
|
||||
|
||||
def load_manifest(path: str | Path) -> tuple[ManifestV1, str]:
|
||||
"""Read and validate a manifest fail-fast, BEFORE any source access (§4, §9).
|
||||
|
||||
Returns the validated model and the §5 provenance stamp ``{stem}@{hash16}`` — the first
|
||||
16 hex chars of SHA-256 over the manifest file's RAW bytes, so every generated file
|
||||
points at the exact manifest version that produced it. Raises
|
||||
``pydantic.ValidationError`` / ``json.JSONDecodeError`` without touching any source."""
|
||||
The two-value return is the repo-local shape: the library returns the manifest alone and
|
||||
mints the stamp INSIDE ``materialize_bundle``, exposing no stamp helper (v0.3.1). The
|
||||
formula is therefore restated here, which is the one place this adapter is not purely
|
||||
delegating — so it is pinned by
|
||||
``tests/test_ingest_library_seam.py::test_adapter_stamp_equals_library_stamp``, which
|
||||
compares this value against the ``ingest_manifest`` the library actually writes. That test
|
||||
goes RED if either side's formula drifts."""
|
||||
manifest_path = Path(path)
|
||||
raw = manifest_path.read_bytes()
|
||||
stamp = f"{manifest_path.stem}@{hashlib.sha256(raw).hexdigest()[:16]}"
|
||||
manifest = ManifestV1.model_validate(json.loads(raw.decode("utf-8")))
|
||||
manifest = _load_manifest(manifest_path)
|
||||
stamp = f"{manifest_path.stem}@{hashlib.sha256(manifest_path.read_bytes()).hexdigest()[:16]}"
|
||||
return manifest, stamp
|
||||
|
||||
|
||||
def read_csv(root: str | Path, query: str, *, max_rows: int) -> tuple[list[str], list[list[str]]]:
|
||||
"""Execute a ``file``-source extraction: read the CSV at ``query`` inside ``root`` (§4).
|
||||
|
||||
Fail-closed boundary check via ``safe_resolve`` (a query can never read outside the
|
||||
catalogue); ``utf-8-sig`` so a BOM never leaks into the first header cell (pinned
|
||||
decision); streaming ``max_rows`` cap — exceeding it is an ERROR the moment it happens,
|
||||
never a silent truncation (§8). Input-shape failures (missing root/file, empty CSV,
|
||||
ragged row) raise ``IngestError`` fail-fast — never a leaked ``FileNotFoundError`` and
|
||||
never silent coercion. Pure: no logging, no writes (materialization owns the §8 log)."""
|
||||
root_path = Path(root)
|
||||
if not root_path.is_dir():
|
||||
raise IngestError(f"file-source root is not a directory: {root_path}")
|
||||
resolved = Path(safe_resolve(str(root_path), query))
|
||||
if not resolved.is_file():
|
||||
raise IngestError(f"extraction query does not resolve to a file: {query!r}")
|
||||
with resolved.open(encoding="utf-8-sig", newline="") as handle:
|
||||
reader = csv.reader(handle)
|
||||
header = next(reader, None)
|
||||
if header is None:
|
||||
raise IngestError(f"CSV has no header row: {query!r}")
|
||||
rows: list[list[str]] = []
|
||||
for row in reader:
|
||||
if len(rows) >= max_rows:
|
||||
raise IngestError(
|
||||
f"extraction {query!r} exceeds max_rows={max_rows} (error, never "
|
||||
"silent truncation — ingest spec §8)"
|
||||
)
|
||||
if len(row) != len(header):
|
||||
raise IngestError(
|
||||
f"ragged CSV row in {query!r}: expected {len(header)} cells, got {len(row)}"
|
||||
)
|
||||
rows.append(row)
|
||||
return header, rows
|
||||
|
||||
|
||||
def _sql_value_to_text(value: object) -> str:
|
||||
"""Convert one sqlite cell value to its §5 text form (BEFORE the §5 table escaping).
|
||||
|
||||
§5 for ``sql``: SQL NULL → empty string; integers in plain decimal; non-integral numbers
|
||||
in shortest round-trip decimal form; text verbatim; any other value type MUST fail —
|
||||
never silent coercion. sqlite3's default typing yields ``None``/``int``/``float``/``str``/
|
||||
``bytes``, so BLOB (bytes) and anything unexpected raise. ``bool`` is an ``int`` subclass
|
||||
sqlite never emits — guarded explicitly so a stray one can never render as ``str(True)``."""
|
||||
if value is None:
|
||||
return "" # SQL NULL → empty string
|
||||
if isinstance(value, bool):
|
||||
raise IngestError(
|
||||
f"unsupported SQL cell type bool ({value!r}) — never silent coercion (spec §5)"
|
||||
)
|
||||
if isinstance(value, int):
|
||||
return str(value) # plain decimal (arbitrary precision)
|
||||
if isinstance(value, float):
|
||||
# repr is Python's shortest round-trip decimal form; extreme magnitudes use E-notation.
|
||||
return repr(value)
|
||||
if isinstance(value, str):
|
||||
return value # verbatim; table escaping happens in render_table
|
||||
raise IngestError(
|
||||
f"unsupported SQL cell type {type(value).__name__} — ingest spec §5 requires "
|
||||
"integer/float/text/NULL; any other type MUST fail (never silent coercion)"
|
||||
)
|
||||
|
||||
|
||||
def read_sql(
|
||||
connection_ref: str, query: str, *, max_rows: int
|
||||
) -> tuple[list[str], list[list[str]]]:
|
||||
"""Execute a ``sql``-source extraction: a read-only SELECT against the sqlite database
|
||||
whose path is resolved at run time from the env var ``connection_ref`` names (§4).
|
||||
|
||||
Pinned I4 decision: ``connection_ref`` names an environment variable whose value is a
|
||||
filesystem PATH to a sqlite database (the I4 sqlite-fixture reading of §4's "connection
|
||||
string or database path"). Opened read-only (``mode=ro``) so a write in ``query`` fails
|
||||
at the DB — §4's "SHOULD enforce read-only" honoured robustly, not by fragile string
|
||||
parsing; ``Connection.execute`` runs exactly one statement (§4). Cells are converted to
|
||||
their §5 text form (``_sql_value_to_text``); the streaming ``max_rows`` cap is an ERROR
|
||||
the moment it is exceeded, never a silent truncation (§8). Env-unset, missing file, and
|
||||
any ``sqlite3`` error (syntax, write attempt, corruption) raise ``IngestError`` fail-fast.
|
||||
Pure: no writes, no logging (materialization owns the §8 log)."""
|
||||
dsn = os.environ.get(connection_ref)
|
||||
if not dsn:
|
||||
raise IngestError(
|
||||
f"sql source connection_ref {connection_ref!r} is not set in the environment "
|
||||
"(paths/credentials resolve at run time, never from the manifest — §4)"
|
||||
)
|
||||
db_file = Path(dsn)
|
||||
if not db_file.is_file():
|
||||
raise IngestError(
|
||||
f"sql connection_ref {connection_ref!r} points at a missing database file: {db_file}"
|
||||
)
|
||||
# quote keeps '/' but encodes spaces/'?'/'#' so an odd path can't corrupt the URI; the
|
||||
# absolute-path form file:/abs/path is sqlite's documented read-only open.
|
||||
uri = f"file:{quote(str(db_file))}?mode=ro"
|
||||
try:
|
||||
with closing(sqlite3.connect(uri, uri=True)) as conn:
|
||||
cursor = conn.execute(query)
|
||||
if cursor.description is None: # a SELECT always has columns; defensive
|
||||
raise IngestError(f"sql extraction returned no columns: {query!r}")
|
||||
header = [column[0] for column in cursor.description]
|
||||
rows: list[list[str]] = []
|
||||
for row in cursor:
|
||||
if len(rows) >= max_rows:
|
||||
raise IngestError(
|
||||
f"extraction {query!r} exceeds max_rows={max_rows} (error, never "
|
||||
"silent truncation — ingest spec §8)"
|
||||
)
|
||||
rows.append([_sql_value_to_text(value) for value in row])
|
||||
except (sqlite3.Error, sqlite3.Warning) as exc:
|
||||
raise IngestError(f"sql extraction failed for query {query!r}: {exc}") from exc
|
||||
return header, rows
|
||||
|
||||
|
||||
# --- http source (I6): injectable transport seam + verbatim fenced body --------------------------
|
||||
|
||||
#: The http transport seam: ``(url, credential) -> response body text``. Injecting a canned
|
||||
#: implementation in tests keeps the suite socket-free; the default ``_urllib_get`` is the ONLY
|
||||
#: path that opens a socket, and it is reached ONLY inside the gated http branch of materialize.
|
||||
HttpGet = Callable[[str, str | None], str]
|
||||
|
||||
|
||||
def _urllib_get(url: str, credential: str | None) -> str:
|
||||
"""The stdlib GET — the ONLY socket path in this module (I6).
|
||||
|
||||
Builds a ``GET`` request, attaches ``Authorization: Bearer {credential}`` iff a credential was
|
||||
resolved (never otherwise), reads the body and decodes it UTF-8. Transport / decode failures
|
||||
wrap as ``IngestError`` WITHOUT the secret — the credential rides in the header, never in
|
||||
``url`` (base_url embedded credentials are already rejected at schema validation)."""
|
||||
headers = {"Authorization": f"Bearer {credential}"} if credential is not None else {}
|
||||
request = Request(url, headers=headers, method="GET")
|
||||
try:
|
||||
with urlopen(request) as response:
|
||||
raw = response.read()
|
||||
return raw.decode("utf-8")
|
||||
except (URLError, OSError, UnicodeDecodeError) as exc:
|
||||
raise IngestError(f"http GET failed for {url!r}: {exc}") from exc
|
||||
|
||||
|
||||
def read_http(
|
||||
base_url: str,
|
||||
query: str,
|
||||
*,
|
||||
max_rows: int,
|
||||
credential_ref: str | None = None,
|
||||
get: HttpGet = _urllib_get,
|
||||
) -> str:
|
||||
"""Execute an ``http``-source extraction: GET ``base_url`` joined with ``query`` via the
|
||||
injectable ``get`` seam (default stdlib ``_urllib_get``), returning the response body for
|
||||
verbatim fenced rendering (§4/§5, I6).
|
||||
|
||||
Pinned I6 decisions (spec-silent, mirroring the ``sql`` template): ``credential_ref`` names an
|
||||
environment variable holding the secret, resolved at run time (present-but-unset → fail-fast,
|
||||
like ``read_sql``; ``None`` → no auth) — never read from the manifest, never logged, never
|
||||
stamped in frontmatter. URL = ``base_url`` with one trailing slash stripped + ``/`` + ``query``
|
||||
with leading slashes stripped (explicit join, not ``urljoin`` — predictable path semantics).
|
||||
The body is decoded UTF-8 (in ``get``), CRLF→LF normalized, and capped on its LF-only line
|
||||
count — the newline count (+1 for a missing trailing newline), NOT ``str.splitlines()`` (which
|
||||
also splits on the extended Unicode set and would over-count vs the LF-only rendered file).
|
||||
Exceeding ``max_rows`` is an ERROR, never a truncation (§8). A body line beginning with a code
|
||||
fence (after stripping up to 3 leading spaces — a CommonMark closing fence may be indented) →
|
||||
``IngestError``: a body that cannot be safely embedded in a fenced code block is an error,
|
||||
never silently-corrupted markdown. Pure: no writes, no logging (materialization owns the §8
|
||||
log)."""
|
||||
if credential_ref is None:
|
||||
credential: str | None = None
|
||||
else:
|
||||
credential = os.environ.get(credential_ref)
|
||||
if not credential:
|
||||
raise IngestError(
|
||||
f"http source credential_ref {credential_ref!r} is not set in the environment "
|
||||
"(credentials resolve at run time, never from the manifest — §4)"
|
||||
)
|
||||
url = base_url.rstrip("/") + "/" + query.lstrip("/")
|
||||
normalized = get(url, credential).replace("\r\n", "\n").replace("\r", "\n")
|
||||
for line in normalized.split("\n"):
|
||||
if line.lstrip(" ").startswith("```"):
|
||||
raise IngestError(
|
||||
f"http extraction {query!r} contains a line that is a code-fence marker "
|
||||
"(```) — cannot be safely embedded in a fenced code block; fail-fast, never "
|
||||
"silent corruption (ingest spec §8)"
|
||||
)
|
||||
newline_count = normalized.count("\n")
|
||||
line_count = (
|
||||
newline_count if (normalized == "" or normalized.endswith("\n")) else newline_count + 1
|
||||
)
|
||||
if line_count > max_rows:
|
||||
raise IngestError(
|
||||
f"http extraction {query!r} exceeds max_rows={max_rows} (error, never silent "
|
||||
"truncation — ingest spec §8)"
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _render_fenced_block(body: str) -> str:
|
||||
"""Render an http response body verbatim inside a fenced code block (§5, I6): LF-only, exactly
|
||||
one trailing newline, the body's own trailing newlines stripped so the closing fence sits
|
||||
flush. NOT table-escaped — pipe/backslash survive verbatim (the discriminator vs
|
||||
``render_table``)."""
|
||||
return f"```\n{body.rstrip(chr(10))}\n```\n"
|
||||
|
||||
|
||||
def _escape_cell(value: str) -> str:
|
||||
# §5 escape order is load-bearing: `\` FIRST (else the backslash introduced by
|
||||
# pipe-escaping gets double-escaped), then `|`, then newlines → single space with
|
||||
# CRLF treated as ONE unit.
|
||||
value = value.replace("\\", "\\\\").replace("|", "\\|")
|
||||
return value.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")
|
||||
|
||||
|
||||
def render_table(header: list[str], rows: list[list[str]]) -> str:
|
||||
"""Render extraction rows as the §5 markdown-table body (LF-only, one trailing newline).
|
||||
|
||||
Cells are strings by the time they reach here: the ``file``/CSV path is text-verbatim, and
|
||||
``sql`` values are converted to their §5 text form in ``read_sql`` (``_sql_value_to_text``:
|
||||
integers/floats/NULL) BEFORE this escaping. Header cells are escaped identically to data
|
||||
cells (pinned decision)."""
|
||||
|
||||
def line(cells: list[str]) -> str:
|
||||
return "| " + " | ".join(_escape_cell(cell) for cell in cells) + " |"
|
||||
|
||||
separator = "| " + " | ".join("---" for _ in header) + " |"
|
||||
return "\n".join([line(header), separator, *(line(row) for row in rows)]) + "\n"
|
||||
|
||||
|
||||
def _render_concept_file(
|
||||
manifest: ManifestV1, extraction: Extraction, body: str, *, ingested_at: str, stamp: str
|
||||
) -> str:
|
||||
# §5 frontmatter: exactly these keys, in exactly this order (insertion order is
|
||||
# preserved by okf.render_frontmatter, which also single-lines every value).
|
||||
frontmatter = {
|
||||
"type": extraction.okf_type,
|
||||
"title": extraction.title,
|
||||
"source_system": manifest.source.id,
|
||||
"source_query": extraction.query,
|
||||
"ingested_at": ingested_at,
|
||||
"ingest_manifest": stamp,
|
||||
"generated": "true",
|
||||
}
|
||||
return f"---\n{okf.render_frontmatter(frontmatter)}\n---\n\n{body}"
|
||||
|
||||
|
||||
def _write_bytes(bundle_dir: Path, name: str, content: str) -> Path:
|
||||
# LF-only + exactly one trailing newline are byte-level guarantees (§5), so the write
|
||||
# is raw bytes — never Path.write_text, whose platform newline translation would break
|
||||
# golden byte-determinism. Path-safety via the same fail-closed seam okf.py uses.
|
||||
resolved = Path(safe_resolve(str(bundle_dir), name))
|
||||
resolved.write_bytes(content.encode("utf-8"))
|
||||
return resolved
|
||||
|
||||
|
||||
def _is_ingest_owned(path: Path) -> bool:
|
||||
# §3/§5 ownership: the ingest stamp is `generated: true` AND an `ingest_manifest`
|
||||
# reference. parse_frontmatter returns STRINGS ("true", never booleans). Promoted
|
||||
# verdict files carry neither key, so they can never classify as ingest-owned.
|
||||
frontmatter = okf.parse_frontmatter(path)
|
||||
return frontmatter.get("generated") == "true" and "ingest_manifest" in frontmatter
|
||||
|
||||
|
||||
# One managed index line: `- [<label>](<target>)`. Anchored full-line — removal and label
|
||||
# refresh key on this exact shape for specific ingest targets, NEVER a bare substring (a
|
||||
# promoted verdict's line has the same shape but a non-ingest target; curated prose
|
||||
# mentioning a target inline does not match the full-line form).
|
||||
_MANAGED_LINE_RE = re.compile(r"^- \[(?P<label>[^\]]*)\]\((?P<target>[^)]+)\)$")
|
||||
|
||||
|
||||
def _update_index_lines(
|
||||
index_path: Path, removed_targets: set[str], labels_by_target: dict[str, str]
|
||||
) -> None:
|
||||
"""§6 index maintenance on an EXISTING index: drop managed lines whose target is an
|
||||
ingest file removed in this run; refresh in place a managed label that no longer equals
|
||||
the extraction title. Every other line is preserved verbatim, in order."""
|
||||
original = index_path.read_bytes().decode("utf-8")
|
||||
lines = original.splitlines(keepends=True)
|
||||
updated: list[str] = []
|
||||
changed = False
|
||||
for line in lines:
|
||||
content = line.rstrip("\r\n")
|
||||
ending = line[len(content) :]
|
||||
match = _MANAGED_LINE_RE.match(content)
|
||||
if match is not None:
|
||||
target = match.group("target")
|
||||
if target in removed_targets:
|
||||
changed = True
|
||||
continue
|
||||
new_label = labels_by_target.get(target)
|
||||
if new_label is not None and match.group("label") != new_label:
|
||||
line = f"- [{new_label}]({target})" + ending
|
||||
changed = True
|
||||
updated.append(line)
|
||||
if changed:
|
||||
index_path.write_bytes("".join(updated).encode("utf-8"))
|
||||
|
||||
|
||||
def materialize(
|
||||
manifest_path: str | Path,
|
||||
bundle_dir: str | Path,
|
||||
|
|
@ -477,122 +131,23 @@ def materialize(
|
|||
allow_network: bool = False,
|
||||
http_get: HttpGet | None = None,
|
||||
) -> list[Path]:
|
||||
"""Materialize a manifest's extractions into an OKF bundle (§5): the I2 invocation
|
||||
surface (a ``python -m`` CLI is deliberately deferred).
|
||||
"""Materialize a manifest's extractions into an OKF bundle (§5), returning the generated
|
||||
concept-file paths in extraction order.
|
||||
|
||||
Three explicit inputs — manifest, target bundle dir, and ``ingested_at`` (REQUIRED
|
||||
keyword, NO wall-clock default, stamped verbatim; mirrors the promotion gate's
|
||||
timestamp rule). Deterministic and offline: zero model calls, zero network for the
|
||||
``file``/``sql`` source types. All extractions execute and render IN MEMORY before the
|
||||
first disk mutation (crash-window mitigation for the non-atomic §5 replace sequence;
|
||||
recovery = idempotent re-run, §10). Source calls are logged per §8 (which source,
|
||||
when = the ``ingested_at`` argument, row count) — never cell contents, never secrets.
|
||||
|
||||
``allow_network`` (I6, §8) is the per-run network opt-in: an ``http`` source is refused
|
||||
fail-fast unless it is set — the manifest itself cannot grant network access (local-only
|
||||
default, no silent egress). ``http_get`` optionally injects the transport seam (default
|
||||
``_urllib_get``, the only socket path); both are ignored for ``file``/``sql`` sources."""
|
||||
if not _INGESTED_AT_RE.match(ingested_at):
|
||||
raise ValueError(
|
||||
f"ingested_at must be ISO-8601 UTC with a Z suffix "
|
||||
f"(e.g. 2026-07-03T12:00:00Z), got {ingested_at!r}"
|
||||
)
|
||||
manifest_file = Path(manifest_path)
|
||||
manifest, stamp = load_manifest(manifest_file)
|
||||
source = manifest.source
|
||||
# §8 network gate (I6): refuse http fail-fast BEFORE any source access unless the per-run
|
||||
# flag is set. The `and not allow_network` clause is the load-bearing seam — the manifest
|
||||
# cannot grant itself network access (local-only default, no silent egress).
|
||||
if isinstance(source, HttpSource) and not allow_network:
|
||||
raise IngestError(
|
||||
f"source type {source.type!r} requires the per-run network opt-in "
|
||||
"(materialize(..., allow_network=True)) — the manifest cannot grant itself network "
|
||||
"access (ingest spec §8, local-only default, no silent egress)"
|
||||
)
|
||||
# Pinned decision (file): a relative root resolves against the manifest file's directory —
|
||||
# never the process cwd, or the extraction would not be reproducible. (sql resolves its
|
||||
# source at run time from the env var connection_ref names, inside read_sql.)
|
||||
if isinstance(source, FileSource):
|
||||
root = Path(source.root)
|
||||
if not root.is_absolute():
|
||||
root = manifest_file.parent / root
|
||||
|
||||
# Stage everything in memory BEFORE any disk mutation.
|
||||
staged: list[tuple[str, str]] = []
|
||||
for extraction in manifest.extractions:
|
||||
if isinstance(source, FileSource):
|
||||
header, rows = read_csv(root, extraction.query, max_rows=extraction.max_rows)
|
||||
body = render_table(header, rows)
|
||||
row_count = len(rows)
|
||||
elif isinstance(source, HttpSource):
|
||||
# The gate above guarantees allow_network here; _urllib_get is the only socket path.
|
||||
get = http_get if http_get is not None else _urllib_get
|
||||
text = read_http(
|
||||
source.base_url,
|
||||
extraction.query,
|
||||
max_rows=extraction.max_rows,
|
||||
credential_ref=source.credential_ref,
|
||||
get=get,
|
||||
)
|
||||
body = _render_fenced_block(text) # verbatim fenced block, NOT table-escaped
|
||||
row_count = len(text.splitlines())
|
||||
else: # SqlSource
|
||||
header, rows = read_sql(
|
||||
source.connection_ref, extraction.query, max_rows=extraction.max_rows
|
||||
)
|
||||
body = render_table(header, rows)
|
||||
row_count = len(rows)
|
||||
_LOGGER.info(
|
||||
"source call: source=%s ingested_at=%s rows=%d",
|
||||
source.id,
|
||||
The historical repo signature over the library's ``materialize_bundle``: ``ingested_at`` is
|
||||
a REQUIRED keyword (no wall-clock default, stamped verbatim; mirrors the promotion gate's
|
||||
timestamp rule), and the return is the ``written`` paths as a list rather than the
|
||||
library's ``IngestResult``. ``allow_network`` (§8) is the per-run network opt-in — an
|
||||
``http`` source is refused fail-fast unless it is set, so the manifest itself can never
|
||||
grant network access. ``http_get`` optionally injects the transport seam; both are ignored
|
||||
for ``file``/``sql`` sources. Call ``materialize_bundle`` directly when the stamp is
|
||||
wanted alongside the paths."""
|
||||
return list(
|
||||
materialize_bundle(
|
||||
Path(manifest_path),
|
||||
Path(bundle_dir),
|
||||
ingested_at,
|
||||
row_count,
|
||||
)
|
||||
content = _render_concept_file(
|
||||
manifest, extraction, body, ingested_at=ingested_at, stamp=stamp
|
||||
)
|
||||
staged.append((f"ingest-{extraction.id}.md", content))
|
||||
|
||||
# Disk phase. safe_resolve never creates directories and the byte-writer has no
|
||||
# implicit mkdir (unlike okf.write_concept_file) — create the bundle dir explicitly.
|
||||
bundle = Path(bundle_dir)
|
||||
bundle.mkdir(parents=True, exist_ok=True)
|
||||
staged_names = {name for name, _ in staged}
|
||||
|
||||
# §3 ownership scan (sorted for determinism): only files carrying the ingest stamp are
|
||||
# ours to replace.
|
||||
owned = {
|
||||
path.name
|
||||
for path in sorted(bundle.glob("*.md"))
|
||||
if path.name != _INDEX_NAME and _is_ingest_owned(path)
|
||||
}
|
||||
# §3 collision gate — BEFORE any mutation: a staged filename occupied by a file WITHOUT
|
||||
# the stamp is curated content; never overwrite it.
|
||||
for name in sorted(staged_names):
|
||||
if (bundle / name).is_file() and name not in owned:
|
||||
raise IngestError(
|
||||
f"generated filename {name!r} collides with an existing file that does not "
|
||||
"carry the ingest stamp — refusing to overwrite curated content (ingest "
|
||||
"spec §3)"
|
||||
)
|
||||
|
||||
# §5 replacement: remove every stamped file, then write the new set.
|
||||
for name in sorted(owned):
|
||||
(bundle / name).unlink()
|
||||
written = [_write_bytes(bundle, name, content) for name, content in staged]
|
||||
|
||||
# §6 index generation — the LAST disk mutation. Fresh index gets bundle_summary as its
|
||||
# body (no frontmatter — spec-minimal pinned decision); an existing index keeps every
|
||||
# unmanaged line verbatim.
|
||||
index_path = bundle / _INDEX_NAME
|
||||
labels_by_target = {
|
||||
f"ingest-{extraction.id}.md": extraction.title for extraction in manifest.extractions
|
||||
}
|
||||
if not index_path.is_file():
|
||||
_write_bytes(bundle, _INDEX_NAME, manifest.bundle_summary + "\n")
|
||||
else:
|
||||
removed_targets = owned - staged_names
|
||||
_update_index_lines(index_path, removed_targets, labels_by_target)
|
||||
for extraction in manifest.extractions:
|
||||
okf.link_in_index(str(bundle), f"ingest-{extraction.id}.md", extraction.title)
|
||||
return written
|
||||
allow_network=allow_network,
|
||||
http_get=http_get,
|
||||
).written
|
||||
)
|
||||
|
|
|
|||
127
tests/test_ingest_library_seam.py
Normal file
127
tests/test_ingest_library_seam.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""Load-bearing checks on the consumer seam over ``llm-ingestion-okf`` (adopted 2026-07-20).
|
||||
|
||||
Door A is no longer implemented here — ``src/portfolio_optimiser/ingest.py`` is a thin adapter
|
||||
over the shared library, so the spec §4–§6 rules are covered by the library's own suite plus the
|
||||
repo's golden regressions. What is NOT covered by either is the seam itself: the places where the
|
||||
adapter restates something instead of delegating, and the boundary claims the adapter's docstring
|
||||
makes. Those are pinned here, because a docstring that no test can falsify is decoration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from llm_ingestion_okf import NetworkGateError
|
||||
|
||||
from portfolio_optimiser import okf
|
||||
from portfolio_optimiser.ingest import load_manifest, materialize, materialize_bundle
|
||||
|
||||
_INGESTED_AT = "2026-07-03T12:00:00Z"
|
||||
|
||||
_MANIFEST: dict[str, Any] = {
|
||||
"manifest_version": 1,
|
||||
"source": {"type": "file", "id": "prosjekt-arkiv", "root": "fixture"},
|
||||
"bundle_summary": "Cost extracts from the project archive.",
|
||||
"extractions": [
|
||||
{
|
||||
"id": "costs",
|
||||
"title": "Project costs",
|
||||
"query": "costs.csv",
|
||||
"okf_type": "dataset",
|
||||
"max_rows": 100,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _project(tmp_path: Path, source: dict[str, Any] | None = None) -> tuple[Path, Path]:
|
||||
fixture = tmp_path / "fixture"
|
||||
fixture.mkdir()
|
||||
(fixture / "costs.csv").write_bytes(b"item,cost_nok\nled-retrofit,120000\n")
|
||||
data = json.loads(json.dumps(_MANIFEST))
|
||||
if source is not None:
|
||||
data["source"] = source
|
||||
manifest_path = tmp_path / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(data), encoding="utf-8")
|
||||
return manifest_path, tmp_path / "bundle"
|
||||
|
||||
|
||||
def test_adapter_stamp_equals_library_stamp(tmp_path: Path) -> None:
|
||||
"""LOAD-BEARING ANTI-DRIFT: the adapter's ``load_manifest`` restates the §5 stamp formula
|
||||
because the library (v0.3.1) mints the stamp inside ``materialize_bundle`` and exposes no
|
||||
stamp helper. That is the ONE place the adapter is not purely delegating, so the two
|
||||
formulas can drift apart silently — this compares the adapter's value against the stamp the
|
||||
library actually writes into ``ingest_manifest`` frontmatter. RED the moment either side
|
||||
changes how the stamp is computed."""
|
||||
manifest_path, bundle_dir = _project(tmp_path)
|
||||
|
||||
_, adapter_stamp = load_manifest(manifest_path)
|
||||
result = materialize_bundle(manifest_path, bundle_dir, _INGESTED_AT)
|
||||
|
||||
assert adapter_stamp == result.stamp
|
||||
# ...and the stamp is what actually landed on disk, not merely an agreeing computation.
|
||||
written_stamp = okf.parse_frontmatter(result.written[0])["ingest_manifest"]
|
||||
assert adapter_stamp == written_stamp
|
||||
# §5 shape, pinned independently of both implementations.
|
||||
expected = "manifest@" + hashlib.sha256(manifest_path.read_bytes()).hexdigest()[:16]
|
||||
assert adapter_stamp == expected
|
||||
|
||||
|
||||
def test_adapter_returns_written_paths_in_extraction_order(tmp_path: Path) -> None:
|
||||
"""The adapter keeps the repo's historical ``list[Path]`` return over the library's
|
||||
``IngestResult``. RED if the unwrapping is dropped or reordered."""
|
||||
manifest_path, bundle_dir = _project(tmp_path)
|
||||
written = materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||||
assert isinstance(written, list)
|
||||
assert [p.name for p in written] == ["ingest-costs.md"]
|
||||
assert all(p.is_file() for p in written)
|
||||
|
||||
|
||||
def test_local_only_default_holds_at_the_adapter_seam(tmp_path: Path) -> None:
|
||||
"""LOAD-BEARING BOUNDARY (§8): the adapter's docstring claims an ``http`` source is refused
|
||||
unless a run explicitly opts in — the manifest can never grant itself network. The library
|
||||
owns the gate, but the adapter owns the DEFAULT it is called with. RED if the adapter ever
|
||||
starts passing ``allow_network=True`` (or forwards a manifest-derived value)."""
|
||||
manifest_path, bundle_dir = _project(
|
||||
tmp_path, source={"type": "http", "id": "api", "base_url": "https://host/api"}
|
||||
)
|
||||
|
||||
with pytest.raises(NetworkGateError) as exc:
|
||||
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||||
assert exc.value.code == "network_opt_in_missing"
|
||||
assert not bundle_dir.exists(), "a refused network source must write NOTHING"
|
||||
|
||||
# The opt-in is reachable, so the refusal above is a real default rather than a dead path.
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_get(url: str, credential: str | None) -> str:
|
||||
calls.append(url)
|
||||
return "ok\n"
|
||||
|
||||
materialize(
|
||||
manifest_path,
|
||||
bundle_dir,
|
||||
ingested_at=_INGESTED_AT,
|
||||
allow_network=True,
|
||||
http_get=fake_get,
|
||||
)
|
||||
assert calls == ["https://host/api/costs.csv"]
|
||||
|
||||
|
||||
def test_adapter_does_not_reimplement_door_a(tmp_path: Path) -> None:
|
||||
"""The adoption's point: Door A improves in ONE place. This pins the adapter as thin — it
|
||||
must not regrow a local connector/renderer/materializer. RED if the module starts carrying
|
||||
the machinery it delegates (csv/sqlite/urllib reading, table escaping, frontmatter
|
||||
rendering), which is how a 'temporary local fix' silently forks the shared implementation."""
|
||||
source = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser" / "ingest.py"
|
||||
).read_text(encoding="utf-8")
|
||||
for forbidden in ("import csv", "import sqlite3", "urlopen", "def render_table", "\\\\|"):
|
||||
assert forbidden not in source, (
|
||||
f"ingest.py reimplements Door A machinery ({forbidden!r}) — it must delegate to "
|
||||
"llm-ingestion-okf, not fork it"
|
||||
)
|
||||
|
|
@ -40,7 +40,7 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from llm_ingestion_okf import ManifestError
|
||||
|
||||
from portfolio_optimiser import okf
|
||||
from portfolio_optimiser.ingest import Extraction, materialize
|
||||
|
|
@ -104,8 +104,9 @@ def test_verdict_typed_manifest_is_rejected_and_writes_nothing(tmp_path: Path) -
|
|||
manifest_path = _write_project(tmp_path, extractions)
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ManifestError) as exc:
|
||||
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||||
assert exc.value.code == "okf_type_reserved"
|
||||
|
||||
assert not bundle_dir.exists(), (
|
||||
"a verdict-typed manifest must write NOTHING (no index, no files)"
|
||||
|
|
|
|||
|
|
@ -4,8 +4,16 @@ Every malformed manifest raises at validation, BEFORE any source access (the sta
|
|||
discipline of method spec §10 / ingest spec §4 and §9's technical gate). The verdict-layer
|
||||
reservation (okf_type != verdict, case-insensitive) is enforced here — at the contract, never
|
||||
downstream — closing session-plan key assumption 2 (fail-fast manifest validation without
|
||||
network). Pattern: tests/test_contracts.py (inline dict constants + pytest.raises per
|
||||
malformation; fail-fast ordering proof mirrors test_no_chat_client_call_on_malformed_contract).
|
||||
network).
|
||||
|
||||
Since the ``llm-ingestion-okf`` adoption (2026-07-20) the contract is enforced by the shared
|
||||
library rather than by repo-local pydantic models. The INVARIANTS are unchanged — every
|
||||
malformation rejected before was verified to still be rejected — but the assertion vehicle
|
||||
moved: ``ManifestV1.model_validate(dict)`` → ``load_manifest_bytes(bytes)``, and
|
||||
``pydantic.ValidationError`` → ``ManifestError``. The library has zero runtime dependencies
|
||||
BY DESIGN, so pydantic is not available to it. These tests now also pin the refusal ``code``,
|
||||
which is the library's documented stability contract (the message text explicitly is not) —
|
||||
a strictly sharper assertion than "some validation error was raised".
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
|
@ -15,7 +23,8 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from llm_ingestion_okf import FileSource, HttpSource, ManifestError, SqlSource
|
||||
from llm_ingestion_okf.manifest import load_manifest_bytes
|
||||
|
||||
from portfolio_optimiser.ingest import ManifestV1, load_manifest
|
||||
|
||||
|
|
@ -54,11 +63,19 @@ def _variant(**overrides: Any) -> dict[str, Any]:
|
|||
return data
|
||||
|
||||
|
||||
def _validate(data: dict[str, Any]) -> ManifestV1:
|
||||
"""In-memory validation without touching disk — the library's equivalent of the pydantic
|
||||
``model_validate`` these tests used before the adoption."""
|
||||
return load_manifest_bytes(json.dumps(data).encode("utf-8"))
|
||||
|
||||
|
||||
def test_valid_file_manifest_loads_with_stamp(tmp_path: Path) -> None:
|
||||
path = _write(tmp_path, _MANIFEST)
|
||||
manifest, stamp = load_manifest(path)
|
||||
assert isinstance(manifest, ManifestV1)
|
||||
assert manifest.source.type == "file"
|
||||
# The library's source models drop the `type` discriminator as a FIELD (it is consumed by
|
||||
# validation dispatch), so the variant is identified by class rather than by `.type`.
|
||||
assert isinstance(manifest.source, FileSource)
|
||||
assert [e.id for e in manifest.extractions] == ["costs", "meta"]
|
||||
# §5: stamp = {stem}@{first 16 hex of SHA-256 over the manifest file's RAW bytes}.
|
||||
expected = "manifest@" + hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
||||
|
|
@ -69,59 +86,62 @@ def test_each_missing_top_level_field_raises(tmp_path: Path) -> None:
|
|||
for field in ("manifest_version", "source", "bundle_summary", "extractions"):
|
||||
data = copy.deepcopy(_MANIFEST)
|
||||
del data[field]
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ManifestError):
|
||||
load_manifest(_write(tmp_path, data, name=f"missing-{field}.json"))
|
||||
|
||||
|
||||
def test_manifest_version_other_than_1_rejected(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ManifestError) as exc:
|
||||
load_manifest(_write(tmp_path, _variant(manifest_version=2)))
|
||||
assert exc.value.code == "manifest_version_unsupported"
|
||||
|
||||
|
||||
def test_empty_extractions_rejected(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ManifestError):
|
||||
load_manifest(_write(tmp_path, _variant(extractions=[])))
|
||||
|
||||
|
||||
def test_bad_id_grammar_rejected(tmp_path: Path) -> None:
|
||||
def test_bad_id_grammar_rejected() -> None:
|
||||
# §4 grammar for source.id and extraction.id: ^[a-z0-9][a-z0-9-]*$
|
||||
for bad in ("Upper", "-leading", "", "space id", "æøå"):
|
||||
data = copy.deepcopy(_MANIFEST)
|
||||
data["source"]["id"] = bad
|
||||
with pytest.raises(ValidationError):
|
||||
ManifestV1.model_validate(data)
|
||||
with pytest.raises(ManifestError):
|
||||
_validate(data)
|
||||
data = copy.deepcopy(_MANIFEST)
|
||||
data["extractions"][0]["id"] = bad
|
||||
with pytest.raises(ValidationError):
|
||||
ManifestV1.model_validate(data)
|
||||
with pytest.raises(ManifestError):
|
||||
_validate(data)
|
||||
|
||||
|
||||
def test_duplicate_extraction_ids_rejected(tmp_path: Path) -> None:
|
||||
data = copy.deepcopy(_MANIFEST)
|
||||
data["extractions"][1]["id"] = data["extractions"][0]["id"]
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ManifestError) as exc:
|
||||
load_manifest(_write(tmp_path, data))
|
||||
assert exc.value.code == "extraction_id_duplicate"
|
||||
|
||||
|
||||
def test_nonpositive_max_rows_rejected(tmp_path: Path) -> None:
|
||||
for bad in (0, -1):
|
||||
data = copy.deepcopy(_MANIFEST)
|
||||
data["extractions"][0]["max_rows"] = bad
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ManifestError):
|
||||
load_manifest(_write(tmp_path, data, name=f"rows-{bad}.json"))
|
||||
|
||||
|
||||
def test_unknown_source_type_rejected(tmp_path: Path) -> None:
|
||||
data = copy.deepcopy(_MANIFEST)
|
||||
data["source"] = {"type": "ftp", "id": "x", "root": "fixture"}
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ManifestError) as exc:
|
||||
load_manifest(_write(tmp_path, data))
|
||||
assert exc.value.code == "source_type_unknown"
|
||||
|
||||
|
||||
def test_file_source_missing_root_rejected(tmp_path: Path) -> None:
|
||||
data = copy.deepcopy(_MANIFEST)
|
||||
del data["source"]["root"]
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ManifestError):
|
||||
load_manifest(_write(tmp_path, data))
|
||||
|
||||
|
||||
|
|
@ -132,46 +152,57 @@ def test_verdict_okf_type_rejected_case_insensitively(tmp_path: Path) -> None:
|
|||
for spelling in ("verdict", "Verdict", "VERDICT"):
|
||||
data = copy.deepcopy(_MANIFEST)
|
||||
data["extractions"][0]["okf_type"] = spelling
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ManifestError) as exc:
|
||||
load_manifest(_write(tmp_path, data, name=f"verdict-{spelling}.json"))
|
||||
assert exc.value.code == "okf_type_reserved"
|
||||
|
||||
|
||||
def test_multiline_title_rejected(tmp_path: Path) -> None:
|
||||
def test_multiline_title_rejected() -> None:
|
||||
for bad in ("line1\nline2", "line1\rline2", ""):
|
||||
data = copy.deepcopy(_MANIFEST)
|
||||
data["extractions"][0]["title"] = bad
|
||||
with pytest.raises(ValidationError):
|
||||
ManifestV1.model_validate(data)
|
||||
with pytest.raises(ManifestError):
|
||||
_validate(data)
|
||||
|
||||
|
||||
def test_title_whitespace_normalized() -> None:
|
||||
# Pinned decision: title runs are collapsed at validation so the frontmatter rendering
|
||||
# (okf.render_frontmatter collapses runs) and the index label are guaranteed identical.
|
||||
def test_title_whitespace_is_preserved_verbatim() -> None:
|
||||
"""ACCEPTED DIVERGENCE (2026-07-20), recorded rather than silently dropped.
|
||||
|
||||
The repo previously COLLAPSED title whitespace runs at validation, so the frontmatter
|
||||
title and the index label were guaranteed byte-identical. The library stores the title
|
||||
verbatim instead: ``_render_frontmatter`` still collapses runs when writing frontmatter,
|
||||
but the §6 index label is written raw — so for a title with irregular internal whitespace
|
||||
the two now differ. Both behaviours are spec-conformant: ``shared/ingest-spec.md`` is
|
||||
SILENT on normalization, and the old behaviour was a repo-local pinned decision, not a
|
||||
spec requirement.
|
||||
|
||||
This test pins the CURRENT behaviour so the divergence cannot drift unnoticed. It goes RED
|
||||
if the library starts normalizing — which is the desired end state, and is why the point is
|
||||
queued as a commons-amendment candidate so both stacks pin the same answer in the spec.
|
||||
The golden bundles are unaffected (their titles carry no irregular whitespace)."""
|
||||
data = copy.deepcopy(_MANIFEST)
|
||||
data["extractions"][0]["title"] = " Project costs "
|
||||
manifest = ManifestV1.model_validate(data)
|
||||
assert manifest.extractions[0].title == "Project costs"
|
||||
manifest = _validate(data)
|
||||
assert manifest.extractions[0].title == " Project costs "
|
||||
|
||||
|
||||
def test_base_url_embedded_credentials_rejected() -> None:
|
||||
# §4: base_url MUST NOT embed credentials (userinfo is the URL credential mechanism).
|
||||
data = copy.deepcopy(_MANIFEST)
|
||||
data["source"] = {"type": "http", "id": "api", "base_url": "https://user:pw@host/api"}
|
||||
with pytest.raises(ValidationError):
|
||||
ManifestV1.model_validate(data)
|
||||
with pytest.raises(ManifestError) as exc:
|
||||
_validate(data)
|
||||
assert exc.value.code == "credential_embedded"
|
||||
|
||||
|
||||
def test_sql_and_http_variants_validate() -> None:
|
||||
# Schema breadth (brief assumption 1): the polymorphic §4 schema validates all three
|
||||
# source variants; the `file` and `sql` connectors EXECUTE (I2, I4), http → I6.
|
||||
sql = ManifestV1.model_validate(
|
||||
_variant(source={"type": "sql", "id": "db", "connection_ref": "PROJ_DB"})
|
||||
)
|
||||
assert sql.source.type == "sql"
|
||||
http = ManifestV1.model_validate(
|
||||
_variant(source={"type": "http", "id": "api", "base_url": "https://host/api"})
|
||||
)
|
||||
assert http.source.type == "http"
|
||||
sql = _validate(_variant(source={"type": "sql", "id": "db", "connection_ref": "PROJ_DB"}))
|
||||
assert isinstance(sql.source, SqlSource)
|
||||
assert sql.source.connection_ref == "PROJ_DB"
|
||||
http = _validate(_variant(source={"type": "http", "id": "api", "base_url": "https://host/api"}))
|
||||
assert isinstance(http.source, HttpSource)
|
||||
assert http.source.credential_ref is None
|
||||
|
||||
|
||||
|
|
@ -183,13 +214,16 @@ def test_failfast_before_source_access(tmp_path: Path) -> None:
|
|||
data = copy.deepcopy(_MANIFEST)
|
||||
data["source"]["root"] = str(tmp_path / "does-not-exist")
|
||||
data["extractions"][0]["max_rows"] = 0
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(ManifestError):
|
||||
load_manifest(_write(tmp_path, data))
|
||||
assert not (tmp_path / "does-not-exist").exists()
|
||||
|
||||
|
||||
def test_malformed_json_raises(tmp_path: Path) -> None:
|
||||
# The library wraps json decoding so EVERY manifest problem surfaces as one typed family
|
||||
# (previously this leaked a raw json.JSONDecodeError to the caller).
|
||||
path = tmp_path / "broken.json"
|
||||
path.write_text("{not json", encoding="utf-8")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
with pytest.raises(ManifestError) as exc:
|
||||
load_manifest(path)
|
||||
assert exc.value.code == "manifest_invalid_json"
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ from typing import Any
|
|||
|
||||
import pytest
|
||||
|
||||
from llm_ingestion_okf import MaterializationError, SourceError
|
||||
|
||||
from portfolio_optimiser import okf
|
||||
from portfolio_optimiser.ingest import IngestError, materialize, read_csv, render_table
|
||||
from portfolio_optimiser.retrieval import PathSecurityError
|
||||
|
||||
_INGESTED_AT = "2026-07-03T12:00:00Z"
|
||||
|
||||
|
|
@ -109,8 +110,11 @@ def test_max_rows_cap_is_an_error_not_truncation(tmp_path: Path) -> None:
|
|||
def test_path_escape_raises(tmp_path: Path) -> None:
|
||||
root = _catalogue(tmp_path, {"data.csv": b"a\n"})
|
||||
(tmp_path / "outside.csv").write_bytes(b"a\n1\n")
|
||||
with pytest.raises(PathSecurityError):
|
||||
# Fail-closed containment is unchanged; the library raises its own typed refusal
|
||||
# (SourceError, code="path_escape") where the repo-local seam raised PathSecurityError.
|
||||
with pytest.raises(SourceError) as exc:
|
||||
_read(root, query="../outside.csv")
|
||||
assert exc.value.code == "path_escape"
|
||||
|
||||
|
||||
def test_missing_root_raises_ingest_error(tmp_path: Path) -> None:
|
||||
|
|
@ -206,13 +210,17 @@ def test_generated_file_is_lf_only_with_one_trailing_newline(tmp_path: Path) ->
|
|||
assert data.endswith(b"\n") and not data.endswith(b"\n\n")
|
||||
|
||||
|
||||
def test_invalid_ingested_at_raises_value_error(tmp_path: Path) -> None:
|
||||
def test_invalid_ingested_at_is_refused(tmp_path: Path) -> None:
|
||||
manifest_path, bundle_dir = _project(tmp_path)
|
||||
# §5 format is ISO-8601 UTC with a Z suffix — validated by regex (datetime.fromisoformat
|
||||
# rejects 'Z' on Python 3.10, the repo floor).
|
||||
# rejects 'Z' on Python 3.10, the repo floor). The refusal is unchanged; its type moved
|
||||
# from ValueError to the library's MaterializationError.
|
||||
for bad in ("2026-07-03 12:00:00", "2026-07-03T12:00:00+00:00", "2026-07-03", ""):
|
||||
with pytest.raises(ValueError):
|
||||
with pytest.raises(MaterializationError) as exc:
|
||||
materialize(manifest_path, bundle_dir, ingested_at=bad)
|
||||
assert exc.value.code == "ingested_at_invalid"
|
||||
# The refusal is fail-fast: nothing was written before the format was checked.
|
||||
assert not bundle_dir.exists()
|
||||
|
||||
|
||||
def test_materialize_creates_nonexistent_nested_bundle_dir(tmp_path: Path) -> None:
|
||||
|
|
@ -226,11 +234,16 @@ def test_source_call_logged_with_id_timestamp_rowcount(
|
|||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
manifest_path, bundle_dir = _project(tmp_path)
|
||||
with caplog.at_level(logging.INFO, logger="portfolio_optimiser.ingest"):
|
||||
# The §8 audit log is still emitted per source call, but the CHANNEL moved with the
|
||||
# implementation: "portfolio_optimiser.ingest" -> "llm_ingestion_okf.materialize"
|
||||
# (accepted 2026-07-20; nothing in the repo consumed the old logger name).
|
||||
with caplog.at_level(logging.INFO, logger="llm_ingestion_okf.materialize"):
|
||||
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
|
||||
# §8: which source, when (the deterministic ingested_at argument), row count.
|
||||
joined = " ".join(record.getMessage() for record in caplog.records)
|
||||
assert "prosjekt-arkiv" in joined and _INGESTED_AT in joined and "rows=2" in joined
|
||||
# §8 also bounds what may be logged: never cell contents.
|
||||
assert "led-retrofit" not in joined
|
||||
|
||||
|
||||
def test_two_runs_with_identical_inputs_are_byte_identical(tmp_path: Path) -> None:
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ drifts away from what the spec documents.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
|
@ -187,7 +188,7 @@ def test_ingest_spec_documents_every_contract_field() -> None:
|
|||
def documented(field: str, source: str) -> None:
|
||||
assert f"`{field}`" in text, f"ingest spec does not document {source} field `{field}`"
|
||||
|
||||
for field in ManifestV1.model_fields:
|
||||
for field in (f.name for f in dataclasses.fields(ManifestV1)):
|
||||
documented(field, "manifest top-level")
|
||||
for model, source in (
|
||||
(FileSource, "file source"),
|
||||
|
|
@ -195,8 +196,13 @@ def test_ingest_spec_documents_every_contract_field() -> None:
|
|||
(HttpSource, "http source"),
|
||||
(Extraction, "extraction"),
|
||||
):
|
||||
for field in model.model_fields:
|
||||
for field in (f.name for f in dataclasses.fields(model)):
|
||||
documented(field, source)
|
||||
# The library's source models consume the `type` discriminator during validation dispatch
|
||||
# instead of storing it as a field, so it is no longer reachable by introspection. It is a
|
||||
# REAL §4 contract field, so it is asserted explicitly — without this line the swap from
|
||||
# `model_fields` to `dataclasses.fields` would silently drop it from the cross-check.
|
||||
documented("type", "source discriminator")
|
||||
|
||||
# The §5/§7 provenance layer — exactly the keys the materializer stamps.
|
||||
for key in (
|
||||
|
|
|
|||
7
uv.lock
generated
7
uv.lock
generated
|
|
@ -1096,6 +1096,11 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "llm-ingestion-okf"
|
||||
version = "0.3.1"
|
||||
source = { git = "https://git.fromaitochitta.com/open/llm-ingestion-okf.git?rev=v0.3.1#692f2df2ba5aa160810b126dab3574cd297218b9" }
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.28.0"
|
||||
|
|
@ -1421,6 +1426,7 @@ dependencies = [
|
|||
{ name = "agent-framework-openai" },
|
||||
{ name = "agent-framework-orchestrations" },
|
||||
{ name = "azure-identity" },
|
||||
{ name = "llm-ingestion-okf" },
|
||||
{ name = "mcp" },
|
||||
{ name = "pulp" },
|
||||
{ name = "pydantic" },
|
||||
|
|
@ -1441,6 +1447,7 @@ requires-dist = [
|
|||
{ name = "agent-framework-openai", specifier = ">=1.8.2" },
|
||||
{ name = "agent-framework-orchestrations", specifier = ">=1.0.0" },
|
||||
{ name = "azure-identity", specifier = ">=1.25" },
|
||||
{ name = "llm-ingestion-okf", git = "https://git.fromaitochitta.com/open/llm-ingestion-okf.git?rev=v0.3.1" },
|
||||
{ name = "mcp", specifier = ">=1.28.0" },
|
||||
{ name = "pulp", specifier = ">=2.8" },
|
||||
{ name = "pydantic", specifier = ">=2.11,<3" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue