tensorgrid-ui 1.6.1 → 1.10.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/lib/index.js CHANGED
@@ -236,10 +236,65 @@ export function apply(ctx) {
236
236
  })
237
237
  })
238
238
 
239
+ // ── ЗАСЛОН ДЛЯ НАШИХ МАРШРУТОВ ──────────────────────────────────────
240
+ //
241
+ // Берётся ШТАТНЫЙ, а не пишется свой. Это не перестраховка, а починка
242
+ // настоящей дыры, найденной обзором и подтверждённой на живом
243
+ // приложении:
244
+ //
245
+ // / 401 защищено
246
+ // /api/любой-другой 401 защищено
247
+ // /api/tensorgrid.auth 200 НАШ, без токена
248
+ //
249
+ // Приложение регистрирует на префикс `/api` обработчик с проверкой, а
250
+ // точное совпадение пути выигрывает у префикса. Наши точные маршруты не
251
+ // просто «не добавляли токен» — они ВЫРЕЗАЛИ из-под себя весь заслон.
252
+ //
253
+ // Штатная проверка делает три вещи, которых моя самодельная не делала:
254
+ // требует петлевой или явно доверенный Host (это и закрывает подмену
255
+ // имени), отклоняет запрос с `sec-fetch-site: cross-site` и лишь затем
256
+ // сверяет Origin. Плюс проверяет токен браузерной сессии.
257
+ const guard = { connection: null, generation: null }
258
+ web.inject(['connection'], (bound) => {
259
+ const generation = {}
260
+ guard.connection = bound.connection
261
+ guard.generation = generation
262
+ bound.effect(() => () => {
263
+ if (guard.generation !== generation) return
264
+ guard.connection = null
265
+ guard.generation = null
266
+ })
267
+ })
268
+
269
+ /**
270
+ * Отклонить запрос, если штатный заслон против.
271
+ *
272
+ * @returns true, если ответ уже отправлен и обработчику делать нечего.
273
+ */
274
+ function rejected(req, res) {
275
+ const connection = guard.connection
276
+ if (connection === null) {
277
+ // Заслона нет — значит браузерная часть не поднята. Открывать
278
+ // маршрут «пока суд да дело» нельзя: это ровно та дыра, что чиним.
279
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' })
280
+ res.end(JSON.stringify({ problem: 'проверка доступа недоступна' }))
281
+ return true
282
+ }
283
+ const rejection = connection.requestRejection(req)
284
+ if (rejection === undefined) return false
285
+ res.writeHead(rejection, { 'content-type': 'application/json; charset=utf-8' })
286
+ res.end(JSON.stringify({ problem: rejection === 401 ? 'требуется вход в приложение' : 'запрос отклонён' }))
287
+ return true
288
+ }
289
+
239
290
  // Манифест собирается в горячем модуле: он ЧИТАЕТ поставочный файл и
240
291
  // дополняет его нашими полями, а значит зависит от чужой формы, которая
241
292
  // может поменяться. Такую логику правильнее держать там, где её можно
242
293
  // поправить без перезапуска.
