Skip to content

Commit 506012e

Browse files
committed
Q2-4: ingest and library nits (streaming, title unescape, JPEG modes, raise from, payload guard)
RED-FIRST: three acceptance tests were written and shown failing on origin/dev before any production code was changed. RED run (4 tests, 0 fixes applied): ``` FAILED tests/test_knowledge_ingest.py::test_download_article_unescapes_title_entities FAILED tests/test_library.py::TestImageProcessor::test_process_pa_mode_image FAILED tests/test_library.py::TestImageProcessor::test_process_la_mode_image FAILED tests/test_agent_registry_store.py::TestTokenMinting::test_verify_non_dict_payload_raises 4 failed in 1.13s ``` GREEN run (after fixes): ``` 4 passed in 0.84s ``` Changes (audit pass-2 card Q2-4, section 4.4): - library_pipeline.py TextProcessor: stream file read/write in a single pass instead of loading the entire file into a str (avoids the 100 MB str copy); char_count, line_count, preview, and auto-title are computed incrementally while streaming. - library_pipeline.py ImageProcessor: JPEG thumbnail conversion now handles LA, PA, I;16 and other non-RGB/L/CMYK Pillow modes instead of only RGBA/P. - knowledge_ingest.py _download_article: unescape HTML entities in the title extracted from the <title> tag, matching library_pipeline.WebProcessor. - x.py XWatchStore.create_watch: chain sqlite3.IntegrityError via raise from e. - agent_registry_store.py verify_registry_token: isinstance payload dict guard with a clear ValueError when the JWT payload is not a JSON object. Already fixed by prior commit (verified, no change needed): - youtube.py / x.py: --dump-single-json (was --dump-json) - youtube.py / x.py: asyncio.wait_for timeout on subprocess communicate() - x.py fetch_tweet_ytdlp: raise from exc Docs-Reviewed: no routes/, desktop app, or catalog manifest changes; these are internal library pipeline fixes with no agent-facing API surface
1 parent 137fdd3 commit 506012e

8 files changed

Lines changed: 151 additions & 16 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
### Fixed
2+
3+
- TextProcessor now streams file reads instead of loading the entire file into a single `str`, eliminating the 100 MB `str` copy on large text files.
4+
- Article title extraction in `knowledge_ingest._download_article` now unescapes HTML entities (e.g. `&amp;` becomes `&`), matching the behaviour already present in `library_pipeline.WebProcessor`.
5+
- ImageProcessor JPEG thumbnail conversion now handles `LA`, `PA`, `I;16` and other non-RGB/L/CMYK Pillow modes instead of raising on them.
6+
- `x.py` `create_watch` now chains the `sqlite3.IntegrityError` via `raise ... from e`.
7+
- `verify_registry_token` now raises `ValueError` with a clear message when the JWT payload is not a JSON object (dict).

tests/test_agent_registry_store.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,27 @@ def test_verify_truncated_token_raises(self, signing_keypair):
278278
with pytest.raises(ValueError):
279279
verify_registry_token("one", pub)
280280

281+
def test_verify_non_dict_payload_raises(self, signing_keypair):
282+
"""RED-FIRST: a token whose payload JSON decodes to a non-dict
283+
(e.g. a JSON list) must raise ValueError mentioning the field name,
284+
not silently return the non-dict value."""
285+
from cryptography.hazmat.primitives.serialization import load_pem_private_key
286+
287+
priv, pub = signing_keypair
288+
private_key = load_pem_private_key(priv, password=None)
289+
290+
header = _b64url_encode(
291+
json.dumps({"alg": "EdDSA", "typ": "JWT"}, separators=(",", ":")).encode()
292+
)
293+
# Payload is a JSON array, NOT a dict
294+
payload = _b64url_encode(json.dumps([1, 2, 3]).encode())
295+
signing_input = f"{header}.{payload}".encode()
296+
signature = _b64url_encode(private_key.sign(signing_input))
297+
token = f"{header}.{payload}.{signature}"
298+
299+
with pytest.raises(ValueError, match="payload"):
300+
verify_registry_token(token, pub)
301+
281302
def test_token_has_jti(self, signing_keypair):
282303
priv, pub = signing_keypair
283304
token = mint_registry_token("agent-006", priv)

tests/test_knowledge_ingest.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,3 +715,46 @@ async def test_embed_failure_sets_partial_status(store):
715715
assert item["status"] == "partial", (
716716
f"Expected 'partial' status on embed failure, got '{item['status']}'"
717717
)
718+
719+
720+
# ------------------------------------------------------------------
721+
# Q2-4 RED-FIRST: title HTML entities must be unescaped
722+
# ------------------------------------------------------------------
723+
724+
725+
@pytest.mark.asyncio
726+
async def test_download_article_unescapes_title_entities(store):
727+
"""RED-FIRST: a <title> containing HTML entities (e.g. &amp;) must be
728+
unescaped to the bare character in the returned title.
729+
&amp; must become &."""
730+
html = (
731+
"<html><head><title>Test &amp; Demo Title</title></head>"
732+
"<body><article>"
733+
"<p>This is a long enough article body for readability extraction.</p>"
734+
"</article></body></html>"
735+
)
736+
resp = _TrackedResponse([html.encode("utf-8")])
737+
738+
mock_http = AsyncMock()
739+
mock_http.get = AsyncMock(return_value=resp)
740+
741+
notif = AsyncMock()
742+
notif.emit_event = AsyncMock()
743+
cat_engine = AsyncMock()
744+
cat_engine.categorise = AsyncMock(return_value=[])
745+
746+
pipeline = IngestPipeline(
747+
store=store,
748+
http_client=mock_http,
749+
fetch_client=mock_http,
750+
notifications=notif,
751+
category_engine=cat_engine,
752+
)
753+
754+
content, title, author, metadata = await pipeline._download_article(
755+
"https://example.com/test", "", {}
756+
)
757+
758+
assert title == "Test & Demo Title", (
759+
f"Expected unescaped title 'Test & Demo Title', got: {title!r}"
760+
)

tests/test_library.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,54 @@ async def test_process_missing_image(self, lib_store, storage_dir):
356356
artifacts = await proc.process(item)
357357
assert len(artifacts) == 0
358358

359+
@pytest.mark.asyncio
360+
async def test_process_pa_mode_image(self, lib_store, storage_dir):
361+
"""RED-FIRST: a PA-mode (palette + alpha) image must convert to JPEG
362+
without raising, producing a thumbnail artifact."""
363+
from PIL import Image
364+
365+
file_path = storage_dir / "test_pa.tiff"
366+
img = Image.new("PA", (100, 50), color=(128, 255))
367+
img.save(file_path, format="TIFF")
368+
369+
item_id = await lib_store.create_item(
370+
kind="image", title="test_pa.tiff", storage_path=str(file_path)
371+
)
372+
item = await lib_store.get_item(item_id)
373+
374+
proc = ImageProcessor(lib_store, storage_dir)
375+
artifacts = await proc.process(item)
376+
377+
kinds = {a["kind"] for a in artifacts}
378+
assert "thumbnail" in kinds, (
379+
f"PA-mode image should produce a thumbnail, got kinds: {kinds}"
380+
)
381+
thumb_art = [a for a in artifacts if a["kind"] == "thumbnail"][0]
382+
assert Path(thumb_art["path"]).exists()
383+
384+
@pytest.mark.asyncio
385+
async def test_process_la_mode_image(self, lib_store, storage_dir):
386+
"""RED-FIRST: an LA-mode (grayscale + alpha) PNG must convert to JPEG
387+
without raising, producing a thumbnail artifact."""
388+
from PIL import Image
389+
390+
file_path = storage_dir / "test_la.png"
391+
img = Image.new("LA", (100, 50), color=(128, 255))
392+
img.save(file_path, format="PNG")
393+
394+
item_id = await lib_store.create_item(
395+
kind="image", title="test_la.png", storage_path=str(file_path)
396+
)
397+
item = await lib_store.get_item(item_id)
398+
399+
proc = ImageProcessor(lib_store, storage_dir)
400+
artifacts = await proc.process(item)
401+
402+
kinds = {a["kind"] for a in artifacts}
403+
assert "thumbnail" in kinds, (
404+
f"LA-mode image should produce a thumbnail, got kinds: {kinds}"
405+
)
406+
359407

360408
# ---------------------------------------------------------------------------
361409
# run_pipeline

tinyagentos/agent_registry_store.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,10 @@ def verify_registry_token(token: str, public_key_pem: bytes) -> dict:
394394
raise ValueError("token signature verification failed") from None
395395

