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/LICENSE +15 -0
- package/brand/tensorgrid.ico +0 -0
- package/env/snapshot.json +916 -0
- package/install.ps1 +457 -0
- package/lib/cli.mjs +67 -0
- package/lib/client.js +134 -104
- package/lib/http-runtime.js +58 -23
- package/lib/index.js +62 -2
- package/lib/review-log.js +296 -35
- package/lib/review-settings.js +205 -144
- package/lib/review.js +36 -26
- package/lib/reviewers.js +73 -16
- package/package.json +12 -2
- package/preset/agent.cordis.yml +304 -0
- package/preset/preset.yml +5 -0
- package/profile/cordis.patch.yml +65 -0
- package/profile/package.json +14 -0
- package/tools/round-ico-corners.mjs +113 -0
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
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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}.${
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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)}-${
|
|
349
|
+
id: `run-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`,
|
|
165
350
|
at: new Date().toISOString(),
|
|
166
|
-
request:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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),
|