Files
Aislo/resources/master_data/scripts/master_region.py
T
eomsangdonandClaude Opus 5.5 b9a8311c6a feat(830-11): 재료 지역 열 → 시도 · 시군구 두 칸 · 행정구역 표 따로(_행정구역.json)
- 행정구역 표 = 시도 17(짧은 이름) + 전국 · 시도별 시군구 229(자치 227 + 제주 행정시 2 · 2026-07-01 인천 제물포 · 영종 · 검단 반영) · 옛 이름(강원도 · 전라북도 · 청원 · 마산 · 진해 · 연기 · 인천 남구 · 중구 · 동구 · 경북 군위) · 별칭(영종도 · 용유도 · 세종시 · 제주도)
- 풀이 master_region.split — 시도만 = 시군구 「전체」 · 시군구만 = 시도 찾음 · 읍면 = 시군구(읍면) · 여러 시도 = 쉼표 · 제외 조건 = 끝에 「 제외」 · 조합 이름 = 비고 · 잘린 끝 낱말 = 하나뿐인 시군구(동두 → 동두천 · 금 → 금천)
- 지역 글 242 종 모두 풀림(못 푼 것 0) · 옛 이름 바꾼 줄 648 · 줄 · 키 · 값 그대로(24,027 · 비고만 조합 121 줄)
- 쓰는 곳: 지역 글 = material_merge.where(시도 + 시군구) — M01 찾기(label · 검색어 · 자재지역) · 표 찾기 · 후보 글 · 불러오기(M02_Mat_Place · Hopyo_Match · Hopyo_Material — 가져온 지역 낱말도 짧은 이름으로 · 시군구가 있으면 그것으로 맞댐)
- M01 화면: 시도 = 고르기(목록 밖 값 「대전,충남」 은 끝에) · 시군구 = 글 · 새 줄 - · -
- 로직 1,520 시험 계산 전후 다름 0 · check_master 틀 · 본문 · 로직 · 조합 · 목차 0 · 단위 27 · 배 7(전과 같음)
- 빌더 adopt 가 로직 장 파일 「차례」 를 하나씩 미는 것은 이번 일 밖 — 되돌림(로직 파일 손대지 않음)
- 새 지킴 test_830_11_material_sido · 낡은 시험 고침: test_830_10 · test_830_2(열 · 지역 기대를 두 칸으로 · 830-11 새 겹침 38 묶음 = 청원 → 청주 4 · 조합 비고 34) · test_m02_hopyo_match(강원도(강릉시) → 강원 강릉)
- 틀 문서 _틀.md 11 절 행정구역 표

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 09:56:54 +09:00

