Kansho/backend/routers/journal.py
2026-08-28 08:40:28 +02:00

701 lines
21 KiB
Python

from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from auth import require_auth
from dialogue_store import StoreError, delete_conversation, get_conversation, list_messages
from dialogue_turn import run_turn, visible_for_role
from privacy_gateway import GatewayRequest, inspect
from engine import EngineError
from journal_generate import generate_draft
from journal_generation_policy import settings_payload
from journal_opening import maybe_open_journal_conversation
from journal_policy import PolicyError
from journal_store import (
create_day_conversation,
create_user_space,
day_payload,
get_day,
get_entry,
get_or_create_day,
get_user_space,
list_days,
list_deleted_entries,
list_space_entries,
list_user_spaces,
list_versions,
purge_entry,
rename_user_space,
restore_entry,
save_day_scratch,
save_entry,
soft_delete_entry,
start_payload,
undelete_entry,
)
from media_store import delete_media, get_media, list_media, save_media
from writing_profile_store import (
accept_suggestion as accept_writing_suggestion,
export_document as export_writing_document,
get_profile,
import_corpus,
list_corpus,
rebuild_brief,
reject_suggestion as reject_writing_suggestion,
remember_dialogue_style,
remember_journal_entry,
restore_document as restore_writing_document,
set_governance as set_writing_governance,
update_facet,
update_trait,
)
from profile_review import (
ingest_import,
ingest_journal,
import_result as import_review_result,
open_paste_review,
review_status,
run_api_review,
accept_proposal,
reject_proposal,
)
from interaction_profile_store import (
accept_suggestion as accept_interaction_suggestion,
export_document as export_interaction_document,
get_profile as get_interaction_profile,
reject_suggestion as reject_interaction_suggestion,
restore_document as restore_interaction_document,
set_governance as set_interaction_governance,
upsert_preference,
)
from profile_documents import export_bundle, restore_bundle
router = APIRouter(prefix="/api/journal", tags=["journal"])
def _http(exc: StoreError | PolicyError | EngineError):
detail = {"code": exc.code, "message": exc.message}
extra = getattr(exc, "diagnostics", None)
if extra:
detail["diagnostics"] = extra
raise HTTPException(status_code=exc.status_code, detail=detail) from exc
class SpaceWrite(BaseModel):
title: str = Field(min_length=1)
class DayWrite(BaseModel):
calendar_date: str = Field(min_length=10, max_length=10)
class ConversationWrite(BaseModel):
title: str = ""
class TurnWrite(BaseModel):
body: str = Field(min_length=1)
id: str | None = None
class GenerateWrite(BaseModel):
conversation_ids: list[str] | None = None
include_existing: bool = False
generation_selection: dict | None = None
remember_generation_selection: bool = False
class EntryWrite(BaseModel):
journal_day_id: str
title: str = ""
body: str = ""
entry_id: str | None = None
origin: str = "user_edit"
source_conversation_ids: list[str] = Field(default_factory=list)
source_message_ids: list[str] = Field(default_factory=list)
class RestoreWrite(BaseModel):
version_id: str
class PurgeWrite(BaseModel):
confirm: bool = False
class ScratchItem(BaseModel):
id: str | None = None
text: str = ""
done: bool = False
class ScratchWrite(BaseModel):
items: list[ScratchItem] = Field(default_factory=list)
class ImportWrite(BaseModel):
body: str = Field(min_length=1)
occurred_at: str | None = None
context_hint: str | None = None
class CorpusWrite(BaseModel):
items: list[dict]
class TraitWrite(BaseModel):
statement: str | None = None
locked: bool | None = None
facet_key: str | None = None
label: str | None = None
class GovernanceWrite(BaseModel):
governance: str
class FacetWrite(BaseModel):
value: str | None = None
locked: bool | None = None
class PreferenceWrite(BaseModel):
scope: str = "global"
pref_key: str
value: str
locked: bool | None = None
origin: str = "manual"
class ReviewImportWrite(BaseModel):
result: dict | str
review_id: str | None = None
class ReviewAcceptWrite(BaseModel):
review_id: str | None = None
indexes: list[int] | None = None
baseline: bool = False
@router.get("/continuity")
def continuity(session: dict = Depends(require_auth)):
return start_payload(session["profile_id"])
@router.get("/spaces")
def spaces(session: dict = Depends(require_auth)):
return list_user_spaces(session["profile_id"])
@router.post("/spaces")
def create_space(body: SpaceWrite, session: dict = Depends(require_auth)):
try:
return create_user_space(session["profile_id"], body.title)
except StoreError as exc:
_http(exc)
@router.get("/spaces/{space_id}")
def space_detail(space_id: str, session: dict = Depends(require_auth)):
try:
space = get_user_space(session["profile_id"], space_id)
return {
"space": space,
"days": list_days(session["profile_id"], space_id),
"entries": list_space_entries(session["profile_id"], space_id),
}
except StoreError as exc:
_http(exc)
@router.get("/spaces/{space_id}/entries")
def space_entries(space_id: str, session: dict = Depends(require_auth)):
try:
return list_space_entries(session["profile_id"], space_id)
except StoreError as exc:
_http(exc)
@router.get("/spaces/{space_id}/trash")
def space_trash(space_id: str, session: dict = Depends(require_auth)):
try:
return list_deleted_entries(session["profile_id"], space_id)
except StoreError as exc:
_http(exc)
@router.patch("/spaces/{space_id}")
def space_rename(space_id: str, body: SpaceWrite, session: dict = Depends(require_auth)):
try:
return rename_user_space(session["profile_id"], space_id, body.title)
except StoreError as exc:
_http(exc)
@router.post("/spaces/{space_id}/days")
def open_day(space_id: str, body: DayWrite, session: dict = Depends(require_auth)):
try:
day = get_or_create_day(session["profile_id"], space_id, body.calendar_date)
return day_payload(session["profile_id"], day["id"])
except StoreError as exc:
_http(exc)
@router.get("/days/{journal_day_id}")
def read_day(journal_day_id: str, session: dict = Depends(require_auth)):
try:
return day_payload(session["profile_id"], journal_day_id)
except StoreError as exc:
_http(exc)
@router.patch("/days/{journal_day_id}/scratch")
def write_scratch(journal_day_id: str, body: ScratchWrite, session: dict = Depends(require_auth)):
try:
return {
"scratch": save_day_scratch(
session["profile_id"],
journal_day_id,
[{"id": item.id, "text": item.text, "done": item.done} for item in body.items],
)
}
except StoreError as exc:
_http(exc)
@router.post("/days/{journal_day_id}/conversations")
def start_conversation(journal_day_id: str, body: ConversationWrite, session: dict = Depends(require_auth)):
try:
conversation = create_day_conversation(session["profile_id"], journal_day_id, body.title)
opened = maybe_open_journal_conversation(session["profile_id"], conversation)
return visible_for_role(opened, session.get("role"))
except StoreError as exc:
_http(exc)
@router.delete("/conversations/{conversation_id}")
def remove_conversation(conversation_id: str, session: dict = Depends(require_auth)):
try:
result = delete_conversation(session["profile_id"], conversation_id)
remember_dialogue_style(session["profile_id"])
return result
except StoreError as exc:
_http(exc)
@router.get("/conversations/{conversation_id}")
def read_conversation(conversation_id: str, session: dict = Depends(require_auth)):
try:
conv = get_conversation(session["profile_id"], conversation_id)
return {"conversation": conv, "messages": list_messages(session["profile_id"], conversation_id)}
except StoreError as exc:
_http(exc)
@router.get("/egress-status")
def egress_status(session: dict = Depends(require_auth)):
result = inspect(GatewayRequest(prompt_id=None, purpose="dialogue_turn", data_class="B"))
return {"ready": result.allowed, "reason": result.reason, "provider": result.provider}
@router.post("/conversations/{conversation_id}/turn")
def conversation_turn(conversation_id: str, body: TurnWrite, session: dict = Depends(require_auth)):
try:
return visible_for_role(
run_turn(session["profile_id"], conversation_id, body.body, message_id=body.id),
session.get("role"),
)
except (StoreError, EngineError) as exc:
_http(exc)
@router.get("/generation-settings")
def read_generation_settings(session: dict = Depends(require_auth)):
return settings_payload(session["profile_id"])
@router.post("/days/{journal_day_id}/generate")
def generate(journal_day_id: str, body: GenerateWrite, session: dict = Depends(require_auth)):
try:
return visible_for_role(
generate_draft(
session["profile_id"],
journal_day_id,
conversation_ids=body.conversation_ids,
include_existing=body.include_existing,
explicit=True,
generation_selection=body.generation_selection,
remember_generation_selection=body.remember_generation_selection,
),
session.get("role"),
)
except (StoreError, PolicyError, EngineError) as exc:
_http(exc)
@router.post("/entries")
def write_entry(body: EntryWrite, session: dict = Depends(require_auth)):
try:
get_day(session["profile_id"], body.journal_day_id)
entry = save_entry(
session["profile_id"],
body.journal_day_id,
body.title,
body.body,
origin=body.origin,
entry_id=body.entry_id,
source_conversation_ids=body.source_conversation_ids,
source_message_ids=body.source_message_ids,
)
remember_journal_entry(
session["profile_id"],
entry["id"],
entry.get("body") or "",
origin=body.origin,
)
ingest_journal(
session["profile_id"],
entry["id"],
entry.get("body") or "",
origin=body.origin,
)
return entry
except (StoreError, PolicyError) as exc:
_http(exc)
@router.get("/entries/{entry_id}")
def read_entry(entry_id: str, session: dict = Depends(require_auth)):
try:
entry = get_entry(session["profile_id"], entry_id)
entry["media"] = list_media(session["profile_id"], entry_id)
entry["versions"] = list_versions(session["profile_id"], entry_id)
return entry
except StoreError as exc:
_http(exc)
@router.get("/entries/{entry_id}/versions")
def entry_versions(entry_id: str, session: dict = Depends(require_auth)):
try:
return list_versions(session["profile_id"], entry_id)
except StoreError as exc:
_http(exc)
@router.post("/entries/{entry_id}/restore")
def entry_restore(entry_id: str, body: RestoreWrite, session: dict = Depends(require_auth)):
try:
entry = restore_entry(session["profile_id"], entry_id, body.version_id)
remember_journal_entry(
session["profile_id"],
entry["id"],
entry.get("body") or "",
origin=entry.get("origin"),
)
ingest_journal(
session["profile_id"],
entry["id"],
entry.get("body") or "",
origin=entry.get("origin"),
)
return entry
except StoreError as exc:
_http(exc)
@router.post("/entries/{entry_id}/undelete")
def entry_undelete(entry_id: str, session: dict = Depends(require_auth)):
try:
return undelete_entry(session["profile_id"], entry_id)
except StoreError as exc:
_http(exc)
@router.post("/entries/{entry_id}/purge")
def entry_purge(entry_id: str, body: PurgeWrite, session: dict = Depends(require_auth)):
try:
return purge_entry(session["profile_id"], entry_id, confirm=body.confirm)
except StoreError as exc:
_http(exc)
@router.delete("/entries/{entry_id}")
def entry_delete(entry_id: str, session: dict = Depends(require_auth)):
try:
return soft_delete_entry(session["profile_id"], entry_id)
except StoreError as exc:
_http(exc)
@router.post("/entries/{entry_id}/media")
async def entry_media(entry_id: str, file: UploadFile = File(...), session: dict = Depends(require_auth)):
try:
data = await file.read()
return save_media(
session["profile_id"],
entry_id,
file.filename or "media",
file.content_type or "",
data,
)
except StoreError as exc:
_http(exc)
@router.get("/media/{media_id}")
def read_media(media_id: str, session: dict = Depends(require_auth)):
try:
row, path = get_media(session["profile_id"], media_id)
return FileResponse(path, media_type=row["content_type"], filename=row["filename"])
except StoreError as exc:
_http(exc)
@router.delete("/media/{media_id}")
def remove_media(media_id: str, session: dict = Depends(require_auth)):
try:
return delete_media(session["profile_id"], media_id)
except StoreError as exc:
_http(exc)
@router.get("/writing-profile")
def writing_profile(session: dict = Depends(require_auth)):
return get_profile(session["profile_id"])
@router.patch("/writing-profile")
def writing_patch(body: GovernanceWrite, session: dict = Depends(require_auth)):
try:
return set_writing_governance(session["profile_id"], body.governance)
except StoreError as exc:
_http(exc)
@router.patch("/writing-profile/facets/{facet_key}")
def writing_facet(facet_key: str, body: FacetWrite, session: dict = Depends(require_auth)):
try:
return update_facet(
session["profile_id"],
facet_key,
value=body.value,
locked=body.locked,
)
except StoreError as exc:
_http(exc)
@router.patch("/writing-profile/traits/{slug}")
def writing_trait(slug: str, body: TraitWrite, session: dict = Depends(require_auth)):
try:
return update_trait(
session["profile_id"],
slug,
statement=body.statement,
locked=body.locked,
facet_key=body.facet_key,
label=body.label,
)
except StoreError as exc:
_http(exc)
@router.post("/writing-profile/suggestions/{suggestion_id}/accept")
def writing_accept(suggestion_id: str, session: dict = Depends(require_auth)):
try:
return accept_writing_suggestion(session["profile_id"], suggestion_id)
except StoreError as exc:
_http(exc)
@router.post("/writing-profile/suggestions/{suggestion_id}/reject")
def writing_reject(suggestion_id: str, session: dict = Depends(require_auth)):
try:
return reject_writing_suggestion(session["profile_id"], suggestion_id)
except StoreError as exc:
_http(exc)
@router.post("/writing-profile/import")
def writing_import(body: ImportWrite, session: dict = Depends(require_auth)):
try:
return ingest_import(
session["profile_id"],
body.body,
occurred_at=body.occurred_at,
context_hint=body.context_hint,
)
except StoreError as exc:
_http(exc)
@router.post("/writing-profile/corpus")
def writing_corpus(body: CorpusWrite, session: dict = Depends(require_auth)):
try:
return import_corpus(session["profile_id"], body.items)
except StoreError as exc:
_http(exc)
@router.get("/writing-profile/corpus")
def writing_corpus_list(session: dict = Depends(require_auth)):
return {"items": list_corpus(session["profile_id"])}
@router.post("/writing-profile/rebuild")
def writing_rebuild(session: dict = Depends(require_auth)):
return rebuild_brief(session["profile_id"])
@router.get("/writing-profile/review")
def writing_review_status(session: dict = Depends(require_auth)):
return review_status(session["profile_id"])
@router.post("/writing-profile/review/paste")
def writing_review_paste(session: dict = Depends(require_auth)):
try:
return open_paste_review(session["profile_id"])
except StoreError as exc:
_http(exc)
@router.post("/writing-profile/initial-build/paste")
def writing_initial_build_paste(session: dict = Depends(require_auth)):
try:
return open_paste_review(session["profile_id"], mode="initial_build")
except StoreError as exc:
_http(exc)
@router.post("/writing-profile/review/api")
def writing_review_api(session: dict = Depends(require_auth)):
try:
return run_api_review(session["profile_id"])
except (StoreError, EngineError) as exc:
_http(exc)
@router.post("/writing-profile/initial-build/api")
def writing_initial_build_api(session: dict = Depends(require_auth)):
try:
return run_api_review(session["profile_id"], mode="initial_build")
except (StoreError, EngineError) as exc:
_http(exc)
@router.post("/writing-profile/review/import")
def writing_review_import(body: ReviewImportWrite, session: dict = Depends(require_auth)):
try:
return import_review_result(session["profile_id"], body.result, review_id=body.review_id)
except StoreError as exc:
_http(exc)
@router.post("/writing-profile/review/accept")
def writing_review_accept(body: ReviewAcceptWrite, session: dict = Depends(require_auth)):
try:
return accept_proposal(
session["profile_id"],
body.review_id,
indexes=body.indexes,
baseline=body.baseline,
)
except StoreError as exc:
_http(exc)
@router.post("/writing-profile/review/reject")
def writing_review_reject(body: ReviewAcceptWrite, session: dict = Depends(require_auth)):
try:
return reject_proposal(session["profile_id"], body.review_id, indexes=body.indexes)
except StoreError as exc:
_http(exc)
@router.get("/writing-profile/export")
def writing_export(session: dict = Depends(require_auth)):
return export_writing_document(session["profile_id"])
@router.post("/writing-profile/restore")
def writing_restore(body: dict, session: dict = Depends(require_auth)):
try:
return restore_writing_document(session["profile_id"], body)
except StoreError as exc:
_http(exc)
@router.get("/interaction-profile")
def interaction_profile(session: dict = Depends(require_auth)):
return get_interaction_profile(session["profile_id"])
@router.patch("/interaction-profile")
def interaction_patch(body: GovernanceWrite, session: dict = Depends(require_auth)):
try:
return set_interaction_governance(session["profile_id"], body.governance)
except StoreError as exc:
_http(exc)
@router.patch("/interaction-profile/preferences")
def interaction_pref(body: PreferenceWrite, session: dict = Depends(require_auth)):
try:
return upsert_preference(
session["profile_id"],
scope=body.scope,
pref_key=body.pref_key,
value=body.value,
origin=body.origin if body.origin in {"explicit", "manual"} else "manual",
locked=body.locked,
)
except StoreError as exc:
_http(exc)
@router.post("/interaction-profile/suggestions/{suggestion_id}/accept")
def interaction_accept(suggestion_id: str, session: dict = Depends(require_auth)):
try:
return accept_interaction_suggestion(session["profile_id"], suggestion_id)
except StoreError as exc:
_http(exc)
@router.post("/interaction-profile/suggestions/{suggestion_id}/reject")
def interaction_reject(suggestion_id: str, session: dict = Depends(require_auth)):
try:
return reject_interaction_suggestion(session["profile_id"], suggestion_id)
except StoreError as exc:
_http(exc)
@router.get("/interaction-profile/export")
def interaction_export(session: dict = Depends(require_auth)):
return export_interaction_document(session["profile_id"])
@router.post("/interaction-profile/restore")
def interaction_restore(body: dict, session: dict = Depends(require_auth)):
try:
return restore_interaction_document(session["profile_id"], body)
except StoreError as exc:
_http(exc)
@router.get("/profiles/export")
def profiles_export(session: dict = Depends(require_auth)):
return export_bundle(session["profile_id"])
@router.post("/profiles/restore")
def profiles_restore(body: dict, session: dict = Depends(require_auth)):
try:
return restore_bundle(session["profile_id"], body)
except StoreError as exc:
_http(exc)