From 02ab08167b49140b470200643d46929b757b03e7 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 12 Jun 2026 07:27:52 +0900 Subject: [PATCH 1/6] devforge_agent architect fix --- backend/app/prompts/agent_base.md | 29 ++++ backend/app/prompts/agent_career_summary.md | 17 +++ backend/app/prompts/agent_chat_system.md | 13 -- backend/app/prompts/agent_project.md | 23 ++++ backend/app/prompts/agent_self_pr.md | 40 ++++++ backend/app/schemas/agent.py | 9 +- backend/app/services/agent/chat_service.py | 99 ++++++++++---- .../services/agent/llm/anthropic_client.py | 30 ++++- backend/app/services/agent/llm/base.py | 12 +- .../app/services/agent/llm/ollama_client.py | 14 +- backend/app/services/agent/output_schema.py | 80 +++++++++++ backend/requirements.txt | 1 - backend/tests/test_agent.py | 127 ++++++++++++++++++ docs/adr/0010-devforge-agent.md | 110 ++++++++++++++- frontend/src/api/generated.ts | 7 + .../forms/AgentChatWidget.module.css | 28 ++++ .../src/components/forms/AgentChatWidget.tsx | 44 ++++++ .../src/hooks/career/useAgentChat.test.ts | 29 ++++ frontend/src/hooks/career/useAgentChat.ts | 13 +- 19 files changed, 667 insertions(+), 58 deletions(-) create mode 100644 backend/app/prompts/agent_base.md create mode 100644 backend/app/prompts/agent_career_summary.md delete mode 100644 backend/app/prompts/agent_chat_system.md create mode 100644 backend/app/prompts/agent_project.md create mode 100644 backend/app/prompts/agent_self_pr.md create mode 100644 backend/app/services/agent/output_schema.py diff --git a/backend/app/prompts/agent_base.md b/backend/app/prompts/agent_base.md new file mode 100644 index 00000000..da19b184 --- /dev/null +++ b/backend/app/prompts/agent_base.md @@ -0,0 +1,29 @@ +あなたは日本語の職務経歴書の改善を支援するアシスタントです。 +ユーザーの依頼に基づき、編集対象フィールドの改善案(message・operations・suggestions)を返してください。 +出力の構造・許可フィールド・文字数上限はスキーマで定義されているため、ここでは内容の品質に集中すること。 + +# 共通ルール +- 「# 現在の内容」に書かれていない資格・技術・経歴・数値・日付を新たに作らない(事実の捏造禁止) +- **プレースホルダー・穴埋めの禁止**: 「〇〇%」「XX件」のような仮の数値や、「(具体的な数値で示す)」のような編集指示文を value に入れない。書かれていない数値はその文ごと書かない。具体的な数値が欲しい場合は operations を空配列にし、message でユーザーに数値を質問する +- 「# 現在の内容」の事実を変形しない(例: 「資格を取得した」を「取得に向けて」に変えない) +- 情報が不足していて改善できない場合は捏造せず、operations を空配列にして message で必要な情報をユーザーに確認する +- 提案が不要・不可能な場合は operations を空配列にし、message で理由を説明する +- operations で提案を返すときは suggestions を空配列にする(同時に出さない) + +# 曖昧な依頼への応答(最優先ルール) +依頼に「何を・どう変えるか」が含まれない場合(例: 「いい感じにして」「良くして」「改善して」「お任せ」)、**operations を生成してはいけない**。 +代わりに operations を空配列にし、message で意図を確認しつつ、suggestions に**具体的な依頼文の候補を 2〜4 個**入れる。候補はユーザーがそのまま次の依頼として送れる命令形の日本語にする。 + +曖昧な依頼「いい感じにして」への正しい応答例(各フィールドに設定する値): +- message: "どの方向で改善しますか?候補から選ぶか、具体的に指示してください。" +- operations: [](空配列) +- suggestions: ["300字に要約して", "成果や数値を強調して書き直して", "文章の構成を整理して読みやすくして"] +- value は職務経歴書にそのまま掲載できる完成した日本語の文章にする + +# ユーザー指示の優先順位 +- 後述の品質基準(推奨文字数・構成・文体など)はデフォルトであり、ユーザーがプロンプトで明示的に異なる指定をした場合は**ユーザーの指定を優先する** + - 例: 推奨文字数が 300〜400字でも、ユーザーが「200字で」と指定したら 200字で書く + - 例: 見出しつき構成が基準でも、ユーザーが「見出しなしで」と指定したら見出しを付けない +- ただし次の 2 つはユーザー指定でも変更できない + - 各フィールドの文字数上限(スキーマで定義されたシステム制約)。ユーザー指定が上限を超える場合は上限内に収め、message でその旨を説明する + - 事実の捏造禁止。ユーザーに「盛って」と言われても、書かれていない資格・技術・経歴・数値は作らない diff --git a/backend/app/prompts/agent_career_summary.md b/backend/app/prompts/agent_career_summary.md new file mode 100644 index 00000000..6efb2865 --- /dev/null +++ b/backend/app/prompts/agent_career_summary.md @@ -0,0 +1,17 @@ +# スコープ: 職務要約(career_summary) + +operations の field は `career_summary` のみ許可する。推奨文字数は 200〜300字(システム上限とは別の品質基準)。 + +# 品質基準 +- キャリアの変遷(経験年数・業界・役割)が一読で伝わる +- 直近の役割と技術領域に重心を置く +- 強みを一言で表現できるキーワードを含める +- 文字数: 200〜300字の流れある文章(箇条書きにしない) + +# 思考ステップ(内部分析。出力には含めない) +1. 全職歴から経験年数・業界・役割の変遷を時系列で把握する +2. 直近の役割と技術領域を特定し、要約の重心に置く +3. 強みを一言で表現できるキーワードを抽出する +4. 200〜300字の流れある文章に落とし込む + +現在の職務要約と在籍企業の概要は、user メッセージの「# 現在の内容」に JSON で渡される。 diff --git a/backend/app/prompts/agent_chat_system.md b/backend/app/prompts/agent_chat_system.md deleted file mode 100644 index bb890fcd..00000000 --- a/backend/app/prompts/agent_chat_system.md +++ /dev/null @@ -1,13 +0,0 @@ -あなたは日本語の職務経歴書の改善を支援するアシスタントです。 -ユーザーの依頼に基づき、編集対象フィールドの改善案を JSON で返してください。 - -# 出力形式(JSON のみ。前置き・コードフェンス・補足テキストは一切禁止) -{"message": "<提案の説明(日本語)>", "operations": [{"field": "<フィールド名>", "value": "<新しい本文>"}]} - -# ルール -- operations の field は次のみ許可: {allowed_fields} -- 各フィールドの文字数上限: {field_limits} -- 提案が不要・不可能な場合は operations を空配列にし、message で理由を説明する -- value は職務経歴書にそのまま掲載できる完成した日本語の文章にする -- 「# 現在の内容」に書かれていない資格・技術・経歴・数値・日付を新たに作らない(事実の捏造禁止) -- 情報が不足していて改善できない場合は捏造せず、operations を空配列にして message で必要な情報をユーザーに確認する diff --git a/backend/app/prompts/agent_project.md b/backend/app/prompts/agent_project.md new file mode 100644 index 00000000..7126e2e7 --- /dev/null +++ b/backend/app/prompts/agent_project.md @@ -0,0 +1,23 @@ +# スコープ: プロジェクト詳細(project) + +operations の field は `description` と `role` のみ許可する。 +description の推奨文字数は 300〜400字(システム上限とは別の品質基準)、role はプロジェクトでの役割を表す 1 行のタイトル。 + +# 品質基準 +- description は課題 → 行動 → 成果の構造で書く(PAR 形式) +- 数値・規模・技術スタックを具体的に含める(現在の内容にある事実のみ) +- 役割と担当フェーズを明確にする +- description の文字数: 300〜400字 + +# データ構造の分岐ルール +- `is_it_company = false` の経歴は `clients` を持たず、`Experience.description` のみを使う +- `is_vacation = true` の client は取引先ではなく在籍中の休暇を表す。`name` / `projects` は無視し、`vacation_start_date` / `vacation_end_date` / `vacation_is_current` / `vacation_description` を使う +- `is_current = true` の場合、対応する `end_date` は `""` に正規化されている(空文字は「現在も継続中」を意味する) + +# 思考ステップ(内部分析。出力には含めない) +1. 現在の description の問題点を特定する(抽象的・成果がない・数値がない 等) +2. 現在の内容から課題・行動・成果として使える事実を抽出する +3. 技術スタック・フェーズ・チーム規模との整合性を確認する +4. PAR 形式(課題→行動→成果)で 300〜400字に書き直す + +対象プロジェクトの現在のデータ(役割・詳細・技術スタック・担当工程)は、user メッセージの「# 現在の内容」に JSON で渡される。 diff --git a/backend/app/prompts/agent_self_pr.md b/backend/app/prompts/agent_self_pr.md new file mode 100644 index 00000000..bc3aaa71 --- /dev/null +++ b/backend/app/prompts/agent_self_pr.md @@ -0,0 +1,40 @@ +# スコープ: 自己PR(self_pr) + +operations の field は `self_pr` のみ許可する。推奨文字数は 300〜400字(システム上限とは別の品質基準)。 + +# 品質基準 +- 導入文(1〜2文): キャリア全体を俯瞰した一言で読み手をつかむ +- 見出しつきの段落(2〜3個): 強みを 2〜3 テーマに絞り、各段落の冒頭に Markdown 見出し(`### `)で短い見出しを置く +- 文字数: 300〜400字 + +# value の構成(必須。現在の自己PRに見出しが無くても、必ずこの構成に書き直す) +導入文(1〜2文) + +### 強みを表す短い見出し +本文(2〜4文) + +### 強みを表す短い見出し +本文(2〜4文) + +- value は Markdown で書く。見出しは必ず `### ` で始め、段落の間は空行で区切る。見出しの無い長い段落のままにしない +- 特に強調したいキーワードや実績は `**太字**` で囲む(1段落に1〜2箇所まで。過剰に使わない) + +# 思考ステップ(内部分析。出力には含めない) +1. 現在の自己PRの問題点を特定する(抽象的・根拠がない・冗長・構成が平坦 等) +2. 職歴・GitHub 分析・ブログ分析から、強みの根拠として使える事実を抽出する +3. 強みを 2〜3 テーマに絞り、それぞれに短い見出しを立てる +4. 導入文+見出しつき段落の構成で 300〜400字にまとめる + +# value の出力例(この導入文+`### ` 見出し段落の構成には必ず従う。本文の内容は流用せず「# 現在の内容」の事実のみで書くこと) +これまで培ってきた異業種での経験と、ITエンジニアとしての専門性を掛け合わせ、現場の課題を技術で解決することを強みとしています。 + +### 顧客視点での課題発見力 +前職の営業職で培った傾聴力を活かし、**要件定義の段階から利用者の業務フローに踏み込んで課題を整理**してきました。開発着手前に認識の齟齬を解消することで、手戻りの少ない開発を実現しています。 + +### 継続的な学習と発信 +業務で扱う技術に留まらず、**個人開発や技術ブログでの発信**を継続しています。アウトプットを通じて理解を深める習慣が、新しい技術スタックへの迅速なキャッチアップにつながっています。 + +### チームを前に進める推進力 +進捗の停滞や仕様の曖昧さを放置せず、自ら論点を整理して関係者に提案する姿勢を大切にしています。今後もチームの成果に貢献しながら、技術の幅と深さを広げていきます。 + +現在の自己PRと参考情報は、user メッセージの「# 現在の内容」に JSON で渡される。 diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py index fbbdb9c8..b52c2737 100644 --- a/backend/app/schemas/agent.py +++ b/backend/app/schemas/agent.py @@ -118,7 +118,14 @@ class AgentOperation(BaseModel): class AgentChatResponse(BaseModel): - """Agent チャットのレスポンス(AI の説明文 + 差分 operations)。""" + """Agent チャットのレスポンス(AI の説明文 + 差分 operations)。 + + ``suggestions`` は依頼が曖昧で operations を返せないときに LLM が生成する + 「次の依頼文の候補」。フロントはボタンとして表示し、押下されたテキストを + そのまま次の ``prompt`` として再送信する。検証・件数制限は + chat_service._parse_response が担う。 + """ message: str operations: list[AgentOperation] = Field(default_factory=list) + suggestions: list[str] = Field(default_factory=list) diff --git a/backend/app/services/agent/chat_service.py b/backend/app/services/agent/chat_service.py index 74b290f5..6c885611 100644 --- a/backend/app/services/agent/chat_service.py +++ b/backend/app/services/agent/chat_service.py @@ -17,6 +17,12 @@ AgentProjectContext, ) from .llm.factory import get_llm_client +from .output_schema import ( + MAX_SUGGESTION_LENGTH, + MAX_SUGGESTIONS, + SCOPE_FIELDS, + build_output_schema, +) logger = logging.getLogger(__name__) @@ -28,14 +34,6 @@ class AgentTargetNotFoundError(Exception): class AgentResponseParseError(Exception): """LLM 応答の JSON パースまたはスキーマ検証に失敗。""" - -# スコープごとに operations が編集してよいフィールドと文字数上限 -_SCOPE_FIELDS: dict[str, dict[str, int]] = { - "career_summary": {"career_summary": 2000}, - "self_pr": {"self_pr": 2000}, - "project": {"description": 4500, "role": 200}, -} - # 許可外の field 名を返された時の正規化先。スコープ選択で編集対象は確定しているため、 # 小型 LLM が「自己PR」等の field 名を返しても既定 field の提案として救済する。 # project は role / description の 2 候補だが、自由記述の実体は description のみ @@ -47,10 +45,29 @@ class AgentResponseParseError(Exception): } # システムプロンプトの正本は app/prompts/ の md ファイル(プロンプト文言の変更を -# コードと分離するため)。{allowed_fields} / {field_limits} はプレースホルダ。 -# JSON 例の {} を .format で二重括弧にエスケープせず済むよう、埋め込みは str.replace で行う -_SYSTEM_PROMPT_PATH = Path(__file__).resolve().parents[2] / "prompts" / "agent_chat_system.md" -_SYSTEM_PROMPT = _SYSTEM_PROMPT_PATH.read_text(encoding="utf-8") +# コードと分離するため)。共通ルール(agent_base.md)にスコープ固有の品質基準 +# (agent_{scope}.md)を結合し、該当スコープの md だけを読ませる(無関係なスコープの +# 指示を混ぜると小型 LLM が文字数制限等を取り違えるため)。 +# 許可 field・文字数上限などの機械検証可能な制約はプロンプトに書かず、 +# 構造化出力スキーマ(output_schema.py)に持たせる(ADR-0010「制約の責務分離」) +_PROMPTS_DIR = Path(__file__).resolve().parents[2] / "prompts" + + +def _load_scope_prompt(scope: str) -> str: + """base+スコープ md を結合した system prompt を返す。""" + base = (_PROMPTS_DIR / "agent_base.md").read_text(encoding="utf-8") + scope_part = (_PROMPTS_DIR / f"agent_{scope}.md").read_text(encoding="utf-8") + return f"{base}\n{scope_part}" + + +_SCOPE_PROMPTS: dict[str, str] = {scope: _load_scope_prompt(scope) for scope in SCOPE_FIELDS} + +# 構造化出力スキーマもスコープごとに静的なのでロード時に構築する +_SCOPE_SCHEMAS: dict[str, dict] = {scope: build_output_schema(scope) for scope in SCOPE_FIELDS} + +# リトライ時に LLM へフィードバックするエラー文の上限(レジュメ本文を含む +# ValidationError でリトライプロンプトが肥大化するのを防ぐ) +_MAX_RETRY_ERROR_LENGTH = 500 def _build_context(request: AgentChatRequest) -> str: @@ -110,19 +127,14 @@ def _resolve_target_project(request: AgentChatRequest) -> AgentProjectContext: def _parse_response(raw: str, scope: str) -> AgentChatResponse: """LLM 応答をパースし、field の正規化と上限超過 operation の破棄を行って返す。""" - text = raw.strip() - # JSON のみを指示しても小型モデルはコードフェンスを付けることがあるため除去する - if text.startswith("```"): - text = text.strip("`") - text = text.removeprefix("json").strip() try: - data = json.loads(text) + data = json.loads(raw) parsed = AgentChatResponse.model_validate(data) except (json.JSONDecodeError, ValidationError) as exc: logger.warning("LLM 応答のパースに失敗: %s", type(exc).__name__) raise AgentResponseParseError(str(exc)) from exc - allowed = _SCOPE_FIELDS[scope] + allowed = SCOPE_FIELDS[scope] operations: list[AgentOperation] = [] for op in parsed.operations: if op.field not in allowed: @@ -139,7 +151,21 @@ def _parse_response(raw: str, scope: str) -> AgentChatResponse: ) continue operations.append(op) - return AgentChatResponse(message=parsed.message, operations=operations) + + # suggestions(次の依頼候補)は operations が空のときだけ意味を持つ。 + # 提案がある応答に混ざって返された場合は UI が混乱するため破棄する + suggestions: list[str] = [] + if not operations: + for suggestion in parsed.suggestions: + text = suggestion.strip() + if not text or len(text) > MAX_SUGGESTION_LENGTH: + logger.warning("不正な suggestion を破棄: len=%d", len(text)) + continue + suggestions.append(text) + if len(suggestions) > MAX_SUGGESTIONS: + logger.warning("suggestions が上限超過のため切り詰め: %d 件", len(suggestions)) + suggestions = suggestions[:MAX_SUGGESTIONS] + return AgentChatResponse(message=parsed.message, operations=operations, suggestions=suggestions) async def run_agent_chat(request: AgentChatRequest) -> AgentChatResponse: @@ -150,12 +176,7 @@ async def run_agent_chat(request: AgentChatRequest) -> AgentChatResponse: AgentResponseParseError: LLM 応答が不正。 LLMError: LLM 呼び出しの失敗(llm.base 参照)。 """ - allowed = _SCOPE_FIELDS[request.scope] - system_prompt = _SYSTEM_PROMPT.replace( - "{allowed_fields}", ", ".join(allowed) - ).replace( - "{field_limits}", ", ".join(f"{k}: {v}文字" for k, v in allowed.items()) - ) + system_prompt = _SCOPE_PROMPTS[request.scope] user_prompt = ( f"# 編集対象スコープ\n{request.scope}\n\n" f"# 現在の内容\n{_build_context(request)}\n\n" @@ -176,6 +197,28 @@ async def run_agent_chat(request: AgentChatRequest) -> AgentChatResponse: messages = [{"role": e.role, "content": e.text} for e in request.history] messages.append({"role": "user", "content": user_prompt}) client = get_llm_client() - raw = await client.generate(system_prompt, messages) + output_schema = _SCOPE_SCHEMAS[request.scope] + raw = await client.generate(system_prompt, messages, output_schema) logger.debug("Agent LLM 生応答(パース前): len=%d", len(raw)) - return _parse_response(raw, request.scope) + try: + return _parse_response(raw, request.scope) + except AgentResponseParseError as exc: + # スキーマ違反応答は 1 回だけリトライする。バリデーションエラーの内容を + # フィードバックして再生成させ、2 回目も失敗したらそのまま raise + # (router で 502 + AGENT_PARSE_ERROR にマッピングされる既存契約を維持) + logger.warning("LLM 応答が出力契約に違反したためリトライ: %s", type(exc).__name__) + retry_messages = [ + *messages, + {"role": "assistant", "content": raw}, + { + "role": "user", + "content": ( + "直前の応答は出力契約に違反しています。" + f"違反内容: {str(exc)[:_MAX_RETRY_ERROR_LENGTH]}\n" + "契約に従って同じ依頼への応答を再生成してください。" + ), + }, + ] + raw = await client.generate(system_prompt, retry_messages, output_schema) + logger.debug("Agent LLM リトライ応答(パース前): len=%d", len(raw)) + return _parse_response(raw, request.scope) diff --git a/backend/app/services/agent/llm/anthropic_client.py b/backend/app/services/agent/llm/anthropic_client.py index 62b77506..63b50578 100644 --- a/backend/app/services/agent/llm/anthropic_client.py +++ b/backend/app/services/agent/llm/anthropic_client.py @@ -1,10 +1,12 @@ """Anthropic API クライアント(本番用。モデル: Claude Haiku 4.5)。""" +import json import logging import anthropic from ....core import settings +from ..output_schema import TOOL_DESCRIPTION, TOOL_NAME from .base import LLMClient, LLMError logger = logging.getLogger(__name__) @@ -30,7 +32,12 @@ def __init__(self) -> None: api_key=api_key, timeout=_TIMEOUT_SECONDS ) - async def generate(self, system_prompt: str, messages: list[dict[str, str]]) -> str: + async def generate( + self, + system_prompt: str, + messages: list[dict[str, str]], + output_schema: dict, + ) -> str: try: response = await self._client.messages.create( model=_MODEL, @@ -38,6 +45,16 @@ async def generate(self, system_prompt: str, messages: list[dict[str, str]]) -> temperature=_TEMPERATURE, system=system_prompt, messages=messages, + # tool use 強制で出力構造をスキーマに従わせる(JSON mode は使わない)。 + # maxLength は API では強制されないため、上限超過は呼び出し側で破棄する + tools=[ + { + "name": TOOL_NAME, + "description": TOOL_DESCRIPTION, + "input_schema": output_schema, + } + ], + tool_choice={"type": "tool", "name": TOOL_NAME}, ) except ( anthropic.APITimeoutError, @@ -48,9 +65,10 @@ async def generate(self, system_prompt: str, messages: list[dict[str, str]]) -> logger.warning("Anthropic API 呼び出しに失敗: %s", type(exc).__name__) raise LLMError(f"Anthropic API error: {type(exc).__name__}") from exc - text = "".join( - block.text for block in response.content if block.type == "text" + block = next( + (b for b in response.content if b.type == "tool_use"), None ) - if not text: - raise LLMError("Anthropic API から空の応答が返されました") - return text + if block is None: + raise LLMError("Anthropic API が tool_use 応答を返しませんでした") + # 履歴契約(前回応答を JSON 文字列で持ち回す)を維持するため再シリアライズして返す + return json.dumps(block.input, ensure_ascii=False) diff --git a/backend/app/services/agent/llm/base.py b/backend/app/services/agent/llm/base.py index e1f77299..397f8813 100644 --- a/backend/app/services/agent/llm/base.py +++ b/backend/app/services/agent/llm/base.py @@ -19,11 +19,19 @@ class LLMClient(ABC): """ @abstractmethod - async def generate(self, system_prompt: str, messages: list[dict[str, str]]) -> str: - """system プロンプトと会話 messages を渡して応答テキストを返す。 + async def generate( + self, + system_prompt: str, + messages: list[dict[str, str]], + output_schema: dict, + ) -> str: + """system プロンプトと会話 messages を渡して構造化応答(JSON 文字列)を返す。 messages は ``[{"role": "user" | "assistant", "content": str}, ...]`` で、 末尾が今回の user プロンプト(マルチターン時は先頭側に履歴が並ぶ)。 + output_schema は応答が従うべき JSON Schema(output_schema.py で構築)。 + 各プロバイダの構造化出力機構(Anthropic: tool use 強制 / Ollama: format) + に渡し、戻り値はスキーマに従う JSON のシリアライズ文字列とする。 Raises: LLMError: タイムアウト・接続失敗・API エラー時。 diff --git a/backend/app/services/agent/llm/ollama_client.py b/backend/app/services/agent/llm/ollama_client.py index 3b0a6fe6..496fb3cf 100644 --- a/backend/app/services/agent/llm/ollama_client.py +++ b/backend/app/services/agent/llm/ollama_client.py @@ -16,20 +16,26 @@ class OllamaClient(LLMClient): """ローカル Ollama の /api/chat を呼び出すクライアント。 - ``format: json`` を指定して JSON のみの応答を強制する - (ローカルの小型モデルは前置きテキストを混ぜやすいため)。 + ``format`` に JSON Schema を渡して構造化出力(文法制約)を強制する。 + 構造・許可 field(const)・maxItems は文法レベルで保証されるが、 + maxLength は強制されないため上限超過は呼び出し側で破棄する。 """ def __init__(self) -> None: self._base_url = settings.get_ollama_base_url() self._model = settings.get_ollama_model() - async def generate(self, system_prompt: str, messages: list[dict[str, str]]) -> str: + async def generate( + self, + system_prompt: str, + messages: list[dict[str, str]], + output_schema: dict, + ) -> str: payload = { "model": self._model, "messages": [{"role": "system", "content": system_prompt}, *messages], "stream": False, - "format": "json", + "format": output_schema, # 職務経歴書の改善提案は事実忠実性が最優先のため低温度に固定する # (デフォルト 0.8 では小型モデルが架空の資格・技術を捏造しやすい) "options": {"temperature": 0.2}, diff --git a/backend/app/services/agent/output_schema.py b/backend/app/services/agent/output_schema.py new file mode 100644 index 00000000..767d7b5d --- /dev/null +++ b/backend/app/services/agent/output_schema.py @@ -0,0 +1,80 @@ +"""Agent 応答の構造化出力スキーマ(tool use input_schema)の構築。 + +機械検証可能な制約(許可 field・文字数上限・JSON 構造・配列上限)の正本。 +プロンプト(app/prompts/agent_*.md)には機械検証不能な制約(品質基準・ +捏造禁止・思考ステップ)のみを書く(ADR-0010「制約の責務分離」)。 + +注意: Anthropic の非 strict tool use / Ollama の format(文法制約)は +maxLength を API 側で強制しない(モデルへの助言扱い)。文字数上限の +実強制は chat_service._parse_response の破棄ロジックが担う(二重防衛)。 +maxLength は JSON Schema 仕様どおり Unicode 文字数(日本語の len() と一致)。 +""" + +TOOL_NAME = "propose_revision" +TOOL_DESCRIPTION = "職務経歴書フィールドの改善案・説明・次の依頼候補を返す" + +# スコープごとに operations が編集してよいフィールドと文字数上限(SSoT) +SCOPE_FIELDS: dict[str, dict[str, int]] = { + "career_summary": {"career_summary": 2000}, + "self_pr": {"self_pr": 2000}, + "project": {"description": 4500, "role": 200}, +} + +# LLM が生成する「次の依頼候補」(suggestions)の制約 +MAX_SUGGESTIONS = 4 +MAX_SUGGESTION_LENGTH = 200 + + +def build_output_schema(scope: str) -> dict: + """スコープの許可 field・上限から propose_revision の input JSON Schema を構築する。 + + operations.items は field ごとの oneOf 分岐にする(project は description と + role で maxLength が異なるため、field 名と上限をペアで制約する必要がある)。 + """ + operation_branches = [ + { + "type": "object", + "properties": { + "field": {"const": field}, + "value": { + "type": "string", + "maxLength": limit, + "description": "職務経歴書にそのまま掲載できる完成した日本語の本文", + }, + }, + "required": ["field", "value"], + "additionalProperties": False, + } + for field, limit in SCOPE_FIELDS[scope].items() + ] + return { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "提案の説明(日本語)。何をどう改善したか", + }, + "operations": { + "type": "array", + "description": "編集対象フィールドの置換案。提案できない場合は空配列", + "items": {"oneOf": operation_branches}, + }, + "suggestions": { + "type": "array", + "description": "曖昧な依頼への次の依頼文候補。operations を返すときは空配列", + "items": {"type": "string", "maxLength": MAX_SUGGESTION_LENGTH}, + "maxItems": MAX_SUGGESTIONS, + }, + }, + "required": ["message", "operations", "suggestions"], + "additionalProperties": False, + } + + +def build_tool_definition(scope: str) -> dict: + """Anthropic Messages API に渡す tool 定義を構築する。""" + return { + "name": TOOL_NAME, + "description": TOOL_DESCRIPTION, + "input_schema": build_output_schema(scope), + } diff --git a/backend/requirements.txt b/backend/requirements.txt index 4c3b25c6..0d79b697 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,7 +12,6 @@ google-cloud-storage==2.19.0 pytest==9.0.3 python-jose[cryptography]==3.5.0 httpx==0.28.1 -# DevForge Agent(ADR-0010)。Claude Haiku 4.5 呼び出し用 anthropic>=0.40,<1 PyGithub==2.5.0 pandas==2.2.3 diff --git a/backend/tests/test_agent.py b/backend/tests/test_agent.py index 2ac2f3cd..d5d4fe88 100644 --- a/backend/tests/test_agent.py +++ b/backend/tests/test_agent.py @@ -236,6 +236,133 @@ def test_chat_normalizes_out_of_scope_operations(client: TestClient, monkeypatch assert [op["value"] for op in ops] == ["スコープ外の提案", "スコープ内の提案"] +def test_chat_requires_prompt(client: TestClient, monkeypatch) -> None: + """バリデーション: prompt 未指定は 422。LLM は呼ばれない。""" + fake = _mock_llm(monkeypatch, response=_llm_json("self_pr", "提案")) + headers = auth_header(client, "agentuser") + resp = client.post( + "/api/agent/chat", + json={"scope": "self_pr", "resume": _resume_payload()}, + headers=headers, + ) + assert resp.status_code == 422 + assert fake.received_messages is None + + +def test_chat_ambiguous_input_returns_llm_suggestions(client: TestClient, monkeypatch) -> None: + """契約: 曖昧入力時、LLM が返した suggestions(依頼文候補)がそのまま返る。""" + _mock_llm( + monkeypatch, + response=json.dumps( + { + "message": "どの方向で改善しますか?", + "operations": [], + "suggestions": ["300字に要約して", "成果を強調して書き直して"], + }, + ensure_ascii=False, + ), + ) + headers = auth_header(client, "agentuser") + resp = client.post( + "/api/agent/chat", + json={"scope": "self_pr", "prompt": "いい感じにして", "resume": _resume_payload()}, + headers=headers, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["operations"] == [] + assert body["suggestions"] == ["300字に要約して", "成果を強調して書き直して"] + + +def test_parse_response_discards_invalid_suggestions() -> None: + """suggestions の検証: 空文字・200字超は破棄し、4 件を超えた分は切り詰める。""" + raw = json.dumps( + { + "message": "確認です", + "operations": [], + "suggestions": ["", "A" * 201, "候補1", "候補2", "候補3", "候補4", "候補5"], + }, + ensure_ascii=False, + ) + result = _parse_response(raw, "self_pr") + assert result.suggestions == ["候補1", "候補2", "候補3", "候補4"] + + +def test_parse_response_drops_suggestions_when_operations_present() -> None: + """suggestions は operations が空のときのみ返す(同時提示しない契約)。""" + raw = json.dumps( + { + "message": "提案です", + "operations": [{"field": "self_pr", "value": "改善案"}], + "suggestions": ["別の候補"], + }, + ensure_ascii=False, + ) + result = _parse_response(raw, "self_pr") + assert len(result.operations) == 1 + assert result.suggestions == [] + + +def test_chat_without_suggestions_field_defaults_empty(client: TestClient, monkeypatch) -> None: + """後方互換: LLM が suggestions を返さなくても空配列として扱う。""" + _mock_llm(monkeypatch, response=_llm_json("self_pr", "提案")) + headers = auth_header(client, "agentuser") + resp = client.post( + "/api/agent/chat", + json={"scope": "self_pr", "prompt": "改善して", "resume": _resume_payload()}, + headers=headers, + ) + assert resp.status_code == 200 + assert resp.json()["suggestions"] == [] + + +@pytest.mark.parametrize( + ("scope", "field", "target"), + [ + ("career_summary", "career_summary", None), + ("self_pr", "self_pr", None), + ( + "project", + "description", + {"experience_index": 0, "client_index": 0, "project_index": 0}, + ), + ], +) +def test_chat_system_prompt_is_scope_specific( + client: TestClient, monkeypatch, scope: str, field: str, target: dict | None +) -> None: + """契約: system prompt は base+該当スコープの md のみで構成される。 + + 他スコープの品質基準が混ざると小型 LLM が文字数制限等を取り違えるため、 + 自スコープの見出しを含み、他スコープの見出しを含まないことを検証する。 + """ + fake = _mock_llm(monkeypatch, response=_llm_json(field, "提案")) + headers = auth_header(client, "agentuser") + payload: dict = {"scope": scope, "prompt": "改善して", "resume": _resume_payload()} + if target is not None: + payload["target"] = target + resp = client.post("/api/agent/chat", json=payload, headers=headers) + assert resp.status_code == 200 + prompt = fake.received_system_prompt + assert prompt is not None + # 共通ルール(agent_base.md)が含まれる + assert "# 共通ルール" in prompt + # スコープ見出し(agent_{scope}.md 冒頭)は自スコープのみ + scope_headings = { + "career_summary": "# スコープ: 職務要約(career_summary)", + "self_pr": "# スコープ: 自己PR(self_pr)", + "project": "# スコープ: プロジェクト詳細(project)", + } + assert scope_headings[scope] in prompt + for other, heading in scope_headings.items(): + if other != scope: + assert heading not in prompt + # プレースホルダはロード時に埋め込み済み(残骸が無い) + assert "{allowed_fields}" not in prompt + assert "{field_limits}" not in prompt + assert field in prompt + + def test_chat_passes_history_to_llm(client: TestClient, monkeypatch) -> None: """契約: history が LLM の messages に展開され、末尾が今回の user prompt になる。""" fake = _mock_llm(monkeypatch, response=_llm_json("self_pr", "提案")) diff --git a/docs/adr/0010-devforge-agent.md b/docs/adr/0010-devforge-agent.md index 1fd0ecd9..bc8c996b 100644 --- a/docs/adr/0010-devforge-agent.md +++ b/docs/adr/0010-devforge-agent.md @@ -74,6 +74,100 @@ Agent のレスポンス(差分 operations)はフロントの state にの ADR-0004 の `generate()` は失敗時に空文字を返す設計で、UI がエラーを検知できなかった。本機能は対話型のため、`POST /agent/chat` では LLM 呼び出しの失敗(タイムアウト / モデル未起動 / API エラー / JSON パース失敗)を**明示的に区別して HTTP エラー(日本語メッセージ)で返す**。エラーメッセージは `backend/app/messages.json` を正本とし、frontend は `AppErrorResponse.message` を表示する(`.claude/rules/frontend/messages.md` 準拠)。空文字フォールバックで握りつぶさない。 +### エラー契約 + +`POST /api/agent/chat` のエラーは以下の契約で返す(実装: `backend/app/routers/agent.py` / `backend/app/services/agent/chat_service.py`)。メッセージ正本は `backend/app/messages.json` の `error.agent`。 + +| 事象 | HTTP | ErrorCode | messages.json キー | +|---|---|---|---| +| project スコープで target 未指定 | 422 | `VALIDATION_ERROR` | `agent.target_required`(schema validator で発火) | +| target インデックスが範囲外 | 422 | `VALIDATION_ERROR` | `agent.target_not_found` | +| LLM 呼び出し失敗(タイムアウト / API エラー / モデル未起動) | 502 | `AGENT_LLM_ERROR` | `agent.llm_failed` | +| LLM 応答の JSON パース / スキーマ検証失敗 | 502 | `AGENT_PARSE_ERROR` | `agent.parse_failed` | +| レート制限超過(`slowapi` 10/minute) | 429 | — | slowapi 既定 | + +LLM 出力の検証は次の多段で行い、バリデーションを通過したもののみフロントに返す: + +1. JSON パース + `AgentChatResponse` の Pydantic 検証(失敗は `AGENT_PARSE_ERROR`) +2. 許可外の `field` 名はスコープの既定フィールドへ正規化する(スコープ選択で編集対象は確定しており、提案を捨てるよりユーザー利益が大きい) +3. **文字数上限を超過した operation は切り詰めず破棄する**(warning ログを残す)。`message` は返るため、ユーザーは依頼を変えて再指示できる + +文字数超過時にバックエンドで切り詰めて返す案は**却下**した。文章が途中で切れた経歴書は品質として許容できないためである(「代替案」参照)。 + +**曖昧入力のフォールバック**: 改善に必要な情報が不足している場合、LLM は事実を捏造せず `operations` を空配列にし、`message` で必要な情報をユーザーに確認する(system prompt の共通ルールで規定)。依頼が曖昧・抽象的なときはこれに加えて、LLM が `suggestions`(次の依頼文候補の文字列配列)を生成して選択肢を提示する(「対話型選択肢(LLM 生成 suggestions)設計」参照)。 + +### コスト設計 + +#### system prompt のスコープ分岐 + +system prompt は共通ルール(`agent_base.md`)+**選択スコープの md 1 枚のみ**(`agent_{scope}.md`)を結合する。3 スコープ分を全結合すると毎リクエスト約 2,000 トークンの無駄が出ることに加え、無関係なスコープの品質基準(文字数制限等)を小型モデルが取り違える品質問題があるため、スコープ分岐を必須とする。プレースホルダ(`{allowed_fields}` / `{field_limits}`)はスコープごとに静的なためモジュールロード時に埋め込み、system prompt を完全静的化してプロバイダ側のプロンプトキャッシュを効かせる。 + +#### コンテキスト圧縮(GitHub / ブログ連携時の契約) + +Phase 2 で GitHub / ブログ分析を Agent コンテキストに渡す際は、生データではなく**派生サマリーに圧縮**して渡す。 + +**github_context(~200 トークン以下)** + +- `languages` 上位 5 件(バイト数または割合) +- 年ごとの `total_contributions`(`contribution_calendars` から `weeks` を捨てて集計) +- 直近 12 ヶ月の活動日数 + +`contribution_calendars` の日次グリッド(1 年分で約 4,500〜5,500 トークン)はヒートマップ描画用の構造であり、LLM コンテキストとしては渡さない。 + +**blog_context(~200 トークン以下)** + +- 更新頻度サマリー(`avg_monthly_posts` / `tech_article_count` 等) +- 直近記事のタイトル・タグ + +記事本文・全記事リストは渡さない。 + +### 会話履歴(マルチターン) + +当初 Phase 2 予定だったが、推敲の連続性(「さっきの提案のここだけ直して」)が Phase 1 の中核 UX に直結するため **Phase 1 に前倒しして実装済み**。 + +- 履歴は**フロントのみ**で保持する(DB 永続化なし。サーバーはセッションを持たない) +- ページリロードで履歴はリセットされる。スコープ切り替えでは履歴を保持する(各エントリが送信時のスコープ・target を持ち、適用時に参照する) +- **3 往復(user + assistant で 6 エントリ)**を上限とし、超過分は古いものから切り詰める(`AgentChatRequest.history` の `max_length=6` とフロント `useAgentChat.ts` の `HISTORY_LIMIT` で同期) +- assistant メッセージは `message` だけでなく **`operations`(提案した本文)を含む応答 JSON 原文**を履歴に保持する。推敲の連続性に加え、出力形式の実例として few-shot 的に働き小型モデルのフォーマット逸脱を抑える +- レジュメコンテキストは最新ターンの prompt にのみ載せる(履歴側は依頼文 / 応答 JSON のみで、毎ターンの重複でトークンが膨れるのを防ぐ) + +リクエストスキーマ(実装準拠): + +```json +{ + "scope": "self_pr", + "prompt": "もっと簡潔にして", + "resume": { "career_summary": "...", "self_pr": "...", "experiences": [] }, + "target": null, + "history": [ + { "role": "user", "text": "自己PRを改善して" }, + { "role": "assistant", "text": "{\"message\": \"...\", \"operations\": [...]}" } + ] +} +``` + +**将来の検討事項(Phase 2 以降)**: 履歴往復数の拡大は operations 込みでトークン量が線形に増えるため、コンテキスト圧縮(上記)とセットで再評価する。 + +### 対話型選択肢(LLM 生成 suggestions)設計 + +#### 背景 + +曖昧な自由入力(「いい感じにして」等)は LLM の出力精度を下げる(特にローカルの小型モデル)。入力は**フリーテキストのみ**とし、依頼が曖昧で意図を特定できないときに **LLM 自身が対話の流れに沿った選択肢(次の依頼文の候補)を生成して提示**する。ユーザーは選択肢をタップするだけで意図を具体化でき、言語化の負担が下がる。 + +#### レスポンスの `suggestions` + +`AgentChatResponse.suggestions: list[str]`。LLM が生成する「次の依頼文」候補で、フロントはボタンとして表示し、押下されたテキストを**そのまま次の `prompt` として再送信**する(専用 API・専用フィールドは増やさない)。 + +- system prompt(`agent_base.md` の共通ルール)で規定: 依頼が曖昧・抽象的で operations を返せないときは、`message` で確認しつつ `suggestions` に具体的な依頼文の候補を **2〜4 個**入れる(ユーザーがそのまま送れる命令形の日本語)。通常の提案時は空配列 +- バックエンド(`chat_service._parse_response`)の検証: 空文字・200 字超の候補は破棄、最大 4 件に切り詰め。**operations がある応答に suggestions が混ざっていた場合は破棄**する(提案と選択肢の同時提示は UI が混乱するため) +- 小型モデルが suggestions を返せない場合は空配列に degrade し、従来どおり `message` のみで対話する(機能破壊にならない) + +#### フロントエンド UI + +- 入力はフリーテキストのみ(事前定義のアクションボタンは置かない) +- assistant メッセージに `suggestions` が含まれる場合、メッセージ直下に候補ボタンを表示する(`AgentChatWidget` の `SuggestionButtons`) +- ボタン押下で候補テキストを通常のフリーテキスト送信と**同じ経路**(`useAgentChat.send`)で送信する。チャット欄・LLM 履歴にもそのテキストが user 発話として残る + ### セキュリティ・横断要件 - **認証ガード**: `POST /agent/chat` は `get_current_user` 依存を付与する(未認証アクセス不可)。 @@ -91,12 +185,18 @@ ADR-0004 の `generate()` は失敗時に空文字を返す設計で、UI がエ - system prompt 設計・チューニング - フロント: チャットウィジェット UI - フロント: スコープ選択 → operations 適用ロジック(state プレビュー、DB 未更新) +- 会話履歴の保持(マルチターン対応。当初 Phase 2 予定から前倒し。「会話履歴(マルチターン)」参照) + +Phase 1 追補(対話型選択肢。「対話型選択肢(LLM 生成 suggestions)設計」参照、実装済み): + +- [x] レスポンスに `suggestions: list[str]` 追加(LLM が生成、`_parse_response` で検証) +- [x] system prompt(`agent_base.md`)に曖昧入力時の suggestions 生成ルールを追加 +- [x] フロント: suggestions ボタン表示(押下でそのテキストを prompt として再送信) **Phase 2(拡張)** - experience 単位のスコープ追加 -- 会話履歴の保持(マルチターン対応) -- GitHub / ブログ分析との連携強化 +- GitHub / ブログ分析との連携強化(「コスト設計」のコンテキスト圧縮契約に従う) **Phase 3(将来)** @@ -114,6 +214,12 @@ ADR-0004 の `generate()` は失敗時に空文字を返す設計で、UI がエ **pdfme カスタムテンプレート(今回の優先度外)** DevForge の本質的価値は「蓄積データ × LLM による経歴書生成」であり、PDF レイアウトのカスタマイズは副次的機能と判断。Agent 機能が完成し GitHub・ブログ分析との連携が充実した段階で改めて検討する。 +**事前定義のアクションカタログ(定型ボタン)** +スコープごとの定型改善依頼を `agent_actions.yaml` + `GET /api/agent/actions` + `action_id` で提供する案。一度実装したが却下した。定型ボタンはユーザーの文脈・対話の流れに合わない提案になりやすく、曖昧入力への選択肢は LLM が対話に沿って生成する方が適切。カタログとエンドポイントの保守コストも不要になる。入力はフリーテキストのみとし、選択肢は LLM 生成の `suggestions` で提示する。採用しない。 + +**文字数超過時のバックエンド切り詰め** +LLM が文字数上限を超える `value` を返した場合に、バックエンドで上限まで切り詰めて返す案。文章が途中で切れた経歴書は品質として許容できないため却下。超過 operation は破棄(warning ログ)し、`message` でユーザーに再指示を促す(「エラー契約」参照)。採用しない。 + **LLM を再導入せずルールベースを維持(ADR-0008 の現状維持)** 対話的なキャリア戦略支援はルールベースでは表現力が不足する。本機能の中核価値が LLM による自由記述生成であるため、ADR-0008 を Superseded として LLM を再導入する。 diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index 5e6a0020..cbe2b714 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -737,12 +737,19 @@ export interface components { /** * AgentChatResponse * @description Agent チャットのレスポンス(AI の説明文 + 差分 operations)。 + * + * ``suggestions`` は依頼が曖昧で operations を返せないときに LLM が生成する + * 「次の依頼文の候補」。フロントはボタンとして表示し、押下されたテキストを + * そのまま次の ``prompt`` として再送信する。検証・件数制限は + * chat_service._parse_response が担う。 */ AgentChatResponse: { /** Message */ message: string; /** Operations */ operations?: components["schemas"]["AgentOperation"][]; + /** Suggestions */ + suggestions?: string[]; }; /** * AgentClientContext diff --git a/frontend/src/components/forms/AgentChatWidget.module.css b/frontend/src/components/forms/AgentChatWidget.module.css index 7d8c9271..3eb0fe8f 100644 --- a/frontend/src/components/forms/AgentChatWidget.module.css +++ b/frontend/src/components/forms/AgentChatWidget.module.css @@ -231,3 +231,31 @@ background: var(--text-disabled); cursor: not-allowed; } + +/* LLM が提示する「次の依頼文」候補のボタン列 */ +.actionRow { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 4px 0; +} + +.actionButton { + padding: 4px 10px; + border: 1px solid #059669; + border-radius: 999px; + background: transparent; + color: #059669; + font-size: 0.78rem; + cursor: pointer; +} + +.actionButton:hover { + background: rgba(5, 150, 105, 0.1); +} + +.actionButton:disabled { + border-color: var(--text-disabled); + color: var(--text-disabled); + cursor: not-allowed; +} diff --git a/frontend/src/components/forms/AgentChatWidget.tsx b/frontend/src/components/forms/AgentChatWidget.tsx index 089db975..0005a80f 100644 --- a/frontend/src/components/forms/AgentChatWidget.tsx +++ b/frontend/src/components/forms/AgentChatWidget.tsx @@ -38,6 +38,34 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } +/** LLM が提示した「次の依頼文」候補のボタン列。押下でそのまま prompt として再送する。 */ +function SuggestionButtons({ + items, + disabled, + onSelect, +}: { + items: string[]; + disabled: boolean; + onSelect: (suggestion: string) => void; +}) { + if (items.length === 0) return null; + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + function buildProjectOptions(form: CareerFormState): ProjectOption[] { const options: ProjectOption[] = []; form.experiences.forEach((exp, ei) => { @@ -86,6 +114,15 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin } setPrompt(""); }; + /** suggestions ボタンの送信可否(自由入力と違い入力テキストは不要) */ + const canSendSuggestion = !sending && (scope !== "project" || selectedTarget !== null); + + const handleSuggestion = (suggestion: string) => { + if (!canSendSuggestion) return; + clearError(); + void send(form, scope, scope === "project" ? selectedTarget : null, suggestion); + }; + // パネル左上のハンドルをドラッグしてリサイズする。パネルは右下固定なので // ポインタが左上に動くほど大きくなる(差分を加算) const handleResizeStart = useCallback((e: React.PointerEvent) => { @@ -202,6 +239,13 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin } className={entry.role === "user" ? styles.userMessage : styles.assistantMessage} >

