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
309 changes: 0 additions & 309 deletions .report/refactoring-plan-20260612.md

This file was deleted.

6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.PHONY: help \
setup install-hooks install-backend install-frontend generate-keys \
dev dev-build dev-down dev-frontend preview-frontend dev-proxy dev-proxy-only \
dev dev-build dev-down dev-frontend preview-frontend dev-proxy dev-proxy-only stripe-webhook \
test test-backend test-frontend \
lint lint-backend lint-frontend lint-frontend-messages lint-fix \
format format-check \
Expand Down Expand Up @@ -29,6 +29,7 @@ help:
@echo " dev-down docker-compose を停止"
@echo " dev-frontend Frontend 開発サーバーを起動 (Vite / localhost:5173)"
@echo " preview-frontend ビルド済みを wrangler でローカル提供 (HMR なし / localhost:8788)"
@echo " stripe-webhook Stripe Webhook を localhost:8000 へ転送 (要 stripe login / whsec を .env へ)"
@echo ""
@echo "テスト・リント"
@echo " ci lint + test + build-frontend を一括実行 (CI 相当)"
Expand Down Expand Up @@ -101,6 +102,9 @@ dev-build:
dev-down:
docker compose down

stripe-webhook:
nix develop --command stripe listen --forward-to localhost:8000/api/billing/webhook

dev-frontend:
nix develop --command bash -c "cd frontend && npm run dev"

Expand Down
6 changes: 6 additions & 0 deletions backend/app/prompts/agent_base.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@
- 提案が不要・不可能な場合は operations を空配列にし、message で理由を説明する
- operations で提案を返すときは suggestions を空配列にする(同時に出さない)

# 会話履歴の活用
- これまでの会話(user / assistant のやり取り)を踏まえて今回の依頼を解釈する。会話は積み重ねであり、毎ターンを独立した初回依頼として扱わない。
- 直前の assistant 応答で suggestions(依頼候補)を提示しており、今回の user 依頼がそのいずれか、または「何を・どう変えるか」が読み取れる具体的な指示である場合は、曖昧依頼として扱わず operations を生成する。
- 同じ suggestions を繰り返し返さない。ユーザーが選択肢を選んだ/具体的に指示したら、確認に留めず実際の改善案(operations)を返す。

# 曖昧な依頼への応答(最優先ルール)
依頼に「何を・どう変えるか」が含まれない場合(例: 「いい感じにして」「良くして」「改善して」「お任せ」)、**operations を生成してはいけない**。
(「300字に要約して」「成果を強調して書き直して」のように何をどう変えるかが読み取れる依頼は曖昧ではない。operations を生成すること。)
代わりに operations を空配列にし、message で意図を確認しつつ、suggestions に**具体的な依頼文の候補を 2〜4 個**入れる。候補はユーザーがそのまま次の依頼として送れる命令形の日本語にする。

曖昧な依頼「いい感じにして」への正しい応答例(各フィールドに設定する値):
Expand Down
20 changes: 17 additions & 3 deletions backend/app/routers/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from ..services.agent.chat_service import (
AgentResponseParseError,
AgentTargetNotFoundError,
AgentUsage,
)
from ..services.agent.context_builder import build_reference_context
from ..services.agent.llm.base import LLMError
Expand All @@ -32,6 +33,19 @@
router = APIRouter(prefix="/api/agent", tags=["agent"])


def _record_usage_after_llm(db: Session, user_id: str, usage: AgentUsage) -> None:
"""LLM 応答後のクレジット消費・使用ログ記録を、ストリームを開き直してから行う。

LLM 呼び出しの await 中にリクエストの DB セッションがアイドルになり、libSQL
(Hrana over HTTP)のストリームが idle timeout で失効する。失効したまま commit
すると `STREAM_EXPIRED` で 400 → 500 になり、課金記録も落ちる。`db.close()` で
失効ストリームを解放しておけば、record_chat_usage 内の次の SELECT/commit が
新しいコネクション(=新規 Hrana ストリーム)を取得して正常に確定できる。
"""
db.close()
credit_service.record_chat_usage(db, user_id, usage)


