"""Writing profile store with facets, governance, and evidence. AI drafts are never a positive style source.""" from __future__ import annotations import json import uuid from db import get_db, row_to_dict from dialogue_store import StoreError from journal_body import plain_text from profile_documents import ( FORMAT_VERSION, KIND_WRITING, ORIGIN_WRITING, exported_at, writing_payload, ) from writing_profile_infer import ( MIN_STYLE_CHARS, compile_style_signals, ) from writing_profile_schema import ( EXISTING_BEFORE_NEW, LAYERS, LEGACY_STYLE_KEYS, LIFECYCLE, ORIGINS, SEED_FACETS, SEED_TRAIT_HINTS, coerce_slug, facet_label, facet_layer, hint_for_context, normalize_facet_key, recency_role, recency_weight, seed_catalog, valid_slug, ) MAX_ENTRY_SOURCES = 64 MAX_IMPORT_SOURCES = 32 BRIEF_ENTRY_EXCERPTS = 8 BRIEF_IMPORT_EXCERPTS = 6 INITIAL_BUILD_SOURCES = 40 EXCERPT_CHARS = 1200 TRAIT_EXCERPT_CHARS = 280 DIALOGUE_KIND = "dialogue_style" MAX_DIALOGUE_MESSAGES = 8 MAX_DIALOGUE_CHARS = 500 DIALOGUE_WEIGHT = 0.25 ENTRY_WEIGHT = 1.0 USER_EDIT_WEIGHT = 1.2 ACCEPTED_DRAFT_WEIGHT = 0.7 GOVERNANCE = {"learning", "advising", "frozen"} __all__ = [ "GOVERNANCE", "LIFECYCLE", "SEED_FACETS", "compile_style_signals", "get_profile", "import_text", "import_corpus", "remember_journal_entry", "remember_dialogue_style", "recent_user_bodies", "rebuild_brief", "refresh_profile", "bootstrap_from_existing", "set_governance", "update_facet", "update_trait", "accept_suggestion", "reject_suggestion", "export_document", "restore_document", "has_facets", "has_confirmed_profile", "set_lifecycle", "list_corpus", "snapshot_version", "upsert_trait", "replace_trait_refs", "EXISTING_BEFORE_NEW", "seed_catalog", "INITIAL_BUILD_SOURCES", ] def ensure_profile(profile_id: str) -> dict: with get_db() as conn: row = row_to_dict( conn.execute("SELECT * FROM writing_profiles WHERE profile_id = ?", (profile_id,)).fetchone() ) if row: return row conn.execute( """ INSERT INTO writing_profiles (profile_id, compiled_brief, governance, lifecycle, version, review_ready) VALUES (?, '', 'learning', 'uninitialized', 0, 0) """, (profile_id,), ) return row_to_dict( conn.execute("SELECT * FROM writing_profiles WHERE profile_id = ?", (profile_id,)).fetchone() ) def _parse_json(raw, fallback): if not raw: return fallback if isinstance(raw, (dict, list)): return raw try: data = json.loads(raw) except json.JSONDecodeError: return fallback return data if data is not None else fallback def get_profile(profile_id: str) -> dict: ensure_profile(profile_id) with get_db() as conn: row = row_to_dict( conn.execute("SELECT * FROM writing_profiles WHERE profile_id = ?", (profile_id,)).fetchone() ) sources = [ row_to_dict(item) for item in conn.execute( "SELECT * FROM writing_profile_sources WHERE profile_id = ? ORDER BY COALESCE(occurred_at, created) DESC", (profile_id,), ).fetchall() ] facets = [ row_to_dict(item) for item in conn.execute( """ SELECT * FROM writing_profile_facets WHERE profile_id = ? ORDER BY CASE layer WHEN 'core' THEN 0 WHEN 'context' THEN 1 ELSE 2 END, facet_key """, (profile_id,), ).fetchall() ] traits = _load_traits(conn, profile_id) suggestions = [ row_to_dict(item) for item in conn.execute( """ SELECT * FROM writing_profile_suggestions WHERE profile_id = ? AND status = 'pending' ORDER BY created DESC """, (profile_id,), ).fetchall() ] for item in sources: item["recency_weight"] = recency_weight(item.get("occurred_at") or item.get("created")) item["recency_role"] = recency_role(item.get("occurred_at") or item.get("created")) for item in facets: key = normalize_facet_key(item.get("facet_key") or "") item["facet_key"] = key item["label"] = facet_label(key) item["layer"] = item.get("layer") or facet_layer(key) item["locked"] = bool(item.get("locked")) item["traits"] = [trait for trait in traits if trait.get("facet_key") == key and trait.get("status") == "active"] core = next((item for item in facets if item.get("layer") == "core" or item.get("facet_key") == "core"), None) pending_evidence = 0 versions = [] with get_db() as conn: try: pending_evidence = conn.execute( "SELECT COUNT(*) AS c FROM writing_profile_evidence WHERE profile_id = ? AND status = 'pending'", (profile_id,), ).fetchone()["c"] versions = [ row_to_dict(item) for item in conn.execute( """ SELECT seq, cause, created FROM writing_profile_versions WHERE profile_id = ? ORDER BY seq DESC LIMIT 8 """, (profile_id,), ).fetchall() ] except Exception: pending_evidence = 0 versions = [] lifecycle = (row or {}).get("lifecycle") or "uninitialized" return { "profile_id": profile_id, "compiled_brief": (row or {}).get("compiled_brief") or "", "governance": (row or {}).get("governance") or "learning", "lifecycle": lifecycle, "confirmed": lifecycle == "confirmed", "version": (row or {}).get("version") or 0, "review_ready": bool((row or {}).get("review_ready")), "last_reviewed": (row or {}).get("last_reviewed"), "updated": (row or {}).get("updated"), "core": core, "sources": sources, "facets": facets, "traits": [item for item in traits if item.get("status") == "active"], "suggestions": suggestions, "pending_evidence": pending_evidence, "versions": versions, "seed_catalog": seed_catalog(), "note": ( "Semantische Traits entstehen durch Review, nicht durch Wortzählungen. " "Kontinuierliches Lernen erst nach bestätigtem Initial Profile Build." if lifecycle != "confirmed" else "Profilhülle bestätigt. Neue Evidenz geht in die Review-Queue, nicht in ein Blind-Reinfer." ), } def _load_traits(conn, profile_id: str) -> list[dict]: try: rows = [ row_to_dict(item) for item in conn.execute( """ SELECT * FROM writing_profile_traits WHERE profile_id = ? ORDER BY facet_key, slug """, (profile_id,), ).fetchall() ] except Exception: return [] for item in rows: item["locked"] = bool(item.get("locked")) item["label"] = item.get("label") or item.get("slug") try: item["refs"] = [ row_to_dict(ref) for ref in conn.execute( """ SELECT * FROM writing_profile_trait_refs WHERE trait_id = ? ORDER BY role, created """, (item["id"],), ).fetchall() ] except Exception: item["refs"] = [] item["exemplars"] = [ref for ref in item["refs"] if ref.get("role") == "exemplar"] item["evidence_refs"] = [ref for ref in item["refs"] if ref.get("role") == "evidence"] return rows def set_governance(profile_id: str, governance: str) -> dict: mode = (governance or "").strip() if mode not in GOVERNANCE: raise StoreError("invalid_governance", "governance muss learning, advising oder frozen sein") ensure_profile(profile_id) with get_db() as conn: conn.execute( "UPDATE writing_profiles SET governance = ?, updated = datetime('now') WHERE profile_id = ?", (mode, profile_id), ) return get_profile(profile_id) def update_facet(profile_id: str, facet_key: str, *, value: str | None = None, locked: bool | None = None) -> dict: key = normalize_facet_key(facet_key) if key in LEGACY_STYLE_KEYS: return update_trait( profile_id, key, statement=value, locked=locked, facet_key="core", label=key, ) if not valid_slug(key) and key not in SEED_FACETS: raise StoreError("unknown_facet", "Facet-Schlüssel muss ein Daten-Slug sein.") ensure_profile(profile_id) layer = facet_layer(key, "core" if key == "core" else "context") with get_db() as conn: current = row_to_dict( conn.execute( "SELECT * FROM writing_profile_facets WHERE profile_id = ? AND facet_key = ?", (profile_id, key), ).fetchone() ) next_value = value if value is not None else (current or {}).get("value") or "" next_locked = int(locked) if locked is not None else int((current or {}).get("locked") or 0) next_origin = "manual" if value is not None or not current else ((current.get("origin") or "manual")) if next_origin not in ORIGINS: next_origin = "manual" if current: conn.execute( """ UPDATE writing_profile_facets SET value = ?, locked = ?, origin = ?, layer = ?, updated = datetime('now') WHERE id = ? """, (next_value, next_locked, next_origin, layer, current["id"]), ) else: conn.execute( """ INSERT INTO writing_profile_facets (id, profile_id, facet_key, layer, value, evidence, origin, locked) VALUES (?, ?, ?, ?, ?, 'manuelle Korrektur', 'manual', ?) """, (str(uuid.uuid4()), profile_id, key, layer, next_value, next_locked), ) _assemble_brief(profile_id) snapshot_version(profile_id, cause="manual_facet") return get_profile(profile_id) def update_trait( profile_id: str, slug: str, *, statement: str | None = None, locked: bool | None = None, facet_key: str | None = None, label: str | None = None, ) -> dict: ensure_profile(profile_id) trait_slug = coerce_slug(slug) upsert_trait( profile_id, slug=trait_slug, facet_key=normalize_facet_key(facet_key) or "core", label=label or trait_slug, statement=statement, locked=locked, origin="manual", force=True, ) _assemble_brief(profile_id) snapshot_version(profile_id, cause="manual_trait") return get_profile(profile_id) def _ensure_layer(profile_id: str, facet_key: str, *, origin: str = "manual") -> None: key = normalize_facet_key(facet_key) or "core" layer = facet_layer(key) with get_db() as conn: current = row_to_dict( conn.execute( "SELECT id FROM writing_profile_facets WHERE profile_id = ? AND facet_key = ?", (profile_id, key), ).fetchone() ) if current: return conn.execute( """ INSERT INTO writing_profile_facets (id, profile_id, facet_key, layer, value, evidence, origin, locked) VALUES (?, ?, ?, ?, '', '', ?, 0) """, (str(uuid.uuid4()), profile_id, key, layer, origin if origin in ORIGINS else "manual"), ) def upsert_trait( profile_id: str, *, slug: str, facet_key: str = "core", label: str = "", statement: str | None = None, locked: bool | None = None, origin: str = "manual", status: str = "active", observed_from: str | None = None, observed_to: str | None = None, force: bool = False, ) -> str: """Returns applied | skipped.""" ensure_profile(profile_id) trait_slug = coerce_slug(slug) key = normalize_facet_key(facet_key) or "core" _ensure_layer(profile_id, key, origin=origin) origin = origin if origin in ORIGINS else "manual" with get_db() as conn: current = row_to_dict( conn.execute( "SELECT * FROM writing_profile_traits WHERE profile_id = ? AND slug = ?", (profile_id, trait_slug), ).fetchone() ) if current and int(current.get("locked") or 0) and not force: return "skipped" if current and current.get("origin") in {"manual", "accepted_suggestion"} and not force and statement is not None: if origin != "manual": return "skipped" next_statement = statement if statement is not None else (current or {}).get("statement") or "" next_locked = int(locked) if locked is not None else int((current or {}).get("locked") or 0) next_label = label or (current or {}).get("label") or trait_slug if current: conn.execute( """ UPDATE writing_profile_traits SET facet_key = ?, label = ?, statement = ?, origin = ?, locked = ?, status = ?, observed_from = COALESCE(?, observed_from), observed_to = COALESCE(?, observed_to), updated = datetime('now') WHERE id = ? """, ( key, next_label, next_statement, origin, next_locked, status, observed_from, observed_to, current["id"], ), ) return "applied" conn.execute( """ INSERT INTO writing_profile_traits (id, profile_id, facet_key, slug, label, statement, origin, locked, status, observed_from, observed_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( str(uuid.uuid4()), profile_id, key, trait_slug, next_label, next_statement, origin, next_locked, status, observed_from, observed_to, ), ) return "applied" def replace_trait_refs(profile_id: str, slug: str, refs: list[dict] | None) -> None: if refs is None: return trait_slug = coerce_slug(slug) with get_db() as conn: trait = row_to_dict( conn.execute( "SELECT id FROM writing_profile_traits WHERE profile_id = ? AND slug = ?", (profile_id, trait_slug), ).fetchone() ) if not trait: return conn.execute("DELETE FROM writing_profile_trait_refs WHERE trait_id = ?", (trait["id"],)) for item in refs[:8]: role = (item.get("role") or "evidence").strip() if role not in {"evidence", "exemplar"}: continue excerpt = plain_text(item.get("excerpt") or "")[:TRAIT_EXCERPT_CHARS] if not excerpt: continue conn.execute( """ INSERT INTO writing_profile_trait_refs (id, trait_id, source_id, role, excerpt, occurred_at) VALUES (?, ?, ?, ?, ?, ?) """, ( str(uuid.uuid4()), trait["id"], item.get("source_id"), role, excerpt, item.get("occurred_at"), ), ) def retire_trait(profile_id: str, slug: str, status: str = "retired") -> None: if status not in {"merged", "split", "retired", "active"}: status = "retired" with get_db() as conn: conn.execute( """ UPDATE writing_profile_traits SET status = ?, updated = datetime('now') WHERE profile_id = ? AND slug = ? """, (status, profile_id, coerce_slug(slug)), ) def accept_suggestion(profile_id: str, suggestion_id: str) -> dict: with get_db() as conn: row = row_to_dict( conn.execute( """ SELECT * FROM writing_profile_suggestions WHERE id = ? AND profile_id = ? AND status = 'pending' """, (suggestion_id, profile_id), ).fetchone() ) if not row: raise StoreError("not_found", "Vorschlag nicht gefunden", 404) payload = _parse_json(row.get("payload_json"), {}) slug = (row.get("trait_slug") or payload.get("slug") or row.get("facet_key") or "").strip() if slug in LEGACY_STYLE_KEYS or (valid_slug(slug) and slug not in SEED_FACETS and slug != "core"): upsert_trait( profile_id, slug=coerce_slug(slug), facet_key=normalize_facet_key(payload.get("facet_key") or row.get("facet_key") or "core"), label=payload.get("label") or slug, statement=row.get("proposed_value") or payload.get("statement") or "", origin="accepted_suggestion", force=True, ) else: _upsert_facet( profile_id, normalize_facet_key(row["facet_key"] or "core"), row.get("proposed_value") or "", evidence=row.get("evidence") or "", origin="accepted_suggestion", force=True, ) with get_db() as conn: conn.execute( "UPDATE writing_profile_suggestions SET status = 'accepted', resolved = datetime('now') WHERE id = ?", (suggestion_id,), ) _assemble_brief(profile_id) snapshot_version(profile_id, cause="accepted_suggestion") return get_profile(profile_id) def reject_suggestion(profile_id: str, suggestion_id: str) -> dict: with get_db() as conn: row = row_to_dict( conn.execute( """ SELECT * FROM writing_profile_suggestions WHERE id = ? AND profile_id = ? AND status = 'pending' """, (suggestion_id, profile_id), ).fetchone() ) if not row: raise StoreError("not_found", "Vorschlag nicht gefunden", 404) conn.execute( "UPDATE writing_profile_suggestions SET status = 'rejected', resolved = datetime('now') WHERE id = ?", (suggestion_id,), ) return get_profile(profile_id) def export_document(profile_id: str) -> dict: profile = get_profile(profile_id) return { "kind": KIND_WRITING, "format_version": FORMAT_VERSION, "exported_at": exported_at(), "governance": profile.get("governance") or "learning", "lifecycle": profile.get("lifecycle") or "uninitialized", "facets": [ { "facet_key": item.get("facet_key"), "layer": item.get("layer") or facet_layer(item.get("facet_key") or ""), "value": item.get("value") or "", "evidence": item.get("evidence") or "", "origin": item.get("origin") or "imported", "locked": bool(item.get("locked")), } for item in profile.get("facets") or [] if item.get("facet_key") ], "traits": [ { "slug": item.get("slug"), "facet_key": item.get("facet_key") or "core", "label": item.get("label") or "", "statement": item.get("statement") or "", "origin": item.get("origin") or "imported", "locked": bool(item.get("locked")), "status": item.get("status") or "active", "observed_from": item.get("observed_from"), "observed_to": item.get("observed_to"), "refs": [ { "role": ref.get("role"), "excerpt": ref.get("excerpt") or "", "occurred_at": ref.get("occurred_at"), } for ref in item.get("refs") or [] if ref.get("excerpt") ], } for item in profile.get("traits") or [] if item.get("slug") ], } def restore_document(profile_id: str, document: dict) -> dict: """Replace layers/traits and governance from an explicit user restore. Journal sources stay.""" payload = writing_payload(document) governance = (payload.get("governance") or "learning").strip() if governance not in GOVERNANCE: raise StoreError("invalid_governance", "governance muss learning, advising oder frozen sein") facets = payload.get("facets") traits_in = payload.get("traits") or [] if facets is None and not traits_in: raise StoreError("invalid_document", "Writing-Profile-Dokument braucht facets oder traits.") if facets is None: facets = [] if not isinstance(facets, list) or not isinstance(traits_in, list): raise StoreError("invalid_document", "facets und traits müssen Listen sein.") prepared: list[dict] = [] prepared_traits: list[dict] = [] seen: set[str] = set() for item in facets: if not isinstance(item, dict): raise StoreError("invalid_document", "Jede Facet muss ein Objekt sein.") key = normalize_facet_key(item.get("facet_key") or "") if not key: raise StoreError("unknown_facet", "Facet ohne Schlüssel.") if key in LEGACY_STYLE_KEYS: prepared_traits.append( { "slug": coerce_slug(key), "facet_key": "core", "label": item.get("label") or key, "statement": item.get("value") or "", "origin": item.get("origin") or "imported", "locked": 1 if item.get("locked") else 0, "refs": [], } ) continue if key in seen: raise StoreError("duplicate_facet", f"Facet {key} ist doppelt.") origin = (item.get("origin") or "imported").strip() if origin not in ORIGIN_WRITING or origin == "inferred": origin = "imported" seen.add(key) prepared.append( { "facet_key": key, "layer": item.get("layer") if item.get("layer") in LAYERS else facet_layer(key), "value": item.get("value") or "", "evidence": item.get("evidence") or "aus Profil-Dokument", "origin": origin, "locked": 1 if item.get("locked") else 0, } ) unique_traits: list[dict] = [] seen_slugs: set[str] = set() for item in list(traits_in) + list(prepared_traits): if not isinstance(item, dict): raise StoreError("invalid_document", "Jeder Trait muss ein Objekt sein.") slug = coerce_slug(item.get("slug") or "") if not slug or slug in seen_slugs: continue seen_slugs.add(slug) origin = (item.get("origin") or "imported").strip() if origin not in ORIGINS: origin = "imported" unique_traits.append( { "slug": slug, "facet_key": normalize_facet_key(item.get("facet_key") or "core") or "core", "label": item.get("label") or slug, "statement": item.get("statement") or item.get("value") or "", "origin": origin, "locked": 1 if item.get("locked") else 0, "refs": item.get("refs") or [], } ) ensure_profile(profile_id) lifecycle = (payload.get("lifecycle") or "").strip() if lifecycle not in LIFECYCLE: lifecycle = "confirmed" if unique_traits or prepared else "uninitialized" with get_db() as conn: conn.execute( """ UPDATE writing_profiles SET governance = ?, lifecycle = ?, updated = datetime('now') WHERE profile_id = ? """, (governance, lifecycle, profile_id), ) conn.execute("DELETE FROM writing_profile_facets WHERE profile_id = ?", (profile_id,)) conn.execute("DELETE FROM writing_profile_traits WHERE profile_id = ?", (profile_id,)) conn.execute( """ UPDATE writing_profile_suggestions SET status = 'rejected', resolved = datetime('now') WHERE profile_id = ? AND status = 'pending' """, (profile_id,), ) for item in prepared: conn.execute( """ INSERT INTO writing_profile_facets (id, profile_id, facet_key, layer, value, evidence, origin, locked) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( str(uuid.uuid4()), profile_id, item["facet_key"], item["layer"], item["value"], item["evidence"], item["origin"], item["locked"], ), ) for item in unique_traits: upsert_trait( profile_id, slug=item["slug"], facet_key=item["facet_key"], label=item["label"], statement=item["statement"], locked=bool(item["locked"]), origin=item["origin"], force=True, ) replace_trait_refs(profile_id, item["slug"], item.get("refs")) _assemble_brief(profile_id) snapshot_version(profile_id, cause="restore") return get_profile(profile_id) def _insert_imported_source( profile_id: str, body: str, *, occurred_at: str | None = None, context_hint: str | None = None, ) -> str: text = (body or "").strip() if not text: raise StoreError("empty_body", "Importtext darf nicht leer sein") source_id = str(uuid.uuid4()) hint = hint_for_context(context_hint) with get_db() as conn: conn.execute( """ INSERT INTO writing_profile_sources (id, profile_id, kind, body, weight, occurred_at, context_hint) VALUES (?, ?, 'imported_text', ?, 0.8, ?, ?) """, (source_id, profile_id, text, occurred_at, hint), ) return source_id def import_text( profile_id: str, body: str, *, occurred_at: str | None = None, context_hint: str | None = None, title: str | None = None, ) -> dict: _insert_imported_source(profile_id, body, occurred_at=occurred_at, context_hint=context_hint) return refresh_profile(profile_id) def import_corpus(profile_id: str, items: list[dict]) -> dict: if not isinstance(items, list) or not items: raise StoreError("empty_corpus", "Korpus darf nicht leer sein.") added = 0 for item in items: if not isinstance(item, dict): continue body = (item.get("body") or item.get("text") or "").strip() if not body: continue _insert_imported_source( profile_id, body, occurred_at=item.get("occurred_at") or item.get("date"), context_hint=item.get("context_hint") or item.get("context"), ) added += 1 if not added: raise StoreError("empty_corpus", "Korpus enthielt keine Texte.") return refresh_profile(profile_id) def remember_journal_entry(profile_id: str, entry_id: str, body: str, origin: str | None = None) -> None: text = plain_text(body or "").strip() if not text: return weight = USER_EDIT_WEIGHT if origin == "user_edit" else ENTRY_WEIGHT if origin == "accepted_draft": weight = ACCEPTED_DRAFT_WEIGHT occurred_at = None with get_db() as conn: day = row_to_dict( conn.execute( """ SELECT d.calendar_date FROM journal_entries e JOIN journal_days d ON d.id = e.journal_day_id WHERE e.id = ? AND e.profile_id = ? """, (entry_id, profile_id), ).fetchone() ) occurred_at = (day or {}).get("calendar_date") existing = row_to_dict( conn.execute( """ SELECT id FROM writing_profile_sources WHERE profile_id = ? AND kind = 'journal_entry' AND entry_id = ? """, (profile_id, entry_id), ).fetchone() ) if existing: conn.execute( """ UPDATE writing_profile_sources SET body = ?, weight = ?, occurred_at = COALESCE(?, occurred_at) WHERE id = ? """, (text, weight, occurred_at, existing["id"]), ) else: conn.execute( """ INSERT INTO writing_profile_sources (id, profile_id, kind, body, entry_id, weight, occurred_at, context_hint) VALUES (?, ?, 'journal_entry', ?, ?, ?, ?, 'autobiographical_journal') """, (str(uuid.uuid4()), profile_id, text, entry_id, weight, occurred_at), ) def _style_worthy(text: str) -> bool: cleaned = (text or "").strip() return len(cleaned) >= MIN_STYLE_CHARS or len(cleaned.split()) >= 12 def recent_user_bodies( profile_id: str, limit: int = 16, exclude_conversation_ids: list[str] | None = None, ) -> list[str]: excluded = [item for item in (exclude_conversation_ids or []) if item] with get_db() as conn: if excluded: placeholders = ",".join("?" * len(excluded)) rows = conn.execute( f""" SELECT body FROM messages WHERE profile_id = ? AND role = 'user' AND conversation_id NOT IN ({placeholders}) ORDER BY created DESC LIMIT ? """, (profile_id, *excluded, limit), ).fetchall() else: rows = conn.execute( """ SELECT body FROM messages WHERE profile_id = ? AND role = 'user' ORDER BY created DESC LIMIT ? """, (profile_id, limit), ).fetchall() return [row["body"] for row in reversed(rows)] def _clear_dialogue_style(profile_id: str) -> None: with get_db() as conn: conn.execute( "DELETE FROM writing_profile_sources WHERE profile_id = ? AND kind = ?", (profile_id, DIALOGUE_KIND), ) def remember_dialogue_style( profile_id: str, bodies: list[str] | None = None, exclude_conversation_ids: list[str] | None = None, ) -> dict: raw = ( bodies if bodies is not None else recent_user_bodies(profile_id, exclude_conversation_ids=exclude_conversation_ids) ) picked = [item.strip() for item in raw if _style_worthy(item)] if not picked: picked = [item.strip() for item in raw if (item or "").strip()] picked = picked[-MAX_DIALOGUE_MESSAGES:] text = "\n\n".join(picked).strip() if len(text) > MAX_DIALOGUE_CHARS: text = text[-MAX_DIALOGUE_CHARS:].lstrip() if not text: _clear_dialogue_style(profile_id) _assemble_brief(profile_id) return get_profile(profile_id) with get_db() as conn: existing = row_to_dict( conn.execute( """ SELECT id FROM writing_profile_sources WHERE profile_id = ? AND kind = ? """, (profile_id, DIALOGUE_KIND), ).fetchone() ) if existing: conn.execute( "UPDATE writing_profile_sources SET body = ?, weight = ? WHERE id = ?", (text, DIALOGUE_WEIGHT, existing["id"]), ) else: conn.execute( """ INSERT INTO writing_profile_sources (id, profile_id, kind, body, weight) VALUES (?, ?, ?, ?, ?) """, (str(uuid.uuid4()), profile_id, DIALOGUE_KIND, text, DIALOGUE_WEIGHT), ) _assemble_brief(profile_id) return get_profile(profile_id) def _load_ranked_sources(profile_id: str) -> dict[str, list[dict]]: with get_db() as conn: entries = [ row_to_dict(row) for row in conn.execute( """ SELECT * FROM writing_profile_sources WHERE profile_id = ? AND kind = 'journal_entry' ORDER BY COALESCE(occurred_at, created) DESC, weight DESC LIMIT ? """, (profile_id, MAX_ENTRY_SOURCES), ).fetchall() ] imports = [ row_to_dict(row) for row in conn.execute( """ SELECT * FROM writing_profile_sources WHERE profile_id = ? AND kind = 'imported_text' ORDER BY COALESCE(occurred_at, created) DESC LIMIT ? """, (profile_id, MAX_IMPORT_SOURCES), ).fetchall() ] dialogue = [ row_to_dict(row) for row in conn.execute( """ SELECT * FROM writing_profile_sources WHERE profile_id = ? AND kind = ? ORDER BY created DESC LIMIT 1 """, (profile_id, DIALOGUE_KIND), ).fetchall() ] return {"journal_entry": entries, "imported_text": imports, "dialogue_style": dialogue} def _source_line(item: dict) -> str: when = (item.get("occurred_at") or "")[:10] role = recency_role(item.get("occurred_at") or item.get("created")) prefix = f"[{when} · {role}] " if when else f"[{role}] " return prefix + plain_text(item.get("body") or "")[:EXCERPT_CHARS] def _assemble_brief(profile_id: str) -> None: ranked = _load_ranked_sources(profile_id) entries = ranked["journal_entry"][:BRIEF_ENTRY_EXCERPTS] imports = ranked["imported_text"][:BRIEF_IMPORT_EXCERPTS] dialogue = ranked["dialogue_style"] profile_row = ensure_profile(profile_id) with get_db() as conn: facets = [ row_to_dict(item) for item in conn.execute( "SELECT facet_key, layer, value FROM writing_profile_facets WHERE profile_id = ?", (profile_id,), ).fetchall() ] traits = _load_traits(conn, profile_id) parts = [ "Stilquellen in Priorität: finale Nutzerfassungen vor importierten Texten vor Dialogmerkmalen. " "Finale, selbst redigierte Journaltexte sind die Stimme. " "Sobald solche Fassungen existieren, sind Dialogzeilen nur Inhalt, nicht die Schreibweise. " "Ein KI-Draft ist keine positive Stilreferenz. " "Lokale Heuristiken sind Messhilfe, nicht das Profil." ] if (profile_row.get("lifecycle") or "uninitialized") != "confirmed": parts.append("Writing Profile noch nicht bestätigt. Stimme vorerst aus Quellenexzerpten, nicht aus gezählten Stilfacetten.") core = next((item for item in facets if (item.get("facet_key") == "core" or item.get("layer") == "core") and (item.get("value") or "")), None) if core: parts.append("Core: " + (core.get("value") or "")) active = [item for item in traits if item.get("status") == "active" and (item.get("statement") or "")] if active: parts.append("Semantische Traits:") for item in active: line = f"- {item.get('label') or item.get('slug')} ({item.get('facet_key')}): {item.get('statement')}" exemplars = [ref.get("excerpt") for ref in item.get("exemplars") or [] if ref.get("excerpt")] if exemplars: line += " Beispiel: " + exemplars[0][:TRAIT_EXCERPT_CHARS] parts.append(line) for item in facets: key = item.get("facet_key") if key in {"core"} or not (item.get("value") or ""): continue parts.append(f"{facet_label(key)}: {item.get('value')}") if entries: parts.append("Finale Journal Entries (verbindliche Stimme, höchstes Gewicht; neuere Texte stärker für die aktuelle Ausprägung):") for item in entries: excerpt = _source_line(item) if excerpt: parts.append(excerpt) if imports: parts.append("Importierte eigene Texte (historischer Korpus behält Zeitbezug):") for item in imports: excerpt = _source_line(item) if excerpt: parts.append(excerpt) if dialogue and not entries: parts.append("Formulierungen aus dem Dialog (nur user:, schwächer als finale Fassungen):") for item in dialogue: excerpt = plain_text(item.get("body") or "")[:EXCERPT_CHARS] if excerpt: parts.append(excerpt) elif dialogue and entries: parts.append("Dialogmerkmale nur als schwacher Hinweis, nicht als Stimme.") brief = "\n\n".join(part for part in parts if part).strip() if not entries and not imports and not dialogue and not active and not any(item.get("value") for item in facets): brief = "" with get_db() as conn: conn.execute( """ UPDATE writing_profiles SET compiled_brief = ?, updated = datetime('now') WHERE profile_id = ? """, (brief, profile_id), ) 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: row = conn.execute( "SELECT 1 FROM writing_profile_facets WHERE profile_id = ? LIMIT 1", (profile_id,), ).fetchone() trait = conn.execute( "SELECT 1 FROM writing_profile_traits WHERE profile_id = ? AND status = 'active' LIMIT 1", (profile_id,), ).fetchone() return bool(row or trait) def has_confirmed_profile(profile_id: str) -> bool: row = ensure_profile(profile_id) return (row.get("lifecycle") or "uninitialized") == "confirmed" def set_lifecycle(profile_id: str, lifecycle: str) -> dict: mode = (lifecycle or "").strip() if mode not in LIFECYCLE: raise StoreError("invalid_lifecycle", "lifecycle muss uninitialized, initial_pending oder confirmed sein") ensure_profile(profile_id) with get_db() as conn: conn.execute( "UPDATE writing_profiles SET lifecycle = ?, updated = datetime('now') WHERE profile_id = ?", (mode, profile_id), ) _assemble_brief(profile_id) snapshot_version(profile_id, cause="lifecycle") return get_profile(profile_id) def list_corpus(profile_id: str, limit: int = INITIAL_BUILD_SOURCES) -> list[dict]: ensure_profile(profile_id) with get_db() as conn: rows = [ row_to_dict(item) for item in conn.execute( """ SELECT * FROM writing_profile_sources WHERE profile_id = ? AND kind IN ('journal_entry', 'imported_text') ORDER BY COALESCE(occurred_at, created) DESC LIMIT ? """, (profile_id, limit), ).fetchall() ] for item in rows: item["recency_weight"] = recency_weight(item.get("occurred_at") or item.get("created")) item["recency_role"] = recency_role(item.get("occurred_at") or item.get("created")) hint = hint_for_context(item.get("context_hint")) item["facet_hint"] = hint or ( "autobiographical_journal" if item.get("kind") == "journal_entry" else "" ) return rows def snapshot_version(profile_id: str, cause: str = "") -> int: ensure_profile(profile_id) with get_db() as conn: row = row_to_dict( conn.execute("SELECT * FROM writing_profiles WHERE profile_id = ?", (profile_id,)).fetchone() ) facets = [ { "facet_key": item["facet_key"], "layer": item.get("layer") or facet_layer(item["facet_key"]), "value": item.get("value") or "", "evidence": item.get("evidence") or "", "origin": item.get("origin") or "manual", "locked": bool(item.get("locked")), } for item in ( row_to_dict(entry) for entry in conn.execute( "SELECT facet_key, layer, value, evidence, origin, locked FROM writing_profile_facets WHERE profile_id = ?", (profile_id,), ).fetchall() ) ] traits = [ { "slug": item.get("slug"), "facet_key": item.get("facet_key"), "statement": item.get("statement") or "", "origin": item.get("origin") or "manual", "status": item.get("status") or "active", } for item in _load_traits(conn, profile_id) ] seq = int((row or {}).get("version") or 0) + 1 try: conn.execute( """ INSERT INTO writing_profile_versions (id, profile_id, seq, governance, lifecycle, facets_json, traits_json, compiled_brief, cause) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( str(uuid.uuid4()), profile_id, seq, (row or {}).get("governance") or "learning", (row or {}).get("lifecycle") or "uninitialized", json.dumps(facets, ensure_ascii=False), json.dumps(traits, ensure_ascii=False), (row or {}).get("compiled_brief") or "", cause, ), ) except Exception: conn.execute( """ INSERT INTO writing_profile_versions (id, profile_id, seq, governance, facets_json, compiled_brief, cause) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( str(uuid.uuid4()), profile_id, seq, (row or {}).get("governance") or "learning", json.dumps(facets, ensure_ascii=False), (row or {}).get("compiled_brief") or "", cause, ), ) conn.execute( "UPDATE writing_profiles SET version = ?, updated = datetime('now') WHERE profile_id = ?", (seq, profile_id), ) return seq def refresh_profile(profile_id: str) -> dict: """Assemble brief from sources and confirmed traits. Never infers semantic traits.""" ensure_profile(profile_id) _assemble_brief(profile_id) return get_profile(profile_id) def rebuild_brief(profile_id: str, *, force: bool = True) -> dict: """Explicit local rebuild of the derived brief. Not an AI review and not trait inference.""" return refresh_profile(profile_id) def seed_sources_from_entries(profile_id: str) -> int: """Copy current saved entries into style sources. Never uses unsaved drafts.""" added = 0 with get_db() as conn: rows = conn.execute( """ SELECT e.id AS entry_id, e.deleted_at, v.body, v.origin FROM journal_entries e JOIN journal_entry_versions v ON v.id = e.current_version_id WHERE e.profile_id = ? AND e.deleted_at IS NULL ORDER BY e.created """, (profile_id,), ).fetchall() for row in rows: body = plain_text(row["body"] or "").strip() if not body: continue remember_journal_entry(profile_id, row["entry_id"], body, origin=row["origin"]) added += 1 return added def bootstrap_from_existing(profile_id: str | None = None) -> dict: """Idempotent backfill of sources. Does not invent semantic traits from counts.""" if profile_id: ids = [profile_id] else: with get_db() as conn: ids = [item["id"] for item in conn.execute("SELECT id FROM profiles").fetchall()] result = {} for item in ids: seed_sources_from_entries(item) profile = refresh_profile(item) result[item] = { "sources": len(profile.get("sources") or []), "facets": len(profile.get("facets") or []), "traits": len(profile.get("traits") or []), "lifecycle": profile.get("lifecycle"), "governance": profile.get("governance"), } return result def _upsert_facet( profile_id: str, facet_key: str, value: str, *, evidence: str, origin: str, force: bool = False, ) -> str: """Returns applied | skipped | suggested.""" ensure_profile(profile_id) key = normalize_facet_key(facet_key) layer = facet_layer(key) origin = origin if origin in ORIGINS else "manual" with get_db() as conn: current = row_to_dict( conn.execute( "SELECT * FROM writing_profile_facets WHERE profile_id = ? AND facet_key = ?", (profile_id, key), ).fetchone() ) if current and int(current.get("locked") or 0): return "skipped" if current and current.get("origin") in {"manual", "accepted_suggestion"} and not force: return "skipped" if current and (current.get("value") or "") == value: conn.execute( "UPDATE writing_profile_facets SET evidence = ?, updated = datetime('now') WHERE id = ?", (evidence, current["id"]), ) return "applied" if current: conn.execute( """ UPDATE writing_profile_facets SET value = ?, evidence = ?, origin = ?, layer = ?, updated = datetime('now') WHERE id = ? """, (value, evidence, origin, layer, current["id"]), ) else: conn.execute( """ INSERT INTO writing_profile_facets (id, profile_id, facet_key, layer, value, evidence, origin, locked) VALUES (?, ?, ?, ?, ?, ?, ?, 0) """, (str(uuid.uuid4()), profile_id, key, layer, value, evidence, origin), ) return "applied" def _queue_suggestion( profile_id: str, facet_key: str, value: str, evidence: str, *, trait_slug: str = "", action: str = "update", payload: dict | None = None, ) -> None: with get_db() as conn: pending = row_to_dict( conn.execute( """ SELECT id, proposed_value FROM writing_profile_suggestions WHERE profile_id = ? AND status = 'pending' AND ( (? != '' AND trait_slug = ?) OR (? = '' AND facet_key = ?) ) ORDER BY created DESC LIMIT 1 """, (profile_id, trait_slug, trait_slug, trait_slug, facet_key), ).fetchone() ) if pending and (pending.get("proposed_value") or "") == value: return if pending: conn.execute( "UPDATE writing_profile_suggestions SET status = 'rejected', resolved = datetime('now') WHERE id = ?", (pending["id"],), ) conn.execute( """ INSERT INTO writing_profile_suggestions (id, profile_id, facet_key, trait_slug, action, proposed_value, evidence, payload_json, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending') """, ( str(uuid.uuid4()), profile_id, facet_key, trait_slug, action, value, evidence, json.dumps(payload or {}, ensure_ascii=False), ), )