Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
603a16d
chore: .gitignore에 claude 폴더 추가
gyesswhat May 17, 2026
dbb17ae
chore: tagging 도메인 enum 및 요청/응답 스키마 추가
gyesswhat May 17, 2026
0503d62
chore: 태그 화이트리스트 및 카테고리 매핑 데이터 모듈 추가
gyesswhat May 17, 2026
30b517a
feat: tagging v1_baseline 시스템 프롬프트 추가
gyesswhat May 17, 2026
7c43766
chore: tagging_variant 설정 필드 추가
gyesswhat May 17, 2026
561357a
feat: tagging v1_baseline 서비스 구현 (corrective 재시도 포함)
gyesswhat May 17, 2026
82ce14a
feat: POST /v1/tagging 실 구현 라우터 추가
gyesswhat May 17, 2026
ff7620c
feat: POST /api/tagging 안정 경로 더미 라우터 추가
gyesswhat May 17, 2026
dec59da
test: tagging 라우터/서비스 통합 테스트 추가
gyesswhat May 17, 2026
be0251f
chore: tagging v1_baseline 평가 fixture/스크립트 추가
gyesswhat May 17, 2026
78cf877
test: 테스트 결과 json 추가
gyesswhat May 17, 2026
5685233
feat: tagging 평가 결과 .md 자동 생성 (템플릿 + 마커 upsert)
gyesswhat May 17, 2026
a73cb72
test: v1 테스팅 결과 기록
gyesswhat May 18, 2026
07c530e
feat: tagging v2_postscore variant — strip 확장 + 가중치 후처리 통합
gyesswhat May 18, 2026
df8bb60
test: v2 테스트 결과 및 분석 md
gyesswhat May 18, 2026
3fdcd26
style: ruff format 적용 (v2_postscore)
gyesswhat May 18, 2026
998316d
fix: get_llm_client assert를 RuntimeError로 교체 (코드래빗 리뷰 반영)
gyesswhat May 18, 2026
172bfeb
fix: tagging_variant Literal로 부팅 시점 검증 (코드래빗 리뷰 반영)
gyesswhat May 18, 2026
031b238
fix: v1 프롬프트 1패스 치환 + STAR 본문 경계화 (코드래빗 리뷰 반영)
gyesswhat May 18, 2026
7e6254e
fix: v1 카테고리별 허용 태그 셋으로 경계 검증 (코드래빗 리뷰 반영)
gyesswhat May 18, 2026
910aa51
fix: v2 프롬프트 1패스 치환 + STAR 본문 경계화 (코드래빗 리뷰 반영)
gyesswhat May 18, 2026
f6d911b
fix: v2 카테고리별 허용 태그 셋으로 후보 경계 검증 (코드래빗 리뷰 반영)
gyesswhat May 18, 2026
3d4459e
style: ruff format 적용 (v1_baseline)
gyesswhat May 18, 2026
0284dd5
Merge branch 'main' into feat/#7-tagging-v1
gyesswhat May 18, 2026
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ INTERNAL_API_TOKEN=change-me-to-a-random-32-byte-token
# ---- AI provider ----
ANTHROPIC_API_KEY=sk-ant-
ANTHROPIC_MODEL=claude-haiku-4-5-20251001

# ---- Tagging ----
# 활성 프롬프트/구현 버전 (app/prompts/tagging/ 하위 .md 파일명과 일치).
# 실험 시에는 본인 .env 만 바꾸고, 이 기본값은 함부로 변경하지 말 것.
TAGGING_VARIANT=v1_baseline
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ logs/

# OMC (oh-my-claudecode local state — 개인 작업 파일이므로 레포에 올리지 않음)
.omc/

# Claude
.claude
46 changes: 46 additions & 0 deletions app/api/tagging_public.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""POST /api/tagging — Spring 이 호출할 안정 경로 (현재 더미 응답).

