From c5c3b4f54fae30c952f30a04f991bea199482b85 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 08:06:08 +0000 Subject: [PATCH 1/4] =?UTF-8?q?ADR-0010=20Phase=202:=20experience=20?= =?UTF-8?q?=E3=82=B9=E3=82=B3=E3=83=BC=E3=83=97=E8=BF=BD=E5=8A=A0=E3=81=A8?= =?UTF-8?q?=20GitHub/=E3=83=96=E3=83=AD=E3=82=B0=E5=8F=82=E7=85=A7?= =?UTF-8?q?=E3=82=B3=E3=83=B3=E3=83=86=E3=82=AD=E3=82=B9=E3=83=88=E9=80=A3?= =?UTF-8?q?=E6=90=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - experience スコープ追加(business_description 200字 / description 4500字) - ExperienceTarget 新設(extra="forbid" で ProjectTarget との union 解決を確定) - context_builder.py 新設: GitHub/ブログ参照コンテキストを career_summary/self_pr に付与 - degrade 設計: 取得失敗は None に degrade しチャット本体を継続 - codegen 再生成(generated.ts / types.ts に ExperienceTarget 追加) - FE: AgentChatWidget に experience 選択 UI 追加 - テスト: test_agent.py に 11 ケース追加、test_agent_context_builder.py 新設(14 ケース) - ドキュメント: ADR-0010 / agent.md に Phase 2 設計判断を追記 https://claude.ai/code/session_01RFi89w7xFopUSLaVbDUuE7 --- .claude/rules/backend/agent.md | 30 +- backend/app/messages.json | 4 +- backend/app/prompts/agent_career_summary.md | 8 + backend/app/prompts/agent_experience.md | 38 +++ backend/app/prompts/agent_self_pr.md | 8 + backend/app/routers/agent.py | 11 +- backend/app/schemas/agent.py | 35 ++- backend/app/services/agent/chat_service.py | 82 ++++-- backend/app/services/agent/context_builder.py | 141 ++++++++++ backend/app/services/agent/output_schema.py | 1 + backend/tests/test_agent.py | 264 +++++++++++++++++- backend/tests/test_agent_context_builder.py | 249 +++++++++++++++++ docs/adr/0010-devforge-agent.md | 57 +++- frontend/src/api/generated.ts | 27 +- frontend/src/api/types.ts | 3 + .../src/components/forms/AgentChatWidget.tsx | 69 ++++- frontend/src/constants/messages.ts | 3 + .../src/hooks/career/useAgentChat.test.ts | 14 + frontend/src/hooks/career/useAgentChat.ts | 13 +- frontend/src/utils/agentOperations.test.ts | 29 ++ frontend/src/utils/agentOperations.ts | 43 ++- 21 files changed, 1062 insertions(+), 67 deletions(-) create mode 100644 backend/app/prompts/agent_experience.md create mode 100644 backend/app/services/agent/context_builder.py create mode 100644 backend/tests/test_agent_context_builder.py diff --git a/.claude/rules/backend/agent.md b/.claude/rules/backend/agent.md index 13ac72b3..522fce8f 100644 --- a/.claude/rules/backend/agent.md +++ b/.claude/rules/backend/agent.md @@ -20,11 +20,13 @@ backend/ │ │ ├── agent_base.md # 共通ルール(全スコープに適用) │ │ ├── agent_career_summary.md │ │ ├── agent_self_pr.md -│ │ └── agent_project.md +│ │ ├── agent_project.md +│ │ └── agent_experience.md # Phase 2 追加 │ ├── schemas/ │ │ └── agent.py # リクエスト/レスポンス Pydantic スキーマ │ └── services/agent/ -│ ├── chat_service.py # コンテキスト組み立て → LLM → 検証 +│ ├── chat_service.py # コンテキスト組み立て → LLM → 検証(DB に触れない) +│ ├── context_builder.py # Phase 2 追加: GitHub/ブログ参照コンテキスト構築(DB 読み取りはここだけ) │ ├── output_schema.py # tool use スキーマ(機械制約の正本) │ └── llm/ │ ├── base.py # LLMClient 抽象・LLMError @@ -32,7 +34,8 @@ backend/ │ ├── ollama_client.py │ └── factory.py └── tests/ - └── test_agent.py + ├── test_agent.py + └── test_agent_context_builder.py # Phase 2 追加 ``` ## 制約の責務分離(最重要) @@ -63,6 +66,8 @@ backend/ | `self_pr` | `self_pr` | 2000 | | `project` | `description` | 4500 | | `project` | `role` | 200 | +| `experience` | `business_description` | 200 | +| `experience` | `description` | 4500 | 上限値を変更する場合は `output_schema.py` の `SCOPE_FIELDS` を編集する。 `schemas/resume.py` の max_length と一致させること(`test_scope_limits_match_resume_schema` で検証済み)。 @@ -92,9 +97,12 @@ backend/ 1. `output_schema.py` の `SCOPE_FIELDS` にスコープ名とフィールド/上限を追加 2. `agent_{scope}.md` を `backend/app/prompts/` に作成(品質基準・思考ステップ・few-shot) + - **注意**: `SCOPE_FIELDS` 追加と md 作成は**同時**に行う。`_SCOPE_PROMPTS` がモジュールロード時に走査するため、片方だけだと import 時 FileNotFoundError で全テスト落ち 3. `chat_service._SCOPE_DEFAULT_FIELD` に正規化先を追加 4. `chat_service._build_context` に該当スコープの分岐を追加 5. `schemas/agent.py` の `AgentScope` Literal に追加 + - target が必要なスコープは `validate_target` にガードを追加(project / experience が先例) + - target が新しい型なら `AgentChatRequest.target` の union に型を追加(`extra="forbid"` で union 解決を確定させる) 6. `test_agent.py` に `test_chat_system_prompt_is_scope_specific` のパラメータを追加 7. `test_scope_limits_match_resume_schema` に上限一致の assert を追加 @@ -110,11 +118,25 @@ backend/ 超過 operation を切り詰めて返すことは**禁止**(途中で切れた経歴書は品質として不可)。 +## GitHub/ブログ参照コンテキスト(Phase 2) + +`career_summary` / `self_pr` スコープのみに GitHub・ブログ分析サマリーを付与する。`project` / `experience` には付与しない(捏造リスク・トークン浪費のため)。 + +**データフロー**: router → `context_builder.build_reference_context(db, user_id, scope)` → `run_agent_chat(request, reference)` → `_build_context` で JSON に merge。 + +**DB アクセスの分離**: `context_builder.py` が DB 読み取りを担う。`chat_service.py` は DB に触れない。 + +**圧縮契約**: `languages_top5`(上位 5 言語・割合 %)/ `contributions_by_year`(直近 5 年・weeks 捨て)/ `active_days_last_12_months` / `recent_articles`(直近 5 件・タイトル + タグ先頭 5 個)。 + +**degrade**: 取得失敗(未連携・processing・0 件・DB 例外)はいずれも `None` に degrade しチャット本体を継続。`build_reference_context` と各ヘルパーが独立に `try/except Exception` + `logger.warning(exc_info=True)` でラップする。両方 None なら Phase 1 と同一動作。 + +**読み取りのみ**: `context_builder.py` で commit / flush / add を書いてはいけない。 + ## エラー契約(変えてはいけない) | 事象 | HTTP | ErrorCode | |---|---|---| -| project スコープで target 未指定 | 422 | `VALIDATION_ERROR` | +| project / experience スコープで target 未指定 | 422 | `VALIDATION_ERROR` | | target インデックスが範囲外 | 422 | `VALIDATION_ERROR` | | LLM 呼び出し失敗 | 502 | `AGENT_LLM_ERROR` | | LLM 応答のパース / スキーマ違反(リトライ後も失敗) | 502 | `AGENT_PARSE_ERROR` | diff --git a/backend/app/messages.json b/backend/app/messages.json index 8cc005e0..4ce0d7b3 100644 --- a/backend/app/messages.json +++ b/backend/app/messages.json @@ -71,8 +71,8 @@ "agent": { "llm_failed": "AI の応答取得に失敗しました。しばらくしてからもう一度お試しください。", "parse_failed": "AI の応答を解釈できませんでした。もう一度お試しください。", - "target_required": "project スコープでは対象プロジェクトの指定が必要です。", - "target_not_found": "指定されたプロジェクトが見つかりません。" + "target_required": "このスコープでは対象の指定が必要です。", + "target_not_found": "指定された対象が見つかりません。" } }, "notification": { diff --git a/backend/app/prompts/agent_career_summary.md b/backend/app/prompts/agent_career_summary.md index 6ae8902a..283dd592 100644 --- a/backend/app/prompts/agent_career_summary.md +++ b/backend/app/prompts/agent_career_summary.md @@ -14,4 +14,12 @@ 3. 強みを一言で表現できるキーワードを抽出する 4. 200〜300字の流れある文章に落とし込む +# GitHub・ブログ情報の使い方 + +「# 現在の内容」に `github_context` / `blog_context` が含まれる場合、職務要約の説得力を高める参考情報として使ってよい。 + +- 使ってよい内容: `github_context` の言語・コントリビューション数値、`blog_context` の記事タイトル・タグ・投稿頻度 +- 含まれていない場合は GitHub・ブログに言及しない(捏造禁止) +- 言及は簡潔に補強程度に留める。技術スタックの裏付けや継続的な学習の根拠として 1〜2 文で触れる程度が適切 + 現在の職務要約と在籍企業の概要は、user メッセージの「# 現在の内容」に JSON で渡される。 diff --git a/backend/app/prompts/agent_experience.md b/backend/app/prompts/agent_experience.md new file mode 100644 index 00000000..d6d74349 --- /dev/null +++ b/backend/app/prompts/agent_experience.md @@ -0,0 +1,38 @@ +# スコープ: 在籍企業(experience) + +対象の在籍企業について、2 つのフィールドを改善する。 + +- `business_description`: 事業内容(推奨 50〜150字) +- `description`: 職務詳細(推奨 300〜400字) + +# フィールドの役割と分岐ルール + +**business_description(事業内容)** +- 会社が何をしているかを 1〜2 文で表す +- 読み手が業界・事業規模を即座に把握できる記述にする + +**description(職務詳細)** +- `is_it_company = false` の場合: `clients` / `projects` を持たないため、この在籍期間の担当業務をすべてここに記述する。課題 → 行動 → 成果(PAR 形式)で書く +- `is_it_company = true` の場合: 具体的な案件詳細は `clients` / `projects` 側に記述されているため、`description` は会社単位で補足したい内容(組織横断の取り組み・社内改善・マネジメント経験など)の記述に留める。`clients` / `projects` の内容を重複させない + +# 品質基準 +- description(PAR 形式): 課題 → 行動 → 成果の構造で書く(非IT企業での担当業務) +- 数値・規模・具体的な成果を含める(現在の内容にある事実のみ) +- プレースホルダ・穴埋め(「〇〇課長」「〇名のチーム」等)は使わない + +# 思考ステップ(内部分析。出力には含めない) +1. `is_it_company` を確認し、description の役割(職務詳細 or 会社単位の補足)を決める +2. 現在の `business_description` に問題点(抽象的・長すぎる等)があれば特定する +3. 現在の `description` の問題点を特定する(根拠がない・成果不明・冗長 等) +4. 現在の内容にある事実から改善案を組み立てる +5. `business_description` は 1〜2 文に、`description` は PAR 形式(または補足記述)で書き直す + +# 出力例(この構成には必ず従う。本文の内容は流用せず「# 現在の内容」の事実のみで書くこと) + +**business_description の例**: +「中小企業向けの基幹業務システムを中心に、受発注・在庫管理・会計連携のパッケージ開発・導入を手掛けるソフトウェアベンダー。」 + +**description の例(非IT企業・PAR 形式)**: +「既存の手作業による受発注フローに起因する入力ミスが月平均 20 件発生しており、返品・再発注コストが問題となっていた(課題)。在庫管理台帳の Excel 化と発注トリガーの自動化を提案し、社内システム担当と連携して半年で導入した(行動)。ミス件数を月 2 件以下に抑制し、担当部署の残業時間を月 15 時間削減することを実現した(成果)。」 + +対象在籍企業の現在のデータは、user メッセージの「# 現在の内容」に JSON で渡される。 diff --git a/backend/app/prompts/agent_self_pr.md b/backend/app/prompts/agent_self_pr.md index 8b33f68e..45dbb7b5 100644 --- a/backend/app/prompts/agent_self_pr.md +++ b/backend/app/prompts/agent_self_pr.md @@ -37,4 +37,12 @@ ### チームを前に進める推進力 進捗の停滞や仕様の曖昧さを放置せず、自ら論点を整理して関係者に提案する姿勢を大切にしています。今後もチームの成果に貢献しながら、技術の幅と深さを広げていきます。 +# GitHub・ブログ情報の使い方 + +「# 現在の内容」に `github_context` / `blog_context` が含まれる場合、自己 PR の根拠として使ってよい。 + +- 使ってよい内容: `github_context` の言語・コントリビューション実績、`blog_context` の記事タイトル・投稿頻度・技術記事数 +- 含まれていない場合は GitHub・ブログに言及しない(捏造禁止) +- 継続的な学習・発信の具体的な裏付けとして活用し、見出し段落の根拠に組み込む + 現在の自己PRと参考情報は、user メッセージの「# 現在の内容」に JSON で渡される。 diff --git a/backend/app/routers/agent.py b/backend/app/routers/agent.py index a6afdd2d..703ee784 100644 --- a/backend/app/routers/agent.py +++ b/backend/app/routers/agent.py @@ -1,17 +1,20 @@ """DevForge Agent(LLM チャット)エンドポイント(ADR-0010)。 外部 LLM API を呼ぶ高コスト endpoint のため rate limit を付与する。 -DB は参照・更新しない(フロントが編集中フォームを送り、適用はフロント側)。 +career_summary / self_pr スコープでは GitHub/ブログ分析サマリーを参照情報として付与する(DB は読み取りのみ)。 +Agent のレスポンス(operations)はフロントの state にのみ適用され、DB は更新しない。 """ import logging from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session from ..core.errors import ErrorCode, raise_app_error from ..core.messages import get_error from ..core.security.auth import get_current_user from ..core.security.dependencies import limiter +from ..db import get_db from ..models import User from ..schemas.agent import AgentChatRequest, AgentChatResponse from ..services.agent import chat_service @@ -19,6 +22,7 @@ AgentResponseParseError, AgentTargetNotFoundError, ) +from ..services.agent.context_builder import build_reference_context from ..services.agent.llm.base import LLMError logger = logging.getLogger(__name__) @@ -32,14 +36,17 @@ async def agent_chat( request: Request, body: AgentChatRequest, user: User = Depends(get_current_user), + db: Session = Depends(get_db), ) -> AgentChatResponse: """選択スコープの内容とプロンプトをもとに、職務経歴書への差分 operations を返す。 + career_summary / self_pr スコープでは GitHub・ブログ分析サマリーを参照情報として付与する。 レスポンスはフロントの state にのみ適用され、DB は更新しない。 ユーザーが確認して「適用」した時点で既存の保存 API が呼ばれる。 """ try: - return await chat_service.run_agent_chat(body) + reference = build_reference_context(db, user.id, body.scope) + return await chat_service.run_agent_chat(body, reference) except AgentTargetNotFoundError: raise_app_error( status_code=422, diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py index b52c2737..75fa7fde 100644 --- a/backend/app/schemas/agent.py +++ b/backend/app/schemas/agent.py @@ -5,18 +5,18 @@ から Agent を呼べる必要があり、保存時バリデーションを再利用すると 422 になるため。 フィールドの max_length は保存契約(``resume.py``)と同じ上限に揃える。 -レスポンスの ``operations`` はテキストフィールドの置換のみ(構造編集なし、Phase 1)。 -スコープ選択が必須で対象 project の位置はリクエストで確定するため、operation は +レスポンスの ``operations`` はテキストフィールドの置換のみ(構造編集なし)。 +スコープ選択が必須で対象の位置はリクエストで確定するため、operation は パス指定を持たず「フィールド名 + 新しい値」だけを返す。 """ from typing import Literal -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from ..core.messages import get_error -AgentScope = Literal["project", "career_summary", "self_pr"] +AgentScope = Literal["project", "career_summary", "self_pr", "experience"] class AgentTechnologyStack(BaseModel): @@ -48,6 +48,10 @@ class AgentExperienceContext(BaseModel): company: str = Field(default="", max_length=120) business_description: str = Field(default="", max_length=200) + # experience スコープで編集対象となる自由記述欄(非IT企業の職務詳細) + description: str = Field(default="", max_length=4500) + # IT企業かどうか(experience スコープのプロンプト分岐に使用) + is_it_company: bool = True clients: list[AgentClientContext] = Field(default_factory=list) @@ -71,6 +75,18 @@ class ProjectTarget(BaseModel): project_index: int = Field(ge=0) +class ExperienceTarget(BaseModel): + """scope=experience のとき対象在籍企業を特定するインデックス。 + + ``extra="forbid"`` により ProjectTarget の 3 キー payload は + ExperienceTarget にマッチしない(union で型を決定的に区別するため)。 + """ + + model_config = ConfigDict(extra="forbid") + + experience_index: int = Field(ge=0) + + class AgentHistoryEntry(BaseModel): """マルチターン用の会話履歴 1 件。 @@ -90,14 +106,19 @@ class AgentChatRequest(BaseModel): scope: AgentScope prompt: str = Field(min_length=1, max_length=2000) resume: AgentResumeContext - target: ProjectTarget | None = None + # project は ProjectTarget | ExperienceTarget | None の union(ProjectTarget を先に配置)。 + # ExperienceTarget は extra="forbid" により 3 キー payload がマッチしないため + # union の解決は決定的。project スコープは ProjectTarget 必須、experience は ExperienceTarget 必須。 + target: ProjectTarget | ExperienceTarget | None = None # 直近 3 往復(6 エントリ)まで。サーバーはセッションを持たずフロントが送る history: list[AgentHistoryEntry] = Field(default_factory=list, max_length=6) @model_validator(mode="after") def validate_target(self) -> "AgentChatRequest": - """project スコープでは対象プロジェクトの指定を必須とする。""" - if self.scope == "project" and self.target is None: + """project / experience スコープでは対象の指定を必須とする。""" + if self.scope == "project" and not isinstance(self.target, ProjectTarget): + raise ValueError(get_error("agent.target_required")) + if self.scope == "experience" and self.target is None: raise ValueError(get_error("agent.target_required")) return self diff --git a/backend/app/services/agent/chat_service.py b/backend/app/services/agent/chat_service.py index 61725bb6..2c33e009 100644 --- a/backend/app/services/agent/chat_service.py +++ b/backend/app/services/agent/chat_service.py @@ -1,7 +1,9 @@ """Agent チャットの中核ロジック(コンテキスト組み立て → LLM → operations 検証)。 -DB アクセスは行わない。フロントが編集中フォームをリクエストに載せて送り、 -レスポンスの operations はフロントの state にのみ適用される(DB を更新しない原則 / ADR-0010)。 +フロントが編集中フォームをリクエストに載せて送り、レスポンスの operations は +フロントの state にのみ適用される(DB を更新しない原則 / ADR-0010)。 +参照データ(GitHub・ブログ分析サマリー)は router が context_builder 経由で読み取って渡す。 +本モジュールは DB に触れない。 """ import json @@ -13,8 +15,10 @@ from ...schemas.agent import ( AgentChatRequest, AgentChatResponse, + AgentExperienceContext, AgentOperation, AgentProjectContext, + ExperienceTarget, ) from .llm.factory import get_llm_client from .output_schema import ( @@ -36,12 +40,12 @@ class AgentResponseParseError(Exception): # 許可外の field 名を返された時の正規化先。スコープ選択で編集対象は確定しているため、 # 小型 LLM が「自己PR」等の field 名を返しても既定 field の提案として救済する。 -# project は role / description の 2 候補だが、自由記述の実体は description のみ -# (role は 1 行の肩書き入力)なので description に倒す +# project / experience は複数候補だが、自由記述の実体は description なので description に倒す _SCOPE_DEFAULT_FIELD: dict[str, str] = { "career_summary": "career_summary", "self_pr": "self_pr", "project": "description", + "experience": "description", } # システムプロンプトの正本は app/prompts/ の md ファイル(プロンプト文言の変更を @@ -70,28 +74,47 @@ def _load_scope_prompt(scope: str) -> str: _MAX_RETRY_ERROR_LENGTH = 500 -def _build_context(request: AgentChatRequest) -> str: - """スコープに応じて LLM に渡すコンテキスト文字列を組み立てる。""" +def _build_context(request: AgentChatRequest, reference: dict | None = None) -> str: + """スコープに応じて LLM に渡すコンテキスト文字列を組み立てる。 + + reference には router が context_builder 経由で取得した GitHub/ブログ参照データを渡す。 + career_summary / self_pr スコープの場合のみ参照データをコンテキストに含める。 + """ resume = request.resume # 編集対象フィールドのキーは operations の正規 field 名(career_summary 等)に揃える。 # 小型 LLM はコンテキストのキー名を operations.field に流用しやすいため、 # 日本語キーにすると許可外 field として破棄される(パース失敗の主因だった) if request.scope == "career_summary": - return json.dumps( - { - "career_summary": resume.career_summary, - "在籍企業の概要": [ - {"会社": e.company, "事業内容": e.business_description} - for e in resume.experiences - ], - }, - ensure_ascii=False, - ) + ctx: dict = { + "career_summary": resume.career_summary, + "在籍企業の概要": [ + {"会社": e.company, "事業内容": e.business_description} + for e in resume.experiences + ], + } + if reference: + ctx.update(reference) + return json.dumps(ctx, ensure_ascii=False) if request.scope == "self_pr": + ctx = { + "self_pr": resume.self_pr, + "職務要約(参考情報)": resume.career_summary, + } + if reference: + ctx.update(reference) + return json.dumps(ctx, ensure_ascii=False) + if request.scope == "experience": + exp = _resolve_target_experience(request) return json.dumps( { - "self_pr": resume.self_pr, - "職務要約(参考情報)": resume.career_summary, + "会社": exp.company, + "business_description": exp.business_description, + "description": exp.description, + "IT企業かどうか": exp.is_it_company, + "取引先・プロジェクト一覧(参考情報)": [ + {"取引先": c.name, "プロジェクト数": len(c.projects)} + for c in exp.clients + ], }, ensure_ascii=False, ) @@ -108,6 +131,19 @@ def _build_context(request: AgentChatRequest) -> str: ) +def _resolve_target_experience(request: AgentChatRequest) -> AgentExperienceContext: + """target のインデックスから対象在籍企業を取り出す。範囲外は専用例外。""" + target = request.target + # scope=experience のとき target は schema で必須検証済み + assert isinstance(target, ExperienceTarget) + try: + return request.resume.experiences[target.experience_index] + except IndexError as exc: + raise AgentTargetNotFoundError( + f"target index out of range: {target}" + ) from exc + + def _resolve_target_project(request: AgentChatRequest) -> AgentProjectContext: """target のインデックスから対象プロジェクトを取り出す。範囲外は専用例外。""" target = request.target @@ -174,9 +210,15 @@ def _parse_response(raw: str, scope: str) -> AgentChatResponse: return AgentChatResponse(message=parsed.message, operations=operations, suggestions=suggestions) -async def run_agent_chat(request: AgentChatRequest) -> AgentChatResponse: +async def run_agent_chat( + request: AgentChatRequest, reference: dict | None = None +) -> AgentChatResponse: """Agent チャットを実行する。 + Args: + reference: GitHub/ブログ参照コンテキスト(career_summary / self_pr スコープのみ有効)。 + router が context_builder 経由で取得して渡す。None の場合は省略される。 + Raises: AgentTargetNotFoundError: target が範囲外。 AgentResponseParseError: LLM 応答が不正。 @@ -185,7 +227,7 @@ async def run_agent_chat(request: AgentChatRequest) -> AgentChatResponse: system_prompt = _SCOPE_PROMPTS[request.scope] user_prompt = ( f"# 編集対象スコープ\n{request.scope}\n\n" - f"# 現在の内容\n{_build_context(request)}\n\n" + f"# 現在の内容\n{_build_context(request, reference)}\n\n" f"# ユーザーの依頼\n{request.prompt}" ) # 調査用ログはメタデータのみ出す。レジュメ本文・プロンプト本文は個人情報を含むため diff --git a/backend/app/services/agent/context_builder.py b/backend/app/services/agent/context_builder.py new file mode 100644 index 00000000..30f0e16b --- /dev/null +++ b/backend/app/services/agent/context_builder.py @@ -0,0 +1,141 @@ +"""Agent チャット用の参照コンテキスト構築(GitHub/ブログ分析サマリー)。 + +DB アクセスはこのモジュールに閉じ込める。chat_service は DB に触れない。 +GitHub/ブログは career_summary / self_pr スコープのみに付与し、 +project / experience では None を返す(スコープゲート)。 + +参照データの取得失敗(未連携・processing 中・記事 0 件含む)は +チャット本体を落とさずコンテキスト省略(degrade)で吸収する。 +""" + +import logging +from datetime import date, timedelta + +from sqlalchemy.orm import Session + +from ...models.cache import GitHubLinkCache +from ...repositories.blog import BlogArticleRepository +from ...services.blog.scorer import blog_articles_to_score_dicts, calculate_blog_score + +logger = logging.getLogger(__name__) + +# GitHub/ブログコンテキストを付与するスコープ +_REFERENCE_SCOPES: frozenset[str] = frozenset({"career_summary", "self_pr"}) + +# 圧縮契約の上限値(ADR-0010「コンテキスト圧縮」) +_LANGUAGES_TOP_N = 5 +_CONTRIBUTIONS_YEARS = 5 +_RECENT_ARTICLES_N = 5 +_RECENT_ARTICLE_TAGS_N = 5 + + +def build_reference_context(db: Session, user_id: str, scope: str) -> dict | None: + """career_summary / self_pr スコープ向けに GitHub/ブログ参照コンテキストを構築する。 + + スコープが対象外の場合・データが未取得の場合はいずれも None を返す。 + 取得失敗は warning ログを出して None に degrade し、チャット本体への影響を防ぐ。 + DB は SELECT のみ。commit/flush/add は行わない。 + """ + if scope not in _REFERENCE_SCOPES: + return None + + result: dict = {} + + try: + github_ctx = _build_github_context(db, user_id) + except Exception: + logger.warning("GitHub コンテキストの取得に失敗(省略)", exc_info=True) + github_ctx = None + if github_ctx: + result["github_context"] = github_ctx + + try: + blog_ctx = _build_blog_context(db, user_id) + except Exception: + logger.warning("ブログコンテキストの取得に失敗(省略)", exc_info=True) + blog_ctx = None + if blog_ctx: + result["blog_context"] = blog_ctx + + return result if result else None + + +def _build_github_context(db: Session, user_id: str) -> dict | None: + """GitHubLinkCache から圧縮済み GitHub コンテキストを生成する。""" + try: + cache = db.query(GitHubLinkCache).filter_by(user_id=user_id).first() + if not cache or cache.status != "completed" or not cache.result: + return None + + result = cache.result + languages_raw: dict[str, int] = result.get("languages") or {} + calendars: list[dict] = result.get("contribution_calendars") or [] + + # 言語上位 N 件を割合(%)に変換(生バイト数より LLM が解釈しやすい) + total_bytes = sum(languages_raw.values()) or 1 + languages_top = sorted(languages_raw.items(), key=lambda x: x[1], reverse=True)[ + :_LANGUAGES_TOP_N + ] + languages = [ + {"name": lang, "percent": round(b / total_bytes * 100, 1)} + for lang, b in languages_top + ] + + # 年別コントリビューション(直近 N 年分。weeks は捨てる) + sorted_calendars = sorted(calendars, key=lambda c: c.get("year", 0), reverse=True) + contributions_by_year = [ + {"year": c["year"], "total": c["total_contributions"]} + for c in sorted_calendars[:_CONTRIBUTIONS_YEARS] + if "year" in c and "total_contributions" in c + ] + + # 直近 12 ヶ月の活動日数 + cutoff = (date.today() - timedelta(days=365)).isoformat() + active_days = 0 + for cal in calendars: + for week in cal.get("weeks") or []: + for day in week: + if isinstance(day, dict) and day.get("date", "") >= cutoff: + if (day.get("count") or 0) > 0: + active_days += 1 + + return { + "languages_top5": languages, + "contributions_by_year": contributions_by_year, + "active_days_last_12_months": active_days, + } + except Exception: + logger.warning("GitHub コンテキストの取得に失敗(省略)", exc_info=True) + return None + + +def _build_blog_context(db: Session, user_id: str) -> dict | None: + """BlogArticle から圧縮済みブログコンテキストを生成する。""" + try: + repo = BlogArticleRepository(db, user_id) + articles = repo.list_by_user() + if not articles: + return None + + score = calculate_blog_score(blog_articles_to_score_dicts(articles)) + + # 直近 N 件の記事(list_by_user は published_at 降順保証) + recent_articles = [ + { + "title": a.title, + "tags": a.tags[:_RECENT_ARTICLE_TAGS_N], + "published_at": a.published_at, + } + for a in articles[:_RECENT_ARTICLES_N] + ] + + return { + "tech_article_count": score.tech_article_count, + "total_article_count": score.total_article_count, + "avg_monthly_posts": score.avg_monthly_posts, + "avg_likes": score.avg_likes, + "recent_articles": recent_articles, + } + except Exception: + logger.warning("ブログコンテキストの取得に失敗(省略)", exc_info=True) + return None diff --git a/backend/app/services/agent/output_schema.py b/backend/app/services/agent/output_schema.py index e86c5eb1..94c6b1c8 100644 --- a/backend/app/services/agent/output_schema.py +++ b/backend/app/services/agent/output_schema.py @@ -18,6 +18,7 @@ "career_summary": {"career_summary": 2000}, "self_pr": {"self_pr": 2000}, "project": {"description": 4500, "role": 200}, + "experience": {"business_description": 200, "description": 4500}, } # LLM が生成する「次の依頼候補」(suggestions)の制約 diff --git a/backend/tests/test_agent.py b/backend/tests/test_agent.py index 042fed62..ad0309fb 100644 --- a/backend/tests/test_agent.py +++ b/backend/tests/test_agent.py @@ -207,7 +207,7 @@ def test_chat_target_index_out_of_range(client: TestClient, monkeypatch) -> None assert resp.status_code == 422 body = resp.json() assert body["code"] == "VALIDATION_ERROR" - assert "プロジェクトが見つかりません" in body["message"] + assert "対象が見つかりません" in body["message"] def test_chat_llm_failure_returns_502(client: TestClient, monkeypatch) -> None: @@ -405,6 +405,7 @@ def test_chat_without_suggestions_field_defaults_empty(client: TestClient, monke "description", {"experience_index": 0, "client_index": 0, "project_index": 0}, ), + ("experience", "description", {"experience_index": 0}), ], ) def test_chat_system_prompt_is_scope_specific( @@ -431,6 +432,7 @@ def test_chat_system_prompt_is_scope_specific( "career_summary": "# スコープ: 職務要約(career_summary)", "self_pr": "# スコープ: 自己PR(self_pr)", "project": "# スコープ: プロジェクト詳細(project)", + "experience": "# スコープ: 在籍企業(experience)", } assert scope_headings[scope] in prompt for other, heading in scope_headings.items(): @@ -609,7 +611,7 @@ def test_run_agent_chat_target_not_found(monkeypatch) -> None: # --- ユニットテスト(output_schema: スコープ → tool 定義) --- -@pytest.mark.parametrize("scope", ["career_summary", "self_pr", "project"]) +@pytest.mark.parametrize("scope", ["career_summary", "self_pr", "project", "experience"]) def test_build_output_schema_operations_branches(scope: str) -> None: """スコープの許可 field・文字数上限が operations の oneOf 分岐に反映される。""" schema = build_output_schema(scope) @@ -646,7 +648,7 @@ def test_build_tool_definition_wraps_schema() -> None: 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 + from app.schemas.resume import Experience, Project, ResumeBase def max_length(model: type, field: str) -> int: """model の field から MaxLen メタデータを取り出し、max_length 値を返す。""" @@ -661,3 +663,259 @@ def max_length(model: type, field: str) -> int: 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") + assert SCOPE_FIELDS["experience"]["business_description"] == max_length( + Experience, "business_description" + ) + assert SCOPE_FIELDS["experience"]["description"] == max_length(Experience, "description") + + +# --- Phase 2: experience スコープのテスト --- + + +def _resume_payload_with_experience() -> dict: + """experience スコープのテスト用に description / is_it_company を持つレジュメを返す。""" + payload = _resume_payload() + payload["experiences"][0]["description"] = "受託開発を中心とした業務をこなしました。" + payload["experiences"][0]["is_it_company"] = False + return payload + + +def test_chat_experience_success(client: TestClient, monkeypatch) -> None: + """正常系: experience スコープ + target 指定で description の operation が返る。""" + _mock_llm(monkeypatch, response=_llm_json("description", "改善された職務詳細。")) + headers = auth_header(client, "agentuser") + + resp = client.post( + "/api/agent/chat", + json={ + "scope": "experience", + "prompt": "成果がより伝わる詳細にしてください", + "resume": _resume_payload_with_experience(), + "target": {"experience_index": 0}, + }, + headers=headers, + ) + assert resp.status_code == 200 + ops = resp.json()["operations"] + assert ops[0]["field"] == "description" + assert ops[0]["value"] == "改善された職務詳細。" + + +def test_chat_experience_scope_requires_target(client: TestClient) -> None: + """バリデーション: experience スコープで target 欠落は 422 + VALIDATION_ERROR。""" + headers = auth_header(client, "agentuser") + resp = client.post( + "/api/agent/chat", + json={ + "scope": "experience", + "prompt": "改善して", + "resume": _resume_payload(), + }, + headers=headers, + ) + assert resp.status_code == 422 + assert resp.json()["code"] == "VALIDATION_ERROR" + + +def test_chat_experience_target_index_out_of_range(client: TestClient, monkeypatch) -> None: + """バリデーション: experience target のインデックス範囲外は 422 + VALIDATION_ERROR。""" + _mock_llm(monkeypatch, response=_llm_json("description", "x")) + headers = auth_header(client, "agentuser") + resp = client.post( + "/api/agent/chat", + json={ + "scope": "experience", + "prompt": "改善して", + "resume": _resume_payload(), + "target": {"experience_index": 9}, + }, + headers=headers, + ) + assert resp.status_code == 422 + body = resp.json() + assert body["code"] == "VALIDATION_ERROR" + assert "対象が見つかりません" in body["message"] + + +def test_chat_project_scope_rejects_experience_target(client: TestClient) -> None: + """回帰: scope=project に ExperienceTarget(1 キー)を送ると 422(Phase 1 契約後退なし)。""" + headers = auth_header(client, "agentuser") + resp = client.post( + "/api/agent/chat", + json={ + "scope": "project", + "prompt": "改善して", + "resume": _resume_payload(), + "target": {"experience_index": 0}, + }, + headers=headers, + ) + assert resp.status_code == 422 + assert resp.json()["code"] == "VALIDATION_ERROR" + + +def test_parse_response_experience_normalizes_to_description() -> None: + """experience スコープの許可外 field は description に正規化される。""" + raw = json.dumps( + { + "message": "改善案です。", + "operations": [ + {"field": "unknown_field", "value": "正規化される提案"}, + {"field": "business_description", "value": "事業内容の提案"}, + ], + }, + ensure_ascii=False, + ) + result = _parse_response(raw, "experience") + assert [op.field for op in result.operations] == ["description", "business_description"] + + +def test_parse_response_experience_discards_over_limit() -> None: + """experience の business_description(201字) は破棄し、description 上限内は保持する。""" + raw = json.dumps( + { + "message": "m", + "operations": [ + {"field": "business_description", "value": "あ" * 201}, + {"field": "description", "value": "正常な職務詳細"}, + ], + }, + ensure_ascii=False, + ) + result = _parse_response(raw, "experience") + assert len(result.operations) == 1 + assert result.operations[0].field == "description" + + +# --- Phase 2: GitHub/ブログ参照コンテキスト連携のテスト --- + + +def _github_cache_result(languages: dict | None = None, calendars: list | None = None) -> dict: + """GitHubLinkCache.result に格納する dict を生成する。""" + return { + "username": "testuser", + "repos_analyzed": 5, + "unique_skills": 3, + "analyzed_at": "2026-01-01", + "languages": languages or {"Python": 100000, "TypeScript": 50000}, + "contribution_calendars": calendars or [ + { + "year": 2025, + "total_contributions": 300, + "weeks": [ + [{"date": "2025-01-01", "count": 1, "level": 1}], + ], + } + ], + } + + +def test_chat_career_summary_includes_github_and_blog_context( + client: TestClient, monkeypatch, db_session +) -> None: + """Phase 2: career_summary スコープのプロンプトに github_context / blog_context が含まれる。""" + from app.models import GitHubLinkCache + from app.models.blog import BlogAccount, BlogArticle + from app.repositories import UserRepository + + # ユーザーを作成し、GitHubLinkCache と BlogArticle を投入する + repo = UserRepository(db_session) + if not repo.get_by_username("ctxuser"): + repo.create("ctxuser", email="ctxuser@example.com") + db_session.commit() + user = repo.get_by_username("ctxuser") + assert user is not None + + cache = GitHubLinkCache( + user_id=user.id, + status="completed", + result=_github_cache_result(), + ) + db_session.add(cache) + + account = BlogAccount(user_id=user.id, platform="zenn", username="ctxuser") + db_session.add(account) + db_session.flush() + article = BlogArticle( + account_id=account.id, + external_id="a1", + title="Pythonの非同期処理入門", + url="https://zenn.dev/ctxuser/a1", + likes_count=10, + ) + db_session.add(article) + db_session.commit() + + fake = _mock_llm(monkeypatch, response=_llm_json("career_summary", "改善版")) + headers = auth_header(client, "ctxuser") + resp = client.post( + "/api/agent/chat", + json={ + "scope": "career_summary", + "prompt": "GitHubを活かして改善して", + "resume": _resume_payload(), + }, + headers=headers, + ) + assert resp.status_code == 200 + assert fake.received_messages is not None + user_prompt = fake.received_messages[-1]["content"] + assert "github_context" in user_prompt + assert "blog_context" in user_prompt + + +def test_chat_project_scope_has_no_reference_context( + client: TestClient, monkeypatch, db_session +) -> None: + """Phase 2: project スコープのプロンプトには github/blog コンテキストが付与されない。""" + from app.models import GitHubLinkCache + from app.repositories import UserRepository + + repo = UserRepository(db_session) + if not repo.get_by_username("projuser"): + repo.create("projuser", email="projuser@example.com") + db_session.commit() + user = repo.get_by_username("projuser") + assert user is not None + + cache = GitHubLinkCache( + user_id=user.id, + status="completed", + result=_github_cache_result(), + ) + db_session.add(cache) + db_session.commit() + + fake = _mock_llm(monkeypatch, response=_llm_json("description", "改善版")) + headers = auth_header(client, "projuser") + resp = client.post( + "/api/agent/chat", + json={ + "scope": "project", + "prompt": "改善して", + "resume": _resume_payload(), + "target": {"experience_index": 0, "client_index": 0, "project_index": 0}, + }, + headers=headers, + ) + assert resp.status_code == 200 + assert fake.received_messages is not None + user_prompt = fake.received_messages[-1]["content"] + assert "github_context" not in user_prompt + assert "blog_context" not in user_prompt + + +def test_chat_no_github_cache_still_returns_200(client: TestClient, monkeypatch) -> None: + """Phase 2: GitHub 未連携でも career_summary チャットは正常動作する(degrade)。""" + _mock_llm(monkeypatch, response=_llm_json("career_summary", "改善版")) + headers = auth_header(client, "nocacheuser") + resp = client.post( + "/api/agent/chat", + json={ + "scope": "career_summary", + "prompt": "改善して", + "resume": _resume_payload(), + }, + headers=headers, + ) + assert resp.status_code == 200 diff --git a/backend/tests/test_agent_context_builder.py b/backend/tests/test_agent_context_builder.py new file mode 100644 index 00000000..a60ac70c --- /dev/null +++ b/backend/tests/test_agent_context_builder.py @@ -0,0 +1,249 @@ +"""context_builder の単体テスト(GitHub/ブログ参照コンテキスト構築・圧縮・degrade)。 + +DB はモックしない(conftest の db_session fixture で実 SQLite を使う)。 +""" + +from datetime import date, timedelta + +import pytest +from app.models import GitHubLinkCache, User +from app.models.blog import BlogAccount, BlogArticle, BlogArticleTag +from app.repositories import UserRepository +from app.services.agent.context_builder import ( + _LANGUAGES_TOP_N, + _RECENT_ARTICLES_N, + build_reference_context, +) + + +def _make_user(db_session, username: str) -> User: + """テスト用ユーザーを作成して返す。""" + repo = UserRepository(db_session) + if not repo.get_by_username(username): + repo.create(username, email=f"{username}@example.com") + db_session.commit() + user = repo.get_by_username(username) + assert user is not None + return user + + +def _make_github_cache(db_session, user_id: str, *, status: str = "completed", result: dict | None = None) -> GitHubLinkCache: + """GitHubLinkCache を作成して DB に追加する。""" + cache = GitHubLinkCache( + user_id=user_id, + status=status, + result=result or { + "username": "u", + "repos_analyzed": 1, + "unique_skills": 1, + "analyzed_at": "2026-01-01", + "languages": {"Python": 100000, "TypeScript": 50000, "Go": 30000}, + "contribution_calendars": [ + { + "year": 2025, + "total_contributions": 250, + "weeks": [ + [{"date": "2025-06-01", "count": 3, "level": 2}], + [{"date": "2025-06-08", "count": 0, "level": 0}], + ], + } + ], + }, + ) + db_session.add(cache) + db_session.commit() + return cache + + +def _make_blog_articles(db_session, user_id: str, count: int) -> list[BlogArticle]: + """ブログアカウントと記事を count 件作成して返す。""" + account = BlogAccount(user_id=user_id, platform="zenn", username="testblog") + db_session.add(account) + db_session.flush() + articles = [] + for i in range(count): + article = BlogArticle( + account_id=account.id, + external_id=f"article-{i}", + title=f"技術記事 {i}", + url=f"https://zenn.dev/testblog/{i}", + likes_count=i * 2, + ) + db_session.add(article) + db_session.flush() + tag = BlogArticleTag(article_id=article.id, sort_order=0, name="Python") + db_session.add(tag) + articles.append(article) + db_session.commit() + return articles + + +# --- スコープゲートのテスト --- + + +@pytest.mark.parametrize("scope", ["project", "experience"]) +def test_reference_scopes_excluded(db_session, scope: str) -> None: + """project / experience スコープでは参照コンテキストを返さない。""" + user = _make_user(db_session, f"gate_{scope}") + _make_github_cache(db_session, user.id) + + result = build_reference_context(db_session, user.id, scope) + assert result is None + + +# --- GitHub コンテキストのテスト --- + + +def test_github_context_languages_top_n(db_session) -> None: + """languages は上位 N 件を割合(%)に変換して返す。""" + user = _make_user(db_session, "gh_lang") + languages = {f"Lang{i}": (10 - i) * 10000 for i in range(8)} # 8 言語 + _make_github_cache( + db_session, + user.id, + result={ + "username": "u", + "repos_analyzed": 1, + "unique_skills": 1, + "analyzed_at": "2026-01-01", + "languages": languages, + "contribution_calendars": [], + }, + ) + + result = build_reference_context(db_session, user.id, "career_summary") + assert result is not None + gh = result["github_context"] + assert len(gh["languages_top5"]) == _LANGUAGES_TOP_N + # 上位は Lang0(最大バイト数) + assert gh["languages_top5"][0]["name"] == "Lang0" + # 割合の合計は 100% を超えない(上位5件のみ) + total_percent = sum(lang["percent"] for lang in gh["languages_top5"]) + assert total_percent <= 100.0 + + +def test_github_context_weeks_discarded(db_session) -> None: + """contributions_by_year には total のみ入り weeks は含まれない。""" + user = _make_user(db_session, "gh_weeks") + _make_github_cache(db_session, user.id) + + result = build_reference_context(db_session, user.id, "self_pr") + assert result is not None + contributions = result["github_context"]["contributions_by_year"] + assert len(contributions) > 0 + for entry in contributions: + assert "weeks" not in entry + assert "year" in entry + assert "total" in entry + + +def test_github_context_active_days_12m(db_session) -> None: + """active_days_last_12_months は直近 365 日の count > 0 の日数を返す。""" + user = _make_user(db_session, "gh_active") + today = date.today() + recent = (today - timedelta(days=10)).isoformat() + old = (today - timedelta(days=400)).isoformat() + _make_github_cache( + db_session, + user.id, + result={ + "username": "u", + "repos_analyzed": 1, + "unique_skills": 1, + "analyzed_at": "2026-01-01", + "languages": {"Python": 1000}, + "contribution_calendars": [ + { + "year": today.year, + "total_contributions": 5, + "weeks": [ + [{"date": recent, "count": 2, "level": 1}], # 直近365日内、count > 0 + [{"date": old, "count": 3, "level": 2}], # 365日以前、対象外 + ], + } + ], + }, + ) + + result = build_reference_context(db_session, user.id, "career_summary") + assert result is not None + assert result["github_context"]["active_days_last_12_months"] == 1 + + +def test_github_context_status_processing_returns_none(db_session) -> None: + """status が processing のキャッシュは None に degrade する。""" + user = _make_user(db_session, "gh_proc") + _make_github_cache(db_session, user.id, status="processing") + + result = build_reference_context(db_session, user.id, "career_summary") + # github_context が無い(blog も無い)ので None + assert result is None + + +def test_github_context_no_cache_returns_none(db_session) -> None: + """GitHub キャッシュ未連携は github_context が省略され None になる。""" + user = _make_user(db_session, "gh_nocache") + + result = build_reference_context(db_session, user.id, "career_summary") + assert result is None + + +# --- ブログコンテキストのテスト --- + + +def test_blog_context_recent_articles_limit(db_session) -> None: + """記事が N 件超でも recent_articles は上位 N 件に切り詰める。""" + user = _make_user(db_session, "blog_limit") + _make_blog_articles(db_session, user.id, _RECENT_ARTICLES_N + 1) + + result = build_reference_context(db_session, user.id, "career_summary") + assert result is not None + blog = result["blog_context"] + assert len(blog["recent_articles"]) == _RECENT_ARTICLES_N + assert "tech_article_count" in blog + assert "avg_monthly_posts" in blog + + +def test_blog_context_no_articles_returns_none(db_session) -> None: + """記事 0 件は blog_context が省略され None になる。""" + user = _make_user(db_session, "blog_empty") + + result = build_reference_context(db_session, user.id, "career_summary") + assert result is None + + +# --- degrade(DB 例外)のテスト --- + + +def test_github_context_db_error_degrades(db_session, monkeypatch) -> None: + """DB 例外時は github_context を省略して処理を継続する(degrade)。""" + user = _make_user(db_session, "gh_err") + _make_github_cache(db_session, user.id) + + import app.services.agent.context_builder as cb + + def _raise(*a, **kw): + raise RuntimeError("db error") + + monkeypatch.setattr(cb, "_build_github_context", _raise) + result = build_reference_context(db_session, user.id, "career_summary") + # _build_github_context が例外 → github_context は省略。blog も無いので None + assert result is None + + +def test_blog_context_db_error_degrades(db_session, monkeypatch) -> None: + """DB 例外時は blog_context を省略して処理を継続する(degrade)。""" + user = _make_user(db_session, "blog_err") + _make_github_cache(db_session, user.id) + + import app.services.agent.context_builder as cb + + def _raise(*a, **kw): + raise RuntimeError("db error") + + monkeypatch.setattr(cb, "_build_blog_context", _raise) + result = build_reference_context(db_session, user.id, "career_summary") + # blog_context が省略されるが github_context は正常なので None にはならない + assert result is not None + assert "github_context" in result + assert "blog_context" not in result diff --git a/docs/adr/0010-devforge-agent.md b/docs/adr/0010-devforge-agent.md index 206d055d..74f8ce97 100644 --- a/docs/adr/0010-devforge-agent.md +++ b/docs/adr/0010-devforge-agent.md @@ -58,11 +58,12 @@ resume state に差分適用(DB 更新なし) Agent が対応するスコープは以下の 3 種類。スコープはフロントで**必須選択**とし、選択に応じて対象データ(および対応する operations の適用先パス)が自動セットされる。 -| スコープ | 対象 | operations 適用先 | -|---|---|---| -| `project` | 選択中の案件(experience/client/project) | 該当 project の `description` ほか(`role` / `technology_stacks` / `phases`) | -| `career_summary` | 職務要約 | `ResumeBase.career_summary` | -| `self_pr` | 自己 PR | `ResumeBase.self_pr` | +| スコープ | 対象 | operations 適用先 | GitHub/ブログ参照 | +|---|---|---|---| +| `project` | 選択中の案件(experience/client/project) | 該当 project の `description` ほか(`role` / `technology_stacks` / `phases`) | なし | +| `career_summary` | 職務要約 | `ResumeBase.career_summary` | あり(Phase 2) | +| `self_pr` | 自己 PR | `ResumeBase.self_pr` | あり(Phase 2) | +| `experience` | 選択中の在籍企業(experience) | 該当 experience の `business_description` / `description`(Phase 2) | なし | スコープ未選択での汎用モードは Phase 1 では提供しない(Phase 3 で検討)。 @@ -98,7 +99,7 @@ Agent の LLM 出力制御は JSON mode ではなく、プロバイダの構造 | 事象 | HTTP | ErrorCode | messages.json キー | |---|---|---|---| -| project スコープで target 未指定 | 422 | `VALIDATION_ERROR` | `agent.target_required`(schema validator で発火) | +| project / experience スコープで 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` | @@ -213,10 +214,48 @@ Phase 1 追補(対話型選択肢。「対話型選択肢(LLM 生成 suggest - [x] system prompt(`agent_base.md`)に曖昧入力時の suggestions 生成ルールを追加 - [x] フロント: suggestions ボタン表示(押下でそのテキストを prompt として再送信) -**Phase 2(拡張)** +**Phase 2(拡張)— 実装済み** + +- [x] experience 単位のスコープ追加 +- [x] GitHub / ブログ分析との連携強化(「コスト設計」のコンテキスト圧縮契約に従う) + +#### Phase 2 の設計判断 + +**resume コンテキストは FE state から取得** + +GitHub・ブログ分析データは backend が DB から読み取るが、resume コンテキスト(経歴書の編集対象データ)は FE の state から取得する。理由: + +1. **未保存編集の可視性**: LLM が見る context と FE が差分適用する state が一致しなければ、operations の適用先を誤る +2. **差分適用の整合性**: DB に寄せると、ユーザーの未保存編集が LLM に見えずに差分 operations が生成されるため、適用後の state が期待と乖離する +3. **未保存 resume でも動作**: 初めて使うユーザーがまだ保存していない状態でも Agent を利用できる + +GitHub・ブログは「参照データ(read-only な分析結果)」として DB から読む。resume は「編集対象(mutable な下書き)」として FE state から読む。この責務分離は変更しない。 + +**experience スコープの許可フィールドと target 設計** -- experience 単位のスコープ追加 -- GitHub / ブログ分析との連携強化(「コスト設計」のコンテキスト圧縮契約に従う) +`experience` スコープは `business_description`(200 字)と `description`(4500 字)の 2 フィールドを許可する。`AgentExperienceContext` に `description` / `is_it_company` を追加し、non-IT 企業(`is_it_company=false`)では `description` が職務本文になることを LLM が分岐判断できるようにする。 + +target union は `ProjectTarget | ExperienceTarget` とし `ExperienceTarget(extra="forbid")` を追加した。`extra="forbid"` により ProjectTarget の 3 キー payload が ExperienceTarget にマッチしない。Phase 1 の project 契約(3 キー必須)を後退させない。 + +**GitHub/ブログ参照コンテキストの付与スコープ** + +`career_summary` / `self_pr` のみに付与する。`project` / `experience` には付与しない。理由: + +- project/experience は特定の案件・企業の記述を改善する用途であり、GitHub/ブログ情報は文脈として不適切(捏造リスク・トークン浪費) +- career_summary/self_pr はキャリア全体像を表す文章であり、GitHub の言語傾向・ブログの執筆頻度・記事タイトルは LLM が根拠として参照できる + +**degrade 方針** + +GitHub・ブログ参照データの取得失敗(未連携・processing 中・0 件・DB 例外)はいずれも `None` に degrade し、チャット本体を落とさない。`build_reference_context` と各ヘルパーが独立に `try/except Exception` + `logger.warning(exc_info=True)` でラップする。 + +**圧縮の実装値(ADR コスト設計の具体化)** + +| 項目 | 上限 | +|---|---| +| `languages_top5` | 上位 5 言語(バイト数 → 割合 % に変換) | +| `contributions_by_year` | 直近 5 年分(weeks は捨てる) | +| `active_days_last_12_months` | 直近 365 日の count > 0 日数 | +| `recent_articles` | 直近 5 件(タイトル・タグ先頭 5 個・published_at のみ) | **Phase 3(将来)** diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index cbe2b714..62b8574b 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -20,6 +20,7 @@ export interface paths { * Agent Chat * @description 選択スコープの内容とプロンプトをもとに、職務経歴書への差分 operations を返す。 * + * career_summary / self_pr スコープでは GitHub・ブログ分析サマリーを参照情報として付与する。 * レスポンスはフロントの state にのみ適用され、DB は更新しない。 * ユーザーが確認して「適用」した時点で既存の保存 API が呼ばれる。 */ @@ -731,8 +732,9 @@ export interface components { * Scope * @enum {string} */ - scope: "project" | "career_summary" | "self_pr"; - target?: components["schemas"]["ProjectTarget"] | null; + scope: "project" | "career_summary" | "self_pr" | "experience"; + /** Target */ + target?: components["schemas"]["ProjectTarget"] | components["schemas"]["ExperienceTarget"] | null; }; /** * AgentChatResponse @@ -781,6 +783,16 @@ export interface components { * @default */ company: string; + /** + * Description + * @default + */ + description: string; + /** + * Is It Company + * @default true + */ + is_it_company: boolean; }; /** * AgentHistoryEntry @@ -1263,6 +1275,17 @@ export interface components { */ start_date: string; }; + /** + * ExperienceTarget + * @description scope=experience のとき対象在籍企業を特定するインデックス。 + * + * ``extra="forbid"`` により ProjectTarget の 3 キー payload は + * ExperienceTarget にマッチしない(union で型を決定的に区別するため)。 + */ + ExperienceTarget: { + /** Experience Index */ + experience_index: number; + }; /** GitHubCallbackRequest */ GitHubCallbackRequest: { /** Code */ diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f3bf24d6..564a58ac 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -138,3 +138,6 @@ export type AgentResumeContext = Schemas["AgentResumeContext"]; /** project スコープの対象指定。backend `schemas/agent.py:ProjectTarget`。 */ export type ProjectTarget = Schemas["ProjectTarget"]; + +/** experience スコープの対象指定。backend `schemas/agent.py:ExperienceTarget`。 */ +export type ExperienceTarget = Schemas["ExperienceTarget"]; diff --git a/frontend/src/components/forms/AgentChatWidget.tsx b/frontend/src/components/forms/AgentChatWidget.tsx index 59bce9dc..4cbb3f64 100644 --- a/frontend/src/components/forms/AgentChatWidget.tsx +++ b/frontend/src/components/forms/AgentChatWidget.tsx @@ -2,14 +2,14 @@ * Agent チャットウィジェット(ADR-0010)。 * * 職務経歴書フォーム右下のフローティングボタンからチャットパネルを開き、 - * スコープ(職務要約 / 自己PR / プロジェクト)を選んで AI に改善を依頼する。 + * スコープ(職務要約 / 自己PR / 職務経歴 / プロジェクト)を選んで AI に改善を依頼する。 * AI 応答の operations は「フォームに反映」でフォーム state にのみ適用され、 * 保存は既存の保存ボタン(保存 API)をユーザーが明示的に実行する。 */ import { useCallback, useMemo, useState } from "react"; -import type { ProjectTarget } from "../../api/types"; +import type { ExperienceTarget, ProjectTarget } from "../../api/types"; import { AGENT_MESSAGES } from "../../constants/messages"; import { useAgentChat, type AgentChatEntry } from "../../hooks/career/useAgentChat"; import type { CareerFormState } from "../../payloadBuilders"; @@ -29,6 +29,9 @@ type Props = { /** project スコープで選択できる候補(フォーム state の index で特定する)。 */ type ProjectOption = { label: string; target: ProjectTarget }; +/** experience スコープで選択できる候補(フォーム state の index で特定する)。 */ +type ExperienceOption = { label: string; target: ExperienceTarget }; + /** パネルのリサイズ範囲。右下固定のため左上方向にだけ広がる */ const PANEL_MIN_WIDTH = 320; const PANEL_MIN_HEIGHT = 360; @@ -83,10 +86,18 @@ function buildProjectOptions(form: CareerFormState): ProjectOption[] { return options; } +function buildExperienceOptions(form: CareerFormState): ExperienceOption[] { + return form.experiences.map((exp, ei) => ({ + label: exp.company || AGENT_MESSAGES.TARGET_UNNAMED, + target: { experience_index: ei }, + })); +} + 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 [projectTargetIndex, setProjectTargetIndex] = useState(0); + const [experienceTargetIndex, setExperienceTargetIndex] = useState(0); const [prompt, setPrompt] = useState(""); /** ドラッグでリサイズされた寸法。null の間は CSS のデフォルトサイズに従う */ const [panelSize, setPanelSize] = useState<{ width: number; height: number } | null>(null); @@ -95,9 +106,23 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin } useMessageToast(error, "error"); const projectOptions = useMemo(() => buildProjectOptions(form), [form]); - const selectedTarget = projectOptions[targetIndex]?.target ?? null; + const experienceOptions = useMemo(() => buildExperienceOptions(form), [form]); + + const selectedProjectTarget = projectOptions[projectTargetIndex]?.target ?? null; + const selectedExperienceTarget = experienceOptions[experienceTargetIndex]?.target ?? null; + + /** 送信時に渡す target(スコープに応じて選択) */ + function getTarget(): ProjectTarget | ExperienceTarget | null { + if (scope === "project") return selectedProjectTarget; + if (scope === "experience") return selectedExperienceTarget; + return null; + } + const canSend = - !sending && prompt.trim().length > 0 && (scope !== "project" || selectedTarget !== null); + !sending && + prompt.trim().length > 0 && + (scope !== "project" || selectedProjectTarget !== null) && + (scope !== "experience" || selectedExperienceTarget !== null); const handleOpen = () => { if (!isAuthenticated) { @@ -110,17 +135,20 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin } const handleSend = () => { if (!canSend) return; clearError(); - void send(form, scope, scope === "project" ? selectedTarget : null, prompt.trim()); + void send(form, scope, getTarget(), prompt.trim()); setPrompt(""); }; /** suggestions ボタンの送信可否(自由入力と違い入力テキストは不要) */ - const canSendSuggestion = !sending && (scope !== "project" || selectedTarget !== null); + const canSendSuggestion = + !sending && + (scope !== "project" || selectedProjectTarget !== null) && + (scope !== "experience" || selectedExperienceTarget !== null); const handleSuggestion = (suggestion: string) => { if (!canSendSuggestion) return; clearError(); - void send(form, scope, scope === "project" ? selectedTarget : null, suggestion); + void send(form, scope, getTarget(), suggestion); }; // パネル左上のハンドルをドラッグしてリサイズする。パネルは右下固定なので @@ -206,9 +234,30 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin } > + + {scope === "experience" && + (experienceOptions.length === 0 ? ( +

{AGENT_MESSAGES.TARGET_EXPERIENCE_EMPTY}

+ ) : ( + + ))} {scope === "project" && (projectOptions.length === 0 ? (

{AGENT_MESSAGES.TARGET_EMPTY}

@@ -217,8 +266,8 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin } {AGENT_MESSAGES.TARGET_LABEL}