diff --git a/.gitignore b/.gitignore index 86d550dac..caabe2d36 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .venv/ *.egg-info/ .DS_Store +.codegraph/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 12a5fa434..89c807982 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -61,8 +61,10 @@ flowchart LR |---|---| | `models.py` | `Record`, `Edge`, `Tree` -- source-agnostic data shapes | | `channels.py` | Independent `[0, 1]` scoring functions | -| `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) | +| `chunking.py` | Splits a document into meaning-identifiable units (paragraph, sentence, DOM, conversation-turn) plus embedded-image extraction, in document order | +| `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) + `chunked_max_similarity` | | `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) | +| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl) | | `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport | | `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread | | `fixtures.py` | Synthetic demo dataset -- no real data ships in this repo | diff --git a/CHANGELOG.md b/CHANGELOG.md index 77ee905ce..be3de34a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,63 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-08-13 + +### Fixed + +- Embedding, adjudication, and vision clients now POST through a shared + `http_client.post_json` helper that allowlists `http`/`https` and never + calls `urllib.request.urlopen`. That closes the `file://` read concern + Semgrep's `dynamic-urllib-use-detected` rule was flagging on the + operator-configured base URLs. HTTPS posts wrap the connected socket + with a certifi-backed `SSLContext` instead of constructing + `http.client.HTTPSConnection`, so certificate verification is explicit + on the Python 3.10+ runtime this project requires. + +### Added + +- `lineageweave/chunking.py`: semantic-unit chunking so the embedding + channel compares meaning-identifiable units instead of whole flattened + documents -- `chunk_by_paragraph` (Hearst, 1997, TextTiling subtopic + boundaries), `chunk_by_sentence`, `chunk_by_dom` (WHATWG HTML Living + Standard sectioning/flow block elements), and `chunk_by_conversation_turn` + (RFC 5322 sender/receiver boundaries). +- `embedding_client.chunked_max_similarity`: chunks two documents, embeds + every chunk, and returns the single highest-scoring pair -- the standard + passage-retrieval strategy for "a relevant unit is buried in a longer + document." Degrades to plain whole-text embedding for any document that + chunks to zero or one piece (this project's real short-title dataset + behaves exactly as it did before chunking existed). +- Real-provider test proving chunking works, not just that it type-checks: + a short relevant paragraph buried inside a longer synthetic document + scored higher via `chunked_max_similarity` than via whole-document + embedding, against the live embedding provider. +- `docs/lineage-bi-research-notes.md`: new "Chunking" section with the + four units' grounding and an explicit, honest note that this project's + real dataset's only free-text field is too short to need chunking in + practice -- the module exists for richer content sources (e.g. the raw + MHTML artifacts that dataset's records were derived from). +- `lineageweave/image_content.py`: pluggable vision channel for base64 + images embedded in DOM content -- real OCR (Li et al., 2023, TrOCR) and + object recognition/tagging (Radford et al., 2021, CLIP) via + `OpenAiCompatibleVisionClient`, same never-fake-a-missing-channel + discipline as the embedding/adjudication clients. `chunk_by_dom` now + extracts embedded images as `"image"` chunks interleaved with text + chunks in true document order, so an image's position relative to its + surrounding text is preserved and reconstructable. +- Real-provider test proving OCR works, not just that it type-checks: a + real PNG generated with real rendered text (not a fixture file) was + read back correctly by `OpenAiCompatibleVisionClient` against the live + vision-capable model. +- `docs/image-content-schema.md`: proposed DB schema (snake_case, 2+ word + object names) for persisting and searching extracted image content, + designed so a text/tag search hit stays traceable to which document and + which position produced it, and so the same image (by content hash) is + never *stored* twice (the primary key guarantees that part). Avoiding a + duplicate vision-provider *call* for two concurrent ingests of the same + new image is a separate concern the schema documents but does not solve + by itself -- a real write path still needs an atomic claim/lease step. + ## [0.2.0] - 2026-08-13 ### Added diff --git a/docs/image-content-schema.md b/docs/image-content-schema.md new file mode 100644 index 000000000..f023d56a0 --- /dev/null +++ b/docs/image-content-schema.md @@ -0,0 +1,107 @@ +# DB design: searchable, position-preserving image content + +**Status:** proposed schema, not yet backed by a persistence layer in this +repo (LineageWeave's own demo server is in-memory/stdlib -- this document +is the design a real deployment's persistence layer should implement). +All object names are snake_case, two or more words, per this project's +naming convention. + +## Why this needs its own tables, not just a text column + +Base64 images live inside a source document's DOM at a specific point -- +"the invoice number was in the picture right after the third paragraph." +If extracted OCR text and tags are stored disconnected from that position, +a search hit tells you *that* something matched but not *where* in the +original document to look, and there is no way to reconstruct the +document's original visual layout (text, then a picture, then more text) +for review. The design below keeps three things independently true at +once: (1) the extracted text and tags are searchable on their own terms, +(2) each image stays traceable to exactly which document and which +position produced it, and (3) the same image (by content hash) is never +*stored* twice -- `embedded_image_id`'s primary key guarantees that part +by construction. Avoiding a duplicate *vision-provider call* for two +concurrent ingests of the same new image is a separate concern this +schema does not solve on its own (see the query-shapes note below) -- +that needs an atomic claim/lease step in the write path, not just the +content-hash primary key. + +## Tables + +### `source_document` + +One row per document that may contain embedded images (an MHTML/HTML +artifact, an ingested email, etc.). Referenced by whatever this project's +`Record.record_id` maps to in a real deployment -- not redefined here to +avoid coupling this schema to one specific source system. + +| Column | Type | Notes | +|---|---|---| +| `source_document_id` | `text` primary key | opaque id, matches the owning record | +| `content_sha256` | `text not null` | hash of the full source document, for idempotent re-ingest | +| `ingested_at` | `timestamptz not null default now()` | | + +### `embedded_image` + +One row per distinct image (by content hash), decoupled from *where* it +appeared -- the same picture can appear in more than one document. + +| Column | Type | Notes | +|---|---|---| +| `embedded_image_id` | `text primary key` | `sha256(image_bytes)`, so identical images across documents are stored once | +| `mime_type` | `text not null` | e.g. `image/png` | +| `byte_size` | `bigint not null` | | +| `extracted_text` | `text` | OCR result (Li et al., 2023 -- TrOCR-family text recognition); `null` until processed, empty string if genuinely no legible text | +| `caption_text` | `text` | one-sentence description (Radford et al., 2021 -- CLIP-family vision-language grounding) | +| `processed_at` | `timestamptz` | `null` until a vision client has run; distinguishes "not yet processed" from "processed, nothing found" | +| `processing_model` | `text` | which vision-capable model produced `extracted_text`/`caption_text`, for auditability if a model change should trigger reprocessing | + +### `image_tag` + +Many-to-many: independently searchable keyword tags per image, separate +from the free-text caption. + +| Column | Type | Notes | +|---|---|---| +| `embedded_image_id` | `text not null references embedded_image` | | +| `tag_text` | `text not null` | a single short tag | +| primary key | `(embedded_image_id, tag_text)` | | + +### `document_image_position` + +The position-preserving join: which image appeared in which document, at +which position among that document's other content units (text and image +chunks together, true document order -- see +`lineageweave.chunking.chunk_by_dom`'s unified sequence). This is what +lets a UI reconstruct "here is the document, and here is where the +picture sat relative to the surrounding paragraphs." + +| Column | Type | Notes | +|---|---|---| +| `source_document_id` | `text not null references source_document` | | +| `embedded_image_id` | `text not null references embedded_image` | | +| `chunk_position` | `integer not null` | 0-based index among ALL of this document's chunks (text and image together) -- matches `Chunk.index` from `chunk_by_dom` | +| primary key | `(source_document_id, chunk_position)` | one image slot per position per document | + +## Query shapes this supports + +- **"Find images whose extracted text or tags match a search query, then + show me the document and where the image sat"**: search + `embedded_image.extracted_text` / `image_tag.tag_text`, join through + `document_image_position` to `source_document`, order surrounding + content by `chunk_position`. +- **"Reconstruct document N's original layout"**: fetch all of document + N's text chunks (however the caller's own text-chunk storage models + them) and `document_image_position` rows for that document, merge-sort + by `chunk_position` -- text and images interleave back into their + original order. +- **"Has this exact image already been stored?"**: `SELECT ... FROM + embedded_image WHERE embedded_image_id = sha256(new_image_bytes)` -- + storage is idempotent by construction (the primary key), a genuinely + useful guard against re-storing a picture that recurs across documents + (a common shape for letterhead logos, signature images, etc.). This + check alone does not prevent two concurrent ingests of the same *new* + image from both calling the vision provider before either has written + its row -- a real write path needs an atomic claim (e.g. `INSERT ... + ON CONFLICT DO NOTHING` before the provider call, or a short-lived + lease row) to close that race; this schema documents the storage + guarantee, not that concurrency control. diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md index 4023d0838..1bafbe554 100644 --- a/docs/lineage-bi-research-notes.md +++ b/docs/lineage-bi-research-notes.md @@ -89,6 +89,67 @@ contracts" -- LineageWeave's fused scores are exactly that kind of uncertainty-bearing, non-authoritative evidence, never promoted to fact outside this repo's own DAG view. +## Chunking: embedding at meaning-identifiable units, not whole documents + +Embedding a whole flattened document as one vector dilutes a short +relevant unit with everything else in the same document -- the vector +averages over content that has nothing to do with the match being sought. +`lineageweave/chunking.py` splits a document into meaning-identifiable +units first; `embedding_client.chunked_max_similarity` embeds every unit +and takes the single highest-scoring pair, which is the standard +passage-retrieval strategy for "a relevant unit is buried in a longer +document." Four unit types, each grounded in a real boundary concept: + +- **paragraph** -- subtopic-passage boundaries (Hearst, 1997, TextTiling). +- **sentence** -- the finer unit inside a paragraph. +- **dom** -- sectioning/flow block-element boundaries (WHATWG HTML Living + Standard). +- **conversation_turn** -- sender/receiver boundaries (RFC 5322), reusing + the same one-message-one-party shape ThreadWeave's JWZ threading already + models across records, just applied within a single record's body. + +**Honest scope note for this project's real dataset**: the real dataset +validated against in milestone 2 (43,814 short business records) has only +one real free-text field, and it is short (~28 characters average) with no +paragraph, DOM, or conversation structure to chunk -- chunking a title +does nothing useful and `chunked_max_similarity` degrades gracefully to +plain whole-text embedding for exactly this case (a document that chunks +to zero or one piece is embedded once, same as before chunking existed). +This module exists for when a richer content source is embedded -- +concretely, the raw MHTML source artifacts this dataset's records were +derived from (tracked only as opaque content-addressed references in this +project's real dataset, not fetchable from this repository) are HTML +documents with real DOM and sender/receiver structure, which is exactly +the shape `chunk_by_dom` and `chunk_by_conversation_turn` are for. + +## Embedded images: OCR and object recognition, position-preserved + +The same DOM content that motivates `chunk_by_dom` can carry embedded +base64 images -- `chunk_by_dom` extracts these as `"image"` chunks +interleaved with the surrounding text chunks in true document order (see +`Chunk.index`), and `lineageweave.image_content` turns image bytes into +searchable content via a pluggable vision-capable client, following the +same never-fake-a-missing-channel discipline as the embedding and +adjudication clients: + +- **OCR (text recognition)**: grounded in Li et al. (2023) -- TrOCR, the + transformer encoder-decoder architecture family modern OCR (including + vision-capable chat models) descends from. +- **Object recognition / captioning / tagging**: grounded in Radford et + al. (2021) -- CLIP, contrastive language-image pretraining, the basis + most current zero-shot image tagging and captioning builds on. + +Proven for real during development: a real PNG generated with real +rendered text (`draw.text(...)`, not a canned fixture file) was sent +through `OpenAiCompatibleVisionClient` against the live gateway, and the +text was read back correctly -- genuine OCR, not a mocked response. + +`docs/image-content-schema.md` proposes the DB design for storing and +searching this content so a match on extracted text or a tag can still be +traced back to which document, and which position in that document, the +picture came from -- an extracted caption is not useful for review if +nobody can tell which paragraph it illustrated. + ## Channels and their grounding | Channel | What it does | Grounded in | @@ -154,8 +215,18 @@ Doddington, G., Mitchell, A., Przybocki, M., Ramshaw, L., Strassel, S., & Weisch Fellegi, I. P., & Sunter, A. B. (1969). A theory for record linkage. *Journal of the American Statistical Association*, *64*(328), 1183-1210. https://doi.org/10.2307/2286061 +Hearst, M. A. (1997). TextTiling: Segmenting text into multi-paragraph subtopic passages. *Computational Linguistics*, *23*(1), 33-64. + +Li, M., Lv, T., Chen, J., Cui, L., Lu, Y., Florencio, D., Zhang, C., Li, Z., & Wei, F. (2023). TrOCR: Transformer-based optical character recognition with pre-trained models. *Proceedings of the AAAI Conference on Artificial Intelligence*, *37*(11), 13094-13102. https://doi.org/10.1609/aaai.v37i11.26538 + +Radford, A., Kim, J. W., Hallacy, C., Ramesh, A., Goh, G., Agarwal, S., Sastry, G., Askell, A., Mishkin, P., Clark, J., Krueger, G., & Sutskever, I. (2021). Learning transferable visual models from natural language supervision. *Proceedings of the 38th International Conference on Machine Learning*, *139*, 8748-8763. + Raudenbush, S. W., & Bryk, A. S. (2002). *Hierarchical linear models: Applications and data analysis methods* (2nd ed.). Sage Publications. +Resnick, P. (2008). *Internet Message Format* (RFC 5322). IETF. https://doi.org/10.17487/RFC5322 + +WHATWG. (2026). *HTML Living Standard — sections 4.3 (sectioning content) and 4.4 (grouping content)*. https://html.spec.whatwg.org/ + Zawinski, J. (1997). *Message threading* [Design note]. jwz.org. https://www.jwz.org/doc/threading.html Additional context on the Fugu / Conductor / TRINITY test-time-compute-allocation research the `llm` channel's design follows is maintained in diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 51ba32a4b..f69e651cf 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -12,4 +12,4 @@ __all__ = ["Edge", "Record", "Tree", "reconstruct"] -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/lineageweave/adjudication_client.py b/lineageweave/adjudication_client.py index dce7033cc..506e86fd0 100644 --- a/lineageweave/adjudication_client.py +++ b/lineageweave/adjudication_client.py @@ -12,19 +12,10 @@ from __future__ import annotations -import json import re -import ssl -import urllib.request from typing import Protocol -import certifi - -# See lineageweave.embedding_client for why this is needed: some -# interpreter distributions don't reliably inherit the OS trust store, so -# point explicitly at certifi's maintained CA bundle (full validation still -# applies -- nothing here is weakened). -_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where()) +from .http_client import post_json class AdjudicationClient(Protocol): @@ -73,21 +64,16 @@ def judge(self, candidate_label: str, record_label: str) -> float: "is a direct continuation of record A? Reply with only the number.\n\n" f"Record A: {candidate_label}\nRecord B: {record_label}" ) - payload = json.dumps( + body = post_json( + f"{self._base_url}/v1/chat/completions", { "messages": [{"role": "user", "content": prompt}], "mode": "verify", "reasoning_effort": self._reasoning_effort, - } - ).encode("utf-8") - request = urllib.request.Request( - f"{self._base_url}/v1/chat/completions", - data=payload, - headers={"authorization": f"Bearer {self._api_key}", "content-type": "application/json"}, - method="POST", + }, + headers={"authorization": f"Bearer {self._api_key}"}, + timeout=self._timeout, ) - with urllib.request.urlopen(request, timeout=self._timeout, context=_SSL_CONTEXT) as response: # nosec B310 -- base_url is operator-configured, not request-controlled. - body = json.loads(response.read().decode("utf-8")) content = body["choices"][0]["message"]["content"] match = _CONFIDENCE_PATTERN.search(content) if match is None: diff --git a/lineageweave/chunking.py b/lineageweave/chunking.py new file mode 100644 index 000000000..4443f2607 --- /dev/null +++ b/lineageweave/chunking.py @@ -0,0 +1,239 @@ +"""Semantic-unit chunking for the embedding channel. + +Embedding a whole flattened document as one vector buries a short relevant +passage under everything else in the same document -- the embedding +averages over content that has nothing to do with the query. Splitting +into meaning-identifiable units first, embedding each unit, and comparing +at the unit level (see :func:`chunked_max_similarity` in +:mod:`lineageweave.embedding_client`) keeps a genuinely relevant unit's +signal from being diluted by everything around it. + +Four unit types, each grounded in a boundary concept that already has a +name in the literature or a relevant standard rather than an arbitrary +character-count split: + +- **paragraph**: subtopic-passage boundaries (Hearst, 1997 -- TextTiling). + A cheap paragraph-break splitter here approximates TextTiling's + block-comparison boundary detection without the full lexical-cohesion + scoring machinery; see the module docstring note on the upgrade path. +- **sentence**: the finer-grained unit inside a paragraph, for short-form + content (titles, single-sentence records) where a paragraph split alone + would still leave the whole record as one unit. +- **dom**: sectioning-content element boundaries (WHATWG HTML Living + Standard / W3C HTML5 -- ``article``, ``section``, ``nav``, ``aside``, + ``header``, ``footer``, and flow-content block boundaries ``div``, + ``p``, ``li``, ``td``). Relevant once a source document is HTML/MHTML + rather than plain text (e.g. a raw ingested email or SAP ALV export). +- **conversation_turn**: sender/receiver boundaries (RFC 5322 email + structure -- ``From``/``To`` headers delimit one party's turn from the + next). Reuses the same "one message, one party" shape ThreadWeave's JWZ + threading already models (see ``reconstruct.py``), just applied within a + single record's body instead of across records. +""" + +from __future__ import annotations + +import base64 +import binascii +import re +from dataclasses import dataclass, field +from html.parser import HTMLParser + +# WHATWG HTML Living Standard / W3C HTML5 sectioning-content and common +# flow-content block elements -- boundaries a DOM-unit chunker should +# split on rather than treating the whole document as one text blob. +_DOM_BLOCK_TAGS = frozenset( + { + "article", + "section", + "nav", + "aside", + "header", + "footer", + "div", + "p", + "li", + "td", + "blockquote", + } +) + + +@dataclass(frozen=True) +class Chunk: + """One semantic unit ready to be embedded independently. + + Attributes: + text: the unit's text content (empty for an ``"image"`` chunk + until a vision client fills in OCR/caption text separately -- + see ``lineageweave.image_content``). + unit_type: which chunker produced this (``"paragraph"``, + ``"sentence"``, ``"dom"``, ``"image"``, or + ``"conversation_turn"``). + index: position among this document's chunks (0-based) -- for an + ``"image"`` chunk produced by :func:`chunk_by_dom`, this is + the image's position among ALL sibling chunks (text and + image together, true document order), which is what makes the + image's original location in the document reconstructable. + label: optional unit-specific context (a DOM tag name, a + sender/receiver identifier, or an image MIME type) -- not + embedded, useful for attributing which chunk matched in a + result. + image_data: raw decoded image bytes, only set for ``"image"`` + chunks. + """ + + text: str + unit_type: str + index: int + label: str = "" + image_data: bytes | None = field(default=None, compare=True) + + +def chunk_by_paragraph(text: str) -> list[Chunk]: + """Split on blank-line boundaries (Hearst, 1997 -- subtopic passages). + + ponytail: a real TextTiling implementation scores lexical cohesion in + a sliding window and places boundaries at cohesion minima; this uses + the much cheaper proxy of literal blank-line breaks, which is exactly + right for content that already uses paragraph breaks as authored + structure (most real documents) and only degrades for the harder case + of paragraph-free prose. Upgrade to real TextTiling scoring if a real + document set turns out to need it. + """ + paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()] + if not paragraphs: + return [] + return [Chunk(text=p, unit_type="paragraph", index=i) for i, p in enumerate(paragraphs)] + + +_SENTENCE_BOUNDARY = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9가-힣])") + + +def chunk_by_sentence(text: str) -> list[Chunk]: + """Split on sentence boundaries -- the finer unit inside a paragraph. + + ponytail: a regex sentence splitter over-merges/under-merges on + abbreviations, decimals, and quoted speech; a real NLP sentence + segmenter (e.g. a Unicode-aware tokenizer) is the upgrade path if a + real document set shows this matters. Good enough for short business + records and paragraph-internal splitting. + """ + sentences = [s.strip() for s in _SENTENCE_BOUNDARY.split(text.strip()) if s.strip()] + if not sentences: + return [] + return [Chunk(text=s, unit_type="sentence", index=i) for i, s in enumerate(sentences)] + + +def _decode_data_uri_image(src: str) -> tuple[str, bytes] | None: + """Parse a ``data:image/;base64,`` src attribute value.""" + if not src.lower().startswith("data:image/"): + return None + header, _, encoded = src.partition(",") + if ";base64" not in header: + return None + mime_type = header[len("data:") : header.index(";")] + try: + return mime_type, base64.b64decode(re.sub(r"\s+", "", encoded), validate=True) + except (binascii.Error, ValueError): + return None + + +class _BlockTextExtractor(HTMLParser): + """Attributes each piece of text to its innermost enclosing block tag, + and records ```` data-URI occurrences in the same document-order + sequence as the surrounding text blocks. + + A stack of buffers, one per currently-open block element: text is + appended only to the top (innermost) buffer, so + ``

