diff --git a/.env.example b/.env.example index 13dc89b..6d631ed 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,8 @@ REPORT_VARIANT=v1_baseline # ---- AI provider ---- ANTHROPIC_API_KEY=sk-ant- ANTHROPIC_MODEL=claude-haiku-4-5-20251001 + +# ---- Tagging ---- +# 활성 프롬프트/구현 버전 (app/prompts/tagging/ 하위 .md 파일명과 일치). +# 실험 시에는 본인 .env 만 바꾸고, 이 기본값은 함부로 변경하지 말 것. +TAGGING_VARIANT=v1_baseline diff --git a/.gitignore b/.gitignore index b51009e..d3e69b5 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ logs/ # OMC (oh-my-claudecode local state — 개인 작업 파일이므로 레포에 올리지 않음) .omc/ + +# Claude +.claude \ No newline at end of file diff --git a/app/api/tagging_public.py b/app/api/tagging_public.py new file mode 100644 index 0000000..96f4198 --- /dev/null +++ b/app/api/tagging_public.py @@ -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=["#문제해결"], + ) diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py index d377b47..94916f9 100644 --- a/app/api/v1/__init__.py +++ b/app/api/v1/__init__.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends -from app.api.v1 import ping, report +from app.api.v1 import ping, report, tagging from app.core.security import require_internal_token router = APIRouter( @@ -19,4 +19,5 @@ # Feature 라우터 등록 — 새 기능은 한 줄씩 여기에 추가. router.include_router(ping.router) +router.include_router(tagging.router) router.include_router(report.router) diff --git a/app/api/v1/tagging.py b/app/api/v1/tagging.py new file mode 100644 index 0000000..0d6556c --- /dev/null +++ b/app/api/v1/tagging.py @@ -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 diff --git a/app/core/config.py b/app/core/config.py index ddff740..603b8c8 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -71,6 +71,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`` 이 안전한 값인지 검증한다. diff --git a/app/main.py b/app/main.py index d84f2a6..c10cda5 100644 --- a/app/main.py +++ b/app/main.py @@ -15,7 +15,7 @@ from fastapi import FastAPI from app import __version__ -from app.api import health, report_public +from app.api import health, report_public, 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 @@ -72,12 +72,16 @@ def create_app() -> FastAPI: # - v1_router: 비즈니스 API. prefix="/v1" + X-Internal-Token 전역 보호. # 새 기능은 app/api/v1/.py 만들고 app/api/v1/__init__.py 의 # include_router 목록에 한 줄 추가하는 식으로 붙인다 (여기는 건드리지 않음). + # - tagging_public: Spring 합의 안정 경로 /api/tagging. 현재 더미 응답이며 + # v1_baseline 검증 후 cutover 시 핸들러 본문을 service 호출로 교체한다. + # - report_public: Spring 합의 안정 경로 /api/reports/*. 현재 더미 응답. # CORS 미들웨어는 의도적으로 미포함 — 서버 간 통신이므로 불필요. app.add_middleware(RequestContextMiddleware) app.include_router(health.router) app.include_router(report_public.router) app.include_router(v1_router) + app.include_router(tagging_public.router) return app diff --git a/app/prompts/tagging/v1_baseline.md b/app/prompts/tagging/v1_baseline.md new file mode 100644 index 0000000..aa2d474 --- /dev/null +++ b/app/prompts/tagging/v1_baseline.md @@ -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": [...]}` — 도입어 diff --git a/app/prompts/tagging/v2_postscore.md b/app/prompts/tagging/v2_postscore.md new file mode 100644 index 0000000..a7a9e94 --- /dev/null +++ b/app/prompts/tagging/v2_postscore.md @@ -0,0 +1,63 @@ +너는 한국어 STAR 회고 기록에서 세부 역량 태그를 추출하는 분류기다. +출력은 **정확한 JSON 한 줄**. 코드블록(```)·주석·인사말·여백 어떤 추가 텍스트도 붙이지 마라. + +# 입력 +- 유저 직군: {jobRole} +- 유저가 확정한 5대 직무 역량: {primaryCategory} +- S·T 단계 (Situation/Task): {situationTask} +- A 단계 (Action): {action} +- R 단계 (Result): {result} + +# 작업 +주어진 STAR 본문에서 **세부 역량 태그를 5~7개** 아래 "태그 풀" 안에서만 골라 출력한다. +직군 친화도는 우리 시스템이 후처리로 점수화해 상위 3개로 자른다 — 너는 **본문에 실제로 드러난 활동의 근거가 강한 순**으로 후보를 뽑아주면 된다. + +## 선정 규칙 +1. **풀 외 금지** — "태그 풀" 섹션의 태그(`#`로 시작, 공백 없음)만 출력. 새로 만들지 않는다. +2. **개수** — 최소 5개, 최대 7개. 중복 금지. +3. **순서 = 근거 강한 순** — 배열 첫 원소가 본문 근거가 가장 명확한 태그. +4. **근거 기반** — 본문에 실제로 드러난 활동만 선택. 추측·일반화 금지. +5. **카테고리 무관** — 유저의 `primaryCategory` 와 다른 카테고리의 태그도 자유롭게 고를 수 있다. 풀 전체가 열려 있다. +6. **표면 키워드 ≠ 태그** — 본문에 단어가 등장한다고 그 태그를 무조건 고르지 말 것. 실제 수행한 활동인지로 판단. + +# 태그 풀 (총 66개) + +**발견·분석** +#트렌드리서치 #유저리서치 #기술리서치 #데이터해석 #가설검증 #시장분석 #유저인터뷰 #인사이트도출 #도메인학습 #우선순위설정 #사용성평가 #문제정의 #경쟁사례분석 #레퍼런스수집 + +**기획·실행** +#기획구조화 #UX설계 #서비스기획 #플로우설계 #기능구현 #프로토타입제작 #API연동 #MVP개발 #지표설계 #시각화작업 #브랜드기획 #컴포넌트구현 #인터랙션설계 #아키텍처설계 #비주얼디자인 + +**협업·조율** +#피드백수용 #의견조율 #팀커뮤니케이션 #직군간협업 #지식공유 #역할분담 #관계자소통 #발표및설득 #산출물전달 #피드백제공 #요구사항정의 + +**문제해결·개선** +#프로세스개선 #문제해결 #구조재설계 #논리보완 #불편개선 #성과개선 #원인분석 #업무자동화 #사용자테스트 #접근성개선 #반복개선 #사용자흐름개선 #검증및테스트 #디버깅 #QA테스트 + +**성찰·성장** +#툴활용 #프로젝트회고 #커리어설계 #팀문화기여 #업무방식개선 #자기객관화 #변화대응 #역량확장 #학습적용 #주도적기여 #성능최적화 + +# 출력 형식 + +다음 JSON 스키마 한 줄을 그대로 출력한다. + +`{"detailTags": ["#태그A", "#태그B", "#태그C", "#태그D", "#태그E"]}` + +- 키는 `detailTags` 하나뿐. 다른 키 추가 금지. +- 값은 문자열 배열. 각 원소는 `#` 으로 시작하는 풀 내 태그 정확히 그대로. +- 최소 5개, 최대 7개. 근거 강한 순 정렬. +- 응답 첫 글자는 반드시 `{`, 마지막 글자는 `}`. 코드블록(```), 줄바꿈 앞뒤 텍스트, "다음과 같습니다" 같은 도입어 모두 금지. + +# 좋은 예 +입력: 어드민 페이지 기획을 맡아 기능명세서를 작성하고 팀원과 회의를 통해 우선순위를 정리함. +출력: `{"detailTags": ["#기획구조화", "#우선순위설정", "#팀커뮤니케이션", "#요구사항정의", "#의견조율"]}` + +# 나쁜 예 (절대 하지 말 것) +- ` ```json\n{...}\n``` ` — 코드블록 감싸기 +- `{"detailTags": ["기획구조화"]}` — `#` 누락 +- `{"detailTags": ["#존재하지않는태그"]}` — 풀 외 태그 +- `{"detailTags": []}` — 빈 배열 +- `{"detailTags": ["#A","#B"]}` — 5개 미만 +- `{"detailTags": ["#A","#B","#C","#D","#E","#F","#G","#H"]}` — 8개 이상 +- `{"primaryCategory": "...", "detailTags": [...]}` — `primaryCategory` 추가 +- `다음과 같습니다: {"detailTags": [...]}` — 도입어 diff --git a/app/schemas/common.py b/app/schemas/common.py index 7b33063..ff2a16b 100644 --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -1,4 +1,8 @@ -"""여러 도메인에서 공유하는 Enum 및 공통 타입.""" +"""여러 스키마에서 공유하는 도메인 Enum 과 한글 라벨 매핑. + +Spring Boot 백엔드와 동일한 코드값을 유지해야 한다. 새 값이 필요하면 Spring 쪽 +ENUM 도 같이 업데이트하고 PR 본문에 그 사실을 남긴다. +""" from __future__ import annotations @@ -6,12 +10,44 @@ class JobRole(StrEnum): + """유저 직군 — Spring 측 enum 코드와 동일.""" + PLANNER = "PLANNER" DEVELOPER = "DEVELOPER" DESIGNER = "DESIGNER" class UserStatus(StrEnum): + """유저 상태 — Spring 측 enum 코드와 동일.""" + STUDENT = "STUDENT" JOB_SEEKER = "JOB_SEEKER" EMPLOYED = "EMPLOYED" + + +class PrimaryCategory(StrEnum): + """5대 직무 역량 — Spring 측 enum 코드와 동일.""" + + DISCOVERY_ANALYSIS = "DISCOVERY_ANALYSIS" + PLANNING_EXECUTION = "PLANNING_EXECUTION" + COLLABORATION = "COLLABORATION" + PROBLEM_SOLVING = "PROBLEM_SOLVING" + GROWTH = "GROWTH" + + +JOB_ROLE_LABELS_KO: dict[JobRole, str] = { + JobRole.PLANNER: "기획", + JobRole.DEVELOPER: "개발", + JobRole.DESIGNER: "디자인", +} +"""프롬프트 변수 치환 등에서 쓰는 한글 라벨. tags.txt 가중치 표 헤더와 동일.""" + + +PRIMARY_CATEGORY_LABELS_KO: dict[PrimaryCategory, str] = { + PrimaryCategory.DISCOVERY_ANALYSIS: "발견·분석", + PrimaryCategory.PLANNING_EXECUTION: "기획·실행", + PrimaryCategory.COLLABORATION: "협업·조율", + PrimaryCategory.PROBLEM_SOLVING: "문제해결·개선", + PrimaryCategory.GROWTH: "성찰·성장", +} +"""프롬프트 변수 치환에서 쓰는 한글 라벨. tags.txt 카테고리 표기와 동일.""" diff --git a/app/schemas/tagging.py b/app/schemas/tagging.py new file mode 100644 index 0000000..e4bdf7c --- /dev/null +++ b/app/schemas/tagging.py @@ -0,0 +1,68 @@ +"""``POST /v1/tagging`` · ``POST /api/tagging`` 요청·응답 스키마. + +api_spec 계약대로 본문은 camelCase 로 주고받고, 내부 파이썬 코드는 snake_case 로 다룬다. +``populate_by_name=True`` 로 두 이름 모두 받을 수 있게 하고, FastAPI 응답 직렬화는 +라우터에서 ``response_model_by_alias=True`` 로 camelCase 출력을 강제한다. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.common import JobRole, PrimaryCategory + +_MAX_STAR_LENGTH = 300 +"""S·T / A / R 각 단계 본문의 최대 길이 (api_spec 명시).""" + + +class TaggingRequest(BaseModel): + """심화 STAR 기록 1건에 대한 태깅 요청. + + Spring 이 1차 검증한 값이 넘어온다는 전제이지만, AI 서비스도 동일한 가드를 둔다. + """ + + model_config = ConfigDict( + populate_by_name=True, + str_strip_whitespace=True, + ) + + job_role: JobRole = Field(alias="jobRole", description="유저 직군") + selected_competency: PrimaryCategory = Field( + alias="selectedCompetency", + description="유저가 확정한 5대 직무 역량", + ) + situation_task: str = Field( + alias="situationTask", + min_length=1, + max_length=_MAX_STAR_LENGTH, + description="S·T 단계 본문", + ) + action: str = Field( + min_length=1, + max_length=_MAX_STAR_LENGTH, + description="A 단계 본문", + ) + result: str = Field( + min_length=1, + max_length=_MAX_STAR_LENGTH, + description="R 단계 본문", + ) + + +class TaggingResponse(BaseModel): + """태깅 응답. + + ``primaryCategory`` 는 요청의 ``selectedCompetency`` 그대로 echo (api_spec 계약). + ``detailTags`` 는 65개 태그풀 안에서 1~3개를 한글 raw 문자열로 반환하며, Spring 측에서 + 한글 → 내부 ENUM 코드로 매핑한다. + """ + + model_config = ConfigDict(populate_by_name=True) + + primary_category: PrimaryCategory = Field(alias="primaryCategory") + detail_tags: list[str] = Field( + alias="detailTags", + min_length=1, + max_length=3, + description="65개 태그풀에서 추출한 세부 태그 (한글 raw)", + ) diff --git a/app/services/tagging/__init__.py b/app/services/tagging/__init__.py new file mode 100644 index 0000000..8612fdd --- /dev/null +++ b/app/services/tagging/__init__.py @@ -0,0 +1,48 @@ +"""Tagging 도메인 entry — 활성 variant 의 ``run`` 함수를 dispatch. + +``Settings.tagging_variant`` (env: ``TAGGING_VARIANT``) 값으로 어느 구현체를 쓸지 정한다. +모듈 import 시점에 ``get_settings()`` 를 호출하지 않고 (테스트/lifespan 부팅 순서에 영향 X), +``run()`` 첫 호출 시 lazy resolve 한 뒤 :func:`functools.lru_cache` 로 결과를 캐시한다. + +새 variant 를 추가할 때: + 1. ``app/prompts/tagging/{variant}.md`` 작성 + 2. ``app/services/tagging/{variant}.py`` 에 동일 시그니처의 ``run`` 작성 + 3. :func:`_resolve` 의 분기에 한 줄 추가 +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from functools import lru_cache + +from app.core.config import get_settings +from app.schemas.tagging import TaggingRequest, TaggingResponse +from app.services._clients.llm_client import LLMClient + +RunFn = Callable[[TaggingRequest, LLMClient, str], Awaitable[TaggingResponse]] + + +@lru_cache(maxsize=1) +def _resolve() -> RunFn: + """``Settings.tagging_variant`` 에 맞는 ``run`` 함수를 import 해 반환한다.""" + variant = get_settings().tagging_variant + if variant == "v1_baseline": + from app.services.tagging.v1_baseline import run as _impl # noqa: PLC0415 + + return _impl + if variant == "v2_postscore": + from app.services.tagging.v2_postscore import run as _impl # noqa: PLC0415 + + return _impl + raise RuntimeError( + f"Unknown TAGGING_VARIANT={variant!r}. " + "Add a branch in app/services/tagging/__init__.py::_resolve." + ) + + +async def run(req: TaggingRequest, llm: LLMClient, model: str) -> TaggingResponse: + """활성 variant 의 ``run`` 으로 dispatch.""" + return await _resolve()(req, llm, model) + + +__all__ = ["run"] diff --git a/app/services/tagging/data.py b/app/services/tagging/data.py new file mode 100644 index 0000000..1e31cc9 --- /dev/null +++ b/app/services/tagging/data.py @@ -0,0 +1,255 @@ +"""Tagging 도메인 정적 데이터 — 태그 풀 + 5대 역량 카테고리 매핑. + +SoT 는 ``.claude/tags.txt`` (26.05.01 수정본). 이 모듈은 LLM 응답이 정해진 풀 안에서만 +태그를 골랐는지 검증하기 위한 식별자 집합과, 각 태그가 어느 5대 역량에 속하는지 메타데이터를 +노출한다. 직군별 High/Mid/Low 가중치 표는 ``app/prompts/tagging/v1_baseline.md`` 쪽에서 +관리한다 (프롬프트 diff 와 분리하기 위함). + +``TAGS_BY_CATEGORY`` 를 단일 SoT 로 두고 ``ALL_TAGS`` · ``TAG_TO_CATEGORY`` 는 거기서 +파생한다. 두 자료구조가 어긋날 일 없도록 만들기 위함. + +태그를 추가/삭제할 때 동기화 대상: + 1. ``.claude/tags.txt`` — 태그풀과 가중치 표 + 2. ``app/prompts/tagging/v*.md`` — 프롬프트에 박힌 태그풀·가중치 표 + 3. Spring 측 한글 → ENUM 매핑 +""" + +from __future__ import annotations + +from enum import StrEnum + +from app.schemas.common import JobRole, PrimaryCategory + +TAGS_BY_CATEGORY: dict[PrimaryCategory, frozenset[str]] = { + PrimaryCategory.DISCOVERY_ANALYSIS: frozenset( + { + "#트렌드리서치", + "#유저리서치", + "#기술리서치", + "#데이터해석", + "#가설검증", + "#시장분석", + "#유저인터뷰", + "#인사이트도출", + "#도메인학습", + "#우선순위설정", + "#사용성평가", + "#문제정의", + "#경쟁사례분석", + "#레퍼런스수집", + } + ), + PrimaryCategory.PLANNING_EXECUTION: frozenset( + { + "#기획구조화", + "#UX설계", + "#서비스기획", + "#플로우설계", + "#기능구현", + "#프로토타입제작", + "#API연동", + "#MVP개발", + "#지표설계", + "#시각화작업", + "#브랜드기획", + "#컴포넌트구현", + "#인터랙션설계", + "#아키텍처설계", + "#비주얼디자인", + } + ), + PrimaryCategory.COLLABORATION: frozenset( + { + "#피드백수용", + "#의견조율", + "#팀커뮤니케이션", + "#직군간협업", + "#지식공유", + "#역할분담", + "#관계자소통", + "#발표및설득", + "#산출물전달", + "#피드백제공", + "#요구사항정의", + } + ), + PrimaryCategory.PROBLEM_SOLVING: frozenset( + { + "#프로세스개선", + "#문제해결", + "#구조재설계", + "#논리보완", + "#불편개선", + "#성과개선", + "#원인분석", + "#업무자동화", + "#사용자테스트", + "#접근성개선", + "#반복개선", + "#사용자흐름개선", + "#검증및테스트", + "#디버깅", + "#QA테스트", + } + ), + PrimaryCategory.GROWTH: frozenset( + { + "#툴활용", + "#프로젝트회고", + "#커리어설계", + "#팀문화기여", + "#업무방식개선", + "#자기객관화", + "#변화대응", + "#역량확장", + "#학습적용", + "#주도적기여", + "#성능최적화", + } + ), +} +"""5대 역량 카테고리별 태그 집합 — 모듈 내 단일 SoT. + +각 태그는 정확히 한 카테고리에 속한다 (현재 풀 기준 중복 없음). +프롬프트 렌더링·분석·디버깅 용도. v1 검증 로직은 카테고리 무관하게 +``ALL_TAGS`` 전체를 화이트리스트로 쓴다 (research_ref 결정). +""" + + +ALL_TAGS: frozenset[str] = frozenset().union(*TAGS_BY_CATEGORY.values()) +"""tags.txt 26.05.01 기준 전체 태그 식별자 집합. + +LLM 응답의 ``detailTags`` 가 이 집합의 부분집합인지 검사하는 용도. +형식은 ``#태그명`` (해시 prefix · 공백 없음) — tags.txt 원형 그대로. +""" + + +TAG_TO_CATEGORY: dict[str, PrimaryCategory] = { + tag: category for category, tags in TAGS_BY_CATEGORY.items() for tag in tags +} +"""태그 → 소속 카테고리 역인덱스. 분석/디버깅 용도.""" + + +# --------------------------------------------------------------------------- +# 직군별 가중치 (tags.txt 26.05.01 표 그대로) +# +# v3_offload_weights 부터 도입 — LLM 프롬프트에는 가중치 표를 박지 않고, LLM 은 본문 +# 근거 강한 순으로 5~7개 후보만 뽑아 주면 우리가 이 dict 와 ``tag_score()`` 로 직군별 +# 점수를 매겨 상위 3개로 자른다. variant 비교는 v3 service 와 prompts/tagging/v3*.md +# 참고. + + +class TagWeight(StrEnum): + """tags.txt 가중치 표의 High / Mid / Low 분류.""" + + HIGH = "High" + MID = "Mid" + LOW = "Low" + + +_WEIGHT_SCORE: dict[TagWeight, int] = { + TagWeight.HIGH: 3, + TagWeight.MID: 2, + TagWeight.LOW: 1, +} +"""High/Mid/Low → 정수 점수 매핑. 후처리에서 top-N 정렬에 쓰임.""" + + +# 아래 매트릭스 가독성을 위한 모듈-로컬 별칭. underscore prefix 로 외부 노출 회피. +_H = TagWeight.HIGH +_M = TagWeight.MID +_L = TagWeight.LOW +_P = JobRole.PLANNER # 기획 +_DV = JobRole.DEVELOPER # 개발 +_DS = JobRole.DESIGNER # 디자인 + + +WEIGHTS_BY_TAG: dict[str, dict[JobRole, TagWeight]] = { + # 발견·분석 + "#트렌드리서치": {_P: _H, _DV: _M, _DS: _H}, + "#유저리서치": {_P: _H, _DV: _L, _DS: _H}, + "#기술리서치": {_P: _M, _DV: _H, _DS: _M}, + "#데이터해석": {_P: _H, _DV: _H, _DS: _M}, + "#가설검증": {_P: _H, _DV: _H, _DS: _H}, + "#시장분석": {_P: _H, _DV: _L, _DS: _M}, + "#유저인터뷰": {_P: _H, _DV: _L, _DS: _H}, + "#인사이트도출": {_P: _H, _DV: _M, _DS: _H}, + "#도메인학습": {_P: _M, _DV: _H, _DS: _M}, + "#우선순위설정": {_P: _H, _DV: _M, _DS: _M}, + "#사용성평가": {_P: _H, _DV: _L, _DS: _H}, + "#문제정의": {_P: _H, _DV: _M, _DS: _H}, + "#경쟁사례분석": {_P: _H, _DV: _M, _DS: _H}, + "#레퍼런스수집": {_P: _H, _DV: _M, _DS: _H}, + # 기획·실행 + "#기획구조화": {_P: _H, _DV: _L, _DS: _L}, + "#UX설계": {_P: _H, _DV: _L, _DS: _H}, + "#서비스기획": {_P: _H, _DV: _L, _DS: _M}, + "#플로우설계": {_P: _H, _DV: _H, _DS: _M}, + "#기능구현": {_P: _L, _DV: _H, _DS: _L}, + "#프로토타입제작": {_P: _M, _DV: _M, _DS: _H}, + "#API연동": {_P: _L, _DV: _H, _DS: _L}, + "#MVP개발": {_P: _M, _DV: _H, _DS: _L}, + "#지표설계": {_P: _H, _DV: _H, _DS: _M}, + "#시각화작업": {_P: _M, _DV: _M, _DS: _H}, + "#브랜드기획": {_P: _H, _DV: _L, _DS: _H}, + "#컴포넌트구현": {_P: _L, _DV: _H, _DS: _H}, + "#인터랙션설계": {_P: _M, _DV: _M, _DS: _H}, + "#아키텍처설계": {_P: _L, _DV: _H, _DS: _L}, + "#비주얼디자인": {_P: _L, _DV: _L, _DS: _H}, + # 협업·조율 + "#피드백수용": {_P: _H, _DV: _H, _DS: _H}, + "#의견조율": {_P: _H, _DV: _H, _DS: _H}, + "#팀커뮤니케이션": {_P: _H, _DV: _H, _DS: _H}, + "#직군간협업": {_P: _H, _DV: _H, _DS: _H}, + "#지식공유": {_P: _H, _DV: _H, _DS: _H}, + "#역할분담": {_P: _H, _DV: _M, _DS: _M}, + "#관계자소통": {_P: _H, _DV: _M, _DS: _M}, + "#발표및설득": {_P: _H, _DV: _M, _DS: _M}, + "#산출물전달": {_P: _M, _DV: _H, _DS: _H}, + "#피드백제공": {_P: _H, _DV: _H, _DS: _H}, + "#요구사항정의": {_P: _H, _DV: _M, _DS: _M}, + # 문제해결·개선 + "#프로세스개선": {_P: _H, _DV: _H, _DS: _M}, + "#문제해결": {_P: _H, _DV: _H, _DS: _H}, + "#구조재설계": {_P: _H, _DV: _H, _DS: _M}, + "#논리보완": {_P: _H, _DV: _H, _DS: _H}, + "#불편개선": {_P: _H, _DV: _M, _DS: _H}, + "#성과개선": {_P: _H, _DV: _M, _DS: _M}, + "#원인분석": {_P: _H, _DV: _H, _DS: _M}, + "#업무자동화": {_P: _H, _DV: _H, _DS: _L}, + "#사용자테스트": {_P: _H, _DV: _M, _DS: _H}, + "#디버깅": {_P: _L, _DV: _H, _DS: _L}, + "#접근성개선": {_P: _H, _DV: _H, _DS: _H}, + "#반복개선": {_P: _H, _DV: _H, _DS: _H}, + "#사용자흐름개선": {_P: _H, _DV: _M, _DS: _H}, + "#검증및테스트": {_P: _H, _DV: _H, _DS: _M}, + "#QA테스트": {_P: _H, _DV: _H, _DS: _M}, + # 성찰·성장 + "#툴활용": {_P: _M, _DV: _H, _DS: _H}, + "#프로젝트회고": {_P: _H, _DV: _H, _DS: _H}, + "#커리어설계": {_P: _H, _DV: _H, _DS: _H}, + "#팀문화기여": {_P: _H, _DV: _H, _DS: _H}, + "#업무방식개선": {_P: _H, _DV: _H, _DS: _H}, + "#자기객관화": {_P: _H, _DV: _H, _DS: _H}, + "#변화대응": {_P: _H, _DV: _H, _DS: _H}, + "#역량확장": {_P: _H, _DV: _H, _DS: _H}, + "#학습적용": {_P: _H, _DV: _H, _DS: _H}, + "#주도적기여": {_P: _H, _DV: _H, _DS: _H}, + "#성능최적화": {_P: _L, _DV: _H, _DS: _L}, +} +"""태그 × 직군 → 가중치 (tags.txt 26.05.01 표 그대로). 총 66 entry.""" # noqa: RUF001 + + +def tag_score(tag: str, role: JobRole) -> int: + """주어진 직군에서 태그의 가중치 점수 (1~3). 풀에 없거나 매핑 누락은 0. + + 후처리 정렬용. 풀 외 태그는 ``_parse_and_validate`` 에서 미리 걸러지지만, + 방어적으로 0 을 반환해 sort 시 자연스럽게 뒤로 밀린다. + """ + weights = WEIGHTS_BY_TAG.get(tag) + if weights is None: + return 0 + w = weights.get(role) + if w is None: + return 0 + return _WEIGHT_SCORE[w] diff --git a/app/services/tagging/exceptions.py b/app/services/tagging/exceptions.py new file mode 100644 index 0000000..9f2cecc --- /dev/null +++ b/app/services/tagging/exceptions.py @@ -0,0 +1,24 @@ +"""Tagging 도메인 예외 계층. + +LLM 호출 실패(``app.services._clients.exceptions.LLMError`` 하위) 와 분리해서, +**응답 형식·내용 검증 실패** 만 별도로 표현한다. 라우터는 이를 422 +(``code: TAG_EXTRACTION_FAILED``) 로 매핑한다. +""" + +from __future__ import annotations + + +class TaggingError(Exception): + """Tagging 도메인 베이스 예외.""" + + +class TaggingValidationError(TaggingError): + """LLM 응답이 contract 를 만족하지 못한 경우. + + 포함 케이스: + - JSON 파싱 실패 + - ``detailTags`` 키 누락 또는 배열 아님 + - 갯수가 1~3 범위 밖 + - 중복 태그 포함 + - 풀(``ALL_TAGS``) 외 태그 포함 + """ diff --git a/app/services/tagging/v1_baseline.py b/app/services/tagging/v1_baseline.py new file mode 100644 index 0000000..d3feb97 --- /dev/null +++ b/app/services/tagging/v1_baseline.py @@ -0,0 +1,219 @@ +"""Tagging v1_baseline 구현 — zero-shot 분류기. + +흐름: + 1) ``v1_baseline.md`` 시스템 프롬프트에 변수 치환 + 2) ``LLMClient.create_message(workload=TAGGING)`` 호출 + 3) 응답 텍스트에서 코드블록 제거 → JSON 파싱 → 풀/갯수/중복 검증 + 4) 검증 실패 시 corrective prompt(이전 응답 echo + 형식 교정 지시)로 1회 재시도 + 5) 두 시도 모두 실패하면 :class:`TaggingValidationError` raise. + +LLM 호출 단계의 실패(:mod:`LLMError` 하위)는 그대로 propagate 되며, 라우터에서 +HTTP 상태코드로 변환한다. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import orjson + +from app.core.logging import get_logger +from app.schemas.common import ( + JOB_ROLE_LABELS_KO, + PRIMARY_CATEGORY_LABELS_KO, + PrimaryCategory, +) +from app.schemas.tagging import TaggingRequest, TaggingResponse +from app.services._clients.llm_client import LLMClient, WorkloadType +from app.services.tagging.data import ALL_TAGS, TAGS_BY_CATEGORY +from app.services.tagging.exceptions import TaggingValidationError + +logger = get_logger(__name__) + + +_PROMPT_PATH = Path(__file__).parents[2] / "prompts" / "tagging" / "v1_baseline.md" +_PROMPT_TEMPLATE = _PROMPT_PATH.read_text(encoding="utf-8") +"""모듈 로드 시 1회 read 한 시스템 프롬프트 템플릿.""" + + +_CODE_FENCE_RE = re.compile(r"^```(?:json)?\s*\n?(.*?)\n?\s*```$", re.DOTALL) +"""LLM 이 실수로 `````json ... ````` 으로 감쌌을 때 벗기는 정규식.""" + + +_USER_TRIGGER = "위 지침에 따라 detailTags JSON 한 줄을 출력해." +"""1차 user 메시지. Anthropic API 가 messages 1개 이상을 요구하므로 트리거용으로 단 한 줄.""" + + +_MAX_TOKENS = 200 +"""tagging 응답은 짧으니 출력 토큰을 빠듯하게.""" + + +_TOKEN_RE = re.compile(r"\{(jobRole|primaryCategory|situationTask|action|result)\}") +"""프롬프트 템플릿의 치환 토큰 — 1패스 ``re.sub`` 로만 매치된다.""" + + +def _render_prompt(req: TaggingRequest) -> str: + """변수 토큰을 한글 라벨/STAR 본문으로 치환한 시스템 프롬프트를 반환. + + 1패스 정규식 치환 — 사용자 본문에 ``{result}`` 같은 토큰 문자열이 섞여도 + 재치환되지 않는다. STAR 본문은 ``...`` 등 + XML-like 태그로 경계화해 프롬프트 인젝션 저항성을 높인다. + """ + values = { + "jobRole": JOB_ROLE_LABELS_KO[req.job_role], + "primaryCategory": PRIMARY_CATEGORY_LABELS_KO[req.selected_competency], + "situationTask": f"\n{req.situation_task}\n", + "action": f"\n{req.action}\n", + "result": f"\n{req.result}\n", + } + return _TOKEN_RE.sub(lambda m: values[m.group(1)], _PROMPT_TEMPLATE) + + +def _allowed_tags_for(category: PrimaryCategory) -> frozenset[str]: + """선택된 ``primaryCategory`` 안에서 허용되는 태그 셋. + + ``TAGS_BY_CATEGORY`` 의 카테고리별 부분집합을 그대로 반환. 카테고리 외 태그를 + 검증 단계에서 걸러내 contract (선택 역량 내 태그만 출력) 를 보장한다. + """ + return TAGS_BY_CATEGORY[category] + + +def _extract_text(content: Any) -> str: + """Anthropic Message ``content`` 블록에서 텍스트만 모아 strip 후 반환. + + tool_use 등 텍스트가 아닌 블록은 무시한다. 모델이 텍스트를 여러 블록으로 쪼개 + 낼 가능성에 대비해 join 한다. + """ + parts: list[str] = [] + for block in content: + text = getattr(block, "text", None) + if isinstance(text, str): + parts.append(text) + return "".join(parts).strip() + + +def _strip_code_fence(text: str) -> str: + """LLM 이 코드블록으로 감쌌으면 벗긴 본문을 반환, 아니면 그대로.""" + m = _CODE_FENCE_RE.match(text) + if m: + return m.group(1).strip() + return text + + +def _parse_and_validate(raw: str, allowed_tags: frozenset[str] = ALL_TAGS) -> list[str]: + """LLM 응답 텍스트 → 검증된 ``detail_tags`` 리스트. + + 형식·갯수·중복·풀 외 태그 모두 검사. 실패 시 :class:`TaggingValidationError`. + ``allowed_tags`` 는 카테고리별 허용 셋을 좁혀 넘길 수 있다 (기본 ``ALL_TAGS``). + """ + text = _strip_code_fence(raw) + try: + payload = orjson.loads(text) + except orjson.JSONDecodeError as exc: + raise TaggingValidationError(f"JSON 파싱 실패: {exc}") from exc + + if not isinstance(payload, dict): + raise TaggingValidationError("응답이 JSON object 가 아님") + + tags = payload.get("detailTags") + if not isinstance(tags, list): + raise TaggingValidationError("detailTags 가 배열이 아니거나 누락") + + if not all(isinstance(t, str) for t in tags): + raise TaggingValidationError("detailTags 에 문자열 아닌 원소 포함") + + str_tags: list[str] = list(tags) + + if not 1 <= len(str_tags) <= 3: + raise TaggingValidationError(f"detailTags 갯수 위반: {len(str_tags)} (허용 1~3)") + + if len(set(str_tags)) != len(str_tags): + raise TaggingValidationError("detailTags 중복 포함") + + out_of_pool = [t for t in str_tags if t not in allowed_tags] + if out_of_pool: + raise TaggingValidationError(f"허용 태그 풀 외 태그 포함: {out_of_pool}") + + return str_tags + + +def _build_corrective_user_message(reason: str) -> str: + """2차 시도용 corrective user 메시지.""" + return ( + f"이전 응답이 형식에 맞지 않다. 이유: {reason}. " + "다시 시도해. 반드시 시스템 프롬프트의 태그 풀 안에서만 1~3개 골라야 하고, " + '`{"detailTags": ["#태그A"]}` 같은 한 줄 JSON 만 출력. ' + "코드블록(```)·도입어·primaryCategory 키 모두 금지." + ) + + +async def run(req: TaggingRequest, llm: LLMClient, model: str) -> TaggingResponse: + """Tagging v1_baseline 실행 (1회 corrective 재시도 포함). + + Args: + req: 검증된 :class:`TaggingRequest`. + llm: lifespan 에서 생성된 :class:`LLMClient` 싱글턴. + model: 사용할 Claude 모델명 (``Settings.anthropic_model``). + + Returns: + :class:`TaggingResponse` — ``primaryCategory`` 는 요청 echo, + ``detailTags`` 는 검증을 통과한 1~3개 태그. + + Raises: + TaggingValidationError: 두 시도 모두 응답 형식·내용을 만족하지 못한 경우. + LLMError (혹은 하위): LLM 호출 단계의 실패는 :class:`LLMClient` 가 매핑해 던지며, + 여기서는 그대로 propagate. + """ + system = _render_prompt(req) + allowed = _allowed_tags_for(req.selected_competency) + log_ctx = { + "job_role": req.job_role.value, + "primary_category": req.selected_competency.value, + } + + # 1차 시도 + first_msg = await llm.create_message( + workload=WorkloadType.TAGGING, + model=model, + system=system, + messages=[{"role": "user", "content": _USER_TRIGGER}], + max_tokens=_MAX_TOKENS, + ) + first_raw = _extract_text(first_msg.content) + + try: + tags = _parse_and_validate(first_raw, allowed) + except TaggingValidationError as first_err: + logger.warning( + "tagging.validation_failed", + extra={**log_ctx, "attempt": 1, "reason": str(first_err)}, + ) + # 2차 시도 — corrective + retry_msg = await llm.create_message( + workload=WorkloadType.TAGGING, + model=model, + system=system, + messages=[ + {"role": "user", "content": _USER_TRIGGER}, + {"role": "assistant", "content": first_raw}, + {"role": "user", "content": _build_corrective_user_message(str(first_err))}, + ], + max_tokens=_MAX_TOKENS, + ) + retry_raw = _extract_text(retry_msg.content) + try: + tags = _parse_and_validate(retry_raw, allowed) + except TaggingValidationError as retry_err: + logger.warning( + "tagging.validation_failed", + extra={**log_ctx, "attempt": 2, "reason": str(retry_err)}, + ) + raise + + logger.info("tagging.done", extra={**log_ctx, "tag_count": len(tags)}) + return TaggingResponse( + primary_category=req.selected_competency, + detail_tags=tags, + ) diff --git a/app/services/tagging/v2_postscore.py b/app/services/tagging/v2_postscore.py new file mode 100644 index 0000000..b7bb1fc --- /dev/null +++ b/app/services/tagging/v2_postscore.py @@ -0,0 +1,223 @@ +"""Tagging v2_postscore — v1 의 한계를 종합 해소한 두 번째 variant. + +v1_baseline 대비 변경점 세 가지를 한 묶음으로 도입: + +1. **백틱 strip 정규식 확장** — back-ref ``\\1`` 로 1·2·3개 백틱(양 끝 동일 개수) 모두 매치. + v1 실측에서 Haiku 가 응답을 single backtick(``` `...` ```)으로 감싸는 빈도가 높아 + corrective 재시도가 자주 발동했음 (experiments/tagging/2026-05-17_v1_baseline.md §1). + +2. **가중치 표를 LLM 프롬프트에서 제거** — 프롬프트 약 1,500 tok 절감. LLM 은 본문 근거 + 강한 순으로 5~7개 후보만 출력하고, 우리 코드가 :func:`tag_score` 로 직군 가중치 + 점수를 매겨 상위 3개로 자른다 (deterministic 후처리, 가중치 튜닝이 코드 단위로 가능). + +3. **프롬프트 일반 정리** — 가중치 표 제거에 맞춰 직군 우선순위 규칙도 빼고, 출력 갯수 + 5~7개로 변경. + +v3 부터는 본 v2 의 데이터/흐름을 기준으로 추가 개선 (research_ref §2-2 의 v2 개선 조건 — 운영 +데이터 오분류 패턴 누적 후 fewshot 등) 검토. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import orjson + +from app.core.logging import get_logger +from app.schemas.common import ( + JOB_ROLE_LABELS_KO, + PRIMARY_CATEGORY_LABELS_KO, + JobRole, + PrimaryCategory, +) +from app.schemas.tagging import TaggingRequest, TaggingResponse +from app.services._clients.llm_client import LLMClient, WorkloadType +from app.services.tagging.data import ALL_TAGS, TAGS_BY_CATEGORY, tag_score +from app.services.tagging.exceptions import TaggingValidationError +from app.services.tagging.v1_baseline import _extract_text + +logger = get_logger(__name__) + + +_PROMPT_PATH = Path(__file__).parents[2] / "prompts" / "tagging" / "v2_postscore.md" +_PROMPT_TEMPLATE = _PROMPT_PATH.read_text(encoding="utf-8") + + +# v1 대비 변경점 ① — 1·2·3개 백틱(양 끝 동일 개수) 모두 매치. +_CODE_FENCE_RE = re.compile(r"^(`{1,3})(?:json)?\s*\n?(.*?)\n?\s*\1$", re.DOTALL) + + +_CANDIDATE_MIN = 5 +_CANDIDATE_MAX = 7 +"""LLM 에게 요청하는 후보 갯수 범위. 후처리에서 상위 3개로 자른다.""" + +_FINAL_TOP = 3 +"""최종 응답에 노출하는 태그 갯수. ``TaggingResponse.detail_tags`` 의 max.""" + + +_USER_TRIGGER = ( + f"위 지침에 따라 detailTags {_CANDIDATE_MIN}~{_CANDIDATE_MAX}개를 JSON 한 줄로 출력해." +) + + +_MAX_TOKENS = 250 +"""v1 의 200 보다 +50 — 후보가 5~7개라 output 토큰 약간 증가.""" + + +_TOKEN_RE = re.compile(r"\{(jobRole|primaryCategory|situationTask|action|result)\}") +"""프롬프트 템플릿의 치환 토큰 — 1패스 ``re.sub`` 로만 매치된다.""" + + +def _render_prompt(req: TaggingRequest) -> str: + """변수 토큰을 한글 라벨/STAR 본문으로 치환한 시스템 프롬프트를 반환. + + 1패스 정규식 치환 — 사용자 본문에 ``{result}`` 같은 토큰 문자열이 섞여도 + 재치환되지 않는다. STAR 본문은 ``...`` 등 + XML-like 태그로 경계화해 프롬프트 인젝션 저항성을 높인다. + """ + values = { + "jobRole": JOB_ROLE_LABELS_KO[req.job_role], + "primaryCategory": PRIMARY_CATEGORY_LABELS_KO[req.selected_competency], + "situationTask": f"\n{req.situation_task}\n", + "action": f"\n{req.action}\n", + "result": f"\n{req.result}\n", + } + return _TOKEN_RE.sub(lambda m: values[m.group(1)], _PROMPT_TEMPLATE) + + +def _strip_code_fence(text: str) -> str: + """LLM 이 백틱(1~3개)으로 감쌌으면 벗긴 본문 반환, 아니면 그대로.""" + m = _CODE_FENCE_RE.match(text) + if m: + return m.group(2).strip() + return text + + +def _allowed_tags_for(category: PrimaryCategory) -> frozenset[str]: + """선택된 ``primaryCategory`` 안에서 허용되는 태그 셋. + + 카테고리 외 태그를 후보 단계에서 걸러내 contract (선택 역량 내 태그만 출력) + 를 보장한다. + """ + return TAGS_BY_CATEGORY[category] + + +def _parse_and_validate(raw: str, allowed_tags: frozenset[str] = ALL_TAGS) -> list[str]: + """후보 검증 — 갯수 :data:`_CANDIDATE_MIN`~:data:`_CANDIDATE_MAX`, 풀 내, 중복 X. + + 실패 시 :class:`TaggingValidationError`. 후처리에서 ``_FINAL_TOP`` 으로 자르므로 + 여기서는 후보 단계 갯수만 검증한다. ``allowed_tags`` 는 카테고리별 허용 셋을 + 좁혀 넘길 수 있다 (기본 ``ALL_TAGS``). + """ + text = _strip_code_fence(raw) + try: + payload = orjson.loads(text) + except orjson.JSONDecodeError as exc: + raise TaggingValidationError(f"JSON 파싱 실패: {exc}") from exc + + if not isinstance(payload, dict): + raise TaggingValidationError("응답이 JSON object 가 아님") + + tags = payload.get("detailTags") + if not isinstance(tags, list): + raise TaggingValidationError("detailTags 가 배열이 아니거나 누락") + + if not all(isinstance(t, str) for t in tags): + raise TaggingValidationError("detailTags 에 문자열 아닌 원소 포함") + + str_tags: list[str] = list(tags) + + if not _CANDIDATE_MIN <= len(str_tags) <= _CANDIDATE_MAX: + raise TaggingValidationError( + f"detailTags 후보 갯수 위반: {len(str_tags)} (허용 {_CANDIDATE_MIN}~{_CANDIDATE_MAX})" + ) + + if len(set(str_tags)) != len(str_tags): + raise TaggingValidationError("detailTags 중복 포함") + + out_of_pool = [t for t in str_tags if t not in allowed_tags] + if out_of_pool: + raise TaggingValidationError(f"허용 태그 풀 외 태그 포함: {out_of_pool}") + + return str_tags + + +def _select_top3(candidates: list[str], role: JobRole) -> list[str]: + """직군 가중치 점수 내림차순으로 상위 :data:`_FINAL_TOP` 개를 선택. + + 동점 처리: LLM 응답 순서 우선 (배열 앞쪽 = 본문 근거 강하다는 프롬프트 약속). + + sort key 는 ``(-score, index)`` ascending — score 높은 순, 같은 score 면 index 작은 순. + """ + scored = sorted((-tag_score(t, role), i, t) for i, t in enumerate(candidates)) + return [t for _, _, t in scored[:_FINAL_TOP]] + + +def _build_corrective_user_message(reason: str) -> str: + """2차 시도용 corrective user 메시지.""" + return ( + f"이전 응답이 형식에 맞지 않다. 이유: {reason}. " + f"다시 시도해. 반드시 시스템 프롬프트의 태그 풀 안에서만 " + f"{_CANDIDATE_MIN}~{_CANDIDATE_MAX}개 골라야 하고, " + '`{"detailTags": ["#태그A", "#태그B", "#태그C", "#태그D", "#태그E"]}` 같은 ' + "한 줄 JSON 만 출력. 코드블록(```)·도입어·primaryCategory 키 모두 금지." + ) + + +async def run(req: TaggingRequest, llm: LLMClient, model: str) -> TaggingResponse: + """Tagging v2_postscore 실행.""" + system = _render_prompt(req) + allowed = _allowed_tags_for(req.selected_competency) + log_ctx: dict[str, Any] = { + "job_role": req.job_role.value, + "primary_category": req.selected_competency.value, + } + + first_msg = await llm.create_message( + workload=WorkloadType.TAGGING, + model=model, + system=system, + messages=[{"role": "user", "content": _USER_TRIGGER}], + max_tokens=_MAX_TOKENS, + ) + first_raw = _extract_text(first_msg.content) + + try: + candidates = _parse_and_validate(first_raw, allowed) + except TaggingValidationError as first_err: + logger.warning( + "tagging.validation_failed", + extra={**log_ctx, "attempt": 1, "reason": str(first_err)}, + ) + retry_msg = await llm.create_message( + workload=WorkloadType.TAGGING, + model=model, + system=system, + messages=[ + {"role": "user", "content": _USER_TRIGGER}, + {"role": "assistant", "content": first_raw}, + {"role": "user", "content": _build_corrective_user_message(str(first_err))}, + ], + max_tokens=_MAX_TOKENS, + ) + retry_raw = _extract_text(retry_msg.content) + try: + candidates = _parse_and_validate(retry_raw, allowed) + except TaggingValidationError as retry_err: + logger.warning( + "tagging.validation_failed", + extra={**log_ctx, "attempt": 2, "reason": str(retry_err)}, + ) + raise + + selected = _select_top3(candidates, req.job_role) + logger.info( + "tagging.done", + extra={**log_ctx, "candidate_count": len(candidates), "tag_count": len(selected)}, + ) + return TaggingResponse( + primary_category=req.selected_competency, + detail_tags=selected, + ) diff --git a/experiments/tagging/2026-05-17_v1_baseline.md b/experiments/tagging/2026-05-17_v1_baseline.md new file mode 100644 index 0000000..d48bd80 --- /dev/null +++ b/experiments/tagging/2026-05-17_v1_baseline.md @@ -0,0 +1,158 @@ +# tagging v1_baseline — 10케이스 실측 (2026-05-17) + + + +_생성: `2026-05-17T160028Z` · raw: [2026-05-17T160028Z_v1_baseline.json](results/2026-05-17T160028Z_v1_baseline.json)_ + +## 메트릭 (자동 채움 — 마커 사이는 eval 재실행 시 덮어쓰임) + +### 합산 + +| 항목 | 값 | +| --- | --- | +| 모델 | `claude-haiku-4-5-20251001` | +| variant | `v1_baseline` | +| 케이스 수 | 10 | +| 평균 일치율 | **53.3%** (exact 2 · partial 7 · none 1) | +| 평균 응답시간 | 2,035ms (corrective 포함 합산) | +| 평균 입력 토큰 | 5,431 (corrective 포함 합산) | +| 평균 출력 토큰 | 52 | +| 1건 평균 비용 | $0.0057 (≈ 8.0원) | +| 월 1,000건 비용 | $5.69 (≈ 7,970원) | +| corrective 발생 | 6 / 10 | +| LLM 호출 실패 | 0 | +| 최종 파싱 실패 (422) | 0 | + +### 케이스별 + +| id | 직군 | 카테고리 | 일치율 | corrective | latency | tok in/out | expected | actual | +| --- | --- | --- | ---: | :---: | ---: | --- | --- | --- | +| 01 | 기획 | 기획·실행 | 0% | × | 1146ms | 3431 / 31 | #요구사항정의·#기획구조화·#툴활용 | #UX설계·#구조재설계·#직군간협업 | +| 02 | 기획 | 문제해결·개선 | 66% | ○ | 2220ms | 7237 / 59 | **#문제정의**·#구조재설계·**#논리보완** | **#문제정의**·**#논리보완**·#의견조율 | +| 03 | 기획 | 성찰·성장 | 33% | ○ | 2097ms | 6841 / 63 | #툴활용·#반복개선·**#업무자동화** | **#업무자동화**·#기획구조화·#학습적용 | +| 04 | 디자인 | 발견·분석 | 33% | × | 1005ms | 3304 / 34 | **#경쟁사례분석**·#인사이트도출·#레퍼런스수집 | **#경쟁사례분석**·#사용성평가·#학습적용 | +| 05 | 디자인 | 기획·실행 | 66% | × | 1977ms | 3239 / 35 | **#브랜드기획**·#비주얼디자인·**#발표및설득** | **#브랜드기획**·**#발표및설득**·#프로젝트회고 | +| 06 | 디자인 | 협업·조율 | 100% | ○ | 2317ms | 6735 / 63 | **#직군간협업**·**#의견조율**·**#산출물전달** | **#의견조율**·**#직군간협업**·**#산출물전달** | +| 07 | 개발 | 기획·실행 | 33% | × | 1017ms | 3261 / 36 | **#컴포넌트구현**·#기술리서치·#역량확장 | **#컴포넌트구현**·#아키텍처설계·#학습적용 | +| 08 | 개발 | 성찰·성장 | 33% | ○ | 4469ms | 7045 / 75 | **#프로젝트회고**·#자기객관화·#커리어설계 | **#프로젝트회고**·#레퍼런스수집·#구조재설계 | +| 09 | 개발 | 기획·실행 | 100% | ○ | 2067ms | 6568 / 65 | **#검증및테스트**·**#성능최적화**·**#학습적용** | **#성능최적화**·**#검증및테스트**·**#학습적용** | +| 10 | 개발 | 문제해결·개선 | 66% | ○ | 2032ms | 6649 / 63 | **#원인분석**·**#검증및테스트**·#반복개선 | **#원인분석**·**#검증및테스트**·#학습적용 | + +(굵게: `expected ∩ actual` — 표면 매치) + +### 직군별 평균 일치율 + +| 분류 | 케이스 | 평균 | +| --- | --- | ---: | +| 기획 | 01, 02, 03 | 33% | +| 디자인 | 04, 05, 06 | 67% | +| 개발 | 07, 08, 09, 10 | 58% | + +### 카테고리별 평균 일치율 + +| 분류 | 케이스 | 평균 | +| --- | --- | ---: | +| 기획·실행 | 01, 05, 07, 09 | 50% | +| 문제해결·개선 | 02, 10 | 67% | +| 성찰·성장 | 03, 08 | 33% | +| 발견·분석 | 04 | 33% | +| 협업·조율 | 06 | 100% | + + + +## 요약 + +10케이스 첫 테스트. 평균 일치율 53%, 응답 평균 2초, 1건당 약 8원. 큰 장애는 없었지만 +**LLM 이 응답을 백틱(`` ` ``)으로 감싸는 케이스가 10건 중 6건**이라 그때마다 재시도가 +일어나서 비용·지연이 부풀려졌다. 정규식 한 줄만 보강해도 절반 가까이 회수 가능. + +## 관찰 + +### 1. 응답을 백틱으로 감싸는 경우가 많다 → 재시도 6/10 + +LLM 응답이 `` `{"detailTags": [...]}` `` 처럼 백틱 한 개로 감싸진 채로 옴. +지금 백틱 제거 정규식은 백틱 세 개(```` ``` ````)만 잡고 백틱 한 개는 못 잡아서, +JSON 파싱 실패 → 재시도로 빠진다. 두 번째 시도에서는 모든 케이스가 백틱 없이 정상 +응답 → 코드 쪽 정규식만 한 줄 보강하면 즉시 사라질 문제. 비용 영향이 가장 큰 단일 +원인. + +### 2. 정답이랑 달라도 의미적으로 그럴듯한 케이스가 많다 + +표면적 점수는 expected 와 정확히 일치하는 태그만 정답으로 세는데, 본문 보면 +서로 다른 태그가 둘 다 말이 되는 경우가 잦음: + +- **Case 01 (0%)**: AI 가 고른 `#구조재설계` 는 정답의 `#기획구조화` 랑 거의 같은 + 활동 (노션 DB 구조를 새로 짠 것). 정답 `#툴활용` 만 빠진 게 진짜 실수. +- **Case 03**: AI 가 `#학습적용`, 정답은 `#반복개선`. 본문에 둘 다 근거 있음. +- **Case 04**: AI 가 `#사용성평가`, 정답은 `#레퍼런스수집`. 본문에 "다른 앱을 직접 + 써본다" 가 있으니 둘 다 합리적. +- **Case 07**: AI 가 `#아키텍처설계`, 정답은 `#기술리서치`. 본문에 옵션 조사 + 컴포넌트 + 설계가 같이 등장. + +즉 평균 53% 라는 숫자가 "AI 가 53%만 맞는다" 는 뜻이 아니라, **단일 정답 비교가 +실제 품질을 과소평가**하고 있을 가능성이 높다. + +### 3. 직군별 편차 + +| 직군 | 평균 | +| --- | ---: | +| 기획 | 33% | +| 디자인 | 67% | +| 개발 | 58% | + +기획 케이스가 가장 낮음. 기획 활동(설계 / 명세 / 구조화)은 추상도가 높고 의미가 +겹치는 태그가 많아서 AI 가 expected 와 다른 합리적 태그를 고르는 비율이 높아진 +것으로 보임. + +### 4. 가중치 표가 프롬프트의 30%를 차지하는데 효과가 불확실하다 + +지금은 65개 태그 × 3직군 가중치(High / Mid / Low) 표를 시스템 프롬프트에 통째로 +박아둔다. 입력 토큰의 약 30% 가 이 표. 그런데: + +- Low 친화도 태그가 응답에 그대로 나오는 케이스가 보임 (예: Case 07 의 `#아키텍처설계` + 는 디자인/기획에서 Low — 이번엔 개발 케이스라 OK 했지만, AI 가 표를 보고 골랐는지 + 본문만 보고 골랐는지 응답만으로는 확인 불가) +- 가중치를 바꿀 때마다 LLM 응답이 비결정적으로 흔들릴 위험 + +→ 가중치를 LLM 에 맡기지 말고 우리가 코드로 후처리하는 게 검증·튜닝 측면에서 더 +견고할 수 있음 (§결정 2 참고). + +### 5. 호출/파싱 실패는 0건 — 안정성은 OK + +LLM 호출 자체 에러 0건, 최종 파싱 실패(422) 0건. 재시도 6건은 모두 두 번째에서 +회수됨. 운영 안정성 자체는 문제 없음. + +## 결정 (이 브랜치에서 바로 후속 작업) + +1. **백틱 한 개도 잡도록 정규식 확장 (작은 커밋)** + - 백틱 1개 / 3개 둘 다 strip 가능하게 한 줄 변경 + 테스트 픽스처 한 줄 추가. + - 재시도 6 → 0 으로 줄어들 것 → 비용·지연 약 25~30% 즉시 절감. + +2. **가중치 표를 LLM 에서 빼고 코드 후처리로 옮기기 (메인 작업)** + - **LLM 한테**: 가중치 표 없이 "본문 근거 순으로 5~7개 골라줘" 만 요청 (지금 + 1~3개 요청 → 5~7개 후보). + - **우리 코드**: 받은 후보들에 직군 가중치로 점수 매김 (예: High=3 / Mid=2 / + Low=1) → 상위 3개 잘라서 응답. + - 좋아지는 점: + - 프롬프트 약 1,500 토큰 줄어듦 (현 입력 3,300 → 1,800 정도). 비용 추가 절감. + - 가중치 튜닝이 코드 단위로 가능 (LLM 응답 흔들림 영향 X). + - 직군 바꿀 때 LLM 다시 안 부르고 재계산만 해도 됨. + - 단점: + - LLM 이 직군 힌트 없이 뽑으니 후보군에 직군과 거리 먼 태그가 들어올 수 있음 + → 후보 N 을 5~7개로 늘려서 안전 마진 확보. + - 출력 토큰 +30~50 정도 늘어남 (전체 비용엔 미미). + - 추가 위치: `app/services/tagging/data.py` 에 태그 × 직군 가중치 dict 추가 + + 점수 계산 함수. + +3. **프롬프트 본문 정리 (가중치 빼는 김에 같이)** + - 가중치 표 제거 후 남은 텍스트에서 중복·장식 문구 다듬기. + - JSON 출력 강제 / 코드블록 금지 지시는 유지. + +4. **다시 측정 → 같은 .md 갱신** + - 위 1·2·3 적용 후 eval 다시 돌리면 마커 사이 메트릭만 자동 갱신, 이 "관찰/결정" + 섹션은 그대로 유지됨. 그 결과 보고 `/api/tagging` cutover (더미 → 실 구현) 결정. + +5. **v2 (fewshot / chain-of-thought) 검토는 보류** + - 위 §관찰-2 처럼 단일 정답 점수가 실제 품질을 깎아 먹고 있어서, 평가 방식부터 + 개선해야 variant 비교가 의미 있어짐 (예: "허용 set" 도입 또는 인간 평가). + 운영 데이터 좀 쌓인 뒤 재검토. diff --git a/experiments/tagging/2026-05-18_v2_postscore.md b/experiments/tagging/2026-05-18_v2_postscore.md new file mode 100644 index 0000000..4ca334f --- /dev/null +++ b/experiments/tagging/2026-05-18_v2_postscore.md @@ -0,0 +1,169 @@ +# tagging v2_postscore — 10케이스 실측 (2026-05-18) + + + +_생성: `2026-05-18T121137Z` · raw: [2026-05-18T121137Z_v2_postscore.json](results/2026-05-18T121137Z_v2_postscore.json)_ + +## 메트릭 (자동 채움 — 마커 사이는 eval 재실행 시 덮어쓰임) + +### 합산 + +| 항목 | 값 | +| --- | --- | +| 모델 | `claude-haiku-4-5-20251001` | +| variant | `v2_postscore` | +| 케이스 수 | 10 | +| 평균 일치율 | **63.3%** (exact 2 · partial 8 · none 0) | +| 평균 응답시간 | 1,713ms (corrective 포함 합산) | +| 평균 입력 토큰 | 2,310 (corrective 포함 합산) | +| 평균 출력 토큰 | 54 | +| 1건 평균 비용 | $0.0026 (≈ 3.6원) | +| 월 1,000건 비용 | $2.58 (≈ 3,613원) | +| corrective 발생 | 0 / 10 | +| LLM 호출 실패 | 0 | +| 최종 파싱 실패 (422) | 0 | + +### 케이스별 + +| id | 직군 | 카테고리 | 일치율 | corrective | latency | tok in/out | expected | actual | +| --- | --- | --- | ---: | :---: | ---: | --- | --- | --- | +| 01 | 기획 | 기획·실행 | 66% | × | 1918ms | 2419 / 55 | **#요구사항정의**·**#기획구조화**·#툴활용 | **#기획구조화**·#직군간협업·**#요구사항정의** | +| 02 | 기획 | 문제해결·개선 | 66% | × | 1223ms | 2515 / 51 | **#문제정의**·**#구조재설계**·#논리보완 | **#문제정의**·#기획구조화·**#구조재설계** | +| 03 | 기획 | 성찰·성장 | 33% | × | 1192ms | 2316 / 43 | #툴활용·#반복개선·**#업무자동화** | **#업무자동화**·#UX설계·#논리보완 | +| 04 | 디자인 | 발견·분석 | 100% | × | 1847ms | 2292 / 61 | **#경쟁사례분석**·**#인사이트도출**·**#레퍼런스수집** | **#경쟁사례분석**·**#레퍼런스수집**·**#인사이트도출** | +| 05 | 디자인 | 기획·실행 | 33% | × | 2047ms | 2227 / 56 | **#브랜드기획**·#비주얼디자인·#발표및설득 | **#브랜드기획**·#시각화작업·#인사이트도출 | +| 06 | 디자인 | 협업·조율 | 100% | × | 1738ms | 2263 / 55 | **#직군간협업**·**#의견조율**·**#산출물전달** | **#의견조율**·**#직군간협업**·**#산출물전달** | +| 07 | 개발 | 기획·실행 | 66% | × | 1637ms | 2249 / 57 | **#컴포넌트구현**·**#기술리서치**·#역량확장 | **#컴포넌트구현**·#아키텍처설계·**#기술리서치** | +| 08 | 개발 | 성찰·성장 | 33% | × | 2204ms | 2415 / 58 | **#프로젝트회고**·#자기객관화·#커리어설계 | **#프로젝트회고**·#학습적용·#업무방식개선 | +| 09 | 개발 | 기획·실행 | 66% | × | 1686ms | 2179 / 55 | **#검증및테스트**·**#성능최적화**·#학습적용 | **#성능최적화**·#아키텍처설계·**#검증및테스트** | +| 10 | 개발 | 문제해결·개선 | 66% | × | 1635ms | 2220 / 52 | **#원인분석**·**#검증및테스트**·#반복개선 | **#원인분석**·**#검증및테스트**·#디버깅 | + +(굵게: `expected ∩ actual` — 표면 매치) + +### 직군별 평균 일치율 + +| 분류 | 케이스 | 평균 | +| --- | --- | ---: | +| 기획 | 01, 02, 03 | 56% | +| 디자인 | 04, 05, 06 | 78% | +| 개발 | 07, 08, 09, 10 | 58% | + +### 카테고리별 평균 일치율 + +| 분류 | 케이스 | 평균 | +| --- | --- | ---: | +| 기획·실행 | 01, 05, 07, 09 | 58% | +| 문제해결·개선 | 02, 10 | 67% | +| 성찰·성장 | 03, 08 | 33% | +| 발견·분석 | 04 | 100% | +| 협업·조율 | 06 | 100% | + + + +## 요약 + +v2 의 세 가지 변경(백틱 strip 정규식 보강 + 가중치 표 LLM 외부 후처리 + 프롬프트 정리)이 +실측에서 다 일관되게 효과. **평균 일치율 53% → 63%, 1건 비용 약 8원 → 3.6원, 재시도 +6/10 → 0/10.** v1 의 핵심 부담 (백틱 재시도 + 비싼 프롬프트) 이 거의 해소됐고, 표면 +점수도 한 단계 올라옴. + +## 관찰 + +### 1. 백틱 strip 보강은 효과적 + +raw 응답을 보면 10건 **전부** single backtick(`` ` ``) 으로 감싸져 옴. 그런데 1차에서 +strip 처리되어 재시도가 한 번도 안 일어남 (v1 에서는 같은 패턴이 6건 재시도로 빠졌음). +정규식 한 줄 변경의 효과가 가장 큼 — 평균 토큰·비용 절감의 큰 축. + +### 2. 비용·지연이 절반 가까이 떨어졌다 + +| 항목 | v1 (이전 측정) | v2 | 변화 | +| --- | ---: | ---: | --- | +| 평균 입력 토큰 | 5,431 | 2,310 | **-57%** | +| 1건 비용 | $0.0057 | $0.0026 | **-55%** | +| 평균 응답시간 | 2,035ms | 1,713ms | -16% | + +큰 절감 두 원인: ① 가중치 표 제거 (~1,500 tok 절감), ② 재시도 0 으로 떨어진 만큼 두 +번째 호출 비용 제거. + +### 3. 후처리가 의미 있게 작동 - 특히 디자인 직군 + +LLM 이 5~7개 후보를 뽑고 우리가 직군 가중치로 top 3 자르는 흐름. 케이스 04 (디자인 · +발견·분석) 와 06 (디자인 · 협업·조율) 은 **100% 매치**. 디자인 평균 78% 로 가장 높음. + +예 — 케이스 01 (기획): +- LLM 후보 6개: `#기획구조화, #직군간협업, #요구사항정의, #프로세스개선, #툴활용, #피드백수용` +- 기획 가중치로 점수: 5개가 High(3), `#툴활용` 만 Mid(2) +- 동점은 입력 순서대로 → top 3 = `#기획구조화, #직군간협업, #요구사항정의` +- 결과 2/3 매치 (`#기획구조화` `#요구사항정의`) +- `#툴활용` 은 점수 낮아 뒤로 밀려서 빠짐 — 기획자에게 `#툴활용` 이 Mid 라는 가중치가 + 정답을 깎아 먹은 케이스. 데이터 다듬을 여지 있음. + +### 4. 정답 누락 패턴은 일부 남아 있다 + +Case 03 · 05 · 08 (33%) 은 expected 가 LLM 후보 5~7개 자체에 없는 케이스. 본문 보면 +LLM 이 의미적으로 합리적인 다른 태그를 골랐고 (예: 03 의 `#논리보완`, 05 의 `#시각화작업`, +08 의 `#학습적용`), 후처리로도 살릴 방법 없음. v1 측정과 같은 "단일 expected" metric 의 +한계. + +### 5. 100% 매치 케이스가 2건 (none 0건) + +v1 에서 1건 있었던 0%(none) 케이스가 v2 에서는 0건. 모든 케이스가 최소 partial 이상으로 +정렬됐고, 100% (Case 04, 06) 도 2건 나옴. metric 변동성이 큰 점은 그대로지만 floor 가 +올라간 추세. + +### 6. 직군별 평균 + +| 직군 | v1 측정 | v2 측정 | +| --- | ---: | ---: | +| 기획 | 33% | 56% | +| 디자인 | 67% | 78% | +| 개발 | 58% | 58% | + +기획·디자인 모두 상승, 개발 동일. 가중치 후처리가 기획·디자인 케이스에서 더 의미 있게 +작동한 듯. + +## 결정 + +v2 효과는 명확. 다만 default 전환·cutover 확정 전에 한 단계 더 짜낼 여지가 있는지 +검토한다. **기획·데이터(태그풀/expected/가중치 H·M·L 분포)는 건드리지 않고 코드만** +만지는 후보: + +### 검토 후보 + +1. **점수 매핑 격차 넓히기 (가장 작고 효과 클 듯)** + - 지금 `H=3 · M=2 · L=1`. Case 01 의 `#툴활용` 처럼 정답이 Mid 라서 H 들에 동점으로 + 밀려 빠지는 케이스가 자주 보임. + - 비율을 더 벌리면 (예: `5/2/1` 또는 `10/3/1`) **H/M 사이 격차가 커져서 후처리에서 + Mid 가 H 와 같은 우선순위로 끼지 못함** → 동점 빈도 ↓. + - 단, 너무 벌리면 정답이 Mid 인 케이스에서 오히려 손해. 한두 케이스로 점수 매핑 + ablation 측정해 보는 게 좋음. + +2. **LLM 후보 갯수 5~7 → 7~10 확장** + - Case 03·05·08 의 33% 는 전부 **LLM 후보 5~7개 안에 정답 자체가 없어서** 발생. + 후보 늘리면 회수 가능성 ↑. + - 단점: 출력 토큰 ~30~50 추가, prompt 의 "5~7" 지시도 변경. 비용 영향은 미미 + (~$0.0001 추가). + +3. **동점 처리에 LLM 응답 순서를 점수와 결합** + - 지금 동점은 단순 입력 순서. 점수 + 응답 순서 약한 가중치 (예: `score - α*index`) + 로 LLM 의 "근거 강한 순" 신호를 좀 더 살림. + - Case 01 의 경우 LLM 첫 번째가 `#기획구조화` 라 그건 살아남았지만, 만약 `#툴활용` + 이 LLM 응답 1~2번째였다면 점수 격차로 밀린 게 부당. 이런 케이스 보호. + +4. **사용자 확정 `primaryCategory` 약 부스트** + - 같은 카테고리 태그에 +0.5 같은 약한 가산점. "카테고리 무관 풀 오픈" 기조는 유지 + 하되 동점 tie-break 보조. + - Case 02 처럼 정답이 본인 카테고리(문제해결·개선) 인 경우 잘 잡힐 가능성 ↑. + +### 진행 제안 + +- **1·2·3 을 한 묶음으로** 다음 measurement round 에서 같이 시도. 셋 다 같은 후처리 + 알고리즘 영역이라 자연스럽게 묶임. 결과 보고 v2 default 전환 vs 추가 보강 결정. +- **4** 는 1·2·3 효과 본 다음 별도 시도 (effect size 작을 수 있음). +- **`/api/tagging` cutover 는 위 1·2·3 측정 결과 본 다음.** v2 그대로 default 로 가도 좋고, 한 번 더 다듬어서 가도 좋을 듯. + +### 측정 metric 자체의 한계는 그대로 보류 +- 표면 일치율이 ±10%p 출렁이는 변동성은 v2 에서도 동일. +- "허용 set" 도입 또는 인간 평가는 기획·평가 데이터 영역이라 본 흐름에서는 보류. +- 운영 데이터 쌓인 뒤 재검토. \ No newline at end of file diff --git a/experiments/tagging/_template.md b/experiments/tagging/_template.md new file mode 100644 index 0000000..0687933 --- /dev/null +++ b/experiments/tagging/_template.md @@ -0,0 +1,56 @@ +# tagging {{VARIANT}} — {{N_CASES}}케이스 실측 ({{DATE}}) + + + +_생성: `{{RAN_AT}}` · raw: [{{RAW_JSON_NAME}}](results/{{RAW_JSON_NAME}})_ + +## 메트릭 (자동 채움 — 마커 사이는 eval 재실행 시 덮어쓰임) + +### 합산 + +| 항목 | 값 | +| --- | --- | +| 모델 | `{{MODEL}}` | +| variant | `{{VARIANT}}` | +| 케이스 수 | {{N_CASES}} | +| 평균 일치율 | **{{AVG_SCORE}}%** (exact {{EXACT}} · partial {{PARTIAL}} · none {{NONE}}) | +| 평균 응답시간 | {{AVG_LATENCY}}ms (corrective 포함 합산) | +| 평균 입력 토큰 | {{AVG_INPUT}} (corrective 포함 합산) | +| 평균 출력 토큰 | {{AVG_OUTPUT}} | +| 1건 평균 비용 | ${{AVG_COST_USD}} (≈ {{AVG_COST_KRW}}원) | +| 월 1,000건 비용 | ${{MONTHLY_COST_USD}} (≈ {{MONTHLY_COST_KRW}}원) | +| corrective 발생 | {{N_CORRECTIVE}} / {{N_CASES}} | +| LLM 호출 실패 | {{N_LLM_ERR}} | +| 최종 파싱 실패 (422) | {{N_PARSE_FAIL}} | + +### 케이스별 + +{{CASE_TABLE}} + +(굵게: `expected ∩ actual` — 표면 매치) + +### 직군별 평균 일치율 + +{{ROLE_TABLE}} + +### 카테고리별 평균 일치율 + +{{CATEGORY_TABLE}} + + + +## 요약 + +> TODO (1~2줄): 결과 핵심 정리. 평균 일치율 · corrective 발생 여부 · 이전 variant 대비 변화 등. + +## 관찰 + +> TODO: 케이스별 raw response 보면서 인사이트 정리. 자주 유용한 항목: +> - 어느 케이스가 expected 와 크게 달랐는지, 의미적으로는 합리적인지 +> - corrective 발생 패턴 (코드블록 / 갯수 / 풀-외 / 기타) +> - 직군 / 카테고리별 편차 해석 +> - 일치율 metric 의 표면적 한계 (semantic vs surface) + +## 결정 + +> TODO: 활성 variant 유지/교체 · 후속 PR 권고 · 다음 variant 검토 여부 등. diff --git a/experiments/tagging/eval_v1_baseline.py b/experiments/tagging/eval_v1_baseline.py new file mode 100644 index 0000000..15d51c6 --- /dev/null +++ b/experiments/tagging/eval_v1_baseline.py @@ -0,0 +1,439 @@ +"""Tagging v1_baseline 평가 스크립트 — 10케이스 실측. + +``experiments/tagging/fixtures/eval_set_v1.jsonl`` 의 10케이스를 실제 Anthropic API 로 +태우고 일치율 / 응답시간 / 토큰 / 비용을 측정한다. **service 의 ``run()`` 을 그대로 호출** +하므로 corrective 재시도가 발생하면 그 호출도 합산되어 집계된다 (운영과 동일한 흐름). + +실행: + uv run python experiments/tagging/eval_v1_baseline.py + +준비: + .env 의 ``ANTHROPIC_API_KEY`` 가 유효한 실 키여야 한다. 잘못된 키면 LLM_ERR 로 떨어진다. + +결과: + - stdout: 케이스별 + 합산 메트릭 + - JSON dump: ``experiments/tagging/results/{timestamp}_{variant}.json`` + → ``experiments/tagging/_.md`` 작성 시 참조 (README §실험결과기록) +""" + +from __future__ import annotations + +import asyncio +import json +import re +import sys +import time +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, cast + +from app.core.config import get_settings +from app.core.logging import configure_logging +from app.schemas.common import ( + JOB_ROLE_LABELS_KO, + PRIMARY_CATEGORY_LABELS_KO, + JobRole, + PrimaryCategory, +) +from app.schemas.tagging import TaggingRequest, TaggingResponse +from app.services._clients.exceptions import LLMError +from app.services._clients.llm_client import LLMClient +from app.services.tagging import run as tagging_run +from app.services.tagging.exceptions import TaggingValidationError + +# Anthropic 가격 (USD per million tokens) — claude-haiku-4-5-20251001 +# https://www.anthropic.com/pricing (2026-05 기준) +_PRICE_INPUT_PER_MTOK = 1.0 +_PRICE_OUTPUT_PER_MTOK = 5.0 +_USD_TO_KRW = 1400 # 대략 환산. 1건/월비용 표시용 + +_HERE = Path(__file__).parent +_FIXTURES = _HERE / "fixtures" / "eval_set_v1.jsonl" +_RESULTS_DIR = _HERE / "results" +_TEMPLATE_PATH = _HERE / "_template.md" + +_PLACEHOLDER_KEYS = frozenset({"", "dev-no-api-key", "sk-ant-"}) + +# 마커 사이 메트릭 블록만 in-place 교체 (사람이 적은 "관찰/결정" 보존) +_METRIC_BLOCK_RE = re.compile( + r".*?", + re.DOTALL, +) + + +class _LLMTracker: + """``LLMClient`` 를 감싸 각 호출의 토큰·지연·응답 텍스트를 기록한다. + + ``v1_baseline.run()`` 이 corrective 재시도 시 LLMClient 를 2번 부르는데, 그 두 호출의 + metric 을 모두 수집해 합산 비용·토큰을 정확히 산출하기 위함이다. + """ + + def __init__(self, inner: LLMClient) -> None: + self._inner = inner + self.calls: list[dict[str, Any]] = [] + + async def create_message(self, **kwargs: Any) -> Any: + start = time.perf_counter() + msg = await self._inner.create_message(**kwargs) + latency_ms = int((time.perf_counter() - start) * 1000) + text = "".join(getattr(b, "text", "") for b in msg.content).strip() + self.calls.append( + { + "input_tokens": msg.usage.input_tokens, + "output_tokens": msg.usage.output_tokens, + "latency_ms": latency_ms, + "response_text": text, + } + ) + return msg + + +def _load_fixtures(path: Path) -> list[dict[str, Any]]: + return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] + + +def _match_score(expected: list[str], actual: list[str]) -> float: + """expected ∩ actual / expected. 0~1. expected 가 비어있으면 0.""" + if not expected: + return 0.0 + return len(set(expected) & set(actual)) / len(expected) + + +async def _eval_one(inner_llm: LLMClient, model: str, case: dict[str, Any]) -> dict[str, Any]: + """1케이스를 service.run() 으로 태우고 metric 집계.""" + req = TaggingRequest.model_validate(case["input"]) + tracker = _LLMTracker(inner_llm) + + response: TaggingResponse | None = None + parse_err: str | None = None + llm_err: str | None = None + + start = time.perf_counter() + try: + response = await tagging_run(req, cast(LLMClient, tracker), model) + except TaggingValidationError as e: + parse_err = str(e) + except LLMError as e: + llm_err = f"{type(e).__name__}: {e}" + total_latency_ms = int((time.perf_counter() - start) * 1000) + + total_in = sum(c["input_tokens"] for c in tracker.calls) + total_out = sum(c["output_tokens"] for c in tracker.calls) + cost_usd = ( + total_in / 1_000_000 * _PRICE_INPUT_PER_MTOK + + total_out / 1_000_000 * _PRICE_OUTPUT_PER_MTOK + ) + actual = response.detail_tags if response else [] + + return { + "id": case["id"], + "title": case.get("title", ""), + "job_role": req.job_role.value, + "primary_category": req.selected_competency.value, + "expected": case["expected_tags"], + "actual": actual, + "match_score": _match_score(case["expected_tags"], actual), + "n_llm_calls": len(tracker.calls), + "had_corrective": len(tracker.calls) > 1, + "total_latency_ms": total_latency_ms, + "input_tokens": total_in, + "output_tokens": total_out, + "cost_usd": cost_usd, + "parse_error": parse_err, + "llm_error": llm_err, + "raw_calls": tracker.calls, + } + + +def _print_per_case(results: list[dict[str, Any]]) -> None: + print("\n=== 케이스별 결과 ===") + for r in results: + if r["llm_error"]: + status = "LLM_ERR" + elif r["parse_error"]: + status = "PARSE_ERR" + else: + status = f"{int(r['match_score'] * 100)}%" + corrective = " (corrective)" if r["had_corrective"] else "" + print( + f" [{r['id']}] {r['job_role']:<10} {r['primary_category']:<20} " + f"{status:<10} {r['total_latency_ms']}ms " + f"tok={r['input_tokens']}/{r['output_tokens']} cost=${r['cost_usd']:.4f}{corrective}" + ) + print(f" expected : {r['expected']}") + if r["llm_error"]: + print(f" ! llm error: {r['llm_error']}") + elif r["parse_error"]: + print(f" ! parse err: {r['parse_error']}") + else: + print(f" actual : {r['actual']}") + + +def _print_summary(results: list[dict[str, Any]]) -> None: + n = len(results) + ok = [r for r in results if not r["parse_error"] and not r["llm_error"]] + n_ok = len(ok) + n_parse = sum(1 for r in results if r["parse_error"]) + n_llm = sum(1 for r in results if r["llm_error"]) + n_corrective = sum(1 for r in results if r["had_corrective"]) + + print(f"\n=== 합산 (총 {n}건) ===") + print(f" 파싱 성공 : {n_ok} · 파싱 실패 : {n_parse} · LLM 실패 : {n_llm}") + print(f" corrective 발생 : {n_corrective}건") + if not ok: + return + + avg_score = sum(r["match_score"] for r in ok) / n_ok + exact = sum(1 for r in ok if r["match_score"] >= 1.0) + partial = sum(1 for r in ok if 0 < r["match_score"] < 1.0) + none_ = sum(1 for r in ok if r["match_score"] == 0) + avg_latency = sum(r["total_latency_ms"] for r in ok) / n_ok + avg_input = sum(r["input_tokens"] for r in ok) / n_ok + avg_output = sum(r["output_tokens"] for r in ok) / n_ok + avg_cost = sum(r["cost_usd"] for r in ok) / n_ok + + breakdown = f"exact {exact} · partial {partial} · none {none_}" + print(f" 평균 일치율 : {avg_score * 100:.1f}% ({breakdown})") + print(f" 평균 응답시간 : {avg_latency:.0f}ms (총 호출 합산 기준)") + print(f" 평균 입력토큰 : {avg_input:.0f} (corrective 포함 합산)") + print(f" 평균 출력토큰 : {avg_output:.0f}") + print(f" 1건 비용 : ${avg_cost:.4f} (≈ {avg_cost * _USD_TO_KRW:.2f}원)") + print(f" 월 1,000건 : ${avg_cost * 1000:.2f} (≈ {avg_cost * 1000 * _USD_TO_KRW:.0f}원)") + + +def _rel(path: Path) -> Path | str: + """cwd 안이면 relative, 밖이면 absolute — 표시 전용 (tmpdir dry-run 호환).""" + try: + return path.relative_to(Path.cwd()) + except ValueError: + return str(path) + + +def _bold_overlap(tags: list[str], overlap: set[str]) -> str: + """expected/actual 표시용 — 교집합 태그만 ``**bold**``.""" + return "·".join(f"**{t}**" if t in overlap else t for t in tags) + + +def _build_case_table(results: list[dict[str, Any]]) -> str: + """케이스별 markdown 표 (헤더 포함).""" + lines = [ + "| id | 직군 | 카테고리 | 일치율 | corrective | latency | tok in/out | expected | actual |", + "| --- | --- | --- | ---: | :---: | ---: | --- | --- | --- |", + ] + for r in results: + if r["llm_error"]: + score = "LLM_ERR" + elif r["parse_error"]: + score = "PARSE_ERR" + else: + score = f"{int(r['match_score'] * 100)}%" + corrective = "○" if r["had_corrective"] else "×" # noqa: RUF001 + role = JOB_ROLE_LABELS_KO[JobRole(r["job_role"])] + cat = PRIMARY_CATEGORY_LABELS_KO[PrimaryCategory(r["primary_category"])] + overlap = set(r["expected"]) & set(r["actual"]) + exp = _bold_overlap(r["expected"], overlap) + act = _bold_overlap(r["actual"], overlap) if r["actual"] else "-" + lines.append( + f"| {r['id']} | {role} | {cat} | {score} | {corrective} | " + f"{r['total_latency_ms']}ms | {r['input_tokens']} / {r['output_tokens']} | " + f"{exp} | {act} |" + ) + return "\n".join(lines) + + +def _build_group_table( + results: list[dict[str, Any]], + group_key: str, + label_map: dict[Any, str], + enum_cls: type[Any], +) -> str: + """직군/카테고리 등 그룹별 평균 일치율 표.""" + buckets: dict[str, list[float]] = {} + case_ids: dict[str, list[str]] = {} + for r in results: + if r["parse_error"] or r["llm_error"]: + continue + k = r[group_key] + buckets.setdefault(k, []).append(r["match_score"]) + case_ids.setdefault(k, []).append(r["id"]) + if not buckets: + return "_(파싱 성공 케이스 없음)_" + lines = ["| 분류 | 케이스 | 평균 |", "| --- | --- | ---: |"] + for k, scores in buckets.items(): + label = label_map.get(enum_cls(k), k) + avg = sum(scores) / len(scores) * 100 + ids = ", ".join(case_ids[k]) + lines.append(f"| {label} | {ids} | {avg:.0f}% |") + return "\n".join(lines) + + +def _summary_context( + results: list[dict[str, Any]], + variant: str, + model: str, + ran_at: str, + raw_json_name: str, +) -> dict[str, str]: + """템플릿 placeholder → 값 매핑 dict.""" + n = len(results) + ok = [r for r in results if not r["parse_error"] and not r["llm_error"]] + n_ok = len(ok) + n_parse = sum(1 for r in results if r["parse_error"]) + n_llm = sum(1 for r in results if r["llm_error"]) + n_corrective = sum(1 for r in results if r["had_corrective"]) + + if ok: + avg_score = sum(r["match_score"] for r in ok) / n_ok + exact = sum(1 for r in ok if r["match_score"] >= 1.0) + partial = sum(1 for r in ok if 0 < r["match_score"] < 1.0) + none_ = sum(1 for r in ok if r["match_score"] == 0) + avg_latency = sum(r["total_latency_ms"] for r in ok) / n_ok + avg_input = sum(r["input_tokens"] for r in ok) / n_ok + avg_output = sum(r["output_tokens"] for r in ok) / n_ok + avg_cost = sum(r["cost_usd"] for r in ok) / n_ok + else: + avg_score = exact = partial = none_ = 0 + avg_latency = avg_input = avg_output = avg_cost = 0.0 + + return { + "MODEL": model, + "VARIANT": variant, + "RAN_AT": ran_at, + "DATE": ran_at[:10], + "N_CASES": str(n), + "AVG_SCORE": f"{avg_score * 100:.1f}", + "EXACT": str(exact), + "PARTIAL": str(partial), + "NONE": str(none_), + "AVG_LATENCY": f"{avg_latency:,.0f}", + "AVG_INPUT": f"{avg_input:,.0f}", + "AVG_OUTPUT": f"{avg_output:,.0f}", + "AVG_COST_USD": f"{avg_cost:.4f}", + "AVG_COST_KRW": f"{avg_cost * _USD_TO_KRW:.1f}", + "MONTHLY_COST_USD": f"{avg_cost * 1000:.2f}", + "MONTHLY_COST_KRW": f"{avg_cost * 1000 * _USD_TO_KRW:,.0f}", + "N_CORRECTIVE": str(n_corrective), + "N_LLM_ERR": str(n_llm), + "N_PARSE_FAIL": str(n_parse), + "RAW_JSON_NAME": raw_json_name, + "CASE_TABLE": _build_case_table(results), + "ROLE_TABLE": _build_group_table(results, "job_role", JOB_ROLE_LABELS_KO, JobRole), + "CATEGORY_TABLE": _build_group_table( + results, "primary_category", PRIMARY_CATEGORY_LABELS_KO, PrimaryCategory + ), + } + + +def _render_full_md(template: str, ctx: dict[str, str]) -> str: + """템플릿의 모든 ``{{KEY}}`` 를 ctx 값으로 치환.""" + out = template + for key, value in ctx.items(): + out = out.replace(f"{{{{{key}}}}}", value) + return out + + +def _upsert_md(out_path: Path, ctx: dict[str, str]) -> str: + """결과 .md upsert. + + - 마커 사이 메트릭 블록만 in-place 교체하여 사람이 적은 "관찰/결정" 섹션을 보존한다. + - 파일이 없으면 ``_template.md`` 기반으로 새로 작성한다. + - 파일은 있지만 마커가 없으면 (옛 수동 작성 .md) 충돌 회피로 ``_`` suffix 의 + alt 파일을 새로 만들고, 기존 파일은 건드리지 않는다. + + Returns: + 사용자에게 보여줄 상태 문자열. + """ + rendered = _render_full_md(_TEMPLATE_PATH.read_text(encoding="utf-8"), ctx) + new_block_match = _METRIC_BLOCK_RE.search(rendered) + assert new_block_match, "_template.md 에 AUTO:START/END METRICS 마커가 있어야 한다" + new_block = new_block_match.group(0) + + if not out_path.exists(): + out_path.write_text(rendered, encoding="utf-8") + return f"created → {_rel(out_path)}" + + existing = out_path.read_text(encoding="utf-8") + if _METRIC_BLOCK_RE.search(existing): + updated = _METRIC_BLOCK_RE.sub(lambda _m: new_block, existing, count=1) + out_path.write_text(updated, encoding="utf-8") + return f"updated (인사이트 보존) → {_rel(out_path)}" + + # 기존 파일에 마커가 없음 — 옛 수동 작성. 건드리지 않고 alt 생성. + ts = datetime.now(UTC).strftime("%Y-%m-%dT%H%M%SZ") + alt = out_path.with_name(f"{out_path.stem}__autorendered_{ts}{out_path.suffix}") + alt.write_text(rendered, encoding="utf-8") + return f"conflict (기존 파일에 마커 없음, 건드리지 않음). alt 생성 → {_rel(alt)}" + + +def _save_raw(results: list[dict[str, Any]], variant: str, model: str, ran_at: str) -> Path: + _RESULTS_DIR.mkdir(exist_ok=True) + path = _RESULTS_DIR / f"{ran_at}_{variant}.json" + path.write_text( + json.dumps( + { + "variant": variant, + "model": model, + "ran_at": ran_at, + "n_cases": len(results), + "cases": results, + }, + ensure_ascii=False, + indent=2, + ) + ) + return path + + +async def main() -> None: + configure_logging() + settings = get_settings() + + if settings.anthropic_api_key in _PLACEHOLDER_KEYS: + sys.exit("ANTHROPIC_API_KEY 가 비어있거나 placeholder. .env 를 확인하세요.") + + cases = _load_fixtures(_FIXTURES) + print(f"Loaded {len(cases)} cases from {_FIXTURES.name}") + print(f"Variant: {settings.tagging_variant}") + print(f"Model: {settings.anthropic_model}") + print() + + llm = LLMClient(api_key=settings.anthropic_api_key) + try: + results: list[dict[str, Any]] = [] + for case in cases: + short = (case.get("title") or "")[:40] + print(f" [{case['id']}] {short}...", flush=True, end=" ") + r = await _eval_one(llm, settings.anthropic_model, case) + if r["llm_error"]: + tag = "LLM_ERR" + elif r["parse_error"]: + tag = "PARSE_ERR" + else: + tag = f"{int(r['match_score'] * 100)}%" + print(f"{tag:>10} {r['total_latency_ms']}ms") + results.append(r) + finally: + await llm.aclose() + + _print_per_case(results) + _print_summary(results) + + ran_at = datetime.now(UTC).strftime("%Y-%m-%dT%H%M%SZ") + saved_json = _save_raw(results, settings.tagging_variant, settings.anthropic_model, ran_at) + print(f"\nRaw saved → {saved_json.relative_to(Path.cwd())}") + + md_path = _HERE / f"{ran_at[:10]}_{settings.tagging_variant}.md" + ctx = _summary_context( + results=results, + variant=settings.tagging_variant, + model=settings.anthropic_model, + ran_at=ran_at, + raw_json_name=saved_json.name, + ) + status = _upsert_md(md_path, ctx) + print(f"MD → {status}") + print("→ '관찰' / '결정' 섹션을 채워서 commit 하면 README §실험결과기록 컨벤션 충족.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/experiments/tagging/fixtures/eval_set_v1.jsonl b/experiments/tagging/fixtures/eval_set_v1.jsonl new file mode 100644 index 0000000..1d1dab2 --- /dev/null +++ b/experiments/tagging/fixtures/eval_set_v1.jsonl @@ -0,0 +1,10 @@ +{"id": "01", "title": "팀원용 데일리 STAR 기록 DB 제작", "input": {"jobRole": "PLANNER", "selectedCompetency": "PLANNING_EXECUTION", "situationTask": "서비스 내 STAR 심화 기록 UX를 매끄럽게 기획함에 있어 실제 유저들에게 작성을 부탁하고 피드백을 얻는 과정이 필요했으며, 이후 백엔드가 AI를 정교하게 고도화 함에 있어 실제 유저들이 작성하는 퀄리티 높은 데이터가 필요했다.", "action": "각 파트의 팀원들이 직접 매일 스크럼과 STAR 기록을 작성할 수 있는 노션 DB를 만들어 추후 백엔드가 활용할 수 있는 데이터를 쌓고, 피드백을 받을 수 있는 구조로 설계했다. 현재 서비스 구조 상 스크럼과 심화 STAR 기록 2단계에 대한 기록을 동시에 할 수 있어야 했고, 5대 직무 역량/직군별로 제시되는 가이드가 달라져야 했는데, 백엔드가 데이터로 활용하기 유리하면서도 팀원들이 기록하기 쉬워야 했기에 동시에 만족하는 구조를 설계하는 것이 어려웠다.", "result": "한 DB에서 스크럼과 심화STAR기록을 모두 할 수 있고, 5대 직무 역량 별, 직군별 세부 가이드를 다르게 참고할 수 있으며, 팀원들도 최대한 쉽게 기록할 수 있는 방향성으로 최종 활용할 DB를 만들었다. 이 과정에서 노션 DB 활용 능력을 더 키울 수 있었고, 타 파트의 입장에서의 데이터 활용 용이성을 함께 고려해볼 수 있었다."}, "expected_tags": ["#요구사항정의", "#기획구조화", "#툴활용"]} +{"id": "02", "title": "5대 직무 역량 & 세부 역량 태그 설계 재고민", "input": {"jobRole": "PLANNER", "selectedCompetency": "PROBLEM_SOLVING", "situationTask": "기록 화면에서 심화 기록에 대한 세부 역량이 태깅되고 있다는 안내 화면의 와이어프레임을 그리는 중, 현재 우리가 설계한 '세부 역량 태그'의 명칭에 대한 의문이 들었다. 현재 태그는 기록된 경험 자체에 대한 것이기에 역량보다는 '경험 태그'에 가깝지 않나 하는 고민이었고, 이어 5대 직무 역량 또한 현재 구조 상 직군별, 프젝 진행 상황 별로 유사해질 수 밖에 없을 것 같은데 어떻게 의미를 창출할 수 있을지 고민이 되었다.", "action": "현재 두 기능이 해당 서비스의 근간이 되기에 디자인과 개발이 시작되지 않은 지금 명확히 해결되어야 한다 생각해 와이어프레임 제작을 멈추고 다시 재설계에 들어갔다. 이를 해결하기 위해 현재 해당 기능 기획에 대한 설계와 의도, 근거를 다시 명확히 정리하고, 현재 파악한 설계 상의 의문 점들을 정리해 어떻게 정리하고 해결하면 좋을지 클로드와 설계 검토 및 대안 제시 중심으로 논의했다.", "result": "결과적으로 세부 역량 태그는 그 자체로는 경험 중심이나, 이가 누적되었을 때 개인의 역량 데이터로 활용되기에 명칭 자체는 유지되어도 되지만 서비스 내에서 이를 '역량이 태깅되는 중이다'라고 명시하지 않기로 했다. 동시에 5대 직무 역량의 경우 기획은 유지하되, 현재와 같이 단순 빈도로 체크된다면 역량 빈도=강점 으로 오인될 여지가 있어 구조 설계를 디벨롭하기로 했다."}, "expected_tags": ["#문제정의", "#구조재설계", "#논리보완"]} +{"id": "03", "title": "기능명세서 작성 완료", "input": {"jobRole": "PLANNER", "selectedCompetency": "GROWTH", "situationTask": "다음 주까지 기능명세서와 와이어프레임을 설계해야 하는 빠듯한 일정 속에서, 오늘 하루 안에 전체 기능에 대한 기능명세서를 완성하고자 했습니다. 제한된 시간 내에 핵심 기능을 빠짐없이 정의하고, 이후 와이어프레임 설계까지 이어질 수 있도록 하고자 하였습니다.", "action": "작성 속도를 높이기 위해 클로드 코드 기반 자동화를 활용해 기존 기획 문서를 학습시키고, 이를 바탕으로 기능명세서 초안을 빠르게 생성했습니다. 이후 생성된 초안을 그대로 사용하는 것이 아니라, 실제 서비스 흐름과 UX를 고려해 디테일한 조건, 예외 처리, 문구 등을 직접 수정하며 완성도를 높였습니다.", "result": "그 결과 약 32개 항목의 기능명세서를 빠르게 작성 완료할 수 있었고, 빠듯한 일정 속에서도 전체 기능 구조를 안정적으로 정리할 수 있었습니다. 이 경험을 통해 기획 업무에서도 AI 기반 자동화를 적극적으로 활용하는 것이 생산성을 크게 높일 수 있다는 점을 체감하게 되었습니다."}, "expected_tags": ["#툴활용", "#반복개선", "#업무자동화"]} +{"id": "04", "title": "기록 화면 디자인 작업 중 레퍼런스 분석", "input": {"jobRole": "DESIGNER", "selectedCompetency": "DISCOVERY_ANALYSIS", "situationTask": "기록 화면 디자인 작업 중에 텍스트 입력 영역 레이아웃을 어떻게 잡을지 막혔음. 입력 요소가 많아서 화면이 무거워 보이는 문제가 있었는데, 다른 서비스들은 이걸 어떻게 처리하는지 봐야겠다고 생각했음.", "action": "노션, 클래프트, 데이원 같은 기록·일지 앱들을 직접 써보면서 입력 영역 구성, 타이포 처리, 여백 방식을 각각 항목으로 정리했음. 스크린샷만 모으는 게 아니라 \"이 앱은 왜 이렇게 했을까\"를 하나씩 써봤음. 비슷해 보여도 실제로 써보면 입력 흐름이 꽤 다르다는 걸 느껴서, 직접 써보는 걸 원칙으로 삼았음.", "result": "입력 요소가 많아도 구조감을 살릴 수 있는 방식이 있다는 걸 파악했고, 막혔던 레이아웃 방향을 잡을 수 있었음. 레퍼런스를 그냥 모으는 것보다 \"왜 이렇게 했는지\"를 같이 분석하는 게 실제 작업할 때 훨씬 도움이 된다는 걸 느꼈음."}, "expected_tags": ["#경쟁사례분석", "#인사이트도출", "#레퍼런스수집"]} +{"id": "05", "title": "서비스 캐릭터 아이데이션", "input": {"jobRole": "DESIGNER", "selectedCompetency": "PLANNING_EXECUTION", "situationTask": "밋업 프로젝트에서 서비스 캐릭터 방향을 디자인이 주도해서 잡기로 했음. 캐릭터가 서비스 정체성이랑 연결되면서 실제 비주얼로도 쓸 수 있어야 했고, 팀 회의에서 방향을 제안하는 게 내 역할이었음.", "action": "캐릭터 모티프 설정부터 시작해서 동물 특성 분석, 서비스 연결성, 스토리텔링 구성, 컬러 설정, 형태감 스케치 순서로 진행했음. 단순히 귀여운 캐릭터가 아니라 서비스 타겟이랑 연결되는 맥락이 있어야 설득이 된다고 판단해서, 왜 이 모티프인지 근거부터 정리하고 시각화로 이어지도록 했음.", "result": "팀 회의에서 방향을 공유했고 전체적인 무드에 대한 공감대를 만들 수 있었음. 비주얼만 가져가면 설득이 안 된다는 걸 다시 확인했고, 서사 구조부터 잡는 연습이 됐음."}, "expected_tags": ["#브랜드기획", "#비주얼디자인", "#발표및설득"]} +{"id": "06", "title": "프론트와 미정의 상태 화면 처리 방향 논의", "input": {"jobRole": "DESIGNER", "selectedCompetency": "COLLABORATION", "situationTask": "프론트 팀원이 작업하다가 \"버튼 비활성화 상태 디자인이 없는데 어떻게 처리할까요?\"라고 물어왔음. 확인해보니 내가 활성 상태만 만들어두고 비활성, 로딩, 에러 같은 상태 화면은 빠졌었음. 이 부분을 어떻게 처리할지 방향을 정하는 게 급해졌음.", "action": "어떤 상태들이 빠졌는지 프론트랑 같이 화면 보면서 체크했음. 일정을 고려해서 이번 스프린트 안에 꼭 필요한 상태만 추리고, 나머지는 다음에 추가하기로 우선순위를 정했음. 급한 것들은 바로 피그마에 추가해서 공유했고, 자주 나올 것 같은 패턴은 컴포넌트로 만들어두기로 했음.", "result": "프론트가 작업을 이어갈 수 있게 됐음. 화면 만들 때 상태별로 미리 체크하는 게 습관이 됐음. 기획과 프론트 입장을 함께 고려하지 않으면 나중에 화면을 수정해야 하는 일이 생길 수 있다는 걸 다시 인식했음."}, "expected_tags": ["#직군간협업", "#의견조율", "#산출물전달"]} +{"id": "07", "title": "공통 컴포넌트 구현", "input": {"jobRole": "DEVELOPER", "selectedCompetency": "PLANNING_EXECUTION", "situationTask": "프론트 회의 이후 공통 컴포넌트를 구현해야 했습니다. 저는 아이콘 svg 파일 하나만으로 원하는 색상, 크기를 자유롭게 사용할 수 있도록 구축해야했고, ProgressBar, Tag 컴포넌트 제작 후 스토리북에 연결해야 했습니다.", "action": "SVG를 React 컴포넌트로 불러오되 `dimensions: false` 옵션을 적용해 width/height를 제거했습니다. 덕분에 Tailwind의 `size-*`로 크기, `text-*`로 색상을 사용처에서 자유롭게 제어할 수 있도록 했습니다. Tag와 ProgressBar는 디자인 토큰 기반으로 구현하고 Storybook에 연결해 UI를 직접 확인할 수 있도록 했습니다.", "result": "아이콘 파일 하나로 크기, 색상을 자유롭게 사용하는 환경이 만들어졌고, Tag, ProgressBar 컴포넌트가 스토리북에서 바로 확인 가능한 상태로 구축됐습니다. 이 과정에서 번들러 설정과 SVG 처리 방식에 대한 이해를 높일 수 있었습니다."}, "expected_tags": ["#컴포넌트구현", "#기술리서치", "#역량확장"]} +{"id": "08", "title": "블로그 정리 및 개발 과정 기록", "input": {"jobRole": "DEVELOPER", "selectedCompetency": "GROWTH", "situationTask": "그동안 Velog에 작성한 글들을 복기하며 운영 방식을 점검함. 기존의 글들이 단순히 지식을 나열하거나 위계가 불분명한 시리즈로 구성되어 있어, 정체성이 모호하다는 문제점을 발견함. 단순 기록을 넘어 개발 과정에서의 핵심 인사이트와 트러블슈팅 경험이 돋보이는 기술 블로그로 개선하고자 하는 목표를 설정함.", "action": "프로젝트 단위로 파편화되어 늘어만 가던 시리즈들을 'Frontend'라는 하나의 큰 카테고리로 통합하여 콘텐츠의 응집도를 높임. 단순 개념 정리나 추상적인 내용의 글은 과감히 삭제 또는 비공개 처리하여 전체적인 글의 퀄리티를 상향 평준화함. 또한, 양질의 레퍼런스들을 분석하며 독자에게 정보와 통찰을 동시에 줄 수 있는 전달력 높은 포스팅 구조를 연구하고 적용함.", "result": "최근 진행한 프로젝트의 주요 로직과 기술적 난제들을 깔끔하게 구조화하여 정리함. 이 과정을 통해 지식의 휘발을 막기 위해 즉시 기록하는 습관의 중요성을 체감함. 결과적으로 단순 저장소 역할에 그치지 않고, 나의 고민의 흔적과 성장 궤적을 타인에게 효과적으로 공유할 수 있는 인사이트 중심의 포트폴리오형 블로그로 연재할 계획임."}, "expected_tags": ["#프로젝트회고", "#자기객관화", "#커리어설계"]} +{"id": "09", "title": "CI 구현", "input": {"jobRole": "DEVELOPER", "selectedCompetency": "PLANNING_EXECUTION", "situationTask": "백엔드 팀원들과 본격적인 개발을 들어가기 전 세팅해야할 것들에 대한 역할을 나누었고, 그중 ci.yml 로직을 구성하게 되었다. 목표는 단순 빌드 성공보다는, 어떻게 더 체계적으로 검증하면서 & 최적화할 수 있을지 고민해보기.", "action": "빌드를 한번 성공한 후에도, 작성한 로직 중 개선할 여지가 없는지 오랜 시간 고민했다. 명확한 판단이 서지 않을 때는 다른 개발자의 깃허브 코드를 참고하며 공부해나갔다.", "result": "단순한 빌드 로직일뿐이지만, 더욱 안전한 검증, 속도 최적화를 통한 비용 절감에 대한 효과를 조금이나마 개선할 수 있었다. 실무에서의 리소스를 고려해보며 생각을 더욱 확장할 수 있었던 경험이었다."}, "expected_tags": ["#검증및테스트", "#성능최적화", "#학습적용"]} +{"id": "10", "title": "배포 후 특정 조건 오류 원인 파악 및 수정", "input": {"jobRole": "DEVELOPER", "selectedCompetency": "PROBLEM_SOLVING", "situationTask": "배포하고 나서 특정 상황에서만 오류가 나는데 로컬에서는 재현이 안 되었다. 마감이 얼마 남지 않은 상황이었고, 해당 API 로직 담당으로서 반드시 해결해야 했다.", "action": "프로덕션 로그를 뒤져 오류가 어떤 조건에서 발생하는지 패턴을 추렸다. 환경 변수 차이인지, 네트워크 타이밍 문제인지 가설을 세우고 배포 환경에서 하나씩 검증해나갔다. 특정 API 응답이 늦어질 때 터지는 거라는 걸 확인하고, 타임아웃 처리와 재시도 로직을 추가했다.", "result": "오류가 재현되지 않게 되었고, 마감 전에 배포를 안정화할 수 있었다. 로컬과 프로덕션 환경이 다르다는 걸 항상 염두에 두어야 한다는 것을 몸으로 배웠고, 앞으로는 배포 환경 기준으로 테스트하는 습관을 들여야겠다고 생각했다."}, "expected_tags": ["#원인분석", "#검증및테스트", "#반복개선"]} diff --git a/experiments/tagging/results/.gitkeep b/experiments/tagging/results/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/experiments/tagging/results/2026-05-17T160028Z_v1_baseline.json b/experiments/tagging/results/2026-05-17T160028Z_v1_baseline.json new file mode 100644 index 0000000..3df1e9c --- /dev/null +++ b/experiments/tagging/results/2026-05-17T160028Z_v1_baseline.json @@ -0,0 +1,374 @@ +{ + "variant": "v1_baseline", + "model": "claude-haiku-4-5-20251001", + "ran_at": "2026-05-17T160028Z", + "n_cases": 10, + "cases": [ + { + "id": "01", + "title": "팀원용 데일리 STAR 기록 DB 제작", + "job_role": "PLANNER", + "primary_category": "PLANNING_EXECUTION", + "expected": [ + "#요구사항정의", + "#기획구조화", + "#툴활용" + ], + "actual": [ + "#UX설계", + "#구조재설계", + "#직군간협업" + ], + "match_score": 0.0, + "n_llm_calls": 1, + "had_corrective": false, + "total_latency_ms": 1146, + "input_tokens": 3431, + "output_tokens": 31, + "cost_usd": 0.003586, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 3431, + "output_tokens": 31, + "latency_ms": 1144, + "response_text": "{\"detailTags\": [\"#UX설계\", \"#구조재설계\", \"#직군간협업\"]}" + } + ] + }, + { + "id": "02", + "title": "5대 직무 역량 & 세부 역량 태그 설계 재고민", + "job_role": "PLANNER", + "primary_category": "PROBLEM_SOLVING", + "expected": [ + "#문제정의", + "#구조재설계", + "#논리보완" + ], + "actual": [ + "#문제정의", + "#논리보완", + "#의견조율" + ], + "match_score": 0.6666666666666666, + "n_llm_calls": 2, + "had_corrective": true, + "total_latency_ms": 2220, + "input_tokens": 7237, + "output_tokens": 59, + "cost_usd": 0.0075320000000000005, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 3527, + "output_tokens": 30, + "latency_ms": 837, + "response_text": "`{\"detailTags\": [\"#문제정의\", \"#논리보완\", \"#의견조율\"]}`" + }, + { + "input_tokens": 3710, + "output_tokens": 29, + "latency_ms": 1383, + "response_text": "{\"detailTags\": [\"#문제정의\", \"#논리보완\", \"#의견조율\"]}" + } + ] + }, + { + "id": "03", + "title": "기능명세서 작성 완료", + "job_role": "PLANNER", + "primary_category": "GROWTH", + "expected": [ + "#툴활용", + "#반복개선", + "#업무자동화" + ], + "actual": [ + "#업무자동화", + "#기획구조화", + "#학습적용" + ], + "match_score": 0.3333333333333333, + "n_llm_calls": 2, + "had_corrective": true, + "total_latency_ms": 2097, + "input_tokens": 6841, + "output_tokens": 63, + "cost_usd": 0.007156, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 3328, + "output_tokens": 32, + "latency_ms": 924, + "response_text": "`{\"detailTags\": [\"#업무자동화\", \"#기획구조화\", \"#학습적용\"]}`" + }, + { + "input_tokens": 3513, + "output_tokens": 31, + "latency_ms": 1172, + "response_text": "{\"detailTags\": [\"#업무자동화\", \"#기획구조화\", \"#학습적용\"]}" + } + ] + }, + { + "id": "04", + "title": "기록 화면 디자인 작업 중 레퍼런스 분석", + "job_role": "DESIGNER", + "primary_category": "DISCOVERY_ANALYSIS", + "expected": [ + "#경쟁사례분석", + "#인사이트도출", + "#레퍼런스수집" + ], + "actual": [ + "#경쟁사례분석", + "#사용성평가", + "#학습적용" + ], + "match_score": 0.3333333333333333, + "n_llm_calls": 1, + "had_corrective": false, + "total_latency_ms": 1005, + "input_tokens": 3304, + "output_tokens": 34, + "cost_usd": 0.003474, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 3304, + "output_tokens": 34, + "latency_ms": 1005, + "response_text": "{\"detailTags\": [\"#경쟁사례분석\", \"#사용성평가\", \"#학습적용\"]}" + } + ] + }, + { + "id": "05", + "title": "서비스 캐릭터 아이데이션", + "job_role": "DESIGNER", + "primary_category": "PLANNING_EXECUTION", + "expected": [ + "#브랜드기획", + "#비주얼디자인", + "#발표및설득" + ], + "actual": [ + "#브랜드기획", + "#발표및설득", + "#프로젝트회고" + ], + "match_score": 0.6666666666666666, + "n_llm_calls": 1, + "had_corrective": false, + "total_latency_ms": 1977, + "input_tokens": 3239, + "output_tokens": 35, + "cost_usd": 0.003414, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 3239, + "output_tokens": 35, + "latency_ms": 1977, + "response_text": "{\"detailTags\": [\"#브랜드기획\", \"#발표및설득\", \"#프로젝트회고\"]}" + } + ] + }, + { + "id": "06", + "title": "프론트와 미정의 상태 화면 처리 방향 논의", + "job_role": "DESIGNER", + "primary_category": "COLLABORATION", + "expected": [ + "#직군간협업", + "#의견조율", + "#산출물전달" + ], + "actual": [ + "#의견조율", + "#직군간협업", + "#산출물전달" + ], + "match_score": 1.0, + "n_llm_calls": 2, + "had_corrective": true, + "total_latency_ms": 2317, + "input_tokens": 6735, + "output_tokens": 63, + "cost_usd": 0.00705, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 3275, + "output_tokens": 32, + "latency_ms": 1520, + "response_text": "`{\"detailTags\": [\"#의견조율\", \"#직군간협업\", \"#산출물전달\"]}`" + }, + { + "input_tokens": 3460, + "output_tokens": 31, + "latency_ms": 797, + "response_text": "{\"detailTags\": [\"#의견조율\", \"#직군간협업\", \"#산출물전달\"]}" + } + ] + }, + { + "id": "07", + "title": "공통 컴포넌트 구현", + "job_role": "DEVELOPER", + "primary_category": "PLANNING_EXECUTION", + "expected": [ + "#컴포넌트구현", + "#기술리서치", + "#역량확장" + ], + "actual": [ + "#컴포넌트구현", + "#아키텍처설계", + "#학습적용" + ], + "match_score": 0.3333333333333333, + "n_llm_calls": 1, + "had_corrective": false, + "total_latency_ms": 1017, + "input_tokens": 3261, + "output_tokens": 36, + "cost_usd": 0.003441, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 3261, + "output_tokens": 36, + "latency_ms": 1017, + "response_text": "{\"detailTags\": [\"#컴포넌트구현\", \"#아키텍처설계\", \"#학습적용\"]}" + } + ] + }, + { + "id": "08", + "title": "블로그 정리 및 개발 과정 기록", + "job_role": "DEVELOPER", + "primary_category": "GROWTH", + "expected": [ + "#프로젝트회고", + "#자기객관화", + "#커리어설계" + ], + "actual": [ + "#프로젝트회고", + "#레퍼런스수집", + "#구조재설계" + ], + "match_score": 0.3333333333333333, + "n_llm_calls": 2, + "had_corrective": true, + "total_latency_ms": 4469, + "input_tokens": 7045, + "output_tokens": 75, + "cost_usd": 0.00742, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 3427, + "output_tokens": 38, + "latency_ms": 3604, + "response_text": "`{\"detailTags\": [\"#프로젝트회고\", \"#레퍼런스수집\", \"#구조재설계\"]}`" + }, + { + "input_tokens": 3618, + "output_tokens": 37, + "latency_ms": 864, + "response_text": "{\"detailTags\": [\"#프로젝트회고\", \"#레퍼런스수집\", \"#구조재설계\"]}" + } + ] + }, + { + "id": "09", + "title": "CI 구현", + "job_role": "DEVELOPER", + "primary_category": "PLANNING_EXECUTION", + "expected": [ + "#검증및테스트", + "#성능최적화", + "#학습적용" + ], + "actual": [ + "#성능최적화", + "#검증및테스트", + "#학습적용" + ], + "match_score": 1.0, + "n_llm_calls": 2, + "had_corrective": true, + "total_latency_ms": 2067, + "input_tokens": 6568, + "output_tokens": 65, + "cost_usd": 0.006893, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 3191, + "output_tokens": 33, + "latency_ms": 863, + "response_text": "`{\"detailTags\": [\"#성능최적화\", \"#검증및테스트\", \"#학습적용\"]}`" + }, + { + "input_tokens": 3377, + "output_tokens": 32, + "latency_ms": 1203, + "response_text": "{\"detailTags\": [\"#성능최적화\", \"#검증및테스트\", \"#학습적용\"]}" + } + ] + }, + { + "id": "10", + "title": "배포 후 특정 조건 오류 원인 파악 및 수정", + "job_role": "DEVELOPER", + "primary_category": "PROBLEM_SOLVING", + "expected": [ + "#원인분석", + "#검증및테스트", + "#반복개선" + ], + "actual": [ + "#원인분석", + "#검증및테스트", + "#학습적용" + ], + "match_score": 0.6666666666666666, + "n_llm_calls": 2, + "had_corrective": true, + "total_latency_ms": 2032, + "input_tokens": 6649, + "output_tokens": 63, + "cost_usd": 0.006964000000000001, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 3232, + "output_tokens": 32, + "latency_ms": 905, + "response_text": "`{\"detailTags\": [\"#원인분석\", \"#검증및테스트\", \"#학습적용\"]}`" + }, + { + "input_tokens": 3417, + "output_tokens": 31, + "latency_ms": 1126, + "response_text": "{\"detailTags\": [\"#원인분석\", \"#검증및테스트\", \"#학습적용\"]}" + } + ] + } + ] +} \ No newline at end of file diff --git a/experiments/tagging/results/2026-05-18T121137Z_v2_postscore.json b/experiments/tagging/results/2026-05-18T121137Z_v2_postscore.json new file mode 100644 index 0000000..3a728bd --- /dev/null +++ b/experiments/tagging/results/2026-05-18T121137Z_v2_postscore.json @@ -0,0 +1,338 @@ +{ + "variant": "v2_postscore", + "model": "claude-haiku-4-5-20251001", + "ran_at": "2026-05-18T121137Z", + "n_cases": 10, + "cases": [ + { + "id": "01", + "title": "팀원용 데일리 STAR 기록 DB 제작", + "job_role": "PLANNER", + "primary_category": "PLANNING_EXECUTION", + "expected": [ + "#요구사항정의", + "#기획구조화", + "#툴활용" + ], + "actual": [ + "#기획구조화", + "#직군간협업", + "#요구사항정의" + ], + "match_score": 0.6666666666666666, + "n_llm_calls": 1, + "had_corrective": false, + "total_latency_ms": 1918, + "input_tokens": 2419, + "output_tokens": 55, + "cost_usd": 0.002694, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 2419, + "output_tokens": 55, + "latency_ms": 1915, + "response_text": "`{\"detailTags\": [\"#기획구조화\", \"#직군간협업\", \"#요구사항정의\", \"#프로세스개선\", \"#툴활용\", \"#피드백수용\"]}`" + } + ] + }, + { + "id": "02", + "title": "5대 직무 역량 & 세부 역량 태그 설계 재고민", + "job_role": "PLANNER", + "primary_category": "PROBLEM_SOLVING", + "expected": [ + "#문제정의", + "#구조재설계", + "#논리보완" + ], + "actual": [ + "#문제정의", + "#기획구조화", + "#구조재설계" + ], + "match_score": 0.6666666666666666, + "n_llm_calls": 1, + "had_corrective": false, + "total_latency_ms": 1223, + "input_tokens": 2515, + "output_tokens": 51, + "cost_usd": 0.00277, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 2515, + "output_tokens": 51, + "latency_ms": 1223, + "response_text": "`{\"detailTags\": [\"#문제정의\", \"#기획구조화\", \"#구조재설계\", \"#의견조율\", \"#논리보완\", \"#발표및설득\"]}`" + } + ] + }, + { + "id": "03", + "title": "기능명세서 작성 완료", + "job_role": "PLANNER", + "primary_category": "GROWTH", + "expected": [ + "#툴활용", + "#반복개선", + "#업무자동화" + ], + "actual": [ + "#업무자동화", + "#UX설계", + "#논리보완" + ], + "match_score": 0.3333333333333333, + "n_llm_calls": 1, + "had_corrective": false, + "total_latency_ms": 1192, + "input_tokens": 2316, + "output_tokens": 43, + "cost_usd": 0.002531, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 2316, + "output_tokens": 43, + "latency_ms": 1191, + "response_text": "`{\"detailTags\": [\"#업무자동화\", \"#기능구현\", \"#UX설계\", \"#논리보완\", \"#역량확장\"]}`" + } + ] + }, + { + "id": "04", + "title": "기록 화면 디자인 작업 중 레퍼런스 분석", + "job_role": "DESIGNER", + "primary_category": "DISCOVERY_ANALYSIS", + "expected": [ + "#경쟁사례분석", + "#인사이트도출", + "#레퍼런스수집" + ], + "actual": [ + "#경쟁사례분석", + "#레퍼런스수집", + "#인사이트도출" + ], + "match_score": 1.0, + "n_llm_calls": 1, + "had_corrective": false, + "total_latency_ms": 1847, + "input_tokens": 2292, + "output_tokens": 61, + "cost_usd": 0.0025970000000000003, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 2292, + "output_tokens": 61, + "latency_ms": 1846, + "response_text": "`{\"detailTags\": [\"#경쟁사례분석\", \"#레퍼런스수집\", \"#인사이트도출\", \"#문제정의\", \"#사용성평가\", \"#업무방식개선\"]}`" + } + ] + }, + { + "id": "05", + "title": "서비스 캐릭터 아이데이션", + "job_role": "DESIGNER", + "primary_category": "PLANNING_EXECUTION", + "expected": [ + "#브랜드기획", + "#비주얼디자인", + "#발표및설득" + ], + "actual": [ + "#브랜드기획", + "#시각화작업", + "#인사이트도출" + ], + "match_score": 0.3333333333333333, + "n_llm_calls": 1, + "had_corrective": false, + "total_latency_ms": 2047, + "input_tokens": 2227, + "output_tokens": 56, + "cost_usd": 0.0025069999999999997, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 2227, + "output_tokens": 56, + "latency_ms": 2047, + "response_text": "`{\"detailTags\": [\"#브랜드기획\", \"#발표및설득\", \"#시각화작업\", \"#인사이트도출\", \"#기획구조화\", \"#직군간협업\"]}`" + } + ] + }, + { + "id": "06", + "title": "프론트와 미정의 상태 화면 처리 방향 논의", + "job_role": "DESIGNER", + "primary_category": "COLLABORATION", + "expected": [ + "#직군간협업", + "#의견조율", + "#산출물전달" + ], + "actual": [ + "#의견조율", + "#직군간협업", + "#산출물전달" + ], + "match_score": 1.0, + "n_llm_calls": 1, + "had_corrective": false, + "total_latency_ms": 1738, + "input_tokens": 2263, + "output_tokens": 55, + "cost_usd": 0.002538, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 2263, + "output_tokens": 55, + "latency_ms": 1737, + "response_text": "`{\"detailTags\": [\"#의견조율\", \"#직군간협업\", \"#우선순위설정\", \"#산출물전달\", \"#피드백수용\", \"#프로세스개선\"]}`" + } + ] + }, + { + "id": "07", + "title": "공통 컴포넌트 구현", + "job_role": "DEVELOPER", + "primary_category": "PLANNING_EXECUTION", + "expected": [ + "#컴포넌트구현", + "#기술리서치", + "#역량확장" + ], + "actual": [ + "#컴포넌트구현", + "#아키텍처설계", + "#기술리서치" + ], + "match_score": 0.6666666666666666, + "n_llm_calls": 1, + "had_corrective": false, + "total_latency_ms": 1637, + "input_tokens": 2249, + "output_tokens": 57, + "cost_usd": 0.0025340000000000002, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 2249, + "output_tokens": 57, + "latency_ms": 1637, + "response_text": "`{\"detailTags\": [\"#컴포넌트구현\", \"#아키텍처설계\", \"#기술리서치\", \"#시각화작업\", \"#지식공유\", \"#역량확장\"]}`" + } + ] + }, + { + "id": "08", + "title": "블로그 정리 및 개발 과정 기록", + "job_role": "DEVELOPER", + "primary_category": "GROWTH", + "expected": [ + "#프로젝트회고", + "#자기객관화", + "#커리어설계" + ], + "actual": [ + "#프로젝트회고", + "#학습적용", + "#업무방식개선" + ], + "match_score": 0.3333333333333333, + "n_llm_calls": 1, + "had_corrective": false, + "total_latency_ms": 2204, + "input_tokens": 2415, + "output_tokens": 58, + "cost_usd": 0.002705, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 2415, + "output_tokens": 58, + "latency_ms": 2203, + "response_text": "`{\"detailTags\": [\"#프로젝트회고\", \"#레퍼런스수집\", \"#기획구조화\", \"#문제정의\", \"#학습적용\", \"#업무방식개선\"]}`" + } + ] + }, + { + "id": "09", + "title": "CI 구현", + "job_role": "DEVELOPER", + "primary_category": "PLANNING_EXECUTION", + "expected": [ + "#검증및테스트", + "#성능최적화", + "#학습적용" + ], + "actual": [ + "#성능최적화", + "#아키텍처설계", + "#검증및테스트" + ], + "match_score": 0.6666666666666666, + "n_llm_calls": 1, + "had_corrective": false, + "total_latency_ms": 1686, + "input_tokens": 2179, + "output_tokens": 55, + "cost_usd": 0.002454, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 2179, + "output_tokens": 55, + "latency_ms": 1686, + "response_text": "`{\"detailTags\": [\"#성능최적화\", \"#아키텍처설계\", \"#검증및테스트\", \"#기술리서치\", \"#학습적용\", \"#문제해결\"]}`" + } + ] + }, + { + "id": "10", + "title": "배포 후 특정 조건 오류 원인 파악 및 수정", + "job_role": "DEVELOPER", + "primary_category": "PROBLEM_SOLVING", + "expected": [ + "#원인분석", + "#검증및테스트", + "#반복개선" + ], + "actual": [ + "#원인분석", + "#검증및테스트", + "#디버깅" + ], + "match_score": 0.6666666666666666, + "n_llm_calls": 1, + "had_corrective": false, + "total_latency_ms": 1635, + "input_tokens": 2220, + "output_tokens": 52, + "cost_usd": 0.00248, + "parse_error": null, + "llm_error": null, + "raw_calls": [ + { + "input_tokens": 2220, + "output_tokens": 52, + "latency_ms": 1635, + "response_text": "`{\"detailTags\": [\"#원인분석\", \"#검증및테스트\", \"#디버깅\", \"#문제해결\", \"#프로세스개선\", \"#학습적용\"]}`" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/test_tagging.py b/tests/test_tagging.py new file mode 100644 index 0000000..2227348 --- /dev/null +++ b/tests/test_tagging.py @@ -0,0 +1,342 @@ +"""POST /v1/tagging · POST /api/tagging 통합 테스트. + +LLM 호출은 :mod:`respx` 로 Anthropic Messages API 를 모킹한다. LLM 예외 → HTTP 매핑은 +``dependency_overrides`` 로 LLMClient 자리에 raise 만 하는 더미를 끼워 검증한다 +(LLMClient 의 httpx → 도메인 예외 매핑은 그 자체로 별개 책임이며 tenacity 의 재시도/대기 +시간 영향을 받지 않도록 라우터 catch 만 정밀 검증). + +PII 안전성: STAR 본문 자리에 ``MAGICPII-*`` 마커를 박아두고, 모든 로그 record 의 +``__dict__`` 직렬화에 해당 마커가 없는지 검사한다. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest +import respx +from fastapi.testclient import TestClient +from httpx import Response + +from app.api.v1.tagging import get_llm_client +from app.main import app +from app.schemas.common import JobRole +from app.services._clients.exceptions import ( + LLMAuthError, + LLMBadRequestError, + LLMRateLimitedError, + LLMUpstreamUnavailableError, +) +from app.services.tagging.exceptions import TaggingValidationError +from app.services.tagging.v2_postscore import ( + _parse_and_validate as _v2_parse_and_validate, +) +from app.services.tagging.v2_postscore import ( + _select_top3 as _v2_select_top3, +) +from app.services.tagging.v2_postscore import ( + _strip_code_fence as _v2_strip_code_fence, +) + +ANTHROPIC_URL = "https://api.anthropic.com/v1/messages" + +_PAYLOAD: dict[str, Any] = { + "jobRole": "DEVELOPER", + "selectedCompetency": "PROBLEM_SOLVING", + "situationTask": "MAGICPII-ST-1234", + "action": "MAGICPII-A-5678", + "result": "MAGICPII-R-9012", +} + +_PII_MARKERS = ("MAGICPII-ST-1234", "MAGICPII-A-5678", "MAGICPII-R-9012") + + +def _anthropic_msg(text: str) -> dict[str, Any]: + """Anthropic Messages API 응답 형태 (단일 text block).""" + return { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "test-model", + "content": [{"type": "text", "text": text}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 100, "output_tokens": 20}, + } + + +def _assert_no_pii_in_logs(caplog: pytest.LogCaptureFixture) -> None: + """모든 캡처된 record 의 ``__dict__`` 에서 STAR PII 마커가 없는지 검사.""" + for record in caplog.records: + serialized = repr(record.__dict__) + for marker in _PII_MARKERS: + assert marker not in serialized, ( + f"STAR PII marker {marker!r} 가 로그에 노출됨 " + f"({record.name}.{record.levelname}): {serialized[:200]}" + ) + + +# === /api/tagging — 안정 경로 더미 === + + +def test_api_tagging_dummy_returns_fixed_response(client: TestClient, token: str) -> None: + r = client.post("/api/tagging", json=_PAYLOAD, headers={"X-Internal-Token": token}) + assert r.status_code == 200 + assert r.json() == {"primaryCategory": "PROBLEM_SOLVING", "detailTags": ["#문제해결"]} + + +# === /v1/tagging — 인증 === + + +def test_v1_tagging_missing_token_returns_401(client: TestClient) -> None: + r = client.post("/v1/tagging", json=_PAYLOAD) + assert r.status_code == 401 + + +def test_v1_tagging_wrong_token_returns_403(client: TestClient) -> None: + r = client.post("/v1/tagging", json=_PAYLOAD, headers={"X-Internal-Token": "wrong"}) + assert r.status_code == 403 + + +# === /v1/tagging — 요청 스키마 검증 (LLM 미호출) === + + +@respx.mock +def test_v1_tagging_300char_overflow_returns_422_and_skips_llm( + client: TestClient, token: str +) -> None: + # respx 가 anthropic 호출을 mock 하지 않은 상태 — LLM 호출이 일어났다면 unmatched 로 실패. + bad = {**_PAYLOAD, "action": "a" * 301} + r = client.post("/v1/tagging", json=bad, headers={"X-Internal-Token": token}) + assert r.status_code == 422 + + +# === /v1/tagging — LLM happy === + + +@respx.mock +def test_v1_tagging_happy_path(client: TestClient, token: str) -> None: + route = respx.post(ANTHROPIC_URL).mock( + return_value=Response( + 200, json=_anthropic_msg('{"detailTags": ["#원인분석", "#검증및테스트"]}') + ) + ) + r = client.post("/v1/tagging", json=_PAYLOAD, headers={"X-Internal-Token": token}) + assert r.status_code == 200 + body = r.json() + assert body == { + "primaryCategory": "PROBLEM_SOLVING", + "detailTags": ["#원인분석", "#검증및테스트"], + } + assert route.call_count == 1 + + +# === /v1/tagging — corrective 재시도 시나리오 === + + +@respx.mock +def test_v1_tagging_corrective_retry_recovers(client: TestClient, token: str) -> None: + route = respx.post(ANTHROPIC_URL).mock( + side_effect=[ + Response(200, json=_anthropic_msg('{"detailTags": ["#존재하지않는태그"]}')), + Response(200, json=_anthropic_msg('{"detailTags": ["#원인분석"]}')), + ] + ) + r = client.post("/v1/tagging", json=_PAYLOAD, headers={"X-Internal-Token": token}) + assert r.status_code == 200 + assert r.json()["detailTags"] == ["#원인분석"] + assert route.call_count == 2 + + +# === /v1/tagging — 두 시도 모두 실패 → 422 TAG_EXTRACTION_FAILED === + + +@respx.mock +def test_v1_tagging_double_validation_failure_returns_422(client: TestClient, token: str) -> None: + route = respx.post(ANTHROPIC_URL).mock( + side_effect=[ + Response(200, json=_anthropic_msg('{"detailTags": ["#없는태그A"]}')), + Response(200, json=_anthropic_msg('{"detailTags": ["#없는태그B"]}')), + ] + ) + r = client.post("/v1/tagging", json=_PAYLOAD, headers={"X-Internal-Token": token}) + assert r.status_code == 422 + body = r.json() + assert body["detail"]["code"] == "TAG_EXTRACTION_FAILED" + assert route.call_count == 2 + + +# === /v1/tagging — 갯수 위반 (0개 / 4개) → corrective 통해 회복 === + + +@pytest.mark.parametrize( + "bad_response", + [ + '{"detailTags": []}', + '{"detailTags": ["#원인분석","#검증및테스트","#반복개선","#문제해결"]}', + ], + ids=["empty", "four_tags"], +) +@respx.mock +def test_v1_tagging_count_violation_then_recovers_via_corrective( + client: TestClient, token: str, bad_response: str +) -> None: + route = respx.post(ANTHROPIC_URL).mock( + side_effect=[ + Response(200, json=_anthropic_msg(bad_response)), + Response(200, json=_anthropic_msg('{"detailTags": ["#원인분석"]}')), + ] + ) + r = client.post("/v1/tagging", json=_PAYLOAD, headers={"X-Internal-Token": token}) + assert r.status_code == 200 + assert route.call_count == 2 + + +# === /v1/tagging — LLM 예외 → HTTP 매핑 === + + +@pytest.mark.parametrize( + ("exc_cls", "expected_status", "expected_detail"), + [ + (LLMRateLimitedError, 503, "LLM rate limited"), + (LLMUpstreamUnavailableError, 502, "LLM upstream unavailable"), + (LLMBadRequestError, 502, "LLM upstream unavailable"), + (LLMAuthError, 502, "LLM upstream unavailable"), + ], + ids=["rate_limited", "upstream_unavailable", "bad_request", "auth"], +) +def test_v1_tagging_maps_llm_exception_to_http( + client: TestClient, + token: str, + exc_cls: type[Exception], + expected_status: int, + expected_detail: str, +) -> None: + """LLM 도메인 예외 → HTTP 매핑. + + tenacity 재시도/대기 영향 회피를 위해 ``dependency_overrides`` 로 LLMClient 자리에 + 바로 raise 하는 더미를 끼운다 (LLMClient 의 httpx → 도메인 예외 매핑은 별도 책임). + """ + + class _RaisingLLM: + async def create_message(self, **_kwargs: Any) -> Any: + raise exc_cls("simulated") + + app.dependency_overrides[get_llm_client] = _RaisingLLM + try: + r = client.post("/v1/tagging", json=_PAYLOAD, headers={"X-Internal-Token": token}) + assert r.status_code == expected_status + assert r.json()["detail"] == expected_detail + finally: + app.dependency_overrides.clear() + + +# === /v1/tagging — 로그 PII 안전성 === + + +@respx.mock +def test_v1_tagging_does_not_log_star_body_on_happy( + client: TestClient, token: str, caplog: pytest.LogCaptureFixture +) -> None: + respx.post(ANTHROPIC_URL).mock( + return_value=Response(200, json=_anthropic_msg('{"detailTags": ["#원인분석"]}')) + ) + caplog.set_level(logging.INFO) + r = client.post("/v1/tagging", json=_PAYLOAD, headers={"X-Internal-Token": token}) + assert r.status_code == 200 + _assert_no_pii_in_logs(caplog) + + +# === v2_postscore — unit tests === + + +@pytest.mark.parametrize( + "wrapped", + [ + '{"detailTags": ["#문제해결"]}', + '`{"detailTags": ["#문제해결"]}`', + '``{"detailTags": ["#문제해결"]}``', + '```\n{"detailTags": ["#문제해결"]}\n```', + '```json\n{"detailTags": ["#문제해결"]}\n```', + ], + ids=["no_fence", "single_backtick", "double_backtick", "triple_backtick", "triple_json"], +) +def test_v2_postscore_strips_all_backtick_widths(wrapped: str) -> None: + """v2 의 정규식이 1·2·3개 백틱 + 옵션 ``json`` prefix 모두 strip 하는지 검증.""" + assert _v2_strip_code_fence(wrapped) == '{"detailTags": ["#문제해결"]}' + + +def test_v2_postscore_parse_accepts_5_to_7_candidates() -> None: + """후보 갯수 5~7 범위는 통과.""" + five = '{"detailTags": ["#원인분석","#검증및테스트","#반복개선","#문제해결","#디버깅"]}' + seven = ( + '{"detailTags": ' + '["#원인분석","#검증및테스트","#반복개선","#문제해결","#디버깅","#가설검증","#성능최적화"]}' + ) + assert len(_v2_parse_and_validate(five)) == 5 + assert len(_v2_parse_and_validate(seven)) == 7 + + +@pytest.mark.parametrize( + "raw", + [ + '{"detailTags": ["#원인분석","#검증및테스트","#반복개선","#문제해결"]}', + ('{"detailTags": ["#A","#B","#C","#D","#E","#F","#G","#H"]}'), + ], + ids=["four_tags_under_min", "eight_tags_over_max"], +) +def test_v2_postscore_parse_rejects_out_of_range_candidate_count(raw: str) -> None: + """후보 갯수가 [5, 7] 범위 밖이면 TaggingValidationError.""" + with pytest.raises(TaggingValidationError, match="후보 갯수 위반"): + _v2_parse_and_validate(raw) + + +def test_v2_postscore_select_top3_sorts_by_weight_then_input_order() -> None: + """가중치 점수 내림차순, 동점은 입력 인덱스 작은 게 먼저.""" + # 개발자(DEVELOPER) 기준 점수: + # #기술리서치=High(3) · #UX설계=Low(1) · #API연동=High(3) · #디버깅=High(3) + # · #비주얼디자인=Low(1) · #피드백수용=High(3) + # 입력 순서: 기술리서치 / UX설계 / API연동 / 디버깅 / 비주얼디자인 / 피드백수용 + # 점수 동점(3) 셋 — 입력 순서로 기술리서치 / API연동 / 디버깅 이 top 3. + candidates = ["#기술리서치", "#UX설계", "#API연동", "#디버깅", "#비주얼디자인", "#피드백수용"] + top = _v2_select_top3(candidates, JobRole.DEVELOPER) + assert top == ["#기술리서치", "#API연동", "#디버깅"] + + +def test_v2_postscore_select_top3_respects_role_switch() -> None: + """같은 후보 list 라도 직군 바뀌면 다른 top3 가 나온다 (가중치가 도메인 점수).""" + # PLANNER 기준: + # #기술리서치=Mid(2) · #UX설계=High(3) · #API연동=Low(1) · #디버깅=Low(1) + # · #비주얼디자인=Low(1) · #피드백수용=High(3) + # → top3: #UX설계(3, idx 1) · #피드백수용(3, idx 5) · #기술리서치(2, idx 0) + candidates = ["#기술리서치", "#UX설계", "#API연동", "#디버깅", "#비주얼디자인", "#피드백수용"] + top = _v2_select_top3(candidates, JobRole.PLANNER) + assert top == ["#UX설계", "#피드백수용", "#기술리서치"] + + +def test_v2_postscore_select_top3_handles_fewer_than_three_candidates() -> None: + """후보가 3개 미만이면 그 갯수 그대로 정렬해 반환.""" + candidates = ["#UX설계", "#기술리서치"] + top = _v2_select_top3(candidates, JobRole.DEVELOPER) + # 개발자 점수: UX설계=1, 기술리서치=3 → 기술리서치가 먼저 + assert top == ["#기술리서치", "#UX설계"] + + +# === /v1/tagging — 로그 PII 안전성 (검증 실패 path) === + + +@respx.mock +def test_v1_tagging_does_not_log_star_body_on_validation_failure( + client: TestClient, token: str, caplog: pytest.LogCaptureFixture +) -> None: + respx.post(ANTHROPIC_URL).mock( + side_effect=[ + Response(200, json=_anthropic_msg('{"detailTags": ["#없는1"]}')), + Response(200, json=_anthropic_msg('{"detailTags": ["#없는2"]}')), + ] + ) + caplog.set_level(logging.INFO) + r = client.post("/v1/tagging", json=_PAYLOAD, headers={"X-Internal-Token": token}) + assert r.status_code == 422 + _assert_no_pii_in_logs(caplog)