@nitra/cursor 5.0.1 → 5.0.3
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/.pi-template/extensions/n-cursor-adr/docs/index.md +31 -178
- package/CHANGELOG.md +12 -0
- package/lib/docs/models.md +32 -0
- package/package.json +1 -1
- package/rules/abie/docs/fix.md +17 -8
- package/rules/abie/js/docs/applies.md +12 -14
- package/rules/abie/js/docs/firebase_hosting.md +18 -13
- package/rules/abie/lib/docs/enabled.md +20 -15
- package/rules/abie/lib/docs/env-dns.md +14 -22
- package/rules/abie/lib/docs/hc-yaml.md +14 -18
- package/rules/abie/lib/docs/http-route.md +10 -30
- package/skills/docgen/js/docgen-batch-omlx.mjs +82 -0
- package/skills/docgen/js/docgen-compare-pi-vs-direct.mjs +95 -0
- package/skills/docgen/js/docgen-extract-anchors.mjs +92 -0
- package/skills/docgen/js/docgen-gen.mjs +114 -12
- package/skills/docgen/js/docgen-prompts.mjs +92 -17
|
@@ -2,43 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## Огляд
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Файл надає інструмент для порівняльного аналізу конфігурації HTTP-маршрутів. Він виконує порівняння кількості `backendRefs` для сервісів `auth-run-hl` та `file-link-hl` у базових маніфестах пакета з кількістю патчів, визначеною в оверлеях. Цей механізм використовується для синхронізації кількості патчів у верхньому рівні з фактичною кількістю посилань у базі. (abie.mdc)
|
|
6
6
|
|
|
7
7
|
## Поведінка
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
3. Враховуються лише документи з `kind: HTTPRoute`. Інші види та структурно некоректні корені дають нульовий внесок.
|
|
12
|
-
4. Для `HTTPRoute` обхід заходить у `spec.rules[].backendRefs[]` і для кожного посилання перевіряє, чи його `name` належить набору спільних cross-namespace сервісів.
|
|
13
|
-
5. Спільними вважаються рівно два сервіси: `auth-run-hl` та `file-link-hl`. Кожне таке посилання збільшує загальний лічильник посилань на 1.
|
|
14
|
-
6. Якщо посилання на спільний сервіс не має `namespace: dev`, додається помилка виду `<rel>: HTTPRoute backendRefs до <name> має містити namespace: dev (abie.mdc)`.
|
|
15
|
-
7. Підсумок по всьому пакету повертається як агрегований лічильник посилань (`refCount`) і список base-помилок (`baseErrors`).
|
|
16
|
-
|
|
17
|
-
Приклад спільного backend-посилання, яке проходить перевірку:
|
|
18
|
-
|
|
19
|
-
```yaml
|
|
20
|
-
kind: HTTPRoute
|
|
21
|
-
spec:
|
|
22
|
-
rules:
|
|
23
|
-
- backendRefs:
|
|
24
|
-
- name: auth-run-hl
|
|
25
|
-
namespace: dev
|
|
26
|
-
```
|
|
9
|
+
ABIE_SHARED_CROSS_NS_BACKEND_NAMES
|
|
10
|
+
Визначає список спільних сервісів, які підлягають аналітиці.
|
|
27
11
|
|
|
28
|
-
|
|
12
|
+
analyzeAbieSharedBackendRefsInPackageK8s
|
|
13
|
+
Збирає кількість посилань на спільні бекенди та порушення вимог до namespace у базових документах HTTPRoute пакета.
|
|
29
14
|
|
|
30
|
-
|
|
31
|
-
- `ABIE_SHARED_CROSS_NS_BACKEND_NAMES` — заморожений перелік імен спільних cross-namespace сервісів (`auth-run-hl`, `file-link-hl`), за якими ведеться підрахунок.
|
|
15
|
+
## Публічний API
|
|
32
16
|
|
|
33
|
-
|
|
17
|
+
ABIE_SHARED_CROSS_NS_BACKEND_NAMES — Формує імена для крос-нішових зв'язків між бекендами. (abie.mdc)
|
|
34
18
|
|
|
35
|
-
|
|
19
|
+
analyzeAbieSharedBackendRefsInPackageK8s — Збирає кількість спільних посилань `backendRefs` та базові помилки з YAML-файлів пакета, ігноруючи неймспейс `dev`. (abie.mdc)
|
|
36
20
|
|
|
37
21
|
## Гарантії поведінки
|
|
38
22
|
|
|
39
|
-
- Read-only:
|
|
40
|
-
-
|
|
41
|
-
- Структурна стійкість: `null`, масиви та поля неочікуваного типу на будь-якому рівні (корінь, `spec`, `rules`, окреме посилання) дають нульовий внесок без винятків.
|
|
42
|
-
- Лічильник рахує тільки посилання на два визначені спільні сервіси; інші backend-сервіси ігноруються.
|
|
43
|
-
- Перелік спільних імен заморожений і не може бути змінений споживачами.
|
|
44
|
-
- Шляхи у повідомленнях нормалізовані до `/`, тож вихід однаковий на POSIX і Windows.
|
|
23
|
+
- Read-only: файл не виконує операцій запису у файлову систему.
|
|
24
|
+
- Не звертається до мережі.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Тимчасовий A/B-batch: docgen Tier 1 через omlx (gemma-4-e2b 4bit на MLX)
|
|
3
|
+
* замість pi/ollama. Перезаписує всі docs/<stem>.md для файлів з sym<4,
|
|
4
|
+
* НЕ ескалює в cloud. Призначення — порівняння якості omlx vs попередньої версії.
|
|
5
|
+
*
|
|
6
|
+
* Запуск: node npm/skills/docgen/js/docgen-batch-omlx.mjs [--limit N] [--from N]
|
|
7
|
+
* --limit N — обробити перші N файлів зі списку sym<4
|
|
8
|
+
* --from N — почати з індексу N (для дозапуску)
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { dirname, join, resolve } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { execSync } from 'node:child_process'
|
|
14
|
+
import { env } from 'node:process'
|
|
15
|
+
import { generateDoc } from './docgen-gen.mjs'
|
|
16
|
+
import { extractFacts } from './docgen-extract.mjs'
|
|
17
|
+
|
|
18
|
+
const ROOT = resolve(fileURLToPath(import.meta.url), '../../../../..')
|
|
19
|
+
|
|
20
|
+
const args = process.argv.slice(2)
|
|
21
|
+
const limitIdx = args.indexOf('--limit')
|
|
22
|
+
const limit = limitIdx !== -1 ? Number(args[limitIdx + 1]) : Infinity
|
|
23
|
+
const fromIdx = args.indexOf('--from')
|
|
24
|
+
const from = fromIdx !== -1 ? Number(args[fromIdx + 1]) : 0
|
|
25
|
+
|
|
26
|
+
env.N_CURSOR_DOCGEN_BACKEND = 'omlx'
|
|
27
|
+
|
|
28
|
+
const scanOut = execSync('node npm/bin/n-cursor.js docgen scan', { cwd: ROOT, encoding: 'utf8' })
|
|
29
|
+
const all = JSON.parse(scanOut)
|
|
30
|
+
|
|
31
|
+
const local = []
|
|
32
|
+
for (const f of all) {
|
|
33
|
+
try {
|
|
34
|
+
const src = readFileSync(join(ROOT, f.sourcePath), 'utf8')
|
|
35
|
+
const facts = extractFacts(src, join(ROOT, f.sourcePath))
|
|
36
|
+
const sym = (facts.internalSymbols ?? []).length
|
|
37
|
+
if (sym < 4) local.push({ ...f, sym })
|
|
38
|
+
} catch {
|
|
39
|
+
/* пропускаємо нечитані */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const slice = local.slice(from, from + limit)
|
|
44
|
+
console.log(`📋 Файлів sym<4 у проєкті: ${local.length}; обробляємо: ${slice.length} (from=${from}, limit=${limit === Infinity ? 'усе' : limit})`)
|
|
45
|
+
console.log(`🤖 Бекенд: omlx → ${env.N_CURSOR_DOCGEN_OMLX_URL ?? 'http://127.0.0.1:8000/v1/chat/completions'}`)
|
|
46
|
+
|
|
47
|
+
const stats = { ok: 0, err: 0, totalMs: 0, scores: [], errors: [] }
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < slice.length; i++) {
|
|
50
|
+
const f = slice[i]
|
|
51
|
+
const t0 = Date.now()
|
|
52
|
+
const pct = Math.round(((i + 1) / slice.length) * 100)
|
|
53
|
+
process.stdout.write(` [${i + 1}/${slice.length} ${pct}%] sym=${f.sym} ${f.sourcePath} ... `)
|
|
54
|
+
try {
|
|
55
|
+
const result = await generateDoc(join(ROOT, f.sourcePath), {
|
|
56
|
+
symThreshold: 999, // не уходити в cloud за sym
|
|
57
|
+
cloudModel: null // повністю вимкнути cloud-fallback навіть при low det-score
|
|
58
|
+
})
|
|
59
|
+
const docAbs = join(ROOT, f.docPath)
|
|
60
|
+
mkdirSync(dirname(docAbs), { recursive: true })
|
|
61
|
+
writeFileSync(docAbs, result.md)
|
|
62
|
+
const ms = Date.now() - t0
|
|
63
|
+
stats.ok++
|
|
64
|
+
stats.totalMs += ms
|
|
65
|
+
stats.scores.push(result.score ?? 0)
|
|
66
|
+
process.stdout.write(`✓ ${Math.round(ms / 1000)}s score=${result.score ?? '?'} tier=${result.tier}\n`)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
stats.err++
|
|
69
|
+
stats.errors.push({ path: f.sourcePath, msg: error.message })
|
|
70
|
+
process.stdout.write(`✗ ${error.message}\n`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const avgScore = stats.scores.length ? Math.round(stats.scores.reduce((a, b) => a + b, 0) / stats.scores.length) : 0
|
|
75
|
+
console.log(`\n${'─'.repeat(60)}`)
|
|
76
|
+
console.log(`✓ OK: ${stats.ok} ✗ Err: ${stats.err}`)
|
|
77
|
+
console.log(` Сумарний час: ${Math.round(stats.totalMs / 1000)}s; середній на файл: ${stats.ok ? Math.round(stats.totalMs / stats.ok / 1000) : 0}s`)
|
|
78
|
+
console.log(` Середній det-score: ${avgScore}`)
|
|
79
|
+
if (stats.errors.length) {
|
|
80
|
+
console.log('Помилки:')
|
|
81
|
+
for (const e of stats.errors) console.log(` - ${e.path}: ${e.msg}`)
|
|
82
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A/B: docgen Tier 1 через pi cli (з omlx-провайдером у ~/.pi/agent/models.json)
|
|
3
|
+
* vs прямий callOmlxMessages (`N_CURSOR_DOCGEN_BACKEND=omlx`).
|
|
4
|
+
*
|
|
5
|
+
* Однаковий 8-сет файлів, однаковий оркестратор (E1+E2+E3+E4), різний backend.
|
|
6
|
+
* Пише в /tmp/docgen-compare/{pi,direct}/<idx>-<stem>.md і збирає метрики.
|
|
7
|
+
*
|
|
8
|
+
* Запуск: node npm/skills/docgen/js/docgen-compare-pi-vs-direct.mjs [--from N] [--limit N]
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'
|
|
11
|
+
import { join, resolve, basename } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { execSync } from 'node:child_process'
|
|
14
|
+
import { env } from 'node:process'
|
|
15
|
+
import { generateDoc } from './docgen-gen.mjs'
|
|
16
|
+
import { extractFacts } from './docgen-extract.mjs'
|
|
17
|
+
|
|
18
|
+
const ROOT = resolve(fileURLToPath(import.meta.url), '../../../../..')
|
|
19
|
+
const TMP = '/tmp/docgen-compare'
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2)
|
|
22
|
+
const limitIdx = args.indexOf('--limit')
|
|
23
|
+
const limit = limitIdx !== -1 ? Number(args[limitIdx + 1]) : 8
|
|
24
|
+
const fromIdx = args.indexOf('--from')
|
|
25
|
+
const from = fromIdx !== -1 ? Number(args[fromIdx + 1]) : 1
|
|
26
|
+
|
|
27
|
+
const scanOut = execSync('node npm/bin/n-cursor.js docgen scan', { cwd: ROOT, encoding: 'utf8' })
|
|
28
|
+
const all = JSON.parse(scanOut)
|
|
29
|
+
|
|
30
|
+
const local = []
|
|
31
|
+
for (const f of all) {
|
|
32
|
+
try {
|
|
33
|
+
const src = readFileSync(join(ROOT, f.sourcePath), 'utf8')
|
|
34
|
+
const facts = extractFacts(src, join(ROOT, f.sourcePath))
|
|
35
|
+
const sym = (facts.internalSymbols ?? []).length
|
|
36
|
+
if (sym < 4) local.push({ ...f, sym })
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
const slice = local.slice(from, from + limit)
|
|
40
|
+
|
|
41
|
+
mkdirSync(join(TMP, 'pi'), { recursive: true })
|
|
42
|
+
mkdirSync(join(TMP, 'direct'), { recursive: true })
|
|
43
|
+
|
|
44
|
+
async function runBackendAsync(kind) {
|
|
45
|
+
if (kind === 'direct') env.N_CURSOR_DOCGEN_BACKEND = 'omlx'
|
|
46
|
+
else delete env.N_CURSOR_DOCGEN_BACKEND
|
|
47
|
+
const out = { ok: 0, err: 0, totalMs: 0, scores: [], lengths: [], errors: [], times: [] }
|
|
48
|
+
console.log(`\n══════ Backend: ${kind} ══════`)
|
|
49
|
+
for (let i = 0; i < slice.length; i++) {
|
|
50
|
+
const f = slice[i]
|
|
51
|
+
const t0 = Date.now()
|
|
52
|
+
const stem = basename(f.sourcePath).replace(/\.[^.]+$/, '')
|
|
53
|
+
const destFile = join(TMP, kind, `${String(i + 1).padStart(2, '0')}-${stem}.md`)
|
|
54
|
+
process.stdout.write(` [${i + 1}/${slice.length}] sym=${f.sym} ${f.sourcePath} ... `)
|
|
55
|
+
try {
|
|
56
|
+
const r = await generateDoc(join(ROOT, f.sourcePath), { symThreshold: 999, cloudModel: null })
|
|
57
|
+
writeFileSync(destFile, r.md)
|
|
58
|
+
const ms = Date.now() - t0
|
|
59
|
+
out.ok++
|
|
60
|
+
out.totalMs += ms
|
|
61
|
+
out.times.push(ms)
|
|
62
|
+
out.scores.push(r.score ?? 0)
|
|
63
|
+
out.lengths.push(r.md.length)
|
|
64
|
+
process.stdout.write(`✓ ${Math.round(ms / 1000)}s score=${r.score ?? '?'} chars=${r.md.length}\n`)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
out.err++
|
|
67
|
+
out.errors.push({ path: f.sourcePath, msg: error.message })
|
|
68
|
+
process.stdout.write(`✗ ${error.message}\n`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const direct = await runBackendAsync('direct')
|
|
75
|
+
const pi = await runBackendAsync('pi')
|
|
76
|
+
|
|
77
|
+
function avg(a) { return a.length ? Math.round(a.reduce((x, y) => x + y, 0) / a.length) : 0 }
|
|
78
|
+
function median(a) {
|
|
79
|
+
if (!a.length) return 0
|
|
80
|
+
const s = [...a].sort((x, y) => x - y)
|
|
81
|
+
return s[Math.floor(s.length / 2)]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const report = {
|
|
85
|
+
files: slice.map(f => f.sourcePath),
|
|
86
|
+
direct: { ok: direct.ok, err: direct.err, avgMs: avg(direct.times), medianMs: median(direct.times), avgScore: avg(direct.scores), avgChars: avg(direct.lengths), totalSec: Math.round(direct.totalMs / 1000) },
|
|
87
|
+
pi: { ok: pi.ok, err: pi.err, avgMs: avg(pi.times), medianMs: median(pi.times), avgScore: avg(pi.scores), avgChars: avg(pi.lengths), totalSec: Math.round(pi.totalMs / 1000) }
|
|
88
|
+
}
|
|
89
|
+
writeFileSync(join(TMP, 'report.json'), JSON.stringify(report, null, 2))
|
|
90
|
+
|
|
91
|
+
console.log(`\n${'─'.repeat(60)}\nA/B SUMMARY (${slice.length} файлів, той самий оркестратор)\n${'─'.repeat(60)}`)
|
|
92
|
+
console.log(`Backend | ok | err | avg s | median s | avg score | avg chars | total s`)
|
|
93
|
+
console.log(`direct (curl) | ${direct.ok} | ${direct.err} | ${Math.round(report.direct.avgMs / 1000)} | ${Math.round(report.direct.medianMs / 1000)} | ${report.direct.avgScore} | ${report.direct.avgChars} | ${report.direct.totalSec}`)
|
|
94
|
+
console.log(`pi cli | ${pi.ok} | ${pi.err} | ${Math.round(report.pi.avgMs / 1000)} | ${Math.round(report.pi.medianMs / 1000)} | ${report.pi.avgScore} | ${report.pi.avgChars} | ${report.pi.totalSec}`)
|
|
95
|
+
console.log(`\nФайли: ${TMP}/{direct,pi}/<idx>-<stem>.md\nReport: ${TMP}/report.json`)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (Fact-anchoring): детермінований витяг «анкорів» — конкретних фрагментів
|
|
3
|
+
* з коду, які LLM зобовʼязана згадати в документації, щоб не зісковзнути на
|
|
4
|
+
* generic-фрази.
|
|
5
|
+
*
|
|
6
|
+
* Категорії анкорів:
|
|
7
|
+
* - urls : усі https?://… у вихідному коді
|
|
8
|
+
* - magicStrings : export const X = '…' з непорожнім value (≤120 символів)
|
|
9
|
+
* - errorMarkers : суфікси повідомлень про помилки виду `(rule.mdc)`
|
|
10
|
+
* - configRefs : посилання на .json-конфіги проєкту (.n-cursor.json, …)
|
|
11
|
+
* - examples : ```…```-блоки у file-header JSDoc (першому коментарі файла)
|
|
12
|
+
*
|
|
13
|
+
* Всі регулярки — на сирому src без AST: дешево, безпечно, без false-positive
|
|
14
|
+
* критичної ваги (надмір — менша проблема, ніж пропуск).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const URL_RE = /https?:\/\/[^\s'"`)<>]+/g
|
|
18
|
+
const EXPORT_CONST_RE = /export\s+const\s+([A-Z][A-Z0-9_]+)\s*=\s*(['"`])([^'"`]+)\2/g
|
|
19
|
+
const ERROR_MARKER_RE = /\(([a-z][\w-]*\.mdc)\)/g
|
|
20
|
+
const CONFIG_REF_RE = /\b(\.[a-z][\w.-]*\.json)\b/gi
|
|
21
|
+
const FILE_HEADER_RE = /^\s*\/\*\*([\s\S]*?)\*\//
|
|
22
|
+
const CODE_BLOCK_RE = /```[a-z]*\n([\s\S]*?)\n\s*\*?\s*```/g
|
|
23
|
+
|
|
24
|
+
/** Dedup масив, зберігаючи порядок появи. */
|
|
25
|
+
function uniq(arr) {
|
|
26
|
+
const seen = new Set()
|
|
27
|
+
const out = []
|
|
28
|
+
for (const x of arr) {
|
|
29
|
+
if (!seen.has(x)) {
|
|
30
|
+
seen.add(x)
|
|
31
|
+
out.push(x)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Витягує анкори з вихідного коду файла.
|
|
39
|
+
* @param {string} src
|
|
40
|
+
* @returns {{
|
|
41
|
+
* urls: string[],
|
|
42
|
+
* magicStrings: Array<{name:string, value:string}>,
|
|
43
|
+
* errorMarkers: string[],
|
|
44
|
+
* configRefs: string[],
|
|
45
|
+
* examples: string[]
|
|
46
|
+
* }}
|
|
47
|
+
*/
|
|
48
|
+
export function extractAnchors(src) {
|
|
49
|
+
const urls = uniq([...src.matchAll(URL_RE)].map(m => m[0]))
|
|
50
|
+
|
|
51
|
+
const magicStrings = []
|
|
52
|
+
const seenNames = new Set()
|
|
53
|
+
for (const m of src.matchAll(EXPORT_CONST_RE)) {
|
|
54
|
+
const name = m[1]
|
|
55
|
+
const value = m[3]
|
|
56
|
+
if (!seenNames.has(name) && value.length <= 120) {
|
|
57
|
+
seenNames.add(name)
|
|
58
|
+
magicStrings.push({ name, value })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const errorMarkers = uniq([...src.matchAll(ERROR_MARKER_RE)].map(m => m[1]))
|
|
63
|
+
const configRefs = uniq([...src.matchAll(CONFIG_REF_RE)].map(m => m[1]))
|
|
64
|
+
|
|
65
|
+
// Витягуємо code-block приклади тільки з file-header — там автор зазвичай показує контракт.
|
|
66
|
+
const headerMatch = src.match(FILE_HEADER_RE)
|
|
67
|
+
const examples = headerMatch ? uniq([...headerMatch[1].matchAll(CODE_BLOCK_RE)].map(m => m[1].trim())) : []
|
|
68
|
+
|
|
69
|
+
return { urls, magicStrings, errorMarkers, configRefs, examples }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Форматує анкори у компактний текст для system-промпта.
|
|
74
|
+
* Якщо анкорів немає взагалі — повертає порожній рядок (системний блок про
|
|
75
|
+
* анкори не додається, щоб не вводити LLM в оману «обовʼязковими» полями).
|
|
76
|
+
* @param {ReturnType<typeof extractAnchors>} a
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function anchorsToPrompt(a) {
|
|
80
|
+
const blocks = []
|
|
81
|
+
if (a.urls.length) blocks.push(`URLs (згадай у тексті): ${a.urls.join(', ')}`)
|
|
82
|
+
if (a.magicStrings.length) {
|
|
83
|
+
blocks.push(
|
|
84
|
+
`Експортовані константи-рядки (наведи назву і призначення): ${a.magicStrings.map(s => `${s.name}=${JSON.stringify(s.value)}`).join('; ')}`
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
if (a.errorMarkers.length) blocks.push(`Маркери повідомлень (згадай у Поведінці): ${a.errorMarkers.map(m => `(${m})`).join(', ')}`)
|
|
88
|
+
if (a.configRefs.length) blocks.push(`Конфіги, на які спирається код: ${a.configRefs.join(', ')}`)
|
|
89
|
+
if (a.examples.length) blocks.push(`Приклади з документації автора (наведи дослівно у Поведінці):\n${a.examples.map(e => '```\n' + e + '\n```').join('\n')}`)
|
|
90
|
+
if (!blocks.length) return ''
|
|
91
|
+
return `АНКОРИ ДО ОБОВ'ЯЗКОВОГО ВКЛЮЧЕННЯ:\n${blocks.join('\n')}`
|
|
92
|
+
}
|
|
@@ -5,7 +5,8 @@ import { spawnSync } from 'node:child_process'
|
|
|
5
5
|
import { env } from 'node:process'
|
|
6
6
|
import { resolveModel } from '../../../lib/models.mjs'
|
|
7
7
|
import { extractFacts } from './docgen-extract.mjs'
|
|
8
|
-
import {
|
|
8
|
+
import { extractAnchors } from './docgen-extract-anchors.mjs'
|
|
9
|
+
import { oneShotMessages, sectionMessages, criticMessages, refineMessages, guaranteesFromMarkers } from './docgen-prompts.mjs'
|
|
9
10
|
|
|
10
11
|
const QUALITY_THRESHOLD = 70
|
|
11
12
|
|
|
@@ -89,8 +90,62 @@ function scoreDoc(md, facts) {
|
|
|
89
90
|
return { score: Math.max(0, score), issues }
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
93
|
+
/**
|
|
94
|
+
* omlx-бекенд: справжні OpenAI-сумісні messages (system+user збереженi).
|
|
95
|
+
* Вмикається `N_CURSOR_DOCGEN_BACKEND=omlx`.
|
|
96
|
+
* URL: `N_CURSOR_DOCGEN_OMLX_URL` або http://127.0.0.1:8000/v1/chat/completions.
|
|
97
|
+
* Модель: переданий `model`, потім `N_CURSOR_DOCGEN_OMLX_MODEL`, потім дефолт.
|
|
98
|
+
*/
|
|
99
|
+
function callOmlxMessages(messages, model, timeoutMs, temperature = 0.2) {
|
|
100
|
+
const url = env.N_CURSOR_DOCGEN_OMLX_URL ?? 'http://127.0.0.1:8000/v1/chat/completions'
|
|
101
|
+
const m = model || env.N_CURSOR_DOCGEN_OMLX_MODEL || 'mlx-community--gemma-4-e2b-it-4bit'
|
|
102
|
+
const body = JSON.stringify({
|
|
103
|
+
model: m,
|
|
104
|
+
messages,
|
|
105
|
+
max_tokens: 4096,
|
|
106
|
+
temperature
|
|
107
|
+
})
|
|
108
|
+
// Ретраїмо лише transient curl-помилки (18 = transfer closed, 56 = recv failure, 52 = empty reply).
|
|
109
|
+
const TRANSIENT_CURL_CODES = new Set([18, 52, 56])
|
|
110
|
+
let lastErr
|
|
111
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
112
|
+
const r = spawnSync(
|
|
113
|
+
'curl',
|
|
114
|
+
['-sS', '-X', 'POST', url, '-H', 'Content-Type: application/json', '-H', 'Connection: close', '--max-time', String(Math.ceil(timeoutMs / 1000)), '--data-binary', '@-'],
|
|
115
|
+
{ input: body, encoding: 'utf8', timeout: timeoutMs + 5000 }
|
|
116
|
+
)
|
|
117
|
+
if (r.error) {
|
|
118
|
+
lastErr = new Error(`omlx curl error: ${r.error.message}`)
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
if (r.status !== 0) {
|
|
122
|
+
if (TRANSIENT_CURL_CODES.has(r.status) && attempt < 3) {
|
|
123
|
+
lastErr = new Error(`omlx curl exit ${r.status} (transient, retry ${attempt})`)
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`omlx curl exit ${r.status}: ${r.stderr?.slice(0, 300) ?? ''}`)
|
|
127
|
+
}
|
|
128
|
+
let j
|
|
129
|
+
try { j = JSON.parse(r.stdout) } catch { throw new Error(`omlx bad json: ${r.stdout?.slice(0, 200) ?? ''}`) }
|
|
130
|
+
if (j.error) throw new Error(`omlx api: ${JSON.stringify(j.error).slice(0, 300)}`)
|
|
131
|
+
const content = j.choices?.[0]?.message?.content?.trim() ?? ''
|
|
132
|
+
if (!content) {
|
|
133
|
+
const finish = j.choices?.[0]?.finish_reason
|
|
134
|
+
throw new Error(`omlx empty content (finish=${finish})`)
|
|
135
|
+
}
|
|
136
|
+
return content
|
|
137
|
+
}
|
|
138
|
+
throw lastErr ?? new Error('omlx unknown failure')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Універсальний виклик LLM за повним messages-масивом.
|
|
143
|
+
* - omlx: шле messages напряму (system збережено)
|
|
144
|
+
* - pi: конкатенує message.content (pi приймає лише plain prompt)
|
|
145
|
+
*/
|
|
146
|
+
function callLlm(messages, model, timeoutMs, temperature = 0.2) {
|
|
147
|
+
if (env.N_CURSOR_DOCGEN_BACKEND === 'omlx') return callOmlxMessages(messages, model, timeoutMs, temperature)
|
|
148
|
+
const prompt = messages.map(m => m.content).join('\n\n')
|
|
94
149
|
const modelArgs = model ? ['--model', model] : []
|
|
95
150
|
const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
|
|
96
151
|
encoding: 'utf8',
|
|
@@ -101,9 +156,30 @@ function callPi(prompt, model, timeoutMs) {
|
|
|
101
156
|
return r.stdout?.trim() ?? ''
|
|
102
157
|
}
|
|
103
158
|
|
|
104
|
-
/**
|
|
159
|
+
/**
|
|
160
|
+
* E2 — один цикл critique→refine на секцію.
|
|
161
|
+
* Повертає або уточнену чорнетку, або оригінал якщо критик повідомив NONE.
|
|
162
|
+
*/
|
|
163
|
+
function critiqueRefineSection(sectionKey, draft, facts, anchors, model, timeoutMs) {
|
|
164
|
+
const critique = callLlm(criticMessages(sectionKey, draft, facts, anchors), model, timeoutMs).trim()
|
|
165
|
+
if (!critique || /^\s*NONE\s*$/i.test(critique) || critique.length < 12) return draft
|
|
166
|
+
const refined = callLlm(refineMessages(sectionKey, draft, critique, facts, anchors), model, timeoutMs).trim()
|
|
167
|
+
return stripSignatures(stripSection(refined)) || draft
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Чи треба refine для секції API: тільки якщо є >1 експорту і всі desc-и порожні
|
|
172
|
+
* (саме там модель схильна писати «застосовує логіку до файлу»).
|
|
173
|
+
*/
|
|
174
|
+
function apiNeedsRefine(facts) {
|
|
175
|
+
const exps = facts.exports ?? []
|
|
176
|
+
if (exps.length <= 1) return false
|
|
177
|
+
return exps.every(e => !e.desc)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** One-shot: один виклик LLM на весь документ. */
|
|
105
181
|
function piOneShot(facts, src, model, timeoutMs = 120_000) {
|
|
106
|
-
const text =
|
|
182
|
+
const text = callLlm(oneShotMessages(facts, src), model, timeoutMs)
|
|
107
183
|
let md = stripSignatures(stripSection(text))
|
|
108
184
|
if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
|
|
109
185
|
return { md: md + '\n', genTok: 0 }
|
|
@@ -129,12 +205,19 @@ function assemble(stem, sections) {
|
|
|
129
205
|
* Orchestrated: N окремих pi-викликів, по одному на секцію.
|
|
130
206
|
* Код потрапляє лише в `behavior`; решта секцій — на мінімальному факт-листі.
|
|
131
207
|
*/
|
|
132
|
-
function piOrchestrated(facts, src, model, timeoutMs) {
|
|
208
|
+
function piOrchestrated(facts, src, model, timeoutMs, { anchors = null, temperature = 0.2 } = {}) {
|
|
133
209
|
const sections = {}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
210
|
+
const anc = anchors ?? extractAnchors(src)
|
|
211
|
+
// E3: «Гарантії» — детермінований шаблон з markers (0 LLM-запитів, 0 generic-фраз)
|
|
212
|
+
sections.guarantees = guaranteesFromMarkers(facts)
|
|
213
|
+
for (const s of sectionMessages(facts, src, anc)) {
|
|
214
|
+
if (s.key === 'guarantees') continue // вже згенеровано детерміновано
|
|
215
|
+
let draft = stripSignatures(stripSection(callLlm(s.messages, model, timeoutMs, temperature)))
|
|
216
|
+
// E2 + E3: critique→refine лише для секцій, де gemma-4 зриває на generic
|
|
217
|
+
if (s.key === 'overview' || (s.key === 'api' && apiNeedsRefine(facts))) {
|
|
218
|
+
draft = critiqueRefineSection(s.key, draft, facts, anc, model, timeoutMs)
|
|
219
|
+
}
|
|
220
|
+
sections[s.key] = draft
|
|
138
221
|
}
|
|
139
222
|
return { md: assemble(basename(facts.relPath), sections), genTok: 0 }
|
|
140
223
|
}
|
|
@@ -181,10 +264,11 @@ export async function generateDoc(
|
|
|
181
264
|
// Tier 1: pi orchestrated (секція за секцією), timeout на секцію = LOCAL_TIMEOUT_MS
|
|
182
265
|
// facts.unsupported → one-shot (структура файлу нестандартна)
|
|
183
266
|
let r
|
|
267
|
+
const anchors = facts.unsupported ? null : extractAnchors(src)
|
|
184
268
|
try {
|
|
185
269
|
r = facts.unsupported
|
|
186
270
|
? piOneShot(facts, src, model, LOCAL_TIMEOUT_MS)
|
|
187
|
-
: piOrchestrated(facts, src, model, LOCAL_TIMEOUT_MS)
|
|
271
|
+
: piOrchestrated(facts, src, model, LOCAL_TIMEOUT_MS, { anchors })
|
|
188
272
|
} catch (error) {
|
|
189
273
|
if (cloudModel) {
|
|
190
274
|
const r2 = piOneShot(facts, src, cloudModel)
|
|
@@ -194,7 +278,25 @@ export async function generateDoc(
|
|
|
194
278
|
}
|
|
195
279
|
|
|
196
280
|
// Stage 2.5: детермінований скоринг (0 токенів) — gate перед Tier 2
|
|
197
|
-
|
|
281
|
+
let { score: detScore, issues: detIssues } = scoreDoc(r.md, facts)
|
|
282
|
+
|
|
283
|
+
// E4: best-of-N. Якщо score нижчий за threshold і немає cloud-fallback — спроба
|
|
284
|
+
// ще раз з вищою температурою, керуємо через env (повторні прогони коштовні).
|
|
285
|
+
if (detScore < threshold && !cloudModel && !facts.unsupported && env.N_CURSOR_DOCGEN_BEST_OF !== '0') {
|
|
286
|
+
try {
|
|
287
|
+
const r2 = piOrchestrated(facts, src, model, LOCAL_TIMEOUT_MS, { anchors, temperature: 0.5 })
|
|
288
|
+
const s2 = scoreDoc(r2.md, facts)
|
|
289
|
+
if (s2.score > detScore) {
|
|
290
|
+
r = r2
|
|
291
|
+
detScore = s2.score
|
|
292
|
+
detIssues = [...s2.issues, 'best-of-2:retry-won']
|
|
293
|
+
} else {
|
|
294
|
+
detIssues = [...detIssues, 'best-of-2:retry-lost']
|
|
295
|
+
}
|
|
296
|
+
} catch (error) {
|
|
297
|
+
detIssues = [...detIssues, `best-of-2:retry-error: ${error.message}`]
|
|
298
|
+
}
|
|
299
|
+
}
|
|
198
300
|
|
|
199
301
|
if (detScore < threshold && cloudModel) {
|
|
200
302
|
const r2 = piOneShot(facts, src, cloudModel)
|