Files
ProfessorNovaandClaude Opus 5 784b76baf7 Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor
whose sections an LLM refines while you write, and RAG question answering
over the documents that result. FastAPI + Postgres/pgvector on the back,
SvelteKit on the front, everything OpenAI-compatible and self-hostable.

Squashed into a single commit; the development history stays local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
2026-09-04 09:21:37 +02:00

199 lines
6.4 KiB
Python

"""Request and response shapes for the documents API.
Kept in one module because the whole package answers with the same handful of
document shapes: a summary in lists, a detail on a single document, and the
few command bodies that change one.
"""
import uuid
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
from app.models import (
AccessReason,
DocumentEventAction,
DocumentStatus,
DocumentVisibility,
)
class DocumentSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
title: str
status: DocumentStatus
visibility: DocumentVisibility
department_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
# Why this user sees it and what they may do — so the UI can explain
# access instead of leaving the rules implicit.
access_reason: AccessReason
can_edit: bool
# Unanswered questions about this document. A published document with an
# open question is readable but not settled, and every surface that shows
# the document says so — including the sources under a chat answer.
open_reviews: int
# Shipped with the product: read-only, and never deletable.
is_builtin: bool
class DepartmentRef(BaseModel):
id: uuid.UUID
name: str
class ReviewOut(BaseModel):
"""One request to check this document. Open while `resolved_at` is null."""
id: uuid.UUID
question: str | None
requester_name: str | None
reviewer_id: uuid.UUID | None
reviewer_name: str | None
created_at: datetime
resolved_at: datetime | None
resolved_by_name: str | None
# Whether the caller is the one being asked, so the UI can offer the
# answer rather than just showing the question.
is_mine: bool
class DocumentDetail(DocumentSummary):
content_md: str
# Every request on this document, oldest first, open and answered — the
# answered ones are the record of what was already checked.
reviews: list[ReviewOut] = []
# Additional departments the document is shared with, on top of its owning
# `department_id` (the `doc_permissions` grants). Resolved where the endpoint
# looks it up (get_document, the departments endpoint).
shared_departments: list[DepartmentRef] = []
class DocumentSort(StrEnum):
"""How the browse list is ordered. Deliberately two options: "what
changed" and "what is new" are the two questions people actually ask of
a document list."""
updated = "updated"
created = "created"
class DocumentPage(BaseModel):
items: list[DocumentSummary]
# Total matching the filters, not the page — the UI needs it to know
# whether there is a next page at all.
total: int
per_page: int
class DocumentSearchHit(DocumentSummary):
"""A search result: the document plus the section that matched.
Empty `heading_path` means the match was on the title, not a section.
"""
heading_path: str = ""
class DocumentStats(BaseModel):
"""Company-wide counts, read by the landing page's first-run guide.
Aggregates only — no titles, no per-user data. Deliberately not
permission-filtered: a bare count reveals nothing about content.
"""
documents_total: int
departments_total: int
class DocumentCreate(BaseModel):
"""Start a new document the user will write in the editor.
With a `template_id` the draft opens on that template's Markdown skeleton
and title; without one it starts blank and `title` is required. The result
is a `draft` — author-only and never indexed until it is published."""
template_id: uuid.UUID | None = None
title: str | None = None
visibility: DocumentVisibility | None = None
# When the capture started from a chat: its subject is summarized and kept
# on the draft as background for section refinement.
conversation_id: uuid.UUID | None = None
class DocumentUpdate(BaseModel):
title: str | None = None
content_md: str | None = None
visibility: DocumentVisibility | None = None
# Only the archive transition is settable here; publishing has its own
# endpoint, because it indexes the document.
status: DocumentStatus | None = None
# An admin may knowingly make a change that removes their own access; an
# author never can (they keep access as author). See access.guard_self_lockout.
# Nullable (not `bool = False`) so it stays optional in the generated client.
confirm_lockout: bool | None = None
# Continuing an EXISTING document out of a chat: the same background the
# create path attaches, for the document that already covers the topic.
conversation_id: uuid.UUID | None = None
class DocumentDepartments(BaseModel):
"""The full set of ADDITIONAL departments the document is shared with (on
top of the owning department) — replaces the existing grants."""
department_ids: list[uuid.UUID]
confirm_lockout: bool | None = None
class ReviewerCandidate(BaseModel):
"""A user the author may ask to check a document — id + name only."""
id: uuid.UUID
name: str
class ReviewRequestBody(BaseModel):
"""Ask someone to check this document, optionally about something specific
("do the holiday numbers still hold?")."""
reviewer_id: uuid.UUID
question: str | None = Field(default=None, max_length=2000)
class DocumentEventOut(BaseModel):
"""One entry in a document's history timeline — metadata only."""
id: uuid.UUID
action: DocumentEventAction
actor_id: uuid.UUID | None
# Null once the actor's account is deleted (SET NULL on the event).
actor_name: str | None
visibility: DocumentVisibility | None
created_at: datetime
# A content snapshot exists for this event and can be fetched for diffing.
has_snapshot: bool
class DocumentVersion(BaseModel):
"""A past version's frozen content, for viewing or diffing.
A snapshot is taken *after* its event, so `content_md` is the state this
event produced and `previous_content_md` the state it started from — the
pair is what "what did this change do?" needs. `previous_content_md` is
null for the first snapshot, where everything was added.
"""
id: uuid.UUID
action: DocumentEventAction
actor_id: uuid.UUID | None
actor_name: str | None
created_at: datetime
title: str | None
content_md: str | None
previous_content_md: str | None
visibility: DocumentVisibility | None