"""Conservative prompt budget for personal journal generation. Token counts are estimates, not tokenizer-accurate. Kanshō overestimates input so a request is refused before it can overflow a context window. There is no silent middle-out deletion of the day. """ from __future__ import annotations import math import os from dataclasses import dataclass, field from typing import Any # Journal generation requires a wide window so a full day can be reconstructed # without dropping middle events. 32_768 is the product default, not a claim # that every model tokenizer uses this unit identically. JOURNAL_MIN_CONTEXT_TOKENS = 32_768 JOURNAL_TARGET_COMPLETION_TOKENS = 4_096 # Overestimate: German mixed with prompt English is typically closer to 3–4 # characters per token. Two characters per token is deliberately conservative. DEFAULT_CHARS_PER_TOKEN = 2.0 DEFAULT_SAFETY_MARGIN = 0.15 CHAT_FORMAT_OVERHEAD_TOKENS = 64 JOURNAL_PURPOSES = {"journal_generate", "journal_reconstruct"} ERROR_MODEL_CONTEXT_TOO_SMALL = "model_context_too_small" ERROR_PROMPT_BUDGET_EXCEEDED = "prompt_budget_exceeded" ERROR_MODEL_METADATA_UNKNOWN = "model_metadata_unknown" ERROR_RECONSTRUCTION_INVALID = "reconstruction_invalid" ERROR_NO_USER_SOURCES = "no_user_sources" ERROR_OUTPUT_LIMIT_UNSUPPORTED = "output_limit_unsupported" ERROR_PROVIDER_CONTEXT_LENGTH = "provider_context_length_rejected" USER_MESSAGES = { ERROR_MODEL_CONTEXT_TOO_SMALL: ( "Das gewählte Modell hat ein zu kleines Kontextfenster für die " "Journalgenerierung. Es sind mindestens 32.000 Tokens erforderlich. " "Es wurde kein anderes Modell gewählt." ), ERROR_PROMPT_BUDGET_EXCEEDED: ( "Der Tagesdialog ist für eine sichere Verarbeitung zu umfangreich. " "Kanshō hat nichts stillschweigend aus der Mitte entfernt. " "Bitte wähle weniger Gespräche oder ein Modell mit größerem Kontext." ), ERROR_MODEL_METADATA_UNKNOWN: ( "Die Kontextgrenzen des Modells konnten nicht sicher bestimmt werden. " "Der Aufruf wurde nicht gesendet." ), ERROR_RECONSTRUCTION_INVALID: ( "Die inhaltliche Rekonstruktion war unvollständig oder ungültig. " "Es wurde kein Journalentwurf erzeugt." ), ERROR_NO_USER_SOURCES: ( "In den ausgewählten Gesprächen gibt es noch keinen Nutzertext. " "Es wurde kein Journalentwurf erzeugt." ), ERROR_OUTPUT_LIMIT_UNSUPPORTED: ( "Das Modell unterstützt die für die Journalgenerierung reservierte " "Ausgabelänge nicht. Der Aufruf wurde nicht gesendet." ), ERROR_PROVIDER_CONTEXT_LENGTH: ( "Der Anbieter hat die Anfrage wegen der Kontextlänge abgelehnt. " "Kanshō hat den Tagesdialog nicht automatisch gekürzt." ), } class JournalBudgetError(Exception): def __init__( self, code: str, message: str | None = None, status_code: int = 422, diagnostics: dict[str, Any] | None = None, ): super().__init__(message or USER_MESSAGES.get(code) or code) self.code = code self.message = message or USER_MESSAGES.get(code) or code self.status_code = status_code self.diagnostics = diagnostics or {} @dataclass class ModelWindow: model: str context_length: int max_completion_tokens: int | None source: str provider: str | None = None cached: bool = False @dataclass class JournalBudget: model: str purpose: str effective_context_window: int reserved_output_tokens: int safety_margin: float estimated_input_tokens: int = 0 available_input_tokens: int = 0 chars_per_token: float = DEFAULT_CHARS_PER_TOKEN budget_ok: bool = True abort_reason: str | None = None context_compression: str = "disabled" extras: dict[str, Any] = field(default_factory=dict) def as_diagnostics(self) -> dict[str, Any]: payload = { "model": self.model, "purpose": self.purpose, "estimated_input_tokens": self.estimated_input_tokens, "effective_context_window": self.effective_context_window, "reserved_output_tokens": self.reserved_output_tokens, "safety_margin": self.safety_margin, "available_input_tokens": self.available_input_tokens, "chars_per_token": self.chars_per_token, "budget_ok": self.budget_ok, "context_compression": self.context_compression, "abort_reason": self.abort_reason, "estimation": "conservative_char_ratio", } payload.update(self.extras) return payload def _float_env(name: str, default: float) -> float: raw = (os.environ.get(name) or "").strip() if not raw: return default try: value = float(raw) except ValueError: return default return value if value > 0 else default def _int_env(name: str, default: int) -> int: raw = (os.environ.get(name) or "").strip() if not raw: return default try: value = int(raw) except ValueError: return default return value if value > 0 else default def chars_per_token() -> float: return _float_env("KANSHO_TOKEN_CHARS_PER_TOKEN", DEFAULT_CHARS_PER_TOKEN) def safety_margin() -> float: return min(0.4, max(0.05, _float_env("KANSHO_JOURNAL_SAFETY_MARGIN", DEFAULT_SAFETY_MARGIN))) def min_context_tokens() -> int: return _int_env("KANSHO_JOURNAL_MIN_CONTEXT_TOKENS", JOURNAL_MIN_CONTEXT_TOKENS) def target_completion_tokens() -> int: return _int_env("KANSHO_JOURNAL_MAX_COMPLETION_TOKENS", JOURNAL_TARGET_COMPLETION_TOKENS) def day_message_safety_cap() -> int: return _int_env("KANSHO_JOURNAL_DAY_MAX_MESSAGES", 500) def estimate_tokens(text: str, *, extra: int = 0) -> int: """Conservative character ratio. Not a model tokenizer.""" n = len(text or "") if n <= 0: return extra return max(1, math.ceil(n / chars_per_token())) + extra def usable_context_tokens(window: ModelWindow) -> int: margin = safety_margin() usable = math.floor(window.context_length * (1.0 - margin)) return max(0, usable) def reserved_output_tokens(window: ModelWindow) -> int: wanted = target_completion_tokens() supported = window.max_completion_tokens if supported is None or supported <= 0: raise JournalBudgetError( ERROR_OUTPUT_LIMIT_UNSUPPORTED, diagnostics={"model": window.model, "max_completion_tokens": supported}, ) reserved = min(wanted, int(supported)) if reserved < 256: raise JournalBudgetError( ERROR_OUTPUT_LIMIT_UNSUPPORTED, diagnostics={ "model": window.model, "max_completion_tokens": supported, "reserved_output_tokens": reserved, }, ) return reserved def plan_journal_budget(window: ModelWindow, *, purpose: str) -> JournalBudget: required = min_context_tokens() if window.context_length < required: raise JournalBudgetError( ERROR_MODEL_CONTEXT_TOO_SMALL, diagnostics={ "model": window.model, "effective_context_window": window.context_length, "required_context_window": required, "source": window.source, }, ) reserved = reserved_output_tokens(window) usable = usable_context_tokens(window) available = usable - reserved - CHAT_FORMAT_OVERHEAD_TOKENS if available < 512: raise JournalBudgetError( ERROR_PROMPT_BUDGET_EXCEEDED, diagnostics={ "model": window.model, "effective_context_window": window.context_length, "reserved_output_tokens": reserved, "available_input_tokens": available, }, ) compression = "disabled" if purpose in JOURNAL_PURPOSES else "provider_default" return JournalBudget( model=window.model, purpose=purpose, effective_context_window=window.context_length, reserved_output_tokens=reserved, safety_margin=safety_margin(), available_input_tokens=available, chars_per_token=chars_per_token(), context_compression=compression, extras={"metadata_source": window.source, "cached_metadata": window.cached}, ) def assert_input_fits(budget: JournalBudget, text: str) -> JournalBudget: estimated = estimate_tokens(text, extra=CHAT_FORMAT_OVERHEAD_TOKENS) budget.estimated_input_tokens = estimated total = estimated + budget.reserved_output_tokens ceiling = math.floor(budget.effective_context_window * (1.0 - budget.safety_margin)) if estimated > budget.available_input_tokens or total > ceiling: budget.budget_ok = False budget.abort_reason = ERROR_PROMPT_BUDGET_EXCEEDED raise JournalBudgetError( ERROR_PROMPT_BUDGET_EXCEEDED, diagnostics={ **budget.as_diagnostics(), "estimated_input_tokens": estimated, "required_tokens": total, "usable_tokens": ceiling, }, ) budget.budget_ok = True budget.abort_reason = None return budget def merge_usage(diagnostics: dict[str, Any], usage: dict[str, Any] | None, model: str | None = None) -> dict[str, Any]: payload = dict(diagnostics) data = usage or {} if model: payload["actual_model"] = model payload["prompt_tokens"] = data.get("prompt_tokens") payload["completion_tokens"] = data.get("completion_tokens") payload["total_tokens"] = data.get("total_tokens") cost = data.get("cost") if cost is None: cost = data.get("total_cost") payload["cost"] = cost return payload