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
8 changes: 5 additions & 3 deletions web/src/components/agent/AgentModelBadge.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@
gap: 6px;
padding: 0.4rem 0.75rem;
font-size: 0.78rem;
color: var(--text-secondary);
/* サイドバー背景はライト/ダーク両テーマで暗色のため、本文用の --text-* ではなく
サイドバー専用のテキスト変数を使う(ライトモードで暗背景に暗文字となり読めなくなるのを防ぐ)。 */
color: var(--sidebar-text);
}

.label {
color: var(--text-secondary);
color: var(--sidebar-text);
}

.value {
font-weight: 600;
color: var(--text-primary);
color: var(--sidebar-title);
}

.paid {
Expand Down
10 changes: 6 additions & 4 deletions web/src/components/billing/CreditBalanceBadge.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
padding: 0.4rem 0.75rem;
margin-bottom: 0.4rem;
font-size: 0.78rem;
color: var(--text-secondary);
/* サイドバー背景は両テーマで暗色のため、本文用の --text-* ではなくサイドバー専用変数を使う
(ライトモードで暗背景に暗文字となり読めなくなるのを防ぐ)。 */
color: var(--sidebar-text);
border-bottom: 1px solid var(--sidebar-border);
}

Expand All @@ -16,18 +18,18 @@
}

.label {
color: var(--text-secondary);
color: var(--sidebar-text);
}

.value {
font-weight: 600;
color: var(--text-primary);
color: var(--sidebar-title);
font-variant-numeric: tabular-nums;
}

.estimate {
font-size: 0.72rem;
color: var(--text-secondary);
color: var(--sidebar-text);
opacity: 0.8;
}

