1555 lines
55 KiB
Python
1555 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",
|
|
*,
|
|
include_core: bool = True,
|
|
include_facet: bool = True,
|
|
include_traits: bool = True,
|
|
) -> 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. Callers may
|
|
omit core, facet or traits; omitted parts are not rendered as empty headings.
|
|
"""
|
|
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 include_core and not include_facet and not include_traits:
|
|
return ""
|
|
|
|
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 include_core else ""
|
|
if include_core and 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 include_facet else ""
|
|
if include_facet and 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 []) if include_traits else []
|
|
used_statements = [core_text, facet_text]
|
|
trait_lines: list[str] = []
|
|
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
|
|
trait_lines.append(f"- {item.get('label') or item.get('slug')}: {clipped}")
|
|
used_statements.append(statement)
|
|
parts.extend(trait_lines)
|
|
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()
|
|
if include_core and include_facet and include_traits:
|
|
return brief or NEUTRAL_JOURNAL_STYLE
|
|
return brief
|
|
|
|
|
|
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),
|
|
),
|
|
)
|
|
|