diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a6662430..edbdd3468 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index d7acf6a57..22686727a 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -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.
diff --git a/package.json b/package.json
index 921a52b65..f28045086 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/insights/__tests__/prose-blocks.test.tsx b/src/components/insights/__tests__/prose-blocks.test.tsx
index 65c20860d..1a58fe22d 100644
--- a/src/components/insights/__tests__/prose-blocks.test.tsx
+++ b/src/components/insights/__tests__/prose-blocks.test.tsx
@@ -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
and strips the markers", () => {
+ const html = render(
+ ,
+ );
+ expect((html.match(/ blocks.
+ expect((html.match(/ and leaves unclosed markers literal", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain("key takeaway");
+ expect(html).toContain("A stray ** stays literal.");
+ });
});
describe("StreamedProse", () => {
diff --git a/src/components/insights/coach-panel/streamed-prose.tsx b/src/components/insights/coach-panel/streamed-prose.tsx
index 7b41f4df0..2ea1be8d6 100644
--- a/src/components/insights/coach-panel/streamed-prose.tsx
+++ b/src/components/insights/coach-panel/streamed-prose.tsx
@@ -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) => (
-
-
-
+
))}
{/* The growing tail paragraph: word-by-word fade. No linkify here —
a half-arrived URL must not flicker into a link mid-stream; it
diff --git a/src/components/insights/prose-blocks.tsx b/src/components/insights/prose-blocks.tsx
index 18835477d..b16d1aba2 100644
--- a/src/components/insights/prose-blocks.tsx
+++ b/src/components/insights/prose-blocks.tsx
@@ -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
+ * ``, 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(
+
+ {linkified(line.slice(lastIndex, start))}
+ ,
+ );
+ nodes.push({linkified(match[1])});
+ lastIndex = start + match[0].length;
+ }
+ if (nodes.length === 0) return linkified(line);
+ if (lastIndex < line.length)
+ nodes.push(
+
+ {linkified(line.slice(lastIndex))}
+ ,
+ );
+ return <>{nodes}>;
+}
+
/**
* Render one paragraph's inner text: each single `\n` becomes a `
`, 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 (
{i > 0 &&
}
- {linkify ? linkifyLearnLinks(cleaned) : cleaned}
+ {renderInline(cleaned, linkify)}
);
})}
@@ -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 `` (marker stripped, one `- ` per line), the remaining line runs
+ * render as `
` with `
` 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 ? (
+
+ {run.lines.map((line, j) => (
+ -
+
+
+ ))}
+
+ ) : (
+
+
+
+ ),
+ )}
+ >
+ );
+}
+
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) => (
-
-
-
+
))}
>
);
diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts
index 0d2c31c2a..d06c0f108 100644
--- a/src/lib/ai/coach/system-prompt.ts
+++ b/src/lib/ai/coach/system-prompt.ts
@@ -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
diff --git a/src/lib/ai/prompts/shared-contracts.ts b/src/lib/ai/prompts/shared-contracts.ts
index fdf81a122..5e867e73b 100644
--- a/src/lib/ai/prompts/shared-contracts.ts
+++ b/src/lib/ai/prompts/shared-contracts.ts
@@ -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 ``, `**bold**` becomes `` — 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.`,
};
/**