이 라우터는 백엔드와 합의된 최종 endpoint 의 자리를 미리 잡아두기 위한 것이다.
``/v1/tagging`` 의 v1_baseline 이 검증을 마치면 **이 핸들러 본문만** service 호출로
교체하는 cutover 가 일어난다. 그동안 Spring 측은 이 경로로 연동을 진행할 수 있다
(요청·응답 스키마는 동일, 응답 내용만 고정 더미).

라우터 책임:
- 요청 스키마 검증 (FastAPI 가 ``TaggingRequest`` 로 처리)
- ``X-Internal-Token`` 인증 (라우터 전역 dependency)
- 고정 더미 응답 반환 (LLM 호출 없음)
"""

from __future__ import annotations

from fastapi import APIRouter, Depends

from app.core.security import require_internal_token
from app.schemas.tagging import TaggingRequest, TaggingResponse

router = APIRouter(
prefix="/api",
tags=["tagging-public"],
dependencies=[Depends(require_internal_token)],
)


@router.post(
"/tagging",
summary="STAR 심화 기록 세부 역량 태그 추출 (안정 경로 · 현재 더미)",
description=(
"Spring 이 호출하기로 합의한 최종 경로. 현재는 요청 스키마 검증만 통과시키고 "
"고정 더미 JSON 을 돌려준다. v1_baseline 검증 후 cutover 시 이 핸들러 본문을 "
"``service.tagging.run()`` 호출로 교체한다."
),
response_model=TaggingResponse,
response_model_by_alias=True,
)
async def tagging_public(req: TaggingRequest) -> TaggingResponse:
# TODO(cutover): 이 본문을 app/api/v1/tagging.py 의 tagging_v1 과 동일한 흐름
# (LLMClient dependency 주입 → service.tagging.run() 호출 → LLM/검증 예외 →
# HTTP 매핑) 으로 교체. v1 라우터의 패턴을 그대로 복붙하면 된다.
return TaggingResponse(
primary_category=req.selected_competency,
detail_tags=["#문제해결"],
)
3 changes: 2 additions & 1 deletion app/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from fastapi import APIRouter, Depends

from app.api.v1 import ping
from app.api.v1 import ping, tagging
from app.core.security import require_internal_token

router = APIRouter(
Expand All @@ -19,3 +19,4 @@

# Feature 라우터 등록 — 새 기능은 한 줄씩 여기에 추가.
router.include_router(ping.router)
router.include_router(tagging.router)
105 changes: 105 additions & 0 deletions app/api/v1/tagging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""POST /v1/tagging — STAR 심화 기록에 대한 세부 역량 태그 추출 (실험 경로).

cutover 전 실험·검증용 경로. Spring 이 호출하기로 한 안정 경로는 ``POST /api/tagging``
(별도 라우터, 더미 응답) 이며, v1_baseline 이 확정되면 안정 경로의 본문을 이쪽 service
호출로 교체하는 cutover 가 일어난다.

라우터 책임:
- 요청 스키마 검증 (FastAPI 가 ``TaggingRequest`` 로 처리)
- ``LLMClient`` 싱글턴을 ``request.state`` 에서 꺼내 service 호출
- 도메인 예외 → HTTP 상태코드 매핑
- 실패 단계만 ``tagging.failed`` 로 로깅 (성공 로그는 service 가 찍음)
- STAR 본문은 절대 로그/응답에 echo 하지 않는다.
"""

from __future__ import annotations

from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Request, status

from app.core.config import get_settings
from app.core.logging import get_logger
from app.schemas.tagging import TaggingRequest, TaggingResponse
from app.services._clients.exceptions import (
LLMAuthError,
LLMBadRequestError,
LLMRateLimitedError,
LLMUpstreamUnavailableError,
)
from app.services._clients.llm_client import LLMClient
from app.services.tagging import run as tagging_run
from app.services.tagging.exceptions import TaggingValidationError

logger = get_logger(__name__)

router = APIRouter(tags=["tagging"])


