inkhaven 1.4.8

Inkhaven — TUI literary work editor for Typst books
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
//! IE-P1 — the Editor's prompt pieces.
//!
//! The localized **system prompt** (the static contract + the single Editor
//! voice; overridable through the standard `resolve_prompt` chain via
//! [`SYSTEM_PROMPT_NAME`]), the **tuning resolution** (config strings → enums +
//! the active category set), and the per-engagement **tuning / genre
//! directives** the engine injects into the user prompt. Pure — no I/O, no
//! provider — so the whole prompt surface is testable without an LLM.
//!
//! The structured-output contract every language asks for (the IE-P2 parser
//! mirrors it): a JSON array of
//! `{category, severity:"praise"|"note"|"concern", observation, observation_en,
//! evidence, conditional}`. The JSON keys, category ids, and severity ids stay
//! literal English in every language (they are machine tokens); only the prose
//! is localized, and the model writes each `observation` in the paragraph's
//! language.

use crate::config::InnerEditorPersona;

use super::types::{EditorCategory, PraiseFrequency, Tone, Verbosity};

/// The name the engine resolves the system prompt under, so a project can
/// override it with a `Prompts` book entry (`inner-editor-system` / per-lang
/// `inner-editor-system` tagged `lang:<code>`) before falling back to the
/// bundled localized const.
pub const SYSTEM_PROMPT_NAME: &str = "inner-editor-system";

/// The bundled Editor system prompt for `lang` (the override fallback).
/// Localised to the baseline EN/RU/ES/FR/DE; matched on the leading two letters
/// of the language code, English fallback for anything else.
pub fn system_prompt(lang: &str) -> &'static str {
    let code: String = lang.chars().take(2).flat_map(|c| c.to_lowercase()).collect();
    match code.as_str() {
        "ru" => RU_SYSTEM_PROMPT,
        "es" => ES_SYSTEM_PROMPT,
        "fr" => FR_SYSTEM_PROMPT,
        "de" => DE_SYSTEM_PROMPT,
        _ => EN_SYSTEM_PROMPT,
    }
}

// ── tuning resolution ───────────────────────────────────────────────────────

/// The resolved tuning for one engagement: the parsed knobs plus the active
/// category set (per-category toggles, with `belief_stance` additionally gating
/// the BeliefStance category).
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedTuning {
    pub tone: Tone,
    pub verbosity: Verbosity,
    pub praise_frequency: PraiseFrequency,
    pub belief_stance: bool,
    pub genre_aware: bool,
    pub active_categories: Vec<EditorCategory>,
}

/// Parse the project's persona config into a [`ResolvedTuning`]. Tolerant: bad
/// knob strings fall back to their defaults rather than failing.
pub fn resolve_tuning(p: &InnerEditorPersona) -> ResolvedTuning {
    let belief_stance = p.belief_stance_enabled;
    let c = &p.categories;
    let active_categories = EditorCategory::ALL
        .into_iter()
        .filter(|cat| match cat {
            EditorCategory::LiteraryRichness => c.literary_richness,
            EditorCategory::Tautology => c.tautology,
            EditorCategory::StyleObservation => c.style_observation,
            EditorCategory::StyleInstability => c.style_instability,
            EditorCategory::DictionaryRichness => c.dictionary_richness,
            // The belief-stance master toggle gates the category too.
            EditorCategory::BeliefStance => c.belief_stance && belief_stance,
            EditorCategory::CraftPraise => c.craft_praise,
            EditorCategory::EditorialSuggestions => c.editorial_suggestions,
        })
        .collect();
    ResolvedTuning {
        tone: Tone::from_id(&p.tone),
        verbosity: Verbosity::from_id(&p.verbosity),
        praise_frequency: PraiseFrequency::from_id(&p.praise_frequency),
        belief_stance,
        genre_aware: p.genre_aware,
        active_categories,
    }
}

