244 lines
8.2 KiB
Python
244 lines
8.2 KiB
Python
"""Journal-adapter editorial policy. Not a general provenance or privacy rule.
|
|
|
|
Fact fidelity is not wording fidelity. Mixed prose, notes and fragments in the
|
|
same day are the normal case and are handled in one generate 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,
|
|
)
|
|
|
|
STYLE_EXAMPLE_MAX = 2
|
|
STYLE_EXAMPLE_CHARS = 900
|
|
MIN_EXAMPLE_CHARS = 40
|
|
|
|
GENERATE_SEED_REVISION = "2026-08-27-journal-mixed-sources-v1"
|
|
|
|
EMPTY_STYLE_EXAMPLES = (
|
|
"Keine historischen Stilbeispiele. Es gilt nur WRITING_PROFILE "
|
|
"(oder der neutrale Journalstil)."
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
|
|
_DANGLING_DETERMINER = re.compile(
|
|
r"(?i)\b(?:der|die|das|des|dem|den|ein|eine|einem|einen|einer)\s*$"
|
|
)
|
|
_DETERMINER_BEFORE_FINITE = re.compile(
|
|
r"(?i)\b(?:des|dem|den|der|die|das|ein|eine|einem|einen|einer)\s+"
|
|
r"(?:kann|können|konnte|muss|müssen|will|wollen|soll|sollen|"
|
|
r"ist|sind|war|waren|wird|werden|hat|haben|hatte|"
|
|
r"geht|gehen|ging|kam|kommen|kommt|gelangen|gelangte)\b"
|
|
)
|
|
|
|
|
|
def incomplete_syntax_markers(text: str) -> int:
|
|
"""Diagnostic count of dangling determiners or unpunctuated long clauses.
|
|
|
|
Does not rewrite text and must not trigger a retry.
|
|
"""
|
|
body = (text or "").strip()
|
|
if not body:
|
|
return 0
|
|
count = 0
|
|
clauses = [part.strip() for part in re.split(r"(?<=[.!?])\s+|\n+", body) if part.strip()]
|
|
for clause in clauses:
|
|
bare = clause.rstrip(".!?…\"»'")
|
|
if _DANGLING_DETERMINER.search(bare):
|
|
count += 1
|
|
if _DETERMINER_BEFORE_FINITE.search(clause):
|
|
count += 1
|
|
for para in re.split(r"\n\s*\n", body):
|
|
chunk = para.strip()
|
|
if len(chunk.split()) >= 8 and not re.search(r"[.!?]", chunk):
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def style_example_diagnostics(examples: list[dict]) -> dict:
|
|
return {
|
|
"count": len(examples or []),
|
|
"kinds": [item.get("kind") or "style" for item in (examples or [])],
|
|
"chars": sum(len(item.get("excerpt") or "") for item in (examples or [])),
|
|
}
|
|
|
|
|
|
def writing_profile_trace(profile_id: str) -> dict:
|
|
"""Presence metadata only. No profile text, no labels."""
|
|
from writing_profile_store import (
|
|
NEUTRAL_JOURNAL_STYLE,
|
|
compile_task_brief,
|
|
get_profile,
|
|
has_confirmed_profile,
|
|
)
|
|
|
|
confirmed = has_confirmed_profile(profile_id)
|
|
profile = get_profile(profile_id)
|
|
core = ((profile.get("core") or {}).get("value") or "").strip()
|
|
facet = next(
|
|
(
|
|
item
|
|
for item in profile.get("facets") or []
|
|
if item.get("facet_key") == "autobiographical_journal" and (item.get("value") or "").strip()
|
|
),
|
|
None,
|
|
)
|
|
traits = [
|
|
item
|
|
for item in profile.get("traits") or []
|
|
if item.get("status") == "active" and (item.get("statement") or "").strip()
|
|
]
|
|
brief = compile_task_brief(profile_id, "journal_generate")
|
|
return {
|
|
"confirmed": confirmed,
|
|
"present": bool(confirmed and brief and brief != NEUTRAL_JOURNAL_STYLE),
|
|
"neutral_fallback": brief == NEUTRAL_JOURNAL_STYLE,
|
|
"has_core": bool(confirmed and core),
|
|
"has_facet": bool(confirmed and facet),
|
|
"trait_count": len(traits) if confirmed else 0,
|
|
"brief_chars": len(brief or ""),
|
|
}
|
|
|
|
|
|
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
|