From 67b418fb0a0ab82e652fce1039a3ea1d23d9f7e4 Mon Sep 17 00:00:00 2001 From: bat1120 Date: Tue, 5 May 2026 12:19:00 +0900 Subject: [PATCH] =?UTF-8?q?A1+ABM+Legal:=20=EC=A0=84=EC=88=98=EC=A1=B0?= =?UTF-8?q?=EC=82=AC=20=EB=B0=9C=EA=B2=AC=204=20HIGH=20+=20=EB=8B=A4?= =?UTF-8?q?=EC=88=98=20MED=20fix=20(NULL/=EB=A7=A4=ED=95=91/RAG/=EA=B2=80?= =?UTF-8?q?=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전수조사 (2026-05-05) 4 영역 (DB/업종/ABM/법률) 발견 문제 fix. 3 병렬 sub-agent 작업 통합. ## ABM 시뮬 (simulation/) - C1 male/female_70_74 NULL 컬럼 회피 + COALESCE 가드 (profile_builder.py) → AGE_BUCKETS '70_plus' 단일 사용, NULL 회귀 방지 주석 고정 - C2 apt_trade_real ILIKE 매핑 강화 (profile_builder.py:201-282) → 망원2동/성산2동 명시 + neighbor_groups 인접 동 평균 fallback (income default 0.5 silent fail 차단) - C3 kakao_store.brand_name NULL 72.8% → cannibalization brand 매칭 명시 skip → Store.brand_name field 추가, skipped_no_brand 카운터로 silent 0 가시화 - C4 living_population_grid vs legacy fallback key 형식 통일 (world_loader/runner) → _TIME_ZONE_TO_HOURS 매핑으로 6구간 → 24h 확장, daily boost dict swap 정합 ## 법률 (agents/legal/) - D1 RAG 0-row 폭발 차단 (4 specialist) → rag_empty 플래그 + LLM prompt 에 caution 강제 directive + post-LLM safe→caution floor - D2 FTC churn_rate frcsCnt=NULL 의미 손상 fix (legal.py) → max(0,1) 트릭 제거, frcsCnt is None or store_count<=0 시 churn_rate=None → 소비자 코드: None → caution + summary "데이터 부족" 명시 - D3 school_zone mock 5개 위험 docstring 알림 (rules.py) - D4 territory_radius_m 50~1500m 범위 검증 (specialists.py) → 범위 외/파싱 실패 시 None fallback + warning 로그 - D5 cache key brand_name 정규화 (legal.py) → 공백/대소문자/lower 통합 (Starbucks/starbucks/STARBUCKS 동일 키) ## DB / dong_resolver / schemas - E2 dong_resolver SoT 통합 (services/dong_resolver.py) → MAPO_DONG_MAP 단일 source. population_api / demographic_depth 가 import 하도록 - E3 resolve_dong_code(name, default=None) 시그니처 + resolve_dong_code_or_default helper - E4 get_dong_centroid 마포 한정 docstring 명시 (commercial_intelligence) - B1 SimulationInput Pydantic 검증 강화 (simulation_input.py) → business_type 28종 화이트리스트 (silent fail 차단 1단계, 강격 strict Literal 은 별 PR) → lat/lon 마포 bbox 범위 (37.50~37.65, 126.85~126.95) → 빈 business_type 차단 ## 검증 - ruff check/format 통과 (수정 파일 한정) - 모든 모듈 import OK - 기존 테스트 통과 (test_legal_orchestrator 12 + test_runner_day_loop_boost 3 + test_living_pop_loader 3 + test_vacancy_pse_brand 4) - 호출자 backward compat 검증 (MAPO_DONG_CODES dict id 동일 — 6 모듈) ## 보류 (별 PR) - E1 dong_code String(8)/(10) 통일 — alembic 마이그레이션 + ORM 8+ 곳 변경 (별 PR `IM3-dong-code-string-unify`) - C5 SimulationInput 처리 (이번 PR 에 통합됨) - C6 새벽 4시 강제 휴식 — 정책 결정 필요 - B2 corp_brand_resolver 재도입 — 팀 합의 후 ## DB 변경 없음. 코드 fix + Pydantic 검증 강화만. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/agents/legal/rules.py | 32 +++--- backend/src/agents/legal/specialists.py | 66 +++++++++++- backend/src/agents/nodes/demographic_depth.py | 39 ++----- backend/src/agents/nodes/legal.py | 100 +++++++++++------- backend/src/schemas/simulation_input.py | 87 ++++++++++++++- .../src/services/commercial_intelligence.py | 12 ++- backend/src/services/dong_resolver.py | 74 ++++++++++--- backend/src/services/population_api.py | 35 +++--- backend/src/simulation/profile_builder.py | 60 +++++++++-- backend/src/simulation/runner.py | 22 +++- backend/src/simulation/world.py | 3 + backend/src/simulation/world_loader.py | 34 +++++- docs/retrospective/2026-05-05.md | 18 ++++ 13 files changed, 443 insertions(+), 139 deletions(-) diff --git a/backend/src/agents/legal/rules.py b/backend/src/agents/legal/rules.py index a5161ba6..0c9e9e5b 100644 --- a/backend/src/agents/legal/rules.py +++ b/backend/src/agents/legal/rules.py @@ -139,8 +139,7 @@ def rule_food_hygiene(business_type: str) -> dict: { "article_ref": "식품위생법 시행령 제21조", "content": ( - "식품접객업의 종류: 휴게음식점·일반음식점·단란주점·유흥주점·" - "위탁급식·제과점 영업으로 구분한다." + "식품접객업의 종류: 휴게음식점·일반음식점·단란주점·유흥주점·위탁급식·제과점 영업으로 구분한다." ), }, ] @@ -339,8 +338,7 @@ def rule_fire_safety(business_type: str, store_area_pyeong: float) -> dict: { "article_ref": "소방시설법 제12조", "content": ( - "특정소방대상물의 관계인은 대통령령으로 정하는 소방시설을 화재안전기준에 따라 " - "설치·관리하여야 한다." + "특정소방대상물의 관계인은 대통령령으로 정하는 소방시설을 화재안전기준에 따라 설치·관리하여야 한다." ), } ] @@ -642,7 +640,7 @@ def rule_sewage(business_type: str) -> dict: # --------------------------------------------------------------------------- # 학교보건법 제6조 정화구역 거리 -SCHOOL_ABSOLUTE_ZONE_M: float = 50.0 # 절대정화구역 (모든 술집/노래방 영업금지) +SCHOOL_ABSOLUTE_ZONE_M: float = 50.0 # 절대정화구역 (모든 술집/노래방 영업금지) SCHOOL_RELATIVE_ZONE_M: float = 200.0 # 상대정화구역 (정화위원회 심의 대상) @@ -691,10 +689,7 @@ def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: R = 6_371_000.0 dlat = radians(lat2 - lat1) dlon = radians(lon2 - lon1) - a = ( - sin(dlat / 2) ** 2 - + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2 - ) + a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2 return 2 * R * asin(sqrt(a)) @@ -758,7 +753,13 @@ def rule_school_zone( - 상대정화구역 200m: 학교환경위생정화위원회 심의 (대부분 거부) → 주점 danger - 카페/음식점은 적용 X (safe) - schools=None 이면 DB 조회. lat/lon 없으면 caution fallback. + schools=None 이면 DB 조회. lat/lon 없으면 caution fallback (시뮬 시작 시점 정상 케이스). + + ⚠️ D3 알림 (mock 데이터 위험): + mapo_schools 테이블이 비어있거나 POSTGRES_URL 미설정이면 ``_fetch_mapo_schools`` 가 + ``_MOCK_MAPO_SCHOOLS`` (5개 학교 좌표) fallback 으로 전환된다. mock 결과는 + 실제 마포구 학교 분포의 일부에 불과하므로 danger/safe 판정을 그대로 신뢰하면 안 된다. + 프로덕션 환경에서는 mapo_schools 적재 후에만 정확한 거리 계산을 보장한다. """ biz = _normalize_biz(business_type) @@ -767,9 +768,7 @@ def rule_school_zone( return { "type": "school_zone", "level": "safe", - "summary": ( - f"{biz or '해당 업종'}은 학교환경위생정화구역 영업 제한 대상이 아닙니다." - ), + "summary": (f"{biz or '해당 업종'}은 학교환경위생정화구역 영업 제한 대상이 아닙니다."), "recommendation": ( "[근거: 학교보건법 제6조] 정화구역 영업제한은 술집·노래방 등에 " "한정 적용 — 카페·음식점은 별도 거리 제한 없음." @@ -791,9 +790,7 @@ def rule_school_zone( return { "type": "school_zone", "level": "caution", - "summary": ( - "주점 영업장 좌표 미입력 — 학교환경위생정화구역 거리 확인이 필요합니다." - ), + "summary": ("주점 영업장 좌표 미입력 — 학교환경위생정화구역 거리 확인이 필요합니다."), "recommendation": _format_recommendation( ["학교보건법 제6조"], [ @@ -896,8 +893,7 @@ def rule_school_zone( "type": "school_zone", "level": "safe", "summary": ( - "주점 영업 후보지 반경 200m 이내에 학교가 없어 학교환경위생정화구역 " - "영업 제한 대상에서 제외됩니다." + "주점 영업 후보지 반경 200m 이내에 학교가 없어 학교환경위생정화구역 영업 제한 대상에서 제외됩니다." ), "recommendation": _format_recommendation( ["학교보건법 제6조"], diff --git a/backend/src/agents/legal/specialists.py b/backend/src/agents/legal/specialists.py index 567ee914..c774ca75 100644 --- a/backend/src/agents/legal/specialists.py +++ b/backend/src/agents/legal/specialists.py @@ -328,6 +328,23 @@ async def specialist_franchise_law( brand = (brand or "")[:100] business_type = (business_type or "")[:100] district = (district or "")[:100] + # D4 fix: territory_radius_m 합리 범위 검증 (50~1500m). + # 정보공개서·가맹계약서 표준치 기반. 범위 밖이면 None 처리해 일반 임계값(500m) fallback. + if territory_radius_m is not None: + try: + _terr_int = int(territory_radius_m) + if _terr_int < 50 or _terr_int > 1500: + logger.warning( + f"[specialist_franchise_law] territory_radius_m={_terr_int} 범위 외 (50~1500m) — None fallback" + ) + territory_radius_m = None + else: + territory_radius_m = _terr_int + except (TypeError, ValueError): + logger.warning( + f"[specialist_franchise_law] territory_radius_m={territory_radius_m!r} 파싱 실패 — None fallback" + ) + territory_radius_m = None retriever = LegalDocumentRetriever() query = f"{brand} {business_type} {district} 영업지역 가맹사업법 정보공개서 폐점률 허위과장 필수품목 카니발리제이션" # RAG(법령) + 판례 RAG + 영업지역 정량 분석 병렬 (업종별 거리 감쇠 곡선 적용) @@ -356,6 +373,8 @@ async def specialist_franchise_law( else: territory = territory_raw or {} + # D1 fix: RAG docs 0건 시 caution floor (LLM "자료 없음" → safe 오판 차단). + rag_empty = not docs ftc_hint = _format_ftc_hint(ftc_data) rag_text = _format_docs(docs) precedent_text = _format_precedents(precedents) @@ -377,6 +396,10 @@ async def specialist_franchise_law( "- 2km 내 동일 브랜드 3개 이상 → caution (자기잠식 위험)\n" ) + # D1 fix: RAG 비었을 때 LLM 에 명시적으로 caution 이상 강제 안내 + _rag_empty_directive = ( + "- ⚠️ RAG_CONTEXT 가 비어있음(자료 없음) → safe 절대 금지, **caution 이상** 반환 필수\n" if rag_empty else "" + ) user_content = ( f"브랜드: {brand}\n" f"업종: {business_type}\n" @@ -388,7 +411,9 @@ async def specialist_franchise_law( "- 폐점률 ≥10% → caution\n" f"{_territory_criteria}" "- 영업지역 침해(제12조의4)/허위과장(제9조)/필수품목 구입강제(제12조) → danger 후보\n" - "- 신규 브랜드/직영 → safe~caution\n\n" + "- 신규 브랜드/직영 → safe~caution\n" + f"{_rag_empty_directive}" + "\n" "<<>>\n" f"{rag_text}\n" "<<>>\n\n" @@ -418,6 +443,10 @@ async def specialist_franchise_law( if brand and brand.strip() and result.level == "safe": result.level = "caution" result.summary = "[브랜드 입력됨 — 가맹사업법 적용 검토 필요] " + (result.summary or "") + # D1 fix: RAG 0건이면 LLM 결과와 무관하게 caution floor 강제 + if rag_empty and result.level == "safe": + result.level = "caution" + result.summary = "[RAG 법조문 자료 부재 — 수동 검토 필요] " + (result.summary or "") # 영업지역 정량 룰 floor — LLM 이 정량 데이터를 무시하고 낮은 level 로 평가하는 # 케이스 차단. 룰이 산출한 floor 보다 LLM 이 더 높은 level 을 주면 LLM 그대로. # floor 강제 상향 시 summary/recommendation 에도 정량 근거 명시 (level↔텍스트 불일치 방지). @@ -510,6 +539,8 @@ async def specialist_fair_trade_law( else: precedents = precedent_raw or [] + # D1 fix: RAG docs 0건 시 caution floor (LLM "자료 없음" → safe 오판 차단). + rag_empty = not docs rag_text = _format_docs(docs) precedent_text = _format_precedents(precedents) mapo_hint = "" @@ -522,6 +553,10 @@ async def specialist_fair_trade_law( "마포구는 caution 이상 권장." ) + # D1 fix: RAG 비었을 때 LLM 에 명시적으로 caution 이상 강제 안내 + _rag_empty_directive = ( + "- ⚠️ RAG_CONTEXT 가 비어있음(자료 없음) → safe 절대 금지, **caution 이상** 반환 필수\n" if rag_empty else "" + ) user_content = ( f"브랜드: {brand}\n" f"업종: {business_type}\n" @@ -530,7 +565,9 @@ async def specialist_fair_trade_law( "[평가 기준]\n" "- 가맹본부 거래강제·필수품목 부당 공급 → danger 후보 (공정거래법 제45조)\n" "- 마포구 행정동 → 지역상권 상생협력 조례 명시 + caution 이상\n" - "- 부당한 표시광고/허위광고 → caution\n\n" + "- 부당한 표시광고/허위광고 → caution\n" + f"{_rag_empty_directive}" + "\n" "<<>>\n" f"{rag_text}\n" "<<>>\n\n" @@ -564,6 +601,10 @@ async def specialist_fair_trade_law( "• 마포구청 상생협력상가위원회 사전 협의\n" "• 골목상권 보호 영역 여부 확인\n" + (result.recommendation or "") ) + # D1 fix: RAG 0건이면 LLM 결과와 무관하게 caution floor 강제 + if rag_empty and result.level == "safe": + result.level = "caution" + result.summary = "[RAG 법조문 자료 부재 — 수동 검토 필요] " + (result.summary or "") articles = _articles_from_law_docs(docs, max_n=2) + _articles_from_precedent_docs(precedents, max_n=2) # B 단계: articles 에 케이스 맞춤 1~2문장 explanation 추가 articles = await _explain_articles_batch(articles, brand, business_type, district, "공정거래법/지역조례") @@ -628,6 +669,8 @@ async def specialist_building_law(business_type: str, district: str) -> dict: else: precedents = precedent_raw or [] + # D1 fix: RAG docs 0건 시 caution floor (LLM "자료 없음" → safe 오판 차단). + rag_empty = not docs rag_text = _format_docs(docs) precedent_text = _format_precedents(precedents) @@ -639,6 +682,10 @@ async def specialist_building_law(business_type: str, district: str) -> dict: f"{'제한' if is_restricted else ('허용' if is_allowed else '추가 확인 필요')}" ) + # D1 fix: RAG 비었을 때 LLM 에 명시적으로 caution 이상 강제 안내 + _rag_empty_directive = ( + "- ⚠️ RAG_CONTEXT 가 비어있음(자료 없음) → safe 절대 금지, **caution 이상** 반환 필수\n" if rag_empty else "" + ) user_content = ( f"업종: {business_type} ({biz_label})\n" f"지역: {district}\n" @@ -647,7 +694,9 @@ async def specialist_building_law(business_type: str, district: str) -> dict: "- 제한 업종 → danger (영업 자체 불가/용도변경 필요)\n" "- 허용 + 근린생활시설 외 건물 → caution (용도변경 신고 필요)\n" "- 허용 + 근린생활시설 → safe~caution\n" - "- 위반건축물 등재 시 이행강제금 리스크 별도 caution\n\n" + "- 위반건축물 등재 시 이행강제금 리스크 별도 caution\n" + f"{_rag_empty_directive}" + "\n" "<<>>\n" f"{rag_text}\n" "<<>>\n\n" @@ -681,6 +730,10 @@ async def specialist_building_law(business_type: str, district: str) -> dict: f"• 입지 변경 또는 용도변경 신고 (관할 구청 건축과)\n" f"• 영업신고 전 건물 용도 확인 (건축물대장 발급)\n" + (result.recommendation or "") ) + # D1 fix: RAG 0건이면 LLM 결과와 무관하게 caution floor 강제 + if rag_empty and result.level == "safe": + result.level = "caution" + result.summary = "[RAG 법조문 자료 부재 — 수동 검토 필요] " + (result.summary or "") articles = _articles_from_law_docs(docs, max_n=2) + _articles_from_precedent_docs(precedents, max_n=2) # B 단계: articles 에 케이스 맞춤 1~2문장 explanation 추가 articles = await _explain_articles_batch(articles, "", business_type, district, f"건축법/{zone}") @@ -763,6 +816,9 @@ async def specialist_privacy_law( else: precedents = precedent_raw or [] + # D1 fix: RAG docs 0건 시 caution floor (LLM "자료 없음" → safe 오판 차단). + # privacy 는 default 가 이미 caution (safe 금지 prompt) 라 보강 차원. + rag_empty = not docs rag_text = _format_docs(docs) precedent_text = _format_precedents(precedents) membership_hint = "" @@ -809,6 +865,10 @@ async def specialist_privacy_law( # 멤버십 키워드면 safe 차단 if has_membership_keyword and result.level == "safe": result.level = "caution" + # D1 fix: RAG 0건이면 LLM 결과와 무관하게 caution floor 강제 + if rag_empty and result.level == "safe": + result.level = "caution" + result.summary = "[RAG 법조문 자료 부재 — 수동 검토 필요] " + (result.summary or "") articles = _articles_from_law_docs(docs, max_n=2) + _articles_from_precedent_docs(precedents, max_n=2) # B 단계: articles 에 케이스 맞춤 1~2문장 explanation 추가 articles = await _explain_articles_batch(articles, brand, business_type, "", "개인정보보호법") diff --git a/backend/src/agents/nodes/demographic_depth.py b/backend/src/agents/nodes/demographic_depth.py index 78bd5ccc..9328b436 100644 --- a/backend/src/agents/nodes/demographic_depth.py +++ b/backend/src/agents/nodes/demographic_depth.py @@ -25,42 +25,21 @@ ) from src.schemas.state import AgentState +# 동명 → 코드 매핑은 services.dong_resolver 가 SoT (2026-05-04 통합 완료). +# 기존 _MAPO_DONG_CODE_FALLBACK 은 dong_resolver.MAPO_DONG_MAP + _DONG_ALIASES 로 일원화. +from src.services.dong_resolver import resolve_dong_code_or_default + logger = logging.getLogger(__name__) _CACHE_TTL = 86400 # 24h -# 동명 → 코드 폴백 매핑 (dong_mapping 테이블 기준, 2026-04-22 AWS RDS 실측 검증) -# TODO: 장기적으로 services/population_api.MAPO_DONG_CODES 또는 services/dong_resolver 로 통합해 -# Single Source of Truth 유지 (현재는 방어적 fallback 용도) -_MAPO_DONG_CODE_FALLBACK: dict[str, str] = { - # ── 행정동 (16개) ────────────────────────────────────────── - "아현동": "11440555", - "공덕동": "11440565", - "도화동": "11440585", - "용강동": "11440590", - "대흥동": "11440600", - "염리동": "11440610", - "신수동": "11440630", - "서강동": "11440655", - "서교동": "11440660", - "합정동": "11440680", - "망원1동": "11440690", - "망원2동": "11440700", - "연남동": "11440710", - "성산1동": "11440720", - "성산2동": "11440730", - "상암동": "11440740", - # ── 법정동 별칭 ──────────────────────────────────────────── - "망원동": "11440690", - "성산동": "11440720", -} - def _resolve_dong_code(district: str) -> str: - """target_district가 이미 코드면 그대로, 동명이면 매핑. 매칭 실패 시 서교동 기본값.""" - if district and district.isdigit() and len(district) == 8: - return district - return _MAPO_DONG_CODE_FALLBACK.get(district, "11440660") + """target_district가 이미 코드면 그대로, 동명이면 매핑. 매칭 실패 시 서교동 기본값. + + NOTE: 시그니처/반환값 동일 — 기존 호출자(demographic_depth_node, 테스트) 영향 없음. + """ + return resolve_dong_code_or_default(district) def _age_to_range(age_key: str) -> str: diff --git a/backend/src/agents/nodes/legal.py b/backend/src/agents/nodes/legal.py index d4b10c67..4d502259 100644 --- a/backend/src/agents/nodes/legal.py +++ b/backend/src/agents/nodes/legal.py @@ -458,18 +458,25 @@ async def _search_ftc_from_db(brand_name: str) -> dict | None: if not row: return None - store_count = int(row.frcsCnt or 0) + # D2 fix: frcsCnt NULL/0 시 churn_rate 계산 의미 손상 (max(0,1)=1 → 1500% 가짜 위험). + # 명시적 None 으로 표기 + summary 에서 "데이터 부족" 으로 표현. + raw_store_count = row.frcsCnt + store_count = int(raw_store_count) if raw_store_count is not None else 0 end_count = int(row.ctrtEndCnt or 0) cancel_count = int(row.ctrtCncltnCnt or 0) avg_sales = int(row.avrgSlsAmt or 0) * 10000 # 만원 단위 → 원 단위 - churn_rate = (end_count + cancel_count) / max(store_count, 1) + if raw_store_count is None or store_count <= 0: + # frcsCnt 없으면 churn_rate 계산 안 함 — None 으로 다운스트림에 명시 + churn_rate: float | None = None + else: + churn_rate = round((end_count + cancel_count) / store_count, 4) return { "brand_name": row.brandNm, "corp_name": row.corpNm, "store_count_total": store_count, - "churn_rate": round(churn_rate, 4), + "churn_rate": churn_rate, "avg_sales_amount": avg_sales, "franchise_fee": None, # DB에 가맹금 컬럼 없음 — 0은 무료와 혼동 } @@ -534,31 +541,39 @@ async def check_ftc_franchise(state: AgentState) -> dict: } try: - churn_rate = detail.get("churn_rate", 0.0) + # D2 fix: churn_rate 가 None 이면 "데이터 부족" — 가짜 위험 판정 차단. + churn_rate = detail.get("churn_rate") # None 가능 avg_sales = detail.get("avg_sales_amount", 0) franchise_fee = detail.get("franchise_fee") # None이면 "정보 없음" 표시 store_count = detail.get("store_count_total", 0) - # 리스크 레벨 판정 - if churn_rate > 0.10: + # 리스크 레벨 판정 — churn_rate None 이면 매출 기준만 사용 (또는 caution) + if churn_rate is None: + level = "caution" # 폐점률 미산출 — 보수적 caution + elif churn_rate > 0.10: level = "danger" elif churn_rate > 0.05 or avg_sales < 100_000_000: level = "caution" else: level = "safe" + # churn_rate None 표기 — 가짜 1500% 차단 + churn_str = f"{churn_rate:.1%}" if churn_rate is not None else "데이터 부족" summary = ( f"'{detail.get('brand_name', brand)}' ({detail.get('corp_name', '')}) " f"정보공개서 기준 — " f"전체 가맹점 수: {store_count}개, " - f"폐점률: {churn_rate:.1%}, " + f"폐점률: {churn_str}, " f"평균 매출액: {avg_sales:,}원, " f"가입비: {f'{franchise_fee:,}원' if franchise_fee is not None else '정보 없음'}. " ) if level == "danger": summary += "폐점률이 10%를 초과하여 사업 안정성 리스크가 높습니다." elif level == "caution": - summary += "폐점률 또는 매출 수준에서 주의가 필요합니다." + if churn_rate is None: + summary += "정보공개서에 가맹점 수 데이터가 없어 폐점률을 산출하지 못해 수동 검토가 필요합니다." + else: + summary += "폐점률 또는 매출 수준에서 주의가 필요합니다." else: summary += "공정위 지표 기준 안정적인 브랜드로 판단됩니다." @@ -575,7 +590,7 @@ async def check_ftc_franchise(state: AgentState) -> dict: "content": ( f"브랜드: {detail.get('brand_name', brand)} ({detail.get('corp_name', '')})\n" f"전체 가맹점 수: {store_count}개\n" - f"폐점률: {churn_rate:.1%}\n" + f"폐점률: {churn_str}\n" f"평균 매출액: {avg_sales:,}원\n" f"가입비: {f'{franchise_fee:,}원' if franchise_fee is not None else '정보 없음'}" ), @@ -710,7 +725,8 @@ async def _run_legal_pipeline(state: dict) -> dict: }.get(biz_norm_for_curve, "default") _radius_scan = _terr_radius_m or 500 # 영업구역 미입력 시 500m default _candidate_spots = [ - s for s in _vac_spots[:4] + s + for s in _vac_spots[:4] if isinstance(s, dict) and s.get("lat") is not None and s.get("lon") is not None ] if brand: @@ -732,18 +748,19 @@ async def _run_legal_pipeline(state: dict) -> dict: continue nearby = _r.get("nearby_stores", []) or [] same_within = sum( - 1 for n in nearby - if isinstance(n, dict) and (n.get("distance_m") or 0) <= _radius_scan + 1 for n in nearby if isinstance(n, dict) and (n.get("distance_m") or 0) <= _radius_scan + ) + vacancy_spot_analyses.append( + { + "dong_name": _s.get("dong_name"), + "lat": _s.get("lat"), + "lon": _s.get("lon"), + "same_brand_within_territory": same_within, + "same_brand_2000m": _r.get("same_brand_nearby", 0), + "closest_m": _r.get("closest_distance_m"), + "territory_radius_m": _radius_scan, + } ) - vacancy_spot_analyses.append({ - "dong_name": _s.get("dong_name"), - "lat": _s.get("lat"), - "lon": _s.get("lon"), - "same_brand_within_territory": same_within, - "same_brand_2000m": _r.get("same_brand_nearby", 0), - "closest_m": _r.get("closest_distance_m"), - "territory_radius_m": _radius_scan, - }) except Exception as e: logger.warning(f"[legal_node] vacancy_spot 사전 스캔 전체 실패 ({e})") @@ -790,10 +807,11 @@ async def _run_legal_pipeline(state: dict) -> dict: # 캐시 키 정규화 — brand/district/business_type 모두 strip+lowercase # + store_area 는 소수 1자리 반올림으로 동일 키 보장. - _norm_brand = (brand or "").strip().lower()[:100] + # D5 fix: brand 내부 공백도 collapse — "Star bucks" / " Starbucks" / "STARBUCKS" 동일 키. + _norm_brand = re.sub(r"\s+", " ", (brand or "").strip().lower())[:100] _norm_district = (district or "").strip() _normalized_biz = BIZ_NORMALIZE.get(business_type.lower(), business_type) - _norm_biz = _normalized_biz.strip() + _norm_biz = _normalized_biz.strip().lower() # Redis 캐시 조회 — 동일 조합 재요청 시 즉시 반환 _CACHE_TTL = 86400 # 24시간 @@ -802,8 +820,10 @@ async def _run_legal_pipeline(state: dict) -> dict: # 좌표 누락 시 "none" 으로 정규화 — 좌표·영업구역 입력 시 자동 invalidation. _coord_key = f"{lat_val:.5f},{lon_val:.5f}" if lat_val is not None and lon_val is not None else "none" _territory_key = state.get("territory_radius_m") or "none" - # v8 → v9: 운영 카테고리 5종 제거 (10 risks). 옛 v8 캐시(15 risks) 자동 무효화. - cache_key = f"v9:legal:{_norm_brand}:{_norm_district}:{_norm_biz}:{float(store_area):.1f}:{_coord_key}:{_territory_key}" + # v9 → v10: brand 내부 공백 collapse + biz lower (D5 정규화 강화). 옛 v9 캐시 자동 무효화. + cache_key = ( + f"v10:legal:{_norm_brand}:{_norm_district}:{_norm_biz}:{float(store_area):.1f}:{_coord_key}:{_territory_key}" + ) _redis = None try: _redis = aioredis.from_url(settings.redis_url, decode_responses=True) @@ -1127,20 +1147,22 @@ def _r(type_name: str) -> dict: _msg = "500m 내 동일 브랜드 0개" _dong = _va.get("dong_name") _rank = _dong_rank.get(_dong) - spot_evaluations.append({ - "rank": _rank, - "rank_label": f"{_rank}등" if _rank else "순위 미정", - "dong_name": _dong, - "lat": _va.get("lat"), - "lon": _va.get("lon"), - "territory_radius_m": _terr, - "same_brand_within_territory": _within, - "same_brand_500m": _500, - "same_brand_2000m": _2km, - "closest_m": _closest, - "level": _lvl, - "summary": _msg, - }) + spot_evaluations.append( + { + "rank": _rank, + "rank_label": f"{_rank}등" if _rank else "순위 미정", + "dong_name": _dong, + "lat": _va.get("lat"), + "lon": _va.get("lon"), + "territory_radius_m": _terr, + "same_brand_within_territory": _within, + "same_brand_500m": _500, + "same_brand_2000m": _2km, + "closest_m": _closest, + "level": _lvl, + "summary": _msg, + } + ) # rank 순 정렬 (1등 → 4등). rank None 은 뒤로. spot_evaluations.sort(key=lambda x: x.get("rank") or 999) diff --git a/backend/src/schemas/simulation_input.py b/backend/src/schemas/simulation_input.py index 3abae94b..d66ac3a2 100644 --- a/backend/src/schemas/simulation_input.py +++ b/backend/src/schemas/simulation_input.py @@ -2,7 +2,62 @@ 시뮬레이션 요청 입력 모델 — 클라이언트에서 API로 보내는 요청 스키마 """ -from pydantic import BaseModel, Field +import logging + +from pydantic import BaseModel, Field, field_validator + +logger = logging.getLogger(__name__) + + +# ───────────────────────────────────────────────────────────────────────────── +# business_type 화이트리스트 +# - Korean(프론트 BUSINESS_TYPE_BACKEND_KEY 출력값) + 영문 alias(테스트/구버전 호환). +# - 런타임에는 main.py `_BIZ_TYPE_NORMALIZE` 가 영문→한글 정규화하므로 +# 양쪽 모두 받아주되, 화이트리스트 외 입력은 WARN 로그만 찍고 통과. +# - 향후 데이터 정합성 강화 시 strict Literal 로 교체 가능 (TODO 참조). +# ───────────────────────────────────────────────────────────────────────────── +_BUSINESS_TYPE_KOREAN = { + "한식", + "중식", + "일식", + "양식", + "제과점", + "제과", + "패스트푸드", + "치킨", + "분식", + "호프", + "커피", + "카페", + "편의점", + "베이커리", + "음식점", + "커피-음료", + "호프-간이주점", +} +_BUSINESS_TYPE_ENGLISH = { + "cafe", + "coffee", + "restaurant", + "food", + "chicken", + "convenience", + "bakery", + "pub", + "burger", + "korean", + "default", +} +_BUSINESS_TYPE_ALLOWED = _BUSINESS_TYPE_KOREAN | _BUSINESS_TYPE_ENGLISH + +# ───────────────────────────────────────────────────────────────────────────── +# 마포구 lat/lon bounding box (서비스 대상 지역) +# - 마포구 외 좌표가 들어오면 학교 거리 룰 / 카니발리제이션 분석이 의미 없는 결과 반환. +# - bbox 는 마포구 행정경계 + 약간의 여유(약 ±0.005도, 500m). +# - 출처: 마포구 행정경계 GeoJSON 외접 사각형 (37.534~37.582, 126.890~126.945). +# ───────────────────────────────────────────────────────────────────────────── +_MAPO_LAT_MIN, _MAPO_LAT_MAX = 37.50, 37.65 +_MAPO_LON_MIN, _MAPO_LON_MAX = 126.85, 126.95 class ExistingStoreInput(BaseModel): @@ -43,10 +98,19 @@ class SimulationInput(BaseModel): ) # 출점 후보지 좌표 — 학교환경위생정화구역(rule_school_zone) 거리 계산 트리거 + # 마포구 bounding box (37.50~37.65, 126.85~126.95) 강제. 범위 외 입력은 422 에러. lat: float | None = Field( - default=None, description="출점 후보지 위도 (학교 거리 룰 트리거)" + default=None, + ge=_MAPO_LAT_MIN, + le=_MAPO_LAT_MAX, + description="출점 후보지 위도 (학교 거리 룰 트리거, 마포구 bbox)", + ) + lon: float | None = Field( + default=None, + ge=_MAPO_LON_MIN, + le=_MAPO_LON_MAX, + description="출점 후보지 경도 (마포구 bbox)", ) - lon: float | None = Field(default=None, description="출점 후보지 경도") # [customer_revenue P1-C] 타겟 고객 프로필 — models/customer_revenue/predict.py 입력 # 값은 SegmentProfile 스펙 그대로 (age: "30대", time: "time_11_14", day: "weekday|weekend") @@ -60,3 +124,20 @@ class SimulationInput(BaseModel): ) target_day_type: str | None = Field(default=None, description="타겟 요일: 'weekday' | 'weekend' | None(전체)") target_monthly_sales: int | None = Field(default=None, description="예상 월매출 (원). None=비율만 계산, 금액 제외") + + @field_validator("business_type") + @classmethod + def _warn_unknown_business_type(cls, v: str) -> str: + """업종 화이트리스트 검증 — 외 값은 WARN 로그 (silent fallback 차단 1단계). + + TODO: 데이터 정합성 강화 시 Literal[...] 로 격상 (현재는 backward compat 우선). + """ + if not v: + raise ValueError("business_type 은 비어있을 수 없습니다.") + if v.lower() not in _BUSINESS_TYPE_ALLOWED and v not in _BUSINESS_TYPE_ALLOWED: + logger.warning( + "[SimulationInput] 화이트리스트 외 business_type=%r — 다운스트림 분석 정확도 저하 가능. 허용 값: %s", + v, + sorted(_BUSINESS_TYPE_ALLOWED), + ) + return v diff --git a/backend/src/services/commercial_intelligence.py b/backend/src/services/commercial_intelligence.py index 516da3b8..164beb91 100644 --- a/backend/src/services/commercial_intelligence.py +++ b/backend/src/services/commercial_intelligence.py @@ -48,7 +48,17 @@ def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: def get_dong_centroid(dong_code: str) -> tuple[float, float] | None: """dong_code 의 중심 좌표 — store_info 평균 lat/lon. - dong_mapping 에 좌표가 없어 대체 사용. 매장 분포 편향으로 ±100m 편차 가능. + **범위: 마포구 한정** (`dong_code` prefix `11440*`). + - 마포 16개 행정동: store_info 매장 평균 lat/lon 반환 (±100m 편차 가능). + - 마포 외 동코드: store_info 에 매장 데이터 없어 None 반환 → + 호출자(`analyze_competition`, `analyze_cannibalization`)는 graceful 하게 + `{"error": "centroid not found for ..."}` dict 로 대체 응답하도록 설계됨. + + dong_mapping 에 정식 centroid 컬럼이 없어 대체 사용. 매장 분포 편향으로 + 실제 행정동 기하학적 중심과 차이 있음. + + TODO(E4-extension): 서울 전체 확장 시 dong_centroid 테이블 신설 필요 + (별 마이그레이션 PR 권장 — 25개 자치구 약 425동 좌표 일괄 적재). """ sql = text( """ diff --git a/backend/src/services/dong_resolver.py b/backend/src/services/dong_resolver.py index 27a2d5dd..5596006f 100644 --- a/backend/src/services/dong_resolver.py +++ b/backend/src/services/dong_resolver.py @@ -1,8 +1,8 @@ """ -동이름 ↔ 동코드 변환 유틸리티 +동이름 ↔ 동코드 변환 유틸리티 — Single Source of Truth -동이름("서교동") → 동코드("11440660") 변환. -DongMapping 테이블 또는 하드코딩 매핑 사용. +마포구 16개 행정동 코드 매핑의 정식 정의 위치. +다른 모듈은 이 파일에서 import 해서 재사용 (population_api, demographic_depth 등). 담당: A1 — 데이터 엔지니어 (찬영) """ @@ -22,8 +22,11 @@ f"postgresql://postgres:{_pw}@localhost:5432/mapo_simulator", ) -# 하드코딩 매핑 (DB 접속 불가 시 fallback) -MAPO_DONG_MAP = { +# ───────────────────────────────────────────────────────────────────────────── +# Single Source of Truth: 마포구 16개 행정동 매핑 +# 다른 모듈은 반드시 이 dict 를 import 해서 사용 (재정의 금지). +# ───────────────────────────────────────────────────────────────────────────── +MAPO_DONG_MAP: dict[str, str] = { "아현동": "11440555", "공덕동": "11440565", "도화동": "11440585", @@ -42,24 +45,57 @@ "상암동": "11440740", } -DONG_CODE_TO_NAME = {v: k for k, v in MAPO_DONG_MAP.items()} +# 법정동 별칭 (행정동과 1:1 매핑되지 않는 호출자 대응용) +# resolve_dong_code 에서만 사용. MAPO_DONG_MAP 본체는 16개 행정동만 유지. +_DONG_ALIASES: dict[str, str] = { + "망원동": "11440690", # 행정동: 망원1동 + "성산동": "11440720", # 행정동: 성산1동 +} + +# 코드 → 동이름 역매핑 (16개 행정동 기준) +DONG_CODE_TO_NAME: dict[str, str] = {v: k for k, v in MAPO_DONG_MAP.items()} + +# Backward-compat alias — population_api.py 가 기존에 노출하던 이름 그대로 재export. +# 신규 호출자는 MAPO_DONG_MAP 사용 권장. 단 인터페이스 호환을 위해 유지. +MAPO_DONG_CODES: dict[str, str] = MAPO_DONG_MAP +# 기본 fallback 동코드 (서교동) — 동명/코드 모두 매칭 실패 시 사용. +# 마포구 핵심 상권으로 demographic_depth 등 분석 노드의 안정성 보장 용도. +DEFAULT_MAPO_DONG_CODE = "11440660" -def resolve_dong_code(dong_name: str, db_url: str | None = None) -> str | None: - """동이름 → 동코드 변환 (DB 우선, fallback 하드코딩). + +def resolve_dong_code( + dong_name: str | None, + db_url: str | None = None, + default: str | None = None, +) -> str | None: + """동이름 → 동코드 변환 (DB 우선, fallback 하드코딩 + 별칭). Args: - dong_name: 행정동명 (예: "서교동", "망원1동") - db_url: DB 접속 URL (None이면 환경변수 사용) + dong_name: 행정동명 (예: "서교동", "망원1동") 또는 별칭 ("망원동"). + None/"" 이면 default 반환. + db_url: DB 접속 URL (None이면 환경변수 사용). + default: 매칭 실패 시 반환할 fallback 코드. None 이면 None 반환 (silent miss). Returns: - 동코드 문자열 (예: "11440660") 또는 None + 동코드 문자열 (예: "11440660") 또는 default. """ + if not dong_name: + return default + + # 0. 이미 8자리 숫자 코드면 그대로 통과 (호출자 편의 — 동명/코드 혼용 입력 방어) + if dong_name.isdigit() and len(dong_name) == 8: + return dong_name + # 1. 하드코딩 매핑 먼저 (빠름) if dong_name in MAPO_DONG_MAP: return MAPO_DONG_MAP[dong_name] - # 2. DB에서 조회 + # 2. 법정동 별칭 + if dong_name in _DONG_ALIASES: + return _DONG_ALIASES[dong_name] + + # 3. DB에서 조회 try: engine = get_sync_engine(db_url or DB_URL) with engine.connect() as conn: @@ -72,7 +108,19 @@ def resolve_dong_code(dong_name: str, db_url: str | None = None) -> str | None: except Exception as e: logger.warning(f"[resolve_dong_code] DB 조회 실패 dong={dong_name}: {e}") - return None + return default + + +def resolve_dong_code_or_default( + dong_name: str | None, + fallback: str = DEFAULT_MAPO_DONG_CODE, + db_url: str | None = None, +) -> str: + """resolve_dong_code 의 default 보장 helper — 매칭 실패 시 마포 기본동(서교동) 반환. + + 분석 노드(demographic_depth 등) 처럼 None 을 처리하기 곤란한 호출자용. + """ + return resolve_dong_code(dong_name, db_url=db_url, default=fallback) or fallback def resolve_dong_name(dong_code: str, db_url: str | None = None) -> str | None: diff --git a/backend/src/services/population_api.py b/backend/src/services/population_api.py index 6fc80382..d46ab4dc 100644 --- a/backend/src/services/population_api.py +++ b/backend/src/services/population_api.py @@ -10,27 +10,20 @@ import httpx -# 마포구 행정동 코드 매핑 -MAPO_DONG_CODES = { - "아현동": "11440555", - "공덕동": "11440565", - "도화동": "11440585", - "용강동": "11440590", - "대흥동": "11440600", - "염리동": "11440610", - "신수동": "11440630", - "서강동": "11440655", - "서교동": "11440660", - "합정동": "11440680", - "망원1동": "11440690", - "망원2동": "11440700", - "연남동": "11440710", - "성산1동": "11440720", - "성산2동": "11440730", - "상암동": "11440740", -} - -DONG_CODE_TO_NAME = {v: k for k, v in MAPO_DONG_CODES.items()} +# 마포구 행정동 코드 매핑은 dong_resolver 가 SoT. +# 외부 호출자(tools.py, district_ranking.py, inflow_scorer.py 등)가 기존에 +# `from src.services.population_api import MAPO_DONG_CODES` 로 import 하므로 +# backward-compat 위해 동일 이름으로 재export. +from src.services.dong_resolver import ( + DONG_CODE_TO_NAME, + MAPO_DONG_CODES, +) + +__all__ = [ + "MAPO_DONG_CODES", + "DONG_CODE_TO_NAME", + "get_population_by_dongs", +] API_KEY = os.environ.get("SEOUL_OPENDATA_KEY", "") BASE_URL = "http://openapi.seoul.go.kr:8088" diff --git a/backend/src/simulation/profile_builder.py b/backend/src/simulation/profile_builder.py index 9af51154..2ee98687 100644 --- a/backend/src/simulation/profile_builder.py +++ b/backend/src/simulation/profile_builder.py @@ -96,6 +96,9 @@ def short_summary(self) -> str: # --------------------------------------------------------------- # 연령 버킷 (living_population 컬럼명과 일치) # --------------------------------------------------------------- +# 주의: living_population 의 male_70_74/female_70_74 컬럼은 100% NULL (audit 2026-05-04). +# male_70_plus/female_70_plus 만 실데이터 보유. 70대 통합 버킷("70_plus") 만 사용. +# 70_74/70_79 등 분리 버킷 추가 금지 — column 자체가 비어 있음. AGE_BUCKETS = [ ("20_24", 20, 24), ("25_29", 25, 29), @@ -154,10 +157,12 @@ def load_dong_mix(self) -> dict[str, dict]: if "dong_mix" in self._cache: return self._cache["dong_mix"] + # COALESCE — male_70_74/female_70_74 가 100% NULL 이지만 AGE_BUCKETS 는 + # "70_plus" 만 사용하므로 영향 없음. 그래도 다른 _XX_XX 컬럼 NULL row 방어. sql = text(f""" SELECT dong_name, - {", ".join(f"AVG(male_{k}) m_{k}" for k, _, _ in AGE_BUCKETS)}, - {", ".join(f"AVG(female_{k}) f_{k}" for k, _, _ in AGE_BUCKETS)}, + {", ".join(f"AVG(COALESCE(male_{k},0)) m_{k}" for k, _, _ in AGE_BUCKETS)}, + {", ".join(f"AVG(COALESCE(female_{k},0)) f_{k}" for k, _, _ in AGE_BUCKETS)}, AVG(total_pop) total_pop FROM living_population WHERE date >= (SELECT MAX(date) - 30 FROM living_population) @@ -197,12 +202,43 @@ def load_mobility(self) -> dict[str, float]: return out def load_rent_index(self) -> dict[str, float]: - """apt_trade_real 최근 거래의 단위면적 가격 → 0~1 normalized.""" + """apt_trade_real 최근 거래의 단위면적 가격 → 0~1 normalized. + + 매핑 정책 (2026-05-04 강화): + - 마포 16동 모두 명시 매핑 (망원2동/성산2동 누락 fix). + - region_full 에 행정동 번호 존재 시 우선 (예: '망원2동' → 망원2동). + - 매핑 실패 동은 인접 동 평균으로 fallback (income 0.5 default 회피). + """ if "rent" in self._cache: return self._cache["rent"] + # 인접 동 (지리적 근접) — ILIKE 매칭 누락 시 평균 fallback 용. + # 동명 약어 ILIKE 가 망원2동 / 성산2동 과 같은 번호 동을 1동에만 귀속시키는 문제 보완. + neighbor_groups = { + "망원1동": ["망원2동", "합정동", "서교동"], + "망원2동": ["망원1동", "성산1동", "성산2동"], + "성산1동": ["성산2동", "망원2동", "상암동"], + "성산2동": ["성산1동", "연남동"], + "공덕동": ["아현동", "도화동", "용강동"], + "아현동": ["공덕동", "대흥동", "염리동"], + "도화동": ["공덕동", "용강동"], + "용강동": ["공덕동", "도화동"], + "대흥동": ["아현동", "염리동", "신수동"], + "염리동": ["대흥동", "아현동"], + "신수동": ["대흥동", "서강동"], + "서강동": ["신수동", "서교동"], + "서교동": ["합정동", "연남동", "서강동"], + "합정동": ["서교동", "망원1동"], + "연남동": ["서교동", "성산2동"], + "상암동": ["성산1동"], + } + # 망원2동 / 성산2동 도 region_full 에 직접 적시되는 경우 우선 매핑. sql = text(""" SELECT CASE + WHEN region_full ILIKE '%망원2%' OR region_full ILIKE '%망원 2%' THEN '망원2동' + WHEN region_full ILIKE '%망원1%' OR region_full ILIKE '%망원 1%' THEN '망원1동' + WHEN region_full ILIKE '%성산2%' OR region_full ILIKE '%성산 2%' THEN '성산2동' + WHEN region_full ILIKE '%성산1%' OR region_full ILIKE '%성산 1%' THEN '성산1동' WHEN region_full ILIKE '%서교%' THEN '서교동' WHEN region_full ILIKE '%연남%' THEN '연남동' WHEN region_full ILIKE '%합정%' THEN '합정동' @@ -231,9 +267,21 @@ def load_rent_index(self) -> dict[str, float]: if not valid: self._cache["rent"] = {} return {} - mn = min(v for _, v in valid) - mx = max(v for _, v in valid) - out = {d: round((v - mn) / (mx - mn), 3) if mx > mn else 0.5 for d, v in valid} + # 1) 매핑된 동 → 가격 + raw = {d: float(v) for d, v in valid} + # 2) 인접 동 평균 fallback — 매핑 실패한 마포 16동에 대해 보완 + from .config import MAPO_DONGS + + for dong in MAPO_DONGS: + if dong in raw: + continue + neighbors = [raw[n] for n in neighbor_groups.get(dong, []) if n in raw] + if neighbors: + raw[dong] = sum(neighbors) / len(neighbors) + # 3) 정규화 (전체 평균 활용) + mn = min(raw.values()) + mx = max(raw.values()) + out = {d: round((v - mn) / (mx - mn), 3) if mx > mn else 0.5 for d, v in raw.items()} self._cache["rent"] = out return out diff --git a/backend/src/simulation/runner.py b/backend/src/simulation/runner.py index 3cb6f325..30ea8a01 100644 --- a/backend/src/simulation/runner.py +++ b/backend/src/simulation/runner.py @@ -247,6 +247,8 @@ def _swap_dong_hour_boost_for_day( Args: living_pop_daily_boost: {(dong, hour, day_idx): ratio} (옵션 B). + hour 는 24h 해상도 (0~23). _load_living_population_daily 가 + time_zone 6구간 → 24h expansion 후 반환 (2026-05-04 fix). fallback_boost: 기존 분기 평균 boost {(dong, hour, weekday): ratio}. day_idx: 현재 시뮬 day index (0 ~ days-1). weekday: 0(월) ~ 6(일). @@ -261,7 +263,9 @@ def _swap_dong_hour_boost_for_day( out = dict(fallback_boost) # fallback 복사 for (dong, hour, didx), ratio in living_pop_daily_boost.items(): if didx == day_idx: - out[(dong, hour, weekday)] = ratio + # hour 안전 가드 — producer 보장 0~23 이지만 외부 source 변경 대비. + h = int(hour) % 24 + out[(dong, h, weekday)] = ratio return out @@ -1359,6 +1363,9 @@ def _home_coord(a) -> tuple[float, float] | None: affected_count = 0 affected_visits_raw = 0 weighted_nearby = 0.0 + # brand_name NULL 매장은 cannibalization 분석에서 명시 skip + # (kakao_store.brand_name 72.8% NULL — audit 2026-05-04). silent 0-impact 회피. + skipped_no_brand = 0 def _huff_weight(dist_m: float, primary: float, secondary: float) -> float: """Huff β=2 거리 가중 + tiered. 50m 기준 정규화 (max 1.0).""" @@ -1383,12 +1390,20 @@ def _huff_weight(dist_m: float, primary: float, secondary: float) -> float: continue if not (s.lat and s.lon): continue + # 거리 계산은 brand 무관 — 시장 자기잠식(market cannibalization)은 + # 카테고리 단위 경합. 다만 brand_name NULL 매장은 reporting 시 + # impact 추정 신뢰도 낮음 → 카운터에 표기하되 모델 제외 옵션 보존. dlat_m = (float(s.lat) - ns_lat) * 111000.0 dlon_m = (float(s.lon) - ns_lon) * 89000.0 d_m = (dlat_m * dlat_m + dlon_m * dlon_m) ** 0.5 w = _huff_weight(d_m, primary_r, secondary_r) if w <= 0: continue + if getattr(s, "brand_name", None) is None: + skipped_no_brand += 1 + # brand 정보 부재 → cannibalization 가중치에서 명시 제외. + # silent 0 매칭이 아니라 카운터로 노출하여 데이터 품질 가시화. + continue affected_count += 1 affected_visits_raw += int(s.visits_today) weighted_nearby += float(s.visits_today) * w @@ -1410,11 +1425,13 @@ def _huff_weight(dist_m: float, primary: float, secondary: float) -> float: # 좌표 없으면 fallback: dong 전체 같은 카테고리 매장 # stores_by_dong 은 store_id(int) 리스트 → world.stores 로 객체 lookup. target_cat = scenario.new_store.get("category") or "음식점" - same_cat_in_dong = [ + same_cat_all = [ world.stores[sid] for sid in world.stores_by_dong.get(target_dong, []) if sid in world.stores and world.stores[sid].category == target_cat ] + same_cat_in_dong = [s for s in same_cat_all if getattr(s, "brand_name", None) is not None] + skipped_no_brand = len(same_cat_all) - len(same_cat_in_dong) affected_count = len(same_cat_in_dong) affected_visits_raw = sum(int(s.visits_today) for s in same_cat_in_dong) impact_pct = 5.0 # legacy fallback @@ -1425,6 +1442,7 @@ def _huff_weight(dist_m: float, primary: float, secondary: float) -> float: "estimated_impact_pct": impact_pct, "affected_stores": affected_count, "affected_visits": affected_visits_raw, + "skipped_no_brand": skipped_no_brand, # brand_name NULL 로 제외된 매장 수 "model": "huff_b2_tiered_v1", # rate_pct: 한국 실증 (서경원·고사랑 2023 KCI) 19% 기준. impact 계산식 # `(ns/total) * 20` 과 일관. 이전 40.0 잔류 → 19% 로 정정 (review M-1 fix). diff --git a/backend/src/simulation/world.py b/backend/src/simulation/world.py index a7d71e06..c743405c 100644 --- a/backend/src/simulation/world.py +++ b/backend/src/simulation/world.py @@ -32,6 +32,9 @@ class Store: menu_items: list[dict] = field(default_factory=list) # [{name, price}] # district_sales_seoul / mapo_sns_sentiment 기반 매장 인기 가중치 popularity_boost: float = 1.0 + # 브랜드명 (kakao_store.brand_name) — None 이면 무브랜드(개인 사장님 등). + # cannibalization 분석에서 명시 skip 용 (NULL 72.8% 라 silent 0 매칭 회피). + brand_name: str | None = None @property def occupancy(self) -> float: diff --git a/backend/src/simulation/world_loader.py b/backend/src/simulation/world_loader.py index 280e913d..6b87d5d1 100644 --- a/backend/src/simulation/world_loader.py +++ b/backend/src/simulation/world_loader.py @@ -321,9 +321,13 @@ def load_world_from_rds( # 인기 보정 (매출 × 감성) pop = dong_industry_w.get((dong, cat), 1.0) * sentiment_map.get(r.place_name or "", 1.0) + # brand_name 정규화 — 빈 문자열 / 공백만은 NULL 취급. + # kakao_store.brand_name 72.8% NULL (audit 2026-05-04) → cannibalization skip 분기에서 사용. + brand_raw = (r.brand_name or "").strip() + brand_clean: str | None = brand_raw if brand_raw else None store = Store( store_id=sid, - name=r.place_name or r.brand_name or f"store_{sid}", + name=r.place_name or brand_clean or f"store_{sid}", dong=dong, category=cat, seats=30, @@ -333,6 +337,7 @@ def load_world_from_rds( lon=float(r.lon) if r.lon is not None else None, menu_items=menu, popularity_boost=round(pop, 3), + brand_name=brand_clean, ) world.add_store(store) @@ -371,6 +376,20 @@ def store_open_at( from src.database.sync_engine import get_sync_engine # noqa: E402 +# living_population.time_zone (6/11/14/17/20/24) → 24h hour 범위 expansion. +# 이전: time_zone 값을 그대로 hour 자리에 넣어 consumer (24h hour 기대) 와 mismatch +# → daily boost dict swap 시 hour 6/11/14/17/20/24 만 갱신, 나머지 18시간 무시. +# fix (2026-05-04): time_zone 마다 해당 시간 구간 전체로 확장하여 정합성 확보. +_TIME_ZONE_TO_HOURS: dict[int, list[int]] = { + 6: list(range(6, 11)), # 06~10 + 11: list(range(11, 14)), # 11~13 + 14: list(range(14, 17)), # 14~16 + 17: list(range(17, 20)), # 17~19 + 20: list(range(20, 24)), # 20~23 + 24: list(range(0, 6)), # 00~05 (자정 ~ 새벽) +} + + def _load_living_population_daily( start_date: _date, days: int, @@ -384,8 +403,12 @@ def _load_living_population_daily( days: 시뮬 일수 (90 분기 권장). Returns: - {(dong_name, hour, day_idx): float}. + {(dong_name, hour, day_idx): float} — hour 는 0~23 (24h 해상도). DB 데이터 부재 시 빈 dict (시뮬은 정적 boost fallback). + + 주의: living_population.time_zone 은 6구간 코드(6/11/14/17/20/24). + consumer (_swap_dong_hour_boost_for_day, score_store) 가 24h hour 기대 → + 여기서 _TIME_ZONE_TO_HOURS 로 expansion 후 반환. """ sql = text(""" WITH avg_pop AS ( @@ -419,5 +442,10 @@ def _load_living_population_daily( continue ratio = float(r["total_pop"] or 0) / avg ratio = max(0.5, min(ratio, 2.0)) # clamp 0.5~2.0 - out[(r["dong_name"], int(r["time_zone"]), int(r["day_idx"]))] = ratio + tz = int(r["time_zone"]) + hours = _TIME_ZONE_TO_HOURS.get(tz, [tz]) + day_idx = int(r["day_idx"]) + dong_name = r["dong_name"] + for h in hours: + out[(dong_name, h, day_idx)] = ratio return out diff --git a/docs/retrospective/2026-05-05.md b/docs/retrospective/2026-05-05.md index a9e5a2e6..23b3d105 100644 --- a/docs/retrospective/2026-05-05.md +++ b/docs/retrospective/2026-05-05.md @@ -516,3 +516,21 @@ ``` --- + +## 12:00:41 세션 완료 + + +--- + +## 12:05:55 세션 완료 + +### 변경 파일 +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + docs/retrospective/2026-05-05.md | 13 +++++++++++++ + 1 file changed, 13 insertions(+) +``` + +---