@router.post("/chat", response_model=AgentChatResponse)
@limiter.limit("10/minute")
async def agent_chat(
Expand Down Expand Up @@ -72,7 +86,7 @@ async def agent_chat(
# 本来の LLM 失敗(502)を優先して返す
if exc.usage is not None:
try:
credit_service.record_chat_usage(db, user.id, exc.usage)
_record_usage_after_llm(db, user.id, exc.usage)
except Exception:
logger.error("LLM 失敗時のクレジット消費記録に失敗", exc_info=True)
raise_app_error(
Expand All @@ -86,7 +100,7 @@ async def agent_chat(
# ログに残し、本来のパース失敗(502)を優先して返す
if exc.usage is not None:
try:
credit_service.record_chat_usage(db, user.id, exc.usage)
_record_usage_after_llm(db, user.id, exc.usage)
except Exception:
logger.error("パース失敗時のクレジット消費記録に失敗", exc_info=True)
raise_app_error(
Expand All @@ -96,5 +110,5 @@ async def agent_chat(
)
# 実トークン量に基づくクレジット消費 + 使用ログ記録(haiku はログのみ)。
# 記録失敗は応答を返さず 500 にする(課金漏れを黙って通さない / ADR-0012)
credit_service.record_chat_usage(db, user.id, result.usage)
_record_usage_after_llm(db, user.id, result.usage)
return result.response
4 changes: 3 additions & 1 deletion backend/app/services/agent/context_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ def _build_blog_context(db: Session, user_id: str) -> dict | None:
{
"title": a.title,
"tags": a.tags[:_RECENT_ARTICLE_TAGS_N],
"published_at": a.published_at.isoformat() if a.published_at else None,
# published_at は format_iso_date 済みの str(または None)を返すプロパティ。
# ここで .isoformat() を呼ぶと str に対する呼び出しで AttributeError になる。
"published_at": a.published_at,
}
for a in articles[:_RECENT_ARTICLES_N]
]
Expand Down
41 changes: 41 additions & 0 deletions backend/tests/test_agent_context_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,47 @@ def test_blog_context_recent_articles_limit(db_session) -> None:
assert "avg_monthly_posts" in blog


def test_blog_context_published_at_is_iso_string(db_session) -> None:
"""published_at が設定された記事でも例外を出さず ISO 文字列で返す(二重変換の回帰防止)。

BlogArticle.published_at は format_iso_date 済みの str を返すプロパティ。
以前は context_builder が `.isoformat()` を二重に呼び AttributeError で
ブログコンテキスト全体が degrade していた。
"""
user = _make_user(db_session, "blog_pub")
account = BlogAccount(user_id=user.id, platform="zenn", username="blog_pub")
db_session.add(account)
db_session.flush()
# 1 件目は published_at あり、2 件目は None(どちらの分岐も検証する)
article_with_date = BlogArticle(
account_id=account.id,
external_id="with-date",
title="技術記事 日付あり",
url="https://zenn.dev/blog_pub/1",
likes_count=3,
published_at_value=date(2026, 1, 15),
)
article_without_date = BlogArticle(
account_id=account.id,
external_id="without-date",
title="技術記事 日付なし",
url="https://zenn.dev/blog_pub/2",
likes_count=1,
)
db_session.add_all([article_with_date, article_without_date])
db_session.flush()
db_session.add(BlogArticleTag(article_id=article_with_date.id, sort_order=0, name="Python"))
db_session.add(BlogArticleTag(article_id=article_without_date.id, sort_order=0, name="Python"))
db_session.commit()

result = build_reference_context(db_session, user.id, "career_summary")
assert result is not None
recent = result["blog_context"]["recent_articles"]
published_values = {a["title"]: a["published_at"] for a in recent}
assert published_values["技術記事 日付あり"] == "2026-01-15"
assert published_values["技術記事 日付なし"] is None


def test_blog_context_no_articles_returns_none(db_session) -> None:
"""記事 0 件は blog_context が省略され None になる。"""
user = _make_user(db_session, "blog_empty")
Expand Down
51 changes: 51 additions & 0 deletions backend/tests/test_billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,57 @@ def test_chat_sonnet_consumes_credits(client: TestClient, monkeypatch) -> None:
assert consumption[0].balance_after == 10_000 - 5


def test_chat_refreshes_session_before_recording_usage(
client: TestClient, monkeypatch
) -> None:
"""LLM 応答後、消費記録の前に DB セッションを開き直す(Hrana STREAM_EXPIRED 回帰防止)。

libSQL(Hrana over HTTP)は LLM の await 中に idle stream が失効するため、消費記録の
commit 前に ``db.close()`` で失効ストリームを解放し、新規ストリームで commit させる。
本テストは「close が record_chat_usage より前に呼ばれる」順序と、消費が正しく確定する
ことを検証する。
"""
fake = _FakeLLM(
response=_llm_json("self_pr", "改善した自己PR"),
input_tokens=1000,
output_tokens=1000,
)
monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake)
headers = auth_header(client, "billing-refresh")
user_id = _get_user_id(client, "billing-refresh")
credit_service.grant_credits(
client._db_session,
user_id,
10_000,
transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT,
)

events: list[str] = []
original_close = client._db_session.close
monkeypatch.setattr(
client._db_session,
"close",
lambda *a, **kw: (events.append("close"), original_close(*a, **kw))[1],
)
import app.routers.agent as agent_router

original_record = credit_service.record_chat_usage

def _spy_record(*a, **kw):
events.append("record")
return original_record(*a, **kw)

monkeypatch.setattr(agent_router.credit_service, "record_chat_usage", _spy_record)

resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers)

assert resp.status_code == 200
# close が record より前に呼ばれている
assert events == ["close", "record"]
# セッション刷新後も消費が正しく確定している
assert BillingRepository(client._db_session, user_id).get_balance() == 10_000 - 5


def test_chat_sonnet_with_zero_balance_returns_402(client: TestClient, monkeypatch) -> None:
"""sonnet は残高 0 で 402 INSUFFICIENT_CREDITS。LLM は呼ばれない。"""
fake = _FakeLLM(response=_llm_json("self_pr", "提案"))
Expand Down
2 changes: 2 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
# --- ミドルウェア ---
redis # Redis 7(ローカル開発用)
turso-cli # Turso (libSQL) CLI(ローカル開発用)
stripe-cli # Stripe CLI(Webhook 転送 / ローカル決済確認用)

# --- IaC ---
opentofu # OpenTofu CLI(Terraform 互換 / インフラ管理)
Expand Down Expand Up @@ -78,6 +79,7 @@
echo " Redis : $(redis-server --version)"
echo " tofu : $(tofu --version | head -1)"
echo " Turso : $(turso --version 2>/dev/null | head -1)"
echo " Stripe : $(stripe version 2>/dev/null | head -1)"
echo " gh : $(gh --version | head -1)"
echo ""
echo "セットアップ: make setup"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/forms/AgentChatWidget.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@
.actionButton {
padding: 4px 10px;
border: 1px solid #059669;
border-radius: 999px;
border-radius: 6px;
background: transparent;
color: #059669;
font-size: 0.78rem;
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/hooks/career/useAgentChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,45 @@ describe("useAgentChat", () => {
text: JSON.stringify({
message: "提案です",
operations: [{ field: "self_pr", value: "改善案" }],
suggestions: [],
}),
},
],
}),
);
});

