Skip to content

Commit cd64eb2

Browse files
authored
Merge pull request #10 from 2026-KUSITMS-GLIT/feat/#7-tagging-v1
feat: 세부 역량 태그 추출 v1_baseline 구현 + v2_postscore variant 검증 (#7)
2 parents f36ec0e + 0284dd5 commit cd64eb2

25 files changed

Lines changed: 3135 additions & 3 deletions

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,8 @@ REPORT_VARIANT=v1_baseline
2121
# ---- AI provider ----
2222
ANTHROPIC_API_KEY=sk-ant-
2323
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
24+
25+
# ---- Tagging ----
26+
# 활성 프롬프트/구현 버전 (app/prompts/tagging/ 하위 .md 파일명과 일치).
27+
# 실험 시에는 본인 .env 만 바꾸고, 이 기본값은 함부로 변경하지 말 것.
28+
TAGGING_VARIANT=v1_baseline

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,6 @@ logs/
5959

6060
# OMC (oh-my-claudecode local state — 개인 작업 파일이므로 레포에 올리지 않음)
6161
.omc/
62+
63+
# Claude
64+
.claude

app/api/tagging_public.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""POST /api/tagging — Spring 이 호출할 안정 경로 (현재 더미 응답).
2+
3+
이 라우터는 백엔드와 합의된 최종 endpoint 의 자리를 미리 잡아두기 위한 것이다.
4+
``/v1/tagging`` 의 v1_baseline 이 검증을 마치면 **이 핸들러 본문만** service 호출로
5+
교체하는 cutover 가 일어난다. 그동안 Spring 측은 이 경로로 연동을 진행할 수 있다
6+
(요청·응답 스키마는 동일, 응답 내용만 고정 더미).
7+
8+
라우터 책임:
9+
- 요청 스키마 검증 (FastAPI 가 ``TaggingRequest`` 로 처리)
10+
- ``X-Internal-Token`` 인증 (라우터 전역 dependency)
11+
- 고정 더미 응답 반환 (LLM 호출 없음)
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from fastapi import APIRouter, Depends
17+
18+
from app.core.security import require_internal_token
19+
from app.schemas.tagging import TaggingRequest, TaggingResponse
20+
21+
router = APIRouter(
22+
prefix="/api",
23+
tags=["tagging-public"],
24+
dependencies=[Depends(require_internal_token)],
25+
)
26+
27+
28+
@router.post(
29+
"/tagging",
30+
summary="STAR 심화 기록 세부 역량 태그 추출 (안정 경로 · 현재 더미)",
31+
description=(
32+
"Spring 이 호출하기로 합의한 최종 경로. 현재는 요청 스키마 검증만 통과시키고 "
33+
"고정 더미 JSON 을 돌려준다. v1_baseline 검증 후 cutover 시 이 핸들러 본문을 "
34+
"``service.tagging.run()`` 호출로 교체한다."
35+
),
36+
response_model=TaggingResponse,
37+
response_model_by_alias=True,
38+
)
39+
async def tagging_public(req: TaggingRequest) -> TaggingResponse:
40+
# TODO(cutover): 이 본문을 app/api/v1/tagging.py 의 tagging_v1 과 동일한 흐름
41+
# (LLMClient dependency 주입 → service.tagging.run() 호출 → LLM/검증 예외 →
42+
# HTTP 매핑) 으로 교체. v1 라우터의 패턴을 그대로 복붙하면 된다.
43+
return TaggingResponse(
44+
primary_category=req.selected_competency,
45+
detail_tags=["#문제해결"],
46+
)

app/api/v1/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from fastapi import APIRouter, Depends
1111

12-
from app.api.v1 import ping, report
12+
from app.api.v1 import ping, report, tagging
1313
from app.core.security import require_internal_token
1414

1515
router = APIRouter(
@@ -19,4 +19,5 @@
1919

2020
# Feature 라우터 등록 — 새 기능은 한 줄씩 여기에 추가.
2121
router.include_router(ping.router)
22+
router.include_router(tagging.router)
2223
router.include_router(report.router)

app/api/v1/tagging.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""POST /v1/tagging — STAR 심화 기록에 대한 세부 역량 태그 추출 (실험 경로).
2+
3+
cutover 전 실험·검증용 경로. Spring 이 호출하기로 한 안정 경로는 ``POST /api/tagging``
4+
(별도 라우터, 더미 응답) 이며, v1_baseline 이 확정되면 안정 경로의 본문을 이쪽 service
5+
호출로 교체하는 cutover 가 일어난다.
6+
7+
라우터 책임:
8+
- 요청 스키마 검증 (FastAPI 가 ``TaggingRequest`` 로 처리)
9+
- ``LLMClient`` 싱글턴을 ``request.state`` 에서 꺼내 service 호출
10+
- 도메인 예외 → HTTP 상태코드 매핑
11+
- 실패 단계만 ``tagging.failed`` 로 로깅 (성공 로그는 service 가 찍음)
12+
- STAR 본문은 절대 로그/응답에 echo 하지 않는다.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from typing import Annotated
18+
19+
from fastapi import APIRouter, Depends, HTTPException, Request, status
20+
21+
from app.core.config import get_settings
22+
from app.core.logging import get_logger
23+
from app.schemas.tagging import TaggingRequest, TaggingResponse
24+
from app.services._clients.exceptions import (
25+
LLMAuthError,
26+
LLMBadRequestError,
27+
LLMRateLimitedError,
28+
LLMUpstreamUnavailableError,
29+
)
30+
from app.services._clients.llm_client import LLMClient
31+
from app.services.tagging import run as tagging_run
32+
from app.services.tagging.exceptions import TaggingValidationError
33+
34+
logger = get_logger(__name__)
35+
36+
router = APIRouter(tags=["tagging"])
37+
38+
39+
def get_llm_client(request: Request) -> LLMClient:
40+
"""lifespan 에서 yield 한 ``LLMClient`` 싱글턴을 dependency 로 노출한다.
41+
42+
main.py 의 ``lifespan`` 이 ``yield {"llm_client": llm_client}`` 로 starlette ASGI
43+
lifespan state 에 박아 두면, 각 요청의 ``request.state.llm_client`` 로 접근 가능.
44+
이 함수는 그 접근을 한 곳으로 모아 두어 테스트 시 ``dependency_overrides`` 로 mock
45+
주입을 가능케 한다.
46+
"""
47+
client = getattr(request.state, "llm_client", None)
48+
if not isinstance(client, LLMClient):
49+
raise RuntimeError("lifespan 에서 llm_client 가 올바르게 주입되어야 한다")
50+
return client
51+
52+
53+
@router.post(
54+
"/tagging",
55+
summary="STAR 심화 기록 세부 역량 태그 추출 (실험 경로)",
56+
description=(
57+
"v1_baseline 프롬프트로 LLM 을 호출해 66개 태그 풀에서 1~3개를 추출한다. "
58+
"Spring 이 호출할 안정 경로는 ``/api/tagging`` (별도). 이 경로는 cutover 전 검증용."
59+
),
60+
response_model=TaggingResponse,
61+
response_model_by_alias=True,
62+
)
63+
async def tagging_v1(
64+
req: TaggingRequest,
65+
llm: Annotated[LLMClient, Depends(get_llm_client)],
66+
) -> TaggingResponse:
67+
settings = get_settings()
68+
log_ctx = {
69+
"job_role": req.job_role.value,
70+
"primary_category": req.selected_competency.value,
71+
}
72+
try:
73+
return await tagging_run(req, llm, settings.anthropic_model)
74+
except TaggingValidationError as exc:
75+
logger.warning(
76+
"tagging.failed",
77+
extra={**log_ctx, "stage": "validation", "reason": str(exc)},
78+
)
79+
raise HTTPException(
80+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
81+
detail={
82+
"code": "TAG_EXTRACTION_FAILED",
83+
"message": "두 번의 시도 모두 응답이 형식을 만족하지 못했습니다.",
84+
},
85+
) from exc
86+
except LLMRateLimitedError as exc:
87+
logger.warning(
88+
"tagging.failed",
89+
extra={**log_ctx, "stage": "llm", "error_type": "rate_limited"},
90+
)
91+
raise HTTPException(
92+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
93+
detail="LLM rate limited",
94+
) from exc
95+
except (LLMUpstreamUnavailableError, LLMBadRequestError, LLMAuthError) as exc:
96+
# Auth/BadRequest 도 외부에는 ``upstream unavailable`` 로 마스킹한다.
97+
# (``LLMAuthError`` docstring 참고 — 키 노출 회피)
98+
logger.warning(
99+
"tagging.failed",
100+
extra={**log_ctx, "stage": "llm", "error_type": type(exc).__name__},
101+
)
102+
raise HTTPException(
103+
status_code=status.HTTP_502_BAD_GATEWAY,
104+
detail="LLM upstream unavailable",
105+
) from exc

app/core/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ class Settings(BaseSettings):
7171
description="사용할 Claude 모델명.",
7272
)
7373

74+
# ---- Tagging ----
75+
tagging_variant: Literal["v1_baseline", "v2_postscore"] = Field(
76+
default="v1_baseline",
77+
description=(
78+
"활성 tagging 프롬프트/구현 버전. "
79+
"``app/prompts/tagging/{variant}.md`` 와 ``app/services/tagging/{variant}.py`` 가 "
80+
"존재해야 한다. 실험 시에는 본인 ``.env`` 만 바꾸고, 기본값은 함부로 변경 X. "
81+
"허용값 외 문자열이 주입되면 부팅 시점에 ValidationError 로 실패한다."
82+
),
83+
)
84+
7485
@model_validator(mode="after")
7586
def _require_secure_token_in_prod(self) -> Settings:
7687
"""prod 환경에서 ``INTERNAL_API_TOKEN`` 이 안전한 값인지 검증한다.

app/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from fastapi import FastAPI
1616

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

7881
app.include_router(health.router)
7982
app.include_router(report_public.router)
8083
app.include_router(v1_router)
84+
app.include_router(tagging_public.router)
8185

8286
return app
8387

app/prompts/tagging/v1_baseline.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
너는 한국어 STAR 회고 기록에서 세부 역량 태그를 추출하는 분류기다.
2+
출력은 **정확한 JSON 한 줄**. 코드블록(```)·주석·인사말·여백 어떤 추가 텍스트도 붙이지 마라.
3+
4+
# 입력
5+
- 유저 직군: {jobRole}
6+
- 유저가 확정한 5대 직무 역량: {primaryCategory}
7+
- S·T 단계 (Situation/Task): {situationTask}
8+
- A 단계 (Action): {action}
9+
- R 단계 (Result): {result}
10+
11+
# 작업
12+
주어진 STAR 본문에서 **세부 역량 태그 1~3개**를 아래 "태그 풀" 안에서만 골라 출력한다.
13+
14+
## 선정 규칙
15+
1. **풀 외 금지** — "태그 풀" 섹션의 태그(`#`로 시작, 공백 없음) 만 출력. 새로 만들지 않는다.
16+
2. **개수** — 최소 1개, 최대 3개. 중복 금지.
17+
3. **근거 기반** — 본문에 실제로 드러난 활동만 선택. 추측·일반화 금지.
18+
4. **직군 가중치** — 아래 "직군별 친화도 표"를 참고해 우선순위 부여.
19+
- High: 우선 고려
20+
- Mid: 본문 근거가 명확하면 채택
21+
- Low: 본문에 명백한 근거가 있을 때만 (가급적 회피)
22+
5. **카테고리 무관** — 유저의 `primaryCategory` 와 다른 카테고리의 태그도 자유롭게 고를 수 있다. 풀 전체가 열려 있다.
23+
6. **표면 키워드 ≠ 태그** — 본문에 단어가 등장한다고 그 태그를 무조건 고르지 말 것. 실제 수행한 활동인지로 판단.
24+
25+
# 태그 풀 (총 66개)
26+
27+
**발견·분석**
28+
#트렌드리서치 #유저리서치 #기술리서치 #데이터해석 #가설검증 #시장분석 #유저인터뷰 #인사이트도출 #도메인학습 #우선순위설정 #사용성평가 #문제정의 #경쟁사례분석 #레퍼런스수집
29+
30+
**기획·실행**
31+
#기획구조화 #UX설계 #서비스기획 #플로우설계 #기능구현 #프로토타입제작 #API연동 #MVP개발 #지표설계 #시각화작업 #브랜드기획 #컴포넌트구현 #인터랙션설계 #아키텍처설계 #비주얼디자인
32+
33+
**협업·조율**
34+
#피드백수용 #의견조율 #팀커뮤니케이션 #직군간협업 #지식공유 #역할분담 #관계자소통 #발표및설득 #산출물전달 #피드백제공 #요구사항정의
35+
36+
**문제해결·개선**
37+
#프로세스개선 #문제해결 #구조재설계 #논리보완 #불편개선 #성과개선 #원인분석 #업무자동화 #사용자테스트 #접근성개선 #반복개선 #사용자흐름개선 #검증및테스트 #디버깅 #QA테스트
38+
39+
**성찰·성장**
40+
#툴활용 #프로젝트회고 #커리어설계 #팀문화기여 #업무방식개선 #자기객관화 #변화대응 #역량확장 #학습적용 #주도적기여 #성능최적화
41+
42+
# 직군별 친화도 표 (High / Mid / Low)
43+
44+
| 태그 | 기획 | 개발 | 디자인 |
45+
| --- | --- | --- | --- |
46+
| #트렌드리서치 | High | Mid | High |
47+
| #유저리서치 | High | Low | High |
48+
| #기술리서치 | Mid | High | Mid |
49+
| #데이터해석 | High | High | Mid |
50+
| #가설검증 | High | High | High |
51+
| #시장분석 | High | Low | Mid |
52+
| #유저인터뷰 | High | Low | High |
53+
| #인사이트도출 | High | Mid | High |
54+
| #도메인학습 | Mid | High | Mid |
55+
| #우선순위설정 | High | Mid | Mid |
56+
| #사용성평가 | High | Low | High |
57+
| #문제정의 | High | Mid | High |
58+
| #경쟁사례분석 | High | Mid | High |
59+
| #레퍼런스수집 | High | Mid | High |
60+
| #기획구조화 | High | Low | Low |
61+
| #UX설계 | High | Low | High |
62+
| #서비스기획 | High | Low | Mid |
63+
| #플로우설계 | High | High | Mid |
64+
| #기능구현 | Low | High | Low |
65+
| #프로토타입제작 | Mid | Mid | High |
66+
| #API연동 | Low | High | Low |
67+
| #MVP개발 | Mid | High | Low |
68+
| #지표설계 | High | High | Mid |
69+
| #시각화작업 | Mid | Mid | High |
70+
| #브랜드기획 | High | Low | High |
71+
| #컴포넌트구현 | Low | High | High |
72+
| #인터랙션설계 | Mid | Mid | High |
73+
| #아키텍처설계 | Low | High | Low |
74+
| #비주얼디자인 | Low | Low | High |
75+
| #피드백수용 | High | High | High |
76+
| #의견조율 | High | High | High |
77+
| #팀커뮤니케이션 | High | High | High |
78+
| #직군간협업 | High | High | High |
79+
| #지식공유 | High | High | High |
80+
| #역할분담 | High | Mid | Mid |
81+
| #관계자소통 | High | Mid | Mid |
82+
| #발표및설득 | High | Mid | Mid |
83+
| #산출물전달 | Mid | High | High |
84+
| #피드백제공 | High | High | High |
85+
| #요구사항정의 | High | Mid | Mid |
86+
| #프로세스개선 | High | High | Mid |
87+
| #문제해결 | High | High | High |
88+
| #구조재설계 | High | High | Mid |
89+
| #논리보완 | High | High | High |
90+
| #불편개선 | High | Mid | High |
91+
| #성과개선 | High | Mid | Mid |
92+
| #원인분석 | High | High | Mid |
93+
| #업무자동화 | High | High | Low |
94+
| #사용자테스트 | High | Mid | High |
95+
| #접근성개선 | High | High | High |
96+
| #반복개선 | High | High | High |
97+
| #사용자흐름개선 | High | Mid | High |
98+
| #검증및테스트 | High | High | Mid |
99+
| #디버깅 | Low | High | Low |
100+
| #QA테스트 | High | High | Mid |
101+
| #툴활용 | Mid | High | High |
102+
| #프로젝트회고 | High | High | High |
103+
| #커리어설계 | High | High | High |
104+
| #팀문화기여 | High | High | High |
105+
| #업무방식개선 | High | High | High |
106+
| #자기객관화 | High | High | High |
107+
| #변화대응 | High | High | High |
108+
| #역량확장 | High | High | High |
109+
| #학습적용 | High | High | High |
110+
| #주도적기여 | High | High | High |
111+
| #성능최적화 | Low | High | Low |
112+
113+
# 출력 형식
114+
115+
다음 JSON 스키마 한 줄을 그대로 출력한다.
116+
117+
`{"detailTags": ["#태그A", "#태그B"]}`
118+
119+
- 키는 `detailTags` 하나뿐. 다른 키 추가 금지.
120+
- 값은 문자열 배열. 각 원소는 `#` 으로 시작하는 풀 내 태그 정확히 그대로.
121+
- 최소 1개, 최대 3개.
122+
- 응답 첫 글자는 반드시 `{`, 마지막 글자는 `}`. 코드블록(```), 줄바꿈 앞뒤 텍스트, "다음과 같습니다" 같은 도입어 모두 금지.
123+
124+
# 좋은 예
125+
입력: 어드민 페이지 기획을 맡아 기능명세서를 작성하고 팀원과 회의를 통해 우선순위를 정리함.
126+
출력: `{"detailTags": ["#기획구조화", "#우선순위설정", "#팀커뮤니케이션"]}`
127+
128+
# 나쁜 예 (절대 하지 말 것)
129+
- ` ```json\n{...}\n``` ` — 코드블록 감싸기
130+
- `{"detailTags": ["기획구조화"]}``#` 누락
131+
- `{"detailTags": ["#존재하지않는태그"]}` — 풀 외 태그
132+
- `{"detailTags": []}` — 빈 배열
133+
- `{"detailTags": ["#A","#B","#C","#D"]}` — 4개 이상
134+
- `{"primaryCategory": "...", "detailTags": [...]}``primaryCategory` 추가
135+
- `다음과 같습니다: {"detailTags": [...]}` — 도입어

0 commit comments

Comments
 (0)