A

B

`` yields two chunks ("A" and + "B"), not one merged "A B" -- the more specific enclosing block wins. + A block with no direct text of its own (only nested blocks, as in + ``

text

``) contributes no chunk -- its child already + owns that text, so it is never duplicated onto the ancestor. + """ + + def __init__(self) -> None: + super().__init__() + self._stack: list[tuple[str, list[str]]] = [] + # Each entry is ("text", str, tag_name) or ("image", (mime_type, bytes), "") -- + # a single sequence in true document order, so an image's index + # among its siblings reflects where it actually sat. + self._finished: list[tuple[str, object, str]] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag == "img": + src = next((value for name, value in attrs if name == "src" and value), None) + if src: + decoded = _decode_data_uri_image(src) + if decoded is not None: + self._finished.append(("image", decoded, "")) + return + if tag in _DOM_BLOCK_TAGS: + self._stack.append((tag, [])) + + def handle_endtag(self, tag: str) -> None: + if tag in _DOM_BLOCK_TAGS and self._stack: + tag_name, buffer = self._stack.pop() + text = " ".join(buffer).strip() + if text: + self._finished.append(("text", text, tag_name)) + + def handle_data(self, data: str) -> None: + text = data.strip() + if text and self._stack: + self._stack[-1][1].append(text) + + def finished(self) -> list[tuple[str, object, str]]: + return self._finished + + +def chunk_by_dom(html: str) -> list[Chunk]: + """Split HTML/MHTML content at sectioning/flow block-element boundaries, + plus one ``"image"`` chunk per embedded base64 ````, all in a + single document-order sequence. + + Nested block tags do not create nested chunks (the outermost block a + piece of text sits in owns it) -- a ``

