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
67 changes: 67 additions & 0 deletions lib/orchestrator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export async function runHarness(question, deps) {
maybeInjectConnectivityStep(ledger, question, log)
maybeInjectRegionNeuronCountStep(ledger, question, log)
maybeInjectScrnaseqStep(ledger, question, log)
maybeInjectCountQueryStep(ledger, question, log)
continue
}
if (action.action === 'run_step') {
Expand Down Expand Up @@ -663,6 +664,72 @@ export function maybeInjectRegionNeuronCountStep(ledger, question, log = () => {
log({ inject: 'vfb_get_region_neuron_count', region: name })
}

const COUNT_INTENT_RE = /\b(how many|how much|number of|count of)\b/i
const COUNT_STOPWORDS = new Set([
'how', 'many', 'much', 'number', 'count', 'available', 'there', 'the', 'and', 'for',
'with', 'some', 'part', 'parts', 'that', 'this', 'which', 'what', 'are', 'have', 'has'
])

function countQueryWords(s = '') {
return (String(s).toLowerCase().match(/[a-z0-9]+/g) || [])
.filter(w => w.length > 2 && !COUNT_STOPWORDS.has(w))
}

/**
* For a "how many X of <term>" question, pick the single term-info query whose
* label best matches the question's distinctive words (ignoring the term's own
* name — every query label repeats it). Returns a query only when there is an
* 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 termWords = new Set(countQueryWords(digest?.name || ''))
const qWords = new Set(countQueryWords(question))
if (!qWords.size) return null
let best = null
let bestScore = 0
let secondScore = 0
for (const q of queries) {
let score = 0
for (const w of countQueryWords(q.label)) {
if (termWords.has(w)) continue
if (qWords.has(w)) score++
}
if (score > bestScore) { secondScore = bestScore; bestScore = score; best = q }
else if (score > secondScore) { secondScore = score }
}
return (bestScore >= 1 && bestScore > secondScore) ? best : null
}

/**
* Auto-run the matching term-info query for a count question ("how many images
* of neurons in <region>", "how many subclasses of <term>", …) so the answer
* gives the actual number instead of telling the user to run the query. This is
* what surfaces the count for queries get_term_info returns uncounted (count -1).
* Skipped when a specialised count/graph/expression step already covers it, or
* when the match is ambiguous. Idempotent.
*/
export function maybeInjectCountQueryStep(ledger, question, log = () => {}) {
const q = String(question || '')
if (!COUNT_INTENT_RE.test(q)) return
if (ledger.plan.some(s => /run_query|region_neuron_count|connectivity|scrnaseq/i.test(s.tool))) return
const terms = Object.values(ledger.terms || {}).filter(t => t.id && t.digest?.queries?.length)
if (terms.length !== 1) return
const t = terms[0]
const best = pickBestQueryForQuestion(q, t.digest)
if (!best?.query_type) return
ledger.plan.push({
id: `cq${ledger.plan.length + 1}`,
tool: 'vfb_run_query',
answers: [q],
args: { id: t.id, query_type: best.query_type },
status: 'pending',
note: `auto-injected count step (count question → run ${best.query_type})`
})
log({ inject: 'vfb_run_query', query_type: best.query_type, id: t.id })
}

export function pickBestTermId(search, queryName = '') {
const docs = search?.response?.docs || search?.docs || []
const valid = docs.filter(d => {
Expand Down
Loading
Loading