fix(feedback): keine doppelte Anzeige von Title + Body

Bei kurzen Posts (oder wenn mana-llm fehlschlug) hat der Auto-Title-
Fallback `feedbackText.slice(0, 80)` den Body 1:1 als Title gespeichert
— Card zeigte dann zwei Mal denselben Text.

Zwei Schichten Schutz:

1. **Server (mana-analytics)**: catch-Branch wirft den Prefix-Fallback
   raus (title bleibt null). Zusätzlich neue isRedundantTitle()-Heuristik
   verwirft auch Auto-Titles, die nur ein truncierter Prefix des Bodies
   sind (Whitespace-collapse + Ellipsis-strip).

2. **Frontend (ItemCard)**: defensive showTitle-Computed — ältere DB-
   Items mit redundantem Title rendern automatisch nur den Body, ohne
   dass eine Datenbank-Cleanup nötig ist.

Title-Slot bleibt für echte Auto-Summaries und manuelle Titel sichtbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 17:37:51 +02:00
parent f754d4ecbb
commit 248549b15a
2 changed files with 54 additions and 3 deletions

View file

@ -30,6 +30,30 @@
let tier = $derived(tierFromKarma(item.karma ?? 0));
let tierCfg = $derived(KARMA_TIER_CONFIG[tier]);
// Hide the bold title when it's just a truncated/prefix copy of the
// body — pre-rename items have title === feedbackText for short posts.
let normalizedTitle = $derived(
(item.title ?? '')
.toLowerCase()
.replace(/[…\.]+$/u, '')
.replace(/\s+/g, ' ')
.trim()
);
let normalizedBody = $derived(
item.feedbackText
.toLowerCase()
.replace(/[…\.]+$/u, '')
.replace(/\s+/g, ' ')
.trim()
);
let showTitle = $derived(
!!item.title &&
normalizedTitle.length > 0 &&
normalizedTitle !== normalizedBody &&
!normalizedBody.startsWith(normalizedTitle) &&
!normalizedTitle.startsWith(normalizedBody)
);
function handleClick() {
if (onClick) onClick(item.id);
}
@ -72,7 +96,7 @@
</span>
<span class="muted">{formatDate(item.createdAt)}</span>
</div>
{#if item.title}
{#if showTitle}
<h3 class="title">{item.title}</h3>
{/if}
</header>

View file

@ -129,10 +129,19 @@ export class FeedbackService {
try {
title = await this.generateTitle(data.feedbackText);
} catch {
title = data.feedbackText.slice(0, 80);
// Don't fall back to a feedbackText prefix — it just duplicates
// the body in the UI. Leave title null; the card renders the
// text on its own.
title = undefined;
}
}
// If the auto-titler returned something that's effectively the same
// as the body (prefix match modulo whitespace + ellipsis), drop it.
if (title && isRedundantTitle(title, data.feedbackText)) {
title = undefined;
}
const displayHash = createDisplayHash(userId, this.pseudonymSecret);
const displayName = generateDisplayName(displayHash);
@ -141,7 +150,7 @@ export class FeedbackService {
.values({
userId,
appId: data.appId,
title: title || data.feedbackText.slice(0, 80),
title: title ?? null,
feedbackText: data.feedbackText,
category: (data.category as any) || 'other',
...(typeof data.isPublic === 'boolean' ? { isPublic: data.isPublic } : {}),
@ -742,6 +751,24 @@ export class FeedbackService {
}
}
/**
* True if the title is just a truncated/prefixed version of the body
* i.e. would render twice in the card. Compares whitespace-collapsed
* lowercase forms, ignoring trailing ellipsis on either side.
*/
function isRedundantTitle(title: string, body: string): boolean {
const norm = (s: string) =>
s
.toLowerCase()
.replace(/[\.]+$/u, '')
.replace(/\s+/g, ' ')
.trim();
const t = norm(title);
const b = norm(body);
if (!t || !b) return false;
return b === t || b.startsWith(t) || t.startsWith(b);
}
/** Strips userId / displayHash / deviceInfo from a row. */
function redact(
row: FeedbackRow,