"""Explicit journal draft generation. Never overwrites the current user entry.""" from __future__ import annotations from context_builder import assemble_text, build_internal_context from dialogue_store import StoreError, list_conversations_for_day, list_messages from engine import EngineError, execute_prompt, load_active_prompt from journal_policy import require_explicit_generate, source_conversation_ids from identity_store import list_mappings from journal_body import clean_title from journal_reconstruct import ( assign_source_ids, claim_texts, local_verified_artifact, reconstruction_from_model, reconstruction_text, ) from journal_shape import shape_journal from journal_store import current_draft, current_entries, get_day, insert_draft from model_catalog import resolve_generate_metadata from prompt_budget import ( JournalBudgetError, estimate_tokens, plan_journal_budget, ) from providers import generate_provider from writing_profile_store import compile_style_signals, remember_dialogue_style def _message_ids(profile_id: str, conversation_ids: list[str]) -> list[str]: ids: list[str] = [] for conversation_id in conversation_ids: for message in list_messages(profile_id, conversation_id): ids.append(message["id"]) return ids def _split_title(content: str) -> tuple[str, str]: text = (content or "").strip() if not text: return "", "" lines = text.splitlines() title = clean_title(lines[0]) body = "\n".join(lines[1:]).strip() if len(lines) > 1 else text if not title or len(title) > 80: return "", text return title, body or text def _local_narration(reconstruction: dict) -> tuple[str, str]: """Fail-closed draft from verified local sources. No leaking model text.""" parts = [str(part).strip() for part in claim_texts(reconstruction) if str(part).strip()] body = "\n\n".join(parts) return "Ein Tag", body def _identity_leak_result(purpose: str, exc: EngineError) -> dict: return { "content": "", "trace": { "purpose": purpose, "guard": "identity_leak_blocked", }, "diagnostics": getattr(exc, "diagnostics", None) or {}, } def _raise_budget(exc: JournalBudgetError) -> None: raise EngineError(exc.code, exc.message, exc.status_code, exc.diagnostics) from exc def _stage_trace(result: dict, fallback_purpose: str) -> dict: trace = dict(result.get("trace") or {}) if result.get("diagnostics"): trace["budget"] = result.get("diagnostics") elif not trace.get("budget"): trace["budget"] = None trace["purpose"] = trace.get("purpose") or fallback_purpose return trace def _day_messages_from_context(context: dict) -> list[dict]: for item in context.get("items") or []: if item.get("type") == "day_messages": return list(item.get("messages") or []) return [] def _existing_text(profile_id: str, journal_day_id: str) -> str: """Saved user entries are the Fassung. A leftover draft is only used if none exist.""" entries = current_entries(profile_id, journal_day_id) bodies = [(item.get("body") or "").strip() for item in entries if (item.get("body") or "").strip()] if bodies: return "\n\n".join(bodies) draft = current_draft(profile_id, journal_day_id) if draft: return (draft.get("body") or "").strip() return "" def generate_draft( profile_id: str, journal_day_id: str, conversation_ids: list[str] | None = None, include_existing: bool = False, explicit: bool = True, ) -> dict: require_explicit_generate(explicit) day = get_day(profile_id, journal_day_id) day_conversations = list_conversations_for_day(profile_id, journal_day_id) selected = source_conversation_ids( conversation_ids, [item["id"] for item in day_conversations], ) remember_dialogue_style(profile_id, exclude_conversation_ids=selected) existing_text = _existing_text(profile_id, journal_day_id) if include_existing else "" config = generate_provider() if not config: raise EngineError( "no_egress_provider_configured", "Persönlicher KI-Aufruf wurde vom Privacy Gateway blockiert. " "Es ist kein Egress-Provider konfiguriert.", 503, ) try: window = resolve_generate_metadata(config) reconstruct_budget = plan_journal_budget(window, purpose="journal_reconstruct") narrate_budget = plan_journal_budget(window, purpose="journal_generate") except JournalBudgetError as exc: _raise_budget(exc) reconstruct_prompt = load_active_prompt("mvp.journal_reconstruct") static_tokens = estimate_tokens(reconstruct_prompt.get("template") or "") available_for_day = reconstruct_budget.available_input_tokens - static_tokens if available_for_day < 256: raise EngineError( "prompt_budget_exceeded", "Der Tagesdialog ist für eine sichere Verarbeitung zu umfangreich. " "Kanshō hat nichts stillschweigend aus der Mitte entfernt.", 422, diagnostics=reconstruct_budget.as_diagnostics(), ) try: reconstruct_context = build_internal_context( profile_id, space_id=day["space_id"], journal_day_id=journal_day_id, purpose="journal_reconstruct", conversation_ids=selected, conversation_id=selected[0] if selected else None, day_spec={ "overflow": "abort", "max_estimated_tokens": available_for_day, }, ) except JournalBudgetError as exc: _raise_budget(exc) reconstruct_assembled = assemble_text(reconstruct_context) source_messages = assign_source_ids(_day_messages_from_context(reconstruct_context)) source_user = [ message.get("body") or "" for message in source_messages if message.get("role") == "user" ] reconstruct_result = {"trace": {"purpose": "journal_reconstruct"}, "content": "", "diagnostics": {}} try: reconstruct_result = execute_prompt( reconstruct_prompt, profile_id, purpose="journal_reconstruct", data_class="B", context=reconstruct_assembled, max_tokens=reconstruct_budget.reserved_output_tokens, disable_context_compression=True, budget=reconstruct_budget, ) reconstruction, stage1 = reconstruction_from_model( reconstruct_result.get("content") or "", source_messages, ) except EngineError as exc: if exc.code != "response_validation_failed": raise reconstruction = local_verified_artifact(source_messages) stage1 = { "stage1": "local_fallback", "reason": "identity_leak_blocked", "model_rejected": exc.code, } reconstruct_result = _identity_leak_result("journal_reconstruct", exc) except JournalBudgetError as exc: _raise_budget(exc) signals = compile_style_signals(source_user) narrate_context = build_internal_context( profile_id, space_id=day["space_id"], journal_day_id=journal_day_id, purpose="journal_generate", include_existing=include_existing, conversation_ids=selected, existing_text=existing_text, conversation_id=selected[0] if selected else None, reconstruction=reconstruction_text(reconstruction), ) assembled = assemble_text(narrate_context) profile = assembled.get("writing_profile") or "" if signals and signals not in profile and len(profile) < 3500: assembled["writing_profile"] = "\n\n".join(part for part in (profile, signals) if part) narrate_prompt = load_active_prompt("mvp.journal_generate") try: narrate_result = execute_prompt( narrate_prompt, profile_id, purpose="journal_generate", data_class="B", context=assembled, max_tokens=narrate_budget.reserved_output_tokens, disable_context_compression=True, budget=narrate_budget, ) title, body = _split_title(narrate_result.get("content") or "") except EngineError as exc: if exc.code != "response_validation_failed": raise title, body = _local_narration(reconstruction) narrate_result = _identity_leak_result("journal_generate", exc) narrate_result["content"] = f"{title}\n\n{body}".strip() person_labels = [ (item.get("local_label") or "").strip() for item in list_mappings(profile_id) if (item.get("local_label") or "").strip() and str(item.get("token") or "").upper().lstrip("[").startswith("PERSON:") ] title, body = shape_journal(title, body, source_user, person_labels=person_labels) before_entries = {item["id"]: item.get("current_version_id") for item in current_entries(profile_id, journal_day_id)} draft = insert_draft( profile_id, journal_day_id, title=title, body=body, source_conversation_ids=selected, source_message_ids=_message_ids(profile_id, selected), ) after_entries = current_entries(profile_id, journal_day_id) for item in after_entries: previous = before_entries.get(item["id"]) if previous is not None and previous != item.get("current_version_id"): raise StoreError("policy_violation", "Generate darf die Nutzerfassung nicht verändern", 500) reconstruct_trace = _stage_trace(reconstruct_result, "journal_reconstruct") reconstruct_trace.update(stage1) narrate_trace = _stage_trace(narrate_result, "journal_generate") draft["trace"] = { **narrate_trace, "stages": [reconstruct_trace, narrate_trace], } return draft