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
48 changes: 48 additions & 0 deletions app/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,53 @@ function FeedbackPrompt({ msg, feedbackState, onSubmitHelpful, onSelectNeedsWork
)
}

/**
* The identifier for one answer, shown so it can be quoted.
*
* The DPIA describes a data-subject-rights route in which someone asks about
* the data held on a particular exchange by quoting its response identifier.
* The identifier was generated and stored from the beginning and never shown,
* so the route could not be exercised and an assessor could disprove it in a
* browser in under a minute.
*
* On EVERY answer, not only the latest. The feedback prompt is deliberately
* rendered once at the foot of the conversation, but the response someone wants
* to ask about is often not the last one, and an identifier that is only
* available for the newest answer does not support the route the DPIA sets out.
*
* Quiet by design — 0.68em, muted, below the sources line — and selectable, with
* a copy button for the common case. The clipboard API is unavailable over plain
* HTTP and in some embedded browsers, so the text itself is always selectable
* and the button degrades to saying so rather than to nothing.
*/
function ResponseIdentifier({ responseId }) {
const [copied, setCopied] = useState('')
const copy = useCallback(() => {
const write = globalThis.navigator?.clipboard?.writeText
if (typeof write !== 'function') { setCopied('select and copy'); return }
globalThis.navigator.clipboard.writeText(responseId)
.then(() => setCopied('copied'))
.catch(() => setCopied('select and copy'))
}, [responseId])
return (
<div style={{ marginTop: '8px', fontSize: '0.68em', color: '#6f6f6f', display: 'flex', alignItems: 'baseline', gap: '6px', flexWrap: 'wrap' }}>
<span>Response ID:</span>
<code style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', color: '#9a9a9a', userSelect: 'all' }}>
{responseId}
</code>
<button
type="button"
onClick={copy}
aria-label={`Copy response ID ${responseId}`}
style={{ background: 'none', border: '1px solid #2c2c2c', borderRadius: '4px', color: '#8f8f8f', cursor: 'pointer', fontSize: '1em', padding: '1px 6px' }}
>
Copy
</button>
{copied ? <span role="status">{copied}</span> : null}
</div>
)
}

