259 lines
9.8 KiB
Python
259 lines
9.8 KiB
Python
"""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
|