Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 14 additions & 18 deletions backend/src/agents/legal/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,7 @@ def rule_food_hygiene(business_type: str) -> dict:
{
"article_ref": "식품위생법 시행령 제21조",
"content": (
"식품접객업의 종류: 휴게음식점·일반음식점·단란주점·유흥주점·"
"위탁급식·제과점 영업으로 구분한다."
"식품접객업의 종류: 휴게음식점·일반음식점·단란주점·유흥주점·위탁급식·제과점 영업으로 구분한다."
),
},
]
Expand Down Expand Up @@ -339,8 +338,7 @@ def rule_fire_safety(business_type: str, store_area_pyeong: float) -> dict:
{
"article_ref": "소방시설법 제12조",
"content": (
"특정소방대상물의 관계인은 대통령령으로 정하는 소방시설을 화재안전기준에 따라 "
"설치·관리하여야 한다."
"특정소방대상물의 관계인은 대통령령으로 정하는 소방시설을 화재안전기준에 따라 설치·관리하여야 한다."
),
}
]
Expand Down Expand Up @@ -642,7 +640,7 @@ def rule_sewage(business_type: str) -> dict:
# ---------------------------------------------------------------------------

# 학교보건법 제6조 정화구역 거리
SCHOOL_ABSOLUTE_ZONE_M: float = 50.0 # 절대정화구역 (모든 술집/노래방 영업금지)
SCHOOL_ABSOLUTE_ZONE_M: float = 50.0 # 절대정화구역 (모든 술집/노래방 영업금지)
SCHOOL_RELATIVE_ZONE_M: float = 200.0 # 상대정화구역 (정화위원회 심의 대상)


Expand Down Expand Up @@ -691,10 +689,7 @@ def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000.0
dlat = radians(lat2 - lat1)
dlon = radians(lon2 - lon1)
a = (
sin(dlat / 2) ** 2
+ cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
)
a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
return 2 * R * asin(sqrt(a))


