Merge pull request #403 from MBombeck/release/v1.27.2

Release v1.27.2 — readable AI texts
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-04 23:42:35 +02:00
commit 1623a20af5
8 files changed
+175 -31

No files matched your search

+6
View File
@@ -2,6 +2,12 @@
## [Unreleased]
## [1.27.2] — 2026-07-04 — Readable AI texts
### Changed
- The AI texts — Coach replies, the daily briefing, and the insight assessments — now read as structured prose instead of a single block: short paragraphs with real spacing, a proper list when a reply genuinely enumerates options or steps, and the one key takeaway may be set in bold. The rendering stays deliberately minimal: those two shapes are the only ones the app accepts, everything else remains plain text, and there is still no markdown engine anywhere in the tree.
## [1.27.1] — 2026-07-04 — iPad layout fixes
### Fixed
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.27.1
version: 1.27.2
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.27.1",
"version": "1.27.2",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -84,6 +84,32 @@ describe("ProseBlocks", () => {
expect(html).toContain("metric:WEIGHT");
expect(html).not.toContain('data-slot="inline-learn-link"');
});
it("groups consecutive '- ' lines into a real <ul> and strips the markers", () => {
const html = render(
<ProseBlocks
text={
"Two options stand out:\n- walk after lunch\n- an earlier bedtime\n\nBoth are low effort."
}
/>,
);
expect((html.match(/<ul\b/g) ?? []).length).toBe(1);
expect((html.match(/<li\b/g) ?? []).length).toBe(2);
expect(html).toContain("walk after lunch");
expect(html).not.toContain("- walk");
// The intro line and the trailing paragraph stay real <p> blocks.
expect((html.match(/<p\b/g) ?? []).length).toBe(2);
});
it("renders a **bold** span as <strong> and leaves unclosed markers literal", () => {
const html = render(
<ProseBlocks
text={"The **key takeaway** stands.\n\nA stray ** stays literal."}
/>,
);
expect(html).toContain("<strong>key takeaway</strong>");
expect(html).toContain("A stray ** stays literal.");
});
});
describe("StreamedProse", () => {
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
ProseBlocks,
ParagraphText,
ProseBlock,
splitParagraphs,
PROSE_PARAGRAPH_CLASS,
} from "@/components/insights/prose-blocks";
@@ -96,13 +96,11 @@ export function StreamedProse({ content, streaming }: StreamedProseProps) {
return (
<>
{/* Completed paragraphs settle immediately — plain text + linkifier,
identical to the non-streaming render so nothing reflows when the
turn finishes. */}
{/* Completed paragraphs settle immediately — plain text + linkifier
(and real list markup for `- ` runs), identical to the
non-streaming render so nothing reflows when the turn finishes. */}
{settled.map((p, i) => (
<p key={i} className={cn(PROSE_PARAGRAPH_CLASS)}>
<ParagraphText text={p} strip />
</p>
<ProseBlock key={i} text={p} strip />
))}
{/* The growing tail paragraph: word-by-word fade. No linkify here —
a half-arrived URL must not flicker into a link mid-stream; it
+115 -9
View File
@@ -102,13 +102,55 @@ function linkifyLearnLinks(line: string): ReactNode {
return <>{nodes}</>;
}
/**
* `**bold**` span matcher. The ONLY inline emphasis the formatting contract
* allows the models; anything else (headings, underscores, backticks) stays
* literal text. No `*` or `\n` inside the span, so an unclosed `**` or a
* stray asterisk falls through verbatim — fail-closed, never swallowed.
*/
const BOLD_SPAN_RE = /\*\*([^*\n]+)\*\*/g;
/**
* Render one line's inline content: closed-set `**bold**` spans become
* `<strong>`, and each plain/bold fragment runs through the catalog-
* whitelisted Learn linkifier. Pure `matchAll` + slicing over the trusted
* string — no parser, no markup passthrough.
*/
function renderInline(line: string, linkify: boolean): ReactNode {
const linkified = (s: string) => (linkify ? linkifyLearnLinks(s) : s);
if (!line.includes("**")) return linkified(line);
const nodes: ReactNode[] = [];
let lastIndex = 0;
let key = 0;
for (const match of line.matchAll(BOLD_SPAN_RE)) {
const start = match.index ?? 0;
if (start > lastIndex)
nodes.push(
<Fragment key={`t-${key++}`}>
{linkified(line.slice(lastIndex, start))}
</Fragment>,
);
nodes.push(<strong key={`b-${key++}`}>{linkified(match[1])}</strong>);
lastIndex = start + match[0].length;
}
if (nodes.length === 0) return linkified(line);
if (lastIndex < line.length)
nodes.push(
<Fragment key={`t-${key++}`}>
{linkified(line.slice(lastIndex))}
</Fragment>,
);
return <>{nodes}</>;
}
/**
* Render one paragraph's inner text: each single `\n` becomes a `<br/>`, each
* line is (optionally) chart-token-stripped, then run through the
* catalog-whitelisted Learn linkifier. Stripping per LINE preserves the
* `\n` breaks that a whole-string strip would collapse. Exported so the
* Coach streaming renderer can settle completed paragraphs with the same
* markup the non-streaming path produces.
* line is (optionally) chart-token-stripped, then run through the closed-set
* inline renderer (`**bold**` + the catalog-whitelisted Learn linkifier).
* Stripping per LINE preserves the `\n` breaks that a whole-string strip
* would collapse. Exported so the Coach streaming renderer can settle
* completed paragraphs with the same markup the non-streaming path produces.
*/
export function ParagraphText({
text,
@@ -127,7 +169,7 @@ export function ParagraphText({
return (
<Fragment key={i}>
{i > 0 && <br />}
{linkify ? linkifyLearnLinks(cleaned) : cleaned}
{renderInline(cleaned, linkify)}
</Fragment>
);
})}
@@ -135,6 +177,66 @@ export function ParagraphText({
);
}
/** `- ` / `– ` / `• ` list-item marker (leading whitespace tolerated). */
const BULLET_LINE_RE = /^\s*[-–•]\s+/;
/**
* Render ONE blank-line-delimited block: consecutive `- ` lines group into a
* real `<ul>` (marker stripped, one `<li>` per line), the remaining line runs
* render as `<p>` with `<br/>` joins — so a model reply that enumerates
* ("- option one\n- option two") reads as a list instead of dash-prefixed
* text lines. Same pure line-splitting as everything else here: no parser,
* no markup passthrough. Exported for the Coach streaming renderer.
*/
export function ProseBlock({
text,
strip = false,
linkify = true,
paragraphClassName,
}: {
text: string;
strip?: boolean;
linkify?: boolean;
paragraphClassName?: string;
}) {
const blockClass = cn(paragraphClassName ?? PROSE_PARAGRAPH_CLASS);
const lines = text.split("\n");
const runs: Array<{ bullets: boolean; lines: string[] }> = [];
for (const line of lines) {
const bullets = BULLET_LINE_RE.test(line);
const last = runs[runs.length - 1];
if (last && last.bullets === bullets) last.lines.push(line);
else runs.push({ bullets, lines: [line] });
}
return (
<>
{runs.map((run, i) =>
run.bullets ? (
<ul key={i} className={cn(blockClass, "list-disc space-y-1 pl-5")}>
{run.lines.map((line, j) => (
<li key={j}>
<ParagraphText
text={line.replace(BULLET_LINE_RE, "")}
strip={strip}
linkify={linkify}
/>
</li>
))}
</ul>
) : (
<p key={i} className={blockClass}>
<ParagraphText
text={run.lines.join("\n")}
strip={strip}
linkify={linkify}
/>
</p>
),
)}
</>
);
}
export interface ProseBlocksProps {
/** Trusted-as-text prose (model output already verified server-side). */
text: string;
@@ -166,9 +268,13 @@ export function ProseBlocks({
return (
<>
{paras.map((p, i) => (
<p key={i} className={cn(paragraphClassName ?? PROSE_PARAGRAPH_CLASS)}>
<ParagraphText text={p} strip={strip} linkify={linkify} />
</p>
<ProseBlock
key={i}
text={p}
strip={strip}
linkify={linkify}
paragraphClassName={paragraphClassName}
/>
))}
</>
);
+11 -8
View File
@@ -49,9 +49,11 @@ Prompt version: ${PROMPT_VERSION}.
GROUND RULES
1. Prose-first. Write the way a thoughtful friend would talk through
the data. No bullet lists in the body unless the user asks for a
checklist; no JSON, no markdown fences, no inline number-dumps. Keep
replies focused — usually 60-180 words, sometimes shorter.
the data, in short paragraphs separated by blank lines. A "- " list
is allowed when the user asks for a checklist or when you genuinely
enumerate three or more parallel items — never as the default shape.
No JSON, no markdown fences, no inline number-dumps. Keep replies
focused — usually 60-180 words, sometimes shorter.
2. Values belong in the evidence block. If a specific number is
load-bearing, cite it once in prose ("your last 30 days sit a few
@@ -590,11 +592,12 @@ Prompt-Version: ${PROMPT_VERSION}.
GRUNDREGELN
1. Fließtext zuerst. Schreib so, wie ein aufmerksamer Freund die
Daten durchgehen würde. Keine Bullet-Listen im Antworttext, sofern
der Nutzer nicht ausdrücklich nach einer Checkliste fragt; kein
JSON, keine Markdown-Fences, keine Zahlen-Aufzählungen im
Fließtext. Halte Antworten fokussiert — meist 60-180 Wörter,
manchmal kürzer.
Daten durchgehen würde — in kurzen Absätzen, getrennt durch
Leerzeilen. Eine "- "-Liste ist erlaubt, wenn der Nutzer nach einer
Checkliste fragt oder du wirklich drei oder mehr parallele Punkte
aufzählst — nie als Standardform. Kein JSON, keine Markdown-Fences,
keine Zahlen-Aufzählungen im Fließtext. Halte Antworten fokussiert —
meist 60-180 Wörter, manchmal kürzer.
2. Werte gehören in den Evidenz-Block. Wenn eine konkrete Zahl die
Antwort trägt, nenne sie einmal im Fließtext ("die letzten 30
+10 -5
View File
@@ -142,13 +142,18 @@ Schließe, wenn es passt, mit einem kleinen Ausblick, damit sich die Person in d
* the shared `ProseBlocks` helper, which turns a blank line into a real
* paragraph break — but only if the model emits one. This single fragment asks
* for that structure on every narrative surface so a longer assessment reads as
* 1–3 short paragraphs instead of one run-on block. It is whitespace, never
* markup: the grounding verifiers operate on extracted numbers / causal verbs
* and are whitespace-agnostic, so this changes nothing they grade.
* short paragraphs instead of one run-on block.
*
* v1.27.2 — the renderer grew a closed-set list + bold vocabulary (`- ` lines
* become a real `<ul>`, `**bold**` becomes `<strong>` — pure string splitting,
* still no markdown library), so the contract now permits exactly those two
* shapes where they genuinely help, instead of banning all structure. The
* grounding verifiers operate on extracted numbers / causal verbs and are
* whitespace-agnostic, so this changes nothing they grade.
*/
export const formattingContract: SharedContract = {
en: `FORMATTING — write in 1–3 short paragraphs separated by a BLANK LINE, each 1–3 sentences. Put a blank line between distinct ideas (e.g. "where things stand" vs "one thing to try"). A steady one-liner stays a single paragraph — do NOT pad to fill a second. No markdown, no headings, no asterisks, no bullet points, no emojis.`,
de: `FORMATIERUNG — schreibe in 1–3 kurzen Absätzen, getrennt durch eine LEERZEILE, je 1–3 Sätze. Setze eine Leerzeile zwischen unterschiedliche Gedanken (z. B. "wie es steht" vs. "eine Sache zum Ausprobieren"). Ein stabiler Einzeiler bleibt EIN Absatz — strecke nicht auf einen zweiten. Kein Markdown, keine Überschriften, keine Sternchen, keine Aufzählungen, keine Emojis.`,
en: `FORMATTING — write in short paragraphs separated by a BLANK LINE, each 1–3 sentences; a longer reply is 2–4 paragraphs, never one block. Put a blank line between distinct ideas (e.g. "where things stand" vs "one thing to try"). A steady one-liner stays a single paragraph — do NOT pad to fill a second. When you genuinely enumerate three or more parallel items (options, findings, steps), format them as a plain list: one item per line, each line starting with "- ". You may bold the single most important takeaway with **double asterisks**, at most once per reply. Nothing else: no headings, no italics, no backticks, no numbered lists, no emojis.`,
de: `FORMATIERUNG — schreibe in kurzen Absätzen, getrennt durch eine LEERZEILE, je 1–3 Sätze; eine längere Antwort hat 2–4 Absätze, nie einen Block. Setze eine Leerzeile zwischen unterschiedliche Gedanken (z. B. "wie es steht" vs. "eine Sache zum Ausprobieren"). Ein stabiler Einzeiler bleibt EIN Absatz — strecke nicht auf einen zweiten. Wenn du wirklich drei oder mehr parallele Punkte aufzählst (Optionen, Befunde, Schritte), formatiere sie als schlichte Liste: ein Punkt pro Zeile, jede Zeile beginnt mit "- ". Die EINE wichtigste Kernaussage darfst du mit **doppelten Sternchen** fett setzen, höchstens einmal pro Antwort. Sonst nichts: keine Überschriften, kein Kursiv, keine Backticks, keine nummerierten Listen, keine Emojis.`,
};
/**