최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.
backend/ frontend/{admin,site,shared} → solution/{backend,front,site,shared} + admin/
## 왜
내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.
그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
(앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).
## admin 에 백엔드를 두지 않았다
내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.
## admin 의 `@` 는 solution/front/src 를 가리킨다
내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.
admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.
## 그 밖
- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
(conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.
검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
256 lines
11 KiB
Python
256 lines
11 KiB
Python
"""소개문·FAQ 의 근거 검증 — LLM 이 지어낸 문장을 걸러내는 규칙.
|
|
|
|
★ 왜 코드로 또 검사하나
|
|
프롬프트에 "주어진 사실만 써라" 를 몇 번이나 못박아도 모델은 종종 어긴다.
|
|
프롬프트는 1차 방어일 뿐이고, 실제로 손님에게 나갈 문장을 지키는 건 여기다.
|
|
틀린 요금·없는 시설이 사이트에 실리면 그건 그대로 예약 클레임이 된다.
|
|
|
|
★ 왜 호출 코드에서 떼어냈나
|
|
한때 이 규칙이 Gemini HTTP 호출과 같은 파일에 500줄로 뭉쳐 있었다.
|
|
"FAQ 가 멀쩡한데 반려된다" 를 고치려면 호출·재시도 코드를 헤치고 들어가야 했다.
|
|
검증 규칙은 외부 호출이 전혀 없는 순수 함수라 여기서 따로 읽고 따로 테스트한다.
|
|
|
|
프롬프트를 고치려면 services/prompts/copy.py, 호출을 고치려면 services/llm/gemini.py 다.
|
|
"""
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
# ── 시설 어휘 사전 ────────────────────────────────────────────────────────
|
|
# 문장에 이 낱말이 나오면 대응 fact key 중 하나가 **반드시** 있어야 한다.
|
|
# 어느 업종 스키마에도 없는 key(pool/sauna/elevator …)로 매핑된 낱말은 언제나 반려된다 —
|
|
# 그게 바로 "fact 에 없는 시설을 지어낸" 경우다.
|
|
_FACILITY_VOCAB: dict[str, tuple[str, ...]] = {
|
|
# 실제로 스키마에 있는 것
|
|
"바비큐": ("bbq_available", "bbq_fee"),
|
|
"BBQ": ("bbq_available", "bbq_fee"),
|
|
"주차": ("parking", "parking_capacity", "parking_free_hours"),
|
|
"와이파이": ("wifi",),
|
|
"wifi": ("wifi",),
|
|
"Wi-Fi": ("wifi",),
|
|
"인터넷": ("wifi",),
|
|
"조식": ("breakfast",),
|
|
"아침 식사": ("breakfast",),
|
|
"반려동물": ("pet_allowed",),
|
|
"애완": ("pet_allowed",),
|
|
"취사": ("cooking_allowed", "has_kitchen"),
|
|
"조리": ("cooking_allowed", "has_kitchen"),
|
|
"주방": ("has_kitchen", "cooking_allowed"),
|
|
"흡연": ("smoking",),
|
|
"픽업": ("pickup_service",),
|
|
"유아용품": ("baby_amenities",),
|
|
"에어컨": ("has_aircon",),
|
|
"냉방": ("has_aircon",),
|
|
"테라스": ("terrace",),
|
|
"야외석": ("terrace",),
|
|
"콘센트": ("power_outlet",),
|
|
"배달": ("delivery",),
|
|
"포장": ("takeout",),
|
|
"테이크아웃": ("takeout",),
|
|
"휠체어": ("wheelchair_accessible",),
|
|
"물품보관함": ("locker_available",),
|
|
"락커": ("locker_available",),
|
|
"샤워": ("shower_available",),
|
|
"콜키지": ("corkage",),
|
|
"단체석": ("group_seat_max",),
|
|
"노키즈": ("kids_allowed",),
|
|
"예약 필수": ("reservation_required",),
|
|
# 어느 스키마에도 없는 시설 — 언급되면 무조건 근거 없음
|
|
"수영장": ("pool",),
|
|
"온수풀": ("pool",),
|
|
"스파": ("spa",),
|
|
"사우나": ("sauna",),
|
|
"찜질": ("sauna",),
|
|
"엘리베이터": ("elevator",),
|
|
"헬스": ("gym",),
|
|
"피트니스": ("gym",),
|
|
"노래방": ("karaoke",),
|
|
"당구": ("billiard",),
|
|
"족구": ("sports_court",),
|
|
"카라반": ("caravan",),
|
|
"글램핑": ("glamping",),
|
|
"루프탑": ("rooftop",),
|
|
"정원": ("garden",),
|
|
"오션뷰": ("view",),
|
|
"조식뷔페": ("breakfast",),
|
|
}
|
|
|
|
# 값이 '아니다'를 뜻하는 표기. 이 값인데 문장이 긍정으로 쓰면 반려한다.
|
|
_FALSY = {"false", "0", "no", "n", "불가", "불가능", "없음", "미제공", "제공안함", "안됨", "없습니다"}
|
|
|
|
# 부정 표현. 시설 낱말 뒤 이 범위 안에 있으면 '없다'고 말한 것으로 본다.
|
|
_NEGATION = ("불가", "안 됩", "안됩", "안 돼", "안돼", "없습니다", "없음", "않습니다", "않으", "제한", "금지", "미제공", "어렵습니다")
|
|
_NEGATION_WINDOW = 24
|
|
|
|
# 문장 분리용. 의문문은 '주장' 이 아니라서 값 반대 판정(3번)에서 제외한다 —
|
|
# "반려동물 동반이 가능한가요?" 는 사실을 주장하는 게 아니라 묻는 것이다(실호출에서 오탐 확인).
|
|
_SENT_SPLIT = re.compile(r"(?<=[.!?。])\s*|\n+")
|
|
|
|
# 홍보성·과장 표현. fact 값에 그대로 들어있지 않으면 반려한다.
|
|
# (객실명이 "프리미엄 스위트" 라면 fact 값에 있으므로 통과한다.)
|
|
_PROMOTIONAL = (
|
|
"최고", "최상", "최적", "최대한", "완벽", "국내 최", "업계 1위", "1위", "명품", "럭셔리",
|
|
"최고급", "독보적", "비교불가", "단연", "손꼽히는", "자랑하는", "환상적", "황홀", "잊지 못할",
|
|
"특별한 추억", "아름다운", "쾌적한", "넓고", "저렴한", "합리적인 가격",
|
|
)
|
|
|
|
# 숫자 뒤 한글 자릿수. "2만원" 을 20000 으로도 본다.
|
|
_SCALE = {"만": 10_000, "천": 1_000, "억": 100_000_000}
|
|
|
|
_NUM_RE = re.compile(r"(\d[\d,]*)\s*([만천억])?")
|
|
|
|
|
|
@dataclass
|
|
class FactInput:
|
|
"""생성 근거로 넘기는 확보된 fact 1건. 검증도 이 목록으로만 한다."""
|
|
|
|
key: str
|
|
label: str
|
|
value: str
|
|
unit: Optional[str] = None
|
|
|
|
|
|
# ── 근거 검증 ─────────────────────────────────────────────────────────────
|
|
def _number_occurrences(text: str) -> list[tuple[str, set[str]]]:
|
|
"""문자열의 숫자를 **등장 단위로** 뽑는다. (표기, 같은 값으로 볼 수 있는 후보들)
|
|
|
|
"2만원" → ("2만", {"2", "20000"}) ← 둘 중 하나만 fact 에 있으면 근거가 있는 것이다
|
|
"20,000" → ("20,000", {"20000"})
|
|
"15:00" → ("15", {"15"}), ("00", {"0"})
|
|
|
|
등장 단위로 묶는 이유: "2만원" 을 {"2","20000"} 로 평평하게 펴면
|
|
fact 에 20000 이 있어도 "2" 가 근거 없다며 반려된다."""
|
|
out: list[tuple[str, set[str]]] = []
|
|
for raw, scale in _NUM_RE.findall(text or ""):
|
|
digits = raw.replace(",", "")
|
|
if not digits:
|
|
continue
|
|
alts = {digits.lstrip("0") or "0"}
|
|
if scale:
|
|
try:
|
|
alts.add(str(int(digits) * _SCALE[scale]))
|
|
except ValueError:
|
|
pass
|
|
out.append((raw + (scale or ""), alts))
|
|
return out
|
|
|
|
|
|
def _fact_number_tokens(facts: list[FactInput]) -> set[str]:
|
|
"""fact 값에 등장하는 모든 숫자 표현. 여기서는 평평하게 펴도 된다(대조 대상이라서)."""
|
|
tokens: set[str] = set()
|
|
for f in facts:
|
|
for _raw, alts in _number_occurrences(f.value or ""):
|
|
tokens |= alts
|
|
return tokens
|
|
|
|
|
|
def _fact_key_map(facts: list[FactInput]) -> dict[str, FactInput]:
|
|
return {f.key: f for f in facts}
|
|
|
|
|
|
def _is_falsy(value: str) -> bool:
|
|
v = (value or "").strip().lower()
|
|
return v in _FALSY
|
|
|
|
|
|
def _negated_near(text: str, pos: int, word_len: int) -> bool:
|
|
"""시설 낱말 바로 뒤에 부정 표현이 있는지 — '반려동물 동반 불가' 를 긍정 주장으로 보지 않게."""
|
|
window = text[pos + word_len: pos + word_len + _NEGATION_WINDOW]
|
|
return any(n in window for n in _NEGATION)
|
|
|
|
|
|
def _is_question_at(text: str, pos: int) -> bool:
|
|
"""그 위치가 의문문 안인지. 의문문은 사실을 주장하지 않는다.
|
|
|
|
FAQ 의 질문("반려동물 동반이 가능한가요?")을 긍정 주장으로 오해하면
|
|
멀쩡한 문답이 통째로 버려진다 — 실호출에서 실제로 겪은 오탐이다."""
|
|
start = 0
|
|
for match in _SENT_SPLIT.finditer(text):
|
|
if match.start() > pos:
|
|
break
|
|
start = match.end()
|
|
end = len(text)
|
|
for match in _SENT_SPLIT.finditer(text, pos):
|
|
end = match.start()
|
|
break
|
|
sentence = text[start:end]
|
|
return sentence.rstrip().endswith("?") or "나요" in sentence or "까요" in sentence
|
|
|
|
|
|
def faq_polarity_ok(question: str, answer: str, facts: list[FactInput]) -> tuple[bool, list[str]]:
|
|
"""FAQ 전용 — 질문이 '불가한 시설' 을 물었으면 **답변이 반드시 부정해야** 한다.
|
|
|
|
질문은 주장이 아니라 ground_check 의 값-반대 판정에서 빠진다. 그 빈틈을 여기서 막는다:
|
|
pet_allowed=false 인데 "가능한가요?" 라 묻고 "네, 가능합니다" 라 답하면 잡아야 한다."""
|
|
key_map = _fact_key_map(facts)
|
|
reasons: list[str] = []
|
|
lowered_q = (question or "").lower()
|
|
for word, keys in _FACILITY_VOCAB.items():
|
|
if lowered_q.find(word.lower()) < 0:
|
|
continue
|
|
falsy = [k for k in keys if k in key_map and _is_falsy(key_map[k].value)]
|
|
if not falsy:
|
|
continue
|
|
if not any(n in (answer or "") for n in _NEGATION):
|
|
reasons.append(f"'{word}' 은 {falsy[0]}='{key_map[falsy[0]].value}' 인데 답변이 부정하지 않는다")
|
|
return (not reasons), reasons
|
|
|
|
|
|
def ground_check(text: str, facts: list[FactInput]) -> tuple[bool, list[str]]:
|
|
"""생성된 문장이 fact 로 뒷받침되는지 검사한다.
|
|
|
|
★ 이게 이 모듈의 핵심이다. 프롬프트로 "지어내지 마라" 라고 해도 모델은 종종 지어낸다.
|
|
그래서 결과를 코드로 검증하고, 통과 못 하면 문장을 버린다.
|
|
|
|
잡아내는 것:
|
|
1. 문장에 나온 숫자·시각·금액이 fact 값에 없다 → 근거 없는 수치
|
|
2. fact 에 없는 시설을 언급했다 (수영장·사우나·엘리베이터 …)
|
|
3. fact 값이 '불가/없음' 인데 긍정문으로 썼다 (pet_allowed=false 인데 "반려동물 동반 가능")
|
|
4. 홍보성 과장 표현 ("국내 최고의", "완벽한")
|
|
|
|
반환: (통과 여부, 사유 목록)
|
|
"""
|
|
if not (text or "").strip():
|
|
return False, ["빈 문장"]
|
|
|
|
reasons: list[str] = []
|
|
key_map = _fact_key_map(facts)
|
|
fact_values = " ".join((f.value or "") for f in facts)
|
|
fact_numbers = _fact_number_tokens(facts)
|
|
|
|
# 1. 근거 없는 수치 — 등장마다 후보 중 하나라도 fact 에 있으면 통과
|
|
seen_bad: set[str] = set()
|
|
for raw, alts in _number_occurrences(text):
|
|
if not (alts & fact_numbers) and raw not in seen_bad:
|
|
seen_bad.add(raw)
|
|
reasons.append(f"근거 없는 수치 '{raw}' — fact 값에 없다")
|
|
|
|
# 2·3. 시설 언급
|
|
lowered = text.lower()
|
|
for word, keys in _FACILITY_VOCAB.items():
|
|
pos = lowered.find(word.lower())
|
|
if pos < 0:
|
|
continue
|
|
if word in fact_values: # fact 값에 그 낱말이 그대로 있으면 근거가 있다
|
|
continue
|
|
present = [k for k in keys if k in key_map]
|
|
if not present:
|
|
reasons.append(f"근거 없는 시설 언급 '{word}' — 해당 fact 가 없다")
|
|
continue
|
|
# 값이 '아니다'인데 부정 없이 **주장**했다 → 반대로 말한 것.
|
|
# 의문문은 주장이 아니므로 제외한다(FAQ 질문이 통째로 버려지는 것을 막는다).
|
|
if (
|
|
all(_is_falsy(key_map[k].value) for k in present)
|
|
and not _negated_near(text, pos, len(word))
|
|
and not _is_question_at(text, pos)
|
|
):
|
|
reasons.append(f"사실과 반대 '{word}' — {present[0]} 값이 '{key_map[present[0]].value}' 다")
|
|
|
|
# 4. 홍보성 과장
|
|
for phrase in _PROMOTIONAL:
|
|
if phrase in text and phrase not in fact_values:
|
|
reasons.append(f"홍보성 표현 '{phrase}'")
|
|
|
|
return (not reasons), reasons
|
|
|