Problema
detect_duplicates() filtra deleted_at/arquivado apenas no lado A do par. O lado B vem do KNN em vec_notes, que guarda o vetor de notas já apagadas — e não passa por filtro nenhum.
Resultado: uma nota removida do vault continua aparecendo como candidata a duplicata. Como titles também filtra deleted_at IS NULL, ela sai com b_title: None.
Onde
skills/obsidian-organizer/scripts/duplicates.py:
_active_note_ids() (linha ~54) — filtra deleted_at IS NULL AND status != 'arquivado', e alimenta só o loop externo (for note_id in active).
_knn_top() (linha ~72) — SELECT note_id, distance FROM vec_notes WHERE embedding MATCH ?, sem restrição.
detect_duplicates() (linha ~139) — consome o vizinho direto, sem checar se ainda está ativo.
Como reproduzir
pairs = detect_duplicates(conn, min_cos=0.7)
apagada = pairs[0]["note_b_id"]
conn.execute("UPDATE notes SET deleted_at = ? WHERE id = ?", (agora, apagada))
conn.commit()
# esperado: nenhum par contendo `apagada`
# obtido: o par continua, com b_title = None
detect_duplicates(conn, min_cos=0.7)
Em um vault real (121 notas, 3 apagadas com vetor), medido:
min_cos |
pares totais |
pares com nota apagada |
| 0.7 |
1 |
0 |
| 0.6 |
111 |
8 |
| 0.5 |
794 |
20 |
Exemplo da saída: 79 None × 82 'N8N' 0.6627.
No limiar padrão o efeito depende de quão parecidas as notas apagadas são — pode não aparecer, e voltar depois.
Correção sugerida
Filtrar o vizinho no próprio detect_duplicates(), reaproveitando o conjunto que já é calculado:
active = _active_note_ids(conn)
active_set = set(active)
...
for neigh_id, dist in _knn_top(conn, note_id, vec, scan_k):
if neigh_id not in active_set:
continue
...
Preferi isso a mexer no SQL de _knn_top() por dois motivos: cobre deleted_at e status = 'arquivado' de graça (mesma regra de _active_note_ids), e evita join com a virtual table vec0.
Verificado no vault acima: 111 → 103 pares em min_cos=0.6, sendo a diferença exatamente os 8 pares fantasma. Nenhum par legítimo removido.
Teste de regressão
Falha no código atual, passa com o patch:
def test_detect_duplicates_ignora_nota_apagada_no_lado_b(dup, vault_com_duplicatas):
_, conn = vault_com_duplicatas
antes = dup.detect_duplicates(conn, min_cos=0.7)
assert antes
apagada = antes[0]["note_b_id"]
conn.execute(
"UPDATE notes SET deleted_at = '2026-07-29T00:00:00+00:00' WHERE id = ?",
(apagada,),
)
conn.commit()
depois = dup.detect_duplicates(conn, min_cos=0.7)
assert all(apagada not in (p["note_a_id"], p["note_b_id"]) for p in depois)
Com a fixture vault_com_duplicatas já existente em tests/test_organizer_duplicates.py: 9 passed → 10 passed com o patch; o teste novo falha sem ele.
Contorno enquanto isso
rebuild-db --yes + scan limpa os vetores órfãos.
Abro PR com o patch e o teste, se for útil.
Ambiente: Windows 10, CPython 3.12.13, sqlite-vec 0.1.9, model2vec 0.8.2 (static-similarity-mrl-multilingual-v1, dim 256). Repo em e9b6392.
Problema
detect_duplicates()filtradeleted_at/arquivadoapenas no lado A do par. O lado B vem do KNN emvec_notes, que guarda o vetor de notas já apagadas — e não passa por filtro nenhum.Resultado: uma nota removida do vault continua aparecendo como candidata a duplicata. Como
titlestambém filtradeleted_at IS NULL, ela sai comb_title: None.Onde
skills/obsidian-organizer/scripts/duplicates.py:_active_note_ids()(linha ~54) — filtradeleted_at IS NULL AND status != 'arquivado', e alimenta só o loop externo (for note_id in active)._knn_top()(linha ~72) —SELECT note_id, distance FROM vec_notes WHERE embedding MATCH ?, sem restrição.detect_duplicates()(linha ~139) — consome o vizinho direto, sem checar se ainda está ativo.Como reproduzir
Em um vault real (121 notas, 3 apagadas com vetor), medido:
min_cosExemplo da saída:
79 None × 82 'N8N' 0.6627.No limiar padrão o efeito depende de quão parecidas as notas apagadas são — pode não aparecer, e voltar depois.
Correção sugerida
Filtrar o vizinho no próprio
detect_duplicates(), reaproveitando o conjunto que já é calculado:Preferi isso a mexer no SQL de
_knn_top()por dois motivos: cobredeleted_atestatus = 'arquivado'de graça (mesma regra de_active_note_ids), e evita join com a virtual tablevec0.Verificado no vault acima: 111 → 103 pares em
min_cos=0.6, sendo a diferença exatamente os 8 pares fantasma. Nenhum par legítimo removido.Teste de regressão
Falha no código atual, passa com o patch:
Com a fixture
vault_com_duplicatasjá existente emtests/test_organizer_duplicates.py: 9 passed → 10 passed com o patch; o teste novo falha sem ele.Contorno enquanto isso
rebuild-db --yes+scanlimpa os vetores órfãos.Abro PR com o patch e o teste, se for útil.
Ambiente: Windows 10, CPython 3.12.13,
sqlite-vec0.1.9,model2vec0.8.2 (static-similarity-mrl-multilingual-v1, dim 256). Repo eme9b6392.