Expand Down
2 changes: 1 addition & 1 deletion web/src/components/forms/AgentChatWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin }
{entries.length === 0 && <p className={styles.emptyState}>{AGENT_MESSAGES.EMPTY_STATE}</p>}
{entries.map((entry, i) => (
<div
key={i}
key={entry.id}
className={entry.role === "user" ? styles.userMessage : styles.assistantMessage}
>
<p className={styles.messageText}>{entry.text}</p>
Expand Down
114 changes: 23 additions & 91 deletions web/src/components/forms/CareerResumeForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { CSSProperties, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";

import { useCareerFormModals } from "../../hooks/career/useCareerFormModals";

Expand All @@ -18,10 +17,9 @@ import { useResumeDiffPreview } from "../../hooks/career/useResumeDiffPreview";
import { useResumeImportAssist } from "../../hooks/career/useResumeImportAssist";
import { useDocumentForm } from "../../hooks/useDocumentForm";
import { clearCareerDraft, loadCareerDraft, saveCareerDraft } from "../../utils/careerDraft";
import { buildCareerPayload, validateCareerForm } from "../../payloadBuilders";
import type { CareerFieldLocator, CareerFormState } from "../../payloadBuilders";
import { buildCareerPayload } from "../../payloadBuilders";
import { buildCareerChanges } from "../../utils/careerDiff";
import type { CareerTextFieldKey } from "../../formTypes";
import { useCareerFormValidationFocus } from "../../hooks/career/useCareerFormValidationFocus";
import { useQualifications, useTechnologyStacks } from "../../hooks/useMasterData";
import { useCareerExportActions } from "../../hooks/career/useCareerExportActions";
import { useMessageToast } from "../ui/toast";
Expand Down Expand Up @@ -126,13 +124,6 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean
}
}, [isAuthenticated, form]);

/**
* 保存前バリデーション(項目バリデーション)のメッセージ。
* 保存/削除/PDF などの非同期処理の成否はトーストで通知するが、
* 入力エラーは該当フィールドのフォーカス・赤枠とセットでフォーム内にインライン表示する。
*/
const [validationError, setValidationError] = useState<string | null>(null);

const { items: techStackOptions, loading: techLoading } = useTechnologyStacks();
const { items: qualificationOptions, loading: qualLoading } = useQualifications();
const qualificationNames = qualificationOptions.map((item) => item.name);
Expand Down Expand Up @@ -186,85 +177,26 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean
/** フォームデータ・技術スタック・資格の3つが揃った時に送信可能 */
const canSubmit = !loading && !techLoading && !qualLoading;

/**
* バリデーション失敗フィールドの位置と nonce。保存時にセットし、
* 該当入力へのフォーカス・赤枠表示・折りたたみ自動展開に使う。
* nonce は「同じフィールドで再度保存した時」も折りたたみ展開 effect を再発火させるための鍵。
*/
const [focusTarget, setFocusTarget] = useState<{
locator: CareerFieldLocator;
nonce: number;
} | null>(null);
const focusNonceRef = useRef(0);

/** 編集が入ったらフォーカス強調を解除する(赤枠を消す)setForm ラッパー。 */
const setFormAndClearFocus = useCallback<Dispatch<SetStateAction<CareerFormState>>>(
(action) => {
setFocusTarget(null);
setValidationError(null);
setForm(action);
},
[setForm],
);

const onChangeField = (key: CareerTextFieldKey, value: string) => {
setFocusTarget(null);
setValidationError(null);
setForm((prev) => ({ ...prev, [key]: value }));
};

/** バリデーション失敗を画面へ反映する(メッセージ・フォーカス・モーダル自動展開)。 */
const applyValidationError = (validation: NonNullable<ReturnType<typeof validateCareerForm>>) => {
setValidationError(validation.message);
focusNonceRef.current += 1;
setFocusTarget({ locator: validation.locator, nonce: focusNonceRef.current });
// 自己PR / 職務要約はモーダルへ逃がしているため、該当フィールドの失敗時はモーダルを自動で開く
// (隠れた textarea には直接フォーカスできないため)。
if (
validation.locator.kind === "career_summary" ||
validation.locator.kind === "self_pr"
) {
setEditingField(validation.locator.kind);
}
};

const onSubmit = (event: FormEvent) => {
event.preventDefault();

// 未ログインのお試し入力: 全項目の入力完了は求めず(カジュアルな体験を優先)、
// 氏名だけ確認して(空の経歴書でログインさせない)ドラフトを退避し、ログインを促す。
// 残りの項目検証はログイン後の実保存時にサーバ側で行う。
if (!isAuthenticated) {
if (!form.full_name.trim()) {
const validation = validateCareerForm(form);
if (validation) applyValidationError(validation);
return;
}
setValidationError(null);
setFocusTarget(null);
// 入力内容は effect で sessionStorage に退避済み。ログインを促す。
requestLogin();
return;
}

// 保存前にフォーム全体を検証し、最初のエラーフィールドへフォーカスする。
const validation = validateCareerForm(form);
if (validation) {
applyValidationError(validation);
return;
}
setValidationError(null);
setFocusTarget(null);
// 変更が無ければ確認を挟まずそのまま保存。変更があれば確認ダイアログを開く。
if (changes.length === 0) {
void save();
return;
}
setShowSaveConfirm(true);
};

const focusLocator = focusTarget?.locator ?? null;
const focusNonce = focusTarget?.nonce ?? 0;
// バリデーション結果の画面反映(メッセージ・フォーカス強調・モーダル自動展開・送信分岐)。
const {
validationError,
focusLocator,
focusNonce,
setFormAndClearFocus,
onChangeField,
onSubmit,
} = useCareerFormValidationFocus({
form,
setForm,
isAuthenticated,
changeCount: changes.length,
save,
openSaveConfirm: () => setShowSaveConfirm(true),
requestLogin,
// ゲスト入力はログイン遷移の直前に同期退避する(effect の未反映で最後の入力を失わないため)。
persistDraft: saveCareerDraft,
openMarkdownField: setEditingField,
});

return (
<>
Expand Down
Loading
Loading