233 lines
9.7 KiB
Python
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."
|
|
)
|