diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..47c42dd --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,3 @@ +#!/bin/sh +# Sperrliste auch für die Commit-Nachricht (scripts/sperrliste.mjs). +exec node scripts/sperrliste.mjs --nachricht "$1" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..d913f62 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,3 @@ +#!/bin/sh +# Sperrliste: keine Begriffe aus privaten Manuskripten committen (scripts/sperrliste.mjs). +exec node scripts/sperrliste.mjs diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..4a37c4e --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,3 @@ +#!/bin/sh +# Sperrliste: Branch-Namen und alle neuen Commits vor dem Push prüfen (scripts/sperrliste.mjs). +exec node scripts/sperrliste.mjs --push diff --git a/CLAUDE.md b/CLAUDE.md index a95f883..540fa6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ Lokale Mac-App (Tauri 2 + React/TypeScript) zum Schreiben und Planen von Romanen - `core/` ist reines TypeScript ohne DOM und ohne Tauri; dort liegt die testbare Logik (Vitest). - Die KI schreibt nie Text: `core/ki` darf nur `ProjektLeser` nutzen, nie `ProjektDateisystem`. - Releases: Tag `v*` → `.github/workflows/release.yml`, Ablauf in `docs/release.md`; Version mit `node scripts/version.mjs ` setzen. -- Keine Inhalte aus privaten Manuskripten ins Repo; Beispiele nur aus dem Demo-Roman in `fixtures/`. +- Keine Inhalte aus privaten Manuskripten ins Repo; Beispiele nur aus dem Demo-Roman in `fixtures/`. Die Git-Hooks in `.githooks/` (aktiv nach `npm install`) blockieren Commits und Pushes mit Begriffen aus `~/.config/plotwerk/sperrliste.txt`; `npm run sperrliste` prüft das ganze Repo. - Rust liegt unter `/opt/homebrew/opt/rustup/bin` (vor Cargo-Befehlen `export PATH="/opt/homebrew/opt/rustup/bin:$PATH"`). ## Befehle diff --git a/core/funktionen/funktionen.test.ts b/core/funktionen/funktionen.test.ts new file mode 100644 index 0000000..e3ef249 --- /dev/null +++ b/core/funktionen/funktionen.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import { + abhaengigeVon, + aktiveFunktionen, + ausschalten, + eigenesPresetLoeschen, + eigenesPresetSpeichern, + einschalten, + FUNKTION_IDS, + FUNKTIONEN, + kiFunktionen, + kiKontextUmfang, + kiStufe, + normalisiere, + PRESET_IDS, + PRESETS, + presetKennung, + profilAusJson, + profilMitAuswahl, + profilMitEigenemPreset, + profilMitPreset, + standardProfil, +} from './index'; + +describe('Katalog', () => { + it('jede Abhängigkeit zeigt auf eine bekannte Funktion, ohne Kreise', () => { + for (const f of FUNKTIONEN) { + for (const b of f.braucht ?? []) expect(FUNKTION_IDS).toContain(b); + expect(abhaengigeVon(f.id)).not.toContain(f.id); + } + }); + + it('KI-Funktionen haben eine Last, andere nicht', () => { + for (const f of FUNKTIONEN) expect(!!f.kiLast).toBe(!!f.ki); + }); + + it('Presets sind normalisiert und wachsen von Stufe zu Stufe', () => { + let vorher: readonly string[] = []; + for (const id of PRESET_IDS) { + const p = PRESETS[id]; + expect(normalisiere(p)).toEqual([...p].sort((a, b) => FUNKTION_IDS.indexOf(a) - FUNKTION_IDS.indexOf(b))); + for (const f of vorher) expect(p).toContain(f); + expect(p.length).toBeGreaterThan(vorher.length); + vorher = p; + } + expect(PRESETS.autor).toEqual(FUNKTION_IDS); + }); + + it('KI-Stufe steigt mit dem Preset', () => { + expect(PRESET_IDS.map((p) => kiStufe(PRESETS[p]))).toEqual(['gering', 'mittel', 'mittel', 'hoch']); + expect(kiStufe(PRESETS.autor, true)).toBe('aus'); + expect(kiStufe(['editor'])).toBe('keine'); + }); +}); + +describe('Auswahl', () => { + it('normalisiere ergänzt Pflicht und Abhängigkeiten, verwirft Unbekanntes', () => { + expect(normalisiere(['kiPlanAbgleich', 'gibtsnicht'])).toEqual(['editor', 'hinweise', 'kiPruefung', 'kiPlanAbgleich']); + }); + + it('einschalten nimmt Benötigtes mit', () => { + expect(einschalten(['editor'], 'boegen')).toEqual(['editor', 'figuren', 'boegen']); + }); + + it('ausschalten nimmt Abhängige mit, Pflicht bleibt', () => { + const alle = [...FUNKTION_IDS]; + const ohneFiguren = ausschalten(alle, 'figuren'); + expect(ohneFiguren).not.toContain('figuren'); + expect(ohneFiguren).not.toContain('boegen'); + expect(ohneFiguren).not.toContain('beziehungen'); + expect(ohneFiguren).not.toContain('kiStimme'); + expect(ohneFiguren).toContain('orte'); + expect(ausschalten(alle, 'editor')).toContain('editor'); + expect(ausschalten(alle, 'hinweise')).not.toContain('kiTiefencheck'); + }); +}); + +describe('Profil', () => { + it('Preset wählen und frei ändern: Basis wird „eigen“, zurück zum Preset wieder das Preset', () => { + const p = profilMitPreset(standardProfil(), 'einsteiger'); + expect(p.basis).toBe('einsteiger'); + const mehr = profilMitAuswahl(p, einschalten(p.funktionen, 'recherche')); + expect(mehr.basis).toBe('eigen'); + const zurueck = profilMitAuswahl({ ...mehr, basis: 'einsteiger' }, PRESETS.einsteiger); + expect(zurueck.basis).toBe('einsteiger'); + }); + + it('No-AI-Modus blendet KI-Funktionen aus, behält aber die Auswahl', () => { + const p = { ...profilMitPreset(standardProfil(), 'profi'), kiAus: true }; + const aktiv = aktiveFunktionen(p); + expect([...aktiv].some((id) => id.startsWith('ki'))).toBe(false); + expect(aktiv.has('zeitleiste')).toBe(true); + expect(p.funktionen).toContain('kiZeit'); + expect(kiFunktionen(p.funktionen)).toEqual(['kiPruefung', 'kiPlanAbgleich', 'kiErkennung', 'kiZeit', 'kiStimme', 'kiLeser']); + }); + + it('Plan-Felder gehen nur mit Plan-Abgleich an die KI', () => { + expect(kiKontextUmfang(profilMitPreset(standardProfil(), 'einsteiger')).plan).toBe(false); + expect(kiKontextUmfang(profilMitPreset(standardProfil(), 'fortgeschritten')).plan).toBe(true); + expect(kiKontextUmfang({ ...profilMitPreset(standardProfil(), 'autor'), kiAus: true }).plan).toBe(false); + }); + + it('eigene Presets speichern, überschreiben, wählen und löschen', () => { + let p = profilMitAuswahl(profilMitPreset(standardProfil(), 'einsteiger'), ['editor', 'recherche']); + p = eigenesPresetSpeichern(p, ' Mein Krimi '); + expect(p.eigenePresets).toEqual([{ id: 'eigen-mein-krimi', name: 'Mein Krimi', funktionen: ['editor', 'recherche'], kiAus: false }]); + expect(p.basis).toBe('eigen-mein-krimi'); + + p = eigenesPresetSpeichern(profilMitAuswahl(p, ['editor', 'orte']), 'mein krimi'); + expect(p.eigenePresets).toHaveLength(1); + expect(p.eigenePresets[0]!.funktionen).toEqual(['editor', 'orte']); + + p = profilMitEigenemPreset(profilMitPreset(p, 'autor'), 'eigen-mein-krimi'); + expect(p.funktionen).toEqual(['editor', 'orte']); + + p = eigenesPresetLoeschen(p, 'eigen-mein-krimi'); + expect(p.eigenePresets).toEqual([]); + expect(p.basis).toBe('eigen'); + }); + + it('Kennungen sind eindeutig und ohne Sonderzeichen', () => { + expect(presetKennung('Größe & Übermut', [])).toBe('eigen-grosse-ubermut'); + expect(presetKennung('A', ['eigen-a'])).toBe('eigen-a-2'); + expect(presetKennung('!!!', [])).toBe('eigen-preset'); + }); +}); + +describe('profilAusJson', () => { + it('liest ein gespeichertes Profil zurück', () => { + const p = eigenesPresetSpeichern(profilMitPreset(standardProfil(), 'profi'), 'X'); + expect(profilAusJson(JSON.parse(JSON.stringify({ ...p, eingerichtet: true })))).toEqual({ ...p, eingerichtet: true }); + }); + + it('Unbrauchbares ergibt null', () => { + expect(profilAusJson(null)).toBeNull(); + expect(profilAusJson({ version: 2, funktionen: [] })).toBeNull(); + expect(profilAusJson('profi')).toBeNull(); + }); + + it('neue Funktionen: nach Preset der Basis, bei eigener Auswahl eingeschaltet', () => { + const ohneZeit = FUNKTION_IDS.filter((id) => id !== 'zeitleiste' && id !== 'kiZeit'); + const einsteiger = profilAusJson({ version: 1, basis: 'einsteiger', funktionen: PRESETS.einsteiger, bekannt: ohneZeit }); + expect(einsteiger!.funktionen).not.toContain('zeitleiste'); + const profi = profilAusJson({ version: 1, basis: 'profi', funktionen: ['editor'], bekannt: ohneZeit }); + expect(profi!.funktionen).toEqual(['editor', 'zeitleiste', 'kiZeit']); + const eigen = profilAusJson({ version: 1, basis: 'eigen', funktionen: ['editor'], bekannt: ohneZeit }); + expect(eigen!.funktionen).toContain('kiZeit'); + }); + + it('ohne „bekannt“ gilt der ganze Katalog als bekannt (nichts wird nachträglich eingeschaltet)', () => { + expect(profilAusJson({ version: 1, basis: 'eigen', funktionen: ['editor'] })!.funktionen).toEqual(['editor']); + }); + + it('kaputte eigene Presets fallen weg, unbekannte Basis wird „eigen“', () => { + const p = profilAusJson({ + version: 1, + basis: 'eigen-weg', + funktionen: ['editor'], + eigenePresets: [{ id: 'eigen-ok', name: 'OK', funktionen: ['orte'] }, { id: 'kaputt', name: 'x' }, { id: 'eigen-leer', name: ' ' }], + })!; + expect(p.basis).toBe('eigen'); + expect(p.eigenePresets).toEqual([{ id: 'eigen-ok', name: 'OK', funktionen: ['editor', 'orte'], kiAus: false }]); + }); +}); diff --git a/core/funktionen/index.ts b/core/funktionen/index.ts new file mode 100644 index 0000000..c03fcd2 --- /dev/null +++ b/core/funktionen/index.ts @@ -0,0 +1,377 @@ +/** + * Funktionsumfang (E15): Katalog der abwählbaren Funktionen, Presets je Schreibertyp, eigene Presets und + * No-AI-Modus (E16). Reine Logik ohne DOM und ohne Tauri; Besitzer: Fundament. + * + * Grundsätze: + * - Ausgeblendet heißt nicht gelöscht. Daten ausgeblendeter Funktionen bleiben im Projekt und im Export erhalten; + * blendet man die Funktion wieder ein, ist alles wieder da. + * - Abhängigkeiten werden aufgelöst: Einschalten nimmt benötigte Funktionen mit, Ausschalten nimmt abhängige mit. + * - KI-Funktionen kosten nur Tokens, wenn sie laufen. Ausgeblendete KI-Funktionen laufen nie; der Plan-Abgleich + * bestimmt zusätzlich, ob Plan-Felder im Buchkontext jeder Prüfung mitgehen (`kiKontextUmfang`). + * - Neue Funktionen späterer Versionen: Was ein gespeichertes Profil noch nicht kannte (`bekannt`), wird nach + * seiner Basis entschieden (Preset: wie im Preset, sonst eingeschaltet). + */ + +export const FUNKTION_IDS = [ + // Schreiben + 'editor', + 'editorWerkzeuge', + 'statistik', + 'fassungen', + 'abgabe', + // Planen + 'kapitelUeberblick', + 'akte', + 'faeden', + 'zeitleiste', + 'recherche', + // Figuren & Welt + 'figuren', + 'boegen', + 'beziehungen', + 'orte', + // Prüfen & KI + 'hinweise', + 'kiPruefung', + 'kiPlanAbgleich', + 'kiTiefencheck', + 'kiErkennung', + 'kiZeit', + 'kiStimme', + 'kiLeser', +] as const; + +export type FunktionId = (typeof FUNKTION_IDS)[number]; + +export type FunktionsGruppe = 'schreiben' | 'planen' | 'figuren' | 'pruefen'; + +export const FUNKTIONS_GRUPPEN: readonly FunktionsGruppe[] = ['schreiben', 'planen', 'figuren', 'pruefen']; + +/** Modellrolle einer KI-Funktion (Anzeige): erkennung = kleines, pruefung = mittleres, tiefencheck = großes Modell */ +export type KiModellRolle = 'erkennung' | 'pruefung' | 'tiefencheck'; + +export interface FunktionDefinition { + id: FunktionId; + gruppe: FunktionsGruppe; + /** immer an, nicht abwählbar */ + pflicht?: true; + /** braucht die KI (im No-AI-Modus aus) */ + ki?: true; + /** grobe KI-Last je Lauf: 1 klein, 2 mittel, 3 groß (Modellgröße × Anfragen) */ + kiLast?: 1 | 2 | 3; + kiModell?: KiModellRolle; + /** muss eingeschaltet sein, damit diese Funktion sichtbar ist */ + braucht?: readonly FunktionId[]; +} + +export const FUNKTIONEN: readonly FunktionDefinition[] = [ + { id: 'editor', gruppe: 'schreiben', pflicht: true }, + { id: 'editorWerkzeuge', gruppe: 'schreiben' }, + { id: 'statistik', gruppe: 'schreiben' }, + { id: 'fassungen', gruppe: 'schreiben' }, + { id: 'abgabe', gruppe: 'schreiben' }, + { id: 'kapitelUeberblick', gruppe: 'planen' }, + { id: 'akte', gruppe: 'planen' }, + { id: 'faeden', gruppe: 'planen' }, + { id: 'zeitleiste', gruppe: 'planen' }, + { id: 'recherche', gruppe: 'planen' }, + { id: 'figuren', gruppe: 'figuren' }, + { id: 'boegen', gruppe: 'figuren', braucht: ['figuren'] }, + { id: 'beziehungen', gruppe: 'figuren', braucht: ['figuren'] }, + { id: 'orte', gruppe: 'figuren' }, + { id: 'hinweise', gruppe: 'pruefen' }, + { id: 'kiPruefung', gruppe: 'pruefen', ki: true, kiLast: 2, kiModell: 'pruefung', braucht: ['hinweise'] }, + { id: 'kiPlanAbgleich', gruppe: 'pruefen', ki: true, kiLast: 1, kiModell: 'pruefung', braucht: ['kiPruefung'] }, + { id: 'kiTiefencheck', gruppe: 'pruefen', ki: true, kiLast: 3, kiModell: 'tiefencheck', braucht: ['kiPruefung'] }, + { id: 'kiErkennung', gruppe: 'pruefen', ki: true, kiLast: 1, kiModell: 'erkennung' }, + { id: 'kiZeit', gruppe: 'pruefen', ki: true, kiLast: 1, kiModell: 'pruefung', braucht: ['zeitleiste'] }, + { id: 'kiStimme', gruppe: 'pruefen', ki: true, kiLast: 1, kiModell: 'pruefung', braucht: ['figuren'] }, + { id: 'kiLeser', gruppe: 'pruefen', ki: true, kiLast: 2, kiModell: 'pruefung' }, +]; + +const DEFINITION = new Map(FUNKTIONEN.map((f) => [f.id, f])); + +export function funktion(id: FunktionId): FunktionDefinition { + return DEFINITION.get(id)!; +} + +export function istFunktionId(id: unknown): id is FunktionId { + return typeof id === 'string' && DEFINITION.has(id as FunktionId); +} + +// --------------------------------------------------------------------------- +// Presets +// --------------------------------------------------------------------------- + +/** Schreibertypen im Einrichtungs-Assistenten */ +export const PRESET_IDS = ['einsteiger', 'fortgeschritten', 'profi', 'autor'] as const; +export type PresetId = (typeof PRESET_IDS)[number]; + +const EINSTEIGER: readonly FunktionId[] = [ + 'editor', + 'statistik', + 'kapitelUeberblick', + 'figuren', + 'orte', + 'hinweise', + 'kiPruefung', + 'kiErkennung', +]; +const FORTGESCHRITTEN: readonly FunktionId[] = [ + ...EINSTEIGER, + 'editorWerkzeuge', + 'fassungen', + 'akte', + 'recherche', + 'beziehungen', + 'kiPlanAbgleich', + 'kiStimme', +]; +const PROFI: readonly FunktionId[] = [...FORTGESCHRITTEN, 'faeden', 'zeitleiste', 'boegen', 'kiZeit', 'kiLeser']; + +export const PRESETS: Record = { + einsteiger: EINSTEIGER, + fortgeschritten: FORTGESCHRITTEN, + profi: PROFI, + autor: FUNKTION_IDS, +}; + +export function istPresetId(id: unknown): id is PresetId { + return typeof id === 'string' && (PRESET_IDS as readonly string[]).includes(id); +} + +// --------------------------------------------------------------------------- +// Auswahl: Abhängigkeiten auflösen +// --------------------------------------------------------------------------- + +/** In Katalog-Reihenfolge, nur bekannte IDs, Pflichtfunktionen und benötigte Funktionen ergänzt. */ +export function normalisiere(auswahl: Iterable): FunktionId[] { + const menge = new Set(); + const nimm = (id: FunktionId) => { + if (menge.has(id)) return; + menge.add(id); + for (const b of funktion(id).braucht ?? []) nimm(b); + }; + for (const id of auswahl) if (istFunktionId(id)) nimm(id); + for (const f of FUNKTIONEN) if (f.pflicht) menge.add(f.id); + return FUNKTION_IDS.filter((id) => menge.has(id)); +} + +/** Funktionen, die (direkt oder indirekt) `id` brauchen. */ +export function abhaengigeVon(id: FunktionId): FunktionId[] { + const ergebnis = new Set(); + const suche = (ziel: FunktionId) => { + for (const f of FUNKTIONEN) { + if (f.braucht?.includes(ziel) && !ergebnis.has(f.id)) { + ergebnis.add(f.id); + suche(f.id); + } + } + }; + suche(id); + return FUNKTION_IDS.filter((f) => ergebnis.has(f)); +} + +/** Schaltet `id` ein (mit allem, was sie braucht). */ +export function einschalten(auswahl: Iterable, id: FunktionId): FunktionId[] { + return normalisiere([...auswahl, id]); +} + +/** Schaltet `id` aus (mit allem, was sie braucht); Pflichtfunktionen bleiben. */ +export function ausschalten(auswahl: Iterable, id: FunktionId): FunktionId[] { + if (funktion(id).pflicht) return normalisiere(auswahl); + const weg = new Set([id, ...abhaengigeVon(id)]); + return normalisiere([...auswahl].filter((f) => !weg.has(f as FunktionId))); +} + +export function gleicheAuswahl(a: Iterable, b: Iterable): boolean { + const x = normalisiere(a); + const y = normalisiere(b); + return x.length === y.length && x.every((id, i) => id === y[i]); +} + +// --------------------------------------------------------------------------- +// KI-Umfang +// --------------------------------------------------------------------------- + +export type KiStufe = 'aus' | 'keine' | 'gering' | 'mittel' | 'hoch'; + +/** Summe der KI-Last der gewählten Funktionen (0 im No-AI-Modus). */ +export function kiLast(auswahl: Iterable, kiAus = false): number { + if (kiAus) return 0; + return normalisiere(auswahl).reduce((summe, id) => summe + (funktion(id).kiLast ?? 0), 0); +} + +/** Grobe Einordnung für die Anzeige: bis 3 gering, bis 8 mittel, darüber hoch. */ +export function kiStufe(auswahl: Iterable, kiAus = false): KiStufe { + if (kiAus) return 'aus'; + const last = kiLast(auswahl); + return last === 0 ? 'keine' : last <= 3 ? 'gering' : last <= 8 ? 'mittel' : 'hoch'; +} + +/** KI-Funktionen, die im No-AI-Modus wegfallen (aus der Auswahl). */ +export function kiFunktionen(auswahl: Iterable): FunktionId[] { + return normalisiere(auswahl).filter((id) => funktion(id).ki); +} + +// --------------------------------------------------------------------------- +// Profil (App-Einstellung `funktionen`, nie im Projektordner) +// --------------------------------------------------------------------------- + +export interface EigenesPreset { + /** stabile Kennung, z. B. „eigen-krimi“ */ + id: string; + name: string; + funktionen: FunktionId[]; + kiAus: boolean; +} + +/** Woher die Auswahl stammt: Schreibertyp, eigenes Preset (dessen id) oder freie Auswahl */ +export type ProfilBasis = PresetId | 'eigen' | `eigen-${string}`; + +export interface FunktionsProfil { + version: 1; + basis: ProfilBasis; + /** eingeschaltete Funktionen (normalisiert) */ + funktionen: FunktionId[]; + /** No-AI-Modus: alle KI-Funktionen aus, kein Aufruf an ein Modell */ + kiAus: boolean; + eigenePresets: EigenesPreset[]; + /** Einrichtungs-Assistent abgeschlossen (oder übersprungen) */ + eingerichtet: boolean; + /** Katalog beim Speichern – spätere Funktionen werden nach `basis` ergänzt */ + bekannt: FunktionId[]; +} + +/** Alles sichtbar, Assistent noch nicht gelaufen. */ +export function standardProfil(): FunktionsProfil { + return { + version: 1, + basis: 'autor', + funktionen: [...FUNKTION_IDS], + kiAus: false, + eigenePresets: [], + eingerichtet: false, + bekannt: [...FUNKTION_IDS], + }; +} + +/** Profil für bestehende Nutzer (vor E15): alles wie bisher, ohne Assistent. */ +export function bestandsProfil(): FunktionsProfil { + return { ...standardProfil(), eingerichtet: true }; +} + +export function profilMitPreset(profil: FunktionsProfil, preset: PresetId): FunktionsProfil { + return { ...profil, basis: preset, funktionen: normalisiere(PRESETS[preset]) }; +} + +export function profilMitEigenemPreset(profil: FunktionsProfil, id: string): FunktionsProfil { + const p = profil.eigenePresets.find((e) => e.id === id); + if (!p) return profil; + return { ...profil, basis: p.id as ProfilBasis, funktionen: normalisiere(p.funktionen), kiAus: p.kiAus }; +} + +/** Freie Änderung der Auswahl: Basis wird „eigen“, solange die Auswahl keinem Preset mehr entspricht. */ +export function profilMitAuswahl(profil: FunktionsProfil, funktionen: Iterable): FunktionsProfil { + const auswahl = normalisiere(funktionen); + return { ...profil, funktionen: auswahl, basis: passendeBasis(profil, auswahl) }; +} + +function passendeBasis(profil: FunktionsProfil, auswahl: FunktionId[]): ProfilBasis { + if (istPresetId(profil.basis) && gleicheAuswahl(PRESETS[profil.basis], auswahl)) return profil.basis; + const eigen = profil.eigenePresets.find((p) => p.id === profil.basis); + if (eigen && gleicheAuswahl(eigen.funktionen, auswahl)) return eigen.id as ProfilBasis; + return 'eigen'; +} + +/** Kennung aus dem Namen („Mein Krimi“ → „eigen-mein-krimi“), eindeutig unter den vorhandenen. */ +export function presetKennung(name: string, vorhanden: Iterable): string { + const slug = + name + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/ß/g, 'ss') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'preset'; + const belegt = new Set(vorhanden); + let id = `eigen-${slug}`; + for (let n = 2; belegt.has(id); n++) id = `eigen-${slug}-${n}`; + return id; +} + +/** + * Speichert die aktuelle Auswahl als eigenes Preset. Gibt es schon eines mit diesem Namen (ohne Groß-/Kleinschreibung), + * wird es überschrieben. Die Basis des Profils wird das gespeicherte Preset. + */ +export function eigenesPresetSpeichern(profil: FunktionsProfil, name: string): FunktionsProfil { + const sauber = name.trim(); + if (!sauber) return profil; + const alt = profil.eigenePresets.find((p) => p.name.toLocaleLowerCase() === sauber.toLocaleLowerCase()); + const id = alt?.id ?? presetKennung(sauber, profil.eigenePresets.map((p) => p.id)); + const preset: EigenesPreset = { id, name: sauber, funktionen: normalisiere(profil.funktionen), kiAus: profil.kiAus }; + const eigenePresets = alt + ? profil.eigenePresets.map((p) => (p.id === id ? preset : p)) + : [...profil.eigenePresets, preset]; + return { ...profil, eigenePresets, basis: id as ProfilBasis }; +} + +export function eigenesPresetLoeschen(profil: FunktionsProfil, id: string): FunktionsProfil { + return { + ...profil, + eigenePresets: profil.eigenePresets.filter((p) => p.id !== id), + basis: profil.basis === id ? 'eigen' : profil.basis, + }; +} + +/** Wirksam eingeschaltete Funktionen: Auswahl, im No-AI-Modus ohne KI-Funktionen. */ +export function aktiveFunktionen(profil: FunktionsProfil): Set { + return new Set(normalisiere(profil.funktionen).filter((id) => !(profil.kiAus && funktion(id).ki))); +} + +/** Umfang des Buchkontexts für die KI (core/ki): Plan-Felder nur mit Plan-Abgleich. */ +export interface KiKontextUmfang { + plan: boolean; +} + +export function kiKontextUmfang(profil: FunktionsProfil): KiKontextUmfang { + return { plan: aktiveFunktionen(profil).has('kiPlanAbgleich') }; +} + +// --------------------------------------------------------------------------- +// Laden (gespeicherter Wert ist unbekannte JSON-Struktur) +// --------------------------------------------------------------------------- + +function idListe(wert: unknown): FunktionId[] { + return Array.isArray(wert) ? wert.filter(istFunktionId) : []; +} + +function basisAus(wert: unknown, eigene: EigenesPreset[]): ProfilBasis { + if (istPresetId(wert)) return wert; + if (typeof wert === 'string' && eigene.some((p) => p.id === wert)) return wert as ProfilBasis; + return 'eigen'; +} + +/** Gespeichertes Profil prüfen und ergänzen; null = unbrauchbar (dann gilt das Standardprofil). */ +export function profilAusJson(wert: unknown): FunktionsProfil | null { + if (!wert || typeof wert !== 'object') return null; + const o = wert as Record; + if (o.version !== 1 || !Array.isArray(o.funktionen)) return null; + const eigenePresets: EigenesPreset[] = (Array.isArray(o.eigenePresets) ? o.eigenePresets : []) + .filter((p): p is Record => !!p && typeof p === 'object') + .filter((p) => typeof p.id === 'string' && p.id.startsWith('eigen-') && typeof p.name === 'string' && p.name.trim() !== '') + .map((p) => ({ id: p.id as string, name: (p.name as string).trim(), funktionen: normalisiere(idListe(p.funktionen)), kiAus: p.kiAus === true })); + const basis = basisAus(o.basis, eigenePresets); + // Funktionen, die es beim Speichern noch nicht gab: wie im Preset der Basis, sonst eingeschaltet + const bekannt = new Set(Array.isArray(o.bekannt) ? idListe(o.bekannt) : FUNKTION_IDS); + const neu = FUNKTION_IDS.filter((id) => !bekannt.has(id) && (!istPresetId(basis) || PRESETS[basis].includes(id))); + return { + version: 1, + basis, + funktionen: normalisiere([...idListe(o.funktionen), ...neu]), + kiAus: o.kiAus === true, + eigenePresets, + eingerichtet: o.eingerichtet === true, + bekannt: [...FUNKTION_IDS], + }; +} diff --git a/core/ki/anbieter.test.ts b/core/ki/anbieter.test.ts index 78156d1..9c62832 100644 --- a/core/ki/anbieter.test.ts +++ b/core/ki/anbieter.test.ts @@ -20,7 +20,7 @@ titel: Hafenlichter kapitel: - grundriss ki: - endpoint: https://flamingo.services.ai.azure.com/anthropic + endpoint: https://beispiel.services.ai.azure.com/anthropic deployments: erkennung: mein-haiku pruefung: mein-sonnet @@ -31,7 +31,7 @@ Notiz. `; const CLAUDE: KiEinstellungen = { - endpoint: 'https://flamingo.services.ai.azure.com/anthropic', + endpoint: 'https://beispiel.services.ai.azure.com/anthropic', deployments: { erkennung: 'mein-haiku', pruefung: 'mein-sonnet', tiefencheck: 'mein-opus' }, anbieter: 'claude', claude: { @@ -44,12 +44,12 @@ describe('KI-Anbieter', () => { it('ältere projekt.md lädt unverändert und gilt als Foundry', () => { const p = parseProjekt(ALT); expect(p.ki).toEqual({ - endpoint: 'https://flamingo.services.ai.azure.com/anthropic', + endpoint: 'https://beispiel.services.ai.azure.com/anthropic', deployments: { erkennung: 'mein-haiku', pruefung: 'mein-sonnet', tiefencheck: 'mein-opus' }, }); expect(kiAnbieter(p.ki)).toBe('foundry'); expect(kiModell(p.ki, 'pruefung')).toBe('mein-sonnet'); - expect(kiEndpoint(p.ki)).toBe('https://flamingo.services.ai.azure.com/anthropic'); + expect(kiEndpoint(p.ki)).toBe('https://beispiel.services.ai.azure.com/anthropic'); // unverändert zurückgeschrieben: kein anbieter/claude-Block, Datei bleibt identisch expect(serializeProjekt(p, ALT)).toBe(ALT); }); diff --git a/core/ki/index.ts b/core/ki/index.ts index 517fa5f..cc0b9eb 100644 --- a/core/ki/index.ts +++ b/core/ki/index.ts @@ -43,7 +43,7 @@ export { export { KiFehler, type KiFehlerArt } from './antwort'; export { kiMeldungen, type KiMeldung } from './meldungen'; export { KI_HINWEIS_TYPEN, MAX_TOKENS, EFFORT, type KiRolle } from './anfrage'; -export { baueBuchKontext, leseBuchKontext, type BuchKontext, type GelesenerKontext } from './kontext'; +export { baueBuchKontext, leseBuchKontext, type BuchKontext, type GelesenerKontext, type KontextUmfang } from './kontext'; export { ersatztextGrund, loseEndeId } from './pruefung'; export { formatiereUsd, kostenUsd, preisFuer, PREISE } from './kosten'; /** Vorschläge für Figuren, Orte, Beziehungen (Request-Form: README.md) */ @@ -76,6 +76,8 @@ export interface PruefAuftrag { tiefencheck?: boolean; /** Kapitel auch dann prüfen, wenn Text-Hash und Analyse unverändert sind */ erzwingen?: boolean; + /** ohne Plan-Felder im Buchkontext (Funktion „Plan-Abgleich“ ausgeblendet, E15): weniger Tokens, kein F-05 */ + ohnePlan?: boolean; } export interface PruefErgebnis { @@ -173,7 +175,7 @@ interface Lauf { async function planeLauf(leser: ProjektLeser, auftrag: PruefAuftrag, einstellungen: KiEinstellungen, oberflaeche: Sprache): Promise { const m = meldungenIn(oberflaeche); const daten = await ladeProjekt(leser); - const kontext = baueBuchKontext(daten); + const kontext = baueBuchKontext(daten, undefined, { plan: !auftrag.ohnePlan }); const rolle: KiRolle = auftrag.tiefencheck ? 'tiefencheck' : 'pruefung'; let kandidaten: KontextKapitel[]; if (auftrag.umfang === 'kapitel') { diff --git a/core/ki/ki.test.ts b/core/ki/ki.test.ts index b2a792e..3b552fe 100644 --- a/core/ki/ki.test.ts +++ b/core/ki/ki.test.ts @@ -687,6 +687,24 @@ describe('Lose Enden', () => { }); describe('F-05: Planung gegen Text', () => { + it('ohne Plan-Abgleich (E15) gehen keine Plan-Felder an das Modell und die Anfrage wird kleiner', async () => { + const mit = aufzeichnend(); + const ohne = aufzeichnend(); + const sMit = await schaetzeLauf(frischFs(), { umfang: 'buch' }, mit.transport, STANDARD_KI); + const sOhne = await schaetzeLauf(frischFs(), { umfang: 'buch', ohnePlan: true }, ohne.transport, STANDARD_KI); + await pruefe(frischFs(), { umfang: 'kapitel', kapitel: 'flut', ohnePlan: true }, ohne.transport, STANDARD_KI); + expect(sOhne.prefixTokens).toBeLessThan(sMit.prefixTokens); + expect(ohne.bodies.length).toBeGreaterThan(0); + for (const body of [...ohne.bodies, ...ohne.zaehlBodies]) { + // der System-Prompt erklärt weiterhin; im Buchkontext (messages) steht nichts davon + const kontext = JSON.stringify(body.messages); + expect(kontext).not.toContain('Alkohol'); + expect(kontext).not.toContain(''); + expect(kontext).not.toContain('Planung ·'); + } + expect(JSON.stringify(mit.zaehlBodies)).toContain('Alkohol'); + }); + it('Demo-KI meldet den Plan-Widerspruch bei Paul mit Plan-Bezug', async () => { const e = await pruefe(frischFs(), { umfang: 'buch' }, erstelleDemoKiTransport({ verzoegerungMs: 0 }), STANDARD_KI); const h = e.hinweise.find((x) => x.plan?.figur === 'paul'); diff --git a/core/ki/kontext.ts b/core/ki/kontext.ts index 4f53d02..dd42ed8 100644 --- a/core/ki/kontext.ts +++ b/core/ki/kontext.ts @@ -62,6 +62,12 @@ export interface KontextRealitaet { anker?: TextAnker; } +/** Was neben Text, Namen und Gewissheit in den Kontext geht (E15). Fehlt = alles laut Allowlist E12. */ +export interface KontextUmfang { + /** Plan-Felder von Figuren und Kapitelplänen (F-05); false = weglassen */ + plan: boolean; +} + export interface BuchKontext { kapitel: KontextKapitel[]; figuren: KontextFigur[]; @@ -158,8 +164,16 @@ export function planText(s: string | undefined): string { .trim(); } -/** `sprache`: Buchsprache (Standard: projekt.angaben.sprache, leer = Deutsch). */ -export function baueBuchKontext(daten: ProjektDaten, sprache: Sprache = kiSprache(daten.projekt.angaben)): BuchKontext { +/** + * `sprache`: Buchsprache (Standard: projekt.angaben.sprache, leer = Deutsch). `umfang.plan = false` lässt die + * Plan-Felder weg (Funktion „Plan-Abgleich“ ausgeblendet). + */ +export function baueBuchKontext( + daten: ProjektDaten, + sprache: Sprache = kiSprache(daten.projekt.angaben), + umfang: KontextUmfang = { plan: true }, +): BuchKontext { + const mitPlan = umfang.plan; const kapitel: KontextKapitel[] = daten.projekt.kapitel .map((id, i) => { const k = daten.kapitel[id]; @@ -169,7 +183,7 @@ export function baueBuchKontext(daten: ProjektDaten, sprache: Sprache = kiSprach titel: k?.titel ?? id, text: k ? klartext(k.text) : '', hash: textHash(k?.text ?? ''), - plan: { szene: planText(daten.plaene[id]?.szene), notiz: planText(daten.plaene[id]?.notiz) }, + plan: mitPlan ? { szene: planText(daten.plaene[id]?.szene), notiz: planText(daten.plaene[id]?.notiz) } : { szene: '', notiz: '' }, }; }); @@ -181,7 +195,7 @@ export function baueBuchKontext(daten: ProjektDaten, sprache: Sprache = kiSprach rolle: f.rolle === 'haupt' ? ('haupt' as const) : ('neben' as const), existenz: f.existenz, formen: [...(f.formen ?? [])], - plan: Object.entries(f.plan ?? {}) + plan: Object.entries(mitPlan ? (f.plan ?? {}) : {}) .map(([feld, wert]) => ({ feld: planText(feld), wert: planText(wert) })) .filter((p) => p.feld && p.wert), })) @@ -206,7 +220,7 @@ export function baueBuchKontext(daten: ProjektDaten, sprache: Sprache = kiSprach realitaet.push({ kapitel: r.anker.kapitel, stufe: r.stufe, text: '', anker: r.anker }); } - const text = renderBuch(kapitel, figuren, orte, gruppen, realitaet, sprache); + const text = renderBuch(kapitel, figuren, orte, gruppen, realitaet, sprache, mitPlan); return { kapitel, figuren, orte, gruppen, realitaet, text, sprache }; } @@ -217,12 +231,13 @@ function renderBuch( gruppen: KontextGruppe[], realitaet: KontextRealitaet[], sprache: Sprache, + mitPlan: boolean, ): string { const B = BESCHRIFTUNG[sprache]; const z: string[] = []; z.push(''); z.push(B.einleitung); - z.push(B.planHinweis); + if (mitPlan) z.push(B.planHinweis); z.push(''); z.push(B.figuren); if (figuren.length === 0) z.push(B.keine); diff --git a/docs/abnahme.md b/docs/abnahme.md index 8b878c9..31e1321 100644 --- a/docs/abnahme.md +++ b/docs/abnahme.md @@ -16,6 +16,11 @@ Stand 9. Oktober 2026. Grundlage: `docs/anforderungen-v1.0.md`, Entscheidungen E **Nachtrag 10. Oktober 2026 (Werkzeuge G1–G8):** 31 weitere Punkte (Abschnitt „Werkzeuge“) – 29 erfüllt, 2 teilweise (EW-03 in der Mac-App nicht per Klick gehört, MAC-01 nur ad-hoc signiert). Gesamt: `npm test` 861 Tests in 74 Dateien grün, `cargo test --workspace` grün, `npx tauri build --debug --bundles app` gebaut und 10 s ohne Fehler gestartet. +**Nachtrag 13. Oktober 2026 (Funktionsumfang FU-01 … FU-08):** alle 8 erfüllt. Nachweis: `core/funktionen/funktionen.test.ts` +(Katalog, Presets, Abhängigkeiten, eigene Presets, Laden), `core/ki/ki.test.ts` („ohne Plan-Abgleich …“, FU-07), +`src/features/funktionen/funktionen.test.tsx` (Assistent, Auswahl, Preset speichern, No-AI-Sperre, ausgeblendete +Bereiche mit Rückfrage), Browser (Assistent im Demo durchgeklickt). + Legende Nachweis: Testdatei (automatisch) · „Browser“ = im Browser-Demo bedient · „Code“ = im Quelltext geprüft. ## Akzeptanzkriterien diff --git a/docs/anforderungen-v1.1.md b/docs/anforderungen-v1.1.md index 3de55ce..fb149de 100644 --- a/docs/anforderungen-v1.1.md +++ b/docs/anforderungen-v1.1.md @@ -19,6 +19,12 @@ Oct 9, 2026 · @Leon Machnik · fortgeschrieben aus v1.0 - Hinweis-Typ „Zeitleiste“ entsteht jetzt auch lokal (Zeitprüfung), „Sprache“ auch aus der Figurenstimme. - Tastenkürzel ergänzt (⌘8, ⌘9, ⌘0, ⌘⇧T, ⌘⇧S). Nicht-Ziele präzisiert (Abgabeformate statt Buchsatz, kein iPhone-Begleiter). +**Nachtrag 13. Oktober 2026 – Funktionsumfang und Einrichtung** + +- Neuer Abschnitt „Funktionsumfang und Einrichtung“ mit den IDs FU-01 … FU-08: Einrichtungs-Assistent beim ersten Start, + Presets je Schreibertyp, eigene Presets, Funktionen ein- und ausblenden, KI-Umfang, No-AI-Modus. +- Neue Entscheidungen E15 (Funktionsumfang als App-Einstellung, ausgeblendet ≠ gelöscht) und E16 (No-AI-Modus). + ## Ziel und Leitprinzipien Plotwerk (Arbeitstitel) ist eine lokale Mac-App, in der Leon seinen Roman schreibt, plant und auf Stimmigkeit prüfen lässt. Die KI liest dabei nur mit und verändert nie den Text. @@ -53,7 +59,7 @@ Nutzer ist eine Person. Zum Start verwaltet die App ein Projekt; die Ordnerstruk **Datenschutz:** Nur beim Start einer Analyse gehen Daten an den gewählten KI-Anbieter (Microsoft Foundry oder Claude API), und nur, was auf der Allowlist steht (E12): Kapiteltext, Kapiteltitel, Namen und Aliase von Figuren und Orten, Existenz und Realitätsebene, Gruppennamen, Plan-Felder von Figuren und Kapitelplänen (Szene, „Was soll hier passieren?“). Nie: „Wahrheit hinter den Kulissen“, Notizen, Anmerkungen, Ideen, Abweichungen. Hosting und Region des Deployments sind egal; Leon akzeptiert die Verarbeitung in den USA. Ohne Netz funktioniert alles außer der KI-Prüfung. -## Grundsatzentscheidungen (E1–E14) +## Grundsatzentscheidungen (E1–E16) | # | Entscheidung | Grund | | --- | --- | --- | @@ -71,6 +77,8 @@ Nutzer ist eine Person. Zum Start verwaltet die App ein Projekt; die Ordnerstruk | E12 | Allowlist für alles, was an das Modell geht (siehe Datenschutz). Plan-Felder freigegeben am 2026-10-09 (F-05). | private Felder können nicht durchrutschen | | E13 | Ganzes Buch als gecachter Prefix (Prompt Caching) plus Aufgaben pro Kapitel; Ergebnis-Cache pro Kapitel-Text-Hash; Token-Schätzung über `count_tokens`. | findet kapitelübergreifende Widersprüche | | E14 | Zwei Anbieter zur Wahl: Microsoft Foundry (Deployment-Namen) oder Claude API (Modell-IDs, serverseitiger Fallback bei Sonnet/Opus 5.5). Hosting/Region egal. Modelle Haiku/Sonnet/Opus 5.5. | Leons Vorgabe | +| E15 | Funktionsumfang als App-Einstellung (nie im Projektordner): Presets je Schreibertyp, eigene Presets, freie Auswahl. Ausgeblendet heißt nicht gelöscht – Daten bleiben im Projekt und im Export. Ohne Plan-Abgleich gehen keine Plan-Felder an die KI. | Einstieg für neue Nutzer, weniger Tokens | +| E16 | No-AI-Modus: alle KI-Funktionen ausgeblendet, der KI-Transport ist gesperrt (auch `count_tokens`). | Datenschutz, Arbeiten ohne Zugang | ## Datenmodell und Projektordner @@ -254,6 +262,22 @@ Seit 10. Oktober 2026. Alles, was Leon festlegt, liegt als Markdown oder JSON im | MAC-01 | Mac | Ad-hoc-Signatur im Build; Skript für Developer-ID-Signatur und Notarisierung (`scripts/notarisieren.sh`), Anleitung `docs/mac-verteilung.md` | Soll | | MAC-02 | Mac | Updater-Vorlage (nicht aktiv) | Kann | +## Funktionsumfang und Einrichtung (E15/E16) + +Seit 13. Oktober 2026. Plotwerk hat alle Funktionen an Bord, zeigt aber nur, was zum Schreibertyp passt. Die Auswahl +gilt für die App (alle Projekte), nicht für ein Projekt. Logik: `core/funktionen`. + +| ID | Anforderung | Prio | +| --- | --- | --- | +| FU-01 | Beim ersten Start führt ein Assistent durch die Einrichtung: Schreibertyp → Funktionen → mit oder ohne KI → Fertig. Überspringen zeigt alles. Bestehende Nutzer (schon ein Projekt geöffnet) sehen ihn nicht und behalten alles. | Soll | +| FU-02 | Schreibertypen als Presets: Anfänger („Ich schreibe meine erste Geschichte“), Mittel („ein paar Geschichten geschrieben“), Profi („viele Geschichten veröffentlicht, z. B. auf Wattpad“), Schriftsteller („verkaufe bereits Bücher“) sowie „Alles selbst einstellen“. Jede Stufe enthält die vorige. | Soll | +| FU-03 | Die Funktionsauswahl zeigt jede Funktion mit Kurzbeschreibung, Abhängigkeiten („braucht Figuren“), KI-Kennzeichen mit Modellgröße und einem Beispiel aus dem Demo-Roman; Abhängigkeiten werden beim Ein- und Ausschalten aufgelöst. | Soll | +| FU-04 | Die aktuelle Auswahl lässt sich unter einem Namen als eigenes Preset speichern, später wählen und löschen. | Soll | +| FU-05 | Ausgeblendete Funktionen fehlen in Seitenleiste, Schnellsuche, Titelleisten und Inspektoren. Ruft man sie über Menü, Tastenkürzel oder Verweis auf, fragt die App, ob sie sie einblenden soll. Daten bleiben erhalten. | Soll | +| FU-06 | Einstellungen (⌘,) → Funktionen: aktueller Umfang, „Funktionen anpassen …“ (Ausgangspunkt, Auswahl, KI) und „Einrichtung erneut starten“. | Soll | +| FU-07 | KI-Umfang: Ausgeblendete KI-Funktionen laufen nie; ohne Plan-Abgleich enthält der Buchkontext der Prüfung keine Plan-Felder (weniger Tokens). Anzeige der KI-Nutzung als gering/mittel/hoch. | Soll | +| FU-08 | No-AI-Modus: blendet alle KI-Funktionen aus und sperrt den Transport. Vor dem Einschalten nennt die App konkret, welche gewählten Funktionen wegfallen; lokale Hinweise (Struktur, Zeit, Schreibweisen) bleiben. | Soll | + ## Erkennung und KI-Prüfung Die exakte Erkennung läuft lokal bei jeder Änderung. Die KI-Prüfung startet Leon bewusst per Knopf, für ein Kapitel oder das ganze Buch. diff --git a/docs/architektur.md b/docs/architektur.md index 20e79e4..ac6761a 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -22,6 +22,8 @@ Design: `docs/design/screens/*.dc.html` (verbindlich, Pixelwerte und Farben exak | E12 | **Allowlist** für alles, was an das Modell geht: Kapiteltext, Kapiteltitel, Figuren-/Ortsnamen + Aliase, Gewissheit von Figuren und Stellen (`existenz`/`realitaet`), Gruppen-Namen, **Plan-Felder von Figuren (`plan`) und Kapitelplänen (Szene, „Was soll hier passieren?“)** – freigegeben von Leon am 2026-10-09 für F-05. Nie: `wahrheit`, `notizen`, `kurz`, Ortsbeschreibungen, Abweichungen, Akte, Stränge, Anmerkungen, Ideen. Werkzeuge (2026-10-10): Die **Namen der Zustandsarten** (`plan/zeitleiste.md`, z. B. „nüchtern“, „betrunken“) gehen als Antwortvokabular der Zustands-Aufgabe (G1) mit – nur die Namen, keine eingetragenen Zustände, Zeitpunkte oder Notizen; von Leon am 2026-10-11 bestätigt. | private Felder können nicht durchrutschen; Plan ↔ Text-Abgleich (F-05) | | E13 | **Ganzes Buch als gecachter Prefix** (Prompt Caching) + Aufgaben pro Kapitel; Ergebnis-Cache pro Kapitel-Text-Hash. Token-Schätzung über `count_tokens`. | einfacher, findet kapitelübergreifende Widersprüche | | E14 | **Zwei KI-Anbieter, Wahl in den Einstellungen** (`projekt.ki.anbieter`, fehlt = Foundry): **Microsoft Foundry** (Endpoint `https://.services.ai.azure.com/anthropic`, eingetragen werden Deployment-Namen; Schlüsselbund-Konto `azure-foundry`) oder **Claude API** von Anthropic (Basis-URL `https://api.anthropic.com`, überschreibbar; Modell-IDs; Konto `anthropic-api`). Die Einstellungen des anderen Anbieters bleiben beim Umschalten erhalten. Modelle: `claude-haiku-5-5` (Erkennung), `claude-sonnet-5-5` (Prüfung), `claude-opus-5-5` (Tiefencheck). Nur Claude API: bei Sonnet/Opus 5.5 serverseitiger Fallback `fallbacks: "default"` (Beta `server-side-fallback-2026-07-01`), gesetzt in der Transportschicht. Hosting/Region egal (Leon akzeptiert Verarbeitung in den USA). | Leons Vorgabe | +| E15 | **Funktionsumfang als App-Einstellung** `funktionen` in `einstellungen.json` (Browser-Demo: `localStorage` `plotwerk:funktionen`), nie im Projektordner. Katalog, Presets (Anfänger/Mittel/Profi/Schriftsteller), Abhängigkeiten und eigene Presets in `core/funktionen`. Ausgeblendet ≠ gelöscht: Daten ausgeblendeter Funktionen bleiben im Projekt und im Export. Was ein gespeichertes Profil noch nicht kannte (`bekannt`), wird nach seiner Basis entschieden. Ohne „Plan-Abgleich“ setzt die Prüfung `PruefAuftrag.ohnePlan` (keine Plan-Felder im Kontext). | Einstieg für neue Nutzer, weniger Tokens (2026-10-13) | +| E16 | **No-AI-Modus** (`profil.kiAus`): alle KI-Funktionen ausgeblendet; `mitKiSperre` (src/state/funktionen.ts) umhüllt den KI-Transport des Backends und lehnt `senden` und `tokenZaehlen` ab. | Datenschutz, Arbeiten ohne Zugang (2026-10-13) | ## Phasen @@ -119,6 +121,8 @@ Jedes Verzeichnis hat genau einen Besitzer. Fremde Dateien nur lesen. Braucht ei | `src-tauri/` | Rust-Kern, Tauri-Konfiguration | Tauri | | `fixtures/` | Demo-Projekt „Hafenlichter“ (frei erfundener Roman, Testdaten) | Fundament | | `abnahme/` | Akzeptanztests AK-1 … AK-10 über alle Module (`docs/abnahme.md`) | Abnahme | +| `core/funktionen/` | Funktionskatalog, Presets, Abhängigkeiten, KI-Last, Profil laden (E15/E16) | Fundament | +| `src/state/funktionen.ts`, `src/app/funktionen.ts`, `src/app/NurMit.tsx`, `src/features/funktionen/` | Profil-Zustand und Speicherung, KI-Sperre, Zuordnung Bereich/Befehl → Funktion, Assistent, Funktionsauswahl, Dialog „Funktionen“ | Fundament | ### Werkzeuge G1–G8 (ab 2026-10-10) @@ -159,6 +163,25 @@ Zeitpunkte, Zustände, Bögen, Marken, Recherche und Lektorat gehören nicht daz - Rust-Kommandos (camelCase-Argumente via Tauri): `projekt_oeffnen(pfad)`, `fs_lesen(pfad)`, `fs_schreiben(pfad, inhalt)`, `fs_auflisten(ordner)`, `fs_existiert(pfad)`, `fs_entfernen(pfad)`, `vorlesen_stimmen()`, `vorlesen_sagen(text, stimme?, tempo?)` → `"fertig"`/`"abgebrochen"`, `vorlesen_stopp()` (macOS `say`, Text über stdin; in der Mac-App immer statt der Web Speech API), `link_oeffnen(url)` (nur http/https), `schluessel_setzen(wert, anbieter?)`, `schluessel_vorhanden(anbieter?)`, `schluessel_loeschen(anbieter?)`, `ki_anfrage(endpoint, body, anbieter?)`, `ki_tokens(endpoint, body, anbieter?)` (`anbieter`: `"foundry"` (Standard) oder `"claude"`). Alle `fs_*` arbeiten relativ zum geöffneten Projekt und lehnen `..`/absolute Pfade ab. - Binärdateien (G8): `fs_lesen_binaer(kennung?, pfad)` → rohe Bytes (`ipc::Response`, in TS `ArrayBuffer`), `fs_schreiben_binaer` mit rohem Body und Kopfzeilen `plotwerk-pfad` (URI-kodiert) und `plotwerk-kennung`. Nur unter `recherche/` und `.versionen/recherche/` (`dateien::binaer_erlaubt` = `binaerPfadErlaubt` in TS), atomar, gleicher Pfadwächter. TS: optionale Methoden `lesenBinaer`/`schreibenBinaer` an `ProjektDateisystem` (TauriFs, MemoryFs), Aufruf über `leseBinaer`/`schreibeBinaer`/`entferneBinaerMitSchnappschuss` (core/projekt) bzw. Store `leseBinaerDatei`/`speichereBinaerDatei`/`entferneBinaerDatei`. +## Funktionsumfang (E15/E16, ab 2026-10-13) + +- **Katalog** (`core/funktionen`): 22 Funktionen in vier Gruppen (Schreiben, Planen, Figuren & Welt, Prüfen & KI), + `editor` ist Pflicht. KI-Funktionen haben eine Last (1–3) und eine Modellrolle; die Summe ergibt die Anzeige + „KI-Nutzung: gering (≤ 3) / mittel (≤ 8) / hoch“. Abhängigkeiten (`braucht`) löst `einschalten`/`ausschalten` auf. +- **Zuordnung** (`src/app/funktionen.ts`): `BEREICH_FUNKTION` (jeder Bereich), `funktionFuerBefehl` (Befehle aus Menü, + Tastenkürzeln, Schnellsuche). `befehlAusfuehren` und der Rahmen fragen bei ausgeblendeten Funktionen nach + (`funktionSicherstellen`); wird der aktive Bereich in den Einstellungen ausgeblendet, geht es still zum Schreiben. + Das native Menü bleibt vollständig. +- **Einhängen in Ansichten**: `` bzw. `useFunktion(id)`. Unterfunktionen: Typewriter und + Statusleisten-Werkzeuge, Tab „Stil“ (`editorWerkzeuge`), Schreibziel (`statistik`), Szenen-Karte/-Übersicht + (`faeden`), Recherche-Leiste (`recherche`), Bögen (`boegen`), Stimme (`kiStimme`), Leser-Karte (`kiLeser`), + KI-Vorschläge (`kiErkennung`, über `useOffeneVorschlaege`), Prüfen-Knöpfe (`kiPruefung`), Tiefencheck im + Prüf-Dialog (`kiTiefencheck`), Zeitvorschläge (`kiZeit`), Fassungen in Statistik und Versionsverlauf (`fassungen`), + Abgabe- und Rückimport-Verweise (`abgabe`), Tab „Hinweise“ im Schreiben-Inspektor (`hinweise`). +- **Laden** vor dem ersten Rendern (`initialisiereFunktionen` in `main.tsx`): gespeichertes Profil → so; keins, aber + `letztesProjekt` gesetzt → Bestandsprofil (alles, ohne Assistent); sonst Assistent. Tests laufen mit allen + Funktionen und ohne Assistent (`src/test/setup.ts`). + ## Sprachen (i18n, ab 2026-10-12) Oberfläche Deutsch (Standard und Quellsprache) und Englisch, ohne i18n-Bibliothek. diff --git a/docs/handbuch.md b/docs/handbuch.md index 5f9062a..7bc6504 100644 --- a/docs/handbuch.md +++ b/docs/handbuch.md @@ -22,6 +22,21 @@ Abnahmestand `docs/abnahme.md` · Release `docs/release.md` · Mac-Verteilung `d - **Mehrdeutigkeit ist ein Stilmittel.** Für Figuren und Textstellen legst du die **Gewissheit** fest: „gesichert“, „noch offen“ oder „bewusst vage“. Bewusst Vages wird nie als Widerspruch gemeldet. +## Einrichtung und Funktionsumfang + +Beim ersten Start fragt ein Assistent, was für ein Schreiber du bist – Anfänger, Mittel, Profi oder Schriftsteller – +und blendet passend dazu Funktionen ein. Jede Funktion hat eine kurze Erklärung und ein Beispiel aus „Hafenlichter“; +du kannst einzelne Funktionen dazu- oder abwählen, die Auswahl als eigenes Preset speichern und festlegen, ob du mit +oder ohne KI arbeitest. „Überspringen“ zeigt alles. + +- **Ausgeblendet heißt nicht gelöscht.** Die Daten bleiben im Projekt. Rufst du eine ausgeblendete Funktion über + Menü oder Tastenkürzel auf, fragt Plotwerk, ob sie eingeblendet werden soll. +- **Einstellungen (⌘,) → Funktionen**: Umfang anpassen, Preset wechseln, Einrichtung erneut starten. +- **KI-Umfang**: Ausgeblendete KI-Funktionen laufen nie. Ohne „Plan-Abgleich“ gehen keine Plan-Felder an die KI, + jede Prüfung wird kleiner. +- **No-AI-Modus**: Alle KI-Funktionen sind aus, kein Text verlässt den Mac. Vorher zeigt Plotwerk, welche Funktionen + wegfallen; Hinweise ohne KI (Struktur, Zeit, Schreibweisen) bleiben. + ## Funktionen nach Bereichen Bereiche stehen in der Seitenleiste; die Werkzeuge in der einklappbaren Gruppe „Werkzeuge“ (eingeklappt bleibt das diff --git a/package.json b/package.json index bbc59dd..1d0789d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "build": "tsc --noEmit -p . && vite build", "test": "vitest run", "typecheck": "tsc --noEmit -p .", - "tauri": "tauri" + "tauri": "tauri", + "prepare": "git config core.hooksPath .githooks || true", + "sperrliste": "node scripts/sperrliste.mjs --alles" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/scripts/sperrliste.mjs b/scripts/sperrliste.mjs new file mode 100755 index 0000000..6c85880 --- /dev/null +++ b/scripts/sperrliste.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +// Sperrliste: verhindert, dass Begriffe aus privaten Manuskripten (Figuren, Orte, +// Titel) ins öffentliche Repo gelangen. Die Liste selbst liegt bewusst außerhalb +// des Repos, sonst würde sie die Begriffe veröffentlichen. +// +// node scripts/sperrliste.mjs geänderte Zeilen im Commit prüfen (pre-commit) +// node scripts/sperrliste.mjs --nachricht Commit-Nachricht prüfen (commit-msg) +// node scripts/sperrliste.mjs --alles alle Dateien im Repo prüfen +// node scripts/sperrliste.mjs --push Branch-Namen und alle neuen Commits prüfen (pre-push, liest stdin) +// +// Liste: $PLOTWERK_SPERRLISTE oder ~/.config/plotwerk/sperrliste.txt +// Ein Begriff pro Zeile, # leitet Kommentare ein, Groß-/Kleinschreibung egal. +// Leerzeichen passen auch auf - und _ („Blauer Hafen“ findet „blauer-hafen“). +// Ein * am Ende erlaubt Fortsetzungen („Möwe*“ findet „Möwen“). + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const LISTE = process.env.PLOTWERK_SPERRLISTE || join(homedir(), '.config', 'plotwerk', 'sperrliste.txt'); +const args = process.argv.slice(2); +const alles = args.includes('--alles'); +const push = args.includes('--push'); +const nachrichtIndex = args.indexOf('--nachricht'); + +if (!existsSync(LISTE)) { + // Ohne Liste nichts zu prüfen – Mitwirkende ohne private Manuskripte sollen nicht blockiert werden. + if (alles) console.log(`Keine Sperrliste unter ${LISTE} – nichts geprüft.`); + process.exit(0); +} + +const begriffe = readFileSync(LISTE, 'utf8') + .split('\n') + .map((z) => z.replace(/#.*/, '').trim()) + .filter(Boolean); + +const muster = begriffe.map((b) => { + const offen = b.endsWith('*'); + const kern = (offen ? b.slice(0, -1) : b) + .trim() + .split(/\s+/) + .map((teil) => teil.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('[\\s_-]+'); + return { begriff: b, re: new RegExp(`(?= 0) { + readFileSync(args[nachrichtIndex + 1], 'utf8') + .split('\n') + .forEach((text, i) => { + if (!text.startsWith('#')) zeilen.push({ ort: `Commit-Nachricht:${i + 1}`, text }); + }); +} else if (alles) { + for (const datei of git('ls-files', '-z').split('\0').filter(Boolean)) { + zeilen.push({ ort: `${datei} (Dateiname)`, text: datei }); + if (!existsSync(datei)) continue; + const inhalt = readFileSync(datei); + if (inhalt.includes(0)) continue; // binär + inhalt + .toString('utf8') + .split('\n') + .forEach((text, i) => zeilen.push({ ort: `${datei}:${i + 1}`, text })); + } +} else if (push) { + // stdin: + const NULL = /^0+$/; + for (const z of readFileSync(0, 'utf8').split('\n').filter(Boolean)) { + const [, lokal, ziel, entfernt] = z.split(' '); + zeilen.push({ ort: `Branch ${ziel}`, text: ziel }); + if (NULL.test(lokal)) continue; // Löschen + const bereich = NULL.test(entfernt) ? [lokal, '--not', '--remotes'] : [`${entfernt}..${lokal}`]; + git('log', '--format=%h %B', ...bereich) + .split('\n') + .forEach((text) => zeilen.push({ ort: 'Commit-Nachricht', text })); + diffZeilen(git('log', '-p', '-U0', '--no-color', '--no-ext-diff', '--format=', ...bereich)); + } +} else { + diffZeilen(git('diff', '--cached', '-U0', '--no-color', '--no-ext-diff', '--diff-filter=ACMR')); +} + +/** Hinzugefügte Zeilen und Dateinamen aus einem Diff übernehmen. */ +function diffZeilen(diff) { + let datei = ''; + let nr = 0; + for (const z of diff.split('\n')) { + if (z.startsWith('+++ ')) { + datei = z.slice(4).replace(/^b\//, ''); + zeilen.push({ ort: `${datei} (Dateiname)`, text: datei }); + } else if (z.startsWith('@@')) { + nr = Number(/\+(\d+)/.exec(z)?.[1] ?? 0); + } else if (z.startsWith('+')) { + zeilen.push({ ort: `${datei}:${nr++}`, text: z.slice(1) }); + } + } +} + +const treffer = []; +for (const { ort, text } of zeilen) { + for (const { begriff, re } of muster) { + if (re.test(text)) treffer.push(` ${ort} → „${begriff}“`); + } +} + +if (treffer.length) { + console.error(`Sperrliste: ${treffer.length} Treffer mit Begriffen aus privaten Manuskripten:\n`); + console.error(treffer.join('\n')); + console.error(`\nBitte entfernen oder durch Beispiele aus dem Demo-Roman ersetzen (Liste: ${LISTE}).`); + process.exit(1); +} +if (alles) console.log(`Sperrliste: ${zeilen.length} Zeilen geprüft, keine Treffer.`); diff --git a/src-tauri/crates/plotwerk-ki/src/lib.rs b/src-tauri/crates/plotwerk-ki/src/lib.rs index 97d96db..b5a5021 100644 --- a/src-tauri/crates/plotwerk-ki/src/lib.rs +++ b/src-tauri/crates/plotwerk-ki/src/lib.rs @@ -531,7 +531,7 @@ mod tests { #[test] fn basis_url_normalisiert() { - let b = "https://flamingo.services.ai.azure.com/anthropic"; + let b = "https://beispiel.services.ai.azure.com/anthropic"; assert_eq!(basis_url(F, b).unwrap(), b); assert_eq!(basis_url(F, &format!(" {b}/ ")).unwrap(), b); assert_eq!(basis_url(F, &format!("{b}/v1/messages")).unwrap(), b); @@ -558,14 +558,14 @@ mod tests { #[test] fn urls_je_anbieter() { - let foundry = "https://flamingo.services.ai.azure.com/anthropic"; + let foundry = "https://beispiel.services.ai.azure.com/anthropic"; assert_eq!( nachrichten_url(F, foundry).unwrap(), - "https://flamingo.services.ai.azure.com/anthropic/v1/messages" + "https://beispiel.services.ai.azure.com/anthropic/v1/messages" ); assert_eq!( tokens_url(F, &format!("{foundry}/")).unwrap(), - "https://flamingo.services.ai.azure.com/anthropic/v1/messages/count_tokens" + "https://beispiel.services.ai.azure.com/anthropic/v1/messages/count_tokens" ); // Claude API: leer = Standard-Basis-URL, eigene Basis-URL überschreibt assert_eq!( diff --git a/src/app/App.tsx b/src/app/App.tsx index a82b066..83b64d4 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { lazy, Suspense, useEffect } from 'react'; import { istTauri } from '../backend'; import { useTexte } from '../i18n'; import { useStore } from '../state/store'; @@ -9,11 +9,15 @@ import { Fehlergrenze } from './Fehlergrenze'; import { Rahmen } from './Rahmen'; import { Startbildschirm } from './Startbildschirm'; import { useTastenkuerzel } from './tastenkuerzel'; +import { useFunktionsStore } from '../state/funktionen'; + +const Assistent = lazy(() => import('../features/funktionen/Assistent').then((m) => ({ default: m.Assistent }))); export function App() { const ladeStatus = useStore((s) => s.ladeStatus); const fehler = useStore((s) => s.fehler); const initialisieren = useStore((s) => s.initialisieren); + const assistentOffen = useFunktionsStore((s) => s.assistentOffen); const t = useTexte(); useTastenkuerzel(); useEffect(() => { @@ -38,6 +42,11 @@ export function App() {
)} + {assistentOffen && ( + + + + )}
); diff --git a/src/app/NurMit.tsx b/src/app/NurMit.tsx new file mode 100644 index 0000000..042bfbd --- /dev/null +++ b/src/app/NurMit.tsx @@ -0,0 +1,8 @@ +import type { ReactNode } from 'react'; +import type { FunktionId } from '@core/funktionen'; +import { useFunktion } from '../state/funktionen'; + +/** Zeigt den Inhalt nur, solange die Funktion eingeblendet ist (E15). */ +export function NurMit({ funktion, children }: { funktion: FunktionId; children: ReactNode }) { + return useFunktion(funktion) ? <>{children} : null; +} diff --git a/src/app/Rahmen.tsx b/src/app/Rahmen.tsx index 7aac95e..e231a71 100644 --- a/src/app/Rahmen.tsx +++ b/src/app/Rahmen.tsx @@ -1,10 +1,12 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { VorschauEbene } from '../features/vorschau'; import { useSprache } from '../i18n'; +import { useFunktionsStore } from '../state/funktionen'; import { useStore } from '../state/store'; import { bereichDefinition, bereicheVorladen, useBereichModul } from './bereiche'; import { deepLinkAnwenden } from './deeplink'; import { Fehlergrenze } from './Fehlergrenze'; +import { BEREICH_FUNKTION, funktionSicherstellen } from './funktionen'; import { Seitenleiste } from './Seitenleiste'; import { Titelleiste } from './Titelleiste'; @@ -16,11 +18,33 @@ function BereichInhalt() { return ; } +/** + * Aktiver Bereich ausgeblendet (E15): Kam man per Verweis dorthin, fragt die App, ob sie ihn einblenden soll; wurde + * er gerade ausgeblendet (Einstellungen), geht es still zurück zum Schreiben. + */ +function useBereichSichtbar() { + const bereich = useStore((s) => s.bereich); + const aktiv = useFunktionsStore((s) => s.aktiv); + const vorher = useRef(bereich); + useEffect(() => { + const navigiert = vorher.current !== bereich; + vorher.current = bereich; + const id = BEREICH_FUNKTION[bereich]; + if (aktiv.has(id)) return; + const zurueck = () => { + if (useStore.getState().bereich === bereich) useStore.getState().setzeBereich('schreiben'); + }; + if (!navigiert) return zurueck(); + void funktionSicherstellen(id).then((ok) => ok || zurueck()); + }, [bereich, aktiv]); +} + /** Fenster mit Titelleiste, Seitenleiste und Inhalt des aktiven Bereichs. */ export function Rahmen() { const bereich = useStore((s) => s.bereich); const fokus = useStore((s) => s.fokusModus); const modul = useBereichModul(bereich); + useBereichSichtbar(); // Bereichsnamen folgen der Oberflächensprache: beim Umschalten neu zeichnen useSprache(); useEffect(() => { diff --git a/src/app/Seitenleiste.tsx b/src/app/Seitenleiste.tsx index e2c422d..6014a31 100644 --- a/src/app/Seitenleiste.tsx +++ b/src/app/Seitenleiste.tsx @@ -1,9 +1,11 @@ import { useState } from 'react'; import { f, useTexte } from '../i18n'; +import { useFunktionsStore } from '../state/funktionen'; import { useStore } from '../state/store'; import { klassen, Zaehler } from '../ui'; import { BEREICHE, type BereichDefinition, type BereichModul } from './bereiche'; import { Fehlergrenze } from './Fehlergrenze'; +import { BEREICH_FUNKTION } from './funktionen'; const WERKZEUGE_OFFEN = 'plotwerk:seitenleiste:werkzeuge'; @@ -31,6 +33,7 @@ export function Seitenleiste({ modul }: { modul: BereichModul | null }) { const entschieden = new Set(s.daten?.entscheidungen.hinweise.map((e) => e.schluessel)); return s.index.hinweise.filter((h) => h.status === 'neu' && !entschieden.has(h.schluessel)).length; }); + const aktiv = useFunktionsStore((s) => s.aktiv); const [werkzeugeOffen, setWerkzeugeOffen] = useState(gemerktOffen); const t = useTexte(); const Inhalt = modul?.Seitenleiste; @@ -51,7 +54,9 @@ export function Seitenleiste({ modul }: { modul: BereichModul | null }) { ); }; - const werkzeuge = BEREICHE.filter((b) => b.gruppe === 'werkzeuge'); + // ausgeblendete Funktionen (E15) erscheinen nicht + const sichtbare = BEREICHE.filter((b) => aktiv.has(BEREICH_FUNKTION[b.id])); + const werkzeuge = sichtbare.filter((b) => b.gruppe === 'werkzeuge'); // eingeklappt: nur das gerade aktive Werkzeug bleibt sichtbar const sichtbar = werkzeugeOffen ? werkzeuge : werkzeuge.filter((b) => b.id === bereich); const umschalten = () => { @@ -61,7 +66,7 @@ export function Seitenleiste({ modul }: { modul: BereichModul | null }) { return (