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/20] =?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/20] =?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:52:22 +0200 Subject: [PATCH 07/20] =?UTF-8?q?Str=C3=A4nge:=20Haupt-/Nebenhandlung,=20K?= =?UTF-8?q?I-Vorschl=C3=A4ge,=20lose=20Enden=20als=20Str=C3=A4nge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kapitel planen: - „Geplante Szene“ heißt jetzt „Kurzbeschreibung“ (Feld szene unverändert) - Neues optionales Feld „Funktion im Buch“ (Opening, Wendepunkt … oder eigene), als Etikett auf der Karte; geht nicht an die KI Stränge (Akte planen → Stränge): - Strang-Inspektor: Haupt-/Nebenhandlung (rolle), „Wofür steht der Strang?“ (beschreibung), Farbe; Haupthandlung oben im Raster und in der Legende - KI-Aufgabe „Stränge vorschlagen“ (core/ki/straenge.ts): Zuordnungen von Kapiteln, neue Stränge mit Rolle und Zweck, Rückfragen – je mit wörtlichem Beleg; Übernehmen/Verwerfen/Antworten, Ablage .plotwerk/strang-vorschlaege.json - E12: Strang-Angaben gehen nur im Aufgabentext dieser Aufgabe mit (freigegeben 2026-10-12) Lose Enden (bisher „Fäden“): - Umbenennung in der Oberfläche; interne Namen bleiben - „Als Strang übernehmen“ legt aus einem losen Ende einen Strang an und trägt ihn in alle Kapitel ein, in denen es vorkommt - Umschalter Board | Tabelle: lose Enden × Kapitel, gruppiert nach Strang Co-Authored-By: Claude Opus 5.5 Signed-off-by: Leon Machnik <98738343+leonatwork@users.noreply.github.com> --- core/index/hinweisTypen.ts | 4 +- core/ki/index.ts | 2 + core/ki/kontext.ts | 3 +- core/ki/meldungen.ts | 13 + core/ki/straenge.test.ts | 107 ++++++ core/ki/straenge.ts | 350 +++++++++++++++++++ core/model/types.ts | 44 ++- core/projekt/extern.ts | 1 + core/projekt/lesen.ts | 45 ++- core/projekt/schreiben.ts | 12 +- core/struktur/straenge.ts | 183 ++++++++++ core/struktur/struktur.en.test.ts | 2 +- docs/abnahme.md | 3 + docs/anforderungen-v1.1.md | 3 + docs/architektur.md | 13 +- docs/handbuch.md | 20 +- src/app/app.en.test.tsx | 6 +- src/app/app.test.tsx | 2 +- src/app/befehle.ts | 6 + src/app/deeplink.ts | 1 + src/app/dialoge.tsx | 1 + src/app/suche.ts | 1 + src/backend/ki/demo/straenge.ts | 120 +++++++ src/backend/ki/demoAntworten/index.ts | 3 +- src/backend/ki/demoAntworten/struktur.ts | 8 + src/features/akte/KapitelInspektor.tsx | 41 ++- src/features/akte/Karten.tsx | 5 + src/features/akte/index.tsx | 17 +- src/features/faeden/FadenTabelle.tsx | 153 ++++++++ src/features/faeden/StraengeDialog.tsx | 198 +++++++++++ src/features/faeden/StrangInspektor.tsx | 129 +++++++ src/features/faeden/StrangRaster.tsx | 86 +++-- src/features/faeden/StrangVorschlaege.tsx | 340 ++++++++++++++++++ src/features/faeden/faeden.css | 215 ++++++++++++ src/features/faeden/faeden.en.test.tsx | 2 +- src/features/faeden/faeden.test.tsx | 4 +- src/features/faeden/index.tsx | 34 +- src/features/faeden/straenge.test.tsx | 161 +++++++++ src/features/hinweise/hinweise.en.test.tsx | 4 +- src/features/hinweise/hinweise.test.tsx | 2 +- src/features/schreiben/SchreibInspektor.tsx | 1 + src/features/schreiben/schreiben.en.test.tsx | 4 +- src/i18n/de/akte.ts | 19 +- src/i18n/de/app.ts | 7 +- src/i18n/de/faeden.ts | 145 ++++++-- src/i18n/de/figuren.ts | 2 +- src/i18n/de/fragen.ts | 6 +- src/i18n/de/hinweise.ts | 4 +- src/i18n/de/kapitel.ts | 2 +- src/i18n/de/schreiben.ts | 7 +- src/i18n/en/akte.ts | 19 +- src/i18n/en/app.ts | 7 +- src/i18n/en/faeden.ts | 138 ++++++-- src/i18n/en/figuren.ts | 2 +- src/i18n/en/fragen.ts | 6 +- src/i18n/en/hinweise.ts | 4 +- src/i18n/en/kapitel.ts | 2 +- src/i18n/en/schreiben.ts | 7 +- src/state/slices/struktur.ts | 172 ++++++++- src/state/store.ts | 7 + 60 files changed, 2764 insertions(+), 141 deletions(-) create mode 100644 core/ki/straenge.test.ts create mode 100644 core/ki/straenge.ts create mode 100644 core/struktur/straenge.ts create mode 100644 src/backend/ki/demo/straenge.ts create mode 100644 src/backend/ki/demoAntworten/struktur.ts create mode 100644 src/features/faeden/FadenTabelle.tsx create mode 100644 src/features/faeden/StraengeDialog.tsx create mode 100644 src/features/faeden/StrangInspektor.tsx create mode 100644 src/features/faeden/StrangVorschlaege.tsx create mode 100644 src/features/faeden/straenge.test.tsx diff --git a/core/index/hinweisTypen.ts b/core/index/hinweisTypen.ts index 45c31c0..d6f1f8c 100644 --- a/core/index/hinweisTypen.ts +++ b/core/index/hinweisTypen.ts @@ -6,7 +6,7 @@ import type { HinweisTyp } from '../model/types'; export const HINWEIS_TYP_NAME: Record = { pruefen: 'Prüfen', schreibweise: 'Schreibweise', - offener_faden: 'Offener Faden', + offener_faden: 'Loses Ende', unbekannt: 'Unbekannt', struktur: 'Struktur', zeitleiste: 'Zeitleiste', @@ -18,7 +18,7 @@ export const HINWEIS_TYP_REIHE = Object.keys(HINWEIS_TYP_NAME) as HinweisTyp[]; const HINWEIS_TYP_NAME_EN: Record = { pruefen: 'Check', schreibweise: 'Spelling', - offener_faden: 'Open thread', + offener_faden: 'Loose end', unbekannt: 'Unknown', struktur: 'Structure', zeitleiste: 'Timeline', diff --git a/core/ki/index.ts b/core/ki/index.ts index 517fa5f..6a26c52 100644 --- a/core/ki/index.ts +++ b/core/ki/index.ts @@ -54,6 +54,8 @@ export * from './aufgabe'; export * from './zeit'; export * from './stimme'; export * from './leser'; +/** G2 Stränge vorschlagen */ +export * from './straenge'; /** Transport zur Messages-API (Foundry oder Claude API). In der App: Rust-Kommando ki_anfrage. */ export interface KiTransport { diff --git a/core/ki/kontext.ts b/core/ki/kontext.ts index 4f53d02..beda1f6 100644 --- a/core/ki/kontext.ts +++ b/core/ki/kontext.ts @@ -4,7 +4,8 @@ * Es werden ausschließlich Felder einzeln übernommen, nie ganze Objekte: Kapiteltitel und * Klartext, Figuren (name, aliase, rolle, existenz, Plan-Felder), Kapitelpläne (szene, notiz), * Orte (name, aliase), Gruppen (name, typ, status) und Realitätseinträge (stufe, text, Zitat). - * Nie: wahrheit, notizen, kurz, beschreibung, Anmerkungen, Ideen, Abweichungen, Akte, Stränge. + * Nie: wahrheit, notizen, kurz, beschreibung, Anmerkungen, Ideen, Abweichungen, Akte, Stränge. (Stränge gehen nur im + * Aufgabentext von „Stränge vorschlagen“ mit, siehe straenge.ts – freigegeben 2026-10-12.) * Plan-Felder sind seit Leons Entscheidung vom 2026-10-09 freigegeben (F-05). * * Buchsprache (angaben.sprache, nur als Sprache der Beschriftungen, nicht als Feld): Für englische Bücher sind die diff --git a/core/ki/meldungen.ts b/core/ki/meldungen.ts index cc08188..eadc870 100644 --- a/core/ki/meldungen.ts +++ b/core/ki/meldungen.ts @@ -117,6 +117,13 @@ const de = { planAbgleichOhneStatus: 'Plan-Abgleich ohne gültigen Status', nameFrage: 'Frage an das Buch', antwortFehlt: 'Antwort fehlt', + + // Erzählstränge (straenge.ts) + nameStraenge: 'Stränge vorschlagen', + strangUnbekannt: 'unbekannter Strang „{strang}“', + schonImPlan: 'Kap. {nr} steht schon beim Strang „{name}“', + strangGibtEs: 'den Strang „{name}“ gibt es schon', + ohneKapitel: 'ohne Kapitel mit Beleg', }; const en: typeof de = { @@ -221,6 +228,12 @@ const en: typeof de = { planAbgleichOhneStatus: 'Plan comparison without a valid status', nameFrage: 'Question to the book', antwortFehlt: 'Answer missing', + + nameStraenge: 'Suggest plot lines', + strangUnbekannt: 'unknown plot line “{strang}”', + schonImPlan: 'Ch. {nr} is already part of the plot line “{name}”', + strangGibtEs: 'the plot line “{name}” already exists', + ohneKapitel: 'no chapter with evidence', }; /** Wörterbuch der core/ki-Meldungen je Oberflächensprache. */ diff --git a/core/ki/straenge.test.ts b/core/ki/straenge.test.ts new file mode 100644 index 0000000..aa8b8bc --- /dev/null +++ b/core/ki/straenge.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { demoFs, demoFsEn, ladeDemoProjekt } from '../../fixtures/demoProjekt'; +import { erstelleDemoKiTransport } from '../../src/backend/ki/demo'; +import { ladeProjekt, MemoryFs, parseKiStrangVorschlaege, serializeKiStrangVorschlaege } from '../projekt'; +import { offeneStrangVorschlaege, strangFrageBeantworten, strangVorschlagUebernehmen, strangVorschlagVerwerfen } from '../struktur/straenge'; +import { aufgabenAnfrage, mischeStrangVorschlaege, schlageStraengeVor, straengeDefinition, strangAuftrag } from './index'; + +const demo = () => erstelleDemoKiTransport({ verzoegerungMs: 0 }); + +/** Text des zweiten Blocks (Aufgabe) aus einem Messages-API-Body */ +function aufgabeAus(body: Record): string { + const inhalt = (body.messages as { content: { text?: string }[] }[])[0]!.content; + return inhalt.map((c) => c.text ?? '').join('\n'); +} + +describe('Stränge vorschlagen (G2)', () => { + it('schickt Name, Rolle, Zweck und Plan-Kapitel der Stränge nur in der Aufgabe, nicht im Prefix', async () => { + const fs = demoFs(); + const d = await ladeProjekt(fs); + const nordhaven = d.straenge.find((s) => s.id === 'nordhaven')!; + d.straenge = d.straenge.map((s) => (s.id === 'nordhaven' ? { ...nordhaven, rolle: 'haupt' as const, beschreibung: 'Der Auftrag für den [[figur:konrad|Konrad]]-Kunden' } : s)); + const auftrag = strangAuftrag(d); + expect(auftrag.straenge.find((s) => s.id === 'nordhaven')).toEqual({ + id: 'nordhaven', + name: 'Antagonist: Nordhaven', + rolle: 'haupt', + beschreibung: 'Der Auftrag für den Konrad-Kunden', + kapitel: ['grundriss', 'fassade', 'staub', 'weissraum'], + }); + const body = await aufgabenAnfrage(fs, straengeDefinition(auftrag)); + const text = aufgabeAus(body); + expect(text).toContain('Antagonist: Nordhaven (id: nordhaven) · Haupthandlung · Zweck: Der Auftrag für den Konrad-Kunden'); + const prefix = (body.messages as { content: { text?: string }[] }[])[0]!.content[0]!.text ?? ''; + expect(prefix).not.toContain('Zweck:'); + }); + + it('prüft die Demo-Antwort: Zitate wörtlich, Erfundenes verworfen, schon Geplantes nicht', async () => { + const r = await schlageStraengeVor(demoFs(), demo(), undefined); + const arten = r.ergebnis.vorschlaege.map((v) => `${v.art}:${v.schluessel}`); + expect(arten).toEqual([ + 'neu:strang:der-alte-speicher', + 'zuordnung:zuordnung:nordhaven|funkstille', + expect.stringMatching(/^frage:frage:gehoert-der-verkauf/), + expect.stringMatching(/^frage:frage:welcher-strang/), + ]); + const neu = r.ergebnis.vorschlaege[0]!; + expect(neu).toMatchObject({ name: 'Der alte Speicher', rolle: 'neben', kapitel: ['grundriss', 'staub', 'funkstille'] }); + expect(neu.belege).toHaveLength(3); + // erfundenes Postkarten-Zitat + expect(r.verworfen.some((v) => /nicht wörtlich/.test(v.grund))).toBe(true); + }); + + it('beantwortet englische Bücher auf Englisch', async () => { + const r = await schlageStraengeVor(demoFsEn(), demo(), undefined, { oberflaeche: 'en' }); + expect(r.ergebnis.vorschlaege.find((v) => v.art === 'neu')?.name).toBe('The old granary'); + }); + + it('übernimmt, verwirft und beantwortet – Entschiedenes kommt nicht wieder', async () => { + const d = await ladeDemoProjekt(); + const r = await schlageStraengeVor(demoFs(), demo(), undefined); + const gemischt = mischeStrangVorschlaege(undefined, r.ergebnis.vorschlaege, { datum: r.datum, modell: r.modell }); + expect(gemischt.neu).toBe(4); + d.kiStrangVorschlaege = gemischt.ablage; + expect(offeneStrangVorschlaege(d, 'funkstille').map((v) => v.art)).toEqual(['neu', 'zuordnung']); + + // neuer Strang nur für zwei der drei Kapitel + const u = strangVorschlagUebernehmen(d, 'strang:der-alte-speicher', ['grundriss', 'staub'])!; + const speicher = u.straenge.find((s) => s.id === 'der-alte-speicher')!; + expect(speicher).toMatchObject({ name: 'Der alte Speicher', rolle: 'neben', farbe: '#2F6B45' }); + expect(speicher.beschreibung).toMatch(/Kornspeicher/); + expect(u.plaene.map((p) => [p.id, p.straenge.includes('der-alte-speicher')])).toEqual([ + ['grundriss', true], + ['staub', true], + ]); + + const zuordnung = strangVorschlagUebernehmen(d, 'zuordnung:nordhaven|funkstille')!; + expect(zuordnung.plaene[0]!.straenge).toContain('nordhaven'); + + const frage = gemischt.ablage.eintraege.find((v) => v.art === 'frage')!; + let ablage = strangFrageBeantworten(u.ablage, frage.schluessel, 'Eigener Strang, gehört aber zum Hafen.'); + ablage = strangVorschlagVerwerfen(ablage, 'zuordnung:nordhaven|funkstille'); + d.kiStrangVorschlaege = ablage; + expect(strangAuftrag(d).antworten).toEqual([{ frage: frage.frage, antwort: 'Eigener Strang, gehört aber zum Hafen.' }]); + + // zweiter Lauf: alles Entschiedene bleibt draußen + const nochmal = mischeStrangVorschlaege(ablage, r.ergebnis.vorschlaege); + expect(nochmal.bereitsEntschieden).toBe(3); + expect(nochmal.ablage.eintraege.filter((v) => v.status === 'neu')).toHaveLength(1); + + // Ablage übersteht Speichern und Lesen + expect(parseKiStrangVorschlaege(serializeKiStrangVorschlaege(nochmal.ablage))).toEqual(nochmal.ablage); + }); + + it('liest und schreibt Rolle, Beschreibung und Funktion', async () => { + const fs = new MemoryFs({ + 'projekt.md': '---\nversion: 1\ntitel: T\nkapitel: [eins]\n---\n', + 'plan/straenge.md': '---\nstraenge:\n - id: fall\n name: Der Fall\n farbe: "#3D5A75"\n rolle: haupt\n beschreibung: Jan wird ausgenutzt\n - id: x\n name: X\n farbe: "#C2405C"\n rolle: quatsch\n---\n', + 'plan/kapitel/eins.md': '---\nid: eins\ntitel: Eins\nakt: null\nstatus: geplant\nszene: Opening\nfunktion: Wendepunkt\nstraenge: []\nfiguren: []\norte: []\n---\nNotiz\n', + }); + const d = await ladeProjekt(fs); + expect(d.straenge).toEqual([ + { id: 'fall', name: 'Der Fall', farbe: '#3D5A75', rolle: 'haupt', beschreibung: 'Jan wird ausgenutzt' }, + { id: 'x', name: 'X', farbe: '#C2405C' }, + ]); + expect(d.plaene.eins).toMatchObject({ szene: 'Opening', funktion: 'Wendepunkt', notiz: 'Notiz' }); + }); +}); diff --git a/core/ki/straenge.ts b/core/ki/straenge.ts new file mode 100644 index 0000000..0025917 --- /dev/null +++ b/core/ki/straenge.ts @@ -0,0 +1,350 @@ +/** + * KI-Aufgabe „Stränge vorschlagen“ (G2 Plot-Struktur). Besitzer: G2. Nutzt fuehreAufgabeAus/AufgabenDefinition aus + * ./aufgabe. Nur ProjektLeser und KiTransport. + * + * Ablauf: ganzes Buch als gecachter Prefix (E13), danach die Aufgabe „straenge“ mit Leons Strängen (Name, + * Haupt-/Nebenhandlung, „Wofür steht der Strang?“, Kapitel im Plan) und seinen Antworten auf frühere Rückfragen – + * freigegeben von Leon am 2026-10-12 (E12, nur für diese Aufgabe, nicht im Prefix). + * Antwort: Zuordnungen geschriebener Kapitel zu angelegten Strängen, neue Stränge mit Kapiteln, Rückfragen. + * Jede Zuordnung und jedes Kapitel eines neuen Strangs braucht ein wörtliches Zitat aus genau diesem Kapitel (E6). + * Schreibt nichts – gespeichert wird im Store (.plotwerk/strang-vorschlaege.json). + */ +import type { Id, KiAblage, KiEinstellungen, ProjektDaten, StrangRolle, StrangVorschlag, Zitat } from '../model/types'; +import type { ProjektLeser } from '../projekt/fs'; +import { ladeProjekt } from '../projekt/lesen'; +import { + fuehreAufgabeAus, + schaetzeAufgabe, + type AufgabenDefinition, + type AufgabenErgebnis, + type AufgabenOptionen, + type AufgabenPruefung, +} from './aufgabe'; +import type { KiTransport, Schaetzung } from './index'; +import { planText, type BuchKontext } from './kontext'; +import { meldungenIn } from './meldungen'; +import { loseEndeId } from './pruefung'; +import { ANTWORTSPRACHE_EN, kapKurz, type Sprache } from './sprache'; + +/** Kennung im Aufgabenkopf (); Demo-Antworten sind unter diesem Schlüssel registriert. */ +export const STRAENGE_ART = 'straenge'; + +/** höchstens so viele Rückfragen je Lauf */ +export const STRAENGE_MAX_FRAGEN = 3; + +/** Ein Strang, wie er an das Modell geht (E12: nur diese Felder). */ +export interface KontextStrang { + id: Id; + name: string; + rolle?: StrangRolle; + /** „Wofür steht der Strang?“ als eine Zeile */ + beschreibung: string; + /** Kapitel, in denen der Plan den Strang schon führt (Buchreihenfolge) */ + kapitel: Id[]; +} + +export interface StrangAuftrag { + straenge: KontextStrang[]; + /** von Leon beantwortete Rückfragen früherer Läufe */ + antworten: { frage: string; antwort: string }[]; +} + +export interface StrangErgebnis { + /** geprüfte Vorschläge (Status „neu“), Buchreihenfolge */ + vorschlaege: StrangVorschlag[]; +} + +/** Allowlist (E12) für diese Aufgabe: nur Name, Rolle, Beschreibung und Plan-Kapitel der Stränge, dazu beantwortete Rückfragen. */ +export function strangAuftrag(daten: ProjektDaten): StrangAuftrag { + const reihe = daten.projekt.kapitel; + const straenge = daten.straenge.map((s) => ({ + id: s.id, + name: s.name, + ...(s.rolle ? { rolle: s.rolle } : {}), + beschreibung: planText(s.beschreibung), + kapitel: reihe.filter((k) => daten.plaene[k]?.straenge.includes(s.id)), + })); + const antworten = (daten.kiStrangVorschlaege?.eintraege ?? []) + .filter((v) => v.art === 'frage' && v.status === 'uebernommen' && v.frage && v.antwort?.trim()) + .map((v) => ({ frage: planText(v.frage), antwort: planText(v.antwort) })); + return { straenge, antworten }; +} + +export const SYSTEM_STRAENGE = `Du hilfst einem Autor, die Erzählstränge seines deutschsprachigen Romanmanuskripts zu ordnen. Ein Erzählstrang ist ein Handlungsfaden, der sich über mehrere Kapitel zieht (z. B. ein Fall, eine Beziehung, ein Konflikt). Du liest das Buch und schlägst vor, welche geschriebenen Kapitel zu welchem Strang gehören, welche Stränge das Buch trägt, die noch nicht angelegt sind, und wo du beim Autor nachfragen musst. + +Du schreibst nie Text für das Buch: keine Umformulierungen, keine Ersatzsätze, keine Formulierungsvorschläge, keine Ideen für neue Szenen. Was ein Strang ist und wohin er gehört, entscheidet allein der Autor. + +Regeln: +- Jeder Vorschlag stützt sich auf den der Kapitel. Jedes Zitat ist eine zusammenhängende Passage aus dem Kapitel, das du unter "kapitel" mit seiner id angibst, Zeichen für Zeichen kopiert, ohne Auslassungen, höchstens ein Satz. +- Planung des Autors (, Stränge, Antworten auf Rückfragen) hilft beim Einordnen; sie ist kein Beleg. +- Namen, Zweck und Begründungen sind sachlich und kurz. Lieber wenige sichere Vorschläge als viele unsichere.`; + +export const SYSTEM_STRAENGE_EN = `You help an author organise the plot lines of their English-language novel manuscript. A plot line is a narrative thread that runs through several chapters (e.g. a case, a relationship, a conflict). You read the book and suggest which written chapters belong to which plot line, which plot lines the book carries that have not been set up yet, and where you need to ask the author. + +You never write text for the book: no rewordings, no replacement sentences, no suggested phrasings, no ideas for new scenes. What a plot line is and where it belongs is decided by the author alone. + +Rules: +- Every suggestion is based on the of the chapters. Every quote is one continuous passage from the chapter you name under "kapitel" with its id, copied character for character, without omissions, at most one sentence. +- The author's planning (, plot lines, answers to questions) helps you to classify; it is not evidence. +- Names, purposes and reasons are factual and short. Better a few certain suggestions than many uncertain ones. + +${ANTWORTSPRACHE_EN}`; + +function strangZeile(s: KontextStrang, kontext: BuchKontext, en: boolean): string { + const nr = new Map(kontext.kapitel.map((k) => [k.id, k.nr])); + const teile = [`${s.name} (id: ${s.id})`]; + if (s.rolle) teile.push(s.rolle === 'haupt' ? (en ? 'main plot' : 'Haupthandlung') : en ? 'subplot' : 'Nebenhandlung'); + if (s.beschreibung) teile.push(`${en ? 'purpose' : 'Zweck'}: ${s.beschreibung}`); + const kap = s.kapitel.map((k) => kapKurz(nr.get(k) ?? '?', kontext.sprache)); + teile.push(kap.length ? `${en ? 'in the plan in' : 'im Plan in'}: ${kap.join(', ')}` : en ? 'not yet in any chapter' : 'noch in keinem Kapitel'); + return `- ${teile.join(' · ')}`; +} + +function aufgabenText(kontext: BuchKontext, auftrag: StrangAuftrag): string { + const en = kontext.sprache === 'en'; + const z: string[] = []; + z.push(en ? "The author's plot lines (planning, not part of the book):" : 'Erzählstränge des Autors (Planung, gehört nicht zum Buch):'); + if (auftrag.straenge.length === 0) z.push(en ? '- (none set up yet)' : '- (noch keine angelegt)'); + for (const s of auftrag.straenge) z.push(strangZeile(s, kontext, en)); + if (auftrag.antworten.length) { + z.push(''); + z.push(en ? "The author's answers to earlier questions:" : 'Antworten des Autors auf frühere Rückfragen:'); + for (const a of auftrag.antworten) z.push(en ? `- Question: ${a.frage} · Answer: ${a.antwort}` : `- Frage: ${a.frage} · Antwort: ${a.antwort}`); + } + z.push(''); + if (en) { + z.push('Read the whole book and organise the written chapters into plot lines.'); + z.push('1. zuordnungen: written chapters that take up a plot line already set up, but are not yet listed with it in the plan. For each: strang (id), kapitel (id), zitat (verbatim from this chapter), begruendung (one sentence: what in the chapter belongs to the plot line).'); + z.push('2. neueStraenge: plot lines the book clearly carries that are not set up yet and do not overlap with an existing one. For each: name (two to four words), rolle (haupt = carries the central plot, neben = subplot), zweck (one factual sentence: what the plot line is about), kapitel: every written chapter that takes it up, each with one verbatim zitat from that chapter.'); + z.push(`3. rueckfragen: at most ${STRAENGE_MAX_FRAGEN} questions to the author where you are unsure – e.g. whether a thread is a plot line of its own or part of another, which plot line is the main plot, what an existing plot line without a purpose stands for. For each: frage (one sentence), strang (id of an existing plot line concerned, otherwise empty), belege (zero to two verbatim quotes). Do not repeat questions the author has already answered.`); + z.push('Never suggest anything that is already in the plan. Only chapters with text. If there is nothing to suggest, return empty lists.'); + } else { + z.push('Lies das ganze Buch und ordne die geschriebenen Kapitel Erzählsträngen zu.'); + z.push('1. zuordnungen: geschriebene Kapitel, die einen angelegten Strang aufgreifen, im Plan aber noch nicht bei ihm stehen. Je Eintrag: strang (id), kapitel (id), zitat (wörtlich aus diesem Kapitel), begruendung (ein Satz: was im Kapitel zum Strang gehört).'); + z.push('2. neueStraenge: Erzählstränge, die das Buch erkennbar trägt, die aber noch nicht angelegt sind und sich nicht mit einem angelegten überschneiden. Je Eintrag: name (zwei bis vier Wörter), rolle (haupt = trägt die zentrale Handlung, neben = Nebenhandlung), zweck (ein sachlicher Satz: worum es in dem Strang geht), kapitel: jedes geschriebene Kapitel, das ihn aufgreift, mit je einem wörtlichen zitat aus diesem Kapitel.'); + z.push(`3. rueckfragen: höchstens ${STRAENGE_MAX_FRAGEN} Fragen an den Autor, wo du unsicher bist – z. B. ob ein Faden ein eigener Strang ist oder zu einem anderen gehört, welcher Strang die Haupthandlung ist, wofür ein angelegter Strang ohne Zweck steht. Je Eintrag: frage (ein Satz), strang (id eines betroffenen angelegten Strangs, sonst leer), belege (null bis zwei wörtliche Zitate). Wiederhole keine Fragen, die der Autor schon beantwortet hat.`); + z.push('Schlage nie vor, was schon im Plan steht. Nur Kapitel mit Text. Gibt es nichts vorzuschlagen, gib leere Listen zurück.'); + } + return z.join('\n'); +} + +const obj = (properties: Record) => ({ + type: 'object', + additionalProperties: false, + properties, + required: Object.keys(properties), +}); + +/** Antwortschema – ohne Feld für Ersatztext (harte Regel 2). */ +export function straengeSchema(kontext: BuchKontext, auftrag: StrangAuftrag): Record { + const mitText = kontext.kapitel.filter((k) => k.text.trim()).map((k) => k.id); + const kapitel = { type: 'string', enum: mitText }; + const ids = auftrag.straenge.map((s) => s.id); + const strang = ids.length ? { type: 'string', enum: ids } : { type: 'string' }; + return obj({ + zuordnungen: { type: 'array', items: obj({ strang, kapitel, zitat: { type: 'string' }, begruendung: { type: 'string' } }) }, + neueStraenge: { + type: 'array', + items: obj({ + name: { type: 'string' }, + rolle: { type: 'string', enum: ['haupt', 'neben'] }, + zweck: { type: 'string' }, + kapitel: { type: 'array', items: obj({ kapitel, zitat: { type: 'string' } }) }, + }), + }, + rueckfragen: { + type: 'array', + items: obj({ + frage: { type: 'string' }, + strang: { type: 'string', enum: ['', ...ids] }, + belege: { type: 'array', items: obj({ kapitel, text: { type: 'string' } }) }, + }), + }, + }); +} + +/** Stabiler Schlüssel je Vorschlagsart (siehe StrangVorschlag). */ +export function strangVorschlagSchluessel(v: Pick): string { + if (v.art === 'zuordnung') return `zuordnung:${v.strang ?? ''}|${v.kapitel[0] ?? ''}`; + if (v.art === 'neu') return `strang:${loseEndeId(v.name ?? '')}`; + return `frage:${loseEndeId(v.frage ?? '').slice(0, 80)}`; +} + +/** Antwort auswerten (auch einzeln testbar). */ +export function werteStraengeAus(daten: unknown, p: AufgabenPruefung, auftrag: StrangAuftrag): StrangErgebnis { + const m = meldungenIn(p.oberflaeche); + const ergebnis: StrangErgebnis = { vorschlaege: [] }; + const d = (daten ?? {}) as Record; + if (!d || typeof d !== 'object' || !Array.isArray(d.zuordnungen) || !Array.isArray(d.neueStraenge) || !Array.isArray(d.rueckfragen)) { + p.verwerfen(m('schemaFalsch'), daten); + return ergebnis; + } + const straenge = new Map(auftrag.straenge.map((s) => [s.id, s])); + const namen = new Set(auftrag.straenge.map((s) => loseEndeId(s.name))); + const reihe = new Map(p.kontext.kapitel.map((k, i) => [k.id, i])); + const gesehen = new Set(); + const dazu = (v: StrangVorschlag) => { + if (gesehen.has(v.schluessel)) return; + gesehen.add(v.schluessel); + ergebnis.vorschlaege.push(v); + }; + /** ein Zitat aus genau diesem Kapitel; null = verworfen (Grund vermerkt) */ + const beleg = (kapitel: unknown, text: unknown, roh: unknown): { kapitel: Id; zitat: Zitat } | null => { + const k = p.kapitel(kapitel); + if (!k || !k.text.trim()) { + p.verwerfen(m('kapitelUnbekannt', { kapitel: String(kapitel ?? '') }), roh); + return null; + } + const zitate = p.zitate([{ kapitel: k.id, text }], roh, { kapitel: new Set([k.id]) }); + return zitate ? { kapitel: k.id, zitat: zitate[0]! } : null; + }; + + for (const roh of d.zuordnungen) { + const z = (roh ?? {}) as Record; + const s = typeof z.strang === 'string' ? straenge.get(z.strang) : undefined; + if (!s) { + p.verwerfen(m('strangUnbekannt', { strang: String(z.strang ?? '') }), roh); + continue; + } + const b = beleg(z.kapitel, z.zitat, roh); + if (!b) continue; + if (s.kapitel.includes(b.kapitel)) { + p.verwerfen(m('schonImPlan', { nr: p.kapitel(b.kapitel)?.nr ?? '?', name: s.name }), roh); + continue; + } + const begruendung = p.text(z.begruendung, roh); + if (begruendung === null) continue; + const v: Omit = { art: 'zuordnung', strang: s.id, kapitel: [b.kapitel], begruendung, belege: [b.zitat], status: 'neu' }; + dazu({ schluessel: strangVorschlagSchluessel(v), ...v }); + } + + for (const roh of d.neueStraenge) { + const n = (roh ?? {}) as Record; + const name = p.text(n.name, roh, { pflicht: true }); + if (name === null) continue; + if (namen.has(loseEndeId(name))) { + p.verwerfen(m('strangGibtEs', { name }), roh); + continue; + } + const zweck = p.text(n.zweck, roh); + if (zweck === null) continue; + const kapitel: Id[] = []; + const belege: Zitat[] = []; + for (const kRoh of Array.isArray(n.kapitel) ? n.kapitel : []) { + const kk = (kRoh ?? {}) as Record; + const b = beleg(kk.kapitel, kk.zitat, roh); + if (!b || kapitel.includes(b.kapitel)) continue; + kapitel.push(b.kapitel); + belege.push(b.zitat); + } + if (kapitel.length === 0) { + p.verwerfen(m('ohneKapitel'), roh); + continue; + } + const folge = kapitel.map((k, i) => ({ k, z: belege[i]! })).sort((a, b) => (reihe.get(a.k) ?? 0) - (reihe.get(b.k) ?? 0)); + const v: Omit = { + art: 'neu', + name, + ...(n.rolle === 'haupt' || n.rolle === 'neben' ? { rolle: n.rolle } : {}), + ...(zweck ? { beschreibung: zweck } : {}), + kapitel: folge.map((x) => x.k), + begruendung: '', + belege: folge.map((x) => x.z), + status: 'neu', + }; + dazu({ schluessel: strangVorschlagSchluessel(v), ...v }); + } + + let fragen = 0; + for (const roh of d.rueckfragen) { + if (fragen >= STRAENGE_MAX_FRAGEN) break; + const f = (roh ?? {}) as Record; + const frage = p.text(f.frage, roh, { pflicht: true }); + if (frage === null) continue; + const belege = Array.isArray(f.belege) && f.belege.length ? p.zitate(f.belege, roh, { mindestens: 0 }) : []; + if (belege === null) continue; + const strang = typeof f.strang === 'string' && straenge.has(f.strang) ? f.strang : undefined; + const v: Omit = { + art: 'frage', + ...(strang ? { strang } : {}), + frage, + kapitel: [...new Set(belege.map((b) => b.kapitel))], + begruendung: '', + belege, + status: 'neu', + }; + dazu({ schluessel: strangVorschlagSchluessel(v), ...v }); + fragen++; + } + + const pos = (v: StrangVorschlag) => (v.kapitel[0] ? (reihe.get(v.kapitel[0]) ?? 0) : Number.MAX_SAFE_INTEGER); + const artReihe = { neu: 0, zuordnung: 1, frage: 2 } as const; + ergebnis.vorschlaege.sort((a, b) => artReihe[a.art] - artReihe[b.art] || pos(a) - pos(b)); + return ergebnis; +} + +/** Aufgabendefinition „Stränge vorschlagen“. */ +export function straengeDefinition(auftrag: StrangAuftrag): AufgabenDefinition { + return { + name: meldungenIn('de')('nameStraenge'), + nameEn: meldungenIn('en')('nameStraenge'), + art: STRAENGE_ART, + rolle: 'pruefung', + system: SYSTEM_STRAENGE, + systemEn: SYSTEM_STRAENGE_EN, + ausgabeSchaetzung: 4000, + aufgabe: (kontext) => aufgabenText(kontext, auftrag), + schema: (kontext) => straengeSchema(kontext, auftrag), + auswerten: (daten, p) => werteStraengeAus(daten, p, auftrag), + }; +} + +/** Schlägt Stränge für das ganze Buch vor (eine Anfrage). Schreibt nichts. */ +export async function schlageStraengeVor( + leser: ProjektLeser, + transport: KiTransport, + einstellungen: KiEinstellungen | undefined, + optionen: AufgabenOptionen = {}, +): Promise> { + const auftrag = strangAuftrag(await ladeProjekt(leser)); + return fuehreAufgabeAus(leser, transport, einstellungen, straengeDefinition(auftrag), optionen); +} + +/** Schätzung vor dem Start (Tokens, Kosten). */ +export async function schaetzeStraenge( + leser: ProjektLeser, + transport: KiTransport, + einstellungen: KiEinstellungen | undefined, + oberflaeche: Sprache = 'de', +): Promise { + const auftrag = strangAuftrag(await ladeProjekt(leser)); + return schaetzeAufgabe(leser, transport, einstellungen, straengeDefinition(auftrag), oberflaeche); +} + +/** + * Neue Vorschläge in die Ablage mischen: entschiedene (übernommen/verworfen) bleiben und werden nie erneut + * vorgeschlagen; offene Vorschläge früherer Läufe werden durch das neue Ergebnis ersetzt. + */ +export function mischeStrangVorschlaege( + ablage: KiAblage | undefined, + neu: StrangVorschlag[], + lauf?: KiAblage['lauf'], +): { ablage: KiAblage; neu: number; bereitsEntschieden: number } { + const a = ablage ?? { version: 1 as const, eintraege: [] }; + const entschieden = a.eintraege.filter((v) => v.status !== 'neu'); + const schluessel = new Set(entschieden.map((v) => v.schluessel)); + const vorher = new Set(a.eintraege.map((v) => v.schluessel)); + const offen: StrangVorschlag[] = []; + let bereitsEntschieden = 0; + let zahl = 0; + for (const v of neu) { + if (schluessel.has(v.schluessel)) { + bereitsEntschieden++; + continue; + } + if (offen.some((x) => x.schluessel === v.schluessel)) continue; + offen.push({ ...v, status: 'neu' }); + if (!vorher.has(v.schluessel)) zahl++; + } + return { ablage: { ...a, version: 1, eintraege: [...entschieden, ...offen], ...(lauf ? { lauf } : {}) }, neu: zahl, bereitsEntschieden }; +} diff --git a/core/model/types.ts b/core/model/types.ts index 65bbf80..70be858 100644 --- a/core/model/types.ts +++ b/core/model/types.ts @@ -111,8 +111,10 @@ export interface KapitelPlan { /** Aktnummer; null = noch keinem Akt zugeordnet */ akt: number | null; status: KapitelStatus; - /** Titel der geplanten Szene, z. B. „Paul trifft Lotte am Lindenhof“ */ + /** Kurzbeschreibung in einer Zeile (Oberfläche „Kurzbeschreibung“, früher „Geplante Szene“), z. B. „Paul trifft Lotte am Lindenhof“ */ szene: string; + /** Funktion des Kapitels im Buch, z. B. „Opening“, „Wendepunkt“ (Vorschläge in der Oberfläche oder frei). Nicht an die KI. */ + funktion?: string; straenge: Id[]; figuren: Id[]; orte: Id[]; @@ -135,6 +137,9 @@ export interface Akt { antagonisten: Id[]; } +/** Haupthandlung oder Nebenhandlung */ +export type StrangRolle = 'haupt' | 'neben'; + /** plan/straenge.md – Frontmatter {straenge: Strang[]} */ export interface Strang { id: Id; @@ -143,6 +148,10 @@ export interface Strang { farbe: string; /** Wenn der Strang zu einer Gruppe (Antagonist) gehört */ gruppe?: Id; + /** Haupthandlung oder Nebenhandlung; fehlt = nicht festgelegt */ + rolle?: StrangRolle; + /** „Wofür steht der Strang?“ – Zweck des Erzählstrangs in Leons Worten */ + beschreibung?: string; } /** plan/ideen.md – Frontmatter {ideen: Idee[]} */ @@ -455,6 +464,8 @@ export interface ProjektDaten { kiLeser?: KiAblage; /** G6 .plotwerk/fragen.json */ kiFragen?: KiAblage; + /** G2 .plotwerk/strang-vorschlaege.json */ + kiStrangVorschlaege?: KiAblage; } // --------------------------------------------------------------------------- @@ -684,6 +695,37 @@ export interface FadenPlan { strang?: Id; } +/** + * KI-Vorschlag zu Erzählsträngen (.plotwerk/strang-vorschlaege.json). Leon übernimmt oder verwirft jeden Vorschlag; + * Verworfenes wird nie erneut vorgeschlagen (Abgleich über `schluessel`). + * - zuordnung: Kapitel `kapitel[0]` gehört zum angelegten Strang `strang` (Schlüssel „zuordnung:|“) + * - neu: ein noch nicht angelegter Strang mit Name, Rolle, Zweck und Kapiteln (Schlüssel „strang:“) + * - frage: Rückfrage an Leon (Schlüssel „frage:“); seine Antwort geht beim nächsten Lauf mit + */ +export interface StrangVorschlag { + schluessel: string; + art: 'zuordnung' | 'neu' | 'frage'; + /** zuordnung: ID des angelegten Strangs · frage: betroffener Strang, falls einer */ + strang?: Id; + /** neu: Name des vorgeschlagenen Strangs */ + name?: string; + rolle?: StrangRolle; + /** neu: Zweck in einem sachlichen Satz (wird bei der Übernahme zu Strang.beschreibung) */ + beschreibung?: string; + /** Kapitel in Buchreihenfolge (zuordnung: genau eins) */ + kapitel: Id[]; + /** frage: die Rückfrage */ + frage?: string; + /** frage: Leons Antwort (dann status 'uebernommen') */ + antwort?: string; + begruendung: string; + /** wörtliche Zitate (E6), mindestens eins je Kapitel */ + belege: Zitat[]; + status: VorschlagsStatus; + /** neu: nach der Übernahme die ID des angelegten Strangs */ + angelegt?: Id; +} + /** plan/faeden.md – Frontmatter {ruheSchwelle, faeden}. Status bleibt in entscheidungen.faeden. */ export interface Fadenplanung { /** Standard-Schwelle in Kapiteln (K-02: 3) */ diff --git a/core/projekt/extern.ts b/core/projekt/extern.ts index b17100e..d52987e 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.kiStraenge, 'kiStrangVorschlaege'], ]; const imOrdner = (pfad: string, ordner: string, endung: string) => diff --git a/core/projekt/lesen.ts b/core/projekt/lesen.ts index 3e3a7e3..09c3ab9 100644 --- a/core/projekt/lesen.ts +++ b/core/projekt/lesen.ts @@ -52,6 +52,7 @@ import type { RealitaetsEintrag, Spur, Strang, + StrangVorschlag, TextAnker, Verweis, } from '../model/types'; @@ -107,6 +108,8 @@ export const PFADE = { kiLeser: '.plotwerk/leser.json', /** G6 Fragen an das Buch (KiAblage) */ kiFragen: '.plotwerk/fragen.json', + /** G2 KI-Vorschläge zu Erzählsträngen (KiAblage) */ + kiStraenge: '.plotwerk/strang-vorschlaege.json', } as const; export const kapitelPfad = (id: Id): string => `${PFADE.manuskript}/${id}.md`; @@ -360,6 +363,7 @@ export function parseKapitelPlan(text: string, id?: Id): KapitelPlan { akt: akt === null || Number.isNaN(akt) ? null : akt, status: eines(daten.status, ['geplant', 'entwurf', 'geschrieben'] as const, 'geplant'), szene: s(daten.szene), + ...(sOpt(daten.funktion) ? { funktion: s(daten.funktion) } : {}), straenge: texte(daten.straenge), figuren: texte(daten.figuren), orte: texte(daten.orte), @@ -384,11 +388,22 @@ export function parseAkte(text: string): Akt[] { })); } +const STRANG_ROLLEN = ['haupt', 'neben'] as const; + export function parseStraenge(text: string): Strang[] { const { daten } = parseFrontmatter(text); return liste(daten.straenge) .filter(istObjekt) - .map((st) => ohneUndefined({ id: s(st.id), name: s(st.name), farbe: s(st.farbe), gruppe: sOpt(st.gruppe) })) + .map((st) => + ohneUndefined({ + id: s(st.id), + name: s(st.name), + farbe: s(st.farbe), + gruppe: sOpt(st.gruppe), + rolle: STRANG_ROLLEN.find((r) => r === st.rolle), + beschreibung: sOpt(st.beschreibung), + }), + ) .filter((st) => st.id !== ''); } @@ -1029,6 +1044,30 @@ export function parseKiStimmen(text: string): KiAblage { }); } +const STRANG_VORSCHLAG_ARTEN = ['zuordnung', 'neu', 'frage'] as const; + +export function parseKiStrangVorschlaege(text: string): KiAblage { + return parseKiAblage(text, (v) => { + const art = STRANG_VORSCHLAG_ARTEN.find((a) => a === v.art); + if (!s(v.schluessel) || !art) return null; + return ohneUndefined({ + schluessel: s(v.schluessel), + art, + strang: sOpt(v.strang), + name: sOpt(v.name), + rolle: STRANG_ROLLEN.find((r) => r === v.rolle), + beschreibung: sOpt(v.beschreibung), + kapitel: texte(v.kapitel), + frage: sOpt(v.frage), + antwort: sOpt(v.antwort), + begruendung: s(v.begruendung), + belege: parseZitate(v.belege), + status: eines(v.status, VORSCHLAGS_STATI, 'neu'), + angelegt: sOpt(v.angelegt), + }); + }); +} + export function parseKiLeser(text: string): KiAblage { return parseKiAblage(text, (v) => { if (!s(v.kapitel)) return null; @@ -1177,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, kiStrangVorschlaege] = await Promise.all([ lies(PFADE.kiZeit, parseKiZeitVorschlaege), lies(PFADE.kiZustand, parseKiZustandVorschlaege), lies(PFADE.kiStimmen, parseKiStimmen), lies(PFADE.kiLeser, parseKiLeser), lies(PFADE.kiFragen, parseKiFragen), + lies(PFADE.kiStraenge, parseKiStrangVorschlaege), ]); const projekt: Projekt = projektRoh ?? { version: 1, titel: '', kapitel: [], notiz: '' }; @@ -1237,6 +1277,7 @@ export async function ladeProjektMitBericht(leser: ProjektLeser, sprache: LadeSp ...(kiStimmen ? { kiStimmen } : {}), ...(kiLeser ? { kiLeser } : {}), ...(kiFragen ? { kiFragen } : {}), + ...(kiStrangVorschlaege ? { kiStrangVorschlaege } : {}), }, warnungen, }; diff --git a/core/projekt/schreiben.ts b/core/projekt/schreiben.ts index 49713ec..68d4171 100644 --- a/core/projekt/schreiben.ts +++ b/core/projekt/schreiben.ts @@ -38,6 +38,7 @@ import type { Ort, Projekt, Strang, + StrangVorschlag, } from '../model/types'; import { versionsPfadMitZusatz } from '../versionen/index'; import type { ProjektDateisystem } from './fs'; @@ -65,14 +66,14 @@ const S = { deployments: ['erkennung', 'pruefung', 'tiefencheck'], claude: ['endpoint', 'modelle'], kapitel: ['id', 'titel'], - plan: ['id', 'titel', 'akt', 'status', 'szene', 'straenge', 'figuren', 'orte', 'abweichungen', 'realitaet', 'zeit', 'szenen'], + plan: ['id', 'titel', 'akt', 'status', 'szene', 'funktion', 'straenge', 'figuren', 'orte', 'abweichungen', 'realitaet', 'zeit', 'szenen'], zeitpunkt: ['tag', 'wochentag', 'datum', 'tageszeit', 'uhrzeit', 'dauer', 'sicherheit', 'beleg', 'notiz'], szene: ['id', 'index', 'titel', 'ort', 'figuren', 'zeit', 'notiz'], abweichung: ['id', 'notiz', 'entscheidung', 'datum', 'alterPlan'], realitaet: ['id', 'text', 'stufe', 'anker'], anker: ['kapitel', 'exakt', 'davor', 'danach'], akt: ['nummer', 'titel', 'quintessenz', 'wendepunkt', 'antagonisten'], - strang: ['id', 'name', 'farbe', 'gruppe'], + strang: ['id', 'name', 'farbe', 'gruppe', 'rolle', 'beschreibung'], idee: ['id', 'titel', 'notiz', 'straenge'], motiv: ['id', 'name', 'aliase'], figur: ['id', 'name', 'aliase', 'formen', 'rolle', 'kurz', 'existenz', 'gruppe', 'beziehungen', 'plan', 'netz'], @@ -377,6 +378,7 @@ const SW = { leserAussage: ['text', 'belege'], leserFrage: ['id', 'frage', 'belege', 'beantwortetIn'], buchFrage: ['id', 'frage', 'datum', 'antwort', 'belege', 'imBuch', 'modell', 'kostenUsd'], + strangVorschlag: ['schluessel', 'art', 'strang', 'name', 'rolle', 'beschreibung', 'kapitel', 'frage', 'antwort', 'begruendung', 'belege', 'status', 'angelegt'], } as const; const zitate = (z: readonly object[]) => z.map((x) => ordne(x, S.zitat)); @@ -485,6 +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 serializeKiStrangVorschlaege = (a: KiAblage) => + serializeKiAblage(a, (v) => ordne({ ...v, kapitel: [...v.kapitel], belege: zitate(v.belege) }, SW.strangVorschlag)); + export const serializeKiLeser = (a: KiAblage) => serializeKiAblage(a, (v) => ordne( @@ -617,6 +622,9 @@ export const speichereKiLeser = (fs: ProjektDateisystem, a: KiAblage export const speichereKiFragen = (fs: ProjektDateisystem, a: KiAblage) => schreibeWennGeaendert(fs, PFADE.kiFragen, serializeKiFragen(a), true); +export const speichereKiStrangVorschlaege = (fs: ProjektDateisystem, a: KiAblage) => + schreibeWennGeaendert(fs, PFADE.kiStraenge, serializeKiStrangVorschlaege(a), true); + // --------------------------------------------------------------------------- // Schnappschüsse und Entfernen // --------------------------------------------------------------------------- diff --git a/core/struktur/straenge.ts b/core/struktur/straenge.ts new file mode 100644 index 0000000..ac0de7a --- /dev/null +++ b/core/struktur/straenge.ts @@ -0,0 +1,183 @@ +/** + * Erzählstränge (G2 Plot-Struktur): anlegen, Haupt-/Nebenhandlung, KI-Vorschläge übernehmen oder verwerfen. + * Besitzer: G2. Rein, ohne DOM und ohne Speichern – der Store schreibt plan/straenge.md, die Kapitelpläne und + * .plotwerk/strang-vorschlaege.json. + */ +import type { Fadenplanung, Id, KapitelPlan, KiAblage, ProjektDaten, Strang, StrangVorschlag } from '../model/types'; +import { leereFadenplanung, standardPlan } from '../projekt/lesen'; +import { fadenPlanAendern, fadenSlug, kapitelMitText, type FadenKarte } from './faeden'; + +/** Farben für neue Stränge (Hex wie in plan/straenge.md; die Anzeige läuft über Tokens). */ +export const STRANG_FARBEN = [ + { hex: '#3D5A75', name: 'blau' }, + { hex: '#C2405C', name: 'rosa' }, + { hex: '#A86A12', name: 'ocker' }, + { hex: '#6A4C9C', name: 'violett' }, + { hex: '#2F6B45', name: 'gruen' }, + { hex: '#4A4A4F', name: 'grau' }, +] as const; + +/** Erste noch nicht benutzte Farbe, sonst reihum. */ +export function freieStrangFarbe(straenge: Strang[]): string { + const frei = STRANG_FARBEN.find((f) => !straenge.some((s) => s.farbe.toLowerCase() === f.hex.toLowerCase())); + return (frei ?? STRANG_FARBEN[straenge.length % STRANG_FARBEN.length]!).hex; +} + +/** Eindeutige Strang-ID aus dem Namen (Slug, bei Bedarf mit Zähler). */ +export function neueStrangId(straenge: Strang[], name: string): Id { + const belegt = new Set(straenge.map((s) => s.id)); + const basis = fadenSlug(name); + let id = basis; + for (let i = 2; belegt.has(id); i++) id = `${basis}-${i}`; + return id; +} + +/** Haupthandlung zuerst, dann nicht festgelegte, dann Nebenhandlungen; sonst Reihenfolge der Datei. */ +export function straengeSortiert(straenge: Strang[]): Strang[] { + const rang = (s: Strang) => (s.rolle === 'haupt' ? 0 : s.rolle === 'neben' ? 2 : 1); + return straenge.map((s, i) => ({ s, i })).sort((a, b) => rang(a.s) - rang(b.s) || a.i - b.i).map((x) => x.s); +} + +/** Kapitelplan oder Standardplan (Kapitel ohne Plan-Datei). */ +function planVon(daten: ProjektDaten, kapitel: Id): KapitelPlan | null { + const p = daten.plaene[kapitel]; + if (p) return p; + const k = daten.kapitel[kapitel]; + return k ? standardPlan(k) : null; +} + +/** + * Noch offene Vorschläge, die zum aktuellen Stand passen: Zuordnungen zu gelöschten Strängen oder schon im Plan + * eingetragene fallen weg, ebenso neue Stränge, die es inzwischen unter diesem Namen gibt. + * `kapitel`: nur Vorschläge, die dieses Kapitel betreffen. + */ +export function offeneStrangVorschlaege(daten: ProjektDaten, kapitel?: Id): StrangVorschlag[] { + const namen = new Set(daten.straenge.map((s) => fadenSlug(s.name))); + const ids = new Set(daten.straenge.map((s) => s.id)); + return (daten.kiStrangVorschlaege?.eintraege ?? []).filter((v) => { + if (v.status !== 'neu') return false; + if (kapitel && !v.kapitel.includes(kapitel)) return false; + if (v.art === 'zuordnung') { + const k = v.kapitel[0]; + return !!v.strang && ids.has(v.strang) && !!k && !!daten.kapitel[k] && !daten.plaene[k]?.straenge.includes(v.strang); + } + if (v.art === 'neu') return !!v.name && !namen.has(fadenSlug(v.name)); + return true; + }); +} + +function mitStatus(ablage: KiAblage, schluessel: string, teil: Partial): KiAblage { + return { ...ablage, eintraege: ablage.eintraege.map((v) => (v.schluessel === schluessel ? { ...v, ...teil } : v)) }; +} + +export interface StrangUebernahme { + /** neue Strangliste (nur bei „neu“ geändert) */ + straenge: Strang[]; + /** geänderte Kapitelpläne */ + plaene: KapitelPlan[]; + ablage: KiAblage; + /** angelegter oder zugeordneter Strang */ + strang: Id | null; +} + +/** + * Vorschlag übernehmen. Zuordnung: Strang in den Kapitelplan. Neuer Strang: anlegen (Name, Rolle, Zweck, freie + * Farbe) und in die Pläne der gewählten Kapitel (`kapitel`, Standard: alle vorgeschlagenen) eintragen. + * Rückfragen werden über `strangFrageBeantworten` erledigt. null = Vorschlag fehlt oder passt nicht mehr. + */ +export function strangVorschlagUebernehmen(daten: ProjektDaten, schluessel: string, kapitel?: Id[]): StrangUebernahme | null { + const ablage = daten.kiStrangVorschlaege; + const v = ablage?.eintraege.find((x) => x.schluessel === schluessel); + if (!ablage || !v || v.status !== 'neu' || v.art === 'frage') return null; + const eintragen = (strang: Id, ziele: Id[]): KapitelPlan[] => + ziele + .map((k) => planVon(daten, k)) + .filter((p): p is KapitelPlan => !!p && !p.straenge.includes(strang)) + .map((p) => ({ ...p, straenge: [...p.straenge, strang] })); + + if (v.art === 'zuordnung') { + if (!v.strang || !daten.straenge.some((s) => s.id === v.strang)) return null; + return { + straenge: daten.straenge, + plaene: eintragen(v.strang, v.kapitel.slice(0, 1)), + ablage: mitStatus(ablage, schluessel, { status: 'uebernommen' }), + strang: v.strang, + }; + } + + const name = v.name?.trim(); + if (!name) return null; + const id = neueStrangId(daten.straenge, name); + const strang: Strang = { + id, + name, + farbe: freieStrangFarbe(daten.straenge), + ...(v.rolle ? { rolle: v.rolle } : {}), + ...(v.beschreibung ? { beschreibung: v.beschreibung } : {}), + }; + const ziele = (kapitel ?? v.kapitel).filter((k) => v.kapitel.includes(k)); + return { + straenge: [...daten.straenge, strang], + plaene: eintragen(id, ziele), + ablage: mitStatus(ablage, schluessel, { status: 'uebernommen', angelegt: id }), + strang: id, + }; +} + +/** Vorschlag verwerfen – wird nie wieder vorgeschlagen. */ +export function strangVorschlagVerwerfen(ablage: KiAblage, schluessel: string): KiAblage { + return mitStatus(ablage, schluessel, { status: 'verworfen' }); +} + +/** Rückfrage beantworten; die Antwort geht beim nächsten Lauf mit. Leere Antwort = zurück auf offen. */ +export function strangFrageBeantworten(ablage: KiAblage, schluessel: string, antwort: string): KiAblage { + const a = antwort.trim(); + return mitStatus(ablage, schluessel, a ? { status: 'uebernommen', antwort: a } : { status: 'neu', antwort: undefined }); +} + +// --------------------------------------------------------------------------- +// Faden → Strang: ein von der Prüfung gefundenes loses Ende (z. B. „Der Deloitte-Auftrag“) als Strang übernehmen +// --------------------------------------------------------------------------- + +/** Kapitel mit Text, in denen der Faden vorkommt (eröffnet oder aufgegriffen), in Buchreihenfolge. */ +export function fadenKapitel(daten: Pick, karte: Pick): Id[] { + const mitText = kapitelMitText(daten); + const drin = new Set([karte.eroeffnetIn, ...karte.aufgegriffenIn].filter((k): k is Id => !!k)); + return daten.projekt.kapitel.filter((k) => drin.has(k) && mitText.has(k)); +} + +/** Fäden ohne Strang, die als Strang taugen (kommen in mindestens einem Kapitel mit Text vor); optional nur die eines Kapitels. */ +export function faedenOhneStrang(daten: ProjektDaten, karten: FadenKarte[], kapitel?: Id): FadenKarte[] { + const ids = new Set(daten.straenge.map((s) => s.id)); + return karten.filter((k) => { + if (k.strang && ids.has(k.strang)) return false; + const kap = fadenKapitel(daten, k); + return kap.length > 0 && (!kapitel || kap.includes(kapitel)); + }); +} + +export interface FadenAlsStrang { + straenge: Strang[]; + plaene: KapitelPlan[]; + fadenplanung: Fadenplanung; + strang: Id; +} + +/** + * Legt aus einem Faden einen Strang an (Name des Fadens, freie Farbe), trägt ihn in die Pläne aller Kapitel ein, in + * denen der Faden vorkommt, und verbindet den Faden mit dem Strang (plan/faeden.md, Feld `strang`). + */ +export function strangAusFaden(daten: ProjektDaten, karte: FadenKarte): FadenAlsStrang { + const id = neueStrangId(daten.straenge, karte.name); + const strang: Strang = { id, name: karte.name, farbe: freieStrangFarbe(daten.straenge), ...(karte.notiz?.trim() ? { beschreibung: karte.notiz.trim() } : {}) }; + const plaene = fadenKapitel(daten, karte) + .map((k) => planVon(daten, k)) + .filter((p): p is KapitelPlan => !!p) + .map((p) => ({ ...p, straenge: [...p.straenge, id] })); + return { + straenge: [...daten.straenge, strang], + plaene, + fadenplanung: fadenPlanAendern(daten.fadenplanung ?? leereFadenplanung(), karte.id, { strang: id }), + strang: id, + }; +} diff --git a/core/struktur/struktur.en.test.ts b/core/struktur/struktur.en.test.ts index ca7e29e..6356c3f 100644 --- a/core/struktur/struktur.en.test.ts +++ b/core/struktur/struktur.en.test.ts @@ -80,7 +80,7 @@ describe('weitere lokale Hinweise (en)', () => { it('Hinweis-Typnamen', () => { expect(hinweisTypName('schreibweise', 'en')).toBe('Spelling'); - expect(hinweisTypName('offener_faden')).toBe('Offener Faden'); + expect(hinweisTypName('offener_faden')).toBe('Loses Ende'); expect(Object.keys(hinweisTypNamen('en'))).toEqual(Object.keys(HINWEIS_TYP_NAME)); }); }); diff --git a/docs/abnahme.md b/docs/abnahme.md index 8b878c9..08ad951 100644 --- a/docs/abnahme.md +++ b/docs/abnahme.md @@ -145,6 +145,9 @@ Browser-Durchlauf mit dem Demo-Projekt bei 1440 × 900 und 1100 × 700, hell und | PS-01 | Fäden-Board | erfüllt | `src/features/faeden/faeden.test.tsx`, `core/struktur/faeden.test.ts`; Browser | Befehl „Neuer Faden …“ in Menü und Schnellsuche. | | PS-02 | Strang-Raster | erfüllt | `faeden.test.tsx`; Browser | | | PS-03 | Szenen | erfüllt | `core/struktur/szenen.test.ts`, `faeden.test.tsx`; Browser | Szenenkarte auch für Kapitel ohne Plan; Link springt zur Szene in der Zeitleiste. | +| PS-04 | Haupt-/Nebenhandlung, Zweck, Kurzbeschreibung, Funktion | erfüllt | `core/ki/straenge.test.ts`, `src/features/faeden/straenge.test.tsx`; Browser | „Geplante Szene“ heißt jetzt „Kurzbeschreibung“ (Feld `szene` unverändert). | +| PS-05 | KI-Vorschläge zu Strängen | erfüllt | `core/ki/straenge.test.ts`, `src/features/faeden/straenge.test.tsx`; Browser | Nur Demo-KI; Strang-Angaben an die KI von Leon am 2026-10-12 freigegeben (E12). | +| PS-06 | Lose Enden: Tabelle, als Strang übernehmen | erfüllt | `src/features/faeden/straenge.test.tsx`; Browser | Interne Namen (`faeden`, `plan/faeden.md`, Befehl `faden-neu`) bleiben. | | FB-01 | Figuren-Bögen | erfüllt | `core/figuren/boegen.test.ts`, `src/features/figuren/Boegen.test.tsx`; Browser | | | FB-02 | Figurenstimme | erfüllt | `core/figuren/stimme.test.ts`, `core/ki/stimme.test.ts`, `Stimme.test.tsx`, `core/index/index.test.ts` | Erscheint als Hinweis „Sprache“ und auf der Figurenkarte. | | EW-01 | Typewriter | erfüllt | `src/features/schreiben/werkzeuge/werkzeuge.test.tsx`; Browser | | diff --git a/docs/anforderungen-v1.1.md b/docs/anforderungen-v1.1.md index 3de55ce..69bea01 100644 --- a/docs/anforderungen-v1.1.md +++ b/docs/anforderungen-v1.1.md @@ -232,6 +232,9 @@ Seit 10. Oktober 2026. Alles, was Leon festlegt, liegt als Markdown oder JSON im | PS-01 | Fäden | Board mit Spalten Offen, Auflösung geplant, Erledigt, Bewusst offen; Warnung bei ruhenden Fäden und überschrittenem Auflösungskapitel; eigene Fäden anlegen | Soll | | PS-02 | Akte planen | Strang-Raster (Stränge × Kapitel) mit Lücken; Klick nimmt einen Strang in den Kapitelplan auf | Soll | | PS-03 | Szenen | Szenen über die Trenner `* * *`: Plan je Szene (Titel, Ort, Figuren, Zeit, Notiz) im Schreiben-Inspektor und im Kapitel-Überblick; der Kapiteltext wird nie verändert | Soll | +| PS-04 | Akte planen | Stränge als Haupt- oder Nebenhandlung markieren und beschreiben („Wofür steht der Strang?“); Kapitelplan mit Kurzbeschreibung und optionaler Funktion im Buch | Soll | +| PS-05 | Akte planen | KI schlägt Kapitel-Zuordnungen zu Strängen, neue Stränge (mit Rolle und Zweck) und Rückfragen vor, jeweils mit wörtlichem Beleg; Leon übernimmt, verwirft oder antwortet, Verworfenes kommt nicht wieder | Kann | +| PS-06 | Lose Enden | Bereich „Fäden“ heißt „Lose Enden“; Tabelle lose Enden × Kapitel, gruppiert nach Strang; ein loses Ende lässt sich als Strang übernehmen (mit seinen Kapiteln) | Soll | | FB-01 | Figuren | Figuren-Bögen: frei benannte Stufen je Kapitel oder Akt mit wörtlichen Belegen, Auftritte als Linie, Vergleich mehrerer Figuren | Soll | | FB-02 | Figuren | Figurenstimme: KI vergleicht die Rede einer Figur mit ihren typischen Stellen; Ergebnis mit auffälliger und typischer Stelle, beide wörtlich; erscheint auch als Hinweis „Sprache“ | Kann | | EW-01 | Editor | Typewriter-Modus: Cursorzeile mittig, übrige Absätze abgedunkelt | Kann | diff --git a/docs/architektur.md b/docs/architektur.md index 20e79e4..49bbfe1 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -19,7 +19,7 @@ Design: `docs/design/screens/*.dc.html` (verbindlich, Pixelwerte und Farben exak | E9 | **Speichern** atomar (temp + fsync + rename), Debounce 1,5 s, bei Dauertippen spätestens 1,8 s nach der ersten ungespeicherten Eingabe (0,2 s Reserve fürs Schreiben): jede Eingabe liegt nach höchstens 2 s auf der Platte. | Absturzsicherheit | | E10 | **Struktur-Hinweise lokal** (leeres Kapitel, Satzabbruch am Kapitelende, leere Überschrift beim Import, doppelte Leerzeilen …), ohne KI. | schon in Phase 1 | | E11 | **KI-Aufruf im Rust-Kern** (`ki_anfrage`), Schlüssel nur im macOS-Schlüsselbund (je Anbieter ein Eintrag), nie in der Web-Oberfläche. Prompt-Aufbau und Antwortprüfung in TypeScript (`core/ki`), die nur `ProjektLeser` und `KiTransport` kennt. | Schlüssel und Schreibschutz | -| E12 | **Allowlist** für alles, was an das Modell geht: Kapiteltext, Kapiteltitel, Figuren-/Ortsnamen + Aliase, Gewissheit von Figuren und Stellen (`existenz`/`realitaet`), Gruppen-Namen, **Plan-Felder von Figuren (`plan`) und Kapitelplänen (Szene, „Was soll hier passieren?“)** – freigegeben von Leon am 2026-10-09 für F-05. Nie: `wahrheit`, `notizen`, `kurz`, Ortsbeschreibungen, Abweichungen, Akte, Stränge, Anmerkungen, Ideen. Werkzeuge (2026-10-10): Die **Namen der Zustandsarten** (`plan/zeitleiste.md`, z. B. „nüchtern“, „betrunken“) gehen als Antwortvokabular der Zustands-Aufgabe (G1) mit – nur die Namen, keine eingetragenen Zustände, Zeitpunkte oder Notizen; von Leon am 2026-10-11 bestätigt. | private Felder können nicht durchrutschen; Plan ↔ Text-Abgleich (F-05) | +| E12 | **Allowlist** für alles, was an das Modell geht: Kapiteltext, Kapiteltitel, Figuren-/Ortsnamen + Aliase, Gewissheit von Figuren und Stellen (`existenz`/`realitaet`), Gruppen-Namen, **Plan-Felder von Figuren (`plan`) und Kapitelplänen (Szene, „Was soll hier passieren?“)** – freigegeben von Leon am 2026-10-09 für F-05. Nie: `wahrheit`, `notizen`, `kurz`, Ortsbeschreibungen, Abweichungen, Akte, Stränge, Anmerkungen, Ideen. Werkzeuge (2026-10-10): Die **Namen der Zustandsarten** (`plan/zeitleiste.md`, z. B. „nüchtern“, „betrunken“) gehen als Antwortvokabular der Zustands-Aufgabe (G1) mit – nur die Namen, keine eingetragenen Zustände, Zeitpunkte oder Notizen; von Leon am 2026-10-11 bestätigt. **Stränge (2026-10-12):** Für die Aufgabe „Stränge vorschlagen“ (G2) gehen Name, Haupt-/Nebenhandlung (`rolle`), „Wofür steht der Strang?“ (`beschreibung`) und die Plan-Kapitel jedes Strangs sowie Leons Antworten auf frühere Rückfragen mit – nur im Aufgabentext, nicht im gecachten Prefix; von Leon am 2026-10-12 freigegeben. Die `funktion` eines Kapitels geht nicht an die KI. | private Felder können nicht durchrutschen; Plan ↔ Text-Abgleich (F-05) | | E13 | **Ganzes Buch als gecachter Prefix** (Prompt Caching) + Aufgaben pro Kapitel; Ergebnis-Cache pro Kapitel-Text-Hash. Token-Schätzung über `count_tokens`. | einfacher, findet kapitelübergreifende Widersprüche | | E14 | **Zwei KI-Anbieter, Wahl in den Einstellungen** (`projekt.ki.anbieter`, fehlt = Foundry): **Microsoft Foundry** (Endpoint `https://.services.ai.azure.com/anthropic`, eingetragen werden Deployment-Namen; Schlüsselbund-Konto `azure-foundry`) oder **Claude API** von Anthropic (Basis-URL `https://api.anthropic.com`, überschreibbar; Modell-IDs; Konto `anthropic-api`). Die Einstellungen des anderen Anbieters bleiben beim Umschalten erhalten. Modelle: `claude-haiku-5-5` (Erkennung), `claude-sonnet-5-5` (Prüfung), `claude-opus-5-5` (Tiefencheck). Nur Claude API: bei Sonnet/Opus 5.5 serverseitiger Fallback `fallbacks: "default"` (Beta `server-side-fallback-2026-07-01`), gesetzt in der Transportschicht. Hosting/Region egal (Leon akzeptiert Verarbeitung in den USA). | Leons Vorgabe | @@ -39,10 +39,10 @@ Design: `docs/design/screens/*.dc.html` (verbindlich, Pixelwerte und Farben exak ├── projekt.md Frontmatter: version, titel, kapitel[], ki{endpoint, deployments, anbieter, claude{endpoint, modelle}} ├── manuskript/.md Frontmatter: id, titel · Body: Markdown ├── plan/akte.md Frontmatter: akte[] -├── plan/straenge.md Frontmatter: straenge[] +├── plan/straenge.md Frontmatter: straenge[] (id, name, farbe, gruppe?, rolle? haupt|neben, beschreibung?) ├── plan/ideen.md Frontmatter: ideen[] ├── plan/motive.md Frontmatter: motive[] (id, name, aliase) – Motive im Tab „Erkannt“ (S-10) -├── plan/kapitel/.md Frontmatter: KapitelPlan ohne notiz · Body: notiz +├── plan/kapitel/.md Frontmatter: KapitelPlan ohne notiz (szene = „Kurzbeschreibung“, funktion? = „Funktion im Buch“) · Body: notiz ├── figuren/.md Frontmatter: Figur ohne wahrheit/notizen · Body: "## Wahrheit hinter den Kulissen" + "## Notizen" ├── gruppen/.md Frontmatter: Gruppe ohne notizen · Body: "## Notizen" ├── orte/.md Frontmatter: Ort ohne beschreibung/notizen · Body: Beschreibung + "## Notizen" @@ -68,6 +68,7 @@ Design: `docs/design/screens/*.dc.html` (verbindlich, Pixelwerte und Farben exak ├── .plotwerk/stimmen.json G3 KiAblage ├── .plotwerk/leser.json G6 KiAblage (weiss, fragen, vermutungen, spannung, plan) ├── .plotwerk/fragen.json G6 KiAblage (imBuch: false = „steht nicht im Buch“) +├── .plotwerk/strang-vorschlaege.json G2 KiAblage (zuordnung, neu, frage; Antworten auf Rückfragen) └── .versionen/… Schnappschüsse (Binärdateien: .versionen/recherche/…/.) ``` @@ -129,7 +130,7 @@ Gemeinsame Grundlage (Fundament, nur additiv ändern): Typen in `core/model/type | Gruppe | Eigene Pfade | Einhängepunkte in fremden Bereichen | | --- | --- | --- | | G1 Zeit & Zustand | `src/features/zeitleiste/`, `src/state/slices/zeit.ts`, `core/zeit/`, `core/ki/zeit.ts`, `src/backend/ki/demoAntworten/zeit.ts` | `zeitHinweise()` (core/zeit) fließt in core/index-Hinweise | -| G2 Plot-Struktur | `src/features/faeden/`, `src/state/slices/struktur.ts`, `core/struktur/faeden.ts`, `core/struktur/szenen.ts` | `StrangRaster` (Akte planen, Segment „Stränge“), `SzenenUebersicht` (Kapitel-Überblick), `SzenenKarte` (Schreiben-Inspektor, Tab Plan) | +| G2 Plot-Struktur | `src/features/faeden/`, `src/state/slices/struktur.ts`, `core/struktur/faeden.ts`, `core/struktur/szenen.ts`, `core/struktur/straenge.ts`, `core/ki/straenge.ts`, `src/backend/ki/demoAntworten/struktur.ts` | `StrangRaster` und `StrangInspektor` (Akte planen, Segment „Stränge“), `KapitelStrangVorschlaege` (Kapitel-Inspektor), `SzenenUebersicht` (Kapitel-Überblick), `SzenenKarte` (Schreiben-Inspektor, Tab Plan) | | G3 Figuren | `src/features/figuren/Boegen*.tsx`, `Stimme*.tsx`, `src/state/slices/figurenbogen.ts`, `core/figuren/`, `core/ki/stimme.ts`, `core/ki/stimmeRede.ts`, `src/backend/ki/demoAntworten/figuren.ts` | `FigurenBoegen` (Figuren, Segment „Bögen“), Spalte „Stimme“ an der Figurenkarte; `stimmenHinweise()` fließt in core/index-Hinweise (Typ `sprache`) | | G4 Editor | `src/features/schreiben/werkzeuge/`, `src/state/slices/editorwerkzeuge.ts`, `core/stil/`, `src/backend/tauri/vorlesen.ts`, `src-tauri/src/vorlesen.rs` | `editorErweiterungen()`, `` (Editor), `` (Statusleiste), Tab „Stil“ (Schreiben-Inspektor) | | G5 Versionen & Statistik | `src/features/statistik/`, `src/state/slices/statistik.ts`, `core/statistik/` | `` (Statusleiste); Schnappschüsse über `schnappschuss(pfad, 'benannt')` | @@ -138,9 +139,9 @@ Gemeinsame Grundlage (Fundament, nur additiv ändern): Typen in `core/model/type | G8 Recherche & Mac | `src/features/recherche/`, `src/state/slices/recherche.ts`, `core/recherche/`, `scripts/`, Tauri-Konfiguration für Signatur/Updater (`docs/mac-verteilung.md`) | `RechercheLeiste` an Figurenkarte, Ortskarte, Kapitel-Überblick, Schreiben-Inspektor (Plan) und Szenen-Formular | Neue Bereiche (Seitenleisten-Gruppe „Werkzeuge“, einklappbar): `zeitleiste` (⌘8), `faeden` (⌘9), `recherche` (⌘0), -`statistik`, `fragen`. Neue Dialoge: `zeit-vorschlaege`, `stimme-pruefen`, `marken`, `fassung`, `versionsvergleich`, +`statistik`, `fragen`. Neue Dialoge: `zeit-vorschlaege`, `stimme-pruefen`, `straenge-vorschlagen`, `marken`, `fassung`, `versionsvergleich`, `leser-simulation`, `abgabe-export`, `rueckimport`, `recherche-eintrag`. Befehle (Menü, Schnellsuche): -`zeit-vorschlaege`, `stimme-pruefen`, `marken`, `typewriter` (⌘⇧T), `wiederholungen`, `vorlesen`, `fassung`, +`zeit-vorschlaege`, `stimme-pruefen`, `straenge-vorschlagen`, `marken`, `typewriter` (⌘⇧T), `wiederholungen`, `vorlesen`, `fassung`, `versionsvergleich`, `leser-simulation`, `abgabe`, `rueckimport`, `recherche-neu`, `faden-neu`. Fassung sichern: ⌘⇧S. Solange ein Dialog offen ist, wechseln ⌘-Bereichstasten nicht den Bereich dahinter. Anzeigenamen der Hinweis-Typen: `core/index/hinweisTypen.ts` (Hinweise-Ansicht und Lektoratsmappe). diff --git a/docs/handbuch.md b/docs/handbuch.md index 5f9062a..23e45a0 100644 --- a/docs/handbuch.md +++ b/docs/handbuch.md @@ -55,10 +55,22 @@ aktive Werkzeug sichtbar). Alle Befehle finden sich auch in der Schnellsuche ( Enden bis hier, „Leserin nach Kap. n“, „Was ist sicher?“ (Gewissheit der Stellen), Recherche zum Kapitel. - **Akte planen** (⌘3): Akt-Board mit Kopfkarten (Quintessenz, Wendepunkt) und Ideen-Ablage; Umschalter „Nur Plan“, „Plan + Text“ und **„Stränge“** – das Strang-Raster zeigt, in welchen Kapiteln ein Strang fehlt oder verschwindet; - Klick oder Leertaste nimmt ihn in den Kapitelplan auf. -- **Fäden** (⌘9): Board mit Offen, Auflösung geplant, Erledigt, Bewusst offen. Warnung bei Fäden, die zu lange ruhen, - oder deren geplante Auflösung schon geschrieben ist. „+ Faden“ bzw. „Neuer Faden …“ legt eigene Fäden an. - Pfeiltasten wandern, ⌥←/⌥→ verschiebt in die Nachbarspalte. + Klick oder Leertaste nimmt ihn in den Kapitelplan auf. Ein Klick auf den Strangnamen öffnet ihn im Inspektor: + Haupthandlung oder Nebenhandlung, „Wofür steht der Strang?“, Farbe. **„Mit KI vorschlagen …“** lässt die KI das Buch + lesen: Sie ordnet geschriebene Kapitel deinen Strängen zu, schlägt fehlende Stränge vor und stellt Rückfragen – jeweils + mit Zitat. Du übernimmst, verwirfst oder antwortest; Verworfenes kommt nicht wieder, Antworten gehen beim nächsten + Lauf mit. +- **Kapitel planen** (Inspektor im Akt-Board): **Kurzbeschreibung** in einem Satz (steht auf der Karte), optional die + **Funktion im Buch** (Opening, Wendepunkt, Atempause … oder eigene; als Etikett auf der Karte) und ausführlich „Was + soll hier passieren?“. KI-Vorschläge zu Strängen erscheinen direkt beim Feld „Strang“. +- **Lose Enden** (⌘9, früher „Fäden“): offene Fragen und Versprechen an die Leserin, die sich auflösen sollen – im + Unterschied zu **Strängen**, den Handlungslinien, die du in „Akte planen“ planst. Ein loses Ende kann zu einem Strang + gehören (Feld „Strang“). Ist es eigentlich eine ganze Handlungslinie (z. B. „Der Deloitte-Auftrag“), macht + **„Als Strang übernehmen“** daraus einen Strang und trägt ihn in alle Kapitel ein, in denen es vorkommt – auch aus dem + Kapitel-Inspektor und im Segment „Stränge“ möglich. Umschalter **Board | Tabelle**: Das Board hat die Spalten Offen, + Auflösung geplant, Erledigt, Bewusst offen; die Tabelle zeigt alle losen Enden × Kapitel, gruppiert nach Strang. + Warnung bei losen Enden, die zu lange ruhen, oder deren geplante Auflösung schon geschrieben ist. „+ Loses Ende“ + legt eigene an. Pfeiltasten wandern, ⌥←/⌥→ verschiebt in die Nachbarspalte. - **Szenen**: Kapitel werden über `* * *` in Szenen geteilt. Pro Szene Titel, Ort, Figuren, Zeit und Notiz – im Schreiben-Inspektor und im Kapitel-Überblick. Der Kapiteltext wird dabei nie verändert. - In Notizen und Plan-Feldern verlinkt `[[` auf Figuren, Orte, Kapitel, Plan-Karten und Ideen. diff --git a/src/app/app.en.test.tsx b/src/app/app.en.test.tsx index 234e8fe..183e79d 100644 --- a/src/app/app.en.test.tsx +++ b/src/app/app.en.test.tsx @@ -30,7 +30,7 @@ describe('Oberfläche auf Englisch', () => { festeSprache('en'); expect(BEREICHE.map((b) => b.titel)).toEqual([ 'Write', 'Chapter overview', 'Plan acts', 'Characters', 'Relationships', 'Places', 'Notes', - 'Timeline', 'Threads', 'Research', 'Statistics', 'Questions', + 'Timeline', 'Loose ends', 'Research', 'Statistics', 'Questions', ]); expect(bereichDefinition('zeitleiste')).toMatchObject({ id: 'zeitleiste', taste: '8', titel: 'Timeline' }); festeSprache('de'); @@ -64,7 +64,7 @@ describe('Oberfläche auf Englisch', () => { ]); expect(within(nav).getByRole('button', { name: /^Write/ })).toHaveAttribute('title', 'Write (⌘1)'); fireEvent.click(within(nav).getByRole('button', { name: 'Tools' })); - expect(within(nav).getAllByRole('button').slice(8).map((e) => e.textContent)).toEqual(['Timeline', 'Threads', 'Research', 'Statistics', 'Questions']); + expect(within(nav).getAllByRole('button').slice(8).map((e) => e.textContent)).toEqual(['Timeline', 'Loose ends', 'Research', 'Statistics', 'Questions']); expect(nav.textContent).not.toMatch(DEUTSCH); await act(async () => { @@ -76,7 +76,7 @@ describe('Oberfläche auf Englisch', () => { expect(screen.getByText('No results.')).toBeInTheDocument(); fireEvent.change(eingabe, { target: { value: 'threads' } }); const liste = screen.getByRole('listbox', { name: 'Results' }); - expect(within(liste).getAllByRole('option')[0]).toHaveTextContent('Threads⌘9'); + expect(within(liste).getAllByRole('option')[0]).toHaveTextContent('Loose ends⌘9'); fireEvent.keyDown(eingabe, { key: 'Enter' }); expect(useStore.getState().bereich).toBe('faeden'); expect(useStore.getState().dialog).toBeNull(); diff --git a/src/app/app.test.tsx b/src/app/app.test.tsx index b466f39..990e607 100644 --- a/src/app/app.test.tsx +++ b/src/app/app.test.tsx @@ -63,7 +63,7 @@ describe('App im Browser-Demo', () => { expect(gruppe).toHaveAttribute('aria-expanded', 'false'); fireEvent.click(gruppe); expect(gruppe).toHaveAttribute('aria-expanded', 'true'); - expect(within(nav).getAllByRole('button').slice(8).map((e) => e.textContent)).toEqual(['Zeitleiste', 'Fäden', 'Recherche', 'Statistik', 'Fragen']); + expect(within(nav).getAllByRole('button').slice(8).map((e) => e.textContent)).toEqual(['Zeitleiste', 'Lose Enden', 'Recherche', 'Statistik', 'Fragen']); fireEvent.click(within(nav).getByText('Zeitleiste')); expect(useStore.getState().bereich).toBe('zeitleiste'); expect(await screen.findByRole('heading', { name: 'Zeitleiste', level: 1 })).toBeInTheDocument(); diff --git a/src/app/befehle.ts b/src/app/befehle.ts index abdec2c..3b2e4d4 100644 --- a/src/app/befehle.ts +++ b/src/app/befehle.ts @@ -21,6 +21,7 @@ export type Befehl = // Werkzeuge G1–G8 | 'zeit-vorschlaege' | 'stimme-pruefen' + | 'straenge-vorschlagen' | 'marken' | 'typewriter' | 'wiederholungen' @@ -91,6 +92,11 @@ export async function befehlAusfuehren(befehl: Befehl): Promise { await sichern(); s.oeffneDialog('stimme-pruefen', { figur: s.auswahl.figur }); break; + case 'straenge-vorschlagen': + if (!s.daten) break; + await sichern(); + s.oeffneDialog('straenge-vorschlagen'); + break; case 'marken': if (!s.daten) break; await sichern(); diff --git a/src/app/deeplink.ts b/src/app/deeplink.ts index 328b317..a530c77 100644 --- a/src/app/deeplink.ts +++ b/src/app/deeplink.ts @@ -24,6 +24,7 @@ const DIALOGE: DialogId[] = [ 'schnellsuche', 'zeit-vorschlaege', 'stimme-pruefen', + 'straenge-vorschlagen', 'marken', 'fassung', 'versionsvergleich', diff --git a/src/app/dialoge.tsx b/src/app/dialoge.tsx index 614700b..262e3d0 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')), + 'straenge-vorschlagen': lazy(() => import('../features/faeden/StraengeDialog')), marken: lazy(() => import('../features/schreiben/werkzeuge/MarkenDialog')), fassung: lazy(() => import('../features/statistik/FassungDialog')), versionsvergleich: lazy(() => import('../features/statistik/VersionsvergleichDialog')), diff --git a/src/app/suche.ts b/src/app/suche.ts index 688c869..abcfbfd 100644 --- a/src/app/suche.ts +++ b/src/app/suche.ts @@ -24,6 +24,7 @@ const BEFEHLE: { befehl: Befehl; text: BefehlText }[] = [ // Werkzeuge G1–G8 { befehl: 'zeit-vorschlaege', text: 'zeitVorschlaege' }, { befehl: 'stimme-pruefen', text: 'stimmePruefen' }, + { befehl: 'straenge-vorschlagen', text: 'straengeVorschlagen' }, { befehl: 'marken', text: 'marken' }, { befehl: 'typewriter', text: 'typewriter' }, { befehl: 'wiederholungen', text: 'wiederholungen' }, diff --git a/src/backend/ki/demo/straenge.ts b/src/backend/ki/demo/straenge.ts new file mode 100644 index 0000000..2d00b54 --- /dev/null +++ b/src/backend/ki/demo/straenge.ts @@ -0,0 +1,120 @@ +/** + * Demo-Antwort für die Aufgabe „straenge“ (G2 Stränge vorschlagen). Besitzer: G2 Plot-Struktur. + * Registriert in ../demoAntworten/struktur.ts. Alle echten Zitate sind wörtliche Ausschnitte der Fixture-Kapitel; + * sie werden nur geliefert, wenn sie im mitgeschickten Kontext stehen. Zuordnungen nur zu Strängen, die im + * Aufgabentext stehen. Absichtlich enthalten: eine Zuordnung mit erfundenem Zitat – die Auswertung in + * core/ki/straenge.ts muss sie verwerfen. + */ +import type { DemoAntwort } from '../demoAntworten/typen'; + +interface Zitat { + kapitel: string; + text: string; +} + +interface DemoStraenge { + zuordnungen: { strang: string; kapitel: string; zitat: string; begruendung: string; ungueltig?: boolean }[]; + neueStraenge: { name: string; rolle: 'haupt' | 'neben'; zweck: string; kapitel: { kapitel: string; zitat: string }[] }[]; + rueckfragen: { frage: string; strang: string; belege: Zitat[] }[]; +} + +const DE: DemoStraenge = { + zuordnungen: [ + { + strang: 'nordhaven', + kapitel: 'funkstille', + zitat: 'auf dem Beifahrersitz die Papprolle mit den Glasfassaden', + begruendung: 'Paul hat die Glasfassaden dabei, die Nordhaven verlangt.', + }, + { + strang: 'postkarten', + kapitel: 'lotte', + zitat: 'Lotte schiebt ihm eine Postkarte über den Tisch.', + begruendung: 'Eine weitere Postkarte taucht auf.', + ungueltig: true, + }, + ], + neueStraenge: [ + { + name: 'Der alte Speicher', + rolle: 'neben', + zweck: 'Paul vermisst und plant den alten Kornspeicher, während am Hafen leerstehende Speicher verkauft werden.', + kapitel: [ + { kapitel: 'grundriss', zitat: 'Auf dem langen Tisch in der Teeküche steht das Modell des alten Speichers, dreißig Zentimeter hoch, aus grauer Pappe.' }, + { kapitel: 'staub', zitat: 'Im Kornspeicher liegt der Staub von hundert Jahren auf den Dielen.' }, + { kapitel: 'funkstille', zitat: 'Im Radio läuft eine Meldung über leerstehende Speicher, die über Nacht den Besitzer wechseln.' }, + ], + }, + ], + rueckfragen: [ + { + frage: 'Gehört der Verkauf der Werfthalle am Nordkai zum Strang um Nordhaven oder ist er ein eigener Strang?', + strang: 'nordhaven', + belege: [{ kapitel: 'fassade', text: 'die Schlagzeile quer über der Titelseite: Werfthalle am Nordkai verkauft.' }], + }, + { + frage: 'Welcher Strang trägt die Haupthandlung: Pauls Arbeit für Nordhaven oder die Begegnungen?', + strang: '', + belege: [], + }, + ], +}; + +const EN: DemoStraenge = { + zuordnungen: [ + { + strang: 'nordhaven', + kapitel: 'radio-silence', + zitat: 'the cardboard tube with the glass facades on the passenger seat', + begruendung: 'Paul has the glass facades with him that Nordhaven demands.', + }, + { + strang: 'postcards', + kapitel: 'lotte', + zitat: 'Lotte pushes a postcard across the table to him.', + begruendung: 'Another postcard turns up.', + ungueltig: true, + }, + ], + neueStraenge: [ + { + name: 'The old granary', + rolle: 'neben', + zweck: 'Paul measures and plans the old granary while empty granaries at the harbour are being sold.', + kapitel: [ + { kapitel: 'floor-plan', zitat: 'On the long table in the kitchenette stands the model of the old granary, thirty centimetres tall, made of grey cardboard.' }, + { kapitel: 'dust', zitat: 'In the granary the dust of a hundred years lies on the floorboards.' }, + { kapitel: 'radio-silence', zitat: 'The radio is running a report about empty granaries changing hands overnight.' }, + ], + }, + ], + rueckfragen: [ + { + frage: 'Does the sale of the shipyard hall on the North Quay belong to the Nordhaven plot line, or is it a plot line of its own?', + strang: 'nordhaven', + belege: [{ kapitel: 'facade', text: 'the headline right across the front page: Shipyard hall on the North Quay sold.' }], + }, + { + frage: 'Which plot line carries the main plot: Paul’s work for Nordhaven or the encounters?', + strang: '', + belege: [], + }, + ], +}; + +export const demoStraenge: DemoAntwort = (ctx, anfrage) => { + const d = ctx.sprache === 'en' ? EN : DE; + const steht = (kapitel: string, text: string) => !!ctx.kapitel.find((k) => k.id === kapitel)?.text.includes(text); + const strangDa = (id: string) => anfrage.aufgabe.includes(`(id: ${id})`); + return { + zuordnungen: d.zuordnungen + .filter((z) => strangDa(z.strang) && (z.ungueltig || steht(z.kapitel, z.zitat))) + .map(({ strang, kapitel, zitat, begruendung }) => ({ strang, kapitel, zitat, begruendung })), + neueStraenge: d.neueStraenge + .map((n) => ({ ...n, kapitel: n.kapitel.filter((k) => steht(k.kapitel, k.zitat)) })) + .filter((n) => n.kapitel.length > 0), + rueckfragen: d.rueckfragen + .filter((f) => !f.strang || strangDa(f.strang)) + .map((f) => ({ ...f, belege: f.belege.filter((b) => steht(b.kapitel, b.text)) })), + }; +}; diff --git a/src/backend/ki/demoAntworten/index.ts b/src/backend/ki/demoAntworten/index.ts index fef6815..5fbf4ab 100644 --- a/src/backend/ki/demoAntworten/index.ts +++ b/src/backend/ki/demoAntworten/index.ts @@ -4,13 +4,14 @@ */ import { DEMO_ANTWORTEN_FIGUREN } from './figuren'; import { DEMO_ANTWORTEN_LESER } from './leser'; +import { DEMO_ANTWORTEN_STRUKTUR } from './struktur'; import { DEMO_ANTWORTEN_ZEIT } from './zeit'; import type { DemoAntwort } from './typen'; export type { DemoAnfrage, DemoAntwort } from './typen'; /** Alle Gruppen-Tabellen (live gelesen, Tests dürfen Einträge ergänzen) */ -const TABELLEN: Record[] = [DEMO_ANTWORTEN_ZEIT, DEMO_ANTWORTEN_FIGUREN, DEMO_ANTWORTEN_LESER]; +const TABELLEN: Record[] = [DEMO_ANTWORTEN_ZEIT, DEMO_ANTWORTEN_FIGUREN, DEMO_ANTWORTEN_LESER, DEMO_ANTWORTEN_STRUKTUR]; export function demoAntwortFuer(art: string): DemoAntwort | undefined { for (const t of TABELLEN) if (Object.prototype.hasOwnProperty.call(t, art)) return t[art]; diff --git a/src/backend/ki/demoAntworten/struktur.ts b/src/backend/ki/demoAntworten/struktur.ts new file mode 100644 index 0000000..941e74e --- /dev/null +++ b/src/backend/ki/demoAntworten/struktur.ts @@ -0,0 +1,8 @@ +/** Demo-Antworten für Stränge vorschlagen (G2). Besitzer: G2. Schlüssel = art im Aufgabenkopf. */ +import { STRAENGE_ART } from '@core/ki/index'; +import { demoStraenge } from '../demo/straenge'; +import type { DemoAntwort } from './typen'; + +export const DEMO_ANTWORTEN_STRUKTUR: Record = { + [STRAENGE_ART]: demoStraenge, +}; diff --git a/src/features/akte/KapitelInspektor.tsx b/src/features/akte/KapitelInspektor.tsx index 181cfd4..23626c8 100644 --- a/src/features/akte/KapitelInspektor.tsx +++ b/src/features/akte/KapitelInspektor.tsx @@ -7,6 +7,8 @@ import { kurzfassung } from '../../state/slices/planung'; import { bestaetigen as rueckfrage, Button, Chip, Inspektor } from '../../ui'; import { AngehefteteNotizen, AuswahlMenue, EntfernbarerChip, EntwurfBereich, EntwurfFeld, strangFarbe, strangGeplant, strangKurzname } from './gemeinsam'; import { VerweisText } from '../schreiben/VerweisFeld'; +import { FaedenAlsStraenge, KapitelStrangVorschlaege } from '../faeden/StrangVorschlaege'; +import { straengeSortiert } from '@core/struktur/straenge'; import { datum, f, useTexte, vergleich, woerterbuch } from '../../i18n'; const kopfLabel: CSSProperties = { fontSize: 11, fontWeight: 600, letterSpacing: '0.06em', color: 'var(--accent-dark)' }; @@ -59,7 +61,7 @@ function StrangWahl({ gewaehlt, daten, onChange }: { gewaehlt: Id[]; daten: Proj const t = useTexte(); return (
- {daten.straenge.map((s) => { + {straengeSortiert(daten.straenge).map((s) => { const aktiv = gewaehlt.includes(s.id); return ( onChange(aktiv ? gewaehlt.filter((x) => x !== s.id) : [...gewaehlt, s.id])} > {strangGeplant(s, daten) && ( @@ -82,6 +85,25 @@ function StrangWahl({ gewaehlt, daten, onChange }: { gewaehlt: Id[]; daten: Proj ); } +/** „Funktion im Buch“: eigenes Feld plus Schnellauswahl (Opening, Wendepunkt …); leer = nicht festgelegt. */ +function FunktionWahl({ id, wert, onChange }: { id: string; wert: string; onChange(w: string): void }) { + const t = useTexte(); + const ti = t.akte.inspektor; + const vorschlaege = Object.values(ti.funktionen); + return ( +
+ onChange(w.trim())} /> +
+ {vorschlaege.map((v) => ( + + ))} +
+
+ ); +} + function Nachbar({ richtung, id, daten, index }: { richtung: string; id: Id | undefined; daten: ProjektDaten; index: Index }) { const oeffne = useStore((s) => s.waehle); const t = useTexte(); @@ -155,7 +177,7 @@ function Abweichungen({ plan }: { plan: KapitelPlan }) { ); } -export function KapitelPlanInspektor({ id, daten, index }: { id: Id; daten: ProjektDaten; index: Index }) { +export function KapitelPlanInspektor({ id, daten, index, zurueck }: { id: Id; daten: ProjektDaten; index: Index; zurueck?: () => void }) { const plan = daten.plaene[id]; const aendern = useStore((s) => s.planung.kapitelPlanAendern); const schreibenBeginnen = useStore((s) => s.planung.schreibenBeginnen); @@ -177,13 +199,18 @@ export function KapitelPlanInspektor({ id, daten, index }: { id: Id; daten: Proj return ( + {zurueck && ( + + )} {f(ti.kopf, { nr, status: statusText })} titel.trim() && speichern({ titel: titel.trim() })} /> speichern({ szene })} /> + + speichern({ funktion: funktion || undefined })} />
{ti.strang} speichern({ straenge })} /> + +
{ti.figuren} diff --git a/src/features/akte/Karten.tsx b/src/features/akte/Karten.tsx index 29b2a16..87e9b13 100644 --- a/src/features/akte/Karten.tsx +++ b/src/features/akte/Karten.tsx @@ -92,6 +92,11 @@ export function KapitelKarte({ )} {titel} + {plan.funktion && ( + + {plan.funktion} + + )} {szene && {szene}} {zeile ? ( {zeile} diff --git a/src/features/akte/index.tsx b/src/features/akte/index.tsx index 03c7501..018ae66 100644 --- a/src/features/akte/index.tsx +++ b/src/features/akte/index.tsx @@ -8,6 +8,8 @@ import { IdeeInspektor, KapitelPlanInspektor } from './KapitelInspektor'; import { strangFarbe, strangGeplant } from './gemeinsam'; import { Inspektor } from '../../ui'; import { StrangRaster } from '../faeden/StrangRaster'; +import { StrangInspektor } from '../faeden/StrangInspektor'; +import { straengeSortiert } from '@core/struktur/straenge'; import { texte, useTexte } from '../../i18n'; /** Segment in der Titelleiste: zwei Board-Ansichten (Planung) und das Strang-Raster (G2). */ @@ -21,6 +23,8 @@ export default function Ansicht() { const ideeId = useStore((s) => s.planung.ideeAuswahl); const aktHinzufuegen = useStore((s) => s.planung.aktHinzufuegen); const darstellung = useStore((s) => s.struktur.akteDarstellung); + const rasterKapitel = useStore((s) => s.struktur.rasterKapitel); + const waehleStrang = useStore((s) => s.struktur.waehleStrang); const t = useTexte(); if (!daten) return null; const idee = ideeId ? daten.ideen.find((i) => i.id === ideeId) : undefined; @@ -55,7 +59,11 @@ export default function Ansicht() { )}
- {idee ? ( + {darstellung === 'straenge' && !(rasterKapitel && planId && daten.plaene[planId]) ? ( + + ) : darstellung === 'straenge' && planId ? ( + waehleStrang(null)} /> + ) : idee ? ( ) : planId && daten.plaene[planId] ? ( @@ -81,12 +89,13 @@ export function Seitenleiste() { {daten.straenge.length > 0 && (
{t.akte.legende.straenge} - {daten.straenge.map((s) => { + {straengeSortiert(daten.straenge).map((s) => { const geplant = strangGeplant(s, daten); return ( - + - {s.name} + {s.name} + {s.rolle && {t.faeden.raster.rolle[s.rolle]}} {geplant && {t.akte.legende.geplant}} ); diff --git a/src/features/faeden/FadenTabelle.tsx b/src/features/faeden/FadenTabelle.tsx new file mode 100644 index 0000000..9613cec --- /dev/null +++ b/src/features/faeden/FadenTabelle.tsx @@ -0,0 +1,153 @@ +/** + * Tabelle „Lose Enden × Kapitel“ (G2): alle losen Enden untereinander, gruppiert nach Strang (Haupthandlung zuerst, + * dann Nebenhandlungen, zuletzt ohne Strang), je Kapitel eine Zelle: eröffnet, aufgegriffen, ruht, Auflösung geplant. + * Klick auf Name oder Zelle wählt das lose Ende im Inspektor. Besitzer: G2. + */ +import { Fragment, useMemo } from 'react'; +import type { Id, ProjektDaten } from '@core/model/types'; +import { fadenStreifen, strangRaster, type FadenKarte, type StreifenZelle } from '@core/struktur/faeden'; +import { straengeSortiert } from '@core/struktur/straenge'; +import { useStore } from '../../state/store'; +import { klassen, StrangPunkt } from '../../ui'; +import { strangFarbe } from '../akte/gemeinsam'; +import { fadenFarbe, kapitelDauer, kapitelVoll, roemisch, StreifenLegende } from './gemeinsam'; +import { f, texte, useTexte } from '../../i18n'; +import './faeden.css'; + +interface Gruppe { + strang: Id | null; + karten: FadenKarte[]; +} + +function gruppen(daten: ProjektDaten, karten: FadenKarte[]): Gruppe[] { + const ids = new Set(daten.straenge.map((s) => s.id)); + const ergebnis: Gruppe[] = straengeSortiert(daten.straenge) + .map((s) => ({ strang: s.id as Id | null, karten: karten.filter((k) => k.strang === s.id) })) + .filter((g) => g.karten.length > 0); + const ohne = karten.filter((k) => !k.strang || !ids.has(k.strang)); + if (ohne.length) ergebnis.push({ strang: null, karten: ohne }); + return ergebnis; +} + +function zellenTitel(daten: ProjektDaten, karte: FadenKarte, z: StreifenZelle): string { + const t = texte().faeden.streifen; + const teile: string[] = []; + if (z.eroeffnet) teile.push(t.eroeffnet); + if (z.aufgegriffen) teile.push(t.aufgegriffen); + if (z.aufloesung) teile.push(t.aufloesungGeplant); + if (z.ruht) teile.push(t.ruht); + if (z.ohneText) teile.push(t.nochOhneText); + return `${karte.name} · ${kapitelVoll(daten, z.kapitel)}${teile.length ? `: ${teile.join(', ')}` : ''}`; +} + +function zeilenInfo(karte: FadenKarte, zellen: StreifenZelle[]): string { + const t = texte().faeden; + const drin = zellen.filter((z) => z.eroeffnet || z.aufgegriffen).map((z) => z.nummer); + const teile: string[] = []; + if (drin.length === 0) teile.push(t.karte.keinKapitel); + else if (drin.length === 1) teile.push(f(t.raster.nurKap, { nr: drin[0]! })); + else teile.push(f(t.raster.bereich, { von: drin[0]!, bis: drin[drin.length - 1]!, n: drin.length })); + if (karte.ruhend) teile.push(f(t.karte.ruhtKurz, { dauer: kapitelDauer(karte.ruhtSeit) })); + if (karte.status !== 'offen') teile.push(t.spalten[karte.spalte]); + return teile.join(' · '); +} + +export function FadenTabelle({ daten, karten }: { daten: ProjektDaten; karten: FadenKarte[] }) { + const auswahl = useStore((s) => s.struktur.fadenAuswahl); + const waehle = useStore((s) => s.struktur.waehleFaden); + const t = useTexte(); + const tt = t.faeden.tabelle; + const raster = useMemo(() => strangRaster(daten), [daten]); + const liste = useMemo(() => gruppen(daten, karten), [daten, karten]); + const n = raster.kapitel.length; + const spalten = `minmax(220px, max-content) repeat(${n}, minmax(28px, 34px))`; + let zeile = 3; + + return ( +
+
+
+
+
+ {raster.akte.map((a) => ( +
+ {a.akt === null ? t.faeden.raster.ohneAkt : f(t.faeden.raster.akt, { nr: roemisch(a.akt) })} +
+ ))} +
+
+
+ {tt.ecke} +
+ {raster.kapitel.map((k, i) => ( +
+ {k.nummer} +
+ ))} +
+ {liste.map((g) => { + const s = g.strang ? daten.straenge.find((x) => x.id === g.strang) : undefined; + const kopfZeile = zeile++; + return ( + +
+
+ {s ? : null} + {s ? s.name : tt.ohneStrang} + {s?.rolle && {t.faeden.raster.rolleKurz[s.rolle]}} + {f(t.faeden.ansicht.anzahl, { n: g.karten.length })} +
+
+ {g.karten.map((karte) => { + const r = zeile++; + const zellen = fadenStreifen(karte, daten); + const farbe = fadenFarbe(daten, karte.strang); + return ( +
+
+ + {zeilenInfo(karte, zellen)} +
+ {zellen.map((z, i) => ( +
waehle(karte.id)}> + + {z.eroeffnet ? '●' : ''} + +
+ ))} +
+ ); + })} +
+ ); + })} +
+
+ +
+ ); +} diff --git a/src/features/faeden/StraengeDialog.tsx b/src/features/faeden/StraengeDialog.tsx new file mode 100644 index 0000000..2608189 --- /dev/null +++ b/src/features/faeden/StraengeDialog.tsx @@ -0,0 +1,198 @@ +/** + * Dialog „straenge-vorschlagen“ (G2): Kosten schätzen, KI-Lauf starten, Ergebnis zusammenfassen. Die Vorschläge + * selbst stehen danach im Inspektor des Segments „Stränge“ und im Kapitel-Inspektor. Besitzer: G2. + */ +import { useEffect, useState } from 'react'; +import { EFFORT, formatiereUsd, kiEndpoint, type Schaetzung } from '@core/ki/index'; +import type { DialogKomponentenProps } from '../../app/dialogTypen'; +import { kiFehlerAnzeige, type KiFehlerAnzeige } from '../../state/slices/ki'; +import { f as fmt, sprache, useTexte } from '../../i18n'; +import { useStore } from '../../state/store'; +import { Button, Dialog } from '../../ui'; +import '../ki/ki.css'; + +function FehlerKasten({ fehler, einstellungen }: { fehler: KiFehlerAnzeige; einstellungen(): void }) { + const t = useTexte(); + return ( +
+ {fehler.text} + {fehler.art === 'nicht_bereit' && ( + + )} +
+ ); +} + +export default function StraengeDialog({ schliessen }: DialogKomponentenProps) { + const daten = useStore((s) => s.daten); + const backendArt = useStore((s) => s.backend?.art); + const st = useStore((s) => s.struktur); + const kiEinstellungen = useStore((s) => s.kiEinstellungen); + const oeffneDialog = useStore((s) => s.oeffneDialog); + const oeffne = useStore((s) => s.oeffne); + const t = useTexte(); + const td = t.faeden.dialog; + const [schaetzung, setSchaetzung] = useState<{ wert?: Schaetzung; fehler?: KiFehlerAnzeige; laedt: boolean }>({ laedt: true }); + + useEffect(() => { + useStore.getState().struktur.strangZuruecksetzen(); + }, []); + + const phase = st.strangLauf ? 'laeuft' : st.strangErgebnis ? 'fertig' : 'vorbereiten'; + + useEffect(() => { + if (phase !== 'vorbereiten' || !daten) return; + let aktiv = true; + setSchaetzung((s) => ({ ...s, laedt: true, fehler: undefined })); + useStore + .getState() + .struktur.strangSchaetzen() + .then( + (wert) => aktiv && setSchaetzung({ wert, laedt: false }), + (e) => aktiv && setSchaetzung({ fehler: kiFehlerAnzeige(e), laedt: false }), + ); + return () => { + aktiv = false; + }; + // Schätzung nur beim Öffnen und nach einem Lauf, nicht bei jeder Datenänderung + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase, !!daten]); + + const einstellungenOeffnen = () => oeffneDialog('ki-einstellungen'); + const endpointFehlt = backendArt === 'tauri' && !kiEndpoint(kiEinstellungen()); + + if (!daten) { + return ( + {t.gemeinsam.schliessen}}> +

{td.keinProjekt}

+
+ ); + } + + const ansehen = () => { + oeffne('akte'); + st.setzeAkteDarstellung('straenge'); + st.waehleStrang(null); + schliessen(); + }; + + if (phase === 'laeuft') { + return ( + + + + + } + > +
+

{td.liest}

+
+ +
+

{td.eineAnfrage}

+
+
+ ); + } + + const e = st.strangErgebnis; + if (phase === 'fertig' && e) { + return ( + + + + + } + > +
+ {fmt(td.offen, { n: e.offen })} + + {fmt(td.neu, { n: e.neu })} + {e.bereitsEntschieden > 0 && ` · ${fmt(td.bereits, { n: e.bereitsEntschieden })}`} + {` · ${fmt(td.verworfen, { n: e.verworfen.length })}`} + +
+
+
{td.modell}
+
{e.modell}
+
{td.kosten}
+
{formatiereUsd(e.kostenUsd, sprache())}
+
+ {e.verworfen.length > 0 && ( +
+ {td.gruende} +
    + {e.verworfen.map((v, i) => ( +
  • {v.grund}
  • + ))} +
+
+ )} +

{td.verworfenRegel}

+
+ ); + } + + const s = schaetzung.wert; + const antworten = (daten.kiStrangVorschlaege?.eintraege ?? []).filter((v) => v.art === 'frage' && v.status === 'uebernommen' && v.antwort).length; + return ( + + + + + } + > +

{td.intro}

+ {st.strangFehler && } + {endpointFehlt && } +

+ {daten.straenge.length ? fmt(td.straenge, { n: daten.straenge.length }) : td.keineStraenge} + {antworten > 0 && ` · ${fmt(td.antworten, { n: antworten })}`} +

+

{td.mitgeschickt}

+ +
+ {schaetzung.fehler ? ( + + ) : !s || schaetzung.laedt ? ( +

{td.schaetzen}

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

{td.nichtGezaehlt}

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

{td.demo}

} +
+ ); +} diff --git a/src/features/faeden/StrangInspektor.tsx b/src/features/faeden/StrangInspektor.tsx new file mode 100644 index 0000000..0bffa0b --- /dev/null +++ b/src/features/faeden/StrangInspektor.tsx @@ -0,0 +1,129 @@ +/** + * Inspektor im Segment „Stränge“ (Akte planen): gewählter Strang (Name, Haupt-/Nebenhandlung, „Wofür steht der + * Strang?“, Farbe, Kapitel im Plan, KI-Vorschläge zu ihm) oder – ohne Auswahl – alle KI-Vorschläge. Besitzer: G2. + */ +import { useId } from 'react'; +import type { ProjektDaten, Strang, StrangRolle } from '@core/model/types'; +import { STRANG_FARBEN } from '@core/struktur/straenge'; +import { useStore } from '../../state/store'; +import { Inspektor, Segment } from '../../ui'; +import { EntwurfBereich, EntwurfFeld, strangFarbe } from '../akte/gemeinsam'; +import { kapitelVoll } from './gemeinsam'; +import { FaedenAlsStraenge, StrangVorschlaegeListe } from './StrangVorschlaege'; +import { f, useTexte } from '../../i18n'; +import './faeden.css'; + +type RolleWahl = StrangRolle | 'offen'; + +function StrangBearbeiten({ strang, daten }: { strang: Strang; daten: ProjektDaten }) { + const aendern = useStore((s) => s.struktur.strangAendern); + const waehleStrang = useStore((s) => s.struktur.waehleStrang); + const waehle = useStore((s) => s.waehle); + const uid = useId(); + const t = useTexte(); + const ts = t.faeden.strang; + const kapitel = daten.projekt.kapitel.filter((k) => daten.plaene[k]?.straenge.includes(strang.id)); + const gruppe = strang.gruppe ? daten.gruppen[strang.gruppe] : undefined; + return ( + <> + + + {ts.kopf} + + + name.trim() && void aendern(strang.id, { name: name.trim() })} + /> + {gruppe && {f(ts.antagonist, { name: gruppe.name })}} + {ts.rolle} + + rolle="group" + label={ts.rolle} + style={{ marginTop: -8 }} + voll + wert={strang.rolle ?? 'offen'} + onChange={(w) => void aendern(strang.id, { rolle: w === 'offen' ? undefined : w })} + optionen={[ + { wert: 'haupt', label: t.faeden.raster.rolle.haupt }, + { wert: 'neben', label: t.faeden.raster.rolle.neben }, + { wert: 'offen', label: ts.rolleOffen }, + ]} + /> + + void aendern(strang.id, { beschreibung })} + /> + {ts.farbe} +
+ {STRANG_FARBEN.map((fb) => ( +
+ {ts.kapitel} + {kapitel.length === 0 ? ( + + {ts.keineKapitel} + + ) : ( +
    + {kapitel.map((k) => ( +
  • + +
  • + ))} +
+ )} + + + ); +} + +/** Rechte Spalte im Segment „Stränge“. */ +export function StrangInspektor({ daten }: { daten: ProjektDaten }) { + const auswahl = useStore((s) => s.struktur.strangAuswahl); + const t = useTexte(); + const strang = auswahl ? daten.straenge.find((s) => s.id === auswahl) : undefined; + return ( + + {strang ? ( + + ) : ( + <> + + + + )} + + ); +} diff --git a/src/features/faeden/StrangRaster.tsx b/src/features/faeden/StrangRaster.tsx index a0efae0..4cd8326 100644 --- a/src/features/faeden/StrangRaster.tsx +++ b/src/features/faeden/StrangRaster.tsx @@ -7,25 +7,15 @@ */ import { useMemo, useRef, useState, type KeyboardEvent } from 'react'; import type { ProjektDaten, Strang } from '@core/model/types'; -import { fadenSlug, RUHE_STANDARD, strangRaster, type RasterZeile, type RasterZelle } from '@core/struktur/faeden'; +import { RUHE_STANDARD, strangRaster, type RasterZeile, type RasterZelle } from '@core/struktur/faeden'; +import { freieStrangFarbe, neueStrangId, offeneStrangVorschlaege, STRANG_FARBEN, straengeSortiert } from '@core/struktur/straenge'; import { useStore } from '../../state/store'; import { Button, klassen, LeerZustand, StrangPunkt } from '../../ui'; import { strangFarbe, strangGeplant } from '../akte/gemeinsam'; import { kapitelVoll, roemisch } from './gemeinsam'; -import { f, texte, useTexte, type Woerterbuch } from '../../i18n'; +import { f, texte, useTexte } from '../../i18n'; import './faeden.css'; -type FarbName = keyof Woerterbuch['faeden']['raster']['farben']; - -/** Farben für neue Stränge (Hex wie in plan/straenge.md; die Anzeige läuft über strangFarbe → Token). */ -const STRANG_FARBEN: { hex: string; name: FarbName }[] = [ - { hex: '#3D5A75', name: 'blau' }, - { hex: '#C2405C', name: 'rosa' }, - { hex: '#A86A12', name: 'ocker' }, - { hex: '#6A4C9C', name: 'violett' }, - { hex: '#2F6B45', name: 'gruen' }, - { hex: '#4A4A4F', name: 'grau' }, -]; function strangInfo(z: RasterZeile): { text: string; warn: boolean } { const t = texte().faeden.raster; @@ -46,18 +36,16 @@ function zellenTitel(daten: ProjektDaten, strang: Strang, c: RasterZelle): strin function NeuerStrang({ daten, onFertig }: { daten: ProjektDaten; onFertig(): void }) { const speichern = useStore((s) => s.speichereStraenge); + const waehleStrang = useStore((s) => s.struktur.waehleStrang); const [name, setName] = useState(''); - const frei = STRANG_FARBEN.find((f) => !daten.straenge.some((s) => s.farbe.toLowerCase() === f.hex.toLowerCase())) ?? STRANG_FARBEN[0]!; - const [farbe, setFarbe] = useState(frei.hex); + const [farbe, setFarbe] = useState(() => freieStrangFarbe(daten.straenge)); const t = useTexte(); const anlegen = () => { const n = name.trim(); if (!n) return; - const belegt = new Set(daten.straenge.map((s) => s.id)); - const basis = fadenSlug(n); - let id = basis; - for (let i = 2; belegt.has(id); i++) id = `${basis}-${i}`; + const id = neueStrangId(daten.straenge, n); void speichern([...daten.straenge, { id, name: n, farbe }]); + waehleStrang(id); onFertig(); }; return ( @@ -110,9 +98,20 @@ function NeuerStrang({ daten, onFertig }: { daten: ProjektDaten; onFertig(): voi export function StrangRaster({ daten }: { daten: ProjektDaten }) { const umschalten = useStore((s) => s.struktur.strangUmschalten); const waehle = useStore((s) => s.waehle); + const waehleStrang = useStore((s) => s.struktur.waehleStrang); + const setzeRasterKapitel = useStore((s) => s.struktur.setzeRasterKapitel); + const strangAuswahl = useStore((s) => s.struktur.strangAuswahl); + const rasterKapitel = useStore((s) => s.struktur.rasterKapitel); + const oeffneDialog = useStore((s) => s.oeffneDialog); + const kiLauf = useStore((s) => s.struktur.strangLauf); const gewaehlt = useStore((s) => s.auswahl.plan ?? s.auswahl.kapitel); const schwelle = daten.fadenplanung?.ruheSchwelle ?? RUHE_STANDARD; - const raster = useMemo(() => strangRaster(daten, schwelle), [daten, schwelle]); + const raster = useMemo(() => { + const r = strangRaster(daten, schwelle); + const reihe = straengeSortiert(daten.straenge).map((s) => s.id); + return { ...r, zeilen: [...r.zeilen].sort((a, b) => reihe.indexOf(a.strang) - reihe.indexOf(b.strang)) }; + }, [daten, schwelle]); + const offeneVorschlaege = useMemo(() => offeneStrangVorschlaege(daten).length, [daten]); const [fokus, setFokus] = useState<{ r: number; c: number }>({ r: 0, c: 0 }); const [neu, setNeu] = useState(false); const gitter = useRef(null); @@ -122,7 +121,19 @@ export function StrangRaster({ daten }: { daten: ProjektDaten }) { const n = raster.kapitel.length; if (daten.straenge.length === 0 && !neu) return ( - setNeu(true)}>{tr.plusStrang}}> + + + {n > 0 && ( + + )} + + } + > {tr.leerText} ); @@ -193,11 +204,14 @@ export function StrangRaster({ daten }: { daten: ProjektDaten }) { type="button" className={klassen('fd-raster__kapitel', k.ohneText && 'fd-raster__kapitel--ohnetext')} style={{ flex: 1 }} - aria-current={gewaehlt === k.id ? 'true' : undefined} + aria-current={rasterKapitel && gewaehlt === k.id ? 'true' : undefined} title={f(tr.kapitelTitel, { kap: f(t.gemeinsam.kapitelKurz, { nr: k.nummer }), titel: k.titel, ohneText: k.ohneText ? tr.nochOhneText : '' })} aria-label={f(tr.kapitelLabel, { nr: k.nummer, titel: k.titel })} tabIndex={-1} - onClick={() => waehle({ plan: k.id, kapitel: k.id })} + onClick={() => { + waehle({ plan: k.id, kapitel: k.id }); + setzeRasterKapitel(true); + }} > {k.nummer} @@ -213,10 +227,22 @@ export function StrangRaster({ daten }: { daten: ProjektDaten }) { return (
- + {info.text}
{z.zellen.map((c, i) => ( @@ -273,6 +299,14 @@ export function StrangRaster({ daten }: { daten: ProjektDaten }) {
{verschwunden > 0 && {f(tr.verschwinden, { n: verschwunden })}} + {offeneVorschlaege > 0 && ( + + )} + {!neu && ( + ); +} + +function NeuerStrangKarte({ v, daten, kapitel }: { v: StrangVorschlag; daten: ProjektDaten; kapitel?: Id }) { + const uebernehmen = useStore((s) => s.struktur.strangVorschlagUebernehmen); + const [auswahl, setAuswahl] = useState(v.kapitel); + const t = useTexte(); + const tv = t.faeden.vorschlaege; + return ( +
+
+ {v.name} + {v.rolle && {t.faeden.raster.rolle[v.rolle]}} +
+ {v.beschreibung && {v.beschreibung}} + {!kapitel && v.kapitel.length > 1 ? ( +
+ {v.kapitel.map((k) => ( + + ))} +
+ ) : null} + b.kapitel === kapitel) } : v} daten={daten} /> +
+ + +
+
+ ); +} + +function ZuordnungKarte({ v, daten, ohneStrang }: { v: StrangVorschlag; daten: ProjektDaten; ohneStrang?: boolean }) { + const uebernehmen = useStore((s) => s.struktur.strangVorschlagUebernehmen); + const texte = useTexte(); + const t = texte.faeden.vorschlaege; + const s = daten.straenge.find((x) => x.id === v.strang); + const k = v.kapitel[0]!; + if (!s) return null; + return ( +
+
+ {!ohneStrang && } + {ohneStrang ? `${f(texte.gemeinsam.kapitelKurz, { nr: kapNr(daten, k) })} · ${daten.kapitel[k]?.titel ?? k}` : f(t.passtZu, { nr: kapNr(daten, k), name: s.name })} +
+ {v.begruendung && {v.begruendung}} + +
+ + +
+
+ ); +} + +function FrageKarte({ v, daten }: { v: StrangVorschlag; daten: ProjektDaten }) { + const beantworten = useStore((s) => s.struktur.strangFrageBeantworten); + const [antwort, setAntwort] = useState(''); + const t = useTexte().faeden.vorschlaege; + const s = v.strang ? daten.straenge.find((x) => x.id === v.strang) : undefined; + const absenden = () => antwort.trim() && void beantworten(v.schluessel, antwort); + return ( +
+
+ {s && } + {v.frage} +
+ +