diff --git a/core/abgabe/expose.ts b/core/abgabe/expose.ts index 90d4140..08e20be 100644 --- a/core/abgabe/expose.ts +++ b/core/abgabe/expose.ts @@ -10,6 +10,7 @@ */ import { AlignmentType, Document, HeadingLevel, HighlightColor, LineRuleType, Packer, Paragraph, TextRun } from 'docx'; import { buchSprache, fmt, localeVon, texteJe, type Sprache } from '../i18n'; +import { nachRolle } from '../figuren/namen'; import type { Id, ProjektDaten } from '../model/types'; import { wortzahl } from '../text'; import { normseitenZaehlen } from './normseite'; @@ -162,8 +163,8 @@ export function exposeGeruest(daten: ProjektDaten, optionen: ExposeOptionen): Ex bloecke.push({ art: 'ueberschrift', ebene: 1, text: t.figuren }); const figuren = Object.values(daten.figuren) - .filter((f) => optionen.nebenfiguren !== false || f.rolle === 'haupt') - .sort((a, b) => (a.rolle === b.rolle ? a.name.localeCompare(b.name, sprache) : a.rolle === 'haupt' ? -1 : 1)); + .filter((f) => f.rolle !== 'statist' && (optionen.nebenfiguren !== false || f.rolle === 'haupt')) + .sort(nachRolle((a, b) => a.name.localeCompare(b.name, sprache))); if (figuren.length === 0) bloecke.push({ art: 'absatz', text: [P(t.figurenFrage)] }); for (const f of figuren) { bloecke.push({ art: 'punkt', text: [F(f.name), T(' – '), f.kurz?.trim() ? T(f.kurz.trim()) : P(t.kurzbeschreibung)] }); diff --git a/core/abgabe/index.ts b/core/abgabe/index.ts index 7220c38..7a101e7 100644 --- a/core/abgabe/index.ts +++ b/core/abgabe/index.ts @@ -10,6 +10,7 @@ export { type ExposeOptionen, type ExposeStueck, } from './expose'; +export { personenAuswahl, personenKapitel, personenverzeichnis, type PersonenOptionen } from './personen'; export { exportiereLektoratsmappe, HINWEIS_TYP_NAME, diff --git a/core/abgabe/personen.test.ts b/core/abgabe/personen.test.ts new file mode 100644 index 0000000..477b1a9 --- /dev/null +++ b/core/abgabe/personen.test.ts @@ -0,0 +1,58 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { demoFs } from '../../fixtures/demoProjekt'; +import { berechneIndex } from '../index/index'; +import type { ProjektDaten } from '../model/types'; +import { ladeProjekt } from '../projekt'; +import { exposeMarkdown } from './expose'; +import { personenAuswahl, personenKapitel, personenverzeichnis } from './personen'; + +let daten: ProjektDaten; +let erst: Record; +beforeAll(async () => { + daten = await ladeProjekt(demoFs()); + erst = berechneIndex(daten).erstesVorkommen; +}); + +describe('Personenverzeichnis', () => { + it('nach Gruppen: Hauptgruppen als Abschnitte, Rest unter „Weitere Figuren“; nie private Felder', () => { + const d: ProjektDaten = { + ...daten, + figuren: { ...daten.figuren, ole: { ...daten.figuren.ole!, aliase: ['Fenster-Ole'], wahrheit: 'GEHEIM', notizen: 'PRIVAT' } }, + }; + const md = exposeMarkdown(personenverzeichnis(d, erst, { gliederung: 'gruppen' })); + expect(md).toMatch(/^# Personen/); + expect(md).toContain('## Nordhaven'); + expect(md).toContain('## Weitere Figuren'); + expect(md).toContain('**Ole**'); + expect(md).toContain('auch „Fenster-Ole“'); + expect(md).not.toContain('GEHEIM'); + expect(md).not.toContain('PRIVAT'); + // bewusst vage Figuren bleiben draußen, geplante (noch nicht im Text) auch + const vage = Object.values(d.figuren).find((f) => f.existenz === 'bewusst_vage'); + if (vage) expect(md).not.toContain(vage.name); + }); + + it('nach Rolle; Spitznamen abschaltbar; Statisten nur auf Wunsch', () => { + const d: ProjektDaten = { ...daten, figuren: { ...daten.figuren, malte: { ...daten.figuren.malte!, rolle: 'statist', aliase: ['M.'] } } }; + const ohne = exposeMarkdown(personenverzeichnis(d, erst, { gliederung: 'rolle', spitznamen: false })); + expect(ohne).toContain('## Hauptfiguren'); + expect(ohne).not.toContain('Malte'); + const mit = exposeMarkdown(personenverzeichnis(d, erst, { gliederung: 'rolle', statisten: true, spitznamen: false })); + expect(mit).toContain('**Malte**'); + expect(mit).not.toContain('„M.“'); + }); + + it('nur bis Kapitel X: keine Figuren, die erst später auftauchen', () => { + const erstes = daten.projekt.kapitel[0]!; + const bis = personenAuswahl(daten, erst, { gliederung: 'rolle', bisKapitel: erstes }); + expect(bis.every((f) => erst[f.id] === erstes)).toBe(true); + expect(bis.length).toBeLessThan(personenAuswahl(daten, erst, { gliederung: 'rolle' }).length); + }); + + it('als Vorspann-Kapitel (Markdown mit fett/kursiv)', () => { + const k = personenKapitel(personenverzeichnis(daten, erst, { gliederung: 'rolle' })); + expect(k.titel).toBe('Personen'); + expect(k.text).toContain('## Hauptfiguren'); + expect(k.text).toMatch(/\*\*Paul\*\*/); + }); +}); diff --git a/core/abgabe/personen.ts b/core/abgabe/personen.ts new file mode 100644 index 0000000..2a5a6f5 --- /dev/null +++ b/core/abgabe/personen.ts @@ -0,0 +1,109 @@ +/** + * Personenverzeichnis (G7 Abgabe): Figuren nach Gruppen oder Rolle, je Eintrag Name, Spitznamen, Kurzbeschreibung + * und Rolle in der Gruppe. Liefert dieselben Blöcke wie das Exposé (exposeDocx/exposeMarkdown) und als Markdown + * für einen Vorspann vor EPUB und Normseite. Private Felder (`wahrheit`, `notizen`, Planfelder) kommen nie hinein. + */ +import { fmt, texteJe, type Sprache } from '../i18n'; +import type { Figur, Id, Kapitel, ProjektDaten } from '../model/types'; +import { nachRolle } from '../figuren/namen'; +import type { ExposeBlock, ExposeStueck } from './expose'; + +const T = texteJe( + { + titel: 'Personen', + haupt: 'Hauptfiguren', + neben: 'Nebenfiguren', + statist: 'Weitere Figuren', + ohneGruppe: 'Weitere Figuren', + auch: 'auch {namen}', + zitat: '„{text}“', + keine: 'Noch keine Figuren.', + }, + { + titel: 'Characters', + haupt: 'Main characters', + neben: 'Supporting characters', + statist: 'Other characters', + ohneGruppe: 'Other characters', + auch: 'also {namen}', + zitat: '“{text}”', + keine: 'No characters yet.', + }, +); + +export interface PersonenOptionen { + /** nach Gruppen (Hauptgruppe) oder nach Rolle */ + gliederung: 'gruppen' | 'rolle'; + /** Statisten aufnehmen (Standard: nein) */ + statisten?: boolean; + /** Spitznamen nennen (Standard: ja) */ + spitznamen?: boolean; + /** nur Figuren, die bis zu diesem Kapitel im Text vorkommen (kein Spoiler); fehlt = alle im Text */ + bisKapitel?: Id; + /** auch Figuren, die noch nicht im Text vorkommen (Standard: nein) */ + geplante?: boolean; + /** „bewusst vage“ Figuren aufnehmen (Standard: nein – sie sollen offen bleiben) */ + vage?: boolean; +} + +/** Figuren für das Verzeichnis (Filter nach Optionen), in Listenreihenfolge. */ +export function personenAuswahl(daten: ProjektDaten, erstesVorkommen: Record, optionen: PersonenOptionen): Figur[] { + const reihe = daten.projekt.kapitel; + const bis = optionen.bisKapitel ? reihe.indexOf(optionen.bisKapitel) : reihe.length - 1; + return Object.values(daten.figuren) + .filter((f) => optionen.statisten || f.rolle !== 'statist') + .filter((f) => optionen.vage || f.existenz !== 'bewusst_vage') + .filter((f) => { + const erst = erstesVorkommen[f.id]; + if (!erst) return !!optionen.geplante && !optionen.bisKapitel; + return reihe.indexOf(erst) <= bis; + }) + .sort(nachRolle((a, b) => a.name.localeCompare(b.name, 'de'))); +} + +function eintrag(f: Figur, daten: ProjektDaten, optionen: PersonenOptionen, sprache: Sprache): ExposeStueck[] { + const t = T(sprache); + const teile: ExposeStueck[] = [{ text: f.name, art: 'fett' }]; + const zusatz: string[] = []; + if (optionen.spitznamen !== false && f.aliase.length) zusatz.push(fmt(t.auch, { namen: f.aliase.map((a) => fmt(t.zitat, { text: a })).join(', ') })); + const rolle = f.gruppe ? daten.gruppen[f.gruppe]?.rollen?.[f.id] : undefined; + const beschreibung = [rolle, f.kurz?.trim()].filter(Boolean).join(', '); + if (beschreibung) teile.push({ text: ` – ${beschreibung}` }); + if (zusatz.length) teile.push({ text: ` (${zusatz.join('; ')})`, art: 'kursiv' }); + return teile; +} + +/** Blöcke des Verzeichnisses (Überschrift, Abschnitte, Einträge als Aufzählung). */ +export function personenverzeichnis(daten: ProjektDaten, erstesVorkommen: Record, optionen: PersonenOptionen, sprache: Sprache = 'de'): ExposeBlock[] { + const t = T(sprache); + const figuren = personenAuswahl(daten, erstesVorkommen, optionen); + const bloecke: ExposeBlock[] = [{ art: 'titel', text: t.titel }]; + if (!figuren.length) return [...bloecke, { art: 'absatz', text: [{ text: t.keine }] }]; + const abschnitt = (titel: string, liste: Figur[]) => { + if (!liste.length) return; + bloecke.push({ art: 'ueberschrift', ebene: 1, text: titel }); + for (const f of liste) bloecke.push({ art: 'punkt', text: eintrag(f, daten, optionen, sprache) }); + }; + if (optionen.gliederung === 'rolle') { + abschnitt(t.haupt, figuren.filter((f) => f.rolle === 'haupt')); + abschnitt(t.neben, figuren.filter((f) => f.rolle === 'neben')); + abschnitt(t.statist, figuren.filter((f) => f.rolle === 'statist')); + return bloecke; + } + const gruppen = Object.values(daten.gruppen).sort((a, b) => a.name.localeCompare(b.name, sprache)); + for (const g of gruppen) abschnitt(g.name, figuren.filter((f) => f.gruppe === g.id)); + abschnitt(t.ohneGruppe, figuren.filter((f) => !f.gruppe || !daten.gruppen[f.gruppe])); + return bloecke; +} + +/** Als Kapitel für den Vorspann von EPUB und Normseite (Markdown wie im Manuskript: **fett**, *kursiv*). */ +export function personenKapitel(bloecke: ExposeBlock[]): Kapitel { + const stuecke = (s: ExposeStueck[]) => s.map((x) => (x.art === 'fett' ? `**${x.text}**` : x.art === 'kursiv' ? ` *${x.text.trim()}*` : x.text)).join(''); + const titel = bloecke.find((b) => b.art === 'titel'); + const absaetze: string[] = []; + for (const b of bloecke) { + if (b.art === 'ueberschrift') absaetze.push(`## ${b.text}`); + else if (b.art === 'punkt' || b.art === 'absatz') absaetze.push(stuecke(b.text)); + } + return { id: 'personenverzeichnis', titel: titel?.art === 'titel' ? titel.text : '', text: absaetze.join('\n\n') }; +} diff --git a/core/figuren/besetzung.ts b/core/figuren/besetzung.ts new file mode 100644 index 0000000..cbb8642 --- /dev/null +++ b/core/figuren/besetzung.ts @@ -0,0 +1,65 @@ +/** + * F-15 Besetzungsübersicht: Figuren × Kapitel. Je Zelle die wichtigste Art des Vorkommens (tritt auf > per + * Nachricht > erwähnt > im Text, Art offen) oder „plan“, wenn die Figur nur im Kapitelplan steht. Reine Ableitung + * aus dem Index, keine eigene Datei. + */ +import type { Erwaehnung, ErwaehnungsArt, Figur, Id, ProjektDaten } from '../model/types'; +import { ROLLEN } from './namen'; + +export type BesetzungsZelle = ErwaehnungsArt | 'plan' | null; + +export interface BesetzungsZeile { + figur: Figur; + /** je Kapitel (Reihenfolge wie `kapitel`) */ + zellen: BesetzungsZelle[]; + /** erste Erwähnung je Kapitel (zum Springen) */ + erste: (Erwaehnung | null)[]; + /** Kapitel mit Vorkommen im Text */ + anzahl: number; +} + +export interface Besetzung { + kapitel: Id[]; + zeilen: BesetzungsZeile[]; + /** Figuren mit Vorkommen im Text je Kapitel */ + summen: number[]; +} + +/** Rang der Arten (gleiche Reihenfolge wie im Auftrittsstreifen der Figurenkarte). */ +export const ART_RANG: Record = { auftritt: 4, nachricht: 3, erwaehnt: 2, unbestimmt: 1 }; + +export function besetzung(daten: ProjektDaten, erwaehnungen: Erwaehnung[]): Besetzung { + const kapitel = [...daten.projekt.kapitel]; + const pos = new Map(kapitel.map((id, i) => [id, i])); + const figuren = Object.values(daten.figuren).sort( + (a, b) => ROLLEN.indexOf(a.rolle) - ROLLEN.indexOf(b.rolle) || a.name.localeCompare(b.name, 'de'), + ); + const zeilen = new Map( + figuren.map((f) => [f.id, { figur: f, zellen: kapitel.map(() => null), erste: kapitel.map(() => null), anzahl: 0 }]), + ); + for (const e of erwaehnungen) { + if (e.zielTyp !== 'figur') continue; + const z = zeilen.get(e.ziel); + const i = pos.get(e.kapitel); + if (!z || i === undefined) continue; + const alt = z.zellen[i]; + if (!alt || alt === 'plan' || ART_RANG[e.art] > ART_RANG[alt]) z.zellen[i] = e.art; + if (!z.erste[i] || e.start < z.erste[i]!.start) z.erste[i] = e; + } + kapitel.forEach((id, i) => { + for (const f of daten.plaene[id]?.figuren ?? []) { + const z = zeilen.get(f); + if (z && !z.zellen[i]) z.zellen[i] = 'plan'; + } + }); + const summen = kapitel.map(() => 0); + for (const z of zeilen.values()) { + z.zellen.forEach((c, i) => { + if (c && c !== 'plan') { + z.anzahl += 1; + summen[i]! += 1; + } + }); + } + return { kapitel, zeilen: [...zeilen.values()], summen }; +} diff --git a/core/figuren/fakten.test.ts b/core/figuren/fakten.test.ts new file mode 100644 index 0000000..9d8b593 --- /dev/null +++ b/core/figuren/fakten.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { demoFs } from '../../fixtures/demoProjekt'; +import type { FigurFakt, ProjektDaten } from '../model/types'; +import { ladeProjekt, parseKiFakten, serializeKiFakten } from '../projekt'; +import { faktenDerFigur, faktenHinweise, mischeFakten, setzeFaktStatus, werteVertraeglich, wertForm } from './index'; + +const SAAB = 'Dann nimmt er den Schlüssel und fährt mit seinem alten Saab 900 zum Lindenhof.'; +const VOLVO = 'Ich steige aus dem Volvo, schlage die Tür zu und sehe ihm nach, bis er hinter den Lagerhallen verschwunden ist.'; +const ORGELN = 'Vera erzählt von ihrer Werkstatt, in der sie alte Orgeln restauriert, Paul hört nur halb zu.'; + +const fakt = (figur: string, merkmal: string, wert: string, kapitel: string, text: string): FigurFakt => ({ + schluessel: `fakt:${figur}|${merkmal.toLowerCase()}|${kapitel}/${text.slice(0, 20)}`, + figur, + merkmal, + wert, + kapitel, + zitat: { kapitel, text }, + status: 'neu', +}); + +let daten: ProjektDaten; +beforeEach(async () => { + daten = await ladeProjekt(demoFs()); +}); + +describe('Figurenfakten: Vergleich, Widersprüche, Planung (F-17)', () => { + it('Werte: Füllwörter fallen weg, enthalten = verträglich', () => { + expect(wertForm('seinem alten Saab 900')).toBe('seinem saab 900'); + expect(werteVertraeglich('alter Saab 900', 'Saab')).toBe(true); + expect(werteVertraeglich('Saab', 'Volvo')).toBe(false); + expect(werteVertraeglich('Architekt', 'architekt')).toBe(true); + }); + + it('Widerspruch beim gleichen Merkmal → Hinweis „pruefen“ mit Figur-Bezug und beiden Zitaten', () => { + daten.kiFakten = { version: 1, eintraege: [fakt('paul', 'Auto', 'alter Saab 900', 'lotte', SAAB), fakt('paul', 'auto', 'Volvo', 'flut', VOLVO)] }; + const g = faktenDerFigur(daten, 'paul'); + expect(g).toHaveLength(1); + expect(g[0]!.widerspruch).toBe(true); + const h = faktenHinweise(daten).filter((x) => x.schluessel === 'fakt:widerspruch:paul|auto'); + expect(h).toHaveLength(1); + expect(h[0]).toMatchObject({ typ: 'pruefen', plan: { figur: 'paul' } }); + expect(h[0]!.zitate.map((z) => z.text)).toEqual([SAAB, VOLVO]); + }); + + it('Planabweichung: Planfeld gleichen Namens, kein Textwert passt', () => { + daten.figuren.vera = { ...daten.figuren.vera!, plan: { ...daten.figuren.vera!.plan, Beruf: 'Geigenbauerin' } }; + daten.kiFakten = { version: 1, eintraege: [fakt('vera', 'Beruf', 'restauriert alte Orgeln', 'ebbe', ORGELN)] }; + const g = faktenDerFigur(daten, 'vera')[0]!; + expect(g.plan).toEqual({ feld: 'Beruf', wert: 'Geigenbauerin', abweichend: true }); + expect(faktenHinweise(daten).map((x) => x.schluessel)).toContain('fakt:plan:vera|beruf'); + // passender Plan: kein Hinweis + daten.figuren.vera = { ...daten.figuren.vera!, plan: { ...daten.figuren.vera!.plan, Beruf: 'restauriert Orgeln' } }; + expect(faktenHinweise(daten).filter((x) => x.schluessel.startsWith('fakt:plan:vera'))).toEqual([]); + }); + + it('verworfene und nicht mehr gültige Angaben zählen nicht; Mischen ersetzt nur die abgefragten Figuren', () => { + const a = fakt('paul', 'Auto', 'alter Saab 900', 'lotte', SAAB); + const b = fakt('paul', 'Auto', 'Volvo', 'flut', VOLVO); + const v = fakt('vera', 'Beruf', 'Orgeln', 'ebbe', ORGELN); + let ablage = mischeFakten(undefined, ['paul', 'vera'], [a, b, v]).ablage; + ablage = setzeFaktStatus(ablage, b.schluessel, 'verworfen'); + daten.kiFakten = ablage; + expect(faktenDerFigur(daten, 'paul')[0]!.widerspruch).toBe(false); + // neuer Lauf nur für Paul: Verworfenes kommt nicht wieder, Vera bleibt + const zweiter = mischeFakten(ablage, ['paul'], [a, b]); + expect(zweiter.ablage.eintraege.filter((x) => x.figur === 'paul').map((x) => x.status).sort()).toEqual(['neu', 'verworfen']); + expect(zweiter.ablage.eintraege.some((x) => x.figur === 'vera')).toBe(true); + expect(zweiter.neu).toBe(0); + // Ablage übersteht Speichern + expect(parseKiFakten(serializeKiFakten(zweiter.ablage))).toEqual(zweiter.ablage); + // geänderter Text: Beleg nicht mehr gültig + daten.kapitel.lotte = { ...daten.kapitel.lotte!, text: 'Ganz anders.' }; + expect(faktenDerFigur(daten, 'paul')).toEqual([]); + }); +}); diff --git a/core/figuren/fakten.ts b/core/figuren/fakten.ts new file mode 100644 index 0000000..ed71c8f --- /dev/null +++ b/core/figuren/fakten.ts @@ -0,0 +1,167 @@ +/** + * F-17 Figurenfakten: Ablage der KI-Angaben (.plotwerk/figurenfakten.json), Gruppierung je Merkmal, Widersprüche + * (gleiches Merkmal, verschiedene Werte) und Abweichungen von der Planung – lokal, ohne KI. + * Widersprüche werden Hinweise vom Typ „pruefen“ mit Figur-Bezug (bewusst Vages filtert hinweiseBerechnen). + */ +import { fmt, texteJe, type Sprache } from '../i18n'; +import type { FigurFakt, Hinweis, Id, KiAblage, ProjektDaten } from '../model/types'; +import { findeZitat, klartext } from '../text'; + +const T = texteJe( + { + widerspruch: '{figur}: {merkmal} – {werte}?', + widerspruchErklaerung: 'Der Text nennt für {figur} verschiedene Angaben zu „{merkmal}“: {liste}.', + plan: '{figur}: {merkmal} laut Planung {plan}, im Text {text}', + planErklaerung: 'Im Planungsfeld „{feld}“ steht {plan}, der Text sagt {text} ({nr}).', + nr: 'Kap. {n}', + zitat: '„{text}“', + oder: ' oder ', + }, + { + widerspruch: '{figur}: {merkmal} – {werte}?', + widerspruchErklaerung: 'The text gives different values for {figur}’s “{merkmal}”: {liste}.', + plan: '{figur}: {merkmal} planned as {plan}, in the text {text}', + planErklaerung: 'The plan field “{feld}” says {plan}, the text says {text} ({nr}).', + nr: 'Ch. {n}', + zitat: '“{text}”', + oder: ' or ', + }, +); + +export function leereFakten(): KiAblage { + return { version: 1, eintraege: [] }; +} + +/** Vergleichsform eines Merkmals */ +export const merkmalForm = (s: string) => s.trim().toLocaleLowerCase('de'); + +const FUELLWOERTER = new Set(['der', 'die', 'das', 'den', 'dem', 'des', 'ein', 'eine', 'einen', 'einem', 'einer', 'sein', 'seine', 'seinen', 'ihr', 'ihre', 'ihren', 'alt', 'alte', 'alter', 'alten', 'altes', 'the', 'a', 'an', 'his', 'her', 'their', 'old']); + +/** Vergleichsform eines Werts: klein, ohne Satzzeichen und Füllwörter („seinem alten Saab 900“ → „saab 900“). */ +export function wertForm(s: string): string { + return s + .toLocaleLowerCase('de') + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .split(/\s+/u) + .filter((w) => w && !FUELLWOERTER.has(w)) + .join(' '); +} + +/** Zwei Werte passen zusammen, wenn einer im anderen steckt („Saab“ ~ „alter Saab 900“). */ +export function werteVertraeglich(a: string, b: string): boolean { + const x = wertForm(a); + const y = wertForm(b); + if (!x || !y) return true; + return x === y || x.includes(y) || y.includes(x); +} + +/** + * Lauf in die Ablage mischen: Angaben der abgefragten Figuren werden ersetzt, verworfene bleiben verworfen und + * kommen nicht wieder; andere Figuren bleiben unberührt. + */ +export function mischeFakten( + ablage: KiAblage | undefined, + figuren: Id[], + neu: FigurFakt[], + lauf?: KiAblage['lauf'], +): { ablage: KiAblage; neu: number } { + const a = ablage ?? leereFakten(); + const betroffen = new Set(figuren); + const verworfen = a.eintraege.filter((f) => betroffen.has(f.figur) && f.status === 'verworfen'); + const weg = new Set(verworfen.map((f) => f.schluessel)); + const vorher = new Set(a.eintraege.map((f) => f.schluessel)); + const frisch = neu.filter((f) => !weg.has(f.schluessel)).map((f) => ({ ...f, status: 'neu' as const })); + return { + ablage: { ...a, version: 1, eintraege: [...a.eintraege.filter((f) => !betroffen.has(f.figur)), ...verworfen, ...frisch], ...(lauf ? { lauf } : {}) }, + neu: frisch.filter((f) => !vorher.has(f.schluessel)).length, + }; +} + +/** Ablage mit verworfener (bzw. wiederhergestellter) Angabe. */ +export function setzeFaktStatus(ablage: KiAblage | undefined, schluessel: string, status: FigurFakt['status']): KiAblage { + const a = ablage ?? leereFakten(); + return { ...a, eintraege: a.eintraege.map((f) => (f.schluessel === schluessel ? { ...f, status } : f)) }; +} + +/** Steht der Beleg noch wörtlich im Text? */ +export function faktGueltig(daten: ProjektDaten, f: FigurFakt): boolean { + const md = daten.kapitel[f.zitat.kapitel]?.text; + return !!md && findeZitat(klartext(md), f.zitat.text).length > 0; +} + +export interface MerkmalGruppe { + merkmal: string; + fakten: FigurFakt[]; + /** verschiedene, nicht verträgliche Werte */ + widerspruch: boolean; + /** Planfeld gleichen Namens mit Wert, der zu keinem Textwert passt */ + plan?: { feld: string; wert: string; abweichend: boolean }; +} + +/** Gültige, nicht verworfene Angaben einer Figur je Merkmal (Buchreihenfolge), mit Widerspruch und Planabgleich. */ +export function faktenDerFigur(daten: ProjektDaten, figur: Id): MerkmalGruppe[] { + const reihe = daten.projekt.kapitel; + const f = daten.figuren[figur]; + const liste = (daten.kiFakten?.eintraege ?? []) + .filter((x) => x.figur === figur && x.status !== 'verworfen' && faktGueltig(daten, x)) + .sort((a, b) => reihe.indexOf(a.kapitel) - reihe.indexOf(b.kapitel)); + const gruppen = new Map(); + for (const x of liste) { + const k = merkmalForm(x.merkmal); + const g = gruppen.get(k); + if (g) g.fakten.push(x); + else gruppen.set(k, { merkmal: x.merkmal, fakten: [x], widerspruch: false }); + } + for (const [k, g] of gruppen) { + g.widerspruch = g.fakten.some((a, i) => g.fakten.slice(i + 1).some((b) => !werteVertraeglich(a.wert, b.wert))); + const feld = f ? Object.keys(f.plan).find((p) => merkmalForm(p) === k && f.plan[p]?.trim()) : undefined; + if (f && feld) { + const wert = f.plan[feld]!.trim(); + g.plan = { feld, wert, abweichend: g.fakten.every((x) => !werteVertraeglich(x.wert, wert)) }; + } + } + return [...gruppen.values()]; +} + +/** Widersprüche und Planabweichungen als Hinweise (Typ „pruefen“, Figur-Bezug). */ +export function faktenHinweise(daten: ProjektDaten, sprache: Sprache = 'de'): Hinweis[] { + if (!daten.kiFakten?.eintraege.length) return []; + const t = T(sprache); + const pos = new Map(daten.projekt.kapitel.map((id, i) => [id, i + 1])); + const nr = (k: Id) => fmt(t.nr, { n: pos.get(k) ?? '?' }); + const q = (x: string) => fmt(t.zitat, { text: x }); + const ergebnis: Hinweis[] = []; + const figuren = [...new Set(daten.kiFakten.eintraege.map((f) => f.figur))].filter((id) => daten.figuren[id]); + for (const id of figuren) { + const f = daten.figuren[id]!; + for (const g of faktenDerFigur(daten, id)) { + if (g.widerspruch) { + const werte = [...new Map(g.fakten.map((x) => [wertForm(x.wert), x.wert])).values()]; + ergebnis.push({ + schluessel: `fakt:widerspruch:${id}|${merkmalForm(g.merkmal)}`, + typ: 'pruefen', + titel: fmt(t.widerspruch, { figur: f.name, merkmal: g.merkmal, werte: werte.map(q).join(t.oder) }), + erklaerung: fmt(t.widerspruchErklaerung, { figur: f.name, merkmal: g.merkmal, liste: g.fakten.map((x) => `${nr(x.kapitel)} ${q(x.wert)}`).join(', ') }), + zitate: g.fakten.map((x) => x.zitat), + quelle: 'ki', + plan: { figur: id }, + status: 'neu', + }); + } + if (g.plan?.abweichend) { + const x = g.fakten[0]!; + ergebnis.push({ + schluessel: `fakt:plan:${id}|${merkmalForm(g.plan.feld)}`, + typ: 'pruefen', + titel: fmt(t.plan, { figur: f.name, merkmal: g.merkmal, plan: q(g.plan.wert), text: q(x.wert) }), + erklaerung: fmt(t.planErklaerung, { feld: g.plan.feld, plan: q(g.plan.wert), text: q(x.wert), nr: nr(x.kapitel) }), + zitate: [x.zitat], + quelle: 'ki', + plan: { figur: id, feld: g.plan.feld }, + status: 'neu', + }); + } + } + } + return ergebnis; +} diff --git a/core/figuren/hinweise.test.ts b/core/figuren/hinweise.test.ts new file mode 100644 index 0000000..123fa52 --- /dev/null +++ b/core/figuren/hinweise.test.ts @@ -0,0 +1,186 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { demoFs } from '../../fixtures/demoProjekt'; +import { berechneIndex } from '../index/index'; +import type { Figur, ProjektDaten } from '../model/types'; +import { ladeProjekt } from '../projekt'; +import { klartext } from '../text'; +import { figurenHinweise, ruhendeStrecken, satzUm } from './index'; + +let demo: ProjektDaten; +beforeAll(async () => { + demo = await ladeProjekt(demoFs()); +}); + +const figur = (id: string, name: string, teil: Partial = {}): Figur => ({ + id, + name, + aliase: [], + rolle: 'neben', + existenz: 'gesichert', + gruppe: null, + beziehungen: [], + plan: {}, + wahrheit: '', + notizen: '', + ...teil, +}); + +/** Kleines Projekt aus Kapiteltexten (k1, k2, …) und Figuren, auf Basis der Demo-Struktur. */ +function projekt(texte: string[], figuren: Figur[]): ProjektDaten { + const ids = texte.map((_, i) => `k${i + 1}`); + return { + ...demo, + projekt: { ...demo.projekt, kapitel: ids }, + kapitel: Object.fromEntries(ids.map((id, i) => [id, { id, titel: id, text: texte[i]! }])), + plaene: {}, + figuren: Object.fromEntries(figuren.map((f) => [f.id, f])), + gruppen: {}, + orte: {}, + kiHinweise: [], + }; +} + +function hinweise(d: ProjektDaten, art: string) { + return figurenHinweise(d, berechneIndex(d).erwaehnungen).filter((h) => h.schluessel.startsWith(`figuren:${art}:`)); +} + +describe('Satz um eine Stelle', () => { + it('liefert den ganzen Satz, wörtlich', () => { + const t = 'Erster Satz. Paul geht zum Kai. Dritter Satz.'; + const i = t.indexOf('Paul'); + expect(satzUm(t, i, i + 4)).toBe('Paul geht zum Kai.'); + }); +}); + +describe('F-11 Spitzname vor dem Namen', () => { + it('meldet den ersten Spitznamen vor dem ersten Namen, mit beiden Sätzen als Zitat', () => { + const d = projekt(['Der Alte stand am Fenster.', 'Später sagte Henrik nichts.'], [figur('henrik', 'Henrik', { aliase: ['Alte'] })]); + const h = hinweise(d, 'vorher'); + expect(h).toHaveLength(1); + expect(h[0]!.schluessel).toBe('figuren:vorher:henrik|alte'); + expect(h[0]!.zitate).toEqual([ + { kapitel: 'k1', text: 'Der Alte stand am Fenster.' }, + { kapitel: 'k2', text: 'Später sagte Henrik nichts.' }, + ]); + for (const z of h[0]!.zitate) expect(klartext(d.kapitel[z.kapitel]!.text)).toContain(z.text); + }); + + it('nicht, wenn der Name zuerst fällt (auch im Genitiv), ohne Namen im Text oder bei „bewusst vage“', () => { + expect(hinweise(projekt(['Henriks Hut lag da. Der Alte kam.'], [figur('henrik', 'Henrik', { aliase: ['Alte'] })]), 'vorher')).toEqual([]); + expect(hinweise(projekt(['Der Alte kam.'], [figur('henrik', 'Henrik', { aliase: ['Alte'] })]), 'vorher')).toEqual([]); + expect( + hinweise(projekt(['Der Alte kam.', 'Henrik ging.'], [figur('henrik', 'Henrik', { aliase: ['Alte'], existenz: 'bewusst_vage' })]), 'vorher'), + ).toEqual([]); + }); +}); + +describe('F-12 verwechselbare Namen', () => { + it('ein Buchstabe Unterschied, gleicher Anfang bei gleicher Länge', () => { + const d = projekt( + ['Henrik kam mit Hendrik. Sabine und Sabina lachten.'], + [figur('henrik', 'Henrik'), figur('hendrik', 'Hendrik'), figur('sabine', 'Sabine'), figur('sabina', 'Sabina')], + ); + expect(hinweise(d, 'aehnlich').map((h) => h.schluessel)).toEqual([ + 'figuren:aehnlich:hendrik|henrik|Hendrik|Henrik', + 'figuren:aehnlich:sabina|sabine|Sabina|Sabine', + ]); + const anfang = projekt(['Marina und Marlis kamen.'], [figur('marina', 'Marina'), figur('marlis', 'Marlis')]); + expect(hinweise(anfang, 'aehnlich')).toHaveLength(1); + }); + + it('nicht bei deutlich verschiedenen Namen oder wenn eine Figur nicht im Text vorkommt', () => { + expect(hinweise(projekt(['Paul und Vera.'], [figur('paul', 'Paul'), figur('vera', 'Vera')]), 'aehnlich')).toEqual([]); + expect(hinweise(projekt(['Henrik kam.'], [figur('henrik', 'Henrik'), figur('hendrik', 'Hendrik')]), 'aehnlich')).toEqual([]); + }); +}); + +describe('F-13 ruhende Figuren', () => { + const texte = ['Ole kam.', 'Leer eins.', 'Leer zwei.', 'Leer drei.', 'Ole ging.', 'Leer vier.']; + + it('Lücke ab der Schwelle (Hauptfigur 3), Hinweis mit Schlüssel an Figur und letztem Kapitel', () => { + const d = projekt(texte, [figur('ole', 'Ole', { rolle: 'haupt' })]); + const h = hinweise(d, 'ruht'); + expect(h.map((x) => x.schluessel)).toEqual(['figuren:ruht:ole@k1']); + expect(h[0]!.zitate).toEqual([{ kapitel: 'k1', text: 'Ole kam.' }]); + }); + + it('Nebenfigur erst ab 6; eigene Schwelle; null = nie', () => { + const f = figur('ole', 'Ole'); + const d = projekt(texte, [f]); + const { erwaehnungen } = berechneIndex(d); + expect(ruhendeStrecken(d, erwaehnungen, f)).toEqual([]); + expect(ruhendeStrecken(d, erwaehnungen, { ...f, ruheSchwelle: 1 })).toEqual([ + { nach: 'k1', kapitel: ['k2', 'k3', 'k4'] }, + { nach: 'k5', kapitel: ['k6'] }, + ]); + expect(ruhendeStrecken(d, erwaehnungen, { ...f, rolle: 'haupt', ruheSchwelle: null })).toEqual([]); + }); + + it('Kapitelplan zählt als Vorkommen (Ich-Erzähler ohne Namen)', () => { + const f = figur('ole', 'Ole', { rolle: 'haupt' }); + const d = projekt(texte, [f]); + d.plaene = { k3: { ...Object.values(demo.plaene)[0]!, id: 'k3', figuren: ['ole'] } }; + expect(ruhendeStrecken(d, berechneIndex(d).erwaehnungen, f)).toEqual([]); + }); +}); + +describe('F-14 Statist in vielen Kapiteln', () => { + it('ab drei Kapiteln „zur Nebenfigur machen?“; nicht für Nebenfiguren', () => { + const texte = ['Die Kellnerin Uta kam.', 'Uta brachte Kaffee.', 'Uta lachte.']; + expect(hinweise(projekt(texte, [figur('uta', 'Uta', { rolle: 'statist' })]), 'aufsteigen').map((h) => h.schluessel)).toEqual(['figuren:aufsteigen:uta']); + expect(hinweise(projekt(texte.slice(0, 2), [figur('uta', 'Uta', { rolle: 'statist' })]), 'aufsteigen')).toEqual([]); + expect(hinweise(projekt(texte, [figur('uta', 'Uta')]), 'aufsteigen')).toEqual([]); + }); + + it('Statisten ruhen nie und zählen nicht als verwechselbar', () => { + const d = projekt( + ['Sabine kam.', 'a.', 'b.', 'c.', 'd.', 'e.', 'f.', 'Sabine ging. Sabina auch.'], + [figur('sabine', 'Sabine', { rolle: 'statist', ruheSchwelle: undefined }), figur('sabina', 'Sabina')], + ); + expect(hinweise(d, 'ruht').filter((h) => h.schluessel.includes('sabine'))).toEqual([]); + expect(hinweise(d, 'aehnlich')).toEqual([]); + }); +}); + +describe('F-15 Besetzung', () => { + it('Figuren × Kapitel mit wichtigster Art, Kapitelplan als „plan“, Summen je Kapitel', async () => { + const { besetzung } = await import('./besetzung'); + const d = projekt(['Ole kam. Paul sah ihn.', 'Paul schrieb.', 'Leer.'], [figur('ole', 'Ole', { rolle: 'haupt' }), figur('paul', 'Paul', { rolle: 'haupt' }), figur('uta', 'Uta', { rolle: 'statist' })]); + d.plaene = { k3: { ...Object.values(demo.plaene)[0]!, id: 'k3', figuren: ['ole'] } }; + const b = besetzung(d, berechneIndex(d).erwaehnungen); + expect(b.kapitel).toEqual(['k1', 'k2', 'k3']); + expect(b.zeilen.map((z) => z.figur.id)).toEqual(['ole', 'paul', 'uta']); + const ole = b.zeilen[0]!; + expect(ole.zellen[0]).toBeTruthy(); + expect(ole.zellen[1]).toBeNull(); + expect(ole.zellen[2]).toBe('plan'); + expect(ole.anzahl).toBe(1); + expect(b.summen).toEqual([2, 1, 0]); + expect(ole.erste[0]?.treffer).toBe('Ole'); + }); +}); + +describe('F-16 Spitzname vor seinem geplanten Kapitel', () => { + it('meldet die erste Stelle vor „ab Kapitel“; ab dort nicht', () => { + const d = projekt(['Der Alte kam.', 'Henrik lachte.', 'Der Alte ging.'], [ + figur('henrik', 'Henrik', { aliase: ['Alte'], spitznamen: [{ name: 'Alte', abKapitel: 'k3' }] }), + ]); + const h = hinweise(d, 'frueh'); + expect(h.map((x) => x.schluessel)).toEqual(['figuren:frueh:henrik|alte']); + expect(h[0]!.zitate).toEqual([{ kapitel: 'k1', text: 'Der Alte kam.' }]); + d.figuren.henrik = { ...d.figuren.henrik!, spitznamen: [{ name: 'Alte', abKapitel: 'k1' }] }; + expect(hinweise(d, 'frueh')).toEqual([]); + }); + + it('Kontext: setzen, leeren, beim Entfernen des Namens weg; übersteht Speichern', async () => { + const { mitAliasen, mitSpitznameKontext } = await import('./namen'); + const { parseFigur, serializeFigur } = await import('../projekt'); + let f = figur('ole', 'Ole', { aliase: ['Olli'] }); + f = mitSpitznameKontext(f, { name: 'Olli', von: ['paul', 'paul'], notiz: ' Kosename ' }); + expect(f.spitznamen).toEqual([{ name: 'Olli', von: ['paul'], notiz: 'Kosename' }]); + expect(parseFigur(serializeFigur(f), 'ole').spitznamen).toEqual(f.spitznamen); + expect(mitSpitznameKontext(f, { name: 'Olli' }).spitznamen).toBeUndefined(); + expect(mitAliasen(f, ['Fenster-Ole']).spitznamen).toBeUndefined(); + expect(mitAliasen(f, ['Olli', 'olli', 'Ole', ' ']).aliase).toEqual(['Olli']); + }); +}); diff --git a/core/figuren/hinweise.ts b/core/figuren/hinweise.ts new file mode 100644 index 0000000..279ba60 --- /dev/null +++ b/core/figuren/hinweise.ts @@ -0,0 +1,297 @@ +/** + * Lokale Hinweise rund um Figurennamen (Typ „figuren“, ohne KI): + * - F-11 Spitzname, bevor der Leser ihn kennt: Die Figur wird zuerst über einen Spitznamen erkannt, ihr Name + * fällt erst später. + * - F-12 Verwechselbare Namen: Namen zweier Figuren unterscheiden sich nur in einem Buchstaben oder beginnen + * gleich und sind gleich lang. + * - F-13 Ruhende Figuren: Eine Figur kommt mehrere Kapitel lang nicht vor (wie ruhende Fäden, K-02). + * + * Schlüssel hängen an Figur und Namen bzw. Kapitel, nicht an Zitaten, damit „Ist Absicht“ Textänderungen übersteht. + */ +import { fmt, texteJe, type Sprache } from '../i18n'; +import type { Erwaehnung, Figur, Hinweis, Id, ProjektDaten, Zitat } from '../model/types'; +import { klartext } from '../text'; +import { namensVergleich } from './namen'; + +const T = texteJe( + { + nr: 'Kap. {n}', + vorher: '{alias} steht vor dem Namen {name}', + vorherErklaerung: + '{figur} wird in {nrAlias} zuerst als {alias} erkannt, der Name {name} fällt erst in {nrName}. Wer das Buch liest, weiß bis dahin vielleicht nicht, wer gemeint ist. Gewollt? Dann „Ist Absicht“.', + aehnlich: 'Leicht zu verwechseln: {a} und {b}', + aehnlichEinBuchstabe: 'Die Namen {a} ({figurA}) und {b} ({figurB}) unterscheiden sich nur in einem Buchstaben.', + aehnlichAnfang: 'Die Namen {a} ({figurA}) und {b} ({figurB}) beginnen gleich und sind gleich lang.', + ruht: '{figur} ruht seit {nr}', + ruhtErklaerung: { + one: '{figur} kommt nach {nr} ein Kapitel lang nicht mehr vor ({von}).', + other: '{figur} kommt nach {nr} {n} Kapitel lang nicht mehr vor ({von}–{bis}).', + }, + frueh: '{alias} schon in {nr}, geplant ab {ab}', + fruehErklaerung: '{figur} soll laut Planung erst ab {ab} {alias} heißen, wird aber schon in {nr} so genannt.', + aufsteigen: 'Statist {figur} in {n} Kapiteln – zur Nebenfigur machen?', + aufsteigenErklaerung: + '{figur} ist als Statist angelegt, kommt aber in {n} Kapiteln vor ({liste}). Als Nebenfigur erscheint die Figur in Liste und Netz und wird bei ruhenden Strecken gemeldet.', + zitat: '»{text}«', + }, + { + nr: 'Ch. {n}', + vorher: '{alias} appears before the name {name}', + vorherErklaerung: + '{figur} is first recognised as {alias} in {nrAlias}; the name {name} only appears in {nrName}. Readers may not know who is meant until then. Intended? Then “Intentional”.', + aehnlich: 'Easy to confuse: {a} and {b}', + aehnlichEinBuchstabe: 'The names {a} ({figurA}) and {b} ({figurB}) differ in only one letter.', + aehnlichAnfang: 'The names {a} ({figurA}) and {b} ({figurB}) start the same and have the same length.', + ruht: '{figur} has been absent since {nr}', + ruhtErklaerung: { + one: 'After {nr}, {figur} does not appear for one chapter ({von}).', + other: 'After {nr}, {figur} does not appear for {n} chapters ({von}–{bis}).', + }, + frueh: '{alias} already in {nr}, planned from {ab}', + fruehErklaerung: 'According to the plan, {figur} is only called {alias} from {ab}, but is already called that in {nr}.', + aufsteigen: 'Walk-on {figur} in {n} chapters – make a supporting character?', + aufsteigenErklaerung: + '{figur} is set up as a walk-on but appears in {n} chapters ({liste}). As a supporting character they show up in the list and the map and are reported when absent for a while.', + zitat: '“{text}”', + }, +); + +/** F-14: Ab so vielen Kapiteln wird ein Statist zum Aufstieg vorgeschlagen. */ +export const STATIST_AUFSTIEG = 3; + +/** Standard-Schwellen für ruhende Figuren (Kapitel ohne Vorkommen); Statisten nie. */ +export const RUHE_SCHWELLE = { haupt: 3, neben: 6 } as const; + +/** Schwelle einer Figur: eigene Angabe (null = nie melden), sonst nach Rolle. */ +export function ruheSchwelle(f: Figur): number | null { + if (f.ruheSchwelle !== undefined) return f.ruheSchwelle; + return f.rolle === 'haupt' ? RUHE_SCHWELLE.haupt : f.rolle === 'neben' ? RUHE_SCHWELLE.neben : null; +} + +/** Der Satz um eine Stelle, wörtlich (höchstens ~160 Zeichen, sonst Wörter um die Stelle). */ +export function satzUm(text: string, start: number, ende: number): string { + let a = start; + while (a > 0 && !/[.!?…\n]/u.test(text[a - 1]!)) a--; + let b = ende; + while (b < text.length && !/[.!?…\n]/u.test(text[b]!)) b++; + if (b < text.length && text[b] !== '\n') b++; + // schließende Anführungszeichen gehören zum Satz + while (b < text.length && /[«»“”"’']/u.test(text[b]!)) b++; + let satz = text.slice(a, b).trim(); + if (satz.length > 160) { + const vor = text.slice(Math.max(a, start - 60), start).replace(/^\S*\s/u, ''); + const nach = text.slice(ende, Math.min(b, ende + 60)).replace(/\s\S*$/u, ''); + satz = `${vor}${text.slice(start, ende)}${nach}`.trim(); + } + return satz; +} + +function reihe(daten: ProjektDaten): Id[] { + const r = [...(daten.projekt?.kapitel ?? [])]; + for (const id of Object.keys(daten.kapitel ?? {})) if (!r.includes(id)) r.push(id); + return r; +} + +/** Ist der Treffer der Name der Figur (inkl. Formen und Genitiv) – und kein Spitzname? */ +function istName(f: Figur, treffer: string): boolean { + const t = namensVergleich(treffer); + return [f.name, ...(f.formen ?? [])].some((n) => { + const v = namensVergleich(n); + return t === v || t === `${v}s` || t === `${v}’s` || t === `${v}'s` || t === `${v}’` || t === `${v}'`; + }); +} + +/** Levenshtein-Abstand, früh abgebrochen ab 2. */ +function abstandHoechstensEins(a: string, b: string): boolean { + if (Math.abs(a.length - b.length) > 1) return false; + let i = 0; + let j = 0; + let fehler = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + i++; + j++; + continue; + } + if (++fehler > 1) return false; + if (a.length > b.length) i++; + else if (b.length > a.length) j++; + else { + i++; + j++; + } + } + return fehler + (a.length - i) + (b.length - j) <= 1; +} + +/** Wörter eines Namens, die als Rufname taugen (≥ 4 Buchstaben, groß geschrieben). */ +function namensWoerter(f: Figur): string[] { + const woerter = [f.name, ...f.aliase].flatMap((n) => n.split(/\s+/u)).filter((w) => w.length >= 4 && /^\p{Lu}/u.test(w)); + return [...new Set(woerter)]; +} + +export function figurenHinweise(daten: ProjektDaten, erwaehnungen: Erwaehnung[], sprache: Sprache = 'de'): Hinweis[] { + const t = T(sprache); + const kapitel = reihe(daten); + const pos = new Map(kapitel.map((id, i) => [id, i])); + const nr = (id: Id) => fmt(t.nr, { n: (pos.get(id) ?? 0) + 1 }); + const zitatVon = (e: Erwaehnung): Zitat => ({ kapitel: e.kapitel, text: satzUm(klartext(daten.kapitel[e.kapitel]?.text ?? ''), e.start, e.ende) }); + const q = (x: string) => fmt(t.zitat, { text: x }); + const nachFigur = new Map(); + for (const e of erwaehnungen) { + if (e.zielTyp !== 'figur') continue; + const l = nachFigur.get(e.ziel); + if (l) l.push(e); + else nachFigur.set(e.ziel, [e]); + } + const hinweis = (schluessel: string, titel: string, erklaerung: string, zitate: Zitat[]): Hinweis => ({ + schluessel, + typ: 'figuren', + titel, + erklaerung, + zitate, + quelle: 'lokal', + status: 'neu', + }); + const ergebnis: Hinweis[] = []; + const figuren = Object.values(daten.figuren).sort((a, b) => a.name.localeCompare(b.name, 'de')); + + // F-11: Spitzname vor dem Namen + for (const f of figuren) { + if (f.existenz === 'bewusst_vage' || f.aliase.length === 0) continue; + const liste = (nachFigur.get(f.id) ?? []).filter((e) => e.quelle === 'exakt'); + const ersterName = liste.find((e) => istName(f, e.treffer)); + const ersterAlias = liste.find((e) => !istName(f, e.treffer)); + if (!ersterName || !ersterAlias) continue; + const vorher = + (pos.get(ersterAlias.kapitel) ?? 0) < (pos.get(ersterName.kapitel) ?? 0) || + (ersterAlias.kapitel === ersterName.kapitel && ersterAlias.start < ersterName.start); + if (!vorher) continue; + const alias = ersterAlias.treffer; + ergebnis.push( + hinweis( + `figuren:vorher:${f.id}|${namensVergleich(alias)}`, + fmt(t.vorher, { alias: q(alias), name: q(f.name) }), + fmt(t.vorherErklaerung, { figur: f.name, alias: q(alias), name: q(f.name), nrAlias: nr(ersterAlias.kapitel), nrName: nr(ersterName.kapitel) }), + [zitatVon(ersterAlias), zitatVon(ersterName)], + ), + ); + } + + // F-16: Spitzname vor seinem geplanten Kapitel + for (const f of figuren) { + for (const k of f.spitznamen ?? []) { + const ab = k.abKapitel !== undefined ? pos.get(k.abKapitel) : undefined; + if (ab === undefined) continue; + const form = namensVergleich(k.name); + const frueh = (nachFigur.get(f.id) ?? []).find((e) => { + const t2 = namensVergleich(e.treffer); + return (pos.get(e.kapitel) ?? 0) < ab && (t2 === form || t2.replace(/[’']?s$/u, '') === form); + }); + if (!frueh) continue; + ergebnis.push( + hinweis( + `figuren:frueh:${f.id}|${form}`, + fmt(t.frueh, { alias: q(k.name), nr: nr(frueh.kapitel), ab: nr(k.abKapitel!) }), + fmt(t.fruehErklaerung, { figur: f.name, alias: q(k.name), nr: nr(frueh.kapitel), ab: nr(k.abKapitel!) }), + [zitatVon(frueh)], + ), + ); + } + } + + // F-12: verwechselbare Namen (beide Figuren im Text, keine Statisten) + const imText = figuren.filter((f) => f.rolle !== 'statist' && nachFigur.has(f.id)); + for (let i = 0; i < imText.length; i++) + for (let j = i + 1; j < imText.length; j++) { + const a = imText[i]!; + const b = imText[j]!; + let gefunden: { wa: string; wb: string; art: 'eins' | 'anfang' } | null = null; + for (const wa of namensWoerter(a)) { + for (const wb of namensWoerter(b)) { + const x = wa.toLocaleLowerCase('de'); + const y = wb.toLocaleLowerCase('de'); + if (x === y) continue; + if (abstandHoechstensEins(x, y)) gefunden = { wa, wb, art: 'eins' }; + else if (x.length === y.length && x.slice(0, 3) === y.slice(0, 3)) gefunden = { wa, wb, art: 'anfang' }; + if (gefunden) break; + } + if (gefunden) break; + } + if (!gefunden) continue; + const ea = nachFigur.get(a.id)![0]!; + const eb = nachFigur.get(b.id)![0]!; + const [x, y] = [gefunden.wa, gefunden.wb].sort((m, n) => m.localeCompare(n, 'de')); + ergebnis.push( + hinweis( + `figuren:aehnlich:${[a.id, b.id].sort().join('|')}|${x}|${y}`, + fmt(t.aehnlich, { a: q(gefunden.wa), b: q(gefunden.wb) }), + fmt(gefunden.art === 'eins' ? t.aehnlichEinBuchstabe : t.aehnlichAnfang, { a: q(gefunden.wa), b: q(gefunden.wb), figurA: a.name, figurB: b.name }), + [zitatVon(ea), zitatVon(eb)], + ), + ); + } + + // F-14: Statist in vielen Kapiteln + for (const f of figuren) { + if (f.rolle !== 'statist') continue; + const liste = nachFigur.get(f.id) ?? []; + const kap = kapitel.filter((id) => liste.some((e) => e.kapitel === id)); + if (kap.length < STATIST_AUFSTIEG) continue; + ergebnis.push( + hinweis( + `figuren:aufsteigen:${f.id}`, + fmt(t.aufsteigen, { figur: f.name, n: kap.length }), + fmt(t.aufsteigenErklaerung, { figur: f.name, n: kap.length, liste: kap.map(nr).join(', ') }), + [zitatVon(liste[0]!)], + ), + ); + } + + // F-13: ruhende Figuren + for (const f of figuren) { + for (const s of ruhendeStrecken(daten, erwaehnungen, f)) { + const letzte = (nachFigur.get(f.id) ?? []).filter((e) => e.kapitel === s.nach).pop(); + ergebnis.push( + hinweis( + `figuren:ruht:${f.id}@${s.nach}`, + fmt(t.ruht, { figur: f.name, nr: nr(s.nach) }), + fmt(t.ruhtErklaerung, { figur: f.name, nr: nr(s.nach), n: s.kapitel.length, von: nr(s.kapitel[0]!), bis: nr(s.kapitel[s.kapitel.length - 1]!) }), + letzte ? [zitatVon(letzte)] : [], + ), + ); + } + } + return ergebnis; +} + +export interface RuhendeStrecke { + /** letztes Kapitel mit der Figur vor der Strecke */ + nach: Id; + /** Kapitel (mit Text) ohne die Figur */ + kapitel: Id[]; +} + +/** + * Strecken von mindestens `ruheSchwelle(f)` Kapiteln mit Text, in denen die Figur weder im Text vorkommt noch im + * Kapitelplan steht – zwischen zwei Vorkommen oder nach dem letzten (nur, solange danach noch Text kommt). + */ +export function ruhendeStrecken(daten: ProjektDaten, erwaehnungen: Erwaehnung[], f: Figur): RuhendeStrecke[] { + const schwelle = ruheSchwelle(f); + if (schwelle === null || schwelle < 1) return []; + const mitText = reihe(daten).filter((id) => daten.kapitel[id]?.text.trim()); + const da = new Set(erwaehnungen.filter((e) => e.zielTyp === 'figur' && e.ziel === f.id).map((e) => e.kapitel)); + for (const p of Object.values(daten.plaene ?? {})) if (p.figuren.includes(f.id)) da.add(p.id); + const strecken: RuhendeStrecke[] = []; + let letzte: Id | null = null; + let luecke: Id[] = []; + for (const id of mitText) { + if (da.has(id)) { + if (letzte && luecke.length >= schwelle) strecken.push({ nach: letzte, kapitel: luecke }); + letzte = id; + luecke = []; + } else if (letzte) luecke.push(id); + } + if (letzte && luecke.length >= schwelle) strecken.push({ nach: letzte, kapitel: luecke }); + return strecken; +} diff --git a/core/figuren/index.ts b/core/figuren/index.ts index f1d312b..ce4c46c 100644 --- a/core/figuren/index.ts +++ b/core/figuren/index.ts @@ -1,6 +1,11 @@ /** - * G3 Figuren: Figuren-Bögen (plan/boegen.md) und Figurenstimme (.plotwerk/stimmen.json). Besitzer: G3 Figuren. + * G3 Figuren: Figuren-Bögen (plan/boegen.md), Figurenstimme (.plotwerk/stimmen.json) und Zusammenführen. Besitzer: G3 Figuren. * Reines TypeScript ohne DOM/Tauri; die KI-Aufgabe liegt in core/ki/stimme.ts. */ export * from './boegen'; export * from './stimme'; +export * from './zusammenfuehren'; +export * from './namen'; +export * from './hinweise'; +export * from './besetzung'; +export * from './fakten'; diff --git a/core/figuren/namen.test.ts b/core/figuren/namen.test.ts new file mode 100644 index 0000000..4487a3c --- /dev/null +++ b/core/figuren/namen.test.ts @@ -0,0 +1,42 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { demoFs } from '../../fixtures/demoProjekt'; +import type { ProjektDaten } from '../model/types'; +import { ladeProjekt } from '../projekt'; +import { figurenSuchen, figurMitNamensform, mitSpitzname, spitznameAusText } from './index'; + +let daten: ProjektDaten; +beforeAll(async () => { + daten = await ladeProjekt(demoFs()); +}); + +describe('Spitznamen', () => { + it('aus einer Markierung: ohne Artikel, Anführungszeichen und Satzzeichen am Rand', () => { + expect(spitznameAusText('dem Alten')).toBe('Alten'); + expect(spitznameAusText(' der Mann am Kai, ')).toBe('Mann am Kai'); + expect(spitznameAusText('»Kranich«')).toBe('Kranich'); + expect(spitznameAusText('the Stranger.')).toBe('Stranger'); + // nur ein Artikel: bleibt stehen + expect(spitznameAusText('Die')).toBe('Die'); + }); + + it('Figur zu einem Namen: Name, Spitzname oder Form, ohne Artikel und Groß/klein', () => { + expect(figurMitNamensform(daten, 'ole')?.id).toBe('ole'); + expect(figurMitNamensform(daten, 'Herrn Kessler')?.id).toBe('herr-kessler'); + expect(figurMitNamensform(daten, 'die fremde')?.id).toBe('die-fremde'); + expect(figurMitNamensform(daten, 'Niemand')).toBeUndefined(); + }); + + it('Spitzname ergänzen: nur, wenn er neu ist', () => { + const ole = daten.figuren.ole!; + expect(mitSpitzname(ole, 'Fensterbank-Ole')?.aliase).toEqual([...ole.aliase, 'Fensterbank-Ole']); + expect(mitSpitzname(ole, 'OLE')).toBeNull(); + expect(mitSpitzname(ole, ' ')).toBeNull(); + }); + + it('Suche: bevorzugte zuerst, dann Treffer am Namensanfang, dann im Namen oder in der Kurzbeschreibung', () => { + const ids = figurenSuchen(daten, 'o', ['paul']).map((f) => f.id); + expect(ids[0]).toBe('paul'); + expect(ids.indexOf('ole')).toBeLessThan(ids.indexOf('konrad')); + expect(figurenSuchen(daten, 'xyz')).toEqual([]); + }); +}); diff --git a/core/figuren/namen.ts b/core/figuren/namen.ts new file mode 100644 index 0000000..9df1497 --- /dev/null +++ b/core/figuren/namen.ts @@ -0,0 +1,114 @@ +/** + * Namen und Spitznamen von Figuren: markierten Text in einen Spitznamen verwandeln, Spitznamen ergänzen, + * Figuren zu einem Namen finden. Reine Funktionen ohne Store. + */ +import type { Figur, FigurRolle, Id, ProjektDaten, SpitznameKontext } from '../model/types'; + +/** Führende Artikel, die die Erkennung selbst ergänzt („dem Alten“ findet auch „der Alte“ nicht, „Alte“ schon). */ +const ARTIKEL = /^(der|die|das|den|dem|des|ein|eine|einen|einem|einer|eines|the|a|an)\s+/i; + +/** Vergleichsform: getrimmt, klein, ohne führenden Artikel. */ +export function namensVergleich(s: string): string { + return s.trim().replace(ARTIKEL, '').trim().toLocaleLowerCase('de'); +} + +/** + * Spitzname aus einer Markierung: ohne Anführungszeichen, Satzzeichen am Rand und führenden Artikel, + * Leerraum zusammengefasst. Leer, wenn nichts Brauchbares übrig bleibt. + */ +export function spitznameAusText(text: string): string { + const s = text + .replace(/\s+/g, ' ') + .trim() + .replace(/^[»«„“”"‚‘’'›‹([{–—-]+/u, '') + .replace(/[»«„“”"‚‘’'›‹)\]}.,;:!?…–—-]+$/u, '') + .trim(); + const ohne = s.replace(ARTIKEL, '').trim(); + return ohne || s; +} + +/** Alle Namen einer Figur (Name, Spitznamen, Formen). */ +export function namenVon(f: Pick): string[] { + return [f.name, ...f.aliase, ...(f.formen ?? [])]; +} + +/** Figur, die diesen Namen schon trägt (Name, Spitzname oder Form; ohne Artikel, Groß/klein egal). */ +export function figurMitNamensform(daten: Pick, name: string): Figur | undefined { + const form = namensVergleich(name); + if (!form) return undefined; + return Object.values(daten.figuren).find((f) => namenVon(f).some((n) => namensVergleich(n) === form)); +} + +/** Figur mit zusätzlichem Spitznamen; null, wenn sie ihn (oder den Namen) schon trägt oder er leer ist. */ +export function mitSpitzname(f: Figur, name: string): Figur | null { + const n = name.trim(); + if (!n || namenVon(f).some((x) => namensVergleich(x) === namensVergleich(n))) return null; + return { ...f, aliase: [...f.aliase, n] }; +} + +/** Figuren für eine Suche (Name, Spitznamen, Kurzbeschreibung); Treffer am Anfang zuerst. */ +export function figurenSuchen(daten: Pick, suche: string, bevorzugt: Id[] = []): Figur[] { + const q = suche.trim().toLocaleLowerCase('de'); + const rang = (f: Figur): number => { + if (bevorzugt.includes(f.id)) return 0; + if (!q) return 2; + const namen = namenVon(f).map((n) => n.toLocaleLowerCase('de')); + if (namen.some((n) => n.startsWith(q))) return 1; + if (namen.some((n) => n.includes(q)) || (f.kurz ?? '').toLocaleLowerCase('de').includes(q)) return 2; + return 99; + }; + return Object.values(daten.figuren) + .map((f) => ({ f, r: rang(f) })) + .filter((x) => x.r < 99) + .sort((a, b) => a.r - b.r || a.f.name.localeCompare(b.f.name, 'de')) + .map((x) => x.f); +} + +/** Reihenfolge der Rollen in Listen: Haupt-, Neben-, Statistenrollen. */ +export const ROLLEN: FigurRolle[] = ['haupt', 'neben', 'statist']; + +/** Vergleich für Listen: erst nach Rolle, dann mit `name` (z. B. nach Namen). */ +export function nachRolle(name: (a: T, b: T) => number): (a: T, b: T) => number { + return (a, b) => ROLLEN.indexOf(a.rolle) - ROLLEN.indexOf(b.rolle) || name(a, b); +} + +/** F-16: Spitznamen setzen (Reihenfolge, ohne Doppelte); Kontext zu entfernten Namen fällt weg. */ +export function mitAliasen(f: Figur, aliase: string[]): Figur { + const gesehen = new Set([namensVergleich(f.name)]); + const neu = aliase + .map((a) => a.replace(/\s+/g, ' ').trim()) + .filter((a) => { + const k = namensVergleich(a); + if (!k || gesehen.has(k)) return false; + gesehen.add(k); + return true; + }); + const formen = new Set(neu.map(namensVergleich)); + const spitznamen = (f.spitznamen ?? []).filter((k) => formen.has(namensVergleich(k.name))); + const ergebnis: Figur = { ...f, aliase: neu }; + if (spitznamen.length) ergebnis.spitznamen = spitznamen; + else delete ergebnis.spitznamen; + return ergebnis; +} + +/** F-16: Kontext eines Spitznamens (undefined, wenn keiner eingetragen ist). */ +export function spitznameKontext(f: Figur, name: string): SpitznameKontext | undefined { + const k = namensVergleich(name); + return f.spitznamen?.find((x) => namensVergleich(x.name) === k); +} + +/** F-16: Kontext setzen bzw. ersetzen; ohne Angaben fällt der Eintrag weg. */ +export function mitSpitznameKontext(f: Figur, kontext: SpitznameKontext): Figur { + const k = namensVergleich(kontext.name); + const rest = (f.spitznamen ?? []).filter((x) => namensVergleich(x.name) !== k); + const sauber: SpitznameKontext = { name: kontext.name }; + if (kontext.von?.length) sauber.von = [...new Set(kontext.von)]; + if (kontext.abKapitel) sauber.abKapitel = kontext.abKapitel; + if (kontext.notiz?.trim()) sauber.notiz = kontext.notiz.trim(); + const leer = !sauber.von && !sauber.abKapitel && !sauber.notiz; + const liste = leer ? rest : [...rest, sauber]; + const ergebnis: Figur = { ...f }; + if (liste.length) ergebnis.spitznamen = liste; + else delete ergebnis.spitznamen; + return ergebnis; +} diff --git a/core/figuren/zusammenfuehren.test.ts b/core/figuren/zusammenfuehren.test.ts new file mode 100644 index 0000000..9203020 --- /dev/null +++ b/core/figuren/zusammenfuehren.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { demoFs } from '../../fixtures/demoProjekt'; +import type { Figur, ProjektDaten } from '../model/types'; +import { ladeProjekt } from '../projekt'; +import { figurenZusammenfuehren } from './index'; + +let daten: ProjektDaten; + +/** Demo-Projekt mit einer doppelt angelegten Vera („die Nachbarin“), auf die überall verwiesen wird. */ +beforeEach(async () => { + daten = await ladeProjekt(demoFs()); + const doppelt: Figur = { + id: 'nachbarin', + name: 'die Nachbarin', + aliase: ['Vera', 'Frau von oben'], + formen: ['Nachbarin'], + rolle: 'haupt', + kurz: 'wohnt über Paul', + existenz: 'offen', + gruppe: 'nordhaven', + beziehungen: [ + { zu: 'paul', art: 'Verabredung', abKapitel: 'ebbe', kategorie: 'dates' }, + { zu: 'ole', art: 'Bekannte', kategorie: 'sonstige' }, + { zu: 'vera', art: 'selbst', kategorie: 'sonstige' }, + ], + plan: { Alter: '34', Hobby: 'Segeln' }, + wahrheit: 'Kennt Kessler.', + notizen: 'Siehe [[figur:paul|Paul]].', + }; + daten.figuren.nachbarin = doppelt; + daten.figuren.ole = { + ...daten.figuren.ole!, + beziehungen: [...daten.figuren.ole!.beziehungen, { zu: 'nachbarin', art: 'Bekannte', kategorie: 'sonstige' }], + notizen: 'Trifft [[figur:nachbarin|die Nachbarin]] im Treppenhaus.', + }; + daten.gruppen.nordhaven = { ...daten.gruppen.nordhaven!, mitglieder: [...daten.gruppen.nordhaven!.mitglieder, 'nachbarin'] }; + const plan = daten.plaene.ebbe!; + daten.plaene.ebbe = { ...plan, figuren: [...plan.figuren, 'vera', 'nachbarin'] }; + daten.zeitleiste = { + ...daten.zeitleiste!, + zustaende: [ + ...daten.zeitleiste!.zustaende, + { id: 'z-n1', figur: 'nachbarin', kapitel: 'ebbe', zustand: 'nuechtern' }, + { id: 'z-v1', figur: 'vera', kapitel: 'ebbe', zustand: 'betrunken' }, + { id: 'z-n2', figur: 'nachbarin', kapitel: 'funkstille', zustand: 'nuechtern' }, + ], + }; + daten.boegen = [...(daten.boegen ?? []), { figur: 'nachbarin', stufen: [{ id: 's1', titel: 'zieht ein', belege: [] }] }]; + daten.anmerkungen = [{ id: 'a1', anker: { kapitel: 'ebbe', start: 0, ende: 1, davor: '', text: 'x', danach: '' } as never, notiz: '', ziel: { typ: 'figur', id: 'nachbarin' }, erstellt: '' }]; +}); + +describe('Figuren zusammenführen', () => { + it('bleibende Figur übernimmt Namen, Formen, Beziehungen, Plan, Notizen; Leeres wird ergänzt', () => { + const vorher = daten.figuren.vera!; + const d = figurenZusammenfuehren(daten, 'vera', 'nachbarin'); + expect(d.figuren.nachbarin).toBeUndefined(); + const v = d.figuren.vera!; + expect(v.name).toBe('Vera'); + expect(v.existenz).toBe(vorher.existenz); + expect(v.aliase).toEqual([...vorher.aliase, 'die Nachbarin', 'Frau von oben']); + expect(v.formen).toEqual(['Nachbarin']); + expect(v.rolle).toBe('haupt'); + expect(v.gruppe).toBe(vorher.gruppe ?? 'nordhaven'); + // doppelte Beziehung (Verabredung mit Paul) nur einmal, Beziehung zu sich selbst fällt weg + expect(v.beziehungen.map((b) => `${b.zu}:${b.art}`)).toEqual(['paul:Verabredung', 'ole:Bekannte']); + expect(v.plan.Hobby).toBe('Segeln'); + expect(v.plan.Alter).toBe(vorher.plan.Alter ? `${vorher.plan.Alter}\n\n34` : '34'); + expect(v.wahrheit).toContain('Kennt Kessler.'); + expect(v.notizen).toContain('[[figur:paul|Paul]]'); + }); + + it('alle Verweise zeigen danach auf die bleibende Figur', () => { + const d = figurenZusammenfuehren(daten, 'vera', 'nachbarin'); + expect(d.figuren.ole!.beziehungen.filter((b) => b.zu === 'nachbarin')).toEqual([]); + expect(d.figuren.ole!.beziehungen.some((b) => b.zu === 'vera' && b.art === 'Bekannte')).toBe(true); + expect(d.figuren.ole!.notizen).toBe('Trifft [[figur:vera|die Nachbarin]] im Treppenhaus.'); + expect(d.gruppen.nordhaven!.mitglieder).not.toContain('nachbarin'); + expect(d.gruppen.nordhaven!.mitglieder.filter((m) => m === 'vera').length).toBe(1); + expect(d.plaene.ebbe!.figuren.filter((f) => f === 'vera').length).toBe(1); + expect(d.plaene.ebbe!.figuren).not.toContain('nachbarin'); + // Zustand: bei Überschneidung gilt der der bleibenden Figur + const zustaende = d.zeitleiste!.zustaende.filter((z) => ['z-n1', 'z-v1', 'z-n2'].includes(z.id)); + expect(zustaende.map((z) => [z.id, z.figur])).toEqual([ + ['z-v1', 'vera'], + ['z-n2', 'vera'], + ]); + expect(d.boegen!.find((b) => b.figur === 'vera')!.stufen.map((s) => s.id)).toContain('s1'); + expect(d.boegen!.some((b) => b.figur === 'nachbarin')).toBe(false); + expect(d.anmerkungen[0]!.ziel).toEqual({ typ: 'figur', id: 'vera' }); + }); + + it('unbeteiligte Teile behalten ihre Referenz (nur geänderte Dateien werden geschrieben)', () => { + const d = figurenZusammenfuehren(daten, 'vera', 'nachbarin'); + expect(d.figuren.paul).toBe(daten.figuren.paul); + expect(d.kapitel).toBe(daten.kapitel); + expect(d.plaene.grundriss).toBe(daten.plaene.grundriss); + expect(d.orte).toBe(daten.orte); + expect(d.figuren.ole).not.toBe(daten.figuren.ole); + }); + + it('dieselbe oder eine unbekannte Figur: unverändert', () => { + expect(figurenZusammenfuehren(daten, 'vera', 'vera')).toBe(daten); + expect(figurenZusammenfuehren(daten, 'vera', 'gibt-es-nicht')).toBe(daten); + }); +}); diff --git a/core/figuren/zusammenfuehren.ts b/core/figuren/zusammenfuehren.ts new file mode 100644 index 0000000..7b609d0 --- /dev/null +++ b/core/figuren/zusammenfuehren.ts @@ -0,0 +1,247 @@ +/** + * Zwei Figuren zusammenführen (z. B. „Henrik“ und „der Alte“, die sich als dieselbe Person herausstellen). + * Reine Funktion: liefert neue ProjektDaten; unveränderte Teile behalten ihre Referenz, damit der Store nur die + * geänderten Dateien schreibt. + * + * Die bleibende Figur behält ID, Namen und Gewissheit. Von der aufgegebenen übernimmt sie Namen (als Aliase), + * Formen, Beziehungen, Planfelder, Notizen, „Wahrheit“ und, wo bei ihr leer, Kurzbeschreibung, Gruppe und + * Netzposition. Alle Verweise im Projekt (Beziehungen anderer Figuren, Gruppen, Kapitelpläne und Szenen, + * Zeitleiste, Bögen, Anmerkungen, Recherche, Entscheidungen, KI-Ablagen, `[[figur:…]]`-Links in Notizen und + * Planfeldern) zeigen danach auf die bleibende Figur. + */ +import type { Beziehung, Figur, Figurenbogen, Id, KiAblage, ProjektDaten, Verweis, VorschlagsVerweis } from '../model/types'; + +const vergleichsform = (s: string) => s.trim().toLocaleLowerCase('de'); + +function eindeutig(liste: T[], schluessel: (x: T) => string = String): T[] { + const gesehen = new Set(); + return liste.filter((x) => { + const k = schluessel(x); + if (gesehen.has(k)) return false; + gesehen.add(k); + return true; + }); +} + +/** Ersetzt `aus` durch `nach` in einer ID-Liste; gleiche Referenz, wenn `aus` nicht vorkommt. */ +function idsErsetzen(ids: Id[], aus: Id, nach: Id): Id[] { + if (!ids.includes(aus)) return ids; + return eindeutig(ids.map((i) => (i === aus ? nach : i))); +} + +/** Zwei Freitexte verbinden (leere fallen weg, gleiche nur einmal). */ +function textVerbinden(a: string | undefined, b: string | undefined): string { + const teile = eindeutig([a ?? '', b ?? ''].map((t) => t.trim()).filter(Boolean)); + return teile.join('\n\n'); +} + +const beziehungsSchluessel = (b: Beziehung) => `${b.zu}|${vergleichsform(b.art)}|${b.abKapitel ?? ''}`; + +/** Beziehungen auf `nach` umbiegen; Beziehungen zu sich selbst fallen weg, doppelte nur einmal. */ +function beziehungenUmbiegen(liste: Beziehung[], eigene: Id, aus: Id, nach: Id): Beziehung[] { + if (!liste.some((b) => b.zu === aus)) return liste; + return eindeutig( + liste.map((b) => (b.zu === aus ? { ...b, zu: nach } : b)).filter((b) => b.zu !== eigene), + beziehungsSchluessel, + ); +} + +/** `[[figur:aus|…]]` → `[[figur:nach|…]]` in allen Zeichenketten eines Werts (gleiche Referenz ohne Treffer). */ +function linksErsetzen(wert: T, aus: Id, nach: Id): T { + const muster = new RegExp(`\\[\\[figur:\\s*${aus.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*(?=[|\\]])`, 'g'); + const ersetzt = `[[figur:${nach}`; + const gehe = (v: unknown): unknown => { + if (typeof v === 'string') return v.includes('[[figur:') ? v.replace(muster, ersetzt) : v; + if (Array.isArray(v)) { + const neu = v.map(gehe); + return neu.some((x, i) => x !== v[i]) ? neu : v; + } + if (v && typeof v === 'object') { + let geaendert = false; + const neu: Record = {}; + for (const [k, x] of Object.entries(v)) { + neu[k] = gehe(x); + if (neu[k] !== x) geaendert = true; + } + return geaendert ? neu : v; + } + return v; + }; + return gehe(wert) as T; +} + +function verweisErsetzen(v: V, aus: Id, nach: Id): V { + return v && v.typ === 'figur' && v.id === aus ? ({ ...v, id: nach } as V) : v; +} + +/** Die zusammengeführte Figur (ohne Verweise anderer Dateien). */ +export function zusammengefuehrteFigur(a: Figur, b: Figur): Figur { + const vergeben = new Set([vergleichsform(a.name)]); + const aliase = [...a.aliase, b.name, ...b.aliase].filter((n) => { + const k = vergleichsform(n); + if (!k || vergeben.has(k)) return false; + vergeben.add(k); + return true; + }); + const formen = eindeutig([...(a.formen ?? []), ...(b.formen ?? [])], vergleichsform); + const plan: Record = { ...a.plan }; + for (const [feld, wert] of Object.entries(b.plan)) plan[feld] = feld in plan ? textVerbinden(plan[feld], wert) : wert; + const beziehungen = eindeutig( + [...a.beziehungen, ...b.beziehungen] + .map((x) => (x.zu === b.id ? { ...x, zu: a.id } : x)) + .filter((x) => x.zu !== a.id), + beziehungsSchluessel, + ); + const neu: Figur = { + ...a, + aliase, + rolle: a.rolle === 'haupt' || b.rolle === 'haupt' ? 'haupt' : a.rolle, + gruppe: a.gruppe ?? b.gruppe, + beziehungen, + plan, + wahrheit: textVerbinden(a.wahrheit, b.wahrheit), + notizen: textVerbinden(a.notizen, b.notizen), + }; + const kontexte = eindeutig([...(a.spitznamen ?? []), ...(b.spitznamen ?? [])], (k) => vergleichsform(k.name)); + if (kontexte.length) neu.spitznamen = kontexte; + if (formen.length) neu.formen = formen; + else delete neu.formen; + const kurz = a.kurz?.trim() || b.kurz?.trim(); + if (kurz) neu.kurz = kurz; + const netz = a.netz ?? b.netz; + if (netz) neu.netz = netz; + return neu; +} + +function boegenZusammenfuehren(boegen: Figurenbogen[], aus: Id, nach: Id): Figurenbogen[] { + const alt = boegen.find((b) => b.figur === aus); + if (!alt) return boegen; + const ziel = boegen.find((b) => b.figur === nach); + if (!ziel) return boegen.map((b) => (b === alt ? { ...b, figur: nach } : b)); + return boegen.filter((b) => b !== alt).map((b) => (b === ziel ? { ...b, stufen: [...b.stufen, ...alt.stufen] } : b)); +} + +/** + * Führt die Figur `aufgeben` in `behalten` zusammen. Unbekannte IDs oder dieselbe ID → Daten unverändert. + */ +export function figurenZusammenfuehren(daten: ProjektDaten, behalten: Id, aufgeben: Id): ProjektDaten { + const a = daten.figuren[behalten]; + const b = daten.figuren[aufgeben]; + if (!a || !b || behalten === aufgeben) return daten; + const aus = aufgeben; + const nach = behalten; + + const figuren: Record = {}; + for (const f of Object.values(daten.figuren)) { + if (f.id === aus) continue; + figuren[f.id] = f.id === nach ? zusammengefuehrteFigur(a, b) : { ...f, beziehungen: beziehungenUmbiegen(f.beziehungen, f.id, aus, nach) }; + if (f.id !== nach && figuren[f.id]!.beziehungen === f.beziehungen) figuren[f.id] = f; + } + + const gruppen = Object.fromEntries( + Object.entries(daten.gruppen).map(([id, g]) => { + const mitglieder = idsErsetzen(g.mitglieder, aus, nach); + let rollen = g.rollen; + if (rollen && aus in rollen) { + const { [aus]: alt, ...rest } = rollen; + rollen = { ...rest, ...(rest[nach] ? {} : { [nach]: alt! }) }; + } + return [id, mitglieder === g.mitglieder && rollen === g.rollen ? g : { ...g, mitglieder, ...(rollen ? { rollen } : {}) }]; + }), + ); + + const plaene = Object.fromEntries( + Object.entries(daten.plaene).map(([id, p]) => { + const pf = idsErsetzen(p.figuren, aus, nach); + const szenen = p.szenen?.map((s) => { + const sf = idsErsetzen(s.figuren, aus, nach); + return sf === s.figuren ? s : { ...s, figuren: sf }; + }); + const szenenGeaendert = !!szenen && szenen.some((s, i) => s !== p.szenen![i]); + if (pf === p.figuren && !szenenGeaendert) return [id, p]; + return [id, { ...p, figuren: pf, ...(szenenGeaendert ? { szenen } : {}) }]; + }), + ); + + const anmerkungen = daten.anmerkungen.some((x) => x.ziel?.typ === 'figur' && x.ziel.id === aus) + ? daten.anmerkungen.map((x) => ({ ...x, ziel: verweisErsetzen(x.ziel, aus, nach) })) + : daten.anmerkungen; + + const e = daten.entscheidungen; + const betrifft = (v: Verweis | null | undefined) => v?.typ === 'figur' && v.id === aus; + const entscheidungen = + e.schreibweisen.some((s) => betrifft(s.ziel)) || e.zuordnungen.some((z) => betrifft(z.ziel)) + ? { + ...e, + schreibweisen: e.schreibweisen.map((s) => (betrifft(s.ziel) ? { ...s, ziel: verweisErsetzen(s.ziel, aus, nach) } : s)), + zuordnungen: e.zuordnungen.map((z) => (betrifft(z.ziel) ? { ...z, ziel: verweisErsetzen(z.ziel, aus, nach) } : z)), + } + : e; + + const kiHinweise = daten.kiHinweise.some((h) => h.plan?.figur === aus) + ? daten.kiHinweise.map((h) => (h.plan?.figur === aus ? { ...h, plan: { ...h.plan, figur: nach } } : h)) + : daten.kiHinweise; + + let kiVorschlaege = daten.kiVorschlaege; + if (kiVorschlaege) { + const v = kiVorschlaege; + const ref = (r: VorschlagsVerweis): VorschlagsVerweis => (r.typ === 'angelegt' && r.id === aus ? { ...r, id: nach, name: a.name } : r); + const betroffen = + v.figuren.some((f) => f.figur === aus) || v.beziehungen.some((x) => [x.von, x.zu].some((r) => r.typ === 'angelegt' && r.id === aus)); + if (betroffen) + kiVorschlaege = { + ...v, + figuren: v.figuren.map((f) => (f.figur === aus ? { ...f, figur: nach } : f)), + beziehungen: v.beziehungen.map((x) => ({ ...x, von: ref(x.von), zu: ref(x.zu) })), + }; + } + + let zeitleiste = daten.zeitleiste; + if (zeitleiste?.zustaende.some((z) => z.figur === aus)) { + // ein Zustand pro Figur und Kapitel/Szene: bei Überschneidung gilt der der bleibenden Figur + const belegt = new Set(zeitleiste.zustaende.filter((z) => z.figur === nach).map((z) => `${z.kapitel}#${z.szene ?? ''}`)); + zeitleiste = { + ...zeitleiste, + zustaende: zeitleiste.zustaende + .filter((z) => z.figur !== aus || !belegt.has(`${z.kapitel}#${z.szene ?? ''}`)) + .map((z) => (z.figur === aus ? { ...z, figur: nach } : z)), + }; + } + + const boegen = daten.boegen ? boegenZusammenfuehren(daten.boegen, aus, nach) : daten.boegen; + + const recherche = + daten.recherche && daten.recherche.eintraege.some((r) => r.verweise.some(betrifft)) + ? { + ...daten.recherche, + eintraege: daten.recherche.eintraege.map((r) => + r.verweise.some(betrifft) + ? { ...r, verweise: eindeutig(r.verweise.map((x) => verweisErsetzen(x, aus, nach)), (x) => `${x.typ}:${x.id}`) } + : r, + ), + } + : daten.recherche; + + const ablage = (x: KiAblage | undefined): KiAblage | undefined => + x && x.eintraege.some((y) => y.figur === aus) ? { ...x, eintraege: x.eintraege.map((y) => (y.figur === aus ? { ...y, figur: nach } : y)) } : x; + + const ergebnis: ProjektDaten = { + ...daten, + figuren, + gruppen, + plaene, + anmerkungen, + entscheidungen, + kiHinweise, + ...(kiVorschlaege ? { kiVorschlaege } : {}), + ...(zeitleiste ? { zeitleiste } : {}), + ...(boegen ? { boegen } : {}), + ...(recherche ? { recherche } : {}), + ...(daten.kiZustandVorschlaege ? { kiZustandVorschlaege: ablage(daten.kiZustandVorschlaege) } : {}), + ...(daten.kiStimmen ? { kiStimmen: ablage(daten.kiStimmen) } : {}), + }; + + // `[[figur:…]]`-Links in Notizen und Planfeldern (nicht im Manuskript, S-07) + const { kapitel, ...rest } = ergebnis; + return { ...linksErsetzen(rest, aus, nach), kapitel }; +} diff --git a/core/index/hinweisTypen.ts b/core/index/hinweisTypen.ts index 45c31c0..9529bdd 100644 --- a/core/index/hinweisTypen.ts +++ b/core/index/hinweisTypen.ts @@ -11,6 +11,7 @@ export const HINWEIS_TYP_NAME: Record = { struktur: 'Struktur', zeitleiste: 'Zeitleiste', sprache: 'Sprache', + figuren: 'Figuren', }; export const HINWEIS_TYP_REIHE = Object.keys(HINWEIS_TYP_NAME) as HinweisTyp[]; @@ -23,6 +24,7 @@ const HINWEIS_TYP_NAME_EN: Record = { struktur: 'Structure', zeitleiste: 'Timeline', sprache: 'Language', + figuren: 'Characters', }; /** Alle Anzeigenamen in der Sprache (gleiche Schlüssel und Reihenfolge wie HINWEIS_TYP_NAME). */ diff --git a/core/index/index.ts b/core/index/index.ts index e05eeda..73d0411 100644 --- a/core/index/index.ts +++ b/core/index/index.ts @@ -12,6 +12,8 @@ import type { Zitat, } from '../model/types'; import { stimmenHinweise } from '../figuren/stimme'; +import { faktenHinweise } from '../figuren/fakten'; +import { figurenHinweise } from '../figuren/hinweise'; import type { Sprache } from '../i18n'; import { strukturHinweise } from '../struktur'; import { zeitHinweise } from '../zeit'; @@ -165,7 +167,7 @@ function kiHinweisGueltig(h: Hinweis, daten: ProjektDaten): boolean { }); } -function hinweiseBerechnen(daten: ProjektDaten, sprache: Sprache): Hinweis[] { +function hinweiseBerechnen(daten: ProjektDaten, sprache: Sprache, erwaehnungen: Erwaehnung[]): Hinweis[] { const entscheidungen = new Map((daten.entscheidungen?.hinweise ?? []).map((e) => [e.schluessel, e.entscheidung])); const stellen = markierteStellen(daten); // lokal berechnet (Struktur, Schreibweisen, Zeitprüfung) bzw. aus eigenen KI-Ablagen (Figurenstimme, bereits geprüft); @@ -175,6 +177,8 @@ function hinweiseBerechnen(daten: ProjektDaten, sprache: Sprache): Hinweis[] { ...schreibweisenHinweise(daten, sprache), ...zeitHinweise(daten, sprache), ...stimmenHinweise(daten, sprache), + ...figurenHinweise(daten, erwaehnungen, sprache), + ...faktenHinweise(daten, sprache), ]; const ki = (daten.kiHinweise ?? []) .filter((h) => kiHinweisGueltig(h, daten)) @@ -240,7 +244,7 @@ export function berechneIndex(daten: ProjektDaten, vorher?: Index | null, sprach erwaehnungen, erstesVorkommen, faeden: faedenBerechnen(daten, reihe), - hinweise: hinweiseBerechnen(daten, sprache), + hinweise: hinweiseBerechnen(daten, sprache, erwaehnungen), wortzahlen, textHashes, erkennungsHash, diff --git a/core/ki/README.md b/core/ki/README.md index 0a917d8..ec3b814 100644 --- a/core/ki/README.md +++ b/core/ki/README.md @@ -287,3 +287,9 @@ Aufgabentext, Schema, optional `kapitelBis`, `auswerten`). `fuehreAufgabeAus` ba Zitate. `schaetzeAufgabe` liefert die Kostenschätzung, `werteAufgabenAntwortAus` wertet vorbereitete Antworten aus. Die Browser-Demo beantwortet Aufgaben über `src/backend/ki/demoAntworten/` (je Gruppe eine Datei, Schlüssel = `art`). +Figurenfakten (`fakten.ts`, `art="fakten"`, F-17): ein Request für das ganze Buch (optional eine Figur), Antwort +`{fakten: [{figur, merkmal, wert, kapitel, zitat}]}`. Merkmal höchstens drei, Wert höchstens acht Wörter; Zitat wörtlich +(E6); Figur nur aus dem Schema-Enum. Die KI vergleicht nichts – Widersprüche und Planabweichungen berechnet +`core/figuren/fakten.ts` lokal. An das Modell gehen nur Daten der Allowlist (E12): Namen, Aliase, Gewissheit und die +Namen der Planfelder. + diff --git a/core/ki/fakten.test.ts b/core/ki/fakten.test.ts new file mode 100644 index 0000000..d3c04a2 --- /dev/null +++ b/core/ki/fakten.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { demoFs } from '../../fixtures/demoProjekt'; +import { erstelleDemoKiTransport } from '../../src/backend/ki/demo'; +import { MemoryFs } from '../projekt'; +import { FAKTEN_ART, faktSchluessel, KiFehler, sammleFakten, schaetzeFakten, STANDARD_KI, type KiTransport } from './index'; + +const SAAB = 'Dann nimmt er den Schlüssel und fährt mit seinem alten Saab 900 zum Lindenhof.'; + +function transport(antwort: unknown, bodies: Record[] = []): KiTransport { + return { + bereit: async () => true, + senden: async (body) => { + bodies.push(body); + return { + type: 'message', + content: [{ type: 'text', text: JSON.stringify(antwort) }], + stop_reason: 'end_turn', + usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 0, cache_creation_input_tokens: 1000 }, + }; + }, + tokenZaehlen: async (body) => Math.ceil(JSON.stringify(body).length / 3), + }; +} + +/** Dateisystem, das bei jedem Schreibversuch fehlschlägt (harte Regel 1). */ +class NurLesen extends MemoryFs { + override async schreiben(): Promise { + throw new Error('Schreiben verboten'); + } +} + +describe('KI-Aufgabe Figurenfakten (F-17)', () => { + it('Demo: wörtliche Angaben bleiben, erfundenes Zitat fällt weg; nichts wird geschrieben', async () => { + const fs = new NurLesen((await import('../../fixtures/demoProjekt')).demoDateien()); + const r = await sammleFakten(fs, erstelleDemoKiTransport({ verzoegerungMs: 0 }), STANDARD_KI, {}); + const paul = r.ergebnis.fakten.filter((f) => f.figur === 'paul'); + expect(paul.map((f) => f.wert)).toEqual(expect.arrayContaining(['alter Saab 900', 'Saab', 'Volvo'])); + expect(paul).toHaveLength(3); + expect(r.ergebnis.fakten.some((f) => f.figur === 'ole')).toBe(false); + expect(r.verworfen.map((v) => v.grund).join(' ')).toMatch(/nicht wörtlich/); + expect(paul[0]!.zitat.text).toBe(SAAB); + expect(paul[0]!.schluessel).toBe(faktSchluessel('paul', 'Auto', { kapitel: 'lotte', text: SAAB })); + }); + + it('nur eine Figur: Kopf trägt figur, Schema erlaubt nur sie; Statisten bei „alle“ nicht gefragt', async () => { + const bodies: Record[] = []; + await sammleFakten(demoFs(), transport({ fakten: [] }, bodies), STANDARD_KI, { figur: 'vera' }); + const text = JSON.stringify(bodies[0]); + expect(text).toContain(`art=\\"${FAKTEN_ART}\\"`); + expect(text).toContain('figur=\\"vera\\"'); + expect(text).toMatch(/"enum":\["vera"\]/); + }); + + it('Prüfung: unbekannte Figur, zu lange Werte, Ersatztext und fremde Zitate fliegen raus', async () => { + const r = await sammleFakten( + demoFs(), + transport({ + fakten: [ + { figur: 'paul', merkmal: 'Auto', wert: 'alter Saab 900', kapitel: 'lotte', zitat: SAAB }, + { figur: 'niemand', merkmal: 'Auto', wert: 'Saab', kapitel: 'lotte', zitat: SAAB }, + { figur: 'paul', merkmal: 'Auto mit sehr langem Merkmal', wert: 'Saab', kapitel: 'lotte', zitat: SAAB }, + { figur: 'paul', merkmal: 'Auto', wert: 'ein Saab, der wohl nicht mehr lange fahren wird und rostet', kapitel: 'lotte', zitat: SAAB }, + { figur: 'paul', merkmal: 'Auto', wert: 'Saab', kapitel: 'lotte', zitat: 'Er fährt einen Saab.' }, + ], + }), + STANDARD_KI, + {}, + ); + expect(r.ergebnis.fakten).toHaveLength(1); + expect(r.verworfen).toHaveLength(4); + }); + + it('Schätzung ohne Senden; nicht bereit → KiFehler', async () => { + const bodies: Record[] = []; + const s = await schaetzeFakten(demoFs(), transport({ fakten: [] }, bodies), STANDARD_KI, {}); + expect(s.anfragen).toBe(1); + expect(bodies).toHaveLength(0); + const nicht: KiTransport = { ...transport({}), bereit: async () => false }; + await expect(sammleFakten(demoFs(), nicht, STANDARD_KI, {})).rejects.toBeInstanceOf(KiFehler); + }); +}); diff --git a/core/ki/fakten.ts b/core/ki/fakten.ts new file mode 100644 index 0000000..0840f12 --- /dev/null +++ b/core/ki/fakten.ts @@ -0,0 +1,219 @@ +/** + * KI-Aufgabe Figurenfakten (F-17): Angaben über Figuren, wie sie im Text stehen (Alter, Aussehen, Beruf, Auto …), + * jede mit wörtlichem Beleg (E6). Nur Beobachtung, nie Text fürs Buch. Widersprüche und Abweichungen von der + * Planung erkennt die App selbst (core/figuren/fakten.ts) – die KI vergleicht nichts. + * + * Ein Request für das ganze Buch (Buchkontext als gecachter Prefix, Allowlist E12) – optional nur für eine Figur. + * Nur ProjektLeser und KiTransport; gespeichert wird im Store (.plotwerk/figurenfakten.json). + */ +import type { FigurFakt, Id, KiEinstellungen } from '../model/types'; +import type { ProjektLeser } from '../projekt/fs'; +import { zitatKern } from '../text'; +import type { KiRolle } from './anfrage'; +import { + fuehreAufgabeAus, + schaetzeAufgabe, + type AufgabenDefinition, + type AufgabenErgebnis, + type AufgabenOptionen, + type AufgabenPruefung, +} from './aufgabe'; +import type { KiTransport, Schaetzung } from './index'; +import type { BuchKontext, KontextFigur } from './kontext'; +import { meldungenIn } from './meldungen'; +import { ANTWORTSPRACHE_EN, type Sprache } from './sprache'; + +/** Kennung im Aufgabenkopf (); Demo-Antworten sind unter diesem Schlüssel registriert. */ +export const FAKTEN_ART = 'fakten'; + +/** Höchstzahl Wörter für Merkmal bzw. Wert */ +export const MERKMAL_MAX_WOERTER = 3; +export const WERT_MAX_WOERTER = 8; + +/** Übliche Merkmale (zusätzlich zu den Planfeldern der Figuren) */ +export const STANDARD_MERKMALE = ['Alter', 'Geburtstag', 'Aussehen', 'Haare', 'Augen', 'Größe', 'Beruf', 'Wohnort', 'Herkunft', 'Familie', 'Auto']; +export const STANDARD_MERKMALE_EN = ['Age', 'Birthday', 'Appearance', 'Hair', 'Eyes', 'Height', 'Occupation', 'Home', 'Origin', 'Family', 'Car']; + +export interface FaktenAuftrag { + /** nur diese Figur; fehlt = alle Figuren */ + figur?: Id; + /** Opus statt Sonnet */ + tiefencheck?: boolean; +} + +export interface FaktenErgebnis { + fakten: FigurFakt[]; + /** Figuren, für die gefragt wurde */ + figuren: Id[]; +} + +export const SYSTEM_FAKTEN = `Du bist Lektorats-Leser für ein deutschsprachiges Romanmanuskript. Du liest das Buch so, wie eine Leserin es liest: den Text der Kapitel in ihrer Reihenfolge und die wenigen Angaben des Autors zu Figuren, Orten und Gruppen. Dazu kommt die Planung des Autors (Zeilen „Planung“ bei Figuren); sie gehört nicht zum Buch und ist nie ein Beleg. + +Deine Aufgabe: Sammle, was der Text über die Figuren als Tatsache sagt – Alter, Aussehen, Beruf, Wohnort, Familie, Besitz wie ein Auto, feste Gewohnheiten. Jede Angabe mit einem wörtlichen Zitat. + +Du schreibst nie Text für das Buch und bewertest nichts. Du vergleichst die Angaben nicht und meldest keine Widersprüche – das macht die App. Du legst nichts fest, was der Text offenlässt. + +Regeln: +- Nur, was der Text ausdrücklich sagt oder eindeutig zeigt; keine Vermutungen, keine Deutung, keine Gefühle oder Stimmungen. +- figur ist die id einer angelegten Figur. Ein namenloser Ich-Erzähler gehört zu der Figur, die erkennbar erzählt. +- merkmal: höchstens drei Wörter; nimm, wo es passt, den Namen eines Planungsfelds der Figur oder eines der üblichen Merkmale. Gleiche Merkmale immer gleich benennen. +- wert: höchstens acht Wörter, so nah am Text wie möglich (zum Beispiel „34“, „alter Saab 900“, „Architekt“). +- Jedes Zitat ist eine zusammenhängende Passage aus dem Kapitel, das du unter "kapitel" mit seiner id angibst, Zeichen für Zeichen aus dem kopiert, ohne Auslassungen, höchstens ein Satz. +- Nennt der Text dasselbe Merkmal an mehreren Stellen, gib jede Stelle als eigene Angabe an – auch wenn die Werte voneinander abweichen. +- Als „bewusst vage“ markierte Figuren sind absichtlich mehrdeutig; gib bei ihnen nur an, was der Text eindeutig sagt. +- Lieber wenige belastbare Angaben als viele unsichere.`; + +export const SYSTEM_FAKTEN_EN = `You are an editorial reader for an English-language novel. You read the book the way a reader does: the text of the chapters in their order and the author's few notes on characters, places and groups. In addition there is the author's planning (lines “Plan” for characters); it is not part of the book and never evidence. + +Your task: collect what the text states as fact about the characters – age, appearance, occupation, home, family, possessions such as a car, fixed habits. Each with a verbatim quote. + +You never write text for the book and judge nothing. You do not compare the facts and report no contradictions – the app does that. You settle nothing the text leaves open. + +Rules: +- Only what the text states explicitly or shows unambiguously; no assumptions, no interpretation, no feelings or moods. +- figur is the id of a character that is set up. A nameless first-person narrator belongs to the character who recognisably narrates. +- merkmal: at most three words; where it fits, use the name of one of the character's plan fields or one of the usual traits. Always name the same trait the same way. +- wert: at most eight words, as close to the text as possible (for example “34”, “old Saab 900”, “architect”). +- Every quote is one continuous passage from the chapter you name under "kapitel" with its id, copied character for character from the , without omissions, at most one sentence. +- If the text mentions the same trait in several places, give each place as its own fact – even if the values differ. +- Characters marked “deliberately vague” are intentionally ambiguous; for them give only what the text states unambiguously. +- Prefer a few solid facts to many uncertain ones. + +${ANTWORTSPRACHE_EN}`; + +/** Stabiler Schlüssel aus Figur, Merkmal und Beleg. */ +export function faktSchluessel(figur: Id, merkmal: string, zitat: { kapitel: Id; text: string }): string { + return `fakt:${figur}|${merkmal.trim().toLocaleLowerCase('de')}|${zitat.kapitel}/${zitatKern(zitat.text)}`; +} + +function figurZeile(f: KontextFigur, en: boolean): string { + const teile = [`${f.name} (id: ${f.id})`]; + if (f.aliase.length) teile.push(`${en ? 'also called' : 'auch genannt'}: ${f.aliase.join(', ')}`); + if (f.existenz === 'bewusst_vage') teile.push(en ? 'deliberately vague' : 'bewusst vage'); + if (f.plan.length) teile.push(`${en ? 'plan fields' : 'Planungsfelder'}: ${f.plan.map((p) => p.feld).join(', ')}`); + return `- ${teile.join(' · ')}`; +} + +function aufgabenText(kontext: BuchKontext, figuren: KontextFigur[]): string { + const en = kontext.sprache === 'en'; + const z: string[] = []; + if (en) { + z.push(figuren.length === 1 ? `Collect the facts about ${figuren[0]!.name} in the whole book.` : 'Collect the facts about these characters in the whole book:'); + z.push(...figuren.map((f) => figurZeile(f, true))); + z.push(''); + z.push(`Usual traits: ${STANDARD_MERKMALE_EN.join(', ')}.`); + z.push('For each fact: figur (id), merkmal, wert, kapitel (id) and zitat (verbatim).'); + } else { + z.push(figuren.length === 1 ? `Sammle die Angaben über ${figuren[0]!.name} im ganzen Buch.` : 'Sammle die Angaben über diese Figuren im ganzen Buch:'); + z.push(...figuren.map((f) => figurZeile(f, false))); + z.push(''); + z.push(`Übliche Merkmale: ${STANDARD_MERKMALE.join(', ')}.`); + z.push('Für jede Angabe: figur (id), merkmal, wert, kapitel (id) und zitat (wörtlich).'); + } + return z.join('\n'); +} + +/** Antwortschema – ohne Feld für Ersatztext oder Bewertung. */ +export function faktenSchema(kontext: BuchKontext, figuren: Id[]): Record { + const kapitel = { type: 'string', enum: kontext.kapitel.filter((k) => k.text.trim()).map((k) => k.id) }; + const obj = (properties: Record) => ({ type: 'object', additionalProperties: false, properties, required: Object.keys(properties) }); + return obj({ + fakten: { + type: 'array', + items: obj({ + figur: { type: 'string', enum: figuren }, + merkmal: { type: 'string' }, + wert: { type: 'string' }, + kapitel, + zitat: { type: 'string' }, + }), + }, + }); +} + +const woerter = (s: string) => s.split(/\s+/u).filter(Boolean).length; + +/** Antwort auswerten (auch einzeln testbar). */ +export function werteFaktenAus(daten: unknown, p: AufgabenPruefung, figuren: Id[]): FaktenErgebnis { + const ergebnis: FaktenErgebnis = { fakten: [], figuren }; + const m = meldungenIn(p.oberflaeche); + const d = (daten ?? {}) as Record; + if (!d || typeof d !== 'object' || !Array.isArray(d.fakten)) { + p.verwerfen(m('schemaFalsch'), daten); + return ergebnis; + } + const erlaubt = new Set(figuren); + const reihe = new Map(p.kontext.kapitel.map((k, i) => [k.id, i])); + const gesehen = new Set(); + for (const roh of d.fakten) { + const f = (roh ?? {}) as Record; + const figur = p.figur(f.figur); + if (!figur || !erlaubt.has(figur.id)) { + p.verwerfen(m('unbekannteFigur', { name: String(f.figur ?? '') }), roh); + continue; + } + const merkmal = p.text(f.merkmal, roh, { pflicht: true }); + const wert = p.text(f.wert, roh, { pflicht: true }); + if (merkmal === null || wert === null) continue; + if (woerter(merkmal) > MERKMAL_MAX_WOERTER || woerter(wert) > WERT_MAX_WOERTER) { + p.verwerfen(m('faktZuLang'), roh); + continue; + } + const zitate = p.zitate([{ kapitel: f.kapitel, text: f.zitat }], roh); + if (!zitate) continue; + const zitat = zitate[0]!; + const schluessel = faktSchluessel(figur.id, merkmal, zitat); + if (gesehen.has(schluessel)) continue; + gesehen.add(schluessel); + ergebnis.fakten.push({ schluessel, figur: figur.id, merkmal, wert, kapitel: zitat.kapitel, zitat, status: 'neu' }); + } + ergebnis.fakten.sort((a, b) => (reihe.get(a.kapitel) ?? 0) - (reihe.get(b.kapitel) ?? 0)); + return ergebnis; +} + +/** Figuren eines Auftrags: eine bestimmte oder alle außer Statisten. */ +function figurenFuer(kontext: BuchKontext, auftrag: FaktenAuftrag): KontextFigur[] { + if (auftrag.figur) return kontext.figuren.filter((f) => f.id === auftrag.figur); + return kontext.figuren.filter((f) => f.rolle !== 'statist'); +} + +export function faktenDefinition(auftrag: FaktenAuftrag): AufgabenDefinition { + const rolle: KiRolle = auftrag.tiefencheck ? 'tiefencheck' : 'pruefung'; + return { + name: 'Figurenfakten', + nameEn: meldungenIn('en')('nameFakten'), + art: FAKTEN_ART, + attribute: auftrag.figur ? { figur: auftrag.figur } : {}, + rolle, + system: SYSTEM_FAKTEN, + systemEn: SYSTEM_FAKTEN_EN, + ausgabeSchaetzung: auftrag.tiefencheck ? 8000 : 4000, + aufgabe(kontext) { + return aufgabenText(kontext, figurenFuer(kontext, auftrag)); + }, + schema: (kontext) => faktenSchema(kontext, figurenFuer(kontext, auftrag).map((f) => f.id)), + auswerten: (daten, p) => werteFaktenAus(daten, p, figurenFuer(p.kontext, auftrag).map((f) => f.id)), + }; +} + +/** Sammelt die Figurenfakten (eine Anfrage). Schreibt nichts. */ +export function sammleFakten( + leser: ProjektLeser, + transport: KiTransport, + einstellungen: KiEinstellungen | undefined, + auftrag: FaktenAuftrag, + optionen: AufgabenOptionen = {}, +): Promise> { + return fuehreAufgabeAus(leser, transport, einstellungen, faktenDefinition(auftrag), optionen); +} + +/** Schätzung vor dem Start (Tokens, Kosten). */ +export function schaetzeFakten( + leser: ProjektLeser, + transport: KiTransport, + einstellungen: KiEinstellungen | undefined, + auftrag: FaktenAuftrag, + oberflaeche: Sprache = 'de', +): Promise { + return schaetzeAufgabe(leser, transport, einstellungen, faktenDefinition(auftrag), oberflaeche); +} diff --git a/core/ki/index.ts b/core/ki/index.ts index 517fa5f..bc1e889 100644 --- a/core/ki/index.ts +++ b/core/ki/index.ts @@ -53,6 +53,7 @@ export * from './aufgabe'; /** G1 Zeitpunkte und Zustände · G3 Figurenstimme · G6 Leser-Simulation und Fragen (Besitzer: die Gruppen) */ export * from './zeit'; export * from './stimme'; +export * from './fakten'; export * from './leser'; /** Transport zur Messages-API (Foundry oder Claude API). In der App: Rust-Kommando ki_anfrage. */ diff --git a/core/ki/kontext.ts b/core/ki/kontext.ts index 4f53d02..5ef7d4b 100644 --- a/core/ki/kontext.ts +++ b/core/ki/kontext.ts @@ -11,7 +11,7 @@ * Beschriftungen im Prefix englisch („Characters:“, „Existence: …“, „Planned scene: …“); Tags und Attribute * (, , , , , ) bleiben gleich. */ -import type { GruppenTyp, Id, ProjektDaten, Realitaet, TextAnker } from '../model/types'; +import type { FigurRolle, GruppenTyp, Id, ProjektDaten, Realitaet, TextAnker } from '../model/types'; import { klartext, textHash } from '../text'; import { kiSprache, type Sprache } from './sprache'; @@ -32,7 +32,7 @@ export interface KontextFigur { id: Id; name: string; aliase: string[]; - rolle: 'haupt' | 'neben'; + rolle: FigurRolle; existenz: Realitaet; /** nur lokal für die Prüfung (Genitiv-/Wortformen), wird nicht gesendet */ formen: string[]; @@ -99,6 +99,7 @@ const BESCHRIFTUNG = { auch: 'auch', haupt: 'Hauptfigur', neben: 'Nebenfigur', + statist: 'Statist', existenz: 'Existenz', planung: 'Planung', imText: 'im Text', @@ -120,6 +121,7 @@ const BESCHRIFTUNG = { auch: 'also', haupt: 'main character', neben: 'minor character', + statist: 'walk-on character', existenz: 'Existence', planung: 'Plan', imText: 'in the text', @@ -178,7 +180,7 @@ export function baueBuchKontext(daten: ProjektDaten, sprache: Sprache = kiSprach id: f.id, name: f.name, aliase: [...(f.aliase ?? [])], - rolle: f.rolle === 'haupt' ? ('haupt' as const) : ('neben' as const), + rolle: f.rolle, existenz: f.existenz, formen: [...(f.formen ?? [])], plan: Object.entries(f.plan ?? {}) @@ -229,7 +231,7 @@ function renderBuch( for (const f of figuren) { const teile = [`${f.name} (id: ${f.id})`]; if (f.aliase.length) teile.push(`${B.auch}: ${f.aliase.join(', ')}`); - teile.push(f.rolle === 'haupt' ? B.haupt : B.neben); + teile.push(B[f.rolle]); teile.push(`${B.existenz}: ${B.stufe[f.existenz]}`); z.push(`- ${teile.join(' · ')}`); for (const p of f.plan) z.push(` ${B.planung} · ${p.feld}: ${p.wert}`); diff --git a/core/ki/meldungen.ts b/core/ki/meldungen.ts index cc08188..2e94c16 100644 --- a/core/ki/meldungen.ts +++ b/core/ki/meldungen.ts @@ -107,6 +107,8 @@ const de = { // Figurenstimme (stimme.ts) nameStimme: 'Figurenstimme', + nameFakten: 'Figurenfakten', + faktZuLang: 'Merkmal (höchstens drei Wörter) oder Wert (höchstens acht Wörter) zu lang', vergleichGleicheStelle: 'Vergleich nennt dieselbe Stelle wie die Auffälligkeit', vageFigurNurDeutlich: 'bewusst vage Figur „{name}“: nur deutliche Auffälligkeiten', @@ -212,6 +214,8 @@ const en: typeof de = { zustandUnbekannt: 'unknown state “{zustand}”', nameStimme: 'Character voice', + nameFakten: 'Character facts', + faktZuLang: 'trait (at most three words) or value (at most eight words) too long', vergleichGleicheStelle: 'Comparison cites the same passage as the observation', vageFigurNurDeutlich: 'deliberately vague character “{name}”: only clear observations', diff --git a/core/ki/stimme.ts b/core/ki/stimme.ts index 4ba9f45..4b34567 100644 --- a/core/ki/stimme.ts +++ b/core/ki/stimme.ts @@ -87,7 +87,7 @@ export function stimmenSchluessel(figur: Id, zitate: Zitat[]): string { } function figurZeileEn(f: KontextFigur): string { - const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'main character' : 'minor character']; + const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'main character' : f.rolle === 'statist' ? 'walk-on character' : 'minor character']; if (f.aliase.length) teile.push(`also called: ${f.aliase.join(', ')}`); if (f.existenz === 'bewusst_vage') teile.push('deliberately vague'); else if (f.existenz === 'offen') teile.push('existence still open'); @@ -95,7 +95,7 @@ function figurZeileEn(f: KontextFigur): string { } function figurZeile(f: KontextFigur): string { - const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'Hauptfigur' : 'Nebenfigur']; + const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'Hauptfigur' : f.rolle === 'statist' ? 'Statist' : 'Nebenfigur']; if (f.aliase.length) teile.push(`auch genannt: ${f.aliase.join(', ')}`); if (f.existenz === 'bewusst_vage') teile.push('bewusst vage'); else if (f.existenz === 'offen') teile.push('Existenz noch offen'); diff --git a/core/ki/vorschlaege.test.ts b/core/ki/vorschlaege.test.ts index d72aa9e..a8a2f84 100644 --- a/core/ki/vorschlaege.test.ts +++ b/core/ki/vorschlaege.test.ts @@ -226,6 +226,63 @@ describe('Antwortprüfung', () => { expect(r.bereitsAngelegt).toBe(1); }); + it('Spitznamen: Eigenname wird Name, Bezeichnung Alias; Spitzname einer angelegten Figur legt keine zweite an', async () => { + const fiete = beleg('funkstille', 'ob er die Krabben wieder für Fiete mitnimmt'); + const ole = beleg('grundriss', 'Ole sitzt auf der Fensterbank neben dem Oleander und isst einen Apfel.'); + const antwort = { + figuren: [ + { name: 'der Krabbenmann', aliase: ['Fiete', 'Fietje'], rolle: 'neben', kurz: '', belege: [fiete] }, + { name: 'der Mann am Fenster', aliase: ['Ole'], rolle: 'neben', kurz: '', belege: [ole] }, + { name: 'die Stimme am Telefon', aliase: ['die Anruferin'], rolle: 'neben', kurz: '', belege: [fiete] }, + ], + orte: [], + beziehungen: [ + { von: 'der Mann am Fenster', zu: 'der Krabbenmann', art: 'Bekannter', kategorie: 'sonstige', abKapitel: 'funkstille', unsicher: false, belege: [fiete], begruendung: '' }, + ], + }; + const r = await pruefe(antwort); + expect(r.figuren.map((f) => [f.schluessel, f.name, f.aliase, f.ziel])).toEqual([ + ['figur:fiete', 'Fiete', ['der Krabbenmann', 'Fietje'], undefined], + // F-10: der Spitzname einer angelegten Figur wird kein neuer Figurvorschlag, sondern ein Spitzname für Ole + ['spitzname:ole|der-mann-am-fenster', 'der Mann am Fenster', [], 'ole'], + // ohne Eigennamen bleibt die Bezeichnung der Name + ['figur:die-stimme-am-telefon', 'die Stimme am Telefon', ['die Anruferin'], undefined], + ]); + expect(r.bereitsAngelegt).toBe(0); + // die Beziehung nennt Ole beim Spitznamen und wird trotzdem der angelegten Figur zugeordnet + expect(r.beziehungen.map((b) => [b.von, b.zu])).toEqual([ + [{ typ: 'angelegt', id: 'ole', name: 'Ole' }, { typ: 'vorschlag', id: 'figur:fiete', name: 'Fiete' }], + ]); + + // früher unter dem Spitznamen verworfen → kommt unter dem Eigennamen nicht wieder + const daten = await ladeProjekt(fsMit()); + daten.kiVorschlaege = { + version: 1, + figuren: [{ schluessel: 'figur:der-krabbenmann', name: 'der Krabbenmann', aliase: [], rolle: 'neben', kurz: '', belege: [], status: 'verworfen' }], + orte: [], + beziehungen: [], + }; + const zweiter = pruefeVorschlaege(daten, antwort); + expect(zweiter.figuren.map((f) => f.name)).toEqual(['der Mann am Fenster', 'die Stimme am Telefon']); + expect(zweiter.bereitsEntschieden).toBe(2); + }); + + it('F-10: Spitznamen-Vorschlag ist offen, bis die Figur alle Namen trägt; Übernehmen ergänzt sie, Ablage hält „ziel“', async () => { + const ole = beleg('grundriss', 'Ole sitzt auf der Fensterbank neben dem Oleander und isst einen Apfel.'); + const daten = await ladeProjekt(fsMit()); + const r = pruefeVorschlaege(daten, { + figuren: [{ name: 'der Mann am Fenster', aliase: ['Ole', 'Fenster-Ole'], rolle: 'neben', kurz: '', belege: [ole] }], + orte: [], + beziehungen: [], + }); + expect(r.figuren).toEqual([expect.objectContaining({ ziel: 'ole', name: 'der Mann am Fenster', aliase: ['Fenster-Ole'] })]); + const { ablage } = mischeVorschlaege(undefined, r); + expect(parseKiVorschlaege(serializeKiVorschlaege(ablage)).figuren[0]!.ziel).toBe('ole'); + expect(offeneVorschlaege(ablage, daten).figuren).toHaveLength(1); + daten.figuren.ole = { ...daten.figuren.ole!, aliase: ['der Mann am Fenster', 'Fenster-Ole'] }; + expect(offeneVorschlaege(ablage, daten).figuren).toHaveLength(0); + }); + it('Ersatztext-Filter auf kurz, art und begruendung; art höchstens vier Wörter; abKapitel und Kategorie werden geprüft', async () => { const z = beleg('funkstille', 'ob er die Krabben wieder für Fiete mitnimmt'); const r = await pruefe({ diff --git a/core/ki/vorschlaege.ts b/core/ki/vorschlaege.ts index 1adf1db..571a86a 100644 --- a/core/ki/vorschlaege.ts +++ b/core/ki/vorschlaege.ts @@ -61,8 +61,9 @@ Du machst Vorschläge, die der Autor einzeln übernimmt oder verwirft. Du schrei Figuren (figuren): - Schlage jede Person vor, die im Text handelt, spricht oder mit Namen bzw. fester Bezeichnung erwähnt wird (zum Beispiel „der Mann am Fischstand“), und die noch nicht unter den angelegten Figuren steht. Ein namenloser Ich-Erzähler ist keine eigene Figur, wenn er erkennbar eine angelegte oder vorgeschlagene Figur ist. -- name: wie die Person im Text am häufigsten heißt. aliase: weitere Namen und Bezeichnungen derselben Person, Zeichen für Zeichen wie im Text, ohne Genitivformen. -- rolle: haupt für Figuren, die das Buch tragen (Erzähler, wiederkehrende zentrale Figuren), sonst neben. +- name: der Eigenname der Person (Vorname, Nachname oder beides), sobald der Text ihn irgendwo nennt, auch wenn sie meist anders genannt wird. Spitznamen, Titel und Bezeichnungen (zum Beispiel „die Chefin“, „der Alte“) gehören dann zu aliase. Nur wenn der Text keinen Eigennamen nennt, ist name die Bezeichnung, unter der die Person am häufigsten vorkommt. aliase: weitere Namen, Spitznamen und Bezeichnungen derselben Person, Zeichen für Zeichen wie im Text, ohne Genitivformen. +- Eine Person ist genau ein Vorschlag: Schlage niemanden zweimal unter verschiedenen Namen vor. +- rolle: haupt für Figuren, die das Buch tragen (Erzähler, wiederkehrende zentrale Figuren); statist für benannte Figuren, die höchstens in zwei Szenen vorkommen und keine eigene Handlung tragen (zum Beispiel eine Kellnerin, die einmal bedient); sonst neben. - kurz: eine sachliche Zeile, höchstens vier Wörter, was der Text über die Figur sagt (zum Beispiel „Kollege der Hauptfigur“ oder „Jugendfreundin“); sie steht als Untertitel auf der Karte im Beziehungsnetz. Keine Deutung, keine Vermutung über Verborgenes. - belege: eine bis drei Stellen, an denen die Figur vorkommt. @@ -85,7 +86,7 @@ Regeln für alle Belege: - kapitel ist die id des Kapitels, zitat eine zusammenhängende Passage, Zeichen für Zeichen aus dem dieses Kapitels kopiert, ohne Auslassungen, höchstens ein Satz. Vorschläge ohne wörtliches Zitat werden verworfen. Weitere Regeln: -- Angelegte Figuren und Orte (unter ) schlägst du nicht erneut vor, auch nicht unter einer anderen Schreibweise. Beziehungen zwischen angelegten Figuren darfst du vorschlagen. +- Angelegte Figuren und Orte (unter ) schlägst du nicht erneut vor, auch nicht unter einer anderen Schreibweise, einem Spitznamen oder einer Bezeichnung. Beziehungen zwischen angelegten Figuren darfst du vorschlagen. - Als „bewusst vage“ markierte Figuren und Stellen sind absichtlich mehrdeutig. Lege über sie nichts fest, was der Text offenlässt. - Lieber wenige belastbare Vorschläge als viele vage. Erfinde nichts, was nicht im Text steht.`; @@ -96,8 +97,9 @@ You make suggestions which the author adopts or discards one by one. You never w Characters (figuren): - Suggest every person who acts, speaks or is mentioned by name or by a fixed designation in the text (for example “the man at the kiosk”) and who is not yet among the characters set up. A nameless first-person narrator is not a separate character if they are recognisably a character that is set up or suggested. -- name: what the person is called most often in the text. aliase: further names and designations of the same person, character for character as in the text, without possessive forms (’s). -- rolle: haupt for characters who carry the book (narrators, recurring central characters), otherwise neben. +- name: the person's proper name (first name, surname or both) as soon as the text gives it anywhere, even if they are mostly called something else. Nicknames, titles and designations (for example “the boss”, “the old man”) then belong in aliase. Only if the text gives no proper name is name the designation the person appears under most often. aliase: further names, nicknames and designations of the same person, character for character as in the text, without possessive forms (’s). +- One person is exactly one suggestion: never suggest someone twice under different names. +- rolle: haupt for characters who carry the book (narrators, recurring central characters); statist for named characters who appear in at most two scenes and carry no plot of their own (for example a waitress who serves once); otherwise neben. - kurz: one factual line, at most four words, on what the text says about the character (for example “Paul's colleague” or “Kleinanzeigen match”); it appears as a subtitle on the card in the relationship web. No interpretation, no speculation about hidden things. - belege: one to three passages where the character appears. @@ -120,7 +122,7 @@ Rules for all evidence: - kapitel is the id of the chapter, zitat one continuous passage, copied character for character from the of that chapter, without omissions, at most one sentence. Suggestions without a verbatim quote are discarded. Further rules: -- Do not suggest characters and places that are set up (under ) again, not even under a different spelling. You may suggest relationships between characters that are set up. +- Do not suggest characters and places that are set up (under ) again, not even under a different spelling, a nickname or a designation. You may suggest relationships between characters that are set up. - Characters and passages marked “deliberately vague” are intentionally ambiguous. Settle nothing about them that the text leaves open. - Prefer a few solid suggestions to many vague ones. Invent nothing that is not in the text. @@ -149,7 +151,7 @@ export function vorschlagsSchema(kontext: BuchKontext): Record obj({ name: str, aliase: liste(str), - rolle: { type: 'string', enum: ['haupt', 'neben'] }, + rolle: { type: 'string', enum: ['haupt', 'neben', 'statist'] }, kurz: str, belege, }), @@ -392,6 +394,9 @@ export async function werteVorschlagsAntwortAus( // --------------------------------------------------------------------------- export const figurSchluessel = (name: string): string => `figur:${loseEndeId(name)}`; +/** F-10: Spitznamen für eine angelegte Figur */ +export const spitznamenSchluessel = (figur: Id, namen: string[]): string => + `spitzname:${figur}|${namen.map(loseEndeId).sort().join('+')}`; export const ortSchluessel = (name: string): string => `ort:${loseEndeId(name)}`; /** „beziehung:|@“ – Paar alphabetisch, damit die Richtung keine Rolle spielt. */ @@ -429,6 +434,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 +548,47 @@ export function pruefeVorschlaege( for (const r of rohFiguren as unknown[]) { const f = (r ?? {}) as Record; - const name = nameSauber(f.name); + const rohName = nameSauber(f.name); + const { name, aliase } = hauptname(rohName, aliaseSauber(f.aliase, rohName)); + const namen = [name, ...aliase]; const weg = (grund: string) => { verworfen.push({ grund, roh: r }); - if (name) figurVerworfen.set(namensForm(name), { grund, entschieden: false }); + for (const n of namen) if (n) figurVerworfen.set(namensForm(n), { grund, entschieden: false }); }; if (!name) { verworfen.push({ grund: m('figurOhneNamen'), roh: r }); continue; } - if (bekannteFigur(name)) { - bereitsAngelegt += 1; + // auch über einen Alias: „der Alte“ mit Alias „Ole“ ist die angelegte Figur Ole, keine neue + const angelegt = namen.map(bekannteFigur).find((x) => x); + if (angelegt) { + // Beziehungen dürfen die Figur unter den Namen dieses Vorschlags nennen + const bekannt = new Set(figurNamen(angelegt).map(namensForm)); + const neueNamen = namen.filter((n) => !bekannt.has(namensForm(n)) && !figurNachForm.has(namensForm(n))); + for (const n of namen) if (!figurNachForm.has(namensForm(n))) figurNachForm.set(namensForm(n), angelegt); + // F-10: Namen, die die angelegte Figur noch nicht kennt, als Spitznamen vorschlagen + const belege = neueNamen.length ? belegePruefen(f.belege) : []; + if (neueNamen.length === 0 || typeof belege === 'string') { + bereitsAngelegt += 1; + continue; + } + const schluessel = spitznamenSchluessel(angelegt.id, neueNamen); + if (figurEntschieden.has(schluessel)) { + bereitsEntschieden += 1; + continue; + } + const doppelt = figuren.find((x) => x.schluessel === schluessel); + if (doppelt) continue; + figuren.push({ + schluessel, + name: neueNamen[0]!, + aliase: neueNamen.slice(1), + rolle: angelegt.rolle, + kurz: '', + belege, + status: 'neu', + ziel: angelegt.id, + }); continue; } const belege = belegePruefen(f.belege); @@ -560,17 +608,18 @@ export function pruefeVorschlaege( for (const b of belege) if (!doppelt.belege.some((x) => x.kapitel === b.kapitel && x.text === b.text)) doppelt.belege.push(b); continue; } - const status = figurEntschieden.get(schluessel); + // entschieden auch unter einem anderen Namen derselben Person (z. B. früher als „der Alte“ vorgeschlagen) + const status = namen.map((n) => figurEntschieden.get(figurSchluessel(n))).find((x) => x); if (status) { bereitsEntschieden += 1; - if (status === 'verworfen') figurVerworfen.set(namensForm(name), { grund: m('figurVerworfen', { name }), entschieden: true }); + if (status === 'verworfen') for (const n of namen) figurVerworfen.set(namensForm(n), { grund: m('figurVerworfen', { name }), entschieden: true }); continue; } const vorschlag: FigurVorschlag = { schluessel, name, - aliase: aliaseSauber(f.aliase, name), - rolle: (f.rolle === 'haupt' ? 'haupt' : 'neben') satisfies FigurRolle, + aliase, + rolle: (f.rolle === 'haupt' || f.rolle === 'statist' ? f.rolle : 'neben') satisfies FigurRolle, kurz, belege, status: 'neu', @@ -782,10 +831,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 +858,8 @@ export function figurVerweisAufloesen( const v = ablage.figuren.find((f) => f.schluessel === verweis.id); if (!v || v.status === 'verworfen') return null; if (v.status === 'uebernommen') return v.figur && daten.figuren[v.figur] ? { figur: v.figur } : null; - const vorhanden = figurMitNamen(daten, v.name); + if (v.ziel) return daten.figuren[v.ziel] ? { figur: v.ziel } : null; + const vorhanden = figurMitNamen(daten, v.name, ...v.aliase); return vorhanden ? { figur: vorhanden } : { vorschlag: v }; } @@ -823,7 +876,17 @@ export interface OffeneVorschlaege { */ export function offeneVorschlaege(ablage: KiVorschlaege | undefined, daten: ProjektDaten): OffeneVorschlaege { if (!ablage) return { figuren: [], orte: [], beziehungen: [], anzahl: 0 }; - const figuren = ablage.figuren.filter((f) => f.status === 'neu' && !figurMitNamen(daten, f.name)); + const figuren = ablage.figuren.filter((f) => { + if (f.status !== 'neu') return false; + // Spitznamen-Vorschlag: offen, solange die Figur da ist und einen der Namen noch nicht trägt + if (f.ziel) { + const z = daten.figuren[f.ziel]; + if (!z) return false; + const bekannt = new Set(figurNamen(z).map(namensForm)); + return [f.name, ...f.aliase].some((n) => !bekannt.has(namensForm(n))); + } + return !figurMitNamen(daten, f.name, ...f.aliase); + }); const orte = ablage.orte.filter((o) => o.status === 'neu' && !ortMitNamen(daten, o.name)); const beziehungen = ablage.beziehungen.filter((b) => { if (b.status !== 'neu') return false; diff --git a/core/ki/zeit.ts b/core/ki/zeit.ts index 7202ebf..eb45a30 100644 --- a/core/ki/zeit.ts +++ b/core/ki/zeit.ts @@ -458,11 +458,11 @@ export function schaetzeZeitpunkte( function figurZeile(f: KontextFigur, sprache?: Sprache): string { if (sprache === 'en') { - const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'main character' : 'minor character']; + const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'main character' : f.rolle === 'statist' ? 'walk-on character' : 'minor character']; if (f.aliase.length) teile.push(`also called: ${f.aliase.join(', ')}`); return teile.join(' · '); } - const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'Hauptfigur' : 'Nebenfigur']; + const teile = [`${f.name} (id: ${f.id})`, f.rolle === 'haupt' ? 'Hauptfigur' : f.rolle === 'statist' ? 'Statist' : 'Nebenfigur']; if (f.aliase.length) teile.push(`auch genannt: ${f.aliase.join(', ')}`); return teile.join(' · '); } diff --git a/core/model/types.ts b/core/model/types.ts index 65bbf80..ee80da2 100644 --- a/core/model/types.ts +++ b/core/model/types.ts @@ -182,7 +182,18 @@ export interface Beziehung { unsicher?: boolean; } -export type FigurRolle = 'haupt' | 'neben'; +export type FigurRolle = 'haupt' | 'neben' | 'statist'; + +/** F-16: Kontext zu einem Spitznamen (der Name selbst steht in `Figur.aliase`, das die Erkennung speist). */ +export interface SpitznameKontext { + /** wie in `aliase` */ + name: string; + /** Figuren, die die Figur so nennen */ + von?: Id[]; + /** gilt ab diesem Kapitel (früher im Text → Hinweis) */ + abKapitel?: Id; + notiz?: string; +} /** figuren/.md – Body-Abschnitte „## Wahrheit hinter den Kulissen“ und „## Notizen“ (beide privat) */ export interface Figur { @@ -195,6 +206,7 @@ export interface Figur { /** Kurzbeschreibung für Listen und Netzkarten, z. B. „Jugendfreundin“, „Ich-Erzähler“ (Plan, nicht an die KI) */ kurz?: string; existenz: Realitaet; + /** Hauptgruppe (Farbe, Platz im Netz); maßgeblich für die Mitgliedschaft ist Gruppe.mitglieder (A-05: mehrere Gruppen) */ gruppe: Id | null; beziehungen: Beziehung[]; /** Freie Planungsfelder („Deine Planung“), Reihenfolge = Anzeige-Reihenfolge */ @@ -205,6 +217,13 @@ export interface Figur { notizen: string; /** Position der Karte im Beziehungsnetz */ netz?: { x: number; y: number }; + /** + * F-13: nach so vielen Kapiteln ohne Vorkommen melden; null = nie; fehlt = nach Rolle + * (Hauptfigur 3, Nebenfigur 6, Statist nie) + */ + ruheSchwelle?: number | null; + /** F-16: Kontext je Spitzname (nur für Namen in `aliase`) */ + spitznamen?: SpitznameKontext[]; } export type GruppenTyp = 'antagonist' | 'firma' | 'familie' | 'sonstige'; @@ -232,6 +251,8 @@ export interface Gruppe { notizen: string; /** Fläche im Beziehungsnetz */ netz?: { x: number; y: number; breite: number; hoehe: number }; + /** A-06: Rolle eines Mitglieds in der Gruppe (Figur-ID → z. B. „Chef“, „Praktikant“) */ + rollen?: Record; } /** orte/.md – Body = Beschreibung (Plan), Abschnitt „## Notizen“ privat */ @@ -349,7 +370,9 @@ export type HinweisTyp = | 'unbekannt' | 'struktur' | 'zeitleiste' - | 'sprache'; + | 'sprache' + /** lokal: Namen und Vorkommen von Figuren (F-11–F-13) */ + | 'figuren'; export interface Zitat { kapitel: Id; @@ -451,6 +474,8 @@ export interface ProjektDaten { kiZustandVorschlaege?: KiAblage; /** G3 .plotwerk/stimmen.json */ kiStimmen?: KiAblage; + /** F-17 .plotwerk/figurenfakten.json */ + kiFakten?: KiAblage; /** G6 .plotwerk/leser.json */ kiLeser?: KiAblage; /** G6 .plotwerk/fragen.json */ @@ -487,6 +512,11 @@ export interface FigurVorschlag { status: VorschlagsStatus; /** nach der Übernahme: ID der angelegten Figur */ figur?: Id; + /** + * F-10: keine neue Figur, sondern Spitznamen (`name` und `aliase`) für diese angelegte Figur; + * Schlüssel „spitzname:|“ + */ + ziel?: Id; } export interface OrtVorschlag { @@ -728,6 +758,25 @@ export interface StimmenBeobachtung { status: KiBeobachtungsStatus; } +/** + * F-17: Angabe über eine Figur, wie sie im Text steht (KI, wörtlich belegt). Widersprüche (gleiches Merkmal, + * verschiedene Werte) und Abweichungen von der Planung findet die App selbst (core/figuren/fakten.ts). + */ +export interface FigurFakt { + /** stabiler Schlüssel „fakt:||/“ */ + schluessel: string; + figur: Id; + /** Merkmal, z. B. „Alter“, „Beruf“, „Auto“ (möglichst wie ein Planfeld der Figur benannt) */ + merkmal: string; + /** Wert laut Text, kurz, z. B. „34“, „alter Saab 900“ */ + wert: string; + kapitel: Id; + /** Beleg (wörtlich, E6) */ + zitat: Zitat; + /** „verworfen“: Leon hält die Angabe für falsch erkannt; kommt nicht wieder */ + status: 'neu' | 'verworfen'; +} + // --------------------------------------------------------------------------- // G4 Editor – plan/marken.md // --------------------------------------------------------------------------- diff --git a/core/projekt/extern.ts b/core/projekt/extern.ts index b17100e..4c1ab91 100644 --- a/core/projekt/extern.ts +++ b/core/projekt/extern.ts @@ -37,6 +37,7 @@ const WERKZEUG_DATEIEN: [string, keyof ProjektDaten][] = [ [PFADE.kiStimmen, 'kiStimmen'], [PFADE.kiLeser, 'kiLeser'], [PFADE.kiFragen, 'kiFragen'], + [PFADE.kiFakten, 'kiFakten'], ]; const imOrdner = (pfad: string, ordner: string, endung: string) => diff --git a/core/projekt/lesen.ts b/core/projekt/lesen.ts index 3e3a7e3..f90c002 100644 --- a/core/projekt/lesen.ts +++ b/core/projekt/lesen.ts @@ -25,6 +25,7 @@ import type { Recherche, RechercheEintrag, Schreibstatistik, + FigurFakt, StimmenBeobachtung, Szene, Tageszeit, @@ -50,6 +51,7 @@ import type { Projekt, ProjektDaten, RealitaetsEintrag, + SpitznameKontext, Spur, Strang, TextAnker, @@ -103,6 +105,8 @@ export const PFADE = { kiZustand: '.plotwerk/zustand-vorschlaege.json', /** G3 KI-Beobachtungen zur Figurenstimme (KiAblage) */ kiStimmen: '.plotwerk/stimmen.json', + /** F-17 Figurenfakten aus dem Text (KiAblage) */ + kiFakten: '.plotwerk/figurenfakten.json', /** G6 Leser-Simulation (KiAblage) */ kiLeser: '.plotwerk/leser.json', /** G6 Fragen an das Buch (KiAblage) */ @@ -429,6 +433,21 @@ function parsePlanFelder(v: unknown): Record { return ergebnis; } +function parseSpitznamen(v: unknown): SpitznameKontext[] | undefined { + const l = liste(v) + .filter(istObjekt) + .filter((x) => s(x.name)) + .map((x) => + ohneUndefined({ + name: s(x.name), + von: x.von === undefined ? undefined : texte(x.von), + abKapitel: sOpt(x.abKapitel), + notiz: sOpt(x.notiz), + }), + ); + return l.length ? l : undefined; +} + export const FIGUR_ABSCHNITTE = { wahrheit: 'Wahrheit hinter den Kulissen', notizen: 'Notizen' } as const; export function parseFigur(text: string, id?: Id): Figur { @@ -444,7 +463,7 @@ export function parseFigur(text: string, id?: Id): Figur { name: s(daten.name) || fid, aliase: texte(daten.aliase), formen: daten.formen === undefined ? undefined : texte(daten.formen), - rolle: eines(daten.rolle, ['haupt', 'neben'] as const, 'neben'), + rolle: eines(daten.rolle, ['haupt', 'neben', 'statist'] as const, 'neben'), kurz: sOpt(daten.kurz), existenz: eines(daten.existenz, REALITAET, 'gesichert'), gruppe: sOpt(daten.gruppe) ?? null, @@ -453,6 +472,8 @@ export function parseFigur(text: string, id?: Id): Figur { wahrheit, notizen: verbinde(notizen, rest), netz, + ruheSchwelle: daten.ruheSchwelle === null ? null : typeof daten.ruheSchwelle === 'number' ? Math.max(1, Math.round(daten.ruheSchwelle)) : undefined, + spitznamen: parseSpitznamen(daten.spitznamen), }); } @@ -496,6 +517,7 @@ export function parseGruppe(text: string, id?: Id): Gruppe { praesenz, notizen: verbinde(notizen, rest), netz, + rollen: istObjekt(daten.rollen) && Object.keys(daten.rollen).length ? parsePlanFelder(daten.rollen) : undefined, }); } @@ -600,7 +622,7 @@ export function parseAnalyse(text: string, id?: Id): KapitelAnalyse { }; } -const HINWEIS_TYPEN = ['pruefen', 'schreibweise', 'offener_faden', 'unbekannt', 'struktur', 'zeitleiste', 'sprache'] as const; +const HINWEIS_TYPEN = ['pruefen', 'schreibweise', 'offener_faden', 'unbekannt', 'struktur', 'zeitleiste', 'sprache', 'figuren'] as const; export function parseHinweise(text: string): Hinweis[] { return liste(json(text)) @@ -652,11 +674,12 @@ export function parseKiVorschlaege(text: string): KiVorschlaegeT { schluessel: s(f.schluessel), name: s(f.name), aliase: texte(f.aliase), - rolle: eines(f.rolle, ['haupt', 'neben'] as const, 'neben'), + rolle: eines(f.rolle, ['haupt', 'neben', 'statist'] as const, 'neben'), kurz: s(f.kurz), belege: parseBelege(f.belege), status: eines(f.status, VORSCHLAGS_STATUS, 'neu'), figur: sOpt(f.figur), + ziel: sOpt(f.ziel), }), ), orte: liste(d.orte) @@ -1013,6 +1036,22 @@ export function parseKiZustandVorschlaege(text: string): KiAblage { + return parseKiAblage(text, (v) => { + const zitat = parseZitate([v.zitat])[0]; + if (!s(v.schluessel) || !s(v.figur) || !s(v.merkmal) || !zitat) return null; + return { + schluessel: s(v.schluessel), + figur: s(v.figur), + merkmal: s(v.merkmal), + wert: s(v.wert), + kapitel: s(v.kapitel) || zitat.kapitel, + zitat, + status: eines(v.status, ['neu', 'verworfen'] as const, 'neu'), + }; + }); +} + export function parseKiStimmen(text: string): KiAblage { return parseKiAblage(text, (v) => { if (!s(v.schluessel) || !s(v.figur)) return null; @@ -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, kiFakten] = await Promise.all([ lies(PFADE.kiZeit, parseKiZeitVorschlaege), lies(PFADE.kiZustand, parseKiZustandVorschlaege), lies(PFADE.kiStimmen, parseKiStimmen), lies(PFADE.kiLeser, parseKiLeser), lies(PFADE.kiFragen, parseKiFragen), + lies(PFADE.kiFakten, parseKiFakten), ]); const projekt: Projekt = projektRoh ?? { version: 1, titel: '', kapitel: [], notiz: '' }; @@ -1237,6 +1277,7 @@ export async function ladeProjektMitBericht(leser: ProjektLeser, sprache: LadeSp ...(kiStimmen ? { kiStimmen } : {}), ...(kiLeser ? { kiLeser } : {}), ...(kiFragen ? { kiFragen } : {}), + ...(kiFakten ? { kiFakten } : {}), }, warnungen, }; diff --git a/core/projekt/schreiben.ts b/core/projekt/schreiben.ts index 49713ec..9c6302c 100644 --- a/core/projekt/schreiben.ts +++ b/core/projekt/schreiben.ts @@ -20,6 +20,7 @@ import type { Markenliste, Recherche, Schreibstatistik, + FigurFakt, StimmenBeobachtung, Szene, Zeitleiste, @@ -75,9 +76,9 @@ const S = { strang: ['id', 'name', 'farbe', 'gruppe'], idee: ['id', 'titel', 'notiz', 'straenge'], motiv: ['id', 'name', 'aliase'], - figur: ['id', 'name', 'aliase', 'formen', 'rolle', 'kurz', 'existenz', 'gruppe', 'beziehungen', 'plan', 'netz'], + figur: ['id', 'name', 'aliase', 'formen', 'rolle', 'kurz', 'existenz', 'gruppe', 'beziehungen', 'plan', 'netz', 'ruheSchwelle', 'spitznamen'], beziehung: ['zu', 'art', 'abKapitel', 'kategorie', 'strang', 'notiz', 'unsicher'], - gruppe: ['id', 'name', 'typ', 'status', 'mitglieder', 'strang', 'spuren', 'praesenz', 'netz'], + gruppe: ['id', 'name', 'typ', 'status', 'mitglieder', 'strang', 'spuren', 'praesenz', 'netz', 'rollen'], spur: ['id', 'text', 'kapitel', 'anker', 'status'], ort: ['id', 'name', 'aliase', 'in'], hinweis: ['schluessel', 'typ', 'titel', 'zitate', 'erklaerung', 'quelle', 'offen', 'status'], @@ -315,7 +316,7 @@ export function serializeHinweise(hinweise: Hinweis[]): string { /** Schlüsselreihenfolge = Typdefinitionen KiVorschlaege, FigurVorschlag, OrtVorschlag, BeziehungsVorschlag */ const SV = { ablage: ['version', 'figuren', 'orte', 'beziehungen', 'lauf'], - figur: ['schluessel', 'name', 'aliase', 'rolle', 'kurz', 'belege', 'status', 'figur'], + figur: ['schluessel', 'name', 'aliase', 'rolle', 'kurz', 'belege', 'status', 'figur', 'ziel'], ort: ['schluessel', 'name', 'aliase', 'in', 'belege', 'status', 'ort'], beziehung: ['schluessel', 'von', 'zu', 'art', 'kategorie', 'abKapitel', 'unsicher', 'belege', 'begruendung', 'status'], verweis: ['typ', 'id', 'name'], @@ -372,6 +373,7 @@ const SW = { zeitVorschlag: ['schluessel', 'kapitel', 'szene', 'zeit', 'belege', 'begruendung', 'status'], zustandVorschlag: ['schluessel', 'figur', 'kapitel', 'szene', 'zustand', 'belege', 'begruendung', 'status'], stimme: ['schluessel', 'figur', 'kapitel', 'titel', 'erklaerung', 'zitate', 'vergleich', 'status'], + fakt: ['schluessel', 'figur', 'merkmal', 'wert', 'kapitel', 'zitat', 'status'], leser: ['kapitel', 'basisHash', 'weiss', 'fragen', 'vermutungen', 'spannung', 'plan', 'modell', 'datum'], leserPlan: ['kapitel', 'aussage', 'status', 'erklaerung', 'belege'], leserAussage: ['text', 'belege'], @@ -485,6 +487,9 @@ export const serializeKiZustandVorschlaege = (a: KiAblage) => export const serializeKiStimmen = (a: KiAblage) => serializeKiAblage(a, (v) => ordne({ ...v, zitate: zitate(v.zitate), vergleich: zitate(v.vergleich) }, SW.stimme)); +export const serializeKiFakten = (a: KiAblage) => + serializeKiAblage(a, (v) => ordne({ ...v, zitat: zitate([v.zitat])[0] }, SW.fakt)); + export const serializeKiLeser = (a: KiAblage) => serializeKiAblage(a, (v) => ordne( @@ -610,6 +615,8 @@ export const speichereKiZustandVorschlaege = (fs: ProjektDateisystem, a: KiAblag export const speichereKiStimmen = (fs: ProjektDateisystem, a: KiAblage) => schreibeWennGeaendert(fs, PFADE.kiStimmen, serializeKiStimmen(a), true); +export const speichereKiFakten = (fs: ProjektDateisystem, a: KiAblage) => + schreibeWennGeaendert(fs, PFADE.kiFakten, serializeKiFakten(a), true); export const speichereKiLeser = (fs: ProjektDateisystem, a: KiAblage) => schreibeWennGeaendert(fs, PFADE.kiLeser, serializeKiLeser(a), true); diff --git a/core/recherche/index.ts b/core/recherche/index.ts index f9bb79b..34b829d 100644 --- a/core/recherche/index.ts +++ b/core/recherche/index.ts @@ -8,6 +8,7 @@ * - Recherche geht nie an die KI (E12). */ import { fmt, localeVon, texteJe, type Sprache } from '../i18n'; +import { nachRolle } from '../figuren/namen'; import type { Id, ProjektDaten, Recherche, RechercheArt, RechercheEintrag, Szene, Verweis, VerweisTyp } from '../model/types'; export const RECHERCHE_ORDNER = 'recherche/dateien'; @@ -316,7 +317,7 @@ export interface HeftZiel { /** Alles, woran Leon einen Eintrag heften kann: Figuren, Orte, Kapitel, Szenen (in dieser Reihenfolge). */ export function heftZiele(daten: ProjektDaten, sprache: Sprache = 'de'): HeftZiel[] { const liste: HeftZiel[] = []; - const figuren = Object.values(daten.figuren).sort((a, b) => (a.rolle === b.rolle ? a.name.localeCompare(b.name, 'de') : a.rolle === 'haupt' ? -1 : 1)); + const figuren = Object.values(daten.figuren).sort(nachRolle((a, b) => a.name.localeCompare(b.name, 'de'))); for (const f of figuren) liste.push({ verweis: { typ: 'figur', id: f.id }, art: 'figur', name: f.name, suche: [f.name, ...f.aliase] }); for (const o of Object.values(daten.orte).sort((a, b) => a.name.localeCompare(b.name, 'de'))) liste.push({ verweis: { typ: 'ort', id: o.id }, art: 'ort', name: o.name, suche: [o.name, ...o.aliase] }); diff --git a/docs/abnahme.md b/docs/abnahme.md index 8b878c9..13ca256 100644 --- a/docs/abnahme.md +++ b/docs/abnahme.md @@ -72,6 +72,17 @@ Legende Nachweis: Testdatei (automatisch) · „Browser“ = im Browser-Demo bed | F-04 | Notizen, Auftritte als Kapitelstreifen, Beziehungen | erfüllt | Browser | | | F-05 | Hinweis, wenn ein Plan-Feld dem Text widerspricht (Soll) | erfüllt | `core/ki/ki.test.ts` (F-05), `Figurenkarte.tsx` (`hinweiseFuerFigur`) | Plan-Felder von Figuren und Kapitelplänen gehen an das Modell (Leon, 2026-10-09). Hinweise mit Plan-Bezug (Figur/Kapitel, Feld) werden geprüft; bei bewusst vagen Figuren verworfen. Demo: Paul „Alkohol: nein, trinkt keinen“ ↔ Aperol Spritz in Kap. 9. Live nur mit Demo-KI belegt. | | F-06 | Figur vorab anlegen | erfüllt | `planung.test.ts` | | +| F-07 | Figuren zusammenführen | erfüllt | `core/figuren/zusammenfuehren.test.ts`, `gruppenFiguren.test.ts`, Browser | | +| F-08 | Spitznamen-Feld | erfüllt | `core/ki/vorschlaege.test.ts`, Browser | Feld gab es als „Auch genannt“; neu beschriftet, KI-Vorschläge gleichen Aliase ab. | +| F-09 | Blase „Figur“ (neue Figur / Spitzname) | erfüllt | `Editor.test.tsx`, Browser | | +| F-10 | KI-Vorschläge als Spitznamen | erfüllt | `core/ki/vorschlaege.test.ts`, `ki.test.tsx`, `gruppenFiguren.test.ts` | Mit Demo-KI belegt. | +| F-11 | Spitzname vor dem Namen | erfüllt | `core/figuren/hinweise.test.ts` | | +| F-12 | Verwechselbare Namen | erfüllt | `core/figuren/hinweise.test.ts` | | +| F-13 | Ruhende Figuren | erfüllt | `core/figuren/hinweise.test.ts`, Browser | | +| F-14 | Statisten | erfüllt | `gruppenFiguren.test.ts`, `core/figuren/hinweise.test.ts`, Browser | | +| F-15 | Besetzungsübersicht | erfüllt | `core/figuren/hinweise.test.ts`, Browser | | +| F-16 | Spitznamen mit Kontext | erfüllt | `Figurenkarte.test.tsx`, `core/figuren/hinweise.test.ts`, Browser | | +| F-17 | Figurenfakten | erfüllt | `core/ki/fakten.test.ts`, `core/figuren/fakten.test.ts`, `gruppenFiguren.test.ts`, `Figurenkarte.test.tsx`, Browser | Mit Demo-KI belegt; Live-Test mit echtem Modell steht aus. | | O-01 | Ortsliste mit Kapiteln, Text-Belegen, Notizen, Unterorten | erfüllt | `ansichten.test.tsx`; Browser | | | O-02 | Schreibweisen zusammenführen, gültige festlegen | erfüllt | `planung.test.ts`, `hinweise.test.tsx` | | | O-03 | Weg einer Figur (Soll) | erfüllt | `core/index/index.test.ts`, `ansichten.test.tsx` | | @@ -82,6 +93,9 @@ Legende Nachweis: Testdatei (automatisch) · „Browser“ = im Browser-Demo bed | A-01 | Zwei Antagonisten-Gruppen (Demo: Nordhaven und Konsortium) | erfüllt | `src/features/gruppen/gruppen.test.tsx` | | | A-02 | Mitglieder, Präsenz, Spuren als Vorschlag | erfüllt | `gruppen.test.tsx` | | | A-03 | Antagonisten als Stränge in Board und Überblick | erfüllt | Browser | | +| A-04 | Gruppen in der App anlegen und löschen | erfüllt | `gruppenFiguren.test.ts`, Browser | | +| A-05 | Mehrere Gruppen pro Figur | erfüllt | `gruppen.test.tsx`, `gruppenFiguren.test.ts`, Browser | | +| A-06 | Rollen in Gruppen | erfüllt | `gruppen.test.tsx`, `gruppenFiguren.test.ts` | | ## Erkennung, KI-Prüfung, Hinweis-Typen @@ -161,6 +175,7 @@ Browser-Durchlauf mit dem Demo-Projekt bei 1440 × 900 und 1100 × 700, hell und | AB-03 | EPUB 3 | erfüllt | `core/epub/epub.test.ts` | Satz gemeinsam mit der Vorschau (`core/satz`). Nicht mit epubcheck geprüft. | | AB-04 | Lektoratsmappe | erfüllt | `abgabe.test.ts` | | | AB-05 | Rückimport aus Word | erfüllt | `core/docx/rueckimport.test.ts`, `abgabe.test.tsx`; Browser | Offene Änderungen im Editor gepunktet unterstrichen. | +| AB-06 | Personenverzeichnis | erfüllt | `core/abgabe/personen.test.ts`, `gruppenFiguren.test.ts`, Browser | | | RC-01 | Recherche ablegen | erfüllt | `core/recherche/recherche.test.ts`, `src/features/recherche/recherche.test.tsx`, `src-tauri/src/recherche.rs`; Browser | AK-4 erlaubt Mediendateien unter `recherche/dateien/`. | | RC-02 | Heften und Recherche-Leiste | erfüllt | `recherche.test.tsx`; Browser | Figurenkarte, Ortskarte, Kapitel-Überblick, Schreiben-Inspektor, Szenen-Formular. | | RC-03 | Filter, Raster/Liste | erfüllt | `recherche.test.tsx`; Browser | | diff --git a/docs/anforderungen-v1.1.md b/docs/anforderungen-v1.1.md index 3de55ce..d7efd05 100644 --- a/docs/anforderungen-v1.1.md +++ b/docs/anforderungen-v1.1.md @@ -208,6 +208,17 @@ Die Planung ist die zweite Ebene neben dem Text. Referenz-Screens: `Akte.dc.html | F-04 | Figuren | Notizen, Auftritte als Kapitelstreifen (tritt auf, nur erwähnt oder per Nachricht), Beziehungen | Muss | | F-05 | Figuren | Hinweis, wenn ein Plan-Feld dem Text widerspricht, außer die Figur oder Stelle ist bewusst vage. Umgesetzt: Die Prüfung vergleicht Plan-Felder von Figuren und Kapitelplänen mit dem Text und meldet Widersprüche als „Prüfen“ mit Plan-Bezug (Figur/Kapitel, Feld); die Figurenkarte zeigt sie mit Etikett „Plan“ | Soll | | F-06 | Figuren | Figur vorab anlegen, bevor sie im Text vorkommt | Muss | +| F-07 | Figuren | Zwei Figuren zusammenführen (Richtung wählbar): Namen werden Spitznamen, Beziehungen, Planfelder und Notizen kommen dazu, alle Verweise im Projekt zeigen auf die bleibende Figur; Hinweis auf der Figurenkarte bei gleichem Namen | Soll | +| F-08 | Figuren | Feld „Auch genannt / Spitzname“: mehrere Namen mit Komma, sofort in der Erkennung und bei KI-Vorschlägen berücksichtigt | Muss | +| F-09 | Figuren | Auswahl-Leiste im Editor „Figur“: markierten Text als neue Figur oder als Spitznamen einer Figur übernehmen | Soll | +| F-10 | Figuren | KI-Vorschläge, die eine angelegte Figur unter anderem Namen meinen, als „Spitznamen für X“ übernehmen; Erwähnungsvorschläge „Als Spitznamen merken“ | Soll | +| F-11 | Figuren | Hinweis, wenn eine Figur zuerst über einen Spitznamen vorkommt und ihr Name erst später fällt | Soll | +| F-12 | Figuren | Hinweis auf leicht verwechselbare Namen (ein Buchstabe Unterschied, gleicher Anfang bei gleicher Länge) | Kann | +| F-13 | Figuren | Hinweis auf ruhende Figuren (Standard Haupt 3, Neben 6 Kapitel, je Figur einstellbar); im Auftrittsstreifen markiert | Soll | +| F-14 | Figuren | Rolle „Statist“: eigener eingeklappter Bereich, schlanke Karte, im Netz nur auf Wunsch, nie im Exposé; Hinweis „zur Nebenfigur machen?“ ab drei Kapiteln | Soll | +| F-15 | Figuren | Besetzungsübersicht Figuren × Kapitel | Soll | +| F-16 | Figuren | Spitznamen als Chips mit Kontext (wer nennt die Figur so, ab welchem Kapitel, Notiz); Hinweis, wenn ein Spitzname vor seinem Kapitel fällt | Kann | +| F-17 | Figuren | Figurenfakten aus dem Text (KI, wörtlich belegt) je Merkmal auf der Figurenkarte; Widersprüche und Planabweichungen als „Prüfen“ | Soll | | O-01 | Orte | Liste mit Kapiteln, Belegen aus dem Text, Beschreibung, Notizen; Unterorte (Zeichensaal im Büro) | Muss | | O-02 | Orte | Schreibweisen zusammenführen und die gültige festlegen | Muss | | O-03 | Orte | Weg einer Figur (z. B. „Pauls Weg“): Ortsfolge über alle Kapitel | Soll | @@ -218,6 +229,9 @@ Die Planung ist die zweite Ebene neben dem Text. Referenz-Screens: `Akte.dc.html | A-01 | Antagonisten | Zwei Antagonisten als Gruppen: Nordhaven (im Text über Herrn Kessler und den Aufsichtsrat) und das Konsortium (geplant) | Muss | | A-02 | Antagonisten | Pro Gruppe Mitglieder, Präsenz pro Akt und Kapitel und mögliche Spuren im Text als Vorschlag (z. B. »Kranich«), die Leon bestätigt oder verwirft | Muss | | A-03 | Antagonisten | Antagonisten erscheinen als eigene Stränge im Akt-Board und im Kapitel-Überblick | Muss | +| A-04 | Gruppen | Gruppen in der App anlegen („+ Gruppe“ im Beziehungsnetz, „+ Neue Gruppe …“ auf der Figurenkarte) und löschen; eine neue Gruppe bekommt sofort eine Fläche im Netz um ihre Mitglieder | Muss | +| A-05 | Gruppen | Eine Figur in mehreren Gruppen; Hauptgruppe bestimmt Farbe und Platz im Netz, andere Gruppen zeigen sie als Verweis | Soll | +| A-06 | Gruppen | Rolle je Mitglied in der Gruppe (Inspektor, Netzkarte, Figurenkarte) | Kann | ## Funktionen: Werkzeuge (G1–G8) @@ -248,6 +262,7 @@ Seit 10. Oktober 2026. Alles, was Leon festlegt, liegt als Markdown oder JSON im | AB-03 | Abgabe | EPUB 3 mit Satz wie die Buch-Vorschau | Kann | | AB-04 | Abgabe | Lektoratsmappe (PDF) mit offenen Hinweisen | Kann | | AB-05 | Abgabe | Rückimport aus Word: Kommentare als Anmerkungen, nachverfolgte Änderungen einzeln annehmen/ablehnen (vorher Schnappschuss); offene Änderungen im Editor markiert | Soll | +| AB-06 | Abgabe | Personenverzeichnis (.docx/.md) nach Gruppen oder Rolle, „nur bis Kapitel X“; optional als Vorspann von Normseite und EPUB | Kann | | RC-01 | Recherche | Bilder, Links und Notizen ablegen (Ziehen, Einfügen, Dialog), Bilder nach `recherche/dateien/`; Links werden nie geladen, nur auf Klick im Browser geöffnet | Soll | | RC-02 | Recherche | Einträge an Figur, Ort, Kapitel oder Szene heften; Recherche-Leiste auf Figurenkarte, Ortskarte, Kapitel-Überblick, Schreiben-Inspektor und Szenen-Formular | Soll | | RC-03 | Recherche | Filter nach Art, Ziel, Schlagwort, Suche; Raster oder Liste | Soll | diff --git a/docs/plan-figuren-namen.md b/docs/plan-figuren-namen.md new file mode 100644 index 0000000..0517264 --- /dev/null +++ b/docs/plan-figuren-namen.md @@ -0,0 +1,206 @@ +# Plan: Figuren, Namen und Gruppen (Paket nach v0.2.0) + +Stand: 13. Oktober 2026 – alle Phasen 1–9 umgesetzt (Branch `claude/statisten-handling-b9afbc`, ein Sammel-PR). +Ursprünglicher Stand: 12. Oktober 2026. Grundlage: Rückmeldungen aus dem Schreiben mit dem eigenen Manuskript (Spitznamen werden als +eigene Figuren angelegt, doppelte Figuren lassen sich nicht zusammenführen, Gruppen nur über Dateien). Bereits +umgesetzt auf diesem Branch: F-07 Figuren zusammenführen, F-08 Spitznamen-Feld, A-04 Gruppen in der App, +KI-Vorschläge nehmen den Eigennamen. + +Leitlinien für alle Phasen: + +- Datenmodell nur additiv (`core/model/types.ts`); alte Projekte bleiben ohne Migration gültig. +- Lokale Prüfungen (ohne KI) zuerst, KI-Werkzeuge mit wörtlichen Zitaten (E6) und ohne Ersatztext. +- Neue Hinweise laufen über `core/index` (`hinweiseBerechnen`) und den bestehenden Hinweis-Mechanismus: + „Ist Absicht“ und „Erledigt“ gelten dauerhaft (`entscheidungen.hinweise`). +- Beispiele in Code, Tests und Doku nur aus dem Demo-Roman (`fixtures/`), nie aus dem privaten Manuskript. +- Jede Phase endet mit Tests (Vitest), Browser-Durchlauf hell/dunkel, deutsch/englisch, und einem eigenen PR. + +## Übersicht + +| Phase | Inhalt | Anforderungen | Aufwand | KI | +|---|---|---|---|---| +| 0 | Zusammenführen, Spitzname-Feld, Gruppen in der App | F-07, F-08, A-04 | erledigt | – | +| 1 | Hover-Blase im Editor stabil machen | S-02 (Qualität) | mittel | – | +| 2 | Namen zuordnen: Blase „Figur / Spitzname“, KI-Vorschläge als Spitzname übernehmen | F-09, F-10 | mittel | nutzt vorhandene | +| 3 | Lokale Namens-Hinweise: Spitzname vor Einführung, verwechselbare Namen, verschwundene Figuren | F-11, F-12, F-13 | klein–mittel | – | +| 4 | Statisten als dritte Rolle | F-14 | mittel | Prompt-Anpassung | +| 5 | Gruppen: mehrere pro Figur, Rollen in der Gruppe | A-05, A-06 | mittel | – | +| 6 | Besetzungsübersicht (Figuren × Kapitel) | F-15 | mittel | – | +| 7 | Spitznamen mit Kontext (wer nennt wen so, ab wann) | F-16 | klein–mittel | – | +| 8 | Personenverzeichnis exportieren | AB-06 | klein–mittel | – | +| 9 | Figurenfakten aus dem Text, Widersprüche je Figur | F-17 | groß | ja | +| – | Erzählperspektive pro Kapitel | – | – | zurückgestellt | + +Reihenfolge: 1 vor 2 (die neue Blasen-Aktion setzt eine verlässliche Blase voraus). 3 profitiert von 2 (mehr +Spitznamen gepflegt → bessere Hinweise). 4 und 5 sind unabhängig. 9 zuletzt, weil am teuersten und am besten +mit dem echten Manuskript zu erproben. + +--- + +## Phase 1 – Hover-Blase im Editor + +Die Blase (`src/features/schreiben/Blase.tsx`, eigene Komponente über TipTap/ProseMirror, kein BubbleMenu) +hat mehrere Schwachstellen, die beim Schreiben als „hakelig“ auffallen. Befund und Abhilfe: + +| # | Problem | Ursache | Abhilfe | +|---|---|---|---| +| 1 | Blase am linken/rechten Rand abgeschnitten, Ebenen-Untermenü erst recht | Klemmung nur 70 px, Blase ~220–260 px breit; `.pw-s-papier` hat `overflow-x: hidden` | Breite messen, Mitte auf `[breite/2, papierbreite − breite/2]` klemmen; Untermenü an der Blase ausrichten statt zentriert | +| 2 | Blase verschwindet oben, wenn die markierte Zeile am oberen Rand steht | kein Umklappen | unter die Auswahl klappen, wenn oben kein Platz ist (`data-seite="unten"`, Pfeil spiegeln) | +| 3 | Blase bleibt an alter Stelle nach Fokus-Modus, Fenstergröße, Seitenleisten ein/aus, Kapitelwechsel | kein Resize-/Layout-Listener; `view.updateState` beim Laden löst kein Ereignis aus | `ResizeObserver` auf Papier und Editor, `resize`-Listener; beim Laden eines Kapitels Blase schließen | +| 4 | „Notiz“ im Fokus-Modus legt einen unsichtbaren Entwurf an (rosa Markierung, kein Eingabefeld) | Randnotizen sind im Fokus-Modus nicht gerendert | Notiz-Knopf im Fokus-Modus: Fokus-Modus verlassen und Entwurf öffnen (oder Knopf ausblenden) | +| 5 | Escape schließt weder Blase noch Untermenü; im Fokus-Modus beendet Escape stattdessen den Modus | kein Escape-Handler | Escape schließt zuerst das Untermenü, dann die Blase (Auswahl bleibt); erst danach greift der Fokus-Modus | +| 6 | Untermenü „Ebene“ ist nach Weg- und Zurückklicken noch offen | Zustand nur bei Auswahländerung zurückgesetzt | beim Verbergen zurücksetzen | +| 7 | Blase erscheint nach einem Klick gar nicht mehr, bis man erneut klickt | `maus`-Flag bleibt hängen, wenn die Maustaste außerhalb des Fensters losgelassen wird | Flag auch bei `pointerup`/`pointercancel`/`blur` am Fenster zurücksetzen; Pointer-Events statt Maus-Events | +| 8 | Blase kann mitten im Ziehen aufblitzen, wenn das Ziehen außerhalb des Textes beginnt | Mausmerker nur auf `view.dom` | `pointerdown` auf dem ganzen Papier beobachten | +| 9 | ⌘A zeigt keine Blase | Prüfung auf Inline-Elternknoten schließt `AllSelection` aus | `AllSelection` zulassen (Fett/Kursiv), Ebene/Notiz/Figur dann ausblenden | +| 10 | Tastatur: kein Weg in die Blase; ist der Fokus drin, verschwindet sie bei der nächsten Transaktion (z. B. Analyse-Dekorationen) | `messen` prüft `editor.isFocused` | Tastenkürzel (⌥⌘B o. Ä.) fokussiert die Blase, Pfeiltasten wandern (`role="toolbar"`), `messen` berücksichtigt Fokus in der Blase; Timer beim Verbergen löschen | +| 11 | „Ü“-Knopf mit `aria-pressed` und wechselndem Label | doppelte Semantik | nur `aria-pressed`, festes Label | + +Tests: bisher nur Verzögerung, Ebene, Ziehen, Ü/Absatz (`Editor.test.tsx`, `schreiben.en.test.tsx`). Neu je +Problem ein Test (Klemmung und Umklappen mit gestubbtem `coordsAtPos`, Escape, Fokus-Modus-Notiz, hängender +Mausmerker, Resize schließt bzw. misst neu, Tastatur). Zusätzlich Browser-Durchlauf: Markieren am linken Rand, +in der ersten Zeile, im Fokus-Modus, mit ⌘A, per Tastatur, hell und dunkel, deutsch und englisch (englische +Labels sind breiter). Damit Phase 2 eine weitere Aktion mit Eingabefeld/Auswahlliste in die Blase setzen kann, +bekommt die Blase einen „Panel“-Mechanismus (Untermenü mit eigenem Fokus, der die Blase nicht schließt) – die +Ebenen-Auswahl wird das erste Panel. + +--- + +## Phase 2 – Namen dort zuordnen, wo man sie bemerkt + +**F-09 Blase „Figur / Spitzname“.** Neue Aktion in der Blase über markiertem Text (nach Phase 1): + +- „Neue Figur“: legt eine Figur mit dem markierten Text als Namen an (Rolle Neben bzw. Statist nach Phase 4), + öffnet sie nicht, zeigt eine kurze Bestätigung mit Link zur Karte. +- „Spitzname von …“: Suchliste der Figuren als Panel der Blase (Name, Spitznamen, Kurzbeschreibung; Tastatur + ↑↓/Enter/Escape; Grundlage `recherche/ZielWahl.tsx`, auf Figuren gefiltert; vorausgewählt die Figur, die an der + Stelle schon erkannt ist), fügt den markierten Text als Alias hinzu und hält die Stelle wie + `unbekanntZuordnen` als bestätigte Zuordnung fest. Führender Artikel wird entfernt („der Alte“ → „Alte“), weil die + Erkennung Artikelformen selbst findet; der Originaltext steht als Vorschau daneben. +- Gilt nur für eine Markierung innerhalb eines Absatzes, 2–60 Zeichen. +- Danach sofort neue Erkennung (Signatur ändert sich über die Aliase, kein Zusatzaufwand). + +**F-10 KI-Vorschläge als Spitzname übernehmen.** + +- Figurvorschläge (`core/ki/vorschlaege.ts`): Trägt ein Vorschlag den Namen einer angelegten Figur als Alias, + wird er heute still als „bereits angelegt“ gezählt. Neu: Hat er Namen, die die angelegte Figur noch nicht + kennt, wird daraus ein Vorschlag „Spitznamen für X: …“ (`FigurVorschlag.ziel?: Id` additiv) mit Knopf + „Als Spitznamen übernehmen“. +- Erwähnungsvorschläge (`ErwaehnungsVorschlaege.tsx`, Haiku): Bei einer bestätigten indirekten Erwähnung + („der Alte“ → Ole) zusätzlich „Immer so erkennen“ → Alias statt nur dieser einen Zuordnung. +- Unbekannte Namen (Hinweistyp `unbekannt`) lassen sich schon heute einer Figur als Spitzname zuordnen + (`unbekanntZuordnen`); dort nur die neue Figurenauswahl (Suche statt langer Auswahlliste) übernehmen. + +Tests: Kernlogik in `core/ki/vorschlaege.test.ts`, Store-Aktionen, UI-Test für die Figurenauswahl (Tastatur). + +--- + +## Phase 3 – Lokale Hinweise rund um Namen + +Alle drei lokal in einem neuen Modul `core/figuren/hinweise.ts`, eingehängt in `hinweiseBerechnen`. Neuer +Hinweistyp `figuren` (additiv in `HinweisTyp`, mit Filter im Bereich Hinweise und im Figuren-Abschnitt +„Hinweise“ der Karte). + +**F-11 Spitzname, bevor der Leser ihn kennt.** Aus `index.erwaehnungen` (Feld `treffer`): erste Stelle, an der +die Figur über einen Spitznamen erkannt wird, liegt vor der ersten Stelle mit ihrem Namen (inkl. Genitiv- und +Artikelformen). Hinweis mit beiden Zitaten. Nicht für Figuren ohne Namensnennung im Text und nicht für +„bewusst vage“. + +**F-12 Verwechselbare Namen.** Paare von Figuren (Name und Spitznamen), die sich leicht verwechseln lassen: +Editierabstand 1 bei Namen ≥ 4 Zeichen (Hendrik/Henrik-Typ), gleicher Anfang und gleiche Länge, gleicher +Anlaut bei Haupt- und Nebenfiguren in denselben Kapiteln. Nur Figuren, die beide im Text vorkommen; Statisten +ausgenommen. Bewusst sparsam: lieber wenige Treffer. + +**F-13 Verschwundene Figuren.** Wie ruhende Fäden (K-02): Hauptfigur seit N Kapiteln nicht aufgetreten +(Standard 3, für Nebenfiguren 6, Statisten nie), solange noch Kapitel mit Text folgen. Schwelle pro Figur +einstellbar (`Figur.ruheSchwelle?: number | null`, null = nie melden). Erscheint auch als Markierung im +Auftrittsstreifen der Figurenkarte. + +Tests: je Regel positive und negative Fälle mit dem Demo-Roman. + +--- + +## Phase 4 – Statisten + +**F-14 Rolle `statist`.** `FigurRolle = 'haupt' | 'neben' | 'statist'` (additiv; jede Stelle, die heute +zweiwertig denkt, wird angepasst – u. a. `vorschlaege.ts` Rollen-Mapping, `figurenBereiche`, Segment in der +Karte, Exposé). + +- Figurenliste: eigener Bereich „Statisten“, standardmäßig eingeklappt, kompakte Zeilen (Name, Kurzbeschreibung, + Kapitel). +- Figurenkarte: Planfelder, Bogen, Zustände und Stimme eingeklappt bzw. ausgeblendet, bis etwas eingetragen ist. +- Beziehungsnetz: Statisten standardmäßig ausgeblendet (Filter „Statisten zeigen“), Beziehungen zu ihnen + gestrichelt. +- Erkennung, Schreibweisen, Auftritte bleiben voll aktiv. +- KI-Vorschläge: Prompt erlaubt `statist` (benannt, höchstens zwei Szenen, keine eigene Handlung); Sammelaktion + „Alle Statisten übernehmen“. +- Hinweis „Zur Nebenfigur machen?“, wenn ein Statist in drei oder mehr Kapiteln auftritt (Typ `figuren`, + Entscheidung dauerhaft). +- Exposé: Statisten nie. + +--- + +## Phase 5 – Gruppen + +**A-05 Mehrere Gruppen pro Figur.** Maßgeblich wird `Gruppe.mitglieder` (kann eine Figur mehrfach enthalten); +`Figur.gruppe` bleibt als „Hauptgruppe“ für Farbe und Darstellung (additiv, keine Migration). Figurenkarte: +Gruppen als Chips mit „+ Gruppe“ statt einer Auswahl; Hauptgruppe per Stern. `mitgliedHinzufuegen` entfernt die +Figur nicht mehr aus anderen Gruppen. Netz: Karte liegt in der Fläche der Hauptgruppe, andere Gruppen zeigen +die Figur als kleinen Verweis-Chip. + +**A-06 Rollen in Gruppen.** `Gruppe.rollen?: Record` (z. B. „Chef“, „Praktikant“). Inspektor: +Rolle je Mitglied; Netz: Rolle als zweite Zeile auf der Karte innerhalb der Fläche; Figurenkarte: „Rolle in +Gruppe“. Optional später: Reihenfolge der Mitglieder als Hierarchie. + +--- + +## Phase 6 – Besetzungsübersicht + +**F-15** Neue Ansicht im Bereich Figuren (Umschalter „Karte · Bögen · Besetzung“): Matrix Figuren × Kapitel aus +`index.erwaehnungen` (Art je Kapitel wie im Auftrittsstreifen: tritt auf, Nachricht, nur erwähnt), gruppiert nach +Rolle, Statisten einklappbar. Spaltensummen „Figuren je Kapitel“, Zeilensummen; Klick auf eine Zelle springt zur +ersten Stelle. Ruhende Strecken (Phase 3) hervorgehoben. Reine Ableitung, keine neue Datei. + +--- + +## Phase 7 – Spitznamen mit Kontext + +**F-16** Spitznamen werden optional zu Einträgen mit Kontext: `Figur.spitznamen?: { name: string; von?: Id[]; +abKapitel?: Id; notiz?: string }[]` (additiv; `aliase` bleibt die Liste für die Erkennung und wird daraus +gespeist). Figurenkarte: Spitznamen als Chips; Klick öffnet „Wer nennt X so? Ab wann?“. Lokaler Hinweis (Typ +`figuren`): Spitzname taucht vor `abKapitel` auf. „Wer nennt“ wird angezeigt und fließt später in die +Figurenstimme (G3) ein; keine automatische Prüfung der Sprecher in dieser Phase (Sprecherzuordnung ist +unzuverlässig). + +Ersetzt nebenbei das Komma-Feld durch Chips (Eingabe mit Enter oder Komma, × zum Entfernen). + +--- + +## Phase 8 – Personenverzeichnis + +**AB-06** Neues Abgabe-Format „Personenverzeichnis“ (`core/abgabe/personen.ts`): Figuren nach Gruppen oder +Rolle, je Zeile Name, Spitznamen, Kurzbeschreibung, optional Rolle in der Gruppe. Optionen: Statisten ja/nein, +„geplant“ ausblenden, nur bis Kapitel X (kein Spoiler). Ausgabe als .docx/PDF/Markdown und als optionaler +Vorspann im EPUB- und Normseiten-Export. Private Felder (`wahrheit`, `notizen`) nie. + +--- + +## Phase 9 – Figurenfakten aus dem Text + +**F-17** Die KI-Prüfung meldet heute schon Widersprüche allgemein (Typ `pruefen`). Neu ist eine +figurenbezogene Sammlung: KI-Aufgabe „Figurenfakten“ (eigene Ablage `.plotwerk/figurenfakten.json`) liefert je +Figur Angaben aus dem Text (Alter, Aussehen, Beruf, Herkunft, Familie …) mit wörtlichem Beleg. Die Figurenkarte +zeigt sie im Abschnitt „Aus dem Text“ (F-02) mit Kapitelbeleg; widersprüchliche Angaben (gleiches Merkmal, +unterschiedliche Werte) werden lokal erkannt und als Hinweis `pruefen` mit Figur-Bezug gemeldet. Planfelder +werden dagegen wie bei F-05 abgeglichen. Regeln wie bei allen KI-Werkzeugen: Zitat wörtlich, nur Beobachtung, +„bewusst vage“ wird nie als Widerspruch gemeldet, Leon entscheidet jeden Eintrag. Kosten vorab schätzen +(Haiku pro Kapitel, Abgleich lokal). + +--- + +## Zurückgestellt + +**Erzählperspektive pro Kapitel/Szene.** Für das eigene Manuskript nicht nötig (ein Ich-Erzähler), für die +öffentliche Version aber eine häufige Planungsfrage. Notiz für später: Feld `perspektive?: Id` an +`KapitelPlan`/`Szene`, Auswertung „wer erzählt wie oft, wer war lange nicht dran“, Ansicht in der +Besetzungsübersicht (Phase 6) als zusätzliche Zeile. diff --git a/src/app/dialoge.tsx b/src/app/dialoge.tsx index 614700b..9e87cd8 100644 --- a/src/app/dialoge.tsx +++ b/src/app/dialoge.tsx @@ -18,6 +18,7 @@ export const DIALOGE: Record = { // Werkzeuge G1–G8 'zeit-vorschlaege': lazy(() => import('../features/zeitleiste/ZeitVorschlagDialog')), 'stimme-pruefen': lazy(() => import('../features/figuren/StimmeDialog')), + figurenfakten: lazy(() => import('../features/figuren/FaktenDialog')), marken: lazy(() => import('../features/schreiben/werkzeuge/MarkenDialog')), fassung: lazy(() => import('../features/statistik/FassungDialog')), versionsvergleich: lazy(() => import('../features/statistik/VersionsvergleichDialog')), diff --git a/src/backend/ki/demo/fakten.ts b/src/backend/ki/demo/fakten.ts new file mode 100644 index 0000000..f2ca598 --- /dev/null +++ b/src/backend/ki/demo/fakten.ts @@ -0,0 +1,43 @@ +/** + * Demo-Antwort für die Aufgabe „fakten“ (F-17 Figurenfakten). Registriert in ../demoAntworten/figuren.ts. + * Alle echten Zitate sind wörtliche Ausschnitte der Fixture-Kapitel und werden nur geliefert, wenn sie im + * mitgeschickten Kontext stehen. Enthalten: Pauls Auto als Saab und als Volvo (Widerspruch) und ein erfundenes + * Zitat, das die Auswertung verwerfen muss. + */ +import type { DemoAntwort } from '../demoAntworten/typen'; + +interface Fakt { + figur: string; + merkmal: string; + wert: string; + kapitel: string; + zitat: string; + /** absichtlich ungültig (Test der Antwortprüfung) – wird ohne Fundprüfung geliefert */ + ungueltig?: boolean; +} + +const FAKTEN: Fakt[] = [ + { figur: 'paul', merkmal: 'Auto', wert: 'alter Saab 900', kapitel: 'lotte', zitat: 'Dann nimmt er den Schlüssel und fährt mit seinem alten Saab 900 zum Lindenhof.' }, + { figur: 'paul', merkmal: 'Auto', wert: 'Saab', kapitel: 'funkstille', zitat: 'Paul sitzt im Saab auf dem Parkplatz am Linden-Hof, auf dem Beifahrersitz die Papprolle mit den Glasfassaden.' }, + { figur: 'paul', merkmal: 'Auto', wert: 'Volvo', kapitel: 'flut', zitat: 'Ich steige aus dem Volvo, schlage die Tür zu und sehe ihm nach, bis er hinter den Lagerhallen verschwunden ist.' }, + { figur: 'vera', merkmal: 'Beruf', wert: 'restauriert alte Orgeln', kapitel: 'ebbe', zitat: 'Vera erzählt von ihrer Werkstatt, in der sie alte Orgeln restauriert, Paul hört nur halb zu.' }, + { figur: 'ole', merkmal: 'Alter', wert: '41', kapitel: 'grundriss', zitat: 'Ole ist einundvierzig und trägt immer denselben Pullover.', ungueltig: true }, +]; + +const FAKTEN_EN: Fakt[] = [ + { figur: 'paul', merkmal: 'Car', wert: 'old Saab 900', kapitel: 'lotte', zitat: 'Then he takes the key and drives his old Saab 900 to the Lindenhof.' }, + { figur: 'paul', merkmal: 'Car', wert: 'Saab', kapitel: 'radio-silence', zitat: 'Paul sits in the Saab in the car park at the Linden-Hof, the cardboard tube with the glass facades on the passenger seat.' }, + { figur: 'paul', merkmal: 'Car', wert: 'Volvo', kapitel: 'high-tide', zitat: 'I get out of the Volvo, slam the door and watch it until it has disappeared behind the warehouses.' }, + { figur: 'vera', merkmal: 'Occupation', wert: 'restores old organs', kapitel: 'low-tide', zitat: 'Vera talks about her workshop, where she restores old organs, Paul is only half listening.' }, + { figur: 'ole', merkmal: 'Age', wert: '41', kapitel: 'floor-plan', zitat: 'Ole is forty-one and always wears the same jumper.', ungueltig: true }, +]; + +export const demoFakten: DemoAntwort = (ctx, anfrage) => { + const nur = anfrage.attribute.figur; + const figuren = new Set(ctx.figuren.map((f) => f.id)); + const steht = (f: Fakt) => !!ctx.kapitel.find((k) => k.id === f.kapitel)?.text.includes(f.zitat); + const liste = (ctx.sprache === 'en' ? FAKTEN_EN : FAKTEN) + .filter((f) => (!nur || f.figur === nur) && figuren.has(f.figur)) + .filter((f) => f.ungueltig || steht(f)); + return { fakten: liste.map(({ figur, merkmal, wert, kapitel, zitat }) => ({ figur, merkmal, wert, kapitel, zitat })) }; +}; diff --git a/src/backend/ki/demo/sprachen.ts b/src/backend/ki/demo/sprachen.ts index 28b3db2..13f53b1 100644 --- a/src/backend/ki/demo/sprachen.ts +++ b/src/backend/ki/demo/sprachen.ts @@ -13,7 +13,7 @@ export type DemoBeleg = [kapitel: string, zitat: string]; export interface DemoFigur { name: string; aliase: string[]; - rolle: 'haupt' | 'neben'; + rolle: 'haupt' | 'neben' | 'statist'; kurz: string; belege: DemoBeleg[]; } diff --git a/src/backend/ki/demoAntworten/figuren.ts b/src/backend/ki/demoAntworten/figuren.ts index 12845e9..8fde4fb 100644 --- a/src/backend/ki/demoAntworten/figuren.ts +++ b/src/backend/ki/demoAntworten/figuren.ts @@ -1,8 +1,10 @@ -/** Demo-Antworten für Figurenstimme (G3). Besitzer: G3. Schlüssel = art im Aufgabenkopf. */ -import { STIMME_ART } from '@core/ki/index'; +/** Demo-Antworten für Figurenstimme (G3) und Figurenfakten (F-17). Besitzer: G3. Schlüssel = art im Aufgabenkopf. */ +import { FAKTEN_ART, STIMME_ART } from '@core/ki/index'; +import { demoFakten } from '../demo/fakten'; import { demoStimme } from '../demo/stimme'; import type { DemoAntwort } from './typen'; export const DEMO_ANTWORTEN_FIGUREN: Record = { [STIMME_ART]: demoStimme, + [FAKTEN_ART]: demoFakten, }; diff --git a/src/features/abgabe/AbgabeDialog.tsx b/src/features/abgabe/AbgabeDialog.tsx index ebc5194..92fab02 100644 --- a/src/features/abgabe/AbgabeDialog.tsx +++ b/src/features/abgabe/AbgabeDialog.tsx @@ -3,7 +3,7 @@ * Alle Dateien gehen über backend.dateiSpeichern (wie der normale Export). Keine KI. */ import { useId, useMemo, useState } from 'react'; -import { exposeGeruest, exposePlatzhalter, normseitenZaehlen, offeneHinweise, type ExposeKapitelQuelle } from '@core/abgabe/index'; +import { exposeGeruest, exposePlatzhalter, normseitenZaehlen, offeneHinweise, personenAuswahl, type ExposeKapitelQuelle, type PersonenOptionen } from '@core/abgabe/index'; import { epubKapitel } from '@core/epub/index'; import type { Id, KapitelStatus, ProjektAngaben } from '@core/model/types'; import { wortzahl } from '@core/text/index'; @@ -22,7 +22,7 @@ import { Abschnittslabel, Button, Checkbox, Dialog, Radio, Segment, StatusPunkt, import '../importexport/importexport.css'; import './abgabe.css'; -const FORMATE: AbgabeFormat[] = ['normseite', 'expose', 'epub', 'mappe']; +const FORMATE: AbgabeFormat[] = ['normseite', 'expose', 'epub', 'mappe', 'personen']; /** Formate mit Beschriftung in der Oberflächensprache */ function formate(t: Woerterbuch): { wert: AbgabeFormat; label: string }[] { @@ -154,6 +154,7 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro const daten = useStore((s) => s.daten); const backend = useStore((s) => s.backend); const hinweise = useStore((s) => s.index.hinweise); + const index = useStore((s) => s.index); const auswahlKapitel = useStore((s) => s.auswahl.kapitel); const abgabe = useStore((s) => s.abgabe); const e = abgabe.einstellungen; @@ -170,6 +171,10 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro const [nebenfiguren, setNebenfiguren] = useState(e.exposeNebenfiguren); const [epubSchrift, setEpubSchrift] = useState(e.epubSchrift); const [mappeKapitel, setMappeKapitel] = useState(() => e.mappeKapitel ?? null); + const [personen, setPersonen] = useState(e.personen); + const [personenFormat, setPersonenFormat] = useState(e.personenFormat); + const [personenVorspann, setPersonenVorspann] = useState(e.personenVorspann); + const personenId = useId(); const [angaben, setAngaben] = useState(() => daten?.projekt.angaben ?? {}); const [dateinamen, setDateinamen] = useState>>({}); const [status, setStatus] = useState({ art: 'bereit' }); @@ -226,7 +231,11 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro } const titel = daten.projekt.titel; - const dateiname = dateinamen[format] ?? abgabeDateiname(titel, format, exposeFormat); + const dateiformat = format === 'personen' ? personenFormat : exposeFormat; + const dateiname = dateinamen[format] ?? abgabeDateiname(titel, format, dateiformat); + const personenAnzahl = format === 'personen' ? personenAuswahl(daten, index.erstesVorkommen, personen).length : 0; + const setzePersonen = (teil: Partial) => setPersonen((p) => ({ ...p, ...teil })); + const vorspannOption = personenVorspann ? personen : undefined; const setzeDateiname = (n: string) => setDateinamen((alt) => ({ ...alt, [format]: n })); const laeuft = status.art === 'laeuft'; @@ -240,7 +249,7 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro let fussText = ''; switch (format) { case 'normseite': - auftrag = { format, kapitel: gewaehlt, titelseite }; + auftrag = { format, kapitel: gewaehlt, titelseite, personen: vorspannOption }; bereit = gewaehlt.length > 0; fussText = normseiten ? `${f(t.abgabe.kapitelAnzahl, { n: gewaehlt.length })} · ${f(te.normseiten, { n: normseiten.seiten })}` : ''; break; @@ -249,7 +258,7 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro fussText = expose ? f(te.platzhalter, { n: expose.platzhalter }) : ''; break; case 'epub': - auftrag = { format, kapitel: gewaehlt, titelseite, schrift: epubSchrift }; + auftrag = { format, kapitel: gewaehlt, titelseite, schrift: epubSchrift, personen: vorspannOption }; bereit = gewaehlt.length > 0; fussText = `${f(t.abgabe.kapitelAnzahl, { n: gewaehlt.length })} · ${f(t.gemeinsam.woerter, { n: zeilen.filter((z) => gewaehlt.includes(z.id)).reduce((s, z) => s + z.woerter, 0) })}`; break; @@ -258,10 +267,14 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro bereit = offen.length > 0; fussText = f(te.offeneHinweise, { n: offen.length }); break; + case 'personen': + auftrag = { format, ziel: personenFormat, optionen: personen }; + fussText = f(te.personen.anzahl, { n: personenAnzahl }); + break; } async function starten() { - const name = mitAbgabeEndung(dateiname, format, exposeFormat); + const name = mitAbgabeEndung(dateiname, format, dateiformat); setzeDateiname(name); const einstellungen: Partial = { format, @@ -273,6 +286,9 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro exposeNebenfiguren: nebenfiguren, epubSchrift, mappeKapitel, + personen, + personenFormat, + personenVorspann, }; abgabe.setzeEinstellungen(einstellungen); setStatus({ art: 'laeuft' }); @@ -367,6 +383,9 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro setTitelseite(ev.target.checked)}> {te.normseite.titelseite} + setPersonenVorspann(ev.target.checked)}> + {te.vorspann} + )} @@ -431,6 +450,9 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro setEpubSchrift(ev.target.checked)}> {te.epub.schrift} + setPersonenVorspann(ev.target.checked)}> + {te.vorspann} + )} @@ -463,6 +485,60 @@ export default function AbgabeDialog({ param, schliessen }: DialogKomponentenPro )} + {format === 'personen' && ( +
+

{te.personen.text}

+
+ {te.personen.gliederung} +
+ setzePersonen({ gliederung: 'gruppen' })}> + {te.personen.nachGruppen} + + setzePersonen({ gliederung: 'rolle' })}> + {te.personen.nachRolle} + +
+
+ setzePersonen({ spitznamen: ev.target.checked })}> + {te.personen.spitznamen} + + setzePersonen({ statisten: ev.target.checked })}> + {te.personen.statisten} + + setzePersonen({ geplante: ev.target.checked })}> + {te.personen.geplante} + +
+ + +
+
+ {te.expose.dateiformat} + + label={te.expose.dateiformatLabel} + rolle="group" + voll + optionen={[ + { wert: 'docx', label: te.expose.word }, + { wert: 'md', label: te.expose.markdown }, + ]} + wert={personenFormat} + onChange={(fm) => { + setPersonenFormat(fm); + if (dateinamen.personen) setDateinamen((alt) => ({ ...alt, personen: mitAbgabeEndung(alt.personen!, 'personen', fm) })); + }} + /> +
+
+ )} + {format !== 'mappe' && setAngaben((a) => ({ ...a, ...teil }))} />}
diff --git a/src/features/akte/planung.css b/src/features/akte/planung.css index 94d3488..f90c4f5 100644 --- a/src/features/akte/planung.css +++ b/src/features/akte/planung.css @@ -275,6 +275,117 @@ button.pl-chip__text:hover { min-width: 0; } +/* Spitznamen als Chips (F-08/F-16) */ +.pl-spitznamen { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + min-height: 32px; + padding: 2px 4px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); + cursor: text; +} +.pl-spitznamen:focus-within { + border-color: var(--ink-soft); +} +.pl-spitznamen__eingabe { + flex: 1 1 120px; + min-width: 80px; + height: 26px; + border: 0; + outline: 0; + background: transparent; + color: var(--ink); + font: inherit; + font-size: 13px; +} +.pl-spitzname { + display: inline-flex; + align-items: center; + height: 24px; + border-radius: 12px; + background: var(--chip); + font-size: 12px; +} +.pl-spitzname[data-offen] { + outline: 1px solid var(--ink-soft); +} +.pl-spitzname button, +.pl-spitzname > span { + border: 0; + background: none; + color: var(--ink); + font: inherit; +} +.pl-spitzname__name { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 4px 0 9px; + cursor: pointer; +} +.pl-spitzname__punkt { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--link); +} +.pl-spitzname__weg { + padding: 0 8px 0 3px; + color: var(--ink-quiet) !important; + cursor: pointer; +} +.pl-spitzname__kontext { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--line-soft); + border-radius: 8px; + font-size: 12px; +} + +/* Gruppen einer Figur (A-05): Chip mit Hauptgruppen-Stern, Name/Rolle, Entfernen */ +.pl-gruppenchip { + display: inline-flex; + align-items: center; + height: 28px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface); + font-size: 12px; +} +.pl-gruppenchip[data-haupt] { + border-color: var(--accent-line); + background: var(--accent-flaeche); +} +.pl-gruppenchip button { + height: 100%; + border: 0; + background: none; + color: var(--ink); + font: inherit; + cursor: pointer; +} +.pl-gruppenchip__stern { + padding: 0 2px 0 9px; + color: var(--accent-dark); +} +.pl-gruppenchip__name { + padding: 0 4px; + font-weight: 600; +} +.pl-gruppenchip__weg { + padding: 0 9px 0 4px; + color: var(--ink-quiet); +} +.pl-gruppenchip button:hover { + color: var(--link-hover); +} + /* Listen (Figuren, Orte) ---------------------------------------------------- */ .pl-listeneintrag { display: flex; diff --git a/src/features/beziehungen/Netz.tsx b/src/features/beziehungen/Netz.tsx index dff501e..bed363b 100644 --- a/src/features/beziehungen/Netz.tsx +++ b/src/features/beziehungen/Netz.tsx @@ -82,7 +82,7 @@ export function Netz({ daten, index, ansicht: basis, standIdx, filter }: NetzPro ...g, netz: { ...g.netz, breite: Math.max(120, g.netz.breite + zug.dx), hoehe: Math.max(80, g.netz.hoehe + zug.dy) }, }; - return netzBerechnen({ ...daten, figuren, gruppen }, index, standIdx, filter); + return netzBerechnen({ ...daten, figuren, gruppen }, index, standIdx, filter, useStore.getState().netzStatisten); }, [basis, zug, daten, index, standIdx, filter]); const punkt = (e: { clientX: number; clientY: number }) => { @@ -282,6 +282,21 @@ export function Netz({ daten, index, ansicht: basis, standIdx, filter }: NetzPro {f.label}
))} + {ansicht.flaechen + .filter((f) => f.aussen.length > 0) + .map((f) => ( +
+ {f.aussen.map((m) => ( + + ))} +
+ ))}
+ )} + {offen.figuren.map((fv: FigurVorschlag) => + fv.ziel ? ( + +
+ {[fv.name, ...fv.aliase].join(', ')} +
+ {f(tv.spitznamenFuer, { name: daten.figuren[fv.ziel]?.name ?? fv.ziel })} + +
+ ) : ( + +
+ {fv.name} + {fv.rolle === 'haupt' ? tv.hauptfigur : fv.rolle === 'statist' ? tv.statist : tv.nebenfigur} +
+ {fv.kurz && {fv.kurz}} + {fv.aliase.length > 0 && {f(tv.auch, { namen: fv.aliase.join(', ') })}} + +
+ ), + )} diff --git a/src/features/beziehungen/beziehungen.css b/src/features/beziehungen/beziehungen.css index a326009..ea721b5 100644 --- a/src/features/beziehungen/beziehungen.css +++ b/src/features/beziehungen/beziehungen.css @@ -85,6 +85,26 @@ white-space: nowrap; pointer-events: none; } +.pw-bz-gruppe__aussen { + position: absolute; + z-index: 1; + display: flex; + flex-wrap: wrap; + gap: 4px; + overflow: hidden; + max-height: 22px; +} +.pw-bz-gruppe__aussen button { + height: 20px; + padding: 0 8px; + border: 1px dashed currentColor; + border-radius: 10px; + background: var(--surface); + color: inherit; + font: inherit; + font-size: 11px; + cursor: pointer; +} .pw-bz-gruppe__groesse { position: absolute; right: -1px; diff --git a/src/features/beziehungen/index.tsx b/src/features/beziehungen/index.tsx index 1954b11..91c53d9 100644 --- a/src/features/beziehungen/index.tsx +++ b/src/features/beziehungen/index.tsx @@ -16,6 +16,8 @@ export default function Ansicht() { const index = useStore((s) => s.index); const stand = useStore((s) => s.netzStand); const filter = useStore((s) => s.netzFilter); + const statisten = useStore((s) => s.netzStatisten); + const umschalteNetzStatisten = useStore((s) => s.umschalteNetzStatisten); const verbinden = useStore((s) => s.netzVerbinden); const globaleFigur = useStore((s) => s.auswahl.figur); const globaleGruppe = useStore((s) => s.auswahl.gruppe); @@ -64,8 +66,8 @@ export default function Ansicht() { // Beschriftungen aus logik.ts folgen der Oberflächensprache: beim Umschalten neu berechnen const stufen = useMemo(() => (daten ? aktStufen(daten) : []), [daten, sprache]); const ansicht = useMemo( - () => (daten ? netzBerechnen(daten, index, standIdx, filter) : null), - [daten, index, standIdx, filter, sprache], + () => (daten ? netzBerechnen(daten, index, standIdx, filter, statisten) : null), + [daten, index, standIdx, filter, statisten, sprache], ); if (!daten || !ansicht) return null; @@ -167,6 +169,11 @@ export default function Ansicht() { {t.beziehungen.kategorie[k]} ))} + {Object.values(daten.figuren).some((x) => x.rolle === 'statist') && ( + + {ta.statisten} + + )}
{ablage.length > 0 && (
@@ -199,7 +206,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/beziehungen/logik.ts b/src/features/beziehungen/logik.ts index db2ed68..30ea7fc 100644 --- a/src/features/beziehungen/logik.ts +++ b/src/features/beziehungen/logik.ts @@ -177,6 +177,8 @@ export interface NetzFlaeche { rechteck: Rechteck; farben: FarbSatz; label: string; + /** A-05: am Stand bekannte Mitglieder, deren Karte nicht in der Fläche liegt (z. B. andere Hauptgruppe) */ + aussen: { id: Id; name: string }[]; } export interface NetzPlatzhalter { @@ -260,10 +262,11 @@ function rechteckVon(f: Figur): Rechteck | null { const einWort = (s: string) => !/\s/.test(s.trim()) && s.trim().length > 0 && s.trim().length <= 24; -export function netzBerechnen(daten: ProjektDaten, index: Index, standIdx: number, filter: NetzFilter): NetzAnsicht { +export function netzBerechnen(daten: ProjektDaten, index: Index, standIdx: number, filter: NetzFilter, statisten = false): NetzAnsicht { const t = texte(); const tl = t.beziehungen.logik; - const figuren = Object.values(daten.figuren); + // F-14: Statisten nur auf Wunsch + const figuren = Object.values(daten.figuren).filter((f) => statisten || f.rolle !== 'statist'); const sichtbar = new Set(figuren.filter((f) => figurSichtbar(daten, index, f.id, standIdx)).map((f) => f.id)); // Paare am Stand @@ -342,6 +345,9 @@ export function netzBerechnen(daten: ProjektDaten, index: Index, standIdx: numbe variante = 'normal'; untertitel = f.kurz ?? kapLabel(daten, erst); } + // A-06: Rolle in der Hauptgruppe ersetzt die Kurzbeschreibung bei gesicherten Figuren + const rolle = gruppe?.rollen?.[f.id]; + if (rolle && (variante === 'normal' || variante === 'zentrum')) untertitel = rolle; karten.push({ figur: f, rechteck: r, variante, untertitel, farben }); rechtecke.set(f.id, r); } @@ -357,8 +363,17 @@ export function netzBerechnen(daten: ProjektDaten, index: Index, standIdx: numbe } const farben = farbSatz(strangVon(daten, g.strang)?.farbe); const rechteck = { x: g.netz.x, y: g.netz.y, breite: g.netz.breite, hoehe: g.netz.hoehe }; - flaechen.push({ gruppe: g, rechteck, farben, label: fmt(tl.flaecheLabel, { typ: t.beziehungen.gruppenTyp[g.typ], name: g.name }) }); - const mitgliedSichtbar = g.mitglieder.some((m) => rechtecke.has(m)); + const innen = (r: Rechteck) => { + const cx = r.x + r.breite / 2; + const cy = r.y + r.hoehe / 2; + return cx >= rechteck.x && cx <= rechteck.x + rechteck.breite && cy >= rechteck.y && cy <= rechteck.y + rechteck.hoehe; + }; + const aussen = g.mitglieder + .filter((m) => sichtbar.has(m) && daten.figuren[m] && !(rechtecke.has(m) && innen(rechtecke.get(m)!))) + .map((m) => ({ id: m, name: daten.figuren[m]!.name })); + flaechen.push({ gruppe: g, rechteck, farben, label: fmt(tl.flaecheLabel, { typ: t.beziehungen.gruppenTyp[g.typ], name: g.name }), aussen }); + // Platzhalter nur, wenn keine Mitgliedskarte in der Fläche liegt (Mitglieder anderer Hauptgruppen zählen nicht) + const mitgliedSichtbar = g.mitglieder.some((m) => rechtecke.has(m) && innen(rechtecke.get(m)!)); if (mitgliedSichtbar) continue; const spuren = g.spuren .filter((s) => s.status !== 'verworfen') diff --git a/src/features/figuren/Besetzung.tsx b/src/features/figuren/Besetzung.tsx new file mode 100644 index 0000000..6e75f5f --- /dev/null +++ b/src/features/figuren/Besetzung.tsx @@ -0,0 +1,142 @@ +/** + * F-15 Besetzungsübersicht: Figuren × Kapitel. Zellen wie im Auftrittsstreifen der Figurenkarte (gefüllt: tritt + * auf, umrandet: per Nachricht, gestrichelt: erwähnt, hell: im Text, schraffiert: nur im Kapitelplan), ruhende + * Strecken gelb unterstrichen. Klick auf eine Zelle springt zur ersten Stelle, auf den Namen zur Figurenkarte. + */ +import { useMemo, useState, type CSSProperties } from 'react'; +import { besetzung, ruhendeStrecken, type BesetzungsZeile } from '@core/figuren/index'; +import type { Index } from '@core/index/index'; +import type { FigurRolle, ProjektDaten } from '@core/model/types'; +import { f as fmt, useTexte } from '../../i18n'; +import { useStore } from '../../state/store'; +import { figurDarstellung, stelleAnker } from '../akte/gemeinsam'; +import { kapKurz } from './texte'; + +const ZELLE = 26; + +export function Besetzung({ daten, index }: { daten: ProjektDaten; index: Index }) { + const markiere = useStore((s) => s.markiere); + const waehle = useStore((s) => s.waehle); + const setzeAnsicht = useStore((s) => s.figurenbogen.setzeFigurenAnsicht); + const suche = useStore((s) => s.planung.figurenSuche).trim().toLocaleLowerCase('de'); + const [statistenOffen, setStatistenOffen] = useState(false); + const t = useTexte(); + const T = t.figuren.besetzung; + const b = useMemo(() => besetzung(daten, index.erwaehnungen), [daten, index]); + const ruhend = useMemo( + () => new Map(b.zeilen.map((z) => [z.figur.id, new Set(ruhendeStrecken(daten, index.erwaehnungen, z.figur).flatMap((s) => s.kapitel))])), + [b, daten, index], + ); + const passt = (z: BesetzungsZeile) => !suche || [z.figur.name, ...z.figur.aliase].some((n) => n.toLocaleLowerCase('de').includes(suche)); + const gruppen: { rolle: FigurRolle; titel: string }[] = [ + { rolle: 'haupt', titel: t.figuren.liste.haupt }, + { rolle: 'neben', titel: t.figuren.liste.neben }, + { rolle: 'statist', titel: fmt(t.figuren.liste.statisten, { n: b.zeilen.filter((z) => z.figur.rolle === 'statist').length }) }, + ]; + const spalten = `minmax(140px, 200px) repeat(${b.kapitel.length}, ${ZELLE}px) 48px`; + + const zelle = (z: BesetzungsZeile, i: number) => { + const k = b.kapitel[i]!; + const art = z.zellen[i]; + const farbe = figurDarstellung(z.figur, daten).farbe; + const ohneText = !daten.kapitel[k]?.text.trim(); + const stil: CSSProperties = { height: ZELLE - 4, borderRadius: 4, background: 'var(--surface-muted)', border: 0, padding: 0 }; + if (art === 'auftritt') Object.assign(stil, { background: farbe }); + else if (art === 'nachricht') Object.assign(stil, { background: 'transparent', border: `2px solid ${farbe}` }); + else if (art === 'erwaehnt') Object.assign(stil, { background: 'transparent', border: `1.5px dashed ${farbe}` }); + else if (art === 'unbestimmt') stil.background = `color-mix(in srgb, ${farbe} 22%, var(--surface))`; + else if (art === 'plan') stil.background = `repeating-linear-gradient(135deg, color-mix(in srgb, ${farbe} 35%, transparent) 0 3px, transparent 3px 6px)`; + else if (ohneText) stil.background = 'repeating-linear-gradient(135deg, var(--schraffur) 0 4px, var(--surface) 4px 8px)'; + if (ruhend.get(z.figur.id)?.has(k)) stil.boxShadow = 'inset 0 -3px 0 var(--ampel-gelb)'; + const titel = `${z.figur.name} · ${kapKurz(i + 1)} ${daten.plaene[k]?.titel ?? daten.kapitel[k]?.titel ?? ''} · ${ + art === 'plan' ? T.nurPlan : art ? t.figuren.art[art] : ohneText ? t.figuren.karte.auftritte.keinText : '–' + }`; + const e = z.erste[i]; + return e ? ( + + {z.zellen.map((_, i) => zelle(z, i))} + {z.anzahl} +
+ ); + + return ( +
+

{T.titel}

+ {T.legende} +
+
+ + {b.kapitel.map((k, i) => ( + + {i + 1} + + ))} + + {T.kap} + +
+ {gruppen.map(({ rolle, titel }) => { + const liste = b.zeilen.filter((z) => z.figur.rolle === rolle && passt(z)); + if (!liste.length) return null; + const statist = rolle === 'statist'; + const offen = !statist || statistenOffen || !!suche; + return ( +
+ {statist ? ( + + ) : ( + {titel} + )} + {offen && liste.map(zeile)} +
+ ); + })} +
+ {T.jeKapitel} + {b.summen.map((n, i) => ( + + {n || '–'} + + ))} + +
+
+
+ ); +} diff --git a/src/features/figuren/FaktenDialog.tsx b/src/features/figuren/FaktenDialog.tsx new file mode 100644 index 0000000..39bc6b0 --- /dev/null +++ b/src/features/figuren/FaktenDialog.tsx @@ -0,0 +1,221 @@ +/** + * Dialog „figurenfakten“ (F-17): Angaben über Figuren aus dem Text sammeln lassen – eine Figur oder alle, Kosten + * schätzen, starten, Ergebnis zusammenfassen. Die KI sammelt nur, was der Text sagt (mit Zitat); Widersprüche + * findet die App. Parameter: { figur?: Id }. + */ +import { useEffect, useMemo, useState } from 'react'; +import { EFFORT, formatiereUsd, kiEndpoint, type FaktenAuftrag, type Schaetzung } from '@core/ki/index'; +import type { Id } from '@core/model/types'; +import type { DialogKomponentenProps } from '../../app/dialogTypen'; +import { kiFehlerAnzeige, type KiFehlerAnzeige } from '../../state/slices/ki'; +import { f as fmt, sprache, useTexte, vergleich } from '../../i18n'; +import { useStore } from '../../state/store'; +import { Button, Checkbox, Dialog } from '../../ui'; +import '../ki/ki.css'; + +const ALLE = ''; + +function FehlerKasten({ fehler, einstellungen }: { fehler: KiFehlerAnzeige; einstellungen(): void }) { + const t = useTexte(); + return ( +
+ {fehler.text} + {fehler.art === 'nicht_bereit' && ( + + )} +
+ ); +} + +export default function FaktenDialog({ param, schliessen }: DialogKomponentenProps) { + const daten = useStore((s) => s.daten); + const backendArt = useStore((s) => s.backend?.art); + const fb = useStore((s) => s.figurenbogen); + const kiEinstellungen = useStore((s) => s.kiEinstellungen); + const oeffneDialog = useStore((s) => s.oeffneDialog); + const oeffne = useStore((s) => s.oeffne); + const t = useTexte(); + const td = t.figuren.faktenDialog; + const ts = t.figuren.stimmeDialog; + + const paramFigur = param && typeof param === 'object' && typeof (param as { figur?: unknown }).figur === 'string' ? (param as { figur: string }).figur : undefined; + const figuren = daten ? Object.values(daten.figuren).sort((a, b) => vergleich(a.name, b.name)) : []; + const [figur, setFigur] = useState(paramFigur && daten?.figuren[paramFigur] ? paramFigur : ALLE); + const [tiefencheck, setTiefencheck] = useState(false); + const [schaetzung, setSchaetzung] = useState<{ wert?: Schaetzung; fehler?: KiFehlerAnzeige; laedt: boolean }>({ laedt: true }); + + useEffect(() => { + useStore.getState().figurenbogen.faktenZuruecksetzen(); + }, []); + + const auftrag = useMemo(() => ({ ...(figur ? { figur } : {}), ...(tiefencheck ? { tiefencheck: true } : {}) }), [figur, tiefencheck]); + const lauf = fb.faktenLauf; + const ergebnis = fb.faktenErgebnis; + const phase = lauf ? 'laeuft' : ergebnis ? 'fertig' : 'vorbereiten'; + + useEffect(() => { + if (phase !== 'vorbereiten' || !daten) return; + let aktiv = true; + setSchaetzung((s) => ({ ...s, laedt: true, fehler: undefined })); + useStore + .getState() + .figurenbogen.faktenSchaetzen(auftrag) + .then( + (wert) => aktiv && setSchaetzung({ wert, laedt: false }), + (e) => aktiv && setSchaetzung({ fehler: kiFehlerAnzeige(e), laedt: false }), + ); + return () => { + aktiv = false; + }; + }, [auftrag, phase, daten]); + + const einstellungenOeffnen = () => oeffneDialog('ki-einstellungen'); + const endpointFehlt = backendArt === 'tauri' && !kiEndpoint(kiEinstellungen()); + + if (!daten || figuren.length === 0) { + return ( + {t.gemeinsam.schliessen}}> +

{daten ? ts.keineFiguren : ts.keinProjekt}

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

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

+
+ +
+

{ts.eineAnfrage}

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

{td.widersprueche}

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

{td.intro}

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

{ts.schaetzen}

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

{ts.nichtGezaehlt}

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

{ts.demo}

} +
+ ); +} diff --git a/src/features/figuren/Figurenkarte.test.tsx b/src/features/figuren/Figurenkarte.test.tsx index c5b0081..d6ff2a4 100644 --- a/src/features/figuren/Figurenkarte.test.tsx +++ b/src/features/figuren/Figurenkarte.test.tsx @@ -68,3 +68,52 @@ describe('Figurenkarte – Abschnitt „Bogen“', () => { expect(screen.getByRole('button', { name: /Erste Stufe planen/ })).toBeInTheDocument(); }); }); + +describe('Figurenkarte – Spitznamen als Chips (F-08/F-16)', () => { + it('Enter und Komma fügen hinzu, × entfernt; Klick öffnet den Kontext (wer, ab wann)', async () => { + useStore.getState().waehle({ figur: 'ole' }); + render(); + const feld = screen.getByLabelText('Auch genannt / Spitzname'); + fireEvent.change(feld, { target: { value: 'Fenster-Ole' } }); + fireEvent.keyDown(feld, { key: 'Enter' }); + fireEvent.change(feld, { target: { value: 'der Kollege, Olli,' } }); + await useStore.getState().alleSpeichernAbwarten(); + expect(useStore.getState().daten!.figuren.ole!.aliase).toEqual(['Fenster-Ole', 'der Kollege', 'Olli']); + + fireEvent.click(screen.getByRole('button', { name: 'Olli' })); + const kontext = screen.getByRole('group', { name: 'Kontext zu Olli' }); + fireEvent.change(within(kontext).getAllByRole('combobox')[0]!, { target: { value: 'paul' } }); + fireEvent.change(within(kontext).getAllByRole('combobox')[1]!, { target: { value: 'ebbe' } }); + await useStore.getState().alleSpeichernAbwarten(); + expect(useStore.getState().daten!.figuren.ole!.spitznamen).toEqual([{ name: 'Olli', von: ['paul'], abKapitel: 'ebbe' }]); + + // Entfernen nimmt den Kontext mit + fireEvent.click(screen.getByRole('button', { name: 'Olli entfernen' })); + await useStore.getState().alleSpeichernAbwarten(); + expect(useStore.getState().daten!.figuren.ole!.aliase).toEqual(['Fenster-Ole', 'der Kollege']); + expect(useStore.getState().daten!.figuren.ole!.spitznamen).toBeUndefined(); + }); +}); + +describe('Figurenkarte – Angaben aus dem Text (F-17)', () => { + it('zeigt Merkmale mit Werten und Kapitel, markiert Widersprüche, × verwirft', async () => { + const SAAB = 'Dann nimmt er den Schlüssel und fährt mit seinem alten Saab 900 zum Lindenhof.'; + const VOLVO = 'Ich steige aus dem Volvo, schlage die Tür zu und sehe ihm nach, bis er hinter den Lagerhallen verschwunden ist.'; + await useStore.getState().speichereKiFakten({ + version: 1, + eintraege: [ + { schluessel: 'a', figur: 'paul', merkmal: 'Auto', wert: 'alter Saab 900', kapitel: 'lotte', zitat: { kapitel: 'lotte', text: SAAB }, status: 'neu' }, + { schluessel: 'b', figur: 'paul', merkmal: 'Auto', wert: 'Volvo', kapitel: 'flut', zitat: { kapitel: 'flut', text: VOLVO }, status: 'neu' }, + ], + }); + useStore.getState().indexNeuBerechnen(); + useStore.getState().waehle({ figur: 'paul' }); + render(); + expect(screen.getByText(/^Auto ⚠$/)).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /^alter Saab 900 · Kap\. 3/ }).length).toBeGreaterThan(0); + fireEvent.click(screen.getByRole('button', { name: 'Volvo verwerfen' })); + await useStore.getState().alleSpeichernAbwarten(); + expect(useStore.getState().daten!.kiFakten!.eintraege.find((f) => f.schluessel === 'b')!.status).toBe('verworfen'); + expect(await screen.findByText(/^Auto$/)).toBeInTheDocument(); + }); +}); diff --git a/src/features/figuren/Figurenkarte.tsx b/src/features/figuren/Figurenkarte.tsx index 20d7131..e9a4f2e 100644 --- a/src/features/figuren/Figurenkarte.tsx +++ b/src/features/figuren/Figurenkarte.tsx @@ -1,6 +1,6 @@ /** F-02–F-05: Figurenkarte nach docs/design/screens/Figur.dc.html. */ import { useId, useState, type CSSProperties } from 'react'; -import { bogenVon, sortiereStufen, stufeImKapitel, stufenSpanne } from '@core/figuren/index'; +import { bogenVon, faktenDerFigur, RUHE_SCHWELLE, ruhendeStrecken, ruheSchwelle, sortiereStufen, stufeImKapitel, stufenSpanne } from '@core/figuren/index'; import type { Index } from '@core/index/index'; import { hauptfigur } from '@core/zeit/zustand'; import type { Beziehung, BeziehungsKategorie, Erwaehnung, Figur, FigurRolle, Hinweis, Id, ProjektDaten } from '@core/model/types'; @@ -23,11 +23,11 @@ import { } from '../akte/gemeinsam'; import { RechercheLeiste } from '../recherche/RechercheLeiste'; import { VerweisFeld, VerweisText } from '../schreiben/VerweisFeld'; +import { SpitznamenFeld } from './Spitznamen'; import { kapKurz, stufenOrtText, zitat } from './texte'; const grid2: CSSProperties = { display: 'grid', gridTemplateColumns: '120px minmax(0, 1fr)', gap: '8px 12px', alignItems: 'center', fontSize: 13 }; const leise: CSSProperties = { color: 'var(--ink-quiet)' }; -const eingabe: CSSProperties = { width: '100%' }; function namenDerFigur(f: Figur): string[] { return [f.name, ...f.aliase, ...(f.formen ?? [])].filter((n) => n.trim().length >= 2); @@ -98,7 +98,58 @@ function Kopf({ f, daten, ersteStelle }: { f: Figur; daten: ProjektDaten; ersteS ); } -function AusDemText({ daten, index, nachKapitel }: { daten: ProjektDaten; index: Index; nachKapitel: Map }) { +/** F-17: Angaben aus dem Text je Merkmal (KI, wörtlich belegt); Widersprüche und Planabweichungen markiert. */ +function Fakten({ f, daten }: { f: Figur; daten: ProjektDaten }) { + const markiere = useStore((s) => s.markiere); + const oeffneDialog = useStore((s) => s.oeffneDialog); + const faktVerwerfen = useStore((s) => s.figurenbogen.faktVerwerfen); + const laeuft = useStore((s) => !!s.figurenbogen.faktenLauf); + const t = useTexte().figuren; + const tf = t.karte.fakten; + const gruppen = faktenDerFigur(daten, f.id); + const nr = (k: Id) => daten.projekt.kapitel.indexOf(k) + 1; + return ( + <> + {gruppen.map((g) => { + const auffaellig = g.widerspruch || g.plan?.abweichend; + return ( +
+ + {g.merkmal} + {auffaellig && ' ⚠'} + + + {g.fakten.map((x) => ( + + + + + ))} + {g.plan && ( + + {fmt(tf.planung, { wert: g.plan.wert })} + + )} + + +
+ ); + })} + + + oeffneDialog('figurenfakten', { figur: f.id })}>{laeuft ? tf.laeuft : gruppen.length ? tf.erneut : tf.sammeln} + + + + ); +} + +function AusDemText({ f, daten, index, nachKapitel }: { f: Figur; daten: ProjektDaten; index: Index; nachKapitel: Map }) { const markiere = useStore((s) => s.markiere); const arten = artJeKapitel([...nachKapitel.values()].flat()); const nr = (k: Id) => daten.projekt.kapitel.indexOf(k) + 1; @@ -147,6 +198,7 @@ function AusDemText({ daten, index, nachKapitel }: { daten: ProjektDaten; index: ); })} + )} @@ -207,14 +259,40 @@ 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); + /** F-14: bei Statisten nur ausgefüllte Planfelder, die übrigen auf Klick */ + const [leereZeigen, setLeereZeigen] = useState(false); const uid = useId(); const t = useTexte(); const tp = t.figuren.karte.planung; const gruppen = Object.values(daten.gruppen).sort((a, b) => vergleich(a.name, b.name)); + // A-05: Gruppen der Figur (Mitgliedschaft über gruppe.mitglieder; figur.gruppe = Hauptgruppe) + const meine = gruppen.filter((g) => g.mitglieder.includes(f.id) || g.id === f.gruppe); + const andere = gruppen.filter((g) => !meine.includes(g)); + const gruppeHinzu = (wert: string) => { + if (wert === NEUE_GRUPPE) return setNeueGruppe(''); + if (wert) void mitgliedHinzufuegen(wert, f.id); + }; + const alleFelder = Object.keys(f.plan); + const felder = + f.rolle === 'statist' && !leereZeigen ? alleFelder.filter((feld) => f.plan[feld]?.trim() || umbenennen === feld) : alleFelder; + const versteckt = alleFelder.length - felder.length; + const gruppeAnlegenUndSetzen = async () => { + const name = neueGruppe?.trim(); + if (!name) return; + setNeueGruppe(null); + await gruppeAnlegen(name, { mitglieder: [f.id] }); + }; return ( {tp.rechts}} label={tp.titel}>
@@ -230,52 +308,88 @@ function DeinePlanung({ f, daten }: { f: Figur; daten: ProjektDaten }) { optionen={[ { wert: 'haupt', label: t.figuren.rolle.haupt }, { wert: 'neben', label: t.figuren.rolle.neben }, + { wert: 'statist', label: t.figuren.rolle.statist }, ]} /> - - void p.figurAendern(f.id, { - aliase: [...new Set(t.split(',').map((x) => x.trim()).filter(Boolean))], - }) - } - /> + - - {f.gruppe && daten.gruppen[f.gruppe] && ( - oeffne('beziehungen', { gruppe: f.gruppe!, figur: f.id })}> - {tp.imNetz} - + {neueGruppe === null ? ( + + {meine.map((g) => { + const haupt = g.id === f.gruppe; + const rolle = g.rollen?.[f.id]; + return ( + + + + + + ); + })} + + + ) : ( + <> + setNeueGruppe(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void gruppeAnlegenUndSetzen(); + if (e.key === 'Escape') setNeueGruppe(null); + }} + /> + + + )} - {Object.keys(f.plan).map((feld) => ( + {felder.map((feld) => ( ))}
+ {versteckt > 0 && ( + + )} + +
+ {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; @@ -725,17 +970,28 @@ export function Figurenkarte({ id, daten, index }: { id: Id; daten: ProjektDaten
- + - - + + {(f.rolle !== 'statist' || bogenVon(daten.boegen, f.id).stufen.length > 0) && }
+ + {zusammenfuehrung?.figur !== f.id && Object.keys(daten.figuren).length > 1 && ( + + )} + {statistenSichtbar && + statisten.map((f) => )} + + )} {gruppe(tl.offen, b.offen, zeileOffen, 'var(--vage-dark)')} {tl.geplant} {b.geplant.filter(passt).map((f) => ( diff --git a/src/features/figuren/Spitznamen.tsx b/src/features/figuren/Spitznamen.tsx new file mode 100644 index 0000000..a96fb83 --- /dev/null +++ b/src/features/figuren/Spitznamen.tsx @@ -0,0 +1,135 @@ +/** + * F-08/F-16: Spitznamen einer Figur als Chips. Neue Namen mit Enter oder Komma (Einfügen mit Kommas teilt auf), + * × entfernt. Klick auf einen Chip öffnet den Kontext: wer nennt die Figur so, ab welchem Kapitel, Notiz. + * Die Namen speisen die Erkennung (Figur.aliase), der Kontext steht in Figur.spitznamen. + */ +import { useState } from 'react'; +import { mitAliasen, mitSpitznameKontext, spitznameKontext } from '@core/figuren/namen'; +import type { Figur, ProjektDaten } from '@core/model/types'; +import { f as fmt, useTexte, vergleich } from '../../i18n'; +import { useStore } from '../../state/store'; +import { EntwurfFeld } from '../akte/gemeinsam'; + +export function SpitznamenFeld({ f, daten, id }: { f: Figur; daten: ProjektDaten; id: string }) { + const figurAendern = useStore((s) => s.planung.figurAendern); + const [eingabe, setEingabe] = useState(''); + const [offen, setOffen] = useState(null); + const t = useTexte(); + const T = t.figuren.karte.spitznamen; + const tp = t.figuren.karte.planung; + + const speichern = (neu: Figur) => void figurAendern(f.id, { aliase: neu.aliase, spitznamen: neu.spitznamen }); + const hinzufuegen = (text: string) => { + const namen = text + .split(',') + .map((x) => x.trim()) + .filter(Boolean); + if (namen.length) speichern(mitAliasen(f, [...f.aliase, ...namen])); + setEingabe(''); + }; + const entfernen = (name: string) => { + if (offen === name) setOffen(null); + speichern(mitAliasen(f, f.aliase.filter((a) => a !== name))); + }; + const kontext = offen ? (spitznameKontext(f, offen) ?? { name: offen }) : null; + const andere = Object.values(daten.figuren) + .filter((x) => x.id !== f.id) + .sort((a, b) => vergleich(a.name, b.name)); + + return ( + + e.currentTarget === e.target && document.getElementById(id)?.focus()}> + {f.aliase.map((a) => { + const k = spitznameKontext(f, a); + return ( + + + + + ); + })} + { + const v = e.target.value; + if (v.includes(',')) hinzufuegen(v); + else setEingabe(v); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + hinzufuegen(eingabe); + } else if (e.key === 'Backspace' && !eingabe && f.aliase.length) entfernen(f.aliase[f.aliase.length - 1]!); + }} + onBlur={() => eingabe.trim() && hinzufuegen(eingabe)} + /> + + {kontext && ( + + {fmt(T.werNennt, { name: kontext.name })} + + {(kontext.von ?? []).map((v) => ( + + {daten.figuren[v]?.name ?? v} + + + ))} + + + + speichern(mitSpitznameKontext(f, { ...kontext, notiz }))} + /> + + )} + + ); +} diff --git a/src/features/figuren/index.tsx b/src/features/figuren/index.tsx index beb9363..4adaad2 100644 --- a/src/features/figuren/index.tsx +++ b/src/features/figuren/index.tsx @@ -5,6 +5,7 @@ import type { Store } from '../../state/store'; import { useStore } from '../../state/store'; import { Button, Segment } from '../../ui'; import { FigurenBoegen } from './Boegen'; +import { Besetzung } from './Besetzung'; import { Figurenkarte } from './Figurenkarte'; import { FigurenListe } from './Liste'; import { StimmeKnopf, StimmeSpalte } from './Stimme'; @@ -28,8 +29,10 @@ export default function Ansicht() { if (!daten) return null; return (
- - {figurenAnsicht === 'boegen' ? ( + {figurenAnsicht !== 'besetzung' && } + {figurenAnsicht === 'besetzung' ? ( + + ) : figurenAnsicht === 'boegen' ? ( ) : id ? ( <> @@ -56,7 +59,7 @@ export function Titelaktionen() { const t = useTexte(); return ( <> - + rolle="group" label={t.figuren.ansicht.ansicht} hintergrund="selected" @@ -66,6 +69,7 @@ export function Titelaktionen() { optionen={[ { wert: 'karte', label: t.figuren.ansicht.karte }, { wert: 'boegen', label: t.figuren.ansicht.boegen }, + { wert: 'besetzung', label: t.figuren.ansicht.besetzung }, ]} /> {figurenAnsicht === 'karte' && } diff --git a/src/features/gruppen/Gruppen.tsx b/src/features/gruppen/Gruppen.tsx index 3936947..ddb26d2 100644 --- a/src/features/gruppen/Gruppen.tsx +++ b/src/features/gruppen/Gruppen.tsx @@ -127,6 +127,7 @@ export function GruppenInspektor({ gruppe: id, onFigur }: GruppenInspektorProps) const gruppeAendern = useStore((s) => s.gruppeAendern); const mitgliedHinzufuegen = useStore((s) => s.mitgliedHinzufuegen); const mitgliedEntfernen = useStore((s) => s.mitgliedEntfernen); + const gruppenRolleSetzen = useStore((s) => s.gruppenRolleSetzen); const spurStatusSetzen = useStore((s) => s.spurStatusSetzen); const spurHinzufuegen = useStore((s) => s.spurHinzufuegen); const markiere = useStore((s) => s.markiere); @@ -272,17 +273,29 @@ export function GruppenInspektor({ gruppe: id, onFigur }: GruppenInspektorProps)
{t.mitglieder} -
+
{gruppe.mitglieder.map((m) => ( - - - - +
+ + + + + void gruppenRolleSetzen(id, m, r)} + /> +
))} +
+
{gruppe.mitglieder.length === 0 && {t.keineFigur}} {nichtMitglieder.length > 0 && ( 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/Randnotizen.tsx b/src/features/schreiben/Randnotizen.tsx index 3547988..099d270 100644 --- a/src/features/schreiben/Randnotizen.tsx +++ b/src/features/schreiben/Randnotizen.tsx @@ -3,6 +3,7 @@ * Verweisen und Kapitel-Links; neue Notiz mit Ziel-Auswahl und `[[`-Vorschlägen; Sammelbox für * Notizen, deren Stelle nicht mehr gefunden wird. Ist der Rand zu schmal, stehen dort nur Punkte. */ +import { nachRolle } from '@core/figuren/namen'; import type { Anmerkung, Id, ProjektDaten, TextAnker, Verweis } from '@core/model/types'; import type { Editor } from '@tiptap/react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react'; @@ -49,7 +50,7 @@ function gleich(a: Record, b: Record): boolean { function ZielAuswahl({ daten, kapitelId, wert, onChange }: { daten: ProjektDaten; kapitelId: Id; wert: Verweis | null; onChange(v: Verweis | null): void }) { const T = useTexte().schreiben.notizen; - const figuren = Object.values(daten.figuren).sort((a, b) => (a.rolle === b.rolle ? vergleich(a.name, b.name) : a.rolle === 'haupt' ? -1 : 1)); + const figuren = Object.values(daten.figuren).sort(nachRolle((a, b) => vergleich(a.name, b.name))); const orte = Object.values(daten.orte).sort((a, b) => vergleich(a.name, b.name)); const kopf = notizKopf(daten, wert, kapitelId, true); return ( diff --git a/src/features/schreiben/schreiben.css b/src/features/schreiben/schreiben.css index d5be266..35f2033 100644 --- a/src/features/schreiben/schreiben.css +++ b/src/features/schreiben/schreiben.css @@ -209,7 +209,7 @@ border-radius: 7px; background: var(--ink); box-shadow: var(--schatten-popover); - transform: translate(-50%, calc(-100% - 8px)); + transform: translate(-50%, -100%); font-family: var(--schrift-ui); } .pw-s-blase button { @@ -231,11 +231,19 @@ margin: 4px 2px; background: color-mix(in srgb, var(--on-ink) 25%, transparent); } -.pw-s-blase__ebenen { +.pw-s-blase--unten { + transform: translateX(-50%); +} +.pw-s-blase button:focus-visible { + outline: 2px solid var(--on-ink); + outline-offset: -2px; +} +/* Panel unter der Leiste (Gewissheit, Figur …); --versatz hält es im Papier */ +.pw-s-blase__panel { position: absolute; top: calc(100% + 4px); left: 50%; - transform: translateX(-50%); + transform: translateX(calc(-50% + var(--versatz, 0px))); display: flex; gap: 2px; padding: 3px; @@ -244,6 +252,84 @@ box-shadow: var(--schatten-popover); white-space: nowrap; } +.pw-s-blase__panel--spalte { + flex-direction: column; + align-items: stretch; + gap: 4px; + padding: 8px; + width: 280px; + white-space: normal; + 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/features/schreiben/verweise.ts b/src/features/schreiben/verweise.ts index ae55c72..b2b8721 100644 --- a/src/features/schreiben/verweise.ts +++ b/src/features/schreiben/verweise.ts @@ -4,6 +4,7 @@ * Die ID ist maßgeblich (E1), die Anzeige nur der Text zum Zeitpunkt des Einfügens. * Nicht im Manuskripttext selbst. */ +import { nachRolle } from '@core/figuren/namen'; import type { Id, ProjektDaten, Verweis, VerweisTyp } from '@core/model/types'; import type { Bereich, Store } from '../../state/store'; import { f, sprache, texte, vergleich } from '../../i18n'; @@ -87,7 +88,7 @@ export function alleVerweisZiele(daten: ProjektDaten): VerweisVorschlag[] { const liste: VerweisVorschlag[] = []; const art = verweisArten(); const englisch = sprache() === 'en'; - const figuren = Object.values(daten.figuren).sort((a, b) => (a.rolle === b.rolle ? vergleich(a.name, b.name) : a.rolle === 'haupt' ? -1 : 1)); + const figuren = Object.values(daten.figuren).sort(nachRolle((a, b) => vergleich(a.name, b.name))); for (const fig of figuren) liste.push({ typ: 'figur', id: fig.id, anzeige: fig.name, art: art.figur, suche: [fig.name, ...fig.aliase] }); for (const o of Object.values(daten.orte).sort((a, b) => vergleich(a.name, b.name))) liste.push({ typ: 'ort', id: o.id, anzeige: o.name, art: art.ort, suche: [o.name, ...o.aliase] }); diff --git a/src/features/zeitleiste/Inspektor.tsx b/src/features/zeitleiste/Inspektor.tsx index ff675b5..ee6dd80 100644 --- a/src/features/zeitleiste/Inspektor.tsx +++ b/src/features/zeitleiste/Inspektor.tsx @@ -1,4 +1,5 @@ /** Inspektor der Zeitleiste (G1): Zeitpunkt und Zustand der gewählten Karte festlegen; ohne Auswahl Beginn und Zustandsliste. */ +import { nachRolle } from '@core/figuren/namen'; import { useEffect, useState, type KeyboardEvent, type ReactNode } from 'react'; import type { Id, TextAnker, Zeitpunkt, ZeitSicherheit } from '@core/model/types'; import { @@ -465,7 +466,7 @@ function FigurWahl({ figur }: { figur: Id | undefined }) { const daten = useStore((s) => s.daten)!; const setzeFigur = useStore((s) => s.zeit.setzeFigur); const t = useTexte().zeitleiste.ansicht; - const figuren = Object.values(daten.figuren).sort((a, b) => (a.rolle === b.rolle ? vergleich(a.name, b.name) : a.rolle === 'haupt' ? -1 : 1)); + const figuren = Object.values(daten.figuren).sort(nachRolle((a, b) => vergleich(a.name, b.name))); return (