feat: configurable SubQuestions via Step 1.2 system prompt page

- Split 'Step 1: Query Decomposition' into Step 1.1 (prompt template) and Step 1.2 (format config with description + max_length)

- Add create_subquestions_model() and parse_decompose_format() to decompose.py

- QueryDecomposer reads decompose_format from DB, creates dynamic Pydantic model at runtime

- PromptEditor renders Step 1.2 as textarea (description) + number input (max_length 1-5)

- Graceful fallback to static SubQuestions when decompose_format unavailable
This commit is contained in:
Woody 2026-05-04 17:22:14 +08:00
parent 40b338d3ca
commit 76c3bec2ab
7 changed files with 228 additions and 52 deletions

View File

@ -11,12 +11,18 @@ logger = logging.getLogger(__name__)
_SEED_DECOMPOSE = (
"Given this question: '{question}'\n\n"
"Break it down into 2-5 simplified sub-questions that would help "
"Break it down into 1-3 simplified sub-questions that would help "
"search for relevant information. Each sub-question should be short "
"and focused on one aspect.\n\n"
'Return a JSON array of strings: ["sub-question 1", "sub-question 2", ...]'
)
_SEED_DECOMPOSE_FORMAT = (
'{"description": "請將問題/任務拆解成 1-3 個簡化子問題,標籤式主題必須清楚、簡潔、具體,'
'一看就明白(建議 3-8 個字),若涉及地點、地區、人物、時間、金額/財政 等關鍵資訊,必須包含在標籤中 。'
'具體提問/要求要精準、完整 並全部轉換成以下固定格式:\\n「標籤式主題具體提問/要求」", "max_length": 3}'
)
_SEED_FILTER = (
"Given question '{question}' and these document chunks, rate each 0-10 for relevance. "
"Return JSON array of scores.\n{chunks}\n"
@ -68,6 +74,7 @@ _SEED_PROFILES = [
_SEED_STEPS = [
"decompose",
"decompose_format",
"filter",
"generate",
"generate_per_subq",
@ -77,6 +84,7 @@ _SEED_STEPS = [
]
_SEED_TEMPLATES = {
"decompose": _SEED_DECOMPOSE,
"decompose_format": _SEED_DECOMPOSE_FORMAT,
"filter": _SEED_FILTER,
"generate": _SEED_GENERATE,
"generate_per_subq": _SEED_GENERATE_PER_SUBQ,

View File

@ -1,11 +1,14 @@
from pydantic import BaseModel, Field
from typing import Any
from pydantic import BaseModel, Field, create_model
class SubQuestions(BaseModel):
"""Structured output model for query decomposition.
"""Structured output model for query decomposition — static fallback.
Used with LangChain's with_structured_output() to guarantee
the LLM returns a valid list of sub-questions.
When ``decompose_format`` is available from PromptService, a dynamic
model with DB-configured description and max_length is used instead.
See ``create_subquestions_model()``.
"""
questions: list[str] = Field(
@ -13,3 +16,54 @@ class SubQuestions(BaseModel):
min_length=1,
max_length=3,
)
def create_subquestions_model(description: str, max_length: int) -> type[BaseModel]:
"""Create a dynamic SubQuestions model with configurable field constraints.
Args:
description: Field description text injected into the JSON format
instruction sent to the LLM.
max_length: Maximum number of sub-questions (1-5).
Returns:
A Pydantic BaseModel subclass with the configured constraints.
"""
return create_model(
"SubQuestions",
questions=(
list[str],
Field(
description=description,
min_length=1,
max_length=max_length,
),
),
)
def parse_decompose_format(raw: str) -> dict[str, Any]:
"""Parse the decompose_format JSON string into a validated config dict.
Returns a dict with ``description`` (str) and ``max_length`` (int).
Raises ``ValueError`` if the JSON is invalid or fields are missing/malformed.
"""
import json
try:
config = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"decompose_format is not valid JSON: {exc}") from exc
if not isinstance(config, dict):
raise ValueError("decompose_format must be a JSON object")
description = config.get("description")
if not isinstance(description, str) or not description.strip():
raise ValueError("decompose_format.description must be a non-empty string")
max_length = config.get("max_length")
if not isinstance(max_length, int) or max_length < 1 or max_length > 5:
raise ValueError("decompose_format.max_length must be an integer between 1 and 5")
return {"description": description.strip(), "max_length": max_length}

View File

@ -25,6 +25,7 @@ router = APIRouter(prefix="/api/v1/prompts", tags=["prompts"])
_VALID_NAMES = {"A", "B", "C"}
_VALID_STEPS = {
"decompose",
"decompose_format",
"filter",
"generate",
"generate_per_subq",

View File

@ -14,6 +14,7 @@ logger = logging.getLogger(__name__)
_VALID_NAMES = {"A", "B", "C"}
_VALID_STEPS = {
"decompose",
"decompose_format",
"filter",
"generate",
"generate_per_subq",

View File

@ -24,11 +24,21 @@ logger = logging.getLogger(__name__)
# Fallback template used when prompt_service is not provided (tests, standalone).
_BUILTIN_DECOMPOSE_TEMPLATE = (
"Given this question: '{question}'\n\n"
"Break it down into 2-5 simplified sub-questions that would help "
"Break it down into 1-3 simplified sub-questions that would help "
"search for relevant information. Each sub-question should be short "
"and focused on one aspect. Return as a JSON array of strings."
)
_DEFAULT_DECOMPOSE_FORMAT = {
"description": (
"請將問題/任務拆解成 1-3 個簡化子問題,標籤式主題必須清楚、簡潔、具體,"
"一看就明白(建議 3-8 個字),若涉及地點、地區、人物、時間、金額/財政 等關鍵資訊,"
"必須包含在標籤中 。具體提問/要求要精準、完整 並全部轉換成以下固定格式:\n"
"「標籤式主題:具體提問/要求」"
),
"max_length": 3,
}
def _extract_json_from_markdown(response: str) -> str:
if not isinstance(response, str):
@ -98,12 +108,32 @@ class QueryDecomposer:
prompt = template.replace("{question}", question)
from app.models.decompose import SubQuestions
from app.models.decompose import SubQuestions, create_subquestions_model, parse_decompose_format
pydantic_model = SubQuestions
if self._prompt_service is not None:
try:
format_raw = self._prompt_service.get_prompt_template("decompose_format")
format_config = parse_decompose_format(format_raw)
pydantic_model = create_subquestions_model(
description=format_config["description"],
max_length=format_config["max_length"],
)
logger.info(
"Decompose format loaded from DB: max_length=%d",
format_config["max_length"],
)
except Exception as exc:
logger.warning(
"Failed to load decompose_format from DB: %s. Falling back to static SubQuestions.",
exc,
)
try:
result = await self.llm_client.complete_structured(
prompt=prompt,
pydantic_model=SubQuestions,
pydantic_model=pydantic_model,
step_name="QueryDecomposer",
)
return result.questions, prompt

View File

@ -14,7 +14,8 @@ export interface PromptEditorProps {
}
const STEPS = [
{ key: 'decompose', label: 'Step 1: Query Decomposition', placeholders: ['question'] },
{ key: 'decompose', label: 'Step 1.1: Query Decomposition', placeholders: ['question'] },
{ key: 'decompose_format', label: 'Step 1.2: Query Decomposition Format', placeholders: [], type: 'format_config' },
{ key: 'filter_intro', label: 'Step 2.1: Filter Intro (Preamble)', placeholders: [] },
{ key: 'filter_section', label: 'Step 2.2: Filter Section (Per Sub-Q)', placeholders: ['subq_idx', 'subq_question', 'chunks'] },
{ key: 'filter_outro', label: 'Step 2.3: Filter Outro (Format)', placeholders: [] },
@ -23,6 +24,7 @@ const STEPS = [
const VALID_PLACEHOLDERS: Record<string, string[]> = {
decompose: ['{question}'],
decompose_format: [],
filter: ['{question}', '{chunks}'],
generate: ['{question}', '{context}'],
generate_per_subq: ['{context_sections}'],
@ -37,6 +39,68 @@ const findUnknownPlaceholders = (template: string, stepKey: string): string[] =>
return found.filter((p) => !valid.includes(p))
}
const DEFAULT_FORMAT_CONFIG = { description: '', max_length: 3 }
function parseFormatConfig(raw: string): { description: string; max_length: number } {
try {
const parsed = JSON.parse(raw)
return {
description: typeof parsed.description === 'string' ? parsed.description : '',
max_length: typeof parsed.max_length === 'number' ? parsed.max_length : 3,
}
} catch {
return { ...DEFAULT_FORMAT_CONFIG }
}
}
function serializeFormatConfig(description: string, max_length: number): string {
return JSON.stringify({ description, max_length })
}
interface FormatConfigFieldsProps {
value: string
disabled: boolean
onChange: (description: string, max_length: number) => void
}
const FormatConfigFields: React.FC<FormatConfigFieldsProps> = ({ value, disabled, onChange }) => {
const config = parseFormatConfig(value)
return (
<div className="space-y-3 pl-1">
<div className="space-y-1">
<label className="text-xs font-medium text-gray-600">Description (field description for sub-question format)</label>
<textarea
value={config.description}
onChange={(e) => onChange(e.target.value, config.max_length)}
disabled={disabled}
rows={5}
className="w-full rounded border border-gray-300 px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed resize-y"
/>
<span className="text-xs text-gray-500">{config.description.length} characters</span>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-600">Max Sub-Questions (15)</label>
<input
type="number"
min={1}
max={5}
value={config.max_length}
onChange={(e) => {
const v = parseInt(e.target.value, 10)
if (!isNaN(v) && v >= 1 && v <= 5) {
onChange(config.description, v)
}
}}
disabled={disabled}
className="w-24 rounded border border-gray-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
</div>
</div>
)
}
export const PromptEditor: React.FC<PromptEditorProps> = ({
profileName,
prompts,
@ -80,7 +144,9 @@ export const PromptEditor: React.FC<PromptEditorProps> = ({
{STEPS.map((step) => {
const value = localPrompts[step.key] ?? ''
const unknownPlaceholders = findUnknownPlaceholders(value, step.key)
const unknownPlaceholders = step.type === 'format_config'
? []
: findUnknownPlaceholders(value, step.key)
const placeholderBadges = step.placeholders.map((p) => `{${p}}`).join(', ')
return (
@ -99,6 +165,14 @@ export const PromptEditor: React.FC<PromptEditorProps> = ({
</div>
</div>
{step.type === 'format_config' ? (
<FormatConfigFields
value={value}
disabled={isSaving}
onChange={(desc, ml) => handleChange(step.key, serializeFormatConfig(desc, ml))}
/>
) : (
<>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-500">Placeholders:</span>
{step.placeholders.map((p) => (
@ -132,6 +206,8 @@ export const PromptEditor: React.FC<PromptEditorProps> = ({
</div>
)}
</div>
</>
)}
</div>
)
})}

View File

@ -4,6 +4,7 @@ import { PromptEditor } from '../../components/PromptEditor'
const mockPrompts: Record<string, string> = {
decompose: 'Decompose template with {question}',
decompose_format: '{"description": "test description", "max_length": 3}',
filter_intro: 'Evaluate each chunk for relevance',
filter_section: 'Sub-question {subq_idx}: "{subq_question}"\n{chunks}',
filter_outro: 'Return JSON object mapping sub-question indices to scores.',
@ -31,15 +32,16 @@ describe('PromptEditor', () => {
defaultProps.onCancel.mockClear()
})
it('renders 5 textareas for the active steps', () => {
it('renders 6 textareas for the active steps (5 regular + 1 format description)', () => {
render(<PromptEditor {...defaultProps} />)
const textareas = screen.getAllByRole('textbox')
expect(textareas).toHaveLength(5)
expect(textareas).toHaveLength(6)
})
it('renders labels for each step', () => {
render(<PromptEditor {...defaultProps} />)
expect(screen.getByText('Step 1: Query Decomposition')).toBeInTheDocument()
expect(screen.getByText('Step 1.1: Query Decomposition')).toBeInTheDocument()
expect(screen.getByText('Step 1.2: Query Decomposition Format')).toBeInTheDocument()
expect(screen.getByText('Step 2.1: Filter Intro (Preamble)')).toBeInTheDocument()
expect(screen.getByText('Step 2.2: Filter Section (Per Sub-Q)')).toBeInTheDocument()
expect(screen.getByText('Step 2.3: Filter Outro (Format)')).toBeInTheDocument()
@ -66,18 +68,21 @@ describe('PromptEditor', () => {
render(<PromptEditor {...defaultProps} />)
const resetButtons = screen.getAllByTitle(/reset.*to default/i)
expect(resetButtons).toHaveLength(5)
expect(resetButtons).toHaveLength(6)
fireEvent.click(resetButtons[0])
expect(defaultProps.onResetStep).toHaveBeenCalledWith('decompose')
fireEvent.click(resetButtons[1])
expect(defaultProps.onResetStep).toHaveBeenCalledWith('decompose_format')
fireEvent.click(resetButtons[2])
expect(defaultProps.onResetStep).toHaveBeenCalledWith('filter_intro')
fireEvent.click(resetButtons[3])
fireEvent.click(resetButtons[4])
expect(defaultProps.onResetStep).toHaveBeenCalledWith('filter_outro')
fireEvent.click(resetButtons[4])
fireEvent.click(resetButtons[5])
expect(defaultProps.onResetStep).toHaveBeenCalledWith('generate_per_subq')
confirmSpy.mockRestore()
@ -143,10 +148,11 @@ describe('PromptEditor', () => {
const textareas = screen.getAllByRole('textbox')
expect(textareas[0]).toHaveValue('Decompose template with {question}')
expect(textareas[1]).toHaveValue('Evaluate each chunk for relevance')
expect(textareas[2]).toHaveValue('Sub-question {subq_idx}: "{subq_question}"\n{chunks}')
expect(textareas[3]).toHaveValue('Return JSON object mapping sub-question indices to scores.')
expect(textareas[4]).toHaveValue('Generate per-subq with {context_sections}')
expect(textareas[1]).toHaveValue('test description')
expect(textareas[2]).toHaveValue('Evaluate each chunk for relevance')
expect(textareas[3]).toHaveValue('Sub-question {subq_idx}: "{subq_question}"\n{chunks}')
expect(textareas[4]).toHaveValue('Return JSON object mapping sub-question indices to scores.')
expect(textareas[5]).toHaveValue('Generate per-subq with {context_sections}')
})
it('calls onUpdate when typing in a textarea', () => {