396396
payload = json.loads(_b64url_decode(payload_b64))
397+
if not isinstance(payload, dict):
398+
raise ValueError(
399+
f"payload must be a JSON object, got {type(payload).__name__}"
400+
)
397401
return payload
398402

399403

tinyagentos/knowledge_fetchers/x.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,8 +299,8 @@ def create_watch(
299299
(handle.lstrip("@"), filters_json, frequency, created_at),
300300
)
301301
conn.commit()
302-
except sqlite3.IntegrityError:
303-
raise ValueError(f"Watch for @{handle} already exists")
302+
except sqlite3.IntegrityError as e:
303+
raise ValueError(f"Watch for @{handle} already exists") from e
304304
return self.get_watch(handle.lstrip("@")) # type: ignore[return-value]
305305

306306
def list_watches(self) -> list[dict]:

tinyagentos/knowledge_ingest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,7 @@ async def _download_article(
431431
if not title:
432432
m = re.search(r"<title[^>]*>([^<]+)</title>", html, re.IGNORECASE)
433433
if m:
434-
title = m.group(1).strip()
434+
title = _html_mod.unescape(m.group(1)).strip()
435435
return content, title, "", metadata
436436

437437
# ------------------------------------------------------------------

tinyagentos/library_pipeline.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -154,21 +154,32 @@ async def process(self, item: dict) -> list[dict]:
154154
return artifacts
155155

156156
try:
157-
text = p.read_text(encoding="utf-8", errors="replace")
157+
char_count = 0
158+
line_count = 1
159+
preview = ""
160+
text_dir = self.storage_dir / "text"
161+
text_dir.mkdir(parents=True, exist_ok=True)
162+
text_path = text_dir / f"{item_id}.txt"
163+
with open(p, "r", encoding="utf-8", errors="replace") as src:
164+
with open(text_path, "w", encoding="utf-8") as dst:
165+
while True:
166+
chunk = src.read(8192)
167+
if not chunk:
168+
break
169+
dst.write(chunk)
170+
char_count += len(chunk)
171+
line_count += chunk.count("\n")
172+
if len(preview) < 200:
173+
preview += chunk
174+
preview = preview[:200]
158175
except Exception:
159176
logger.warning("Text processor: could not read %s", storage_path,
160177
exc_info=True)
161178
return artifacts
162179

163-
# Write extracted text as an artifact
164-
text_dir = self.storage_dir / "text"
165-
text_dir.mkdir(parents=True, exist_ok=True)
166-
text_path = text_dir / f"{item_id}.txt"
167-
text_path.write_text(text, encoding="utf-8")
168-
169180
text_meta = {
170-
"char_count": len(text),
171-
"line_count": text.count("\n") + 1,
181+
"char_count": char_count,
182+
"line_count": line_count,
172183
"source_url": item.get("source_url", ""),
173184
"processed_at": time.time(),
174185
"processor": "TextProcessor/v1",
@@ -179,14 +190,13 @@ async def process(self, item: dict) -> list[dict]:
179190
artifacts.append({"kind": "text", "path": str(text_path), "meta": text_meta})
180191

181192
# Store a preview (first 200 chars)
182-
preview = text[:200]
183193
meta = json.loads(item.get("meta_json", "{}"))
184194
meta["preview"] = preview
185195
await self.store.update_item(item_id, meta_json=meta)
186196

187197
# Auto-title from content if no title
188198
if not item.get("title"):
189-
title = text.strip().split("\n", 1)[0][:100]
199+
title = preview.strip().split("\n", 1)[0][:100]
190200
if title:
191201
await self.store.update_item(item_id, title=title)
192202

@@ -295,8 +305,10 @@ async def process(self, item: dict) -> list[dict]:
295305
thumb_path = thumb_dir / f"{item_id}_thumb.jpg"
296306

297307
img.thumbnail((320, 320))
298-
# Convert to RGB if needed (e.g. RGBA/PNG → JPEG)
299-
if img.mode in ("RGBA", "P"):
308+
# Convert to RGB if needed (e.g. RGBA/PNG → JPEG).
309+
# JPEG supports only "L", "RGB", "CMYK"; all other modes
310+
# (including LA, PA, I;16) must be converted first.
311+
if img.mode not in ("RGB", "L", "CMYK"):
300312
img = img.convert("RGB")
301313
img.save(thumb_path, "JPEG", quality=75)
302314

0 commit comments

Comments
 (0)