File size: 5,997 Bytes
f07443e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | """Asset identity and persistence for figures pulled out of a parse. Added 0.4.0.
Two jobs, deliberately small:
1. **Identity.** `asset_id()` content-addresses the bytes. The parser's own id is
not usable: Mistral names images per PAGE (`img-0.jpeg`), so the same name
recurs across pages — the Open Pit textbook produces **7 distinct names for 8
images**, and a flat store keyed on the parser's name loses one. Content
addressing also makes a re-parse idempotent and de-duplicates a figure that
appears in two documents.
2. **Persistence.** An `AssetSink` protocol plus a local-filesystem default.
**Why a protocol and not a blob client.** `src/knowledge_parsing/` imports nothing
from the rest of `src` and that is load-bearing — the package is handed to the
team as a standalone unit, so reaching into `src.storage` (which pulls app
settings, which needs `.env`) would break the export and make the parser
un-runnable outside this repo. The app injects a blob-backed sink instead; see
`knowledge_ingest.asset_sink`.
Persistence is a **never-throw seam**. A figure whose bytes cannot be stored
yields `storage_key=None` and a logged warning, never a failed parse: the parse is
the expensive, cached, hard-to-repeat half, and losing a whole document because
one PNG could not be written is the wrong trade.
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import logging
from pathlib import Path
from typing import Protocol, runtime_checkable
logger = logging.getLogger("knowledge_parsing.assets")
ASSET_ID_CHARS = 16
# Mistral reports ids like "img-0.jpeg"; the suffix is the only extension signal
# available. Anything unrecognised falls back to .bin rather than guessing, so a
# stored object is never mislabelled.
_KNOWN_EXT = {"jpg", "jpeg", "png", "gif", "webp", "bmp", "tiff"}
def asset_id(data: bytes) -> str:
"""Content address for asset bytes: sha256, first 16 hex chars.
16 chars is ~64 bits. At corpus scale (thousands of figures) the collision
probability is negligible, and it keeps the id short enough to read in a
`` reference inside a prose chunk.
"""
return hashlib.sha256(data).hexdigest()[:ASSET_ID_CHARS]
def extension_for(parser_id: str | None) -> str:
"""File extension from the parser's own id, defaulting to `bin`."""
if not parser_id or "." not in parser_id:
return "bin"
ext = parser_id.rsplit(".", 1)[-1].strip().lower()
return ext if ext in _KNOWN_EXT else "bin"
def decode_base64(payload: str | None) -> bytes | None:
"""Decode a base64 image payload, tolerating a `data:` URI prefix.
Returns None rather than raising on anything malformed — the caller treats a
missing payload as "no bytes to store", which is the same degraded path as a
failed write.
"""
if not payload:
return None
text = payload.strip()
if text.startswith("data:"):
_, _, text = text.partition(",")
try:
return base64.b64decode(text, validate=False)
except (binascii.Error, ValueError) as exc:
logger.warning("asset base64 decode failed: %r", exc)
return None
@runtime_checkable
class AssetSink(Protocol):
"""Somewhere to put asset bytes. Returns a storage key, or None on failure."""
def put(self, identifier: str, extension: str, data: bytes) -> str | None:
...
class LocalAssetSink:
"""Write assets to a local directory, named by content address.
The default, and what the CLI uses. Idempotent by construction: an asset whose
id already exists on disk is not rewritten, because the id IS the content.
"""
def __init__(self, root: Path, prefix: str = "assets") -> None:
self.root = Path(root)
self.prefix = prefix
def put(self, identifier: str, extension: str, data: bytes) -> str | None:
try:
target_dir = self.root / self.prefix
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / f"{identifier}.{extension}"
if not target.exists():
target.write_bytes(data)
return f"{self.prefix}/{identifier}.{extension}"
except OSError as exc:
logger.warning("asset write failed for %s: %r", identifier, exc)
return None
class NullAssetSink:
"""Discard asset bytes, keeping the asset metadata. For `--no-assets`.
Not the same as having no assets: the figure is still folded into its prose
chunk with its caption and description, and still carries an `asset_id`. Only
the bytes are not retained, so `storage_key` is None.
"""
def put(self, identifier: str, extension: str, data: bytes) -> str | None:
return None
def store_asset(
sink: AssetSink | None,
parser_id: str | None,
base64_payload: str | None,
) -> tuple[str | None, str | None]:
"""Content-address and persist one asset. Returns `(asset_id, storage_key)`.
`asset_id` is None only when there are no bytes to address — a table, or a
figure whose payload the parser did not return. `storage_key` is None whenever
the bytes were not retained, which is a normal outcome (`NullAssetSink`) as
well as a failure one.
"""
data = decode_base64(base64_payload)
if data is None:
return None, None
identifier = asset_id(data)
if sink is None:
return identifier, None
return identifier, sink.put(identifier, extension_for(parser_id), data)
def synthetic_asset_id(*parts: str) -> str:
"""Id for an asset with no bytes of its own — a table.
Derived from the document and chunk coordinates rather than content, because
there is no content to hash. Deterministic for the same document, which is
what matters: a re-parse produces the same id, so a pointer to a table
survives re-extraction the way a figure's does.
"""
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:ASSET_ID_CHARS]
|