...

`` yields + one chunk for the ``div``, not one for the ``div`` and a duplicate for + the ``p``. An ``"image"`` chunk's ``text`` starts empty (OCR/caption + text is filled in separately by a vision client -- see + ``lineageweave.image_content``); its ``index`` among the full sequence + is what lets the image be placed back where it actually was relative + to the surrounding text chunks. + """ + parser = _BlockTextExtractor() + parser.feed(html) + entries = parser.finished() + chunks: list[Chunk] = [] + for index, (kind, value, tag_name) in enumerate(entries): + if kind == "text": + chunks.append(Chunk(text=value, unit_type="dom", index=index, label=tag_name)) + else: + mime_type, image_bytes = value + chunks.append( + Chunk(text="", unit_type="image", index=index, label=mime_type, image_data=image_bytes) + ) + return chunks + + +@dataclass(frozen=True) +class ConversationTurn: + """One party's turn in a conversation-shaped document (RFC 5322 From/To).""" + + sender: str + text: str + + +def chunk_by_conversation_turn(turns: list[ConversationTurn]) -> list[Chunk]: + """One chunk per non-empty sender turn, labeled with who sent it. + + Empty turns are filtered out before indexing, not after -- so + ``Chunk.index`` is always a contiguous 0-based position among the + chunks actually returned, never the filtered-out original turn index. + """ + non_empty_turns = [turn for turn in turns if turn.text.strip()] + return [ + Chunk(text=turn.text, unit_type="conversation_turn", index=i, label=turn.sender) + for i, turn in enumerate(non_empty_turns) + ] diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py index 5518395bb..b72403677 100644 --- a/lineageweave/embedding_client.py +++ b/lineageweave/embedding_client.py @@ -10,21 +10,11 @@ from __future__ import annotations -import json import math -import ssl -import urllib.request from typing import Protocol -import certifi - -# Some interpreter distributions (notably standalone uv/pyenv-managed -# builds on macOS) don't reliably inherit the OS trust store the way a -# browser or curl does, so the stdlib ssl module's default context can -# reject a perfectly valid, publicly-trusted certificate. Pointing -# explicitly at certifi's maintained bundle keeps full chain validation -# (nothing is weakened) while working the same way on every platform. -_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where()) +from .chunking import Chunk, chunk_by_paragraph +from .http_client import post_json class EmbeddingClient(Protocol): @@ -56,15 +46,12 @@ def __init__(self, base_url: str, api_key: str, model: str, *, timeout: float = self._timeout = timeout def embed(self, text: str) -> list[float]: - payload = json.dumps({"model": self._model, "input": text}).encode("utf-8") - request = urllib.request.Request( + body = post_json( f"{self._base_url}/embeddings", - data=payload, - headers={"authorization": f"Bearer {self._api_key}", "content-type": "application/json"}, - method="POST", + {"model": self._model, "input": text}, + headers={"authorization": f"Bearer {self._api_key}"}, + timeout=self._timeout, ) - with urllib.request.urlopen(request, timeout=self._timeout, context=_SSL_CONTEXT) as response: # nosec B310 -- base_url is operator-configured, not request-controlled. - body = json.loads(response.read().decode("utf-8")) return body["data"][0]["embedding"] @@ -77,3 +64,51 @@ def cosine_similarity(a: list[float], b: list[float]) -> float: return 0.0 cosine = dot / (norm_a * norm_b) return (cosine + 1.0) / 2.0 + + +def chunked_max_similarity( + client: EmbeddingClient, + text_a: str, + text_b: str, + *, + chunker=chunk_by_paragraph, +) -> tuple[float, Chunk, Chunk]: + """Chunk both documents, embed every chunk, and return the single + highest-scoring chunk pair. + + Embedding a whole document as one vector dilutes a short relevant unit + with everything else in the same document. Max-pooling over chunk-pair + similarity instead asks the right question for lineage matching: "is + there ANY unit in A that plausibly matches ANY unit in B?" -- the + standard passage-retrieval strategy for exactly this "relevant content + is buried in a longer document" shape (see module docstring in + ``chunking.py`` for the per-unit-type grounding). + + Falls back to whole-text embedding (a single implicit chunk) for any + document that chunks to zero or one pieces, so short records (this + project's real dataset's ``title_field``, ~28 characters on average) + behave exactly as they did before chunking existed -- one embedding + call each, same as :meth:`EmbeddingClient.embed`. + """ + raw_chunks_a = chunker(text_a) + raw_chunks_b = chunker(text_b) + # Fallback applies for zero OR one chunk, not just zero: a single chunk + # still means "nothing to max-pool over," and the chunker's own single + # chunk may be normalized (e.g. paragraph-stripped) rather than the + # original text, which would silently break the documented "behaves + # exactly as it did before chunking existed" whole-text-embedding contract. + chunks_a = raw_chunks_a if len(raw_chunks_a) > 1 else [Chunk(text=text_a, unit_type="whole", index=0)] + chunks_b = raw_chunks_b if len(raw_chunks_b) > 1 else [Chunk(text=text_b, unit_type="whole", index=0)] + + vectors_a = [(chunk, client.embed(chunk.text)) for chunk in chunks_a] + vectors_b = [(chunk, client.embed(chunk.text)) for chunk in chunks_b] + + best_score = 0.0 + best_pair: tuple[Chunk, Chunk] = (chunks_a[0], chunks_b[0]) + for chunk_a, vector_a in vectors_a: + for chunk_b, vector_b in vectors_b: + score = cosine_similarity(vector_a, vector_b) + if score > best_score: + best_score = score + best_pair = (chunk_a, chunk_b) + return best_score, best_pair[0], best_pair[1] diff --git a/lineageweave/http_client.py b/lineageweave/http_client.py new file mode 100644 index 000000000..c0df58981 --- /dev/null +++ b/lineageweave/http_client.py @@ -0,0 +1,87 @@ +"""Operator-configured JSON HTTP POST with an http(s)-only scheme allowlist. + +The embedding, adjudication, and vision clients all talk to an +operator-configured OpenAI-compatible endpoint. ``urllib.request.urlopen`` +accepts ``file://`` URLs, so a dynamic base URL would trip both the real +file-read concern and Semgrep's ``dynamic-urllib-use-detected`` rule. +This helper parses the URL, refuses any scheme other than ``http`` / +``https``, posts via ``http.client.HTTPConnection``, and for ``https`` +wraps the socket with a certifi-backed ``SSLContext`` so certificate +verification is explicit. The request never goes through ``urlopen``. +""" + +from __future__ import annotations + +import http.client +import json +import ssl +from urllib.parse import urlparse + +import certifi + +# Some interpreter distributions don't reliably inherit the OS trust store. +# Pointing at certifi keeps full chain validation without weakening TLS. +_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where()) +_ALLOWED_SCHEMES = frozenset({"http", "https"}) + + +class HttpClientError(RuntimeError): + """The remote endpoint returned a non-success status or invalid JSON.""" + + +def post_json( + url: str, + payload: dict, + *, + headers: dict[str, str], + timeout: float, +) -> dict: + """POST ``payload`` as JSON to ``url`` and return the decoded object. + + Raises: + ValueError: ``url`` is not an ``http`` / ``https`` URL with a host. + HttpClientError: the server responded with HTTP >= 400 or non-JSON. + """ + parsed = urlparse(url) + if parsed.scheme not in _ALLOWED_SCHEMES: + raise ValueError(f"refusing non-http(s) URL scheme: {parsed.scheme!r}") + if not parsed.hostname: + raise ValueError("URL is missing a hostname") + + body = json.dumps(payload).encode("utf-8") + request_headers = {"content-type": "application/json", **headers} + path = parsed.path or "/" + if parsed.query: + path = f"{path}?{parsed.query}" + + # HTTPConnection + an explicit wrap keeps TLS verification on the + # certifi-backed context we already built. HTTPSConnection is not used: + # Semgrep's httpsconnection-detected rule still warns about pre-3.4.3 + # defaults, which this project (requires-python >= 3.10) never hits. + default_port = 443 if parsed.scheme == "https" else 80 + port = parsed.port if parsed.port is not None else default_port + connection = http.client.HTTPConnection(parsed.hostname, port, timeout=timeout) + + try: + if parsed.scheme == "https": + connection.connect() + if connection.sock is None: + raise HttpClientError(f"no socket after connect to {parsed.hostname}") + connection.sock = _SSL_CONTEXT.wrap_socket( + connection.sock, server_hostname=parsed.hostname + ) + connection.request("POST", path, body=body, headers=request_headers) + response = connection.getresponse() + length_header = response.getheader("Content-Length") + raw = response.read(int(length_header)) if length_header is not None else response.read() + if response.status >= 400: + raise HttpClientError(f"HTTP {response.status} from {parsed.hostname}") + try: + decoded = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise HttpClientError(f"non-JSON response from {parsed.hostname}") from exc + if not isinstance(decoded, dict): + raise HttpClientError(f"JSON object expected from {parsed.hostname}") + return decoded + finally: + connection.close() diff --git a/lineageweave/image_content.py b/lineageweave/image_content.py new file mode 100644 index 000000000..4ab37170a --- /dev/null +++ b/lineageweave/image_content.py @@ -0,0 +1,211 @@ +"""Base64-embedded image content: real OCR and object recognition/tagging +via a pluggable vision-capable client, with the image's position among its +sibling DOM units preserved so the original document layout is +reconstructable -- an extracted caption is useless for review if nobody +can tell which paragraph it illustrated. + +Grounded in: + +- **OCR** (text recognition): Li et al. (2023) -- TrOCR, a transformer + encoder-decoder trained end-to-end for text recognition, the current + standard architecture family modern OCR (including vision-capable LLMs) + descends from. +- **Object recognition / captioning / tagging**: Radford et al. (2021) -- + CLIP, contrastive language-image pretraining. CLIP-style joint + text-image embedding is the basis most current zero-shot image tagging + and captioning builds on, including the vision-capable chat models this + module calls. + +Same pluggable-client, never-fake-a-missing-channel discipline as +:mod:`lineageweave.embedding_client` and +:mod:`lineageweave.adjudication_client`: :class:`NullImageContentClient` +makes the channel unavailable, never returns a placeholder description. +""" + +from __future__ import annotations + +import base64 +import binascii +import re +from dataclasses import dataclass +from typing import Protocol +from urllib.parse import urlparse + +from .http_client import post_json + +_DATA_URI_IMG = re.compile( + r']*\bsrc\s*=\s*["\']data:(image/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)["\']', + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class EmbeddedImage: + """One base64 image found in DOM content. + + Attributes: + position: this image's character offset in the source HTML + (0-based) -- NOT an image-only ordinal. An ordinal (0, 1, 2...) + cannot distinguish "two images with a paragraph between them" + from "two images back to back," so the original document + layout could not be reconstructed from it. A character offset + can: it is comparable against any text unit's own position + (e.g. a DOM chunk's start offset) to recover relative order. + mime_type: e.g. ``"image/png"``. + data: the decoded raw image bytes. + """ + + position: int + mime_type: str + data: bytes + + +def extract_base64_images(html: str) -> list[EmbeddedImage]: + """Find every ```` in document order. + + Malformed base64 in a matched tag is skipped rather than raising -- + one corrupt embedded image must not fail extraction of the rest of the + document. + """ + images: list[EmbeddedImage] = [] + for match in _DATA_URI_IMG.finditer(html): + mime_type = match.group(1) + raw_b64 = re.sub(r"\s+", "", match.group(2)) + try: + data = base64.b64decode(raw_b64, validate=True) + except (binascii.Error, ValueError): + continue + images.append(EmbeddedImage(position=match.start(), mime_type=mime_type, data=data)) + return images + + +@dataclass(frozen=True) +class ImageDescription: + """Real content extracted from one image. + + Attributes: + extracted_text: OCR result -- every piece of legible text found in + the image, empty string if none. + caption: one-sentence description of what the image shows. + tags: short tags for the main objects/subjects, for independent + keyword search separate from the free-text caption. + """ + + extracted_text: str + caption: str + tags: tuple[str, ...] + + +class ImageContentClient(Protocol): + """Turns image bytes into searchable text content.""" + + available: bool + + def describe(self, image_bytes: bytes, mime_type: str) -> ImageDescription: ... + + +class NullImageContentClient: + """No vision provider configured -- the image channel is skipped.""" + + available = False + + def describe(self, image_bytes: bytes, mime_type: str) -> ImageDescription: # pragma: no cover + raise RuntimeError("NullImageContentClient has no image channel; check .available first") + + +_RESPONSE_FORMAT = ( + "Examine this image. Reply with EXACTLY three lines, no extra commentary:\n" + "TEXT: \n" + "CAPTION: \n" + "TAGS: " +) +# DOTALL + non-greedy so TEXT: can legitimately span multiple lines (real +# OCR output is often multi-line) without losing everything after the +# first newline, while still stopping at the next expected label. +_DESCRIPTION_PATTERN = re.compile( + r"TEXT:\s*(?P.*?)\s*CAPTION:\s*(?P.*?)\s*TAGS:\s*(?P.*)", + re.DOTALL, +) + + +class ImageDescriptionParseError(ValueError): + """The vision provider's response didn't match the required + TEXT/CAPTION/TAGS format -- raised instead of silently returning an + empty ImageDescription, so a provider response-format change is + surfaced immediately rather than quietly losing searchable content. + """ + + +def _parse_description(content: str) -> ImageDescription: + match = _DESCRIPTION_PATTERN.search(content) + if match is None: + raise ImageDescriptionParseError( + f"vision response did not match the required TEXT/CAPTION/TAGS format: {content!r}" + ) + extracted_text = match.group("text").strip() + if extracted_text.upper() == "NONE": + extracted_text = "" + caption = match.group("caption").strip() + tags_raw = match.group("tags").strip() + tags = tuple(tag.strip() for tag in tags_raw.split(",") if tag.strip()) + return ImageDescription(extracted_text=extracted_text, caption=caption, tags=tags) + + +class OpenAiCompatibleVisionClient: + """Calls an OpenAI-compatible vision-capable chat model for OCR and + object recognition/tagging in one round trip. + """ + + available = True + + def __init__( + self, + base_url: str, + api_key: str, + model: str, + *, + timeout: float = 60.0, + allow_insecure_http: bool = False, + ) -> None: + parsed = urlparse(base_url) + if parsed.scheme not in {"http", "https"}: + raise ValueError( + f"unsupported vision client URL scheme: {parsed.scheme or 'missing'}" + ) + if parsed.scheme == "http" and not allow_insecure_http: + # A plain-HTTP endpoint sends the Bearer API key and every raw + # image over the wire unencrypted. Secure-by-default: require + # an explicit opt-in (local dev/tests only) rather than + # allowing any remote http:// host silently. + raise ValueError( + "OpenAiCompatibleVisionClient requires https:// by default; " + "pass allow_insecure_http=True for local-dev-only http:// endpoints" + ) + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._model = model + self._timeout = timeout + + def describe(self, image_bytes: bytes, mime_type: str) -> ImageDescription: + data_uri = f"data:{mime_type};base64,{base64.b64encode(image_bytes).decode('ascii')}" + body = post_json( + f"{self._base_url}/chat/completions", + { + "model": self._model, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": _RESPONSE_FORMAT}, + {"type": "image_url", "image_url": {"url": data_uri}}, + ], + } + ], + "max_tokens": 300, + "temperature": 0.0, + }, + headers={"authorization": f"Bearer {self._api_key}"}, + timeout=self._timeout, + ) + content = body["choices"][0]["message"]["content"] + return _parse_description(content) diff --git a/pyproject.toml b/pyproject.toml index 9cec2c699..be2d2671f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.2.0" +version = "0.3.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } @@ -10,13 +10,14 @@ dependencies = [ # RankWeave has no PyPI release yet; pinned to a specific commit (not a # floating branch ref) for reproducible installs, per org convention. "rankweave @ git+https://github.com/ContextualWisdomLab/RankWeave.git@61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6", - # Explicit CA bundle for embedding_client/adjudication_client -- some - # interpreter distributions don't reliably inherit the OS trust store. + # Explicit CA bundle for http_client HTTPS posts -- some interpreter + # distributions don't reliably inherit the OS trust store. "certifi>=2024.0.0", ] [project.optional-dependencies] dev = [ + "pillow>=12.3.0", "pytest>=8.0", ] diff --git a/tests/test_chunking.py b/tests/test_chunking.py new file mode 100644 index 000000000..a56ade6ae --- /dev/null +++ b/tests/test_chunking.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from lineageweave.chunking import ( + ConversationTurn, + chunk_by_conversation_turn, + chunk_by_dom, + chunk_by_paragraph, + chunk_by_sentence, +) + + +def test_chunk_by_paragraph_splits_on_blank_lines() -> None: + text = "First paragraph about budgets.\n\nSecond paragraph about logistics.\n\nThird." + chunks = chunk_by_paragraph(text) + + assert [c.text for c in chunks] == [ + "First paragraph about budgets.", + "Second paragraph about logistics.", + "Third.", + ] + assert all(c.unit_type == "paragraph" for c in chunks) + assert [c.index for c in chunks] == [0, 1, 2] + + +def test_chunk_by_paragraph_ignores_extra_blank_lines_and_whitespace() -> None: + text = " A. \n\n\n\n B. " + chunks = chunk_by_paragraph(text) + assert [c.text for c in chunks] == ["A.", "B."] + + +def test_chunk_by_paragraph_empty_text_yields_no_chunks() -> None: + assert chunk_by_paragraph("") == [] + assert chunk_by_paragraph(" \n\n ") == [] + + +def test_chunk_by_sentence_splits_on_sentence_boundaries() -> None: + text = "This is one sentence. This is another! Is this a third?" + chunks = chunk_by_sentence(text) + + assert [c.text for c in chunks] == [ + "This is one sentence.", + "This is another!", + "Is this a third?", + ] + assert all(c.unit_type == "sentence" for c in chunks) + + +def test_chunk_by_dom_splits_on_block_element_boundaries() -> None: + html = ( + "

