202 lines
7.1 KiB
Python
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
|