o2o-site-AEO/solution/backend/services/grounding/extract.py
Mina Choi 9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — 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
2026-08-31 15:12:09 +09:00

165 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""추출 결과 검증 — "AI 가 원문에서 찾아온 것인가, 지어낸 것인가" 를 코드로 가른다.
★ 이 파일이 없으면 URL·붙여넣기 추출은 쓸 수 없다.
프롬프트로 "지어내지 마라" 를 아무리 적어도 그건 부탁이지 보장이 아니다.
보장은 하나뿐이다 — **모델이 적어 낸 근거 문장이 원문에 실제로 있는지 대조하는 것.**
없으면 그 항목을 버린다. 통과한 것만 fact 후보(UNVERIFIED)로 올라가고,
그 다음 관문은 여전히 사장님 확인이다.
★ 왜 '' 이 아니라 '근거 문장' 을 대조하는가
값만 대조하면 "3" 같은 짧은 값이 원문 아무 데나 있다는 이유로 통과한다
("최대 3인" 을 못 찾아도 "3층" 이 있으면 통과해 버린다).
근거 문장을 통째로 대조하면 그 값이 **그 맥락에서** 나왔다는 것까지 확인된다.
"""
import re
from common.category_schema import CategorySchema
# 원문 대조 전 정규화: 공백·따옴표·괄호 종류 차이로 어긋나는 것을 막는다.
# ★ 글자 자체는 지우지 않는다 — 지우기 시작하면 "불가" 가 사라져 부정이 긍정으로 뒤집힌다.
_WS = re.compile(r"\s+")
_QUOTES = str.maketrans({
"": '"', "": '"', "": "'", "": "'", "": '"',
"": "-", "": "-", "": "-", "~": "~", "": "~",
"": "(", "": ")", "": ":", "": ",", "": ".",
})
# evidence 가 이보다 짧으면 대조가 의미 없다("3" 은 원문 어디에나 있다).
MIN_EVIDENCE = 6
# 값이 evidence 안에 있는지까지 볼 필요가 없는 타입.
# bool 은 "true"/"false" 라 원문에 그 글자가 있을 리 없고,
# 부호 판정은 아래 _bool_ok 가 따로 본다.
_SKIP_VALUE_CHECK = {"bool"}
_FALSE_WORDS = ("false", "불가", "없음", "없슴", "미제공", "제공하지", "안됨", "안 됨", "불가능", "금지", "않습니다", "제한")
_TRUE_WORDS = ("true", "가능", "있음", "제공", "완비", "무료", "구비")
_NUMBER_OK = re.compile(r"^\d+(\.\d+)?$")
def normalize(text: str) -> str:
"""대조용 정규화. 공백을 하나로, 특수 문장부호를 표준형으로."""
return _WS.sub(" ", (text or "").translate(_QUOTES)).strip().lower()
def loose(text: str) -> str:
"""값 대조 전용 — 공백과 자릿수 쉼표까지 지운다.
★ 왜 따로 두는가 (실측)
number 필드는 규칙상 "19000" 으로 오는데 원문 표기는 "19,000원" 이다.
normalize 만으로 대조하면 **모든 메뉴 가격이 '고쳐 쓴 값' 으로 반려된다** —
속초항아리물회 홈페이지에서 가격 5건이 통째로 날아갔다.
쉼표·공백은 같은 수를 다르게 적은 것일 뿐 값을 바꾸지 않으므로 여기서만 지운다.
★ 글자는 여전히 지우지 않는다 — "불가" 가 사라지면 부호가 뒤집힌다.
"""
return normalize(text).replace(",", "").replace(" ", "")
def _bool_ok(value: str, evidence: str) -> tuple[bool, str]:
"""bool 값의 부호가 근거 문장과 맞는가.
★ 부호가 뒤집히면 "반려동물 동반 불가""동반 가능" 이 된다 —
숙박·음식점 모두 critical 항목이라 예약 클레임으로 직결된다.
"""
v = value.strip().lower()
if v not in ("true", "false"):
return False, f"bool 필드인데 값이 'true'/'false' 가 아니다: {value!r}"
ev = evidence.lower()
has_false = any(w in ev for w in _FALSE_WORDS)
has_true = any(w in ev for w in _TRUE_WORDS)
# 근거에 부정 표현이 있는데 true 로 올렸다면 뒤집힌 것이다. 그 반대도 같다.
if v == "true" and has_false and not has_true:
return False, "근거 문장은 부정인데 true 로 올렸다"
if v == "false" and has_true and not has_false:
return False, "근거 문장은 긍정인데 false 로 올렸다"
return True, ""
def verify(
rows: list[dict],
*,
source_text: str,
schema: CategorySchema,
) -> tuple[list[dict], list[tuple[str, str]]]:
"""추출 결과를 걸러 (통과, 반려) 로 나눈다.
반려는 조용히 버리지 않고 (항목, 사유) 로 남긴다 —
운영자가 "왜 이 값이 안 들어왔나" 를 이 목록으로 읽는다.
통과한 dict 는 {key, value, scope, unit_name, evidence} 형태다.
"""
haystack = normalize(source_text)
passed: list[dict] = []
rejected: list[tuple[str, str]] = []
seen: set[tuple[str, str]] = set()
for row in rows:
key = str(row.get("key") or "").strip()
value = str(row.get("value") or "").strip()
unit_name = str(row.get("unit_name") or "").strip()
evidence = str(row.get("evidence") or "").strip()
label = f"{key}={value[:30]}" if key else "(key 없음)"
# 1) 업종 스키마에 있는 key 인가. ★ 없는 key 는 fact 기록 단계에서도 거부되지만,
# 여기서 먼저 걸러야 반려 사유가 남는다.
spec = schema.get(key)
if spec is None:
rejected.append((label, f"업종 스키마에 없는 key: {key!r}"))
continue
if not value:
rejected.append((label, "값이 비었다"))
continue
# 2) 스코프 계약. unit 스코프인데 이름이 없으면 어느 객실 것인지 알 수 없다.
if spec.scope == "unit" and not unit_name:
rejected.append((label, "unit 스코프인데 unit_name 이 없다"))
continue
if spec.scope == "place" and unit_name:
unit_name = "" # place 스코프에 이름이 붙어 온 것은 무시하고 진행한다
# 3) 근거 문장이 원문에 실제로 있는가. ★ 이 검사가 이 파일의 존재 이유다.
if len(evidence) < MIN_EVIDENCE:
rejected.append((label, f"근거 문장이 너무 짧다({len(evidence)}자) — 대조할 수 없다"))
continue
if normalize(evidence) not in haystack:
rejected.append((label, "근거 문장이 원문에 없다 — 지어낸 값으로 본다"))
continue
# 4) 타입 계약.
if spec.type == "bool":
ok, why = _bool_ok(value, evidence)
if not ok:
rejected.append((label, why))
continue
elif spec.type == "number":
if not _NUMBER_OK.match(value.replace(",", "")):
rejected.append((label, f"number 필드인데 숫자가 아니다: {value!r}"))
continue
value = value.replace(",", "")
# 5) 값이 근거 안에 실제로 적혀 있는가(bool 제외).
# ★ 근거는 원문에서 가져왔는데 값이 그 안에 없다면, 값 쪽을 모델이 고쳐 쓴 것이다
# — "오후 3시" 를 "15:00" 으로 환산한 경우가 여기서 걸린다.
if spec.type not in _SKIP_VALUE_CHECK and loose(value) not in loose(evidence):
rejected.append((label, "값이 근거 문장 안에 없다 — 원문 표기를 고쳐 쓴 것으로 본다"))
continue
# 6) 같은 (key, unit) 중복은 첫 건만.
dedupe = (key, unit_name)
if dedupe in seen:
rejected.append((label, "같은 key 가 이미 들어왔다"))
continue
seen.add(dedupe)
passed.append({
"key": key,
"value": value,
"scope": spec.scope,
"unit_name": unit_name or None,
"evidence": evidence,
})
return passed, rejected