fix(photo-exif): caption pass must preserve face enrichment#277
Conversation
Both enrichment workers now build their ingest payload through one shared lib/photo-payload.js, so the caption pass carries faces_detected/pictured/"Pictured: …" through instead of wiping them via the wholesale extra replace. An absent face count stays absent rather than becoming 0, and the two passes may now run in either order.
There was a problem hiding this comment.
Pull request overview
This PR fixes a data-loss bug in the photo-exif connector where the caption-enrichment pass could overwrite face-enrichment output due to ingest’s wholesale replacement of text_repr and extra. It centralizes photo-ingest payload construction so both enrichment workers always post the union of known fields, making the two passes order-independent.
Changes:
- Added
lib/photo-payload.jsto provide a sharedbuildPhotoPayload()plus helpers to read face enrichment and derive pictured names. - Refactored
face-worker.jsandcaption-worker.jsto use the shared payload builder so they no longer clobber each other’s fields. - Updated docs and added regression tests to assert preservation of face enrichment and “absent ≠ zero” semantics.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| connectors/photo-exif/lib/photo-payload.js | New shared builder for consistent ingest payloads and face-enrichment carry-through |
| connectors/photo-exif/face-worker.js | Refactor to delegate payload construction to shared builder |
| connectors/photo-exif/caption-worker.js | Now reads face state and posts a full union payload via the shared builder |
| connectors/photo-exif/test.mjs | Adds regression tests covering preservation + round-trip convergence |
| connectors/photo-exif/README.md | Updates “wave order” guidance to reflect order-independence |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const clusters = clustersPath && existsSync(clustersPath) | ||
| ? parseClustersFile(readFileSync(clustersPath, 'utf8')).clusters | ||
| : []; | ||
| const clustersById = new Map(clusters.map((c) => [c.id, c])); |
| ### Wave order — either order is safe (#276) | ||
|
|
||
| Run **scan → caption → face** for each photo. The ingest contract requires `text_repr` on every upsert and replaces it (and `extra`) wholesale — there is no deep-merge (doc 04 §3). The face worker therefore reconstructs `text_repr` as *base + caption + "Pictured: …"* by reading the caption cache, so it preserves a caption rather than dropping it. Two ordering caveats follow from the wholesale replace: | ||
| Run `scan.js` first (it creates the artifacts). After that, **the caption and face passes may run in either order, or be interleaved**, without losing each other's work. |
Copilot review on #277: the clusters readFileSync was unguarded while the face-state read beside it was wrapped, so a mid-write/permission error would crash the caption run — it now degrades to no face data (omitting both fields rather than asserting an empty pictured list). Also corrects the README, which overclaimed concurrent runs as safe when each worker snapshots the other's state once at startup.
|
Resolving Copilot's review (2 comments). Both accepted — fixed in 3263004. 1.
|
Closes #276
Summary
The two photo-enrichment passes overwrote each other's work, and only one of them knew it.
face-worker.jsalready preserved captions (it reads the caption cache and rebuilds base + caption + "Pictured: …"), butcaption-worker.jsposted a bareextra: { captioned: true }— and since ingest replacestext_reprandextrawholesale (doc 04 §3, no deep-merge), that destroyed anyfaces_detected/picturedthe face pass had written.Both workers now build their payload through one shared
lib/photo-payload.js, so either pass may run first, or they may interleave, without losing the other's output.This matters right now because #217 (face pass) runs first and #275 (descriptions) runs second — so this data loss was on the critical path. #217 carried a final "re-run the face pass to restore what #275 overwrote" step purely because of this asymmetry; that step can now be dropped.
Changes
lib/photo-payload.js(new) —buildPhotoPayload()(the single payload shape),picturedNames()(moved verbatim fromface-worker.js), andreadFaceEnrichment()(face state + clusters →relPath -> {faces, pictured}lookup).face-worker.js—buildPayloadnow delegates to the shared builder; localpicturedNamesdeleted. Behavior byte-identical (existing tests are the guard).caption-worker.js— reads the face state once at startup and carries face data through its upsert.README.md— "Wave order matters" rewritten: either order is now safe, with the two remaining properties (absent≠zero, keep state co-located) documented.test.mjs— 3 new tests.Design decisions
lib/describe.jsconvention ("used by every script so they can never drift").faces_detectedis omitted, never sent as0—0would assert "detection ran and found nothing," which is false and, given the wholesale replace, permanent. Explicitly asserted in tests.entity_hints. Redundant server-side (hints are idempotent), but it makes both workers converge on a byte-identical payload. Without it the face worker's signature check would see a difference after the caption pass and re-post the entire library — a whole extra ingest wave.Verification
npm test— 31/32. The 1 failure (face-worker: scan clusters…) is the pre-existing Windows/Node-v26 libuv crash seen in feat(photo-exif): swap face stack from tfjs-node to onnxruntime-node #273/feat(photo-exif): suggest cluster labels from single-name Google Photos sidecars #274; it reproduces identically on unmodifiedmainand is green on CI.0; and a face → caption → face round trip asserting both workers converge byte-for-byte (this is the regression guard against future drift).npm run check:boundary— clean (29 connector files, nosrc/import).Test plan
npm testpasses (aside from the pre-existing flake noted above)npm run check:boundarypasses