From 6142f6eb0bec5c9b551fd73acc45c1c4b284c876 Mon Sep 17 00:00:00 2001 From: bat1120 Date: Mon, 4 May 2026 23:00:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20ABM=20=EC=8B=9C=EB=AE=AC=20UX=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20+=20=EA=B0=80=EB=A7=B9=EC=82=AC=EC=97=85?= =?UTF-8?q?=EB=B2=95=20=ED=9B=84=EB=B3=B4=EC=A7=80=20=EC=98=81=EC=97=85?= =?UTF-8?q?=EA=B5=AC=EC=97=AD=20=ED=8F=89=EA=B0=80=20=EC=97=B0=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend: - legal node: vacancy_spot_analyses → spot_evaluations (rank/level/summary) 가공, franchise_law risk 에 attach - legal specialists: territory_radius_m 사용자 정의값 동적 반영 - competitor_intel: winner 매물 좌표 기준 카니발 분석 + 캐시 v3→v4 - main.py / abm_simulation_service: ABM Redis 캐시 TTL 1h→24h (재시뮬 비용 절감) - main.py: cache SET 로그 메시지 ttl=86400s 동기화 frontend: - types: LegalSpotEvaluation 타입 추가, LegalRisk.spot_evaluations 옵셔널 필드 - InsightsGrid: 후보지 영업구역 침해 점검 카드 4개 (rank/dong/level/summary) 추가 - AbmPersonaMap: preview 100→300, agent wandering drift 5배, action 별 시각 강화, click hit-test 18px, 디버그 console.log 3개 제거 - AgentMapVisualizer: competitor 라벨 숨김 → 클릭 popup + 비교반경원 - AbmTab: vacancy_spots 상권분석과 동기화 (winner 동만 score top4), same_brand_locations 자사 매장 별도 marker - AbmFloatingWidget: /dashboard/abm 라우트 차단 제거 (시뮬 진행 중 widget 유지) - abmStore: sessionStorage→localStorage (탭 닫음 후 결과 보존) chore: - .gitignore: backend/data/legal/processed/_embedding_cache.pkl 추가 - docs/retrospective/2026-05-04.md: 세션 로그 Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + backend/src/agents/legal/orchestrator.py | 19 +- backend/src/agents/legal/specialists.py | 19 +- backend/src/agents/nodes/competitor_intel.py | 54 +++- backend/src/agents/nodes/legal.py | 74 ++++- backend/src/main.py | 22 +- .../src/services/abm_simulation_service.py | 3 +- backend/tests/test_legal_orchestrator.py | 15 +- docs/retrospective/2026-05-04.md | 138 +++++++++ frontend/src/components/AbmPersonaMap.tsx | 280 ++++++++++++------ .../src/components/AgentMapVisualizer.tsx | 219 +++++++++++--- .../dashboard/tabs/AbmTab.tsx | 88 ++++-- .../sections/InsightsGrid.tsx | 81 +++-- .../simulation/AbmFloatingWidget.tsx | 5 +- frontend/src/stores/abmStore.ts | 5 +- frontend/src/types/index.ts | 22 ++ 16 files changed, 827 insertions(+), 218 deletions(-) diff --git a/.gitignore b/.gitignore index 622676fc..851c2974 100644 --- a/.gitignore +++ b/.gitignore @@ -176,6 +176,7 @@ sim_*.json # 법률 RAG 빌드 산출물 (build_parent_articles.py로 재생성 가능) backend/data/legal/processed/parent_articles.json +backend/data/legal/processed/_embedding_cache.pkl # RAG 검색 trace JSONL (디버깅용, 대용량) validation/results/rag_trace/ diff --git a/backend/src/agents/legal/orchestrator.py b/backend/src/agents/legal/orchestrator.py index 74312c4a..fc9b2ee9 100644 --- a/backend/src/agents/legal/orchestrator.py +++ b/backend/src/agents/legal/orchestrator.py @@ -21,21 +21,18 @@ logger = logging.getLogger(__name__) -# tasks 리스트와 1:1 대응 — 예외 처리 시 type 식별에 사용 +# tasks 리스트와 1:1 대응 — 예외 처리 시 type 식별에 사용. +# 운영(operation) 카테고리(food_hygiene/labor_law/vat_law/privacy_law/sewage_law) 제외 — +# 사용자 요청에 따라 frontend 미표시 + LLM 호출 절감 (privacy_law specialist skip). _RULE_ENGINE_ORDER: list[str] = [ - "food_hygiene", "safety_regulation", "fire_safety_law", "accessibility_law", "school_zone", "commercial_lease_law", - "labor_law", - "vat_law", - "sewage_law", "franchise_law", "fair_trade_law", "building_law", - "privacy_law", ] @@ -100,16 +97,14 @@ async def run_legal_evaluation( # 룰 9 개 — 동기 pure-Python (~ms). asyncio.to_thread executor 점유 회피 위해 # 직접 호출 후 dict 로 수집 — 이벤트루프 블로킹 위험 없음. rule_results: list[dict] = [] + # 운영(operation) 카테고리 룰 4개 (food_hygiene/labor/vat/sewage) 제외 — + # frontend 미표시 + 비용 절감. _RULE_ENGINE_ORDER 와 1:1 대응. for fn, args in ( - (rules.rule_food_hygiene, (business_type,)), (rules.rule_safety_regulation, (business_type, store_area_pyeong)), (rules.rule_fire_safety, (business_type, store_area_pyeong)), (rules.rule_accessibility, (business_type, store_area_pyeong)), (rules.rule_school_zone, (business_type, lat, lon)), (rules.rule_commercial_lease, ()), - (rules.rule_labor, ()), - (rules.rule_vat, ()), - (rules.rule_sewage, (business_type,)), ): try: rule_results.append(fn(*args)) @@ -117,14 +112,14 @@ async def run_legal_evaluation( type_name = _RULE_ENGINE_ORDER[len(rule_results)] rule_results.append(_fallback_for_type(type_name, e)) - # specialist 4 개 — RAG + LLM (I/O bound) 병렬 실행 + # specialist 3 개 — RAG + LLM (I/O bound) 병렬 실행. + # privacy_law specialist (운영 카테고리) 제외 — LLM 호출 절감 + frontend 미표시. # franchise_law 영업지역 분석 = 공실 스팟 좌표 (lat/lon) 기준 500m 반경. # 좌표 None 이면 specialist 가 행정동 centroid 폴백 사용. specialist_tasks = [ specialists.specialist_franchise_law(brand, business_type, district, ftc_data, lat, lon, territory_radius_m), specialists.specialist_fair_trade_law(brand, business_type, district), specialists.specialist_building_law(business_type, district), - specialists.specialist_privacy_law(brand, business_type, ftc_data), ] expected_total = len(rule_results) + len(specialist_tasks) diff --git a/backend/src/agents/legal/specialists.py b/backend/src/agents/legal/specialists.py index 718889a3..567ee914 100644 --- a/backend/src/agents/legal/specialists.py +++ b/backend/src/agents/legal/specialists.py @@ -361,6 +361,22 @@ async def specialist_franchise_law( precedent_text = _format_precedents(precedents) territory_floor, territory_hint = _territory_to_level(territory) + # 평가 기준 — 사용자 입력 영업구역 우선. 영업구역 미입력 시만 500m 일반 임계값. + if territory_radius_m: + _territory_criteria = ( + f"- 사용자 정의 영업구역 = **{territory_radius_m}m**. 이 거리만 침해 판정 기준으로 사용.\n" + f"- {territory_radius_m}m 안 동일 브랜드 1개 이상 → danger (가맹사업법 제12조의4 명백 침해)\n" + f"- {territory_radius_m}m 안 동일 브랜드 0개 → safe (정보공개서 기준 충족)\n" + "- summary/recommendation 에서 '500m' 같은 다른 거리 임계값 인용 금지. " + f"오직 {territory_radius_m}m 영업구역 기준만 사용.\n" + ) + else: + _territory_criteria = ( + "- 500m 내 동일 브랜드 1개 이상 + 자기잠식률 ≤-5% → danger (제12조의4 인접 출점)\n" + "- 500m 내 동일 브랜드 1개 이상 → caution (영업지역 협의 필요)\n" + "- 2km 내 동일 브랜드 3개 이상 → caution (자기잠식 위험)\n" + ) + user_content = ( f"브랜드: {brand}\n" f"업종: {business_type}\n" @@ -370,8 +386,7 @@ async def specialist_franchise_law( "[평가 기준]\n" "- 폐점률 ≥20% → danger 검토\n" "- 폐점률 ≥10% → caution\n" - "- 500m 내 동일 브랜드 1개 이상 + 자기잠식률 ≤-5% → danger (제12조의4 인접 출점)\n" - "- 500m 내 동일 브랜드 1개 이상 → caution (영업지역 협의 필요)\n" + f"{_territory_criteria}" "- 영업지역 침해(제12조의4)/허위과장(제9조)/필수품목 구입강제(제12조) → danger 후보\n" "- 신규 브랜드/직영 → safe~caution\n\n" "<<>>\n" diff --git a/backend/src/agents/nodes/competitor_intel.py b/backend/src/agents/nodes/competitor_intel.py index c216be04..c2a41d45 100644 --- a/backend/src/agents/nodes/competitor_intel.py +++ b/backend/src/agents/nodes/competitor_intel.py @@ -184,11 +184,29 @@ async def _try_cache_set(cache_key: str, payload: dict) -> None: pass -def _run_data_collection(dong_code: str, brand_name: str, keyword: str, ind_code: str | None, ind_label: str) -> dict: - """Python 서비스 4개 호출 (동기). asyncio.to_thread 로 래핑해서 사용.""" +def _run_data_collection( + dong_code: str, + brand_name: str, + keyword: str, + ind_code: str | None, + ind_label: str, + spot_lat: float | None = None, + spot_lon: float | None = None, +) -> dict: + """Python 서비스 4개 호출 (동기). asyncio.to_thread 로 래핑해서 사용. + + spot_lat/spot_lon 입력 시 카니발리제이션 = 매물 좌표 기준 (legal agent 와 일관). + 좌표 없으면 행정동 centroid 폴백. + """ comp_500 = analyze_competition(dong_code, keyword, radius_m=500) comp_1000 = analyze_competition(dong_code, keyword, radius_m=1000) - cannibal = analyze_cannibalization(dong_code, brand_name, radius_m=2000, industry=ind_label) + if spot_lat is not None and spot_lon is not None: + from src.services.commercial_intelligence import analyze_cannibalization_at + cannibal = analyze_cannibalization_at(spot_lat, spot_lon, brand_name, radius_m=2000, industry=ind_label) + if not cannibal or "error" in cannibal: + cannibal = analyze_cannibalization(dong_code, brand_name, radius_m=2000, industry=ind_label) + else: + cannibal = analyze_cannibalization(dong_code, brand_name, radius_m=2000, industry=ind_label) brand_bench = get_brand_benchmark(brand_name) peers: list[dict] = [] if brand_bench.get("benchmark_available") and brand_bench.get("industry_medium"): @@ -322,6 +340,25 @@ def _make_competitor_attr( "agent_attribution": _attr, } + # winner 매물 좌표 (legal agent 와 동일 기준점) — 일관성 확보. + # state.vacancy_spots 에서 winner_district 매칭 spot 추출, 없으면 첫 spot. + spot_lat: float | None = None + spot_lon: float | None = None + _vac_spots = state.get("vacancy_spots") or [] + if isinstance(_vac_spots, list) and _vac_spots and target_district: + _matched = [ + s for s in _vac_spots if isinstance(s, dict) and s.get("dong_name") == target_district + ] + _spot = _matched[0] if _matched else _vac_spots[0] + try: + _slat = _spot.get("lat") if isinstance(_spot, dict) else None + _slon = _spot.get("lon") if isinstance(_spot, dict) else None + if _slat is not None and _slon is not None: + spot_lat = float(_slat) + spot_lon = float(_slon) + except (TypeError, ValueError): + pass + # dong 이름 → code dong_code = resolve_dong_code(target_district) if not dong_code: @@ -361,9 +398,10 @@ def _make_competitor_attr( } # Redis 캐시 조회 - # v2 → v3: brand_mapping_resolver fix (resolve_brand_name 0차 호출 + canonical 통일) 반영. - # v2 캐시는 카니발 0 (브랜드 매칭 실패 결과) 저장되어 있어 무효화 필요. - cache_key = f"v3:competitor_intel:{dong_code}:{brand_name}" + # v3 → v4: 카니발리제이션 기준점 = winner 매물 좌표 (legal agent 와 일관). + # 캐시 키에 spot 좌표 포함 — 매물 변경 시 자동 invalidation. + _spot_key = f"{spot_lat:.5f},{spot_lon:.5f}" if spot_lat is not None and spot_lon is not None else "centroid" + cache_key = f"v4:competitor_intel:{dong_code}:{brand_name}:{_spot_key}" cached = await _try_cache_get(cache_key) if cached: print(f"[competitor_intel] 캐시 히트: {cache_key}") @@ -387,7 +425,9 @@ def _make_competitor_attr( # Python 서비스 데이터 수집 (동기 DB 호출이라 별도 스레드) try: - data = await asyncio.to_thread(_run_data_collection, dong_code, brand_name, keyword, ind_code, ind_label) + data = await asyncio.to_thread( + _run_data_collection, dong_code, brand_name, keyword, ind_code, ind_label, spot_lat, spot_lon + ) except Exception as e: logger.exception(f"[competitor_intel] 서비스 호출 실패: {e}") _attr = _make_competitor_attr( diff --git a/backend/src/agents/nodes/legal.py b/backend/src/agents/nodes/legal.py index 51fc8118..dfa39523 100644 --- a/backend/src/agents/nodes/legal.py +++ b/backend/src/agents/nodes/legal.py @@ -958,20 +958,16 @@ def _safe_ftc(r: object) -> dict: ) ftc_result = _safe_ftc(_ftc_raw) - # 룰엔진 결과 invariant 검증용 — orchestrator._RULE_ENGINE_ORDER 와 동일 13종. + # 룰엔진 결과 invariant 검증용 — orchestrator._RULE_ENGINE_ORDER 와 동일 8종 (location 한정). + # 운영(operation) 카테고리 5종 (food_hygiene/labor/vat/privacy/sewage) 제외 — LLM·시간 절감. _BATCH_TYPES = [ "franchise_law", "commercial_lease_law", - "food_hygiene", "safety_regulation", "building_law", "fire_safety_law", - "labor_law", - "vat_law", - "privacy_law", "accessibility_law", "school_zone", - "sewage_law", "fair_trade_law", ] @@ -1097,9 +1093,71 @@ def _r(type_name: str) -> dict: analysis = dict(state.get("analysis_results") or {}) analysis["legal_risks"] = risks analysis["overall_legal_risk"] = overall_level - # 4 vacancy spot 카니발리제이션 사전 스캔 결과 — franchise_law 보완 정보로 함께 노출. + # 4 vacancy spot 카니발리제이션 사전 스캔 결과 — 각 spot rank + level + summary 부여. + # rank = district_ranking scouting_results 점수 순위 (1등 = 점수 최고). + # 임계: territory 안 ≥1 → danger, 0 → safe(territory 입력 시) / caution(territory 미입력 + 500m 안 ≥1). if vacancy_spot_analyses: - analysis["vacancy_spot_cannibalization"] = vacancy_spot_analyses + # 동 → rank 매핑 (district_ranking scouting_results 기반). + _scout = state.get("scouting_results") or [] + _dong_rank: dict[str, int] = {} + if isinstance(_scout, list): + for _row in _scout: + if isinstance(_row, dict): + _d = _row.get("district") or _row.get("dong_name") + _r = _row.get("rank") + if _d and isinstance(_r, int): + _dong_rank[_d] = _r + + spot_evaluations: list[dict] = [] + for _va in vacancy_spot_analyses: + _terr = _va.get("territory_radius_m") + _within = _va.get("same_brand_within_territory") + _closest = _va.get("closest_m") + _2km = _va.get("same_brand_2000m", 0) + _500 = _va.get("same_brand_500m") or 0 + if _terr and _within is not None: + if _within >= 1: + _lvl = "danger" + _msg = f"영업구역({_terr}m) 안 동일 브랜드 {_within}개 — 가맹사업법 제12조의4 명백 침해" + else: + _lvl = "safe" + _msg = f"영업구역({_terr}m) 안 동일 브랜드 0개 — 정보공개서 기준 침해 없음" + else: + if _500 >= 1: + _lvl = "caution" + _msg = f"500m 내 동일 브랜드 {_500}개 — 영업지역 협의 필요" + elif _2km >= 3: + _lvl = "caution" + _msg = f"2km 내 동일 브랜드 {_2km}개 — 자기잠식 위험" + else: + _lvl = "safe" + _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, + }) + + # rank 순 정렬 (1등 → 4등). rank None 은 뒤로. + spot_evaluations.sort(key=lambda x: x.get("rank") or 999) + + analysis["vacancy_spot_cannibalization"] = spot_evaluations + # franchise_law risk 에 spot_evaluations attach — frontend 가 1등/2등/3등/4등 카드 렌더 가능. + for _r in risks: + if isinstance(_r, dict) and _r.get("type") == "franchise_law": + _r["spot_evaluations"] = spot_evaluations + break # Redis 캐시 저장 — RAG 실패 시 빈 articles가 캐시되는 것을 방지 # articles가 있는 리스크가 3개 미만이면 캐시하지 않음 (재실행 시 정상 결과 기대) diff --git a/backend/src/main.py b/backend/src/main.py index 86c81db3..a0185800 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -964,9 +964,7 @@ async def analyze_location(input_data: SimulationInput, response: Response): result["all_competitor_locations"] = await _collect_all_competitor_locations( winner, top3, input_data.business_type ) - result["same_brand_locations"] = await _collect_same_brand_locations( - winner, top3, input_data.brand_name - ) + result["same_brand_locations"] = await _collect_same_brand_locations(winner, top3, input_data.brand_name) return {"status": "success", "data": result} except Exception as e: print(f"!!! [API ERROR] !!! {str(e)}") @@ -1031,9 +1029,7 @@ async def analyze_llm(input_data: SimulationInput): print(f"[ANALYZE/LLM] all_competitor_locations 수집 실패 (무시): {e}") full["all_competitor_locations"] = [] try: - full["same_brand_locations"] = await _collect_same_brand_locations( - winner, top3, input_data.brand_name - ) + full["same_brand_locations"] = await _collect_same_brand_locations(winner, top3, input_data.brand_name) except Exception as e: print(f"[ANALYZE/LLM] same_brand_locations 수집 실패 (무시): {e}") full["same_brand_locations"] = [] @@ -1126,9 +1122,7 @@ async def _run() -> None: logger.warning(f"[/analyze/llm/async] all_competitor_locations 실패 (무시): {ce}") full["all_competitor_locations"] = [] try: - full["same_brand_locations"] = await _collect_same_brand_locations( - winner, top3, input_data.brand_name - ) + full["same_brand_locations"] = await _collect_same_brand_locations(winner, top3, input_data.brand_name) except Exception as ce: logger.warning(f"[/analyze/llm/async] same_brand_locations 실패 (무시): {ce}") full["same_brand_locations"] = [] @@ -1868,9 +1862,7 @@ async def run_simulation(input_data: SimulationInput, response: Response): winner = result.get("winner_district") or input_data.target_district top3 = result.get("top_3_candidates") or [] try: - result["same_brand_locations"] = await _collect_same_brand_locations( - winner, top3, input_data.brand_name - ) + result["same_brand_locations"] = await _collect_same_brand_locations(winner, top3, input_data.brand_name) except Exception as ce: logger.warning(f"[/simulate] same_brand_locations 실패 (무시): {ce}") result["same_brand_locations"] = [] @@ -2290,8 +2282,10 @@ async def run_abm_simulation(req: AbmSimulationRequest): try: async with aioredis.from_url(settings.redis_url, decode_responses=True) as r: cache_body = {k: v for k, v in response.items() if k != "trajectory"} - await r.setex(cache_key, 3600, _json.dumps(cache_body, ensure_ascii=False)) - logger.info(f"[ABM] cache SET key={cache_key[:16]}... ttl=3600s") + # 사용자 피드백 (2026-05-04): TTL 1h → 24h. ABM 시뮬 비용 큼 (gpt-4.1-mini ~$0.05/회) → + # 같은 시나리오 재시뮬 회피 위해 캐시 더 오래 유지. + await r.setex(cache_key, 86400, _json.dumps(cache_body, ensure_ascii=False)) + logger.info(f"[ABM] cache SET key={cache_key[:16]}... ttl=86400s") except Exception as e: logger.warning(f"[ABM] Redis 캐시 저장 실패(무시): {e}") diff --git a/backend/src/services/abm_simulation_service.py b/backend/src/services/abm_simulation_service.py index 11ebb645..7b563871 100644 --- a/backend/src/services/abm_simulation_service.py +++ b/backend/src/services/abm_simulation_service.py @@ -236,7 +236,8 @@ def _save_to_redis(*, cache_key: str, redis_url: str, response: dict[str, Any]) # daemon thread 안이라 sync redis 가 안전 (asyncio loop 부재) client = _redis_sync.from_url(redis_url, decode_responses=True) try: - client.setex(cache_key, 3600, _json.dumps(cache_body, ensure_ascii=False)) + # TTL 1h → 24h (2026-05-04 사용자 피드백) — main.py 와 동기. + client.setex(cache_key, 86400, _json.dumps(cache_body, ensure_ascii=False)) logger.info(f"[ABM async] redis SET key={cache_key[:16]}... ttl=3600s") finally: client.close() diff --git a/backend/tests/test_legal_orchestrator.py b/backend/tests/test_legal_orchestrator.py index 0980b217..3d43da08 100644 --- a/backend/tests/test_legal_orchestrator.py +++ b/backend/tests/test_legal_orchestrator.py @@ -83,7 +83,7 @@ async def test_returns_13_items(patch_specialists): store_area_pyeong=20.0, ftc_data=None, ) - assert len(risks) == 13 + assert len(risks) == 8 types = [r["type"] for r in risks] # 순서 확인 assert types == _RULE_ENGINE_ORDER @@ -100,8 +100,11 @@ async def test_types_unique_and_complete(patch_specialists): ) types = {r["type"] for r in risks} assert types == set(_RULE_ENGINE_ORDER) - assert len(types) == 13 + assert len(types) == 8 assert "school_zone" in types + # 운영 카테고리 5종 제외 검증 + for excluded in ("food_hygiene", "labor_law", "vat_law", "privacy_law", "sewage_law"): + assert excluded not in types @pytest.mark.asyncio @@ -144,15 +147,15 @@ async def test_specialist_failure_isolated(monkeypatch): store_area_pyeong=20.0, ftc_data=None, ) - assert len(risks) == 13 + assert len(risks) == 8 franchise = next(r for r in risks if r["type"] == "franchise_law") assert franchise["level"] == "caution" assert franchise.get("is_fallback") is True # 나머지 12 항목 중 룰 9 개는 정상 (fallback 아님) others = [r for r in risks if r["type"] != "franchise_law"] rules_only = [r for r in others if r["type"] in { - "food_hygiene", "safety_regulation", "fire_safety_law", "accessibility_law", - "school_zone", "commercial_lease_law", "labor_law", "vat_law", "sewage_law", + "safety_regulation", "fire_safety_law", "accessibility_law", + "school_zone", "commercial_lease_law", }] for r in rules_only: assert not r.get("is_fallback"), f"{r['type']} 이 예기치 않게 fallback 처리됨" @@ -173,7 +176,7 @@ async def test_multiple_specialist_failures(monkeypatch): store_area_pyeong=10.0, ftc_data=None, ) - assert len(risks) == 13 + assert len(risks) == 8 types = {r["type"] for r in risks} assert "franchise_law" in types assert "fair_trade_law" in types diff --git a/docs/retrospective/2026-05-04.md b/docs/retrospective/2026-05-04.md index e307c8e8..5fff8361 100644 --- a/docs/retrospective/2026-05-04.md +++ b/docs/retrospective/2026-05-04.md @@ -885,3 +885,141 @@ ``` --- + +## 22:23:30 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/specialists.py +- backend/src/agents/nodes/competitor_intel.py +- backend/src/agents/nodes/legal.py +- backend/src/main.py +- backend/src/services/abm_simulation_service.py +- docs/retrospective/2026-05-04.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/AgentMapVisualizer.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/simulation/AbmFloatingWidget.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/specialists.py | 19 +- + backend/src/agents/nodes/competitor_intel.py | 54 ++++- + backend/src/agents/nodes/legal.py | 66 ++++++- + backend/src/main.py | 4 +- + backend/src/services/abm_simulation_service.py | 3 +- + docs/retrospective/2026-05-04.md | 18 ++ + frontend/src/components/AbmPersonaMap.tsx | 154 +++++++++------ + frontend/src/components/AgentMapVisualizer.tsx | 219 +++++++++++++++++---- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 88 ++++++--- + .../components/simulation/AbmFloatingWidget.tsx | 5 +- + frontend/src/stores/abmStore.ts | 5 +- + 11 files changed, 502 insertions(+), 133 deletions(-) +``` + +--- + +## 22:25:04 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/specialists.py +- backend/src/agents/nodes/competitor_intel.py +- backend/src/agents/nodes/legal.py +- backend/src/main.py +- backend/src/services/abm_simulation_service.py +- docs/retrospective/2026-05-04.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/AgentMapVisualizer.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/simulation/AbmFloatingWidget.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/specialists.py | 19 +- + backend/src/agents/nodes/competitor_intel.py | 54 ++++- + backend/src/agents/nodes/legal.py | 66 ++++++- + backend/src/main.py | 4 +- + backend/src/services/abm_simulation_service.py | 3 +- + docs/retrospective/2026-05-04.md | 51 +++++ + frontend/src/components/AbmPersonaMap.tsx | 154 +++++++++------ + frontend/src/components/AgentMapVisualizer.tsx | 219 +++++++++++++++++---- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 88 ++++++--- + .../components/simulation/AbmFloatingWidget.tsx | 5 +- + frontend/src/stores/abmStore.ts | 5 +- + 11 files changed, 535 insertions(+), 133 deletions(-) +``` + +--- + +## 22:25:59 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/specialists.py +- backend/src/agents/nodes/competitor_intel.py +- backend/src/agents/nodes/legal.py +- backend/src/main.py +- backend/src/services/abm_simulation_service.py +- docs/retrospective/2026-05-04.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/AgentMapVisualizer.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/simulation/AbmFloatingWidget.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/specialists.py | 19 +- + backend/src/agents/nodes/competitor_intel.py | 54 ++++- + backend/src/agents/nodes/legal.py | 66 ++++++- + backend/src/main.py | 4 +- + backend/src/services/abm_simulation_service.py | 3 +- + docs/retrospective/2026-05-04.md | 84 ++++++++ + frontend/src/components/AbmPersonaMap.tsx | 154 +++++++++------ + frontend/src/components/AgentMapVisualizer.tsx | 219 +++++++++++++++++---- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 88 ++++++--- + .../components/simulation/AbmFloatingWidget.tsx | 5 +- + frontend/src/stores/abmStore.ts | 5 +- + 11 files changed, 568 insertions(+), 133 deletions(-) +``` + +--- + +## 22:39:45 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/orchestrator.py +- backend/src/agents/legal/specialists.py +- backend/src/agents/nodes/competitor_intel.py +- backend/src/agents/nodes/legal.py +- backend/src/main.py +- backend/src/services/abm_simulation_service.py +- backend/tests/test_legal_orchestrator.py +- docs/retrospective/2026-05-04.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/AgentMapVisualizer.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/InsightsGrid.tsx +- frontend/src/components/simulation/AbmFloatingWidget.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/orchestrator.py | 19 +- + backend/src/agents/legal/specialists.py | 19 +- + backend/src/agents/nodes/competitor_intel.py | 54 ++++- + backend/src/agents/nodes/legal.py | 74 ++++++- + backend/src/main.py | 4 +- + backend/src/services/abm_simulation_service.py | 3 +- + backend/tests/test_legal_orchestrator.py | 15 +- + docs/retrospective/2026-05-04.md | 120 +++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 200 ++++++++++++------- + frontend/src/components/AgentMapVisualizer.tsx | 219 +++++++++++++++++---- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 88 ++++++--- + .../SimulationResult/sections/InsightsGrid.tsx | 49 ++--- + .../components/simulation/AbmFloatingWidget.tsx | 5 +- + frontend/src/stores/abmStore.ts | 5 +- + 14 files changed, 676 insertions(+), 198 deletions(-) +``` + +--- diff --git a/frontend/src/components/AbmPersonaMap.tsx b/frontend/src/components/AbmPersonaMap.tsx index 9f017b2e..83d0d853 100644 --- a/frontend/src/components/AbmPersonaMap.tsx +++ b/frontend/src/components/AbmPersonaMap.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; -import { Play, Cloud, Calendar, DollarSign, Sliders } from 'lucide-react'; +import { Play, Cloud, Calendar, DollarSign, Sliders, Loader2 } from 'lucide-react'; import VacancySpotMarker from './VacancySpotMarker'; import VacancyStatsPanel from './VacancyStatsPanel'; import PersonaCard, { type PersonaCardData } from './PersonaCard'; @@ -399,8 +399,9 @@ export default function AbmPersonaMap({ abmError, onRunSimulation, targetDistrict = '서교동', - vacancySpots, - onSpotClick, + // vacancySpots / onSpotClick — 공실 패널 제거 (사용자 피드백 2026-05-04) 후 미사용. props 보존 (interface 호환). + vacancySpots: _vacancySpots, + onSpotClick: _onSpotClick, onClearResult, focusSpot, mode = 'general', @@ -537,7 +538,8 @@ export default function AbmPersonaMap({ }, []); // 시뮬에서 받은 실제 에이전트 수로 점박이 개수 맞춤 (기본 100) - const N_PERSONAS = abmResult?.n_personas ?? abmResult?.n_agents ?? 100; + // 사용자 피드백 (2026-05-04): 시뮬 전 지도 너무 비어보임 → preview 페르소나 100→300 으로 확대. + const N_PERSONAS = abmResult?.n_personas ?? abmResult?.n_agents ?? 300; // targetDistrict에 맞는 노드 세트. // 우선순위: @@ -903,8 +905,13 @@ export default function AbmPersonaMap({ // 현재: 픽셀 격자를 그대로 유지, 4 모서리 lat/lng 만 사용해 pointInPolygon. // 줌·팬 모두에서 일관된 hex 타일링 보장. const dg = densityGridRef.current; - if (dg && dg.cols > 0 && dg.rows > 0) { - const [minLat, minLon, maxLat, maxLon] = dg.bbox; + // 시뮬 전 dg 없을 때도 hex skeleton 그릴 수 있도록 mapo bbox fallback. + const useFallbackBbox = !dg || dg.cols <= 0 || dg.rows <= 0; + if (useFallbackBbox || (dg && dg.cols > 0 && dg.rows > 0)) { + const minLat = useFallbackBbox ? 37.524 : dg!.bbox[0]; + const minLon = useFallbackBbox ? 126.858 : dg!.bbox[1]; + const maxLat = useFallbackBbox ? 37.59 : dg!.bbox[2]; + const maxLon = useFallbackBbox ? 126.967 : dg!.bbox[3]; const topLeft = proj.containerPointFromCoords(new kakao.maps.LatLng(maxLat, minLon)); const bottomRight = proj.containerPointFromCoords(new kakao.maps.LatLng(minLat, maxLon)); const HEX_SIZE = 8; @@ -944,9 +951,13 @@ export default function AbmPersonaMap({ const lonRatio = (px - topLeft.x) / (dLonPx || 1); const hexLat = maxLat - latRatio * (maxLat - minLat); const hexLon = minLon + lonRatio * (maxLon - minLon); - const dr = Math.floor(((maxLat - hexLat) / (maxLat - minLat)) * dg.rows); - const dc = Math.floor(((hexLon - minLon) / (maxLon - minLon)) * dg.cols); - if (dr < 0 || dr >= dg.rows || dc < 0 || dc >= dg.cols) continue; + // dg 없으면 cells 매칭 불필요 → dr/dc 0 placeholder. skeleton-only 분기에서 사용 안 함. + const dgRows = dg?.rows ?? 0; + const dgCols = dg?.cols ?? 0; + const dr = dgRows > 0 ? Math.floor(((maxLat - hexLat) / (maxLat - minLat)) * dgRows) : 0; + const dc = dgCols > 0 ? Math.floor(((hexLon - minLon) / (maxLon - minLon)) * dgCols) : 0; + if (dgRows > 0 && dgCols > 0 && (dr < 0 || dr >= dgRows || dc < 0 || dc >= dgCols)) + continue; if (!inMapo(hexLat, hexLon)) continue; built.push({ x: px, y: py, dr, dc }); } @@ -1105,6 +1116,53 @@ export default function AbmPersonaMap({ .map((n, idx) => ({ idx, id: n.id, tier: n.tier })) .filter(({ id, tier }) => id.startsWith('subway-') || tier === 'S') .map(({ idx }) => idx); + // 사용자 피드백 (2026-05-04): 시뮬 전 인구 이동 느낌 — 마포 전역 분산. + // 각 persona 의 spawn 위치를 마포 polygon 안 random pixel 로 추출. nodes 주변 cluster 회피. + // 마포 polygon 픽셀 캐시 (mapoPolyPixelsRef) 의 bbox 안 random + polygon 내부 검사. + const mapoPolyPx = mapoPolyPixelsRef.current; + const polyBbox = (() => { + let minX = Infinity, + maxX = -Infinity, + minY = Infinity, + maxY = -Infinity; + for (const ring of mapoPolyPx) { + for (const p of ring) { + if (p.x < minX) minX = p.x; + if (p.x > maxX) maxX = p.x; + if (p.y < minY) minY = p.y; + if (p.y > maxY) maxY = p.y; + } + } + return { minX, maxX, minY, maxY }; + })(); + const inMapoPx = (x: number, y: number): boolean => { + if (mapoPolyPx.length === 0) return true; + for (const ring of mapoPolyPx) { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const xi = ring[i].x; + const yi = ring[i].y; + const xj = ring[j].x; + const yj = ring[j].y; + const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + if (inside) return true; + } + return false; + }; + const randomMapoPixel = (): { x: number; y: number } => { + // bbox 내 random + polygon 안 검사. 50회 시도, 실패 시 마지막 반환. + let last = { x: (polyBbox.minX + polyBbox.maxX) / 2, y: (polyBbox.minY + polyBbox.maxY) / 2 }; + for (let i = 0; i < 50; i++) { + const x = randomBetween(polyBbox.minX, polyBbox.maxX); + const y = randomBetween(polyBbox.minY, polyBbox.maxY); + if (inMapoPx(x, y)) return { x, y }; + last = { x, y }; + } + return last; + }; + personasRef.current = Array.from({ length: N_PERSONAS }, (_, i) => { const type = pickType(dist); const traits = roleTraits(type); @@ -1113,10 +1171,17 @@ export default function AbmPersonaMap({ let nodeIdx: number; let sourceIdx: number; - // 시작 위치 — External은 지하철역/교통 허브, 일반은 스팟 근처 let sx: number; let sy: number; - if (isExternal) { + // 시뮬 전 (abmResult null): 마포 전역 random pixel spawn. + // 시뮬 후: 기존 로직 (nodes 주변 spawn). + if (!abmResult && mapoPolyPx.length > 0) { + const px = randomMapoPixel(); + sx = px.x; + sy = px.y; + nodeIdx = preferred[0] ?? 0; + sourceIdx = nodeIdx; + } else if (isExternal) { const hubIdx = transitHubIdxs.length > 0 ? transitHubIdxs[Math.floor(Math.random() * transitHubIdxs.length)] @@ -1455,16 +1520,9 @@ export default function AbmPersonaMap({ // tierSPixelsRef 를 18px 이내 검색. 일치 agent 있으면 PersonaCard 모달 오픈. // (canvas 가 pointer-events:none 이라 직접 onClick 못 씀 → map 이벤트 사용) kakao.maps.event.addListener(map, 'click', (mouseEvent: any) => { - // 디버그 — click 이 도달하는지 확인 (사용자 보고: "안 눌려"). // 빠른 dot 클릭 가능하게 hit 반경 8 → 18px 로 확대. const tsCount = tierSPixelsRef.current.size; - const tCount = thoughtsByAgentRef.current.size; - console.log( - `[ABM click] tierSPixels=${tsCount} thoughts=${tCount} latLng=`, - mouseEvent?.latLng?.getLat?.()?.toFixed?.(5), - mouseEvent?.latLng?.getLng?.()?.toFixed?.(5), - ); if (tsCount === 0) { console.warn( '[ABM click] Tier S dot 없음 — backend 가 thoughts 응답을 안 줬거나, ' + @@ -1489,21 +1547,10 @@ export default function AbmPersonaMap({ } }); if (bestAid === null) { - // 가장 가까운 dot 거리도 함께 표시 (debug) - let nearestD = Infinity; - tierSPixelsRef.current.forEach((pix) => { - const d = Math.hypot(pix.x - clickPx.x, pix.y - clickPx.y); - if (d < nearestD) nearestD = d; - }); - - console.log( - `[ABM click] miss — 가장 가까운 Tier S dot ${nearestD.toFixed(1)}px (hit 임계 18px)`, - ); return; } const aid: number = bestAid; - console.log(`[ABM click] HIT agent#${aid}, distance=${Math.sqrt(bestD2).toFixed(1)}px`); const thoughts = Array.from(thoughtsByAgentRef.current.get(aid)?.values() ?? []); // 부모 콜백 우선 (있으면 부모가 모달 표시), 없으면 내부 PersonaCard 사용. if (onPersonaClick) { @@ -1723,6 +1770,39 @@ export default function AbmPersonaMap({ // intensity > 0.55 hex 는 네온 글로우 + 카운트 텍스트. const dg = densityGridRef.current; const hexes = hexGridRef.current; + + // 시뮬 전 — hex skeleton 만 (faint deep blue stroke). 결과 화면과 동일한 격자 느낌. + // dg 없을 때만 실행. dg 있으면 아래 풀 fill 패스에서 그림. + if (!dg && hexes.length > 0) { + const HEX_SIZE = 8; + const skeletonPath = new Path2D(); + for (let i = 0; i < 6; i++) { + const ang = (Math.PI / 3) * i + Math.PI / 6; + const px = HEX_SIZE * Math.cos(ang); + const py = HEX_SIZE * Math.sin(ang); + if (i === 0) skeletonPath.moveTo(px, py); + else skeletonPath.lineTo(px, py); + } + skeletonPath.closePath(); + ctx.save(); + ctx.strokeStyle = 'rgba(0, 44, 209, 0.15)'; + ctx.lineWidth = 0.5; + for (let h = 0; h < hexes.length; h++) { + const hex = hexes[h]; + if ( + hex.x < -HEX_SIZE || + hex.x > W + HEX_SIZE || + hex.y < -HEX_SIZE || + hex.y > H + HEX_SIZE + ) + continue; + ctx.translate(hex.x, hex.y); + ctx.stroke(skeletonPath); + ctx.translate(-hex.x, -hex.y); + } + ctx.restore(); + } + if (dg && hexes.length > 0) { // 실 인구 데이터(KOSTAT 24h)는 키가 "0"~"23". sim 데이터는 absHour // ("30","31"...) 키일 수 있음. 둘 다 호환되도록 우선 displayHour 직접 → @@ -1835,7 +1915,9 @@ export default function AbmPersonaMap({ // 자유 드리프트 진폭 — 서울 위도에서 1e-5 ≈ 0.85m. 1.8e-4 ≈ ~16m wandering 반경. // 사용자 피드백: "앞뒤로" 보이지 말고 "이리저리" wandering 으로. // 1축 perpendicular wobble (직선 양옆 진동) → lat/lon 독립 2축 Lissajous 드리프트. - const DRIFT_LATLON = 1.8e-4; + // 사용자 피드백 (2026-05-04): wandering 너무 한정적 → 5x 확대 (~80m). + // visit/rest/work 는 driftScale 로 작게 유지, move 만 풀 80m. + const DRIFT_LATLON = 9e-4; // 마포 polygon hit-test (사용자 피드백: agent 가 hex 안에서만 움직이도록). // mapoPolygonsRef 가 비어있으면 마스킹 skip (geo 미로드 시 fallback). @@ -1896,8 +1978,9 @@ export default function AbmPersonaMap({ // - move: 풀 wandering drift + role 색 → "이동" const action = prev.action || 'move'; // 드리프트 스케일 — rest/work 는 거의 정지, visit 은 작게 떨림, move 는 풀 wander. + // 사용자 피드백: 더 넓은 wandering. visit 도 살짝 키워 (0.25→0.4) 결제 펄스 느낌 ↑. const driftScale = - action === 'rest' ? 0.05 : action === 'work' ? 0.08 : action === 'visit' ? 0.25 : 1.0; + action === 'rest' ? 0.08 : action === 'work' ? 0.12 : action === 'visit' ? 0.4 : 1.0; // lat 축 — 주기 ~1.4 hour (실시간 ~5.6초). 너무 빠르지 않게 느리게. const driftLat = Math.sin(virtualHour * 0.7 + seed * tau) * DRIFT_LATLON * driftScale; // lon 축 — 주기 ~2.2 hour. lat 과 frequency 비 무리수 → 반복 X. @@ -1932,17 +2015,46 @@ export default function AbmPersonaMap({ if (isTierS) { tierSPixelsRef.current.set(agentId, { x: pix.x, y: pix.y }); + // action 별 시각 — visit 펄스 빨강, work 글로우, move trail, rest dim. + ctx.save(); ctx.globalAlpha = Math.max(alpha, 0.95); + + // 1) action 별 글로우 ring — 결과적 분위기 강화. + if (action === 'visit') { + // visit = 빨간 펄스 ring + const pulse = 0.7 + 0.3 * Math.sin(tickRef.current * 0.18); + ctx.shadowColor = 'rgba(255,56,0,0.85)'; + ctx.shadowBlur = 10 * pulse; + ctx.strokeStyle = `rgba(255,56,0,${0.6 * pulse})`; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(pix.x, pix.y, 9 + 2 * pulse, 0, Math.PI * 2); + ctx.stroke(); + } else if (action === 'work') { + // work = 초록 steady glow + ctx.shadowColor = 'rgba(0,186,122,0.6)'; + ctx.shadowBlur = 6; + } else if (action === 'move') { + // move = wandering halo + ctx.shadowColor = `${fill}aa`; + ctx.shadowBlur = 5; + } + + // 2) center fill dot — role 색 ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(pix.x, pix.y, 4.5, 0, Math.PI * 2); ctx.fill(); + + // 3) Tier S 표식 — 노란 ring + ctx.shadowBlur = 0; ctx.strokeStyle = '#FF7940'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(pix.x, pix.y, 6, 0, Math.PI * 2); ctx.stroke(); - ctx.globalAlpha = 1; + + ctx.restore(); tierSDrawn++; } // non-Tier-S ambient dot 제거 (사용자 피드백 2026-05-04) — @@ -2525,6 +2637,53 @@ export default function AbmPersonaMap({ style={{ zIndex: 10, pointerEvents: 'none' }} /> + {/* 시뮬 전 ready banner — 사용자가 시뮬 시작하기 전 지도 상태 명시. + abmResult 없고 abmLoading 도 없으면 표시 (시나리오 패널 단계). */} + {!abmResult && !abmLoading && mapLoaded && ( +
+
+ + + + + + READY + + + + 공실{' '} + + {(_vacancySpots ?? []).length} + + {' · '} + 경쟁/자사{' '} + {(competitors ?? []).length} + {' · '} + 페르소나 {N_PERSONAS} + + + 시뮬 시작 대기 +
+
+ )} + + {/* 시뮬 진행 중 banner — abmLoading 시 지도 위에도 progress 표시. */} + {abmLoading && mapLoaded && ( +
+
+ + + SIMULATING + + + + {focusSpot?.label ?? '—'} + + · 진행 중... +
+
+ )} + {/* dong hover tooltip — 마우스 올린 polygon 의 이름 (커서 우상단). */} {hoveredDong && (
)} - {/* 공실 스팟 선택 패널 — vacancySpots 받았을 때만 표시 + 결과 없을 때만 */} - {Array.isArray(vacancySpots) && - vacancySpots.length > 0 && - !abmResult?.new_store_visit_share_pct && ( -
-

- 공실 스팟 ({vacancySpots.length}) -

-

- 스팟 클릭 → 그 위치에서 ABM 시뮬 실행 -

-
- {[...vacancySpots] - .sort((a, b) => (b.listing_count ?? 0) - (a.listing_count ?? 0)) - .slice(0, 8) - .map((spot, idx) => { - const isTarget = spot.dong_name === targetDistrict; - return ( - - ); - })} -
-
- )} + {/* 공실 스팟 선택 패널 — 사용자 피드백 (2026-05-04): 산만해서 제거. + 지도 위 spot dot 클릭으로 직접 시뮬 실행. */} {/* 시뮬 결과 오버레이 — new_store_visit_share_pct 가 있을 때 (스팟 클릭 시뮬 후). shadow + backdrop-blur 제거 — 검은 blob 의심 (사용자 피드백 2026-05-04). */} @@ -3467,15 +3583,7 @@ export default function AbmPersonaMap({ className="mt-2 flex items-center justify-center gap-2 py-3 bg-primary hover:bg-primary/90 text-primary-foreground rounded-xl text-sm font-black tracking-tight transition-all duration-300 shadow-[0_4px_20px_rgba(0,44,209,0.25)] hover:shadow-[0_6px_28px_rgba(0,44,209,0.35)]" > - {targetDistrict} · {scenario.weather_override ?? '현재날씨'} ·{' '} - {scenario.date_override === '2026-05-05' - ? '공휴일' - : scenario.weekend_force - ? '주말' - : scenario.date_override - ? '평일' - : '오늘'}{' '} - · 임대료 +{Math.round(scenario.rent_shock_pct * 100)}% 시뮬 실행 + 시뮬레이션 실행
)} diff --git a/frontend/src/components/AgentMapVisualizer.tsx b/frontend/src/components/AgentMapVisualizer.tsx index c835f3bc..8ffc3940 100644 --- a/frontend/src/components/AgentMapVisualizer.tsx +++ b/frontend/src/components/AgentMapVisualizer.tsx @@ -63,6 +63,14 @@ export default function AgentMapVisualizer({ const [mapLoaded, setMapLoaded] = useState(false); const [targetPixels, setTargetPixels] = useState>({}); const [competitorPixels, setCompetitorPixels] = useState>({}); + // 사용자 피드백 (2026-05-04): 라벨 항상 표시는 산만. 클릭 시만 popup 표시. + // popup 안에 가게명 + 가장 가까운 공실 spot 까지 거리 (m) + 비교 반경 원 표시. + const [selectedCompetitorId, setSelectedCompetitorId] = useState(null); + // 비교 반경 (m) — 사용자 조절. 클릭한 경쟁업체의 가장 가까운 공실 spot 주변에 원 그림. + const [comparisonRadius, setComparisonRadius] = useState(500); + // Kakao Circle 인스턴스 ref — selected 변경 시 destroy + 재생성. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const radiusCircleRef = useRef(null); const KAKAO_MAP_API_KEY: string = import.meta.env?.VITE_KAKAO_MAP_API_KEY || ''; const IS_MOCK_MODE = !KAKAO_MAP_API_KEY || KAKAO_MAP_API_KEY.includes('YOUR'); @@ -198,6 +206,66 @@ export default function AgentMapVisualizer({ return () => cleanupFn(); }, [IS_MOCK_MODE, KAKAO_MAP_API_KEY, locations, updateCoordinates]); + // 선택된 경쟁업체의 가장 가까운 공실 spot 주변 비교 반경 원. + useEffect(() => { + // 기존 원 제거. + if (radiusCircleRef.current) { + try { + radiusCircleRef.current.setMap(null); + } catch { + /* noop */ + } + radiusCircleRef.current = null; + } + if (IS_MOCK_MODE) return; + if (selectedCompetitorId === null) return; + if (!mapInstanceRef.current) return; + const comp = competitors.find((c) => c.id === selectedCompetitorId); + if (!comp || !locations || locations.length === 0) return; + + // 가장 가까운 spot 찾기. + const toRad = (d: number) => (d * Math.PI) / 180; + const R = 6371000; + let nearest: LocationData | null = null; + let nearestDist = Infinity; + for (const loc of locations) { + const dLat = toRad(loc.lat - comp.lat); + const dLng = toRad(loc.lng - comp.lng); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(comp.lat)) * Math.cos(toRad(loc.lat)) * Math.sin(dLng / 2) ** 2; + const d = 2 * R * Math.asin(Math.sqrt(a)); + if (d < nearestDist) { + nearestDist = d; + nearest = loc; + } + } + if (!nearest) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const kakao = (window as any).kakao; + if (!kakao?.maps?.Circle || !kakao?.maps?.LatLng) return; + const center = new kakao.maps.LatLng(nearest.lat, nearest.lng); + const circle = new kakao.maps.Circle({ + center, + radius: comparisonRadius, + strokeWeight: 2, + strokeColor: '#002CD1', + strokeOpacity: 0.6, + strokeStyle: 'dashed', + fillColor: '#002CD1', + fillOpacity: 0.08, + }); + circle.setMap(mapInstanceRef.current); + radiusCircleRef.current = circle; + return () => { + try { + circle.setMap(null); + } catch { + /* noop */ + } + }; + }, [selectedCompetitorId, comparisonRadius, competitors, locations, IS_MOCK_MODE]); + return (
onSpotClick?.(loc)} disabled={!onSpotClick} title={`${loc.name}${loc.listingCount ? ` — 공실 ×${loc.listingCount}` : ''} 클릭해서 ABM 시뮬`} - className="absolute z-30 flex items-center justify-center w-8 h-8 rounded-full bg-decor-cyan border-2 border-decor-cyan text-foreground text-xs font-black shadow-[0_0_14px_rgba(0,224,209,0.8)] transition-all duration-200 pointer-events-auto cursor-pointer hover:scale-125 hover:bg-decor-cyan disabled:cursor-default disabled:opacity-60" + className="absolute z-30 flex items-center justify-center w-8 h-8 rounded-full bg-decor-cyan border-2 border-decor-cyan text-foreground text-xs font-black shadow-[0_0_14px_rgba(0,224,209,0.8)] transition-transform duration-200 pointer-events-auto cursor-pointer hover:scale-125 hover:bg-decor-cyan disabled:cursor-default disabled:opacity-60" style={{ left: pixel.x, top: pixel.y, @@ -288,55 +356,138 @@ export default function AgentMapVisualizer({ ); })} - {/* 경쟁업체 핀 */} + {/* 경쟁업체 핀 — 라벨 hidden, 클릭 시 popup. */} {mapLoaded && competitors.map((comp) => { const pixel = competitorPixels[comp.id]; if (!pixel) return null; const isFranchise = comp.is_franchise; - // 라이트 모드 시맨틱 토큰 — 동일 색을 SVG fill·stroke·background 에 모두 사용하기 위해 hex 리터럴 보존. - // var(--danger) #fb565b, var(--warning) #ffba00. 33/22 suffix 는 alpha (CSS hex8). const pinColor = isFranchise ? '#fb565b' : '#ffba00'; - const borderClass = isFranchise - ? 'border-danger shadow-[0_0_8px_rgba(244,63,94,0.5)]' - : 'border-warning shadow-[0_0_8px_rgba(249,115,22,0.4)]'; - const distLabel = comp.distance_m != null ? ` ${Math.round(comp.distance_m)}m` : ''; + const isSelected = selectedCompetitorId === comp.id; + + // 가장 가까운 공실 spot 까지 거리 (Haversine, m) + let nearestDist: number | null = null; + let nearestName: string | null = null; + if (locations && locations.length > 0) { + const toRad = (d: number) => (d * Math.PI) / 180; + const R = 6371000; // m + for (const loc of locations) { + const dLat = toRad(loc.lat - comp.lat); + const dLng = toRad(loc.lng - comp.lng); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(comp.lat)) * Math.cos(toRad(loc.lat)) * Math.sin(dLng / 2) ** 2; + const d = 2 * R * Math.asin(Math.sqrt(a)); + if (nearestDist === null || d < nearestDist) { + nearestDist = d; + nearestName = loc.name; + } + } + } + return (
-
- {comp.name} - {distLabel && ( - - {distLabel} - - )} -
- - - - { + e.stopPropagation(); + setSelectedCompetitorId((cur) => (cur === comp.id ? null : comp.id)); + }} + className="cursor-pointer hover:scale-125 transition-transform" + aria-label={`${comp.name} 정보`} > - {isFranchise ? '프랜차이즈' : '개인점'} - + + + + + {isSelected && ( +
+
+ + {comp.name} + + +
+
+
+ 유형:{' '} + {isFranchise ? '프랜차이즈' : '개인점'} +
+ {comp.category && ( +
+ 업종: {comp.category} +
+ )} + {nearestDist !== null && ( +
+ 최근접 공실:{' '} + + {Math.round(nearestDist)}m + + {nearestName ? ` (${nearestName})` : ''} +
+ )} + {comp.distance_m != null && ( +
+ 중심거리:{' '} + {Math.round(comp.distance_m)}m +
+ )} +
+ + {/* 비교 반경 slider — 사용자 조절 (100~2000m). 그 spot 주변에 점선 원 그림. */} +
+
+ 비교 반경 + + {comparisonRadius}m + +
+ setComparisonRadius(Number(e.target.value))} + onClick={(e) => e.stopPropagation()} + className="w-full h-1.5 accent-primary cursor-pointer" + aria-label="비교 반경 조절" + /> +
+
+ )}
); })} diff --git a/frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx b/frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx index 6555ce80..d727ac0f 100644 --- a/frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +++ b/frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx @@ -72,18 +72,43 @@ export function AbmTab({ simResult, brandName, businessType, storeArea }: Props) const targetDistrict = r?.winner_district || r?.target_district || r?.target_districts?.[0] || '서교동'; - // 지도 마커 데이터 — raw JSONB에서 방어적 추출. - // 우선순위: - // 1) recommended_vacancy_spots (신규 추천 에이전트 출력 — score/reason 포함, 보통 4개) - // 2) vacancy_spots (district_ranking _load_vacancy_spots — winner+target+top_3 dong 의 전체 월세 매물) - // 신규 에이전트 추천이 있으면 그것만 쓰고 (소수 정예), 없으면 fallback. + // 지도 마커 데이터 — 상권분석 페이지 (MapSection.buildBestVacancies) 와 동일 로직: + // winner_district 의 vacancy_spots 중 score 내림차순 top 4. 별도 추천 에이전트 출력 없음. + // recommended_vacancy_spots 가 있으면 그것 우선 (신규 에이전트 도입 시 자동 활용). + const winner: string | undefined = r?.winner_district || r?.target_district; const recommendedSpots = Array.isArray(r?.recommended_vacancy_spots) - ? r.recommended_vacancy_spots + ? r.recommended_vacancy_spots.slice(0, 4) : []; - const fallbackSpots = Array.isArray(r?.vacancy_spots) ? r.vacancy_spots : []; - const vacancySpots = recommendedSpots.length > 0 ? recommendedSpots : fallbackSpots; - const competitorSamples = Array.isArray(r?.competitor_intel?.competition_500m?.samples) - ? r.competitor_intel.competition_500m.samples + const allVacancySpots = Array.isArray(r?.vacancy_spots) ? r.vacancy_spots : []; + // 상권분석과 동일 — winner dong 만 + score 내림차순 → top 4. + const winnerVacancySpots = allVacancySpots + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .filter((s: any) => s.dong_name === winner && typeof s.lat === 'number' && typeof s.lon === 'number') + .slice() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .sort((a: any, b: any) => { + const sa = typeof a.score === 'number' ? a.score : Number.NEGATIVE_INFINITY; + const sb = typeof b.score === 'number' ? b.score : Number.NEGATIVE_INFINITY; + if (sa !== sb) return sb - sa; + return (b.listing_count ?? 0) - (a.listing_count ?? 0); + }) + .slice(0, 4); + const vacancySpots = recommendedSpots.length > 0 ? recommendedSpots : winnerVacancySpots; + // 경쟁업체 — 상권분석 페이지 buildCompetitors 와 동일. all_competitor_locations 우선 (max 200), + // fallback: competitor_intel.competition_500m.samples (max 100). + const allCompetitorLocations = Array.isArray(r?.all_competitor_locations) + ? r.all_competitor_locations.slice(0, 200) + : []; + const competitorSamples = + allCompetitorLocations.length > 0 + ? allCompetitorLocations + : Array.isArray(r?.competitor_intel?.competition_500m?.samples) + ? r.competitor_intel.competition_500m.samples.slice(0, 100) + : []; + + // 동일 브랜드 자사 매장 — winner+top3 4동 안. competitors 와 별도 marker 로 표시. + const sameBrandLocations = Array.isArray(r?.same_brand_locations) + ? r.same_brand_locations : []; // recommendedSpots 가 활성이면 'recommended' 타입 + score/reason 전달. @@ -103,19 +128,36 @@ export function AbmTab({ simResult, brandName, businessType, storeArea }: Props) // eslint-disable-next-line @typescript-eslint/no-explicit-any .filter((l: any) => typeof l.lat === 'number' && typeof l.lng === 'number'); - const competitors = competitorSamples - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .filter((s: any) => s.lat && (s.lng ?? s.lon)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((s: any) => ({ - id: s.id ?? `comp_${s.place_name}_${s.lat}`, - name: s.place_name || s.brand_name || '경쟁업체', - lat: s.lat, - lng: s.lng ?? s.lon, - distance_m: s.distance_m, - is_franchise: s.is_franchise ?? false, - category: s.category, - })); + const competitors = [ + ...competitorSamples + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .filter((s: any) => s.lat && (s.lng ?? s.lon)) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((s: any) => ({ + id: s.id ?? `comp_${s.place_name}_${s.lat}`, + name: s.place_name || s.brand_name || '경쟁업체', + lat: s.lat, + lng: s.lng ?? s.lon, + distance_m: s.distance_m, + is_franchise: s.is_franchise ?? false, + category: s.category, + })), + // 동일 브랜드 자사 매장 — competitors 컴포넌트 슬롯에 합쳐 marker 표시. + // is_franchise=true 로 marker 색 분기 가능 (자사 = 다른 색 권장). + ...sameBrandLocations + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .filter((s: any) => s.lat && (s.lng ?? s.lon)) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((s: any) => ({ + id: s.id ? `own_${s.id}` : `own_${s.place_name}_${s.lat}`, + name: s.place_name || s.brand_name || '자사 매장', + lat: s.lat, + lng: s.lng ?? s.lon, + distance_m: undefined, + is_franchise: true, // 자사 = 동일 브랜드 표식 + category: s.category ?? 'own_brand', + })), + ]; /** store action wrapper — payload 빌드 + startAbm 호출. */ function runAbm(params: { diff --git a/frontend/src/components/SimulationResult/sections/InsightsGrid.tsx b/frontend/src/components/SimulationResult/sections/InsightsGrid.tsx index b58cb28b..9db565ba 100644 --- a/frontend/src/components/SimulationResult/sections/InsightsGrid.tsx +++ b/frontend/src/components/SimulationResult/sections/InsightsGrid.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import type { LegalRisk, SimulationOutput } from '../../../types'; +import type { LegalRisk, LegalSpotEvaluation, SimulationOutput } from '../../../types'; import { SectionLabel } from '../shared/SectionLabel'; import { LegalDrawer } from '../shared/LegalDrawer'; import { AgentCard } from '../shared/AgentCard'; @@ -274,24 +274,70 @@ function normalizeLevel(level: string): 'HIGH' | 'MEDIUM' | 'LOW' { return 'MEDIUM'; } +const SPOT_LEVEL_CLS: Record = { + danger: { strip: 'bg-danger', text: 'text-danger', bg: 'bg-danger/5', label: '침해' }, + caution: { strip: 'bg-warning', text: 'text-warning', bg: 'bg-warning/5', label: '주의' }, + safe: { strip: 'bg-success', text: 'text-success', bg: 'bg-success/5', label: '안전' }, +}; + +function SpotEvaluationGrid({ spots }: { spots: LegalSpotEvaluation[] }) { + return ( +
+
+