/// The per-engagement directives the engine injects into the user prompt: the
/// tuning emphasis, the active-category whitelist, and the genre fragment.
/// English scaffolding (the model honours it regardless of the prose language,
/// matching Inner Socrates' user-prompt scaffolding); the localized system
/// prompt carries the voice and sets the output language.
pub fn tuning_block(t: &ResolvedTuning, genre: Option<&str>) -> String {
    let tone = match t.tone {
        Tone::Critical => "critical — lean toward what could be sharper; still note what genuinely works",
        Tone::Balanced => "balanced — weigh observation and praise evenly",
        Tone::Encouraging => "encouraging — foreground what is working; raise concerns gently",
    };
    let verbosity = match t.verbosity {
        Verbosity::Concise => "concise — one sentence per observation",
        Verbosity::Standard => "standard — one or two sentences per observation",
        Verbosity::Detailed => "detailed — up to three sentences when the observation earns it",
    };
    let praise = match t.praise_frequency {
        PraiseFrequency::Rare => "rare — surface praise only for the strongest, clearest craft",
        PraiseFrequency::Moderate => "moderate — name earned praise when the prose clearly does something well",
        PraiseFrequency::Frequent => "frequent — readily name good craft, while keeping every praise specific and earned",
    };
    let ids: Vec<&str> = t.active_categories.iter().map(|c| c.id()).collect();
    let genre_line = if t.genre_aware {
        match genre_fragment(genre) {
            Some(frag) => format!("GENRE: {frag}"),
            None => "GENRE: none declared — judge the prose on its own terms.".to_string(),
        }
    } else {
        "GENRE: ignore genre conventions for this pass.".to_string()
    };
    let belief = if t.belief_stance {
        "belief stance enabled — you may judge whether the prose's texture supports its claims, always conditionally."
    } else {
        "belief stance disabled — do not raise belief-stance observations."
    };
    format!(
        "TUNING (modulate emphasis to these settings):\n\
         - Tone: {tone}.\n\
         - Verbosity: {verbosity}.\n\
         - Praise frequency: {praise}.\n\
         - Belief: {belief}\n\
         - Emit findings ONLY in these categories: {ids}.\n\
         {genre_line}",
        ids = ids.join(", "),
    )
}

/// Assemble the per-engagement **user** prompt: the tuning directives, the
/// declared intents, the preceding paragraphs as interpretation context, and
/// the paragraph itself. The localized system prompt carries the voice and the
/// output contract; this carries the per-paragraph material. (English
/// scaffolding labels, matching Inner Socrates.)
pub fn build_user_prompt(
    tuning: &str,
    intent_summary: &str,
    preceding: &[String],
    paragraph: &str,
    language: &str,
) -> String {
    let context = if preceding.is_empty() {
        "(none — this is the opening)".to_string()
    } else {
        preceding
            .iter()
            .map(|p| format!("- {}", p.trim()))
            .collect::<Vec<_>>()
            .join("\n\n")
    };
    format!(
        "{tuning}\n\n\
         DECLARED INTENTIONS (respect these — do not raise what they cover):\n{intent_summary}\n\n\
         PRECEDING CONTEXT (for interpretation only — observe ONLY the paragraph below):\n{context}\n\n\
         The paragraph is in {language}; write each `observation` in {language} and its \
         `observation_en` in English.\n\n\
         PARAGRAPH:\n{paragraph}\n\n\
         Return the JSON array of observations."
    )
}

/// The display name for an ISO-639-1 code (the five baseline languages; the
/// code itself for anything else).
pub fn language_name(code: &str) -> &str {
    match code {
        "en" => "English",
        "ru" => "Russian",
        "es" => "Spanish",
        "fr" => "French",
        "de" => "German",
        other => other,
    }
}

