MVP 0,91
This commit is contained in:
parent
fd7102e3b3
commit
60f0d2830d
|
|
@ -37,6 +37,6 @@
|
|||
"category": "mvp",
|
||||
"prompt_type": "base",
|
||||
"required_feature": "ai_calls",
|
||||
"template": "Du prüfst das Writing Profile von [[SELF]] gegen Evidenz.\n\nKeine Diagnose, kein Persönlichkeitsmodell, keine erfundenen Stilmerkmale. Lokale Heuristiken sind Messhilfe, keine Traits. Keys wie chronology, humor, detail, rhythm sind nur Ordnungshilfe, kein geschlossenes Raster.\n\nHülle: Core, optionale context/output-Facets, darin dynamische semantische Traits, Evidence References, Representative Exemplars.\nExisting-before-New: 1) bestehenden Trait bestätigen, 2) präzisieren, 3) Scope ändern, 4) zusammenführen oder aufteilen, 5) erst dann einen neuen Trait anlegen.\nUrlaubstagebücher und autobiografische Journale belegen primär autobiographical_journal. Einen globalen Core nur so weit verallgemeinern, wie unterschiedliche Quellenarten das tragen. Neuere Texte wiegen stärker für die aktuelle Ausprägung; ältere Texte belegen Langzeitmerkmale.\nJeder Trait nennt evidence_ids und darf Originalzitate als Exemplare führen. Locked-Traits nicht ändern.\n\nAntworte ausschließlich mit einem JSON-Objekt gemäß expected_result im Paket. Actions: confirm, precisify, rescope, merge, split, create. Kein Fließtext davor oder danach.\n\nPaket:\n{{review_package}}\n"
|
||||
"template": "Du prüfst das Writing Profile von [[SELF]] anhand des ProfileAnalysisPackage.\n\nLokale Heuristiken sind Messhilfe, keine Traits. Existing-before-New: bestätigen, präzisieren, Scope ändern, zusammenführen oder aufteilen, erst dann neu anlegen. Neue Facet nur bei semantischer Notwendigkeit.\nUrlaubstagebücher belegen primär autobiographical_journal. Core nur soweit unterschiedliche Quellenarten das tragen.\nWenn du aus diesem Chat weitere authentische Nutzertexte kennst, darfst du sie als evidence_basis external_chat_history nutzen. Keine erfundenen Kanshō-Source-IDs, keine erfundenen Beispiele.\nRepresentative Examples nur aus echten Nutzertexten, nie aus Trigger- oder UI-Texten.\n\nAntworte ausschließlich mit JSON gemäß expected_result. kind: kansho.profile_analysis_result. Actions: keep, update, add, remove, reclassify, merge, split.\n\nPaket:\n{{review_package}}\n"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -144,16 +144,10 @@ def build_internal_context(
|
|||
items.append({"type": "space_recent_sources", "sources": recent_sources})
|
||||
|
||||
if purpose == "journal_generate":
|
||||
profile_rows = retrieve(profile_id, {"kind": "writing_profile"})
|
||||
brief = (profile_rows[0].get("compiled_brief") if profile_rows else "") or ""
|
||||
from writing_profile_store import compile_task_brief
|
||||
|
||||
brief = compile_task_brief(profile_id, "journal_generate")
|
||||
items.append({"type": "writing_profile", "compiled_brief": brief})
|
||||
sources = (profile_rows[0].get("sources") if profile_rows else []) or []
|
||||
style_sources = [
|
||||
item
|
||||
for item in sources
|
||||
if item.get("kind") in {"journal_entry", "imported_text", "dialogue_style"}
|
||||
]
|
||||
items.append({"type": "style_sources", "sources": style_sources})
|
||||
if include_existing and existing_text:
|
||||
items.append({"type": "existing_text", "body": plain_text(existing_text)})
|
||||
elif purpose == "dialogue_turn":
|
||||
|
|
|
|||
|
|
@ -195,12 +195,14 @@ def _validate_response(content: str, mappings: list[dict] | None = None) -> str:
|
|||
def _fake_complete(purpose: str, rendered: str) -> str:
|
||||
if purpose == "profile_review":
|
||||
return (
|
||||
'{"kind":"kansho.profile_review_result","format_version":1,"target":"writing",'
|
||||
'{"kind":"kansho.profile_analysis_result","format_version":1,"target":"writing",'
|
||||
'"evidence_basis":["kansho_sources"],'
|
||||
'"changes":[{"layer":"trait","key":"dry_humor","slug":"dry_humor","facet_key":"autobiographical_journal",'
|
||||
'"action":"create","label":"Trockener Humor",'
|
||||
'"action":"add","label":"Trockener Humor",'
|
||||
'"proposed_value":"gelegentlich trocken, nie aufgesetzt",'
|
||||
'"evidence_basis":["kansho_sources"],'
|
||||
'"rationale":"Fake-Review: Journal-Evidenz trägt Humor in autobiographical_journal, nicht als globalen Core.",'
|
||||
'"evidence_ids":[],"exemplars":[{"excerpt":"haha das war irgendwie lustig","role":"exemplar"}]}]}'
|
||||
'"evidence_ids":[],"exemplars":[{"excerpt":"haha das war irgendwie lustig","role":"exemplar","evidence_basis":["kansho_sources"]}]}]}'
|
||||
)
|
||||
if purpose == "journal_generate":
|
||||
lines = []
|
||||
|
|
|
|||
232
backend/profile_analysis.py
Normal file
232
backend/profile_analysis.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"""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."
|
||||
)
|
||||
|
|
@ -19,6 +19,7 @@ from journal_store import list_versions
|
|||
from writing_profile_infer import SIGNAL_KEYS, infer_features
|
||||
from writing_profile_schema import (
|
||||
ACTION_ALIASES,
|
||||
APPLY_ACTIONS,
|
||||
EXISTING_BEFORE_NEW,
|
||||
LEGACY_STYLE_KEYS,
|
||||
SEED_FACETS,
|
||||
|
|
@ -26,7 +27,11 @@ from writing_profile_schema import (
|
|||
coerce_slug,
|
||||
facet_label,
|
||||
hint_for_context,
|
||||
is_external_only,
|
||||
is_meta_style_text,
|
||||
normalize_evidence_basis,
|
||||
normalize_facet_key,
|
||||
normalize_mode,
|
||||
seed_catalog,
|
||||
valid_slug,
|
||||
)
|
||||
|
|
@ -47,9 +52,22 @@ from writing_profile_store import (
|
|||
snapshot_version,
|
||||
upsert_trait,
|
||||
)
|
||||
from profile_analysis import (
|
||||
KIND_PACKAGE,
|
||||
KIND_PACKAGES,
|
||||
KIND_RESULT,
|
||||
KIND_RESULT_LEGACY,
|
||||
KIND_RESULTS,
|
||||
FORMAT_VERSION as ANALYSIS_FORMAT,
|
||||
expected_result_schema,
|
||||
filter_style_evidence,
|
||||
package_note,
|
||||
render_paste_prompt as render_analysis_prompt,
|
||||
select_corpus_items,
|
||||
semantic_task,
|
||||
)
|
||||
|
||||
KIND_PACKAGE = "kansho.profile_review_package"
|
||||
KIND_RESULT = "kansho.profile_review_result"
|
||||
KIND_PACKAGE_LEGACY = "kansho.profile_review_package"
|
||||
FORMAT_VERSION = 1
|
||||
JSON_BLOCK = re.compile(r"\{.*\}", re.DOTALL)
|
||||
TRIGGERS = {
|
||||
|
|
@ -438,9 +456,10 @@ def review_status(profile_id: str) -> dict:
|
|||
|
||||
|
||||
def _review_mode(profile_id: str, requested: str | None = None) -> str:
|
||||
if requested in {"initial_build", "incremental"}:
|
||||
requested = normalize_mode(requested) or requested
|
||||
if requested in {"initial_build", "review"}:
|
||||
return requested
|
||||
return "incremental" if has_confirmed_profile(profile_id) else "initial_build"
|
||||
return "review" if has_confirmed_profile(profile_id) else "initial_build"
|
||||
|
||||
|
||||
def _corpus_supports_core(corpus: list[dict]) -> bool:
|
||||
|
|
@ -539,39 +558,34 @@ def build_package(
|
|||
raise StoreError("unsupported_target", "Nur Writing-Profile-Review ist gebündelt; Interaction bleibt explizit.")
|
||||
ensure_profile(profile_id)
|
||||
mode = _review_mode(profile_id, mode)
|
||||
if trigger == "explicit_review":
|
||||
enqueue_evidence(
|
||||
profile_id,
|
||||
trigger="explicit_review",
|
||||
excerpt=(
|
||||
"Expliziter Initial Profile Build aus dem historischen Korpus."
|
||||
if mode == "initial_build"
|
||||
else "Explizite Nutzer-Review des Writing Profile."
|
||||
),
|
||||
source_kind="initial_build" if mode == "initial_build" else "explicit",
|
||||
)
|
||||
if mode == "initial_build":
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE writing_profiles SET lifecycle = 'initial_pending', updated = datetime('now') WHERE profile_id = ?",
|
||||
(profile_id,),
|
||||
)
|
||||
evidences = _pending_evidence(profile_id)
|
||||
corpus = list_corpus(profile_id, limit=INITIAL_BUILD_SOURCES if mode == "initial_build" else 12)
|
||||
if not evidences and not corpus:
|
||||
raise StoreError("no_evidence", "Keine Review-Evidenz und kein Korpus vorhanden.")
|
||||
evidences = filter_style_evidence(_pending_evidence(profile_id))
|
||||
raw_corpus = list_corpus(profile_id, limit=INITIAL_BUILD_SOURCES)
|
||||
corpus = select_corpus_items(raw_corpus, mode=mode)
|
||||
profile = get_profile(profile_id)
|
||||
facets = profile.get("facets") or []
|
||||
core = next((item for item in facets if item.get("facet_key") == CORE_KEY or item.get("layer") == "core"), None)
|
||||
others = [item for item in facets if item is not core]
|
||||
empty = not corpus and not evidences
|
||||
package = {
|
||||
"kind": KIND_PACKAGE,
|
||||
"format_version": FORMAT_VERSION,
|
||||
"target": "writing",
|
||||
"mode": mode,
|
||||
"instruction": INSTRUCTION_INITIAL if mode == "initial_build" else INSTRUCTION_INCREMENTAL,
|
||||
"instruction": semantic_task(mode),
|
||||
"existing_before_new": list(EXISTING_BEFORE_NEW),
|
||||
"seed_catalog": seed_catalog(),
|
||||
"note": package_note(mode),
|
||||
"kansho_evidence_empty": empty,
|
||||
"selection": {
|
||||
"budget_chars": int(__import__("os").environ.get("KANSHO_PROFILE_PACKAGE_CHARS") or "12000"),
|
||||
"role": "representative current, older baseline, distinct contexts — not a full dump",
|
||||
},
|
||||
"profile": {
|
||||
"version": profile.get("version") or 0,
|
||||
"governance": profile.get("governance") or "learning",
|
||||
|
|
@ -582,40 +596,14 @@ def build_package(
|
|||
},
|
||||
"corpus": [_public_corpus(item) for item in corpus],
|
||||
"evidences": [_public_evidence(item) for item in evidences],
|
||||
"expected_result": {
|
||||
"kind": KIND_RESULT,
|
||||
"format_version": FORMAT_VERSION,
|
||||
"target": "writing",
|
||||
"mode": mode,
|
||||
"changes": [
|
||||
{
|
||||
"layer": "core|facet|trait",
|
||||
"key": "core|autobiographical_journal|dynamischer-slug",
|
||||
"slug": "datengetriebener Trait-Slug",
|
||||
"facet_key": "core|autobiographical_journal|…",
|
||||
"action": "confirm|precisify|rescope|merge|split|create",
|
||||
"proposed_value": "Aussage des Traits oder der Facet",
|
||||
"label": "optional",
|
||||
"rationale": "kurz",
|
||||
"evidence_ids": ["id"],
|
||||
"exemplars": [{"excerpt": "Originalzitat", "occurred_at": "YYYY-MM-DD"}],
|
||||
"merge_slugs": ["optional"],
|
||||
"split_into": [{"slug": "neu", "statement": "…", "facet_key": "…"}],
|
||||
}
|
||||
],
|
||||
},
|
||||
"expected_result": expected_result_schema(mode),
|
||||
"compiled_brief_excluded": True,
|
||||
}
|
||||
return package
|
||||
|
||||
|
||||
def render_paste_prompt(package: dict) -> str:
|
||||
body = json.dumps(package, ensure_ascii=False, indent=2)
|
||||
return (
|
||||
"Kanshō Writing Profile Review. "
|
||||
"Antworte ausschließlich mit einem JSON-Objekt gemäß expected_result. "
|
||||
"Kein Fließtext davor oder danach.\n\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
return render_analysis_prompt(package)
|
||||
|
||||
|
||||
def parse_result(raw) -> dict:
|
||||
|
|
@ -634,8 +622,11 @@ def parse_result(raw) -> dict:
|
|||
raise StoreError("invalid_review_result", "Review-Ergebnis ist kein gültiges JSON.") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise StoreError("invalid_review_result", "Review-Ergebnis muss ein Objekt sein.")
|
||||
if (data.get("kind") or "") != KIND_RESULT:
|
||||
raise StoreError("invalid_review_result", "kind muss kansho.profile_review_result sein.")
|
||||
if data.get("compiled_brief") and not data.get("changes") and not data.get("profile"):
|
||||
raise StoreError("invalid_review_result", "Der aktuelle Brief ist kein Profile-Importformat.")
|
||||
kind = (data.get("kind") or "").strip()
|
||||
if kind not in KIND_RESULTS:
|
||||
raise StoreError("invalid_review_result", "kind muss kansho.profile_analysis_result sein.")
|
||||
version = data.get("format_version", FORMAT_VERSION)
|
||||
try:
|
||||
version = int(version)
|
||||
|
|
@ -643,98 +634,205 @@ def parse_result(raw) -> dict:
|
|||
raise StoreError("unsupported_format", "format_version ungültig.") from exc
|
||||
if version != FORMAT_VERSION:
|
||||
raise StoreError("unsupported_format", f"format_version {version} wird nicht unterstützt.")
|
||||
changes = data.get("changes")
|
||||
if not isinstance(changes, list):
|
||||
mode = normalize_mode(data.get("mode")) or (data.get("mode") or "")
|
||||
raw_changes = data.get("changes")
|
||||
if raw_changes is None:
|
||||
raw_changes = []
|
||||
if not isinstance(raw_changes, list):
|
||||
raise StoreError("invalid_review_result", "changes muss eine Liste sein.")
|
||||
raw_changes = list(raw_changes) + _changes_from_proposal(_proposal_from_result(data))
|
||||
cleaned = []
|
||||
for item in changes:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
layer = (item.get("layer") or "trait").strip()
|
||||
if layer not in LAYERS:
|
||||
continue
|
||||
action = ACTION_ALIASES.get((item.get("action") or "confirm").strip(), (item.get("action") or "confirm").strip())
|
||||
if action not in TRAIT_ACTIONS:
|
||||
continue
|
||||
key = (item.get("key") or item.get("slug") or "").strip()
|
||||
slug = coerce_slug(item.get("slug") or key or item.get("label") or "trait")
|
||||
if layer == "core":
|
||||
key = CORE_KEY
|
||||
slug = CORE_KEY
|
||||
elif layer == "facet":
|
||||
key = normalize_facet_key(key) or JOURNAL_FACET
|
||||
slug_hint = item.get("slug") or ""
|
||||
as_trait = bool(slug_hint) or action == "create" or key in LEGACY_STYLE_KEYS or (
|
||||
valid_slug(key) and key not in SEED_FACETS and key != CORE_KEY
|
||||
)
|
||||
if as_trait:
|
||||
layer = "trait"
|
||||
slug = coerce_slug(slug_hint or key)
|
||||
else:
|
||||
slug = coerce_slug(key)
|
||||
elif not valid_slug(slug):
|
||||
continue
|
||||
facet_key = normalize_facet_key(item.get("facet_key") or (key if layer == "facet" else "") or CORE_KEY) or CORE_KEY
|
||||
if layer == "core":
|
||||
facet_key = CORE_KEY
|
||||
exemplars = []
|
||||
for ex in item.get("exemplars") or []:
|
||||
if isinstance(ex, str) and ex.strip():
|
||||
exemplars.append({"excerpt": ex.strip(), "role": "exemplar"})
|
||||
elif isinstance(ex, dict) and (ex.get("excerpt") or "").strip():
|
||||
exemplars.append(
|
||||
{
|
||||
"excerpt": (ex.get("excerpt") or "").strip(),
|
||||
"role": ex.get("role") or "exemplar",
|
||||
"source_id": ex.get("source_id"),
|
||||
"occurred_at": ex.get("occurred_at"),
|
||||
}
|
||||
)
|
||||
split_into = []
|
||||
for part in item.get("split_into") or []:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
part_slug = coerce_slug(part.get("slug") or part.get("label") or "")
|
||||
if not part_slug:
|
||||
continue
|
||||
split_into.append(
|
||||
{
|
||||
"slug": part_slug,
|
||||
"label": part.get("label") or part_slug,
|
||||
"statement": part.get("statement") or part.get("proposed_value") or "",
|
||||
"facet_key": normalize_facet_key(part.get("facet_key") or facet_key) or facet_key,
|
||||
}
|
||||
)
|
||||
cleaned.append(
|
||||
{
|
||||
"layer": layer,
|
||||
"key": key,
|
||||
"slug": slug,
|
||||
"facet_key": facet_key,
|
||||
"action": action,
|
||||
"proposed_value": item.get("proposed_value") or item.get("statement") or "",
|
||||
"label": item.get("label") or "",
|
||||
"rationale": item.get("rationale") or "",
|
||||
"evidence_ids": [str(eid) for eid in (item.get("evidence_ids") or []) if eid],
|
||||
"exemplars": exemplars,
|
||||
"merge_slugs": [
|
||||
coerce_slug(value)
|
||||
for value in (item.get("merge_slugs") or item.get("merge_ids") or [])
|
||||
if value
|
||||
],
|
||||
"split_into": split_into,
|
||||
"trait_id": item.get("trait_id") or "",
|
||||
}
|
||||
)
|
||||
for item in raw_changes:
|
||||
parsed_change = _parse_change(item)
|
||||
if parsed_change:
|
||||
cleaned.append(parsed_change)
|
||||
basis = normalize_evidence_basis(data.get("evidence_basis"))
|
||||
if not basis:
|
||||
for change in cleaned:
|
||||
basis.extend(item for item in change.get("evidence_basis") or [] if item not in basis)
|
||||
return {
|
||||
"kind": KIND_RESULT,
|
||||
"format_version": FORMAT_VERSION,
|
||||
"target": "writing",
|
||||
"mode": data.get("mode") or "",
|
||||
"mode": mode,
|
||||
"evidence_basis": basis,
|
||||
"uncertainties": [
|
||||
str(item) for item in (data.get("uncertainties") or []) if item
|
||||
],
|
||||
"changes": cleaned,
|
||||
}
|
||||
|
||||
|
||||
def _proposal_from_result(data: dict) -> dict:
|
||||
nested = data.get("profile") if isinstance(data.get("profile"), dict) else {}
|
||||
profile = dict(nested)
|
||||
for key in ("core", "facets", "traits"):
|
||||
if not profile.get(key) and data.get(key):
|
||||
profile[key] = data[key]
|
||||
return profile
|
||||
|
||||
|
||||
def _parse_basis(raw) -> list[str]:
|
||||
return normalize_evidence_basis(raw)
|
||||
|
||||
|
||||
def _changes_from_proposal(profile: dict) -> list[dict]:
|
||||
if not isinstance(profile, dict) or not profile:
|
||||
return []
|
||||
changes = []
|
||||
core = profile.get("core")
|
||||
if isinstance(core, dict):
|
||||
value = core.get("value") or core.get("statement") or core.get("summary") or ""
|
||||
if value:
|
||||
changes.append(
|
||||
{
|
||||
"layer": "core",
|
||||
"key": CORE_KEY,
|
||||
"action": "update",
|
||||
"proposed_value": value,
|
||||
"rationale": core.get("rationale") or core.get("generalizability") or "Profile Proposal Core",
|
||||
"evidence_basis": core.get("evidence_basis") or [],
|
||||
"exemplars": core.get("exemplars") or [],
|
||||
}
|
||||
)
|
||||
for item in profile.get("facets") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
value = item.get("value") or item.get("statement") or item.get("summary") or ""
|
||||
if not value:
|
||||
continue
|
||||
changes.append(
|
||||
{
|
||||
"layer": "facet",
|
||||
"key": item.get("key") or item.get("facet_key") or JOURNAL_FACET,
|
||||
"action": "update",
|
||||
"proposed_value": value,
|
||||
"rationale": item.get("rationale") or "Profile Proposal Facet",
|
||||
"evidence_basis": item.get("evidence_basis") or [],
|
||||
}
|
||||
)
|
||||
for item in profile.get("traits") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
scope = (item.get("scope") or "").strip()
|
||||
facet_key = item.get("facet_key") or (CORE_KEY if scope == "core" else "") or JOURNAL_FACET
|
||||
changes.append(
|
||||
{
|
||||
"layer": "trait",
|
||||
"slug": item.get("slug") or item.get("key"),
|
||||
"facet_key": facet_key,
|
||||
"action": item.get("action") or "add",
|
||||
"label": item.get("label") or "",
|
||||
"proposed_value": item.get("statement") or item.get("proposed_value") or item.get("summary") or "",
|
||||
"rationale": item.get("rationale") or "Profile Proposal Trait",
|
||||
"evidence_basis": item.get("evidence_basis") or [],
|
||||
"exemplars": item.get("exemplars") or [],
|
||||
"evidence_ids": item.get("evidence_ids") or [],
|
||||
}
|
||||
)
|
||||
return changes
|
||||
|
||||
|
||||
def _parse_change(item: dict | None) -> dict | None:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
layer = (item.get("layer") or "trait").strip()
|
||||
if layer not in LAYERS:
|
||||
return None
|
||||
action = ACTION_ALIASES.get((item.get("action") or "keep").strip(), (item.get("action") or "keep").strip())
|
||||
if action not in TRAIT_ACTIONS:
|
||||
return None
|
||||
action = ACTION_ALIASES.get(action, action)
|
||||
key = (item.get("key") or item.get("slug") or "").strip()
|
||||
slug = coerce_slug(item.get("slug") or key or item.get("label") or "trait")
|
||||
if layer == "core":
|
||||
key = CORE_KEY
|
||||
slug = CORE_KEY
|
||||
elif layer == "facet":
|
||||
key = normalize_facet_key(key) or JOURNAL_FACET
|
||||
slug_hint = item.get("slug") or ""
|
||||
as_trait = bool(slug_hint) or action == "add" or key in LEGACY_STYLE_KEYS or (
|
||||
valid_slug(key) and key not in SEED_FACETS and key != CORE_KEY
|
||||
)
|
||||
if as_trait:
|
||||
layer = "trait"
|
||||
slug = coerce_slug(slug_hint or key)
|
||||
else:
|
||||
slug = coerce_slug(key)
|
||||
elif not valid_slug(slug):
|
||||
return None
|
||||
facet_key = normalize_facet_key(item.get("facet_key") or (key if layer == "facet" else "") or CORE_KEY) or CORE_KEY
|
||||
if layer == "core":
|
||||
facet_key = CORE_KEY
|
||||
exemplars = []
|
||||
for ex in item.get("exemplars") or []:
|
||||
parsed_ex = _parse_exemplar(ex)
|
||||
if parsed_ex:
|
||||
exemplars.append(parsed_ex)
|
||||
split_into = []
|
||||
for part in item.get("split_into") or []:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
part_slug = coerce_slug(part.get("slug") or part.get("label") or "")
|
||||
if not part_slug:
|
||||
continue
|
||||
split_into.append(
|
||||
{
|
||||
"slug": part_slug,
|
||||
"label": part.get("label") or part_slug,
|
||||
"statement": part.get("statement") or part.get("proposed_value") or "",
|
||||
"facet_key": normalize_facet_key(part.get("facet_key") or facet_key) or facet_key,
|
||||
}
|
||||
)
|
||||
basis = _parse_basis(item.get("evidence_basis"))
|
||||
if not basis and (item.get("evidence_ids") or any(ex.get("source_id") for ex in exemplars)):
|
||||
basis = ["kansho_sources"]
|
||||
if is_external_only(basis):
|
||||
for ex in exemplars:
|
||||
ex.pop("source_id", None)
|
||||
return {
|
||||
"layer": layer,
|
||||
"key": key,
|
||||
"slug": slug,
|
||||
"facet_key": facet_key,
|
||||
"action": action,
|
||||
"proposed_value": item.get("proposed_value") or item.get("statement") or "",
|
||||
"label": item.get("label") or "",
|
||||
"rationale": item.get("rationale") or "",
|
||||
"evidence_ids": [str(eid) for eid in (item.get("evidence_ids") or []) if eid],
|
||||
"evidence_basis": basis,
|
||||
"exemplars": exemplars,
|
||||
"merge_slugs": [
|
||||
coerce_slug(value)
|
||||
for value in (item.get("merge_slugs") or item.get("merge_ids") or [])
|
||||
if value
|
||||
],
|
||||
"split_into": split_into,
|
||||
"trait_id": item.get("trait_id") or "",
|
||||
}
|
||||
|
||||
|
||||
def _parse_exemplar(ex) -> dict | None:
|
||||
if isinstance(ex, str) and ex.strip() and not is_meta_style_text(ex):
|
||||
return {"excerpt": ex.strip(), "role": "exemplar", "evidence_basis": []}
|
||||
if not isinstance(ex, dict):
|
||||
return None
|
||||
excerpt = (ex.get("excerpt") or "").strip()
|
||||
if not excerpt or is_meta_style_text(excerpt):
|
||||
return None
|
||||
basis = _parse_basis(ex.get("evidence_basis"))
|
||||
source_id = ex.get("source_id")
|
||||
if is_external_only(basis):
|
||||
source_id = None
|
||||
return {
|
||||
"excerpt": excerpt,
|
||||
"role": ex.get("role") or "exemplar",
|
||||
"source_id": source_id,
|
||||
"occurred_at": ex.get("occurred_at"),
|
||||
"evidence_basis": basis or (["kansho_sources"] if source_id else []),
|
||||
}
|
||||
|
||||
|
||||
def _store_review(profile_id: str, package: dict, channel: str) -> dict:
|
||||
review_id = str(uuid.uuid4())
|
||||
with get_db() as conn:
|
||||
|
|
@ -805,19 +903,10 @@ def run_api_review(profile_id: str, review_id: str | None = None, *, mode: str |
|
|||
parsed = parse_result(result.get("content") or "")
|
||||
if not parsed.get("mode"):
|
||||
parsed["mode"] = package.get("mode") or ""
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE writing_profile_reviews
|
||||
SET result_json = ?, status = 'proposed'
|
||||
WHERE id = ? AND profile_id = ?
|
||||
""",
|
||||
(json.dumps(parsed, ensure_ascii=False), review_id, profile_id),
|
||||
)
|
||||
applied = apply_result(profile_id, parsed, review_id=review_id, package=package)
|
||||
applied["trace"] = result.get("trace")
|
||||
applied["review_id"] = review_id
|
||||
return applied
|
||||
staged = _stage_proposal(profile_id, parsed, review_id=review_id, package=package)
|
||||
staged["trace"] = result.get("trace")
|
||||
staged["review_id"] = review_id
|
||||
return staged
|
||||
|
||||
|
||||
def import_result(profile_id: str, raw, review_id: str | None = None) -> dict:
|
||||
|
|
@ -855,7 +944,55 @@ def import_result(profile_id: str, raw, review_id: str | None = None) -> dict:
|
|||
)
|
||||
package = _parse_json((row or {}).get("package_json"), {})
|
||||
if not parsed.get("mode"):
|
||||
parsed["mode"] = package.get("mode") or ""
|
||||
parsed["mode"] = package.get("mode") or normalize_mode(parsed.get("mode")) or _review_mode(profile_id)
|
||||
if not parsed.get("changes"):
|
||||
raise StoreError(
|
||||
"empty_review_result",
|
||||
"Keine übernehmbaren Änderungen gefunden. Für initial_build werden core, facets und traits oder eine changes-Liste benötigt.",
|
||||
)
|
||||
return _stage_proposal(profile_id, parsed, review_id=review_id, package=package)
|
||||
|
||||
|
||||
def _known_ids(package: dict) -> set[str]:
|
||||
ids = set()
|
||||
for item in (package.get("evidences") or []) + (package.get("corpus") or []):
|
||||
if item.get("id"):
|
||||
ids.add(str(item["id"]))
|
||||
if item.get("source_id"):
|
||||
ids.add(str(item["source_id"]))
|
||||
return ids
|
||||
|
||||
|
||||
def _unknown_refs(parsed: dict, package: dict) -> list[str]:
|
||||
known = _known_ids(package)
|
||||
unknown = []
|
||||
for change in parsed.get("changes") or []:
|
||||
if is_external_only(change.get("evidence_basis")):
|
||||
continue
|
||||
for eid in change.get("evidence_ids") or []:
|
||||
if str(eid) not in known:
|
||||
unknown.append(str(eid))
|
||||
for ex in change.get("exemplars") or []:
|
||||
source_id = ex.get("source_id")
|
||||
if source_id and str(source_id) not in known:
|
||||
unknown.append(str(source_id))
|
||||
return sorted(set(unknown))
|
||||
|
||||
|
||||
def _stage_proposal(profile_id: str, parsed: dict, *, review_id: str, package: dict) -> dict:
|
||||
unknown = _unknown_refs(parsed, package)
|
||||
current = get_profile(profile_id)
|
||||
for index, change in enumerate(parsed.get("changes") or []):
|
||||
payload = {**change, "index": index, "unknown_refs": [item for item in unknown if item in (change.get("evidence_ids") or [])]}
|
||||
_queue_suggestion(
|
||||
profile_id,
|
||||
change.get("facet_key") or change.get("key") or "",
|
||||
change.get("proposed_value") or "",
|
||||
change.get("rationale") or "",
|
||||
trait_slug=change.get("slug") or "",
|
||||
action=change.get("action") or "update",
|
||||
payload=payload,
|
||||
)
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
|
|
@ -865,37 +1002,184 @@ def import_result(profile_id: str, raw, review_id: str | None = None) -> dict:
|
|||
""",
|
||||
(json.dumps(parsed, ensure_ascii=False), review_id, profile_id),
|
||||
)
|
||||
return apply_result(profile_id, parsed, review_id=review_id, package=package)
|
||||
profile = get_profile(profile_id)
|
||||
suggested = len(parsed.get("changes") or [])
|
||||
status = review_status(profile_id)
|
||||
return {
|
||||
**status,
|
||||
"applied": 0,
|
||||
"suggested": suggested,
|
||||
"skipped": 0,
|
||||
"accepted": False,
|
||||
"review_id": review_id,
|
||||
"mode": parsed.get("mode") or package.get("mode"),
|
||||
"unknown_refs": unknown,
|
||||
"proposal": parsed,
|
||||
"result": parsed,
|
||||
"profile": profile,
|
||||
"note": f"{suggested} Vorschläge geprüft. Nichts wurde am Current Profile geändert.",
|
||||
"current_version": current.get("version") or 0,
|
||||
}
|
||||
|
||||
|
||||
def accept_proposal(
|
||||
profile_id: str,
|
||||
review_id: str | None = None,
|
||||
*,
|
||||
indexes: list[int] | None = None,
|
||||
baseline: bool = False,
|
||||
) -> dict:
|
||||
with get_db() as conn:
|
||||
if review_id:
|
||||
row = row_to_dict(
|
||||
conn.execute(
|
||||
"SELECT * FROM writing_profile_reviews WHERE id = ? AND profile_id = ?",
|
||||
(review_id, profile_id),
|
||||
).fetchone()
|
||||
)
|
||||
else:
|
||||
row = row_to_dict(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT * FROM writing_profile_reviews
|
||||
WHERE profile_id = ? AND status = 'proposed'
|
||||
ORDER BY created DESC LIMIT 1
|
||||
""",
|
||||
(profile_id,),
|
||||
).fetchone()
|
||||
)
|
||||
if not row:
|
||||
raise StoreError("not_found", "Kein offenes Profile-Proposal.", 404)
|
||||
parsed = parse_result(_parse_json(row.get("result_json"), {}))
|
||||
package = _parse_json(row.get("package_json"), {})
|
||||
changes = parsed.get("changes") or []
|
||||
remaining = []
|
||||
if indexes is not None:
|
||||
chosen = set(indexes)
|
||||
selected = [changes[i] for i in indexes if 0 <= i < len(changes)]
|
||||
remaining = [item for i, item in enumerate(changes) if i not in chosen]
|
||||
parsed = {**parsed, "changes": selected}
|
||||
return apply_result(
|
||||
profile_id,
|
||||
parsed,
|
||||
review_id=row["id"],
|
||||
package=package,
|
||||
baseline=baseline or ((parsed.get("mode") or package.get("mode")) == "initial_build" and indexes is None),
|
||||
user_accepted=True,
|
||||
remaining=remaining,
|
||||
)
|
||||
|
||||
|
||||
def reject_proposal(profile_id: str, review_id: str | None = None, *, indexes: list[int] | None = None) -> dict:
|
||||
with get_db() as conn:
|
||||
if review_id:
|
||||
row = row_to_dict(
|
||||
conn.execute(
|
||||
"SELECT * FROM writing_profile_reviews WHERE id = ? AND profile_id = ?",
|
||||
(review_id, profile_id),
|
||||
).fetchone()
|
||||
)
|
||||
else:
|
||||
row = row_to_dict(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT * FROM writing_profile_reviews
|
||||
WHERE profile_id = ? AND status IN ('open', 'proposed')
|
||||
ORDER BY created DESC LIMIT 1
|
||||
""",
|
||||
(profile_id,),
|
||||
).fetchone()
|
||||
)
|
||||
if not row:
|
||||
raise StoreError("not_found", "Kein offenes Profile-Proposal.", 404)
|
||||
parsed = parse_result(_parse_json(row.get("result_json"), {})) if row.get("result_json") else {}
|
||||
changes = parsed.get("changes") or []
|
||||
remaining = []
|
||||
if indexes is not None:
|
||||
chosen = set(indexes)
|
||||
remaining = [item for i, item in enumerate(changes) if i not in chosen]
|
||||
if remaining:
|
||||
kept = {**parsed, "changes": remaining}
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE writing_profile_reviews
|
||||
SET result_json = ?, status = 'proposed'
|
||||
WHERE id = ? AND profile_id = ?
|
||||
""",
|
||||
(json.dumps(kept, ensure_ascii=False), row["id"], profile_id),
|
||||
)
|
||||
dropped = {item.get("slug") or item.get("key") for i, item in enumerate(changes) if i in set(indexes)}
|
||||
for slug in dropped:
|
||||
if not slug:
|
||||
continue
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE writing_profile_suggestions
|
||||
SET status = 'rejected', resolved = datetime('now')
|
||||
WHERE profile_id = ? AND status = 'pending' AND (trait_slug = ? OR facet_key = ?)
|
||||
""",
|
||||
(profile_id, slug, slug),
|
||||
)
|
||||
return {
|
||||
"accepted": False,
|
||||
"applied": 0,
|
||||
"review_id": row["id"],
|
||||
"proposal": kept,
|
||||
"result": kept,
|
||||
**review_status(profile_id),
|
||||
"profile": get_profile(profile_id),
|
||||
}
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE writing_profile_reviews
|
||||
SET status = 'dismissed', resolved = datetime('now')
|
||||
WHERE id = ? AND profile_id = ?
|
||||
""",
|
||||
(row["id"], profile_id),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE writing_profile_suggestions
|
||||
SET status = 'rejected', resolved = datetime('now')
|
||||
WHERE profile_id = ? AND status = 'pending'
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
return {"accepted": False, "applied": 0, **review_status(profile_id), "profile": get_profile(profile_id)}
|
||||
|
||||
|
||||
def _refs_from_change(change: dict, evidences: list[dict], corpus: list[dict]) -> list[dict]:
|
||||
refs = []
|
||||
basis = change.get("evidence_basis") or []
|
||||
external_only = is_external_only(basis)
|
||||
by_id = {item.get("id"): item for item in evidences if item.get("id")}
|
||||
by_source = {item.get("id"): item for item in corpus if item.get("id")}
|
||||
for eid in change.get("evidence_ids") or []:
|
||||
item = by_id.get(eid) or by_source.get(eid)
|
||||
if not item:
|
||||
continue
|
||||
excerpt = plain_text(item.get("excerpt") or item.get("body") or "")
|
||||
if not excerpt:
|
||||
continue
|
||||
refs.append(
|
||||
{
|
||||
"role": "evidence",
|
||||
"excerpt": excerpt,
|
||||
"source_id": item.get("source_id") or item.get("id"),
|
||||
"occurred_at": item.get("occurred_at"),
|
||||
}
|
||||
)
|
||||
if not external_only:
|
||||
for eid in change.get("evidence_ids") or []:
|
||||
item = by_id.get(eid) or by_source.get(eid)
|
||||
if not item:
|
||||
continue
|
||||
excerpt = plain_text(item.get("excerpt") or item.get("body") or "")
|
||||
if not excerpt or is_meta_style_text(excerpt):
|
||||
continue
|
||||
refs.append(
|
||||
{
|
||||
"role": "evidence",
|
||||
"excerpt": excerpt,
|
||||
"source_id": item.get("source_id") or item.get("id"),
|
||||
"occurred_at": item.get("occurred_at"),
|
||||
}
|
||||
)
|
||||
for item in change.get("exemplars") or []:
|
||||
excerpt = plain_text(item.get("excerpt") or "")
|
||||
if not excerpt:
|
||||
if not excerpt or is_meta_style_text(excerpt):
|
||||
continue
|
||||
source_id = None if external_only else item.get("source_id")
|
||||
refs.append(
|
||||
{
|
||||
"role": item.get("role") or "exemplar",
|
||||
"excerpt": excerpt,
|
||||
"source_id": item.get("source_id"),
|
||||
"source_id": source_id,
|
||||
"occurred_at": item.get("occurred_at"),
|
||||
}
|
||||
)
|
||||
|
|
@ -912,7 +1196,7 @@ def _apply_change(
|
|||
evidences: list[dict],
|
||||
corpus: list[dict],
|
||||
) -> str:
|
||||
action = change.get("action") or "confirm"
|
||||
action = APPLY_ACTIONS.get(change.get("action") or "keep", change.get("action") or "confirm")
|
||||
layer = change.get("layer") or "trait"
|
||||
value = (change.get("proposed_value") or "").strip()
|
||||
rationale = (change.get("rationale") or "AI Review").strip()
|
||||
|
|
@ -942,6 +1226,9 @@ def _apply_change(
|
|||
|
||||
slug = coerce_slug(change.get("slug") or change.get("key") or "trait")
|
||||
facet_key = normalize_facet_key(change.get("facet_key") or JOURNAL_FACET) or JOURNAL_FACET
|
||||
if action == "remove":
|
||||
retire_trait(profile_id, slug, "retired")
|
||||
return "applied"
|
||||
if action == "create" and not value:
|
||||
return "skipped"
|
||||
if action == "rescope":
|
||||
|
|
@ -1015,45 +1302,38 @@ def apply_result(
|
|||
result: dict,
|
||||
review_id: str | None = None,
|
||||
package: dict | None = None,
|
||||
*,
|
||||
baseline: bool = False,
|
||||
user_accepted: bool = False,
|
||||
remaining: list[dict] | None = None,
|
||||
) -> dict:
|
||||
if not user_accepted:
|
||||
raise StoreError("proposal_not_accepted", "Profile Proposal wird erst nach Nutzerbestätigung übernommen.")
|
||||
parsed = parse_result(result)
|
||||
row = ensure_profile(profile_id)
|
||||
governance = row.get("governance") or "learning"
|
||||
if governance not in GOVERNANCE:
|
||||
governance = "learning"
|
||||
mode = parsed.get("mode") or (package or {}).get("mode") or _review_mode(profile_id)
|
||||
if has_confirmed_profile(profile_id):
|
||||
mode = "incremental"
|
||||
origin = "initial_build" if mode == "initial_build" else "accepted_suggestion"
|
||||
mode = normalize_mode(parsed.get("mode") or (package or {}).get("mode")) or _review_mode(profile_id)
|
||||
origin = "initial_build" if mode == "initial_build" or baseline else "accepted_suggestion"
|
||||
corpus = list_corpus(profile_id, limit=INITIAL_BUILD_SOURCES)
|
||||
evidences = list((package or {}).get("evidences") or []) + _pending_evidence(profile_id, limit=40)
|
||||
allow_core = _corpus_supports_core(corpus)
|
||||
leftover = list(remaining or [])
|
||||
close_review = not leftover
|
||||
snapshot_version(profile_id, cause="before_review")
|
||||
applied = 0
|
||||
suggested = 0
|
||||
skipped = 0
|
||||
for change in parsed.get("changes") or []:
|
||||
if governance == "frozen":
|
||||
if (row.get("governance") or "") == "frozen" and not baseline:
|
||||
skipped += 1
|
||||
continue
|
||||
if governance == "advising" and mode != "initial_build":
|
||||
_queue_suggestion(
|
||||
profile_id,
|
||||
change.get("facet_key") or change.get("key") or "",
|
||||
change.get("proposed_value") or "",
|
||||
change.get("rationale") or "",
|
||||
trait_slug=change.get("slug") or "",
|
||||
action=change.get("action") or "update",
|
||||
payload=change,
|
||||
)
|
||||
suggested += 1
|
||||
continue
|
||||
outcome = _apply_change(
|
||||
profile_id,
|
||||
change,
|
||||
origin=origin,
|
||||
force=mode == "initial_build",
|
||||
allow_core=allow_core,
|
||||
force=True,
|
||||
allow_core=allow_core or baseline,
|
||||
evidences=evidences,
|
||||
corpus=corpus,
|
||||
)
|
||||
|
|
@ -1062,9 +1342,9 @@ def apply_result(
|
|||
else:
|
||||
skipped += 1
|
||||
_assemble_brief(profile_id)
|
||||
if applied or suggested:
|
||||
if applied:
|
||||
snapshot_version(profile_id, cause="accepted_review")
|
||||
if mode == "initial_build" and applied:
|
||||
if close_review and (mode == "initial_build" or baseline) and applied:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
|
|
@ -1080,23 +1360,35 @@ def apply_result(
|
|||
for change in parsed.get("changes") or []
|
||||
for eid in change.get("evidence_ids") or []
|
||||
]
|
||||
applied_slugs = [change.get("slug") or change.get("key") for change in parsed.get("changes") or []]
|
||||
leftover_doc = {**parsed, "changes": leftover} if leftover else None
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE writing_profiles SET last_reviewed = datetime('now'), review_ready = 0 WHERE profile_id = ?",
|
||||
(profile_id,),
|
||||
)
|
||||
if close_review:
|
||||
conn.execute(
|
||||
"UPDATE writing_profiles SET last_reviewed = datetime('now'), review_ready = 0 WHERE profile_id = ?",
|
||||
(profile_id,),
|
||||
)
|
||||
if evidence_ids:
|
||||
placeholders = ",".join("?" * len(evidence_ids))
|
||||
conn.execute(
|
||||
f"UPDATE writing_profile_evidence SET status = 'consumed' WHERE profile_id = ? AND id IN ({placeholders})",
|
||||
(profile_id, *evidence_ids),
|
||||
)
|
||||
else:
|
||||
elif close_review:
|
||||
conn.execute(
|
||||
"UPDATE writing_profile_evidence SET status = 'consumed' WHERE profile_id = ? AND status IN ('bundled', 'pending')",
|
||||
(profile_id,),
|
||||
)
|
||||
if review_id:
|
||||
if review_id and leftover_doc:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE writing_profile_reviews
|
||||
SET result_json = ?, status = 'proposed'
|
||||
WHERE id = ? AND profile_id = ?
|
||||
""",
|
||||
(json.dumps(leftover_doc, ensure_ascii=False), review_id, profile_id),
|
||||
)
|
||||
elif review_id:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE writing_profile_reviews
|
||||
|
|
@ -1105,15 +1397,40 @@ def apply_result(
|
|||
""",
|
||||
(review_id, profile_id),
|
||||
)
|
||||
if close_review:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE writing_profile_suggestions
|
||||
SET status = 'accepted', resolved = datetime('now')
|
||||
WHERE profile_id = ? AND status = 'pending'
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
else:
|
||||
for slug in applied_slugs:
|
||||
if not slug:
|
||||
continue
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE writing_profile_suggestions
|
||||
SET status = 'accepted', resolved = datetime('now')
|
||||
WHERE profile_id = ? AND status = 'pending' AND (trait_slug = ? OR facet_key = ?)
|
||||
""",
|
||||
(profile_id, slug, slug),
|
||||
)
|
||||
_refresh_ready(profile_id)
|
||||
profile = get_profile(profile_id)
|
||||
return {
|
||||
"governance": governance,
|
||||
"mode": mode,
|
||||
"applied": applied,
|
||||
"suggested": suggested,
|
||||
"suggested": len(leftover),
|
||||
"skipped": skipped,
|
||||
"result": parsed,
|
||||
"accepted": True,
|
||||
"baseline": baseline,
|
||||
"review_id": review_id,
|
||||
"proposal": leftover_doc,
|
||||
"result": leftover_doc or parsed,
|
||||
"profile": profile,
|
||||
**review_status(profile_id),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ from profile_review import (
|
|||
open_paste_review,
|
||||
review_status,
|
||||
run_api_review,
|
||||
accept_proposal,
|
||||
reject_proposal,
|
||||
)
|
||||
from interaction_profile_store import (
|
||||
accept_suggestion as accept_interaction_suggestion,
|
||||
|
|
@ -155,6 +157,12 @@ class ReviewImportWrite(BaseModel):
|
|||
review_id: str | None = None
|
||||
|
||||
|
||||
class ReviewAcceptWrite(BaseModel):
|
||||
review_id: str | None = None
|
||||
indexes: list[int] | None = None
|
||||
baseline: bool = False
|
||||
|
||||
|
||||
@router.get("/spaces")
|
||||
def spaces(session: dict = Depends(require_auth)):
|
||||
return list_user_spaces(session["profile_id"])
|
||||
|
|
@ -521,6 +529,27 @@ def writing_review_import(body: ReviewImportWrite, session: dict = Depends(requi
|
|||
_http(exc)
|
||||
|
||||
|
||||
@router.post("/writing-profile/review/accept")
|
||||
def writing_review_accept(body: ReviewAcceptWrite, session: dict = Depends(require_auth)):
|
||||
try:
|
||||
return accept_proposal(
|
||||
session["profile_id"],
|
||||
body.review_id,
|
||||
indexes=body.indexes,
|
||||
baseline=body.baseline,
|
||||
)
|
||||
except StoreError as exc:
|
||||
_http(exc)
|
||||
|
||||
|
||||
@router.post("/writing-profile/review/reject")
|
||||
def writing_review_reject(body: ReviewAcceptWrite, session: dict = Depends(require_auth)):
|
||||
try:
|
||||
return reject_proposal(session["profile_id"], body.review_id, indexes=body.indexes)
|
||||
except StoreError as exc:
|
||||
_http(exc)
|
||||
|
||||
|
||||
@router.get("/writing-profile/export")
|
||||
def writing_export(session: dict = Depends(require_auth)):
|
||||
return export_writing_document(session["profile_id"])
|
||||
|
|
|
|||
|
|
@ -90,7 +90,65 @@ def main() -> None:
|
|||
}],
|
||||
})
|
||||
expect(parsed_create["changes"][0]["slug"] == "scene_cuts", "dynamic trait slugs are accepted")
|
||||
expect(parsed_create["changes"][0]["action"] == "create", "create remains create")
|
||||
expect(parsed_create["changes"][0]["action"] == "add", "create maps to add")
|
||||
external = parse_result({
|
||||
"kind": KIND_RESULT,
|
||||
"format_version": 1,
|
||||
"changes": [{
|
||||
"layer": "trait",
|
||||
"slug": "dry_humor",
|
||||
"action": "add",
|
||||
"proposed_value": "trocken",
|
||||
"evidence_basis": ["external_chat_history"],
|
||||
"exemplars": [{"excerpt": "haha das war irgendwie lustig", "source_id": "invented", "evidence_basis": ["external_chat_history"]}],
|
||||
}],
|
||||
})
|
||||
expect(external["changes"][0]["evidence_basis"] == ["external_chat_history"], "external basis kept")
|
||||
expect(external["changes"][0]["exemplars"][0].get("source_id") in (None, ""), "external chat does not invent kansho source ids")
|
||||
from_proposal = parse_result({
|
||||
"kind": KIND_RESULT,
|
||||
"format_version": 1,
|
||||
"mode": "initial_build",
|
||||
"profile": {
|
||||
"traits": [{
|
||||
"slug": "scene_cuts",
|
||||
"facet_key": "autobiographical_journal",
|
||||
"statement": "wechselt ohne Ankündigung zwischen Szenen",
|
||||
"evidence_basis": ["external_chat_history"],
|
||||
"exemplars": [{"excerpt": "Danach der Hafen.", "source_id": "invented", "evidence_basis": ["external_chat_history"]}],
|
||||
}]
|
||||
},
|
||||
})
|
||||
expect(from_proposal["changes"][0]["slug"] == "scene_cuts", "full profile proposal becomes changes")
|
||||
expect(from_proposal["changes"][0]["exemplars"][0].get("source_id") in (None, ""), "proposal from external chat has no kansho source id")
|
||||
dayone = parse_result({
|
||||
"kind": KIND_RESULT,
|
||||
"format_version": 1,
|
||||
"mode": "initial_build",
|
||||
"core": {"summary": "direkte Erlebnisrekonstruktion", "generalizability": "nur Journal"},
|
||||
"facets": [{"key": "autobiographical_journal", "summary": "zeitliche Feinauflösung"}],
|
||||
"traits": [{
|
||||
"slug": "mikrochronologische-kausalrekonstruktion",
|
||||
"label": "Mikrochronologische Rekonstruktion",
|
||||
"scope": "core",
|
||||
"facet_key": "core",
|
||||
"statement": "Erlebnisse in ihrer tatsächlichen Reihenfolge.",
|
||||
"evidence_basis": ["dayone_export"],
|
||||
"exemplars": [{
|
||||
"excerpt": "Das leichte Kribbeln in der Hand veranlasste mich einen Krankenwagen zu rufen.",
|
||||
"occurred_at": "2024-10-04",
|
||||
"evidence_basis": "dayone_export",
|
||||
}],
|
||||
}],
|
||||
})
|
||||
slugs = {item["slug"] for item in dayone["changes"]}
|
||||
expect("core" in slugs or any(item["layer"] == "core" for item in dayone["changes"]), "top-level core becomes a change")
|
||||
expect(any(item["layer"] == "facet" for item in dayone["changes"]), "top-level facet becomes a change")
|
||||
expect("mikrochronologische_kausalrekonstruktion" in slugs, "hyphen slugs are coerced")
|
||||
trait = next(item for item in dayone["changes"] if item["layer"] == "trait")
|
||||
expect(trait["evidence_basis"] == ["external_context"], "dayone export is external context")
|
||||
expect(trait["exemplars"][0].get("source_id") in (None, ""), "dayone exemplars have no kansho source id")
|
||||
expect(trait["proposed_value"].startswith("Erlebnisse"), "trait statement is kept")
|
||||
|
||||
reset_debug()
|
||||
with TestClient(app) as client:
|
||||
|
|
@ -240,18 +298,24 @@ def main() -> None:
|
|||
paste = client.post("/api/journal/writing-profile/initial-build/paste", headers=headers)
|
||||
expect(paste.status_code == 200, f"initial paste {paste.text}")
|
||||
package = paste.json()["package"]
|
||||
expect(package["kind"] == KIND_PACKAGE, "paste uses review package contract")
|
||||
expect(package["kind"] == KIND_PACKAGE, "paste uses analysis package contract")
|
||||
expect(package["format_version"] == 1, "package version")
|
||||
expect(package["mode"] == "initial_build", "unconfirmed profile opens an initial build")
|
||||
expect(package["existing_before_new"], "package names Existing-before-New")
|
||||
expect(any(item.get("kind") == "trait_hint" for item in package["seed_catalog"]), "seed catalog is ordering help")
|
||||
expect(len(package.get("corpus") or []) >= 3, "initial package includes historical corpus")
|
||||
expect(any(item.get("occurred_at") for item in package["corpus"]), "corpus items keep time")
|
||||
instruction = package.get("instruction") or ""
|
||||
instruction = (package.get("instruction") or "") + (paste.json().get("prompt") or "")
|
||||
expect("bestehenden Trait bestätigen" in instruction, "instruction starts with confirm")
|
||||
expect("erst dann einen neuen Trait anlegen" in instruction, "instruction defers create")
|
||||
expect("autobiographical_journal" in instruction, "vacation/journal facet is named")
|
||||
expect("kansho.profile_review_result" in (paste.json().get("prompt") or ""), "copy prompt names the result contract")
|
||||
expect("external_chat_history" in instruction, "external chat history is named as provenance")
|
||||
expect("Wenn du aus dem bisherigen Verlauf dieses Chats bereits weitere authentische Texte" in instruction, "paste prompt may use existing chat knowledge")
|
||||
expect("Expliziter Initial Profile Build" not in instruction, "trigger meta is not style evidence")
|
||||
expect("Explizite Nutzer-Review" not in (paste.json().get("prompt") or ""), "ui trigger text is not in prompt")
|
||||
expect(package.get("compiled_brief_excluded") is True, "compiled brief is not the package")
|
||||
expect("kansho.profile_analysis_result" in (paste.json().get("prompt") or ""), "copy prompt names the result contract")
|
||||
expect("profile" in (package.get("expected_result") or {}), "initial_build expects a full proposal")
|
||||
|
||||
evidence_id = (package.get("evidences") or [{}])[0].get("id")
|
||||
imported = client.post(
|
||||
|
|
@ -269,26 +333,104 @@ def main() -> None:
|
|||
"layer": "trait",
|
||||
"slug": "dry_humor",
|
||||
"facet_key": "autobiographical_journal",
|
||||
"action": "create",
|
||||
"action": "add",
|
||||
"label": "Trockener Humor",
|
||||
"proposed_value": "gelegentlich trocken, aus Paste-Review",
|
||||
"rationale": "Journal-Evidenz, kein globaler Core.",
|
||||
"evidence_basis": ["kansho_sources"],
|
||||
"evidence_ids": [evidence_id] if evidence_id else [],
|
||||
"exemplars": [{"excerpt": "haha das war irgendwie lustig", "occurred_at": "2024-08-11"}],
|
||||
"exemplars": [{"excerpt": "haha das war irgendwie lustig", "occurred_at": "2024-08-11", "evidence_basis": ["kansho_sources"]}],
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(imported.status_code == 200, f"import result {imported.text}")
|
||||
expect(imported.json()["applied"] >= 1, "initial build applies traits")
|
||||
profile = imported.json()["profile"]
|
||||
expect(imported.json()["applied"] == 0, "import does not apply the current profile")
|
||||
expect(imported.json().get("accepted") is False, "proposal waits for user review")
|
||||
expect("dry_humor" not in trait_map(imported.json()["profile"]), "proposal is not auto-merged")
|
||||
accepted = client.post(
|
||||
"/api/journal/writing-profile/review/accept",
|
||||
headers=headers,
|
||||
json={"review_id": imported.json()["review_id"], "baseline": True},
|
||||
)
|
||||
expect(accepted.status_code == 200, f"accept baseline {accepted.text}")
|
||||
expect(accepted.json()["applied"] >= 1, "baseline applies accepted traits")
|
||||
profile = accepted.json()["profile"]
|
||||
expect(profile.get("lifecycle") == "confirmed", "successful initial build confirms the profile")
|
||||
expect(trait_map(profile)["dry_humor"]["statement"].startswith("gelegentlich trocken"), "created trait is stored")
|
||||
expect(trait_map(profile)["dry_humor"]["facet_key"] == "autobiographical_journal", "vacation evidence stays in journal facet")
|
||||
expect(any("haha" in (ref.get("excerpt") or "") for ref in trait_map(profile)["dry_humor"].get("exemplars") or []), "trait keeps exemplar")
|
||||
expect("core" not in facet_map(profile) or not (facet_map(profile).get("core") or {}).get("value"), "single source kind does not invent a global core")
|
||||
|
||||
review_paste = client.post("/api/journal/writing-profile/review/paste", headers=headers)
|
||||
expect(review_paste.status_code == 200, f"review paste {review_paste.text}")
|
||||
review_pkg = review_paste.json()["package"]
|
||||
expect(review_pkg["mode"] == "review", "confirmed profile opens incremental review")
|
||||
expect("nicht bei jeder Review ein komplett neues Profil" in ((review_pkg.get("instruction") or "") + (review_paste.json().get("prompt") or "")), "review stays incremental")
|
||||
expect(review_pkg["profile"].get("traits"), "review package includes existing traits")
|
||||
expect("profile" not in (review_pkg.get("expected_result") or {}), "review result is incremental")
|
||||
|
||||
from writing_profile_store import compile_task_brief
|
||||
task_brief = compile_task_brief(profile["profile_id"], "journal_generate")
|
||||
expect("trocken" in task_brief, "journal generate uses relevant traits")
|
||||
expect("Tee auf dem Balkon" not in task_brief, "task brief does not dump historical full texts")
|
||||
|
||||
two_changes = client.post(
|
||||
"/api/journal/writing-profile/review/import",
|
||||
headers=headers,
|
||||
json={
|
||||
"review_id": review_paste.json()["id"],
|
||||
"result": {
|
||||
"kind": KIND_RESULT,
|
||||
"format_version": 1,
|
||||
"mode": "review",
|
||||
"changes": [
|
||||
{
|
||||
"layer": "trait",
|
||||
"slug": "dry_humor",
|
||||
"action": "update",
|
||||
"proposed_value": "sollte einzeln ablehnbar bleiben",
|
||||
"evidence_basis": ["kansho_sources"],
|
||||
},
|
||||
{
|
||||
"layer": "trait",
|
||||
"slug": "scene_cuts",
|
||||
"facet_key": "autobiographical_journal",
|
||||
"action": "add",
|
||||
"proposed_value": "wechselt ohne Ankündigung zwischen Szenen",
|
||||
"evidence_basis": ["external_chat_history"],
|
||||
"exemplars": [{"excerpt": "Danach der Hafen.", "evidence_basis": ["external_chat_history"]}],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(two_changes.status_code == 200, f"review import {two_changes.text}")
|
||||
expect(two_changes.json()["applied"] == 0, "review import stays a proposal")
|
||||
accepted_one = client.post(
|
||||
"/api/journal/writing-profile/review/accept",
|
||||
headers=headers,
|
||||
json={"review_id": two_changes.json()["review_id"], "indexes": [1]},
|
||||
)
|
||||
expect(accepted_one.status_code == 200, f"accept one {accepted_one.text}")
|
||||
expect("scene_cuts" in trait_map(accepted_one.json()["profile"]), "accepted change is stored")
|
||||
expect(
|
||||
trait_map(accepted_one.json()["profile"])["dry_humor"]["statement"].startswith("gelegentlich trocken"),
|
||||
"unaccepted change is not applied",
|
||||
)
|
||||
expect(len((accepted_one.json().get("proposal") or {}).get("changes") or []) == 1, "remaining proposal stays")
|
||||
rejected_one = client.post(
|
||||
"/api/journal/writing-profile/review/reject",
|
||||
headers=headers,
|
||||
json={"review_id": accepted_one.json()["review_id"], "indexes": [0]},
|
||||
)
|
||||
expect(rejected_one.status_code == 200, f"reject remaining {rejected_one.text}")
|
||||
expect(
|
||||
trait_map(client.get("/api/journal/writing-profile", headers=headers).json())["dry_humor"]["statement"].startswith("gelegentlich trocken"),
|
||||
"rejected change never reaches the current profile",
|
||||
)
|
||||
|
||||
drift = client.post(
|
||||
"/api/journal/entries",
|
||||
headers=headers,
|
||||
|
|
@ -376,6 +518,7 @@ def main() -> None:
|
|||
|
||||
expect(api_trace["purpose"] == "profile_review", "api review uses dedicated purpose")
|
||||
expect(api_trace["layer"] == "profilreview", "trace is not a dialogue turn")
|
||||
expect(api_review.json()["applied"] == 0, "api result is a proposal, not an auto-apply")
|
||||
expect(api_review.json()["suggested"] >= 1, "advising queues review as suggestion")
|
||||
expect(
|
||||
any("trocken" in (item.get("proposed_value") or "") for item in api_review.json()["profile"]["suggestions"]),
|
||||
|
|
@ -410,8 +553,22 @@ def main() -> None:
|
|||
},
|
||||
)
|
||||
expect(frozen_import.status_code == 200, "frozen import accepted as document")
|
||||
expect(frozen_import.json()["applied"] == 0, "frozen does not apply review")
|
||||
expect(trait_map(frozen_import.json()["profile"])["dry_humor"]["statement"] == humor_before, "frozen keeps trait")
|
||||
expect(frozen_import.json()["applied"] == 0, "frozen import stays a proposal")
|
||||
expect(trait_map(client.get("/api/journal/writing-profile", headers=headers).json())["dry_humor"]["statement"] == humor_before, "frozen keeps trait")
|
||||
|
||||
brief_as_profile = client.post(
|
||||
"/api/journal/writing-profile/review/import",
|
||||
headers=headers,
|
||||
json={"result": {"kind": KIND_RESULT, "format_version": 1, "compiled_brief": "Das ist nur der Brief."}},
|
||||
)
|
||||
expect(brief_as_profile.status_code == 400, "compiled brief is not an import format")
|
||||
|
||||
empty_result = client.post(
|
||||
"/api/journal/writing-profile/review/import",
|
||||
headers=headers,
|
||||
json={"result": {"kind": KIND_RESULT, "format_version": 1, "mode": "review"}},
|
||||
)
|
||||
expect(empty_result.status_code == 400, "empty analysis result is rejected")
|
||||
|
||||
bad = client.post(
|
||||
"/api/journal/writing-profile/review/import",
|
||||
|
|
|
|||
|
|
@ -38,12 +38,68 @@ LEGACY_STYLE_KEYS = {item["slug"] for item in SEED_TRAIT_HINTS}
|
|||
EXISTING_BEFORE_NEW = (
|
||||
"bestehenden Trait bestätigen",
|
||||
"präzisieren",
|
||||
"Scope ändern",
|
||||
"zusammenführen oder aufteilen",
|
||||
"Scope / Core-vs-Facet-Zuordnung ändern",
|
||||
"redundante Traits zusammenführen oder sinnvoll aufteilen",
|
||||
"erst dann einen neuen Trait anlegen",
|
||||
"neue Facet nur bei tatsächlicher semantischer Notwendigkeit",
|
||||
)
|
||||
TRAIT_ACTIONS = (
|
||||
"keep",
|
||||
"update",
|
||||
"add",
|
||||
"remove",
|
||||
"reclassify",
|
||||
"merge",
|
||||
"split",
|
||||
"confirm",
|
||||
"precisify",
|
||||
"rescope",
|
||||
"create",
|
||||
"move",
|
||||
)
|
||||
ACTION_ALIASES = {
|
||||
"keep": "keep",
|
||||
"confirm": "keep",
|
||||
"update": "update",
|
||||
"precisify": "update",
|
||||
"add": "add",
|
||||
"create": "add",
|
||||
"remove": "remove",
|
||||
"retire": "remove",
|
||||
"reclassify": "reclassify",
|
||||
"rescope": "reclassify",
|
||||
"move": "reclassify",
|
||||
"merge": "merge",
|
||||
"split": "split",
|
||||
}
|
||||
APPLY_ACTIONS = {
|
||||
"keep": "confirm",
|
||||
"update": "precisify",
|
||||
"add": "create",
|
||||
"remove": "remove",
|
||||
"reclassify": "rescope",
|
||||
"merge": "merge",
|
||||
"split": "split",
|
||||
}
|
||||
EVIDENCE_BASIS = ("kansho_sources", "external_chat_history", "external_context")
|
||||
EXTERNAL_EVIDENCE = ("external_chat_history", "external_context")
|
||||
BASIS_ALIASES = {
|
||||
"kansho_sources": "kansho_sources",
|
||||
"kansho": "kansho_sources",
|
||||
"external_chat_history": "external_chat_history",
|
||||
"external_context": "external_context",
|
||||
"external": "external_context",
|
||||
"dayone_export": "external_context",
|
||||
"day_one": "external_context",
|
||||
"dayone": "external_context",
|
||||
}
|
||||
ANALYSIS_MODES = ("initial_build", "review")
|
||||
META_STYLE_MARKERS = (
|
||||
"explizite nutzer-review",
|
||||
"expliziter initial profile build",
|
||||
"explizite nutzer-review des writing profile",
|
||||
"expliziter initialer profilaufbau",
|
||||
)
|
||||
TRAIT_ACTIONS = ("confirm", "precisify", "rescope", "merge", "split", "create", "keep", "update")
|
||||
ACTION_ALIASES = {"keep": "confirm", "update": "precisify"}
|
||||
|
||||
|
||||
def normalize_facet_key(key: str | None) -> str:
|
||||
|
|
@ -125,6 +181,47 @@ def recency_role(occurred_at: str | None, now: datetime | None = None) -> str:
|
|||
return "long_term"
|
||||
|
||||
|
||||
def is_meta_style_text(text: str | None) -> bool:
|
||||
raw = (text or "").strip().lower()
|
||||
if not raw:
|
||||
return True
|
||||
if len(raw.split()) <= 24 and any(marker in raw for marker in META_STYLE_MARKERS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def normalize_evidence_basis(raw) -> list[str]:
|
||||
values = raw if isinstance(raw, list) else [raw] if raw else []
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in values:
|
||||
key = str(item or "").strip().lower().replace("-", "_").replace(" ", "_")
|
||||
if not key:
|
||||
continue
|
||||
mapped = BASIS_ALIASES.get(key)
|
||||
if not mapped:
|
||||
mapped = "kansho_sources" if key.startswith("kansho") else "external_context"
|
||||
if mapped in seen:
|
||||
continue
|
||||
seen.add(mapped)
|
||||
out.append(mapped)
|
||||
return out
|
||||
|
||||
|
||||
def is_external_only(basis) -> bool:
|
||||
values = list(basis or [])
|
||||
return bool(values) and "kansho_sources" not in values
|
||||
|
||||
|
||||
def normalize_mode(value: str | None) -> str:
|
||||
raw = (value or "").strip()
|
||||
if raw in {"incremental", "review"}:
|
||||
return "review"
|
||||
if raw == "initial_build":
|
||||
return "initial_build"
|
||||
return ""
|
||||
|
||||
|
||||
def seed_catalog() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ __all__ = [
|
|||
"EXISTING_BEFORE_NEW",
|
||||
"seed_catalog",
|
||||
"INITIAL_BUILD_SOURCES",
|
||||
"compile_task_brief",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -1061,6 +1062,56 @@ def _assemble_brief(profile_id: str) -> None:
|
|||
)
|
||||
|
||||
|
||||
def compile_task_brief(profile_id: str, task: str = "journal_generate") -> str:
|
||||
"""Runtime brief for one task. Not an export, import, or proposal format."""
|
||||
from writing_profile_schema import is_meta_style_text
|
||||
|
||||
profile = get_profile(profile_id)
|
||||
parts = []
|
||||
core = profile.get("core") or {}
|
||||
if core.get("value"):
|
||||
parts.append("Core: " + (core.get("value") or ""))
|
||||
traits = [item for item in profile.get("traits") or [] if item.get("status") == "active"]
|
||||
if task == "journal_generate":
|
||||
facet = next(
|
||||
(
|
||||
item
|
||||
for item in profile.get("facets") or []
|
||||
if item.get("facet_key") == "autobiographical_journal" and (item.get("value") or "")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if facet:
|
||||
parts.append("Autobiografisches Journaling (Facet-Delta): " + (facet.get("value") or ""))
|
||||
relevant = [
|
||||
item
|
||||
for item in traits
|
||||
if item.get("facet_key") in {"autobiographical_journal", "core", ""} and (item.get("statement") or "")
|
||||
]
|
||||
if not relevant:
|
||||
relevant = [item for item in traits if item.get("statement")]
|
||||
for item in relevant[:6]:
|
||||
line = f"- {item.get('label') or item.get('slug')}: {item.get('statement')}"
|
||||
exemplars = [
|
||||
ref.get("excerpt")
|
||||
for ref in item.get("exemplars") or []
|
||||
if ref.get("excerpt") and not is_meta_style_text(ref.get("excerpt"))
|
||||
]
|
||||
if exemplars:
|
||||
line += " Beispiel: " + exemplars[0][:TRAIT_EXCERPT_CHARS]
|
||||
parts.append(line)
|
||||
if parts:
|
||||
return "\n".join(parts).strip()
|
||||
ranked = _load_ranked_sources(profile_id)
|
||||
snippets = (ranked.get("journal_entry") or [])[:2] + (ranked.get("imported_text") or [])[:1]
|
||||
lines = []
|
||||
for item in snippets:
|
||||
body = plain_text(item.get("body") or "")[:280]
|
||||
if body and not is_meta_style_text(body):
|
||||
lines.append(body)
|
||||
return "\n\n".join(lines).strip()
|
||||
|
||||
|
||||
def has_facets(profile_id: str) -> bool:
|
||||
"""True when a semantic layer or trait exists. Not a substitute for confirmed lifecycle."""
|
||||
with get_db() as conn:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ Dieses Dokument dient dazu, für weitere Konzeptarbeit nur die tatsächlich ben
|
|||
| `guardrails.md` | Privacy Gateway, Pseudonymisierung, externe KI |
|
||||
| `mvp.md` | Erster vertikaler Slice (dialoggeführtes Journal) |
|
||||
| `implementation_foundation.md` | Verbindliche Leitplanke zwischen Zielmodell und Slice |
|
||||
| `mvp_stand_und_abgleich.md` | **Kanonische Fit-Gap-Analyse** (2026-08-25): Code gegen Foundation, MVP-Slice und Gesamtziel; Gaps, Ungenauigkeiten, Prüfbrief für Gegenlesung |
|
||||
| `mvp_stand_und_abgleich.md` | **Kanonische Fit-Gap-Analyse** (2026-08-25): Code gegen Foundation, MVP-Slice und Gesamtziel; Gaps, Ungenauigkeiten, Prüfbrief für Gegenlesung; Profile-Analyse-Export 2.5 |
|
||||
|
||||
## 3. Empfohlene Context Bundles
|
||||
|
||||
|
|
|
|||
|
|
@ -185,6 +185,22 @@ Tests: `backend/tests/test_profile_governance.py`, `backend/tests/test_profile_r
|
|||
|
||||
---
|
||||
|
||||
# 2.5 Profile Analysis Export / Import (2026-08-25)
|
||||
|
||||
Additiv zu 2.3 und 2.4. Copy/Paste ist in der Testphase ein vollwertiger Ausführungsweg, nicht nur Debug.
|
||||
|
||||
| Thema | Ist-Stand | Bewusst offen |
|
||||
|---|---|---|
|
||||
| Modi | `initial_build` (vollständiges Proposal) und `review` (inkrementell) | Automatische Periodik, Interaction-Analyse |
|
||||
| Vertrag | Gemeinsames `kansho.profile_analysis_package` / `kansho.profile_analysis_result` für API und Copy/Paste | Provider-API ohne manuelles Paste |
|
||||
| Externe Chat-Historie | Semantisch nutzbar als `external_chat_history`; keine erfundenen Kanshō-Source-IDs | Import derselben Texte als echte Kanshō-Quellen |
|
||||
| Import | Validieren, unbekannte Refs, Proposal, einzelne Übernahme/Ablehnung, erst dann Version. Baseline extra für `initial_build`. Kein Auto-Apply. | Komplexere Diff-UI, Batch über mehrere Reviews |
|
||||
| Current Brief | Runtime-Artefakt nach Acceptance; Journal Generate nur Task-Ausschnitt | Weitere Task-Compiler jenseits Journal |
|
||||
|
||||
Tests: `backend/tests/test_profile_review.py`.
|
||||
|
||||
---
|
||||
|
||||
# 3. Abgleich gegen das Gesamtziel
|
||||
|
||||
Quelle: `produktvision_und_produktidentitaet.md`, ergänzt um Dialog-, Memory- und Output-Kapitel. Das ist **keine** Slice-Checkliste. Ein „Gap“ hier heißt: das Zielmodell ist nicht da — erwartet, solange Foundation-Regel 23/24 gilt. Die spannenden Zeilen sind **Spannung** und **Drift-Risiko**.
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ Produkt-Endpunkte hinter Session-Auth, Isolation über `profile_id`:
|
|||
- Entries: `POST /entries`, `GET /entries/{id}`, Versionen, Restore, Soft-Delete. Speichern aus einem Entwurf mit `entry_id` + `origin=accepted_draft` legt eine neue Version desselben Entry an.
|
||||
- Media: Upload/GET/DELETE; Bilder und einzelne Videos; Position/Unterschrift im Entry-Body als Markdown ``
|
||||
- Tagesstichpunkte: `PATCH /days/{id}/scratch` — lokal, nicht Context-Builder
|
||||
- Writing Profile: `GET /writing-profile`, Import (optional `occurred_at`, `context_hint`), Korpus `GET/POST /writing-profile/corpus`, Rebuild, `PATCH` Governance, Facet-Edit/Lock, Trait-Edit `PATCH /writing-profile/traits/{slug}`, Vorschläge annehmen/verwerfen. Brief ist eine abgeleitete Sicht aus Traits und zeitgestempelten Quellen. Media-Token werden vor dem Brief entfernt. JSON-Export/Restore: `GET /writing-profile/export`, `POST /writing-profile/restore`. Review: `GET /writing-profile/review`, `POST /writing-profile/review/paste`, `POST /writing-profile/review/api`, `POST /writing-profile/review/import`. Initial Build: `POST /writing-profile/initial-build/paste`, `POST /writing-profile/initial-build/api`. API und Copy/Paste nutzen denselben Vertrag `kansho.profile_review_package` / `kansho.profile_review_result` (`mode`, `corpus`, `existing_before_new`).
|
||||
- Writing Profile: `GET /writing-profile`, Import (optional `occurred_at`, `context_hint`), Korpus `GET/POST /writing-profile/corpus`, Rebuild, `PATCH` Governance, Facet-Edit/Lock, Trait-Edit `PATCH /writing-profile/traits/{slug}`, Vorschläge annehmen/verwerfen. Brief ist eine abgeleitete Sicht aus Traits und zeitgestempelten Quellen. Media-Token werden vor dem Brief entfernt. JSON-Export/Restore: `GET /writing-profile/export`, `POST /writing-profile/restore`. Review: `GET /writing-profile/review`, `POST /writing-profile/review/paste`, `POST /writing-profile/review/api`, `POST /writing-profile/review/import`, `POST /writing-profile/review/accept`, `POST /writing-profile/review/reject`. Initial Build: `POST /writing-profile/initial-build/paste`, `POST /writing-profile/initial-build/api`. API und Copy/Paste nutzen denselben Vertrag `kansho.profile_analysis_package` / `kansho.profile_analysis_result` (`mode` `initial_build`\|`review`, `corpus`, `existing_before_new`, `expected_result`). Legacy-`kind` `kansho.profile_review_*` bleibt lesbar. Import erzeugt ein Proposal, kein Current Profile; Übernahme erst nach Accept. Der Current Brief ist kein Import-/Exportformat.
|
||||
- Interaction Profile: `GET /interaction-profile`, `PATCH` Governance und Präferenzen, Vorschläge annehmen/verwerfen. Keine Ableitung aus Nicht-Widersprechen. Der Slot liegt unter `/api/journal` nur als Settings-Nachbar, nicht als Journal-Artefakt. JSON-Export/Restore: `GET /interaction-profile/export`, `POST /interaction-profile/restore`.
|
||||
- Beide Profile: `GET /profiles/export`, `POST /profiles/restore` (`kind: kansho.profiles`). Kein Journal-Backup.
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ Fachlich zusätzlich immer bei Querschnittsentscheidungen:
|
|||
| `admin_diagnostics.md` | Diagnoseansicht |
|
||||
| `voice_and_media.md` | Sprache und Transkription |
|
||||
| `security.md` | Security-Baseline |
|
||||
| `mvp_implementation.md` | **Kanonisches Home der technischen MVP-Umsetzung** (Laufzeit, Module, Daten, API, Pfade). Fit-Gap bleibt fachlich. |
|
||||
| `mvp_implementation.md` | **Kanonisches Home der technischen MVP-Umsetzung** (Laufzeit, Module, Daten, API, Pfade; Profile-Analyse Export/Import §15). Fit-Gap bleibt fachlich. |
|
||||
|
||||
## 3. Empfohlene Context Bundles
|
||||
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ Zusätzlich zu Layer 0:
|
|||
- `writing_profiles` / `writing_profile_sources` halten den Current Brief. Quellenpriorität: finale Nutzerfassungen, Importe, Dialogstil (`dialogue_style` aus `user:`-Zeilen). Quellen tragen `occurred_at` und `context_hint`. KI-Drafts sind keine Stilquelle. Der Brief ist eine abgeleitete Sicht.
|
||||
- `writing_profiles.lifecycle` (`uninitialized` | `initial_pending` | `confirmed`) trennt Korpus-Sammlung vom kontinuierlichen Lernen. `writing_profile_traits` / `writing_profile_trait_refs` sind die dynamischen semantischen Merkmale inkl. Evidence und Exemplaren. `writing_profile_facets` bleiben Layer-Hüllen (Core/context/output), kein festes Stilraster.
|
||||
- `writing_profiles.governance` (`learning` | `advising` | `frozen`) und Locks verhindern stilles Voll-Überschreiben. `writing_profile_suggestions` trägt advising-Vorschläge (auch `trait_slug` / `action`).
|
||||
- `writing_profiles.version` / `review_ready` / `last_reviewed` plus `writing_profile_evidence`, `writing_profile_reviews`, `writing_profile_versions` tragen die Review-Pipeline: lokale Evidenz, gebündelte Pakete, API- oder Paste-Kanal, nachvollziehbare Facet-Stände. Kein Re-Infer nach jedem Save.
|
||||
- `writing_profiles.version` / `review_ready` / `last_reviewed` plus `writing_profile_evidence`, `writing_profile_reviews`, `writing_profile_versions` tragen die Review-Pipeline: lokale Evidenz, gebündelte `kansho.profile_analysis_package`/`kansho.profile_analysis_result`, API- oder Paste-Kanal, Proposal vor User-Acceptance, nachvollziehbare Stände. Kein Re-Infer nach jedem Save. Der Current Brief bleibt abgeleitetes Runtime-Artefakt und wird nicht als Profil importiert.
|
||||
- Portable JSON-Dokumente (`kansho.writing_profile`, `kansho.interaction_profile`, `kansho.profiles`) exportieren Facets/Prefs/Governance, nicht Journalquellen oder Identity-Mapping.
|
||||
- `interaction_profiles` / `interaction_preferences` / `interaction_suggestions` sind getrennt vom Writing Profile. Default-Governance `advising`. Starke Quellen nur explizit/manuell/angenommener Vorschlag.
|
||||
- `conversations.emotional_intensity` / `long_story` sind operative Dialogue-State-Felder, kein User-Fakt.
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ Einstieg: `backend/main.py` (Router: auth, users, dialogue, journal, prompts, pl
|
|||
| `entity_detect.py` / `pronoun_bind.py` / `identity_store.py` | Detection, Dialog-Pronomen, Mapping (Klasse A) |
|
||||
| `writing_profile_store.py` | Lokaler Journal-Stilbrief, Facets, Governance, kein Extra-LLM, keine Dialogsteuerung |
|
||||
| `profile_review.py` | Lokale Evidence-/Drift-Erkennung, Review-Pakete, API- und Paste-Kanal; kein Call nach jedem Dialog |
|
||||
| `profile_analysis.py` | Gemeinsamer ProfileAnalysisPackage-/Result-Vertrag, Evidence-Auswahl, Copy/Paste-Prompt |
|
||||
| `writing_profile_infer.py` | Lokale Heuristik für Facets, kein LLM |
|
||||
| `media_store.py` | Upload, Datei, Token im Body |
|
||||
| `providers.py` / `provider_settings.py` | Provider-Rollen |
|
||||
|
|
@ -282,7 +283,7 @@ AI Review vergleicht Evidenz mit dem bestehenden Profil und liefert Änderungsvo
|
|||
|
||||
API (`POST /writing-profile/review/api`) und Copy/Paste (`POST .../paste` plus `.../import`) nutzen denselben Vertrag: `kansho.profile_review_package` / `kansho.profile_review_result`. Paste erzeugt lokal einen kopierbaren Prompt; das Ergebnis kommt strukturiert zurück. Interaction-Präferenzen entstehen nicht aus fehlendem Widerspruch oder normalem Gesprächsverlauf.
|
||||
|
||||
Das statische Facet-Raster aus §12 ist **nicht** die Profilstruktur. Semantische Traits und der Initial Profile Build: §14.
|
||||
Das statische Facet-Raster aus §12 ist **nicht** die Profilstruktur. Semantische Traits und der Initial Profile Build: §14. Kanonischer Analysevertrag, Copy/Paste als Testpfad und Import ohne Auto-Apply: §15. Die älteren `kind`-Namen bleiben als Legacy lesbar.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -312,6 +313,33 @@ Tests: `backend/tests/test_profile_governance.py`, `backend/tests/test_profile_r
|
|||
|
||||
---
|
||||
|
||||
# 15. Profile Analysis Export / Import (2026-08-25)
|
||||
|
||||
Additiv zu §13 und §14. Keine zweite Profilhülle, kein Auto-Apply.
|
||||
|
||||
Zwei Modi, ein Vertrag für API und Copy/Paste:
|
||||
|
||||
- `initial_build` — vollständiges Profile Proposal; bestehende Struktur zuerst; Strukturänderungen nach Existing-before-New; gedacht auch für Bootstrap aus einem bereits kontextualisierten externen Chat.
|
||||
- `review` — inkrementeller Vergleich neuer Evidence mit dem bestehenden Profil (`keep` / `update` / `add` / `remove` / `reclassify` / `merge` / `split`). Kein komplett neues Profil bei jeder Review.
|
||||
|
||||
Paket: `kansho.profile_analysis_package` (`format_version` 1). Enthält Modus, Target `writing`, bestehendes Profil samt Core/Facets/Traits, Governance, repräsentative Kanshō-Evidence und Exemplare, Source-Referenzen, lokale Messsignale nur als Hilfsinformation, semantische Aufgabenstellung, `expected_result`. Legacy-`kind` `kansho.profile_review_package` bleibt lesbar.
|
||||
|
||||
Ergebnis: `kansho.profile_analysis_result`. `initial_build` darf ein vollständiges `profile`-Proposal mitliefern; `review` beschreibt primär `changes`. Legacy-`kind` `kansho.profile_review_result` bleibt lesbar.
|
||||
|
||||
Copy/Paste ist in der Testphase ein vollwertiger Ausführungsweg, kein Debug-Hilfsmittel. Kanshō erzeugt aus dem Package einen kopierbaren Prompt. Das externe Modell darf authentische Nutzertexte aus dem bestehenden Chat ergänzend nutzen und muss das als `evidence_basis: external_chat_history` kennzeichnen. Keine erfundenen Quellen, Beispiele oder Kanshō-Source-IDs. Externe Chat-Historie ist Provenienz, kein interner Source-Layer.
|
||||
|
||||
Evidence-Auswahl bleibt begrenzt (`KANSHO_PROFILE_PACKAGE_CHARS`, Default 12000): aktuelle Texte, ältere Baseline, unterschiedliche Kontexte, keine unlimitierte Volltextsammlung, keine Trigger-/UI-Metatexte als Stil-Evidence.
|
||||
|
||||
Import niemals direkt als Current Profile. Ablauf: JSON einlesen, Schema prüfen, unbekannte Referenzen listen, Proposal gegen den aktuellen Stand zeigen, einzelne Änderungen übernehmen oder ablehnen oder alles übernehmen; bei `initial_build` zusätzlich „Als neue Profil-Baseline übernehmen“. Erst danach entsteht eine neue Profilversion. Der Current Brief ist abgeleitetes Runtime-Artefakt, nicht Source of Truth, nicht Export, nicht Import, nicht Proposal. Nach Übernahme kompiliert der Brief-Compiler neu; für Journal Generate nur relevanter Core, `autobiographical_journal`-Facet-Deltas und wenige Exemplare, nicht das volle historische Korpus.
|
||||
|
||||
Ein vollständiges Proposal darf `core` / `facets` / `traits` auch oben im Resultat stehen; `summary` gilt als Wert. Externe Provenienz wie `dayone_export` wird als `external_context` gelesen, nicht als Kanshō-Quelle. Ein Resultat ohne übernehmbare Änderungen wird abgelehnt statt still verschluckt.
|
||||
|
||||
API additiv: `POST /writing-profile/review/accept`, `POST /writing-profile/review/reject` (optional `indexes`). Settings: „Profil initial erstellen“ / „Profil überprüfen“ → Prompt kopieren → Ergebnis einfügen → Vorschläge prüfen.
|
||||
|
||||
Tests: `backend/tests/test_profile_review.py`.
|
||||
|
||||
---
|
||||
|
||||
# 10. Querverweise
|
||||
|
||||
- Fit-Gap: `../functional/mvp_stand_und_abgleich.md`
|
||||
|
|
|
|||
|
|
@ -149,6 +149,8 @@ export default function SettingsPage() {
|
|||
const [reviewPrompt, setReviewPrompt] = useState('')
|
||||
const [reviewResult, setReviewResult] = useState('')
|
||||
const [reviewBusy, setReviewBusy] = useState(false)
|
||||
const [proposal, setProposal] = useState(null)
|
||||
const [importNote, setImportNote] = useState('')
|
||||
|
||||
const load = () => {
|
||||
api('/api/subscription', { token: session.token }).then(setSub).catch(() => setSub(null))
|
||||
|
|
@ -227,19 +229,21 @@ export default function SettingsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const copyReviewPrompt = async () => {
|
||||
const openAnalysis = async (mode) => {
|
||||
setError('')
|
||||
setReviewBusy(true)
|
||||
try {
|
||||
const path = profile?.confirmed
|
||||
? '/api/journal/writing-profile/review/paste'
|
||||
: '/api/journal/writing-profile/initial-build/paste'
|
||||
const path = mode === 'initial_build'
|
||||
? '/api/journal/writing-profile/initial-build/paste'
|
||||
: '/api/journal/writing-profile/review/paste'
|
||||
const opened = await api(path, {
|
||||
token: session.token,
|
||||
method: 'POST'
|
||||
})
|
||||
setReview(opened)
|
||||
setReviewPrompt(opened.prompt || '')
|
||||
setProposal(null)
|
||||
setImportNote('')
|
||||
setProfile(await api('/api/journal/writing-profile', { token: session.token }))
|
||||
try {
|
||||
await navigator.clipboard.writeText(opened.prompt || '')
|
||||
|
|
@ -253,6 +257,56 @@ export default function SettingsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const copyReviewPrompt = async () => {
|
||||
if (!reviewPrompt) {
|
||||
openAnalysis(profile?.confirmed ? 'review' : 'initial_build')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(reviewPrompt)
|
||||
} catch {
|
||||
setError('Prompt steht im Feld. Bitte manuell kopieren.')
|
||||
}
|
||||
}
|
||||
|
||||
const acceptOne = async (index) => {
|
||||
setError('')
|
||||
setReviewBusy(true)
|
||||
try {
|
||||
const next = await api('/api/journal/writing-profile/review/accept', {
|
||||
token: session.token,
|
||||
method: 'POST',
|
||||
body: { review_id: review?.review_id || review?.id || review?.open_review?.id, indexes: [index] }
|
||||
})
|
||||
if (next.profile) setProfile(next.profile)
|
||||
setReview(next)
|
||||
setProposal(next.proposal || null)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setReviewBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const rejectOne = async (index) => {
|
||||
setError('')
|
||||
setReviewBusy(true)
|
||||
try {
|
||||
const next = await api('/api/journal/writing-profile/review/reject', {
|
||||
token: session.token,
|
||||
method: 'POST',
|
||||
body: { review_id: review?.review_id || review?.id || review?.open_review?.id, indexes: [index] }
|
||||
})
|
||||
if (next.profile) setProfile(next.profile)
|
||||
setReview(next)
|
||||
setProposal(next.proposal || null)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setReviewBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runApiReview = async () => {
|
||||
setError('')
|
||||
setReviewBusy(true)
|
||||
|
|
@ -266,6 +320,7 @@ export default function SettingsPage() {
|
|||
})
|
||||
if (next.profile) setProfile(next.profile)
|
||||
setReview(next)
|
||||
setProposal(next.proposal || next.result || null)
|
||||
setReviewPrompt('')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
|
|
@ -277,16 +332,65 @@ export default function SettingsPage() {
|
|||
const importReviewResult = async (event) => {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setImportNote('')
|
||||
setReviewBusy(true)
|
||||
try {
|
||||
let payload = reviewResult
|
||||
try {
|
||||
payload = JSON.parse(reviewResult)
|
||||
} catch {
|
||||
payload = reviewResult
|
||||
}
|
||||
const next = await api('/api/journal/writing-profile/review/import', {
|
||||
token: session.token,
|
||||
method: 'POST',
|
||||
body: { result: reviewResult, review_id: review?.open_review?.id }
|
||||
body: { result: payload, review_id: review?.review_id || review?.id || review?.open_review?.id }
|
||||
})
|
||||
if (next.profile) setProfile(next.profile)
|
||||
setReview(next)
|
||||
setReviewResult('')
|
||||
setProposal(next.proposal || next.result || null)
|
||||
setImportNote(next.note || `${next.suggested || 0} Vorschläge geprüft.`)
|
||||
if ((next.proposal || next.result)?.changes?.length) {
|
||||
setReviewResult('')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setReviewBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const acceptProposal = async (baseline = false) => {
|
||||
setError('')
|
||||
setReviewBusy(true)
|
||||
try {
|
||||
const next = await api('/api/journal/writing-profile/review/accept', {
|
||||
token: session.token,
|
||||
method: 'POST',
|
||||
body: { review_id: review?.review_id || review?.id || review?.open_review?.id, baseline }
|
||||
})
|
||||
if (next.profile) setProfile(next.profile)
|
||||
setReview(next)
|
||||
setProposal(null)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setReviewBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const rejectProposal = async () => {
|
||||
setError('')
|
||||
setReviewBusy(true)
|
||||
try {
|
||||
const next = await api('/api/journal/writing-profile/review/reject', {
|
||||
token: session.token,
|
||||
method: 'POST',
|
||||
body: { review_id: review?.review_id || review?.id || review?.open_review?.id }
|
||||
})
|
||||
if (next.profile) setProfile(next.profile)
|
||||
setReview(next)
|
||||
setProposal(null)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
|
|
@ -425,11 +529,10 @@ export default function SettingsPage() {
|
|||
<button type="submit">Text in den Korpus legen</button>
|
||||
</form>
|
||||
<div className="stack">
|
||||
<h3>{profile?.confirmed ? 'Profile Review' : 'Initial Profile Build'}</h3>
|
||||
<h3>Profilanalyse</h3>
|
||||
<p className="muted">
|
||||
{profile?.confirmed
|
||||
? 'Neue Texte werden lokal auf Drift geprüft. Eine AI-Review startet nur, wenn du sie auslöst — gebündelt, als API-Call oder als kopierbares Paket. Existing-before-New: bestehenden Trait bestätigen, präzisieren, Scope ändern, zusammenführen oder aufteilen, erst dann neu anlegen.'
|
||||
: 'Sammle zuerst den historischen Korpus. Der Initial Build liest viele zeitgestempelte Quellen, nicht nur die letzten zwei Einträge. Urlaubstagebücher belegen primär das autobiografische Journal, nicht automatisch den globalen Core.'}
|
||||
Du hast schon ein JSON-Ergebnis aus ChatGPT? Dann die drei Knöpfe nicht verwenden.
|
||||
JSON unten einfügen und auf „Ergebnis prüfen“ klicken. Erst danach erscheinen die Vorschläge.
|
||||
</p>
|
||||
<p className="muted">
|
||||
Version {review?.version ?? profile?.version ?? 0}
|
||||
|
|
@ -450,31 +553,85 @@ export default function SettingsPage() {
|
|||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form className="stack" onSubmit={importReviewResult}>
|
||||
<label className="stack">
|
||||
Analyse-Ergebnis einfügen
|
||||
<textarea
|
||||
rows={10}
|
||||
value={reviewResult}
|
||||
onChange={(e) => setReviewResult(e.target.value)}
|
||||
placeholder="JSON mit kind: kansho.profile_analysis_result hier einfügen …"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={reviewBusy || !reviewResult.trim()}>Ergebnis prüfen</button>
|
||||
</form>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{importNote && <p className="muted">{importNote}</p>}
|
||||
<p className="muted">
|
||||
Nur wenn du noch keinen Prompt hast: „Profil initial erstellen“ für den ersten Aufbau,
|
||||
„Profil überprüfen“ für spätere Runden, „API-Analyse“ für Kanshōs eigenes Modell.
|
||||
</p>
|
||||
<div className="row-actions">
|
||||
<button type="button" className="ghost" disabled={reviewBusy} onClick={copyReviewPrompt}>
|
||||
{profile?.confirmed ? 'Review-Paket kopieren' : 'Initial-Paket kopieren'}
|
||||
<button type="button" className="ghost" disabled={reviewBusy} onClick={() => openAnalysis('initial_build')}>
|
||||
Profil initial erstellen
|
||||
</button>
|
||||
<button type="button" className="ghost" disabled={reviewBusy} onClick={() => openAnalysis('review')}>
|
||||
Profil überprüfen
|
||||
</button>
|
||||
<button type="button" disabled={reviewBusy} onClick={runApiReview}>
|
||||
{profile?.confirmed ? 'API-Review' : 'Initial Build (API)'}
|
||||
API-Analyse
|
||||
</button>
|
||||
</div>
|
||||
{reviewPrompt && (
|
||||
<textarea
|
||||
className="writing-review-prompt"
|
||||
rows={8}
|
||||
readOnly
|
||||
value={reviewPrompt}
|
||||
/>
|
||||
<>
|
||||
<p className="muted">Diesen Prompt in einen bestehenden Chat mit deinen Texten einfügen. Das JSON-Ergebnis danach oben einfügen.</p>
|
||||
<textarea
|
||||
className="writing-review-prompt"
|
||||
rows={8}
|
||||
readOnly
|
||||
value={reviewPrompt}
|
||||
/>
|
||||
<button type="button" className="ghost" onClick={copyReviewPrompt}>Prompt kopieren</button>
|
||||
</>
|
||||
)}
|
||||
{proposal?.changes?.length > 0 && (
|
||||
<div className="stack">
|
||||
<h3>Vorgeschlagene Änderungen</h3>
|
||||
<p className="muted">Noch nicht übernommen. Einzelne Vorschläge unten annehmen oder alles übernehmen.</p>
|
||||
{review?.unknown_refs?.length > 0 && (
|
||||
<p className="muted">Unbekannte Referenzen: {review.unknown_refs.join(', ')}</p>
|
||||
)}
|
||||
{proposal.uncertainties?.length > 0 && (
|
||||
<p className="muted">Unsicherheiten: {proposal.uncertainties.length} Hinweise im Ergebnis.</p>
|
||||
)}
|
||||
<ul className="muted">
|
||||
{proposal.changes.map((item, index) => (
|
||||
<li key={`${item.slug || item.key}-${index}`}>
|
||||
{item.action} · {item.slug || item.key}
|
||||
{item.evidence_basis?.length ? ` · ${item.evidence_basis.join(', ')}` : ''}
|
||||
{item.proposed_value ? ` — ${item.proposed_value}` : ''}
|
||||
{' '}
|
||||
<button type="button" className="ghost" disabled={reviewBusy} onClick={() => acceptOne(index)}>
|
||||
Übernehmen
|
||||
</button>
|
||||
{' '}
|
||||
<button type="button" className="ghost" disabled={reviewBusy} onClick={() => rejectOne(index)}>
|
||||
Ablehnen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="row-actions">
|
||||
<button type="button" disabled={reviewBusy} onClick={() => acceptProposal(false)}>Alles übernehmen</button>
|
||||
{(proposal.mode === 'initial_build' || review?.mode === 'initial_build') && (
|
||||
<button type="button" disabled={reviewBusy} onClick={() => acceptProposal(true)}>
|
||||
Als neue Profil-Baseline übernehmen
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="ghost" disabled={reviewBusy} onClick={rejectProposal}>Alles ablehnen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form className="stack" onSubmit={importReviewResult}>
|
||||
<textarea
|
||||
rows={6}
|
||||
value={reviewResult}
|
||||
onChange={(e) => setReviewResult(e.target.value)}
|
||||
placeholder="Review-Ergebnis (JSON: kansho.profile_review_result) einfügen …"
|
||||
/>
|
||||
<button type="submit" disabled={reviewBusy || !reviewResult.trim()}>Ergebnis prüfen</button>
|
||||
</form>
|
||||
</div>
|
||||
{profile?.suggestions?.length > 0 && (
|
||||
<div className="stack">
|
||||
|
|
@ -540,7 +697,7 @@ export default function SettingsPage() {
|
|||
{profile?.compiled_brief && (
|
||||
<div className="stack">
|
||||
<h2>Aktueller Brief</h2>
|
||||
<p className="muted">Abgeleitet aus bestätigten Traits und zeitgestempelten Quellen, nicht die einzige Wahrheitsquelle.</p>
|
||||
<p className="muted">Abgeleiteter Runtime-Brief, nicht Source of Truth und kein Import-/Exportformat. Nach einer übernommenen Profiländerung wird er neu kompiliert.</p>
|
||||
<p className="writing-brief">{profile.compiled_brief}</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
165
kansho-writing-profile-improved.json
Normal file
165
kansho-writing-profile-improved.json
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
{
|
||||
"kind": "kansho.writing_profile",
|
||||
"format_version": 1,
|
||||
"exported_at": "2026-08-25T17:01:12+02:00",
|
||||
"governance": "learning",
|
||||
"lifecycle": "confirmed",
|
||||
"facets": [
|
||||
{
|
||||
"facet_key": "core",
|
||||
"layer": "core",
|
||||
"value": "Vorläufiger globaler Schreibkern: direkt, persönlich, konkret und verständlich. Erlebnisse werden als nachvollziehbare Ketten aus Ausgangslage, Entscheidung, Hindernis und Folge rekonstruiert. Konkrete Zeiten, Mengen, Wege, Preise und Tätigkeiten bleiben erhalten, wenn sie Aufwand, Stimmung oder Ergebnis erklären. Bewertungen entstehen überwiegend aus beobachtbaren Vorgängen und dem Unterschied zwischen Erwartung und tatsächlichem Verlauf. Gefühle werden klar, aber meist knapp und an konkrete Kippmomente gebunden. Die Wortwahl bleibt alltagsnah; kurze anschauliche Bilder und trockene Kommentare werden punktuell eingesetzt, ohne den Text dauerhaft zu literarisieren. Für autobiografisches Journaling stark belegt; die Übertragbarkeit auf formelle, argumentative oder berufliche Texte bleibt unsicher.",
|
||||
"evidence": "Day-One-Export mit 91 Einträgen vom 2024-08-11 bis 2026-08-24; stabile Muster zusätzlich in kürzeren Alltagsnotizen außerhalb der Urlaubscluster.",
|
||||
"origin": "initial_build",
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"facet_key": "autobiographical_journal",
|
||||
"layer": "context",
|
||||
"value": "Gegenüber dem Core deutlich stärker zeitlich und szenisch aufgelöst. Der Tag erscheint als verteilte Familienchoreografie: Es wird festgehalten, wer fährt, wartet, schläft, kocht, hilft, sich entzieht oder eine Idee einbringt. Routinen, Mahlzeiten, Bewegung, Wege und organisatorische Details bilden das Gerüst. Sinneseindrücke verdichten sich selektiv an Erlebnisgipfeln, während logistische Passagen sachlich bleiben. Begegnungen können sich zu kurzen Wissensfenstern über Orte und Lebensweisen entwickeln. Kleine Widrigkeiten werden gelegentlich trocken kommentiert. Der Schluss bilanziert den Tag meist ruhig oder endet mit einer konkreten letzten Szene. Die Texte von 2026 sind ausführlicher und reflexiver als viele Einträge von 2024; diese aktuelle Ausprägung soll höher gewichtet werden, ohne die stabilen Kernmuster zu überschreiben.",
|
||||
"evidence": "Dominierende autobiografische Familien- und Reisejournale im Day-One-Export; Vergleich der Textphasen 2024, 2025 und 2026.",
|
||||
"origin": "initial_build",
|
||||
"locked": false
|
||||
}
|
||||
],
|
||||
"traits": [
|
||||
{
|
||||
"slug": "causal_microchronology",
|
||||
"facet_key": "core",
|
||||
"label": "Mikrochronologische Rekonstruktion mit Kausalketten",
|
||||
"statement": "Erlebnisse werden überwiegend in ihrer tatsächlichen Reihenfolge erzählt. Übergänge benennen nicht nur, was danach geschah, sondern wodurch der nächste Schritt ausgelöst wurde: eine Frage, ein Hindernis, eine Entscheidung, eine körperliche Reaktion oder eine Planänderung. Entscheidende Szenen werden bis auf einzelne Handlungsschritte aufgelöst; ereignisarme Zwischenräume werden kurz zusammengefasst. Zeiten, Distanzen, Mengen und Dauer bleiben erhalten, wenn sie den Ablauf oder seine Bedeutung erklären.",
|
||||
"origin": "initial_build",
|
||||
"locked": false,
|
||||
"status": "active",
|
||||
"observed_from": "2024-08-11",
|
||||
"observed_to": "2026-08-24",
|
||||
"refs": [
|
||||
{
|
||||
"role": "exemplar",
|
||||
"excerpt": "Das leichte Kribbeln in der Hand veranlasste mich einen Krankenwagen zu rufen. Nachdem die Sanitäter ein völlig unauffälliges EKG geschrieben und mir versicherten, dass ich mit höchster Wahrscheinlichkeit keinen Herzinfarkt hatte, entschied ich mich nicht ins Krankenhaus zu fahren.",
|
||||
"occurred_at": "2024-10-04"
|
||||
},
|
||||
{
|
||||
"role": "exemplar",
|
||||
"excerpt": "Als ich gerade mit den Vorbereitungen fertig war, kam Sushi aus dem Schlafzimmer. Auf meine Frage, ob sie mit mir frühstücken wolle, antwortete sie, dass sie erst Brot vom Laden holen müsse. Ich versprach ihr zu warten und setzte mich solange an den Rechner für den Tagebucheintrag.",
|
||||
"occurred_at": "2026-08-24"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "evaluation_from_expectation_outcome",
|
||||
"facet_key": "core",
|
||||
"label": "Bewertung aus Erwartung und tatsächlichem Verlauf",
|
||||
"statement": "Bewertungen werden bevorzugt aus dem Unterschied zwischen Plan, Erwartung oder erster Einschätzung und dem tatsächlichen Verlauf entwickelt. Rückschläge, Fehlannahmen und überraschend gute Wendungen bilden kleine narrative Bögen. Erst nachdem die Abweichung konkret gezeigt wurde, folgt das Urteil. Das Fazit bleibt häufig gemischt oder ausgleichend, statt den gesamten Tag pauschal als gut oder schlecht festzulegen.",
|
||||
"origin": "initial_build",
|
||||
"locked": false,
|
||||
"status": "active",
|
||||
"observed_from": "2024-08-11",
|
||||
"observed_to": "2026-08-24",
|
||||
"refs": [
|
||||
{
|
||||
"role": "exemplar",
|
||||
"excerpt": "Auch wenn der Tag nicht ganz so verlief wie geplant (kein Fisch, kein Leuchtturm, keine große Tour), war es trotzdem ein richtig schöner Tag.",
|
||||
"occurred_at": "2025-06-17"
|
||||
},
|
||||
{
|
||||
"role": "exemplar",
|
||||
"excerpt": "Am Ende war es einer dieser Tage, von denen man morgens glaubt, sie würden eher unspektakulär verlaufen. Stattdessen bescherte uns ausgerechnet der vermeintliche Regentag einige der schönsten und überraschendsten Erlebnisse unseres gesamten Urlaubs.",
|
||||
"occurred_at": "2026-06-01"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "observation_based_reflection",
|
||||
"facet_key": "core",
|
||||
"label": "Reflexion aus Beobachtung und Kontrast",
|
||||
"statement": "Reflexionen beginnen bei einem konkreten Vorgang und entwickeln sich durch Vergleich, Kontrast oder eine bemerkte Wirkung. Die Verbindung zur auslösenden Szene bleibt sichtbar; allgemeine Lebensweisheiten werden nicht unvermittelt aufgesetzt. Dieses Muster tritt in den Texten von 2026 deutlich stärker und ausgearbeiteter auf und beschreibt daher vor allem die aktuelle Ausdrucksweise, ohne bereits als langfristig unveränderlich zu gelten.",
|
||||
"origin": "initial_build",
|
||||
"locked": false,
|
||||
"status": "active",
|
||||
"observed_from": "2024-08-31",
|
||||
"observed_to": "2026-08-21",
|
||||
"refs": [
|
||||
{
|
||||
"role": "exemplar",
|
||||
"excerpt": "Ich merke immer wieder, dass mich das Aufschreiben unserer täglichen Erlebnisse den vergangenen Tag noch einmal erleben lässt. Die schönen Eindrücke werden dadurch verstärkt und ich habe das Gefühl, den Urlaub und das Leben bewusster und intensiver wahrzunehmen.",
|
||||
"occurred_at": "2026-08-16"
|
||||
},
|
||||
{
|
||||
"role": "exemplar",
|
||||
"excerpt": "Was für ein Gegensatz zu dem Paar, das ich zuvor während meines Spaziergangs beobachtet hatte. Diese beiden jungen Menschen freuten sich miteinander, bestärkten sich gegenseitig und hatten ganz offensichtlich einfach Spaß.",
|
||||
"occurred_at": "2026-08-21"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "people_through_action_and_speech",
|
||||
"facet_key": "core",
|
||||
"label": "Personenzeichnung durch Handlung und Rede",
|
||||
"statement": "Personen werden vor allem darüber charakterisiert, was sie tun, verweigern, beitragen, ertragen oder wörtlich sagen. Körperliche Beschreibungen und abstrakte Charakterporträts sind selten. Positive wie negative Bewertungen folgen gewöhnlich erst nach einer konkreten Handlung oder kurzen Dialogsequenz. Beim Generieren deshalb zuerst beobachtbares Verhalten oder eine aussagekräftige Äußerung zeigen und erst danach knapp bewerten.",
|
||||
"origin": "initial_build",
|
||||
"locked": false,
|
||||
"status": "active",
|
||||
"observed_from": "2024-08-11",
|
||||
"observed_to": "2026-08-24",
|
||||
"refs": [
|
||||
{
|
||||
"role": "exemplar",
|
||||
"excerpt": "Seine Begründung: „Ich hatte Hunger.“ Trotz aller Versuche, ihm die Rücksichtslosigkeit klarzumachen, zeigte er keine Einsicht. Frustrierend!",
|
||||
"occurred_at": "2025-08-27"
|
||||
},
|
||||
{
|
||||
"role": "exemplar",
|
||||
"excerpt": "Rouven war die ganze Zeit bei mir geblieben und unterstützte mich wirklich toll.",
|
||||
"occurred_at": "2026-05-26"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "situated_emotional_directness",
|
||||
"facet_key": "core",
|
||||
"label": "Gefühle an konkreten Kippmomenten",
|
||||
"statement": "Gefühle werden direkt, aber meist knapp benannt, wenn sich eine Situation verändert: bei Enttäuschung, Sorge, Erleichterung, Angst, Dankbarkeit oder überwältigenden Eindrücken. Häufig folgen eine körperliche Wahrnehmung oder die unmittelbar nächste Handlung. Längere abstrakte Gefühlsanalysen und dauerhafte Dramatisierung vermeiden; ein kurzer Satz an der richtigen Stelle trägt die emotionale Wirkung.",
|
||||
"origin": "initial_build",
|
||||
"locked": false,
|
||||
"status": "active",
|
||||
"observed_from": "2024-08-11",
|
||||
"observed_to": "2026-08-20",
|
||||
"refs": [
|
||||
{
|
||||
"role": "exemplar",
|
||||
"excerpt": "Die Wut und die Enttäuschung bin ich den ganzen Tag nicht los geworden.",
|
||||
"occurred_at": "2024-09-07"
|
||||
},
|
||||
{
|
||||
"role": "exemplar",
|
||||
"excerpt": "Im Rückspiegel sah ich den Unfall – und das Herz rutschte mir in die Hose. Zum Glück kamen beide mit ein paar Schürfwunden davon.",
|
||||
"occurred_at": "2025-08-23"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "family_day_choreography",
|
||||
"facet_key": "autobiographical_journal",
|
||||
"label": "Familienalltag als verteilte Choreografie",
|
||||
"statement": "Der autobiografische Tag wird als Zusammenspiel mehrerer Beteiligter rekonstruiert. Der Text wechselt zwischen ich und wir und hält fest, wer fährt, wartet, schläft, kocht, hilft, sich entzieht oder eine Idee einbringt. Routinen und Aufgabenverteilungen strukturieren die Erzählung. Sinnesdetails konzentrieren sich auf besondere Natur-, Körper- und Stimmungsmomente; kleine Widrigkeiten dürfen kurz und trocken kommentiert werden. Der Abschluss bleibt ruhig und entsteht bevorzugt aus einer letzten konkreten Szene oder knappen Tagesbilanz.",
|
||||
"origin": "initial_build",
|
||||
"locked": false,
|
||||
"status": "active",
|
||||
"observed_from": "2024-08-11",
|
||||
"observed_to": "2026-08-24",
|
||||
"refs": [
|
||||
{
|
||||
"role": "exemplar",
|
||||
"excerpt": "Sushi fuhr mit Rohan, ich mit Rouven.",
|
||||
"occurred_at": "2025-08-23"
|
||||
},
|
||||
{
|
||||
"role": "exemplar",
|
||||
"excerpt": "Ich briet dann die Fische, Rouven übernahm das Braten der Burger und Sushi kümmerte sich um den Salat und das Gemüse.",
|
||||
"occurred_at": "2026-08-24"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user