Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/domains/commit/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")

Expand Down
3 changes: 3 additions & 0 deletions app/domains/commit/services/matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) + "."
Expand Down Expand Up @@ -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,
),
Expand Down
133 changes: 132 additions & 1 deletion app/domains/meeting_analysis/services/matching_scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -93,10 +96,17 @@
"change",
"chore",
"cleanup",
"doc",
"docs",
"feature",
"feat",
"fix",
"build",
"minor",
"patch",
"refactor",
"test",
"tests",
"update",
"수정",
"정리",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions tests/test_application_commit_matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions tests/test_application_commit_matching_scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Loading