Files
timmy b838beb548
Quality gates / quality (pull_request) Failing after 2m50s
feat: ground Timmy chat answers in cited ledger records (#37)
Chat now has to show its work. Every answer that states a count, a date, or a
visible form cites the confirmed records behind it, and following a citation
opens exactly that log. Questions the journal cannot answer (food safety,
medicine, what a symptom means) get boundary copy with no borrowed evidence,
and a sparse window refuses to describe a trend instead of naming one.

The Hermes path is held to the same rule: the server gives each ledger record a
positional ref, the prompt requires inline [entry:L<n>] citations, and the
browser resolves them against the ledger it sent. A ref that matches no record
is stripped and the answer is labelled unverified rather than rendered as a
source, so an invented citation cannot look like evidence.

The release recording now exercises the grounded local answer, its citation
sheet, the refusal, and a Hermes reply whose refs resolve, each asserted in the
browser with fictional logs.
2026-10-18 08:17:40 +00:00

155 lines
5.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { answerFromLedger, buildChatLedger, resolveAgentReply } from '../src/grounded-chat.js';
const DAY = 86400000;
const NOW = Date.parse('2026-09-20T12:00:00.000Z');
const entry = (id, daysAgo, bristolType, extra = {}) => ({
id,
occurredAt: new Date(NOW - daysAgo * DAY).toISOString(),
bristolType,
color: 'brown',
urgency: 1,
discomfort: 1,
note: '',
photoDataUrl: '',
symptoms: {},
...extra,
});
test('a frequency question answers with the count from the window and cites exactly those logs', () => {
const answer = answerFromLedger(
'How many times have I logged in the last four weeks?',
[entry('a', 2, 4), entry('b', 5, 3), entry('c', 9, 6), entry('d', 12, 4), entry('old', 40, 2)],
{ now: NOW },
);
assert.equal(answer.kind, 'frequency');
assert.equal(answer.grounded, true);
assert.match(answer.text, /4 confirmed logs/);
assert.match(answer.text, /last 28 days/);
assert.deepEqual(answer.citations.map(citation => citation.id).sort(), ['a', 'b', 'c', 'd']);
// The out-of-window log is never counted and never offered as a source.
assert.equal(answer.citations.some(citation => citation.id === 'old'), false);
});
test('a recency question cites the one log it names, even when the ledger is out of order', () => {
const answer = answerFromLedger(
'When did I last log something?',
[entry('middle', 6, 5), entry('newest', 1, 3), entry('older', 11, 2)],
{ now: NOW },
);
assert.equal(answer.kind, 'latest');
assert.equal(answer.grounded, true);
assert.equal(answer.citations.length, 1);
assert.equal(answer.citations[0].id, 'newest');
assert.equal(answer.citations[0].bristolType, 3);
assert.match(answer.text, /most recent confirmed log/i);
assert.match(answer.text, /Type 3/);
});
test('a question the journal cannot answer refuses instead of reassuring', () => {
const answer = answerFromLedger(
'Is it safe to eat tacos with this?',
[entry('a', 2, 4), entry('b', 5, 3), entry('c', 9, 6)],
{ now: NOW },
);
assert.equal(answer.kind, 'unsupported');
assert.equal(answer.grounded, false);
assert.deepEqual(answer.citations, []);
assert.match(answer.text, /cannot (clear|decide|tell)/i);
// Boundary copy may not invent a verdict about health or food.
assert.doesNotMatch(answer.text, /safe to eat|it is fine|you should eat|diagnos/i);
});
test('a pattern question on a sparse window refuses to describe a trend', () => {
const answer = answerFromLedger(
'What pattern do you see?',
[entry('a', 2, 4), entry('b', 5, 6)],
{ now: NOW },
);
assert.equal(answer.kind, 'insufficient');
assert.equal(answer.grounded, false);
assert.deepEqual(answer.citations, []);
assert.match(answer.text, /not enough confirmed logs/i);
assert.match(answer.text, /2 of 3/);
});
test('a pattern question cites every confirmed log the described pattern rests on', () => {
const answer = answerFromLedger(
'What pattern do you see?',
[entry('a', 2, 1), entry('b', 5, 4), entry('c', 9, 4), entry('d', 12, 4)],
{ now: NOW },
);
assert.equal(answer.kind, 'pattern');
assert.equal(answer.grounded, true);
assert.match(answer.text, /4 confirmed logs/);
assert.match(answer.text, /Firm side \(Type 1–2\): 1 of 4/);
assert.deepEqual(answer.citations.map(citation => citation.id).sort(), ['a', 'b', 'c', 'd']);
});
test('a Hermes reply resolves the record references it cites and never shows the raw marker', () => {
const { chatRecords } = buildChatLedger([entry('a', 2, 4), entry('b', 5, 3)], { now: NOW });
const resolved = resolveAgentReply(
'Your newest Type 4 log is the one from this week [entry:L1], and the Type 3 before it [entry:L2].',
chatRecords,
);
assert.equal(resolved.text.includes('[entry:'), false);
assert.match(resolved.text, /newest Type 4 log/);
assert.deepEqual(resolved.citations.map(citation => citation.id), ['a', 'b']);
assert.equal(resolved.unverified, false);
});
test('a citation to a record that is not in the ledger is dropped instead of being shown as a source', () => {
const { chatRecords } = buildChatLedger([entry('a', 2, 4)], { now: NOW });
const resolved = resolveAgentReply('You had 9 logs last month [entry:L99].', chatRecords);
assert.equal(resolved.text.includes('[entry:'), false);
assert.deepEqual(resolved.citations, []);
assert.equal(resolved.unresolvedRefs, 1);
assert.equal(resolved.unverified, true);
});
test('an uncited claim about the journal is flagged as unverified rather than presented as fact', () => {
const { chatRecords } = buildChatLedger([entry('a', 2, 4), entry('b', 5, 3)], { now: NOW });
const resolved = resolveAgentReply('You logged 7 times this week and you are improving.', chatRecords);
assert.equal(resolved.unverified, true);
assert.deepEqual(resolved.citations, []);
assert.match(resolved.notice, /not verified against your journal/i);
});
test('a privacy question answers with the stored-data boundary instead of the journal summary', () => {
const answer = answerFromLedger('What happens to my photo?', [entry('a', 2, 4), entry('b', 5, 3), entry('c', 9, 6)], { now: NOW });
assert.equal(answer.kind, 'privacy');
assert.equal(answer.grounded, false);
assert.deepEqual(answer.citations, []);
assert.match(answer.text, /browser/i);
assert.match(answer.text, /never receives photos/i);
});
test('an unmatched question still gets a grounded answer instead of an empty bubble', () => {
const answer = answerFromLedger('hello', [entry('a', 2, 4), entry('b', 5, 3), entry('c', 9, 6)], { now: NOW });
assert.equal(answer.kind, 'summary');
assert.equal(answer.grounded, true);
assert.ok(answer.text.length > 20, 'a general question must not produce an empty reply');
assert.equal(answer.citations.length, 3);
const empty = answerFromLedger('hello', [], { now: NOW });
assert.equal(empty.grounded, false);
assert.deepEqual(empty.citations, []);
assert.match(empty.text, /no confirmed logs|not enough/i);
});