diff --git a/app/domains/commit/schemas.py b/app/domains/commit/schemas.py index 7c9993f..dc995d0 100644 --- a/app/domains/commit/schemas.py +++ b/app/domains/commit/schemas.py @@ -108,6 +108,7 @@ class MatchScoreBreakdown(BaseModel): semantic: int = Field(description="의미 유사성 점수(0~50)") keyword: int = Field(description="기술 키워드 일치도 점수(0~30)") context: int = Field(description="파일/모듈 맥락 점수(0~20)") + type_bonus: int = Field(default=0, description="커밋 타입 일치 보너스") penalty: int = Field(description="보정 감점 합계(0~20)") total: int = Field(description="최종 신뢰도 점수(0~100)") diff --git a/app/domains/commit/services/matching.py b/app/domains/commit/services/matching.py index 699bdc7..9a05996 100644 --- a/app/domains/commit/services/matching.py +++ b/app/domains/commit/services/matching.py @@ -98,6 +98,8 @@ def _build_recommendation_reason( ] if score.penalty: parts.append(f"보정 감점 {score.penalty}점") + if score.type_bonus: + parts.append(f"커밋 타입 가산 +{score.type_bonus}점") parts.append(f"겹친 키워드: {_format_tokens(keyword_overlap)}") parts.append(f"겹친 모듈: {_format_tokens(module_overlap)}") return ". ".join(parts) + "." @@ -337,6 +339,7 @@ def _build_match_record( semantic=score.semantic, keyword=score.keyword, context=score.context, + type_bonus=score.type_bonus, penalty=score.penalty, total=score.total, ), diff --git a/app/domains/meeting_analysis/services/matching_scoring.py b/app/domains/meeting_analysis/services/matching_scoring.py index 43f7c98..f499cbb 100644 --- a/app/domains/meeting_analysis/services/matching_scoring.py +++ b/app/domains/meeting_analysis/services/matching_scoring.py @@ -5,9 +5,12 @@ from typing import Literal MatchStatus = Literal["APPLIED", "PARTIAL", "UNAPPLIED"] +CommitType = Literal["feat", "fix", "docs", "refactor", "test", "build", "chore"] _TOKEN_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9._/-]{1,}|[가-힣]{2,}") _PATH_LIKE_PATTERN = re.compile(r"[A-Za-z0-9._/-]+") +_COMMIT_TYPE_PATTERN = re.compile(r"^\s*([a-zA-Z]+)(?:\([^)]+\))?!?:") +_TYPE_BONUS = 3 _GENERIC_STOPWORDS = { "a", @@ -93,10 +96,17 @@ "change", "chore", "cleanup", + "doc", + "docs", + "feature", + "feat", "fix", + "build", "minor", "patch", "refactor", + "test", + "tests", "update", "수정", "정리", @@ -170,12 +180,80 @@ "폐기": "negative", } +_COMMIT_TYPE_ALIASES: dict[str, CommitType] = { + "feature": "feat", + "feat": "feat", + "fix": "fix", + "bugfix": "fix", + "docs": "docs", + "doc": "docs", + "documentation": "docs", + "refactor": "refactor", + "test": "test", + "tests": "test", + "build": "build", + "chore": "chore", +} + +_DOCS_APPLICATION_WORDS = { + "docs", + "documentation", + "openapi", + "swagger", + "문서", + "문서화", + "명세", + "스웨거", +} +_FIX_APPLICATION_WORDS = { + "bug", + "bugfix", + "error", + "exception", + "fix", + "issue", + "문제", + "버그", + "에러", + "예외", + "오류", +} +_FIX_ACTION_WORDS = { + "fix", + "resolve", + "고침", + "수정", + "해결", +} +_REFACTOR_APPLICATION_WORDS = { + "refactor", + "refactoring", + "리팩토링", +} +_FEATURE_APPLICATION_WORDS = { + "add", + "create", + "enable", + "feature", + "implement", + "introduce", + "support", + "구현", + "도입", + "생성", + "연동", + "적용", + "지원", + "추가", +} + @dataclass(frozen=True) class ScoreBreakdown: semantic: int keyword: int context: int + type_bonus: int penalty: int total: int status: MatchStatus @@ -244,6 +322,52 @@ def normalize_direction_labels(*values: str | None) -> set[str]: return labels +def extract_commit_type(commit_message: str) -> CommitType | None: + match = _COMMIT_TYPE_PATTERN.match(commit_message or "") + if not match: + return None + raw_type = _normalize_token(match.group(1)) + return _COMMIT_TYPE_ALIASES.get(raw_type) + + +def infer_application_commit_types(application_text: str) -> set[CommitType]: + tokens = extract_text_tokens(application_text) + expected_types: set[CommitType] = set() + + if tokens & _DOCS_APPLICATION_WORDS: + expected_types.update({"docs", "chore"}) + if tokens & _FIX_APPLICATION_WORDS and ( + not expected_types or tokens & _FIX_ACTION_WORDS + ): + expected_types.add("fix") + if tokens & _REFACTOR_APPLICATION_WORDS: + expected_types.add("refactor") + if tokens & _FEATURE_APPLICATION_WORDS: + expected_types.add("feat") + return expected_types + + +def score_commit_type_match( + application_text: str, + commit_message: str, + commit_text: str = "", +) -> int: + commit_type = extract_commit_type(commit_message) + if not commit_type: + return 0 + expected_types = infer_application_commit_types(application_text) + commit_tokens = extract_text_tokens(f"{commit_message} {commit_text}") + if ( + commit_type == "chore" + and "docs" in expected_types + and not commit_tokens & _DOCS_APPLICATION_WORDS + ): + return 0 + if commit_type in expected_types: + return _TYPE_BONUS + return 0 + + def extract_text_tokens(text: str) -> set[str]: if not text: return set() @@ -452,6 +576,7 @@ def calculate_match_score(payload: ScoringInput) -> ScoreBreakdown: semantic=semantic, keyword=keyword, context=context, + type_bonus=0, penalty=0, total=0, status="UNAPPLIED", @@ -460,6 +585,11 @@ def calculate_match_score(payload: ScoringInput) -> ScoreBreakdown: ) base_score = semantic + keyword + context + type_bonus = score_commit_type_match( + payload.application_text, + payload.commit_message, + payload.commit_text, + ) penalty = 0 if is_abstract_commit_message(payload.commit_message): penalty += 10 @@ -470,11 +600,12 @@ def calculate_match_score(payload: ScoringInput) -> ScoreBreakdown: ): penalty += 10 - total = max(0, min(100, base_score - penalty)) + total = max(0, min(100, base_score + type_bonus - penalty)) return ScoreBreakdown( semantic=semantic, keyword=keyword, context=context, + type_bonus=type_bonus, penalty=penalty, total=total, status=resolve_match_status(total), diff --git a/tests/test_application_commit_matching.py b/tests/test_application_commit_matching.py index 0073af2..2032131 100644 --- a/tests/test_application_commit_matching.py +++ b/tests/test_application_commit_matching.py @@ -177,7 +177,9 @@ async def test_returns_recommended_commits_sorted_by_confidence(self): assert item.recommended_commits[0].confidence >= 70 assert item.recommended_commits[0].reason.endswith(".") assert "총" in item.recommended_commits[0].score_detail + assert "커밋 타입 가산 +3점" in item.recommended_commits[0].score_detail assert "겹친 키워드" in item.recommended_commits[0].score_detail + assert item.recommended_commits[0].score_breakdown.type_bonus == 3 assert item.recommended_commits[1].commit_hash == "h2" assert item.recommended_commits[0].confidence >= ( item.recommended_commits[1].confidence diff --git a/tests/test_application_commit_matching_scoring.py b/tests/test_application_commit_matching_scoring.py index 307f1de..c025820 100644 --- a/tests/test_application_commit_matching_scoring.py +++ b/tests/test_application_commit_matching_scoring.py @@ -2,10 +2,13 @@ ScoringInput, build_connection_reason, calculate_match_score, + extract_commit_type, extract_direction_labels_from_text, extract_module_tokens, extract_tech_keywords, + infer_application_commit_types, normalize_direction_labels, + score_commit_type_match, score_context_match, score_keyword_match, ) @@ -227,3 +230,115 @@ def test_connection_reason_is_readable(self): assert reason.endswith(".") assert len(reason) > 10 + + +class TestCommitTypeScorePolicy: + def test_extract_commit_type_accepts_scope_and_breaking_marker(self): + assert extract_commit_type("feat(auth)!: 로그인 API 추가") == "feat" + assert extract_commit_type("docs: Swagger 명세 보강") == "docs" + assert extract_commit_type("unknown: 기타 작업") is None + + def test_application_commit_type_inference_allows_docs_and_chore(self): + expected_types = infer_application_commit_types( + "Swagger 에러 응답 예시 문서화 및 OpenAPI 명세 정리" + ) + + assert expected_types == {"docs", "chore"} + + def test_type_match_adds_small_positive_bonus(self): + bonus = score_commit_type_match( + "팀 목록 조회 API 구현", + "feat: 본인 소속 팀 목록 조회 api 구현", + ) + + assert bonus == 3 + + def test_type_mismatch_does_not_apply_penalty(self): + payload = _make_payload( + distance=0.30, # semantic 35 + application_text="Swagger 에러 응답 예시 문서화", + commit_message="feat: Swagger 에러 응답 예시 지원", + application_keywords={"swagger", "에러"}, + commit_keywords={"swagger", "에러"}, + application_modules={"swagger"}, + commit_modules={"swagger"}, + ) + score = calculate_match_score(payload) + + assert score.type_bonus == 0 + assert score.penalty == 0 + assert score.total == 70 + + def test_chore_commit_can_match_documentation_application(self): + payload = _make_payload( + distance=0.30, # semantic 35 + application_text="Swagger 에러 응답 예시 문서화", + commit_text="Swagger OpenAPI 에러 응답 예시 문서화", + commit_message="chore: @ApiErrorCodeExample 적용", + application_keywords={"swagger", "에러"}, + commit_keywords={"swagger", "에러"}, + application_modules={"swagger"}, + commit_modules={"swagger"}, + ) + score = calculate_match_score(payload) + + assert score.type_bonus == 3 + assert score.penalty == 0 + assert score.total == 73 + + def test_unrelated_chore_does_not_match_documentation_type(self): + bonus = score_commit_type_match( + "Swagger 에러 응답 예시 문서화", + "chore: spring boot version update", + "Spring Boot 의존성을 업데이트했습니다.", + ) + + assert bonus == 0 + + def test_abstract_commit_penalty_is_not_exempted_by_type_bonus(self): + payload = _make_payload( + distance=0.30, # semantic 35 + application_text="Swagger 응답 예시 문서화", + commit_text="Swagger 문서", + commit_message="docs: update", + application_keywords={"swagger", "문서"}, + commit_keywords={"swagger", "문서"}, + application_modules={"swagger"}, + commit_modules={"swagger"}, + ) + score = calculate_match_score(payload) + + assert score.type_bonus == 3 + assert score.penalty == 10 + assert score.total == 63 + + def test_mixed_documentation_fix_intent_includes_fix_when_action_exists(self): + expected_types = infer_application_commit_types("Swagger 문서화 오류 수정") + + assert expected_types == {"docs", "chore", "fix"} + + def test_mixed_documentation_feature_intent_keeps_docs_and_feat(self): + expected_types = infer_application_commit_types("API 명세 구현") + + assert expected_types == {"docs", "chore", "feat"} + + def test_mixed_refactor_documentation_intent_keeps_both_types(self): + expected_types = infer_application_commit_types("리팩토링 후 Swagger 정리") + + assert expected_types == {"docs", "chore", "refactor"} + + def test_type_bonus_does_not_rescue_goal_mismatch(self): + payload = _make_payload( + distance=0.90, # semantic 5 + application_text="팀 목록 조회 API 구현", + commit_message="feat: 팀 목록 조회 API 추가", + application_keywords={"team"}, + commit_keywords={"team"}, + application_modules={"team"}, + commit_modules={"auth"}, + ) + score = calculate_match_score(payload) + + assert score.is_goal_mismatch is True + assert score.type_bonus == 0 + assert score.total == 0