diff --git a/app/api/chat/route.js b/app/api/chat/route.js index d18f901..3814abd 100644 --- a/app/api/chat/route.js +++ b/app/api/chat/route.js @@ -118,7 +118,18 @@ function logModelResolutionOnce() { // the client disconnects, when the stream is cancelled, or when the run deadline // passes — see lib/runSignal.mjs for why a request nobody is waiting for must // stop rather than run to completion. -function buildSseResponse(startHandler, { clientSignal = null } = {}) { +// How often the stream carries a byte while nothing else is being sent. A run +// can be silent for minutes — a cold class-connectivity aggregation, a planner +// round on a busy gateway — and every hop between the server and the reader +// has an idle timeout that reads silence as a dead connection: Node's fetch +// (undici) gives up after 300 s between body chunks, and proxies are often +// shorter. That is what `terminated` in a task-battery artefact means (T2.7, +// T3.2, T3.8 and C12 on the first Qwen run, all cut between 5 and 7½ minutes +// in, none of them near the 900 s cap). An SSE comment line carries no event, +// so every parser — the page, the battery runner, EventSource — ignores it. +const SSE_HEARTBEAT_MS = 15000 + +function buildSseResponse(startHandler, { clientSignal = null, heartbeatMs = SSE_HEARTBEAT_MS } = {}) { const encoder = new TextEncoder() const run = createRunSignal({ clientSignal }) const stream = new ReadableStream({ @@ -133,6 +144,17 @@ function buildSseResponse(startHandler, { clientSignal = null } = {}) { run.abort('stream-closed') } } + const heartbeat = heartbeatMs > 0 + ? setInterval(() => { + try { + controller.enqueue(encoder.encode(': keepalive\n\n')) + } catch { + run.abort('stream-closed') + } + }, heartbeatMs) + : null + // A timer must never be what keeps the process alive. + if (heartbeat && typeof heartbeat.unref === 'function') heartbeat.unref() try { await startHandler(sendEvent, run.signal) @@ -140,6 +162,7 @@ function buildSseResponse(startHandler, { clientSignal = null } = {}) { if (!isRunAbortedWith(error, run.signal)) throw error console.log(`[VFBchat] RUN ABANDONED | reason=${run.reason() || 'aborted'}`) } finally { + if (heartbeat) clearInterval(heartbeat) run.dispose() try { controller.close() } catch { /* already closed by cancel() */ } } @@ -11342,6 +11365,9 @@ async function renderInLanguage({ text, language, kind = 'answer', sendEvent, ap // grounding audit reports. `links` is what the check had to preserve. try { console.error(`[VFBchat] TRANSLATION | language=${language} | kind=${kind} | ok=${r.ok} | attempts=${r.attempts} | links=${linkTargets(text).length} | chars=${String(text).length} | ms=${Date.now() - startedAt}${r.ok ? '' : ` | reason=${safeText(r.reason)}`}`) + // The English source, trace mode only: "links=0" on a translated answer is + // unreadable without the text it was measured on (battery L3, 11 Sep). + if (process.env.VFB_HARNESS_TRACE === 'true') console.log('[VFBchat] TRANSLATION SOURCE', JSON.stringify(String(text))) } catch { /* logging best-effort */ } if (!r.ok) { sendEvent('draft_discarded', { reason: 'translation-unverified' }) diff --git a/lib/followOns.mjs b/lib/followOns.mjs index a447544..25d2d46 100644 --- a/lib/followOns.mjs +++ b/lib/followOns.mjs @@ -383,18 +383,29 @@ export function linkifyKnownTerms(text, termLinks) { const segs = [{ text: part, done: false }] for (const t of termLinks) { if (linked.has(t.name.toLowerCase())) continue - const esc = escapeRe(t.name) // Either the whole "[name]" span or the bare name — but the bare-name arm // refuses a preceding "[", so an unmatched bracket is left entirely alone // rather than becoming "[[name](url)", which reads as a broken link. - const re = new RegExp(`(?= 2 && !/s$/i.test(n)) { + if (/[^aeiou]y$/i.test(n)) forms.add(n.slice(0, -1) + 'ies') + else if (/(?:x|ch|sh)$/i.test(n)) forms.add(n + 'es') + else forms.add(n + 's') + } + const cased = [...forms].map(f => (/^[a-z]/.test(f) ? `[${f[0].toUpperCase()}${f[0]}]${escapeRe(f.slice(1))}` : escapeRe(f))) + // Longest first, so the plural wins over the singular prefix it contains. + cased.sort((a, b) => b.length - a.length) + return `(?:${cased.join('|')})` +} + /** * Map each term-info query count -> {label, url, title} so figures quoted in the * answer ("226,524 images of neurons with some part in the medulla") can be diff --git a/lib/orchestrator.mjs b/lib/orchestrator.mjs index 11aa3d9..5f945c0 100644 --- a/lib/orchestrator.mjs +++ b/lib/orchestrator.mjs @@ -2747,6 +2747,15 @@ async function synthesise(ledger, deps, models) { ? `\n\nCODE IS ALREADY SUPPLIED FOR THIS ANSWER. A correct, runnable snippet — built from the exact ids this conversation resolved — is added to your answer automatically, immediately after your prose. Your entire job is the one or two sentences that introduce it.\n\nWrite those sentences about THE DATA: what the code returns, and how much of it. Like this:\n\n "The code below returns the VFB has annotated for , as a table."\n\nThen stop. Do not write code. Do not describe the code, name any function, method or attribute, or refer to a mechanism you have not named ("a VFB function", "the relevant method"). Do not say what any library can or cannot do — you do not have its API in front of you and your recollection of it is out of date. EVIDENCE may contain a documentation page showing some other library method: it is not the subject of this question, so do not name it, explain it, or contrast it with what was asked.` : '' const closingRule = '\n\nWrite the answer, never where the answer came from. Not "as stated in the documentation", "according to the documentation", "the provided evidence", "the available information", "not specified in the provided information" — say the thing itself, or say plainly what is not the case. Do not close by sending the reader to a guide, a page, a file or a section for the rest ("consult the relevant guide", "see examples.md", "for detailed steps refer to …"), and do not note that something is not fully detailed: give what you have and stop there.' + // Translate-last depends on the draft being ENGLISH: every gate and linker + // reads English, and the translation step is what the reader sees. Nothing + // had said so to the model, and on a Japanese question it wrote the draft in + // Japanese about one run in three — the linker then found no "mushroom body" + // to link, and the reader got an answer with no links (battery L3). The rule + // is stated on the turns it applies to, and stated last so it is not buried. + const englishDraftRule = isEnglish(ledger.language) + ? '' + : `\n\nWRITE THE ANSWER IN ENGLISH, even though the question is written in ${languageName(ledger.language)}. It is translated for the reader afterwards; an answer written in another language here cannot be linked or checked. Use the English names from RESOLVED ENTITIES exactly as written.` // The service's own version and model, appended to the SYNTHESIS system // message specifically. // @@ -2763,7 +2772,7 @@ async function synthesise(ledger, deps, models) { const messages = [ { role: 'system', content: `You are a Virtual Fly Brain assistant. Answer using the supplied evidence; you may also state what data VFB holds from AVAILABLE VFB DATA. Distinguish a paper's claim ("literature") from a VFB-database fact ("vfb") and documentation ("doc") — never present a paper's claim as a VFB-database fact, and cite papers inline. Do NOT append per-sentence source tags such as "(vfb)" or "according to the VFB database" — provenance is shown separately as linked sources. Refer to entities by their full name exactly as written in RESOLVED ENTITIES; do NOT write ontology ids (FBbt_/VFB_), URLs, or markdown links — entity names and figures are turned into links automatically afterwards. Do NOT embed images. The headings below (EVIDENCE, AVAILABLE VFB DATA, RESOLVED ENTITIES, UNMATCHED NAMES, DOCUMENTATION ANSWERED THIS, WRITING GUIDANCE) are labels on YOUR input and mean nothing to the reader — never name one in the answer. Write "VFB does not currently hold data on X", never "AVAILABLE VFB DATA does not provide …". The reader cannot see what you were given, so never describe it either: no "the provided evidence", "the evidence provided", "the supplied documentation", "not specified in the provided …", "based on the information above". Say what is or is not the case, not what your input did or did not contain — write "VFB's documentation does not appear to cover bridging registrations", NOT "bridging registrations are not explicitly defined in the provided evidence". For the same reason, do not narrate that the answer came from somewhere: drop "as stated in the documentation", "according to the documentation", "the documentation provides this information", "this indicates that". State the fact itself. NEVER OVERCLAIM — this is critical. VFB holds only PARTIAL data; what it has, or lacks, is not definitive of biology, and it is for the USER to interpret. Your job is to point the user at the relevant VFB data, not to draw conclusions for them. Therefore: (1) ABSENCE means only that VFB does not currently hold/annotate that data — it never means the thing does not exist, is not true, or is not connected. Never write "there are no X", "X does not connect to Y", or "X has no Y"; write "VFB does not currently hold data on …". (2) COUNTS and records are what VFB has ANNOTATED, not biological totals or complete sets — write "VFB holds N records" or "VFB has annotated N", never "there are N" / "X has N". (3) DATA-DERIVED facts (neurotransmitter, connectivity, classification, similarity) are evidence VFB records, often predicted or from one dataset — attribute them ("the connectome data indicates", "VFB records show", "predicted as") rather than asserting them as settled fact. (4) Do NOT speculate beyond the data or state functional, causal, or interpretive conclusions of your own. (5) NEVER attribute anything to "the literature", "publications", "published estimates", "papers report/claim", or similar UNLESS the EVIDENCE contains an actual citation for it (a PMID, DOI, or FlyBase reference) — and then cite that specific reference. If you do not have such a reference in EVIDENCE, do NOT make the literature/published claim at all and do NOT invent a number or a source; state only what the VFB data shows. Be constructive, but scoped: say what VFB DOES have where it bears on the question, and name an un-run query ONLY where AVAILABLE VFB DATA marks it WORTH SAYING. Never recite the rest of that block back to the reader — every query in it is already shown beside your answer as a clickable link, so listing them in prose pads the answer without adding anything. What you do say about VFB's holdings is a pointer to data for the user to judge, never your own determination. (6) ABSENCE REQUIRES A LOOKUP THAT HAPPENED. AVAILABLE VFB DATA sorts every query for the resolved terms into four states, and which sentence you are allowed to write is decided by the state, not by how the question was worded or how complete you want the answer to feel. RUN, WITH RESULTS — the results are in EVIDENCE; answer from them, and never say one of these has not been run. RUN, CAME BACK EMPTY — this state, and only this state, licenses an absence: write "VFB does not currently hold …" plainly, with no hedging and no suggestion that the query still needs running. TRIED, NO RESULT — the lookup was attempted and did not complete; that is NOT an empty result and NOT an absence, so say the lookup did not complete, or leave it out. HELD, NOT YET RUN — VFB holds these records and nothing queried them: you are FORBIDDEN to write "VFB does not currently hold data on …", "no data is available for …", or any other absence about them, however the question was worded, INCLUDING when the question asks for exactly this and you have nothing else to say about it; for the ones marked WORTH SAYING, state the HOLDING and its count ("VFB holds 92 transgene expression reports for Kenyon cell") and stop there. Never write that a query "has not been run yet", "still needs to be run", "was not executed", or anything else about this program's queue — the reader has no session and cannot run anything, so that sentence describes this process rather than VFB, and the follow-up query is already offered beside your answer as a clickable link. A holding is a SUPPLEMENT to an answer, never the answer: a reply consisting of a count and a note about a pending query is not an answer to anything. An empty EVIDENCE block means nothing was asked, and something nobody asked about is not something VFB does not have — if nothing ran and nothing relevant is listed at all, say this particular question has not been looked up, NOT that the data does not exist. (7) ANSWER THE QUESTION; DO NOT HAND BACK CODE. Unless the user asked how to do something programmatically, never answer with a vfb_connect, vfbquery, Python or shell snippet in place of the result — writing \`vfb.terms(['DA1 lPN'])\` and then saying the data is unavailable gives the reader a chore instead of an answer. Report what the queries returned.` + serviceIdentityBlock() }, - { role: 'user', content: `${historyBlock}QUESTION:\n${ledger.question}\n\nRESOLVED ENTITIES (refer to these by their exact full names):\n${JSON.stringify(termNames)}\n\nEVIDENCE (JSON):\n${JSON.stringify(evidence)}${availableBlock}${unmatchedBlock}${docBlock}${docMissBlock}${guidanceBlock}${codeBlock}${closingRule}\n\nWrite the answer.` } + { role: 'user', content: `${historyBlock}QUESTION:\n${ledger.question}\n\nRESOLVED ENTITIES (refer to these by their exact full names):\n${JSON.stringify(termNames)}\n\nEVIDENCE (JSON):\n${JSON.stringify(evidence)}${availableBlock}${unmatchedBlock}${docBlock}${docMissBlock}${guidanceBlock}${codeBlock}${closingRule}${englishDraftRule}\n\nWrite the answer.` } ] // Stream tokens when the caller wired a streaming sink; otherwise one-shot. // diff --git a/tests/unit/followOns.test.mjs b/tests/unit/followOns.test.mjs index ec65819..8e1ae60 100644 --- a/tests/unit/followOns.test.mjs +++ b/tests/unit/followOns.test.mjs @@ -345,3 +345,30 @@ test('a query VFB gave no label and this file cannot phrase is offered as nothin }) assert.deepEqual(buildFollowOns(ledger).chips.filter(c => c.kind === 'ask'), []) }) + +// --- written forms: plural and sentence-initial ------------------------------- +// +// "The mushroom bodies are paired neuropils" opened the L3 answer about one +// run in three and carried no link, because the linker matched the registry +// name letter for letter. The name as written may be plural or capitalised. + +import { writtenFormsPattern } from '../../lib/followOns.mjs' + +test('writtenFormsPattern allows the regular plural and a capital first letter, nothing more', () => { + const re = (n) => new RegExp(`^${writtenFormsPattern(n)}$`) + for (const ok of ['mushroom body', 'mushroom bodies', 'Mushroom body', 'Mushroom bodies']) assert.ok(re('mushroom body').test(ok), ok) + for (const no of ['MUSHROOM BODY', 'mushroom bodys', 'mushroom bod']) assert.ok(!re('mushroom body').test(no), no) + assert.ok(re('Kenyon cell').test('Kenyon cells')) + assert.ok(!re('Kenyon cell').test('kenyon cell'), 'a capitalised name keeps its case') + assert.ok(re('PN').test('PNs') && !re('PN').test('pn'), 'a symbol keeps its case') + assert.ok(re('calyx').test('calyxes') && !re('calyx').test('calyxs')) + assert.ok(!re('asymmetrical bodies').test('asymmetrical bodiess'), 'a name ending in s takes no plural') +}) + +test('linkifyKnownTerms links the plural and sentence-initial forms, keeping the prose as written', () => { + const links = [{ name: 'mushroom body', id: 'FBbt_00005801', url: vfbReportUrl('FBbt_00005801') }] + const out = linkifyKnownTerms('Mushroom bodies are paired neuropils. The mushroom body has a calyx.', links) + assert.ok(out.startsWith('[Mushroom bodies](https://www.virtualflybrain.org/reports/FBbt_00005801 "Open mushroom body in Virtual Fly Brain") are'), out) + assert.equal((out.match(/\]\(https/g) || []).length, 1, 'still linked once') + assert.ok(out.includes('The mushroom body has a calyx.'), 'the second mention is left as prose') +}) diff --git a/tests/unit/language.test.mjs b/tests/unit/language.test.mjs index b4e8251..471f490 100644 --- a/tests/unit/language.test.mjs +++ b/tests/unit/language.test.mjs @@ -407,6 +407,12 @@ test('a Hungarian question is answered in Hungarian: the ledger says so and the assert.equal(r.ledger.terms['ellipsoid body'].id, 'FBbt_00003678') assert.ok(deps.calls.synth.length >= 1) assert.equal(deps.calls.synth[0].silent, true, 'the English draft is accumulated, not shown') + // And the model is TOLD to write it in English: on a Japanese question Qwen + // wrote the draft in Japanese about one run in three, so the linker found + // nothing to link and the translation step had nothing to translate (L3). + const userMsg = deps.calls.synth[0].messages.find(m => m.role === 'user').content + assert.match(userMsg, /WRITE THE ANSWER IN ENGLISH, even though the question is written in Hungarian/) + assert.ok(userMsg.indexOf('WRITE THE ANSWER IN ENGLISH') > userMsg.indexOf('Write the answer, never where'), 'stated last') assert.ok(r.trace.some(e => e.step === 'language' && e.code === 'hu')) }) @@ -418,6 +424,7 @@ test('an English question streams as before', async () => { const r = await runHarness('Tell me about the ellipsoid body and its driver lines', deps) assert.equal(r.ledger.language, 'en') assert.equal(deps.calls.synth[0].silent, false) + assert.ok(!deps.calls.synth[0].messages.find(m => m.role === 'user').content.includes('WRITE THE ANSWER IN ENGLISH'), 'no language rule on an English turn') assert.ok(!deps.calls.structured.includes('english_term_name'), 'no translation rung on an English turn') }) diff --git a/tests/unit/runSignal.test.mjs b/tests/unit/runSignal.test.mjs index ddcb027..b89ed3a 100644 --- a/tests/unit/runSignal.test.mjs +++ b/tests/unit/runSignal.test.mjs @@ -141,3 +141,15 @@ test('the cancellation hooks the deployment actually provides are both wired', ( assert.match(src, /cancel \(reason\) \{/, 'the ReadableStream needs a cancel handler') assert.match(src, /clientSignal: request\.signal/, 'and the request signal must be passed in') }) + +test('the SSE stream carries a heartbeat while the run is silent', () => { + // Node's fetch (undici) drops a body that is idle for 300 s, and proxies are + // shorter. Four battery tasks on the first Qwen run were cut between 5 and + // 7½ minutes in as `terminated`, none near the 900 s cap. A comment line is + // invisible to every SSE parser and resets every idle timer on the path. + const src = fs.readFileSync(new URL('../../app/api/chat/route.js', import.meta.url), 'utf8') + assert.match(src, /controller\.enqueue\(encoder\.encode\(': keepalive\\n\\n'\)\)/, 'a comment line, not an event') + assert.match(src, /const SSE_HEARTBEAT_MS = 15000/, 'well inside the 300 s idle timeout') + assert.match(src, /if \(heartbeat\) clearInterval\(heartbeat\)/, 'and stopped when the run ends') + assert.ok(src.indexOf('clearInterval(heartbeat)') < src.indexOf('run.dispose()'), 'before the run signal is disposed') +})