Semantic dedup Pass 2 is dead code and imports never embed - implement or correct the docs #99
Labels
No labels
bug
duplicate
enhancement
future
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/TeaLeaves#99
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: Medium · Confidence: High · Effort: M · Category: code (+ docs drift)
Evidence
api/src/services/dedup.ts:17-33- only exactsource_idmatch; theflagaction is declared inDedupResult(:15) but never returned; comment at :19 says "Pass 2 . deferred to Phase 4".api/src/services/importWorker.ts:262-272- theflag→duplicate_queuebranch (dead);:278-INSERT INTO entrieswith noembedEntrycall.CLAUDE.mdclaims "Pass 2 (active): cosine similarity ontext_embedding. → flag toduplicate_queue"; specdocs/Tea Leaves - spec.md:237-242.Problem
Two documented behaviors don't exist. (1) Semantic dedup Pass 2 is not implemented -
checkDedupnever returnsflag, soduplicate_queueis never populated by similarity; the import worker's flag branch is unreachable. (2) Imported entries are inserted without embeddings, so even a future Pass 2 would see NULL vectors until a manual reanalyze.Impact
A documented core feature silently no-ops (the duplicate review queue never fills from similarity), and semantic search / "more like this" over imported content is empty until the user runs a bulk reanalyze. The bigger cost is trust: the docs assert behavior the code lacks.
Fix (two parts)
text_embeddingabovesearch.similarityThreshold, populateduplicate_queuefor review.Acceptance criteria
Filed from the 2026-07-15 codebase audit. Full report:
docs/.internal/report-2026-07-15.md(gitignored).media.phashis a SHA-256 of the bytes, not a perceptual hash #136Implemented in
26ba1f0(v7.2.0). Went with implement, not defer — theduplicate_queuereview UI has existed since Phase 3 and could never fill, and the schema already hadsimilarity FLOATanddedup_type CHECK IN ('source_id', 'semantic')waiting for it.Pass 2 is real.
checkDedupruns cosine similarity overtext_embeddingand returns{ action: 'flag', reason: 'semantic_similarity', existingId, similarity }; the import worker's previously-unreachable branch now writesduplicate_queuewithdedup_type='semantic'and the score. Existing rows with no vector, and soft-deleted ones, are excluded from the comparison.Imports embed inline. The candidate's vector is computed once in
checkDedup(Pass 2 needs it) and returned on the import path, so the worker stores it on theINSERT— one AI call per post serving both the check and the stored embedding. Semantic search / "more like this" now works over imported archives immediately, no bulk reanalyze needed.AI stays optional: no embedding provider →
generateTextEmbeddingreturns null → Pass 2 is skipped silently and the import behaves exactly as before.One deliberate deviation from the issue
The issue says compare "above
search.similarityThreshold". I didn't — that default is 0.65, which answers "what is related?". Dedup needs "is this the same?". At 0.65, importing a themed archive would flag a large share of its own posts as duplicates and drown the review queue, which is worse than having no Pass 2. Added a dedicatedimport.dedupThreshold(default 0.95), live-editable and validated like the other settings.A test pins the distinction: an entry at 0.707 similarity must not flag — and 0.707 is above 0.65, so it is precisely the false positive the search threshold would have produced. If you'd rather have the looser behaviour it's now one setting change, not a code change.
Also removed
backfillEmbeddings()— zero callers, despite its own doc comment claiming it was "Called at import completion". It was evidently the intended embed-on-import mechanism and was never wired up. It's redundant withrunEmbeddingsJob(POST /api/ai/reanalyze { type: 'embeddings' }), which does the same work with progress tracking and suggestion enqueueing.Acceptance criteria:
CLAUDE.mdnow describes the real behaviour, thresholds and the AI-optional path. The spec (Tea Leaves - spec.md:238-239) needed no change: it described Passes 1–2 accurately and is now true rather than aspirational.Tests —
api/src/test/integration/dedupPass2.test.ts, 7 cases withgenerateTextEmbeddingmocked so Pass 2 is deterministic without an embedding server: Pass 1 short-circuits (and never calls the embedder), identical → flag with similarity ≈1, orthogonal → import returning the vector, no-vector/soft-deleted rows ignored, 0.707 → no flag at 0.95, 0.707 → flag at 0.5 (threshold honoured), AI unavailable → import.Uses one-hot unit vectors so similarities are exact (1.0 / 0.0 / 0.7071) rather than approximate.
CI run 194 green — 249/249 tests. Deployed to dev.
Follow-up filed: #136. With Pass 2 real, the spec's Pass 3 (image dedup) is now the last dedup claim without code —
media.phashis written and never read, and it's a SHA-256 of the file bytes, not a perceptual hash. Left unmilestoned pending your decision (implement vs. drop + rename the column).