diff --git a/app/domains/commit/router.py b/app/domains/commit/router.py index b8ec596..ca88b38 100644 --- a/app/domains/commit/router.py +++ b/app/domains/commit/router.py @@ -20,8 +20,8 @@ from app.domains.commit.services.diff_filter import filter_changed_files from app.domains.commit.services.matching import match_applications_with_commits from app.domains.commit.services.summarize import ( + analyze_commit_content, generate_embedding_text, - summarize_commit, ) router = APIRouter(prefix="/commit", tags=["commit"]) @@ -112,7 +112,7 @@ async def analyze_commit( if not filtered_files: raise AppServiceError("분석할 수 있는 변경 파일이 없습니다.", status_code=400) - summary = await summarize_commit(request.message, filtered_files) + analysis = await analyze_commit_content(request.message, filtered_files) background_tasks.add_task( generate_embedding_text, commit_hash=request.commit_hash, @@ -120,12 +120,13 @@ async def analyze_commit( message=request.message, changed_file_list=filtered_files, commit_id=request.commit_id, + analysis=analysis, ) return ok_response( CommitAnalyzeResponse( commit_hash=request.commit_hash, commit_id=request.commit_id, - summary=summary, + summary=analysis.summary, ) ) diff --git a/app/domains/commit/services/analyze_runs.py b/app/domains/commit/services/analyze_runs.py index c13946d..b617d0c 100644 --- a/app/domains/commit/services/analyze_runs.py +++ b/app/domains/commit/services/analyze_runs.py @@ -14,8 +14,8 @@ ) from app.domains.commit.services.diff_filter import filter_changed_files from app.domains.commit.services.summarize import ( + analyze_commit_content, store_commit_embedding, - summarize_commit, ) RUN_TTL = timedelta(hours=24) @@ -203,12 +203,12 @@ async def run_commit_analyze_pipeline(run_id: str) -> None: ) await _mark_run_processing(run_id) - summary = await summarize_commit(request.message, filtered_files) + analysis = await analyze_commit_content(request.message, filtered_files) result = CommitAnalyzeRunResult( commit_id=request.commit_id, commit_hash=request.commit_hash, repository_id=request.repository_id, - summary=summary, + summary=analysis.summary, embedding_ready=False, ) await _mark_run_phase( @@ -228,6 +228,7 @@ async def run_commit_analyze_pipeline(run_id: str) -> None: message=request.message, changed_file_list=filtered_files, commit_id=request.commit_id, + analysis=analysis, ) completed_result = result.model_copy(update={"embedding_ready": True}) diff --git a/app/domains/commit/services/summarize.py b/app/domains/commit/services/summarize.py index 9db8a74..b3485f6 100644 --- a/app/domains/commit/services/summarize.py +++ b/app/domains/commit/services/summarize.py @@ -18,14 +18,17 @@ RETRY_BACKOFFS = (1.0, 2.0) # 1차 실패 후 1초, 2차 실패 후 2초 대기 logger = logging.getLogger(__name__) -EMBEDDING_PROMPT = """ -너는 Git 커밋의 diff를 분석해서 구조화된 임베딩용 텍스트를 생성하는 전문가야. +COMMIT_ANALYSIS_PROMPT = """ +너는 Git 커밋의 diff를 분석해서 사용자 표시용 요약과 +임베딩용 구조화 정보를 동시에 생성하는 전문가야. ## 절대 원칙 - 커밋 메시지와 diff에 없는 내용을 추측하거나 만들어내지 마. - 코드 변경 사실만 기술해. 의도나 동기를 추론하지 마. +- 요약은 사용자에게 보여줄 문장이고, 변경요약은 검색/임베딩에 사용할 기술 맥락이야. ## 응답 형식 (정확히 이 형식으로만 출력해) +요약: (사용자에게 보여줄 핵심 변경 내용 1문장) 변경요약: (코드 변경 사실을 1~2문장으로) 기술키워드: (DB, 프레임워크, 라이브러리, 모듈 등 기술 요소만 쉼표 구분) 변경방향: (이 커밋의 기능 방향을 @@ -33,7 +36,10 @@ 파일맥락: (변경된 파일 경로에서 비즈니스 도메인 토큰만 쉼표 구분) ## 주의 -- 위 4줄만 출력해. 다른 설명이나 마크다운, 코드블록을 추가하지 마. +- 위 5줄만 출력해. 다른 설명이나 마크다운, 코드블록을 추가하지 마. +- 요약에는 파일명, 메서드명, 클래스명 같은 기술적 식별자를 포함하지 마. +- 요약은 기술 구현 방식(어떻게)보다 기능·목적(무엇을, 왜)에 집중해. +- 요약은 회의 발언처럼 읽히는 자연스러운 한국어로 작성해. - 기술키워드에 일반 단어(함수, 파일, 코드 등)는 넣지 마. 구체적 기술명만 넣어. - 변경방향은 코드 라인 단위가 아니라 커밋 전체의 기능적 목적 기준으로 판단해. 예) 새 API 추가 → add, 버그 수정 → modify, 기능 삭제 → remove, @@ -43,19 +49,6 @@ auth, meeting, billing 같은 비즈니스 도메인만 추출해. """.strip() -SUMMARY_PROMPT = """ -너는 Git 커밋을 분석해서 1~2문장으로 요약하는 전문가야. - -## 절대 원칙 -- 커밋 메시지와 diff에 없는 내용을 추측하거나 만들어내지 마. -- 파일명, 메서드명, 클래스명 같은 기술적 식별자는 포함하지 마. -- 기술 구현 방식(어떻게)보다 기능·목적(무엇을, 왜)에 집중해. -- 회의 발언처럼 읽히는 자연스러운 한국어로 작성해. - -## 응답 형식 -마크다운, 코드블록, 접두어, 추가 설명 없이 핵심 변경 내용만 1문장으로 출력해. -""".strip() - VALID_DIRECTIONS = {direction.value for direction in CommitChangeDirection} PATH_TOKEN_STOPWORDS = { @@ -128,8 +121,22 @@ class ParsedEmbedding: module_tags: list[str] +@dataclass +class CommitAnalysis: + """커밋 요약 응답과 임베딩 저장 재료를 함께 담은 분석 결과.""" + + summary: str + embedding: ParsedEmbedding + + def _parse_embedding_response(text: str) -> ParsedEmbedding: """LLM 구조화 응답에서 변경요약/기술키워드/변경방향/파일맥락을 추출한다.""" + return _parse_commit_analysis_response(text).embedding + + +def _parse_commit_analysis_response(text: str) -> CommitAnalysis: + """LLM 통합 분석 응답에서 사용자 요약과 임베딩 재료를 추출한다.""" + display_summary = "" summary = "" tech_keywords: list[str] = [] directions: list[str] = [] @@ -137,7 +144,9 @@ def _parse_embedding_response(text: str) -> ParsedEmbedding: for line in text.strip().splitlines(): line = line.strip() - if line.startswith("변경요약:"): + if line.startswith("요약:"): + display_summary = line.removeprefix("요약:").strip() + elif line.startswith("변경요약:"): summary = line.removeprefix("변경요약:").strip() elif line.startswith("기술키워드:"): raw = line.removeprefix("기술키워드:").strip() @@ -151,14 +160,21 @@ def _parse_embedding_response(text: str) -> ParsedEmbedding: raw = line.removeprefix("파일맥락:").strip() module_tags = [t.strip() for t in raw.split(",") if t.strip()] + if not summary and display_summary: + summary = display_summary + if not display_summary and summary: + display_summary = summary if not summary: raise ValueError("LLM 응답에서 변경요약을 추출할 수 없습니다.") - return ParsedEmbedding( - summary=summary, - tech_keywords=tech_keywords, - directions=directions, - module_tags=module_tags, + return CommitAnalysis( + summary=display_summary, + embedding=ParsedEmbedding( + summary=summary, + tech_keywords=tech_keywords, + directions=directions, + module_tags=module_tags, + ), ) @@ -278,51 +294,64 @@ async def _generate_embedding(text: str, timeout: float = 30.0) -> list[float]: raise last_error # type: ignore[misc] -async def summarize_commit( +async def analyze_commit_content( message: str, changed_file_list: list[ChangedFile], -) -> str: +) -> CommitAnalysis: client = _get_client() commit_input = _build_commit_input(message, changed_file_list) try: - summary = await _call_gemini(client, SUMMARY_PROMPT, commit_input, timeout=15.0) - if not summary: + raw_text = await _call_gemini( + client, + COMMIT_ANALYSIS_PROMPT, + commit_input, + timeout=60.0, + ) + analysis = _parse_commit_analysis_response(raw_text) + if not analysis.summary: raise ValueError("LLM 응답이 비어 있습니다.") - return summary + return analysis except ValueError as e: logger.error("Gemini 응답 파싱 실패: %s", e) raise AppServiceError( - "커밋 요약 응답을 파싱할 수 없습니다.", status_code=502 + "커밋 분석 응답을 파싱할 수 없습니다.", status_code=502 ) from e except TimeoutError as e: - logger.error("Gemini 커밋 요약 타임아웃") + logger.error("Gemini 커밋 분석 타임아웃") raise AppServiceError( "Gemini 응답 시간이 초과되었습니다.", status_code=504 ) from e except AppServiceError: raise except Exception as e: - logger.exception("Gemini 커밋 요약 실패") + logger.exception("Gemini 커밋 분석 실패") raise AppServiceError( - "커밋 요약 중 오류가 발생했습니다.", status_code=502 + "커밋 분석 중 오류가 발생했습니다.", status_code=502 ) from e +async def summarize_commit( + message: str, + changed_file_list: list[ChangedFile], +) -> str: + return (await analyze_commit_content(message, changed_file_list)).summary + + async def store_commit_embedding( commit_hash: str, repository_id: int, message: str, changed_file_list: list[ChangedFile], commit_id: int | None = None, + analysis: CommitAnalysis | None = None, ) -> None: """커밋 임베딩용 구조화 텍스트를 생성하고 ChromaDB에 저장한다.""" - client = _get_client() - commit_input = _build_commit_input(message, changed_file_list) - - # 1) LLM으로 구조화 텍스트 생성 - raw_text = await _call_gemini(client, EMBEDDING_PROMPT, commit_input, timeout=60.0) - parsed = _parse_embedding_response(raw_text) + # 1) 커밋 분석 결과 확보. + # 호출자가 이미 분석한 경우 같은 diff를 LLM에 다시 보내지 않는다. + if analysis is None: + analysis = await analyze_commit_content(message, changed_file_list) + parsed = analysis.embedding path_module_tokens = _extract_path_module_tokens(changed_file_list) module_tags = _merge_unique_tokens(parsed.module_tags, path_module_tokens) @@ -374,6 +403,7 @@ async def generate_embedding_text( message: str, changed_file_list: list[ChangedFile], commit_id: int | None = None, + analysis: CommitAnalysis | None = None, ) -> None: """백그라운드에서 임베딩용 구조화 텍스트 생성. 응답을 블로킹하지 않음.""" try: @@ -383,6 +413,7 @@ async def generate_embedding_text( message=message, changed_file_list=changed_file_list, commit_id=commit_id, + analysis=analysis, ) except Exception: logger.exception( diff --git a/tests/test_application_commit_matching.py b/tests/test_application_commit_matching.py index fee1de8..86b3b5a 100644 --- a/tests/test_application_commit_matching.py +++ b/tests/test_application_commit_matching.py @@ -23,6 +23,8 @@ match_applications_with_commits, ) from app.domains.commit.services.summarize import ( + CommitAnalysis, + ParsedEmbedding, _extract_path_module_tokens, generate_embedding_text, ) @@ -791,18 +793,31 @@ async def test_analyze_commit_passes_commit_hash_to_background_embedding(self): ], ) + analysis = CommitAnalysis( + summary="API를 구현했습니다.", + embedding=ParsedEmbedding( + summary="API 엔드포인트를 추가했습니다.", + tech_keywords=["fastapi"], + directions=["add"], + module_tags=["api"], + ), + ) + with patch( - "app.domains.commit.router.summarize_commit", + "app.domains.commit.router.analyze_commit_content", new_callable=AsyncMock, - return_value="API를 구현했습니다.", - ): + return_value=analysis, + ) as analyze_mock: response = await analyze_commit(request, background_tasks) assert response.result.commit_id == 1 + assert response.result.summary == "API를 구현했습니다." + analyze_mock.assert_awaited_once() task = background_tasks.tasks[0] assert task.kwargs["commit_hash"] == "b8fd9ad" assert task.kwargs["repository_id"] == 1 assert task.kwargs["commit_id"] == 1 + assert task.kwargs["analysis"] is analysis @pytest.mark.asyncio async def test_generate_embedding_text_stores_commit_hash_metadata(self): @@ -852,6 +867,51 @@ async def test_generate_embedding_text_stores_commit_hash_metadata(self): assert kwargs["metadatas"][0]["commit_message"] == "feat: API 구현" assert kwargs["metadatas"][0]["repository_id"] == 1 + @pytest.mark.asyncio + async def test_generate_embedding_text_reuses_analysis_without_llm(self): + collection = MagicMock() + analysis = CommitAnalysis( + summary="API를 구현했습니다.", + embedding=ParsedEmbedding( + summary="API 엔드포인트를 추가했습니다.", + tech_keywords=["fastapi"], + directions=["add"], + module_tags=["commit"], + ), + ) + + with ( + patch( + "app.domains.commit.services.summarize._call_gemini", + new_callable=AsyncMock, + ) as gemini_mock, + patch( + "app.domains.commit.services.summarize._generate_embedding", + new_callable=AsyncMock, + return_value=[0.1, 0.2, 0.3], + ), + patch( + "app.domains.commit.services.summarize.get_commit_collection", + return_value=collection, + ), + ): + await generate_embedding_text( + commit_hash="b8fd9ad", + repository_id=1, + message="feat: API 구현", + changed_file_list=[ + ChangedFile( + file_name="app/domains/api.py", + changed_code="+def handler():\n+ return True", + ) + ], + commit_id=1, + analysis=analysis, + ) + + gemini_mock.assert_not_awaited() + collection.upsert.assert_called_once() + @pytest.mark.asyncio async def test_generate_embedding_text_merges_llm_modules_with_path_tokens(self): collection = MagicMock() diff --git a/tests/test_commit_analyze_runs.py b/tests/test_commit_analyze_runs.py index 9483a8e..919d8f7 100644 --- a/tests/test_commit_analyze_runs.py +++ b/tests/test_commit_analyze_runs.py @@ -10,6 +10,7 @@ get_commit_analyze_run_status, run_commit_analyze_pipeline, ) +from app.domains.commit.services.summarize import CommitAnalysis, ParsedEmbedding from app.main import app @@ -73,13 +74,22 @@ async def test_run_pipeline_completes_after_embedding_saved(self): **_build_analyze_payload(), ) accepted = await create_commit_analyze_run(request) + analysis = CommitAnalysis( + summary="Swagger 에러 응답 예시 문서화를 보강했습니다.", + embedding=ParsedEmbedding( + summary="Swagger 에러 응답 예시 문서화를 보강했습니다.", + tech_keywords=["Swagger"], + directions=["add"], + module_tags=["swagger"], + ), + ) with ( patch( - "app.domains.commit.services.analyze_runs.summarize_commit", + "app.domains.commit.services.analyze_runs.analyze_commit_content", new_callable=AsyncMock, - return_value="Swagger 에러 응답 예시 문서화를 보강했습니다.", - ) as summarize_mock, + return_value=analysis, + ) as analyze_mock, patch( "app.domains.commit.services.analyze_runs.store_commit_embedding", new_callable=AsyncMock, @@ -95,8 +105,49 @@ async def test_run_pipeline_completes_after_embedding_saved(self): assert status.result is not None assert status.result.summary == "Swagger 에러 응답 예시 문서화를 보강했습니다." assert status.result.embedding_ready is True - summarize_mock.assert_awaited_once() + analyze_mock.assert_awaited_once() embedding_mock.assert_awaited_once() + assert embedding_mock.await_args.kwargs["analysis"] is analysis + + @pytest.mark.asyncio + async def test_run_pipeline_marks_failed_when_embedding_save_fails(self): + request = CommitAnalyzeRequest( + **_build_analyze_payload(), + ) + accepted = await create_commit_analyze_run(request) + analysis = CommitAnalysis( + summary="Swagger 에러 응답 예시 문서화를 보강했습니다.", + embedding=ParsedEmbedding( + summary="Swagger 에러 응답 예시 문서화를 보강했습니다.", + tech_keywords=["Swagger"], + directions=["add"], + module_tags=["swagger"], + ), + ) + + with ( + patch( + "app.domains.commit.services.analyze_runs.analyze_commit_content", + new_callable=AsyncMock, + return_value=analysis, + ), + patch( + "app.domains.commit.services.analyze_runs.store_commit_embedding", + new_callable=AsyncMock, + side_effect=RuntimeError("chroma unavailable"), + ), + ): + await run_commit_analyze_pipeline(accepted.run_id) + + status = await get_commit_analyze_run_status(accepted.run_id) + + assert status is not None + assert status.status == "failed" + assert status.phase == "failed" + assert status.result is not None + assert status.result.summary == "Swagger 에러 응답 예시 문서화를 보강했습니다." + assert status.result.embedding_ready is False + assert status.error == "unexpected_error: chroma unavailable" @pytest.mark.asyncio async def test_run_pipeline_fails_when_all_files_are_filtered(self):