tensorgrid-ui 1.4.0 → 1.6.1
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/client.js +326 -28
- package/lib/http-runtime.js +87 -4
- package/lib/index.js +17 -1
- package/lib/review-log.js +277 -0
- package/lib/review-settings.js +137 -11
- package/lib/review.js +299 -46
- package/lib/reviewers.js +242 -24
- package/package.json +1 -1
package/lib/review.js
CHANGED
|
@@ -39,8 +39,20 @@
|
|
|
39
39
|
* ошибку одной строки вместо обрушения всего ряда.
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Список ревизоров берётся из ОДНОГО места — `reviewers.js`.
|
|
44
|
+
*
|
|
45
|
+
* Здесь стоял свой литерал, а ещё один жил в `review-settings.js`: три
|
|
46
|
+
* источника правды на один список. Ревизор указал, чем это кончится —
|
|
47
|
+
* добавление продукта в один файл из трёх делает запись выбора
|
|
48
|
+
* неопределённой и роняет весь раздел настроек. В прошлый раз я починил
|
|
49
|
+
* половину и отчитался за целое; следующий обзор это и поймал.
|
|
50
|
+
*
|
|
51
|
+
* Импорт статический: `reviewers.js` тянет только встроенные модули Node,
|
|
52
|
+
* поэтому он загружается везде, где загружается этот файл, — включая тесты
|
|
53
|
+
* на машине без dsh.
|
|
54
|
+
*/
|
|
55
|
+
const KNOWN_REVIEWERS = KNOWN
|
|
44
56
|
|
|
45
57
|
/**
|
|
46
58
|
* Задание ревизору.
|
|
@@ -106,45 +118,146 @@ function buildPrompt({ request, paths, focus }) {
|
|
|
106
118
|
return lines.join('\n')
|
|
107
119
|
}
|
|
108
120
|
|
|
121
|
+
import { KNOWN } from './reviewers.js'
|
|
122
|
+
|
|
109
123
|
/** Уровни важности в порядке убывания — по нему сортируется итог. */
|
|
110
|
-
const SEVERITY_ORDER = ['критично', 'серьёзно', 'мелочь']
|
|
124
|
+
const SEVERITY_ORDER = ['критично', 'не указана', 'серьёзно', 'мелочь']
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Найти пути, уводящие за пределы рабочей папки.
|
|
128
|
+
*
|
|
129
|
+
* Проверка идёт по РЕАЛЬНОМУ пути, а не по написанному: символическая
|
|
130
|
+
* ссылка внутри папки может указывать наружу, и сравнение строк этого не
|
|
131
|
+
* увидит. Несуществующий путь не считается выходом — о нём ревизор скажет
|
|
132
|
+
* сам, и отказывать в обзоре из-за опечатки незачем.
|
|
133
|
+
*
|
|
134
|
+
* @param cwd - рабочая папка чата.
|
|
135
|
+
* @param paths - пути из вызова, возможно отсутствующие.
|
|
136
|
+
* @returns те из них, что оказались снаружи.
|
|
137
|
+
*/
|
|
138
|
+
async function pathsOutside(cwd, paths) {
|
|
139
|
+
if (!Array.isArray(paths) || paths.length === 0) return []
|
|
140
|
+
|
|
141
|
+
const { realpath } = await import('node:fs/promises')
|
|
142
|
+
const { isAbsolute, join, relative, resolve } = await import('node:path')
|
|
143
|
+
|
|
144
|
+
const settle = async (value) => {
|
|
145
|
+
try {
|
|
146
|
+
return await realpath(value)
|
|
147
|
+
} catch {
|
|
148
|
+
return resolve(value)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const root = await settle(cwd)
|
|
153
|
+
const outside = []
|
|
154
|
+
|
|
155
|
+
for (const entry of paths) {
|
|
156
|
+
const text = String(entry)
|
|
157
|
+
const full = await settle(isAbsolute(text) ? text : join(cwd, text))
|
|
158
|
+
const step = relative(root, full)
|
|
159
|
+
// Пустой `step` — это сам корень; `..` в начале — выход наружу.
|
|
160
|
+
if (step !== '' && (step.startsWith('..') || isAbsolute(step))) outside.push(text)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return outside
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Отсортировать находки: сначала опасные, внутри уровня — по ревизору. */
|
|
167
|
+
function ordered(findings) {
|
|
168
|
+
return [...findings].sort((a, b) => {
|
|
169
|
+
const bySeverity = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
|
|
170
|
+
return bySeverity !== 0 ? bySeverity : a.reviewer.localeCompare(b.reviewer)
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Пределы на разбор.
|
|
175
|
+
*
|
|
176
|
+
* Ответ приходит из отдельного процесса и ничем не ограничен: многословная
|
|
177
|
+
* или сорвавшаяся в повтор модель способна выдать мегабайт. Всё это уходит в
|
|
178
|
+
* контекст вызывающего агента — то есть стоит дороже самого обзора и
|
|
179
|
+
* способно вытеснить ту работу, которую обзор проверял.
|
|
180
|
+
*/
|
|
181
|
+
const MAX_FINDINGS = 60
|
|
182
|
+
const MAX_FIELD_CHARS = 1200
|
|
183
|
+
|
|
184
|
+
/** Обрезать поле, не притворяясь, что ничего не обрезали. */
|
|
185
|
+
function clip(value) {
|
|
186
|
+
if (value === null) return null
|
|
187
|
+
return value.length <= MAX_FIELD_CHARS ? value : `${value.slice(0, MAX_FIELD_CHARS)}… (обрезано)`
|
|
188
|
+
}
|
|
111
189
|
|
|
112
190
|
/**
|
|
113
191
|
* Разобрать ответ ревизора в список находок.
|
|
114
192
|
*
|
|
115
193
|
* Разбор намеренно снисходительный: ревизор — языковая модель, и точного
|
|
116
|
-
* соблюдения формата от неё ждать нельзя.
|
|
117
|
-
*
|
|
118
|
-
*
|
|
194
|
+
* соблюдения формата от неё ждать нельзя. Пропущенное поле даёт null, а не
|
|
195
|
+
* теряет находку; жирная разметка вокруг названий полей снимается.
|
|
196
|
+
*
|
|
197
|
+
* Текст БЕЗ единого заголовка не выбрасывается, а возвращается одной
|
|
198
|
+
* находкой с пометкой «не по формату». Прежде такой ответ превращался в
|
|
199
|
+
* пустой список, то есть в «находок нет» — и сообщение продукта об истёкшем
|
|
200
|
+
* входе выглядело справкой о чистоте кода.
|
|
119
201
|
*/
|
|
120
202
|
export function parseFindings(text, reviewer) {
|
|
121
203
|
const source = String(text ?? '')
|
|
122
|
-
if (
|
|
204
|
+
if (source.trim() === '') return []
|
|
205
|
+
if (/^\s*НАХОДОК НЕТ\s*$/im.test(source) && !/^\s*#{2,3}\s+/m.test(source)) return []
|
|
123
206
|
|
|
124
207
|
const findings = []
|
|
125
|
-
|
|
208
|
+
// Заголовок распознаётся только в начале строки и не глубже третьего
|
|
209
|
+
// уровня: `###` внутри примера кода в поле «КАК ПРОВЕРИТЬ» иначе разрывал
|
|
210
|
+
// находку надвое, и вторая половина уезжала в конец списка без важности.
|
|
211
|
+
const blocks = source.split(/^#{2,3}[ \t]+/m).slice(1)
|
|
212
|
+
|
|
213
|
+
if (blocks.length === 0) {
|
|
214
|
+
return [{
|
|
215
|
+
reviewer,
|
|
216
|
+
title: 'ответ не по формату',
|
|
217
|
+
severity: 'мелочь',
|
|
218
|
+
where: null,
|
|
219
|
+
what: clip(source.trim()),
|
|
220
|
+
why: 'Ревизор ответил не по заданному образцу — возможно, это сообщение об ошибке, а не обзор.',
|
|
221
|
+
howToCheck: null,
|
|
222
|
+
unparsed: true,
|
|
223
|
+
}]
|
|
224
|
+
}
|
|
126
225
|
|
|
127
226
|
for (const block of blocks) {
|
|
128
227
|
const [titleLine, ...rest] = block.split('\n')
|
|
129
|
-
|
|
228
|
+
// Жирная разметка вокруг названий полей снимается: модели любят её
|
|
229
|
+
// добавлять, а без этого поле просто не находилось и содержимое терялось.
|
|
230
|
+
const body = rest.join('\n').replace(/\*\*/g, '')
|
|
231
|
+
|
|
130
232
|
// Поле тянется до следующего заголовка или до конца блока.
|
|
131
233
|
//
|
|
132
234
|
// Флаг `m` здесь недопустим: с ним `$` означает конец СТРОКИ, и
|
|
133
235
|
// объяснение обрывалось на первой же — а ревизор объясняет развёрнуто,
|
|
134
236
|
// и вторая строка обычно и есть самое полезное. Начало строки
|
|
135
237
|
// обеспечивается явным `(?:^|\n)`, конец блока — `$` без `m`.
|
|
238
|
+
//
|
|
239
|
+
// Следующим полем считается только ИЗВЕСТНОЕ название, а не любая
|
|
240
|
+
// строка из заглавных букв с двоеточием: «ИТОГО:» внутри объяснения
|
|
241
|
+
// обрывал поле на середине.
|
|
242
|
+
const NEXT = '(?:ВАЖНОСТЬ|ГДЕ|ЧТО НЕ ТАК|ПОЧЕМУ ЭТО ВАЖНО|КАК ПРОВЕРИТЬ)'
|
|
136
243
|
const field = (label) => {
|
|
137
|
-
const match = body.match(new RegExp(`(?:^|\\n)${label}:\\s*([\\s\\S]+?)(?=\\n
|
|
138
|
-
return match === null ? null : match[1].trim()
|
|
244
|
+
const match = body.match(new RegExp(`(?:^|\\n)${label}:\\s*([\\s\\S]+?)(?=\\n${NEXT}:|$)`))
|
|
245
|
+
return match === null ? null : clip(match[1].trim())
|
|
139
246
|
}
|
|
140
247
|
|
|
141
248
|
const severityRaw = (field('ВАЖНОСТЬ') ?? '').toLowerCase()
|
|
142
|
-
|
|
249
|
+
// Важность, которую ревизор не указал, ОТМЕЧАЕТСЯ как неуказанную.
|
|
250
|
+
//
|
|
251
|
+
// Прежде она молча становилась «мелочью», и критичная находка с
|
|
252
|
+
// пропущенной строкой уезжала в самый низ списка — туда, куда не
|
|
253
|
+
// смотрят. Теперь неуказанная идёт СРАЗУ ПОСЛЕ критичных: пусть лучше
|
|
254
|
+
// человек прочтёт лишнее, чем пропустит важное.
|
|
255
|
+
const severity = SEVERITY_ORDER.find((level) => severityRaw.includes(level))
|
|
143
256
|
|
|
144
257
|
findings.push({
|
|
145
258
|
reviewer,
|
|
146
|
-
title: titleLine.trim(),
|
|
147
|
-
severity,
|
|
259
|
+
title: clip(titleLine.replace(/\*\*/g, '').trim()),
|
|
260
|
+
severity: severity ?? 'не указана',
|
|
148
261
|
where: field('ГДЕ'),
|
|
149
262
|
what: field('ЧТО НЕ ТАК'),
|
|
150
263
|
why: field('ПОЧЕМУ ЭТО ВАЖНО'),
|
|
@@ -152,7 +265,25 @@ export function parseFindings(text, reviewer) {
|
|
|
152
265
|
})
|
|
153
266
|
}
|
|
154
267
|
|
|
155
|
-
|
|
268
|
+
// Предел применяется ПОСЛЕ сортировки по важности, а не по ходу разбора.
|
|
269
|
+
//
|
|
270
|
+
// Раньше обрезка шла по порядку в тексте: критичная находка на 61-м месте
|
|
271
|
+
// пропадала, а шестьдесят мелочей выше неё оставались. Ревизор выкладывает
|
|
272
|
+
// находки не по важности, так что порядок в тексте ничего не значит.
|
|
273
|
+
if (findings.length <= MAX_FINDINGS) return findings
|
|
274
|
+
|
|
275
|
+
const kept = ordered(findings).slice(0, MAX_FINDINGS)
|
|
276
|
+
kept.push({
|
|
277
|
+
reviewer,
|
|
278
|
+
title: `показаны не все находки: ${findings.length - MAX_FINDINGS} отброшено`,
|
|
279
|
+
severity: 'мелочь',
|
|
280
|
+
where: null,
|
|
281
|
+
what: `Ревизор вернул ${findings.length} находок, показаны ${MAX_FINDINGS} самых важных.`,
|
|
282
|
+
why: 'Остальные отброшены, чтобы обзор не вытеснил из работы то, что он проверял.',
|
|
283
|
+
howToCheck: null,
|
|
284
|
+
truncationNotice: true,
|
|
285
|
+
})
|
|
286
|
+
return kept
|
|
156
287
|
}
|
|
157
288
|
|
|
158
289
|
/**
|
|
@@ -165,6 +296,7 @@ async function askReviewer(ctx, runReviewer, kind, prompt, cwd, choice, signal)
|
|
|
165
296
|
const outcome = await runReviewer(ctx, kind, {
|
|
166
297
|
prompt,
|
|
167
298
|
cwd,
|
|
299
|
+
provider: choice?.provider ?? null,
|
|
168
300
|
model: choice?.model ?? null,
|
|
169
301
|
effort: choice?.effort ?? null,
|
|
170
302
|
signal,
|
|
@@ -173,43 +305,57 @@ async function askReviewer(ctx, runReviewer, kind, prompt, cwd, choice, signal)
|
|
|
173
305
|
const used = { model: choice?.model ?? null, effort: choice?.effort ?? null }
|
|
174
306
|
|
|
175
307
|
if (outcome.failed !== null) {
|
|
176
|
-
return { provider: kind, failed: outcome.failed, findings: [], ...used }
|
|
308
|
+
return { provider: kind, failed: outcome.failed, cancelled: outcome.cancelled === true, findings: [], ...used }
|
|
177
309
|
}
|
|
178
310
|
|
|
179
311
|
return { provider: kind, failed: null, findings: parseFindings(outcome.answer, kind), ...used }
|
|
180
312
|
}
|
|
181
313
|
|
|
182
|
-
/** Отсортировать находки: сначала опасные, внутри уровня — по ревизору. */
|
|
183
|
-
function ordered(findings) {
|
|
184
|
-
return [...findings].sort((a, b) => {
|
|
185
|
-
const bySeverity = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
|
|
186
|
-
return bySeverity !== 0 ? bySeverity : a.reviewer.localeCompare(b.reviewer)
|
|
187
|
-
})
|
|
188
|
-
}
|
|
189
|
-
|
|
190
314
|
/** Человекочитаемый вид итога. */
|
|
191
315
|
export function render(value) {
|
|
192
316
|
const lines = []
|
|
193
317
|
|
|
194
|
-
|
|
195
|
-
|
|
318
|
+
// Массив ревизоров может отсутствовать: схема результата его не требует,
|
|
319
|
+
// и любой ранний выход из инструмента давал объект без него. Обращение к
|
|
320
|
+
// `.length` роняло отрисовку — то есть пользователь вместо объяснения
|
|
321
|
+
// получал ошибку инструмента.
|
|
322
|
+
const reviewers = Array.isArray(value.reviewers) ? value.reviewers : []
|
|
323
|
+
const findings = Array.isArray(value.findings) ? value.findings : []
|
|
324
|
+
|
|
325
|
+
if (reviewers.length === 0) {
|
|
326
|
+
// Настоящая причина приходит в `problem` и обязана быть показана.
|
|
327
|
+
//
|
|
328
|
+
// Здесь стояла одна фраза на все случаи — про несмонтированные
|
|
329
|
+
// провайдеры подагентов. Механизм, которым этот код давно не
|
|
330
|
+
// пользуется: продукты зовутся напрямую. Пользователь, снявший обе
|
|
331
|
+
// галочки, получал объяснение про постороннюю неисправность и шёл чинить
|
|
332
|
+
// не то. Оба ревизора нашли это независимо.
|
|
333
|
+
return typeof value.problem === 'string' && value.problem !== ''
|
|
334
|
+
? `Обзор не выполнен: ${value.problem}`
|
|
335
|
+
: 'Обзор не выполнен: ни один ревизор не выбран.'
|
|
196
336
|
}
|
|
197
337
|
|
|
198
|
-
const failed =
|
|
199
|
-
const worked =
|
|
338
|
+
const failed = reviewers.filter((r) => r.failed !== null)
|
|
339
|
+
const worked = reviewers.filter((r) => r.failed === null)
|
|
200
340
|
|
|
201
|
-
lines.push(`Ревизоров опрошено: ${worked.length} из ${
|
|
341
|
+
lines.push(`Ревизоров опрошено: ${worked.length} из ${reviewers.length}. Находок: ${findings.length}.`)
|
|
202
342
|
for (const entry of failed) lines.push(` ✗ ${entry.provider} — ${entry.failed}`)
|
|
203
343
|
lines.push('')
|
|
204
344
|
|
|
205
|
-
if (
|
|
206
|
-
|
|
345
|
+
if (findings.length === 0) {
|
|
346
|
+
// «Находок нет» говорится ТОЛЬКО когда обзор действительно состоялся.
|
|
347
|
+
// Отказ или отмена, поданные как чистый результат, — худшее, что этот
|
|
348
|
+
// инструмент может сделать: пользователь сочтёт работу проверенной.
|
|
349
|
+
const cancelled = reviewers.some((entry) => entry.cancelled === true)
|
|
350
|
+
if (cancelled) lines.push('Обзор отменён — судить не по чему.')
|
|
351
|
+
else if (worked.length === 0) lines.push('Судить не по чему: не ответил ни один ревизор.')
|
|
352
|
+
else lines.push('Находок нет.')
|
|
207
353
|
return lines.join('\n')
|
|
208
354
|
}
|
|
209
355
|
|
|
210
356
|
// Сортируем здесь, а не полагаемся на вызывающего: опасное должно быть
|
|
211
357
|
// сверху при любом способе получить эти данные.
|
|
212
|
-
for (const finding of ordered(
|
|
358
|
+
for (const finding of ordered(findings)) {
|
|
213
359
|
lines.push(`### ${finding.title} [${finding.severity}] — ${finding.reviewer}`)
|
|
214
360
|
if (finding.where) lines.push(`ГДЕ: ${finding.where}`)
|
|
215
361
|
if (finding.what) lines.push(`ЧТО НЕ ТАК: ${finding.what}`)
|
|
@@ -221,7 +367,7 @@ export function render(value) {
|
|
|
221
367
|
// Совпадение двух независимых ревизоров — сильный признак: они друг друга
|
|
222
368
|
// не видели. Расхождение тоже полезно, но его надо проверять самому.
|
|
223
369
|
const byReviewer = new Map()
|
|
224
|
-
for (const finding of
|
|
370
|
+
for (const finding of findings) {
|
|
225
371
|
byReviewer.set(finding.reviewer, (byReviewer.get(finding.reviewer) ?? 0) + 1)
|
|
226
372
|
}
|
|
227
373
|
lines.push(`По ревизорам: ${[...byReviewer].map(([name, count]) => `${name} — ${count}`).join(', ')}.`)
|
|
@@ -266,7 +412,7 @@ export async function apply(ctx) {
|
|
|
266
412
|
},
|
|
267
413
|
reviewers: {
|
|
268
414
|
type: 'array',
|
|
269
|
-
items: { type: 'string', enum:
|
|
415
|
+
items: { type: 'string', enum: KNOWN_REVIEWERS },
|
|
270
416
|
description:
|
|
271
417
|
'Which reviewers to ask. Defaults to both — they find different things, and asking one guarantees missing half.',
|
|
272
418
|
},
|
|
@@ -287,20 +433,47 @@ export async function apply(ctx) {
|
|
|
287
433
|
const { readSettings } = await import('./review-settings.js')
|
|
288
434
|
|
|
289
435
|
// Рабочая папка берётся у сессии, как это делают поставочные
|
|
290
|
-
// провайдеры: ревизор
|
|
436
|
+
// провайдеры: ревизор смотрит ту же папку, в которой идёт работа.
|
|
291
437
|
const cwd = exec.agent?.session?.header?.cwd
|
|
292
438
|
if (typeof cwd !== 'string' || cwd === '') {
|
|
293
439
|
return { findings: [], reviewers: [], problem: 'у сессии нет рабочей папки — обзору нечего смотреть' }
|
|
294
440
|
}
|
|
295
441
|
|
|
442
|
+
// Ревизор не выходит за папку чата.
|
|
443
|
+
//
|
|
444
|
+
// Он запускается с правами на чтение, и без этой проверки путь вида
|
|
445
|
+
// `../../` или абсолютный увёл бы его в чужое дерево — к переписке,
|
|
446
|
+
// документам, настройкам других программ. Отказ явный, с указанием
|
|
447
|
+
// пути: молчаливое усечение списка выглядело бы как обзор, который
|
|
448
|
+
// просто ничего не нашёл.
|
|
449
|
+
const outside = await pathsOutside(cwd, args.paths)
|
|
450
|
+
if (outside.length > 0) {
|
|
451
|
+
return {
|
|
452
|
+
findings: [],
|
|
453
|
+
reviewers: [],
|
|
454
|
+
problem:
|
|
455
|
+
`эти пути вне рабочей папки чата: ${outside.join(', ')}. ` +
|
|
456
|
+
'Ревизор читает только папку, в которой идёт работа. Чтобы посмотреть другую, ' +
|
|
457
|
+
'откройте чат в ней или разрешите доступ явно.',
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
296
461
|
// Выбор пользователя из настроек; вызов может его перекрыть.
|
|
297
462
|
const settings = await readSettings(cwd)
|
|
298
|
-
const
|
|
463
|
+
const asked = Array.isArray(args.reviewers) && args.reviewers.length > 0
|
|
299
464
|
? args.reviewers
|
|
300
465
|
: settings.enabled
|
|
301
466
|
|
|
467
|
+
// Повторы отбрасываются, и это не придирка: `reviewers` заполняет
|
|
468
|
+
// модель, а список из ста одинаковых имён запустил бы сто платных
|
|
469
|
+
// процессов разом — по одному на каждый элемент.
|
|
470
|
+
const wanted = [...new Set(asked)].filter((kind) => KNOWN_REVIEWERS.includes(kind))
|
|
471
|
+
|
|
302
472
|
if (wanted.length === 0) {
|
|
303
|
-
|
|
473
|
+
const problem = asked.length === 0
|
|
474
|
+
? 'не выбран ни один ревизор — включите их в настройках'
|
|
475
|
+
: `неизвестные ревизоры: ${[...new Set(asked)].join(', ')}`
|
|
476
|
+
return { findings: [], reviewers: [], problem }
|
|
304
477
|
}
|
|
305
478
|
|
|
306
479
|
const prompt = buildPrompt(args)
|
|
@@ -308,18 +481,98 @@ export async function apply(ctx) {
|
|
|
308
481
|
// Спрашиваем ОБОИХ разом: они независимы, и последовательный опрос
|
|
309
482
|
// удвоил бы ожидание без всякой пользы.
|
|
310
483
|
const answers = await Promise.all(
|
|
311
|
-
wanted.map((kind) =>
|
|
484
|
+
wanted.map((kind) =>
|
|
485
|
+
askReviewer(
|
|
486
|
+
// Наш ревизор заводит подагента, и тому нужен родитель —
|
|
487
|
+
// агент, от имени которого идёт вызов. Внешним он не нужен.
|
|
488
|
+
{ ...ctx, get: (name) => ctx.get(name), reviewParent: exec.agent },
|
|
489
|
+
runReviewer,
|
|
490
|
+
kind,
|
|
491
|
+
prompt,
|
|
492
|
+
cwd,
|
|
493
|
+
settings.choices[kind],
|
|
494
|
+
exec.signal,
|
|
495
|
+
),
|
|
496
|
+
),
|
|
312
497
|
)
|
|
313
498
|
|
|
499
|
+
const findings = ordered(answers.flatMap((entry) => entry.findings))
|
|
500
|
+
const reviewers = answers.map((entry) => ({
|
|
501
|
+
provider: entry.provider,
|
|
502
|
+
failed: entry.failed,
|
|
503
|
+
cancelled: entry.cancelled === true,
|
|
504
|
+
found: entry.findings.length,
|
|
505
|
+
model: entry.model,
|
|
506
|
+
effort: entry.effort,
|
|
507
|
+
}))
|
|
508
|
+
|
|
509
|
+
// Обзор записывается в журнал — иначе приговор выносится в переписке
|
|
510
|
+
// и там же теряется. Без записи никто не узнает, какой ревизор на
|
|
511
|
+
// какой модели чаще прав, а это единственная величина, по которой
|
|
512
|
+
// видно, стоит ли он своих денег.
|
|
513
|
+
//
|
|
514
|
+
// Неудача записи НЕ отменяет обзор: находки важнее статистики.
|
|
515
|
+
let recorded = null
|
|
516
|
+
try {
|
|
517
|
+
const { recordRun } = await import('./review-log.js')
|
|
518
|
+
recorded = await recordRun({ request: args.request, reviewers, findings })
|
|
519
|
+
} catch {
|
|
520
|
+
recorded = null
|
|
521
|
+
}
|
|
522
|
+
|
|
314
523
|
return {
|
|
315
|
-
findings:
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
524
|
+
findings: recorded === null
|
|
525
|
+
? findings
|
|
526
|
+
: findings.map((finding, index) => ({ ...finding, id: recorded.findingIds[index] })),
|
|
527
|
+
reviewers,
|
|
528
|
+
...(recorded === null
|
|
529
|
+
? { note: 'журнал обзоров недоступен — приговоры не сохранятся' }
|
|
530
|
+
: { runId: recorded.runId }),
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
}),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
ctx.tools.register(
|
|
537
|
+
defineTool({
|
|
538
|
+
name: 'review_verdict',
|
|
539
|
+
description:
|
|
540
|
+
'Record your verdict on one finding from the last second_opinion review, with the evidence you gathered. ' +
|
|
541
|
+
'Roughly a third of findings are wrong — often plausibly wrong, reasoned from real evidence toward a false conclusion. ' +
|
|
542
|
+
'Verify before you judge: run it, read the surrounding code, reproduce the steps the reviewer gave. ' +
|
|
543
|
+
'The evidence field is not a formality: a verdict without it is an opinion, and this whole tool exists against opinions passed off as facts. ' +
|
|
544
|
+
'Call this once per finding after a review, before telling the user what you are going to fix.',
|
|
545
|
+
parameters: {
|
|
546
|
+
id: { type: 'string', required: true, description: 'Finding id returned by second_opinion.' },
|
|
547
|
+
verdict: {
|
|
548
|
+
type: 'string',
|
|
549
|
+
required: true,
|
|
550
|
+
enum: ['confirmed', 'false', 'deferred'],
|
|
551
|
+
description: 'confirmed — you reproduced or proved it; false — you proved it wrong; deferred — real but not worth acting on now.',
|
|
552
|
+
},
|
|
553
|
+
evidence: {
|
|
554
|
+
type: 'string',
|
|
555
|
+
required: true,
|
|
556
|
+
description:
|
|
557
|
+
'What exactly you checked: the command you ran and its result, the file and line you read, the behaviour you observed. ' +
|
|
558
|
+
'Not "looks right" — what you actually did.',
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
output: {
|
|
562
|
+
schema: { type: 'object', additionalProperties: true },
|
|
563
|
+
render: (_args, value) => [{ type: 'text', text: value.text ?? '' }],
|
|
564
|
+
},
|
|
565
|
+
async execute(args) {
|
|
566
|
+
try {
|
|
567
|
+
const { recordVerdict } = await import('./review-log.js')
|
|
568
|
+
const result = await recordVerdict(args.id, args.verdict, args.evidence, 'agent')
|
|
569
|
+
if (result.applied === false) {
|
|
570
|
+
return { text: `Приговор не изменён: по этой находке уже высказался человек (${result.kept.verdict}).` }
|
|
571
|
+
}
|
|
572
|
+
const names = { confirmed: 'подтверждено', false: 'ложная', deferred: 'отложено' }
|
|
573
|
+
return { text: `Записано: ${names[args.verdict]} — ${args.evidence.slice(0, 120)}` }
|
|
574
|
+
} catch (error) {
|
|
575
|
+
return { text: `Записать не удалось: ${String(error?.message ?? error)}` }
|
|
323
576
|
}
|
|
324
577
|
},
|
|
325
578
|
}),
|