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
365 lines
12 KiB
Python
365 lines
12 KiB
Python
"""Dev seed data: departments, users and the fixture corpus as documents with
|
|
a plausible past. Never runs in production.
|
|
|
|
Seeded documents are not inserted as finished rows — they are given the
|
|
history they would have if someone had written them in the app: an empty
|
|
draft, one edit per section as the author works down the page, the publish,
|
|
and the questions colleagues asked afterwards. Without that, every history
|
|
view, diff and "recently changed" list in the dev stack is empty or lies.
|
|
"""
|
|
|
|
import asyncio
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import TYPE_CHECKING
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth.passwords import hash_password
|
|
from app.config import get_settings
|
|
from app.db import async_session_factory, engine
|
|
from app.ingestion.handlers import INDEX_DOCUMENT
|
|
from app.ingestion.queue import enqueue
|
|
from app.models import (
|
|
Department,
|
|
DocPermission,
|
|
Document,
|
|
DocumentEvent,
|
|
DocumentEventAction,
|
|
DocumentStatus,
|
|
ReviewRequest,
|
|
User,
|
|
UserRole,
|
|
)
|
|
|
|
if TYPE_CHECKING: # the corpus is a dev-only import, see _seed_corpus
|
|
from tests.fixtures.loader import CorpusDoc
|
|
|
|
DEV_PASSWORD = "pablan-dev"
|
|
|
|
DEPARTMENTS = ["Engineering", "Sales", "Administration"]
|
|
|
|
# The dev team, one per department, so the permission scenarios the e2e
|
|
# suite relies on stay intact: an admin in Administration, a member in
|
|
# Engineering, a member in Sales.
|
|
USERS = [
|
|
("florian@pablan.dev", "Florian", UserRole.admin, "Administration"),
|
|
("pablo@pablan.dev", "Pablo", UserRole.member, "Engineering"),
|
|
("max@pablan.dev", "Max", UserRole.member, "Sales"),
|
|
]
|
|
|
|
# Which seeded user authors a department's corpus documents.
|
|
AUTHORS_BY_DEPARTMENT = {
|
|
"Engineering": "pablo@pablan.dev",
|
|
"Sales": "max@pablan.dev",
|
|
"Administration": "florian@pablan.dev",
|
|
}
|
|
|
|
# Deliberately authorless: it doubles as the demo for the "department" access
|
|
# reason (a member sees it without owning it) and, being the knowledge of
|
|
# someone who has since left, fits the offboarding theme.
|
|
AUTHORLESS_SLUG = "wartungsplan-cnc-f350"
|
|
|
|
# Documents whose life stops before the publish: unpublished work in progress,
|
|
# one per author, so whoever logs in finds their own drafts waiting on the
|
|
# home page.
|
|
DRAFT_SLUGS = {
|
|
"netzwerk-produktions-it", # Engineering
|
|
"messevorbereitung", # Sales
|
|
"it-onboarding-arbeitsplatz", # Administration
|
|
}
|
|
|
|
# Published once, then retired — so the archive is not an empty concept in the
|
|
# dev stack.
|
|
ARCHIVED_SLUGS = {"edi-rechnungen"}
|
|
|
|
# How far back the corpus starts and how far apart the documents were written.
|
|
# Deterministic rather than random: the "recently changed" order stays stable
|
|
# across re-seeds, and the oldest documents are the ones that look oldest.
|
|
CORPUS_STARTS_DAYS_AGO = 120
|
|
DAYS_BETWEEN_DOCUMENTS = 6
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SeededReview:
|
|
"""A "please check this" the author sent a colleague.
|
|
|
|
Open ones are the interesting case: the document is published and readable
|
|
and still carries an unanswered question, which is exactly what every
|
|
surface — list, detail page, chat sources — has to mark.
|
|
"""
|
|
|
|
reviewer: str
|
|
question: str
|
|
answered: bool = False
|
|
# What the reviewer corrected, as (current text, the text it replaced).
|
|
# Applied in reverse to every version before the answer, so the history
|
|
# holds a real diff and the answer is visibly a fix, not a rubber stamp.
|
|
correction: tuple[str, str] | None = None
|
|
|
|
|
|
REVIEWS = {
|
|
# Published, public, unanswered: the case a reader most needs to see.
|
|
"urlaubsantrag-prozess": SeededReview(
|
|
reviewer="pablo@pablan.dev",
|
|
question=(
|
|
"Stimmt das für die Fertigung noch so, dass pro Schicht maximal "
|
|
"zwei Personen gleichzeitig Urlaub haben dürfen?"
|
|
),
|
|
),
|
|
# On a draft: being asked is what lets a colleague see it at all.
|
|
"messevorbereitung": SeededReview(
|
|
reviewer="florian@pablan.dev",
|
|
question="Passt der Budgetrahmen so, bevor ich das veröffentliche?",
|
|
),
|
|
# Answered — and the reviewer fixed the number before answering.
|
|
"rabattrichtlinie": SeededReview(
|
|
reviewer="florian@pablan.dev",
|
|
question="Gilt für Ersatzteile weiterhin die 3-%-Grenze?",
|
|
answered=True,
|
|
correction=(
|
|
"- Bis 5 %: eigenverantwortlich durch den Vertriebsmitarbeiter",
|
|
"- Bis 3 %: eigenverantwortlich durch den Vertriebsmitarbeiter",
|
|
),
|
|
),
|
|
}
|
|
|
|
|
|
def _seed_order(doc: "CorpusDoc") -> tuple[int, str]:
|
|
"""The order the corpus was "written" in, oldest first.
|
|
|
|
Not alphabetical: the knowledge of someone who has left is the oldest
|
|
thing in the base, and work still in draft has to be the most recent.
|
|
"""
|
|
if doc.slug == AUTHORLESS_SLUG:
|
|
return (0, doc.slug)
|
|
if doc.slug in DRAFT_SLUGS:
|
|
return (2, doc.slug)
|
|
return (1, doc.slug)
|
|
|
|
|
|
def _writing_steps(content_md: str) -> list[str]:
|
|
"""The document as it grew: the empty draft it starts as, then one state
|
|
per section — the shape the writing editor produces, where a section is
|
|
refined and saved before the next one is started."""
|
|
sections = re.split(r"(?m)^(?=## )", content_md)
|
|
return [""] + ["".join(sections[: index + 1]) for index in range(len(sections))]
|
|
|
|
|
|
async def _get_or_create_department(db: AsyncSession, name: str) -> Department:
|
|
existing = (
|
|
await db.execute(select(Department).where(Department.name == name))
|
|
).scalar_one_or_none()
|
|
if existing is not None:
|
|
return existing
|
|
department = Department(name=name)
|
|
db.add(department)
|
|
await db.flush()
|
|
return department
|
|
|
|
|
|
async def seed() -> None:
|
|
async with async_session_factory() as db:
|
|
departments = {
|
|
name: await _get_or_create_department(db, name) for name in DEPARTMENTS
|
|
}
|
|
|
|
created = 0
|
|
for email, name, role, department_name in USERS:
|
|
existing = (
|
|
await db.execute(select(User).where(User.email == email))
|
|
).scalar_one_or_none()
|
|
if existing is not None:
|
|
continue
|
|
db.add(
|
|
User(
|
|
email=email,
|
|
name=name,
|
|
role=role,
|
|
password_hash=hash_password(DEV_PASSWORD),
|
|
department_id=departments[department_name].id,
|
|
)
|
|
)
|
|
created += 1
|
|
|
|
documents_created = await _seed_corpus(db, departments)
|
|
await db.commit()
|
|
|
|
await engine.dispose()
|
|
print(f"Seeded {len(DEPARTMENTS)} departments, {created} new users.")
|
|
print(f"Dev logins (password: {DEV_PASSWORD!r}):")
|
|
for email, _, role, department in USERS:
|
|
print(f" {email} ({role}, {department})")
|
|
print(
|
|
f"Seeded {documents_created} new corpus documents with their history "
|
|
f"({len(DRAFT_SLUGS)} drafts, {len(REVIEWS)} review requests); "
|
|
"index jobs enqueued (processed once the backend runs)."
|
|
)
|
|
|
|
|
|
async def _seed_corpus(db: AsyncSession, departments: dict[str, Department]) -> int:
|
|
# Dev-only import: the corpus lives with the test fixtures on purpose —
|
|
# seeds and tests draw from the same product asset.
|
|
from tests.fixtures.loader import load_corpus
|
|
|
|
users_by_email = {u.email: u for u in (await db.execute(select(User))).scalars()}
|
|
created = 0
|
|
for index, doc in enumerate(sorted(load_corpus(), key=_seed_order)):
|
|
existing = (
|
|
await db.execute(
|
|
select(Document.id).where(Document.meta["slug"].astext == doc.slug)
|
|
)
|
|
).first()
|
|
if existing is not None:
|
|
continue
|
|
document = await _seed_document(
|
|
db,
|
|
doc,
|
|
department=departments[doc.department],
|
|
users_by_email=users_by_email,
|
|
started_at=datetime.now(UTC)
|
|
- timedelta(days=CORPUS_STARTS_DAYS_AGO - index * DAYS_BETWEEN_DOCUMENTS),
|
|
)
|
|
for grant in doc.grants:
|
|
db.add(
|
|
DocPermission(
|
|
document_id=document.id,
|
|
department_id=departments[grant].id,
|
|
)
|
|
)
|
|
if document.status == DocumentStatus.published:
|
|
await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)})
|
|
created += 1
|
|
return created
|
|
|
|
|
|
async def _seed_document(
|
|
db: AsyncSession,
|
|
doc: "CorpusDoc",
|
|
*,
|
|
department: Department,
|
|
users_by_email: dict[str, User],
|
|
started_at: datetime,
|
|
) -> Document:
|
|
"""One corpus document plus the trail of everything that happened to it."""
|
|
author: User | None = users_by_email[AUTHORS_BY_DEPARTMENT[doc.department]]
|
|
if doc.slug == AUTHORLESS_SLUG:
|
|
author = None
|
|
|
|
slug = doc.slug
|
|
content = doc.content_md
|
|
review = REVIEWS.get(slug)
|
|
is_draft = slug in DRAFT_SLUGS
|
|
|
|
document = Document(
|
|
title=doc.title,
|
|
status=DocumentStatus.draft if is_draft else DocumentStatus.published,
|
|
visibility=doc.visibility,
|
|
content_md=content,
|
|
meta={"slug": slug},
|
|
author_id=author.id if author else None,
|
|
department_id=department.id,
|
|
reviews=[],
|
|
)
|
|
db.add(document)
|
|
await db.flush()
|
|
|
|
at = started_at
|
|
|
|
def happened(
|
|
action: DocumentEventAction,
|
|
actor: User | None,
|
|
snapshot: str | None = None,
|
|
*,
|
|
after: timedelta = timedelta(),
|
|
) -> datetime:
|
|
nonlocal at
|
|
at += after
|
|
db.add(
|
|
DocumentEvent(
|
|
document_id=document.id,
|
|
actor_id=actor.id if actor else None,
|
|
action=action,
|
|
content_md=snapshot,
|
|
title=document.title if snapshot is not None else None,
|
|
visibility=document.visibility,
|
|
meta=document.meta if snapshot is not None else None,
|
|
created_at=at,
|
|
updated_at=at,
|
|
)
|
|
)
|
|
return at
|
|
|
|
# The text as it stood before the reviewer's correction — everything up to
|
|
# their answer holds the old wording.
|
|
written = content
|
|
if review and review.correction:
|
|
current, previous = review.correction
|
|
if current not in content:
|
|
raise ValueError(f"correction text not found in {slug}: {current!r}")
|
|
written = content.replace(current, previous)
|
|
|
|
steps = _writing_steps(written)
|
|
happened(DocumentEventAction.created, author, steps[0])
|
|
for step in steps[1:]:
|
|
happened(DocumentEventAction.edited, author, step, after=timedelta(minutes=40))
|
|
|
|
if not is_draft:
|
|
happened(DocumentEventAction.published, author, after=timedelta(days=1))
|
|
|
|
if review:
|
|
reviewer = users_by_email[review.reviewer]
|
|
asked_at = happened(
|
|
DocumentEventAction.review_requested, author, after=timedelta(days=3)
|
|
)
|
|
resolved_at = None
|
|
if review.answered:
|
|
if review.correction:
|
|
happened(
|
|
DocumentEventAction.edited,
|
|
reviewer,
|
|
content,
|
|
after=timedelta(days=1),
|
|
)
|
|
resolved_at = happened(
|
|
DocumentEventAction.review_resolved,
|
|
reviewer,
|
|
after=timedelta(minutes=20),
|
|
)
|
|
db.add(
|
|
ReviewRequest(
|
|
document_id=document.id,
|
|
requester_id=author.id if author else None,
|
|
reviewer_id=reviewer.id,
|
|
question=review.question,
|
|
resolved_at=resolved_at,
|
|
resolved_by_id=reviewer.id if resolved_at else None,
|
|
created_at=asked_at,
|
|
updated_at=resolved_at or asked_at,
|
|
)
|
|
)
|
|
|
|
if slug in ARCHIVED_SLUGS:
|
|
document.status = DocumentStatus.archived
|
|
happened(DocumentEventAction.archived, author, after=timedelta(days=30))
|
|
|
|
document.created_at = started_at
|
|
document.updated_at = at
|
|
return document
|
|
|
|
|
|
def main() -> None:
|
|
if get_settings().env == "production":
|
|
sys.exit(
|
|
"Refusing to seed: PABLAN_ENV=production. "
|
|
"Seed data contains known dev credentials."
|
|
)
|
|
asyncio.run(seed())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|