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>
512 lines
24 KiB
TypeScript
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)
|
|
}
|