CG-Feedback-Monitor/src/utils/aiPrompt.ts

605 lines
22 KiB
TypeScript
Raw Normal View History

2026-07-07 11:14:00 +02:00
import type {
Consultant, Assignment, FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
FeedbackCategoryRating, Assessment, ConversationEntry, MeetingInstance, RatingScore,
2026-07-07 11:14:00 +02:00
} from '../db/types'
import { clampRatingScore, isValidRatingScore } from '../config/constants'
2026-07-08 09:35:08 +02:00
import { splitNoteLines, parseNotationPrefix } from './notationParser'
import type { TrendResult } from './ratingTrend'
2026-07-08 09:35:08 +02:00
/**
* Löst ein Notations-Präfix (führende Score-Zahl 1–10 bzw. `>`) am Zeilenanfang auf.
* Format bewusst „Score N/10 — Text" bzw. „Kundenzitat — Text" — keine eckigen Klammern
* als Wrapper, da `[Titel]` bereits für Slide-/Themen-Referenzen reserviert ist.
2026-07-08 09:35:08 +02:00
*/
const formatNotationLine = (line: string): string => {
const { rating, isQuote, text } = parseNotationPrefix(line)
const tag = isQuote ? 'Kundenzitat' : rating !== null ? `Score ${rating}/10` : null
2026-07-08 09:35:08 +02:00
return tag ? `${tag} — ${text}` : text
}
/** Ganze Zeile besteht nur aus "[Titel]" — Abschnittsmarker des Bewerters (Slide-/Themen-Referenz). */
const MARKER_ONLY_RE = /^\[[^\]]*\]$/
/**
* Abschnittsmarker wie "[Background]" sind reine Navigationshilfe für den Bewerter (an welcher
* Stelle der Präsentation/des Dialogs eine Beobachtung entstand) — für die KI ohne nachfolgenden
* Inhalt bedeutungslos und keine Bewertungsgrundlage. Bleibt erhalten, wenn die direkt folgende
* Zeile noch Inhalt liefert (dann als Kontext-Header sinnvoll), sonst wird die Marker-Zeile entfernt.
*/
const dropDanglingMarkers = (lines: string[]): string[] =>
lines.filter((line, i) => {
if (!MARKER_ONLY_RE.test(line.trim())) return true
const next = lines[i + 1]
return next !== undefined && !MARKER_ONLY_RE.test(next.trim())
})
2026-07-07 11:14:00 +02:00
export interface DimensionPromptInput {
institutee: Consultant
assignment: Assignment
dimension: FeedbackDimension
dimCategories: FeedbackCategory[]
dimItems: FeedbackCriterionItem[]
ratings: FeedbackCategoryRating[]
catTrends: Map<number, TrendResult>
/** Bereits auf diesen Berater gefiltert. */
instAssessments: Assessment[]
/** Bereits auf diesen Berater gefiltert. */
instEntries: ConversationEntry[]
/** Alle `done`-Meetings des Assignments, chronologisch — siehe `listDoneMeetingsChronological`. */
meetings: MeetingInstance[]
}
/**
* Baut die Platzhalter-Werte für EINE Dimension. Kriterien/Kategorien werden vom Aufrufer
* bereits auf die Dimension gefiltert übergeben, siehe CLAUDE.md „Mehrstufiges Prompt-System".
*/
export function buildDimensionPromptContext(input: DimensionPromptInput): Record<string, string> {
const {
institutee, assignment, dimension, dimCategories, dimItems,
ratings, catTrends, instAssessments, instEntries, meetings,
} = input
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0)
const kategorienStruktur = dimCategories
.map((c, i) => `${i + 1}. ${c.name}${c.description ? ` — ${c.description}` : ''}`)
2026-07-07 11:14:00 +02:00
.join('\n') || '(keine Kategorien)'
const kriterienScores = dimCategories.map(cat => {
const catItems = dimItems.filter(i => i.categoryId === cat.id)
const lines = catItems.map(c => {
const scores = instAssessments.filter(a => a.criteriaId === c.id && isValidRatingScore(a.score))
const generalNotesForCrit = meetings.flatMap(m =>
(m.generalNoteTags ?? []).filter(t => t.criterionItemId === c.id).map(t => t.text),
)
2026-07-07 11:14:00 +02:00
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),
...generalNotesForCrit,
2026-07-08 09:35:08 +02:00
].map(formatNotationLine)
if (scores.length === 0 && notes.length === 0) return null
const avgNum = scores.length > 0
? scores.reduce((s, a) => s + (a.score as number), 0) / scores.length
2026-07-08 09:35:08 +02:00
: null
const clamped = avgNum !== null ? clampRatingScore(avgNum) : null
const avgLabel = avgNum !== null
? `${clamped ?? avgNum.toFixed(1)}/10 (Ø ${avgNum.toFixed(1)})`
: 'nur Notiz, keine Bewertung'
2026-07-08 09:35:08 +02:00
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)
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)'
2026-07-07 11:14:00 +02:00
const kategorieBewertungen = dimCategories.map(cat => {
const r = ratings.find(x => x.feedbackCategoryId === cat.id)
const rating: RatingScore | undefined = r?.rating
const label = isValidRatingScore(rating) ? `${rating}/10` : 'nicht bewertet'
return ` ${cat.name}: ${label}`
2026-07-07 11:14:00 +02:00
}).join('\n') || '(keine Kategorien)'
const entwicklungVerlauf = dimCategories.map(cat => {
const t = catTrends.get(cat.id!)
if (!t || t.history.length === 0) return ` ${cat.name}: keine Verlaufsdaten`
const seq = t.history.map(h => h.score.toFixed(1)).join(' → ')
const trendLabel = t.trend === 'up' ? 'Verbesserung über die Laufzeit'
: t.trend === 'down' ? 'Verschlechterung über die Laufzeit'
: t.trend === 'stable' ? 'stabil'
: 'nur ein Meeting, kein Trend ableitbar'
return ` ${cat.name}: Verlauf ${seq} (${trendLabel})`
}).join('\n') || '(keine Kategorien)'
const notizenAllgemeinMeeting = meetings
2026-07-08 09:35:08 +02:00
.map(m => {
if (!m.generalNotes || m.generalNotes.trim() === '') return null
const rawLines = splitNoteLines(m.generalNotes)
.filter(line => !(m.generalNoteTags ?? []).some(t => t.text === line))
.filter(line => !parseNotationPrefix(line).isQuote)
const lines = dropDanglingMarkers(rawLines).map(line => ` - ${formatNotationLine(line)}`)
if (lines.length === 0) return null
2026-07-08 09:35:08 +02:00
return ` [${m.date}]\n${lines.join('\n')}`
})
.filter((x): x is string => x !== null)
2026-07-07 11:14:00 +02:00
.join('\n') || '(keine allgemeinen Meeting-Notizen)'
const protokollUnzugeordnet = meetings
.map(m => {
const meetingEntries = instEntries.filter(e => e.meetingInstanceId === m.id)
const rawLines = meetingEntries
2026-07-07 11:14:00 +02:00
.flatMap(e => splitNoteLines(e.note).map(line => ({
line, tagged: (e.lineTags ?? []).some(t => t.text === line),
})))
.filter(x => !x.tagged)
.filter(x => !parseNotationPrefix(x.line).isQuote)
.map(x => x.line)
const lines = dropDanglingMarkers(rawLines).map(line => ` - ${formatNotationLine(line)}`)
2026-07-07 11:14:00 +02:00
if (lines.length === 0) return null
return ` [${m.date}]\n${lines.join('\n')}`
})
.filter((x): x is string => x !== null)
.join('\n') || '(keine nicht zugeordneten Protokolleinträge)'
return {
'{{BERATER_NAME}}': `${institutee.firstName} ${institutee.lastName}`,
'{{ASSIGNMENT_TITEL}}': assignment.title,
'{{ASSIGNMENT_KUNDE}}': assignment.client,
'{{DIMENSION_NAME}}': dimension.name,
'{{KATEGORIEN_STRUKTUR}}': kategorienStruktur,
'{{KRITERIEN_SCORES}}': kriterienScores,
'{{KATEGORIE_BEWERTUNGEN}}': kategorieBewertungen,
'{{ENTWICKLUNG_VERLAUF}}': entwicklungVerlauf,
'{{NOTIZEN_ALLGEMEIN_MEETING}}': notizenAllgemeinMeeting,
'{{PROTOKOLL_UNZUGEORDNET}}': protokollUnzugeordnet,
'{{FUELLWOERTER_GESAMT}}': String(fillerTotal),
}
}
export function renderDimensionPrompt(template: string, context: Record<string, string>): string {
let result = template
for (const [token, value] of Object.entries(context)) {
result = result.split(token).join(value)
}
return result
}
export const renderPrompt = renderDimensionPrompt
export interface CategoryPromptInput {
institutee: Consultant
assignment: Assignment
dimension: FeedbackDimension
category: FeedbackCategory
catItems: FeedbackCriterionItem[]
ratings: FeedbackCategoryRating[]
catTrend: TrendResult | undefined
instAssessments: Assessment[]
instEntries: ConversationEntry[]
meetings: MeetingInstance[]
}
export interface CategoryAiResult {
score: number | null
benefits: string
concerns: string
}
export interface CategoryCondenseEntry {
name: string
score: number | null
benefits: string
concerns: string
}
export interface DimensionCondenseInput {
institutee: Consultant
assignment: Assignment
dimension: FeedbackDimension
categoryResults: CategoryCondenseEntry[]
}
export interface ParsedDimensionCondenseResponse {
achievements: string
developmentNeeds: string
}
/** Stufe A: schmaler Kontext für genau eine Kategorie. */
export function buildCategoryPromptContext(input: CategoryPromptInput): Record<string, string> {
const {
institutee, assignment, dimension, category, catItems,
ratings, catTrend, instAssessments, instEntries, meetings,
} = input
const catItemIds = new Set(catItems.map(i => i.id))
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
const kriterienScores = catItems.map(c => {
const scores = instAssessments.filter(a => a.criteriaId === c.id && isValidRatingScore(a.score))
const generalNotesForCrit = meetings.flatMap(m =>
(m.generalNoteTags ?? []).filter(t => t.criterionItemId === c.id).map(t => t.text),
)
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),
...generalNotesForCrit,
].map(formatNotationLine)
if (scores.length === 0 && notes.length === 0) return null
const avgNum = scores.length > 0
? scores.reduce((s, a) => s + (a.score as number), 0) / scores.length
: null
const clamped = avgNum !== null ? clampRatingScore(avgNum) : null
const avgLabel = avgNum !== null
? `${clamped ?? avgNum.toFixed(1)}/10 (Ø ${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).join('\n') || '(keine Scores/Notizen)'
const r = ratings.find(x => x.feedbackCategoryId === category.id)
const manualRating = isValidRatingScore(r?.rating) ? `${r!.rating}/10` : 'nicht bewertet'
let entwicklungVerlauf = 'keine Verlaufsdaten'
if (catTrend && catTrend.history.length > 0) {
const seq = catTrend.history.map(h => h.score.toFixed(1)).join(' → ')
const trendLabel = catTrend.trend === 'up' ? 'Verbesserung über die Laufzeit'
: catTrend.trend === 'down' ? 'Verschlechterung über die Laufzeit'
: catTrend.trend === 'stable' ? 'stabil'
: 'nur ein Meeting, kein Trend ableitbar'
entwicklungVerlauf = `Verlauf ${seq} (${trendLabel})`
}
let trendVorschlag = 'Kein Meeting-Trend berechenbar — SCORE nur setzen wenn manuelle Bewertung oder Kriterien-Daten vorliegen.'
if (catTrend?.suggestion !== null && catTrend?.suggestion !== undefined) {
const s = catTrend.suggestion.toFixed(1)
trendVorschlag = `Vorgeschlagener Score aus Meeting-Verlauf: ${s}/10. Bestätige diesen Wert im SCORE-Feld oder passe maximal um ±1 an (nicht frei erfinden).`
}
const protokollGetaggt = meetings
.map(m => {
const meetingEntries = instEntries.filter(e => e.meetingInstanceId === m.id)
const rawLines = meetingEntries
.flatMap(e => splitNoteLines(e.note).map(line => ({
line,
tags: (e.lineTags ?? []).filter(t => t.text === line && catItemIds.has(t.criterionItemId)),
})))
.filter(x => x.tags.length > 0)
.filter(x => !parseNotationPrefix(x.line).isQuote)
.map(x => x.line)
const generalTagged = (m.generalNoteTags ?? [])
.filter(t => catItemIds.has(t.criterionItemId))
.map(t => t.text)
const allLines = [...rawLines, ...generalTagged]
const lines = dropDanglingMarkers(allLines).map(line => ` - ${formatNotationLine(line)}`)
if (lines.length === 0) return null
return ` [${m.date}]\n${lines.join('\n')}`
})
.filter((x): x is string => x !== null)
.join('\n') || '(keine getaggten Zeilen)'
const erlaeuterung = category.description
? `Erläuterung: ${category.description}`
: ''
return {
'{{BERATER_NAME}}': `${institutee.firstName} ${institutee.lastName}`,
'{{ASSIGNMENT_TITEL}}': assignment.title,
'{{ASSIGNMENT_KUNDE}}': assignment.client,
'{{DIMENSION_NAME}}': dimension.name,
'{{KATEGORIE_NAME}}': category.name,
'{{KATEGORIE_ERLAEUTERUNG}}': erlaeuterung,
'{{KRITERIEN_SCORES}}': kriterienScores,
'{{KATEGORIE_BEWERTUNG}}': manualRating,
'{{ENTWICKLUNG_VERLAUF}}': entwicklungVerlauf,
'{{TREND_VORSCHLAG}}': trendVorschlag,
'{{PROTOKOLL_GETAGGT}}': protokollGetaggt,
}
}
/** Stufe B: Verdichtung aus Kategorie-Ergebnissen (ohne Rohprotokoll). */
export function buildDimensionCondenseContext(input: DimensionCondenseInput): Record<string, string> {
const { institutee, assignment, dimension, categoryResults } = input
const kategorieErgebnisse = categoryResults.map(cr => {
const scoreLine = cr.score !== null ? `Score: ${cr.score}/10` : 'Score: nicht gesetzt'
const ben = cr.benefits.trim() || '- n/a'
const con = cr.concerns.trim() || '- n/a'
return `${cr.name}\n${scoreLine}\nBenefits:\n${ben}\nConcerns:\n${con}`
}).join('\n\n') || '(keine Kategorie-Ergebnisse)'
return {
'{{BERATER_NAME}}': `${institutee.firstName} ${institutee.lastName}`,
'{{ASSIGNMENT_TITEL}}': assignment.title,
'{{ASSIGNMENT_KUNDE}}': assignment.client,
'{{DIMENSION_NAME}}': dimension.name,
'{{KATEGORIE_ERGEBNISSE}}': kategorieErgebnisse,
}
}
export function parseCategoryAiResponse(raw: string): CategoryAiResult {
const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim()
const lines = cleaned.split('\n')
let score: number | null = null
let benefits = ''
let concerns = ''
type Mode = 'none' | 'benefits' | 'concerns'
let mode: Mode = 'none'
let buffer: string[] = []
const takeBuffer = () => {
const t = buffer.join('\n').trim()
buffer = []
return t
}
const append = (prev: string, next: string) => [prev, next].filter(Boolean).join('\n').trim()
for (const line of lines) {
const scoreMatch = line.match(SCORE_HDR)
const benMatch = line.match(BENEFITS_HDR)
const conMatch = line.match(CONCERNS_HDR)
if (scoreMatch) {
score = Number(scoreMatch[1])
continue
}
if (benMatch) {
const leftover = takeBuffer()
if (leftover && mode === 'concerns') concerns = append(concerns, leftover)
else if (leftover && mode === 'none') {
const split = splitLooseCategoryBody(leftover)
benefits = append(benefits, split.benefits)
concerns = append(concerns, split.concerns)
}
mode = 'benefits'
if (benMatch[2].trim()) benefits = append(benefits, benMatch[2].trim())
continue
}
if (conMatch) {
const leftover = takeBuffer()
if (leftover && mode === 'benefits') benefits = append(benefits, leftover)
mode = 'concerns'
if (conMatch[2].trim()) concerns = append(concerns, conMatch[2].trim())
continue
}
buffer.push(line)
}
const leftover = takeBuffer()
if (leftover) {
if (mode === 'benefits') benefits = append(benefits, leftover)
else if (mode === 'concerns') concerns = append(concerns, leftover)
else {
const split = splitLooseCategoryBody(leftover)
benefits = append(benefits, split.benefits)
concerns = append(concerns, split.concerns)
}
}
return { score, benefits, concerns }
}
export function parseDimensionCondenseResponse(raw: string): ParsedDimensionCondenseResponse {
const parsed = parseDimensionAiResponse(raw)
return {
achievements: parsed.achievements,
developmentNeeds: parsed.developmentNeeds,
}
}
2026-07-07 11:14:00 +02:00
export interface ParsedDimensionResponse {
categoryTexts: { name: string; benefits: string; concerns: string; score?: number | null }[]
2026-07-07 11:14:00 +02:00
achievements: string
developmentNeeds: string
}
const BENEFITS_HDR = /^(BENEFITS|STÄRKEN|STAERKEN)\s*:\s*(.*)$/i
const CONCERNS_HDR = /^(CONCERNS|RISIKEN|SCHWÄCHEN|SCHWAECHEN)\s*:\s*(.*)$/i
const ACHIEVEMENTS_HDR = /^ACHIEVEMENTS\s*:\s*(.*)$/i
const DEV_NEEDS_HDR = /^DEVELOPMENT_NEEDS\s*:\s*(.*)$/i
const SCORE_HDR = /^SCORE\s*:\s*(\d{1,2})\s*$/i
/** Lose Stichpunkte ohne BENEFITS/CONCERNS-Marker: nach Score-/Präfix-Hinweisen aufteilen. */
function splitLooseCategoryBody(raw: string): { benefits: string; concerns: string } {
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean)
if (lines.length === 0) return { benefits: '', concerns: '' }
const benefits: string[] = []
const concerns: string[] = []
let bucket: 'b' | 'c' | null = null
for (const line of lines) {
const benInline = line.match(BENEFITS_HDR)
if (benInline) {
bucket = 'b'
if (benInline[2].trim()) benefits.push(benInline[2].trim())
continue
}
const conInline = line.match(CONCERNS_HDR)
if (conInline) {
bucket = 'c'
if (conInline[2].trim()) concerns.push(conInline[2].trim())
continue
}
const bullet = line.replace(/^[-*•]\s*/, '')
if (/^(benefit|stärke|staerke|positiv)\b/i.test(bullet)) {
benefits.push(line)
continue
}
if (/^(concern|risiko|schwäche|schwaeche|negativ|sollte|empfehl)/i.test(bullet)) {
concerns.push(line)
continue
}
const scoreInLine = bullet.match(/\b(?:score|bewertung)?\s*([1-9]|10)\s*(?:\/\s*10)?\b/i)
?? bullet.match(/^([1-9]|10)\s*[:–—-]/)
if (scoreInLine) {
const n = Number(scoreInLine[1])
if (n >= 6) benefits.push(line)
else concerns.push(line)
continue
}
if (bucket === 'b') benefits.push(line)
else if (bucket === 'c') concerns.push(line)
else concerns.push(line)
}
return {
benefits: benefits.join('\n').trim(),
concerns: concerns.join('\n').trim(),
}
}
2026-07-07 11:14:00 +02:00
/**
* Marker-basiert (kein JSON). Tolerant gegenüber Text in derselben Zeile wie der Marker
* (`BENEFITS: - …`) und deutschen Synonymen. Ohne Untermarker: Heuristik über Score/Präfixe.
2026-07-07 11:14:00 +02:00
*/
export function parseDimensionAiResponse(raw: string): ParsedDimensionResponse {
const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim()
const lines = cleaned.split('\n')
const categoryTexts: { name: string; benefits: string; concerns: string; score?: number | null }[] = []
2026-07-07 11:14:00 +02:00
let achievements = ''
let developmentNeeds = ''
type Mode = 'none' | 'category' | 'catBenefits' | 'catConcerns' | 'achievements' | 'development'
let mode: Mode = 'none'
2026-07-07 11:14:00 +02:00
let currentName = ''
let catBenefits = ''
let catConcerns = ''
let catScore: number | null = null
2026-07-07 11:14:00 +02:00
let buffer: string[] = []
const takeBuffer = () => {
const t = buffer.join('\n').trim()
2026-07-07 11:14:00 +02:00
buffer = []
return t
}
const append = (prev: string, next: string) => [prev, next].filter(Boolean).join('\n').trim()
const flushCategory = () => {
if (!currentName) { buffer = []; return }
const leftover = takeBuffer()
let benefits = catBenefits
let concerns = catConcerns
if (mode === 'catBenefits' && leftover) benefits = append(benefits, leftover)
else if (mode === 'catConcerns' && leftover) concerns = append(concerns, leftover)
else if (mode === 'category' && leftover) {
if (!benefits && !concerns) {
const split = splitLooseCategoryBody(leftover)
benefits = split.benefits
concerns = split.concerns
} else {
concerns = append(concerns, leftover)
}
}
categoryTexts.push({ name: currentName, benefits, concerns, score: catScore })
currentName = ''
catBenefits = ''
catConcerns = ''
catScore = null
}
const flush = () => {
if (mode === 'category' || mode === 'catBenefits' || mode === 'catConcerns') flushCategory()
else if (mode === 'achievements') achievements = takeBuffer()
else if (mode === 'development') developmentNeeds = takeBuffer()
else buffer = []
}
const startDimBenefits = (sameLine: string) => {
flush()
mode = 'achievements'
currentName = ''
if (sameLine.trim()) buffer = [sameLine.trim()]
}
const startDimConcerns = (sameLine: string) => {
flush()
mode = 'development'
currentName = ''
if (sameLine.trim()) buffer = [sameLine.trim()]
2026-07-07 11:14:00 +02:00
}
for (const line of lines) {
const catMatch = line.match(/^KATEGORIE:\s*(.+)$/i)
const scoreMatch = line.match(SCORE_HDR)
const benMatch = line.match(BENEFITS_HDR)
const conMatch = line.match(CONCERNS_HDR)
const achMatch = line.match(ACHIEVEMENTS_HDR)
const devMatch = line.match(DEV_NEEDS_HDR)
const inCat = mode === 'category' || mode === 'catBenefits' || mode === 'catConcerns'
2026-07-07 11:14:00 +02:00
if (catMatch) {
flush()
mode = 'category'
currentName = catMatch[1].trim()
catBenefits = ''
catConcerns = ''
catScore = null
continue
}
if (inCat && scoreMatch) {
catScore = Number(scoreMatch[1])
continue
}
if (inCat && benMatch) {
const leftover = takeBuffer()
if (leftover && mode === 'category') {
const split = splitLooseCategoryBody(leftover)
catBenefits = append(catBenefits, split.benefits)
catConcerns = append(catConcerns, split.concerns)
} else if (leftover && mode === 'catConcerns') catConcerns = append(catConcerns, leftover)
else if (leftover && mode === 'catBenefits') catBenefits = append(catBenefits, leftover)
mode = 'catBenefits'
if (benMatch[2].trim()) catBenefits = append(catBenefits, benMatch[2].trim())
continue
}
if (inCat && conMatch) {
const leftover = takeBuffer()
if (leftover && mode === 'catBenefits') catBenefits = append(catBenefits, leftover)
else if (leftover && mode === 'catConcerns') catConcerns = append(catConcerns, leftover)
else if (leftover && mode === 'category') {
const split = splitLooseCategoryBody(leftover)
catBenefits = append(catBenefits, split.benefits)
catConcerns = append(catConcerns, split.concerns)
}
mode = 'catConcerns'
if (conMatch[2].trim()) catConcerns = append(catConcerns, conMatch[2].trim())
continue
}
if (achMatch) {
startDimBenefits(achMatch[1] ?? '')
continue
2026-07-07 11:14:00 +02:00
}
if (devMatch) {
startDimConcerns(devMatch[1] ?? '')
continue
}
if (!inCat && benMatch) {
startDimBenefits(benMatch[2] ?? '')
continue
}
if (!inCat && conMatch) {
startDimConcerns(conMatch[2] ?? '')
continue
}
buffer.push(line)
2026-07-07 11:14:00 +02:00
}
flush()
return { categoryTexts, achievements, developmentNeeds }
}