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
This commit is contained in:
ProfessorNova
2026-09-04 08:36:17 +02:00
co-authored by Claude Opus 5
commit 97dbff309c
346 changed files with 43430 additions and 0 deletions
View File
+89
View File
@@ -0,0 +1,89 @@
"""The shipped system prompts, as code defaults.
Every prompt Pablan sends has its base text here, keyed by a stable id. The
render functions in `app/modes/prompts.py` and `app/authoring/prompts.py` read
the *effective* value through `app/prompts/overrides.py::get_prompt`, which
returns an admin's DB override when one exists and this default otherwise.
Kept as pure strings with no imports so both the overrides cache and the render
functions can depend on it without a cycle. Editing a value here ships a new
default (and resets restore to it); an admin's live override always wins.
"""
# The query (RAG Q&A) assistant. Kept byte-identical per turn so the endpoint's
# prompt cache reuses it — a DB override only changes on an admin write, so that
# still holds (docs/architecture.md, prompt caching).
QUERY_SYSTEM = """\
You are Pablan, this company's internal knowledge assistant.
Answer in the language of the question. Company facts — processes, numbers,
names, responsibilities — come only from the excerpts you are given; say when
something is not documented rather than filling the gap. Be brief and concrete.
"""
# Appended to the final user turn when retrieval found nothing relevant, so the
# assistant still answers a greeting or general question without pretending the
# answer is company policy.
QUERY_NO_SOURCES = """\
The knowledge base has nothing relevant for this message.
Answer anyway, using your general knowledge, and be genuinely useful — a
greeting deserves a normal reply, a general question a real answer. The one
thing you must not do is state anything as if it were this company's
documented process, policy or data. Where the answer would depend on how
this company works, say plainly that this is not documented yet.
"""
# The default persona for section refinement (a template may override it per
# document); the mechanical rules the refined section must follow.
REFINE_PERSONA = (
"You are a precise technical editor in a knowledge-management tool. You "
"turn rough notes into clear, matter-of-fact documentation."
)
REFINE_RULES = (
"Rules: reply in the language the section is written in. Return ONLY the "
"refined section as plain Markdown — no preamble, no explanation, no code "
"fence around the whole thing, and none of the other sections. Keep a "
"heading the section starts with unchanged. Improve clarity, grammar and "
"structure (use a list where the content is a sequence of steps), but "
"invent no facts: use only what the section already states. If the section "
"is already clear, change it little."
)
# The instruction that frames the retrieved grounding block during refinement;
# the retrieved excerpts are appended after it.
GROUNDING_FRAMING = (
"Related knowledge already documented elsewhere (use it only to stay "
"consistent and to reference where this section connects to it — do not "
"copy it in and add no facts from it that the notes above do not already "
"state):"
)
# Condensing a conversation into a short search topic (the topic-summary path).
TOPIC_SUMMARY = (
"You condense a conversation into a short search topic for a knowledge "
"base. Reply with a concise noun phrase (a few words) in the language of "
"the conversation, naming what it is about. No sentence, no preamble, no "
"quotes."
)
# Suggesting a document title from its content.
TITLE = (
"You suggest a concise, specific title for a knowledge document, in the "
"language of the document. Reply with the title only: a short noun phrase, "
"no quotes, no trailing punctuation."
)
DEFAULTS: dict[str, str] = {
"query_system": QUERY_SYSTEM,
"query_no_sources": QUERY_NO_SOURCES,
"refine_persona": REFINE_PERSONA,
"refine_rules": REFINE_RULES,
"grounding_framing": GROUNDING_FRAMING,
"topic_summary": TOPIC_SUMMARY,
"title": TITLE,
}
# Stable display/iteration order for the admin panel.
PROMPT_KEYS: tuple[str, ...] = tuple(DEFAULTS)
+55
View File
@@ -0,0 +1,55 @@
"""The effective system prompts, as the process sees them.
Prompts default to code (`app/prompts/defaults.py`); an admin may override any
of them in `prompt_settings`, applied without a restart. This mirrors the LLM
settings override pattern, with two simplifications: the reset target is the
code default (prompts have no `.env` layer), and there is no bootstrap — a
missing row simply means "use the default".
`get_prompt` is called while rendering a prompt, so it reads a module-level
cache rather than awaiting a query. The cache is filled at startup and refreshed
on every admin write. Single-process by design (`--workers 1`); a multi-worker
deployment would need a notification channel.
"""
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import PromptSetting
from app.prompts.defaults import DEFAULTS
logger = logging.getLogger("pablan.prompts")
# Only holds the keys an admin has actually overridden.
_config: dict[str, str] = {}
async def load_config(db: AsyncSession) -> None:
"""Re-read every override row into the cache. Call at startup and after any
admin write."""
rows = (await db.execute(select(PromptSetting))).scalars().all()
_config.clear()
_config.update({row.key: row.content for row in rows if row.key in DEFAULTS})
logger.info(
"prompt settings loaded",
extra={"event": "prompt_settings_loaded", "overridden": sorted(_config)},
)
def get_prompt(key: str) -> str:
"""The effective prompt: an admin override if present, else the code default.
`key` must be a known prompt (a `KeyError` here is a programming error, not
user input)."""
return _config.get(key) or DEFAULTS[key]
def is_overridden(key: str) -> bool:
return key in _config
def clear() -> None:
"""Drop the cache — used by tests between cases."""
_config.clear()