patina-cli 3.11.0 → 4.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.
Files changed (193) hide show
  1. package/.patina.default.yaml +29 -29
  2. package/CHANGELOG.md +53 -0
  3. package/NOTICE +21 -0
  4. package/README.md +117 -224
  5. package/README_JA.md +134 -77
  6. package/README_KR.md +132 -74
  7. package/README_ZH.md +137 -80
  8. package/SKILL.md +11 -20
  9. package/artifacts/rebaseline-2025/README.md +147 -0
  10. package/artifacts/rebaseline-2025/human-controls.public.jsonl +250 -0
  11. package/artifacts/rebaseline-2025/intake.example.jsonl +2 -0
  12. package/artifacts/rebaseline-2025/intake.local.example.jsonl +25 -0
  13. package/artifacts/rebaseline-2025/prompts.template.jsonl +7 -0
  14. package/artifacts/rebaseline-2025/sources.ko-public.jsonl +39 -0
  15. package/assets/brand/patina-badge.svg +18 -0
  16. package/assets/brand/patina-mark.svg +8 -0
  17. package/assets/demo/README.md +79 -0
  18. package/core/scoring.md +12 -12
  19. package/core/standalone-prompt.md +3 -1
  20. package/core/stylometry.md +93 -22
  21. package/docs/API.md +1554 -0
  22. package/docs/AUTHENTICATION.md +50 -26
  23. package/docs/AUTHENTICATION_KR.md +54 -29
  24. package/docs/BRANDING.md +9 -8
  25. package/docs/CLI.md +55 -14
  26. package/docs/COOKBOOK.md +8 -21
  27. package/docs/DEMO.md +32 -5
  28. package/docs/EXIT-CODES.md +2 -3
  29. package/docs/FALSE-POSITIVES.md +63 -0
  30. package/docs/FAQ.md +9 -1
  31. package/docs/FAQ_KR.md +3 -1
  32. package/docs/FLAG-PARITY.md +33 -47
  33. package/docs/ISSUE-WAVES.md +57 -0
  34. package/docs/PATTERNS-EN.md +67 -3
  35. package/docs/PATTERNS-JA.md +68 -2
  36. package/docs/PATTERNS-KO.md +70 -7
  37. package/docs/PATTERNS-ZH.md +67 -3
  38. package/docs/PATTERNS.md +5 -5
  39. package/docs/RESEARCH-DOCS-PLATFORM.md +54 -0
  40. package/docs/ROADMAP.md +46 -66
  41. package/docs/TRANSLATIONESE-KO.md +51 -0
  42. package/docs/audits/2026-05-deep-research.md +3 -1
  43. package/docs/benchmarks/README.md +51 -0
  44. package/docs/benchmarks/detector-comparison.json +69 -9
  45. package/docs/benchmarks/detector-comparison.md +10 -5
  46. package/docs/benchmarks/katfish-ko-latest.json +657 -0
  47. package/docs/benchmarks/katfish-ko-latest.md +77 -0
  48. package/docs/benchmarks/latest.json +1183 -108
  49. package/docs/benchmarks/latest.md +84 -60
  50. package/docs/benchmarks/lexicon-freshness-en-2026-05-22.json +1121 -0
  51. package/docs/benchmarks/lexicon-freshness-en-2026-05-22.md +136 -0
  52. package/docs/benchmarks/rebaseline-latest.json +381 -0
  53. package/docs/benchmarks/rebaseline-latest.md +121 -0
  54. package/docs/benchmarks/register-stratified-latest.json +164 -0
  55. package/docs/benchmarks/register-stratified-latest.md +99 -0
  56. package/docs/benchmarks/register-stratified.md +43 -0
  57. package/docs/integrations/github-action.md +44 -11
  58. package/docs/integrations/playground.md +58 -0
  59. package/docs/integrations/pre-commit.md +5 -5
  60. package/docs/integrations/release.md +5 -3
  61. package/docs/integrations/static-sites.md +83 -0
  62. package/docs/research/2025-rebaseline-plan.md +71 -2
  63. package/docs/research/2026-rebaseline.md +102 -0
  64. package/docs/research/adversarial-mps.md +41 -0
  65. package/docs/research/ai-human-metrics.md +35 -23
  66. package/docs/research/human-eval-panel.md +42 -0
  67. package/docs/research/judge-agreement.md +24 -0
  68. package/docs/research/ko-2025-corpus-sources.md +135 -0
  69. package/docs/research/lexicon-freshness-audit.md +64 -0
  70. package/docs/research/zh-ja-lexicon-calibration.md +60 -0
  71. package/docs/social/patina-launch-copy.md +173 -100
  72. package/docs/social/patina-launch-execution.md +94 -0
  73. package/docs/social/patina-launch-korean-first.md +83 -0
  74. package/docs/social/signs-of-ai-writing.md +26 -0
  75. package/docs/social/signs-of-ai-writing_KR.md +26 -0
  76. package/lexicon/ai-en.md +21 -24
  77. package/lexicon/ai-ja.md +158 -0
  78. package/lexicon/ai-ko.md +9 -9
  79. package/lexicon/ai-zh.md +158 -0
  80. package/lexicon/provenance/ai-en.json +970 -0
  81. package/lexicon/provenance/ai-ja.json +542 -0
  82. package/lexicon/provenance/ai-ko.json +866 -0
  83. package/lexicon/provenance/ai-zh.json +542 -0
  84. package/package.json +49 -8
  85. package/patterns/en-communication.md +5 -0
  86. package/patterns/en-content.md +5 -0
  87. package/patterns/en-filler.md +5 -0
  88. package/patterns/en-language.md +29 -1
  89. package/patterns/en-structure.md +5 -0
  90. package/patterns/en-style.md +5 -0
  91. package/patterns/en-viral-hook.md +42 -2
  92. package/patterns/ja-communication.md +5 -0
  93. package/patterns/ja-content.md +5 -0
  94. package/patterns/ja-filler.md +5 -0
  95. package/patterns/ja-language.md +33 -1
  96. package/patterns/ja-structure.md +12 -0
  97. package/patterns/ja-style.md +5 -0
  98. package/patterns/ja-viral-hook.md +41 -2
  99. package/patterns/ko-communication.md +5 -0
  100. package/patterns/ko-content.md +5 -0
  101. package/patterns/ko-filler.md +5 -0
  102. package/patterns/ko-language.md +33 -1
  103. package/patterns/ko-structure.md +25 -6
  104. package/patterns/ko-style.md +5 -0
  105. package/patterns/ko-viral-hook.md +38 -2
  106. package/patterns/zh-communication.md +5 -0
  107. package/patterns/zh-content.md +5 -0
  108. package/patterns/zh-filler.md +5 -0
  109. package/patterns/zh-language.md +37 -1
  110. package/patterns/zh-structure.md +12 -0
  111. package/patterns/zh-style.md +5 -0
  112. package/patterns/zh-viral-hook.md +38 -2
  113. package/playground/README.md +55 -0
  114. package/playground/analytics.js +4 -0
  115. package/playground/analyzer.js +883 -0
  116. package/playground/app.js +157 -0
  117. package/playground/data/lexicons.js +343 -0
  118. package/playground/index.html +138 -0
  119. package/playground/styles.css +267 -0
  120. package/profiles/namuwiki.md +111 -0
  121. package/scripts/adversarial-mps-report.mjs +201 -0
  122. package/scripts/badge-json.mjs +79 -0
  123. package/scripts/benchmark-report.mjs +56 -9
  124. package/scripts/check-release-metadata.mjs +0 -2
  125. package/scripts/detector-comparison.mjs +7 -7
  126. package/scripts/generate-playground-data.mjs +77 -0
  127. package/scripts/katfish-calibration.mjs +464 -0
  128. package/scripts/lexicon-freshness.mjs +485 -0
  129. package/scripts/lint.mjs +1 -1
  130. package/scripts/precommit-score.mjs +4 -3
  131. package/scripts/prose-score.mjs +81 -5
  132. package/scripts/rebaseline-intake.mjs +242 -0
  133. package/scripts/rebaseline-score.mjs +268 -0
  134. package/scripts/rebaseline-summary.mjs +773 -0
  135. package/scripts/rebaseline-web-collect.mjs +410 -0
  136. package/scripts/update-benchmark-ranges.mjs +1 -0
  137. package/src/api.js +69 -105
  138. package/src/auth.js +50 -2
  139. package/src/backends/claude-cli.js +19 -4
  140. package/src/backends/codex-cli.js +19 -3
  141. package/src/backends/contract.js +230 -1
  142. package/src/backends/gemini-cli.js +18 -5
  143. package/src/backends/index.js +87 -12
  144. package/src/backends/kimi-cli.js +161 -0
  145. package/src/cli.js +577 -567
  146. package/src/commands/doctor.js +2 -2
  147. package/src/config.js +29 -0
  148. package/src/errors.js +53 -1
  149. package/src/features/discourse-tells.js +68 -0
  150. package/src/features/index.js +82 -8
  151. package/src/features/lexicon.js +40 -6
  152. package/src/features/markup-leakage.js +69 -0
  153. package/src/features/segment.js +41 -0
  154. package/src/features/signal-strength.js +81 -0
  155. package/src/features/stylometry.js +231 -1
  156. package/src/features/translationese.js +127 -0
  157. package/src/loader.js +76 -0
  158. package/src/logger.js +22 -23
  159. package/src/model-defaults.js +55 -0
  160. package/src/ouroboros.js +31 -0
  161. package/src/output.js +102 -90
  162. package/src/prompt-builder.js +103 -68
  163. package/src/providers.js +51 -4
  164. package/src/scoring.js +210 -2
  165. package/src/security.js +75 -0
  166. package/tests/fixtures/live-quality/en/public-docs-01.md +26 -0
  167. package/tests/fixtures/live-quality/ko/public-docs-01.md +26 -0
  168. package/tests/fixtures/suspect-zones/expected-ranges.json +207 -16
  169. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-04-lexicon.md +11 -0
  170. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-04-lexicon-cold.md +11 -0
  171. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-02.md +4 -5
  172. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-07-ko-diagnostic.md +11 -0
  173. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-04-lexicon.md +11 -0
  174. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-04-lexicon-cold.md +11 -0
  175. package/tests/quality/README.md +188 -11
  176. package/tests/quality/adversarial-mps/fixtures.jsonl +10 -0
  177. package/tests/quality/benchmark.mjs +39 -1
  178. package/tests/quality/dogfood.mjs +5 -3
  179. package/tests/quality/live-fixtures.jsonl +2 -0
  180. package/tests/quality/live-quality.mjs +596 -0
  181. package/tests/quality/ranking-metrics.mjs +136 -0
  182. package/tests/quality/rebaseline-manifest.example.jsonl +5 -0
  183. package/vercel.json +53 -0
  184. package/SKILL-MAX.md +0 -455
  185. package/docs/internal/HARNESS.md +0 -14
  186. package/docs/internal/README.md +0 -14
  187. package/docs/internal/WARP.md +0 -23
  188. package/patina-max/SKILL.md +0 -523
  189. package/patina-max/composite.py +0 -457
  190. package/src/cache.js +0 -106
  191. package/src/commands/init.js +0 -208
  192. package/src/manifest.js +0 -162
  193. package/src/max-mode.js +0 -207
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env node
2
+ // Fetch public Korean web pages into the private 2025+ rebaseline workspace.
3
+ //
4
+ // The output intentionally keeps full text in an ignored private JSONL file.
5
+ // Run scripts/rebaseline-score.mjs afterward to publish only hash/metadata and
6
+ // deterministic outcome fields.
7
+
8
+ import { createHash } from 'node:crypto';
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { dirname, relative, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ import { MATRIX } from './rebaseline-summary.mjs';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const REPO_ROOT = resolve(__dirname, '..');
17
+
18
+ export const DEFAULT_SOURCE_INPUT = 'artifacts/rebaseline-2025/sources.ko-public.jsonl';
19
+ export const DEFAULT_PRIVATE_OUTPUT = 'artifacts/rebaseline-2025/private/web-human-controls.generated.private.jsonl';
20
+ export const DEFAULT_MIN_CHARS = 90;
21
+ export const DEFAULT_MAX_CHARS = 700;
22
+ export const DEFAULT_MAX_PER_SOURCE = 8;
23
+ export const DEFAULT_TARGET_PER_REGISTER = 50;
24
+ export const DEFAULT_DELAY_MS = 250;
25
+
26
+ const HANGUL_RE = /[\u3131-\u318e\uac00-\ud7a3]/gu;
27
+ const BAD_BOILERPLATE_RE = /(본문듣기|말하기 속도|글자크기|인쇄하기|공유하기|목록|검색|닫기|저작권자|무단 전재|재배포 금지|자료출처|문의:|페이스북|트위터|카카오|Copyright|All rights reserved|View all|Apply now)/iu;
28
+
29
+ export function parseArgs(argv = process.argv.slice(2)) {
30
+ const args = {
31
+ input: DEFAULT_SOURCE_INPUT,
32
+ output: DEFAULT_PRIVATE_OUTPUT,
33
+ minChars: DEFAULT_MIN_CHARS,
34
+ maxChars: DEFAULT_MAX_CHARS,
35
+ maxPerSource: DEFAULT_MAX_PER_SOURCE,
36
+ targetPerRegister: DEFAULT_TARGET_PER_REGISTER,
37
+ delayMs: DEFAULT_DELAY_MS,
38
+ collectedAt: new Date().toISOString().slice(0, 10),
39
+ dryRun: false,
40
+ json: false,
41
+ help: false,
42
+ };
43
+
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const arg = argv[i];
46
+ if (arg === '--input') args.input = argv[++i];
47
+ else if (arg === '--output') args.output = argv[++i];
48
+ else if (arg === '--min-chars') args.minChars = Number(argv[++i]);
49
+ else if (arg === '--max-chars') args.maxChars = Number(argv[++i]);
50
+ else if (arg === '--max-per-source') args.maxPerSource = Number(argv[++i]);
51
+ else if (arg === '--target-per-register') args.targetPerRegister = Number(argv[++i]);
52
+ else if (arg === '--delay-ms') args.delayMs = Number(argv[++i]);
53
+ else if (arg === '--collected-at') args.collectedAt = argv[++i];
54
+ else if (arg === '--dry-run') args.dryRun = true;
55
+ else if (arg === '--json') args.json = true;
56
+ else if (arg === '--help' || arg === '-h') args.help = true;
57
+ else throw new Error(`Unknown argument: ${arg}`);
58
+ }
59
+
60
+ for (const [name, value] of Object.entries({
61
+ minChars: args.minChars,
62
+ maxChars: args.maxChars,
63
+ maxPerSource: args.maxPerSource,
64
+ targetPerRegister: args.targetPerRegister,
65
+ delayMs: args.delayMs,
66
+ })) {
67
+ if (!Number.isFinite(value) || value < 0) throw new Error(`${name} must be a non-negative number`);
68
+ }
69
+ if (args.minChars > args.maxChars) throw new Error('minChars cannot exceed maxChars');
70
+ if (Number.isNaN(Date.parse(args.collectedAt))) throw new Error('collected-at must be an ISO-like date');
71
+
72
+ return args;
73
+ }
74
+
75
+ export function loadSourceRows(inputPath = DEFAULT_SOURCE_INPUT) {
76
+ const abs = resolveRepoPath(inputPath);
77
+ const result = {
78
+ path: abs,
79
+ relativePath: toRepoRelative(abs),
80
+ rows: [],
81
+ errors: [],
82
+ };
83
+
84
+ if (!existsSync(abs)) {
85
+ result.errors.push(`source input not found: ${result.relativePath}`);
86
+ return result;
87
+ }
88
+
89
+ const lines = readFileSync(abs, 'utf8').split(/\r?\n/u);
90
+ for (let index = 0; index < lines.length; index++) {
91
+ const lineNumber = index + 1;
92
+ const line = lines[index].trim();
93
+ if (!line || line.startsWith('#')) continue;
94
+
95
+ try {
96
+ result.rows.push({ lineNumber, value: normalizeSource(JSON.parse(line), lineNumber) });
97
+ } catch (error) {
98
+ result.errors.push(`line ${lineNumber}: ${error.message}`);
99
+ }
100
+ }
101
+
102
+ return result;
103
+ }
104
+
105
+ function normalizeSource(input, lineNumber) {
106
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
107
+ throw new Error('source row must be a JSON object');
108
+ }
109
+ const source = { ...input };
110
+ for (const field of ['source_id', 'url', 'register', 'source_title', 'source_license']) {
111
+ if (typeof source[field] !== 'string' || source[field].trim() === '') {
112
+ throw new Error(`${field} is required`);
113
+ }
114
+ source[field] = source[field].trim();
115
+ }
116
+ if (!MATRIX.registers.includes(source.register)) {
117
+ throw new Error(`register must be one of ${MATRIX.registers.join(', ')}`);
118
+ }
119
+ try {
120
+ const parsed = new URL(source.url);
121
+ if (parsed.protocol !== 'https:') throw new Error('source url must use https');
122
+ } catch (error) {
123
+ throw new Error(`invalid url on line ${lineNumber}: ${error.message}`);
124
+ }
125
+ if (source.source_published_at && Number.isNaN(Date.parse(source.source_published_at))) {
126
+ throw new Error('source_published_at must be an ISO-like date when present');
127
+ }
128
+ if (source.max_rows !== undefined && (!Number.isFinite(Number(source.max_rows)) || Number(source.max_rows) < 0)) {
129
+ throw new Error('max_rows must be a non-negative number when present');
130
+ }
131
+ source.max_rows = source.max_rows === undefined ? null : Number(source.max_rows);
132
+ source.sample_prefix = typeof source.sample_prefix === 'string' && source.sample_prefix.trim()
133
+ ? source.sample_prefix.trim()
134
+ : `ko-human-web-${slugify(source.source_id)}`;
135
+ source.source_kind = typeof source.source_kind === 'string' && source.source_kind.trim()
136
+ ? source.source_kind.trim()
137
+ : 'public-web';
138
+ return source;
139
+ }
140
+
141
+ export async function collectSources(sources, options = {}) {
142
+ const opts = {
143
+ minChars: options.minChars ?? DEFAULT_MIN_CHARS,
144
+ maxChars: options.maxChars ?? DEFAULT_MAX_CHARS,
145
+ maxPerSource: options.maxPerSource ?? DEFAULT_MAX_PER_SOURCE,
146
+ targetPerRegister: options.targetPerRegister ?? DEFAULT_TARGET_PER_REGISTER,
147
+ delayMs: options.delayMs ?? DEFAULT_DELAY_MS,
148
+ collectedAt: options.collectedAt || new Date().toISOString().slice(0, 10),
149
+ fetchImpl: options.fetchImpl || globalThis.fetch,
150
+ };
151
+ if (typeof opts.fetchImpl !== 'function') throw new Error('fetch is not available in this runtime');
152
+
153
+ const records = [];
154
+ const errors = [];
155
+ const warnings = [];
156
+ const seenHashes = new Set();
157
+ const registerCounts = Object.fromEntries(MATRIX.registers.map((register) => [register, 0]));
158
+
159
+ for (const source of sources) {
160
+ if (registerCounts[source.register] >= opts.targetPerRegister) continue;
161
+ let html;
162
+ try {
163
+ html = await fetchHtml(source.url, opts.fetchImpl);
164
+ } catch (error) {
165
+ warnings.push(`${source.source_id}: ${error.message}`);
166
+ continue;
167
+ }
168
+
169
+ const candidates = extractTextCandidates(html, opts);
170
+ const sourceLimit = Math.min(
171
+ source.max_rows ?? opts.maxPerSource,
172
+ opts.maxPerSource,
173
+ opts.targetPerRegister - registerCounts[source.register]
174
+ );
175
+ let acceptedFromSource = 0;
176
+ for (const text of candidates) {
177
+ if (acceptedFromSource >= sourceLimit) break;
178
+ const textHash = hashText(text);
179
+ if (seenHashes.has(textHash)) continue;
180
+ seenHashes.add(textHash);
181
+ acceptedFromSource++;
182
+ registerCounts[source.register]++;
183
+ records.push(buildPrivateRecord({
184
+ source,
185
+ text,
186
+ textHash,
187
+ ordinal: acceptedFromSource,
188
+ collectedAt: opts.collectedAt,
189
+ }));
190
+ }
191
+
192
+ if (acceptedFromSource === 0) {
193
+ warnings.push(`${source.source_id}: no paragraphs accepted from ${source.url}`);
194
+ }
195
+ if (opts.delayMs > 0) await sleep(opts.delayMs);
196
+ }
197
+
198
+ return {
199
+ records,
200
+ errors,
201
+ warnings,
202
+ registerCounts,
203
+ sources: sources.length,
204
+ };
205
+ }
206
+
207
+ async function fetchHtml(url, fetchImpl) {
208
+ const response = await fetchImpl(url, {
209
+ headers: {
210
+ 'user-agent': 'patina-rebaseline-corpus-builder/1.0 (+https://github.com/devswha/patina)',
211
+ accept: 'text/html,application/xhtml+xml',
212
+ },
213
+ });
214
+ if (!response || !response.ok) {
215
+ throw new Error(`fetch failed: HTTP ${response?.status ?? 'unknown'}`);
216
+ }
217
+ const contentType = response.headers?.get?.('content-type') || '';
218
+ if (contentType && !/text\/html|application\/xhtml\+xml/iu.test(contentType)) {
219
+ throw new Error(`expected HTML but got ${contentType}`);
220
+ }
221
+ return response.text();
222
+ }
223
+
224
+ export function extractTextCandidates(html, options = {}) {
225
+ const minChars = options.minChars ?? DEFAULT_MIN_CHARS;
226
+ const maxChars = options.maxChars ?? DEFAULT_MAX_CHARS;
227
+ const plain = decodeHtmlEntities(String(html || ''))
228
+ .replace(/<!--[\s\S]*?-->/gu, ' ')
229
+ .replace(/<script\b[\s\S]*?<\/script>/giu, ' ')
230
+ .replace(/<style\b[\s\S]*?<\/style>/giu, ' ')
231
+ .replace(/<noscript\b[\s\S]*?<\/noscript>/giu, ' ')
232
+ .replace(/<(?:p|div|section|article|main|br|li|h[1-6]|tr|td|blockquote)\b[^>]*>/giu, '\n')
233
+ .replace(/<[^>]+>/gu, ' ')
234
+ .replace(/\u00a0/gu, ' ');
235
+
236
+ const seen = new Set();
237
+ const candidates = [];
238
+ for (const raw of plain.split(/\n+/u)) {
239
+ const text = normalizeParagraph(raw);
240
+ if (!isUsefulKoreanParagraph(text, { minChars, maxChars })) continue;
241
+ const key = text.toLowerCase();
242
+ if (seen.has(key)) continue;
243
+ seen.add(key);
244
+ candidates.push(text);
245
+ }
246
+ return candidates;
247
+ }
248
+
249
+ function isUsefulKoreanParagraph(text, { minChars, maxChars }) {
250
+ const chars = Array.from(text);
251
+ if (chars.length < minChars || chars.length > maxChars) return false;
252
+ if (BAD_BOILERPLATE_RE.test(text)) return false;
253
+ const hangulCount = (text.match(HANGUL_RE) || []).length;
254
+ if (hangulCount < 25) return false;
255
+ const letterish = chars.filter((char) => /[\p{L}\p{N}]/u.test(char)).length || 1;
256
+ if (hangulCount / letterish < 0.35) return false;
257
+ if ((text.match(/https?:\/\//giu) || []).length > 0) return false;
258
+ if ((text.match(/[|{}[\]<>]/gu) || []).length > 5) return false;
259
+ return true;
260
+ }
261
+
262
+ function buildPrivateRecord({ source, text, textHash, ordinal, collectedAt }) {
263
+ const suffix = String(ordinal).padStart(2, '0');
264
+ return {
265
+ language: 'ko',
266
+ class: 'natural-human',
267
+ model_family: 'human-reference',
268
+ provider: 'web-human-control',
269
+ model: 'human-authored-web-candidate',
270
+ generated_at: source.source_published_at || collectedAt,
271
+ decoding: 'not-applicable',
272
+ postprocess: {
273
+ editing_pass: 'none',
274
+ extraction: 'scripted web paragraph candidate',
275
+ source_kind: source.source_kind,
276
+ },
277
+ redistribution: 'hash-only',
278
+ source_review: source.source_review || {
279
+ status: 'hash-only-web-candidate',
280
+ rationale: 'Raw text stays in gitignored private intake. Commit only URL, license note, metadata, score, and sha256 digest until redistribution review is complete.',
281
+ license_basis: source.source_license,
282
+ },
283
+ reviewer_notes: source.reviewer_notes || 'Human-control candidate from public Korean web source; not a public benchmark claim.',
284
+ sample_id: `${source.sample_prefix}-${suffix}`,
285
+ register: source.register,
286
+ source_url: source.url,
287
+ source_title: source.source_title,
288
+ source_license: source.source_license,
289
+ ...(source.source_published_at ? { source_published_at: source.source_published_at } : {}),
290
+ prompt_id: `${source.sample_prefix}-${suffix}`,
291
+ text_hash: textHash,
292
+ text,
293
+ };
294
+ }
295
+
296
+ export function writePrivateOutput(result, outputPath = DEFAULT_PRIVATE_OUTPUT) {
297
+ if (result.errors.length) throw new Error('refusing to write web corpus output with collection errors');
298
+ const abs = resolveRepoPath(outputPath);
299
+ mkdirSync(dirname(abs), { recursive: true });
300
+ writeFileSync(abs, result.records.map((record) => JSON.stringify(record)).join('\n') + (result.records.length ? '\n' : ''));
301
+ return { output: toRepoRelative(abs) };
302
+ }
303
+
304
+ export function renderSummary(result, written = null) {
305
+ const lines = [
306
+ '# Rebaseline Web Collect Summary',
307
+ '',
308
+ `- Sources: ${result.sources}`,
309
+ `- Private rows: ${result.records.length}`,
310
+ `- Validation: **${result.errors.length ? 'FAIL' : 'PASS'}**`,
311
+ ];
312
+ if (written) lines.push(`- Private output: \`${written.output}\``);
313
+ lines.push('', '## Register counts');
314
+ for (const register of MATRIX.registers) {
315
+ lines.push(`- ${register}: ${result.registerCounts[register] || 0}`);
316
+ }
317
+ if (result.errors.length) lines.push('', '## Errors', ...result.errors.map((error) => `- ${escapeMarkdown(error)}`));
318
+ if (result.warnings.length) lines.push('', '## Warnings', ...result.warnings.map((warning) => `- ${escapeMarkdown(warning)}`));
319
+ return `${lines.join('\n')}\n`;
320
+ }
321
+
322
+ function normalizeParagraph(text) {
323
+ return String(text || '')
324
+ .replace(/\s+/gu, ' ')
325
+ .replace(/^[·•*\\-–—\s]+/u, '')
326
+ .trim();
327
+ }
328
+
329
+ function decodeHtmlEntities(text) {
330
+ return text
331
+ .replace(/&nbsp;/giu, ' ')
332
+ .replace(/&amp;/giu, '&')
333
+ .replace(/&lt;/giu, '<')
334
+ .replace(/&gt;/giu, '>')
335
+ .replace(/&quot;/giu, '"')
336
+ .replace(/&#39;/giu, "'")
337
+ .replace(/&#x([0-9a-f]+);/giu, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
338
+ .replace(/&#([0-9]+);/gu, (_, num) => String.fromCodePoint(Number.parseInt(num, 10)));
339
+ }
340
+
341
+ function hashText(text) {
342
+ return `sha256:${createHash('sha256').update(String(text)).digest('hex')}`;
343
+ }
344
+
345
+ function slugify(value) {
346
+ return String(value || '')
347
+ .normalize('NFKD')
348
+ .toLowerCase()
349
+ .replace(/[^a-z0-9]+/gu, '-')
350
+ .replace(/^-+|-+$/gu, '')
351
+ .slice(0, 64) || 'source';
352
+ }
353
+
354
+ function sleep(ms) {
355
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
356
+ }
357
+
358
+ function resolveRepoPath(path) {
359
+ return resolve(REPO_ROOT, path);
360
+ }
361
+
362
+ function toRepoRelative(path) {
363
+ return relative(REPO_ROOT, path) || path;
364
+ }
365
+
366
+ function escapeMarkdown(value) {
367
+ return String(value ?? '').replace(/\|/gu, '\\|').replace(/\n/gu, ' ');
368
+ }
369
+
370
+ function printHelp() {
371
+ console.log(`Usage: node scripts/rebaseline-web-collect.mjs [--input <sources.jsonl>] [--output <private.jsonl>] [--target-per-register <n>] [--max-per-source <n>] [--dry-run] [--json]
372
+
373
+ Fetches public Korean web pages listed in a JSONL source inventory and writes
374
+ private raw-text rows for the 2025+ rebaseline workflow. The output path should
375
+ stay under artifacts/rebaseline-2025/private/ and must not be committed.
376
+
377
+ Default input: ${DEFAULT_SOURCE_INPUT}
378
+ Default output: ${DEFAULT_PRIVATE_OUTPUT}`);
379
+ }
380
+
381
+ async function main() {
382
+ const args = parseArgs();
383
+ if (args.help) {
384
+ printHelp();
385
+ return;
386
+ }
387
+
388
+ const loaded = loadSourceRows(args.input);
389
+ if (loaded.errors.length) {
390
+ const result = { sources: 0, records: [], registerCounts: {}, errors: loaded.errors, warnings: [] };
391
+ console.log(args.json ? JSON.stringify(result, null, 2) : renderSummary(result));
392
+ process.exitCode = 1;
393
+ return;
394
+ }
395
+
396
+ const result = await collectSources(loaded.rows.map((row) => row.value), args);
397
+ const written = !args.dryRun && result.errors.length === 0
398
+ ? writePrivateOutput(result, args.output)
399
+ : null;
400
+ if (args.json) console.log(JSON.stringify({ ...result, written }, null, 2));
401
+ else console.log(renderSummary(result, written));
402
+ if (result.errors.length) process.exitCode = 1;
403
+ }
404
+
405
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
406
+ main().catch((error) => {
407
+ console.error(`rebaseline-web-collect: ${error.message}`);
408
+ process.exitCode = 1;
409
+ });
410
+ }
@@ -60,6 +60,7 @@ function range(value, tolerance, floor = 0) {
60
60
  function detectorHot(result) {
61
61
  return {
62
62
  burstiness: result.paragraphs.some((p) => p.burstiness?.band === 'low'),
63
+ koDiagnostics: result.paragraphs.some((p) => p.koDiagnostics?.hot),
63
64
  mattr: result.paragraphs.some((p) => p.mattr?.band === 'low'),
64
65
  lexicon: result.paragraphs.some((p) => p.lexicon?.hot),
65
66
  };
package/src/api.js CHANGED
@@ -1,9 +1,18 @@
1
+ // @ts-check
1
2
  import { validateBaseURL } from './security.js';
3
+ import { DEFAULT_BEST_MODELS } from './model-defaults.js';
2
4
 
3
5
  const DEFAULT_TIMEOUT = 120000;
4
6
  const DEFAULT_MAX_RETRIES = 2;
5
7
  const DEFAULT_BASE_BACKOFF_MS = 1000;
6
8
  const DEFAULT_MAX_BACKOFF_MS = 30000;
9
+ /**
10
+ * Default sampling temperature for OpenAI-compatible chat completion calls.
11
+ *
12
+ * @type {number}
13
+ * @example
14
+ * const temperature = DEFAULT_TEMPERATURE; // 0.7
15
+ */
7
16
  export const DEFAULT_TEMPERATURE = 0.7;
8
17
 
9
18
  // Status codes that warrant a retry. Network errors (no status, AbortError)
@@ -12,6 +21,15 @@ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
12
21
 
13
22
  // Subclassed error so the retry loop can read `.status` + `.retryAfter`
14
23
  // without re-parsing strings.
24
+ /**
25
+ * Error raised for non-2xx HTTP responses from an LLM provider.
26
+ *
27
+ * @param {number} status HTTP status code returned by the provider.
28
+ * @param {string} body Response body text, truncated in the message.
29
+ * @param {string|null} retryAfter Raw Retry-After response header, if present.
30
+ * @example
31
+ * throw new HttpError(429, 'rate limit', '2');
32
+ */
15
33
  export class HttpError extends Error {
16
34
  constructor(status, body, retryAfter) {
17
35
  super(`HTTP ${status}: ${truncate(body)}`);
@@ -67,6 +85,15 @@ function sleepWithSignal(sleep, ms, signal) {
67
85
  });
68
86
  }
69
87
 
88
+ /**
89
+ * Decide whether an LLM call failure should be retried.
90
+ *
91
+ * @param {Error|Object} err Error thrown by fetch or {@link HttpError}.
92
+ * @returns {boolean} True for retryable HTTP statuses, aborts, and common network failures.
93
+ * @throws {Error} Does not intentionally throw; unexpected Error-like inputs may still propagate JavaScript runtime failures.
94
+ * @example
95
+ * const retry = isRetryable(new HttpError(429, 'rate limit', '1'));
96
+ */
70
97
  export function isRetryable(err) {
71
98
  if (!err) return false;
72
99
  if (err.name === 'AbortError') return true;
@@ -77,6 +104,21 @@ export function isRetryable(err) {
77
104
 
78
105
  // Honors Retry-After (seconds or HTTP-date). Falls back to exponential
79
106
  // backoff with up to 50% jitter, capped at maxDelay.
107
+ /**
108
+ * Compute retry delay from Retry-After or exponential backoff with jitter.
109
+ *
110
+ * @param {number} attempt Zero-based retry attempt.
111
+ * @param {string|null|undefined} retryAfter Retry-After seconds or HTTP-date header.
112
+ * @param {object} [opts] Backoff tuning and deterministic test hooks.
113
+ * @param {number} [opts.base=1000] Initial exponential backoff in milliseconds.
114
+ * @param {number} [opts.max=30000] Maximum returned delay in milliseconds.
115
+ * @param {Function} [opts.now] Clock returning epoch milliseconds.
116
+ * @param {Function} [opts.random] Random number provider used for jitter.
117
+ * @returns {number} Delay in milliseconds, capped at opts.max.
118
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
119
+ * @example
120
+ * const delay = computeBackoffMs(1, '2'); // 2000
121
+ */
80
122
  export function computeBackoffMs(attempt, retryAfter, opts = {}) {
81
123
  const {
82
124
  base = DEFAULT_BASE_BACKOFF_MS,
@@ -101,39 +143,36 @@ export function computeBackoffMs(attempt, retryAfter, opts = {}) {
101
143
  return Math.min(exp + jitter, max);
102
144
  }
103
145
 
104
- // Bounded-concurrency semaphore. `max <= 0` yields a no-op gate for callers
105
- // that explicitly opt into unlimited fanout.
106
- export function createSemaphore(max) {
107
- if (!max || max <= 0) {
108
- return { acquire: () => Promise.resolve(() => {}) };
109
- }
110
- let active = 0;
111
- const queue = [];
112
- const drain = () => {
113
- if (active < max && queue.length) {
114
- active++;
115
- const resolve = queue.shift();
116
- resolve(() => {
117
- active--;
118
- drain();
119
- });
120
- }
121
- };
122
- return {
123
- acquire() {
124
- return new Promise((resolve) => {
125
- queue.push(resolve);
126
- if (active < max) drain();
127
- });
128
- },
129
- };
130
- }
131
146
 
147
+ /**
148
+ * Call an OpenAI-compatible chat completions endpoint with retries, timeout, and abort support.
149
+ *
150
+ * @param {object} options LLM request options.
151
+ * @param {string} options.prompt User prompt sent as the single chat message.
152
+ * @param {string} [options.apiKey] Bearer token for the provider.
153
+ * @param {string} [options.baseURL] OpenAI-compatible API base URL. Defaults to https://api.openai.com/v1.
154
+ * @param {string} [options.model] Model id to request. Defaults to gpt-5.5.
155
+ * @param {number} [options.temperature=DEFAULT_TEMPERATURE] Sampling temperature.
156
+ * @param {number|string} [options.seed] Optional deterministic seed forwarded to the provider.
157
+ * @param {number} [options.timeout=120000] Per-attempt timeout in milliseconds.
158
+ * @param {number} [options.maxRetries=2] Retry count after the first attempt.
159
+ * @param {number} [options.deadline] Absolute epoch-millisecond deadline for all attempts.
160
+ * @param {AbortSignal} [options.signal] External cancellation signal.
161
+ * @param {boolean} [options.allowInsecureBaseURL=false] Allow non-loopback HTTP base URLs.
162
+ * @param {Function} [options.onResponse] Callback receiving provider metadata.
163
+ * @param {Function} [options.sleep] Injectable sleep function for tests.
164
+ * @param {Function} [options.now] Clock returning epoch milliseconds.
165
+ * @returns {Promise<string>} Assistant message content.
166
+ * @throws {HttpError} When the provider returns a non-2xx response after retries.
167
+ * @throws {Error} On abort, timeout, malformed provider payload, or base URL validation failure.
168
+ * @example
169
+ * const text = await callLLM({ prompt: 'Rewrite this', apiKey: process.env.OPENAI_API_KEY });
170
+ */
132
171
  export async function callLLM({
133
172
  prompt,
134
173
  apiKey,
135
174
  baseURL = 'https://api.openai.com/v1',
136
- model = 'gpt-4o',
175
+ model = DEFAULT_BEST_MODELS.openai,
137
176
  temperature = DEFAULT_TEMPERATURE,
138
177
  seed,
139
178
  timeout = DEFAULT_TIMEOUT,
@@ -142,7 +181,6 @@ export async function callLLM({
142
181
  signal,
143
182
  allowInsecureBaseURL = false,
144
183
  onResponse,
145
- cache,
146
184
  // Allows tests to inject a deterministic delay function.
147
185
  sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
148
186
  now = () => Date.now(),
@@ -156,21 +194,6 @@ export async function callLLM({
156
194
  };
157
195
  if (seed !== undefined && seed !== null) body.seed = seed;
158
196
 
159
- const cached = cache?.get?.({ prompt, model, temperature, baseURL });
160
- if (cached) {
161
- onResponse?.({
162
- provider: 'cache',
163
- model: cached.responseModel ?? cached.model ?? model,
164
- requestedModel: model,
165
- temperature,
166
- seed: seed ?? null,
167
- usage: cached.usage ?? null,
168
- rawResponse: null,
169
- content: cached.content,
170
- cache: { hit: true, key: cached.key, path: cached.path },
171
- });
172
- return cached.content;
173
- }
174
197
 
175
198
  let lastError;
176
199
  let attemptsMade = 0;
@@ -234,9 +257,7 @@ export async function callLLM({
234
257
  usage: data.usage ?? null,
235
258
  rawResponse: data,
236
259
  content,
237
- cache: cache ? { hit: false } : null,
238
260
  };
239
- cache?.set?.({ prompt, model, temperature, baseURL }, content, metadata);
240
261
  onResponse?.(metadata);
241
262
 
242
263
  return content;
@@ -266,65 +287,8 @@ export async function callLLM({
266
287
 
267
288
  const err = new Error(`LLM API failed after ${attemptsMade || 1} attempts: ${lastError?.message ?? 'unknown'}`);
268
289
  if (lastError?.name === 'AbortError') err.name = 'AbortError';
269
- if (typeof lastError?.status === 'number') err.status = lastError.status;
290
+ const lastStatus = lastError ? /** @type {any} */ (lastError).status : undefined;
291
+ if (typeof lastStatus === 'number') /** @type {any} */ (err).status = lastStatus;
270
292
  throw err;
271
293
  }
272
294
 
273
- export async function callLLMMultiple({
274
- prompt,
275
- models,
276
- apiKey,
277
- baseURL = 'https://api.openai.com/v1',
278
- temperature = DEFAULT_TEMPERATURE,
279
- seed,
280
- timeout = DEFAULT_TIMEOUT,
281
- allowInsecureBaseURL = false,
282
- deadline,
283
- signal,
284
- maxConcurrency,
285
- onStart,
286
- onComplete,
287
- onResponse,
288
- cache,
289
- callLLM: callLLMImpl = callLLM,
290
- sleep,
291
- now = () => Date.now(),
292
- }) {
293
- validateBaseURL(baseURL, { allowInsecure: allowInsecureBaseURL });
294
- const effectiveMaxConcurrency =
295
- maxConcurrency === undefined || maxConcurrency === null
296
- ? Math.min(models.length, 3)
297
- : maxConcurrency;
298
- const sem = createSemaphore(effectiveMaxConcurrency);
299
- const promises = models.map(async (model) => {
300
- const release = await sem.acquire();
301
- if (onStart) onStart(model);
302
- try {
303
- const result = await callLLMImpl({
304
- prompt,
305
- apiKey,
306
- baseURL,
307
- model,
308
- temperature,
309
- seed,
310
- timeout,
311
- deadline,
312
- signal,
313
- allowInsecureBaseURL,
314
- onResponse,
315
- cache,
316
- sleep,
317
- now,
318
- });
319
- if (onComplete) onComplete(model, true);
320
- return { model, result, ok: true };
321
- } catch (err) {
322
- if (onComplete) onComplete(model, false);
323
- return { model, error: err.message, ok: false };
324
- } finally {
325
- release();
326
- }
327
- });
328
-
329
- return Promise.all(promises);
330
- }