#!/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); } });