+ 후보지 영업구역 침해 점검 +

+ + 가맹사업법 제12조의4 · 동일 브랜드 영업구역 기준 + +
+
+ {spots.map((s, i) => { + const cls = SPOT_LEVEL_CLS[s.level] ?? SPOT_LEVEL_CLS.caution; + return ( +
+
+ ); + })} +
+
+ ); +} + export function InsightsGrid({ simResult, legalOnly }: Props) { const [tab, setTab] = useState('legal'); const [selected, setSelected] = useState(null); // 안전 항목 (LOW/safe) 펼침 토글 — 본부 영업팀 빠른 판단을 위한 노이즈 정리 const [expandedSafe, setExpandedSafe] = useState(false); - // 운영 그룹(operation) 접힘 토글 — 출점 결정엔 입지 그룹이 우선이므로 default 접힘 - const [expandedOperation, setExpandedOperation] = useState(false); const legalAgent = simResult.agent_attributions?.find((a) => a.id === 'legal'); const risks = simResult.legal_risks ?? []; - // 입지(location) / 운영(operation) 그룹 분리 + // 입지(location) 그룹만 노출. 운영(operation) 카테고리는 사용자 요청에 따라 hide. const locationRisks = risks.filter((r) => resolveGroup(r) === 'location'); - const operationRisks = risks.filter((r) => resolveGroup(r) === 'operation'); - // 그룹별 위험 카운트 (헤더 메트릭) const locationHazard = locationRisks.filter((r) => normalizeLevel(r.risk_level) !== 'LOW').length; - const operationHazard = operationRisks.filter( - (r) => normalizeLevel(r.risk_level) !== 'LOW', - ).length; + // franchise_law risk 의 spot_evaluations — 후보지 4곳 영업구역 침해 평가 (rank/level/summary). + // backend legal.py 가 vacancy_spot_analyses 를 가공해 attach. + const franchiseRisk = risks.find((r) => r.type === 'franchise_law'); + const spotEvaluations = franchiseRisk?.spot_evaluations ?? []; const compIntel = simResult.competitor_intel as Record | null | undefined; const opportunities = (compIntel?.key_opportunities ?? []) as string[]; const riskTexts = (compIntel?.key_risks ?? []) as string[]; @@ -339,6 +385,9 @@ export function InsightsGrid({ simResult, legalOnly }: Props) {
) : ( <> + {/* ── 후보지 영업구역 침해 카드 (franchise_law spot_evaluations) ── */} + {spotEvaluations.length > 0 && } + {/* ── 입지(location) 섹션 — 출점 결정 critical, 항상 펼침 ── */} setExpandedSafe((v) => !v)} /> - - {/* ── 운영(operation) 섹션 — 일상 의무, default 접힘 ── */} - setExpandedOperation((v) => !v)} - onSelect={setSelected} - expandedSafe={expandedSafe} - onToggleSafe={() => setExpandedSafe((v) => !v)} - /> )} diff --git a/frontend/src/components/simulation/AbmFloatingWidget.tsx b/frontend/src/components/simulation/AbmFloatingWidget.tsx index f725656b..438fe005 100644 --- a/frontend/src/components/simulation/AbmFloatingWidget.tsx +++ b/frontend/src/components/simulation/AbmFloatingWidget.tsx @@ -104,7 +104,10 @@ export function AbmFloatingWidget() { }, [pos]); if (status === 'idle' || status === 'done') return null; - if (location.pathname.startsWith('/dashboard/abm')) return null; + // 사용자 피드백 (2026-05-04): ABM 페이지에서도 진행 중 widget 보이게. + // 이전: /dashboard/abm 라우트 차단했지만 사용자가 시뮬 실행 후 시나리오 패널 보면서 + // 진행 상태 안 보임 → widget 항상 표시 (location 차단 제거). + void location; if (mainSimStatus === 'running' || mainSimStatus === 'error') return null; // 드래그 위치 있으면 fixed top/left 로, 없으면 기본 우하단. z-[60] 으로 다른 floating 보다 위. diff --git a/frontend/src/stores/abmStore.ts b/frontend/src/stores/abmStore.ts index 01599f55..5769b0e7 100644 --- a/frontend/src/stores/abmStore.ts +++ b/frontend/src/stores/abmStore.ts @@ -505,7 +505,10 @@ export const useAbmStore = create()( }), { name: 'mapo-abm-store', - storage: createJSONStorage(() => sessionStorage), + // localStorage 사용 — 브라우저 닫아도 history + 마지막 결과 유지. + // 이전 sessionStorage 는 탭 닫으면 휘발. 사용자 피드백 (2026-05-04): 시뮬 결과 + // 더 오래 보존 원함 → localStorage 로 swap. quota 5-10MB 충분 (history max 10). + storage: createJSONStorage(() => localStorage), // running 중 새로고침 시 jobId 보존 → resumePollingIfNeeded 로 재개. // done 상태 result 도 보존 (탭 이동/F5 후 결과 유지). // error 는 휘발 (재진입 시 idle 로). diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3bc27191..654b3589 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -124,6 +124,26 @@ export interface LegalRiskArticle { explanation?: string; } +/** + * 입지 후보 spot 영업구역 침해 평가 (가맹사업법 제12조의4) — + * backend legal node 가 vacancy_spot_analyses 를 rank/level/summary 로 가공한 결과. + * franchise_law 리스크에 attach 되어 1등~4등 후보 동의 영업구역 침해 여부를 한눈에 표시. + */ +export interface LegalSpotEvaluation { + rank: number | null; + rank_label: string; + dong_name: string; + lat: number; + lon: number; + territory_radius_m: number | null; + same_brand_within_territory: number | null; + same_brand_500m: number; + same_brand_2000m: number; + closest_m: number | null; + level: 'safe' | 'caution' | 'danger'; + summary: string; +} + /** 법률 리스크 */ export interface LegalRisk { type: string; @@ -140,6 +160,8 @@ export interface LegalRisk { articles?: LegalRiskArticle[]; checklist?: LegalChecklistItem[]; is_fallback?: boolean; + /** franchise_law 전용 — 후보지 4곳 영업구역 침해 평가 (legal node attach). */ + spot_evaluations?: LegalSpotEvaluation[]; } /** 폐업 위험도 기여 피처 (LightGBM·TCN 공통 구조) */