@vllm-sr/dsh 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -15
- package/package.json +2 -2
- package/src/client.js +13 -5
- package/src/index.js +232 -76
- package/src/keys.js +40 -3
- package/src/questions.js +31 -0
- package/src/shield.js +116 -40
- package/src/spans.js +216 -0
- package/web/client.js +150 -15
package/src/shield.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The injection shield: screen tool output passage by passage, then zoom into
|
|
3
|
-
* flagged passages to find
|
|
4
|
-
*
|
|
5
|
-
* removed.
|
|
3
|
+
* flagged passages to find what carries the injection, and remove only that:
|
|
4
|
+
* the exact spans when a span model answers, else the lines. Lines the harness
|
|
5
|
+
* itself writes are masked while scoring and never removed.
|
|
6
6
|
*/
|
|
7
7
|
import { ITEMS_PER_REQUEST, yes } from './client.js'
|
|
8
|
-
import { SHIELD, SHIELD_FOCUS } from './questions.js'
|
|
8
|
+
import { INJECTION_SPANS, SHIELD, SHIELD_FOCUS } from './questions.js'
|
|
9
|
+
import { excerpts, placeSpans, replaceSpans, toSentences, unnumber } from './spans.js'
|
|
9
10
|
|
|
10
11
|
/** Exact templates of the annotations DeepSeek Harness adds to shell output. */
|
|
11
12
|
const HARNESS_LINES = [
|
|
@@ -50,6 +51,23 @@ async function scoreAll(client, model, questions, states, signal, limits = {}) {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
export const SPAN_MARK = '[System One removed injected text]'
|
|
55
|
+
|
|
56
|
+
// The exact spans that give orders, in each flagged passage, from a span model; a passage it fails on gets none.
|
|
57
|
+
async function injectedSpans({ client, model, about, passages, signal, timeoutMs, retries }) {
|
|
58
|
+
const found = passages.map(() => [])
|
|
59
|
+
let answeredBy
|
|
60
|
+
for (let start = 0; start < passages.length; start += ITEMS_PER_REQUEST) {
|
|
61
|
+
const batch = passages.slice(start, start + ITEMS_PER_REQUEST)
|
|
62
|
+
const response = await client.ask({ model, questions: INJECTION_SPANS, inputs: batch.map(passage => ({ state: { request: about, passage } })), signal, timeoutMs, retries })
|
|
63
|
+
answeredBy = response.model
|
|
64
|
+
batch.forEach((passage, offset) => {
|
|
65
|
+
found[start + offset] = placeSpans(response.spans[offset]?.injected, passage)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
return { model: answeredBy ?? model, found }
|
|
69
|
+
}
|
|
70
|
+
|
|
53
71
|
/**
|
|
54
72
|
* @param {object} options
|
|
55
73
|
* @param {{ask: Function}} options.client
|
|
@@ -59,11 +77,18 @@ async function scoreAll(client, model, questions, states, signal, limits = {}) {
|
|
|
59
77
|
* @param {number} options.threshold
|
|
60
78
|
* @param {number} options.chunkChars
|
|
61
79
|
* @param {AbortSignal} [options.signal]
|
|
62
|
-
* @
|
|
80
|
+
* @param {{model: string, about: string}} [options.spans] - a span model that finds the injected text itself
|
|
81
|
+
* @returns {Promise<{model: string, passages: number, maxP: number, flagged: object[], removed: object[], removedLines: number,
|
|
82
|
+
* removedSpans: number, removedChars: number, spanModel?: string, spanMs?: number, text: string}>}
|
|
63
83
|
* `maxP` is the highest injection probability of any passage
|
|
64
84
|
*/
|
|
65
|
-
export async function screen({ client, model, prefix, text, threshold, chunkChars, signal, timeoutMs, retries }) {
|
|
85
|
+
export async function screen({ client, model, prefix, text, threshold, chunkChars, signal, timeoutMs, retries, spans }) {
|
|
66
86
|
const lines = text.split('\n')
|
|
87
|
+
const lineStart = []
|
|
88
|
+
for (let index = 0, at = 0; index < lines.length; index++) {
|
|
89
|
+
lineStart.push(at)
|
|
90
|
+
at += lines[index].length + 1
|
|
91
|
+
}
|
|
67
92
|
const masked = lines.map(line => (isHarnessLine(line) ? '[harness note]' : line))
|
|
68
93
|
const ranges = passageRanges(masked, chunkChars)
|
|
69
94
|
const quote = (from, to, marks) => {
|
|
@@ -73,12 +98,48 @@ export async function screen({ client, model, prefix, text, threshold, chunkChar
|
|
|
73
98
|
return `${prefix}\n"""\n${body.join('\n')}\n"""`
|
|
74
99
|
}
|
|
75
100
|
const first = await scoreAll(client, model, SHIELD, ranges.map(([from, to]) => quote(from, to)), signal, { timeoutMs, retries })
|
|
76
|
-
const flagged = ranges.map(([from, to], index) => ({ from, to, p: first.scores[index] })).filter(entry => entry.p >= threshold)
|
|
101
|
+
const flagged = ranges.map(([from, to], index) => ({ from, to, p: first.scores[index], text: lines.slice(from, to).join('\n') })).filter(entry => entry.p >= threshold)
|
|
102
|
+
|
|
103
|
+
// With a span model: the text that gives orders, in each flagged passage, grown to whole sentences. The passage is
|
|
104
|
+
// scored again without them, and a cut that leaves an injection behind, or a failure, leaves the lines below.
|
|
105
|
+
let spanModel
|
|
106
|
+
let spanMs
|
|
107
|
+
if (spans && flagged.length) {
|
|
108
|
+
const started = performance.now()
|
|
109
|
+
try {
|
|
110
|
+
// the span model reads a file's text without the read tool's line numbers; each cut is mapped back onto them
|
|
111
|
+
const plain = flagged.map(entry => unnumber(entry.text))
|
|
112
|
+
const answer = await injectedSpans({ client, model: spans.model, about: spans.about, passages: plain.map(view => view.text), signal, timeoutMs, retries })
|
|
113
|
+
spanModel = answer.model
|
|
114
|
+
const cuts = flagged.map((entry, index) => {
|
|
115
|
+
const grown = toSentences(plain[index].text, answer.found[index])
|
|
116
|
+
// the harness's own lines are never removed
|
|
117
|
+
if (!grown.length || grown.some(span => span.text.split('\n').some(isHarnessLine))) return undefined
|
|
118
|
+
entry.plain = { text: plain[index].text, spans: grown }
|
|
119
|
+
return grown.map(span => {
|
|
120
|
+
const start = plain[index].at(span.start)
|
|
121
|
+
const end = plain[index].at(span.end)
|
|
122
|
+
return { ...span, start, end, text: entry.text.slice(start, end) }
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
const tried = flagged.map((entry, index) => ({ entry, cut: cuts[index] })).filter(item => item.cut)
|
|
126
|
+
if (tried.length) {
|
|
127
|
+
const unmask = value => value.split('\n').map(line => (isHarnessLine(line) ? '[harness note]' : line)).join('\n')
|
|
128
|
+
const again = await scoreAll(client, model, SHIELD, tried.map(({ entry, cut }) => `${prefix}\n"""\n${unmask(replaceSpans(entry.text, cut, () => SPAN_MARK))}\n"""`), signal, { timeoutMs, retries })
|
|
129
|
+
tried.forEach(({ entry, cut }, index) => {
|
|
130
|
+
if (again.scores[index] < threshold) entry.spans = cut
|
|
131
|
+
else entry.spansLeft = again.scores[index]
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (signal?.aborted) throw error
|
|
136
|
+
}
|
|
137
|
+
spanMs = performance.now() - started
|
|
138
|
+
}
|
|
77
139
|
|
|
78
|
-
//
|
|
140
|
+
// Otherwise a second look inside each flagged passage: which groups of lines carry it?
|
|
79
141
|
const GROUP = 3
|
|
80
|
-
|
|
81
|
-
await Promise.all(flagged.map(async entry => {
|
|
142
|
+
await Promise.all(flagged.filter(entry => !entry.spans).map(async entry => {
|
|
82
143
|
const groups = []
|
|
83
144
|
for (let start = entry.from; start < entry.to; start += GROUP) groups.push([start, Math.min(entry.to, start + GROUP)])
|
|
84
145
|
let hits = []
|
|
@@ -87,38 +148,47 @@ export async function screen({ client, model, prefix, text, threshold, chunkChar
|
|
|
87
148
|
hits = zoom ? groups.map(([start, end], index) => ({ start, end, p: zoom.scores[index] })).filter(group => group.p >= threshold) : []
|
|
88
149
|
}
|
|
89
150
|
entry.lines = hits.length ? hits : [{ start: entry.from, end: entry.to, p: entry.p }]
|
|
90
|
-
removed.push(...entry.lines)
|
|
91
151
|
}))
|
|
92
152
|
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
153
|
+
// Every removal as a range of the original text and what replaces it.
|
|
154
|
+
const lineEnd = index => (index < lines.length ? lineStart[index] : text.length)
|
|
155
|
+
const cuts = []
|
|
156
|
+
for (const entry of flagged) {
|
|
157
|
+
if (entry.spans) {
|
|
158
|
+
for (const span of entry.spans) {
|
|
159
|
+
const at = lineStart[entry.from]
|
|
160
|
+
cuts.push({ start: at + span.start, end: at + span.end, p: span.probability, replacement: SPAN_MARK, spans: 1, chars: span.end - span.start })
|
|
161
|
+
}
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
const merged = []
|
|
165
|
+
for (const range of [...entry.lines].sort((a, b) => a.start - b.start)) {
|
|
166
|
+
const last = merged.at(-1)
|
|
167
|
+
if (last && last.end >= range.start) {
|
|
168
|
+
last.end = Math.max(last.end, range.end)
|
|
169
|
+
last.p = Math.max(last.p, range.p)
|
|
170
|
+
} else {
|
|
171
|
+
merged.push({ ...range })
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const range of merged) {
|
|
175
|
+
const kept = lines.slice(range.start, range.end).filter(isHarnessLine)
|
|
176
|
+
const dropped = range.end - range.start - kept.length
|
|
177
|
+
if (dropped === 0) continue
|
|
178
|
+
const body = [`[System One removed ${dropped} line(s) here: they give orders to AI agents (p ${range.p.toFixed(2)})]`, ...kept].join('\n')
|
|
179
|
+
cuts.push({ start: lineStart[range.start], end: lineEnd(range.end), p: range.p, replacement: range.end < lines.length ? `${body}\n` : body, lines: dropped, startLine: range.start, endLine: range.end })
|
|
102
180
|
}
|
|
103
181
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
let removedLines = 0
|
|
182
|
+
cuts.sort((a, b) => a.start - b.start)
|
|
183
|
+
let out = ''
|
|
107
184
|
let cursor = 0
|
|
108
|
-
for (const
|
|
109
|
-
out.
|
|
110
|
-
|
|
111
|
-
let dropped = 0
|
|
112
|
-
for (let index = range.start; index < range.end; index++) {
|
|
113
|
-
if (isHarnessLine(lines[index])) kept.push(lines[index])
|
|
114
|
-
else dropped++
|
|
115
|
-
}
|
|
116
|
-
if (dropped > 0) out.push(`[System One removed ${dropped} line(s) here: they give orders to AI agents (p ${range.p.toFixed(2)})]`)
|
|
117
|
-
out.push(...kept)
|
|
118
|
-
removedLines += dropped
|
|
119
|
-
cursor = range.end
|
|
185
|
+
for (const cut of cuts) {
|
|
186
|
+
out += text.slice(cursor, cut.start) + cut.replacement
|
|
187
|
+
cursor = cut.end
|
|
120
188
|
}
|
|
121
|
-
out.
|
|
189
|
+
out += text.slice(cursor)
|
|
190
|
+
const removedLines = cuts.reduce((sum, cut) => sum + (cut.lines ?? 0), 0)
|
|
191
|
+
const removedSpans = cuts.reduce((sum, cut) => sum + (cut.spans ?? 0), 0)
|
|
122
192
|
|
|
123
193
|
return {
|
|
124
194
|
model: first.model,
|
|
@@ -126,11 +196,17 @@ export async function screen({ client, model, prefix, text, threshold, chunkChar
|
|
|
126
196
|
maxP: first.scores.reduce((max, p) => Math.max(max, p), 0),
|
|
127
197
|
flagged: flagged.map(entry => ({
|
|
128
198
|
p: entry.p,
|
|
129
|
-
text:
|
|
130
|
-
lines: entry.lines.map(range => ({ p: range.p, text: lines.slice(range.start, range.end).join('\n') })),
|
|
199
|
+
text: entry.text,
|
|
200
|
+
lines: (entry.lines ?? []).map(range => ({ p: range.p, text: lines.slice(range.start, range.end).join('\n') })),
|
|
201
|
+
spans: (entry.spans ? entry.plain.spans : []).map(span => ({ label: span.label, p: span.probability, text: span.text })),
|
|
202
|
+
excerpts: entry.spans ? excerpts(entry.plain.text, entry.plain.spans, { around: 60, max: 2 }) : [],
|
|
131
203
|
})),
|
|
132
|
-
removed:
|
|
204
|
+
removed: cuts.map(cut => (cut.lines ? { start: cut.startLine, end: cut.endLine, p: cut.p } : { span: true, start: cut.start, end: cut.end, p: cut.p })),
|
|
133
205
|
removedLines,
|
|
134
|
-
|
|
206
|
+
removedSpans,
|
|
207
|
+
removedChars: cuts.reduce((sum, cut) => sum + (cut.chars ?? 0), 0),
|
|
208
|
+
spanModel,
|
|
209
|
+
spanMs,
|
|
210
|
+
text: cuts.length > 0 ? out : text,
|
|
135
211
|
}
|
|
136
212
|
}
|
package/src/spans.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Span answers (Vela 3.0 models, contract 3.2): `spans.<id> = [{label, start, end, text, probability}]`, with offsets in
|
|
3
|
+
* Unicode code points into the text the question read, `end` exclusive. JavaScript strings index UTF-16 units, so every
|
|
4
|
+
* span is moved onto them and checked against its own text before it is used.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** UTF-16 index of each code point boundary of `text` (one more entry than code points). */
|
|
8
|
+
function boundaries(text) {
|
|
9
|
+
const at = [0]
|
|
10
|
+
let index = 0
|
|
11
|
+
for (const char of text) {
|
|
12
|
+
index += char.length
|
|
13
|
+
at.push(index)
|
|
14
|
+
}
|
|
15
|
+
return at
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The spans of one answer that really cut `text` where they say, as UTF-16 ranges, sorted and without overlaps (the
|
|
20
|
+
* more probable of two overlapping spans wins). A span whose offsets miss is found again by its text, nearest first.
|
|
21
|
+
* @param {Array<{label: string, start: number, end: number, text: string, probability: number}>} spans
|
|
22
|
+
* @param {string} text - what the question read
|
|
23
|
+
* @param {object} [options]
|
|
24
|
+
* @param {number} [options.min] - the lowest probability kept
|
|
25
|
+
* @returns {Array<{label: string, start: number, end: number, text: string, probability: number}>}
|
|
26
|
+
*/
|
|
27
|
+
export function placeSpans(spans, text, { min = 0 } = {}) {
|
|
28
|
+
if (!Array.isArray(spans) || typeof text !== 'string' || text.length === 0) return []
|
|
29
|
+
const at = boundaries(text)
|
|
30
|
+
const placed = []
|
|
31
|
+
for (const span of spans) {
|
|
32
|
+
if (!span || typeof span.text !== 'string' || span.text.length === 0) continue
|
|
33
|
+
const probability = typeof span.probability === 'number' && Number.isFinite(span.probability) ? span.probability : 0
|
|
34
|
+
if (probability < min) continue
|
|
35
|
+
let start = Number.isSafeInteger(span.start) && span.start >= 0 && span.start < at.length ? at[span.start] : -1
|
|
36
|
+
let end = Number.isSafeInteger(span.end) && span.end > 0 && span.end < at.length ? at[span.end] : -1
|
|
37
|
+
if (start < 0 || end <= start || text.slice(start, end) !== span.text) {
|
|
38
|
+
const near = start >= 0 ? start : 0
|
|
39
|
+
const after = text.indexOf(span.text, near)
|
|
40
|
+
const before = text.lastIndexOf(span.text, near)
|
|
41
|
+
const found = after < 0 ? before : before < 0 ? after : near - before <= after - near ? before : after
|
|
42
|
+
if (found < 0) continue
|
|
43
|
+
start = found
|
|
44
|
+
end = found + span.text.length
|
|
45
|
+
}
|
|
46
|
+
placed.push({ label: String(span.label ?? ''), start, end, text: span.text, probability })
|
|
47
|
+
}
|
|
48
|
+
placed.sort((a, b) => a.start - b.start || b.probability - a.probability)
|
|
49
|
+
const kept = []
|
|
50
|
+
for (const span of placed) {
|
|
51
|
+
const last = kept.at(-1)
|
|
52
|
+
if (last && span.start < last.end) {
|
|
53
|
+
if (span.probability > last.probability) kept[kept.length - 1] = span
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
kept.push(span)
|
|
57
|
+
}
|
|
58
|
+
return kept
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `text` with each span replaced by what `mask(span)` returns. */
|
|
62
|
+
export function replaceSpans(text, spans, mask) {
|
|
63
|
+
let out = ''
|
|
64
|
+
let cursor = 0
|
|
65
|
+
for (const span of spans) {
|
|
66
|
+
out += text.slice(cursor, span.start) + mask(span)
|
|
67
|
+
cursor = span.end
|
|
68
|
+
}
|
|
69
|
+
return out + text.slice(cursor)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Personal data masked by its label, as `[EMAIL_ADDRESS]`. */
|
|
73
|
+
export const maskLabel = span => `[${span.label || 'PII'}]`
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Up to `max` short excerpts of `text` around the spans, for Chat: plain parts and marked parts. With `hide`, a marked
|
|
77
|
+
* part carries only its label, so personal data never leaves the text it was found in.
|
|
78
|
+
* @returns {Array<Array<{text?: string, mark?: {label: string, probability: number, text?: string}}>>}
|
|
79
|
+
*/
|
|
80
|
+
export function excerpts(text, spans, { around = 48, max = 3, hide = false, clip = 160 } = {}) {
|
|
81
|
+
const groups = []
|
|
82
|
+
for (const span of spans) {
|
|
83
|
+
const from = Math.max(0, span.start - around)
|
|
84
|
+
const to = Math.min(text.length, span.end + around)
|
|
85
|
+
const last = groups.at(-1)
|
|
86
|
+
if (last && from <= last.to) {
|
|
87
|
+
last.to = Math.max(last.to, to)
|
|
88
|
+
last.spans.push(span)
|
|
89
|
+
} else {
|
|
90
|
+
groups.push({ from, to, spans: [span] })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const flat = value => value.replace(/\s+/g, ' ')
|
|
94
|
+
return groups.slice(0, max).map(group => {
|
|
95
|
+
const parts = []
|
|
96
|
+
let cursor = group.from
|
|
97
|
+
if (group.from > 0) parts.push({ text: '…' })
|
|
98
|
+
for (const span of group.spans) {
|
|
99
|
+
if (span.start > cursor) parts.push({ text: flat(text.slice(cursor, span.start)) })
|
|
100
|
+
const shown = flat(span.text)
|
|
101
|
+
parts.push({ mark: { label: span.label, probability: Math.round(span.probability * 1000) / 1000, ...(hide ? {} : { text: shown.length > clip ? `${shown.slice(0, clip - 1)}…` : shown }) } })
|
|
102
|
+
cursor = span.end
|
|
103
|
+
}
|
|
104
|
+
if (group.to > cursor) parts.push({ text: flat(text.slice(cursor, group.to)) })
|
|
105
|
+
if (group.to < text.length) parts.push({ text: '…' })
|
|
106
|
+
return parts
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// merge ranges that touch or overlap, keeping the higher probability
|
|
111
|
+
function merged(spans, text) {
|
|
112
|
+
const out = []
|
|
113
|
+
for (const span of [...spans].sort((a, b) => a.start - b.start)) {
|
|
114
|
+
const last = out.at(-1)
|
|
115
|
+
if (last && span.start <= last.end) {
|
|
116
|
+
last.end = Math.max(last.end, span.end)
|
|
117
|
+
last.probability = Math.max(last.probability, span.probability)
|
|
118
|
+
last.text = text.slice(last.start, last.end)
|
|
119
|
+
} else {
|
|
120
|
+
out.push({ ...span, text: text.slice(span.start, span.end) })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Each span grown to the whole sentence it sits in: an injection is a sentence of orders, and a span model may mark
|
|
128
|
+
* only its loudest words. A sentence ends after `.`, `!` or `?`, at a blank line, and at a line that holds only a
|
|
129
|
+
* delimiter (`<!--`, `-->`, a code fence).
|
|
130
|
+
*/
|
|
131
|
+
export function toSentences(text, spans) {
|
|
132
|
+
const stops = [0, text.length]
|
|
133
|
+
for (const match of text.matchAll(/[.!?](?=\s)/g)) stops.push(match.index + 1)
|
|
134
|
+
for (const pattern of [/\n[ \t]*\n/g, /^[ \t]*(?:<!--|-->|```|~~~)[^\n]*$/gm]) {
|
|
135
|
+
for (const match of text.matchAll(pattern)) stops.push(match.index, match.index + match[0].length)
|
|
136
|
+
}
|
|
137
|
+
stops.sort((a, b) => a - b)
|
|
138
|
+
const grown = spans.map(span => {
|
|
139
|
+
let start = stops.filter(stop => stop <= span.start).pop()
|
|
140
|
+
const end = stops.find(stop => stop >= span.end)
|
|
141
|
+
while (start < span.start && /\s/.test(text[start])) start++
|
|
142
|
+
return { ...span, start, end }
|
|
143
|
+
})
|
|
144
|
+
return merged(grown, text)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Each span grown to the shell command it sits in (between `&&`, `||`, `;`, `|` and line breaks). */
|
|
148
|
+
export function toCommands(command, spans) {
|
|
149
|
+
const stops = [0, command.length]
|
|
150
|
+
for (const match of command.matchAll(/&&|\|\||[;|\n]/g)) stops.push(match.index, match.index + match[0].length)
|
|
151
|
+
stops.sort((a, b) => a - b)
|
|
152
|
+
const grown = spans.map(span => {
|
|
153
|
+
let start = stops.filter(stop => stop <= span.start).pop()
|
|
154
|
+
let end = stops.find(stop => stop >= span.end)
|
|
155
|
+
while (start < end && /\s/.test(command[start])) start++
|
|
156
|
+
while (end > start && /\s/.test(command[end - 1])) end--
|
|
157
|
+
return { ...span, start, end }
|
|
158
|
+
})
|
|
159
|
+
return merged(grown, command)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A text without the line numbers some tools print before each line (`12: `), and the way back: `at(index)` is where a
|
|
164
|
+
* position of the plain text sits in the numbered one. A text whose lines are not numbered comes back as it is.
|
|
165
|
+
*/
|
|
166
|
+
export function unnumber(text) {
|
|
167
|
+
const lines = text.split('\n')
|
|
168
|
+
const filled = lines.filter(line => line.length > 0)
|
|
169
|
+
if (filled.length === 0 || filled.filter(line => /^\d+: ?/.test(line)).length < filled.length * 0.8) return { text, at: index => index }
|
|
170
|
+
const plainStarts = []
|
|
171
|
+
const numberedStarts = []
|
|
172
|
+
const cut = []
|
|
173
|
+
let plain = 0
|
|
174
|
+
let numbered = 0
|
|
175
|
+
const out = lines.map(line => {
|
|
176
|
+
const bare = line.replace(/^\d+: ?/, '')
|
|
177
|
+
plainStarts.push(plain)
|
|
178
|
+
numberedStarts.push(numbered)
|
|
179
|
+
cut.push(line.length - bare.length)
|
|
180
|
+
plain += bare.length + 1
|
|
181
|
+
numbered += line.length + 1
|
|
182
|
+
return bare
|
|
183
|
+
})
|
|
184
|
+
const at = index => {
|
|
185
|
+
let line = 0
|
|
186
|
+
while (line + 1 < plainStarts.length && plainStarts[line + 1] <= index) line++
|
|
187
|
+
return numberedStarts[line] + cut[line] + (index - plainStarts[line])
|
|
188
|
+
}
|
|
189
|
+
return { text: out.join('\n'), at }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** How many spans of each label, most first. */
|
|
193
|
+
export function labelCounts(spans) {
|
|
194
|
+
const counts = {}
|
|
195
|
+
for (const span of spans) counts[span.label || 'PII'] = (counts[span.label || 'PII'] ?? 0) + 1
|
|
196
|
+
return Object.fromEntries(Object.entries(counts).sort((a, b) => b[1] - a[1]))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Cut `text` into windows of at most `size` UTF-16 units that end at line breaks where they can, for questions whose
|
|
201
|
+
* target is limited. @returns {Array<{start: number, text: string}>}
|
|
202
|
+
*/
|
|
203
|
+
export function windows(text, size) {
|
|
204
|
+
const out = []
|
|
205
|
+
let start = 0
|
|
206
|
+
while (start < text.length) {
|
|
207
|
+
let end = Math.min(text.length, start + size)
|
|
208
|
+
if (end < text.length) {
|
|
209
|
+
const nl = text.lastIndexOf('\n', end)
|
|
210
|
+
if (nl > start + size / 2) end = nl + 1
|
|
211
|
+
}
|
|
212
|
+
out.push({ start, text: text.slice(start, end) })
|
|
213
|
+
start = end
|
|
214
|
+
}
|
|
215
|
+
return out
|
|
216
|
+
}
|