"""Journal-adapter editorial policy. Not a general provenance or privacy rule. Fact fidelity is not wording fidelity. Editorial mode is chosen locally, without a second model call. Historical texts are style references, never today's facts. """ from __future__ import annotations import hashlib import re from difflib import SequenceMatcher from journal_body import plain_text from writing_profile_schema import is_meta_style_text, recency_weight from writing_profile_store import ( clip_field, get_profile, has_confirmed_profile, list_style_sources, ) PROSE_EDIT = "prose_edit" NOTES_TO_JOURNAL = "notes_to_journal" EDITORIAL_MODES = (PROSE_EDIT, NOTES_TO_JOURNAL) STYLE_EXAMPLE_MAX = 2 STYLE_EXAMPLE_CHARS = 900 MIN_EXAMPLE_CHARS = 40 INSTRUCTIONS = { PROSE_EDIT: ( "Modus prose_edit: Der Rohtext ist bereits erzählerisch. " "Gute Formulierungen bewahren. Rechtschreibung, Grammatik und Zeichensetzung " "korrigieren. Holprige Stellen glätten, Wiederholungen reduzieren, Absätze und " "Übergänge verbessern. Die persönliche Schreibstimme anwenden. " "Keine unnötige vollständige Neufassung erzwingen." ), NOTES_TO_JOURNAL: ( "Modus notes_to_journal: Die Quellen sind Stichpunkte, Kurztexte oder Fragmente. " "Daraus zusammenhängende Journalprosa bilden. Nur sprachlich nötige Verbindungen " "herstellen. Keine neuen Tatsachen, Ursachen oder Bewertungen ergänzen. " "Die Stichpunkte nicht inklusive ihrer Fehler hintereinanderkopieren." ), } EMPTY_STYLE_EXAMPLES = ( "Keine historischen Stilbeispiele. Es gilt nur WRITING_PROFILE " "(oder der neutrale Journalstil)." ) def choose_editorial_mode(user_bodies: list[str]) -> str: """MVP mode choice. No classifier model. A source block counts as already narrative when it contains `.`, `!` or `?`. Mixed default: `prose_edit` when at least half of the non-empty blocks are narrative; otherwise `notes_to_journal`. """ blocks = [(item or "").strip() for item in user_bodies if (item or "").strip()] if not blocks: return NOTES_TO_JOURNAL narrative = sum(1 for block in blocks if any(mark in block for mark in ".!?")) if narrative * 2 >= len(blocks): return PROSE_EDIT return NOTES_TO_JOURNAL def editorial_instructions(mode: str) -> str: return INSTRUCTIONS.get(mode) or INSTRUCTIONS[PROSE_EDIT] def lexical_similarity(left: str, right: str) -> float: """Diagnostic only. Must not reject a draft or trigger a retry.""" a = re.sub(r"\s+", " ", (left or "").strip().lower()) b = re.sub(r"\s+", " ", (right or "").strip().lower()) if not a or not b: return 0.0 return round(SequenceMatcher(None, a, b).ratio(), 3) def narration_sources_text(artifact: dict) -> str: """Present attested day facts to the model. Not a wording template, not JSON.""" parts: list[str] = [] for item in artifact.get("sources") or []: if not isinstance(item, dict): continue text = (item.get("text") or "").strip() if not text: continue source_id = (item.get("source_id") or "").strip() prefix = f"[{source_id}]\n" if source_id else "" parts.append(prefix + text) if parts: return "\n\n".join(parts) from journal_reconstruct import claim_texts fallback = [str(part).strip() for part in claim_texts(artifact) if str(part).strip()] return "\n\n".join(fallback) def format_style_examples(examples: list[dict]) -> str: if not examples: return EMPTY_STYLE_EXAMPLES lines = [ "Nur Ton, Rhythmus und sprachliche Entscheidungen. " "Ereignisse, Personen, Orte und Bewertungen aus diesen Beispielen " "sind keine Tatsachen des heutigen Eintrags und dürfen nicht übernommen werden." ] for index, item in enumerate(examples, start=1): kind = item.get("kind") or "style" when = (item.get("occurred_at") or "")[:10] header = f"Beispiel {index} ({kind}" + (f", {when}" if when else "") + "):" lines.append(header) lines.append((item.get("excerpt") or "").strip()) return "\n".join(part for part in lines if part).strip() def _content_digest(text: str) -> str: body = re.sub(r"\s+", " ", plain_text(text or "")).strip().lower() return hashlib.sha256(body.encode("utf-8")).hexdigest() def _eligible_excerpt(body: str, limit: int) -> str: text = plain_text(body or "").strip() if len(text) < MIN_EXAMPLE_CHARS or is_meta_style_text(text): return "" return clip_field(text, limit) def select_journal_style_examples( profile_id: str, *, exclude_dates: list[str] | None = None, exclude_entry_ids: list[str] | None = None, max_n: int = STYLE_EXAMPLE_MAX, max_chars: int = STYLE_EXAMPLE_CHARS, ) -> list[dict]: """Final accepted journal texts first, then imports, then trait excerpts. Current-day sources are excluded. Dialogue is never a style authority. """ excluded_dates = {(item or "")[:10] for item in (exclude_dates or []) if item} excluded_ids = {item for item in (exclude_entry_ids or []) if item} ranked = list_style_sources(profile_id) buckets = [ ("journal_entry", ranked.get("journal_entry") or []), ("imported_text", ranked.get("imported_text") or []), ] picked: list[dict] = [] seen: set[str] = set() def consider(kind: str, item: dict, excerpt: str) -> None: if len(picked) >= max_n or not excerpt: return digest = _content_digest(excerpt) if digest in seen: return seen.add(digest) picked.append( { "kind": kind, "excerpt": excerpt, "occurred_at": item.get("occurred_at") or item.get("created"), "entry_id": item.get("entry_id"), "weight": item.get("weight") or recency_weight(item.get("occurred_at")), } ) for kind, rows in buckets: ordered = sorted( rows, key=lambda row: ( -float(row.get("weight") or 0), -recency_weight(row.get("occurred_at") or row.get("created")), ), ) for item in ordered: if len(picked) >= max_n: return picked entry_id = (item.get("entry_id") or "").strip() if entry_id and entry_id in excluded_ids: continue when = (item.get("occurred_at") or "")[:10] if when and when in excluded_dates: continue consider(kind, item, _eligible_excerpt(item.get("body") or "", max_chars)) if picked: return picked profile = get_profile(profile_id) if not has_confirmed_profile(profile_id): return picked for trait in profile.get("traits") or []: if len(picked) >= max_n: break for ref in trait.get("exemplars") or []: excerpt = _eligible_excerpt(ref.get("excerpt") or "", max_chars) consider("trait_exemplar", ref, excerpt) if len(picked) >= max_n: break return picked