def get_llm_client(request: Request) -> LLMClient:
"""lifespan 에서 yield 한 ``LLMClient`` 싱글턴을 dependency 로 노출한다.

main.py 의 ``lifespan`` 이 ``yield {"llm_client": llm_client}`` 로 starlette ASGI
lifespan state 에 박아 두면, 각 요청의 ``request.state.llm_client`` 로 접근 가능.
이 함수는 그 접근을 한 곳으로 모아 두어 테스트 시 ``dependency_overrides`` 로 mock
주입을 가능케 한다.
"""
client = getattr(request.state, "llm_client", None)
if not isinstance(client, LLMClient):
raise RuntimeError("lifespan 에서 llm_client 가 올바르게 주입되어야 한다")
return client


@router.post(
"/tagging",
summary="STAR 심화 기록 세부 역량 태그 추출 (실험 경로)",
description=(
"v1_baseline 프롬프트로 LLM 을 호출해 66개 태그 풀에서 1~3개를 추출한다. "
"Spring 이 호출할 안정 경로는 ``/api/tagging`` (별도). 이 경로는 cutover 전 검증용."
),
response_model=TaggingResponse,
response_model_by_alias=True,
)
async def tagging_v1(
req: TaggingRequest,
llm: Annotated[LLMClient, Depends(get_llm_client)],
) -> TaggingResponse:
settings = get_settings()
log_ctx = {
"job_role": req.job_role.value,
"primary_category": req.selected_competency.value,
}
try:
return await tagging_run(req, llm, settings.anthropic_model)
except TaggingValidationError as exc:
logger.warning(
"tagging.failed",
extra={**log_ctx, "stage": "validation", "reason": str(exc)},
)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"code": "TAG_EXTRACTION_FAILED",
"message": "두 번의 시도 모두 응답이 형식을 만족하지 못했습니다.",
},
) from exc
except LLMRateLimitedError as exc:
logger.warning(
"tagging.failed",
extra={**log_ctx, "stage": "llm", "error_type": "rate_limited"},
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="LLM rate limited",
) from exc
except (LLMUpstreamUnavailableError, LLMBadRequestError, LLMAuthError) as exc:
# Auth/BadRequest 도 외부에는 ``upstream unavailable`` 로 마스킹한다.
# (``LLMAuthError`` docstring 참고 — 키 노출 회피)
logger.warning(
"tagging.failed",
extra={**log_ctx, "stage": "llm", "error_type": type(exc).__name__},
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="LLM upstream unavailable",
) from exc
11 changes: 11 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ class Settings(BaseSettings):
description="사용할 Claude 모델명.",
)

# ---- Tagging ----
tagging_variant: Literal["v1_baseline", "v2_postscore"] = Field(
default="v1_baseline",
description=(
"활성 tagging 프롬프트/구현 버전. "
"``app/prompts/tagging/{variant}.md`` 와 ``app/services/tagging/{variant}.py`` 가 "
"존재해야 한다. 실험 시에는 본인 ``.env`` 만 바꾸고, 기본값은 함부로 변경 X. "
"허용값 외 문자열이 주입되면 부팅 시점에 ValidationError 로 실패한다."
),
)

