diff --git a/backend/src/agents/nodes/legal.py b/backend/src/agents/nodes/legal.py index dfa39523..d4b10c67 100644 --- a/backend/src/agents/nodes/legal.py +++ b/backend/src/agents/nodes/legal.py @@ -802,7 +802,8 @@ 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" - cache_key = f"v8:legal:{_norm_brand}:{_norm_district}:{_norm_biz}:{float(store_area):.1f}:{_coord_key}:{_territory_key}" + # 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}" _redis = None try: _redis = aioredis.from_url(settings.redis_url, decode_responses=True) @@ -1026,8 +1027,9 @@ def _safe_ftc(r: object) -> dict: # batch_results를 타입별로 인덱싱 _batch_map = {r["type"]: r for r in batch_results} - # SP4: 15 risks 구성 — _batch_map(13 룰 항목) + zoning_result + ftc_result - # 순서는 다운스트림(인덱스 기반) 호환을 위해 유지 + # 입지(location) 10 risks 구성 — _batch_map(8 입지 룰) + zoning_result + ftc_result. + # 운영(operation) 5종 (food_hygiene/labor/vat/privacy/sewage) 제외 — fallback caution 부풀림 방지. + # frontend 카운트와 동기화 위해 risks 리스트 자체에서 운영 카테고리 미포함. def _r(type_name: str) -> dict: return _batch_map.get(type_name, _make_fallback_risk(type_name)) @@ -1035,17 +1037,12 @@ def _r(type_name: str) -> dict: _r("franchise_law"), _r("commercial_lease_law"), zoning_result, - _r("food_hygiene"), _r("safety_regulation"), ftc_result, _r("building_law"), _r("fire_safety_law"), - _r("labor_law"), - _r("vat_law"), - _r("privacy_law"), _r("accessibility_law"), _r("school_zone"), - _r("sewage_law"), _r("fair_trade_law"), ] @@ -1063,14 +1060,10 @@ def _r(type_name: str) -> dict: # 벌칙 조문 본문을 recommendation에 자동 추가 _enrich_penalty_info(risks) - # SP4: overall_level 결정 — 핵심 카테고리 + 임계값 룰 - # 핵심 = 미이행 시 영업정지/형사처벌이 직접적인 영역 - # (식품위생/소방/건축 + 학교환경위생정화구역) - # 핵심 1개라도 danger → overall=danger - # 비핵심 danger 2개 이상 → overall=danger (다중 위험) - # 그 외 danger 1개 또는 caution 존재 → caution - # 전부 safe → safe - _CRITICAL_TYPES = {"food_hygiene", "fire_safety_law", "building_law", "school_zone"} + # SP4: overall_level 결정 — 핵심 카테고리 + 임계값 룰 (입지 한정). + # 운영 카테고리(food_hygiene 등) 평가 제외 — 핵심 set 에서도 제거. + # 핵심 = 출점 자체 불가 또는 영업정지 직결 (소방/건축 + 학교환경위생정화구역). + _CRITICAL_TYPES = {"fire_safety_law", "building_law", "school_zone"} danger_types = [r.get("type", "") for r in risks if isinstance(r, dict) and r.get("level") == "danger"] has_critical_danger = any(t in _CRITICAL_TYPES for t in danger_types) diff --git a/docs/retrospective/2026-05-05.md b/docs/retrospective/2026-05-05.md new file mode 100644 index 00000000..27d2d7e8 --- /dev/null +++ b/docs/retrospective/2026-05-05.md @@ -0,0 +1,400 @@ + +## 00:35:32 세션 완료 + +### 변경 파일 +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + frontend/src/components/AbmPersonaMap.tsx | 405 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 2 files changed, 237 insertions(+), 184 deletions(-) +``` + +--- + +## 00:50:26 세션 완료 + +### 변경 파일 +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + frontend/src/components/AbmPersonaMap.tsx | 407 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 2 files changed, 238 insertions(+), 185 deletions(-) +``` + +--- + +## 00:51:54 세션 완료 + +### 변경 파일 +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + frontend/src/components/AbmPersonaMap.tsx | 407 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 2 files changed, 238 insertions(+), 185 deletions(-) +``` + +--- + +## 00:55:53 세션 완료 + +### 변경 파일 +- backend/src/services/corp_brand_resolver.py +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/components/AbmPersonaMap.tsx | 407 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 3 files changed, 260 insertions(+), 188 deletions(-) +``` + +--- + +## 01:23:53 세션 완료 + +### 변경 파일 +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 407 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 4 files changed, 271 insertions(+), 188 deletions(-) +``` + +--- + +## 01:26:05 세션 완료 + +### 변경 파일 +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 407 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 4 files changed, 271 insertions(+), 188 deletions(-) +``` + +--- + +## 01:27:08 세션 완료 + +### 변경 파일 +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 407 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 4 files changed, 271 insertions(+), 188 deletions(-) +``` + +--- + +## 01:28:30 세션 완료 + +### 변경 파일 +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 407 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 4 files changed, 271 insertions(+), 188 deletions(-) +``` + +--- + +## 01:29:11 세션 완료 + +### 변경 파일 +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 407 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 4 files changed, 271 insertions(+), 188 deletions(-) +``` + +--- + +## 01:30:42 세션 완료 + +### 변경 파일 +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 407 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 4 files changed, 271 insertions(+), 188 deletions(-) +``` + +--- + +## 01:32:09 세션 완료 + +### 변경 파일 +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 407 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 4 files changed, 271 insertions(+), 188 deletions(-) +``` + +--- + +## 01:32:57 세션 완료 + +### 변경 파일 +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 407 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 4 files changed, 271 insertions(+), 188 deletions(-) +``` + +--- + +## 01:39:32 세션 완료 + +### 변경 파일 +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 407 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 4 files changed, 271 insertions(+), 188 deletions(-) +``` + +--- + +## 01:42:17 세션 완료 + +### 변경 파일 +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 407 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 4 files changed, 271 insertions(+), 188 deletions(-) +``` + +--- + +## 01:43:10 세션 완료 + +### 변경 파일 +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx + +### diff 요약 +``` + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 407 ++++++++++++--------- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- + 4 files changed, 271 insertions(+), 188 deletions(-) +``` + +--- + +## 02:48:35 세션 완료 + +### 변경 파일 +- backend/src/agents/nodes/legal.py +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/nodes/legal.py | 25 +- + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 864 +++++++++++++-------- + .../SimulationResult/dashboard/DashboardHub.tsx | 4 +- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 56 +- + frontend/src/stores/abmStore.ts | 120 ++- + 7 files changed, 717 insertions(+), 388 deletions(-) +``` + +--- + +## 02:53:08 세션 완료 + +### 변경 파일 +- backend/src/agents/nodes/legal.py +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/ui/InfoTooltip.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/nodes/legal.py | 25 +- + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 864 +++++++++++++-------- + .../SimulationResult/dashboard/DashboardHub.tsx | 4 +- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 56 +- + frontend/src/components/ui/InfoTooltip.tsx | 4 +- + frontend/src/stores/abmStore.ts | 120 ++- + 8 files changed, 719 insertions(+), 390 deletions(-) +``` + +--- + +## 02:54:17 세션 완료 + +### 변경 파일 +- backend/src/agents/nodes/legal.py +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/ui/InfoTooltip.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/nodes/legal.py | 25 +- + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 864 +++++++++++++-------- + .../SimulationResult/dashboard/DashboardHub.tsx | 4 +- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 56 +- + frontend/src/components/ui/InfoTooltip.tsx | 4 +- + frontend/src/stores/abmStore.ts | 120 ++- + 8 files changed, 719 insertions(+), 390 deletions(-) +``` + +--- + +## 02:56:05 세션 완료 + +### 변경 파일 +- backend/src/agents/nodes/legal.py +- backend/src/services/corp_brand_resolver.py +- frontend/src/api/client.ts +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/ui/InfoTooltip.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/nodes/legal.py | 25 +- + backend/src/services/corp_brand_resolver.py | 25 +- + frontend/src/api/client.ts | 11 + + frontend/src/components/AbmPersonaMap.tsx | 864 +++++++++++++-------- + .../SimulationResult/dashboard/DashboardHub.tsx | 4 +- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 56 +- + frontend/src/components/ui/InfoTooltip.tsx | 4 +- + frontend/src/stores/abmStore.ts | 120 ++- + 8 files changed, 719 insertions(+), 390 deletions(-) +``` + +--- + +## 03:02:36 세션 완료 + +### 변경 파일 +- backend/src/agents/nodes/legal.py +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/ui/InfoTooltip.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/nodes/legal.py | 25 +- + frontend/src/components/AbmPersonaMap.tsx | 864 +++++++++++++-------- + .../SimulationResult/dashboard/DashboardHub.tsx | 4 +- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 56 +- + frontend/src/components/ui/InfoTooltip.tsx | 4 +- + frontend/src/stores/abmStore.ts | 120 ++- + 6 files changed, 686 insertions(+), 387 deletions(-) +``` + +--- diff --git a/docs/superpowers/specs/2026-05-04-abm-waiting-screen-design.md b/docs/superpowers/specs/2026-05-04-abm-waiting-screen-design.md new file mode 100644 index 00000000..9233180f --- /dev/null +++ b/docs/superpowers/specs/2026-05-04-abm-waiting-screen-design.md @@ -0,0 +1,192 @@ +# ABM 대기화면 정보 보강 — Spot Info + Persona Preview Stream + +**Date**: 2026-05-04 +**Branch**: `IM3-competitor-place-url` +**Author**: 찬영 (A1) + +## 문제 + +ABM 페르소나 시뮬에서 사용자가 지도에서 공실 spot 클릭하고 ABM 모드 진입하면, 우측 패널에 시나리오 form (날씨/요일/임대료) 만 보임. "시뮬레이션 실행" 누른 후 ~3분 대기 동안에는 `AbmProgressPanel` (7-stage pipeline + progress bar) 만 보임. 둘 다 spot 자체 정보, 시뮬 의미 부여 정보 부족. + +## 목표 + +대기 시간 두 단계에 정보 표시 추가: + +- **Phase 1 (시뮬 실행 전)**: 선택 spot 의 컨텍스트 정보 (`SpotInfoCard`) +- **Phase 2 (시뮬 진행 중)**: 카테고리별 페르소나 샘플 대화 rotate (`PersonaPreviewStream`) + +backend 변경 0개. 모든 데이터 기존 props / 기존 endpoint 에서 조달. + +## 비목표 + +- 진짜 partial result streaming (backend ABM 노드 단계별 snapshot 캐시 — 본 sprint 범위 외, 옵션 B-1 으로 명시 후 폐기) +- 신규 backend endpoint +- spot 사진 / 카카오 로드뷰 iframe (옵션 f 폐기) + +## 설계 + +### 1. 컴포넌트 구조 + +``` +AbmPersonaMap.tsx 우측 패널 분기 (line ~3393): + abmResult ? <결과> + : abmLoading ? + + ← 신규 + : abmError ? <에러> + : ← 신규 + + <시나리오 form> ← 기존 (collapse 처리) +``` + +### 2. 신규 파일 + +| 파일 | 역할 | +|------|------| +| `frontend/src/components/abm/SpotInfoCard.tsx` | Phase 1 spot 정보 카드 | +| `frontend/src/components/abm/PersonaPreviewStream.tsx` | Phase 2 샘플 대화 rotate | +| `frontend/src/data/personaSampleDialogs.ts` | 카테고리별 샘플 대화 pool | + +### 3. SpotInfoCard 상세 + +**Props**: +```ts +interface Props { + focusSpot: { lat: number; lon: number; label?: string }; + storeNodes: StoreNode[]; // AbmPersonaMap 내부 fetch 결과 (지하철 포함) + vacancySpots?: AgentVacancySpot[]; // listing_count 매칭용 + competitors?: Array<{ lat; lng?; lon?; name?; place_name?; distance_m?; category? }>; + businessType?: string; // 'cafe' / 'restaurant' / '음식점' … + dongStats?: { // simResult 에서 추출 (AbmPersonaMap props 추가) + resident_pop?: number; + floating_pop?: number; + closure_rate?: number; + }; +} +``` + +**섹션 (위 → 아래)**: + +1. **Header** — focusSpot.label + dong 명, "선택 SPOT" 배지 +2. **주소** (a) — Kakao Local SDK `services.Geocoder().coord2Address(lon, lat)` 비동기 fetch. 실패 시 `좌표: lat, lon` fallback. +3. **가장 가까운 지하철** (b) — storeNodes 중 `tier === 'S'` 항목들에서 focusSpot 기준 haversine 최단 + 거리 m 표시. storeNodes 비면 "—". +4. **listing_count** (c) — vacancySpots 에서 focusSpot 좌표 ±0.0001 매칭. 매물 N개. 없으면 표시 안함. +5. **동 통계** (d) — dongStats 의 resident_pop / floating_pop / closure_rate 3개 metric 한 줄씩. null 값은 row skip. +6. **주변 경쟁업체** (e) — competitors 중 focusSpot 기준 haversine ≤ 500m 인 것만. distance 오름차순 top 5. 카드 list (이름, 카테고리, 거리). 0개면 "주변 500m 내 경쟁업체 없음". +7. **Brand Fit 한 줄** (g) — competitors 카테고리 분포 + businessType heuristic: + - businessType 과 같은 카테고리 비율 ≥ 30% → "포화 우려 — 차별화 필수" + - 10~30% → "균형 — 표준 진입 가능" + - < 10% → "신규 진입 기회 — 대체 수요 검증 필요" + - 없음(null businessType) → 표시 안함 + +**스타일**: 기존 `box-glass` 패턴, `SectionLabel` 재사용, 텍스트 사이즈는 시나리오 form 과 동일 (`text-[11px]` ~ `text-xs`). + +**collapse 동작**: 시나리오 form 은 SpotInfoCard 아래에 `
` 으로 래핑. 사용자가 spot info 더 보고 싶으면 form 접을 수 있음. default expand. + +### 4. PersonaPreviewStream 상세 + +**Props**: +```ts +interface Props { + businessType?: string; // 카테고리 매칭 (cafe/restaurant/etc) + spotLabel?: string; // 표시용 +} +``` + +**내부 로직**: +- `personaSampleDialogs.ts` 에서 businessType 매칭 카테고리 pool 로드 (없으면 default pool) +- pool 구조: `{ category: string, dialogs: Array<{ persona: string; tier: 'S'|'A'|'B'; text: string }> }[]` +- 6초 interval 로 dialog 1개 add (cumulative, 최근 4개 유지) +- pool 길이 < 추가 횟수 면 shuffle 재순환 +- progress 진행 단계 (`useAbmStore` 의 stage) 표시 prefix — "[탐색 중]" "[추론 중]" 등 +- abmLoading=true 일 때만 마운트 + +**UI**: +``` +┌─ AI 페르소나 추론 라이브 ─────────────┐ +│ [방금] [탐색 중] 30대 직장인 박씨 (S) │ +│ "여기 점심 먹을 만하네. 가격도..." │ +│ [12s 전] [추론 중] 20대 학생 김씨 (A)│ +│ "친구랑 카페로 들리기 좋음." │ +│ ... │ +│ ⓘ 샘플 페르소나 — 실제 시뮬 결과는 │ +│ 완료 후 표시됩니다. │ +└────────────────────────────────────────┘ +``` + +**fade animation**: 새 메시지는 opacity 0 → 1 + translateY(-4px) → 0 transition. 위로 밀려나는 메시지는 opacity 단계 감소. + +**경고 명시**: 카드 하단 "샘플 페르소나" 표기 (사용자 오인 방지). + +### 5. personaSampleDialogs 데이터 구조 + +```ts +export interface PersonaDialog { + persona: string; // "30대 직장인 박씨" + tier: 'S' | 'A' | 'B'; + text: string; // 1~2 문장 한국어 +} + +export interface CategoryPool { + category: string; // 'cafe' | 'restaurant' | 'pub' | 'convenience' | 'default' + dialogs: PersonaDialog[]; +} + +export const SAMPLE_DIALOGS: CategoryPool[] = [ + { category: 'cafe', dialogs: [ + { persona: '20대 학생 김씨', tier: 'A', text: '카페인 충전 필요. 콘센트 있고 wifi 빠르면 OK.' }, + { persona: '30대 직장인 박씨', tier: 'S', text: '점심 후 동료랑 잠깐 — 회의실 분위기 카페면 더 좋음.' }, + // … 8개씩 + ]}, + { category: 'restaurant', dialogs: [...] }, + { category: 'pub', dialogs: [...] }, + { category: 'convenience', dialogs: [...] }, + { category: 'default', dialogs: [...] }, +]; +``` + +각 카테고리 8개 이상 (6초 × 8 = 48초 unique → 3분 동안 4 cycle, 충분히 다양해 보임). + +### 6. AbmPersonaMap 변경 + +- props 에 `dongStats?: { resident_pop?: number; floating_pop?: number; closure_rate?: number }` 추가 +- 우측 패널 분기 두 곳 수정: + - `abmLoading ? ` → `abmLoading ? <>` + - `else <시나리오 form>` → `else <>
시나리오 설정<시나리오 form/>
` +- props 에 `businessType?: string` 추가 (없으면 useEffect skip) + +### 7. AbmTab 변경 + +- `` 에 prop 두 개 추가: + - `businessType={businessType}` + - `dongStats={{ resident_pop: r?.demographic_report?.area_resident_count, floating_pop: r?.market_report?.floating_population, closure_rate: r?.closure_rate?.recent_value }}` (필드명 실제 schema 에 맞춰 추출) + +### 8. 에러 / 폴백 + +| 상황 | 동작 | +|------|------| +| Kakao Geocoder 실패 | 좌표 fallback 표시 | +| storeNodes 비어있음 | 지하철 row 숨김 | +| competitors 비어있음 | "주변 500m 내 경쟁업체 없음" | +| businessType null | brand fit row + persona category default pool | +| dongStats 모두 null | 동 통계 섹션 자체 숨김 | + +### 9. 테스트 + +- `SpotInfoCard.test.tsx` — props 변형으로 각 row 표시/숨김 검증 (Vitest + RTL) +- `PersonaPreviewStream.test.tsx` — interval mock 으로 6초 후 새 dialog add 검증 +- `personaSampleDialogs.test.ts` — 각 카테고리 ≥ 8개, persona/text non-empty + +### 10. 작업량 + +| 단계 | 시간 | +|------|------| +| SpotInfoCard 구현 + 테스트 | 3h | +| PersonaPreviewStream + dialog pool | 2h | +| AbmPersonaMap / AbmTab 배선 | 1h | +| QA + prettier | 0.5h | +| **합계** | **~6.5h (1 일)** | + +## 결정 기록 + +- **B-2 연출 streaming 채택** (B-1 진짜 streaming 폐기) — backend ABM 비동기 task 의 partial snapshot 저장 인프라 미존재, 본 sprint 범위 초과. +- **로드뷰 (f) 폐기** — Kakao Roadview iframe 은 페이지 무거워지고 spot 좌표가 도로 위 아닐 시 빈 화면. ROI 낮음. +- **시나리오 form collapse 채택** — spot info 가 메인 내용이 되어야 하지만 form 은 시뮬 실행에 필수 → `
` 로 양립. diff --git a/frontend/src/components/AbmPersonaMap.tsx b/frontend/src/components/AbmPersonaMap.tsx index 83d0d853..1beef9be 100644 --- a/frontend/src/components/AbmPersonaMap.tsx +++ b/frontend/src/components/AbmPersonaMap.tsx @@ -6,6 +6,10 @@ import PersonaCard, { type PersonaCardData } from './PersonaCard'; import AbmProgressPanel from './AbmProgressPanel'; import { FormField } from './ui/FormField'; import { SectionLabel } from './ui/SectionLabel'; +import type { SpotDongStats } from './abm/SpotInfoCard'; +import { PersonaPreviewStream } from './abm/PersonaPreviewStream'; +import { AbmQueuePanel } from './abm/AbmQueuePanel'; +import { useAbmStore } from '../stores/abmStore'; // 스팟 노드 스키마 — 백엔드 /mapo/spots/{dong} 에서 동적 조회 (하드코딩 없음) interface StoreNode { @@ -204,6 +208,10 @@ export interface AbmPersonaMapProps { * 부모(AbmTab)가 PersonaCard 모달로 연결. */ onPersonaClick?: (agentId: number, thoughts: AbmThought[]) => void; + /** 시뮬 대기화면 SpotInfoCard / PersonaPreviewStream 용 업종. */ + businessType?: string | null; + /** SpotInfoCard 의 동 통계 섹션 — simResult 에서 부모가 추출. */ + dongStats?: SpotDongStats | null; } function randomBetween(a: number, b: number) { @@ -410,6 +418,8 @@ export default function AbmPersonaMap({ vacancyPseSummary = null, competitors, onPersonaClick, + businessType, + dongStats: _dongStats, }: AbmPersonaMapProps) { const mapContainerRef = useRef(null); const mapInstanceRef = useRef(null); @@ -552,6 +562,25 @@ export default function AbmPersonaMap({ // abmResult에서 받은 customer_profile_dist를 ref로 유지 (pickType에 전달) const customerProfileDistRef = useRef | undefined>(undefined); + // wander 모드 활성 조건 ref — abmResult 없음 (시뮬 전 + 진행 중) 이면 agents 가 마포 전역 + // 랜덤 픽셀로 wander (node target 무시) — 한 곳 cluster 회피. 결과 도착 후엔 정상 node 타깃. + const wanderActiveRef = useRef(!abmResult); + useEffect(() => { + wanderActiveRef.current = !abmResult; + }, [abmResult]); + + // 현재 진행 중 시뮬의 spot — focusSpot 과 다르면 사용자가 다른 spot 보는 중 (queue 추가 의도). + // 사용자 피드백 (2026-05-05): 시뮬 진행 중 다른 spot 클릭하면 progress panel 만 떠서 + // 시나리오 form 못 봄 → 진행 중 spot 과 focusSpot 다를 때만 progress 표시 (같은 spot 일 때만). + const runningParams = useAbmStore((s) => s.params); + const isRunningCurrentSpot = + abmLoading && + !!focusSpot && + !!runningParams?.spot_lat && + !!runningParams?.spot_lon && + Math.abs((runningParams.spot_lat ?? 0) - focusSpot.lat) < 1e-5 && + Math.abs((runningParams.spot_lon ?? 0) - focusSpot.lon) < 1e-5; + // vacancy 모드 — 4 endpoint fetch 결과 (mode='vacancy' 시만 사용) const [vacancyTrajectory, setVacancyTrajectory] = useState([]); const [vacancyVisits, setVacancyVisits] = useState([]); @@ -822,12 +851,13 @@ export default function AbmPersonaMap({ } // mode='general' (default). - // 우선순위: competitors prop (선택 공실 근처 동일 카테고리 매장) → spots-all → /mapo/spots/{dong}. - // competitors 가 있으면 마포 전역 80 spot 대신 경쟁사 좌표를 storeNodes 로 사용 → - // 5000 agents 가 그 매장들에 visit 하는 모습이 의미 있는 "신규 vs 경쟁" 분포 시각화가 됨. + // 우선순위: + // - abmResult 없음 (시뮬 실행 전 + 진행 중): 마포 전역 spots-all (~80 spot, 16동 × 5) + // → agents 가 마포 전체로 roam (사용자 피드백 2026-05-05: 한 동 cluster → 전역 분산). + // - abmResult 있음 + competitors: 경쟁업체 좌표 → "신규 vs 경쟁" visit 분포 시각화. let cancelled = false; setSpotsLoading(true); - if (Array.isArray(competitors) && competitors.length > 0) { + if (abmResult && Array.isArray(competitors) && competitors.length > 0) { const compNodes: StoreNode[] = competitors .filter( (c) => @@ -875,7 +905,7 @@ export default function AbmPersonaMap({ return () => { cancelled = true; }; - }, [mode, vacancyJobId, targetDistrict, competitors]); + }, [mode, vacancyJobId, targetDistrict, competitors, abmResult]); // mode='vacancy' 시 외부에서 prop 으로 주입된 pse_summary 동기화 useEffect(() => { @@ -2273,6 +2303,131 @@ export default function AbmPersonaMap({ }); p.hasSpawned = true; } + // 시뮬 진행 중 (abmLoading=true) — 마포 전역 wander. + // 사용자 피드백 (2026-05-05): 한 동에 모임 → 매 cycle 새 random 마포 좌표로 + // 보내 끊임없이 분산 이동. + // + // bbox source 우선순위: + // 1) Kakao map projection 기반 hard-coded 마포 bbox (가장 신뢰) + // 2) mapoPolyPixelsRef (zoom/pan 시 갱신되는 최신 polygon 픽셀) bbox + // 3) storeNodes bbox — polygon 미로드 fallback + // 4) random node + ±150 offset — bbox 둘 다 없을 때 + if (wanderActiveRef.current) { + let wx: number | null = null; + let wy: number | null = null; + // 1) Kakao projection — 마포 bbox lat 37.535~37.585, lon 126.880~126.965. + try { + const kakao = (window as any).kakao; + const map = mapInstanceRef.current; + const proj = map?.getProjection?.(); + if (kakao?.maps?.LatLng && proj) { + const latMin = 37.535, + latMax = 37.585, + lonMin = 126.88, + lonMax = 126.965; + const latR = randomBetween(latMin, latMax); + const lonR = randomBetween(lonMin, lonMax); + const px = proj.containerPointFromCoords(new kakao.maps.LatLng(latR, lonR)); + if (Number.isFinite(px.x) && Number.isFinite(px.y)) { + wx = px.x; + wy = px.y; + } + } + } catch { + /* noop — fallback */ + } + const polyRings = mapoPolyPixelsRef.current; + if (wx === null && polyRings.length > 0) { + let minX = Infinity, + maxX = -Infinity, + minY = Infinity, + maxY = -Infinity; + for (const ring of polyRings) { + for (const pt of ring) { + if (pt.x < minX) minX = pt.x; + if (pt.x > maxX) maxX = pt.x; + if (pt.y < minY) minY = pt.y; + if (pt.y > maxY) maxY = pt.y; + } + } + if (Number.isFinite(minX) && maxX > minX) { + // polygon 안 검사 (ray casting). + const inside = (px: number, py: number): boolean => { + for (const ring of polyRings) { + let isIn = 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 > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi; + if (intersect) isIn = !isIn; + } + if (isIn) return true; + } + return false; + }; + for (let attempt = 0; attempt < 30; attempt++) { + const cx = randomBetween(minX, maxX); + const cy = randomBetween(minY, maxY); + if (inside(cx, cy)) { + wx = cx; + wy = cy; + break; + } + } + if (wx === null) { + // bbox 안에서 polygon 못 찾으면 그냥 bbox random. + wx = randomBetween(minX, maxX); + wy = randomBetween(minY, maxY); + } + } + } + if (wx === null && nodes.length > 0) { + // Fallback A: storeNodes bbox (마포 전역 spots-all 이면 마포 bbox 근사). + let nMinX = Infinity, + nMaxX = -Infinity, + nMinY = Infinity, + nMaxY = -Infinity; + for (const n of nodes) { + if (n.x < nMinX) nMinX = n.x; + if (n.x > nMaxX) nMaxX = n.x; + if (n.y < nMinY) nMinY = n.y; + if (n.y > nMaxY) nMaxY = n.y; + } + if (Number.isFinite(nMinX) && nMaxX > nMinX) { + wx = randomBetween(nMinX, nMaxX); + wy = randomBetween(nMinY, nMaxY); + } + } + if (wx === null && nodes.length > 0) { + // Fallback B: random node + 큰 offset. + const rIdx = Math.floor(Math.random() * nodes.length); + const rn = nodes[rIdx]; + wx = rn.x + randomBetween(-150, 150); + wy = rn.y + randomBetween(-150, 150); + } + if (wx !== null && wy !== null) { + p.sourceIdx = p.targetIdx; + p.tx = wx; + p.ty = wy; + p.waypoints = []; + const sx = p.x; + const sy = p.y; + const segDx = p.tx - sx; + const segDy = p.ty - sy; + const segLen = Math.hypot(segDx, segDy) || 1; + const perpX = -segDy / segLen; + const perpY = segDx / segLen; + const offset = randomBetween(0.15, 0.35) * segLen * (Math.random() < 0.5 ? 1 : -1); + p.mx = (sx + p.tx) / 2 + perpX * offset; + p.my = (sy + p.ty) / 2 + perpY * offset; + p.progress = 0; + p.action = 'move'; + return; + } + } // 선호 스팟 순열에서 다음 목적지 (개인별 루틴) + 30% 확률로 랜덤 let nextIdx: number; if (Math.random() < 0.3) { @@ -2408,22 +2563,28 @@ export default function AbmPersonaMap({ p.y = it * it * sy + 2 * it * t * p.my + t * t * p.ty; p.action = 'move'; if (p.progress >= 1) { - p.waitTicks = Math.floor(randomBetween(60, 200) * p.dwellMultiplier); - const payAmt = randomBetween(3000, 15000); - p.spend += payAmt; - paymentEffectsRef.current.push({ - nodeIdx: p.targetIdx, - amount: Math.round(payAmt), - startTick: tickRef.current, - }); - paymentBouncesRef.current.push({ - nodeIdx: p.targetIdx, - startTick: tickRef.current, - }); - const stats = spotStatsRef.current[p.targetIdx]; - if (stats) { - stats.visits++; - stats.revenue += payAmt; + // wander mode (시뮬 진행 중) — dwell 짧게 (15~50 tick) + 결제/통계 skip. + // 진짜 visit 가 아닌 행동 가시화이므로 spotStats 오염 방지. + if (wanderActiveRef.current) { + p.waitTicks = Math.floor(randomBetween(15, 50)); + } else { + p.waitTicks = Math.floor(randomBetween(60, 200) * p.dwellMultiplier); + const payAmt = randomBetween(3000, 15000); + p.spend += payAmt; + paymentEffectsRef.current.push({ + nodeIdx: p.targetIdx, + amount: Math.round(payAmt), + startTick: tickRef.current, + }); + paymentBouncesRef.current.push({ + nodeIdx: p.targetIdx, + startTick: tickRef.current, + }); + const stats = spotStatsRef.current[p.targetIdx]; + if (stats) { + stats.visits++; + stats.revenue += payAmt; + } } } } @@ -2831,153 +2992,156 @@ export default function AbmPersonaMap({ )} - {/* 결과 카드 — 우하 (col 2, row 2). 원본 좌측 패널 4 metric 카드 디자인 그대로. */} -
+ {/* 우하 (col 2, row 2). AbmQueuePanel 항상 표시 (사용자 피드백 2026-05-05) — + abmResult 있을 때도 queue 가 보이도록. metric 4-card 는 좌측 결과 패널에 있음. */} +
+ {/* 결과 시 metric 4-card 는 그대로 유지하면서 우측 1/3 에 queue panel 추가. */} {abmResult ? ( -
- {[ - { - label: '일 방문', - value: abmResult.daily_visits_mean?.toLocaleString() ?? '-', - suffix: '회', - sub: - abmResult.daily_visits_std > 0 - ? `σ ${abmResult.daily_visits_std}` - : '시뮬 평균', - color: '#002CD1', - glow: 'rgba(0,44,209,0.18)', - icon: ( - - ), - }, - { - label: 'Total Earning', - value: abmResult.monthly_revenue_estimate - ? Math.round(abmResult.monthly_revenue_estimate / 10000).toLocaleString() - : '-', - suffix: '만 ₩', - sub: '월 매출 (일×25)', - color: '#FF7940', - glow: 'rgba(251,191,36,0.18)', - icon: ( - - ₩ - - ), - }, - { - label: 'Peak Hours', - value: - abmResult.peak_hours && abmResult.peak_hours.length > 0 - ? abmResult.peak_hours - .slice(0, 3) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((h: any) => `${h}`) - .join(' · ') - : '-', - suffix: '시', - sub: '상위 3 시간대', - color: '#00E0D1', - glow: 'rgba(34,211,238,0.18)', - icon: ( - <> - +
+
+ {[ + { + label: '일 방문', + value: abmResult.daily_visits_mean?.toLocaleString() ?? '-', + suffix: '회', + sub: + abmResult.daily_visits_std > 0 + ? `σ ${abmResult.daily_visits_std}` + : '시뮬 평균', + color: '#002CD1', + glow: 'rgba(0,44,209,0.18)', + icon: ( - - ), - }, - { - label: 'Active Agents', - value: abmResult.n_personas ? abmResult.n_personas.toLocaleString() : '-', - suffix: '명', - sub: 'Tier S 50 · LLM thought', - color: '#002CD1', - glow: 'rgba(0,44,209,0.18)', - icon: ( - <> - - - - - ), - }, - ].map((m) => ( -
- {/* 상단 글로스 highlight */} -
- {/* 헤더 — 색 박스 아이콘 + 라벨 */} -
-
- + ₩ + + ), + }, + { + label: 'Peak Hours', + value: + abmResult.peak_hours && abmResult.peak_hours.length > 0 + ? abmResult.peak_hours + .slice(0, 3) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((h: any) => `${h}`) + .join(' · ') + : '-', + suffix: '시', + sub: '상위 3 시간대', + color: '#00E0D1', + glow: 'rgba(34,211,238,0.18)', + icon: ( + <> + + + + ), + }, + { + label: 'Active Agents', + value: abmResult.n_personas ? abmResult.n_personas.toLocaleString() : '-', + suffix: '명', + sub: 'Tier S 50 · LLM thought', + color: '#002CD1', + glow: 'rgba(0,44,209,0.18)', + icon: ( + <> + + + + + ), + }, + ].map((m) => ( +
+ {/* 상단 글로스 highlight */} +
+ {/* 헤더 — 색 박스 아이콘 + 라벨 */} +
+
+ +
+ + {m.label} + +
+ {/* 숫자 — 컨테이너 폭에 맞게 적응 */} +
+ + {m.value} + + + {m.suffix} + +
+
+ {/* 서브라인 */} +
+
+ {m.sub}
- - {m.label} - -
- {/* 숫자 — 컨테이너 폭에 맞게 적응 */} -
- - {m.value} - - - {m.suffix} - -
-
- {/* 서브라인 */} -
-
- {m.sub}
-
- ))} + ))} +
+
) : ( -

- 공실 스팟 클릭 후 시뮬 실행 → 결과 표시 -

+ )}
{/* 좌측 결과 패널 — col 1, row span 2 (전체 높이) */} @@ -3001,6 +3165,7 @@ export default function AbmPersonaMap({ LIVE
+ {/* Tier S Agents — 50명 agent 이름 리스트. 행 클릭 → 지도 focus + 리스트 위 overlay 로 plan/thought 카드 표시. 이전: inline accordion expand (목록 늘어남). 변경: absolute overlay. */} @@ -3390,197 +3555,224 @@ export default function AbmPersonaMap({
)}
- ) : abmLoading ? ( - + ) : isRunningCurrentSpot ? ( +
+ + +
) : abmError ? (

{abmError}

) : ( - /* 시나리오 선택 UI — 처음 시뮬 form (App.tsx 운영 조건 박스) 와 동일한 - box-glass + SectionLabel + FormField 패턴. spot 클릭 후 시뮬 실행 전 노출. */ -
- - {/* 날씨 */} - -
- {([null, '맑음', '흐림', '비', '눈'] as const).map((w) => ( - - ))} -
-
- - {/* 요일 — 평일/주말/공휴일 분기 backend is_weekend / is_holiday 자동 결정 */} - +
-
- {[ - { label: '오늘', weekend_force: false, date: null }, - { label: '평일', weekend_force: false, date: '2026-04-21' }, - { label: '주말', weekend_force: true, date: null }, - { label: '공휴일', weekend_force: false, date: '2026-05-05' }, - ].map((opt) => ( - - ))} -
- + + + + 시나리오 설정 + + + + {/* 날씨 */} + +
+ {([null, '맑음', '흐림', '비', '눈'] as const).map((w) => ( + + ))} +
+
- {/* 임대료 충격 */} - -
- {[0, 0.15, 0.3, 0.5].map((pct) => ( - - ))} -
-
+ : 'border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/40' + }`} + > + {opt.label} + + ))} +
+ - {/* 실행 버튼 */} + {/* 임대료 충격 */} + +
+ {[0, 0.15, 0.3, 0.5].map((pct) => ( + + ))} +
+
+
+ {/* 실행 버튼 — details 밖 grandparent flex 에 mt-auto 두어 + 좌측 패널 (~820px col) 하단까지 push. */} + + + )} + + {/* 2. 대기 중 */} + {pendingCount > 0 && ( +
+ + 대기 중 + +
    + {pendingQueue.map((p) => { + const summary = summarizeScenario(p.payload); + return ( +
  • + +
    + + {spotLabel(p.focusSpot)} + + {summary && ( + + {summary} + + )} +
    + +
  • + ); + })} +
+
+ )} + + {/* 3. 완료 (최근 5) */} + {doneCount > 0 && ( +
+ + 완료 + +
    + {recentDone.map((h) => { + const summary = summarizeScenario(h.params); + return ( +
  • + +
  • + ); + })} +
+
+ )} + + ); +} + +export default AbmQueuePanel; diff --git a/frontend/src/components/abm/PersonaPreviewStream.tsx b/frontend/src/components/abm/PersonaPreviewStream.tsx new file mode 100644 index 00000000..cf2021fa --- /dev/null +++ b/frontend/src/components/abm/PersonaPreviewStream.tsx @@ -0,0 +1,137 @@ +/** + * PersonaPreviewStream — ABM 시뮬 진행 중 페르소나 추론 "샘플" 카드 rotate. + * + * 진짜 partial result streaming 은 backend 인프라 미존재 (vacancy-evaluation + * /{job_id}/chats 등은 done 후만 응답). 사용자가 ~3분 대기 동안 "AI 가 뭔가 + * 하고 있다" 감각 부여를 위해 카테고리별 사전 정의 dialog pool 을 6초 간격 + * fade-in 으로 누적 표시. 카드 하단 "샘플" 명시 필수. + * + * AbmPersonaMap 우측 패널에서 abmLoading=true 분기일 때 AbmProgressPanel 아래 + * 마운트. + */ + +import { useEffect, useState } from 'react'; +import { Sparkles } from 'lucide-react'; +import { useAbmStore } from '../../stores/abmStore'; +import { pickDialogPool, type PersonaDialog } from '../../data/personaSampleDialogs'; + +interface Props { + businessType?: string | null; + spotLabel?: string | null; +} + +interface QueueEntry { + id: number; + dialog: PersonaDialog; + addedAt: number; +} + +const ROTATE_INTERVAL_MS = 6_000; +const MAX_VISIBLE = 4; + +const TIER_BADGE: Record = { + S: { color: 'bg-amber-500/15 text-amber-300 border-amber-500/40', label: 'TIER S' }, + A: { color: 'bg-cyan-500/15 text-cyan-300 border-cyan-500/40', label: 'TIER A' }, + B: { color: 'bg-stone-500/15 text-stone-300 border-stone-500/40', label: 'TIER B' }, +}; + +function relativeAge(ms: number): string { + const sec = Math.floor(ms / 1000); + if (sec < 5) return '방금'; + if (sec < 60) return `${sec}s 전`; + const m = Math.floor(sec / 60); + return `${m}m 전`; +} + +export function PersonaPreviewStream({ businessType, spotLabel }: Props) { + const stage = useAbmStore((s) => s.stage); + + const pool = pickDialogPool(businessType); + const [queue, setQueue] = useState([]); + const [tickNow, setTickNow] = useState(Date.now()); + + // 6초 간격으로 dialog 1개 push (cumulative, 최근 MAX_VISIBLE 만 유지). + useEffect(() => { + let counter = 0; + let poolIdx = 0; + const shuffled = [...pool].sort(() => Math.random() - 0.5); + + const push = () => { + const dialog = shuffled[poolIdx % shuffled.length]; + poolIdx += 1; + counter += 1; + setQueue((prev) => { + const next = [{ id: counter, dialog, addedAt: Date.now() }, ...prev]; + return next.slice(0, MAX_VISIBLE); + }); + }; + + // 마운트 직후 즉시 1개 + 그 후 interval. + push(); + const tid = setInterval(push, ROTATE_INTERVAL_MS); + return () => clearInterval(tid); + }, [pool]); + + // 1초 tick — relativeAge 갱신용. + useEffect(() => { + const t = setInterval(() => setTickNow(Date.now()), 1000); + return () => clearInterval(t); + }, []); + + return ( +
+
+
+ + + AI Persona Preview + +
+ + {stage || 'WARMING UP'} + +
+ +
+ {queue.length === 0 && ( +

페르소나 로딩 중…

+ )} + {queue.map((entry, idx) => { + const tier = TIER_BADGE[entry.dialog.tier]; + const age = relativeAge(tickNow - entry.addedAt); + // 위쪽 (최신) 일수록 진하게, 아래쪽은 흐리게. + const fadeStyle = { + opacity: 1 - idx * 0.18, + }; + return ( +
+
+ + {tier.label} + + + {entry.dialog.persona} + + {age} +
+

+ "{entry.dialog.text}" +

+
+ ); + })} +
+ +

