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 문서 지도 갱신
This commit is contained in:
Yun Chan 2026-09-04 09:25:44 +09:00
commit 56a6e2da93
159 changed files with 145825 additions and 0 deletions

390
tests/test_normalize.py Normal file
View file

@ -0,0 +1,390 @@
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)