// Only re-renders when its own props change, NOT when sibling messages
// are added or the thinking indicator ticks.
const ChatMessage = memo(function ChatMessage({
Expand Down Expand Up @@ -816,6 +863,7 @@ const ChatMessage = memo(function ChatMessage({
))}
</div>
)}
{msg.role === 'assistant' && msg.responseId ? <ResponseIdentifier responseId={msg.responseId} /> : null}
</div>
)
})
Expand Down
5 changes: 5 additions & 0 deletions app/privacy/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ 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.
</p>
<p style={{ color: '#b8b8b8', lineHeight: 1.6 }}>
For the main Virtual Fly Brain website privacy notice and broader policy information, see{' '}
<a
Expand Down
42 changes: 32 additions & 10 deletions lib/absence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,27 @@ export function absenceLicence(ledger) {
const DB = '(?:VFB|Virtual Fly Brain|the database|the current data|VFB\'s (?:records|data|holdings))'
const GAP = '[^.!?\\n]{0,30}'

/** One absence pattern and the short, stable name it is logged under. */
const entry = (slug, re) => ({ slug, re })

// EACH PATTERN CARRIES A SLUG, AND THE SLUG IS WHAT GETS LOGGED.
//
// The escalation and gate lines used to print the matched sentence itself. That
// sentence is prose the model wrote, and the model writes an absence by
// restating the question — so the user's wording came back out of it and into
// the cluster log aggregator, outside governance.js's file mode and outside
// pruneRetention. Logging the sentence's LENGTH would have closed that and left
// nothing behind; logging which pattern matched keeps the more useful half. The
// rate at which each SHAPE of denial fires is a property of the deployment, it
// is countable across runs in a way a free-text sentence never was, and it
// contains nothing of the user's.
const ABSENCE_PATTERNS = [
// VFB does not (currently) hold / have / contain / include / provide / list …
new RegExp(`${DB}${GAP}\\b(?:does not|doesn't|do not|don't)\\b[^.!?\\n]{0,20}\\b(?:hold|have|contain|include|provide|list|store|offer|record)s?\\b`, 'i'),
entry('does-not-hold', new RegExp(`${DB}${GAP}\\b(?:does not|doesn't|do not|don't)\\b[^.!?\\n]{0,20}\\b(?:hold|have|contain|include|provide|list|store|offer|record)s?\\b`, 'i')),
// VFB has no … / there are no records in VFB … / no data is available in VFB
new RegExp(`${DB}${GAP}\\b(?:has|have|holds?|contains?)\\s+no\\b`, 'i'),
new RegExp(`\\bno\\s+(?:data|records?|information|images?|entries|annotations?)\\b${GAP}\\b(?:in|for|from|within)\\s+${DB}`, 'i'),
new RegExp(`${DB}${GAP}\\b(?:lacks|is missing|are missing)\\b`, 'i'),
entry('has-no', new RegExp(`${DB}${GAP}\\b(?:has|have|holds?|contains?)\\s+no\\b`, 'i')),
entry('no-data-in', new RegExp(`\\bno\\s+(?:data|records?|information|images?|entries|annotations?)\\b${GAP}\\b(?:in|for|from|within)\\s+${DB}`, 'i')),
entry('lacks', new RegExp(`${DB}${GAP}\\b(?:lacks|is missing|are missing)\\b`, 'i')),
// … are not present in / not available in / not provided in the current data.
//
// "provided", "listed" and "given" were not in this list and a live run walked
Expand All @@ -173,9 +187,9 @@ const ABSENCE_PATTERNS = [
// number of lineage clones, are not provided in the current data" — every one
// of which VFB holds and advertises a query for. The verb a model reaches for
// is not fixed, so the list has to cover the family rather than the instance.
new RegExp(`\\b(?:is|are|were|was)\\s+not\\s+(?:present|available|found|included|recorded|annotated|provided|listed|given|shown|reported|captured|specified|detailed)\\b${GAP}\\b(?:in|for|within|by)\\s+${DB}`, 'i'),
entry('is-not-present-in', new RegExp(`\\b(?:is|are|were|was)\\s+not\\s+(?:present|available|found|included|recorded|annotated|provided|listed|given|shown|reported|captured|specified|detailed)\\b${GAP}\\b(?:in|for|within|by)\\s+${DB}`, 'i')),
// "no such records exist" / "there is no record of X" with VFB nearby
new RegExp(`\\bthere (?:is|are) no\\b${GAP}\\b(?:record|data|entry|entries|image)s?\\b`, 'i'),
entry('there-is-no', new RegExp(`\\bthere (?:is|are) no\\b${GAP}\\b(?:record|data|entry|entries|image)s?\\b`, 'i')),
// THE EVIDENCE BLOCK, TALKING ABOUT ITSELF, IN VFB'S NAME.
//
// harnessFraming rewrites "the provided evidence" to "VFB evidence", which
Expand All @@ -193,7 +207,7 @@ const ABSENCE_PATTERNS = [
// working set, and the gap between subject and negation is long here — the
// sentence recites a whole catalogue first — so this pattern gets a long window
// that the specificity of its subject pays for.
new RegExp('\\b(?:VFB|the|this)\\s+evidence\\b[^.!?\\n]{0,160}?\\b(?:does not|doesn\'t|do not)\\s+(?:include|contain|provide|list|show|specify|report)\\b', 'i')
entry('evidence-does-not', new RegExp('\\b(?:VFB|the|this)\\s+evidence\\b[^.!?\\n]{0,160}?\\b(?:does not|doesn\'t|do not)\\s+(?:include|contain|provide|list|show|specify|report)\\b', 'i'))
]

/** Split into sentences, keeping each sentence's offset so a repair can be spliced back. */
Expand All @@ -219,8 +233,12 @@ export function findAbsenceClaims(text = '') {
const src = String(text)
const masked = src.replace(/\[([^\]]*)\]\([^)]*\)/g, (m, label) => label + ' '.repeat(m.length - label.length))
return sentences(masked)
.filter(s => ABSENCE_PATTERNS.some(re => re.test(s.text)))
.map(s => ({ ...s, text: src.slice(s.start, s.end) }))
.map(s => ({ s, hit: ABSENCE_PATTERNS.find(p => p.re.test(s.text)) }))
.filter(({ hit }) => Boolean(hit))
// `pattern` is the slug of the FIRST pattern that matched. First rather than
// all of them: the patterns overlap by design and a list of three slugs says
// less than the most specific one that fired.
.map(({ s, hit }) => ({ ...s, text: src.slice(s.start, s.end), pattern: hit.slug }))
}

// --- saying something true instead -------------------------------------------
Expand Down Expand Up @@ -331,7 +349,11 @@ export function repairUnlicensedAbsences(answerText = '', licence) {

return {
text: cleaned.trim() ? cleaned : replacement,
repairs: claims.map(c => c.text.trim())
repairs: claims.map(c => c.text.trim()),
// The same repairs named by pattern rather than quoted. Callers that write
// to the container log use these; the strings above are for the trace, which
// is off unless VFB_HARNESS_TRACE is set.
patterns: claims.map(c => c.pattern)
}
}

Expand Down
18 changes: 13 additions & 5 deletions lib/orchestrator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { splitMarkdownCell, stripMarkdownLinks } from './markdownLinks.mjs'
import { summariseSimilarity } from './similarNeurons.mjs'
import { summariseDatasetBreakdown, isDatasetBreakdownQuestion, withoutDatasetSenseConnectome } from './datasetAxis.mjs'
import { recordObservedCount } from './countProvenance.mjs'
import { safeText } from './safeToolArgs.mjs'
import { summariseClassPartners, isClassConnectivityPayload } from './classPartners.mjs'
import { synthGuidance } from './guidanceCards.mjs'
import { isIndividualImageQuery, querySemantics, isGeneExpressionQuestion, isDriverLineQuestion, isSplitGal4Question, isAboutVfbItself, asksIntrinsic, INTRINSIC_RE } from './queryTypes.mjs'
Expand Down Expand Up @@ -554,8 +555,13 @@ export async function maybeEscalateBeforeAbsence(ledger, answer, deps, log = ()
// On stderr as well as the trace. The trace is per-request and only reachable
// from a debug flag; the rate at which answers try to deny data is a property
// of the deployment, and it belongs where the grounding audit already is.
// The QUERIES are VFB's own vocabulary. The claimed SENTENCE is prose the
// model wrote by restating the question, so what goes to the log is which
// absence pattern it matched and how long it was — countable across runs,
// and carrying none of the user's wording. The sentence itself is in the
// trace, which is off unless VFB_HARNESS_TRACE is set.
console.error(`[VFBchat] ABSENCE ESCALATION | queries=${picks.map(p => `${p.query_type}@${p.id}`).join(',')}`
+ ` | claimed="${claims[0].text.trim().slice(0, 120)}"`)
+ ` | claimed=absence:${claims[0].pattern} | len=${claims[0].text.trim().length}`)
// The draft that denied the data has already been streamed. It is about to be
// rewritten, so take it off the screen first — otherwise the replacement is
// appended to it and the reader gets both.
Expand All @@ -581,10 +587,10 @@ export function gateAbsence(ledger, answer, log = () => {}) {
if (!text.trim()) return answer
try {
const licence = absenceLicence(ledger)
const { text: repaired, repairs } = repairUnlicensedAbsences(text, licence)
const { text: repaired, repairs, patterns } = repairUnlicensedAbsences(text, licence)
if (!repairs.length) return answer
log({ step: 'absence-gate', removed: repairs.length, first: repairs[0].slice(0, 140) })
console.error(`[VFBchat] ABSENCE GATE | removed=${repairs.length} | licensed=false | ${repairs[0].slice(0, 160)}`)
console.error(`[VFBchat] ABSENCE GATE | removed=${repairs.length} | licensed=false | absence:${patterns.join(',')}`)
return repaired
} catch (e) {
// A guard that throws must not cost the answer.
Expand Down Expand Up @@ -944,7 +950,7 @@ async function resolveTerms(ledger, names, deps, models, log, speculative = new
record = repRecord
effectiveId = rep.id
publications = extractPublicationRefs(repFetched || {})
console.error(`[VFBchat] deprecated term redirected | term="${name}" ${id} -> ${rep.id}`)
console.error(`[VFBchat] deprecated term redirected | term=${safeText(name)} ${id} -> ${rep.id}`)
}
}
}
Expand Down Expand Up @@ -989,7 +995,9 @@ async function resolveTerms(ledger, names, deps, models, log, speculative = new
const fid = String(fetchedId || '')
const mismatched = /^(FBbt|VFB|FBgn|FBal|FBti|FBtp|FBco)_/.test(fid) && shortId(fid) !== shortId(effectiveId)
if (/^(error|threw|null)/.test(fid) || mismatched) {
console.error(`[VFBchat] get_term_info FAILED | term="${name}" requested_id=${effectiveId} returned=${fid} | digest=${digest ? 'kept' : 'discarded'}`)
// The ids identify the term precisely and are VFB's own; the requested NAME
// is often the user's phrase, so it is rendered by shape.
console.error(`[VFBchat] get_term_info FAILED | term=${safeText(name)} requested_id=${effectiveId} returned=${fid} | digest=${digest ? 'kept' : 'discarded'}`)
}

// Registry: the term-info Name (canonical) and every query-result row label
Expand Down
22 changes: 22 additions & 0 deletions lib/safeToolArgs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ const SAFE_KEYS = new Set([
// looking at earlier" arriving as `id` is the user's prose, not an identifier.
const SAFE_VALUE_RE = /^[\w.,:/-]{0,80}$/

/**
* A free-text value rendered for a container log: its length, not its content.
*
* safeToolArgs solved this for tool ARGUMENTS. The same rule applies to every
* other free-text value a diagnostic wants to print, and those arrived
* separately and were not covered: the term name in the term-info failure
* report, and the verbatim absence sentence in the escalation and gate lines.
* A term name is often the user's own phrase, and an absence sentence is prose
* the model wrote by restating the question — "VFB does not currently hold data
* on the line from Kyoto that labels PAM neurons" puts the question in the
* cluster log aggregator, outside governance.js's 0600 file mode and outside
* the 30-day pruneRetention.
*
* Under VFB_HARNESS_TRACE the value is printed in full, exactly as safeToolArgs
* does, so the diagnostic loses nothing when someone is actually debugging.
*/
export function safeText(value, { trace = process.env.VFB_HARNESS_TRACE === 'true', max = 200 } = {}) {
const s = value === null || value === undefined ? '' : String(value)
if (trace) return s.slice(0, max)
return `<text:${s.length}>`
}

/**
* A one-line, log-safe rendering of a tool's arguments.
* `{ query: 'does the mutant line from Kyoto label PAM neurons?', max_results: 5 }`
Expand Down
Loading
Loading