{entry.text}

+ {entry.suggestions && ( + + )} {entry.operations && (
{entry.operations.map((op, j) => ( diff --git a/frontend/src/hooks/career/useAgentChat.test.ts b/frontend/src/hooks/career/useAgentChat.test.ts index 8651a581..cdf48c5c 100644 --- a/frontend/src/hooks/career/useAgentChat.test.ts +++ b/frontend/src/hooks/career/useAgentChat.test.ts @@ -50,6 +50,35 @@ describe("useAgentChat", () => { ); }); + it("曖昧入力応答の suggestions(依頼文候補)をエントリに保持する", async () => { + postAgentChatMock.mockResolvedValue({ + message: "どの方向で改善しますか?", + operations: [], + suggestions: ["300字に要約して", "成果を強調して書き直して"], + }); + const { result } = renderHook(() => useAgentChat()); + + await act(async () => { + await result.current.send(form, "self_pr", null, "いい感じにして"); + }); + + expect(result.current.entries[1].suggestions).toEqual([ + "300字に要約して", + "成果を強調して書き直して", + ]); + }); + + it("suggestions が無い応答では suggestions は null", async () => { + postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] }); + const { result } = renderHook(() => useAgentChat()); + + await act(async () => { + await result.current.send(form, "self_pr", null, "改善して"); + }); + + expect(result.current.entries[1].suggestions).toBeNull(); + }); + it("API 失敗時は error にメッセージが入り sending が解除される", async () => { postAgentChatMock.mockRejectedValue(new Error("AI の応答取得に失敗しました。")); const { result } = renderHook(() => useAgentChat()); diff --git a/frontend/src/hooks/career/useAgentChat.ts b/frontend/src/hooks/career/useAgentChat.ts index 11ad6a78..629ade34 100644 --- a/frontend/src/hooks/career/useAgentChat.ts +++ b/frontend/src/hooks/career/useAgentChat.ts @@ -23,6 +23,8 @@ export type AgentChatEntry = { text: string; /** AI 応答のみ。フォームへ反映できる差分(適用済みなら null にする) */ operations: AgentOperation[] | null; + /** AI 応答のみ。依頼が曖昧なとき LLM が提示する「次の依頼文」候補 */ + suggestions: string[] | null; /** 送信時点のスコープ・対象(適用時に参照する) */ scope: AgentScope; target: ProjectTarget | null; @@ -67,7 +69,15 @@ export function useAgentChat() { setSending(true); setEntries((prev) => [ ...prev, - { role: "user", text: prompt, operations: null, scope, target, historyText: prompt }, + { + role: "user", + text: prompt, + operations: null, + suggestions: null, + scope, + target, + historyText: prompt, + }, ]); try { const response = await postAgentChat({ @@ -83,6 +93,7 @@ export function useAgentChat() { role: "assistant", text: response.message, operations: response.operations?.length ? response.operations : null, + suggestions: response.suggestions?.length ? response.suggestions : null, scope, target, // 応答 JSON の原文を履歴用に保持する(出力形式の実例としても機能する) From a48e0120bf7b9bb246a301303c18bc2e50ffbbe7 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 12 Jun 2026 07:44:44 +0900 Subject: [PATCH 2/6] devforge_agent architect fix --- backend/app/prompts/agent_base.md | 2 +- backend/app/prompts/agent_career_summary.md | 2 +- backend/app/prompts/agent_project.md | 3 +- backend/app/prompts/agent_self_pr.md | 2 +- backend/app/services/agent/chat_service.py | 8 ++++- .../services/agent/llm/anthropic_client.py | 10 ++---- backend/app/services/agent/output_schema.py | 4 +-- backend/tests/test_agent.py | 17 +++++++-- docs/adr/0010-devforge-agent.md | 35 +++++++++++++++---- 9 files changed, 58 insertions(+), 25 deletions(-) diff --git a/backend/app/prompts/agent_base.md b/backend/app/prompts/agent_base.md index da19b184..1f1add1b 100644 --- a/backend/app/prompts/agent_base.md +++ b/backend/app/prompts/agent_base.md @@ -1,5 +1,5 @@ あなたは日本語の職務経歴書の改善を支援するアシスタントです。 -ユーザーの依頼に基づき、編集対象フィールドの改善案(message・operations・suggestions)を返してください。 +ユーザーの依頼に基づき、編集対象の改善案を返してください。 出力の構造・許可フィールド・文字数上限はスキーマで定義されているため、ここでは内容の品質に集中すること。 # 共通ルール diff --git a/backend/app/prompts/agent_career_summary.md b/backend/app/prompts/agent_career_summary.md index 6efb2865..6ae8902a 100644 --- a/backend/app/prompts/agent_career_summary.md +++ b/backend/app/prompts/agent_career_summary.md @@ -1,6 +1,6 @@ # スコープ: 職務要約(career_summary) -operations の field は `career_summary` のみ許可する。推奨文字数は 200〜300字(システム上限とは別の品質基準)。 +推奨文字数は 200〜300字(システム上限とは別の品質基準)。 # 品質基準 - キャリアの変遷(経験年数・業界・役割)が一読で伝わる diff --git a/backend/app/prompts/agent_project.md b/backend/app/prompts/agent_project.md index 7126e2e7..a25c0710 100644 --- a/backend/app/prompts/agent_project.md +++ b/backend/app/prompts/agent_project.md @@ -1,7 +1,6 @@ # スコープ: プロジェクト詳細(project) -operations の field は `description` と `role` のみ許可する。 -description の推奨文字数は 300〜400字(システム上限とは別の品質基準)、role はプロジェクトでの役割を表す 1 行のタイトル。 +詳細文の推奨文字数は 300〜400字(システム上限とは別の品質基準)。役割はプロジェクトでの立場を表す 1 行のタイトル。 # 品質基準 - description は課題 → 行動 → 成果の構造で書く(PAR 形式) diff --git a/backend/app/prompts/agent_self_pr.md b/backend/app/prompts/agent_self_pr.md index bc3aaa71..8b33f68e 100644 --- a/backend/app/prompts/agent_self_pr.md +++ b/backend/app/prompts/agent_self_pr.md @@ -1,6 +1,6 @@ # スコープ: 自己PR(self_pr) -operations の field は `self_pr` のみ許可する。推奨文字数は 300〜400字(システム上限とは別の品質基準)。 +推奨文字数は 300〜400字(システム上限とは別の品質基準)。 # 品質基準 - 導入文(1〜2文): キャリア全体を俯瞰した一言で読み手をつかむ diff --git a/backend/app/services/agent/chat_service.py b/backend/app/services/agent/chat_service.py index 6c885611..61725bb6 100644 --- a/backend/app/services/agent/chat_service.py +++ b/backend/app/services/agent/chat_service.py @@ -127,8 +127,14 @@ def _resolve_target_project(request: AgentChatRequest) -> AgentProjectContext: def _parse_response(raw: str, scope: str) -> AgentChatResponse: """LLM 応答をパースし、field の正規化と上限超過 operation の破棄を行って返す。""" + text = raw.strip() + # Ollama など tool use ではないローカル実装は、構造化出力指定後もコードフェンスを + # 付ける場合がある。JSON mode への依存ではなく、ローカル開発用の耐性として残す。 + if text.startswith("```"): + text = text.strip("`") + text = text.removeprefix("json").strip() try: - data = json.loads(raw) + data = json.loads(text) parsed = AgentChatResponse.model_validate(data) except (json.JSONDecodeError, ValidationError) as exc: logger.warning("LLM 応答のパースに失敗: %s", type(exc).__name__) diff --git a/backend/app/services/agent/llm/anthropic_client.py b/backend/app/services/agent/llm/anthropic_client.py index 63b50578..22fcee67 100644 --- a/backend/app/services/agent/llm/anthropic_client.py +++ b/backend/app/services/agent/llm/anthropic_client.py @@ -6,7 +6,7 @@ import anthropic from ....core import settings -from ..output_schema import TOOL_DESCRIPTION, TOOL_NAME +from ..output_schema import TOOL_NAME, build_tool_definition from .base import LLMClient, LLMError logger = logging.getLogger(__name__) @@ -47,13 +47,7 @@ async def generate( messages=messages, # tool use 強制で出力構造をスキーマに従わせる(JSON mode は使わない)。 # maxLength は API では強制されないため、上限超過は呼び出し側で破棄する - tools=[ - { - "name": TOOL_NAME, - "description": TOOL_DESCRIPTION, - "input_schema": output_schema, - } - ], + tools=[build_tool_definition(output_schema)], tool_choice={"type": "tool", "name": TOOL_NAME}, ) except ( diff --git a/backend/app/services/agent/output_schema.py b/backend/app/services/agent/output_schema.py index 767d7b5d..e86c5eb1 100644 --- a/backend/app/services/agent/output_schema.py +++ b/backend/app/services/agent/output_schema.py @@ -71,10 +71,10 @@ def build_output_schema(scope: str) -> dict: } -def build_tool_definition(scope: str) -> dict: +def build_tool_definition(input_schema: dict) -> dict: """Anthropic Messages API に渡す tool 定義を構築する。""" return { "name": TOOL_NAME, "description": TOOL_DESCRIPTION, - "input_schema": build_output_schema(scope), + "input_schema": input_schema, } diff --git a/backend/tests/test_agent.py b/backend/tests/test_agent.py index d5d4fe88..f1418e64 100644 --- a/backend/tests/test_agent.py +++ b/backend/tests/test_agent.py @@ -29,10 +29,17 @@ def __init__(self, response: str | None = None, error: Exception | None = None): self._error = error self.received_system_prompt: str | None = None self.received_messages: list[dict[str, str]] | None = None - - async def generate(self, system_prompt: str, messages: list[dict[str, str]]) -> str: + self.received_output_schema: dict | None = None + + async def generate( + self, + system_prompt: str, + messages: list[dict[str, str]], + output_schema: dict, + ) -> str: self.received_system_prompt = system_prompt self.received_messages = messages + self.received_output_schema = output_schema if self._error: raise self._error assert self._response is not None @@ -360,7 +367,11 @@ def test_chat_system_prompt_is_scope_specific( # プレースホルダはロード時に埋め込み済み(残骸が無い) assert "{allowed_fields}" not in prompt assert "{field_limits}" not in prompt - assert field in prompt + assert "JSON のみ" not in prompt + assert fake.received_output_schema is not None + branches = fake.received_output_schema["properties"]["operations"]["items"]["oneOf"] + allowed_fields = [branch["properties"]["field"]["const"] for branch in branches] + assert field in allowed_fields def test_chat_passes_history_to_llm(client: TestClient, monkeypatch) -> None: diff --git a/docs/adr/0010-devforge-agent.md b/docs/adr/0010-devforge-agent.md index bc8c996b..206d055d 100644 --- a/docs/adr/0010-devforge-agent.md +++ b/docs/adr/0010-devforge-agent.md @@ -22,7 +22,7 @@ DevForge は現在、手入力データをもとに職務経歴書(`Resume`) | `self_pr` | `ResumeBase.self_pr`(`str`, `max_length=2000`, 必須) | トップレベルのフラットなフィールド | | `project` | `Experience.clients[].projects[].description`(`str`, `max_length=4500`) | `experiences[] > clients[] > projects[]` の深いネスト | -`career_summary` / `self_pr` は単一文字列フィールドだが、`project` は配列の深いネスト下にあり「どの experience のどの client のどの project か」をパスで特定する必要がある。差分 operations はこのパス指定を含む設計とする。 +`career_summary` / `self_pr` は単一文字列フィールドだが、`project` は配列の深いネスト下にあり「どの experience のどの client のどの project か」を特定する必要がある。対象 project の位置はリクエストの `target` で確定し、LLM の差分 operations はスコープ内のフィールド名と置換値だけを返す。 ## 決定内容 @@ -74,6 +74,24 @@ Agent のレスポンス(差分 operations)はフロントの state にの ADR-0004 の `generate()` は失敗時に空文字を返す設計で、UI がエラーを検知できなかった。本機能は対話型のため、`POST /agent/chat` では LLM 呼び出しの失敗(タイムアウト / モデル未起動 / API エラー / JSON パース失敗)を**明示的に区別して HTTP エラー(日本語メッセージ)で返す**。エラーメッセージは `backend/app/messages.json` を正本とし、frontend は `AppErrorResponse.message` を表示する(`.claude/rules/frontend/messages.md` 準拠)。空文字フォールバックで握りつぶさない。 +### LLM 出力制御と制約の責務分離 + +Agent の LLM 出力制御は JSON mode ではなく、プロバイダの構造化出力機構を使う。 + +- 本番 Anthropic: Messages API の tool use を `tool_choice` で強制し、`propose_revision` tool の `input_schema` に従う入力を返させる +- ローカル Ollama: `/api/chat` の `format` に同じ JSON Schema を渡し、ローカル開発でも構造化出力に寄せる + +制約は次の基準で責務を分離する。 + +| 制約の種類 | 例 | 守らせる場所 | +|---|---|---| +| 機械検証可能 | JSON 構造、必須キー、許可フィールド、文字数上限、配列件数上限 | tool use の JSON Schema + `AgentChatResponse` / `AgentOperation` の Pydantic 検証 + `_parse_response` のスコープ検証 | +| 機械検証不能 | 文体、構成、PAR 形式、捏造禁止、情報不足時の確認、思考ステップ | system prompt(`backend/app/prompts/agent_*.md`) | + +判断基準は「コードでテストが書ける制約はプロンプトに書かない」。そのため、プロンプトには JSON のみ・コードフェンス禁止・許可フィールド列挙・保存上限の数値制約を書かない。スコープごとの許可フィールドと上限は `backend/app/services/agent/output_schema.py` を正本とし、`career_summary` / `self_pr` / `project` ごとに tool schema を切り替える。 + +ただし Anthropic tool use と Ollama `format` は `maxLength` を API 側で厳密に強制するとは限らないため、Pydantic と `_parse_response` で二重に検証する。上限超過の operation は切り詰めず破棄する。 + ### エラー契約 `POST /api/agent/chat` のエラーは以下の契約で返す(実装: `backend/app/routers/agent.py` / `backend/app/services/agent/chat_service.py`)。メッセージ正本は `backend/app/messages.json` の `error.agent`。 @@ -88,9 +106,10 @@ ADR-0004 の `generate()` は失敗時に空文字を返す設計で、UI がエ LLM 出力の検証は次の多段で行い、バリデーションを通過したもののみフロントに返す: -1. JSON パース + `AgentChatResponse` の Pydantic 検証(失敗は `AGENT_PARSE_ERROR`) -2. 許可外の `field` 名はスコープの既定フィールドへ正規化する(スコープ選択で編集対象は確定しており、提案を捨てるよりユーザー利益が大きい) -3. **文字数上限を超過した operation は切り詰めず破棄する**(warning ログを残す)。`message` は返るため、ユーザーは依頼を変えて再指示できる +1. tool use / `format` に渡した JSON Schema で構造・必須キー・許可フィールド・上限をモデルに提示する +2. JSON パース + `AgentChatResponse` の Pydantic 検証(失敗は `AGENT_PARSE_ERROR`) +3. 許可外の `field` 名はスコープの既定フィールドへ正規化する(スコープ選択で編集対象は確定しており、提案を捨てるよりユーザー利益が大きい) +4. **文字数上限を超過した operation は切り詰めず破棄する**(warning ログを残す)。`message` は返るため、ユーザーは依頼を変えて再指示できる 文字数超過時にバックエンドで切り詰めて返す案は**却下**した。文章が途中で切れた経歴書は品質として許容できないためである(「代替案」参照)。 @@ -182,7 +201,8 @@ Phase 2 で GitHub / ブログ分析を Agent コンテキストに渡す際は - `POST /agent/chat` エンドポイント実装(認証ガード + rate limit) - スコープ 3 種(`project` / `career_summary` / `self_pr`)対応 - 差分 operations のパス指定スキーマ設計 -- system prompt 設計・チューニング +- tool use 用のスコープ別出力スキーマ設計 +- system prompt 設計・チューニング(機械検証不能な品質制約のみ) - フロント: チャットウィジェット UI - フロント: スコープ選択 → operations 適用ロジック(state プレビュー、DB 未更新) - 会話履歴の保持(マルチターン対応。当初 Phase 2 予定から前倒し。「会話履歴(マルチターン)」参照) @@ -220,6 +240,9 @@ DevForge の本質的価値は「蓄積データ × LLM による経歴書生成 **文字数超過時のバックエンド切り詰め** LLM が文字数上限を超える `value` を返した場合に、バックエンドで上限まで切り詰めて返す案。文章が途中で切れた経歴書は品質として許容できないため却下。超過 operation は破棄(warning ログ)し、`message` でユーザーに再指示を促す(「エラー契約」参照)。採用しない。 +**JSON mode + プロンプトによる制約記述の継続** +JSON mode を使い、許可フィールド・文字数上限・JSON 構造などを自然言語プロンプトに列挙し続ける案。制約が増えるたびに system prompt が肥大化し、自然言語による制約遵守はモデル任せで保証がないため却下する。機械検証可能な制約は tool use の JSON Schema と Pydantic に寄せる。 + **LLM を再導入せずルールベースを維持(ADR-0008 の現状維持)** 対話的なキャリア戦略支援はルールベースでは表現力が不足する。本機能の中核価値が LLM による自由記述生成であるため、ADR-0008 を Superseded として LLM を再導入する。 @@ -242,7 +265,7 @@ LLM が文字数上限を超える `value` を返した場合に、バックエ |---|---| | `backend/app/routers/` | `agent.py` 追加(`POST /agent/chat`、認証ガード + rate limit) | | `backend/app/services/` | `agent/chat_service.py` 追加(スコープデータ組み立て + LLM 呼び出し + 差分生成)。LLM プロバイダ抽象は ADR-0004 を参考に再構築 | -| `backend/app/schemas/` | Agent リクエスト/レスポンス(差分 operations のパス指定)スキーマ追加 | +| `backend/app/schemas/` | Agent リクエスト/レスポンス(スコープ内フィールドの差分 operations)スキーマ追加 | | `backend/app/messages.json` | Agent 関連のエラーメッセージ(LLM 失敗・パース失敗)追加 | | `backend/app/core/env_keys.py` ほか 4 箇所 | `ANTHROPIC_API_KEY` 追加(5 箇所同期) | | `frontend/src/` | チャットウィジェットコンポーネント追加 | From cfcb700960107d581efc21728f5fa126196fd8b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 00:50:44 +0000 Subject: [PATCH 3/6] =?UTF-8?q?test:=20Agent=20tool=20use=20=E5=8C=96?= =?UTF-8?q?=E3=81=AE=E4=B8=8D=E8=B6=B3=E3=83=86=E3=82=B9=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=EF=BC=88=E3=82=B9=E3=82=AD=E3=83=BC=E3=83=9E?= =?UTF-8?q?=E7=94=9F=E6=88=90=E3=83=BB=E3=83=AA=E3=83=88=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E5=A5=91=E7=B4=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - output_schema の単体テスト: スコープ→tool 定義(許可 field・maxLength の oneOf 分岐、必須キー・additionalProperties・suggestions 上限)を検証 - SCOPE_FIELDS と schemas/resume.py の max_length 一致を検証する drift 防止テスト - リトライ契約のテスト: パース失敗時に違反内容をフィードバックして 1 回だけ 再生成し、成功なら 200 / 再失敗なら 502 + AGENT_PARSE_ERROR(3 回目は呼ばない) https://claude.ai/code/session_01LAD1cYgN7mrMpfExeQhCdK --- backend/tests/test_agent.py | 121 ++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/backend/tests/test_agent.py b/backend/tests/test_agent.py index f1418e64..70f31701 100644 --- a/backend/tests/test_agent.py +++ b/backend/tests/test_agent.py @@ -16,6 +16,14 @@ _parse_response, ) from app.services.agent.llm.base import LLMClient, LLMError +from app.services.agent.output_schema import ( + MAX_SUGGESTION_LENGTH, + MAX_SUGGESTIONS, + SCOPE_FIELDS, + TOOL_NAME, + build_output_schema, + build_tool_definition, +) from fastapi.testclient import TestClient from conftest import auth_header @@ -52,6 +60,23 @@ def _mock_llm(monkeypatch, *, response: str | None = None, error: Exception | No return fake +class _SequentialFakeLLM(LLMClient): + """呼び出しごとに用意した応答を順に返す LLM クライアント(リトライ検証用)。""" + + def __init__(self, responses: list[str]): + self._responses = list(responses) + self.calls: list[list[dict[str, str]]] = [] + + async def generate( + self, + system_prompt: str, + messages: list[dict[str, str]], + output_schema: dict, + ) -> str: + self.calls.append(messages) + return self._responses[len(self.calls) - 1] + + def _resume_payload() -> dict: return { "career_summary": "Web エンジニアとして5年の経験。", @@ -214,6 +239,46 @@ def test_chat_invalid_json_returns_502(client: TestClient, monkeypatch) -> None: assert resp.json()["code"] == "AGENT_PARSE_ERROR" +def test_chat_retries_once_with_error_feedback(client: TestClient, monkeypatch) -> None: + """契約: パース失敗時は違反内容をフィードバックして 1 回だけ再生成する。 + + リトライ用 messages は元の会話+失敗応答(assistant)+違反内容つき + 再生成依頼(user)で構成され、リトライが成功すれば 200 で返る。 + """ + fake = _SequentialFakeLLM(["JSON ではない応答", _llm_json("self_pr", "再生成の提案")]) + monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake) + headers = auth_header(client, "agentuser") + resp = client.post( + "/api/agent/chat", + json={"scope": "self_pr", "prompt": "改善して", "resume": _resume_payload()}, + headers=headers, + ) + assert resp.status_code == 200 + assert resp.json()["operations"][0]["value"] == "再生成の提案" + assert len(fake.calls) == 2 + retry_messages = fake.calls[1] + # 元の会話(1 件)+失敗応答+再生成依頼 + assert len(retry_messages) == len(fake.calls[0]) + 2 + assert retry_messages[-2] == {"role": "assistant", "content": "JSON ではない応答"} + assert retry_messages[-1]["role"] == "user" + assert "違反内容" in retry_messages[-1]["content"] + + +def test_chat_retry_failure_returns_502(client: TestClient, monkeypatch) -> None: + """契約: リトライ(1 回のみ)も失敗したら 502 + AGENT_PARSE_ERROR。3 回目は呼ばない。""" + fake = _SequentialFakeLLM(["不正応答 1 回目", "不正応答 2 回目"]) + monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake) + headers = auth_header(client, "agentuser") + resp = client.post( + "/api/agent/chat", + json={"scope": "self_pr", "prompt": "改善して", "resume": _resume_payload()}, + headers=headers, + ) + assert resp.status_code == 502 + assert resp.json()["code"] == "AGENT_PARSE_ERROR" + assert len(fake.calls) == 2 + + def test_chat_normalizes_out_of_scope_operations(client: TestClient, monkeypatch) -> None: """契約: スコープ外フィールドの operation はスコープの既定 field に正規化される。""" response = json.dumps( @@ -532,3 +597,59 @@ def test_run_agent_chat_target_not_found(monkeypatch) -> None: finally: loop.close() called.generate.assert_not_called() + + +# --- ユニットテスト(output_schema: スコープ → tool 定義) --- + + +@pytest.mark.parametrize("scope", ["career_summary", "self_pr", "project"]) +def test_build_output_schema_operations_branches(scope: str) -> None: + """スコープの許可 field・文字数上限が operations の oneOf 分岐に反映される。""" + schema = build_output_schema(scope) + branches = schema["properties"]["operations"]["items"]["oneOf"] + actual = { + branch["properties"]["field"]["const"]: branch["properties"]["value"]["maxLength"] + for branch in branches + } + assert actual == SCOPE_FIELDS[scope] + for branch in branches: + assert branch["required"] == ["field", "value"] + assert branch["additionalProperties"] is False + + +def test_build_output_schema_top_level_contract() -> None: + """応答の必須キー・追加キー禁止・suggestions の件数/文字数上限がスキーマに入る。""" + schema = build_output_schema("self_pr") + assert schema["required"] == ["message", "operations", "suggestions"] + assert schema["additionalProperties"] is False + suggestions = schema["properties"]["suggestions"] + assert suggestions["maxItems"] == MAX_SUGGESTIONS + assert suggestions["items"]["maxLength"] == MAX_SUGGESTION_LENGTH + + +def test_build_tool_definition_wraps_schema() -> None: + """tool 定義は name / description / input_schema を持ち、スキーマをそのまま包む。""" + schema = build_output_schema("project") + tool = build_tool_definition(schema) + assert tool["name"] == TOOL_NAME + assert tool["description"] + assert tool["input_schema"] is schema + + +def test_scope_limits_match_resume_schema() -> None: + """SCOPE_FIELDS の上限が保存契約(schemas/resume.py)の max_length と一致する(drift 防止)。""" + from annotated_types import MaxLen + from app.schemas.resume import Project, ResumeBase + + def max_length(model: type, field: str) -> int: + for meta in model.model_fields[field].metadata: + if isinstance(meta, MaxLen): + return meta.max_length + raise AssertionError(f"{model.__name__}.{field} に max_length が無い") + + assert SCOPE_FIELDS["career_summary"]["career_summary"] == max_length( + ResumeBase, "career_summary" + ) + assert SCOPE_FIELDS["self_pr"]["self_pr"] == max_length(ResumeBase, "self_pr") + assert SCOPE_FIELDS["project"]["description"] == max_length(Project, "description") + assert SCOPE_FIELDS["project"]["role"] == max_length(Project, "role") From c04a53edb647ddb0a4b2e800ebf1c5c2c2bd6ac9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 01:44:38 +0000 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20DevForge=20Agent=20=E3=81=AE?= =?UTF-8?q?=E8=A8=AD=E8=A8=88=E3=83=AB=E3=83=BC=E3=83=AB=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E3=81=97=20CLAUDE.md=20=E3=81=8B=E3=82=89=E5=8F=82?= =?UTF-8?q?=E7=85=A7=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .claude/rules/backend/agent.md 新規作成(ADR-0010 の実装即参照版) - 制約の責務分離(スキーマ vs プロンプト)の判断基準と禁止事項 - SCOPE_FIELDS 正本・上限値・スコープ別許可フィールド一覧 - プロンプト編集ルール(base / scope 固有に何を書くか / 静的化の理由) - 新規スコープ追加の手順(7 ステップ) - 検証の多段構造・エラー契約・DB 非更新原則・history / suggestions 仕様 - テストの書き方(drift 防止テスト・リトライ契約テストの要点) - CLAUDE.md の「このファイルの読み方」に agent 関連ファイル編集時の agent.md 参照義務を追記 https://claude.ai/code/session_01LAD1cYgN7mrMpfExeQhCdK --- .claude/CLAUDE.md | 1 + .claude/rules/backend/agent.md | 153 +++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 .claude/rules/backend/agent.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index a275d30b..900be2ca 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,6 +4,7 @@ - 本ファイルは全体ルールの索引。AI エージェント(Claude Code 含む)が最初に読むべき内容を集約している。 - 領域固有ルール(backend / frontend / infra)は `.claude/rules//*.md` に分割済み。対象パスを編集する際に自動でロードされる。重複は避け、詳細は各 rule ファイルへ寄せる。 +- **DevForge Agent(`backend/app/services/agent/` / `backend/app/prompts/agent_*.md` / `backend/app/schemas/agent.py`)を変更する場合は、作業前に必ず `.claude/rules/backend/agent.md` を読むこと。** 制約の責務分離(スキーマ vs プロンプト)・エラー契約・DB 非更新原則など、意図せず壊しやすい不変条件が集約されている。 ## AI エージェント実行方法 diff --git a/.claude/rules/backend/agent.md b/.claude/rules/backend/agent.md new file mode 100644 index 00000000..13ac72b3 --- /dev/null +++ b/.claude/rules/backend/agent.md @@ -0,0 +1,153 @@ +--- +paths: + - backend/app/services/agent/** + - backend/app/prompts/agent_*.md + - backend/app/schemas/agent.py + - backend/tests/test_agent.py +--- + +# DevForge Agent 設計ルール(ADR-0010) + +詳細な設計判断・経緯は `docs/adr/0010-devforge-agent.md` が正本。 +本ファイルは実装時に即参照できる要点をまとめたもの。 + +## ファイル構成 + +``` +backend/ +├── app/ +│ ├── prompts/ +│ │ ├── agent_base.md # 共通ルール(全スコープに適用) +│ │ ├── agent_career_summary.md +│ │ ├── agent_self_pr.md +│ │ └── agent_project.md +│ ├── schemas/ +│ │ └── agent.py # リクエスト/レスポンス Pydantic スキーマ +│ └── services/agent/ +│ ├── chat_service.py # コンテキスト組み立て → LLM → 検証 +│ ├── output_schema.py # tool use スキーマ(機械制約の正本) +│ └── llm/ +│ ├── base.py # LLMClient 抽象・LLMError +│ ├── anthropic_client.py +│ ├── ollama_client.py +│ └── factory.py +└── tests/ + └── test_agent.py +``` + +## 制約の責務分離(最重要) + +制約を追加・修正する前に、置き場所を必ず以下の基準で判断する。 + +| 制約の種類 | 例 | 置き場所 | +|---|---|---| +| 機械検証可能 | 許可フィールド名、文字数上限、JSON 構造、配列件数上限 | `output_schema.py`(スキーマ) + `_parse_response`(二重防衛) | +| 機械検証不能 | 文体、構成、PAR 形式、捏造禁止、思考ステップ、曖昧入力への応答方針 | `agent_base.md` / `agent_{scope}.md`(プロンプト) | + +**判断基準: コードでテストが書ける制約はプロンプトに書かない。** + +### やってはいけないこと + +- `agent_*.md` に「JSON のみ出力」「許可フィールドは〇〇」「文字数は〇〇字以内」を書く + → スキーマで保証済みのため書くと二重管理になる +- `output_schema.py` の `SCOPE_FIELDS` と異なる上限を `agent.py` の Pydantic フィールドに設定する + → `test_scope_limits_match_resume_schema` が drift を検出するが、そもそも揃えること +- プロンプトへの制約追記で問題を解決しようとする + → 機械検証可能なものはスキーマへ、精度問題はモデル昇格(Haiku → Sonnet)を先に検討する + +## スコープと許可フィールド(正本: `output_schema.py`) + +| スコープ | 許可フィールド | 文字数上限 | +|---|---|---| +| `career_summary` | `career_summary` | 2000 | +| `self_pr` | `self_pr` | 2000 | +| `project` | `description` | 4500 | +| `project` | `role` | 200 | + +上限値を変更する場合は `output_schema.py` の `SCOPE_FIELDS` を編集する。 +`schemas/resume.py` の max_length と一致させること(`test_scope_limits_match_resume_schema` で検証済み)。 + +## プロンプト編集ルール + +### `agent_base.md`(共通ルール)に書くもの + +- 全スコープに共通する品質ルール(捏造禁止・プレースホルダ禁止) +- 曖昧入力への応答方針(operations 空 + suggestions 生成) +- ユーザー指示の優先順位 +- スキーマ制約との関係を説明する一文(「出力の構造・許可フィールド・文字数上限はスキーマで定義されているため、ここでは内容の品質に集中すること」) + +### `agent_{scope}.md`(スコープ固有)に書くもの + +- 推奨文字数(「品質基準としての目安」であり上限とは別物であることを明示) +- 構成・文体の品質基準(PAR 形式 / 見出し構成 / Markdown ルール) +- 思考ステップ +- few-shot の出力例(内容は流用しない旨を必ず注記する) + +### プロンプトは静的に保つ + +プレースホルダ(`{allowed_fields}` 等)を使って動的に埋め込まない。 +スコープ固有の制約はスキーマで持つ設計なので、プロンプトはモジュールロード時に完全静的にしてプロバイダのキャッシュを効かせる。 + +## 新規スコープを追加する手順 + +1. `output_schema.py` の `SCOPE_FIELDS` にスコープ名とフィールド/上限を追加 +2. `agent_{scope}.md` を `backend/app/prompts/` に作成(品質基準・思考ステップ・few-shot) +3. `chat_service._SCOPE_DEFAULT_FIELD` に正規化先を追加 +4. `chat_service._build_context` に該当スコープの分岐を追加 +5. `schemas/agent.py` の `AgentScope` Literal に追加 +6. `test_agent.py` に `test_chat_system_prompt_is_scope_specific` のパラメータを追加 +7. `test_scope_limits_match_resume_schema` に上限一致の assert を追加 + +## 検証の多段構造(変えてはいけない順序) + +``` +1. tool use スキーマ(API レベルで構造・必須キーをモデルに提示) +2. JSON パース + AgentChatResponse の Pydantic 検証 → 失敗: AGENT_PARSE_ERROR(リトライ 1 回) +3. _parse_response: 許可外フィールドをスコープ既定フィールドへ正規化 +4. _parse_response: 文字数超過の operation を破棄(切り詰めない。ADR-0010 参照) +5. _parse_response: operations がある応答の suggestions を破棄(同時提示しない契約) +``` + +超過 operation を切り詰めて返すことは**禁止**(途中で切れた経歴書は品質として不可)。 + +## エラー契約(変えてはいけない) + +| 事象 | HTTP | ErrorCode | +|---|---|---| +| project スコープで target 未指定 | 422 | `VALIDATION_ERROR` | +| target インデックスが範囲外 | 422 | `VALIDATION_ERROR` | +| LLM 呼び出し失敗 | 502 | `AGENT_LLM_ERROR` | +| LLM 応答のパース / スキーマ違反(リトライ後も失敗) | 502 | `AGENT_PARSE_ERROR` | +| レート制限超過 | 429 | — | + +リトライは **1 回のみ**。違反内容をフィードバックして再生成させる。3 回目は呼ばない。 + +## DB を更新しない原則 + +`POST /agent/chat` のレスポンス(operations)はフロントの state にのみ適用する。 +ユーザーが「適用」した時点で既存の保存 API(`PUT /resumes` 系)を呼ぶ設計。 +Agent エンドポイント自体は DB を書き換えない。この原則を崩してはいけない。 + +## 会話履歴(history)の仕様 + +- フロントのみで保持(DB 永続化なし。サーバーはセッションを持たない) +- 上限: 3 往復(6 エントリ)。`AgentChatRequest.history max_length=6` と `useAgentChat.ts HISTORY_LIMIT` を同期させる +- assistant エントリは `message` だけでなく **operations を含む応答 JSON 原文**を格納する(few-shot として機能する) +- レジュメコンテキストは最新ターンの user prompt にのみ載せる(履歴側には載せない) + +## suggestions の仕様 + +`AgentChatResponse.suggestions: list[str]` — 曖昧入力で operations を返せないときに LLM が生成する「次の依頼文候補」。 + +- 上限: 4 件・各 200 字(`output_schema.MAX_SUGGESTIONS` / `MAX_SUGGESTION_LENGTH`) +- operations がある応答で suggestions が返ってきた場合は破棄する(`_parse_response` が担う) +- 空文字・超過候補も破棄する +- フロントはボタンとして表示し、押下テキストをそのまま次の `prompt` として再送信する + +## テストの書き方 + +- LLM はモック(`_FakeLLM`)。DB はモックしない(実 SQLite セッション) +- `output_schema` の単体テスト: スコープ → oneOf 分岐、上限、必須キー、tool 定義のラップ +- drift 防止テスト: `SCOPE_FIELDS` と `schemas/resume.py` の max_length 一致を検証(`test_scope_limits_match_resume_schema`) +- リトライ契約のテスト: `_SequentialFakeLLM` で「失敗→成功」「失敗→失敗」の両パスを検証 +- 上限超過の operation が破棄されること、許可外フィールドが正規化されること、suggestions の件数・文字数バリデーションを必ず検証する From 443359ed8dabd945feb09cab9b22e5b05f5e6f0e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 01:56:03 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20SuggestionButtons=20=E3=81=AE=20Reac?= =?UTF-8?q?t=20key=20=E3=82=92=20index=20=E3=83=99=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM が同一文字列の suggestions を複数返したとき key={item} では 重複 key 警告が発生する。key={index} に変更して回避する (suggestions リストは再ソートされないため index で安定) https://claude.ai/code/session_01LAD1cYgN7mrMpfExeQhCdK --- frontend/src/components/forms/AgentChatWidget.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/forms/AgentChatWidget.tsx b/frontend/src/components/forms/AgentChatWidget.tsx index 0005a80f..59bce9dc 100644 --- a/frontend/src/components/forms/AgentChatWidget.tsx +++ b/frontend/src/components/forms/AgentChatWidget.tsx @@ -51,9 +51,9 @@ function SuggestionButtons({ if (items.length === 0) return null; return (
- {items.map((item) => ( + {items.map((item, index) => (