From 2e58f0b332e58275946c2c7bcb64c0c9177c28e1 Mon Sep 17 00:00:00 2001 From: Robbie Court Date: Wed, 15 Jul 2026 09:27:31 +0100 Subject: [PATCH] Add query-type semantics: image vs class counts, and image-aware routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat conflated individual-image queries with class queries — it answered "how many images of neurons with a part in the medulla" with 28 (PartsOf, a class query = 28 subparts) reported as "28 images", when the real images count is ImagesNeurons = 226,524. Row id prefixes separate them: individual-image queries return VFB_* rows (count = images), class queries return FBbt_* rows whose thumbnails are just examples (count = number of classes). - Add lib/queryTypes.mjs: per-query_type semantics (kind + countNoun), with querySemantics()/isIndividualImageQuery(). - Digest (termInfoDigest.mjs): render each count with its unit ("226,524 images", "471 neuron types", "28 subparts") and a header explaining that class counts are not image counts and that class thumbnails are examples; include the unit in the uncounted "run this query" line. - Routing (orchestrator pickBestQueryForQuestion): for an "images" question, restrict candidates to individual-image queries so it can never pick a class query like PartsOf. Tested with the user's exact question (deliberate "medualla" misspelling). Tests: queryTypes 4, termInfoDigest 9, orchestrator 23; next lint clean. --- lib/orchestrator.mjs | 14 ++++- lib/queryTypes.mjs | 88 ++++++++++++++++++++++++++++++ lib/termInfoDigest.mjs | 9 ++- tests/unit/orchestrator.test.mjs | 30 ++++++---- tests/unit/queryTypes.test.mjs | 35 ++++++++++++ tests/unit/termInfoDigest.test.mjs | 15 ++--- 6 files changed, 168 insertions(+), 23 deletions(-) create mode 100644 lib/queryTypes.mjs create mode 100644 tests/unit/queryTypes.test.mjs diff --git a/lib/orchestrator.mjs b/lib/orchestrator.mjs index 0bced6b..2033bde 100644 --- a/lib/orchestrator.mjs +++ b/lib/orchestrator.mjs @@ -27,6 +27,7 @@ import { import { extractPublicationRefs } from './literatureRefs.mjs' import { isTermInfo, buildTermInfoDigest, termInfoToDigestText, unwrapTermInfo, parseReplacedBy, isDeprecatedRecord } from './termInfoDigest.mjs' import { synthGuidance } from './guidanceCards.mjs' +import { isIndividualImageQuery } from './queryTypes.mjs' const MAX_EXTRACT_CHARS = 6000 @@ -682,11 +683,20 @@ function countQueryWords(s = '') { * UNAMBIGUOUS best match, so we never auto-run the wrong query. */ export function pickBestQueryForQuestion(question, digest) { - const queries = Array.isArray(digest?.queries) ? digest.queries : [] - if (!queries.length) return null + const all = Array.isArray(digest?.queries) ? digest.queries : [] + if (!all.length) return null const termWords = new Set(countQueryWords(digest?.name || '')) const qWords = new Set(countQueryWords(question)) if (!qWords.size) return null + // An "images" question must be answered by an individual-image query (count = + // images), never a class query like PartsOf/NeuronsPartHere (count = classes). + // Restrict the candidate pool when the user asks about images and any + // individual-image query is available for this term. + let queries = all + if (/\bimages?\b/i.test(String(question || ''))) { + const imageQueries = all.filter(q => isIndividualImageQuery(q.query_type)) + if (imageQueries.length) queries = imageQueries + } let best = null let bestScore = 0 let secondScore = 0 diff --git a/lib/queryTypes.mjs b/lib/queryTypes.mjs new file mode 100644 index 0000000..6677976 --- /dev/null +++ b/lib/queryTypes.mjs @@ -0,0 +1,88 @@ +// Semantic classification of VFB term-info query types, so the assistant can +// (a) describe what a query's COUNT means and (b) pick the right query. +// +// The critical distinction, confirmed by the row id prefix in the results: +// - INDIVIDUAL-IMAGE queries return individual registered images (VFB_* rows); +// the count is a number of IMAGES. e.g. ImagesNeurons on the medulla returns +// 226,524 individual neuron images. +// - CLASS-LIST queries return ontology CLASSES (FBbt_* rows); the count is a +// number of classes/types, and any thumbnail shown is just ONE example image +// of that class. e.g. NeuronsPartHere = 471 neuron types, PartsOf = 28 +// subparts. Reporting these as "images" is wrong. +// +// countNoun is what one row/one unit of the count IS, used to word answers +// ("226,524 images", "471 neuron types", "28 subparts"). + +const S = (kind, countNoun) => ({ kind, countNoun }) + +export const QUERY_SEMANTICS = { + // individual images — count = images (VFB_* rows) + ListAllAvailableImages: S('individual_images', 'images'), + AllAlignedImages: S('individual_images', 'images'), + DatasetImages: S('individual_images', 'images'), + ImagesNeurons: S('individual_images', 'images of neurons'), + ImagesThatDevelopFrom: S('individual_images', 'images'), + epFrag: S('individual_images', 'image fragments'), + PaintedDomains: S('individual_images', 'painted-domain images'), + + // class lists — count = classes/types; thumbnails are examples (FBbt_* rows) + NeuronsPartHere: S('class_list', 'neuron types'), + NeuronsSynaptic: S('class_list', 'neuron types'), + NeuronsPresynapticHere: S('class_list', 'neuron types'), + NeuronsPostsynapticHere: S('class_list', 'neuron types'), + NeuronClassesFasciculatingHere: S('class_list', 'neuron types'), + SubclassesOf: S('class_list', 'subclasses'), + PartsOf: S('class_list', 'subparts'), + ComponentsOf: S('class_list', 'components'), + TractsNervesInnervatingHere: S('class_list', 'tracts/nerves'), + LineageClonesIn: S('class_list', 'lineage clones'), + ExpressionOverlapsHere: S('class_list', 'anatomy terms'), + + // transgene / expression reports + TransgeneExpressionHere: S('expression', 'transgene expression reports'), + + // connectivity — count = partners/classes + ref_neuron_region_connectivity_query: S('connectivity', 'region connections'), + ref_neuron_neuron_connectivity_query: S('connectivity', 'connected neurons'), + ref_downstream_class_connectivity_query: S('connectivity', 'downstream neuron classes'), + ref_upstream_class_connectivity_query: S('connectivity', 'upstream neuron classes'), + DownstreamClassConnectivity: S('connectivity', 'downstream neuron classes'), + UpstreamClassConnectivity: S('connectivity', 'upstream neuron classes'), + NeuronNeuronConnectivityQuery: S('connectivity', 'connected neurons'), + NeuronRegionConnectivityQuery: S('connectivity', 'region connections'), + + // morphological similarity — individual neurons/expression patterns + SimilarMorphologyTo: S('similarity', 'neurons'), + SimilarMorphologyToPartOf: S('similarity', 'expression patterns'), + SimilarMorphologyToPartOfexp: S('similarity', 'neurons'), + SimilarMorphologyToNB: S('similarity', 'neurons'), + SimilarMorphologyToNBexp: S('similarity', 'expression patterns'), + SimilarMorphologyToUserData: S('similarity', 'neurons'), + + // single-cell transcriptomics + anatScRNAseqQuery: S('scrnaseq', 'scRNAseq clusters'), + clusterExpression: S('scrnaseq', 'genes'), + scRNAdatasetData: S('scrnaseq', 'clusters'), + expressionCluster: S('scrnaseq', 'clusters'), + + // datasets + AllDatasets: S('dataset', 'datasets'), + AlignedDatasets: S('dataset', 'datasets'), + + // FlyBase + FindStocks: S('stocks', 'fly stocks'), + FindComboPublications: S('publications', 'publications'), + TermsForPub: S('terms', 'terms') +} + +const DEFAULT = S('other', 'results') + +/** Semantics for a query_type: { kind, countNoun }. Unknown types get a safe default. */ +export function querySemantics(queryType = '') { + return QUERY_SEMANTICS[queryType] || DEFAULT +} + +/** True when a query returns individual images (its count is a number of images). */ +export function isIndividualImageQuery(queryType = '') { + return querySemantics(queryType).kind === 'individual_images' +} diff --git a/lib/termInfoDigest.mjs b/lib/termInfoDigest.mjs index 96f5e24..e3da1ef 100644 --- a/lib/termInfoDigest.mjs +++ b/lib/termInfoDigest.mjs @@ -16,6 +16,8 @@ // // See outputs/reports/vfbchat-live-eval-and-followons-2026-06-13.md. +import { querySemantics } from './queryTypes.mjs' + /** Does this object look like a single (flat) VFB get_term_info record? */ export function isTermInfo(obj) { if (!obj || typeof obj !== 'object') return false @@ -220,15 +222,16 @@ export function digestToText(digest, { maxQueries = 16, maxExamples = 5 } = {}) if (digest.relationships) lines.push(`Relationships: ${digest.relationships}`) if (digest.synonyms?.length) lines.push(`Synonyms: ${digest.synonyms.join(', ')}`) if (digest.queries?.length) { - lines.push('Available VFB data for this term (query result: total count — examples). "run this query" means the total was not precomputed but the data exists — call vfb_run_query with that query_type to get the count/results:') + lines.push('Available VFB data for this term. Each line is a query: the count is the number of that item — "images" are individual images; "neuron types"/"subparts"/"subclasses" are ontology classes that may each show ONE example image (never report a class count as an image count). "run this query" = the total was not precomputed; call vfb_run_query with that query_type to get it:') for (const q of digest.queries.slice(0, maxQueries)) { const ex = q.examples.slice(0, maxExamples).join(', ') + const noun = querySemantics(q.query_type).countNoun if (q.count < 0) { // Count not precomputed by get_term_info — the data is available; the // query must be run to get the count/results (do NOT read this as "no data"). - lines.push(`- ${q.label}: run this query — call vfb_run_query with query_type ${q.query_type} to get the count/results`) + lines.push(`- ${q.label}: run this query — call vfb_run_query with query_type ${q.query_type} to get the number of ${noun}`) } else { - lines.push(`- ${q.label}: ${q.count}${ex ? ` (e.g. ${ex})` : ''}`) + lines.push(`- ${q.label}: ${q.count} ${noun}${ex ? ` (e.g. ${ex})` : ''}`) } } } diff --git a/tests/unit/orchestrator.test.mjs b/tests/unit/orchestrator.test.mjs index 36aba94..0ca628b 100644 --- a/tests/unit/orchestrator.test.mjs +++ b/tests/unit/orchestrator.test.mjs @@ -223,30 +223,38 @@ test('statusSummary: compact and complete flag', () => { // ---- count-question auto-run (surface the number, don't tell the user to run it) ---- +// PartsOf (28 subparts, a CLASS query) is the trap: the deployed chat picked it +// for "how many images …" and reported "28 images". The image-aware routing must +// pick the individual-image query (ImagesNeurons) and never a class query. const MEDULLA_DIGEST = { name: 'medulla', queries: [ { query_type: 'ImagesNeurons', label: 'Images of neurons with some part in medulla', count: -1 }, { query_type: 'NeuronsPartHere', label: 'Neurons with some part in medulla', count: -1 }, + { query_type: 'PartsOf', label: 'Parts of medulla', count: 28 }, { query_type: 'SubclassesOf', label: 'Subclasses of medulla', count: 4 } ] } -test('pickBestQueryForQuestion: unambiguous best match wins, ambiguous/none returns null', () => { - assert.equal( - pickBestQueryForQuestion('how many images of neurons with a part in the medulla are available?', MEDULLA_DIGEST)?.query_type, - 'ImagesNeurons') - assert.equal( - pickBestQueryForQuestion('how many subclasses of the medulla?', MEDULLA_DIGEST)?.query_type, - 'SubclassesOf') - // a question with no distinctive overlap returns null (don't guess) +// The user's exact question — the "medualla" misspelling is deliberate: the term +// still resolves to the medulla, and query matching keys off "images"/"neurons", +// not the (mis-spelled) term word. +const MEDULLA_Q = 'how many images of neurons with a part in the medualla are available?' + +test('pickBestQueryForQuestion: image question picks the individual-image query, never a class query', () => { + const picked = pickBestQueryForQuestion(MEDULLA_Q, MEDULLA_DIGEST) + assert.equal(picked?.query_type, 'ImagesNeurons') + assert.notEqual(picked?.query_type, 'PartsOf') + // a non-image count question is unrestricted and picks the unique match + assert.equal(pickBestQueryForQuestion('how many subclasses of the medulla?', MEDULLA_DIGEST)?.query_type, 'SubclassesOf') + // no distinctive overlap → null (don't guess) assert.equal(pickBestQueryForQuestion('how many kittens of the medulla?', MEDULLA_DIGEST), null) }) -test('maybeInjectCountQueryStep: injects a run_query step for the matched query on a count question', () => { +test('maybeInjectCountQueryStep: auto-runs ImagesNeurons for the medulla image question (deliberate typo)', () => { const l = setPlan(createLedger('q'), { intent: 'other', underspecified: false, clarifying_question: '', terms_to_resolve: ['medulla'], steps: [] }) addTerm(l, 'medulla', { id: 'FBbt_00003748', digest: MEDULLA_DIGEST }) - maybeInjectCountQueryStep(l, 'how many images of neurons with a part in the medulla are available?') + maybeInjectCountQueryStep(l, MEDULLA_Q) assert.equal(l.plan.length, 1) assert.equal(l.plan[0].tool, 'vfb_run_query') assert.deepEqual(l.plan[0].args, { id: 'FBbt_00003748', query_type: 'ImagesNeurons' }) @@ -261,6 +269,6 @@ test('maybeInjectCountQueryStep: no-op for non-count questions and when a run_qu // already has a run_query step → don't duplicate const l2 = setPlan(createLedger('q'), { intent: 'other', underspecified: false, clarifying_question: '', terms_to_resolve: ['medulla'], steps: [{ id: 's1', tool: 'vfb_run_query', answers: ['x'] }] }) addTerm(l2, 'medulla', { id: 'FBbt_00003748', digest: MEDULLA_DIGEST }) - maybeInjectCountQueryStep(l2, 'how many images of neurons in the medulla?') + maybeInjectCountQueryStep(l2, MEDULLA_Q) assert.equal(l2.plan.length, 1) }) diff --git a/tests/unit/queryTypes.test.mjs b/tests/unit/queryTypes.test.mjs new file mode 100644 index 0000000..148ae4c --- /dev/null +++ b/tests/unit/queryTypes.test.mjs @@ -0,0 +1,35 @@ +// Tests for the query-type semantics map. +// Run: node --test tests/unit/queryTypes.test.mjs + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { querySemantics, isIndividualImageQuery, QUERY_SEMANTICS } from '../../lib/queryTypes.mjs' + +test('individual-image queries are classified as image queries (count = images)', () => { + for (const qt of ['ImagesNeurons', 'ListAllAvailableImages', 'AllAlignedImages']) { + assert.equal(isIndividualImageQuery(qt), true, qt) + assert.match(querySemantics(qt).countNoun, /image/) + } +}) + +test('class-list queries are NOT image queries, with class count nouns', () => { + assert.equal(isIndividualImageQuery('NeuronsPartHere'), false) + assert.equal(querySemantics('NeuronsPartHere').countNoun, 'neuron types') + assert.equal(isIndividualImageQuery('PartsOf'), false) + assert.equal(querySemantics('PartsOf').countNoun, 'subparts') + assert.equal(querySemantics('SubclassesOf').countNoun, 'subclasses') +}) + +test('unknown query types get a safe default (not an image query)', () => { + const s = querySemantics('SomethingNew') + assert.equal(s.kind, 'other') + assert.equal(s.countNoun, 'results') + assert.equal(isIndividualImageQuery('SomethingNew'), false) +}) + +test('every entry has a kind and a countNoun', () => { + for (const [qt, s] of Object.entries(QUERY_SEMANTICS)) { + assert.ok(s.kind, `${qt} missing kind`) + assert.ok(s.countNoun, `${qt} missing countNoun`) + } +}) diff --git a/tests/unit/termInfoDigest.test.mjs b/tests/unit/termInfoDigest.test.mjs index b3757c5..7c68794 100644 --- a/tests/unit/termInfoDigest.test.mjs +++ b/tests/unit/termInfoDigest.test.mjs @@ -124,9 +124,10 @@ test('digest name uses the full canonical label, not a short symbol', () => { test('digestToText renders the available-data block with counts (answers "inputs to X")', () => { const text = termInfoToDigestText(MB) assert.match(text, /Available VFB data/) - assert.match(text, /Neurons with presynaptic terminals in mushroom body: 367 \(e\.g\. Li38, Li39\)/) - assert.match(text, /Expression patterns overlapping mushroom body: 23/) - assert.match(text, /Subclasses of mushroom body: 2/) + // each count carries its unit (countNoun) so the model words it correctly + assert.match(text, /Neurons with presynaptic terminals in mushroom body: 367 neuron types \(e\.g\. Li38, Li39\)/) + assert.match(text, /Expression patterns overlapping mushroom body: 23 anatomy terms/) + assert.match(text, /Subclasses of mushroom body: 2 subclasses/) // compact: must be far smaller than a raw 25 KB payload assert.ok(text.length < 4000, `digest should be compact, got ${text.length}`) }) @@ -156,9 +157,9 @@ test('uncounted (-1) queries are kept and flagged to be run, not dropped as "no const text = termInfoToDigestText(UNCOUNTED) // The image query is surfaced with an explicit "run this query" instruction - // naming the query_type, not hidden and not shown as a bare -1. - assert.match(text, /Images of neurons with some part in the medulla: run this query — call vfb_run_query with query_type ImagesNeurons/) + // naming the query_type and what its count is (images), not hidden / bare -1. + assert.match(text, /Images of neurons with some part in the medulla: run this query — call vfb_run_query with query_type ImagesNeurons to get the number of images of neurons/) assert.doesNotMatch(text, /-1/) - // counted query still renders its number - assert.match(text, /Subclasses of medulla: 4/) + // counted query renders its number with the right unit (subclasses, not images) + assert.match(text, /Subclasses of medulla: 4 subclasses/) })