cts / webapp /server.mjs
AlekseyCalvin's picture
Upload 119 files
59f93ff verified
Raw
History Blame Contribute Delete
26.4 kB
#!/usr/bin/env node
// Calliope TS — web app server.
// Serves the static front-end from webapp/public and exposes the full analysis
// pipeline as structured JSON at POST /api/analyze. All the information the
// CLI's Reading and Detailed views print (plus the raw UDPipe / nounsing-pro
// substrates) is serialized here so the browser can render it interactively.
import * as http from 'node:http';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { analyzeReadingDocument } from '../dist/index.js';
import { analyzeReadingDocumentClio } from '../dist/clio/pipeline.js';
import { isPunctuation } from '../dist/parser.js';
import { syllabifyWord, syllableVowelLengths, vowelLengthOf } from '../dist/phonological.js';
import { computeCaesurae } from '../dist/caesura.js';
import { buildFabbHalleGrid } from '../dist/fabbhalle.js';
import { computeBoundaries } from '../dist/calliope/boundaries.js';
import { summarizePoem, analyzePhonopoetics } from '../dist/rhyme.js';
import * as nounsing from 'nounsing-pro';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PUBLIC_DIR = path.join(__dirname, 'public');
const PORT = Number(process.env.PORT || 4321);
// ─── Serialization helpers ──────────────────────────────────────────────
const normWordForm = (s) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z]/g, '');
/** Non-punctuation, syllable-bearing words across a line's results, in order. */
function collectLineWords(ln) {
const ws = [];
for (const res of ln.results) {
for (const w of res.sentence.words) {
if (!isPunctuation(w.lexicalClass) && w.syllables.length > 0) ws.push(w);
}
}
return ws;
}
/** Orthographic syllable chunks for a token, aligned to its word's syllables.
* Mirrors display.ts colourToken(): fast path when the syllabifier's chunks
* reconstruct the token exactly; otherwise walk the token char-by-char. */
function chunkToken(tokenText, word) {
const sylCount = Math.max(1, word.syllables.length);
const chunks = syllabifyWord(
tokenText, sylCount, syllableVowelLengths(word.syllables),
word.morphSuffix, word.morphPrefix,
);
if (chunks.join('') === tokenText) {
return chunks.map((c, i) => ({ text: c, si: Math.min(i, word.syllables.length - 1) }));
}
// Fallback: assign each original character to a syllable by chunk lengths.
const lens = chunks.map(c => c.length);
const out = [];
let ci = 0, consumed = 0;
const push = (ch, si) => {
const last = out[out.length - 1];
if (last && last.si === si) last.text += ch;
else out.push({ text: ch, si });
};
for (const ch of tokenText) {
if (ch === '-') { push(ch, Math.min(ci, word.syllables.length - 1)); continue; }
while (ci < lens.length - 1 && consumed >= lens[ci]) { ci++; consumed = 0; }
push(ch, Math.min(ci, word.syllables.length - 1));
consumed++;
}
return out;
}
/** Structural port of projectStressOntoLine(): the verbatim raw line split into
* gap segments (spaces, punctuation — emitted untouched) and word segments
* (chunked into syllables, each chunk pointing at its word + syllable). */
function buildSegments(rawLine, words) {
const tokenRe = /[A-Za-z\u00C0-\u024F]+(?:['’\-][A-Za-z\u00C0-\u024F]+)*/g; // accented Latin included (Milésien)
const segments = [];
let cursor = 0, wi = 0, m;
const pushGap = (text) => { if (text) segments.push({ t: 'gap', text }); };
while ((m = tokenRe.exec(rawLine)) !== null) {
const start = m.index, end = start + m[0].length;
pushGap(rawLine.slice(cursor, start));
cursor = end;
const token = m[0];
const tokNorm = normWordForm(token);
const matches = (w) => {
if (!w) return false;
const wn = normWordForm(w.word);
return wn.length > 0 && (tokNorm === wn || tokNorm.startsWith(wn));
};
let matched = -1;
if (matches(words[wi])) matched = wi;
else {
for (let k = wi; k < Math.min(words.length, wi + 4); k++) {
if (matches(words[k])) { matched = k; break; }
}
}
if (matched >= 0) {
segments.push({ t: 'word', w: matched, chunks: chunkToken(token, words[matched]) });
wi = matched + 1;
} else {
pushGap(token);
}
}
pushGap(rawLine.slice(cursor));
return segments;
}
/** Fine-grained syllable phonology derived from the ARPAbet transcription:
* onset geometry, nucleus type, rime structure, coda status. */
const ARPA_VOWEL = /^(AA|AE|AH|AO|AW|AY|EH|ER|EY|IH|IY|OW|OY|UH|UW)/;
const ARPA_DIPH = new Set(['AY', 'AW', 'OY', 'EY', 'OW']);
function sylPhonology(phonesStr) {
const toks = String(phonesStr ?? '').replace(/[()]/g, '').trim().split(/\s+/).filter(Boolean);
const vIdx = toks.findIndex(t => ARPA_VOWEL.test(t));
if (vIdx < 0) return null;
const onsetN = vIdx;
const codaN = toks.length - vIdx - 1;
const vowel = toks[vIdx].replace(/\d/g, '');
const isDiph = ARPA_DIPH.has(vowel);
let long = isDiph;
try { if (vowelLengthOf(phonesStr) === 'long') long = true; } catch { /* keep */ }
const rime = codaN === 0 ? (long ? '-VV' : '-V')
: long ? (codaN >= 2 ? '-TCC' : '-TC')
: (codaN >= 2 ? '-LCC' : '-LC');
return {
onset: onsetN === 0 ? '0' : 'C'.repeat(Math.min(3, onsetN)),
nucleus: isDiph ? 'diphthong' : 'monophthong',
vlen: long ? 'long' : 'short',
codaC: codaN,
open: codaN === 0,
rime,
};
}
const SYL_POSITION = ['final', 'penult', 'antepenult', 'preantepenult'];
function serializeWord(w, idx, firstSyl) {
const dep = w.dependency;
// Orthographic syllable chunks for display (syl.text from the dictionary is
// phone-based); group the token chunking by syllable index.
const display = w.displayWord ?? w.word;
const orth = new Array(w.syllables.length).fill('');
try {
for (const c of chunkToken(display, w)) orth[c.si] = (orth[c.si] ?? '') + c.text;
} catch { /* fall back to phone text below */ }
return {
i: idx,
text: w.displayWord ?? w.word,
norm: w.word,
pos: w.lexicalClass,
isContent: !!w.isContent,
phraseStress: w.phraseStress ?? 0,
feats: w.featsMap && Object.keys(w.featsMap).length ? w.featsMap : null,
canonicalRel: w.canonicalRel ?? null,
dep: dep ? {
rel: dep.dependentType,
isRoot: dep.governorIndex === 0 || dep.dependentType === 'root',
govWord: dep.governor?.word ?? null,
govIndex: dep.governorIndex,
} : null,
flags: {
person: !!w.isPersonName,
place: !!w.isPlaceName,
given: !!w.discourseGiven,
coordGiven: !!w.coordinateGiven,
},
morph: (w.morphPrefix || w.morphSuffix)
? { prefix: w.morphPrefix ?? null, suffix: w.morphSuffix ?? null } : null,
firstSyl,
syls: w.syllables.map((s, si) => ({
si,
text: orth[si] || s.text,
phones: s.phones,
weight: s.weight ?? null,
lex: s.lexicalStress ?? s.stress,
boosted: s.stress,
rel: s.relativeStress ?? 'w',
extrametrical: s.extrametrical ?? null,
pos: SYL_POSITION[Math.min(3, w.syllables.length - 1 - si)],
ph: sylPhonology(s.phones),
})),
};
}
/** Map each display word to its IU.PP.CP membership in the prosodic hierarchy. */
function hierarchyMembership(ius, words) {
const loc = new Map();
ius.forEach((iu, ii) =>
iu.phonologicalPhrases.forEach((pp, pi) =>
pp.cliticGroups.forEach((cg, ci) =>
cg.tokens.forEach(t => loc.set(t, { iu: ii, pp: pi, cp: ci })))));
return words.map(w => loc.get(w) ?? null);
}
/** Serialize the hierarchy as nested word-index lists (IU → PP → CP). */
function serializeHierarchy(ius, words) {
const indexOf = new Map(words.map((w, i) => [w, i]));
return ius.map(iu => ({
pps: iu.phonologicalPhrases.map(pp => ({
cps: pp.cliticGroups.map(cg =>
cg.tokens.map(t => indexOf.get(t)).filter(i => i !== undefined)),
})),
}));
}
/** Align scansion feet to global syllable indices (display.ts Layer 6 logic). */
function serializeFeet(scansion, totalSyls) {
const isSylChar = (ch) => 'xXwWnNmMsS'.includes(ch);
const feet = [];
let sylIdx = 0;
for (const rawFoot of scansion.split('|')) {
const cells = [];
for (const ch of rawFoot) {
if (ch === '-') { cells.push({ silent: true }); continue; }
if (!isSylChar(ch)) continue;
if (sylIdx < totalSyls) cells.push({ s: sylIdx++, mark: ch.toLowerCase() });
}
feet.push({ pattern: rawFoot, cells });
}
return feet;
}
function serializeDetail(d) {
if (!d) return null;
return {
meter: d.meter,
meterName: d.meterName,
footCount: d.footCount,
scansion: d.scansion,
certainty: d.certainty,
summary: d.summary,
ranking: (d.ranking ?? []).map(r => ({ meter: r.meter, score: Number(r.score.toFixed(3)) })),
standaloneMeter: d.standaloneMeter ?? null,
consensusMeter: d.consensusMeter ?? null,
rhythmNote: d.rhythmNote ?? null,
metricalityNote: d.metricalityNote ?? null,
formNote: d.formNote ?? null,
rhyme: d.rhyme ? {
endWord: d.rhyme.endWord,
letter: d.rhyme.letter,
type: d.rhyme.type ?? null,
matchedLine: d.rhyme.matchedLine ?? null,
notation: d.rhyme.notation ?? null,
internal: (d.rhyme.internal ?? []).map(iw => ({
word: iw.word, letter: iw.letter, type: iw.type ?? null,
})),
} : null,
};
}
function serializeLine(ln) {
const res = ln.results[0];
if (!res) return { raw: ln.raw, parsed: false };
const words = collectLineWords(ln);
let firstSyl = 0;
const serWords = words.map((w, i) => {
const sw = serializeWord(w, i, firstSyl);
firstSyl += w.syllables.length;
return sw;
});
const totalSyls = firstSyl;
// Flat global syllable list (word order — the scansion domain's order).
const syllables = [];
serWords.forEach(sw => sw.syls.forEach(s => syllables.push({
w: sw.i, si: s.si, text: s.text, phones: s.phones, weight: s.weight,
lex: s.lex, rel: s.rel,
})));
const ius = res.phonologicalHierarchy;
const allWords = res.sentence.words;
let boundaries = null;
try {
const b = computeBoundaries(allWords, ius);
boundaries = {
phi: b.phi.map(x => ({ strength: x.strength, syl: x.syllableIndex })),
iota: b.iota.map(x => ({ strength: x.strength, syl: x.syllableIndex })),
};
} catch { /* clio lines may lack substrate for graded boundaries */ }
let caesurae = [];
try {
caesurae = [...computeCaesurae(allWords, ius, res.phonologicalScansion.scansion)]
.map(([after, info]) => ({ after, kind: info.kind, strength: info.strength }));
} catch { /* non-fatal */ }
const deps = serWords
.filter(sw => sw.dep)
.map(sw => {
const govIdx = sw.dep.isRoot ? -1
: serWords.findIndex(o => words[o.i].index === sw.dep.govIndex
&& words[o.i] === (words[sw.i].dependency?.governor ?? null));
// Prefer object identity; fall back to name match when identity fails.
let to = govIdx;
if (to === -1 && !sw.dep.isRoot) {
to = words.findIndex(w => w === words[sw.i].dependency?.governor);
}
return { from: sw.i, to: sw.dep.isRoot ? -1 : to, rel: sw.dep.rel };
});
return {
raw: ln.raw,
parsed: true,
segments: buildSegments(ln.raw, words),
words: serWords,
syllables,
hierarchy: serializeHierarchy(ius, words),
unitOf: hierarchyMembership(ius, words),
boundaries,
caesurae,
keyStresses: (res.keyStresses ?? []).map(k => ({
unitType: k.unitType, pattern: k.pattern, weight: k.weight, positions: k.positions,
})),
feet: serializeFeet(res.phonologicalScansion.scansion, totalSyls),
detail: serializeDetail(res.phonologicalScansion),
// Charles Hartman's Scandroid (2005), run as a fully independent second
// opinion: its own dictionary, syllabifier, and algorithms, working only
// from the raw line text.
scandroid: (() => {
const sn = res.scandroidNative;
if (!sn) return null;
const serFoot = f => f ? {
algorithm: f.algorithm, scanString: f.scanString, marksString: f.marksString,
substitutions: f.substitutions, ok: f.ok, failReason: f.failReason ?? null,
} : null;
return {
metronName: sn.metronName,
lineLengthName: sn.lineLengthName,
lineFeetSet: sn.lineFeetSet,
verdict: serFoot(sn.verdict),
corralTheWeird: serFoot(sn.corralTheWeird ?? null),
maximizeTheNormal: serFoot(sn.maximizeTheNormal ?? null),
promotions: sn.promotions,
};
})(),
// Fabb–Halle bracketed grid (Meter in Poetry, 2008) — an independent
// second-opinion scansion computed from the line's FINAL meter name;
// null when the meter has no F&H rule set (accentual, free verse).
fabbHalle: (() => {
try {
const perWord = new Map();
syllables.forEach(s => perWord.set(s.w, (perWord.get(s.w) ?? 0) + 1));
const fhSyls = syllables.map(s => ({
text: s.text, lex: s.lex ?? 0, poly: (perWord.get(s.w) ?? 1) > 1,
}));
const d = res.phonologicalScansion;
const fh = buildFabbHalleGrid(fhSyls, d.meter || '', d.footCount);
return fh ? {
rule: fh.ruleLabel, rows: fh.rows,
maxima: fh.maxima, violations: fh.violations, metrical: fh.metrical,
looseFeet: fh.looseFeet ?? null,
} : null;
} catch { return null; }
})(),
deps,
};
}
/** Enjambment summary — port of display.ts summariseEnjambment(). */
function lineRunsOn(raw) {
const t = raw.replace(/["'’”»)\]]+$/, '').trimEnd();
if (!t) return false;
return !/[.!?;:,—–…]$/.test(t);
}
function summariseEnjambment(stanzas) {
const raws = stanzas.flatMap(st => st.lines.map(l => l.raw));
if (raws.length < 2) return null;
const interior = raws.slice(0, -1);
const enjambed = [];
interior.forEach((r, i) => { if (lineRunsOn(r)) enjambed.push(i + 1); });
const n = interior.length, k = enjambed.length;
if (k === 0) return 'end-stopped throughout';
const where = k <= 6 ? ' (lines ' + enjambed.join(', ') + ')' : '';
return k >= Math.ceil(n / 2)
? `predominantly enjambed — ${k} of ${n} line-ends run on${where}`
: `mostly end-stopped — ${k} of ${n} line-ends enjambed${where}`;
}
function analyze(text, engine) {
const t0 = Date.now();
const stanzas = engine === 'clio'
? analyzeReadingDocumentClio(text)
: analyzeReadingDocument(text);
const resultStanzas = stanzas.map(st => {
const firstDetail = st.lines.flatMap(l => l.results)[0]?.phonologicalScansion;
return {
formNote: firstDetail?.formNote ?? null,
lines: st.lines.map(serializeLine),
};
});
const grouped = stanzas.map(st => st.lines.flatMap(l => l.results));
let synopsis = [];
let phonopoetics = null;
try { synopsis = summarizePoem(grouped); } catch { /* non-fatal */ }
try {
const p = analyzePhonopoetics(grouped);
phonopoetics = {
endScheme: p.endScheme,
end: p.end, caesural: p.caesural, head: p.head,
alliteration: p.alliteration, acrostics: p.acrostics,
};
} catch { /* non-fatal */ }
return {
engine: engine === 'clio' ? 'clio' : 'calliope',
elapsedMs: Date.now() - t0,
stanzas: resultStanzas,
synopsis,
enjambment: summariseEnjambment(stanzas),
phonopoetics,
};
}
/** The Nounsing Pro dossier for one word: deep morpho-phonological data from
* the augmented CMU lexicon. Every probe is optional — OOV words return
* whatever survives. */
function wordDossier(raw) {
const word = String(raw).toLowerCase().replace(/[^a-z'’\-]/g, '').replace(/’/g, "'");
const first = (fn) => { try { const r = fn(word); return Array.isArray(r) ? r[0] ?? null : r ?? null; } catch { return null; } };
const insetsRaw = first(nounsing.metricalInsets);
// keep only foot types actually found inside the word
const insets = insetsRaw
? Object.fromEntries(Object.entries(insetsRaw).filter(([, v]) => v?.length))
: null;
let rhymes = [];
try { rhymes = nounsing.rhymes(word).slice(0, 18); } catch { /* OOV */ }
return {
word,
lexicon: first(nounsing.lexicon), // freq, pos, nsylls
phonemics: first(nounsing.phonemics), // phones, syllStruct, syllabification, vowelLength
scansion: first(nounsing.scansion), // contour, label ("dactylic"…), weightPattern
weights: first(nounsing.weights), // pattern[HL], details
vowels: first(nounsing.vowels),
vowelQualities: first(nounsing.vowelQualities), // diphthongs vs monophthongs
edges: first(nounsing.edges), // onset/coda geometry
onsetParse: first(nounsing.onsetParse), // CV structure, maximal onsets
morphology: first(nounsing.morphology), // prefix/suffix types, extrametrical S
suffixShift: first(nounsing.suffixShiftPotential),
extrametricals: first(nounsing.extrametricals),
rhymeProfile: first(nounsing.rhymeProfile),
codaComplexity: first(nounsing.codaComplexity),
insets, // feet hiding inside the word
rhymes, // strict perfect rhymes
};
}
// ─── HTTP plumbing ──────────────────────────────────────────────────────
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.txt': 'text/plain; charset=utf-8',
};
function serveStatic(req, res) {
let urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname);
if (urlPath === '/') urlPath = '/index.html';
const filePath = path.join(PUBLIC_DIR, path.normalize(urlPath));
if (!filePath.startsWith(PUBLIC_DIR)) {
res.writeHead(403); res.end('Forbidden'); return;
}
fs.readFile(filePath, (err, data) => {
if (err) { res.writeHead(404); res.end('Not found'); return; }
res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath)] ?? 'application/octet-stream' });
res.end(data);
});
}
function readBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', c => {
body += c;
if (body.length > 512 * 1024) { reject(new Error('payload too large')); req.destroy(); }
});
req.on('end', () => resolve(body));
req.on('error', reject);
});
}
const server = http.createServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/api/analyze') {
try {
const body = await readBody(req);
const { text, engine } = JSON.parse(body || '{}');
if (!text || typeof text !== 'string' || !text.trim()) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'No text supplied.' }));
return;
}
const lineCount = text.split('\n').filter(l => l.trim()).length;
if (lineCount > 200) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Poem too long — please keep it under 200 lines per request.' }));
return;
}
// Auto-detect Russian text and route to the Russian pipeline
const cyrillic = (text.match(/[А-Яа-яЁё]/g) || []).length;
const latin = (text.match(/[A-Za-z]/g) || []).length;
if (cyrillic > latin && cyrillic > 0 && engine !== 'calliope' && engine !== 'clio') {
try {
const { analyzeRussianPoem } = await import('../dist/russian/engine.js');
const t0 = Date.now();
const ruResult = analyzeRussianPoem(text);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ engine: 'russian', elapsedMs: Date.now() - t0, ...ruResult }));
return;
} catch (ruErr) {
console.error('russian auto-detect failed:', ruErr);
// Fall through to English analysis
}
}
const result = analyze(text, engine);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (err) {
console.error('analyze failed:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Analysis failed: ' + (err?.message ?? String(err)) }));
}
return;
}
if (req.method === 'GET' && req.url.startsWith('/api/word')) {
try {
const params = new URL(req.url, 'http://x').searchParams;
const w = params.get('w') ?? '';
if (!w.trim()) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'no word' })); return; }
const dossier = wordDossier(w);
const syll = Number(params.get('syll'));
if (syll >= 1 && syll <= 8) {
try { dossier.rhymesBySyll = nounsing.rhymeBySyllables(dossier.word, syll).slice(0, 60); }
catch { dossier.rhymesBySyll = []; }
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(dossier));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(err?.message ?? err) }));
}
return;
}
if (req.method === 'GET' && req.url.startsWith('/api/meter')) {
try {
const params = new URL(req.url, 'http://x').searchParams;
const pattern = (params.get('pattern') ?? '').replace(/[^012]/g, '');
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; }
let words = [];
try { words = nounsing.meterMatch(pattern); } catch { /* none */ }
// a stable-but-varied sample: shuffle then cap
for (let i = words.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0; [words[i], words[j]] = [words[j], words[i]]; }
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pattern, total: words.length, words: words.slice(0, 72) }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(err?.message ?? err) }));
}
return;
}
if (req.method === 'POST' && req.url === '/api/rewrite') {
try {
const body = JSON.parse(await readBody(req) || '{}');
const text = String(body.text ?? '').slice(0, 4000);
if (!text.trim()) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No text supplied.' })); return; }
const pos = Math.max(0, Math.min(3, Number(body.posPrecision ?? 1)));
const freq = Math.max(0, Math.min(4, Number(body.freqThreshold ?? 0)));
const mode = body.mode;
// Cyrillic verse transmutes through the Russian engine (stress
// dictionary + poetry-corpus frequency table standing in for Zipf).
const cyr = (text.match(/[А-Яа-яЁё]/g) || []).length;
const lat = (text.match(/[A-Za-z]/g) || []).length;
let output;
if (cyr > lat && cyr > 0) {
const { rewriteRussianText } = await import('../dist/russian/rewrite.js');
const ruMode = mode === 'stress' || mode === 'rhyme' || mode === 'phones' ? mode : 'phones';
output = rewriteRussianText(text, ruMode, pos, freq);
} else {
const fn = mode === 'stress' ? nounsing.rewriteWithStressPattern
: mode === 'rhyme' ? nounsing.rewriteWithRhymes
: nounsing.rewriteFromFirstTwoPhones;
// rewrite per line so verse formatting survives the transmutation
output = text.split('\n').map(line => line.trim() ? fn(line, pos, freq) : line).join('\n');
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ output }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(err?.message ?? err) }));
}
return;
}
if (req.method === 'POST' && req.url === '/api/russian') {
try {
const body = await readBody(req);
const { text } = JSON.parse(body || '{}');
if (!text || typeof text !== 'string' || !text.trim()) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'No text supplied.' }));
return;
}
const lineCount = text.split('\n').filter(l => l.trim()).length;
if (lineCount > 200) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Poem too long — please keep it under 200 lines per request.' }));
return;
}
const { analyzeRussianPoem } = await import('../dist/russian/engine.js');
const t0 = Date.now();
const result = analyzeRussianPoem(text);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ engine: 'russian', elapsedMs: Date.now() - t0, ...result }));
} catch (err) {
console.error('russian analyze failed:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Russian analysis failed: ' + (err?.message ?? String(err)) }));
}
return;
}
if (req.method === 'GET' && req.url === '/api/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
return;
}
if (req.method === 'GET' || req.method === 'HEAD') { serveStatic(req, res); return; }
res.writeHead(405); res.end();
});
server.listen(PORT, () => {
console.log(`Calliope web listening on http://localhost:${PORT}`);
// Warm the UDPipe model so the first user request is fast.
try { analyze('Shall I compare thee to a summer’s day?', 'calliope'); console.log('pipeline warm'); }
catch (e) { console.error('warm-up failed:', e); }
});