Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions backend/app/services/agent/llm/ollama_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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:
Expand All @@ -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},
Expand Down
17 changes: 10 additions & 7 deletions backend/app/services/agent/output_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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() と一致)。
"""

Expand Down Expand Up @@ -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 が担う / 二重防衛)
Expand All @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions backend/tests/test_llm_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading