diff --git a/backend/app/services/agent/llm/ollama_client.py b/backend/app/services/agent/llm/ollama_client.py index 4266a2cc..f6078e95 100644 --- a/backend/app/services/agent/llm/ollama_client.py +++ b/backend/app/services/agent/llm/ollama_client.py @@ -7,6 +7,7 @@ import httpx from ....core import settings +from ..output_schema import to_portable_schema from .base import LLMClient, LLMError, LLMResult, require_text, wrap_api_error logger = logging.getLogger(__name__) @@ -19,8 +20,11 @@ class OllamaClient(LLMClient): """ローカル Ollama の /api/chat を呼び出すクライアント。 ``format`` に JSON Schema を渡して構造化出力(文法制約)を強制する。 - 構造・許可 field(const)・maxItems は文法レベルで保証されるが、 - maxLength は強制されないため上限超過は呼び出し側で破棄する。 + ただし llama.cpp の JSON Schema → GBNF 文法変換は ``maxLength`` / ``maxItems`` + を解釈できず ``failed to parse grammar`` で 400 を返すため、Gemini/OpenAI と同様に + ``to_portable_schema`` で数値制約を除去し ``oneOf`` を ``enum`` へ畳んだ移植スキーマを渡す。 + 許可 field 名(enum)と構造は文法レベルで保証されるが、文字数上限の実強制は + 呼び出し側(``chat_service._parse_response`` の破棄ロジック)が担う(二重防衛 / ADR-0013)。 """ def __init__(self) -> None: @@ -45,7 +49,8 @@ async def generate( "model": self._model, "messages": [{"role": "system", "content": system_prompt}, *messages], "stream": False, - "format": output_schema, + # maxLength/maxItems を含む生スキーマは llama.cpp の文法変換を壊すため移植スキーマを渡す + "format": to_portable_schema(output_schema, drop_additional_properties=False), # 職務経歴書の改善提案は事実忠実性が最優先のため低温度に固定する # (デフォルト 0.8 では小型モデルが架空の資格・技術を捏造しやすい) "options": {"temperature": 0.2}, diff --git a/backend/app/services/agent/output_schema.py b/backend/app/services/agent/output_schema.py index 29a413fb..ec66b08f 100644 --- a/backend/app/services/agent/output_schema.py +++ b/backend/app/services/agent/output_schema.py @@ -4,9 +4,11 @@ プロンプト(app/prompts/agent_*.md)には機械検証不能な制約(品質基準・ 捏造禁止・思考ステップ)のみを書く(ADR-0010「制約の責務分離」)。 -注意: Anthropic の非 strict tool use / Ollama の format(文法制約)は -maxLength を API 側で強制しない(モデルへの助言扱い)。文字数上限の -実強制は chat_service._parse_response の破棄ロジックが担う(二重防衛)。 +注意: Anthropic の非 strict tool use は maxLength を API 側で強制しない(モデルへの +助言扱い)。Ollama(llama.cpp の format → GBNF 文法変換)は maxLength / maxItems を +解釈できず文法変換自体が失敗するため、to_portable_schema で数値制約を除去してから渡す +(ADR-0013)。いずれの経路でも文字数上限の実強制は chat_service._parse_response の +破棄ロジックが担う(二重防衛)。 maxLength は JSON Schema 仕様どおり Unicode 文字数(日本語の len() と一致)。 """ @@ -84,9 +86,9 @@ def build_tool_definition(input_schema: dict) -> dict: def to_portable_schema(schema: dict, *, drop_additional_properties: bool = False) -> dict: """build_output_schema の出力を、Gemini/OpenAI の構造化出力に通る形へ変換する(ADR-0013)。 - Gemini ``response_schema`` / OpenAI strict ``response_format`` は ``oneOf`` / ``const`` / - ``maxLength`` / ``maxItems`` といった JSON Schema キーワードを受け付けないか挙動が - 不安定なため、以下に正規化する: + Gemini ``response_schema`` / OpenAI strict ``response_format`` / Ollama ``format`` + (llama.cpp の GBNF 文法変換)は ``oneOf`` / ``const`` / ``maxLength`` / ``maxItems`` + といった JSON Schema キーワードを受け付けないか挙動が不安定なため、以下に正規化する: - operations.items の ``oneOf`` 分岐 → ``field`` を許可値の ``enum`` に畳んだ単一オブジェクト - ``maxLength`` / ``maxItems`` を除去(上限の実強制は chat_service._parse_response が担う / 二重防衛) @@ -95,7 +97,8 @@ def to_portable_schema(schema: dict, *, drop_additional_properties: bool = False - OpenAI strict は ``additionalProperties: false`` が必須 → 残す(drop_additional_properties=False) - Gemini ``response_schema`` は ``additionalProperties`` 非対応 → 除去する(drop_additional_properties=True) - Anthropic(tool use)と Ollama(format)は元スキーマをそのまま使うため本関数は通さない。 + Anthropic(tool use)は oneOf/const/maxLength を解釈できるため元スキーマをそのまま使い、 + 本関数は通さない。Ollama(format)は数値制約で文法変換が壊れるため本関数を通す。 """ strip_keys = {"maxLength", "maxItems"} if drop_additional_properties: diff --git a/backend/tests/test_llm_clients.py b/backend/tests/test_llm_clients.py index 934831fc..b486c34d 100644 --- a/backend/tests/test_llm_clients.py +++ b/backend/tests/test_llm_clients.py @@ -384,6 +384,52 @@ def test_ollama_client_returns_text_with_zero_usage(monkeypatch) -> None: assert result.output_tokens == 0 +def test_ollama_client_sends_portable_schema(monkeypatch) -> None: + """format には移植スキーマを渡す(llama.cpp の文法変換が maxLength/maxItems/oneOf で + 失敗するため)。oneOf は enum へ畳まれ、maxLength / maxItems は除去される。""" + import json as _json + + from app.services.agent.llm import ollama_client + from app.services.agent.llm.ollama_client import OllamaClient + + captured: dict = {} + + class _CapturingClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args) -> bool: + return False + + async def post(self, url, json=None): + captured["payload"] = json + return _FakeResponse({"message": {"content": '{"message":"ok"}'}}) + + monkeypatch.setattr(ollama_client.settings, "get_ollama_base_url", lambda: "http://x") + monkeypatch.setattr(ollama_client.settings, "get_ollama_model", lambda: "llama3.2") + monkeypatch.setattr(ollama_client.settings, "get_ollama_timeout_seconds", lambda: 1.0) + monkeypatch.setattr( + ollama_client.httpx, "AsyncClient", lambda **kwargs: _CapturingClient() + ) + + asyncio.run( + OllamaClient().generate( + "sys", [{"role": "user", "content": "hi"}], + build_output_schema("project"), "ignored" + ) + ) + + fmt = captured["payload"]["format"] + serialized = _json.dumps(fmt) + # 数値制約と oneOf が文法変換を壊すため、いずれも format に残ってはいけない + assert "maxLength" not in serialized + assert "maxItems" not in serialized + assert "oneOf" not in serialized + # project の許可 field(description / role)は enum に畳まれている + item = fmt["properties"]["operations"]["items"] + assert item["properties"]["field"]["enum"] == ["description", "role"] + + def test_ollama_client_http_error_wrapped(monkeypatch) -> None: """httpx.HTTPError は LLMError にラップされる。""" from app.services.agent.llm.ollama_client import OllamaClient