294
+ //
295
+ // Заслон сюда НЕ ставится намеренно: манифест и иконку браузер просит до
296
+ // всякого входа, и в поставке они тоже отдаются открыто. Секретов в них
297
+ // нет — только имя и цвета.
243
298
  web.effect(() =>
244
299
  web.webServer.register({
245
300
  kind: 'exact',
@@ -262,6 +317,7 @@ export function apply(ctx) {
262
317
  path: '/api/tensorgrid.review',
263
318
  async handler(req, res) {
264
319
  try {
320
+ if (rejected(req, res)) return
265
321
  const runtime = await loadModule('./http-runtime.js')
266
322
  await runtime.handleReview(req, res, loadModule, modelServices)
267
323
  } catch (error) {
@@ -277,6 +333,7 @@ export function apply(ctx) {
277
333
  path: '/api/tensorgrid.auth',
278
334
  async handler(req, res) {
279
335
  try {
336
+ if (rejected(req, res)) return
280
337
  const runtime = await loadModule('./http-runtime.js')
281
338
  await runtime.handleAuth(req, res, authServices, loadModule)
282
339
  } catch (error) {
@@ -292,6 +349,7 @@ export function apply(ctx) {
292
349
  path: '/api/tensorgrid.update',
293
350
  async handler(req, res) {
294
351
  try {
352
+ if (rejected(req, res)) return
295
353
  const runtime = await loadModule('./http-runtime.js')
296
354
  await runtime.handleUpdate(req, res, updateState, loadModule)
297
355
  } catch (error) {
package/lib/review-log.js CHANGED
@@ -27,9 +27,9 @@
27
27
  *
28
28
  * Импортируются только встроенные модули Node.
29
29
  */
30
- import { readFile, rename, writeFile } from 'node:fs/promises'
30
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises'
31
31
  import { existsSync } from 'node:fs'
32
- import { createHash } from 'node:crypto'
32
+ import { createHash, randomBytes } from 'node:crypto'
33
33
  import { dirname, join } from 'node:path'
34
34
  import { fileURLToPath } from 'node:url'
35
35
 
@@ -44,6 +44,27 @@ const FILE_NAME = 'tensorgrid-review-log.json'
44
44
  */
45
45
  const MAX_RUNS = 200
46
46
 
47
+ /**
48
+ * Пределы на объём.
49
+ *
50
+ * Ограничения числа обзоров недостаточно: в каждой находке до пяти полей, в
51
+ * обзоре до шестидесяти находок на ревизора, ревизоров трое. Без предела на
52
+ * ДЛИНУ полей двести обзоров дают сотни мегабайт, а файл читается целиком
53
+ * при каждом открытии настроек и при каждом нажатии в панели.
54
+ */
55
+ const FIELD_CHARS = 1200
56
+ const REQUEST_CHARS = 400
57
+ const EVIDENCE_CHARS = 800
58
+
59
+ /**
60
+ * Сколько знаков доказательства считается доказательством.
61
+ *
62
+ * Порог намеренно низкий: это заслон от ПУСТОГО поля, а не попытка судить
63
+ * о качестве проверки. Судить о качестве может только человек, читающий
64
+ * улику, — для того она и показывается в панели рядом с находкой.
65
+ */
66
+ const MIN_EVIDENCE_CHARS = 12
67
+
47
68
  /** Допустимые приговоры. Четвёртого состояния нет: неразобранное — «ждёт». */
48
69
  export const VERDICTS = ['pending', 'confirmed', 'false', 'deferred']
49
70
 
@@ -93,26 +114,82 @@ export function empty() {
93
114
  return { runs: [], verdicts: {} }
94
115
  }
95
116
 
96
- /** Привести прочитанное к ожидаемой форме: файл могли править руками. */
117
+ /** Отрезать текст, не притворяясь, что ничего не отрезали. */
118
+ function clip(value, limit) {
119
+ if (typeof value !== 'string') return null
120
+ return value.length <= limit ? value : `${value.slice(0, limit)}… (обрезано)`
121
+ }
122
+
123
+ /**
124
+ * Привести прочитанное к ожидаемой форме.
125
+ *
126
+ * Внутрь обзора смотрим ТАК ЖЕ придирчиво, как наружу. Раньше проверялось
127
+ * только наличие `id`, а `findings` и `reviewers` принимались любого типа —
128
+ * и один криво отредактированный обзор ронял `latest()` на `.map`, то есть
129
+ * весь раздел настроек целиком: состав, режимы, выбор моделей, запись
130
+ * приговоров. Починить из интерфейса было нельзя.
131
+ */
97
132
  export function normalise(raw) {
98
133
  if (raw === null || typeof raw !== 'object') return empty()
99
134
 
135
+ const text = (value, limit) => (typeof value === 'string' ? clip(value, limit) : null)
136
+
100
137
  const runs = Array.isArray(raw.runs)
101
- ? raw.runs.filter((run) => run !== null && typeof run === 'object' && typeof run.id === 'string').slice(-MAX_RUNS)
138
+ ? raw.runs
139
+ .filter((run) => run !== null && typeof run === 'object' && typeof run.id === 'string')
140
+ .map((run) => ({
141
+ id: run.id,
142
+ at: text(run.at, 40) ?? null,
143
+ request: text(run.request, REQUEST_CHARS) ?? '',
144
+ reviewers: Array.isArray(run.reviewers)
145
+ ? run.reviewers
146
+ .filter((r) => r !== null && typeof r === 'object')
147
+ .map((r) => ({
148
+ provider: text(r.provider, 64),
149
+ model: text(r.model, 96),
150
+ effort: text(r.effort, 32),
151
+ failed: text(r.failed, 200),
152
+ found: Number.isFinite(r.found) ? r.found : 0,
153
+ }))
154
+ : [],
155
+ findings: Array.isArray(run.findings)
156
+ ? run.findings
157
+ .filter((f) => f !== null && typeof f === 'object' && typeof f.id === 'string')
158
+ .map((f) => ({
159
+ id: f.id,
160
+ reviewer: text(f.reviewer, 64) ?? '?',
161
+ severity: text(f.severity, 32) ?? 'не указана',
162
+ title: text(f.title, FIELD_CHARS) ?? '',
163
+ where: text(f.where, FIELD_CHARS),
164
+ what: text(f.what, FIELD_CHARS),
165
+ why: text(f.why, FIELD_CHARS),
166
+ howToCheck: text(f.howToCheck, FIELD_CHARS),
167
+ }))
168
+ : [],
169
+ }))
170
+ .slice(-MAX_RUNS)
102
171
  : []
103
172
 
104
- const verdicts = {}
173
+ // Признаки находок, которые ещё существуют. По ним отсеиваются
174
+ // приговоры-сироты: обзор выпал за предел, а приговор по нему остался бы
175
+ // в файле навсегда и продолжал бы влиять на статистику.
176
+ const alive = new Set()
177
+ for (const run of runs) for (const finding of run.findings) alive.add(finding.id)
178
+
179
+ // Объект БЕЗ прототипа: иначе ключ `__proto__` из правленого файла не
180
+ // записывается вовсе, а `recordVerdict` при этом отвечает «применено» —
181
+ // приговор человека исчезал при успешном ответе.
182
+ const verdicts = Object.create(null)
105
183
  if (raw.verdicts !== null && typeof raw.verdicts === 'object') {
106
184
  for (const [id, entry] of Object.entries(raw.verdicts)) {
107
185
  if (entry === null || typeof entry !== 'object') continue
108
186
  if (!VERDICTS.includes(entry.verdict)) continue
187
+ if (!alive.has(id)) continue
109
188
  verdicts[id] = {
110
189
  verdict: entry.verdict,
111
- // Доказательство обязательная часть приговора, но пустое лучше,
112
- // чем потерянная запись: по нему как раз видно, что проверки не было.
113
- evidence: typeof entry.evidence === 'string' ? entry.evidence : '',
190
+ evidence: text(entry.evidence, EVIDENCE_CHARS) ?? '',
114
191
  by: entry.by === 'human' ? 'human' : 'agent',
115
- at: typeof entry.at === 'string' ? entry.at : null,
192
+ at: text(entry.at, 40),
116
193
  }
117
194
  }
118
195
  }
@@ -120,24 +197,102 @@ export function normalise(raw) {
120
197
  return { runs, verdicts }
121
198
  }
122
199
 
123
- /** Прочитать журнал. */
200
+ /**
201
+ * Прочитать журнал.
202
+ *
203
+ * Возвращает ещё и признак `intact`: удалось ли разобрать файл. Без него
204
+ * повреждённый журнал выглядел как пустой, а следующая запись накрывала им
205
+ * настоящие данные — двести обзоров и все приговоры, включая возражения
206
+ * человека, исчезали от одной лишней запятой, молча и безвозвратно.
207
+ */
124
208
  export async function read(root = null) {
125
209
  const path = logPath(root)
126
- if (path === null) return empty()
210
+ if (path === null) return { ...empty(), intact: true, missing: true }
211
+ let text
212
+ try {
213
+ text = await readFile(path, 'utf8')
214
+ } catch (error) {
215
+ // Файла нет — это не повреждение, а «обзоров ещё не было».
216
+ if (error?.code === 'ENOENT') return { ...empty(), intact: true, missing: true }
217
+ return { ...empty(), intact: false, problem: String(error?.message ?? error) }
218
+ }
127
219
  try {
128
- return normalise(JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, '')))
129
- } catch {
130
- return empty()
220
+ return { ...normalise(JSON.parse(text.replace(/^\uFEFF/, ''))), intact: true, missing: false }
221
+ } catch (error) {
222
+ return { ...empty(), intact: false, problem: `журнал не разобран: ${String(error?.message ?? error)}` }
131
223
  }
132
224
  }
133
225
 
134
- /** Записать журнал через временный файл: чтение не должно увидеть обрезок. */
226
+ /**
227
+ * Очередь записи на каждый файл.
228
+ *
229
+ * Запись идёт по схеме «прочитать целиком → изменить → записать целиком»,
230
+ * и без очереди одновременные вызовы читают ОДНО состояние, а пишут каждый
231
+ * своё: побеждает последний, остальные исчезают бесследно. Проверено —
232
+ * двенадцать одновременных записей давали одну.
233
+ *
234
+ * Случай не выдуманный: журнал пишут две стороны — Host в конце обзора и
235
+ * браузер по нажатию в панели. Человек подтверждает находку ровно в тот
236
+ * момент, когда завершается второй обзор.
237
+ *
238
+ * Очередь внутрипроцессная, и этого достаточно: оба пути проходят через
239
+ * один Host. От двух ОДНОВРЕМЕННО ЗАПУЩЕННЫХ приложений она не спасает —
240
+ * такой случай потребовал бы файловой блокировки, а он у нас невозможен:
241
+ * второй экземпляр не поднимется на занятом порту.
242
+ */
243
+ const queues = new Map()
244
+
245
+ function serialise(path, work) {
246
+ const previous = queues.get(path) ?? Promise.resolve()
247
+ const next = previous.then(work, work)
248
+ // В очереди держим ТОЛЬКО факт завершения, без результата и без отказа:
249
+ // иначе одна неудачная запись уронила бы все следующие.
250
+ queues.set(path, next.then(() => undefined, () => undefined))
251
+ return next
252
+ }
253
+ /**
254
+ * Переименовать с повтором.
255
+ *
256
+ * На Windows одновременное переименование двух временных файлов в одну цель
257
+ * изредка отказывает с EPERM или EBUSY: файл в этот момент держит чужая
258
+ * операция. Это не ошибка вызывающего и не повод терять запись — достаточно
259
+ * повторить через несколько миллисекунд.
260
+ *
261
+ * Повторяются только эти два кода. Всё прочее — настоящая ошибка (нет прав
262
+ * на каталог, кончилось место), и глотать её нельзя.
263
+ */
264
+ async function renameWithRetry(from, to, attempts = 5) {
265
+ for (let attempt = 1; ; attempt += 1) {
266
+ try {
267
+ await rename(from, to)
268
+ return
269
+ } catch (error) {
270
+ const retriable = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
271
+ if (!retriable || attempt >= attempts) throw error
272
+ await new Promise((resolve) => setTimeout(resolve, attempt * 15))
273
+ }
274
+ }
275
+ }
276
+ /**
277
+ * Записать журнал.
278
+ *
279
+ * Запись идёт через временный файл с УНИКАЛЬНЫМ именем и переименованием.
280
+ * Прежнее имя складывалось из номера процесса и времени в миллисекундах —
281
+ * у двух одновременных записей оно совпадало, и вторая получала отказ на
282
+ * переименовании, потому что первая уже унесла общий временный файл.
283
+ */
135
284
  async function write(value, root = null) {
136
285
  const path = logPath(root)
137
286
  if (path === null) throw new Error('не найден каталог профиля — некуда писать журнал обзоров')
138
- const temporary = `${path}.${process.pid}.${Date.now()}.tmp`
139
- await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
140
- await rename(temporary, path)
287
+ const temporary = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
288
+ try {
289
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
290
+ await renameWithRetry(temporary, path)
291
+ } catch (error) {
292
+ // Неудавшаяся запись не должна оставлять мусор рядом с настройками.
293
+ await rm(temporary, { force: true }).catch(() => {})
294
+ throw error
295
+ }
141
296
  }
142
297
 
143
298
  /**
@@ -147,23 +302,53 @@ async function write(value, root = null) {
147
302
  * @returns признак записи и признаки находок — по ним потом выносится приговор.
148
303
  */
149
304
  export async function recordRun(run, root = null) {
305
+ return serialise(logPath(root) ?? 'нет-пути', () => recordRunNow(run, root))
306
+ }
307
+
308
+ async function recordRunNow(run, root) {
150
309
  const log = await read(root)
151
310
 
152
- const findings = (run.findings ?? []).map((finding) => ({
153
- id: findingId(finding),
154
- reviewer: finding.reviewer,
155
- severity: finding.severity,
156
- title: finding.title,
157
- where: finding.where ?? null,
158
- what: finding.what ?? null,
159
- why: finding.why ?? null,
160
- howToCheck: finding.howToCheck ?? null,
161
- }))
311
+ // Повреждённый журнал НЕ затирается.
312
+ //
313
+ // Раньше любая ошибка чтения давала пустой журнал, и следующая же запись
314
+ // накрывала им настоящие данные: двести обзоров и все приговоры, включая
315
+ // возражения человека, исчезали от одной лишней запятой — молча, с
316
+ // обычным ответом об успехе. Файл правит и человек руками, это заявлено
317
+ // как штатное, так что случай не выдуманный.
318
+ if (log.intact === false) {
319
+ throw new Error(`журнал обзоров повреждён, запись отменена, чтобы не потерять прежние: ${log.problem ?? ''}`.trim())
320
+ }
321
+
322
+ const findings = []
323
+ const seen = new Set()
324
+ for (const finding of run.findings ?? []) {
325
+ let id = findingId(finding)
326
+ // Признак считается по содержанию, и две находки с одинаковыми
327
+ // ревизором, названием и местом получали ОДИН признак: приговор по
328
+ // первой молча становился приговором по второй, а вторая исчезала из
329
+ // разбора. Различаем порядковым номером в пределах обзора.
330
+ if (seen.has(id)) {
331
+ let suffix = 2
332
+ while (seen.has(`${id}-${suffix}`)) suffix += 1
333
+ id = `${id}-${suffix}`
334
+ }
335
+ seen.add(id)
336
+ findings.push({
337
+ id,
338
+ reviewer: finding.reviewer,
339
+ severity: finding.severity,
340
+ title: clip(finding.title, FIELD_CHARS) ?? '',
341
+ where: clip(finding.where, FIELD_CHARS),
342
+ what: clip(finding.what, FIELD_CHARS),
343
+ why: clip(finding.why, FIELD_CHARS),
344
+ howToCheck: clip(finding.howToCheck, FIELD_CHARS),
345
+ })
346
+ }
162
347
 
163
348
  const entry = {
164
- id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
349
+ id: `run-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`,
165
350
  at: new Date().toISOString(),
166
- request: typeof run.request === 'string' ? run.request.slice(0, 400) : '',
351
+ request: clip(run.request, REQUEST_CHARS) ?? '',
167
352
  // Модель и усилие сохраняются вместе с находками: без них статистика
168
353
  // точности бессмысленна — «codex ошибается» ничего не значит, если
169
354
  // неизвестно, на какой модели и с каким усилием.
@@ -180,7 +365,7 @@ export async function recordRun(run, root = null) {
180
365
  log.runs.push(entry)
181
366
  if (log.runs.length > MAX_RUNS) log.runs = log.runs.slice(-MAX_RUNS)
182
367
 
183
- await write(log, root)
368
+ await write({ runs: log.runs, verdicts: log.verdicts }, root)
184
369
  return { runId: entry.id, findingIds: findings.map((f) => f.id) }
185
370
  }
186
371
 
@@ -197,8 +382,45 @@ export async function recordRun(run, root = null) {
197
382
  */
198
383
  export async function recordVerdict(id, verdict, evidence, by = 'agent', root = null) {
199
384
  if (!VERDICTS.includes(verdict)) throw new Error(`неизвестный приговор: ${String(verdict)}`)
385
+ return serialise(logPath(root) ?? 'нет-пути', () => recordVerdictNow(id, verdict, evidence, by, root))
386
+ }
387
+
388
+ async function recordVerdictNow(id, verdict, evidence, by, root) {
389
+
390
+ // ДОКАЗАТЕЛЬСТВО ТРЕБУЕТСЯ, а не просто объявлено обязательным.
391
+ //
392
+ // Весь замысел держится на фразе «приговор без доказательства — это
393
+ // мнение», и я написал её в комментарии, не применив в коде: пустая
394
+ // улика превращалась в пустую строку, приговор записывался и входил в
395
+ // статистику наравне с проверенным. То есть единственной защитой была
396
+ // просьба к тому, кого этот механизм и должен проверять.
397
+ //
398
+ // Отложенное — исключение: «настоящая, но чиним не сейчас» доказывать
399
+ // нечем, это решение о приоритете, а не о факте.
400
+ const proof = typeof evidence === 'string' ? evidence.trim() : ''
401
+ if (verdict !== 'deferred' && proof.length < MIN_EVIDENCE_CHARS) {
402
+ throw new Error(
403
+ `приговор «${verdict}» требует доказательства: что именно проверено — ` +
404
+ `запущенная команда и её результат, прочитанные файл и строка, наблюдённое поведение`,
405
+ )
406
+ }
200
407
 
201
408
  const log = await read(root)
409
+ if (log.intact === false) {
410
+ throw new Error(`журнал обзоров повреждён, приговор не записан: ${log.problem ?? ''}`.trim())
411
+ }
412
+
413
+ // Приговор по НЕСУЩЕСТВУЮЩЕЙ находке отклоняется.
414
+ //
415
+ // Раньше он принимался, объявлялся применённым и терялся: агент мог дать
416
+ // признак из прошлого обзора или просто ошибиться, получить «Записано» —
417
+ // а в панели находка так и оставалась ждущей.
418
+ const known = new Set()
419
+ for (const run of log.runs) for (const finding of run.findings) known.add(finding.id)
420
+ if (!known.has(id)) {
421
+ return { id, applied: false, problem: 'находки с таким признаком нет ни в одном сохранённом обзоре' }
422
+ }
423
+
202
424
  const existing = log.verdicts[id]
203
425
 
204
426
  // Человек перекрывает агента, агент человека — нет. Иначе следующий обзор
@@ -209,12 +431,12 @@ export async function recordVerdict(id, verdict, evidence, by = 'agent', root =
209
431
 
210
432
  const entry = {
211
433
  verdict,
212
- evidence: typeof evidence === 'string' ? evidence.slice(0, 800) : '',
434
+ evidence: clip(proof, EVIDENCE_CHARS) ?? '',
213
435
  by: by === 'human' ? 'human' : 'agent',
214
436
  at: new Date().toISOString(),
215
437
  }
216
438
  log.verdicts[id] = entry
217
- await write(log, root)
439
+ await write({ runs: log.runs, verdicts: log.verdicts }, root)
218
440
  return { id, kept: entry, applied: true }
219
441
  }
220
442
 
@@ -232,13 +454,34 @@ export function accuracy(log) {
232
454
  const key = (finding, run) => {
233
455
  const used = (run.reviewers ?? []).find((r) => r.provider === finding.reviewer)
234
456
  const model = used?.model ?? null
235
- return model === null ? finding.reviewer : `${finding.reviewer} · ${model}`
457
+ const effort = used?.effort ?? null
458
+ // Усилие входит в разрез наравне с моделью: быстрый прогон и глубокий
459
+ // — разные условия, и складывать их в одну долю значит мерить среднюю
460
+ // температуру. Без модели остаётся голое имя ревизора — так и пишем,
461
+ // честнее, чем выдумывать.
462
+ if (model === null && effort === null) return finding.reviewer
463
+ return `${finding.reviewer} · ${model ?? 'по умолчанию'}${effort === null ? '' : ` · ${effort}`}`
236
464
  }
237
465
 
238
- for (const run of log.runs) {
466
+ // КАЖДАЯ находка считается ОДИН раз, сколько бы обзоров её ни повторили.
467
+ //
468
+ // Признак считается по содержанию, поэтому та же находка в следующем
469
+ // обзоре получает тот же признак и ссылается на тот же единственный
470
+ // приговор. Перебор по всем обзорам засчитывал каждое вхождение отдельно:
471
+ // пять повторов одной находки и один разбор давали ревизору пять ошибок.
472
+ // Особенно едко с «ответом не по формату» — у него постоянное название,
473
+ // и пять сорванных запусков превращались в пять ошибок ревизора.
474
+ const counted = new Set()
475
+
476
+ // Идём с конца: разрез берётся по САМОМУ СВЕЖЕМУ обзору, где находка
477
+ // встретилась, — там модель и усилие ближе всего к тому, что оценивают.
478
+ for (let index = log.runs.length - 1; index >= 0; index -= 1) {
479
+ const run = log.runs[index]
239
480
  for (const finding of run.findings ?? []) {
481
+ if (counted.has(finding.id)) continue
240
482
  const verdict = log.verdicts[finding.id]
241
483
  if (verdict === undefined || verdict.verdict === 'pending' || verdict.verdict === 'deferred') continue
484
+ counted.add(finding.id)
242
485
  const name = key(finding, run)
243
486
  const stat = byReviewer.get(name) ?? { reviewer: name, confirmed: 0, wrong: 0 }
244
487
  if (verdict.verdict === 'confirmed') stat.confirmed += 1
@@ -247,10 +490,28 @@ export function accuracy(log) {
247
490
  }
248
491
  }
249
492
 
493
+ // Сколько находок ревизор ВООБЩЕ выдал — вторая половина честной картины.
494
+ //
495
+ // Без неё ревизор, чьи находки никто не разбирал, выглядит наравне с тем,
496
+ // у кого разобрано всё, а агент, склонный подтверждать своё и откладывать
497
+ // чужое, сам себе рисует оценку. Пара «рассужено из выданных» этот перекос
498
+ // и показывает.
499
+ const produced = new Map()
500
+ const seenFindings = new Set()
501
+ for (const run of log.runs) {
502
+ for (const finding of run.findings ?? []) {
503
+ if (seenFindings.has(finding.id)) continue
504
+ seenFindings.add(finding.id)
505
+ const name = key(finding, run)
506
+ produced.set(name, (produced.get(name) ?? 0) + 1)
507
+ }
508
+ }
509
+
250
510
  return [...byReviewer.values()]
251
511
  .map((stat) => ({
252
512
  ...stat,
253
513
  judged: stat.confirmed + stat.wrong,
514
+ produced: produced.get(stat.reviewer) ?? stat.confirmed + stat.wrong,
254
515
  // Доля не округляется до целых процентов в источнике: пусть показ
255
516
  // решает сам, а данные остаются точными.
256
517
  rate: stat.confirmed + stat.wrong === 0 ? null : stat.confirmed / (stat.confirmed + stat.wrong),