Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b1ebfa5
polish(agents): 에이전트 아이콘 박스 제거 — PNG 투명 배경 그대로 노출
yezin013 May 4, 2026
65ba4a1
polish(agents): AnalyzeAiSummaryTab synthesis 아이콘 박스 제거 (누락 fix)
yezin013 May 4, 2026
deb89db
fix(district-ranking): winner 외 동도 경쟁강도/폐업률/생존율 채워서 응답
yezin013 May 4, 2026
b8473fa
chore(gitignore): .env.txt 무시 추가
yezin013 May 4, 2026
839c948
Merge remote-tracking branch 'origin/dev' into IM3-263-ai-summary-layout
yezin013 May 4, 2026
4478c62
Merge remote-tracking branch 'origin/dev' into IM3-263-ai-summary-layout
yezin013 May 4, 2026
d6de292
feat(synthesis+map): 캐시 v11 + 공실 spot dedup + userBrand 별표 분기
yezin013 May 4, 2026
f53117a
feat(synthesis): confidence 동적 산출 — 0.85 하드코딩 회귀 차단
yezin013 May 4, 2026
37d7240
refactor(business-type): 5개 분산 dict → 통합 dict (business_type_mapping)…
yezin013 May 4, 2026
7998841
Revert "feat(synthesis): confidence 동적 산출 — 0.85 하드코딩 회귀 차단"
yezin013 May 4, 2026
9babf27
fix(synthesis): 캐시 v11 → v12 — 동적 confidence 롤백 후 캐시 무효화
yezin013 May 4, 2026
ac4310a
feat(evaluation): 7 LLM 에이전트 정확도 평가 framework 토대
yezin013 May 4, 2026
4612824
feat(evaluation): competitor_intel 평가 시범 실행 스크립트
yezin013 May 4, 2026
8c2c873
feat(evaluation): competitor_intel Redis 실측 스크립트
yezin013 May 5, 2026
c8b8f78
Merge remote-tracking branch 'origin/dev' into IM3-263-ai-summary-layout
yezin013 May 5, 2026
163ecbc
feat(map): 자사 매장 별표 옵션A — 시뮬 업종과 자사 카테고리 일치 시만 표시
yezin013 May 5, 2026
e04d80b
fix(synthesis): '리스크 및 대응' 섹션 법률 조항 인용 금지 (캐시 v12→v13)
yezin013 May 6, 2026
4d76ce4
Merge remote-tracking branch 'origin/dev' into IM3-263-ai-summary-layout
yezin013 May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ venv/

# Environment
.env
.env.txt

# Data
data/raw/
Expand Down
Empty file.
150 changes: 150 additions & 0 deletions backend/scripts/eval/run_competitor_intel_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""competitor_intel 평가 framework 시범 실행.

목적: evaluator 동작 검증 + 첫 metric 산출.
입력: 합성 fixture 10건 (다양한 cannibal/saturation 조합 + LLM signal).
출력: accuracy + confusion matrix + 케이스별 결과.

⚠️ 합성 fixture 의 LLM signal 은 실제 출력이 아닌 "전형적 LLM 응답 패턴" 모방.
실제 정확도 측정은 Redis 캐시 dump → fixture 변환 후 별도 실행.

