diff --git a/CHANGELOG.md b/CHANGELOG.md index befc7ca..a02f914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Added +- **Magazine UI shows page labels, display titles and merge suggestions.** The article index (list, playlist, driving-mode Index sheet and driving header) shows `display_title` (falls back to `title`) with the `page_label` ("Seite 33") in smaller muted text, plus small kind badges (advert, photo, contents, imprint, continuation) and an "Audio is out of date: re-render" badge when `audio_stale` is set. Review shows a suggestion box: the proposed merge target with confidence and a collapsible plain-language "Why do we think so?" list, **Merge into previous article** (`POST .../merge-into-previous {into}`; a 409 "proposal changed" refreshes the suggestion; undoable) and **Use suggested title** (`POST .../accept-title`; undoable). Controls are labelled, keyboard operable and 44 px high. Tests: `tests/magazine.spec.js` ("untitled entries", driving index). - **Untitled magazine entries ("Page 33") are explained, merged on request and given readable titles** (`core/magazine_untitled.py`, `scripts/magazine_untitled.py`). Root cause: the review screen creates a `Page N` stand-in for any page no article covers, and *Approve* without a toggled box saved only the visible page's regions, which cut multi-page features down to one page. On *c't KI Wissen 2026* this left 59 stand-ins, most of them continuation pages. A continuation detector scores each untitled entry against the article before it (lower-case/mid-sentence start, hyphenated break, the narration fusion rule, open ending, adjacent page, column width; a signed-off previous article or the page's own headline counts against). An optional local-LLM check runs only for ambiguous scores. Each proposal comes with a confidence and the evidence. Records whose title repeats the headline of the article they continue are proposed as well. Titles: a deterministic fallback (*Anzeige*, *Bildseite*, *Inhalt*, *Impressum*, *Editorial*, *Textfragment*, the page headline, or the first sentence cut to 8 words), plus optional validated and cached LLM titles in the magazine's language (`magazine_llm_titles`, `--no-llm`). Nothing changes silently. `GET /articles` gains derived `display_title`, `title_source`, `suggested_title`, `page_label`, `untitled_kind`, `merge_suggestion` and `audio_stale`, and `GET /untitled` plus `quality/report → untitled_articles` list everything. Changes happen only through the undoable `POST /articles/{id}/merge-into-previous` and `POST /articles/{id}/accept-title` (never over a real title), `POST /titles/suggest` (suggestion cache only), and the CLI's `--apply` (backup first). A merge keeps reading order, marks the article `audio_stale` (a fresh render clears it) and deletes no audio. See architecture/magazine-ingestion.md, "Untitled entries". - **Distinct, deterministic face profile per character** (`core/face_profile.py`). The cast of one book looked like one family because most sheets name no facial feature and the image model then draws its default face. Each character now gets a compact `face:` clause (face shape, jaw, nose, eyes, brows, mouth, cheekbones, skin, hair colour and style, facial hair, age signs, a mark) in the Subject of every character prompt: traits the sheet states (German or English) are kept, the rest comes from species/age/gender/culture-filtered option pools, chosen so that the characters of a book are spread apart (greedy max-distance in creation order; adding characters never changes an existing profile; relatives named in `relationships` share a few traits). Owner override: a `visual_face` text on the sheet. On the real books 02/03 the mean pairwise face-descriptor distance goes from 0.09/0.04 to 0.91 (44/68 characters had no face trait at all). See `architecture/character-visual-identity.md`. - **Dry run for faces:** `POST /api/character-face-preview {book}` lists every character's face profile (what came from the sheet, what was chosen) with a similarity matrix and before/after score; `POST /api/character-prompt-preview` also returns `face_clause`. Nothing is generated or saved. diff --git a/Manual.md b/Manual.md index c4a6b34..5915b52 100644 --- a/Manual.md +++ b/Manual.md @@ -353,6 +353,12 @@ turned into a podcast discussion between two or three voices you pick. switch which one the drawing and editing tools apply to. Confirm the boundaries, correct the title, then **Approve**. **Approve** on one page of a multi-page article keeps the blocks of all its pages: only boxes you untick or add change what the article contains. + + **Untitled entries ("Page 33") in review.** An entry without a real title shows its page label (for example "Seite 33") and a suggestion box: + - *Merge into previous article* appears when the page looks like the continuation of the article before it. Open **Why do we think so?** to read the reasons in plain words and the confidence. Press the button to merge. Use **Undo** in the bar above the page to take it back. If the suggestion changed in the meantime nothing is merged and the suggestion is refreshed. + - *Use suggested title* writes the proposed title into the entry (never over a title you set). Undo is offered. + - After a merge the article's audio is marked *Audio is out of date: re-render*; render it again from the contents list. No audio file is deleted. + Page labels and these badges also appear in the contents list, the playlist and the driving-mode index. Mobile: buttons are full width and 44 px high; keyboard: Tab to the buttons and the details summary, Enter or Space to use them. Privacy: nothing leaves the device beyond the local server. Optionally, click **"Describe"** under a figure, chart, table or code block to have the configured vision engine generate a one- or two-sentence description for listeners who cannot see the image — e.g. what a chart diff --git a/static/js/magazine.js b/static/js/magazine.js index ba5e0ce..829afa8 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -895,6 +895,23 @@ async function magReviewPage(pageNumber) { magReviewGoMagazinePage(pageNumber); } +// An entry without a real title ("Page 33") shows the server's derived title; the label says where it is. +function magDisplayTitle(a) { return (a && (a.display_title || a.title)) || 'Untitled'; } +function magPageLabelHtml(a, cls) { + return a && a.page_label ? '' + esc(a.page_label) + '' : ''; +} +const MAG_KIND_LABELS = { advert: 'Advert', photo: 'Photo page', content: 'Contents', imprint: 'Imprint', continuation: 'Continuation' }; +function magKindBadgeHtml(a) { + const k = MAG_KIND_LABELS[a && a.untitled_kind]; + return k ? '' + esc(k) + '' : ''; +} +function magStaleHtml(a) { + return a && a.audio_stale + ? '' + + ' Audio is out of date: re-render' + : ''; +} + function renderContents() { const host = $('mag-contents'); if (!host) return; @@ -969,7 +986,7 @@ function renderContents() { 'onblur="magContentsCommitTitle(this, \'' + a.id + '\')" ' + 'onkeydown="if(event.key===\'Enter\'){event.preventDefault();this.blur();}" ' + 'onclick="event.stopPropagation()" title="Click to edit the title">' + - esc(a.title || 'Untitled') + ''; + esc(magDisplayTitle(a)) + '' + magPageLabelHtml(a, 'mag-page-label'); let html = ''; bySection.forEach((arts, section) => { @@ -988,6 +1005,7 @@ function renderContents() { '
' + titleHtml(a, 'mag-toc-title') + (a.author ? 'by ' + esc(a.author) + '' : '') + + magKindBadgeHtml(a) + magStaleHtml(a) + '
' + '
' + '' + fmtPages(a.pages) + '' + @@ -1375,7 +1393,7 @@ async function magContentsCommitTitle(el, id) { const a = state.articles.find((x) => x.id === id); if (!a) return; const value = (el.textContent || '').replace(/\s+/g, ' ').trim(); - if (!value || value === (a.title || '')) { el.textContent = a.title || 'Untitled'; return; } + if (!value || value === (a.title || '') || value === magDisplayTitle(a)) { el.textContent = magDisplayTitle(a); return; } const previous = a.title; a.title = value; try { @@ -1385,7 +1403,7 @@ async function magContentsCommitTitle(el, id) { }); } catch (e) { a.title = previous; - el.textContent = previous || 'Untitled'; + el.textContent = magDisplayTitle(a); say('Could not save the title: ' + e.message, 'err'); } } @@ -2066,6 +2084,7 @@ function renderReview() { '
' + '
'; + html += magReviewSuggestHtml(a); html += '
'; html += '
' + '' + @@ -3956,6 +3975,81 @@ async function magRefineAllFigures() { return magRunFigureRefine(null); } +function magReviewSuggestHtml(a) { + const m = a.merge_suggestion; + const placeholderTitle = a.title_source === 'suggested' && a.suggested_title; + if (!m && !placeholderTitle && !a.audio_stale && !a.untitled_kind) return ''; + let h = '
'; + h += '

' + esc(a.page_label || '') + (a.untitled_kind ? ' ' + magKindBadgeHtml(a) : '') + '

'; + if (a.audio_stale) h += '

' + magStaleHtml(a) + '

'; + if (m) { + const lvl = m.level === 'high' ? 'high' : 'medium'; + const pct = Math.round(Number(m.confidence || 0) * 100); + h += '

This looks like the continuation of ' + esc(m.into_title || 'the article before it') + ' (' + + lvl + ' confidence, ' + pct + '%).

' + + '
Why do we think so?
    ' + + (m.evidence || []).map((e) => '
  • ' + esc(e) + '
  • ').join('') + '
' + + ' '; + } + if (placeholderTitle) { + h += ' ' + + '“' + esc(a.suggested_title) + '”'; + } + return h + '
'; +} + +async function magReselectArticle(id) { + await magRefreshArticles(); + const next = (state.articles || []).find((x) => x.id === id); + if (next) { state.article = next; _magReviewChecked = null; } + return next; +} + +async function magMergeIntoPrevious() { + const a = state.article; + if (!a || !a.merge_suggestion) return; + const btn = $('mag-merge-prev-btn'); + if (btn) btn.disabled = true; + try { + const r = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/merge-into-previous', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ into: a.merge_suggestion.into }), + }); + await magRefreshArticles(); + const target = (state.articles || []).find((x) => x.id === (r.article && r.article.id)) || (state.articles || [])[0]; + if (target) await magReview(target.id); else magShow('contents'); + magOfferUndo(r.undo, 'Merged ' + (a.page_label || 'the page') + ' into ' + magDisplayTitle(target || {})); + say('Merged. Audio of the target is out of date: re-render it.', 'ok'); + } catch (e) { + if (btn) btn.disabled = false; + if (/proposal changed|No merge is proposed/i.test(String(e.message))) { + await magReselectArticle(a.id); + renderReview(); + return say('The proposal changed, so nothing was merged. The suggestion is refreshed.', 'err'); + } + say('Could not merge: ' + e.message, 'err'); + } +} + +async function magAcceptSuggestedTitle() { + const a = state.article; + if (!a) return; + try { + const r = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/accept-title', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', + }); + await magReselectArticle(a.id); + renderReview(); + magOfferUndo(r.undo, 'Title set to "' + ((r.article && r.article.title) || '') + '"'); + } catch (e) { + await magReselectArticle(a.id); + renderReview(); + say('Could not use the suggested title: ' + e.message, 'err'); + } +} + async function magApprove() { magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies const a = state.article; @@ -5082,12 +5176,12 @@ function renderPlaylistTracks() { const duration = a.audio_seconds || progress.duration || a.estimated_seconds; return '
  • ' + '' + '' + esc(fmtPages(a.pages)) + '' + '' + - '' + esc(a.title || 'Untitled') + '' + - (a.author ? 'by ' + esc(a.author) + '' : '') + + '' + esc(magDisplayTitle(a)) + '' + + ((a.page_label || a.author) ? '' + esc([a.page_label, a.author ? 'by ' + a.author : ''].filter(Boolean).join(' · ')) + '' : '') + '' + '' + fmtDuration(duration) + '' + '' + '' + '
  • '; }).join(''); @@ -6473,7 +6567,7 @@ function magDrivingIndexRender() { } function magIndexPlayIcon(ls) { return ls.completed ? 'mdi-replay' : ls.started ? 'mdi-restore' : 'mdi-play'; } function magIndexPlayLabel(ls, a) { - return (ls.completed ? 'Play again: ' : ls.started ? 'Resume: ' : 'Play: ') + (a.title || 'article'); + return (ls.completed ? 'Play again: ' : ls.started ? 'Resume: ' : 'Play: ') + magDisplayTitle(a); } // Live update of the three states (class, icons, accessible text) without re-rendering the 100+ rows. function magDrivingIndexRefresh() { @@ -6734,6 +6828,8 @@ root.magContentsCommitTitle = magContentsCommitTitle; root.magGenerateAllAudio = magGenerateAllAudio; root.magTogglePlaylist = magTogglePlaylist; root.magReviewPage = magReviewPage; +root.magMergeIntoPrevious = magMergeIntoPrevious; +root.magAcceptSuggestedTitle = magAcceptSuggestedTitle; root.magCommitDocField = magCommitDocField; root.magSetPagesCols = magSetPagesCols; root.magPlaylistPlay = magPlaylistPlay; diff --git a/static/style.css b/static/style.css index 96131fc..72f7c0f 100644 --- a/static/style.css +++ b/static/style.css @@ -5972,3 +5972,20 @@ html[data-mag-night] #toast.success { color: var(--green); } html[data-mag-night .mag-driving[data-dtheme="dark"] .mag-driving-zoomctl button:disabled { opacity: 1; color: var(--dim); } .mag-driving[data-dtheme="dark"] .mag-driving-zoomctl button { background: var(--surface); } .mag-driving[data-dtheme="dark"] .mag-driving-seek input[type="range"] { accent-color: var(--accent); } + +/* Untitled entries: page labels, kind/stale badges and the merge/title suggestion in review */ +.mag-page-label { display: block; font-size: 12px; font-weight: 400; color: var(--subtext); } +.mag-kind-badge, .mag-stale-badge { margin-left: 8px; font-size: 12px; } +.mag-stale-badge { white-space: normal; } +.mag-driving-index-list .ix-page { font-variant-numeric: tabular-nums; } +.mag-suggest { border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: var(--radius, 8px); + padding: 10px 12px; margin: 0 0 12px; background: var(--surface); color: var(--text); max-width: 100%; overflow-wrap: anywhere; } +.mag-suggest p { margin: 0 0 8px; } +.mag-suggest-head { font-size: 13px; color: var(--subtext); } +.mag-suggest-why { margin: 0 0 8px; } +.mag-suggest-why summary { cursor: pointer; min-height: 44px; display: flex; align-items: center; } +.mag-suggest-why ul { margin: 0 0 4px; padding-left: 20px; } +.mag-suggest .mag-suggest-btn { min-height: 44px; min-width: 44px; margin: 0 8px 8px 0; } +.mag-suggest summary:focus-visible, .mag-suggest .mag-suggest-btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } +@media (max-width: 480px) { .mag-suggest .mag-suggest-btn { width: 100%; justify-content: center; } } +.mag-stale-badge.mag-pill { color: var(--text); background: rgba(217,119,6,.16); border: 1px solid var(--yellow); } diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js index bdacda3..788cd10 100644 --- a/tests/magazine.spec.js +++ b/tests/magazine.spec.js @@ -3365,6 +3365,29 @@ test.describe('Driving mode layout and views', () => { : THREE.map((a) => ({ ...base, ...a, ...(extra.authors && a.id in extra.authors ? { author: extra.authors[a.id] } : {}) })); await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: extra.progress || {}, default_voice: '', podcast: { hosts: [] } } })); } + test('index: shows the derived title with its page label and keeps 44px hit areas and 1:1 circles', async ({ page }) => { + await setupIndex(page); + const base = { ...ARTICLE_APPROVED, status: 'approved', has_audio: true, audio_seconds: 30 }; + const arts = [ + { ...base, id: 'article-001', title: 'The Future of Batteries', display_title: 'The Future of Batteries', page_label: 'Page 1', pages: [1] }, + { ...base, id: 'article-002', title: 'Page 33', display_title: 'Anzeige', title_source: 'suggested', page_label: 'Seite 33', pages: [33] }, + ]; + await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: {}, default_voice: '', podcast: { hosts: [] } } })); + await page.setViewportSize({ width: 320, height: 568 }); + await open(page); + await page.locator('#mag-driving-indexbtn').click(); + const row = page.locator('#mag-driving-index-list .ix-item').nth(1); + await expect(row.locator('.ix-title')).toHaveText('Anzeige'); + await expect(row.locator('.ix-page')).toContainText('Seite 33'); + const g = await page.evaluate(() => ({ + plays: [...document.querySelectorAll('#mag-driving-index-list .ix-play')].map((e) => { const b = e.getBoundingClientRect(); return [b.width, b.height]; }), + rows: [...document.querySelectorAll('#mag-driving-index-list .ix-row')].map((e) => e.getBoundingClientRect().height), + overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, + })); + for (const [w, h] of g.plays) { expect(w).toBeGreaterThanOrEqual(44); expect(Math.abs(w - h)).toBeLessThan(1); } + for (const h of g.rows) expect(h).toBeGreaterThanOrEqual(44); + expect(g.overflow).toBe(false); + }); test('index: lists every article with title and duration, highlights the current one, jumps and keeps driving mode', async ({ page }) => { await setupIndex(page); await page.setViewportSize({ width: 375, height: 667 }); @@ -4119,3 +4142,82 @@ test.describe('Magazine: approving a multi-page article page by page', () => { expect(posts).toBe(0); }); }); + + +test.describe('Magazine: untitled entries (labels, merge suggestion, titles, stale audio)', () => { + const mk = (id, pg, y) => ({ id, page: pg, bbox: [50, y, 200, 40], type: 'body', text: 'Text ' + id + '. More words here.', reading_order: 0, confidence: 0.9, font_size: 10, font_name: '', bold: false, column: 0, source: 'native' }); + const BLOCKS = [mk('t1', 3, 60), mk('c1', 4, 60)]; + const REAL = { id: 'article-001', title: 'Real Story', display_title: 'Real Story', title_source: 'title', page_label: 'Seite 3', author: '', blocks: ['t1'], images: [], status: 'approved', confidence: 0.9, section: '', pages: [3], has_audio: true, audio_seconds: 5, audio_stale: false, untitled_kind: null, merge_suggestion: null }; + const STAND = { id: 'article-002', title: 'Page 4', display_title: 'Weiter im Text', title_source: 'suggested', suggested_title: 'Weiter im Text', page_label: 'Seite 4', author: '', blocks: ['c1'], images: [], status: 'detected', confidence: 0.5, section: '', pages: [4], has_audio: false, audio_stale: false, untitled_kind: 'continuation', + merge_suggestion: { into: 'article-001', into_title: 'Real Story', level: 'high', confidence: 0.91, evidence: ['starts in lower case', 'previous article ends mid-sentence'] } }; + + async function setup(page, { articles = [REAL, STAND], onMerge, onAccept } = {}) { + await mockBackend(page); + let list = articles.map((a) => ({ ...a })); + await page.route(`**/api/magazine/${DOC_ID}/layout`, r => r.fulfill({ json: { ...LAYOUT, page_count: 4, pages: [3, 4].map((p) => ({ page: p, width: 595, height: 842 })), blocks: BLOCKS } })); + await page.route(`**/api/magazine/${DOC_ID}/articles`, r => r.fulfill({ json: { articles: list, progress: {}, default_voice: '', podcast: { hosts: [] } } })); + await page.route(`**/api/magazine/${DOC_ID}/articles/article-00*`, r => r.fulfill({ json: list[0] })); + await page.route(`**/api/magazine/${DOC_ID}/articles/article-00*/merge-into-previous`, r => onMerge ? onMerge(r, (n) => { list = n; }) : r.fulfill({ json: { ok: true, undo: 'tok1', article: { ...REAL, audio_stale: true } } })); + await page.route(`**/api/magazine/${DOC_ID}/articles/article-00*/accept-title`, r => onAccept ? onAccept(r, (n) => { list = n; }) : r.fulfill({ json: { ok: true, undo: 'tok2', article: { ...STAND, title: 'Weiter im Text' } } })); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + await navTo(page, 's-magazine'); + await page.locator('#mag-library-list .mag-card').first().click(); + } + const reviewStandIn = async (page) => { + await page.locator('.mag-toc-row').filter({ hasText: 'Weiter im Text' }).getByRole('button', { name: 'Review', exact: true }).click(); + }; + + test('the article index shows display titles with page labels and the stale hint', async ({ page }) => { + await setup(page, { articles: [{ ...REAL, audio_stale: true }, STAND] }); + const rows = page.locator('.mag-toc-row'); + await expect(rows.nth(1).locator('.mag-toc-title')).toHaveText('Weiter im Text'); + await expect(rows.nth(1).locator('.mag-page-label')).toHaveText('Seite 4'); + await expect(rows.nth(1).locator('.mag-kind-badge')).toBeVisible(); + await expect(rows.nth(0).locator('.mag-stale-badge')).toContainText('Audio is out of date: re-render'); + await expect(rows.nth(1).locator('.mag-stale-badge')).toHaveCount(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); + // the play button of a row with audio keeps its hit area + const b = await rows.nth(0).locator('.mag-toc-play').boundingBox(); + expect(b.width).toBeGreaterThanOrEqual(30); + }); + + test('review explains the merge suggestion and posts the proposed target', async ({ page }) => { + let posted = null; + await setup(page, { onMerge: (r) => { posted = r.request().postDataJSON(); return r.fulfill({ json: { ok: true, undo: 'tok1', article: { ...REAL, audio_stale: true } } }); } }); + await reviewStandIn(page); + const box = page.locator('.mag-suggest'); + await expect(box).toContainText('Real Story'); + await expect(box).toContainText('high confidence'); + await box.locator('summary').click(); + await expect(box.locator('li')).toHaveCount(2); + const btn = page.getByRole('button', { name: 'Merge into previous article' }); + expect((await btn.boundingBox()).height).toBeGreaterThanOrEqual(44); + await btn.click(); + await expect.poll(() => posted).toEqual({ into: 'article-001' }); + await expect(page.locator('#mag-undo-bar')).toContainText('Undo'); + }); + + test('a 409 on merge refreshes the suggestion and merges nothing', async ({ page }) => { + await setup(page, { onMerge: (r) => r.fulfill({ status: 409, json: { detail: 'The proposal changed: it now merges into article-009' } }) }); + await reviewStandIn(page); + await page.getByRole('button', { name: 'Merge into previous article' }).click(); + await expect(page.locator('#toast')).toContainText('proposal changed'); + await expect(page.getByRole('button', { name: 'Merge into previous article' })).toBeEnabled(); + }); + + test('Use suggested title posts accept-title and offers undo', async ({ page }) => { + let hit = 0; + await setup(page, { onAccept: (r) => { hit++; return r.fulfill({ json: { ok: true, undo: 'tok2', article: { ...STAND, title: 'Weiter im Text' } } }); } }); + await reviewStandIn(page); + await page.getByRole('button', { name: 'Use suggested title' }).click(); + await expect.poll(() => hit).toBe(1); + await expect(page.locator('#mag-undo-bar')).toContainText('Title set to'); + }); + + test('review shows the out-of-date audio hint', async ({ page }) => { + await setup(page, { articles: [{ ...REAL, audio_stale: true, merge_suggestion: null }, STAND] }); + await page.locator('.mag-toc-row').filter({ hasText: 'Real Story' }).getByRole('button', { name: 'Review', exact: true }).click(); + await expect(page.locator('.mag-suggest-stale')).toContainText('Audio is out of date: re-render'); + }); +});