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: 63 additions & 4 deletions app/api/chat/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ import {
annotateFailedRunQuery,
createForceRefreshBudget,
forceRefreshKey,
isFailedRunQueryPayload
isFailedRunQueryPayload,
isSuspiciousZeroRunQuery
} from '../../../lib/runQueryRetry.mjs'
import {
parseThumbnailEntity,
Expand Down Expand Up @@ -1261,7 +1262,14 @@ function getForceRefreshBudget(context) {

async function callMcpToolTextWithForceRefresh(client, name, args, { budget } = {}) {
const text = mcpResultToText(await callMcpToolWithRetry(client, name, args))
if (!FORCE_REFRESH_RETRY_TOOLS.has(name) || !isFailedRunQueryPayload(text)) return text
if (!FORCE_REFRESH_RETRY_TOOLS.has(name)) return text
const failed = isFailedRunQueryPayload(text)
// A class-connectivity count 0 is retried like a -1 (#66): the edge cache
// kept the zeros VFBquery used to emit for a failed aggregation, and one is
// indistinguishable from an empty set until it has been recomputed once.
// See SUSPICIOUS_ZERO_QUERY_TYPES for the scope; a second zero is believed.
const suspiciousZero = !failed && isSuspiciousZeroRunQuery(name, args, text)
if (!failed && !suspiciousZero) return text

// `budget` was passed by exactly one of the 27 call sites — the generic MCP
// routing path. Every macro tool left it empty and fell back to a fresh
Expand All @@ -1272,13 +1280,24 @@ async function callMcpToolTextWithForceRefresh(client, name, args, { budget } =
// that is seven X-Force-Refresh recomputes against the shared upstream instead
// of two. The request's allowance now rides on the client.
const allowance = budget || client?.[FORCE_REFRESH_BUDGET] || createForceRefreshBudget(1)
// annotateFailedRunQuery leaves a count-0 payload untouched, so the
// suspicious-zero path returns the original result when the budget is spent.
if (!allowance.tryConsume(forceRefreshKey(name, args))) return annotateFailedRunQuery(text)

console.error(`[VFBchat] ${name} returned count -1 — retrying once with force_refresh | args=${safeToolArgs(args)}`)
const why = failed ? 'count -1' : 'count 0 for class connectivity'
console.error(`[VFBchat] ${name} returned ${why} — retrying once with force_refresh | args=${safeToolArgs(args)}`)
const startedAt = Date.now()
try {
const retryText = mcpResultToText(
await callMcpToolWithRetry(client, name, { ...args, force_refresh: true })
)
if (suspiciousZero) {
// Recorded either way: a zero that became a result is a cache the edge
// is still serving, and a zero that stayed zero is a class that really
// has no connectome-annotated instances. Both are worth counting.
const after = Number(parseMaybeJsonCount(retryText))
console.error(`[VFBchat] CLASS CONNECTIVITY ZERO RETRY | query_type=${args?.query_type} | id=${safeToolArgs({ id: args?.id })} | count_after=${Number.isFinite(after) ? after : 'n/a'} | ms=${Date.now() - startedAt}`)
}
if (!isFailedRunQueryPayload(retryText)) return retryText
return annotateFailedRunQuery(retryText)
} catch (error) {
Expand All @@ -1287,6 +1306,15 @@ async function callMcpToolTextWithForceRefresh(client, name, args, { budget } =
}
}

function parseMaybeJsonCount(text) {
try {
const parsed = typeof text === 'string' ? JSON.parse(text) : text
return parsed && typeof parsed === 'object' ? parsed.count : undefined
} catch {
return undefined
}
}

// The version sent here is what the MCP server records against every call this
// client makes, so it comes from the one source in lib/appVersion.mjs. A
// hard-coded copy goes stale at the first release nobody remembers to edit it
Expand Down Expand Up @@ -11348,6 +11376,31 @@ async function localiseFollowOns(followOns, language, { apiBaseUrl, apiKey, apiM
return chips.map((c, i) => (labels[i] && labels[i] !== c.label ? { ...c, label: labels[i] } : c))
}

/**
* One chip per reading the resolver could not choose between (#66 follow-up):
* the user's own question with the ambiguous name swapped for the reading's
* VFB label, so clicking it re-asks the question about exactly that term. When
* the name is not literally in the question (the planner paraphrased it) the
* label is prefixed instead, which the next turn's planner reads as the term.
* Exported for the unit test; no model, no network.
*/
export function clarifyReadingChips(question = '', options = []) {
const out = []
const seen = new Set()
for (const o of Array.isArray(options) ? options : []) {
const label = String(o?.label || '').trim()
const name = String(o?.name || '').trim()
if (!label || seen.has(label.toLowerCase())) continue
seen.add(label.toLowerCase())
const q = String(question || '').trim()
const re = name ? new RegExp(`(?<![\\w-])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`, 'i') : null
const query = re && re.test(q) ? q.replace(re, label) : (q ? `${q} (I mean ${label})` : label)
out.push({ kind: 'ask', label, query: query.slice(0, 300), title: `Ask again about ${label}` })
if (out.length >= 6) break
}
return out
}

/**
* The follow-on chips of the previous turn, as the client sent them back in
* its history, for the turn that re-renders that answer in another language.
Expand Down Expand Up @@ -11560,13 +11613,19 @@ async function runRoleHarnessForRequest({ priorMessages, lastAssistantText = '',
// answer. The Finglish question that got "Do you want to know how many
// split-GAL4 driver lines…?" back in English is the case.
const clarifyRendered = await renderInLanguage({ text: clarifyEnglish, language, kind: 'clarification', sendEvent, ...languageDeps })
// When the resolver asked which of several exact readings was meant, the
// readings are offered as chips: each re-asks THIS question with the
// ambiguous name replaced by the term's own VFB label, which the next
// turn resolves exactly. Labels are localised like any other chip.
const readingChips = clarifyReadingChips(userMessage, live.ledger?.clarifyOptions)
const clarifyFollowOns = readingChips.length ? await localiseFollowOns(readingChips, language, languageDeps) : []
return {
ok: true,
responseText: clarifyRendered.text,
images: [],
graphs: [],
tables: [],
followOns: [],
followOns: clarifyFollowOns,
sources: [],
// Even a clarifying turn carries the context forward. A question the
// harness could not answer without more information is exactly the
Expand Down
9 changes: 8 additions & 1 deletion lib/classPartners.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,14 @@ export function summariseClassPartners(parsed, { label = '', partnerFilter = '',
partners: ranked.partners,
aggregates: ranked.aggregates,
self: ranked.self,
rows: ranked.partners.map(r => ({ name: r.label, id: r.id }))
// Every named class, not only the ranked partners: the roll-up classes and
// the self-row are named in the claim and in the table too, and `rows` is
// what the caller registers for linking. Registering partners alone left
// "adult neuron, adult CNS neuron, adult interneuron" as the only names in
// a DNp32 answer without a link (Korean turn, 10 Sep 2026).
rows: [...ranked.partners, ...ranked.aggregates, ...ranked.self]
.filter(r => r?.id)
.map(r => ({ name: r.label, id: r.id }))
}
}

Expand Down
160 changes: 159 additions & 1 deletion lib/orchestrator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,10 @@ export async function runHarness(question, deps) {
const terms = speculative.length ? [...action.terms, ...speculative] : action.terms
emit(deps, `Resolving ${terms.length} term${terms.length === 1 ? '' : 's'} in VFB`, 'mcp')
await resolveTerms(ledger, terms, deps, models, log, new Set(speculative))
// A name the resolver could not read one way (askWhichReading) turns
// the turn into a clarifying question: nothing below should plan, inject
// or run a step against a term the user has not yet chosen.
if (ledger.underspecified && ledger.clarifyingQuestion) continue
displaceBackReferences(ledger, question, questionSymbols, log)
// Deterministic graph routing: a connectivity/graph question about a single
// resolved NEURON TYPE always runs the connectivity tool, so a graph appears
Expand Down Expand Up @@ -939,6 +943,12 @@ async function resolveTerms(ledger, names, deps, models, log, speculative = new
// nameVariants) and the loop stops at the first that is accepted, so the
// usual single-plural case still costs exactly one extra search.
let resolvedId = directId || pickBestTermId(search, name)
// The wording the chosen document was matched AGAINST — the name, or the
// variant, translation or symbol that replaced it below. The tie check at
// the end of the ladder asks which other documents match this wording
// exactly, and asking that of the original name after a variant matched
// would find nothing.
let matchedAs = name
// Why the retry happened, in the vocabulary the trace already uses: the
// three states are distinguishable and a resolution that came from a variant
// is only explainable afterwards if the trace says which state it was in.
Expand Down Expand Up @@ -977,6 +987,7 @@ async function resolveTerms(ledger, names, deps, models, log, speculative = new
if (accepted) {
search = retry
resolvedId = accepted
matchedAs = exact ? variant : name
log({ resolve_retry: name, as: variant, reason: retryReason })
break
}
Expand Down Expand Up @@ -1010,6 +1021,7 @@ async function resolveTerms(ledger, names, deps, models, log, speculative = new
if (hit) {
search = probed
resolvedId = hit.id
matchedAs = hit.as || name
log({ resolve_spelling: name, as: hit.as, id: hit.id, edits: hit.dist, probe })
break
}
Expand Down Expand Up @@ -1039,6 +1051,7 @@ async function resolveTerms(ledger, names, deps, models, log, speculative = new
if (hit) {
search = translated
resolvedId = hit
matchedAs = english
log({ resolve_translation: name, as: english, id: hit, language: ledger.language })
} else if (searchIsEmpty(search)) {
// Candidates to show, at least: the English wording found
Expand Down Expand Up @@ -1091,8 +1104,25 @@ async function resolveTerms(ledger, names, deps, models, log, speculative = new
// knows about EPG neurons (#59). A plural or generic name that landed
// on an INDIVIDUAL without matching its label exactly is a name for a
// class: search the symbol alone among classes, and take an exact match.
//
// The same lift applies to a BARE symbol that landed on an individual by
// GUESS (#66). "what cell types are downstream of KCg?" resolved "KCg"
// to KCg-d_R (FlyEM-HB:1003837184), one hemibrain cell, and the answer
// listed that cell's 221 partners as the cell types downstream of KCg.
// VFB's index ranks the five hundred KCg-* individuals above the class
// whose symbol IS "KCg" (gamma Kenyon cell, FBbt_00100247), so the class
// never reached the thirty rows the ladder saw, and the token-superset
// rule took the first individual carrying the token. Nothing there was
// an exact match — the individual's label is "KCg-d_R (…)", not "KCg".
// A name that matched NO document exactly, and whose exact match among
// classes exists, names that class: exact beats guess, which is the
// ladder's own first rule, applied across the search the ranking hid.
// A name that IS an individual's label or synonym ("EPG neuron" in #59
// matched a synonym exactly) is only lifted when its shape says type.
const chosenNow = validSearchDocs(search).find(d => sfOf(d) === resolvedId)
if (chosenNow && docIsIndividual(chosenNow) && namesAType(name) && norm(docLabel(chosenNow)) !== norm(name)) {
const liftable = chosenNow && docIsIndividual(chosenNow) && norm(docLabel(chosenNow)) !== norm(name) &&
(namesAType(name) || !exactTermMatchId(search, name))
if (liftable) {
const symbol = stripEntityNoun(name)
if (symbol && !budget.expired()) {
const classRaw = await raceDeadline(
Expand All @@ -1105,9 +1135,44 @@ async function resolveTerms(ledger, names, deps, models, log, speculative = new
log({ resolve_lift_to_class: name, from: resolvedId, to: classId, as: symbol })
resolvedId = classId
search = classSearch
matchedAs = symbol
}
}
}
// TIED EXACT MATCHES. "KCg" is the symbol of BOTH "adult gamma Kenyon
// cell" (FBbt_00049828) and "gamma Kenyon cell" (FBbt_00100247), and
// exactTermMatchId hands back whichever VFB ranked first — a coin the
// user never saw tossed. When several documents match the wording
// exactly on the same rung, the question decides if it can (a stage
// word: "adult", "larval"); failing that the model is asked whether the
// question itself settles it; failing that the USER is asked, with the
// readings as chips, rather than answered about a term they may not
// have meant. A single exact match, or a clear winner between rungs
// (a label beats a synonym), is not a tie and none of this runs.
// A speculative name is one WE wrote, so the user cannot be asked which
// of its readings they meant; VFB's first stays. And only CLASSES tie:
// five hemibrain cells all labelled "KCg-d_R" are not readings a user
// can choose between by name — that case is answered about one and the
// others are disclosed (sameKindAlternatives, below).
const tied = speculative.has(name) ? [] : exactTermMatchDocs(search, matchedAs).filter(d => !docIsIndividual(d))
if (tied.length > 1 && tied.some(d => sfOf(d) === resolvedId)) {
let pick = decideTiedReadingFromQuestion(ledger.question, tied)
let via = pick ? 'question' : ''
if (!pick && typeof deps.callStructured === 'function' && !budget.expired()) {
pick = await decideTiedReadingWithModel(ledger.question, name, tied, deps, models)
via = pick ? 'model' : ''
}
if (pick) {
if (sfOf(pick) !== resolvedId) resolvedId = sfOf(pick)
log({ resolve_disambiguated: name, as: matchedAs, to: resolvedId, via, among: tied.map(sfOf) })
} else {
const options = tied.map(d => ({ id: sfOf(d), label: docLabel(d) }))
askWhichReading(ledger, name, options)
addTerm(ledger, name, { id: null, attempted: true, candidates: options.map(o => o.label), ambiguous: true, speculative: speculative.has(name) })
log({ resolve_ambiguous: name, as: matchedAs, candidates: options.map(o => o.id) })
return
}
}
// A bare symbol that several same-kind terms carry ("DA1": DA1 PN, DA1
// lPN, DA1 vPN, MN-DA1) has been READ, not matched. The reading is used —
// an answer beats a clarifying question here — but the answer must say
Expand Down Expand Up @@ -4860,6 +4925,99 @@ export function exactTermMatchId(search, queryName = '') {
return singExact ? sfOf(singExact) : null
}

/**
* EVERY document that matches `queryName` exactly on the strongest rung any
* document reaches — the same three rungs as exactTermMatchId, and the same
* precedence, so the first of these is always what exactTermMatchId returns.
* Two documents here are a tie the ladder cannot break on its own: "KCg" is
* the symbol of both "adult gamma Kenyon cell" and "gamma Kenyon cell", and
* nothing in the wording prefers one. One document, or none, is not a tie.
* De-duplicated by id: VFB returns a row per matching synonym when asked to.
*/
export function exactTermMatchDocs(search, queryName = '') {
const q = norm(queryName)
if (!q) return []
const valid = validSearchDocs(search)
if (!valid.length) return []
const uniq = (docs) => {
const seen = new Set()
return docs.filter(d => { const id = sfOf(d); if (!id || seen.has(id)) return false; seen.add(id); return true })
}
const byLabel = uniq(valid.filter(d => norm(docLabel(d)) === q))
if (byLabel.length) return byLabel
const bySyn = uniq(valid.filter(d => docSynonyms(d).includes(q)))
if (bySyn.length) return bySyn
const qTokFull = toks(q)
if (!qTokFull.length) return []
return uniq(valid.filter(d => sameTokenSet(toks(docLabel(d)), qTokFull)))
}

const STAGE_WORDS = ['adult', 'larval', 'larva', 'embryonic', 'embryo', 'pupal', 'pupa']
const stageIn = (text) => STAGE_WORDS.filter(w => new RegExp(`\\b${w}\\b`, 'i').test(String(text || '')))
const sameStage = (a, b) => a.replace(/^(larva|embryo|pupa)$/, m => ({ larva: 'larval', embryo: 'embryonic', pupa: 'pupal' })[m]) ===
b.replace(/^(larva|embryo|pupa)$/, m => ({ larva: 'larval', embryo: 'embryonic', pupa: 'pupal' })[m])

/**
* The one tied reading the QUESTION picks out, or null. Deterministic and
* narrow: a stage word in the question ("adult", "larval") selects the one
* candidate whose label carries that stage. A question that names no stage
* does not prefer the stage-agnostic class — that is exactly the choice the
* user is asked about.
*/
export function decideTiedReadingFromQuestion(question = '', docs = []) {
const asked = stageIn(question)
if (!asked.length) return null
const hits = docs.filter(d => stageIn(docLabel(d)).some(s => asked.some(a => sameStage(a.toLowerCase(), s.toLowerCase()))))
return hits.length === 1 ? hits[0] : null
}

const TIED_READING_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['choice'],
properties: { choice: { type: 'integer' } }
}

/**
* Ask the model whether the question itself settles which tied reading was
* meant. It may only choose on evidence in the question — a dataset, a stage,
* a qualifier, an earlier mention — and returns null (ask the user) otherwise.
* One structured call on the extract profile; any failure is "unsure".
*/
export async function decideTiedReadingWithModel(question, name, docs, deps, models = {}) {
const candidates = docs.map((d, i) => ({ index: i, label: docLabel(d) }))
const messages = [
{
role: 'system',
content: 'A name in a user\'s question about Drosophila neuroanatomy matches more than one Virtual Fly Brain term exactly. Decide which term the user meant ONLY if the question itself decides it — a life stage, a dataset, a qualifier, or wording that fits exactly one candidate. If the question does not decide, answer -1 so the user can be asked. Never guess from which candidate seems more common. Reply with JSON {"choice": <index or -1>}.'
},
{ role: 'user', content: JSON.stringify({ question: String(question || ''), name: String(name), candidates }) }
]
try {
const r = await deps.callStructured({ messages, schema: TIED_READING_SCHEMA, schemaName: 'tied_term_reading', model: models.extract })
const i = r?.ok ? Number(r.value?.choice) : -1
return Number.isInteger(i) && i >= 0 && i < docs.length ? docs[i] : null
} catch {
return null
}
}

/**
* Turn an unbroken tie into the turn's clarifying question, with the readings
* carried as options for the follow-on chips. Written in English like the
* planner's own clarifying question, so it is rendered in the user's language
* by the same path. Labels only — an id in the text would be stripped as a
* leak, and is not what a reader chooses by.
*/
export function askWhichReading(ledger, name, options = []) {
const labels = options.map(o => o.label).filter(Boolean)
if (labels.length < 2) return
const list = labels.length === 2 ? `${labels[0]} or ${labels[1]}` : `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}`
ledger.underspecified = true
ledger.clarifyingQuestion = `"${name}" matches more than one VFB term: ${list}. Which did you mean?`
ledger.clarifyOptions = [...(ledger.clarifyOptions || []), ...options.map(o => ({ name, id: o.id, label: o.label }))]
}

export function pickBestTermId(search, queryName = '') {
const valid = validSearchDocs(search)
if (!valid.length) return null
Expand Down
Loading
Loading