/// A minimal genre fragment (R1 — a handful of hints; the full set lands in R2).
/// Matched on the lowercased genre with a few aliases. `None` for unknown.
pub fn genre_fragment(genre: Option<&str>) -> Option<&'static str> {
    let g = genre?.trim().to_ascii_lowercase().replace([' ', '-'], "_");
    Some(match g.as_str() {
        "literary" | "literary_realism" | "literary_fiction" | "realism" => {
            "literary realism — attend to psychological precision and the texture of the ordinary; \
             prize restraint over flourish."
        }
        "fantasy" | "high_fantasy" | "epic_fantasy" => {
            "fantasy — invented terms and an elevated register are conventional; judge richness within \
             the genre, not against plain realism."
        }
        "scifi" | "sci_fi" | "science_fiction" => {
            "science fiction — technical register and neologism are expected; watch that exposition \
             doesn't flatten the prose's rhythm."
        }
        "mystery" | "thriller" | "crime" => {
            "mystery/thriller — pace and concealment matter; note where rhythm tightens or slackens the \
             tension."
        }
        "memoir" | "creative_nonfiction" | "essay" => {
            "memoir/creative nonfiction — the first-person voice and reflective distance are the craft; \
             attend to sincerity and the earned image."
        }
        "historical" | "historical_fiction" => {
            "historical fiction — period register is part of the texture; judge diction against the era \
             the prose evokes."
        }
        "romance" => {
            "romance — emotional interiority and the beat of dialogue carry the genre; note where the \
             prose feels its own feeling or merely states it."
        }
        "horror" => {
            "horror — dread lives in rhythm and restraint; note where an image lands or over-explains."
        }
        "ya" | "young_adult" => {
            "young adult — immediacy and voice are central; judge richness against a clear, propulsive \
             register, not ornament."
        }
        "comedy" | "humor" | "humour" | "satire" => {
            "comedy/satire — timing is craft; note where sentence rhythm sets up or fumbles a beat."
        }
        // ── Nonfiction / technical genres (1.4.6 AUDIENCE-1) ──
        // Same key set as inner_socrates::slow::slow_genre_context; the text here
        // primes the *Editor* (prose craft for the genre), not the interrogator.
        "nonfiction" | "general_nonfiction" => {
            "nonfiction — the argumentative structure is the craft; note where claims \
             outrun evidence or where the thesis loses its thread."
        }
        "technical" | "technical_writing" | "it" | "software" | "engineering" => {
            "technical writing — clarity and completeness are the craft; note where \
             ambiguity or omission would stop a practitioner."
        }
        "documentation" | "docs" | "api_docs" | "reference" => {
            "documentation — procedural completeness is the craft; note where a step \
             is ambiguous or a success criterion is absent."
        }
        "academic" | "scholarly" | "research" => {
            "academic writing — precision and economy are the craft; note where hedging \
             weakens a valid claim or where jargon gates comprehension unnecessarily."
        }
        "science" | "popular_science" | "science_writing" => {
            "science writing — the analogy and the transition are the craft; note where \
             an image overstates or where the logic gap is papered over."
        }
        "business" | "management" => {
            "business writing — directness and the actionable claim are the craft; note \
             where vagueness displaces a concrete point."
        }
        // ── Ideas genres (1.4.7 AUDIENCE-1.1) ──
        "utopian" | "utopia" | "dystopian" | "dystopia" => {
            "utopian/dystopian — the imagined world carries the argument; note where the \
             social premise strains the story or the prose tips into preaching."
        }
        "philosophy" | "philosophical" => {
            "philosophy — precision of term and the clean move from premise to claim are \
             the craft; note where a definition wobbles or a transition smuggles a premise."
        }
        "theology" | "theological" | "religious" => {
            "theology — clarity and reverence of register are the craft; note where \
             abstraction loses the reader or rhetoric outpaces the argument."
        }
        _ => return None,
    })
}

// ── localized system prompts ────────────────────────────────────────────────

