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:
co-authored by
Claude Opus 5
parent
68d3a43191
commit
784b76baf7
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user