Kansho/backend/profile_analysis.py
2026-08-25 17:16:35 +02:00

233 lines
9.7 KiB
Python

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