diff --git a/.audit/reverted_patches/4d042d8e_corp_brand_resolver_base.patch b/.audit/reverted_patches/4d042d8e_corp_brand_resolver_base.patch new file mode 100644 index 00000000..6a3ac980 --- /dev/null +++ b/.audit/reverted_patches/4d042d8e_corp_brand_resolver_base.patch @@ -0,0 +1,478 @@ +From 4d042d8e20437c410dcfd7c7e806065db46f82c0 Mon Sep 17 00:00:00 2001 +From: bat1120 +Date: Tue, 5 May 2026 00:34:38 +0900 +Subject: [PATCH] =?UTF-8?q?A1:=20corp=20=EB=8B=A4=EC=97=85=EC=A2=85=20?= + =?UTF-8?q?=EC=9E=90=EB=8F=99=20brand=20resolve=20+=20=EC=9A=B4=EC=98=81?= + =?UTF-8?q?=20=EC=99=B8=20=EC=97=85=EC=A2=85=20=EC=B0=A8=EB=8B=A8?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +다업종 법인 (예: (주)더본코리아 = 빽다방·홍콩반점·빽보이피자·새마을식당...) 가입자가 +시뮬 시 회원가입 top_brand (빽다방) 와 다른 업종 (중식) 선택하면 같은 corp 의 +해당 업종 가장 큰 brand (홍콩반점0410) 자동 매핑. + +운영 외 업종 (예: 치킨) 선택 시 HTTPException(400) + 운영 가능 업종 list 응답. + +신규 파일: +- backend/src/services/corp_brand_resolver.py + * get_corp_industries(biz_number) — corp 운영 brand+업종 list + * resolve_brand_for_industry(biz_number, industry) — 자동 brand 매핑 + * corpNm 정규화 ('(주)', '㈜', '주식회사' 등 noise 제거 + ILIKE 매칭) + +수정: +- backend/src/schemas/simulation_input.py + * biz_number: str | None Optional 필드 추가 (corp 검증 트리거) +- backend/src/main.py + * _validate_and_resolve_brand(input_data) helper 추가 + * /analyze, /analyze/llm, /analyze/llm/async, /analyze/quick, + /predict, /predict/async, /simulate 7개 endpoint 시작에 호출 + +검증 (실제 DB): +- 더본코리아 가입자 → 커피=빽다방(1712) / 중식=홍콩반점0410(293) / + 피자=빽보이피자(243) / 한식=한신포차(129+11alt) / 서양식=롤링파스타 / + 주점=백스비어 → 모두 정확 매핑 +- 치킨/편의점 → INDUSTRY_NOT_OPERATED 거부 + operated_industries 8종 응답 + +엣지 케이스: +- biz_number 미입력 (개인사업자/비회원) → 검증 skip, 기존 동작 (input.brand_name 그대로) +- USER_NOT_FOUND / CORP_NOT_IN_FTC → 경고 로그 + skip (회원가입 안 됐거나 FTC 미등록 corp) +- 같은 업종 brand 여러개 (한식 12 brand) → frcsCnt 큰 것 1개 + alternatives list + +API 응답 (frontend 처리): +- 200 OK + brand_name override 적용 +- 400 INDUSTRY_NOT_OPERATED + {company_name, requested_industry, operated_industries, message} + +DB 변경: 0 (read-only resolver, 시뮬 input 단계 검증) + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + backend/src/main.py | 42 ++++++ + backend/src/schemas/simulation_input.py | 7 +- + backend/src/services/corp_brand_resolver.py | 150 ++++++++++++++++++++ + docs/retrospective/2026-05-04.md | 121 ++++++++++++++++ + 4 files changed, 317 insertions(+), 3 deletions(-) + create mode 100644 backend/src/services/corp_brand_resolver.py + +diff --git a/backend/src/main.py b/backend/src/main.py +index 53bd80a5..e0a06478 100644 +--- a/backend/src/main.py ++++ b/backend/src/main.py +@@ -77,6 +77,7 @@ from src.config.settings import settings + from src.schemas.simulation_input import SimulationInput + from src.services.auth import AuthService + from src.services.biz_mapper import BizMapper ++from src.services.corp_brand_resolver import resolve_brand_for_industry + + from models.explainability.shap_analysis import explain_tcn_prediction + from models.explainability.simulation import ( +@@ -225,6 +226,40 @@ def _pipeline_key(input_data: Any) -> str: + return f"{input_data.target_district}:{input_data.business_type}:{input_data.brand_name}:{rent}:{area}:{radius}:{pop_w}" + + ++def _validate_and_resolve_brand(input_data: SimulationInput) -> None: ++ """biz_number 입력 시 corp 검증 + 다업종 corp 의 brand auto-resolve. ++ ++ 동작 (input_data.biz_number 가 입력됐을 때만): ++ 1. business_type 이 사용자 corp 의 운영 업종인지 검증. ++ 2. 운영 외 업종 → HTTPException(400) + 운영 가능 업종 list 응답. ++ 3. 운영 내 업종 + corp 의 해당 업종 brand 가 다른 brand 면 brand_name override. ++ ++ biz_number 미입력 (개인사업자 / 비회원) → 검증 skip, 사용자 brand_name 그대로. ++ FTC 미등록 corp → 검증 skip + 경고 로그. ++ """ ++ if not input_data.biz_number: ++ return ++ ++ result = resolve_brand_for_industry(input_data.biz_number, input_data.business_type) ++ ++ if result.get("error") == "INDUSTRY_NOT_OPERATED": ++ raise HTTPException(status_code=400, detail=result) ++ ++ if result.get("error") in {"USER_NOT_FOUND", "CORP_NOT_IN_FTC", "INVALID_COMPANY_NAME"}: ++ # 비회원 / FTC 미등록 → 검증 skip, 사용자 brand_name 그대로 ++ logger.warning(f"[brand_resolver] {result['error']} biz={input_data.biz_number} — fallback to input.brand_name") ++ return ++ ++ # 성공: brand_name override (사용자가 다른 brand 입력했어도 corp 정합 brand 로 교체) ++ resolved_brand = result["brand_name"] ++ if input_data.brand_name != resolved_brand: ++ logger.info( ++ f"[brand_resolver] auto-resolve: input.brand_name='{input_data.brand_name}' → '{resolved_brand}' " ++ f"(corp={result['company_name']}, industry={input_data.business_type})" ++ ) ++ input_data.brand_name = resolved_brand ++ ++ + _BIZ_TYPE_NORMALIZE: dict[str, str] = { + "cafe": "카페", + "coffee": "카페", +@@ -935,6 +970,7 @@ async def analyze_location(input_data: SimulationInput, response: Response): + 그쪽으로 옮길 것. 이 endpoint는 기존 프론트/테스트 호환을 위해 유지하다가 + 충분히 검증되면 제거 예정. + """ ++ _validate_and_resolve_brand(input_data) + from src.config.constants import MAPO_DISTRICTS + + # IM3-259: deprecation 헤더 — 클라이언트가 /predict + /analyze/llm 으로 옮길 것을 알림 +@@ -990,6 +1026,7 @@ async def analyze_llm(input_data: SimulationInput): + + /predict와 독립 병렬 호출 가능. winner는 ranking 단계에서 자체 결정. + """ ++ _validate_and_resolve_brand(input_data) + from src.config.constants import MAPO_DISTRICTS + from src.schemas.simulation_output import AnalysisOutput + +@@ -1059,6 +1096,7 @@ _SLOW_GRAPH_NODE_TOTAL = 4 + @app.post("/analyze/llm/async") + async def analyze_llm_async(input_data: SimulationInput) -> dict[str, Any]: + """AI 분석 비동기 시작 — 즉시 job_id 반환. LangGraph 노드별 진행률 추적.""" ++ _validate_and_resolve_brand(input_data) + from src.config.constants import MAPO_DISTRICTS + from src.schemas.simulation_output import AnalysisOutput + from src.services.job_progress_store import ( +@@ -1179,6 +1217,7 @@ async def analyze_quick(input_data: SimulationInput): + + 응답: { district_rankings, winner_district, top_3_candidates } + """ ++ _validate_and_resolve_brand(input_data) + from src.agents.nodes.district_ranking import district_ranking_node + from src.agents.nodes.market_analyst import db_client + +@@ -1661,6 +1700,7 @@ async def predict_districts(input_data: SimulationInput): + - target_districts 전체에 대해 TCN/BEP/폐업률/폐업위험도/SHAP 병렬 실행 + - 응답: 동별 예측 결과 리스트 (프론트 멀티라인 차트용) + """ ++ _validate_and_resolve_brand(input_data) + from src.config.constants import MAPO_DISTRICTS + + target_districts = getattr(input_data, "target_districts", None) or [input_data.target_district] +@@ -1724,6 +1764,7 @@ async def predict_districts(input_data: SimulationInput): + @app.post("/predict/async") + async def predict_districts_async(input_data: SimulationInput) -> dict[str, Any]: + """ML 예측 비동기 시작 — 즉시 job_id 반환. 진행률은 status endpoint 폴링.""" ++ _validate_and_resolve_brand(input_data) + from src.config.constants import MAPO_DISTRICTS + from src.services.job_progress_store import ( + create_job, +@@ -1839,6 +1880,7 @@ async def predict_job_status(job_id: str) -> dict[str, Any]: + @app.post("/simulate", deprecated=True) + async def run_simulation(input_data: SimulationInput, response: Response): + """기본 시뮬레이션 엔드포인트""" ++ _validate_and_resolve_brand(input_data) + response.headers["Deprecation"] = "true" + response.headers["Link"] = '; rel="successor-version", ; rel="successor-version"' + +diff --git a/backend/src/schemas/simulation_input.py b/backend/src/schemas/simulation_input.py +index 3abae94b..b4f4b98e 100644 +--- a/backend/src/schemas/simulation_input.py ++++ b/backend/src/schemas/simulation_input.py +@@ -18,6 +18,9 @@ class SimulationInput(BaseModel): + + business_type: str = Field(..., description="업종 코드 (cafe, restaurant, convenience)") + brand_name: str = Field(..., description="브랜드명") ++ # 사용자 회원가입 사업자번호 — corp_brand_resolver 가 다업종 corp 의 적합 brand 자동 선택용. ++ # 미입력 시 회원 검증 skip + brand_name 그대로 사용 (개인사업자 / 비회원 호환). ++ biz_number: str | None = Field(default=None, description="사업자등록번호 (corp 검증 + auto-brand-resolve)") + target_district: str = Field(..., description="출점 후보 행정동 (대표 1개)") + target_districts: list[str] = Field( + default_factory=list, description="사용자가 선택한 후보 행정동 목록 (복수 선택 지원)" +@@ -43,9 +46,7 @@ class SimulationInput(BaseModel): + ) + + # 출점 후보지 좌표 — 학교환경위생정화구역(rule_school_zone) 거리 계산 트리거 +- lat: float | None = Field( +- default=None, description="출점 후보지 위도 (학교 거리 룰 트리거)" +- ) ++ lat: float | None = Field(default=None, description="출점 후보지 위도 (학교 거리 룰 트리거)") + lon: float | None = Field(default=None, description="출점 후보지 경도") + + # [customer_revenue P1-C] 타겟 고객 프로필 — models/customer_revenue/predict.py 입력 +diff --git a/backend/src/services/corp_brand_resolver.py b/backend/src/services/corp_brand_resolver.py +new file mode 100644 +index 00000000..07373f9a +--- /dev/null ++++ b/backend/src/services/corp_brand_resolver.py +@@ -0,0 +1,150 @@ ++"""사업자번호 + 업종 → 같은 corp 의 해당 업종 자동 brand 매핑. ++ ++다업종 법인 (예: (주)더본코리아 = 빽다방·홍콩반점·빽보이피자·새마을식당...) 의 경우 ++회원가입 시 ``biz_brand_mapping`` 에 top frcsCnt brand 1개만 저장됨. ++시뮬레이션 시 사용자가 다른 업종 (예: 중식) 선택하면 같은 corp 의 해당 업종 ++가장 큰 brand (홍콩반점0410) 로 자동 resolve. ++ ++운영 외 업종 선택 시 ``INDUSTRY_NOT_OPERATED`` 에러 + 운영 가능 업종 list 반환. ++ ++설계: ++- ``users.company_name`` (회원가입 시 기록) 기준 ``ftc_brand_franchise.corpNm`` 매칭 ++- corpNm 표기 변형 흡수 — ILIKE + corp 핵심어 추출 (괄호/특수문자 제거) ++- 매칭 brand 중 ``frcsCnt`` 큰 것 1개 선택 ++- 운영 외 업종 → 거부 (사용자에게 운영 업종 list 안내) ++ ++사용처: ``main.py`` 시뮬 endpoint 호출 직후, 시뮬 input.brand_name override. ++""" ++ ++from __future__ import annotations ++ ++import logging ++import re ++ ++import sqlalchemy as sa ++ ++from src.config.settings import settings ++ ++logger = logging.getLogger(__name__) ++ ++ ++_engine: sa.Engine | None = None ++ ++ ++def _get_engine() -> sa.Engine: ++ global _engine ++ if _engine is None: ++ _engine = sa.create_engine(settings.postgres_url) ++ return _engine ++ ++ ++# corpNm 핵심어 추출용 — '(주)', '㈜', '주식회사' 등 법인 prefix/suffix 제거 ++_CORP_NOISE_RE = re.compile(r"\(주\)|㈜|주식회사|\([^)]*\)|\s+") ++ ++ ++def _normalize_corp(name: str) -> str: ++ """corpNm 정규화 — 법인 표기 noise 제거 후 핵심어 추출.""" ++ if not name: ++ return "" ++ return _CORP_NOISE_RE.sub("", name).strip() ++ ++ ++def get_corp_industries(biz_number: str) -> dict: ++ """사업자번호 → corp 의 운영 brand+업종 list. ++ ++ Args: ++ biz_number: 사업자등록번호 (하이픈 제거). ++ ++ Returns: ++ ``{"company_name": ..., "brands": [...], "industries": [...]}`` 또는 ++ ``{"error": "USER_NOT_FOUND" | "CORP_NOT_IN_FTC", ...}``. ++ """ ++ engine = _get_engine() ++ with engine.connect() as c: ++ user = c.execute( ++ sa.text("SELECT company_name FROM users WHERE biz_number = :biz"), ++ {"biz": biz_number}, ++ ).first() ++ if not user: ++ return {"error": "USER_NOT_FOUND", "biz_number": biz_number} ++ ++ company_name = user._mapping["company_name"] ++ norm = _normalize_corp(company_name) ++ if not norm: ++ return {"error": "INVALID_COMPANY_NAME", "company_name": company_name} ++ ++ # ftc_brand_franchise 에서 corpNm 매칭 (정규화 ILIKE) ++ # frcsCnt 큰 row 부터 정렬 — 같은 brand 의 다년 데이터는 max 사용 ++ rows = c.execute( ++ sa.text( ++ """ ++ SELECT "brandNm", "indutyMlsfcNm", MAX("frcsCnt") AS stores ++ FROM ftc_brand_franchise ++ WHERE "corpNm" IS NOT NULL ++ AND REGEXP_REPLACE("corpNm", '\\(주\\)|㈜|주식회사|\\([^)]*\\)|\\s+', '', 'g') ILIKE :norm ++ GROUP BY "brandNm", "indutyMlsfcNm" ++ ORDER BY stores DESC NULLS LAST ++ """ ++ ), ++ {"norm": f"%{norm}%"}, ++ ).fetchall() ++ ++ if not rows: ++ return { ++ "error": "CORP_NOT_IN_FTC", ++ "company_name": company_name, ++ "message": f"{company_name} 은(는) FTC 가맹사업 정보공개서에 등록되지 않은 corp 입니다.", ++ } ++ ++ brands = [ ++ {"name": r._mapping["brandNm"], "industry": r._mapping["indutyMlsfcNm"], "stores": r._mapping["stores"] or 0} ++ for r in rows ++ ] ++ industries = sorted({b["industry"] for b in brands if b["industry"]}) ++ ++ return { ++ "company_name": company_name, ++ "brands": brands, ++ "industries": industries, ++ } ++ ++ ++def resolve_brand_for_industry(biz_number: str, industry: str) -> dict: ++ """사업자번호 + 업종 → 같은 corp 의 해당 업종 가장 큰 brand 자동 선택. ++ ++ Args: ++ biz_number: 사업자등록번호. ++ industry: 업종명 (FTC indutyMlsfcNm 표기 — 한식/중식/일식/...). ++ ++ Returns: ++ 성공: ``{"brand_name": ..., "industry": ..., "stores": int, ++ "alternatives": [...], "company_name": ...}``. ++ 실패: ``{"error": "INDUSTRY_NOT_OPERATED" | "USER_NOT_FOUND" | "CORP_NOT_IN_FTC", ++ "operated_industries": [...], ...}``. ++ """ ++ portfolio = get_corp_industries(biz_number) ++ if "error" in portfolio: ++ return portfolio ++ ++ matched = [b for b in portfolio["brands"] if b["industry"] == industry] ++ if not matched: ++ return { ++ "error": "INDUSTRY_NOT_OPERATED", ++ "company_name": portfolio["company_name"], ++ "requested_industry": industry, ++ "operated_industries": portfolio["industries"], ++ "message": ( ++ f"'{industry}' 업종은 {portfolio['company_name']} 운영 brand 에 없습니다. " ++ f"운영 가능 업종: {', '.join(portfolio['industries'])}" ++ ), ++ } ++ ++ # frcsCnt 내림차순 정렬됨 (get_corp_industries 가 보장) — 첫 항목 = top brand ++ top = matched[0] ++ return { ++ "brand_name": top["name"], ++ "industry": top["industry"], ++ "stores": top["stores"], ++ "alternatives": [b["name"] for b in matched[1:]], ++ "company_name": portfolio["company_name"], ++ } +diff --git a/docs/retrospective/2026-05-04.md b/docs/retrospective/2026-05-04.md +index 5fff8361..8642e2e7 100644 +--- a/docs/retrospective/2026-05-04.md ++++ b/docs/retrospective/2026-05-04.md +@@ -1023,3 +1023,124 @@ + ``` + + --- ++ ++## 23:17:43 세션 완료 ++ ++ ++--- ++ ++## 23:28:10 세션 완료 ++ ++### 변경 파일 ++- backend/src/main.py ++- backend/src/services/brand_mapping_resolver.py ++- docs/retrospective/2026-05-04.md ++- frontend/src/components/SimulationResult/sections/MarketMap.tsx ++- frontend/src/types/index.ts ++ ++### diff 요약 ++``` ++ backend/src/main.py | 2 ++ ++ backend/src/services/brand_mapping_resolver.py | 6 ++++-- ++ docs/retrospective/2026-05-04.md | 17 +++++++++++++++++ ++ .../SimulationResult/sections/MarketMap.tsx | 20 +++++++++++++++++--- ++ frontend/src/types/index.ts | 2 ++ ++ 5 files changed, 42 insertions(+), 5 deletions(-) ++``` ++ ++--- ++ ++## 23:33:29 세션 완료 ++ ++### 변경 파일 ++- docs/retrospective/2026-05-04.md ++ ++### diff 요약 ++``` ++ docs/retrospective/2026-05-04.md | 34 ++++++++++++++++++++++++++++++++++ ++ 1 file changed, 34 insertions(+) ++``` ++ ++--- ++ ++## 23:35:36 세션 완료 ++ ++### 변경 파일 ++- docs/retrospective/2026-05-04.md ++ ++### diff 요약 ++``` ++ docs/retrospective/2026-05-04.md | 47 ++++++++++++++++++++++++++++++++++++++++ ++ 1 file changed, 47 insertions(+) ++``` ++ ++--- ++ ++## 23:36:56 세션 완료 ++ ++### 변경 파일 ++- docs/retrospective/2026-05-04.md ++ ++### diff 요약 ++``` ++ docs/retrospective/2026-05-04.md | 60 ++++++++++++++++++++++++++++++++++++++++ ++ 1 file changed, 60 insertions(+) ++``` ++ ++--- ++ ++## 23:37:58 세션 완료 ++ ++### 변경 파일 ++- docs/retrospective/2026-05-04.md ++ ++### diff 요약 ++``` ++ docs/retrospective/2026-05-04.md | 73 ++++++++++++++++++++++++++++++++++++++++ ++ 1 file changed, 73 insertions(+) ++``` ++ ++--- ++ ++## 23:40:37 세션 완료 ++ ++### 변경 파일 ++- docs/retrospective/2026-05-04.md ++ ++### diff 요약 ++``` ++ docs/retrospective/2026-05-04.md | 86 ++++++++++++++++++++++++++++++++++++++++ ++ 1 file changed, 86 insertions(+) ++``` ++ ++--- ++ ++## 23:42:13 세션 완료 ++ ++### 변경 파일 ++- docs/retrospective/2026-05-04.md ++ ++### diff 요약 ++``` ++ docs/retrospective/2026-05-04.md | 99 ++++++++++++++++++++++++++++++++++++++++ ++ 1 file changed, 99 insertions(+) ++``` ++ ++--- ++ ++## 23:56:50 세션 완료 ++ ++### 변경 파일 ++- docs/retrospective/2026-05-04.md ++- frontend/src/components/AbmPersonaMap.tsx ++- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx ++ ++### diff 요약 ++``` ++ docs/retrospective/2026-05-04.md | 114 ++++++ ++ frontend/src/components/AbmPersonaMap.tsx | 415 ++++++++++++--------- ++ .../SimulationResult/dashboard/tabs/AbmTab.tsx | 16 +- ++ 3 files changed, 355 insertions(+), 190 deletions(-) ++``` ++ ++--- +-- +2.53.0.windows.2 + diff --git a/.audit/reverted_patches/bdbd5754_jwt_integration.patch b/.audit/reverted_patches/bdbd5754_jwt_integration.patch new file mode 100644 index 00000000..e49dd280 --- /dev/null +++ b/.audit/reverted_patches/bdbd5754_jwt_integration.patch @@ -0,0 +1,260 @@ +From bdbd575406179282a4a2a435c5b96fb5624e0ce2 Mon Sep 17 00:00:00 2001 +From: bat1120 +Date: Tue, 5 May 2026 00:49:54 +0900 +Subject: [PATCH] =?UTF-8?q?A1:=20corp=5Fbrand=5Fresolver=20JWT=20=EC=9E=90?= + =?UTF-8?q?=EB=8F=99=20=ED=86=B5=ED=95=A9=20(frontend=20biz=5Fnumber=20?= + =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=8B=9C=20=ED=86=A0=ED=81=B0=EC=97=90?= + =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EC=B6=9C)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +PR #188 의 corp_brand_resolver 가 frontend 가 biz_number 명시 안 보내면 +검증 skip 되는 회귀 fix. JWT 토큰에서 자동 추출. + +추가: +- _resolve_user_biz_number(user) — JWT UserContext → users.biz_number 조회 + * master role: user_id → users.id 매칭 + * manager role: owner_id → users.id 매칭 (소속 owner) +- _validate_and_resolve_brand(input_data, current_user) 시그니처 확장 +- biz_number 우선순위: + 1. input_data.biz_number (frontend 명시) + 2. JWT 토큰의 user.user_id 또는 owner_id → users.biz_number + 3. 없으면 검증 skip (비회원/개인사업자 호환) + +7 endpoint 시그니처에 Depends(get_optional_user) 추가: +- /analyze, /analyze/llm, /analyze/llm/async, /analyze/quick +- /predict, /predict/async, /simulate + +import: +- from fastapi import Depends 추가 +- from src.services.jwt_auth import UserContext, get_optional_user + +기존 동작 유지: +- 비로그인 사용자 → optional auth, current_user=None → skip (기존과 동일) +- frontend biz_number 명시 → 토큰 무시 (frontend 우선) +- frontend biz_number 없음 + 로그인 → 토큰에서 자동 추출 (이번 fix) + +DB 변경: 0 + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + backend/src/main.py | 98 ++++++++++++++++++++++++++++++++++----------- + 1 file changed, 75 insertions(+), 23 deletions(-) + +diff --git a/backend/src/main.py b/backend/src/main.py +index e0a06478..15046217 100644 +--- a/backend/src/main.py ++++ b/backend/src/main.py +@@ -47,7 +47,7 @@ import redis.asyncio as aioredis + # LangSmith 트레이싱: langchain import 전에 os.environ 주입 필수 + # (langchain SDK는 import 시점에 LANGCHAIN_TRACING_V2를 읽으므로 순서가 중요) + from dotenv import load_dotenv +-from fastapi import FastAPI, HTTPException, Request, Response ++from fastapi import Depends, FastAPI, HTTPException, Request, Response + from fastapi.concurrency import run_in_threadpool + from fastapi.middleware.cors import CORSMiddleware + from fastapi.responses import JSONResponse +@@ -78,6 +78,7 @@ from src.schemas.simulation_input import SimulationInput + from src.services.auth import AuthService + from src.services.biz_mapper import BizMapper + from src.services.corp_brand_resolver import resolve_brand_for_industry ++from src.services.jwt_auth import UserContext, get_optional_user + + from models.explainability.shap_analysis import explain_tcn_prediction + from models.explainability.simulation import ( +@@ -226,28 +227,56 @@ def _pipeline_key(input_data: Any) -> str: + return f"{input_data.target_district}:{input_data.business_type}:{input_data.brand_name}:{rent}:{area}:{radius}:{pop_w}" + + +-def _validate_and_resolve_brand(input_data: SimulationInput) -> None: ++def _resolve_user_biz_number(user: UserContext | None) -> str | None: ++ """JWT user → users.biz_number 조회. master 는 본인, manager 는 owner 의 biz_number.""" ++ if user is None: ++ return None ++ target_id = user.owner_id if user.role == "manager" else user.user_id ++ if not target_id: ++ return None ++ try: ++ import sqlalchemy as sa ++ ++ engine = sa.create_engine(settings.postgres_url) ++ with engine.connect() as conn: ++ row = conn.execute( ++ sa.text("SELECT biz_number FROM users WHERE id = :id"), ++ {"id": target_id}, ++ ).first() ++ return row._mapping["biz_number"] if row else None ++ except Exception as ex: ++ logger.warning(f"[brand_resolver] biz_number 조회 실패: {ex}") ++ return None ++ ++ ++def _validate_and_resolve_brand( ++ input_data: SimulationInput, ++ current_user: UserContext | None = None, ++) -> None: + """biz_number 입력 시 corp 검증 + 다업종 corp 의 brand auto-resolve. + +- 동작 (input_data.biz_number 가 입력됐을 때만): ++ biz_number 우선순위: ++ 1. ``input_data.biz_number`` (frontend 명시 입력) ++ 2. JWT ``current_user`` 토큰에서 자동 추출 (master.user_id 또는 manager.owner_id) ++ 3. 없으면 검증 skip (개인사업자 / 비회원 호환) ++ ++ 동작: + 1. business_type 이 사용자 corp 의 운영 업종인지 검증. + 2. 운영 외 업종 → HTTPException(400) + 운영 가능 업종 list 응답. + 3. 운영 내 업종 + corp 의 해당 업종 brand 가 다른 brand 면 brand_name override. +- +- biz_number 미입력 (개인사업자 / 비회원) → 검증 skip, 사용자 brand_name 그대로. +- FTC 미등록 corp → 검증 skip + 경고 로그. + """ +- if not input_data.biz_number: ++ biz_number = input_data.biz_number or _resolve_user_biz_number(current_user) ++ if not biz_number: + return + +- result = resolve_brand_for_industry(input_data.biz_number, input_data.business_type) ++ result = resolve_brand_for_industry(biz_number, input_data.business_type) + + if result.get("error") == "INDUSTRY_NOT_OPERATED": + raise HTTPException(status_code=400, detail=result) + + if result.get("error") in {"USER_NOT_FOUND", "CORP_NOT_IN_FTC", "INVALID_COMPANY_NAME"}: + # 비회원 / FTC 미등록 → 검증 skip, 사용자 brand_name 그대로 +- logger.warning(f"[brand_resolver] {result['error']} biz={input_data.biz_number} — fallback to input.brand_name") ++ logger.warning(f"[brand_resolver] {result['error']} biz={biz_number} — fallback to input.brand_name") + return + + # 성공: brand_name override (사용자가 다른 brand 입력했어도 corp 정합 brand 로 교체) +@@ -963,14 +992,18 @@ async def get_status(job_id: str): + + + @app.post("/analyze") +-async def analyze_location(input_data: SimulationInput, response: Response): ++async def analyze_location( ++ input_data: SimulationInput, ++ response: Response, ++ current_user: UserContext | None = Depends(get_optional_user), ++): + """[DEPRECATED] 풀파이프 상권 분석 — 전환 기간 동안만 유지. + + IM3-259로 endpoint를 분리(/predict + /analyze/llm)했으므로 신규 호출은 + 그쪽으로 옮길 것. 이 endpoint는 기존 프론트/테스트 호환을 위해 유지하다가 + 충분히 검증되면 제거 예정. + """ +- _validate_and_resolve_brand(input_data) ++ _validate_and_resolve_brand(input_data, current_user) + from src.config.constants import MAPO_DISTRICTS + + # IM3-259: deprecation 헤더 — 클라이언트가 /predict + /analyze/llm 으로 옮길 것을 알림 +@@ -1021,12 +1054,15 @@ async def analyze_location(input_data: SimulationInput, response: Response): + + + @app.post("/analyze/llm") +-async def analyze_llm(input_data: SimulationInput): ++async def analyze_llm( ++ input_data: SimulationInput, ++ current_user: UserContext | None = Depends(get_optional_user), ++): + """AI 분석 전용 endpoint — slow_graph 실행 (~80-140초). + + /predict와 독립 병렬 호출 가능. winner는 ranking 단계에서 자체 결정. + """ +- _validate_and_resolve_brand(input_data) ++ _validate_and_resolve_brand(input_data, current_user) + from src.config.constants import MAPO_DISTRICTS + from src.schemas.simulation_output import AnalysisOutput + +@@ -1094,9 +1130,12 @@ _SLOW_GRAPH_NODE_TOTAL = 4 + + + @app.post("/analyze/llm/async") +-async def analyze_llm_async(input_data: SimulationInput) -> dict[str, Any]: ++async def analyze_llm_async( ++ input_data: SimulationInput, ++ current_user: UserContext | None = Depends(get_optional_user), ++) -> dict[str, Any]: + """AI 분석 비동기 시작 — 즉시 job_id 반환. LangGraph 노드별 진행률 추적.""" +- _validate_and_resolve_brand(input_data) ++ _validate_and_resolve_brand(input_data, current_user) + from src.config.constants import MAPO_DISTRICTS + from src.schemas.simulation_output import AnalysisOutput + from src.services.job_progress_store import ( +@@ -1208,7 +1247,10 @@ async def analyze_llm_job_status(job_id: str) -> dict[str, Any]: + + + @app.post("/analyze/quick") +-async def analyze_quick(input_data: SimulationInput): ++async def analyze_quick( ++ input_data: SimulationInput, ++ current_user: UserContext | None = Depends(get_optional_user), ++): + """ + LLM 없는 경량 랭킹 엔드포인트 (district_ranking 에이전트만 실행). + +@@ -1217,7 +1259,7 @@ async def analyze_quick(input_data: SimulationInput): + + 응답: { district_rankings, winner_district, top_3_candidates } + """ +- _validate_and_resolve_brand(input_data) ++ _validate_and_resolve_brand(input_data, current_user) + from src.agents.nodes.district_ranking import district_ranking_node + from src.agents.nodes.market_analyst import db_client + +@@ -1692,7 +1734,10 @@ def _mock_simulation_response(target_district: str, request_id: str) -> dict: + + + @app.post("/predict") +-async def predict_districts(input_data: SimulationInput): ++async def predict_districts( ++ input_data: SimulationInput, ++ current_user: UserContext | None = Depends(get_optional_user), ++): + """ + 선택 동 1~4개 ML 예측 전용 엔드포인트 (LangGraph 미사용) + +@@ -1700,7 +1745,7 @@ async def predict_districts(input_data: SimulationInput): + - target_districts 전체에 대해 TCN/BEP/폐업률/폐업위험도/SHAP 병렬 실행 + - 응답: 동별 예측 결과 리스트 (프론트 멀티라인 차트용) + """ +- _validate_and_resolve_brand(input_data) ++ _validate_and_resolve_brand(input_data, current_user) + from src.config.constants import MAPO_DISTRICTS + + target_districts = getattr(input_data, "target_districts", None) or [input_data.target_district] +@@ -1762,9 +1807,12 @@ async def predict_districts(input_data: SimulationInput): + # 단계: 동별 _predict_single_district 가 끝날 때마다 progress = done/total. + # --------------------------------------------------------------------------- + @app.post("/predict/async") +-async def predict_districts_async(input_data: SimulationInput) -> dict[str, Any]: ++async def predict_districts_async( ++ input_data: SimulationInput, ++ current_user: UserContext | None = Depends(get_optional_user), ++) -> dict[str, Any]: + """ML 예측 비동기 시작 — 즉시 job_id 반환. 진행률은 status endpoint 폴링.""" +- _validate_and_resolve_brand(input_data) ++ _validate_and_resolve_brand(input_data, current_user) + from src.config.constants import MAPO_DISTRICTS + from src.services.job_progress_store import ( + create_job, +@@ -1878,9 +1926,13 @@ async def predict_job_status(job_id: str) -> dict[str, Any]: + + + @app.post("/simulate", deprecated=True) +-async def run_simulation(input_data: SimulationInput, response: Response): ++async def run_simulation( ++ input_data: SimulationInput, ++ response: Response, ++ current_user: UserContext | None = Depends(get_optional_user), ++): + """기본 시뮬레이션 엔드포인트""" +- _validate_and_resolve_brand(input_data) ++ _validate_and_resolve_brand(input_data, current_user) + response.headers["Deprecation"] = "true" + response.headers["Link"] = '; rel="successor-version", ; rel="successor-version"' + +-- +2.53.0.windows.2 + diff --git a/.audit_post_session.txt b/.audit_post_session.txt new file mode 100644 index 00000000..6b3f5737 --- /dev/null +++ b/.audit_post_session.txt @@ -0,0 +1,57 @@ +### POST-SESSION DB 실측 (PR #178~#197 머지 후) ### + +=== alembic head === +91b66e68ec18 + +=== 테이블 + row counts (top 30) === + ('living_population_grid', 10538127) + ('bus_boarding_daily', 3710508) + ('seoul_ttareungi_usage_daily', 985653) + ('living_population', 961071) + ('seoul_adstrd_stor', 849552) + ('district_sales_seoul', 475334) + ('sgis_population', 224517) + ('seoul_subway_passenger_daily', 199340) + ('golmok_commercial', 178840) + ('jeonse_monthly_rent', 168342) + ('sgis_business', 137356) + ('seoul_district_stores', 100587) + ('seoul_training_dataset', 87938) + ('seoul_district_sales', 87938) + ('kakao_store_menu', 81037) + ('seoul_signgu_stor', 69704) + ('seoul_signgu_selng', 43043) + ('ftc_brand_franchise', 34708) + ('naver_trend_monthly', 33985) + ('store_info', 30488) + ('sgis_household', 25550) + ('golmok_stores', 15800) + ('seoul_resident_pop_quarterly', 13508) + ('seoul_adstrd_flpop', 11900) + ('seoul_golmok_rent', 11900) + ('seoul_adstrd_change_ix', 11900) + ('mart_brand_territory', 11849) + ('langchain_pg_embedding', 10255) + ('seoul_population_quarterly', 10176) + ('small_store_rent_q', 10020) + +=== 100% NULL 컬럼 재측정 (master 메타 backfill 후) === + district_sales_seoul: 100% NULL = ['raw_json'] + dong_mapping: 100% NULL = ['trdar_codes'] + ecos_timeseries: 100% NULL = ['item_name2', 'cycle'] + invite_codes: 100% NULL = ['expires_at'] + kakao_store_hours: 100% NULL = ['mon_hours', 'tue_hours', 'wed_hours', 'thu_hours', 'fri_hours', 'sat_hours', 'sun_hours'] + living_population: 100% NULL = ['male_70_74', 'female_70_74'] + mart_brand_territory: 100% NULL = ['extraction_confidence'] + master_ttareungi_station: 100% NULL = ['dong_code', 'opened_at'] + molit_nrg_trade: 100% NULL = ['realty_type'] + rent_cost: 100% NULL = ['transaction_date', 'price', 'floor_area', 'floor'] + seoul_dong_master: 100% NULL = ['comment'] + +=== orphan FK === + ✓ orphan 0 + +=== ORM ↔ DB 정합 === + ORM: 77, DB: 87 + ORM only (zombie): [] + DB only (raw SQL): ['alembic_version', 'langchain_pg_collection', 'langchain_pg_embedding', 'living_population_grid', 'mapo_schools', 'password_reset_tokens', 'seoul_district_sales_imputed_v4', 'seoul_district_sales_imputed_v4_detail', 'seoul_resident_pop_quarterly', 'user_usage'] diff --git a/backend/scripts/ingest/refresh_kakao_missing_brands.py b/backend/scripts/ingest/refresh_kakao_missing_brands.py new file mode 100644 index 00000000..728cb702 --- /dev/null +++ b/backend/scripts/ingest/refresh_kakao_missing_brands.py @@ -0,0 +1,307 @@ +"""마포 kakao_store 에 누락된 brand 매장 일괄 적재. + +사용자 보고 (2026-05-06): 한신포차 홍대점 (마포 잔다리로 13) 등 더본/본아이에프 다업종 +brand 가 kakao 에 실존하지만 우리 kakao_store DB 에 미적재. corp_brand_resolver 로 +override 된 brand_name 검색 시 마포 0건 → 자사 매장 별표 표시 누락. + +해결: Kakao Local Search API 로 마포 16동 × N brand 검색 → 누락 매장 INSERT. + +Usage: + cd backend && PYTHONIOENCODING=utf-8 python -X utf8 scripts/ingest/refresh_kakao_missing_brands.py + cd backend && ... refresh_kakao_missing_brands.py --dry-run + cd backend && ... refresh_kakao_missing_brands.py --brand 한신포차 +""" + +from __future__ import annotations + +import argparse +import os +import sys +import time +from datetime import datetime, timezone + +import requests +from dotenv import load_dotenv + +load_dotenv("../.env") + +sys.path.insert(0, ".") + +from sqlalchemy import text # noqa: E402 + +from src.database.sync_engine import get_sync_engine # noqa: E402 + +# --------------------------------------------------------------------------- +# 마포 16동 centroid (MapSection.tsx 의 DONG_COORDS 와 동일) +# --------------------------------------------------------------------------- + +MAPO_DONGS: dict[str, tuple[float, float]] = { + "아현동": (37.5502, 126.9594), + "공덕동": (37.543, 126.9519), + "도화동": (37.5393, 126.9457), + "용강동": (37.5382, 126.9383), + "대흥동": (37.548, 126.9437), + "염리동": (37.5523, 126.9474), + "신수동": (37.5453, 126.9361), + "서강동": (37.5493, 126.9347), + "서교동": (37.5565, 126.9239), + "합정동": (37.5497, 126.9143), + "망원1동": (37.5558, 126.9059), + "망원2동": (37.5531, 126.9021), + "연남동": (37.5617, 126.9226), + "성산1동": (37.5663, 126.9069), + "성산2동": (37.5706, 126.9111), + "상암동": (37.5789, 126.8899), +} + +# --------------------------------------------------------------------------- +# 누락 brand list — 검증 결과 (FTC ≥ 50 인데 kakao_store 마포 0건) +# --------------------------------------------------------------------------- + +MISSING_BRANDS: list[str] = [ + # 더본코리아 + "한신포차", + "빽보이피자", + "롤링파스타", + "백스비어", + "리춘시장", + "막이오름", + "인생설렁탕", + # 본아이에프 + "본죽&비빔밥", + "본도시락", + "본설렁탕", + # 다름플러스 + "이차돌", + "제육폭식", +] + + +# --------------------------------------------------------------------------- +# Kakao Local Search API +# --------------------------------------------------------------------------- + +KAKAO_API = "https://dapi.kakao.com/v2/local/search/keyword.json" +SEARCH_RADIUS_M = 2000 # 동 centroid 기준 2km — 마포 16동 cover + + +def _kakao_search(api_key: str, query: str, lat: float, lon: float, radius: int = SEARCH_RADIUS_M) -> list[dict]: + """Kakao Local Search keyword API 호출. 반경 내 매장 list.""" + headers = {"Authorization": f"KakaoAK {api_key}"} + all_docs: list[dict] = [] + page = 1 + while page <= 3: # max 45 results (15 per page × 3) + params = { + "query": query, + "x": str(lon), + "y": str(lat), + "radius": radius, + "page": page, + "size": 15, + "sort": "distance", + } + try: + r = requests.get(KAKAO_API, headers=headers, params=params, timeout=10) + if r.status_code != 200: + print(f" [kakao] {r.status_code} {r.text[:100]}") + break + data = r.json() + docs = data.get("documents", []) + all_docs.extend(docs) + if data.get("meta", {}).get("is_end", True): + break + page += 1 + time.sleep(0.1) # rate limit 부드럽게 + except Exception as e: + print(f" [kakao] error {e}") + break + return all_docs + + +def _is_mapo(address: str) -> bool: + """주소가 마포구 안인지.""" + if not address: + return False + return "마포구" in address or "Mapo" in address + + +def _extract_dong(address: str) -> str | None: + """address 에서 dong_name 추출 — '서울 마포구 X동' 패턴.""" + if not address: + return None + parts = address.split() + for p in parts: + if p.endswith("동") and "마포구" not in p: + return p + return None + + +def _kakao_doc_to_store(doc: dict, brand_query: str) -> dict | None: + """Kakao API doc → kakao_store row dict. 마포 외 매장은 None.""" + address = doc.get("address_name") or "" + road = doc.get("road_address_name") or "" + if not _is_mapo(address) and not _is_mapo(road): + return None + dong = _extract_dong(address) + if not dong: + return None + + return { + "kakao_id": doc.get("id"), + "place_name": doc.get("place_name") or "", + "brand_name": brand_query, # 검색 query 를 brand_name 으로 (정규화는 brand_mapping_resolver) + "category": _infer_category(doc.get("category_name") or ""), + "category_detail": doc.get("category_name") or None, + "address": address or None, + "road_address": road or None, + "dong_name": dong, + "lat": float(doc.get("y") or 0) if doc.get("y") else None, + "lon": float(doc.get("x") or 0) if doc.get("x") else None, + "phone": doc.get("phone") or None, + "place_url": doc.get("place_url") or None, + "is_franchise": True, # 검색 brand 매칭 매장 = 가맹점 추정 + "collected_at": datetime.now(timezone.utc), + } + + +def _infer_category(category_name: str) -> str: + """Kakao category_name (예: '음식점 > 한식 > 백반') → 우리 표기 ('한식음식점' 등).""" + if not category_name: + return "기타" + last = category_name.split(">")[-1].strip().lower() + full = category_name.lower() + # 매핑 — kakao_store top categories 와 일치 + if "한식" in full: + return "한식음식점" + if "중식" in full or "중국" in full: + return "중식음식점" + if "일식" in full: + return "일식음식점" + if "양식" in full or "이탈리" in full or "파스타" in full or "스테이크" in full: + return "양식음식점" + if "베이커리" in full or "제과" in full or "빵" in full: + return "제과점" + if "패스트푸드" in full or "버거" in full or "햄버거" in full: + return "패스트푸드점" + if "치킨" in full: + return "치킨전문점" + if "분식" in full: + return "분식전문점" + if "주점" in full or "호프" in full or "이자카야" in full: + return "호프-간이주점" + if "카페" in full or "커피" in full: + return "커피-음료" + if "피자" in full: + # 피자: kakao_store 의 기존 정책 — 패스트푸드점 (commercial_intelligence.py:_KAKAO_CATEGORY_MAP) + return "패스트푸드점" + return "기타" + + +# --------------------------------------------------------------------------- +# UPSERT +# --------------------------------------------------------------------------- + + +def _upsert_stores(engine, stores: list[dict]) -> int: + """kakao_store ON CONFLICT (kakao_id) DO UPDATE — 변경 row 수 반환.""" + if not stores: + return 0 + sql = text( + """ + INSERT INTO kakao_store ( + kakao_id, place_name, brand_name, category, category_detail, + address, road_address, dong_name, lat, lon, phone, place_url, + is_franchise, collected_at + ) VALUES ( + :kakao_id, :place_name, :brand_name, :category, :category_detail, + :address, :road_address, :dong_name, :lat, :lon, :phone, :place_url, + :is_franchise, :collected_at + ) + ON CONFLICT (kakao_id) DO UPDATE SET + place_name = EXCLUDED.place_name, + brand_name = EXCLUDED.brand_name, + category = EXCLUDED.category, + category_detail = EXCLUDED.category_detail, + address = EXCLUDED.address, + road_address = EXCLUDED.road_address, + dong_name = EXCLUDED.dong_name, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + phone = EXCLUDED.phone, + place_url = EXCLUDED.place_url, + is_franchise = EXCLUDED.is_franchise, + collected_at = EXCLUDED.collected_at + """ + ) + with engine.begin() as conn: + conn.execute(sql, stores) + return len(stores) + + +# --------------------------------------------------------------------------- +# Main ETL +# --------------------------------------------------------------------------- + + +def run(brands: list[str], dry_run: bool = False) -> None: + api_key = os.environ.get("KAKAO_API_KEY") + if not api_key: + raise SystemExit("KAKAO_API_KEY missing in .env") + + engine = get_sync_engine(os.environ["POSTGRES_URL"]) + + print(f"=== Kakao 누락 brand ETL 시작 — {len(brands)} brand × {len(MAPO_DONGS)} 동 ===") + print(f" dry_run={dry_run} radius={SEARCH_RADIUS_M}m") + print() + + total_found = 0 + total_inserted = 0 + seen_ids: set[str] = set() + per_brand: dict[str, int] = {} + + for brand in brands: + per_brand[brand] = 0 + brand_buffer: list[dict] = [] + for dong, (lat, lon) in MAPO_DONGS.items(): + docs = _kakao_search(api_key, brand, lat, lon) + for doc in docs: + kid = doc.get("id") + if not kid or kid in seen_ids: + continue + seen_ids.add(kid) + store = _kakao_doc_to_store(doc, brand) + if store: + brand_buffer.append(store) + per_brand[brand] += 1 + total_found += len(brand_buffer) + print(f" [{brand:<15s}] 마포 {len(brand_buffer)}건 발견") + if brand_buffer and not dry_run: + _upsert_stores(engine, brand_buffer) + total_inserted += len(brand_buffer) + + print() + print(f"=== 완료 — 발견 {total_found}건 / 적재 {total_inserted}건 ===") + if dry_run: + print(" (dry_run 모드 — DB write 없음)") + print() + print("=== brand 별 ===") + for brand, cnt in sorted(per_brand.items(), key=lambda x: -x[1]): + print(f" {brand:<15s}: {cnt}") + + +def main(): + parser = argparse.ArgumentParser(description="Kakao 누락 brand 마포 매장 적재") + parser.add_argument( + "--brand", + default=None, + help="단일 brand 만 처리 (default: 전체 MISSING_BRANDS)", + ) + parser.add_argument("--dry-run", action="store_true", help="DB write 없이 발견만") + args = parser.parse_args() + + brands = [args.brand] if args.brand else MISSING_BRANDS + run(brands, dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/backend/src/agents/legal/categories.py b/backend/src/agents/legal/categories.py index a94d2770..bb99cd05 100644 --- a/backend/src/agents/legal/categories.py +++ b/backend/src/agents/legal/categories.py @@ -21,16 +21,16 @@ # 카테고리 → 그룹 매핑 (단일 소스) LEGAL_CATEGORY_GROUP: dict[str, str] = { # ── 입지 그룹 (출점 결정 critical) ── - "building_law": LEGAL_GROUP_LOCATION, # 용도지역/용도변경 - "school_zone": LEGAL_GROUP_LOCATION, # 학교환경위생정화구역 (50/200m) - "safety_regulation": LEGAL_GROUP_LOCATION, # 다중이용업소 면적 트리거 - "fire_safety_law": LEGAL_GROUP_LOCATION, # 소방시설 면적 - "accessibility_law": LEGAL_GROUP_LOCATION, # 편의시설 면적 - "franchise_law": LEGAL_GROUP_LOCATION, # 영업지역 침해 (인접 출점) - "fair_trade_law": LEGAL_GROUP_LOCATION, # 공정거래/마포구 조례 + "building_law": LEGAL_GROUP_LOCATION, # 용도지역/용도변경 + "school_zone": LEGAL_GROUP_LOCATION, # 학교환경위생정화구역 (50/200m) + "safety_regulation": LEGAL_GROUP_LOCATION, # 다중이용업소 면적 트리거 + "fire_safety_law": LEGAL_GROUP_LOCATION, # 소방시설 면적 + "accessibility_law": LEGAL_GROUP_LOCATION, # 편의시설 면적 + "franchise_law": LEGAL_GROUP_LOCATION, # 영업지역 침해 (인접 출점) + "fair_trade_law": LEGAL_GROUP_LOCATION, # 공정거래/마포구 조례 "commercial_lease_law": LEGAL_GROUP_LOCATION, # 임대차 — 출점 시 결정 - "zoning_regulation": LEGAL_GROUP_LOCATION, # legacy 호환 - "ftc_franchise": LEGAL_GROUP_LOCATION, # 정보공개서 — 출점 전 검토 + "zoning_regulation": LEGAL_GROUP_LOCATION, # legacy 호환 + "ftc_franchise": LEGAL_GROUP_LOCATION, # 정보공개서 — 출점 전 검토 # ── 운영 그룹 (자영업자 통상 인지) ── "food_hygiene": LEGAL_GROUP_OPERATION, "labor_law": LEGAL_GROUP_OPERATION, diff --git a/backend/src/config/constants.py b/backend/src/config/constants.py index 8e94d113..a598e59c 100644 --- a/backend/src/config/constants.py +++ b/backend/src/config/constants.py @@ -23,6 +23,31 @@ "서강동", ] +# ── 마포 행정동 → kakao_store.dong_name 매핑 ── +# kakao 카카오맵 크롤 결과의 dong_name 컬럼은 행정동/법정동 혼합. 행정동 +# 기준으로 매장 전수 조회 시 법정동도 함께 조회해야 누락 차단됨. +# 예: 망원1/2동 행정동 매장 대부분이 법정동 '망원동' 으로 등록됨. +# 출처: 마포구 행정동·법정동 매핑 공식 문서. +MAPO_ADSTRD_TO_KAKAO_DONG_NAMES: dict[str, list[str]] = { + "아현동": ["아현동"], + "공덕동": ["공덕동", "신공덕동"], + "도화동": ["도화동", "마포동"], + "용강동": ["용강동", "토정동", "하중동"], + "대흥동": ["대흥동", "노고산동"], + "염리동": ["염리동"], + "신수동": ["신수동"], + "서교동": ["서교동"], + "합정동": ["합정동", "당인동"], + "망원1동": ["망원1동", "망원동"], + "망원2동": ["망원2동", "망원동"], + "연남동": ["연남동", "동교동"], + "성산1동": ["성산1동", "성산동"], + "성산2동": ["성산2동", "성산동", "중동", "구수동"], + "상암동": ["상암동"], + "서강동": ["서강동", "창전동", "현석동", "상수동", "신정동"], +} + + # ── MVP 비교 대상 동 (3개) ── MVP_TARGET_DISTRICTS = ["망원1동", "공덕동", "대흥동"] diff --git a/backend/src/main.py b/backend/src/main.py index b92b97d1..7a5e0bd9 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -333,6 +333,7 @@ def _is_valid(s: dict) -> bool: def _haversine(a: tuple[float, float], b: tuple[float, float]) -> float: import math + R = 6_371_000.0 rlat1, rlat2 = math.radians(a[0]), math.radians(b[0]) dlat = math.radians(b[0] - a[0]) @@ -349,7 +350,8 @@ def _haversine(a: tuple[float, float], b: tuple[float, float]) -> float: ) top3_set = set(top3) top3_spots = [ - s for s in spots + s + for s in spots if isinstance(s, dict) and s.get("dong_name") in top3_set and s.get("dong_name") != winner and _is_valid(s) ] top3_spots.sort(key=lambda s: -(s.get("listing_count") or 0)) @@ -396,7 +398,7 @@ def _query_kakao_store_by_coord( sql = text( """ SELECT kakao_id, place_name, brand_name, category, - lat, lon, is_franchise, place_url, phone + lat, lon, is_franchise, place_url, phone, dong_name FROM kakao_store WHERE lat BETWEEN :lat_min AND :lat_max AND lon BETWEEN :lon_min AND :lon_max @@ -439,13 +441,67 @@ def _haversine(lat1, lon1, lat2, lon2): "category": r["category"] or "", "place_url": r["place_url"], "phone": r["phone"], - "source_dong": None, # spot 좌표 기준이라 source 동 무관 + # kakao_store.dong_name 직접 — 후처리 행정동 필터에 사용 (선택 4동 외 인접 동 매장 컷). + "source_dong": r.get("dong_name"), } ) within.sort(key=lambda x: (x["distance_m"], x["id"])) # 거리 + id tie-breaker return within[:limit] +def _query_kakao_store_by_dong(dong_name: str, keyword: str, limit: int = 800) -> list[dict]: + """행정동(dong_name) 안 동종 kakao_store 매장 전수 조회. + + 좌표 반경 무시 — 행정동 경계 자체가 필터. 거리 컬럼은 호출자가 spot 기준으로 후처리. + kakao_store.dong_name 컬럼은 행정동/법정동 혼합 (예: 망원1동 행정동 매장 + 대부분이 '망원동' 법정동으로 등록). MAPO_ADSTRD_TO_KAKAO_DONG_NAMES 로 + 행정동에 속하는 모든 dong_name (행정 + 법정) 조회 → 누락 차단. + """ + import os + from sqlalchemy import text + + from src.config.constants import MAPO_ADSTRD_TO_KAKAO_DONG_NAMES + from src.database.sync_engine import get_sync_engine + + dong_names = MAPO_ADSTRD_TO_KAKAO_DONG_NAMES.get(dong_name, [dong_name]) + + sql = text( + """ + SELECT kakao_id, place_name, brand_name, category, + lat, lon, is_franchise, place_url, phone, dong_name + FROM kakao_store + WHERE dong_name = ANY(:dongs) + AND (category ILIKE :kw OR category_detail ILIKE :kw) + ORDER BY kakao_id + LIMIT :lim + """ + ) + engine = get_sync_engine(os.environ["POSTGRES_URL"]) + with engine.connect() as conn: + rows = conn.execute(sql, {"dongs": dong_names, "kw": f"%{keyword}%", "lim": limit}).mappings().all() + + out: list[dict] = [] + for r in rows: + if r["lat"] is None or r["lon"] is None: + continue + out.append( + { + "id": r["kakao_id"] or f"{r['place_name']}_{r['lat']}_{r['lon']}", + "place_name": r["place_name"] or "", + "brand_name": r["brand_name"] or "", + "lat": r["lat"], + "lng": r["lon"], + "distance_m": None, # spot 무관 — frontend 에서 haversine 재계산 + "is_franchise": bool(r["is_franchise"]), + "category": r["category"] or "", + "place_url": r["place_url"], + "phone": r["phone"], + "source_dong": r.get("dong_name"), + } + ) + return out + + async def _collect_all_competitor_locations( winner: str, top3: list, @@ -456,6 +512,8 @@ async def _collect_all_competitor_locations( spot_coords: list[tuple[float, float]] | None = None, spot_radius_m: int = 1500, spot_limit: int = 800, + include_dong: bool = True, + dong_limit: int = 800, ) -> list[dict]: """경쟁업체 좌표 수집 — 지도 마커용. @@ -469,14 +527,47 @@ async def _collect_all_competitor_locations( 합집합 결과의 dedup ordering 도 입력 spot 순서 (= score 순) 결정. """ from src.config.business_type_mapping import kakao_keyword_of + from src.config.constants import MAPO_ADSTRD_TO_KAKAO_DONG_NAMES keyword = kakao_keyword_of(business_type) or business_type - # 4 spot 모드 — 각 spot 별 1.5km 풀 합집합 (가장 우선) + # 선택 4동 (winner+top3) 의 kakao_store dong_name 허용 set — spot bbox 가 인접 동까지 + # 침범하는 매장 컷용. 각 행정동의 매핑된 법정동 모두 포함. + selected_districts = {winner} | set(top3 or []) + allowed_dongs: set[str] = set() + for d in selected_districts: + allowed_dongs.update(MAPO_ADSTRD_TO_KAKAO_DONG_NAMES.get(d, [d])) + + def _filter_allowed(rows: list[dict]) -> list[dict]: + """source_dong 이 allowed set 안 매장만 통과. None / 매핑 외 동 = 컷.""" + before = len(rows) + out = [r for r in rows if r.get("source_dong") in allowed_dongs] + print(f"[all_competitors:filter] dong filter {before} → {len(out)} (allowed={sorted(allowed_dongs)})") + return out + + # 행정동 기준 전수 매장 수집 (winner+top3) — spot/centroid 모드와 합집합. + # include_dong=True 면 spot 1.5km 반경 안 매장 + 행정동 안 모든 매장 = 합집합. + # 색깔 진하기/연하기 = frontend 가 spot 좌표 기준 haversine 으로 재계산 (radius 안 = 진함). + async def _gather_dong_rows() -> list[dict]: + if not include_dong: + return [] + districts = sorted(selected_districts) + out: list[dict] = [] + for d in districts: + try: + rows = await asyncio.to_thread(_query_kakao_store_by_dong, d, keyword, dong_limit) + print(f"[all_competitors:dong] {d} → {len(rows)}개") + out.extend(rows) + except Exception as e: + print(f"[all_competitors:dong] {d} 수집 실패: {e}") + return out + + # 4 spot 모드 — 각 spot 별 1.5km 풀 + 행정동 안 매장 합집합 (가장 우선) if spot_coords: print( f"[all_competitors:spots] {len(spot_coords)} spot 좌표 기준 검색 — " - f"keyword={keyword} per-spot radius={spot_radius_m}m limit={spot_limit}" + f"keyword={keyword} per-spot radius={spot_radius_m}m limit={spot_limit} " + f"include_dong={include_dong}" ) merged: list[dict] = [] seen_ids: set = set() @@ -491,6 +582,14 @@ async def _collect_all_competitor_locations( continue seen_ids.add(rid) merged.append(r) + spot_only_count = len(merged) + # 행정동 안 추가 매장 합치기 (dedup by kakao_id) + for r in await _gather_dong_rows(): + rid = r.get("id") + if rid in seen_ids: + continue + seen_ids.add(rid) + merged.append(r) # 결정적 정렬 — 1번 spot 좌표 기준 거리순 (frontend 도 spot1 기준 haversine 으로 sort 하니 정합성). if spot_coords: anchor_lat, anchor_lon = spot_coords[0] @@ -505,20 +604,35 @@ def _hv(la, lo): return 2 * R * math.asin(math.sqrt(a)) merged.sort(key=lambda r: (_hv(r["lat"], r["lng"]), str(r.get("id")))) - print(f"[all_competitors:spots] 최종 합집합 {len(merged)}개 (dedup 후)") - return merged + print( + f"[all_competitors:spots] 최종 합집합 {len(merged)}개 " + f"(spot {spot_only_count} + dong 추가 {len(merged) - spot_only_count})" + ) + return _filter_allowed(merged) - # 단일 spot 모드 (구버전 호환) + # 단일 spot 모드 (구버전 호환) + 행정동 합집합 if spot_lat is not None and spot_lon is not None: print( f"[all_competitors:spot] 좌표 기준 검색 — ({spot_lat:.5f},{spot_lon:.5f}) " - f"keyword={keyword} radius={spot_radius_m}m limit={spot_limit}" + f"keyword={keyword} radius={spot_radius_m}m limit={spot_limit} " + f"include_dong={include_dong}" ) rows = await asyncio.to_thread( _query_kakao_store_by_coord, spot_lat, spot_lon, keyword, spot_radius_m, spot_limit ) - print(f"[all_competitors:spot] 최종 {len(rows)}개") - return rows + seen_ids: set = {r.get("id") for r in rows} + spot_only_count = len(rows) + for r in await _gather_dong_rows(): + rid = r.get("id") + if rid in seen_ids: + continue + seen_ids.add(rid) + rows.append(r) + print( + f"[all_competitors:spot] 최종 {len(rows)}개 " + f"(spot {spot_only_count} + dong 추가 {len(rows) - spot_only_count})" + ) + return _filter_allowed(rows) # fallback — 4동 centroid 분기 (spot 좌표 결정 못 한 경우) districts = sorted({winner} | set(top3 or [])) # set ordering 결정성 @@ -573,9 +687,18 @@ async def _fetch_one(dong_name: str): print(f"[all_competitors] {dong_name} 수집 실패: {e}\n{traceback.format_exc()}") await asyncio.gather(*[_fetch_one(d) for d in districts]) + pre_dong_count = len(results) + # fallback 모드도 행정동 안 추가 매장 합치기 + for r in await _gather_dong_rows(): + rid = r.get("id") + if rid in seen_ids: + continue + seen_ids.add(rid) + results.append(r) print( f"[all_competitors] 단계별 — raw {_stats['raw']} / dedupe drop {_stats['dedupe_drop']} / " - f"coord drop {_stats['coord_drop']} / 최종 {len(results)}개" + f"coord drop {_stats['coord_drop']} / centroid {pre_dong_count} + dong 추가 " + f"{len(results) - pre_dong_count} / 최종 {len(results)}개" ) return results diff --git a/backend/src/services/brand_mapping_resolver.py b/backend/src/services/brand_mapping_resolver.py index a9693a01..70c92c42 100644 --- a/backend/src/services/brand_mapping_resolver.py +++ b/backend/src/services/brand_mapping_resolver.py @@ -11,6 +11,7 @@ import logging import os +import re from functools import lru_cache from sqlalchemy import text @@ -132,8 +133,35 @@ def get_all_mapo_stores_by_brand(brand_name: str) -> list[dict]: resolved = resolve_brand_name(brand_name) or brand_name # 1차: 하드코딩 alias 역추적, 그 다음 모든 정방향 변형 + 입력 자체 + DB resolved 포함 canonical = _REVERSE_ALIASES.get(resolved, resolved) - aliases = list(BRAND_ALIASES.get(canonical, [])) + [canonical, brand_name, resolved] - aliases = sorted(set(aliases)) + aliases_raw = list(BRAND_ALIASES.get(canonical, [])) + [canonical, brand_name, resolved] + + # 2차: FTC 표기 ↔ Kakao 표기 mismatch 보정. + # FTC brandNm 은 등록번호/연도 접미사 또는 괄호 영문 alias 가 붙는 경우 다수 + # (홍콩반점0410, 메가엠지씨커피(MEGA MGC COFFEE), 비비큐(BBQ) 등). + # kakao_store 는 일반 brand 명만 적재 → 다양한 변형 alias 추출하여 ILIKE 매칭 hit율 ↑. + extra_short: list[str] = [] + for a in aliases_raw: + if not a: + continue + # 끝 숫자 제거 (예: "홍콩반점0410" → "홍콩반점") + s1 = re.sub(r"\d+$", "", a).strip() + if s1 and s1 != a: + extra_short.append(s1) + # 괄호+내용 제거 — 한글 표기만 (예: "메가엠지씨커피(MEGA MGC COFFEE)" → "메가엠지씨커피") + s2 = re.sub(r"\s*\([^)]*\)\s*$", "", a).strip() + if s2 and s2 != a: + extra_short.append(s2) + # 괄호 안 영문/숫자 추출 — kakao_store 가 영문만 적재한 경우 (예: "비비큐(BBQ)" → "BBQ") + m = re.search(r"\(([A-Za-z0-9][A-Za-z0-9 &-]*)\)", a) + if m: + paren = m.group(1).strip() + if paren: + extra_short.append(paren) + # & 이후 suffix 제거 — kakao 가 첫 단어만 적재한 경우 (예: "본죽&비빔밥" → "본죽") + s4 = re.sub(r"\s*&.*$", "", a).strip() + if s4 and s4 != a: + extra_short.append(s4) + aliases = sorted(set(aliases_raw + extra_short)) conditions = " OR ".join(f"brand_name ILIKE :a{i}" for i in range(len(aliases))) sql = text( diff --git a/backend/src/services/corp_brand_resolver.py b/backend/src/services/corp_brand_resolver.py index 07373f9a..0c588ea4 100644 --- a/backend/src/services/corp_brand_resolver.py +++ b/backend/src/services/corp_brand_resolver.py @@ -75,6 +75,8 @@ def get_corp_industries(biz_number: str) -> dict: # ftc_brand_franchise 에서 corpNm 매칭 (정규화 ILIKE) # frcsCnt 큰 row 부터 정렬 — 같은 brand 의 다년 데이터는 max 사용 + # paper brand 차단 — frcsCnt > 0 인 운영 brand 만 후보. 0 인 brand 는 시뮬 의미 없음 + # (brand_profile.py 의 paper brand 가드와 동일 정책 — resolver 단계에서 미리 필터). rows = c.execute( sa.text( """ @@ -83,6 +85,7 @@ def get_corp_industries(biz_number: str) -> dict: WHERE "corpNm" IS NOT NULL AND REGEXP_REPLACE("corpNm", '\\(주\\)|㈜|주식회사|\\([^)]*\\)|\\s+', '', 'g') ILIKE :norm GROUP BY "brandNm", "indutyMlsfcNm" + HAVING MAX("frcsCnt") > 0 ORDER BY stores DESC NULLS LAST """ ), diff --git a/backend/src/simulation/agents.py b/backend/src/simulation/agents.py index 77cf7b62..92122393 100644 --- a/backend/src/simulation/agents.py +++ b/backend/src/simulation/agents.py @@ -117,6 +117,18 @@ class Agent: # DB 기반 개인 프로필 (전 tier 공통) profile: "AgentProfile | None" = None + # PersonaPool (Nemotron 7,187 개) 매칭 페르소나 (전 tier 공통, 선택적). + # spawn_agents 가 sex+age 매칭으로 부여. LLM prompt + UI PersonaCard 노출. + # 사용자 피드백 (2026-05-06): parquet 미통합 → spawn 시 매핑. + persona_uuid: str | None = None + occupation: str = "" # 예: "회계사", "대학원생" + education_level: str = "" # 예: "4년제 대학교" + persona_text: str = "" # 한 문단 요약 (Nemotron persona 컬럼) + hobbies: list[str] = field(default_factory=list) # 취미 list + professional_persona_text: str = "" # 직업 관련 상세 (Tier S LLM prompt 용) + cultural_background: str = "" # 문화적 배경 + career_goals_text: str = "" # 커리어 목표 + # 사회적 상호작용 (원시어 DSL 대화용) friends: list[int] = field(default_factory=list) pending_invites: list[dict] = field(default_factory=list) @@ -837,6 +849,24 @@ def make(role: Role, tier: Tier, prof) -> Agent: arrival_hour=arr_h, departure_hour=dep_h, ) + # PersonaPool inject — sex+age 매칭으로 Nemotron 7,187 풀에서 sample. + # 사용자 피드백 (2026-05-06): parquet 미통합 → spawn 시 매핑. + # 실패 (parquet 미존재 등) 시 무시 — agent 그대로 (필드는 default 빈 값). + try: + from .persona_pool import sample as _persona_sample + + _pp = _persona_sample(gender, age, rng) + if _pp is not None: + a.persona_uuid = _pp.uuid + a.occupation = _pp.occupation + a.education_level = _pp.education_level + a.persona_text = _pp.persona_text + a.hobbies = _pp.hobbies + a.professional_persona_text = _pp.professional_persona + a.cultural_background = _pp.cultural_background + a.career_goals_text = _pp.career_goals + except Exception: + pass aid += 1 return a diff --git a/backend/src/simulation/persona_pool.py b/backend/src/simulation/persona_pool.py new file mode 100644 index 00000000..7583e46c --- /dev/null +++ b/backend/src/simulation/persona_pool.py @@ -0,0 +1,217 @@ +"""PersonaPool — Nemotron 합성 페르소나 7,187 개 (마포) parquet 로드 + sex/age 매칭 sample. + +배경: + `data/processed/nemotron_personas_mapo.parquet` 에 마포 7,187 명 풍부한 페르소나 + (occupation, education, persona text, hobbies, professional/sports/arts/travel/ + culinary/family persona 등 26 컬럼). ABM `spawn_agents` 가 이전엔 ProfileBuilder + (RDS 기반 age/gender/dong 4 속성) 만 써서 풍부한 데이터 미사용. + +설계: + - parquet 1회 로드 (lru_cache 모듈 변수) + - sex+age_bucket 별 인덱스 사전 구축 (bucket 매칭 sample 빠르게) + - sample(sex, age) → PersonaProfile dict (occupation/persona/hobbies 등) + - 동일 RNG seed 재사용 — spawn_agents 와 같은 결정론적 sampling 보장 + +수동 동기화 X — parquet 만 교체하면 됨. RDS 의존성 0. + +사용자 피드백 (2026-05-06): 7,100 페르소나 있는데 왜 안 씀? → 통합. +""" + +from __future__ import annotations + +import logging +import random +from collections import defaultdict +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any + +import pandas as pd + +logger = logging.getLogger(__name__) + +_PROJECT_ROOT = Path(__file__).resolve().parents[3] +_PARQUET_PATH = _PROJECT_ROOT / "data" / "processed" / "nemotron_personas_mapo.parquet" + + +# AGE_BUCKETS — profile_builder.py 와 동일 키. 70+ 통합. +_AGE_BUCKETS: list[tuple[str, int, int]] = [ + ("20_24", 20, 24), + ("25_29", 25, 29), + ("30_34", 30, 34), + ("35_39", 35, 39), + ("40_44", 40, 44), + ("45_49", 45, 49), + ("50_54", 50, 54), + ("55_59", 55, 59), + ("60_64", 60, 64), + ("65_69", 65, 69), + ("70_plus", 70, 999), +] + + +def _age_to_bucket(age: int) -> str: + """나이 → bucket key. 19 이하/100 이상은 가장 가까운 bucket.""" + if age < 20: + return "20_24" + for k, lo, hi in _AGE_BUCKETS: + if lo <= age <= hi: + return k + return "70_plus" + + +def _normalize_sex(sex_raw: Any) -> str: + """parquet sex (한글 '남자'/'여자') → 'M'/'F'. 알 수 없으면 'M' 폴백.""" + if not isinstance(sex_raw, str): + return "M" + if "남" in sex_raw or sex_raw.upper() == "M": + return "M" + if "여" in sex_raw or sex_raw.upper() == "F": + return "F" + return "M" + + +@dataclass +class PersonaProfile: + """spawn_agents 가 Agent 에 inject 할 페르소나 속성.""" + + uuid: str + age: int + sex: str # 'M' / 'F' + occupation: str + education_level: str + persona_text: str # 한 문단 요약 + hobbies: list[str] # 취미 리스트 + professional_persona: str # 직업 관련 상세 + cultural_background: str + career_goals: str + raw: dict[str, Any] # 미파싱 원본 — 추후 LLM prompt 확장 용 + + +@lru_cache(maxsize=1) +def _load_dataframe() -> pd.DataFrame | None: + """parquet 1회 로드. 파일 없으면 None (PersonaPool 비활성).""" + if not _PARQUET_PATH.exists(): + logger.warning(f"[PersonaPool] parquet 미발견: {_PARQUET_PATH} — 비활성") + return None + try: + df = pd.read_parquet(_PARQUET_PATH) + logger.info(f"[PersonaPool] parquet 로드 완료: {len(df):,} 페르소나 ({len(df.columns)} 컬럼)") + return df + except Exception as e: + logger.exception(f"[PersonaPool] parquet 로드 실패: {e}") + return None + + +@lru_cache(maxsize=1) +def _build_index() -> dict[tuple[str, str], list[int]]: + """(sex, age_bucket) → DataFrame index list. 매 sample 시 lookup.""" + df = _load_dataframe() + if df is None: + return {} + idx: dict[tuple[str, str], list[int]] = defaultdict(list) + for i, row in df.iterrows(): + sex = _normalize_sex(row.get("sex")) + age = int(row.get("age") or 0) + bucket = _age_to_bucket(age) + idx[(sex, bucket)].append(int(i)) # type: ignore[arg-type] + return dict(idx) + + +def _safe_str(v: Any, default: str = "") -> str: + """None/NaN 안전 문자열화.""" + if v is None: + return default + try: + if pd.isna(v): + return default + except (TypeError, ValueError): + pass + return str(v) + + +def _parse_list(v: Any) -> list[str]: + """parquet list-like (str repr of list 또는 actual list) → list[str].""" + if v is None: + return [] + if isinstance(v, list): + return [str(x) for x in v if x] + if isinstance(v, str): + s = v.strip() + if s.startswith("[") and s.endswith("]"): + # "['a', 'b']" 형태 — ast.literal_eval 안전 파싱 + import ast + + try: + parsed = ast.literal_eval(s) + if isinstance(parsed, (list, tuple)): + return [str(x) for x in parsed if x] + except (ValueError, SyntaxError): + pass + # 그냥 텍스트 — comma split fallback + return [t.strip() for t in s.split(",") if t.strip()] + return [] + + +def _row_to_profile(row: pd.Series) -> PersonaProfile: + """DataFrame row → PersonaProfile.""" + raw = row.to_dict() + return PersonaProfile( + uuid=_safe_str(row.get("uuid")), + age=int(row.get("age") or 0), + sex=_normalize_sex(row.get("sex")), + occupation=_safe_str(row.get("occupation"), "알 수 없음"), + education_level=_safe_str(row.get("education_level"), "미상"), + persona_text=_safe_str(row.get("persona")), + hobbies=_parse_list(row.get("hobbies_and_interests_list")), + professional_persona=_safe_str(row.get("professional_persona")), + cultural_background=_safe_str(row.get("cultural_background")), + career_goals=_safe_str(row.get("career_goals_and_ambitions")), + raw=raw, + ) + + +def is_available() -> bool: + """parquet 로드 가능 여부 (spawn_agents 가 사전 체크 후 사용).""" + return _load_dataframe() is not None + + +def sample(sex: str, age: int, rng: random.Random | None = None) -> PersonaProfile | None: + """sex (M/F) + age 에 매칭되는 페르소나 1개 무작위 sample. + + 매칭: + 1) (sex, age_bucket) 정확 매칭 + 2) 비면 (sex, ANY bucket) + 3) 비면 None + """ + df = _load_dataframe() + if df is None: + return None + sex_norm = "M" if sex.upper().startswith("M") else "F" + bucket = _age_to_bucket(age) + idx_map = _build_index() + candidates = idx_map.get((sex_norm, bucket), []) + if not candidates: + # bucket fallback — 같은 sex 의 모든 row + candidates = [i for (s, _b), idxs in idx_map.items() if s == sex_norm for i in idxs] + if not candidates: + return None + rng = rng or random + chosen_idx = rng.choice(candidates) + return _row_to_profile(df.iloc[chosen_idx]) + + +def stats() -> dict[str, Any]: + """진단용 — 로드 상태 + bucket 분포 요약.""" + df = _load_dataframe() + if df is None: + return {"loaded": False} + idx = _build_index() + bucket_counts = {f"{s}/{b}": len(v) for (s, b), v in sorted(idx.items())} + return { + "loaded": True, + "total": len(df), + "buckets": bucket_counts, + "path": str(_PARQUET_PATH), + } diff --git a/backend/src/simulation/personas.py b/backend/src/simulation/personas.py index b9a0e64b..e1576c8e 100644 --- a/backend/src/simulation/personas.py +++ b/backend/src/simulation/personas.py @@ -166,8 +166,25 @@ def _build_profile(agent: Agent, arc: dict) -> str: 토큰 절감: 50 agents × system prompt cache hit 시 입력 비용 -70%. 축약: 거주→@, 소득N/3→incN, 예산→bud, 특성→tr, 소비→spd, 선호동→pref. Decision 다양성 보존 위해 핵심 trait·spending·preferred_dongs 유지. + + PersonaPool 매칭 시 (agent.persona_text 존재) occupation/페르소나 요약/취미 추가. + Tier S agent 만 정밀 prompt 받으므로 비용 영향 작음 (50 × extra ~40 tok). """ - return f"""마포 {agent.name} {agent.age}{agent.gender} @{agent.home_dong} inc{agent.income_level}/3 bud{int(agent.budget_today):,} -타입:{arc["label"]} tr:{arc["traits"]} spd:{arc["spending"]} pref:{",".join(arc["preferred_dongs"])} + base = f"""마포 {agent.name} {agent.age}{agent.gender} @{agent.home_dong} inc{agent.income_level}/3 bud{int(agent.budget_today):,} +타입:{arc["label"]} tr:{arc["traits"]} spd:{arc["spending"]} pref:{",".join(arc["preferred_dongs"])}""" + # PersonaPool inject — Nemotron 페르소나 매칭 시 직업/취미/요약 추가. + # 사용자 피드백 (2026-05-06): 풍부한 페르소나 LLM prompt 에 활용. + if getattr(agent, "persona_text", ""): + occ = (agent.occupation or "").strip()[:30] + hobbies = ",".join((agent.hobbies or [])[:3])[:60] + summary = (agent.persona_text or "").strip()[:120] + if occ or hobbies or summary: + base += f"\n직업:{occ} 취미:{hobbies}" + if summary: + base += f"\n요약:{summary}" + return ( + base + + """ 결정: 시간 위치 취향 예산 날씨. JSON: -{{"action":"visit|move|rest|work","target_dong":"동|null","category":"카페|음식점|편의점|주점|null","spend":원,"reason":"30자 fragment"}}""" +{"action":"visit|move|rest|work","target_dong":"동|null","category":"카페|음식점|편의점|주점|null","spend":원,"reason":"30자 fragment"}""" + ) diff --git a/backend/src/simulation/runner.py b/backend/src/simulation/runner.py index 30ea8a01..baa593df 100644 --- a/backend/src/simulation/runner.py +++ b/backend/src/simulation/runner.py @@ -1551,6 +1551,15 @@ def _huff_weight(dist_m: float, primary: float, secondary: float) -> float: "archetype": getattr(a, "persona_id", None) or "office_worker", "home_dong": getattr(a, "home_dong", None), "plan": list(getattr(a, "daily_plan", []) or []), + # PersonaPool (Nemotron 7,187) 매칭 페르소나 — UI PersonaCard 노출. + # 사용자 피드백 (2026-05-06): parquet 페르소나 통합. + "occupation": getattr(a, "occupation", "") or None, + "education_level": getattr(a, "education_level", "") or None, + "persona_text": getattr(a, "persona_text", "") or None, + "hobbies": list(getattr(a, "hobbies", []) or []), + "professional_persona": getattr(a, "professional_persona_text", "") or None, + "career_goals": getattr(a, "career_goals_text", "") or None, + "persona_uuid": getattr(a, "persona_uuid", None), } for a in thought_agents } diff --git a/docs/presentation/spotter-abm-db-briefing.pptx b/docs/presentation/spotter-abm-db-briefing.pptx new file mode 100644 index 00000000..8e4b4669 Binary files /dev/null and b/docs/presentation/spotter-abm-db-briefing.pptx differ diff --git a/docs/presentation/spotter-abm-db-v2.pptx b/docs/presentation/spotter-abm-db-v2.pptx new file mode 100644 index 00000000..b0eb2f7c Binary files /dev/null and b/docs/presentation/spotter-abm-db-v2.pptx differ diff --git a/docs/presentation/spotter-abm-db.pptx b/docs/presentation/spotter-abm-db.pptx new file mode 100644 index 00000000..10f68d95 Binary files /dev/null and b/docs/presentation/spotter-abm-db.pptx differ diff --git a/docs/presentation/spotter-status-briefing.pptx b/docs/presentation/spotter-status-briefing.pptx new file mode 100644 index 00000000..857d7ecc Binary files /dev/null and b/docs/presentation/spotter-status-briefing.pptx differ diff --git a/docs/retrospective/2026-05-05.md b/docs/retrospective/2026-05-05.md index 5e04c208..b5ddd63b 100644 --- a/docs/retrospective/2026-05-05.md +++ b/docs/retrospective/2026-05-05.md @@ -609,3 +609,472 @@ ``` --- + +## 12:51:50 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +++++++++--------- + docs/retrospective/2026-05-05.md | 9 +++++++++ + 2 files changed, 18 insertions(+), 9 deletions(-) +``` + +--- + +## 14:01:30 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +++++++++--------- + docs/retrospective/2026-05-05.md | 24 ++++++++++++++++++++++++ + 2 files changed, 33 insertions(+), 9 deletions(-) +``` + +--- + +## 14:06:29 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 ++++++++-------- + docs/retrospective/2026-05-05.md | 39 ++++++++++++++++++++++++++++++++++ + 2 files changed, 48 insertions(+), 9 deletions(-) +``` + +--- + +## 16:16:57 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 ++++++------ + docs/retrospective/2026-05-05.md | 54 ++++++++++++++++++++++++++++++++++ + 2 files changed, 63 insertions(+), 9 deletions(-) +``` + +--- + +## 16:19:54 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 ++++----- + docs/retrospective/2026-05-05.md | 69 ++++++++++++++++++++++++++++++++++ + 2 files changed, 78 insertions(+), 9 deletions(-) +``` + +--- + +## 17:46:56 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 ++++---- + docs/retrospective/2026-05-05.md | 84 ++++++++++++++++++++++++++++++++++ + 2 files changed, 93 insertions(+), 9 deletions(-) +``` + +--- + +## 17:48:14 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +++---- + docs/retrospective/2026-05-05.md | 99 ++++++++++++++++++++++++++++++++++ + 2 files changed, 108 insertions(+), 9 deletions(-) +``` + +--- + +## 17:51:20 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +++--- + docs/retrospective/2026-05-05.md | 114 +++++++++++++++++++++++++++++++++ + 2 files changed, 123 insertions(+), 9 deletions(-) +``` + +--- + +## 17:53:30 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 ++--- + docs/retrospective/2026-05-05.md | 129 +++++++++++++++++++++++++++++++++ + 2 files changed, 138 insertions(+), 9 deletions(-) +``` + +--- + +## 18:02:55 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- docs/issues/README.md +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 ++-- + backend/src/main.py | 57 +++++++++++++ + backend/src/schemas/simulation_input.py | 7 ++ + docs/issues/README.md | 1 + + docs/retrospective/2026-05-05.md | 147 ++++++++++++++++++++++++++++++++ + 5 files changed, 221 insertions(+), 9 deletions(-) +``` + +--- + +## 18:03:17 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- docs/issues/README.md +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 ++-- + backend/src/main.py | 57 +++++++++++ + backend/src/schemas/simulation_input.py | 7 ++ + docs/issues/README.md | 1 + + docs/retrospective/2026-05-05.md | 168 ++++++++++++++++++++++++++++++++ + 5 files changed, 242 insertions(+), 9 deletions(-) +``` + +--- + +## 18:19:53 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- docs/issues/README.md +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +-- + backend/src/main.py | 125 +++++++++++++++++++-- + backend/src/schemas/simulation_input.py | 7 ++ + docs/issues/README.md | 1 + + docs/retrospective/2026-05-05.md | 189 ++++++++++++++++++++++++++++++++ + 5 files changed, 323 insertions(+), 17 deletions(-) +``` + +--- + +## 18:20:39 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- docs/issues/README.md +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +-- + backend/src/main.py | 125 +++++++++++++++++-- + backend/src/schemas/simulation_input.py | 7 ++ + docs/issues/README.md | 1 + + docs/retrospective/2026-05-05.md | 210 ++++++++++++++++++++++++++++++++ + 5 files changed, 344 insertions(+), 17 deletions(-) +``` + +--- + +## 18:26:13 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/database/models.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- backend/src/services/auth.py +- backend/src/services/jwt_auth.py +- docs/issues/README.md +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +-- + backend/src/database/models.py | 7 + + backend/src/main.py | 125 +++++++++++++++-- + backend/src/schemas/simulation_input.py | 7 + + backend/src/services/auth.py | 3 +- + backend/src/services/jwt_auth.py | 11 +- + docs/issues/README.md | 1 + + docs/retrospective/2026-05-05.md | 234 ++++++++++++++++++++++++++++++++ + 8 files changed, 386 insertions(+), 20 deletions(-) +``` + +--- + +## 18:36:30 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +-- + backend/src/main.py | 125 +++++++++++++++- + backend/src/schemas/simulation_input.py | 7 + + docs/retrospective/2026-05-05.md | 257 ++++++++++++++++++++++++++++++++ + 4 files changed, 390 insertions(+), 17 deletions(-) +``` + +--- + +## 18:38:37 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +-- + backend/src/main.py | 125 ++++++++++++++- + backend/src/schemas/simulation_input.py | 7 + + docs/retrospective/2026-05-05.md | 276 ++++++++++++++++++++++++++++++++ + 4 files changed, 409 insertions(+), 17 deletions(-) +``` + +--- + +## 18:40:48 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/main.py | 125 +++++++++++++- + backend/src/schemas/simulation_input.py | 7 + + docs/retrospective/2026-05-05.md | 295 ++++++++++++++++++++++++++++++++ + 4 files changed, 428 insertions(+), 17 deletions(-) +``` + +--- + +## 18:42:32 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/main.py | 125 ++++++++++++- + backend/src/schemas/simulation_input.py | 7 + + docs/retrospective/2026-05-05.md | 314 ++++++++++++++++++++++++++++++++ + 4 files changed, 447 insertions(+), 17 deletions(-) +``` + +--- + +## 22:04:25 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- backend/src/services/brand_mapping_resolver.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/main.py | 125 ++++++++- + backend/src/schemas/simulation_input.py | 7 + + backend/src/services/brand_mapping_resolver.py | 21 +- + docs/retrospective/2026-05-05.md | 334 +++++++++++++++++++++++++ + 5 files changed, 486 insertions(+), 19 deletions(-) +``` + +--- + +## 22:04:57 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- backend/src/services/brand_mapping_resolver.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/main.py | 125 ++++++++- + backend/src/schemas/simulation_input.py | 7 + + backend/src/services/brand_mapping_resolver.py | 21 +- + docs/retrospective/2026-05-05.md | 355 +++++++++++++++++++++++++ + 5 files changed, 507 insertions(+), 19 deletions(-) +``` + +--- + +## 22:06:15 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- backend/src/services/brand_mapping_resolver.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/main.py | 125 +++++++- + backend/src/schemas/simulation_input.py | 7 + + backend/src/services/brand_mapping_resolver.py | 21 +- + docs/retrospective/2026-05-05.md | 376 +++++++++++++++++++++++++ + 5 files changed, 528 insertions(+), 19 deletions(-) +``` + +--- + +## 22:14:41 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- backend/src/services/brand_mapping_resolver.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/main.py | 125 +++++++- + backend/src/schemas/simulation_input.py | 7 + + backend/src/services/brand_mapping_resolver.py | 21 +- + docs/retrospective/2026-05-05.md | 397 +++++++++++++++++++++++++ + 5 files changed, 549 insertions(+), 19 deletions(-) +``` + +--- + +## 22:22:13 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- backend/src/services/brand_mapping_resolver.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/main.py | 125 +++++++- + backend/src/schemas/simulation_input.py | 7 + + backend/src/services/brand_mapping_resolver.py | 21 +- + docs/retrospective/2026-05-05.md | 418 +++++++++++++++++++++++++ + 5 files changed, 570 insertions(+), 19 deletions(-) +``` + +--- + +## 22:24:37 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- backend/src/services/brand_mapping_resolver.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/main.py | 125 ++++++- + backend/src/schemas/simulation_input.py | 7 + + backend/src/services/brand_mapping_resolver.py | 21 +- + docs/retrospective/2026-05-05.md | 439 +++++++++++++++++++++++++ + 5 files changed, 591 insertions(+), 19 deletions(-) +``` + +--- + +## 22:43:39 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- backend/src/services/brand_mapping_resolver.py +- docs/retrospective/2026-05-05.md + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/main.py | 125 ++++++- + backend/src/schemas/simulation_input.py | 7 + + backend/src/services/brand_mapping_resolver.py | 21 +- + docs/retrospective/2026-05-05.md | 460 +++++++++++++++++++++++++ + 5 files changed, 612 insertions(+), 19 deletions(-) +``` + +--- diff --git a/docs/retrospective/2026-05-06.md b/docs/retrospective/2026-05-06.md new file mode 100644 index 00000000..83aa46c0 --- /dev/null +++ b/docs/retrospective/2026-05-06.md @@ -0,0 +1,1474 @@ + +## 01:02:10 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- backend/src/services/brand_mapping_resolver.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/main.py | 132 +++++- + backend/src/schemas/simulation_input.py | 7 + + backend/src/services/brand_mapping_resolver.py | 21 +- + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + 7 files changed, 666 insertions(+), 24 deletions(-) +``` + +--- + +## 12:03:21 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/schemas/simulation_input.py +- backend/src/services/brand_mapping_resolver.py +- docs/retrospective/2026-05-05.md +- frontend/src/App.tsx +- frontend/src/api/client.ts +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/main.py | 187 +++++++- + backend/src/schemas/simulation_input.py | 7 + + backend/src/services/brand_mapping_resolver.py | 21 +- + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/App.tsx | 83 +++- + frontend/src/api/client.ts | 24 ++ + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + 9 files changed, 807 insertions(+), 45 deletions(-) +``` + +--- + +## 13:30:24 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 21 +- + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + 5 files changed, 535 insertions(+), 16 deletions(-) +``` + +--- + +## 13:33:47 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 21 +- + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + 5 files changed, 535 insertions(+), 16 deletions(-) +``` + +--- + +## 13:33:56 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 21 +- + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + 5 files changed, 535 insertions(+), 16 deletions(-) +``` + +--- + +## 13:42:14 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 21 +- + backend/src/services/corp_brand_resolver.py | 3 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + 6 files changed, 538 insertions(+), 16 deletions(-) +``` + +--- + +## 13:45:46 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 21 +- + backend/src/services/corp_brand_resolver.py | 3 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + 6 files changed, 538 insertions(+), 16 deletions(-) +``` + +--- + +## 13:47:18 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 28 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + 7 files changed, 575 insertions(+), 16 deletions(-) +``` + +--- + +## 13:48:44 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/PersonaCard.tsx | 10 + + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + 10 files changed, 618 insertions(+), 19 deletions(-) +``` + +--- + +## 13:49:52 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 21 + + frontend/src/components/PersonaCard.tsx | 83 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + 11 files changed, 710 insertions(+), 21 deletions(-) +``` + +--- + +## 13:50:41 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 21 + + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + 11 files changed, 713 insertions(+), 21 deletions(-) +``` + +--- + +## 14:06:23 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 21 + + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + 11 files changed, 713 insertions(+), 21 deletions(-) +``` + +--- + +## 14:14:57 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 21 + + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + 11 files changed, 713 insertions(+), 21 deletions(-) +``` + +--- + +## 14:15:55 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 21 + + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + 11 files changed, 713 insertions(+), 21 deletions(-) +``` + +--- + +## 14:19:04 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 21 + + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 12 files changed, 741 insertions(+), 29 deletions(-) +``` + +--- + +## 14:21:19 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 21 + + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 12 files changed, 741 insertions(+), 29 deletions(-) +``` + +--- + +## 14:39:36 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 21 + + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 12 files changed, 741 insertions(+), 29 deletions(-) +``` + +--- + +## 16:17:33 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 21 + + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 12 files changed, 741 insertions(+), 29 deletions(-) +``` + +--- + +## 16:26:15 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 14 +- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 13 files changed, 767 insertions(+), 41 deletions(-) +``` + +--- + +## 16:41:44 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 13 files changed, 816 insertions(+), 51 deletions(-) +``` + +--- + +## 16:42:40 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 13 files changed, 816 insertions(+), 51 deletions(-) +``` + +--- + +## 16:44:03 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 13 files changed, 816 insertions(+), 51 deletions(-) +``` + +--- + +## 16:50:36 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 14 files changed, 822 insertions(+), 51 deletions(-) +``` + +--- + +## 17:20:22 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 14 files changed, 822 insertions(+), 51 deletions(-) +``` + +--- + +## 17:37:07 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 14 files changed, 822 insertions(+), 51 deletions(-) +``` + +--- + +## 17:39:00 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 14 files changed, 822 insertions(+), 51 deletions(-) +``` + +--- + +## 17:42:35 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 14 files changed, 822 insertions(+), 51 deletions(-) +``` + +--- + +## 17:44:00 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 14 files changed, 822 insertions(+), 51 deletions(-) +``` + +--- + +## 17:46:50 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 14 files changed, 822 insertions(+), 51 deletions(-) +``` + +--- + +## 17:52:00 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 14 files changed, 822 insertions(+), 51 deletions(-) +``` + +--- + +## 18:51:54 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 14 files changed, 822 insertions(+), 51 deletions(-) +``` + +--- + +## 18:53:32 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 14 files changed, 822 insertions(+), 51 deletions(-) +``` + +--- + +## 18:54:32 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 14 files changed, 822 insertions(+), 51 deletions(-) +``` + +--- + +## 18:58:23 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 14 files changed, 822 insertions(+), 51 deletions(-) +``` + +--- + +## 19:01:54 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 14 files changed, 822 insertions(+), 51 deletions(-) +``` + +--- + +## 19:04:24 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 14 files changed, 822 insertions(+), 51 deletions(-) +``` + +--- + +## 19:10:39 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/main.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/main.py | 54 +++ + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 19 + + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 15 files changed, 876 insertions(+), 51 deletions(-) +``` + +--- + +## 19:17:43 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/config/constants.py +- backend/src/main.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/config/constants.py | 25 ++ + backend/src/main.py | 123 +++++- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 26 +- + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 16 files changed, 965 insertions(+), 63 deletions(-) +``` + +--- + +## 19:23:04 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/config/constants.py +- backend/src/main.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/config/constants.py | 25 ++ + backend/src/main.py | 123 +++++- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 26 +- + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 16 files changed, 965 insertions(+), 63 deletions(-) +``` + +--- + +## 19:26:30 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/config/constants.py +- backend/src/main.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/config/constants.py | 25 ++ + backend/src/main.py | 123 +++++- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 26 +- + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 16 files changed, 965 insertions(+), 63 deletions(-) +``` + +--- + +## 19:36:26 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/config/constants.py +- backend/src/main.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/config/constants.py | 25 ++ + backend/src/main.py | 147 ++++++- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 26 +- + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 16 files changed, 985 insertions(+), 67 deletions(-) +``` + +--- + +## 19:39:05 세션 완료 + +### 변경 파일 +- backend/src/agents/legal/categories.py +- backend/src/config/constants.py +- backend/src/main.py +- backend/src/services/brand_mapping_resolver.py +- backend/src/services/corp_brand_resolver.py +- backend/src/simulation/agents.py +- backend/src/simulation/personas.py +- backend/src/simulation/runner.py +- docs/retrospective/2026-05-05.md +- frontend/src/components/AbmPersonaMap.tsx +- frontend/src/components/PersonaCard.tsx +- frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +- frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +- frontend/src/components/SimulationResult/sections/MapSection.tsx +- frontend/src/components/SimulationResult/sections/MarketMap.tsx +- frontend/src/stores/abmStore.ts + +### diff 요약 +``` + backend/src/agents/legal/categories.py | 18 +- + backend/src/config/constants.py | 25 ++ + backend/src/main.py | 147 ++++++- + backend/src/services/brand_mapping_resolver.py | 32 +- + backend/src/services/corp_brand_resolver.py | 3 + + backend/src/simulation/agents.py | 30 ++ + backend/src/simulation/personas.py | 23 +- + backend/src/simulation/runner.py | 9 + + docs/retrospective/2026-05-05.md | 469 +++++++++++++++++++++ + frontend/src/components/AbmPersonaMap.tsx | 45 +- + frontend/src/components/PersonaCard.tsx | 86 +++- + .../SimulationResult/dashboard/DashboardHub.tsx | 6 + + .../SimulationResult/dashboard/tabs/AbmTab.tsx | 73 +++- + .../SimulationResult/sections/MapSection.tsx | 26 +- + .../SimulationResult/sections/MarketMap.tsx | 24 +- + frontend/src/stores/abmStore.ts | 36 +- + 16 files changed, 985 insertions(+), 67 deletions(-) +``` + +--- diff --git a/frontend/src/components/AbmPersonaMap.tsx b/frontend/src/components/AbmPersonaMap.tsx index f911d296..d144dbd5 100644 --- a/frontend/src/components/AbmPersonaMap.tsx +++ b/frontend/src/components/AbmPersonaMap.tsx @@ -148,6 +148,15 @@ export interface AbmTierSMeta { archetype: string; home_dong: string | null; plan: AbmPlanSlot[]; + // PersonaPool (Nemotron 7,187) 매칭 페르소나 — backend runner.py 가 채움. + // 사용자 피드백 (2026-05-06): 풍부한 persona 풀 통합 → UI 노출. + occupation?: string | null; + education_level?: string | null; + persona_text?: string | null; + hobbies?: string[]; + professional_persona?: string | null; + career_goals?: string | null; + persona_uuid?: string | null; } // 4950 non-Tier-S 에이전트의 시간별 위치 집계 — 히트맵 렌더용. @@ -1579,11 +1588,23 @@ export default function AbmPersonaMap({ const archetype = thoughts[0]?.archetype || ''; const path = trajectoryPathsRef.current.get(aid); const role = path && path[0] ? path[0].role : undefined; + const meta = tierSMeta.get(aid); setSelectedPersona({ agentId: aid, archetype, thoughts, role, + // PersonaPool 매칭 페르소나 — 사용자 피드백 (2026-05-06). + name: meta?.name ?? undefined, + age: meta?.age ?? undefined, + gender: meta?.gender ?? undefined, + occupation: meta?.occupation ?? undefined, + educationLevel: meta?.education_level ?? undefined, + personaText: meta?.persona_text ?? undefined, + hobbies: meta?.hobbies ?? undefined, + professionalPersona: meta?.professional_persona ?? undefined, + careerGoals: meta?.career_goals ?? undefined, + dongName: meta?.home_dong ?? undefined, }); }); }; @@ -1873,14 +1894,24 @@ export default function AbmPersonaMap({ continue; } // log-scale intensity — 저활성 셀도 visible (linear 은 핫스팟 가려짐). - // log(1+v) / log(1+maxC) 라 v=1 도 충분한 밝기 확보. const logIntensity = Math.log(1 + v) / Math.log(1 + maxC); - // 색 보간: pale blue (저, 톤다운) → medium blue (고). 너무 deep 하면 cluster 시 - // 검은 blob 처럼 보임 (사용자 피드백 2026-05-04). hue 유지하되 max 명도 ↑ + alpha cap. - const r = Math.round(220 + (37 - 220) * logIntensity); - const g = Math.round(230 + (99 - 230) * logIntensity); - const b = Math.round(250 + (235 - 250) * logIntensity); - const alpha = 0.3 + 0.4 * logIntensity; // 0.3 ~ 0.7 cap (검은 blob 방지) + // 사용자 피드백 (2026-05-06): hot 셀 진하기 강화. cool→warm gradient + // (pale blue → 주황) + alpha 범위 확대 (0.3~0.85) — hot 명확히 구분. + let r: number, g: number, b: number; + if (logIntensity < 0.5) { + // cool: pale blue (220,230,250) → medium blue (80,130,235) + const t = logIntensity * 2; // 0~1 + r = Math.round(220 + (80 - 220) * t); + g = Math.round(230 + (130 - 230) * t); + b = Math.round(250 + (235 - 250) * t); + } else { + // warm: medium blue (80,130,235) → orange-red (255,80,40) + const t = (logIntensity - 0.5) * 2; // 0~1 + r = Math.round(80 + (255 - 80) * t); + g = Math.round(130 + (80 - 130) * t); + b = Math.round(235 + (40 - 235) * t); + } + const alpha = 0.3 + 0.55 * logIntensity; // 0.3 ~ 0.85 — hot 더 진함 ctx.fillStyle = `rgba(${r},${g},${b},${alpha.toFixed(2)})`; ctx.translate(hex.x, hex.y); ctx.fill(hexPath); diff --git a/frontend/src/components/AgentMapVisualizer.tsx b/frontend/src/components/AgentMapVisualizer.tsx index 63182bfb..bd98098f 100644 --- a/frontend/src/components/AgentMapVisualizer.tsx +++ b/frontend/src/components/AgentMapVisualizer.tsx @@ -68,6 +68,50 @@ export default function AgentMapVisualizer({ const [selectedCompetitorId, setSelectedCompetitorId] = useState(null); // 비교 반경 (m) — 사용자 조절. 클릭한 경쟁업체의 가장 가까운 공실 spot 주변에 원 그림. const [comparisonRadius, setComparisonRadius] = useState(500); + // 사용자 피드백 (2026-05-06): 공실 spot hover 시 주소/정보 popup. address 는 lazy fetch + cache. + const [hoveredSpotId, setHoveredSpotId] = useState(null); + const [spotAddresses, setSpotAddresses] = useState>({}); + + // hover 된 spot 의 주소 lazy fetch (Kakao reverse geocode). 이미 fetch 했으면 skip. + useEffect(() => { + if (hoveredSpotId === null) return; + if (spotAddresses[hoveredSpotId]) return; + const loc = locations.find((l) => l.id === hoveredSpotId); + if (!loc || loc.type !== 'vacancy') return; + interface KakaoCoord2AddrResult { + road_address?: { address_name?: string } | null; + address?: { address_name?: string } | null; + } + interface KakaoServicesGlobal { + kakao?: { + maps?: { + services?: { + Geocoder: new () => { + coord2Address: ( + lng: number, + lat: number, + cb: ( + results: KakaoCoord2AddrResult[], + status: 'OK' | 'ZERO_RESULT' | 'ERROR', + ) => void, + ) => void; + }; + }; + }; + }; + } + const services = (window as unknown as KakaoServicesGlobal).kakao?.maps?.services; + if (!services?.Geocoder) return; + const geocoder = new services.Geocoder(); + geocoder.coord2Address(loc.lng, loc.lat, (results, status) => { + if (status !== 'OK' || !results.length) return; + const first = results[0]; + const addr = first.road_address?.address_name || first.address?.address_name || ''; + if (addr) { + setSpotAddresses((prev) => ({ ...prev, [loc.id]: addr })); + } + }); + }, [hoveredSpotId, locations, spotAddresses]); // Kakao Circle 인스턴스 ref — selected 변경 시 destroy + 재생성. // eslint-disable-next-line @typescript-eslint/no-explicit-any const radiusCircleRef = useRef(null); @@ -315,23 +359,76 @@ export default function AgentMapVisualizer({ const vacancyNumber = locations .slice(0, idx + 1) .filter((l) => l.type === 'vacancy').length; + const isHovered = hoveredSpotId === loc.id; + const addr = spotAddresses[loc.id]; return ( - + + {isHovered && ( +
+
+ + 공실 spot #{vacancyNumber} + + {typeof loc.score === 'number' && ( + + score {loc.score.toFixed(1)} + + )} +
+
+ {loc.name} +
+ {addr ? ( +
+ {addr} +
+ ) : ( +
+ 주소 조회 중… +
+ )} +
+ {typeof loc.listingCount === 'number' && loc.listingCount > 0 && ( + + 매물 {loc.listingCount}건 + + )} + + {loc.lat.toFixed(5)}, {loc.lng.toFixed(5)} + +
+ {loc.reason && ( +
+ 💡 {loc.reason} +
+ )} + {onSpotClick && ( +
+ 클릭 → ABM 시뮬 +
+ )} +
+ )} + ); } diff --git a/frontend/src/components/PersonaCard.tsx b/frontend/src/components/PersonaCard.tsx index 6e7ac432..c2b7adbd 100644 --- a/frontend/src/components/PersonaCard.tsx +++ b/frontend/src/components/PersonaCard.tsx @@ -14,6 +14,16 @@ export interface PersonaCardData { thoughts: AbmThought[]; // 0~23h 정렬된 thought 배열 dongName?: string; // 가장 최근 hour 의 dong 추정값 role?: string; + // PersonaPool (Nemotron 7,187) 매칭 페르소나 — 사용자 피드백 (2026-05-06). + name?: string; + age?: number; + gender?: string; + occupation?: string; + educationLevel?: string; + personaText?: string; + hobbies?: string[]; + professionalPersona?: string; + careerGoals?: string; } interface PersonaCardProps { @@ -60,9 +70,15 @@ export default function PersonaCard({ data, onClose, currentHour }: PersonaCardP Agent #{data.agentId}
- {data.archetype || '—'} + {data.name || data.archetype || '—'} + {(data.age || data.gender) && ( + + {data.age && `${data.age}세`} + {data.gender && ` · ${data.gender === 'M' ? '남' : '여'}`} + + )}
-
+
{data.role && {data.role}} {data.dongName && ( <> @@ -70,10 +86,76 @@ export default function PersonaCard({ data, onClose, currentHour }: PersonaCardP {data.dongName} )} + {data.occupation && ( + <> + · + {data.occupation} + + )} + {data.educationLevel && ( + <> + · + {data.educationLevel} + + )}
+ {/* PersonaPool (Nemotron) 매칭 페르소나 상세 — 있을 때만. */} + {(data.personaText || + data.hobbies?.length || + data.professionalPersona || + data.careerGoals) && ( +
+ {data.personaText && ( +
+
+ 페르소나 +
+

{data.personaText}

+
+ )} + {data.hobbies && data.hobbies.length > 0 && ( +
+
+ 취미·관심 +
+
+ {data.hobbies.map((h, i) => ( + + {h} + + ))} +
+
+ )} + {data.professionalPersona && ( +
+ + 직업 상세 ▾ + +

+ {data.professionalPersona} +

+
+ )} + {data.careerGoals && ( +
+ + 커리어 목표 ▾ + +

+ {data.careerGoals} +

+
+ )} +
+ )} + {/* 시간별 thought 타임라인 — 0~23h 그리드 */}
diff --git a/frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx b/frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx index 10579a57..ca03ad0d 100644 --- a/frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx +++ b/frontend/src/components/SimulationResult/dashboard/DashboardHub.tsx @@ -242,6 +242,9 @@ export function DashboardHub({ imgAlt="사람들이 다니는 거리" accent="abm" ctaLabel="시뮬 실행" + disabled={isAnalyzeDisabled} + disabledReason={analyzeError ?? undefined} + loading={isAnalyzeLoading} /> ) : ( )}
diff --git a/frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx b/frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx index 7490430c..ae205f96 100644 --- a/frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx +++ b/frontend/src/components/SimulationResult/dashboard/tabs/AbmTab.tsx @@ -64,35 +64,49 @@ export function AbmTab({ simResult, brandName, businessType, storeArea }: Props) const abmLoading = abmStatus === 'running'; // mount 시 persist 복원된 running jobId 가 있으면 polling 재개. - // running 일 때만 ABM 모드 자동 진입 — done 결과는 map 모드에서 - // 공실 스팟 다시 고를 수 있도록 자동 진입 제외 (사용자가 토글로 결과 확인). useEffect(() => { resumePollingIfNeeded(); - if (abmStatus === 'running' && focusSpot) { - setMode('abm'); - } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // 사용자 피드백 (2026-05-06): AbmFloatingWidget "ABM 결과로 이동" 클릭 시 spot select + // 화면이 아니라 선택 spot + 시나리오 페이지로 가야 함. abmStatus / focusSpot 변경 시 + // 자동으로 mode='abm' 전환 (이전엔 mount 시 1회만 → 위젯 click 후 재진입 시 안 먹힘). + useEffect(() => { + if ((abmStatus === 'running' || abmStatus === 'done') && focusSpot) { + setMode('abm'); + } + }, [abmStatus, focusSpot]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const r = simResult as any; const targetDistrict = r?.winner_district || r?.target_district || r?.target_districts?.[0] || '서교동'; - // 지도 마커 데이터 — 상권분석 페이지 (MapSection.buildBestVacancies) 와 동일 로직: - // winner_district 의 vacancy_spots 중 score 내림차순 top 4. 별도 추천 에이전트 출력 없음. - // recommended_vacancy_spots 가 있으면 그것 우선 (신규 에이전트 도입 시 자동 활용). + // 지도 마커 데이터 — 상권분석 MapSection.buildBestVacancies 와 동일 로직: + // winner 동 spot (score 정렬) + 부족분 top3 동 spot 으로 채움 (listing_count 정렬) + 50m dedup. + // 사용자 피드백 (2026-05-06): 이전엔 winner 만 filter 라 AI 추천 화면과 spot 달랐음 → + // top3 fallback 추가로 두 화면 일치. const winner: string | undefined = r?.winner_district || r?.target_district; const recommendedSpots = Array.isArray(r?.recommended_vacancy_spots) ? r.recommended_vacancy_spots.slice(0, 4) : []; + const top3List: string[] = Array.isArray(r?.top_3_candidates) + ? r.top_3_candidates.filter((d: unknown): d is string => typeof d === 'string') + : []; 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 + const validVacancy = allVacancySpots.filter( + (s: any) => + typeof s.lat === 'number' && + typeof s.lon === 'number' && + Number.isFinite(s.lat) && + Number.isFinite(s.lon), + ); + // 1) winner 동 spot — score 우선 + const winnerSorted = validVacancy // eslint-disable-next-line @typescript-eslint/no-explicit-any - .filter( - (s: any) => s.dong_name === winner && typeof s.lat === 'number' && typeof s.lon === 'number', - ) + .filter((s: any) => s.dong_name === winner) .slice() // eslint-disable-next-line @typescript-eslint/no-explicit-any .sort((a: any, b: any) => { @@ -100,8 +114,37 @@ export function AbmTab({ simResult, brandName, businessType, storeArea }: Props) 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); + }); + // 2) top3 동 spot (winner 제외) — listing_count 정렬 + const top3Set = new Set(top3List); + const top3Sorted = validVacancy + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .filter((s: any) => top3Set.has(String(s.dong_name)) && s.dong_name !== winner) + .slice() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .sort((a: any, b: any) => (b.listing_count ?? 0) - (a.listing_count ?? 0)); + // 3) merge + 50m dedup + const merged = [...winnerSorted, ...top3Sorted]; + const haversineM = (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)); + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dedupedTop4: any[] = []; + for (const cand of merged) { + const tooClose = dedupedTop4.some( + (k) => haversineM(k.lat, k.lon ?? k.lng, cand.lat, cand.lon ?? cand.lng) <= 50, + ); + if (!tooClose) dedupedTop4.push(cand); + if (dedupedTop4.length >= 4) break; + } + const winnerVacancySpots = dedupedTop4; const vacancySpots = recommendedSpots.length > 0 ? recommendedSpots : winnerVacancySpots; // 경쟁업체 — 상권분석 페이지 buildCompetitors 와 동일. all_competitor_locations 우선 (max 200), // fallback: competitor_intel.competition_500m.samples (max 100). diff --git a/frontend/src/components/SimulationResult/sections/DistrictRankings.tsx b/frontend/src/components/SimulationResult/sections/DistrictRankings.tsx index 66150349..0a94101c 100644 --- a/frontend/src/components/SimulationResult/sections/DistrictRankings.tsx +++ b/frontend/src/components/SimulationResult/sections/DistrictRankings.tsx @@ -67,15 +67,6 @@ export function DistrictRankings({ simResult }: Props) { 매출성장 - - 폐업위험 - - (ML예측) - - - - BEP - 용도지역 @@ -120,16 +111,6 @@ export function DistrictRankings({ simResult }: Props) { {/* backend qoq_growth는 이미 percent 단위 (tools.py:300 *100 적용). 추가 ×100 금지 */} {isExcluded ? '—' : `${r.sales_growth.toFixed(1)}%`} - - {isExcluded - ? '—' - : r.closure_rate != null - ? `${(r.closure_rate * 100).toFixed(1)}%` - : '—'} - - - {isExcluded ? '—' : r.bep_quarters != null ? `${r.bep_quarters}분기` : '—'} - - +
상권 지표 데이터 없음
@@ -99,20 +97,15 @@ export function IndicatorGrid({ simResult }: Props) { ); } - // 선택 동의 8 지표 추출 — winner 면 market_report 풀, 아니면 district_rankings 의 - // 동별 점수 필드(0~100 정규화, 16동 비교 가능) 로 매핑. 모든 동 8지표 표시 가능. - // 주의: winner 의 market_report 8지표와 다른 동의 ranking 점수는 산식이 다르므로 - // 직접 비교는 신중. 화면엔 안내 문구로 명시. - // closure_rate 은 0~1 fraction 이라 scale: 100 적용 후 0~100 점수화. - // survival_rate 는 winner 외 동에선 100 - closure_rate*100 으로 역산. + // 선택 동의 6 지표 추출 — winner 면 market_report 풀, 아니면 district_rankings 의 + // 동별 점수 필드(0~100 정규화, 16동 비교 가능) 로 매핑. + // 주의: winner 의 market_report 지표와 다른 동의 ranking 점수는 산식이 다름. 화면 안내 명시. const values = INDICATORS.map(({ key, label, shortLabel, scale }) => { let rawVal: unknown = null; - let appliedScale: number | undefined = scale; if (isWinnerSelected && report) { rawVal = (report as Record)[key]; } else if (selectedRanking) { // 동별 점수 매핑 — DistrictRanking (backend district_ranking_node) 의 0~100 점수 필드. - // keyof DistrictRanking 가 인덱스 시그니처로 string|number|symbol 이 되어 string 으로 좁힘. const rankingMap: Record> = { floating_population: 'pop_score', rent_index: 'rent_score', @@ -120,24 +113,16 @@ export function IndicatorGrid({ simResult }: Props) { estimated_revenue: 'sales_score', growth_potential: 'trend_score', accessibility: 'inflow_score', - closure_rate: 'closure_rate', }; const rankingKey = rankingMap[key]; if (rankingKey) { rawVal = (selectedRanking as Record)[rankingKey] ?? null; - } else if (key === 'survival_rate') { - // ranking 응답엔 survival_rate 가 없어 closure_rate (0~1 fraction) 으로 역산. - const cr = selectedRanking.closure_rate; - if (typeof cr === 'number' && Number.isFinite(cr)) { - rawVal = 100 - cr * 100; - appliedScale = undefined; // 이미 0~100 으로 변환됨 - } } } if (typeof rawVal !== 'number' || !Number.isFinite(rawVal)) { return { key, label, shortLabel, val: null as number | null }; } - const scaled = appliedScale ? rawVal * appliedScale : rawVal; + const scaled = scale ? rawVal * scale : rawVal; return { key, label, shortLabel, val: Math.max(0, Math.min(100, scaled)) }; }); @@ -156,7 +141,7 @@ export function IndicatorGrid({ simResult }: Props) {
{/* 헤더 row — SectionLabel + 동 chip selector (시뮬 1~4동, winner 첫번째). */}
- + {districtOrder.length > 0 && (
diff --git a/frontend/src/components/SimulationResult/sections/MapSection.tsx b/frontend/src/components/SimulationResult/sections/MapSection.tsx index 6d42c59b..e4b5735a 100644 --- a/frontend/src/components/SimulationResult/sections/MapSection.tsx +++ b/frontend/src/components/SimulationResult/sections/MapSection.tsx @@ -82,9 +82,8 @@ function buildCompetitors(simResult: SimulationOutput): Competitor[] { }); } // dedup — place_name + 좌표(소수 5자리) 동일하면 동일 매장으로 판단. - // cap 200 → 1000 으로 늘림 — backend 가 spot1 거리순 정렬로 보내는데 cap 200 이면 - // spot 2,3,4 주변 매장이 (spot1 기준 멀어서) 잘려나가 화면에 안 뜸. - // 4 spot × 1.5km 합집합이라 1000개 넘는 일은 사실상 없음 (마포 카페 전체 ~수백개). + // cap 2500 — backend 가 공실 spot 1.5km + 행정동 안 매장 합집합 반환. 4 dong × ~500/dong + // 최악 ~2000 (현재 마포 kakao_store 4430 / 16동). spot1 거리순 정렬 유지. const seen = new Set(); const deduped: Competitor[] = []; for (const c of merged) { @@ -92,7 +91,7 @@ function buildCompetitors(simResult: SimulationOutput): Competitor[] { if (seen.has(key)) continue; seen.add(key); deduped.push(c); - if (deduped.length >= 1000) break; + if (deduped.length >= 2500) break; } return deduped; } @@ -261,6 +260,25 @@ export function MapSection({ simResult, topCompetitors }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps [topCompetitors], ); + // DEBUG: 별표 안 뜨는 이슈 추적 — center vs 별표 좌표 비교 + // eslint-disable-next-line no-console + console.log( + '[MapSection] simResult.same_brand_locations:', + simResult.same_brand_locations, + '→ sameBrandLocations:', + sameBrandLocations.length, + 'items', + ); + // eslint-disable-next-line no-console + console.log( + '[MapSection] winner_district=', + (simResult as SimulationOutput & Record).winner_district, + 'center=', + center, + 'sameBrand[0] lat/lng=', + sameBrandLocations[0]?.lat, + sameBrandLocations[0]?.lng, + ); // 사용자 입력 영업구역 거리 — store.params 에서 직접 (응답에 echo 안 됨). const territoryRadiusM = useSimulationStore((s) => s.params?.territory_radius_m); diff --git a/frontend/src/components/SimulationResult/sections/MarketMap.tsx b/frontend/src/components/SimulationResult/sections/MarketMap.tsx index 14fe02be..5f628c65 100644 --- a/frontend/src/components/SimulationResult/sections/MarketMap.tsx +++ b/frontend/src/components/SimulationResult/sections/MarketMap.tsx @@ -60,8 +60,9 @@ function normalizeBrand(s: string | null | undefined): string { if (!s) return ''; return s .toLowerCase() - .replace(/\([^)]*\)/g, '') - .replace(/[\s\-_·.]/g, '') + .replace(/\([^)]*\)/g, '') // 괄호+내용 (예: "(MEGA MGC COFFEE)") + .replace(/[\s\-_·.]/g, '') // 공백/하이픈/언더스코어/middle-dot + .replace(/\d+$/, '') // 끝 숫자 (FTC 표기: "홍콩반점0410" → "홍콩반점") .trim(); } @@ -134,16 +135,6 @@ interface GeoCollection { features: GeoFeature[]; } -function rankingColor(score: number): string { - if (score >= 75) return '#10b981'; - if (score >= 55) return '#f59e0b'; - return '#6b7280'; -} - -function rankingOpacity(score: number): number { - return Math.max(0.08, Math.min(0.45, score / 220)); -} - const PULSE_STYLE_ID = 'mm-pulse-style'; const PULSE_CSS = ` @keyframes mm-pulse { @@ -365,18 +356,13 @@ export function MarketMap({ ); return; } - const rankingMap = new Map(rankings.map((r) => [r.district, r])); let winnerCentroid: { lat: number; lng: number } | null = null; geo.features.forEach((f) => { const dong = f.properties.dong_name; - const ranking = rankingMap.get(dong); - const score = ranking?.score; - const hasScore = typeof score === 'number'; const isWinner = dong === winnerDistrict; - // 실데이터 원칙: 랭킹 점수 없으면 빗금/투명 중립색 (기존 50 기본값 제거 — 점수 50 동과 구분) - // winner = sunshine-yellow (추천 강조, Trophy 와 통일). 12색 팔레트. - const fillColor = isWinner ? '#ffde00' : hasScore ? rankingColor(score) : '#27272a'; - const fillOpacity = isWinner ? 0.35 : hasScore ? rankingOpacity(score) : 0.08; + // 사용자 요청: winner(1위) 만 색칠. 다른 동은 점수 있어도 중립 회색 처리. + const fillColor = isWinner ? '#ffde00' : '#27272a'; + const fillOpacity = isWinner ? 0.35 : 0.08; const polygons: number[][][] = f.geometry.type === 'MultiPolygon' ? (f.geometry.coordinates as number[][][][]).flatMap((p) => p) @@ -548,13 +534,24 @@ export function MarketMap({ }); // Layer 3 — 자사 매장 마커 (로고 아이콘 별표 only — 영업구역 점선 원은 사용자 요구로 제거) + // DEBUG: 별표 안 뜨는 이슈 추적 — sameBrandLocations props 검사 + console.log( + '[MarketMap Layer 3] sameBrandLocations:', + sameBrandLocations.length, + 'items', + sameBrandLocations, + ); sameBrandLocations.forEach((s) => { - if (typeof s.lat !== 'number' || typeof s.lng !== 'number') return; + if (typeof s.lat !== 'number' || typeof s.lng !== 'number') { + console.warn('[MarketMap Layer 3] skip — bad lat/lng:', s); + return; + } const pos = new maps.LatLng(s.lat, s.lng); // 로고 아이콘 마커 — 금색 동그라미 + 작은 펄스 (자사 매장 표시). const logo = document.createElement('div'); + // DEBUG: 별표 안 보이는 이슈 — 디자인 강화 (크기↑ + 강한 그림자 + 외곽선 추가). logo.style.cssText = - 'position:relative;width:24px;height:24px;display:flex;align-items:center;justify-content:center;background:#fbbf24;border:2px solid #ffffff;border-radius:9999px;box-shadow:0 0 8px rgba(251,191,36,0.6);font-size:12px;font-weight:900;color:#1c1917;cursor:pointer;'; + 'position:relative;width:36px;height:36px;display:flex;align-items:center;justify-content:center;background:#fbbf24;border:3px solid #ffffff;border-radius:9999px;box-shadow:0 0 16px rgba(251,191,36,0.95),0 0 4px rgba(0,0,0,0.5);font-size:18px;font-weight:900;color:#1c1917;cursor:pointer;outline:2px solid #f59e0b;outline-offset:1px;'; logo.innerHTML = '★'; logo.title = `${s.brand_name || '자사매장'} · ${s.place_name}`; logo.addEventListener('click', (ev) => { @@ -588,10 +585,19 @@ export function MarketMap({ content: logo, xAnchor: 0.5, yAnchor: 0.5, - zIndex: 4, + zIndex: 100, // DEBUG: 가시성 강화 (4 → 100) }); sameBrandOverlay.setMap(mapInstance); overlayLayersRef.current.push(sameBrandOverlay); + // eslint-disable-next-line no-console + console.log( + '[MarketMap Layer 3] ★ overlay placed:', + s.place_name, + 'lat=', + s.lat, + 'lng=', + s.lng, + ); }); // Layer 4 — 추천 spot 2~4위 번호 라벨 핀 + 1위와 동일한 핫핑크 반경 원. diff --git a/frontend/src/stores/abmStore.ts b/frontend/src/stores/abmStore.ts index 61b9bfcc..d7a49951 100644 --- a/frontend/src/stores/abmStore.ts +++ b/frontend/src/stores/abmStore.ts @@ -370,16 +370,36 @@ export const useAbmStore = create()( if (get()._abortController !== _abortController) return; if (!res.ok) { - // 404/5xx — error 로 전환. - const msg = `ABM status 조회 실패 (HTTP ${res.status})`; + // 404 — backend 메모리 휘발 (재시작) or TTL cleanup. stale jobId. + // 5xx — backend 일시 오류. + const msg = + res.status === 404 + ? 'ABM 시뮬 정보 만료 (서버 재시작 또는 1시간 초과). 다시 실행하세요.' + : `ABM status 조회 실패 (HTTP ${res.status})`; const { _pollTimer } = get(); if (_pollTimer) clearInterval(_pollTimer); - set({ - status: 'error', - error: msg, - _abortController: null, - _pollTimer: null, - }); + // 404 면 idle 로 reset (error 화면 띄우지 말고 그냥 사라지게) — 사용자가 다시 실행하면 됨. + // 5xx 는 error 로 (재시도 유도). + if (res.status === 404) { + set({ + status: 'idle', + jobId: null, + result: null, + error: null, + progress: 0, + stage: '', + startedAt: null, + _abortController: null, + _pollTimer: null, + }); + } else { + set({ + status: 'error', + error: msg, + _abortController: null, + _pollTimer: null, + }); + } setTimeout(() => get()._processNextInQueue(), 0); return; } diff --git a/scripts/build_abm_db_briefing_pptx.py b/scripts/build_abm_db_briefing_pptx.py new file mode 100644 index 00000000..e15ad153 --- /dev/null +++ b/scripts/build_abm_db_briefing_pptx.py @@ -0,0 +1,599 @@ +"""SPOTTER ABM × DB 브리핑 PPT — 실측 지표 기반. + +기준: origin/dev (alembic a8f3d2e7c1b9), 2026-05-06 +산출: docs/presentation/spotter-abm-db-briefing.pptx + +실 코드 + DB 쿼리 검증: +- DB 총 5.3 GB, 87 public 테이블, 213 인덱스, 43 FK, 1,153 컬럼 +- ABM /simulate-abm: n_agents=5000, default policy mode (LLM 0회) +- RAG 임베딩 10,255건 (법률 57건 + 판례 222건) + +실행: python -m scripts.build_abm_db_briefing_pptx +""" + +from __future__ import annotations + +from pathlib import Path + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.util import Inches, Pt + +# --------------------------------------------------------------------------- +# 색 +# --------------------------------------------------------------------------- +BG = RGBColor(0x10, 0x14, 0x20) +PANEL = RGBColor(0x1A, 0x22, 0x36) +PANEL_ALT = RGBColor(0x12, 0x18, 0x28) +ACCENT = RGBColor(0x4F, 0xC3, 0xF7) +ACCENT_2 = RGBColor(0xFF, 0xB7, 0x4D) +TEXT = RGBColor(0xE3, 0xE9, 0xF4) +MUTED = RGBColor(0x9C, 0xA9, 0xBE) +GREEN = RGBColor(0x6E, 0xE7, 0xB7) +RED = RGBColor(0xFF, 0x9A, 0x9A) +PURPLE = RGBColor(0xC4, 0x9B, 0xFF) + + +# --------------------------------------------------------------------------- +# 헬퍼 +# --------------------------------------------------------------------------- +def add_dark_bg(slide): + bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, Inches(13.333), Inches(7.5)) + bg.fill.solid() + bg.fill.fore_color.rgb = BG + bg.line.fill.background() + bg.shadow.inherit = False + spTree = bg._element.getparent() + spTree.remove(bg._element) + spTree.insert(2, bg._element) + + +def add_text(slide, x, y, w, h, text, *, size=18, bold=False, color=TEXT): + box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h)) + tf = box.text_frame + tf.word_wrap = True + tf.margin_left = Inches(0.05) + tf.margin_right = Inches(0.05) + p = tf.paragraphs[0] + run = p.add_run() + run.text = text + run.font.size = Pt(size) + run.font.bold = bold + run.font.color.rgb = color + run.font.name = "Pretendard" + + +def add_bullets(slide, x, y, w, h, lines, *, size=12): + box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h)) + tf = box.text_frame + tf.word_wrap = True + tf.margin_left = Inches(0.05) + for i, item in enumerate(lines): + if isinstance(item, tuple): + txt, c = item + else: + txt, c = item, TEXT + p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() + run = p.add_run() + run.text = f"· {txt}" + run.font.size = Pt(size) + run.font.color.rgb = c + run.font.name = "Pretendard" + + +def add_table(slide, x, y, w, headers, rows, *, header_size=11, body_size=10, row_h=0.38): + table = slide.shapes.add_table( + len(rows) + 1, + len(headers), + Inches(x), + Inches(y), + Inches(w), + Inches(row_h * (len(rows) + 1) + 0.1), + ).table + for i, h in enumerate(headers): + cell = table.cell(0, i) + cell.text = h + for p in cell.text_frame.paragraphs: + for r in p.runs: + r.font.size = Pt(header_size) + r.font.bold = True + r.font.color.rgb = ACCENT + r.font.name = "Pretendard" + cell.fill.solid() + cell.fill.fore_color.rgb = PANEL + for r_idx, row in enumerate(rows, start=1): + for c_idx, val in enumerate(row): + cell = table.cell(r_idx, c_idx) + if isinstance(val, tuple): + txt, color = val + else: + txt, color = val, TEXT + cell.text = str(txt) + for p in cell.text_frame.paragraphs: + for run in p.runs: + run.font.size = Pt(body_size) + run.font.color.rgb = color + run.font.name = "Pretendard" + cell.fill.solid() + cell.fill.fore_color.rgb = PANEL_ALT + + +def add_band(slide, y, label, *, color=ACCENT): + band = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.5), Inches(y), Inches(12.3), Inches(0.04)) + band.fill.solid() + band.fill.fore_color.rgb = color + band.line.fill.background() + add_text(slide, 0.5, y + 0.05, 12, 0.4, label, size=14, bold=True, color=color) + + +def add_metric_card(slide, x, y, w, h, label, value, *, value_color=ACCENT): + """KPI 카드 — 라벨 + 큰 숫자.""" + card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h)) + card.fill.solid() + card.fill.fore_color.rgb = PANEL + card.line.color.rgb = PANEL + card.shadow.inherit = False + add_text(slide, x + 0.15, y + 0.08, w - 0.3, 0.35, label, size=11, color=MUTED) + add_text(slide, x + 0.15, y + 0.42, w - 0.3, 0.7, value, size=22, bold=True, color=value_color) + + +# --------------------------------------------------------------------------- +# Slide 1 — 표지 +# --------------------------------------------------------------------------- +def build_title(prs): + s = prs.slides.add_slide(prs.slide_layouts[6]) + add_dark_bg(s) + add_text(s, 0.7, 1.5, 12, 1.0, "SPOTTER", size=56, bold=True, color=ACCENT) + add_text(s, 0.7, 2.6, 12, 0.8, "ABM × DB 인프라 브리핑", size=32, bold=True, color=TEXT) + add_text( + s, + 0.7, + 3.5, + 12, + 0.5, + "기준 시점: 2026-05-06 · origin/dev · alembic a8f3d2e7c1b9", + size=15, + color=MUTED, + ) + add_band(s, 4.5, "구성") + add_bullets( + s, + 0.7, + 4.95, + 12, + 2.5, + [ + ("Slide 2 — ABM 시스템 개요 (5,000 에이전트 × 3-Tier × 4 mode)", TEXT), + ("Slide 3 — ABM 의사결정 흐름 + 메모리 시스템 (활성 / 비활성)", TEXT), + ("Slide 4 — DB 인프라 (87 테이블 / 5.3 GB / 213 인덱스)", TEXT), + ("Slide 5 — 데이터 자산 (마포 1,070만 / FTC 35K brand / RAG 10K 임베딩)", TEXT), + ("Slide 6 — 통합 흐름 + 정량 요약", TEXT), + ], + size=14, + ) + + +# --------------------------------------------------------------------------- +# Slide 2 — ABM 개요 +# --------------------------------------------------------------------------- +def build_abm_overview(prs): + s = prs.slides.add_slide(prs.slide_layouts[6]) + add_dark_bg(s) + add_text(s, 0.5, 0.3, 12, 0.7, "ABM 시스템 개요", size=28, bold=True, color=ACCENT) + add_text( + s, + 0.5, + 0.95, + 12, + 0.4, + "마포구 5,000 가상 에이전트가 매 시간 행동 결정 → 매장별 매출 추정", + size=13, + color=MUTED, + ) + + # KPI 카드 4 개 + add_metric_card(s, 0.5, 1.5, 3.0, 1.2, "에이전트 수", "5,000") + add_metric_card(s, 3.7, 1.5, 3.0, 1.2, "Tier", "S 50 / A 200 / B 4,750") + add_metric_card(s, 6.9, 1.5, 3.0, 1.2, "기본 LLM 호출", "0", value_color=GREEN) + add_metric_card(s, 10.1, 1.5, 2.7, 1.2, "Mode", "4") + + # 인구 구성 + add_band(s, 3.0, "Population Mix (마포 실측 비례, n_personas 비례 scale)") + add_table( + s, + 0.5, + 3.45, + 12.3, + ["역할", "비율", "근거", "행동 패턴"], + [ + ["residents (거주민)", "60%", "SGIS 361,380", "일상 생활 — 식사·카페·소비"], + ["ext_commuters (외부 통근)", "20%", "마포 사업체 종사 281,385 일부", "출근 시간 진입 / 퇴근 이탈"], + ["commuters (마포 내 통근)", "10%", "거주+근무 일치", "거주 동 ↔ 근무 동"], + ["ext_visitors (외부 방문)", "5%", "홍대·연남 야간 방문", "저녁 시간 진입"], + ["visitors (단기 방문)", "4%", "마포 내 단기 이동", "비정기 방문"], + ["owners (점주)", "1%", "운영 매장 보유", "9~22시 매장 상주"], + ], + body_size=10, + ) + + # 한 줄 요약 박스 + add_band(s, 6.4, "핵심 한 줄", color=ACCENT_2) + add_text( + s, + 0.5, + 6.85, + 12.3, + 0.5, + "에이전트 = 통계 분포 + 정책 기반 행동 + (옵션) Tier S 50명 LLM 추론", + size=14, + color=GREEN, + ) + + +# --------------------------------------------------------------------------- +# Slide 3 — ABM 의사결정 + 메모리 +# --------------------------------------------------------------------------- +def build_abm_decision(prs): + s = prs.slides.add_slide(prs.slide_layouts[6]) + add_dark_bg(s) + add_text(s, 0.5, 0.3, 12, 0.7, "ABM 의사결정 흐름", size=28, bold=True, color=ACCENT) + add_text( + s, + 0.5, + 0.95, + 12, + 0.4, + "agents.py:decide() 라우터 — 4 mode 분기. /simulate-abm 기본 = use_policy=True", + size=13, + color=MUTED, + ) + + # 4 mode 표 + add_band(s, 1.4, "4가지 Mode") + add_table( + s, + 0.5, + 1.85, + 12.3, + ["Mode", "Tier S (50)", "Tier A (200)", "Tier B (≈4,750)", "LLM 호출"], + [ + [ + ("default (use_policy=True)", GREEN), + "policy", + "policy", + "policy", + ("0", GREEN), + ], + [ + "enable_llm_decisions", + ("smart_decide (LLM)", ACCENT_2), + "policy", + "policy", + "Tier S 만 (≈1,200/sim)", + ], + [ + "풀 JSON 모드", + "smart_decide", + ("fast_decide (LLM)", ACCENT_2), + "rule", + "S+A", + ], + ["DSL 모드", "dsl_decide", "dsl_decide", "dsl_decide", ("전 Tier", RED)], + ], + row_h=0.42, + ) + + # 핵심 트릭 + add_band(s, 4.0, "비용 최소화 트릭") + add_bullets( + s, + 0.5, + 4.45, + 6.4, + 2.5, + [ + ("policy_generator — Ollama Qwen2.5:3b 로컬", TEXT), + (" → role × 날씨 × 시간대 = 11개 정책 cold gen", MUTED), + (" → policy_cache.json 저장 → 매 시뮬 LLM 0회", GREEN), + ("Archetype 30+ multiplier", TEXT), + (" → resident 7 / commuter 5 / visitor 4 ...", MUTED), + (" → 같은 role 안 행동 다양성 확보", MUTED), + ], + size=12, + ) + + # 메모리 시스템 표 + add_text(s, 7.0, 4.4, 6, 0.4, "메모리 / 보조 시스템 (현재 활성)", size=13, bold=True, color=ACCENT_2) + add_table( + s, + 7.0, + 4.85, + 6.0, + ["시스템", "활성"], + [ + ["MemoryStore (raw + 일일 요약)", ("✅ 사용", GREEN)], + ["policy_cache.json (LLM 0회 정책)", ("✅ 사용", GREEN)], + ["Archetype 30+ multiplier", ("✅ 사용", GREEN)], + ["Memory Seeder (14일 prefill)", ("❌ off", RED)], + ["PgVectorMemory (semantic search)", ("❌ off", RED)], + ], + body_size=11, + row_h=0.36, + ) + + +# --------------------------------------------------------------------------- +# Slide 4 — DB 인프라 +# --------------------------------------------------------------------------- +def build_db_infra(prs): + s = prs.slides.add_slide(prs.slide_layouts[6]) + add_dark_bg(s) + add_text(s, 0.5, 0.3, 12, 0.7, "DB 인프라", size=28, bold=True, color=ACCENT) + add_text( + s, + 0.5, + 0.95, + 12, + 0.4, + "PostgreSQL (RDS) · SQLAlchemy ORM · Alembic 마이그레이션", + size=13, + color=MUTED, + ) + + # KPI 5개 + add_metric_card(s, 0.5, 1.5, 2.4, 1.2, "DB 총 크기", "5.3 GB") + add_metric_card(s, 3.0, 1.5, 2.4, 1.2, "Public 테이블", "87") + add_metric_card(s, 5.5, 1.5, 2.4, 1.2, "ORM 클래스", "77") + add_metric_card(s, 8.0, 1.5, 2.4, 1.2, "Alembic 마이그", "37") + add_metric_card(s, 10.5, 1.5, 2.3, 1.2, "FK / 인덱스", "43 / 213") + + # Top 10 size 표 + add_band(s, 3.0, "TOP 10 테이블 (디스크 사용량)") + add_table( + s, + 0.5, + 3.45, + 6.4, + ["테이블", "크기"], + [ + ["living_population_grid", "2.96 GB"], + ["bus_boarding_daily", "640 MB"], + ["living_population", "623 MB"], + ["langchain_pg_embedding (RAG)", "276 MB"], + ["seoul_adstrd_stor", "170 MB"], + ["golmok_commercial", "128 MB"], + ["seoul_ttareungi_usage_daily", "89 MB"], + ["jeonse_monthly_rent", "76 MB"], + ["district_sales_seoul", "71 MB"], + ["seoul_district_sales", "48 MB"], + ], + row_h=0.32, + body_size=10, + ) + + # 마이그레이션 / 운영 안정성 + add_text(s, 7.2, 3.05, 6, 0.4, "운영 안정성", size=13, bold=True, color=ACCENT_2) + add_bullets( + s, + 7.2, + 3.5, + 6.0, + 3.5, + [ + ("alembic head 정합 (phantom revision 복구 후)", GREEN), + ("SQLAlchemy Engine 싱글톤화 → RDS 포화 해소", GREEN), + ("vector_db pool_recycle=1800 (idle 회수)", GREEN), + ("dong_code FK 4 그룹 통합 (425 + 399 master)", GREEN), + ("87 테이블 ORM ↔ DB drift 정합 (zombie 정리)", GREEN), + ("외부 API NULL fill (subway / ttareungi / hotspots)", GREEN), + ("ETL 재적재: ttareungi.dong_code 마포 100%", GREEN), + ("권한: users.is_superadmin BOOLEAN 컬럼 신설", GREEN), + ], + size=11, + ) + + +# --------------------------------------------------------------------------- +# Slide 5 — 데이터 자산 +# --------------------------------------------------------------------------- +def build_data_assets(prs): + s = prs.slides.add_slide(prs.slide_layouts[6]) + add_dark_bg(s) + add_text(s, 0.5, 0.3, 12, 0.7, "데이터 자산 (현재 적재)", size=28, bold=True, color=ACCENT) + add_text( + s, + 0.5, + 0.95, + 12, + 0.4, + "외부 API 8종 적재 + 마포 한정 데이터셋 + 법률 RAG", + size=13, + color=MUTED, + ) + + # 핵심 row count + add_band(s, 1.4, "주요 테이블 row count") + add_table( + s, + 0.5, + 1.85, + 6.4, + ["테이블", "row 수"], + [ + ["living_population_grid (마포)", "10,543,528"], + ["living_population (마포)", "968,064"], + ["seoul_adstrd_stor (마포)", "101,871"], + ["kakao_store_menu", "81,037"], + ["ftc_brand_franchise", "34,708"], + ["seoul_district_sales", "87,938"], + ["langchain_pg_embedding (RAG)", "10,255"], + ["biz_brand_mapping", "5,867"], + ["kakao_store", "4,418"], + ["weather_daily", "2,665"], + ], + row_h=0.32, + body_size=10, + ) + + # 외부 API 출처 + add_text(s, 7.2, 1.4, 6, 0.4, "외부 API 출처 (8종)", size=13, bold=True, color=ACCENT_2) + add_bullets( + s, + 7.2, + 1.85, + 6.0, + 2.7, + [ + ("서울열린데이터광장 (flpop / golmok / 지하철)", TEXT), + ("SGIS — 인구 / 가구 / 사업체 (2026 KOSIS)", TEXT), + ("MOLIT — 부동산 실거래", TEXT), + ("공정거래위원회 (FTC) — 프랜차이즈 정보공개서", TEXT), + ("ECOS — 한국은행 경기 지표 (cycle 100%)", TEXT), + ("기상청 — weather_daily", TEXT), + ("Kakao Local Search — kakao_store / menu", TEXT), + ("공공자전거 — ttareungi 마포 dong_code 100%", TEXT), + ], + size=11, + ) + + # 법률 RAG + add_band(s, 4.7, "법률 RAG (specialist 4 + 판례 인용)") + add_table( + s, + 0.5, + 5.15, + 12.3, + ["항목", "값"], + [ + ["임베딩 (langchain_pg_embedding)", "10,255 vectors (BGE-M3)"], + ["법률 조항 (law_legislations)", "57"], + ["판례 (law_precedents)", "222"], + ["specialist 4종", "가맹사업법 · 공정거래법 · 식품위생법 · 건축법"], + ], + row_h=0.36, + body_size=11, + ) + + +# --------------------------------------------------------------------------- +# Slide 6 — 통합 흐름 + 정량 요약 +# --------------------------------------------------------------------------- +def build_integration(prs): + s = prs.slides.add_slide(prs.slide_layouts[6]) + add_dark_bg(s) + add_text(s, 0.5, 0.3, 12, 0.7, "ABM ↔ DB 통합 흐름", size=28, bold=True, color=ACCENT) + add_text( + s, + 0.5, + 0.95, + 12, + 0.4, + "DB → world_loader → 5,000 agents → 매장 선택 → 매출 집계 → 응답", + size=13, + color=MUTED, + ) + + # 흐름 다이어그램 (텍스트 박스 5개 가로 정렬) + flow = [ + ("DB", "87 테이블\n5.3 GB", PURPLE), + ("world_loader", "stores / dongs\n로드", ACCENT), + ("agents 5,000", "Tier S/A/B\n분배", ACCENT), + ("decide()", "policy\n(LLM 0회)", GREEN), + ("응답", "trajectory +\n매출 집계", ACCENT_2), + ] + box_w = 2.3 + gap = 0.2 + start_x = 0.5 + for i, (title, sub, color) in enumerate(flow): + x = start_x + i * (box_w + gap) + card = s.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(1.6), Inches(box_w), Inches(1.3)) + card.fill.solid() + card.fill.fore_color.rgb = PANEL + card.line.color.rgb = color + card.line.width = Pt(2) + card.shadow.inherit = False + add_text(s, x + 0.1, 1.7, box_w - 0.2, 0.4, title, size=14, bold=True, color=color) + add_text(s, x + 0.1, 2.15, box_w - 0.2, 0.7, sub, size=11, color=TEXT) + # arrow + if i < len(flow) - 1: + arrow = s.shapes.add_shape( + MSO_SHAPE.RIGHT_ARROW, + Inches(x + box_w + 0.01), + Inches(2.15), + Inches(gap - 0.02), + Inches(0.2), + ) + arrow.fill.solid() + arrow.fill.fore_color.rgb = MUTED + arrow.line.fill.background() + + # 정량 요약 + add_band(s, 3.2, "정량 요약 (production 기준)") + add_table( + s, + 0.5, + 3.65, + 6.2, + ["ABM 지표", "값"], + [ + ["에이전트 수", "5,000"], + ["LLM 호출 (default mode)", ("0", GREEN)], + ["LLM 호출 (Tier S 모드, 24h)", "≈1,200"], + ["Population mix base 합", "500 (n_agents 비례 scale)"], + ["Tier S 50 + A 200 + B ≈4,750", "(고정/고정/잔여)"], + ], + body_size=11, + ) + add_table( + s, + 7.0, + 3.65, + 6.0, + ["DB 지표", "값"], + [ + ["DB 총 크기", "5.3 GB"], + ["테이블 / ORM / 마이그", "87 / 77 / 37"], + ["FK / 인덱스 / 컬럼", "43 / 213 / 1,153"], + ["RAG 임베딩", "10,255 (BGE-M3)"], + ["users / superadmin", "23 / 1"], + ], + body_size=11, + ) + + # 마무리 메시지 + add_band(s, 6.3, "한 줄 결론", color=ACCENT_2) + add_text( + s, + 0.5, + 6.75, + 12.3, + 0.5, + "ABM 5,000 에이전트 시뮬을 매 호출 LLM 0회로 돌릴 수 있도록 정책 캐시 + 메모리 + DB 인프라가 정합 상태", + size=13, + color=GREEN, + ) + + +# --------------------------------------------------------------------------- +def main() -> Path: + prs = Presentation() + prs.slide_width = Inches(13.333) + prs.slide_height = Inches(7.5) + + build_title(prs) + build_abm_overview(prs) + build_abm_decision(prs) + build_db_infra(prs) + build_data_assets(prs) + build_integration(prs) + + out = Path(__file__).resolve().parent.parent / "docs" / "presentation" / "spotter-abm-db-briefing.pptx" + out.parent.mkdir(parents=True, exist_ok=True) + prs.save(out) + print(f"saved: {out}") + return out + + +if __name__ == "__main__": + main() diff --git a/scripts/build_abm_db_pptx.py b/scripts/build_abm_db_pptx.py new file mode 100644 index 00000000..87eb2b7a --- /dev/null +++ b/scripts/build_abm_db_pptx.py @@ -0,0 +1,357 @@ +"""SPOTTER A1 (찬영) 작업 PPT 생성 — ABM + DB. + +산출: docs/presentation/spotter-abm-db.pptx + +실 코드 / DB 검증 기반 (2026-05-06): +- ABM 5,000 agents (n_agents 파라미터, /simulate-abm) +- 4가지 mode (default policy / enable_llm_decisions / 풀 JSON / DSL) +- DB: 87 public 테이블 / 77 ORM / 37 alembic migration + +실행: python -m scripts.build_abm_db_pptx +""" + +from __future__ import annotations + +from pathlib import Path + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.util import Inches, Pt + +# --------------------------------------------------------------------------- +# 색 +# --------------------------------------------------------------------------- +BG = RGBColor(0x10, 0x14, 0x20) +ACCENT = RGBColor(0x4F, 0xC3, 0xF7) # cyan +ACCENT_2 = RGBColor(0xFF, 0xB7, 0x4D) # amber +TEXT = RGBColor(0xE3, 0xE9, 0xF4) +MUTED = RGBColor(0x9C, 0xA9, 0xBE) +GREEN = RGBColor(0x6E, 0xE7, 0xB7) + + +# --------------------------------------------------------------------------- +# 헬퍼 +# --------------------------------------------------------------------------- +def add_dark_bg(slide): + bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, Inches(13.333), Inches(7.5)) + bg.fill.solid() + bg.fill.fore_color.rgb = BG + bg.line.fill.background() + bg.shadow.inherit = False + spTree = bg._element.getparent() + spTree.remove(bg._element) + spTree.insert(2, bg._element) + + +def add_text(slide, x, y, w, h, text, *, size=18, bold=False, color=TEXT): + box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h)) + tf = box.text_frame + tf.word_wrap = True + tf.margin_left = tf.margin_right = Inches(0.05) + tf.margin_top = tf.margin_bottom = Inches(0.05) + p = tf.paragraphs[0] + run = p.add_run() + run.text = text + run.font.size = Pt(size) + run.font.bold = bold + run.font.color.rgb = color + run.font.name = "Pretendard" + return box + + +def add_bullets(slide, x, y, w, h, lines, *, size=14, color=TEXT): + box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h)) + tf = box.text_frame + tf.word_wrap = True + tf.margin_left = tf.margin_right = Inches(0.05) + for i, item in enumerate(lines): + if isinstance(item, tuple): + txt, c = item + else: + txt, c = item, color + p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() + run = p.add_run() + run.text = f"· {txt}" + run.font.size = Pt(size) + run.font.color.rgb = c + run.font.name = "Pretendard" + + +def add_kv_table(slide, x, y, w, headers, rows, *, header_size=12, body_size=11): + table = slide.shapes.add_table( + len(rows) + 1, + len(headers), + Inches(x), + Inches(y), + Inches(w), + Inches(0.4 * (len(rows) + 1) + 0.1), + ).table + for i, h in enumerate(headers): + cell = table.cell(0, i) + cell.text = h + for p in cell.text_frame.paragraphs: + for r in p.runs: + r.font.size = Pt(header_size) + r.font.bold = True + r.font.color.rgb = ACCENT + r.font.name = "Pretendard" + cell.fill.solid() + cell.fill.fore_color.rgb = RGBColor(0x1A, 0x22, 0x36) + for r_idx, row in enumerate(rows, start=1): + for c_idx, val in enumerate(row): + cell = table.cell(r_idx, c_idx) + cell.text = str(val) + for p in cell.text_frame.paragraphs: + for run in p.runs: + run.font.size = Pt(body_size) + run.font.color.rgb = TEXT + run.font.name = "Pretendard" + cell.fill.solid() + cell.fill.fore_color.rgb = RGBColor(0x12, 0x18, 0x28) + + +def add_section_band(slide, y, label): + band = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.5), Inches(y), Inches(12.3), Inches(0.04)) + band.fill.solid() + band.fill.fore_color.rgb = ACCENT + band.line.fill.background() + add_text(slide, 0.5, y + 0.05, 12, 0.4, label, size=14, bold=True, color=ACCENT) + + +# --------------------------------------------------------------------------- +# 슬라이드 +# --------------------------------------------------------------------------- +def build_title(prs): + s = prs.slides.add_slide(prs.slide_layouts[6]) + add_dark_bg(s) + add_text(s, 0.7, 1.6, 12, 1.0, "SPOTTER — ABM × DB 인프라", size=44, bold=True) + add_text(s, 0.7, 2.7, 12, 0.6, "찬영 (A1) 기여 분석 · 2026-04 ~ 2026-05", size=20, color=MUTED) + add_section_band(s, 4.0, "발표 범위") + add_bullets( + s, + 0.7, + 4.5, + 12, + 2.5, + [ + "Slide 2 — ABM: 5,000 에이전트 × 4 mode × Policy-as-code", + "Slide 3 — DB 인프라: 87 public 테이블 / 77 ORM / 37 alembic", + "Slide 4 — 정량 가치 + 산출물", + ], + size=16, + ) + + +def build_abm(prs): + s = prs.slides.add_slide(prs.slide_layouts[6]) + add_dark_bg(s) + add_text(s, 0.5, 0.3, 12, 0.7, "ABM — Agent-Based Modeling", size=30, bold=True, color=ACCENT) + add_text( + s, + 0.5, + 0.95, + 12, + 0.4, + '마포구 5,000 가상 에이전트가 매 시간 "어디 갈지" 결정 → 매장별 매출 추정', + size=14, + color=MUTED, + ) + + # 4 mode 표 — 핵심 정정 (Tier A 는 default LLM 호출 X) + add_section_band(s, 1.5, "4가지 의사결정 Mode (agents.py:decide 라우터)") + add_kv_table( + s, + 0.5, + 2.0, + 12.3, + ["Mode", "Tier S (50)", "Tier A (200)", "Tier B (≈4,750)", "LLM 호출"], + [ + ["default (use_policy=True)", "policy", "policy", "policy", "0"], + ["enable_llm_decisions", "smart_decide (LLM)", "policy", "policy", "Tier S 만"], + ["풀 JSON 모드", "smart_decide", "fast_decide (LLM)", "rule (0)", "S+A"], + ["DSL 모드 (use_dsl=True)", "dsl_decide", "dsl_decide", "dsl_decide", "전 Tier"], + ], + ) + + # 핵심 트릭 + add_section_band(s, 4.3, "핵심 설계 (LLM 비용 최소화)") + add_bullets( + s, + 0.5, + 4.7, + 6.4, + 2.5, + [ + ("policy_generator — Ollama Qwen2.5:3b 로 정책 11회 생성", TEXT), + (" → policy_cache.json 캐시 → 매 시뮬 LLM 0회", GREEN), + ("Archetype 30+ multiplier (resident 7종 등 role × 유형)", TEXT), + (" → 같은 role 이어도 행동 패턴 다양성 확보", MUTED), + ("Memory Seeder — 14일 가상 visit_history prefill", TEXT), + (" → Layer 2 학습 cold start 완화 (LLM 0)", MUTED), + ("Tier S: smart_decide (배치 LLM, Hierarchical UIST'23)", TEXT), + (" → /simulate-abm 전 Tier OpenAI gpt-4.1-mini 통일", MUTED), + ], + size=11, + ) + + # 모델 + add_text(s, 7.0, 4.7, 6, 0.4, "모델 / 인구 (실측)", size=14, bold=True, color=ACCENT_2) + add_kv_table( + s, + 7.0, + 5.1, + 6.0, + ["항목", "값"], + [ + ["LLM provider", "OpenAI gpt-4.1-mini"], + ["Population mix", "60/20/10/4/5/1 % (resident → owner)"], + ["base 합계 (n_personas 비례 scale)", "500"], + ["Tier S 모드", "enable_llm_decisions=True 시"], + ["Thought feed (시각화)", "enable_llm_thought=True (별도 LLM)"], + ], + ) + + +def build_db(prs): + s = prs.slides.add_slide(prs.slide_layouts[6]) + add_dark_bg(s) + add_text(s, 0.5, 0.3, 12, 0.7, "DB 인프라 / ORM 정합", size=30, bold=True, color=ACCENT) + add_text( + s, + 0.5, + 0.95, + 12, + 0.4, + "PostgreSQL 87 public 테이블 · SQLAlchemy ORM 77 클래스 · Alembic 37 마이그", + size=14, + color=MUTED, + ) + + add_section_band(s, 1.5, "내가 한 작업 — 6 영역") + + add_bullets( + s, + 0.5, + 2.0, + 6.4, + 4.6, + [ + ("dong_code FK 정합화 — 4 sprint (그룹 A / B-1 / B-2 / B-3)", TEXT), + (" · seoul_dong_master 425동 + jeonse_dong_master 399동", MUTED), + (" · dong_code 8자리 검증 (helper + Pydantic validator)", MUTED), + (" · dong_centroid + master ORM 클래스 3종", MUTED), + ("신규 테이블 / ORM 추가", TEXT), + (" · kakao_store_menu 신설 + panel3 전수 재크롤링", MUTED), + (" · seoul_realtime_hotspots, elderly_ratio_region", MUTED), + (" · emerging-trend B1 — 5 tables (master+operational)", MUTED), + (" · industry_master FK 배선 + InviteCode nullable 명시", MUTED), + ("Alembic 마이그레이션 정합", TEXT), + (" · phantom revision 복구 + simulation_history 생성/cleanup", GREEN), + (" · users / manager_users 라이프사이클 컬럼 catchup", MUTED), + (" · users.is_superadmin 컬럼 추가 (권한 시스템)", MUTED), + ], + size=11, + ) + + add_bullets( + s, + 7.0, + 2.0, + 6.0, + 4.6, + [ + ("연결 풀 / 인프라 안정화", TEXT), + (" · vector_db PGVector pool_recycle=1800 (idle 회수)", MUTED), + (" · services 레이어 Engine 싱글톤화", GREEN), + (" → RDS 커넥션 포화 (max 191) 해소", GREEN), + ("데이터 보강 (NULL / orphan)", TEXT), + (" · 87 테이블 매핑 전수감사 + zombie 정리", MUTED), + (" · NULL/orphan 감사 + master 메타 backfill", MUTED), + (" · 외부 API NULL fill (subway / ttareungi / hotspots)", MUTED), + (" · weather_daily.snow 100% 채움", MUTED), + (" · ETL 재적재: ttareungi.dong_code 마포 + ecos.cycle", GREEN), + ("권한 시스템 신설", TEXT), + (" · users.is_superadmin BOOLEAN 컬럼 + alembic", MUTED), + (" · seed_superadmin.py CLI (자동 부여 금지)", MUTED), + (" · /admin/brands 라우터 (FTC 16K + biz_brand UNION)", MUTED), + ], + size=11, + ) + + +def build_value(prs): + s = prs.slides.add_slide(prs.slide_layouts[6]) + add_dark_bg(s) + add_text(s, 0.5, 0.3, 12, 0.7, "정량 가치 + 산출물", size=30, bold=True, color=ACCENT) + + add_text(s, 0.5, 1.2, 6, 0.4, "ABM 정량", size=16, bold=True, color=ACCENT_2) + add_kv_table( + s, + 0.5, + 1.7, + 6.0, + ["지표", "Before", "After"], + [ + ["LLM 호출 (5K agents × 24h, default)", "120,000", "0 (policy_cache)"], + ["LLM 호출 (Tier S 모드)", "120,000", "1,200 (S 50명만)"], + ["시뮬 latency (score_stores_batch)", "기준", "-49%"], + ["LLM 토큰 (caveman prompt 압축)", "기준", "-60%"], + ["시각화 규모", "1,000", "5,000 agents"], + ], + ) + + add_text(s, 7.0, 1.2, 6, 0.4, "DB 정량", size=16, bold=True, color=ACCENT_2) + add_kv_table( + s, + 7.0, + 1.7, + 6.0, + ["지표", "Before", "After"], + [ + ["dong_code FK 그룹", "0", "4 그룹 (425+399 master)"], + ["ORM ↔ DB drift 감사", "미수행", "87 테이블 정합"], + ["RDS 커넥션 포화 (max 191)", "빈번", "싱글톤 + pool_recycle"], + ["alembic head", "phantom", "정합 (37 마이그)"], + ["weather/ttareungi/ecos NULL", "부분", "100%"], + ], + ) + + add_section_band(s, 4.7, "산출물") + add_bullets( + s, + 0.5, + 5.1, + 12, + 2.0, + [ + ("커밋 50+ (non-merge), PR 19+ (IM3-241/242/243/261 sprint 등)", TEXT), + ("ABM 모듈: brain.py 1,552 LOC · runner.py 1,640 LOC · policy_executor 1,243 LOC", MUTED), + ("DB 모듈: models.py 2,010 LOC · 37 alembic · vector_db 138 LOC", MUTED), + ("문서: docs/issues/2026-05-05-codebase-ultrareview.md (392줄)", MUTED), + ("도구: seed_superadmin.py / RAG trace JSONL / audit_v4 4 CV", MUTED), + ], + size=13, + ) + + +# --------------------------------------------------------------------------- +def main() -> Path: + prs = Presentation() + prs.slide_width = Inches(13.333) + prs.slide_height = Inches(7.5) + + build_title(prs) + build_abm(prs) + build_db(prs) + build_value(prs) + + out = Path(__file__).resolve().parent.parent / "docs" / "presentation" / "spotter-abm-db-v2.pptx" + out.parent.mkdir(parents=True, exist_ok=True) + prs.save(out) + print(f"saved: {out}") + return out + + +if __name__ == "__main__": + main() diff --git a/scripts/build_status_briefing_pptx.py b/scripts/build_status_briefing_pptx.py new file mode 100644 index 00000000..7cd87688 --- /dev/null +++ b/scripts/build_status_briefing_pptx.py @@ -0,0 +1,287 @@ +"""SPOTTER 현재 상태 브리핑 PPT — DB / 코드 실측 기반. + +산출: docs/presentation/spotter-status-briefing.pptx + +기준 시점: 2026-05-06 (origin/dev HEAD f5ee308c, alembic a8f3d2e7c1b9) +실행: python -m scripts.build_status_briefing_pptx +""" + +from __future__ import annotations + +from pathlib import Path + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.util import Inches, Pt + +BG = RGBColor(0x10, 0x14, 0x20) +ACCENT = RGBColor(0x4F, 0xC3, 0xF7) +ACCENT_2 = RGBColor(0xFF, 0xB7, 0x4D) +TEXT = RGBColor(0xE3, 0xE9, 0xF4) +MUTED = RGBColor(0x9C, 0xA9, 0xBE) +GREEN = RGBColor(0x6E, 0xE7, 0xB7) +RED = RGBColor(0xFF, 0x9A, 0x9A) + + +def add_dark_bg(slide): + bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, Inches(13.333), Inches(7.5)) + bg.fill.solid() + bg.fill.fore_color.rgb = BG + bg.line.fill.background() + bg.shadow.inherit = False + spTree = bg._element.getparent() + spTree.remove(bg._element) + spTree.insert(2, bg._element) + + +def add_text(slide, x, y, w, h, text, *, size=18, bold=False, color=TEXT): + box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h)) + tf = box.text_frame + tf.word_wrap = True + p = tf.paragraphs[0] + run = p.add_run() + run.text = text + run.font.size = Pt(size) + run.font.bold = bold + run.font.color.rgb = color + run.font.name = "Pretendard" + + +def add_bullets(slide, x, y, w, h, lines, *, size=13): + box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h)) + tf = box.text_frame + tf.word_wrap = True + for i, item in enumerate(lines): + if isinstance(item, tuple): + txt, c = item + else: + txt, c = item, TEXT + p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() + run = p.add_run() + run.text = f"· {txt}" + run.font.size = Pt(size) + run.font.color.rgb = c + run.font.name = "Pretendard" + + +def add_kv_table(slide, x, y, w, headers, rows, *, header_size=12, body_size=11): + table = slide.shapes.add_table( + len(rows) + 1, + len(headers), + Inches(x), + Inches(y), + Inches(w), + Inches(0.38 * (len(rows) + 1) + 0.1), + ).table + for i, h in enumerate(headers): + cell = table.cell(0, i) + cell.text = h + for p in cell.text_frame.paragraphs: + for r in p.runs: + r.font.size = Pt(header_size) + r.font.bold = True + r.font.color.rgb = ACCENT + r.font.name = "Pretendard" + cell.fill.solid() + cell.fill.fore_color.rgb = RGBColor(0x1A, 0x22, 0x36) + for r_idx, row in enumerate(rows, start=1): + for c_idx, val in enumerate(row): + cell = table.cell(r_idx, c_idx) + cell.text = str(val) + for p in cell.text_frame.paragraphs: + for run in p.runs: + run.font.size = Pt(body_size) + run.font.color.rgb = TEXT + run.font.name = "Pretendard" + cell.fill.solid() + cell.fill.fore_color.rgb = RGBColor(0x12, 0x18, 0x28) + + +def add_band(slide, y, label): + band = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.5), Inches(y), Inches(12.3), Inches(0.04)) + band.fill.solid() + band.fill.fore_color.rgb = ACCENT + band.line.fill.background() + add_text(slide, 0.5, y + 0.05, 12, 0.4, label, size=14, bold=True, color=ACCENT) + + +def build_title(prs): + s = prs.slides.add_slide(prs.slide_layouts[6]) + add_dark_bg(s) + add_text(s, 0.7, 1.6, 12, 1.0, "SPOTTER — 현재 상태 브리핑", size=42, bold=True) + add_text( + s, + 0.7, + 2.7, + 12, + 0.5, + "기준: origin/dev (alembic a8f3d2e7c1b9) · 2026-05-06", + size=18, + color=MUTED, + ) + add_band(s, 4.0, "오늘 다룰 영역") + add_bullets( + s, + 0.7, + 4.5, + 12, + 2.5, + [ + ("ABM 시뮬레이션 — 5,000 agents 매장별 매출 추정", TEXT), + ("DB / 데이터 — 87 테이블 / 외부 API 통합", TEXT), + ("권한 시스템 — master / manager / superadmin 3계층", TEXT), + ], + size=16, + ) + + +def build_abm(prs): + s = prs.slides.add_slide(prs.slide_layouts[6]) + add_dark_bg(s) + add_text(s, 0.5, 0.3, 12, 0.7, "ABM 시뮬레이션 (현재)", size=28, bold=True, color=ACCENT) + add_text( + s, + 0.5, + 0.95, + 12, + 0.4, + "마포구 5,000 가상 에이전트가 매 시간 행동 결정 → 매장별 매출 추정", + size=13, + color=MUTED, + ) + + add_band(s, 1.4, "라우트 / 모드") + add_kv_table( + s, + 0.5, + 1.85, + 12.3, + ["엔드포인트", "default 모드", "옵션 모드"], + [ + [ + "POST /simulate-abm", + "use_policy=True → LLM 0회", + "enable_llm_decisions: Tier S 만 LLM", + ], + [ + "n_agents 파라미터", + "5,000 (production)", + "enable_llm_thought: Tier S thought feed 별도 LLM", + ], + ], + ) + + add_band(s, 3.4, "현재 활성 메모리 / 보조 시스템") + add_kv_table( + s, + 0.5, + 3.85, + 12.3, + ["시스템", "역할", "활성 여부"], + [ + ["MemoryStore", "raw 행동 로그 (deque 200) + 일일 요약 (LLM 0)", "✅ 사용"], + ["policy_cache.json", "역할×날씨×시간 정책 (Ollama 로 11회 cold gen)", "✅ 사용"], + ["Archetype 30+", "role 안 다양성 (ARCHETYPES dict multiplier)", "✅ 사용"], + ["Memory Seeder", "14일 가상 visit prefill (cold start 완화)", "❌ /simulate-abm 비활성"], + ["PgVectorMemory", "Tier S semantic search (BGE-M3)", "❌ 옵션 (default off)"], + ], + ) + + add_band(s, 6.0, "한 줄 요약") + add_text( + s, + 0.5, + 6.4, + 12.3, + 0.5, + "default = 전 Tier policy_decide (LLM 0회). Tier S 50명만 옵션으로 LLM 호출 가능.", + size=14, + color=GREEN, + ) + + +def build_db(prs): + s = prs.slides.add_slide(prs.slide_layouts[6]) + add_dark_bg(s) + add_text(s, 0.5, 0.3, 12, 0.7, "DB / 데이터 (현재)", size=28, bold=True, color=ACCENT) + add_text( + s, + 0.5, + 0.95, + 12, + 0.4, + "PostgreSQL 87 public 테이블 · ORM 77 클래스 · alembic head a8f3d2e7c1b9", + size=13, + color=MUTED, + ) + + add_band(s, 1.4, "핵심 테이블 (실 row count)") + add_kv_table( + s, + 0.5, + 1.85, + 6.0, + ["테이블", "row 수"], + [ + ["ftc_brand_franchise", "34,708"], + ["biz_brand_mapping", "5,867"], + ["users", "23 (superadmin 1)"], + ["simulation_ai (이력)", "7"], + ["simulation_foresee (이력)", "8"], + ], + ) + + add_text(s, 6.8, 1.55, 6, 0.4, "외부 API 연결 (실 적재)", size=14, bold=True, color=ACCENT_2) + add_bullets( + s, + 6.8, + 2.0, + 6.4, + 3.0, + [ + "서울열린데이터 — flpop / golmok / district_sales / 지하철", + "SGIS — 인구 / 가구 / 사업체", + "MOLIT — 부동산 실거래", + "공정거래위원회 (FTC) — 프랜차이즈 정보공개서", + "ECOS — 한국은행 경기 지표", + "기상청 — weather_daily", + "Kakao — kakao_store / kakao_store_menu", + "공공자전거 — ttareungi.dong_code 마포 100%", + ], + size=11, + ) + + add_band(s, 5.0, "권한 시스템") + add_kv_table( + s, + 0.5, + 5.4, + 12.3, + ["역할", "테이블", "데이터 가시 범위"], + [ + ["master", "users", "본인 + 소속 매니저"], + ["manager", "manager_users", "본인만"], + ["superadmin", "users.is_superadmin=true", "전체 가맹본부 (시뮬 이력)"], + ], + ) + + +def main() -> Path: + prs = Presentation() + prs.slide_width = Inches(13.333) + prs.slide_height = Inches(7.5) + + build_title(prs) + build_abm(prs) + build_db(prs) + + out = Path(__file__).resolve().parent.parent / "docs" / "presentation" / "spotter-status-briefing.pptx" + out.parent.mkdir(parents=True, exist_ok=True) + prs.save(out) + print(f"saved: {out}") + return out + + +if __name__ == "__main__": + main()