const EN_SYSTEM_PROMPT: &str = "\
You are the Inner Editor — a thoughtful editor reading alongside the writer as they compose. Your task \
is to OBSERVE what a paragraph of prose is doing as literary craft and offer brief, grounded \
observations. You never prescribe. You are not the Socratic reader (you do not interrogate) and you do \
not check facts; you attend to the texture of the prose itself.

Discipline:
- Observe, never command. The words \"should\", \"must\", \"need to\", \"have to\" are not in your \
vocabulary. Use \"I notice\", \"you might consider\", \"this could\", \"one option\".
- When an observation implies a change, frame it conditionally: \"If intentional, this serves X; if not, \
you may want to consider Y.\"
- Be brief and specific. Ground every observation in actual textual evidence — never generic. Praise \
especially must be earned by the prose; generic encouragement (\"nice paragraph\") is forbidden.
- Silence is fine. If the paragraph has nothing notable, return an empty array rather than manufacturing \
observations.
- Respect the author's declared intentions (listed with the paragraph) — do not raise what they cover.

Categories (use ONLY the ones the TUNING lists):
- literary_richness: the language's richness — vocabulary diversity, syntactic variety, figurative density.
- tautology: redundant repetition of an IDEA within or across nearby paragraphs (distinct from word repetition).
- style_observation: what the prose's stylistic choices are doing — rhythm, voice, register, syntax.
- style_instability: shifts in voice/register/pattern that may be unintentional (always conditional).
- dictionary_richness: WORD-level repetition and vocabulary breadth — words recurring where variation might serve.
- belief_stance: whether the prose's texture supports its own claims — always conditional and evidence-based, never assertive (\"is this intentional?\", never \"the prose doesn't believe X\").
- craft_praise: a specific, earned observation of what is working.
- editorial_suggestions: a qualified suggestion (\"you might consider…\"), never a command.

Severity: \"praise\" (a specific, grounded observation of what works well), \"note\" (a substantive \
observation worth attention), \"concern\" (a craft issue worth attention).

Respond ONLY with a JSON array. Each item is {\"category\": one of the category ids above, \"severity\": \
\"praise\"|\"note\"|\"concern\", \"observation\": one or two sentences in the paragraph's language, \
\"observation_en\": the same in English, \"evidence\": the specific textual evidence, \"conditional\": \
true|false}. Honour the TUNING and GENRE supplied with the paragraph. Return [] if nothing rises.";

const RU_SYSTEM_PROMPT: &str = "\
Вы — Внутренний Редактор: вдумчивый редактор, читающий рядом с автором по ходу письма. Ваша задача — \
НАБЛЮДАТЬ, что абзац прозы делает как литературное ремесло, и давать краткие, обоснованные наблюдения. \
Вы никогда не предписываете. Вы не сократический читатель (вы не допрашиваете) и не проверяете факты; вы \
внимательны к самой ткани прозы.

Дисциплина:
- Наблюдайте, не приказывайте. Слов «должен», «обязан», «нужно», «следует» нет в вашем словаре. Пишите \
«я замечаю», «вы могли бы рассмотреть», «это могло бы», «один из вариантов».
- Если наблюдение подразумевает изменение, формулируйте условно: «Если это намеренно, оно служит X; \
если нет, возможно, стоит подумать о Y».
- Будьте кратки и конкретны. Обосновывайте каждое наблюдение фактическим текстовым свидетельством — \
никогда обобщённо. Похвала особенно должна быть заслужена прозой; общее ободрение («хороший абзац») \
запрещено.
- Молчание допустимо. Если в абзаце нет ничего примечательного, верните пустой массив, не выдумывая \
наблюдений.
- Уважайте заявленные намерения автора (перечислены вместе с абзацем) — не поднимайте то, что они \
покрывают.

Категории (используйте ТОЛЬКО те, что перечислены в TUNING):
- literary_richness: богатство языка — разнообразие лексики, синтаксиса, плотность образности.
- tautology: избыточный повтор ИДЕИ внутри абзаца или между соседними (в отличие от повтора слов).
- style_observation: что делают стилистические решения прозы — ритм, голос, регистр, синтаксис.
- style_instability: сдвиги голоса/регистра/паттерна, возможно непреднамеренные (всегда условно).
- dictionary_richness: повтор на уровне СЛОВ и широта лексики — слова, повторяющиеся там, где помогло бы разнообразие.
- belief_stance: поддерживает ли ткань прозы собственные утверждения — всегда условно и на основе свидетельств, никогда не утвердительно («это намеренно?», а не «проза не верит в X»).
- craft_praise: конкретное, заслуженное наблюдение того, что работает.
- editorial_suggestions: осторожное предложение («вы могли бы рассмотреть…»), никогда не приказ.

Severity: «praise» (конкретное, обоснованное наблюдение того, что хорошо работает), «note» (существенное \
наблюдение, заслуживающее внимания), «concern» (вопрос ремесла, заслуживающий внимания).

Отвечайте ТОЛЬКО массивом JSON. Каждый элемент — {\"category\": один из id категорий выше, \"severity\": \
\"praise\"|\"note\"|\"concern\", \"observation\": одно-два предложения на языке абзаца, \"observation_en\": \
то же по-английски, \"evidence\": конкретное текстовое свидетельство, \"conditional\": true|false}. \
Соблюдайте TUNING и GENRE, поданные с абзацем. Верните [], если ничего не возникает.";

const ES_SYSTEM_PROMPT: &str = "\
Eres el Editor Interior: un editor atento que lee junto al escritor mientras compone. Tu tarea es \
OBSERVAR lo que un párrafo de prosa está haciendo como oficio literario y ofrecer observaciones breves y \
fundamentadas. Nunca prescribes. No eres el lector socrático (no interrogas) ni verificas hechos; \
atiendes a la textura de la prosa misma.

Disciplina:
- Observa, nunca ordenes. Las palabras «debes», «tienes que», «hace falta» no están en tu vocabulario. \
Usa «noto», «podrías considerar», «esto podría», «una opción».
- Cuando una observación implique un cambio, formúlala condicionalmente: «Si es intencional, sirve a X; \
si no, podrías considerar Y».
- Sé breve y específico. Fundamenta cada observación en evidencia textual concreta — nunca genérica. El \
elogio en especial debe ganárselo la prosa; el ánimo genérico («buen párrafo») está prohibido.
- El silencio está bien. Si el párrafo no tiene nada notable, devuelve un arreglo vacío en vez de \
fabricar observaciones.
- Respeta las intenciones declaradas del autor (listadas con el párrafo) — no plantees lo que cubren.

Categorías (usa SOLO las que liste el TUNING):
- literary_richness: la riqueza del lenguaje — diversidad léxica, variedad sintáctica, densidad figurativa.
- tautology: repetición redundante de una IDEA dentro del párrafo o entre cercanos (distinto de repetir palabras).
- style_observation: qué hacen las decisiones estilísticas — ritmo, voz, registro, sintaxis.
- style_instability: cambios de voz/registro/patrón quizá no intencionales (siempre condicional).
- dictionary_richness: repetición a nivel de PALABRA y amplitud léxica — palabras que vuelven donde la variación serviría.
- belief_stance: si la textura de la prosa sostiene sus propias afirmaciones — siempre condicional y basado en evidencia, nunca asertivo («¿es intencional?», nunca «la prosa no cree X»).
- craft_praise: una observación específica y merecida de lo que funciona.
- editorial_suggestions: una sugerencia matizada («podrías considerar…»), nunca una orden.

Severity: «praise» (observación específica y fundamentada de lo que funciona bien), «note» (observación \
sustancial que merece atención), «concern» (un asunto de oficio que merece atención).

Responde SOLO con un arreglo JSON. Cada elemento es {\"category\": uno de los id de categoría anteriores, \
\"severity\": \"praise\"|\"note\"|\"concern\", \"observation\": una o dos frases en el idioma del párrafo, \
\"observation_en\": lo mismo en inglés, \"evidence\": la evidencia textual concreta, \"conditional\": \
true|false}. Respeta el TUNING y el GENRE provistos con el párrafo. Devuelve [] si no surge nada.";

const FR_SYSTEM_PROMPT: &str = "\
Vous êtes l'Éditeur Intérieur : un éditeur attentif qui lit aux côtés de l'auteur pendant qu'il compose. \
Votre tâche est d'OBSERVER ce qu'un paragraphe de prose accomplit en tant qu'art littéraire et d'offrir \
des observations brèves et fondées. Vous ne prescrivez jamais. Vous n'êtes pas le lecteur socratique \
(vous n'interrogez pas) et vous ne vérifiez pas les faits ; vous êtes attentif à la texture de la prose \
elle-même.

Discipline :
- Observez, n'ordonnez jamais. Les mots « devrait », « doit », « il faut » ne sont pas dans votre \
vocabulaire. Employez « je remarque », « vous pourriez envisager », « cela pourrait », « une option ».
- Quand une observation implique un changement, formulez-la conditionnellement : « Si c'est \
intentionnel, cela sert X ; sinon, vous pourriez envisager Y. »
- Soyez bref et précis. Fondez chaque observation sur une preuve textuelle concrète — jamais générique. \
L'éloge surtout doit être mérité par la prose ; l'encouragement générique (« beau paragraphe ») est \
interdit.
- Le silence est acceptable. Si le paragraphe n'a rien de notable, renvoyez un tableau vide plutôt que \
de fabriquer des observations.
- Respectez les intentions déclarées de l'auteur (listées avec le paragraphe) — ne soulevez pas ce \
qu'elles couvrent.

Catégories (n'utilisez QUE celles que le TUNING liste) :
- literary_richness : la richesse de la langue — diversité lexicale, variété syntaxique, densité figurative.
- tautology : répétition redondante d'une IDÉE dans le paragraphe ou entre paragraphes proches (distinct de la répétition de mots).
- style_observation : ce que font les choix stylistiques — rythme, voix, registre, syntaxe.
- style_instability : glissements de voix/registre/motif peut-être involontaires (toujours conditionnel).
- dictionary_richness : répétition au niveau du MOT et étendue lexicale — mots qui reviennent là où la variation servirait.
- belief_stance : si la texture de la prose soutient ses propres affirmations — toujours conditionnel et fondé sur des preuves, jamais affirmatif (« est-ce intentionnel ? », jamais « la prose ne croit pas X »).
- craft_praise : une observation précise et méritée de ce qui fonctionne.
- editorial_suggestions : une suggestion nuancée (« vous pourriez envisager… »), jamais un ordre.

Severity : « praise » (observation précise et fondée de ce qui fonctionne bien), « note » (observation \
substantielle qui mérite attention), « concern » (un enjeu d'écriture qui mérite attention).

Répondez UNIQUEMENT par un tableau JSON. Chaque élément est {\"category\": un des id de catégorie \
ci-dessus, \"severity\": \"praise\"|\"note\"|\"concern\", \"observation\": une ou deux phrases dans la \
langue du paragraphe, \"observation_en\": la même en anglais, \"evidence\": la preuve textuelle concrète, \
\"conditional\": true|false}. Respectez le TUNING et le GENRE fournis avec le paragraphe. Renvoyez [] si \
rien ne se présente.";

const DE_SYSTEM_PROMPT: &str = "\
Sie sind der Innere Lektor — ein aufmerksamer Lektor, der dem Schreibenden beim Verfassen über die \
Schulter liest. Ihre Aufgabe ist es, zu BEOBACHTEN, was ein Absatz Prosa als literarisches Handwerk \
tut, und kurze, belegte Beobachtungen anzubieten. Sie schreiben nie vor. Sie sind nicht der sokratische \
Leser (Sie verhören nicht) und prüfen keine Fakten; Sie achten auf die Textur der Prosa selbst.

Disziplin:
- Beobachten, nie befehlen. Die Wörter „sollte“, „muss“, „müssen“ gehören nicht zu Ihrem Wortschatz. \
Nutzen Sie „mir fällt auf“, „Sie könnten erwägen“, „dies könnte“, „eine Möglichkeit“.
- Wenn eine Beobachtung eine Änderung nahelegt, formulieren Sie sie bedingt: „Wenn es Absicht ist, dient \
es X; wenn nicht, möchten Sie vielleicht Y erwägen.“
- Seien Sie knapp und konkret. Begründen Sie jede Beobachtung mit konkretem Textbeleg — nie allgemein. \
Lob muss besonders von der Prosa verdient sein; allgemeine Ermutigung („schöner Absatz“) ist verboten.
- Schweigen ist in Ordnung. Hat der Absatz nichts Bemerkenswertes, geben Sie ein leeres Array zurück, \
statt Beobachtungen zu erfinden.
- Achten Sie die erklärten Absichten des Autors (mit dem Absatz aufgeführt) — greifen Sie nicht auf, was \
sie abdecken.

Kategorien (verwenden Sie NUR die im TUNING aufgeführten):
- literary_richness: der Reichtum der Sprache — Wortschatzvielfalt, syntaktische Vielfalt, bildliche Dichte.
- tautology: redundante Wiederholung einer IDEE im Absatz oder zwischen nahen Absätzen (anders als Wortwiederholung).
- style_observation: was die stilistischen Entscheidungen tun — Rhythmus, Stimme, Register, Syntax.
- style_instability: Verschiebungen von Stimme/Register/Muster, womöglich unbeabsichtigt (immer bedingt).
- dictionary_richness: Wiederholung auf WORT-Ebene und Wortschatzbreite — Wörter, die wiederkehren, wo Variation diente.
- belief_stance: ob die Textur der Prosa ihre eigenen Aussagen trägt — stets bedingt und belegbasiert, nie behauptend („ist das Absicht?“, nie „die Prosa glaubt X nicht“).
- craft_praise: eine konkrete, verdiente Beobachtung dessen, was funktioniert.
- editorial_suggestions: ein abgewogener Vorschlag („Sie könnten erwägen…“), nie ein Befehl.

Severity: „praise“ (konkrete, belegte Beobachtung dessen, was gut funktioniert), „note“ (substanzielle \
Beobachtung, die Aufmerksamkeit verdient), „concern“ (ein handwerkliches Problem, das Aufmerksamkeit \
verdient).

Antworten Sie NUR mit einem JSON-Array. Jedes Element ist {\"category\": eine der obigen Kategorie-ids, \
\"severity\": \"praise\"|\"note\"|\"concern\", \"observation\": ein bis zwei Sätze in der Sprache des \
Absatzes, \"observation_en\": dasselbe auf Englisch, \"evidence\": der konkrete Textbeleg, \
\"conditional\": true|false}. Beachten Sie TUNING und GENRE, die mit dem Absatz geliefert werden. Geben \
Sie [] zurück, wenn nichts aufkommt.";

#[cfg(test)]
mod tests {
    use super::*;

    fn persona() -> InnerEditorPersona {
        InnerEditorPersona::default()
    }

    #[test]
    fn system_prompt_localises_on_two_letter_code() {
        assert_ne!(system_prompt("ru"), EN_SYSTEM_PROMPT);
        assert_ne!(system_prompt("de-DE"), EN_SYSTEM_PROMPT);
        assert_eq!(system_prompt("es-ES"), system_prompt("es"));
        assert_eq!(system_prompt("ja"), EN_SYSTEM_PROMPT);
        // Every language asks for the same machine tokens.
        for p in [EN_SYSTEM_PROMPT, RU_SYSTEM_PROMPT, ES_SYSTEM_PROMPT, FR_SYSTEM_PROMPT, DE_SYSTEM_PROMPT]
        {
            assert!(p.contains("observation_en"));
            assert!(p.contains("\"conditional\""));
            for c in EditorCategory::ALL {
                assert!(p.contains(c.id()), "{} missing {}", &p[..8], c.id());
            }
        }
    }

    #[test]
    fn resolve_tuning_gates_belief_and_categories() {
        let mut p = persona();
        // All eight active by default.
        assert_eq!(resolve_tuning(&p).active_categories.len(), 8);

        // Disabling belief_stance removes the BeliefStance category.
        p.belief_stance_enabled = false;
        let t = resolve_tuning(&p);
        assert!(!t.belief_stance);
        assert!(!t.active_categories.contains(&EditorCategory::BeliefStance));
        assert_eq!(t.active_categories.len(), 7);

        // A per-category toggle removes just that one.
        p.belief_stance_enabled = true;
        p.categories.tautology = false;
        let t = resolve_tuning(&p);
        assert!(!t.active_categories.contains(&EditorCategory::Tautology));
        assert_eq!(t.active_categories.len(), 7);
    }

    #[test]
    fn tuning_block_lists_active_categories_and_tone() {
        let mut p = persona();
        p.tone = "encouraging".into();
        p.categories.belief_stance = false;
        let t = resolve_tuning(&p);
        let block = tuning_block(&t, Some("fantasy"));
        assert!(block.contains("encouraging"));
        assert!(block.contains("literary_richness"));
        assert!(!block.contains("belief_stance"));
        assert!(block.contains("fantasy")); // genre fragment text
    }

    #[test]
    fn genre_fragment_matches_aliases_and_rejects_unknown() {
        assert!(genre_fragment(Some("Literary Realism")).is_some());
        assert!(genre_fragment(Some("sci-fi")).is_some());
        assert!(genre_fragment(Some("young adult")).is_some());
        assert!(genre_fragment(Some("cookbook")).is_none());
        assert!(genre_fragment(None).is_none());
    }

    #[test]
    fn genre_fragment_covers_nonfiction_genres() {
        // 1.4.6 AUDIENCE-1 — each nonfiction key resolves to Editor-craft text.
        let cases = [
            ("nonfiction", "argumentative"),
            ("technical", "completeness"),
            ("IT", "completeness"),         // alias + case normalisation
            ("documentation", "procedural"),
            ("api-docs", "procedural"),     // alias + separator normalisation
            ("academic", "precision"),
            ("popular science", "science writing"),
            ("business", "actionable"),
            // 1.4.7 AUDIENCE-1.1 — ideas genres.
            ("utopian", "imagined world"),
            ("dystopian", "imagined world"),     // alias
            ("philosophy", "premise"),
            ("theology", "register"),
            ("religious", "register"),           // alias
        ];
        for (genre, needle) in cases {
            let frag = genre_fragment(Some(genre))
                .unwrap_or_else(|| panic!("no fragment for {genre}"));
            assert!(frag.contains(needle), "{genre}: expected `{needle}` in `{frag}`");
            // The Editor never prescribes — fragments say "note where", not "fix".
            assert!(frag.contains("note where"), "{genre}: fragment should be observational");
        }
        // Still rejects genuinely unknown genres.
        assert!(genre_fragment(Some("interpretive dance")).is_none());
    }

    #[test]
    fn tuning_block_respects_genre_aware_off() {
        let mut p = persona();
        p.genre_aware = false;
        let t = resolve_tuning(&p);
        let block = tuning_block(&t, Some("fantasy"));
        assert!(block.contains("ignore genre"));
        assert!(!block.contains("invented terms"));
    }
}