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/abgabe/expose.ts b/core/abgabe/expose.ts index 90d4140..08e20be 100644 --- a/core/abgabe/expose.ts +++ b/core/abgabe/expose.ts @@ -10,6 +10,7 @@ */ import { AlignmentType, Document, HeadingLevel, HighlightColor, LineRuleType, Packer, Paragraph, TextRun } from 'docx'; import { buchSprache, fmt, localeVon, texteJe, type Sprache } from '../i18n'; +import { nachRolle } from '../figuren/namen'; import type { Id, ProjektDaten } from '../model/types'; import { wortzahl } from '../text'; import { normseitenZaehlen } from './normseite'; @@ -162,8 +163,8 @@ export function exposeGeruest(daten: ProjektDaten, optionen: ExposeOptionen): Ex bloecke.push({ art: 'ueberschrift', ebene: 1, text: t.figuren }); const figuren = Object.values(daten.figuren) - .filter((f) => optionen.nebenfiguren !== false || f.rolle === 'haupt') - .sort((a, b) => (a.rolle === b.rolle ? a.name.localeCompare(b.name, sprache) : a.rolle === 'haupt' ? -1 : 1)); + .filter((f) => f.rolle !== 'statist' && (optionen.nebenfiguren !== false || f.rolle === 'haupt')) + .sort(nachRolle((a, b) => a.name.localeCompare(b.name, sprache))); if (figuren.length === 0) bloecke.push({ art: 'absatz', text: [P(t.figurenFrage)] }); for (const f of figuren) { bloecke.push({ art: 'punkt', text: [F(f.name), T(' – '), f.kurz?.trim() ? T(f.kurz.trim()) : P(t.kurzbeschreibung)] }); diff --git a/core/abgabe/index.ts b/core/abgabe/index.ts index 7220c38..7a101e7 100644 --- a/core/abgabe/index.ts +++ b/core/abgabe/index.ts @@ -10,6 +10,7 @@ export { type ExposeOptionen, type ExposeStueck, } from './expose'; +export { personenAuswahl, personenKapitel, personenverzeichnis, type PersonenOptionen } from './personen'; export { exportiereLektoratsmappe, HINWEIS_TYP_NAME, diff --git a/core/abgabe/personen.test.ts b/core/abgabe/personen.test.ts new file mode 100644 index 0000000..477b1a9 --- /dev/null +++ b/core/abgabe/personen.test.ts @@ -0,0 +1,58 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { demoFs } from '../../fixtures/demoProjekt'; +import { berechneIndex } from '../index/index'; +import type { ProjektDaten } from '../model/types'; +import { ladeProjekt } from '../projekt'; +import { exposeMarkdown } from './expose'; +import { personenAuswahl, personenKapitel, personenverzeichnis } from './personen'; + +let daten: ProjektDaten; +let erst: Record; +beforeAll(async () => { + daten = await ladeProjekt(demoFs()); + erst = berechneIndex(daten).erstesVorkommen; +}); + +describe('Personenverzeichnis', () => { + it('nach Gruppen: Hauptgruppen als Abschnitte, Rest unter „Weitere Figuren“; nie private Felder', () => { + const d: ProjektDaten = { + ...daten, + figuren: { ...daten.figuren, ole: { ...daten.figuren.ole!, aliase: ['Fenster-Ole'], wahrheit: 'GEHEIM', notizen: 'PRIVAT' } }, + }; + const md = exposeMarkdown(personenverzeichnis(d, erst, { gliederung: 'gruppen' })); + expect(md).toMatch(/^# Personen/); + expect(md).toContain('## Nordhaven'); + expect(md).toContain('## Weitere Figuren'); + expect(md).toContain('**Ole**'); + expect(md).toContain('auch „Fenster-Ole“'); + expect(md).not.toContain('GEHEIM'); + expect(md).not.toContain('PRIVAT'); + // bewusst vage Figuren bleiben draußen, geplante (noch nicht im Text) auch + const vage = Object.values(d.figuren).find((f) => f.existenz === 'bewusst_vage'); + if (vage) expect(md).not.toContain(vage.name); + }); + + it('nach Rolle; Spitznamen abschaltbar; Statisten nur auf Wunsch', () => { + const d: ProjektDaten = { ...daten, figuren: { ...daten.figuren, malte: { ...daten.figuren.malte!, rolle: 'statist', aliase: ['M.'] } } }; + const ohne = exposeMarkdown(personenverzeichnis(d, erst, { gliederung: 'rolle', spitznamen: false })); + expect(ohne).toContain('## Hauptfiguren'); + expect(ohne).not.toContain('Malte'); + const mit = exposeMarkdown(personenverzeichnis(d, erst, { gliederung: 'rolle', statisten: true, spitznamen: false })); + expect(mit).toContain('**Malte**'); + expect(mit).not.toContain('„M.“'); + }); + + it('nur bis Kapitel X: keine Figuren, die erst später auftauchen', () => { + const erstes = daten.projekt.kapitel[0]!; + const bis = personenAuswahl(daten, erst, { gliederung: 'rolle', bisKapitel: erstes }); + expect(bis.every((f) => erst[f.id] === erstes)).toBe(true); + expect(bis.length).toBeLessThan(personenAuswahl(daten, erst, { gliederung: 'rolle' }).length); + }); + + it('als Vorspann-Kapitel (Markdown mit fett/kursiv)', () => { + const k = personenKapitel(personenverzeichnis(daten, erst, { gliederung: 'rolle' })); + expect(k.titel).toBe('Personen'); + expect(k.text).toContain('## Hauptfiguren'); + expect(k.text).toMatch(/\*\*Paul\*\*/); + }); +}); diff --git a/core/abgabe/personen.ts b/core/abgabe/personen.ts new file mode 100644 index 0000000..2a5a6f5 --- /dev/null +++ b/core/abgabe/personen.ts @@ -0,0 +1,109 @@ +/** + * Personenverzeichnis (G7 Abgabe): Figuren nach Gruppen oder Rolle, je Eintrag Name, Spitznamen, Kurzbeschreibung + * und Rolle in der Gruppe. Liefert dieselben Blöcke wie das Exposé (exposeDocx/exposeMarkdown) und als Markdown + * für einen Vorspann vor EPUB und Normseite. Private Felder (`wahrheit`, `notizen`, Planfelder) kommen nie hinein. + */ +import { fmt, texteJe, type Sprache } from '../i18n'; +import type { Figur, Id, Kapitel, ProjektDaten } from '../model/types'; +import { nachRolle } from '../figuren/namen'; +import type { ExposeBlock, ExposeStueck } from './expose'; + +const T = texteJe( + { + titel: 'Personen', + haupt: 'Hauptfiguren', + neben: 'Nebenfiguren', + statist: 'Weitere Figuren', + ohneGruppe: 'Weitere Figuren', + auch: 'auch {namen}', + zitat: '„{text}“', + keine: 'Noch keine Figuren.', + }, + { + titel: 'Characters', + haupt: 'Main characters', + neben: 'Supporting characters', + statist: 'Other characters', + ohneGruppe: 'Other characters', + auch: 'also {namen}', + zitat: '“{text}”', + keine: 'No characters yet.', + }, +); + +export interface PersonenOptionen { + /** nach Gruppen (Hauptgruppe) oder nach Rolle */ + gliederung: 'gruppen' | 'rolle'; + /** Statisten aufnehmen (Standard: nein) */ + statisten?: boolean; + /** Spitznamen nennen (Standard: ja) */ + spitznamen?: boolean; + /** nur Figuren, die bis zu diesem Kapitel im Text vorkommen (kein Spoiler); fehlt = alle im Text */ + bisKapitel?: Id; + /** auch Figuren, die noch nicht im Text vorkommen (Standard: nein) */ + geplante?: boolean; + /** „bewusst vage“ Figuren aufnehmen (Standard: nein – sie sollen offen bleiben) */ + vage?: boolean; +} + +/** Figuren für das Verzeichnis (Filter nach Optionen), in Listenreihenfolge. */ +export function personenAuswahl(daten: ProjektDaten, erstesVorkommen: Record, optionen: PersonenOptionen): Figur[] { + const reihe = daten.projekt.kapitel; + const bis = optionen.bisKapitel ? reihe.indexOf(optionen.bisKapitel) : reihe.length - 1; + return Object.values(daten.figuren) + .filter((f) => optionen.statisten || f.rolle !== 'statist') + .filter((f) => optionen.vage || f.existenz !== 'bewusst_vage') + .filter((f) => { + const erst = erstesVorkommen[f.id]; + if (!erst) return !!optionen.geplante && !optionen.bisKapitel; + return reihe.indexOf(erst) <= bis; + }) + .sort(nachRolle((a, b) => a.name.localeCompare(b.name, 'de'))); +} + +function eintrag(f: Figur, daten: ProjektDaten, optionen: PersonenOptionen, sprache: Sprache): ExposeStueck[] { + const t = T(sprache); + const teile: ExposeStueck[] = [{ text: f.name, art: 'fett' }]; + const zusatz: string[] = []; + if (optionen.spitznamen !== false && f.aliase.length) zusatz.push(fmt(t.auch, { namen: f.aliase.map((a) => fmt(t.zitat, { text: a })).join(', ') })); + const rolle = f.gruppe ? daten.gruppen[f.gruppe]?.rollen?.[f.id] : undefined; + const beschreibung = [rolle, f.kurz?.trim()].filter(Boolean).join(', '); + if (beschreibung) teile.push({ text: ` – ${beschreibung}` }); + if (zusatz.length) teile.push({ text: ` (${zusatz.join('; ')})`, art: 'kursiv' }); + return teile; +} + +/** Blöcke des Verzeichnisses (Überschrift, Abschnitte, Einträge als Aufzählung). */ +export function personenverzeichnis(daten: ProjektDaten, erstesVorkommen: Record, optionen: PersonenOptionen, sprache: Sprache = 'de'): ExposeBlock[] { + const t = T(sprache); + const figuren = personenAuswahl(daten, erstesVorkommen, optionen); + const bloecke: ExposeBlock[] = [{ art: 'titel', text: t.titel }]; + if (!figuren.length) return [...bloecke, { art: 'absatz', text: [{ text: t.keine }] }]; + const abschnitt = (titel: string, liste: Figur[]) => { + if (!liste.length) return; + bloecke.push({ art: 'ueberschrift', ebene: 1, text: titel }); + for (const f of liste) bloecke.push({ art: 'punkt', text: eintrag(f, daten, optionen, sprache) }); + }; + if (optionen.gliederung === 'rolle') { + abschnitt(t.haupt, figuren.filter((f) => f.rolle === 'haupt')); + abschnitt(t.neben, figuren.filter((f) => f.rolle === 'neben')); + abschnitt(t.statist, figuren.filter((f) => f.rolle === 'statist')); + return bloecke; + } + const gruppen = Object.values(daten.gruppen).sort((a, b) => a.name.localeCompare(b.name, sprache)); + for (const g of gruppen) abschnitt(g.name, figuren.filter((f) => f.gruppe === g.id)); + abschnitt(t.ohneGruppe, figuren.filter((f) => !f.gruppe || !daten.gruppen[f.gruppe])); + return bloecke; +} + +/** Als Kapitel für den Vorspann von EPUB und Normseite (Markdown wie im Manuskript: **fett**, *kursiv*). */ +export function personenKapitel(bloecke: ExposeBlock[]): Kapitel { + const stuecke = (s: ExposeStueck[]) => s.map((x) => (x.art === 'fett' ? `**${x.text}**` : x.art === 'kursiv' ? ` *${x.text.trim()}*` : x.text)).join(''); + const titel = bloecke.find((b) => b.art === 'titel'); + const absaetze: string[] = []; + for (const b of bloecke) { + if (b.art === 'ueberschrift') absaetze.push(`## ${b.text}`); + else if (b.art === 'punkt' || b.art === 'absatz') absaetze.push(stuecke(b.text)); + } + return { id: 'personenverzeichnis', titel: titel?.art === 'titel' ? titel.text : '', text: absaetze.join('\n\n') }; +} diff --git a/core/figuren/besetzung.ts b/core/figuren/besetzung.ts new file mode 100644 index 0000000..cbb8642 --- /dev/null +++ b/core/figuren/besetzung.ts @@ -0,0 +1,65 @@ +/** + * F-15 Besetzungsübersicht: Figuren × Kapitel. Je Zelle die wichtigste Art des Vorkommens (tritt auf > per + * Nachricht > erwähnt > im Text, Art offen) oder „plan“, wenn die Figur nur im Kapitelplan steht. Reine Ableitung + * aus dem Index, keine eigene Datei. + */ +import type { Erwaehnung, ErwaehnungsArt, Figur, Id, ProjektDaten } from '../model/types'; +import { ROLLEN } from './namen'; + +export type BesetzungsZelle = ErwaehnungsArt | 'plan' | null; + +export interface BesetzungsZeile { + figur: Figur; + /** je Kapitel (Reihenfolge wie `kapitel`) */ + zellen: BesetzungsZelle[]; + /** erste Erwähnung je Kapitel (zum Springen) */ + erste: (Erwaehnung | null)[]; + /** Kapitel mit Vorkommen im Text */ + anzahl: number; +} + +export interface Besetzung { + kapitel: Id[]; + zeilen: BesetzungsZeile[]; + /** Figuren mit Vorkommen im Text je Kapitel */ + summen: number[]; +} + +/** Rang der Arten (gleiche Reihenfolge wie im Auftrittsstreifen der Figurenkarte). */ +export const ART_RANG: Record = { auftritt: 4, nachricht: 3, erwaehnt: 2, unbestimmt: 1 }; + +export function besetzung(daten: ProjektDaten, erwaehnungen: Erwaehnung[]): Besetzung { + const kapitel = [...daten.projekt.kapitel]; + const pos = new Map(kapitel.map((id, i) => [id, i])); + const figuren = Object.values(daten.figuren).sort( + (a, b) => ROLLEN.indexOf(a.rolle) - ROLLEN.indexOf(b.rolle) || a.name.localeCompare(b.name, 'de'), + ); + const zeilen = new Map( + figuren.map((f) => [f.id, { figur: f, zellen: kapitel.map(() => null), erste: kapitel.map(() => null), anzahl: 0 }]), + ); + for (const e of erwaehnungen) { + if (e.zielTyp !== 'figur') continue; + const z = zeilen.get(e.ziel); + const i = pos.get(e.kapitel); + if (!z || i === undefined) continue; + const alt = z.zellen[i]; + if (!alt || alt === 'plan' || ART_RANG[e.art] > ART_RANG[alt]) z.zellen[i] = e.art; + if (!z.erste[i] || e.start < z.erste[i]!.start) z.erste[i] = e; + } + kapitel.forEach((id, i) => { + for (const f of daten.plaene[id]?.figuren ?? []) { + const z = zeilen.get(f); + if (z && !z.zellen[i]) z.zellen[i] = 'plan'; + } + }); + const summen = kapitel.map(() => 0); + for (const z of zeilen.values()) { + z.zellen.forEach((c, i) => { + if (c && c !== 'plan') { + z.anzahl += 1; + summen[i]! += 1; + } + }); + } + return { kapitel, zeilen: [...zeilen.values()], summen }; +} diff --git a/core/figuren/fakten.test.ts b/core/figuren/fakten.test.ts new file mode 100644 index 0000000..9d8b593 --- /dev/null +++ b/core/figuren/fakten.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { demoFs } from '../../fixtures/demoProjekt'; +import type { FigurFakt, ProjektDaten } from '../model/types'; +import { ladeProjekt, parseKiFakten, serializeKiFakten } from '../projekt'; +import { faktenDerFigur, faktenHinweise, mischeFakten, setzeFaktStatus, werteVertraeglich, wertForm } from './index'; + +const SAAB = 'Dann nimmt er den Schlüssel und fährt mit seinem alten Saab 900 zum Lindenhof.'; +const VOLVO = 'Ich steige aus dem Volvo, schlage die Tür zu und sehe ihm nach, bis er hinter den Lagerhallen verschwunden ist.'; +const ORGELN = 'Vera erzählt von ihrer Werkstatt, in der sie alte Orgeln restauriert, Paul hört nur halb zu.'; + +const fakt = (figur: string, merkmal: string, wert: string, kapitel: string, text: string): FigurFakt => ({ + schluessel: `fakt:${figur}|${merkmal.toLowerCase()}|${kapitel}/${text.slice(0, 20)}`, + figur, + merkmal, + wert, + kapitel, + zitat: { kapitel, text }, + status: 'neu', +}); + +let daten: ProjektDaten; +beforeEach(async () => { + daten = await ladeProjekt(demoFs()); +}); + +describe('Figurenfakten: Vergleich, Widersprüche, Planung (F-17)', () => { + it('Werte: Füllwörter fallen weg, enthalten = verträglich', () => { + expect(wertForm('seinem alten Saab 900')).toBe('seinem saab 900'); + expect(werteVertraeglich('alter Saab 900', 'Saab')).toBe(true); + expect(werteVertraeglich('Saab', 'Volvo')).toBe(false); + expect(werteVertraeglich('Architekt', 'architekt')).toBe(true); + }); + + it('Widerspruch beim gleichen Merkmal → Hinweis „pruefen“ mit Figur-Bezug und beiden Zitaten', () => { + daten.kiFakten = { version: 1, eintraege: [fakt('paul', 'Auto', 'alter Saab 900', 'lotte', SAAB), fakt('paul', 'auto', 'Volvo', 'flut', VOLVO)] }; + const g = faktenDerFigur(daten, 'paul'); + expect(g).toHaveLength(1); + expect(g[0]!.widerspruch).toBe(true); + const h = faktenHinweise(daten).filter((x) => x.schluessel === 'fakt:widerspruch:paul|auto'); + expect(h).toHaveLength(1); + expect(h[0]).toMatchObject({ typ: 'pruefen', plan: { figur: 'paul' } }); + expect(h[0]!.zitate.map((z) => z.text)).toEqual([SAAB, VOLVO]); + }); + + it('Planabweichung: Planfeld gleichen Namens, kein Textwert passt', () => { + daten.figuren.vera = { ...daten.figuren.vera!, plan: { ...daten.figuren.vera!.plan, Beruf: 'Geigenbauerin' } }; + daten.kiFakten = { version: 1, eintraege: [fakt('vera', 'Beruf', 'restauriert alte Orgeln', 'ebbe', ORGELN)] }; + const g = faktenDerFigur(daten, 'vera')[0]!; + expect(g.plan).toEqual({ feld: 'Beruf', wert: 'Geigenbauerin', abweichend: true }); + expect(faktenHinweise(daten).map((x) => x.schluessel)).toContain('fakt:plan:vera|beruf'); + // passender Plan: kein Hinweis + daten.figuren.vera = { ...daten.figuren.vera!, plan: { ...daten.figuren.vera!.plan, Beruf: 'restauriert Orgeln' } }; + expect(faktenHinweise(daten).filter((x) => x.schluessel.startsWith('fakt:plan:vera'))).toEqual([]); + }); + + it('verworfene und nicht mehr gültige Angaben zählen nicht; Mischen ersetzt nur die abgefragten Figuren', () => { + const a = fakt('paul', 'Auto', 'alter Saab 900', 'lotte', SAAB); + const b = fakt('paul', 'Auto', 'Volvo', 'flut', VOLVO); + const v = fakt('vera', 'Beruf', 'Orgeln', 'ebbe', ORGELN); + let ablage = mischeFakten(undefined, ['paul', 'vera'], [a, b, v]).ablage; + ablage = setzeFaktStatus(ablage, b.schluessel, 'verworfen'); + daten.kiFakten = ablage; + expect(faktenDerFigur(daten, 'paul')[0]!.widerspruch).toBe(false); + // neuer Lauf nur für Paul: Verworfenes kommt nicht wieder, Vera bleibt + const zweiter = mischeFakten(ablage, ['paul'], [a, b]); + expect(zweiter.ablage.eintraege.filter((x) => x.figur === 'paul').map((x) => x.status).sort()).toEqual(['neu', 'verworfen']); + expect(zweiter.ablage.eintraege.some((x) => x.figur === 'vera')).toBe(true); + expect(zweiter.neu).toBe(0); + // Ablage übersteht Speichern + expect(parseKiFakten(serializeKiFakten(zweiter.ablage))).toEqual(zweiter.ablage); + // geänderter Text: Beleg nicht mehr gültig + daten.kapitel.lotte = { ...daten.kapitel.lotte!, text: 'Ganz anders.' }; + expect(faktenDerFigur(daten, 'paul')).toEqual([]); + }); +}); diff --git a/core/figuren/fakten.ts b/core/figuren/fakten.ts new file mode 100644 index 0000000..ed71c8f --- /dev/null +++ b/core/figuren/fakten.ts @@ -0,0 +1,167 @@ +/** + * F-17 Figurenfakten: Ablage der KI-Angaben (.plotwerk/figurenfakten.json), Gruppierung je Merkmal, Widersprüche + * (gleiches Merkmal, verschiedene Werte) und Abweichungen von der Planung – lokal, ohne KI. + * Widersprüche werden Hinweise vom Typ „pruefen“ mit Figur-Bezug (bewusst Vages filtert hinweiseBerechnen). + */ +import { fmt, texteJe, type Sprache } from '../i18n'; +import type { FigurFakt, Hinweis, Id, KiAblage, ProjektDaten } from '../model/types'; +import { findeZitat, klartext } from '../text'; + +const T = texteJe( + { + widerspruch: '{figur}: {merkmal} – {werte}?', + widerspruchErklaerung: 'Der Text nennt für {figur} verschiedene Angaben zu „{merkmal}“: {liste}.', + plan: '{figur}: {merkmal} laut Planung {plan}, im Text {text}', + planErklaerung: 'Im Planungsfeld „{feld}“ steht {plan}, der Text sagt {text} ({nr}).', + nr: 'Kap. {n}', + zitat: '„{text}“', + oder: ' oder ', + }, + { + widerspruch: '{figur}: {merkmal} – {werte}?', + widerspruchErklaerung: 'The text gives different values for {figur}’s “{merkmal}”: {liste}.', + plan: '{figur}: {merkmal} planned as {plan}, in the text {text}', + planErklaerung: 'The plan field “{feld}” says {plan}, the text says {text} ({nr}).', + nr: 'Ch. {n}', + zitat: '“{text}”', + oder: ' or ', + }, +); + +export function leereFakten(): KiAblage { + return { version: 1, eintraege: [] }; +} + +/** Vergleichsform eines Merkmals */ +export const merkmalForm = (s: string) => s.trim().toLocaleLowerCase('de'); + +const FUELLWOERTER = new Set(['der', 'die', 'das', 'den', 'dem', 'des', 'ein', 'eine', 'einen', 'einem', 'einer', 'sein', 'seine', 'seinen', 'ihr', 'ihre', 'ihren', 'alt', 'alte', 'alter', 'alten', 'altes', 'the', 'a', 'an', 'his', 'her', 'their', 'old']); + +/** Vergleichsform eines Werts: klein, ohne Satzzeichen und Füllwörter („seinem alten Saab 900“ → „saab 900“). */ +export function wertForm(s: string): string { + return s + .toLocaleLowerCase('de') + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .split(/\s+/u) + .filter((w) => w && !FUELLWOERTER.has(w)) + .join(' '); +} + +/** Zwei Werte passen zusammen, wenn einer im anderen steckt („Saab“ ~ „alter Saab 900“). */ +export function werteVertraeglich(a: string, b: string): boolean { + const x = wertForm(a); + const y = wertForm(b); + if (!x || !y) return true; + return x === y || x.includes(y) || y.includes(x); +} + +/** + * Lauf in die Ablage mischen: Angaben der abgefragten Figuren werden ersetzt, verworfene bleiben verworfen und + * kommen nicht wieder; andere Figuren bleiben unberührt. + */ +export function mischeFakten( + ablage: KiAblage | undefined, + figuren: Id[], + neu: FigurFakt[], + lauf?: KiAblage['lauf'], +): { ablage: KiAblage; neu: number } { + const a = ablage ?? leereFakten(); + const betroffen = new Set(figuren); + const verworfen = a.eintraege.filter((f) => betroffen.has(f.figur) && f.status === 'verworfen'); + const weg = new Set(verworfen.map((f) => f.schluessel)); + const vorher = new Set(a.eintraege.map((f) => f.schluessel)); + const frisch = neu.filter((f) => !weg.has(f.schluessel)).map((f) => ({ ...f, status: 'neu' as const })); + return { + ablage: { ...a, version: 1, eintraege: [...a.eintraege.filter((f) => !betroffen.has(f.figur)), ...verworfen, ...frisch], ...(lauf ? { lauf } : {}) }, + neu: frisch.filter((f) => !vorher.has(f.schluessel)).length, + }; +} + +/** Ablage mit verworfener (bzw. wiederhergestellter) Angabe. */ +export function setzeFaktStatus(ablage: KiAblage | undefined, schluessel: string, status: FigurFakt['status']): KiAblage { + const a = ablage ?? leereFakten(); + return { ...a, eintraege: a.eintraege.map((f) => (f.schluessel === schluessel ? { ...f, status } : f)) }; +} + +/** Steht der Beleg noch wörtlich im Text? */ +export function faktGueltig(daten: ProjektDaten, f: FigurFakt): boolean { + const md = daten.kapitel[f.zitat.kapitel]?.text; + return !!md && findeZitat(klartext(md), f.zitat.text).length > 0; +} + +export interface MerkmalGruppe { + merkmal: string; + fakten: FigurFakt[]; + /** verschiedene, nicht verträgliche Werte */ + widerspruch: boolean; + /** Planfeld gleichen Namens mit Wert, der zu keinem Textwert passt */ + plan?: { feld: string; wert: string; abweichend: boolean }; +} + +/** Gültige, nicht verworfene Angaben einer Figur je Merkmal (Buchreihenfolge), mit Widerspruch und Planabgleich. */ +export function faktenDerFigur(daten: ProjektDaten, figur: Id): MerkmalGruppe[] { + const reihe = daten.projekt.kapitel; + const f = daten.figuren[figur]; + const liste = (daten.kiFakten?.eintraege ?? []) + .filter((x) => x.figur === figur && x.status !== 'verworfen' && faktGueltig(daten, x)) + .sort((a, b) => reihe.indexOf(a.kapitel) - reihe.indexOf(b.kapitel)); + const gruppen = new Map(); + for (const x of liste) { + const k = merkmalForm(x.merkmal); + const g = gruppen.get(k); + if (g) g.fakten.push(x); + else gruppen.set(k, { merkmal: x.merkmal, fakten: [x], widerspruch: false }); + } + for (const [k, g] of gruppen) { + g.widerspruch = g.fakten.some((a, i) => g.fakten.slice(i + 1).some((b) => !werteVertraeglich(a.wert, b.wert))); + const feld = f ? Object.keys(f.plan).find((p) => merkmalForm(p) === k && f.plan[p]?.trim()) : undefined; + if (f && feld) { + const wert = f.plan[feld]!.trim(); + g.plan = { feld, wert, abweichend: g.fakten.every((x) => !werteVertraeglich(x.wert, wert)) }; + } + } + return [...gruppen.values()]; +} + +/** Widersprüche und Planabweichungen als Hinweise (Typ „pruefen“, Figur-Bezug). */ +export function faktenHinweise(daten: ProjektDaten, sprache: Sprache = 'de'): Hinweis[] { + if (!daten.kiFakten?.eintraege.length) return []; + const t = T(sprache); + const pos = new Map(daten.projekt.kapitel.map((id, i) => [id, i + 1])); + const nr = (k: Id) => fmt(t.nr, { n: pos.get(k) ?? '?' }); + const q = (x: string) => fmt(t.zitat, { text: x }); + const ergebnis: Hinweis[] = []; + const figuren = [...new Set(daten.kiFakten.eintraege.map((f) => f.figur))].filter((id) => daten.figuren[id]); + for (const id of figuren) { + const f = daten.figuren[id]!; + for (const g of faktenDerFigur(daten, id)) { + if (g.widerspruch) { + const werte = [...new Map(g.fakten.map((x) => [wertForm(x.wert), x.wert])).values()]; + ergebnis.push({ + schluessel: `fakt:widerspruch:${id}|${merkmalForm(g.merkmal)}`, + typ: 'pruefen', + titel: fmt(t.widerspruch, { figur: f.name, merkmal: g.merkmal, werte: werte.map(q).join(t.oder) }), + erklaerung: fmt(t.widerspruchErklaerung, { figur: f.name, merkmal: g.merkmal, liste: g.fakten.map((x) => `${nr(x.kapitel)} ${q(x.wert)}`).join(', ') }), + zitate: g.fakten.map((x) => x.zitat), + quelle: 'ki', + plan: { figur: id }, + status: 'neu', + }); + } + if (g.plan?.abweichend) { + const x = g.fakten[0]!; + ergebnis.push({ + schluessel: `fakt:plan:${id}|${merkmalForm(g.plan.feld)}`, + typ: 'pruefen', + titel: fmt(t.plan, { figur: f.name, merkmal: g.merkmal, plan: q(g.plan.wert), text: q(x.wert) }), + erklaerung: fmt(t.planErklaerung, { feld: g.plan.feld, plan: q(g.plan.wert), text: q(x.wert), nr: nr(x.kapitel) }), + zitate: [x.zitat], + quelle: 'ki', + plan: { figur: id, feld: g.plan.feld }, + status: 'neu', + }); + } + } + } + return ergebnis; +} diff --git a/core/figuren/hinweise.test.ts b/core/figuren/hinweise.test.ts new file mode 100644 index 0000000..123fa52 --- /dev/null +++ b/core/figuren/hinweise.test.ts @@ -0,0 +1,186 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { demoFs } from '../../fixtures/demoProjekt'; +import { berechneIndex } from '../index/index'; +import type { Figur, ProjektDaten } from '../model/types'; +import { ladeProjekt } from '../projekt'; +import { klartext } from '../text'; +import { figurenHinweise, ruhendeStrecken, satzUm } from './index'; + +let demo: ProjektDaten; +beforeAll(async () => { + demo = await ladeProjekt(demoFs()); +}); + +const figur = (id: string, name: string, teil: Partial = {}): Figur => ({ + id, + name, + aliase: [], + rolle: 'neben', + existenz: 'gesichert', + gruppe: null, + beziehungen: [], + plan: {}, + wahrheit: '', + notizen: '', + ...teil, +}); + +/** Kleines Projekt aus Kapiteltexten (k1, k2, …) und Figuren, auf Basis der Demo-Struktur. */ +function projekt(texte: string[], figuren: Figur[]): ProjektDaten { + const ids = texte.map((_, i) => `k${i + 1}`); + return { + ...demo, + projekt: { ...demo.projekt, kapitel: ids }, + kapitel: Object.fromEntries(ids.map((id, i) => [id, { id, titel: id, text: texte[i]! }])), + plaene: {}, + figuren: Object.fromEntries(figuren.map((f) => [f.id, f])), + gruppen: {}, + orte: {}, + kiHinweise: [], + }; +} + +function hinweise(d: ProjektDaten, art: string) { + return figurenHinweise(d, berechneIndex(d).erwaehnungen).filter((h) => h.schluessel.startsWith(`figuren:${art}:`)); +} + +describe('Satz um eine Stelle', () => { + it('liefert den ganzen Satz, wörtlich', () => { + const t = 'Erster Satz. Paul geht zum Kai. Dritter Satz.'; + const i = t.indexOf('Paul'); + expect(satzUm(t, i, i + 4)).toBe('Paul geht zum Kai.'); + }); +}); + +describe('F-11 Spitzname vor dem Namen', () => { + it('meldet den ersten Spitznamen vor dem ersten Namen, mit beiden Sätzen als Zitat', () => { + const d = projekt(['Der Alte stand am Fenster.', 'Später sagte Henrik nichts.'], [figur('henrik', 'Henrik', { aliase: ['Alte'] })]); + const h = hinweise(d, 'vorher'); + expect(h).toHaveLength(1); + expect(h[0]!.schluessel).toBe('figuren:vorher:henrik|alte'); + expect(h[0]!.zitate).toEqual([ + { kapitel: 'k1', text: 'Der Alte stand am Fenster.' }, + { kapitel: 'k2', text: 'Später sagte Henrik nichts.' }, + ]); + for (const z of h[0]!.zitate) expect(klartext(d.kapitel[z.kapitel]!.text)).toContain(z.text); + }); + + it('nicht, wenn der Name zuerst fällt (auch im Genitiv), ohne Namen im Text oder bei „bewusst vage“', () => { + expect(hinweise(projekt(['Henriks Hut lag da. Der Alte kam.'], [figur('henrik', 'Henrik', { aliase: ['Alte'] })]), 'vorher')).toEqual([]); + expect(hinweise(projekt(['Der Alte kam.'], [figur('henrik', 'Henrik', { aliase: ['Alte'] })]), 'vorher')).toEqual([]); + expect( + hinweise(projekt(['Der Alte kam.', 'Henrik ging.'], [figur('henrik', 'Henrik', { aliase: ['Alte'], existenz: 'bewusst_vage' })]), 'vorher'), + ).toEqual([]); + }); +}); + +describe('F-12 verwechselbare Namen', () => { + it('ein Buchstabe Unterschied, gleicher Anfang bei gleicher Länge', () => { + const d = projekt( + ['Henrik kam mit Hendrik. Sabine und Sabina lachten.'], + [figur('henrik', 'Henrik'), figur('hendrik', 'Hendrik'), figur('sabine', 'Sabine'), figur('sabina', 'Sabina')], + ); + expect(hinweise(d, 'aehnlich').map((h) => h.schluessel)).toEqual([ + 'figuren:aehnlich:hendrik|henrik|Hendrik|Henrik', + 'figuren:aehnlich:sabina|sabine|Sabina|Sabine', + ]); + const anfang = projekt(['Marina und Marlis kamen.'], [figur('marina', 'Marina'), figur('marlis', 'Marlis')]); + expect(hinweise(anfang, 'aehnlich')).toHaveLength(1); + }); + + it('nicht bei deutlich verschiedenen Namen oder wenn eine Figur nicht im Text vorkommt', () => { + expect(hinweise(projekt(['Paul und Vera.'], [figur('paul', 'Paul'), figur('vera', 'Vera')]), 'aehnlich')).toEqual([]); + expect(hinweise(projekt(['Henrik kam.'], [figur('henrik', 'Henrik'), figur('hendrik', 'Hendrik')]), 'aehnlich')).toEqual([]); + }); +}); + +describe('F-13 ruhende Figuren', () => { + const texte = ['Ole kam.', 'Leer eins.', 'Leer zwei.', 'Leer drei.', 'Ole ging.', 'Leer vier.']; + + it('Lücke ab der Schwelle (Hauptfigur 3), Hinweis mit Schlüssel an Figur und letztem Kapitel', () => { + const d = projekt(texte, [figur('ole', 'Ole', { rolle: 'haupt' })]); + const h = hinweise(d, 'ruht'); + expect(h.map((x) => x.schluessel)).toEqual(['figuren:ruht:ole@k1']); + expect(h[0]!.zitate).toEqual([{ kapitel: 'k1', text: 'Ole kam.' }]); + }); + + it('Nebenfigur erst ab 6; eigene Schwelle; null = nie', () => { + const f = figur('ole', 'Ole'); + const d = projekt(texte, [f]); + const { erwaehnungen } = berechneIndex(d); + expect(ruhendeStrecken(d, erwaehnungen, f)).toEqual([]); + expect(ruhendeStrecken(d, erwaehnungen, { ...f, ruheSchwelle: 1 })).toEqual([ + { nach: 'k1', kapitel: ['k2', 'k3', 'k4'] }, + { nach: 'k5', kapitel: ['k6'] }, + ]); + expect(ruhendeStrecken(d, erwaehnungen, { ...f, rolle: 'haupt', ruheSchwelle: null })).toEqual([]); + }); + + it('Kapitelplan zählt als Vorkommen (Ich-Erzähler ohne Namen)', () => { + const f = figur('ole', 'Ole', { rolle: 'haupt' }); + const d = projekt(texte, [f]); + d.plaene = { k3: { ...Object.values(demo.plaene)[0]!, id: 'k3', figuren: ['ole'] } }; + expect(ruhendeStrecken(d, berechneIndex(d).erwaehnungen, f)).toEqual([]); + }); +}); + +describe('F-14 Statist in vielen Kapiteln', () => { + it('ab drei Kapiteln „zur Nebenfigur machen?“; nicht für Nebenfiguren', () => { + const texte = ['Die Kellnerin Uta kam.', 'Uta brachte Kaffee.', 'Uta lachte.']; + expect(hinweise(projekt(texte, [figur('uta', 'Uta', { rolle: 'statist' })]), 'aufsteigen').map((h) => h.schluessel)).toEqual(['figuren:aufsteigen:uta']); + expect(hinweise(projekt(texte.slice(0, 2), [figur('uta', 'Uta', { rolle: 'statist' })]), 'aufsteigen')).toEqual([]); + expect(hinweise(projekt(texte, [figur('uta', 'Uta')]), 'aufsteigen')).toEqual([]); + }); + + it('Statisten ruhen nie und zählen nicht als verwechselbar', () => { + const d = projekt( + ['Sabine kam.', 'a.', 'b.', 'c.', 'd.', 'e.', 'f.', 'Sabine ging. Sabina auch.'], + [figur('sabine', 'Sabine', { rolle: 'statist', ruheSchwelle: undefined }), figur('sabina', 'Sabina')], + ); + expect(hinweise(d, 'ruht').filter((h) => h.schluessel.includes('sabine'))).toEqual([]); + expect(hinweise(d, 'aehnlich')).toEqual([]); + }); +}); + +describe('F-15 Besetzung', () => { + it('Figuren × Kapitel mit wichtigster Art, Kapitelplan als „plan“, Summen je Kapitel', async () => { + const { besetzung } = await import('./besetzung'); + const d = projekt(['Ole kam. Paul sah ihn.', 'Paul schrieb.', 'Leer.'], [figur('ole', 'Ole', { rolle: 'haupt' }), figur('paul', 'Paul', { rolle: 'haupt' }), figur('uta', 'Uta', { rolle: 'statist' })]); + d.plaene = { k3: { ...Object.values(demo.plaene)[0]!, id: 'k3', figuren: ['ole'] } }; + const b = besetzung(d, berechneIndex(d).erwaehnungen); + expect(b.kapitel).toEqual(['k1', 'k2', 'k3']); + expect(b.zeilen.map((z) => z.figur.id)).toEqual(['ole', 'paul', 'uta']); + const ole = b.zeilen[0]!; + expect(ole.zellen[0]).toBeTruthy(); + expect(ole.zellen[1]).toBeNull(); + expect(ole.zellen[2]).toBe('plan'); + expect(ole.anzahl).toBe(1); + expect(b.summen).toEqual([2, 1, 0]); + expect(ole.erste[0]?.treffer).toBe('Ole'); + }); +}); + +describe('F-16 Spitzname vor seinem geplanten Kapitel', () => { + it('meldet die erste Stelle vor „ab Kapitel“; ab dort nicht', () => { + const d = projekt(['Der Alte kam.', 'Henrik lachte.', 'Der Alte ging.'], [ + figur('henrik', 'Henrik', { aliase: ['Alte'], spitznamen: [{ name: 'Alte', abKapitel: 'k3' }] }), + ]); + const h = hinweise(d, 'frueh'); + expect(h.map((x) => x.schluessel)).toEqual(['figuren:frueh:henrik|alte']); + expect(h[0]!.zitate).toEqual([{ kapitel: 'k1', text: 'Der Alte kam.' }]); + d.figuren.henrik = { ...d.figuren.henrik!, spitznamen: [{ name: 'Alte', abKapitel: 'k1' }] }; + expect(hinweise(d, 'frueh')).toEqual([]); + }); + + it('Kontext: setzen, leeren, beim Entfernen des Namens weg; übersteht Speichern', async () => { + const { mitAliasen, mitSpitznameKontext } = await import('./namen'); + const { parseFigur, serializeFigur } = await import('../projekt'); + let f = figur('ole', 'Ole', { aliase: ['Olli'] }); + f = mitSpitznameKontext(f, { name: 'Olli', von: ['paul', 'paul'], notiz: ' Kosename ' }); + expect(f.spitznamen).toEqual([{ name: 'Olli', von: ['paul'], notiz: 'Kosename' }]); + expect(parseFigur(serializeFigur(f), 'ole').spitznamen).toEqual(f.spitznamen); + expect(mitSpitznameKontext(f, { name: 'Olli' }).spitznamen).toBeUndefined(); + expect(mitAliasen(f, ['Fenster-Ole']).spitznamen).toBeUndefined(); + expect(mitAliasen(f, ['Olli', 'olli', 'Ole', ' ']).aliase).toEqual(['Olli']); + }); +}); diff --git a/core/figuren/hinweise.ts b/core/figuren/hinweise.ts new file mode 100644 index 0000000..279ba60 --- /dev/null +++ b/core/figuren/hinweise.ts @@ -0,0 +1,297 @@ +/** + * Lokale Hinweise rund um Figurennamen (Typ „figuren“, ohne KI): + * - F-11 Spitzname, bevor der Leser ihn kennt: Die Figur wird zuerst über einen Spitznamen erkannt, ihr Name + * fällt erst später. + * - F-12 Verwechselbare Namen: Namen zweier Figuren unterscheiden sich nur in einem Buchstaben oder beginnen + * gleich und sind gleich lang. + * - F-13 Ruhende Figuren: Eine Figur kommt mehrere Kapitel lang nicht vor (wie ruhende Fäden, K-02). + * + * Schlüssel hängen an Figur und Namen bzw. Kapitel, nicht an Zitaten, damit „Ist Absicht“ Textänderungen übersteht. + */ +import { fmt, texteJe, type Sprache } from '../i18n'; +import type { Erwaehnung, Figur, Hinweis, Id, ProjektDaten, Zitat } from '../model/types'; +import { klartext } from '../text'; +import { namensVergleich } from './namen'; + +const T = texteJe( + { + nr: 'Kap. {n}', + vorher: '{alias} steht vor dem Namen {name}', + vorherErklaerung: + '{figur} wird in {nrAlias} zuerst als {alias} erkannt, der Name {name} fällt erst in {nrName}. Wer das Buch liest, weiß bis dahin vielleicht nicht, wer gemeint ist. Gewollt? Dann „Ist Absicht“.', + aehnlich: 'Leicht zu verwechseln: {a} und {b}', + aehnlichEinBuchstabe: 'Die Namen {a} ({figurA}) und {b} ({figurB}) unterscheiden sich nur in einem Buchstaben.', + aehnlichAnfang: 'Die Namen {a} ({figurA}) und {b} ({figurB}) beginnen gleich und sind gleich lang.', + ruht: '{figur} ruht seit {nr}', + ruhtErklaerung: { + one: '{figur} kommt nach {nr} ein Kapitel lang nicht mehr vor ({von}).', + other: '{figur} kommt nach {nr} {n} Kapitel lang nicht mehr vor ({von}–{bis}).', + }, + frueh: '{alias} schon in {nr}, geplant ab {ab}', + fruehErklaerung: '{figur} soll laut Planung erst ab {ab} {alias} heißen, wird aber schon in {nr} so genannt.', + aufsteigen: 'Statist {figur} in {n} Kapiteln – zur Nebenfigur machen?', + aufsteigenErklaerung: + '{figur} ist als Statist angelegt, kommt aber in {n} Kapiteln vor ({liste}). Als Nebenfigur erscheint die Figur in Liste und Netz und wird bei ruhenden Strecken gemeldet.', + zitat: '»{text}«', + }, + { + nr: 'Ch. {n}', + vorher: '{alias} appears before the name {name}', + vorherErklaerung: + '{figur} is first recognised as {alias} in {nrAlias}; the name {name} only appears in {nrName}. Readers may not know who is meant until then. Intended? Then “Intentional”.', + aehnlich: 'Easy to confuse: {a} and {b}', + aehnlichEinBuchstabe: 'The names {a} ({figurA}) and {b} ({figurB}) differ in only one letter.', + aehnlichAnfang: 'The names {a} ({figurA}) and {b} ({figurB}) start the same and have the same length.', + ruht: '{figur} has been absent since {nr}', + ruhtErklaerung: { + one: 'After {nr}, {figur} does not appear for one chapter ({von}).', + other: 'After {nr}, {figur} does not appear for {n} chapters ({von}–{bis}).', + }, + frueh: '{alias} already in {nr}, planned from {ab}', + fruehErklaerung: 'According to the plan, {figur} is only called {alias} from {ab}, but is already called that in {nr}.', + aufsteigen: 'Walk-on {figur} in {n} chapters – make a supporting character?', + aufsteigenErklaerung: + '{figur} is set up as a walk-on but appears in {n} chapters ({liste}). As a supporting character they show up in the list and the map and are reported when absent for a while.', + zitat: '“{text}”', + }, +); + +/** F-14: Ab so vielen Kapiteln wird ein Statist zum Aufstieg vorgeschlagen. */ +export const STATIST_AUFSTIEG = 3; + +/** Standard-Schwellen für ruhende Figuren (Kapitel ohne Vorkommen); Statisten nie. */ +export const RUHE_SCHWELLE = { haupt: 3, neben: 6 } as const; + +/** Schwelle einer Figur: eigene Angabe (null = nie melden), sonst nach Rolle. */ +export function ruheSchwelle(f: Figur): number | null { + if (f.ruheSchwelle !== undefined) return f.ruheSchwelle; + return f.rolle === 'haupt' ? RUHE_SCHWELLE.haupt : f.rolle === 'neben' ? RUHE_SCHWELLE.neben : null; +} + +/** Der Satz um eine Stelle, wörtlich (höchstens ~160 Zeichen, sonst Wörter um die Stelle). */ +export function satzUm(text: string, start: number, ende: number): string { + let a = start; + while (a > 0 && !/[.!?…\n]/u.test(text[a - 1]!)) a--; + let b = ende; + while (b < text.length && !/[.!?…\n]/u.test(text[b]!)) b++; + if (b < text.length && text[b] !== '\n') b++; + // schließende Anführungszeichen gehören zum Satz + while (b < text.length && /[«»“”"’']/u.test(text[b]!)) b++; + let satz = text.slice(a, b).trim(); + if (satz.length > 160) { + const vor = text.slice(Math.max(a, start - 60), start).replace(/^\S*\s/u, ''); + const nach = text.slice(ende, Math.min(b, ende + 60)).replace(/\s\S*$/u, ''); + satz = `${vor}${text.slice(start, ende)}${nach}`.trim(); + } + return satz; +} + +function reihe(daten: ProjektDaten): Id[] { + const r = [...(daten.projekt?.kapitel ?? [])]; + for (const id of Object.keys(daten.kapitel ?? {})) if (!r.includes(id)) r.push(id); + return r; +} + +/** Ist der Treffer der Name der Figur (inkl. Formen und Genitiv) – und kein Spitzname? */ +function istName(f: Figur, treffer: string): boolean { + const t = namensVergleich(treffer); + return [f.name, ...(f.formen ?? [])].some((n) => { + const v = namensVergleich(n); + return t === v || t === `${v}s` || t === `${v}’s` || t === `${v}'s` || t === `${v}’` || t === `${v}'`; + }); +} + +/** Levenshtein-Abstand, früh abgebrochen ab 2. */ +function abstandHoechstensEins(a: string, b: string): boolean { + if (Math.abs(a.length - b.length) > 1) return false; + let i = 0; + let j = 0; + let fehler = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + i++; + j++; + continue; + } + if (++fehler > 1) return false; + if (a.length > b.length) i++; + else if (b.length > a.length) j++; + else { + i++; + j++; + } + } + return fehler + (a.length - i) + (b.length - j) <= 1; +} + +/** Wörter eines Namens, die als Rufname taugen (≥ 4 Buchstaben, groß geschrieben). */ +function namensWoerter(f: Figur): string[] { + const woerter = [f.name, ...f.aliase].flatMap((n) => n.split(/\s+/u)).filter((w) => w.length >= 4 && /^\p{Lu}/u.test(w)); + return [...new Set(woerter)]; +} + +export function figurenHinweise(daten: ProjektDaten, erwaehnungen: Erwaehnung[], sprache: Sprache = 'de'): Hinweis[] { + const t = T(sprache); + const kapitel = reihe(daten); + const pos = new Map(kapitel.map((id, i) => [id, i])); + const nr = (id: Id) => fmt(t.nr, { n: (pos.get(id) ?? 0) + 1 }); + const zitatVon = (e: Erwaehnung): Zitat => ({ kapitel: e.kapitel, text: satzUm(klartext(daten.kapitel[e.kapitel]?.text ?? ''), e.start, e.ende) }); + const q = (x: string) => fmt(t.zitat, { text: x }); + const nachFigur = new Map(); + for (const e of erwaehnungen) { + if (e.zielTyp !== 'figur') continue; + const l = nachFigur.get(e.ziel); + if (l) l.push(e); + else nachFigur.set(e.ziel, [e]); + } + const hinweis = (schluessel: string, titel: string, erklaerung: string, zitate: Zitat[]): Hinweis => ({ + schluessel, + typ: 'figuren', + titel, + erklaerung, + zitate, + quelle: 'lokal', + status: 'neu', + }); + const ergebnis: Hinweis[] = []; + const figuren = Object.values(daten.figuren).sort((a, b) => a.name.localeCompare(b.name, 'de')); + + // F-11: Spitzname vor dem Namen + for (const f of figuren) { + if (f.existenz === 'bewusst_vage' || f.aliase.length === 0) continue; + const liste = (nachFigur.get(f.id) ?? []).filter((e) => e.quelle === 'exakt'); + const ersterName = liste.find((e) => istName(f, e.treffer)); + const ersterAlias = liste.find((e) => !istName(f, e.treffer)); + if (!ersterName || !ersterAlias) continue; + const vorher = + (pos.get(ersterAlias.kapitel) ?? 0) < (pos.get(ersterName.kapitel) ?? 0) || + (ersterAlias.kapitel === ersterName.kapitel && ersterAlias.start < ersterName.start); + if (!vorher) continue; + const alias = ersterAlias.treffer; + ergebnis.push( + hinweis( + `figuren:vorher:${f.id}|${namensVergleich(alias)}`, + fmt(t.vorher, { alias: q(alias), name: q(f.name) }), + fmt(t.vorherErklaerung, { figur: f.name, alias: q(alias), name: q(f.name), nrAlias: nr(ersterAlias.kapitel), nrName: nr(ersterName.kapitel) }), + [zitatVon(ersterAlias), zitatVon(ersterName)], + ), + ); + } + + // F-16: Spitzname vor seinem geplanten Kapitel + for (const f of figuren) { + for (const k of f.spitznamen ?? []) { + const ab = k.abKapitel !== undefined ? pos.get(k.abKapitel) : undefined; + if (ab === undefined) continue; + const form = namensVergleich(k.name); + const frueh = (nachFigur.get(f.id) ?? []).find((e) => { + const t2 = namensVergleich(e.treffer); + return (pos.get(e.kapitel) ?? 0) < ab && (t2 === form || t2.replace(/[’']?s$/u, '') === form); + }); + if (!frueh) continue; + ergebnis.push( + hinweis( + `figuren:frueh:${f.id}|${form}`, + fmt(t.frueh, { alias: q(k.name), nr: nr(frueh.kapitel), ab: nr(k.abKapitel!) }), + fmt(t.fruehErklaerung, { figur: f.name, alias: q(k.name), nr: nr(frueh.kapitel), ab: nr(k.abKapitel!) }), + [zitatVon(frueh)], + ), + ); + } + } + + // F-12: verwechselbare Namen (beide Figuren im Text, keine Statisten) + const imText = figuren.filter((f) => f.rolle !== 'statist' && nachFigur.has(f.id)); + for (let i = 0; i < imText.length; i++) + for (let j = i + 1; j < imText.length; j++) { + const a = imText[i]!; + const b = imText[j]!; + let gefunden: { wa: string; wb: string; art: 'eins' | 'anfang' } | null = null; + for (const wa of namensWoerter(a)) { + for (const wb of namensWoerter(b)) { + const x = wa.toLocaleLowerCase('de'); + const y = wb.toLocaleLowerCase('de'); + if (x === y) continue; + if (abstandHoechstensEins(x, y)) gefunden = { wa, wb, art: 'eins' }; + else if (x.length === y.length && x.slice(0, 3) === y.slice(0, 3)) gefunden = { wa, wb, art: 'anfang' }; + if (gefunden) break; + } + if (gefunden) break; + } + if (!gefunden) continue; + const ea = nachFigur.get(a.id)![0]!; + const eb = nachFigur.get(b.id)![0]!; + const [x, y] = [gefunden.wa, gefunden.wb].sort((m, n) => m.localeCompare(n, 'de')); + ergebnis.push( + hinweis( + `figuren:aehnlich:${[a.id, b.id].sort().join('|')}|${x}|${y}`, + fmt(t.aehnlich, { a: q(gefunden.wa), b: q(gefunden.wb) }), + fmt(gefunden.art === 'eins' ? t.aehnlichEinBuchstabe : t.aehnlichAnfang, { a: q(gefunden.wa), b: q(gefunden.wb), figurA: a.name, figurB: b.name }), + [zitatVon(ea), zitatVon(eb)], + ), + ); + } + + // F-14: Statist in vielen Kapiteln + for (const f of figuren) { + if (f.rolle !== 'statist') continue; + const liste = nachFigur.get(f.id) ?? []; + const kap = kapitel.filter((id) => liste.some((e) => e.kapitel === id)); + if (kap.length < STATIST_AUFSTIEG) continue; + ergebnis.push( + hinweis( + `figuren:aufsteigen:${f.id}`, + fmt(t.aufsteigen, { figur: f.name, n: kap.length }), + fmt(t.aufsteigenErklaerung, { figur: f.name, n: kap.length, liste: kap.map(nr).join(', ') }), + [zitatVon(liste[0]!)], + ), + ); + } + + // F-13: ruhende Figuren + for (const f of figuren) { + for (const s of ruhendeStrecken(daten, erwaehnungen, f)) { + const letzte = (nachFigur.get(f.id) ?? []).filter((e) => e.kapitel === s.nach).pop(); + ergebnis.push( + hinweis( + `figuren:ruht:${f.id}@${s.nach}`, + fmt(t.ruht, { figur: f.name, nr: nr(s.nach) }), + fmt(t.ruhtErklaerung, { figur: f.name, nr: nr(s.nach), n: s.kapitel.length, von: nr(s.kapitel[0]!), bis: nr(s.kapitel[s.kapitel.length - 1]!) }), + letzte ? [zitatVon(letzte)] : [], + ), + ); + } + } + return ergebnis; +} + +export interface RuhendeStrecke { + /** letztes Kapitel mit der Figur vor der Strecke */ + nach: Id; + /** Kapitel (mit Text) ohne die Figur */ + kapitel: Id[]; +} + +/** + * Strecken von mindestens `ruheSchwelle(f)` Kapiteln mit Text, in denen die Figur weder im Text vorkommt noch im + * Kapitelplan steht – zwischen zwei Vorkommen oder nach dem letzten (nur, solange danach noch Text kommt). + */ +export function ruhendeStrecken(daten: ProjektDaten, erwaehnungen: Erwaehnung[], f: Figur): RuhendeStrecke[] { + const schwelle = ruheSchwelle(f); + if (schwelle === null || schwelle < 1) return []; + const mitText = reihe(daten).filter((id) => daten.kapitel[id]?.text.trim()); + const da = new Set(erwaehnungen.filter((e) => e.zielTyp === 'figur' && e.ziel === f.id).map((e) => e.kapitel)); + for (const p of Object.values(daten.plaene ?? {})) if (p.figuren.includes(f.id)) da.add(p.id); + const strecken: RuhendeStrecke[] = []; + let letzte: Id | null = null; + let luecke: Id[] = []; + for (const id of mitText) { + if (da.has(id)) { + if (letzte && luecke.length >= schwelle) strecken.push({ nach: letzte, kapitel: luecke }); + letzte = id; + luecke = []; + } else if (letzte) luecke.push(id); + } + if (letzte && luecke.length >= schwelle) strecken.push({ nach: letzte, kapitel: luecke }); + return strecken; +} diff --git a/core/figuren/index.ts b/core/figuren/index.ts index f1d312b..ce4c46c 100644 --- a/core/figuren/index.ts +++ b/core/figuren/index.ts @@ -1,6 +1,11 @@ /** - * G3 Figuren: Figuren-Bögen (plan/boegen.md) und Figurenstimme (.plotwerk/stimmen.json). Besitzer: G3 Figuren. + * G3 Figuren: Figuren-Bögen (plan/boegen.md), Figurenstimme (.plotwerk/stimmen.json) und Zusammenführen. Besitzer: G3 Figuren. * Reines TypeScript ohne DOM/Tauri; die KI-Aufgabe liegt in core/ki/stimme.ts. */ export * from './boegen'; export * from './stimme'; +export * from './zusammenfuehren'; +export * from './namen'; +export * from './hinweise'; +export * from './besetzung'; +export * from './fakten'; diff --git a/core/figuren/namen.test.ts b/core/figuren/namen.test.ts new file mode 100644 index 0000000..4487a3c --- /dev/null +++ b/core/figuren/namen.test.ts @@ -0,0 +1,42 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { demoFs } from '../../fixtures/demoProjekt'; +import type { ProjektDaten } from '../model/types'; +import { ladeProjekt } from '../projekt'; +import { figurenSuchen, figurMitNamensform, mitSpitzname, spitznameAusText } from './index'; + +let daten: ProjektDaten; +beforeAll(async () => { + daten = await ladeProjekt(demoFs()); +}); + +describe('Spitznamen', () => { + it('aus einer Markierung: ohne Artikel, Anführungszeichen und Satzzeichen am Rand', () => { + expect(spitznameAusText('dem Alten')).toBe('Alten'); + expect(spitznameAusText(' der Mann am Kai, ')).toBe('Mann am Kai'); + expect(spitznameAusText('»Kranich«')).toBe('Kranich'); + expect(spitznameAusText('the Stranger.')).toBe('Stranger'); + // nur ein Artikel: bleibt stehen + expect(spitznameAusText('Die')).toBe('Die'); + }); + + it('Figur zu einem Namen: Name, Spitzname oder Form, ohne Artikel und Groß/klein', () => { + expect(figurMitNamensform(daten, 'ole')?.id).toBe('ole'); + expect(figurMitNamensform(daten, 'Herrn Kessler')?.id).toBe('herr-kessler'); + expect(figurMitNamensform(daten, 'die fremde')?.id).toBe('die-fremde'); + expect(figurMitNamensform(daten, 'Niemand')).toBeUndefined(); + }); + + it('Spitzname ergänzen: nur, wenn er neu ist', () => { + const ole = daten.figuren.ole!; + expect(mitSpitzname(ole, 'Fensterbank-Ole')?.aliase).toEqual([...ole.aliase, 'Fensterbank-Ole']); + expect(mitSpitzname(ole, 'OLE')).toBeNull(); + expect(mitSpitzname(ole, ' ')).toBeNull(); + }); + + it('Suche: bevorzugte zuerst, dann Treffer am Namensanfang, dann im Namen oder in der Kurzbeschreibung', () => { + const ids = figurenSuchen(daten, 'o', ['paul']).map((f) => f.id); + expect(ids[0]).toBe('paul'); + expect(ids.indexOf('ole')).toBeLessThan(ids.indexOf('konrad')); + expect(figurenSuchen(daten, 'xyz')).toEqual([]); + }); +}); diff --git a/core/figuren/namen.ts b/core/figuren/namen.ts new file mode 100644 index 0000000..9df1497 --- /dev/null +++ b/core/figuren/namen.ts @@ -0,0 +1,114 @@ +/** + * Namen und Spitznamen von Figuren: markierten Text in einen Spitznamen verwandeln, Spitznamen ergänzen, + * Figuren zu einem Namen finden. Reine Funktionen ohne Store. + */ +import type { Figur, FigurRolle, Id, ProjektDaten, SpitznameKontext } from '../model/types'; + +/** Führende Artikel, die die Erkennung selbst ergänzt („dem Alten“ findet auch „der Alte“ nicht, „Alte“ schon). */ +const ARTIKEL = /^(der|die|das|den|dem|des|ein|eine|einen|einem|einer|eines|the|a|an)\s+/i; + +/** Vergleichsform: getrimmt, klein, ohne führenden Artikel. */ +export function namensVergleich(s: string): string { + return s.trim().replace(ARTIKEL, '').trim().toLocaleLowerCase('de'); +} + +/** + * Spitzname aus einer Markierung: ohne Anführungszeichen, Satzzeichen am Rand und führenden Artikel, + * Leerraum zusammengefasst. Leer, wenn nichts Brauchbares übrig bleibt. + */ +export function spitznameAusText(text: string): string { + const s = text + .replace(/\s+/g, ' ') + .trim() + .replace(/^[»«„“”"‚‘’'›‹([{–—-]+/u, '') + .replace(/[»«„“”"‚‘’'›‹)\]}.,;:!?…–—-]+$/u, '') + .trim(); + const ohne = s.replace(ARTIKEL, '').trim(); + return ohne || s; +} + +/** Alle Namen einer Figur (Name, Spitznamen, Formen). */ +export function namenVon(f: Pick): string[] { + return [f.name, ...f.aliase, ...(f.formen ?? [])]; +} + +/** Figur, die diesen Namen schon trägt (Name, Spitzname oder Form; ohne Artikel, Groß/klein egal). */ +export function figurMitNamensform(daten: Pick, name: string): Figur | undefined { + const form = namensVergleich(name); + if (!form) return undefined; + return Object.values(daten.figuren).find((f) => namenVon(f).some((n) => namensVergleich(n) === form)); +} + +/** Figur mit zusätzlichem Spitznamen; null, wenn sie ihn (oder den Namen) schon trägt oder er leer ist. */ +export function mitSpitzname(f: Figur, name: string): Figur | null { + const n = name.trim(); + if (!n || namenVon(f).some((x) => namensVergleich(x) === namensVergleich(n))) return null; + return { ...f, aliase: [...f.aliase, n] }; +} + +/** Figuren für eine Suche (Name, Spitznamen, Kurzbeschreibung); Treffer am Anfang zuerst. */ +export function figurenSuchen(daten: Pick, suche: string, bevorzugt: Id[] = []): Figur[] { + const q = suche.trim().toLocaleLowerCase('de'); + const rang = (f: Figur): number => { + if (bevorzugt.includes(f.id)) return 0; + if (!q) return 2; + const namen = namenVon(f).map((n) => n.toLocaleLowerCase('de')); + if (namen.some((n) => n.startsWith(q))) return 1; + if (namen.some((n) => n.includes(q)) || (f.kurz ?? '').toLocaleLowerCase('de').includes(q)) return 2; + return 99; + }; + return Object.values(daten.figuren) + .map((f) => ({ f, r: rang(f) })) + .filter((x) => x.r < 99) + .sort((a, b) => a.r - b.r || a.f.name.localeCompare(b.f.name, 'de')) + .map((x) => x.f); +} + +/** Reihenfolge der Rollen in Listen: Haupt-, Neben-, Statistenrollen. */ +export const ROLLEN: FigurRolle[] = ['haupt', 'neben', 'statist']; + +/** Vergleich für Listen: erst nach Rolle, dann mit `name` (z. B. nach Namen). */ +export function nachRolle(name: (a: T, b: T) => number): (a: T, b: T) => number { + return (a, b) => ROLLEN.indexOf(a.rolle) - ROLLEN.indexOf(b.rolle) || name(a, b); +} + +/** F-16: Spitznamen setzen (Reihenfolge, ohne Doppelte); Kontext zu entfernten Namen fällt weg. */ +export function mitAliasen(f: Figur, aliase: string[]): Figur { + const gesehen = new Set([namensVergleich(f.name)]); + const neu = aliase + .map((a) => a.replace(/\s+/g, ' ').trim()) + .filter((a) => { + const k = namensVergleich(a); + if (!k || gesehen.has(k)) return false; + gesehen.add(k); + return true; + }); + const formen = new Set(neu.map(namensVergleich)); + const spitznamen = (f.spitznamen ?? []).filter((k) => formen.has(namensVergleich(k.name))); + const ergebnis: Figur = { ...f, aliase: neu }; + if (spitznamen.length) ergebnis.spitznamen = spitznamen; + else delete ergebnis.spitznamen; + return ergebnis; +} + +/** F-16: Kontext eines Spitznamens (undefined, wenn keiner eingetragen ist). */ +export function spitznameKontext(f: Figur, name: string): SpitznameKontext | undefined { + const k = namensVergleich(name); + return f.spitznamen?.find((x) => namensVergleich(x.name) === k); +} + +/** F-16: Kontext setzen bzw. ersetzen; ohne Angaben fällt der Eintrag weg. */ +export function mitSpitznameKontext(f: Figur, kontext: SpitznameKontext): Figur { + const k = namensVergleich(kontext.name); + const rest = (f.spitznamen ?? []).filter((x) => namensVergleich(x.name) !== k); + const sauber: SpitznameKontext = { name: kontext.name }; + if (kontext.von?.length) sauber.von = [...new Set(kontext.von)]; + if (kontext.abKapitel) sauber.abKapitel = kontext.abKapitel; + if (kontext.notiz?.trim()) sauber.notiz = kontext.notiz.trim(); + const leer = !sauber.von && !sauber.abKapitel && !sauber.notiz; + const liste = leer ? rest : [...rest, sauber]; + const ergebnis: Figur = { ...f }; + if (liste.length) ergebnis.spitznamen = liste; + else delete ergebnis.spitznamen; + return ergebnis; +} diff --git a/core/figuren/zusammenfuehren.test.ts b/core/figuren/zusammenfuehren.test.ts new file mode 100644 index 0000000..9203020 --- /dev/null +++ b/core/figuren/zusammenfuehren.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { demoFs } from '../../fixtures/demoProjekt'; +import type { Figur, ProjektDaten } from '../model/types'; +import { ladeProjekt } from '../projekt'; +import { figurenZusammenfuehren } from './index'; + +let daten: ProjektDaten; + +/** Demo-Projekt mit einer doppelt angelegten Vera („die Nachbarin“), auf die überall verwiesen wird. */ +beforeEach(async () => { + daten = await ladeProjekt(demoFs()); + const doppelt: Figur = { + id: 'nachbarin', + name: 'die Nachbarin', + aliase: ['Vera', 'Frau von oben'], + formen: ['Nachbarin'], + rolle: 'haupt', + kurz: 'wohnt über Paul', + existenz: 'offen', + gruppe: 'nordhaven', + beziehungen: [ + { zu: 'paul', art: 'Verabredung', abKapitel: 'ebbe', kategorie: 'dates' }, + { zu: 'ole', art: 'Bekannte', kategorie: 'sonstige' }, + { zu: 'vera', art: 'selbst', kategorie: 'sonstige' }, + ], + plan: { Alter: '34', Hobby: 'Segeln' }, + wahrheit: 'Kennt Kessler.', + notizen: 'Siehe [[figur:paul|Paul]].', + }; + daten.figuren.nachbarin = doppelt; + daten.figuren.ole = { + ...daten.figuren.ole!, + beziehungen: [...daten.figuren.ole!.beziehungen, { zu: 'nachbarin', art: 'Bekannte', kategorie: 'sonstige' }], + notizen: 'Trifft [[figur:nachbarin|die Nachbarin]] im Treppenhaus.', + }; + daten.gruppen.nordhaven = { ...daten.gruppen.nordhaven!, mitglieder: [...daten.gruppen.nordhaven!.mitglieder, 'nachbarin'] }; + const plan = daten.plaene.ebbe!; + daten.plaene.ebbe = { ...plan, figuren: [...plan.figuren, 'vera', 'nachbarin'] }; + daten.zeitleiste = { + ...daten.zeitleiste!, + zustaende: [ + ...daten.zeitleiste!.zustaende, + { id: 'z-n1', figur: 'nachbarin', kapitel: 'ebbe', zustand: 'nuechtern' }, + { id: 'z-v1', figur: 'vera', kapitel: 'ebbe', zustand: 'betrunken' }, + { id: 'z-n2', figur: 'nachbarin', kapitel: 'funkstille', zustand: 'nuechtern' }, + ], + }; + daten.boegen = [...(daten.boegen ?? []), { figur: 'nachbarin', stufen: [{ id: 's1', titel: 'zieht ein', belege: [] }] }]; + daten.anmerkungen = [{ id: 'a1', anker: { kapitel: 'ebbe', start: 0, ende: 1, davor: '', text: 'x', danach: '' } as never, notiz: '', ziel: { typ: 'figur', id: 'nachbarin' }, erstellt: '' }]; +}); + +describe('Figuren zusammenführen', () => { + it('bleibende Figur übernimmt Namen, Formen, Beziehungen, Plan, Notizen; Leeres wird ergänzt', () => { + const vorher = daten.figuren.vera!; + const d = figurenZusammenfuehren(daten, 'vera', 'nachbarin'); + expect(d.figuren.nachbarin).toBeUndefined(); + const v = d.figuren.vera!; + expect(v.name).toBe('Vera'); + expect(v.existenz).toBe(vorher.existenz); + expect(v.aliase).toEqual([...vorher.aliase, 'die Nachbarin', 'Frau von oben']); + expect(v.formen).toEqual(['Nachbarin']); + expect(v.rolle).toBe('haupt'); + expect(v.gruppe).toBe(vorher.gruppe ?? 'nordhaven'); + // doppelte Beziehung (Verabredung mit Paul) nur einmal, Beziehung zu sich selbst fällt weg + expect(v.beziehungen.map((b) => `${b.zu}:${b.art}`)).toEqual(['paul:Verabredung', 'ole:Bekannte']); + expect(v.plan.Hobby).toBe('Segeln'); + expect(v.plan.Alter).toBe(vorher.plan.Alter ? `${vorher.plan.Alter}\n\n34` : '34'); + expect(v.wahrheit).toContain('Kennt Kessler.'); + expect(v.notizen).toContain('[[figur:paul|Paul]]'); + }); + + it('alle Verweise zeigen danach auf die bleibende Figur', () => { + const d = figurenZusammenfuehren(daten, 'vera', 'nachbarin'); + expect(d.figuren.ole!.beziehungen.filter((b) => b.zu === 'nachbarin')).toEqual([]); + expect(d.figuren.ole!.beziehungen.some((b) => b.zu === 'vera' && b.art === 'Bekannte')).toBe(true); + expect(d.figuren.ole!.notizen).toBe('Trifft [[figur:vera|die Nachbarin]] im Treppenhaus.'); + expect(d.gruppen.nordhaven!.mitglieder).not.toContain('nachbarin'); + expect(d.gruppen.nordhaven!.mitglieder.filter((m) => m === 'vera').length).toBe(1); + expect(d.plaene.ebbe!.figuren.filter((f) => f === 'vera').length).toBe(1); + expect(d.plaene.ebbe!.figuren).not.toContain('nachbarin'); + // Zustand: bei Überschneidung gilt der der bleibenden Figur + const zustaende = d.zeitleiste!.zustaende.filter((z) => ['z-n1', 'z-v1', 'z-n2'].includes(z.id)); + expect(zustaende.map((z) => [z.id, z.figur])).toEqual([ + ['z-v1', 'vera'], + ['z-n2', 'vera'], + ]); + expect(d.boegen!.find((b) => b.figur === 'vera')!.stufen.map((s) => s.id)).toContain('s1'); + expect(d.boegen!.some((b) => b.figur === 'nachbarin')).toBe(false); + expect(d.anmerkungen[0]!.ziel).toEqual({ typ: 'figur', id: 'vera' }); + }); + + it('unbeteiligte Teile behalten ihre Referenz (nur geänderte Dateien werden geschrieben)', () => { + const d = figurenZusammenfuehren(daten, 'vera', 'nachbarin'); + expect(d.figuren.paul).toBe(daten.figuren.paul); + expect(d.kapitel).toBe(daten.kapitel); + expect(d.plaene.grundriss).toBe(daten.plaene.grundriss); + expect(d.orte).toBe(daten.orte); + expect(d.figuren.ole).not.toBe(daten.figuren.ole); + }); + + it('dieselbe oder eine unbekannte Figur: unverändert', () => { + expect(figurenZusammenfuehren(daten, 'vera', 'vera')).toBe(daten); + expect(figurenZusammenfuehren(daten, 'vera', 'gibt-es-nicht')).toBe(daten); + }); +}); diff --git a/core/figuren/zusammenfuehren.ts b/core/figuren/zusammenfuehren.ts new file mode 100644 index 0000000..7b609d0 --- /dev/null +++ b/core/figuren/zusammenfuehren.ts @@ -0,0 +1,247 @@ +/** + * Zwei Figuren zusammenführen (z. B. „Henrik“ und „der Alte“, die sich als dieselbe Person herausstellen). + * Reine Funktion: liefert neue ProjektDaten; unveränderte Teile behalten ihre Referenz, damit der Store nur die + * geänderten Dateien schreibt. + * + * Die bleibende Figur behält ID, Namen und Gewissheit. Von der aufgegebenen übernimmt sie Namen (als Aliase), + * Formen, Beziehungen, Planfelder, Notizen, „Wahrheit“ und, wo bei ihr leer, Kurzbeschreibung, Gruppe und + * Netzposition. Alle Verweise im Projekt (Beziehungen anderer Figuren, Gruppen, Kapitelpläne und Szenen, + * Zeitleiste, Bögen, Anmerkungen, Recherche, Entscheidungen, KI-Ablagen, `[[figur:…]]`-Links in Notizen und + * Planfeldern) zeigen danach auf die bleibende Figur. + */ +import type { Beziehung, Figur, Figurenbogen, Id, KiAblage, ProjektDaten, Verweis, VorschlagsVerweis } from '../model/types'; + +const vergleichsform = (s: string) => s.trim().toLocaleLowerCase('de'); + +function eindeutig(liste: T[], schluessel: (x: T) => string = String): T[] { + const gesehen = new Set(); + return liste.filter((x) => { + const k = schluessel(x); + if (gesehen.has(k)) return false; + gesehen.add(k); + return true; + }); +} + +/** Ersetzt `aus` durch `nach` in einer ID-Liste; gleiche Referenz, wenn `aus` nicht vorkommt. */ +function idsErsetzen(ids: Id[], aus: Id, nach: Id): Id[] { + if (!ids.includes(aus)) return ids; + return eindeutig(ids.map((i) => (i === aus ? nach : i))); +} + +/** Zwei Freitexte verbinden (leere fallen weg, gleiche nur einmal). */ +function textVerbinden(a: string | undefined, b: string | undefined): string { + const teile = eindeutig([a ?? '', b ?? ''].map((t) => t.trim()).filter(Boolean)); + return teile.join('\n\n'); +} + +const beziehungsSchluessel = (b: Beziehung) => `${b.zu}|${vergleichsform(b.art)}|${b.abKapitel ?? ''}`; + +/** Beziehungen auf `nach` umbiegen; Beziehungen zu sich selbst fallen weg, doppelte nur einmal. */ +function beziehungenUmbiegen(liste: Beziehung[], eigene: Id, aus: Id, nach: Id): Beziehung[] { + if (!liste.some((b) => b.zu === aus)) return liste; + return eindeutig( + liste.map((b) => (b.zu === aus ? { ...b, zu: nach } : b)).filter((b) => b.zu !== eigene), + beziehungsSchluessel, + ); +} + +/** `[[figur:aus|…]]` → `[[figur:nach|…]]` in allen Zeichenketten eines Werts (gleiche Referenz ohne Treffer). */ +function linksErsetzen(wert: T, aus: Id, nach: Id): T { + const muster = new RegExp(`\\[\\[figur:\\s*${aus.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*(?=[|\\]])`, 'g'); + const ersetzt = `[[figur:${nach}`; + const gehe = (v: unknown): unknown => { + if (typeof v === 'string') return v.includes('[[figur:') ? v.replace(muster, ersetzt) : v; + if (Array.isArray(v)) { + const neu = v.map(gehe); + return neu.some((x, i) => x !== v[i]) ? neu : v; + } + if (v && typeof v === 'object') { + let geaendert = false; + const neu: Record = {}; + for (const [k, x] of Object.entries(v)) { + neu[k] = gehe(x); + if (neu[k] !== x) geaendert = true; + } + return geaendert ? neu : v; + } + return v; + }; + return gehe(wert) as T; +} + +function verweisErsetzen(v: V, aus: Id, nach: Id): V { + return v && v.typ === 'figur' && v.id === aus ? ({ ...v, id: nach } as V) : v; +} + +/** Die zusammengeführte Figur (ohne Verweise anderer Dateien). */ +export function zusammengefuehrteFigur(a: Figur, b: Figur): Figur { + const vergeben = new Set([vergleichsform(a.name)]); + const aliase = [...a.aliase, b.name, ...b.aliase].filter((n) => { + const k = vergleichsform(n); + if (!k || vergeben.has(k)) return false; + vergeben.add(k); + return true; + }); + const formen = eindeutig([...(a.formen ?? []), ...(b.formen ?? [])], vergleichsform); + const plan: Record = { ...a.plan }; + for (const [feld, wert] of Object.entries(b.plan)) plan[feld] = feld in plan ? textVerbinden(plan[feld], wert) : wert; + const beziehungen = eindeutig( + [...a.beziehungen, ...b.beziehungen] + .map((x) => (x.zu === b.id ? { ...x, zu: a.id } : x)) + .filter((x) => x.zu !== a.id), + beziehungsSchluessel, + ); + const neu: Figur = { + ...a, + aliase, + rolle: a.rolle === 'haupt' || b.rolle === 'haupt' ? 'haupt' : a.rolle, + gruppe: a.gruppe ?? b.gruppe, + beziehungen, + plan, + wahrheit: textVerbinden(a.wahrheit, b.wahrheit), + notizen: textVerbinden(a.notizen, b.notizen), + }; + const kontexte = eindeutig([...(a.spitznamen ?? []), ...(b.spitznamen ?? [])], (k) => vergleichsform(k.name)); + if (kontexte.length) neu.spitznamen = kontexte; + if (formen.length) neu.formen = formen; + else delete neu.formen; + const kurz = a.kurz?.trim() || b.kurz?.trim(); + if (kurz) neu.kurz = kurz; + const netz = a.netz ?? b.netz; + if (netz) neu.netz = netz; + return neu; +} + +function boegenZusammenfuehren(boegen: Figurenbogen[], aus: Id, nach: Id): Figurenbogen[] { + const alt = boegen.find((b) => b.figur === aus); + if (!alt) return boegen; + const ziel = boegen.find((b) => b.figur === nach); + if (!ziel) return boegen.map((b) => (b === alt ? { ...b, figur: nach } : b)); + return boegen.filter((b) => b !== alt).map((b) => (b === ziel ? { ...b, stufen: [...b.stufen, ...alt.stufen] } : b)); +} + +/** + * Führt die Figur `aufgeben` in `behalten` zusammen. Unbekannte IDs oder dieselbe ID → Daten unverändert. + */ +export function figurenZusammenfuehren(daten: ProjektDaten, behalten: Id, aufgeben: Id): ProjektDaten { + const a = daten.figuren[behalten]; + const b = daten.figuren[aufgeben]; + if (!a || !b || behalten === aufgeben) return daten; + const aus = aufgeben; + const nach = behalten; + + const figuren: Record = {}; + for (const f of Object.values(daten.figuren)) { + if (f.id === aus) continue; + figuren[f.id] = f.id === nach ? zusammengefuehrteFigur(a, b) : { ...f, beziehungen: beziehungenUmbiegen(f.beziehungen, f.id, aus, nach) }; + if (f.id !== nach && figuren[f.id]!.beziehungen === f.beziehungen) figuren[f.id] = f; + } + + const gruppen = Object.fromEntries( + Object.entries(daten.gruppen).map(([id, g]) => { + const mitglieder = idsErsetzen(g.mitglieder, aus, nach); + let rollen = g.rollen; + if (rollen && aus in rollen) { + const { [aus]: alt, ...rest } = rollen; + rollen = { ...rest, ...(rest[nach] ? {} : { [nach]: alt! }) }; + } + return [id, mitglieder === g.mitglieder && rollen === g.rollen ? g : { ...g, mitglieder, ...(rollen ? { rollen } : {}) }]; + }), + ); + + const plaene = Object.fromEntries( + Object.entries(daten.plaene).map(([id, p]) => { + const pf = idsErsetzen(p.figuren, aus, nach); + const szenen = p.szenen?.map((s) => { + const sf = idsErsetzen(s.figuren, aus, nach); + return sf === s.figuren ? s : { ...s, figuren: sf }; + }); + const szenenGeaendert = !!szenen && szenen.some((s, i) => s !== p.szenen![i]); + if (pf === p.figuren && !szenenGeaendert) return [id, p]; + return [id, { ...p, figuren: pf, ...(szenenGeaendert ? { szenen } : {}) }]; + }), + ); + + const anmerkungen = daten.anmerkungen.some((x) => x.ziel?.typ === 'figur' && x.ziel.id === aus) + ? daten.anmerkungen.map((x) => ({ ...x, ziel: verweisErsetzen(x.ziel, aus, nach) })) + : daten.anmerkungen; + + const e = daten.entscheidungen; + const betrifft = (v: Verweis | null | undefined) => v?.typ === 'figur' && v.id === aus; + const entscheidungen = + e.schreibweisen.some((s) => betrifft(s.ziel)) || e.zuordnungen.some((z) => betrifft(z.ziel)) + ? { + ...e, + schreibweisen: e.schreibweisen.map((s) => (betrifft(s.ziel) ? { ...s, ziel: verweisErsetzen(s.ziel, aus, nach) } : s)), + zuordnungen: e.zuordnungen.map((z) => (betrifft(z.ziel) ? { ...z, ziel: verweisErsetzen(z.ziel, aus, nach) } : z)), + } + : e; + + const kiHinweise = daten.kiHinweise.some((h) => h.plan?.figur === aus) + ? daten.kiHinweise.map((h) => (h.plan?.figur === aus ? { ...h, plan: { ...h.plan, figur: nach } } : h)) + : daten.kiHinweise; + + let kiVorschlaege = daten.kiVorschlaege; + if (kiVorschlaege) { + const v = kiVorschlaege; + const ref = (r: VorschlagsVerweis): VorschlagsVerweis => (r.typ === 'angelegt' && r.id === aus ? { ...r, id: nach, name: a.name } : r); + const betroffen = + v.figuren.some((f) => f.figur === aus) || v.beziehungen.some((x) => [x.von, x.zu].some((r) => r.typ === 'angelegt' && r.id === aus)); + if (betroffen) + kiVorschlaege = { + ...v, + figuren: v.figuren.map((f) => (f.figur === aus ? { ...f, figur: nach } : f)), + beziehungen: v.beziehungen.map((x) => ({ ...x, von: ref(x.von), zu: ref(x.zu) })), + }; + } + + let zeitleiste = daten.zeitleiste; + if (zeitleiste?.zustaende.some((z) => z.figur === aus)) { + // ein Zustand pro Figur und Kapitel/Szene: bei Überschneidung gilt der der bleibenden Figur + const belegt = new Set(zeitleiste.zustaende.filter((z) => z.figur === nach).map((z) => `${z.kapitel}#${z.szene ?? ''}`)); + zeitleiste = { + ...zeitleiste, + zustaende: zeitleiste.zustaende + .filter((z) => z.figur !== aus || !belegt.has(`${z.kapitel}#${z.szene ?? ''}`)) + .map((z) => (z.figur === aus ? { ...z, figur: nach } : z)), + }; + } + + const boegen = daten.boegen ? boegenZusammenfuehren(daten.boegen, aus, nach) : daten.boegen; + + const recherche = + daten.recherche && daten.recherche.eintraege.some((r) => r.verweise.some(betrifft)) + ? { + ...daten.recherche, + eintraege: daten.recherche.eintraege.map((r) => + r.verweise.some(betrifft) + ? { ...r, verweise: eindeutig(r.verweise.map((x) => verweisErsetzen(x, aus, nach)), (x) => `${x.typ}:${x.id}`) } + : r, + ), + } + : daten.recherche; + + const ablage = (x: KiAblage | undefined): KiAblage | undefined => + x && x.eintraege.some((y) => y.figur === aus) ? { ...x, eintraege: x.eintraege.map((y) => (y.figur === aus ? { ...y, figur: nach } : y)) } : x; + + const ergebnis: ProjektDaten = { + ...daten, + figuren, + gruppen, + plaene, + anmerkungen, + entscheidungen, + kiHinweise, + ...(kiVorschlaege ? { kiVorschlaege } : {}), + ...(zeitleiste ? { zeitleiste } : {}), + ...(boegen ? { boegen } : {}), + ...(recherche ? { recherche } : {}), + ...(daten.kiZustandVorschlaege ? { kiZustandVorschlaege: ablage(daten.kiZustandVorschlaege) } : {}), + ...(daten.kiStimmen ? { kiStimmen: ablage(daten.kiStimmen) } : {}), + }; + + // `[[figur:…]]`-Links in Notizen und Planfeldern (nicht im Manuskript, S-07) + const { kapitel, ...rest } = ergebnis; + return { ...linksErsetzen(rest, aus, nach), kapitel }; +} 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..4665707 --- /dev/null +++ b/core/funktionen/index.ts @@ -0,0 +1,377 @@ +/** + * Funktionsumfang (E16): Katalog der abwählbaren Funktionen, Presets je Schreibertyp, eigene Presets und + * No-AI-Modus (E17). 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 E16): 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/index/hinweisTypen.ts b/core/index/hinweisTypen.ts index 45c31c0..b73fff2 100644 --- a/core/index/hinweisTypen.ts +++ b/core/index/hinweisTypen.ts @@ -6,11 +6,12 @@ import type { HinweisTyp } from '../model/types'; export const HINWEIS_TYP_NAME: Record = { pruefen: 'Prüfen', schreibweise: 'Schreibweise', - offener_faden: 'Offener Faden', + offener_faden: 'Loses Ende', unbekannt: 'Unbekannt', struktur: 'Struktur', zeitleiste: 'Zeitleiste', sprache: 'Sprache', + figuren: 'Figuren', }; export const HINWEIS_TYP_REIHE = Object.keys(HINWEIS_TYP_NAME) as HinweisTyp[]; @@ -18,11 +19,12 @@ export const HINWEIS_TYP_REIHE = Object.keys(HINWEIS_TYP_NAME) as HinweisTyp[]; const HINWEIS_TYP_NAME_EN: Record = { pruefen: 'Check', schreibweise: 'Spelling', - offener_faden: 'Open thread', + offener_faden: 'Loose end', unbekannt: 'Unknown', struktur: 'Structure', zeitleiste: 'Timeline', sprache: 'Language', + figuren: 'Characters', }; /** Alle Anzeigenamen in der Sprache (gleiche Schlüssel und Reihenfolge wie HINWEIS_TYP_NAME). */ diff --git a/core/index/index.ts b/core/index/index.ts index e05eeda..73d0411 100644 --- a/core/index/index.ts +++ b/core/index/index.ts @@ -12,6 +12,8 @@ import type { Zitat, } from '../model/types'; import { stimmenHinweise } from '../figuren/stimme'; +import { faktenHinweise } from '../figuren/fakten'; +import { figurenHinweise } from '../figuren/hinweise'; import type { Sprache } from '../i18n'; import { strukturHinweise } from '../struktur'; import { zeitHinweise } from '../zeit'; @@ -165,7 +167,7 @@ function kiHinweisGueltig(h: Hinweis, daten: ProjektDaten): boolean { }); } -function hinweiseBerechnen(daten: ProjektDaten, sprache: Sprache): Hinweis[] { +function hinweiseBerechnen(daten: ProjektDaten, sprache: Sprache, erwaehnungen: Erwaehnung[]): Hinweis[] { const entscheidungen = new Map((daten.entscheidungen?.hinweise ?? []).map((e) => [e.schluessel, e.entscheidung])); const stellen = markierteStellen(daten); // lokal berechnet (Struktur, Schreibweisen, Zeitprüfung) bzw. aus eigenen KI-Ablagen (Figurenstimme, bereits geprüft); @@ -175,6 +177,8 @@ function hinweiseBerechnen(daten: ProjektDaten, sprache: Sprache): Hinweis[] { ...schreibweisenHinweise(daten, sprache), ...zeitHinweise(daten, sprache), ...stimmenHinweise(daten, sprache), + ...figurenHinweise(daten, erwaehnungen, sprache), + ...faktenHinweise(daten, sprache), ]; const ki = (daten.kiHinweise ?? []) .filter((h) => kiHinweisGueltig(h, daten)) @@ -240,7 +244,7 @@ export function berechneIndex(daten: ProjektDaten, vorher?: Index | null, sprach erwaehnungen, erstesVorkommen, faeden: faedenBerechnen(daten, reihe), - hinweise: hinweiseBerechnen(daten, sprache), + hinweise: hinweiseBerechnen(daten, sprache, erwaehnungen), wortzahlen, textHashes, erkennungsHash, diff --git a/core/ki/README.md b/core/ki/README.md index 0a917d8..e8eb16d 100644 --- a/core/ki/README.md +++ b/core/ki/README.md @@ -8,8 +8,9 @@ Stand Oktober 2026) und die Claude-API-Doku. ## Anbieter -Leon wählt in den Einstellungen **Microsoft Foundry** oder die **Claude API** (Anthropic direkt). -Beide sprechen dieselbe Messages-API; Prompt-Aufbau und Antwortprüfung sind identisch. +Leon wählt in den Einstellungen **Microsoft Foundry**, die **Claude API** (Anthropic direkt) oder +einen **OpenAI-kompatiblen Dienst** (siehe unten). Foundry und Claude API sprechen dieselbe +Messages-API; Prompt-Aufbau und Antwortprüfung sind für alle Anbieter identisch. | | Microsoft Foundry | Claude API | | --- | --- | --- | @@ -24,6 +25,40 @@ Beide sprechen dieselbe Messages-API; Prompt-Aufbau und Antwortprüfung sind ide Umschalten erhalten. Helfer: `kiAnbieter`, `kiModell`, `kiEndpoint`, `kiMitStandards` (`core/ki/anbieter.ts`). +## OpenAI-kompatible Dienste (E15) + +Dritter Anbieter `openai`: alles mit OpenAI-kompatibler Chat-Completions-API. Vorlagen in +`core/ki/anbieter.ts` (`VORLAGEN`): + +| Vorlage | Basis-URL | Schlüssel | Antwortformat | Effort | +| --- | --- | --- | --- | --- | +| Ollama (Standard) | `http://localhost:11434/v1` | optional | `json_schema` (per Grammatik erzwungen) | – | +| LM Studio | `http://localhost:1234/v1` | optional | `json_schema` | – | +| OpenAI | `https://api.openai.com/v1` | Pflicht | `json_schema` strict, `max_completion_tokens` | `reasoning_effort` | +| OpenRouter | `https://openrouter.ai/api/v1` | Pflicht | `json_schema`, `provider: {require_parameters, data_collection: "deny"}` | `reasoning.effort` | +| Qwen (Alibaba Cloud) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | Pflicht | `json_object`, Schema im System-Prompt | – | +| Eigene URL | – | optional | `json_schema` | – | + +- `core/ki` baut weiter Messages-API-Bodys und liest Messages-API-Antworten. Der Tauri-Transport + übersetzt davor und danach (`nachOpenAi`, `vonOpenAi` in `core/ki/openai.ts`): `system` → Nachricht + `system`, Inhaltsblöcke → ein Text (ohne `cache_control`; die Dienste cachen gleiche Anfänge selbst), + `finish_reason` `stop`/`length`/`content_filter` → `end_turn`/`max_tokens`/`refusal`, + `prompt_tokens − cached_tokens` → `input_tokens`, `cached_tokens` → `cache_read_input_tokens`. Ein + vorangestelltes `…` und ein ```-Zaun werden vor dem JSON entfernt. +- Unsere Schemas erfüllen den Strict-Modus von OpenAI (`obj()` in `anfrage.ts`: alle Felder Pflicht, + `additionalProperties: false`). +- Rust: `POST {endpoint}/chat/completions`, `content-type` und – nur wenn hinterlegt – + `Authorization: Bearer ` (Konto `openai-kompatibel`). Kein `x-api-key`, keine + `anthropic-*`-Kopfzeilen. `http://` nur für localhost. Zeitlimit 30 min (lokale Modelle lesen ein + ganzes Buch langsam ein). +- Kein `count_tokens`: `tokenZaehlen` lehnt ab, `core/ki` schätzt über Zeichen/3. +- Keine Listenpreise: Schätzung und Ergebnis tragen `kostenBekannt: false` (`kostenUsd` 0 bzw. fehlt), + Ablagen speichern dann kein `kostenUsd`. +- Kontextlänge: Ist `ki.openai.kontextTokens` gesetzt, lehnt der Transport Anfragen ab, die geschätzt + samt 4 000 Tokens Reserve nicht hineinpassen. Lokale Server kürzen sonst still (Ollama: + `OLLAMA_CONTEXT_LENGTH`, LM Studio: Kontext beim Laden des Modells). +- Modellnamen haben keinen Claude-Standard; eine leere Rolle nutzt das Prüfmodell. + ## Endpoint und Pfade - Basis-URL ohne `/v1/messages`. Vor der Nutzung: Leerraum und `/` am Ende entfernen; falls doch @@ -287,3 +322,9 @@ Aufgabentext, Schema, optional `kapitelBis`, `auswerten`). `fuehreAufgabeAus` ba Zitate. `schaetzeAufgabe` liefert die Kostenschätzung, `werteAufgabenAntwortAus` wertet vorbereitete Antworten aus. Die Browser-Demo beantwortet Aufgaben über `src/backend/ki/demoAntworten/` (je Gruppe eine Datei, Schlüssel = `art`). +Figurenfakten (`fakten.ts`, `art="fakten"`, F-17): ein Request für das ganze Buch (optional eine Figur), Antwort +`{fakten: [{figur, merkmal, wert, kapitel, zitat}]}`. Merkmal höchstens drei, Wert höchstens acht Wörter; Zitat wörtlich +(E6); Figur nur aus dem Schema-Enum. Die KI vergleicht nichts – Widersprüche und Planabweichungen berechnet +`core/figuren/fakten.ts` lokal. An das Modell gehen nur Daten der Allowlist (E12): Namen, Aliase, Gewissheit und die +Namen der Planfelder. + diff --git a/core/ki/anbieter.test.ts b/core/ki/anbieter.test.ts index 78156d1..0da4ef5 100644 --- a/core/ki/anbieter.test.ts +++ b/core/ki/anbieter.test.ts @@ -1,11 +1,13 @@ /** - * KI-Anbieter (Foundry | Claude API): ältere projekt.md bleiben gültig (= Foundry), beim Umschalten + * KI-Anbieter (Foundry | Claude API | OpenAI-kompatibel): ältere projekt.md bleiben gültig (= Foundry), beim Umschalten * geht die Konfiguration des anderen Anbieters nicht verloren, Modellwahl je Anbieter. */ import { parseProjekt, serializeProjekt } from '../projekt'; import type { KiEinstellungen, Projekt } from '../model/types'; import { + anbieterName, CLAUDE_API_ENDPOINT, + kostenBekannt, kiAnbieter, kiEndpoint, kiMitStandards, @@ -20,7 +22,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 +33,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 +46,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); }); @@ -66,6 +68,7 @@ describe('KI-Anbieter', () => { endpoint: 'https://api.anthropic.com', modelle: { erkennung: 'claude-haiku-5-5', pruefung: 'claude-sonnet-5-5', tiefencheck: 'claude-opus-5-5' }, }, + openai: { vorlage: 'ollama', endpoint: 'http://localhost:11434/v1', modelle: { erkennung: '', pruefung: '', tiefencheck: '' } }, }); }); @@ -108,8 +111,64 @@ describe('KI-Anbieter', () => { expect([...pos].sort((a, b) => a - b)).toEqual(pos); }); + it('OpenAI-kompatibel: Modelle ohne Claude-Standard, fehlende Rolle = Prüfmodell, Basis-URL der Vorlage', () => { + const ki: KiEinstellungen = { + ...CLAUDE, + anbieter: 'openai', + openai: { vorlage: 'lmstudio', endpoint: ' ', modelle: { erkennung: 'qwen/qwen3-8b', pruefung: 'qwen/qwen3-32b', tiefencheck: '' } }, + }; + expect(kiAnbieter(ki)).toBe('openai'); + expect(kiEndpoint(ki)).toBe('http://localhost:1234/v1'); + expect(kiModell(ki, 'erkennung')).toBe('qwen/qwen3-8b'); + expect(kiModell(ki, 'tiefencheck')).toBe('qwen/qwen3-32b'); + expect(anbieterName(ki)).toBe('LM Studio'); + expect(kostenBekannt(ki)).toBe(false); + expect(kostenBekannt(CLAUDE)).toBe(true); + // nur ein Modell eingetragen: gilt für alle Rollen + const eins: KiEinstellungen = { ...ki, openai: { vorlage: 'ollama', endpoint: '', modelle: { erkennung: 'qwen3:8b', pruefung: '', tiefencheck: '' } } }; + expect(kiModell(eins, 'pruefung')).toBe('qwen3:8b'); + expect(kiModell(eins, 'tiefencheck')).toBe('qwen3:8b'); + // keines eingetragen: leer (kein Claude-Modell an Ollama schicken) + expect(kiModell({ ...ki, openai: undefined }, 'pruefung')).toBe(''); + // eigene URL ohne Endpoint bleibt leer + expect(kiEndpoint({ ...ki, openai: { vorlage: 'eigen', endpoint: '', modelle: ki.openai!.modelle } })).toBe(''); + expect(kiMitStandards({ ...ki, openai: { ...ki.openai!, vorlage: 'gibtsnicht' as never } }).openai.vorlage).toBe('ollama'); + }); + + it('Roundtrip mit OpenAI-kompatiblem Dienst; Umschalten verliert nichts', () => { + const OPENAI: KiEinstellungen = { + ...CLAUDE, + anbieter: 'openai', + openai: { + vorlage: 'openrouter', + endpoint: 'https://openrouter.ai/api/v1', + modelle: { erkennung: 'qwen/qwen3-32b', pruefung: 'openai/gpt-5', tiefencheck: '' }, + kontextTokens: 128000, + }, + }; + const projekt: Projekt = { ...parseProjekt(ALT), ki: OPENAI }; + const text = serializeProjekt(projekt, ALT); + expect(text).toContain('anbieter: openai'); + expect(text).toContain('vorlage: openrouter'); + expect(text).toContain('kontextTokens: 128000'); + expect(parseProjekt(text).ki).toEqual(OPENAI); + + // zurück zur Claude API, ohne openai-Block im Aufruf: die Einstellungen bleiben in der Datei + const { openai: _o, ...ohne } = OPENAI; + void _o; + const zurueck = serializeProjekt({ ...projekt, ki: { ...ohne, anbieter: 'claude' } }, text); + const geladen = parseProjekt(zurueck).ki!; + expect(kiAnbieter(geladen)).toBe('claude'); + expect(geladen.openai).toEqual(OPENAI.openai); + + // Schlüsselreihenfolge im openai-Block + const block = text.slice(text.indexOf('openai:')); + const pos = ['vorlage:', 'endpoint:', 'modelle:', 'kontextTokens:'].map((k) => block.indexOf(k)); + expect([...pos].sort((a, b) => a - b)).toEqual(pos); + }); + it('unbekannter Anbieter in der Datei gilt als Foundry, unbekannte Felder bleiben erhalten', () => { - const datei = ALT.replace(' endpoint: https', ' anbieter: openai\n eigenesFeld: 1\n endpoint: https'); + const datei = ALT.replace(' endpoint: https', ' anbieter: gemini\n eigenesFeld: 1\n endpoint: https'); const p = parseProjekt(datei); expect(p.ki?.anbieter).toBeUndefined(); expect(kiAnbieter(p.ki)).toBe('foundry'); diff --git a/core/ki/anbieter.ts b/core/ki/anbieter.ts index 11b1374..929872b 100644 --- a/core/ki/anbieter.ts +++ b/core/ki/anbieter.ts @@ -1,18 +1,20 @@ /** - * KI-Anbieter: Claude in Microsoft Foundry oder die Claude API von Anthropic direkt. - * Beide sprechen dieselbe Messages-API; es unterscheiden sich Basis-URL, Schlüsselbund-Eintrag - * und die Modellnamen (Foundry: Deployment-Namen, Claude API: Modell-IDs). + * KI-Anbieter: Claude in Microsoft Foundry, die Claude API von Anthropic direkt oder ein Dienst mit + * OpenAI-kompatibler Chat-Completions-API. Foundry und Claude API sprechen dieselbe Messages-API; es + * unterscheiden sich Basis-URL, Schlüsselbund-Eintrag und die Modellnamen (Foundry: Deployment-Namen, + * Claude API: Modell-IDs). Für OpenAI-kompatible Dienste übersetzt der Transport (core/ki/openai.ts). * * Ältere projekt.md-Dateien kennen nur `ki: {endpoint, deployments}` und gelten als Foundry. */ -import type { KiAnbieter, KiClaudeEinstellungen, KiEinstellungen, KiModelle } from '../model/types'; +import type { KiAnbieter, KiClaudeEinstellungen, KiEinstellungen, KiModelle, KiOpenAiEinstellungen, KiVorlage } from '../model/types'; import type { KiRolle } from './anfrage'; -export const KI_ANBIETER: readonly KiAnbieter[] = ['foundry', 'claude']; +export const KI_ANBIETER: readonly KiAnbieter[] = ['foundry', 'claude', 'openai']; export const ANBIETER_NAME: Record = { foundry: 'Microsoft Foundry', claude: 'Claude API', + openai: 'OpenAI-kompatibel', }; /** Standardmodelle (E14) – für Foundry als Deployment-Namen, für die Claude API als Modell-IDs. */ @@ -30,11 +32,110 @@ export const STANDARD_CLAUDE: Readonly = { modelle: STANDARD_MODELLE, }; +/** Wie ein OpenAI-kompatibler Dienst angesprochen wird (E15). */ +export interface VorlageInfo { + name: string; + /** Standard-Basis-URL ohne /chat/completions ('' = selbst eintragen) */ + endpoint: string; + /** Ohne Schlüssel nutzbar (lokale Server); sonst ist ein Schlüssel Pflicht */ + ohneSchluessel: boolean; + /** Läuft auf diesem Mac: Kontextlänge des Servers beachten, keine Kosten */ + lokal: boolean; + /** + * json_schema: `response_format` mit Schema (lokale Server erzwingen es per Grammatik); + * json_object: nur „gültiges JSON“, das Schema steht dann zusätzlich im System-Prompt. + */ + strukturiert: 'json_schema' | 'json_object'; + /** Wie `output_config.effort` übergeben wird (null = gar nicht) */ + effort: 'reasoning_effort' | 'reasoning' | null; + /** OpenAI: max_completion_tokens (max_tokens nehmen Reasoning-Modelle nicht an); sonst max_tokens */ + maxTokensFeld: 'max_completion_tokens' | 'max_tokens'; + /** Zusätzliche Felder im Body */ + zusatz?: Readonly>; + /** Beispiel-Modellnamen (nur Platzhalter, keine Standardwerte) */ + beispiele: Readonly; +} + +export const VORLAGEN: Readonly> = { + ollama: { + name: 'Ollama', + endpoint: 'http://localhost:11434/v1', + ohneSchluessel: true, + lokal: true, + strukturiert: 'json_schema', + effort: null, + maxTokensFeld: 'max_tokens', + beispiele: { erkennung: 'qwen3:8b', pruefung: 'qwen3:32b', tiefencheck: 'qwen3:32b' }, + }, + lmstudio: { + name: 'LM Studio', + endpoint: 'http://localhost:1234/v1', + ohneSchluessel: true, + lokal: true, + strukturiert: 'json_schema', + effort: null, + maxTokensFeld: 'max_tokens', + beispiele: { erkennung: 'qwen/qwen3-8b', pruefung: 'qwen/qwen3-32b', tiefencheck: 'qwen/qwen3-32b' }, + }, + openai: { + name: 'OpenAI', + endpoint: 'https://api.openai.com/v1', + ohneSchluessel: false, + lokal: false, + strukturiert: 'json_schema', + effort: 'reasoning_effort', + maxTokensFeld: 'max_completion_tokens', + beispiele: { erkennung: 'gpt-5-mini', pruefung: 'gpt-5', tiefencheck: 'gpt-5' }, + }, + openrouter: { + name: 'OpenRouter', + endpoint: 'https://openrouter.ai/api/v1', + ohneSchluessel: false, + lokal: false, + strukturiert: 'json_schema', + effort: 'reasoning', + maxTokensFeld: 'max_tokens', + // Nur Anbieter, die alle Parameter (Schema) unterstützen und keine Daten speichern bzw. zum Training nutzen. + zusatz: { provider: { require_parameters: true, data_collection: 'deny' } }, + beispiele: { erkennung: 'qwen/qwen3-32b', pruefung: 'openai/gpt-5', tiefencheck: 'openai/gpt-5' }, + }, + qwen: { + name: 'Qwen (Alibaba Cloud)', + endpoint: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + ohneSchluessel: false, + lokal: false, + strukturiert: 'json_object', + effort: null, + maxTokensFeld: 'max_tokens', + beispiele: { erkennung: 'qwen-flash', pruefung: 'qwen-plus', tiefencheck: 'qwen-max' }, + }, + eigen: { + name: 'Eigene URL', + endpoint: '', + ohneSchluessel: true, + lokal: false, + strukturiert: 'json_schema', + effort: null, + maxTokensFeld: 'max_tokens', + beispiele: { erkennung: 'modell-klein', pruefung: 'modell-gross', tiefencheck: 'modell-gross' }, + }, +}; + +/** Reihenfolge in den Einstellungen: lokal zuerst. */ +export const KI_VORLAGEN: readonly KiVorlage[] = ['ollama', 'lmstudio', 'openai', 'openrouter', 'qwen', 'eigen']; + +export const STANDARD_VORLAGE: KiVorlage = 'ollama'; + const ROLLEN: readonly KiRolle[] = ['erkennung', 'pruefung', 'tiefencheck']; +/** Unbekannte oder fehlende Vorlage = Ollama. */ +export function kiVorlage(v: unknown): KiVorlage { + return typeof v === 'string' && (KI_VORLAGEN as readonly string[]).includes(v) ? (v as KiVorlage) : STANDARD_VORLAGE; +} + /** Gewählter Anbieter; fehlt er (ältere projekt.md), ist es Foundry. */ export function kiAnbieter(ki: KiEinstellungen | undefined | null): KiAnbieter { - return ki?.anbieter === 'claude' ? 'claude' : 'foundry'; + return ki?.anbieter === 'claude' || ki?.anbieter === 'openai' ? ki.anbieter : 'foundry'; } function modelleMitStandard(m: Partial | undefined): KiModelle { @@ -43,7 +144,20 @@ function modelleMitStandard(m: Partial | undefined): KiModelle { return ergebnis; } -/** Alle Felder beider Anbieter mit Standardwerten (leerer Foundry-Endpoint bleibt leer). */ +/** OpenAI-kompatibel: Modelle ohne Claude-Standard (leer bleibt leer), Basis-URL der Vorlage, wenn keine eingetragen ist. */ +function openAiMitStandards(o: Partial | undefined): KiOpenAiEinstellungen { + const vorlage = kiVorlage(o?.vorlage); + const m = o?.modelle; + const kontext = o?.kontextTokens; + return { + vorlage, + endpoint: o?.endpoint?.trim() || VORLAGEN[vorlage].endpoint, + modelle: { erkennung: m?.erkennung?.trim() ?? '', pruefung: m?.pruefung?.trim() ?? '', tiefencheck: m?.tiefencheck?.trim() ?? '' }, + ...(typeof kontext === 'number' && Number.isFinite(kontext) && kontext > 0 ? { kontextTokens: Math.round(kontext) } : {}), + }; +} + +/** Alle Felder aller Anbieter mit Standardwerten (leerer Foundry-Endpoint bleibt leer). */ export function kiMitStandards(ki: KiEinstellungen | undefined | null): Required { return { endpoint: ki?.endpoint ?? '', @@ -53,17 +167,52 @@ export function kiMitStandards(ki: KiEinstellungen | undefined | null): Required endpoint: ki?.claude?.endpoint?.trim() || CLAUDE_API_ENDPOINT, modelle: modelleMitStandard(ki?.claude?.modelle), }, + openai: openAiMitStandards(ki?.openai), }; } -/** Modell für eine Aufgabe beim gewählten Anbieter (Foundry: Deployment-Name, Claude API: Modell-ID). */ +/** OpenAI-kompatibel: fehlt das Modell einer Rolle, gilt das der Prüfung (sonst irgendeines, sonst ''). */ +function openAiModell(m: KiModelle, rolle: KiRolle): string { + return m[rolle] || m.pruefung || m.tiefencheck || m.erkennung; +} + +/** + * Modell für eine Aufgabe beim gewählten Anbieter (Foundry: Deployment-Name, Claude API: Modell-ID, + * OpenAI-kompatibel: Modellname des Dienstes, '' = keiner eingetragen). + */ export function kiModell(ki: KiEinstellungen | undefined | null, rolle: KiRolle): string { const voll = kiMitStandards(ki); + if (voll.anbieter === 'openai') return openAiModell(voll.openai.modelle, rolle); return voll.anbieter === 'claude' ? voll.claude.modelle[rolle] : voll.deployments[rolle]; } -/** Basis-URL des gewählten Anbieters ('' = Foundry ohne Endpoint). */ +/** Basis-URL des gewählten Anbieters ('' = Foundry bzw. eigene URL ohne Endpoint). */ export function kiEndpoint(ki: KiEinstellungen | undefined | null): string { const voll = kiMitStandards(ki); + if (voll.anbieter === 'openai') return voll.openai.endpoint; return voll.anbieter === 'claude' ? voll.claude.endpoint : voll.endpoint.trim(); } + +/** Name für Texte wie „An {name} gehen nur …“: bei OpenAI-kompatiblen Diensten der Name der Vorlage. */ +export function anbieterName(ki: KiEinstellungen | undefined | null): string { + const voll = kiMitStandards(ki); + return voll.anbieter === 'openai' ? VORLAGEN[voll.openai.vorlage].name : ANBIETER_NAME[voll.anbieter]; +} + +/** + * Kosten nach Listenpreis bekannt? Nur für Claude (Foundry, Claude API). Bei OpenAI-kompatiblen Diensten + * hängen die Preise von Dienst und Modell ab (lokal kostet es nichts); die App nennt dann keinen Betrag. + */ +export function kostenBekannt(ki: KiEinstellungen | undefined | null): boolean { + return kiAnbieter(ki) !== 'openai'; +} + +/** Kosten für Schätzung oder Ergebnis; ohne bekannten Listenpreis 0 mit `kostenBekannt: false`. */ +export function mitKosten(ki: KiEinstellungen | undefined | null, usd: number): { kostenUsd: number; kostenBekannt?: false } { + return kostenBekannt(ki) ? { kostenUsd: usd } : { kostenUsd: 0, kostenBekannt: false }; +} + +/** Für Ablagen (`lauf.kostenUsd`): nur bekannte Kosten speichern. */ +export function bekannteKosten(r: { kostenUsd: number; kostenBekannt?: false }): { kostenUsd?: number } { + return r.kostenBekannt === false ? {} : { kostenUsd: r.kostenUsd }; +} diff --git a/core/ki/aufgabe.ts b/core/ki/aufgabe.ts index dd6e79e..5e1cf5b 100644 --- a/core/ki/aufgabe.ts +++ b/core/ki/aufgabe.ts @@ -12,7 +12,7 @@ import type { Sprache } from '../i18n'; import type { Id, KiEinstellungen, ProjektDaten, Zitat } from '../model/types'; import type { ProjektLeser } from '../projekt/fs'; import { ladeProjekt } from '../projekt/lesen'; -import { kiModell } from './anbieter'; +import { kiModell, mitKosten } from './anbieter'; import { baueAnfrage, EFFORT, MAX_TOKENS, prefixZaehlBody, zaehlBody, type KiRolle } from './anfrage'; import { KiFehler, LEERE_NUTZUNG, leseAntwort, type Nutzung } from './antwort'; import type { KiTransport, Schaetzung } from './index'; @@ -86,6 +86,8 @@ export interface AufgabenErgebnis { tokens: { eingabe: number; ausgabe: number; cacheGelesen: number }; modell: string; kostenUsd: number; + /** false = kein Listenpreis bekannt (OpenAI-kompatible Dienste); kostenUsd ist dann 0 */ + kostenBekannt?: false; /** ISO-Zeitpunkt */ datum: string; } @@ -137,6 +139,7 @@ interface AufgabenLauf { kontext: BuchKontext; modell: string; body: Record; + einstellungen: KiEinstellungen | undefined; } function planeAufgabe( @@ -154,7 +157,7 @@ function planeAufgabe( ...baueAnfrage({ modell, system, kontext, aufgabe, schema: d.schema(kontext), effort: EFFORT[d.rolle] }), max_tokens: d.maxTokens ?? MAX_TOKENS, }; - return { kontext, modell, body }; + return { kontext, modell, body, einstellungen }; } /** Der vollständige Messages-API-Body der Aufgabe (z. B. für Tests oder vorbereitete Antworten). */ @@ -251,7 +254,7 @@ function ergebnisAus( verworfen: [...p.verworfen], tokens: { eingabe: nutzung.eingabe + nutzung.cacheGeschrieben, ausgabe: nutzung.ausgabe, cacheGelesen: nutzung.cacheGelesen }, modell: lauf.modell, - kostenUsd: kostenUsd(nutzung, preisFuer(lauf.modell, d.rolle, lauf.kontext.text.length / 3)), + ...mitKosten(lauf.einstellungen, kostenUsd(nutzung, preisFuer(lauf.modell, d.rolle, lauf.kontext.text.length / 3))), datum: jetzt().toISOString(), }; } @@ -347,7 +350,7 @@ export async function schaetzeAufgabe( eingabeTokens: voll.tokens, prefixTokens, ausgabeTokens: ausgabe, - kostenUsd: kostenUsd({ eingabe: voll.tokens - prefixTokens, ausgabe, cacheGelesen: 0, cacheGeschrieben: prefixTokens }, preis), + ...mitKosten(einstellungen, kostenUsd({ eingabe: voll.tokens - prefixTokens, ausgabe, cacheGelesen: 0, cacheGeschrieben: prefixTokens }, preis)), gezaehlt: voll.gezaehlt && prefix.gezaehlt, }; } diff --git a/core/ki/fakten.test.ts b/core/ki/fakten.test.ts new file mode 100644 index 0000000..d3c04a2 --- /dev/null +++ b/core/ki/fakten.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { demoFs } from '../../fixtures/demoProjekt'; +import { erstelleDemoKiTransport } from '../../src/backend/ki/demo'; +import { MemoryFs } from '../projekt'; +import { FAKTEN_ART, faktSchluessel, KiFehler, sammleFakten, schaetzeFakten, STANDARD_KI, type KiTransport } from './index'; + +const SAAB = 'Dann nimmt er den Schlüssel und fährt mit seinem alten Saab 900 zum Lindenhof.'; + +function transport(antwort: unknown, bodies: Record[] = []): KiTransport { + return { + bereit: async () => true, + senden: async (body) => { + bodies.push(body); + return { + type: 'message', + content: [{ type: 'text', text: JSON.stringify(antwort) }], + stop_reason: 'end_turn', + usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 0, cache_creation_input_tokens: 1000 }, + }; + }, + tokenZaehlen: async (body) => Math.ceil(JSON.stringify(body).length / 3), + }; +} + +/** Dateisystem, das bei jedem Schreibversuch fehlschlägt (harte Regel 1). */ +class NurLesen extends MemoryFs { + override async schreiben(): Promise { + throw new Error('Schreiben verboten'); + } +} + +describe('KI-Aufgabe Figurenfakten (F-17)', () => { + it('Demo: wörtliche Angaben bleiben, erfundenes Zitat fällt weg; nichts wird geschrieben', async () => { + const fs = new NurLesen((await import('../../fixtures/demoProjekt')).demoDateien()); + const r = await sammleFakten(fs, erstelleDemoKiTransport({ verzoegerungMs: 0 }), STANDARD_KI, {}); + const paul = r.ergebnis.fakten.filter((f) => f.figur === 'paul'); + expect(paul.map((f) => f.wert)).toEqual(expect.arrayContaining(['alter Saab 900', 'Saab', 'Volvo'])); + expect(paul).toHaveLength(3); + expect(r.ergebnis.fakten.some((f) => f.figur === 'ole')).toBe(false); + expect(r.verworfen.map((v) => v.grund).join(' ')).toMatch(/nicht wörtlich/); + expect(paul[0]!.zitat.text).toBe(SAAB); + expect(paul[0]!.schluessel).toBe(faktSchluessel('paul', 'Auto', { kapitel: 'lotte', text: SAAB })); + }); + + it('nur eine Figur: Kopf trägt figur, Schema erlaubt nur sie; Statisten bei „alle“ nicht gefragt', async () => { + const bodies: Record[] = []; + await sammleFakten(demoFs(), transport({ fakten: [] }, bodies), STANDARD_KI, { figur: 'vera' }); + const text = JSON.stringify(bodies[0]); + expect(text).toContain(`art=\\"${FAKTEN_ART}\\"`); + expect(text).toContain('figur=\\"vera\\"'); + expect(text).toMatch(/"enum":\["vera"\]/); + }); + + it('Prüfung: unbekannte Figur, zu lange Werte, Ersatztext und fremde Zitate fliegen raus', async () => { + const r = await sammleFakten( + demoFs(), + transport({ + fakten: [ + { figur: 'paul', merkmal: 'Auto', wert: 'alter Saab 900', kapitel: 'lotte', zitat: SAAB }, + { figur: 'niemand', merkmal: 'Auto', wert: 'Saab', kapitel: 'lotte', zitat: SAAB }, + { figur: 'paul', merkmal: 'Auto mit sehr langem Merkmal', wert: 'Saab', kapitel: 'lotte', zitat: SAAB }, + { figur: 'paul', merkmal: 'Auto', wert: 'ein Saab, der wohl nicht mehr lange fahren wird und rostet', kapitel: 'lotte', zitat: SAAB }, + { figur: 'paul', merkmal: 'Auto', wert: 'Saab', kapitel: 'lotte', zitat: 'Er fährt einen Saab.' }, + ], + }), + STANDARD_KI, + {}, + ); + expect(r.ergebnis.fakten).toHaveLength(1); + expect(r.verworfen).toHaveLength(4); + }); + + it('Schätzung ohne Senden; nicht bereit → KiFehler', async () => { + const bodies: Record[] = []; + const s = await schaetzeFakten(demoFs(), transport({ fakten: [] }, bodies), STANDARD_KI, {}); + expect(s.anfragen).toBe(1); + expect(bodies).toHaveLength(0); + const nicht: KiTransport = { ...transport({}), bereit: async () => false }; + await expect(sammleFakten(demoFs(), nicht, STANDARD_KI, {})).rejects.toBeInstanceOf(KiFehler); + }); +}); diff --git a/core/ki/fakten.ts b/core/ki/fakten.ts new file mode 100644 index 0000000..0840f12 --- /dev/null +++ b/core/ki/fakten.ts @@ -0,0 +1,219 @@ +/** + * KI-Aufgabe Figurenfakten (F-17): Angaben über Figuren, wie sie im Text stehen (Alter, Aussehen, Beruf, Auto …), + * jede mit wörtlichem Beleg (E6). Nur Beobachtung, nie Text fürs Buch. Widersprüche und Abweichungen von der + * Planung erkennt die App selbst (core/figuren/fakten.ts) – die KI vergleicht nichts. + * + * Ein Request für das ganze Buch (Buchkontext als gecachter Prefix, Allowlist E12) – optional nur für eine Figur. + * Nur ProjektLeser und KiTransport; gespeichert wird im Store (.plotwerk/figurenfakten.json). + */ +import type { FigurFakt, Id, KiEinstellungen } from '../model/types'; +import type { ProjektLeser } from '../projekt/fs'; +import { zitatKern } from '../text'; +import type { KiRolle } from './anfrage'; +import { + fuehreAufgabeAus, + schaetzeAufgabe, + type AufgabenDefinition, + type AufgabenErgebnis, + type AufgabenOptionen, + type AufgabenPruefung, +} from './aufgabe'; +import type { KiTransport, Schaetzung } from './index'; +import type { BuchKontext, KontextFigur } from './kontext'; +import { meldungenIn } from './meldungen'; +import { ANTWORTSPRACHE_EN, type Sprache } from './sprache'; + +/** Kennung im Aufgabenkopf (); Demo-Antworten sind unter diesem Schlüssel registriert. */ +export const FAKTEN_ART = 'fakten'; + +/** Höchstzahl Wörter für Merkmal bzw. Wert */ +export const MERKMAL_MAX_WOERTER = 3; +export const WERT_MAX_WOERTER = 8; + +/** Übliche Merkmale (zusätzlich zu den Planfeldern der Figuren) */ +export const STANDARD_MERKMALE = ['Alter', 'Geburtstag', 'Aussehen', 'Haare', 'Augen', 'Größe', 'Beruf', 'Wohnort', 'Herkunft', 'Familie', 'Auto']; +export const STANDARD_MERKMALE_EN = ['Age', 'Birthday', 'Appearance', 'Hair', 'Eyes', 'Height', 'Occupation', 'Home', 'Origin', 'Family', 'Car']; + +export interface FaktenAuftrag { + /** nur diese Figur; fehlt = alle Figuren */ + figur?: Id; + /** Opus statt Sonnet */ + tiefencheck?: boolean; +} + +export interface FaktenErgebnis { + fakten: FigurFakt[]; + /** Figuren, für die gefragt wurde */ + figuren: Id[]; +} + +export const SYSTEM_FAKTEN = `Du bist Lektorats-Leser für ein deutschsprachiges Romanmanuskript. Du liest das Buch so, wie eine Leserin es liest: den Text der Kapitel in ihrer Reihenfolge und die wenigen Angaben des Autors zu Figuren, Orten und Gruppen. Dazu kommt die Planung des Autors (Zeilen „Planung“ bei Figuren); sie gehört nicht zum Buch und ist nie ein Beleg. + +Deine Aufgabe: Sammle, was der Text über die Figuren als Tatsache sagt – Alter, Aussehen, Beruf, Wohnort, Familie, Besitz wie ein Auto, feste Gewohnheiten. Jede Angabe mit einem wörtlichen Zitat. + +Du schreibst nie Text für das Buch und bewertest nichts. Du vergleichst die Angaben nicht und meldest keine Widersprüche – das macht die App. Du legst nichts fest, was der Text offenlässt. + +Regeln: +- Nur, was der Text ausdrücklich sagt oder eindeutig zeigt; keine Vermutungen, keine Deutung, keine Gefühle oder Stimmungen. +- figur ist die id einer angelegten Figur. Ein namenloser Ich-Erzähler gehört zu der Figur, die erkennbar erzählt. +- merkmal: höchstens drei Wörter; nimm, wo es passt, den Namen eines Planungsfelds der Figur oder eines der üblichen Merkmale. Gleiche Merkmale immer gleich benennen. +- wert: höchstens acht Wörter, so nah am Text wie möglich (zum Beispiel „34“, „alter Saab 900“, „Architekt“). +- Jedes Zitat ist eine zusammenhängende Passage aus dem Kapitel, das du unter "kapitel" mit seiner id angibst, Zeichen für Zeichen aus dem kopiert, ohne Auslassungen, höchstens ein Satz. +- Nennt der Text dasselbe Merkmal an mehreren Stellen, gib jede Stelle als eigene Angabe an – auch wenn die Werte voneinander abweichen. +- Als „bewusst vage“ markierte Figuren sind absichtlich mehrdeutig; gib bei ihnen nur an, was der Text eindeutig sagt. +- Lieber wenige belastbare Angaben als viele unsichere.`; + +export const SYSTEM_FAKTEN_EN = `You are an editorial reader for an English-language novel. You read the book the way a reader does: the text of the chapters in their order and the author's few notes on characters, places and groups. In addition there is the author's planning (lines “Plan” for characters); it is not part of the book and never evidence. + +Your task: collect what the text states as fact about the characters – age, appearance, occupation, home, family, possessions such as a car, fixed habits. Each with a verbatim quote. + +You never write text for the book and judge nothing. You do not compare the facts and report no contradictions – the app does that. You settle nothing the text leaves open. + +Rules: +- Only what the text states explicitly or shows unambiguously; no assumptions, no interpretation, no feelings or moods. +- figur is the id of a character that is set up. A nameless first-person narrator belongs to the character who recognisably narrates. +- merkmal: at most three words; where it fits, use the name of one of the character's plan fields or one of the usual traits. Always name the same trait the same way. +- wert: at most eight words, as close to the text as possible (for example “34”, “old Saab 900”, “architect”). +- Every quote is one continuous passage from the chapter you name under "kapitel" with its id, copied character for character from the , without omissions, at most one sentence. +- If the text mentions the same trait in several places, give each place as its own fact – even if the values differ. +- Characters marked “deliberately vague” are intentionally ambiguous; for them give only what the text states unambiguously. +- Prefer a few solid facts to many uncertain ones. + +${ANTWORTSPRACHE_EN}`; + +/** Stabiler Schlüssel aus Figur, Merkmal und Beleg. */ +export function faktSchluessel(figur: Id, merkmal: string, zitat: { kapitel: Id; text: string }): string { + return `fakt:${figur}|${merkmal.trim().toLocaleLowerCase('de')}|${zitat.kapitel}/${zitatKern(zitat.text)}`; +} + +function figurZeile(f: KontextFigur, en: boolean): string { + const teile = [`${f.name} (id: ${f.id})`]; + if (f.aliase.length) teile.push(`${en ? 'also called' : 'auch genannt'}: ${f.aliase.join(', ')}`); + if (f.existenz === 'bewusst_vage') teile.push(en ? 'deliberately vague' : 'bewusst vage'); + if (f.plan.length) teile.push(`${en ? 'plan fields' : 'Planungsfelder'}: ${f.plan.map((p) => p.feld).join(', ')}`); + return `- ${teile.join(' · ')}`; +} + +function aufgabenText(kontext: BuchKontext, figuren: KontextFigur[]): string { + const en = kontext.sprache === 'en'; + const z: string[] = []; + if (en) { + z.push(figuren.length === 1 ? `Collect the facts about ${figuren[0]!.name} in the whole book.` : 'Collect the facts about these characters in the whole book:'); + z.push(...figuren.map((f) => figurZeile(f, true))); + z.push(''); + z.push(`Usual traits: ${STANDARD_MERKMALE_EN.join(', ')}.`); + z.push('For each fact: figur (id), merkmal, wert, kapitel (id) and zitat (verbatim).'); + } else { + z.push(figuren.length === 1 ? `Sammle die Angaben über ${figuren[0]!.name} im ganzen Buch.` : 'Sammle die Angaben über diese Figuren im ganzen Buch:'); + z.push(...figuren.map((f) => figurZeile(f, false))); + z.push(''); + z.push(`Übliche Merkmale: ${STANDARD_MERKMALE.join(', ')}.`); + z.push('Für jede Angabe: figur (id), merkmal, wert, kapitel (id) und zitat (wörtlich).'); + } + return z.join('\n'); +} + +/** Antwortschema – ohne Feld für Ersatztext oder Bewertung. */ +export function faktenSchema(kontext: BuchKontext, figuren: Id[]): Record { + const kapitel = { type: 'string', enum: kontext.kapitel.filter((k) => k.text.trim()).map((k) => k.id) }; + const obj = (properties: Record) => ({ type: 'object', additionalProperties: false, properties, required: Object.keys(properties) }); + return obj({ + fakten: { + type: 'array', + items: obj({ + figur: { type: 'string', enum: figuren }, + merkmal: { type: 'string' }, + wert: { type: 'string' }, + kapitel, + zitat: { type: 'string' }, + }), + }, + }); +} + +const woerter = (s: string) => s.split(/\s+/u).filter(Boolean).length; + +/** Antwort auswerten (auch einzeln testbar). */ +export function werteFaktenAus(daten: unknown, p: AufgabenPruefung, figuren: Id[]): FaktenErgebnis { + const ergebnis: FaktenErgebnis = { fakten: [], figuren }; + const m = meldungenIn(p.oberflaeche); + const d = (daten ?? {}) as Record; + if (!d || typeof d !== 'object' || !Array.isArray(d.fakten)) { + p.verwerfen(m('schemaFalsch'), daten); + return ergebnis; + } + const erlaubt = new Set(figuren); + const reihe = new Map(p.kontext.kapitel.map((k, i) => [k.id, i])); + const gesehen = new Set(); + for (const roh of d.fakten) { + const f = (roh ?? {}) as Record; + const figur = p.figur(f.figur); + if (!figur || !erlaubt.has(figur.id)) { + p.verwerfen(m('unbekannteFigur', { name: String(f.figur ?? '') }), roh); + continue; + } + const merkmal = p.text(f.merkmal, roh, { pflicht: true }); + const wert = p.text(f.wert, roh, { pflicht: true }); + if (merkmal === null || wert === null) continue; + if (woerter(merkmal) > MERKMAL_MAX_WOERTER || woerter(wert) > WERT_MAX_WOERTER) { + p.verwerfen(m('faktZuLang'), roh); + continue; + } + const zitate = p.zitate([{ kapitel: f.kapitel, text: f.zitat }], roh); + if (!zitate) continue; + const zitat = zitate[0]!; + const schluessel = faktSchluessel(figur.id, merkmal, zitat); + if (gesehen.has(schluessel)) continue; + gesehen.add(schluessel); + ergebnis.fakten.push({ schluessel, figur: figur.id, merkmal, wert, kapitel: zitat.kapitel, zitat, status: 'neu' }); + } + ergebnis.fakten.sort((a, b) => (reihe.get(a.kapitel) ?? 0) - (reihe.get(b.kapitel) ?? 0)); + return ergebnis; +} + +/** Figuren eines Auftrags: eine bestimmte oder alle außer Statisten. */ +function figurenFuer(kontext: BuchKontext, auftrag: FaktenAuftrag): KontextFigur[] { + if (auftrag.figur) return kontext.figuren.filter((f) => f.id === auftrag.figur); + return kontext.figuren.filter((f) => f.rolle !== 'statist'); +} + +export function faktenDefinition(auftrag: FaktenAuftrag): AufgabenDefinition { + const rolle: KiRolle = auftrag.tiefencheck ? 'tiefencheck' : 'pruefung'; + return { + name: 'Figurenfakten', + nameEn: meldungenIn('en')('nameFakten'), + art: FAKTEN_ART, + attribute: auftrag.figur ? { figur: auftrag.figur } : {}, + rolle, + system: SYSTEM_FAKTEN, + systemEn: SYSTEM_FAKTEN_EN, + ausgabeSchaetzung: auftrag.tiefencheck ? 8000 : 4000, + aufgabe(kontext) { + return aufgabenText(kontext, figurenFuer(kontext, auftrag)); + }, + schema: (kontext) => faktenSchema(kontext, figurenFuer(kontext, auftrag).map((f) => f.id)), + auswerten: (daten, p) => werteFaktenAus(daten, p, figurenFuer(p.kontext, auftrag).map((f) => f.id)), + }; +} + +/** Sammelt die Figurenfakten (eine Anfrage). Schreibt nichts. */ +export function sammleFakten( + leser: ProjektLeser, + transport: KiTransport, + einstellungen: KiEinstellungen | undefined, + auftrag: FaktenAuftrag, + optionen: AufgabenOptionen = {}, +): Promise> { + return fuehreAufgabeAus(leser, transport, einstellungen, faktenDefinition(auftrag), optionen); +} + +/** Schätzung vor dem Start (Tokens, Kosten). */ +export function schaetzeFakten( + leser: ProjektLeser, + transport: KiTransport, + einstellungen: KiEinstellungen | undefined, + auftrag: FaktenAuftrag, + oberflaeche: Sprache = 'de', +): Promise { + return schaetzeAufgabe(leser, transport, einstellungen, faktenDefinition(auftrag), oberflaeche); +} diff --git a/core/ki/fragen.ts b/core/ki/fragen.ts index dd952e1..f15025e 100644 --- a/core/ki/fragen.ts +++ b/core/ki/fragen.ts @@ -170,7 +170,7 @@ export function alsBuchFrage(id: Id, frage: string, e: AufgabenErgebnis; @@ -76,6 +93,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, E16): weniger Tokens, kein F-05 */ + ohnePlan?: boolean; } export interface PruefErgebnis { @@ -93,6 +112,7 @@ export interface PruefErgebnis { /** Lauf vorzeitig beendet (Fehler oder Abbruch); bis dahin gesammelte Ergebnisse sind enthalten */ fehler?: string; modell?: string; + /** fehlt, wenn die Kosten unbekannt sind (OpenAI-kompatible Dienste) */ kostenUsd?: number; } @@ -137,6 +157,8 @@ export interface Schaetzung { prefixTokens: number; ausgabeTokens: number; kostenUsd: number; + /** false = kein Listenpreis bekannt (OpenAI-kompatible Dienste); kostenUsd ist dann 0 und wird nicht angezeigt */ + kostenBekannt?: false; /** false = count_tokens nicht verfügbar, Schätzung über Zeichen/3 */ gezaehlt: boolean; } @@ -173,7 +195,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') { @@ -254,7 +276,7 @@ export async function schaetzeLauf( eingabeTokens: voll.tokens * n, prefixTokens, ausgabeTokens: ausgabe, - kostenUsd: kosten, + ...mitKosten(einstellungen, kosten), gezaehlt: voll.gezaehlt && prefix.gezaehlt, }; } @@ -361,7 +383,8 @@ export async function pruefe( optionen.fortschritt?.({ erledigt: ergebnis.geprueft!.length, gesamt, kapitel: null, titel: '' }); ergebnis.bereitsEntschieden = entschiedenGesehen.size; ergebnis.tokens = { eingabe: nutzung.eingabe + nutzung.cacheGeschrieben, ausgabe: nutzung.ausgabe, cacheGelesen: nutzung.cacheGelesen }; - ergebnis.kostenUsd = kostenUsd(nutzung, preisFuer(lauf.modell, lauf.rolle, lauf.kontext.text.length / 3)); + if (kostenBekannt(einstellungen)) ergebnis.kostenUsd = kostenUsd(nutzung, preisFuer(lauf.modell, lauf.rolle, lauf.kontext.text.length / 3)); + else delete ergebnis.kostenUsd; return ergebnis; } diff --git a/core/ki/ki.test.ts b/core/ki/ki.test.ts index b2a792e..e45e3d5 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 (E16) 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..687e961 100644 --- a/core/ki/kontext.ts +++ b/core/ki/kontext.ts @@ -4,14 +4,15 @@ * Es werden ausschließlich Felder einzeln übernommen, nie ganze Objekte: Kapiteltitel und * Klartext, Figuren (name, aliase, rolle, existenz, Plan-Felder), Kapitelpläne (szene, notiz), * Orte (name, aliase), Gruppen (name, typ, status) und Realitätseinträge (stufe, text, Zitat). - * Nie: wahrheit, notizen, kurz, beschreibung, Anmerkungen, Ideen, Abweichungen, Akte, Stränge. + * Nie: wahrheit, notizen, kurz, beschreibung, Anmerkungen, Ideen, Abweichungen, Akte, Stränge. (Stränge gehen nur im + * Aufgabentext von „Stränge vorschlagen“ mit, siehe straenge.ts – freigegeben 2026-10-12.) * Plan-Felder sind seit Leons Entscheidung vom 2026-10-09 freigegeben (F-05). * * Buchsprache (angaben.sprache, nur als Sprache der Beschriftungen, nicht als Feld): Für englische Bücher sind die * Beschriftungen im Prefix englisch („Characters:“, „Existence: …“, „Planned scene: …“); Tags und Attribute * (, , , , , ) bleiben gleich. */ -import type { GruppenTyp, Id, ProjektDaten, Realitaet, TextAnker } from '../model/types'; +import type { FigurRolle, GruppenTyp, Id, ProjektDaten, Realitaet, TextAnker } from '../model/types'; import { klartext, textHash } from '../text'; import { kiSprache, type Sprache } from './sprache'; @@ -32,7 +33,7 @@ export interface KontextFigur { id: Id; name: string; aliase: string[]; - rolle: 'haupt' | 'neben'; + rolle: FigurRolle; existenz: Realitaet; /** nur lokal für die Prüfung (Genitiv-/Wortformen), wird nicht gesendet */ formen: string[]; @@ -62,6 +63,12 @@ export interface KontextRealitaet { anker?: TextAnker; } +/** Was neben Text, Namen und Gewissheit in den Kontext geht (E16). 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[]; @@ -99,6 +106,7 @@ const BESCHRIFTUNG = { auch: 'auch', haupt: 'Hauptfigur', neben: 'Nebenfigur', + statist: 'Statist', existenz: 'Existenz', planung: 'Planung', imText: 'im Text', @@ -120,6 +128,7 @@ const BESCHRIFTUNG = { auch: 'also', haupt: 'main character', neben: 'minor character', + statist: 'walk-on character', existenz: 'Existence', planung: 'Plan', imText: 'in the text', @@ -158,8 +167,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 +186,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: '' }, }; }); @@ -178,10 +195,10 @@ export function baueBuchKontext(daten: ProjektDaten, sprache: Sprache = kiSprach id: f.id, name: f.name, aliase: [...(f.aliase ?? [])], - rolle: f.rolle === 'haupt' ? ('haupt' as const) : ('neben' as const), + rolle: f.rolle, 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 +223,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,19 +234,20 @@ 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); for (const f of figuren) { const teile = [`${f.name} (id: ${f.id})`]; if (f.aliase.length) teile.push(`${B.auch}: ${f.aliase.join(', ')}`); - teile.push(f.rolle === 'haupt' ? B.haupt : B.neben); + teile.push(B[f.rolle]); teile.push(`${B.existenz}: ${B.stufe[f.existenz]}`); z.push(`- ${teile.join(' · ')}`); for (const p of f.plan) z.push(` ${B.planung} · ${p.feld}: ${p.wert}`); diff --git a/core/ki/leser.test.ts b/core/ki/leser.test.ts index c459b14..189e1fe 100644 --- a/core/ki/leser.test.ts +++ b/core/ki/leser.test.ts @@ -9,12 +9,18 @@ import { aufgabenAnfrage, ausLeserStand, fadenAbgleich, + frageKapitel, fuehreAufgabeAus, leserBasisHash, leserSimulationAufgabe, leserStandVeraltet, + aktuellerStand, offeneFragen, + ordneStaende, schaetzeAufgabe, + staendeFuer, + standId, + uebernahmenMitnehmen, werteAufgabenAntwortAus, type LeserSimulation, } from './index'; @@ -197,7 +203,8 @@ describe('Leser-Simulation: Speicherform und Stand', () => { it('übersteht LeserStand und .plotwerk/leser.json verlustfrei', () => { const stand = alsLeserStand(sim); const datei = serializeKiLeser({ version: 1, eintraege: [stand] }); - expect(ausLeserStand(parseKiLeser(datei).eintraege[0]!)).toEqual(sim); + // ohne gespeicherte id leitet ausLeserStand sie aus Kapitel und Datum ab + expect(ausLeserStand(parseKiLeser(datei).eintraege[0]!)).toEqual({ ...sim, id: 'lotte@2026-10-10T10:00:00.000Z' }); expect(offeneFragen(sim).map((f) => f.id)).toEqual(['weissraum']); }); @@ -236,3 +243,77 @@ describe('Fäden-Planung neben den Leserfragen (lokal)', () => { expect(fadenAbgleich(reihe, 'b', faeden, fragen)[1]).toMatchObject({ soll: 'offen', passt: false }); }); }); + +describe('Übernahmen aus der Leser-Simulation', () => { + const stand = (fragen: { id: string; frage: string; faden?: string; anmerkung?: string }[]) => ({ + kapitel: 'c', + basisHash: 'x', + weiss: [], + fragen: fragen.map((f) => ({ ...f, belege: [] })), + modell: 'm', + datum: '2026-10-12T10:00:00.000Z', + }); + + it('nimmt Faden und Notiz gleicher Fragen in den neuen Stand mit', () => { + const alt = stand([ + { id: 'wer-kauft', frage: 'Wer kauft?', faden: 'wer-kauft' }, + { id: 'wer-spricht', frage: 'Wer spricht?', anmerkung: 'wer-spricht-hier' }, + { id: 'weg', frage: 'Weg?', faden: 'weg' }, + ]); + const neu = uebernahmenMitnehmen(alt, stand([{ id: 'wer-kauft', frage: 'Wer kauft?' }, { id: 'wer-spricht', frage: 'Wer spricht?' }, { id: 'neu', frage: 'Neu?' }])); + expect(neu.fragen).toEqual([ + { id: 'wer-kauft', frage: 'Wer kauft?', belege: [], faden: 'wer-kauft' }, + { id: 'wer-spricht', frage: 'Wer spricht?', belege: [], anmerkung: 'wer-spricht-hier' }, + { id: 'neu', frage: 'Neu?', belege: [] }, + ]); + expect(uebernahmenMitnehmen(undefined, neu)).toBe(neu); + }); + + it('Faden und Notiz überstehen Speichern und Laden', () => { + const s = stand([{ id: 'wer-kauft', frage: 'Wer kauft?', faden: 'wer-kauft', anmerkung: 'notiz-1' }]); + expect(parseKiLeser(serializeKiLeser({ version: 1, eintraege: [s] })).eintraege[0]!.fragen[0]).toEqual(s.fragen[0]); + }); + + it('eine Frage kommt im frühesten Belegkapitel auf', () => { + const reihe = ['a', 'b', 'c']; + const z = (kapitel: string) => ({ kapitel, text: 'x' }); + expect(frageKapitel({ belege: [z('c'), z('b')] }, reihe, 'c')).toBe('b'); + expect(frageKapitel({ belege: [] }, reihe, 'c')).toBe('c'); + expect(frageKapitel({ belege: [z('fremd')] }, reihe, 'c')).toBe('c'); + }); +}); + +describe('Mehrere Simulationen je Kapitel', () => { + const st = (kapitel: string, datum: string, extra: { id?: string; erledigt?: string } = {}) => ({ + ...extra, + kapitel, + basisHash: 'x', + weiss: [], + fragen: [], + modell: 'm', + datum, + }); + + it('ID: gespeichert oder aus Kapitel und Datum abgeleitet', () => { + expect(standId(st('a', '2026-10-01T10:00:00.000Z', { id: 'a-1' }))).toBe('a-1'); + expect(standId(st('a', '2026-10-01T10:00:00.000Z'))).toBe('a@2026-10-01T10:00:00.000Z'); + }); + + it('neueste zuerst; aktuell ist die neueste nicht erledigte; gespeichert nach Kapitel und Datum', () => { + const alt = st('b', '2026-10-01T10:00:00.000Z', { id: 'b-alt' }); + const neu = st('b', '2026-10-05T10:00:00.000Z', { id: 'b-neu', erledigt: '2026-10-06T10:00:00.000Z' }); + const a = st('a', '2026-10-03T10:00:00.000Z', { id: 'a' }); + expect(staendeFuer([alt, a, neu], 'b').map((e) => e.id)).toEqual(['b-neu', 'b-alt']); + expect(aktuellerStand([alt, a, neu], 'b')?.id).toBe('b-alt'); + expect(aktuellerStand([neu], 'b')).toBeUndefined(); + expect(ordneStaende([neu, a, alt], ['a', 'b']).map((e) => e.id)).toEqual(['a', 'b-alt', 'b-neu']); + }); + + it('id und erledigt überstehen Speichern und Laden; ältere Dateien bleiben unverändert', () => { + const s = st('a', '2026-10-01T10:00:00.000Z', { id: 'a-1', erledigt: '2026-10-02T10:00:00.000Z' }); + expect(parseKiLeser(serializeKiLeser({ version: 1, eintraege: [s] })).eintraege[0]).toEqual(s); + const ohne = serializeKiLeser({ version: 1, eintraege: [st('a', '2026-10-01T10:00:00.000Z')] }); + expect(serializeKiLeser(parseKiLeser(ohne))).toBe(ohne); + expect(ohne).not.toContain('"id"'); + }); +}); diff --git a/core/ki/leser.ts b/core/ki/leser.ts index 56dd815..0b94b2f 100644 --- a/core/ki/leser.ts +++ b/core/ki/leser.ts @@ -33,6 +33,8 @@ export type PlanAbgleich = LeserPlanAbgleich; /** Ausführliche Form eines Leser-Stands (gespeichert als LeserStand, siehe alsLeserStand). */ export interface LeserSimulation { + /** gespeicherte Simulation (siehe standId) */ + id?: Id; kapitel: Id; basisHash: string; weiss: LeserAussage[]; @@ -43,6 +45,8 @@ export interface LeserSimulation { plan: PlanAbgleich[]; modell: string; datum: string; + /** von Leon als erledigt markiert (ISO-Datum) */ + erledigt?: string; } // --------------------------------------------------------------------------- @@ -57,6 +61,7 @@ const P_PLAN = 'plan|'; export function alsLeserStand(s: LeserSimulation): LeserStand { return { + ...(s.id ? { id: s.id } : {}), kapitel: s.kapitel, basisHash: s.basisHash, weiss: s.weiss, @@ -66,6 +71,7 @@ export function alsLeserStand(s: LeserSimulation): LeserStand { vermutungen: s.vermutungen, spannung: s.spannung, plan: s.plan, + ...(s.erledigt ? { erledigt: s.erledigt } : {}), }; } @@ -75,6 +81,7 @@ function istStatus(s: unknown): s is PlanBelegStatus { export function ausLeserStand(stand: LeserStand): LeserSimulation { const sim: LeserSimulation = { + id: standId(stand), kapitel: stand.kapitel, basisHash: stand.basisHash, weiss: stand.weiss, @@ -84,6 +91,7 @@ export function ausLeserStand(stand: LeserStand): LeserSimulation { plan: [], modell: stand.modell, datum: stand.datum, + ...(stand.erledigt ? { erledigt: stand.erledigt } : {}), }; for (const f of stand.fragen) { if (f.id.startsWith(P_VERMUTUNG)) sim.vermutungen.push({ text: f.frage, belege: f.belege }); @@ -107,6 +115,62 @@ export function offeneFragen(s: Pick): LeserFrage[] { return s.fragen.filter((f) => !f.beantwortetIn); } +// --------------------------------------------------------------------------- +// Mehrere Simulationen je Kapitel: jede bleibt erhalten, bis Leon sie löscht; „erledigt“ blendet sie aus. +// --------------------------------------------------------------------------- + +/** ID einer gespeicherten Simulation; ältere Stände ohne id: aus Kapitel und Datum (stabil, eindeutig je Kapitel). */ +export function standId(stand: Pick): Id { + return stand.id || `${stand.kapitel}@${stand.datum}`; +} + +/** Simulationen eines Kapitels, neueste zuerst. */ +export function staendeFuer(eintraege: readonly LeserStand[], kapitel: Id): LeserStand[] { + return eintraege.filter((e) => e.kapitel === kapitel).sort((a, b) => b.datum.localeCompare(a.datum)); +} + +/** Neueste nicht erledigte Simulation eines Kapitels. */ +export function aktuellerStand(eintraege: readonly LeserStand[], kapitel: Id): LeserStand | undefined { + return staendeFuer(eintraege, kapitel).find((e) => !e.erledigt); +} + +/** Speicherreihenfolge: nach Kapitel (Lesereihenfolge), je Kapitel älteste zuerst. */ +export function ordneStaende(eintraege: readonly LeserStand[], reihenfolge: Id[]): LeserStand[] { + const pos = (id: Id) => { + const i = reihenfolge.indexOf(id); + return i < 0 ? reihenfolge.length : i; + }; + return [...eintraege].sort((a, b) => pos(a.kapitel) - pos(b.kapitel) || a.datum.localeCompare(b.datum)); +} + +/** + * Neuer Stand nach erneuter Simulation: Übernahmen (Faden, Notiz) gleicher Fragen (gleiche ID = gleicher Wortlaut) + * aus dem alten Stand mitnehmen, damit Leons Zuordnungen nicht verloren gehen. + */ +export function uebernahmenMitnehmen(alt: LeserStand | undefined, neu: LeserStand): LeserStand { + if (!alt) return neu; + const bisher = new Map(alt.fragen.filter((f) => f.faden || f.anmerkung).map((f) => [f.id, f])); + if (bisher.size === 0) return neu; + return { + ...neu, + fragen: neu.fragen.map((f) => { + const a = bisher.get(f.id); + if (!a) return f; + return { ...f, ...(a.faden && !f.faden ? { faden: a.faden } : {}), ...(a.anmerkung && !f.anmerkung ? { anmerkung: a.anmerkung } : {}) }; + }), + }; +} + +/** Kapitel, in dem eine Leserfrage aufkommt: frühester Beleg in Lesereihenfolge, sonst `ersatz`. */ +export function frageKapitel(frage: Pick, reihenfolge: Id[], ersatz: Id): Id { + let beste: Id | undefined; + for (const b of frage.belege) { + const i = reihenfolge.indexOf(b.kapitel); + if (i >= 0 && (beste === undefined || i < reihenfolge.indexOf(beste))) beste = b.kapitel; + } + return beste ?? ersatz; +} + // --------------------------------------------------------------------------- // Stand-Hash: Kapiteltexte und Kapitelpläne (Szene, Notiz) bis einschließlich n // --------------------------------------------------------------------------- diff --git a/core/ki/meldungen.ts b/core/ki/meldungen.ts index cc08188..026e4e2 100644 --- a/core/ki/meldungen.ts +++ b/core/ki/meldungen.ts @@ -107,6 +107,8 @@ const de = { // Figurenstimme (stimme.ts) nameStimme: 'Figurenstimme', + nameFakten: 'Figurenfakten', + faktZuLang: 'Merkmal (höchstens drei Wörter) oder Wert (höchstens acht Wörter) zu lang', vergleichGleicheStelle: 'Vergleich nennt dieselbe Stelle wie die Auffälligkeit', vageFigurNurDeutlich: 'bewusst vage Figur „{name}“: nur deutliche Auffälligkeiten', @@ -117,6 +119,13 @@ const de = { planAbgleichOhneStatus: 'Plan-Abgleich ohne gültigen Status', nameFrage: 'Frage an das Buch', antwortFehlt: 'Antwort fehlt', + + // Erzählstränge (straenge.ts) + nameStraenge: 'Stränge vorschlagen', + strangUnbekannt: 'unbekannter Strang „{strang}“', + schonImPlan: 'Kap. {nr} steht schon beim Strang „{name}“', + strangGibtEs: 'den Strang „{name}“ gibt es schon', + ohneKapitel: 'ohne Kapitel mit Beleg', }; const en: typeof de = { @@ -212,6 +221,8 @@ const en: typeof de = { zustandUnbekannt: 'unknown state “{zustand}”', nameStimme: 'Character voice', + nameFakten: 'Character facts', + faktZuLang: 'trait (at most three words) or value (at most eight words) too long', vergleichGleicheStelle: 'Comparison cites the same passage as the observation', vageFigurNurDeutlich: 'deliberately vague character “{name}”: only clear observations', @@ -221,6 +232,12 @@ const en: typeof de = { planAbgleichOhneStatus: 'Plan comparison without a valid status', nameFrage: 'Question to the book', antwortFehlt: 'Answer missing', + + nameStraenge: 'Suggest plot lines', + strangUnbekannt: 'unknown plot line “{strang}”', + schonImPlan: 'Ch. {nr} is already part of the plot line “{name}”', + strangGibtEs: 'the plot line “{name}” already exists', + ohneKapitel: 'no chapter with evidence', }; /** Wörterbuch der core/ki-Meldungen je Oberflächensprache. */ diff --git a/core/ki/openai.test.ts b/core/ki/openai.test.ts new file mode 100644 index 0000000..187adba --- /dev/null +++ b/core/ki/openai.test.ts @@ -0,0 +1,204 @@ +/** + * OpenAI-kompatible Dienste (E15): Übersetzung Messages-API ⇄ Chat Completions, und ein ganzer Prüflauf + * über die Übersetzung mit Demo-Antworten (wie ein lokales Denkmodell: -Block und ```-Zaun). + */ +import { demoDateien } from '../../fixtures/demoProjekt'; +import { erstelleDemoKiTransport } from '../../src/backend/ki/demo'; +import type { KiEinstellungen } from '../model/types'; +import { MemoryFs, PFADE } from '../projekt'; +import { baueAnfrage, MAX_TOKENS } from './anfrage'; +import { + jsonAusText, + KONTEXT_RESERVE, + nachOpenAi, + passtInKontext, + pruefe, + schaetzeLauf, + STANDARD_KI, + VORLAGEN, + vonOpenAi, + type KiTransport, +} from './index'; + +type Body = Record; + +const SCHEMA = { type: 'object', additionalProperties: false, properties: { a: { type: 'string' } }, required: ['a'] }; + +function anfrage(): Body { + return baueAnfrage({ + modell: 'qwen3:32b', + system: 'Du prüfst.', + kontext: { text: 'Text' } as never, + aufgabe: 'Prüfe.', + schema: SCHEMA, + effort: 'medium', + }); +} + +describe('nachOpenAi', () => { + it('Ollama: system zuerst, Buch vor Aufgabe in einer Nachricht, Schema als json_schema strict', () => { + const b = nachOpenAi(anfrage(), VORLAGEN.ollama); + expect(b).toEqual({ + model: 'qwen3:32b', + messages: [ + { role: 'system', content: 'Du prüfst.' }, + { role: 'user', content: 'Text\n\nPrüfe.' }, + ], + max_tokens: MAX_TOKENS, + stream: false, + response_format: { type: 'json_schema', json_schema: { name: 'antwort', strict: true, schema: SCHEMA } }, + }); + // nichts Anthropic-Spezifisches + const text = JSON.stringify(b); + for (const feld of ['cache_control', 'output_config', 'fallbacks', 'thinking']) expect(text).not.toContain(feld); + }); + + it('OpenAI: max_completion_tokens und reasoning_effort', () => { + const b = nachOpenAi(anfrage(), VORLAGEN.openai); + expect(b.max_completion_tokens).toBe(MAX_TOKENS); + expect(b.max_tokens).toBeUndefined(); + expect(b.reasoning_effort).toBe('medium'); + }); + + it('OpenRouter: reasoning.effort und Anbieter nur mit Schema-Unterstützung, ohne Datenspeicherung', () => { + const b = nachOpenAi(anfrage(), VORLAGEN.openrouter); + expect(b.reasoning).toEqual({ effort: 'medium' }); + expect(b.provider).toEqual({ require_parameters: true, data_collection: 'deny' }); + }); + + it('json_object (Qwen): Schema steht im System-Prompt', () => { + const b = nachOpenAi(anfrage(), VORLAGEN.qwen); + expect(b.response_format).toEqual({ type: 'json_object' }); + const system = (b.messages as { role: string; content: string }[])[0]!; + expect(system.role).toBe('system'); + expect(system.content.startsWith('Du prüfst.')).toBe(true); + expect(system.content).toContain('JSON'); + expect(system.content).toContain(JSON.stringify(SCHEMA)); + }); + + it('Bodys ohne Schema (z. B. zum Zählen) bekommen kein response_format', () => { + const b = nachOpenAi({ model: 'm', system: [{ type: 'text', text: 's' }], messages: [{ role: 'user', content: 'x' }] }, VORLAGEN.ollama); + expect(b.response_format).toBeUndefined(); + expect(b.messages).toEqual([ + { role: 'system', content: 's' }, + { role: 'user', content: 'x' }, + ]); + }); +}); + +describe('vonOpenAi', () => { + const antwort = (content: unknown, finish = 'stop', extra: Body = {}): Body => ({ + id: 'c1', + model: 'qwen3:32b', + choices: [{ index: 0, message: { role: 'assistant', content, ...extra }, finish_reason: finish }], + usage: { prompt_tokens: 1000, completion_tokens: 50, prompt_tokens_details: { cached_tokens: 800 } }, + }); + + it('übersetzt Text, stop_reason und Nutzung (gecachte Tokens getrennt)', () => { + expect(vonOpenAi(antwort('{"a":"x"}'))).toEqual({ + type: 'message', + role: 'assistant', + model: 'qwen3:32b', + content: [{ type: 'text', text: '{"a":"x"}' }], + stop_reason: 'end_turn', + usage: { input_tokens: 200, output_tokens: 50, cache_read_input_tokens: 800, cache_creation_input_tokens: 0 }, + }); + }); + + it('length → max_tokens, content_filter und refusal → refusal', () => { + expect(vonOpenAi(antwort('{', 'length')).stop_reason).toBe('max_tokens'); + expect(vonOpenAi(antwort(null, 'content_filter')).stop_reason).toBe('refusal'); + expect(vonOpenAi(antwort(null, 'stop', { refusal: 'Nein.' })).stop_reason).toBe('refusal'); + expect(vonOpenAi({ choices: [] }).stop_reason).toBe('keine_antwort'); + }); + + it('Fehler-JSON (OpenAI und Ollama) → Messages-API-Fehler', () => { + expect(vonOpenAi({ error: { message: 'model not found', type: 'invalid_request_error' } })).toEqual({ + type: 'error', + error: { type: 'invalid_request_error', message: 'model not found' }, + }); + expect(vonOpenAi({ error: 'model "x" not found' })).toEqual({ type: 'error', error: { type: 'error', message: 'model "x" not found' } }); + }); + + it('jsonAusText: ohne , ohne Zaun, ohne Text drumherum', () => { + expect(jsonAusText('erst denken {nicht das}\n{"a":1}')).toBe('{"a":1}'); + expect(jsonAusText('```json\n{"a":1}\n```')).toBe('{"a":1}'); + expect(jsonAusText('Hier ist das Ergebnis: {"a":{"b":2}} – fertig.')).toBe('{"a":{"b":2}}'); + expect(jsonAusText(' {"a":"bleibt"} ')).toBe('{"a":"bleibt"}'); + }); +}); + +describe('Kontextlänge', () => { + it('lehnt ab, was samt Reserve nicht in den Kontext passt; ohne Angabe wird nicht geprüft', () => { + const b = nachOpenAi(anfrage(), VORLAGEN.ollama); + const { tokens } = passtInKontext(b, undefined); + expect(passtInKontext(b, undefined).passt).toBe(true); + expect(passtInKontext(b, tokens + KONTEXT_RESERVE).passt).toBe(true); + expect(passtInKontext(b, tokens + KONTEXT_RESERVE - 1).passt).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- + +const OLLAMA: KiEinstellungen = { + ...STANDARD_KI, + anbieter: 'openai', + openai: { vorlage: 'ollama', endpoint: '', modelle: { erkennung: '', pruefung: 'qwen3:32b', tiefencheck: '' } }, +}; + +/** Demo-Antworten, aber über die Übersetzung – wie ein lokales Denkmodell verpackt. Ohne count_tokens. */ +function ueberOpenAi(): KiTransport & { gesendet: Body[] } { + const demo = erstelleDemoKiTransport({ verzoegerungMs: 0 }); + const gesendet: Body[] = []; + return { + gesendet, + bereit: async () => true, + async senden(body) { + const oa = nachOpenAi(body, VORLAGEN.ollama); + gesendet.push(oa); + const m = await demo.senden(body); + const text = (m.content as { text: string }[])[0]!.text; + return vonOpenAi({ + model: oa.model, + choices: [{ index: 0, message: { role: 'assistant', content: `Ich lese.\n\`\`\`json\n${text}\n\`\`\`` }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1000, completion_tokens: 50, prompt_tokens_details: { cached_tokens: 800 } }, + }); + }, + async tokenZaehlen() { + throw new Error('count_tokens gibt es hier nicht'); + }, + }; +} + +function frischFs(): MemoryFs { + const d = demoDateien(); + for (const p of Object.keys(d)) if (p.startsWith(`${PFADE.analysen}/`)) delete d[p]; + return new MemoryFs(d); +} + +describe('Prüflauf über einen OpenAI-kompatiblen Dienst', () => { + it('liefert dieselben Hinweise wie mit Claude; Kosten unbekannt, Cache aus der Nutzung', async () => { + const direkt = await pruefe(frischFs(), { umfang: 'buch' }, erstelleDemoKiTransport({ verzoegerungMs: 0 }), STANDARD_KI); + const t = ueberOpenAi(); + const e = await pruefe(frischFs(), { umfang: 'buch' }, t, OLLAMA); + + expect(e.fehler).toBeUndefined(); + expect(e.hinweise.map((h) => h.schluessel).sort()).toEqual(direkt.hinweise.map((h) => h.schluessel).sort()); + expect(e.analysen).toHaveLength(direkt.analysen.length); + expect(e.modell).toBe('qwen3:32b'); + expect(e.kostenUsd).toBeUndefined(); + expect(e.tokens.cacheGelesen).toBe(800 * t.gesendet.length); + for (const b of t.gesendet) { + expect(b.model).toBe('qwen3:32b'); + expect(b.response_format).toMatchObject({ type: 'json_schema' }); + } + }); + + it('Schätzung ohne count_tokens: über die Zeichenzahl, Kosten unbekannt', async () => { + const s = await schaetzeLauf(frischFs(), { umfang: 'buch' }, ueberOpenAi(), OLLAMA); + expect(s.gezaehlt).toBe(false); + expect(s.kostenBekannt).toBe(false); + expect(s.kostenUsd).toBe(0); + expect(s.modell).toBe('qwen3:32b'); + }); +}); diff --git a/core/ki/openai.ts b/core/ki/openai.ts new file mode 100644 index 0000000..441f135 --- /dev/null +++ b/core/ki/openai.ts @@ -0,0 +1,138 @@ +/** + * Übersetzer Messages-API ⇄ OpenAI Chat Completions (E15). core/ki baut weiter Messages-API-Bodys und + * prüft Messages-API-Antworten; für OpenAI-kompatible Dienste (OpenAI, OpenRouter, Qwen, Ollama, + * LM Studio …) übersetzt der Transport davor und danach. Prompt, Allowlist und Antwortprüfung bleiben + * damit für alle Anbieter dieselben. + * + * - system-Blöcke → Nachricht `system`; Inhaltsblöcke → ein Text (cache_control entfällt: OpenAI-kompatible + * Dienste cachen gleiche Anfänge selbst, und das Buch bleibt der feste Anfang). + * - output_config.format → response_format (json_schema strict; bei `json_object` steht das Schema im Prompt). + * - output_config.effort → reasoning_effort bzw. reasoning.effort, wo die Vorlage es vorsieht. + * - Antwort: finish_reason → stop_reason, usage → input/output/cache_read_input_tokens, Text → JSON + * (ohne vorangestelltes … und ohne ```-Zaun). + */ +import { MAX_TOKENS } from './anfrage'; +import type { VorlageInfo } from './anbieter'; + +/** Ausgabe-Tokens, die im Kontext des Modells frei bleiben müssen (Antwort samt Denken, grob). */ +export const KONTEXT_RESERVE = 4000; + +type Roh = Record; + +function istObjekt(v: unknown): v is Roh { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** Text aus einem String oder einer Liste von Text-Blöcken (Messages-API wie Chat Completions). */ +function textAus(inhalt: unknown): string { + if (typeof inhalt === 'string') return inhalt; + if (!Array.isArray(inhalt)) return ''; + return inhalt + .filter((b): b is Roh => istObjekt(b) && b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text as string) + .join('\n\n'); +} + +/** Messages-API-Body → Chat-Completions-Body für die Vorlage. */ +export function nachOpenAi(body: Roh, vorlage: VorlageInfo): Roh { + const ausgabe = istObjekt(body.output_config) ? body.output_config : {}; + const format = istObjekt(ausgabe.format) ? ausgabe.format : undefined; + const schema = format?.type === 'json_schema' && istObjekt(format.schema) ? format.schema : undefined; + + let system = textAus(body.system); + if (schema && vorlage.strukturiert === 'json_object') { + system += `\n\nRespond only with one JSON object that matches this JSON Schema, without any other text:\n${JSON.stringify(schema)}`; + } + const nachrichten: Roh[] = []; + if (system.trim()) nachrichten.push({ role: 'system', content: system }); + for (const n of Array.isArray(body.messages) ? body.messages : []) { + if (!istObjekt(n)) continue; + nachrichten.push({ role: n.role === 'assistant' ? 'assistant' : 'user', content: textAus(n.content) }); + } + + const ergebnis: Roh = { + model: body.model, + messages: nachrichten, + [vorlage.maxTokensFeld]: typeof body.max_tokens === 'number' ? body.max_tokens : MAX_TOKENS, + stream: false, + }; + if (schema) { + ergebnis.response_format = + vorlage.strukturiert === 'json_schema' + ? { type: 'json_schema', json_schema: { name: 'antwort', strict: true, schema } } + : { type: 'json_object' }; + } + const effort = typeof ausgabe.effort === 'string' ? ausgabe.effort : undefined; + if (effort && vorlage.effort === 'reasoning_effort') ergebnis.reasoning_effort = effort; + if (effort && vorlage.effort === 'reasoning') ergebnis.reasoning = { effort }; + return { ...ergebnis, ...(vorlage.zusatz ?? {}) }; +} + +/** + * JSON aus dem Antworttext: ohne vorangestelltes … (lokale Denkmodelle) und ohne + * ```json-Zaun; steht davor oder danach noch Text, zählt der Teil vom ersten { bis zur letzten }. + */ +export function jsonAusText(text: string): string { + let t = text.replace(/^\s*[\s\S]*?<\/think>/, '').trim(); + const zaun = /^```[a-zA-Z]*\s*\n([\s\S]*?)\n?```$/.exec(t); + if (zaun) t = zaun[1]!.trim(); + if (!t.startsWith('{') && !t.startsWith('[')) { + const anfang = t.indexOf('{'); + const ende = t.lastIndexOf('}'); + if (anfang >= 0 && ende > anfang) t = t.slice(anfang, ende + 1); + } + return t; +} + +function zahl(v: unknown): number { + return typeof v === 'number' && Number.isFinite(v) ? v : 0; +} + +const STOP: Record = { + stop: 'end_turn', + length: 'max_tokens', + content_filter: 'refusal', +}; + +/** Chat-Completions-Antwort → Messages-API-Antwort (für leseAntwort). */ +export function vonOpenAi(roh: Roh): Roh { + if (roh.error !== undefined && roh.error !== null) { + const f = roh.error; + const meldung = typeof f === 'string' ? f : istObjekt(f) && typeof f.message === 'string' ? f.message : JSON.stringify(f); + const typ = istObjekt(f) ? String(f.type ?? f.code ?? 'error') : 'error'; + return { type: 'error', error: { type: typ, message: meldung } }; + } + const wahl = Array.isArray(roh.choices) && istObjekt(roh.choices[0]) ? roh.choices[0] : undefined; + const nachricht = wahl && istObjekt(wahl.message) ? wahl.message : {}; + const u = istObjekt(roh.usage) ? roh.usage : {}; + const details = istObjekt(u.prompt_tokens_details) ? u.prompt_tokens_details : {}; + const gecacht = zahl(details.cached_tokens); + const usage = { + input_tokens: Math.max(0, zahl(u.prompt_tokens) - gecacht), + output_tokens: zahl(u.completion_tokens), + cache_read_input_tokens: gecacht, + cache_creation_input_tokens: 0, + }; + const abgelehnt = typeof nachricht.refusal === 'string' && nachricht.refusal.trim() !== ''; + const grund = typeof wahl?.finish_reason === 'string' ? wahl.finish_reason : 'keine_antwort'; + const text = textAus(nachricht.content); + return { + type: 'message', + role: 'assistant', + model: roh.model, + content: text ? [{ type: 'text', text: jsonAusText(text) }] : [], + stop_reason: abgelehnt ? 'refusal' : (STOP[grund] ?? grund), + usage, + }; +} + +/** Grobe Schätzung der Eingabe-Tokens eines Chat-Completions-Bodys (Zeichen/3 wie ohne count_tokens, eher zu hoch). */ +export function eingabeTokensSchaetzen(body: Roh): number { + return Math.ceil(JSON.stringify(body.messages ?? []).length / 3); +} + +/** Passt die Anfrage samt KONTEXT_RESERVE in `kontextTokens`? Ohne Angabe wird nicht geprüft. */ +export function passtInKontext(body: Roh, kontextTokens: number | undefined): { passt: boolean; tokens: number } { + const tokens = eingabeTokensSchaetzen(body); + return { passt: !kontextTokens || tokens + KONTEXT_RESERVE <= kontextTokens, tokens }; +} diff --git a/core/ki/stimme.ts b/core/ki/stimme.ts index 4ba9f45..4b34567 100644 --- a/core/ki/stimme.ts +++ b/core/ki/stimme.ts @@ -87,7 +87,7 @@ export function stimmenSchluessel(figur: Id, zitate: Zitat[]): string { } function figurZeileEn(f: KontextFigur): string { - const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'main character' : 'minor character']; + const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'main character' : f.rolle === 'statist' ? 'walk-on character' : 'minor character']; if (f.aliase.length) teile.push(`also called: ${f.aliase.join(', ')}`); if (f.existenz === 'bewusst_vage') teile.push('deliberately vague'); else if (f.existenz === 'offen') teile.push('existence still open'); @@ -95,7 +95,7 @@ function figurZeileEn(f: KontextFigur): string { } function figurZeile(f: KontextFigur): string { - const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'Hauptfigur' : 'Nebenfigur']; + const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'Hauptfigur' : f.rolle === 'statist' ? 'Statist' : 'Nebenfigur']; if (f.aliase.length) teile.push(`auch genannt: ${f.aliase.join(', ')}`); if (f.existenz === 'bewusst_vage') teile.push('bewusst vage'); else if (f.existenz === 'offen') teile.push('Existenz noch offen'); diff --git a/core/ki/straenge.test.ts b/core/ki/straenge.test.ts new file mode 100644 index 0000000..aa8b8bc --- /dev/null +++ b/core/ki/straenge.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { demoFs, demoFsEn, ladeDemoProjekt } from '../../fixtures/demoProjekt'; +import { erstelleDemoKiTransport } from '../../src/backend/ki/demo'; +import { ladeProjekt, MemoryFs, parseKiStrangVorschlaege, serializeKiStrangVorschlaege } from '../projekt'; +import { offeneStrangVorschlaege, strangFrageBeantworten, strangVorschlagUebernehmen, strangVorschlagVerwerfen } from '../struktur/straenge'; +import { aufgabenAnfrage, mischeStrangVorschlaege, schlageStraengeVor, straengeDefinition, strangAuftrag } from './index'; + +const demo = () => erstelleDemoKiTransport({ verzoegerungMs: 0 }); + +/** Text des zweiten Blocks (Aufgabe) aus einem Messages-API-Body */ +function aufgabeAus(body: Record): string { + const inhalt = (body.messages as { content: { text?: string }[] }[])[0]!.content; + return inhalt.map((c) => c.text ?? '').join('\n'); +} + +describe('Stränge vorschlagen (G2)', () => { + it('schickt Name, Rolle, Zweck und Plan-Kapitel der Stränge nur in der Aufgabe, nicht im Prefix', async () => { + const fs = demoFs(); + const d = await ladeProjekt(fs); + const nordhaven = d.straenge.find((s) => s.id === 'nordhaven')!; + d.straenge = d.straenge.map((s) => (s.id === 'nordhaven' ? { ...nordhaven, rolle: 'haupt' as const, beschreibung: 'Der Auftrag für den [[figur:konrad|Konrad]]-Kunden' } : s)); + const auftrag = strangAuftrag(d); + expect(auftrag.straenge.find((s) => s.id === 'nordhaven')).toEqual({ + id: 'nordhaven', + name: 'Antagonist: Nordhaven', + rolle: 'haupt', + beschreibung: 'Der Auftrag für den Konrad-Kunden', + kapitel: ['grundriss', 'fassade', 'staub', 'weissraum'], + }); + const body = await aufgabenAnfrage(fs, straengeDefinition(auftrag)); + const text = aufgabeAus(body); + expect(text).toContain('Antagonist: Nordhaven (id: nordhaven) · Haupthandlung · Zweck: Der Auftrag für den Konrad-Kunden'); + const prefix = (body.messages as { content: { text?: string }[] }[])[0]!.content[0]!.text ?? ''; + expect(prefix).not.toContain('Zweck:'); + }); + + it('prüft die Demo-Antwort: Zitate wörtlich, Erfundenes verworfen, schon Geplantes nicht', async () => { + const r = await schlageStraengeVor(demoFs(), demo(), undefined); + const arten = r.ergebnis.vorschlaege.map((v) => `${v.art}:${v.schluessel}`); + expect(arten).toEqual([ + 'neu:strang:der-alte-speicher', + 'zuordnung:zuordnung:nordhaven|funkstille', + expect.stringMatching(/^frage:frage:gehoert-der-verkauf/), + expect.stringMatching(/^frage:frage:welcher-strang/), + ]); + const neu = r.ergebnis.vorschlaege[0]!; + expect(neu).toMatchObject({ name: 'Der alte Speicher', rolle: 'neben', kapitel: ['grundriss', 'staub', 'funkstille'] }); + expect(neu.belege).toHaveLength(3); + // erfundenes Postkarten-Zitat + expect(r.verworfen.some((v) => /nicht wörtlich/.test(v.grund))).toBe(true); + }); + + it('beantwortet englische Bücher auf Englisch', async () => { + const r = await schlageStraengeVor(demoFsEn(), demo(), undefined, { oberflaeche: 'en' }); + expect(r.ergebnis.vorschlaege.find((v) => v.art === 'neu')?.name).toBe('The old granary'); + }); + + it('übernimmt, verwirft und beantwortet – Entschiedenes kommt nicht wieder', async () => { + const d = await ladeDemoProjekt(); + const r = await schlageStraengeVor(demoFs(), demo(), undefined); + const gemischt = mischeStrangVorschlaege(undefined, r.ergebnis.vorschlaege, { datum: r.datum, modell: r.modell }); + expect(gemischt.neu).toBe(4); + d.kiStrangVorschlaege = gemischt.ablage; + expect(offeneStrangVorschlaege(d, 'funkstille').map((v) => v.art)).toEqual(['neu', 'zuordnung']); + + // neuer Strang nur für zwei der drei Kapitel + const u = strangVorschlagUebernehmen(d, 'strang:der-alte-speicher', ['grundriss', 'staub'])!; + const speicher = u.straenge.find((s) => s.id === 'der-alte-speicher')!; + expect(speicher).toMatchObject({ name: 'Der alte Speicher', rolle: 'neben', farbe: '#2F6B45' }); + expect(speicher.beschreibung).toMatch(/Kornspeicher/); + expect(u.plaene.map((p) => [p.id, p.straenge.includes('der-alte-speicher')])).toEqual([ + ['grundriss', true], + ['staub', true], + ]); + + const zuordnung = strangVorschlagUebernehmen(d, 'zuordnung:nordhaven|funkstille')!; + expect(zuordnung.plaene[0]!.straenge).toContain('nordhaven'); + + const frage = gemischt.ablage.eintraege.find((v) => v.art === 'frage')!; + let ablage = strangFrageBeantworten(u.ablage, frage.schluessel, 'Eigener Strang, gehört aber zum Hafen.'); + ablage = strangVorschlagVerwerfen(ablage, 'zuordnung:nordhaven|funkstille'); + d.kiStrangVorschlaege = ablage; + expect(strangAuftrag(d).antworten).toEqual([{ frage: frage.frage, antwort: 'Eigener Strang, gehört aber zum Hafen.' }]); + + // zweiter Lauf: alles Entschiedene bleibt draußen + const nochmal = mischeStrangVorschlaege(ablage, r.ergebnis.vorschlaege); + expect(nochmal.bereitsEntschieden).toBe(3); + expect(nochmal.ablage.eintraege.filter((v) => v.status === 'neu')).toHaveLength(1); + + // Ablage übersteht Speichern und Lesen + expect(parseKiStrangVorschlaege(serializeKiStrangVorschlaege(nochmal.ablage))).toEqual(nochmal.ablage); + }); + + it('liest und schreibt Rolle, Beschreibung und Funktion', async () => { + const fs = new MemoryFs({ + 'projekt.md': '---\nversion: 1\ntitel: T\nkapitel: [eins]\n---\n', + 'plan/straenge.md': '---\nstraenge:\n - id: fall\n name: Der Fall\n farbe: "#3D5A75"\n rolle: haupt\n beschreibung: Jan wird ausgenutzt\n - id: x\n name: X\n farbe: "#C2405C"\n rolle: quatsch\n---\n', + 'plan/kapitel/eins.md': '---\nid: eins\ntitel: Eins\nakt: null\nstatus: geplant\nszene: Opening\nfunktion: Wendepunkt\nstraenge: []\nfiguren: []\norte: []\n---\nNotiz\n', + }); + const d = await ladeProjekt(fs); + expect(d.straenge).toEqual([ + { id: 'fall', name: 'Der Fall', farbe: '#3D5A75', rolle: 'haupt', beschreibung: 'Jan wird ausgenutzt' }, + { id: 'x', name: 'X', farbe: '#C2405C' }, + ]); + expect(d.plaene.eins).toMatchObject({ szene: 'Opening', funktion: 'Wendepunkt', notiz: 'Notiz' }); + }); +}); diff --git a/core/ki/straenge.ts b/core/ki/straenge.ts new file mode 100644 index 0000000..0025917 --- /dev/null +++ b/core/ki/straenge.ts @@ -0,0 +1,350 @@ +/** + * KI-Aufgabe „Stränge vorschlagen“ (G2 Plot-Struktur). Besitzer: G2. Nutzt fuehreAufgabeAus/AufgabenDefinition aus + * ./aufgabe. Nur ProjektLeser und KiTransport. + * + * Ablauf: ganzes Buch als gecachter Prefix (E13), danach die Aufgabe „straenge“ mit Leons Strängen (Name, + * Haupt-/Nebenhandlung, „Wofür steht der Strang?“, Kapitel im Plan) und seinen Antworten auf frühere Rückfragen – + * freigegeben von Leon am 2026-10-12 (E12, nur für diese Aufgabe, nicht im Prefix). + * Antwort: Zuordnungen geschriebener Kapitel zu angelegten Strängen, neue Stränge mit Kapiteln, Rückfragen. + * Jede Zuordnung und jedes Kapitel eines neuen Strangs braucht ein wörtliches Zitat aus genau diesem Kapitel (E6). + * Schreibt nichts – gespeichert wird im Store (.plotwerk/strang-vorschlaege.json). + */ +import type { Id, KiAblage, KiEinstellungen, ProjektDaten, StrangRolle, StrangVorschlag, Zitat } from '../model/types'; +import type { ProjektLeser } from '../projekt/fs'; +import { ladeProjekt } from '../projekt/lesen'; +import { + fuehreAufgabeAus, + schaetzeAufgabe, + type AufgabenDefinition, + type AufgabenErgebnis, + type AufgabenOptionen, + type AufgabenPruefung, +} from './aufgabe'; +import type { KiTransport, Schaetzung } from './index'; +import { planText, type BuchKontext } from './kontext'; +import { meldungenIn } from './meldungen'; +import { loseEndeId } from './pruefung'; +import { ANTWORTSPRACHE_EN, kapKurz, type Sprache } from './sprache'; + +/** Kennung im Aufgabenkopf (); Demo-Antworten sind unter diesem Schlüssel registriert. */ +export const STRAENGE_ART = 'straenge'; + +/** höchstens so viele Rückfragen je Lauf */ +export const STRAENGE_MAX_FRAGEN = 3; + +/** Ein Strang, wie er an das Modell geht (E12: nur diese Felder). */ +export interface KontextStrang { + id: Id; + name: string; + rolle?: StrangRolle; + /** „Wofür steht der Strang?“ als eine Zeile */ + beschreibung: string; + /** Kapitel, in denen der Plan den Strang schon führt (Buchreihenfolge) */ + kapitel: Id[]; +} + +export interface StrangAuftrag { + straenge: KontextStrang[]; + /** von Leon beantwortete Rückfragen früherer Läufe */ + antworten: { frage: string; antwort: string }[]; +} + +export interface StrangErgebnis { + /** geprüfte Vorschläge (Status „neu“), Buchreihenfolge */ + vorschlaege: StrangVorschlag[]; +} + +/** Allowlist (E12) für diese Aufgabe: nur Name, Rolle, Beschreibung und Plan-Kapitel der Stränge, dazu beantwortete Rückfragen. */ +export function strangAuftrag(daten: ProjektDaten): StrangAuftrag { + const reihe = daten.projekt.kapitel; + const straenge = daten.straenge.map((s) => ({ + id: s.id, + name: s.name, + ...(s.rolle ? { rolle: s.rolle } : {}), + beschreibung: planText(s.beschreibung), + kapitel: reihe.filter((k) => daten.plaene[k]?.straenge.includes(s.id)), + })); + const antworten = (daten.kiStrangVorschlaege?.eintraege ?? []) + .filter((v) => v.art === 'frage' && v.status === 'uebernommen' && v.frage && v.antwort?.trim()) + .map((v) => ({ frage: planText(v.frage), antwort: planText(v.antwort) })); + return { straenge, antworten }; +} + +export const SYSTEM_STRAENGE = `Du hilfst einem Autor, die Erzählstränge seines deutschsprachigen Romanmanuskripts zu ordnen. Ein Erzählstrang ist ein Handlungsfaden, der sich über mehrere Kapitel zieht (z. B. ein Fall, eine Beziehung, ein Konflikt). Du liest das Buch und schlägst vor, welche geschriebenen Kapitel zu welchem Strang gehören, welche Stränge das Buch trägt, die noch nicht angelegt sind, und wo du beim Autor nachfragen musst. + +Du schreibst nie Text für das Buch: keine Umformulierungen, keine Ersatzsätze, keine Formulierungsvorschläge, keine Ideen für neue Szenen. Was ein Strang ist und wohin er gehört, entscheidet allein der Autor. + +Regeln: +- Jeder Vorschlag stützt sich auf den der Kapitel. Jedes Zitat ist eine zusammenhängende Passage aus dem Kapitel, das du unter "kapitel" mit seiner id angibst, Zeichen für Zeichen kopiert, ohne Auslassungen, höchstens ein Satz. +- Planung des Autors (, Stränge, Antworten auf Rückfragen) hilft beim Einordnen; sie ist kein Beleg. +- Namen, Zweck und Begründungen sind sachlich und kurz. Lieber wenige sichere Vorschläge als viele unsichere.`; + +export const SYSTEM_STRAENGE_EN = `You help an author organise the plot lines of their English-language novel manuscript. A plot line is a narrative thread that runs through several chapters (e.g. a case, a relationship, a conflict). You read the book and suggest which written chapters belong to which plot line, which plot lines the book carries that have not been set up yet, and where you need to ask the author. + +You never write text for the book: no rewordings, no replacement sentences, no suggested phrasings, no ideas for new scenes. What a plot line is and where it belongs is decided by the author alone. + +Rules: +- Every suggestion is based on the of the chapters. Every quote is one continuous passage from the chapter you name under "kapitel" with its id, copied character for character, without omissions, at most one sentence. +- The author's planning (, plot lines, answers to questions) helps you to classify; it is not evidence. +- Names, purposes and reasons are factual and short. Better a few certain suggestions than many uncertain ones. + +${ANTWORTSPRACHE_EN}`; + +function strangZeile(s: KontextStrang, kontext: BuchKontext, en: boolean): string { + const nr = new Map(kontext.kapitel.map((k) => [k.id, k.nr])); + const teile = [`${s.name} (id: ${s.id})`]; + if (s.rolle) teile.push(s.rolle === 'haupt' ? (en ? 'main plot' : 'Haupthandlung') : en ? 'subplot' : 'Nebenhandlung'); + if (s.beschreibung) teile.push(`${en ? 'purpose' : 'Zweck'}: ${s.beschreibung}`); + const kap = s.kapitel.map((k) => kapKurz(nr.get(k) ?? '?', kontext.sprache)); + teile.push(kap.length ? `${en ? 'in the plan in' : 'im Plan in'}: ${kap.join(', ')}` : en ? 'not yet in any chapter' : 'noch in keinem Kapitel'); + return `- ${teile.join(' · ')}`; +} + +function aufgabenText(kontext: BuchKontext, auftrag: StrangAuftrag): string { + const en = kontext.sprache === 'en'; + const z: string[] = []; + z.push(en ? "The author's plot lines (planning, not part of the book):" : 'Erzählstränge des Autors (Planung, gehört nicht zum Buch):'); + if (auftrag.straenge.length === 0) z.push(en ? '- (none set up yet)' : '- (noch keine angelegt)'); + for (const s of auftrag.straenge) z.push(strangZeile(s, kontext, en)); + if (auftrag.antworten.length) { + z.push(''); + z.push(en ? "The author's answers to earlier questions:" : 'Antworten des Autors auf frühere Rückfragen:'); + for (const a of auftrag.antworten) z.push(en ? `- Question: ${a.frage} · Answer: ${a.antwort}` : `- Frage: ${a.frage} · Antwort: ${a.antwort}`); + } + z.push(''); + if (en) { + z.push('Read the whole book and organise the written chapters into plot lines.'); + z.push('1. zuordnungen: written chapters that take up a plot line already set up, but are not yet listed with it in the plan. For each: strang (id), kapitel (id), zitat (verbatim from this chapter), begruendung (one sentence: what in the chapter belongs to the plot line).'); + z.push('2. neueStraenge: plot lines the book clearly carries that are not set up yet and do not overlap with an existing one. For each: name (two to four words), rolle (haupt = carries the central plot, neben = subplot), zweck (one factual sentence: what the plot line is about), kapitel: every written chapter that takes it up, each with one verbatim zitat from that chapter.'); + z.push(`3. rueckfragen: at most ${STRAENGE_MAX_FRAGEN} questions to the author where you are unsure – e.g. whether a thread is a plot line of its own or part of another, which plot line is the main plot, what an existing plot line without a purpose stands for. For each: frage (one sentence), strang (id of an existing plot line concerned, otherwise empty), belege (zero to two verbatim quotes). Do not repeat questions the author has already answered.`); + z.push('Never suggest anything that is already in the plan. Only chapters with text. If there is nothing to suggest, return empty lists.'); + } else { + z.push('Lies das ganze Buch und ordne die geschriebenen Kapitel Erzählsträngen zu.'); + z.push('1. zuordnungen: geschriebene Kapitel, die einen angelegten Strang aufgreifen, im Plan aber noch nicht bei ihm stehen. Je Eintrag: strang (id), kapitel (id), zitat (wörtlich aus diesem Kapitel), begruendung (ein Satz: was im Kapitel zum Strang gehört).'); + z.push('2. neueStraenge: Erzählstränge, die das Buch erkennbar trägt, die aber noch nicht angelegt sind und sich nicht mit einem angelegten überschneiden. Je Eintrag: name (zwei bis vier Wörter), rolle (haupt = trägt die zentrale Handlung, neben = Nebenhandlung), zweck (ein sachlicher Satz: worum es in dem Strang geht), kapitel: jedes geschriebene Kapitel, das ihn aufgreift, mit je einem wörtlichen zitat aus diesem Kapitel.'); + z.push(`3. rueckfragen: höchstens ${STRAENGE_MAX_FRAGEN} Fragen an den Autor, wo du unsicher bist – z. B. ob ein Faden ein eigener Strang ist oder zu einem anderen gehört, welcher Strang die Haupthandlung ist, wofür ein angelegter Strang ohne Zweck steht. Je Eintrag: frage (ein Satz), strang (id eines betroffenen angelegten Strangs, sonst leer), belege (null bis zwei wörtliche Zitate). Wiederhole keine Fragen, die der Autor schon beantwortet hat.`); + z.push('Schlage nie vor, was schon im Plan steht. Nur Kapitel mit Text. Gibt es nichts vorzuschlagen, gib leere Listen zurück.'); + } + return z.join('\n'); +} + +const obj = (properties: Record) => ({ + type: 'object', + additionalProperties: false, + properties, + required: Object.keys(properties), +}); + +/** Antwortschema – ohne Feld für Ersatztext (harte Regel 2). */ +export function straengeSchema(kontext: BuchKontext, auftrag: StrangAuftrag): Record { + const mitText = kontext.kapitel.filter((k) => k.text.trim()).map((k) => k.id); + const kapitel = { type: 'string', enum: mitText }; + const ids = auftrag.straenge.map((s) => s.id); + const strang = ids.length ? { type: 'string', enum: ids } : { type: 'string' }; + return obj({ + zuordnungen: { type: 'array', items: obj({ strang, kapitel, zitat: { type: 'string' }, begruendung: { type: 'string' } }) }, + neueStraenge: { + type: 'array', + items: obj({ + name: { type: 'string' }, + rolle: { type: 'string', enum: ['haupt', 'neben'] }, + zweck: { type: 'string' }, + kapitel: { type: 'array', items: obj({ kapitel, zitat: { type: 'string' } }) }, + }), + }, + rueckfragen: { + type: 'array', + items: obj({ + frage: { type: 'string' }, + strang: { type: 'string', enum: ['', ...ids] }, + belege: { type: 'array', items: obj({ kapitel, text: { type: 'string' } }) }, + }), + }, + }); +} + +/** Stabiler Schlüssel je Vorschlagsart (siehe StrangVorschlag). */ +export function strangVorschlagSchluessel(v: Pick): string { + if (v.art === 'zuordnung') return `zuordnung:${v.strang ?? ''}|${v.kapitel[0] ?? ''}`; + if (v.art === 'neu') return `strang:${loseEndeId(v.name ?? '')}`; + return `frage:${loseEndeId(v.frage ?? '').slice(0, 80)}`; +} + +/** Antwort auswerten (auch einzeln testbar). */ +export function werteStraengeAus(daten: unknown, p: AufgabenPruefung, auftrag: StrangAuftrag): StrangErgebnis { + const m = meldungenIn(p.oberflaeche); + const ergebnis: StrangErgebnis = { vorschlaege: [] }; + const d = (daten ?? {}) as Record; + if (!d || typeof d !== 'object' || !Array.isArray(d.zuordnungen) || !Array.isArray(d.neueStraenge) || !Array.isArray(d.rueckfragen)) { + p.verwerfen(m('schemaFalsch'), daten); + return ergebnis; + } + const straenge = new Map(auftrag.straenge.map((s) => [s.id, s])); + const namen = new Set(auftrag.straenge.map((s) => loseEndeId(s.name))); + const reihe = new Map(p.kontext.kapitel.map((k, i) => [k.id, i])); + const gesehen = new Set(); + const dazu = (v: StrangVorschlag) => { + if (gesehen.has(v.schluessel)) return; + gesehen.add(v.schluessel); + ergebnis.vorschlaege.push(v); + }; + /** ein Zitat aus genau diesem Kapitel; null = verworfen (Grund vermerkt) */ + const beleg = (kapitel: unknown, text: unknown, roh: unknown): { kapitel: Id; zitat: Zitat } | null => { + const k = p.kapitel(kapitel); + if (!k || !k.text.trim()) { + p.verwerfen(m('kapitelUnbekannt', { kapitel: String(kapitel ?? '') }), roh); + return null; + } + const zitate = p.zitate([{ kapitel: k.id, text }], roh, { kapitel: new Set([k.id]) }); + return zitate ? { kapitel: k.id, zitat: zitate[0]! } : null; + }; + + for (const roh of d.zuordnungen) { + const z = (roh ?? {}) as Record; + const s = typeof z.strang === 'string' ? straenge.get(z.strang) : undefined; + if (!s) { + p.verwerfen(m('strangUnbekannt', { strang: String(z.strang ?? '') }), roh); + continue; + } + const b = beleg(z.kapitel, z.zitat, roh); + if (!b) continue; + if (s.kapitel.includes(b.kapitel)) { + p.verwerfen(m('schonImPlan', { nr: p.kapitel(b.kapitel)?.nr ?? '?', name: s.name }), roh); + continue; + } + const begruendung = p.text(z.begruendung, roh); + if (begruendung === null) continue; + const v: Omit = { art: 'zuordnung', strang: s.id, kapitel: [b.kapitel], begruendung, belege: [b.zitat], status: 'neu' }; + dazu({ schluessel: strangVorschlagSchluessel(v), ...v }); + } + + for (const roh of d.neueStraenge) { + const n = (roh ?? {}) as Record; + const name = p.text(n.name, roh, { pflicht: true }); + if (name === null) continue; + if (namen.has(loseEndeId(name))) { + p.verwerfen(m('strangGibtEs', { name }), roh); + continue; + } + const zweck = p.text(n.zweck, roh); + if (zweck === null) continue; + const kapitel: Id[] = []; + const belege: Zitat[] = []; + for (const kRoh of Array.isArray(n.kapitel) ? n.kapitel : []) { + const kk = (kRoh ?? {}) as Record; + const b = beleg(kk.kapitel, kk.zitat, roh); + if (!b || kapitel.includes(b.kapitel)) continue; + kapitel.push(b.kapitel); + belege.push(b.zitat); + } + if (kapitel.length === 0) { + p.verwerfen(m('ohneKapitel'), roh); + continue; + } + const folge = kapitel.map((k, i) => ({ k, z: belege[i]! })).sort((a, b) => (reihe.get(a.k) ?? 0) - (reihe.get(b.k) ?? 0)); + const v: Omit = { + art: 'neu', + name, + ...(n.rolle === 'haupt' || n.rolle === 'neben' ? { rolle: n.rolle } : {}), + ...(zweck ? { beschreibung: zweck } : {}), + kapitel: folge.map((x) => x.k), + begruendung: '', + belege: folge.map((x) => x.z), + status: 'neu', + }; + dazu({ schluessel: strangVorschlagSchluessel(v), ...v }); + } + + let fragen = 0; + for (const roh of d.rueckfragen) { + if (fragen >= STRAENGE_MAX_FRAGEN) break; + const f = (roh ?? {}) as Record; + const frage = p.text(f.frage, roh, { pflicht: true }); + if (frage === null) continue; + const belege = Array.isArray(f.belege) && f.belege.length ? p.zitate(f.belege, roh, { mindestens: 0 }) : []; + if (belege === null) continue; + const strang = typeof f.strang === 'string' && straenge.has(f.strang) ? f.strang : undefined; + const v: Omit = { + art: 'frage', + ...(strang ? { strang } : {}), + frage, + kapitel: [...new Set(belege.map((b) => b.kapitel))], + begruendung: '', + belege, + status: 'neu', + }; + dazu({ schluessel: strangVorschlagSchluessel(v), ...v }); + fragen++; + } + + const pos = (v: StrangVorschlag) => (v.kapitel[0] ? (reihe.get(v.kapitel[0]) ?? 0) : Number.MAX_SAFE_INTEGER); + const artReihe = { neu: 0, zuordnung: 1, frage: 2 } as const; + ergebnis.vorschlaege.sort((a, b) => artReihe[a.art] - artReihe[b.art] || pos(a) - pos(b)); + return ergebnis; +} + +/** Aufgabendefinition „Stränge vorschlagen“. */ +export function straengeDefinition(auftrag: StrangAuftrag): AufgabenDefinition { + return { + name: meldungenIn('de')('nameStraenge'), + nameEn: meldungenIn('en')('nameStraenge'), + art: STRAENGE_ART, + rolle: 'pruefung', + system: SYSTEM_STRAENGE, + systemEn: SYSTEM_STRAENGE_EN, + ausgabeSchaetzung: 4000, + aufgabe: (kontext) => aufgabenText(kontext, auftrag), + schema: (kontext) => straengeSchema(kontext, auftrag), + auswerten: (daten, p) => werteStraengeAus(daten, p, auftrag), + }; +} + +/** Schlägt Stränge für das ganze Buch vor (eine Anfrage). Schreibt nichts. */ +export async function schlageStraengeVor( + leser: ProjektLeser, + transport: KiTransport, + einstellungen: KiEinstellungen | undefined, + optionen: AufgabenOptionen = {}, +): Promise> { + const auftrag = strangAuftrag(await ladeProjekt(leser)); + return fuehreAufgabeAus(leser, transport, einstellungen, straengeDefinition(auftrag), optionen); +} + +/** Schätzung vor dem Start (Tokens, Kosten). */ +export async function schaetzeStraenge( + leser: ProjektLeser, + transport: KiTransport, + einstellungen: KiEinstellungen | undefined, + oberflaeche: Sprache = 'de', +): Promise { + const auftrag = strangAuftrag(await ladeProjekt(leser)); + return schaetzeAufgabe(leser, transport, einstellungen, straengeDefinition(auftrag), oberflaeche); +} + +/** + * Neue Vorschläge in die Ablage mischen: entschiedene (übernommen/verworfen) bleiben und werden nie erneut + * vorgeschlagen; offene Vorschläge früherer Läufe werden durch das neue Ergebnis ersetzt. + */ +export function mischeStrangVorschlaege( + ablage: KiAblage | undefined, + neu: StrangVorschlag[], + lauf?: KiAblage['lauf'], +): { ablage: KiAblage; neu: number; bereitsEntschieden: number } { + const a = ablage ?? { version: 1 as const, eintraege: [] }; + const entschieden = a.eintraege.filter((v) => v.status !== 'neu'); + const schluessel = new Set(entschieden.map((v) => v.schluessel)); + const vorher = new Set(a.eintraege.map((v) => v.schluessel)); + const offen: StrangVorschlag[] = []; + let bereitsEntschieden = 0; + let zahl = 0; + for (const v of neu) { + if (schluessel.has(v.schluessel)) { + bereitsEntschieden++; + continue; + } + if (offen.some((x) => x.schluessel === v.schluessel)) continue; + offen.push({ ...v, status: 'neu' }); + if (!vorher.has(v.schluessel)) zahl++; + } + return { ablage: { ...a, version: 1, eintraege: [...entschieden, ...offen], ...(lauf ? { lauf } : {}) }, neu: zahl, bereitsEntschieden }; +} diff --git a/core/ki/vorschlaege.test.ts b/core/ki/vorschlaege.test.ts index d72aa9e..a8a2f84 100644 --- a/core/ki/vorschlaege.test.ts +++ b/core/ki/vorschlaege.test.ts @@ -226,6 +226,63 @@ describe('Antwortprüfung', () => { expect(r.bereitsAngelegt).toBe(1); }); + it('Spitznamen: Eigenname wird Name, Bezeichnung Alias; Spitzname einer angelegten Figur legt keine zweite an', async () => { + const fiete = beleg('funkstille', 'ob er die Krabben wieder für Fiete mitnimmt'); + const ole = beleg('grundriss', 'Ole sitzt auf der Fensterbank neben dem Oleander und isst einen Apfel.'); + const antwort = { + figuren: [ + { name: 'der Krabbenmann', aliase: ['Fiete', 'Fietje'], rolle: 'neben', kurz: '', belege: [fiete] }, + { name: 'der Mann am Fenster', aliase: ['Ole'], rolle: 'neben', kurz: '', belege: [ole] }, + { name: 'die Stimme am Telefon', aliase: ['die Anruferin'], rolle: 'neben', kurz: '', belege: [fiete] }, + ], + orte: [], + beziehungen: [ + { von: 'der Mann am Fenster', zu: 'der Krabbenmann', art: 'Bekannter', kategorie: 'sonstige', abKapitel: 'funkstille', unsicher: false, belege: [fiete], begruendung: '' }, + ], + }; + const r = await pruefe(antwort); + expect(r.figuren.map((f) => [f.schluessel, f.name, f.aliase, f.ziel])).toEqual([ + ['figur:fiete', 'Fiete', ['der Krabbenmann', 'Fietje'], undefined], + // F-10: der Spitzname einer angelegten Figur wird kein neuer Figurvorschlag, sondern ein Spitzname für Ole + ['spitzname:ole|der-mann-am-fenster', 'der Mann am Fenster', [], 'ole'], + // ohne Eigennamen bleibt die Bezeichnung der Name + ['figur:die-stimme-am-telefon', 'die Stimme am Telefon', ['die Anruferin'], undefined], + ]); + expect(r.bereitsAngelegt).toBe(0); + // die Beziehung nennt Ole beim Spitznamen und wird trotzdem der angelegten Figur zugeordnet + expect(r.beziehungen.map((b) => [b.von, b.zu])).toEqual([ + [{ typ: 'angelegt', id: 'ole', name: 'Ole' }, { typ: 'vorschlag', id: 'figur:fiete', name: 'Fiete' }], + ]); + + // früher unter dem Spitznamen verworfen → kommt unter dem Eigennamen nicht wieder + const daten = await ladeProjekt(fsMit()); + daten.kiVorschlaege = { + version: 1, + figuren: [{ schluessel: 'figur:der-krabbenmann', name: 'der Krabbenmann', aliase: [], rolle: 'neben', kurz: '', belege: [], status: 'verworfen' }], + orte: [], + beziehungen: [], + }; + const zweiter = pruefeVorschlaege(daten, antwort); + expect(zweiter.figuren.map((f) => f.name)).toEqual(['der Mann am Fenster', 'die Stimme am Telefon']); + expect(zweiter.bereitsEntschieden).toBe(2); + }); + + it('F-10: Spitznamen-Vorschlag ist offen, bis die Figur alle Namen trägt; Übernehmen ergänzt sie, Ablage hält „ziel“', async () => { + const ole = beleg('grundriss', 'Ole sitzt auf der Fensterbank neben dem Oleander und isst einen Apfel.'); + const daten = await ladeProjekt(fsMit()); + const r = pruefeVorschlaege(daten, { + figuren: [{ name: 'der Mann am Fenster', aliase: ['Ole', 'Fenster-Ole'], rolle: 'neben', kurz: '', belege: [ole] }], + orte: [], + beziehungen: [], + }); + expect(r.figuren).toEqual([expect.objectContaining({ ziel: 'ole', name: 'der Mann am Fenster', aliase: ['Fenster-Ole'] })]); + const { ablage } = mischeVorschlaege(undefined, r); + expect(parseKiVorschlaege(serializeKiVorschlaege(ablage)).figuren[0]!.ziel).toBe('ole'); + expect(offeneVorschlaege(ablage, daten).figuren).toHaveLength(1); + daten.figuren.ole = { ...daten.figuren.ole!, aliase: ['der Mann am Fenster', 'Fenster-Ole'] }; + expect(offeneVorschlaege(ablage, daten).figuren).toHaveLength(0); + }); + it('Ersatztext-Filter auf kurz, art und begruendung; art höchstens vier Wörter; abKapitel und Kategorie werden geprüft', async () => { const z = beleg('funkstille', 'ob er die Krabben wieder für Fiete mitnimmt'); const r = await pruefe({ diff --git a/core/ki/vorschlaege.ts b/core/ki/vorschlaege.ts index 1adf1db..f37416e 100644 --- a/core/ki/vorschlaege.ts +++ b/core/ki/vorschlaege.ts @@ -29,7 +29,7 @@ import { ladeProjekt } from '../projekt/lesen'; import { normalisiere } from '../text'; import { baueAnfrage, EFFORT, prefixZaehlBody, zaehlBody } from './anfrage'; import { KiFehler, LEERE_NUTZUNG, leseAntwort, type Nutzung } from './antwort'; -import { kiModell } from './anbieter'; +import { kiModell, mitKosten } from './anbieter'; import { kostenUsd, preisFuer } from './kosten'; import { meldungenIn } from './meldungen'; import { baueBuchKontext, type BuchKontext, type KontextFigur, type KontextOrt } from './kontext'; @@ -61,8 +61,9 @@ Du machst Vorschläge, die der Autor einzeln übernimmt oder verwirft. Du schrei Figuren (figuren): - Schlage jede Person vor, die im Text handelt, spricht oder mit Namen bzw. fester Bezeichnung erwähnt wird (zum Beispiel „der Mann am Fischstand“), und die noch nicht unter den angelegten Figuren steht. Ein namenloser Ich-Erzähler ist keine eigene Figur, wenn er erkennbar eine angelegte oder vorgeschlagene Figur ist. -- name: wie die Person im Text am häufigsten heißt. aliase: weitere Namen und Bezeichnungen derselben Person, Zeichen für Zeichen wie im Text, ohne Genitivformen. -- rolle: haupt für Figuren, die das Buch tragen (Erzähler, wiederkehrende zentrale Figuren), sonst neben. +- name: der Eigenname der Person (Vorname, Nachname oder beides), sobald der Text ihn irgendwo nennt, auch wenn sie meist anders genannt wird. Spitznamen, Titel und Bezeichnungen (zum Beispiel „die Chefin“, „der Alte“) gehören dann zu aliase. Nur wenn der Text keinen Eigennamen nennt, ist name die Bezeichnung, unter der die Person am häufigsten vorkommt. aliase: weitere Namen, Spitznamen und Bezeichnungen derselben Person, Zeichen für Zeichen wie im Text, ohne Genitivformen. +- Eine Person ist genau ein Vorschlag: Schlage niemanden zweimal unter verschiedenen Namen vor. +- rolle: haupt für Figuren, die das Buch tragen (Erzähler, wiederkehrende zentrale Figuren); statist für benannte Figuren, die höchstens in zwei Szenen vorkommen und keine eigene Handlung tragen (zum Beispiel eine Kellnerin, die einmal bedient); sonst neben. - kurz: eine sachliche Zeile, höchstens vier Wörter, was der Text über die Figur sagt (zum Beispiel „Kollege der Hauptfigur“ oder „Jugendfreundin“); sie steht als Untertitel auf der Karte im Beziehungsnetz. Keine Deutung, keine Vermutung über Verborgenes. - belege: eine bis drei Stellen, an denen die Figur vorkommt. @@ -85,7 +86,7 @@ Regeln für alle Belege: - kapitel ist die id des Kapitels, zitat eine zusammenhängende Passage, Zeichen für Zeichen aus dem dieses Kapitels kopiert, ohne Auslassungen, höchstens ein Satz. Vorschläge ohne wörtliches Zitat werden verworfen. Weitere Regeln: -- Angelegte Figuren und Orte (unter ) schlägst du nicht erneut vor, auch nicht unter einer anderen Schreibweise. Beziehungen zwischen angelegten Figuren darfst du vorschlagen. +- Angelegte Figuren und Orte (unter ) schlägst du nicht erneut vor, auch nicht unter einer anderen Schreibweise, einem Spitznamen oder einer Bezeichnung. Beziehungen zwischen angelegten Figuren darfst du vorschlagen. - Als „bewusst vage“ markierte Figuren und Stellen sind absichtlich mehrdeutig. Lege über sie nichts fest, was der Text offenlässt. - Lieber wenige belastbare Vorschläge als viele vage. Erfinde nichts, was nicht im Text steht.`; @@ -96,8 +97,9 @@ You make suggestions which the author adopts or discards one by one. You never w Characters (figuren): - Suggest every person who acts, speaks or is mentioned by name or by a fixed designation in the text (for example “the man at the kiosk”) and who is not yet among the characters set up. A nameless first-person narrator is not a separate character if they are recognisably a character that is set up or suggested. -- name: what the person is called most often in the text. aliase: further names and designations of the same person, character for character as in the text, without possessive forms (’s). -- rolle: haupt for characters who carry the book (narrators, recurring central characters), otherwise neben. +- name: the person's proper name (first name, surname or both) as soon as the text gives it anywhere, even if they are mostly called something else. Nicknames, titles and designations (for example “the boss”, “the old man”) then belong in aliase. Only if the text gives no proper name is name the designation the person appears under most often. aliase: further names, nicknames and designations of the same person, character for character as in the text, without possessive forms (’s). +- One person is exactly one suggestion: never suggest someone twice under different names. +- rolle: haupt for characters who carry the book (narrators, recurring central characters); statist for named characters who appear in at most two scenes and carry no plot of their own (for example a waitress who serves once); otherwise neben. - kurz: one factual line, at most four words, on what the text says about the character (for example “Paul's colleague” or “Kleinanzeigen match”); it appears as a subtitle on the card in the relationship web. No interpretation, no speculation about hidden things. - belege: one to three passages where the character appears. @@ -120,7 +122,7 @@ Rules for all evidence: - kapitel is the id of the chapter, zitat one continuous passage, copied character for character from the of that chapter, without omissions, at most one sentence. Suggestions without a verbatim quote are discarded. Further rules: -- Do not suggest characters and places that are set up (under ) again, not even under a different spelling. You may suggest relationships between characters that are set up. +- Do not suggest characters and places that are set up (under ) again, not even under a different spelling, a nickname or a designation. You may suggest relationships between characters that are set up. - Characters and passages marked “deliberately vague” are intentionally ambiguous. Settle nothing about them that the text leaves open. - Prefer a few solid suggestions to many vague ones. Invent nothing that is not in the text. @@ -149,7 +151,7 @@ export function vorschlagsSchema(kontext: BuchKontext): Record obj({ name: str, aliase: liste(str), - rolle: { type: 'string', enum: ['haupt', 'neben'] }, + rolle: { type: 'string', enum: ['haupt', 'neben', 'statist'] }, kurz: str, belege, }), @@ -212,6 +214,7 @@ interface VorschlagsLauf { rolle: Rolle; modell: string; body: Record; + einstellungen: KiEinstellungen | undefined; } function modellFuer(einstellungen: KiEinstellungen | undefined, rolle: Rolle): string { @@ -235,7 +238,7 @@ function planeVorschlaege(daten: ProjektDaten, einstellungen: KiEinstellungen | }), max_tokens: VORSCHLAG_MAX_TOKENS, }; - return { daten, kontext, rolle, modell, body }; + return { daten, kontext, rolle, modell, body, einstellungen }; } /** Der vollständige Messages-API-Body, wie er an das Modell ginge (z. B. um Antworten vorzubereiten). */ @@ -279,7 +282,7 @@ export async function schaetzeVorschlaege( eingabeTokens: voll.tokens, prefixTokens, ausgabeTokens: ausgabe, - kostenUsd: kostenUsd({ eingabe: voll.tokens - prefixTokens, ausgabe, cacheGelesen: 0, cacheGeschrieben: prefixTokens }, preis), + ...mitKosten(einstellungen, kostenUsd({ eingabe: voll.tokens - prefixTokens, ausgabe, cacheGelesen: 0, cacheGeschrieben: prefixTokens }, preis)), gezaehlt: voll.gezaehlt && prefix.gezaehlt, }; } @@ -301,6 +304,8 @@ export interface VorschlagsErgebnis { tokens: { eingabe: number; ausgabe: number; cacheGelesen: number }; modell: string; kostenUsd: number; + /** false = kein Listenpreis bekannt (OpenAI-kompatible Dienste); kostenUsd ist dann 0 */ + kostenBekannt?: false; /** ISO-Zeitpunkt des Laufs */ datum: string; } @@ -321,7 +326,7 @@ function ergebnisAus(lauf: VorschlagsLauf, roh: unknown, nutzung: Nutzung, jetzt ...geprueft, tokens: { eingabe: nutzung.eingabe + nutzung.cacheGeschrieben, ausgabe: nutzung.ausgabe, cacheGelesen: nutzung.cacheGelesen }, modell: lauf.modell, - kostenUsd: kostenUsd(nutzung, preisFuer(lauf.modell, lauf.rolle, lauf.kontext.text.length / 3)), + ...mitKosten(lauf.einstellungen, kostenUsd(nutzung, preisFuer(lauf.modell, lauf.rolle, lauf.kontext.text.length / 3))), datum: jetzt().toISOString(), }; } @@ -392,6 +397,9 @@ export async function werteVorschlagsAntwortAus( // --------------------------------------------------------------------------- export const figurSchluessel = (name: string): string => `figur:${loseEndeId(name)}`; +/** F-10: Spitznamen für eine angelegte Figur */ +export const spitznamenSchluessel = (figur: Id, namen: string[]): string => + `spitzname:${figur}|${namen.map(loseEndeId).sort().join('+')}`; export const ortSchluessel = (name: string): string => `ort:${loseEndeId(name)}`; /** „beziehung:|@“ – Paar alphabetisch, damit die Richtung keine Rolle spielt. */ @@ -429,6 +437,19 @@ function figurNamen(f: { name: string; aliase?: string[]; formen?: string[] }): return [f.name, ...(f.aliase ?? []), ...(f.formen ?? [])]; } +const MIT_ARTIKEL = /^(der|die|das|den|dem|des|the)\s/i; + +/** + * Hauptname eines Figurvorschlags: Ist der vorgeschlagene Name eine Bezeichnung mit Artikel („der Alte“) und steht + * unter den Aliasen ein Eigenname, wird der Eigenname zum Namen und die Bezeichnung zum Alias. + */ +export function hauptname(name: string, aliase: string[]): { name: string; aliase: string[] } { + if (!MIT_ARTIKEL.test(name)) return { name, aliase }; + const eigen = aliase.find((a) => !MIT_ARTIKEL.test(a) && /^\p{Lu}/u.test(a)); + if (!eigen) return { name, aliase }; + return { name: eigen, aliase: [name, ...aliase.filter((a) => a !== eigen)] }; +} + // --------------------------------------------------------------------------- // Antwortprüfung // --------------------------------------------------------------------------- @@ -530,17 +551,47 @@ export function pruefeVorschlaege( for (const r of rohFiguren as unknown[]) { const f = (r ?? {}) as Record; - const name = nameSauber(f.name); + const rohName = nameSauber(f.name); + const { name, aliase } = hauptname(rohName, aliaseSauber(f.aliase, rohName)); + const namen = [name, ...aliase]; const weg = (grund: string) => { verworfen.push({ grund, roh: r }); - if (name) figurVerworfen.set(namensForm(name), { grund, entschieden: false }); + for (const n of namen) if (n) figurVerworfen.set(namensForm(n), { grund, entschieden: false }); }; if (!name) { verworfen.push({ grund: m('figurOhneNamen'), roh: r }); continue; } - if (bekannteFigur(name)) { - bereitsAngelegt += 1; + // auch über einen Alias: „der Alte“ mit Alias „Ole“ ist die angelegte Figur Ole, keine neue + const angelegt = namen.map(bekannteFigur).find((x) => x); + if (angelegt) { + // Beziehungen dürfen die Figur unter den Namen dieses Vorschlags nennen + const bekannt = new Set(figurNamen(angelegt).map(namensForm)); + const neueNamen = namen.filter((n) => !bekannt.has(namensForm(n)) && !figurNachForm.has(namensForm(n))); + for (const n of namen) if (!figurNachForm.has(namensForm(n))) figurNachForm.set(namensForm(n), angelegt); + // F-10: Namen, die die angelegte Figur noch nicht kennt, als Spitznamen vorschlagen + const belege = neueNamen.length ? belegePruefen(f.belege) : []; + if (neueNamen.length === 0 || typeof belege === 'string') { + bereitsAngelegt += 1; + continue; + } + const schluessel = spitznamenSchluessel(angelegt.id, neueNamen); + if (figurEntschieden.has(schluessel)) { + bereitsEntschieden += 1; + continue; + } + const doppelt = figuren.find((x) => x.schluessel === schluessel); + if (doppelt) continue; + figuren.push({ + schluessel, + name: neueNamen[0]!, + aliase: neueNamen.slice(1), + rolle: angelegt.rolle, + kurz: '', + belege, + status: 'neu', + ziel: angelegt.id, + }); continue; } const belege = belegePruefen(f.belege); @@ -560,17 +611,18 @@ export function pruefeVorschlaege( for (const b of belege) if (!doppelt.belege.some((x) => x.kapitel === b.kapitel && x.text === b.text)) doppelt.belege.push(b); continue; } - const status = figurEntschieden.get(schluessel); + // entschieden auch unter einem anderen Namen derselben Person (z. B. früher als „der Alte“ vorgeschlagen) + const status = namen.map((n) => figurEntschieden.get(figurSchluessel(n))).find((x) => x); if (status) { bereitsEntschieden += 1; - if (status === 'verworfen') figurVerworfen.set(namensForm(name), { grund: m('figurVerworfen', { name }), entschieden: true }); + if (status === 'verworfen') for (const n of namen) figurVerworfen.set(namensForm(n), { grund: m('figurVerworfen', { name }), entschieden: true }); continue; } const vorschlag: FigurVorschlag = { schluessel, name, - aliase: aliaseSauber(f.aliase, name), - rolle: (f.rolle === 'haupt' ? 'haupt' : 'neben') satisfies FigurRolle, + aliase, + rolle: (f.rolle === 'haupt' || f.rolle === 'statist' ? f.rolle : 'neben') satisfies FigurRolle, kurz, belege, status: 'neu', @@ -782,10 +834,13 @@ export function mischeVorschlaege( return { ablage: { version: 1, figuren, orte, beziehungen, ...(stand ? { lauf: stand } : {}) }, neu: anzahlNeu, aktualisiert }; } -/** Angelegte Figur mit diesem Namen (Name, Alias oder Form), z. B. wenn Leon sie inzwischen selbst angelegt hat. */ -export function figurMitNamen(daten: ProjektDaten, name: string): Id | undefined { - const form = namensForm(name); - return Object.values(daten.figuren).find((f) => figurNamen(f).some((n) => namensForm(n) === form))?.id; +/** + * Angelegte Figur, die einen dieser Namen trägt (Name, Alias oder Form), z. B. wenn Leon sie inzwischen selbst + * angelegt hat. Mehrere Namen: Name und Aliase eines Vorschlags. + */ +export function figurMitNamen(daten: ProjektDaten, ...namen: string[]): Id | undefined { + const formen = new Set(namen.map(namensForm).filter(Boolean)); + return Object.values(daten.figuren).find((f) => figurNamen(f).some((n) => formen.has(namensForm(n))))?.id; } export function ortMitNamen(daten: ProjektDaten, name: string): Id | undefined { @@ -806,7 +861,8 @@ export function figurVerweisAufloesen( const v = ablage.figuren.find((f) => f.schluessel === verweis.id); if (!v || v.status === 'verworfen') return null; if (v.status === 'uebernommen') return v.figur && daten.figuren[v.figur] ? { figur: v.figur } : null; - const vorhanden = figurMitNamen(daten, v.name); + if (v.ziel) return daten.figuren[v.ziel] ? { figur: v.ziel } : null; + const vorhanden = figurMitNamen(daten, v.name, ...v.aliase); return vorhanden ? { figur: vorhanden } : { vorschlag: v }; } @@ -823,7 +879,17 @@ export interface OffeneVorschlaege { */ export function offeneVorschlaege(ablage: KiVorschlaege | undefined, daten: ProjektDaten): OffeneVorschlaege { if (!ablage) return { figuren: [], orte: [], beziehungen: [], anzahl: 0 }; - const figuren = ablage.figuren.filter((f) => f.status === 'neu' && !figurMitNamen(daten, f.name)); + const figuren = ablage.figuren.filter((f) => { + if (f.status !== 'neu') return false; + // Spitznamen-Vorschlag: offen, solange die Figur da ist und einen der Namen noch nicht trägt + if (f.ziel) { + const z = daten.figuren[f.ziel]; + if (!z) return false; + const bekannt = new Set(figurNamen(z).map(namensForm)); + return [f.name, ...f.aliase].some((n) => !bekannt.has(namensForm(n))); + } + return !figurMitNamen(daten, f.name, ...f.aliase); + }); const orte = ablage.orte.filter((o) => o.status === 'neu' && !ortMitNamen(daten, o.name)); const beziehungen = ablage.beziehungen.filter((b) => { if (b.status !== 'neu') return false; diff --git a/core/ki/zeit.ts b/core/ki/zeit.ts index 7202ebf..eb45a30 100644 --- a/core/ki/zeit.ts +++ b/core/ki/zeit.ts @@ -458,11 +458,11 @@ export function schaetzeZeitpunkte( function figurZeile(f: KontextFigur, sprache?: Sprache): string { if (sprache === 'en') { - const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'main character' : 'minor character']; + const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'main character' : f.rolle === 'statist' ? 'walk-on character' : 'minor character']; if (f.aliase.length) teile.push(`also called: ${f.aliase.join(', ')}`); return teile.join(' · '); } - const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'Hauptfigur' : 'Nebenfigur']; + const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'Hauptfigur' : f.rolle === 'statist' ? 'Statist' : 'Nebenfigur']; if (f.aliase.length) teile.push(`auch genannt: ${f.aliase.join(', ')}`); return teile.join(' · '); } diff --git a/core/model/types.ts b/core/model/types.ts index 65bbf80..72b4961 100644 --- a/core/model/types.ts +++ b/core/model/types.ts @@ -18,8 +18,14 @@ export type Realitaet = 'gesichert' | 'offen' | 'bewusst_vage'; // Projekt (projekt.md) // --------------------------------------------------------------------------- -/** KI-Anbieter: Claude in Microsoft Foundry oder die Claude API von Anthropic direkt. */ -export type KiAnbieter = 'foundry' | 'claude'; +/** + * KI-Anbieter: Claude in Microsoft Foundry, die Claude API von Anthropic direkt oder ein Dienst mit + * OpenAI-kompatibler Chat-Completions-API (OpenAI, OpenRouter, Qwen, Ollama, LM Studio …). + */ +export type KiAnbieter = 'foundry' | 'claude' | 'openai'; + +/** Vorlage für den OpenAI-kompatiblen Anbieter: bestimmt Standard-URL und kleine Unterschiede im Request. */ +export type KiVorlage = 'openai' | 'openrouter' | 'qwen' | 'ollama' | 'lmstudio' | 'eigen'; /** Modell je Aufgabe (Foundry: Deployment-Namen, Claude API: Modell-IDs). */ export interface KiModelle { @@ -39,6 +45,17 @@ export interface KiClaudeEinstellungen { modelle: KiModelle; } +/** OpenAI-kompatibler Dienst. Schlüssel (optional, lokal meist keiner) im Schlüsselbund, Konto „openai-kompatibel“. */ +export interface KiOpenAiEinstellungen { + vorlage: KiVorlage; + /** Basis-URL ohne /chat/completions, z. B. http://localhost:11434/v1 */ + endpoint: string; + /** Modellnamen des Dienstes; leer = das Modell einer anderen Rolle (kein Claude-Standard) */ + modelle: KiModelle; + /** Kontextlänge des Modells in Tokens; größere Anfragen werden vor dem Senden abgelehnt statt still gekürzt */ + kontextTokens?: number; +} + export interface KiEinstellungen { /** Foundry: z. B. https://.services.ai.azure.com/anthropic – ohne /v1/messages */ endpoint: string; @@ -55,6 +72,8 @@ export interface KiEinstellungen { anbieter?: KiAnbieter; /** Einstellungen der Claude API; bleiben erhalten, solange Foundry gewählt ist. */ claude?: KiClaudeEinstellungen; + /** Einstellungen des OpenAI-kompatiblen Dienstes; bleiben erhalten, solange ein anderer Anbieter gewählt ist. */ + openai?: KiOpenAiEinstellungen; } export interface Projekt { @@ -111,8 +130,10 @@ export interface KapitelPlan { /** Aktnummer; null = noch keinem Akt zugeordnet */ akt: number | null; status: KapitelStatus; - /** Titel der geplanten Szene, z. B. „Paul trifft Lotte am Lindenhof“ */ + /** Kurzbeschreibung in einer Zeile (Oberfläche „Kurzbeschreibung“, früher „Geplante Szene“), z. B. „Paul trifft Lotte am Lindenhof“ */ szene: string; + /** Funktion des Kapitels im Buch, z. B. „Opening“, „Wendepunkt“ (Vorschläge in der Oberfläche oder frei). Nicht an die KI. */ + funktion?: string; straenge: Id[]; figuren: Id[]; orte: Id[]; @@ -135,6 +156,9 @@ export interface Akt { antagonisten: Id[]; } +/** Haupthandlung oder Nebenhandlung */ +export type StrangRolle = 'haupt' | 'neben'; + /** plan/straenge.md – Frontmatter {straenge: Strang[]} */ export interface Strang { id: Id; @@ -143,6 +167,10 @@ export interface Strang { farbe: string; /** Wenn der Strang zu einer Gruppe (Antagonist) gehört */ gruppe?: Id; + /** Haupthandlung oder Nebenhandlung; fehlt = nicht festgelegt */ + rolle?: StrangRolle; + /** „Wofür steht der Strang?“ – Zweck des Erzählstrangs in Leons Worten */ + beschreibung?: string; } /** plan/ideen.md – Frontmatter {ideen: Idee[]} */ @@ -182,7 +210,18 @@ export interface Beziehung { unsicher?: boolean; } -export type FigurRolle = 'haupt' | 'neben'; +export type FigurRolle = 'haupt' | 'neben' | 'statist'; + +/** F-16: Kontext zu einem Spitznamen (der Name selbst steht in `Figur.aliase`, das die Erkennung speist). */ +export interface SpitznameKontext { + /** wie in `aliase` */ + name: string; + /** Figuren, die die Figur so nennen */ + von?: Id[]; + /** gilt ab diesem Kapitel (früher im Text → Hinweis) */ + abKapitel?: Id; + notiz?: string; +} /** figuren/.md – Body-Abschnitte „## Wahrheit hinter den Kulissen“ und „## Notizen“ (beide privat) */ export interface Figur { @@ -195,6 +234,7 @@ export interface Figur { /** Kurzbeschreibung für Listen und Netzkarten, z. B. „Jugendfreundin“, „Ich-Erzähler“ (Plan, nicht an die KI) */ kurz?: string; existenz: Realitaet; + /** Hauptgruppe (Farbe, Platz im Netz); maßgeblich für die Mitgliedschaft ist Gruppe.mitglieder (A-05: mehrere Gruppen) */ gruppe: Id | null; beziehungen: Beziehung[]; /** Freie Planungsfelder („Deine Planung“), Reihenfolge = Anzeige-Reihenfolge */ @@ -205,6 +245,13 @@ export interface Figur { notizen: string; /** Position der Karte im Beziehungsnetz */ netz?: { x: number; y: number }; + /** + * F-13: nach so vielen Kapiteln ohne Vorkommen melden; null = nie; fehlt = nach Rolle + * (Hauptfigur 3, Nebenfigur 6, Statist nie) + */ + ruheSchwelle?: number | null; + /** F-16: Kontext je Spitzname (nur für Namen in `aliase`) */ + spitznamen?: SpitznameKontext[]; } export type GruppenTyp = 'antagonist' | 'firma' | 'familie' | 'sonstige'; @@ -232,6 +279,8 @@ export interface Gruppe { notizen: string; /** Fläche im Beziehungsnetz */ netz?: { x: number; y: number; breite: number; hoehe: number }; + /** A-06: Rolle eines Mitglieds in der Gruppe (Figur-ID → z. B. „Chef“, „Praktikant“) */ + rollen?: Record; } /** orte/.md – Body = Beschreibung (Plan), Abschnitt „## Notizen“ privat */ @@ -349,7 +398,9 @@ export type HinweisTyp = | 'unbekannt' | 'struktur' | 'zeitleiste' - | 'sprache'; + | 'sprache' + /** lokal: Namen und Vorkommen von Figuren (F-11–F-13) */ + | 'figuren'; export interface Zitat { kapitel: Id; @@ -451,10 +502,14 @@ export interface ProjektDaten { kiZustandVorschlaege?: KiAblage; /** G3 .plotwerk/stimmen.json */ kiStimmen?: KiAblage; + /** F-17 .plotwerk/figurenfakten.json */ + kiFakten?: KiAblage; /** G6 .plotwerk/leser.json */ kiLeser?: KiAblage; /** G6 .plotwerk/fragen.json */ kiFragen?: KiAblage; + /** G2 .plotwerk/strang-vorschlaege.json */ + kiStrangVorschlaege?: KiAblage; } // --------------------------------------------------------------------------- @@ -487,6 +542,11 @@ export interface FigurVorschlag { status: VorschlagsStatus; /** nach der Übernahme: ID der angelegten Figur */ figur?: Id; + /** + * F-10: keine neue Figur, sondern Spitznamen (`name` und `aliase`) für diese angelegte Figur; + * Schlüssel „spitzname:|“ + */ + ziel?: Id; } export interface OrtVorschlag { @@ -684,6 +744,37 @@ export interface FadenPlan { strang?: Id; } +/** + * KI-Vorschlag zu Erzählsträngen (.plotwerk/strang-vorschlaege.json). Leon übernimmt oder verwirft jeden Vorschlag; + * Verworfenes wird nie erneut vorgeschlagen (Abgleich über `schluessel`). + * - zuordnung: Kapitel `kapitel[0]` gehört zum angelegten Strang `strang` (Schlüssel „zuordnung:|“) + * - neu: ein noch nicht angelegter Strang mit Name, Rolle, Zweck und Kapiteln (Schlüssel „strang:“) + * - frage: Rückfrage an Leon (Schlüssel „frage:“); seine Antwort geht beim nächsten Lauf mit + */ +export interface StrangVorschlag { + schluessel: string; + art: 'zuordnung' | 'neu' | 'frage'; + /** zuordnung: ID des angelegten Strangs · frage: betroffener Strang, falls einer */ + strang?: Id; + /** neu: Name des vorgeschlagenen Strangs */ + name?: string; + rolle?: StrangRolle; + /** neu: Zweck in einem sachlichen Satz (wird bei der Übernahme zu Strang.beschreibung) */ + beschreibung?: string; + /** Kapitel in Buchreihenfolge (zuordnung: genau eins) */ + kapitel: Id[]; + /** frage: die Rückfrage */ + frage?: string; + /** frage: Leons Antwort (dann status 'uebernommen') */ + antwort?: string; + begruendung: string; + /** wörtliche Zitate (E6), mindestens eins je Kapitel */ + belege: Zitat[]; + status: VorschlagsStatus; + /** neu: nach der Übernahme die ID des angelegten Strangs */ + angelegt?: Id; +} + /** plan/faeden.md – Frontmatter {ruheSchwelle, faeden}. Status bleibt in entscheidungen.faeden. */ export interface Fadenplanung { /** Standard-Schwelle in Kapiteln (K-02: 3) */ @@ -728,6 +819,25 @@ export interface StimmenBeobachtung { status: KiBeobachtungsStatus; } +/** + * F-17: Angabe über eine Figur, wie sie im Text steht (KI, wörtlich belegt). Widersprüche (gleiches Merkmal, + * verschiedene Werte) und Abweichungen von der Planung findet die App selbst (core/figuren/fakten.ts). + */ +export interface FigurFakt { + /** stabiler Schlüssel „fakt:||/“ */ + schluessel: string; + figur: Id; + /** Merkmal, z. B. „Alter“, „Beruf“, „Auto“ (möglichst wie ein Planfeld der Figur benannt) */ + merkmal: string; + /** Wert laut Text, kurz, z. B. „34“, „alter Saab 900“ */ + wert: string; + kapitel: Id; + /** Beleg (wörtlich, E6) */ + zitat: Zitat; + /** „verworfen“: Leon hält die Angabe für falsch erkannt; kommt nicht wieder */ + status: 'neu' | 'verworfen'; +} + // --------------------------------------------------------------------------- // G4 Editor – plan/marken.md // --------------------------------------------------------------------------- @@ -802,6 +912,10 @@ export interface LeserFrage { belege: Zitat[]; /** im Text (bis zu diesem Kapitel) schon beantwortet in Kapitel … */ beantwortetIn?: Id; + /** von Leon als Faden übernommen (FadenPlan-ID in plan/faeden.md) */ + faden?: Id; + /** von Leon als Notiz an die Belegstelle geheftet (Anmerkung-ID in anmerkungen.json) */ + anmerkung?: Id; } /** Plan-Abgleich der Leser-Simulation: „Plan sagt: … – belegt / nur angedeutet / nicht belegt“. */ @@ -817,8 +931,13 @@ export interface LeserPlanAbgleich { belege: Zitat[]; } -/** Leser-Simulation: Was weiß der Leser nach Kapitel n, welche Fragen hat er offen? */ +/** + * Leser-Simulation: Was weiß der Leser nach Kapitel n, welche Fragen hat er offen? Jede Simulation bleibt erhalten + * (mehrere je Kapitel), bis Leon sie löscht. + */ export interface LeserStand { + /** eindeutig je Simulation; ältere Stände ohne id: `standId()` leitet sie aus Kapitel und Datum ab */ + id?: Id; kapitel: Id; /** Hash der Kapiteltexte bis einschließlich `kapitel` (veraltet, wenn sich etwas ändert) */ basisHash: string; @@ -832,6 +951,8 @@ export interface LeserStand { spannung?: LeserAussage[]; /** Abgleich mit Leons Kapitelplänen (ältere Stände: „plan|status|kapitel|…“) */ plan?: LeserPlanAbgleich[]; + /** von Leon als erledigt markiert (ISO-Datum) */ + erledigt?: string; } /** Frage an das Buch (Chat) – Antworten nur mit Belegstellen. */ diff --git a/core/projekt/extern.ts b/core/projekt/extern.ts index b17100e..44a44ce 100644 --- a/core/projekt/extern.ts +++ b/core/projekt/extern.ts @@ -37,6 +37,8 @@ const WERKZEUG_DATEIEN: [string, keyof ProjektDaten][] = [ [PFADE.kiStimmen, 'kiStimmen'], [PFADE.kiLeser, 'kiLeser'], [PFADE.kiFragen, 'kiFragen'], + [PFADE.kiStraenge, 'kiStrangVorschlaege'], + [PFADE.kiFakten, 'kiFakten'], ]; const imOrdner = (pfad: string, ordner: string, endung: string) => diff --git a/core/projekt/lesen.ts b/core/projekt/lesen.ts index 3e3a7e3..c296cc8 100644 --- a/core/projekt/lesen.ts +++ b/core/projekt/lesen.ts @@ -25,6 +25,7 @@ import type { Recherche, RechercheEintrag, Schreibstatistik, + FigurFakt, StimmenBeobachtung, Szene, Tageszeit, @@ -45,13 +46,16 @@ import type { KapitelAnalyse, KapitelPlan, KiEinstellungen, + KiOpenAiEinstellungen, Motiv, Ort, Projekt, ProjektDaten, RealitaetsEintrag, + SpitznameKontext, Spur, Strang, + StrangVorschlag, TextAnker, Verweis, } from '../model/types'; @@ -103,10 +107,14 @@ export const PFADE = { kiZustand: '.plotwerk/zustand-vorschlaege.json', /** G3 KI-Beobachtungen zur Figurenstimme (KiAblage) */ kiStimmen: '.plotwerk/stimmen.json', + /** F-17 Figurenfakten aus dem Text (KiAblage) */ + kiFakten: '.plotwerk/figurenfakten.json', /** G6 Leser-Simulation (KiAblage) */ kiLeser: '.plotwerk/leser.json', /** G6 Fragen an das Buch (KiAblage) */ kiFragen: '.plotwerk/fragen.json', + /** G2 KI-Vorschläge zu Erzählsträngen (KiAblage) */ + kiStraenge: '.plotwerk/strang-vorschlaege.json', } as const; export const kapitelPfad = (id: Id): string => `${PFADE.manuskript}/${id}.md`; @@ -294,15 +302,31 @@ function parseKiModelle(v: unknown): KiEinstellungen['deployments'] { }; } -/** Ältere projekt.md ohne `anbieter`/`claude` bleiben gültig (gelten als Foundry). */ +/** Vorlagen des OpenAI-kompatiblen Anbieters (= KiVorlage; Standard Ollama wie in core/ki/anbieter.ts). */ +const KI_VORLAGEN = ['ollama', 'lmstudio', 'openai', 'openrouter', 'qwen', 'eigen'] as const; + +/** OpenAI-kompatibel: Modellnamen ohne Claude-Standard (leer bleibt leer). */ +function parseOpenAi(v: Record): KiOpenAiEinstellungen { + const m = istObjekt(v.modelle) ? v.modelle : {}; + const kontext = zahl(v.kontextTokens, 0); + return ohneUndefined({ + vorlage: eines(v.vorlage, KI_VORLAGEN, 'ollama'), + endpoint: s(v.endpoint), + modelle: { erkennung: s(m.erkennung), pruefung: s(m.pruefung), tiefencheck: s(m.tiefencheck) }, + kontextTokens: kontext > 0 ? Math.round(kontext) : undefined, + }); +} + +/** Ältere projekt.md ohne `anbieter`/`claude`/`openai` bleiben gültig (gelten als Foundry). */ function parseKi(v: unknown): KiEinstellungen | undefined { if (!istObjekt(v)) return undefined; const c = istObjekt(v.claude) ? v.claude : undefined; return ohneUndefined({ endpoint: s(v.endpoint), deployments: parseKiModelle(v.deployments), - anbieter: v.anbieter === 'claude' ? 'claude' : v.anbieter === 'foundry' ? 'foundry' : undefined, + anbieter: v.anbieter === 'claude' || v.anbieter === 'foundry' || v.anbieter === 'openai' ? v.anbieter : undefined, claude: c ? { endpoint: s(c.endpoint), modelle: parseKiModelle(c.modelle) } : undefined, + openai: istObjekt(v.openai) ? parseOpenAi(v.openai) : undefined, }); } @@ -360,6 +384,7 @@ export function parseKapitelPlan(text: string, id?: Id): KapitelPlan { akt: akt === null || Number.isNaN(akt) ? null : akt, status: eines(daten.status, ['geplant', 'entwurf', 'geschrieben'] as const, 'geplant'), szene: s(daten.szene), + ...(sOpt(daten.funktion) ? { funktion: s(daten.funktion) } : {}), straenge: texte(daten.straenge), figuren: texte(daten.figuren), orte: texte(daten.orte), @@ -384,11 +409,22 @@ export function parseAkte(text: string): Akt[] { })); } +const STRANG_ROLLEN = ['haupt', 'neben'] as const; + export function parseStraenge(text: string): Strang[] { const { daten } = parseFrontmatter(text); return liste(daten.straenge) .filter(istObjekt) - .map((st) => ohneUndefined({ id: s(st.id), name: s(st.name), farbe: s(st.farbe), gruppe: sOpt(st.gruppe) })) + .map((st) => + ohneUndefined({ + id: s(st.id), + name: s(st.name), + farbe: s(st.farbe), + gruppe: sOpt(st.gruppe), + rolle: STRANG_ROLLEN.find((r) => r === st.rolle), + beschreibung: sOpt(st.beschreibung), + }), + ) .filter((st) => st.id !== ''); } @@ -429,6 +465,21 @@ function parsePlanFelder(v: unknown): Record { return ergebnis; } +function parseSpitznamen(v: unknown): SpitznameKontext[] | undefined { + const l = liste(v) + .filter(istObjekt) + .filter((x) => s(x.name)) + .map((x) => + ohneUndefined({ + name: s(x.name), + von: x.von === undefined ? undefined : texte(x.von), + abKapitel: sOpt(x.abKapitel), + notiz: sOpt(x.notiz), + }), + ); + return l.length ? l : undefined; +} + export const FIGUR_ABSCHNITTE = { wahrheit: 'Wahrheit hinter den Kulissen', notizen: 'Notizen' } as const; export function parseFigur(text: string, id?: Id): Figur { @@ -444,7 +495,7 @@ export function parseFigur(text: string, id?: Id): Figur { name: s(daten.name) || fid, aliase: texte(daten.aliase), formen: daten.formen === undefined ? undefined : texte(daten.formen), - rolle: eines(daten.rolle, ['haupt', 'neben'] as const, 'neben'), + rolle: eines(daten.rolle, ['haupt', 'neben', 'statist'] as const, 'neben'), kurz: sOpt(daten.kurz), existenz: eines(daten.existenz, REALITAET, 'gesichert'), gruppe: sOpt(daten.gruppe) ?? null, @@ -453,6 +504,8 @@ export function parseFigur(text: string, id?: Id): Figur { wahrheit, notizen: verbinde(notizen, rest), netz, + ruheSchwelle: daten.ruheSchwelle === null ? null : typeof daten.ruheSchwelle === 'number' ? Math.max(1, Math.round(daten.ruheSchwelle)) : undefined, + spitznamen: parseSpitznamen(daten.spitznamen), }); } @@ -496,6 +549,7 @@ export function parseGruppe(text: string, id?: Id): Gruppe { praesenz, notizen: verbinde(notizen, rest), netz, + rollen: istObjekt(daten.rollen) && Object.keys(daten.rollen).length ? parsePlanFelder(daten.rollen) : undefined, }); } @@ -600,7 +654,7 @@ export function parseAnalyse(text: string, id?: Id): KapitelAnalyse { }; } -const HINWEIS_TYPEN = ['pruefen', 'schreibweise', 'offener_faden', 'unbekannt', 'struktur', 'zeitleiste', 'sprache'] as const; +const HINWEIS_TYPEN = ['pruefen', 'schreibweise', 'offener_faden', 'unbekannt', 'struktur', 'zeitleiste', 'sprache', 'figuren'] as const; export function parseHinweise(text: string): Hinweis[] { return liste(json(text)) @@ -652,11 +706,12 @@ export function parseKiVorschlaege(text: string): KiVorschlaegeT { schluessel: s(f.schluessel), name: s(f.name), aliase: texte(f.aliase), - rolle: eines(f.rolle, ['haupt', 'neben'] as const, 'neben'), + rolle: eines(f.rolle, ['haupt', 'neben', 'statist'] as const, 'neben'), kurz: s(f.kurz), belege: parseBelege(f.belege), status: eines(f.status, VORSCHLAGS_STATUS, 'neu'), figur: sOpt(f.figur), + ziel: sOpt(f.ziel), }), ), orte: liste(d.orte) @@ -1013,6 +1068,22 @@ export function parseKiZustandVorschlaege(text: string): KiAblage { + return parseKiAblage(text, (v) => { + const zitat = parseZitate([v.zitat])[0]; + if (!s(v.schluessel) || !s(v.figur) || !s(v.merkmal) || !zitat) return null; + return { + schluessel: s(v.schluessel), + figur: s(v.figur), + merkmal: s(v.merkmal), + wert: s(v.wert), + kapitel: s(v.kapitel) || zitat.kapitel, + zitat, + status: eines(v.status, ['neu', 'verworfen'] as const, 'neu'), + }; + }); +} + export function parseKiStimmen(text: string): KiAblage { return parseKiAblage(text, (v) => { if (!s(v.schluessel) || !s(v.figur)) return null; @@ -1029,10 +1100,35 @@ export function parseKiStimmen(text: string): KiAblage { }); } +const STRANG_VORSCHLAG_ARTEN = ['zuordnung', 'neu', 'frage'] as const; + +export function parseKiStrangVorschlaege(text: string): KiAblage { + return parseKiAblage(text, (v) => { + const art = STRANG_VORSCHLAG_ARTEN.find((a) => a === v.art); + if (!s(v.schluessel) || !art) return null; + return ohneUndefined({ + schluessel: s(v.schluessel), + art, + strang: sOpt(v.strang), + name: sOpt(v.name), + rolle: STRANG_ROLLEN.find((r) => r === v.rolle), + beschreibung: sOpt(v.beschreibung), + kapitel: texte(v.kapitel), + frage: sOpt(v.frage), + antwort: sOpt(v.antwort), + begruendung: s(v.begruendung), + belege: parseZitate(v.belege), + status: eines(v.status, VORSCHLAGS_STATI, 'neu'), + angelegt: sOpt(v.angelegt), + }); + }); +} + export function parseKiLeser(text: string): KiAblage { return parseKiAblage(text, (v) => { if (!s(v.kapitel)) return null; return { + ...ohneUndefined({ id: sOpt(v.id) }), kapitel: s(v.kapitel), basisHash: s(v.basisHash), weiss: liste(v.weiss) @@ -1040,7 +1136,14 @@ export function parseKiLeser(text: string): KiAblage { .map((a) => ({ text: s(a.text), belege: parseZitate(a.belege) })), fragen: liste(v.fragen) .filter(istObjekt) - .map((f, i) => ohneUndefined({ id: s(f.id) || `frage-${i + 1}`, frage: s(f.frage), belege: parseZitate(f.belege), beantwortetIn: sOpt(f.beantwortetIn) })), + .map((f, i) => ohneUndefined({ + id: s(f.id) || `frage-${i + 1}`, + frage: s(f.frage), + belege: parseZitate(f.belege), + beantwortetIn: sOpt(f.beantwortetIn), + faden: sOpt(f.faden), + anmerkung: sOpt(f.anmerkung), + })), modell: s(v.modell), datum: s(v.datum), ...ohneUndefined({ @@ -1054,6 +1157,7 @@ export function parseKiLeser(text: string): KiAblage { : []; }) : undefined, + erledigt: sOpt(v.erledigt), }), }; }); @@ -1177,12 +1281,14 @@ export async function ladeProjektMitBericht(leser: ProjektLeser, sprache: LadeSp lies(PFADE.lektorat, parseLektorat), lies(PFADE.recherche, parseRecherche), ]); - const [kiZeitVorschlaege, kiZustandVorschlaege, kiStimmen, kiLeser, kiFragen] = await Promise.all([ + const [kiZeitVorschlaege, kiZustandVorschlaege, kiStimmen, kiLeser, kiFragen, kiStrangVorschlaege, kiFakten] = await Promise.all([ lies(PFADE.kiZeit, parseKiZeitVorschlaege), lies(PFADE.kiZustand, parseKiZustandVorschlaege), lies(PFADE.kiStimmen, parseKiStimmen), lies(PFADE.kiLeser, parseKiLeser), lies(PFADE.kiFragen, parseKiFragen), + lies(PFADE.kiStraenge, parseKiStrangVorschlaege), + lies(PFADE.kiFakten, parseKiFakten), ]); const projekt: Projekt = projektRoh ?? { version: 1, titel: '', kapitel: [], notiz: '' }; @@ -1237,6 +1343,8 @@ export async function ladeProjektMitBericht(leser: ProjektLeser, sprache: LadeSp ...(kiStimmen ? { kiStimmen } : {}), ...(kiLeser ? { kiLeser } : {}), ...(kiFragen ? { kiFragen } : {}), + ...(kiStrangVorschlaege ? { kiStrangVorschlaege } : {}), + ...(kiFakten ? { kiFakten } : {}), }, warnungen, }; diff --git a/core/projekt/schreiben.ts b/core/projekt/schreiben.ts index 49713ec..998d092 100644 --- a/core/projekt/schreiben.ts +++ b/core/projekt/schreiben.ts @@ -20,6 +20,7 @@ import type { Markenliste, Recherche, Schreibstatistik, + FigurFakt, StimmenBeobachtung, Szene, Zeitleiste, @@ -38,6 +39,7 @@ import type { Ort, Projekt, Strang, + StrangVorschlag, } from '../model/types'; import { versionsPfadMitZusatz } from '../versionen/index'; import type { ProjektDateisystem } from './fs'; @@ -61,23 +63,24 @@ import { const S = { projekt: ['version', 'titel', 'kapitel', 'ki', 'angaben'], angaben: ['autor', 'untertitel', 'genre', 'sprache'], - ki: ['endpoint', 'deployments', 'anbieter', 'claude'], + ki: ['endpoint', 'deployments', 'anbieter', 'claude', 'openai'], deployments: ['erkennung', 'pruefung', 'tiefencheck'], claude: ['endpoint', 'modelle'], + openai: ['vorlage', 'endpoint', 'modelle', 'kontextTokens'], kapitel: ['id', 'titel'], - plan: ['id', 'titel', 'akt', 'status', 'szene', 'straenge', 'figuren', 'orte', 'abweichungen', 'realitaet', 'zeit', 'szenen'], + plan: ['id', 'titel', 'akt', 'status', 'szene', 'funktion', 'straenge', 'figuren', 'orte', 'abweichungen', 'realitaet', 'zeit', 'szenen'], zeitpunkt: ['tag', 'wochentag', 'datum', 'tageszeit', 'uhrzeit', 'dauer', 'sicherheit', 'beleg', 'notiz'], szene: ['id', 'index', 'titel', 'ort', 'figuren', 'zeit', 'notiz'], abweichung: ['id', 'notiz', 'entscheidung', 'datum', 'alterPlan'], realitaet: ['id', 'text', 'stufe', 'anker'], anker: ['kapitel', 'exakt', 'davor', 'danach'], akt: ['nummer', 'titel', 'quintessenz', 'wendepunkt', 'antagonisten'], - strang: ['id', 'name', 'farbe', 'gruppe'], + strang: ['id', 'name', 'farbe', 'gruppe', 'rolle', 'beschreibung'], idee: ['id', 'titel', 'notiz', 'straenge'], motiv: ['id', 'name', 'aliase'], - figur: ['id', 'name', 'aliase', 'formen', 'rolle', 'kurz', 'existenz', 'gruppe', 'beziehungen', 'plan', 'netz'], + figur: ['id', 'name', 'aliase', 'formen', 'rolle', 'kurz', 'existenz', 'gruppe', 'beziehungen', 'plan', 'netz', 'ruheSchwelle', 'spitznamen'], beziehung: ['zu', 'art', 'abKapitel', 'kategorie', 'strang', 'notiz', 'unsicher'], - gruppe: ['id', 'name', 'typ', 'status', 'mitglieder', 'strang', 'spuren', 'praesenz', 'netz'], + gruppe: ['id', 'name', 'typ', 'status', 'mitglieder', 'strang', 'spuren', 'praesenz', 'netz', 'rollen'], spur: ['id', 'text', 'kapitel', 'anker', 'status'], ort: ['id', 'name', 'aliase', 'in'], hinweis: ['schluessel', 'typ', 'titel', 'zitate', 'erklaerung', 'quelle', 'offen', 'status'], @@ -149,9 +152,20 @@ export function serializeProjekt(p: Projekt, vorhanden?: string | null): string const claude = p.ki?.claude ? ordne({ endpoint: p.ki.claude.endpoint, modelle: ordne(p.ki.claude.modelle, S.deployments, altClaude?.modelle) }, S.claude, altClaude) : altClaude; + // Ebenso die Einstellungen des OpenAI-kompatiblen Dienstes. + const altOpenAi = altKi && istObjekt(altKi.openai) ? altKi.openai : undefined; + const openai = p.ki?.openai + ? ordne({ ...p.ki.openai, modelle: ordne(p.ki.openai.modelle, S.deployments, altOpenAi?.modelle) }, S.openai, altOpenAi) + : altOpenAi; const ki = p.ki ? ordne( - { endpoint: p.ki.endpoint, deployments: ordne(p.ki.deployments, S.deployments, altKi?.deployments), anbieter: p.ki.anbieter, claude }, + { + endpoint: p.ki.endpoint, + deployments: ordne(p.ki.deployments, S.deployments, altKi?.deployments), + anbieter: p.ki.anbieter, + claude, + openai, + }, S.ki, altKi, ) @@ -315,7 +329,7 @@ export function serializeHinweise(hinweise: Hinweis[]): string { /** Schlüsselreihenfolge = Typdefinitionen KiVorschlaege, FigurVorschlag, OrtVorschlag, BeziehungsVorschlag */ const SV = { ablage: ['version', 'figuren', 'orte', 'beziehungen', 'lauf'], - figur: ['schluessel', 'name', 'aliase', 'rolle', 'kurz', 'belege', 'status', 'figur'], + figur: ['schluessel', 'name', 'aliase', 'rolle', 'kurz', 'belege', 'status', 'figur', 'ziel'], ort: ['schluessel', 'name', 'aliase', 'in', 'belege', 'status', 'ort'], beziehung: ['schluessel', 'von', 'zu', 'art', 'kategorie', 'abKapitel', 'unsicher', 'belege', 'begruendung', 'status'], verweis: ['typ', 'id', 'name'], @@ -372,11 +386,13 @@ const SW = { zeitVorschlag: ['schluessel', 'kapitel', 'szene', 'zeit', 'belege', 'begruendung', 'status'], zustandVorschlag: ['schluessel', 'figur', 'kapitel', 'szene', 'zustand', 'belege', 'begruendung', 'status'], stimme: ['schluessel', 'figur', 'kapitel', 'titel', 'erklaerung', 'zitate', 'vergleich', 'status'], - leser: ['kapitel', 'basisHash', 'weiss', 'fragen', 'vermutungen', 'spannung', 'plan', 'modell', 'datum'], + fakt: ['schluessel', 'figur', 'merkmal', 'wert', 'kapitel', 'zitat', 'status'], + leser: ['id', 'kapitel', 'basisHash', 'weiss', 'fragen', 'vermutungen', 'spannung', 'plan', 'modell', 'datum', 'erledigt'], leserPlan: ['kapitel', 'aussage', 'status', 'erklaerung', 'belege'], leserAussage: ['text', 'belege'], - leserFrage: ['id', 'frage', 'belege', 'beantwortetIn'], + leserFrage: ['id', 'frage', 'belege', 'beantwortetIn', 'faden', 'anmerkung'], buchFrage: ['id', 'frage', 'datum', 'antwort', 'belege', 'imBuch', 'modell', 'kostenUsd'], + strangVorschlag: ['schluessel', 'art', 'strang', 'name', 'rolle', 'beschreibung', 'kapitel', 'frage', 'antwort', 'begruendung', 'belege', 'status', 'angelegt'], } as const; const zitate = (z: readonly object[]) => z.map((x) => ordne(x, S.zitat)); @@ -485,6 +501,12 @@ export const serializeKiZustandVorschlaege = (a: KiAblage) => export const serializeKiStimmen = (a: KiAblage) => serializeKiAblage(a, (v) => ordne({ ...v, zitate: zitate(v.zitate), vergleich: zitate(v.vergleich) }, SW.stimme)); +export const serializeKiStrangVorschlaege = (a: KiAblage) => + serializeKiAblage(a, (v) => ordne({ ...v, kapitel: [...v.kapitel], belege: zitate(v.belege) }, SW.strangVorschlag)); + +export const serializeKiFakten = (a: KiAblage) => + serializeKiAblage(a, (v) => ordne({ ...v, zitat: zitate([v.zitat])[0] }, SW.fakt)); + export const serializeKiLeser = (a: KiAblage) => serializeKiAblage(a, (v) => ordne( @@ -610,6 +632,8 @@ export const speichereKiZustandVorschlaege = (fs: ProjektDateisystem, a: KiAblag export const speichereKiStimmen = (fs: ProjektDateisystem, a: KiAblage) => schreibeWennGeaendert(fs, PFADE.kiStimmen, serializeKiStimmen(a), true); +export const speichereKiFakten = (fs: ProjektDateisystem, a: KiAblage) => + schreibeWennGeaendert(fs, PFADE.kiFakten, serializeKiFakten(a), true); export const speichereKiLeser = (fs: ProjektDateisystem, a: KiAblage) => schreibeWennGeaendert(fs, PFADE.kiLeser, serializeKiLeser(a), true); @@ -617,6 +641,9 @@ export const speichereKiLeser = (fs: ProjektDateisystem, a: KiAblage export const speichereKiFragen = (fs: ProjektDateisystem, a: KiAblage) => schreibeWennGeaendert(fs, PFADE.kiFragen, serializeKiFragen(a), true); +export const speichereKiStrangVorschlaege = (fs: ProjektDateisystem, a: KiAblage) => + schreibeWennGeaendert(fs, PFADE.kiStraenge, serializeKiStrangVorschlaege(a), true); + // --------------------------------------------------------------------------- // Schnappschüsse und Entfernen // --------------------------------------------------------------------------- diff --git a/core/recherche/index.ts b/core/recherche/index.ts index f9bb79b..34b829d 100644 --- a/core/recherche/index.ts +++ b/core/recherche/index.ts @@ -8,6 +8,7 @@ * - Recherche geht nie an die KI (E12). */ import { fmt, localeVon, texteJe, type Sprache } from '../i18n'; +import { nachRolle } from '../figuren/namen'; import type { Id, ProjektDaten, Recherche, RechercheArt, RechercheEintrag, Szene, Verweis, VerweisTyp } from '../model/types'; export const RECHERCHE_ORDNER = 'recherche/dateien'; @@ -316,7 +317,7 @@ export interface HeftZiel { /** Alles, woran Leon einen Eintrag heften kann: Figuren, Orte, Kapitel, Szenen (in dieser Reihenfolge). */ export function heftZiele(daten: ProjektDaten, sprache: Sprache = 'de'): HeftZiel[] { const liste: HeftZiel[] = []; - const figuren = Object.values(daten.figuren).sort((a, b) => (a.rolle === b.rolle ? a.name.localeCompare(b.name, 'de') : a.rolle === 'haupt' ? -1 : 1)); + const figuren = Object.values(daten.figuren).sort(nachRolle((a, b) => a.name.localeCompare(b.name, 'de'))); for (const f of figuren) liste.push({ verweis: { typ: 'figur', id: f.id }, art: 'figur', name: f.name, suche: [f.name, ...f.aliase] }); for (const o of Object.values(daten.orte).sort((a, b) => a.name.localeCompare(b.name, 'de'))) liste.push({ verweis: { typ: 'ort', id: o.id }, art: 'ort', name: o.name, suche: [o.name, ...o.aliase] }); diff --git a/core/struktur/straenge.ts b/core/struktur/straenge.ts new file mode 100644 index 0000000..ac0de7a --- /dev/null +++ b/core/struktur/straenge.ts @@ -0,0 +1,183 @@ +/** + * Erzählstränge (G2 Plot-Struktur): anlegen, Haupt-/Nebenhandlung, KI-Vorschläge übernehmen oder verwerfen. + * Besitzer: G2. Rein, ohne DOM und ohne Speichern – der Store schreibt plan/straenge.md, die Kapitelpläne und + * .plotwerk/strang-vorschlaege.json. + */ +import type { Fadenplanung, Id, KapitelPlan, KiAblage, ProjektDaten, Strang, StrangVorschlag } from '../model/types'; +import { leereFadenplanung, standardPlan } from '../projekt/lesen'; +import { fadenPlanAendern, fadenSlug, kapitelMitText, type FadenKarte } from './faeden'; + +/** Farben für neue Stränge (Hex wie in plan/straenge.md; die Anzeige läuft über Tokens). */ +export const STRANG_FARBEN = [ + { hex: '#3D5A75', name: 'blau' }, + { hex: '#C2405C', name: 'rosa' }, + { hex: '#A86A12', name: 'ocker' }, + { hex: '#6A4C9C', name: 'violett' }, + { hex: '#2F6B45', name: 'gruen' }, + { hex: '#4A4A4F', name: 'grau' }, +] as const; + +/** Erste noch nicht benutzte Farbe, sonst reihum. */ +export function freieStrangFarbe(straenge: Strang[]): string { + const frei = STRANG_FARBEN.find((f) => !straenge.some((s) => s.farbe.toLowerCase() === f.hex.toLowerCase())); + return (frei ?? STRANG_FARBEN[straenge.length % STRANG_FARBEN.length]!).hex; +} + +/** Eindeutige Strang-ID aus dem Namen (Slug, bei Bedarf mit Zähler). */ +export function neueStrangId(straenge: Strang[], name: string): Id { + const belegt = new Set(straenge.map((s) => s.id)); + const basis = fadenSlug(name); + let id = basis; + for (let i = 2; belegt.has(id); i++) id = `${basis}-${i}`; + return id; +} + +/** Haupthandlung zuerst, dann nicht festgelegte, dann Nebenhandlungen; sonst Reihenfolge der Datei. */ +export function straengeSortiert(straenge: Strang[]): Strang[] { + const rang = (s: Strang) => (s.rolle === 'haupt' ? 0 : s.rolle === 'neben' ? 2 : 1); + return straenge.map((s, i) => ({ s, i })).sort((a, b) => rang(a.s) - rang(b.s) || a.i - b.i).map((x) => x.s); +} + +/** Kapitelplan oder Standardplan (Kapitel ohne Plan-Datei). */ +function planVon(daten: ProjektDaten, kapitel: Id): KapitelPlan | null { + const p = daten.plaene[kapitel]; + if (p) return p; + const k = daten.kapitel[kapitel]; + return k ? standardPlan(k) : null; +} + +/** + * Noch offene Vorschläge, die zum aktuellen Stand passen: Zuordnungen zu gelöschten Strängen oder schon im Plan + * eingetragene fallen weg, ebenso neue Stränge, die es inzwischen unter diesem Namen gibt. + * `kapitel`: nur Vorschläge, die dieses Kapitel betreffen. + */ +export function offeneStrangVorschlaege(daten: ProjektDaten, kapitel?: Id): StrangVorschlag[] { + const namen = new Set(daten.straenge.map((s) => fadenSlug(s.name))); + const ids = new Set(daten.straenge.map((s) => s.id)); + return (daten.kiStrangVorschlaege?.eintraege ?? []).filter((v) => { + if (v.status !== 'neu') return false; + if (kapitel && !v.kapitel.includes(kapitel)) return false; + if (v.art === 'zuordnung') { + const k = v.kapitel[0]; + return !!v.strang && ids.has(v.strang) && !!k && !!daten.kapitel[k] && !daten.plaene[k]?.straenge.includes(v.strang); + } + if (v.art === 'neu') return !!v.name && !namen.has(fadenSlug(v.name)); + return true; + }); +} + +function mitStatus(ablage: KiAblage, schluessel: string, teil: Partial): KiAblage { + return { ...ablage, eintraege: ablage.eintraege.map((v) => (v.schluessel === schluessel ? { ...v, ...teil } : v)) }; +} + +export interface StrangUebernahme { + /** neue Strangliste (nur bei „neu“ geändert) */ + straenge: Strang[]; + /** geänderte Kapitelpläne */ + plaene: KapitelPlan[]; + ablage: KiAblage; + /** angelegter oder zugeordneter Strang */ + strang: Id | null; +} + +/** + * Vorschlag übernehmen. Zuordnung: Strang in den Kapitelplan. Neuer Strang: anlegen (Name, Rolle, Zweck, freie + * Farbe) und in die Pläne der gewählten Kapitel (`kapitel`, Standard: alle vorgeschlagenen) eintragen. + * Rückfragen werden über `strangFrageBeantworten` erledigt. null = Vorschlag fehlt oder passt nicht mehr. + */ +export function strangVorschlagUebernehmen(daten: ProjektDaten, schluessel: string, kapitel?: Id[]): StrangUebernahme | null { + const ablage = daten.kiStrangVorschlaege; + const v = ablage?.eintraege.find((x) => x.schluessel === schluessel); + if (!ablage || !v || v.status !== 'neu' || v.art === 'frage') return null; + const eintragen = (strang: Id, ziele: Id[]): KapitelPlan[] => + ziele + .map((k) => planVon(daten, k)) + .filter((p): p is KapitelPlan => !!p && !p.straenge.includes(strang)) + .map((p) => ({ ...p, straenge: [...p.straenge, strang] })); + + if (v.art === 'zuordnung') { + if (!v.strang || !daten.straenge.some((s) => s.id === v.strang)) return null; + return { + straenge: daten.straenge, + plaene: eintragen(v.strang, v.kapitel.slice(0, 1)), + ablage: mitStatus(ablage, schluessel, { status: 'uebernommen' }), + strang: v.strang, + }; + } + + const name = v.name?.trim(); + if (!name) return null; + const id = neueStrangId(daten.straenge, name); + const strang: Strang = { + id, + name, + farbe: freieStrangFarbe(daten.straenge), + ...(v.rolle ? { rolle: v.rolle } : {}), + ...(v.beschreibung ? { beschreibung: v.beschreibung } : {}), + }; + const ziele = (kapitel ?? v.kapitel).filter((k) => v.kapitel.includes(k)); + return { + straenge: [...daten.straenge, strang], + plaene: eintragen(id, ziele), + ablage: mitStatus(ablage, schluessel, { status: 'uebernommen', angelegt: id }), + strang: id, + }; +} + +/** Vorschlag verwerfen – wird nie wieder vorgeschlagen. */ +export function strangVorschlagVerwerfen(ablage: KiAblage, schluessel: string): KiAblage { + return mitStatus(ablage, schluessel, { status: 'verworfen' }); +} + +/** Rückfrage beantworten; die Antwort geht beim nächsten Lauf mit. Leere Antwort = zurück auf offen. */ +export function strangFrageBeantworten(ablage: KiAblage, schluessel: string, antwort: string): KiAblage { + const a = antwort.trim(); + return mitStatus(ablage, schluessel, a ? { status: 'uebernommen', antwort: a } : { status: 'neu', antwort: undefined }); +} + +// --------------------------------------------------------------------------- +// Faden → Strang: ein von der Prüfung gefundenes loses Ende (z. B. „Der Deloitte-Auftrag“) als Strang übernehmen +// --------------------------------------------------------------------------- + +/** Kapitel mit Text, in denen der Faden vorkommt (eröffnet oder aufgegriffen), in Buchreihenfolge. */ +export function fadenKapitel(daten: Pick, karte: Pick): Id[] { + const mitText = kapitelMitText(daten); + const drin = new Set([karte.eroeffnetIn, ...karte.aufgegriffenIn].filter((k): k is Id => !!k)); + return daten.projekt.kapitel.filter((k) => drin.has(k) && mitText.has(k)); +} + +/** Fäden ohne Strang, die als Strang taugen (kommen in mindestens einem Kapitel mit Text vor); optional nur die eines Kapitels. */ +export function faedenOhneStrang(daten: ProjektDaten, karten: FadenKarte[], kapitel?: Id): FadenKarte[] { + const ids = new Set(daten.straenge.map((s) => s.id)); + return karten.filter((k) => { + if (k.strang && ids.has(k.strang)) return false; + const kap = fadenKapitel(daten, k); + return kap.length > 0 && (!kapitel || kap.includes(kapitel)); + }); +} + +export interface FadenAlsStrang { + straenge: Strang[]; + plaene: KapitelPlan[]; + fadenplanung: Fadenplanung; + strang: Id; +} + +/** + * Legt aus einem Faden einen Strang an (Name des Fadens, freie Farbe), trägt ihn in die Pläne aller Kapitel ein, in + * denen der Faden vorkommt, und verbindet den Faden mit dem Strang (plan/faeden.md, Feld `strang`). + */ +export function strangAusFaden(daten: ProjektDaten, karte: FadenKarte): FadenAlsStrang { + const id = neueStrangId(daten.straenge, karte.name); + const strang: Strang = { id, name: karte.name, farbe: freieStrangFarbe(daten.straenge), ...(karte.notiz?.trim() ? { beschreibung: karte.notiz.trim() } : {}) }; + const plaene = fadenKapitel(daten, karte) + .map((k) => planVon(daten, k)) + .filter((p): p is KapitelPlan => !!p) + .map((p) => ({ ...p, straenge: [...p.straenge, id] })); + return { + straenge: [...daten.straenge, strang], + plaene, + fadenplanung: fadenPlanAendern(daten.fadenplanung ?? leereFadenplanung(), karte.id, { strang: id }), + strang: id, + }; +} diff --git a/core/struktur/struktur.en.test.ts b/core/struktur/struktur.en.test.ts index ca7e29e..6356c3f 100644 --- a/core/struktur/struktur.en.test.ts +++ b/core/struktur/struktur.en.test.ts @@ -80,7 +80,7 @@ describe('weitere lokale Hinweise (en)', () => { it('Hinweis-Typnamen', () => { expect(hinweisTypName('schreibweise', 'en')).toBe('Spelling'); - expect(hinweisTypName('offener_faden')).toBe('Offener Faden'); + expect(hinweisTypName('offener_faden')).toBe('Loses Ende'); expect(Object.keys(hinweisTypNamen('en'))).toEqual(Object.keys(HINWEIS_TYP_NAME)); }); }); diff --git a/docs/abnahme.md b/docs/abnahme.md index 8b878c9..7971a39 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 @@ -72,6 +77,17 @@ Legende Nachweis: Testdatei (automatisch) · „Browser“ = im Browser-Demo bed | F-04 | Notizen, Auftritte als Kapitelstreifen, Beziehungen | erfüllt | Browser | | | F-05 | Hinweis, wenn ein Plan-Feld dem Text widerspricht (Soll) | erfüllt | `core/ki/ki.test.ts` (F-05), `Figurenkarte.tsx` (`hinweiseFuerFigur`) | Plan-Felder von Figuren und Kapitelplänen gehen an das Modell (Leon, 2026-10-09). Hinweise mit Plan-Bezug (Figur/Kapitel, Feld) werden geprüft; bei bewusst vagen Figuren verworfen. Demo: Paul „Alkohol: nein, trinkt keinen“ ↔ Aperol Spritz in Kap. 9. Live nur mit Demo-KI belegt. | | F-06 | Figur vorab anlegen | erfüllt | `planung.test.ts` | | +| F-07 | Figuren zusammenführen | erfüllt | `core/figuren/zusammenfuehren.test.ts`, `gruppenFiguren.test.ts`, Browser | | +| F-08 | Spitznamen-Feld | erfüllt | `core/ki/vorschlaege.test.ts`, Browser | Feld gab es als „Auch genannt“; neu beschriftet, KI-Vorschläge gleichen Aliase ab. | +| F-09 | Blase „Figur“ (neue Figur / Spitzname) | erfüllt | `Editor.test.tsx`, Browser | | +| F-10 | KI-Vorschläge als Spitznamen | erfüllt | `core/ki/vorschlaege.test.ts`, `ki.test.tsx`, `gruppenFiguren.test.ts` | Mit Demo-KI belegt. | +| F-11 | Spitzname vor dem Namen | erfüllt | `core/figuren/hinweise.test.ts` | | +| F-12 | Verwechselbare Namen | erfüllt | `core/figuren/hinweise.test.ts` | | +| F-13 | Ruhende Figuren | erfüllt | `core/figuren/hinweise.test.ts`, Browser | | +| F-14 | Statisten | erfüllt | `gruppenFiguren.test.ts`, `core/figuren/hinweise.test.ts`, Browser | | +| F-15 | Besetzungsübersicht | erfüllt | `core/figuren/hinweise.test.ts`, Browser | | +| F-16 | Spitznamen mit Kontext | erfüllt | `Figurenkarte.test.tsx`, `core/figuren/hinweise.test.ts`, Browser | | +| F-17 | Figurenfakten | erfüllt | `core/ki/fakten.test.ts`, `core/figuren/fakten.test.ts`, `gruppenFiguren.test.ts`, `Figurenkarte.test.tsx`, Browser | Mit Demo-KI belegt; Live-Test mit echtem Modell steht aus. | | O-01 | Ortsliste mit Kapiteln, Text-Belegen, Notizen, Unterorten | erfüllt | `ansichten.test.tsx`; Browser | | | O-02 | Schreibweisen zusammenführen, gültige festlegen | erfüllt | `planung.test.ts`, `hinweise.test.tsx` | | | O-03 | Weg einer Figur (Soll) | erfüllt | `core/index/index.test.ts`, `ansichten.test.tsx` | | @@ -82,6 +98,9 @@ Legende Nachweis: Testdatei (automatisch) · „Browser“ = im Browser-Demo bed | A-01 | Zwei Antagonisten-Gruppen (Demo: Nordhaven und Konsortium) | erfüllt | `src/features/gruppen/gruppen.test.tsx` | | | A-02 | Mitglieder, Präsenz, Spuren als Vorschlag | erfüllt | `gruppen.test.tsx` | | | A-03 | Antagonisten als Stränge in Board und Überblick | erfüllt | Browser | | +| A-04 | Gruppen in der App anlegen und löschen | erfüllt | `gruppenFiguren.test.ts`, Browser | | +| A-05 | Mehrere Gruppen pro Figur | erfüllt | `gruppen.test.tsx`, `gruppenFiguren.test.ts`, Browser | | +| A-06 | Rollen in Gruppen | erfüllt | `gruppen.test.tsx`, `gruppenFiguren.test.ts` | | ## Erkennung, KI-Prüfung, Hinweis-Typen @@ -145,6 +164,9 @@ Browser-Durchlauf mit dem Demo-Projekt bei 1440 × 900 und 1100 × 700, hell und | PS-01 | Fäden-Board | erfüllt | `src/features/faeden/faeden.test.tsx`, `core/struktur/faeden.test.ts`; Browser | Befehl „Neuer Faden …“ in Menü und Schnellsuche. | | PS-02 | Strang-Raster | erfüllt | `faeden.test.tsx`; Browser | | | PS-03 | Szenen | erfüllt | `core/struktur/szenen.test.ts`, `faeden.test.tsx`; Browser | Szenenkarte auch für Kapitel ohne Plan; Link springt zur Szene in der Zeitleiste. | +| PS-04 | Haupt-/Nebenhandlung, Zweck, Kurzbeschreibung, Funktion | erfüllt | `core/ki/straenge.test.ts`, `src/features/faeden/straenge.test.tsx`; Browser | „Geplante Szene“ heißt jetzt „Kurzbeschreibung“ (Feld `szene` unverändert). | +| PS-05 | KI-Vorschläge zu Strängen | erfüllt | `core/ki/straenge.test.ts`, `src/features/faeden/straenge.test.tsx`; Browser | Nur Demo-KI; Strang-Angaben an die KI von Leon am 2026-10-12 freigegeben (E12). | +| PS-06 | Lose Enden: Tabelle, als Strang übernehmen | erfüllt | `src/features/faeden/straenge.test.tsx`; Browser | Interne Namen (`faeden`, `plan/faeden.md`, Befehl `faden-neu`) bleiben. | | FB-01 | Figuren-Bögen | erfüllt | `core/figuren/boegen.test.ts`, `src/features/figuren/Boegen.test.tsx`; Browser | | | FB-02 | Figurenstimme | erfüllt | `core/figuren/stimme.test.ts`, `core/ki/stimme.test.ts`, `Stimme.test.tsx`, `core/index/index.test.ts` | Erscheint als Hinweis „Sprache“ und auf der Figurenkarte. | | EW-01 | Typewriter | erfüllt | `src/features/schreiben/werkzeuge/werkzeuge.test.tsx`; Browser | | @@ -161,6 +183,7 @@ Browser-Durchlauf mit dem Demo-Projekt bei 1440 × 900 und 1100 × 700, hell und | AB-03 | EPUB 3 | erfüllt | `core/epub/epub.test.ts` | Satz gemeinsam mit der Vorschau (`core/satz`). Nicht mit epubcheck geprüft. | | AB-04 | Lektoratsmappe | erfüllt | `abgabe.test.ts` | | | AB-05 | Rückimport aus Word | erfüllt | `core/docx/rueckimport.test.ts`, `abgabe.test.tsx`; Browser | Offene Änderungen im Editor gepunktet unterstrichen. | +| AB-06 | Personenverzeichnis | erfüllt | `core/abgabe/personen.test.ts`, `gruppenFiguren.test.ts`, Browser | | | RC-01 | Recherche ablegen | erfüllt | `core/recherche/recherche.test.ts`, `src/features/recherche/recherche.test.tsx`, `src-tauri/src/recherche.rs`; Browser | AK-4 erlaubt Mediendateien unter `recherche/dateien/`. | | RC-02 | Heften und Recherche-Leiste | erfüllt | `recherche.test.tsx`; Browser | Figurenkarte, Ortskarte, Kapitel-Überblick, Schreiben-Inspektor, Szenen-Formular. | | RC-03 | Filter, Raster/Liste | erfüllt | `recherche.test.tsx`; Browser | | diff --git a/docs/anforderungen-v1.1.md b/docs/anforderungen-v1.1.md index 3de55ce..4ef7947 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 (dritter KI-Anbieter „OpenAI-kompatibel“), E16 (Funktionsumfang als App-Einstellung, ausgeblendet ≠ gelöscht) und E17 (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–E17) | # | Entscheidung | Grund | | --- | --- | --- | @@ -71,6 +77,9 @@ 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 | Dritter Anbieter „OpenAI-kompatibel“ (Ollama, LM Studio, OpenAI, OpenRouter, Qwen, eigene URL): Übersetzung nach Chat Completions im Transport, keine Listenpreise, optionale Kontextlänge. | lokale und andere Modelle | +| E16 | 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 | +| E17 | No-AI-Modus: alle KI-Funktionen ausgeblendet, der KI-Transport ist gesperrt (auch `count_tokens`). | Datenschutz, Arbeiten ohne Zugang | ## Datenmodell und Projektordner @@ -208,6 +217,17 @@ Die Planung ist die zweite Ebene neben dem Text. Referenz-Screens: `Akte.dc.html | F-04 | Figuren | Notizen, Auftritte als Kapitelstreifen (tritt auf, nur erwähnt oder per Nachricht), Beziehungen | Muss | | F-05 | Figuren | Hinweis, wenn ein Plan-Feld dem Text widerspricht, außer die Figur oder Stelle ist bewusst vage. Umgesetzt: Die Prüfung vergleicht Plan-Felder von Figuren und Kapitelplänen mit dem Text und meldet Widersprüche als „Prüfen“ mit Plan-Bezug (Figur/Kapitel, Feld); die Figurenkarte zeigt sie mit Etikett „Plan“ | Soll | | F-06 | Figuren | Figur vorab anlegen, bevor sie im Text vorkommt | Muss | +| F-07 | Figuren | Zwei Figuren zusammenführen (Richtung wählbar): Namen werden Spitznamen, Beziehungen, Planfelder und Notizen kommen dazu, alle Verweise im Projekt zeigen auf die bleibende Figur; Hinweis auf der Figurenkarte bei gleichem Namen | Soll | +| F-08 | Figuren | Feld „Auch genannt / Spitzname“: mehrere Namen mit Komma, sofort in der Erkennung und bei KI-Vorschlägen berücksichtigt | Muss | +| F-09 | Figuren | Auswahl-Leiste im Editor „Figur“: markierten Text als neue Figur oder als Spitznamen einer Figur übernehmen | Soll | +| F-10 | Figuren | KI-Vorschläge, die eine angelegte Figur unter anderem Namen meinen, als „Spitznamen für X“ übernehmen; Erwähnungsvorschläge „Als Spitznamen merken“ | Soll | +| F-11 | Figuren | Hinweis, wenn eine Figur zuerst über einen Spitznamen vorkommt und ihr Name erst später fällt | Soll | +| F-12 | Figuren | Hinweis auf leicht verwechselbare Namen (ein Buchstabe Unterschied, gleicher Anfang bei gleicher Länge) | Kann | +| F-13 | Figuren | Hinweis auf ruhende Figuren (Standard Haupt 3, Neben 6 Kapitel, je Figur einstellbar); im Auftrittsstreifen markiert | Soll | +| F-14 | Figuren | Rolle „Statist“: eigener eingeklappter Bereich, schlanke Karte, im Netz nur auf Wunsch, nie im Exposé; Hinweis „zur Nebenfigur machen?“ ab drei Kapiteln | Soll | +| F-15 | Figuren | Besetzungsübersicht Figuren × Kapitel | Soll | +| F-16 | Figuren | Spitznamen als Chips mit Kontext (wer nennt die Figur so, ab welchem Kapitel, Notiz); Hinweis, wenn ein Spitzname vor seinem Kapitel fällt | Kann | +| F-17 | Figuren | Figurenfakten aus dem Text (KI, wörtlich belegt) je Merkmal auf der Figurenkarte; Widersprüche und Planabweichungen als „Prüfen“ | Soll | | O-01 | Orte | Liste mit Kapiteln, Belegen aus dem Text, Beschreibung, Notizen; Unterorte (Zeichensaal im Büro) | Muss | | O-02 | Orte | Schreibweisen zusammenführen und die gültige festlegen | Muss | | O-03 | Orte | Weg einer Figur (z. B. „Pauls Weg“): Ortsfolge über alle Kapitel | Soll | @@ -218,6 +238,9 @@ Die Planung ist die zweite Ebene neben dem Text. Referenz-Screens: `Akte.dc.html | A-01 | Antagonisten | Zwei Antagonisten als Gruppen: Nordhaven (im Text über Herrn Kessler und den Aufsichtsrat) und das Konsortium (geplant) | Muss | | A-02 | Antagonisten | Pro Gruppe Mitglieder, Präsenz pro Akt und Kapitel und mögliche Spuren im Text als Vorschlag (z. B. »Kranich«), die Leon bestätigt oder verwirft | Muss | | A-03 | Antagonisten | Antagonisten erscheinen als eigene Stränge im Akt-Board und im Kapitel-Überblick | Muss | +| A-04 | Gruppen | Gruppen in der App anlegen („+ Gruppe“ im Beziehungsnetz, „+ Neue Gruppe …“ auf der Figurenkarte) und löschen; eine neue Gruppe bekommt sofort eine Fläche im Netz um ihre Mitglieder | Muss | +| A-05 | Gruppen | Eine Figur in mehreren Gruppen; Hauptgruppe bestimmt Farbe und Platz im Netz, andere Gruppen zeigen sie als Verweis | Soll | +| A-06 | Gruppen | Rolle je Mitglied in der Gruppe (Inspektor, Netzkarte, Figurenkarte) | Kann | ## Funktionen: Werkzeuge (G1–G8) @@ -232,6 +255,9 @@ Seit 10. Oktober 2026. Alles, was Leon festlegt, liegt als Markdown oder JSON im | PS-01 | Fäden | Board mit Spalten Offen, Auflösung geplant, Erledigt, Bewusst offen; Warnung bei ruhenden Fäden und überschrittenem Auflösungskapitel; eigene Fäden anlegen | Soll | | PS-02 | Akte planen | Strang-Raster (Stränge × Kapitel) mit Lücken; Klick nimmt einen Strang in den Kapitelplan auf | Soll | | PS-03 | Szenen | Szenen über die Trenner `* * *`: Plan je Szene (Titel, Ort, Figuren, Zeit, Notiz) im Schreiben-Inspektor und im Kapitel-Überblick; der Kapiteltext wird nie verändert | Soll | +| PS-04 | Akte planen | Stränge als Haupt- oder Nebenhandlung markieren und beschreiben („Wofür steht der Strang?“); Kapitelplan mit Kurzbeschreibung und optionaler Funktion im Buch | Soll | +| PS-05 | Akte planen | KI schlägt Kapitel-Zuordnungen zu Strängen, neue Stränge (mit Rolle und Zweck) und Rückfragen vor, jeweils mit wörtlichem Beleg; Leon übernimmt, verwirft oder antwortet, Verworfenes kommt nicht wieder | Kann | +| PS-06 | Lose Enden | Bereich „Fäden“ heißt „Lose Enden“; Tabelle lose Enden × Kapitel, gruppiert nach Strang; ein loses Ende lässt sich als Strang übernehmen (mit seinen Kapiteln) | Soll | | FB-01 | Figuren | Figuren-Bögen: frei benannte Stufen je Kapitel oder Akt mit wörtlichen Belegen, Auftritte als Linie, Vergleich mehrerer Figuren | Soll | | FB-02 | Figuren | Figurenstimme: KI vergleicht die Rede einer Figur mit ihren typischen Stellen; Ergebnis mit auffälliger und typischer Stelle, beide wörtlich; erscheint auch als Hinweis „Sprache“ | Kann | | EW-01 | Editor | Typewriter-Modus: Cursorzeile mittig, übrige Absätze abgedunkelt | Kann | @@ -248,12 +274,29 @@ Seit 10. Oktober 2026. Alles, was Leon festlegt, liegt als Markdown oder JSON im | AB-03 | Abgabe | EPUB 3 mit Satz wie die Buch-Vorschau | Kann | | AB-04 | Abgabe | Lektoratsmappe (PDF) mit offenen Hinweisen | Kann | | AB-05 | Abgabe | Rückimport aus Word: Kommentare als Anmerkungen, nachverfolgte Änderungen einzeln annehmen/ablehnen (vorher Schnappschuss); offene Änderungen im Editor markiert | Soll | +| AB-06 | Abgabe | Personenverzeichnis (.docx/.md) nach Gruppen oder Rolle, „nur bis Kapitel X“; optional als Vorspann von Normseite und EPUB | Kann | | RC-01 | Recherche | Bilder, Links und Notizen ablegen (Ziehen, Einfügen, Dialog), Bilder nach `recherche/dateien/`; Links werden nie geladen, nur auf Klick im Browser geöffnet | Soll | | RC-02 | Recherche | Einträge an Figur, Ort, Kapitel oder Szene heften; Recherche-Leiste auf Figurenkarte, Ortskarte, Kapitel-Überblick, Schreiben-Inspektor und Szenen-Formular | Soll | | RC-03 | Recherche | Filter nach Art, Ziel, Schlagwort, Suche; Raster oder Liste | Soll | | 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 (E16/E17) + +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..9c5aaf1 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -19,9 +19,12 @@ Design: `docs/design/screens/*.dc.html` (verbindlich, Pixelwerte und Farben exak | E9 | **Speichern** atomar (temp + fsync + rename), Debounce 1,5 s, bei Dauertippen spätestens 1,8 s nach der ersten ungespeicherten Eingabe (0,2 s Reserve fürs Schreiben): jede Eingabe liegt nach höchstens 2 s auf der Platte. | Absturzsicherheit | | E10 | **Struktur-Hinweise lokal** (leeres Kapitel, Satzabbruch am Kapitelende, leere Überschrift beim Import, doppelte Leerzeilen …), ohne KI. | schon in Phase 1 | | E11 | **KI-Aufruf im Rust-Kern** (`ki_anfrage`), Schlüssel nur im macOS-Schlüsselbund (je Anbieter ein Eintrag), nie in der Web-Oberfläche. Prompt-Aufbau und Antwortprüfung in TypeScript (`core/ki`), die nur `ProjektLeser` und `KiTransport` kennt. | Schlüssel und Schreibschutz | -| 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) | +| 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. **Stränge (2026-10-12):** Für die Aufgabe „Stränge vorschlagen“ (G2) gehen Name, Haupt-/Nebenhandlung (`rolle`), „Wofür steht der Strang?“ (`beschreibung`) und die Plan-Kapitel jedes Strangs sowie Leons Antworten auf frühere Rückfragen mit – nur im Aufgabentext, nicht im gecachten Prefix; von Leon am 2026-10-12 freigegeben. Die `funktion` eines Kapitels geht nicht an die KI. | 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 | **Dritter Anbieter: OpenAI-kompatibel** (`projekt.ki.anbieter: openai`, Block `ki.openai{vorlage, endpoint, modelle, kontextTokens?}`). Vorlagen Ollama (Standard, `http://localhost:11434/v1`), LM Studio (`:1234/v1`), OpenAI, OpenRouter, Qwen (Alibaba Cloud), eigene URL (`core/ki/anbieter.ts`, `VORLAGEN`). `core/ki` baut weiter Messages-API-Bodys; der Transport übersetzt nach Chat Completions und zurück (`core/ki/openai.ts`: `nachOpenAi`/`vonOpenAi`, Schema als `response_format` json_schema strict, bei Qwen json_object mit Schema im System-Prompt), der Rust-Kern schickt an `{endpoint}/chat/completions` mit `Authorization: Bearer` (Schlüssel optional, Konto `openai-kompatibel`, http nur für localhost, Zeitlimit 30 min). Modelle ohne Claude-Standard (leere Rolle = Prüfmodell). Kein `count_tokens` (Schätzung Zeichen/3), keine Listenpreise (Kosten „keine Angabe“, Ablagen ohne `kostenUsd`). Ist `kontextTokens` gesetzt, lehnt der Transport Anfragen ab, die samt 4 000 Tokens Reserve nicht hineinpassen – lokale Server kürzen sonst still. Andere Modelle als Claude sind nicht abgestimmt; die Zitatprüfung (E6) verwirft, was nicht wörtlich zitiert. | Leons Vorgabe: lokale und andere Modelle | +| E16 | **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) | +| E17 | **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 @@ -36,13 +39,13 @@ Design: `docs/design/screens/*.dc.html` (verbindlich, Pixelwerte und Farben exak ``` / -├── projekt.md Frontmatter: version, titel, kapitel[], ki{endpoint, deployments, anbieter, claude{endpoint, modelle}} +├── projekt.md Frontmatter: version, titel, kapitel[], ki{endpoint, deployments, anbieter, claude{endpoint, modelle}, openai{vorlage, endpoint, modelle, kontextTokens}} ├── manuskript/.md Frontmatter: id, titel · Body: Markdown ├── plan/akte.md Frontmatter: akte[] -├── plan/straenge.md Frontmatter: straenge[] +├── plan/straenge.md Frontmatter: straenge[] (id, name, farbe, gruppe?, rolle? haupt|neben, beschreibung?) ├── plan/ideen.md Frontmatter: ideen[] ├── plan/motive.md Frontmatter: motive[] (id, name, aliase) – Motive im Tab „Erkannt“ (S-10) -├── plan/kapitel/.md Frontmatter: KapitelPlan ohne notiz · Body: notiz +├── plan/kapitel/.md Frontmatter: KapitelPlan ohne notiz (szene = „Kurzbeschreibung“, funktion? = „Funktion im Buch“) · Body: notiz ├── figuren/.md Frontmatter: Figur ohne wahrheit/notizen · Body: "## Wahrheit hinter den Kulissen" + "## Notizen" ├── gruppen/.md Frontmatter: Gruppe ohne notizen · Body: "## Notizen" ├── orte/.md Frontmatter: Ort ohne beschreibung/notizen · Body: Beschreibung + "## Notizen" @@ -66,8 +69,9 @@ Design: `docs/design/screens/*.dc.html` (verbindlich, Pixelwerte und Farben exak ├── .plotwerk/zeit-vorschlaege.json G1 KiAblage ├── .plotwerk/zustand-vorschlaege.json G1 KiAblage ├── .plotwerk/stimmen.json G3 KiAblage -├── .plotwerk/leser.json G6 KiAblage (weiss, fragen, vermutungen, spannung, plan) +├── .plotwerk/leser.json G6 KiAblage (mehrere je Kapitel: id, erledigt; Frage → faden/anmerkung) ├── .plotwerk/fragen.json G6 KiAblage (imBuch: false = „steht nicht im Buch“) +├── .plotwerk/strang-vorschlaege.json G2 KiAblage (zuordnung, neu, frage; Antworten auf Rückfragen) └── .versionen/… Schnappschüsse (Binärdateien: .versionen/recherche/…/.) ``` @@ -86,7 +90,11 @@ beim Beenden und beim Projektwechsel. **LeserStand** (G6): Ältere Stände legten Vermutungen, Spannung und Plan-Abgleich als `LeserFrage` mit Präfix in der `id` ab (`vermutung|…`, `spannung|…`, `plan|status|kapitel|…`); `ausLeserStand` liest beides, eigene Felder haben -Vorrang, neu gespeichert wird nur die Form mit eigenen Feldern. +Vorrang, neu gespeichert wird nur die Form mit eigenen Feldern. Jede Simulation bleibt erhalten (mehrere Stände je +Kapitel, sortiert nach Kapitel und Datum); neue bekommen eine `id`, ältere ohne `id` heißen `kapitel@datum` +(`standId`). `erledigt` (ISO-Datum) blendet eine Simulation aus der Kapitelkarte aus (`aktuellerStand`). Übernimmt Leon eine offene Frage als Faden oder +Notiz, merkt sich die `LeserFrage` die ID (`faden`, `anmerkung`); eine erneute Simulation nimmt diese Zuordnungen für +Fragen mit gleicher `id` (gleicher Wortlaut) aus der vorigen Simulation des Kapitels mit (`uebernahmenMitnehmen`). YAML-Frontmatter mit `yaml` (Paket), Schlüssel in der Reihenfolge der Typdefinition, 2 Leerzeichen Einrückung. Unbekannte Frontmatter-Felder beim Speichern erhalten (Obsidian-Kompatibilität). @@ -119,6 +127,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 (E16/E17) | 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) @@ -129,7 +139,7 @@ Gemeinsame Grundlage (Fundament, nur additiv ändern): Typen in `core/model/type | Gruppe | Eigene Pfade | Einhängepunkte in fremden Bereichen | | --- | --- | --- | | G1 Zeit & Zustand | `src/features/zeitleiste/`, `src/state/slices/zeit.ts`, `core/zeit/`, `core/ki/zeit.ts`, `src/backend/ki/demoAntworten/zeit.ts` | `zeitHinweise()` (core/zeit) fließt in core/index-Hinweise | -| G2 Plot-Struktur | `src/features/faeden/`, `src/state/slices/struktur.ts`, `core/struktur/faeden.ts`, `core/struktur/szenen.ts` | `StrangRaster` (Akte planen, Segment „Stränge“), `SzenenUebersicht` (Kapitel-Überblick), `SzenenKarte` (Schreiben-Inspektor, Tab Plan) | +| G2 Plot-Struktur | `src/features/faeden/`, `src/state/slices/struktur.ts`, `core/struktur/faeden.ts`, `core/struktur/szenen.ts`, `core/struktur/straenge.ts`, `core/ki/straenge.ts`, `src/backend/ki/demoAntworten/struktur.ts` | `StrangRaster` und `StrangInspektor` (Akte planen, Segment „Stränge“), `KapitelStrangVorschlaege` (Kapitel-Inspektor), `SzenenUebersicht` (Kapitel-Überblick), `SzenenKarte` (Schreiben-Inspektor, Tab Plan) | | G3 Figuren | `src/features/figuren/Boegen*.tsx`, `Stimme*.tsx`, `src/state/slices/figurenbogen.ts`, `core/figuren/`, `core/ki/stimme.ts`, `core/ki/stimmeRede.ts`, `src/backend/ki/demoAntworten/figuren.ts` | `FigurenBoegen` (Figuren, Segment „Bögen“), Spalte „Stimme“ an der Figurenkarte; `stimmenHinweise()` fließt in core/index-Hinweise (Typ `sprache`) | | G4 Editor | `src/features/schreiben/werkzeuge/`, `src/state/slices/editorwerkzeuge.ts`, `core/stil/`, `src/backend/tauri/vorlesen.ts`, `src-tauri/src/vorlesen.rs` | `editorErweiterungen()`, `` (Editor), `` (Statusleiste), Tab „Stil“ (Schreiben-Inspektor) | | G5 Versionen & Statistik | `src/features/statistik/`, `src/state/slices/statistik.ts`, `core/statistik/` | `` (Statusleiste); Schnappschüsse über `schnappschuss(pfad, 'benannt')` | @@ -138,9 +148,9 @@ Gemeinsame Grundlage (Fundament, nur additiv ändern): Typen in `core/model/type | G8 Recherche & Mac | `src/features/recherche/`, `src/state/slices/recherche.ts`, `core/recherche/`, `scripts/`, Tauri-Konfiguration für Signatur/Updater (`docs/mac-verteilung.md`) | `RechercheLeiste` an Figurenkarte, Ortskarte, Kapitel-Überblick, Schreiben-Inspektor (Plan) und Szenen-Formular | Neue Bereiche (Seitenleisten-Gruppe „Werkzeuge“, einklappbar): `zeitleiste` (⌘8), `faeden` (⌘9), `recherche` (⌘0), -`statistik`, `fragen`. Neue Dialoge: `zeit-vorschlaege`, `stimme-pruefen`, `marken`, `fassung`, `versionsvergleich`, +`statistik`, `fragen`. Neue Dialoge: `zeit-vorschlaege`, `stimme-pruefen`, `straenge-vorschlagen`, `marken`, `fassung`, `versionsvergleich`, `leser-simulation`, `abgabe-export`, `rueckimport`, `recherche-eintrag`. Befehle (Menü, Schnellsuche): -`zeit-vorschlaege`, `stimme-pruefen`, `marken`, `typewriter` (⌘⇧T), `wiederholungen`, `vorlesen`, `fassung`, +`zeit-vorschlaege`, `stimme-pruefen`, `straenge-vorschlagen`, `marken`, `typewriter` (⌘⇧T), `wiederholungen`, `vorlesen`, `fassung`, `versionsvergleich`, `leser-simulation`, `abgabe`, `rueckimport`, `recherche-neu`, `faden-neu`. Fassung sichern: ⌘⇧S. Solange ein Dialog offen ist, wechseln ⌘-Bereichstasten nicht den Bereich dahinter. Anzeigenamen der Hinweis-Typen: `core/index/hinweisTypen.ts` (Hinweise-Ansicht und Lektoratsmappe). @@ -154,11 +164,30 @@ Zeitpunkte, Zustände, Bögen, Marken, Recherche und Lektorat gehören nicht daz - `ProjektLeser` / `ProjektDateisystem`: `core/projekt/fs.ts` - `core/projekt`: `ladeProjekt(leser): Promise`, `speichereKapitel(fs, kapitel)`, `speicherePlan(fs, plan)`, … je Dateityp ein Paar `parseX(text)` / `serializeX(obj)`; `schnappschuss(fs, pfad, zeit)`. -- `KiTransport` (`core/ki`): `senden(body: object): Promise` (Messages-API-Body → Antwort-JSON), `tokenZaehlen(body): Promise`, `bereit(): Promise`. +- `KiTransport` (`core/ki`): `senden(body: object): Promise` (Messages-API-Body → Antwort-JSON; OpenAI-kompatibel übersetzt der Transport, E15), `tokenZaehlen(body): Promise`, `bereit(): Promise`. - Store: `useStore` (zustand). Slices bekommen `(set, get)` und lesen `get().daten` (ProjektDaten) und `get().fs`. -- 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. +- 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), `"claude"` oder `"openai"`; bei `"openai"` geht `ki_anfrage` an `/chat/completions`, `ki_tokens` gibt es nicht). 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 (E16/E17, 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..59c7b4a 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 @@ -55,10 +70,22 @@ aktive Werkzeug sichtbar). Alle Befehle finden sich auch in der Schnellsuche ( Enden bis hier, „Leserin nach Kap. n“, „Was ist sicher?“ (Gewissheit der Stellen), Recherche zum Kapitel. - **Akte planen** (⌘3): Akt-Board mit Kopfkarten (Quintessenz, Wendepunkt) und Ideen-Ablage; Umschalter „Nur Plan“, „Plan + Text“ und **„Stränge“** – das Strang-Raster zeigt, in welchen Kapiteln ein Strang fehlt oder verschwindet; - Klick oder Leertaste nimmt ihn in den Kapitelplan auf. -- **Fäden** (⌘9): Board mit Offen, Auflösung geplant, Erledigt, Bewusst offen. Warnung bei Fäden, die zu lange ruhen, - oder deren geplante Auflösung schon geschrieben ist. „+ Faden“ bzw. „Neuer Faden …“ legt eigene Fäden an. - Pfeiltasten wandern, ⌥←/⌥→ verschiebt in die Nachbarspalte. + Klick oder Leertaste nimmt ihn in den Kapitelplan auf. Ein Klick auf den Strangnamen öffnet ihn im Inspektor: + Haupthandlung oder Nebenhandlung, „Wofür steht der Strang?“, Farbe. **„Mit KI vorschlagen …“** lässt die KI das Buch + lesen: Sie ordnet geschriebene Kapitel deinen Strängen zu, schlägt fehlende Stränge vor und stellt Rückfragen – jeweils + mit Zitat. Du übernimmst, verwirfst oder antwortest; Verworfenes kommt nicht wieder, Antworten gehen beim nächsten + Lauf mit. +- **Kapitel planen** (Inspektor im Akt-Board): **Kurzbeschreibung** in einem Satz (steht auf der Karte), optional die + **Funktion im Buch** (Opening, Wendepunkt, Atempause … oder eigene; als Etikett auf der Karte) und ausführlich „Was + soll hier passieren?“. KI-Vorschläge zu Strängen erscheinen direkt beim Feld „Strang“. +- **Lose Enden** (⌘9, früher „Fäden“): offene Fragen und Versprechen an die Leserin, die sich auflösen sollen – im + Unterschied zu **Strängen**, den Handlungslinien, die du in „Akte planen“ planst. Ein loses Ende kann zu einem Strang + gehören (Feld „Strang“). Ist es eigentlich eine ganze Handlungslinie (z. B. „Der Deloitte-Auftrag“), macht + **„Als Strang übernehmen“** daraus einen Strang und trägt ihn in alle Kapitel ein, in denen es vorkommt – auch aus dem + Kapitel-Inspektor und im Segment „Stränge“ möglich. Umschalter **Board | Tabelle**: Das Board hat die Spalten Offen, + Auflösung geplant, Erledigt, Bewusst offen; die Tabelle zeigt alle losen Enden × Kapitel, gruppiert nach Strang. + Warnung bei losen Enden, die zu lange ruhen, oder deren geplante Auflösung schon geschrieben ist. „+ Loses Ende“ + legt eigene an. Pfeiltasten wandern, ⌥←/⌥→ verschiebt in die Nachbarspalte. - **Szenen**: Kapitel werden über `* * *` in Szenen geteilt. Pro Szene Titel, Ort, Figuren, Zeit und Notiz – im Schreiben-Inspektor und im Kapitel-Überblick. Der Kapiteltext wird dabei nie verändert. - In Notizen und Plan-Feldern verlinkt `[[` auf Figuren, Orte, Kapitel, Plan-Karten und Ideen. @@ -99,6 +126,14 @@ aktive Werkzeug sichtbar). Alle Befehle finden sich auch in der Schnellsuche ( - **Leser-Simulation** (Kapitel-Überblick „Leserin simulieren …“, Fragen → „Leser-Simulation …“): Die KI liest nur Kapitel 1 bis n und beschreibt, was eine Erstleserin weiß, fragt, vermutet und wo Spannung entsteht – plus Abgleich mit deinen Kapitelplänen („belegt / nur angedeutet / nicht belegt“). Wird veraltet, sobald sich Text oder Plan ändert. + Jede Simulation bleibt gespeichert, auch wenn du dasselbe Kapitel erneut simulierst; im Dialog wählst du zwischen + den Simulationen eines Kapitels. „Erledigt“ klappt eine Simulation unter „erledigt“ weg (die Kapitelkarte zeigt dann + die neueste offene), „Löschen“ entfernt sie nach Rückfrage. Beides geht im Dialog und in der Seitenspalte unter + Fragen (✓ und × beim Überfahren); ein Klick auf eine Zeile öffnet die Simulation. Läuft eine Simulation im + Hintergrund, zeigt unten rechts eine Meldung den Lauf, das fertige Ergebnis oder den Fehler. Jede offene Frage lässt + sich übernehmen: „Als Faden planen“ legt einen Faden an (kommt auf in, Auflösung geplant in, wie sie gelöst wird, oder + bewusst offen), „Notiz an die Stelle“ heftet eine Notiz an die Belegstelle im Text. Die nächste Simulation gleicht die + Fäden wieder mit den Leserfragen ab. - **Fragen an das Buch** (Werkzeuge → Fragen): „Wo erwähne ich Pauls Bleistifte?“ – Antworten nur mit Belegstellen; Klick öffnet die Stelle. „nicht im Buch“ und „ohne Beleg“ sind unterschieden. ↩ fragt, ⇧↩ neue Zeile, ↑ holt die letzte Frage zurück. Schreibaufträge lehnt die App ab, ohne das Modell zu fragen. diff --git a/docs/plan-figuren-namen.md b/docs/plan-figuren-namen.md new file mode 100644 index 0000000..0517264 --- /dev/null +++ b/docs/plan-figuren-namen.md @@ -0,0 +1,206 @@ +# Plan: Figuren, Namen und Gruppen (Paket nach v0.2.0) + +Stand: 13. Oktober 2026 – alle Phasen 1–9 umgesetzt (Branch `claude/statisten-handling-b9afbc`, ein Sammel-PR). +Ursprünglicher Stand: 12. Oktober 2026. Grundlage: Rückmeldungen aus dem Schreiben mit dem eigenen Manuskript (Spitznamen werden als +eigene Figuren angelegt, doppelte Figuren lassen sich nicht zusammenführen, Gruppen nur über Dateien). Bereits +umgesetzt auf diesem Branch: F-07 Figuren zusammenführen, F-08 Spitznamen-Feld, A-04 Gruppen in der App, +KI-Vorschläge nehmen den Eigennamen. + +Leitlinien für alle Phasen: + +- Datenmodell nur additiv (`core/model/types.ts`); alte Projekte bleiben ohne Migration gültig. +- Lokale Prüfungen (ohne KI) zuerst, KI-Werkzeuge mit wörtlichen Zitaten (E6) und ohne Ersatztext. +- Neue Hinweise laufen über `core/index` (`hinweiseBerechnen`) und den bestehenden Hinweis-Mechanismus: + „Ist Absicht“ und „Erledigt“ gelten dauerhaft (`entscheidungen.hinweise`). +- Beispiele in Code, Tests und Doku nur aus dem Demo-Roman (`fixtures/`), nie aus dem privaten Manuskript. +- Jede Phase endet mit Tests (Vitest), Browser-Durchlauf hell/dunkel, deutsch/englisch, und einem eigenen PR. + +## Übersicht + +| Phase | Inhalt | Anforderungen | Aufwand | KI | +|---|---|---|---|---| +| 0 | Zusammenführen, Spitzname-Feld, Gruppen in der App | F-07, F-08, A-04 | erledigt | – | +| 1 | Hover-Blase im Editor stabil machen | S-02 (Qualität) | mittel | – | +| 2 | Namen zuordnen: Blase „Figur / Spitzname“, KI-Vorschläge als Spitzname übernehmen | F-09, F-10 | mittel | nutzt vorhandene | +| 3 | Lokale Namens-Hinweise: Spitzname vor Einführung, verwechselbare Namen, verschwundene Figuren | F-11, F-12, F-13 | klein–mittel | – | +| 4 | Statisten als dritte Rolle | F-14 | mittel | Prompt-Anpassung | +| 5 | Gruppen: mehrere pro Figur, Rollen in der Gruppe | A-05, A-06 | mittel | – | +| 6 | Besetzungsübersicht (Figuren × Kapitel) | F-15 | mittel | – | +| 7 | Spitznamen mit Kontext (wer nennt wen so, ab wann) | F-16 | klein–mittel | – | +| 8 | Personenverzeichnis exportieren | AB-06 | klein–mittel | – | +| 9 | Figurenfakten aus dem Text, Widersprüche je Figur | F-17 | groß | ja | +| – | Erzählperspektive pro Kapitel | – | – | zurückgestellt | + +Reihenfolge: 1 vor 2 (die neue Blasen-Aktion setzt eine verlässliche Blase voraus). 3 profitiert von 2 (mehr +Spitznamen gepflegt → bessere Hinweise). 4 und 5 sind unabhängig. 9 zuletzt, weil am teuersten und am besten +mit dem echten Manuskript zu erproben. + +--- + +## Phase 1 – Hover-Blase im Editor + +Die Blase (`src/features/schreiben/Blase.tsx`, eigene Komponente über TipTap/ProseMirror, kein BubbleMenu) +hat mehrere Schwachstellen, die beim Schreiben als „hakelig“ auffallen. Befund und Abhilfe: + +| # | Problem | Ursache | Abhilfe | +|---|---|---|---| +| 1 | Blase am linken/rechten Rand abgeschnitten, Ebenen-Untermenü erst recht | Klemmung nur 70 px, Blase ~220–260 px breit; `.pw-s-papier` hat `overflow-x: hidden` | Breite messen, Mitte auf `[breite/2, papierbreite − breite/2]` klemmen; Untermenü an der Blase ausrichten statt zentriert | +| 2 | Blase verschwindet oben, wenn die markierte Zeile am oberen Rand steht | kein Umklappen | unter die Auswahl klappen, wenn oben kein Platz ist (`data-seite="unten"`, Pfeil spiegeln) | +| 3 | Blase bleibt an alter Stelle nach Fokus-Modus, Fenstergröße, Seitenleisten ein/aus, Kapitelwechsel | kein Resize-/Layout-Listener; `view.updateState` beim Laden löst kein Ereignis aus | `ResizeObserver` auf Papier und Editor, `resize`-Listener; beim Laden eines Kapitels Blase schließen | +| 4 | „Notiz“ im Fokus-Modus legt einen unsichtbaren Entwurf an (rosa Markierung, kein Eingabefeld) | Randnotizen sind im Fokus-Modus nicht gerendert | Notiz-Knopf im Fokus-Modus: Fokus-Modus verlassen und Entwurf öffnen (oder Knopf ausblenden) | +| 5 | Escape schließt weder Blase noch Untermenü; im Fokus-Modus beendet Escape stattdessen den Modus | kein Escape-Handler | Escape schließt zuerst das Untermenü, dann die Blase (Auswahl bleibt); erst danach greift der Fokus-Modus | +| 6 | Untermenü „Ebene“ ist nach Weg- und Zurückklicken noch offen | Zustand nur bei Auswahländerung zurückgesetzt | beim Verbergen zurücksetzen | +| 7 | Blase erscheint nach einem Klick gar nicht mehr, bis man erneut klickt | `maus`-Flag bleibt hängen, wenn die Maustaste außerhalb des Fensters losgelassen wird | Flag auch bei `pointerup`/`pointercancel`/`blur` am Fenster zurücksetzen; Pointer-Events statt Maus-Events | +| 8 | Blase kann mitten im Ziehen aufblitzen, wenn das Ziehen außerhalb des Textes beginnt | Mausmerker nur auf `view.dom` | `pointerdown` auf dem ganzen Papier beobachten | +| 9 | ⌘A zeigt keine Blase | Prüfung auf Inline-Elternknoten schließt `AllSelection` aus | `AllSelection` zulassen (Fett/Kursiv), Ebene/Notiz/Figur dann ausblenden | +| 10 | Tastatur: kein Weg in die Blase; ist der Fokus drin, verschwindet sie bei der nächsten Transaktion (z. B. Analyse-Dekorationen) | `messen` prüft `editor.isFocused` | Tastenkürzel (⌥⌘B o. Ä.) fokussiert die Blase, Pfeiltasten wandern (`role="toolbar"`), `messen` berücksichtigt Fokus in der Blase; Timer beim Verbergen löschen | +| 11 | „Ü“-Knopf mit `aria-pressed` und wechselndem Label | doppelte Semantik | nur `aria-pressed`, festes Label | + +Tests: bisher nur Verzögerung, Ebene, Ziehen, Ü/Absatz (`Editor.test.tsx`, `schreiben.en.test.tsx`). Neu je +Problem ein Test (Klemmung und Umklappen mit gestubbtem `coordsAtPos`, Escape, Fokus-Modus-Notiz, hängender +Mausmerker, Resize schließt bzw. misst neu, Tastatur). Zusätzlich Browser-Durchlauf: Markieren am linken Rand, +in der ersten Zeile, im Fokus-Modus, mit ⌘A, per Tastatur, hell und dunkel, deutsch und englisch (englische +Labels sind breiter). Damit Phase 2 eine weitere Aktion mit Eingabefeld/Auswahlliste in die Blase setzen kann, +bekommt die Blase einen „Panel“-Mechanismus (Untermenü mit eigenem Fokus, der die Blase nicht schließt) – die +Ebenen-Auswahl wird das erste Panel. + +--- + +## Phase 2 – Namen dort zuordnen, wo man sie bemerkt + +**F-09 Blase „Figur / Spitzname“.** Neue Aktion in der Blase über markiertem Text (nach Phase 1): + +- „Neue Figur“: legt eine Figur mit dem markierten Text als Namen an (Rolle Neben bzw. Statist nach Phase 4), + öffnet sie nicht, zeigt eine kurze Bestätigung mit Link zur Karte. +- „Spitzname von …“: Suchliste der Figuren als Panel der Blase (Name, Spitznamen, Kurzbeschreibung; Tastatur + ↑↓/Enter/Escape; Grundlage `recherche/ZielWahl.tsx`, auf Figuren gefiltert; vorausgewählt die Figur, die an der + Stelle schon erkannt ist), fügt den markierten Text als Alias hinzu und hält die Stelle wie + `unbekanntZuordnen` als bestätigte Zuordnung fest. Führender Artikel wird entfernt („der Alte“ → „Alte“), weil die + Erkennung Artikelformen selbst findet; der Originaltext steht als Vorschau daneben. +- Gilt nur für eine Markierung innerhalb eines Absatzes, 2–60 Zeichen. +- Danach sofort neue Erkennung (Signatur ändert sich über die Aliase, kein Zusatzaufwand). + +**F-10 KI-Vorschläge als Spitzname übernehmen.** + +- Figurvorschläge (`core/ki/vorschlaege.ts`): Trägt ein Vorschlag den Namen einer angelegten Figur als Alias, + wird er heute still als „bereits angelegt“ gezählt. Neu: Hat er Namen, die die angelegte Figur noch nicht + kennt, wird daraus ein Vorschlag „Spitznamen für X: …“ (`FigurVorschlag.ziel?: Id` additiv) mit Knopf + „Als Spitznamen übernehmen“. +- Erwähnungsvorschläge (`ErwaehnungsVorschlaege.tsx`, Haiku): Bei einer bestätigten indirekten Erwähnung + („der Alte“ → Ole) zusätzlich „Immer so erkennen“ → Alias statt nur dieser einen Zuordnung. +- Unbekannte Namen (Hinweistyp `unbekannt`) lassen sich schon heute einer Figur als Spitzname zuordnen + (`unbekanntZuordnen`); dort nur die neue Figurenauswahl (Suche statt langer Auswahlliste) übernehmen. + +Tests: Kernlogik in `core/ki/vorschlaege.test.ts`, Store-Aktionen, UI-Test für die Figurenauswahl (Tastatur). + +--- + +## Phase 3 – Lokale Hinweise rund um Namen + +Alle drei lokal in einem neuen Modul `core/figuren/hinweise.ts`, eingehängt in `hinweiseBerechnen`. Neuer +Hinweistyp `figuren` (additiv in `HinweisTyp`, mit Filter im Bereich Hinweise und im Figuren-Abschnitt +„Hinweise“ der Karte). + +**F-11 Spitzname, bevor der Leser ihn kennt.** Aus `index.erwaehnungen` (Feld `treffer`): erste Stelle, an der +die Figur über einen Spitznamen erkannt wird, liegt vor der ersten Stelle mit ihrem Namen (inkl. Genitiv- und +Artikelformen). Hinweis mit beiden Zitaten. Nicht für Figuren ohne Namensnennung im Text und nicht für +„bewusst vage“. + +**F-12 Verwechselbare Namen.** Paare von Figuren (Name und Spitznamen), die sich leicht verwechseln lassen: +Editierabstand 1 bei Namen ≥ 4 Zeichen (Hendrik/Henrik-Typ), gleicher Anfang und gleiche Länge, gleicher +Anlaut bei Haupt- und Nebenfiguren in denselben Kapiteln. Nur Figuren, die beide im Text vorkommen; Statisten +ausgenommen. Bewusst sparsam: lieber wenige Treffer. + +**F-13 Verschwundene Figuren.** Wie ruhende Fäden (K-02): Hauptfigur seit N Kapiteln nicht aufgetreten +(Standard 3, für Nebenfiguren 6, Statisten nie), solange noch Kapitel mit Text folgen. Schwelle pro Figur +einstellbar (`Figur.ruheSchwelle?: number | null`, null = nie melden). Erscheint auch als Markierung im +Auftrittsstreifen der Figurenkarte. + +Tests: je Regel positive und negative Fälle mit dem Demo-Roman. + +--- + +## Phase 4 – Statisten + +**F-14 Rolle `statist`.** `FigurRolle = 'haupt' | 'neben' | 'statist'` (additiv; jede Stelle, die heute +zweiwertig denkt, wird angepasst – u. a. `vorschlaege.ts` Rollen-Mapping, `figurenBereiche`, Segment in der +Karte, Exposé). + +- Figurenliste: eigener Bereich „Statisten“, standardmäßig eingeklappt, kompakte Zeilen (Name, Kurzbeschreibung, + Kapitel). +- Figurenkarte: Planfelder, Bogen, Zustände und Stimme eingeklappt bzw. ausgeblendet, bis etwas eingetragen ist. +- Beziehungsnetz: Statisten standardmäßig ausgeblendet (Filter „Statisten zeigen“), Beziehungen zu ihnen + gestrichelt. +- Erkennung, Schreibweisen, Auftritte bleiben voll aktiv. +- KI-Vorschläge: Prompt erlaubt `statist` (benannt, höchstens zwei Szenen, keine eigene Handlung); Sammelaktion + „Alle Statisten übernehmen“. +- Hinweis „Zur Nebenfigur machen?“, wenn ein Statist in drei oder mehr Kapiteln auftritt (Typ `figuren`, + Entscheidung dauerhaft). +- Exposé: Statisten nie. + +--- + +## Phase 5 – Gruppen + +**A-05 Mehrere Gruppen pro Figur.** Maßgeblich wird `Gruppe.mitglieder` (kann eine Figur mehrfach enthalten); +`Figur.gruppe` bleibt als „Hauptgruppe“ für Farbe und Darstellung (additiv, keine Migration). Figurenkarte: +Gruppen als Chips mit „+ Gruppe“ statt einer Auswahl; Hauptgruppe per Stern. `mitgliedHinzufuegen` entfernt die +Figur nicht mehr aus anderen Gruppen. Netz: Karte liegt in der Fläche der Hauptgruppe, andere Gruppen zeigen +die Figur als kleinen Verweis-Chip. + +**A-06 Rollen in Gruppen.** `Gruppe.rollen?: Record` (z. B. „Chef“, „Praktikant“). Inspektor: +Rolle je Mitglied; Netz: Rolle als zweite Zeile auf der Karte innerhalb der Fläche; Figurenkarte: „Rolle in +Gruppe“. Optional später: Reihenfolge der Mitglieder als Hierarchie. + +--- + +## Phase 6 – Besetzungsübersicht + +**F-15** Neue Ansicht im Bereich Figuren (Umschalter „Karte · Bögen · Besetzung“): Matrix Figuren × Kapitel aus +`index.erwaehnungen` (Art je Kapitel wie im Auftrittsstreifen: tritt auf, Nachricht, nur erwähnt), gruppiert nach +Rolle, Statisten einklappbar. Spaltensummen „Figuren je Kapitel“, Zeilensummen; Klick auf eine Zelle springt zur +ersten Stelle. Ruhende Strecken (Phase 3) hervorgehoben. Reine Ableitung, keine neue Datei. + +--- + +## Phase 7 – Spitznamen mit Kontext + +**F-16** Spitznamen werden optional zu Einträgen mit Kontext: `Figur.spitznamen?: { name: string; von?: Id[]; +abKapitel?: Id; notiz?: string }[]` (additiv; `aliase` bleibt die Liste für die Erkennung und wird daraus +gespeist). Figurenkarte: Spitznamen als Chips; Klick öffnet „Wer nennt X so? Ab wann?“. Lokaler Hinweis (Typ +`figuren`): Spitzname taucht vor `abKapitel` auf. „Wer nennt“ wird angezeigt und fließt später in die +Figurenstimme (G3) ein; keine automatische Prüfung der Sprecher in dieser Phase (Sprecherzuordnung ist +unzuverlässig). + +Ersetzt nebenbei das Komma-Feld durch Chips (Eingabe mit Enter oder Komma, × zum Entfernen). + +--- + +## Phase 8 – Personenverzeichnis + +**AB-06** Neues Abgabe-Format „Personenverzeichnis“ (`core/abgabe/personen.ts`): Figuren nach Gruppen oder +Rolle, je Zeile Name, Spitznamen, Kurzbeschreibung, optional Rolle in der Gruppe. Optionen: Statisten ja/nein, +„geplant“ ausblenden, nur bis Kapitel X (kein Spoiler). Ausgabe als .docx/PDF/Markdown und als optionaler +Vorspann im EPUB- und Normseiten-Export. Private Felder (`wahrheit`, `notizen`) nie. + +--- + +## Phase 9 – Figurenfakten aus dem Text + +**F-17** Die KI-Prüfung meldet heute schon Widersprüche allgemein (Typ `pruefen`). Neu ist eine +figurenbezogene Sammlung: KI-Aufgabe „Figurenfakten“ (eigene Ablage `.plotwerk/figurenfakten.json`) liefert je +Figur Angaben aus dem Text (Alter, Aussehen, Beruf, Herkunft, Familie …) mit wörtlichem Beleg. Die Figurenkarte +zeigt sie im Abschnitt „Aus dem Text“ (F-02) mit Kapitelbeleg; widersprüchliche Angaben (gleiches Merkmal, +unterschiedliche Werte) werden lokal erkannt und als Hinweis `pruefen` mit Figur-Bezug gemeldet. Planfelder +werden dagegen wie bei F-05 abgeglichen. Regeln wie bei allen KI-Werkzeugen: Zitat wörtlich, nur Beobachtung, +„bewusst vage“ wird nie als Widerspruch gemeldet, Leon entscheidet jeden Eintrag. Kosten vorab schätzen +(Haiku pro Kapitel, Abgleich lokal). + +--- + +## Zurückgestellt + +**Erzählperspektive pro Kapitel/Szene.** Für das eigene Manuskript nicht nötig (ein Ich-Erzähler), für die +öffentliche Version aber eine häufige Planungsfrage. Notiz für später: Feld `perspektive?: Id` an +`KapitelPlan`/`Szene`, Auswertung „wer erzählt wie oft, wer war lange nicht dran“, Ansicht in der +Besetzungsübersicht (Phase 6) als zusätzliche Zeile. 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..215953f 100644 --- a/src-tauri/crates/plotwerk-ki/src/lib.rs +++ b/src-tauri/crates/plotwerk-ki/src/lib.rs @@ -1,16 +1,18 @@ -//! HTTP-Transport zur Messages-API: Claude in Microsoft Foundry oder die Claude API von -//! Anthropic direkt (Anbieter wählt Leon in den Einstellungen). +//! HTTP-Transport zur KI: Messages-API (Claude in Microsoft Foundry oder die Claude API von +//! Anthropic direkt) oder OpenAI-kompatible Chat-Completions-API (OpenAI, OpenRouter, Ollama, +//! LM Studio …). Den Anbieter wählt Leon in den Einstellungen. //! //! Dieses Crate hat bewusst keinen Dateizugriff (siehe `clippy.toml` und //! `tests/kein_dateizugriff.rs`): Die KI kann nichts schreiben. Es nimmt einen fertigen -//! Request-Body entgegen, schickt ihn an `{endpoint}/v1/messages` bzw. -//! `{endpoint}/v1/messages/count_tokens` und gibt das Antwort-JSON zurück. +//! Request-Body entgegen, schickt ihn an `{endpoint}/v1/messages` (bzw. +//! `{endpoint}/v1/messages/count_tokens`) oder `{endpoint}/chat/completions` und gibt das +//! Antwort-JSON zurück. Die Übersetzung zwischen beiden Formaten passiert in TypeScript. #![deny(clippy::disallowed_methods, clippy::disallowed_types)] use std::fmt; use std::time::Duration; -use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE, RETRY_AFTER}; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER}; use reqwest::StatusCode; use serde_json::{Map, Value}; @@ -24,6 +26,8 @@ pub const BETA_FALLBACK_STANDARD: &str = "server-side-fallback-2026-07-01"; pub const BETA_FALLBACK_LISTE: &str = "server-side-fallback-2026-06-01"; /// Großzügig: Ein Tiefencheck über das ganze Buch kann mehrere Minuten dauern. pub const ZEITLIMIT: Duration = Duration::from_secs(600); +/// OpenAI-kompatibel: Lokale Modelle lesen ein ganzes Buch deutlich langsamer ein. +pub const ZEITLIMIT_OPENAI: Duration = Duration::from_secs(1800); const VERBINDUNGS_ZEITLIMIT: Duration = Duration::from_secs(30); /// Längere Retry-After-Angaben werden nicht abgewartet, sondern als Fehler gemeldet. const MAX_RETRY_AFTER: u64 = 60; @@ -51,6 +55,8 @@ pub enum Anbieter { Foundry, /// Claude API von Anthropic (`https://api.anthropic.com`). Claude, + /// OpenAI-kompatible Chat-Completions-API (z. B. `http://localhost:11434/v1`). Schlüssel optional. + OpenAi, } impl Anbieter { @@ -59,11 +65,14 @@ impl Anbieter { match wert.map(str::trim) { None | Some("" | "foundry") => Ok(Self::Foundry), Some("claude") => Ok(Self::Claude), + Some("openai") => Ok(Self::OpenAi), Some(andere) => Err(KiFehler::Einstellung(Text::neu( format!( - "Unbekannter KI-Anbieter „{andere}“. Erwartet wird „foundry“ oder „claude“." + "Unbekannter KI-Anbieter „{andere}“. Erwartet wird „foundry“, „claude“ oder „openai“." + ), + format!( + "Unknown AI provider “{andere}”. Expected “foundry”, “claude” or “openai”." ), - format!("Unknown AI provider “{andere}”. Expected “foundry” or “claude”."), ))), } } @@ -72,6 +81,15 @@ impl Anbieter { match self { Self::Foundry => "Microsoft Foundry", Self::Claude => "Claude API", + Self::OpenAi => "OpenAI-kompatibel", + } + } + + /// Zeitlimit je Anfrage. + pub fn zeitlimit(self) -> Duration { + match self { + Self::OpenAi => ZEITLIMIT_OPENAI, + _ => ZEITLIMIT, } } } @@ -113,7 +131,8 @@ pub enum KiFehler { Einstellung(Text), /// Keine Verbindung (DNS, TLS, Netz weg). Verbindung(String), - Zeitueberschreitung, + /// Keine Antwort innerhalb des Zeitlimits (in Minuten). + Zeitueberschreitung { minuten: u64 }, /// HTTP-Status ≠ 2xx. Http { status: u16, @@ -149,18 +168,16 @@ impl KiFehler { match self { KiFehler::Einstellung(m) => m.in_sprache(englisch).to_string(), KiFehler::Verbindung(m) if de => format!( - "Keine Verbindung zum KI-Endpoint. Bitte Internetverbindung und Endpoint-URL in den KI-Einstellungen prüfen. ({m})" + "Keine Verbindung zum KI-Endpoint. Bitte Internetverbindung (bei einem lokalen Modell: ob der Server läuft) und Endpoint-URL in den KI-Einstellungen prüfen. ({m})" ), KiFehler::Verbindung(m) => format!( - "No connection to the AI endpoint. Please check the internet connection and the endpoint URL in the AI settings. ({m})" + "No connection to the AI endpoint. Please check the internet connection (for a local model: whether the server is running) and the endpoint URL in the AI settings. ({m})" ), - KiFehler::Zeitueberschreitung if de => format!( - "Die KI hat nicht innerhalb von {} Minuten geantwortet. Bitte später erneut versuchen.", - ZEITLIMIT.as_secs() / 60 + KiFehler::Zeitueberschreitung { minuten } if de => format!( + "Die KI hat nicht innerhalb von {minuten} Minuten geantwortet. Bitte später erneut versuchen." ), - KiFehler::Zeitueberschreitung => format!( - "The AI did not answer within {} minutes. Please try again later.", - ZEITLIMIT.as_secs() / 60 + KiFehler::Zeitueberschreitung { minuten } => format!( + "The AI did not answer within {minuten} minutes. Please try again later." ), KiFehler::Antwort(m) if de => { format!("Die Antwort des KI-Dienstes ist unbrauchbar: {m}") @@ -231,7 +248,9 @@ impl std::error::Error for KiFehler {} /// Basis-URL aus dem eingetragenen Endpoint, z. B. /// `https://.services.ai.azure.com/anthropic` (Foundry) oder /// `https://api.anthropic.com` (Claude API; leer = dieser Standard). Akzeptiert auch -/// eingefügte Ziel-URIs mit `/v1/messages` am Ende. Nur HTTPS (HTTP nur für localhost/Tests). +/// eingefügte Ziel-URIs mit `/v1/messages` am Ende. OpenAI-kompatibel: Basis-URL samt `/v1` +/// (z. B. `http://localhost:11434/v1`), ein angehängtes `/chat/completions` wird abgeschnitten. +/// Nur HTTPS (HTTP nur für localhost/Tests). pub fn basis_url(anbieter: Anbieter, endpoint: &str) -> Result { let mut e = endpoint.trim().trim_end_matches('/').to_string(); if e.is_empty() { @@ -241,13 +260,22 @@ pub fn basis_url(anbieter: Anbieter, endpoint: &str) -> Result "Kein KI-Endpoint eingetragen. Bitte in den KI-Einstellungen die Foundry-URL angeben (https://.services.ai.azure.com/anthropic).", "No AI endpoint set. Please enter the Foundry URL in the AI settings (https://.services.ai.azure.com/anthropic).", ))), + Anbieter::OpenAi => return Err(KiFehler::Einstellung(Text::neu( + "Keine Basis-URL eingetragen. Bitte in den KI-Einstellungen die Adresse des Dienstes angeben (z. B. http://localhost:11434/v1).", + "No base URL set. Please enter the address of the service in the AI settings (e.g. http://localhost:11434/v1).", + ))), } } let beispiel = match anbieter { Anbieter::Foundry => "https://.services.ai.azure.com/anthropic", Anbieter::Claude => CLAUDE_API_ENDPOINT, + Anbieter::OpenAi => "http://localhost:11434/v1", + }; + let endungen: &[&str] = match anbieter { + Anbieter::OpenAi => &["/chat/completions"], + _ => &["/v1/messages/count_tokens", "/v1/messages", "/v1"], }; - for endung in ["/v1/messages/count_tokens", "/v1/messages", "/v1"] { + for endung in endungen { if e.to_ascii_lowercase().ends_with(endung) { e.truncate(e.len() - endung.len()); e = e.trim_end_matches('/').to_string(); @@ -281,11 +309,21 @@ pub fn basis_url(anbieter: Anbieter, endpoint: &str) -> Result /// `POST`-Ziel für eine Nachricht. pub fn nachrichten_url(anbieter: Anbieter, endpoint: &str) -> Result { - Ok(format!("{}/v1/messages", basis_url(anbieter, endpoint)?)) + let basis = basis_url(anbieter, endpoint)?; + Ok(match anbieter { + Anbieter::OpenAi => format!("{basis}/chat/completions"), + _ => format!("{basis}/v1/messages"), + }) } -/// `POST`-Ziel für die Token-Zählung. +/// `POST`-Ziel für die Token-Zählung (OpenAI-kompatible Dienste haben keine; die App schätzt dann). pub fn tokens_url(anbieter: Anbieter, endpoint: &str) -> Result { + if anbieter == Anbieter::OpenAi { + return Err(KiFehler::Einstellung(Text::neu( + "OpenAI-kompatible Dienste können keine Tokens zählen.", + "OpenAI-compatible services cannot count tokens.", + ))); + } Ok(format!( "{}/v1/messages/count_tokens", basis_url(anbieter, endpoint)? @@ -322,12 +360,28 @@ pub fn token_body(body: &Value) -> Result { } /// Kopfzeilen: `x-api-key`, `anthropic-version`, `content-type` und bei Bedarf `anthropic-beta`. +/// OpenAI-kompatibel: `content-type` und, falls ein Schlüssel hinterlegt ist, `Authorization: Bearer`. pub fn kopfzeilen( anbieter: Anbieter, schluessel: &str, body: &Value, ) -> Result { let schluessel = schluessel.trim(); + if anbieter == Anbieter::OpenAi { + let mut h = HeaderMap::new(); + h.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + if !schluessel.is_empty() { + let mut wert = HeaderValue::from_str(&format!("Bearer {schluessel}")).map_err(|_| { + KiFehler::Einstellung(Text::neu( + "Der gespeicherte API-Schlüssel enthält ungültige Zeichen. Bitte neu eintragen.", + "The stored API key contains invalid characters. Please enter it again.", + )) + })?; + wert.set_sensitive(true); + h.insert(AUTHORIZATION, wert); + } + return Ok(h); + } if schluessel.is_empty() { return Err(KiFehler::Einstellung(Text::neu( "Kein API-Schlüssel hinterlegt. Bitte in den KI-Einstellungen eintragen.", @@ -377,9 +431,11 @@ fn wiederholen_nach(h: &HeaderMap) -> Option { h.get(RETRY_AFTER)?.to_str().ok()?.trim().parse().ok() } -fn netzfehler(e: reqwest::Error) -> KiFehler { +fn netzfehler(e: reqwest::Error, zeitlimit: Duration) -> KiFehler { if e.is_timeout() { - return KiFehler::Zeitueberschreitung; + return KiFehler::Zeitueberschreitung { + minuten: zeitlimit.as_secs() / 60, + }; } let mut ursache: &dyn std::error::Error = &e; while let Some(innen) = ursache.source() { @@ -396,8 +452,8 @@ pub struct KiKlient { impl KiKlient { pub fn neu() -> Result { + // Zeitlimit je Anfrage (Anbieter::zeitlimit) let http = reqwest::Client::builder() - .timeout(ZEITLIMIT) .connect_timeout(VERBINDUNGS_ZEITLIMIT) .user_agent(concat!("Plotwerk/", env!("CARGO_PKG_VERSION"))) .build() @@ -419,18 +475,25 @@ impl KiKlient { self } - async fn einmal(&self, url: &str, kopf: &HeaderMap, body: &Value) -> Result { + async fn einmal( + &self, + url: &str, + kopf: &HeaderMap, + body: &Value, + zeitlimit: Duration, + ) -> Result { let antwort = self .http .post(url) + .timeout(zeitlimit) .headers(kopf.clone()) .json(body) .send() .await - .map_err(netzfehler)?; + .map_err(|e| netzfehler(e, zeitlimit))?; let status = antwort.status(); let retry = wiederholen_nach(antwort.headers()); - let text = antwort.text().await.map_err(netzfehler)?; + let text = antwort.text().await.map_err(|e| netzfehler(e, zeitlimit))?; if !status.is_success() { return Err(KiFehler::Http { status: status.as_u16(), @@ -446,10 +509,16 @@ impl KiKlient { } /// Mit höchstens zwei Wiederholungen bei 429/529/5xx (Retry-After wird beachtet). - async fn post(&self, url: String, kopf: HeaderMap, body: &Value) -> Result { + async fn post( + &self, + url: String, + kopf: HeaderMap, + body: &Value, + zeitlimit: Duration, + ) -> Result { let mut versuch = 0; loop { - let fehler = match self.einmal(&url, &kopf, body).await { + let fehler = match self.einmal(&url, &kopf, body, zeitlimit).await { Ok(v) => return Ok(v), Err(e) => e, }; @@ -473,7 +542,8 @@ impl KiKlient { } } - /// POST {endpoint}/v1/messages – Body unverändert, Antwort-JSON unverändert. + /// POST {endpoint}/v1/messages (OpenAI-kompatibel: {endpoint}/chat/completions) – Body + /// unverändert, Antwort-JSON unverändert. pub async fn nachricht( &self, anbieter: Anbieter, @@ -489,7 +559,7 @@ impl KiKlient { } let url = nachrichten_url(anbieter, endpoint)?; let kopf = kopfzeilen(anbieter, schluessel, body)?; - self.post(url, kopf, body).await + self.post(url, kopf, body, anbieter.zeitlimit()).await } /// POST {endpoint}/v1/messages/count_tokens → `input_tokens`. @@ -503,7 +573,7 @@ impl KiKlient { let url = tokens_url(anbieter, endpoint)?; let body = token_body(body)?; let kopf = kopfzeilen(anbieter, schluessel, &body)?; - let antwort = self.post(url, kopf, &body).await?; + let antwort = self.post(url, kopf, &body, anbieter.zeitlimit()).await?; antwort .get("input_tokens") .and_then(Value::as_u64) @@ -518,6 +588,7 @@ mod tests { const F: Anbieter = Anbieter::Foundry; const C: Anbieter = Anbieter::Claude; + const O: Anbieter = Anbieter::OpenAi; #[test] fn anbieter_aus_text() { @@ -525,13 +596,14 @@ mod tests { assert_eq!(Anbieter::aus_text(Some("")).unwrap(), F); assert_eq!(Anbieter::aus_text(Some("foundry")).unwrap(), F); assert_eq!(Anbieter::aus_text(Some(" claude ")).unwrap(), C); - assert!(Anbieter::aus_text(Some("openai")).is_err()); + assert_eq!(Anbieter::aus_text(Some("openai")).unwrap(), O); + assert!(Anbieter::aus_text(Some("gemini")).is_err()); assert_eq!(Anbieter::default(), F); } #[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 +630,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!( @@ -644,13 +716,13 @@ mod tests { assert!(ohne .to_string() .starts_with("Kein KI-Endpoint eingetragen.")); - assert!(Anbieter::aus_text(Some("openai")) + assert!(Anbieter::aus_text(Some("gemini")) .unwrap_err() .meldung(true) - .contains("Unknown AI provider “openai”")); - assert!(KiFehler::Zeitueberschreitung + .contains("Unknown AI provider “gemini”")); + assert!(KiFehler::Zeitueberschreitung { minuten: 30 } .meldung(true) - .contains("did not answer within")); + .contains("did not answer within 30 minutes")); assert!(KiFehler::Verbindung("dns".into()) .meldung(true) .starts_with("No connection to the AI endpoint.")); @@ -713,4 +785,48 @@ mod tests { .is_none()); assert_eq!(beta_fuer(C, &json!({"fallbacks": "irgendwas"})), None); } + + #[test] + fn openai_urls() { + assert_eq!( + nachrichten_url(O, "http://localhost:11434/v1").unwrap(), + "http://localhost:11434/v1/chat/completions" + ); + assert_eq!( + nachrichten_url(O, " https://openrouter.ai/api/v1/chat/completions/ ").unwrap(), + "https://openrouter.ai/api/v1/chat/completions" + ); + // /v1 bleibt Teil der Basis-URL + assert_eq!( + basis_url(O, "https://api.openai.com/v1/").unwrap(), + "https://api.openai.com/v1" + ); + assert!(basis_url(O, "") + .unwrap_err() + .to_string() + .contains("Basis-URL")); + // HTTP nur lokal + assert!(basis_url(O, "http://127.0.0.1:1234/v1").is_ok()); + assert!(basis_url(O, "http://example.com/v1").is_err()); + assert!(tokens_url(O, "http://localhost:11434/v1").is_err()); + } + + #[test] + fn openai_kopfzeilen() { + let b = json!({"model":"qwen3:8b","messages":[]}); + // lokal ohne Schlüssel: nur content-type + let ohne = kopfzeilen(O, "", &b).unwrap(); + assert_eq!(ohne.len(), 1); + assert_eq!(ohne[CONTENT_TYPE], "application/json"); + // mit Schlüssel: Bearer, kein x-api-key, keine anthropic-*-Kopfzeilen + let mit = kopfzeilen(O, " sk-or-x ", &json!({"fallbacks":"default"})).unwrap(); + assert_eq!(mit[AUTHORIZATION], "Bearer sk-or-x"); + assert!(mit[AUTHORIZATION].is_sensitive()); + assert!(mit.get("x-api-key").is_none()); + assert!(mit.get("anthropic-version").is_none()); + assert!(mit.get("anthropic-beta").is_none()); + assert!(kopfzeilen(O, "a\nb", &b).is_err()); + assert_eq!(O.zeitlimit(), ZEITLIMIT_OPENAI); + assert_eq!(C.zeitlimit(), ZEITLIMIT); + } } diff --git a/src-tauri/crates/plotwerk-ki/tests/http.rs b/src-tauri/crates/plotwerk-ki/tests/http.rs index b4d2be4..68ce909 100644 --- a/src-tauri/crates/plotwerk-ki/tests/http.rs +++ b/src-tauri/crates/plotwerk-ki/tests/http.rs @@ -9,6 +9,7 @@ use tokio::net::TcpListener; const F: Anbieter = Anbieter::Foundry; const C: Anbieter = Anbieter::Claude; +const O: Anbieter = Anbieter::OpenAi; struct Anfrage { zeile: String, @@ -394,3 +395,81 @@ async fn foundry_ohne_beta_header() { assert_eq!(anfrage.zeile, "POST /anthropic/v1/messages HTTP/1.1"); assert_eq!(anfrage.kopf("anthropic-beta"), None); } + +#[tokio::test] +async fn openai_chat_completions_mit_bearer() { + let (endpoint, server) = server( + "200 OK", + "", + r#"{"choices":[{"index":0,"message":{"role":"assistant","content":"{}"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}"#, + ) + .await; + let body = json!({"model":"gpt-5","messages":[{"role":"user","content":"Hallo"}],"max_completion_tokens":10}); + let antwort = KiKlient::neu() + .unwrap() + .nachricht( + O, + &format!("{endpoint}/v1/chat/completions"), + "sk-test", + &body, + ) + .await + .unwrap(); + assert_eq!(antwort["usage"]["prompt_tokens"], 3); + let anfrage = server.await.unwrap(); + assert_eq!( + anfrage.zeile, + "POST /anthropic/v1/chat/completions HTTP/1.1" + ); + assert_eq!(anfrage.kopf("authorization"), Some("Bearer sk-test")); + assert_eq!(anfrage.kopf("x-api-key"), None); + assert_eq!(anfrage.kopf("anthropic-version"), None); + assert_eq!(anfrage.body, body); +} + +#[tokio::test] +async fn openai_lokal_ohne_schluessel() { + let (endpoint, server) = server( + "200 OK", + "", + r#"{"choices":[{"index":0,"message":{"role":"assistant","content":"{}"},"finish_reason":"stop"}]}"#, + ) + .await; + klient() + .nachricht( + O, + &format!("{endpoint}/v1"), + "", + &json!({"model":"qwen3:8b","messages":[]}), + ) + .await + .unwrap(); + let anfrage = server.await.unwrap(); + assert_eq!( + anfrage.zeile, + "POST /anthropic/v1/chat/completions HTTP/1.1" + ); + assert_eq!(anfrage.kopf("authorization"), None); +} + +#[tokio::test] +async fn openai_fehlermeldung_von_ollama() { + let (endpoint, server) = server( + "404 Not Found", + "", + r#"{"error":{"message":"model \"qwen3:99b\" not found, try pulling it first","type":"api_error"}}"#, + ) + .await; + let e = klient() + .nachricht( + O, + &format!("{endpoint}/v1"), + "", + &json!({"model":"qwen3:99b"}), + ) + .await + .unwrap_err(); + server.await.unwrap(); + assert_eq!(e.status(), Some(404)); + assert!(e.to_string().contains("try pulling it first"), "{e}"); +} diff --git a/src-tauri/src/ki.rs b/src-tauri/src/ki.rs index feb82cc..b553c75 100644 --- a/src-tauri/src/ki.rs +++ b/src-tauri/src/ki.rs @@ -47,7 +47,7 @@ async fn im_hintergrund( })? } -/// `anbieter`: "foundry" (Standard, auch wenn es fehlt) oder "claude". +/// `anbieter`: "foundry" (Standard, auch wenn es fehlt), "claude" oder "openai". fn anbieter(wert: Option) -> Result { Anbieter::aus_text(wert.as_deref()).map_err(|e| KiFehlerAntwort::from(e).meldung) } @@ -78,7 +78,16 @@ fn klient(zustand: &Zustand) -> Result { Ok(zustand.ki.get_or_init(|| k).clone()) } -/// POST {endpoint}/v1/messages mit dem Schlüssel des Anbieters aus dem Schlüsselbund. +/// Schlüssel des Anbieters; beim OpenAI-kompatiblen Dienst darf er fehlen (lokale Server). +async fn schluessel_fuer(a: Anbieter) -> Result { + match a { + Anbieter::OpenAi => im_hintergrund(move || schluessel::lesen_optional(a)).await, + _ => im_hintergrund(move || schluessel::lesen(a)).await, + } +} + +/// POST {endpoint}/v1/messages bzw. {endpoint}/chat/completions mit dem Schlüssel des Anbieters +/// aus dem Schlüsselbund. #[tauri::command] pub async fn ki_anfrage( zustand: State<'_, Zustand>, @@ -88,7 +97,7 @@ pub async fn ki_anfrage( ) -> Result { let a = Anbieter::aus_text(anbieter.as_deref())?; let k = klient(&zustand)?; - let schluessel = im_hintergrund(move || schluessel::lesen(a)).await?; + let schluessel = schluessel_fuer(a).await?; Ok(k.nachricht(a, &endpoint, &schluessel, &body).await?) } @@ -102,7 +111,7 @@ pub async fn ki_tokens( ) -> Result { let a = Anbieter::aus_text(anbieter.as_deref())?; let k = klient(&zustand)?; - let schluessel = im_hintergrund(move || schluessel::lesen(a)).await?; + let schluessel = schluessel_fuer(a).await?; Ok(k.tokens_zaehlen(a, &endpoint, &schluessel, &body).await?) } diff --git a/src-tauri/src/schluessel.rs b/src-tauri/src/schluessel.rs index e93802a..d9768e7 100644 --- a/src-tauri/src/schluessel.rs +++ b/src-tauri/src/schluessel.rs @@ -9,11 +9,14 @@ pub const DIENST: &str = "app.plotwerk.mac"; pub const KONTO: &str = "azure-foundry"; /// Konto für die Claude API von Anthropic. pub const KONTO_CLAUDE: &str = "anthropic-api"; +/// Konto für den OpenAI-kompatiblen Dienst (OpenAI, OpenRouter, Qwen …; lokal meist ohne Schlüssel). +pub const KONTO_OPENAI: &str = "openai-kompatibel"; pub fn konto(anbieter: Anbieter) -> &'static str { match anbieter { Anbieter::Foundry => KONTO, Anbieter::Claude => KONTO_CLAUDE, + Anbieter::OpenAi => KONTO_OPENAI, } } @@ -82,13 +85,22 @@ pub fn lesen(anbieter: Anbieter) -> Result { eintrag(anbieter)?.get_password().map_err(fehlertext) } +/// Wie `lesen`, aber ein fehlender Eintrag ist kein Fehler (lokale Server brauchen keinen Schlüssel). +pub fn lesen_optional(anbieter: Anbieter) -> Result { + match eintrag(anbieter)?.get_password() { + Ok(w) => Ok(w), + Err(Error::NoEntry) => Ok(String::new()), + Err(e) => Err(fehlertext(e)), + } +} + #[cfg(test)] mod tests { use super::*; #[test] fn leerer_schluessel_wird_abgelehnt() { - for anbieter in [Anbieter::Foundry, Anbieter::Claude] { + for anbieter in [Anbieter::Foundry, Anbieter::Claude, Anbieter::OpenAi] { assert!(setzen(anbieter, " ").is_err()); assert!(setzen(anbieter, "abc\ndef").is_err()); } @@ -98,6 +110,7 @@ mod tests { fn je_anbieter_ein_eintrag() { assert_eq!(konto(Anbieter::Foundry), "azure-foundry"); assert_eq!(konto(Anbieter::Claude), "anthropic-api"); + assert_eq!(konto(Anbieter::OpenAi), "openai-kompatibel"); assert_eq!(DIENST, "app.plotwerk.mac"); } 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..4f98b15 --- /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 (E16). */ +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..34f57f4 100644 --- a/src/app/Rahmen.tsx +++ b/src/app/Rahmen.tsx @@ -1,10 +1,13 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; +import { SimulationStatus } from '../features/fragen/SimulationStatus'; 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 +19,33 @@ function BereichInhalt() { return ; } +/** + * Aktiver Bereich ausgeblendet (E16): 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(() => { @@ -45,6 +70,7 @@ export function Rahmen() { + ); } diff --git a/src/app/Seitenleiste.tsx b/src/app/Seitenleiste.tsx index e2c422d..c478c5b 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 (E16) 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 (