From 60f0d2830d74dba3708d7a443b891d1397cbe409 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 25 Aug 2026 17:16:35 +0200 Subject: [PATCH] MVP 0,91 --- backend/config/prompts.seed.json | 2 +- backend/context_builder.py | 12 +- backend/privacy_gateway.py | 8 +- backend/profile_analysis.py | 232 ++++++ backend/profile_review.py | 709 +++++++++++++----- backend/routers/journal.py | 29 + backend/tests/test_profile_review.py | 177 ++++- backend/writing_profile_schema.py | 105 ++- backend/writing_profile_store.py | 51 ++ .../functional/documentation_index.md | 2 +- .../functional/mvp_stand_und_abgleich.md | 16 + .../architecture/technical/backend_and_api.md | 2 +- .../technical/documentation_index.md | 2 +- .../technical/memory_storage_and_offline.md | 2 +- .../technical/mvp_implementation.md | 30 +- frontend/src/pages/SettingsPage.jsx | 215 +++++- kansho-writing-profile-improved.json | 165 ++++ 17 files changed, 1502 insertions(+), 257 deletions(-) create mode 100644 backend/profile_analysis.py create mode 100644 kansho-writing-profile-improved.json diff --git a/backend/config/prompts.seed.json b/backend/config/prompts.seed.json index b4dcf1c..9a2c462 100644 --- a/backend/config/prompts.seed.json +++ b/backend/config/prompts.seed.json @@ -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" } ] diff --git a/backend/context_builder.py b/backend/context_builder.py index 68aedc8..580b44f 100644 --- a/backend/context_builder.py +++ b/backend/context_builder.py @@ -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": diff --git a/backend/privacy_gateway.py b/backend/privacy_gateway.py index 9f95318..42820f4 100644 --- a/backend/privacy_gateway.py +++ b/backend/privacy_gateway.py @@ -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 = [] diff --git a/backend/profile_analysis.py b/backend/profile_analysis.py new file mode 100644 index 0000000..1292005 --- /dev/null +++ b/backend/profile_analysis.py @@ -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." + ) diff --git a/backend/profile_review.py b/backend/profile_review.py index c668301..d242c2a 100644 --- a/backend/profile_review.py +++ b/backend/profile_review.py @@ -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), } diff --git a/backend/routers/journal.py b/backend/routers/journal.py index f7a2b79..ab3327b 100644 --- a/backend/routers/journal.py +++ b/backend/routers/journal.py @@ -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"]) diff --git a/backend/tests/test_profile_review.py b/backend/tests/test_profile_review.py index b2ab539..d4dba94 100644 --- a/backend/tests/test_profile_review.py +++ b/backend/tests/test_profile_review.py @@ -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", diff --git a/backend/writing_profile_schema.py b/backend/writing_profile_schema.py index 2fcce00..6d41c01 100644 --- a/backend/writing_profile_schema.py +++ b/backend/writing_profile_schema.py @@ -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 [ { diff --git a/backend/writing_profile_store.py b/backend/writing_profile_store.py index 8c4a6f9..c8721f2 100644 --- a/backend/writing_profile_store.py +++ b/backend/writing_profile_store.py @@ -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: diff --git a/docs/architecture/functional/documentation_index.md b/docs/architecture/functional/documentation_index.md index 5e7119c..747d07d 100644 --- a/docs/architecture/functional/documentation_index.md +++ b/docs/architecture/functional/documentation_index.md @@ -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 diff --git a/docs/architecture/functional/mvp_stand_und_abgleich.md b/docs/architecture/functional/mvp_stand_und_abgleich.md index eb3af04..624035d 100644 --- a/docs/architecture/functional/mvp_stand_und_abgleich.md +++ b/docs/architecture/functional/mvp_stand_und_abgleich.md @@ -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**. diff --git a/docs/architecture/technical/backend_and_api.md b/docs/architecture/technical/backend_and_api.md index 7b52025..e967c9f 100644 --- a/docs/architecture/technical/backend_and_api.md +++ b/docs/architecture/technical/backend_and_api.md @@ -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 `![…](kansho-media:)` - 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. diff --git a/docs/architecture/technical/documentation_index.md b/docs/architecture/technical/documentation_index.md index d5e0311..5f0ec20 100644 --- a/docs/architecture/technical/documentation_index.md +++ b/docs/architecture/technical/documentation_index.md @@ -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 diff --git a/docs/architecture/technical/memory_storage_and_offline.md b/docs/architecture/technical/memory_storage_and_offline.md index 79153bd..f94cbae 100644 --- a/docs/architecture/technical/memory_storage_and_offline.md +++ b/docs/architecture/technical/memory_storage_and_offline.md @@ -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. diff --git a/docs/architecture/technical/mvp_implementation.md b/docs/architecture/technical/mvp_implementation.md index 5587134..ba097f1 100644 --- a/docs/architecture/technical/mvp_implementation.md +++ b/docs/architecture/technical/mvp_implementation.md @@ -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` diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index ca2e898..ef33f63 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -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() {
-

{profile?.confirmed ? 'Profile Review' : 'Initial Profile Build'}

+

Profilanalyse

- {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.

Version {review?.version ?? profile?.version ?? 0} @@ -450,31 +553,85 @@ export default function SettingsPage() { ))} )} +

+