+ ⓘ 샘플 페르소나 — 실제 시뮬 결과는 완료 후 표시됩니다 + {spotLabel ? ` · ${spotLabel}` : ''} +

+
+ ); +} + +export default PersonaPreviewStream; diff --git a/frontend/src/components/abm/SpotInfoCard.tsx b/frontend/src/components/abm/SpotInfoCard.tsx new file mode 100644 index 00000000..3e8b34ec --- /dev/null +++ b/frontend/src/components/abm/SpotInfoCard.tsx @@ -0,0 +1,613 @@ +/** + * SpotInfoCard — ABM 시뮬 실행 전 (시나리오 form 상단) 선택 spot 정보 카드. + * + * 사용자가 공실 spot 클릭 후 ABM 모드 진입 → 시뮬 실행 누르기 전까지의 대기 시간에 + * spot 컨텍스트 (주소·지하철·매물 수·동 통계·경쟁업체·brand fit) 표시. + * + * 모든 데이터 기존 props / 기존 endpoint 에서 조달 (backend 신규 endpoint 0개): + * - 주소: Kakao services.Geocoder.coord2Address (frontend SDK) + * - 지하철: storeNodes 의 tier='S' 항목 중 focusSpot 기준 haversine 최단 + * - 매물 수: vacancySpots 매칭 + * - 동 통계: simResult 에서 추출 후 prop 으로 전달 + * - 경쟁업체: competitors prop 에서 500m 이내 필터 + * - brand fit: competitors 카테고리 분포 + businessType heuristic + */ + +import { useEffect, useState } from 'react'; +import { MapPin, TrainFront, Store, Users, AlertTriangle } from 'lucide-react'; +import { useKakaoMap } from '../kakao/useKakaoMap'; + +interface FocusSpot { + lat: number; + lon: number; + label?: string; +} + +interface StoreNodeLike { + id: string; + label: string; + lat: number; + lng: number; + tier: string; +} + +interface VacancySpotLike { + id: number | string; + lat: number; + lon: number; + listing_count?: number; +} + +interface CompetitorLike { + id?: string; + name?: string; + place_name?: string; + brand_name?: string; + lat: number; + lng?: number; + lon?: number; + distance_m?: number; + is_franchise?: boolean; + category?: string; +} + +export interface SpotDongStats { + resident_pop?: number | null; + floating_pop?: number | null; + closure_rate?: number | null; +} + +interface Props { + focusSpot: FocusSpot; + /** 부모가 fetch 한 지하철 포함 storeNodes — 없으면 dongName 으로 직접 fetch. */ + storeNodes?: StoreNodeLike[]; + /** focusSpot 의 동 이름 — storeNodes 없을 때 /api/mapo/spots/{dong} fetch 용. */ + dongName?: string | null; + vacancySpots?: VacancySpotLike[]; + competitors?: CompetitorLike[]; + businessType?: string | null; + dongStats?: SpotDongStats | null; + /** vertical(default) = 우측 패널 stack / horizontal = 지도 아래 가로 4분할. */ + layout?: 'vertical' | 'horizontal'; +} + +interface KakaoCoord2AddressResult { + road_address?: { address_name?: string } | null; + address?: { address_name?: string } | null; +} + +interface KakaoGeocoder { + coord2Address: ( + lng: number, + lat: number, + cb: (results: KakaoCoord2AddressResult[], status: 'OK' | 'ZERO_RESULT' | 'ERROR') => void, + ) => void; +} + +interface KakaoServices { + Geocoder: new () => KakaoGeocoder; +} + +interface KakaoGlobal { + maps?: { services?: KakaoServices }; +} + +const NEARBY_RADIUS_M = 500; + +/** Haversine 거리 (m). */ +function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6_371_000; + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(a)); +} + +/** competitors 카테고리 분포 분석 → brand fit 한 줄 요약. */ +function computeBrandFit( + businessType: string | null | undefined, + nearbyCompetitors: Array, +): { tone: 'good' | 'warn' | 'danger'; text: string } | null { + if (!businessType || nearbyCompetitors.length === 0) return null; + const lowerType = businessType.toLowerCase(); + // 카테고리 토큰 매칭 — businessType 이 'cafe' 면 category 에 'cafe'/'카페' 포함 + const sameCount = nearbyCompetitors.filter((c) => { + const cat = (c.category ?? '').toLowerCase(); + if (!cat) return false; + return cat.includes(lowerType) || lowerType.includes(cat); + }).length; + const ratio = sameCount / nearbyCompetitors.length; + if (ratio >= 0.3) { + return { + tone: 'danger', + text: `포화 우려 — 동종 ${sameCount}/${nearbyCompetitors.length}개 (${Math.round(ratio * 100)}%). 차별화 필수.`, + }; + } + if (ratio >= 0.1) { + return { + tone: 'warn', + text: `균형 — 동종 ${sameCount}/${nearbyCompetitors.length}개 (${Math.round(ratio * 100)}%). 표준 진입 가능.`, + }; + } + return { + tone: 'good', + text: `신규 진입 기회 — 동종 ${sameCount}/${nearbyCompetitors.length}개 (${Math.round(ratio * 100)}%). 대체 수요 검증 필요.`, + }; +} + +export function SpotInfoCard({ + focusSpot, + storeNodes, + dongName, + vacancySpots = [], + competitors = [], + businessType, + dongStats, + layout = 'vertical', +}: Props) { + const { ready: kakaoReady } = useKakaoMap(); + const [address, setAddress] = useState(null); + const [addressLoading, setAddressLoading] = useState(false); + const [fetchedNodes, setFetchedNodes] = useState(null); + + // 지하철 row 용 storeNodes — 부모 prop 우선, 없으면 dongName 으로 fetch. + // 부모가 competitors 모드일 때 storeNodes 가 경쟁업체 좌표로 덮어써져 + // tier='S' 가 비므로 보조 fetch 필요. + useEffect(() => { + if (storeNodes && storeNodes.some((n) => n.tier === 'S')) { + setFetchedNodes(null); + return; + } + if (!dongName) return; + let cancelled = false; + fetch(`/api/mapo/spots/${encodeURIComponent(dongName)}?limit=4`) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`spots ${r.status}`)))) + .then((data: { spots?: StoreNodeLike[] }) => { + if (cancelled) return; + if (Array.isArray(data.spots)) setFetchedNodes(data.spots); + }) + .catch(() => { + /* noop — 지하철 row 만 숨김 */ + }); + return () => { + cancelled = true; + }; + }, [dongName, storeNodes]); + + const effectiveNodes: StoreNodeLike[] = + storeNodes && storeNodes.some((n) => n.tier === 'S') ? storeNodes : (fetchedNodes ?? []); + + // Kakao reverse geocode — focusSpot 변경 시 재요청. + useEffect(() => { + if (!kakaoReady) return; + setAddress(null); + setAddressLoading(true); + const w = window as unknown as { kakao?: KakaoGlobal }; + const services = w.kakao?.maps?.services; + if (!services?.Geocoder) { + setAddressLoading(false); + return; + } + const geocoder = new services.Geocoder(); + geocoder.coord2Address(focusSpot.lon, focusSpot.lat, (results, status) => { + setAddressLoading(false); + if (status !== 'OK' || !results.length) return; + const first = results[0]; + const addr = first.road_address?.address_name || first.address?.address_name; + if (addr) setAddress(addr); + }); + }, [kakaoReady, focusSpot.lat, focusSpot.lon]); + + // 가장 가까운 지하철 — effectiveNodes 중 tier='S' 만. + const subwayRow = (() => { + const subways = effectiveNodes.filter((n) => n.tier === 'S'); + if (subways.length === 0) return null; + let best = subways[0]; + let bestDist = haversineMeters(focusSpot.lat, focusSpot.lon, best.lat, best.lng); + for (const n of subways.slice(1)) { + const d = haversineMeters(focusSpot.lat, focusSpot.lon, n.lat, n.lng); + if (d < bestDist) { + bestDist = d; + best = n; + } + } + return { label: best.label, distanceM: Math.round(bestDist) }; + })(); + + // listing_count 매칭 — focusSpot 좌표 ±0.0001 (~10m). + const listingCount = (() => { + const match = vacancySpots.find( + (s) => Math.abs(s.lat - focusSpot.lat) < 1e-4 && Math.abs(s.lon - focusSpot.lon) < 1e-4, + ); + return match?.listing_count ?? null; + })(); + + // 주변 경쟁업체 — 500m 이내 distance 오름차순 top 5. + const nearbyCompetitors = competitors + .map((c) => { + const lng = c.lng ?? c.lon; + if (typeof lng !== 'number') return null; + const d = haversineMeters(focusSpot.lat, focusSpot.lon, c.lat, lng); + return { ...c, distance_m: d }; + }) + .filter( + (c): c is CompetitorLike & { distance_m: number } => + c !== null && c.distance_m <= NEARBY_RADIUS_M, + ) + .sort((a, b) => a.distance_m - b.distance_m); + + const top5Competitors = nearbyCompetitors.slice(0, 5); + const brandFit = computeBrandFit(businessType, nearbyCompetitors); + + // 동 통계 표시 여부 — 적어도 1개 값 존재해야 섹션 노출. + const hasDongStats = + dongStats && + (dongStats.resident_pop != null || + dongStats.floating_pop != null || + dongStats.closure_rate != null); + + if (layout === 'horizontal') { + return ( +
+ {/* Col 1: 헤더 + 주소 + 지하철 */} +
+
+
+ + 선택 SPOT + +

+ {focusSpot.label || '공실 후보'} +

+
+ +
+
+ + 주소 + + + {address ?? + (addressLoading + ? '주소 조회 중…' + : `${focusSpot.lat.toFixed(5)}, ${focusSpot.lon.toFixed(5)}`)} + +
+ {subwayRow && ( +
+ +
+ + 지하철 + + + {subwayRow.label}{' '} + + · {subwayRow.distanceM}m + + +
+
+ )} +
+ + {/* Col 2: 동 통계 + listing_count */} +
+
+ + + 동 통계 + +
+ {hasDongStats ? ( +
+ {dongStats?.resident_pop != null && ( +
+ 주거 + + {dongStats.resident_pop.toLocaleString()} + +
+ )} + {dongStats?.floating_pop != null && ( +
+ 유동 + + {dongStats.floating_pop.toLocaleString()} + +
+ )} + {dongStats?.closure_rate != null && ( +
+ 폐업률 + + {(dongStats.closure_rate * 100).toFixed(1)}% + +
+ )} +
+ ) : ( +

통계 미제공

+ )} + {listingCount != null && listingCount > 0 && ( +
+ + + 매물 {listingCount} + 건 + +
+ )} +
+ + {/* Col 3: 경쟁업체 top 5 */} +
+
+ + 500m 경쟁/자사 + + + {nearbyCompetitors.length} + +
+ {top5Competitors.length === 0 ? ( +

없음

+ ) : ( +
    + {top5Competitors.map((c, i) => { + const name = c.place_name || c.name || c.brand_name || '경쟁업체'; + const isOwn = c.is_franchise && c.category === 'own_brand'; + return ( +
  • + + {name} + {isOwn && ( + + [자] + + )} + + + {Math.round(c.distance_m)}m + +
  • + ); + })} +
+ )} +
+ + {/* Col 4: Brand fit */} +
+
+ + + Brand Fit + +
+

+ {brandFit?.text ?? '업종/경쟁업체 정보 부족 — fit 계산 불가'} +

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + 선택 SPOT + +

+ {focusSpot.label || '공실 후보'} +

+
+
+ +
+
+ + {/* 주소 */} +
+ +
+ + 주소 + + + {address ?? + (addressLoading + ? '주소 조회 중…' + : `${focusSpot.lat.toFixed(5)}, ${focusSpot.lon.toFixed(5)}`)} + +
+
+ + {/* 가장 가까운 지하철 */} + {subwayRow && ( +
+ +
+ + 가장 가까운 지하철 + + + {subwayRow.label}{' '} + + · {subwayRow.distanceM}m + + +
+
+ )} + + {/* listing_count */} + {listingCount != null && listingCount > 0 && ( +
+ +
+ + 인근 매물 + + + {listingCount}건 + 조회됨 + +
+
+ )} + + {/* 동 통계 */} + {hasDongStats && ( +
+
+ + + 동 통계 + +
+
+ {dongStats?.resident_pop != null && ( +
+ 주거 인구 + + {dongStats.resident_pop.toLocaleString()}명 + +
+ )} + {dongStats?.floating_pop != null && ( +
+ 유동 인구 + + {dongStats.floating_pop.toLocaleString()}명 + +
+ )} + {dongStats?.closure_rate != null && ( +
+ 최근 폐업률 + + {(dongStats.closure_rate * 100).toFixed(1)}% + +
+ )} +
+
+ )} + + {/* 주변 경쟁업체 */} +
+
+ + 500m 내 경쟁/자사 + + + {nearbyCompetitors.length}개 + +
+ {top5Competitors.length === 0 ? ( +

+ 반경 500m 내 등록 경쟁업체 없음 +

+ ) : ( +
    + {top5Competitors.map((c, i) => { + const name = c.place_name || c.name || c.brand_name || '경쟁업체'; + const isOwn = c.is_franchise && c.category === 'own_brand'; + return ( +
  • +
    + + {name} + {isOwn && ( + + [자사] + + )} + + {c.category && !isOwn && ( + + {c.category} + + )} +
    + + {Math.round(c.distance_m)}m + +
  • + ); + })} +
+ )} +
+ + {/* Brand fit */} + {brandFit && ( +
+ + {brandFit.text} +
+ )} +
+ ); +} + +export default SpotInfoCard; diff --git a/frontend/src/components/ui/InfoTooltip.tsx b/frontend/src/components/ui/InfoTooltip.tsx index bc306b04..17018c10 100644 --- a/frontend/src/components/ui/InfoTooltip.tsx +++ b/frontend/src/components/ui/InfoTooltip.tsx @@ -53,10 +53,10 @@ export function InfoTooltip({ text, size = 14, className = '' }: Props) { {open && ( {text} - + )} diff --git a/frontend/src/data/personaSampleDialogs.ts b/frontend/src/data/personaSampleDialogs.ts new file mode 100644 index 00000000..2d2d4a72 --- /dev/null +++ b/frontend/src/data/personaSampleDialogs.ts @@ -0,0 +1,262 @@ +/** + * personaSampleDialogs — ABM 대기화면 (PersonaPreviewStream) 용 샘플 페르소나 대화 pool. + * + * 진짜 시뮬 partial 결과 streaming 은 backend 인프라 미존재 → 카테고리별 사전 정의 + * 대화 rotate 로 "AI 가 페르소나 추론 중" 분위기 연출. 카드 하단에 "샘플" 명시 필수. + */ + +export type PersonaTier = 'S' | 'A' | 'B'; + +export interface PersonaDialog { + persona: string; // "30대 직장인 박씨" + tier: PersonaTier; + text: string; // 1~2 문장 한국어 - 해당 spot 카테고리 방문 의향/이유 +} + +export interface CategoryPool { + category: string; + dialogs: PersonaDialog[]; +} + +const CAFE_DIALOGS: PersonaDialog[] = [ + { + persona: '20대 학생 김씨', + tier: 'A', + text: '시험기간. 콘센트 + wifi 빠르면 무조건 옴. 가격은 5천원 안쪽 선호.', + }, + { + persona: '30대 직장인 박씨', + tier: 'S', + text: '점심 후 동료랑 잠깐. 회의 분위기 가능한 좌석 있으면 주 3회 방문 가능.', + }, + { + persona: '40대 주부 이씨', + tier: 'B', + text: '아이 픽업 전 1시간. 편안한 의자 + 디저트 종류 다양하면 매주 옴.', + }, + { + persona: '20대 프리랜서 최씨', + tier: 'S', + text: '하루 종일 작업할 자리. 좌석 회전율 압박 느끼면 안 옴.', + }, + { + persona: '50대 자영업자 정씨', + tier: 'B', + text: '거래처 미팅 장소. 조용하고 주차 가까우면 단골 가능.', + }, + { + persona: '20대 직장인 윤씨', + tier: 'A', + text: '출근 전 7시 30분 테이크아웃. 모바일 주문 빠르면 매일 옴.', + }, + { + persona: '30대 부부 한씨', + tier: 'B', + text: '주말 오후 산책 후. 외부 좌석 있으면 강아지 동반 방문.', + }, + { + persona: '60대 노년 강씨', + tier: 'B', + text: '오전 9시쯤 친구들과. 메뉴 사진 크고 대형 글씨면 편함.', + }, +]; + +const RESTAURANT_DIALOGS: PersonaDialog[] = [ + { + persona: '30대 직장인 박씨', + tier: 'S', + text: '점심 한정 1만원 이하 정식 있으면 주 4회 방문. 대기 5분 이상은 패스.', + }, + { + persona: '20대 커플 김씨', + tier: 'A', + text: '데이트 코스. 인스타 인증샷 가능한 인테리어 + 객단가 2~3만원선 선호.', + }, + { + persona: '40대 가족 이씨', + tier: 'B', + text: '주말 점심 4인. 아이 메뉴 + 룸 좌석 있으면 한 달 한 번은 옴.', + }, + { + persona: '30대 회사원 정씨', + tier: 'A', + text: '회식 가능 인원 10~15명. 단체 코스 + 주류 라인업 풍부하면 분기 1회 잡음.', + }, + { + persona: '50대 부부 윤씨', + tier: 'B', + text: '저녁 외식. 시끄럽지 않고 음식 짜지 않으면 단골 가능.', + }, + { + persona: '20대 학생 최씨', + tier: 'B', + text: '친구 생일. 5인 메뉴 + 케이크 반입 OK 면 예약함.', + }, + { + persona: '30대 직장인 한씨', + tier: 'A', + text: '혼밥 자리. 카운터석 있고 1인 메뉴 명확하면 주 2회 옴.', + }, + { + persona: '40대 사업자 강씨', + tier: 'S', + text: '거래처 접대. 위치 알기 쉽고 발렛 가능 + 코스요리 5만원 이상이면 선호.', + }, +]; + +const PUB_DIALOGS: PersonaDialog[] = [ + { + persona: '30대 직장인 박씨', + tier: 'S', + text: '회식 2차. 안주 푸짐 + 1만원대 술 라인업이면 매주 옴.', + }, + { + persona: '20대 친구모임 김씨', + tier: 'A', + text: '6명 이상 단체석 필수. 새벽까지 영업하면 주 1회 단골.', + }, + { + persona: '40대 사장님 이씨', + tier: 'B', + text: '직원 회식. 룸 + 주류 다양 + 결제 법인카드 OK 면 분기 1회.', + }, + { + persona: '20대 데이트 정씨', + tier: 'A', + text: '와인 위주 분위기 좋은 곳. 시끄럽지 않고 안주 1.5~2만원이면 매월 옴.', + }, + { + persona: '30대 동호회 윤씨', + tier: 'B', + text: '월례 모임 8명. 예약 가능 + 1인 2만원선 안주 코스 있으면 정착.', + }, + { + persona: '20대 직장인 최씨', + tier: 'S', + text: '퇴근 후 혼술. 카운터석 + 5천원 이하 단품 안주 있으면 주 2~3회 옴.', + }, + { + persona: '40대 친구모임 한씨', + tier: 'B', + text: '동창 모임. 조용하고 안주 정갈하면 분기 1회 잡힘.', + }, + { + persona: '30대 커플 강씨', + tier: 'A', + text: '데이트 마무리 1잔. 좌석 좁아도 분위기만 좋으면 옴.', + }, +]; + +const CONVENIENCE_DIALOGS: PersonaDialog[] = [ + { + persona: '20대 1인가구 김씨', + tier: 'A', + text: '도시락 + 음료 매일 1회. 신상 도시락 자주 들어오면 충성도 높음.', + }, + { + persona: '30대 직장인 박씨', + tier: 'S', + text: '출근길 커피 + 아침. 7시 정각 오픈 + 결제 빠르면 주 5일 옴.', + }, + { + persona: '40대 주부 이씨', + tier: 'B', + text: '저녁 반찬 + 우유. 신선식품 코너 있으면 주 3회 옴.', + }, + { + persona: '20대 학생 정씨', + tier: 'B', + text: '시험기간 야식. 24시간 + 라면/치킨 핫박스 있으면 매일.', + }, + { + persona: '60대 노년 윤씨', + tier: 'B', + text: '담배 + 막걸리. 친절하고 거스름돈 정확하면 단골.', + }, + { + persona: '30대 1인가구 최씨', + tier: 'A', + text: '택배 보관 + 야식. 보관함 여유 있고 영업시간 길면 매주 4회.', + }, + { + persona: '20대 커플 한씨', + tier: 'B', + text: '데이트 후 음료. 신상 디저트 자주 입고되면 주 1~2회.', + }, + { + persona: '40대 직장인 강씨', + tier: 'A', + text: '점심 샐러드 + 단백질음료. 헬스 타깃 상품 다양하면 주 4회.', + }, +]; + +const DEFAULT_DIALOGS: PersonaDialog[] = [ + { + persona: '30대 직장인 박씨', + tier: 'S', + text: '동선상 들리기 좋은 위치. 가격 합리적이면 주 1~2회 가능.', + }, + { + persona: '20대 학생 김씨', + tier: 'A', + text: '학교/회사 이동길. 빠르고 저렴하면 자주 옴.', + }, + { + persona: '40대 가족 이씨', + tier: 'B', + text: '주말 외출. 주차 + 가족친화 분위기면 월 1~2회 옴.', + }, + { + persona: '20대 1인가구 정씨', + tier: 'A', + text: '집 근처. 영업시간 길면 활용도 높음.', + }, + { + persona: '50대 자영업자 윤씨', + tier: 'B', + text: '거래처 미팅용. 주차 + 깔끔한 인테리어 필요.', + }, + { + persona: '30대 부부 최씨', + tier: 'B', + text: '주말 데이트 코스. 분위기 좋으면 매월 옴.', + }, + { + persona: '20대 직장인 한씨', + tier: 'A', + text: '퇴근길 동선. 결제 빠르면 단골 됨.', + }, + { + persona: '60대 노년 강씨', + tier: 'B', + text: '도보 거리. 친절하고 익숙한 메뉴면 자주 옴.', + }, +]; + +/** + * 카테고리별 pool. 키는 backend businessType 값과 한국어 표기 둘 다 매칭 가능하게. + */ +export const SAMPLE_DIALOG_POOLS: CategoryPool[] = [ + { category: 'cafe', dialogs: CAFE_DIALOGS }, + { category: '카페', dialogs: CAFE_DIALOGS }, + { category: 'restaurant', dialogs: RESTAURANT_DIALOGS }, + { category: '음식점', dialogs: RESTAURANT_DIALOGS }, + { category: 'pub', dialogs: PUB_DIALOGS }, + { category: '주점', dialogs: PUB_DIALOGS }, + { category: 'bar', dialogs: PUB_DIALOGS }, + { category: 'convenience', dialogs: CONVENIENCE_DIALOGS }, + { category: '편의점', dialogs: CONVENIENCE_DIALOGS }, + { category: 'default', dialogs: DEFAULT_DIALOGS }, +]; + +/** + * 카테고리 키워드 → dialog list. 매칭 안 되면 default pool 반환. + */ +export function pickDialogPool(businessType?: string | null): PersonaDialog[] { + if (!businessType) return DEFAULT_DIALOGS; + const lower = businessType.toLowerCase(); + for (const pool of SAMPLE_DIALOG_POOLS) { + if (lower.includes(pool.category.toLowerCase())) return pool.dialogs; + } + return DEFAULT_DIALOGS; +} diff --git a/frontend/src/stores/abmStore.ts b/frontend/src/stores/abmStore.ts index 5769b0e7..61b9bfcc 100644 --- a/frontend/src/stores/abmStore.ts +++ b/frontend/src/stores/abmStore.ts @@ -74,6 +74,14 @@ export interface AbmHistoryEntry { result: any; } +/** Queue 대기 entry — 사용자가 실행한 시뮬 중 active 가 점유 중일 때 대기열로. */ +export interface AbmPendingRun { + id: string; + payload: AbmRequestPayload; + focusSpot: AbmFocusSpot | null; + addedAt: number; +} + interface AbmState { status: AbmStatus; jobId: string | null; @@ -86,15 +94,31 @@ interface AbmState { stage: string; startedAt: number | null; history: AbmHistoryEntry[]; + /** 대기 중 시뮬 queue — active 가 진행 중일 때 add 된 시뮬은 여기 누적. FIFO. */ + pendingQueue: AbmPendingRun[]; + /** loadHistory 로 사용자가 view 만 하는 history result — active sim 영향 X. + * null 이면 기본 active result 표시. UI 는 displayResult ?? result 사용. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + displayResult: any | null; + /** displayResult 의 focusSpot — UI 가 그 spot 컨텍스트 표시 가능. */ + displayFocusSpot: AbmFocusSpot | null; _abortController: AbortController | null; _pollTimer: ReturnType | null; startAbm: (payload: AbmRequestPayload, focusSpot?: AbmFocusSpot | null) => Promise; + /** active 가 idle/done/error 면 즉시 startAbm, 아니면 pendingQueue 에 push. */ + enqueueAbm: (payload: AbmRequestPayload, focusSpot?: AbmFocusSpot | null) => string; + /** pendingQueue 에서 단건 제거. */ + cancelPending: (id: string) => void; + /** pendingQueue 통째로 비움. */ + clearPending: () => void; cancelAbm: () => void; dismissResult: () => void; setFocusSpot: (spot: AbmFocusSpot | null) => void; loadHistory: (id: string) => void; + /** displayResult / displayFocusSpot 초기화 — active sim view 로 복귀. */ + clearDisplayResult: () => void; clearHistory: () => void; /** persist 복원 후 재진입 시 polling 재개. App mount 에서 호출. */ resumePollingIfNeeded: () => void; @@ -103,20 +127,27 @@ interface AbmState { // 내부 액션 — 외부 호출 비권장 (test 용 export). _pollStatus: () => Promise; _fetchResult: () => Promise; + /** active 종료 후 pendingQueue 의 다음 항목 자동 시작. */ + _processNextInQueue: () => void; } const INITIAL_STATE: Omit< AbmState, | 'startAbm' + | 'enqueueAbm' + | 'cancelPending' + | 'clearPending' | 'cancelAbm' | 'dismissResult' | 'setFocusSpot' | 'loadHistory' + | 'clearDisplayResult' | 'clearHistory' | 'resumePollingIfNeeded' | 'reset' | '_pollStatus' | '_fetchResult' + | '_processNextInQueue' > = { status: 'idle', jobId: null, @@ -128,6 +159,9 @@ const INITIAL_STATE: Omit< stage: '', startedAt: null, history: [], + pendingQueue: [], + displayResult: null, + displayFocusSpot: null, _abortController: null, _pollTimer: null, }; @@ -230,6 +264,7 @@ export const useAbmStore = create()( : `ABM 시뮬 요청 실패: ${(err as Error).message || '네트워크 오류'}`, _abortController: null, }); + setTimeout(() => get()._processNextInQueue(), 0); return; } @@ -249,6 +284,7 @@ export const useAbmStore = create()( error: `ABM 응답 파싱 실패 (HTTP ${response.status})`, _abortController: null, }); + setTimeout(() => get()._processNextInQueue(), 0); return; } @@ -258,6 +294,7 @@ export const useAbmStore = create()( error: data?.message || `ABM 시뮬 실패 (HTTP ${response.status})`, _abortController: null, }); + setTimeout(() => get()._processNextInQueue(), 0); return; } @@ -269,6 +306,7 @@ export const useAbmStore = create()( error: 'ABM 모듈 준비 중입니다. (simulation 브랜치 머지 대기)', _abortController: null, }); + setTimeout(() => get()._processNextInQueue(), 0); return; } if (data.status === 'error') { @@ -277,6 +315,7 @@ export const useAbmStore = create()( error: data?.message || 'ABM 시뮬레이션 실행 중 오류가 발생했습니다.', _abortController: null, }); + setTimeout(() => get()._processNextInQueue(), 0); return; } // 동기 결과 — 바로 done. @@ -288,6 +327,7 @@ export const useAbmStore = create()( _abortController: null, _pollTimer: null, }); + setTimeout(() => get()._processNextInQueue(), 0); return; } @@ -340,6 +380,7 @@ export const useAbmStore = create()( _abortController: null, _pollTimer: null, }); + setTimeout(() => get()._processNextInQueue(), 0); return; } @@ -367,6 +408,7 @@ export const useAbmStore = create()( _abortController: null, _pollTimer: null, }); + setTimeout(() => get()._processNextInQueue(), 0); } // 'running' — 계속 poll. }, @@ -404,6 +446,7 @@ export const useAbmStore = create()( _abortController: null, _pollTimer: null, }); + setTimeout(() => get()._processNextInQueue(), 0); return; } @@ -418,6 +461,7 @@ export const useAbmStore = create()( _abortController: null, _pollTimer: null, }); + setTimeout(() => get()._processNextInQueue(), 0); return; } @@ -441,22 +485,75 @@ export const useAbmStore = create()( _abortController: null, _pollTimer: null, }); + setTimeout(() => get()._processNextInQueue(), 0); + }, + + enqueueAbm: (payload, focusSpot = null) => { + const id = `q_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + const { status, pendingQueue } = get(); + // active 가 idle/done/error 면 즉시 startAbm. running 이면 queue 에 추가. + if (status === 'idle' || status === 'done' || status === 'error') { + // pendingQueue 가 비어있으면 바로 시작. 아니면 queue 끝에 추가 후 process 가 pop. + if (pendingQueue.length === 0) { + void get().startAbm(payload, focusSpot ?? null); + return id; + } + // 이론상 도달 X (active 가 비어있는데 queue 가 비지 않음 → process 가 즉시 pop). + // 안전망: queue 에 추가 + process 트리거. + set({ + pendingQueue: [ + ...pendingQueue, + { id, payload, focusSpot: focusSpot ?? null, addedAt: Date.now() }, + ], + }); + get()._processNextInQueue(); + return id; + } + // running 중 — queue 에 누적. + set({ + pendingQueue: [ + ...pendingQueue, + { id, payload, focusSpot: focusSpot ?? null, addedAt: Date.now() }, + ], + }); + return id; + }, + + cancelPending: (id) => { + const { pendingQueue } = get(); + set({ pendingQueue: pendingQueue.filter((p) => p.id !== id) }); + }, + + clearPending: () => set({ pendingQueue: [] }), + + _processNextInQueue: () => { + const { status, pendingQueue } = get(); + if (status === 'running') return; + if (pendingQueue.length === 0) return; + const [next, ...rest] = pendingQueue; + set({ pendingQueue: rest }); + void get().startAbm(next.payload, next.focusSpot); }, cancelAbm: () => { - const { _abortController, _pollTimer, history } = get(); + const { _abortController, _pollTimer, history, pendingQueue } = get(); _abortController?.abort(); if (_pollTimer) clearInterval(_pollTimer); set({ ...INITIAL_STATE, history, // history 유지 + pendingQueue, // queue 유지 — 다음 항목 자동 시작. }); + // 마이크로태스크 후 다음 queue 시작 (state set 반영 후). + setTimeout(() => get()._processNextInQueue(), 0); }, dismissResult: () => { - const { status, history } = get(); + const { status, history, pendingQueue } = get(); if (status !== 'done' && status !== 'error') return; - set({ ...INITIAL_STATE, history }); + set({ ...INITIAL_STATE, history, pendingQueue }); + // queue auto-trigger 제거 (사용자 피드백 2026-05-05): dismiss 는 user 가 결과 패널 + // 닫는 동작 → 다음 큐 자동 시작하면 의도치 않은 spinner. 큐는 자연 완료/cancel 시만 진행. }, setFocusSpot: (spot) => set({ focusSpot: spot }), @@ -465,17 +562,17 @@ export const useAbmStore = create()( const { history } = get(); const entry = history.find((e) => e.id === id); if (!entry) return; + // displayResult 만 set — active sim (status/jobId/_abortController/_pollTimer) 절대 + // 건들지 않음. 사용자 피드백 (2026-05-05): loadHistory 가 status='done' 으로 + // 덮어써서 진행 중 시뮬이 사라짐 → 별도 displayResult 채널로 분리. set({ - status: 'done', - result: entry.result, - focusSpot: entry.focusSpot, - params: entry.params, - progress: 100, - stage: 'COMPLETE', - error: null, + displayResult: entry.result, + displayFocusSpot: entry.focusSpot, }); }, + clearDisplayResult: () => set({ displayResult: null, displayFocusSpot: null }), + clearHistory: () => set({ history: [] }), resumePollingIfNeeded: () => { @@ -525,6 +622,7 @@ export const useAbmStore = create()( error: null, progress: 0, stage: '', + pendingQueue: state.pendingQueue, }; } if (state.status === 'done') { @@ -538,6 +636,7 @@ export const useAbmStore = create()( error: null, progress: 100, stage: 'COMPLETE', + pendingQueue: state.pendingQueue, }; } return { @@ -550,6 +649,7 @@ export const useAbmStore = create()( error: null, progress: 0, stage: '', + pendingQueue: state.pendingQueue, }; }, },