From 6e8bf8d5771548905d10b8830b770fd9139900c8 Mon Sep 17 00:00:00 2001 From: bat1120 Date: Tue, 5 May 2026 11:40:59 +0900 Subject: [PATCH] =?UTF-8?q?A1:=20brand=5Fprofile=20fuzzy=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20+=20paper=20brand=20=EA=B0=80=EB=93=9C=20(None=20*?= =?UTF-8?q?=20100=20=ED=8F=AD=EB=B0=9C=20=EC=B0=A8=EB=8B=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agents/competitor_intel.py:257 의 bench.get('closure_rate', 0) * 100 None * int TypeError 근본 fix — 호출자 측이 아닌 데이터 소스 (brand_profile) 측에서 차단. 발견 케이스 (지앤푸드 커피 시뮬): ftc_brand_franchise 의 듀먼카페/에이에스커피(AS COFFEE) frcsCnt=0 (paper brand) → closure_rate = (0) / 0 → None → competitor_intel.py:257 의 None * 100 → TypeError 폭발 Fix: 1. paper brand (frcsCnt=0) 매칭 시 benchmark_available=False 응답 - 호출자 (competitor_intel.py:254) 의 기존 'FTC 미등재' 분기로 자동 fallback - franchise_count_national=0 명시 (다운스트림 KeyError 방지) 2. fuzzy prefix 매칭 (정확 매칭 실패 시 ILIKE prefix + frcsCnt > 0 우선) - 예: '홍콩반점' → '홍콩반점0410' 자동 매칭 - 가드: brand_name 길이 ≥ 4 (false positive 방지: '파리'/'피자' 같은 2자는 차단) 3. 정확 매칭 silent tie-break 제거 - 동일 (brandNm, yr) 다 row 시 logger.warning 으로 데이터 품질 신호 노출 엔지니어링: - _FUZZY_SQL module-level constant 분리 (text() 객체 매 호출 재생성 회피) - f-string 외부 괄호 정리 검증 (실 DB): - 빽다방(정상): avail=True, frcs=1449 ✓ - 홍콩반점→홍콩반점0410 (fuzzy 4자): matched, frcs=282 ✓ - 듀먼카페/에이에스커피 (paper): avail=False, frcs=0 명시 ✓ - 파리/피자 (2자 가드): 차단 ✓ - 스타벅스 (미등재): avail=False ✓ - competitor_intel.py:254 bench_block 빌드 시뮬: 폭발 안 함 ✓ - peer_brands 회귀: 통과 ✓ 다른 팀원 코드 변경 0 — competitor_intel.py 의 기존 'benchmark_available' 분기로 자동 처리. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/services/brand_profile.py | 70 ++++++++++++++++++++++++--- docs/retrospective/2026-05-05.md | 32 ++++++++++++ 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/backend/src/services/brand_profile.py b/backend/src/services/brand_profile.py index e2d4c31a..0ddd0367 100644 --- a/backend/src/services/brand_profile.py +++ b/backend/src/services/brand_profile.py @@ -8,6 +8,7 @@ from __future__ import annotations +import logging import os from typing import TypedDict @@ -15,8 +16,25 @@ from src.database.sync_engine import get_sync_engine +logger = logging.getLogger(__name__) + DEFAULT_YEAR = 2024 # FTC 2024 공시 기준 (2025 데이터는 2026 하반기 발표 예정) +# Module-level fuzzy SQL — text() 객체 매 호출마다 재생성 회피. +_FUZZY_SQL = text( + """ + SELECT "corpNm", "brandNm", "indutyLclasNm", "indutyMlsfcNm", + "frcsCnt", "newFrcsRgsCnt", "ctrtEndCnt", "ctrtCncltnCnt", "nmChgCnt", + "avrgSlsAmt", "arUnitAvrgSlsAmt" + FROM ftc_brand_franchise + WHERE "brandNm" ILIKE :pattern + AND yr = :year + AND ("frcsCnt" IS NULL OR "frcsCnt" > 0) + ORDER BY "frcsCnt" DESC NULLS LAST + LIMIT 1 + """ +) + class BrandBenchmark(TypedDict, total=False): brand_name: str @@ -46,9 +64,18 @@ def _won_from_thousand(val: int | None) -> int | None: def get_brand_benchmark(brand_name: str, year: int = DEFAULT_YEAR) -> BrandBenchmark: """FTC 가맹본부 공시에서 브랜드 연간 실적 조회. + 매칭 우선순위: + 1. 정확 일치 (`brandNm = :brand`) + 2. 1차 실패 시 fuzzy 매칭 (예: '홍콩반점' → '홍콩반점0410') — ILIKE prefix + frcsCnt 큰 것 우선 + 3. 매칭 row 의 frcsCnt = 0 이면 benchmark_available=False (closure_rate/growth_rate 계산 불가) + FTC 미등재 (직영 브랜드 등) 시 benchmark_available=False. """ - sql = text( + engine = get_sync_engine(os.environ["POSTGRES_URL"]) + + # 1차: 정확 일치 — 동일 (brandNm, yr) 다 row 시 silent tie-break 회피. + # 복수 row 발견 시 첫 row 반환 + WARNING 로그 (FTC 원본 brand명 변경 중간 단계 등 데이터 품질 신호). + exact_sql = text( """ SELECT "corpNm", "brandNm", "indutyLclasNm", "indutyMlsfcNm", "frcsCnt", "newFrcsRgsCnt", "ctrtEndCnt", "ctrtCncltnCnt", "nmChgCnt", @@ -56,12 +83,25 @@ def get_brand_benchmark(brand_name: str, year: int = DEFAULT_YEAR) -> BrandBench FROM ftc_brand_franchise WHERE "brandNm" = :brand AND yr = :year - LIMIT 1 """ ) - engine = get_sync_engine(os.environ["POSTGRES_URL"]) with engine.connect() as conn: - row = conn.execute(sql, {"brand": brand_name, "year": year}).mappings().first() + all_exact = conn.execute(exact_sql, {"brand": brand_name, "year": year}).mappings().all() + if len(all_exact) > 1: + logger.warning( + "[brand_profile] FTC 동일 brandNm 복수 row 감지 brand=%s yr=%d count=%d corps=%s", + brand_name, + year, + len(all_exact), + [r["corpNm"] for r in all_exact], + ) + row = all_exact[0] if all_exact else None + + # 2차: fuzzy prefix 매칭 — 정확 매칭 실패 시 접미 변형 흡수 (예: '홍콩반점' → '홍콩반점0410'). + # 위험 (false positive): '파리' → '파리바게뜨'/'파리크라상' 같은 별 brand 잘못 매칭 가능. + # 가드: 길이 ≥ 4 (한글 4자 이상은 prefix 충돌 거의 없음) + frcsCnt > 0 우선. + if row is None and brand_name and len(brand_name) >= 4: + row = conn.execute(_FUZZY_SQL, {"pattern": f"{brand_name}%", "year": year}).mappings().first() if not row: return { @@ -72,8 +112,22 @@ def get_brand_benchmark(brand_name: str, year: int = DEFAULT_YEAR) -> BrandBench } frcs = row["frcsCnt"] or 0 - closure_rate = (row["ctrtEndCnt"] or 0) / frcs if frcs else None - growth_rate = (row["newFrcsRgsCnt"] or 0) / frcs if frcs else None + + # frcsCnt = 0 (paper brand) → 계산 불가 명시. None * 100 등 다운스트림 폭발 방지. + if frcs == 0: + return { + "brand_name": row["brandNm"], + "benchmark_available": False, + "reason": f"FTC 등재됐으나 가맹점 0 (paper brand). corp={row['corpNm']!s}, yr={year}. 통계 계산 불가.", + "reference_year": year, + "corp_name": row["corpNm"], + "franchise_count_national": 0, + "industry_large": row["indutyLclasNm"], + "industry_medium": row["indutyMlsfcNm"], + } + + closure_rate = (row["ctrtEndCnt"] or 0) / frcs + growth_rate = (row["newFrcsRgsCnt"] or 0) / frcs return { "brand_name": row["brandNm"], @@ -87,8 +141,8 @@ def get_brand_benchmark(brand_name: str, year: int = DEFAULT_YEAR) -> BrandBench "closed_contracts": row["ctrtEndCnt"], "cancelled_contracts": row["ctrtCncltnCnt"], "name_changes": row["nmChgCnt"], - "closure_rate": round(closure_rate, 4) if closure_rate is not None else None, - "growth_rate": round(growth_rate, 4) if growth_rate is not None else None, + "closure_rate": round(closure_rate, 4), + "growth_rate": round(growth_rate, 4), "industry_large": row["indutyLclasNm"], "industry_medium": row["indutyMlsfcNm"], } diff --git a/docs/retrospective/2026-05-05.md b/docs/retrospective/2026-05-05.md index 68f5afa1..1208652a 100644 --- a/docs/retrospective/2026-05-05.md +++ b/docs/retrospective/2026-05-05.md @@ -450,3 +450,35 @@ ``` --- + +## 11:04:25 세션 완료 + +### 변경 파일 +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + docs/retrospective/2026-05-05.md | 8 ++++++++ + 1 file changed, 8 insertions(+) +``` + +--- + +## 11:35:51 세션 완료 + +### 변경 파일 +- backend/src/services/brand_profile.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AgentMapVisualizer.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + backend/src/services/brand_profile.py | 54 +++++++++++++++++++--- + docs/retrospective/2026-05-05.md | 24 ++++++++++ + frontend/src/components/AgentMapVisualizer.tsx | 2 +- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 4 +- + 4 files changed, 75 insertions(+), 9 deletions(-) +``` + +---