Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b267c0fff | ||
|
|
b4be7049ea | ||
|
|
a033cb3ae9 | ||
|
|
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 tests/escalation-regression.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"
|
||||
|
||||
@@ -37,9 +37,14 @@ DEMO_REQUIRED_CAPTIONS = (
|
||||
"Delete all local data clears the ledger in this browser; there is no server copy of it to keep",
|
||||
"Hermes Agent connected — free-text, contextual, and server-side",
|
||||
"Urgent language is intercepted deterministically before Hermes",
|
||||
"Bleeding reported as wiping still escalates, in the app’s own fixed words",
|
||||
"Tarry and black stool wording escalates too: one red flag, one unchanging message",
|
||||
"A tarry note already saved in the journal escalates the same way, before any provider call",
|
||||
"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 +143,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 +185,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 +224,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 +270,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 +283,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();
|
||||
@@ -337,6 +385,44 @@ await tap('#send-chat', 450);
|
||||
await page.getByText(/Pause and get medical help/i).waitFor();
|
||||
if (hermesCalls !== 1) throw new Error('Urgent chat must be intercepted before Hermes');
|
||||
await caption('Urgent language is intercepted deterministically before Hermes', 1800);
|
||||
|
||||
// Issue #21 — the escalation has to fire for the phrasing people actually use, not only for the one
|
||||
// word the previous scene typed. Each line below is asserted in the browser: exactly one new Timmy
|
||||
// bubble, carrying the app's own fixed copy, with hermesCalls unmoved.
|
||||
const escalationBubbles = () => page.locator('.bubble.timmy', { hasText: 'Pause and get medical help' }).count();
|
||||
const expectEscalation = async (text, label) => {
|
||||
const before = await escalationBubbles();
|
||||
await page.locator('#chat-message').fill(text);
|
||||
await tap('#send-chat', 450);
|
||||
const bubble = page.locator('.bubble.timmy', { hasText: 'Pause and get medical help' }).last();
|
||||
await bubble.waitFor();
|
||||
if (await escalationBubbles() !== before + 1) throw new Error(`${label}: expected exactly one escalation bubble`);
|
||||
if (!/^Pause and get medical help\. These reported symptoms can need prompt medical care\./.test(await bubble.innerText())) {
|
||||
throw new Error(`${label}: the escalation copy must be the app's own fixed string`);
|
||||
}
|
||||
if (hermesCalls !== 1) throw new Error(`${label}: an escalated turn must not reach Hermes`);
|
||||
};
|
||||
await expectEscalation('There is blood when I wipe', 'bleeding reported as wiping');
|
||||
await caption('Bleeding reported as wiping still escalates, in the app’s own fixed words', 2600);
|
||||
await expectEscalation('I have tarry stools', 'tarry stool');
|
||||
await caption('Tarry and black stool wording escalates too: one red flag, one unchanging message', 2600);
|
||||
|
||||
// The same detector runs over the confirmed ledger, so a symptom already written down as a note
|
||||
// escalates even when the question itself is innocuous. The seeded entry is fictional and stays in
|
||||
// this browser, exactly like the summary scene above.
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await seedSyntheticLedger([{ ...sampleDay('demo-tarry-note', 1, 4), note: 'stool was tarry yesterday' }]);
|
||||
await tap('[data-view="timmy"]', 550);
|
||||
const ledgerEscalations = await escalationBubbles();
|
||||
await page.locator('#chat-message').fill('What do my recent logs show?');
|
||||
await tap('#send-chat', 450);
|
||||
await page.locator('.bubble.timmy', { hasText: 'Pause and get medical help' }).last().waitFor();
|
||||
if (await escalationBubbles() !== ledgerEscalations + 1) throw new Error('a tarry note in the journal must escalate the next answer');
|
||||
if (hermesCalls !== 1) throw new Error('a ledger red flag must be intercepted before Hermes');
|
||||
await caption('A tarry note already saved in the journal escalates the same way, before any provider call', 2800);
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
await tap('[data-view="timmy"]', 500);
|
||||
await sleep(400);
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('#demo-caption')?.remove();
|
||||
|
||||
+50
-6
@@ -1,14 +1,58 @@
|
||||
const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas'];
|
||||
const URGENT_MESSAGE = 'These reported symptoms can need prompt medical care. Contact a clinician or urgent service now; call emergency services for heavy or nonstop bleeding, fainting, or severe worsening symptoms.';
|
||||
// Bleeding is reported in more shapes than "blood in my stool": wiping, toilet paper, or the
|
||||
// anatomy word the person actually uses. Each alternative still requires a bowel or wiping
|
||||
// context, and the near-miss wording that must stay quiet ("a paper cut", "blood pressure")
|
||||
// is pinned in tests/escalation-regression.test.js.
|
||||
const BLEEDING_TEXT = new RegExp(`\\b(?:${[
|
||||
'rectal bleeding',
|
||||
'bleeding from (?:the )?(?:rectum|bottom|back passage|anus)',
|
||||
'(?:blood|bleeding)\\b[^.!?]{0,30}\\b(?:when i wipe|when wiping|on (?:the )?toilet paper)',
|
||||
'(?:blood|bleeding)\\b[^.!?]{0,20}\\b(?:from|in|out of) (?:my|the) (?:bottom|rectum|back passage|anus)',
|
||||
'(?:blood|bleeding)\\b[^.!?]{0,20}\\b(?:in|mixed (?:in|into)) (?:my |the )?(?:poo|poop|stool|bowel movement)',
|
||||
'(?:diarrh(?:ea|oea)|loose stools?)\\b[^.!?]{0,15}\\bwith blood',
|
||||
'blood(?:y)? (?:in|on|with) (?:my |the )?(?:poo|poop|stool|bowel movement)',
|
||||
'(?:stool|poop|poo) (?:has|contains|with) blood',
|
||||
].join('|')})\\b`, 'i');
|
||||
const URGENT_TEXT_PATTERNS = Object.freeze([
|
||||
['blood', /\b(?:rectal bleeding|bleeding from (?:the )?(?:rectum|bottom)|blood(?:y)? (?:in|on|with) (?:my |the )?(?:stool|poop|bowel movement)|(?:stool|poop) (?:has|contains|with) blood)\b/i],
|
||||
['blackOrDarkRed', /\b(?:(?:black|dark[- ]?red) (?:stool|poop|bowel movement)|(?:stool|poop|bowel movement) (?:is|looks?) (?:black|dark[- ]?red))s?\b/i],
|
||||
['severePain', /\b(?:severe|constant|unrelenting) (?:abdominal|stomach|belly) pain\b/i],
|
||||
['blood', BLEEDING_TEXT],
|
||||
['blackOrDarkRed', new RegExp(`\\b(?:${[
|
||||
'(?:black|dark[- ]?red|tarry) (?:stool|stools|poop|poo|bowel movement|bathroom visit)s?',
|
||||
'(?:stool|stools|poop|poo|bowel movement) (?:is|are|was|were|looks?|looked|seems?|came out) (?:very |pitch )?(?:black|dark[- ]?red|tarry)',
|
||||
'(?:stool|stools|poop|poo) (?:that )?looks? like tar',
|
||||
'black[^.!?]{0,15}\\btarry',
|
||||
].join('|')})\\b`, 'i')],
|
||||
['severePain', new RegExp(`\\b(?:${[
|
||||
'(?:severe|constant|unrelenting|unbearable|worst) (?:abdominal|stomach|belly|tummy|abdomen|gut) pain',
|
||||
'(?:abdominal|stomach|belly|tummy|abdomen|gut)[^.!?]{0,20}\\bpain\\b[^.!?]{0,20}\\b(?:severe|constant|unrelenting|unbearable|worse)\\b',
|
||||
'(?:severe|constant|unrelenting|unbearable|worst)\\b[^.!?]{0,40}\\bpain\\b[^.!?]{0,40}\\b(?:abdominal|stomach|belly|tummy|abdomen|gut)\\b',
|
||||
'\\bpain\\b[^.!?]{0,25}\\b(?:abdominal|stomach|belly|tummy|abdomen|gut)\\b[^.!?]{0,25}\\b(?:severe|constant|unrelenting|unbearable|worse)\\b',
|
||||
].join('|')})\\b`, 'i')],
|
||||
['vomiting', /\b(?:vomit(?:ing|ed|s)?|throw(?:ing|s)? up|threw up|thrown up|puk(?:e|ed|ing|es)|barf(?:ed|ing|s)?|upchuck(?:ed|ing|s)?|toss(?:ed|ing|es)? (?:my|your|his|her|our|their|the) cookies|los(?:e|t|ing|es) (?:my|your|his|her|our|their|the) lunch|(?:i|we|you|he|she|they|someone) (?:(?:have|had|just|already|recently|am|are|was|were|kept) )?(?:hurl(?:s|ed|ing)?|spew(?:s|ed|ing)?)(?=\s*(?:[.!?]|$|again\b|twice\b|all night\b))|emesis)\b/i],
|
||||
['fever', /\bfever(?:ish)?\b/i],
|
||||
['cannotPassGas', /\b(?:cannot|can['’]?t|cant|unable to|not able to) pass gas\b/i],
|
||||
['fever', new RegExp(`\\b(?:${[
|
||||
'fever(?:ish)?',
|
||||
'running a (?:high )?temperature',
|
||||
'high temperature',
|
||||
'(?:temp|temperature)\\b[^.!?]{0,15}\\b(?:of |is |was |at |hit |reached )?(?:1\\d\\d(?:\\.\\d+)?|3[89](?:\\.\\d+)?|4[01](?:\\.\\d+)?)\\b',
|
||||
].join('|')})\\b`, 'i')],
|
||||
['cannotPassGas', new RegExp(`\\b(?:${[
|
||||
"(?:cannot|can['’]?t|cant|unable to|not able to|not|never|stopped|have not|haven['’]?t|did not|didn['’]?t)\\b[^.!?]{0,30}\\bpass(?:ing|ed)? (?:any )?(?:gas|wind|fart)\\b",
|
||||
'\\bno (?:gas|wind)\\b[^.!?]{0,20}\\b(?:passing|passed|coming)\\b',
|
||||
'\\b(?:cannot|can[\'’]?t|not)\\b[^.!?]{0,15}\\bfart(?:ing)?\\b',
|
||||
].join('|')})\\b`, 'i')],
|
||||
]);
|
||||
|
||||
// A reported symptom is a claim. Anything truthy-ish is read as reported; only an explicit clear
|
||||
// (false, 0, "no", blank, or absent) is read as not reported. Dropping a value because it was not
|
||||
// the literal boolean `true` would silently clear a symptom the person reported, which is the one
|
||||
// direction this boundary may never fail in.
|
||||
export function isReportedSymptom(value) {
|
||||
if (value === true) return true;
|
||||
if (typeof value === 'number') return value === 1;
|
||||
if (typeof value === 'string') return /^(?:true|yes|y|1)$/i.test(value.trim());
|
||||
return false;
|
||||
}
|
||||
|
||||
export function bucketForBristolType(type) {
|
||||
const value = Number(type);
|
||||
if (value === 1 || value === 2) return 'constipation';
|
||||
@@ -18,7 +62,7 @@ export function bucketForBristolType(type) {
|
||||
}
|
||||
|
||||
export function detectUrgentFlags(symptoms = {}) {
|
||||
const flags = URGENT_KEYS.filter((key) => symptoms[key] === true);
|
||||
const flags = URGENT_KEYS.filter((key) => isReportedSymptom(symptoms[key]));
|
||||
return {
|
||||
urgent: flags.length > 0,
|
||||
flags,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { detectUrgentText, hasUrgentLedgerContext, urgentChatMessage } from './domain.js';
|
||||
import { detectUrgentText, hasUrgentLedgerContext, isReportedSymptom, urgentChatMessage } from './domain.js';
|
||||
|
||||
const MAX_MESSAGE_CHARS = 4000;
|
||||
const MAX_LEDGER_ENTRIES = 20;
|
||||
@@ -125,7 +125,7 @@ function sanitizeLedger(entries) {
|
||||
urgency: Math.min(4, Math.max(0, Number(entry?.urgency) || 0)),
|
||||
discomfort: Math.min(4, Math.max(0, Number(entry?.discomfort) || 0)),
|
||||
note: String(entry?.note || '').trim().slice(0, 300),
|
||||
symptoms: Object.fromEntries(Object.entries(entry?.symptoms || {}).filter(([, value]) => value === true).map(([key]) => [String(key).slice(0, 40), true])),
|
||||
symptoms: Object.fromEntries(Object.entries(entry?.symptoms || {}).filter(([, value]) => isReportedSymptom(value)).map(([key]) => [String(key).slice(0, 40), true])),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,34 @@ 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"/);
|
||||
});
|
||||
|
||||
test('the deterministic symptom escalation regression suite runs in the default unit gate', async () => {
|
||||
const [packageJsonText, builder] = await Promise.all([
|
||||
readFile(packagePath, 'utf8'),
|
||||
readFile(new URL('../scripts/build_release.py', import.meta.url), 'utf8'),
|
||||
]);
|
||||
const packageJson = JSON.parse(packageJsonText);
|
||||
|
||||
// Issue #21: the escalation boundary is only as good as the suite that is actually run.
|
||||
assert.match(packageJson.scripts.test, /tests\/escalation-regression\.test\.js/);
|
||||
assert.match(builder, /"src\/domain\.js"/);
|
||||
assert.match(builder, /"src\/hermes-agent-service\.js"/);
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
// Issue #21 — deterministic symptom escalation regression suite.
|
||||
//
|
||||
// The product boundary is one-directional: model output and phrasing variety may never
|
||||
// suppress, soften, or delay an urgent-symptom escalation, and may never invent one from
|
||||
// the image. This suite pins that direction from three angles:
|
||||
//
|
||||
// 1. every red flag has positive and negative language coverage,
|
||||
// 2. model output can neither set nor clear a reported symptom, and
|
||||
// 3. the serious copy stays byte-identical when provider output is missing or malicious.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { detectUrgentFlags, detectUrgentText, urgentChatMessage, urgentSymptomKeys } from '../src/domain.js';
|
||||
import { mergeVisualSuggestion, parseVisionResponse } from '../src/analysis.js';
|
||||
import { createHermesAgentService, resolveHermesAgentConfig } from '../src/hermes-agent-service.js';
|
||||
|
||||
test('detects bleeding reported as wiping, toilet paper, or anatomy rather than the word "stool"', () => {
|
||||
for (const message of [
|
||||
'There is blood when I wipe',
|
||||
'Bleeding when I wipe',
|
||||
'I saw blood on the toilet paper',
|
||||
'Bright red blood on the toilet paper',
|
||||
'There is blood from my bottom',
|
||||
'Bleeding from my back passage',
|
||||
'There is blood in my rectum',
|
||||
'There is blood mixed in my poo',
|
||||
'I have diarrhea with blood',
|
||||
]) {
|
||||
const result = detectUrgentText(message);
|
||||
assert.equal(result.urgent, true, message);
|
||||
assert.deepEqual(result.flags, ['blood'], message);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not escalate bleeding wording that is not about the bowel', () => {
|
||||
for (const message of [
|
||||
'I got a paper cut and it is bleeding',
|
||||
'My blood pressure was checked today',
|
||||
'The ink is bleeding on the paper',
|
||||
'I used a wipe on the kitchen counter',
|
||||
'There is blood on my shirt from a nosebleed',
|
||||
]) {
|
||||
assert.equal(detectUrgentText(message).urgent, false, message);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects black, tarry, or dark-red stool reported without the exact phrase "black stool"', () => {
|
||||
for (const message of [
|
||||
'I have tarry stools',
|
||||
'My stool looks like tar',
|
||||
'I have black tarry poop',
|
||||
'My poop is very dark red',
|
||||
'My stool came out black',
|
||||
]) {
|
||||
const result = detectUrgentText(message);
|
||||
assert.equal(result.urgent, true, message);
|
||||
assert.deepEqual(result.flags, ['blackOrDarkRed'], message);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not escalate colour wording that is clothing, food, roadworks, or a verb', () => {
|
||||
for (const message of [
|
||||
'I wore black pants today',
|
||||
'I had black beans for lunch',
|
||||
'I drank dark red wine',
|
||||
'I tarried too long at the shop',
|
||||
'The road looks like tar',
|
||||
]) {
|
||||
assert.equal(detectUrgentText(message).urgent, false, message);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects reported fever language beyond the single word "fever"', () => {
|
||||
for (const message of [
|
||||
'my temperature is 103',
|
||||
'I have a temperature of 38.5',
|
||||
'I have a high temperature',
|
||||
'I am running a temperature',
|
||||
]) {
|
||||
assert.equal(detectUrgentText(message).urgent, true, message);
|
||||
assert.deepEqual(detectUrgentText(message).flags, ['fever'], message);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not escalate a normal or unrelated temperature reading', () => {
|
||||
for (const message of [
|
||||
'my temperature is 98.6',
|
||||
'my temperature is 37',
|
||||
'my temperature was checked at work',
|
||||
'I keep a feverfew plant on the windowsill',
|
||||
]) {
|
||||
assert.equal(detectUrgentText(message).urgent, false, message);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects severe abdominal pain in every natural word order', () => {
|
||||
for (const message of [
|
||||
'I have severe pain in my belly',
|
||||
'I have severe pain in my abdomen',
|
||||
'The pain in my stomach is severe',
|
||||
'My abdominal pain is constant and severe',
|
||||
'I have severe stomach pain',
|
||||
]) {
|
||||
assert.equal(detectUrgentText(message).urgent, true, message);
|
||||
assert.deepEqual(detectUrgentText(message).flags, ['severePain'], message);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not escalate severe pain that is not abdominal or not severe', () => {
|
||||
for (const message of [
|
||||
'I have severe pain in my knee',
|
||||
'I have severe back pain',
|
||||
'I have mild stomach pain',
|
||||
'The abdominal pain is gone now',
|
||||
]) {
|
||||
assert.equal(detectUrgentText(message).urgent, false, message);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects reported inability to pass gas in the past tense and with slang', () => {
|
||||
for (const message of [
|
||||
'I am not passing any gas',
|
||||
'I have not passed gas all day',
|
||||
'I stopped passing gas',
|
||||
'I cannot fart',
|
||||
'I have not passed any wind',
|
||||
]) {
|
||||
assert.equal(detectUrgentText(message).urgent, true, message);
|
||||
assert.deepEqual(detectUrgentText(message).flags, ['cannotPassGas'], message);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not escalate ordinary gas, exam, or fuel wording', () => {
|
||||
for (const message of [
|
||||
'I passed gas this morning',
|
||||
'I have not passed my driving test',
|
||||
'The car has no gas',
|
||||
]) {
|
||||
assert.equal(detectUrgentText(message).urgent, false, message);
|
||||
}
|
||||
});
|
||||
|
||||
test('treats a reported symptom as reported even when it is not a strict boolean', () => {
|
||||
// A truthy report that is dropped as "not exactly true" is a silently cleared symptom, which is
|
||||
// the one direction this product may never fail in.
|
||||
for (const value of [true, 1, 'yes', 'YES', 'true', ' y ']) {
|
||||
const result = detectUrgentFlags({ blood: value });
|
||||
assert.equal(result.urgent, true, `blood=${JSON.stringify(value)}`);
|
||||
assert.deepEqual(result.flags, ['blood'], `blood=${JSON.stringify(value)}`);
|
||||
}
|
||||
for (const value of [false, 0, 'no', '', null, undefined]) {
|
||||
assert.equal(detectUrgentFlags({ blood: value }).urgent, false, `blood=${JSON.stringify(value)}`);
|
||||
}
|
||||
assert.equal(detectUrgentFlags({}).urgent, false);
|
||||
});
|
||||
|
||||
test('a model suggestion can neither set nor clear a symptom, urgency, discomfort, or note', () => {
|
||||
const userForm = {
|
||||
bristolType: 4,
|
||||
color: 'brown',
|
||||
urgency: 2,
|
||||
discomfort: 1,
|
||||
note: 'after lunch',
|
||||
symptoms: { blood: true, fever: false },
|
||||
};
|
||||
const malicious = {
|
||||
status: 'suggestion',
|
||||
bristolType: 7,
|
||||
color: 'black',
|
||||
confidence: 0.99,
|
||||
imageQuality: 'good',
|
||||
observations: 'Nothing to worry about; this looks completely normal.',
|
||||
warning: 'No need to see a doctor.',
|
||||
symptoms: { blood: false, fever: true, vomiting: true },
|
||||
urgent: false,
|
||||
escalate: false,
|
||||
urgency: 0,
|
||||
discomfort: 0,
|
||||
note: 'No bleeding. All clear.',
|
||||
diagnosis: 'nothing wrong',
|
||||
};
|
||||
|
||||
const merged = mergeVisualSuggestion(userForm, malicious);
|
||||
assert.deepEqual(merged.symptoms, { blood: true, fever: false }, 'model output may not clear or set a symptom');
|
||||
assert.equal(merged.urgency, 2);
|
||||
assert.equal(merged.discomfort, 1);
|
||||
assert.equal(merged.note, 'after lunch');
|
||||
assert.equal(merged.bristolType, 7, 'only the visible form may be suggested');
|
||||
assert.equal(merged.color, 'black', 'only the visible colour may be suggested');
|
||||
assert.deepEqual(detectUrgentFlags(merged.symptoms).flags, ['blood'], 'the escalation still comes from the user report');
|
||||
});
|
||||
|
||||
test('a schema-valid provider reply cannot overwrite the deterministic warning or carry symptoms', () => {
|
||||
const parsed = parseVisionResponse(JSON.stringify({
|
||||
isStool: true,
|
||||
bristolType: 4,
|
||||
color: 'brown',
|
||||
confidence: 0.9,
|
||||
imageQuality: 'good',
|
||||
observations: 'Looks fine.',
|
||||
warning: 'No need to see a doctor — this is normal.',
|
||||
reason: 'cleared',
|
||||
urgent: false,
|
||||
symptoms: { blood: false },
|
||||
diagnosis: 'healthy',
|
||||
}));
|
||||
|
||||
assert.equal(parsed.status, 'suggestion');
|
||||
assert.match(parsed.warning, /not a diagnosis/i);
|
||||
assert.doesNotMatch(parsed.warning, /no need to see a doctor/i);
|
||||
for (const key of ['symptoms', 'urgent', 'diagnosis', 'reason']) {
|
||||
assert.equal(parsed[key], undefined, `${key} must never travel from the provider to the user`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every red flag produces byte-identical escalation copy on its own', () => {
|
||||
const messages = urgentSymptomKeys.map(key => {
|
||||
const result = detectUrgentFlags({ [key]: true });
|
||||
assert.equal(result.urgent, true, key);
|
||||
assert.deepEqual(result.flags, [key], key);
|
||||
return result.message;
|
||||
});
|
||||
assert.equal(new Set(messages).size, 1, 'one symptom must never produce softer copy than another');
|
||||
assert.equal(messages[0], urgentChatMessage.replace('Pause and get medical help. ', ''));
|
||||
});
|
||||
|
||||
const gatewayConfig = () => resolveHermesAgentConfig({
|
||||
TIMMY_AGENT_ENABLED: 'true',
|
||||
TIMMY_AGENT_ACCESS_TOKEN: 'test-agent-access-code-2026',
|
||||
TIMMY_PUBLIC_ORIGIN: 'http://127.0.0.1:4173',
|
||||
TIMMY_AGENT_WORKDIR: '/tmp/timmy-agent-workspace',
|
||||
});
|
||||
|
||||
test('every red flag phrase is intercepted at the gateway with the same fixed copy', async () => {
|
||||
const calls = [];
|
||||
const service = createHermesAgentService({
|
||||
config: gatewayConfig(),
|
||||
randomToken: () => 'flag-cookie',
|
||||
runTurn: async input => { calls.push(input); return { reply: 'upstream says everything is fine', sessionId: 'private-session' }; },
|
||||
});
|
||||
await service.unlock({ origin: 'http://127.0.0.1:4173', accessCode: 'test-agent-access-code-2026' });
|
||||
|
||||
for (const message of [
|
||||
'I have rectal bleeding',
|
||||
'my stool is black',
|
||||
'I have severe stomach pain',
|
||||
'I threw up',
|
||||
'I have a fever',
|
||||
'I cannot pass gas',
|
||||
]) {
|
||||
const result = await service.chat({
|
||||
origin: 'http://127.0.0.1:4173',
|
||||
cookieToken: 'flag-cookie',
|
||||
payload: { message, ledger: [] },
|
||||
});
|
||||
assert.equal(result.reply, urgentChatMessage, message);
|
||||
assert.equal(result.safetyOverride, true, message);
|
||||
}
|
||||
assert.equal(calls.length, 0, 'no red flag may reach the provider');
|
||||
});
|
||||
|
||||
test('the escalation copy is unchanged when the provider is missing, throwing, or malicious', async () => {
|
||||
const origin = 'http://127.0.0.1:4173';
|
||||
for (const runTurn of [
|
||||
async () => { throw new Error('provider is offline'); },
|
||||
async () => ({ reply: 'Everything is fine — no need to see a doctor.', sessionId: 'private-session' }),
|
||||
]) {
|
||||
const service = createHermesAgentService({ config: gatewayConfig(), randomToken: () => 'copy-cookie', runTurn });
|
||||
await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' });
|
||||
const result = await service.chat({ origin, cookieToken: 'copy-cookie', payload: { message: 'I have tarry stools', ledger: [] } });
|
||||
assert.equal(result.reply, urgentChatMessage);
|
||||
assert.equal(result.safetyOverride, true);
|
||||
}
|
||||
});
|
||||
@@ -149,6 +149,35 @@ test('authoritative chat service intercepts urgent message and ledger symptoms b
|
||||
}
|
||||
});
|
||||
|
||||
test('ledger context keeps a symptom reported in any truthy shape instead of silently clearing it', async () => {
|
||||
const calls = [];
|
||||
const service = createHermesAgentService({
|
||||
config: configured(),
|
||||
randomToken: () => 'ledger-cookie',
|
||||
runTurn: async input => { calls.push(input); return { reply: 'should not be reached', sessionId: 'private-session' }; },
|
||||
});
|
||||
await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' });
|
||||
|
||||
// Issue #21: a browser-supplied ledger whose symptom is reported but not the literal boolean
|
||||
// `true` used to be filtered out of the sanitized context, which cleared the symptom before the
|
||||
// escalation check ever saw it.
|
||||
const truthy = await service.chat({ origin, cookieToken: 'ledger-cookie', payload: {
|
||||
message: 'What do my logs show?',
|
||||
ledger: [{ bristolType: 4, color: 'brown', note: '', symptoms: { blood: 'yes' } }],
|
||||
} });
|
||||
assert.equal(calls.length, 0, 'a reported symptom must be intercepted before Hermes');
|
||||
assert.equal(truthy.safetyOverride, true);
|
||||
assert.match(truthy.reply, /medical help/i);
|
||||
|
||||
const cleared = await service.chat({ origin, cookieToken: 'ledger-cookie', payload: {
|
||||
message: 'What do my logs show?',
|
||||
ledger: [{ bristolType: 4, color: 'brown', note: '', symptoms: { blood: 'no', fever: false } }],
|
||||
} });
|
||||
assert.equal(cleared.safetyOverride, undefined);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].prompt, /"symptoms":\{\}/);
|
||||
});
|
||||
|
||||
test('Hermes CLI parser accepts quiet output, strips control sequences, and rejects leaked metadata', () => {
|
||||
const parsed = parseHermesCliOutput('\u001b[32msession_id: 20260820_abcd\u001b[0m\nA concise answer.');
|
||||
assert.deepEqual(parsed, { sessionId: '20260820_abcd', reply: 'A concise answer.' });
|
||||
|
||||
@@ -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,47 @@ 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 demo visibly exercises the red-flag language coverage instead of one canned word', async () => {
|
||||
const demo = await readFile(demoPath, 'utf8');
|
||||
const builder = await readFile(builderPath, 'utf8');
|
||||
|
||||
// Issue #21: the escalation has to fire for real phrasing, on both surfaces the detector serves.
|
||||
assert.match(demo, /const expectEscalation = async \(text, label\)/);
|
||||
assert.match(demo, /expectEscalation\('There is blood when I wipe', 'bleeding reported as wiping'\)/);
|
||||
assert.match(demo, /expectEscalation\('I have tarry stools', 'tarry stool'\)/);
|
||||
assert.match(demo, /note: 'stool was tarry yesterday'/);
|
||||
assert.match(demo, /a tarry note in the journal must escalate the next answer/);
|
||||
// Each scene is asserted in the browser, not narrated: one new bubble, the app's own copy, no provider call.
|
||||
assert.match(demo, /expected exactly one escalation bubble/);
|
||||
assert.match(demo, /the escalation copy must be the app's own fixed string/);
|
||||
assert.match(demo, /an escalated turn must not reach Hermes/);
|
||||
// The new captions are required release gates, so a later release cannot drop the scenes silently.
|
||||
assert.match(builder, /Bleeding reported as wiping still escalates, in the app’s own fixed words/);
|
||||
assert.match(builder, /Tarry and black stool wording escalates too: one red flag, one unchanging message/);
|
||||
assert.match(builder, /A tarry note already saved in the journal escalates the same way, before any provider call/);
|
||||
});
|
||||
|
||||
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