Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d670360560 |
No files matched your search
@@ -57,6 +57,7 @@ jobs:
|
||||
npm run test:followup
|
||||
npm run test:vision-refusal
|
||||
npm run test:worker-readiness
|
||||
npm run test:pattern-summary
|
||||
- name: Dependency audit
|
||||
run: npm audit --audit-level=high
|
||||
- name: Syntax checks
|
||||
|
||||
@@ -119,6 +119,7 @@ npm audit --audit-level=high
|
||||
- A committed CC0 synthetic vision corpus that continuously proves every reply is schema-valid or fails closed. A schema-valid reply with no readable form or colour now **abstains** ("No confident match") instead of surfacing as a worker failure, and unparsable or out-of-range replies return plain copy — "No suggestion was applied. Continue manually, or try a clearer photo." — with no provider text, image bytes, or credentials echoed. See [tests/fixtures/vision-corpus](tests/fixtures/vision-corpus/README.md)
|
||||
- Unsaved logging drafts are retained in the session with an explicit start over, and the service worker never caches analysis requests or image bodies
|
||||
- Local browser ledger, calendar, pattern summary, JSON portability, and delete-all
|
||||
- Explainable pattern summaries: the home insight card reports the confirmed-log count and form groupings for the last 28 days, every line names the exact confirmed records behind it, following a citation opens those records, and a window below the minimum says it is too sparse instead of describing a trend. No line explains a cause, rules on food, or offers a health verdict
|
||||
- Installable/offline PWA shell for the manual and saved-ledger paths
|
||||
|
||||
## What is deliberately not faked
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createDraft, draftNotice, isDraftDirty } from './src/draft.js';
|
||||
import { changeVisualField, confirmSuggestion, createSuggestionState, provenanceCopy } from './src/suggestion.js';
|
||||
import { HARD_STOOL_QUESTIONS, createFollowUpAnswers, evaluateHardStoolFollowUp, needsHardStoolFollowUp } from './src/followup.js';
|
||||
import { visionReadiness } from './src/vision-readiness.js';
|
||||
import { buildPatternSummary } from './src/summary.js';
|
||||
|
||||
const runtimeConfig = {
|
||||
basePath: document.querySelector('meta[name="timmy-base-path"]')?.content || '/',
|
||||
@@ -96,6 +97,7 @@ function bindGlobal(){
|
||||
document.querySelectorAll('[data-view]').forEach(btn=>btn.onclick=()=>{view=btn.dataset.view;render()});
|
||||
document.querySelectorAll('[data-log]').forEach(btn=>btn.onclick=openLogger);
|
||||
document.querySelectorAll('[data-scan]').forEach(btn=>btn.onclick=openPhotoFirst);
|
||||
document.querySelectorAll('[data-statement-link]').forEach(btn=>btn.onclick=()=>showPatternRecords(btn.dataset.statementLink));
|
||||
}
|
||||
|
||||
function recentList(limit=5){
|
||||
@@ -105,9 +107,35 @@ function recentList(limit=5){
|
||||
function thisWeek(){const now=Date.now(),week=7*864e5;return entries.filter(e=>now-new Date(e.occurredAt).getTime()<week).length}
|
||||
function currentStreak(){const dates=new Set(entries.map(e=>e.occurredAt.slice(0,10)));let n=0,d=new Date();while(dates.has(d.toISOString().slice(0,10))){n++;d.setDate(d.getDate()-1)}return n}
|
||||
|
||||
// Issue #34: the home insight card is explainable. Every line says which confirmed records it was
|
||||
// built from, following a citation shows exactly those records, and a sparse window says so
|
||||
// instead of describing a trend the data cannot support.
|
||||
function patternSummaryCard(){
|
||||
const summary=buildPatternSummary(entries);
|
||||
const logs=`${summary.records.length} confirmed log${summary.records.length===1?'':'s'}`;
|
||||
const lines=summary.insufficient
|
||||
? `<p class="fine" data-summary-insufficient>${esc(summary.copy)}</p>`
|
||||
: `<ul class="pattern-statements">${summary.statements.map(statement=>`<li data-statement="${statement.id}" data-records="${statement.entryIds.length}">${esc(statement.text)} <button class="pattern-link" data-statement-link="${statement.id}">${statement.entryIds.length} record${statement.entryIds.length===1?'':'s'} ›</button></li>`).join('')}</ul>`;
|
||||
return `<section class="section"><div class="insight-card pattern-card" data-pattern-summary role="group" aria-label="Explainable pattern summary"><div class="timmy-orb"><img src="${appPath('assets/timmy.svg')}" alt=""></div><div><span class="eyebrow">Timmy noticed</span><p class="pattern-window" data-summary-window>${esc(summary.window.label)} · ${logs}</p>${lines}<p class="fine pattern-boundary">${esc(summary.boundary)}</p></div></div></section>`;
|
||||
}
|
||||
function showPatternRecords(statementId){
|
||||
const summary=buildPatternSummary(entries);
|
||||
const statement=summary.statements.find(item=>item.id===statementId);
|
||||
if(!statement)return;
|
||||
const byId=new Map(summary.records.map(record=>[record.id,record]));
|
||||
const rows=statement.entryIds.map(id=>byId.get(id)).filter(Boolean)
|
||||
.map(record=>`<article class="entry" data-summary-record data-entry-id="${esc(record.id)}"><div class="type-dot">T${record.bristolType}</div><div><strong>${formatDate(record.occurredAt)}</strong><span>Type ${record.bristolType} · ${esc(record.bucket)}</span></div></article>`).join('');
|
||||
document.querySelector('.sheet-backdrop')?.remove();
|
||||
const wrap=document.createElement('div');wrap.className='sheet-backdrop';
|
||||
wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="pattern-records-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Pattern source</span><h2 id="pattern-records-title">${esc(statement.text)}</h2></div><button class="icon-btn" id="pattern-records-close" aria-label="Close">×</button></div><div data-summary-records><p class="fine">These are the confirmed logs this line was built from.</p><div class="entries-card">${rows}</div><p class="fine">Window: ${esc(summary.window.label)}. Change or delete any of these logs and this line changes with them.</p></div></section>`;
|
||||
document.body.append(wrap);
|
||||
document.querySelector('#pattern-records-close').onclick=()=>wrap.remove();
|
||||
wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};
|
||||
}
|
||||
|
||||
function home(){
|
||||
const latest=entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt))[0];
|
||||
shell(`<main class="home-main"><section class="hero sleek-hero"><span class="eyebrow">Your intelligent pooping pal</span><h1>Log it.<br>Learn the pattern.</h1><p class="lead">Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.</p><button class="capture-cta btn-primary" data-scan><span class="capture-icon" aria-hidden="true">◉</span><span><strong>Start photo log</strong><small>Private, guided, about 10 seconds</small></span><i aria-hidden="true">→</i></button><button class="text-action" data-log>Log manually instead</button></section><section class="glance" aria-label="Journal at a glance"><div><strong>${thisWeek()}</strong><span>this week</span></div><i></i><div><strong>${currentStreak()}</strong><span>day streak</span></div><i></i><div><strong>${entries.length}</strong><span>all logs</span></div></section><section class="section"><div class="insight-card"><div class="timmy-orb"><img src="${appPath('assets/timmy.svg')}" alt=""></div><div><span class="eyebrow">Timmy noticed</span><p>${esc(buildTimmySummary(entries))}</p></div></div></section><section class="section recent-section"><div class="section-head"><div><span class="eyebrow">Latest</span><h2>${latest?'Recent log':'Ready when you are'}</h2></div>${latest?'<button class="text-action compact" data-view="calendar">View journal</button>':''}</div>${latest?`<div class="latest-card"><div class="type-dot">T${latest.bristolType}</div><div><strong>${formatDate(latest.occurredAt)}</strong><span>${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}</span></div><span class="chevron">›</span></div>`:'<p class="fine">One quick, confirmed entry is enough to begin seeing your pattern.</p>'}</section></main>`);
|
||||
shell(`<main class="home-main"><section class="hero sleek-hero"><span class="eyebrow">Your intelligent pooping pal</span><h1>Log it.<br>Learn the pattern.</h1><p class="lead">Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.</p><button class="capture-cta btn-primary" data-scan><span class="capture-icon" aria-hidden="true">◉</span><span><strong>Start photo log</strong><small>Private, guided, about 10 seconds</small></span><i aria-hidden="true">→</i></button><button class="text-action" data-log>Log manually instead</button></section><section class="glance" aria-label="Journal at a glance"><div><strong>${thisWeek()}</strong><span>this week</span></div><i></i><div><strong>${currentStreak()}</strong><span>day streak</span></div><i></i><div><strong>${entries.length}</strong><span>all logs</span></div></section>${patternSummaryCard()}<section class="section recent-section"><div class="section-head"><div><span class="eyebrow">Latest</span><h2>${latest?'Recent log':'Ready when you are'}</h2></div>${latest?'<button class="text-action compact" data-view="calendar">View journal</button>':''}</div>${latest?`<div class="latest-card"><div class="type-dot">T${latest.bristolType}</div><div><strong>${formatDate(latest.occurredAt)}</strong><span>${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}</span></div><span class="chevron">›</span></div>`:'<p class="fine">One quick, confirmed entry is enough to begin seeing your pattern.</p>'}</section></main>`);
|
||||
}
|
||||
|
||||
function calendar(){
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 394 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 380 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 256 KiB |
+3
-2
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js",
|
||||
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js",
|
||||
"test:ui": "node tests/ui.acceptance.mjs",
|
||||
"test:photo": "node tests/photo-first.acceptance.mjs",
|
||||
"test:mobile-capture": "node tests/mobile-capture.acceptance.mjs",
|
||||
@@ -14,7 +14,8 @@
|
||||
"test:followup": "node tests/followup.acceptance.mjs",
|
||||
"test:vision-refusal": "node tests/vision-refusal.acceptance.mjs",
|
||||
"test:staging-smoke": "node tests/staging.acceptance.mjs",
|
||||
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check scripts/record_worker_benchmark.mjs && node --check tests/worker-readiness.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh",
|
||||
"test:pattern-summary": "node tests/pattern-summary.acceptance.mjs",
|
||||
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check src/summary.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check scripts/record_worker_benchmark.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh",
|
||||
"check:diff": "bash scripts/check_diff.sh",
|
||||
"start": "node server.mjs",
|
||||
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs"
|
||||
|
||||
@@ -40,6 +40,8 @@ DEMO_REQUIRED_CAPTIONS = (
|
||||
"An out-of-range answer fails closed: nothing is applied and no provider detail reaches you",
|
||||
"A filled-in field that is out of range still fails closed, even when the other field came back blank",
|
||||
"Reachable is not loaded: no AI control, no consent, and no upload until the pinned model is resident",
|
||||
"Every pattern line names the confirmed logs behind it, and following one shows exactly those records",
|
||||
"Two logs is not a pattern: the summary refuses to describe a trend it cannot support",
|
||||
)
|
||||
|
||||
|
||||
@@ -138,6 +140,7 @@ def main() -> int:
|
||||
run(["npm", "run", "test:followup"], tree)
|
||||
run(["npm", "run", "test:vision-refusal"], tree)
|
||||
run(["npm", "run", "test:worker-readiness"], tree)
|
||||
run(["npm", "run", "test:pattern-summary"], tree)
|
||||
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
|
||||
caption_receipt = release_dir / f"timmy-talking-turd-{version}-demo-captions.json"
|
||||
demo_env = dict(server_env)
|
||||
@@ -179,7 +182,7 @@ def main() -> int:
|
||||
if not contact_sheet.exists() or contact_sheet.stat().st_size == 0:
|
||||
raise SystemExit("Release demo contact sheet was not produced")
|
||||
run(["npm", "audit", "--audit-level=high"], tree)
|
||||
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/vision-corpus.js", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs"):
|
||||
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/vision-corpus.js", "src/summary.js", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs", "tests/pattern-summary.acceptance.mjs"):
|
||||
run(["node", "--check", file], tree)
|
||||
run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree)
|
||||
run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree)
|
||||
@@ -218,6 +221,7 @@ def main() -> int:
|
||||
"- Self-hosted worker container: a digest-pinned image that builds the pinned llama.cpp revision, mounts hash-verified weights read-only, runs unprivileged with no credential, and is published on loopback only (issue #15)\n"
|
||||
"- Post-load readiness: the app offers AI only once the pinned model is actually loaded, so a reachable-but-unloaded worker falls back to manual logging in one tap instead of failing after the upload (issue #15)\n"
|
||||
"- Target-GPU benchmark: scripts/record_worker_benchmark.mjs records cold start, warm latency, and VRAM on the target GPU and refuses to write a receipt it did not measure; no GPU measurement is bundled with this release\n"
|
||||
"- Explainable pattern summaries (issue #34): the home insight card reports confirmed-log counts and form groupings per window, every line names the exact confirmed records behind it, following a citation opens those records, and a window below the minimum says it is too sparse instead of describing a trend; no line explains a cause, rules on food, or offers a health verdict\n"
|
||||
"- Safety: suggestions are observable-field assistance, never diagnosis; user confirmation is required\n"
|
||||
"- Known limitation: open-weight VLM acceptance is proven, Bristol accuracy is not clinically validated\n"
|
||||
"- Package excludes model weights, medical-image corpora, raw research pages, generated screenshots, and video production assets\n"
|
||||
@@ -263,7 +267,7 @@ def main() -> int:
|
||||
"codec": "h264",
|
||||
"pixel_format": "yuv420p",
|
||||
"recorded_from_working_app": True,
|
||||
"fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, user-reported hard-stool follow-up with non-diagnostic self-care and a confirmed black/tarry red flag, the CC0 synthetic vision corpus replaying a non-stool refusal, an unreadable-fields abstention, an out-of-range fail-closed reply, and a blank-plus-out-of-range fail-closed reply, offline vision status, and deterministic Hermes chat response",
|
||||
"fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, user-reported hard-stool follow-up with non-diagnostic self-care and a confirmed black/tarry red flag, the CC0 synthetic vision corpus replaying a non-stool refusal, an unreadable-fields abstention, an out-of-range fail-closed reply, and a blank-plus-out-of-range fail-closed reply, offline vision status, fictional multi-day sample logs driving the explainable pattern summary and its citation sheet, a sparse fictional window showing the insufficient-data copy, and deterministic Hermes chat response",
|
||||
},
|
||||
"gates": {
|
||||
"unit_security": "passed",
|
||||
@@ -276,6 +280,7 @@ def main() -> int:
|
||||
"vision_corpus_refusal": "passed",
|
||||
"worker_container_receipts": "passed",
|
||||
"worker_post_load_readiness": "passed",
|
||||
"longitudinal_pattern_summary": "passed",
|
||||
"dependency_audit_high": "passed",
|
||||
"syntax": "passed",
|
||||
"secret_scan": "passed",
|
||||
|
||||
@@ -314,6 +314,54 @@ await caption('Every refusal replays a committed synthetic fixture — there are
|
||||
analyzeOverride = null;
|
||||
await tap('#close-sheet', 450);
|
||||
|
||||
// Explainable pattern summary (issue #34). The recording seeds fictional multi-day sample logs into
|
||||
// the app's own local ledger contract, then follows one summary line to the exact records behind it.
|
||||
// Every claim below is checked in the browser, and no real health data is involved anywhere.
|
||||
const seedSyntheticLedger = async entries => {
|
||||
const key = await page.evaluate(() => `timmy:${document.querySelector('meta[name="timmy-base-path"]')?.content || '/'}:ledger-v1`);
|
||||
await page.evaluate(({ storeKey, list }) => localStorage.setItem(storeKey, JSON.stringify(list)), { storeKey: key, list: entries });
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
};
|
||||
const sampleDay = (id, daysAgo, bristolType) => ({
|
||||
id,
|
||||
occurredAt: new Date(Date.now() - daysAgo * 86400000).toISOString(),
|
||||
bristolType,
|
||||
color: 'brown',
|
||||
urgency: 1,
|
||||
discomfort: 1,
|
||||
note: '',
|
||||
photoDataUrl: '',
|
||||
symptoms: {},
|
||||
});
|
||||
await caption('The journal explains its own pattern lines instead of just asserting a trend', 2200);
|
||||
await seedSyntheticLedger([
|
||||
sampleDay('demo-firm', 2, 2),
|
||||
sampleDay('demo-typical-1', 5, 3),
|
||||
sampleDay('demo-typical-2', 9, 4),
|
||||
sampleDay('demo-typical-3', 12, 4),
|
||||
sampleDay('demo-old', 40, 6),
|
||||
]);
|
||||
await caption("Fictional sample logs are seeded into this browser's own storage — no real health data appears here", 2600);
|
||||
await page.locator('[data-pattern-summary]').waitFor();
|
||||
await indicate('[data-pattern-summary]');
|
||||
await caption('The summary says which confirmed logs it read, and a citation opens exactly those records', 2400);
|
||||
await indicate('[data-statement="typical"]');
|
||||
await tap('[data-statement-link="typical"]', 650);
|
||||
const citedRecords = await page.locator('[data-summary-record]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-entry-id')).sort());
|
||||
if (JSON.stringify(citedRecords) !== JSON.stringify(['demo-typical-1', 'demo-typical-2', 'demo-typical-3'])) {
|
||||
throw new Error('A summary citation must list exactly the records it cites');
|
||||
}
|
||||
await caption('Every pattern line names the confirmed logs behind it, and following one shows exactly those records', 2900);
|
||||
await indicate('[data-summary-records]');
|
||||
await tap('#pattern-records-close', 450);
|
||||
await seedSyntheticLedger([sampleDay('demo-sparse-1', 1, 4), sampleDay('demo-sparse-2', 4, 6)]);
|
||||
await page.locator('[data-summary-insufficient]').waitFor();
|
||||
await indicate('[data-summary-insufficient]');
|
||||
await caption('Two logs is not a pattern: the summary refuses to describe a trend it cannot support', 2900);
|
||||
// Restore the empty ledger the earlier scenes started from, so the rest of the recording is unchanged.
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
await caption('The threat model documents this surface, so the recording shows it live rather than describing it', 2200);
|
||||
await tap('[data-view="calendar"]', 550);
|
||||
await page.getByText(/Your private journal/i).waitFor();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { bucketForBristolType } from './domain.js';
|
||||
|
||||
export const PATTERN_WINDOW_DAYS = 28;
|
||||
// Two logs are an anecdote. Below this the summary is required to say so instead of describing a trend.
|
||||
export const MIN_WINDOW_LOGS = 3;
|
||||
|
||||
const DAY_MS = 86400000;
|
||||
|
||||
// The summary may only describe what the user recorded. No cause, no health verdict, no food ruling.
|
||||
const BOUNDARY = 'Built only from your own confirmed records in this window. Timmy reports what you logged and when — not why, and never a verdict on your health or your food.';
|
||||
|
||||
function inWindowRecords(entries, now, windowDays) {
|
||||
const startedAt = now - windowDays * DAY_MS;
|
||||
return (Array.isArray(entries) ? entries : [])
|
||||
.map(entry => ({ entry, at: new Date(entry?.occurredAt).getTime() }))
|
||||
.filter(({ at }) => Number.isFinite(at) && at <= now && at >= startedAt)
|
||||
.sort((left, right) => left.at - right.at)
|
||||
.map(({ entry, at }) => ({
|
||||
id: String(entry?.id ?? ''),
|
||||
occurredAt: new Date(at).toISOString(),
|
||||
bristolType: Number(entry?.bristolType),
|
||||
bucket: bucketForBristolType(entry?.bristolType),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildPatternSummary(entries = [], { now = Date.now(), windowDays = PATTERN_WINDOW_DAYS } = {}) {
|
||||
const records = inWindowRecords(entries, now, windowDays);
|
||||
const window = {
|
||||
days: windowDays,
|
||||
label: `last ${windowDays} days`,
|
||||
start: new Date(now - windowDays * DAY_MS).toISOString(),
|
||||
end: new Date(now).toISOString(),
|
||||
};
|
||||
|
||||
if (records.length < MIN_WINDOW_LOGS) {
|
||||
return {
|
||||
window,
|
||||
records,
|
||||
insufficient: true,
|
||||
statements: [],
|
||||
boundary: BOUNDARY,
|
||||
copy: `Not enough confirmed logs in this window yet — ${records.length} of ${MIN_WINDOW_LOGS} needed before Timmy describes a pattern.`,
|
||||
};
|
||||
}
|
||||
|
||||
const frequency = {
|
||||
id: 'frequency',
|
||||
text: `${records.length} confirmed log${records.length === 1 ? '' : 's'} in the ${window.label}.`,
|
||||
entryIds: records.map(record => record.id),
|
||||
};
|
||||
|
||||
const buckets = new Map();
|
||||
for (const record of records) {
|
||||
if (!buckets.has(record.bucket)) buckets.set(record.bucket, []);
|
||||
buckets.get(record.bucket).push(record.id);
|
||||
}
|
||||
const bucketLine = (bucket, label) => ({
|
||||
id: bucket === 'constipation' ? 'firm' : bucket,
|
||||
text: `${label}: ${buckets.get(bucket).length} of ${records.length} confirmed log${records.length === 1 ? '' : 's'}.`,
|
||||
entryIds: buckets.get(bucket),
|
||||
});
|
||||
const formLines = [
|
||||
['constipation', 'Firm side (Type 1–2)'],
|
||||
['typical', 'Typical range (Type 3–4)'],
|
||||
['loose', 'Loose side (Type 5–7)'],
|
||||
].filter(([bucket]) => buckets.has(bucket)).map(([bucket, label]) => bucketLine(bucket, label));
|
||||
// Both ends in one window is not a trend, and the summary has to say so out loud rather than
|
||||
// picking whichever side is marginally larger.
|
||||
const mixedLine = buckets.has('constipation') && buckets.has('loose') ? [{
|
||||
id: 'mixed',
|
||||
text: `Firm and loose logs both appear in the ${window.label} (${buckets.get('constipation').length} firm, ${buckets.get('loose').length} loose), which is a mixed pattern and not one trend.`,
|
||||
entryIds: records.filter(record => record.bucket === 'constipation' || record.bucket === 'loose').map(record => record.id),
|
||||
}] : [];
|
||||
|
||||
return { window, records, insufficient: false, statements: [frequency, ...formLines, ...mixedLine], copy: '', boundary: BOUNDARY };
|
||||
}
|
||||
@@ -46,3 +46,9 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t
|
||||
.followup-guidance .fine{margin:8px 0 0}
|
||||
.followup-guidance.escalated{background:var(--red-soft);border-color:#eec1ba}
|
||||
.followup-guidance.escalated>strong{color:#75261f}
|
||||
.pattern-card{align-items:start}
|
||||
.pattern-window{margin:3px 0 6px;font-size:12px;font-weight:800;color:var(--teal);letter-spacing:.02em}
|
||||
.pattern-statements{margin:0;padding:0;list-style:none}
|
||||
.pattern-statements li{font-size:13.5px;line-height:1.45;margin-bottom:7px;color:#4d453e}
|
||||
.pattern-link{display:inline-block;margin-top:3px;min-height:32px;padding:5px 10px;border:1px solid #bcdfd4;border-radius:999px;background:var(--surface);color:var(--teal);font-weight:800;font-size:11px;cursor:pointer}
|
||||
.pattern-boundary{margin:8px 0 0;color:var(--muted)}
|
||||
@@ -143,3 +143,21 @@ test('the pinned worker container, post-load gate, and GPU benchmark are gated e
|
||||
assert.match(builder, /"worker_post_load_readiness": "passed"/);
|
||||
assert.match(builder, /"scripts\/worker_ready\.sh"/);
|
||||
});
|
||||
|
||||
test('explainable pattern summaries are a unit-, browser-, and release-gated contract', async () => {
|
||||
const [packageJsonText, workflow, builder] = await Promise.all([
|
||||
readFile(packagePath, 'utf8'),
|
||||
readFile(workflowPath, 'utf8'),
|
||||
readFile(new URL('../scripts/build_release.py', import.meta.url), 'utf8'),
|
||||
]);
|
||||
const packageJson = JSON.parse(packageJsonText);
|
||||
|
||||
assert.match(packageJson.scripts.test, /tests\/summary\.test\.js/);
|
||||
assert.equal(packageJson.scripts['test:pattern-summary'], 'node tests/pattern-summary.acceptance.mjs');
|
||||
assert.match(packageJson.scripts['check:syntax'], /node --check src\/summary\.js/);
|
||||
assert.match(packageJson.scripts['check:syntax'], /node --check tests\/pattern-summary\.acceptance\.mjs/);
|
||||
assert.match(workflow, /npm run test:pattern-summary/);
|
||||
assert.match(builder, /"test:pattern-summary"/);
|
||||
assert.match(builder, /"longitudinal_pattern_summary": "passed"/);
|
||||
assert.match(builder, /"src\/summary\.js"/);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
|
||||
// Issue #34: every longitudinal summary line must point at the confirmed records behind it, and a
|
||||
// sparse window must say so instead of describing a trend. The records are fictional sample entries
|
||||
// written into the app's own local ledger contract; no real health data is involved.
|
||||
const DAY = 86400000;
|
||||
const sample = (id, daysAgo, bristolType) => ({
|
||||
id,
|
||||
occurredAt: new Date(Date.now() - daysAgo * DAY).toISOString(),
|
||||
bristolType,
|
||||
color: 'brown',
|
||||
urgency: 1,
|
||||
discomfort: 1,
|
||||
note: '',
|
||||
photoDataUrl: '',
|
||||
symptoms: {},
|
||||
});
|
||||
|
||||
await mkdir('artifacts', { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
|
||||
const seed = async entries => page.evaluate(list => {
|
||||
const base = document.querySelector('meta[name="timmy-base-path"]')?.content || '/';
|
||||
localStorage.setItem(`timmy:${base}:ledger-v1`, JSON.stringify(list));
|
||||
}, entries);
|
||||
|
||||
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
|
||||
// 1. A sparse window refuses to describe a pattern.
|
||||
await seed([sample('sparse-a', 1, 4), sample('sparse-b', 4, 6)]);
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
const sparse = page.locator('[data-summary-insufficient]');
|
||||
assert.equal(await sparse.count(), 1, 'a window below the minimum must show honest insufficient-data copy');
|
||||
assert.match(await sparse.innerText(), /not enough confirmed logs/i);
|
||||
assert.match(await sparse.innerText(), /2 of 3/);
|
||||
assert.equal(await page.locator('[data-statement]').count(), 0, 'no pattern line may be claimed from two logs');
|
||||
await page.screenshot({ path: 'artifacts/pattern-summary-insufficient-mobile.png' });
|
||||
|
||||
// 2. A populated window renders explainable lines, each citing its own records.
|
||||
const seeded = [
|
||||
sample('rec-firm', 2, 1),
|
||||
sample('rec-typical-1', 5, 3),
|
||||
sample('rec-typical-2', 9, 4),
|
||||
sample('rec-typical-3', 12, 4),
|
||||
sample('rec-old', 40, 6),
|
||||
];
|
||||
await seed(seeded);
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
const card = page.locator('[data-pattern-summary]');
|
||||
assert.equal(await card.count(), 1, 'the home insight card is the explainable summary');
|
||||
assert.match(await page.locator('[data-summary-window]').innerText(), /last 28 days/i);
|
||||
assert.match(await page.locator('[data-statement="frequency"]').innerText(), /4 confirmed logs/);
|
||||
assert.equal(await page.locator('[data-statement="firm"]').getAttribute('data-records'), '1');
|
||||
assert.equal(await page.locator('[data-statement="typical"]').getAttribute('data-records'), '3');
|
||||
// The out-of-window log is neither counted nor offered as a citation.
|
||||
assert.doesNotMatch(await card.innerText(), /5 confirmed logs/);
|
||||
await page.screenshot({ path: 'artifacts/pattern-summary-home-mobile.png' });
|
||||
|
||||
// 3. Following a citation shows exactly the contributing confirmed records.
|
||||
await page.locator('[data-statement-link="typical"]').click();
|
||||
const records = page.locator('[data-summary-records]');
|
||||
await records.waitFor();
|
||||
assert.equal(await page.locator('[data-summary-record]').count(), 3, 'the citation lists exactly its own records');
|
||||
const listed = await page.locator('[data-summary-record]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-entry-id')));
|
||||
assert.deepEqual(listed.sort(), ['rec-typical-1', 'rec-typical-2', 'rec-typical-3']);
|
||||
assert.match(await records.innerText(), /Type 3/);
|
||||
assert.match(await records.innerText(), /These are the confirmed logs this line was built from/);
|
||||
await page.screenshot({ path: 'artifacts/pattern-summary-records-mobile.png' });
|
||||
await page.locator('#pattern-records-close').click();
|
||||
assert.equal(await page.locator('[data-summary-records]').count(), 0, 'the citation sheet closes cleanly');
|
||||
|
||||
// 4. The summary never explains a cause or clears a food.
|
||||
const summaryText = await card.innerText();
|
||||
assert.doesNotMatch(summaryText, /because|due to|caused|safe to eat|avoid eating|should eat|diagnos/i);
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
await browser.close();
|
||||
console.log('PASS longitudinal pattern summaries cite their confirmed records and refuse to describe a sparse window');
|
||||
@@ -50,6 +50,27 @@ test('release demo shows the provisional, confirmed, and corrected suggestion st
|
||||
assert.match(demo, /Type 2/, 'the retained draft in the demo must show the value the user corrected to');
|
||||
});
|
||||
|
||||
test('release demo shows the explainable pattern summary and follows a citation to its own records', async () => {
|
||||
const demo = await readFile(demoPath, 'utf8');
|
||||
const builder = await readFile(builderPath, 'utf8');
|
||||
|
||||
// Issue #34: the recording must exercise the summary, its citation sheet, and the sparse case.
|
||||
assert.match(demo, /\[data-pattern-summary\]/);
|
||||
assert.match(demo, /\[data-statement-link="typical"\]/);
|
||||
assert.match(demo, /\[data-summary-record\]/);
|
||||
assert.match(demo, /\[data-summary-insufficient\]/);
|
||||
assert.match(demo, /The summary says which confirmed logs it read, and a citation opens exactly those records/);
|
||||
assert.match(demo, /Every pattern line names the confirmed logs behind it, and following one shows exactly those records/);
|
||||
assert.match(demo, /Fictional sample logs are seeded into this browser's own storage — no real health data appears here/);
|
||||
assert.match(demo, /Two logs is not a pattern: the summary refuses to describe a trend it cannot support/);
|
||||
// The cited records are asserted in the browser, not narrated.
|
||||
assert.match(demo, /must list exactly the records it cites/);
|
||||
// Both captions are required release gates, so a later release cannot drop the scene silently.
|
||||
assert.match(builder, /Every pattern line names the confirmed logs behind it, and following one shows exactly those records/);
|
||||
assert.match(builder, /Two logs is not a pattern: the summary refuses to describe a trend it cannot support/);
|
||||
assert.match(builder, /"longitudinal_pattern_summary": "passed"/);
|
||||
});
|
||||
|
||||
test('release builder gates the sleek shell, Hermes chat, and bootstrap syntax', async () => {
|
||||
const builder = await readFile(builderPath, 'utf8');
|
||||
assert.match(builder, /"test:sleek"/);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildPatternSummary } from '../src/summary.js';
|
||||
|
||||
const DAY = 86400000;
|
||||
const NOW = Date.parse('2026-09-20T12:00:00.000Z');
|
||||
const entry = (id, daysAgo, bristolType) => ({
|
||||
id,
|
||||
occurredAt: new Date(NOW - daysAgo * DAY).toISOString(),
|
||||
bristolType,
|
||||
color: 'brown',
|
||||
urgency: 0,
|
||||
discomfort: 0,
|
||||
note: '',
|
||||
symptoms: {},
|
||||
});
|
||||
|
||||
test('every summary statement cites the confirmed records it was built from', () => {
|
||||
const summary = buildPatternSummary([entry('a', 1, 4), entry('b', 3, 3), entry('c', 5, 6)], { now: NOW });
|
||||
|
||||
assert.equal(summary.insufficient, false);
|
||||
assert.equal(summary.window.label, 'last 28 days');
|
||||
|
||||
const frequency = summary.statements.find(statement => statement.id === 'frequency');
|
||||
assert.ok(frequency, 'the window needs a frequency line');
|
||||
assert.match(frequency.text, /3 confirmed logs/);
|
||||
assert.match(frequency.text, /last 28 days/);
|
||||
assert.deepEqual(frequency.entryIds, ['c', 'b', 'a']);
|
||||
|
||||
// No statement may cite an id the summary cannot point at a real record for.
|
||||
const known = new Set(summary.records.map(record => record.id));
|
||||
for (const statement of summary.statements) {
|
||||
assert.ok(statement.entryIds.length > 0, `${statement.id} must cite its contributing records`);
|
||||
for (const id of statement.entryIds) assert.ok(known.has(id), `${statement.id} cited an unknown record ${id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('each form line cites only the records in that form bucket', () => {
|
||||
const summary = buildPatternSummary([entry('a', 1, 2), entry('b', 3, 4), entry('c', 5, 4), entry('d', 6, 4)], { now: NOW });
|
||||
|
||||
const firm = summary.statements.find(statement => statement.id === 'firm');
|
||||
assert.ok(firm, 'a firm-side line is needed when firm logs exist');
|
||||
assert.match(firm.text, /Type 1–2/);
|
||||
assert.match(firm.text, /1 of 4/);
|
||||
assert.deepEqual(firm.entryIds, ['a']);
|
||||
|
||||
const typical = summary.statements.find(statement => statement.id === 'typical');
|
||||
assert.match(typical.text, /3 of 4/);
|
||||
assert.deepEqual(typical.entryIds, ['d', 'c', 'b']);
|
||||
assert.equal(summary.statements.some(statement => statement.id === 'loose'), false);
|
||||
});
|
||||
|
||||
test('a contradictory window is reported as a mixed pattern instead of one trend', () => {
|
||||
const summary = buildPatternSummary([entry('a', 1, 1), entry('b', 2, 1), entry('c', 3, 6), entry('d', 4, 7)], { now: NOW });
|
||||
|
||||
const mixed = summary.statements.find(statement => statement.id === 'mixed');
|
||||
assert.ok(mixed, 'a window with both firm and loose logs needs an explicit mixed line');
|
||||
assert.match(mixed.text, /firm and loose logs both appear/i);
|
||||
assert.match(mixed.text, /not one trend/i);
|
||||
assert.deepEqual(mixed.entryIds, ['d', 'c', 'b', 'a']);
|
||||
});
|
||||
|
||||
test('logs outside the window are neither counted nor cited', () => {
|
||||
const summary = buildPatternSummary([entry('old', 40, 1), entry('a', 1, 4), entry('b', 3, 3), entry('c', 5, 4)], { now: NOW });
|
||||
|
||||
assert.deepEqual(summary.records.map(record => record.id), ['c', 'b', 'a']);
|
||||
assert.equal(summary.statements.some(statement => statement.entryIds.includes('old')), false);
|
||||
assert.match(summary.statements.find(statement => statement.id === 'frequency').text, /3 confirmed logs/);
|
||||
|
||||
const weekly = buildPatternSummary([entry('old', 40, 1), entry('a', 1, 4), entry('b', 2, 4), entry('c', 3, 4)], { now: NOW, windowDays: 7 });
|
||||
assert.equal(weekly.window.label, 'last 7 days');
|
||||
assert.equal(weekly.window.days, 7);
|
||||
assert.deepEqual(weekly.records.map(record => record.id), ['c', 'b', 'a']);
|
||||
});
|
||||
|
||||
test('the summary states its boundary and never explains a cause or clears a food', () => {
|
||||
const summary = buildPatternSummary([entry('a', 1, 4), entry('b', 3, 3), entry('c', 5, 6)], { now: NOW });
|
||||
assert.match(summary.boundary, /your own confirmed records/i);
|
||||
|
||||
const forbidden = /because|due to|caused|triggered|safe to eat|avoid eating|should eat|clears|diagnos|treat/i;
|
||||
for (const text of [summary.boundary, summary.copy, ...summary.statements.map(statement => statement.text)]) {
|
||||
assert.doesNotMatch(text, forbidden, text);
|
||||
}
|
||||
const sparse = buildPatternSummary([entry('a', 1, 4)], { now: NOW });
|
||||
for (const text of [sparse.boundary, sparse.copy]) {
|
||||
assert.doesNotMatch(text, forbidden, text);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses to describe a pattern from too few confirmed logs', () => {
|
||||
const summary = buildPatternSummary([entry('a', 1, 4), entry('b', 3, 3)], { now: NOW });
|
||||
|
||||
assert.equal(summary.insufficient, true);
|
||||
assert.deepEqual(summary.statements, []);
|
||||
assert.match(summary.copy, /not enough confirmed logs/i);
|
||||
assert.match(summary.copy, /2 of 3/);
|
||||
// The summary still lists exactly what it did read, in the order it happened.
|
||||
assert.deepEqual(summary.records.map(record => record.id), ['b', 'a']);
|
||||
});
|
||||
Reference in new issue
Block a user