Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions lib/orchestrator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions lib/queryTypes.mjs
Original file line number Diff line number Diff line change
@@ -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'
}
9 changes: 6 additions & 3 deletions lib/termInfoDigest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})` : ''}`)
}
}
}
Expand Down
30 changes: 19 additions & 11 deletions tests/unit/orchestrator.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand All @@ -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)
})
35 changes: 35 additions & 0 deletions tests/unit/queryTypes.test.mjs
Original file line number Diff line number Diff line change
@@ -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`)
}
})
15 changes: 8 additions & 7 deletions tests/unit/termInfoDigest.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
})
Expand Down Expand Up @@ -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/)
})
Loading