"""Shared Writing-Profile analysis contract for API and Copy/Paste. Not a second profile model. Builds a bounded ProfileAnalysisPackage and a human-readable paste prompt. External chat history is evidence provenance, not a Kanshō source. """ from __future__ import annotations import json import os from writing_profile_schema import ( EVIDENCE_BASIS, EXISTING_BEFORE_NEW, is_meta_style_text, recency_role, ) KIND_PACKAGE = "kansho.profile_analysis_package" KIND_RESULT = "kansho.profile_analysis_result" KIND_PACKAGE_LEGACY = "kansho.profile_review_package" KIND_RESULT_LEGACY = "kansho.profile_review_result" KIND_PACKAGES = {KIND_PACKAGE, KIND_PACKAGE_LEGACY} KIND_RESULTS = {KIND_RESULT, KIND_RESULT_LEGACY} FORMAT_VERSION = 1 PACKAGE_CHAR_BUDGET = int(os.environ.get("KANSHO_PROFILE_PACKAGE_CHARS") or "12000") CORPUS_EXCERPT = int(os.environ.get("KANSHO_PROFILE_EXCERPT_CHARS") or "360") MAX_CURRENT = 5 MAX_LONG_TERM = 3 MAX_PENDING = 6 MAX_INITIAL = 14 def package_budget() -> int: return max(4000, PACKAGE_CHAR_BUDGET) def expected_result_schema(mode: str) -> dict: change = { "layer": "core|facet|trait", "key": "core|autobiographical_journal|datengetriebener-slug", "slug": "datengetriebener Trait-Slug", "facet_key": "core|autobiographical_journal|…", "action": "keep|update|add|remove|reclassify|merge|split", "proposed_value": "Aussage", "label": "optional", "rationale": "kurz, semantisch", "evidence_basis": list(EVIDENCE_BASIS), "evidence_ids": ["nur echte Kanshō-IDs aus diesem Paket, sonst weglassen"], "exemplars": [ { "excerpt": "echter Originalausschnitt", "occurred_at": "YYYY-MM-DD", "source_id": "nur bei kansho_sources", "evidence_basis": "kansho_sources|external_chat_history", } ], "merge_slugs": ["optional"], "split_into": [{"slug": "neu", "statement": "…", "facet_key": "…"}], } result = { "kind": KIND_RESULT, "format_version": FORMAT_VERSION, "target": "writing", "mode": mode, "evidence_basis": list(EVIDENCE_BASIS), "uncertainties": ["was die Evidence nicht trägt"], "changes": [change], } if mode == "initial_build": result["profile"] = { "core": {"value": "nur soweit unterschiedliche Quellenarten das tragen", "traits": []}, "facets": [ { "key": "autobiographical_journal", "value": "Facet-Delta gegenüber dem Core", "traits": [], } ], "traits": [ { "slug": "datengetrieben", "facet_key": "autobiographical_journal", "statement": "semantisches Merkmal", "exemplars": [{"excerpt": "Originalzitat", "evidence_basis": "kansho_sources"}], } ], } return result def select_corpus_items(items: list[dict], *, mode: str) -> list[dict]: real = [item for item in items if not is_meta_style_text(item.get("body") or item.get("excerpt"))] current = [ item for item in real if (item.get("recency_role") or recency_role(item.get("occurred_at"))) == "current_expression" ] long_term = [ item for item in real if (item.get("recency_role") or recency_role(item.get("occurred_at"))) == "long_term" ] picked: list[dict] = [] seen: set[str] = set() def add(item: dict) -> None: item_id = item.get("id") or "" if item_id and item_id in seen: return if item_id: seen.add(item_id) picked.append(item) for item in current[:MAX_CURRENT]: add(item) for item in list(reversed(long_term))[:MAX_LONG_TERM]: add(item) by_hint: dict[str, list[dict]] = {} for item in real: by_hint.setdefault(item.get("facet_hint") or item.get("context_hint") or "unspecified", []).append(item) for group in by_hint.values(): add(group[0]) limit = MAX_INITIAL if mode == "initial_build" else MAX_CURRENT + MAX_LONG_TERM + 4 if mode == "initial_build": for item in real: if len(picked) >= limit: break add(item) return _trim_budget(picked) def _trim_budget(items: list[dict]) -> list[dict]: budget = package_budget() kept: list[dict] = [] used = 0 for item in items: excerpt = (item.get("excerpt") or item.get("body") or "")[:CORPUS_EXCERPT] cost = len(excerpt) if kept and used + cost > budget: break kept.append(item) used += cost return kept or items[:2] def filter_style_evidence(items: list[dict]) -> list[dict]: cleaned = [] for item in items: excerpt = item.get("excerpt") or item.get("body") or "" if is_meta_style_text(excerpt): continue cleaned.append(item) return cleaned[:MAX_PENDING] def semantic_task(mode: str) -> str: shared = ( "Lokale Heuristiken (Wortzählungen, Signalwörter, Uhrzeiten) sind Messhilfe, keine fertigen Stilurteile. " "Leite keine Traits allein aus Zählungen ab. Keine Persönlichkeitsdiagnose, keine psychologischen Zuschreibungen, " "keine erfundenen Stilmerkmale, keine erfundenen Quellen, keine erfundenen Kanshō-Source-IDs, keine erfundenen Beispiele.\n\n" "Analysiere semantisch:\n" "- Was macht den persönlichen Schreibstil tatsächlich charakteristisch?\n" "- Welche Merkmale sind stabilerer Core?\n" "- Welche Merkmale sind kontextspezifische Facet-Deltas (z. B. autobiographical_journal)?\n" "- Welche bestehenden Traits werden bestätigt, sind zu grob, redundant oder falsch gescoped?\n" "- Welche fehlenden Merkmale sind über mehrere Texte hinweg belastbar?\n" "- Welche Merkmale haben sich über die Zeit verändert?\n" "- Welche Aussagen sind wegen zu wenig Evidence noch unsicher?\n\n" "Zeit: Ältere Texte können Langzeitmerkmale belegen. Neuere Texte wiegen stärker für die aktuelle Ausprägung. " "Nicht alle Jahre gleich gewichten.\n" "Besteht das Korpus vor allem aus Urlaubstagebüchern, sind starke Aussagen zur Facet autobiographical_journal zulässig. " "Einen globalen Core nur so weit, wie unterschiedliche Quellenarten das tragen; Generalisierbarkeit sonst als unsicher markieren.\n\n" "Existing-before-New:\n- " + "\n- ".join(EXISTING_BEFORE_NEW) + "\n" "Keine komplett neue Taxonomie nur weil ein anderes Raster eleganter wirkt.\n\n" "Representative Examples müssen echte Ausschnitte aus authentischen Nutzertexten sein. " "Unzulässig als Stilbeispiel: technische Triggertexte, UI-Texte, Meta-Beschreibungen einer Review, Statistiktexte, KI-Drafts.\n\n" "Wenn du aus dem bisherigen Verlauf dieses Chats bereits weitere authentische Texte des Nutzers kennst, " "darfst du dieses Wissen ergänzend für die semantische Analyse des Schreibstils verwenden. " "Das ist externe Chat-Historie, kein Kanshō-Source-Layer. Solche Evidenz als evidence_basis: external_chat_history kennzeichnen. " "Keine internen Source-IDs dafür erfinden. Später kann der Nutzer echte Texte nach Kanshō importieren, wenn interne Provenance gewünscht ist." ) if mode == "initial_build": return ( "Modus initial_build: Erzeuge ein vollständiges Profile Proposal (Core, optionale Facets, dynamische Traits, " "Facet-Deltas gegenüber dem Core, Representative Exemplars, Evidence-Basis, Unsicherheiten, Strukturänderungen). " "Bestehende Struktur zuerst berücksichtigen. Du darfst bei guter Begründung Traits, Core/Facet-Zuordnung und Facets ändern.\n\n" + shared ) return ( "Modus review: Vergleiche neue Evidence mit dem bestehenden Profil. " "Schlage primär inkrementelle Änderungen vor (keep, update, add, remove, reclassify, merge, split). " "Erzeuge nicht bei jeder Review ein komplett neues Profil.\n\n" + shared ) def render_paste_prompt(package: dict) -> str: mode = package.get("mode") or "review" task = semantic_task(mode) empty = package.get("kansho_evidence_empty") empty_note = ( "\nIn diesem Paket sind keine oder nur wenige Kanshō-Quellen. " "Du darfst, wenn der Chat authentische Nutzertexte kennt, external_chat_history nutzen und das kennzeichnen.\n" if empty else "\n" ) body = json.dumps(package, ensure_ascii=False, indent=2) return ( "Kanshō Writing-Profile-Analyse\n\n" "Du hilfst, den persönlichen Schreibstil einer Person semantisch zu beschreiben. " "Kanshō ist ein Reflexionsbegleiter; du brauchst kein internes Datenmodell zu kennen. " "Das JSON unten ist das ProfileAnalysisPackage (gemeinsamer Vertrag für Copy/Paste und API).\n\n" f"{task}{empty_note}\n" "Antworte ausschließlich mit einem JSON-Objekt gemäß expected_result. " "Kein Fließtext davor oder danach. kind muss kansho.profile_analysis_result sein.\n\n" "ProfileAnalysisPackage:\n" f"{body}\n" ) def package_note(mode: str) -> str: if mode == "initial_build": return ( "Copy/Paste ist in der Testphase ein vollwertiger Ausführungsweg. " "Das Paket ist eine repräsentative Auswahl, keine unlimitierte Volltextsammlung. " "Der aktuelle Brief ist kein Exportformat." ) return ( "Inkrementelle Review gegen das bestehende Profil. " "Copy/Paste und API nutzen dasselbe Package. Der aktuelle Brief ist kein Importformat." )