tensorgrid-ui 1.4.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
@@ -220,10 +220,81 @@ export function apply(ctx) {
220
220
  })
221
221
  })
222
222
 
223
+ // Служба моделей нужна отдельным рядом: она отдаёт живой список
224
+ // подключённых моделей для выбора ревизора. Отдельным — потому что
225
+ // объединение с парой выше связало бы настройки обзора со стойкой
226
+ // входа: нет одной — пропала бы и другая, хотя они независимы.
227
+ const modelServices = { llm: null, generation: null }
228
+ web.inject(['llm'], (bound) => {
229
+ const generation = {}
230
+ modelServices.llm = bound.llm
231
+ modelServices.generation = generation
232
+ bound.effect(() => () => {
233
+ if (modelServices.generation !== generation) return
234
+ modelServices.llm = null
235
+ modelServices.generation = null
236
+ })
237
+ })
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
+
223
290
  // Манифест собирается в горячем модуле: он ЧИТАЕТ поставочный файл и
224
291
  // дополняет его нашими полями, а значит зависит от чужой формы, которая
225
292
  // может поменяться. Такую логику правильнее держать там, где её можно
226
293
  // поправить без перезапуска.
294
+ //
295
+ // Заслон сюда НЕ ставится намеренно: манифест и иконку браузер просит до
296
+ // всякого входа, и в поставке они тоже отдаются открыто. Секретов в них
297
+ // нет — только имя и цвета.
227
298
  web.effect(() =>
228
299
  web.webServer.register({
229
300
  kind: 'exact',
@@ -246,8 +317,9 @@ export function apply(ctx) {
246
317
  path: '/api/tensorgrid.review',
247
318
  async handler(req, res) {
248
319
  try {
320
+ if (rejected(req, res)) return
249
321
  const runtime = await loadModule('./http-runtime.js')
250
- await runtime.handleReview(req, res, loadModule)
322
+ await runtime.handleReview(req, res, loadModule, modelServices)
251
323
  } catch (error) {
252
324
  fail(res, error)
253
325
  }
@@ -261,6 +333,7 @@ export function apply(ctx) {
261
333
  path: '/api/tensorgrid.auth',
262
334
  async handler(req, res) {
263
335
  try {
336
+ if (rejected(req, res)) return
264
337
  const runtime = await loadModule('./http-runtime.js')
265
338
  await runtime.handleAuth(req, res, authServices, loadModule)
266
339
  } catch (error) {
@@ -276,6 +349,7 @@ export function apply(ctx) {
276
349
  path: '/api/tensorgrid.update',
277
350
  async handler(req, res) {
278
351
  try {
352
+ if (rejected(req, res)) return
279
353
  const runtime = await loadModule('./http-runtime.js')
280
354
  await runtime.handleUpdate(req, res, updateState, loadModule)
281
355
  } catch (error) {
@@ -0,0 +1,538 @@
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
+ * Импортируются только встроенные модули Node.
29
+ */
30
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises'
31
+ import { existsSync } from 'node:fs'
32
+ import { createHash, randomBytes } from 'node:crypto'
33
+ import { dirname, join } from 'node:path'
34
+ import { fileURLToPath } from 'node:url'
35
+
36
+ const FILE_NAME = 'tensorgrid-review-log.json'
37
+
38
+ /**
39
+ * Сколько обзоров храним.
40
+ *
41
+ * Журнал нужен для статистики, а не для архива. Двести обзоров — это месяцы
42
+ * работы, и по ним уже видно, какой ревизор чаще прав. Расти бесконечно
43
+ * файлу нельзя: его читают при каждом открытии настроек.
44
+ */
45
+ const MAX_RUNS = 200
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
+
68
+ /** Допустимые приговоры. Четвёртого состояния нет: неразобранное — «ждёт». */
69
+ export const VERDICTS = ['pending', 'confirmed', 'false', 'deferred']
70
+
71
+ /** Каталог профиля: поднимаемся, пока не найдём отметку об установке. */
72
+ function findProfileRoot(startDir) {
73
+ let dir = startDir
74
+ for (let depth = 0; depth < 10; depth += 1) {
75
+ if (existsSync(join(dir, 'tensorgrid-installed.json'))) return dir
76
+ const parent = dirname(dir)
77
+ if (parent === dir) break
78
+ dir = parent
79
+ }
80
+ return null
81
+ }
82
+
83
+ /**
84
+ * Где лежит журнал. Рядом с настройками обзора.
85
+ *
86
+ * Каталог ищется ОТ СОБСТВЕННОГО расположения модуля, а не от рабочей
87
+ * папки. Это важно: рабочую папку задаёт чат, и подложенная в неё отметка
88
+ * об установке увела бы журнал в чужое дерево. Читают и пишут журнал
89
+ * разные стороны — агент и браузер, — и указывать они обязаны на один файл.
90
+ *
91
+ * @param root - явный каталог; только для проверок, где установленного
92
+ * профиля нет вовсе.
93
+ */
94
+ export function logPath(root = null) {
95
+ const found = root ?? findProfileRoot(dirname(fileURLToPath(import.meta.url)))
96
+ return found === null ? null : join(found, FILE_NAME)
97
+ }
98
+
99
+ /**
100
+ * Устойчивый признак находки.
101
+ *
102
+ * Считается по содержанию, а не по порядковому номеру: тот же дефект,
103
+ * найденный повторно в следующем обзоре, получит тот же признак, и прежний
104
+ * приговор к нему подойдёт. Нумерация этого не дала бы — она сдвигается от
105
+ * любой новой находки выше по списку.
106
+ */
107
+ export function findingId(finding) {
108
+ const parts = [finding.reviewer, finding.title, finding.where ?? '']
109
+ return createHash('sha1').update(parts.join('\u0000')).digest('hex').slice(0, 12)
110
+ }
111
+
112
+ /** Пустой журнал. Отсутствие файла — не ошибка, а «обзоров ещё не было». */
113
+ export function empty() {
114
+ return { runs: [], verdicts: {} }
115
+ }
116
+
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
+ */
132
+ export function normalise(raw) {
133
+ if (raw === null || typeof raw !== 'object') return empty()
134
+
135
+ const text = (value, limit) => (typeof value === 'string' ? clip(value, limit) : null)
136
+
137
+ const runs = Array.isArray(raw.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)
171
+ : []
172
+
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)
183
+ if (raw.verdicts !== null && typeof raw.verdicts === 'object') {
184
+ for (const [id, entry] of Object.entries(raw.verdicts)) {
185
+ if (entry === null || typeof entry !== 'object') continue
186
+ if (!VERDICTS.includes(entry.verdict)) continue
187
+ if (!alive.has(id)) continue
188
+ verdicts[id] = {
189
+ verdict: entry.verdict,
190
+ evidence: text(entry.evidence, EVIDENCE_CHARS) ?? '',
191
+ by: entry.by === 'human' ? 'human' : 'agent',
192
+ at: text(entry.at, 40),
193
+ }
194
+ }
195
+ }
196
+
197
+ return { runs, verdicts }
198
+ }
199
+
200
+ /**
201
+ * Прочитать журнал.
202
+ *
203
+ * Возвращает ещё и признак `intact`: удалось ли разобрать файл. Без него
204
+ * повреждённый журнал выглядел как пустой, а следующая запись накрывала им
205
+ * настоящие данные — двести обзоров и все приговоры, включая возражения
206
+ * человека, исчезали от одной лишней запятой, молча и безвозвратно.
207
+ */
208
+ export async function read(root = null) {
209
+ const path = logPath(root)
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
+ }
219
+ try {
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)}` }
223
+ }
224
+ }
225
+
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
+ */
284
+ async function write(value, root = null) {
285
+ const path = logPath(root)
286
+ if (path === null) throw new Error('не найден каталог профиля — некуда писать журнал обзоров')
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
+ }
296
+ }
297
+
298
+ /**
299
+ * Записать состоявшийся обзор.
300
+ *
301
+ * @param run - что спрашивали, кого звали и что нашли.
302
+ * @returns признак записи и признаки находок — по ним потом выносится приговор.
303
+ */
304
+ export async function recordRun(run, root = null) {
305
+ return serialise(logPath(root) ?? 'нет-пути', () => recordRunNow(run, root))
306
+ }
307
+
308
+ async function recordRunNow(run, root) {
309
+ const log = await read(root)
310
+
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
+ }
347
+
348
+ const entry = {
349
+ id: `run-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`,
350
+ at: new Date().toISOString(),
351
+ request: clip(run.request, REQUEST_CHARS) ?? '',
352
+ // Модель и усилие сохраняются вместе с находками: без них статистика
353
+ // точности бессмысленна — «codex ошибается» ничего не значит, если
354
+ // неизвестно, на какой модели и с каким усилием.
355
+ reviewers: (run.reviewers ?? []).map((r) => ({
356
+ provider: r.provider,
357
+ model: r.model ?? null,
358
+ effort: r.effort ?? null,
359
+ failed: r.failed ?? null,
360
+ found: r.found ?? 0,
361
+ })),
362
+ findings,
363
+ }
364
+
365
+ log.runs.push(entry)
366
+ if (log.runs.length > MAX_RUNS) log.runs = log.runs.slice(-MAX_RUNS)
367
+
368
+ await write({ runs: log.runs, verdicts: log.verdicts }, root)
369
+ return { runId: entry.id, findingIds: findings.map((f) => f.id) }
370
+ }
371
+
372
+ /**
373
+ * Вынести приговор по находке.
374
+ *
375
+ * `evidence` не формальность: приговор без доказательства — это мнение, а
376
+ * инструмент затевался как раз против мнений, выдаваемых за факты.
377
+ *
378
+ * @param id - признак находки.
379
+ * @param verdict - подтверждено, ложная или отложено.
380
+ * @param evidence - что именно проверено: команда, файл, наблюдение.
381
+ * @param by - агент или человек; человек перекрывает агента.
382
+ */
383
+ export async function recordVerdict(id, verdict, evidence, by = 'agent', root = null) {
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
+ }
407
+
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
+
424
+ const existing = log.verdicts[id]
425
+
426
+ // Человек перекрывает агента, агент человека — нет. Иначе следующий обзор
427
+ // молча отменил бы возражение, ради которого всё и затевалось.
428
+ if (existing !== undefined && existing.by === 'human' && by === 'agent') {
429
+ return { id, kept: existing, applied: false }
430
+ }
431
+
432
+ const entry = {
433
+ verdict,
434
+ evidence: clip(proof, EVIDENCE_CHARS) ?? '',
435
+ by: by === 'human' ? 'human' : 'agent',
436
+ at: new Date().toISOString(),
437
+ }
438
+ log.verdicts[id] = entry
439
+ await write({ runs: log.runs, verdicts: log.verdicts }, root)
440
+ return { id, kept: entry, applied: true }
441
+ }
442
+
443
+ /**
444
+ * Точность ревизоров по накопленному журналу.
445
+ *
446
+ * Считаются ТОЛЬКО находки с вынесенным приговором: непроверенные не значат
447
+ * ни «правда», ни «ложь», и включать их значило бы врать в обе стороны.
448
+ * Поэтому рядом с долей всегда стоит, на скольких находках она посчитана —
449
+ * доля по трём находкам не значит ничего.
450
+ */
451
+ export function accuracy(log) {
452
+ const byReviewer = new Map()
453
+
454
+ const key = (finding, run) => {
455
+ const used = (run.reviewers ?? []).find((r) => r.provider === finding.reviewer)
456
+ const model = used?.model ?? null
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}`}`
464
+ }
465
+
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]
480
+ for (const finding of run.findings ?? []) {
481
+ if (counted.has(finding.id)) continue
482
+ const verdict = log.verdicts[finding.id]
483
+ if (verdict === undefined || verdict.verdict === 'pending' || verdict.verdict === 'deferred') continue
484
+ counted.add(finding.id)
485
+ const name = key(finding, run)
486
+ const stat = byReviewer.get(name) ?? { reviewer: name, confirmed: 0, wrong: 0 }
487
+ if (verdict.verdict === 'confirmed') stat.confirmed += 1
488
+ else stat.wrong += 1
489
+ byReviewer.set(name, stat)
490
+ }
491
+ }
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
+
510
+ return [...byReviewer.values()]
511
+ .map((stat) => ({
512
+ ...stat,
513
+ judged: stat.confirmed + stat.wrong,
514
+ produced: produced.get(stat.reviewer) ?? stat.confirmed + stat.wrong,
515
+ // Доля не округляется до целых процентов в источнике: пусть показ
516
+ // решает сам, а данные остаются точными.
517
+ rate: stat.confirmed + stat.wrong === 0 ? null : stat.confirmed / (stat.confirmed + stat.wrong),
518
+ }))
519
+ .sort((a, b) => b.judged - a.judged)
520
+ }
521
+
522
+ /** Находки последнего обзора вместе с приговорами — то, что показывает панель. */
523
+ export function latest(log) {
524
+ const run = log.runs[log.runs.length - 1]
525
+ if (run === undefined) return null
526
+ return {
527
+ id: run.id,
528
+ at: run.at,
529
+ request: run.request,
530
+ reviewers: run.reviewers,
531
+ findings: (run.findings ?? []).map((finding) => ({
532
+ ...finding,
533
+ verdict: log.verdicts[finding.id]?.verdict ?? 'pending',
534
+ evidence: log.verdicts[finding.id]?.evidence ?? '',
535
+ by: log.verdicts[finding.id]?.by ?? null,
536
+ })),
537
+ }
538
+ }