Kansho/backend/writing_profile_store.py

1540 lines
55 KiB
Python

"""Writing profile store with facets, governance, and evidence. AI drafts are never a positive style source."""
from __future__ import annotations
import hashlib
import json
import re
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
TASK_BRIEF_MAX_CHARS = 4000
TASK_BRIEF_CORE_CHARS = 800
TASK_BRIEF_FACET_CHARS = 700
TASK_BRIEF_TRAIT_CHARS = 280
TASK_BRIEF_EXEMPLAR_CHARS = 220
TASK_BRIEF_MAX_TRAITS = 6
TASK_BRIEF_MAX_EXEMPLARS = 2
NEUTRAL_JOURNAL_STYLE = (
"Neutraler Journalstil (kein individuelles Writing Profile): "
"klare Ich-Form, kurze Überschrift, ruhige Übergänge, natürliche Absätze. "
"Keine künstliche Literarisierung. Keine Stilableitung aus dem aktuellen Tagesdialog."
)
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",
"NEUTRAL_JOURNAL_STYLE",
"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",
"compile_task_brief",
"clip_field",
"list_style_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_content_digest(item: dict) -> str:
body = re.sub(r"\s+", " ", plain_text(item.get("body") or "")).strip().lower()
return hashlib.sha256(body.encode("utf-8")).hexdigest()
def _dedupe_brief_sources(items: list[dict], *, seen_keys: set[tuple[str, str]] | None = None) -> list[dict]:
"""Display-only. Originals remain in writing_profile_sources."""
seen_entry: set[str] = set()
seen = seen_keys if seen_keys is not None else set()
result = []
for item in items:
entry_id = (item.get("entry_id") or "").strip()
if entry_id:
if entry_id in seen_entry:
continue
seen_entry.add(entry_id)
when = (item.get("occurred_at") or "")[:10]
digest = _source_content_digest(item)
key = (when, digest)
if key in seen:
continue
seen.add(key)
result.append(item)
return result
def clip_field(text: str, limit: int) -> str:
raw = (text or "").strip()
if limit <= 0 or not raw:
return ""
if len(raw) <= limit:
return raw
cut = raw[:limit]
if not raw[limit].isspace():
sp = max(cut.rfind(" "), cut.rfind("\n"), cut.rfind("\t"))
if sp >= max(1, limit // 3):
cut = cut[:sp]
else:
return ""
return cut.rstrip(" \t\n,;:-")
_clip_field = clip_field
def list_style_sources(profile_id: str) -> dict[str, list[dict]]:
"""Intent-neutral ranked style sources. Journal policy decides which to send."""
return _load_ranked_sources(profile_id)
def _norm_overlap(left: str, right: str) -> float:
a = re.sub(r"\s+", " ", (left or "").strip().lower())
b = re.sub(r"\s+", " ", (right or "").strip().lower())
if not a or not b:
return 0.0
if a == b or a in b or b in a:
return min(len(a), len(b)) / max(len(a), len(b))
return 0.0
def _trait_recency_stamp(item: dict) -> str | None:
stamps = [item.get("updated"), item.get("observed_to"), item.get("observed_from")]
for ref in item.get("exemplars") or []:
stamps.append(ref.get("occurred_at"))
stamps = [stamp for stamp in stamps if stamp]
return max(stamps) if stamps else None
def _select_task_traits(traits: list[dict]) -> list[dict]:
relevant = [
item
for item in traits
if item.get("status") == "active"
and (item.get("statement") or "")
and item.get("facet_key") in {"autobiographical_journal", "core", ""}
]
if not relevant:
relevant = [item for item in traits if item.get("status") == "active" and (item.get("statement") or "")]
current = []
long_term = []
for item in relevant:
stamp = _trait_recency_stamp(item)
role = recency_role(stamp)
weight = recency_weight(stamp)
scored = (item, weight, role)
if role == "current_expression":
current.append(scored)
else:
long_term.append(scored)
current.sort(key=lambda row: (-row[1], row[0].get("slug") or ""))
long_term.sort(key=lambda row: (-row[1], row[0].get("slug") or ""))
picked: list[dict] = []
for item, _, _ in current[:4]:
picked.append(item)
for item, _, _ in long_term:
if len(picked) >= TASK_BRIEF_MAX_TRAITS:
break
picked.append(item)
if len(picked) < TASK_BRIEF_MAX_TRAITS:
for item, _, _ in current[4:]:
if len(picked) >= TASK_BRIEF_MAX_TRAITS:
break
picked.append(item)
return picked[:TASK_BRIEF_MAX_TRAITS]
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)
display_keys: set[tuple[str, str]] = set()
entries = _dedupe_brief_sources(ranked["journal_entry"], seen_keys=display_keys)[:BRIEF_ENTRY_EXCERPTS]
imports = _dedupe_brief_sources(ranked["imported_text"], seen_keys=display_keys)[: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 compile_task_brief(profile_id: str, task: str = "journal_generate") -> str:
"""Compact confirmed style brief. Historical texts are selected separately.
Unconfirmed profiles do not become a style authority. Journal adapters attach
final entries as STYLE_EXAMPLES, not as extra rules in this brief.
"""
profile = get_profile(profile_id)
if task != "journal_generate":
core = (profile.get("core") or {}).get("value") or ""
return _clip_field(core, TASK_BRIEF_MAX_CHARS)
if not has_confirmed_profile(profile_id):
return NEUTRAL_JOURNAL_STYLE
parts: list[str] = []
core_text = ((profile.get("core") or {}).get("value") or "").strip()
if core_text:
clipped = _clip_field(core_text, TASK_BRIEF_CORE_CHARS)
if clipped:
parts.append("Core: " + clipped)
facet = next(
(
item
for item in profile.get("facets") or []
if item.get("facet_key") == "autobiographical_journal" and (item.get("value") or "")
),
None,
)
facet_text = ((facet or {}).get("value") or "").strip()
if facet_text and _norm_overlap(facet_text, core_text) < 0.8:
clipped = _clip_field(facet_text, TASK_BRIEF_FACET_CHARS)
if clipped:
parts.append("Autobiografisches Journaling (Facet-Delta): " + clipped)
selected = _select_task_traits(profile.get("traits") or [])
used_statements = [core_text, facet_text]
for item in selected:
statement = (item.get("statement") or "").strip()
if any(_norm_overlap(statement, previous) >= 0.85 for previous in used_statements if previous):
continue
clipped = _clip_field(statement, TASK_BRIEF_TRAIT_CHARS)
if not clipped:
continue
parts.append(f"- {item.get('label') or item.get('slug')}: {clipped}")
used_statements.append(statement)
brief = "\n".join(part for part in parts if part).strip()
while len(brief) > TASK_BRIEF_MAX_CHARS and parts:
last = parts.pop()
remain = TASK_BRIEF_MAX_CHARS - (len("\n".join(parts)) + (1 if parts else 0))
clipped = _clip_field(last, remain)
if clipped:
parts.append(clipped)
brief = "\n".join(parts).strip()
break
brief = "\n".join(parts).strip()
return brief or NEUTRAL_JOURNAL_STYLE
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),
),
)