diff --git a/CLAUDE.md b/CLAUDE.md index dbcb6b9..be2032e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,7 +149,7 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()` | L | Server-seitiges "Seed-if-empty" für Assignment-Typen/Feedback-Struktur (aktuell nur clientseitig vorhanden und nicht mehr wirksam) — nicht dringend, da der Backup-Import denselben Zweck erfüllt | `server/db/queries/` (neu) | | L | SQLite WASM + OPFS Migration — durch den lokalen Server-Ansatz (2026-07-04) überholt/hinfällig | `src/db/schema.ts` | -**Erledigt (2026-07-07):** KI-Feedback-Generierung neu konzipiert und umgesetzt — löst den zuvor diagnostizierten `response_format`/Markdown-Codefence-Bug strukturell statt punktuell. Statt einem Monolith-Call für alle Dimensionen macht `generateAllDimensions()` (`AssignmentFeedbackPage.tsx`) jetzt **sequenziell einen Call pro Dimension**, mit Fortschrittsanzeige und Teil-Fehlerbehandlung (fehlgeschlagene Dimensionen einzeln erneut versuchbar, `dimensionErrors`). Jede Dimension hat einen **vollständig frei editierbaren Prompt** (`FeedbackDimensionPrompt`, neuer Admin-Tab „KI-Prompts" → `AiPromptConfig.tsx`), der bei `addFeedbackDimension()` automatisch mit einem Default-Template vorbelegt wird (`AI_CONFIG.defaultDimensionPromptTemplate`) — neue Dimensionen funktionieren dadurch ohne Zutun. Antwortformat ist jetzt einfacher Text statt JSON (`KATEGORIE:`/`ACHIEVEMENTS:`/`DEVELOPMENT_NEEDS:`-Marker, geparst in `src/utils/aiPrompt.ts::parseDimensionAiResponse`), da pro Call ohnehin nur eine Dimension behandelt wird — macht den JSON/Codefence-Bug strukturell unmöglich statt ihn nur zu patchen. KI-generierte Texte landen zunächst als **Vorschlag** (`suggestedAchievements`/`suggestedDevelopmentNeeds` auf `FeedbackDimensionText`, `suggestedText` auf neuer Tabelle `FeedbackCategoryText`) und werden erst durch expliziten „Übernehmen"-Klick in die echten Felder kopiert (gleiches Muster wie die bestehende Rating-Vorschlags-Badge) — persistiert, übersteht also einen Seitenwechsel. Neu: kurzer KI-Text zusätzlich auf Kategorie-Ebene (`FeedbackCategoryText`), nicht nur auf Dimension-Ebene. Cross-Boundary-Falle dabei gefunden: `src/config/constants.ts` importierte bisher `FeedbackRating` aus dem Barrel `'../db'` (re-exportiert Dexie/rpcClient/Seeds) statt direkt aus `'../db/types'` — beim Import von `AI_CONFIG` in `server/db/queries/feedbackStructure.ts` hätte das transitiv Browser-only-Code (`fetch`, `localStorage`) in den Server-TS-Scope gezogen; auf direkten Typ-Import umgestellt und `src/config/constants.ts` zusätzlich explizit in `tsconfig.server.json`s `include` aufgenommen (gleiches Muster wie das dort schon gelistete `src/db/types.ts`). +**Erledigt (2026-07-07):** KI-Feedback-Generierung neu konzipiert und umgesetzt — löst den zuvor diagnostizierten `response_format`/Markdown-Codefence-Bug strukturell statt punktuell. Statt einem Monolith-Call für alle Dimensionen macht `generateAllDimensions()` (`AssignmentFeedbackPage.tsx`) jetzt **sequenziell einen Call pro Dimension**, mit Fortschrittsanzeige und Teil-Fehlerbehandlung (fehlgeschlagene Dimensionen einzeln erneut versuchbar, `dimensionErrors`). Jede Dimension hat einen **vollständig frei editierbaren Prompt** (`FeedbackDimensionPrompt`, neuer Admin-Tab „KI-Prompts" → `AiPromptConfig.tsx`), der bei `addFeedbackDimension()` automatisch mit einem Default-Template vorbelegt wird (`AI_CONFIG.defaultDimensionPromptTemplate`) — neue Dimensionen funktionieren dadurch ohne Zutun. Antwortformat ist jetzt einfacher Text statt JSON (`KATEGORIE:`/`ACHIEVEMENTS:`/`DEVELOPMENT_NEEDS:`-Marker, geparst in `src/utils/aiPrompt.ts::parseDimensionAiResponse`), da pro Call ohnehin nur eine Dimension behandelt wird — macht den JSON/Codefence-Bug strukturell unmöglich statt ihn nur zu patchen. KI-generierte Texte landen zunächst als **Vorschlag** (`suggestedAchievements`/`suggestedDevelopmentNeeds` auf `FeedbackDimensionText`, `suggestedText` auf neuer Tabelle `FeedbackCategoryText`) und werden erst durch expliziten „Übernehmen"-Klick in die echten Felder kopiert (gleiches Muster wie die bestehende Rating-Vorschlags-Badge) — persistiert, übersteht also einen Seitenwechsel. Neu: kurzer KI-Text zusätzlich auf Kategorie-Ebene (`FeedbackCategoryText`), nicht nur auf Dimension-Ebene. Cross-Boundary-Falle dabei gefunden: `src/config/constants.ts` importierte bisher `FeedbackRating` aus dem Barrel `'../db'` (re-exportiert Dexie/rpcClient/Seeds) statt direkt aus `'../db/types'` — beim Import von `AI_CONFIG` in `server/db/queries/feedbackStructure.ts` hätte das transitiv Browser-only-Code (`fetch`, `localStorage`) in den Server-TS-Scope gezogen; auf direkten Typ-Import umgestellt und `src/config/constants.ts` zusätzlich explizit in `tsconfig.server.json`s `include` aufgenommen (gleiches Muster wie das dort schon gelistete `src/db/types.ts`). Nachträglich (gleicher Tag, nach erstem Testlauf) zwei Lücken gefixt/ergänzt: (1) `database.ts` migrierte nur neue Tabellen, nie Spalten einer bereits bestehenden Tabelle — `suggestedAchievements` u.ä. auf `feedbackDimensionTexts` fehlten dadurch auf bereits laufenden Installationen und scheiterten erst zur Laufzeit; jetzt gleicht `database.ts` zusätzlich per `PRAGMA table_info` ab und zieht fehlende Spalten per `ALTER TABLE ... ADD COLUMN` nach (siehe Datenbank-Abschnitt oben). (2) Prompt-Vorschau ergänzt: pro Dimension ein „👁 KI-Prompt ansehen"-Toggle auf `AssignmentFeedbackPage.tsx`, der den vollständig gerenderten Prompt (Template + aufgelöste Platzhalter) ohne API-Call anzeigt — Feedback war, dass die generierten Texte „brauchbar, aber nicht konkret genug" waren und ohne Einblick in den tatsächlich gesendeten Prompt nicht nachvollziehbar. Zusätzlich neues optionales Feld `FeedbackCriterionItem.description` (editierbar in `FeedbackStructureConfig.tsx`, direkt unter dem Kriteriumsnamen), das automatisch in `{{KRITERIEN_SCORES}}` einfließt (`src/utils/aiPrompt.ts`) — macht die KI-Vorgabe pro Kriterium einmal zentral pflegbar statt in jedem Dimension-Prompt wiederholt werden zu müssen. Bewusst **nicht** reaktiviert: die bereits vorhandene, aber seit Langem unverdrahtete Legacy-Tabelle `CriterionLevelDescription` (Text pro Kriterium UND Rating-Stufe) — wäre eine präzisere Rubrik gewesen, aber deutlich mehr Pflege-/UI-Aufwand; bei Bedarf spätere, bewusste Entscheidung. **Erledigt (2026-07-04, zweite Runde):** Lokaler Node/Express-Server mit `node:sqlite` (statt IndexedDB) eingeführt, RPC-Bridge (`POST /api/rpc`) zwischen `src/db/queries/*.ts` (Client) und neuem `server/db/queries/*.ts` (Server) — siehe eigener Abschnitt "Datenbank: lokaler Server statt IndexedDB" oben für Details, Abweichungen (node:sqlite statt better-sqlite3) und die wichtige `npx tsc -b`-statt-`--noEmit`-Korrektur. Ziel: mehrere Browser auf demselben Laptop teilen sich dieselben Daten, unabhängig vom Browser-Cache, als Zwischenschritt vor einem Docker/Linux-Deployment. diff --git a/server/db/tableRegistry.ts b/server/db/tableRegistry.ts index dd7fd72..17bedc0 100644 --- a/server/db/tableRegistry.ts +++ b/server/db/tableRegistry.ts @@ -124,6 +124,7 @@ export const TABLE_DEFINITIONS: TableDef[] = [ col('name', 'TEXT'), col('weight', 'REAL'), col('order', 'INTEGER'), + col('description', 'TEXT'), ]}, { name: 'assignmentFeedbacks', columns: [ col('assignmentId', 'INTEGER'), diff --git a/src/config/constants.ts b/src/config/constants.ts index e62fce6..9dfa499 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -11,14 +11,16 @@ export const RATING_OPTIONS: ReadonlyArray<{ value: FeedbackRating short: string label: string + /** Ausgeschriebene Bezeichnung fürs KI-Prompting — "Not" allein ist für ein Modell nicht aussagekräftig, "Not Client Ready" transportiert das eigentliche Bewertungsziel des Trainee-Programms. */ + full: string color: string active: string }> = [ - { value: 'na', short: 'N/A', label: 'N/A (0)', color: 'bg-gray-100 text-gray-500 border-gray-300', active: 'bg-gray-400 text-white border-gray-400' }, - { value: 'not_client_ready', short: 'Not', label: 'Not (1)', color: 'bg-red-100 text-red-700 border-red-300', active: 'bg-red-500 text-white border-red-500' }, - { value: 'partially_client_ready', short: 'Partially', label: 'Partially (2)', color: 'bg-orange-100 text-orange-700 border-orange-300', active: 'bg-orange-500 text-white border-orange-500' }, - { value: 'nearly_client_ready', short: 'Nearly', label: 'Nearly (3)', color: 'bg-green-100 text-green-600 border-green-300', active: 'bg-green-400 text-white border-green-400' }, - { value: 'client_ready', short: 'Fully', label: 'Fully (4)', color: 'bg-green-100 text-green-800 border-green-400', active: 'bg-green-700 text-white border-green-700' }, + { value: 'na', short: 'N/A', label: 'N/A (0)', full: 'Not Applicable', color: 'bg-gray-100 text-gray-500 border-gray-300', active: 'bg-gray-400 text-white border-gray-400' }, + { value: 'not_client_ready', short: 'Not', label: 'Not (1)', full: 'Not Client Ready', color: 'bg-red-100 text-red-700 border-red-300', active: 'bg-red-500 text-white border-red-500' }, + { value: 'partially_client_ready', short: 'Partially', label: 'Partially (2)', full: 'Partially Client Ready', color: 'bg-orange-100 text-orange-700 border-orange-300', active: 'bg-orange-500 text-white border-orange-500' }, + { value: 'nearly_client_ready', short: 'Nearly', label: 'Nearly (3)', full: 'Nearly Client Ready', color: 'bg-green-100 text-green-600 border-green-300', active: 'bg-green-400 text-white border-green-400' }, + { value: 'client_ready', short: 'Fully', label: 'Fully (4)', full: 'Fully Client Ready', color: 'bg-green-100 text-green-800 border-green-400', active: 'bg-green-700 text-white border-green-700' }, ] /** Farb-Schemata für Teilnehmer-Badges (Index = Reihenfolge im Assignment). */ @@ -43,7 +45,7 @@ FÜLLWÖRTER GESAMT: {{FUELLWOERTER_GESAMT}} KATEGORIEN DIESER DIMENSION: {{KATEGORIEN_STRUKTUR}} -BEOBACHTETE KRITERIEN UND SCORES (0-4): +BEOBACHTETE KRITERIEN (nur mit tatsächlicher Bewertung oder Notiz): {{KRITERIEN_SCORES}} MANUELLE KATEGORIE-BEWERTUNGEN: @@ -55,24 +57,27 @@ ENTWICKLUNG ÜBER DIE LAUFZEIT (chronologisch je Kategorie): ALLGEMEINE MEETING-NOTIZEN (Berater-unabhängig, chronologisch): {{NOTIZEN_ALLGEMEIN_MEETING}} -NICHT EINEM KRITERIUM ZUGEORDNETE PROTOKOLL-NOTIZEN DIESES BERATERS (chronologisch, Notation: + gut, ! negativ, > Kundeninput): +NICHT EINEM KRITERIUM ZUGEORDNETE PROTOKOLL-NOTIZEN DIESES BERATERS (chronologisch, Bewertungs-Notation bereits als "Not/Partially/Nearly/Fully Client Ready — Text" bzw. "Kundenzitat — Text" aufgelöst): {{PROTOKOLL_UNZUGEORDNET}} -Schreibe für JEDE der oben gelisteten Kategorien einen kurzen Bewertungstext (2-3 Sätze, konkret, auf Beobachtungen gestützt) sowie abschließend einen Achievements-Absatz (2-4 Sätze, anerkennend) und einen Development-Needs-Absatz (2-4 Sätze, konstruktiv, handlungsorientiert) für die gesamte Dimension. Falls eine Kategorie aus den Daten nicht beurteilbar ist, schreibe "n/a". Beziehe erkennbare Verbesserungen/Verschlechterungen aus der Entwicklung über die Laufzeit ein. Schreibe direkt und persönlich (du-Form), vermeide generische Phrasen. +Schreibe für JEDE der oben gelisteten Kategorien 1-3 kurze Stichpunkte (konkret, auf Beobachtungen gestützt, jede Zeile beginnt mit "- "). Schreibe abschließend für die gesamte Dimension einen Achievements-Abschnitt (2-4 Stichpunkte, anerkennend, jede Zeile beginnt mit "- ") und einen Development-Needs-Abschnitt (2-4 Stichpunkte, jeder als konkrete Handlungsempfehlung formuliert, z.B. "Sollte darauf achten, dass ..." oder "Empfehlenswert wäre, ...", jede Zeile beginnt mit "- "). Falls eine Kategorie aus den Daten nicht beurteilbar ist, schreibe "- n/a". Beziehe erkennbare Verbesserungen/Verschlechterungen aus der Entwicklung über die Laufzeit ein. Schreibe direkt und persönlich (du-Form), vermeide generische Phrasen und ganze Fließtext-Absätze. Antworte AUSSCHLIESSLICH in exakt diesem Format, ohne zusätzliche Erklärungen, ohne Markdown-Formatierung: KATEGORIE: - +- +- KATEGORIE: - +- ACHIEVEMENTS: - +- +- DEVELOPMENT_NEEDS: -`, +- +- `, dimensionPromptPlaceholders: [ { token: '{{BERATER_NAME}}', description: 'Vor- und Nachname des Institutee' }, { token: '{{ASSIGNMENT_TITEL}}', description: 'Titel des Assignments' }, diff --git a/src/db/types.ts b/src/db/types.ts index ff781fd..792cea7 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -166,6 +166,8 @@ export interface FeedbackCriterionItem { name: string weight?: number order: number + /** Konkretisierung, worauf bei diesem Kriterium zu achten ist — fließt in {{KRITERIEN_SCORES}} der KI-Prompts ein. */ + description?: string } export interface AssignmentFeedback { diff --git a/src/pages/AssignmentFeedbackPage.tsx b/src/pages/AssignmentFeedbackPage.tsx index b3180b8..01498bc 100644 --- a/src/pages/AssignmentFeedbackPage.tsx +++ b/src/pages/AssignmentFeedbackPage.tsx @@ -96,6 +96,9 @@ export default function AssignmentFeedbackPage() { const [generationProgress, setGenerationProgress] = useState('') const [aiError, setAiError] = useState('') const [dimensionErrors, setDimensionErrors] = useState>({}) + const [previewOpen, setPreviewOpen] = useState>(new Set()) + const [previewText, setPreviewText] = useState>({}) + const [previewLoading, setPreviewLoading] = useState>(new Set()) const saveTimer = useRef>>(new Map()) const load = useCallback(async () => { @@ -264,6 +267,41 @@ export default function AssignmentFeedbackPage() { if (existing?.id) await updateFeedbackCategoryText(existing.id, { suggestedText: '' }) } + // ── Prompt-Vorschau (ohne API-Call) ─────────────────────────────────────── + + const togglePreview = async (dim: FeedbackDimension) => { + if (previewOpen.has(dim.id!)) { + setPreviewOpen(prev => { const next = new Set(prev); next.delete(dim.id!); return next }) + return + } + setPreviewOpen(prev => new Set(prev).add(dim.id!)) + if (previewText[dim.id!] || !institutee || !assignment) return + + setPreviewLoading(prev => new Set(prev).add(dim.id!)) + try { + const meetings = await listDoneMeetingsChronological(assignmentId) + const meetingIds = meetings.map(m => m.id!) + const [allAssessments, allEntries] = await Promise.all([ + listAssessmentsForMeetings(meetingIds), + listConversationEntriesForMeetings(meetingIds), + ]) + const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id) + const instEntries = allEntries.filter(e => e.instituteeId === institutee.id) + const dimCategories = categories.filter(c => c.dimensionId === dim.id) + const dimItems = items.filter(i => dimCategories.some(c => c.id === i.categoryId)) + + const promptCfg = await getFeedbackDimensionPrompt(dim.id!) + const context = buildDimensionPromptContext({ + institutee, assignment, dimension: dim, dimCategories, dimItems, + ratings, catTrends, instAssessments, instEntries, meetings, + }) + const rendered = renderDimensionPrompt(promptCfg.promptTemplate, context) + setPreviewText(prev => ({ ...prev, [dim.id!]: rendered })) + } finally { + setPreviewLoading(prev => { const next = new Set(prev); next.delete(dim.id!); return next }) + } + } + // ── KI-Generierung: ein Call pro Dimension ──────────────────────────────── const generateOneDimension = async ( @@ -526,6 +564,32 @@ export default function AssignmentFeedbackPage() { {isOpen && (
+ {/* Prompt-Vorschau */} +
+ + {previewOpen.has(dim.id!) && ( +
+ {previewLoading.has(dim.id!) ? ( +
Lädt…
+ ) : ( + <> +
+                            {previewText[dim.id!]}
+                          
+ + + )} +
+ )} +
+ {/* Kategorien */} {dimCats.map(cat => { const catItems = items.filter(i => i.categoryId === cat.id) diff --git a/src/pages/FeedbackStructureConfig.tsx b/src/pages/FeedbackStructureConfig.tsx index 575d5ac..a9e20d0 100644 --- a/src/pages/FeedbackStructureConfig.tsx +++ b/src/pages/FeedbackStructureConfig.tsx @@ -109,6 +109,11 @@ export default function FeedbackStructureConfig() { setItems(prev => prev.map(i => i.id === id ? { ...i, weight } : i)) } + const updateItemDescription = async (id: number, description: string) => { + await updateFeedbackCriterionItem(id, { description }) + setItems(prev => prev.map(i => i.id === id ? { ...i, description } : i)) + } + const sorted = [...dimensions].sort((a, b) => a.order - b.order) return ( @@ -184,22 +189,30 @@ export default function FeedbackStructureConfig() { {isCatOpen && (
{catItems.map(item => ( -
- · +
+
+ · + updateItem(item.id!, e.target.value)} + /> + updateItemWeight(item.id!, Number(e.target.value))} + title="Gewichtung" + className="w-14 text-xs border border-gray-200 rounded px-1 py-0.5 text-gray-500 bg-white focus:outline-none focus:border-brand" + /> + +
updateItem(item.id!, e.target.value)} + className="w-full ml-3.5 text-xs text-gray-400 border-b border-transparent focus:border-gray-300 focus:outline-none bg-transparent py-0.5" + placeholder="Beschreibung, worauf zu achten ist (optional, fließt in KI-Prompts ein)…" + value={item.description ?? ''} + onChange={e => updateItemDescription(item.id!, e.target.value)} /> - updateItemWeight(item.id!, Number(e.target.value))} - title="Gewichtung" - className="w-14 text-xs border border-gray-200 rounded px-1 py-0.5 text-gray-500 bg-white focus:outline-none focus:border-brand" - /> -
))}
diff --git a/src/utils/aiPrompt.ts b/src/utils/aiPrompt.ts index b201813..cebba3b 100644 --- a/src/utils/aiPrompt.ts +++ b/src/utils/aiPrompt.ts @@ -3,12 +3,26 @@ import type { FeedbackCategoryRating, Assessment, ConversationEntry, MeetingInstance, FeedbackRating, } from '../db/types' import { RATING_OPTIONS, RATING_NUM_MAP } from '../config/constants' -import { splitNoteLines } from './notationParser' -import type { TrendResult } from './ratingTrend' +import { splitNoteLines, parseNotationPrefix } from './notationParser' +import { numberToRating, type TrendResult } from './ratingTrend' const ratingToNum = (r: FeedbackRating | null | undefined): number => r ? (RATING_NUM_MAP[r] ?? 0) : 0 +const fullRatingLabel = (r: FeedbackRating): string => RATING_OPTIONS.find(o => o.value === r)?.full ?? r + +/** + * Löst ein Notation-Präfix (`+`/`(+)`/`(!)`/`!`/`>`) am Zeilenanfang zu einem ausgeschriebenen, + * mit " — " abgetrennten Label auf. Bewusst KEINE eckigen Klammern als Wrapper — die Notizen + * nutzen `[Titel]` bereits für Slide-/Themen-Referenzen (siehe SLIDE_RE in notationParser.tsx), + * eckige Klammern um das Label würden damit für die KI ununterscheidbar kollidieren. + */ +const formatNotationLine = (line: string): string => { + const { rating, isQuote, text } = parseNotationPrefix(line) + const tag = isQuote ? 'Kundenzitat' : rating ? fullRatingLabel(rating) : null + return tag ? `${tag} — ${text}` : text +} + export interface DimensionPromptInput { institutee: Consultant assignment: Assignment @@ -46,17 +60,24 @@ export function buildDimensionPromptContext(input: DimensionPromptInput): Record const catItems = dimItems.filter(i => i.categoryId === cat.id) const lines = catItems.map(c => { const scores = instAssessments.filter(a => a.criteriaId === c.id && a.score !== null && a.score !== 'na') - const avg = scores.length > 0 - ? (scores.reduce((s, a) => s + ratingToNum(a.score), 0) / scores.length).toFixed(1) - : '—' const notes = [ ...instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note), ...allLineTags.filter(t => t.criterionItemId === c.id).map(t => t.text), - ] - return ` ${c.name}: ${avg}/4${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}` - }).join('\n') - return ` ${cat.name}:\n${lines || ' (keine Kriterien)'}` - }).join('\n') || '(keine Scores erfasst)' + ].map(formatNotationLine) + // Kriterien ohne jede Beobachtung (weder Score noch Notiz) auslassen — reines Rauschen für die KI. + if (scores.length === 0 && notes.length === 0) return null + const avgNum = scores.length > 0 + ? scores.reduce((s, a) => s + ratingToNum(a.score), 0) / scores.length + : null + const avgLabel = avgNum !== null ? `${fullRatingLabel(numberToRating(avgNum))} (${avgNum.toFixed(1)})` : 'nur Notiz, keine Bewertung' + const label = c.description ? `${c.name} (${c.description})` : c.name + return ` ${label}: ${avgLabel}${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}` + }).filter((l): l is string => l !== null) + // Kategorien ohne jede Beobachtung ganz auslassen — tauchen bereits über KATEGORIEN_STRUKTUR/ + // KATEGORIE_BEWERTUNGEN/ENTWICKLUNG_VERLAUF auf, hier nur zusätzliches Rauschen. + if (lines.length === 0) return null + return ` ${cat.name}:\n${lines.join('\n')}` + }).filter((l): l is string => l !== null).join('\n') || '(keine Scores erfasst)' const kategorieBewertungen = dimCategories.map(cat => { const r = ratings.find(r => r.feedbackCategoryId === cat.id) @@ -76,8 +97,12 @@ export function buildDimensionPromptContext(input: DimensionPromptInput): Record }).join('\n') || '(keine Kategorien)' const notizenAllgemeinMeeting = meetings - .filter(m => m.generalNotes && m.generalNotes.trim() !== '') - .map(m => ` [${m.date}] ${m.generalNotes}`) + .map(m => { + if (!m.generalNotes || m.generalNotes.trim() === '') return null + const lines = splitNoteLines(m.generalNotes).map(line => ` - ${formatNotationLine(line)}`) + return ` [${m.date}]\n${lines.join('\n')}` + }) + .filter((x): x is string => x !== null) .join('\n') || '(keine allgemeinen Meeting-Notizen)' const protokollUnzugeordnet = meetings @@ -88,7 +113,7 @@ export function buildDimensionPromptContext(input: DimensionPromptInput): Record line, tagged: (e.lineTags ?? []).some(t => t.text === line), }))) .filter(x => !x.tagged) - .map(x => ` - ${x.line}`) + .map(x => ` - ${formatNotationLine(x.line)}`) if (lines.length === 0) return null return ` [${m.date}]\n${lines.join('\n')}` }) diff --git a/src/utils/notationParser.tsx b/src/utils/notationParser.tsx index 0cab838..c466659 100644 --- a/src/utils/notationParser.tsx +++ b/src/utils/notationParser.tsx @@ -11,11 +11,25 @@ const NOTATION_RATING_ORDER: [string, FeedbackRating][] = [ ] export function detectNotationRating(line: string): FeedbackRating | null { + return parseNotationPrefix(line).rating +} + +export interface NotationPrefixMatch { + rating: FeedbackRating | null + /** `>`-Präfix — Zitat/Kundeninput, keine Bewertung. */ + isQuote: boolean + /** Zeile ohne das erkannte Präfix. */ + text: string +} + +/** Wie `detectNotationRating`, liefert zusätzlich die Zitat-Kennzeichnung und den Text ohne Präfix. */ +export function parseNotationPrefix(line: string): NotationPrefixMatch { const trimmed = line.trim() + if (trimmed.startsWith('>')) return { rating: null, isQuote: true, text: trimmed.slice(1).trim() } for (const [prefix, rating] of NOTATION_RATING_ORDER) { - if (trimmed.startsWith(prefix)) return rating + if (trimmed.startsWith(prefix)) return { rating, isQuote: false, text: trimmed.slice(prefix.length).trim() } } - return null + return { rating: null, isQuote: false, text: trimmed } } // Notations-Regeln (Reihenfolge wichtig: längere Symbole zuerst)