Kansho/backend/journal_editorial.py

202 lines
7.1 KiB
Python

"""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