# SPDX-License-Identifier: BUSL-1.1 # Copyright (c) 2024-2026 Ali Sadeghi Aghili """Assemble the final LLM prompt from retrieval context and schema. This module contains a single static class, :class:`PromptBuilder`, whose ``build()`` method combines every piece of retrieved knowledge — schema, relationships, business rules, few-shot examples, and value filters — into a single structured string ready for the LLM backend. Two assembly paths, one gate ----------------------------- As of Phase 2 (latency), ``build()`` no longer always re-retrieves and re-assembles prompt content per question. It picks one of two paths via :func:`prompt_engine.static_prefix.should_use_static_prefix`: * **Static path** (the default for today's 12-table schema) — a byte-identical prefix (system prompt, full schema, all relationships, all business rules, all metrics, all examples) built once and cached by :mod:`prompt_engine.static_prefix`, followed by a small variable suffix (detected filters, session context, the question). Identical prefix bytes across requests is what lets llama.cpp/vLLM reuse its KV cache instead of re-running prefill over the whole prompt every time — ``docs/api-contract-v2.md`` §8. * **Retrieval path** (the scaling escape hatch) — the original per-question behaviour: only the tables the six-retriever pipeline matched go into the prompt. Used automatically once the knowledge base grows past ``cfg.settings.prompt_retrieval_token_budget``. One data source per prompt -------------------------- With several data sources configured, every method takes the ``source`` a question was routed to (:mod:`retrieval.source_selector`) and describes that source alone: its tables, the relationships between them, and the few-shot examples that can run there. The path is chosen per source: the static prefix when that source's prefix fits the token budget, otherwise retrieval restricted to its tables -- and when retrieval finds no table, every table *of that source* is shown, never every table of the deployment. ``source=None`` (every single-source deployment) changes nothing. Typical usage:: from retrieval.context_retriever import ContextRetriever from prompt_engine.builder import PromptBuilder context = ContextRetriever.retrieve("فروش ماهانه مشتریان") prompt = PromptBuilder.build( question="فروش ماهانه مشتریان", system_prompt=system_prompt_text, context=context, ) """ from __future__ import annotations from core.models import RetrievalContext from prompt_engine.source_scope import examples_for_source, scoped_source from prompt_engine.static_prefix import build_static_prefix, should_use_static_prefix from prompt_engine.templates import PROMPT_TEMPLATE, SUFFIX_TEMPLATE from prompt_engine.untrusted import UNTRUSTED_INSTRUCTION, fence_untrusted from schema_data.registry import SchemaRegistry def _render_access_notes(access_notes: str) -> str: """Render *access_notes* as its own ``COLUMN ACCESS`` section, or ``""``. The returned text starts with a newline and ends with one, so that dropped between the two blank-line-separated sections of :data:`~prompt_engine.templates.SUFFIX_TEMPLATE` / :data:`~prompt_engine.templates.PROMPT_TEMPLATE` it reads as one more section, and an empty *access_notes* leaves the template exactly as it was. Examples -------- >>> _render_access_notes("") '' >>> print(_render_access_notes("Use X only in JOIN ... ON.")) ================================================== COLUMN ACCESS ================================================== Use X only in JOIN ... ON. """ if not access_notes: return "" return ( "\n==================================================\n" "COLUMN ACCESS\n" "==================================================\n\n" f"{access_notes}\n" ) def _render_resolved_values(resolved_values: dict[str, list[str]] | None) -> str: """Render *resolved_values* as a fenced, explicitly-labelled block. Finding 19 (2026 audit): a value in *resolved_values* was read out of the live warehouse by :func:`retrieval.value_resolver.resolve_value` (or :mod:`retrieval.dimension_vocabulary`) because it matched something in the analyst's question — it is exchange data, not something either this codebase or the analyst wrote. Splicing it into the prompt as bare prose (the pre-fix behaviour of ``prompt_engine/builder.py``, via ``context.filters``) put it on equal footing with the actual instructions. Every value here goes through :func:`~prompt_engine.untrusted.fence_untrusted` instead, preceded by :data:`~prompt_engine.untrusted.UNTRUSTED_INSTRUCTION` so the fence has a stated meaning rather than being two mysterious tokens. Returns ``""`` for ``None``/empty input, so an unaffected caller's prompt is byte-identical to before this section existed — an empty ``RESOLVED WAREHOUSE VALUES`` block is not exercised further. """ if not resolved_values: return "" lines = [ f"{identifier}: {', '.join(str(v) for v in values)}" for identifier, values in resolved_values.items() ] body = "\n".join(lines) return f"{UNTRUSTED_INSTRUCTION}\n{fence_untrusted(body)}" class PromptBuilder: """Stateless factory that converts a :class:`~core.models.RetrievalContext` into a prompt string. All methods are static — there is no instance state. The class exists purely as a namespace to keep prompt-assembly logic together. Design notes ------------ * :meth:`build` dispatches to :meth:`build_static` or :meth:`_build_retrieval` based on :func:`~prompt_engine.static_prefix.should_use_static_prefix` — see the module docstring. * :meth:`_build_retrieval` (the fallback path) uses ``context.selected_tables`` — the order-preserving property on :class:`~core.models.RetrievalContext` — rather than deduplicating ``context.entities + context.facts`` with a bare ``set()``. A plain ``set()`` does not preserve insertion order, which made table order in the prompt vary between runs with the same input for no reason; this was flagged in this module's own prior docstring but never fixed until now. * :meth:`~schema_data.registry.SchemaRegistry.build_schema_context` is called with the deduplicated table list so the schema block contains only the columns relevant to the question (retrieval path), or with ``None`` for every table (static path). * Empty sections (no rules, no examples, no filters) produce empty strings rather than placeholder text — the template handles missing sections gracefully without printing ``"None"`` or ``"[]"``. """ @staticmethod def build( question: str, system_prompt: str, context: RetrievalContext | None = None, *, session_context: str = "", resolved_values: dict[str, list[str]] | None = None, source: str | None = None, access_notes: str = "", ) -> str: """Build a complete prompt string for the LLM backend. Parameters ---------- question: The original natural-language question in Persian or English. system_prompt: Domain-specific system instructions loaded from ``/system_prompt.md`` at server startup. context: Fully populated :class:`~core.models.RetrievalContext` produced by :class:`~retrieval.context_retriever.ContextRetriever`. Only ``context.filters`` is used on the static path (see the module docstring); every field is used on the retrieval fallback path. Defaults to an empty :class:`~core.models.RetrievalContext` (no tables, no filters) so a caller that only wants to exercise *resolved_values* — e.g. a test asserting the fence in isolation — need not construct one. session_context: Prior-turn context for a conversational session (see ``docs/api-contract-v2.md`` §8's "session context" block — question, SQL, and result *column names* for the last ``session_prompt_turns`` turns; never row data). Empty string when there is no session, which is every call today — sessions are not yet implemented. Always placed in the variable suffix, never the static prefix. resolved_values: ``{"Table.Column": [value, ...]}`` for warehouse values that matched the question (Finding 19, 2026 audit) — the same identifier shape :attr:`observability.audit.AuditRecord .resolved_columns` already uses. Rendered into its own ``RESOLVED WAREHOUSE VALUES`` section, fenced via :func:`prompt_engine.untrusted.fence_untrusted` so the model is told, in the prompt itself, that this span is data rather than instruction — see ``prompt_engine/untrusted.py``'s module docstring. Deliberately **separate** from ``context.filters``: that dict already mixes values taken from the question's own text with values matched against the live warehouse, with no way to tell the two apart once merged — see ``prompt_engine/templates.py``'s ``SUFFIX_TEMPLATE`` comment. ``None`` (the default) renders an empty section, byte-identical to a prompt built before this parameter existed. source: The data source the question was routed to. With several sources configured the prompt describes only that source (see the module docstring); ``None`` (the default, and the only value that does anything with one source) describes the whole schema, as before. access_notes: One line naming the columns the calling principal may use only as ``JOIN ... ON`` keys (:func:`security.column_policy.join_only_prompt_line`). Rendered under its own ``COLUMN ACCESS`` heading in the variable suffix -- never in the cached static prefix, so the prefix stays byte-identical across principals. ``""`` (the default) renders nothing, byte-identical to a prompt built before this existed. Returns ------- str A ready-to-send prompt string. Raises ------ ValueError If several sources are configured and *source* is not one of them. Examples -------- On the static path (today's schema), the prompt always contains every known table and every configured example — ``context``'s own ``entities``/``examples`` only matter on the retrieval fallback path (see :meth:`_build_retrieval`): >>> from core.models import RetrievalContext >>> ctx = RetrievalContext( ... entities=["Customer"], ... facts=["Contract"], ... filters={"PersianYear": 1402}, ... ) >>> prompt = PromptBuilder.build("خرید مشتریان در ۱۴۰۲", "You are a T-SQL expert.", ctx) >>> "Table: Customer" in prompt True >>> "1402" in prompt True Forcing the retrieval fallback path (a tiny token budget) makes the prompt reflect ``context``'s own retrieved knowledge instead: >>> from config import override_settings >>> ctx2 = RetrievalContext( ... entities=["Customer"], ... facts=["Contract"], ... relationships=["JOIN [Dim].[Customer] ON [Fact].[Contract].[CustID] = [Dim].[Customer].[CustID]"], ... business_rules=["Persian year starts from Farvardin (month 1)."], ... examples=[{"question": "Top buyers", "sql": "SELECT TOP 10 Name FROM Customer", "tags": ["customer"]}], ... filters={"PersianYear": 1402}, ... ) >>> with override_settings(prompt_retrieval_token_budget=1): ... fallback_prompt = PromptBuilder.build( ... "خرید مشتریان در ۱۴۰۲", "You are a T-SQL expert.", ctx2 ... ) >>> "Top buyers" in fallback_prompt True A resolved warehouse value arrives fenced, not as bare prose (Finding 19, 2026 audit) — ``context`` is optional when only this is being exercised: >>> from prompt_engine.untrusted import UNTRUSTED_OPEN >>> fenced_prompt = PromptBuilder.build( ... "گزارش فولاد مبارکه", "You are a T-SQL expert.", ... resolved_values={"Supplier.Customer_Name": ["فولاد مبارکه"]}, ... ) >>> before = fenced_prompt.split("فولاد مبارکه")[0] >>> UNTRUSTED_OPEN in before True """ if context is None: context = RetrievalContext() source = scoped_source(source) if should_use_static_prefix(system_prompt, source): return PromptBuilder.build_static( question, system_prompt, context, session_context=session_context, resolved_values=resolved_values, source=source, access_notes=access_notes, ) return PromptBuilder._build_retrieval( question, system_prompt, context, session_context=session_context, resolved_values=resolved_values, source=source, access_notes=access_notes, ) @staticmethod def build_static( question: str, system_prompt: str, context: RetrievalContext, *, session_context: str = "", resolved_values: dict[str, list[str]] | None = None, source: str | None = None, access_notes: str = "", ) -> str: """Static-prefix path: cached prefix + a small variable suffix. The prefix (system prompt, full schema, all relationships, all business rules, all metrics, all examples) comes from :func:`~prompt_engine.static_prefix.build_static_prefix`, which is cached and therefore byte-identical across calls with the same ``system_prompt`` — the whole point of this path (see module docstring). Only ``context.filters``, ``session_context``, ``resolved_values``, and ``question`` vary. Parameters ---------- question, system_prompt, context, session_context, resolved_values, source: As in :meth:`build`. Returns ------- str Examples -------- >>> from core.models import RetrievalContext >>> ctx = RetrievalContext(filters={"Ring": "تالار پتروشیمی"}) >>> p1 = PromptBuilder.build_static("q1", "You are a T-SQL expert.", ctx) >>> p2 = PromptBuilder.build_static("q2", "You are a T-SQL expert.", ctx) >>> p1[: p1.index("DETECTED FILTERS")] == p2[: p2.index("DETECTED FILTERS")] True >>> "تالار پتروشیمی" in p1 True """ prefix = build_static_prefix(system_prompt, source) return prefix + PromptBuilder.build_static_suffix( question, context, session_context=session_context, resolved_values=resolved_values, access_notes=access_notes, ) @staticmethod def build_static_suffix( question: str, context: RetrievalContext, *, session_context: str = "", resolved_values: dict[str, list[str]] | None = None, access_notes: str = "", ) -> str: """The per-request tail :meth:`build_static` appends to the cached prefix. Split out so :func:`llm.router.build_prompt_segments` can take the prefix and the tail separately instead of concatenating them only to cut the result apart again at the prefix's length (a copy of the whole prefix per request, which grows with the schema). Exactly the text :meth:`build_static` has always appended, by construction: ``build_static`` is now ``prefix + build_static_suffix(...)``. Parameters ---------- question, context, session_context, resolved_values, access_notes: As in :meth:`build`; the prefix and *source* are not inputs here because nothing in the suffix depends on them. Returns ------- str Examples -------- >>> from core.models import RetrievalContext >>> ctx = RetrievalContext(filters={"Ring": "تالار پتروشیمی"}) >>> tail = PromptBuilder.build_static_suffix("q1", ctx) >>> PromptBuilder.build_static("q1", "You are a T-SQL expert.", ctx).endswith(tail) True >>> "تالار پتروشیمی" in tail and "q1" in tail True """ filters = "\n".join(f"{key}: {value}" for key, value in context.filters.items()) return SUFFIX_TEMPLATE.format( filters=filters, resolved_values=_render_resolved_values(resolved_values), session_context=session_context, access_notes=_render_access_notes(access_notes), question=question, ) @staticmethod def _build_retrieval( question: str, system_prompt: str, context: RetrievalContext, *, session_context: str = "", resolved_values: dict[str, list[str]] | None = None, source: str | None = None, access_notes: str = "", ) -> str: """Retrieval-fallback path: only the retrieved tables/rules/examples. This is the original per-question prompt assembly, used once the knowledge base is too large for :meth:`build_static` to be economical (see module docstring). Kept fully functional — it is not dead code, it is the scaling path for later phases with a bigger schema. With a *source* (several data sources configured) the retrieved tables are narrowed to that source's, and when none are left -- retrieval found nothing, or only tables of other sources -- every table of the source is shown instead of every table of the deployment. The relationships are then those between the tables shown (``context.relationships`` was computed over tables of every source), and the examples are those of ``context.examples`` whose SQL can run on the source. """ selected_tables = context.selected_tables relationships = "\n".join(context.relationships) retrieved_examples = context.examples if source is None: schema_context = SchemaRegistry.build_schema_context(selected_tables) else: in_source = set(SchemaRegistry.tables_for_source(source)) kept = [t for t in selected_tables if t in in_source] shown = kept or SchemaRegistry.tables_for_source(source) schema_context = SchemaRegistry.build_schema_context(shown, source=source) relationships = "\n".join(SchemaRegistry.get_relationships(shown)) retrieved_examples = examples_for_source(retrieved_examples, source) rules = "\n\n".join(context.business_rules) examples = [] for item in retrieved_examples: examples.append( f"""Question:\n{item['question']}\n\nSQL:\n{item['sql']}""" ) example_context = "\n\n".join(examples) filters = [] for key, value in context.filters.items(): filters.append(f"{key}: {value}") filter_context = "\n".join(filters) return PROMPT_TEMPLATE.format( system_prompt=system_prompt, business_rules=rules, schema=schema_context, relationships=relationships, filters=filter_context, resolved_values=_render_resolved_values(resolved_values), access_notes=_render_access_notes(access_notes), examples=example_context, question=question, )