calliope-ts 0.0.4 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -3
- package/dist/calliope/bracketing.d.ts.map +1 -1
- package/dist/calliope/bracketing.js +59 -5
- package/dist/calliope/deps.d.ts +5 -1
- package/dist/calliope/deps.d.ts.map +1 -1
- package/dist/calliope/deps.js +53 -3
- package/dist/calliope/engine.d.ts.map +1 -1
- package/dist/calliope/engine.js +6 -1
- package/dist/calliope/relstress.d.ts.map +1 -1
- package/dist/calliope/relstress.js +411 -15
- package/dist/calliope/syntax.d.ts +33 -0
- package/dist/calliope/syntax.d.ts.map +1 -1
- package/dist/calliope/syntax.js +199 -0
- package/dist/data/en_common_words.json +1 -0
- package/dist/display.d.ts +15 -1
- package/dist/display.d.ts.map +1 -1
- package/dist/display.js +112 -10
- package/dist/fabbhalle.d.ts +54 -0
- package/dist/fabbhalle.d.ts.map +1 -0
- package/dist/fabbhalle.js +404 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +86 -33
- package/dist/phonological.d.ts.map +1 -1
- package/dist/phonological.js +7 -1
- package/dist/rewriteEn.d.ts +14 -0
- package/dist/rewriteEn.d.ts.map +1 -0
- package/dist/rewriteEn.js +392 -0
- package/dist/rhyme.d.ts +35 -3
- package/dist/rhyme.d.ts.map +1 -1
- package/dist/rhyme.js +176 -11
- package/dist/russian/accentuator.d.ts +31 -0
- package/dist/russian/accentuator.d.ts.map +1 -0
- package/dist/russian/accentuator.js +546 -0
- package/dist/russian/collocations.d.ts +7 -0
- package/dist/russian/collocations.d.ts.map +1 -0
- package/dist/russian/collocations.js +38 -0
- package/dist/russian/compounds.d.ts +6 -0
- package/dist/russian/compounds.d.ts.map +1 -0
- package/dist/russian/compounds.js +81 -0
- package/dist/russian/download.d.ts +23 -0
- package/dist/russian/download.d.ts.map +1 -0
- package/dist/russian/download.js +117 -0
- package/dist/russian/engine.d.ts +6 -0
- package/dist/russian/engine.d.ts.map +1 -0
- package/dist/russian/engine.js +1424 -0
- package/dist/russian/parser.d.ts +8 -0
- package/dist/russian/parser.d.ts.map +1 -0
- package/dist/russian/parser.js +92 -0
- package/dist/russian/paths.d.ts +55 -0
- package/dist/russian/paths.d.ts.map +1 -0
- package/dist/russian/paths.js +114 -0
- package/dist/russian/rewrite.d.ts +6 -0
- package/dist/russian/rewrite.d.ts.map +1 -0
- package/dist/russian/rewrite.js +400 -0
- package/dist/russian/rhyme.d.ts +18 -0
- package/dist/russian/rhyme.d.ts.map +1 -0
- package/dist/russian/rhyme.js +230 -0
- package/dist/russian/syllabifier.d.ts +14 -0
- package/dist/russian/syllabifier.d.ts.map +1 -0
- package/dist/russian/syllabifier.js +475 -0
- package/dist/russian/types.d.ts +94 -0
- package/dist/russian/types.d.ts.map +1 -0
- package/dist/russian/types.js +27 -0
- package/dist/scandroidNative/constants.d.ts +16 -0
- package/dist/scandroidNative/constants.d.ts.map +1 -0
- package/dist/scandroidNative/constants.js +60 -0
- package/dist/scandroidNative/deduceParameters.d.ts +16 -0
- package/dist/scandroidNative/deduceParameters.d.ts.map +1 -0
- package/dist/scandroidNative/deduceParameters.js +69 -0
- package/dist/scandroidNative/dictionary-data.d.ts +2 -0
- package/dist/scandroidNative/dictionary-data.d.ts.map +1 -0
- package/dist/scandroidNative/dictionary-data.js +744 -0
- package/dist/scandroidNative/dictionary.d.ts +12 -0
- package/dist/scandroidNative/dictionary.d.ts.map +1 -0
- package/dist/scandroidNative/dictionary.js +50 -0
- package/dist/scandroidNative/engine.d.ts +41 -0
- package/dist/scandroidNative/engine.d.ts.map +1 -0
- package/dist/scandroidNative/engine.js +119 -0
- package/dist/scandroidNative/machine.d.ts +93 -0
- package/dist/scandroidNative/machine.d.ts.map +1 -0
- package/dist/scandroidNative/machine.js +795 -0
- package/dist/scandroidNative/positioner.d.ts +32 -0
- package/dist/scandroidNative/positioner.d.ts.map +1 -0
- package/dist/scandroidNative/positioner.js +208 -0
- package/dist/scandroidNative/syllabizer.d.ts +3 -0
- package/dist/scandroidNative/syllabizer.d.ts.map +1 -0
- package/dist/scandroidNative/syllabizer.js +215 -0
- package/dist/scandroidNative/utilities.d.ts +37 -0
- package/dist/scandroidNative/utilities.d.ts.map +1 -0
- package/dist/scandroidNative/utilities.js +86 -0
- package/dist/scansion.d.ts.map +1 -1
- package/dist/scansion.js +118 -14
- package/dist/stress.d.ts +1 -0
- package/dist/stress.d.ts.map +1 -1
- package/dist/stress.js +502 -42
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +27 -6
- package/tools/fetch-russian-data.mjs +30 -0
- package/webapp/public/app.js +2418 -0
- package/webapp/public/assets/GithubLogo.png +0 -0
- package/webapp/public/assets/NPM2.png +0 -0
- package/webapp/public/assets/fonts/MurenaBold-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/MurenaItalic-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/MurenaRegular-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/Myra_4F_Caps_Bold-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/SinkinSans-700Bold.otf +0 -0
- package/webapp/public/assets/fonts/butterflies.ttf +0 -0
- package/webapp/public/assets/fonts/midcase_blackline-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/neueral-bold-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/neueral-regular-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/popelka-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/vremenagrotesk-webfont.woff +0 -0
- package/webapp/public/assets/fonts/wicky_javick_bold-webfont.woff2 +0 -0
- package/webapp/public/assets/logos/Logo_Var_10_Celtic_Knot_SVG.svg +116 -0
- package/webapp/public/assets/logos/Logo_Var_11_Graph_SVG.svg +313 -0
- package/webapp/public/assets/logos/Logo_Var_12_Limacon_Knot_SVG.svg +3942 -0
- package/webapp/public/assets/logos/Logo_Var_13_Crescents_SVG.svg +1030 -0
- package/webapp/public/assets/logos/Logo_Var_1_Spiral_SVG.svg +1491 -0
- package/webapp/public/assets/logos/Logo_Var_2_Tunnel_SVG.svg +12 -0
- package/webapp/public/assets/logos/Logo_Var_3_Triskelion_SVG.svg +10 -0
- package/webapp/public/assets/logos/Logo_Var_4_Quinquetra_PNG.png +0 -0
- package/webapp/public/assets/logos/Logo_Var_5_Penrose_Pentagram_SVG.svg +37 -0
- package/webapp/public/assets/logos/Logo_Var_6_Triskelion2_SVG.svg +10 -0
- package/webapp/public/assets/logos/Logo_Var_7_SixfoldSpirals_SVG.svg +20 -0
- package/webapp/public/assets/logos/Logo_Var_8_Radial_Labyrinth_SVG.svg +163 -0
- package/webapp/public/assets/logos/Logo_Var_9_Radial_Labyrinth_2_SVG.svg +106 -0
- package/webapp/public/index.html +316 -0
- package/webapp/public/styles.css +1114 -0
- package/webapp/server.mjs +680 -0
- package/src/caesura.ts +0 -201
- package/src/calliope/boundaries.ts +0 -190
- package/src/calliope/bracketing.ts +0 -390
- package/src/calliope/deps.ts +0 -160
- package/src/calliope/engine.ts +0 -77
- package/src/calliope/feats.ts +0 -46
- package/src/calliope/names.ts +0 -44
- package/src/calliope/postag.ts +0 -253
- package/src/calliope/prosodic.ts +0 -262
- package/src/calliope/relstress.ts +0 -645
- package/src/calliope/stressrules.ts +0 -147
- package/src/calliope/syntax.ts +0 -218
- package/src/calliope/udpos.ts +0 -152
- package/src/calliope_src_contents.md +0 -19049
- package/src/clio/caesura.ts +0 -145
- package/src/clio/depfix.ts +0 -88
- package/src/clio/display.ts +0 -1042
- package/src/clio/engine.ts +0 -38
- package/src/clio/parser.ts +0 -845
- package/src/clio/phonological.ts +0 -849
- package/src/clio/phrasestress.ts +0 -108
- package/src/clio/pipeline.ts +0 -154
- package/src/clio/rhyme.ts +0 -740
- package/src/clio/scandroid.ts +0 -434
- package/src/clio/scansion.ts +0 -1130
- package/src/clio/semantics.ts +0 -134
- package/src/clio/stress.ts +0 -1731
- package/src/clio/tagfix.ts +0 -104
- package/src/depfix.ts +0 -88
- package/src/display.ts +0 -1138
- package/src/engine.ts +0 -22
- package/src/index.ts +0 -690
- package/src/parser.ts +0 -501
- package/src/phonological.ts +0 -917
- package/src/phrasestress.ts +0 -108
- package/src/rhyme.ts +0 -748
- package/src/scandroid.ts +0 -434
- package/src/scansion.ts +0 -1174
- package/src/semantics.ts +0 -134
- package/src/stress.ts +0 -2111
- package/src/tagfix.ts +0 -104
- package/src/types.ts +0 -265
- package/tests/DataForHayesLinesOnly.txt +0 -364
- package/tests/DataForHayesStressSymbolsRevised.txt +0 -728
- package/tests/basic.test.ts +0 -1102
- package/tests/bench-hayes.mjs +0 -72
- package/tests/epg64.meter.train.txt +0 -8139
- package/tests/litlab-sample-2016.txt +0 -1738
- package/tests/prosodic.meter.train.txt +0 -13192
- package/tests/wagner-stress.test.ts +0 -188
- package/tsconfig.json +0 -29
- package/vitest.config.d.ts +0 -2
- package/vitest.config.js +0 -14
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Calliope TS — web app server.
|
|
3
|
+
// Serves the static front-end from webapp/public and exposes the full analysis
|
|
4
|
+
// pipeline as structured JSON at POST /api/analyze. All the information the
|
|
5
|
+
// CLI's Reading and Detailed views print (plus the raw UDPipe / nounsing-pro
|
|
6
|
+
// substrates) is serialized here so the browser can render it interactively.
|
|
7
|
+
|
|
8
|
+
import * as http from 'node:http';
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
import { analyzeReadingDocument } from '../dist/index.js';
|
|
14
|
+
import { analyzeReadingDocumentClio } from '../dist/clio/pipeline.js';
|
|
15
|
+
import { isPunctuation } from '../dist/parser.js';
|
|
16
|
+
import { syllabifyWord, syllableVowelLengths, vowelLengthOf } from '../dist/phonological.js';
|
|
17
|
+
import { computeCaesurae } from '../dist/caesura.js';
|
|
18
|
+
import { buildFabbHalleGridsForPoem } from '../dist/fabbhalle.js';
|
|
19
|
+
import { computeBoundaries } from '../dist/calliope/boundaries.js';
|
|
20
|
+
import { summarizePoem, analyzePhonopoetics } from '../dist/rhyme.js';
|
|
21
|
+
import * as nounsing from 'nounsing-pro';
|
|
22
|
+
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
25
|
+
const PORT = Number(process.env.PORT || 4321);
|
|
26
|
+
|
|
27
|
+
// ─── Serialization helpers ──────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const normWordForm = (s) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z]/g, '');
|
|
30
|
+
|
|
31
|
+
/** Non-punctuation, syllable-bearing words across a line's results, in order. */
|
|
32
|
+
function collectLineWords(ln) {
|
|
33
|
+
const ws = [];
|
|
34
|
+
for (const res of ln.results) {
|
|
35
|
+
for (const w of res.sentence.words) {
|
|
36
|
+
if (!isPunctuation(w.lexicalClass) && w.syllables.length > 0) ws.push(w);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return ws;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Orthographic syllable chunks for a token, aligned to its word's syllables.
|
|
43
|
+
* Mirrors display.ts colourToken(): fast path when the syllabifier's chunks
|
|
44
|
+
* reconstruct the token exactly; otherwise walk the token char-by-char. */
|
|
45
|
+
function chunkToken(tokenText, word) {
|
|
46
|
+
const sylCount = Math.max(1, word.syllables.length);
|
|
47
|
+
const chunks = syllabifyWord(
|
|
48
|
+
tokenText, sylCount, syllableVowelLengths(word.syllables),
|
|
49
|
+
word.morphSuffix, word.morphPrefix,
|
|
50
|
+
);
|
|
51
|
+
if (chunks.join('') === tokenText) {
|
|
52
|
+
return chunks.map((c, i) => ({ text: c, si: Math.min(i, word.syllables.length - 1) }));
|
|
53
|
+
}
|
|
54
|
+
// Fallback: assign each original character to a syllable by chunk lengths.
|
|
55
|
+
const lens = chunks.map(c => c.length);
|
|
56
|
+
const out = [];
|
|
57
|
+
let ci = 0, consumed = 0;
|
|
58
|
+
const push = (ch, si) => {
|
|
59
|
+
const last = out[out.length - 1];
|
|
60
|
+
if (last && last.si === si) last.text += ch;
|
|
61
|
+
else out.push({ text: ch, si });
|
|
62
|
+
};
|
|
63
|
+
for (const ch of tokenText) {
|
|
64
|
+
if (ch === '-') { push(ch, Math.min(ci, word.syllables.length - 1)); continue; }
|
|
65
|
+
while (ci < lens.length - 1 && consumed >= lens[ci]) { ci++; consumed = 0; }
|
|
66
|
+
push(ch, Math.min(ci, word.syllables.length - 1));
|
|
67
|
+
consumed++;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Structural port of projectStressOntoLine(): the verbatim raw line split into
|
|
73
|
+
* gap segments (spaces, punctuation — emitted untouched) and word segments
|
|
74
|
+
* (chunked into syllables, each chunk pointing at its word + syllable). */
|
|
75
|
+
function buildSegments(rawLine, words) {
|
|
76
|
+
const tokenRe = /[A-Za-z\u00C0-\u024F]+(?:['’\-][A-Za-z\u00C0-\u024F]+)*/g; // accented Latin included (Milésien)
|
|
77
|
+
const segments = [];
|
|
78
|
+
let cursor = 0, wi = 0, m;
|
|
79
|
+
const pushGap = (text) => { if (text) segments.push({ t: 'gap', text }); };
|
|
80
|
+
while ((m = tokenRe.exec(rawLine)) !== null) {
|
|
81
|
+
const start = m.index, end = start + m[0].length;
|
|
82
|
+
pushGap(rawLine.slice(cursor, start));
|
|
83
|
+
cursor = end;
|
|
84
|
+
const token = m[0];
|
|
85
|
+
const tokNorm = normWordForm(token);
|
|
86
|
+
const matches = (w) => {
|
|
87
|
+
if (!w) return false;
|
|
88
|
+
const wn = normWordForm(w.word);
|
|
89
|
+
return wn.length > 0 && (tokNorm === wn || tokNorm.startsWith(wn));
|
|
90
|
+
};
|
|
91
|
+
let matched = -1;
|
|
92
|
+
if (matches(words[wi])) matched = wi;
|
|
93
|
+
else {
|
|
94
|
+
for (let k = wi; k < Math.min(words.length, wi + 4); k++) {
|
|
95
|
+
if (matches(words[k])) { matched = k; break; }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (matched >= 0) {
|
|
99
|
+
segments.push({ t: 'word', w: matched, chunks: chunkToken(token, words[matched]) });
|
|
100
|
+
wi = matched + 1;
|
|
101
|
+
} else {
|
|
102
|
+
pushGap(token);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
pushGap(rawLine.slice(cursor));
|
|
106
|
+
return segments;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Fine-grained syllable phonology derived from the ARPAbet transcription:
|
|
110
|
+
* onset geometry, nucleus type, rime structure, coda status. */
|
|
111
|
+
const ARPA_VOWEL = /^(AA|AE|AH|AO|AW|AY|EH|ER|EY|IH|IY|OW|OY|UH|UW)/;
|
|
112
|
+
const ARPA_DIPH = new Set(['AY', 'AW', 'OY', 'EY', 'OW']);
|
|
113
|
+
function sylPhonology(phonesStr) {
|
|
114
|
+
const toks = String(phonesStr ?? '').replace(/[()]/g, '').trim().split(/\s+/).filter(Boolean);
|
|
115
|
+
const vIdx = toks.findIndex(t => ARPA_VOWEL.test(t));
|
|
116
|
+
if (vIdx < 0) return null;
|
|
117
|
+
const onsetN = vIdx;
|
|
118
|
+
const codaN = toks.length - vIdx - 1;
|
|
119
|
+
const vowel = toks[vIdx].replace(/\d/g, '');
|
|
120
|
+
const isDiph = ARPA_DIPH.has(vowel);
|
|
121
|
+
let long = isDiph;
|
|
122
|
+
try { if (vowelLengthOf(phonesStr) === 'long') long = true; } catch { /* keep */ }
|
|
123
|
+
const rime = codaN === 0 ? (long ? '-VV' : '-V')
|
|
124
|
+
: long ? (codaN >= 2 ? '-TCC' : '-TC')
|
|
125
|
+
: (codaN >= 2 ? '-LCC' : '-LC');
|
|
126
|
+
return {
|
|
127
|
+
onset: onsetN === 0 ? '0' : 'C'.repeat(Math.min(3, onsetN)),
|
|
128
|
+
nucleus: isDiph ? 'diphthong' : 'monophthong',
|
|
129
|
+
vlen: long ? 'long' : 'short',
|
|
130
|
+
codaC: codaN,
|
|
131
|
+
open: codaN === 0,
|
|
132
|
+
rime,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const SYL_POSITION = ['final', 'penult', 'antepenult', 'preantepenult'];
|
|
136
|
+
|
|
137
|
+
function serializeWord(w, idx, firstSyl) {
|
|
138
|
+
const dep = w.dependency;
|
|
139
|
+
// Orthographic syllable chunks for display (syl.text from the dictionary is
|
|
140
|
+
// phone-based); group the token chunking by syllable index.
|
|
141
|
+
const display = w.displayWord ?? w.word;
|
|
142
|
+
const orth = new Array(w.syllables.length).fill('');
|
|
143
|
+
try {
|
|
144
|
+
for (const c of chunkToken(display, w)) orth[c.si] = (orth[c.si] ?? '') + c.text;
|
|
145
|
+
} catch { /* fall back to phone text below */ }
|
|
146
|
+
return {
|
|
147
|
+
i: idx,
|
|
148
|
+
text: w.displayWord ?? w.word,
|
|
149
|
+
norm: w.word,
|
|
150
|
+
pos: w.lexicalClass,
|
|
151
|
+
isContent: !!w.isContent,
|
|
152
|
+
phraseStress: w.phraseStress ?? 0,
|
|
153
|
+
feats: w.featsMap && Object.keys(w.featsMap).length ? w.featsMap : null,
|
|
154
|
+
canonicalRel: w.canonicalRel ?? null,
|
|
155
|
+
dep: dep ? {
|
|
156
|
+
rel: dep.dependentType,
|
|
157
|
+
isRoot: dep.governorIndex === 0 || dep.dependentType === 'root',
|
|
158
|
+
govWord: dep.governor?.word ?? null,
|
|
159
|
+
govIndex: dep.governorIndex,
|
|
160
|
+
} : null,
|
|
161
|
+
flags: {
|
|
162
|
+
person: !!w.isPersonName,
|
|
163
|
+
place: !!w.isPlaceName,
|
|
164
|
+
given: !!w.discourseGiven,
|
|
165
|
+
coordGiven: !!w.coordinateGiven,
|
|
166
|
+
},
|
|
167
|
+
morph: (w.morphPrefix || w.morphSuffix)
|
|
168
|
+
? { prefix: w.morphPrefix ?? null, suffix: w.morphSuffix ?? null } : null,
|
|
169
|
+
firstSyl,
|
|
170
|
+
syls: w.syllables.map((s, si) => ({
|
|
171
|
+
si,
|
|
172
|
+
text: orth[si] || s.text,
|
|
173
|
+
phones: s.phones,
|
|
174
|
+
weight: s.weight ?? null,
|
|
175
|
+
lex: s.lexicalStress ?? s.stress,
|
|
176
|
+
boosted: s.stress,
|
|
177
|
+
rel: s.relativeStress ?? 'w',
|
|
178
|
+
extrametrical: s.extrametrical ?? null,
|
|
179
|
+
pos: SYL_POSITION[Math.min(3, w.syllables.length - 1 - si)],
|
|
180
|
+
ph: sylPhonology(s.phones),
|
|
181
|
+
})),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Map each display word to its IU.PP.CP membership in the prosodic hierarchy. */
|
|
186
|
+
function hierarchyMembership(ius, words) {
|
|
187
|
+
const loc = new Map();
|
|
188
|
+
ius.forEach((iu, ii) =>
|
|
189
|
+
iu.phonologicalPhrases.forEach((pp, pi) =>
|
|
190
|
+
pp.cliticGroups.forEach((cg, ci) =>
|
|
191
|
+
cg.tokens.forEach(t => loc.set(t, { iu: ii, pp: pi, cp: ci })))));
|
|
192
|
+
return words.map(w => loc.get(w) ?? null);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Serialize the hierarchy as nested word-index lists (IU → PP → CP). */
|
|
196
|
+
function serializeHierarchy(ius, words) {
|
|
197
|
+
const indexOf = new Map(words.map((w, i) => [w, i]));
|
|
198
|
+
return ius.map(iu => ({
|
|
199
|
+
pps: iu.phonologicalPhrases.map(pp => ({
|
|
200
|
+
cps: pp.cliticGroups.map(cg =>
|
|
201
|
+
cg.tokens.map(t => indexOf.get(t)).filter(i => i !== undefined)),
|
|
202
|
+
})),
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Align scansion feet to global syllable indices (display.ts Layer 6 logic). */
|
|
207
|
+
function serializeFeet(scansion, totalSyls) {
|
|
208
|
+
const isSylChar = (ch) => 'xXwWnNmMsS'.includes(ch);
|
|
209
|
+
const feet = [];
|
|
210
|
+
let sylIdx = 0;
|
|
211
|
+
for (const rawFoot of scansion.split('|')) {
|
|
212
|
+
const cells = [];
|
|
213
|
+
for (const ch of rawFoot) {
|
|
214
|
+
if (ch === '-') { cells.push({ silent: true }); continue; }
|
|
215
|
+
if (!isSylChar(ch)) continue;
|
|
216
|
+
if (sylIdx < totalSyls) cells.push({ s: sylIdx++, mark: ch.toLowerCase() });
|
|
217
|
+
}
|
|
218
|
+
feet.push({ pattern: rawFoot, cells });
|
|
219
|
+
}
|
|
220
|
+
return feet;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function serializeDetail(d) {
|
|
224
|
+
if (!d) return null;
|
|
225
|
+
return {
|
|
226
|
+
meter: d.meter,
|
|
227
|
+
meterName: d.meterName,
|
|
228
|
+
footCount: d.footCount,
|
|
229
|
+
scansion: d.scansion,
|
|
230
|
+
certainty: d.certainty,
|
|
231
|
+
summary: d.summary,
|
|
232
|
+
ranking: (d.ranking ?? []).map(r => ({ meter: r.meter, score: Number(r.score.toFixed(3)) })),
|
|
233
|
+
standaloneMeter: d.standaloneMeter ?? null,
|
|
234
|
+
consensusMeter: d.consensusMeter ?? null,
|
|
235
|
+
rhythmNote: d.rhythmNote ?? null,
|
|
236
|
+
metricalityNote: d.metricalityNote ?? null,
|
|
237
|
+
formNote: d.formNote ?? null,
|
|
238
|
+
rhyme: d.rhyme ? {
|
|
239
|
+
endWord: d.rhyme.endWord,
|
|
240
|
+
letter: d.rhyme.letter,
|
|
241
|
+
type: d.rhyme.type ?? null,
|
|
242
|
+
matchedLine: d.rhyme.matchedLine ?? null,
|
|
243
|
+
notation: d.rhyme.notation ?? null,
|
|
244
|
+
internal: (d.rhyme.internal ?? []).map(iw => ({
|
|
245
|
+
word: iw.word, letter: iw.letter, type: iw.type ?? null,
|
|
246
|
+
})),
|
|
247
|
+
} : null,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function serializeLine(ln) {
|
|
252
|
+
const res = ln.results[0];
|
|
253
|
+
if (!res) return { raw: ln.raw, parsed: false };
|
|
254
|
+
|
|
255
|
+
const words = collectLineWords(ln);
|
|
256
|
+
let firstSyl = 0;
|
|
257
|
+
const serWords = words.map((w, i) => {
|
|
258
|
+
const sw = serializeWord(w, i, firstSyl);
|
|
259
|
+
firstSyl += w.syllables.length;
|
|
260
|
+
return sw;
|
|
261
|
+
});
|
|
262
|
+
const totalSyls = firstSyl;
|
|
263
|
+
|
|
264
|
+
// Flat global syllable list (word order — the scansion domain's order).
|
|
265
|
+
const syllables = [];
|
|
266
|
+
serWords.forEach(sw => sw.syls.forEach(s => syllables.push({
|
|
267
|
+
w: sw.i, si: s.si, text: s.text, phones: s.phones, weight: s.weight,
|
|
268
|
+
lex: s.lex, rel: s.rel,
|
|
269
|
+
})));
|
|
270
|
+
|
|
271
|
+
const ius = res.phonologicalHierarchy;
|
|
272
|
+
const allWords = res.sentence.words;
|
|
273
|
+
|
|
274
|
+
let boundaries = null;
|
|
275
|
+
try {
|
|
276
|
+
const b = computeBoundaries(allWords, ius);
|
|
277
|
+
boundaries = {
|
|
278
|
+
phi: b.phi.map(x => ({ strength: x.strength, syl: x.syllableIndex })),
|
|
279
|
+
iota: b.iota.map(x => ({ strength: x.strength, syl: x.syllableIndex })),
|
|
280
|
+
};
|
|
281
|
+
} catch { /* clio lines may lack substrate for graded boundaries */ }
|
|
282
|
+
|
|
283
|
+
let caesurae = [];
|
|
284
|
+
try {
|
|
285
|
+
caesurae = [...computeCaesurae(allWords, ius, res.phonologicalScansion.scansion)]
|
|
286
|
+
.map(([after, info]) => ({ after, kind: info.kind, strength: info.strength }));
|
|
287
|
+
} catch { /* non-fatal */ }
|
|
288
|
+
|
|
289
|
+
const deps = serWords
|
|
290
|
+
.filter(sw => sw.dep)
|
|
291
|
+
.map(sw => {
|
|
292
|
+
const govIdx = sw.dep.isRoot ? -1
|
|
293
|
+
: serWords.findIndex(o => words[o.i].index === sw.dep.govIndex
|
|
294
|
+
&& words[o.i] === (words[sw.i].dependency?.governor ?? null));
|
|
295
|
+
// Prefer object identity; fall back to name match when identity fails.
|
|
296
|
+
let to = govIdx;
|
|
297
|
+
if (to === -1 && !sw.dep.isRoot) {
|
|
298
|
+
to = words.findIndex(w => w === words[sw.i].dependency?.governor);
|
|
299
|
+
}
|
|
300
|
+
return { from: sw.i, to: sw.dep.isRoot ? -1 : to, rel: sw.dep.rel };
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
raw: ln.raw,
|
|
305
|
+
parsed: true,
|
|
306
|
+
segments: buildSegments(ln.raw, words),
|
|
307
|
+
words: serWords,
|
|
308
|
+
syllables,
|
|
309
|
+
hierarchy: serializeHierarchy(ius, words),
|
|
310
|
+
unitOf: hierarchyMembership(ius, words),
|
|
311
|
+
boundaries,
|
|
312
|
+
caesurae,
|
|
313
|
+
keyStresses: (res.keyStresses ?? []).map(k => ({
|
|
314
|
+
unitType: k.unitType, pattern: k.pattern, weight: k.weight, positions: k.positions,
|
|
315
|
+
})),
|
|
316
|
+
feet: serializeFeet(res.phonologicalScansion.scansion, totalSyls),
|
|
317
|
+
detail: serializeDetail(res.phonologicalScansion),
|
|
318
|
+
// Charles Hartman's Scandroid (2005), run as a fully independent second
|
|
319
|
+
// opinion: its own dictionary, syllabifier, and algorithms, working only
|
|
320
|
+
// from the raw line text.
|
|
321
|
+
scandroid: (() => {
|
|
322
|
+
const sn = res.scandroidNative;
|
|
323
|
+
if (!sn) return null;
|
|
324
|
+
const serFoot = f => f ? {
|
|
325
|
+
algorithm: f.algorithm, scanString: f.scanString, marksString: f.marksString,
|
|
326
|
+
substitutions: f.substitutions, ok: f.ok, failReason: f.failReason ?? null,
|
|
327
|
+
} : null;
|
|
328
|
+
return {
|
|
329
|
+
metronName: sn.metronName,
|
|
330
|
+
lineLengthName: sn.lineLengthName,
|
|
331
|
+
lineFeetSet: sn.lineFeetSet,
|
|
332
|
+
verdict: serFoot(sn.verdict),
|
|
333
|
+
corralTheWeird: serFoot(sn.corralTheWeird ?? null),
|
|
334
|
+
maximizeTheNormal: serFoot(sn.maximizeTheNormal ?? null),
|
|
335
|
+
promotions: sn.promotions,
|
|
336
|
+
};
|
|
337
|
+
})(),
|
|
338
|
+
// fabbHalle is attached poem-level in analyze() — F&H's rule set is
|
|
339
|
+
// DISCOVERED by grid construction over the whole poem, not stipulated
|
|
340
|
+
// per line (see attachFabbHalle below).
|
|
341
|
+
fabbHalle: null,
|
|
342
|
+
deps,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Fabb–Halle bracketed grids (Meter in Poetry, 2008) — an independent
|
|
347
|
+
* second opinion whose rule set is discovered by F&H's own poem-level
|
|
348
|
+
* procedure (buildFabbHalleGridsForPoem) from lexical stress alone; the
|
|
349
|
+
* main engine's meter verdict is reported alongside for comparison but
|
|
350
|
+
* never consulted. */
|
|
351
|
+
function attachFabbHalle(resultStanzas) {
|
|
352
|
+
try {
|
|
353
|
+
const lines = resultStanzas.flatMap(st => st.lines).filter(l => l.parsed);
|
|
354
|
+
const allFhSyls = lines.map(line => {
|
|
355
|
+
const perWord = new Map();
|
|
356
|
+
line.syllables.forEach(s => perWord.set(s.w, (perWord.get(s.w) ?? 0) + 1));
|
|
357
|
+
return line.syllables.map(s => {
|
|
358
|
+
const poly = (perWord.get(s.w) ?? 1) > 1;
|
|
359
|
+
// Monosyllabic function words are clitics — no lexical stress in
|
|
360
|
+
// F&H's sense (they never block a neighbour's maximumhood).
|
|
361
|
+
const cliticMono = !poly && line.words[s.w] && !line.words[s.w].isContent;
|
|
362
|
+
return { text: s.text, lex: cliticMono ? 0 : (s.lex ?? 0), poly };
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
const poem = buildFabbHalleGridsForPoem(allFhSyls);
|
|
366
|
+
if (!poem) return null;
|
|
367
|
+
lines.forEach((line, i) => {
|
|
368
|
+
const fh = poem.grids[i];
|
|
369
|
+
const engineMeter = line.detail?.meter ?? null;
|
|
370
|
+
const agrees = engineMeter
|
|
371
|
+
? (engineMeter.includes(poem.schema)
|
|
372
|
+
|| (poem.schema === 'loose' && /accentual|loose|dolnik|free/i.test(engineMeter)))
|
|
373
|
+
: null;
|
|
374
|
+
line.fabbHalle = fh ? {
|
|
375
|
+
rule: fh.ruleLabel
|
|
376
|
+
+ ' · poem-discovered by grid construction'
|
|
377
|
+
+ (agrees === null ? '' : agrees ? ', agreeing with the engine' : ` (the engine read ${engineMeter})`),
|
|
378
|
+
rows: fh.rows,
|
|
379
|
+
maxima: fh.maxima, violations: fh.violations, metrical: fh.metrical,
|
|
380
|
+
looseFeet: fh.looseFeet ?? null,
|
|
381
|
+
schema: poem.schema,
|
|
382
|
+
} : null;
|
|
383
|
+
});
|
|
384
|
+
return poem.schema;
|
|
385
|
+
} catch { return null; }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Enjambment summary — port of display.ts summariseEnjambment(). */
|
|
389
|
+
function lineRunsOn(raw) {
|
|
390
|
+
const t = raw.replace(/["'’”»)\]]+$/, '').trimEnd();
|
|
391
|
+
if (!t) return false;
|
|
392
|
+
return !/[.!?;:,—–…]$/.test(t);
|
|
393
|
+
}
|
|
394
|
+
function summariseEnjambment(stanzas) {
|
|
395
|
+
const raws = stanzas.flatMap(st => st.lines.map(l => l.raw));
|
|
396
|
+
if (raws.length < 2) return null;
|
|
397
|
+
const interior = raws.slice(0, -1);
|
|
398
|
+
const enjambed = [];
|
|
399
|
+
interior.forEach((r, i) => { if (lineRunsOn(r)) enjambed.push(i + 1); });
|
|
400
|
+
const n = interior.length, k = enjambed.length;
|
|
401
|
+
if (k === 0) return 'end-stopped throughout';
|
|
402
|
+
const where = k <= 6 ? ' (lines ' + enjambed.join(', ') + ')' : '';
|
|
403
|
+
return k >= Math.ceil(n / 2)
|
|
404
|
+
? `predominantly enjambed — ${k} of ${n} line-ends run on${where}`
|
|
405
|
+
: `mostly end-stopped — ${k} of ${n} line-ends enjambed${where}`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function analyze(text, engine) {
|
|
409
|
+
const t0 = Date.now();
|
|
410
|
+
const stanzas = engine === 'clio'
|
|
411
|
+
? analyzeReadingDocumentClio(text)
|
|
412
|
+
: analyzeReadingDocument(text);
|
|
413
|
+
|
|
414
|
+
const resultStanzas = stanzas.map(st => {
|
|
415
|
+
const firstDetail = st.lines.flatMap(l => l.results)[0]?.phonologicalScansion;
|
|
416
|
+
return {
|
|
417
|
+
formNote: firstDetail?.formNote ?? null,
|
|
418
|
+
lines: st.lines.map(serializeLine),
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
const fabbHalleMeter = attachFabbHalle(resultStanzas);
|
|
422
|
+
|
|
423
|
+
const grouped = stanzas.map(st => st.lines.flatMap(l => l.results));
|
|
424
|
+
let synopsis = [];
|
|
425
|
+
let phonopoetics = null;
|
|
426
|
+
try { synopsis = summarizePoem(grouped); } catch { /* non-fatal */ }
|
|
427
|
+
try {
|
|
428
|
+
const p = analyzePhonopoetics(grouped);
|
|
429
|
+
phonopoetics = {
|
|
430
|
+
endScheme: p.endScheme,
|
|
431
|
+
end: p.end, caesural: p.caesural, head: p.head,
|
|
432
|
+
alliteration: p.alliteration, acrostics: p.acrostics,
|
|
433
|
+
};
|
|
434
|
+
} catch { /* non-fatal */ }
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
engine: engine === 'clio' ? 'clio' : 'calliope',
|
|
438
|
+
elapsedMs: Date.now() - t0,
|
|
439
|
+
stanzas: resultStanzas,
|
|
440
|
+
synopsis,
|
|
441
|
+
enjambment: summariseEnjambment(stanzas),
|
|
442
|
+
phonopoetics,
|
|
443
|
+
fabbHalleMeter,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The Nounsing Pro dossier for one word: deep morpho-phonological data from
|
|
448
|
+
* the augmented CMU lexicon. Every probe is optional — OOV words return
|
|
449
|
+
* whatever survives. */
|
|
450
|
+
function wordDossier(raw) {
|
|
451
|
+
const word = String(raw).toLowerCase().replace(/[^a-z'’\-]/g, '').replace(/’/g, "'");
|
|
452
|
+
const first = (fn) => { try { const r = fn(word); return Array.isArray(r) ? r[0] ?? null : r ?? null; } catch { return null; } };
|
|
453
|
+
const insetsRaw = first(nounsing.metricalInsets);
|
|
454
|
+
// keep only foot types actually found inside the word
|
|
455
|
+
const insets = insetsRaw
|
|
456
|
+
? Object.fromEntries(Object.entries(insetsRaw).filter(([, v]) => v?.length))
|
|
457
|
+
: null;
|
|
458
|
+
let rhymes = [];
|
|
459
|
+
try { rhymes = nounsing.rhymes(word).slice(0, 18); } catch { /* OOV */ }
|
|
460
|
+
return {
|
|
461
|
+
word,
|
|
462
|
+
lexicon: first(nounsing.lexicon), // freq, pos, nsylls
|
|
463
|
+
phonemics: first(nounsing.phonemics), // phones, syllStruct, syllabification, vowelLength
|
|
464
|
+
scansion: first(nounsing.scansion), // contour, label ("dactylic"…), weightPattern
|
|
465
|
+
weights: first(nounsing.weights), // pattern[HL], details
|
|
466
|
+
vowels: first(nounsing.vowels),
|
|
467
|
+
vowelQualities: first(nounsing.vowelQualities), // diphthongs vs monophthongs
|
|
468
|
+
edges: first(nounsing.edges), // onset/coda geometry
|
|
469
|
+
onsetParse: first(nounsing.onsetParse), // CV structure, maximal onsets
|
|
470
|
+
morphology: first(nounsing.morphology), // prefix/suffix types, extrametrical S
|
|
471
|
+
suffixShift: first(nounsing.suffixShiftPotential),
|
|
472
|
+
extrametricals: first(nounsing.extrametricals),
|
|
473
|
+
rhymeProfile: first(nounsing.rhymeProfile),
|
|
474
|
+
codaComplexity: first(nounsing.codaComplexity),
|
|
475
|
+
insets, // feet hiding inside the word
|
|
476
|
+
rhymes, // strict perfect rhymes
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ─── HTTP plumbing ──────────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
const MIME = {
|
|
483
|
+
'.html': 'text/html; charset=utf-8',
|
|
484
|
+
'.css': 'text/css; charset=utf-8',
|
|
485
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
486
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
487
|
+
'.json': 'application/json; charset=utf-8',
|
|
488
|
+
'.svg': 'image/svg+xml',
|
|
489
|
+
'.png': 'image/png',
|
|
490
|
+
'.ico': 'image/x-icon',
|
|
491
|
+
'.woff2': 'font/woff2',
|
|
492
|
+
'.woff': 'font/woff',
|
|
493
|
+
'.ttf': 'font/ttf',
|
|
494
|
+
'.otf': 'font/otf',
|
|
495
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
function serveStatic(req, res) {
|
|
499
|
+
let urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
|
500
|
+
if (urlPath === '/') urlPath = '/index.html';
|
|
501
|
+
const filePath = path.join(PUBLIC_DIR, path.normalize(urlPath));
|
|
502
|
+
if (!filePath.startsWith(PUBLIC_DIR)) {
|
|
503
|
+
res.writeHead(403); res.end('Forbidden'); return;
|
|
504
|
+
}
|
|
505
|
+
fs.readFile(filePath, (err, data) => {
|
|
506
|
+
if (err) { res.writeHead(404); res.end('Not found'); return; }
|
|
507
|
+
res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath)] ?? 'application/octet-stream' });
|
|
508
|
+
res.end(data);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function readBody(req) {
|
|
513
|
+
return new Promise((resolve, reject) => {
|
|
514
|
+
let body = '';
|
|
515
|
+
req.on('data', c => {
|
|
516
|
+
body += c;
|
|
517
|
+
if (body.length > 512 * 1024) { reject(new Error('payload too large')); req.destroy(); }
|
|
518
|
+
});
|
|
519
|
+
req.on('end', () => resolve(body));
|
|
520
|
+
req.on('error', reject);
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const server = http.createServer(async (req, res) => {
|
|
525
|
+
if (req.method === 'POST' && req.url === '/api/analyze') {
|
|
526
|
+
try {
|
|
527
|
+
const body = await readBody(req);
|
|
528
|
+
const { text, engine } = JSON.parse(body || '{}');
|
|
529
|
+
if (!text || typeof text !== 'string' || !text.trim()) {
|
|
530
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
531
|
+
res.end(JSON.stringify({ error: 'No text supplied.' }));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const lineCount = text.split('\n').filter(l => l.trim()).length;
|
|
535
|
+
if (lineCount > 200) {
|
|
536
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
537
|
+
res.end(JSON.stringify({ error: 'Poem too long — please keep it under 200 lines per request.' }));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
// Auto-detect Russian text and route to the Russian pipeline
|
|
541
|
+
const cyrillic = (text.match(/[А-Яа-яЁё]/g) || []).length;
|
|
542
|
+
const latin = (text.match(/[A-Za-z]/g) || []).length;
|
|
543
|
+
if (cyrillic > latin && cyrillic > 0 && engine !== 'calliope' && engine !== 'clio') {
|
|
544
|
+
try {
|
|
545
|
+
const { analyzeRussianPoem } = await import('../dist/russian/engine.js');
|
|
546
|
+
const t0 = Date.now();
|
|
547
|
+
const ruResult = await analyzeRussianPoem(text);
|
|
548
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
549
|
+
res.end(JSON.stringify({ engine: 'russian', elapsedMs: Date.now() - t0, ...ruResult }));
|
|
550
|
+
return;
|
|
551
|
+
} catch (ruErr) {
|
|
552
|
+
console.error('russian auto-detect failed:', ruErr);
|
|
553
|
+
// Fall through to English analysis
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const result = analyze(text, engine);
|
|
557
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
558
|
+
res.end(JSON.stringify(result));
|
|
559
|
+
} catch (err) {
|
|
560
|
+
console.error('analyze failed:', err);
|
|
561
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
562
|
+
res.end(JSON.stringify({ error: 'Analysis failed: ' + (err?.message ?? String(err)) }));
|
|
563
|
+
}
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (req.method === 'GET' && req.url.startsWith('/api/word')) {
|
|
567
|
+
try {
|
|
568
|
+
const params = new URL(req.url, 'http://x').searchParams;
|
|
569
|
+
const w = params.get('w') ?? '';
|
|
570
|
+
if (!w.trim()) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'no word' })); return; }
|
|
571
|
+
const dossier = wordDossier(w);
|
|
572
|
+
const syll = Number(params.get('syll'));
|
|
573
|
+
if (syll >= 1 && syll <= 8) {
|
|
574
|
+
try { dossier.rhymesBySyll = nounsing.rhymeBySyllables(dossier.word, syll).slice(0, 60); }
|
|
575
|
+
catch { dossier.rhymesBySyll = []; }
|
|
576
|
+
}
|
|
577
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
578
|
+
res.end(JSON.stringify(dossier));
|
|
579
|
+
} catch (err) {
|
|
580
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
581
|
+
res.end(JSON.stringify({ error: String(err?.message ?? err) }));
|
|
582
|
+
}
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (req.method === 'GET' && req.url.startsWith('/api/meter')) {
|
|
586
|
+
try {
|
|
587
|
+
const params = new URL(req.url, 'http://x').searchParams;
|
|
588
|
+
const pattern = (params.get('pattern') ?? '').replace(/[^012]/g, '');
|
|
589
|
+
if (!pattern) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'pattern must be digits 0/1/2, e.g. 010' })); return; }
|
|
590
|
+
let words = [];
|
|
591
|
+
try { words = nounsing.meterMatch(pattern); } catch { /* none */ }
|
|
592
|
+
// a stable-but-varied sample: shuffle then cap
|
|
593
|
+
for (let i = words.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0; [words[i], words[j]] = [words[j], words[i]]; }
|
|
594
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
595
|
+
res.end(JSON.stringify({ pattern, total: words.length, words: words.slice(0, 72) }));
|
|
596
|
+
} catch (err) {
|
|
597
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
598
|
+
res.end(JSON.stringify({ error: String(err?.message ?? err) }));
|
|
599
|
+
}
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (req.method === 'POST' && req.url === '/api/rewrite') {
|
|
603
|
+
try {
|
|
604
|
+
const body = JSON.parse(await readBody(req) || '{}');
|
|
605
|
+
const text = String(body.text ?? '').slice(0, 4000);
|
|
606
|
+
if (!text.trim()) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No text supplied.' })); return; }
|
|
607
|
+
const pos = Math.max(0, Math.min(3, Number(body.posPrecision ?? 1)));
|
|
608
|
+
const freq = Math.max(0, Math.min(4, Number(body.freqThreshold ?? 0)));
|
|
609
|
+
const mode = body.mode;
|
|
610
|
+
const fuzzy = Boolean(body.fuzzyRhyme);
|
|
611
|
+
const morphGround = Boolean(body.morphGround);
|
|
612
|
+
const registerFidelity = Math.max(0, Math.min(4, Number(body.registerFidelity ?? 0)));
|
|
613
|
+
const dictPos = Boolean(body.dictPos);
|
|
614
|
+
// Cyrillic verse transmutes through the Russian engine (stress
|
|
615
|
+
// dictionary + poetry-corpus frequency table standing in for Zipf).
|
|
616
|
+
const cyr = (text.match(/[А-Яа-яЁё]/g) || []).length;
|
|
617
|
+
const lat = (text.match(/[A-Za-z]/g) || []).length;
|
|
618
|
+
let output;
|
|
619
|
+
if (cyr > lat && cyr > 0) {
|
|
620
|
+
const { rewriteRussianText } = await import('../dist/russian/rewrite.js');
|
|
621
|
+
const ruMode = mode === 'stress' || mode === 'rhyme' || mode === 'phones' ? mode : 'phones';
|
|
622
|
+
output = await rewriteRussianText(text, ruMode, pos, freq, fuzzy);
|
|
623
|
+
} else {
|
|
624
|
+
// Scored-ranking rewriter over nounsing-pro primitives — morphemic
|
|
625
|
+
// and register grounding, fuzzy rhyme tiers, punctuation preserved.
|
|
626
|
+
const { rewriteEnglishText } = await import('../dist/rewriteEn.js');
|
|
627
|
+
const enMode = mode === 'stress' || mode === 'rhyme' || mode === 'phones' ? mode : 'phones';
|
|
628
|
+
output = rewriteEnglishText(text, enMode, pos, freq,
|
|
629
|
+
{ fuzzyRhyme: fuzzy, morphGround, registerFidelity, dictPos });
|
|
630
|
+
}
|
|
631
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
632
|
+
res.end(JSON.stringify({ output }));
|
|
633
|
+
} catch (err) {
|
|
634
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
635
|
+
res.end(JSON.stringify({ error: String(err?.message ?? err) }));
|
|
636
|
+
}
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (req.method === 'POST' && req.url === '/api/russian') {
|
|
640
|
+
try {
|
|
641
|
+
const body = await readBody(req);
|
|
642
|
+
const { text } = JSON.parse(body || '{}');
|
|
643
|
+
if (!text || typeof text !== 'string' || !text.trim()) {
|
|
644
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
645
|
+
res.end(JSON.stringify({ error: 'No text supplied.' }));
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const lineCount = text.split('\n').filter(l => l.trim()).length;
|
|
649
|
+
if (lineCount > 200) {
|
|
650
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
651
|
+
res.end(JSON.stringify({ error: 'Poem too long — please keep it under 200 lines per request.' }));
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const { analyzeRussianPoem } = await import('../dist/russian/engine.js');
|
|
655
|
+
const t0 = Date.now();
|
|
656
|
+
const result = await analyzeRussianPoem(text);
|
|
657
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
658
|
+
res.end(JSON.stringify({ engine: 'russian', elapsedMs: Date.now() - t0, ...result }));
|
|
659
|
+
} catch (err) {
|
|
660
|
+
console.error('russian analyze failed:', err);
|
|
661
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
662
|
+
res.end(JSON.stringify({ error: 'Russian analysis failed: ' + (err?.message ?? String(err)) }));
|
|
663
|
+
}
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (req.method === 'GET' && req.url === '/api/health') {
|
|
667
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
668
|
+
res.end(JSON.stringify({ ok: true }));
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (req.method === 'GET' || req.method === 'HEAD') { serveStatic(req, res); return; }
|
|
672
|
+
res.writeHead(405); res.end();
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
server.listen(PORT, () => {
|
|
676
|
+
console.log(`Calliope web listening on http://localhost:${PORT}`);
|
|
677
|
+
// Warm the UDPipe model so the first user request is fast.
|
|
678
|
+
try { analyze('Shall I compare thee to a summer’s day?', 'calliope'); console.log('pipeline warm'); }
|
|
679
|
+
catch (e) { console.error('warm-up failed:', e); }
|
|
680
|
+
});
|