feat(prompts): implement TOML-backed prompt loading and refactor message generation

This commit is contained in:
2026-07-24 11:38:51 -04:00
parent 94f18722e4
commit 8073144e2b
7 changed files with 83 additions and 24 deletions
+2 -10
View File
@@ -6,6 +6,7 @@ import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from python.ebook_search.llm_interface import request_chat_completion from python.ebook_search.llm_interface import request_chat_completion
from python.ebook_search.prompts import load_prompt
if TYPE_CHECKING: if TYPE_CHECKING:
import httpx import httpx
@@ -42,16 +43,7 @@ async def answer_query(
content = await request_chat_completion( content = await request_chat_completion(
client, client,
config, config,
[ load_prompt("answer").messages(query=query, context=context),
{
"role": "system",
"content": (
"Answer only from the provided context. Cite sources with bracketed numbers like [1]. "
"If the context is insufficient, say so."
),
},
{"role": "user", "content": f"Question:\n{query}\n\nContext:\n{context}"},
],
) )
logger.info(f"ebook_answer_request_complete {config.chat_model=} answer_length={len(content)}") logger.info(f"ebook_answer_request_complete {config.chat_model=} answer_length={len(content)}")
+8
View File
@@ -0,0 +1,8 @@
"""LLM prompt templates for EPUB search."""
from python.ebook_search.prompts.lib import (
Prompt,
load_prompt,
)
__all__ = ["Prompt", "load_prompt"]
+9
View File
@@ -0,0 +1,9 @@
system = """\
Answer only from the provided context. Cite sources with bracketed numbers like [1]. \
If the context is insufficient, say so."""
user = """\
Question:
{query}
Context:
{context}"""
+44
View File
@@ -0,0 +1,44 @@
"""Load and render TOML-backed LLM prompt templates."""
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from functools import cache
from pathlib import Path
@dataclass(frozen=True)
class Prompt:
"""A system and user prompt pair loaded from TOML."""
system: str
user: str
def messages(self, **values: str) -> list[dict[str, str]]:
"""Render this prompt as OpenAI-style chat messages."""
return [
{"role": "system", "content": self.system.format(**values)},
{"role": "user", "content": self.user.format(**values)},
]
@cache
def _get_prompt_dir() -> Path:
"""Return the directory containing prompt template files."""
return Path(__file__).resolve().parent
@cache
def load_prompt(name: str) -> Prompt:
"""Load and validate a named system and user prompt pair from TOML."""
path = _get_prompt_dir() / f"{name}.toml"
with path.open("rb") as file:
body = tomllib.load(file)
system = body.get("system")
user = body.get("user")
if not isinstance(system, str) or not isinstance(user, str):
msg = f"{path} must define string system and user prompts"
raise TypeError(msg)
return Prompt(system=system, user=user)
@@ -0,0 +1,10 @@
system = """\
Judge whether a candidate phrase from a book should be protected for RAG retrieval. \
Do not extract new phrases. Reject common grammar fragments, ordinary nonspecific \
phrases, unstable fragments, and phrases kept only because they are frequent. Keep \
people, places, organizations, factions, events, technologies, fictional conditions, \
magic systems, formal titles, named concepts, and recurring world-specific terms. \
Return only a JSON object with keys: keep, canonical, category, aliases, confidence, \
importance, allow_nested, suppress_children, reason."""
user = "{candidate_json}"
@@ -14,6 +14,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from python.ebook_search.llm_interface import request_chat_completion from python.ebook_search.llm_interface import request_chat_completion
from python.ebook_search.prompts import load_prompt
from python.ebook_search.protected_phrases.extraction import ( from python.ebook_search.protected_phrases.extraction import (
candidate_source_names, candidate_source_names,
get_sample_contexts, get_sample_contexts,
@@ -369,20 +370,7 @@ def build_judge_messages(candidate: PhraseCandidate) -> list[dict[str, str]]:
"chapter_count": candidate.chapter_count, "chapter_count": candidate.chapter_count,
"contexts": candidate.sample_contexts, "contexts": candidate.sample_contexts,
} }
return [ return load_prompt("phrase_judge").messages(candidate_json=json.dumps(payload, ensure_ascii=True))
{
"role": "system",
"content": (
"Judge whether a candidate phrase from a book should be protected for RAG retrieval. "
"Do not extract new phrases. Reject common grammar fragments, ordinary nonspecific phrases, "
"unstable fragments, and phrases kept only because they are frequent. Keep people, places, "
"organizations, factions, events, technologies, fictional conditions, magic systems, formal titles, "
"named concepts, and recurring world-specific terms. Return only a JSON object with keys: keep, "
"canonical, category, aliases, confidence, importance, allow_nested, suppress_children, reason."
),
},
{"role": "user", "content": json.dumps(payload, ensure_ascii=True)},
]
def parse_llm_judgment(content: str, config: EbookSearchConfig) -> LLMJudgment: def parse_llm_judgment(content: str, config: EbookSearchConfig) -> LLMJudgment:
+8
View File
@@ -93,6 +93,14 @@ async def test_answer_query_uses_httpx_chat_completions(mocker: MockerFixture) -
payload = kwargs["json"] payload = kwargs["json"]
assert isinstance(payload, dict) assert isinstance(payload, dict)
assert payload["model"] == "deepseek-v4-flash" assert payload["model"] == "deepseek-v4-flash"
assert payload["messages"] == [
{
"role": "system",
"content": "Answer only from the provided context. Cite sources with bracketed numbers like [1]. "
"If the context is insufficient, say so.",
},
{"role": "user", "content": "Question:\nquestion\n\nContext:\n[1] Book\nsource"},
]
async def test_embed_texts_uses_httpx_embeddings(mocker: MockerFixture) -> None: async def test_embed_texts_uses_httpx_embeddings(mocker: MockerFixture) -> None: