tensorgrid-ui 1.6.1 → 2.0.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
@@ -224,22 +224,79 @@ export function apply(ctx) {
224
224
  // подключённых моделей для выбора ревизора. Отдельным — потому что
225
225
  // объединение с парой выше связало бы настройки обзора со стойкой
226
226
  // входа: нет одной — пропала бы и другая, хотя они независимы.
227
- const modelServices = { llm: null, generation: null }
228
- web.inject(['llm'], (bound) => {
227
+ const modelServices = { llm: null, credentials: null, generation: null }
228
+ web.inject(['llm', 'credentials'], (bound) => {
229
229
  const generation = {}
230
230
  modelServices.llm = bound.llm
231
+ modelServices.credentials = bound.credentials
231
232
  modelServices.generation = generation
232
233
  bound.effect(() => () => {
233
234
  if (modelServices.generation !== generation) return
234
235
  modelServices.llm = null
236
+ modelServices.credentials = null
235
237
  modelServices.generation = null
236
238
  })
237
239
  })
238
240
 
241
+ // ── ЗАСЛОН ДЛЯ НАШИХ МАРШРУТОВ ──────────────────────────────────────
242
+ //
243
+ // Берётся ШТАТНЫЙ, а не пишется свой. Это не перестраховка, а починка
244
+ // настоящей дыры, найденной обзором и подтверждённой на живом
245
+ // приложении:
246
+ //
247
+ // / 401 защищено
248
+ // /api/любой-другой 401 защищено
249
+ // /api/tensorgrid.auth 200 НАШ, без токена
250
+ //
251
+ // Приложение регистрирует на префикс `/api` обработчик с проверкой, а
252
+ // точное совпадение пути выигрывает у префикса. Наши точные маршруты не
253
+ // просто «не добавляли токен» — они ВЫРЕЗАЛИ из-под себя весь заслон.
254
+ //
255
+ // Штатная проверка делает три вещи, которых моя самодельная не делала:
256
+ // требует петлевой или явно доверенный Host (это и закрывает подмену
257
+ // имени), отклоняет запрос с `sec-fetch-site: cross-site` и лишь затем
258
+ // сверяет Origin. Плюс проверяет токен браузерной сессии.
259
+ const guard = { connection: null, generation: null }
260
+ web.inject(['connection'], (bound) => {
261
+ const generation = {}
262
+ guard.connection = bound.connection
263
+ guard.generation = generation
264
+ bound.effect(() => () => {
265
+ if (guard.generation !== generation) return
266
+ guard.connection = null
267
+ guard.generation = null
268
+ })
269
+ })
270
+
271
+ /**
272
+ * Отклонить запрос, если штатный заслон против.
273
+ *
274
+ * @returns true, если ответ уже отправлен и обработчику делать нечего.
275
+ */
276
+ function rejected(req, res) {
277
+ const connection = guard.connection
278
+ if (connection === null) {
279
+ // Заслона нет — значит браузерная часть не поднята. Открывать
280
+ // маршрут «пока суд да дело» нельзя: это ровно та дыра, что чиним.
281
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' })
282
+ res.end(JSON.stringify({ problem: 'проверка доступа недоступна' }))
283
+ return true
284
+ }
285
+ const rejection = connection.requestRejection(req)
286
+ if (rejection === undefined) return false
287
+ res.writeHead(rejection, { 'content-type': 'application/json; charset=utf-8' })
288
+ res.end(JSON.stringify({ problem: rejection === 401 ? 'требуется вход в приложение' : 'запрос отклонён' }))
289
+ return true
290
+ }
291
+
239
292
  // Манифест собирается в горячем модуле: он ЧИТАЕТ поставочный файл и
240
293
  // дополняет его нашими полями, а значит зависит от чужой формы, которая
241
294
  // может поменяться. Такую логику правильнее держать там, где её можно
242
295
  // поправить без перезапуска.
296
+ //
297
+ // Заслон сюда НЕ ставится намеренно: манифест и иконку браузер просит до
298
+ // всякого входа, и в поставке они тоже отдаются открыто. Секретов в них
299
+ // нет — только имя и цвета.
243
300
  web.effect(() =>
244
301
  web.webServer.register({
245
302
  kind: 'exact',
@@ -262,6 +319,7 @@ export function apply(ctx) {
262
319
  path: '/api/tensorgrid.review',
263
320
  async handler(req, res) {
264
321
  try {
322
+ if (rejected(req, res)) return
265
323
  const runtime = await loadModule('./http-runtime.js')
266
324
  await runtime.handleReview(req, res, loadModule, modelServices)
267
325
  } catch (error) {
@@ -277,6 +335,7 @@ export function apply(ctx) {
277
335
  path: '/api/tensorgrid.auth',
278
336
  async handler(req, res) {
279
337
  try {
338
+ if (rejected(req, res)) return
280
339
  const runtime = await loadModule('./http-runtime.js')
281
340
  await runtime.handleAuth(req, res, authServices, loadModule)
282
341
  } catch (error) {
@@ -292,6 +351,7 @@ export function apply(ctx) {
292
351
  path: '/api/tensorgrid.update',
293
352
  async handler(req, res) {
294
353
  try {
354
+ if (rejected(req, res)) return
295
355
  const runtime = await loadModule('./http-runtime.js')
296
356
  await runtime.handleUpdate(req, res, updateState, loadModule)
297
357
  } 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),