From 64f7e81e8b0ec1deae3cae21c4123d3af81e1dd1 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 29 May 2026 00:06:13 -0400 Subject: [PATCH 1/4] docs: add ollama structured output example --- examples/integrations/README.md | 7 + .../ollama_structured_output/README.md | 58 ++++++ .../ollama_structured_output/example.py | 169 ++++++++++++++++++ tests/test_example_integrations_imports.py | 5 + .../test_ollama_structured_output_example.py | 60 +++++++ 5 files changed, 299 insertions(+) create mode 100644 examples/integrations/ollama_structured_output/README.md create mode 100644 examples/integrations/ollama_structured_output/example.py create mode 100644 tests/test_ollama_structured_output_example.py diff --git a/examples/integrations/README.md b/examples/integrations/README.md index a1c611c..691ce44 100644 --- a/examples/integrations/README.md +++ b/examples/integrations/README.md @@ -37,6 +37,13 @@ Gateway-level integration using a LiteLLM pre-call hook. See: [LiteLLM Proxy README](litellm_proxy/README.md) +## Ollama structured output (host-side schema selection) + +Minimal example showing host-side JSON Schema selection for Ollama `format` based on +Context Compiler policy state. + +See: [ollama_structured_output/README.md](ollama_structured_output/README.md) + ## Open WebUI Pipe Function Tested target: Open WebUI `v0.8.12`. diff --git a/examples/integrations/ollama_structured_output/README.md b/examples/integrations/ollama_structured_output/README.md new file mode 100644 index 0000000..d5d8129 --- /dev/null +++ b/examples/integrations/ollama_structured_output/README.md @@ -0,0 +1,58 @@ +# Ollama structured output (host-side selection) + +This example shows a visible host behavior change that is different from prompt reinjection. + +Flow: + +`Context Compiler state -> host schema decision -> Ollama format request -> model call` + +The host reads compiled policy state, picks a JSON Schema (or none), and sends that choice through Ollama's `format` field. + +## What this example guarantees + +- Context Compiler provides deterministic state transitions. +- The host integration decides whether to request a schema. +- Ollama structured output is a runtime request made by the host. +- If policy state is unknown or insufficient, the host requests no schema. + +## What `prohibit shell_command` means here + +- The host will not request the `shell_command` schema. +- The host may still request a different schema when policy supports it (for example, `python_script`). +- This does not block normal language discussion about shell commands. + +## Observable behavior + +Given policy state: + +```text +use python_script +prohibit shell_command +``` + +this host selects `python_script` schema and does not request `shell_command` schema. + +## Test boundary + +Tests verify schema selection behavior only: + +- compiler state -> selected schema (or no schema) +- contradiction handling stays in compiler `clarify` + +Tests do not assert exact model wording. + +## Run without Ollama + +```shell +uv run python examples/integrations/ollama_structured_output/example.py +``` + +## Optional Ollama smoke run + +```shell +export RUN_OLLAMA_SMOKE=1 +export OLLAMA_MODEL=llama3.1 +uv run python examples/integrations/ollama_structured_output/example.py +``` + +When smoke mode is enabled, the host sends the selected JSON Schema through Ollama `format`. diff --git a/examples/integrations/ollama_structured_output/example.py b/examples/integrations/ollama_structured_output/example.py new file mode 100644 index 0000000..ef2f3f6 --- /dev/null +++ b/examples/integrations/ollama_structured_output/example.py @@ -0,0 +1,169 @@ +"""Minimal host-side Ollama structured-output schema selection. + +Flow: +Context Compiler state -> host selection logic -> Ollama `format` JSON Schema. + +This example keeps model execution optional so tests can validate behavior without Ollama. +""" + +import json +import os +import urllib.error +import urllib.request +from collections.abc import Mapping +from typing import Any, TypedDict, cast + +from context_compiler import ( + POLICY_PROHIBIT, + POLICY_USE, + State, + create_engine, + get_clarify_prompt, + get_decision_state, + get_policy_items, + is_clarify, +) +from context_compiler.engine import Engine + +PYTHON_SCRIPT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "python_script": { + "type": "string", + "description": "A complete Python script.", + } + }, + "required": ["python_script"], + "additionalProperties": False, +} + +SHELL_COMMAND_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "shell_command": { + "type": "string", + "description": "A single shell command.", + } + }, + "required": ["shell_command"], + "additionalProperties": False, +} + +# Small, explicit mapping from policy item -> Ollama `format` schema. +_SCHEMA_BY_ITEM: dict[str, dict[str, Any]] = { + "python_script": PYTHON_SCRIPT_SCHEMA, + "shell_command": SHELL_COMMAND_SCHEMA, +} + + +class TurnPlan(TypedDict): + decision_kind: str + clarify_prompt: str | None + selected_schema_item: str | None + format_schema: dict[str, Any] | None + + +def select_ollama_format_schema(state: State) -> tuple[str | None, dict[str, Any] | None]: + """Return (policy_item, schema) or (None, None) when no safe match exists. + + Unknown/insufficient policy state intentionally selects no schema. + """ + + use_items = set(get_policy_items(state, POLICY_USE)) + prohibit_items = set(get_policy_items(state, POLICY_PROHIBIT)) + + for item, schema in _SCHEMA_BY_ITEM.items(): + if item in use_items and item not in prohibit_items: + return item, schema + + return None, None + + +def plan_turn(user_input: str, engine: Engine) -> TurnPlan: + """Run compiler step and decide whether to request Ollama structured output.""" + + decision = engine.step(user_input) + if is_clarify(decision): + return { + "decision_kind": "clarify", + "clarify_prompt": get_clarify_prompt(decision), + "selected_schema_item": None, + "format_schema": None, + } + + decision_state = get_decision_state(decision) + compiled_state = decision_state if decision_state is not None else engine.state + selected_item, format_schema = select_ollama_format_schema(compiled_state) + + return { + "decision_kind": str(decision["kind"]), + "clarify_prompt": None, + "selected_schema_item": selected_item, + "format_schema": format_schema, + } + + +def optional_ollama_call( + *, + user_input: str, + model: str, + format_schema: Mapping[str, Any] | None, + host: str | None = None, +) -> dict[str, Any]: + """Optional smoke call to Ollama's /api/chat. + + If `format_schema` is provided, it is passed through `format` exactly. + """ + + base_url = host or os.getenv("OLLAMA_BASE_URL") or "http://localhost:11434" + payload: dict[str, Any] = { + "model": model, + "messages": [{"role": "user", "content": user_input}], + "stream": False, + } + if format_schema is not None: + payload["format"] = dict(format_schema) + + request = urllib.request.Request( + url=f"{base_url.rstrip('/')}/api/chat", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + + try: + with urllib.request.urlopen(request, timeout=30) as response: + raw = response.read().decode("utf-8") + except urllib.error.URLError as exc: + raise RuntimeError(f"Ollama call failed: {exc}") from exc + + decoded = cast(object, json.loads(raw)) + if not isinstance(decoded, dict): + raise RuntimeError("Ollama response must be a JSON object") + return cast(dict[str, Any], decoded) + + +def main() -> None: + engine = create_engine() + + # Demonstration setup. + engine.step("use python_script") + engine.step("prohibit shell_command") + + plan = plan_turn("Write a helper script to parse CSV files.", engine) + print("decision_kind:", plan["decision_kind"]) + print("selected_schema_item:", plan["selected_schema_item"]) + print("format_schema_selected:", plan["format_schema"] is not None) + + # Optional model execution path; disabled by default. + if os.getenv("RUN_OLLAMA_SMOKE") == "1": + response = optional_ollama_call( + user_input="Write a helper script to parse CSV files.", + model=os.getenv("OLLAMA_MODEL", "llama3.1"), + format_schema=plan["format_schema"], + ) + print("ollama_response_keys:", sorted(response.keys())) + + +if __name__ == "__main__": + main() diff --git a/tests/test_example_integrations_imports.py b/tests/test_example_integrations_imports.py index d795e24..5612dde 100644 --- a/tests/test_example_integrations_imports.py +++ b/tests/test_example_integrations_imports.py @@ -84,6 +84,11 @@ def _guarded_import( [ (INTEGRATIONS_DIR / "litellm" / "basic.py", ("litellm",), False), (INTEGRATIONS_DIR / "litellm" / "with_preprocessor.py", ("litellm",), False), + ( + INTEGRATIONS_DIR / "ollama_structured_output" / "example.py", + (), + False, + ), ( INTEGRATIONS_DIR / "litellm_proxy" / "context_compiler_precall_hook.py", ("litellm",), diff --git a/tests/test_ollama_structured_output_example.py b/tests/test_ollama_structured_output_example.py new file mode 100644 index 0000000..656c260 --- /dev/null +++ b/tests/test_ollama_structured_output_example.py @@ -0,0 +1,60 @@ +import importlib.util +from pathlib import Path + +from context_compiler import create_engine + +REPO_ROOT = Path(__file__).resolve().parents[1] +EXAMPLE_PATH = REPO_ROOT / "examples" / "integrations" / "ollama_structured_output" / "example.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("ollama_structured_output_example", EXAMPLE_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_use_python_script_selects_python_schema() -> None: + module = _load_module() + engine = create_engine() + + first = engine.step("use python_script") + assert first["kind"] == "update" + + plan = module.plan_turn("write script", engine) + assert plan["selected_schema_item"] == "python_script" + assert plan["format_schema"] == module.PYTHON_SCRIPT_SCHEMA + + +def test_prohibit_shell_command_does_not_select_shell_schema() -> None: + module = _load_module() + engine = create_engine() + + assert engine.step("use python_script")["kind"] == "update" + assert engine.step("prohibit shell_command")["kind"] == "update" + + plan = module.plan_turn("do task", engine) + assert plan["selected_schema_item"] == "python_script" + assert plan["format_schema"] != module.SHELL_COMMAND_SCHEMA + + +def test_unknown_or_no_matching_state_selects_no_schema() -> None: + module = _load_module() + engine = create_engine() + + plan = module.plan_turn("hello", engine) + assert plan["decision_kind"] == "passthrough" + assert plan["selected_schema_item"] is None + assert plan["format_schema"] is None + + +def test_contradictory_input_is_compiler_clarify_not_host_resolution() -> None: + module = _load_module() + engine = create_engine() + + assert engine.step("use python_script")["kind"] == "update" + conflict_plan = module.plan_turn("prohibit python_script", engine) + assert conflict_plan["decision_kind"] == "clarify" + assert conflict_plan["selected_schema_item"] is None + assert conflict_plan["format_schema"] is None From dba410672001846458778820c4b3c93c5f3574ca Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 29 May 2026 00:48:37 -0400 Subject: [PATCH 2/4] docs: tighten reinjection FAQ entry --- README.md | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c3e4a8a..03a0e51 100644 --- a/README.md +++ b/README.md @@ -185,20 +185,44 @@ uv run pytest Reinjection helps with persistence, and it remains useful. Context Compiler handles a different problem: rules for when state is allowed to change. -Examples: -- replacement semantics (`use X instead of Y`) when `Y` may not exist -- contradiction detection before applying a mutation -- lifecycle enforcement (for example, you cannot change an unset premise) -- pending clarification flows that must be resolved before other mutations - In short: reinjection carries state forward; Context Compiler decides when your app should change state. +Prompt reinjection is one common way to consume compiler state, but it is not +the only one. + +Hosts may also use compiler state to: +- select output schemas +- route requests +- gate tool usage +- choose backends +- shape runtime request behavior + +Context Compiler determines when state changes are allowed with deterministic +state-transition rules and clarification when needed. +Hosts decide how that state is consumed. + **Isn’t this just prompt engineering?** It complements prompt engineering, but solves a different problem. Prompting shapes model behavior. Context Compiler enforces state rules and updates state only through explicit directives. +**Why not just use a plain dict?** +A plain dict is enough to drive prompt construction, schema selection, and +other host behavior. + +Context Compiler solves a different problem: who updates that state, under what +rules, and what happens when instructions conflict. + +```text +User: use python_script +User: prohibit python_script +``` + +With a plain dict, the application must invent conflict-resolution rules. +Context Compiler applies deterministic state-transition rules and can return +clarification instead of silently overwriting state. + --- ## 10-Second Example From 80b401f24a4cc6cc030862fbdd0d0701c0f914a3 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 29 May 2026 00:54:25 -0400 Subject: [PATCH 3/4] docs: remove reinjection FAQ entry --- README.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/README.md b/README.md index 03a0e51..763c669 100644 --- a/README.md +++ b/README.md @@ -181,27 +181,6 @@ uv run pytest ## FAQ -**Is this just prompt reinjection?** -Reinjection helps with persistence, and it remains useful. Context Compiler -handles a different problem: rules for when state is allowed to change. - -In short: reinjection carries state forward; Context Compiler decides when your -app should change state. - -Prompt reinjection is one common way to consume compiler state, but it is not -the only one. - -Hosts may also use compiler state to: -- select output schemas -- route requests -- gate tool usage -- choose backends -- shape runtime request behavior - -Context Compiler determines when state changes are allowed with deterministic -state-transition rules and clarification when needed. -Hosts decide how that state is consumed. - **Isn’t this just prompt engineering?** It complements prompt engineering, but solves a different problem. Prompting shapes model behavior. Context Compiler enforces state rules and updates state From 50195263e74b838119c0adc9291901370de2bb59 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 29 May 2026 00:55:56 -0400 Subject: [PATCH 4/4] chore: bump version to 0.7.6 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 61305ca..265b8a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "context-compiler" -version = "0.7.5" +version = "0.7.6" description = "Deterministic conversational state engine for LLM applications." readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 6a8c81e..ea948eb 100644 --- a/uv.lock +++ b/uv.lock @@ -468,7 +468,7 @@ wheels = [ [[package]] name = "context-compiler" -version = "0.7.5" +version = "0.7.6" source = { editable = "." } [package.optional-dependencies]