Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions backend/app/prompts/agent_chat_system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
あなたは日本語の職務経歴書の改善を支援するアシスタントです。
ユーザーの依頼に基づき、編集対象フィールドの改善案を JSON で返してください。

# 出力形式(JSON のみ。前置き・コードフェンス・補足テキストは一切禁止)
{"message": "<提案の説明(日本語)>", "operations": [{"field": "<フィールド名>", "value": "<新しい本文>"}]}

# ルール
- operations の field は次のみ許可: {allowed_fields}
- 各フィールドの文字数上限: {field_limits}
- 提案が不要・不可能な場合は operations を空配列にし、message で理由を説明する
- value は職務経歴書にそのまま掲載できる完成した日本語の文章にする
- 「# 現在の内容」に書かれていない資格・技術・経歴・数値・日付を新たに作らない(事実の捏造禁止)
- 情報が不足していて改善できない場合は捏造せず、operations を空配列にして message で必要な情報をユーザーに確認する
24 changes: 20 additions & 4 deletions backend/app/schemas/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 コンテキスト用の技術スタック(保存契約より緩い)。"""
Expand Down Expand Up @@ -74,13 +71,28 @@ 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 チャットのリクエスト。スコープ選択は必須。"""

scope: AgentScope
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":
Expand All @@ -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)


Expand Down
76 changes: 51 additions & 25 deletions backend/app/services/agent/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import json
import logging
from pathlib import Path

from pydantic import ValidationError

Expand Down Expand Up @@ -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
Expand All @@ -67,17 +73,17 @@ 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,
)
project = _resolve_target_project(request)
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,
},
Expand All @@ -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("```"):
Expand All @@ -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)
Expand All @@ -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)
7 changes: 5 additions & 2 deletions backend/app/services/agent/llm/anthropic_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
# operations JSON(最大 4500 文字のテキスト置換 + 説明文)に十分な上限
_MAX_TOKENS = 4096
_TIMEOUT_SECONDS = 60.0
# 職務経歴書の改善提案は事実忠実性が最優先のため低温度に固定する
_TEMPERATURE = 0.2


class AnthropicClient(LLMClient):
Expand All @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions backend/app/services/agent/llm/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 エラー時。
Expand Down
17 changes: 11 additions & 6 deletions backend/app/services/agent/llm/ollama_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Loading
Loading