사용:
cd backend
python -m scripts.eval.run_competitor_intel_demo
"""

from __future__ import annotations

import asyncio
import io
import sys

# Windows cp949 콘솔 인코딩 → UTF-8 강제 (한글·유니코드 출력 깨짐 방지)
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")

sys.path.insert(0, "C:\\dev\\Final_project\\backend") # noqa

from src.evaluation.competitor_intel_eval import CompetitorIntelEvaluator


# 합성 fixture — 10 케이스, expected vs LLM 출력 다양성.
# expected (룰엔진 임계값):
# green : cannibal_pct < 0.05 AND saturation in {sparse, low}
# yellow: 0.05 <= cannibal_pct <= 0.15 OR saturation == medium
# red : cannibal_pct > 0.15 OR saturation in {high, saturated}
FIXTURES = [
# green 정답 케이스 (LLM 도 green) — 일치
{
"case_id": "case01_green_correct",
"simulated_output": {
"market_entry_signal": "green",
"cannibalization": {"estimated_revenue_impact_pct": -0.03},
"competition_500m": {"saturation_level": "low"},
},
},
# green 정답 케이스 (LLM 은 yellow) — 보수적 LLM 오답
{
"case_id": "case02_green_to_yellow",
"simulated_output": {
"market_entry_signal": "yellow",
"cannibalization": {"estimated_revenue_impact_pct": -0.02},
"competition_500m": {"saturation_level": "sparse"},
},
},
# yellow 정답 (cannibal 7%) — LLM yellow 정답
{
"case_id": "case03_yellow_correct_cannibal",
"simulated_output": {
"market_entry_signal": "yellow",
"cannibalization": {"estimated_revenue_impact_pct": -0.07},
"competition_500m": {"saturation_level": "low"},
},
},
# yellow 정답 (saturation medium) — LLM yellow 정답
{
"case_id": "case04_yellow_correct_medium",
"simulated_output": {
"market_entry_signal": "yellow",
"cannibalization": {"estimated_revenue_impact_pct": -0.04},
"competition_500m": {"saturation_level": "medium"},
},
},
# yellow 정답이지만 LLM 은 green (낙관적 오답)
{
"case_id": "case05_yellow_to_green",
"simulated_output": {
"market_entry_signal": "green",
"cannibalization": {"estimated_revenue_impact_pct": -0.10},
"competition_500m": {"saturation_level": "low"},
},
},
# red 정답 (cannibal 30%) — LLM red 정답
{
"case_id": "case06_red_correct_cannibal",
"simulated_output": {
"market_entry_signal": "red",
"cannibalization": {"estimated_revenue_impact_pct": -0.30},
"competition_500m": {"saturation_level": "medium"},
},
},
# red 정답 (saturation high) — LLM red 정답
{
"case_id": "case07_red_correct_high",
"simulated_output": {
"market_entry_signal": "red",
"cannibalization": {"estimated_revenue_impact_pct": -0.04},
"competition_500m": {"saturation_level": "high"},
},
},
# red 정답 (saturated) — LLM yellow (위험 과소평가)
{
"case_id": "case08_red_to_yellow",
"simulated_output": {
"market_entry_signal": "yellow",
"cannibalization": {"estimated_revenue_impact_pct": -0.08},
"competition_500m": {"saturation_level": "saturated"},
},
},
# 50% 캡 도달 케이스 — red 정답
{
"case_id": "case09_red_capped",
"simulated_output": {
"market_entry_signal": "red",
"cannibalization": {"estimated_revenue_impact_pct": -0.50},
"competition_500m": {"saturation_level": "high"},
},
},
# green 정답 (이상적 케이스) — LLM green
{
"case_id": "case10_green_ideal",
"simulated_output": {
"market_entry_signal": "green",
"cannibalization": {"estimated_revenue_impact_pct": -0.01},
"competition_500m": {"saturation_level": "sparse"},
},
},
]


async def main() -> None:
evaluator = CompetitorIntelEvaluator(fixtures=FIXTURES)
summary = await evaluator.run()

print("=" * 60)
print("competitor_intel 평가 결과 (합성 fixture 10건)")
print("=" * 60)
for line in summary.report_lines():
print(line)
print()
print("케이스별 결과:")
for r in summary.raw_results:
mark = "✓" if r.passed else "✗"
print(f" {mark} {r.case_id}: expected={r.expected:6} actual={r.actual:6}")
print()
print("=" * 60)
print(f"📊 정확도: {summary.metric_mean:.1%} ({summary.n_passed}/{summary.n_cases})")
print("=" * 60)


if __name__ == "__main__":
asyncio.run(main())
97 changes: 97 additions & 0 deletions backend/scripts/eval/run_competitor_intel_real.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""competitor_intel 실제 LLM 정확도 측정.

Redis 캐시(`v3:competitor_intel:*`) 의 실제 시뮬 결과를 fixture 로 변환 후
CompetitorIntelEvaluator 실행 → LLM market_entry_signal vs 룰엔진 정답 비교.

사용:
cd backend
python -m scripts.eval.run_competitor_intel_real

전제:
- Redis 띄워져 있음 (settings.redis_url)
- v3:competitor_intel:* 키에 시뮬 결과 캐시되어 있음 (≥1건)
"""

from __future__ import annotations

import asyncio
import io
import json
import sys

# Windows cp949 콘솔 → UTF-8 강제
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")

sys.path.insert(0, "C:\\dev\\Final_project\\backend") # noqa

import redis.asyncio as aioredis

from src.config.settings import settings
from src.evaluation.competitor_intel_eval import CompetitorIntelEvaluator


async def dump_redis_to_fixtures(pattern: str = "v3:competitor_intel:*") -> list[dict]:
"""Redis 에서 캐시된 시뮬 결과 → evaluator fixture 로 변환."""
fixtures: list[dict] = []
r = aioredis.from_url(settings.redis_url, decode_responses=True)
try:
keys = await r.keys(pattern)
print(f"[dump] Redis 패턴 '{pattern}' → {len(keys)}개 키 발견")
for key in keys:
raw = await r.get(key)
if not raw:
continue
try:
payload = json.loads(raw)
except Exception as e:
print(f" [skip] {key}: JSON parse 실패 — {e}")
continue
# fixture 변환 — case_id 는 dong:brand 조합
# 키 형식: v3:competitor_intel:{dong_code}:{brand_name}
parts = key.split(":", 3)
case_id = ":".join(parts[2:]) if len(parts) >= 4 else key
fixtures.append(
{
"case_id": case_id,
"simulated_output": payload,
}
)
finally:
await r.aclose()
return fixtures


