(text: string, opts: { query?: string; budget: number })
| 77 | * query, fall back to the head+tail window. PURE. |
| 78 | */ |
| 79 | export function selectRelevantPassages(text: string, opts: { query?: string; budget: number }): ExtractSelection { |
| 80 | const fullLength = text.length; |
| 81 | const budget = Math.max(200, opts.budget); |
| 82 | if (fullLength <= budget) return { content: text, mode: 'whole', fullLength, keptChars: fullLength, omittedChars: 0 }; |
| 83 | |
| 84 | const query = (opts.query ?? '').trim(); |
| 85 | const passages = splitPassages(text); |
| 86 | const qv = query ? termFreq(semanticTokens(query)) : null; |
| 87 | if (!qv || qv.size === 0 || passages.length <= 1) { |
| 88 | const ht = headTailWindow(text, budget); |
| 89 | return { content: ht.content, mode: 'head-tail', fullLength, keptChars: ht.keptChars, omittedChars: fullLength - ht.keptChars }; |
| 90 | } |
| 91 | |
| 92 | // Score every passage; always keep passage 0 (title/lede) as context anchor. |
| 93 | const scored = passages.map((p, i) => ({ i, p, score: cosineSim(qv, termFreq(semanticTokens(p.text))) })); |
| 94 | const chosen = new Set<number>([0]); |
| 95 | let used = passages[0]!.text.length; |
| 96 | for (const s of scored.filter(s => s.i !== 0 && s.score > 0).sort((a, b) => b.score - a.score)) { |
| 97 | if (used + s.p.text.length + 2 > budget) continue; // skip; a smaller relevant one may still fit |
| 98 | chosen.add(s.i); used += s.p.text.length + 2; |
| 99 | } |
| 100 | // If the query matched nothing beyond the anchor, positional is the safer bet. |
| 101 | if (chosen.size === 1 && passages.length > 1) { |
| 102 | const ht = headTailWindow(text, budget); |
| 103 | return { content: ht.content, mode: 'head-tail', fullLength, keptChars: ht.keptChars, omittedChars: fullLength - ht.keptChars }; |
| 104 | } |
| 105 | |
| 106 | // Emit chosen passages in document order, marking gaps between non-adjacent selections. |
| 107 | const order = [...chosen].sort((a, b) => a - b); |
| 108 | const chunks: string[] = []; |
| 109 | let kept = 0; let prev = -1; |
| 110 | for (const idx of order) { |
| 111 | if (prev >= 0 && idx > prev + 1) { |
| 112 | const gap = passages.slice(prev + 1, idx).reduce((a, p) => a + p.text.length, 0); |
| 113 | chunks.push(`[… ${gap} chars omitted (less relevant) …]`); |
| 114 | } |
| 115 | chunks.push(passages[idx]!.text); |
| 116 | kept += passages[idx]!.text.length; |
| 117 | prev = idx; |
| 118 | } |
| 119 | if (prev < passages.length - 1) { |
| 120 | const gap = passages.slice(prev + 1).reduce((a, p) => a + p.text.length, 0); |
| 121 | chunks.push(`[… ${gap} chars omitted (less relevant) …]`); |
| 122 | } |
| 123 | return { content: chunks.join('\n\n'), mode: 'semantic', fullLength, keptChars: kept, omittedChars: fullLength - kept }; |
| 124 | } |
no test coverage detected