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