it("suggestions を含む応答は history の assistant text にも suggestions を載せる", async () => {
// 1 往復目: 曖昧依頼に対し選択肢を返す(operations は空)
postAgentChatMock.mockResolvedValueOnce({
message: "どの方向で改善しますか?",
operations: [],
suggestions: ["300字に要約して", "成果を強調して書き直して"],
});
// 2 往復目(選択肢を選んで再送)
postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] });
const { result } = renderHook(() => useAgentChat());

await act(async () => {
await result.current.send(form, "self_pr", null, "改善して");
});
await act(async () => {
await result.current.send(form, "self_pr", null, "300字に要約して");
});

// 選択肢を選んだ次の送信時、history の assistant エントリに suggestions が含まれ、
// LLM が「前ターンで選択肢を提示した」文脈を受け取れる(選択肢ループ回帰防止)
expect(postAgentChatMock).toHaveBeenLastCalledWith(
expect.objectContaining({
prompt: "300字に要約して",
history: [
{ role: "user", text: "改善して" },
{
role: "assistant",
text: JSON.stringify({
message: "どの方向で改善しますか?",
operations: [],
suggestions: ["300字に要約して", "成果を強調して書き直して"],
}),
},
],
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/hooks/career/useAgentChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,14 @@ export function useAgentChat() {
suggestions: response.suggestions?.length ? response.suggestions : null,
scope,
target,
// 応答 JSON の原文を履歴用に保持する(出力形式の実例としても機能する)
// 応答 JSON の原文を履歴用に保持する(出力形式の実例としても機能する)。
// suggestions も含めることで、次ターンの LLM が「前ターンで選択肢を提示し、
// ユーザーがその 1 つを選んだ」文脈を認識でき、選択肢ループから operations へ
// 収束しやすくなる(suggestions を落とすと曖昧依頼と再判定され提案が続く)
historyText: JSON.stringify({
message: response.message,
operations: response.operations ?? [],
suggestions: response.suggestions ?? [],
}),
},
]);
Expand Down
Loading