CG-Feedback-Monitor/src/utils/aiPrompt.ts
Lars 234f87d408 V2-Dev-Stand: Skala 1-10, Benefits/Concerns, DEV-Isolation und Gitea-Doku.
Bündelt die Neuentwicklung in AssigmentMonitorV2 (eigene Ports/DB), die Umstellung auf numerische Bewertungen, Meeting-Checklisten, KI-Tagging und die geplante Feedback-Kaskade — als Basis für Versionsverwaltung in Gitea.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 17:35:05 +02:00

605 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
Consultant, Assignment, FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
FeedbackCategoryRating, Assessment, ConversationEntry, MeetingInstance, RatingScore,
} from '../db/types'
import { clampRatingScore, isValidRatingScore } from '../config/constants'
import { splitNoteLines, parseNotationPrefix } from './notationParser'
import type { TrendResult } from './ratingTrend'
/**
* Löst ein Notations-Präfix (führende Score-Zahl 110 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.
*/
const formatNotationLine = (line: string): string => {
const { rating, isQuote, text } = parseNotationPrefix(line)
const tag = isQuote ? 'Kundenzitat' : rating !== null ? `Score ${rating}/10` : null
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())
})
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}` : ''}`)
.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),
)
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)
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(x => x.feedbackCategoryId === cat.id)
const rating: RatingScore | undefined = r?.rating
const label = isValidRatingScore(rating) ? `${rating}/10` : 'nicht bewertet'
return ` ${cat.name}: ${label}`
}).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
.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
return ` [${m.date}]\n${lines.join('\n')}`
})
.filter((x): x is string => x !== null)
.join('\n') || '(keine allgemeinen Meeting-Notizen)'
const protokollUnzugeordnet = meetings
.map(m => {
const meetingEntries = instEntries.filter(e => e.meetingInstanceId === m.id)
const rawLines = meetingEntries
.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)}`)
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,
}
}
export interface ParsedDimensionResponse {
categoryTexts: { name: string; benefits: string; concerns: string; score?: number | null }[]
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(),
}
}
/**
* 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.
*/
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 }[] = []
let achievements = ''
let developmentNeeds = ''
type Mode = 'none' | 'category' | 'catBenefits' | 'catConcerns' | 'achievements' | 'development'
let mode: Mode = 'none'
let currentName = ''
let catBenefits = ''
let catConcerns = ''
let catScore: number | null = null
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()
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()]
}
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'
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
}
if (devMatch) {
startDimConcerns(devMatch[1] ?? '')
continue
}
if (!inCat && benMatch) {
startDimBenefits(benMatch[2] ?? '')
continue
}
if (!inCat && conMatch) {
startDimConcerns(conMatch[2] ?? '')
continue
}
buffer.push(line)
}
flush()
return { categoryTexts, achievements, developmentNeeds }
}