CG-Feedback-Monitor/server/db/queries/feedbackStructure.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

512 lines
24 KiB
TypeScript

import {
getAllRows, getRowsWhereIn, insertRow, updateRow, getRowById, insertRowWithId,
getRowsWhereEquals, deleteRowsWhereEquals, deleteRowById,
} from '../crud'
import type {
FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, FeedbackDimensionPrompt,
Assessment, ConversationEntry, MeetingInstance, Assignment, AssignmentType, PhaseTemplate, Consultant,
AppSettings,
} from '../../../src/db/types'
import { AI_CONFIG } from '../../../src/config/constants'
import { getOrSeedDefaultDimensionPromptTemplate } from './appSettings'
import {
parseFeedbackStructureMarkdown,
type FeedbackStructureSeed,
} from '../../../src/utils/feedbackStructureImport'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
export interface FeedbackStructure {
dimensions: FeedbackDimension[]
categories: FeedbackCategory[]
items: FeedbackCriterionItem[]
}
const byOrder = <T extends { order: number }>(rows: T[]) => [...rows].sort((a, b) => a.order - b.order)
/** Vollständige, nach `order` sortierte Feedback-Taxonomie. */
export function loadFeedbackStructure(): FeedbackStructure {
return {
dimensions: byOrder(getAllRows<FeedbackDimension>('feedbackDimensions')),
categories: byOrder(getAllRows<FeedbackCategory>('feedbackCategories')),
items: byOrder(getAllRows<FeedbackCriterionItem>('feedbackCriterionItems')),
}
}
export const listFeedbackCategoriesOrdered = (): FeedbackCategory[] => byOrder(getAllRows<FeedbackCategory>('feedbackCategories'))
export const listFeedbackCategoriesAll = (): FeedbackCategory[] => getAllRows<FeedbackCategory>('feedbackCategories')
export const listFeedbackCriterionItemsAll = (): FeedbackCriterionItem[] => getAllRows<FeedbackCriterionItem>('feedbackCriterionItems')
export const getFeedbackCriterionItemsByIds = (ids: number[]): FeedbackCriterionItem[] =>
getRowsWhereIn<FeedbackCriterionItem>('feedbackCriterionItems', 'id', ids)
export interface CriterionUsage {
assessmentCount: number
lineTagCount: number
generalNoteTagCount: number
assignmentTypeNames: string[]
phaseTemplateLabels: string[]
meetings: { meetingId: number; assignmentId: number; assignmentTitle: string; date: string }[]
}
/**
* Verwendungs-Übersicht für ALLE Kriterien in einem Durchlauf (nicht pro Kriterium einzeln) —
* macht sichtbar, wo ein Kriterium schon referenziert ist, bevor man es beim Umbau der
* Feedback-Struktur umbenennt/verschiebt/löscht. Siehe CLAUDE.md.
*/
export function getCriterionUsageMap(): Record<number, CriterionUsage> {
const usage = new Map<number, CriterionUsage>()
const ensure = (id: number): CriterionUsage => {
let u = usage.get(id)
if (!u) {
u = { assessmentCount: 0, lineTagCount: 0, generalNoteTagCount: 0, assignmentTypeNames: [], phaseTemplateLabels: [], meetings: [] }
usage.set(id, u)
}
return u
}
const meetings = getAllRows<MeetingInstance>('meetingInstances')
const meetingMap = new Map(meetings.map(m => [m.id!, m]))
const assignments = getAllRows<Assignment>('assignments')
const assignmentMap = new Map(assignments.map(a => [a.id!, a]))
const addMeetingRef = (u: CriterionUsage, meetingId: number) => {
if (u.meetings.some(x => x.meetingId === meetingId)) return
const m = meetingMap.get(meetingId)
if (!m) return
const a = assignmentMap.get(m.assignmentId)
u.meetings.push({ meetingId, assignmentId: m.assignmentId, assignmentTitle: a?.title ?? '—', date: m.date })
}
for (const a of getAllRows<Assessment>('assessments')) {
const u = ensure(a.criteriaId)
u.assessmentCount++
addMeetingRef(u, a.meetingInstanceId)
}
for (const e of getAllRows<ConversationEntry>('conversationEntries')) {
for (const tag of e.lineTags ?? []) {
const u = ensure(tag.criterionItemId)
u.lineTagCount++
addMeetingRef(u, e.meetingInstanceId)
}
}
for (const m of meetings) {
for (const tag of m.generalNoteTags ?? []) {
const u = ensure(tag.criterionItemId)
u.generalNoteTagCount++
addMeetingRef(u, m.id!)
}
}
for (const at of getAllRows<AssignmentType>('assignmentTypes')) {
for (const cid of at.criteriaIds ?? []) ensure(cid).assignmentTypeNames.push(at.name)
}
for (const pt of getAllRows<PhaseTemplate>('phaseTemplates')) {
for (const cid of pt.criteriaIds ?? []) ensure(cid).phaseTemplateLabels.push(pt.label)
}
return Object.fromEntries(usage)
}
export type CriterionUsageInstance =
| { kind: 'assessment'; assessmentId: number; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
| { kind: 'lineTag'; conversationEntryId: number; tagText: string; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
| { kind: 'generalNoteTag'; meetingInstanceId: number; tagText: string; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
| { kind: 'assignmentTypeVisibility'; assignmentTypeId: number; criterionId: number; text: string }
| { kind: 'phaseTemplateVisibility'; phaseTemplateId: number; criterionId: number; text: string }
/**
* Einzelinstanzen-Liste für EIN Kriterium (im Unterschied zu `getCriterionUsageMap`, das nur
* Zählungen für ALLE Kriterien liefert) — Grundlage dafür, eine einzelne Zuordnung direkt
* umzuhängen oder zu entfernen, ohne dafür ins jeweilige Meeting navigieren zu müssen.
* Bewusst lazy/pro-Kriterium (nicht eager für alle), da hier echte Texte/Namen aufgelöst werden.
*/
export function getCriterionUsageDetails(criterionId: number): CriterionUsageInstance[] {
const instances: CriterionUsageInstance[] = []
const meetings = getAllRows<MeetingInstance>('meetingInstances')
const meetingMap = new Map(meetings.map(m => [m.id!, m]))
const assignments = getAllRows<Assignment>('assignments')
const assignmentMap = new Map(assignments.map(a => [a.id!, a]))
const consultants = getAllRows<Consultant>('consultants')
const consultantMap = new Map(consultants.map(c => [c.id!, c]))
const meetingInfo = (meetingId: number) => {
const m = meetingMap.get(meetingId)
const a = m ? assignmentMap.get(m.assignmentId) : undefined
return { meetingId, assignmentTitle: a?.title ?? '—', date: m?.date ?? '' }
}
const personLabel = (instituteeId: number | undefined) => {
if (instituteeId === undefined) return 'Allgemein'
const c = consultantMap.get(instituteeId)
return c ? `${c.firstName} ${c.lastName}` : '—'
}
for (const a of getAllRows<Assessment>('assessments')) {
if (a.criteriaId !== criterionId) continue
instances.push({
kind: 'assessment', assessmentId: a.id!, personLabel: personLabel(a.instituteeId),
text: `Score: ${a.score ?? '—'}${a.note ? ` · Notiz: ${a.note}` : ''}`,
...meetingInfo(a.meetingInstanceId),
})
}
for (const e of getAllRows<ConversationEntry>('conversationEntries')) {
for (const tag of e.lineTags ?? []) {
if (tag.criterionItemId !== criterionId) continue
instances.push({
kind: 'lineTag', conversationEntryId: e.id!, tagText: tag.text, personLabel: personLabel(e.instituteeId),
text: tag.text,
...meetingInfo(e.meetingInstanceId),
})
}
}
for (const m of meetings) {
for (const tag of m.generalNoteTags ?? []) {
if (tag.criterionItemId !== criterionId) continue
instances.push({
kind: 'generalNoteTag', meetingInstanceId: m.id!, tagText: tag.text, personLabel: personLabel(undefined),
text: tag.text,
...meetingInfo(m.id!),
})
}
}
for (const at of getAllRows<AssignmentType>('assignmentTypes')) {
if (!at.criteriaIds?.includes(criterionId)) continue
instances.push({ kind: 'assignmentTypeVisibility', assignmentTypeId: at.id!, criterionId, text: at.name })
}
for (const pt of getAllRows<PhaseTemplate>('phaseTemplates')) {
if (!pt.criteriaIds?.includes(criterionId)) continue
instances.push({ kind: 'phaseTemplateVisibility', phaseTemplateId: pt.id!, criterionId, text: pt.label })
}
return instances
}
/** Hängt EINE einzelne Verwendungsinstanz auf ein anderes Kriterium um (Konsolidieren ohne den ganzen Rest mitzuziehen). */
export function reassignUsageInstance(instance: CriterionUsageInstance, toId: number): { merged: boolean } {
if (instance.kind === 'assessment') {
const allAssessments = getAllRows<Assessment>('assessments')
const row = allAssessments.find(a => a.id === instance.assessmentId)
if (!row) return { merged: false }
return { merged: mergeOrReassignAssessment(row, toId, allAssessments) }
}
if (instance.kind === 'lineTag') {
const e = getAllRows<ConversationEntry>('conversationEntries').find(x => x.id === instance.conversationEntryId)
if (!e?.lineTags) return { merged: false }
const lineTags = e.lineTags.map(t => t.text === instance.tagText ? { ...t, criterionItemId: toId } : t)
updateRow('conversationEntries', e.id!, { lineTags })
return { merged: false }
}
if (instance.kind === 'generalNoteTag') {
const m = getAllRows<MeetingInstance>('meetingInstances').find(x => x.id === instance.meetingInstanceId)
if (!m?.generalNoteTags) return { merged: false }
const generalNoteTags = m.generalNoteTags.map(t => t.text === instance.tagText ? { ...t, criterionItemId: toId } : t)
updateRow('meetingInstances', m.id!, { generalNoteTags })
return { merged: false }
}
if (instance.kind === 'assignmentTypeVisibility') {
const at = getAllRows<AssignmentType>('assignmentTypes').find(x => x.id === instance.assignmentTypeId)
if (!at?.criteriaIds) return { merged: false }
const criteriaIds = [...new Set(at.criteriaIds.map(id => id === instance.criterionId ? toId : id))]
updateRow('assignmentTypes', at.id!, { criteriaIds })
return { merged: false }
}
// phaseTemplateVisibility
const pt = getAllRows<PhaseTemplate>('phaseTemplates').find(x => x.id === instance.phaseTemplateId)
if (!pt?.criteriaIds) return { merged: false }
const criteriaIds = [...new Set(pt.criteriaIds.map(id => id === instance.criterionId ? toId : id))]
updateRow('phaseTemplates', pt.id!, { criteriaIds })
return { merged: false }
}
/** Entfernt EINE einzelne Verwendungsinstanz (Assessment wird gelöscht, Tags nur von der Kriterium-Zuordnung befreit, Text bleibt). */
export function removeUsageInstance(instance: CriterionUsageInstance): void {
if (instance.kind === 'assessment') {
deleteRowById('assessments', instance.assessmentId)
return
}
if (instance.kind === 'lineTag') {
const e = getAllRows<ConversationEntry>('conversationEntries').find(x => x.id === instance.conversationEntryId)
if (!e?.lineTags) return
updateRow('conversationEntries', e.id!, { lineTags: e.lineTags.filter(t => t.text !== instance.tagText) })
return
}
if (instance.kind === 'generalNoteTag') {
const m = getAllRows<MeetingInstance>('meetingInstances').find(x => x.id === instance.meetingInstanceId)
if (!m?.generalNoteTags) return
updateRow('meetingInstances', m.id!, { generalNoteTags: m.generalNoteTags.filter(t => t.text !== instance.tagText) })
return
}
if (instance.kind === 'assignmentTypeVisibility') {
const at = getAllRows<AssignmentType>('assignmentTypes').find(x => x.id === instance.assignmentTypeId)
if (!at?.criteriaIds) return
updateRow('assignmentTypes', at.id!, { criteriaIds: at.criteriaIds.filter(id => id !== instance.criterionId) })
return
}
// phaseTemplateVisibility
const pt = getAllRows<PhaseTemplate>('phaseTemplates').find(x => x.id === instance.phaseTemplateId)
if (!pt?.criteriaIds) return
updateRow('phaseTemplates', pt.id!, { criteriaIds: pt.criteriaIds.filter(id => id !== instance.criterionId) })
}
/**
* Schreibt EINE Assessment-Zeile auf `toId` um. Konfliktfall: existiert für dieselbe
* (meetingInstanceId, instituteeId) bereits eine Zeile mit criteriaId=toId (keine DB-Unique-
* Constraint verhindert das), wird `row` stattdessen in die bestehende Zeile gemergt (Notizen
* zusammengeführt, vorhandener Score der Zielzeile hat Vorrang) statt eine Dubletten-Zeile zu
* erzeugen. Von `reassignCriterionReferences` (alle Zeilen) UND `reassignUsageInstance` (eine
* einzelne Zeile) genutzt, damit die Konflikt-Logik nicht doppelt gepflegt werden muss.
*/
function mergeOrReassignAssessment(row: Assessment, toId: number, allAssessments: Assessment[]): boolean {
const conflict = allAssessments.find(a =>
a.id !== row.id && a.criteriaId === toId && a.meetingInstanceId === row.meetingInstanceId && a.instituteeId === row.instituteeId,
)
if (conflict) {
const mergedNote = [conflict.note, row.note].filter(n => n?.trim()).join('\n')
updateRow('assessments', conflict.id!, { note: mergedNote, score: conflict.score ?? row.score })
deleteRowById('assessments', row.id!)
return true
}
updateRow('assessments', row.id!, { criteriaId: toId })
return false
}
/**
* Überträgt alle Referenzen von `fromId` auf `toId` (Assessments, Protokoll-/Notiz-Tags,
* Sichtbarkeits-Listen) — für sicheres Löschen mit Zusammenführung statt Datenverlust.
*/
export function reassignCriterionReferences(fromId: number, toId: number): { mergedConflicts: number } {
let mergedConflicts = 0
const allAssessments = getAllRows<Assessment>('assessments')
for (const row of allAssessments.filter(a => a.criteriaId === fromId)) {
if (mergeOrReassignAssessment(row, toId, allAssessments)) mergedConflicts++
}
for (const e of getAllRows<ConversationEntry>('conversationEntries')) {
if (!e.lineTags?.some(t => t.criterionItemId === fromId)) continue
const lineTags = e.lineTags.map(t => t.criterionItemId === fromId ? { ...t, criterionItemId: toId } : t)
updateRow('conversationEntries', e.id!, { lineTags })
}
for (const m of getAllRows<MeetingInstance>('meetingInstances')) {
if (!m.generalNoteTags?.some(t => t.criterionItemId === fromId)) continue
const generalNoteTags = m.generalNoteTags.map(t => t.criterionItemId === fromId ? { ...t, criterionItemId: toId } : t)
updateRow('meetingInstances', m.id!, { generalNoteTags })
}
for (const at of getAllRows<AssignmentType>('assignmentTypes')) {
if (!at.criteriaIds?.includes(fromId)) continue
const criteriaIds = [...new Set(at.criteriaIds.map(id => id === fromId ? toId : id))]
updateRow('assignmentTypes', at.id!, { criteriaIds })
}
for (const pt of getAllRows<PhaseTemplate>('phaseTemplates')) {
if (!pt.criteriaIds?.includes(fromId)) continue
const criteriaIds = [...new Set(pt.criteriaIds.map(id => id === fromId ? toId : id))]
updateRow('phaseTemplates', pt.id!, { criteriaIds })
}
return { mergedConflicts }
}
/** Entfernt alle Referenzen auf `id` (Assessments werden gelöscht, Tags/Sichtbarkeitslisten nur von der ID befreit). */
function clearCriterionReferences(id: number): void {
deleteRowsWhereEquals('assessments', 'criteriaId', id)
for (const e of getAllRows<ConversationEntry>('conversationEntries')) {
if (!e.lineTags?.some(t => t.criterionItemId === id)) continue
updateRow('conversationEntries', e.id!, { lineTags: e.lineTags.filter(t => t.criterionItemId !== id) })
}
for (const m of getAllRows<MeetingInstance>('meetingInstances')) {
if (!m.generalNoteTags?.some(t => t.criterionItemId === id)) continue
updateRow('meetingInstances', m.id!, { generalNoteTags: m.generalNoteTags.filter(t => t.criterionItemId !== id) })
}
for (const at of getAllRows<AssignmentType>('assignmentTypes')) {
if (!at.criteriaIds?.includes(id)) continue
updateRow('assignmentTypes', at.id!, { criteriaIds: at.criteriaIds.filter(cid => cid !== id) })
}
for (const pt of getAllRows<PhaseTemplate>('phaseTemplates')) {
if (!pt.criteriaIds?.includes(id)) continue
updateRow('phaseTemplates', pt.id!, { criteriaIds: pt.criteriaIds.filter(cid => cid !== id) })
}
}
/** Legt zusätzlich einen Default-KI-Prompt für die neue Dimension an, siehe CLAUDE.md „Mehrstufiges Prompt-System". */
export function addFeedbackDimension(d: Omit<FeedbackDimension, 'id'>): number {
const id = insertRow('feedbackDimensions', d)
insertRow('feedbackDimensionPrompts', { dimensionId: id, promptTemplate: getOrSeedDefaultDimensionPromptTemplate() })
return id
}
export const updateFeedbackDimension = (id: number, patch: Partial<FeedbackDimension>): number =>
updateRow('feedbackDimensions', id, patch)
/** Cascade: Items der Kategorien (über deleteFeedbackCriterionItem, räumt Verwendungen mit auf) → Kategorien → Prompt-Konfiguration → Dimension. */
export function deleteFeedbackDimension(id: number): void {
const cats = getRowsWhereEquals<FeedbackCategory>('feedbackCategories', 'dimensionId', id)
for (const cat of cats) {
const catItems = getRowsWhereEquals<FeedbackCriterionItem>('feedbackCriterionItems', 'categoryId', cat.id!)
for (const item of catItems) deleteFeedbackCriterionItem(item.id!)
}
deleteRowsWhereEquals('feedbackCategories', 'dimensionId', id)
deleteRowsWhereEquals('feedbackDimensionPrompts', 'dimensionId', id)
deleteRowById('feedbackDimensions', id)
}
/**
* Selbstheilend: Dimensionen, die vor Einführung des Prompt-Systems (2026-07-07) angelegt wurden,
* haben noch keine `feedbackDimensionPrompts`-Zeile — wird hier per Backfill nachgezogen, statt eine
* separate Migration zu brauchen.
*/
export function listFeedbackDimensionPrompts(): FeedbackDimensionPrompt[] {
const dims = getAllRows<FeedbackDimension>('feedbackDimensions')
const existingDimIds = new Set(getAllRows<FeedbackDimensionPrompt>('feedbackDimensionPrompts').map(p => p.dimensionId))
const missing = dims.filter(dim => !existingDimIds.has(dim.id!))
if (missing.length > 0) {
const defaultTemplate = getOrSeedDefaultDimensionPromptTemplate()
for (const dim of missing) {
insertRow('feedbackDimensionPrompts', { dimensionId: dim.id!, promptTemplate: defaultTemplate })
}
}
return getAllRows<FeedbackDimensionPrompt>('feedbackDimensionPrompts')
}
/** Selbstheilend wie `listFeedbackDimensionPrompts` — legt die Zeile bei Bedarf lazy an. */
export function getFeedbackDimensionPrompt(dimensionId: number): FeedbackDimensionPrompt {
const existing = getRowsWhereEquals<FeedbackDimensionPrompt>('feedbackDimensionPrompts', 'dimensionId', dimensionId)[0]
if (existing) return existing
const promptTemplate = getOrSeedDefaultDimensionPromptTemplate()
const id = insertRow('feedbackDimensionPrompts', { dimensionId, promptTemplate })
return { id, dimensionId, promptTemplate }
}
export const updateFeedbackDimensionPrompt = (id: number, patch: Partial<FeedbackDimensionPrompt>): number =>
updateRow('feedbackDimensionPrompts', id, patch)
/** Setzt alle Dimension-Prompts auf den aktuellen Code-Standard (AI_CONFIG) und synchronisiert appSettings. */
export function resetAllDimensionPromptsToDefault(): { updated: number } {
const template = AI_CONFIG.defaultDimensionPromptTemplate
const settings = getRowById<AppSettings>('appSettings', 1)
if (settings) updateRow('appSettings', 1, { defaultDimensionPromptTemplate: template })
else insertRowWithId('appSettings', 1, {
ratingTrendWeightStep: 0.5,
defaultDimensionPromptTemplate: template,
})
const dims = getAllRows<FeedbackDimension>('feedbackDimensions')
let updated = 0
for (const dim of dims) {
const existing = getRowsWhereEquals<FeedbackDimensionPrompt>('feedbackDimensionPrompts', 'dimensionId', dim.id!)[0]
if (existing?.id) {
updateRow('feedbackDimensionPrompts', existing.id, { promptTemplate: template })
} else {
insertRow('feedbackDimensionPrompts', { dimensionId: dim.id!, promptTemplate: template })
}
updated++
}
return { updated }
}
export const addFeedbackCategory = (c: Omit<FeedbackCategory, 'id'>): number => insertRow('feedbackCategories', c)
export const updateFeedbackCategory = (id: number, patch: Partial<FeedbackCategory>): number =>
updateRow('feedbackCategories', id, patch)
/** Cascade: Items der Kategorie → Kategorie. */
export function deleteFeedbackCategory(id: number): void {
const catItems = getRowsWhereEquals<FeedbackCriterionItem>('feedbackCriterionItems', 'categoryId', id)
for (const item of catItems) deleteFeedbackCriterionItem(item.id!)
deleteRowById('feedbackCategories', id)
}
export const addFeedbackCriterionItem = (i: Omit<FeedbackCriterionItem, 'id'>): number => insertRow('feedbackCriterionItems', i)
export const updateFeedbackCriterionItem = (id: number, patch: Partial<FeedbackCriterionItem>): number =>
updateRow('feedbackCriterionItems', id, patch)
/**
* Räumt vor dem eigentlichen Löschen selbst alle Verwendungen auf (Verteidigung in der Tiefe,
* unabhängig davon ob die UI vorher per reassignCriterionReferences umgehängt hat) — siehe
* clearCriterionReferences() oben. Dadurch kann nach dem Löschen nie eine verwaiste Referenz
* übrig bleiben, egal über welchen Aufrufpfad (auch Kategorie-/Dimension-Cascade).
*/
export function deleteFeedbackCriterionItem(id: number): void {
clearCriterionReferences(id)
deleteRowById('feedbackCriterionItems', id)
}
export interface ReplaceFeedbackStructureResult {
dimensions: number
categories: number
items: number
clearedDimensions: number
}
/**
* Ersetzt die gesamte Feedback-Struktur durch ein Seed-Objekt (MD-Import / Preload).
* Löscht bestehende Dimensionen inkl. Cascade (Kategorien, Kriterien, Verwendungen, Prompts).
*/
export function replaceFeedbackStructure(seed: FeedbackStructureSeed): ReplaceFeedbackStructureResult {
if (!seed?.dimensions?.length) throw new Error('Seed enthält keine Dimensionen.')
const existing = getAllRows<FeedbackDimension>('feedbackDimensions')
for (const dim of existing) deleteFeedbackDimension(dim.id!)
let categoryCount = 0
let itemCount = 0
for (let di = 0; di < seed.dimensions.length; di++) {
const dimSeed = seed.dimensions[di]
const dimId = addFeedbackDimension({ name: dimSeed.name, order: di + 1 })
for (let ci = 0; ci < dimSeed.categories.length; ci++) {
const catSeed = dimSeed.categories[ci]
const catId = addFeedbackCategory({
dimensionId: dimId,
name: catSeed.name,
order: ci + 1,
description: catSeed.description,
})
categoryCount++
for (let ii = 0; ii < catSeed.items.length; ii++) {
const itemSeed = catSeed.items[ii]
addFeedbackCriterionItem({
categoryId: catId,
name: itemSeed.name,
order: ii + 1,
weight: itemSeed.weight,
description: itemSeed.description,
})
itemCount++
}
}
}
return {
dimensions: seed.dimensions.length,
categories: categoryCount,
items: itemCount,
clearedDimensions: existing.length,
}
}
/** Parst Markdown (Export-Format) und ersetzt die Struktur. */
export function importFeedbackStructureFromMarkdown(md: string): ReplaceFeedbackStructureResult {
return replaceFeedbackStructure(parseFeedbackStructureMarkdown(md))
}
/**
* Lädt die finale Struktur aus docs/preload/Feedback-Struktur-final.md (DEV-Preload).
* Pfad relativ zum Projektroot — nur innerhalb AssigmentMonitorV2.
*/
export function preloadFinalFeedbackStructure(): ReplaceFeedbackStructureResult {
const projectRoot = resolve(join(__dirname, '../../..'))
const mdPath = resolve(join(projectRoot, 'docs', 'preload', 'Feedback-Struktur-final.md'))
const rootNorm = projectRoot.toLowerCase()
const pathNorm = mdPath.toLowerCase()
if (!pathNorm.startsWith(rootNorm)) {
throw new Error(`[preload] SAFETY: Preload-Datei außerhalb des V2-Projekts: ${mdPath}`)
}
if (!existsSync(mdPath)) {
throw new Error(`Preload-Datei fehlt: ${mdPath}`)
}
const md = readFileSync(mdPath, 'utf8')
return importFeedbackStructureFromMarkdown(md)
}