DMF_Crawler/tests/test_normalize.py
Yun Chan 56a6e2da93 chore: 저장소 구조 정리 및 문서화, 첫 커밋
- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes)
- 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에
  안내용 README.md 추가
- CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가
- docs/README.md 문서 지도 갱신
2026-09-04 09:25:44 +09:00

390 lines
18 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
"""``normalize.py`` 회귀 테스트.
정본: ``docs/design/02-data-model.md`` §2(등록번호 파싱) §4(정규화 알고리즘).
집중 대상(임무 지시서): **dmf_key 안정성 · 국가 다중값 분해 · 성분명 표기 흔들림**.
등록번호 파서 회귀표(§2.5)는 이 모듈의 정본이므로 그대로 파라미터화한다.
"""
import pytest
from dmf_crawler.models import COL_APPLICANT, COL_COUNTRY, COL_INGREDIENT
from dmf_crawler.models import COL_MANUFACTURER, COL_PERMIT_DATE, COL_PERMIT_NO
from dmf_crawler.models import COL_PLACE, RawRecord
normalize = pytest.importorskip("dmf_crawler.normalize")
def _raw(
permit_no: str = "20200101-1-A-1-1",
*,
ingredient: str = "테스트성분",
applicant: str = "테스트제약",
manufacturer: str = "Test Manufacturer",
place: str = "1 Test Rd, Testville",
country: str = "미국",
permit_date: str = "2020-01-01",
) -> RawRecord:
return RawRecord(fields={
COL_PERMIT_NO: permit_no,
COL_INGREDIENT: ingredient,
COL_APPLICANT: applicant,
COL_MANUFACTURER: manufacturer,
COL_PLACE: place,
COL_COUNTRY: country,
COL_PERMIT_DATE: permit_date,
})
# ==========================================================================
# 1. 등록번호 파서 — docs/design/02-data-model.md §2.5 회귀표 그대로
# ==========================================================================
# (입력, fmt, accept_date, ingr_no, group, serial, sub, grant, ingr_group_key)
_PERMIT_REGRESSION_CASES = [
pytest.param(
"20110531-71-B-317-05", "standard", "2011-05-31", 71, "B", 317, 5, None, "71-B",
id="standard-basic",
),
pytest.param(
"20110531-71-B-317-05(1)", "standard", "2011-05-31", 71, "B", 317, 5, 1, "71-B",
id="standard-grant-derived",
),
pytest.param(
"20260901-209-J-2270", "standard", "2026-09-01", 209, "J", 2270, None, None, "209-J",
id="standard-j-group-no-sub",
),
pytest.param(
"20121228-168-I-169-04", "standard", "2012-12-28", 168, "I", 169, 4, None, "168-I",
id="standard-i-group",
),
pytest.param(
"수6580-16-ND(20)", "new_substance", None, None, None, None, None, 20, None,
id="new-substance",
),
pytest.param(
"20260231-1-A-1-1", "standard", None, 1, "A", 1, 1, None, "1-A",
id="standard-invalid-calendar-date",
),
pytest.param(
"20260901-209-Z-1", "standard", "2026-09-01", 209, "Z", 1, None, None, "209-Z",
id="standard-unknown-group",
),
pytest.param(
"이상한값", "unknown", None, None, None, None, None, None, None,
id="unknown-garbage",
),
pytest.param(
"", "unknown", None, None, None, None, None, None, None,
id="unknown-empty",
),
]
@pytest.mark.parametrize(
"raw,fmt,accept_date,ingr_no,group,serial,sub,grant,ingr_group_key",
_PERMIT_REGRESSION_CASES,
)
def test_parse_permit_no_regression_table(
raw, fmt, accept_date, ingr_no, group, serial, sub, grant, ingr_group_key
):
"""§2.5 회귀표의 각 행을 그대로 검증한다. 어떤 입력에서도 예외를 던지지 않는다."""
parts = normalize.parse_permit_no(raw)
assert parts.fmt == fmt
assert parts.accept_date == accept_date
assert parts.ingr_no == ingr_no
assert parts.group == group
assert parts.serial == serial
assert parts.sub == sub
assert parts.grant == grant
assert parts.ingr_group_key == ingr_group_key
assert parts.raw == raw
def test_parse_permit_no_fullwidth_and_space_noise_normalizes_identically():
"""전각 하이픈·전각(이데오그래픽) 공백이 섞여도 §2.5 의 표준 케이스와 같은 결과가 나온다."""
noisy = "20121228168I16904 " # 전각 하이픈() + 말미 전각 공백( )
clean = normalize.parse_permit_no("20121228-168-I-169-04")
noisy_parts = normalize.parse_permit_no(noisy)
assert noisy_parts.fmt == clean.fmt == "standard"
assert noisy_parts.normalized == clean.normalized == "20121228-168-I-169-04"
assert noisy_parts.accept_date == "2012-12-28"
assert noisy_parts.ingr_no == 168
assert noisy_parts.group == "I"
assert noisy_parts.serial == 169
assert noisy_parts.sub == 4
assert noisy_parts.ingr_group_key == "168-I"
def test_parse_permit_no_unknown_group_keeps_parsing_but_drops_table_fields():
"""GROUP_TABLE 에 없는 군은 파싱은 성공시키되 group_table·group_effective_from 을 비운다."""
parts = normalize.parse_permit_no("20260901-209-Z-1")
assert parts.fmt == "standard"
assert parts.group == "Z"
assert parts.group_table is None
assert parts.group_effective_from is None
def test_parse_permit_no_grant_sets_base_permit_no_and_flag():
"""허여서 파생 등록번호는 괄호를 뗀 기준 등록번호와 파생 플래그를 함께 갖는다."""
parts = normalize.parse_permit_no("20110531-71-B-317-05(1)")
assert parts.is_grant_derived is True
assert parts.base_permit_no == "20110531-71-B-317-05"
not_derived = normalize.parse_permit_no("20110531-71-B-317-05")
assert not_derived.is_grant_derived is False
assert not_derived.grant is None
def test_parse_permit_no_never_raises_on_garbage():
"""어떤 쓰레기 입력에도 예외 없이 fmt='unknown' 을 돌려준다(결정 D7)."""
for garbage in (None, "", " ", "####", "20200101", "()()()", "A" * 500):
parts = normalize.parse_permit_no(garbage)
assert parts.fmt in ("unknown", "standard", "new_substance")
# ==========================================================================
# 2. dmf_key 안정성
# ==========================================================================
def test_make_dmf_key_normal_and_duplicate_suffix():
"""정상 건은 등록번호 그대로, 중복 건은 #2·#3 결정론적 접미사를 받는다."""
assert normalize.make_dmf_key("20121228-168-I-169-04", 0) == "20121228-168-I-169-04"
assert normalize.make_dmf_key("20121228-168-I-169-04", 1) == "20121228-168-I-169-04#2"
assert normalize.make_dmf_key("20121228-168-I-169-04", 2) == "20121228-168-I-169-04#3"
def test_make_dmf_key_synthetic_for_missing_permit_no():
"""등록번호가 없으면 'SYN-' + sha1 앞 12자의 합성키를 만든다."""
key = normalize.make_dmf_key("", 0, fallback_seed="ingr|applicant|mnf|2020-01-01")
assert key.startswith("SYN-")
assert len(key) == len("SYN-") + 12
def test_make_dmf_key_synthetic_is_deterministic():
"""같은 시드는 항상 같은 합성키를 만든다 — diff 가 매일 같은 키로 짝지을 수 있어야 한다."""
seed = "ingr|applicant|mnf|2020-01-01"
assert normalize.make_dmf_key("", 0, fallback_seed=seed) == normalize.make_dmf_key(
"", 0, fallback_seed=seed
)
def test_make_dmf_key_synthetic_differs_by_seed():
"""시드가 다르면 합성키도 달라야 한다(서로 다른 레코드가 같은 키로 충돌하면 안 된다)."""
a = normalize.make_dmf_key("", 0, fallback_seed="a|b|c|2020-01-01")
b = normalize.make_dmf_key("", 0, fallback_seed="x|y|z|2020-01-01")
assert a != b
def test_normalize_all_dmf_key_deterministic_across_runs():
"""같은 원본 입력을 두 번 정규화해도 dmf_key·해시가 완전히 같다(순수 함수 재현성)."""
raws = [
_raw("20121228-168-I-169-04", ingredient="포르모테롤푸마르산염수화물"),
_raw("20110531-71-B-317-05", ingredient="아토르바스타틴칼슘삼수화물"),
_raw("수6580-16-ND(20)", ingredient="프레가발린"),
]
records_1, stats_1 = normalize.normalize_all(list(raws))
records_2, stats_2 = normalize.normalize_all(list(raws))
keys_1 = [r.dmf_key for r in records_1]
keys_2 = [r.dmf_key for r in records_2]
assert keys_1 == keys_2
assert len(set(keys_1)) == len(keys_1), "한 스냅샷 안에서 dmf_key 는 유일해야 한다"
hashes_1 = {r.dmf_key: (r.content_hash, r.identity_hash) for r in records_1}
hashes_2 = {r.dmf_key: (r.content_hash, r.identity_hash) for r in records_2}
assert hashes_1 == hashes_2
assert stats_1.total_out == stats_2.total_out == 3
def test_normalize_all_duplicate_permit_no_gets_deterministic_suffix_regardless_of_input_order():
"""중복 등록번호는 정렬 튜플로 접미사가 결정되며, 입력 순서를 바꿔도 같은 결과가 나온다."""
dup_permit = "20110531-71-B-317-05"
zeta = _raw(dup_permit, manufacturer="Zeta Labs", applicant="Zeta Pharma")
alpha = _raw(dup_permit, manufacturer="Alpha Labs", applicant="Alpha Pharma")
order_a, stats_a = normalize.normalize_all([zeta, alpha])
order_b, stats_b = normalize.normalize_all([alpha, zeta])
by_manufacturer_a = {r.manufacturer: r.dmf_key for r in order_a}
by_manufacturer_b = {r.manufacturer: r.dmf_key for r in order_b}
assert by_manufacturer_a == by_manufacturer_b, "입력 순서와 무관하게 같은 레코드가 같은 접미사를 받아야 한다"
assert stats_a.duplicate_permit_no == stats_b.duplicate_permit_no == 1
assert stats_a.duplicate_groups == stats_b.duplicate_groups == 1
# 두 dmf_key 는 서로 달라야 하고(유일성), 하나는 접미사가 없어야 한다.
keys = set(by_manufacturer_a.values())
assert len(keys) == 2
assert dup_permit in keys
assert f"{dup_permit}#2" in keys
# ==========================================================================
# 3. 국가 다중값 분해
# ==========================================================================
def test_split_countries_order_independent():
"""콤마로 나열된 국가는 정렬돼 순서 흔들림이 오탐을 만들지 않는다(요구 R2.3)."""
a = normalize.split_countries("이탈리아,스위스")
b = normalize.split_countries("스위스,이탈리아")
assert a == b
assert set(a) == {"이탈리아", "스위스"}
assert len(a) == 2
def test_split_countries_dedup():
"""중복 국가명은 한 번만 남는다."""
result = normalize.split_countries("이탈리아,이탈리아,스위스")
assert set(result) == {"이탈리아", "스위스"}
assert len(result) == 2
def test_split_countries_alias_resolution():
"""영문·별칭 표기가 표준 한글명으로 접힌다."""
result = normalize.split_countries("USA,Korea")
assert set(result) == {"미국", "한국"}
def test_split_countries_alternate_delimiters():
"""콤마 외 구분자(슬래시·가운뎃점·세미콜론·파이프)도 분해된다."""
for text in ("이탈리아/스위스", "이탈리아·스위스", "이탈리아;스위스", "이탈리아|스위스"):
result = normalize.split_countries(text)
assert set(result) == {"이탈리아", "스위스"}, f"구분자 처리 실패: {text!r}"
def test_split_countries_empty_and_blank():
"""빈 값·공백만 있는 값은 빈 튜플을 돌려준다(널 방어)."""
assert normalize.split_countries("") == ()
assert normalize.split_countries(None) == ()
assert normalize.split_countries(" ") == ()
def test_normalize_all_country_order_does_not_change_dmf_record():
"""API 응답의 국가 순서가 오락가락해도 정규화 결과(countries 튜플)는 항상 같다."""
raw_a = _raw("20200101-1-A-1-1", country="이탈리아,스위스")
raw_b = _raw("20200101-1-A-1-1", country="스위스,이탈리아")
rec_a = normalize.normalize_one(raw_a)
rec_b = normalize.normalize_one(raw_b)
assert rec_a.countries == rec_b.countries
assert rec_a.content_hash == rec_b.content_hash
# ==========================================================================
# 4. 성분명 표기 흔들림
# ==========================================================================
def test_matching_key_absorbs_original_typo_spacing():
"""원본 오탈자 공백(CORTICOSTER OIDO)이 정정본과 같은 매칭키가 된다(§4.2)."""
typo = normalize.matching_key("CORTICOSTER OIDO")
fixed = normalize.matching_key("CORTICOSTEROIDO")
assert typo == fixed == "corticosteroido"
def test_matching_key_case_and_punctuation_insensitive():
"""대소문자·구두점 차이는 매칭키에서 사라진다."""
a = normalize.matching_key("S.R.L.")
b = normalize.matching_key("s.r.l.")
assert a == b
def test_normalize_ingredient_strips_hydrate_and_salt_suffix_for_base():
"""ingredient_base 는 수화물·염 접미를 반복 제거한 기본명이다. 동일성 판정에는 쓰지 않는다."""
display, key, base, micronized = normalize.normalize_ingredient("포르모테롤푸마르산염수화물")
assert display == "포르모테롤푸마르산염수화물"
assert key == "포르모테롤푸마르산염수화물"
assert base == "포르모테롤"
assert micronized is False
def test_normalize_ingredient_micronized_prefix():
"""미분화 접두는 감지되고 base 산출 시 제거된다."""
display, key, base, micronized = normalize.normalize_ingredient("미분화포르모테롤푸마르산염수화물")
assert micronized is True
assert base == "포르모테롤"
assert display.startswith("미분화")
def test_normalize_ingredient_whitespace_variance_same_key_different_display():
"""성분명에 불필요한 공백이 섞여도 매칭키는 같다. 다만 표시값은 다를 수 있다(COSMETIC 원천)."""
display_a, key_a, _, _ = normalize.normalize_ingredient("포르모테롤 푸마르산염수화물")
display_b, key_b, _, _ = normalize.normalize_ingredient("포르모테롤푸마르산염수화물")
assert key_a == key_b
assert display_a != display_b
def test_normalize_one_ingredient_notation_variance_only_changes_display():
"""레코드 전체를 정규화해도 성분명 표기 흔들림은 ingredient_key 를 바꾸지 않는다."""
rec_spaced = normalize.normalize_one(_raw(ingredient="포르모테롤 푸마르산염수화물"))
rec_tight = normalize.normalize_one(_raw(ingredient="포르모테롤푸마르산염수화물"))
assert rec_spaced.ingredient_key == rec_tight.ingredient_key
assert rec_spaced.ingredient_name != rec_tight.ingredient_name
# 표시값이 다르므로 content_hash 는 달라야 하지만, identity_hash(매칭키 기준)는 같아야 한다.
assert rec_spaced.content_hash != rec_tight.content_hash
assert rec_spaced.identity_hash == rec_tight.identity_hash
# ==========================================================================
# 5. 조직명(신청인·제조소) 정규화
# ==========================================================================
def test_normalize_org_key_removes_legal_form_variants():
"""'(주)대웅제약''주식회사 대웅제약'은 같은 매칭키가 된다."""
key_a = normalize.normalize_org_key(normalize.normalize_org_display("(주)대웅제약"))
key_b = normalize.normalize_org_key(normalize.normalize_org_display("주식회사 대웅제약"))
assert key_a == key_b == "대웅제약"
def test_normalize_org_display_folds_hanja_circle_marks():
"""㈜ 같은 원문자 법인격 기호는 표시 단계에서 (주) 로 통일된다."""
display = normalize.normalize_org_display("㈜하이플")
assert display == "(주)하이플"
# ==========================================================================
# 6. NormalizeStats — 필수 필드 널 비율·중복 통계
# ==========================================================================
def test_normalize_all_null_counts_tracks_missing_required_fields():
"""필수 필드(성분명)가 빈 값이면 null_counts 에 반영된다(무결성 게이트 4의 입력)."""
raws = [
_raw("20200101-1-A-1-1", ingredient=""),
_raw("20200102-1-A-1-2", ingredient="정상성분"),
]
_, stats = normalize.normalize_all(raws)
assert stats.total_in == 2
assert stats.null_counts.get("ingredient_name", 0) >= 1
def test_normalize_all_clean_input_has_no_rejects():
"""정상 입력만 주면 rejected 는 비어 있어야 한다."""
raws = [_raw("20200101-1-A-1-1"), _raw("20200102-1-A-1-2")]
records, stats = normalize.normalize_all(raws)
assert stats.rejected == ()
assert stats.total_out == len(records) == 2
def test_normalize_all_synthetic_key_stats_when_permit_no_missing():
"""등록번호가 빈 레코드는 합성키를 받고 synthetic_key_count 에 집계된다."""
raws = [_raw("")]
records, stats = normalize.normalize_all(raws)
assert stats.synthetic_key_count >= 1
assert any(r.is_synthetic_key for r in records)
assert any(r.dmf_key.startswith("SYN-") for r in records)
# ==========================================================================
# 7. compute_content_hash · compute_identity_hash
# ==========================================================================
def test_compute_hashes_are_16_hex_chars_and_deterministic():
rec = normalize.normalize_one(_raw("20121228-168-I-169-04"))
h1 = normalize.compute_content_hash(rec)
h2 = normalize.compute_content_hash(rec)
i1 = normalize.compute_identity_hash(rec)
assert h1 == h2
assert len(h1) == 16
assert all(c in "0123456789abcdef" for c in h1)
assert len(i1) == 16
def test_compute_content_hash_changes_when_compare_field_changes():
"""COMPARE_FIELDS 중 하나라도 바뀌면 content_hash 가 바뀐다."""
rec_a = normalize.normalize_one(_raw("20200101-1-A-1-1", applicant="Applicant A"))
rec_b = normalize.normalize_one(_raw("20200101-1-A-1-1", applicant="Applicant B"))
assert normalize.compute_content_hash(rec_a) != normalize.compute_content_hash(rec_b)