forked from ChelseaKR/sprout
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgenerator.ts
More file actions
140 lines (131 loc) · 4.34 KB
/
Copy pathgenerator.ts
File metadata and controls
140 lines (131 loc) · 4.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
/**
* Selects query-relevant sentences verbatim from retrieved chunks — a mirror of
* `providers/deterministic.py`'s `ExtractiveGenerator`.
*
* Selection is facet-coverage aware (EXP-01): the query is split into clauses
* (`extractFacets`) and, after ranking every candidate sentence by query overlap,
* sentences are picked greedily to maximise *marginal* facet coverage first and raw
* score second. Single-clause queries reduce to plain top-score selection.
*/
import type { RetrievedChunk } from "./models.js";
import { extractFacets, splitSentences, tokenSet } from "./text.js";
interface Candidate {
score: number;
sentence: string;
chunkId: string;
covers: Set<number>;
}
export class ExtractiveGenerator {
private readonly floor: number;
constructor(relevanceFloor = 0.34) {
this.floor = relevanceFloor;
}
generate(
query: string,
context: readonly RetrievedChunk[],
maxSentences: number,
): [string, string][] {
const qTokens = tokenSet(query);
if (qTokens.size === 0) {
return [];
}
const facets = extractFacets(query);
const candidates = this.scoreCandidates(qTokens, facets, context);
return ExtractiveGenerator.selectDiverse(candidates, maxSentences);
}
/**
* Rank every sentence by query overlap and tag which facets it covers — mirrors
* `_score_candidates` (sorted by score desc / retrieval order, deduplicated on exact
* sentence text, highest score wins).
*/
private scoreCandidates(
qTokens: ReadonlySet<string>,
facets: readonly Set<string>[],
context: readonly RetrievedChunk[],
): Candidate[] {
const scored: [number, number, string, string, Set<number>][] = [];
context.forEach((rc, rank) => {
for (const sentence of splitSentences(rc.chunk.text)) {
const sTokens = tokenSet(sentence);
if (sTokens.size === 0) {
continue;
}
let overlapCount = 0;
for (const t of qTokens) {
if (sTokens.has(t)) {
overlapCount += 1;
}
}
const overlap = overlapCount / qTokens.size;
if (overlap < this.floor) {
continue;
}
// Prefer query overlap; nudge by retrieval score; break ties by order.
const score = overlap + rc.score * 0.25 - rank * 1e-3;
const covers = new Set<number>();
facets.forEach((facet, i) => {
let hit = 0;
for (const t of facet) {
if (sTokens.has(t)) {
hit += 1;
}
}
if (hit / facet.size >= this.floor) {
covers.add(i);
}
});
scored.push([score, rank, sentence.trim(), rc.chunk.chunk_id, covers]);
}
});
scored.sort((a, b) => (b[0] - a[0] !== 0 ? b[0] - a[0] : a[1] - b[1]));
const deduped: Candidate[] = [];
const seen = new Set<string>();
for (const [score, , sentence, chunkId, covers] of scored) {
const key = sentence.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push({ score, sentence, chunkId, covers });
}
return deduped;
}
/**
* Greedily pick sentences maximising marginal facet coverage, then score — mirrors
* `_select_diverse` (Python `max` keeps the *first* maximal candidate on ties).
*/
private static selectDiverse(candidates: Candidate[], maxSentences: number): [string, string][] {
const out: [string, string][] = [];
const coveredFacets = new Set<number>();
const remaining = [...candidates];
while (remaining.length > 0 && out.length < maxSentences) {
let bestIdx = 0;
let bestMarginal = -1;
let bestScore = -Infinity;
remaining.forEach((cand, i) => {
let marginal = 0;
for (const f of cand.covers) {
if (!coveredFacets.has(f)) {
marginal += 1;
}
}
if (marginal > bestMarginal || (marginal === bestMarginal && cand.score > bestScore)) {
bestIdx = i;
bestMarginal = marginal;
bestScore = cand.score;
}
});
const [picked] = remaining.splice(bestIdx, 1);
const cand = picked as Candidate;
out.push([cand.sentence, cand.chunkId]);
for (const f of cand.covers) {
coveredFacets.add(f);
}
}
return out;
}
/** Offline generation is free. */
estimatedCostUsd(): number {
return 0.0;
}
}