async def main() -> None:
fixtures = await dump_redis_to_fixtures()
if not fixtures:
print("⚠️ v3:competitor_intel:* 캐시 없음 — 시뮬 1회 이상 돌린 후 재실행.")
return

evaluator = CompetitorIntelEvaluator(fixtures=fixtures)
summary = await evaluator.run()

print("=" * 70)
print(f"competitor_intel 실측 LLM 정확도 (Redis dump {len(fixtures)}건)")
print("=" * 70)
for line in summary.report_lines():
print(line)
print()
print("케이스별 결과:")
for r in summary.raw_results:
mark = "✓" if r.passed else "✗"
cn = r.details.get("cannibal_pct", 0)
sat = r.details.get("saturation_level", "?")
print(
f" {mark} {r.case_id}: "
f"expected={r.expected:6} actual={r.actual:6} "
f"(cannibal={cn * 100:.1f}% sat={sat})"
)
print()
print("=" * 70)
print(f"📊 실측 정확도: {summary.metric_mean:.1%} ({summary.n_passed}/{summary.n_cases})")
print("=" * 70)


if __name__ == "__main__":
asyncio.run(main())
42 changes: 29 additions & 13 deletions backend/src/agents/legal/specialists.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,17 +157,34 @@ def _make_specialist_fallback(
# "커피" 입력은 BIZ_NORMALIZE 가 "카페" 로 변환해 들어오므로 이 dict 에 "커피" 키 불필요.
# 직접 호출자 추가 시 BIZ_NORMALIZE 거치는지 확인.
_INDUSTRY_DEFAULT = "default"
_INDUSTRY_LABEL_MAP = {
"카페": "cafe",
"음식점": "restaurant",
# 주점 — commercial_intelligence 거리 감쇠 곡선이 별도로 없어 default 사용.
# default 곡선(0.20)이 보수적이라 주점 자기잠식 과대평가 방지.
"주점": _INDUSTRY_DEFAULT,
# 편의점 — 시뮬 미지원이지만 운영 데이터(매장 분류)에서 여전히 등장 가능.
"편의점": "convenience",
# industry 라벨은 통합 dict (config.business_type_mapping) 의 label_en 에서 가져온 후
# commercial_intelligence.estimate_cannibalization 의 base_by_industry 키와 매핑.
# base_by_industry 키: cafe / coffee / restaurant / chicken / burger / korean / convenience / default.
_LABEL_EN_TO_CANNIBAL: dict[str, str] = {
"cafe": "cafe",
"burger": "burger",
"fastfood": "burger", # 통합 dict label_en="fastfood" → cannibal 곡선 burger
"chicken": "chicken",
"korean": "korean",
}


def _resolve_cannibal_industry(business_type: str | None) -> str:
"""업종 → cannibal industry 라벨 (default fallback).

BIZ_NORMALIZE → 통합 dict get_entry → label_en → cannibal 라벨 매핑.
"""
from src.config.business_type_mapping import get_entry

if not business_type:
return _INDUSTRY_DEFAULT
biz_normalized = BIZ_NORMALIZE.get(business_type.lower(), business_type)
entry = get_entry(biz_normalized) or get_entry(business_type)
if entry:
return _LABEL_EN_TO_CANNIBAL.get(entry["label_en"], _INDUSTRY_DEFAULT)
return _INDUSTRY_DEFAULT


async def _analyze_territory(
brand: str,
district: str,
Expand Down Expand Up @@ -201,12 +218,11 @@ async def _analyze_territory(
)
from src.services.dong_resolver import resolve_dong_code

# 업종 정규화 후 industry 라벨 매핑. 미매핑은 default — cafe 곡선 강제 회피.
biz_normalized = BIZ_NORMALIZE.get((business_type or "").lower(), business_type or "")
industry = _INDUSTRY_LABEL_MAP.get(biz_normalized, _INDUSTRY_DEFAULT)
if industry == _INDUSTRY_DEFAULT and biz_normalized:
# 업종 → cannibal industry 라벨 (통합 dict 기반). 미매핑은 default — cafe 곡선 강제 회피.
industry = _resolve_cannibal_industry(business_type)
if industry == _INDUSTRY_DEFAULT and business_type:
logger.debug(
f"[_analyze_territory] 업종 '{business_type}' (정규화: '{biz_normalized}') 미매핑 — default 곡선 사용"
f"[_analyze_territory] 업종 '{business_type}' 미매핑 — default 곡선 사용"
)

result = None
Expand Down
Loading
Loading