Expand Down Expand Up @@ -758,7 +753,13 @@ def rule_school_zone(
- 상대정화구역 200m: 학교환경위생정화위원회 심의 (대부분 거부) → 주점 danger
- 카페/음식점은 적용 X (safe)

schools=None 이면 DB 조회. lat/lon 없으면 caution fallback.
schools=None 이면 DB 조회. lat/lon 없으면 caution fallback (시뮬 시작 시점 정상 케이스).

⚠️ D3 알림 (mock 데이터 위험):
mapo_schools 테이블이 비어있거나 POSTGRES_URL 미설정이면 ``_fetch_mapo_schools`` 가
``_MOCK_MAPO_SCHOOLS`` (5개 학교 좌표) fallback 으로 전환된다. mock 결과는
실제 마포구 학교 분포의 일부에 불과하므로 danger/safe 판정을 그대로 신뢰하면 안 된다.
프로덕션 환경에서는 mapo_schools 적재 후에만 정확한 거리 계산을 보장한다.
"""
biz = _normalize_biz(business_type)

Expand All @@ -767,9 +768,7 @@ def rule_school_zone(
return {
"type": "school_zone",
"level": "safe",
"summary": (
f"{biz or '해당 업종'}은 학교환경위생정화구역 영업 제한 대상이 아닙니다."
),
"summary": (f"{biz or '해당 업종'}은 학교환경위생정화구역 영업 제한 대상이 아닙니다."),
"recommendation": (
"[근거: 학교보건법 제6조] 정화구역 영업제한은 술집·노래방 등에 "
"한정 적용 — 카페·음식점은 별도 거리 제한 없음."
Expand All @@ -791,9 +790,7 @@ def rule_school_zone(
return {
"type": "school_zone",
"level": "caution",
"summary": (
"주점 영업장 좌표 미입력 — 학교환경위생정화구역 거리 확인이 필요합니다."
),
"summary": ("주점 영업장 좌표 미입력 — 학교환경위생정화구역 거리 확인이 필요합니다."),
"recommendation": _format_recommendation(
["학교보건법 제6조"],
[
Expand Down Expand Up @@ -896,8 +893,7 @@ def rule_school_zone(
"type": "school_zone",
"level": "safe",
"summary": (
"주점 영업 후보지 반경 200m 이내에 학교가 없어 학교환경위생정화구역 "
"영업 제한 대상에서 제외됩니다."
"주점 영업 후보지 반경 200m 이내에 학교가 없어 학교환경위생정화구역 영업 제한 대상에서 제외됩니다."
),
"recommendation": _format_recommendation(
["학교보건법 제6조"],
Expand Down
66 changes: 63 additions & 3 deletions backend/src/agents/legal/specialists.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,23 @@ async def specialist_franchise_law(
brand = (brand or "")[:100]
business_type = (business_type or "")[:100]
district = (district or "")[:100]
# D4 fix: territory_radius_m 합리 범위 검증 (50~1500m).
# 정보공개서·가맹계약서 표준치 기반. 범위 밖이면 None 처리해 일반 임계값(500m) fallback.
if territory_radius_m is not None:
try:
_terr_int = int(territory_radius_m)
if _terr_int < 50 or _terr_int > 1500:
logger.warning(
f"[specialist_franchise_law] territory_radius_m={_terr_int} 범위 외 (50~1500m) — None fallback"
)
territory_radius_m = None
else:
territory_radius_m = _terr_int
except (TypeError, ValueError):
logger.warning(
f"[specialist_franchise_law] territory_radius_m={territory_radius_m!r} 파싱 실패 — None fallback"
)
territory_radius_m = None
retriever = LegalDocumentRetriever()
query = f"{brand} {business_type} {district} 영업지역 가맹사업법 정보공개서 폐점률 허위과장 필수품목 카니발리제이션"
# RAG(법령) + 판례 RAG + 영업지역 정량 분석 병렬 (업종별 거리 감쇠 곡선 적용)
Expand Down Expand Up @@ -356,6 +373,8 @@ async def specialist_franchise_law(
else:
territory = territory_raw or {}

# D1 fix: RAG docs 0건 시 caution floor (LLM "자료 없음" → safe 오판 차단).
rag_empty = not docs
ftc_hint = _format_ftc_hint(ftc_data)
rag_text = _format_docs(docs)
precedent_text = _format_precedents(precedents)
Expand All @@ -377,6 +396,10 @@ async def specialist_franchise_law(
"- 2km 내 동일 브랜드 3개 이상 → caution (자기잠식 위험)\n"
)

# D1 fix: RAG 비었을 때 LLM 에 명시적으로 caution 이상 강제 안내
_rag_empty_directive = (
"- ⚠️ RAG_CONTEXT 가 비어있음(자료 없음) → safe 절대 금지, **caution 이상** 반환 필수\n" if rag_empty else ""
)
user_content = (
f"브랜드: {brand}\n"
f"업종: {business_type}\n"
Expand All @@ -388,7 +411,9 @@ async def specialist_franchise_law(
"- 폐점률 ≥10% → caution\n"
f"{_territory_criteria}"
"- 영업지역 침해(제12조의4)/허위과장(제9조)/필수품목 구입강제(제12조) → danger 후보\n"
"- 신규 브랜드/직영 → safe~caution\n\n"
"- 신규 브랜드/직영 → safe~caution\n"
f"{_rag_empty_directive}"
"\n"
"<<<RAG_CONTEXT>>>\n"
f"{rag_text}\n"
"<<<END_RAG_CONTEXT>>>\n\n"
Expand Down Expand Up @@ -418,6 +443,10 @@ async def specialist_franchise_law(
if brand and brand.strip() and result.level == "safe":
result.level = "caution"
result.summary = "[브랜드 입력됨 — 가맹사업법 적용 검토 필요] " + (result.summary or "")
# D1 fix: RAG 0건이면 LLM 결과와 무관하게 caution floor 강제
if rag_empty and result.level == "safe":
result.level = "caution"
result.summary = "[RAG 법조문 자료 부재 — 수동 검토 필요] " + (result.summary or "")
# 영업지역 정량 룰 floor — LLM 이 정량 데이터를 무시하고 낮은 level 로 평가하는
# 케이스 차단. 룰이 산출한 floor 보다 LLM 이 더 높은 level 을 주면 LLM 그대로.
# floor 강제 상향 시 summary/recommendation 에도 정량 근거 명시 (level↔텍스트 불일치 방지).
Expand Down Expand Up @@ -510,6 +539,8 @@ async def specialist_fair_trade_law(
else:
precedents = precedent_raw or []

# D1 fix: RAG docs 0건 시 caution floor (LLM "자료 없음" → safe 오판 차단).
rag_empty = not docs
rag_text = _format_docs(docs)
precedent_text = _format_precedents(precedents)
mapo_hint = ""
Expand All @@ -522,6 +553,10 @@ async def specialist_fair_trade_law(
"마포구는 caution 이상 권장."
)

# D1 fix: RAG 비었을 때 LLM 에 명시적으로 caution 이상 강제 안내
_rag_empty_directive = (
"- ⚠️ RAG_CONTEXT 가 비어있음(자료 없음) → safe 절대 금지, **caution 이상** 반환 필수\n" if rag_empty else ""
)
user_content = (
f"브랜드: {brand}\n"
f"업종: {business_type}\n"
Expand All @@ -530,7 +565,9 @@ async def specialist_fair_trade_law(
"[평가 기준]\n"
"- 가맹본부 거래강제·필수품목 부당 공급 → danger 후보 (공정거래법 제45조)\n"
"- 마포구 행정동 → 지역상권 상생협력 조례 명시 + caution 이상\n"
"- 부당한 표시광고/허위광고 → caution\n\n"
"- 부당한 표시광고/허위광고 → caution\n"
f"{_rag_empty_directive}"
"\n"
"<<<RAG_CONTEXT>>>\n"
f"{rag_text}\n"
"<<<END_RAG_CONTEXT>>>\n\n"
Expand Down Expand Up @@ -564,6 +601,10 @@ async def specialist_fair_trade_law(
"• 마포구청 상생협력상가위원회 사전 협의\n"
"• 골목상권 보호 영역 여부 확인\n" + (result.recommendation or "")
)
# D1 fix: RAG 0건이면 LLM 결과와 무관하게 caution floor 강제
if rag_empty and result.level == "safe":
result.level = "caution"
result.summary = "[RAG 법조문 자료 부재 — 수동 검토 필요] " + (result.summary or "")
articles = _articles_from_law_docs(docs, max_n=2) + _articles_from_precedent_docs(precedents, max_n=2)
# B 단계: articles 에 케이스 맞춤 1~2문장 explanation 추가
articles = await _explain_articles_batch(articles, brand, business_type, district, "공정거래법/지역조례")
Expand Down Expand Up @@ -628,6 +669,8 @@ async def specialist_building_law(business_type: str, district: str) -> dict:
else:
precedents = precedent_raw or []

# D1 fix: RAG docs 0건 시 caution floor (LLM "자료 없음" → safe 오판 차단).
rag_empty = not docs
rag_text = _format_docs(docs)
precedent_text = _format_precedents(precedents)

Expand All @@ -639,6 +682,10 @@ async def specialist_building_law(business_type: str, district: str) -> dict:
f"{'제한' if is_restricted else ('허용' if is_allowed else '추가 확인 필요')}"
)

# D1 fix: RAG 비었을 때 LLM 에 명시적으로 caution 이상 강제 안내
_rag_empty_directive = (
"- ⚠️ RAG_CONTEXT 가 비어있음(자료 없음) → safe 절대 금지, **caution 이상** 반환 필수\n" if rag_empty else ""
)
user_content = (
f"업종: {business_type} ({biz_label})\n"
f"지역: {district}\n"
Expand All @@ -647,7 +694,9 @@ async def specialist_building_law(business_type: str, district: str) -> dict:
"- 제한 업종 → danger (영업 자체 불가/용도변경 필요)\n"
"- 허용 + 근린생활시설 외 건물 → caution (용도변경 신고 필요)\n"
"- 허용 + 근린생활시설 → safe~caution\n"
"- 위반건축물 등재 시 이행강제금 리스크 별도 caution\n\n"
"- 위반건축물 등재 시 이행강제금 리스크 별도 caution\n"
f"{_rag_empty_directive}"
"\n"
"<<<RAG_CONTEXT>>>\n"
f"{rag_text}\n"
"<<<END_RAG_CONTEXT>>>\n\n"
Expand Down Expand Up @@ -681,6 +730,10 @@ async def specialist_building_law(business_type: str, district: str) -> dict:
f"• 입지 변경 또는 용도변경 신고 (관할 구청 건축과)\n"
f"• 영업신고 전 건물 용도 확인 (건축물대장 발급)\n" + (result.recommendation or "")
)
# D1 fix: RAG 0건이면 LLM 결과와 무관하게 caution floor 강제
if rag_empty and result.level == "safe":
result.level = "caution"
result.summary = "[RAG 법조문 자료 부재 — 수동 검토 필요] " + (result.summary or "")
articles = _articles_from_law_docs(docs, max_n=2) + _articles_from_precedent_docs(precedents, max_n=2)
# B 단계: articles 에 케이스 맞춤 1~2문장 explanation 추가
articles = await _explain_articles_batch(articles, "", business_type, district, f"건축법/{zone}")
Expand Down Expand Up @@ -763,6 +816,9 @@ async def specialist_privacy_law(
else:
precedents = precedent_raw or []

# D1 fix: RAG docs 0건 시 caution floor (LLM "자료 없음" → safe 오판 차단).
# privacy 는 default 가 이미 caution (safe 금지 prompt) 라 보강 차원.
rag_empty = not docs
rag_text = _format_docs(docs)
precedent_text = _format_precedents(precedents)
membership_hint = ""
Expand Down Expand Up @@ -809,6 +865,10 @@ async def specialist_privacy_law(
# 멤버십 키워드면 safe 차단
if has_membership_keyword and result.level == "safe":
result.level = "caution"
# D1 fix: RAG 0건이면 LLM 결과와 무관하게 caution floor 강제
if rag_empty and result.level == "safe":
result.level = "caution"
result.summary = "[RAG 법조문 자료 부재 — 수동 검토 필요] " + (result.summary or "")
articles = _articles_from_law_docs(docs, max_n=2) + _articles_from_precedent_docs(precedents, max_n=2)
# B 단계: articles 에 케이스 맞춤 1~2문장 explanation 추가
articles = await _explain_articles_batch(articles, brand, business_type, "", "개인정보보호법")
Expand Down
39 changes: 9 additions & 30 deletions backend/src/agents/nodes/demographic_depth.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,42 +25,21 @@
)
from src.schemas.state import AgentState

# 동명 → 코드 매핑은 services.dong_resolver 가 SoT (2026-05-04 통합 완료).
# 기존 _MAPO_DONG_CODE_FALLBACK 은 dong_resolver.MAPO_DONG_MAP + _DONG_ALIASES 로 일원화.
from src.services.dong_resolver import resolve_dong_code_or_default

logger = logging.getLogger(__name__)

_CACHE_TTL = 86400 # 24h

# 동명 → 코드 폴백 매핑 (dong_mapping 테이블 기준, 2026-04-22 AWS RDS 실측 검증)
# TODO: 장기적으로 services/population_api.MAPO_DONG_CODES 또는 services/dong_resolver 로 통합해
# Single Source of Truth 유지 (현재는 방어적 fallback 용도)
_MAPO_DONG_CODE_FALLBACK: dict[str, str] = {
# ── 행정동 (16개) ──────────────────────────────────────────
"아현동": "11440555",
"공덕동": "11440565",
"도화동": "11440585",
"용강동": "11440590",
"대흥동": "11440600",
"염리동": "11440610",
"신수동": "11440630",
"서강동": "11440655",
"서교동": "11440660",
"합정동": "11440680",
"망원1동": "11440690",
"망원2동": "11440700",
"연남동": "11440710",
"성산1동": "11440720",
"성산2동": "11440730",
"상암동": "11440740",
# ── 법정동 별칭 ────────────────────────────────────────────
"망원동": "11440690",
"성산동": "11440720",
}


def _resolve_dong_code(district: str) -> str:
"""target_district가 이미 코드면 그대로, 동명이면 매핑. 매칭 실패 시 서교동 기본값."""
if district and district.isdigit() and len(district) == 8:
return district
return _MAPO_DONG_CODE_FALLBACK.get(district, "11440660")
"""target_district가 이미 코드면 그대로, 동명이면 매핑. 매칭 실패 시 서교동 기본값.

NOTE: 시그니처/반환값 동일 — 기존 호출자(demographic_depth_node, 테스트) 영향 없음.
"""
return resolve_dong_code_or_default(district)


def _age_to_range(age_key: str) -> str:
Expand Down
Loading
Loading