From cf6e3a6cbb854b4878166faa8c5b693673c0f3ae Mon Sep 17 00:00:00 2001 From: Rob Court Date: Thu, 26 Mar 2026 14:13:35 +0000 Subject: [PATCH 01/19] feat: update search functionality and documentation for VFB Connect integration --- README.md | 25 +- app/api/chat/route.js | 605 ++++++++++++++++++++++++++++---- config/reviewed-docs-index.json | 7 + docker-compose.yml | 7 +- lib/runtimeConfig.js | 41 ++- 5 files changed, 589 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index f7aff6b..bbd031a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ VFB Chat is a Next.js chat interface for exploring Virtual Fly Brain (VFB) data ## What Changed - Native `web_search` has been removed from the model toolset. -- Search is limited to approved `virtualflybrain.org` and `neurofly.org` pages plus reviewed `flybase.org` pages through server-side, domain-restricted tools. +- Search is limited to approved `virtualflybrain.org`, `neurofly.org`, and `vfb-connect.readthedocs.io` pages plus reviewed `flybase.org` pages through server-side, domain-restricted tools. - Outbound links are sanitized server-side to approved domains only. - Raw IP-based security logs are retained for up to 30 days under `/logs/security`. - Aggregated analytics and structured feedback are retained under `/logs/analytics` and `/logs/feedback`. @@ -36,7 +36,7 @@ The app now uses a 3-layer logging model rooted at `LOG_ROOT_DIR`: The reviewed documentation search path uses two server-side sources: - a seed index from `config/reviewed-docs-index.json` -- a domain-restricted discovery path for approved `virtualflybrain.org` and `neurofly.org` pages using configured sitemap and robots sources +- a domain-restricted discovery path for approved `virtualflybrain.org`, `neurofly.org`, and `vfb-connect.readthedocs.io` pages using configured sitemap and robots sources This keeps search scoped to approved domains while avoiding a hand-maintained list of every VFB news or documentation page. @@ -52,13 +52,16 @@ Environment variable: Required for production: -- `OPENAI_API_KEY` -- `OPENAI_BASE_URL` or `APPROVED_ELM_BASE_URL` -- `OPENAI_MODEL` or `APPROVED_ELM_MODEL` +- `ELM_API_KEY` (or `OPENAI_API_KEY` as backward-compatible fallback) +- `ELM_BASE_URL` (or `OPENAI_BASE_URL`) or `APPROVED_ELM_BASE_URL` +- `ELM_MODEL` (or `OPENAI_MODEL`) or `APPROVED_ELM_MODEL` - `LOG_ROOT_DIR=/logs` Optional: +- `OPENAI_API_KEY` +- `OPENAI_BASE_URL` +- `OPENAI_MODEL` - `APPROVED_ELM_BASE_URL` - `APPROVED_ELM_MODEL` - `RATE_LIMIT_PER_IP` @@ -68,21 +71,21 @@ Optional: - `GA_MEASUREMENT_ID` - `GA_API_SECRET` -When `APPROVED_ELM_BASE_URL` and/or `APPROVED_ELM_MODEL` are provided, production enforces that they exactly match the active `OPENAI_*` values. If they are omitted, the app uses the active gateway/model as the approved baseline so existing single-config deployments continue to work. +When `APPROVED_ELM_BASE_URL` and/or `APPROVED_ELM_MODEL` are provided, production enforces that they exactly match the active configured gateway/model (resolved from `ELM_*` first, then `OPENAI_*`). If they are omitted, the app uses the active gateway/model as the approved baseline so existing single-config deployments continue to work. Default allow-lists: -- Search allow-list: `virtualflybrain.org`, `*.virtualflybrain.org`, `flybase.org`, `neurofly.org`, `*.neurofly.org` -- Outbound allow-list: `virtualflybrain.org`, `*.virtualflybrain.org`, `flybase.org`, `neurofly.org`, `*.neurofly.org`, `doi.org`, `pubmed.ncbi.nlm.nih.gov`, `biorxiv.org`, `medrxiv.org` +- Search allow-list: `virtualflybrain.org`, `*.virtualflybrain.org`, `flybase.org`, `neurofly.org`, `*.neurofly.org`, `vfb-connect.readthedocs.io` +- Outbound allow-list: `virtualflybrain.org`, `*.virtualflybrain.org`, `flybase.org`, `neurofly.org`, `*.neurofly.org`, `vfb-connect.readthedocs.io`, `doi.org`, `pubmed.ncbi.nlm.nih.gov`, `biorxiv.org`, `medrxiv.org` ## Local Development Create `.env.local` with explicit values: ```bash -OPENAI_API_KEY=your-key-here -OPENAI_BASE_URL=https://your-elm-gateway.example/v1 -OPENAI_MODEL=your-approved-model +ELM_API_KEY=elm-xxxxxxxx-xxxxxxxxxxxxxxxx +ELM_BASE_URL=https://elm.edina.ac.uk/api/v1 +ELM_MODEL=meta-llama/Llama-3.3-70B-Instruct LOG_ROOT_DIR=./logs ``` diff --git a/app/api/chat/route.js b/app/api/chat/route.js index 6ef0ccb..02e04ff 100644 --- a/app/api/chat/route.js +++ b/app/api/chat/route.js @@ -22,6 +22,7 @@ import { checkAndIncrement } from '../../../lib/rateLimit.js' import { getReviewedPage, searchReviewedDocs } from '../../../lib/reviewedDocsSearch.js' import { getConfiguredApiBaseUrl, + getConfiguredApiKey, getConfiguredModel, getOutboundAllowList, getSearchAllowList, @@ -146,6 +147,35 @@ function buildClarifyingQuestions(message = '') { return Array.from(new Set(questions)).slice(0, 4) } +function linkifyFollowUpQueryItems(text = '') { + if (!text) return text + + const lines = text.split('\n') + const linkedLines = lines.map((line) => { + const listMatch = line.match(/^(\s*(?:[-*]|\d+\.)\s+)(.+)$/) + if (!listMatch) return line + + const prefix = listMatch[1] + const rawItem = listMatch[2].trim() + + // Skip lines that are already markdown links or contain explicit URLs. + if (!rawItem || rawItem.includes('](') || /https?:\/\//i.test(rawItem)) { + return line + } + + const questionMatch = rawItem.match(/^(.+?\?)\s*$/) + if (!questionMatch) return line + + const question = questionMatch[1].trim() + if (question.length < 6 || question.length > 220) return line + + const queryUrl = `https://chat.virtualflybrain.org?query=${encodeURIComponent(question)}` + return `${prefix}[${question}](${queryUrl})` + }) + + return linkedLines.join('\n') +} + function extractImagesFromResponseText(responseText = '') { const thumbnailRegex = /https:\/\/www\.virtualflybrain\.org\/data\/VFB\/i\/([^/]+)\/([^/]+)\/thumbnail(?:T)?\.png/g const images = [] @@ -165,14 +195,15 @@ function extractImagesFromResponseText(responseText = '') { function buildSuccessfulTextResult({ responseText, responseId, toolUsage, toolRounds, outboundAllowList }) { const { sanitizedText, blockedDomains } = sanitizeAssistantOutput(responseText, outboundAllowList) - const images = extractImagesFromResponseText(sanitizedText) + const linkedResponseText = linkifyFollowUpQueryItems(sanitizedText) + const images = extractImagesFromResponseText(linkedResponseText) return { ok: true, responseId, toolUsage, toolRounds, - responseText: sanitizedText, + responseText: linkedResponseText, images, blockedResponseDomains: blockedDomains } @@ -466,7 +497,7 @@ function getToolConfig() { tools.push({ type: 'function', name: 'search_reviewed_docs', - description: 'Search approved Virtual Fly Brain, NeuroFly, and reviewed FlyBase pages using a server-side site index. Use this for documentation, news or blog posts, conference or event questions, and other approved website questions.', + description: 'Search approved Virtual Fly Brain, NeuroFly, VFB Connect documentation, and reviewed FlyBase pages using a server-side site index. Use this for documentation, news or blog posts, conference or event questions, and approved Python usage guidance pages.', parameters: { type: 'object', properties: { @@ -480,7 +511,7 @@ function getToolConfig() { tools.push({ type: 'function', name: 'get_reviewed_page', - description: 'Fetch and extract content from an approved Virtual Fly Brain, NeuroFly, or reviewed FlyBase page URL returned by search_reviewed_docs.', + description: 'Fetch and extract content from an approved Virtual Fly Brain, NeuroFly, VFB Connect documentation, or reviewed FlyBase page URL returned by search_reviewed_docs.', parameters: { type: 'object', properties: { @@ -835,6 +866,7 @@ APPROVED OUTPUT LINKS ONLY: You may only output links or images from these approved domains: - virtualflybrain.org and subdomains - neurofly.org and subdomains +- vfb-connect.readthedocs.io - flybase.org - doi.org - pubmed.ncbi.nlm.nih.gov @@ -851,7 +883,7 @@ TOOLS: - vfb_search_terms: search VFB terms with filters - vfb_get_term_info: fetch detailed VFB term information - vfb_run_query: run VFB analyses returned by vfb_get_term_info -- search_reviewed_docs: search approved VFB, NeuroFly, and reviewed FlyBase pages using a server-side site index +- search_reviewed_docs: search approved VFB, NeuroFly, VFB Connect docs, and reviewed FlyBase pages using a server-side site index - get_reviewed_page: fetch and extract content from an approved page returned by search_reviewed_docs - search_pubmed / get_pubmed_article: search and fetch peer-reviewed publications - biorxiv_search_preprints / biorxiv_get_preprint / biorxiv_search_published_preprints / biorxiv_get_categories: preprint discovery @@ -859,7 +891,9 @@ TOOLS: TOOL SELECTION: - Questions about VFB terms, anatomy, neurons, genes, or datasets: use VFB tools - Questions about published papers or recent literature: use PubMed first, optionally bioRxiv/medRxiv for preprints -- Questions about VFB, NeuroFly, or approved FlyBase documentation pages, news posts, workshops, conference pages, or event dates: use search_reviewed_docs, then use get_reviewed_page when you need page details +- Questions about VFB, NeuroFly, VFB Connect Python documentation, or approved FlyBase documentation pages, news posts, workshops, conference pages, or event dates: use search_reviewed_docs, then use get_reviewed_page when you need page details +- For questions about how to run VFB queries in Python or how to use vfb-connect, prioritize search_reviewed_docs/get_reviewed_page on vfb-connect.readthedocs.io alongside VFB tool outputs when useful. +- For connectivity, synaptic, or NBLAST questions, and especially when the user explicitly asks for vfb_run_query, do not use reviewed-doc search first; use VFB tools (vfb_search_terms/vfb_get_term_info/vfb_run_query). - Do not attempt general web search or browsing outside the approved reviewed-doc index TOOL ECONOMY: @@ -880,8 +914,13 @@ FORMATTING VFB REFERENCES: - When thumbnail URLs are present in tool output, include them using markdown image syntax - Only use thumbnail URLs that actually appear in tool results +TOOL RELAY: +- You can request server-side tool execution using the tool relay protocol. +- If tool results are available, use them directly and do not invent missing values. +- If a question needs data and no results are available yet, request tools first, then answer after results arrive. + FOLLOW-UP QUESTIONS: -When useful, suggest 2-3 short follow-up questions relevant to Drosophila neuroscience and actionable with the available tools.` +When useful, suggest 2-3 short follow-up questions relevant to Drosophila neuroscience and actionable in this chat.` function getStatusForTool(toolName) { if (toolName.startsWith('vfb_')) { @@ -907,7 +946,239 @@ function getStatusForTool(toolName) { return { message: 'Processing results', phase: 'llm' } } +const CHAT_COMPLETIONS_ENDPOINT = '/chat/completions' +const CHAT_COMPLETION_ALLOWED_ROLES = new Set(['system', 'user', 'assistant']) +const TOOL_DEFINITIONS = getToolConfig() +const TOOL_NAME_SET = new Set(TOOL_DEFINITIONS.map(tool => tool.name)) + +function normalizeChatRole(role) { + if (role === 'reasoning') return 'assistant' + if (typeof role !== 'string') return 'assistant' + return CHAT_COMPLETION_ALLOWED_ROLES.has(role) ? role : 'assistant' +} + +function normalizeChatMessage(message) { + if (!message || typeof message.content !== 'string') return null + return { + role: normalizeChatRole(message.role), + content: message.content + } +} + +function buildToolRelaySystemPrompt() { + const toolSchemas = TOOL_DEFINITIONS.map(tool => ({ + name: tool.name, + required: tool.parameters?.required || [], + parameters: Object.entries(tool.parameters?.properties || {}).reduce((acc, [key, value]) => { + acc[key] = { + type: value?.type || 'any', + enum: Array.isArray(value?.enum) ? value.enum : undefined + } + return acc + }, {}) + })) + + return `TOOL RELAY PROTOCOL: +- When you need tools, respond with JSON only, with no markdown and no extra text. +- Valid JSON format: +{"tool_calls":[{"name":"tool_name","arguments":{}}]} +- "name" must be one of the available tool names. +- "arguments" must be a JSON object matching that tool schema. +- You may request multiple tool calls in one response. +- After server tool execution, you will receive a user message starting with "TOOL_RESULTS_JSON:". +- If more data is needed, emit another JSON tool call payload. +- When you are ready to answer the user, return a normal assistant response (not JSON). + +AVAILABLE TOOL SCHEMAS (JSON): +${JSON.stringify(toolSchemas)}` +} + +const TOOL_RELAY_SYSTEM_PROMPT = buildToolRelaySystemPrompt() + +function extractJsonCandidates(text = '') { + const trimmed = text.trim() + if (!trimmed) return [] + + const candidates = [trimmed] + const fenceRegex = /```(?:json)?\s*([\s\S]*?)```/gi + let match + + while ((match = fenceRegex.exec(trimmed)) !== null) { + const candidate = match[1]?.trim() + if (candidate) candidates.push(candidate) + } + + const firstBrace = trimmed.indexOf('{') + const lastBrace = trimmed.lastIndexOf('}') + if (firstBrace >= 0 && lastBrace > firstBrace) { + candidates.push(trimmed.slice(firstBrace, lastBrace + 1).trim()) + } + + return Array.from(new Set(candidates)) +} + +function normalizeRelayedToolCall(toolCall) { + if (!toolCall || typeof toolCall !== 'object') return null + + const name = typeof toolCall.name === 'string' ? toolCall.name.trim() : '' + if (!name || !TOOL_NAME_SET.has(name)) return null + + let args = toolCall.arguments + if (args === undefined || args === null) args = {} + + if (typeof args === 'string') { + try { + args = JSON.parse(args) + } catch { + return { name, arguments: {} } + } + } + + if (!args || typeof args !== 'object' || Array.isArray(args)) { + args = {} + } + + return { name, arguments: args } +} + +function parseRelayedToolCalls(responseText = '') { + const candidates = extractJsonCandidates(responseText) + + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate) + const rawCalls = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.tool_calls) + ? parsed.tool_calls + : parsed?.tool_call + ? [parsed.tool_call] + : [] + + const normalizedCalls = rawCalls + .map(normalizeRelayedToolCall) + .filter(Boolean) + + if (normalizedCalls.length > 0) { + return normalizedCalls + } + } catch { + // Keep checking other JSON candidates. + } + } + + return [] +} + +function truncateToolOutput(output = '', maxChars = 12000) { + const text = typeof output === 'string' ? output : JSON.stringify(output) + if (text.length <= maxChars) return text + return `${text.slice(0, maxChars)}\n\n[truncated ${text.length - maxChars} chars]` +} + +function buildRelayedToolResultsMessage(toolOutputs = []) { + const payload = toolOutputs.map(item => ({ + name: item.name, + arguments: item.arguments, + output: truncateToolOutput(item.output) + })) + + return `TOOL_RESULTS_JSON: +${JSON.stringify(payload)} + +Use these results to continue. If more tools are needed, send another JSON tool call payload. Otherwise, provide the final answer to the user.` +} + +function hasExplicitVfbRunQueryRequest(message = '') { + return /\bvfb_run_query\b/i.test(message) +} + +function hasConnectivityIntent(message = '') { + return /\b(connectome|connectivity|connection|connections|synapse|synaptic|presynaptic|postsynaptic|input|inputs|output|outputs|nblast)\b/i.test(message) +} + +function isDocsOnlyToolCallSet(toolCalls = []) { + if (!Array.isArray(toolCalls) || toolCalls.length === 0) return false + return toolCalls.every(call => call.name === 'search_reviewed_docs' || call.name === 'get_reviewed_page') +} + +function buildToolPolicyCorrectionMessage({ + userMessage = '', + explicitRunQueryRequested = false, + connectivityIntent = false, + missingRunQueryExecution = false +}) { + const policyBullets = [ + '- For this request, prioritize VFB tools over reviewed-doc search.', + '- Use vfb_search_terms and/or vfb_get_term_info to identify the target entity and valid query types.', + '- Use vfb_run_query when a relevant query_type is available.' + ] + + if (explicitRunQueryRequested) { + policyBullets.push('- The user explicitly asked for vfb_run_query, so include a plan that leads to vfb_run_query.') + } + + if (connectivityIntent) { + policyBullets.push('- This is a connectivity-style request; do not default to search_reviewed_docs first.') + } + + if (missingRunQueryExecution) { + policyBullets.push('- You have not executed vfb_run_query yet in this turn; correct that now if feasible.') + } + + return `TOOL_POLICY_CORRECTION: +The original user request was: +"${userMessage}" + +${policyBullets.join('\n')} + +Return JSON only using the tool relay format: +{"tool_calls":[{"name":"tool_name","arguments":{}}} + +Do not provide a final prose answer until tool calls are executed.` +} + +function buildChatCompletionMessages(conversationInput = [], extraMessages = [], allowToolRelay = false) { + const normalizedConversation = conversationInput + .map(normalizeChatMessage) + .filter(Boolean) + + const normalizedExtras = extraMessages + .map(normalizeChatMessage) + .filter(Boolean) + + return [ + { role: 'system', content: systemPrompt }, + ...(allowToolRelay ? [{ role: 'system', content: TOOL_RELAY_SYSTEM_PROMPT }] : []), + ...normalizedConversation, + ...normalizedExtras + ] +} + +function createChatCompletionsRequestBody({ + apiModel, + conversationInput, + extraMessages = [], + allowToolRelay = false +}) { + return { + model: apiModel, + messages: buildChatCompletionMessages(conversationInput, extraMessages, allowToolRelay), + stream: true + } +} + async function readResponseStream(apiResponse, sendEvent) { + if (!apiResponse?.body) { + return { + textAccumulator: '', + functionCalls: [], + responseId: null, + failed: true, + errorMessage: 'The AI service returned an empty stream response.' + } + } + const reader = apiResponse.body.getReader() const decoder = new TextDecoder() const functionCalls = [] @@ -932,10 +1203,36 @@ async function readResponseStream(apiResponse, sendEvent) { if (!line.startsWith('data: ')) continue const dataStr = line.slice(6).trim() + if (!dataStr) continue if (dataStr === '[DONE]') continue try { const event = JSON.parse(dataStr) + + if (event?.error?.message) { + failed = true + errorMessage = event.error.message + return { textAccumulator, functionCalls, responseId, failed, errorMessage } + } + + // OpenAI-compatible /chat/completions streaming chunks: + // { id, choices: [{ delta: { content } }] } + if (Array.isArray(event?.choices)) { + responseId = event.id || responseId + + const firstChoice = event.choices[0] + const deltaContent = firstChoice?.delta?.content + const messageContent = firstChoice?.message?.content + + if (typeof deltaContent === 'string' && deltaContent.length > 0) { + textAccumulator += deltaContent + } else if (typeof messageContent === 'string' && messageContent.length > 0) { + textAccumulator += messageContent + } + + continue + } + const eventType = event.type switch (eventType) { @@ -1027,21 +1324,20 @@ async function requestNoToolFallbackResponse({ fallbackInput.push({ role: 'assistant', content: partialAssistantText.trim() }) } - fallbackInput.push({ role: 'user', content: instruction }) + const fallbackExtraMessages = [{ role: 'user', content: instruction }] - const fallbackResponse = await fetch(`${apiBaseUrl}/responses`, { + const fallbackResponse = await fetch(`${apiBaseUrl}${CHAT_COMPLETIONS_ENDPOINT}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}) }, - body: JSON.stringify({ - model: apiModel, - instructions: systemPrompt, - input: fallbackInput, - tools: [], - stream: true - }) + body: JSON.stringify(createChatCompletionsRequestBody({ + apiModel, + conversationInput: fallbackInput, + extraMessages: fallbackExtraMessages, + allowToolRelay: false + })) }) if (!fallbackResponse.ok) { @@ -1207,9 +1503,13 @@ async function processResponseStream({ const toolUsage = {} const accumulatedItems = [] const maxToolRounds = 10 + const maxToolPolicyCorrections = 3 + const explicitRunQueryRequested = hasExplicitVfbRunQueryRequest(userMessage) + const connectivityIntent = hasConnectivityIntent(userMessage) let currentResponse = apiResponse let latestResponseId = null let toolRounds = 0 + let toolPolicyCorrections = 0 for (let round = 0; round < maxToolRounds; round++) { const { textAccumulator, functionCalls, responseId, failed, errorMessage } = await readResponseStream(currentResponse, sendEvent) @@ -1245,74 +1545,145 @@ async function processResponseStream({ } } - if (functionCalls.length > 0) { + const relayedToolCalls = parseRelayedToolCalls(textAccumulator) + const legacyFunctionCalls = functionCalls + .map(functionCall => { + let args = {} + + if (typeof functionCall?.arguments === 'string') { + try { + args = JSON.parse(functionCall.arguments) + } catch { + args = {} + } + } else if (functionCall?.arguments && typeof functionCall.arguments === 'object' && !Array.isArray(functionCall.arguments)) { + args = functionCall.arguments + } + + return normalizeRelayedToolCall({ + name: functionCall?.name, + arguments: args + }) + }) + .filter(Boolean) + + const requestedToolCalls = relayedToolCalls.length > 0 + ? relayedToolCalls + : legacyFunctionCalls + + if (requestedToolCalls.length > 0) { + const hasVfbToolCall = requestedToolCalls.some(toolCall => toolCall.name.startsWith('vfb_')) + const docsOnlyToolCalls = isDocsOnlyToolCallSet(requestedToolCalls) + const shouldCorrectToolChoice = toolPolicyCorrections < maxToolPolicyCorrections && ( + (explicitRunQueryRequested && !hasVfbToolCall) || + (connectivityIntent && docsOnlyToolCalls) + ) + + if (shouldCorrectToolChoice) { + sendEvent('status', { message: 'Refining tool choice for VFB query', phase: 'llm' }) + + if (textAccumulator.trim()) { + accumulatedItems.push({ role: 'assistant', content: textAccumulator.trim() }) + } + + accumulatedItems.push({ + role: 'user', + content: buildToolPolicyCorrectionMessage({ + userMessage, + explicitRunQueryRequested, + connectivityIntent + }) + }) + + const correctionResponse = await fetch(`${apiBaseUrl}${CHAT_COMPLETIONS_ENDPOINT}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}) + }, + body: JSON.stringify(createChatCompletionsRequestBody({ + apiModel, + conversationInput: [...conversationInput, ...accumulatedItems], + allowToolRelay: true + })) + }) + + if (!correctionResponse.ok) { + const correctionErrorText = await correctionResponse.text() + return { + ok: false, + responseId: latestResponseId, + toolUsage, + toolRounds, + errorMessage: `Failed to apply tool policy correction. ${sanitizeApiError(correctionResponse.status, correctionErrorText)}`, + errorCategory: 'tool_policy_correction_failed', + errorStatus: correctionResponse.status + } + } + + toolPolicyCorrections += 1 + currentResponse = correctionResponse + continue + } + toolRounds += 1 - const toolOutputs = await Promise.all(functionCalls.map(async (functionCall) => { - try { - const args = typeof functionCall.arguments === 'string' - ? JSON.parse(functionCall.arguments) - : functionCall.arguments + const announcedStatuses = new Set() + for (const toolCall of requestedToolCalls) { + if (!announcedStatuses.has(toolCall.name)) { + sendEvent('status', getStatusForTool(toolCall.name)) + announcedStatuses.add(toolCall.name) + } + } - toolUsage[functionCall.name] = (toolUsage[functionCall.name] || 0) + 1 + const toolOutputs = await Promise.all(requestedToolCalls.map(async (toolCall) => { + toolUsage[toolCall.name] = (toolUsage[toolCall.name] || 0) + 1 + try { return { - call_id: functionCall.call_id, - name: functionCall.name, - arguments: functionCall.arguments, - output: await executeFunctionTool(functionCall.name, args) + name: toolCall.name, + arguments: toolCall.arguments, + output: await executeFunctionTool(toolCall.name, toolCall.arguments) } } catch (error) { - toolUsage[functionCall.name] = (toolUsage[functionCall.name] || 0) + 1 - return { - call_id: functionCall.call_id, - name: functionCall.name, - arguments: functionCall.arguments, + name: toolCall.name, + arguments: toolCall.arguments, output: JSON.stringify({ error: error.message }) } } })) - for (const toolOutput of toolOutputs) { - accumulatedItems.push({ - type: 'function_call', - call_id: toolOutput.call_id, - name: toolOutput.name, - arguments: typeof toolOutput.arguments === 'string' - ? toolOutput.arguments - : JSON.stringify(toolOutput.arguments) - }) - accumulatedItems.push({ - type: 'function_call_output', - call_id: toolOutput.call_id, - output: toolOutput.output - }) + if (textAccumulator.trim()) { + accumulatedItems.push({ role: 'assistant', content: textAccumulator.trim() }) } - const submitResponse = await fetch(`${apiBaseUrl}/responses`, { + accumulatedItems.push({ + role: 'user', + content: buildRelayedToolResultsMessage(toolOutputs) + }) + + const submitResponse = await fetch(`${apiBaseUrl}${CHAT_COMPLETIONS_ENDPOINT}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}) }, - body: JSON.stringify({ - model: apiModel, - instructions: systemPrompt, - input: [...conversationInput, ...accumulatedItems], - tools: getToolConfig(), - stream: true - }) + body: JSON.stringify(createChatCompletionsRequestBody({ + apiModel, + conversationInput: [...conversationInput, ...accumulatedItems], + allowToolRelay: true + })) }) if (!submitResponse.ok) { - const errorText = await submitResponse.text() + const submitErrorText = await submitResponse.text() return { ok: false, responseId: latestResponseId, toolUsage, toolRounds, - errorMessage: `Failed to process tool results. ${sanitizeApiError(submitResponse.status, errorText)}`, + errorMessage: `Failed to process tool results. ${sanitizeApiError(submitResponse.status, submitErrorText)}`, errorCategory: 'tool_submission_failed', errorStatus: submitResponse.status } @@ -1322,7 +1693,55 @@ async function processResponseStream({ continue } - if (!textAccumulator) { + if (explicitRunQueryRequested && (toolUsage.vfb_run_query || 0) === 0 && toolPolicyCorrections < maxToolPolicyCorrections) { + sendEvent('status', { message: 'Honoring requested vfb_run_query workflow', phase: 'llm' }) + + if (textAccumulator.trim()) { + accumulatedItems.push({ role: 'assistant', content: textAccumulator.trim() }) + } + + accumulatedItems.push({ + role: 'user', + content: buildToolPolicyCorrectionMessage({ + userMessage, + explicitRunQueryRequested, + connectivityIntent, + missingRunQueryExecution: true + }) + }) + + const correctionResponse = await fetch(`${apiBaseUrl}${CHAT_COMPLETIONS_ENDPOINT}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}) + }, + body: JSON.stringify(createChatCompletionsRequestBody({ + apiModel, + conversationInput: [...conversationInput, ...accumulatedItems], + allowToolRelay: true + })) + }) + + if (!correctionResponse.ok) { + const correctionErrorText = await correctionResponse.text() + return { + ok: false, + responseId: latestResponseId, + toolUsage, + toolRounds, + errorMessage: `Failed to honor requested vfb_run_query flow. ${sanitizeApiError(correctionResponse.status, correctionErrorText)}`, + errorCategory: 'vfb_run_query_enforcement_failed', + errorStatus: correctionResponse.status + } + } + + toolPolicyCorrections += 1 + currentResponse = correctionResponse + continue + } + + if (!textAccumulator.trim()) { const clarification = await requestClarifyingFollowUp({ sendEvent, conversationInput, @@ -1352,6 +1771,39 @@ async function processResponseStream({ } } + const trimmedResponseText = textAccumulator.trim() + const looksLikeToolPayload = trimmedResponseText.startsWith('{') || trimmedResponseText.startsWith('```') + + if (looksLikeToolPayload && /"tool_calls"\s*:/.test(trimmedResponseText) && relayedToolCalls.length === 0) { + const clarification = await requestClarifyingFollowUp({ + sendEvent, + conversationInput, + accumulatedItems, + partialAssistantText: textAccumulator, + apiBaseUrl, + apiKey, + apiModel, + outboundAllowList, + toolUsage, + toolRounds, + userMessage, + reason: 'invalid_tool_call_payload' + }) + + if (clarification) { + return clarification + } + + return { + ok: false, + responseId: latestResponseId, + toolUsage, + toolRounds, + errorMessage: 'The AI returned an invalid tool-call payload. Please try again.', + errorCategory: 'invalid_tool_call_payload' + } + } + return buildSuccessfulTextResult({ responseText: textAccumulator, responseId: latestResponseId, @@ -1505,7 +1957,7 @@ export async function POST(request) { }) const responseId = `local-${requestId}` - const refusalMessage = `I can only search reviewed Virtual Fly Brain and FlyBase pages. The requested domain${blockedRequestedDomains.length === 1 ? '' : 's'} ${blockedRequestedDomains.join(', ')} ${blockedRequestedDomains.length === 1 ? 'is' : 'are'} not approved for search in this service.` + const refusalMessage = `I can only search reviewed Virtual Fly Brain, NeuroFly, VFB Connect docs, and FlyBase pages. The requested domain${blockedRequestedDomains.length === 1 ? '' : 's'} ${blockedRequestedDomains.join(', ')} ${blockedRequestedDomains.length === 1 ? 'is' : 'are'} not approved for search in this service.` await finalizeGovernanceEvent({ requestId, @@ -1527,13 +1979,18 @@ export async function POST(request) { return buildSseResponse(async (sendEvent) => { const resolvedUserMessage = replaceTermsWithLinks(message) + const priorMessages = messages + .slice(0, -1) + .map(normalizeChatMessage) + .filter(Boolean) + const conversationInput = [ - ...messages.slice(0, -1).map(item => ({ role: item.role, content: item.content })), + ...priorMessages, { role: 'user', content: resolvedUserMessage } ] const apiBaseUrl = getConfiguredApiBaseUrl() - const apiKey = process.env.OPENAI_API_KEY?.trim() || '' + const apiKey = getConfiguredApiKey() const apiModel = getConfiguredModel() sendEvent('status', { message: 'Thinking...', phase: 'llm' }) @@ -1544,19 +2001,17 @@ export async function POST(request) { const abortController = new AbortController() const timeoutId = setTimeout(() => abortController.abort(), timeoutMs) - apiResponse = await fetch(`${apiBaseUrl}/responses`, { + apiResponse = await fetch(`${apiBaseUrl}${CHAT_COMPLETIONS_ENDPOINT}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}) }, - body: JSON.stringify({ - model: apiModel, - instructions: systemPrompt, - input: conversationInput, - tools: getToolConfig(), - stream: true - }), + body: JSON.stringify(createChatCompletionsRequestBody({ + apiModel, + conversationInput, + allowToolRelay: true + })), signal: abortController.signal }) @@ -1576,19 +2031,17 @@ export async function POST(request) { const retryTimeoutId = setTimeout(() => retryAbort.abort(), timeoutMs) try { - const retryResponse = await fetch(`${apiBaseUrl}/responses`, { + const retryResponse = await fetch(`${apiBaseUrl}${CHAT_COMPLETIONS_ENDPOINT}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}) }, - body: JSON.stringify({ - model: apiModel, - instructions: systemPrompt, - input: conversationInput, - tools: getToolConfig(), - stream: true - }), + body: JSON.stringify(createChatCompletionsRequestBody({ + apiModel, + conversationInput, + allowToolRelay: true + })), signal: retryAbort.signal }) diff --git a/config/reviewed-docs-index.json b/config/reviewed-docs-index.json index 00a063a..2aab480 100644 --- a/config/reviewed-docs-index.json +++ b/config/reviewed-docs-index.json @@ -61,5 +61,12 @@ "url": "https://flybase.org/", "summary": "FlyBase portal for Drosophila genes, alleles, stocks, and literature references.", "keywords": ["flybase", "genes", "literature", "references", "drosophila"] + }, + { + "id": "vfb-connect-docs", + "title": "VFB Connect Python docs", + "url": "https://vfb-connect.readthedocs.io/en/stable/", + "summary": "Official VFB Connect Python documentation for querying Virtual Fly Brain data programmatically.", + "keywords": ["vfb-connect", "python", "api", "query", "tutorial", "programmatic access"] } ] diff --git a/docker-compose.yml b/docker-compose.yml index f1e4493..2d43b90 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,14 +8,17 @@ services: environment: - NODE_ENV=production - LOG_ROOT_DIR=/logs + - ELM_API_KEY=${ELM_API_KEY} + - ELM_BASE_URL=${ELM_BASE_URL} + - ELM_MODEL=${ELM_MODEL} - OPENAI_API_KEY=${OPENAI_API_KEY} - OPENAI_BASE_URL=${OPENAI_BASE_URL} - OPENAI_MODEL=${OPENAI_MODEL} - APPROVED_ELM_BASE_URL=${APPROVED_ELM_BASE_URL} - APPROVED_ELM_MODEL=${APPROVED_ELM_MODEL} - RATE_LIMIT_PER_IP=${RATE_LIMIT_PER_IP:-50} - - SEARCH_ALLOWLIST=${SEARCH_ALLOWLIST:-virtualflybrain.org,*.virtualflybrain.org,flybase.org,neurofly.org,*.neurofly.org} - - OUTBOUND_ALLOWLIST=${OUTBOUND_ALLOWLIST:-virtualflybrain.org,*.virtualflybrain.org,flybase.org,neurofly.org,*.neurofly.org,doi.org,pubmed.ncbi.nlm.nih.gov,biorxiv.org,medrxiv.org} + - SEARCH_ALLOWLIST=${SEARCH_ALLOWLIST:-virtualflybrain.org,*.virtualflybrain.org,flybase.org,neurofly.org,*.neurofly.org,vfb-connect.readthedocs.io} + - OUTBOUND_ALLOWLIST=${OUTBOUND_ALLOWLIST:-virtualflybrain.org,*.virtualflybrain.org,flybase.org,neurofly.org,*.neurofly.org,vfb-connect.readthedocs.io,doi.org,pubmed.ncbi.nlm.nih.gov,biorxiv.org,medrxiv.org} - REVIEWED_DOCS_INDEX_FILE=${REVIEWED_DOCS_INDEX_FILE:-/app/config/reviewed-docs-index.json} - GA_MEASUREMENT_ID=${GA_MEASUREMENT_ID} - GA_API_SECRET=${GA_API_SECRET} diff --git a/lib/runtimeConfig.js b/lib/runtimeConfig.js index 6dfc2bf..02036d0 100644 --- a/lib/runtimeConfig.js +++ b/lib/runtimeConfig.js @@ -5,7 +5,8 @@ const DEFAULT_SEARCH_ALLOWLIST = [ '*.virtualflybrain.org', 'flybase.org', 'neurofly.org', - '*.neurofly.org' + '*.neurofly.org', + 'vfb-connect.readthedocs.io' ] const DEFAULT_OUTBOUND_ALLOWLIST = [ @@ -14,6 +15,7 @@ const DEFAULT_OUTBOUND_ALLOWLIST = [ 'flybase.org', 'neurofly.org', '*.neurofly.org', + 'vfb-connect.readthedocs.io', 'doi.org', 'pubmed.ncbi.nlm.nih.gov', 'biorxiv.org', @@ -32,7 +34,9 @@ const DEFAULT_REVIEWED_DOCS_DISCOVERY_URLS = [ 'https://www.neurofly.org/sitemap_index.xml', 'https://neurofly.org/robots.txt', 'https://neurofly.org/sitemap.xml', - 'https://neurofly.org/sitemap_index.xml' + 'https://neurofly.org/sitemap_index.xml', + 'https://vfb-connect.readthedocs.io/robots.txt', + 'https://vfb-connect.readthedocs.io/sitemap.xml' ] function trimEnv(name) { @@ -112,13 +116,16 @@ export function getReviewedDocsFetchTimeoutMs() { } export function getConfiguredApiBaseUrl() { + const explicitElm = trimEnv('ELM_BASE_URL') + if (explicitElm) return normalizeBaseUrl(explicitElm) + const explicit = trimEnv('OPENAI_BASE_URL') if (explicit) return normalizeBaseUrl(explicit) const approved = trimEnv('APPROVED_ELM_BASE_URL') if (approved) return normalizeBaseUrl(approved) - throw new Error('OPENAI_BASE_URL or APPROVED_ELM_BASE_URL must be configured.') + throw new Error('ELM_BASE_URL, OPENAI_BASE_URL, or APPROVED_ELM_BASE_URL must be configured.') } function getApprovedApiBaseUrl() { @@ -129,6 +136,9 @@ function getApprovedApiBaseUrl() { } export function getConfiguredModel() { + const explicitElm = trimEnv('ELM_MODEL') + if (explicitElm) return explicitElm + const explicit = trimEnv('OPENAI_MODEL') if (explicit) return explicit @@ -136,10 +146,24 @@ export function getConfiguredModel() { if (approved) return approved if (isProduction()) { - throw new Error('OPENAI_MODEL or APPROVED_ELM_MODEL must be configured in production.') + throw new Error('ELM_MODEL, OPENAI_MODEL, or APPROVED_ELM_MODEL must be configured in production.') } - return 'gpt-4o-mini' + return 'meta-llama/Llama-3.3-70B-Instruct' +} + +export function getConfiguredApiKey() { + const elmApiKey = trimEnv('ELM_API_KEY') + if (elmApiKey) return elmApiKey + + const openAiApiKey = trimEnv('OPENAI_API_KEY') + if (openAiApiKey) return openAiApiKey + + if (isProduction()) { + throw new Error('ELM_API_KEY or OPENAI_API_KEY must be configured in production.') + } + + return '' } function getApprovedModel() { @@ -152,18 +176,21 @@ function getApprovedModel() { export function validateProductionCompliance() { if (!isProduction()) return + // Ensure an API key is configured in production. + getConfiguredApiKey() + const configuredBaseUrl = getConfiguredApiBaseUrl() const approvedBaseUrl = getApprovedApiBaseUrl() if (configuredBaseUrl !== approvedBaseUrl) { - throw new Error('OPENAI_BASE_URL must match the approved ELM gateway in production.') + throw new Error('Configured base URL must match the approved ELM gateway in production.') } const configuredModel = getConfiguredModel() const approvedModel = getApprovedModel() if (configuredModel !== approvedModel) { - throw new Error('OPENAI_MODEL must match the approved ELM model in production.') + throw new Error('Configured model must match the approved ELM model in production.') } } From 6e13d7a3ea6f1485ab00bbf7eb987c29ab4146f1 Mon Sep 17 00:00:00 2001 From: Rob Court Date: Thu, 26 Mar 2026 14:37:41 +0000 Subject: [PATCH 02/19] feat: add VFB query link skill and short names for enhanced query functionality --- app/api/chat/route.js | 67 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/app/api/chat/route.js b/app/api/chat/route.js index 02e04ff..83f3839 100644 --- a/app/api/chat/route.js +++ b/app/api/chat/route.js @@ -851,6 +851,71 @@ function replaceTermsWithLinks(text) { return result } +const VFB_QUERY_LINK_BASE = 'https://v2.virtualflybrain.org/org.geppetto.frontend/geppetto?q=' + +const VFB_QUERY_SHORT_NAMES = [ + { name: 'ListAllAvailableImages', description: 'List all available images of $NAME' }, + { name: 'TransgeneExpressionHere', description: 'Reports of transgene expression in $NAME' }, + { name: 'ExpressionOverlapsHere', description: 'Anatomy $NAME is expressed in' }, + { name: 'NeuronClassesFasciculatingHere', description: 'Neurons fasciculating in $NAME' }, + { name: 'ImagesNeurons', description: 'Images of neurons with some part in $NAME' }, + { name: 'NeuronsPartHere', description: 'Neurons with some part in $NAME' }, + { name: 'epFrag', description: 'Images of fragments of $NAME' }, + { name: 'NeuronsSynaptic', description: 'Neurons with synaptic terminals in $NAME' }, + { name: 'NeuronsPresynapticHere', description: 'Neurons with presynaptic terminals in $NAME' }, + { name: 'NeuronsPostsynapticHere', description: 'Neurons with postsynaptic terminals in $NAME' }, + { name: 'PaintedDomains', description: 'List all painted anatomy available for $NAME' }, + { name: 'DatasetImages', description: 'List all images included in $NAME' }, + { name: 'TractsNervesInnervatingHere', description: 'Tracts/nerves innervating $NAME' }, + { name: 'ComponentsOf', description: 'Components of $NAME' }, + { name: 'LineageClonesIn', description: 'Lineage clones found in $NAME' }, + { name: 'AllAlignedImages', description: 'List all images aligned to $NAME' }, + { name: 'PartsOf', description: 'Parts of $NAME' }, + { name: 'SubclassesOf', description: 'Subclasses of $NAME' }, + { name: 'AlignedDatasets', description: 'List all datasets aligned to $NAME' }, + { name: 'AllDatasets', description: 'List all datasets' }, + { name: 'ref_neuron_region_connectivity_query', description: 'Show connectivity per region for $NAME' }, + { name: 'ref_neuron_neuron_connectivity_query', description: 'Show neurons connected to $NAME' }, + { name: 'ref_downstream_class_connectivity_query', description: 'Show downstream connectivity by class for $NAME' }, + { name: 'ref_upstream_class_connectivity_query', description: 'Show upstream connectivity by class for $NAME' }, + { name: 'SimilarMorphologyTo', description: 'Neurons with similar morphology to $NAME [NBLAST mean score]' }, + { name: 'SimilarMorphologyToPartOf', description: 'Expression patterns with some similar morphology to $NAME [NBLAST mean score]' }, + { name: 'TermsForPub', description: 'List all terms that reference $NAME' }, + { name: 'SimilarMorphologyToPartOfexp', description: 'Neurons with similar morphology to part of $NAME [NBLAST mean score]' }, + { name: 'SimilarMorphologyToNB', description: 'Neurons that overlap with $NAME [NeuronBridge]' }, + { name: 'SimilarMorphologyToNBexp', description: 'Expression patterns that overlap with $NAME [NeuronBridge]' }, + { name: 'anatScRNAseqQuery', description: 'Single cell transcriptomics data for $NAME' }, + { name: 'clusterExpression', description: 'Genes expressed in $NAME' }, + { name: 'scRNAdatasetData', description: 'List all Clusters for $NAME' }, + { name: 'expressionCluster', description: 'scRNAseq clusters expressing $NAME' }, + { name: 'SimilarMorphologyToUserData', description: 'Neurons with similar morphology to your upload $NAME [NBLAST mean score]' }, + { name: 'ImagesThatDevelopFrom', description: 'List images of neurons that develop from $NAME' } +] + +function buildVfbQueryLinkSkill() { + const queryLines = VFB_QUERY_SHORT_NAMES + .map(({ name, description }) => `- ${name}: ${description}`) + .join('\n') + + return `VFB QUERY LINK SKILL: +- Build direct VFB query-result links so users can open the full results list. +- Link format: ${VFB_QUERY_LINK_BASE}, +- Construct links from the exact pair: term_id + query_name. +- URL-encode TERM_ID and QUERY_SHORT_NAME independently before concatenating. +- Only use query names returned by vfb_get_term_info for that specific term. +- In term-info JSON, read short names from Queries[].query and user-facing descriptions from Queries[].label. +- Treat Queries[] from vfb_get_term_info as authoritative for the current term; use the static list below as a fallback reference. +- When you answer with query findings, include matching query-result links when useful. +- Examples: + - ${VFB_QUERY_LINK_BASE}FBbt_00100482,ListAllAvailableImages + - ${VFB_QUERY_LINK_BASE}FBbt_00100482,SubclassesOf + - ${VFB_QUERY_LINK_BASE}FBbt_00100482,ref_upstream_class_connectivity_query +- Query short names and descriptions (from geppetto-vfb/model): +${queryLines}` +} + +const VFB_QUERY_LINK_SKILL = buildVfbQueryLinkSkill() + const systemPrompt = `You are a Virtual Fly Brain (VFB) assistant specialising in Drosophila melanogaster neuroanatomy, neuroscience, and related research. SCOPE: @@ -888,6 +953,8 @@ TOOLS: - search_pubmed / get_pubmed_article: search and fetch peer-reviewed publications - biorxiv_search_preprints / biorxiv_get_preprint / biorxiv_search_published_preprints / biorxiv_get_categories: preprint discovery +${VFB_QUERY_LINK_SKILL} + TOOL SELECTION: - Questions about VFB terms, anatomy, neurons, genes, or datasets: use VFB tools - Questions about published papers or recent literature: use PubMed first, optionally bioRxiv/medRxiv for preprints From 046bf35fd2771ccdddc79c0fee70534e8283ad52 Mon Sep 17 00:00:00 2001 From: Rob Court Date: Thu, 26 Mar 2026 14:48:15 +0000 Subject: [PATCH 03/19] feat: implement caching for VFB term info and run query with retry logic for transient errors --- app/api/chat/route.js | 146 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 138 insertions(+), 8 deletions(-) diff --git a/app/api/chat/route.js b/app/api/chat/route.js index 83f3839..620d375 100644 --- a/app/api/chat/route.js +++ b/app/api/chat/route.js @@ -661,6 +661,96 @@ const MCP_TOOL_ROUTING = { biorxiv_get_categories: { server: 'biorxiv', mcpName: 'get_categories' } } +const VFB_CACHED_TERM_INFO_URL = 'https://v3-cached.virtualflybrain.org/get_term_info' +const VFB_CACHED_RUN_QUERY_URL = 'https://v3-cached.virtualflybrain.org/run_query' +const VFB_CACHED_TERM_INFO_TIMEOUT_MS = 12000 + +function isRetryableMcpError(error) { + const message = `${error?.name || ''} ${error?.message || ''}`.toLowerCase() + return ( + message.includes('timeout') || + message.includes('timed out') || + message.includes('abort') || + message.includes('network') || + message.includes('fetch failed') || + message.includes('econnreset') || + message.includes('econnrefused') || + message.includes('enotfound') || + message.includes('etimedout') || + message.includes('eai_again') || + message.includes('connectivity') + ) +} + +async function fetchCachedVfbTermInfo(id) { + const safeId = String(id || '').trim() + if (!safeId) throw new Error('Missing id for cached VFB get_term_info fallback.') + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), VFB_CACHED_TERM_INFO_TIMEOUT_MS) + + try { + const cacheUrl = `${VFB_CACHED_TERM_INFO_URL}?id=${encodeURIComponent(safeId)}` + const response = await fetch(cacheUrl, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: controller.signal + }) + + if (!response.ok) { + const responseText = await response.text() + throw new Error(`Cached VFB get_term_info failed: HTTP ${response.status} ${responseText.slice(0, 200)}`.trim()) + } + + const responseText = await response.text() + try { + JSON.parse(responseText) + } catch { + throw new Error('Cached VFB get_term_info returned non-JSON payload.') + } + + return responseText + } finally { + clearTimeout(timeoutId) + } +} + +async function fetchCachedVfbRunQuery(id, queryType) { + const safeId = String(id || '').trim() + const safeQueryType = String(queryType || '').trim() + if (!safeId || !safeQueryType) { + throw new Error('Missing id or query_type for cached VFB run_query fallback.') + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), VFB_CACHED_TERM_INFO_TIMEOUT_MS) + + try { + const cacheUrl = `${VFB_CACHED_RUN_QUERY_URL}?id=${encodeURIComponent(safeId)}&query_type=${encodeURIComponent(safeQueryType)}` + const response = await fetch(cacheUrl, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: controller.signal + }) + + if (!response.ok) { + const responseText = await response.text() + throw new Error(`Cached VFB run_query failed: HTTP ${response.status} ${responseText.slice(0, 200)}`.trim()) + } + + const responseText = await response.text() + try { + JSON.parse(responseText) + } catch { + throw new Error('Cached VFB run_query returned non-JSON payload.') + } + + return responseText + } finally { + clearTimeout(timeoutId) + } +} + async function executeFunctionTool(name, args) { if (name === 'search_pubmed') { return searchPubmed(args.query, args.max_results, args.sort) @@ -689,15 +779,55 @@ async function executeFunctionTool(name, args) { if (value !== undefined && value !== null) cleanArgs[key] = value } - const result = await client.callTool({ name: routing.mcpName, arguments: cleanArgs }) - if (result?.content) { - const texts = result.content - .filter(item => item.type === 'text') - .map(item => item.text) - return texts.join('\n') || JSON.stringify(result.content) - } + try { + const result = await client.callTool({ name: routing.mcpName, arguments: cleanArgs }) + if (result?.content) { + const texts = result.content + .filter(item => item.type === 'text') + .map(item => item.text) + return texts.join('\n') || JSON.stringify(result.content) + } + + return JSON.stringify(result) + } catch (error) { + const shouldUseCachedTermInfoFallback = + name === 'vfb_get_term_info' && + routing.server === 'vfb' && + typeof cleanArgs.id === 'string' && + cleanArgs.id.trim().length > 0 && + isRetryableMcpError(error) + + if (shouldUseCachedTermInfoFallback) { + try { + return await fetchCachedVfbTermInfo(cleanArgs.id) + } catch (fallbackError) { + throw new Error( + `VFB MCP get_term_info failed (${error?.message || 'unknown error'}); cached fallback failed (${fallbackError?.message || 'unknown error'}).` + ) + } + } - return JSON.stringify(result) + const shouldUseCachedRunQueryFallback = + name === 'vfb_run_query' && + routing.server === 'vfb' && + typeof cleanArgs.id === 'string' && + cleanArgs.id.trim().length > 0 && + typeof cleanArgs.query_type === 'string' && + cleanArgs.query_type.trim().length > 0 && + isRetryableMcpError(error) + + if (shouldUseCachedRunQueryFallback) { + try { + return await fetchCachedVfbRunQuery(cleanArgs.id, cleanArgs.query_type) + } catch (fallbackError) { + throw new Error( + `VFB MCP run_query failed (${error?.message || 'unknown error'}); cached fallback failed (${fallbackError?.message || 'unknown error'}).` + ) + } + } + + throw error + } } throw new Error(`Unknown function tool: ${name}`) From 57908de80d921a7f8598c7cd454c39f791e2d6ac Mon Sep 17 00:00:00 2001 From: Rob Court Date: Thu, 26 Mar 2026 15:57:48 +0000 Subject: [PATCH 04/19] feat: add bioRxiv API fallback functionality for enhanced data retrieval --- app/api/chat/route.js | 394 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) diff --git a/app/api/chat/route.js b/app/api/chat/route.js index 620d375..7647349 100644 --- a/app/api/chat/route.js +++ b/app/api/chat/route.js @@ -649,6 +649,389 @@ async function getPubmedArticle(pmid) { }) } +// --- bioRxiv direct API fallback (used when bioRxiv MCP is unavailable) --- + +const BIORXIV_API_BASE_URL = 'https://api.biorxiv.org' +const BIORXIV_API_TIMEOUT_MS = 15000 +const BIORXIV_MAX_RECENT_DAYS = 3650 +const BIORXIV_TOOL_NAME_SET = new Set([ + 'biorxiv_search_preprints', + 'biorxiv_get_preprint', + 'biorxiv_search_published_preprints', + 'biorxiv_get_categories' +]) + +function normalizeBiorxivServer(server = 'biorxiv') { + const normalized = String(server || 'biorxiv').trim().toLowerCase() + if (normalized === 'biorxiv' || normalized === 'medrxiv') return normalized + throw new Error(`Invalid server "${server}". Expected "biorxiv" or "medrxiv".`) +} + +function normalizeInteger(value, defaultValue, min, max) { + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed)) return defaultValue + return Math.min(Math.max(parsed, min), max) +} + +function formatIsoDateUtc(date) { + return date.toISOString().slice(0, 10) +} + +function isIsoDateString(value) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false + const parsed = new Date(`${value}T00:00:00Z`) + return Number.isFinite(parsed.getTime()) && formatIsoDateUtc(parsed) === value +} + +function resolveBiorxivDateRange(args = {}, defaultRecentDays = 30) { + const rawFrom = typeof args.date_from === 'string' ? args.date_from.trim() : '' + const rawTo = typeof args.date_to === 'string' ? args.date_to.trim() : '' + const hasRecentDays = args.recent_days !== undefined && args.recent_days !== null && String(args.recent_days).trim() !== '' + + if (hasRecentDays || (!rawFrom && !rawTo)) { + const recentDays = normalizeInteger( + hasRecentDays ? args.recent_days : defaultRecentDays, + defaultRecentDays, + 1, + BIORXIV_MAX_RECENT_DAYS + ) + const endDate = new Date() + const startDate = new Date(endDate) + startDate.setUTCDate(startDate.getUTCDate() - (recentDays - 1)) + return { + dateFrom: formatIsoDateUtc(startDate), + dateTo: formatIsoDateUtc(endDate), + recentDays + } + } + + if (!rawFrom || !rawTo) { + throw new Error('Both date_from and date_to are required when recent_days is not provided.') + } + + if (!isIsoDateString(rawFrom) || !isIsoDateString(rawTo)) { + throw new Error('date_from and date_to must be valid YYYY-MM-DD values.') + } + + if (rawFrom > rawTo) { + throw new Error('date_from must be earlier than or equal to date_to.') + } + + return { + dateFrom: rawFrom, + dateTo: rawTo, + recentDays: null + } +} + +function normalizeCategoryLabel(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[_\s]+/g, ' ') +} + +function toCategoryQueryValue(value) { + return String(value || '') + .trim() + .replace(/\s+/g, '_') +} + +function normalizeDoiForPath(value) { + const normalized = String(value || '') + .trim() + .replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, '') + + if (!normalized) { + throw new Error('A DOI is required for biorxiv_get_preprint.') + } + + // Keep slash separators because the endpoint expects DOI path segments. + return normalized + .split('/') + .map(segment => encodeURIComponent(segment)) + .join('/') +} + +function normalizePublisherPrefix(value) { + return String(value || '').trim().toLowerCase().replace(/\/+$/, '') +} + +function decodeHtmlEntity(value) { + return value + .replace(/&/gi, '&') + .replace(/'/g, '\'') + .replace(/"/gi, '"') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .trim() +} + +function parseCategorySummaryHtml(html = '') { + const categorySet = new Set() + const categories = [] + const regex = /([^<]+)<\/td>\s*\d+<\/td>\s*[\d.]+<\/td>/gi + let match + + while ((match = regex.exec(html)) !== null) { + const label = decodeHtmlEntity(match[1]) + if (!label) continue + const dedupeKey = label.toLowerCase() + if (!categorySet.has(dedupeKey)) { + categorySet.add(dedupeKey) + categories.push(label) + } + } + + return categories.sort((a, b) => a.localeCompare(b)) +} + +async function fetchBioRxivApiJson(pathname, searchParams = {}) { + const url = new URL(pathname, BIORXIV_API_BASE_URL) + for (const [key, value] of Object.entries(searchParams || {})) { + if (value === undefined || value === null || String(value).trim() === '') continue + url.searchParams.set(key, String(value)) + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), BIORXIV_API_TIMEOUT_MS) + + try { + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: controller.signal + }) + const responseText = (await response.text()).replace(/^\uFEFF/, '') + + if (!response.ok) { + throw new Error(`bioRxiv API request failed: HTTP ${response.status} for ${url.pathname}`) + } + + let payload + try { + payload = JSON.parse(responseText) + } catch { + throw new Error(`bioRxiv API returned non-JSON content for ${url.pathname}: ${responseText.slice(0, 180)}`) + } + + const message = Array.isArray(payload?.messages) ? payload.messages[0] : null + const status = typeof message?.status === 'string' ? message.status.trim().toLowerCase() : '' + const textError = typeof payload === 'string' ? payload.trim() : '' + const messageError = typeof message === 'string' ? message.trim() : '' + + if (textError) { + throw new Error(`bioRxiv API error: ${textError}`) + } + + if (messageError && messageError.toLowerCase() !== 'ok') { + throw new Error(`bioRxiv API error: ${messageError}`) + } + + if (status && status !== 'ok') { + const detail = message?.message || message?.status + throw new Error(`bioRxiv API error: ${detail}`) + } + + return { + payload, + url: url.toString() + } + } finally { + clearTimeout(timeoutId) + } +} + +async function fetchBioRxivReportHtml(pathname) { + const url = new URL(pathname, BIORXIV_API_BASE_URL) + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), BIORXIV_API_TIMEOUT_MS) + + try { + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'text/html' }, + signal: controller.signal + }) + if (!response.ok) { + throw new Error(`bioRxiv reporting request failed: HTTP ${response.status} for ${url.pathname}`) + } + const html = await response.text() + return { + html, + url: url.toString() + } + } finally { + clearTimeout(timeoutId) + } +} + +async function biorxivSearchPreprintsFallback(args = {}) { + const server = normalizeBiorxivServer(args.server) + const limit = normalizeInteger(args.limit, 10, 1, 100) + const cursor = normalizeInteger(args.cursor, 0, 0, 1_000_000) + const category = normalizeCategoryLabel(args.category) + const { dateFrom, dateTo, recentDays } = resolveBiorxivDateRange(args, 30) + + const { payload, url } = await fetchBioRxivApiJson( + `/details/${server}/${dateFrom}/${dateTo}/${cursor}`, + category ? { category: toCategoryQueryValue(category) } : {} + ) + + let results = Array.isArray(payload?.collection) ? payload.collection : [] + if (category) { + results = results.filter(item => normalizeCategoryLabel(item?.category) === category) + } + + return { + source: 'biorxiv_api_fallback', + server, + query: { + date_from: dateFrom, + date_to: dateTo, + recent_days: recentDays, + category: category || null, + limit, + cursor + }, + total_available: Number.parseInt(payload?.messages?.[0]?.total, 10) || results.length, + returned_count: Math.min(results.length, limit), + api_url: url, + results: results.slice(0, limit) + } +} + +async function biorxivGetPreprintFallback(args = {}) { + const server = normalizeBiorxivServer(args.server) + const doiPath = normalizeDoiForPath(args.doi) + const doi = String(args.doi || '').trim().replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, '') + const { payload, url } = await fetchBioRxivApiJson(`/details/${server}/${doiPath}`) + + const versions = Array.isArray(payload?.collection) + ? payload.collection.slice().sort((a, b) => Number.parseInt(b.version, 10) - Number.parseInt(a.version, 10)) + : [] + + return { + source: 'biorxiv_api_fallback', + server, + doi, + version_count: versions.length, + latest: versions[0] || null, + versions, + api_url: url + } +} + +async function biorxivSearchPublishedFallback(args = {}) { + const server = normalizeBiorxivServer(args.server) + const limit = normalizeInteger(args.limit, 10, 1, 100) + const cursor = normalizeInteger(args.cursor, 0, 0, 1_000_000) + const publisherPrefix = normalizePublisherPrefix(args.publisher) + const { dateFrom, dateTo, recentDays } = resolveBiorxivDateRange(args, 30) + + let endpointPath = `/pubs/${server}/${dateFrom}/${dateTo}/${cursor}` + if (publisherPrefix && server === 'biorxiv') { + endpointPath = `/publisher/${publisherPrefix}/${dateFrom}/${dateTo}/${cursor}` + } + + const { payload, url } = await fetchBioRxivApiJson(endpointPath) + let results = Array.isArray(payload?.collection) ? payload.collection : [] + + if (publisherPrefix) { + results = results.filter(item => normalizePublisherPrefix(item?.published_doi).startsWith(publisherPrefix)) + } + + return { + source: 'biorxiv_api_fallback', + server, + query: { + date_from: dateFrom, + date_to: dateTo, + recent_days: recentDays, + publisher: publisherPrefix || null, + limit, + cursor + }, + total_available: Number.parseInt(payload?.messages?.[0]?.total, 10) || results.length, + returned_count: Math.min(results.length, limit), + api_url: url, + results: results.slice(0, limit) + } +} + +async function biorxivGetCategoriesFallback() { + const reportPaths = { + biorxiv: '/reporting/biorxiv/category_summary', + medrxiv: '/reporting/medrxiv/category_summary' + } + + const entries = await Promise.all( + Object.entries(reportPaths).map(async ([server, reportPath]) => { + try { + const { html, url } = await fetchBioRxivReportHtml(reportPath) + return { + server, + ok: true, + url, + categories: parseCategorySummaryHtml(html) + } + } catch (error) { + return { + server, + ok: false, + errorMessage: error?.message || 'Unknown error' + } + } + }) + ) + + const categories = {} + const apiUrls = {} + const errors = {} + + for (const entry of entries) { + if (entry.ok) { + const { server, url, categories: parsedCategories } = entry + categories[server] = parsedCategories + apiUrls[server] = url + continue + } + + errors[entry.server] = entry.errorMessage + } + + if (!Array.isArray(categories.biorxiv) || categories.biorxiv.length === 0) { + throw new Error('Unable to load category summary from bioRxiv reporting endpoints.') + } + + return { + source: 'biorxiv_api_fallback', + method: 'reporting_category_summary', + categories, + category_counts: Object.fromEntries( + Object.entries(categories).map(([server, values]) => [server, values.length]) + ), + api_urls: apiUrls, + errors: Object.keys(errors).length > 0 ? errors : undefined + } +} + +async function executeBiorxivApiFallback(name, args = {}) { + if (name === 'biorxiv_search_preprints') { + return biorxivSearchPreprintsFallback(args) + } + if (name === 'biorxiv_get_preprint') { + return biorxivGetPreprintFallback(args) + } + if (name === 'biorxiv_search_published_preprints') { + return biorxivSearchPublishedFallback(args) + } + if (name === 'biorxiv_get_categories') { + return biorxivGetCategoriesFallback() + } + throw new Error(`No bioRxiv API fallback available for tool: ${name}`) +} + // --- Function tool execution (routes to MCP clients or direct APIs) --- const MCP_TOOL_ROUTING = { @@ -826,6 +1209,17 @@ async function executeFunctionTool(name, args) { } } + const shouldUseBiorxivApiFallback = routing.server === 'biorxiv' && BIORXIV_TOOL_NAME_SET.has(name) + if (shouldUseBiorxivApiFallback) { + try { + return await executeBiorxivApiFallback(name, cleanArgs) + } catch (fallbackError) { + throw new Error( + `bioRxiv MCP ${routing.mcpName} failed (${error?.message || 'unknown error'}); bioRxiv API fallback failed (${fallbackError?.message || 'unknown error'}).` + ) + } + } + throw error } } From 2434f9a32b4d0070e8e32532e7d54725bc3bcb7d Mon Sep 17 00:00:00 2001 From: Rob Court Date: Thu, 26 Mar 2026 16:38:55 +0000 Subject: [PATCH 05/19] feat: update VFB MCP URL and enhance tool descriptions for clarity and batch support --- app/api/chat/route.js | 167 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 146 insertions(+), 21 deletions(-) diff --git a/app/api/chat/route.js b/app/api/chat/route.js index 7647349..ef3a514 100644 --- a/app/api/chat/route.js +++ b/app/api/chat/route.js @@ -343,7 +343,8 @@ async function finalizeGovernanceEvent({ let vfbMcpClient = null let biorxivMcpClient = null -const VFB_MCP_URL = 'https://vfb3-mcp.virtualflybrain.org/' +const DEFAULT_VFB_MCP_URL = 'https://vfb3-mcp-preview.virtualflybrain.org/' +const VFB_MCP_URL = (process.env.VFB_MCP_URL || '').trim() || DEFAULT_VFB_MCP_URL const BIORXIV_MCP_URL = 'https://mcp.deepsense.ai/biorxiv/mcp' async function getVfbMcpClient() { @@ -410,11 +411,17 @@ function getToolConfig() { tools.push({ type: 'function', name: 'vfb_get_term_info', - description: 'Get detailed information about a VFB term by ID, including definitions, relationships, images, queries, and references.', + description: 'Get detailed information from VFB by ID. Supports batch requests using an array of IDs.', parameters: { type: 'object', properties: { - id: { type: 'string', description: 'The VFB term ID such as VFB_00102107 or FBbt_00003748' } + id: { + oneOf: [ + { type: 'string', description: 'A single VFB ID such as VFB_00102107 or FBbt_00003748' }, + { type: 'array', items: { type: 'string' }, description: 'Array of VFB IDs for batch lookup' } + ], + description: 'One or more VFB IDs to look up' + } }, required: ['id'] } @@ -423,14 +430,110 @@ function getToolConfig() { tools.push({ type: 'function', name: 'vfb_run_query', - description: 'Run analyses such as PaintedDomains, NBLAST, or connectivity on a VFB entity. Only use query types returned by vfb_get_term_info.', + description: 'Run VFB analyses such as PaintedDomains, NBLAST, or connectivity. Use only query types returned by vfb_get_term_info.', + parameters: { + type: 'object', + properties: { + id: { + oneOf: [ + { type: 'string', description: 'A single VFB ID to query' }, + { type: 'array', items: { type: 'string' }, description: 'Array of VFB IDs to query with the same query_type' } + ], + description: 'One or more VFB IDs' + }, + query_type: { type: 'string', description: 'A query type returned by vfb_get_term_info for that term' }, + queries: { + type: 'array', + description: 'Optional mixed batch input: each item has {id, query_type}. If provided, id/query_type are ignored.', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'VFB ID' }, + query_type: { type: 'string', description: 'Query type for this VFB ID' } + }, + required: ['id', 'query_type'] + } + } + } + } + }) + + tools.push({ + type: 'function', + name: 'vfb_resolve_entity', + description: 'Resolve an unresolved FlyBase name/synonym to an ID and metadata (EXACT/SYNONYM/BROAD match).', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'Raw unresolved FlyBase-related query, e.g. dpp, MB002B, SS04495, Hb9-GAL4' } + }, + required: ['name'] + } + }) + + tools.push({ + type: 'function', + name: 'vfb_find_stocks', + description: 'Find fly stocks for a FlyBase feature ID (gene, allele, insertion, combination, or stock).', + parameters: { + type: 'object', + properties: { + feature_id: { type: 'string', description: 'FlyBase ID such as FBgn..., FBal..., FBti..., FBco..., or FBst...' }, + collection_filter: { type: 'string', description: 'Optional stock centre filter, e.g. Bloomington, Kyoto, VDRC' } + }, + required: ['feature_id'] + } + }) + + tools.push({ + type: 'function', + name: 'vfb_resolve_combination', + description: 'Resolve an unresolved split-GAL4 combination name/synonym to FBco ID and components.', parameters: { type: 'object', properties: { - id: { type: 'string', description: 'The VFB term ID to query' }, - query_type: { type: 'string', description: 'A query type returned for the term by vfb_get_term_info' } + name: { type: 'string', description: 'Raw unresolved split-GAL4 combination text, e.g. MB002B or SS04495' } }, - required: ['id', 'query_type'] + required: ['name'] + } + }) + + tools.push({ + type: 'function', + name: 'vfb_find_combo_publications', + description: 'Find publications linked to a split-GAL4 combination by FBco ID, with DOI/PMID/PMCID when available.', + parameters: { + type: 'object', + properties: { + fbco_id: { type: 'string', description: 'FlyBase combination ID such as FBco0000052' } + }, + required: ['fbco_id'] + } + }) + + tools.push({ + type: 'function', + name: 'vfb_list_connectome_datasets', + description: 'List available connectome dataset symbols/labels for comparative connectivity queries.', + parameters: { + type: 'object', + properties: {} + } + }) + + tools.push({ + type: 'function', + name: 'vfb_query_connectivity', + description: 'Live comparative connectomics query between neuron classes across datasets. Can be slow.', + parameters: { + type: 'object', + properties: { + upstream_type: { type: 'string', description: 'Upstream (presynaptic) neuron class label or FBbt ID' }, + downstream_type: { type: 'string', description: 'Downstream (postsynaptic) neuron class label or FBbt ID' }, + weight: { type: 'number', description: 'Minimum synapse count threshold (recommended default 5)' }, + group_by_class: { type: 'boolean', description: 'Aggregate by class instead of per-neuron pairs' }, + exclude_dbs: { type: 'array', items: { type: 'string' }, description: 'Dataset symbols to exclude, e.g. [\"hb\", \"fafb\"]' } + } } }) @@ -1038,6 +1141,12 @@ const MCP_TOOL_ROUTING = { vfb_search_terms: { server: 'vfb', mcpName: 'search_terms' }, vfb_get_term_info: { server: 'vfb', mcpName: 'get_term_info' }, vfb_run_query: { server: 'vfb', mcpName: 'run_query' }, + vfb_resolve_entity: { server: 'vfb', mcpName: 'resolve_entity' }, + vfb_find_stocks: { server: 'vfb', mcpName: 'find_stocks' }, + vfb_resolve_combination: { server: 'vfb', mcpName: 'resolve_combination' }, + vfb_find_combo_publications: { server: 'vfb', mcpName: 'find_combo_publications' }, + vfb_list_connectome_datasets: { server: 'vfb', mcpName: 'list_connectome_datasets' }, + vfb_query_connectivity: { server: 'vfb', mcpName: 'query_connectivity' }, biorxiv_search_preprints: { server: 'biorxiv', mcpName: 'search_preprints' }, biorxiv_get_preprint: { server: 'biorxiv', mcpName: 'get_preprint' }, biorxiv_search_published_preprints: { server: 'biorxiv', mcpName: 'search_published_preprints' }, @@ -1472,6 +1581,9 @@ TOOLS: - vfb_search_terms: search VFB terms with filters - vfb_get_term_info: fetch detailed VFB term information - vfb_run_query: run VFB analyses returned by vfb_get_term_info +- vfb_resolve_entity / vfb_find_stocks: resolve FlyBase entity names and find relevant stocks +- vfb_resolve_combination / vfb_find_combo_publications: resolve split-GAL4 combinations and fetch linked publications +- vfb_list_connectome_datasets / vfb_query_connectivity: comparative class-level or neuron-level connectivity across datasets - search_reviewed_docs: search approved VFB, NeuroFly, VFB Connect docs, and reviewed FlyBase pages using a server-side site index - get_reviewed_page: fetch and extract content from an approved page returned by search_reviewed_docs - search_pubmed / get_pubmed_article: search and fetch peer-reviewed publications @@ -1480,15 +1592,26 @@ TOOLS: ${VFB_QUERY_LINK_SKILL} TOOL SELECTION: +- Choose tools dynamically based on the user request and available evidence; the guidance below is preferred, not a rigid workflow. - Questions about VFB terms, anatomy, neurons, genes, or datasets: use VFB tools +- For VFB entity questions where suitable query types are available, prefer vfb_get_term_info + vfb_run_query as a first pass because vfb_run_query is usually cached and faster. +- Questions about FlyBase genes/alleles/insertions/stocks: use vfb_resolve_entity first (if unresolved), then vfb_find_stocks +- Questions about split-GAL4 combination names/synonyms (for example MB002B, SS04495): use vfb_resolve_combination first, then vfb_find_combo_publications (and optionally vfb_find_stocks if the user asks for lines) +- Questions about comparative connectivity between neuron classes across datasets: use vfb_query_connectivity (optionally vfb_list_connectome_datasets first to pick valid dataset symbols) - Questions about published papers or recent literature: use PubMed first, optionally bioRxiv/medRxiv for preprints - Questions about VFB, NeuroFly, VFB Connect Python documentation, or approved FlyBase documentation pages, news posts, workshops, conference pages, or event dates: use search_reviewed_docs, then use get_reviewed_page when you need page details - For questions about how to run VFB queries in Python or how to use vfb-connect, prioritize search_reviewed_docs/get_reviewed_page on vfb-connect.readthedocs.io alongside VFB tool outputs when useful. -- For connectivity, synaptic, or NBLAST questions, and especially when the user explicitly asks for vfb_run_query, do not use reviewed-doc search first; use VFB tools (vfb_search_terms/vfb_get_term_info/vfb_run_query). +- For connectivity, synaptic, or NBLAST questions, and especially when the user explicitly asks for vfb_run_query, do not use reviewed-doc search first; use VFB tools (vfb_search_terms/vfb_get_term_info/vfb_run_query). Use vfb_query_connectivity when the user asks for class-to-class connectivity comparisons across datasets. - Do not attempt general web search or browsing outside the approved reviewed-doc index +ENTITY RESOLUTION RULES: +- If vfb_resolve_entity or vfb_resolve_combination returns match_type SYNONYM or BROAD, confirm the resolved entity with the user before running downstream tools. +- If resolver output includes multiple candidates, show a short disambiguation list and ask the user to choose before continuing. +- If the user already provided a canonical FlyBase ID (for example FBgn..., FBal..., FBti..., FBco..., FBst...), you may call downstream tools directly. + TOOL ECONOMY: - Prefer the fewest tool steps needed to produce a useful answer. +- Start with cached vfb_run_query pathways when they can answer the question, then use other tools for deeper refinement only when needed. - Do not keep calling tools just to exhaustively enumerate large result sets. - If the question is broad or combinatorial, stop once you have enough evidence to give a partial answer. - For broad gene-expression or transgene-pattern requests, prefer a short representative list (about 3-5 items) and ask how the user wants to narrow further instead of trying to enumerate everything in one turn. @@ -1511,9 +1634,13 @@ TOOL RELAY: - If a question needs data and no results are available yet, request tools first, then answer after results arrive. FOLLOW-UP QUESTIONS: -When useful, suggest 2-3 short follow-up questions relevant to Drosophila neuroscience and actionable in this chat.` +When useful, suggest 2-3 short potential follow-up questions that are directly answerable with the available tools in this chat.` function getStatusForTool(toolName) { + if (toolName === 'vfb_query_connectivity') { + return { message: 'Comparing connectome datasets', phase: 'mcp' } + } + if (toolName.startsWith('vfb_')) { return { message: 'Querying the fly hive mind', phase: 'mcp' } } @@ -1688,11 +1815,6 @@ function hasConnectivityIntent(message = '') { return /\b(connectome|connectivity|connection|connections|synapse|synaptic|presynaptic|postsynaptic|input|inputs|output|outputs|nblast)\b/i.test(message) } -function isDocsOnlyToolCallSet(toolCalls = []) { - if (!Array.isArray(toolCalls) || toolCalls.length === 0) return false - return toolCalls.every(call => call.name === 'search_reviewed_docs' || call.name === 'get_reviewed_page') -} - function buildToolPolicyCorrectionMessage({ userMessage = '', explicitRunQueryRequested = false, @@ -1700,9 +1822,11 @@ function buildToolPolicyCorrectionMessage({ missingRunQueryExecution = false }) { const policyBullets = [ - '- For this request, prioritize VFB tools over reviewed-doc search.', - '- Use vfb_search_terms and/or vfb_get_term_info to identify the target entity and valid query types.', - '- Use vfb_run_query when a relevant query_type is available.' + '- Choose the smallest set of tools that best answers the user request.', + '- For VFB query-type questions, prefer vfb_get_term_info + vfb_run_query as the first pass because vfb_run_query is typically cached and fast.', + '- Use more specialized tools (for example vfb_query_connectivity, vfb_resolve_entity, vfb_find_stocks, vfb_resolve_combination, vfb_find_combo_publications) when deeper refinement is needed.', + '- Prefer direct data tools over documentation search when the question asks for concrete VFB data.', + '- If existing tool outputs already answer the question, provide the final answer instead of requesting more tools.' ] if (explicitRunQueryRequested) { @@ -1710,7 +1834,7 @@ function buildToolPolicyCorrectionMessage({ } if (connectivityIntent) { - policyBullets.push('- This is a connectivity-style request; do not default to search_reviewed_docs first.') + policyBullets.push('- This is a connectivity-style request; favor VFB connectivity/query tools over docs-only search.') } if (missingRunQueryExecution) { @@ -1974,6 +2098,7 @@ Using only the gathered tool outputs already provided in this conversation: - clearly say that the answer is partial because the request branched into too many tool steps - summarize the strongest findings you already have - end with 2-4 direct clarification questions the user can answer so you can continue in a narrower, lower-tool way +- make those questions concrete and answerable with the tools available in this chat Do not call tools. Do not ask to browse the web.` @@ -2016,6 +2141,7 @@ Using only the existing conversation and any tool outputs already provided: - give a brief summary of what direction is available so far - do not invent missing facts - ask 2-4 short clarifying questions the user can answer so the next turn can be narrower and easier to resolve +- keep clarifying questions concrete and answerable with the tools available in this chat Do not call tools. Do not ask to browse the web.` @@ -2062,6 +2188,7 @@ Using only the existing conversation, any tool outputs already provided, and any - if the evidence is still too incomplete, say that briefly and ask 2-4 short clarifying questions - prefer a short concrete answer over more questions if the available evidence already supports one - do not invent missing facts +- if you ask questions, make them concrete and answerable with the tools available in this chat Do not call tools. Do not ask to browse the web.` @@ -2164,10 +2291,8 @@ async function processResponseStream({ if (requestedToolCalls.length > 0) { const hasVfbToolCall = requestedToolCalls.some(toolCall => toolCall.name.startsWith('vfb_')) - const docsOnlyToolCalls = isDocsOnlyToolCallSet(requestedToolCalls) const shouldCorrectToolChoice = toolPolicyCorrections < maxToolPolicyCorrections && ( - (explicitRunQueryRequested && !hasVfbToolCall) || - (connectivityIntent && docsOnlyToolCalls) + explicitRunQueryRequested && !hasVfbToolCall ) if (shouldCorrectToolChoice) { From 009b95744994fe1567badfe4d5c9521d2ce85b6f Mon Sep 17 00:00:00 2001 From: Rob Court Date: Thu, 26 Mar 2026 16:59:04 +0000 Subject: [PATCH 06/19] feat: implement graph normalization and visualization features for enhanced data representation --- app/api/chat/route.js | 233 +++++++++++++++++++++++++++++++++++++++++- app/page.js | 164 +++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+), 4 deletions(-) diff --git a/app/api/chat/route.js b/app/api/chat/route.js index ef3a514..b345fb9 100644 --- a/app/api/chat/route.js +++ b/app/api/chat/route.js @@ -176,6 +176,139 @@ function linkifyFollowUpQueryItems(text = '') { return linkedLines.join('\n') } +function normalizeGraphSpec(rawSpec = {}) { + if (!rawSpec || typeof rawSpec !== 'object' || Array.isArray(rawSpec)) return null + + const rawNodes = Array.isArray(rawSpec.nodes) ? rawSpec.nodes : [] + const rawEdges = Array.isArray(rawSpec.edges) ? rawSpec.edges : [] + if (rawNodes.length === 0 || rawEdges.length === 0) return null + + const nodes = [] + const nodeIdSet = new Set() + for (const rawNode of rawNodes.slice(0, 80)) { + if (!rawNode || typeof rawNode !== 'object') continue + const id = String(rawNode.id || '').trim() + if (!id || nodeIdSet.has(id)) continue + nodeIdSet.add(id) + + const label = String(rawNode.label || id).trim() || id + const group = rawNode.group === undefined || rawNode.group === null + ? null + : String(rawNode.group).trim() || null + const color = typeof rawNode.color === 'string' && /^#[0-9a-f]{6}$/i.test(rawNode.color.trim()) + ? rawNode.color.trim() + : null + const parsedSize = Number(rawNode.size) + const size = Number.isFinite(parsedSize) + ? Math.min(Math.max(parsedSize, 0.5), 4) + : 1 + + nodes.push({ id, label, group, color, size }) + } + + if (nodes.length === 0) return null + + const knownNodeIds = new Set(nodes.map(node => node.id)) + const edges = [] + for (const rawEdge of rawEdges.slice(0, 200)) { + if (!rawEdge || typeof rawEdge !== 'object') continue + const source = String(rawEdge.source || '').trim() + const target = String(rawEdge.target || '').trim() + if (!source || !target) continue + + if (!knownNodeIds.has(source)) { + knownNodeIds.add(source) + nodes.push({ id: source, label: source, group: null, color: null, size: 1 }) + } + if (!knownNodeIds.has(target)) { + knownNodeIds.add(target) + nodes.push({ id: target, label: target, group: null, color: null, size: 1 }) + } + + const label = rawEdge.label === undefined || rawEdge.label === null + ? null + : String(rawEdge.label).trim() || null + const parsedWeight = Number(rawEdge.weight) + const weight = Number.isFinite(parsedWeight) + ? Math.min(Math.max(parsedWeight, 0), 1_000_000) + : null + + edges.push({ source, target, label, weight }) + } + + if (edges.length === 0) return null + + const layout = rawSpec.layout === 'radial' ? 'radial' : 'circle' + const directed = rawSpec.directed !== false + const title = rawSpec.title === undefined || rawSpec.title === null + ? null + : String(rawSpec.title).trim() || null + + return { + type: 'basic_graph', + version: 1, + title, + directed, + layout, + nodes, + edges + } +} + +function extractGraphSpecsFromResponseText(responseText = '') { + if (!responseText) return { textWithoutGraphs: responseText, graphs: [] } + + const graphs = [] + const graphBlockRegex = /```(?:vfb-graph|vfb_graph|graphjson|graph-json)\s*([\s\S]*?)```/gi + const textWithoutGraphs = responseText.replace(graphBlockRegex, (match, rawJson) => { + try { + const parsed = JSON.parse(String(rawJson || '').trim()) + const normalized = normalizeGraphSpec(parsed) + if (normalized) { + graphs.push(normalized) + return '' + } + } catch { + // Keep original block when parsing fails. + } + return match + }) + + return { textWithoutGraphs, graphs } +} + +function extractGraphSpecsFromToolOutputs(toolOutputs = []) { + const graphs = [] + for (const output of toolOutputs) { + if (output?.name !== 'create_basic_graph') continue + const normalized = normalizeGraphSpec(output.output) + if (normalized) graphs.push(normalized) + } + return graphs +} + +function dedupeGraphSpecs(graphs = []) { + const deduped = [] + const seen = new Set() + + for (const graph of graphs) { + const normalized = normalizeGraphSpec(graph) + if (!normalized) continue + const key = JSON.stringify({ + title: normalized.title, + directed: normalized.directed, + layout: normalized.layout, + nodes: normalized.nodes.map(node => node.id).sort(), + edges: normalized.edges.map(edge => `${edge.source}->${edge.target}:${edge.label || ''}:${edge.weight || ''}`).sort() + }) + if (seen.has(key)) continue + seen.add(key) + deduped.push(normalized) + } + + return deduped.slice(0, 3) +} + function extractImagesFromResponseText(responseText = '') { const thumbnailRegex = /https:\/\/www\.virtualflybrain\.org\/data\/VFB\/i\/([^/]+)\/([^/]+)\/thumbnail(?:T)?\.png/g const images = [] @@ -193,10 +326,12 @@ function extractImagesFromResponseText(responseText = '') { return images } -function buildSuccessfulTextResult({ responseText, responseId, toolUsage, toolRounds, outboundAllowList }) { +function buildSuccessfulTextResult({ responseText, responseId, toolUsage, toolRounds, outboundAllowList, graphSpecs = [] }) { const { sanitizedText, blockedDomains } = sanitizeAssistantOutput(responseText, outboundAllowList) - const linkedResponseText = linkifyFollowUpQueryItems(sanitizedText) + const { textWithoutGraphs, graphs: inlineGraphs } = extractGraphSpecsFromResponseText(sanitizedText) + const linkedResponseText = linkifyFollowUpQueryItems(textWithoutGraphs) const images = extractImagesFromResponseText(linkedResponseText) + const graphs = dedupeGraphSpecs([...(Array.isArray(graphSpecs) ? graphSpecs : []), ...inlineGraphs]) return { ok: true, @@ -205,6 +340,7 @@ function buildSuccessfulTextResult({ responseText, responseId, toolUsage, toolRo toolRounds, responseText: linkedResponseText, images, + graphs, blockedResponseDomains: blockedDomains } } @@ -537,6 +673,50 @@ function getToolConfig() { } }) + tools.push({ + type: 'function', + name: 'create_basic_graph', + description: 'Create a lightweight graph specification for UI rendering. Use this to visualise connectivity as nodes and edges.', + parameters: { + type: 'object', + properties: { + title: { type: 'string', description: 'Optional graph title' }, + directed: { type: 'boolean', description: 'Whether edges are directed (default true)' }, + layout: { type: 'string', enum: ['circle', 'radial'], description: 'Simple layout hint (default circle)' }, + nodes: { + type: 'array', + description: 'Graph nodes', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Unique node identifier' }, + label: { type: 'string', description: 'Display label for the node' }, + group: { type: 'string', description: 'Optional group/type' }, + color: { type: 'string', description: 'Optional color in #RRGGBB format' }, + size: { type: 'number', description: 'Optional relative node size (1-3 recommended)' } + }, + required: ['id'] + } + }, + edges: { + type: 'array', + description: 'Graph edges', + items: { + type: 'object', + properties: { + source: { type: 'string', description: 'Source node id' }, + target: { type: 'string', description: 'Target node id' }, + label: { type: 'string', description: 'Optional edge label' }, + weight: { type: 'number', description: 'Optional edge weight for styling/labels' } + }, + required: ['source', 'target'] + } + } + }, + required: ['nodes', 'edges'] + } + }) + tools.push({ type: 'function', name: 'biorxiv_search_preprints', @@ -1243,6 +1423,14 @@ async function fetchCachedVfbRunQuery(id, queryType) { } } +function createBasicGraph(args = {}) { + const normalized = normalizeGraphSpec(args) + if (!normalized) { + throw new Error('Invalid graph spec. Provide non-empty nodes and edges with valid ids, source, and target fields.') + } + return normalized +} + async function executeFunctionTool(name, args) { if (name === 'search_pubmed') { return searchPubmed(args.query, args.max_results, args.sort) @@ -1260,6 +1448,10 @@ async function executeFunctionTool(name, args) { return getReviewedPage(args.url) } + if (name === 'create_basic_graph') { + return createBasicGraph(args) + } + const routing = MCP_TOOL_ROUTING[name] if (routing) { const client = routing.server === 'vfb' @@ -1584,6 +1776,7 @@ TOOLS: - vfb_resolve_entity / vfb_find_stocks: resolve FlyBase entity names and find relevant stocks - vfb_resolve_combination / vfb_find_combo_publications: resolve split-GAL4 combinations and fetch linked publications - vfb_list_connectome_datasets / vfb_query_connectivity: comparative class-level or neuron-level connectivity across datasets +- create_basic_graph: package node/edge graph specs for UI graph rendering - search_reviewed_docs: search approved VFB, NeuroFly, VFB Connect docs, and reviewed FlyBase pages using a server-side site index - get_reviewed_page: fetch and extract content from an approved page returned by search_reviewed_docs - search_pubmed / get_pubmed_article: search and fetch peer-reviewed publications @@ -1602,6 +1795,7 @@ TOOL SELECTION: - Questions about VFB, NeuroFly, VFB Connect Python documentation, or approved FlyBase documentation pages, news posts, workshops, conference pages, or event dates: use search_reviewed_docs, then use get_reviewed_page when you need page details - For questions about how to run VFB queries in Python or how to use vfb-connect, prioritize search_reviewed_docs/get_reviewed_page on vfb-connect.readthedocs.io alongside VFB tool outputs when useful. - For connectivity, synaptic, or NBLAST questions, and especially when the user explicitly asks for vfb_run_query, do not use reviewed-doc search first; use VFB tools (vfb_search_terms/vfb_get_term_info/vfb_run_query). Use vfb_query_connectivity when the user asks for class-to-class connectivity comparisons across datasets. +- When connectivity relationships would be easier to understand visually, you may call create_basic_graph with key nodes and weighted edges. - Do not attempt general web search or browsing outside the approved reviewed-doc index ENTITY RESOLUTION RULES: @@ -1628,6 +1822,12 @@ FORMATTING VFB REFERENCES: - When thumbnail URLs are present in tool output, include them using markdown image syntax - Only use thumbnail URLs that actually appear in tool results +GRAPH VISUALS: +- Graph rendering is optional and should be used only when it improves clarity for this specific answer. +- For connectivity answers, use at most one concise graph (typically 4-20 nodes) when a visual summary is clearer than text alone. +- Keep graph specs focused on the strongest relationships and avoid very dense or exhaustive graphs. +- Skip graph output when a short table or plain-language summary is clearer. + TOOL RELAY: - You can request server-side tool execution using the tool relay protocol. - If tool results are available, use them directly and do not invent missing values. @@ -1637,6 +1837,10 @@ FOLLOW-UP QUESTIONS: When useful, suggest 2-3 short potential follow-up questions that are directly answerable with the available tools in this chat.` function getStatusForTool(toolName) { + if (toolName === 'create_basic_graph') { + return { message: 'Preparing graph view', phase: 'llm' } + } + if (toolName === 'vfb_query_connectivity') { return { message: 'Comparing connectome datasets', phase: 'mcp' } } @@ -1825,6 +2029,7 @@ function buildToolPolicyCorrectionMessage({ '- Choose the smallest set of tools that best answers the user request.', '- For VFB query-type questions, prefer vfb_get_term_info + vfb_run_query as the first pass because vfb_run_query is typically cached and fast.', '- Use more specialized tools (for example vfb_query_connectivity, vfb_resolve_entity, vfb_find_stocks, vfb_resolve_combination, vfb_find_combo_publications) when deeper refinement is needed.', + '- If the result is connectivity-heavy and a graph would help, consider create_basic_graph for a compact node/edge view.', '- Prefer direct data tools over documentation search when the question asks for concrete VFB data.', '- If existing tool outputs already answer the question, provide the final answer instead of requesting more tools.' ] @@ -2025,6 +2230,7 @@ async function requestNoToolFallbackResponse({ outboundAllowList, toolUsage, toolRounds, + graphSpecs = [], statusMessage, instruction }) { @@ -2069,7 +2275,8 @@ async function requestNoToolFallbackResponse({ responseId, toolUsage, toolRounds, - outboundAllowList + outboundAllowList, + graphSpecs }) } @@ -2083,6 +2290,7 @@ async function requestToolLimitSummary({ outboundAllowList, toolUsage, toolRounds, + graphSpecs = [], maxToolRounds, userMessage }) { @@ -2112,6 +2320,7 @@ Do not call tools. Do not ask to browse the web.` outboundAllowList, toolUsage, toolRounds, + graphSpecs, statusMessage: 'Summarizing partial results', instruction: summaryInstruction }) @@ -2128,6 +2337,7 @@ async function requestClarifyingFollowUp({ outboundAllowList, toolUsage, toolRounds, + graphSpecs = [], userMessage, reason }) { @@ -2156,6 +2366,7 @@ Do not call tools. Do not ask to browse the web.` outboundAllowList, toolUsage, toolRounds, + graphSpecs, statusMessage: 'Clarifying next step', instruction: clarificationInstruction }) @@ -2172,6 +2383,7 @@ async function requestStreamFailureRecovery({ outboundAllowList, toolUsage, toolRounds, + graphSpecs = [], userMessage, reason }) { @@ -2203,6 +2415,7 @@ Do not call tools. Do not ask to browse the web.` outboundAllowList, toolUsage, toolRounds, + graphSpecs, statusMessage: 'Recovering partial answer', instruction: recoveryInstruction }) @@ -2224,6 +2437,7 @@ async function processResponseStream({ const maxToolPolicyCorrections = 3 const explicitRunQueryRequested = hasExplicitVfbRunQueryRequest(userMessage) const connectivityIntent = hasConnectivityIntent(userMessage) + const collectedGraphSpecs = [] let currentResponse = apiResponse let latestResponseId = null let toolRounds = 0 @@ -2245,6 +2459,7 @@ async function processResponseStream({ outboundAllowList, toolUsage, toolRounds, + graphSpecs: collectedGraphSpecs, userMessage, reason: errorMessage || 'The AI service returned an unexpected stream error.' }) @@ -2370,6 +2585,11 @@ async function processResponseStream({ } })) + const graphSpecsFromTools = extractGraphSpecsFromToolOutputs(toolOutputs) + if (graphSpecsFromTools.length > 0) { + collectedGraphSpecs.push(...graphSpecsFromTools) + } + if (textAccumulator.trim()) { accumulatedItems.push({ role: 'assistant', content: textAccumulator.trim() }) } @@ -2469,6 +2689,7 @@ async function processResponseStream({ outboundAllowList, toolUsage, toolRounds, + graphSpecs: collectedGraphSpecs, userMessage, reason: 'empty_response' }) @@ -2502,6 +2723,7 @@ async function processResponseStream({ outboundAllowList, toolUsage, toolRounds, + graphSpecs: collectedGraphSpecs, userMessage, reason: 'invalid_tool_call_payload' }) @@ -2525,7 +2747,8 @@ async function processResponseStream({ responseId: latestResponseId, toolUsage, toolRounds, - outboundAllowList + outboundAllowList, + graphSpecs: collectedGraphSpecs }) } @@ -2539,6 +2762,7 @@ async function processResponseStream({ outboundAllowList, toolUsage, toolRounds, + graphSpecs: collectedGraphSpecs, maxToolRounds, userMessage }) @@ -2852,6 +3076,7 @@ export async function POST(request) { sendEvent('result', { response: result.responseText, images: result.images, + graphs: result.graphs, newScene: scene, requestId, responseId diff --git a/app/page.js b/app/page.js index c0fd72d..316cae6 100644 --- a/app/page.js +++ b/app/page.js @@ -15,6 +15,158 @@ const FEEDBACK_REASON_LABELS = { out_of_scope_refusal: 'Out of scope/refusal' } +const GRAPH_PALETTE = ['#4a9eff', '#4ade80', '#f59e0b', '#f472b6', '#22d3ee', '#a78bfa', '#f87171', '#34d399'] + +function hashString(value = '') { + let hash = 0 + for (let i = 0; i < value.length; i += 1) { + hash = ((hash << 5) - hash) + value.charCodeAt(i) + hash |= 0 + } + return Math.abs(hash) +} + +const BasicGraphView = memo(function BasicGraphView({ graph, graphKey }) { + const width = 640 + const height = 360 + const centerX = width / 2 + const centerY = height / 2 + + const nodes = Array.isArray(graph?.nodes) ? graph.nodes : [] + const edges = Array.isArray(graph?.edges) ? graph.edges : [] + if (nodes.length === 0 || edges.length === 0) return null + + const radius = Math.max(90, Math.min(width, height) / 2 - 64) + const positionedNodes = nodes.map((node, index) => { + const angle = nodes.length === 1 + ? 0 + : ((2 * Math.PI * index) / nodes.length) - (Math.PI / 2) + + return { + ...node, + x: centerX + (radius * Math.cos(angle)), + y: centerY + (radius * Math.sin(angle)) + } + }) + + const nodeById = new Map(positionedNodes.map(node => [String(node.id), node])) + const visibleEdges = edges + .map(edge => ({ + ...edge, + source: String(edge.source), + target: String(edge.target) + })) + .filter(edge => nodeById.has(edge.source) && nodeById.has(edge.target)) + + const markerId = `arrow-${hashString(String(graphKey || graph?.title || 'graph'))}` + + return ( +
+ {graph?.title && ( +
+ {graph.title} +
+ )} + + {graph?.directed !== false && ( + + + + + + )} + + {visibleEdges.map((edge, index) => { + const sourceNode = nodeById.get(edge.source) + const targetNode = nodeById.get(edge.target) + const rawWeight = Number(edge.weight) + const strokeWidth = Number.isFinite(rawWeight) + ? Math.min(4, Math.max(1, 1 + (Math.log10(rawWeight + 1)))) + : 1.4 + const midX = (sourceNode.x + targetNode.x) / 2 + const midY = (sourceNode.y + targetNode.y) / 2 + const edgeText = edge.label || (Number.isFinite(rawWeight) ? `${rawWeight}` : '') + + return ( + + + {edgeText && ( + + {edgeText} + + )} + + ) + })} + + {positionedNodes.map((node, index) => { + const groupKey = String(node.group || node.label || node.id) + const color = (typeof node.color === 'string' && /^#[0-9a-f]{6}$/i.test(node.color)) + ? node.color + : GRAPH_PALETTE[hashString(groupKey) % GRAPH_PALETTE.length] + const nodeRadius = Math.min(18, Math.max(7, 8 + (Number(node.size) || 1))) + return ( + + + + {node.label || node.id} + + + ) + })} + +
+ ) +}) + // ── Memoized single-message bubble ────────────────────────────────── // Only re-renders when its own props change, NOT when sibling messages // are added or the thinking indicator ticks. @@ -66,6 +218,17 @@ const ChatMessage = memo(function ChatMessage({ {msg.content} + {Array.isArray(msg.graphs) && msg.graphs.length > 0 && ( +
+ {msg.graphs.map((graph, graphIndex) => ( + + ))} +
+ )} {/* Image gallery from API images field */} {msg.images && msg.images.length > 0 && (
@@ -479,6 +642,7 @@ Feel free to ask about neural circuits, gene expression, connectome data, or any } else if (currentEvent === 'result') { setMessages(prev => [...prev, makeMsg('assistant', data.response, { images: data.images, + graphs: data.graphs, requestId: data.requestId, responseId: data.responseId })]) From d516908a36649eec81bc420be7da9dbe56cc31d1 Mon Sep 17 00:00:00 2001 From: Rob Court Date: Thu, 26 Mar 2026 17:20:08 +0000 Subject: [PATCH 07/19] feat: enhance query handling with new response functions and error enrichment logic --- app/api/chat/route.js | 208 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 201 insertions(+), 7 deletions(-) diff --git a/app/api/chat/route.js b/app/api/chat/route.js index b345fb9..ec66c3e 100644 --- a/app/api/chat/route.js +++ b/app/api/chat/route.js @@ -90,6 +90,19 @@ function createImmediateErrorResponse(message, requestId, responseId) { }) } +function createImmediateResultResponse(message, requestId, responseId) { + return buildSseResponse(async (sendEvent) => { + sendEvent('result', { + response: message, + images: [], + graphs: [], + newScene: {}, + requestId, + responseId + }) + }) +} + function getClientIp(request) { const xForwardedFor = request.headers.get('x-forwarded-for') || '' return (xForwardedFor.split(',')[0] || '').trim() || request.headers.get('x-real-ip') || 'unknown' @@ -1423,6 +1436,40 @@ async function fetchCachedVfbRunQuery(id, queryType) { } } +function extractQueryNamesFromTermInfoPayload(rawPayload) { + let parsed = rawPayload + if (typeof rawPayload === 'string') { + try { + parsed = JSON.parse(rawPayload) + } catch { + return [] + } + } + + if (!parsed || typeof parsed !== 'object') return [] + + const candidateRecords = [] + if (Array.isArray(parsed.Queries)) { + candidateRecords.push(parsed) + } + + for (const value of Object.values(parsed)) { + if (value && typeof value === 'object' && Array.isArray(value.Queries)) { + candidateRecords.push(value) + } + } + + const queryNames = [] + for (const record of candidateRecords) { + for (const entry of record.Queries || []) { + const queryName = typeof entry?.query === 'string' ? entry.query.trim() : '' + if (queryName) queryNames.push(queryName) + } + } + + return Array.from(new Set(queryNames)) +} + function createBasicGraph(args = {}) { const normalized = normalizeGraphSpec(args) if (!normalized) { @@ -1510,6 +1557,47 @@ async function executeFunctionTool(name, args) { } } + const shouldEnrichRunQueryError = + name === 'vfb_run_query' && + routing.server === 'vfb' && + typeof cleanArgs.id === 'string' && + cleanArgs.id.trim().length > 0 && + typeof cleanArgs.query_type === 'string' && + cleanArgs.query_type.trim().length > 0 && + /\b(query[_\s-]?type|invalid query|not available for this id|not a valid query|available queries|http\s*400|status code 400|bad request)\b/i.test(error?.message || '') + + if (shouldEnrichRunQueryError) { + let termInfoPayload = null + + try { + const termInfoResult = await client.callTool({ + name: 'get_term_info', + arguments: { id: cleanArgs.id } + }) + const termInfoText = termInfoResult?.content + ?.filter(item => item.type === 'text') + ?.map(item => item.text) + ?.join('\n') + + if (termInfoText) termInfoPayload = termInfoText + } catch (termInfoError) { + if (isRetryableMcpError(termInfoError)) { + try { + termInfoPayload = await fetchCachedVfbTermInfo(cleanArgs.id) + } catch { + // Keep the original run_query error when enrichment lookup fails. + } + } + } + + const availableQueryTypes = extractQueryNamesFromTermInfoPayload(termInfoPayload) + if (availableQueryTypes.length > 0) { + throw new Error( + `${error?.message || 'run_query failed'}. Available query_type values for ${cleanArgs.id}: ${availableQueryTypes.join(', ')}.` + ) + } + } + const shouldUseBiorxivApiFallback = routing.server === 'biorxiv' && BIORXIV_TOOL_NAME_SET.has(name) if (shouldUseBiorxivApiFallback) { try { @@ -1717,6 +1805,58 @@ const VFB_QUERY_SHORT_NAMES = [ { name: 'ImagesThatDevelopFrom', description: 'List images of neurons that develop from $NAME' } ] +function escapeRegexForPattern(value = '') { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +const VFB_QUERY_SHORT_NAME_MAP = new Map( + VFB_QUERY_SHORT_NAMES.map(entry => [entry.name.toLowerCase(), entry.name]) +) + +const VFB_QUERY_SHORT_NAME_REGEX = new RegExp( + `\\b(?:${VFB_QUERY_SHORT_NAMES.map(entry => escapeRegexForPattern(entry.name)).join('|')})\\b`, + 'gi' +) + +const VFB_CANONICAL_ID_REGEX = /\b(?:FBbt_\d{8}|VFB_\d{8}|FBgn\d{7}|FBal\d{7}|FBti\d{7}|FBco\d{7}|FBst\d{7})\b/i +const RUN_QUERY_PREPARATION_TOOL_NAMES = new Set([ + 'vfb_search_terms', + 'vfb_get_term_info', + 'vfb_resolve_entity', + 'vfb_resolve_combination' +]) + +function extractRequestedVfbQueryShortNames(message = '') { + if (!message) return [] + + const matches = message.match(VFB_QUERY_SHORT_NAME_REGEX) || [] + const canonicalMatches = matches + .map(match => VFB_QUERY_SHORT_NAME_MAP.get(match.toLowerCase())) + .filter(Boolean) + + return Array.from(new Set(canonicalMatches)) +} + +function hasCanonicalVfbOrFlybaseId(message = '') { + return VFB_CANONICAL_ID_REGEX.test(message) +} + +function isStandaloneQueryTypeDirective(message = '', requestedQueryTypes = []) { + if (!message || requestedQueryTypes.length === 0) return false + + let residual = message.toLowerCase() + for (const queryType of requestedQueryTypes) { + residual = residual.replace(new RegExp(`\\b${escapeRegexForPattern(queryType)}\\b`, 'gi'), ' ') + } + + residual = residual + .replace(/\b(vfb_run_query|run_query|run query|use|please|can|could|you|tool|tools|query|queries|for|with|the|a|an|and|or|this|that|now|show|me)\b/gi, ' ') + .replace(/[^a-z0-9_]+/gi, ' ') + .trim() + + return residual.length === 0 +} + function buildVfbQueryLinkSkill() { const queryLines = VFB_QUERY_SHORT_NAMES .map(({ name, description }) => `- ${name}: ${description}`) @@ -2012,7 +2152,7 @@ Use these results to continue. If more tools are needed, send another JSON tool } function hasExplicitVfbRunQueryRequest(message = '') { - return /\bvfb_run_query\b/i.test(message) + return /\b(vfb_run_query|run_query|run query)\b/i.test(message) } function hasConnectivityIntent(message = '') { @@ -2023,7 +2163,9 @@ function buildToolPolicyCorrectionMessage({ userMessage = '', explicitRunQueryRequested = false, connectivityIntent = false, - missingRunQueryExecution = false + missingRunQueryExecution = false, + requestedQueryTypes = [], + hasCanonicalIdInUserMessage = false }) { const policyBullets = [ '- Choose the smallest set of tools that best answers the user request.', @@ -2038,6 +2180,15 @@ function buildToolPolicyCorrectionMessage({ policyBullets.push('- The user explicitly asked for vfb_run_query, so include a plan that leads to vfb_run_query.') } + if (requestedQueryTypes.length > 0) { + const queryList = requestedQueryTypes.join(', ') + policyBullets.push(`- The user explicitly requested query type${requestedQueryTypes.length > 1 ? 's' : ''}: ${queryList}. Preserve these exact query_type values when calling vfb_run_query.`) + policyBullets.push('- Resolve target term(s), then use vfb_get_term_info + vfb_run_query. Do not substitute vfb_query_connectivity for this request unless the user asks for class-to-class dataset comparison.') + if (!hasCanonicalIdInUserMessage) { + policyBullets.push('- If the target term is ambiguous, ask one short clarifying question instead of starting broad exploratory tool loops.') + } + } + if (connectivityIntent) { policyBullets.push('- This is a connectivity-style request; favor VFB connectivity/query tools over docs-only search.') } @@ -2435,7 +2586,9 @@ async function processResponseStream({ const accumulatedItems = [] const maxToolRounds = 10 const maxToolPolicyCorrections = 3 - const explicitRunQueryRequested = hasExplicitVfbRunQueryRequest(userMessage) + const requestedQueryTypes = extractRequestedVfbQueryShortNames(userMessage) + const explicitRunQueryRequested = hasExplicitVfbRunQueryRequest(userMessage) || requestedQueryTypes.length > 0 + const hasCanonicalIdInUserMessage = hasCanonicalVfbOrFlybaseId(userMessage) const connectivityIntent = hasConnectivityIntent(userMessage) const collectedGraphSpecs = [] let currentResponse = apiResponse @@ -2506,8 +2659,13 @@ async function processResponseStream({ if (requestedToolCalls.length > 0) { const hasVfbToolCall = requestedToolCalls.some(toolCall => toolCall.name.startsWith('vfb_')) + const hasVfbRunQueryToolCall = requestedToolCalls.some(toolCall => toolCall.name === 'vfb_run_query') + const hasRunQueryPreparationCall = requestedToolCalls.some(toolCall => RUN_QUERY_PREPARATION_TOOL_NAMES.has(toolCall.name)) + const hasConnectivityComparisonCall = requestedToolCalls.some(toolCall => toolCall.name === 'vfb_query_connectivity') const shouldCorrectToolChoice = toolPolicyCorrections < maxToolPolicyCorrections && ( - explicitRunQueryRequested && !hasVfbToolCall + (explicitRunQueryRequested && !hasVfbToolCall) || + (explicitRunQueryRequested && !hasVfbRunQueryToolCall && !hasRunQueryPreparationCall) || + (requestedQueryTypes.length > 0 && hasConnectivityComparisonCall && !hasVfbRunQueryToolCall) ) if (shouldCorrectToolChoice) { @@ -2522,7 +2680,9 @@ async function processResponseStream({ content: buildToolPolicyCorrectionMessage({ userMessage, explicitRunQueryRequested, - connectivityIntent + connectivityIntent, + requestedQueryTypes, + hasCanonicalIdInUserMessage }) }) @@ -2642,7 +2802,9 @@ async function processResponseStream({ userMessage, explicitRunQueryRequested, connectivityIntent, - missingRunQueryExecution: true + missingRunQueryExecution: true, + requestedQueryTypes, + hasCanonicalIdInUserMessage }) }) @@ -2842,7 +3004,7 @@ export async function POST(request) { const body = await request.json() const messages = Array.isArray(body.messages) ? body.messages : [] const scene = body.scene || {} - const message = typeof messages[messages.length - 1]?.content === 'string' + let message = typeof messages[messages.length - 1]?.content === 'string' ? messages[messages.length - 1].content : '' @@ -2915,6 +3077,38 @@ export async function POST(request) { return createImmediateErrorResponse(refusalMessage, requestId, responseId) } + const requestedQueryTypes = extractRequestedVfbQueryShortNames(message) + if (requestedQueryTypes.length > 0 && isStandaloneQueryTypeDirective(message, requestedQueryTypes)) { + const recentUserContext = messages + .slice(0, -1) + .reverse() + .find(item => item?.role === 'user' && typeof item?.content === 'string' && item.content.trim().length > 0) + ?.content + ?.trim() + + if (recentUserContext) { + message = `${message}\n\nUse this most recent user context as the target term scope: "${recentUserContext}".` + } else { + const responseId = `local-${requestId}` + const clarificationMessage = `I can run ${requestedQueryTypes.join(', ')}, but I need a target term label or ID first (for example "medulla" or "FBbt_00003748").` + + await finalizeGovernanceEvent({ + requestId, + responseId, + clientIp, + startTime, + rateCheck, + message, + responseText: clarificationMessage, + blockedRequestedDomains, + refusal: false, + reasonCode: 'query_target_required' + }) + + return createImmediateResultResponse(clarificationMessage, requestId, responseId) + } + } + loadLookupCache() return buildSseResponse(async (sendEvent) => { From faedffb8cdad6ac4262b28d19ccdf6f6ef667166 Mon Sep 17 00:00:00 2001 From: Rob Court Date: Thu, 26 Mar 2026 17:34:35 +0000 Subject: [PATCH 08/19] feat: increase max tool rounds from 10 to 50 for enhanced processing capacity --- app/api/chat/route.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/chat/route.js b/app/api/chat/route.js index ec66c3e..da049a3 100644 --- a/app/api/chat/route.js +++ b/app/api/chat/route.js @@ -2584,7 +2584,7 @@ async function processResponseStream({ const outboundAllowList = getOutboundAllowList() const toolUsage = {} const accumulatedItems = [] - const maxToolRounds = 10 + const maxToolRounds = 50 const maxToolPolicyCorrections = 3 const requestedQueryTypes = extractRequestedVfbQueryShortNames(userMessage) const explicitRunQueryRequested = hasExplicitVfbRunQueryRequest(userMessage) || requestedQueryTypes.length > 0 From 99d72a20e71f408896f24ad1d4d7d37d133384b0 Mon Sep 17 00:00:00 2001 From: Rob Court Date: Thu, 26 Mar 2026 18:18:35 +0000 Subject: [PATCH 09/19] feat: normalize connectivity defaults for group_by_class and weight parameters in executeFunctionTool --- app/api/chat/route.js | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/app/api/chat/route.js b/app/api/chat/route.js index da049a3..960c5d1 100644 --- a/app/api/chat/route.js +++ b/app/api/chat/route.js @@ -679,8 +679,8 @@ function getToolConfig() { properties: { upstream_type: { type: 'string', description: 'Upstream (presynaptic) neuron class label or FBbt ID' }, downstream_type: { type: 'string', description: 'Downstream (postsynaptic) neuron class label or FBbt ID' }, - weight: { type: 'number', description: 'Minimum synapse count threshold (recommended default 5)' }, - group_by_class: { type: 'boolean', description: 'Aggregate by class instead of per-neuron pairs' }, + weight: { type: 'number', description: 'Minimum synapse count threshold (default 5)' }, + group_by_class: { type: 'boolean', description: 'Aggregate by class instead of per-neuron pairs (default true)' }, exclude_dbs: { type: 'array', items: { type: 'string' }, description: 'Dataset symbols to exclude, e.g. [\"hb\", \"fafb\"]' } } } @@ -1510,6 +1510,29 @@ async function executeFunctionTool(name, args) { if (value !== undefined && value !== null) cleanArgs[key] = value } + // Normalize connectivity defaults so class-level summaries are used unless explicitly overridden. + if (name === 'vfb_query_connectivity') { + if (typeof cleanArgs.group_by_class === 'string') { + const normalized = cleanArgs.group_by_class.trim().toLowerCase() + if (normalized === 'true') cleanArgs.group_by_class = true + else if (normalized === 'false') cleanArgs.group_by_class = false + else cleanArgs.group_by_class = true + } else if (typeof cleanArgs.group_by_class !== 'boolean') { + cleanArgs.group_by_class = true + } + + const parsedWeight = Number(cleanArgs.weight) + if (!Number.isFinite(parsedWeight)) { + cleanArgs.weight = 5 + } else { + cleanArgs.weight = parsedWeight + } + + if (!Array.isArray(cleanArgs.exclude_dbs)) { + cleanArgs.exclude_dbs = [] + } + } + try { const result = await client.callTool({ name: routing.mcpName, arguments: cleanArgs }) if (result?.content) { From 70a482949be2916749e2f986d4078f31811300e7 Mon Sep 17 00:00:00 2001 From: Rob Court Date: Thu, 26 Mar 2026 18:24:07 +0000 Subject: [PATCH 10/19] feat: implement neuron class handling and connectivity endpoint normalization in vfb_query_connectivity --- app/api/chat/route.js | 272 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) diff --git a/app/api/chat/route.js b/app/api/chat/route.js index 960c5d1..feff00d 100644 --- a/app/api/chat/route.js +++ b/app/api/chat/route.js @@ -1470,6 +1470,249 @@ function extractQueryNamesFromTermInfoPayload(rawPayload) { return Array.from(new Set(queryNames)) } +const VFB_TERM_ID_TOKEN_REGEX = /\b(?:FBbt_\d{8}|VFB_\d{8})\b/i +const VFB_NEURON_CLASS_ID_REGEX = /^FBbt_\d{8}$/i + +function parseJsonPayload(rawPayload) { + if (rawPayload === null || rawPayload === undefined) return null + if (typeof rawPayload === 'object') return rawPayload + if (typeof rawPayload !== 'string') return null + + try { + return JSON.parse(rawPayload) + } catch { + return null + } +} + +function stripMarkdownLinkText(value = '') { + const text = String(value || '').trim() + if (!text) return '' + + const markdownLinkMatch = text.match(/^\[([^\]]+)\]\(([^)]+)\)$/) + if (!markdownLinkMatch) return text + + return markdownLinkMatch[1]?.trim() || text +} + +function extractCanonicalVfbTermId(value = '') { + const text = String(value || '') + const tokenMatch = text.match(VFB_TERM_ID_TOKEN_REGEX) + return tokenMatch ? tokenMatch[0] : null +} + +function normalizeConnectivityEndpointValue(value = '') { + const text = String(value || '').trim() + if (!text) return '' + + const canonicalId = extractCanonicalVfbTermId(text) + if (canonicalId) return canonicalId + + return stripMarkdownLinkText(text) +} + +function extractTermInfoRecordFromPayload(rawPayload, requestedId = '') { + const parsed = parseJsonPayload(rawPayload) + if (!parsed || typeof parsed !== 'object') return null + + if (parsed.Id || Array.isArray(parsed.SuperTypes) || Array.isArray(parsed.Queries)) { + return parsed + } + + if (requestedId && parsed[requestedId] && typeof parsed[requestedId] === 'object') { + return parsed[requestedId] + } + + for (const value of Object.values(parsed)) { + if (!value || typeof value !== 'object') continue + if (value.Id || Array.isArray(value.SuperTypes) || Array.isArray(value.Queries)) { + return value + } + } + + return null +} + +function extractRowsFromRunQueryPayload(rawPayload) { + const parsed = parseJsonPayload(rawPayload) + if (!parsed || typeof parsed !== 'object') return [] + + if (Array.isArray(parsed.rows)) return parsed.rows + + const rows = [] + for (const value of Object.values(parsed)) { + if (value && typeof value === 'object' && Array.isArray(value.rows)) { + rows.push(...value.rows) + } + } + + return rows +} + +function extractNeuronClassCandidatesFromRows(rows = [], limit = 10) { + if (!Array.isArray(rows) || rows.length === 0) return [] + + const candidates = [] + const seenIds = new Set() + + for (const row of rows) { + if (!row || typeof row !== 'object') continue + + const idCandidate = extractCanonicalVfbTermId(row.id || row.short_form || row.label || '') + if (!idCandidate || !VFB_NEURON_CLASS_ID_REGEX.test(idCandidate) || seenIds.has(idCandidate)) continue + + seenIds.add(idCandidate) + const label = stripMarkdownLinkText(row.label || idCandidate) || idCandidate + candidates.push({ id: idCandidate, label }) + + if (candidates.length >= limit) break + } + + return candidates +} + +function buildNeuronsPartHereLink(termId = '') { + if (!VFB_NEURON_CLASS_ID_REGEX.test(termId)) return null + return `${VFB_QUERY_LINK_BASE}${encodeURIComponent(termId)},${encodeURIComponent('NeuronsPartHere')}` +} + +function isNeuronClassTerm(termRecord) { + const superTypes = Array.isArray(termRecord?.SuperTypes) ? termRecord.SuperTypes : [] + return superTypes.some(type => String(type || '').toLowerCase() === 'neuron') +} + +function getReadableTermName(termRecord, fallback = '') { + if (typeof termRecord?.Name === 'string' && termRecord.Name.trim()) { + return termRecord.Name.trim() + } + + if (typeof termRecord?.Meta?.Name === 'string' && termRecord.Meta.Name.trim()) { + return stripMarkdownLinkText(termRecord.Meta.Name) + } + + return fallback +} + +async function callVfbToolTextWithFallback(client, toolName, toolArguments = {}) { + try { + const result = await client.callTool({ name: toolName, arguments: toolArguments }) + if (result?.content) { + const texts = result.content + .filter(item => item.type === 'text') + .map(item => item.text) + return texts.join('\n') || JSON.stringify(result.content) + } + + return JSON.stringify(result) + } catch (error) { + const shouldFallbackTermInfo = + toolName === 'get_term_info' && + typeof toolArguments?.id === 'string' && + toolArguments.id.trim().length > 0 && + isRetryableMcpError(error) + + if (shouldFallbackTermInfo) { + return fetchCachedVfbTermInfo(toolArguments.id) + } + + const shouldFallbackRunQuery = + toolName === 'run_query' && + typeof toolArguments?.id === 'string' && + toolArguments.id.trim().length > 0 && + typeof toolArguments?.query_type === 'string' && + toolArguments.query_type.trim().length > 0 && + isRetryableMcpError(error) + + if (shouldFallbackRunQuery) { + return fetchCachedVfbRunQuery(toolArguments.id, toolArguments.query_type) + } + + throw error + } +} + +async function assessConnectivityEndpointForNeuronClass({ client, side, rawValue }) { + const normalizedValue = normalizeConnectivityEndpointValue(rawValue) + const termId = extractCanonicalVfbTermId(normalizedValue) + + if (!termId || !VFB_NEURON_CLASS_ID_REGEX.test(termId)) { + return { + side, + raw_input: String(rawValue || ''), + normalized_input: normalizedValue, + requires_selection: false + } + } + + let termInfoText = null + try { + termInfoText = await callVfbToolTextWithFallback(client, 'get_term_info', { id: termId }) + } catch { + return { + side, + raw_input: String(rawValue || ''), + normalized_input: normalizedValue, + term_id: termId, + requires_selection: false + } + } + + const termRecord = extractTermInfoRecordFromPayload(termInfoText, termId) + const termName = getReadableTermName(termRecord, termId) + if (!termRecord) { + return { + side, + raw_input: String(rawValue || ''), + normalized_input: normalizedValue, + term_id: termId, + term_name: termName, + requires_selection: false + } + } + + if (isNeuronClassTerm(termRecord)) { + return { + side, + raw_input: String(rawValue || ''), + normalized_input: normalizedValue, + term_id: termId, + term_name: termName, + requires_selection: false + } + } + + const queryNames = extractQueryNamesFromTermInfoPayload(termInfoText) + const hasNeuronsPartHere = queryNames.includes('NeuronsPartHere') + const suggestionLink = buildNeuronsPartHereLink(termId) + let candidates = [] + + if (hasNeuronsPartHere) { + try { + const runQueryText = await callVfbToolTextWithFallback(client, 'run_query', { + id: termId, + query_type: 'NeuronsPartHere' + }) + const rows = extractRowsFromRunQueryPayload(runQueryText) + candidates = extractNeuronClassCandidatesFromRows(rows, 10) + } catch { + // If candidate extraction fails, still return the selection guidance payload. + } + } + + return { + side, + raw_input: String(rawValue || ''), + normalized_input: normalizedValue, + term_id: termId, + term_name: termName, + super_types: Array.isArray(termRecord.SuperTypes) ? termRecord.SuperTypes : [], + requires_selection: true, + selection_query: hasNeuronsPartHere ? 'NeuronsPartHere' : null, + selection_query_link: suggestionLink, + candidates + } +} + function createBasicGraph(args = {}) { const normalized = normalizeGraphSpec(args) if (!normalized) { @@ -1512,6 +1755,9 @@ async function executeFunctionTool(name, args) { // Normalize connectivity defaults so class-level summaries are used unless explicitly overridden. if (name === 'vfb_query_connectivity') { + cleanArgs.upstream_type = normalizeConnectivityEndpointValue(cleanArgs.upstream_type) + cleanArgs.downstream_type = normalizeConnectivityEndpointValue(cleanArgs.downstream_type) + if (typeof cleanArgs.group_by_class === 'string') { const normalized = cleanArgs.group_by_class.trim().toLowerCase() if (normalized === 'true') cleanArgs.group_by_class = true @@ -1531,6 +1777,30 @@ async function executeFunctionTool(name, args) { if (!Array.isArray(cleanArgs.exclude_dbs)) { cleanArgs.exclude_dbs = [] } + + const endpointChecks = await Promise.all([ + assessConnectivityEndpointForNeuronClass({ + client, + side: 'upstream', + rawValue: cleanArgs.upstream_type + }), + assessConnectivityEndpointForNeuronClass({ + client, + side: 'downstream', + rawValue: cleanArgs.downstream_type + }) + ]) + + const selectionsNeeded = endpointChecks.filter(check => check.requires_selection) + if (selectionsNeeded.length > 0) { + return JSON.stringify({ + requires_user_selection: true, + tool: 'vfb_query_connectivity', + message: 'vfb_query_connectivity requires neuron class inputs. One or more provided terms are anatomy regions rather than neuron classes.', + instruction: 'Ask the user to choose one neuron class from each side before running connectivity.', + selections_needed: selectionsNeeded + }) + } } try { @@ -1954,10 +2224,12 @@ TOOL SELECTION: - Questions about FlyBase genes/alleles/insertions/stocks: use vfb_resolve_entity first (if unresolved), then vfb_find_stocks - Questions about split-GAL4 combination names/synonyms (for example MB002B, SS04495): use vfb_resolve_combination first, then vfb_find_combo_publications (and optionally vfb_find_stocks if the user asks for lines) - Questions about comparative connectivity between neuron classes across datasets: use vfb_query_connectivity (optionally vfb_list_connectome_datasets first to pick valid dataset symbols) +- vfb_query_connectivity requires neuron class inputs. If the user provides anatomy regions (for example medulla or central complex), use NeuronsPartHere first for each region, then ask the user to pick one neuron class per side before running vfb_query_connectivity. - Questions about published papers or recent literature: use PubMed first, optionally bioRxiv/medRxiv for preprints - Questions about VFB, NeuroFly, VFB Connect Python documentation, or approved FlyBase documentation pages, news posts, workshops, conference pages, or event dates: use search_reviewed_docs, then use get_reviewed_page when you need page details - For questions about how to run VFB queries in Python or how to use vfb-connect, prioritize search_reviewed_docs/get_reviewed_page on vfb-connect.readthedocs.io alongside VFB tool outputs when useful. - For connectivity, synaptic, or NBLAST questions, and especially when the user explicitly asks for vfb_run_query, do not use reviewed-doc search first; use VFB tools (vfb_search_terms/vfb_get_term_info/vfb_run_query). Use vfb_query_connectivity when the user asks for class-to-class connectivity comparisons across datasets. +- If vfb_query_connectivity returns requires_user_selection: true, do not claim connectivity results. Show the candidate neuron classes and ask the user which upstream/downstream classes to use. - When connectivity relationships would be easier to understand visually, you may call create_basic_graph with key nodes and weighted edges. - Do not attempt general web search or browsing outside the approved reviewed-doc index From 5a04be3341fe39160540d8390b2ad2ec09ef4069 Mon Sep 17 00:00:00 2001 From: Rob Court Date: Thu, 26 Mar 2026 19:19:08 +0000 Subject: [PATCH 11/19] feat: add accessibility statement page with compliance details and feedback options --- app/accessibility/page.js | 102 ++++++++++++++++++++++++ app/api/chat/route.js | 82 ++++++++++++++++++- app/layout.js | 5 ++ app/page.js | 160 ++++++++++++++++++++++++++------------ 4 files changed, 296 insertions(+), 53 deletions(-) create mode 100644 app/accessibility/page.js diff --git a/app/accessibility/page.js b/app/accessibility/page.js new file mode 100644 index 0000000..c68369f --- /dev/null +++ b/app/accessibility/page.js @@ -0,0 +1,102 @@ +export const metadata = { + title: 'VFB Chat Accessibility Statement', + description: 'Accessibility statement for VFB Chat' +} + +export default function AccessibilityPage() { + return ( +
+
+

Accessibility Statement

+

+ This accessibility statement applies to VFB Chat (chat.virtualflybrain.org). + This service is run by the Virtual Fly Brain project at the University of Edinburgh. +

+ +
+

Compliance Status

+

+ We aim to make this website accessible in accordance with the Public Sector Bodies + (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018 and + the Web Content Accessibility Guidelines (WCAG) 2.2 at Level AA. +

+

+ This website is partially compliant with the WCAG 2.2 Level AA standard. +

+
+ +
+

What We Do to Ensure Accessibility

+
    +
  • Full keyboard navigation throughout the chat interface
  • +
  • Skip-to-content link for keyboard and screen reader users
  • +
  • Proper ARIA landmarks and live regions for dynamic content
  • +
  • Sufficient colour contrast ratios (minimum 4.5:1 for text)
  • +
  • Visible focus indicators for interactive elements
  • +
  • Semantic HTML structure with appropriate heading hierarchy
  • +
  • Alternative text for images
  • +
  • Accessible form inputs with associated labels
  • +
  • No time-limited content
  • +
  • No flashing content
  • +
+
+ +
+

Known Limitations

+
    +
  • Network graph visualisations (SVG) convey information visually that may not be fully available to screen reader users, though graph titles and labels are provided as text.
  • +
  • AI-generated content may occasionally produce complex formatting that is not optimally structured for assistive technology.
  • +
+
+ +
+

Feedback and Contact

+

+ If you encounter any accessibility barriers when using this website, please contact us: +

+ +

+ We aim to respond to accessibility feedback within 5 working days. +

+
+ +
+

Enforcement Procedure

+

+ The Equality and Human Rights Commission (EHRC) is responsible for enforcing the + Public Sector Bodies (Websites and Mobile Applications) (No. 2) Accessibility + Regulations 2018. If you are not happy with how we respond to your complaint, contact + the{' '} + + Equality Advisory and Support Service (EASS) + . +

+
+ +
+

Preparation of This Statement

+

+ This statement was prepared on 26 March 2026. It was last reviewed on 26 March 2026. +

+
+ +

+ Back to VFB Chat +

+
+
+ ) +} diff --git a/app/api/chat/route.js b/app/api/chat/route.js index feff00d..f963d49 100644 --- a/app/api/chat/route.js +++ b/app/api/chat/route.js @@ -339,8 +339,24 @@ function extractImagesFromResponseText(responseText = '') { return images } +function stripLeakedToolCallJson(text = '') { + if (!text) return text + + // Remove code-fenced JSON blocks that contain "tool_calls" or "name"+"arguments" patterns + let cleaned = text.replace(/```(?:json)?\s*\{[\s\S]*?"(?:tool_calls|name)"[\s\S]*?\}[\s\S]*?```/g, '') + + // Remove bare JSON objects that look like tool call payloads (start with { and contain "tool_calls") + cleaned = cleaned.replace(/\{[\s\S]*?"tool_calls"\s*:\s*\[[\s\S]*?\]\s*\}/g, '') + + // Clean up excess whitespace left behind + cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim() + + return cleaned || text +} + function buildSuccessfulTextResult({ responseText, responseId, toolUsage, toolRounds, outboundAllowList, graphSpecs = [] }) { - const { sanitizedText, blockedDomains } = sanitizeAssistantOutput(responseText, outboundAllowList) + const strippedText = stripLeakedToolCallJson(responseText) + const { sanitizedText, blockedDomains } = sanitizeAssistantOutput(strippedText, outboundAllowList) const { textWithoutGraphs, graphs: inlineGraphs } = extractGraphSpecsFromResponseText(sanitizedText) const linkedResponseText = linkifyFollowUpQueryItems(textWithoutGraphs) const images = extractImagesFromResponseText(linkedResponseText) @@ -2374,6 +2390,31 @@ function extractJsonCandidates(text = '') { return Array.from(new Set(candidates)) } +function normalizeToolArgValue(value) { + if (typeof value !== 'string') return value + + const trimmed = value.trim() + if (!trimmed) return value + + // If the value is a markdown link like [FBbt_00003624](https://...), + // extract just the VFB term ID from it + const canonicalId = extractCanonicalVfbTermId(trimmed) + if (canonicalId) return canonicalId + + // Also strip markdown link wrapping even for non-VFB IDs + return stripMarkdownLinkText(trimmed) +} + +function normalizeToolArgs(args) { + if (!args || typeof args !== 'object' || Array.isArray(args)) return args + + const normalized = {} + for (const [key, value] of Object.entries(args)) { + normalized[key] = normalizeToolArgValue(value) + } + return normalized +} + function normalizeRelayedToolCall(toolCall) { if (!toolCall || typeof toolCall !== 'object') return null @@ -2395,7 +2436,7 @@ function normalizeRelayedToolCall(toolCall) { args = {} } - return { name, arguments: args } + return { name, arguments: normalizeToolArgs(args) } } function parseRelayedToolCalls(responseText = '') { @@ -3168,6 +3209,43 @@ async function processResponseStream({ const trimmedResponseText = textAccumulator.trim() const looksLikeToolPayload = trimmedResponseText.startsWith('{') || trimmedResponseText.startsWith('```') + // Detect when the model describes tool usage in prose instead of actually calling them. + // Common patterns: "I will use vfb_get_term_info", "let me call vfb_run_query", etc. + const describesToolUsageWithoutCalling = toolRounds === 0 + && relayedToolCalls.length === 0 + && /\b(I (?:will|can|'ll|need to) (?:use|call|run|query|start)|let me (?:use|call|run|start|find)|Please wait for the results)\b/i.test(trimmedResponseText) + && /\bvfb_\w+\b/.test(trimmedResponseText) + + if (describesToolUsageWithoutCalling) { + // The model described what tools it would use but didn't actually produce + // tool call JSON. Re-prompt with the tool relay format instruction. + sendEvent('status', { message: 'Retrying tool execution', phase: 'llm' }) + + accumulatedItems.push({ role: 'assistant', content: textAccumulator.trim() }) + accumulatedItems.push({ + role: 'user', + content: `You described which tools to use but did not actually call them. Do not describe your plan — execute it now by returning valid JSON in this exact format:\n{"tool_calls":[{"name":"tool_name","arguments":{}}]}\n\nCall the tools you just described.` + }) + + const retryResponse = await fetch(`${apiBaseUrl}${CHAT_COMPLETIONS_ENDPOINT}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}) + }, + body: JSON.stringify(createChatCompletionsRequestBody({ + apiModel, + conversationInput: [...conversationInput, ...accumulatedItems], + allowToolRelay: true + })) + }) + + if (retryResponse.ok) { + currentResponse = retryResponse + continue + } + } + if (looksLikeToolPayload && /"tool_calls"\s*:/.test(trimmedResponseText) && relayedToolCalls.length === 0) { const clarification = await requestClarifyingFollowUp({ sendEvent, diff --git a/app/layout.js b/app/layout.js index 8c6582d..66a43d3 100644 --- a/app/layout.js +++ b/app/layout.js @@ -3,6 +3,11 @@ export const metadata = { description: 'Guardrailed chat for Virtual Fly Brain neuroanatomy queries', } +export const viewport = { + width: 'device-width', + initialScale: 1, +} + export default function RootLayout({ children }) { return ( diff --git a/app/page.js b/app/page.js index 316cae6..371e0df 100644 --- a/app/page.js +++ b/app/page.js @@ -78,7 +78,7 @@ const BasicGraphView = memo(function BasicGraphView({ graph, graphKey }) { {graph.title}
)} - + {graph?.directed !== false && ( -