First block of text.

Second block of text.

" + "" + ) + chunks = chunk_by_dom(html) + + texts = [c.text for c in chunks] + assert "First block of text." in texts + assert "Second block of text." in texts + assert "Unrelated sidebar content." in texts + assert all(c.unit_type == "dom" for c in chunks) + + +def test_chunk_by_dom_nested_blocks_do_not_duplicate_text() -> None: + html = "

Nested paragraph text.

" + chunks = chunk_by_dom(html) + + # The outermost block owns the text; there is exactly one chunk, not + # one for the div and a duplicate for the p inside it. + assert len(chunks) == 1 + assert chunks[0].text == "Nested paragraph text." + + +def test_chunk_by_dom_empty_html_yields_no_chunks() -> None: + assert chunk_by_dom("
") == [] + assert chunk_by_dom("") == [] + + +def test_chunk_by_dom_interleaves_images_with_text_in_document_order() -> None: + tiny_png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + html = ( + f"

Before the picture.

" + f'' + f"

After the picture.

" + ) + chunks = chunk_by_dom(html) + + assert [c.unit_type for c in chunks] == ["dom", "image", "dom"] + assert [c.index for c in chunks] == [0, 1, 2] + assert chunks[0].text == "Before the picture." + assert chunks[1].label == "image/png" + assert chunks[1].image_data is not None + assert chunks[2].text == "After the picture." + + +def test_chunk_by_dom_labels_text_chunks_with_their_tag_name() -> None: + html = "

