From ec9c5e3b2af2d8c228f39f0ac76c2f96809afcbc Mon Sep 17 00:00:00 2001 From: Leon Machnik <98738343+leonatwork@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:11:52 +0200 Subject: [PATCH 01/13] =?UTF-8?q?KI-Vorschl=C3=A4ge:=20Eigenname=20statt?= =?UTF-8?q?=20Spitzname,=20keine=20Doppelten=20=C3=BCber=20Aliase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Prompt verlangt den Eigennamen als Namen, Spitznamen und Bezeichnungen als Aliase. Schlägt die KI trotzdem eine Bezeichnung mit Artikel vor und steht ein Eigenname unter den Aliasen, werden beide getauscht. Ein Vorschlag, dessen Alias eine angelegte Figur trägt, gilt als bereits angelegt; früher Verworfenes kommt auch unter einem anderen Namen derselben Person nicht wieder. Co-Authored-By: Claude Opus 5.5 --- core/ki/vorschlaege.test.ts | 39 ++++++++++++++++++++++ core/ki/vorschlaege.ts | 57 ++++++++++++++++++++++++--------- src/state/slices/vorschlaege.ts | 2 +- 3 files changed, 81 insertions(+), 17 deletions(-) diff --git a/core/ki/vorschlaege.test.ts b/core/ki/vorschlaege.test.ts index d72aa9e..2b612ca 100644 --- a/core/ki/vorschlaege.test.ts +++ b/core/ki/vorschlaege.test.ts @@ -226,6 +226,45 @@ 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])).toEqual([ + ['figur:fiete', 'Fiete', ['der Krabbenmann', 'Fietje']], + // ohne Eigennamen bleibt die Bezeichnung der Name + ['figur:die-stimme-am-telefon', 'die Stimme am Telefon', ['die Anruferin']], + ]); + expect(r.bereitsAngelegt).toBe(1); + // 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(['die Stimme am Telefon']); + expect(zweiter.bereitsEntschieden).toBe(2); + }); + 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..855aefa 100644 --- a/core/ki/vorschlaege.ts +++ b/core/ki/vorschlaege.ts @@ -61,7 +61,8 @@ 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. +- 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), 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,7 +97,8 @@ 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). +- 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), 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. @@ -429,6 +431,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 +545,23 @@ 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)) { + // 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) { bereitsAngelegt += 1; + // Beziehungen dürfen die Figur unter den Namen dieses Vorschlags nennen + for (const n of namen) if (!figurNachForm.has(namensForm(n))) figurNachForm.set(namensForm(n), angelegt); continue; } const belege = belegePruefen(f.belege); @@ -560,16 +581,17 @@ 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), + aliase, rolle: (f.rolle === 'haupt' ? 'haupt' : 'neben') satisfies FigurRolle, kurz, belege, @@ -782,10 +804,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 +831,7 @@ 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); + const vorhanden = figurMitNamen(daten, v.name, ...v.aliase); return vorhanden ? { figur: vorhanden } : { vorschlag: v }; } @@ -823,7 +848,7 @@ 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) => f.status === 'neu' && !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/src/state/slices/vorschlaege.ts b/src/state/slices/vorschlaege.ts index 9eac2d4..2752153 100644 --- a/src/state/slices/vorschlaege.ts +++ b/src/state/slices/vorschlaege.ts @@ -117,7 +117,7 @@ export const createVorschlaegeSlice: StateCreator Date: Mon, 12 Oct 2026 23:21:32 +0200 Subject: [PATCH 02/13] =?UTF-8?q?Figuren=20zusammenf=C3=BChren,=20Gruppen?= =?UTF-8?q?=20in=20der=20App=20anlegen,=20Spitznamen-Feld?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Figurenkarte: „Mit anderer Figur zusammenführen …“ (Richtung umkehrbar) und Hinweis bei gleichnamigen Figuren. Die bleibende Figur übernimmt Namen als Spitznamen, Beziehungen, Planfelder und Notizen; alle Verweise im Projekt (Beziehungen, Gruppen, Kapitelpläne, Szenen, Zeitleiste, Bögen, Anmerkungen, Recherche, Entscheidungen, KI-Ablagen, [[figur:…]]-Links) werden umgehängt. - Gruppen: „+ Gruppe“ im Beziehungsnetz, „+ Neue Gruppe …“ auf der Figurenkarte, „Gruppe löschen“ im Inspektor. Eine neue Gruppe bekommt sofort eine Fläche um ihre Mitglieder. - Gruppenauswahl auf der Figurenkarte setzt jetzt auch gruppe.mitglieder (vorher nur figur.gruppe, die Figur fehlte in Fläche und Präsenz). - „Auch genannt“ heißt jetzt „Auch genannt / Spitzname“. Co-Authored-By: Claude Opus 5.5 --- core/figuren/index.ts | 3 +- core/figuren/zusammenfuehren.test.ts | 105 +++++++++ core/figuren/zusammenfuehren.ts | 240 +++++++++++++++++++++ docs/abnahme.md | 3 + docs/anforderungen-v1.1.md | 3 + src/features/beziehungen/NetzInspektor.tsx | 18 +- src/features/beziehungen/index.tsx | 11 + src/features/figuren/Figurenkarte.tsx | 164 ++++++++++++-- src/i18n/de/beziehungen.ts | 5 + src/i18n/de/figuren.ts | 22 +- src/i18n/de/gruppen.ts | 2 +- src/i18n/en/beziehungen.ts | 5 + src/i18n/en/figuren.ts | 22 +- src/i18n/en/gruppen.ts | 2 +- src/state/slices/gruppenFiguren.test.ts | 66 ++++++ src/state/slices/netz.ts | 71 +++++- src/state/store.ts | 42 ++++ 17 files changed, 756 insertions(+), 28 deletions(-) create mode 100644 core/figuren/zusammenfuehren.test.ts create mode 100644 core/figuren/zusammenfuehren.ts create mode 100644 src/state/slices/gruppenFiguren.test.ts diff --git a/core/figuren/index.ts b/core/figuren/index.ts index f1d312b..df98766 100644 --- a/core/figuren/index.ts +++ b/core/figuren/index.ts @@ -1,6 +1,7 @@ /** - * 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'; 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..50a4fb0 --- /dev/null +++ b/core/figuren/zusammenfuehren.ts @@ -0,0 +1,240 @@ +/** + * Zwei Figuren zusammenführen (z. B. „Marian“ 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), + }; + 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); + return [id, mitglieder === g.mitglieder ? g : { ...g, mitglieder }]; + }), + ); + + 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/docs/abnahme.md b/docs/abnahme.md index 8b878c9..fc7d5c0 100644 --- a/docs/abnahme.md +++ b/docs/abnahme.md @@ -72,6 +72,8 @@ 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. | | 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 +84,7 @@ 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 | | ## Erkennung, KI-Prüfung, Hinweis-Typen diff --git a/docs/anforderungen-v1.1.md b/docs/anforderungen-v1.1.md index 3de55ce..4f5c9fd 100644 --- a/docs/anforderungen-v1.1.md +++ b/docs/anforderungen-v1.1.md @@ -208,6 +208,8 @@ 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 | | 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 +220,7 @@ 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 | ## Funktionen: Werkzeuge (G1–G8) diff --git a/src/features/beziehungen/NetzInspektor.tsx b/src/features/beziehungen/NetzInspektor.tsx index d07a129..d27d687 100644 --- a/src/features/beziehungen/NetzInspektor.tsx +++ b/src/features/beziehungen/NetzInspektor.tsx @@ -4,7 +4,7 @@ import type { Index } from '@core/index/index'; import type { BeziehungsKategorie, Id, ProjektDaten } from '@core/model/types'; import { f, useTexte, vergleich } from '../../i18n'; import { useStore } from '../../state/store'; -import { Abschnittslabel, Button, Checkbox, Chip, Inspektor, klassen, RealitaetsUmschalter, Segment } from '../../ui'; +import { Abschnittslabel, bestaetigen as rueckfrage, Button, Checkbox, Chip, Inspektor, klassen, RealitaetsUmschalter, Segment } from '../../ui'; import { GruppenInspektor } from '../gruppen'; import { EntwurfFeld } from './EntwurfFeld'; import { farbSatz, KATEGORIEN, kapitelIndex, kapitelTitel, kapLabel } from './gemeinsam'; @@ -387,7 +387,10 @@ function BeziehungTeil({ function GruppeTeil({ ansicht, id }: Props & { id: Id }) { const waehleImNetz = useStore((s) => s.waehleImNetz); const gruppeImNetzSetzen = useStore((s) => s.gruppeImNetzSetzen); - const ti = useTexte().beziehungen.inspektor; + const entferneGruppe = useStore((s) => s.entferneGruppe); + const name = useStore((s) => s.daten?.gruppen[id]?.name ?? ''); + const t = useTexte(); + const ti = t.beziehungen.inspektor; const hatFlaeche = ansicht.flaechen.some((x) => x.gruppe.id === id); return ( <> @@ -401,6 +404,17 @@ function GruppeTeil({ ansicht, id }: Props & { id: Id }) { {ti.flaecheAusDemNetz} )} + ); diff --git a/src/features/beziehungen/index.tsx b/src/features/beziehungen/index.tsx index 1954b11..9fca883 100644 --- a/src/features/beziehungen/index.tsx +++ b/src/features/beziehungen/index.tsx @@ -199,7 +199,15 @@ export function Titelaktionen() { const mitText = useStore((s) => !!s.daten && s.daten.projekt.kapitel.some((id) => s.daten!.kapitel[id]?.text.trim())); const laeuft = useStore((s) => s.vorschlagLaeuft); const oeffneVorschlaege = useStore((s) => s.oeffneVorschlaege); + const gruppeAnlegen = useStore((s) => s.gruppeAnlegen); + const waehleImNetz = useStore((s) => s.waehleImNetz); const ta = useTexte().beziehungen.ansicht; + const neueGruppe = async () => { + // eine ausgewählte Figur wird gleich Mitglied + const a = useStore.getState().netzAuswahl; + const id = await gruppeAnlegen(ta.neueGruppe, { mitglieder: a?.typ === 'figur' ? [a.id] : [] }); + waehleImNetz({ typ: 'gruppe', id }); + }; return ( <> + diff --git a/src/features/figuren/Figurenkarte.tsx b/src/features/figuren/Figurenkarte.tsx index 20d7131..a9796ad 100644 --- a/src/features/figuren/Figurenkarte.tsx +++ b/src/features/figuren/Figurenkarte.tsx @@ -207,14 +207,34 @@ function FeldZeile({ f, feld, umbenennen, setUmbenennen }: { f: Figur; feld: str ); } +const NEUE_GRUPPE = '\u0000neu'; + function DeinePlanung({ f, daten }: { f: Figur; daten: ProjektDaten }) { const p = useStore((s) => s.planung); const oeffne = useStore((s) => s.oeffne); + const mitgliedHinzufuegen = useStore((s) => s.mitgliedHinzufuegen); + const mitgliedEntfernen = useStore((s) => s.mitgliedEntfernen); + const gruppeAnlegen = useStore((s) => s.gruppeAnlegen); const [umbenennen, setUmbenennen] = useState(null); + /** Name der neuen Gruppe, solange das Eingabefeld offen ist */ + const [neueGruppe, setNeueGruppe] = useState(null); const uid = useId(); const t = useTexte(); const tp = t.figuren.karte.planung; const gruppen = Object.values(daten.gruppen).sort((a, b) => vergleich(a.name, b.name)); + // Mitgliedschaft immer über die Gruppe setzen: figur.gruppe und gruppe.mitglieder bleiben gleich + const gruppeSetzen = (wert: string) => { + if (wert === NEUE_GRUPPE) return setNeueGruppe(''); + if (wert) void mitgliedHinzufuegen(wert, f.id); + else if (f.gruppe && daten.gruppen[f.gruppe]) void mitgliedEntfernen(f.gruppe, f.id); + else void p.figurAendern(f.id, { gruppe: null }); + }; + const gruppeAnlegenUndSetzen = async () => { + const name = neueGruppe?.trim(); + if (!name) return; + setNeueGruppe(null); + await gruppeAnlegen(name, { mitglieder: [f.id] }); + }; return ( {tp.rechts}} label={tp.titel}>
@@ -252,21 +272,41 @@ function DeinePlanung({ f, daten }: { f: Figur; daten: ProjektDaten }) { {tp.gruppe} - - {f.gruppe && daten.gruppen[f.gruppe] && ( + {neueGruppe === null ? ( + + ) : ( + <> + setNeueGruppe(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void gruppeAnlegenUndSetzen(); + if (e.key === 'Escape') setNeueGruppe(null); + }} + /> + + + + )} + {neueGruppe === null && f.gruppe && daten.gruppen[f.gruppe] && ( oeffne('beziehungen', { gruppe: f.gruppe!, figur: f.id })}> {tp.imNetz} @@ -702,8 +742,91 @@ function Hinweise({ f, daten, index }: { f: Figur; daten: ProjektDaten; index: I ); } +const namensform = (n: string) => n.trim().toLocaleLowerCase('de'); + +/** Andere Figuren, die einen Namen (Name, Spitzname, Form) mit `f` teilen – vermutlich dieselbe Person. */ +export function gleichnamigeFiguren(f: Figur, daten: ProjektDaten): Figur[] { + const formen = new Set(namenDerFigur(f).map(namensform)); + return Object.values(daten.figuren) + .filter((x) => x.id !== f.id && namenDerFigur(x).some((n) => formen.has(namensform(n)))) + .sort((a, b) => vergleich(a.name, b.name)); +} + +type Zusammenfuehrung = { figur: Id; wahl: Id } | null; + +/** Hinweis auf gleichnamige Figuren und Auswahl, welche Figur in dieser aufgeht. */ +function Zusammenfuehren({ f, daten, zustand, setZustand }: { f: Figur; daten: ProjektDaten; zustand: Zusammenfuehrung; setZustand(z: Zusammenfuehrung): void }) { + const zusammenfuehren = useStore((s) => s.figurenZusammenfuehren); + const uid = useId(); + const t = useTexte(); + const tz = t.figuren.karte.zusammenfuehren; + const doppelte = gleichnamigeFiguren(f, daten); + const offen = zustand?.figur === f.id ? zustand : null; + if (!offen) { + if (doppelte.length === 0) return null; + return ( +
+ {fmt(tz.doppelt, { namen: doppelte.map((x) => `${fmt(t.figuren.zitat, { text: x.name })}${x.kurz ? ` (${x.kurz})` : ''}`).join(', ') })} + setZustand({ figur: f.id, wahl: doppelte[0]!.id })}> + {tz.pruefen} + +
+ ); + } + const doppelteIds = new Set(doppelte.map((x) => x.id)); + const andere = Object.values(daten.figuren) + .filter((x) => x.id !== f.id && !doppelteIds.has(x.id)) + .sort((a, b) => vergleich(a.name, b.name)); + const option = (x: Figur, gleich: boolean) => ( + + ); + const gewaehlt = daten.figuren[offen.wahl]; + /** `bleibt` übernimmt `geht` (nach Rückfrage) */ + const ausfuehren = async (bleibt: Figur, geht: Figur) => { + if (!(await rueckfrage(fmt(tz.frage, { andere: geht.name, name: bleibt.name }), { knopf: tz.ausfuehren }))) return; + setZustand(null); + await zusammenfuehren(bleibt.id, geht.id); + }; + return ( +
+ + + {fmt(tz.hilfe, { name: f.name })} + + + + + {gewaehlt && ( + void ausfuehren(gewaehlt, f)}> + {fmt(tz.umgekehrt, { andere: gewaehlt.name })} + + )} +
+ ); +} + export function Figurenkarte({ id, daten, index }: { id: Id; daten: ProjektDaten; index: Index }) { const entfernen = useStore((s) => s.entferneFigur); + const [zusammenfuehrung, setZusammenfuehrung] = useState(null); const t = useTexte(); const f = daten.figuren[id]; if (!f) return null; @@ -731,11 +854,22 @@ export function Figurenkarte({ id, daten, index }: { id: Id; daten: ProjektDaten
+ + {zusammenfuehrung?.figur !== f.id && Object.keys(daten.figuren).length > 1 && ( + + )} - -
diff --git a/src/features/schreiben/FigurPanel.tsx b/src/features/schreiben/FigurPanel.tsx new file mode 100644 index 0000000..4b12a51 --- /dev/null +++ b/src/features/schreiben/FigurPanel.tsx @@ -0,0 +1,112 @@ +/** + * F-09: Panel der Auswahl-Leiste „Figur“: markierten Text als neue Figur anlegen oder als Spitznamen einer + * Figur übernehmen. Die Erkennung findet die Stellen danach von selbst (Signatur über Name und Spitznamen). + */ +import { useId, useMemo, useState } from 'react'; +import { figurenSuchen, figurMitNamensform, spitznameAusText } from '@core/figuren/namen'; +import type { Id } from '@core/model/types'; +import { f, useTexte } from '../../i18n'; +import { useStore } from '../../state/store'; + +/** Höchstens so viele Figuren in der Liste */ +const MAX = 6; + +export function FigurPanel({ markiert, erkannt, onFertig }: { markiert: string; erkannt: Id[]; onFertig(meldung: string): void }) { + const daten = useStore((s) => s.daten); + const planung = useStore((s) => s.planung); + const t = useTexte(); + const T = t.schreiben.figurPanel; + const uid = useId(); + const [name, setName] = useState(() => spitznameAusText(markiert)); + const [suche, setSuche] = useState(''); + const [aktiv, setAktiv] = useState(0); + const [arbeitet, setArbeitet] = useState(false); + const treffer = useMemo(() => (daten ? figurenSuchen(daten, suche, suche ? [] : erkannt).slice(0, MAX) : []), [daten, suche, erkannt]); + if (!daten) return null; + const n = name.trim(); + const besitzer = n ? figurMitNamensform(daten, n) : undefined; + const zitat = (x: string) => f(t.figuren.zitat, { text: x }); + + const ausfuehren = async (arbeit: () => Promise) => { + if (arbeitet) return; + setArbeitet(true); + try { + onFertig(await arbeit()); + } finally { + setArbeitet(false); + } + }; + const neueFigur = () => + void ausfuehren(async () => { + await planung.figurAnlegen(n); + return f(T.angelegt, { name: zitat(n) }); + }); + const spitzname = (id: Id) => + void ausfuehren(async () => { + const figur = daten.figuren[id]!; + const neu = await planung.spitznameHinzufuegen(id, n); + return f(neu ? T.spitznameNeu : T.spitznameDa, { name: zitat(n), figur: figur.name }); + }); + + return ( +
+ + setName(e.target.value)} spellCheck={false} /> + {besitzer ? ( + {f(T.gehoertZu, { name: zitat(n), figur: besitzer.name })} + ) : ( + + )} + + 0} + aria-controls={`${uid}-liste`} + aria-activedescendant={treffer[aktiv] ? `${uid}-${treffer[aktiv]!.id}` : undefined} + placeholder={T.suchen} + value={suche} + autoComplete="off" + onChange={(e) => { + setSuche(e.target.value); + setAktiv(0); + }} + onKeyDown={(e) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + if (treffer.length) setAktiv((i) => (i + (e.key === 'ArrowDown' ? 1 : treffer.length - 1)) % treffer.length); + } else if (e.key === 'Enter') { + e.preventDefault(); + const ziel = treffer[aktiv]; + if (ziel && n && !besitzer) spitzname(ziel.id); + } + }} + /> +
    + {treffer.map((x, i) => ( +
  • setAktiv(i)} + onClick={() => n && !besitzer && spitzname(x.id)} + > + {x.name} + {x.kurz && {x.kurz}} +
  • + ))} + {treffer.length === 0 &&
  • {T.keine}
  • } +
+
+ ); +} diff --git a/src/features/schreiben/schreiben.css b/src/features/schreiben/schreiben.css index 9687e59..35f2033 100644 --- a/src/features/schreiben/schreiben.css +++ b/src/features/schreiben/schreiben.css @@ -262,6 +262,74 @@ color: var(--on-ink); font-size: 12px; } +/* Panel „Figur“ (FigurPanel.tsx) */ +.pw-s-figurpanel { + display: flex; + flex-direction: column; + gap: 5px; +} +.pw-s-figurpanel__label { + margin-top: 2px; + color: color-mix(in srgb, var(--on-ink) 70%, transparent); + font-size: 11px; +} +.pw-s-figurpanel input { + height: 26px; + padding: 0 8px; + border: 1px solid color-mix(in srgb, var(--on-ink) 25%, transparent); + border-radius: 5px; + background: color-mix(in srgb, var(--on-ink) 8%, transparent); + color: var(--on-ink); + font: inherit; + font-size: 12px; +} +.pw-s-figurpanel input:focus { + outline: 2px solid color-mix(in srgb, var(--on-ink) 60%, transparent); + outline-offset: -1px; +} +.pw-s-figurpanel input::placeholder { + color: color-mix(in srgb, var(--on-ink) 50%, transparent); +} +.pw-s-blase .pw-s-figurpanel__neu { + justify-content: flex-start; + text-align: left; + background: color-mix(in srgb, var(--on-ink) 12%, transparent); +} +.pw-s-figurpanel__hinweis { + color: color-mix(in srgb, var(--on-ink) 75%, transparent); +} +.pw-s-figurpanel__liste { + margin: 0; + padding: 0; + list-style: none; + max-height: 180px; + overflow-y: auto; +} +.pw-s-figurpanel__liste li { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 5px 8px; + border-radius: 5px; + cursor: pointer; +} +.pw-s-figurpanel__liste li[aria-disabled='true'] { + cursor: default; + opacity: 0.6; +} +.pw-s-figurpanel__option--aktiv { + background: color-mix(in srgb, var(--on-ink) 18%, transparent); +} +.pw-s-figurpanel__kurz { + overflow: hidden; + color: color-mix(in srgb, var(--on-ink) 60%, transparent); + text-overflow: ellipsis; + white-space: nowrap; +} +.pw-s-figurpanel__leer { + color: color-mix(in srgb, var(--on-ink) 60%, transparent); + cursor: default; +} .pw-s-blase__punkt { display: inline-block; width: 7px; diff --git a/src/i18n/de/beziehungen.ts b/src/i18n/de/beziehungen.ts index 9fccc57..5012bce 100644 --- a/src/i18n/de/beziehungen.ts +++ b/src/i18n/de/beziehungen.ts @@ -186,6 +186,8 @@ export const beziehungen = { hauptfigur: 'Hauptfigur', nebenfigur: 'Nebenfigur', auch: 'auch: {namen}', + spitznamenFuer: 'Spitznamen für {name}', + alsSpitznamen: 'Als Spitznamen übernehmen', inOrt: 'in {name}', legtAuchAn: { one: 'Legt auch die Figur {namen} an', other: 'Legt auch die Figuren {namen} an' }, abKap: ' · ab {kap}', diff --git a/src/i18n/de/ki.ts b/src/i18n/de/ki.ts index e1e943a..fbfa408 100644 --- a/src/i18n/de/ki.ts +++ b/src/i18n/de/ki.ts @@ -103,6 +103,9 @@ export const ki = { unbekannterOrt: 'unbekannter Ort', unbekannteFigur: 'unbekannte Figur', bestaetigen: 'Bestätigen', + spitzname: 'Als Spitznamen merken', + spitznameTitel: 'Bestätigen und „{name}“ künftig immer als {figur} erkennen', + spitznameGemerkt: '„{name}“ ist jetzt ein Spitzname von {figur}.', ortAnlegen: 'Ort anlegen', figurAnlegen: 'Figur anlegen', verwerfen: 'Verwerfen', diff --git a/src/i18n/de/schreiben.ts b/src/i18n/de/schreiben.ts index 938a410..a5d70fa 100644 --- a/src/i18n/de/schreiben.ts +++ b/src/i18n/de/schreiben.ts @@ -85,6 +85,20 @@ export const schreiben = { ebene: 'Gewissheit', ebeneTitel: 'Gewissheit dieser Stelle: gesichert, noch offen oder bewusst vage', ebenenLabel: 'Gewissheit der Stelle', + figur: 'Figur', + figurTitel: 'Als neue Figur anlegen oder als Spitznamen einer Figur übernehmen', + }, + /** Panel „Figur“ der Auswahl-Leiste (FigurPanel.tsx) */ + figurPanel: { + name: 'Name bzw. Spitzname', + neueFigur: '+ Neue Figur {name}', + gehoertZu: '{name} gehört schon zu {figur}.', + spitznameVon: 'Spitzname von …', + suchen: 'Figur suchen', + keine: 'Keine Figur gefunden', + angelegt: 'Figur {name} angelegt.', + spitznameNeu: '{name} ist jetzt ein Spitzname von {figur}.', + spitznameDa: '{figur} heißt schon {name}.', }, /** Notizen am Rand (Randnotizen.tsx, ziele.ts) */ notizen: { diff --git a/src/i18n/en/beziehungen.ts b/src/i18n/en/beziehungen.ts index 5cc16d5..80dcc92 100644 --- a/src/i18n/en/beziehungen.ts +++ b/src/i18n/en/beziehungen.ts @@ -178,6 +178,8 @@ export const beziehungen: Form = { hauptfigur: 'Main character', nebenfigur: 'Supporting character', auch: 'also: {namen}', + spitznamenFuer: 'Nicknames for {name}', + alsSpitznamen: 'Add as nicknames', inOrt: 'in {name}', legtAuchAn: { one: 'Also creates the character {namen}', other: 'Also creates the characters {namen}' }, abKap: ' · from {kap}', diff --git a/src/i18n/en/ki.ts b/src/i18n/en/ki.ts index 1f16685..27258ab 100644 --- a/src/i18n/en/ki.ts +++ b/src/i18n/en/ki.ts @@ -106,6 +106,9 @@ export const ki: Form = { unbekannterOrt: 'unknown place', unbekannteFigur: 'unknown character', bestaetigen: 'Confirm', + spitzname: 'Remember as nickname', + spitznameTitel: 'Confirm and always recognize “{name}” as {figur} from now on', + spitznameGemerkt: '“{name}” is now a nickname of {figur}.', ortAnlegen: 'Create place', figurAnlegen: 'Create character', verwerfen: 'Dismiss', diff --git a/src/i18n/en/schreiben.ts b/src/i18n/en/schreiben.ts index 519a7b1..3ec148c 100644 --- a/src/i18n/en/schreiben.ts +++ b/src/i18n/en/schreiben.ts @@ -83,6 +83,19 @@ export const schreiben: Form = { ebene: 'Certainty', ebeneTitel: 'Certainty of this passage: confirmed, open or deliberately vague', ebenenLabel: 'Certainty of the passage', + figur: 'Character', + figurTitel: 'Create as a new character or add as a nickname of a character', + }, + figurPanel: { + name: 'Name or nickname', + neueFigur: '+ New character {name}', + gehoertZu: '{name} already belongs to {figur}.', + spitznameVon: 'Nickname of …', + suchen: 'Search characters', + keine: 'No character found', + angelegt: 'Character {name} created.', + spitznameNeu: '{name} is now a nickname of {figur}.', + spitznameDa: '{figur} is already called {name}.', }, notizen: { notiz: 'Comment', diff --git a/src/state/slices/gruppenFiguren.test.ts b/src/state/slices/gruppenFiguren.test.ts index 3ac3100..c55b766 100644 --- a/src/state/slices/gruppenFiguren.test.ts +++ b/src/state/slices/gruppenFiguren.test.ts @@ -64,3 +64,22 @@ describe('Figuren zusammenführen (Store)', () => { expect(store.getState().auswahl.figur).toBe('ole'); }); }); + +describe('KI-Vorschlag als Spitznamen übernehmen (F-10)', () => { + it('ergänzt die Spitznamen der Zielfigur statt eine Figur anzulegen', async () => { + const { store } = await laden(); + const s = store.getState(); + const anzahl = Object.keys(s.daten!.figuren).length; + await s.speichereKiVorschlaege({ + version: 1, + figuren: [{ schluessel: 'spitzname:ole|der-kollege', name: 'der Kollege', aliase: ['Fenster-Ole'], rolle: 'neben', kurz: '', belege: [], status: 'neu', ziel: 'ole' }], + orte: [], + beziehungen: [], + }); + await store.getState().vorschlagUebernehmen('figur', 'spitzname:ole|der-kollege'); + const d = store.getState().daten!; + expect(Object.keys(d.figuren)).toHaveLength(anzahl); + expect(d.figuren.ole!.aliase).toEqual(expect.arrayContaining(['der Kollege', 'Fenster-Ole'])); + expect(d.kiVorschlaege!.figuren[0]).toMatchObject({ status: 'uebernommen', figur: 'ole' }); + }); +}); diff --git a/src/state/slices/planung.ts b/src/state/slices/planung.ts index 58365e5..b459fbb 100644 --- a/src/state/slices/planung.ts +++ b/src/state/slices/planung.ts @@ -5,6 +5,7 @@ */ import type { StateCreator } from 'zustand'; import type { Index } from '@core/index/index'; +import { mitSpitzname } from '@core/figuren/namen'; import type { AbweichungsEntscheidung, Akt, @@ -350,6 +351,8 @@ export interface Planung { // Figuren figurAnlegen(name: string, rolle?: FigurRolle): Promise; figurAendern(id: Id, teil: Partial>): Promise; + /** F-09/F-10: Spitznamen ergänzen (für die Erkennung); false, wenn die Figur ihn schon trägt */ + spitznameHinzufuegen(figur: Id, name: string): Promise; planFeldSetzen(figur: Id, feld: string, wert: string): Promise; /** gibt den tatsächlichen (eindeutigen) Feldnamen zurück */ planFeldHinzufuegen(figur: Id, feld: string): Promise; @@ -637,6 +640,13 @@ export const createPlanungSlice: StateCreator = (se await get().speichereFigur({ ...figur(id), ...teil, id }); }, + async spitznameHinzufuegen(id, name) { + const neu = mitSpitzname(figur(id), name); + if (!neu) return false; + await get().speichereFigur(neu); + return true; + }, + async planFeldSetzen(id, feld, wert) { const f = figur(id); await get().speichereFigur({ ...f, plan: { ...f.plan, [feld]: wert } }); diff --git a/src/state/slices/vorschlaege.ts b/src/state/slices/vorschlaege.ts index 2752153..bfbb4cd 100644 --- a/src/state/slices/vorschlaege.ts +++ b/src/state/slices/vorschlaege.ts @@ -117,6 +117,13 @@ export const createVorschlaegeSlice: StateCreator Date: Mon, 12 Oct 2026 23:57:13 +0200 Subject: [PATCH 06/13] =?UTF-8?q?Namens-Hinweise:=20Spitzname=20vor=20dem?= =?UTF-8?q?=20Namen,=20=C3=A4hnliche=20Namen,=20ruhende=20Figuren=20(Phase?= =?UTF-8?q?=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer lokaler Hinweistyp „figuren“ (core/figuren/hinweise.ts, über hinweiseBerechnen): - F-11: Figur wird zuerst über einen Spitznamen erkannt, der Name fällt erst später (nicht bei „bewusst vage“) - F-12: Namen zweier Figuren im Text unterscheiden sich nur in einem Buchstaben oder beginnen gleich und sind gleich lang - F-13: Figur kommt N Kapitel mit Text lang nicht vor (Kapitelplan zählt als Vorkommen); Standard Hauptfigur 3, Nebenfigur 6; Figur.ruheSchwelle (null = nie) mit Auswahl „Ruhe melden“ auf der Figurenkarte; ruhende Kapitel im Auftrittsstreifen gelb unterstrichen Schlüssel hängen an Figur und Namen bzw. Kapitel, damit Entscheidungen Textänderungen überstehen. Co-Authored-By: Claude Opus 5.5 --- core/figuren/hinweise.test.ts | 125 +++++++++++ core/figuren/hinweise.ts | 246 +++++++++++++++++++++ core/figuren/index.ts | 1 + core/index/hinweisTypen.ts | 2 + core/index/index.ts | 6 +- core/model/types.ts | 9 +- core/projekt/lesen.ts | 3 +- core/projekt/schreiben.ts | 2 +- src/features/figuren/Figurenkarte.tsx | 60 ++++- src/features/hinweise/hinweise.en.test.tsx | 2 +- src/features/hinweise/logik.ts | 1 + src/i18n/de/figuren.ts | 8 + src/i18n/de/hinweise.ts | 2 + src/i18n/de/schreiben.ts | 1 + src/i18n/en/figuren.ts | 8 + src/i18n/en/hinweise.ts | 2 + src/i18n/en/schreiben.ts | 1 + 17 files changed, 467 insertions(+), 12 deletions(-) create mode 100644 core/figuren/hinweise.test.ts create mode 100644 core/figuren/hinweise.ts diff --git a/core/figuren/hinweise.test.ts b/core/figuren/hinweise.test.ts new file mode 100644 index 0000000..4761c25 --- /dev/null +++ b/core/figuren/hinweise.test.ts @@ -0,0 +1,125 @@ +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 Marian nichts.'], [figur('marian', 'Marian', { aliase: ['Alte'] })]); + const h = hinweise(d, 'vorher'); + expect(h).toHaveLength(1); + expect(h[0]!.schluessel).toBe('figuren:vorher:marian|alte'); + expect(h[0]!.zitate).toEqual([ + { kapitel: 'k1', text: 'Der Alte stand am Fenster.' }, + { kapitel: 'k2', text: 'Später sagte Marian 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(['Marians Hut lag da. Der Alte kam.'], [figur('marian', 'Marian', { aliase: ['Alte'] })]), 'vorher')).toEqual([]); + expect(hinweise(projekt(['Der Alte kam.'], [figur('marian', 'Marian', { aliase: ['Alte'] })]), 'vorher')).toEqual([]); + expect( + hinweise(projekt(['Der Alte kam.', 'Marian ging.'], [figur('marian', 'Marian', { 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( + ['Marian kam mit Marion. Sabine und Sabina lachten.'], + [figur('marian', 'Marian'), figur('marion', 'Marion'), figur('sabine', 'Sabine'), figur('sabina', 'Sabina')], + ); + expect(hinweise(d, 'aehnlich').map((h) => h.schluessel)).toEqual([ + 'figuren:aehnlich:marian|marion|Marian|Marion', + '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(['Marian kam.'], [figur('marian', 'Marian'), figur('marion', 'Marion')]), '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([]); + }); +}); diff --git a/core/figuren/hinweise.ts b/core/figuren/hinweise.ts new file mode 100644 index 0000000..06e4ac8 --- /dev/null +++ b/core/figuren/hinweise.ts @@ -0,0 +1,246 @@ +/** + * 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}).', + }, + 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}).', + }, + zitat: '“{text}”', + }, +); + +/** 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-12: verwechselbare Namen (beide Figuren im Text, keine Statisten) + const imText = figuren.filter((f) => 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-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 56bfc41..ddff438 100644 --- a/core/figuren/index.ts +++ b/core/figuren/index.ts @@ -6,3 +6,4 @@ export * from './boegen'; export * from './stimme'; export * from './zusammenfuehren'; export * from './namen'; +export * from './hinweise'; diff --git a/core/index/hinweisTypen.ts b/core/index/hinweisTypen.ts index 45c31c0..9529bdd 100644 --- a/core/index/hinweisTypen.ts +++ b/core/index/hinweisTypen.ts @@ -11,6 +11,7 @@ export const HINWEIS_TYP_NAME: Record = { struktur: 'Struktur', zeitleiste: 'Zeitleiste', sprache: 'Sprache', + figuren: 'Figuren', }; export const HINWEIS_TYP_REIHE = Object.keys(HINWEIS_TYP_NAME) as HinweisTyp[]; @@ -23,6 +24,7 @@ const HINWEIS_TYP_NAME_EN: Record = { 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..effbf46 100644 --- a/core/index/index.ts +++ b/core/index/index.ts @@ -12,6 +12,7 @@ import type { Zitat, } from '../model/types'; import { stimmenHinweise } from '../figuren/stimme'; +import { figurenHinweise } from '../figuren/hinweise'; import type { Sprache } from '../i18n'; import { strukturHinweise } from '../struktur'; import { zeitHinweise } from '../zeit'; @@ -165,7 +166,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 +176,7 @@ function hinweiseBerechnen(daten: ProjektDaten, sprache: Sprache): Hinweis[] { ...schreibweisenHinweise(daten, sprache), ...zeitHinweise(daten, sprache), ...stimmenHinweise(daten, sprache), + ...figurenHinweise(daten, erwaehnungen, sprache), ]; const ki = (daten.kiHinweise ?? []) .filter((h) => kiHinweisGueltig(h, daten)) @@ -240,7 +242,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/model/types.ts b/core/model/types.ts index 4481ec7..6f24b50 100644 --- a/core/model/types.ts +++ b/core/model/types.ts @@ -205,6 +205,11 @@ 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; } export type GruppenTyp = 'antagonist' | 'firma' | 'familie' | 'sonstige'; @@ -349,7 +354,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; diff --git a/core/projekt/lesen.ts b/core/projekt/lesen.ts index 18fc641..9db0319 100644 --- a/core/projekt/lesen.ts +++ b/core/projekt/lesen.ts @@ -453,6 +453,7 @@ 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, }); } @@ -600,7 +601,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)) diff --git a/core/projekt/schreiben.ts b/core/projekt/schreiben.ts index 3de3acb..36013ce 100644 --- a/core/projekt/schreiben.ts +++ b/core/projekt/schreiben.ts @@ -75,7 +75,7 @@ const S = { strang: ['id', 'name', 'farbe', 'gruppe'], 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'], beziehung: ['zu', 'art', 'abKapitel', 'kategorie', 'strang', 'notiz', 'unsicher'], gruppe: ['id', 'name', 'typ', 'status', 'mitglieder', 'strang', 'spuren', 'praesenz', 'netz'], spur: ['id', 'text', 'kapitel', 'anker', 'status'], diff --git a/src/features/figuren/Figurenkarte.tsx b/src/features/figuren/Figurenkarte.tsx index a9796ad..011a526 100644 --- a/src/features/figuren/Figurenkarte.tsx +++ b/src/features/figuren/Figurenkarte.tsx @@ -1,6 +1,6 @@ /** F-02–F-05: Figurenkarte nach docs/design/screens/Figur.dc.html. */ import { useId, useState, type CSSProperties } from 'react'; -import { bogenVon, sortiereStufen, stufeImKapitel, stufenSpanne } from '@core/figuren/index'; +import { bogenVon, RUHE_SCHWELLE, ruhendeStrecken, ruheSchwelle, sortiereStufen, stufeImKapitel, stufenSpanne } from '@core/figuren/index'; import type { Index } from '@core/index/index'; import { hauptfigur } from '@core/zeit/zustand'; import type { Beziehung, BeziehungsKategorie, Erwaehnung, Figur, FigurRolle, Hinweis, Id, ProjektDaten } from '@core/model/types'; @@ -328,20 +328,63 @@ function DeinePlanung({ f, daten }: { f: Figur; daten: ProjektDaten }) { ); } -function Auftritte({ daten, nachKapitel, farbe }: { daten: ProjektDaten; nachKapitel: Map; farbe: string }) { +/** Auswahl „Ruht seit … melden“: Standard nach Rolle, feste Schwellen oder nie (F-13). */ +const RUHE_STUFEN = [2, 3, 4, 6, 10] as const; + +function Auftritte({ + f, + daten, + index, + nachKapitel, + farbe, +}: { + f: Figur; + daten: ProjektDaten; + index: Index; + nachKapitel: Map; + farbe: string; +}) { const markiere = useStore((s) => s.markiere); + const figurAendern = useStore((s) => s.planung.figurAendern); + const uid = useId(); const arten = artJeKapitel([...nachKapitel.values()].flat()); const reihenfolge = daten.projekt.kapitel; const t = useTexte().figuren; const tau = t.karte.auftritte; + const ruhend = new Set(ruhendeStrecken(daten, index.erwaehnungen, f).flatMap((s) => s.kapitel)); + const standard = f.rolle === 'haupt' ? RUHE_SCHWELLE.haupt : f.rolle === 'neben' ? RUHE_SCHWELLE.neben : null; + const wert = f.ruheSchwelle === undefined ? 'standard' : f.ruheSchwelle === null ? 'nie' : String(f.ruheSchwelle); + const schwelle = ruheSchwelle(f); return (
- {tau.titel} + + {tau.titel} + + +
{reihenfolge.map((k, i) => { const art = arten.get(k); const ohneText = !daten.kapitel[k]?.text.trim(); - const titel = `${kapKurz(i + 1)} ${daten.plaene[k]?.titel ?? ''} · ${art ? t.art[art] : ohneText ? tau.keinText : '–'}`; + const titel = `${kapKurz(i + 1)} ${daten.plaene[k]?.titel ?? ''} · ${art ? t.art[art] : ohneText ? tau.keinText : '–'}${ruhend.has(k) ? ` · ${tau.ruht}` : ''}`; const stil: CSSProperties = { height: 34, borderRadius: 4, @@ -358,6 +401,8 @@ function Auftritte({ daten, nachKapitel, farbe }: { daten: ProjektDaten; nachKap else if (art === 'unbestimmt') Object.assign(stil, { background: `color-mix(in srgb, ${farbe} 22%, var(--surface))`, color: 'var(--ink)', fontWeight: 600 }); else if (ohneText) stil.background = 'repeating-linear-gradient(135deg, var(--schraffur) 0 4px, var(--surface) 4px 8px)'; + // F-13: Kapitel einer ruhenden Strecke + if (ruhend.has(k)) Object.assign(stil, { boxShadow: 'inset 0 -3px 0 var(--ampel-gelb)' }); const e = nachKapitel.get(k)?.[0]; return e ? (
); } @@ -850,7 +898,7 @@ export function Figurenkarte({ id, daten, index }: { id: Id; daten: ProjektDaten
- +
diff --git a/src/features/hinweise/hinweise.en.test.tsx b/src/features/hinweise/hinweise.en.test.tsx index 37f4f36..dad9008 100644 --- a/src/features/hinweise/hinweise.en.test.tsx +++ b/src/features/hinweise/hinweise.en.test.tsx @@ -54,7 +54,7 @@ describe('Hinweise-Ansicht auf Englisch', () => { it('Typ- und Status-Bezeichnungen sowie der Kontext folgen der Sprache', () => { expect(STATUS_LABEL).toMatchObject({ neu: 'New', absicht: 'Intentional', erledigt: 'Done' }); expect(TYP_LABEL.pruefen).toBe('Check'); - expect(HINWEIS_TYPEN.map((t) => t.label)).toEqual(['Check', 'Spelling', 'Open thread', 'Unknown', 'Structure', 'Timeline', 'Language']); + expect(HINWEIS_TYPEN.map((t) => t.label)).toEqual(['Check', 'Spelling', 'Open thread', 'Unknown', 'Structure', 'Timeline', 'Language', 'Characters']); expect(kontext(useStore.getState())).toMatch(/^Notes( · \d+ new)?$/); festeSprache('de'); expect(STATUS_LABEL.absicht).toBe('Ist Absicht'); diff --git a/src/features/hinweise/logik.ts b/src/features/hinweise/logik.ts index 375fa02..e843723 100644 --- a/src/features/hinweise/logik.ts +++ b/src/features/hinweise/logik.ts @@ -30,6 +30,7 @@ const TYP_FARBEN: [HinweisTyp, string][] = [ ['struktur', 'var(--ink-soft)'], ['zeitleiste', 'var(--organisation-dark)'], ['sprache', 'var(--ink-quiet)'], + ['figuren', 'var(--link)'], ]; /** Reihenfolge und Bezeichnungen wie in der Anforderung (Hinweis-Typen); Texte folgen der Oberflächensprache. */ diff --git a/src/i18n/de/figuren.ts b/src/i18n/de/figuren.ts index 331e95d..59ba830 100644 --- a/src/i18n/de/figuren.ts +++ b/src/i18n/de/figuren.ts @@ -83,6 +83,13 @@ export const figuren = { titel: 'Auftritte', keinText: 'noch kein Text', legende: 'Gefüllt: tritt auf · umrandet: nur per Nachricht · gestrichelt: nur erwähnt · hell: im Text, Art noch offen', + ruheLabel: 'Ruhe melden', + ruheStandard: 'nach {n} Kapiteln (Standard)', + ruheStandardNie: 'nie (Standard)', + ruheNach: 'nach {n} Kapiteln', + ruheNie: 'nie', + ruht: 'ruht', + ruhtLegende: 'gelb unterstrichen: {n} Kapitel oder länger nicht im Text', }, bogen: { titel: 'Bogen', @@ -148,6 +155,7 @@ export const figuren = { struktur: 'Struktur', zeitleiste: 'Zeitleiste', sprache: 'Sprache', + figuren: 'Figuren', }, }, loeschen: 'Figur löschen', diff --git a/src/i18n/de/hinweise.ts b/src/i18n/de/hinweise.ts index b772a4a..21fef8b 100644 --- a/src/i18n/de/hinweise.ts +++ b/src/i18n/de/hinweise.ts @@ -9,6 +9,7 @@ export const hinweise = { struktur: 'Struktur', zeitleiste: 'Zeitleiste', sprache: 'Sprache', + figuren: 'Figuren', }, /** Kurzbeschreibung je Typ (Gruppenkopf) */ typBeschreibung: { @@ -19,6 +20,7 @@ export const hinweise = { struktur: 'leere Kapitel, Satzabbrüche, leere Überschriften', zeitleiste: 'Zeitpunkte, die nicht zusammenpassen, und zeitliche Verankerung', sprache: 'Sprache und Figurenstimme – nur markiert, nie korrigiert', + figuren: 'Spitznamen vor dem Namen, verwechselbare Namen, ruhende Figuren', }, /** Status (neu / Entscheidung) */ status: { neu: 'Neu', absicht: 'Ist Absicht', erledigt: 'Erledigt' }, diff --git a/src/i18n/de/schreiben.ts b/src/i18n/de/schreiben.ts index a5d70fa..7148e55 100644 --- a/src/i18n/de/schreiben.ts +++ b/src/i18n/de/schreiben.ts @@ -142,6 +142,7 @@ export const schreiben = { struktur: 'Struktur', zeitleiste: 'Zeitleiste', sprache: 'Sprache', + figuren: 'Figuren', }, // Erkannt keineErkannt: 'Noch keine Figuren oder Orte im Text erkannt.', diff --git a/src/i18n/en/figuren.ts b/src/i18n/en/figuren.ts index 9773b01..3a7f31e 100644 --- a/src/i18n/en/figuren.ts +++ b/src/i18n/en/figuren.ts @@ -81,6 +81,13 @@ export const figuren: Form = { titel: 'Appearances', keinText: 'no text yet', legende: 'Filled: appears · outlined: by message only · dashed: only mentioned · light: in the text, kind still open', + ruheLabel: 'Report absence', + ruheStandard: 'after {n} chapters (default)', + ruheStandardNie: 'never (default)', + ruheNach: 'after {n} chapters', + ruheNie: 'never', + ruht: 'absent', + ruhtLegende: 'yellow underline: not in the text for {n} chapters or more', }, bogen: { titel: 'Arc', @@ -146,6 +153,7 @@ export const figuren: Form = { struktur: 'Structure', zeitleiste: 'Timeline', sprache: 'Language', + figuren: 'Characters', }, }, loeschen: 'Delete character', diff --git a/src/i18n/en/hinweise.ts b/src/i18n/en/hinweise.ts index da4655d..6a400e2 100644 --- a/src/i18n/en/hinweise.ts +++ b/src/i18n/en/hinweise.ts @@ -10,6 +10,7 @@ export const hinweise: Form = { struktur: 'Structure', zeitleiste: 'Timeline', sprache: 'Language', + figuren: 'Characters', }, typBeschreibung: { pruefen: 'details that might contradict each other', @@ -19,6 +20,7 @@ export const hinweise: Form = { struktur: 'empty chapters, broken-off sentences, empty headings', zeitleiste: 'points in time that don’t fit together, and anchoring in time', sprache: 'language and character voice – only flagged, never corrected', + figuren: 'nicknames before the name, names easy to confuse, characters absent for a while', }, status: { neu: 'New', absicht: 'Intentional', erledigt: 'Done' }, titel: 'Notes', diff --git a/src/i18n/en/schreiben.ts b/src/i18n/en/schreiben.ts index 3ec148c..7f4db13 100644 --- a/src/i18n/en/schreiben.ts +++ b/src/i18n/en/schreiben.ts @@ -136,6 +136,7 @@ export const schreiben: Form = { struktur: 'Structure', zeitleiste: 'Timeline', sprache: 'Language', + figuren: 'Characters', }, keineErkannt: 'No characters or places detected in the text yet.', imTextKarte: '{n}× in the text · Open card', From 8b0038281a3a4eb765ed13ded1f8c8c01d80f685 Mon Sep 17 00:00:00 2001 From: Leon Machnik <98738343+leonatwork@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:14:39 +0200 Subject: [PATCH 07/13] Statisten als dritte Rolle (Phase 4, F-14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FigurRolle „statist“ (additiv); Listen sortieren nach Rolle (nachRolle) - Figurenliste: eigener, eingeklappter Bereich „Statisten“ mit Kapiteln; offen bei Suche oder gewähltem Statisten - Figurenkarte: Rolle „Statist“; nur ausgefüllte Planfelder (übrige auf Klick), Bogen nur, wenn schon geplant - Beziehungsnetz: Statisten standardmäßig aus, Filter-Chip „Statisten“ - KI-Vorschläge: Rolle statist im Prompt und Schema, „Alle Statisten übernehmen“ - Hinweis „zur Nebenfigur machen?“ ab drei Kapiteln; Statisten ruhen nie und zählen nicht bei verwechselbaren Namen; nie im Exposé Co-Authored-By: Claude Opus 5.5 --- core/abgabe/expose.ts | 5 +-- core/figuren/hinweise.test.ts | 18 ++++++++++ core/figuren/hinweise.ts | 27 +++++++++++++- core/figuren/namen.ts | 10 +++++- core/ki/kontext.ts | 10 +++--- core/ki/stimme.ts | 4 +-- core/ki/vorschlaege.ts | 8 ++--- core/ki/zeit.ts | 4 +-- core/model/types.ts | 2 +- core/projekt/lesen.ts | 4 +-- core/recherche/index.ts | 3 +- src/backend/ki/demo/sprachen.ts | 2 +- src/features/beziehungen/Netz.tsx | 2 +- src/features/beziehungen/Vorschlaege.tsx | 12 ++++++- src/features/beziehungen/index.tsx | 11 ++++-- src/features/beziehungen/logik.ts | 5 +-- src/features/figuren/Figurenkarte.tsx | 16 +++++++-- src/features/figuren/Liste.tsx | 27 +++++++++++++- src/features/schreiben/Randnotizen.tsx | 3 +- src/features/schreiben/verweise.ts | 3 +- src/features/zeitleiste/Inspektor.tsx | 3 +- .../zeitleiste/ZeitVorschlagDialog.tsx | 3 +- src/features/zeitleiste/index.tsx | 3 +- src/i18n/de/beziehungen.ts | 5 +++ src/i18n/de/figuren.ts | 6 +++- src/i18n/en/beziehungen.ts | 5 +++ src/i18n/en/figuren.ts | 6 +++- src/state/slices/gruppenFiguren.test.ts | 36 ++++++++++++++++++- src/state/slices/netz.ts | 5 +++ src/state/slices/planung.ts | 6 ++-- src/state/slices/vorschlaege.ts | 9 +++++ 31 files changed, 223 insertions(+), 40 deletions(-) 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/figuren/hinweise.test.ts b/core/figuren/hinweise.test.ts index 4761c25..1512030 100644 --- a/core/figuren/hinweise.test.ts +++ b/core/figuren/hinweise.test.ts @@ -123,3 +123,21 @@ describe('F-13 ruhende Figuren', () => { 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([]); + }); +}); diff --git a/core/figuren/hinweise.ts b/core/figuren/hinweise.ts index 06e4ac8..3c0755a 100644 --- a/core/figuren/hinweise.ts +++ b/core/figuren/hinweise.ts @@ -27,6 +27,9 @@ const T = texteJe( 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}).', }, + 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}«', }, { @@ -42,10 +45,16 @@ const T = texteJe( one: 'After {nr}, {figur} does not appear for one chapter ({von}).', other: 'After {nr}, {figur} does not appear for {n} chapters ({von}–{bis}).', }, + 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; @@ -166,7 +175,7 @@ export function figurenHinweise(daten: ProjektDaten, erwaehnungen: Erwaehnung[], } // F-12: verwechselbare Namen (beide Figuren im Text, keine Statisten) - const imText = figuren.filter((f) => nachFigur.has(f.id)); + 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]!; @@ -197,6 +206,22 @@ export function figurenHinweise(daten: ProjektDaten, erwaehnungen: Erwaehnung[], ); } + // 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)) { diff --git a/core/figuren/namen.ts b/core/figuren/namen.ts index 116fade..51297aa 100644 --- a/core/figuren/namen.ts +++ b/core/figuren/namen.ts @@ -2,7 +2,7 @@ * 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, Id, ProjektDaten } from '../model/types'; +import type { Figur, FigurRolle, Id, ProjektDaten } 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; @@ -63,3 +63,11 @@ export function figurenSuchen(daten: Pick, suche: strin .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); +} diff --git a/core/ki/kontext.ts b/core/ki/kontext.ts index 4f53d02..5ef7d4b 100644 --- a/core/ki/kontext.ts +++ b/core/ki/kontext.ts @@ -11,7 +11,7 @@ * 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 +32,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[]; @@ -99,6 +99,7 @@ const BESCHRIFTUNG = { auch: 'auch', haupt: 'Hauptfigur', neben: 'Nebenfigur', + statist: 'Statist', existenz: 'Existenz', planung: 'Planung', imText: 'im Text', @@ -120,6 +121,7 @@ const BESCHRIFTUNG = { auch: 'also', haupt: 'main character', neben: 'minor character', + statist: 'walk-on character', existenz: 'Existence', planung: 'Plan', imText: 'in the text', @@ -178,7 +180,7 @@ 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 ?? {}) @@ -229,7 +231,7 @@ function renderBuch( 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/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/vorschlaege.ts b/core/ki/vorschlaege.ts index eb30e57..571a86a 100644 --- a/core/ki/vorschlaege.ts +++ b/core/ki/vorschlaege.ts @@ -63,7 +63,7 @@ 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: 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), sonst neben. +- 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. @@ -99,7 +99,7 @@ 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: 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), otherwise neben. +- 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. @@ -151,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, }), @@ -619,7 +619,7 @@ export function pruefeVorschlaege( schluessel, name, aliase, - rolle: (f.rolle === 'haupt' ? 'haupt' : 'neben') satisfies FigurRolle, + rolle: (f.rolle === 'haupt' || f.rolle === 'statist' ? f.rolle : 'neben') satisfies FigurRolle, kurz, belege, status: 'neu', 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 6f24b50..adb311b 100644 --- a/core/model/types.ts +++ b/core/model/types.ts @@ -182,7 +182,7 @@ export interface Beziehung { unsicher?: boolean; } -export type FigurRolle = 'haupt' | 'neben'; +export type FigurRolle = 'haupt' | 'neben' | 'statist'; /** figuren/.md – Body-Abschnitte „## Wahrheit hinter den Kulissen“ und „## Notizen“ (beide privat) */ export interface Figur { diff --git a/core/projekt/lesen.ts b/core/projekt/lesen.ts index 9db0319..ef828c0 100644 --- a/core/projekt/lesen.ts +++ b/core/projekt/lesen.ts @@ -444,7 +444,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, @@ -653,7 +653,7 @@ 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'), 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/src/backend/ki/demo/sprachen.ts b/src/backend/ki/demo/sprachen.ts index 28b3db2..13f53b1 100644 --- a/src/backend/ki/demo/sprachen.ts +++ b/src/backend/ki/demo/sprachen.ts @@ -13,7 +13,7 @@ export type DemoBeleg = [kapitel: string, zitat: string]; export interface DemoFigur { name: string; aliase: string[]; - rolle: 'haupt' | 'neben'; + rolle: 'haupt' | 'neben' | 'statist'; kurz: string; belege: DemoBeleg[]; } diff --git a/src/features/beziehungen/Netz.tsx b/src/features/beziehungen/Netz.tsx index dff501e..254d896 100644 --- a/src/features/beziehungen/Netz.tsx +++ b/src/features/beziehungen/Netz.tsx @@ -82,7 +82,7 @@ export function Netz({ daten, index, ansicht: basis, standIdx, filter }: NetzPro ...g, netz: { ...g.netz, breite: Math.max(120, g.netz.breite + zug.dx), hoehe: Math.max(80, g.netz.hoehe + zug.dy) }, }; - return netzBerechnen({ ...daten, figuren, gruppen }, index, standIdx, filter); + return netzBerechnen({ ...daten, figuren, gruppen }, index, standIdx, filter, useStore.getState().netzStatisten); }, [basis, zug, daten, index, standIdx, filter]); const punkt = (e: { clientX: number; clientY: number }) => { diff --git a/src/features/beziehungen/Vorschlaege.tsx b/src/features/beziehungen/Vorschlaege.tsx index cb5fc99..1fd455b 100644 --- a/src/features/beziehungen/Vorschlaege.tsx +++ b/src/features/beziehungen/Vorschlaege.tsx @@ -284,6 +284,7 @@ export function VorschlaegePanel({ daten }: { daten: ProjektDaten }) { const uebernehmen = useStore((s) => s.vorschlagUebernehmen); const verwerfen = useStore((s) => s.vorschlagVerwerfen); const alleUebernehmen = useStore((s) => s.alleVorschlaegeUebernehmen); + const statistenUebernehmen = useStore((s) => s.alleStatistenUebernehmen); const [arbeitet, setArbeitet] = useState(null); const [meldung, setMeldung] = useState(null); const [aktionsFehler, setAktionsFehler] = useState(null); @@ -323,6 +324,10 @@ export function VorschlaegePanel({ daten }: { daten: ProjektDaten }) { }); }); + const statisten = () => + void ausfuehren('statisten', async () => f(tv.statistenUebernommen, { figuren: f(tv.figurenAnzahl, { n: await statistenUebernehmen() }) })); + const offeneStatisten = offen.figuren.filter((x) => x.rolle === 'statist' && !x.ziel).length; + const a = ablage ?? { version: 1 as const, figuren: [], orte: [], beziehungen: [] }; return ( @@ -371,6 +376,11 @@ export function VorschlaegePanel({ daten }: { daten: ProjektDaten }) { )} + {offeneStatisten > 1 && ( + + )} {offen.figuren.map((fv: FigurVorschlag) => fv.ziel ? ( @@ -384,7 +394,7 @@ export function VorschlaegePanel({ daten }: { daten: ProjektDaten }) {
{fv.name} - {fv.rolle === 'haupt' ? tv.hauptfigur : tv.nebenfigur} + {fv.rolle === 'haupt' ? tv.hauptfigur : fv.rolle === 'statist' ? tv.statist : tv.nebenfigur}
{fv.kurz && {fv.kurz}} {fv.aliase.length > 0 && {f(tv.auch, { namen: fv.aliase.join(', ') })}} diff --git a/src/features/beziehungen/index.tsx b/src/features/beziehungen/index.tsx index 9fca883..91c53d9 100644 --- a/src/features/beziehungen/index.tsx +++ b/src/features/beziehungen/index.tsx @@ -16,6 +16,8 @@ export default function Ansicht() { const index = useStore((s) => s.index); const stand = useStore((s) => s.netzStand); const filter = useStore((s) => s.netzFilter); + const statisten = useStore((s) => s.netzStatisten); + const umschalteNetzStatisten = useStore((s) => s.umschalteNetzStatisten); const verbinden = useStore((s) => s.netzVerbinden); const globaleFigur = useStore((s) => s.auswahl.figur); const globaleGruppe = useStore((s) => s.auswahl.gruppe); @@ -64,8 +66,8 @@ export default function Ansicht() { // Beschriftungen aus logik.ts folgen der Oberflächensprache: beim Umschalten neu berechnen const stufen = useMemo(() => (daten ? aktStufen(daten) : []), [daten, sprache]); const ansicht = useMemo( - () => (daten ? netzBerechnen(daten, index, standIdx, filter) : null), - [daten, index, standIdx, filter, sprache], + () => (daten ? netzBerechnen(daten, index, standIdx, filter, statisten) : null), + [daten, index, standIdx, filter, statisten, sprache], ); if (!daten || !ansicht) return null; @@ -167,6 +169,11 @@ export default function Ansicht() { {t.beziehungen.kategorie[k]} ))} + {Object.values(daten.figuren).some((x) => x.rolle === 'statist') && ( + + {ta.statisten} + + )}
{ablage.length > 0 && (
diff --git a/src/features/beziehungen/logik.ts b/src/features/beziehungen/logik.ts index db2ed68..a038597 100644 --- a/src/features/beziehungen/logik.ts +++ b/src/features/beziehungen/logik.ts @@ -260,10 +260,11 @@ function rechteckVon(f: Figur): Rechteck | null { const einWort = (s: string) => !/\s/.test(s.trim()) && s.trim().length > 0 && s.trim().length <= 24; -export function netzBerechnen(daten: ProjektDaten, index: Index, standIdx: number, filter: NetzFilter): NetzAnsicht { +export function netzBerechnen(daten: ProjektDaten, index: Index, standIdx: number, filter: NetzFilter, statisten = false): NetzAnsicht { const t = texte(); const tl = t.beziehungen.logik; - const figuren = Object.values(daten.figuren); + // F-14: Statisten nur auf Wunsch + const figuren = Object.values(daten.figuren).filter((f) => statisten || f.rolle !== 'statist'); const sichtbar = new Set(figuren.filter((f) => figurSichtbar(daten, index, f.id, standIdx)).map((f) => f.id)); // Paare am Stand diff --git a/src/features/figuren/Figurenkarte.tsx b/src/features/figuren/Figurenkarte.tsx index 011a526..988ff9e 100644 --- a/src/features/figuren/Figurenkarte.tsx +++ b/src/features/figuren/Figurenkarte.tsx @@ -218,6 +218,8 @@ function DeinePlanung({ f, daten }: { f: Figur; daten: ProjektDaten }) { const [umbenennen, setUmbenennen] = useState(null); /** Name der neuen Gruppe, solange das Eingabefeld offen ist */ const [neueGruppe, setNeueGruppe] = useState(null); + /** F-14: bei Statisten nur ausgefüllte Planfelder, die übrigen auf Klick */ + const [leereZeigen, setLeereZeigen] = useState(false); const uid = useId(); const t = useTexte(); const tp = t.figuren.karte.planung; @@ -229,6 +231,10 @@ function DeinePlanung({ f, daten }: { f: Figur; daten: ProjektDaten }) { else if (f.gruppe && daten.gruppen[f.gruppe]) void mitgliedEntfernen(f.gruppe, f.id); else void p.figurAendern(f.id, { gruppe: null }); }; + const alleFelder = Object.keys(f.plan); + const felder = + f.rolle === 'statist' && !leereZeigen ? alleFelder.filter((feld) => f.plan[feld]?.trim() || umbenennen === feld) : alleFelder; + const versteckt = alleFelder.length - felder.length; const gruppeAnlegenUndSetzen = async () => { const name = neueGruppe?.trim(); if (!name) return; @@ -250,6 +256,7 @@ function DeinePlanung({ f, daten }: { f: Figur; daten: ProjektDaten }) { optionen={[ { wert: 'haupt', label: t.figuren.rolle.haupt }, { wert: 'neben', label: t.figuren.rolle.neben }, + { wert: 'statist', label: t.figuren.rolle.statist }, ]} />
+ {versteckt > 0 && ( + + )} + {statistenSichtbar && + statisten.map((f) => )} + + )} {gruppe(tl.offen, b.offen, zeileOffen, 'var(--vage-dark)')} {tl.geplant} {b.geplant.filter(passt).map((f) => ( diff --git a/src/features/schreiben/Randnotizen.tsx b/src/features/schreiben/Randnotizen.tsx index 3547988..099d270 100644 --- a/src/features/schreiben/Randnotizen.tsx +++ b/src/features/schreiben/Randnotizen.tsx @@ -3,6 +3,7 @@ * Verweisen und Kapitel-Links; neue Notiz mit Ziel-Auswahl und `[[`-Vorschlägen; Sammelbox für * Notizen, deren Stelle nicht mehr gefunden wird. Ist der Rand zu schmal, stehen dort nur Punkte. */ +import { nachRolle } from '@core/figuren/namen'; import type { Anmerkung, Id, ProjektDaten, TextAnker, Verweis } from '@core/model/types'; import type { Editor } from '@tiptap/react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react'; @@ -49,7 +50,7 @@ function gleich(a: Record, b: Record): boolean { function ZielAuswahl({ daten, kapitelId, wert, onChange }: { daten: ProjektDaten; kapitelId: Id; wert: Verweis | null; onChange(v: Verweis | null): void }) { const T = useTexte().schreiben.notizen; - const figuren = Object.values(daten.figuren).sort((a, b) => (a.rolle === b.rolle ? vergleich(a.name, b.name) : a.rolle === 'haupt' ? -1 : 1)); + const figuren = Object.values(daten.figuren).sort(nachRolle((a, b) => vergleich(a.name, b.name))); const orte = Object.values(daten.orte).sort((a, b) => vergleich(a.name, b.name)); const kopf = notizKopf(daten, wert, kapitelId, true); return ( diff --git a/src/features/schreiben/verweise.ts b/src/features/schreiben/verweise.ts index ae55c72..b2b8721 100644 --- a/src/features/schreiben/verweise.ts +++ b/src/features/schreiben/verweise.ts @@ -4,6 +4,7 @@ * Die ID ist maßgeblich (E1), die Anzeige nur der Text zum Zeitpunkt des Einfügens. * Nicht im Manuskripttext selbst. */ +import { nachRolle } from '@core/figuren/namen'; import type { Id, ProjektDaten, Verweis, VerweisTyp } from '@core/model/types'; import type { Bereich, Store } from '../../state/store'; import { f, sprache, texte, vergleich } from '../../i18n'; @@ -87,7 +88,7 @@ export function alleVerweisZiele(daten: ProjektDaten): VerweisVorschlag[] { const liste: VerweisVorschlag[] = []; const art = verweisArten(); const englisch = sprache() === 'en'; - const figuren = Object.values(daten.figuren).sort((a, b) => (a.rolle === b.rolle ? vergleich(a.name, b.name) : a.rolle === 'haupt' ? -1 : 1)); + const figuren = Object.values(daten.figuren).sort(nachRolle((a, b) => vergleich(a.name, b.name))); for (const fig of figuren) liste.push({ typ: 'figur', id: fig.id, anzeige: fig.name, art: art.figur, suche: [fig.name, ...fig.aliase] }); for (const o of Object.values(daten.orte).sort((a, b) => vergleich(a.name, b.name))) liste.push({ typ: 'ort', id: o.id, anzeige: o.name, art: art.ort, suche: [o.name, ...o.aliase] }); diff --git a/src/features/zeitleiste/Inspektor.tsx b/src/features/zeitleiste/Inspektor.tsx index ff675b5..ee6dd80 100644 --- a/src/features/zeitleiste/Inspektor.tsx +++ b/src/features/zeitleiste/Inspektor.tsx @@ -1,4 +1,5 @@ /** Inspektor der Zeitleiste (G1): Zeitpunkt und Zustand der gewählten Karte festlegen; ohne Auswahl Beginn und Zustandsliste. */ +import { nachRolle } from '@core/figuren/namen'; import { useEffect, useState, type KeyboardEvent, type ReactNode } from 'react'; import type { Id, TextAnker, Zeitpunkt, ZeitSicherheit } from '@core/model/types'; import { @@ -465,7 +466,7 @@ function FigurWahl({ figur }: { figur: Id | undefined }) { const daten = useStore((s) => s.daten)!; const setzeFigur = useStore((s) => s.zeit.setzeFigur); const t = useTexte().zeitleiste.ansicht; - const figuren = Object.values(daten.figuren).sort((a, b) => (a.rolle === b.rolle ? vergleich(a.name, b.name) : a.rolle === 'haupt' ? -1 : 1)); + const figuren = Object.values(daten.figuren).sort(nachRolle((a, b) => vergleich(a.name, b.name))); return ( gruppeSetzen(e.target.value)}> - - {gruppen.map((g) => ( - - ))} - - + + {meine.map((g) => { + const haupt = g.id === f.gruppe; + const rolle = g.rollen?.[f.id]; + return ( + + + + + + ); + })} + + ) : ( <> )} - {neueGruppe === null && f.gruppe && daten.gruppen[f.gruppe] && ( - oeffne('beziehungen', { gruppe: f.gruppe!, figur: f.id })}> - {tp.imNetz} - - )} {felder.map((feld) => ( diff --git a/src/features/gruppen/Gruppen.tsx b/src/features/gruppen/Gruppen.tsx index 3936947..ddb26d2 100644 --- a/src/features/gruppen/Gruppen.tsx +++ b/src/features/gruppen/Gruppen.tsx @@ -127,6 +127,7 @@ export function GruppenInspektor({ gruppe: id, onFigur }: GruppenInspektorProps) const gruppeAendern = useStore((s) => s.gruppeAendern); const mitgliedHinzufuegen = useStore((s) => s.mitgliedHinzufuegen); const mitgliedEntfernen = useStore((s) => s.mitgliedEntfernen); + const gruppenRolleSetzen = useStore((s) => s.gruppenRolleSetzen); const spurStatusSetzen = useStore((s) => s.spurStatusSetzen); const spurHinzufuegen = useStore((s) => s.spurHinzufuegen); const markiere = useStore((s) => s.markiere); @@ -272,17 +273,29 @@ export function GruppenInspektor({ gruppe: id, onFigur }: GruppenInspektorProps)
{t.mitglieder} -
+
{gruppe.mitglieder.map((m) => ( - - - - +
+ + + + + void gruppenRolleSetzen(id, m, r)} + /> +
))} +
+
{gruppe.mitglieder.length === 0 && {t.keineFigur}} {nichtMitglieder.length > 0 && ( { + const v = e.target.value; + if (v.includes(',')) hinzufuegen(v); + else setEingabe(v); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + hinzufuegen(eingabe); + } else if (e.key === 'Backspace' && !eingabe && f.aliase.length) entfernen(f.aliase[f.aliase.length - 1]!); + }} + onBlur={() => eingabe.trim() && hinzufuegen(eingabe)} + /> + + {kontext && ( + + {fmt(T.werNennt, { name: kontext.name })} + + {(kontext.von ?? []).map((v) => ( + + {daten.figuren[v]?.name ?? v} + + + ))} + + + + speichern(mitSpitznameKontext(f, { ...kontext, notiz }))} + /> + + )} + + ); +} diff --git a/src/i18n/de/figuren.ts b/src/i18n/de/figuren.ts index 53d2277..c60c78b 100644 --- a/src/i18n/de/figuren.ts +++ b/src/i18n/de/figuren.ts @@ -90,6 +90,20 @@ export const figuren = { leereFelder: 'Leere Planfelder zeigen ({n})', neuesFeld: 'Neues Feld', }, + /** F-16: Spitznamen als Chips mit Kontext (Spitznamen.tsx) */ + spitznamen: { + kontextTitel: 'Wer nennt die Figur so, ab wann?', + mitKontext: 'mit Kontext', + entfernen: '{name} entfernen', + weitere: 'weiterer Spitzname, Enter', + kontextLabel: 'Kontext zu {name}', + werNennt: 'Wer sagt „{name}“?', + figurHinzu: '+ Figur …', + alle: 'alle bzw. die Erzählstimme', + abKapitel: 'ab', + vonAnfang: 'von Anfang an', + notiz: 'Notiz, z. B. Spott, Kosename', + }, feld: { feldname: 'Feldname', umbenennenTitel: 'Doppelklick zum Umbenennen', diff --git a/src/i18n/en/figuren.ts b/src/i18n/en/figuren.ts index bd4b50b..dbbb81f 100644 --- a/src/i18n/en/figuren.ts +++ b/src/i18n/en/figuren.ts @@ -87,6 +87,19 @@ export const figuren: Form = { leereFelder: 'Show empty plan fields ({n})', neuesFeld: 'New field', }, + spitznamen: { + kontextTitel: 'Who calls the character this, and from when?', + mitKontext: 'with context', + entfernen: 'Remove {name}', + weitere: 'another nickname, Enter', + kontextLabel: 'Context for {name}', + werNennt: 'Who says “{name}”?', + figurHinzu: '+ Character …', + alle: 'everyone or the narrator', + abKapitel: 'from', + vonAnfang: 'from the start', + notiz: 'Note, e.g. mocking, term of endearment', + }, feld: { feldname: 'Field name', umbenennenTitel: 'Double-click to rename', From c53192eaff8b73be969c4fb15b7e62e0aad2882b Mon Sep 17 00:00:00 2001 From: Leon Machnik <98738343+leonatwork@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:27:03 +0200 Subject: [PATCH 11/13] Personenverzeichnis als Abgabe-Format (Phase 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core/abgabe/personen.ts: Figuren nach Gruppen (Hauptgruppe) oder Rolle, je Eintrag Name, Spitznamen, Rolle in der Gruppe und Kurzbeschreibung; Statisten, Spitznamen, geplante Figuren wählbar; „nur bis Kapitel X“ gegen Spoiler; „bewusst vage“ und private Felder nie - Abgabe-Dialog: Reiter „Personen“ (.docx oder Markdown, über das Exposé-Gerüst); bei Normseite und EPUB „Personenverzeichnis voranstellen“ Co-Authored-By: Claude Opus 5.5 --- core/abgabe/index.ts | 1 + core/abgabe/personen.test.ts | 58 +++++++++++++ core/abgabe/personen.ts | 109 ++++++++++++++++++++++++ src/features/abgabe/AbgabeDialog.tsx | 88 +++++++++++++++++-- src/i18n/de/abgabe.ts | 15 +++- src/i18n/de/importexport.ts | 2 +- src/i18n/de/state.ts | 2 +- src/i18n/en/abgabe.ts | 15 +++- src/i18n/en/importexport.ts | 2 +- src/i18n/en/state.ts | 2 +- src/state/slices/abgabe.ts | 47 +++++++--- src/state/slices/gruppenFiguren.test.ts | 14 +++ 12 files changed, 333 insertions(+), 22 deletions(-) create mode 100644 core/abgabe/personen.test.ts create mode 100644 core/abgabe/personen.ts 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/src/features/abgabe/AbgabeDialog.tsx b/src/features/abgabe/AbgabeDialog.tsx index ebc5194..92fab02 100644 --- a/src/features/abgabe/AbgabeDialog.tsx +++ b/src/features/abgabe/AbgabeDialog.tsx @@ -3,7 +3,7 @@ * Alle Dateien gehen über backend.dateiSpeichern (wie der normale Export). Keine KI. */ import { useId, useMemo, useState } from 'react'; -import { exposeGeruest, exposePlatzhalter, normseitenZaehlen, offeneHinweise, type ExposeKapitelQuelle } from '@core/abgabe/index'; +import { exposeGeruest, exposePlatzhalter, normseitenZaehlen, offeneHinweise, personenAuswahl, type ExposeKapitelQuelle, type PersonenOptionen } from '@core/abgabe/index'; import { epubKapitel } from '@core/epub/index'; import type { Id, KapitelStatus, ProjektAngaben } from '@core/model/types'; import { wortzahl } from '@core/text/index'; @@ -22,7 +22,7 @@ import { Abschnittslabel, Button, Checkbox, Dialog, Radio, Segment, StatusPunkt, import '../importexport/importexport.css'; import './abgabe.css'; -const FORMATE: AbgabeFormat[] = ['normseite', 'expose', 'epub', 'mappe']; +const FORMATE: AbgabeFormat[] = ['normseite', 'expose', 'epub', 'mappe', 'personen']; /** Formate mit Beschriftung in der Oberflächensprache */ function formate(t: Woerterbuch): { wert: AbgabeFormat; label: string }[] { @@ -154,6 +154,7 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro const daten = useStore((s) => s.daten); const backend = useStore((s) => s.backend); const hinweise = useStore((s) => s.index.hinweise); + const index = useStore((s) => s.index); const auswahlKapitel = useStore((s) => s.auswahl.kapitel); const abgabe = useStore((s) => s.abgabe); const e = abgabe.einstellungen; @@ -170,6 +171,10 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro const [nebenfiguren, setNebenfiguren] = useState(e.exposeNebenfiguren); const [epubSchrift, setEpubSchrift] = useState(e.epubSchrift); const [mappeKapitel, setMappeKapitel] = useState(() => e.mappeKapitel ?? null); + const [personen, setPersonen] = useState(e.personen); + const [personenFormat, setPersonenFormat] = useState(e.personenFormat); + const [personenVorspann, setPersonenVorspann] = useState(e.personenVorspann); + const personenId = useId(); const [angaben, setAngaben] = useState(() => daten?.projekt.angaben ?? {}); const [dateinamen, setDateinamen] = useState>>({}); const [status, setStatus] = useState({ art: 'bereit' }); @@ -226,7 +231,11 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro } const titel = daten.projekt.titel; - const dateiname = dateinamen[format] ?? abgabeDateiname(titel, format, exposeFormat); + const dateiformat = format === 'personen' ? personenFormat : exposeFormat; + const dateiname = dateinamen[format] ?? abgabeDateiname(titel, format, dateiformat); + const personenAnzahl = format === 'personen' ? personenAuswahl(daten, index.erstesVorkommen, personen).length : 0; + const setzePersonen = (teil: Partial) => setPersonen((p) => ({ ...p, ...teil })); + const vorspannOption = personenVorspann ? personen : undefined; const setzeDateiname = (n: string) => setDateinamen((alt) => ({ ...alt, [format]: n })); const laeuft = status.art === 'laeuft'; @@ -240,7 +249,7 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro let fussText = ''; switch (format) { case 'normseite': - auftrag = { format, kapitel: gewaehlt, titelseite }; + auftrag = { format, kapitel: gewaehlt, titelseite, personen: vorspannOption }; bereit = gewaehlt.length > 0; fussText = normseiten ? `${f(t.abgabe.kapitelAnzahl, { n: gewaehlt.length })} · ${f(te.normseiten, { n: normseiten.seiten })}` : ''; break; @@ -249,7 +258,7 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro fussText = expose ? f(te.platzhalter, { n: expose.platzhalter }) : ''; break; case 'epub': - auftrag = { format, kapitel: gewaehlt, titelseite, schrift: epubSchrift }; + auftrag = { format, kapitel: gewaehlt, titelseite, schrift: epubSchrift, personen: vorspannOption }; bereit = gewaehlt.length > 0; fussText = `${f(t.abgabe.kapitelAnzahl, { n: gewaehlt.length })} · ${f(t.gemeinsam.woerter, { n: zeilen.filter((z) => gewaehlt.includes(z.id)).reduce((s, z) => s + z.woerter, 0) })}`; break; @@ -258,10 +267,14 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro bereit = offen.length > 0; fussText = f(te.offeneHinweise, { n: offen.length }); break; + case 'personen': + auftrag = { format, ziel: personenFormat, optionen: personen }; + fussText = f(te.personen.anzahl, { n: personenAnzahl }); + break; } async function starten() { - const name = mitAbgabeEndung(dateiname, format, exposeFormat); + const name = mitAbgabeEndung(dateiname, format, dateiformat); setzeDateiname(name); const einstellungen: Partial = { format, @@ -273,6 +286,9 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro exposeNebenfiguren: nebenfiguren, epubSchrift, mappeKapitel, + personen, + personenFormat, + personenVorspann, }; abgabe.setzeEinstellungen(einstellungen); setStatus({ art: 'laeuft' }); @@ -367,6 +383,9 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro setTitelseite(ev.target.checked)}> {te.normseite.titelseite} + setPersonenVorspann(ev.target.checked)}> + {te.vorspann} + )}
@@ -431,6 +450,9 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro setEpubSchrift(ev.target.checked)}> {te.epub.schrift} + setPersonenVorspann(ev.target.checked)}> + {te.vorspann} + )}
@@ -463,6 +485,60 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro
)} + {format === 'personen' && ( +
+

{te.personen.text}

+
+ {te.personen.gliederung} +
+ setzePersonen({ gliederung: 'gruppen' })}> + {te.personen.nachGruppen} + + setzePersonen({ gliederung: 'rolle' })}> + {te.personen.nachRolle} + +
+
+ setzePersonen({ spitznamen: ev.target.checked })}> + {te.personen.spitznamen} + + setzePersonen({ statisten: ev.target.checked })}> + {te.personen.statisten} + + setzePersonen({ geplante: ev.target.checked })}> + {te.personen.geplante} + +
+ + +
+
+ {te.expose.dateiformat} + + label={te.expose.dateiformatLabel} + rolle="group" + voll + optionen={[ + { wert: 'docx', label: te.expose.word }, + { wert: 'md', label: te.expose.markdown }, + ]} + wert={personenFormat} + onChange={(fm) => { + setPersonenFormat(fm); + if (dateinamen.personen) setDateinamen((alt) => ({ ...alt, personen: mitAbgabeEndung(alt.personen!, 'personen', fm) })); + }} + /> +
+
+ )} + {format !== 'mappe' && setAngaben((a) => ({ ...a, ...teil }))} />}
diff --git a/src/i18n/de/abgabe.ts b/src/i18n/de/abgabe.ts index b336108..51b89c4 100644 --- a/src/i18n/de/abgabe.ts +++ b/src/i18n/de/abgabe.ts @@ -5,7 +5,8 @@ export const abgabe = { /** Dialog „Für die Abgabe exportieren“ */ export: { titel: 'Für die Abgabe exportieren', - formate: { normseite: 'Normseite', expose: 'Exposé', epub: 'EPUB', mappe: 'Lektoratsmappe' }, + formate: { normseite: 'Normseite', expose: 'Exposé', epub: 'EPUB', mappe: 'Lektoratsmappe', personen: 'Personen' }, + vorspann: 'Personenverzeichnis voranstellen (Einstellungen wie unter „Personen“)', wasExportieren: 'Was exportieren?', umfang: 'Umfang', ganzesBuch: { one: 'Ganzes Buch ({n} Kapitel mit Text)', other: 'Ganzes Buch ({n} Kapitel mit Text)' }, @@ -53,6 +54,18 @@ export const abgabe = { titelseite: 'Titelseite', schrift: 'Newsreader einbetten (sonst wählt das Lesegerät die Schrift)', }, + personen: { + text: 'Verzeichnis der Figuren mit Spitznamen, Kurzbeschreibung und Rolle in der Gruppe – für Lektorat, Verlag oder als Seite vorne im Buch. Private Felder kommen nie hinein.', + gliederung: 'Gliederung', + nachGruppen: 'Nach Gruppen', + nachRolle: 'Nach Rolle (Haupt-, Nebenfiguren)', + statisten: 'Statisten aufnehmen', + spitznamen: 'Spitznamen nennen', + geplante: 'Auch Figuren, die noch nicht im Text vorkommen', + bisKapitel: 'Nur Figuren bis', + alleKapitel: 'Ende des Buchs', + anzahl: { one: '1 Figur', other: '{n} Figuren' }, + }, mappe: { text: 'Die offenen Hinweise als PDF zum Weitergeben oder Ausdrucken – je Hinweis Typ, Titel, Zitat und Erklärung. Als Absicht oder erledigt markierte Hinweise fehlen.', ganzesBuch: 'Ganzes Buch ({n})', diff --git a/src/i18n/de/importexport.ts b/src/i18n/de/importexport.ts index 32291b6..a594d21 100644 --- a/src/i18n/de/importexport.ts +++ b/src/i18n/de/importexport.ts @@ -30,7 +30,7 @@ export const importexport = { dateiname: 'Dateiname', nichtExportiert: 'Anmerkungen, Markierungen und Planungsnotizen werden nicht exportiert.', abgabeVor: 'Für Verlag, Agentur oder Lektorat:', - abgabeLink: 'Normseite, Exposé, EPUB oder Lektoratsmappe …', + abgabeLink: 'Normseite, Exposé, EPUB, Lektoratsmappe oder Personen …', schriftFehler: 'Schrift für den PDF-Export nicht ladbar ({status})', }, import: { diff --git a/src/i18n/de/state.ts b/src/i18n/de/state.ts index 9ebcd25..999f123 100644 --- a/src/i18n/de/state.ts +++ b/src/i18n/de/state.ts @@ -68,7 +68,7 @@ export const state = { schonImText: 'Die Änderung steht schon im Text. Den Stand davor findest du unter „Versionen“ des Kapitels.', kapitelFehlt: 'Das Kapitel gibt es nicht mehr.', /** Zusatz im vorgeschlagenen Dateinamen, z. B. „Hafenlichter (Normseiten).docx“ */ - zusatz: { normseite: ' (Normseiten)', expose: ' – Exposé', mappe: ' – Lektoratsmappe' }, + zusatz: { normseite: ' (Normseiten)', expose: ' – Exposé', mappe: ' – Lektoratsmappe', personen: ' – Personen' }, }, importexport: { keinNeuesProjekt: 'Kein neues Projekt angelegt.', diff --git a/src/i18n/en/abgabe.ts b/src/i18n/en/abgabe.ts index 3e3d183..4ea716a 100644 --- a/src/i18n/en/abgabe.ts +++ b/src/i18n/en/abgabe.ts @@ -6,7 +6,8 @@ export const abgabe: Form = { kapitelAnzahl: { one: '{n} chapter', other: '{n} chapters' }, export: { titel: 'Export for submission', - formate: { normseite: 'Standard manuscript', expose: 'Synopsis', epub: 'EPUB', mappe: 'Editing report' }, + formate: { normseite: 'Standard manuscript', expose: 'Synopsis', epub: 'EPUB', mappe: 'Editing report', personen: 'Characters' }, + vorspann: 'Put the character list in front (settings as under “Characters”)', wasExportieren: 'What to export?', umfang: 'Scope', ganzesBuch: { one: 'Whole book ({n} chapter with text)', other: 'Whole book ({n} chapters with text)' }, @@ -54,6 +55,18 @@ export const abgabe: Form = { titelseite: 'Title page', schrift: 'Embed Newsreader (otherwise the reading device chooses the font)', }, + personen: { + text: 'A list of the characters with nicknames, short description and role in their group – for editors, publishers or as a page at the front of the book. Private fields are never included.', + gliederung: 'Arrangement', + nachGruppen: 'By group', + nachRolle: 'By role (main, supporting characters)', + statisten: 'Include walk-ons', + spitznamen: 'Name nicknames', + geplante: 'Also characters not yet in the text', + bisKapitel: 'Only characters up to', + alleKapitel: 'end of the book', + anzahl: { one: '1 character', other: '{n} characters' }, + }, mappe: { text: 'The open notes as a PDF to pass on or print – type, title, quote and explanation for each note. Notes marked as intentional or done are left out.', ganzesBuch: 'Whole book ({n})', diff --git a/src/i18n/en/importexport.ts b/src/i18n/en/importexport.ts index 03cbfb5..1e9c7a7 100644 --- a/src/i18n/en/importexport.ts +++ b/src/i18n/en/importexport.ts @@ -30,7 +30,7 @@ export const importexport: Form = { dateiname: 'File name', nichtExportiert: 'Comments, highlights and planning notes are not exported.', abgabeVor: 'For a publisher, agency or editor:', - abgabeLink: 'Standard manuscript pages, synopsis, EPUB or editor’s package …', + abgabeLink: 'Standard manuscript pages, synopsis, EPUB, editor’s package or characters …', schriftFehler: 'Couldn’t load the font for the PDF export ({status})', }, import: { diff --git a/src/i18n/en/state.ts b/src/i18n/en/state.ts index 0809f13..d1918d4 100644 --- a/src/i18n/en/state.ts +++ b/src/i18n/en/state.ts @@ -65,7 +65,7 @@ export const state: Form = { aenderungFehlt: 'This change no longer exists.', schonImText: 'The change is already in the text. You’ll find the previous state under the chapter’s “Versions”.', kapitelFehlt: 'The chapter no longer exists.', - zusatz: { normseite: ' (standard pages)', expose: ' – Synopsis', mappe: ' – Editing packet' }, + zusatz: { normseite: ' (standard pages)', expose: ' – Synopsis', mappe: ' – Editing packet', personen: ' – Characters' }, }, importexport: { keinNeuesProjekt: 'No new project was created.', diff --git a/src/state/slices/abgabe.ts b/src/state/slices/abgabe.ts index 558c81f..9b044be 100644 --- a/src/state/slices/abgabe.ts +++ b/src/state/slices/abgabe.ts @@ -7,7 +7,7 @@ * den Kapiteltext erst auf Leons Klick, über die normale Speicherung und mit Schnappschuss davor (E8). */ import type { StateCreator } from 'zustand'; -import type { ExposeKapitelQuelle } from '@core/abgabe/index'; +import type { ExposeKapitelQuelle, PersonenOptionen } from '@core/abgabe/index'; import type { Rueckimport } from '@core/docx/rueckimport'; import type { Id, Kapitel, LektoratsAenderung, ProjektAngaben } from '@core/model/types'; import { buchSprache } from '@core/i18n'; @@ -15,7 +15,7 @@ import { kapitelPfad } from '@core/projekt'; import { sprache, texte } from '../../i18n'; import type { Store } from '../store'; -export type AbgabeFormat = 'normseite' | 'expose' | 'epub' | 'mappe'; +export type AbgabeFormat = 'normseite' | 'expose' | 'epub' | 'mappe' | 'personen'; export type ExposeDateiformat = 'docx' | 'md'; export interface AbgabeEinstellungen { @@ -31,13 +31,19 @@ export interface AbgabeEinstellungen { epubSchrift: boolean; /** Lektoratsmappe: Kapitel; null = ganzes Buch */ mappeKapitel: Id | null; + /** Personenverzeichnis (eigene Datei und Vorspann) */ + personen: PersonenOptionen; + personenFormat: ExposeDateiformat; + /** Normseite/EPUB: Personenverzeichnis als Vorspann */ + personenVorspann: boolean; } export type AbgabeAuftrag = - | { format: 'normseite'; kapitel: Id[]; titelseite: boolean } + | { format: 'normseite'; kapitel: Id[]; titelseite: boolean; personen?: PersonenOptionen } | { format: 'expose'; ziel: ExposeDateiformat; kapitel: ExposeKapitelQuelle; nebenfiguren: boolean } - | { format: 'epub'; kapitel: Id[]; titelseite: boolean; schrift: boolean } - | { format: 'mappe'; kapitel: Id | null }; + | { format: 'epub'; kapitel: Id[]; titelseite: boolean; schrift: boolean; personen?: PersonenOptionen } + | { format: 'mappe'; kapitel: Id | null } + | { format: 'personen'; ziel: ExposeDateiformat; optionen: PersonenOptionen }; export interface RueckimportOptionen { /** Dateiname der .docx */ @@ -77,7 +83,9 @@ export interface AbgabeSlice { abgabe: Abgabe; } -const ENDUNG: Record = { normseite: 'docx', expose: 'docx', epub: 'epub', mappe: 'pdf' }; +const ENDUNG: Record = { normseite: 'docx', expose: 'docx', epub: 'epub', mappe: 'pdf', personen: 'docx' }; +/** Formate mit wählbarer Endung (.docx oder .md) */ +const MIT_DATEIFORMAT: AbgabeFormat[] = ['expose', 'personen']; /** Zusatz im Dateinamen je Format (Sprache der Oberfläche). */ function zusatz(format: AbgabeFormat): string { return format === 'epub' ? '' : texte().state.abgabe.zusatz[format]; @@ -87,13 +95,13 @@ function zusatz(format: AbgabeFormat): string { export function abgabeDateiname(titel: string, format: AbgabeFormat, exposeFormat: ExposeDateiformat = 'docx'): string { const basis = titel.replace(/[/\\:*?"<>|\u0000-\u001f]+/g, '-').replace(/\s+/g, ' ').trim().replace(/^\.+/, '') || texte().state.standard.manuskript; - const endung = format === 'expose' ? exposeFormat : ENDUNG[format]; + const endung = MIT_DATEIFORMAT.includes(format) ? exposeFormat : ENDUNG[format]; return `${basis}${zusatz(format)}.${endung}`; } /** Endung des Dateinamens an das Format anpassen. */ export function mitAbgabeEndung(name: string, format: AbgabeFormat, exposeFormat: ExposeDateiformat = 'docx'): string { - const endung = format === 'expose' ? exposeFormat : ENDUNG[format]; + const endung = MIT_DATEIFORMAT.includes(format) ? exposeFormat : ENDUNG[format]; const basis = name.trim().replace(/\.(docx|pdf|epub|md)$/i, ''); return `${basis || texte().state.standard.manuskript}.${endung}`; } @@ -107,6 +115,14 @@ export const createAbgabeSlice: StateCreator = (set, return d; }; + /** Personenverzeichnis als Vorspann-Kapitel (Buchsprache) */ + const vorspann = async (optionen: PersonenOptionen | undefined): Promise => { + if (!optionen) return []; + const { personenverzeichnis, personenKapitel } = await import('@core/abgabe/index'); + get().indexNeuBerechnen(); + return [personenKapitel(personenverzeichnis(daten(), get().index.erstesVorkommen, optionen, buchSprache(daten().projekt.angaben)))]; + }; + /** Kapitel in Projektreihenfolge (fehlende Manuskripte als leere Kapitel). */ const kapitelListe = (ids: Id[]): Kapitel[] => { const d = daten(); @@ -126,6 +142,9 @@ export const createAbgabeSlice: StateCreator = (set, exposeNebenfiguren: true, epubSchrift: true, mappeKapitel: null, + personen: { gliederung: 'gruppen', statisten: false, spitznamen: true }, + personenFormat: 'docx', + personenVorspann: false, }, setzeEinstellungen: (teil) => setze({ einstellungen: { ...get().abgabe.einstellungen, ...teil } }), @@ -150,7 +169,7 @@ export const createAbgabeSlice: StateCreator = (set, switch (auftrag.format) { case 'normseite': { const { exportiereNormseite } = await import('@core/abgabe/index'); - return exportiereNormseite(kapitelListe(auftrag.kapitel), titel, { angaben, titelseite: auftrag.titelseite }); + return exportiereNormseite([...(await vorspann(auftrag.personen)), ...kapitelListe(auftrag.kapitel)], titel, { angaben, titelseite: auftrag.titelseite }); } case 'expose': { const { exposeGeruest, exposeDocx, exposeMarkdown } = await import('@core/abgabe/index'); @@ -165,7 +184,7 @@ export const createAbgabeSlice: StateCreator = (set, const s = (await ladePdfSchriften())[0]!; schrift = { name: s.name, normal: s.normal, kursiv: s.kursiv, fett: s.fett, fettKursiv: s.fettKursiv }; } - return exportiereEpub(kapitelListe(auftrag.kapitel), titel, { angaben, schrift, titelseite: auftrag.titelseite }); + return exportiereEpub([...(await vorspann(auftrag.personen)), ...kapitelListe(auftrag.kapitel)], titel, { angaben, schrift, titelseite: auftrag.titelseite }); } case 'mappe': { const [{ lektoratsmappe, exportiereLektoratsmappe }, { ladePdfSchriften }] = await Promise.all([ @@ -176,6 +195,14 @@ export const createAbgabeSlice: StateCreator = (set, const mappe = lektoratsmappe(daten(), get().index.hinweise, { kapitel: auftrag.kapitel }); return exportiereLektoratsmappe(mappe, await ladePdfSchriften()); } + case 'personen': { + const { personenverzeichnis, exposeDocx, exposeMarkdown } = await import('@core/abgabe/index'); + get().indexNeuBerechnen(); + const sprache = buchSprache(angaben); + const bloecke = personenverzeichnis(daten(), get().index.erstesVorkommen, auftrag.optionen, sprache); + if (auftrag.ziel === 'md') return new TextEncoder().encode(exposeMarkdown(bloecke, sprache)); + return exposeDocx(bloecke, { autor: angaben?.autor, sprache }); + } } }, diff --git a/src/state/slices/gruppenFiguren.test.ts b/src/state/slices/gruppenFiguren.test.ts index c577d25..a824733 100644 --- a/src/state/slices/gruppenFiguren.test.ts +++ b/src/state/slices/gruppenFiguren.test.ts @@ -161,3 +161,17 @@ describe('Mehrere Gruppen und Rollen (A-05, A-06)', () => { expect(g.rollen).toEqual({ ole: 'Bauleiter' }); }); }); + +describe('Personenverzeichnis (Abgabe)', () => { + it('als Markdown-Datei und als Vorspann der Normseite', async () => { + const { store } = await laden(); + const md = new TextDecoder().decode( + await store.getState().abgabe.erstellen({ format: 'personen', ziel: 'md', optionen: { gliederung: 'rolle' } }), + ); + expect(md).toContain('# Personen'); + expect(md).toContain('**Paul**'); + const mit = await store.getState().abgabe.erstellen({ format: 'normseite', kapitel: ['grundriss'], titelseite: false, personen: { gliederung: 'rolle' } }); + const ohne = await store.getState().abgabe.erstellen({ format: 'normseite', kapitel: ['grundriss'], titelseite: false }); + expect(mit.length).toBeGreaterThan(ohne.length); + }); +}); From 16dbccf0d43712158e7f72bbb4fac0f089063222 Mon Sep 17 00:00:00 2001 From: Leon Machnik <98738343+leonatwork@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:35:24 +0200 Subject: [PATCH 12/13] Figurenfakten aus dem Text (Phase 9, F-17) und Doku MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KI-Aufgabe „fakten“ (core/ki/fakten.ts): je Figur Merkmal, Wert und wörtlicher Beleg; ein Request für das Buch oder eine Figur; Merkmal/Wert begrenzt, Figur nur aus dem Schema; die KI vergleicht nichts - core/figuren/fakten.ts: Ablage .plotwerk/figurenfakten.json mischen, verworfene Angaben kommen nicht wieder; Widersprüche (gleiches Merkmal, unverträgliche Werte) und Abweichungen vom Planfeld gleichen Namens als Hinweise „Prüfen“ mit Figur-Bezug - Figurenkarte „Aus dem Text“: Angaben je Merkmal mit Kapitel, ⚠ bei Widerspruch/Planabweichung, × verwirft; Dialog „Fakten aus dem Text sammeln“ mit Kostenschätzung - Demo-KI: Pauls Auto als Saab und als Volvo (Widerspruch), ein erfundenes Zitat zum Verwerfen - Anforderungen F-09–F-17, A-05/A-06, AB-06 und Abnahme nachgetragen; Plan auf „umgesetzt“ Co-Authored-By: Claude Opus 5.5 --- core/figuren/fakten.test.ts | 75 +++++++ core/figuren/fakten.ts | 167 ++++++++++++++++ core/figuren/index.ts | 1 + core/index/index.ts | 2 + core/ki/README.md | 6 + core/ki/fakten.test.ts | 81 ++++++++ core/ki/fakten.ts | 219 ++++++++++++++++++++ core/ki/index.ts | 1 + core/ki/meldungen.ts | 4 + core/model/types.ts | 21 ++ core/projekt/extern.ts | 1 + core/projekt/lesen.ts | 23 ++- core/projekt/schreiben.ts | 7 + docs/abnahme.md | 12 ++ docs/anforderungen-v1.1.md | 12 ++ docs/plan-figuren-namen.md | 9 +- src/app/dialoge.tsx | 1 + src/backend/ki/demo/fakten.ts | 43 ++++ src/backend/ki/demoAntworten/figuren.ts | 6 +- src/features/figuren/FaktenDialog.tsx | 221 +++++++++++++++++++++ src/features/figuren/Figurenkarte.test.tsx | 23 +++ src/features/figuren/Figurenkarte.tsx | 58 +++++- src/i18n/de/figuren.ts | 25 +++ src/i18n/en/figuren.ts | 23 +++ src/state/slices/figurenbogen.ts | 77 ++++++- src/state/slices/gruppenFiguren.test.ts | 22 +- src/state/store.ts | 6 + 27 files changed, 1134 insertions(+), 12 deletions(-) create mode 100644 core/figuren/fakten.test.ts create mode 100644 core/figuren/fakten.ts create mode 100644 core/ki/fakten.test.ts create mode 100644 core/ki/fakten.ts create mode 100644 src/backend/ki/demo/fakten.ts create mode 100644 src/features/figuren/FaktenDialog.tsx 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/index.ts b/core/figuren/index.ts index c5740a5..ce4c46c 100644 --- a/core/figuren/index.ts +++ b/core/figuren/index.ts @@ -8,3 +8,4 @@ export * from './zusammenfuehren'; export * from './namen'; export * from './hinweise'; export * from './besetzung'; +export * from './fakten'; diff --git a/core/index/index.ts b/core/index/index.ts index effbf46..73d0411 100644 --- a/core/index/index.ts +++ b/core/index/index.ts @@ -12,6 +12,7 @@ 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'; @@ -177,6 +178,7 @@ function hinweiseBerechnen(daten: ProjektDaten, sprache: Sprache, erwaehnungen: ...zeitHinweise(daten, sprache), ...stimmenHinweise(daten, sprache), ...figurenHinweise(daten, erwaehnungen, sprache), + ...faktenHinweise(daten, sprache), ]; const ki = (daten.kiHinweise ?? []) .filter((h) => kiHinweisGueltig(h, daten)) diff --git a/core/ki/README.md b/core/ki/README.md index 0a917d8..ec3b814 100644 --- a/core/ki/README.md +++ b/core/ki/README.md @@ -287,3 +287,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/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/index.ts b/core/ki/index.ts index 517fa5f..bc1e889 100644 --- a/core/ki/index.ts +++ b/core/ki/index.ts @@ -53,6 +53,7 @@ export * from './aufgabe'; /** G1 Zeitpunkte und Zustände · G3 Figurenstimme · G6 Leser-Simulation und Fragen (Besitzer: die Gruppen) */ export * from './zeit'; export * from './stimme'; +export * from './fakten'; export * from './leser'; /** Transport zur Messages-API (Foundry oder Claude API). In der App: Rust-Kommando ki_anfrage. */ diff --git a/core/ki/meldungen.ts b/core/ki/meldungen.ts index cc08188..2e94c16 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', @@ -212,6 +214,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', diff --git a/core/model/types.ts b/core/model/types.ts index b8cccf4..ee80da2 100644 --- a/core/model/types.ts +++ b/core/model/types.ts @@ -474,6 +474,8 @@ 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 */ @@ -756,6 +758,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 // --------------------------------------------------------------------------- diff --git a/core/projekt/extern.ts b/core/projekt/extern.ts index b17100e..4c1ab91 100644 --- a/core/projekt/extern.ts +++ b/core/projekt/extern.ts @@ -37,6 +37,7 @@ const WERKZEUG_DATEIEN: [string, keyof ProjektDaten][] = [ [PFADE.kiStimmen, 'kiStimmen'], [PFADE.kiLeser, 'kiLeser'], [PFADE.kiFragen, 'kiFragen'], + [PFADE.kiFakten, 'kiFakten'], ]; const imOrdner = (pfad: string, ordner: string, endung: string) => diff --git a/core/projekt/lesen.ts b/core/projekt/lesen.ts index 3e1df99..f90c002 100644 --- a/core/projekt/lesen.ts +++ b/core/projekt/lesen.ts @@ -25,6 +25,7 @@ import type { Recherche, RechercheEintrag, Schreibstatistik, + FigurFakt, StimmenBeobachtung, Szene, Tageszeit, @@ -104,6 +105,8 @@ 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) */ @@ -1033,6 +1036,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; @@ -1197,12 +1216,13 @@ 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, 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.kiFakten, parseKiFakten), ]); const projekt: Projekt = projektRoh ?? { version: 1, titel: '', kapitel: [], notiz: '' }; @@ -1257,6 +1277,7 @@ export async function ladeProjektMitBericht(leser: ProjektLeser, sprache: LadeSp ...(kiStimmen ? { kiStimmen } : {}), ...(kiLeser ? { kiLeser } : {}), ...(kiFragen ? { kiFragen } : {}), + ...(kiFakten ? { kiFakten } : {}), }, warnungen, }; diff --git a/core/projekt/schreiben.ts b/core/projekt/schreiben.ts index 906fec0..9c6302c 100644 --- a/core/projekt/schreiben.ts +++ b/core/projekt/schreiben.ts @@ -20,6 +20,7 @@ import type { Markenliste, Recherche, Schreibstatistik, + FigurFakt, StimmenBeobachtung, Szene, Zeitleiste, @@ -372,6 +373,7 @@ 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'], + fakt: ['schluessel', 'figur', 'merkmal', 'wert', 'kapitel', 'zitat', 'status'], leser: ['kapitel', 'basisHash', 'weiss', 'fragen', 'vermutungen', 'spannung', 'plan', 'modell', 'datum'], leserPlan: ['kapitel', 'aussage', 'status', 'erklaerung', 'belege'], leserAussage: ['text', 'belege'], @@ -485,6 +487,9 @@ 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 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 +615,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); diff --git a/docs/abnahme.md b/docs/abnahme.md index fc7d5c0..13ca256 100644 --- a/docs/abnahme.md +++ b/docs/abnahme.md @@ -74,6 +74,15 @@ Legende Nachweis: Testdatei (automatisch) · „Browser“ = im Browser-Demo bed | 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` | | @@ -85,6 +94,8 @@ Legende Nachweis: Testdatei (automatisch) · „Browser“ = im Browser-Demo bed | 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 @@ -164,6 +175,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 4f5c9fd..d7efd05 100644 --- a/docs/anforderungen-v1.1.md +++ b/docs/anforderungen-v1.1.md @@ -210,6 +210,15 @@ Die Planung ist die zweite Ebene neben dem Text. Referenz-Screens: `Akte.dc.html | 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 | @@ -221,6 +230,8 @@ Die Planung ist die zweite Ebene neben dem Text. Referenz-Screens: `Akte.dc.html | 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) @@ -251,6 +262,7 @@ 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 | diff --git a/docs/plan-figuren-namen.md b/docs/plan-figuren-namen.md index 4386d3a..a9302fe 100644 --- a/docs/plan-figuren-namen.md +++ b/docs/plan-figuren-namen.md @@ -1,6 +1,7 @@ # Plan: Figuren, Namen und Gruppen (Paket nach v0.2.0) -Stand: 12. Oktober 2026. Grundlage: Rückmeldungen aus dem Schreiben mit dem eigenen Manuskript (Spitznamen werden als +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. @@ -18,7 +19,7 @@ Leitlinien für alle Phasen: | Phase | Inhalt | Anforderungen | Aufwand | KI | |---|---|---|---|---| -| 0 | PR mit dem bisherigen Stand | F-07, F-08, A-04 | erledigt, offen: PR | – | +| 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 | – | @@ -26,7 +27,7 @@ Leitlinien für alle Phasen: | 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-xx | 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 | @@ -177,7 +178,7 @@ Ersetzt nebenbei das Komma-Feld durch Chips (Eingabe mit Enter oder Komma, × zu ## Phase 8 – Personenverzeichnis -**AB-xx** Neues Abgabe-Format „Personenverzeichnis“ (`core/abgabe/personen.ts`): Figuren nach Gruppen oder +**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. diff --git a/src/app/dialoge.tsx b/src/app/dialoge.tsx index 614700b..9e87cd8 100644 --- a/src/app/dialoge.tsx +++ b/src/app/dialoge.tsx @@ -18,6 +18,7 @@ export const DIALOGE: Record = { // Werkzeuge G1–G8 'zeit-vorschlaege': lazy(() => import('../features/zeitleiste/ZeitVorschlagDialog')), 'stimme-pruefen': lazy(() => import('../features/figuren/StimmeDialog')), + figurenfakten: lazy(() => import('../features/figuren/FaktenDialog')), marken: lazy(() => import('../features/schreiben/werkzeuge/MarkenDialog')), fassung: lazy(() => import('../features/statistik/FassungDialog')), versionsvergleich: lazy(() => import('../features/statistik/VersionsvergleichDialog')), diff --git a/src/backend/ki/demo/fakten.ts b/src/backend/ki/demo/fakten.ts new file mode 100644 index 0000000..f2ca598 --- /dev/null +++ b/src/backend/ki/demo/fakten.ts @@ -0,0 +1,43 @@ +/** + * Demo-Antwort für die Aufgabe „fakten“ (F-17 Figurenfakten). Registriert in ../demoAntworten/figuren.ts. + * Alle echten Zitate sind wörtliche Ausschnitte der Fixture-Kapitel und werden nur geliefert, wenn sie im + * mitgeschickten Kontext stehen. Enthalten: Pauls Auto als Saab und als Volvo (Widerspruch) und ein erfundenes + * Zitat, das die Auswertung verwerfen muss. + */ +import type { DemoAntwort } from '../demoAntworten/typen'; + +interface Fakt { + figur: string; + merkmal: string; + wert: string; + kapitel: string; + zitat: string; + /** absichtlich ungültig (Test der Antwortprüfung) – wird ohne Fundprüfung geliefert */ + ungueltig?: boolean; +} + +const FAKTEN: Fakt[] = [ + { figur: 'paul', merkmal: 'Auto', wert: 'alter Saab 900', kapitel: 'lotte', zitat: 'Dann nimmt er den Schlüssel und fährt mit seinem alten Saab 900 zum Lindenhof.' }, + { figur: 'paul', merkmal: 'Auto', wert: 'Saab', kapitel: 'funkstille', zitat: 'Paul sitzt im Saab auf dem Parkplatz am Linden-Hof, auf dem Beifahrersitz die Papprolle mit den Glasfassaden.' }, + { figur: 'paul', merkmal: 'Auto', wert: 'Volvo', kapitel: 'flut', zitat: 'Ich steige aus dem Volvo, schlage die Tür zu und sehe ihm nach, bis er hinter den Lagerhallen verschwunden ist.' }, + { figur: 'vera', merkmal: 'Beruf', wert: 'restauriert alte Orgeln', kapitel: 'ebbe', zitat: 'Vera erzählt von ihrer Werkstatt, in der sie alte Orgeln restauriert, Paul hört nur halb zu.' }, + { figur: 'ole', merkmal: 'Alter', wert: '41', kapitel: 'grundriss', zitat: 'Ole ist einundvierzig und trägt immer denselben Pullover.', ungueltig: true }, +]; + +const FAKTEN_EN: Fakt[] = [ + { figur: 'paul', merkmal: 'Car', wert: 'old Saab 900', kapitel: 'lotte', zitat: 'Then he takes the key and drives his old Saab 900 to the Lindenhof.' }, + { figur: 'paul', merkmal: 'Car', wert: 'Saab', kapitel: 'radio-silence', zitat: 'Paul sits in the Saab in the car park at the Linden-Hof, the cardboard tube with the glass facades on the passenger seat.' }, + { figur: 'paul', merkmal: 'Car', wert: 'Volvo', kapitel: 'high-tide', zitat: 'I get out of the Volvo, slam the door and watch it until it has disappeared behind the warehouses.' }, + { figur: 'vera', merkmal: 'Occupation', wert: 'restores old organs', kapitel: 'low-tide', zitat: 'Vera talks about her workshop, where she restores old organs, Paul is only half listening.' }, + { figur: 'ole', merkmal: 'Age', wert: '41', kapitel: 'floor-plan', zitat: 'Ole is forty-one and always wears the same jumper.', ungueltig: true }, +]; + +export const demoFakten: DemoAntwort = (ctx, anfrage) => { + const nur = anfrage.attribute.figur; + const figuren = new Set(ctx.figuren.map((f) => f.id)); + const steht = (f: Fakt) => !!ctx.kapitel.find((k) => k.id === f.kapitel)?.text.includes(f.zitat); + const liste = (ctx.sprache === 'en' ? FAKTEN_EN : FAKTEN) + .filter((f) => (!nur || f.figur === nur) && figuren.has(f.figur)) + .filter((f) => f.ungueltig || steht(f)); + return { fakten: liste.map(({ figur, merkmal, wert, kapitel, zitat }) => ({ figur, merkmal, wert, kapitel, zitat })) }; +}; diff --git a/src/backend/ki/demoAntworten/figuren.ts b/src/backend/ki/demoAntworten/figuren.ts index 12845e9..8fde4fb 100644 --- a/src/backend/ki/demoAntworten/figuren.ts +++ b/src/backend/ki/demoAntworten/figuren.ts @@ -1,8 +1,10 @@ -/** Demo-Antworten für Figurenstimme (G3). Besitzer: G3. Schlüssel = art im Aufgabenkopf. */ -import { STIMME_ART } from '@core/ki/index'; +/** Demo-Antworten für Figurenstimme (G3) und Figurenfakten (F-17). Besitzer: G3. Schlüssel = art im Aufgabenkopf. */ +import { FAKTEN_ART, STIMME_ART } from '@core/ki/index'; +import { demoFakten } from '../demo/fakten'; import { demoStimme } from '../demo/stimme'; import type { DemoAntwort } from './typen'; export const DEMO_ANTWORTEN_FIGUREN: Record = { [STIMME_ART]: demoStimme, + [FAKTEN_ART]: demoFakten, }; diff --git a/src/features/figuren/FaktenDialog.tsx b/src/features/figuren/FaktenDialog.tsx new file mode 100644 index 0000000..39bc6b0 --- /dev/null +++ b/src/features/figuren/FaktenDialog.tsx @@ -0,0 +1,221 @@ +/** + * Dialog „figurenfakten“ (F-17): Angaben über Figuren aus dem Text sammeln lassen – eine Figur oder alle, Kosten + * schätzen, starten, Ergebnis zusammenfassen. Die KI sammelt nur, was der Text sagt (mit Zitat); Widersprüche + * findet die App. Parameter: { figur?: Id }. + */ +import { useEffect, useMemo, useState } from 'react'; +import { EFFORT, formatiereUsd, kiEndpoint, type FaktenAuftrag, type Schaetzung } from '@core/ki/index'; +import type { Id } from '@core/model/types'; +import type { DialogKomponentenProps } from '../../app/dialogTypen'; +import { kiFehlerAnzeige, type KiFehlerAnzeige } from '../../state/slices/ki'; +import { f as fmt, sprache, useTexte, vergleich } from '../../i18n'; +import { useStore } from '../../state/store'; +import { Button, Checkbox, Dialog } from '../../ui'; +import '../ki/ki.css'; + +const ALLE = ''; + +function FehlerKasten({ fehler, einstellungen }: { fehler: KiFehlerAnzeige; einstellungen(): void }) { + const t = useTexte(); + return ( +
+ {fehler.text} + {fehler.art === 'nicht_bereit' && ( + + )} +
+ ); +} + +export default function FaktenDialog({ param, schliessen }: DialogKomponentenProps) { + const daten = useStore((s) => s.daten); + const backendArt = useStore((s) => s.backend?.art); + const fb = useStore((s) => s.figurenbogen); + const kiEinstellungen = useStore((s) => s.kiEinstellungen); + const oeffneDialog = useStore((s) => s.oeffneDialog); + const oeffne = useStore((s) => s.oeffne); + const t = useTexte(); + const td = t.figuren.faktenDialog; + const ts = t.figuren.stimmeDialog; + + const paramFigur = param && typeof param === 'object' && typeof (param as { figur?: unknown }).figur === 'string' ? (param as { figur: string }).figur : undefined; + const figuren = daten ? Object.values(daten.figuren).sort((a, b) => vergleich(a.name, b.name)) : []; + const [figur, setFigur] = useState(paramFigur && daten?.figuren[paramFigur] ? paramFigur : ALLE); + const [tiefencheck, setTiefencheck] = useState(false); + const [schaetzung, setSchaetzung] = useState<{ wert?: Schaetzung; fehler?: KiFehlerAnzeige; laedt: boolean }>({ laedt: true }); + + useEffect(() => { + useStore.getState().figurenbogen.faktenZuruecksetzen(); + }, []); + + const auftrag = useMemo(() => ({ ...(figur ? { figur } : {}), ...(tiefencheck ? { tiefencheck: true } : {}) }), [figur, tiefencheck]); + const lauf = fb.faktenLauf; + const ergebnis = fb.faktenErgebnis; + const phase = lauf ? 'laeuft' : ergebnis ? 'fertig' : 'vorbereiten'; + + useEffect(() => { + if (phase !== 'vorbereiten' || !daten) return; + let aktiv = true; + setSchaetzung((s) => ({ ...s, laedt: true, fehler: undefined })); + useStore + .getState() + .figurenbogen.faktenSchaetzen(auftrag) + .then( + (wert) => aktiv && setSchaetzung({ wert, laedt: false }), + (e) => aktiv && setSchaetzung({ fehler: kiFehlerAnzeige(e), laedt: false }), + ); + return () => { + aktiv = false; + }; + }, [auftrag, phase, daten]); + + const einstellungenOeffnen = () => oeffneDialog('ki-einstellungen'); + const endpointFehlt = backendArt === 'tauri' && !kiEndpoint(kiEinstellungen()); + + if (!daten || figuren.length === 0) { + return ( + {t.gemeinsam.schliessen}}> +

{daten ? ts.keineFiguren : ts.keinProjekt}

+
+ ); + } + + if (phase === 'laeuft' && lauf) { + return ( + + + + + } + > +
+

{lauf.figur ? fmt(td.liestEine, { name: daten.figuren[lauf.figur]?.name ?? lauf.figur }) : td.liestAlle}

+
+ +
+

{ts.eineAnfrage}

+
+
+ ); + } + + if (phase === 'fertig' && ergebnis) { + const ziel = ergebnis.figur ?? figuren.find((x) => x.rolle === 'haupt')?.id; + return ( + + + {ziel && ( + + )} + + } + > +
+ {fmt(td.angaben, { n: ergebnis.angaben })} + + {fmt(ts.neu, { n: ergebnis.neu })} · {fmt(ts.verworfen, { n: ergebnis.verworfen.length })} + +
+

{td.widersprueche}

+
+
{ts.modell}
+
{ergebnis.modell}
+
{ts.kosten}
+
{formatiereUsd(ergebnis.kostenUsd, sprache())}
+
+ {ergebnis.verworfen.length > 0 && ( +
+ {ts.gruende} +
    + {ergebnis.verworfen.map((v, i) => ( +
  • {v.grund}
  • + ))} +
+
+ )} +
+ ); + } + + const s = schaetzung.wert; + return ( + + + + + } + > +

{td.intro}

+ {fb.faktenFehler && } + {endpointFehlt && } + +
+ + +
+ setTiefencheck(e.target.checked)}> + {ts.tiefencheck} + + +
+ {schaetzung.fehler ? ( + + ) : !s || schaetzung.laedt ? ( +

{ts.schaetzen}

+ ) : ( + <> +
+
{ts.modell}
+
+ {s.modell} · Effort {EFFORT[s.rolle]} +
+
{ts.eingabe}
+
{fmt(ts.eingabeWert, { n: Math.round(s.eingabeTokens) })}
+
{ts.ausgabe}
+
{fmt(ts.ausgabeWert, { n: Math.round(s.ausgabeTokens) })}
+
{ts.kosten}
+
{fmt(ts.kostenWert, { usd: formatiereUsd(s.kostenUsd, sprache()) })}
+
+ {!s.gezaehlt &&

{ts.nichtGezaehlt}

} + + )} +
+ {backendArt === 'demo' &&

{ts.demo}

} +
+ ); +} diff --git a/src/features/figuren/Figurenkarte.test.tsx b/src/features/figuren/Figurenkarte.test.tsx index 1309933..d6ff2a4 100644 --- a/src/features/figuren/Figurenkarte.test.tsx +++ b/src/features/figuren/Figurenkarte.test.tsx @@ -94,3 +94,26 @@ describe('Figurenkarte – Spitznamen als Chips (F-08/F-16)', () => { expect(useStore.getState().daten!.figuren.ole!.spitznamen).toBeUndefined(); }); }); + +describe('Figurenkarte – Angaben aus dem Text (F-17)', () => { + it('zeigt Merkmale mit Werten und Kapitel, markiert Widersprüche, × verwirft', async () => { + 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.'; + await useStore.getState().speichereKiFakten({ + version: 1, + eintraege: [ + { schluessel: 'a', figur: 'paul', merkmal: 'Auto', wert: 'alter Saab 900', kapitel: 'lotte', zitat: { kapitel: 'lotte', text: SAAB }, status: 'neu' }, + { schluessel: 'b', figur: 'paul', merkmal: 'Auto', wert: 'Volvo', kapitel: 'flut', zitat: { kapitel: 'flut', text: VOLVO }, status: 'neu' }, + ], + }); + useStore.getState().indexNeuBerechnen(); + useStore.getState().waehle({ figur: 'paul' }); + render(); + expect(screen.getByText(/^Auto ⚠$/)).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /^alter Saab 900 · Kap\. 3/ }).length).toBeGreaterThan(0); + fireEvent.click(screen.getByRole('button', { name: 'Volvo verwerfen' })); + await useStore.getState().alleSpeichernAbwarten(); + expect(useStore.getState().daten!.kiFakten!.eintraege.find((f) => f.schluessel === 'b')!.status).toBe('verworfen'); + expect(await screen.findByText(/^Auto$/)).toBeInTheDocument(); + }); +}); diff --git a/src/features/figuren/Figurenkarte.tsx b/src/features/figuren/Figurenkarte.tsx index e9f8a28..e9a4f2e 100644 --- a/src/features/figuren/Figurenkarte.tsx +++ b/src/features/figuren/Figurenkarte.tsx @@ -1,6 +1,6 @@ /** F-02–F-05: Figurenkarte nach docs/design/screens/Figur.dc.html. */ import { useId, useState, type CSSProperties } from 'react'; -import { bogenVon, RUHE_SCHWELLE, ruhendeStrecken, ruheSchwelle, sortiereStufen, stufeImKapitel, stufenSpanne } from '@core/figuren/index'; +import { bogenVon, faktenDerFigur, RUHE_SCHWELLE, ruhendeStrecken, ruheSchwelle, sortiereStufen, stufeImKapitel, stufenSpanne } from '@core/figuren/index'; import type { Index } from '@core/index/index'; import { hauptfigur } from '@core/zeit/zustand'; import type { Beziehung, BeziehungsKategorie, Erwaehnung, Figur, FigurRolle, Hinweis, Id, ProjektDaten } from '@core/model/types'; @@ -98,7 +98,58 @@ function Kopf({ f, daten, ersteStelle }: { f: Figur; daten: ProjektDaten; ersteS ); } -function AusDemText({ daten, index, nachKapitel }: { daten: ProjektDaten; index: Index; nachKapitel: Map }) { +/** F-17: Angaben aus dem Text je Merkmal (KI, wörtlich belegt); Widersprüche und Planabweichungen markiert. */ +function Fakten({ f, daten }: { f: Figur; daten: ProjektDaten }) { + const markiere = useStore((s) => s.markiere); + const oeffneDialog = useStore((s) => s.oeffneDialog); + const faktVerwerfen = useStore((s) => s.figurenbogen.faktVerwerfen); + const laeuft = useStore((s) => !!s.figurenbogen.faktenLauf); + const t = useTexte().figuren; + const tf = t.karte.fakten; + const gruppen = faktenDerFigur(daten, f.id); + const nr = (k: Id) => daten.projekt.kapitel.indexOf(k) + 1; + return ( + <> + {gruppen.map((g) => { + const auffaellig = g.widerspruch || g.plan?.abweichend; + return ( +
+ + {g.merkmal} + {auffaellig && ' ⚠'} + + + {g.fakten.map((x) => ( + + + + + ))} + {g.plan && ( + + {fmt(tf.planung, { wert: g.plan.wert })} + + )} + + +
+ ); + })} + + + oeffneDialog('figurenfakten', { figur: f.id })}>{laeuft ? tf.laeuft : gruppen.length ? tf.erneut : tf.sammeln} + + + + ); +} + +function AusDemText({ f, daten, index, nachKapitel }: { f: Figur; daten: ProjektDaten; index: Index; nachKapitel: Map }) { const markiere = useStore((s) => s.markiere); const arten = artJeKapitel([...nachKapitel.values()].flat()); const nr = (k: Id) => daten.projekt.kapitel.indexOf(k) + 1; @@ -147,6 +198,7 @@ function AusDemText({ daten, index, nachKapitel }: { daten: ProjektDaten; index:
); })} + )}
@@ -918,7 +970,7 @@ export function Figurenkarte({ id, daten, index }: { id: Id; daten: ProjektDaten
- + {(f.rolle !== 'statist' || bogenVon(daten.boegen, f.id).stufen.length > 0) && } diff --git a/src/i18n/de/figuren.ts b/src/i18n/de/figuren.ts index c60c78b..ffb2225 100644 --- a/src/i18n/de/figuren.ts +++ b/src/i18n/de/figuren.ts @@ -68,6 +68,17 @@ export const figuren = { erstesVorkommen: 'Erstes Vorkommen', stelleZeigen: 'Stelle im Text zeigen', }, + /** F-17 Angaben aus dem Text (Figurenkarte, Abschnitt „Aus dem Text“) */ + fakten: { + widerspruch: 'Der Text nennt verschiedene Werte', + planAbweichung: 'Der Text weicht von deiner Planung ab', + planung: 'Planung: {wert}', + verwerfen: '{wert} verwerfen', + verwerfenTitel: 'Falsch erkannt – verwerfen (kommt nicht wieder)', + sammeln: 'Fakten aus dem Text sammeln (KI) …', + erneut: 'Fakten erneut sammeln (KI) …', + laeuft: 'Fakten werden gesammelt …', + }, planung: { titel: 'Deine Planung', rechts: 'frei bearbeitbar', @@ -326,6 +337,20 @@ export const figuren = { nichtsEntschieden: 'Noch nichts entschieden.', }, + /** F-17 Dialog „Figurenfakten“ (FaktenDialog.tsx) */ + faktenDialog: { + titel: 'Fakten aus dem Text sammeln', + intro: + 'Die KI liest das Buch und sammelt, was der Text über die Figuren als Tatsache sagt – Alter, Aussehen, Beruf, Wohnort, Auto … –, jede Angabe mit wörtlichem Zitat. Widersprüche und Abweichungen von deiner Planung findet danach die App. Die KI bewertet nichts und ändert keinen Text.', + alle: 'Alle Figuren (ohne Statisten)', + liestAlle: 'Die KI sammelt die Angaben über alle Figuren …', + liestEine: 'Die KI sammelt die Angaben über {name} …', + fertig: 'Fakten gesammelt', + angaben: { one: '1 Angabe', other: '{n} Angaben' }, + widersprueche: 'Widersprüche und Abweichungen von der Planung stehen auf der Figurenkarte unter „Aus dem Text“ und als „Prüfen“ in den Hinweisen.', + starten: 'Sammeln', + }, + stimmeDialog: { titel: 'Figurenstimme prüfen', keineFiguren: 'Es gibt noch keine Figuren.', diff --git a/src/i18n/en/figuren.ts b/src/i18n/en/figuren.ts index dbbb81f..2439b00 100644 --- a/src/i18n/en/figuren.ts +++ b/src/i18n/en/figuren.ts @@ -65,6 +65,16 @@ export const figuren: Form = { erstesVorkommen: 'First appearance', stelleZeigen: 'Show passage in text', }, + fakten: { + widerspruch: 'The text gives different values', + planAbweichung: 'The text differs from your plan', + planung: 'Plan: {wert}', + verwerfen: 'Discard {wert}', + verwerfenTitel: 'Wrongly recognised – discard (won’t come back)', + sammeln: 'Collect facts from the text (AI) …', + erneut: 'Collect facts again (AI) …', + laeuft: 'Collecting facts …', + }, planung: { titel: 'Your plan', rechts: 'freely editable', @@ -322,6 +332,19 @@ export const figuren: Form = { nichtsEntschieden: 'Nothing decided yet.', }, + faktenDialog: { + titel: 'Collect facts from the text', + intro: + 'The AI reads the book and collects what the text states as fact about the characters – age, appearance, occupation, home, car … – each with a verbatim quote. Contradictions and deviations from your plan are then found by the app. The AI judges nothing and changes no text.', + alle: 'All characters (without walk-ons)', + liestAlle: 'The AI is collecting the facts about all characters …', + liestEine: 'The AI is collecting the facts about {name} …', + fertig: 'Facts collected', + angaben: { one: '1 fact', other: '{n} facts' }, + widersprueche: 'Contradictions and deviations from the plan appear on the character card under “From the text” and as “Check” in the notes.', + starten: 'Collect', + }, + stimmeDialog: { titel: 'Check character voice', keineFiguren: 'There are no characters yet.', diff --git a/src/state/slices/figurenbogen.ts b/src/state/slices/figurenbogen.ts index 527bf7d..3d416fe 100644 --- a/src/state/slices/figurenbogen.ts +++ b/src/state/slices/figurenbogen.ts @@ -17,7 +17,8 @@ import { setzeStimmenStatus, stimmenStatus, } from '@core/figuren/index'; -import { KiFehler, pruefeStimme, schaetzeStimme, type Schaetzung, type StimmenAuftrag } from '@core/ki/index'; +import { mischeFakten, setzeFaktStatus } from '@core/figuren/fakten'; +import { KiFehler, pruefeStimme, sammleFakten, schaetzeFakten, schaetzeStimme, type FaktenAuftrag, type Schaetzung, type StimmenAuftrag } from '@core/ki/index'; import type { BogenStufe, Figurenbogen as BogenDaten, HinweisEntscheidung, Id, ProjektDaten, TextAnker } from '@core/model/types'; import type { ProjektLeser } from '@core/projekt/fs'; import { sprache, texte } from '../../i18n'; @@ -42,6 +43,19 @@ export interface StimmenLaufErgebnis { zeit: number; } +/** F-17: Ergebnis eines Laufs „Figurenfakten“ */ +export interface FaktenLaufErgebnis { + figur?: Id; + /** gültige Angaben aus der Antwort */ + angaben: number; + /** neu in der Ablage */ + neu: number; + /** verworfene Teile der Antwort */ + verworfen: { grund: string }[]; + modell: string; + kostenUsd: number; +} + export interface Figurenbogen { /** Bereich „Figuren“: Figurenkarte oder Figuren-Bögen */ figurenAnsicht: 'karte' | 'boegen' | 'besetzung'; @@ -80,6 +94,18 @@ export interface Figurenbogen { stimmeZuruecksetzen(): void; /** „Ist Absicht“ / „Erledigt“; null setzt zurück */ stimmeEntscheiden(schluessel: string, entscheidung: HinweisEntscheidung | null): Promise; + + /** F-17 Figurenfakten: laufender Lauf (figur: nur diese Figur, sonst alle) */ + faktenLauf: FaktenAuftrag | null; + faktenFehler: KiFehlerAnzeige | null; + faktenErgebnis: FaktenLaufErgebnis | null; + faktenSchaetzen(auftrag: FaktenAuftrag): Promise; + /** Ergebnis wird in .plotwerk/figurenfakten.json gemischt */ + faktenSammeln(auftrag: FaktenAuftrag): Promise; + faktenAbbrechen(): void; + faktenZuruecksetzen(): void; + /** falsch erkannte Angabe verwerfen (kommt nicht wieder) bzw. wiederherstellen */ + faktVerwerfen(schluessel: string, verworfen?: boolean): Promise; } export interface FigurenbogenSlice { @@ -237,6 +263,55 @@ export const createFigurenbogenSlice: StateCreator ({ grund: v.grund })), + modell: r.modell, + kostenUsd: r.kostenUsd, + }, + }); + } catch (e) { + setze({ faktenFehler: kiFehlerAnzeige(e) }); + } finally { + if (abbruch === steuerung) abbruch = null; + setze({ faktenLauf: null }); + } + }, + + faktenAbbrechen() { + abbruch?.abort(); + }, + + faktenZuruecksetzen() { + if (get().figurenbogen.faktenLauf) return; + setze({ faktenErgebnis: null, faktenFehler: null }); + }, + + async faktVerwerfen(schluessel, verworfen = true) { + await get().speichereKiFakten(setzeFaktStatus(get().daten?.kiFakten, schluessel, verworfen ? 'verworfen' : 'neu')); + }, + async stimmeEntscheiden(schluessel, entscheidung) { await get().hinweisEntscheiden(schluessel, entscheidung); // Zurücksetzen gilt auch für einen in der Datei gespeicherten Status diff --git a/src/state/slices/gruppenFiguren.test.ts b/src/state/slices/gruppenFiguren.test.ts index a824733..1441fe3 100644 --- a/src/state/slices/gruppenFiguren.test.ts +++ b/src/state/slices/gruppenFiguren.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { MemoryFs, parseFigur, parseGruppe, figurPfad, gruppePfad } from '@core/projekt'; +import { MemoryFs, parseFigur, parseGruppe, parseKiFakten, figurPfad, gruppePfad, PFADE } from '@core/projekt'; +import { erstelleDemoKiTransport } from '../../backend/ki/demo'; import { demoDateien } from '../../../fixtures/demoProjekt'; import { erstelleDemoBackend } from '../../backend/demo'; import { exposeGeruest } from '@core/abgabe/index'; @@ -175,3 +176,22 @@ describe('Personenverzeichnis (Abgabe)', () => { expect(mit.length).toBeGreaterThan(ohne.length); }); }); + +describe('Figurenfakten (F-17, Store)', () => { + it('Lauf mit Demo-KI speichert die Ablage; Widerspruch erscheint als Hinweis; Verwerfen wirkt', async () => { + const { store, fs } = await laden(); + store.setState({ backend: { ...erstelleDemoBackend(), ki: erstelleDemoKiTransport({ verzoegerungMs: 0 }) } }); + await store.getState().figurenbogen.faktenSammeln({}); + const s = store.getState(); + expect(s.figurenbogen.faktenFehler).toBeNull(); + expect(s.figurenbogen.faktenErgebnis!.angaben).toBeGreaterThanOrEqual(4); + await s.alleSpeichernAbwarten(); + expect(parseKiFakten((await fs.lesen(PFADE.kiFakten))!).eintraege.length).toBe(s.figurenbogen.faktenErgebnis!.angaben); + s.indexNeuBerechnen(); + expect(store.getState().index.hinweise.some((h) => h.schluessel === 'fakt:widerspruch:paul|auto')).toBe(true); + const volvo = store.getState().daten!.kiFakten!.eintraege.find((f) => f.wert === 'Volvo')!; + await store.getState().figurenbogen.faktVerwerfen(volvo.schluessel); + store.getState().indexNeuBerechnen(); + expect(store.getState().index.hinweise.some((h) => h.schluessel === 'fakt:widerspruch:paul|auto')).toBe(false); + }); +}); diff --git a/src/state/store.ts b/src/state/store.ts index adae5e0..2546aca 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -23,6 +23,7 @@ import type { Markenliste, Recherche, Schreibstatistik, + FigurFakt, StimmenBeobachtung, Zeitleiste, ZeitVorschlag, @@ -92,6 +93,7 @@ export type DialogId = | 'zeit-vorschlaege' /** G3: Figurenstimme prüfen; param {figur?: Id} */ | 'stimme-pruefen' + | 'figurenfakten' /** G4: Markennamen-Übersicht */ | 'marken' /** G5: benannte Fassung anlegen */ @@ -213,6 +215,8 @@ export interface Basis { speichereKiZustandVorschlaege(a: KiAblage): Promise; /** G3 .plotwerk/stimmen.json */ speichereKiStimmen(a: KiAblage): Promise; + /** F-17 .plotwerk/figurenfakten.json */ + speichereKiFakten(a: KiAblage): Promise; /** G6 .plotwerk/leser.json */ speichereKiLeser(a: KiAblage): Promise; /** G6 .plotwerk/fragen.json */ @@ -708,6 +712,7 @@ const createBasis: StateCreator = (set, get) => { speichereKiZustandVorschlaege: (a) => speichereFeld('kiZustandVorschlaege', a, persistenz.PFADE.kiZustand, persistenz.speichereKiZustandVorschlaege), speichereKiStimmen: (a) => speichereFeld('kiStimmen', a, persistenz.PFADE.kiStimmen, persistenz.speichereKiStimmen), + speichereKiFakten: (a) => speichereFeld('kiFakten', a, persistenz.PFADE.kiFakten, persistenz.speichereKiFakten), speichereKiLeser: (a) => speichereFeld('kiLeser', a, persistenz.PFADE.kiLeser, persistenz.speichereKiLeser), speichereKiFragen: (a) => speichereFeld('kiFragen', a, persistenz.PFADE.kiFragen, persistenz.speichereKiFragen), @@ -810,6 +815,7 @@ const createBasis: StateCreator = (set, get) => { recherche: get().speichereRecherche, kiZustandVorschlaege: get().speichereKiZustandVorschlaege, kiStimmen: get().speichereKiStimmen, + kiFakten: get().speichereKiFakten, }; const einzeln = (Object.keys(felder) as (keyof ProjektDaten)[]) .filter((k) => nachher[k] !== vorher[k] && nachher[k] != null) From 46f3b0fe571fd7606c9fcde6a6ffe1bfa2e5947f Mon Sep 17 00:00:00 2001 From: Leon Machnik <98738343+leonatwork@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:35:48 +0200 Subject: [PATCH 13/13] Beispielnamen in Tests und Doku neutral (Henrik/Hendrik) Co-Authored-By: Claude Opus 5.5 --- core/figuren/hinweise.test.ts | 28 ++++++++++++++-------------- core/figuren/zusammenfuehren.ts | 2 +- docs/plan-figuren-namen.md | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/core/figuren/hinweise.test.ts b/core/figuren/hinweise.test.ts index bbf0f38..123fa52 100644 --- a/core/figuren/hinweise.test.ts +++ b/core/figuren/hinweise.test.ts @@ -54,22 +54,22 @@ describe('Satz um eine Stelle', () => { 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 Marian nichts.'], [figur('marian', 'Marian', { aliase: ['Alte'] })]); + 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:marian|alte'); + 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 Marian nichts.' }, + { 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(['Marians Hut lag da. Der Alte kam.'], [figur('marian', 'Marian', { aliase: ['Alte'] })]), 'vorher')).toEqual([]); - expect(hinweise(projekt(['Der Alte kam.'], [figur('marian', 'Marian', { aliase: ['Alte'] })]), 'vorher')).toEqual([]); + 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.', 'Marian ging.'], [figur('marian', 'Marian', { aliase: ['Alte'], existenz: 'bewusst_vage' })]), 'vorher'), + hinweise(projekt(['Der Alte kam.', 'Henrik ging.'], [figur('henrik', 'Henrik', { aliase: ['Alte'], existenz: 'bewusst_vage' })]), 'vorher'), ).toEqual([]); }); }); @@ -77,11 +77,11 @@ describe('F-11 Spitzname vor dem Namen', () => { describe('F-12 verwechselbare Namen', () => { it('ein Buchstabe Unterschied, gleicher Anfang bei gleicher Länge', () => { const d = projekt( - ['Marian kam mit Marion. Sabine und Sabina lachten.'], - [figur('marian', 'Marian'), figur('marion', 'Marion'), figur('sabine', 'Sabine'), figur('sabina', 'Sabina')], + ['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:marian|marion|Marian|Marion', + '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')]); @@ -90,7 +90,7 @@ describe('F-12 verwechselbare Namen', () => { 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(['Marian kam.'], [figur('marian', 'Marian'), figur('marion', 'Marion')]), 'aehnlich')).toEqual([]); + expect(hinweise(projekt(['Henrik kam.'], [figur('henrik', 'Henrik'), figur('hendrik', 'Hendrik')]), 'aehnlich')).toEqual([]); }); }); @@ -162,13 +162,13 @@ describe('F-15 Besetzung', () => { 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.', 'Marian lachte.', 'Der Alte ging.'], [ - figur('marian', 'Marian', { aliase: ['Alte'], spitznamen: [{ name: 'Alte', abKapitel: 'k3' }] }), + 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:marian|alte']); + expect(h.map((x) => x.schluessel)).toEqual(['figuren:frueh:henrik|alte']); expect(h[0]!.zitate).toEqual([{ kapitel: 'k1', text: 'Der Alte kam.' }]); - d.figuren.marian = { ...d.figuren.marian!, spitznamen: [{ name: 'Alte', abKapitel: 'k1' }] }; + d.figuren.henrik = { ...d.figuren.henrik!, spitznamen: [{ name: 'Alte', abKapitel: 'k1' }] }; expect(hinweise(d, 'frueh')).toEqual([]); }); diff --git a/core/figuren/zusammenfuehren.ts b/core/figuren/zusammenfuehren.ts index 0cc5ce9..7b609d0 100644 --- a/core/figuren/zusammenfuehren.ts +++ b/core/figuren/zusammenfuehren.ts @@ -1,5 +1,5 @@ /** - * Zwei Figuren zusammenführen (z. B. „Marian“ und „der Alte“, die sich als dieselbe Person herausstellen). + * 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. * diff --git a/docs/plan-figuren-namen.md b/docs/plan-figuren-namen.md index a9302fe..0517264 100644 --- a/docs/plan-figuren-namen.md +++ b/docs/plan-figuren-namen.md @@ -107,7 +107,7 @@ Artikelformen). Hinweis mit beiden Zitaten. Nicht für Figuren ohne Namensnennun „bewusst vage“. **F-12 Verwechselbare Namen.** Paare von Figuren (Name und Spitznamen), die sich leicht verwechseln lassen: -Editierabstand 1 bei Namen ≥ 4 Zeichen (Marion/Marian-Typ), gleicher Anfang und gleiche Länge, gleicher +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.