From 56428495e76c4ad44025d683d7efdd55eaa6690f Mon Sep 17 00:00:00 2001 From: bat1120 Date: Wed, 6 May 2026 20:26:52 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C/?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20UI=20=EC=A0=95=EB=B9=84=20+=20ABM=20person?= =?UTF-8?q?a=20pool=20+=20=ED=9A=8C=EA=B3=A0/=ED=94=84=EB=A0=88=EC=A0=A0?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지도/대시보드: - 경쟁점 = 공실 spot 1.5km + 행정동 매장 합집합 (kakao_id dedup) - MAPO_ADSTRD_TO_KAKAO_DONG_NAMES 매핑 (kakao 행정/법정동 혼합 보정) - spot bbox 인접 동 침범 매장 후처리 컷 (선택 4동 + 매핑 법정동만 통과) - choropleth = winner(1위) 만 색칠, 나머지 회색 - IndicatorGrid: 생존률/폐업률 삭제 (8 → 6 핵심 지표) - DistrictRankings: 폐업위험/BEP 컬럼 삭제 ABM: - persona_pool 모듈 신설 - agents/personas/runner refactor 기타: - legal/brand resolver/legal categories 보강 - refresh_kakao_missing_brands ingest 스크립트 - 2026-05-05/06 회고 - spotter ABM/status presentation pptx Co-Authored-By: Claude Opus 4.7 (1M context) --- .../4d042d8e_corp_brand_resolver_base.patch | 478 ++++++ .../bdbd5754_jwt_integration.patch | 260 +++ .audit_post_session.txt | 57 + .../ingest/refresh_kakao_missing_brands.py | 307 ++++ backend/src/agents/legal/categories.py | 18 +- backend/src/config/constants.py | 25 + backend/src/main.py | 147 +- .../src/services/brand_mapping_resolver.py | 32 +- backend/src/services/corp_brand_resolver.py | 3 + backend/src/simulation/agents.py | 30 + backend/src/simulation/persona_pool.py | 217 +++ backend/src/simulation/personas.py | 23 +- backend/src/simulation/runner.py | 9 + .../presentation/spotter-abm-db-briefing.pptx | Bin 0 -> 42469 bytes docs/presentation/spotter-abm-db-v2.pptx | Bin 0 -> 36416 bytes docs/presentation/spotter-abm-db.pptx | Bin 0 -> 36514 bytes .../presentation/spotter-status-briefing.pptx | Bin 0 -> 33844 bytes docs/retrospective/2026-05-05.md | 469 ++++++ docs/retrospective/2026-05-06.md | 1474 +++++++++++++++++ frontend/src/components/AbmPersonaMap.tsx | 45 +- .../src/components/AgentMapVisualizer.tsx | 115 +- frontend/src/components/PersonaCard.tsx | 86 +- .../dashboard/DashboardHub.tsx | 6 + .../dashboard/tabs/AbmTab.tsx | 73 +- .../sections/DistrictRankings.tsx | 19 - .../sections/IndicatorGrid.tsx | 27 +- .../SimulationResult/sections/MapSection.tsx | 26 +- .../SimulationResult/sections/MarketMap.tsx | 52 +- frontend/src/stores/abmStore.ts | 36 +- scripts/build_abm_db_briefing_pptx.py | 599 +++++++ scripts/build_abm_db_pptx.py | 357 ++++ scripts/build_status_briefing_pptx.py | 287 ++++ 32 files changed, 5143 insertions(+), 134 deletions(-) create mode 100644 .audit/reverted_patches/4d042d8e_corp_brand_resolver_base.patch create mode 100644 .audit/reverted_patches/bdbd5754_jwt_integration.patch create mode 100644 .audit_post_session.txt create mode 100644 backend/scripts/ingest/refresh_kakao_missing_brands.py create mode 100644 backend/src/simulation/persona_pool.py create mode 100644 docs/presentation/spotter-abm-db-briefing.pptx create mode 100644 docs/presentation/spotter-abm-db-v2.pptx create mode 100644 docs/presentation/spotter-abm-db.pptx create mode 100644 docs/presentation/spotter-status-briefing.pptx create mode 100644 docs/retrospective/2026-05-06.md create mode 100644 scripts/build_abm_db_briefing_pptx.py create mode 100644 scripts/build_abm_db_pptx.py create mode 100644 scripts/build_status_briefing_pptx.py 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 0000000000000000000000000000000000000000..8e4b4669321c93441361ee60c1084f81ce6bde22 GIT binary patch literal 42469 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 literal 0 HcmV?d00001 diff --git a/docs/presentation/spotter-abm-db-v2.pptx b/docs/presentation/spotter-abm-db-v2.pptx new file mode 100644 index 0000000000000000000000000000000000000000..b0eb2f7ca96719a6d01204430e1fce3677a59711 GIT binary patch literal 36416 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 Jc>eL-{{tvocas1B literal 0 HcmV?d00001 diff --git a/docs/presentation/spotter-abm-db.pptx b/docs/presentation/spotter-abm-db.pptx new file mode 100644 index 0000000000000000000000000000000000000000..10f68d95489a26121d1052d308fb10a6f2b86d2c GIT binary patch literal 36514 zcmdqIV{~QRy0#nJw(W{-&9GwIw#|xDamBW6+qO}$jY@LzzH5KythLT=ZGWxpf9uB> zZI1bCj;HtMy6=AXKEF$Yf}sHc0YL!`Cl_hkxXWUV0s{dFA^-uQetoMYVrT1YV(YA@ z>|t->q{HBDV?CKNZoAHeB=+zUH?b z_s2huB5qNXiUUP*YQu|@49d~DmByt3EOso7j!X6p07lUq!dy?+n!Rx!3J4~XC2nN< zNp971;P}UYRfDG|ECJs1CS$}08RU*D5k={&=Xzw}RS|{bU4}DNU<^B~-q(-F(FzLg zp=2&>IN$2mSV+XSL+-`^z#QT%Gd1)u-Cfo5=5tvJI{#U z|7GNF;5p8LzD9l*1PBQBYvlDDO{|?58UDIg#!t(E5+nV%;s;-aEoNA^jQVEc^^Kwv zTZvFV_=LeF09r=&?J+Oh&$3Atk+1L1^C7N&*UF1fhtU`V1bwj!DL4dbC2VFgWBKrC zCR!@7I+mdz{nM}Cd7Wtj`#{=L2U`jl;3#!fo~CVTH0cC<2iz#3iWxAl!v5 zne8pWZ))F;+7DyVt<6gb-rE!)_?o@}|7)Ql(f;gvd=+Z_t5C>ag)+7?`tE3F@5E?i z=V=|R4l;M)c|Aa9+^>dKrdrt zRj}(T1j6!4wg#tLH;A)B6US8N8rq)Yk44hj(1!XpfPahfs{B&?);F_n#=eu*tU*2t+>o^X z@*^bx((%3A4QYzLiuvO((W5o)pldMc;{q8f6^LAytFyO3Jl;9pC=G1b)=OqOG% ztHgt87{D9ISxBeOz2J~2_{nH%ScW&t`Bu*k9muA2Nncavsg#&bnvZx5FH(P0mpay6 zF!G`uh5lhyXn`u4VKaq|<3+t0^gypuCgVdQOdB$Xjw_10Xt-x+VXniMw8@7>AKxT> z-9SbiRhHiG5&X^Ly6lp2aX}{;YrdN67<<0x`0&^$Sp_AAKoCQaR%T_=H_&SyqH~Xl zlC5(N%7>&?iMD38369ymIdohk4fh?73~OV?y$(f2u#N!KFmk4fAMyg=5mAmhcFYU= z3Q6xbx~c}Xg5WYKzr(gN;-w+aZJmbXqdL7q zE9%)P%1n@O{Tt{N(e^~C89lERjZkUasD6SfZ{xllS zETv6%$Xk(pGm^u~p2>GSn(LBdjVP_JA0!R$CGr}(;Nx{H%|BOD+C!iKkKsqL26;F0 z$yYb%m^HV|7e&0Z^|Y&DCh%`foUgdw^Zl!VQQ?7r{!%}CduK*_M-!(nsWWi4u(SPZ zf?TAm+wL(Tg`?++tH{6k? z0z$?lmgq{788V6uxDhI~G+k%r5d<8dR{LAn42uC0w<0<-VQ=Nl7)vj>yn*m@mMTj0$G#;a>K9uX%)V5mj2TJ{!*-G7I5QRJmRM7Meo3zJJ z;(N(Wso_M?iU}{F;7g4NYH@B;=`D_I>c^lFmc@`fS*V}rZ{+-GPgX@?E?V^X&eVKm zXK5y5$3YAQQwKv7(~wZ6`kZq4(sGgB8sFEfCw6*a(T^pa*<2jklkM`-%HW&vM_Jpm z$-gxrQXUX#g z&J2VGG3yS{D`m~Di_QM^cu_sEC-AR$()v)FWBS4qFa!_~;eXA*e`xN%&%?hpxN|{B zzMlvU^13}c@P00#@ZvDT)EU#LE-2jL4z%X6kzA3;f8N(CTyB{i_h#mP1oY$2El9;u zHoOGnOejF8k1Rq2j3fmd6&+g7Ep~Yv30qoBs1;4H4@0J9s!Elyr_{wuW-?KTK@*1yM3xL4ibF|i^ZQw7TVKSXT?0tQ&<)6$t@zIe=Gh{mfGsqDziVaSr}225 zF&FR1pH5(dX}kbBtd`&%Ctw35Bevmp%0AXKS`{4ZQ>Q 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

x|5h6IS}QnEo)q5*MW=ziI-)WCn8u1zc^S;xxl4 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 literal 0 HcmV?d00001 diff --git a/docs/presentation/spotter-status-briefing.pptx b/docs/presentation/spotter-status-briefing.pptx new file mode 100644 index 0000000000000000000000000000000000000000..857d7ecc09b89f47cc3b9ab76d5c3aa310e5a4f6 GIT binary patch literal 33844 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 // 비교 반경 (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()