223 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
"""지역 글 → 시도 · 시군구 두 칸(요구 842 · PLAN 830-11) — 표 = `_행정구역.json` · 시험 = test_830_11_material_sido.
`split(글)` → (시도, 시군구, 조합 글 | None)
- 없음(-) = (-, -) · 전국 = (전국, 전체) · 시도만 = (시도, 전체) · 「전지역」 은 전체
- 시군구만 = 표로 시도를 찾음 · 시도 + 시군구 = 둘 다 · 여러 시군구 · 시도 = 쉼표(적힌 차례)
- 읍면 = 「시군구(읍면)」 꼴 · 「… 제외」 = 시군구 칸 끝에 「 제외」
- 옛 이름 · 긴 이름 · 「…지역」 = 지금 짧은 이름 · 조합 이름(「…(협)」) = 셋째(비고 몫)
- 30 자 잘린 끝 낱말 = 같은 시도 안 앞글자가 하나뿐인 시군구로 채움
- 못 풀면 ValueError(빌더가 멈춤).
"""
from __future__ import annotations
import json
import re
from functools import lru_cache
from pathlib import Path
DB = Path(__file__).resolve().parents[1] / "_행정구역.json"
NONE = "-"
NATION = "전국"
WHOLE = "전체"
EXCEPT = "제외"
_UNION = re.compile(r",?\s*[가-힣]+\(협\)")
_CUT = ",.()및 "
def short(name: str) -> str:
"""짧은 이름 — 끝 시 · 군 · 구를 뗌(남는 글이 두 자 넘을 때만) · 「송파구」 → 송파 · 「중구」 그대로."""
return name[:-1] if len(name) > 2 and name[-1] in "시군구" else name
@lru_cache(maxsize=1)
def table() -> dict:
"""{시도: [시군구 짧은 이름]} · 낱말 사전 {글: [뜻]} · 옛 이름 글 묶음."""
db = json.loads(DB.read_text(encoding="utf-8"))
sido = {s["이름"]: [short(g) for g in s["시군구"]] for s in db["시도"]}
words: dict[str, list[tuple]] = {} # 뜻 끝 「옛」 = 옛 이름으로 풂
def put(text: str, meaning: tuple) -> None:
words.setdefault(text, [])
if meaning not in words[text]:
words[text].append(meaning)
for s in db["시도"]:
for text in (s["이름"], s["정식"], s["정식"].removesuffix("도")):
put(text, ("시도", s["이름"]))
for g in s["시군구"]:
for text in {g, short(g)}:
put(text, ("시군구", s["이름"], short(g)))
put(NATION, ("시도", NATION))
for text in ("전지역", WHOLE):
put(text, ("전체",))
put(EXCEPT, ("제외",))
for kind, book in (("옛", db["옛이름"]), ("별", db["별칭"])):
for src, dst in book.items():
to, src_name = dst.split(), src.split()[-1]
mark = ("옛",) if kind == "옛" and short(src_name) != short(to[-1]) else ()
meaning = ("시도", to[0]) if len(to) == 1 else ("시군구", to[0], short(to[1]))
for text in {src_name, short(src_name)}:
put(text, meaning + mark)
return {"시도": sido, "낱말": words}
def _tokens(text: str) -> list[tuple]:
"""(종류, 뜻 | 글) 줄 — 사전 낱말은 가장 긴 것 · 시군구 뒤 괄호 · 띄움 글 = 읍면(그대로 글)."""
words = table()["낱말"]
out, i, n = [], 0, len(text)
longest = max(map(len, words))
while i < n:
ch = text[i]
if ch in "()":
out.append((ch, None))
i += 1
continue
if ch in ",.및 ":
out.append(("띄움" if ch == " " else "끊음", None))
i += 1
continue
hit = next(
(
text[i : i + k]
for k in range(min(longest, n - i), 0, -1)
if text[i : i + k] in words
),
None,
)
if hit and text[i + len(hit) :].startswith("지역"): # 충남지역 · 제주지역
out.append(("낱말", hit, words[hit]))
i += len(hit) + 2
continue
if hit:
out.append(("낱말", hit, words[hit]))
i += len(hit)
if words[hit][0][0] == "시군구" and i < n:
emd = _emd(text, i)
if emd:
out.append(("읍면", emd[0]))
i = emd[1]
continue
j = i
while j < n and text[j] not in _CUT:
j += 1
out.append(("글", text[i:j]))
i = j
return out
def _emd(text: str, i: int) -> tuple[str, int] | None:
"""시군구 바로 뒤 읍면 글 — 「(미원면 제외)」 · 「 내면」 → (글, 다음 자리) · 아니면 None."""
words = table()["낱말"]
if text[i] == "(":
depth, j = 0, i
while j < len(text):
depth += {"(": 1, ")": -1}.get(text[j], 0)
if depth == 0:
break
j += 1
return text[i + 1 : j], j + 1
if text[i] == " ":
j = i + 1
while j < len(text) and text[j] not in ",)":
j += 1
word = text[i + 1 : j].strip()
if word and word not in words and not any(w in words for w in word.split()):
return word, j
return None
def split(text) -> tuple[str, str, str | None]:
"""지역 글 → (시도, 시군구, 조합 글 | None) — 맨 위 설명."""
return _parse(text)[:3]
def is_old(text) -> bool:
"""지역 글을 옛 이름(개편 전 이름)으로 풀었나 — 보고 셈용."""
return _parse(text)[3]
def _parse(text) -> tuple[str, str, str | None, bool]:
raw = str(text or "").strip()
if raw in ("", NONE):
return NONE, NONE, None, False
unions = [u.lstrip(", ") for u in _UNION.findall(raw)]
body = _UNION.sub("", raw).strip(", ")
sidos: list[str] = []
towns: list[tuple[str, str]] = [] # (시도, 짧은 이름(읍면))
cur, cut, old, i = None, False, False, 0
tokens = _tokens(body)
while i < len(tokens):
tok = tokens[i]
if tok[0] == "낱말":
meanings = tok[2]
kinds = {m[0] for m in meanings}
if kinds == {"전체"}:
pass
elif kinds == {"제외"}:
cut = True
elif "시도" in kinds and (
cur is None or "시군구" not in kinds or not _inside(tokens, i)
):
got = next(m for m in meanings if m[0] == "시도")
cur, old = got[1], old or got[-1] == "옛"
if cur not in sidos:
sidos.append(cur)
else:
got = _town(meanings, cur, sidos, raw)
towns.append(got[1:3])
old = old or got[-1] == "옛"
elif tok[0] == "읍면" and towns:
s, g = towns[-1]
towns[-1] = (s, f"{g}({tok[1]})")
elif tok[0] == "글":
if i != len(tokens) - 1 or body.count("(") <= body.count(")"):
raise ValueError(f"지역 글 못 풂 — 「{tok[1]}」 ({raw})")
towns.append(_complete(tok[1], cur, sidos, raw))
i += 1
for s, _ in towns:
if s not in sidos:
sidos.append(s)
if not sidos:
raise ValueError(f"지역 글 못 풂 — 시도 없음 ({raw})")
names = list(dict.fromkeys(g for _, g in towns))
sgg = ",".join(names) + (f" {EXCEPT}" if cut else "") if names else WHOLE
sido = ",".join(sidos)
return sido, (WHOLE if sido == NATION else sgg), " · ".join(unions) or None, old
def _inside(tokens: list[tuple], i: int) -> bool:
"""i 번 낱말이 괄호 안인지."""
depth = 0
for t in tokens[:i]:
depth += {"(": 1, ")": -1}.get(t[0], 0)
return depth > 0
def _town(meanings: list[tuple], cur, sidos: list[str], raw: str) -> tuple:
"""시군구 낱말 → 뜻(「시군구」, 시도, 짧은 이름[, 「옛」]) — 지금 시도 › 적힌 시도 › 전국에서 하나뿐인 것."""
towns = [m for m in meanings if m[0] == "시군구"]
for pool in ([cur], sidos, None):
got = [m for m in towns if pool is None or m[1] in pool]
if len({(m[1], m[2]) for m in got}) == 1:
return got[0]
raise ValueError(f"시군구 소속 못 정함 — {[m[1:] for m in towns]} ({raw})")
def _complete(head: str, cur, sidos: list[str], raw: str) -> tuple[str, str]:
"""잘린 끝 낱말 → 앞글자가 하나뿐인 시군구(지금 시도 › 적힌 시도 › 전국)."""
sido = table()["시도"]
for pool in ([cur] if cur else [], sidos, list(sido)):
got = [(s, g) for s in pool for g in sido.get(s, []) if g.startswith(head)]
if len(got) == 1:
return got[0]
raise ValueError(f"잘린 지역 글 못 풂 — 「{head}」 ({raw})")
def valid_sido(value) -> bool:
"""시도 칸 값 — 「-」 · 「전국」 · 시도 짧은 이름(쉼표로 여럿)."""
if value in (NONE, NATION):
return True
parts = str(value or "").split(",")
return all(p in table()["시도"] for p in parts) and len(set(parts)) == len(parts)