diff --git a/backend/app/prompts/agent_chat_system.md b/backend/app/prompts/agent_chat_system.md new file mode 100644 index 00000000..bb890fcd --- /dev/null +++ b/backend/app/prompts/agent_chat_system.md @@ -0,0 +1,13 @@ +あなたは日本語の職務経歴書の改善を支援するアシスタントです。 +ユーザーの依頼に基づき、編集対象フィールドの改善案を JSON で返してください。 + +# 出力形式(JSON のみ。前置き・コードフェンス・補足テキストは一切禁止) +{"message": "<提案の説明(日本語)>", "operations": [{"field": "<フィールド名>", "value": "<新しい本文>"}]} + +# ルール +- operations の field は次のみ許可: {allowed_fields} +- 各フィールドの文字数上限: {field_limits} +- 提案が不要・不可能な場合は operations を空配列にし、message で理由を説明する +- value は職務経歴書にそのまま掲載できる完成した日本語の文章にする +- 「# 現在の内容」に書かれていない資格・技術・経歴・数値・日付を新たに作らない(事実の捏造禁止) +- 情報が不足していて改善できない場合は捏造せず、operations を空配列にして message で必要な情報をユーザーに確認する diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py index 9ce3b4be..fbbdb9c8 100644 --- a/backend/app/schemas/agent.py +++ b/backend/app/schemas/agent.py @@ -18,9 +18,6 @@ AgentScope = Literal["project", "career_summary", "self_pr"] -# operation が編集できるフィールド(Phase 1 はテキストのみ) -AgentField = Literal["career_summary", "self_pr", "description", "role"] - class AgentTechnologyStack(BaseModel): """LLM コンテキスト用の技術スタック(保存契約より緩い)。""" @@ -74,6 +71,19 @@ class ProjectTarget(BaseModel): project_index: int = Field(ge=0) +class AgentHistoryEntry(BaseModel): + """マルチターン用の会話履歴 1 件。 + + user はユーザーの依頼文のみ(レジュメコンテキストは含めない。コンテキストは + 最新ターンの prompt にのみ載せ、毎ターンの重複でトークンが膨れるのを防ぐ)。 + assistant は前回 LLM が返した JSON 文字列をそのまま入れる(出力形式の実例として + few-shot 的に働き、小型モデルのフォーマット逸脱を抑える狙い)。 + """ + + role: Literal["user", "assistant"] + text: str = Field(min_length=1, max_length=6000) + + class AgentChatRequest(BaseModel): """Agent チャットのリクエスト。スコープ選択は必須。""" @@ -81,6 +91,8 @@ class AgentChatRequest(BaseModel): prompt: str = Field(min_length=1, max_length=2000) resume: AgentResumeContext target: ProjectTarget | None = None + # 直近 3 往復(6 エントリ)まで。サーバーはセッションを持たずフロントが送る + history: list[AgentHistoryEntry] = Field(default_factory=list, max_length=6) @model_validator(mode="after") def validate_target(self) -> "AgentChatRequest": @@ -95,9 +107,13 @@ class AgentOperation(BaseModel): フロントは選択済みスコープ(と target)に対応するフィールドへ value を反映する。 DB は更新せず、ユーザーが「適用」した時点で既存の保存 API を呼ぶ。 + + ``field`` は意図的に Literal ではなく str で受ける。小型 LLM が許可外の + field 名を返すことがあり、Literal だと operation 1 件の逸脱でレスポンス全体が + ValidationError になる。許可 field の検証・破棄は chat_service._parse_response が担う。 """ - field: AgentField + field: str = Field(max_length=120) value: str = Field(max_length=4500) diff --git a/backend/app/services/agent/chat_service.py b/backend/app/services/agent/chat_service.py index 00cee490..74b290f5 100644 --- a/backend/app/services/agent/chat_service.py +++ b/backend/app/services/agent/chat_service.py @@ -6,6 +6,7 @@ import json import logging +from pathlib import Path from pydantic import ValidationError @@ -35,28 +36,33 @@ class AgentResponseParseError(Exception): "project": {"description": 4500, "role": 200}, } -_SYSTEM_PROMPT = """\ -あなたは日本語の職務経歴書の改善を支援するアシスタントです。 -ユーザーの依頼に基づき、編集対象フィールドの改善案を JSON で返してください。 - -# 出力形式(JSON のみ。前置き・コードフェンス・補足テキストは一切禁止) -{{"message": "<提案の説明(日本語)>", "operations": [{{"field": "<フィールド名>", "value": "<新しい本文>"}}]}} +# 許可外の field 名を返された時の正規化先。スコープ選択で編集対象は確定しているため、 +# 小型 LLM が「自己PR」等の field 名を返しても既定 field の提案として救済する。 +# project は role / description の 2 候補だが、自由記述の実体は description のみ +# (role は 1 行の肩書き入力)なので description に倒す +_SCOPE_DEFAULT_FIELD: dict[str, str] = { + "career_summary": "career_summary", + "self_pr": "self_pr", + "project": "description", +} -# ルール -- operations の field は次のみ許可: {allowed_fields} -- 各フィールドの文字数上限: {field_limits} -- 提案が不要・不可能な場合は operations を空配列にし、message で理由を説明する -- value は職務経歴書にそのまま掲載できる完成した日本語の文章にする -""" +# システムプロンプトの正本は 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") def _build_context(request: AgentChatRequest) -> str: """スコープに応じて LLM に渡すコンテキスト文字列を組み立てる。""" resume = request.resume + # 編集対象フィールドのキーは operations の正規 field 名(career_summary 等)に揃える。 + # 小型 LLM はコンテキストのキー名を operations.field に流用しやすいため、 + # 日本語キーにすると許可外 field として破棄される(パース失敗の主因だった) if request.scope == "career_summary": return json.dumps( { - "現在の職務要約": resume.career_summary, + "career_summary": resume.career_summary, "在籍企業の概要": [ {"会社": e.company, "事業内容": e.business_description} for e in resume.experiences @@ -67,8 +73,8 @@ def _build_context(request: AgentChatRequest) -> str: if request.scope == "self_pr": return json.dumps( { - "現在の自己PR": resume.self_pr, - "職務要約": resume.career_summary, + "self_pr": resume.self_pr, + "職務要約(参考情報)": resume.career_summary, }, ensure_ascii=False, ) @@ -76,8 +82,8 @@ def _build_context(request: AgentChatRequest) -> str: return json.dumps( { "プロジェクト名": project.name, - "現在の役割": project.role, - "現在の詳細": project.description, + "role": project.role, + "description": project.description, "技術スタック": [s.name for s in project.technology_stacks if s.name], "担当工程": project.phases, }, @@ -103,7 +109,7 @@ def _resolve_target_project(request: AgentChatRequest) -> AgentProjectContext: def _parse_response(raw: str, scope: str) -> AgentChatResponse: - """LLM 応答をパースし、スコープ外・上限超過の operation を破棄して返す。""" + """LLM 応答をパースし、field の正規化と上限超過 operation の破棄を行って返す。""" text = raw.strip() # JSON のみを指示しても小型モデルはコードフェンスを付けることがあるため除去する if text.startswith("```"): @@ -120,9 +126,13 @@ def _parse_response(raw: str, scope: str) -> AgentChatResponse: operations: list[AgentOperation] = [] for op in parsed.operations: if op.field not in allowed: - # スコープ外フィールドの提案は適用先が特定できないため破棄する - logger.warning("スコープ外の operation を破棄: scope=%s field=%s", scope, op.field) - continue + # 許可外の field 名はスコープの既定 field の提案として正規化する + # (スコープ選択で編集対象は確定しており、提案を捨てるよりユーザー利益が大きい) + normalized = _SCOPE_DEFAULT_FIELD[scope] + logger.warning( + "許可外の field を正規化: scope=%s field=%s -> %s", scope, op.field, normalized + ) + op = AgentOperation(field=normalized, value=op.value) if len(op.value) > allowed[op.field]: logger.warning( "文字数上限超過の operation を破棄: field=%s len=%d", op.field, len(op.value) @@ -141,15 +151,31 @@ async def run_agent_chat(request: AgentChatRequest) -> AgentChatResponse: LLMError: LLM 呼び出しの失敗(llm.base 参照)。 """ allowed = _SCOPE_FIELDS[request.scope] - system_prompt = _SYSTEM_PROMPT.format( - allowed_fields=", ".join(allowed), - field_limits=", ".join(f"{k}: {v}文字" for k, v in allowed.items()), + system_prompt = _SYSTEM_PROMPT.replace( + "{allowed_fields}", ", ".join(allowed) + ).replace( + "{field_limits}", ", ".join(f"{k}: {v}文字" for k, v in allowed.items()) ) user_prompt = ( f"# 編集対象スコープ\n{request.scope}\n\n" f"# 現在の内容\n{_build_context(request)}\n\n" f"# ユーザーの依頼\n{request.prompt}" ) + # 調査用ログはメタデータのみ出す。レジュメ本文・プロンプト本文は個人情報を含むため + # DEBUG でもログに載せない(.claude/rules/security.md「ログへの秘密情報出力禁止」) + logger.debug( + "Agent LLM 入力: scope=%s target=%s history=%d resume_len=%d user_prompt_len=%d", + request.scope, + request.target, + len(request.history), + len(request.resume.model_dump_json()), + len(user_prompt), + ) + # 履歴(直近 3 往復)の後ろに今回の user prompt を置く。レジュメコンテキストは + # 最新ターンにのみ載せる(履歴側はフロントが依頼文 / 前回応答 JSON だけを送る契約) + 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, user_prompt) + raw = await client.generate(system_prompt, messages) + 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 6e876ccf..62b77506 100644 --- a/backend/app/services/agent/llm/anthropic_client.py +++ b/backend/app/services/agent/llm/anthropic_client.py @@ -15,6 +15,8 @@ # operations JSON(最大 4500 文字のテキスト置換 + 説明文)に十分な上限 _MAX_TOKENS = 4096 _TIMEOUT_SECONDS = 60.0 +# 職務経歴書の改善提案は事実忠実性が最優先のため低温度に固定する +_TEMPERATURE = 0.2 class AnthropicClient(LLMClient): @@ -28,13 +30,14 @@ def __init__(self) -> None: api_key=api_key, timeout=_TIMEOUT_SECONDS ) - async def generate(self, system_prompt: str, user_prompt: str) -> str: + async def generate(self, system_prompt: str, messages: list[dict[str, str]]) -> str: try: response = await self._client.messages.create( model=_MODEL, max_tokens=_MAX_TOKENS, + temperature=_TEMPERATURE, system=system_prompt, - messages=[{"role": "user", "content": user_prompt}], + messages=messages, ) except ( anthropic.APITimeoutError, diff --git a/backend/app/services/agent/llm/base.py b/backend/app/services/agent/llm/base.py index 10e61c22..e1f77299 100644 --- a/backend/app/services/agent/llm/base.py +++ b/backend/app/services/agent/llm/base.py @@ -19,8 +19,11 @@ class LLMClient(ABC): """ @abstractmethod - async def generate(self, system_prompt: str, user_prompt: str) -> str: - """system / user プロンプトを渡して応答テキストを返す。 + async def generate(self, system_prompt: str, messages: list[dict[str, str]]) -> str: + """system プロンプトと会話 messages を渡して応答テキストを返す。 + + messages は ``[{"role": "user" | "assistant", "content": str}, ...]`` で、 + 末尾が今回の user プロンプト(マルチターン時は先頭側に履歴が並ぶ)。 Raises: LLMError: タイムアウト・接続失敗・API エラー時。 diff --git a/backend/app/services/agent/llm/ollama_client.py b/backend/app/services/agent/llm/ollama_client.py index 75b19016..3b0a6fe6 100644 --- a/backend/app/services/agent/llm/ollama_client.py +++ b/backend/app/services/agent/llm/ollama_client.py @@ -24,15 +24,15 @@ 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, user_prompt: str) -> str: + async def generate(self, system_prompt: str, messages: list[dict[str, str]]) -> str: payload = { "model": self._model, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], + "messages": [{"role": "system", "content": system_prompt}, *messages], "stream": False, "format": "json", + # 職務経歴書の改善提案は事実忠実性が最優先のため低温度に固定する + # (デフォルト 0.8 では小型モデルが架空の資格・技術を捏造しやすい) + "options": {"temperature": 0.2}, } try: async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client: @@ -45,10 +45,15 @@ async def generate(self, system_prompt: str, user_prompt: str) -> str: raise LLMError(f"Ollama API error: {type(exc).__name__}") from exc try: - text = response.json().get("message", {}).get("content", "") + data = response.json() except json.JSONDecodeError as exc: logger.warning("Ollama 応答の JSON パースに失敗: %s", type(exc).__name__) raise LLMError("Ollama 応答が JSON ではありません") from exc + # dict 以外(配列・文字列等)が返ると .get で AttributeError になるため LLMError(502)に倒す + if not isinstance(data, dict): + logger.warning("Ollama 応答が想定外の型: %s", type(data).__name__) + raise LLMError("Ollama 応答が想定外の形式です") + text = data.get("message", {}).get("content", "") if not text: raise LLMError("Ollama から空の応答が返されました") return text diff --git a/backend/tests/test_agent.py b/backend/tests/test_agent.py index aa6e7988..2ac2f3cd 100644 --- a/backend/tests/test_agent.py +++ b/backend/tests/test_agent.py @@ -22,13 +22,17 @@ class _FakeLLM(LLMClient): - """テスト用の LLM クライアント(固定応答 or 例外)。""" + """テスト用の LLM クライアント(固定応答 or 例外)。受信した入力を記録する。""" def __init__(self, response: str | None = None, error: Exception | None = None): self._response = response 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, user_prompt: str) -> str: + async def generate(self, system_prompt: str, messages: list[dict[str, str]]) -> str: + self.received_system_prompt = system_prompt + self.received_messages = messages if self._error: raise self._error assert self._response is not None @@ -203,8 +207,8 @@ def test_chat_invalid_json_returns_502(client: TestClient, monkeypatch) -> None: assert resp.json()["code"] == "AGENT_PARSE_ERROR" -def test_chat_discards_out_of_scope_operations(client: TestClient, monkeypatch) -> None: - """契約: スコープ外フィールドの operation は破棄され、message は返る。""" +def test_chat_normalizes_out_of_scope_operations(client: TestClient, monkeypatch) -> None: + """契約: スコープ外フィールドの operation はスコープの既定 field に正規化される。""" response = json.dumps( { "message": "提案です。", @@ -228,8 +232,72 @@ def test_chat_discards_out_of_scope_operations(client: TestClient, monkeypatch) ) assert resp.status_code == 200 ops = resp.json()["operations"] - assert len(ops) == 1 - assert ops[0]["field"] == "career_summary" + assert [op["field"] for op in ops] == ["career_summary", "career_summary"] + assert [op["value"] for op in ops] == ["スコープ外の提案", "スコープ内の提案"] + + +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", "提案")) + headers = auth_header(client, "agentuser") + history = [ + {"role": "user", "text": "自己PRを改善して"}, + {"role": "assistant", "text": '{"message": "改善しました", "operations": []}'}, + ] + resp = client.post( + "/api/agent/chat", + json={ + "scope": "self_pr", + "prompt": "もっと短くして", + "resume": _resume_payload(), + "history": history, + }, + headers=headers, + ) + assert resp.status_code == 200 + assert fake.received_messages is not None + assert [m["role"] for m in fake.received_messages] == ["user", "assistant", "user"] + assert fake.received_messages[0]["content"] == "自己PRを改善して" + assert fake.received_messages[1]["content"] == history[1]["text"] + # 末尾の今回ターンにのみレジュメコンテキストが載る + assert "もっと短くして" in fake.received_messages[2]["content"] + assert "self_pr" in fake.received_messages[2]["content"] + + +def test_chat_without_history_sends_single_message(client: TestClient, monkeypatch) -> None: + """history 省略時は従来どおり user メッセージ 1 件のみが 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", + "prompt": "改善して", + "resume": _resume_payload(), + }, + headers=headers, + ) + assert resp.status_code == 200 + assert fake.received_messages is not None + assert [m["role"] for m in fake.received_messages] == ["user"] + + +def test_chat_rejects_history_over_limit(client: TestClient, monkeypatch) -> None: + """history は 6 エントリ(3 往復)まで。超過は 422。""" + _mock_llm(monkeypatch, response=_llm_json("self_pr", "提案")) + headers = auth_header(client, "agentuser") + history = [{"role": "user", "text": f"依頼{i}"} for i in range(7)] + resp = client.post( + "/api/agent/chat", + json={ + "scope": "self_pr", + "prompt": "改善して", + "resume": _resume_payload(), + "history": history, + }, + headers=headers, + ) + assert resp.status_code == 422 # --- ユニットテスト(service 層) --- @@ -252,6 +320,44 @@ def test_parse_response_discards_over_limit_value() -> None: assert result.operations == [] +def test_parse_response_normalizes_unknown_field_name() -> None: + """許可リスト外の field 名(コンテキストの日本語キー流用など)は既定 field に正規化される。 + + field は Literal ではなく str で受けるため、逸脱 operation が混ざっても + レスポンス全体は ValidationError にならない(502 多発の再発防止)。 + """ + raw = json.dumps( + { + "message": "改善案です。", + "operations": [ + {"field": "現在の自己PR", "value": "逸脱した提案"}, + {"field": "self_pr", "value": "正しい提案"}, + ], + }, + ensure_ascii=False, + ) + result = _parse_response(raw, "self_pr") + assert result.message == "改善案です。" + assert [op.field for op in result.operations] == ["self_pr", "self_pr"] + assert [op.value for op in result.operations] == ["逸脱した提案", "正しい提案"] + + +def test_parse_response_project_normalizes_to_description() -> None: + """project スコープの許可外 field は description に正規化される(role は明示時のみ)。""" + raw = json.dumps( + { + "message": "改善案です。", + "operations": [ + {"field": "プロジェクト詳細", "value": "詳細の提案"}, + {"field": "role", "value": "役割の提案"}, + ], + }, + ensure_ascii=False, + ) + result = _parse_response(raw, "project") + assert [op.field for op in result.operations] == ["description", "role"] + + def test_parse_response_invalid_schema_raises() -> None: """operations の形式不正は AgentResponseParseError。""" with pytest.raises(AgentResponseParseError): diff --git a/docker-compose.yml b/docker-compose.yml index ed94681a..db591650 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,10 +35,10 @@ services: CALLBACK_BASE_URL: ${CALLBACK_BASE_URL} TASK_MAX_ATTEMPTS: ${TASK_MAX_ATTEMPTS} # DevForge Agent(ADR-0010)。ローカルは Ollama を既定とする - LLM_PROVIDER: ${LLM_PROVIDER:-ollama} - ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} - OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} - OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3.2} + LLM_PROVIDER: ${LLM_PROVIDER} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL} + OLLAMA_MODEL: ${OLLAMA_MODEL} LOG_LEVEL: ${LOG_LEVEL} LOG_FORMAT: ${LOG_FORMAT} APP_VERSION: ${APP_VERSION} diff --git a/frontend/e2e/agent-chat.spec.ts b/frontend/e2e/agent-chat.spec.ts new file mode 100644 index 00000000..9d8ce795 --- /dev/null +++ b/frontend/e2e/agent-chat.spec.ts @@ -0,0 +1,120 @@ +import { test, expect, type Page } from "@playwright/test"; +import { setupAuth, waitForAuthenticatedLayout } from "./helpers/auth"; + +/** + * Agent チャットウィジェット(ADR-0010)E2E。 + * + * シナリオ: + * 1. 職務経歴書を開く → 右下に「devforge Agent」ボタン + * 2. チャットを開き、職務要約スコープでプロンプト送信(POST /api/agent/chat をモック) + * 3. AI 応答と提案テキストが表示される + * 4. 「フォームに反映」→ フォームの職務要約が提案値に置き換わる(DB 更新なし = PUT は飛ばない) + */ + +const baseResume = { + id: "resume-1", + full_name: "山田 太郎", + email: "yamada@example.com", + github_url: "", + career_summary: "現在のサマリー", + self_pr: "自己PR", + experiences: [], + qualifications: [], +}; + +async function setupResumeApi(page: Page) { + await page.route("**/api/master-data/qualification", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ); + await page.route("**/api/master-data/technology-stack", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ); + await page.route("**/api/resumes/latest", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(baseResume), + }), + ); +} + +test.describe("Agent チャットウィジェット", () => { + test.beforeEach(async ({ page }) => { + await setupAuth(page); + await setupResumeApi(page); + }); + + test("職務要約の改善提案を受け取りフォームに反映できる", async ({ page }) => { + // Agent チャット API モック。送られた scope / resume を検証できるよう記録する + let chatRequestBody: Record | null = null; + await page.route("**/api/agent/chat", async (route) => { + chatRequestBody = JSON.parse(route.request().postData() ?? "{}"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + message: "より具体的な職務要約を提案します。", + operations: [{ field: "career_summary", value: "改善された職務要約です。" }], + }), + }); + }); + + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + + // ウィジェットを開く + await page.getByRole("button", { name: "devforge Agent" }).click(); + + // 職務要約スコープ(デフォルト)でプロンプト送信 + await page + .getByPlaceholder("例: 成果がより伝わる文章にしてください") + .fill("もっと具体的にして"); + await page.getByRole("button", { name: "送信", exact: true }).click(); + + // AI 応答と提案テキストの表示 + await expect(page.getByText("より具体的な職務要約を提案します。")).toBeVisible(); + await expect(page.getByText("改善された職務要約です。")).toBeVisible(); + + // リクエスト内容: スコープと編集中フォームのコンテキストが送られている + expect(chatRequestBody).toMatchObject({ + scope: "career_summary", + prompt: "もっと具体的にして", + resume: { career_summary: "現在のサマリー" }, + }); + + // フォームに反映 → 職務要約フィールドが提案値になる(state のみ。保存はしない)。 + // フォーム側のトリガーは先頭 N 文字 + … の省略プレビュー表示のため部分一致で検証する + await page.getByRole("button", { name: "フォームに反映" }).click(); + await expect(page.getByRole("button", { name: "職務要約を編集" })).toContainText( + "改善された職務要約で", + ); + // 反映済みになると反映ボタンは消える + await expect(page.getByRole("button", { name: "フォームに反映" })).toHaveCount(0); + }); + + test("LLM 失敗(502)はエラートーストで通知される", async ({ page }) => { + await page.route("**/api/agent/chat", (route) => + route.fulfill({ + status: 502, + contentType: "application/json", + body: JSON.stringify({ + code: "AGENT_LLM_ERROR", + message: "AI の応答取得に失敗しました。しばらくしてからもう一度お試しください。", + }), + }), + ); + + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + + await page.getByRole("button", { name: "devforge Agent" }).click(); + await page + .getByPlaceholder("例: 成果がより伝わる文章にしてください") + .fill("改善して"); + await page.getByRole("button", { name: "送信", exact: true }).click(); + + await expect( + page.getByText("AI の応答取得に失敗しました。しばらくしてからもう一度お試しください。"), + ).toBeVisible(); + }); +}); diff --git a/frontend/e2e/navigation.spec.ts b/frontend/e2e/navigation.spec.ts index 9630091c..bb831f1a 100644 --- a/frontend/e2e/navigation.spec.ts +++ b/frontend/e2e/navigation.spec.ts @@ -14,7 +14,8 @@ test.describe("認証済みユーザーのナビゲーション", () => { await page.goto("/career"); await waitForAuthenticatedLayout(page); - await expect(page.getByText("DevForge")).toBeVisible(); + // Agent FAB(aria-label: devforge Agent)と二重マッチしないよう exact 指定 + await expect(page.getByText("DevForge", { exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: "職務経歴書" })).toBeVisible(); // GitHub連携 は画面遷移に徹するためリンク(連携実行はサブパネルのボタン) await expect( diff --git a/frontend/src/api/agent.ts b/frontend/src/api/agent.ts new file mode 100644 index 00000000..fa467478 --- /dev/null +++ b/frontend/src/api/agent.ts @@ -0,0 +1,14 @@ +import { request } from "./client"; +import { PATHS } from "./paths"; +import type { AgentChatRequest, AgentChatResponse } from "./types"; + +/** + * Agent チャット(ADR-0010)。選択スコープの内容とプロンプトを送り、 + * 職務経歴書への差分 operations を受け取る。DB は更新されない。 + */ +export function postAgentChat(payload: AgentChatRequest): Promise { + return request(PATHS.agent.chat, { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index 112e8d3c..5e6a0020 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -722,6 +722,8 @@ export interface components { * @description Agent チャットのリクエスト。スコープ選択は必須。 */ AgentChatRequest: { + /** History */ + history?: components["schemas"]["AgentHistoryEntry"][]; /** Prompt */ prompt: string; resume: components["schemas"]["AgentResumeContext"]; @@ -773,19 +775,38 @@ export interface components { */ company: string; }; + /** + * AgentHistoryEntry + * @description マルチターン用の会話履歴 1 件。 + * + * user はユーザーの依頼文のみ(レジュメコンテキストは含めない。コンテキストは + * 最新ターンの prompt にのみ載せ、毎ターンの重複でトークンが膨れるのを防ぐ)。 + * assistant は前回 LLM が返した JSON 文字列をそのまま入れる(出力形式の実例として + * few-shot 的に働き、小型モデルのフォーマット逸脱を抑える狙い)。 + */ + AgentHistoryEntry: { + /** + * Role + * @enum {string} + */ + role: "user" | "assistant"; + /** Text */ + text: string; + }; /** * AgentOperation * @description resume state へ適用する差分(テキストフィールドの置換)。 * * フロントは選択済みスコープ(と target)に対応するフィールドへ value を反映する。 * DB は更新せず、ユーザーが「適用」した時点で既存の保存 API を呼ぶ。 + * + * ``field`` は意図的に Literal ではなく str で受ける。小型 LLM が許可外の + * field 名を返すことがあり、Literal だと operation 1 件の逸脱でレスポンス全体が + * ValidationError になる。許可 field の検証・破棄は chat_service._parse_response が担う。 */ AgentOperation: { - /** - * Field - * @enum {string} - */ - field: "career_summary" | "self_pr" | "description" | "role"; + /** Field */ + field: string; /** Value */ value: string; }; diff --git a/frontend/src/api/paths.ts b/frontend/src/api/paths.ts index bba168a9..3f3bff41 100644 --- a/frontend/src/api/paths.ts +++ b/frontend/src/api/paths.ts @@ -27,6 +27,9 @@ export const PATHS = { githubLoginUrl: "/auth/github/login-url", logout: "/auth/logout", }, + agent: { + chat: "/api/agent/chat", + }, resumes: { base: "/api/resumes", latest: "/api/resumes/latest", diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 4adb6d0e..f3bf24d6 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -118,3 +118,23 @@ export type UnreadCountResponse = Schemas["UnreadCountResponse"]; /** 全件既読レスポンス。backend `routers/notifications.py:MarkAllReadResponse`。 */ export type MarkAllReadResponse = Schemas["MarkAllReadResponse"]; + +// ── Agent(agent.py / ADR-0010)────────────────────────────────────────── + +/** Agent チャットのリクエスト。backend `schemas/agent.py:AgentChatRequest`。 */ +export type AgentChatRequest = Schemas["AgentChatRequest"]; + +/** Agent チャットのレスポンス。backend `schemas/agent.py:AgentChatResponse`。 */ +export type AgentChatResponse = Schemas["AgentChatResponse"]; + +/** resume state へ適用する差分。backend `schemas/agent.py:AgentOperation`。 */ +export type AgentOperation = Schemas["AgentOperation"]; + +/** マルチターン用の会話履歴 1 件。backend `schemas/agent.py:AgentHistoryEntry`。 */ +export type AgentHistoryEntry = Schemas["AgentHistoryEntry"]; + +/** Agent に渡す編集中の職務経歴書コンテキスト。backend `schemas/agent.py:AgentResumeContext`。 */ +export type AgentResumeContext = Schemas["AgentResumeContext"]; + +/** project スコープの対象指定。backend `schemas/agent.py:ProjectTarget`。 */ +export type ProjectTarget = Schemas["ProjectTarget"]; diff --git a/frontend/src/components/forms/AgentChatWidget.module.css b/frontend/src/components/forms/AgentChatWidget.module.css new file mode 100644 index 00000000..7d8c9271 --- /dev/null +++ b/frontend/src/components/forms/AgentChatWidget.module.css @@ -0,0 +1,233 @@ +/* Agent チャットウィジェット(ADR-0010)。右下フローティング + パネル */ +/* 配色はテーマ変数(styles.css の :root / [data-theme="dark"])に追従する */ + +.fab { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 60; + padding: 10px 16px; + border: none; + border-radius: 24px; + background: #059669; + color: #fff; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +.fab:hover { + background: #047857; +} + +.panel { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 60; + display: flex; + flex-direction: column; + width: min(380px, calc(100vw - 48px)); + height: min(520px, calc(100vh - 96px)); + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-card); + color: var(--text-primary); + box-shadow: var(--shadow-card); +} + +/* 左上のリサイズハンドル。右下固定パネルを左上方向にドラッグで拡張する */ +.resizeHandle { + position: absolute; + top: -4px; + left: -4px; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-top: 2px solid var(--text-hint); + border-left: 2px solid var(--text-hint); + border-top-left-radius: 12px; + background: none; + cursor: nwse-resize; + touch-action: none; +} + +.resizeHandle:hover { + border-color: var(--text-muted); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--border); +} + +.title { + font-weight: 600; + font-size: 0.95rem; + color: var(--text-primary); +} + +.closeButton { + border: none; + background: none; + font-size: 1.2rem; + line-height: 1; + cursor: pointer; + color: var(--text-muted); +} + +.scopeRow { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 14px; + border-bottom: 1px solid var(--border); +} + +.scopeLabel { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.select { + flex: 1; + padding: 4px 8px; + border: 1px solid var(--border-input); + border-radius: 6px; + font-size: 0.85rem; + background: var(--bg-input); + color: var(--text-primary); +} + +.targetEmpty { + margin: 0; + font-size: 0.8rem; + color: var(--warning-text); +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.emptyState { + margin: 0; + font-size: 0.85rem; + color: var(--text-muted); +} + +.userMessage, +.assistantMessage { + max-width: 92%; + padding: 8px 12px; + border-radius: 10px; + font-size: 0.85rem; +} + +.userMessage { + align-self: flex-end; + background: #059669; + color: #fff; +} + +.assistantMessage { + align-self: flex-start; + background: var(--bg-section); + border: 1px solid var(--border); + color: var(--text-primary); +} + +.messageText { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.operations { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.operationPreview { + margin: 0; + padding: 8px; + max-height: 140px; + overflow-y: auto; + border: 1px solid var(--border-input); + border-radius: 6px; + background: var(--bg-input); + color: var(--text-primary); + font-size: 0.78rem; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.applyButton { + align-self: flex-start; + padding: 5px 12px; + border: none; + border-radius: 6px; + background: #059669; + color: #fff; + font-size: 0.8rem; + cursor: pointer; +} + +.applyButton:hover { + background: #047857; +} + +.sendingNote { + margin: 0; + font-size: 0.8rem; + color: var(--text-muted); +} + +.inputRow { + display: flex; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid var(--border); +} + +.promptInput { + flex: 1; + resize: none; + padding: 6px 8px; + border: 1px solid var(--border-input); + border-radius: 6px; + font-size: 0.85rem; + font-family: inherit; + background: var(--bg-input); + color: var(--text-primary); +} + +.sendButton { + align-self: flex-end; + padding: 6px 14px; + border: none; + border-radius: 6px; + background: #059669; + color: #fff; + font-size: 0.85rem; + cursor: pointer; +} + +.sendButton:disabled { + background: var(--text-disabled); + cursor: not-allowed; +} diff --git a/frontend/src/components/forms/AgentChatWidget.tsx b/frontend/src/components/forms/AgentChatWidget.tsx new file mode 100644 index 00000000..089db975 --- /dev/null +++ b/frontend/src/components/forms/AgentChatWidget.tsx @@ -0,0 +1,266 @@ +/** + * Agent チャットウィジェット(ADR-0010)。 + * + * 職務経歴書フォーム右下のフローティングボタンからチャットパネルを開き、 + * スコープ(職務要約 / 自己PR / プロジェクト)を選んで AI に改善を依頼する。 + * AI 応答の operations は「フォームに反映」でフォーム state にのみ適用され、 + * 保存は既存の保存ボタン(保存 API)をユーザーが明示的に実行する。 + */ + +import { useCallback, useMemo, useState } from "react"; + +import type { ProjectTarget } from "../../api/types"; +import { AGENT_MESSAGES } from "../../constants/messages"; +import { useAgentChat, type AgentChatEntry } from "../../hooks/career/useAgentChat"; +import type { CareerFormState } from "../../payloadBuilders"; +import { useMessageToast, useToast } from "../ui/toast"; +import { applyAgentOperations, type AgentScope } from "../../utils/agentOperations"; +import styles from "./AgentChatWidget.module.css"; + +type Props = { + form: CareerFormState; + /** operations 適用用の setForm(CareerResumeForm の setFormAndClearFocus) */ + onApply: (updater: (prev: CareerFormState) => CareerFormState) => void; + /** 未ログイン時はチャットを開かずログイン導線へ流す */ + isAuthenticated: boolean; + requestLogin: () => void; +}; + +/** project スコープで選択できる候補(フォーム state の index で特定する)。 */ +type ProjectOption = { label: string; target: ProjectTarget }; + +/** パネルのリサイズ範囲。右下固定のため左上方向にだけ広がる */ +const PANEL_MIN_WIDTH = 320; +const PANEL_MIN_HEIGHT = 360; +const PANEL_VIEWPORT_MARGIN = 48; + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function buildProjectOptions(form: CareerFormState): ProjectOption[] { + const options: ProjectOption[] = []; + form.experiences.forEach((exp, ei) => { + exp.clients.forEach((client, ci) => { + client.projects.forEach((proj, pi) => { + const name = proj.name || AGENT_MESSAGES.TARGET_UNNAMED; + const company = exp.company || AGENT_MESSAGES.TARGET_UNNAMED; + options.push({ + label: `${company} / ${name}`, + target: { experience_index: ei, client_index: ci, project_index: pi }, + }); + }); + }); + }); + return options; +} + +export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin }: Props) { + const [open, setOpen] = useState(false); + const [scope, setScope] = useState("career_summary"); + const [targetIndex, setTargetIndex] = useState(0); + const [prompt, setPrompt] = useState(""); + /** ドラッグでリサイズされた寸法。null の間は CSS のデフォルトサイズに従う */ + const [panelSize, setPanelSize] = useState<{ width: number; height: number } | null>(null); + const { entries, sending, error, send, markApplied, clearError } = useAgentChat(); + const { showSuccess } = useToast(); + useMessageToast(error, "error"); + + const projectOptions = useMemo(() => buildProjectOptions(form), [form]); + const selectedTarget = projectOptions[targetIndex]?.target ?? null; + const canSend = + !sending && prompt.trim().length > 0 && (scope !== "project" || selectedTarget !== null); + + const handleOpen = () => { + if (!isAuthenticated) { + requestLogin(); + return; + } + setOpen(true); + }; + + const handleSend = () => { + if (!canSend) return; + clearError(); + void send(form, scope, scope === "project" ? selectedTarget : null, prompt.trim()); + setPrompt(""); + }; + + // パネル左上のハンドルをドラッグしてリサイズする。パネルは右下固定なので + // ポインタが左上に動くほど大きくなる(差分を加算) + const handleResizeStart = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + const panel = (e.currentTarget as HTMLElement).closest("section"); + if (!panel) return; + const startX = e.clientX; + const startY = e.clientY; + const startRect = panel.getBoundingClientRect(); + const maxWidth = window.innerWidth - PANEL_VIEWPORT_MARGIN; + const maxHeight = window.innerHeight - PANEL_VIEWPORT_MARGIN * 2; + + const onMove = (ev: PointerEvent) => { + setPanelSize({ + width: clamp(startRect.width + (startX - ev.clientX), PANEL_MIN_WIDTH, maxWidth), + height: clamp(startRect.height + (startY - ev.clientY), PANEL_MIN_HEIGHT, maxHeight), + }); + }; + const onUp = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }, []); + + const handleApply = (entry: AgentChatEntry, index: number) => { + if (!entry.operations) return; + const { scope: entryScope, target, operations } = entry; + onApply((prev) => applyAgentOperations(prev, entryScope, target, operations)); + markApplied(index); + showSuccess(AGENT_MESSAGES.APPLIED_TOAST); + }; + + if (!open) { + return ( + + ); + } + + return ( +
+ + + +
+ + {scope === "project" && + (projectOptions.length === 0 ? ( +

{AGENT_MESSAGES.TARGET_EMPTY}

+ ) : ( + + ))} +
+ +
+ {entries.length === 0 &&

{AGENT_MESSAGES.EMPTY_STATE}

} + {entries.map((entry, i) => ( +
+

{entry.text}

+ {entry.operations && ( +
+ {entry.operations.map((op, j) => ( +
+                    {op.value}
+                  
+ ))} + +
+ )} +
+ ))} + {sending &&

{AGENT_MESSAGES.SENDING}

} +
+ +
+