From a25488cd6e5c64170454f2c0fc40d07ff92e542b Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Thu, 11 Jun 2026 14:10:18 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(agent):=20AI=20=E3=82=A2=E3=82=B7?= =?UTF-8?q?=E3=82=B9=E3=82=BF=E3=83=B3=E3=83=88=20=E3=83=81=E3=83=A3?= =?UTF-8?q?=E3=83=83=E3=83=88=E3=82=A6=E3=82=A3=E3=82=B8=E3=82=A7=E3=83=83?= =?UTF-8?q?=E3=83=88=E3=82=92=E5=AE=9F=E8=A3=85=EF=BC=88ADR-0010=20Phase?= =?UTF-8?q?=201=20FE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 職務経歴書フォーム右下に AI アシスタント(フローティングボタン + チャットパネル)を追加 - スコープ選択(職務要約 / 自己PR / プロジェクト)と project 対象選択 UI - POST /api/agent/chat クライアント(PATHS / generated 型)と useAgentChat フック - operations の「フォームに反映」は state のみ更新(保存は既存の保存ボタン / DB 未更新の原則) - buildAgentResumeContext / applyAgentOperations を utils に追加(個人情報はコンテキストに含めない) - 文言は AGENT_MESSAGES として messages.ts に集約 - ユニットテスト 10 件 + E2E 2 件追加(全 E2E 32 件 pass) Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/agent-chat.spec.ts | 120 +++++++++++ frontend/src/api/agent.ts | 14 ++ frontend/src/api/paths.ts | 3 + frontend/src/api/types.ts | 17 ++ .../forms/AgentChatWidget.module.css | 203 ++++++++++++++++++ .../src/components/forms/AgentChatWidget.tsx | 199 +++++++++++++++++ .../src/components/forms/CareerResumeForm.tsx | 8 + frontend/src/constants/messages.ts | 26 +++ .../src/hooks/career/useAgentChat.test.ts | 96 +++++++++ frontend/src/hooks/career/useAgentChat.ts | 85 ++++++++ frontend/src/utils/agentOperations.test.ts | 113 ++++++++++ frontend/src/utils/agentOperations.ts | 93 ++++++++ 12 files changed, 977 insertions(+) create mode 100644 frontend/e2e/agent-chat.spec.ts create mode 100644 frontend/src/api/agent.ts create mode 100644 frontend/src/components/forms/AgentChatWidget.module.css create mode 100644 frontend/src/components/forms/AgentChatWidget.tsx create mode 100644 frontend/src/hooks/career/useAgentChat.test.ts create mode 100644 frontend/src/hooks/career/useAgentChat.ts create mode 100644 frontend/src/utils/agentOperations.test.ts create mode 100644 frontend/src/utils/agentOperations.ts diff --git a/frontend/e2e/agent-chat.spec.ts b/frontend/e2e/agent-chat.spec.ts new file mode 100644 index 00000000..1866eb8d --- /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. 職務経歴書を開く → 右下に「AI アシスタント」ボタン + * 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: "AI アシスタント" }).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: "AI アシスタント" }).click(); + await page + .getByPlaceholder("例: 成果がより伝わる文章にしてください") + .fill("改善して"); + await page.getByRole("button", { name: "送信", exact: true }).click(); + + await expect( + page.getByText("AI の応答取得に失敗しました。しばらくしてからもう一度お試しください。"), + ).toBeVisible(); + }); +}); 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/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..bbfcd93c 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -118,3 +118,20 @@ 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"]; + +/** 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..031e7931 --- /dev/null +++ b/frontend/src/components/forms/AgentChatWidget.module.css @@ -0,0 +1,203 @@ +/* Agent チャットウィジェット(ADR-0010)。右下フローティング + パネル */ + +.fab { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 60; + padding: 10px 16px; + border: none; + border-radius: 24px; + background: #2563eb; + 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: #1d4ed8; +} + +.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 #d1d5db; + border-radius: 12px; + background: #fff; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid #e5e7eb; +} + +.title { + font-weight: 600; + font-size: 0.95rem; +} + +.closeButton { + border: none; + background: none; + font-size: 1.2rem; + line-height: 1; + cursor: pointer; + color: #6b7280; +} + +.scopeRow { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 14px; + border-bottom: 1px solid #e5e7eb; +} + +.scopeLabel { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + color: #374151; +} + +.select { + flex: 1; + padding: 4px 8px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 0.85rem; +} + +.targetEmpty { + margin: 0; + font-size: 0.8rem; + color: #b45309; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.emptyState { + margin: 0; + font-size: 0.85rem; + color: #6b7280; +} + +.userMessage, +.assistantMessage { + max-width: 92%; + padding: 8px 12px; + border-radius: 10px; + font-size: 0.85rem; +} + +.userMessage { + align-self: flex-end; + background: #2563eb; + color: #fff; +} + +.assistantMessage { + align-self: flex-start; + background: #f3f4f6; + color: #111827; +} + +.messageText { + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +.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 #d1d5db; + border-radius: 6px; + background: #fff; + font-size: 0.78rem; + white-space: pre-wrap; + word-break: break-word; +} + +.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: #6b7280; +} + +.inputRow { + display: flex; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid #e5e7eb; +} + +.promptInput { + flex: 1; + resize: none; + padding: 6px 8px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 0.85rem; + font-family: inherit; +} + +.sendButton { + align-self: flex-end; + padding: 6px 14px; + border: none; + border-radius: 6px; + background: #2563eb; + color: #fff; + font-size: 0.85rem; + cursor: pointer; +} + +.sendButton:disabled { + background: #9ca3af; + 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..83051d2b --- /dev/null +++ b/frontend/src/components/forms/AgentChatWidget.tsx @@ -0,0 +1,199 @@ +/** + * Agent チャットウィジェット(ADR-0010)。 + * + * 職務経歴書フォーム右下のフローティングボタンからチャットパネルを開き、 + * スコープ(職務要約 / 自己PR / プロジェクト)を選んで AI に改善を依頼する。 + * AI 応答の operations は「フォームに反映」でフォーム state にのみ適用され、 + * 保存は既存の保存ボタン(保存 API)をユーザーが明示的に実行する。 + */ + +import { 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 }; + +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(""); + 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 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 ( +
+
+ ✨ {AGENT_MESSAGES.TITLE} + +
+ +
+ + {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}

} +
+ +
+