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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/api/chat/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { stripSupersededFigures } from '../../../lib/countProvenance.mjs'
import { isAggregateClassPartner } from '../../../lib/classPartners.mjs'
import { planNextAttempt } from '../../../lib/callBudget.mjs'
import { detectJailbreakRule } from '../../../lib/jailbreak.mjs'
import { safeToolArgs } from '../../../lib/safeToolArgs.mjs'
import { safeToolArgs, safeText } from '../../../lib/safeToolArgs.mjs'
import { createRunSignal, throwIfAborted, isRunAbortedWith, NOBODY_WAITING } from '../../../lib/runSignal.mjs'
import { parseScrnaseqClusters, parseClusterExpression, extractRequestedGenes, buildExpressionMatrix, renderExpressionMarkdown } from '../../../lib/scrnaseq.mjs'
import { pickSeedIndividuals, parseSimilarityHits, groupSimilarByClass } from '../../../lib/similarNeurons.mjs'
Expand Down Expand Up @@ -1203,7 +1203,7 @@ async function callMcpToolWithRetry(client, name, args, { retries = VFB_MCP_MAX_
// `spent` and `next` are the two facts that were missing when this was
// diagnosed from logs alone: a stall looks exactly like a server refusing
// three times unless the line says which budget ran out.
console.error(`[VFBchat] MCP CALL FAILED | tool=${name} | attempt=${attempt + 1}/${retries + 1} | transient=${transient} | spent=${elapsedMs}ms/${budgetMs}ms | next=${next.reason} | args=${safeToolArgs(args)} | error=${error?.message || error}`)
console.error(`[VFBchat] MCP CALL FAILED | tool=${name} | attempt=${attempt + 1}/${retries + 1} | transient=${transient} | spent=${elapsedMs}ms/${budgetMs}ms | next=${next.reason} | args=${safeToolArgs(args)} | error=${safeText(error?.message || error)}`)
if (!next.retry) throw error
attemptTimeoutMs = next.timeoutMs
if (next.waitMs > 0) await new Promise(resolve => setTimeout(resolve, next.waitMs))
Expand Down Expand Up @@ -1277,7 +1277,7 @@ async function callMcpToolTextWithForceRefresh(client, name, args, { budget } =
if (!isFailedRunQueryPayload(retryText)) return retryText
return annotateFailedRunQuery(retryText)
} catch (error) {
console.error(`[VFBchat] force_refresh retry for ${name} failed: ${error?.message || error}`)
console.error(`[VFBchat] force_refresh retry for ${name} failed: ${safeText(error?.message || error)}`)
return annotateFailedRunQuery(text)
}
}
Expand Down
2 changes: 1 addition & 1 deletion app/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -1031,7 +1031,7 @@ export default function Home() {
- Raw security logs are retained for up to 30 days
- We do not store full chat content for routine analytics
- If you report a problem, you can optionally attach the visible chat for investigation for up to 30 days
- Do not share confidential or sensitive information
- Do not share personal, confidential or sensitive information
- Use this tool to enhance your understanding of neuroscience concepts
- See the [Privacy Notice](/privacy) for more information

Expand Down
69 changes: 66 additions & 3 deletions app/privacy/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,36 @@ export default function PrivacyPage() {
VFB Chat is a public-facing AI-assisted interface to Virtual Fly Brain data.
</p>

<section style={{ marginTop: '28px' }}>
<h2 style={{ color: '#fff' }}>Who is responsible for your data</h2>
<p style={{ color: '#b8b8b8', lineHeight: 1.6 }}>
The <strong>University of Edinburgh</strong> is the data controller for the personal data
described in this notice. The University is registered with the Information
Commissioner&rsquo;s Office under registration number Z6426984. VFB Chat is operated by the
Virtual Fly Brain project in the School of Informatics.
</p>
</section>

<section style={{ marginTop: '28px' }}>
<h2 style={{ color: '#fff' }}>Our lawful basis</h2>
<p style={{ color: '#b8b8b8', lineHeight: 1.6 }}>
We process this data in the performance of a <strong>task carried out in the public
interest</strong> (UK GDPR Article 6(1)(e)). The University&rsquo;s constitution is
statutory &mdash; the Universities (Scotland) Acts 1858 to 1966 &mdash; and the Further and
Higher Education (Scotland) Act 2005 provides for the undertaking of research among
fundable bodies, of which the University is one. Operating Virtual Fly Brain as an open
research resource, and making its data reachable, falls within those functions. We process
only the minimum technical data needed to run the service securely and to show how much it
is used; the IP address held for abuse prevention is processed on the same basis, because
it exists solely to keep that service available.
</p>
<p style={{ color: '#b8b8b8', lineHeight: 1.6 }}>
Where you choose to attach a conversation to a problem report, we rely on your{' '}
<strong>consent</strong> for that transcript. You do not have to attach one, and you can
ask us to delete it at any time.
</p>
</section>

<section style={{ marginTop: '28px' }}>
<h2 style={{ color: '#fff' }}>What We Collect</h2>
<ul style={{ lineHeight: 1.7 }}>
Expand Down Expand Up @@ -67,6 +97,12 @@ export default function PrivacyPage() {
models. Individual prompts are not routinely read; ELM examines them only in exceptional circumstances,
for safeguarding or legal compliance.
</p>
<p style={{ color: '#b8b8b8', lineHeight: 1.6 }}>
To find published papers, the service may also search PubMed (US National Library of
Medicine) and bioRxiv. What is sent to them is a search term built from the Virtual Fly
Brain terms your question resolved to &mdash; not your question, and nothing that
identifies you.
</p>
<p style={{ color: '#b8b8b8', lineHeight: 1.6 }}>
Please still avoid typing personal information into your question. We ask this because it is good practice,
not because we can see it.
Expand Down Expand Up @@ -101,9 +137,36 @@ export default function PrivacyPage() {
<section style={{ marginTop: '28px' }}>
<h2 style={{ color: '#fff' }}>Your Rights and Contact</h2>
<p style={{ color: '#b8b8b8', lineHeight: 1.6 }}>
Every answer carries a response ID, shown beneath it. We hold no account and no name for you,
so that ID is the only way we can find the records relating to a particular exchange. Quote it
when you ask what we hold about one, or ask for it to be deleted.
Under data protection law you have the right to:
</p>
<ul style={{ lineHeight: 1.7 }}>
<li><strong>Access</strong> &mdash; ask for a copy of the personal data we hold about you</li>
<li><strong>Rectification</strong> &mdash; ask us to correct data that is inaccurate</li>
<li><strong>Erasure</strong> &mdash; ask us to delete data we hold about you</li>
<li><strong>Restriction</strong> &mdash; ask us to limit how we use it</li>
<li><strong>Objection</strong> &mdash; object to our processing it</li>
<li><strong>Portability</strong> &mdash; where it applies, receive it in a structured, machine-readable form</li>
<li><strong>Withdraw consent</strong> &mdash; where we rely on consent, which for this service means a conversation you attached to a problem report</li>
</ul>
<p style={{ color: '#b8b8b8', lineHeight: 1.6 }}>
<strong>How to exercise them.</strong> Every answer carries a response ID, shown beneath it.
We hold no account and no name for you, so that ID is the only way we can find the records
relating to a particular exchange &mdash; quote it when you ask what we hold, or ask for it
to be deleted. Because we hold nothing that identifies you, there will be cases where we
cannot locate any data for a request; where that happens we will say so.
</p>
<p style={{ color: '#b8b8b8', lineHeight: 1.6 }}>
<strong>If you are unhappy with how we have handled your data</strong>, please contact the
University&rsquo;s Data Protection Officer using the details below. You also have the right
to complain to the UK supervisory authority, the{' '}
<a
href="https://ico.org.uk/make-a-complaint/"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#66d9ff', textDecoration: 'underline' }}
>
Information Commissioner&rsquo;s Office
</a>, at any time.
</p>
<p style={{ color: '#b8b8b8', lineHeight: 1.6 }}>
For the main Virtual Fly Brain website privacy notice and broader policy information, see{' '}
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ services:
- 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}
- NCBI_EMAIL=${NCBI_EMAIL:-vfb@virtualflybrain.org}
- GA_MEASUREMENT_ID=${GA_MEASUREMENT_ID}
- GA_API_SECRET=${GA_API_SECRET}
volumes:
Expand Down
32 changes: 21 additions & 11 deletions lib/governance.js
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,15 @@ export function recordAnalyticsEvent(event) {
total_images: 0,
total_citations: 0,
tool_usage: {},
blocked_requested_domains: {},
blocked_response_domains: {},
// Counts only. The hostnames themselves are NOT kept here.
//
// A blocked "requested" domain is a hostname the user typed into a question,
// extracted by regex before any outbound call is made. This store is the
// 26-month tier, and a fragment of what someone wrote does not belong in it —
// the 30-day security and blocked-search logs already hold the names, which
// is where an abuse investigation would look anyway.
blocked_requested_domain_count: 0,
blocked_response_domain_count: 0,
vfb_term_ids: {}
}

Expand All @@ -311,21 +318,14 @@ export function recordAnalyticsEvent(event) {
incrementMap(bucket.tool_usage, toolName, Number(count) || 0)
}

for (const hostname of blockedRequested) {
incrementMap(bucket.blocked_requested_domains, hostname, 1)
}

for (const hostname of blockedResponse) {
incrementMap(bucket.blocked_response_domains, hostname, 1)
}
bucket.blocked_requested_domain_count += blockedRequested.length
bucket.blocked_response_domain_count += blockedResponse.length

for (const termId of event.vfbTermIds || []) {
incrementMap(bucket.vfb_term_ids, termId, 1)
}

bucket.tool_usage = normalizeCountMap(bucket.tool_usage)
bucket.blocked_requested_domains = normalizeCountMap(bucket.blocked_requested_domains)
bucket.blocked_response_domains = normalizeCountMap(bucket.blocked_response_domains)
bucket.vfb_term_ids = normalizeCountMap(bucket.vfb_term_ids)

data.buckets[bucketKey] = bucket
Expand Down Expand Up @@ -373,6 +373,16 @@ export async function sendStructuredTelemetry(event) {
const gaConfig = getGoogleAnalyticsConfig()
if (!gaConfig.enabled) return

// Everything here is generalised or drawn from VFB's own published vocabulary.
// Nothing a user typed reaches this payload.
//
// Two fields are worth being explicit about, because they look like content
// and are not. `topic_category` is a classification into one of six fixed
// values — anatomy, connectivity, gene expression, images, publications,
// how-to — so no text can travel through it. `vfb_term_ids` is matched out of
// the ANSWER, not the question, by /\b(?:VFB|FBbt)_\d{8}\b/: VFB and FlyBase
// ontology identifiers from VFB's own public data, and structurally incapable
// of carrying anything else.
const payload = {
client_id: event.requestId || createRequestId(),
events: [{
Expand Down
10 changes: 8 additions & 2 deletions lib/liveHarness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// See outputs/reports/vfbchat-harness-design.md §4, §8, §9.

import { runHarness } from './orchestrator.mjs'
import { safeToolArgs } from './safeToolArgs.mjs'
import { safeToolArgs, safeText } from './safeToolArgs.mjs'
import { throwIfAborted, isRunAbortedWith } from './runSignal.mjs'

// The largest MCP result this process will turn into a JavaScript object graph.
Expand Down Expand Up @@ -162,8 +162,14 @@ export function buildLiveDeps(p) {
// shape-only: search_reviewed_docs is called with the whole user question as
// its `query` on every turn, so JSON.stringify(args) here put the question
// verbatim into the cluster log on any doc-search blip.
//
// The ERROR needs the same treatment, and did not have it. An upstream
// message quotes what it was given: get_reviewed_page takes a URL whose
// path is unconstrained — only the host must be allow-listed — and the
// fetcher throws that URL back. So a redacted `args` sat next to an
// unredacted `error` carrying the same value. Seen in a public CI log.
try {
console.error(`[VFBchat] TOOL FAILED | tool=${name} | args=${safeToolArgs(args)} | error=${msg}`)
console.error(`[VFBchat] TOOL FAILED | tool=${name} | args=${safeToolArgs(args)} | error=${safeText(msg)}`)
} catch { /* logging best-effort */ }
return { error: msg, tool: name }
}
Expand Down
10 changes: 9 additions & 1 deletion lib/safeToolArgs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ const SAFE_KEYS = new Set([
// No whitespace. An id, a query type and a template name have none; a sentence
// does. A safe KEY carrying a sentence is still a sentence — "the neuron I was
// looking at earlier" arriving as `id` is the user's prose, not an identifier.
const SAFE_VALUE_RE = /^[\w.,:/-]{0,80}$/
//
// Absence of whitespace was the ONLY test here, and it is not enough: a URL, a
// filesystem path, or a 70-character lab-internal string all pass it, and all of
// them are something the user typed. So the rule is identifier-SHAPED rather
// than merely space-free — no `/` or `:`, which is what excludes URLs and paths,
// and 40 characters rather than 80, comfortably longer than any VFB id, query
// type, template or gene symbol and shorter than a phrase worth logging.
// Anything outside that is rendered as its length, exactly as before.
const SAFE_VALUE_RE = /^[\w.,+-]{0,40}$/

/**
* A free-text value rendered for a container log: its length, not its content.
Expand Down
Loading