import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, ConversationLineTag } from '../db/types' import { resolveVisibleStructure } from '../db/queries/feedbackStructure' import { findSimilarTaggedLines } from './criterionSuggestion' const byOrder = (rows: T[]) => [...rows].sort((a, b) => a.order - b.order) /** * `{{KRITERIEN_STRUKTUR}}` — ID-referenzierte, verschachtelte Liste Dimension → Kategorie * (Name+Beschreibung) → Kriterium (Name+Beschreibung), gefiltert auf die im Assignment-Typ * sichtbaren Kriterien über `resolveVisibleStructure()` (dieselbe Logik wie Meeting/Feedback). */ export function buildCriterionStructureText( dimensions: FeedbackDimension[], allCategories: FeedbackCategory[], allItems: FeedbackCriterionItem[], visibleCriteriaIds: number[] | undefined, ): string { const { dimensions: visibleDimensions, categories: visibleCategories, items: visibleItems, } = resolveVisibleStructure(dimensions, allCategories, allItems, visibleCriteriaIds) const text = byOrder(visibleDimensions).map(dim => { const dimCategories = byOrder(visibleCategories.filter(c => c.dimensionId === dim.id)) const catLines = dimCategories.map(cat => { const catItems = byOrder(visibleItems.filter(i => i.categoryId === cat.id)) const itemLines = catItems.map(item => { const label = item.description ? `${item.name} — ${item.description}` : item.name return ` [${item.id}] ${label}` }).join('\n') const catLabel = cat.description ? `${cat.name} — ${cat.description}` : cat.name return ` ${catLabel}\n${itemLines}` }).join('\n') return `${dim.name}\n${catLines}` }).join('\n') return text || '(keine sichtbaren Kriterien)' } export interface CriterionSuggestionLine { /** Stabiler, nur innerhalb dieser Analyse gültiger Anker — nicht persistiert. */ lineNumber: number text: string /** Institutee-Name bei Gesprächsbeiträgen, undefined bei allgemeinen Notizen. */ speakerLabel?: string } export interface CriterionSuggestionPromptInput { assignmentTypeName: string dimensions: FeedbackDimension[] categories: FeedbackCategory[] items: FeedbackCriterionItem[] visibleCriteriaIds: number[] | undefined lines: CriterionSuggestionLine[] /** Globaler Lern-Korpus, siehe `getGlobalLineTagCorpus()`. */ corpus: ConversationLineTag[] } const similarityLabel = (score: number): string => score >= 0.5 ? 'hoch' : score >= 0.25 ? 'mittel' : 'niedrig' export function buildCriterionSuggestionPromptContext(input: CriterionSuggestionPromptInput): Record { const { assignmentTypeName, dimensions, categories, items, visibleCriteriaIds, lines, corpus } = input const { items: visibleItems } = resolveVisibleStructure(dimensions, categories, items, visibleCriteriaIds) const itemName = (id: number) => items.find(i => i.id === id)?.name ?? `#${id}` const kriterienStruktur = buildCriterionStructureText(dimensions, categories, items, visibleCriteriaIds) const protokollZeilen = lines .map(l => `${l.lineNumber}. ${l.speakerLabel ? `[${l.speakerLabel}] ` : ''}${l.text}`) .join('\n') || '(keine offenen Zeilen)' const aehnlicheAltZuordnungen = lines.map(l => { const hints = findSimilarTaggedLines(l.text, corpus) if (hints.length === 0) return `Zeile ${l.lineNumber}: (keine ähnlichen Alt-Zeilen gefunden)` const hintLines = hints.map(h => ` - "${h.text}" → [${h.criterionItemId}] ${itemName(h.criterionItemId)} (Ähnlichkeit ${similarityLabel(h.score)})`) return `Zeile ${l.lineNumber}:\n${hintLines.join('\n')}` }).join('\n') || '(keine offenen Zeilen)' const gelernteMuster = visibleItems .filter(i => i.learnedPatternHints?.trim()) .map(i => ` [${i.id}] ${i.name}: ${i.learnedPatternHints}`) .join('\n') || '(noch keine gelernten Muster vorhanden)' return { '{{ASSIGNMENT_TYP}}': assignmentTypeName, '{{KRITERIEN_STRUKTUR}}': kriterienStruktur, '{{GELERNTE_MUSTER}}': gelernteMuster, '{{AEHNLICHE_ALT_ZUORDNUNGEN}}': aehnlicheAltZuordnungen, '{{PROTOKOLL_ZEILEN}}': protokollZeilen, } } export interface ParsedCriterionSuggestionResponse { /** Zeilennummer → priorisierte Kriterium-ID-Liste (leer = kein Vorschlag). */ lineSuggestions: Map /** Kriterium-ID → destilliertes Formulierungsmuster (Nebenprodukt dieses Laufs). */ patternHints: Map } /** * Parst das Klartext-Antwortformat (`ZEILE 3: 12, 47, 5` / `ZEILE 9: -`, gefolgt von einem * `MUSTER:`-Block) — bewusst kein JSON, gleiches Robustheitsprinzip wie `parseDimensionAiResponse` * (Codefence-Stripping vorab, tolerant gegenüber fehlenden/kaputten Zeilen, kein Absturz). */ export function parseCriterionSuggestionResponse(raw: string): ParsedCriterionSuggestionResponse { const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim() const lines = cleaned.split('\n') const lineSuggestions = new Map() const patternHints = new Map() let mode: 'lines' | 'patterns' = 'lines' let currentPatternId: number | null = null let patternBuffer: string[] = [] const flushPattern = () => { if (currentPatternId !== null) { const text = patternBuffer.join(' ').trim() if (text) patternHints.set(currentPatternId, text) } currentPatternId = null patternBuffer = [] } for (const rawLine of lines) { const line = rawLine.trim() if (/^MUSTER:\s*$/i.test(line)) { flushPattern() mode = 'patterns' continue } if (mode === 'lines') { const m = line.match(/^ZEILE\s+(\d+)\s*:\s*(.*)$/i) if (!m) continue const lineNumber = Number(m[1]) const rest = m[2].trim() if (rest === '' || rest === '-') { lineSuggestions.set(lineNumber, []) } else { const ids = rest.split(',').map(s => Number(s.trim())).filter(n => Number.isFinite(n)) lineSuggestions.set(lineNumber, ids) } } else { const m = line.match(/^(\d+)\s*:\s*(.*)$/) if (m) { flushPattern() currentPatternId = Number(m[1]) patternBuffer = [m[2].trim()] } else if (currentPatternId !== null && line !== '') { patternBuffer.push(line) } } } flushPattern() return { lineSuggestions, patternHints } }