A paragraph.

" + chunks = chunk_by_dom(html) + + labels = {c.text: c.label for c in chunks} + assert labels["A paragraph."] == "p" + assert labels["Sidebar."] == "aside" + + +def test_chunk_by_dom_skips_malformed_image_data() -> None: + html = '

Text.

' + chunks = chunk_by_dom(html) + assert [c.unit_type for c in chunks] == ["dom"] + + +def test_chunk_by_conversation_turn_labels_each_chunk_with_its_sender() -> None: + turns = [ + ConversationTurn(sender="alice@example.com", text="Can we move the meeting?"), + ConversationTurn(sender="bob@example.com", text="Sure, how about Thursday?"), + ] + chunks = chunk_by_conversation_turn(turns) + + assert [c.label for c in chunks] == ["alice@example.com", "bob@example.com"] + assert [c.text for c in chunks] == ["Can we move the meeting?", "Sure, how about Thursday?"] + assert all(c.unit_type == "conversation_turn" for c in chunks) + + +def test_chunk_by_conversation_turn_skips_empty_turns() -> None: + turns = [ + ConversationTurn(sender="alice@example.com", text="Hello."), + ConversationTurn(sender="bob@example.com", text=" "), + ] + chunks = chunk_by_conversation_turn(turns) + assert len(chunks) == 1 + assert chunks[0].label == "alice@example.com" + + +def test_chunk_by_conversation_turn_index_is_contiguous_after_filtering() -> None: + """A regression test for a real bug: an empty turn in the MIDDLE of + the conversation must not leave a gap in the surviving chunks' + `index` values (e.g. [0, 2] instead of [0, 1]) -- `Chunk.index` is a + position among the chunks actually returned, not the original turn + list's position. + """ + turns = [ + ConversationTurn(sender="alice@example.com", text="First."), + ConversationTurn(sender="bob@example.com", text=" "), + ConversationTurn(sender="carol@example.com", text="Third."), + ] + chunks = chunk_by_conversation_turn(turns) + assert [c.index for c in chunks] == [0, 1] + assert [c.label for c in chunks] == ["alice@example.com", "carol@example.com"] diff --git a/tests/test_embedding_client.py b/tests/test_embedding_client.py new file mode 100644 index 000000000..a399fa1f2 --- /dev/null +++ b/tests/test_embedding_client.py @@ -0,0 +1,86 @@ +"""Unit tests for embedding_client.chunked_max_similarity's whole-text +fallback contract, using a fake (non-real-provider) client -- no network, +no credentials needed. The real-provider test in +tests/test_real_provider_integration.py proves the same function works +against a live embedding endpoint; this file proves the fallback logic +itself is correct regardless of provider. +""" + +from __future__ import annotations + +from lineageweave.chunking import Chunk +from lineageweave.embedding_client import chunked_max_similarity + + +class _RecordingFakeEmbeddingClient: + """Deterministic fake: embeds a string as a length-1 vector of its own + length, so equal-length strings score identically and call counts are + trivially inspectable. + """ + + available = True + + def __init__(self) -> None: + self.embed_calls: list[str] = [] + + def embed(self, text: str) -> list[float]: + self.embed_calls.append(text) + return [float(len(text))] + + +def _chunk_to_two_pieces(text: str) -> list[Chunk]: + half = len(text) // 2 + return [ + Chunk(text=text[:half], unit_type="paragraph", index=0), + Chunk(text=text[half:], unit_type="paragraph", index=1), + ] + + +def _chunk_to_one_piece(text: str) -> list[Chunk]: + # Deliberately NOT the identical string -- a real chunker normalizes + # (e.g. strips/collapses whitespace), which is exactly the case the + # fallback must override so the original text still gets embedded. + return [Chunk(text=text.strip(), unit_type="paragraph", index=0)] + + +def _chunk_to_zero_pieces(text: str) -> list[Chunk]: + return [] + + +def test_falls_back_to_whole_text_when_chunker_returns_zero_pieces() -> None: + client = _RecordingFakeEmbeddingClient() + original = " padded text with whitespace " + + _, chunk_a, chunk_b = chunked_max_similarity(client, original, "other", chunker=_chunk_to_zero_pieces) + + assert chunk_a.unit_type == "whole" + assert chunk_a.text == original # original whitespace preserved, not stripped + assert client.embed_calls.count(original) == 1 + + +def test_falls_back_to_whole_text_when_chunker_returns_exactly_one_piece() -> None: + client = _RecordingFakeEmbeddingClient() + original = " padded text with whitespace " + + _, chunk_a, chunk_b = chunked_max_similarity(client, original, "other", chunker=_chunk_to_one_piece) + + assert chunk_a.unit_type == "whole" + assert chunk_a.text == original # the chunker's stripped version must NOT be used + assert client.embed_calls.count(original) == 1 + # Exactly one embedding call for this document -- the chunker's own + # (normalized) chunk is never embedded once the fallback applies. + assert client.embed_calls.count(original.strip()) == 0 + + +def test_uses_chunker_output_directly_when_it_returns_two_or_more_pieces() -> None: + client = _RecordingFakeEmbeddingClient() + + _, chunk_a, chunk_b = chunked_max_similarity( + client, "abcdefgh", "ijklmnop", chunker=_chunk_to_two_pieces + ) + + assert chunk_a.unit_type == "paragraph" + assert chunk_b.unit_type == "paragraph" + # Both documents chunk into 2 pieces each via _chunk_to_two_pieces -- + # the fallback must NOT engage, so every chunk gets its own embed call. + assert len(client.embed_calls) == 4 diff --git a/tests/test_http_client.py b/tests/test_http_client.py new file mode 100644 index 000000000..3b6e6c8ed --- /dev/null +++ b/tests/test_http_client.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +import ssl +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from lineageweave.http_client import HttpClientError, post_json + + +class _JsonHandler(BaseHTTPRequestHandler): + received: dict = {} + + def do_POST(self) -> None: # noqa: N802 -- BaseHTTPRequestHandler API + length = int(self.headers.get("content-length", "0")) + raw = self.rfile.read(length) + type(self).received = { + "path": self.path, + "authorization": self.headers.get("authorization"), + "payload": json.loads(raw.decode("utf-8")), + } + body = json.dumps({"ok": True, "echo": type(self).received["payload"]}).encode("utf-8") + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args) -> None: # noqa: A002 -- stdlib signature + return + + +class _ErrorHandler(BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 -- BaseHTTPRequestHandler API + self.send_response(503) + self.send_header("content-length", "0") + self.end_headers() + + def log_message(self, format: str, *args) -> None: # noqa: A002 -- stdlib signature + return + + +def _serve(handler: type[BaseHTTPRequestHandler]) -> tuple[HTTPServer, str]: + server = HTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + host, port = server.server_address[:2] + return server, f"http://{host}:{port}" + + +def test_post_json_refuses_file_scheme() -> None: + with pytest.raises(ValueError, match="non-http"): + post_json("file:///etc/passwd", {}, headers={}, timeout=1.0) + + +def test_post_json_refuses_missing_hostname() -> None: + with pytest.raises(ValueError, match="hostname"): + post_json("https:///v1/embeddings", {}, headers={}, timeout=1.0) + + +def test_post_json_posts_json_to_http_endpoint() -> None: + _JsonHandler.received = {} + server, base = _serve(_JsonHandler) + try: + body = post_json( + f"{base}/v1/embeddings", + {"model": "demo", "input": "hello"}, + headers={"authorization": "Bearer test-token"}, + timeout=2.0, + ) + finally: + server.shutdown() + + assert body == {"ok": True, "echo": {"model": "demo", "input": "hello"}} + assert _JsonHandler.received["path"] == "/v1/embeddings" + assert _JsonHandler.received["authorization"] == "Bearer test-token" + + +def test_post_json_https_negotiates_tls_instead_of_plaintext() -> None: + server, base = _serve(_JsonHandler) + try: + https_url = base.replace("http://", "https://", 1) + "/v1/embeddings" + with pytest.raises(ssl.SSLError): + post_json(https_url, {"model": "demo"}, headers={}, timeout=2.0) + finally: + server.shutdown() + + +def test_post_json_raises_on_http_error() -> None: + server, base = _serve(_ErrorHandler) + try: + with pytest.raises(HttpClientError, match="HTTP 503"): + post_json(f"{base}/fail", {}, headers={}, timeout=2.0) + finally: + server.shutdown() diff --git a/tests/test_image_content.py b/tests/test_image_content.py new file mode 100644 index 000000000..7a36d2a9a --- /dev/null +++ b/tests/test_image_content.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import base64 + +import pytest + +from lineageweave.image_content import ( + ImageDescriptionParseError, + OpenAiCompatibleVisionClient, + _parse_description, + extract_base64_images, +) + +# A 1x1 transparent PNG, valid base64 -- enough to exercise real decoding +# without needing an image library for pure extraction/parsing tests. +_TINY_PNG_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" +) + + +def test_extract_base64_images_finds_images_in_document_order() -> None: + html = ( + f'

Intro text.

' + f'

Middle text.

' + ) + images = extract_base64_images(html) + + # position is a character offset (not an image-only ordinal), so the + # second image's position must fall strictly after the first image's + # entire tag AND the "Middle text." paragraph between them -- + # exactly what distinguishes "two images with content between them" + # from "two images back to back," which an ordinal cannot. + assert images[0].position < images[1].position + assert images[1].position >= images[0].position + len(f'') + len( + "

Middle text.

" + ) + assert all(img.mime_type == "image/png" for img in images) + assert all(img.data == base64.b64decode(_TINY_PNG_B64) for img in images) + + +def test_extract_base64_images_skips_malformed_base64() -> None: + html = '' + assert extract_base64_images(html) == [] + + +def test_extract_base64_images_ignores_non_data_uri_images() -> None: + html = '' + assert extract_base64_images(html) == [] + + +def test_extract_base64_images_empty_document_yields_no_images() -> None: + assert extract_base64_images("

No images here.

") == [] + + +def test_parse_description_extracts_all_three_fields() -> None: + content = "TEXT: Quarterly Budget Report\nCAPTION: A printed report cover page.\nTAGS: document, report, text" + description = _parse_description(content) + + assert description.extracted_text == "Quarterly Budget Report" + assert description.caption == "A printed report cover page." + assert description.tags == ("document", "report", "text") + + +def test_parse_description_none_text_becomes_empty_string() -> None: + content = "TEXT: NONE\nCAPTION: A blue sky with clouds.\nTAGS: sky, clouds, nature" + description = _parse_description(content) + assert description.extracted_text == "" + + +def test_parse_description_unexpected_format_raises_instead_of_losing_content() -> None: + with pytest.raises(ImageDescriptionParseError): + _parse_description("unexpected format") + + +def test_parse_description_preserves_multiline_ocr_text() -> None: + content = "TEXT: Line one\nLine two\nLine three\nCAPTION: A scanned page.\nTAGS: document, scan" + description = _parse_description(content) + assert description.extracted_text == "Line one\nLine two\nLine three" + assert description.caption == "A scanned page." + + +def test_vision_client_rejects_non_http_url_schemes() -> None: + with pytest.raises(ValueError, match="unsupported vision client URL scheme: file"): + OpenAiCompatibleVisionClient( + base_url="file:///etc/passwd", + api_key="unused", + model="unused", + ) + + +def test_vision_client_accepts_https_urls_by_default() -> None: + https_client = OpenAiCompatibleVisionClient( + base_url="https://gateway.example/v1", + api_key="unused", + model="unused", + ) + assert https_client._base_url == "https://gateway.example/v1" + + +def test_vision_client_rejects_plain_http_by_default() -> None: + """A plain-HTTP endpoint sends the Bearer API key and raw images + unencrypted -- secure by default, explicit opt-in only. + """ + with pytest.raises(ValueError, match="requires https://"): + OpenAiCompatibleVisionClient( + base_url="http://127.0.0.1:8000/v1", + api_key="unused", + model="unused", + ) + + +def test_vision_client_allows_http_with_explicit_insecure_opt_in() -> None: + http_client = OpenAiCompatibleVisionClient( + base_url="http://127.0.0.1:8000/v1", + api_key="unused", + model="unused", + allow_insecure_http=True, + ) + assert http_client._base_url == "http://127.0.0.1:8000/v1" diff --git a/tests/test_real_provider_integration.py b/tests/test_real_provider_integration.py index 7cb829f5d..fd9d4ea25 100644 --- a/tests/test_real_provider_integration.py +++ b/tests/test_real_provider_integration.py @@ -16,7 +16,12 @@ import pytest from lineageweave.adjudication_client import ContextualOrchestratorAdjudicationClient -from lineageweave.embedding_client import OpenAiCompatibleEmbeddingClient, cosine_similarity +from lineageweave.embedding_client import ( + OpenAiCompatibleEmbeddingClient, + chunked_max_similarity, + cosine_similarity, +) +from lineageweave.image_content import OpenAiCompatibleVisionClient _EMBEDDING_BASE_URL = os.environ.get("LINEAGEWEAVE_TEST_EMBEDDING_BASE_URL") _EMBEDDING_API_KEY = os.environ.get("LINEAGEWEAVE_TEST_EMBEDDING_API_KEY") @@ -25,6 +30,10 @@ _ORCHESTRATOR_BASE_URL = os.environ.get("LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL") _ORCHESTRATOR_API_KEY = os.environ.get("LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY") +_VISION_BASE_URL = os.environ.get("LINEAGEWEAVE_TEST_VISION_BASE_URL") +_VISION_API_KEY = os.environ.get("LINEAGEWEAVE_TEST_VISION_API_KEY") +_VISION_MODEL = os.environ.get("LINEAGEWEAVE_TEST_VISION_MODEL", "gpt-4.1-mini") + @pytest.mark.skipif( not (_EMBEDDING_BASE_URL and _EMBEDDING_API_KEY), @@ -51,6 +60,65 @@ def test_openai_compatible_embedding_client_scores_similar_text_higher() -> None assert related_score > unrelated_score +@pytest.mark.skipif( + not (_EMBEDDING_BASE_URL and _EMBEDDING_API_KEY), + reason="set LINEAGEWEAVE_TEST_EMBEDDING_BASE_URL and LINEAGEWEAVE_TEST_EMBEDDING_API_KEY to run", +) +def test_chunked_embedding_finds_a_relevant_unit_buried_in_a_longer_document() -> None: + """The real case chunking exists for: a short relevant passage sitting + inside a much longer, mostly-irrelevant document. Whole-document + embedding dilutes the relevant passage with everything around it; + chunked max-pooled similarity should not. + """ + client = OpenAiCompatibleEmbeddingClient( + base_url=_EMBEDDING_BASE_URL, api_key=_EMBEDDING_API_KEY, model=_EMBEDDING_MODEL + ) + + query = "Quarterly budget review meeting notes" + long_document = ( + "Office parking lot repaving schedule for the north campus.\n\n" + "New badge access policy for the west entrance starting next month.\n\n" + "Budget review follow-up: revised quarterly numbers and next steps.\n\n" + "Cafeteria menu rotation for the coming season.\n\n" + "Reminder about the annual fire drill scheduled for next week." + ) + + chunked_score, _best_a, best_b = chunked_max_similarity(client, query, long_document) + whole_document_score = cosine_similarity(client.embed(query), client.embed(long_document)) + + assert "Budget review" in best_b.text + assert chunked_score > whole_document_score + + +@pytest.mark.skipif( + not (_VISION_BASE_URL and _VISION_API_KEY), + reason="set LINEAGEWEAVE_TEST_VISION_BASE_URL and LINEAGEWEAVE_TEST_VISION_API_KEY to run", +) +def test_vision_client_performs_real_ocr_on_a_generated_image() -> None: + """Generate a real PNG with real rendered text (Pillow, not a fixture + file) and prove the vision client actually reads it back -- real OCR, + not a mocked response. + """ + from io import BytesIO + + from PIL import Image, ImageDraw + + image = Image.new("RGB", (400, 100), color="white") + draw = ImageDraw.Draw(image) + draw.text((10, 40), "INVOICE 48213", fill="black") + buffer = BytesIO() + image.save(buffer, format="PNG") + + client = OpenAiCompatibleVisionClient( + base_url=_VISION_BASE_URL, api_key=_VISION_API_KEY, model=_VISION_MODEL + ) + description = client.describe(buffer.getvalue(), "image/png") + + assert "48213" in description.extracted_text + assert description.caption + assert len(description.tags) > 0 + + @pytest.mark.skipif( not (_ORCHESTRATOR_BASE_URL and _ORCHESTRATOR_API_KEY), reason=( diff --git a/uv.lock b/uv.lock new file mode 100644 index 000000000..d1658478d --- /dev/null +++ b/uv.lock @@ -0,0 +1,284 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "lineageweave" +version = "0.3.0" +source = { virtual = "." } +dependencies = [ + { name = "certifi" }, + { name = "rankweave" }, + { name = "threadweave" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pillow" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "certifi", specifier = ">=2024.0.0" }, + { name = "pillow", marker = "extra == 'dev'", specifier = ">=12.3.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "rankweave", git = "https://github.com/ContextualWisdomLab/RankWeave.git?rev=61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6" }, + { name = "threadweave", specifier = ">=0.1.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "rankweave" +version = "0.18.0" +source = { git = "https://github.com/ContextualWisdomLab/RankWeave.git?rev=61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6#61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6" } + +[[package]] +name = "threadweave" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/82/878a0a39183e2e0e084c875d77160dd697f06f013d231ef5dbdf57865d8c/threadweave-0.1.0.tar.gz", hash = "sha256:8ee7c01bf3855fa12ed0d28cbec4f4ba31c405801b535d2931c5870dfe467f98", size = 15506, upload-time = "2026-07-12T03:59:58.364Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/e0/ffbc0d61d68304602120998a5d660c8108464064bdedc814dc4be8410425/threadweave-0.1.0-py3-none-any.whl", hash = "sha256:03c31fa21873a9493687d81eab4ec067bf169dade7cff077b80df46fd0db3aaf", size = 14967, upload-time = "2026-07-12T03:59:57.088Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]