humanizer-lint 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +469 -2
- package/SKILL.md +116 -0
- package/package.json +23 -4
- package/profiles/default-human.json +36 -0
- package/rules/en-slop.json +49 -0
- package/rules/id-slop.json +57 -0
- package/scripts/analyze.mjs +408 -0
- package/scripts/fingerprint.mjs +127 -0
- package/scripts/utils/metrics.mjs +73 -0
- package/scripts/utils/sanitizer.mjs +48 -0
- package/scripts/utils/tokenizer.mjs +82 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"language": "en",
|
|
3
|
+
"patterns": [
|
|
4
|
+
{"id":"EN001","type":"SLOP_PHRASE","pattern":"\\bdelve\\b","fixInstruction":"Name the exact action or subject instead."},
|
|
5
|
+
{"id":"EN002","type":"SLOP_PHRASE","pattern":"\\btapestry\\b","fixInstruction":"Replace the metaphor with a concrete description."},
|
|
6
|
+
{"id":"EN003","type":"SLOP_PHRASE","pattern":"\\btestament\\b","fixInstruction":"State the evidence directly."},
|
|
7
|
+
{"id":"EN004","type":"SLOP_PHRASE","pattern":"\\bunderscore(?:s|d|ing)?\\b","fixInstruction":"State what the evidence shows."},
|
|
8
|
+
{"id":"EN005","type":"SLOP_PHRASE","pattern":"\\bpivotal\\b","fixInstruction":"Describe the actual consequence without inflated significance."},
|
|
9
|
+
{"id":"EN006","type":"SLOP_PHRASE","pattern":"\\bcrucial\\b","fixInstruction":"Explain why the item matters in this context."},
|
|
10
|
+
{"id":"EN007","type":"SLOP_PHRASE","pattern":"\\bfoster(?:s|ed|ing)?\\b","fixInstruction":"Name the concrete behavior or outcome."},
|
|
11
|
+
{"id":"EN008","type":"SLOP_PHRASE","pattern":"\\blandscape\\b","fixInstruction":"Name the field or conditions directly."},
|
|
12
|
+
{"id":"EN009","type":"SLOP_PHRASE","pattern":"\\brealm\\b","fixInstruction":"Name the actual domain."},
|
|
13
|
+
{"id":"EN010","type":"SLOP_PHRASE","pattern":"\\bbeacon\\b","fixInstruction":"Replace the metaphor with a supported fact."},
|
|
14
|
+
{"id":"EN011","type":"SLOP_PHRASE","pattern":"\\bsymphony\\b","fixInstruction":"Describe the relationship without a decorative metaphor."},
|
|
15
|
+
{"id":"EN012","type":"SLOP_PHRASE","pattern":"\\bunlock(?:s|ed|ing)?\\b","fixInstruction":"Name what becomes possible and how."},
|
|
16
|
+
{"id":"EN013","type":"SLOP_PHRASE","pattern":"\\belevate(?:s|d|ing)?\\b","fixInstruction":"State the measurable improvement."},
|
|
17
|
+
{"id":"EN014","type":"SLOP_PHRASE","pattern":"\\bunleash(?:es|ed|ing)?\\b","fixInstruction":"Describe the actual capability or result."},
|
|
18
|
+
{"id":"EN015","type":"SLOP_PHRASE","pattern":"\\bgame[- ]changer\\b","fixInstruction":"State the specific change and its effect."},
|
|
19
|
+
{"id":"EN016","type":"SLOP_PHRASE","pattern":"\\bparadigm shift\\b","fixInstruction":"Describe what changed in concrete terms."},
|
|
20
|
+
{"id":"EN017","type":"SLOP_PHRASE","pattern":"\\bmoreover\\b","fixInstruction":"Connect the sentences by their actual relationship."},
|
|
21
|
+
{"id":"EN018","type":"SLOP_PHRASE","pattern":"\\bfurthermore\\b","fixInstruction":"Connect the sentences by their actual relationship."},
|
|
22
|
+
{"id":"EN019","type":"SLOP_PHRASE","pattern":"\\bin conclusion\\b","fixInstruction":"End on the final concrete point."},
|
|
23
|
+
{"id":"EN020","type":"SLOP_PHRASE","pattern":"\\bit is important to note\\b","fixInstruction":"Remove the announcement and state the fact."},
|
|
24
|
+
{"id":"EN021","type":"SLOP_PHRASE","pattern":"\\bit is worth noting\\b","fixInstruction":"Remove the announcement and state the fact."},
|
|
25
|
+
{"id":"EN022","type":"SLOP_PHRASE","pattern":"\\bin today['’]s fast-paced\\b","fixInstruction":"Name the current condition precisely."},
|
|
26
|
+
{"id":"EN023","type":"SLOP_PHRASE","pattern":"\\bever-evolving\\b","fixInstruction":"Name the change and its time frame."},
|
|
27
|
+
{"id":"EN024","type":"SLOP_PHRASE","pattern":"\\bseamless(?:ly)?\\b","fixInstruction":"Describe the missing step, delay, or transition precisely."},
|
|
28
|
+
{"id":"EN025","type":"SLOP_PHRASE","pattern":"\\brobust\\b","fixInstruction":"Name the failure mode or capacity the system handles."},
|
|
29
|
+
{"id":"EN026","type":"SLOP_PHRASE","pattern":"\\bcutting[- ]edge\\b","fixInstruction":"Name the specific technical capability."},
|
|
30
|
+
{"id":"EN027","type":"SLOP_PHRASE","pattern":"\\brevolutionary\\b","fixInstruction":"Describe the concrete difference without promotional language."},
|
|
31
|
+
{"id":"EN028","type":"SLOP_PHRASE","pattern":"\\bnext[- ]generation\\b","fixInstruction":"Name the new behavior or version."},
|
|
32
|
+
{"id":"EN029","type":"SLOP_PHRASE","pattern":"\\btransformative\\b","fixInstruction":"State what changed and for whom."},
|
|
33
|
+
{"id":"EN030","type":"SLOP_PHRASE","pattern":"\\bjourney\\b","fixInstruction":"Name the process or sequence directly."},
|
|
34
|
+
{"id":"EN031","type":"SLOP_PHRASE","pattern":"\\bempower(?:s|ed|ing)?\\b","fixInstruction":"Name the action users can now take."},
|
|
35
|
+
{"id":"EN032","type":"SLOP_PHRASE","pattern":"\\bharness(?:es|ed|ing)?\\b","fixInstruction":"State how the resource is used."},
|
|
36
|
+
{"id":"EN033","type":"SLOP_PHRASE","pattern":"\\bnavigate(?:s|d|ing)? the complexities\\b","fixInstruction":"Name the specific difficulty and response."},
|
|
37
|
+
{"id":"EN034","type":"SLOP_PHRASE","pattern":"\\bholistic approach\\b","fixInstruction":"List the parts included in the approach."},
|
|
38
|
+
{"id":"EN035","type":"SLOP_PHRASE","pattern":"\\bvaluable insights\\b","fixInstruction":"State the finding itself."},
|
|
39
|
+
{"id":"EN036","type":"SLOP_PHRASE","pattern":"\\bplays? a (?:key|vital|crucial) role\\b","fixInstruction":"Describe the action or consequence directly."},
|
|
40
|
+
{"id":"EN037","type":"SLOP_PHRASE","pattern":"\\bthe future looks bright\\b","fixInstruction":"End on a supported plan or concrete fact."},
|
|
41
|
+
{"id":"EN038","type":"SLOP_PHRASE","pattern":"\\bexciting times (?:lie|are) ahead\\b","fixInstruction":"Replace optimism with a supported next step."},
|
|
42
|
+
{"id":"EN039","type":"SLOP_PHRASE","pattern":"\\bstands as a testament\\b","fixInstruction":"State the evidence directly."},
|
|
43
|
+
{"id":"EN040","type":"SLOP_PHRASE","pattern":"\\bmarking a (?:pivotal|significant) moment\\b","fixInstruction":"State what happened and its direct effect."},
|
|
44
|
+
{"id":"EN041","type":"STRUCTURAL_CLICHE","pattern":",\\s+[a-z]+ing\\s+(?:a|the|its|their|this|that)\\s+[a-z]+","fixInstruction":"Split or rewrite the participial tail as a supported clause."},
|
|
45
|
+
{"id":"EN042","type":"STRUCTURAL_CLICHE","pattern":"\\bnot only[\\s\\S]{0,100}?but also\\b","fixInstruction":"State both claims directly without the binary formula."},
|
|
46
|
+
{"id":"EN043","type":"STRUCTURAL_CLICHE","pattern":"\\bit['’]s not just about[\\s\\S]{0,100}?it['’]s about\\b","fixInstruction":"State the positive claim directly."},
|
|
47
|
+
{"id":"EN044","type":"STRUCTURAL_CLICHE","pattern":"(?:—|--)","maxOccurrences":1,"perWords":250,"fixInstruction":"Replace repeated dashes with the punctuation that matches the relationship."}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"language": "id",
|
|
3
|
+
"patterns": [
|
|
4
|
+
{"id":"ID001","type":"SLOP_PHRASE","pattern":"\\bdi era digital\\b","fixInstruction":"Mulai langsung dari subjek atau fakta konkret."},
|
|
5
|
+
{"id":"ID002","type":"SLOP_PHRASE","pattern":"\\bdi tengah pesatnya\\b","fixInstruction":"Hapus pembuka umum dan sebutkan perubahan yang dimaksud."},
|
|
6
|
+
{"id":"ID003","type":"SLOP_PHRASE","pattern":"\\btidak dapat dipungkiri\\b","fixInstruction":"Nyatakan klaim secara langsung tanpa pembuka klise."},
|
|
7
|
+
{"id":"ID004","type":"SLOP_PHRASE","pattern":"\\btak dapat dipungkiri\\b","fixInstruction":"Nyatakan klaim secara langsung tanpa pembuka klise."},
|
|
8
|
+
{"id":"ID005","type":"SLOP_PHRASE","pattern":"\\bpenting untuk diingat\\b","fixInstruction":"Hapus penanda kepentingan dan tulis fakta yang perlu diingat."},
|
|
9
|
+
{"id":"ID006","type":"SLOP_PHRASE","pattern":"\\bperlu dicatat bahwa\\b","fixInstruction":"Hapus pengantar dan mulai dari informasi intinya."},
|
|
10
|
+
{"id":"ID007","type":"SLOP_PHRASE","pattern":"\\bsebagai kesimpulan\\b","fixInstruction":"Hapus kesimpulan generik atau tutup dengan fakta terakhir."},
|
|
11
|
+
{"id":"ID008","type":"SLOP_PHRASE","pattern":"\\bsecara keseluruhan\\b","fixInstruction":"Ganti rangkuman umum dengan kesimpulan yang spesifik."},
|
|
12
|
+
{"id":"ID009","type":"SLOP_PHRASE","pattern":"\\blebih lanjut\\s*,","fixInstruction":"Hubungkan kalimat berdasarkan maknanya, bukan transisi otomatis."},
|
|
13
|
+
{"id":"ID010","type":"SLOP_PHRASE","pattern":"\\bnamun demikian\\s*,","fixInstruction":"Gunakan kontras langsung jika memang ada pertentangan."},
|
|
14
|
+
{"id":"ID011","type":"SLOP_PHRASE","pattern":"\\bselain itu\\s*,","maxOccurrences":1,"perWords":300,"fixInstruction":"Pertahankan paling banyak satu kali atau gunakan hubungan yang lebih spesifik."},
|
|
15
|
+
{"id":"ID012","type":"SLOP_PHRASE","pattern":"\\bmari kita bahas\\b","fixInstruction":"Mulai pembahasan tanpa mengumumkannya."},
|
|
16
|
+
{"id":"ID013","type":"SLOP_PHRASE","pattern":"\\bmenyelami lebih dalam\\b","fixInstruction":"Sebutkan aspek yang dianalisis secara langsung."},
|
|
17
|
+
{"id":"ID014","type":"SLOP_PHRASE","pattern":"\\bbukan sekadar\\b","fixInstruction":"Nyatakan fungsi atau manfaat utama secara langsung."},
|
|
18
|
+
{"id":"ID015","type":"SLOP_PHRASE","pattern":"\\btidak hanya[\\s\\S]{0,80}?tetapi juga\\b","maxOccurrences":1,"perWords":300,"fixInstruction":"Pisahkan kedua klaim atau nyatakan hubungan tanpa formula biner."},
|
|
19
|
+
{"id":"ID016","type":"SLOP_PHRASE","pattern":"\\bhal ini menunjukkan bahwa\\b","fixInstruction":"Sebutkan bukti dan kesimpulannya secara langsung."},
|
|
20
|
+
{"id":"ID017","type":"SLOP_PHRASE","pattern":"\\bdapat disimpulkan bahwa\\b","fixInstruction":"Tulis kesimpulan tanpa frasa pengantar."},
|
|
21
|
+
{"id":"ID018","type":"SLOP_PHRASE","pattern":"\\bseiring dengan perkembangan zaman\\b","fixInstruction":"Sebutkan periode atau perubahan yang konkret."},
|
|
22
|
+
{"id":"ID019","type":"SLOP_PHRASE","pattern":"\\bdi zaman yang serba\\b","fixInstruction":"Ganti generalisasi dengan konteks yang nyata."},
|
|
23
|
+
{"id":"ID020","type":"SLOP_PHRASE","pattern":"\\bdi dunia yang semakin\\b","fixInstruction":"Sebutkan kondisi yang benar-benar berubah."},
|
|
24
|
+
{"id":"ID021","type":"SLOP_PHRASE","pattern":"\\bdalam lanskap yang\\b","fixInstruction":"Sebutkan bidang atau keadaan secara langsung."},
|
|
25
|
+
{"id":"ID022","type":"SLOP_PHRASE","pattern":"\\bmemainkan peran penting\\b","fixInstruction":"Jelaskan tindakan atau dampak konkretnya."},
|
|
26
|
+
{"id":"ID023","type":"SLOP_PHRASE","pattern":"\\bmenjadi kunci utama\\b","fixInstruction":"Jelaskan syarat atau mekanisme yang sebenarnya."},
|
|
27
|
+
{"id":"ID024","type":"SLOP_PHRASE","pattern":"\\blangkah yang tepat\\b","fixInstruction":"Sebutkan langkah yang dimaksud."},
|
|
28
|
+
{"id":"ID025","type":"SLOP_PHRASE","pattern":"\\blangkah strategis\\b","fixInstruction":"Sebutkan tindakan dan hasil yang dituju."},
|
|
29
|
+
{"id":"ID026","type":"SLOP_PHRASE","pattern":"\\bsolusi inovatif\\b","fixInstruction":"Jelaskan cara kerja solusi tanpa label promosi."},
|
|
30
|
+
{"id":"ID027","type":"SLOP_PHRASE","pattern":"\\bpendekatan holistik\\b","fixInstruction":"Sebutkan bagian yang digabungkan oleh pendekatan tersebut."},
|
|
31
|
+
{"id":"ID028","type":"SLOP_PHRASE","pattern":"\\bberbagai aspek\\b","fixInstruction":"Sebutkan aspek yang relevan."},
|
|
32
|
+
{"id":"ID029","type":"SLOP_PHRASE","pattern":"\\bdampak yang signifikan\\b","fixInstruction":"Jelaskan dampak dan ukurannya jika tersedia."},
|
|
33
|
+
{"id":"ID030","type":"SLOP_PHRASE","pattern":"\\bhasil yang optimal\\b","fixInstruction":"Nyatakan hasil yang dapat diamati atau diukur."},
|
|
34
|
+
{"id":"ID031","type":"SLOP_PHRASE","pattern":"\\bsecara efektif dan efisien\\b","fixInstruction":"Jelaskan perbaikan waktu, biaya, atau mutu secara spesifik."},
|
|
35
|
+
{"id":"ID032","type":"SLOP_PHRASE","pattern":"\\bpada akhirnya\\s*,","fixInstruction":"Tutup langsung dengan akibat atau keputusan akhir."},
|
|
36
|
+
{"id":"ID033","type":"SLOP_PHRASE","pattern":"\\bdengan demikian\\s*,","fixInstruction":"Nyatakan akibatnya tanpa transisi otomatis."},
|
|
37
|
+
{"id":"ID034","type":"SLOP_PHRASE","pattern":"\\boleh karena itu\\s*,","fixInstruction":"Hubungkan sebab dan akibat secara langsung."},
|
|
38
|
+
{"id":"ID035","type":"SLOP_PHRASE","pattern":"\\bpada dasarnya\\s*,","fixInstruction":"Hapus pengantar dan nyatakan pokoknya."},
|
|
39
|
+
{"id":"ID036","type":"SLOP_PHRASE","pattern":"\\bperlu dipahami bahwa\\b","fixInstruction":"Mulai dari informasi yang perlu dipahami."},
|
|
40
|
+
{"id":"ID037","type":"SLOP_PHRASE","pattern":"\\btentu saja\\s*,","fixInstruction":"Hapus penegasan otomatis jika tidak menambah makna."},
|
|
41
|
+
{"id":"ID038","type":"SLOP_PHRASE","pattern":"\\btanpa diragukan lagi\\b","fixInstruction":"Ganti kepastian retoris dengan bukti atau klaim langsung."},
|
|
42
|
+
{"id":"ID039","type":"SLOP_PHRASE","pattern":"\\bmembuka peluang baru\\b","fixInstruction":"Sebutkan peluang yang benar-benar tersedia."},
|
|
43
|
+
{"id":"ID040","type":"SLOP_PHRASE","pattern":"\\bmendorong pertumbuhan berkelanjutan\\b","fixInstruction":"Sebutkan mekanisme dan indikator pertumbuhannya."},
|
|
44
|
+
{"id":"ID041","type":"DEFINITIONAL_OPENING","scope":"paragraph-start","pattern":"^[A-ZÀ-ÖØ-Þ][\\p{L}\\p{N}_\\s'\"-]{2,80}\\s+adalah\\s+(?:sebuah|suatu|proses|metode|sistem|cara|langkah|pendekatan)\\b","fixInstruction":"Ganti definisi kaku dengan fungsi, contoh, atau fakta konkret."},
|
|
45
|
+
{"id":"ID042","type":"DEFINITIONAL_OPENING","scope":"paragraph-start","pattern":"^Dalam (?:konteks|dunia|ranah) [\\p{L}\\p{N}\\s-]+,","fixInstruction":"Mulai langsung dari subjek yang dibahas."}
|
|
46
|
+
],
|
|
47
|
+
"morphology": {
|
|
48
|
+
"nominalizationPattern": "^(?:peng|pen|pem|peny|pe|ke)[a-z]{5,}an$",
|
|
49
|
+
"nominalizationWhitelist": ["perusahaan","pekerjaan","pertanyaan","pernyataan","perjalanan","permainan","peralatan","peraturan","perubahan","perbedaan","persamaan","pertemuan","pernikahan","pendidikan","pemerintahan","pembangunan","pengalaman","pengetahuan","pendapatan","pengeluaran","kegiatan","kesehatan","kebutuhan","keputusan","keamanan","kecepatan","kekuatan","kesempatan","kesalahan","ketentuan","keuangan","kebijakan","kehidupan","kemampuan","kenyataan","kepercayaan","keterangan","kewajiban","kerusakan","keuntungan","kerugian","kemajuan","kehadiran","kebiasaan"],
|
|
50
|
+
"nominalizationMaxRatio": 0.045,
|
|
51
|
+
"passivePattern": "^di[a-z]{4,}(?:kan|i)$",
|
|
52
|
+
"loanwordExclusionPattern": "^(?:diskusi|dinamika|dimensi|direktori|distribusi|divisi|disiplin|digital|diagnosa|diagram|dialek|dialog|diameter|dilema|dinamis|diplomasi|direktur|disertasi|diskon|divergensi|diversifikasi|dedikasi|definisi|delegasi|demokrasi|depresi|deskripsi|destinasi|deteksi|deviasi|dominasi|donasi|durasi)$",
|
|
53
|
+
"loanwordSuffixExclusionPattern": "(?:asi|isi|ensi|uksi|logi|nomi|grafi|metri|krasi|fobi|pati|terapi|stasi|nasti)$",
|
|
54
|
+
"passiveMaxRatio": 0.035,
|
|
55
|
+
"subordinateOpeningPattern": "^(?:Dengan menggunakan|Untuk mencapai|Dalam rangka|Melalui pendekatan|Guna memastikan|Dengan memanfaatkan)\\b"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
|
|
6
|
+
import { sanitizeMarkdown } from './utils/sanitizer.mjs';
|
|
7
|
+
import { calculateMetrics, mean, punctuationRates } from './utils/metrics.mjs';
|
|
8
|
+
import { tokenizeText } from './utils/tokenizer.mjs';
|
|
9
|
+
|
|
10
|
+
const RULES = {
|
|
11
|
+
id: JSON.parse(readFileSync(new URL('../rules/id-slop.json', import.meta.url), 'utf8')),
|
|
12
|
+
en: JSON.parse(readFileSync(new URL('../rules/en-slop.json', import.meta.url), 'utf8')),
|
|
13
|
+
};
|
|
14
|
+
const DEFAULT_PROFILE = JSON.parse(readFileSync(new URL('../profiles/default-human.json', import.meta.url), 'utf8'));
|
|
15
|
+
const ID_WORDS = new Set(['yang', 'dan', 'di', 'ini', 'untuk', 'dengan', 'dari', 'pada', 'dalam', 'tidak', 'akan', 'atau', 'juga', 'oleh', 'adalah', 'sebagai', 'bahwa', 'dapat', 'karena', 'tersebut']);
|
|
16
|
+
const EN_WORDS = new Set(['the', 'and', 'of', 'to', 'in', 'is', 'that', 'for', 'it', 'with', 'as', 'on', 'are', 'this', 'by', 'be', 'at', 'or', 'from', 'an']);
|
|
17
|
+
|
|
18
|
+
function round(value, places = 4) {
|
|
19
|
+
const factor = 10 ** places;
|
|
20
|
+
return Math.round(value * factor) / factor;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function detectLanguage(words) {
|
|
24
|
+
let id = 0;
|
|
25
|
+
let en = 0;
|
|
26
|
+
for (const word of words) {
|
|
27
|
+
if (ID_WORDS.has(word)) id += 1;
|
|
28
|
+
if (EN_WORDS.has(word)) en += 1;
|
|
29
|
+
}
|
|
30
|
+
return en > id ? 'en' : 'id';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function locationFor(sentence) {
|
|
34
|
+
return `Paragraph ${sentence.paragraphIndex}, Sentence ${sentence.sentenceIndex}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function regexFor(pattern) {
|
|
38
|
+
return new RegExp(pattern, 'giu');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function patternViolations(tokenized, rules, wordCount) {
|
|
42
|
+
const violations = [];
|
|
43
|
+
for (const rule of rules.patterns) {
|
|
44
|
+
const candidates = [];
|
|
45
|
+
const sentences = rule.scope === 'paragraph-start'
|
|
46
|
+
? tokenized.paragraphs.map((paragraph) => paragraph.sentences[0]).filter(Boolean)
|
|
47
|
+
: tokenized.sentences;
|
|
48
|
+
|
|
49
|
+
for (const sentence of sentences) {
|
|
50
|
+
const regex = regexFor(rule.pattern);
|
|
51
|
+
for (const match of sentence.text.matchAll(regex)) candidates.push({ sentence, match: match[0] });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const allowed = rule.maxOccurrences == null
|
|
55
|
+
? 0
|
|
56
|
+
: rule.maxOccurrences * Math.max(1, Math.ceil(wordCount / (rule.perWords || wordCount || 1)));
|
|
57
|
+
for (const candidate of candidates.slice(allowed)) {
|
|
58
|
+
violations.push({
|
|
59
|
+
type: rule.type,
|
|
60
|
+
ruleId: rule.id,
|
|
61
|
+
location: locationFor(candidate.sentence),
|
|
62
|
+
match: candidate.match,
|
|
63
|
+
fixInstruction: rule.fixInstruction,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return violations;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function monotonyViolations(tokenized) {
|
|
71
|
+
const violations = [];
|
|
72
|
+
for (const paragraph of tokenized.paragraphs) {
|
|
73
|
+
for (let index = 0; index <= paragraph.sentences.length - 3; index += 1) {
|
|
74
|
+
const window = paragraph.sentences.slice(index, index + 3);
|
|
75
|
+
const lengths = window.map((sentence) => sentence.wordCount);
|
|
76
|
+
if (Math.abs(lengths[1] - lengths[0]) <= 3 && Math.abs(lengths[2] - lengths[1]) <= 3) {
|
|
77
|
+
violations.push({
|
|
78
|
+
type: 'MONOTONOUS_RHYTHM',
|
|
79
|
+
location: `Paragraph ${paragraph.paragraphIndex}, Sentences ${window[0].sentenceIndex}-${window[2].sentenceIndex}`,
|
|
80
|
+
lengths,
|
|
81
|
+
fixInstruction: `Sentence lengths are uniform (${lengths.join(', ')} words). Split one sentence into a short statement or merge two into a longer clause.`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return violations;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function structuralViolations(tokenized, metrics) {
|
|
90
|
+
const violations = [];
|
|
91
|
+
if (metrics.wordCount >= 150 && metrics.compressionRatio < 0.42) {
|
|
92
|
+
violations.push({
|
|
93
|
+
type: 'LOW_ENTROPY_REPETITION',
|
|
94
|
+
location: 'Whole text',
|
|
95
|
+
fixInstruction: 'Remove repeated templates and vary concrete sentence structures.',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (let index = 0; index <= tokenized.paragraphs.length - 3; index += 1) {
|
|
100
|
+
const window = tokenized.paragraphs.slice(index, index + 3);
|
|
101
|
+
const counts = window.map((paragraph) => paragraph.sentences.length);
|
|
102
|
+
if (counts[0] > 0 && counts.every((count) => count === counts[0])) {
|
|
103
|
+
violations.push({
|
|
104
|
+
type: 'SYMMETRIC_PARAGRAPHS',
|
|
105
|
+
location: `Paragraphs ${window[0].paragraphIndex}-${window[2].paragraphIndex}`,
|
|
106
|
+
counts,
|
|
107
|
+
fixInstruction: 'Merge or split a paragraph where the content supports a different rhythm.',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const paragraph of tokenized.paragraphs) {
|
|
113
|
+
const starters = new Map();
|
|
114
|
+
for (const sentence of paragraph.sentences) {
|
|
115
|
+
const starter = sentence.words[0];
|
|
116
|
+
if (!starter) continue;
|
|
117
|
+
const group = starters.get(starter) ?? [];
|
|
118
|
+
group.push(sentence.sentenceIndex);
|
|
119
|
+
starters.set(starter, group);
|
|
120
|
+
}
|
|
121
|
+
for (const [starter, indexes] of starters) {
|
|
122
|
+
if (indexes.length >= 3) {
|
|
123
|
+
violations.push({
|
|
124
|
+
type: 'REPETITIVE_STARTERS',
|
|
125
|
+
location: `Paragraph ${paragraph.paragraphIndex}, Sentences ${indexes.join(', ')}`,
|
|
126
|
+
match: starter,
|
|
127
|
+
fixInstruction: 'Change one sentence opening or combine sentences where the subject repeats.',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return violations;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function statisticalViolations(metrics, thresholds, language) {
|
|
136
|
+
const violations = [];
|
|
137
|
+
const cvTarget = metrics.sentenceCount >= 8 ? thresholds.burstinessCVLong : thresholds.burstinessCVShort;
|
|
138
|
+
if (metrics.burstinessCV < cvTarget) {
|
|
139
|
+
violations.push({
|
|
140
|
+
type: 'LOW_BURSTINESS',
|
|
141
|
+
location: 'Whole text',
|
|
142
|
+
actual: round(metrics.burstinessCV),
|
|
143
|
+
target: cvTarget,
|
|
144
|
+
fixInstruction: 'Vary sentence lengths by splitting one uniform sentence and merging another where the meaning supports it.',
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (metrics.sentenceCount >= 8 && (metrics.shortSentenceRatio < thresholds.shortSentenceRatio || metrics.longSentenceRatio < thresholds.longSentenceRatio)) {
|
|
148
|
+
violations.push({
|
|
149
|
+
type: 'MISSING_SENTENCE_RANGE',
|
|
150
|
+
location: 'Whole text',
|
|
151
|
+
shortSentenceRatio: round(metrics.shortSentenceRatio),
|
|
152
|
+
longSentenceRatio: round(metrics.longSentenceRatio),
|
|
153
|
+
fixInstruction: 'Add a supported sentence of 6 words or fewer and a supported sentence of 26 words or more until both distribution thresholds pass.',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (metrics.adjacentDelta < thresholds.adjacentDelta) {
|
|
157
|
+
violations.push({
|
|
158
|
+
type: 'LOW_ADJACENT_DELTA',
|
|
159
|
+
location: 'Whole text',
|
|
160
|
+
actual: round(metrics.adjacentDelta),
|
|
161
|
+
target: thresholds.adjacentDelta,
|
|
162
|
+
fixInstruction: 'Change neighboring sentence lengths by splitting or merging only where the content allows it.',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (metrics.mattr < thresholds.mattr[language]) {
|
|
166
|
+
violations.push({
|
|
167
|
+
type: 'LOW_LEXICAL_DIVERSITY',
|
|
168
|
+
location: 'Whole text',
|
|
169
|
+
actual: round(metrics.mattr),
|
|
170
|
+
target: thresholds.mattr[language],
|
|
171
|
+
fixInstruction: 'Replace repeated generic wording with precise terms already supported by the source.',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return violations;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function morphologyViolations(tokenized, morphology) {
|
|
178
|
+
if (!morphology) return { violations: [], nominalizationRatio: 0, passiveRatio: 0 };
|
|
179
|
+
const words = tokenized.words;
|
|
180
|
+
const nominalizationPattern = new RegExp(morphology.nominalizationPattern, 'iu');
|
|
181
|
+
const nominalizationWhitelist = new Set(morphology.nominalizationWhitelist);
|
|
182
|
+
const nominalizations = words.filter((word) => nominalizationPattern.test(word) && !nominalizationWhitelist.has(word));
|
|
183
|
+
const passivePattern = new RegExp(morphology.passivePattern, 'iu');
|
|
184
|
+
const loanwordExclusion = new RegExp(morphology.loanwordExclusionPattern, 'iu');
|
|
185
|
+
const loanwordSuffixExclusion = new RegExp(morphology.loanwordSuffixExclusionPattern ?? '$^', 'iu');
|
|
186
|
+
const passives = words.filter((word) => passivePattern.test(word) && !loanwordExclusion.test(word) && !loanwordSuffixExclusion.test(word));
|
|
187
|
+
const nominalizationRatio = words.length ? nominalizations.length / words.length : 0;
|
|
188
|
+
const passiveRatio = words.length ? passives.length / words.length : 0;
|
|
189
|
+
const violations = [];
|
|
190
|
+
|
|
191
|
+
if (nominalizationRatio > morphology.nominalizationMaxRatio) {
|
|
192
|
+
violations.push({
|
|
193
|
+
type: 'EXCESSIVE_NOMINALIZATION',
|
|
194
|
+
location: 'Whole text',
|
|
195
|
+
ratio: round(nominalizationRatio),
|
|
196
|
+
matches: [...new Set(nominalizations)],
|
|
197
|
+
fixInstruction: 'Replace abstract nominalizations with concrete actors and verbs.',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (passiveRatio > morphology.passiveMaxRatio) {
|
|
201
|
+
violations.push({
|
|
202
|
+
type: 'PASSIVE_BLOATING',
|
|
203
|
+
location: 'Whole text',
|
|
204
|
+
ratio: round(passiveRatio),
|
|
205
|
+
matches: [...new Set(passives)],
|
|
206
|
+
fixInstruction: 'Name the actor and use an active verb where the actor is known.',
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const openingPattern = new RegExp(morphology.subordinateOpeningPattern, 'iu');
|
|
211
|
+
let foundConsecutiveOpening = false;
|
|
212
|
+
for (const paragraph of tokenized.paragraphs) {
|
|
213
|
+
for (let index = 1; index < paragraph.sentences.length; index += 1) {
|
|
214
|
+
const previous = paragraph.sentences[index - 1];
|
|
215
|
+
const current = paragraph.sentences[index];
|
|
216
|
+
if (!openingPattern.test(previous.text) || !openingPattern.test(current.text)) continue;
|
|
217
|
+
violations.push({
|
|
218
|
+
type: 'PASSIVE_BLOATING',
|
|
219
|
+
location: `Paragraph ${paragraph.paragraphIndex}, Sentences ${previous.sentenceIndex}-${current.sentenceIndex}`,
|
|
220
|
+
match: 'Two or more consecutive sentences start with translationese subordinate clauses',
|
|
221
|
+
fixInstruction: 'Rewrite to start directly with the active subject (Subjek + Predikat).',
|
|
222
|
+
});
|
|
223
|
+
foundConsecutiveOpening = true;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
if (foundConsecutiveOpening) break;
|
|
227
|
+
}
|
|
228
|
+
return { violations, nominalizationRatio, passiveRatio };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function voiceDeviation(metrics, tokenized, text, profile) {
|
|
232
|
+
const targets = profile?.targets ?? profile?.metrics;
|
|
233
|
+
if (!targets) return 0;
|
|
234
|
+
const actual = {
|
|
235
|
+
meanSentenceLength: metrics.meanSentenceLength,
|
|
236
|
+
burstinessCV: metrics.burstinessCV,
|
|
237
|
+
adjacentDelta: metrics.adjacentDelta,
|
|
238
|
+
mattr: metrics.mattr,
|
|
239
|
+
averageParagraphSentenceCount: mean(tokenized.paragraphs.map((paragraph) => paragraph.sentences.length)),
|
|
240
|
+
};
|
|
241
|
+
const deviations = [];
|
|
242
|
+
for (const key of Object.keys(actual)) {
|
|
243
|
+
if (typeof targets[key] !== 'number') continue;
|
|
244
|
+
deviations.push(Math.min(3, Math.abs(actual[key] - targets[key]) / Math.max(Math.abs(targets[key]), 0.01)));
|
|
245
|
+
}
|
|
246
|
+
const targetPunctuation = targets.punctuationRates;
|
|
247
|
+
if (targetPunctuation) {
|
|
248
|
+
const actualPunctuation = punctuationRates(text, metrics.wordCount);
|
|
249
|
+
for (const key of Object.keys(targetPunctuation)) {
|
|
250
|
+
deviations.push(Math.min(3, Math.abs(actualPunctuation[key] - targetPunctuation[key]) / Math.max(Math.abs(targetPunctuation[key]), 0.01)));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const result = deviations.length ? mean(deviations) : 0;
|
|
254
|
+
return result < 0.0005 ? 0 : result;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function scoreResult(metrics, violations, language, thresholds) {
|
|
258
|
+
const cvTarget = metrics.sentenceCount >= 8 ? thresholds.burstinessCVLong : thresholds.burstinessCVShort;
|
|
259
|
+
let burstinessPenalty = metrics.burstinessCV < cvTarget ? Math.min(25, Math.round((cvTarget - metrics.burstinessCV) * 100)) : 0;
|
|
260
|
+
if (metrics.sentenceCount >= 8 && (metrics.shortSentenceRatio < thresholds.shortSentenceRatio || metrics.longSentenceRatio < thresholds.longSentenceRatio)) {
|
|
261
|
+
burstinessPenalty = Math.min(25, burstinessPenalty + 8);
|
|
262
|
+
}
|
|
263
|
+
const rhythmPenalty = Math.min(20, violations.filter((violation) => violation.type === 'MONOTONOUS_RHYTHM').length * 6);
|
|
264
|
+
const lexicalPenalty = Math.min(30, violations.reduce((total, violation) => {
|
|
265
|
+
if (violation.type === 'SLOP_PHRASE' || violation.type === 'DEFINITIONAL_OPENING') return total + 5;
|
|
266
|
+
if (violation.type === 'STRUCTURAL_CLICHE') return total + 4;
|
|
267
|
+
return total;
|
|
268
|
+
}, 0));
|
|
269
|
+
const morphologyPenalty = Math.min(15,
|
|
270
|
+
(violations.some((violation) => violation.type === 'EXCESSIVE_NOMINALIZATION') ? 7.5 : 0)
|
|
271
|
+
+ Math.min(15, violations.filter((violation) => violation.type === 'PASSIVE_BLOATING').length * 7.5));
|
|
272
|
+
const vocabularyPenalty = metrics.mattr < thresholds.mattr[language] ? 5 : 0;
|
|
273
|
+
const entropyPenalty = violations.some((violation) => ['LOW_ENTROPY_REPETITION', 'SYMMETRIC_PARAGRAPHS', 'REPETITIVE_STARTERS'].includes(violation.type)) ? 5 : 0;
|
|
274
|
+
const profilePenalty = violations.some((violation) => violation.type === 'VOICE_PROFILE_DEVIATION') ? 5 : 0;
|
|
275
|
+
const vocabularyAndProfilePenalty = Math.min(10, vocabularyPenalty + entropyPenalty + profilePenalty);
|
|
276
|
+
return Math.max(0, Math.round(100 - burstinessPenalty - rhythmPenalty - lexicalPenalty - morphologyPenalty - vocabularyAndProfilePenalty));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function analyzeText(input, options = {}) {
|
|
280
|
+
const sanitized = sanitizeMarkdown(input);
|
|
281
|
+
const initial = tokenizeText(sanitized, options.language ?? 'auto');
|
|
282
|
+
const language = options.language && options.language !== 'auto' ? options.language : detectLanguage(initial.words);
|
|
283
|
+
if (!RULES[language]) throw new Error(`Unsupported language: ${language}`);
|
|
284
|
+
const tokenized = language === (options.language ?? 'auto') ? initial : tokenizeText(sanitized, language);
|
|
285
|
+
const profile = options.profile ?? DEFAULT_PROFILE;
|
|
286
|
+
const thresholds = { ...DEFAULT_PROFILE.thresholds, ...(profile.thresholds ?? {}) };
|
|
287
|
+
const metrics = calculateMetrics(tokenized, sanitized);
|
|
288
|
+
const patternMatches = patternViolations(tokenized, RULES[language], metrics.wordCount);
|
|
289
|
+
|
|
290
|
+
if (metrics.wordCount < thresholds.minimumWords || metrics.sentenceCount < thresholds.minimumSentences) {
|
|
291
|
+
const violations = patternMatches.filter((violation) => violation.type === 'SLOP_PHRASE');
|
|
292
|
+
return {
|
|
293
|
+
status: 'SKIP',
|
|
294
|
+
score: 100,
|
|
295
|
+
language,
|
|
296
|
+
warning: 'Text too short for statistical stylometry (< 3 sentences)',
|
|
297
|
+
metrics: Object.fromEntries(Object.entries({ ...metrics, nominalizationRatio: 0, passiveRatio: 0, voiceProfileDeviation: 0 }).map(([key, value]) => [key, typeof value === 'number' ? round(value) : value])),
|
|
298
|
+
violations,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const morphology = morphologyViolations(tokenized, RULES[language].morphology);
|
|
303
|
+
metrics.nominalizationRatio = morphology.nominalizationRatio;
|
|
304
|
+
metrics.passiveRatio = morphology.passiveRatio;
|
|
305
|
+
metrics.voiceProfileDeviation = voiceDeviation(metrics, tokenized, sanitized, profile);
|
|
306
|
+
const profileTargets = profile.targets ?? profile.metrics ?? {};
|
|
307
|
+
const targetMean = Number(profileTargets.meanSentenceLength);
|
|
308
|
+
const meanDeviation = targetMean > 0 ? Math.abs(metrics.meanSentenceLength - targetMean) / targetMean : 0;
|
|
309
|
+
const profileViolations = options.profile != null && meanDeviation > 0.3 ? [{
|
|
310
|
+
type: 'VOICE_PROFILE_DEVIATION',
|
|
311
|
+
location: 'Global Document',
|
|
312
|
+
match: 'Mean sentence length deviates >30% from target voice profile',
|
|
313
|
+
fixInstruction: 'Adjust overall sentence complexity so mean sentence length approaches the target profile.',
|
|
314
|
+
}] : [];
|
|
315
|
+
const violations = [
|
|
316
|
+
...patternMatches,
|
|
317
|
+
...monotonyViolations(tokenized),
|
|
318
|
+
...structuralViolations(tokenized, metrics),
|
|
319
|
+
...morphology.violations,
|
|
320
|
+
...profileViolations,
|
|
321
|
+
...statisticalViolations(metrics, thresholds, language),
|
|
322
|
+
];
|
|
323
|
+
const score = scoreResult(metrics, violations, language, thresholds);
|
|
324
|
+
const cvTarget = metrics.sentenceCount >= 8 ? thresholds.burstinessCVLong : thresholds.burstinessCVShort;
|
|
325
|
+
const forbidden = violations.some((violation) => violation.type === 'SLOP_PHRASE' || violation.type === 'DEFINITIONAL_OPENING');
|
|
326
|
+
const rhythmPass = metrics.adjacentDelta >= thresholds.adjacentDelta;
|
|
327
|
+
const distributionPass = metrics.sentenceCount < 8 || (metrics.shortSentenceRatio >= thresholds.shortSentenceRatio && metrics.longSentenceRatio >= thresholds.longSentenceRatio);
|
|
328
|
+
const status = score >= thresholds.passScore && !forbidden && metrics.burstinessCV >= cvTarget && rhythmPass && distributionPass ? 'PASS' : 'FAIL';
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
status,
|
|
332
|
+
score,
|
|
333
|
+
language,
|
|
334
|
+
metrics: Object.fromEntries(Object.entries(metrics).map(([key, value]) => [key, typeof value === 'number' ? round(value) : value])),
|
|
335
|
+
violations,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function formatHuman(result) {
|
|
340
|
+
const lines = [`Status: ${result.status}`, `Score: ${result.score}`, `Language: ${result.language}`];
|
|
341
|
+
if (result.warning) lines.push(`Warning: ${result.warning}`);
|
|
342
|
+
lines.push('', 'Metrics:');
|
|
343
|
+
for (const [name, value] of Object.entries(result.metrics)) lines.push(` ${name}: ${value}`);
|
|
344
|
+
if (result.violations.length) lines.push('', 'Violations:');
|
|
345
|
+
for (const violation of result.violations) lines.push(` ${violation.type} | ${violation.location} | ${violation.fixInstruction}`);
|
|
346
|
+
return `${lines.join('\n')}\n`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function readStdin() {
|
|
350
|
+
const chunks = [];
|
|
351
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
352
|
+
return Buffer.concat(chunks.map((chunk) => Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))).toString('utf8');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function runAnalyzeCli(argv, io = {}) {
|
|
356
|
+
const stdout = io.stdout ?? ((value) => process.stdout.write(value));
|
|
357
|
+
const stderr = io.stderr ?? ((value) => process.stderr.write(value));
|
|
358
|
+
const stdin = io.stdin ?? readStdin;
|
|
359
|
+
try {
|
|
360
|
+
const { values } = parseArgs({
|
|
361
|
+
args: argv,
|
|
362
|
+
options: {
|
|
363
|
+
file: { type: 'string' },
|
|
364
|
+
stdin: { type: 'boolean' },
|
|
365
|
+
lang: { type: 'string', default: 'auto' },
|
|
366
|
+
profile: { type: 'string' },
|
|
367
|
+
json: { type: 'boolean' },
|
|
368
|
+
help: { type: 'boolean' },
|
|
369
|
+
},
|
|
370
|
+
strict: true,
|
|
371
|
+
allowPositionals: false,
|
|
372
|
+
});
|
|
373
|
+
if (values.help) {
|
|
374
|
+
stdout('Usage: humanizer-lint (--file <path> | --stdin) [--lang id|en|auto] [--profile <path>] [--json]\n');
|
|
375
|
+
return 0;
|
|
376
|
+
}
|
|
377
|
+
if (Boolean(values.file) === Boolean(values.stdin)) throw new Error('Choose exactly one input: --file or --stdin');
|
|
378
|
+
if (!['id', 'en', 'auto'].includes(values.lang)) throw new Error('--lang must be id, en, or auto');
|
|
379
|
+
if (values.stdin && io.stdin == null && process.stdin.isTTY) throw new Error('--stdin requires piped input');
|
|
380
|
+
const text = values.file ? await new Promise((resolve, reject) => readFile(values.file, 'utf8', (error, data) => error ? reject(error) : resolve(data))) : await stdin();
|
|
381
|
+
const profile = values.profile ? JSON.parse(await new Promise((resolve, reject) => readFile(values.profile, 'utf8', (error, data) => error ? reject(error) : resolve(data)))) : undefined;
|
|
382
|
+
const result = analyzeText(text, { language: values.lang, profile });
|
|
383
|
+
stdout(values.json ? `${JSON.stringify(result, null, 2)}\n` : formatHuman(result));
|
|
384
|
+
return result.status === 'FAIL' ? 1 : 0;
|
|
385
|
+
} catch (error) {
|
|
386
|
+
stderr(`ERROR: ${error.message}\n`);
|
|
387
|
+
return 2;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function currentModulePath() {
|
|
392
|
+
let value = decodeURIComponent(new URL(import.meta.url).pathname);
|
|
393
|
+
if (/^\/[A-Za-z]:/.test(value)) value = value.slice(1);
|
|
394
|
+
return path.resolve(value);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isMainModule() {
|
|
398
|
+
if (!process.argv[1]) return false;
|
|
399
|
+
try {
|
|
400
|
+
return realpathSync(process.argv[1]) === realpathSync(currentModulePath());
|
|
401
|
+
} catch {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (isMainModule()) {
|
|
407
|
+
process.exitCode = await runAnalyzeCli(process.argv.slice(2));
|
|
408
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { promises as fs, realpathSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
|
|
6
|
+
import { sanitizeMarkdown } from './utils/sanitizer.mjs';
|
|
7
|
+
import { calculateMetrics, mean, punctuationRates } from './utils/metrics.mjs';
|
|
8
|
+
import { tokenizeText } from './utils/tokenizer.mjs';
|
|
9
|
+
|
|
10
|
+
const { mkdir, readFile, readdir, rename, stat, writeFile } = fs;
|
|
11
|
+
|
|
12
|
+
function round(value, places = 4) {
|
|
13
|
+
const factor = 10 ** places;
|
|
14
|
+
return Math.round(value * factor) / factor;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function averageObjects(objects) {
|
|
18
|
+
const keys = [...new Set(objects.flatMap((object) => Object.keys(object)))];
|
|
19
|
+
return Object.fromEntries(keys.map((key) => [key, round(mean(objects.map((object) => object[key] ?? 0)))]));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function fingerprintSamples(files, options = {}) {
|
|
23
|
+
if (!Array.isArray(files) || files.length < 1 || files.length > 5) throw new Error('Fingerprint requires 1 to 5 sample files');
|
|
24
|
+
const samples = [];
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
const raw = await readFile(file, 'utf8');
|
|
27
|
+
const sanitized = sanitizeMarkdown(raw);
|
|
28
|
+
const tokenized = tokenizeText(sanitized, 'auto');
|
|
29
|
+
const metrics = calculateMetrics(tokenized, sanitized);
|
|
30
|
+
if (metrics.wordCount < 20 || metrics.sentenceCount < 3) throw new Error(`Sample is too short: ${file}`);
|
|
31
|
+
samples.push({
|
|
32
|
+
...metrics,
|
|
33
|
+
averageParagraphSentenceCount: mean(tokenized.paragraphs.map((paragraph) => paragraph.sentences.length)),
|
|
34
|
+
punctuationRates: punctuationRates(sanitized, metrics.wordCount),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const punctuation = averageObjects(samples.map((sample) => sample.punctuationRates));
|
|
39
|
+
const profile = {
|
|
40
|
+
name: options.name ?? 'custom-voice',
|
|
41
|
+
version: 1,
|
|
42
|
+
sampleCount: samples.length,
|
|
43
|
+
language: options.language ?? 'auto',
|
|
44
|
+
metrics: {
|
|
45
|
+
meanSentenceLength: round(mean(samples.map((sample) => sample.meanSentenceLength))),
|
|
46
|
+
stdDev: round(mean(samples.map((sample) => sample.stdDev))),
|
|
47
|
+
burstinessCV: round(mean(samples.map((sample) => sample.burstinessCV))),
|
|
48
|
+
adjacentDelta: round(mean(samples.map((sample) => sample.adjacentDelta))),
|
|
49
|
+
mattr: round(mean(samples.map((sample) => sample.mattr))),
|
|
50
|
+
averageParagraphSentenceCount: round(mean(samples.map((sample) => sample.averageParagraphSentenceCount))),
|
|
51
|
+
punctuationRates: punctuation,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (options.output) {
|
|
56
|
+
const output = path.resolve(options.output);
|
|
57
|
+
await mkdir(path.dirname(output), { recursive: true });
|
|
58
|
+
const temporary = `${output}.tmp-${process.pid}`;
|
|
59
|
+
await writeFile(temporary, `${JSON.stringify(profile, null, 2)}\n`, 'utf8');
|
|
60
|
+
await rename(temporary, output);
|
|
61
|
+
}
|
|
62
|
+
return profile;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function collectInput(input) {
|
|
66
|
+
const info = await stat(input);
|
|
67
|
+
if (info.isFile()) return [path.resolve(input)];
|
|
68
|
+
if (!info.isDirectory()) throw new Error('--input must be a file or directory');
|
|
69
|
+
const entries = await readdir(input, { withFileTypes: true });
|
|
70
|
+
return entries
|
|
71
|
+
.filter((entry) => entry.isFile() && /\.(?:md|txt)$/i.test(entry.name))
|
|
72
|
+
.map((entry) => path.resolve(input, entry.name))
|
|
73
|
+
.sort()
|
|
74
|
+
.slice(0, 5);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function runFingerprintCli(argv, io = {}) {
|
|
78
|
+
const stdout = io.stdout ?? ((value) => process.stdout.write(value));
|
|
79
|
+
const stderr = io.stderr ?? ((value) => process.stderr.write(value));
|
|
80
|
+
try {
|
|
81
|
+
const { values } = parseArgs({
|
|
82
|
+
args: argv,
|
|
83
|
+
options: {
|
|
84
|
+
input: { type: 'string' },
|
|
85
|
+
output: { type: 'string' },
|
|
86
|
+
name: { type: 'string' },
|
|
87
|
+
lang: { type: 'string', default: 'auto' },
|
|
88
|
+
help: { type: 'boolean' },
|
|
89
|
+
},
|
|
90
|
+
strict: true,
|
|
91
|
+
allowPositionals: false,
|
|
92
|
+
});
|
|
93
|
+
if (values.help) {
|
|
94
|
+
stdout('Usage: humanizer-fingerprint --input <file-or-dir> --output <profile.json> [--name <name>] [--lang id|en|auto]\n');
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
if (!values.input || !values.output) throw new Error('--input and --output are required');
|
|
98
|
+
if (!['id', 'en', 'auto'].includes(values.lang)) throw new Error('--lang must be id, en, or auto');
|
|
99
|
+
const files = await collectInput(values.input);
|
|
100
|
+
if (!files.length) throw new Error('No .txt or .md samples found');
|
|
101
|
+
const profile = await fingerprintSamples(files, { output: values.output, name: values.name, language: values.lang });
|
|
102
|
+
stdout(`${JSON.stringify(profile, null, 2)}\n`);
|
|
103
|
+
return 0;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
stderr(`ERROR: ${error.message}\n`);
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function currentModulePath() {
|
|
111
|
+
let value = decodeURIComponent(new URL(import.meta.url).pathname);
|
|
112
|
+
if (/^\/[A-Za-z]:/.test(value)) value = value.slice(1);
|
|
113
|
+
return path.resolve(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isMainModule() {
|
|
117
|
+
if (!process.argv[1]) return false;
|
|
118
|
+
try {
|
|
119
|
+
return realpathSync(process.argv[1]) === realpathSync(currentModulePath());
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isMainModule()) {
|
|
126
|
+
process.exitCode = await runFingerprintCli(process.argv.slice(2));
|
|
127
|
+
}
|