@model_validator(mode="after")
def _require_secure_token_in_prod(self) -> Settings:
"""prod 환경에서 ``INTERNAL_API_TOKEN`` 이 안전한 값인지 검증한다.
Expand Down
5 changes: 4 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from fastapi import FastAPI

from app import __version__
from app.api import health
from app.api import health, tagging_public
from app.api.v1 import router as v1_router
from app.core.config import get_settings
from app.core.logging import configure_logging, get_logger
Expand Down Expand Up @@ -72,11 +72,14 @@ def create_app() -> FastAPI:
# - v1_router: 비즈니스 API. prefix="/v1" + X-Internal-Token 전역 보호.
# 새 기능은 app/api/v1/<feature>.py 만들고 app/api/v1/__init__.py 의
# include_router 목록에 한 줄 추가하는 식으로 붙인다 (여기는 건드리지 않음).
# - tagging_public: Spring 합의 안정 경로 /api/tagging. 현재 더미 응답이며
# v1_baseline 검증 후 cutover 시 핸들러 본문을 service 호출로 교체한다.
# CORS 미들웨어는 의도적으로 미포함 — 서버 간 통신이므로 불필요.
app.add_middleware(RequestContextMiddleware)

app.include_router(health.router)
app.include_router(v1_router)
app.include_router(tagging_public.router)

return app

Expand Down
135 changes: 135 additions & 0 deletions app/prompts/tagging/v1_baseline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
너는 한국어 STAR 회고 기록에서 세부 역량 태그를 추출하는 분류기다.
출력은 **정확한 JSON 한 줄**. 코드블록(```)·주석·인사말·여백 어떤 추가 텍스트도 붙이지 마라.

# 입력
- 유저 직군: {jobRole}
- 유저가 확정한 5대 직무 역량: {primaryCategory}
- S·T 단계 (Situation/Task): {situationTask}
- A 단계 (Action): {action}
- R 단계 (Result): {result}

# 작업
주어진 STAR 본문에서 **세부 역량 태그 1~3개**를 아래 "태그 풀" 안에서만 골라 출력한다.

## 선정 규칙
1. **풀 외 금지** — "태그 풀" 섹션의 태그(`#`로 시작, 공백 없음) 만 출력. 새로 만들지 않는다.
2. **개수** — 최소 1개, 최대 3개. 중복 금지.
3. **근거 기반** — 본문에 실제로 드러난 활동만 선택. 추측·일반화 금지.
4. **직군 가중치** — 아래 "직군별 친화도 표"를 참고해 우선순위 부여.
- High: 우선 고려
- Mid: 본문 근거가 명확하면 채택
- Low: 본문에 명백한 근거가 있을 때만 (가급적 회피)
5. **카테고리 무관** — 유저의 `primaryCategory` 와 다른 카테고리의 태그도 자유롭게 고를 수 있다. 풀 전체가 열려 있다.
6. **표면 키워드 ≠ 태그** — 본문에 단어가 등장한다고 그 태그를 무조건 고르지 말 것. 실제 수행한 활동인지로 판단.

# 태그 풀 (총 66개)

**발견·분석**
#트렌드리서치 #유저리서치 #기술리서치 #데이터해석 #가설검증 #시장분석 #유저인터뷰 #인사이트도출 #도메인학습 #우선순위설정 #사용성평가 #문제정의 #경쟁사례분석 #레퍼런스수집

**기획·실행**
#기획구조화 #UX설계 #서비스기획 #플로우설계 #기능구현 #프로토타입제작 #API연동 #MVP개발 #지표설계 #시각화작업 #브랜드기획 #컴포넌트구현 #인터랙션설계 #아키텍처설계 #비주얼디자인

**협업·조율**
#피드백수용 #의견조율 #팀커뮤니케이션 #직군간협업 #지식공유 #역할분담 #관계자소통 #발표및설득 #산출물전달 #피드백제공 #요구사항정의

**문제해결·개선**
#프로세스개선 #문제해결 #구조재설계 #논리보완 #불편개선 #성과개선 #원인분석 #업무자동화 #사용자테스트 #접근성개선 #반복개선 #사용자흐름개선 #검증및테스트 #디버깅 #QA테스트

**성찰·성장**
#툴활용 #프로젝트회고 #커리어설계 #팀문화기여 #업무방식개선 #자기객관화 #변화대응 #역량확장 #학습적용 #주도적기여 #성능최적화

# 직군별 친화도 표 (High / Mid / Low)

| 태그 | 기획 | 개발 | 디자인 |
| --- | --- | --- | --- |
| #트렌드리서치 | High | Mid | High |
| #유저리서치 | High | Low | High |
| #기술리서치 | Mid | High | Mid |
| #데이터해석 | High | High | Mid |
| #가설검증 | High | High | High |
| #시장분석 | High | Low | Mid |
| #유저인터뷰 | High | Low | High |
| #인사이트도출 | High | Mid | High |
| #도메인학습 | Mid | High | Mid |
| #우선순위설정 | High | Mid | Mid |
| #사용성평가 | High | Low | High |
| #문제정의 | High | Mid | High |
| #경쟁사례분석 | High | Mid | High |
| #레퍼런스수집 | High | Mid | High |
| #기획구조화 | High | Low | Low |
| #UX설계 | High | Low | High |
| #서비스기획 | High | Low | Mid |
| #플로우설계 | High | High | Mid |
| #기능구현 | Low | High | Low |
| #프로토타입제작 | Mid | Mid | High |
| #API연동 | Low | High | Low |
| #MVP개발 | Mid | High | Low |
| #지표설계 | High | High | Mid |
| #시각화작업 | Mid | Mid | High |
| #브랜드기획 | High | Low | High |
| #컴포넌트구현 | Low | High | High |
| #인터랙션설계 | Mid | Mid | High |
| #아키텍처설계 | Low | High | Low |
| #비주얼디자인 | Low | Low | High |
| #피드백수용 | High | High | High |
| #의견조율 | High | High | High |
| #팀커뮤니케이션 | High | High | High |
| #직군간협업 | High | High | High |
| #지식공유 | High | High | High |
| #역할분담 | High | Mid | Mid |
| #관계자소통 | High | Mid | Mid |
| #발표및설득 | High | Mid | Mid |
| #산출물전달 | Mid | High | High |
| #피드백제공 | High | High | High |
| #요구사항정의 | High | Mid | Mid |
| #프로세스개선 | High | High | Mid |
| #문제해결 | High | High | High |
| #구조재설계 | High | High | Mid |
| #논리보완 | High | High | High |
| #불편개선 | High | Mid | High |
| #성과개선 | High | Mid | Mid |
| #원인분석 | High | High | Mid |
| #업무자동화 | High | High | Low |
| #사용자테스트 | High | Mid | High |
| #접근성개선 | High | High | High |
| #반복개선 | High | High | High |
| #사용자흐름개선 | High | Mid | High |
| #검증및테스트 | High | High | Mid |
| #디버깅 | Low | High | Low |
| #QA테스트 | High | High | Mid |
| #툴활용 | Mid | High | High |
| #프로젝트회고 | High | High | High |
| #커리어설계 | High | High | High |
| #팀문화기여 | High | High | High |
| #업무방식개선 | High | High | High |
| #자기객관화 | High | High | High |
| #변화대응 | High | High | High |
| #역량확장 | High | High | High |
| #학습적용 | High | High | High |
| #주도적기여 | High | High | High |
| #성능최적화 | Low | High | Low |

# 출력 형식

다음 JSON 스키마 한 줄을 그대로 출력한다.

`{"detailTags": ["#태그A", "#태그B"]}`

- 키는 `detailTags` 하나뿐. 다른 키 추가 금지.
- 값은 문자열 배열. 각 원소는 `#` 으로 시작하는 풀 내 태그 정확히 그대로.
- 최소 1개, 최대 3개.
- 응답 첫 글자는 반드시 `{`, 마지막 글자는 `}`. 코드블록(```), 줄바꿈 앞뒤 텍스트, "다음과 같습니다" 같은 도입어 모두 금지.

# 좋은 예
입력: 어드민 페이지 기획을 맡아 기능명세서를 작성하고 팀원과 회의를 통해 우선순위를 정리함.
출력: `{"detailTags": ["#기획구조화", "#우선순위설정", "#팀커뮤니케이션"]}`

# 나쁜 예 (절대 하지 말 것)
- ` ```json\n{...}\n``` ` — 코드블록 감싸기
- `{"detailTags": ["기획구조화"]}` — `#` 누락
- `{"detailTags": ["#존재하지않는태그"]}` — 풀 외 태그
- `{"detailTags": []}` — 빈 배열
- `{"detailTags": ["#A","#B","#C","#D"]}` — 4개 이상
- `{"primaryCategory": "...", "detailTags": [...]}` — `primaryCategory` 추가
- `다음과 같습니다: {"detailTags": [...]}` — 도입어
Loading
Loading