diff --git a/src/renderer/src/services/aymurai/queries.ts b/src/renderer/src/services/aymurai/queries.ts index 4a654be8..972966a9 100644 --- a/src/renderer/src/services/aymurai/queries.ts +++ b/src/renderer/src/services/aymurai/queries.ts @@ -167,15 +167,33 @@ export const disambiguate = (file: DocFile) => const parsed = disambiguateSchema.parse(response.data); - // Map response items back to paragraphs by **index** — the backend - // echoes the array in the same order we sent it. - return parsed.data.flatMap((item, i) => { - const paragraph = paragraphs[i]; + // Build a consumption queue per paragraph text so we can match response + // items by content instead of array index. This tolerates backend + // reordering and count drops. Duplicate paragraph texts are matched in + // the order they appear in the original paragraphs array. + const paragraphQueue = new Map(); + for (const p of paragraphs) { + const q = paragraphQueue.get(p.value) ?? []; + q.push(p); + paragraphQueue.set(p.value, q); + } + const consumed = new Map(); + const matchedParagraphIds = new Set(); + + const disambiguated = parsed.data.flatMap((item) => { + const queue = paragraphQueue.get(item.document) ?? []; + const nextIdx = consumed.get(item.document) ?? 0; + const paragraph = queue[nextIdx]; + consumed.set(item.document, nextIdx + 1); + + if (!paragraph) return []; + + matchedParagraphIds.add(paragraph.id); // Build a lookup from (start_char, end_char) → original mention so we // can reuse the stable mentionId. const origByPos = new Map(); - for (const orig of byParagraphId.get(paragraph?.id ?? "") ?? []) { + for (const orig of byParagraphId.get(paragraph.id) ?? []) { origByPos.set(`${orig.start_char}:${orig.end_char}`, orig); } @@ -207,10 +225,18 @@ export const disambiguate = (file: DocFile) => aymurai_label_instance: l.attrs.aymurai_label_instance ?? null, aymurai_disambiguation: l.attrs.aymurai_disambiguation ?? null, }, - paragraphId: paragraph?.id ?? item.document, + paragraphId: paragraph.id, } satisfies PredictLabel; }); }); + + // Preserve raw predictions for any paragraph the backend did not return, + // so a backend count drop never silently erases annotations. + const unmatched = predictions.filter( + (p) => !matchedParagraphIds.has(p.paragraphId), + ); + + return [...disambiguated, ...unmatched]; }, });