Merge remote-tracking branch 'origin/main' into feature/social-post

# Conflicts:
#	docs/DECISIONS.md
#	docs/DEVLOG.md
#	solution/backend/requirements.txt
#	solution/backend/scheduler/__init__.py
#	solution/backend/worker/handlers.py
#	solution/site/src/pages/HomePage.tsx
#	solution/site/src/sections/index.ts
This commit is contained in:
hbyang 2026-09-16 08:46:48 +09:00
commit 58d6249102
502 changed files with 127276 additions and 5267 deletions

View File

@ -42,6 +42,11 @@ TOUR_API_KEY=
# 그래도 API 가 필수로 요구하는 필드라 값을 채워 보낸다.
SUNO_API_KEY=
SUNO_CALLBACK_URL=https://example.com/api/suno/callback
# 발행 사이트 메타 키워드(keywords · 제목)를 받아 올 SiteOntology 주소(o2o-site-ontology, 기본 :3100).
# 비우면 그 단계만 건너뛴다 — 제목·메타가 예전 그대로 나간다.
# ★ 워커가 부르는 주소다. compose 로 띄우면 컨테이너 안에서 보는 주소(http://host.docker.internal:3100),
# 백엔드를 네이티브로 돌리면 http://127.0.0.1:3100
SITE_ONTOLOGY_URL=
# ── SNS 게재(스레드) ────────────────────────────────────────────────
# 사장님이 [SNS에 알리기] 를 누르면 확인된 fact 로 짧은 글을 쓰고, 승인을 받아
@ -103,6 +108,14 @@ PUBLIC_WEB_BASE_URL=http://localhost
# SITE_PUBLIC_HOST=web4ai.o2osolution.ai
# 비우면 색인 통보를 건너뛴다(발행은 정상)
INDEXNOW_KEY=
# Google Search Console — 최초 소유권/서비스 계정 권한 설정 후 켠다 (docs/SEARCH_CONSOLE.md).
GSC_ENABLED=0
GSC_PROPERTY_URL=
GSC_CREDENTIALS_FILE=
GSC_CREDENTIALS_HOST_FILE=
GSC_ALERT_DAYS=7
GSC_ALERT_WEBHOOK_URL=
# 비우면 로컬 발행만 한다
AZURE_STORAGE_CONNECTION_STRING=
AZURE_STORAGE_CONTAINER=

View File

@ -1,5 +1,8 @@
# AGENTS.md — 이 레포에서 작업하기 전에
> 2026-09-15 발행 버전 전환: [docs/PUBLISH_VERSION.md](docs/PUBLISH_VERSION.md)가
> 아래의 상시 프리렌더·재굽기·자산 주소 교체 절차를 대체한다. 목업은 변경하지 않는다.
에이전트와 신규 합류자가 **먼저 읽는 파일**이다. 여기에는 *밟기 쉬운 함정*과 *규약*만 둔다.
설명은 각 문서가 단일 출처다 — 여기로 복사하지 말고 링크한다.
@ -34,7 +37,48 @@
**`out/assets` 에서 파일을 지우는 코드는 `out/s/**` 의 HTML 이 참조하는 것을 먼저 뺀다**
(`prerender.ts` `referencedAssets`). 보관 기간으로는 못 막는다 — 기간이 지나면 같은 일이 난다.
→ 목업을 다루는 작업은 `out/s/` 를 먼저 열어 **payload 가 없는 디렉토리가 무엇인지** 본다.
→ ★ **`stay` 는 프리렌더가 굽지 않는다** (`prerender.ts` `PROTECTED_SLUGS`, 기본 `stay` ·
`PRERENDER_PROTECTED_SLUGS` 로 덮어쓴다). "payload 가 없으면 안 굽는다"
는 보호가 못 된다 — **payload 가 생기는 순간** 덮인다. 실측(2026-09-15): 누가 빌더에서
슬러그 `stay` 로 발행해 `payloads/stay.json` 이 생기자 프리렌더가 `/s/stay` 를 그 payload 로
구워 목업을 통째로 날렸다(캐치프레이즈 100개·미니 플레이어·날씨 문구·주입분 전부).
그 payload 는 `solution/site/payloads-mockup-hold/` 로 옮긴다 — 지우면 재발행 때 또 온다.
- **★ 굽기는 네트워크를 탄다 — 사진을 내려받는다** (`prerender.ts` `mirrorMedia`).
`payload.media[].url` 이 남의 도메인이면 `out/s/<slug>/img/<주소해시>.<확장자>` 로 받아 놓고
payload 의 주소를 **우리 오리진 절대주소**로 바꾼 뒤에 굽는다. 이유는 캔버스다 —
수집처(`*.pstatic.net` · `tong.visitkorea.or.kr`)가 `Access-Control-Allow-Origin` 을 안 줘서
그 사진을 캔버스에 그리면 오염돼 `toBlob` 이 막히고, **엽서 쓰기의 저장·공유가 모든 발행
사이트에서 죽어 있었다**(실측 2026-09-15). 클라이언트에서는 못 넘는다.
→ 못 받은 사진은 **원래 주소를 그대로 쓴다**(사진이 사라지는 것보다 낫다). 로그에 한 줄 남는다.
→ 주소가 그대로면 파일명도 그대로라 **다시 구워도 내려받지 않는다.** 처음 한 번만 느리다.
→ ★ **이미 나가 있는 사이트는 그대로 둔다.** 새 기능은 사장님이 **다시 발행할 때** 들어간다
(아래 항목). 그 사이를 메우는 건 **중계**다 — `/v1/image/relay?url=…`
(`backend/router/v1/media/relay.py`). 캔버스가 CORS 로 사진을 못 받으면 같은 오리진의
이 주소로 한 번 더 받아 본다(`site/src/lib/postcard-canvas.ts` `loadImage`).
열린 프록시가 아니다 — https · 호스트 allowlist · 이미지 타입 · 8MB · 리다이렉트 후
호스트 재검사. **호스트를 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는가" 를 먼저 본다.**
`originUrl` · `sourceType` 은 손대지 않는다 — 재게시 권리(DECISIONS 1-2)가 "불가" 로
결론 나면 `sourceType = CRAWL` 을 빼는 그 대응이 그대로 먹어야 한다.
- **★★ 배포해도 기존 사이트를 다시 굽지 않는다 — 자산 주소만 갈아 끼운다.**
(2026-09-15 대표 지시: "전체 재굽기 할 필요가 없어, 사장님이 재발행하면 끝인데 /
css js만 안 깨지게 하란 말이야")
예전에는 `solution-prerender` 가 뜰 때마다 payload 를 **전부 다시 구웠다.** 그러면 렌더러를
한 줄 고칠 때마다 이미 나가 있는 사이트의 HTML 이 통째로 바뀐다 — 사장님은 발행한 적이
없는데 내용이 달라지고, 구글이 다시 읽어 가는 값도 달라진다.
지금 기동이 하는 일은 `prerender.js --refresh-assets` 하나다
(`watch-payloads.mjs` `refreshAssets``prerender.ts` `refreshBakedAssets`):
→ 구워진 `index.html` 안의 `assets/index-<해시>.css|js` **파일명만** 새 번들로 바꾼다.
내용·구조·payload 는 손대지 않는다. 접두사(`/assets` · `/sites/assets`)도 그대로 둔다.
**한 번도 안 구워진 payload 만** 굽는다(볼륨이 비었거나 감시가 꺼진 새 발행).
→ ★ **payload 가 없는 디렉토리는 건드리지 않는다**(목업 `stay3` · `*.old`, 그리고
`PROTECTED_SLUGS`). 손으로 만든 유일본에 최신 번들을 물렸다가 깨지면 되돌릴 수 없다 —
그쪽 번들 교체는 사람이 한다(mockup/README "번들만 갈아 끼운다").
대상은 `--payload-dir``<슬러그>.json` 이 있는 사이트뿐이다.
★ 그래서 **정적 HTML 은 옛 렌더러의 것이고 스크립트는 새 렌더러**다. 어긋나면 리액트가
그 자리에서 다시 그리므로 손님 화면은 새것이지만, **크롤러가 읽는 HTML 은 옛것**이다.
둘을 맞추는 방법은 재발행뿐이고 그건 사장님이 누른다. 급하면 `republish_all.py` 지만
**먼저 묻는다** — 전 사이트의 발행일이 한꺼번에 움직이는 일이다.
- **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로
가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다
(`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다.
@ -50,15 +94,19 @@
없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다.
옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
→ 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.**
→ 이미 끊겼다면 복구는 `docker compose restart solution-prerender` (기동하며 전체 재굽기).
→ 이미 끊겼다면 복구는 `docker compose restart solution-prerender` — 기동이 공용 자산을
다시 깔고 구워진 HTML 의 자산 주소를 맞춘다(전체 재굽기가 아니다, 위 ★★ 항목).
- **★ 사이트를 굽는 컨테이너는 `solution-prerender` 다.** `solution-frontend` 는 **개발용**이라
운영에서는 아예 뜨지 않는다(`docker-compose.yml` `profiles: ["dev"]`). 이름이 비슷해서
`restart solution-frontend` 를 치면 **아무 일도 안 일어나는데 명령은 성공한다**
재굽기를 했다고 믿고 넘어가게 된다. 실제로 그렇게 복구가 한 번 헛돌았다(2026-09-07).
- **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
`azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 —
**렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.**
`docker compose restart solution-prerender``python scripts/republish_all.py`
- **★ 프론트(`solution/site`)를 고쳐도 기존 사이트의 내용은 안 바뀐다.** 기동은 자산 주소만
맞춘다(위 ★★ 항목) — 새 렌더러로 다시 그려지는 건 **그 사장님이 다시 발행할 때**다.
Azure 를 쓰는 경우엔 한 겹 더 있다: `azure_static.publish(slug)` 는 공용 자산 + `s/<slug>`
올린다 — 다른 사이트의 블롭은 그대로다.
→ 전 사이트를 한꺼번에 새 렌더러로 맞춰야 할 일이 생기면
`docker compose restart solution-prerender``python scripts/republish_all.py` 인데,
**먼저 묻는다**(발행일이 전부 움직인다).
- **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`,
`site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부
이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더

137
deploy.sh
View File

@ -1,127 +1,40 @@
#!/usr/bin/env bash
# 배포 — 코드를 당기고, 지정한 서비스만 다시 빌드해 갈아끼운다.
#
# ./deploy.sh 전체
# ./deploy.sh solution-backend 그 서비스만
# ./deploy.sh solution-backend solution-worker 여럿
#
# 서비스명 대신 컨테이너명(o2o-web4ai-solution-backend)으로 불러도 받는다.
# 서버 변경과 꺼진 admin을 보존한다. 렌더러 배포도 기존 사이트를 재굽지 않는다.
set -euo pipefail
cd "$(dirname "$0")"
PREFIX=o2o-web4ai
# api·worker·api-admin 은 이미지 한 벌(o2o-web4ai-backend)을 나눠 쓴다.
BACKEND_SVCS=(solution-backend solution-worker admin-backend)
BRANCH=${DEPLOY_BRANCH:-main}
PULL=1
ONLY=0
TARGETS=()
usage() {
cat <<'USAGE'
사용법: ./deploy.sh [옵션] [서비스...]
옵션
--no-pull 코드를 당기지 않는다(디스크에 있는 코드 그대로 빌드)
--only 백엔드 형제 서비스를 함께 갈아끼우지 않는다 (아래 ★ 참고)
-h, --help
환경변수
DEPLOY_BRANCH 기본 main. 다른 브랜치를 배포할 때만 쓴다
서비스: solution-backend · solution-worker · solution-prerender · solution-site
admin-backend · admin-frontend (프로필 admin, 기본 기동에서 빠져 있다)
solution-frontend (프로필 dev, 로컬 HMR 전용 — 운영에서 띄우지 않는다)
o2o-web4ai-solution-backend 처럼 컨테이너명으로 적어도 된다
★ solution-backend·solution-worker·admin-backend 는 이미지가 한 벌이다. 하나를 빌드하면 나머지도 새 이미지로
갈아끼워야 한다 — 안 그러면 옛 코드로 도는 컨테이너가 남는데, 셋 다 "살아 있음" 이라
화면상으로는 배포가 끝난 것처럼 보인다. --only 는 그걸 알고 건너뛸 때만 쓴다.
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
for arg in "$@"; do
case "$arg" in
--no-pull) PULL=0 ;;
--only) ONLY=1 ;;
-h|--help) usage; exit 0 ;;
-*) echo "모르는 옵션: $1" >&2; usage >&2; exit 2 ;;
*) TARGETS+=("${1#"$PREFIX"-}") ;; # 컨테이너명으로 불러도 받는다
-h|--help) echo "사용법: ./deploy.sh [--no-pull] [--only] [서비스...]"; exit 0 ;;
-*) echo "모르는 옵션: $arg" >&2; exit 2 ;;
*) TARGETS+=("${arg#o2o-web4ai-}") ;;
esac
shift
done
ALL_SVCS=$(docker compose config --services)
for t in ${TARGETS+"${TARGETS[@]}"}; do
grep -qx "$t" <<<"$ALL_SVCS" || {
echo "그런 서비스가 없다: $t" >&2
echo "있는 것: $(tr '\n' ' ' <<<"$ALL_SVCS")" >&2
exit 2
}
done
# ── 코드 ────────────────────────────────────────────────────────────
# 배포 서버의 작업트리는 main 의 **사본**이지 작업 공간이 아니다. 그래서 머지(pull)가 아니라
# origin/$BRANCH 로 하드 리셋한다 — 밖에서 force-push 가 나도 --ff-only 로 막히지 않고,
# 서버에서 누가 손댄 흔적이 다음 배포까지 살아남지 않는다.
#
# ★ fetch 가 실패하면 **리셋하지 않는다.** 이 서버엔 gitea 자격증명이 없어 fetch 가 죽는데,
# 그 상태의 origin/$BRANCH 는 마지막으로 fetch 된 낡은 ref 다.
# 실측(2026-09-01 킹서버): HEAD=9b4fe40 인데 origin/main=4871e50 — 믿고 리셋하면 한 커밋
# 롤백된다. 그러고도 빌드는 성공하고 컨테이너는 뜬다. 조용히 틀리는 종류다.
#
# ★ git clean 은 하지 않는다. .env 와 nginx/site.conf 는 추적되지 않는 파일이고 서버마다
# 다르다 — reset --hard 는 이 둘을 건드리지 않지만 clean 은 지운다.
if [ "$PULL" = 1 ]; then
echo "▶ git fetch origin $BRANCH"
if git fetch --prune origin "$BRANCH" 2>&1; then
DIRTY=$(git status --porcelain)
if [ -n "$DIRTY" ]; then
echo " ! 작업트리 변경을 버린다:"
sed 's/^/ /' <<<"$DIRTY"
git diff --quiet && git diff --cached --quiet || { echo "서버 변경을 먼저 정리하세요" >&2; exit 1; }
git pull --ff-only origin "${DEPLOY_BRANCH:-main}"
fi
echo "▶ git reset --hard origin/$BRANCH"
git reset --hard "origin/$BRANCH"
else
echo " ! fetch 실패 — 리셋을 건너뛰고 디스크에 있는 코드 그대로 간다." >&2
echo " origin/$BRANCH 가 낡았을 수 있어 그걸로 리셋하면 배포가 조용히 롤백된다." >&2
echo " (밖에서 밀어넣었으면 이대로 두면 되고, 자동화하려면 gitea 배포키를 건다)" >&2
fi
fi
echo "▶ 지금 코드: $(git log --oneline -1)"
# ── 대상 정하기 ─────────────────────────────────────────────────────
if [ ${#TARGETS[@]} -eq 0 ]; then
SVCS=() # 빈 인자 = compose 가 전부로 해석한다
echo "▶ 대상: 전체"
else
SVCS=("${TARGETS[@]}")
# 백엔드 하나를 건드리면 같은 이미지를 쓰는 형제도 함께 갈아끼운다.
[ ${#TARGETS[@]} -gt 0 ] || TARGETS=(solution-backend solution-worker solution-site)
RUNNING=$(docker compose ps --services --status running)
if [ "$ONLY" = 0 ]; then
for t in "${TARGETS[@]}"; do
for b in "${BACKEND_SVCS[@]}"; do [ "$t" = "$b" ] || continue
for sib in "${BACKEND_SVCS[@]}"; do
printf '%s\n' "${SVCS[@]}" | grep -qx "$sib" || {
SVCS+=("$sib")
echo " + $sib — 같은 이미지를 쓴다(옛 코드로 남지 않게 함께 간다)"
}
done
done
done
case " ${TARGETS[*]} " in
*" solution-backend "*|*" solution-worker "*)
TARGETS+=(solution-backend solution-worker)
if grep -qx admin-backend <<<"$RUNNING"; then TARGETS+=(admin-backend); fi ;;
esac
fi
echo "▶ 대상: ${SVCS[*]}"
fi
# ── 빌드 · 교체 ─────────────────────────────────────────────────────
# build 절이 없는 서비스(node:24-alpine · nginx:alpine)는 build 가 조용히 건너뛴다.
echo "▶ build"
docker compose build ${SVCS+"${SVCS[@]}"}
echo "▶ up -d"
docker compose up -d --force-recreate ${SVCS+"${SVCS[@]}"}
echo
SERVICES=()
for target in "${TARGETS[@]}"; do
case " ${SERVICES[*]-} " in *" $target "*) ;; *) SERVICES+=("$target");; esac
done
# site의 렌더러도 worker 이미지에 들어간다.
case " ${SERVICES[*]} " in
*" solution-site "*) case " ${SERVICES[*]} " in *" solution-worker "*) ;; *) SERVICES+=(solution-worker);; esac ;;
esac
docker compose build "${SERVICES[@]}"
docker compose up -d --no-deps --force-recreate "${SERVICES[@]}"
docker compose ps
echo
echo "로그: ./log.sh"

View File

@ -0,0 +1,13 @@
# 선택 연동. 키 파일은 저장소 밖에 두고 기존 API 스케줄러에만 읽기 전용으로 전달한다.
# docker compose -f docker-compose.yml -f docker-compose.search-console.yml up -d solution-backend
services:
solution-backend:
environment:
GSC_CREDENTIALS_FILE: /run/secrets/search-console.json
volumes:
- type: bind
source: ${GSC_CREDENTIALS_HOST_FILE:?Search Console 키 파일 절대경로 필요}
target: /run/secrets/search-console.json
read_only: true
bind:
create_host_path: false

View File

@ -28,8 +28,12 @@ x-common-env: &common-env
# ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
# 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-localhost}
SITE_PAYLOAD_DIR: /app/out/payloads
SITE_OUTPUT_DIR: /app/out/sites
# ★ 렌더러(solution/site/scripts/prerender.ts)가 songs·out 디렉토리를 **자기 파일 위치
# 기준 상대경로**로 찾는다(SITE_ROOT = dist/prerender/../..) — 워커 컨테이너 안에서 그
# 렌더러를 직접 띄우므로(render_service.py) 세 디렉토리가 실제 설치 자리
# (/app/solution/site/) 아래 나란히 있어야 한다. 아래 볼륨 마운트와 짝이 맞아야 한다.
SITE_PAYLOAD_DIR: /app/solution/site/payloads
SITE_OUTPUT_DIR: /app/solution/site/out
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
@ -48,10 +52,10 @@ services:
# ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다.
SCHEDULER_ENABLED: "1"
volumes:
- ./solution/site/payloads:/app/out/payloads
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
- ./solution/site/songs:/app/out/songs
- ./solution/site/payloads:/app/solution/site/payloads
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 워커가 렌더러를
# 돌릴 때 사이트 디렉토리로 복사된다(services/song_service · site/scripts/prerender.ts).
- ./solution/site/songs:/app/solution/site/songs
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
- ./postgres-init:/app/postgres-init:ro
@ -64,13 +68,17 @@ services:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# ★ BUILD·ROLLBACK 잡이 렌더러(solution/site)를 subprocess 로 직접 돌린다
# (services/render_service.py) — 예전에 solution-prerender 컨테이너가 하던 일이다.
# 그래서 이 서비스만 **다른 이미지**(Dockerfile.worker, Node 런타임 + 컴파일된 렌더러
# 포함)를 쓴다. solution-backend·admin-backend 는 그 Node 를 쓸 일이 없다.
solution-worker:
build:
context: .
dockerfile: solution/backend/Dockerfile
image: o2o-web4ai-backend
dockerfile: solution/backend/Dockerfile.worker
image: o2o-web4ai-worker
container_name: o2o-web4ai-solution-worker
command: ["python", "worker_main.py"]
command: ["sh", "-c", "node /app/solution/site/dist/prerender/prerender.js --seed-assets --out=/app/solution/site/out && exec python worker_main.py"]
env_file:
- .env
environment:
@ -79,15 +87,20 @@ services:
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1}
JOB_DEADLINE_SEC: ${JOB_DEADLINE_SEC:-900}
JOB_LEASE_SEC: ${JOB_LEASE_SEC:-120}
# 렌더러 subprocess 가 굽는 동안 기다리는 시간(사진 내려받기 포함). BUILD 의
# job_deadline_sec 보다 짧아야 한다 — 안 그러면 잡 전체가 먼저 타임아웃된다.
RENDER_TIMEOUT_SEC: ${RENDER_TIMEOUT_SEC:-180}
# 이미지의 HEALTHCHECK 는 API 용(HTTP :9800)이다. 워커는 포트가 없어 그대로 두면 늘 unhealthy 다.
healthcheck:
disable: true
volumes:
- ./solution/site/payloads:/app/out/payloads
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
- ./solution/site/payloads:/app/solution/site/payloads
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 렌더
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
- ./solution/site/songs:/app/out/songs
- site-out:/app/out/sites:ro
- ./solution/site/songs:/app/solution/site/songs
# ★ 이제 이 컨테이너가 굽는 쪽이다 — 읽기전용이 아니다(예전 solution-prerender 가
# 쓰던 자리를 그대로 이어받는다).
- site-out:/app/solution/site/out
extra_hosts:
- "host.docker.internal:host-gateway"
stop_grace_period: 300s
@ -124,10 +137,10 @@ services:
start_period: 20s
retries: 3
volumes:
- ./solution/site/payloads:/app/out/payloads
- ./solution/site/payloads:/app/solution/site/payloads
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
- ./solution/site/songs:/app/out/songs
- ./solution/site/songs:/app/solution/site/songs
ports:
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"
@ -140,48 +153,6 @@ services:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 발행 사이트를 굽는다. **굽기만 한다** — 서빙은 solution-site(nginx)가 맡는다.
#
# ★ 사장님 앱 dev 서버는 여기서 빠졌다. 운영에 `vite dev` 를 띄우면 요청마다 트랜스파일하고
# 기동이 npm install 네트워크에 의존하고 /src 원본이 그대로 나간다. 번들은 solution-site
# 이미지가 굽는다(nginx/Dockerfile). 로컬에서 HMR 이 필요하면 `--profile dev`.
solution-prerender:
image: node:24-alpine
container_name: o2o-web4ai-solution-prerender
working_dir: /app
command:
- sh
- -c
- |
cd /app
# ★ `-d node_modules` 로 판단하면 안 된다. 익명 볼륨은 빈 디렉토리로 이미 존재해서
# 설치를 건너뛰고 `vite: not found`(exit 127)로 죽는다.
[ -x node_modules/.bin/vite ] || npm install
exec node solution/site/scripts/watch-payloads.mjs
environment:
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
# ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를
# 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
volumes:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
- ./tsconfig.base.json:/app/tsconfig.base.json
- ./solution:/app/solution
- ./admin:/app/admin
# ★ node_modules 는 컨테이너 것을 쓴다. 호스트가 macOS(arm64-darwin)라 그 안의
# rollup·esbuild 네이티브 바이너리를 리눅스 컨테이너가 못 쓴다.
- /app/node_modules
- /app/solution/site/node_modules
- /app/solution/frontend/node_modules
- /app/admin/frontend/node_modules
# ★ 산출물은 named volume. 호스트 경로면 재배포로 코드를 갈아엎는 순간 전 사이트가 404 다.
- site-out:/app/solution/site/out
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 사장님 앱 dev 서버 + 발행본 정적서버(:3001). **로컬 전용**이다 — `--profile dev`.
# 운영에서 이게 뜨면 안 된다(위 solution-prerender 주석).
solution-frontend:
@ -294,7 +265,66 @@ services:
# 앞단 프록시를 세울 거면 여기서 포트만 옮기고 프록시가 이쪽을 가리키게 한다.
- "${SITE_HTTP_BIND:-0.0.0.0}:${SITE_HTTP_PORT:-80}:80"
depends_on:
- solution-prerender
- solution-backend
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# ── 온톨로지(o2o-site-ontology) — 발행본 메타 키워드·제목 업종어 ──────────────
# ★ 자체 DB 가 따로 있다. 호스트 postgres 를 같이 쓰지 않는 이유는 pgvector 확장이 필요해서다.
# 우리 DB 에 확장을 걸면 web4ai_db 가 그 확장에 묶인다 — 남의 서비스 사정을 우리 DB 가 떠안는다.
ontology-postgres:
image: pgvector/pgvector:pg16
container_name: o2o-web4ai-ontology-postgres
environment:
POSTGRES_USER: ontology
POSTGRES_PASSWORD: ontology
POSTGRES_DB: ontology
volumes:
- ontology-pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ontology -d ontology']
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
ontology-redis:
image: redis:7-alpine
container_name: o2o-web4ai-ontology-redis
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
ontology:
build:
context: ./ontology
container_name: o2o-web4ai-ontology
environment:
PORT: "3100"
# 컨테이너끼리는 서비스 이름으로 만난다 — 호스트 포트(55432·56379)는 사람이 들여다볼 때만 쓴다.
DATABASE_URL: postgres://ontology:ontology@ontology-postgres:5432/ontology
REDIS_HOST: ontology-redis
REDIS_PORT: "6379"
# API 키 없이 도는 기본값(로컬 임베딩 + mock LLM). 키를 쓰려면 .env 에서 덮어쓴다.
EMBEDDING_PROVIDER: ${ONTOLOGY_EMBEDDING_PROVIDER:-local}
EMBEDDING_LOCAL_MODEL: ${ONTOLOGY_EMBEDDING_MODEL:-Xenova/multilingual-e5-small}
LLM_PROVIDER: ${ONTOLOGY_LLM_PROVIDER:-mock}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
volumes:
# 임베딩 모델 캐시. 볼륨이 없으면 컨테이너를 새로 만들 때마다 120MB 를 다시 받는다.
- ontology-model:/app/.cache
ports:
- "127.0.0.1:3100:3100"
depends_on:
ontology-postgres:
condition: service_healthy
ontology-redis:
condition: service_healthy
restart: unless-stopped
logging:
driver: json-file
@ -303,3 +333,6 @@ services:
volumes:
# ★ `down -v` 만 지운다. 재생성물이라 백업 대상이 아니다 — 날아가도 payload 로 다시 굽는다.
site-out:
# 온톨로지 DB 와 임베딩 모델 캐시. 모델 캐시가 날아가면 첫 요청이 120MB 를 다시 받는다.
ontology-pgdata:
ontology-model:

View File

@ -1,5 +1,8 @@
# ARCHITECTURE
> 2026-09-15: 현재 발행 실행·산출물 버전·배포는 [PUBLISH_VERSION.md](PUBLISH_VERSION.md).
> 아래의 별도 프리렌더 컨테이너·폴링·전체 재굽기 설명은 이전 구조다.
제품 판단은 [PRODUCT.md](PRODUCT.md), 배포 절차는 [DEPLOY.md](DEPLOY.md),
에이전트가 밟기 쉬운 함정 목록은 [AGENTS.md](../AGENTS.md). 여기는 **구조와 경계**만 다룬다.
@ -37,7 +40,10 @@ React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포
```
BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
├ build_snapshot → site_versions 행 insert (원본 데이터, JSONB)
├ build_snapshot → 원본 데이터(JSONB)
├ seo_keywords.fetch() → SiteOntology 추천을 이 가게 자료로 거른 키워드 → snapshot["seo"]
│ (숙박만 · 설정 없거나 실패하면 생략 · 발행은 계속)
│ → site_versions 행 insert
├ 1차 게이트 (DB 사실 기준) → publish_gate.evaluate()
├ site_payload.emit_payload() → out/payloads/<slug>.json ★ 백엔드의 유일한 산출물
@ -66,6 +72,10 @@ BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
## 3. 서빙 — 테스트 서버가 정적 파일을 직접 서빙한다
Google 추적은 발행 잡 밖에서 실행한다. 기존 API 스케줄러가 발행 완료 DB를 감지해
사이트맵 제출·색인 조회·알림을 수행하고 `site_search_status`에 저장한다.
선택 설정/인증/재시도 경계는 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md)가 단일 출처다.
**결정 (2026-08-31).** 발행 사이트는 **서버 안에서 nginx 가 정적 파일로 서빙한다.**
Azure Blob 업로드 경로(`azure_static.py`)는 코드에 있고 동작하지만 **지금은 켜지 않는다**
`AZURE_STORAGE_CONNECTION_STRING` 을 비워 두면 발행 잡이 업로드 단계를 건너뛴다.

View File

@ -7,6 +7,8 @@
여기서는 그 앞뒤를 잇는다.
- 수집이 **무엇을 어디서 가져오는지**는 [COLLECTION_SEO_AEO_FLOW.md](COLLECTION_SEO_AEO_FLOW.md).
- 표를 고치는 절차는 [postgres-init/migrations/README.md](../postgres-init/migrations/README.md).
- Google 제출/색인 관측은 `site_search_status`의 별도 상태다. 발행 상태와 섞지 않는다.
컬럼 의미·조회·설정은 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md).
정의는 두 곳이고 **둘 다 최신이어야 한다** — ORM(`solution/backend/common/database/model/models.py`)
과 DDL(`postgres-init/init-data/init.sql` + `migrations/`). 컬럼 주석은 ORM 이 더 자세하다.
@ -124,6 +126,23 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
활성 유니크는 `(place, unit, key)` 당 **노출값 1건**이다(status 3·4 부분 인덱스).
후보(1·2)와 이력(5·6)은 여러 건 공존한다 — 재수집이 쌓일 수 있어야 하기 때문이다.
**수집값은 빈 자리에 바로 노출값(VERIFIED)으로 들어간다** (2026-09-14, `services/fact_service`).
예전에는 크롤링 값이 전부 UNVERIFIED 후보였다. 그러면 수집 직후 발행이 "확인된 사실 0건" 으로
막혀, 사장님이 한 건씩 승인하기 전에는 사이트가 만들어지지 않았다 — 수집이 끝난 뒤에야 오는
값이라 승인할 화면을 이미 지나가 있었다.
지금 규칙은 **누가 그 자리를 이미 차지했는지**로 갈린다.
| 그 key 의 현재 노출값 | 수집값이 오면 |
|---|---|
| 없음 | 바로 노출값(VERIFIED). `verified_by`**비운다** — 사람이 승인한 이력과 구별된다 |
| 같은 값 | REFRESHED — 확인 시각만 갱신. 검증을 초기화하지 않는다 |
| 사장님이 넣은 값(OWNER) · 정정본(CORRECTED) | 덮지 않는다. PENDING_OWNER **후보**로 쌓여 사람이 고른다 |
| 앞선 수집값 | 새 값이 노출값 자리를 가져간다(옛 값은 EXPIRED 이력) |
즉 자동이 사람을 덮지 못한다는 보호(절대규칙 6)는 그대로이고, 자동끼리는 최신값이 이긴다.
UNVERIFIED 는 이제 공식 API 수집이 빈 자리에 넣을 때 생긴다.
### `place_channels` — 크롤링 대상 URL
`confirmed_at` 이 NULL 이면 **크롤링하지 않는다.** 카카오 로컬로 동일 업소임을 확인한 URL 만 넘긴다.
@ -137,8 +156,16 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
### `place_faqs` — FAQ
`source_fact_ids` 가 비면 **발행 게이트가 반려한다.** 확보된 fact 만 근거로 쓴다는 규칙이
데이터 모양으로 강제된 자리다.
출처(`generated_by`)마다 근거 요구가 다르다.
| generated_by | 무엇 | source_fact_ids | 어디에 나가나 |
|---|---|---|---|
| `LLM`(4) | 확인된 fact 로 쓴 문장 | 근거 key 필수 — 없으면 저장하지 않는다(`copy_service`) | 화면 · JSON-LD · llms.txt |
| `OWNER`(1) | 사장님이 쓰거나 고친 문장 | 없을 수 있다 | 화면 · JSON-LD · llms.txt |
| `TEMPLATE`(5) | 20개를 채운 공통 질문 + 문의 안내 답 | 없음 | **화면만** |
★ 예전 문서는 "비면 발행 게이트가 반려한다" 고 적었지만 그런 검사는 없었다(2026-09-14 확인).
근거 강제는 저장 시점(`copy_service`)에 있다. 채우기 규칙은 [DECISIONS 8절](DECISIONS.md).
### `place_songs` — 이 숙소의 노래
@ -184,7 +211,7 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다.
|---|---|---|
| 1 WEATHER | Open-Meteo | — |
| 2 FESTIVAL · 3 ATTRACTION · 4 RESTAURANT · 5 COURSE | TourAPI (좌표 반경) | — |
| 6 STORY | Perplexity | `songs` `people` `chronicle` `postcard` `quiz` |
| 6 STORY | Perplexity | `songs` `daily` `people` `chronicle` `reading` `postcard` `quiz` |
`body`(JSONB)에 항목이 들어간다. **지역 이야기는 종류당 한 행**이고 항목들은 `body.items` 안에 있다.
@ -242,6 +269,9 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다.
### `jobs` — 작업 큐 (PostgreSQL 을 큐로)
COPY 단계는 `jobs.progress`(JSONB)의 `steps`·`attempt`에 기록한다.
생성 화면 복구와 모듈별 책임은 [GENERATION_FLOW.md](GENERATION_FLOW.md).
| `job_type` | 핸들러 | 하는 일 |
|---|---|---|
| 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 |

View File

@ -34,6 +34,25 @@
| 결론이 "불가"일 때 | 폴백 3단계로 간다 — ① 공식 API → ② 사장님이 직접 붙여넣기 → ③ 최소 정보로 생성 + 보완 요청. **생성 자체는 실패시키지 않는다** |
| 확정 사항 | 캡차 우회 · 봇 탐지 우회 · IP 회전은 **결론과 무관하게 금지**. 구현하지 않는다 |
**변경 (2026-09-14 / 확인 2026-09-15) — NOL 전용 어댑터를 등록한다.**
위 표의 "야놀자·여기어때 불가" 와 "Playwright 어댑터는 등록하지 않는다" 를 **한 패턴에 한해**
연다. 무엇을 열고 무엇을 안 여는지는 정확히 이렇다.
| | 지금 |
|---|---|
| `nol.yanolja.com/stay/domestic/<id>` | **전용 어댑터 `yanolja`** 가 Playwright 로 렌더해 읽는다. 기본 활성 |
| 그 밖의 `yanolja.com` · `goodchoice.kr` 전부 | **막는다.** 범용 HTML 어댑터의 `_DENY_HOSTS` 에 그대로 있다 |
| 캡차 우회 · 봇 탐지 우회 · IP 회전 | **여전히 금지.** 차단되면 그대로 실패로 돌린다 |
- 레지스트리가 `yanolja``static_html` 보다 **앞에** 등록하므로 그 한 패턴만 전용 경로로 가고
나머지는 예전처럼 `AdapterNotFound` 로 끊긴다. 순서가 곧 이 경계다.
- ★ 실측(2026-09-15): 어댑터를 들이면서 `static_html``_DENY_HOSTS` 에서 `yanolja.com` ·
`goodchoice.kr` 이 함께 빠져 있었다. 그러면 전용 어댑터가 아니라 **범용 HTML 수집기가**
두 플랫폼을 받는다 — 전용 경로 하나를 여는 것과 범용 수집을 그 플랫폼에 푸는 것은 다른
일이라, 차단 목록과 그 법무 근거 주석을 되돌렸다.
- 민사 10억 선례(서울중앙지법 2021-08)는 그대로다. **재게시 범위는 1-2 가 따로 정한다**
이 항목은 "읽을 수 있나" 까지만 정하고 "다시 실어도 되나" 는 정하지 않는다.
### 1-2. 크롤링한 **이미지**의 재게시 권리
2026-09-14: SNS 사본은 나중에 필터링해 회수할 수 없어 기존 격리를 적용할 수 없다. 미디어 첨부는 구현하지 않는다. 링크 카드의 og:image 캐시는 별도로 남을 수 있다.
@ -351,3 +370,30 @@ JWT 를 쓰지 않는 이유는 JWT 가 서명만 검증하고 **소비를 못
남은 정책: 만료 24시간의 최종 근거, 야간 발송(현재 화면 채널만 사용), 다계정 선택,
장기 미사용 계정의 사전 토큰 갱신. 계정은 현재 user/provider당 하나다.
## 8. FAQ 는 20개를 채운다 — 모자란 만큼 공통 질문 + 문의 안내 (2026-09-14)
**왜** — 확인된 fact 로만 쓰면 FAQ 가 4~8개에서 끝난다. 실측(2026-09-14, 로컬): 스테이머뭄 fact 8건,
산하연 풀빌라 fact 4건 · FAQ 4건.
**어떻게**
- 생성 상한 `max_faqs` 8 → 20 (`services/faq_fill.FAQ_TARGET`).
- 노출 중 FAQ 가 20개에 모자라면 업종 카탈로그(`common/faq_catalog/resources/pension.json`, 30문항)에서
**겹치지 않는** 질문을 카탈로그 순서대로 고른다. 건너뛰는 것:
- 답할 fact 가 있는 질문 — LLM 이 fact 로 답할 자리다. 프롬프트에 그 질문들을 실어 먼저 쓰게 한다.
- 기존 FAQ(생성분·사장님 입력·정정분)와 **근거 fact key** 가 겹치거나 **질문 키워드**가 겹치는 질문.
key 만 보면 사장님 입력(근거 없음)을 놓치고, 키워드만 보면 "주차 및 와이파이" 처럼 묶인 문항의 한쪽을 놓친다.
- 답은 **문의 안내뿐**이다("…은 전화(…)로 문의해 주시면 안내해 드립니다"). 값·가능 여부를 적지 않는다.
업종 시드 FAQ 가 "숯과 그릴 세트(25,000원)" 같은 가공의 값을 사이트에 내보낸 일을 반복하지 않기 위해서다
(`frontend … canvas/variants/faq/useFaqList.ts` 주석).
- 출처는 `SourceType.TEMPLATE`(5). 재생성 때 LLM 생성분과 함께 내리고 다시 고른다. fact 에는 쓸 수 없다.
- ★ **fact 가 0건이어도 20개다.** 근거가 없으면 LLM 은 부르지 않고(환각·유료 호출 방지) 채우기만 돈다.
그 경로는 API 키도 필요 없다. 예전에는 `start_copy``FAQ_UNGROUNDED` 로 잡을 만들지 않아 FAQ 가 0개였다 —
이제 그 거절은 **카탈로그가 없는 업종**(카페·음식점·호텔)에만 남는다.
**어디에 안 나가나** — 사이트 화면에는 나간다. FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수 · SEO 감사 FAQ
점수에서는 뺀다. 답이 없는 문답을 구조화 데이터로 내보내면 AI 검색에 잡음이고, 모든 펜션에 같은 문구라
고유 콘텐츠로 세면 내용 없는 사이트가 발행 게이트를 통과한다.
**적용 범위** — 숙박 업종이면서 외부 분류(`places.external_category`)가 호텔·모텔·리조트가 아닌 곳.
분류가 비어도 적용한다(펜션인데 네이버 분류가 없는 곳이 있다). 카페·음식점·체험시설은 카탈로그가 없어 채우지 않는다.

View File

@ -1,5 +1,8 @@
# 배포 · 스토리지
> 2026-09-15 이후 절차는 [PUBLISH_VERSION.md](PUBLISH_VERSION.md)를 따른다.
> 기존 사이트 전체 재굽기는 하지 않는다. 최초 전환 때 구 프리렌더를 중지한다.
> **현재 결정 (2026-08-31): 발행 사이트는 서버 안에서 nginx 가 정적 서빙한다.**
> Azure Blob 은 코드에 있으나 **켜지 않는다**(`AZURE_STORAGE_CONNECTION_STRING` 비움).
> 클라우드는 고도화 때 붙인다 — 근거는 [ARCHITECTURE.md 3절](ARCHITECTURE.md).

View File

@ -1,10 +1,130 @@
# 개발 일지
## 2026-09-15 — 워커 렌더·발행 버전·예약 안내·미리보기 대기
- 상시 프리렌더를 제거하고 워커가 컴파일된 Node 렌더러를 실행한다.
- 버전별 HTML을 보존하고 게이트 통과 뒤 공개 링크를 전환한다. 재시도는 저장된 성공본을 사용한다.
- 예약 전 확인을 이용안내에 통합하고 iframe 렌더 완료까지 스피너를 표시한다.
- 배포는 기존 HTML과 목업을 재굽지 않는다. 상세: [PUBLISH_VERSION.md](PUBLISH_VERSION.md).
- 읽기 생성 토큰 상한을 늘리고 추첨 배열을 고정해 반복 렌더를 방지한다.
- 편집기 주소는 /builder?placeId=…로 통일한다. 옛 step=editor 주소는 ID 복원 후 정정한다.
- 검증: 사이트 81건, 백엔드 발행·롤백·서치콘솔 45건 통과. 빌더·사이트 빌드 통과.
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다.
결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
---
## 2026-09-15 — Google 사이트맵 자동 제출·색인 관측
- 기존 스케줄러에서 발행 완료 DB 감지 → 사이트맵 제출 → 색인 조회 → 지연/실패 알림.
- 관측값·재시도·알림 시각은 `site_search_status`에 보관. 발행 잡/상태는 건드리지 않는다.
- API 인증/호출과 DB·배치·알림 모듈 분리. Google·Teams 실호출은 설정 전까지 꺼진다.
- 설정/적용/관측 의미: [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md). 운영 배포·권한 부여는 미실행.
**검증** — 관련 59건 통과. 추가 회귀 23건 통과·기존 발행 검수 실패 1건(변경 전 코드에서도 재현).
## 2026-09-15 — 콘텐츠 생성 진행 상태·새로고침 복구
- COPY의 실제 단계 상태를 DB에 기록하고 Orval 응답으로 표시. 폴링 횟수 기반 진행률 제거.
- URL의 jobId로 조회 재개. 구 URL 복구는 완료·실패 이력까지 재사용해 중복 생성 방지.
- 실행 흐름·단계 메소드·프롬프트·프론트 조회 훅·화면 문구 분리.
- 구조·적용 순서: [GENERATION_FLOW.md](GENERATION_FLOW.md).
**검증** — 백엔드 관련 테스트 34건·브라우저 복구/실패 시나리오 6건 통과. 프론트 타입검사·lint·빌드 통과.
---
## 2026-09-14 — 엽서 쓰기를 발행본에도 넣는다 (사진이 남의 도메인이면 저장·공유는 막힌다)
**무슨 일** — 시연본에만 주입 스크립트로 있던 '엽서 쓰기'(사진 고르기 + 한 마디 + 캔버스 엽서)를
발행본 컴포넌트로 옮겼다. 그리기 규칙은 `site/src/lib/postcard-canvas.ts` 한 곳에 두고,
화면·입력·공유는 `sections/items/PostcardMakerSection.tsx` 가 맡는다. 사진이 있는 사이트면 나간다.
**★ 저장·공유가 사진 출처에 걸린다** — 캔버스는 **남의 도메인 사진을 그리면 오염돼서**(tainted)
`toBlob` 이 SecurityError 로 막힌다. 미리보기는 멀쩡히 보이는데 저장·공유만 죽는, 눈으로는 못 찾는 종류다.
CORS 로 받으면 안 오염되지만 실측(2026-09-14) 발행본 사진은 네이버 CDN(`*.pstatic.net`)에 있고
그쪽은 `Access-Control-Allow-Origin` 을 주지 않는다 — `curl -I` 로 확인했다.
→ 지금은 **정직하게 막는다.** CORS 로 한 번 받아 보고, 실패하면 CORS 없이 다시 받아 미리보기만 세우고
저장·공유 단추를 아예 감춘다("이 사진은 다른 사이트에 올라와 있어 …"). 눌러도 안 되는 단추를 두지 않는다.
**근본 해결은 사진을 우리 오리진으로 옮기는 것이다.** 시연본이 `img/mirror/` 로 그렇게 하고 있고,
발행 파이프라인이 같은 일을 하면(빌드 때 내려받아 `out/s/<slug>/img/` 에 두고 payload 주소를 바꾼다)
저장·공유가 풀린다. 덤으로 외부 주소 만료·핫링크 문제도 같이 사라진다. **아직 안 했다.**
---
## 2026-09-14 — FAQ 를 20개까지 채운다 (펜션 공통 질문 30개 + 문의 안내)
**무슨 일** — COPY 잡의 FAQ 생성 상한을 8 → 20 으로 올리고, 그래도 모자라면 펜션 공통 질문 카탈로그에서
겹치지 않는 질문을 골라 **문의 안내** 답으로 채운다.
```
생성(fact 근거, 최대 20) → 노출 중 FAQ 세기(생성분 + 사장님 입력·정정분)
→ 모자란 만큼 카탈로그 순서대로: fact 로 답할 수 있는 질문 · 이미 다룬 주제(근거 key / 질문 키워드) 건너뜀
→ "…은 전화(…)로 문의해 주시면 안내해 드립니다" (generated_by=TEMPLATE, VERIFIED)
```
**왜** — 확인된 fact 로만 쓰면 4~8개에서 끝났다(실측 로컬: 스테이머뭄 fact 8건, 산하연 풀빌라 fact 4건 · FAQ 4건).
**★ 공통 답에 값을 적지 않는다** — 가게마다 다른 값(바비큐 가능·반려동물 불가·체크인 15시)을 공통으로 적으면
업종 시드 FAQ 가 가공의 가격을 내보낸 사고와 같다. 답은 문의 안내뿐이고, 그래서 **화면에만** 나간다 —
FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수(prerender ↔ conftest) · SEO 감사 FAQ 점수에서는 뺐다.
**바꾼 곳**
- `common/faq_catalog/`(신규): 카탈로그 로더 + `resources/pension.json`. fact_keys 가 업종 스키마에 없으면 로드 시 예외.
- `services/faq_fill.py`(신규): 고르기 규칙(순수 함수). `copy_service._fill_faqs` 가 부른다.
- `SourceType.TEMPLATE = 5`(백엔드 enum · shared · orval 모델). fact 에는 못 쓴다(`fact_service` 규칙 4).
- `postgres-init/migrations/0012_place_faqs_template_source.sql` + `init.sql`: 컬럼 변경은 없다(CHECK 없는 SMALLINT).
`generated_by` · `source_fact_ids` 에 코드값 뜻을 `COMMENT ON` 으로 남긴다. 0012 는 컬럼이 있을 때만 단다(`DO $$ IF EXISTS`).
init.sql 은 옛 주석("비면 발행 게이트가 반려한다" — 그런 검사는 없었다)을 고치고 같은 `COMMENT ON` 을 붙였다.
- `faq_crud.expire_generated`: TEMPLATE 도 재생성 때 내린다 — 안 내리면 새 fact 로 답이 생긴 주제에 옛 문의 안내가 남는다.
- 프롬프트: fact 로 답할 수 있는 카탈로그 질문을 싣고, "한 문항에 주제 하나" 규칙 추가
(노출 중 생성 FAQ 4건 중 3건이 "체크인 및 체크아웃" 식으로 묶여 있었다).
- ★ fact 0건이어도 20개: `start_copy` 는 카탈로그가 있으면 잡을 만들고(`FAQ_UNGROUNDED` 는 카탈로그 없는 업종만),
`run_copy` 는 근거가 없거나 키가 없으면 LLM 없이 채우기만 한다. 온보딩 알림(`notifyCopy`)도 `faq_fill` 을 본다.
- 발행본 FAQ 섹션: 문의 안내가 섞이면 "모두 사업자가 확인한 내용" 안내 문구를 달지 않는다.
- 빌더 FAQ 패널: "노출 N건 (문의 안내 M)" 과 문의 안내 표시.
**남은 것** — 카페·음식점·체험시설 카탈로그. 스키마에 없는 주제(짐 보관·퇴실 정리·보증금·수영장 온수·주변 편의시설)는
fact key 로 만들면 문의 안내 대신 답이 된다. 결론은 [DECISIONS 8절](DECISIONS.md).
**검증** — 백엔드 664 passed(신규 `test_faq_fill` 10건 · `test_copy_api` 3건, 기존 2건은 fact 0건 경로에 맞게 고침).
실패 2건(`test_place_search::test_rate_limit_closes_the_tap` · `test_site_thumbnail` 호스트)은 이 변경 전 HEAD 에서도 같게 실패한다.
site·frontend·admin `tsc --noEmit` 통과 · site vitest 63 passed.
로컬 실사업장(2026-09-14, 하늘물빛정원 — fact 4건): 생성 FAQ 4건 + 문의 안내 16건 = 20건, 질문 중복 0.
0012 는 새 DB(init.sql → migrate 규칙)와 로컬 DB 사본 양쪽에서 두 번씩 적용해 통과.
---
## 2026-09-14 — 발행 사이트 제목·keywords 메타에 SiteOntology 키워드를 싣는다
**무슨 일** — 숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를
받고, 거른 결과를 `<meta name="keywords">` 와 제목 업종어 자리에 싣는다.
```
스냅샷 → 프로필(확인된 fact · 주소 · 발행되는 주변 관광지)
→ POST /v1/merchants/publish (generate:false) → POST /v1/match (query=place_id)
→ 거르기 → snapshot["seo"] → payload.seo
<title>스테이,머뭄 · 군산 독채펜션</title> · <meta name="keywords" content="군산 펜션 독채, …">
```
**★ 거르기가 필요한 이유 (실측)** — 스테이머뭄 프로필로 받은 추천 10건 중 `군산 독채 마당 펜션`·
`군산 독채 복층 펜션`·`군산 커플 프라이빗 펜션` 이 status=ok 로 왔다. SiteOntology 의 사실 필터는 수용 인원과
일부 시설만 보기 때문이다. 사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다.
**키워드의 모든 낱말이 이 가게 자료에 있어야** 싣는다. 이 규칙 하나로 셋이 같이 걸리고, 10건이 4건이 됐다.
제목에는 `예약`·`추천` 이 붙은 것과 시·군 이름이 없는 것도 뺀다. 규칙의 단일 출처는 `services/seo_keywords.py`.
**★ SiteOntology 쪽 함정 (실측)**
- region 표에 없는 `regionId` 를 보내면 **500**(외래키 위반). 표 내용은 적재한 데이터셋에 따라 달라 우리가 모른다
→ 500 이면 지역 없이 한 번 더 보낸다.
- 해석되지 않은 `query` 에도 **201** 로 입력 문자열 검색 결과를 준다(`나운동 숙소` …) → `resolved`
우리 place_id 가 아니면 버린다.
**경계** — SiteOntology 는 **수정하지 않았다**. 설정(`SITE_ONTOLOGY_URL`)이 비면 호출하지 않고, 실패하면
키워드 없이 예전 제목으로 발행한다. 키워드는 스냅샷에 실려 `site_versions.snapshot` 이 곧 발행 기록이다.
**남은 것** — SiteOntology 매칭은 지역으로 거르지 않는다. 지금은 낱말 대조가 다른 지역 단어를 막지만,
운영에 전국 데이터가 들어가면 SiteOntology 에 지역 필터를 넣는 것이 맞다.
## 2026-09-14 — SNS 게재: 사장님이 누르면 글을 쓰고, 승인받아, 사장님 계정으로 올린다
**추가 검증 (Threads 전환 완료본)** — 격리 DB `web4ai_social_isolated_test_db`, `SCHEDULER_ENABLED=0`에서

46
docs/GENERATION_FLOW.md Normal file
View File

@ -0,0 +1,46 @@
# 콘텐츠 생성 · 진행 복구
2026-09-15. `builder?step=generating`은 COPY(소개문·FAQ) 작업이다.
사진 분석은 VISION, 정적 사이트·노래 생성은 발행 BUILD에 속한다.
```text
템플릿 선택 → POST /v1/place/{placeId}/copy → jobId를 URL에 기록
새로고침 ──────────────────────→ GET /v1/job/{jobId}
COPY 워커: prepare → generate → save → faq_fill
각 단계 진입·완료 → jobs.progress(JSONB)
화면: 서버 단계 표시 → DONE일 때 데이터 갱신 → editor
```
| 책임 | 파일 |
|---|---|
| 실행 순서 | `solution/backend/services/copy_service.py` |
| 단계 구현 | `solution/backend/services/copy_steps.py``prepare_copy`, `generate_copy`, `save_copy`, `fill_faqs` |
| 프롬프트·응답 스키마 | `solution/backend/services/prompts/copy.py` |
| 모델 호출·생성물 검증 | `solution/backend/services/external/gemini_text.py``llm/gemini.py`, `grounding/copy.py` |
| 단계 기록 | `solution/backend/services/job_progress.py``crud/job_crud.py` |
| API 계약 | `solution/backend/router/v1/job/protocol.py` → OpenAPI → Orval |
| 조회·복구·완료 전환 | `solution/frontend/src/features/onboarding/useGenerationJob.ts` |
| 화면 / 문구 | 같은 폴더의 `Step5Generating.tsx` / `generationLabels.ts` |
- `jobs.status`는 작업 전체 상태, `progress.steps[].status`는 단계 상태다.
단계는 `pending/running/done/skipped/failed`. 시간으로 퍼센트나 단계를 올리지 않는다.
- `progress.attempt`는 워커 시도 번호다. 재시도는 단계를 처음부터 다시 기록한다.
기록은 실행 중인 워커·시도 번호·유효한 lease가 일치할 때만 허용한다.
- 새로고침은 GET만 한다. jobId가 없는 구 URL은 `POST copy {resume: true}`
해당 사업장의 최근 COPY를 찾는다. DONE·DEAD도 반환하므로 완료됐다고 새 작업을 만들지 않는다.
권한 검사는 사업장 조회가 먼저 한다. URL로 조회하는 COPY도 소유자를 검사한다.
- 템플릿의 생성 버튼을 명시적으로 누르면 기본 POST로 새 작업을 요청한다.
같은 사업장의 활성 작업이 있으면 기존 중복 방지 규칙으로 그 작업에 연결한다.
- 통신 오류는 상태 재조회, DEAD는 이전 단계 또는 편집기로 직접 이동할 수 있다.
오류·대기·미설정 상태를 가짜 진행이나 완료 화면으로 바꾸지 않는다.
- 노래 단계는 이번 COPY 흐름에 추가하지 않았다. 발행 BUILD 진행 표시 확장은 별도다.
적용: 마이그레이션 `0013_job_progress.sql`을 먼저 적용한 뒤 API·워커·빌더를 배포한다.
기존 잡의 `progress`는 NULL이다. 이 경우 단계 목록을 지어내지 않고 전체 상태만 표시한다.
검증: `tests/test_copy_api.py`, `tests/test_job_queue.py`, `tests/test_schema_ddl.py`.
프론트는 개발 서버를 켜고 `node solution/frontend/tests/generation.mjs <개발 URL>` 실행.
브라우저 테스트는 모든 API를 가짜 응답으로 대체한다.

32
docs/PUBLISH_VERSION.md Normal file
View File

@ -0,0 +1,32 @@
# 발행 버전과 워커 (2026-09-15)
이 문서가 이전 문서의 프리렌더 상시 기동·전체 재굽기 절차를 대체한다.
`BUILD → snapshot → payload → Node 렌더 → 결과 게이트 → 공개 링크 전환 → DB 기록`
- Python은 HTML을 만들지 않는다. 미리 컴파일된 Node를 실행하고 JSON 보고서만 읽는다.
- 워커 이미지에 Node와 렌더러를 포함한다. 실행 중 npm 설치·번들 빌드는 없다.
- 공유 볼륨의 파일 잠금으로 렌더·공개 전환을 직렬화한다. 수집 등 다른 잡은 동시 실행한다.
- `out/versions/<slug>/<version>`에 성공한 HTML과 렌더 보고서를 보존한다.
- `out/s/<slug>`는 공개 버전의 상대 심볼릭 링크다. 게이트 통과 후 전환한다.
- 성공한 버전은 재시도·롤백 때 다시 쓰지 않는다. 기존 일반 디렉토리는 첫 재발행 때 legacy로 보존한다.
- 배포 시 HTML·기존 HTML의 자산 주소를 수정하지 않는다. 공용 자산과 미리보기 셸만 준비한다.
- 목업과 보관 버전의 참조 자산도 삭제 대상에서 제외한다. versions는 Azure 공용 업로드에서 제외한다.
- 롤백 API: `POST /v1/place/{place_id}/site/version/rollback`, `target_version`. 소유권 검사와 BUILD 중복 방지 키를 공유한다.
- 로컬 공개 전환과 DB/Azure는 단일 트랜잭션이 아니다. 외부 저장소·DB 실패 시 재시도 및 운영 확인이 필요하다.
- 최초 일반 디렉토리→링크 전환은 두 rename 사이 짧은 공백이 가능하다. 이후 링크 교체는 원자적이다.
## 배포
1. 진행 중 잡·서버 변경·목업 및 참조 자산 해시를 확인하고 site-out을 백업한다.
2. backend·worker·site 이미지를 빌드한다. admin은 기본 대상이 아니다.
3. 기존 worker와 solution-prerender를 중지한 뒤 새 worker를 기동한다. 두 렌더러를 동시에 실행하지 않는다.
4. API·미리보기·테스트 발행을 확인하고 목업 해시를 대조한다. 전체 재굽기·republish_all은 실행하지 않는다.
워커 경로: `SITE_PAYLOAD_DIR=/app/solution/site/payloads`, `SITE_OUTPUT_DIR=/app/solution/site/out`.
DB 테이블 추가는 없다. 기존 버전·잡·발행 로그를 사용한다.
## UI
예약 전 확인과 요약을 이용안내 및 예약에 통합한다. 별도 요약 섹션과 예약 카드의 중복 규정은 제거한다.
빌더 미리보기는 iframe 내부 렌더 완료 신호까지 스피너를 표시한다. 출처·iframe을 확인하고 12초 상한을 둔다.

99
docs/SEARCH_CONSOLE.md Normal file
View File

@ -0,0 +1,99 @@
# Google Search Console 자동 추적
`발행 DB 감지 → 공개 사이트맵 확인/제출 → 색인 조회 → 상태 저장·Teams 알림`
## 경계
- 기존 API의 스케줄러에서 10분마다 실행한다. 컨테이너 추가 없음.
- `sites.status=PUBLISHED`인 사이트만 등록하므로 초안/목업 디렉토리 나열을 작업 원장으로 쓰지 않는다.
- 발행 DB에서 재발견한다. 발행 순간 별도 큐 적재가 실패하는 틈이 없고 재시작해도 이어진다.
- 발행 트랜잭션/잡과 독립적이다. Google 실패가 사이트 발행을 실패로 바꾸지 않는다.
- 한 번에 신규 발행 100개 등록, 조회는 오래 기다린 5개 처리. 정상 조회는 24시간 후 반복.
- 현재 렌더러의 단일 루트 urlset만 지원하고 읽기 상한은 5MB다. 향후 sitemap index 분할 시 확장한다.
- 오류는 1·2·4·8·16·24시간 간격 재시도. 기본 주기 기준 하루 최대 720회 검사이며,
다른 도구의 같은 속성 사용량도 Google 할당량에 포함된다. 대량 백로그는 여러 날에 걸쳐 소진한다.
- PostgreSQL transaction advisory lock으로 다중 API 프로세스의 동시 배치를 막는다.
단일 배치는 외부 호출 동안 트랜잭션/연결 1개를 점유한다(검사 1건 최대 90초, 최대 5건).
- 사이트맵 제출 성공과 URL 색인 성공은 별개다. `first_indexed_at`은 **우리가 처음 PASS를 관측한 시각**이다.
Google 내부 색인 시각이나 최신 발행 버전 반영 시각이 아니다. 원본 `lastCrawlTime`도 함께 보관한다.
- 재발행 시 해당 발행의 관측 상태를 초기화한다. 지난 관측 이력 전체를 누적하는 이벤트 저장소는 아니다.
- `SITE_PUBLIC_HOST` 변경은 기존 지침대로 재발행이 필요하다. 사이트 주소의 단일 출처는 `site_payload`다.
## 최초 설정 (운영자)
1. Search Console에서 발행 도메인의 소유권 확인. URL-prefix 속성이면
`https://web4ai.o2osolution.ai/`, 도메인 속성이면 `sc-domain:web4ai.o2osolution.ai` 형태.
2. Google Cloud에서 Search Console API 활성화, 전용 서비스 계정 생성.
3. Search Console 속성 설정 → 사용자 및 권한에서 그 서비스 계정 이메일에 전체 사용자 권한 부여.
Google 로그인용 `GOOGLE_CLIENT_ID`와는 다른 인증이다.
4. 서비스 계정 JSON 키는 **저장소 밖**에 보관한다. 권한을 최소화하고 git/이미지/로그에 넣지 않는다.
5. 루트 `.env` 설정:
```dotenv
GSC_ENABLED=1
GSC_PROPERTY_URL=https://web4ai.o2osolution.ai/
GSC_CREDENTIALS_HOST_FILE=/secure/location/search-console.json
GSC_ALERT_DAYS=7
GSC_ALERT_WEBHOOK_URL=
```
키 생성/권한 부여/실제 알림 전송은 구현 검증 중 자동 수행하지 않는다.
## 배포
먼저 새 이미지에 requirements를 설치하고 `0014_search_console.sql`을 기존 마이그레이션 도구로 적용한다.
프로젝트 전체 마이그레이션 순서를 확인한 뒤 실행한다. 아래는 운영자가 실행할 명령이며 자동 배포하지 않았다.
```bash
docker compose exec -T solution-backend python scripts/migrate.py
docker compose -f docker-compose.yml -f docker-compose.search-console.yml up -d --build solution-backend
```
선택 compose 파일은 API에만 키를 읽기 전용 마운트하고 `GSC_CREDENTIALS_FILE`을 설정한다.
없는 파일을 디렉토리로 자동 생성하지 않는다. 이후 배포에서도 이 override를 함께 사용해야 한다.
로컬 Python 실행은 `GSC_CREDENTIALS_FILE`에 로컬 키 파일 경로를 지정한다.
켜진 스케줄러는 첫 10분 주기부터 기존 발행 사이트도 등록한다. `GSC_ENABLED=0`이면 DB/Google 호출 모두 생략한다.
## 알림
Teams Workflows의 webhook 수신 → 채널에 Adaptive Card 게시 흐름 URL을
`GSC_ALERT_WEBHOOK_URL`에 넣는다. 비우면 외부 전송 없이 경고 로그/DB만 남는다.
API/사이트맵 오류 또는 발행 후 기본 7일 미색인 시 알린다. 성공한 알림은 사이트별 24시간 중복 억제.
전송 실패는 `alerted_at`을 갱신하지 않아 다음 검사 때 재시도한다.
외부 전송 후 DB commit 전에 죽으면 중복 알림이 가능하다(at-least-once).
키·토큰·webhook URL·Google 오류 본문은 알림에 포함하지 않는다.
## 결과 확인
```bash
docker compose exec -T solution-backend python scripts/search_console_status.py
```
읽기 전용이며 Google API를 추가 호출하지 않는다. 프론트 화면/API 계약은 변경하지 않았다.
| 파일 | 책임 |
|---|---|
| `services/search_console_client.py` | 인증·Google HTTP·오류 정규화 |
| `services/search_console_settings.py` | 선택 설정·속성 URL 범위 |
| `services/search_console_service.py` | 배치 흐름·재시도·관측 결과 |
| `crud/search_console_crud.py` | 발행 감지·등록·조회 순서·동시 실행 잠금 |
| `services/search_console_alerts.py` | 알림 조건·Teams 전송 |
## 구글 지원 범위 / 남은 운영 작업
- [사이트맵 제출 API](https://developers.google.com/webmaster-tools/v1/sitemaps/submit)는 지원된다.
- [URL Inspection API](https://developers.google.com/webmaster-tools/v1/urlInspection.index/inspect)는
Google이 이미 알고 있는 상태 조회용이며 실시간 페이지 테스트나 색인 요청 API가 아니다.
- 일반 숙박 사이트는 [Indexing API](https://developers.google.com/search/apis/indexing-api/v3/using-api) 대상이 아니다.
- [검사 할당량](https://developers.google.com/webmaster-tools/limits)은 속성당 하루 2,000회다.
- [Teams webhook 형식](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook).
- 실제 서비스 계정 권한/사이트맵 제출/색인 관측/Teams 수신은 설정 후 운영 검증이 필요하다.
- 기존 루트 사이트맵의 백업 URL 정리와 IndexNow 개별 사이트맵 참조 문제는 이 기능과 별도다.
이 기능은 기존 공개 사이트맵을 제출하며 내용을 다시 만들거나 목업을 삭제하지 않는다.
## 구현 검증 (2026-09-15)
- 격리 PostgreSQL에서 클라이언트·배치·스키마·IndexNow 관련 59건 통과.
- 발행·설정·사이트 목록 회귀검사: 23건 통과, `test_unverified_fact_blocks_publish` 1건 실패.
해당 실패는 변경 전 HEAD `9773bc0`의 발행 코드에서도 동일 재현됨(GSC 비활성).
- Google/Teams 실호출 없음. 서비스 계정 권한·실제 제출·채널 수신은 운영 설정 후 검증 대상.

View File

@ -0,0 +1,71 @@
# Search Console 클라이언트
`solution/backend/services/search_console_client.py` — Google Search Console 에
사이트맵을 제출하고 URL 색인 상태를 조회하는 REST 클라이언트만 다룬다.
DB 저장·스케줄링·발행 감지·환경 설정은 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md)를 따른다.
공식 문서: [Sitemaps.submit](https://developers.google.com/webmaster-tools/v1/sitemaps/submit) ·
[urlInspection.index.inspect](https://developers.google.com/webmaster-tools/v1/urlInspection.index/inspect)
## 1. 계약
```python
class SearchConsoleClient:
def __init__(self, credentials_file: str, *, transport: httpx.AsyncBaseTransport | None = None): ...
async def submit_sitemap(self, property_url: str, sitemap_url: str) -> None: ...
async def inspect_url(self, property_url: str, page_url: str) -> dict: ... # indexStatusResult 만
async def aclose(self) -> None: ...
# async with SearchConsoleClient(...) as client: ...
```
- `credentials_file`: 서비스 계정 JSON 키 파일 경로.
- `transport`: 테스트에서 `httpx.MockTransport` 를 꽂는 자리 — 실제 Google 호출 없이 검증한다.
- `inspect_url` 은 응답의 `inspectionResult.indexStatusResult` 만 돌려준다. 그 경로가
없거나(검사 실패) 빈 dict 면(실제 검사가 안 된 응답) `SearchConsoleError` 를 올린다 —
**"미색인"으로 넘겨짚지 않는다.**
## 2. 인증
서비스 계정 JSON 키 파일 + scope `https://www.googleapis.com/auth/webmasters`.
`google.oauth2.service_account` · `google.auth.transport.requests.Request` · `requests`
전부 함수 안에서 import 한다. 두 패키지는 백엔드 `requirements.txt`에 포함되어 있다.
- 토큰은 클라이언트 인스턴스에 캐시된다(`credentials.valid` 인 동안 재사용, 매 호출
갱신하지 않는다). 동시 호출은 `asyncio.Lock` 으로 갱신을 한 번만 태운다.
- 갱신은 `asyncio.to_thread` 로 별도 스레드에서 돈다. 내부 `requests.Session` 요청에는
타임아웃을 강제로 20초로 덮어씌운다(`Request.__call__` 기본값 120초를 무시) — 만료된
키·막힌 네트워크에서 무한정 걸리는 것을 막는다.
## 3. 오류 — `SearchConsoleError(code)`
`code` 문자열 하나만 들고 다닌다. **Google 응답 본문·액세스 토큰·키 파일 내용·원본 예외
메시지는 절대 담지 않는다** — 로그·잡 상태·관리 화면 어디로 흘러도 안전하다.
| code | 뜻 |
|---|---|
| `invalid_credentials_file` | 키 파일을 못 읽거나 형식이 잘못됨 |
| `auth_failed` | 토큰 갱신 실패, 또는 갱신 후에도 토큰이 비어 있음 |
| `unauthorized` | HTTP 401 |
| `forbidden` | HTTP 403 |
| `rate_limited` | HTTP 429 |
| `server_error` | HTTP 5xx |
| `http_<code>` | 그 외 실패 상태코드 |
| `timeout` | 요청 타임아웃 |
| `transport_error` | 그 외 전송 실패(연결 끊김 등) |
| `invalid_json` | 200 인데 본문이 JSON 이 아님 |
| `missing_inspection_result` | 응답에 `inspectionResult` 가 없음 |
| `missing_index_status_result` | `inspectionResult` 는 있는데 `indexStatusResult` 가 없거나 빈 dict |
## 4. 테스트
```bash
cd solution/backend
APP_ENV=test .venv/bin/python -m pytest tests/test_search_console_client.py --confcutdir=tests
```
`--confcutdir=tests` 가 필요한 이유: 저장소 루트 `conftest.py` 의 세션 스코프 autouse
픽스처가 실 Postgres 연결을 요구한다(`solution/backend/conftest.py`). 이 클라이언트
테스트는 DB 를 전혀 쓰지 않으므로 그 픽스처를 건너뛴다 — `--confcutdir=tests` 로 상위
`conftest.py` 탐색을 끊는다. (통합 후 전체 스위트를 돌릴 때는 이 플래그 없이 실행한다.)
Google 실 API 는 전부 `httpx.MockTransport` 로 막았다 — 네트워크 호출도, 과금도 없다.

11
docs/TEAMS_WEBHOOK.md Normal file
View File

@ -0,0 +1,11 @@
# Teams 웹훅 확인 (2026-09-15)
- Adaptive Card 요청의 `contentUrl: null``$schema`를 공식 예제에 맞춰 보완했다.
- HTTP 202는 워크플로의 요청 접수다. Teams 채널 게시 성공을 뜻하지 않는다.
- 실제 전송 2건은 202였지만 사용자가 확인한 워크플로 실행은 실패였다.
상세 오류를 확인하지 못했으므로 누락 필드를 실제 실패 원인으로 단정하지 않는다.
- 운영 자동 알림 활성화 전, 채널 수신 또는 워크플로의 최종 게시 단계 성공을 확인해야 한다.
- 웹훅은 `.env`에만 보관하고 커밋하지 않는다.
검증: 백엔드에서 `APP_ENV=test PYTHONPATH=. .venv/bin/pytest tests/test_search_console_alerts.py --confcutdir=tests`.
공식 형식: https://learn.microsoft.com/en-us/connectors/teams/#adaptivecarditemschema

17
docs/WEATHER.md Normal file
View File

@ -0,0 +1,17 @@
# 오늘의 날씨
`Open-Meteo → /v1/local/weather → useLiveWeather → WeatherSection`
관측값은 기존 API를 사용하며 브라우저에서 10분마다 갱신한다. 조회 실패 시 마지막 관측값과
관측 시각을 유지한다. 날씨 문구는 API 요청마다 생성하지 않는다.
`weather_notes.json → weather_notes.py → site_payload._weather → noteSets/tempNoteSets`
하늘 5종·기온 5구간에 각 5문구를 싣는다. 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고,
브라우저에서는 무작위 시작 후 20초마다 한 바퀴 안에서 중복 없이 순환한다.
기온 구간은 기존 `weatherBand`의 30·25·20·10도다. 구름많음은 그림상 흐림과 같지만 문구는 별도다.
목업 README 2.3의 순환 계약을 제품으로 옮겼다. 군산 전용 시설·장소를 다른 사업장에 복사하지
않도록 기본 문구는 장소·시설·영업시간을 주장하지 않는 공통 안내로 구성한다. **LLM 생성이 아니다.**
지역별 장소 추천을 자동 생성하는 작업은 포함하지 않았다. 목업의 수기 문구·산출물은 변경하지 않는다.
옛 단일 `note`·`notes`·`tempNotes` payload도 계속 지원한다. 이미 발행된 사이트는 재발행해야 반영된다.

5
ontology/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
.git
.env
*.log

37
ontology/.env.example Normal file
View File

@ -0,0 +1,37 @@
# --- server ---
PORT=3100
# --- postgres (docker-compose 기본값) ---
DATABASE_URL=postgres://ontology:ontology@localhost:55432/ontology
# --- redis (BullMQ) ---
REDIS_HOST=localhost
REDIS_PORT=56379
# --- 임베딩 ---
# local : 로컬 multilingual-e5-small (384차원, 최초 1회 모델 다운로드 후 오프라인)
# mock : 문자 bigram 해싱 — 의미는 못 잡음
# openai : text-embedding-3-small (dimensions=384 로 요청)
EMBEDDING_PROVIDER=local
EMBEDDING_LOCAL_MODEL=Xenova/multilingual-e5-small
# --- LLM ---
# mock : API 키 없이 로컬에서 전체 파이프라인 동작 (기본값)
# openai : 실제 OpenAI 호출
LLM_PROVIDER=mock
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4.1-mini
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
# --- 생성/중복제거 튜닝 ---
# 코사인 자동 병합 임계값. 짧은 한글 키워드는 같은 도메인이면 0.93+ 가 기본으로 나오므로
# 0.92 는 오병합을 부른다. 실측상 어순 변형만 0.999 대에 모이므로 0.99 로 둔다.
DEDUP_COSINE_THRESHOLD=0.99
# trigram 유사도 사전 필터
DEDUP_TRIGRAM_THRESHOLD=0.6
# 벡터 비교 대상 상위 후보 수
DEDUP_CANDIDATE_LIMIT=20
# 1회 생성 요청당 키워드 목표 개수
GENERATION_TARGET_KEYWORDS=15
# 주기 리프레시 간격(일)
REFRESH_INTERVAL_DAYS=30

8
ontology/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
.env
*.tsbuildinfo
.DS_Store
# 배포 덤프 — 13MB, 재생성 가능 (npm run db:dump)
data/*.sql.gz

23
ontology/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# o2o-site-ontology — 발행 사이트의 메타 키워드를 주는 서비스.
# ★ 왜 이 레포 안에 있나 (2026-09-14) — 발행 파이프라인이 이걸 부르는데 따로 띄워 두면
# "코드는 올라갔는데 서버가 없어" 로 조용히 키워드 없이 발행된다. compose 한 벌로 같이 뜬다.
# ★ alpine 을 쓰지 않는다 (2026-09-14 실측). 임베딩 런타임(onnxruntime)이 musl 용 바이너리를
# 내주지 않아 적재가 ERR_DLOPEN_FAILED 로 죽는다 — 빌드는 성공하고 실행에서만 터진다.
FROM node:22-slim
WORKDIR /app
# 의존성 먼저 — 소스만 바뀌면 이 층은 캐시된다.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# 임베딩 모델(약 120MB)은 첫 실행에 받아 볼륨에 남긴다 — 이미지에 굽지 않는다.
ENV PORT=3100 \
TRANSFORMERS_CACHE=/app/.cache \
HF_HOME=/app/.cache
EXPOSE 3100
CMD ["node", "dist/main.js"]

429
ontology/README.md Normal file
View File

@ -0,0 +1,429 @@
# o2o-site-ontology
o2o-site-AEO 가 발행한 사이트에 **업체별 SEO/AEO 키워드**를 제공하는 온톨로지 서비스.
- **고정 데이터셋 1회 적재** 정책 — 주기 수집 없음 (`data/gunsan-pension-keywords.json`, 1,000건)
- 로컬 임베딩(`multilingual-e5-small`, 384차원)으로 pgvector 에 적재 후 의미 검색
- 업체명 또는 자연어 문장 → 사전에서 잘 맞는 키워드를 골라주는 **매칭 API + 데모 콘솔**
- 어휘 단계 중복제거는 자동, 벡터 근접쌍은 자동 병합하지 않고 검토 목록으로만
## 빠른 시작 (로컬)
**필요한 것:** Docker Desktop 실행 중 · Node 20+
```bash
git clone https://gitea.o2o.kr/Web4ai/o2o-site-ontology.git
cd o2o-site-ontology
npm install
npm run setup # .env 생성 → 컨테이너 → 마이그레이션 → 시드 → 키워드 7,093건 적재
npm start # http://localhost:3100
```
`npm run setup` 이 전부 한다. API 키는 필요 없다 (LLM=mock, 임베딩=로컬 모델).
최초 1회 임베딩 모델을 내려받는다 — 약 120MB, 1~2분. 그 뒤로는 오프라인으로 동작한다.
포트는 기존 개발환경과 겹치지 않게 잡아 두었다 — postgres `55432`, redis `56379`, 앱 `3100`.
확인: **http://localhost:3100/demo** 입력창에 `스테이 머뭄`
엑셀 산출 스크립트를 쓸 때만 파이썬 의존성이 필요하다.
```bash
pip3 install -r scripts/requirements.txt
```
<details><summary>수동으로 단계별 실행</summary>
```bash
cp .env.example .env
npm run db:up # postgres(pgvector) + redis
npm run db:migrate
npm run db:seed # 업종/지역 계층 + 데모 업체
npm run dataset:ingest # 군산 상세 974건
npm run dataset:ingest-nationwide # 전국 54개 지역
```
</details>
브라우저에서 **http://localhost:3100/demo** 를 열면 매칭 콘솔이 뜬다.
입력창에 `스테이 머뭄` 을 넣으면 (띄어쓰기가 달라도) 업체를 해석하고
적재된 971건 사전에서 잘 맞는 키워드를 순위대로 보여준다.
`npm run db:reset` 은 볼륨까지 지우고 migrate + seed 를 다시 돌린다.
### 실제 OpenAI 로 전환
```bash
# .env
LLM_PROVIDER=openai
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4.1-mini
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
```
`LLM_PROVIDER=mock` 은 문자 bigram 해싱 임베딩을 쓴다. 랜덤이 아니라 **비슷한 문자열이면
비슷한 벡터**가 나오므로 중복제거 파이프라인 검증에는 충분하지만, 의미 기반 중복
(`강남 미용실` ↔ `강남 헤어샵`) 은 실제 임베딩 모델에서만 잡힌다.
## 데이터 모델
| 테이블 | 역할 |
|---|---|
| `industry` / `region` | `ltree` 업종·지역 계층. 상위 노드 키워드 상속의 기반 |
| `merchant` | 업체. `external_id` 가 o2o-site-AEO 의 사이트 ID |
| `keyword` | **전역** 키워드 사전. `normalized` 유니크, `aliases[]`, `embedding vector(1536)` |
| `merchant_keyword` | 업체 ↔ 키워드 연결. `relevance` / `status` / `impressions` / `ctr` |
| `qa_pair` | AEO 용 질문-답변 쌍 |
| `generation_run` | 생성 감사 로그 (프롬프트 버전·토큰·통계) |
키워드는 업체에 복제하지 않고 전역 사전 + 연결 테이블로 둔다. 그래야 임베딩이 하나만
저장되고, `강남 미용실` 을 쓰는 업체가 100곳이어도 중복제거가 성립한다.
## 시드 데이터 주의
`src/db/seed.ts` 의 업체 중 **스테이머뭄(site-3001)만 실재 업체**이고, 나머지(레브살롱·헤어랩·소담한상)는
동작 확인용 가상 업체다. 스테이머뭄 프로필도 공개 정보로 확인된 항목만 채웠고,
가격·바베큐·스파·주차·애견동반은 `profile.unverified` 에 남겨 두었다 — 사업자 확인 후 채울 것.
매칭 품질은 프로필 정확도에 그대로 좌우된다. 실제로 초기 시드에 잘못 들어가 있던
"고군산군도 오션뷰" 설정으로는 상위 매칭이 전부 `오션뷰 / 고군산군도` 로 나왔고,
실제 값(원도심 신흥동, 독채 2동)으로 고치자 `군산 원도심 독채펜션 / 군산 독채스테이` 로 바뀌었다.
## 배포
DB 가 기준이다. 데이터셋 JSON 은 생성 원본일 뿐 적재분과 완전히 같지 않다
(지역 간 중복 태그가 한 행으로 합쳐지므로 7,129 → 6,243).
| 산출물 | 명령 | 용도 |
|---|---|---|
| `data/배포용_키워드_DB덤프.xlsx` | `npm run db:export-xlsx` | **DB 7개 테이블 전부**. 8시트 |
| `data/ontology-dump.sql.gz` | `npm run db:dump` | **임베딩 포함 그대로 복원**. 13MB, git 제외 |
### A. pg_dump 복원 (권장)
```bash
npm run db:dump
gunzip -c data/ontology-dump.sql.gz | psql "$TARGET_DATABASE_URL"
```
대상 DB 에 `vector` · `ltree` · `pg_trgm` 확장이 있어야 한다. 재임베딩이 없어 즉시 뜬다.
복원 검증 완료 — 6개 테이블 행수 일치, 임베딩 7,093/7,093 보존, HNSW 인덱스 재생성, 벡터 검색 동작.
### B. 재적재
```bash
npm run db:migrate && npm run db:seed
npm run dataset:ingest && npm run dataset:ingest-nationwide
```
텍스트에서 임베딩을 다시 만든다. 최초 1회 모델 다운로드(약 50초) + 임베딩 약 15초.
같은 모델이면 값이 동일하게 나오므로 A 와 결과가 같다.
### 엑셀 시트 (DB 테이블과 1:1)
| 시트 | 테이블 | 행 |
|---|---|---:|
| 키워드 | `keyword` | 7,093 |
| 지역 | `region` | 70 |
| 업종 | `industry` | 9 |
| 업체 | `merchant` | 4 |
| 업체키워드 | `merchant_keyword` | 11 |
| QA(AEO) | `qa_pair` | 20 |
| 생성이력 | `generation_run` | 4 |
| 배포가이드 | — | 25 |
임베딩만 담지 않는다 (384 float × 7천 행). `[임베딩]` 열에 보유 여부만 표시하며,
같은 모델로 재생성하면 동일하게 복원된다.
### 현재 적재 내용
| 출처 | 건수 | 내용 |
|---|---|---|
| `nationwide` | 6,243 | 전국 54개 지역 |
| `dataset` | 850 | 군산 상세 (매칭 엔진 개발용) |
| **합계** | **7,093** | 전부 임베딩 보유 |
⚠ 검색량은 아직 비어 있다. 실서비스 전에 키워드도구로 채우고 월 10 미만을 걷어내야 한다.
## 전국 지역별 데이터셋 (기획 변경분)
`data/전국_펜션_SEO_AEO_키워드.xlsx` — 전국 54개 펜션 수요 지역 × **7,138건**.
`npm run dataset:nationwide` 로 재생성한다 (`data/regions.json` → JSON → 엑셀).
시트 4개: `키워드` / `지역마스터` / `지역별요약` / `사용가이드`
**조합 폭발을 하지 않았다.** 군산 단일 지역 974건을 54개에 곱하면 5만 건이 되는데,
단일 지역 검증에서 저장분의 89%가 한 번도 쓰이지 않았다. 지역당 ~110건으로 눌렀다.
**지역 성격이 시설 키워드를 결정한다.** `regions.json``type`(해변·산간·호수·강변·도심·섬·계곡)에
따라 유효한 시설만 전개한다 — 평창·무주에는 오션뷰 키워드가 0건, 태안·거제에는 산뷰가 0건이다.
**티어** — 주력 568 / 보조 3,816 / 롱테일 1,836 / 태그 918.
주력은 페이지당 1개만 쓰는 대표 키워드 후보다.
⚠ **이 키워드는 검색 패턴 생성물이지 실제 검색 데이터가 아니다.**
엑셀의 `월간검색수`·`경쟁도` 열은 비워 두었다. 네이버 검색광고 키워드도구로 채운 뒤
월 10 미만을 걷어내야 실제로 쓸 수 있다.
## 데이터셋 (군산 단일 지역 · 매칭 엔진용)
`data/gunsan-pension-keywords.json` — "군산 펜션" 주제로 직접 작성한 1,000건.
실제 군산 지명(선유도·고군산군도·새만금·은파호수공원·경암동 철길마을 …)과
숙박 시설 용어를 어휘로 두고, 한국 로컬 숙박 검색에서 실제로 쓰이는 패턴만 전개했다.
| 카테고리 | 건수 | 예시 |
|---|---:|---|
| 롱테일 | 374 | 군산 커플 오션뷰 펜션 |
| 시설 | 104 | 군산 자쿠지 펜션 |
| 권역 | 99 | 선유도 독채펜션 |
| 동반자 | 98 | 군산 애견동반 펜션 |
| 시즌 | 72 | 군산 여름휴가 펜션 |
| 관광지 | 64 | 경암동 철길마을 근처 숙소 |
| 태그 | 63 | 오션뷰 · 불멍 · 애견운동장 |
| 코어 | 51 | 군산 펜션 추천 |
| 질문형 | 33 | 군산 펜션 바베큐 가능한가요 |
| 의도 | 13 | 군산 펜션 실시간예약 |
적재 결과: 1,000건 → 어휘 중복 27건 병합, 금칙어 2건 차단 → **971건 적재**.
`npm run dataset:build` 로 다시 만들고 `npm run dataset:ingest` 로 다시 넣는다.
적재는 upsert 이고, **데이터셋에서 빠진 행은 같이 지운다** — 안 그러면 재빌드할 때마다
이전 판본 잔여가 쌓여 사전이 계속 커진다 (실제로 974건 데이터셋인데 사전이 1072건까지 불었다).
| 명령 | 용도 |
|---|---|
| `npm run dataset:build` | 데이터셋 생성 |
| `npm run dataset:ingest` | 임베딩 + 적재 + 잔여 정리 |
| `npm run dataset:purge` | 큐레이션 외 출처(`llm` 등) 제거. `--apply` 로 실행 |
| `npm run dataset:import-related` | 검색광고 키워드도구 내려받기(CSV/JSON) 병합. `--apply` 로 실행 |
`dataset:import-related` 는 API 클라이언트가 아니라 파일 임포터다. 검색광고 API 는
계정·HMAC 서명이 필요해 자격증명 없이 검증할 수 없다. 키워드도구에서 CSV 를 내려받아
`data/related-keywords.sample.csv` 형식으로 두면 그대로 병합된다 —
나중에 API 를 붙여도 이 임포터를 재사용한다.
## 임베딩 임계값 — 실측으로 정정한 부분
설계 초안의 코사인 자동 병합 임계값 0.92 는 **틀렸다.** 짧은 한글 키워드에서는
같은 도메인이기만 하면 절대 코사인이 기본적으로 높게 나온다.
| 쌍 | 실제 관계 | cos (e5-small) |
|---|---|---:|
| 군산 키즈룸 펜션 ↔ 군산 펜션 키즈룸 | 중복 (어순) | **0.9995** |
| 군산 애견동반 펜션 ↔ 군산 반려견 동반 펜션 | 중복 (동의어) | 0.9886 |
| 선유도 펜션 ↔ 선유도 팬션 | 중복 (오타) | 0.9585 |
| 군산 펜션 ↔ 군산 호텔 | **별개** | 0.9698 |
| 선유도 펜션 ↔ 새만금 펜션 | **별개** | 0.9356 |
중복과 별개의 분포가 겹치므로 단일 임계값으로는 깨끗하게 못 가른다
(`paraphrase-multilingual-MiniLM-L12-v2` 도 동일).
그래서 정책을 이렇게 바꿨다.
- **자동 병합의 주력은 어휘 단계(1~2)** — 공백/구두점 정규화와 `pg_trgm` 이 오타·표기 변형을 잡는다
- **벡터 단계는 0.99 로 올려 잡는다** — 어순 변형처럼 확실한 것만 걸린다
- **적재 시에는 벡터 병합을 아예 하지 않고 검토 목록만 출력한다** (`npm run dataset:ingest` 끝부분)
## 중복제거 4단계
값비싼 벡터 비교를 마지막에 두고, 후보 집합 안에서만 수행한다.
| 단계 | 방법 | 걸러내는 것 |
|---|---|---|
| 0 | 금칙어 필터 | `최고`, `1위`, `100%` 등 과장광고 |
| 1 | `normalized` 완전 일치 (공백·구두점 제거) | `강남 뿌리 염색` = `강남 뿌리염색` |
| 2 | `pg_trgm` 유사도 ≥ 0.6 | `강남 뿌리염색약``강남 뿌리염색` |
| 3 | 코사인 유사도 ≥ 0.92 | `강남 미용실``강남 헤어샵` (의미 중복) |
| 4 | 신규 등록 | 위에 안 걸리면 새 키워드 |
1~3 단계에서 매칭되면 원래 표기는 버리지 않고 기존 키워드의 `aliases[]` 로 흡수한다
(롱테일 검색어 보존 + 성과 피드백 매칭에 사용).
임계값은 `.env``DEDUP_COSINE_THRESHOLD` / `DEDUP_TRIGRAM_THRESHOLD` 로 조정.
## API
| 메서드 | 경로 | 용도 |
|---|---|---|
| `GET` | `/health` | 헬스체크 |
| `POST` | `/v1/merchants/publish` | **사이트 발행 웹훅** — 업체 upsert + 생성 예약 (`sync:true` 면 동기 실행) |
| `POST` | `/v1/merchants/:id/generate?sync=true` | 수동 재생성 |
| `GET` | `/v1/merchants` `/v1/merchants/:id` | 조회 |
| `GET` | `/v1/sites/:id/seo?limit=20` | **발행 사이트가 렌더링 시 호출** — title/description/keywords/tags |
| `GET` | `/v1/sites/:id/aeo?limit=10` | 답변엔진용 topics/FAQ/structuredDataHints |
| `POST` | `/v1/match` | **업체명 또는 문장 → 사전에서 잘 맞는 키워드.** `mode=fusion`(기본) / `single`(통짜, 비교용) |
| `POST` | `/v1/keywords/search` | 의미 기반 키워드 검색 (어드민) |
| `GET` | `/demo` | 매칭 콘솔 (로컬 확인용) |
| `POST` | `/v1/sites/:id/performance` | Search Console·유입 로그 피드백 → 저성과 키워드 강등 |
`:id``external_id` 또는 내부 UUID 둘 다 받는다.
### 발행 웹훅 예시
```bash
curl -X POST http://localhost:3100/v1/merchants/publish \
-H 'content-type: application/json' \
-d '{
"externalId": "site-1003",
"name": "강남 뷰티랩",
"industryId": "beauty.hair",
"regionId": "kr.seoul.gangnam",
"description": "강남 미용실. 염색 전문.",
"profile": { "services": ["뿌리염색", "여성펌"], "features": ["주차가능"] }
}'
```
### 서빙 예시
```bash
curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8'
```
```json
{
"title": "레브살롱 | 강남 미용실",
"description": "강남역 3번 출구 앞 프라이빗 헤어살롱. ... 정보를 확인하세요.",
"keywords": ["레브살롱", "강남 미용실", "강남 남자 커트", "..."],
"tags": [{ "keyword": "강남 미용실", "intent": "local", "relevance": 0.95, "aliases": ["강남미용실"] }]
}
```
### 매칭 — 속성별 다중 질의 + 사실 기반 필터
프로필을 통짜로 한 벡터에 넣으면 속성이 희석된다. 실측:
| 방식 | 점수 범위 | 폭 |
|---|---|---|
| 통짜 질의문 하나 | 0.8761 ~ 0.8837 | 0.0076 |
| 속성별로 쪼갠 질의 | 0.8552 ~ 0.9176 | **0.0624** |
976건이 전부 0.87 언저리에 뭉쳐 순위는 매기지만 변별하지 못하는 상태였다.
그래서 프로필을 레인으로 쪼개 각각 임베딩하고 가중 RRF 로 융합한다.
| 레인 | 가중치 | 질의문 예시 |
|---|---|---|
| 유형 | 1.0 | `군산 펜션 독채 감성숙소` |
| 위치 | 0.7 | `원도심 신흥동 말랭이마을 동국사 근처` |
| 동반자 | 0.6 | `커플 친구 가족 혼자` |
| 시설 | 0.6 | `프라이빗` |
레인 설계에서 실측으로 배운 것 세 가지.
- **브랜드 레인을 두면 안 된다.** 상호는 사전에 없으므로 결국 `군산 펜션` 만 남아
가장 generic 한 것들을 끌어온다. 넣었더니 상위 6개가 전부 `~예약` 으로 도배됐다.
- **레인끼리 겹치면 안 된다.** 권역과 인근을 따로 두었더니 `신흥동` 토큰이 양쪽에 걸려
위치 키워드가 상위를 쓸어갔고, 정작 핵심인 `군산 펜션 독채` 가 8위로 밀렸다. 한 레인으로 합쳤다.
- **RRF 상수는 관례값 60 이 아니라 20.** 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라
깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 올라온다. 20 이면 2.9배로 벌어진다.
#### 레인 구성
| 레인 | 가중치 | 출처 | 비고 |
|---|---|---|---|
| 유형 | 1.0 | 지역 + 업종 + 숙소유형 | 앵커. 주력 키워드가 여기서 나온다 |
| **고객언어** | **0.9** | `reviewSignals` 빈출어 + `hashtags` | 사업자 표현보다 검색어에 가깝다 |
| 위치 | 0.7 | 권역 + 행정동 + 인근 랜드마크 | |
| 동반자 | 0.6 | `audiences` | |
| 시설 | 0.6 | 정규화된 `amenities` | |
레인 텍스트는 **낱말 단위로 중복을 제거**한다. 문자열 단위 Set 만으로는 `신흥동`
`신흥동 일본식가옥` 이 서로 다른 원소라 같은 낱말이 두 번 실리고, 그쪽으로 레인이 쏠린다.
후보 풀은 **업체 업종으로 한정**하고 `source IN ('dataset','manual')` 만 본다.
사전 전체를 뒤지면 다른 업종 키워드(`강남 미용실` 등)가 후보에 섞인다.
#### 고객 언어 신호
리뷰 **원문은 받지 않는다** (저작권·개인정보). 빈도 집계만 받는다.
```json
"hashtags": ["#군산감성숙소", "#뚜벅이여행"],
"reviewSignals": [{ "term": "조용한", "count": 41 }, { "term": "사진찍기 좋은", "count": 28 }]
```
빈도 높은 순으로 정렬해 레인 질의문을 만든다. 데이터가 없으면 레인 자체가 생기지 않는다.
현재 스테이머뭄에는 이 데이터가 없다 — 인스타그램은 로그인 월이라 스크래퍼가 채워야 한다.
#### 사실 기반 필터 — 벡터가 못 거르는 것
임베딩은 "비슷함"만 알지 "최대 4인 < 단체" 모른다. 그래서 코드 조건으로 배제한다.
| 규칙 | 예시 |
|---|---|
| 수용 인원 | 최대 4인 → `군산 단체 독채펜션`, `군산 독채 세미나실 펜션` 배제 |
| 권역 불일치 | 원도심 업체 → `선유도`·`오션뷰` 계열 배제 |
| 미보유 시설 | `수영장` 없음 → `군산 독채 온수풀 펜션` 배제 |
| **미확인 시설** | `바베큐``unverified` → 배제하지 않고 **보류** 표시 |
마지막 항목이 중요하다. 사업자가 확인해주지 않은 항목은 "없음"이 아니라 "모름"이다.
스테이머뭄 기준 61건이 배제됐고, 배제 사유는 응답의 `excluded` 로 함께 내려준다.
#### 레인별 출력 = SEO 페이지 배분
응답의 `byLane` 은 레인별 상위 8건이다. 평평한 순위보다 이쪽이 실무에 쓰인다 —
한 페이지의 주력 키워드는 1개여야 하므로, **레인 1위가 그 페이지의 주력**이 된다.
| 레인 | → 페이지 | 주력 |
|---|---|---|
| 유형 | 메인 | 군산 펜션 독채 |
| 위치 | 주변 여행 | 신흥동 일본식가옥 근처 숙소 |
| 동반자 | 객실 | 군산 커플 프라이빗 펜션 |
| 시설 | 시설 | 군산 프라이빗 펜션 |
### 매칭 예시
```bash
curl -s -X POST http://localhost:3100/v1/match \
-H 'content-type: application/json' -d '{"query":"스테이 머뭄","limit":5}'
```
업체명이면 상호만으로 임베딩하지 않고 **프로필 전체를 질의문으로 조립**한다.
상호는 브랜드명이라 그것만으로는 매칭이 얕아지기 때문이다.
```
해석: 스테이머뭄 (군산 / 펜션) ← "스테이 머뭄" 과 띄어쓰기가 달라도 해석됨
질의문: 스테이머뭄 군산 펜션 고군산군도 초입에 자리한 독채 펜션 … 오션뷰 애견동반 …
0.8765 군산 독채펜션 예약 [transactional] 코어
0.8762 군산 애견동반 독채펜션 [local] 동반자
0.8751 고군산군도 독채펜션 [local] 권역
0.8735 군산 바베큐 펜션 예약 [transactional] 시설
0.8718 군산 오션뷰 펜션 예약 [transactional] 시설
```
업체가 해석되지 않으면 입력 문장을 그대로 질의로 쓴다.
```
"선유도 근처에서 바베큐 되는 독채"
0.9166 선유도 독채펜션
0.9155 선유도 바베큐 펜션
0.9051 선유도해수욕장 근처 숙소
```
## 생성 주기
- **발행 즉시**`/v1/merchants/publish` 가 BullMQ 에 적재 (60초 dedupe 창)
- **주기 리프레시** — 매일 03:00 크론이 `REFRESH_INTERVAL_DAYS`(기본 30일) 지난 업체를 적재
- **성과 기반** — 노출 100회 이상 & CTR < 0.2% 키워드는 `demoted` 강등, 다음 사이클에서 대체
프롬프트에는 해당 업체와 같은 업종의 기존 키워드 목록을 넣어 **중복 후보 생성 자체를 줄인다.**
그래도 남는 중복만 위 4단계가 처리한다.
## 남은 작업
- [ ] JSON-LD (`LocalBusiness` / `FAQPage` / `Service`) 조립 — `structuredDataHints` 를 그대로 매핑
- [ ] `/llms.txt` 서빙
- [ ] 업종 `ltree` 상위 노드 키워드 상속 (`source: 'inherited'`)
- [ ] Redis 응답 캐시 (서빙은 읽기 99%)
- [ ] Search Console API 연동 (현재는 `/performance` 수동 주입)
- [ ] 어드민 UI
## 아키텍처 도식
| 파일 | 용도 |
|---|---|
| `docs/architecture.html` | 브라우저용 설계 문서 — 전체 흐름 · 중복제거 단계 · 데이터 모델 |
| `docs/architecture.pptx` | 발표용 11장 덱. 도식은 이미지가 아니라 네이티브 도형이라 PowerPoint 에서 바로 편집된다 |
덱은 `python3 scripts/build-deck.py` 로 다시 생성한다 (`pip install python-pptx` 필요).
한글 폰트는 `Apple SD Gothic Neo`, 코드는 `Menlo` 로 지정되어 있다 — Windows 에서 열 때는
`scripts/build-deck.py` 상단의 `SANS` / `MONO``맑은 고딕` / `Consolas` 로 바꿔 다시 생성하면 된다.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

803
ontology/data/regions.json Normal file
View File

@ -0,0 +1,803 @@
{
"note": "전국 펜션 수요 지역 마스터. type 은 그 지역에서 유효한 시설 키워드를 결정한다 (해변→오션뷰, 산간→계곡뷰 등). spots 는 실제 대표 관광지. aliases 는 같은 지역을 가리키는 다른 검색 표기 (예: 대천/보령). ski=true 는 실제 스키장이 있는 지역 (산간이라고 다 스키장이 있는 건 아니다).",
"regions": [
{
"sido": "경기",
"name": "가평",
"key": "kr.gyeonggi.gapyeong",
"type": [
"호수",
"산간"
],
"spots": [
"남이섬",
"쁘띠프랑스",
"아침고요수목원",
"자라섬",
"청평호"
]
},
{
"sido": "경기",
"name": "양평",
"key": "kr.gyeonggi.yangpyeong",
"type": [
"강변",
"산간"
],
"spots": [
"두물머리",
"세미원",
"용문사"
]
},
{
"sido": "경기",
"name": "포천",
"key": "kr.gyeonggi.pocheon",
"type": [
"호수",
"산간"
],
"spots": [
"산정호수",
"포천아트밸리",
"허브아일랜드"
]
},
{
"sido": "경기",
"name": "파주",
"key": "kr.gyeonggi.paju",
"type": [
"도심",
"강변"
],
"spots": [
"헤이리예술마을",
"임진각",
"프로방스마을"
]
},
{
"sido": "인천",
"name": "강화",
"key": "kr.incheon.ganghwa",
"type": [
"해변",
"산간"
],
"spots": [
"마니산",
"동막해변",
"강화고인돌",
"전등사"
]
},
{
"sido": "인천",
"name": "을왕리",
"key": "kr.incheon.yeongjong",
"type": [
"해변"
],
"spots": [
"을왕리해수욕장",
"무의도",
"하나개해수욕장"
],
"aliases": [
"영종도"
]
},
{
"sido": "강원",
"name": "춘천",
"key": "kr.gangwon.chuncheon",
"type": [
"호수",
"도심"
],
"spots": [
"소양강스카이워크",
"김유정역",
"의암호",
"남이섬"
]
},
{
"sido": "강원",
"name": "홍천",
"key": "kr.gangwon.hongcheon",
"type": [
"산간",
"계곡"
],
"spots": [
"비발디파크",
"은행나무숲",
"홍천강"
],
"ski": true
},
{
"sido": "강원",
"name": "인제",
"key": "kr.gangwon.inje",
"type": [
"산간",
"계곡"
],
"spots": [
"자작나무숲",
"내린천",
"백담사"
]
},
{
"sido": "강원",
"name": "평창",
"key": "kr.gangwon.pyeongchang",
"type": [
"산간"
],
"spots": [
"대관령",
"오대산",
"월정사",
"알펜시아",
"양떼목장"
],
"ski": true
},
{
"sido": "강원",
"name": "정선",
"key": "kr.gangwon.jeongseon",
"type": [
"산간",
"계곡"
],
"spots": [
"하이원리조트",
"레일바이크",
"병방치스카이워크"
],
"ski": true
},
{
"sido": "강원",
"name": "강릉",
"key": "kr.gangwon.gangneung",
"type": [
"해변",
"도심"
],
"spots": [
"경포대",
"안목해변",
"정동진",
"주문진",
"오죽헌"
]
},
{
"sido": "강원",
"name": "속초",
"key": "kr.gangwon.sokcho",
"type": [
"해변",
"산간"
],
"spots": [
"설악산",
"대포항",
"영금정",
"아바이마을",
"속초해수욕장"
]
},
{
"sido": "강원",
"name": "양양",
"key": "kr.gangwon.yangyang",
"type": [
"해변"
],
"spots": [
"낙산사",
"죽도해변",
"하조대",
"서피비치",
"인구해변"
]
},
{
"sido": "강원",
"name": "고성",
"key": "kr.gangwon.goseong",
"type": [
"해변"
],
"spots": [
"송지호",
"화진포",
"통일전망대",
"백섬해상전망대"
]
},
{
"sido": "강원",
"name": "동해",
"key": "kr.gangwon.donghae",
"type": [
"해변"
],
"spots": [
"추암촛대바위",
"망상해수욕장",
"무릉계곡"
]
},
{
"sido": "강원",
"name": "삼척",
"key": "kr.gangwon.samcheok",
"type": [
"해변",
"계곡"
],
"spots": [
"장호항",
"환선굴",
"맹방해변"
]
},
{
"sido": "강원",
"name": "태백",
"key": "kr.gangwon.taebaek",
"type": [
"산간"
],
"spots": [
"태백산",
"검룡소",
"365세이프타운"
],
"ski": true
},
{
"sido": "충북",
"name": "단양",
"key": "kr.chungbuk.danyang",
"type": [
"산간",
"강변"
],
"spots": [
"도담삼봉",
"만천하스카이워크",
"고수동굴",
"단양강잔도"
]
},
{
"sido": "충북",
"name": "제천",
"key": "kr.chungbuk.jecheon",
"type": [
"호수",
"산간"
],
"spots": [
"청풍호",
"의림지",
"배론성지"
]
},
{
"sido": "충북",
"name": "충주",
"key": "kr.chungbuk.chungju",
"type": [
"호수",
"산간"
],
"spots": [
"탄금대",
"수안보온천",
"충주호"
]
},
{
"sido": "충북",
"name": "괴산",
"key": "kr.chungbuk.goesan",
"type": [
"산간",
"계곡"
],
"spots": [
"산막이옛길",
"화양구곡"
]
},
{
"sido": "충남",
"name": "태안",
"key": "kr.chungnam.taean",
"type": [
"해변"
],
"spots": [
"만리포해수욕장",
"꽃지해변",
"안면도",
"신두리해안사구",
"청포대"
]
},
{
"sido": "충남",
"name": "대천",
"key": "kr.chungnam.boryeong",
"type": [
"해변"
],
"spots": [
"대천해수욕장",
"무창포",
"죽도상화원",
"성주산"
],
"aliases": [
"보령"
]
},
{
"sido": "충남",
"name": "서산",
"key": "kr.chungnam.seosan",
"type": [
"해변",
"도심"
],
"spots": [
"해미읍성",
"간월암",
"개심사"
]
},
{
"sido": "충남",
"name": "공주",
"key": "kr.chungnam.gongju",
"type": [
"도심",
"강변"
],
"spots": [
"공산성",
"무령왕릉",
"마곡사"
]
},
{
"sido": "충남",
"name": "부여",
"key": "kr.chungnam.buyeo",
"type": [
"도심",
"강변"
],
"spots": [
"궁남지",
"부소산성",
"백제문화단지"
]
},
{
"sido": "전북",
"name": "군산",
"key": "kr.jeonbuk.gunsan",
"type": [
"해변",
"도심"
],
"spots": [
"선유도",
"고군산군도",
"말랭이마을",
"경암동 철길마을",
"근대역사박물관",
"은파호수공원",
"새만금"
]
},
{
"sido": "전북",
"name": "변산",
"key": "kr.jeonbuk.buan",
"type": [
"해변",
"산간"
],
"spots": [
"채석강",
"변산해수욕장",
"내소사",
"격포항"
],
"aliases": [
"부안"
]
},
{
"sido": "전북",
"name": "전주",
"key": "kr.jeonbuk.jeonju",
"type": [
"도심"
],
"spots": [
"전주한옥마을",
"경기전",
"전동성당",
"남부시장"
]
},
{
"sido": "전북",
"name": "무주",
"key": "kr.jeonbuk.muju",
"type": [
"산간",
"계곡"
],
"spots": [
"덕유산",
"무주리조트",
"반디랜드",
"구천동계곡"
],
"ski": true
},
{
"sido": "전북",
"name": "남원",
"key": "kr.jeonbuk.namwon",
"type": [
"도심",
"산간"
],
"spots": [
"광한루원",
"지리산",
"춘향테마파크"
]
},
{
"sido": "전남",
"name": "여수",
"key": "kr.jeonnam.yeosu",
"type": [
"해변",
"도심"
],
"spots": [
"오동도",
"돌산대교",
"향일암",
"여수해상케이블카",
"낭만포차"
]
},
{
"sido": "전남",
"name": "순천",
"key": "kr.jeonnam.suncheon",
"type": [
"도심",
"습지"
],
"spots": [
"순천만습지",
"낙안읍성",
"순천만국가정원"
]
},
{
"sido": "전남",
"name": "담양",
"key": "kr.jeonnam.damyang",
"type": [
"도심",
"계곡"
],
"spots": [
"죽녹원",
"메타세쿼이아길",
"소쇄원"
]
},
{
"sido": "전남",
"name": "구례",
"key": "kr.jeonnam.gurye",
"type": [
"산간",
"계곡"
],
"spots": [
"지리산",
"화엄사",
"섬진강",
"사성암"
]
},
{
"sido": "전남",
"name": "해남",
"key": "kr.jeonnam.haenam",
"type": [
"해변",
"산간"
],
"spots": [
"땅끝마을",
"두륜산",
"대흥사"
]
},
{
"sido": "전남",
"name": "완도",
"key": "kr.jeonnam.wando",
"type": [
"해변",
"섬"
],
"spots": [
"청산도",
"명사십리해수욕장",
"완도타워"
]
},
{
"sido": "전남",
"name": "보성",
"key": "kr.jeonnam.boseong",
"type": [
"도심",
"해변"
],
"spots": [
"보성녹차밭",
"율포해수욕장"
]
},
{
"sido": "경북",
"name": "경주",
"key": "kr.gyeongbuk.gyeongju",
"type": [
"도심",
"호수"
],
"spots": [
"불국사",
"첨성대",
"황리단길",
"보문단지",
"동궁과월지"
]
},
{
"sido": "경북",
"name": "포항",
"key": "kr.gyeongbuk.pohang",
"type": [
"해변",
"도심"
],
"spots": [
"호미곶",
"영일대해수욕장",
"죽도시장",
"스페이스워크"
]
},
{
"sido": "경북",
"name": "안동",
"key": "kr.gyeongbuk.andong",
"type": [
"도심",
"강변"
],
"spots": [
"하회마을",
"월영교",
"도산서원"
]
},
{
"sido": "경북",
"name": "영덕",
"key": "kr.gyeongbuk.yeongdeok",
"type": [
"해변"
],
"spots": [
"강구항",
"블루로드",
"고래불해수욕장"
]
},
{
"sido": "경북",
"name": "울진",
"key": "kr.gyeongbuk.uljin",
"type": [
"해변",
"산간"
],
"spots": [
"죽변항",
"덕구온천",
"성류굴",
"후포항"
]
},
{
"sido": "경북",
"name": "문경",
"key": "kr.gyeongbuk.mungyeong",
"type": [
"산간",
"계곡"
],
"spots": [
"문경새재",
"단산모노레일",
"에코랄라"
]
},
{
"sido": "경남",
"name": "거제",
"key": "kr.gyeongnam.geoje",
"type": [
"해변",
"섬"
],
"spots": [
"외도보타니아",
"바람의언덕",
"학동몽돌해변",
"매미성",
"windy hill"
]
},
{
"sido": "경남",
"name": "통영",
"key": "kr.gyeongnam.tongyeong",
"type": [
"해변",
"섬"
],
"spots": [
"동피랑벽화마을",
"통영케이블카",
"미륵산",
"한산도",
"장사도"
]
},
{
"sido": "경남",
"name": "남해",
"key": "kr.gyeongnam.namhae",
"type": [
"해변",
"섬"
],
"spots": [
"다랭이마을",
"독일마을",
"상주은모래비치",
"보리암"
]
},
{
"sido": "경남",
"name": "하동",
"key": "kr.gyeongnam.hadong",
"type": [
"산간",
"강변"
],
"spots": [
"화개장터",
"쌍계사",
"섬진강",
"최참판댁"
]
},
{
"sido": "경남",
"name": "사천",
"key": "kr.gyeongnam.sacheon",
"type": [
"해변"
],
"spots": [
"삼천포대교",
"사천케이블카",
"실안노을길"
]
},
{
"sido": "경남",
"name": "산청",
"key": "kr.gyeongnam.sancheong",
"type": [
"산간",
"계곡"
],
"spots": [
"지리산",
"동의보감촌",
"대원사계곡"
]
},
{
"sido": "부산",
"name": "기장",
"key": "kr.busan.gijang",
"type": [
"해변"
],
"spots": [
"해동용궁사",
"일광해수욕장",
"아난티코브",
"죽성성당"
]
},
{
"sido": "제주",
"name": "제주시",
"key": "kr.jeju.jejusi",
"type": [
"해변",
"도심"
],
"spots": [
"애월",
"함덕해수욕장",
"협재해수욕장",
"이호테우",
"한림공원"
]
},
{
"sido": "제주",
"name": "서귀포",
"key": "kr.jeju.seogwipo",
"type": [
"해변",
"산간"
],
"spots": [
"중문색달해변",
"성산일출봉",
"쇠소깍",
"천지연폭포",
"우도"
]
}
]
}

View File

@ -0,0 +1,8 @@
# 네이버 검색광고 > 도구 > 키워드도구 에서 내려받은 CSV 를 이 형태로 두면 된다.
# (아래 숫자는 형식 예시용 더미값 — 실제 데이터로 교체할 것)
relKeyword,monthlyPcQcCnt,monthlyMobileQcCnt,compIdx
군산독채펜션,210,1830,중간
군산감성숙소,90,760,낮음
군산2인펜션,40,310,낮음
군산뚜벅이여행,30,240,낮음
말랭이마을숙소,10,90,낮음
1 # 네이버 검색광고 > 도구 > 키워드도구 에서 내려받은 CSV 를 이 형태로 두면 된다.
2 # (아래 숫자는 형식 예시용 더미값 — 실제 데이터로 교체할 것)
3 relKeyword,monthlyPcQcCnt,monthlyMobileQcCnt,compIdx
4 군산독채펜션,210,1830,중간
5 군산감성숙소,90,760,낮음
6 군산2인펜션,40,310,낮음
7 군산뚜벅이여행,30,240,낮음
8 말랭이마을숙소,10,90,낮음

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,33 @@
services:
postgres:
image: pgvector/pgvector:pg16
container_name: ontology-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ontology
POSTGRES_PASSWORD: ontology
POSTGRES_DB: ontology
ports:
- '55432:5432'
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ontology -d ontology']
interval: 5s
timeout: 3s
retries: 20
redis:
image: redis:7-alpine
container_name: ontology-redis
restart: unless-stopped
ports:
- '56379:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 20
volumes:
pgdata:

View File

@ -0,0 +1,714 @@
<title>키워드 온톨로지 설계</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Gowun+Batang:wght@400;700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+KR:wght@300;400;500;600;700&display=swap">
<style>
:root {
--bg: #f4f6f5;
--surface: #ffffff;
--surface-2: #eceff0;
--ink: #101819;
--ink-soft: #3d4c4e;
--muted: #63757a;
--line: #d5dcdb;
--line-soft: #e4e9e8;
--accent: #0d6a60;
--accent-bg: #dff0ec;
--warn: #8a5a06;
--warn-bg: #f6ead2;
--stop: #9d3a30;
--stop-bg: #f6e0dc;
--shadow: 0 1px 2px rgba(16,24,25,.05), 0 8px 24px -16px rgba(16,24,25,.35);
--display: 'Gowun Batang', 'Apple SD Gothic Neo', serif;
--body: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', -apple-system, sans-serif;
--mono: 'IBM Plex Mono', 'SFMono-Regular', ui-monospace, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0d1213;
--surface: #141b1c;
--surface-2: #1b2425;
--ink: #e7edeb;
--ink-soft: #c2cecd;
--muted: #8d9d9f;
--line: #263130;
--line-soft: #1e2728;
--accent: #56c2b1;
--accent-bg: #12312e;
--warn: #d7a34a;
--warn-bg: #33270f;
--stop: #e28a80;
--stop-bg: #37201d;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -16px rgba(0,0,0,.8);
}
}
:root[data-theme="dark"] {
--bg: #0d1213;
--surface: #141b1c;
--surface-2: #1b2425;
--ink: #e7edeb;
--ink-soft: #c2cecd;
--muted: #8d9d9f;
--line: #263130;
--line-soft: #1e2728;
--accent: #56c2b1;
--accent-bg: #12312e;
--warn: #d7a34a;
--warn-bg: #33270f;
--stop: #e28a80;
--stop-bg: #37201d;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -16px rgba(0,0,0,.8);
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--body);
font-weight: 400;
line-height: 1.7;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1240px; margin: 0 auto; padding: 56px 32px 96px; }
.col { max-width: 760px; }
/* ---------- masthead ---------- */
.masthead { border-bottom: 1px solid var(--line); padding-bottom: 28px; margin-bottom: 44px; }
.eyebrow {
font-family: var(--mono); font-size: 11px; font-weight: 500;
letter-spacing: .14em; text-transform: uppercase; color: var(--accent);
margin: 0 0 14px;
}
h1 {
font-family: var(--display); font-weight: 700;
font-size: clamp(30px, 4.4vw, 46px); line-height: 1.18; letter-spacing: -.01em;
margin: 0 0 16px; text-wrap: balance;
}
.standfirst { font-size: 17px; color: var(--ink-soft); margin: 0; max-width: 62ch; font-weight: 300; }
.meta {
display: flex; flex-wrap: wrap; gap: 8px; margin-top: 22px;
font-family: var(--mono); font-size: 11.5px; color: var(--muted);
}
.meta span {
border: 1px solid var(--line); border-radius: 3px;
padding: 3px 9px; background: var(--surface);
}
/* ---------- sections ---------- */
section { margin-top: 64px; }
h2 {
font-family: var(--display); font-weight: 700;
font-size: 25px; line-height: 1.3; margin: 0 0 6px; letter-spacing: -.005em;
}
.lede { color: var(--muted); margin: 0 0 26px; max-width: 66ch; font-size: 15px; }
h3 {
font-size: 15px; font-weight: 600; margin: 34px 0 10px;
letter-spacing: .01em;
}
p { margin: 0 0 14px; max-width: 68ch; }
strong { font-weight: 600; }
code {
font-family: var(--mono); font-size: .875em;
background: var(--surface-2); padding: 1px 5px; border-radius: 3px;
color: var(--ink-soft);
}
/* ---------- figures ---------- */
figure { margin: 0 0 8px; }
.fig {
background: var(--surface); border: 1px solid var(--line);
border-radius: 6px; box-shadow: var(--shadow);
padding: 26px 22px 18px; margin: 8px 0 0;
}
.fig-scroll { overflow-x: auto; }
.fig svg { display: block; min-width: 720px; max-width: 100%; height: auto; color: var(--ink); }
figcaption {
font-size: 13px; color: var(--muted); margin-top: 16px;
padding-top: 14px; border-top: 1px solid var(--line-soft); max-width: 78ch;
}
/* ---------- tables ---------- */
.tbl-wrap { overflow-x: auto; margin: 20px 0 8px; }
table { border-collapse: collapse; width: 100%; font-size: 14px; min-width: 520px; }
th, td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
thead th {
font-family: var(--mono); font-size: 11px; font-weight: 600;
letter-spacing: .1em; text-transform: uppercase; color: var(--muted);
border-bottom: 1px solid var(--line);
}
tbody tr:last-child td { border-bottom: none; }
td.mono, th.mono { font-family: var(--mono); font-size: 12.5px; }
.num { font-variant-numeric: tabular-nums; }
/* ---------- callout ---------- */
.verdict {
background: var(--accent-bg); border-left: 3px solid var(--accent);
padding: 18px 22px; border-radius: 0 5px 5px 0; margin: 24px 0;
}
.verdict p { margin: 0; max-width: none; }
.verdict p + p { margin-top: 10px; }
/* ---------- stage list (진짜 순서가 있는 것에만) ---------- */
ol.stages { list-style: none; counter-reset: s -1; padding: 0; margin: 20px 0 8px; }
ol.stages li {
counter-increment: s; display: grid;
grid-template-columns: 34px 1fr; gap: 16px;
padding: 14px 0; border-bottom: 1px solid var(--line-soft);
}
ol.stages li:last-child { border-bottom: none; }
ol.stages li::before {
content: counter(s);
font-family: var(--mono); font-size: 12px; font-weight: 600;
color: var(--accent); border: 1px solid var(--line);
border-radius: 3px; height: 26px; display: grid; place-items: center;
background: var(--surface);
}
ol.stages b { display: block; font-weight: 600; font-size: 14.5px; }
ol.stages span { font-size: 13.5px; color: var(--muted); }
ul.plain { padding-left: 20px; margin: 12px 0; }
ul.plain li { margin-bottom: 7px; max-width: 68ch; }
pre {
background: var(--surface); border: 1px solid var(--line); border-radius: 5px;
padding: 16px 18px; overflow-x: auto; font-family: var(--mono);
font-size: 12.5px; line-height: 1.75; margin: 16px 0; color: var(--ink-soft);
}
pre b { color: var(--accent); font-weight: 500; }
.pill {
display: inline-block; font-family: var(--mono); font-size: 11px;
padding: 2px 7px; border-radius: 3px; letter-spacing: .02em;
}
.pill-go { background: var(--accent-bg); color: var(--accent); }
.pill-warn { background: var(--warn-bg); color: var(--warn); }
.pill-stop { background: var(--stop-bg); color: var(--stop); }
footer {
margin-top: 76px; padding-top: 22px; border-top: 1px solid var(--line);
font-size: 13px; color: var(--muted);
}
a { color: var(--accent); }
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
</style>
<div class="wrap">
<header class="masthead col">
<p class="eyebrow">o2o-site-ontology</p>
<h1>발행 사이트에 붙는<br>SEO/AEO 키워드 온톨로지</h1>
<p class="standfirst">
업체 사이트를 발행하면 그 업체에 맞는 검색 키워드·태그·질문답변이 따라붙어야 한다.
LLM 이 주기적으로 후보를 만들고, 4단계 중복제거가 전역 키워드 사전을 깨끗하게 유지하고,
발행된 사이트는 REST 로 완성된 payload 만 받아 쓴다.
</p>
<div class="meta">
<span>PostgreSQL 16 + pgvector</span>
<span>NestJS</span>
<span>BullMQ</span>
<span>OpenAI Structured Outputs</span>
</div>
</header>
<!-- ======================================================= 1 -->
<section>
<div class="col">
<h2>일반 DB 냐 벡터 DB 냐</h2>
<p class="lede">둘 중 하나를 고르는 문제가 아니다. 이 서비스는 성격이 다른 세 종류의 조회를 동시에 요구한다.</p>
</div>
<div class="tbl-wrap col">
<table>
<thead>
<tr><th>조회 유형</th><th>실제 질의</th><th>필요한 것</th></tr>
</thead>
<tbody>
<tr>
<td>정확 조회</td>
<td>업체 A 의 활성 키워드 20개</td>
<td class="mono">B-tree / 관계형 조인</td>
</tr>
<tr>
<td>의미 조회</td>
<td>이 후보가 기존 키워드와 의미상 겹치는가</td>
<td class="mono">vector (HNSW)</td>
</tr>
<tr>
<td>관계 탐색</td>
<td>업종 트리 상위에서 물려받을 공통 키워드</td>
<td class="mono">ltree 계층 / recursive CTE</td>
</tr>
</tbody>
</table>
</div>
<div class="verdict col">
<p><strong>결론 — PostgreSQL 하나로 시작한다.</strong>
<code>pgvector</code> + <code>ltree</code> + <code>pg_trgm</code> + <code>JSONB</code> 로 세 가지가 모두 한 엔진 안에서 해결되고,
무엇보다 <em>키워드 조회에는 항상 "어느 업체의"라는 조인이 따라붙는다.</em></p>
<p>전용 벡터 DB 를 지금 분리하면 매 요청이 2-hop 이 되고 정합성을 따로 관리해야 한다.
벡터 행이 1천만 건을 넘거나 ANN 지연이 실제로 문제가 되는 시점에 Qdrant 로 떼어내도 늦지 않다.
Neo4j 도 같은 논리 — 고정 깊이 상속이면 <code>ltree</code> 로 충분하다.</p>
</div>
</section>
<!-- ======================================================= 2 -->
<section>
<div class="col">
<h2>전체 흐름</h2>
<p class="lede">생성은 큐 뒤에서 비동기로, 서빙은 DB 읽기만으로. 두 경로가 만나는 지점은 Postgres 한 곳뿐이다.</p>
</div>
<figure>
<div class="fig fig-scroll">
<svg viewBox="0 0 1160 500" role="img"
aria-label="트리거가 BullMQ 큐에 적재되고, 생성 워커가 OpenAI 를 호출해 후보 키워드를 만들고, 4단계 중복제거를 거쳐 PostgreSQL 에 저장되며, 서빙 API 가 발행 사이트에 SEO/AEO payload 를 내려주고, 유입 성과가 다시 트리거로 돌아오는 순환 구조">
<defs>
<marker id="a1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>
</marker>
<marker id="a1acc" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--accent)"/>
</marker>
</defs>
<!-- boxes -->
<g stroke="currentColor" stroke-width="1" fill="var(--surface-2)" opacity="1">
<rect x="24" y="64" width="180" height="88" rx="4"/>
<rect x="252" y="64" width="180" height="88" rx="4"/>
<rect x="480" y="64" width="180" height="88" rx="4"/>
<rect x="708" y="248" width="180" height="88" rx="4"/>
<rect x="252" y="248" width="180" height="88" rx="4"/>
<rect x="252" y="400" width="400" height="60" rx="4"/>
</g>
<rect x="708" y="64" width="180" height="88" rx="4" fill="var(--warn-bg)" stroke="var(--warn)" stroke-width="1.5"/>
<rect x="936" y="48" width="200" height="120" rx="4" fill="var(--accent-bg)" stroke="var(--accent)" stroke-width="1.5"/>
<!-- labels -->
<g font-family="IBM Plex Sans KR, sans-serif" fill="currentColor">
<text x="40" y="88" font-size="13" font-weight="600">트리거</text>
<text x="40" y="110" font-size="11" opacity=".75">사이트 발행 — 즉시</text>
<text x="40" y="127" font-size="11" opacity=".75">크론 03:00 — 30일 경과</text>
<text x="40" y="144" font-size="11" opacity=".75">성과 저조 — 재생성</text>
<text x="268" y="88" font-size="13" font-weight="600">BullMQ 큐</text>
<text x="268" y="110" font-size="11" opacity=".75">60초 dedupe 창</text>
<text x="268" y="127" font-size="11" opacity=".75">재시도 3회 · 지수 백오프</text>
<text x="268" y="144" font-size="11" opacity=".75">동시성 2</text>
<text x="496" y="88" font-size="13" font-weight="600">생성 워커</text>
<text x="496" y="110" font-size="11" opacity=".75">OpenAI · gpt-4.1-mini</text>
<text x="496" y="127" font-size="11" opacity=".75">Structured Outputs</text>
<text x="496" y="144" font-size="11" opacity=".75">임베딩 배치 1회</text>
<text x="724" y="88" font-size="13" font-weight="600" fill="var(--warn)">중복제거 4단계</text>
<text x="724" y="110" font-size="11" fill="var(--warn)" opacity=".9">해시 → trigram → 벡터</text>
<text x="724" y="127" font-size="11" fill="var(--warn)" opacity=".9">미일치만 신규 등록</text>
<text x="724" y="144" font-size="11" fill="var(--warn)" opacity=".9">나머지는 alias 흡수</text>
<text x="952" y="76" font-size="13" font-weight="600" fill="var(--accent)">PostgreSQL 16</text>
<text x="952" y="98" font-size="11" fill="var(--accent)" opacity=".9">pgvector · ltree · pg_trgm</text>
<text x="952" y="120" font-size="11" fill="var(--accent)" opacity=".9">keyword (전역 사전)</text>
<text x="952" y="137" font-size="11" fill="var(--accent)" opacity=".9">merchant_keyword</text>
<text x="952" y="154" font-size="11" fill="var(--accent)" opacity=".9">qa_pair · generation_run</text>
<text x="724" y="272" font-size="13" font-weight="600">Serving API</text>
<text x="724" y="294" font-size="11" opacity=".75">GET /v1/sites/:id/seo</text>
<text x="724" y="311" font-size="11" opacity=".75">GET /v1/sites/:id/aeo</text>
<text x="724" y="328" font-size="11" opacity=".75">읽기 99% · 캐시 대상</text>
<text x="268" y="272" font-size="13" font-weight="600">발행된 사이트</text>
<text x="268" y="294" font-size="11" opacity=".75">o2o-site-AEO</text>
<text x="268" y="311" font-size="11" opacity=".75">렌더링 시 호출</text>
<text x="268" y="426" font-size="13" font-weight="600">성과 수집</text>
<text x="268" y="447" font-size="11" opacity=".75">Search Console · 네이버 서치어드바이저 · 유입 로그</text>
</g>
<!-- flow arrows -->
<g stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#a1)">
<line x1="204" y1="108" x2="244" y2="108"/>
<line x1="432" y1="108" x2="472" y2="108"/>
<line x1="660" y1="108" x2="700" y2="108"/>
<line x1="888" y1="108" x2="928" y2="108"/>
<path d="M1036 168 L1036 292 L896 292"/>
<line x1="708" y1="292" x2="440" y2="292"/>
<line x1="342" y1="336" x2="342" y2="392"/>
<path d="M252 430 L114 430 L114 160"/>
</g>
<!-- prompt feedback (dashed, accent) -->
<g stroke="var(--accent)" stroke-width="1.4" fill="none" stroke-dasharray="5 4" marker-end="url(#a1acc)">
<path d="M1036 48 L1036 24 L570 24 L570 56"/>
</g>
<!-- arrow labels -->
<g font-family="IBM Plex Mono, monospace" font-size="10.5" fill="currentColor" opacity=".7">
<text x="224" y="100" text-anchor="middle">적재</text>
<text x="452" y="100" text-anchor="middle">job</text>
<text x="680" y="100" text-anchor="middle">후보</text>
<text x="908" y="100" text-anchor="middle">write</text>
<text x="1046" y="230">읽기</text>
<text x="574" y="284" text-anchor="middle">SEO / AEO payload</text>
<text x="352" y="368">노출 · 클릭</text>
<text x="124" y="212">CTR &lt; 0.2% → 강등</text>
</g>
<text x="570" y="16" text-anchor="middle" font-family="IBM Plex Mono, monospace"
font-size="10.5" fill="var(--accent)">기존 키워드 주입 — 중복 후보 생성 자체를 억제</text>
</svg>
</div>
<figcaption>
점선 화살표가 이 설계의 핵심이다. 프롬프트에 해당 업종의 기존 키워드를 넣어 중복 후보가 <em>만들어지기 전에</em> 줄이고,
그래도 남는 것만 중복제거 단계가 처리한다. 생성 경로(위)와 서빙 경로(아래)는 Postgres 에서만 만나므로
OpenAI 가 느리거나 죽어도 발행된 사이트의 응답에는 영향이 없다.
</figcaption>
</figure>
</section>
<!-- ======================================================= 3 -->
<section>
<div class="col">
<h2>중복제거 4단계</h2>
<p class="lede">
값싼 판정을 먼저, 비싼 판정을 나중에. 벡터 비교는 후보 20건 안에서만 일어나므로 전수 비교가 발생하지 않는다.
</p>
</div>
<figure>
<div class="fig fig-scroll">
<svg viewBox="0 0 1000 500" role="img"
aria-label="LLM 후보 키워드가 금칙어 필터, 정규화 완전 일치, trigram 유사도, 코사인 유사도 순으로 통과하며 각 단계에서 탈락한 것은 차단되거나 기존 키워드의 alias 로 흡수되고, 전부 통과한 것만 새 키워드로 등록된다">
<defs>
<marker id="a2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>
</marker>
<marker id="a2w" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--warn)"/>
</marker>
<marker id="a2s" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--stop)"/>
</marker>
<marker id="a2acc" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--accent)"/>
</marker>
</defs>
<text x="440" y="26" text-anchor="middle" font-family="IBM Plex Sans KR, sans-serif"
font-size="12.5" font-weight="600" fill="currentColor">LLM 후보 키워드</text>
<line x1="440" y1="34" x2="440" y2="54" stroke="currentColor" stroke-width="1.4" marker-end="url(#a2)"/>
<!-- stage spine -->
<g stroke="currentColor" stroke-width="1" fill="var(--surface-2)">
<rect x="280" y="60" width="320" height="58" rx="4"/>
<rect x="280" y="150" width="320" height="58" rx="4"/>
<rect x="280" y="240" width="320" height="58" rx="4"/>
<rect x="280" y="330" width="320" height="58" rx="4"/>
</g>
<rect x="280" y="420" width="320" height="58" rx="4" fill="var(--accent-bg)" stroke="var(--accent)" stroke-width="1.5"/>
<g font-family="IBM Plex Sans KR, sans-serif" fill="currentColor">
<text x="298" y="84" font-size="13" font-weight="600">0 · 금칙어 필터</text>
<text x="298" y="104" font-size="11" opacity=".75">최고 · 1위 · 100% · 완치</text>
<text x="298" y="174" font-size="13" font-weight="600">1 · normalized 완전 일치</text>
<text x="298" y="194" font-size="11" opacity=".75">NFKC · 소문자 · 구두점/공백 제거</text>
<text x="298" y="264" font-size="13" font-weight="600">2 · pg_trgm 유사도 ≥ 0.6</text>
<text x="298" y="284" font-size="11" opacity=".75">표기 변형 · 오타</text>
<text x="298" y="354" font-size="13" font-weight="600">3 · 코사인 유사도 ≥ 0.92</text>
<text x="298" y="374" font-size="11" opacity=".75">의미 중복 — 후보 20건 안에서만</text>
<text x="298" y="444" font-size="13" font-weight="600" fill="var(--accent)">4 · 새 키워드로 INSERT</text>
<text x="298" y="464" font-size="11" fill="var(--accent)" opacity=".9">embedding 저장 · usage_count 1</text>
</g>
<!-- pass-down arrows -->
<g stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#a2)">
<line x1="440" y1="118" x2="440" y2="144"/>
<line x1="440" y1="208" x2="440" y2="234"/>
<line x1="440" y1="298" x2="440" y2="324"/>
<line x1="440" y1="388" x2="440" y2="414"/>
</g>
<g font-family="IBM Plex Mono, monospace" font-size="10" fill="currentColor" opacity=".6">
<text x="450" y="137">미일치</text>
<text x="450" y="227">미일치</text>
<text x="450" y="317">미일치</text>
<text x="450" y="407">미일치</text>
</g>
<!-- cost annotations (left) -->
<g font-family="IBM Plex Mono, monospace" font-size="10" fill="currentColor" opacity=".55" text-anchor="end">
<text x="262" y="93">비용 0</text>
<text x="262" y="183">B-tree 1회</text>
<text x="262" y="273">GIN trgm</text>
<text x="262" y="363">HNSW top-20</text>
<text x="262" y="453">INSERT</text>
</g>
<!-- exits -->
<rect x="672" y="66" width="304" height="46" rx="4" fill="var(--stop-bg)" stroke="var(--stop)" stroke-width="1.2"/>
<line x1="600" y1="89" x2="664" y2="89" stroke="var(--stop)" stroke-width="1.4" marker-end="url(#a2s)"/>
<text x="688" y="84" font-family="IBM Plex Sans KR, sans-serif" font-size="12" font-weight="600" fill="var(--stop)">차단 — 저장하지 않음</text>
<text x="688" y="102" font-family="IBM Plex Mono, monospace" font-size="10.5" fill="var(--stop)" opacity=".9">rejected_banned</text>
<g>
<rect x="672" y="156" width="304" height="46" rx="4" fill="var(--warn-bg)" stroke="var(--warn)" stroke-width="1.2"/>
<rect x="672" y="246" width="304" height="46" rx="4" fill="var(--warn-bg)" stroke="var(--warn)" stroke-width="1.2"/>
<rect x="672" y="336" width="304" height="46" rx="4" fill="var(--warn-bg)" stroke="var(--warn)" stroke-width="1.2"/>
</g>
<g stroke="var(--warn)" stroke-width="1.4" marker-end="url(#a2w)">
<line x1="600" y1="179" x2="664" y2="179"/>
<line x1="600" y1="269" x2="664" y2="269"/>
<line x1="600" y1="359" x2="664" y2="359"/>
</g>
<g font-family="IBM Plex Sans KR, sans-serif" fill="var(--warn)">
<text x="688" y="174" font-size="12" font-weight="600">기존 키워드에 alias 흡수</text>
<text x="688" y="192" font-size="10.5" font-family="IBM Plex Mono, monospace" opacity=".9">강남 뿌리 염색 → 강남 뿌리염색</text>
<text x="688" y="264" font-size="12" font-weight="600">기존 키워드에 alias 흡수</text>
<text x="688" y="282" font-size="10.5" font-family="IBM Plex Mono, monospace" opacity=".9">강남 뿌리염색약 → 강남 뿌리염색 (0.67)</text>
<text x="688" y="354" font-size="12" font-weight="600">기존 키워드에 alias 흡수</text>
<text x="688" y="372" font-size="10.5" font-family="IBM Plex Mono, monospace" opacity=".9">강남 헤어샵 → 강남 미용실 (0.94)</text>
</g>
<!-- 모든 경로가 합류하는 지점 -->
<rect x="672" y="420" width="304" height="52" rx="4"
fill="none" stroke="currentColor" stroke-width="1.2" stroke-dasharray="5 4" opacity=".8"/>
<line x1="824" y1="382" x2="824" y2="414" stroke="var(--warn)" stroke-width="1.4"
fill="none" marker-end="url(#a2w)"/>
<line x1="600" y1="446" x2="664" y2="446" stroke="var(--accent)" stroke-width="1.4"
fill="none" marker-end="url(#a2acc)"/>
<text x="688" y="443" font-family="IBM Plex Sans KR, sans-serif" font-size="12" font-weight="600"
fill="currentColor">어느 경로든 업체에는 연결된다</text>
<text x="688" y="462" font-family="IBM Plex Mono, monospace" font-size="10.5"
fill="currentColor" opacity=".7">merchant_keyword · relevance · status</text>
</svg>
</div>
<figcaption>
1~3 단계에서 걸린 표기는 버리지 않고 기존 키워드의 <code>aliases[]</code> 에 흡수한다.
롱테일 검색어를 잃지 않으면서 사전은 한 행으로 유지되고, 나중에 Search Console 이
<code>강남 뿌리염색약</code> 으로 성과를 보고해도 같은 키워드에 매칭된다.
</figcaption>
</figure>
<div class="col">
<h3>실제 로컬 실행 결과</h3>
<p>같은 지역·업종 업체를 순서대로 발행했을 때 <code>npm run smoke</code> 출력이다.</p>
</div>
<pre>1. 레브살롱 (첫 업체) 후보 19 → <b>신규 19</b> / 중복 0
2. 헤어랩 강남점 후보 19 → <b>신규 4</b> / 중복(정확 15, 표기 0, 의미 0)
3. 강남 뷰티랩 후보 16 → <b>신규 3</b> / 중복(정확 12, 표기 1, 의미 0)
matched_exact 강남 뿌리 염색 (sim=1.000 → '강남 뿌리염색')
matched_trigram 강남 뿌리염색약 (sim=0.667 → '강남 뿌리염색')
matched_exact 강남미용실추천 (sim=1.000 → '강남 미용실 추천')</pre>
<div class="col">
<p style="font-size:13.5px;color:var(--muted)">
<span class="pill pill-warn">참고</span>
위 수치는 <code>LLM_PROVIDER=mock</code> 기준이다. mock 임베딩은 문자 bigram 해싱이라 표기 유사도만 잡는다.
의미 중복(<code>강남 미용실</code><code>강남 헤어샵</code>)은 실제 <code>text-embedding-3-small</code> 로 전환해야 3단계가 발동한다.
</p>
</div>
</section>
<!-- ======================================================= 4 -->
<section>
<div class="col">
<h2>데이터 모델</h2>
<p class="lede">
키워드를 업체에 복제하지 않는 것이 이 스키마의 전부다. 복제하는 순간 중복제거 자체가 성립하지 않는다.
</p>
</div>
<figure>
<div class="fig fig-scroll">
<svg viewBox="0 0 1000 420" role="img"
aria-label="industry 와 region 계층이 keyword 를 분류하고, merchant 는 merchant_keyword 연결 테이블을 통해 전역 keyword 사전을 참조하며, qa_pair 는 merchant 에 직접 매달린다">
<defs>
<marker id="a3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>
</marker>
</defs>
<g stroke="currentColor" stroke-width="1" fill="var(--surface-2)">
<rect x="24" y="32" width="190" height="62" rx="4"/>
<rect x="24" y="116" width="190" height="62" rx="4"/>
<rect x="24" y="224" width="190" height="104" rx="4"/>
<rect x="380" y="224" width="230" height="104" rx="4"/>
<rect x="720" y="250" width="250" height="90" rx="4"/>
</g>
<rect x="720" y="32" width="250" height="158" rx="4" fill="var(--accent-bg)" stroke="var(--accent)" stroke-width="1.5"/>
<g font-family="IBM Plex Mono, monospace" fill="currentColor">
<text x="40" y="56" font-size="12.5" font-weight="600">industry</text>
<text x="40" y="76" font-size="10.5" opacity=".7">path ltree · beauty.hair</text>
<text x="40" y="140" font-size="12.5" font-weight="600">region</text>
<text x="40" y="160" font-size="10.5" opacity=".7">path ltree · kr.seoul.gangnam</text>
<text x="40" y="250" font-size="12.5" font-weight="600">merchant</text>
<text x="40" y="270" font-size="10.5" opacity=".7">external_id ← 사이트 ID</text>
<text x="40" y="288" font-size="10.5" opacity=".7">description · profile jsonb</text>
<text x="40" y="306" font-size="10.5" opacity=".7">last_generated_at</text>
<text x="396" y="250" font-size="12.5" font-weight="600">merchant_keyword</text>
<text x="396" y="270" font-size="10.5" opacity=".7">relevance · status · source</text>
<text x="396" y="288" font-size="10.5" opacity=".7">impressions · clicks · ctr</text>
<text x="396" y="306" font-size="10.5" opacity=".7">PK (merchant_id, keyword_id)</text>
<text x="736" y="56" font-size="12.5" font-weight="600" fill="var(--accent)">keyword — 전역 사전</text>
<text x="736" y="80" font-size="10.5" fill="var(--accent)" opacity=".9">canonical · 표시용</text>
<text x="736" y="98" font-size="10.5" fill="var(--accent)" opacity=".9">normalized UNIQUE · 판정용</text>
<text x="736" y="116" font-size="10.5" fill="var(--accent)" opacity=".9">aliases text[] · 흡수된 표기</text>
<text x="736" y="134" font-size="10.5" fill="var(--accent)" opacity=".9">embedding vector(1536) HNSW</text>
<text x="736" y="152" font-size="10.5" fill="var(--accent)" opacity=".9">intent · locale</text>
<text x="736" y="170" font-size="10.5" fill="var(--accent)" opacity=".9">usage_count</text>
<text x="736" y="274" font-size="12.5" font-weight="600">qa_pair</text>
<text x="736" y="294" font-size="10.5" opacity=".7">question · answer</text>
<text x="736" y="312" font-size="10.5" opacity=".7">normalized_question UNIQUE</text>
<text x="736" y="330" font-size="10.5" opacity=".7">embedding vector(1536)</text>
</g>
<g stroke="currentColor" stroke-width="1.3" fill="none" marker-end="url(#a3)">
<line x1="214" y1="63" x2="712" y2="63"/>
<line x1="214" y1="147" x2="712" y2="147"/>
<line x1="214" y1="276" x2="372" y2="276"/>
<path d="M610 262 L666 262 L666 111 L712 111"/>
<path d="M119 328 L119 380 L845 380 L845 348"/>
</g>
<g font-family="IBM Plex Mono, monospace" font-size="10.5" fill="currentColor" opacity=".65">
<text x="463" y="56" text-anchor="middle">업종 분류</text>
<text x="463" y="140" text-anchor="middle">지역 분류</text>
<text x="293" y="269" text-anchor="middle">1 : N</text>
<text x="672" y="205">N : 1</text>
<text x="482" y="373" text-anchor="middle">1 : N</text>
</g>
</svg>
</div>
<figcaption>
<code>강남 미용실</code> 을 100개 업체가 쓰더라도 <code>keyword</code> 에는 행이 하나, 임베딩도 하나뿐이다.
업체별 관련도·성과는 전부 <code>merchant_keyword</code> 가 들고 있으므로 사전을 오염시키지 않고
업체마다 다른 순위를 낼 수 있다.
</figcaption>
</figure>
</section>
<!-- ======================================================= 5 -->
<section>
<div class="col">
<h2>API</h2>
<p class="lede">
<code>:id</code> 는 o2o-site-AEO 의 <code>external_id</code> 와 내부 UUID 를 모두 받는다.
연동 쪽에서 ID 매핑 테이블을 따로 들 필요가 없다.
</p>
</div>
<div class="tbl-wrap">
<table>
<thead>
<tr><th style="width:78px">메서드</th><th style="width:300px">경로</th><th>용도</th></tr>
</thead>
<tbody>
<tr><td class="mono">GET</td><td class="mono">/health</td><td>헬스체크 · 현재 LLM provider 확인</td></tr>
<tr><td class="mono">POST</td><td class="mono">/v1/merchants/publish</td><td><strong>사이트 발행 웹훅.</strong> 업체 upsert 후 생성 작업 적재. <code>sync:true</code> 면 동기 실행</td></tr>
<tr><td class="mono">POST</td><td class="mono">/v1/merchants/:id/generate</td><td>수동 재생성. <code>?sync=true</code> 로 결과를 즉시 확인</td></tr>
<tr><td class="mono">GET</td><td class="mono">/v1/sites/:id/seo</td><td><strong>발행 사이트가 렌더링 시 호출.</strong> title · description · keywords · tags(alias 포함)</td></tr>
<tr><td class="mono">GET</td><td class="mono">/v1/sites/:id/aeo</td><td>답변엔진용 topics · FAQ · structuredDataHints</td></tr>
<tr><td class="mono">POST</td><td class="mono">/v1/keywords/search</td><td>어드민 — 자연어 질의로 키워드 사전 벡터 검색</td></tr>
<tr><td class="mono">POST</td><td class="mono">/v1/sites/:id/performance</td><td>노출·클릭 주입 → CTR 갱신 → 저성과 키워드 강등</td></tr>
</tbody>
</table>
</div>
<div class="col">
<h3>SEO 응답</h3>
</div>
<pre>$ curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8'
{
"title": "레브살롱 | 강남 미용실",
"description": "강남역 3번 출구 앞 프라이빗 헤어살롱. … 정보를 확인하세요.",
"keywords": ["레브살롱", "강남 미용실", "강남 남자 커트", "강남 두피 클리닉", …],
"tags": [
{ "keyword": "강남 미용실", "intent": "local", "relevance": 0.95,
"aliases": ["강남미용실"] }
]
}</pre>
<div class="col">
<h3>AEO 응답</h3>
<p>
SEO 가 키워드라면 AEO 는 <strong>질문-답변 쌍과 구조화 데이터</strong>다. AI 검색 크롤러가 인용하는 것은 이쪽이다.
<code>structuredDataHints</code> 는 후속 단계에서 <code>LocalBusiness</code> / <code>FAQPage</code> JSON-LD 로 그대로 매핑되도록
필드를 미리 맞춰 두었다.
</p>
</div>
<pre>{
"topics": ["강남 미용실", "강남 남자 커트", "강남 여성 펌"],
"faqs": [
{ "question": "레브살롱은(는) 어디에 있나요?",
"answer": "레브살롱은(는) 강남에 위치한 미용실입니다." }
],
"structuredDataHints": {
"type": "LocalBusiness", "name": "레브살롱",
"areaServed": "강남", "category": "미용실"
}
}</pre>
</section>
<!-- ======================================================= 6 -->
<section>
<div class="col">
<h2>기술 선택</h2>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th style="width:130px">레이어</th><th style="width:250px">선택</th><th>이유</th></tr></thead>
<tbody>
<tr><td>런타임</td><td class="mono">NestJS · TypeScript</td><td>o2o-site-AEO 와 payload 타입을 공유할 수 있다</td></tr>
<tr><td>DB</td><td class="mono">PostgreSQL 16 + pgvector<br>+ ltree + pg_trgm</td><td>정확 · 의미 · 계층 조회 3-in-1</td></tr>
<tr><td>DB 접근</td><td class="mono">postgres.js (raw SQL)</td><td>벡터 연산자 <code>&lt;=&gt;</code><code>ltree</code> 는 어차피 raw SQL. ORM 을 얹으면 우회 코드가 더 는다</td></tr>
<tr><td>큐 · 스케줄</td><td class="mono">BullMQ + Redis</td><td>60초 dedupe 창, 지수 백오프 재시도, 크론이 전부 내장</td></tr>
<tr><td>LLM</td><td class="mono">OpenAI Structured Outputs<br>text-embedding-3-small</td><td>JSON Schema 강제 — 자유 텍스트 파싱은 반드시 깨진다</td></tr>
<tr><td>관측</td><td class="mono">generation_run 테이블</td><td>프롬프트 버전 · 토큰 · 단계별 통계를 행으로 남긴다</td></tr>
</tbody>
</table>
</div>
<div class="col">
<h3>로컬 실행</h3>
</div>
<pre>npm install
cp .env.example .env <b># 기본 LLM_PROVIDER=mock — API 키 불필요</b>
npm run db:up <b># postgres(pgvector) + redis</b>
npm run db:migrate &amp;&amp; npm run db:seed
npm start <b># http://localhost:3100</b>
npm run smoke <b># 다른 터미널 — 엔드투엔드 점검</b></pre>
</section>
<!-- ======================================================= 7 -->
<section>
<div class="col">
<h2>남은 작업</h2>
<p class="lede">연동에 필요한 API 표면은 이미 고정되어 있다. 아래는 그 뒤에서 채워 넣는 것들이다.</p>
<ul class="plain">
<li><span class="pill pill-go">next</span> JSON-LD 조립 — <code>structuredDataHints</code><code>LocalBusiness</code> / <code>FAQPage</code> / <code>Service</code></li>
<li><span class="pill pill-go">next</span> <code>/llms.txt</code> 서빙 — AI 검색 크롤러 진입점</li>
<li><span class="pill pill-warn">later</span> 업종 <code>ltree</code> 상위 노드 키워드 상속 (<code>source: 'inherited'</code>)</li>
<li><span class="pill pill-warn">later</span> Redis 응답 캐시 — 서빙은 읽기 99%, TTL 1시간 + 발행 이벤트 무효화</li>
<li><span class="pill pill-warn">later</span> Search Console API 직접 연동 (지금은 <code>/performance</code> 수동 주입)</li>
<li><span class="pill pill-warn">later</span> 키워드 승인 · 차단 어드민 UI</li>
</ul>
</div>
</section>
<footer class="col">
o2o-site-ontology · 설계 문서 · 코드와 함께 <code>docs/architecture.html</code> 에 보관
</footer>
</div>

Binary file not shown.

View File

@ -0,0 +1,124 @@
-- o2o-site-ontology : initial schema
-- pgvector(유사도) + ltree(업종/지역 계층) + pg_trgm(표기 변형) 3-in-1
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS ltree;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- ---------------------------------------------------------------- 분류 계층
CREATE TABLE IF NOT EXISTS industry (
id text PRIMARY KEY,
path ltree NOT NULL UNIQUE,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS industry_path_gist ON industry USING gist (path);
CREATE TABLE IF NOT EXISTS region (
id text PRIMARY KEY,
path ltree NOT NULL UNIQUE,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS region_path_gist ON region USING gist (path);
-- ---------------------------------------------------------------- 업체
CREATE TABLE IF NOT EXISTS merchant (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
external_id text NOT NULL UNIQUE, -- o2o-site-AEO 의 사이트/업체 ID
name text NOT NULL,
industry_id text REFERENCES industry(id),
region_id text REFERENCES region(id),
description text NOT NULL DEFAULT '',
profile jsonb NOT NULL DEFAULT '{}'::jsonb,
site_url text,
last_generated_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS merchant_industry_idx ON merchant (industry_id);
CREATE INDEX IF NOT EXISTS merchant_stale_idx ON merchant (last_generated_at NULLS FIRST);
-- ---------------------------------------------------------------- 전역 키워드 사전
DO $$ BEGIN
CREATE TYPE keyword_intent AS ENUM
('informational','navigational','transactional','local','brand');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE TABLE IF NOT EXISTS keyword (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
canonical text NOT NULL, -- 화면 노출용 대표 표기
normalized text NOT NULL, -- 중복 판정용 정규화 표기
locale text NOT NULL DEFAULT 'ko-KR',
aliases text[] NOT NULL DEFAULT '{}', -- 흡수된 표기 변형 (롱테일 확보)
intent keyword_intent NOT NULL DEFAULT 'informational',
industry_id text REFERENCES industry(id),
region_id text REFERENCES region(id),
embedding vector(1536),
usage_count integer NOT NULL DEFAULT 0, -- 몇 개 업체가 쓰는가
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT keyword_normalized_locale_uq UNIQUE (normalized, locale)
);
CREATE INDEX IF NOT EXISTS keyword_embedding_hnsw
ON keyword USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS keyword_normalized_trgm
ON keyword USING gin (normalized gin_trgm_ops);
CREATE INDEX IF NOT EXISTS keyword_industry_idx ON keyword (industry_id);
-- ---------------------------------------------------------------- 업체 <-> 키워드
DO $$ BEGIN
CREATE TYPE merchant_keyword_status AS ENUM
('candidate','active','demoted','blocked');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE TABLE IF NOT EXISTS merchant_keyword (
merchant_id uuid NOT NULL REFERENCES merchant(id) ON DELETE CASCADE,
keyword_id uuid NOT NULL REFERENCES keyword(id) ON DELETE CASCADE,
relevance real NOT NULL DEFAULT 0,
source text NOT NULL DEFAULT 'llm', -- llm | manual | inherited
status merchant_keyword_status NOT NULL DEFAULT 'candidate',
rationale text,
impressions bigint NOT NULL DEFAULT 0,
clicks bigint NOT NULL DEFAULT 0,
ctr real NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (merchant_id, keyword_id)
);
CREATE INDEX IF NOT EXISTS merchant_keyword_serving_idx
ON merchant_keyword (merchant_id, status, relevance DESC);
-- ---------------------------------------------------------------- AEO 질문-답변
CREATE TABLE IF NOT EXISTS qa_pair (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
merchant_id uuid NOT NULL REFERENCES merchant(id) ON DELETE CASCADE,
question text NOT NULL,
answer text NOT NULL,
normalized_question text NOT NULL,
embedding vector(1536),
status merchant_keyword_status NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT qa_pair_merchant_question_uq UNIQUE (merchant_id, normalized_question)
);
CREATE INDEX IF NOT EXISTS qa_pair_merchant_idx ON qa_pair (merchant_id, status);
-- ---------------------------------------------------------------- 생성 감사 로그
CREATE TABLE IF NOT EXISTS generation_run (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
merchant_id uuid REFERENCES merchant(id) ON DELETE CASCADE,
provider text NOT NULL,
model text NOT NULL,
prompt_version text NOT NULL,
trigger text NOT NULL, -- published | scheduled | manual
status text NOT NULL DEFAULT 'running', -- running | succeeded | failed
input jsonb NOT NULL DEFAULT '{}'::jsonb,
output jsonb NOT NULL DEFAULT '{}'::jsonb,
stats jsonb NOT NULL DEFAULT '{}'::jsonb,
error text,
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz
);
CREATE INDEX IF NOT EXISTS generation_run_merchant_idx
ON generation_run (merchant_id, started_at DESC);

View File

@ -0,0 +1,22 @@
-- 임베딩 모델 전환: 로컬 multilingual-e5-small (384차원)
-- mock/openai 도 384 로 통일한다 (openai 는 dimensions 파라미터로 축소 요청).
DO $$
BEGIN
IF (SELECT format_type(atttypid, atttypmod) FROM pg_attribute
WHERE attrelid = 'keyword'::regclass AND attname = 'embedding') <> 'vector(384)' THEN
DROP INDEX IF EXISTS keyword_embedding_hnsw;
ALTER TABLE keyword ALTER COLUMN embedding TYPE vector(384) USING NULL::vector(384);
CREATE INDEX keyword_embedding_hnsw ON keyword USING hnsw (embedding vector_cosine_ops);
END IF;
IF (SELECT format_type(atttypid, atttypmod) FROM pg_attribute
WHERE attrelid = 'qa_pair'::regclass AND attname = 'embedding') <> 'vector(384)' THEN
ALTER TABLE qa_pair ALTER COLUMN embedding TYPE vector(384) USING NULL::vector(384);
END IF;
END $$;
-- 데이터셋 출처 추적 (수작업 큐레이션 / LLM 생성 구분)
ALTER TABLE keyword ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'llm';
ALTER TABLE keyword ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'keyword';
ALTER TABLE keyword ADD COLUMN IF NOT EXISTS category text;
CREATE INDEX IF NOT EXISTS keyword_kind_idx ON keyword (kind, category);

8
ontology/nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

6333
ontology/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
ontology/package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "o2o-site-ontology",
"version": "0.1.0",
"description": "SEO/AEO keyword ontology service for o2o-site-AEO",
"private": true,
"scripts": {
"setup": "bash scripts/setup.sh",
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main.js",
"db:up": "docker compose up -d",
"db:down": "docker compose down",
"db:migrate": "tsx src/db/migrate.ts",
"db:seed": "tsx src/db/seed.ts",
"db:reset": "docker compose down -v && docker compose up -d --wait && npm run db:migrate && npm run db:seed",
"smoke": "tsx scripts/smoke.ts",
"dataset:build": "node scripts/build-dataset.mjs",
"dataset:ingest": "tsx scripts/ingest-dataset.ts",
"dataset:purge": "tsx scripts/purge-nondataset.ts",
"dataset:import-related": "tsx scripts/import-related.ts",
"dataset:nationwide": "node scripts/build-nationwide-dataset.mjs && python3 scripts/export-xlsx.py",
"dataset:ingest-nationwide": "tsx scripts/ingest-nationwide.ts",
"db:dump": "bash scripts/db-dump.sh",
"db:export-xlsx": "python3 scripts/export-db-xlsx.py"
},
"dependencies": {
"@huggingface/transformers": "^4.2.0",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.0.12",
"@nestjs/core": "^11.0.12",
"@nestjs/platform-express": "^11.0.12",
"@nestjs/schedule": "^5.0.1",
"bullmq": "^5.44.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"openai": "^4.89.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.5",
"@nestjs/schematics": "^11.0.2",
"@types/express": "^5.0.1",
"@types/node": "^22.13.14",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
}

333
ontology/public/demo.html Normal file
View File

@ -0,0 +1,333 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>키워드 매칭 콘솔 · o2o-site-ontology</title>
<style>
:root{
--bg:#f4f6f5; --surface:#fff; --surface2:#eceff0; --ink:#101819; --ink-soft:#3d4c4e;
--muted:#63757a; --line:#d5dcdb; --line-soft:#e4e9e8;
--accent:#0d6a60; --accent-bg:#dff0ec; --warn:#8a5a06; --warn-bg:#f6ead2;
--stop:#9d3a30; --info:#1d5b7a; --info-bg:#dceaf2;
--sans:'Apple SD Gothic Neo',system-ui,-apple-system,'Malgun Gothic',sans-serif;
--mono:'Menlo','SFMono-Regular',Consolas,monospace;
}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
--bg:#0d1213; --surface:#141b1c; --surface2:#1b2425; --ink:#e7edeb; --ink-soft:#c2cecd;
--muted:#8d9d9f; --line:#263130; --line-soft:#1e2728;
--accent:#56c2b1; --accent-bg:#12312e; --warn:#d7a34a; --warn-bg:#33270f;
--stop:#e28a80; --info:#7fb6d4; --info-bg:#142c3a;
}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.6}
.wrap{max-width:1180px;margin:0 auto;padding:32px 24px 80px}
header{border-bottom:1px solid var(--line);padding-bottom:20px;margin-bottom:24px}
.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.12em;color:var(--accent);margin:0 0 8px}
h1{margin:0 0 6px;font-size:26px;letter-spacing:-.01em}
.sub{margin:0;color:var(--muted);font-size:14px}
.status{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px;font-family:var(--mono);font-size:11px;color:var(--muted)}
.status span{border:1px solid var(--line);background:var(--surface);border-radius:3px;padding:3px 9px}
form{display:flex;gap:10px;margin:0 0 20px;flex-wrap:wrap}
input[type=text]{flex:1;min-width:260px;padding:12px 14px;font-size:15px;font-family:var(--sans);
background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:5px}
input[type=text]:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
select,button{padding:12px 16px;font-size:14px;font-family:var(--sans);border-radius:5px;border:1px solid var(--line);
background:var(--surface);color:var(--ink)}
button{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;cursor:pointer}
button:disabled{opacity:.5;cursor:progress}
.examples{display:flex;gap:6px;flex-wrap:wrap;margin:-8px 0 22px}
.examples button{background:var(--surface);color:var(--muted);border:1px solid var(--line);
font-weight:400;font-size:12px;padding:5px 11px;border-radius:20px}
.examples button:hover{color:var(--accent);border-color:var(--accent)}
.grid{display:grid;grid-template-columns:320px 1fr;gap:22px;align-items:start}
@media(max-width:900px){.grid{grid-template-columns:1fr}}
.card{background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:18px}
.card h2{margin:0 0 12px;font-size:14px;letter-spacing:.02em}
.kv{display:grid;grid-template-columns:72px 1fr;gap:4px 10px;font-size:13px}
.kv dt{color:var(--muted)}
.kv dd{margin:0;color:var(--ink-soft)}
.chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:4px}
.chip{font-size:11px;font-family:var(--mono);background:var(--surface2);color:var(--ink-soft);
padding:2px 7px;border-radius:3px}
.qtext{margin-top:14px;padding-top:12px;border-top:1px solid var(--line-soft);
font-family:var(--mono);font-size:11.5px;color:var(--muted);word-break:break-all;line-height:1.7}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
.toolbar .count{font-family:var(--mono);font-size:12px;color:var(--muted);margin-left:auto}
.filter{font-size:12px;font-family:var(--mono);padding:4px 10px;border-radius:20px;
border:1px solid var(--line);background:var(--surface);color:var(--muted);cursor:pointer}
.filter[aria-pressed=true]{background:var(--accent);color:#fff;border-color:var(--accent)}
table{width:100%;border-collapse:collapse;font-size:14px}
th{text-align:left;font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;color:var(--muted);
text-transform:uppercase;padding:8px 10px;border-bottom:1px solid var(--line);font-weight:600}
td{padding:9px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
tr:hover td{background:var(--surface2)}
.rank{font-family:var(--mono);font-size:11px;color:var(--muted);width:34px;text-align:right;
font-variant-numeric:tabular-nums}
.kw{font-weight:500}
.kw small{display:block;font-family:var(--mono);font-size:10.5px;color:var(--muted);font-weight:400}
.bar{position:relative;width:120px;height:7px;background:var(--surface2);border-radius:4px;overflow:hidden}
.bar i{position:absolute;inset:0 auto 0 0;background:var(--accent);border-radius:4px}
.score{font-family:var(--mono);font-size:11.5px;color:var(--ink-soft);width:48px;
font-variant-numeric:tabular-nums;text-align:right}
.tag{font-size:10.5px;font-family:var(--mono);padding:2px 7px;border-radius:3px;white-space:nowrap}
.t-local{background:var(--accent-bg);color:var(--accent)}
.t-transactional{background:var(--warn-bg);color:var(--warn)}
.t-informational{background:var(--info-bg);color:var(--info)}
.t-brand{background:var(--surface2);color:var(--ink-soft)}
.cat{font-size:11px;color:var(--muted);font-family:var(--mono)}
.linked{font-size:10.5px;font-family:var(--mono);color:var(--accent)}
.empty{padding:40px;text-align:center;color:var(--muted);font-size:14px}
.err{background:var(--surface);border:1px solid var(--stop);color:var(--stop);
border-radius:6px;padding:14px 16px;font-size:13.5px;margin-bottom:16px}
.tblwrap{overflow-x:auto}
.modes{display:flex;gap:0;border:1px solid var(--line);border-radius:5px;overflow:hidden}
.modes button{border:0;border-radius:0;background:var(--surface);color:var(--muted);font-size:13px;padding:12px 16px;font-weight:500}
.modes button[aria-pressed=true]{background:var(--accent);color:#fff}
.lanes{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin-bottom:16px}
.lane{background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:11px 13px}
.lane b{font-size:12.5px}
.lane .w{font-family:var(--mono);font-size:10.5px;color:var(--accent);margin-left:5px}
.lane .q{font-family:var(--mono);font-size:11px;color:var(--muted);margin-top:5px;line-height:1.55;word-break:break-all}
.tabs{display:flex;gap:6px;margin-bottom:12px}
.tabs button{font-size:12.5px;padding:6px 13px;border-radius:20px;border:1px solid var(--line);
background:var(--surface);color:var(--muted);font-weight:400}
.tabs button[aria-pressed=true]{background:var(--accent);color:#fff;border-color:var(--accent)}
.prov{display:inline-flex;gap:4px;flex-wrap:wrap}
.prov span{font-family:var(--mono);font-size:10px;background:var(--surface2);color:var(--muted);
padding:1px 5px;border-radius:3px}
.hold{font-size:10.5px;font-family:var(--mono);color:var(--warn);background:var(--warn-bg);
padding:1px 6px;border-radius:3px;white-space:nowrap}
.facts{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}
.facts span{font-family:var(--mono);font-size:11px;background:var(--surface2);color:var(--ink-soft);
padding:3px 8px;border-radius:3px}
.lanegroup{margin-bottom:20px}
.lanegroup h3{margin:0 0 4px;font-size:13.5px}
.lanegroup .q{font-family:var(--mono);font-size:11px;color:var(--muted);margin:0 0 8px}
.exrow td{color:var(--muted)}
.exwhy{font-family:var(--mono);font-size:11px;color:var(--stop)}
</style>
</head>
<body>
<div class="wrap">
<header>
<p class="eyebrow">O2O-SITE-ONTOLOGY</p>
<h1>키워드 매칭 콘솔</h1>
<p class="sub">업체명이나 문장을 넣으면 적재된 “군산 펜션” 키워드 사전에서 잘 맞는 것을 골라 보여줍니다.</p>
<div class="status" id="status"><span>연결 확인 중…</span></div>
</header>
<form id="f">
<input type="text" id="q" value="스테이 머뭄" placeholder="업체명 또는 문장" autocomplete="off">
<div class="modes">
<button type="button" id="m-fusion" aria-pressed="true">융합</button>
<button type="button" id="m-single" aria-pressed="false">통짜</button>
</div>
<select id="limit">
<option value="30">상위 30</option>
<option value="50" selected>상위 50</option>
<option value="100">상위 100</option>
</select>
<button type="submit" id="go">매칭</button>
</form>
<div class="examples" id="ex"></div>
<div id="err"></div>
<div class="grid">
<aside class="card" id="side"><div class="empty">업체 정보</div></aside>
<section>
<div class="lanes" id="lanes"></div>
<div class="tabs" id="tabs"></div>
<div class="toolbar" id="filters"></div>
<div class="card" style="padding:0">
<div class="tblwrap"><table id="tbl">
<thead><tr><th class="rank">#</th><th>키워드</th><th>유사도</th><th>의도</th><th>카테고리</th></tr></thead>
<tbody><tr><td colspan="5"><div class="empty">매칭 버튼을 눌러 시작하세요</div></td></tr></tbody>
</table></div>
</div>
</section>
</div>
</div>
<script>
const API = location.origin;
const $ = (s) => document.querySelector(s);
let LAST = null, FILTER = null, MODE = 'fusion', TAB = 'fused';
const EXAMPLES = ['스테이 머뭄', '스테이머뭄', '군산 애견동반 펜션', '강아지랑 갈 수 있는 바다 근처 숙소',
'아이랑 물놀이 하기 좋은 곳', '말랭이마을 걸어서 갈 수 있는 숙소'];
$('#ex').innerHTML = EXAMPLES.map(e => `<button type="button" data-q="${e}">${e}</button>`).join('');
$('#ex').addEventListener('click', (e) => {
const b = e.target.closest('button'); if (!b) return;
$('#q').value = b.dataset.q; run();
});
for (const m of ['fusion', 'single']) {
$('#m-' + m).addEventListener('click', () => {
MODE = m;
$('#m-fusion').setAttribute('aria-pressed', String(m === 'fusion'));
$('#m-single').setAttribute('aria-pressed', String(m === 'single'));
run();
});
}
async function health() {
try {
const h = await (await fetch(API + '/health')).json();
$('#status').innerHTML =
`<span>API ${h.status}</span><span>임베딩 ${h.embeddingProvider}</span><span>생성 ${h.llmProvider}</span>`;
} catch { $('#status').innerHTML = '<span>API 연결 실패 — npm start 실행 중인지 확인</span>'; }
}
async function run() {
const query = $('#q').value.trim();
if (!query) return;
$('#go').disabled = true; $('#err').innerHTML = '';
try {
const res = await fetch(API + '/v1/match', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, limit: Number($('#limit').value), mode: MODE }),
});
if (!res.ok) throw new Error(await res.text());
LAST = await res.json(); FILTER = null; TAB = 'fused';
renderSide(); renderLanes(); renderTabs(); renderBody();
} catch (e) {
$('#err').innerHTML = `<div class="err">요청 실패 — ${esc(String(e.message || e)).slice(0, 300)}</div>`;
} finally { $('#go').disabled = false; }
}
function renderSide() {
const m = LAST.resolved;
if (!m) {
$('#side').innerHTML = `<h2>업체 미해석</h2>
<p style="font-size:13px;color:var(--muted);margin:0">일치하는 업체가 없어 입력 문장을 그대로 질의로 씁니다.</p>`;
return;
}
const p = m.profile || {};
const arr = (k) => Array.isArray(p[k]) ? p[k] : [];
const chips = (k, label) => arr(k).length
? `<dt>${label}</dt><dd><div class="chips">${arr(k).map(v => `<span class="chip">${esc(v)}</span>`).join('')}</div></dd>` : '';
const f = LAST.facts;
$('#side').innerHTML = `
<h2>해석된 업체</h2>
<dl class="kv">
<dt>상호</dt><dd><b>${esc(m.name)}</b></dd>
<dt>지역</dt><dd>${esc(m.region || '-')}</dd>
<dt>업종</dt><dd>${esc(m.industry || '-')}</dd>
<dt>소개</dt><dd>${esc(m.description || '-')}</dd>
${p.address ? `<dt>주소</dt><dd>${esc(p.address)}</dd>` : ''}
${chips('features', '특징')}${chips('audiences', '동반자')}${chips('nearby', '인근')}
</dl>
${f ? `<div class="qtext"><b>필터에 쓰는 사실</b>
<div class="facts">
<span>권역 ${esc(f.areaGroup || '미상')}</span>
<span>최대 ${f.capacityMax ?? '?'}인</span>
${f.amenities.map(a => `<span>${esc(a)}</span>`).join('')}
${f.unverified.map(u => `<span style="color:var(--warn)">${esc(u)}?</span>`).join('')}
</div></div>` : ''}
${LAST.mode === 'single' ? `<div class="qtext"><b>임베딩에 사용한 질의문 (통짜)</b><br>${esc(LAST.queryText)}</div>` : ''}`;
}
function renderLanes() {
if (LAST.mode !== 'fusion') { $('#lanes').innerHTML = ''; return; }
$('#lanes').innerHTML = LAST.lanes.map(l => `
<div class="lane">
<b>${esc(l.label)}</b><span class="w">w=${l.weight}</span>
<div class="q">${esc(l.text)}</div>
</div>`).join('');
}
function renderTabs() {
if (LAST.mode !== 'fusion') { $('#tabs').innerHTML = ''; return; }
const tabs = [['fused', `융합 순위 ${LAST.matches.length}`],
['lanes', '레인별 (페이지 배분)'],
['excluded', `배제됨 ${LAST.excludedTotal}`]];
$('#tabs').innerHTML = tabs.map(([k, label]) =>
`<button data-t="${k}" aria-pressed="${k === TAB}">${label}</button>`).join('');
$('#tabs').onclick = (e) => {
const b = e.target.closest('button'); if (!b) return;
TAB = b.dataset.t;
[...$('#tabs').querySelectorAll('button')].forEach(x => x.setAttribute('aria-pressed', String(x.dataset.t === TAB)));
renderBody();
};
}
function renderBody() {
if (LAST.mode === 'fusion' && TAB === 'lanes') return renderLaneGroups();
if (LAST.mode === 'fusion' && TAB === 'excluded') return renderExcluded();
renderFilters(); renderTable(LAST.matches);
}
function renderFilters() {
const cats = [...new Set(LAST.matches.map(m => m.category).filter(Boolean))];
$('#filters').innerHTML =
`<button class="filter" data-c="" aria-pressed="${!FILTER}">전체</button>` +
cats.map(c => `<button class="filter" data-c="${esc(c)}" aria-pressed="${FILTER === c}">${esc(c)}</button>`).join('') +
`<span class="count">사전 ${LAST.total.toLocaleString()}건 · ${LAST.mode === 'fusion' ? '융합' : '통짜'}</span>`;
$('#filters').onclick = (e) => {
const b = e.target.closest('.filter'); if (!b) return;
FILTER = b.dataset.c || null;
[...$('#filters').querySelectorAll('.filter')]
.forEach(x => x.setAttribute('aria-pressed', String((x.dataset.c || null) === FILTER)));
renderTable(LAST.matches);
};
}
function row(r, i, metric) {
const val = metric === 'rrf' ? r.rrf : r.score;
return `<tr>
<td class="rank">${i + 1}</td>
<td class="kw">${esc(r.canonical)}
${r.kind === 'tag' ? '<span class="chip">태그</span>' : ''}
${r.linked ? '<span class="linked">· 연결됨</span>' : ''}
${r.status === 'hold' ? `<span class="hold">보류 · ${esc(r.holdReason || '')}</span>` : ''}
${r.lanes ? `<small class="prov">${r.lanes.slice(0, 4).map(l => `<span>${esc(l.label)}${l.rank}</span>`).join('')}</small>` : ''}
</td>
<td><span class="score">${metric === 'rrf' ? val.toFixed(5) : val.toFixed(4)}</span></td>
<td><span class="tag t-${esc(r.intent)}">${esc(r.intent)}</span></td>
<td class="cat">${esc(r.category || '-')}</td>
</tr>`;
}
function renderTable(rows) {
const list = rows.filter(m => !FILTER || m.category === FILTER);
const metric = LAST.mode === 'fusion' ? 'rrf' : 'cos';
$('#tbl thead').innerHTML =
`<tr><th class="rank">#</th><th>키워드</th><th>${metric === 'rrf' ? 'RRF' : '유사도'}</th><th>의도</th><th>카테고리</th></tr>`;
$('#tbl tbody').innerHTML = list.length
? list.map((r, i) => row(r, i, metric)).join('')
: '<tr><td colspan="5"><div class="empty">결과 없음</div></td></tr>';
}
function renderLaneGroups() {
$('#filters').innerHTML = '<span class="count">레인 1위가 그 페이지의 주력 키워드가 된다</span>';
$('#tbl').closest('.card').innerHTML = '<div style="padding:18px">' + LAST.byLane.map(l => `
<div class="lanegroup">
<h3>${esc(l.label)} <span class="chip">w=${l.weight}</span></h3>
<p class="q">${esc(l.text)}</p>
<table><tbody>${l.items.map((r, i) => row(r, i, 'cos')).join('')}</tbody></table>
</div>`).join('') + '</div>';
}
function renderExcluded() {
$('#filters').innerHTML = `<span class="count">사실 기반 필터로 걸러낸 ${LAST.excludedTotal}건 — 벡터만으로는 못 거른다</span>`;
$('#tbl thead').innerHTML = '<tr><th class="rank">#</th><th>키워드</th><th colspan="3">배제 사유</th></tr>';
$('#tbl tbody').innerHTML = LAST.excluded.length
? LAST.excluded.map((e, i) => `<tr class="exrow"><td class="rank">${i + 1}</td>
<td class="kw">${esc(e.canonical)}</td>
<td colspan="3" class="exwhy">${esc(e.reason)}</td></tr>`).join('')
: '<tr><td colspan="5"><div class="empty">배제된 항목 없음</div></td></tr>';
}
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
$('#f').addEventListener('submit', (e) => { e.preventDefault(); run(); });
health(); run();
</script>
</body>
</html>

View File

@ -0,0 +1,204 @@
/**
* "군산 펜션" SEO/AEO 키워드·태그 데이터셋 1,000 생성.
* node scripts/build-dataset.mjs data/gunsan-pension-keywords.json
*
* 어휘는 실제 군산 지명·관광지·숙소 시설 용어로 구성했고,
* 패턴은 한국 로컬 숙박 검색에서 실제로 쓰이는 조합만 전개한다.
* 가치가 높은 순으로 방출하므로 1,000건에서 잘라도 상위 의도가 남는다.
*/
import { writeFileSync, mkdirSync } from 'node:fs';
// ──────────────────────────────────────────────── 어휘 (실제 군산 기반)
const REGION = '군산';
// 고군산군도·해안 권역
const ISLANDS = ['선유도', '무녀도', '장자도', '대장도', '신시도', '야미도', '고군산군도'];
// 시내·주요 권역
const AREAS = ['새만금', '비응항', '오식도', '은파호수공원', '월명동', '나운동', '수송동', '미룡동', '옥도면',
'군산 원도심', '신흥동', '영화동'];
// 관광지 (근처 숙소 검색의 앵커)
const SPOTS = [
'선유도해수욕장', '새만금방조제', '경암동 철길마을', '근대역사박물관', '초원사진관',
'이성당', '동국사', '진포해양테마공원', '신흥동 일본식가옥', '은파호수공원',
'월명공원', '금강하구둑', '철새조망대', '채만식문학관', '째보선창', '시간여행마을',
'말랭이마을', '군산 근대문화역사거리', '해망굴', '군산항 뜬다리부두',
];
// 숙소 유형
const STAY_CORE = ['펜션', '숙소', '풀빌라', '독채펜션', '스파펜션', '애견펜션', '감성펜션'];
const STAY_ALT = ['글램핑', '카라반', '캠핑장', '게스트하우스', '민박', '리조트', '한옥펜션', '촌집', '별장',
'독채스테이', '감성숙소', '스테이', '일본식가옥 숙소'];
// 검색 의도어
const INTENT_CORE = ['추천', '예약', '가격', '후기', '순위'];
const INTENT_MORE = ['저렴한곳', '가성비', '최저가', '실시간예약', '당일예약', '특가', '할인',
'위치', '주차', '전화번호', '체크인시간', '조식포함', '청소상태'];
// 동반자
const WITH_CORE = ['커플', '가족', '친구', '애견동반', '단체'];
const WITH_MORE = ['신혼', '아이동반', '유아동반', '부모님', '효도여행', '대학생', 'MT', '워크샵',
'회사', '태교여행', '혼자', '여자끼리', '2인', '3인', '4인', '6인', '10인', '20인'];
// 분위기·취향 — 감성 독채 스테이 계열에서 실제로 많이 쓰이는 수식어
const VIBE = ['감성', '조용한', '분위기 좋은', '예쁜', '사진찍기 좋은', '인생샷', '뷰맛집',
'깔끔한', '신축', '프라이빗한', '혼자 있기 좋은'];
// 여행 형태 — 숙소 검색은 '며칠/어떻게 다니는가'로도 갈린다
const TRAVEL = ['1박2일', '2박3일', '당일치기', '주말여행', '뚜벅이 여행', '혼행',
'워케이션', '무박', '한달살기'];
// 시설·특징
const FEAT_CORE = ['오션뷰', '바다뷰', '독채', '프라이빗', '스파', '자쿠지', '바베큐', '수영장'];
const FEAT_MORE = ['노을뷰', '일출뷰', '월풀', '온수풀', '야외수영장', '인피니티풀', '불멍', '화로대',
'넷플릭스', '빔프로젝터', '노래방', '파티룸', '복층', '테라스', '마당', '벽난로',
'애견운동장', '키즈룸', '트램폴린', '무료주차', '조식', '세미나실'];
// 시즌·행사
const SEASON = ['여름휴가', '물놀이', '해수욕', '겨울', '연말', '크리스마스', '신정', '설날', '추석',
'봄', '벚꽃', '가을', '단풍', '일출', '낙조', '불꽃놀이', '성수기', '비수기', '주말', '평일'];
const OCCASION = ['생일', '기념일', '결혼기념일', '프러포즈', '100일', '가족여행', '워크샵', '단합대회'];
// ──────────────────────────────────────────────── 방출
const rows = [];
const seen = new Set();
const INTENT_MAP = {
예약: 'transactional', 실시간예약: 'transactional', 당일예약: 'transactional',
가격: 'transactional', 최저가: 'transactional', 특가: 'transactional', 할인: 'transactional',
후기: 'informational', 순위: 'informational', 청소상태: 'informational', 체크인시간: 'informational',
};
const intentOf = (m) => INTENT_MAP[m] ?? 'local';
function add(keyword, { intent = 'local', kind = 'keyword', category, relevance }) {
const k = keyword.replace(/\s+/g, ' ').trim();
if (!k || seen.has(k)) return false;
seen.add(k);
rows.push({ keyword: k, intent, kind, category, relevance: Math.round(relevance * 100) / 100 });
return true;
}
// T1 — 코어: 지역 × 숙소유형 × 의도
for (const s of STAY_CORE) add(`${REGION} ${s}`, { category: '코어', relevance: 0.97 });
for (const s of STAY_CORE) for (const m of INTENT_CORE)
add(`${REGION} ${s} ${m}`, { intent: intentOf(m), category: '코어', relevance: 0.93 });
for (const s of STAY_ALT) add(`${REGION} ${s}`, { category: '코어', relevance: 0.86 });
for (const m of INTENT_MORE) add(`${REGION} 펜션 ${m}`, { intent: intentOf(m), category: '의도', relevance: 0.88 });
// 단독 명사형 — '군산 독채펜션' 만 있으면 '군산 독채' 검색을 놓친다
for (const n of ['독채', '스테이', '풀빌라', '민박', '한옥', '글램핑', '숙박'])
add(`${REGION} ${n}`, { category: '코어', relevance: 0.87 });
// T2 — 섬·권역 × 숙소유형
for (const g of [ISLANDS, AREAS]) for (const p of g) for (const s of STAY_CORE.slice(0, 4))
add(`${p} ${s}`, { category: '권역', relevance: g === ISLANDS ? 0.9 : 0.85 });
for (const p of ISLANDS) for (const m of INTENT_CORE)
add(`${p} 펜션 ${m}`, { intent: intentOf(m), category: '권역', relevance: 0.82 });
// T3 — 동반자
for (const w of WITH_CORE) {
add(`${REGION} ${w} 펜션`, { category: '동반자', relevance: 0.91 });
for (const m of INTENT_CORE) add(`${REGION} ${w} 펜션 ${m}`, { intent: intentOf(m), category: '동반자', relevance: 0.8 });
for (const s of STAY_CORE.slice(2, 6)) add(`${REGION} ${w} ${s}`, { category: '동반자', relevance: 0.78 });
}
for (const w of WITH_MORE) {
add(`${REGION} ${w} 펜션`, { category: '동반자', relevance: 0.82 });
add(`${REGION} ${w} 펜션 추천`, { category: '동반자', relevance: 0.75 });
add(`${REGION} ${w} 숙소`, { category: '동반자', relevance: 0.73 });
}
// T4 — 시설·특징
for (const f of FEAT_CORE) {
add(`${REGION} ${f} 펜션`, { category: '시설', relevance: 0.89 });
add(`${REGION} 펜션 ${f}`, { category: '시설', relevance: 0.76 });
for (const m of INTENT_CORE.slice(0, 3)) add(`${REGION} ${f} 펜션 ${m}`, { intent: intentOf(m), category: '시설', relevance: 0.72 });
}
for (const f of FEAT_MORE) {
add(`${REGION} ${f} 펜션`, { category: '시설', relevance: 0.79 });
add(`${REGION} 펜션 ${f}`, { category: '시설', relevance: 0.7 });
}
for (const p of ISLANDS.slice(0, 4)) for (const f of FEAT_CORE)
add(`${p} ${f} 펜션`, { category: '시설', relevance: 0.74 });
// T5 — 시즌·행사
for (const s of SEASON) {
add(`${REGION} ${s} 펜션`, { category: '시즌', relevance: 0.8 });
add(`${REGION} ${s} 펜션 예약`, { intent: 'transactional', category: '시즌', relevance: 0.71 });
add(`${s} ${REGION} 숙소`, { category: '시즌', relevance: 0.68 });
}
for (const o of OCCASION) {
add(`${REGION} ${o} 펜션`, { category: '시즌', relevance: 0.75 });
add(`${REGION} ${o} 펜션 추천`, { category: '시즌', relevance: 0.69 });
}
// T5.5 — 분위기·여행형태
for (const v of VIBE) {
add(`${REGION} ${v} 펜션`, { category: '분위기', relevance: 0.81 });
add(`${REGION} ${v} 숙소`, { category: '분위기', relevance: 0.78 });
add(`${REGION} ${v} 독채`, { category: '분위기', relevance: 0.7 });
}
for (const t of TRAVEL) {
add(`${REGION} ${t} 숙소`, { category: '여행형태', relevance: 0.76 });
add(`${REGION} ${t} 펜션 추천`, { category: '여행형태', relevance: 0.7 });
}
for (const v of VIBE.slice(0, 6)) for (const w of WITH_CORE.slice(0, 3))
add(`${REGION} ${w} ${v} 숙소`, { category: '분위기', relevance: 0.58 });
// T6 — 관광지 앵커
for (const sp of SPOTS) {
add(`${sp} 근처 펜션`, { category: '관광지', relevance: 0.83 });
add(`${sp} 근처 숙소`, { category: '관광지', relevance: 0.8 });
add(`${sp} 펜션 추천`, { category: '관광지', relevance: 0.72 });
add(`${sp} 가까운 숙소`, { category: '관광지', relevance: 0.66 });
}
// T7 — 태그 (칩 UI 용 짧은 패싯)
const TAGS = [...FEAT_CORE, ...FEAT_MORE, ...WITH_CORE, ...STAY_CORE, ...STAY_ALT,
'오션뷰객실', '반려동물동반', '금연객실', '엘리베이터', '와이파이', '취사가능',
'단체가능', '조용한', '신축', '리모델링', '뷰맛집', '인생샷',
...VIBE, ...TRAVEL, '2인전용', '소인원', '뚜벅이', '원도심'];
for (const t of TAGS) add(t, { kind: 'tag', category: '태그', relevance: 0.6 });
// T8 — 질문형 (AEO)
const Q = [];
for (const w of [...WITH_CORE, '아이', '부모님']) Q.push([`${REGION} ${w} 펜션 어디가 좋아요`, 'informational', 0.7]);
for (const f of FEAT_CORE) Q.push([`${REGION}${f} 펜션 있나요`, 'informational', 0.67]);
for (const p of ISLANDS.slice(0, 5)) {
Q.push([`${p} 펜션 어떻게 가나요`, 'informational', 0.64]);
Q.push([`${p} 숙소 예약 언제 해야 하나요`, 'informational', 0.6]);
}
Q.push([`${REGION} 펜션 1박 얼마인가요`, 'transactional', 0.72]);
Q.push([`${REGION} 펜션 바베큐 가능한가요`, 'informational', 0.7]);
Q.push([`${REGION} 펜션 체크인 몇시인가요`, 'informational', 0.66]);
Q.push([`${REGION} 펜션 주차 되나요`, 'informational', 0.66]);
Q.push([`${REGION} 애견동반 펜션 추가요금 있나요`, 'informational', 0.63]);
Q.push([`${REGION} 펜션 성수기 언제인가요`, 'informational', 0.61]);
Q.push([`선유도 들어가는 배 시간표`, 'informational', 0.55]);
Q.push([`${REGION} 여행 몇박이 좋을까요`, 'informational', 0.54]);
for (const [k, i, r] of Q) add(k, { intent: i, category: '질문형', relevance: r });
// T9 — 롱테일: 동반자 × 시설 / 권역 × 동반자 / 시즌 × 동반자
const LONGTAIL = [];
for (const w of WITH_CORE) for (const f of FEAT_CORE) LONGTAIL.push([`${REGION} ${w} ${f} 펜션`, 0.5]);
for (const p of ISLANDS) for (const w of WITH_CORE) LONGTAIL.push([`${p} ${w} 펜션`, 0.48]);
for (const s of SEASON) for (const w of WITH_CORE) LONGTAIL.push([`${REGION} ${s} ${w} 펜션`, 0.44]);
for (const f of FEAT_CORE) for (const f2 of FEAT_MORE) LONGTAIL.push([`${REGION} ${f} ${f2} 펜션`, 0.4]);
for (const a of AREAS) for (const f of FEAT_CORE) LONGTAIL.push([`${a} ${f} 펜션`, 0.42]);
for (const [k, r] of LONGTAIL) {
if (rows.length >= 1000) break;
add(k, { category: '롱테일', relevance: r });
}
const dataset = rows.slice(0, 1000);
mkdirSync('data', { recursive: true });
writeFileSync('data/gunsan-pension-keywords.json', JSON.stringify({
topic: '군산 펜션',
locale: 'ko-KR',
generatedBy: 'hand-authored vocabulary × search-pattern expansion',
count: dataset.length,
items: dataset,
}, null, 2) + '\n');
const by = (f) => dataset.reduce((a, r) => (a[r[f]] = (a[r[f]] ?? 0) + 1, a), {});
console.log(`✅ data/gunsan-pension-keywords.json ${dataset.length}`);
console.log(' 카테고리:', by('category'));
console.log(' 의도 :', by('intent'));
console.log(' 종류 :', by('kind'));

View File

@ -0,0 +1,524 @@
# -*- coding: utf-8 -*-
"""docs/architecture.html 의 내용을 PPTX 로 다시 만든다.
python3 scripts/build-deck.py
도식은 이미지가 아니라 네이티브 도형으로 그리므로 PowerPoint 에서 그대로 편집된다."""
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR
from pptx.oxml.ns import qn
# ---------------------------------------------------------------- 팔레트 (HTML 문서와 동일)
INK = RGBColor(0x10, 0x18, 0x19)
INK_SOFT = RGBColor(0x3D, 0x4C, 0x4E)
MUTED = RGBColor(0x63, 0x75, 0x7A)
LINE = RGBColor(0xD5, 0xDC, 0xDB)
BG = RGBColor(0xF4, 0xF6, 0xF5)
SURFACE = RGBColor(0xFF, 0xFF, 0xFF)
SURF2 = RGBColor(0xEC, 0xEF, 0xF0)
ACCENT = RGBColor(0x0D, 0x6A, 0x60)
ACC_BG = RGBColor(0xDF, 0xF0, 0xEC)
WARN = RGBColor(0x8A, 0x5A, 0x06)
WARN_BG = RGBColor(0xF6, 0xEA, 0xD2)
STOP = RGBColor(0x9D, 0x3A, 0x30)
STOP_BG = RGBColor(0xF6, 0xE0, 0xDC)
SANS = 'Apple SD Gothic Neo' # macOS 기본 한글 산세리프
MONO = 'Menlo'
W, H = 13.333, 7.5
MX = 0.75 # 좌우 여백
# ---------------------------------------------------------------- 저수준 헬퍼
def _ea(run, name):
"""한글이 라틴 폰트로 떨어지지 않도록 동아시아 typeface 를 함께 지정."""
rPr = run._r.get_or_add_rPr()
for tag in ('a:ea', 'a:cs'):
el = rPr.find(qn(tag))
if el is None:
el = rPr.makeelement(qn(tag), {})
rPr.append(el)
el.set('typeface', name)
def write(tf, lines, space=2):
"""lines: [(text, size, bold, color, font)] — 첫 줄은 기존 문단 재사용."""
tf.word_wrap = True
for i, spec in enumerate(lines):
text, size, bold, color = spec[0], spec[1], spec[2], spec[3]
font = spec[4] if len(spec) > 4 else SANS
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.space_after = Pt(space)
p.line_spacing = 1.15
r = p.add_run()
r.text = text
r.font.size = Pt(size)
r.font.bold = bold
r.font.color.rgb = color
r.font.name = font
_ea(r, font if font != MONO else SANS)
return tf
def textbox(sl, x, y, w, h, lines, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, space=2):
tb = sl.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
tf = tb.text_frame
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
tf.vertical_anchor = anchor
write(tf, lines, space)
for p in tf.paragraphs:
p.alignment = align
return tb
def box(sl, x, y, w, h, lines=None, fill=SURF2, line=LINE, lw=1.0,
pad=0.12, anchor=MSO_ANCHOR.TOP, align=PP_ALIGN.LEFT, space=2, rounded=True):
shape = sl.shapes.add_shape(
MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE,
Inches(x), Inches(y), Inches(w), Inches(h))
if fill is None:
shape.fill.background()
else:
shape.fill.solid()
shape.fill.fore_color.rgb = fill
if line is None:
shape.line.fill.background()
else:
shape.line.color.rgb = line
shape.line.width = Pt(lw)
if rounded:
shape.adjustments[0] = 0.09
shape.shadow.inherit = False
tf = shape.text_frame
tf.margin_left = tf.margin_right = Inches(pad)
tf.margin_top = tf.margin_bottom = Inches(pad * 0.7)
tf.vertical_anchor = anchor
if lines:
write(tf, lines, space)
for p in tf.paragraphs:
p.alignment = align
return shape
def arrow(sl, x1, y1, x2, y2, color=INK, width=1.25, dash=False):
c = sl.shapes.add_connector(MSO_CONNECTOR.STRAIGHT,
Inches(x1), Inches(y1), Inches(x2), Inches(y2))
c.line.color.rgb = color
c.line.width = Pt(width)
ln = c.line._get_or_add_ln()
if dash:
ln.append(ln.makeelement(qn('a:prstDash'), {'val': 'dash'}))
ln.append(ln.makeelement(qn('a:tailEnd'), {'type': 'triangle', 'w': 'med', 'len': 'med'}))
return c
def elbow(sl, pts, color=INK, width=1.25, dash=False):
"""직교 경로: 마지막 구간에만 화살촉."""
for i in range(len(pts) - 1):
(x1, y1), (x2, y2) = pts[i], pts[i + 1]
if i == len(pts) - 2:
arrow(sl, x1, y1, x2, y2, color, width, dash)
else:
c = sl.shapes.add_connector(MSO_CONNECTOR.STRAIGHT,
Inches(x1), Inches(y1), Inches(x2), Inches(y2))
c.line.color.rgb = color
c.line.width = Pt(width)
if dash:
c.line._get_or_add_ln().append(
c.line._get_or_add_ln().makeelement(qn('a:prstDash'), {'val': 'dash'}))
def label(sl, x, y, text, size=9, color=MUTED, font=MONO, align=PP_ALIGN.LEFT, w=2.4):
return textbox(sl, x, y, w, 0.22, [(text, size, False, color, font)], align=align)
# ---------------------------------------------------------------- 슬라이드 골격
prs = Presentation()
prs.slide_width = Inches(W)
prs.slide_height = Inches(H)
BLANK = prs.slide_layouts[6]
def slide(title=None, lede=None, eyebrow=None):
sl = prs.slides.add_slide(BLANK)
bg = sl.background.fill
bg.solid()
bg.fore_color.rgb = BG
y = 0.42
if eyebrow:
textbox(sl, MX, y, 8, 0.22, [(eyebrow.upper(), 9, True, ACCENT, MONO)])
y += 0.30
if title:
textbox(sl, MX, y, W - 2 * MX, 0.5, [(title, 26, True, INK)])
y += 0.62
if lede:
textbox(sl, MX, y, W - 2 * MX - 1.2, 0.4, [(lede, 12.5, False, MUTED)])
y += 0.46
return sl, y + 0.22
def table(sl, x, y, w, cols, rows, widths=None, fs=10.5, hfs=8.5, rowh=0.34):
shape = sl.shapes.add_table(len(rows) + 1, len(cols), Inches(x), Inches(y),
Inches(w), Inches(rowh * (len(rows) + 1)))
t = shape.table
t.first_row = False
if widths:
for i, ww in enumerate(widths):
t.columns[i].width = Inches(ww)
for i, c in enumerate(cols):
cell = t.cell(0, i)
cell.fill.solid(); cell.fill.fore_color.rgb = BG
cell.margin_left = cell.margin_right = Inches(0.09)
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
write(cell.text_frame, [(c.upper(), hfs, True, MUTED, MONO)])
for r, row in enumerate(rows, start=1):
t.rows[r].height = Inches(rowh)
for i, val in enumerate(row):
cell = t.cell(r, i)
cell.fill.solid(); cell.fill.fore_color.rgb = SURFACE
cell.margin_left = cell.margin_right = Inches(0.09)
cell.margin_top = cell.margin_bottom = Inches(0.04)
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
mono = val.startswith('`')
write(cell.text_frame,
[(val.lstrip('`'), fs, False, INK_SOFT, MONO if mono else SANS)])
return t
def footer(sl, n):
textbox(sl, MX, H - 0.52, 6, 0.24,
[('o2o-site-ontology', 8.5, False, MUTED, MONO)])
textbox(sl, W - MX - 1.2, H - 0.52, 1.2, 0.24,
[(f'{n:02d}', 8.5, False, MUTED, MONO)], align=PP_ALIGN.RIGHT)
# ================================================================ 1 표지
sl = prs.slides.add_slide(BLANK)
sl.background.fill.solid(); sl.background.fill.fore_color.rgb = BG
box(sl, 0, 0, 0.16, H, fill=ACCENT, line=None, rounded=False)
textbox(sl, 1.3, 1.95, 10, 0.3, [('O2O-SITE-ONTOLOGY', 10.5, True, ACCENT, MONO)])
textbox(sl, 1.3, 2.35, 11, 1.5,
[('발행 사이트에 붙는', 40, True, INK), ('SEO/AEO 키워드 온톨로지', 40, True, INK)], space=4)
textbox(sl, 1.3, 4.15, 8.6, 1.0,
[('업체 사이트를 발행하면 그 업체에 맞는 검색 키워드·태그·질문답변이 따라붙어야 한다.', 13, False, INK_SOFT),
('LLM 이 후보를 만들고, 4단계 중복제거가 전역 사전을 깨끗하게 유지하고,', 13, False, INK_SOFT),
('발행된 사이트는 REST 로 완성된 payload 만 받아 쓴다.', 13, False, INK_SOFT)], space=3)
for i, chip in enumerate(['PostgreSQL 16 + pgvector', 'NestJS', 'BullMQ', 'OpenAI Structured Outputs']):
wch = 0.16 + len(chip) * 0.082
box(sl, 1.3 + sum(0.16 + len(c) * 0.082 + 0.14 for c in
['PostgreSQL 16 + pgvector', 'NestJS', 'BullMQ', 'OpenAI Structured Outputs'][:i]),
5.5, wch, 0.32, [(chip, 9, False, MUTED, MONO)],
fill=SURFACE, line=LINE, pad=0.08, anchor=MSO_ANCHOR.MIDDLE, align=PP_ALIGN.CENTER)
textbox(sl, 1.3, 6.5, 8, 0.24, [('설계 문서 · 로컬 구현 검증 완료', 9.5, False, MUTED, MONO)])
# ================================================================ 2 DB 선택
sl, y = slide('일반 DB 냐 벡터 DB 냐', '둘 중 하나를 고르는 문제가 아니다. 이 서비스는 성격이 다른 세 종류의 조회를 동시에 요구한다.', '설계 판단 1')
table(sl, MX, y, W - 2 * MX,
['조회 유형', '실제 질의', '필요한 것'],
[['정확 조회', '업체 A 의 활성 키워드 20개', '`B-tree / 관계형 조인'],
['의미 조회', '이 후보가 기존 키워드와 의미상 겹치는가', '`vector (HNSW)'],
['관계 탐색', '업종 트리 상위에서 물려받을 공통 키워드', '`ltree 계층 / recursive CTE']],
widths=[2.3, 5.9, 3.633], rowh=0.42)
yy = y + 2.0
box(sl, MX, yy, 0.06, 1.55, fill=ACCENT, line=None, rounded=False)
box(sl, MX + 0.06, yy, W - 2 * MX - 0.06, 1.55,
[('결론 — PostgreSQL 하나로 시작한다.', 14, True, ACCENT),
('pgvector + ltree + pg_trgm + JSONB 로 세 가지가 한 엔진 안에서 해결되고, 무엇보다', 11.5, False, INK_SOFT),
('키워드 조회에는 항상 "어느 업체의" 라는 조인이 따라붙는다.', 11.5, True, INK_SOFT),
('', 6, False, INK_SOFT),
('전용 벡터 DB 를 지금 분리하면 매 요청이 2-hop 이 되고 정합성을 따로 관리해야 한다. 벡터 행이 1천만 건을', 11.5, False, INK_SOFT),
('넘거나 ANN 지연이 실제로 문제가 되는 시점에 Qdrant 로 떼어내도 늦지 않다.', 11.5, False, INK_SOFT)],
fill=ACC_BG, line=None, pad=0.24, space=3, rounded=False)
footer(sl, 2)
# ================================================================ 3 전체 흐름
sl, y = slide('전체 흐름', '생성은 큐 뒤에서 비동기로, 서빙은 DB 읽기만으로. 두 경로가 만나는 지점은 Postgres 한 곳뿐이다.', '아키텍처')
BW, BH = 2.15, 1.05
xs = [MX, MX + 2.5, MX + 5.0, MX + 7.5, MX + 10.0]
r1 = y + 0.42
box(sl, xs[0], r1, BW, BH,
[('트리거', 11.5, True, INK), ('사이트 발행 — 즉시', 9, False, MUTED),
('크론 03:00 — 30일 경과', 9, False, MUTED), ('성과 저조 — 재생성', 9, False, MUTED)], space=1)
box(sl, xs[1], r1, BW, BH,
[('BullMQ 큐', 11.5, True, INK), ('60초 dedupe 창', 9, False, MUTED),
('재시도 3회 · 백오프', 9, False, MUTED), ('동시성 2', 9, False, MUTED)], space=1)
box(sl, xs[2], r1, BW, BH,
[('생성 워커', 11.5, True, INK), ('OpenAI · gpt-4.1-mini', 9, False, MUTED),
('Structured Outputs', 9, False, MUTED), ('임베딩 배치 1회', 9, False, MUTED)], space=1)
box(sl, xs[3], r1, BW, BH,
[('중복제거 4단계', 11.5, True, WARN), ('해시 → trigram → 벡터', 9, False, WARN),
('미일치만 신규 등록', 9, False, WARN), ('나머지는 alias 흡수', 9, False, WARN)],
fill=WARN_BG, line=WARN, lw=1.4, space=1)
box(sl, xs[4], r1 - 0.14, 2.58, BH + 0.28,
[('PostgreSQL 16', 11.5, True, ACCENT), ('pgvector · ltree · pg_trgm', 9, False, ACCENT),
('keyword (전역 사전)', 9, False, ACCENT), ('merchant_keyword', 9, False, ACCENT),
('qa_pair · generation_run', 9, False, ACCENT)],
fill=ACC_BG, line=ACCENT, lw=1.4, space=1)
mid = r1 + BH / 2
for i in range(4):
a, b = xs[i] + BW, xs[i + 1]
arrow(sl, a + 0.04, mid, b - 0.04, mid)
for i, t in enumerate(['적재', 'job', '후보', 'write']):
label(sl, xs[i] + BW + 0.02, mid - 0.28, t, 8.5, MUTED, MONO, PP_ALIGN.CENTER, w=0.42)
r2 = r1 + 2.05
box(sl, xs[3], r2, BW, BH,
[('Serving API', 11.5, True, INK), ('GET /v1/sites/:id/seo', 9, False, MUTED),
('GET /v1/sites/:id/aeo', 9, False, MUTED), ('읽기 99% · 캐시 대상', 9, False, MUTED)], space=1)
box(sl, xs[1], r2, BW, BH,
[('발행된 사이트', 11.5, True, INK), ('o2o-site-AEO', 9, False, MUTED),
('렌더링 시 호출', 9, False, MUTED)], space=1)
box(sl, xs[1], r2 + 1.5, 4.65, 0.6,
[('성과 수집 · Search Console · 서치어드바이저 · 유입 로그', 9.5, False, INK)],
anchor=MSO_ANCHOR.MIDDLE, space=1)
elbow(sl, [(xs[4] + 1.29, r1 - 0.14 + BH + 0.28), (xs[4] + 1.29, r2 + BH / 2), (xs[3] + BW + 0.04, r2 + BH / 2)])
label(sl, xs[4] + 1.35, r2 - 0.05, '읽기', 8.5, w=0.6)
arrow(sl, xs[3] - 0.04, r2 + BH / 2, xs[1] + BW + 0.04, r2 + BH / 2)
label(sl, xs[1] + BW + 0.5, r2 + BH / 2 - 0.28, 'SEO / AEO payload', 8.5, MUTED, MONO, PP_ALIGN.CENTER, w=1.6)
arrow(sl, xs[1] + BW / 2, r2 + BH, xs[1] + BW / 2, r2 + 1.46)
label(sl, xs[1] + BW / 2 + 0.08, r2 + BH + 0.06, '노출 · 클릭', 8.5)
elbow(sl, [(xs[1], r2 + 1.8), (MX + 0.5, r2 + 1.8), (MX + 0.5, r1 + BH + 0.06)])
label(sl, MX + 0.58, r2 + 0.9, 'CTR < 0.2% → 강등', 8.5)
elbow(sl, [(xs[4] + 1.29, r1 - 0.18), (xs[4] + 1.29, r1 - 0.5), (xs[2] + BW / 2, r1 - 0.5), (xs[2] + BW / 2, r1 - 0.04)],
color=ACCENT, dash=True)
label(sl, xs[2] + BW / 2, r1 - 0.78, '기존 키워드 주입 — 중복 후보 생성 자체를 억제', 9, ACCENT, MONO, PP_ALIGN.CENTER, w=4.6)
footer(sl, 3)
# ================================================================ 4 중복제거
sl, y = slide('중복제거 4단계', '값싼 판정을 먼저, 비싼 판정을 나중에. 벡터 비교는 후보 20건 안에서만 일어난다.', '핵심 메커니즘')
SX, SW, SH, GAP = 3.55, 3.5, 0.66, 0.19
stages = [
('0 · 금칙어 필터', '최고 · 1위 · 100% · 완치', '비용 0', STOP, STOP_BG, '차단 — 저장하지 않음', 'rejected_banned'),
('1 · normalized 완전 일치', 'NFKC · 소문자 · 공백/구두점 제거', 'B-tree 1회', WARN, WARN_BG, 'alias 흡수', '강남 뿌리 염색 → 강남 뿌리염색'),
('2 · pg_trgm 유사도 ≥ 0.6', '표기 변형 · 오타', 'GIN trgm', WARN, WARN_BG, 'alias 흡수', '강남 뿌리염색약 → 강남 뿌리염색 (0.67)'),
('3 · 코사인 유사도 ≥ 0.92', '의미 중복 — 후보 20건 안에서만', 'HNSW top-20', WARN, WARN_BG, 'alias 흡수', '강남 헤어샵 → 강남 미용실 (0.94)'),
]
top = y + 0.28
label(sl, SX, top - 0.30, 'LLM 후보 키워드', 11, INK, SANS, PP_ALIGN.CENTER, w=SW)
for i, (t, sub, cost, col, colbg, exit_t, exit_s) in enumerate(stages):
yy = top + i * (SH + GAP)
box(sl, SX, yy, SW, SH, [(t, 11, True, INK), (sub, 8.5, False, MUTED)], space=1)
label(sl, SX - 1.55, yy + 0.20, cost, 8.5, MUTED, MONO, PP_ALIGN.RIGHT, w=1.45)
arrow(sl, SX + SW + 0.04, yy + SH / 2, SX + SW + 0.7, yy + SH / 2, color=col)
box(sl, SX + SW + 0.74, yy, 4.3, SH,
[(exit_t, 10, True, col), (exit_s, 8.5, False, col, MONO)],
fill=colbg, line=col, lw=1.1, space=1)
if i < len(stages) - 1:
arrow(sl, SX + SW / 2, yy + SH, SX + SW / 2, yy + SH + GAP - 0.02)
last = top + len(stages) * (SH + GAP)
arrow(sl, SX + SW / 2, last - GAP, SX + SW / 2, last - 0.02)
box(sl, SX, last, SW, SH,
[('4 · 새 키워드로 INSERT', 11, True, ACCENT), ('embedding 저장 · usage_count 1', 8.5, False, ACCENT)],
fill=ACC_BG, line=ACCENT, lw=1.4, space=1)
label(sl, SX - 1.55, last + 0.20, 'INSERT', 8.5, MUTED, MONO, PP_ALIGN.RIGHT, w=1.45)
box(sl, SX + SW + 0.74, last, 4.3, SH,
[('어느 경로든 업체에는 연결된다', 10, True, INK), ('merchant_keyword · relevance · status', 8.5, False, MUTED, MONO)],
fill=None, line=INK, lw=1.0, space=1)
textbox(sl, MX, H - 1.0, W - 2 * MX, 0.4,
[('1~3 단계에서 걸린 표기는 버리지 않고 기존 키워드의 aliases[] 에 흡수한다 — 롱테일 검색어를 잃지 않으면서 사전은 한 행으로 유지된다.',
10, False, MUTED)])
footer(sl, 4)
# ================================================================ 5 데이터 모델
sl, y = slide('데이터 모델', '키워드를 업체에 복제하지 않는 것이 이 스키마의 전부다. 복제하는 순간 중복제거가 성립하지 않는다.', '스키마')
c1, c2, c3 = MX, MX + 4.7, MX + 8.9
box(sl, c1, y + 0.15, 2.6, 0.62, [('industry', 10.5, True, INK, MONO), ('path ltree · beauty.hair', 8.5, False, MUTED, MONO)], space=1)
box(sl, c1, y + 0.97, 2.6, 0.62, [('region', 10.5, True, INK, MONO), ('path ltree · kr.jeonbuk.gunsan', 8.5, False, MUTED, MONO)], space=1)
box(sl, c1, y + 2.35, 2.6, 1.15,
[('merchant', 10.5, True, INK, MONO), ('external_id ← 사이트 ID', 8.5, False, MUTED, MONO),
('description · profile jsonb', 8.5, False, MUTED, MONO), ('last_generated_at', 8.5, False, MUTED, MONO)], space=1)
box(sl, c2, y + 2.35, 3.1, 1.15,
[('merchant_keyword', 10.5, True, INK, MONO), ('relevance · status · source', 8.5, False, MUTED, MONO),
('impressions · clicks · ctr', 8.5, False, MUTED, MONO), ('PK (merchant_id, keyword_id)', 8.5, False, MUTED, MONO)], space=1)
box(sl, c3, y + 0.15, 3.6, 1.95,
[('keyword — 전역 사전', 10.5, True, ACCENT, MONO), ('canonical · 표시용', 8.5, False, ACCENT, MONO),
('normalized UNIQUE · 판정용', 8.5, False, ACCENT, MONO), ('aliases text[] · 흡수된 표기', 8.5, False, ACCENT, MONO),
('embedding vector(1536) HNSW', 8.5, False, ACCENT, MONO), ('intent · locale · usage_count', 8.5, False, ACCENT, MONO)],
fill=ACC_BG, line=ACCENT, lw=1.4, space=1)
box(sl, c3, y + 2.55, 3.6, 0.95,
[('qa_pair', 10.5, True, INK, MONO), ('question · answer', 8.5, False, MUTED, MONO),
('normalized_question UNIQUE', 8.5, False, MUTED, MONO), ('embedding vector(1536)', 8.5, False, MUTED, MONO)], space=1)
arrow(sl, c1 + 2.64, y + 0.46, c3 - 0.04, y + 0.55)
label(sl, c1 + 3.0, y + 0.18, '업종 분류', 8.5)
arrow(sl, c1 + 2.64, y + 1.28, c3 - 0.04, y + 1.20)
label(sl, c1 + 3.0, y + 1.32, '지역 분류', 8.5)
arrow(sl, c1 + 2.64, y + 2.92, c2 - 0.04, y + 2.92)
label(sl, c1 + 2.75, y + 2.62, '1 : N', 8.5, MUTED, MONO, PP_ALIGN.CENTER, w=1.9)
elbow(sl, [(c2 + 3.14, y + 2.75), (c3 - 0.35, y + 2.75), (c3 - 0.35, y + 1.1), (c3 - 0.04, y + 1.1)])
label(sl, c3 - 0.95, y + 1.85, 'N : 1', 8.5)
elbow(sl, [(c1 + 1.3, y + 3.54), (c1 + 1.3, y + 3.95), (c3 + 1.8, y + 3.95), (c3 + 1.8, y + 3.54)])
label(sl, c2 + 1.3, y + 3.62, '1 : N', 8.5)
textbox(sl, MX, H - 1.0, W - 2 * MX, 0.4,
[('강남 미용실 을 100개 업체가 쓰더라도 keyword 에는 행이 하나, 임베딩도 하나뿐이다. 업체별 관련도·성과는 전부 merchant_keyword 가 들고 있다.',
10, False, MUTED)])
footer(sl, 5)
# ================================================================ 6 API
sl, y = slide('API', ':id 는 o2o-site-AEO 의 external_id 와 내부 UUID 를 모두 받는다 — 연동 쪽에 ID 매핑 테이블이 필요 없다.', '연동 표면')
table(sl, MX, y, W - 2 * MX,
['메서드', '경로', '용도'],
[['`GET', '`/health', '헬스체크 · 현재 LLM provider 확인'],
['`POST', '`/v1/merchants/publish', '사이트 발행 웹훅. 업체 upsert 후 생성 작업 적재 (sync:true 면 동기)'],
['`POST', '`/v1/merchants/:id/generate', '수동 재생성. ?sync=true&count=N'],
['`GET', '`/v1/sites/:id/seo', '발행 사이트가 렌더링 시 호출. title · description · keywords · tags'],
['`GET', '`/v1/sites/:id/aeo', '답변엔진용 topics · FAQ · structuredDataHints'],
['`POST', '`/v1/keywords/search', '어드민 — 자연어 질의로 키워드 사전 벡터 검색'],
['`POST', '`/v1/sites/:id/performance', '노출·클릭 주입 → CTR 갱신 → 저성과 강등']],
widths=[1.0, 3.5, 7.333], rowh=0.4)
box(sl, MX, y + 3.35, 5.75, 1.85,
[('SEO 응답', 10, True, ACCENT, MONO),
('{', 9.5, False, INK_SOFT, MONO),
(' "title": "스테이머뭄 | 군산 펜션",', 9.5, False, INK_SOFT, MONO),
(' "keywords": ["스테이머뭄", "군산 펜션", …],', 9.5, False, INK_SOFT, MONO),
(' "tags": [{ "keyword": "군산 애견동반 펜션",', 9.5, False, INK_SOFT, MONO),
(' "relevance": 0.88, "aliases": [ … ] }]', 9.5, False, INK_SOFT, MONO),
('}', 9.5, False, INK_SOFT, MONO)],
fill=SURFACE, space=1)
box(sl, MX + 6.05, y + 3.35, 5.78, 1.85,
[('AEO 응답 — 답변엔진이 인용하는 쪽', 10, True, ACCENT, MONO),
('{', 9.5, False, INK_SOFT, MONO),
(' "topics": ["군산 펜션", "군산 커플 펜션"],', 9.5, False, INK_SOFT, MONO),
(' "faqs": [{ "question": "…근처에 가볼 만한 곳은?",', 9.5, False, INK_SOFT, MONO),
(' "answer": "선유도, 은파호수공원 …" }],', 9.5, False, INK_SOFT, MONO),
(' "structuredDataHints": { "type": "LocalBusiness" }', 9.5, False, INK_SOFT, MONO),
('}', 9.5, False, INK_SOFT, MONO)],
fill=SURFACE, space=1)
footer(sl, 6)
# ================================================================ 7 기술 선택
sl, y = slide('기술 선택', None, '스택')
table(sl, MX, y, W - 2 * MX,
['레이어', '선택', '이유'],
[['런타임', '`NestJS · TypeScript', 'o2o-site-AEO 와 payload 타입을 공유할 수 있다'],
['DB', '`PostgreSQL 16 + pgvector + ltree + pg_trgm', '정확 · 의미 · 계층 조회 3-in-1'],
['DB 접근', '`postgres.js (raw SQL)', '벡터 연산자와 ltree 는 어차피 raw SQL — ORM 을 얹으면 우회 코드가 더 는다'],
['큐 · 스케줄', '`BullMQ + Redis', '60초 dedupe 창, 지수 백오프 재시도, 크론이 전부 내장'],
['LLM', '`OpenAI Structured Outputs / text-embedding-3-small', 'JSON Schema 강제 — 자유 텍스트 파싱은 반드시 깨진다'],
['관측', '`generation_run 테이블', '프롬프트 버전 · 토큰 · 단계별 통계를 행으로 남긴다']],
widths=[1.5, 4.6, 5.733], rowh=0.44)
box(sl, MX, y + 3.5, W - 2 * MX, 1.5,
[('로컬 실행', 10, True, ACCENT, MONO),
('npm install && cp .env.example .env # 기본 LLM_PROVIDER=mock — API 키 불필요', 10, False, INK_SOFT, MONO),
('npm run db:up && npm run db:migrate && npm run db:seed', 10, False, INK_SOFT, MONO),
('npm start # http://localhost:3100', 10, False, INK_SOFT, MONO),
('npm run smoke # 다른 터미널 — 엔드투엔드 점검', 10, False, INK_SOFT, MONO)],
fill=SURFACE, space=2)
footer(sl, 7)
# ================================================================ 8 검증 1
sl, y = slide('로컬 검증 — 같은 지역·업종 3곳', '강남/미용실 업체를 순서대로 발행했을 때 중복제거가 실제로 어떻게 걸리는지.', '검증 1')
table(sl, MX, y, 7.4,
['순서', '업체', '후보', '신규', '중복 (정확/표기/의미)'],
[['1', '레브살롱', '19', '19', '0 / 0 / 0'],
['2', '헤어랩 강남점', '19', '4', '15 / 0 / 0'],
['3', '강남 뷰티랩', '16', '3', '12 / 1 / 0']],
widths=[0.7, 2.4, 1.0, 1.0, 2.3], rowh=0.42)
box(sl, MX, y + 2.1, 7.4, 1.5,
[('matched_exact 강남 뿌리 염색 (sim=1.000 → \'강남 뿌리염색\')', 10, False, INK_SOFT, MONO),
('matched_trigram 강남 뿌리염색약 (sim=0.667 → \'강남 뿌리염색\')', 10, False, INK_SOFT, MONO),
('matched_exact 강남미용실추천 (sim=1.000 → \'강남 미용실 추천\')', 10, False, INK_SOFT, MONO)],
fill=SURFACE, space=3)
box(sl, MX + 7.8, y, 4.03, 3.6,
[('세 번째 업체에서는', 11, False, MUTED),
('16개 중 3개만', 22, True, ACCENT),
('새 키워드였다', 11, False, MUTED),
('', 8, False, MUTED),
('나머지 13개는 이미 사전에 있던', 10.5, False, INK_SOFT),
('키워드에 흡수됐다. 업체가 늘어도', 10.5, False, INK_SOFT),
('사전은 선형으로 늘지 않는다.', 10.5, False, INK_SOFT)],
fill=SURFACE, pad=0.24, space=4)
textbox(sl, MX, H - 1.0, W - 2 * MX, 0.4,
[('참고 — LLM_PROVIDER=mock 기준. mock 임베딩은 문자 bigram 해싱이라 표기 유사도만 잡는다. 의미 중복은 실제 text-embedding-3-small 로 전환해야 3단계가 발동한다.',
9.5, False, MUTED)])
footer(sl, 8)
# ================================================================ 9 검증 2
sl, y = slide('로컬 검증 — 군산 스테이머뭄 200개', '펜션 한 곳으로 키워드 200개를 뽑아 pgvector 에 적재했을 때 실제로 무엇이 쌓이는가.', '검증 2')
box(sl, MX, y, 5.6, 0.95,
[('후보 200 → 신규 174 / 중복(표기 26) / 연결 174 / QA 5 799ms', 10.5, False, INK_SOFT, MONO),
('keyword 174행 · 임베딩 174건 · 흡수된 표기 26개', 10.5, False, ACCENT, MONO)],
fill=SURFACE, space=3)
textbox(sl, MX, y + 1.25, 5.6, 0.3, [('relevance 분포', 11.5, True, INK)])
dist = [('0.93~0.99', 4, '브랜드 · 핵심', ACCENT),
('0.80~0.88', 32, '지역 × 업종 × 동반자', ACCENT),
('0.72~0.76', 11, '시즌', INK_SOFT),
('0.60~0.70', 51, '시설 · 서비스', INK_SOFT),
('0.50', 6, '질문형', MUTED),
('0.42', 40, '동반자 × 시설', STOP),
('0.38', 30, '동반자 × 서비스', STOP)]
by = y + 1.62
for i, (rng, n, note, col) in enumerate(dist):
yy = by + i * 0.36
label(sl, MX, yy + 0.03, rng, 9, MUTED, MONO, PP_ALIGN.RIGHT, w=0.95)
box(sl, MX + 1.05, yy, max(0.06, n * 0.048), 0.24, fill=col, line=None, rounded=False)
label(sl, MX + 1.05 + max(0.06, n * 0.048) + 0.1, yy + 0.03, f'{n} {note}', 9, col, SANS, w=3.2)
box(sl, MX + 7.4, y, 4.43, 2.05,
[('하위 70개는 이런 것들', 11, True, STOP),
('애견동반 바베큐장 0.38', 10, False, INK_SOFT, MONO),
('태교여행 프라이빗 스파 0.38', 10, False, INK_SOFT, MONO),
('커플 바베큐장 0.38', 10, False, INK_SOFT, MONO),
('', 6, False, MUTED),
('문법은 맞지만 아무도 이렇게 검색하지 않는다.', 10, False, MUTED)],
fill=STOP_BG, line=STOP, lw=1.1, space=2)
box(sl, MX + 7.4, y + 2.35, 4.43, 1.9,
[('잘 작동한 부분 — 벡터 검색', 11, True, ACCENT),
('"선유도 근처 바베큐 되는 펜션"', 10, False, INK_SOFT, MONO),
(' 0.686 선유도 근처 펜션', 10, False, ACCENT, MONO),
(' 0.439 고군산군도 근처 펜션', 10, False, ACCENT, MONO),
(' 0.392 선유도 펜션 추천', 10, False, ACCENT, MONO)],
fill=SURFACE, space=2)
footer(sl, 9)
# ================================================================ 10 발견
sl, y = slide('발견 — 저장한 것의 89%는 쓰이지 않는다', None, '문제 정의')
box(sl, MX, y, 5.3, 2.5,
[('적재된 키워드 174개 중', 12, False, MUTED),
('89%', 62, True, STOP),
('가 한 번도 서빙되지 않는다 (서빙 20개 / 사장 154개)', 11.5, False, INK_SOFT)],
fill=STOP_BG, line=None, pad=0.3, space=6, anchor=MSO_ANCHOR.MIDDLE)
box(sl, MX + 5.7, y, 6.13, 2.5,
[('그런데 이 154개는', 12, True, INK),
('· 매번 dedup 후보 검색 대상이고', 11.5, False, INK_SOFT),
('· HNSW 인덱스에 들어가 있고', 11.5, False, INK_SOFT),
('· 다음 생성 때 프롬프트에도 실린다', 11.5, False, INK_SOFT),
('', 6, False, MUTED),
('순수한 부채다. 주기 생성을 30일마다 돌리면 매달 반복된다.', 11.5, True, STOP)],
fill=SURFACE, pad=0.3, space=5)
textbox(sl, MX, y + 2.85, W - 2 * MX, 0.3, [('시간이 지나면 실제로 바뀌는 건 3가지뿐', 14, True, INK)])
table(sl, MX, y + 3.3, W - 2 * MX,
['무엇이 바뀌나', '올바른 대응', '현행 설계'],
[['업체 정보 (메뉴 추가, 이전, 서비스 변경)', '이벤트 기반 재생성', '30일 크론이 대신 처리'],
['성과 데이터 누적', '재순위 — 생성이 아님', '재생성으로 오해'],
['계절 · 트렌드 (연말 파티헤어, 여름 네일)', '업종 단위 생성 — 업체 수와 무관', '업체마다 중복 생성']],
widths=[4.6, 4.0, 3.233], rowh=0.4)
footer(sl, 10)
# ================================================================ 11 개선
sl, y = slide('개선 방향', '키워드는 업체 수 × 시간이 아니라 업체 수에만 비례해야 한다.', '다음 단계')
items = [
('relevance 컷', '0.6 미만 후보는 저장하지 않는다', '200개 → 110개. 저장조차 하지 말아야 할 것들.'),
('업체당 정원제', 'active 슬롯 30개 고정', '새 후보는 최약체와 경쟁해서 이겨야 들어온다.\n시스템이 스스로 상한을 갖는다.'),
('profile_hash', '업체 정보가 바뀔 때만 재생성', '정보가 그대로면 재생성해서 얻을 게 없다. 주기 크론이 사실상 무력화된다.'),
('크론 성격 전환', '생성 → 정리', '고아 키워드 삭제, 저성과 강등. 늘리는 일이 아니라 줄이는 일.'),
]
for i, (t, s, d) in enumerate(items):
yy = y + i * 0.85
box(sl, MX, yy, 0.42, 0.68, [(str(i + 1), 12, True, ACCENT, MONO)],
fill=SURFACE, anchor=MSO_ANCHOR.MIDDLE, align=PP_ALIGN.CENTER, pad=0.02)
textbox(sl, MX + 0.62, yy + 0.02, 2.5, 0.3, [(t, 13, True, INK)])
textbox(sl, MX + 3.2, yy + 0.04, 3.0, 0.3, [(s, 11, False, ACCENT, MONO)])
textbox(sl, MX + 6.4, yy + 0.02, 5.4, 0.6,
[(ln, 10.5, False, MUTED) for ln in d.split('\n')], space=1)
box(sl, MX, y + 3.7, W - 2 * MX, 1.5,
[('그 뒤에 남은 작업', 11, True, ACCENT),
('JSON-LD 조립 (structuredDataHints → LocalBusiness / FAQPage / Service) · /llms.txt 서빙', 11, False, INK_SOFT),
('업종 ltree 상위 노드 키워드 상속 · Redis 응답 캐시 · Search Console API 직접 연동 · 키워드 승인/차단 어드민', 11, False, INK_SOFT)],
fill=ACC_BG, line=None, pad=0.26, space=4)
footer(sl, 11)
prs.save('docs/architecture.pptx')
print('✅ docs/architecture.pptx')

View File

@ -0,0 +1,160 @@
/**
* 전국 지역별 펜션 SEO/AEO 키워드 데이터셋.
* node scripts/build-nationwide-dataset.mjs data/nationwide-pension-keywords.json
*
* 설계 원칙
* · 조합 폭발을 하지 않는다. 군산 단일 지역 974건을 54 지역에 곱하면 5 건이 되고
* 대부분 검색량 0 된다 (실측: 저장분의 89% 미사용).
* · 지역 성격(해변/산간/호수/도심/) 맞는 시설 키워드만 전개한다.
* 산간 지역에 '오션뷰 펜션' 만들지 않는다.
* · 티어를 매겨 주력/보조/롱테일을 구분한다. SEO 페이지당 주력 1개다.
*/
import { readFileSync, writeFileSync } from 'node:fs';
const { regions } = JSON.parse(readFileSync('data/regions.json', 'utf8'));
// ── 공통 어휘
const STAY = ['펜션', '숙소', '독채펜션', '풀빌라', '스파펜션', '애견펜션', '감성펜션', '글램핑', '독채'];
const INTENT = { 추천:'local', 예약:'transactional', 가격:'transactional', 후기:'informational',
순위:'informational', 저렴한곳:'local', 가성비:'local', 실시간예약:'transactional',
당일예약:'transactional', 특가:'transactional' };
const WITH = ['커플', '가족', '친구', '애견동반', '단체', '아이동반', '부모님', '4인', '6인', '2인'];
const VIBE = ['감성', '조용한', '분위기 좋은', '사진찍기 좋은', '인생샷', '깔끔한', '신축'];
const TRAVEL = ['1박2일', '2박3일', '주말여행', '뚜벅이 여행', '워케이션'];
// 지역 성격별 유효 시설 — 여기가 조합 폭발을 막는 장치다
const FEATURES_BY_TYPE = {
해변: ['오션뷰', '바다뷰', '노을뷰', '일출뷰', '해변 근처', '바다 보이는'],
: ['오션뷰', '바다뷰', '배타고 가는', '섬'],
산간: ['산뷰', '숲속', '불멍', '화로대', '벽난로', '단풍'],
계곡: ['계곡', '물놀이', '계곡뷰', '불멍'],
호수: ['호수뷰', '레이크뷰', '물놀이', '노을뷰'],
강변: ['강뷰', '리버뷰', '노을뷰'],
도심: ['역세권', '시내', '주차', '도보 여행'],
습지: ['자연', '산책'],
};
const FEATURES_COMMON = ['바베큐', '스파', '자쿠지', '수영장', '독채', '프라이빗', '복층', '테라스', '애견운동장', '넷플릭스'];
const SEASON_BY_TYPE = {
해변: ['여름휴가', '물놀이', '해수욕', '일출', '낙조'],
: ['여름휴가', '일출'],
산간: ['겨울', '단풍', '눈꽃'],
계곡: ['여름휴가', '물놀이', '단풍'],
호수: ['여름휴가', '단풍', '벚꽃'],
강변: ['벚꽃', '단풍'],
도심: ['벚꽃', '연말'],
습지: ['가을', '갈대'],
};
const SEASON_COMMON = ['겨울', '연말', '크리스마스', '주말', '성수기'];
const rows = [];
const seen = new Set();
const norm = (s) => s.normalize('NFKC').toLowerCase().replace(/\s+/g, '');
function add(region, keyword, { intent = 'local', kind = 'keyword', category, tier, relevance }) {
const k = keyword.replace(/\s+/g, ' ').trim();
const id = `${region.key}|${norm(k)}`;
if (!k || seen.has(id)) return;
seen.add(id);
rows.push({
sido: region.sido, region: region.name, regionKey: region.key,
regionType: region.type.join('·'),
keyword: k, kind, intent, category, tier,
relevance: Math.round(relevance * 100) / 100,
});
}
const uniq = (a) => [...new Set(a)];
for (const r of regions) {
const R = r.name;
const feats = uniq([...r.type.flatMap((t) => FEATURES_BY_TYPE[t] ?? []), ...FEATURES_COMMON]);
// '산간'이라고 다 스키장이 있는 건 아니다. 가평·양평·강화에 '스키 펜션'이 생기면 안 된다.
const seasons = uniq([
...r.type.flatMap((t) => SEASON_BY_TYPE[t] ?? []),
...(r.ski ? ['스키', '스키장 근처', '보드'] : []),
...SEASON_COMMON,
]);
// T1 코어 — 주력 후보.
// 별칭(대천/보령 처럼 같은 지역의 다른 검색 표기)도 코어·의도 계층까지는 함께 전개한다.
// 전 계층에 곱하면 두 배가 되므로 상위 티어에만 적용한다.
const names = [R, ...(r.aliases ?? [])];
for (const N of names) {
add(r, `${N} 펜션`, { category: '코어', tier: '주력', relevance: N === R ? 0.98 : 0.94 });
add(r, `${N} 숙소`, { category: '코어', tier: '주력', relevance: N === R ? 0.96 : 0.92 });
for (const s of STAY.slice(2)) add(r, `${N} ${s}`, { category: '코어', tier: '주력', relevance: 0.9 });
}
// T2 의도 — 보조
for (const N of names)
for (const [m, it] of Object.entries(INTENT))
add(r, `${N} 펜션 ${m}`, { intent: it, category: '의도', tier: '보조', relevance: N === R ? 0.88 : 0.84 });
for (const s of ['독채펜션', '풀빌라', '애견펜션', '감성펜션'])
for (const m of ['추천', '예약', '가격', '후기'])
add(r, `${R} ${s} ${m}`, { intent: INTENT[m], category: '의도', tier: '보조', relevance: 0.8 });
// T3 동반자
for (const w of WITH) {
add(r, `${R} ${w} 펜션`, { category: '동반자', tier: '보조', relevance: 0.85 });
add(r, `${R} ${w} 펜션 추천`, { category: '동반자', tier: '롱테일', relevance: 0.7 });
}
// T4 시설 — 지역 성격에 맞는 것만
for (const f of feats) {
add(r, `${R} ${f} 펜션`, { category: '시설', tier: '보조', relevance: 0.83 });
}
for (const f of feats.slice(0, 6))
add(r, `${R} 커플 ${f} 펜션`, { category: '시설', tier: '롱테일', relevance: 0.55 });
// T5 시즌
for (const s of seasons) add(r, `${R} ${s} 펜션`, { category: '시즌', tier: '보조', relevance: 0.76 });
// T6 관광지 앵커 — 지역 고유
for (const sp of r.spots) {
add(r, `${sp} 근처 펜션`, { category: '관광지', tier: '보조', relevance: 0.84 });
add(r, `${sp} 근처 숙소`, { category: '관광지', tier: '보조', relevance: 0.81 });
}
// T7 분위기·여행형태
for (const v of VIBE) add(r, `${R} ${v} 숙소`, { category: '분위기', tier: '롱테일', relevance: 0.68 });
for (const t of TRAVEL) add(r, `${R} ${t} 숙소`, { category: '여행형태', tier: '롱테일', relevance: 0.66 });
// T8 질문형 (AEO)
const qs = [
[`${R} 펜션 어디가 좋아요`, 'informational', 0.72],
[`${R} 펜션 1박 얼마인가요`, 'transactional', 0.7],
[`${R} 애견동반 펜션 있나요`, 'informational', 0.68],
[`${R} 펜션 바베큐 가능한가요`, 'informational', 0.67],
[`${R} 여행 몇박이 좋을까요`, 'informational', 0.6],
[`${R} 펜션 성수기 언제인가요`, 'informational', 0.58],
];
for (const [q, it, rel] of qs) add(r, q, { intent: it, category: '질문형', tier: '롱테일', relevance: rel });
// T9 태그 (칩 UI)
for (const t of uniq([...feats.slice(0, 8), ...WITH.slice(0, 5), ...VIBE.slice(0, 4)]))
add(r, t, { kind: 'tag', category: '태그', tier: '태그', relevance: 0.5 });
}
// 광역 단위 롤업. ltree 라벨은 ASCII 만 허용하므로 시군 키에서 마지막 마디를 떼어 쓴다.
const sidoKey = {};
for (const r of regions) sidoKey[r.sido] ??= r.key.split('.').slice(0, -1).join('.');
const sidoList = uniq(regions.map((x) => x.sido));
for (const sido of sidoList) {
const pseudo = { sido, name: sido, key: sidoKey[sido], type: [] };
for (const s of ['펜션', '숙소', '독채펜션', '풀빌라', '애견펜션'])
add(pseudo, `${sido} ${s}`, { category: '광역', tier: '주력', relevance: 0.92 });
for (const m of ['추천', '예약', '가격', '후기'])
add(pseudo, `${sido} 펜션 ${m}`, { intent: INTENT[m], category: '광역', tier: '보조', relevance: 0.85 });
}
writeFileSync('data/nationwide-pension-keywords.json',
JSON.stringify({ topic: '전국 지역별 펜션', locale: 'ko-KR',
generatedBy: 'region master × search-pattern expansion (region-type aware)',
regionCount: regions.length, count: rows.length, items: rows }, null, 2) + '\n');
const by = (f) => rows.reduce((a, r) => (a[r[f]] = (a[r[f]] ?? 0) + 1, a), {});
console.log(`✅ data/nationwide-pension-keywords.json ${rows.length}건 / ${regions.length}개 지역`);
console.log(` 지역당 평균 ${Math.round(rows.length / (regions.length + sidoList.length))}`);
console.log(' 티어:', by('tier'));
console.log(' 카테고리:', by('category'));

10
ontology/scripts/db-dump.sh Executable file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env bash
# 임베딩 포함 전체 덤프 — 배포 대상에서 재임베딩 없이 그대로 복원된다.
set -euo pipefail
OUT="${1:-data/ontology-dump.sql.gz}"
mkdir -p "$(dirname "$OUT")"
docker exec -i ontology-postgres pg_dump -U ontology -d ontology \
--no-owner --no-privileges --clean --if-exists | gzip -9 > "$OUT"
echo "$OUT ($(du -h "$OUT" | cut -f1))"
echo " 복원: gunzip -c $OUT | psql \"\$TARGET_DATABASE_URL\""
echo " (대상 DB 에 vector · ltree · pg_trgm 확장이 설치돼 있어야 한다)"

View File

@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
"""벡터 DB 에 실제로 적재된 내용을 그대로 엑셀로 뽑는다 (배포용).
python3 scripts/export-db-xlsx.py
데이터셋 JSON 아니라 DB 기준이다. 임베딩은 엑셀에 담지 않는다
384 float × 7 행이라 의미가 없고, 같은 모델로 재생성하면 동일하게 복원된다."""
import csv, io, subprocess, collections
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
OUT = 'data/배포용_키워드_DB덤프.xlsx'
CONT = 'ontology-postgres'
DB = ['psql', '-U', 'ontology', '-d', 'ontology', '-t', '-A', '--csv', '-c']
INK = '1F2A2B'
HEAD = PatternFill('solid', fgColor='0D6A60')
THIN = Side(style='thin', color='D5DCDB')
BOX = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
SRC_FILL = {'dataset': 'DFF0EC', 'nationwide': 'FFFFFF', 'manual': 'F6EAD2'}
def query(sql: str):
out = subprocess.run(['docker', 'exec', '-i', CONT, *DB, sql],
capture_output=True, text=True, check=True).stdout
return list(csv.reader(io.StringIO(out)))
def sheet(wb, title, header, rows, widths_, fill_col=None, first=False):
ws = wb.active if first else wb.create_sheet(title)
if first: ws.title = title
ws.append(header)
for r in rows: ws.append(r)
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=len(header)):
fill = None
if fill_col is not None:
fill = PatternFill('solid', fgColor=SRC_FILL.get(row[fill_col].value, 'FFFFFF'))
for c in row:
c.font = Font(size=10, color=INK); c.border = BOX
if fill: c.fill = fill
for c in range(1, len(header) + 1):
cell = ws.cell(row=1, column=c)
cell.fill = HEAD; cell.font = Font(bold=True, color='FFFFFF', size=10)
cell.alignment = Alignment(horizontal='center', vertical='center')
cell.border = BOX
ws.row_dimensions[1].height = 22
ws.freeze_panes = 'A2'
ws.auto_filter.ref = f'A1:{get_column_letter(len(header))}{ws.max_row}'
for i, w in enumerate(widths_, start=1):
ws.column_dimensions[get_column_letter(i)].width = w
return ws
wb = Workbook()
# 1) 키워드 — DB 전체
kw = query("""
SELECT k.id, k.canonical, k.normalized, k.locale,
array_to_string(k.aliases, ' | ') AS aliases,
k.intent, k.kind, COALESCE(k.category,'') AS category, k.source,
COALESCE(k.industry_id,'') , COALESCE(k.region_id,''),
COALESCE(r.name,''), COALESCE(sido.name,''),
k.usage_count,
(k.embedding IS NOT NULL) AS has_embedding,
to_char(k.updated_at,'YYYY-MM-DD HH24:MI')
FROM keyword k
LEFT JOIN region r ON r.id = k.region_id
LEFT JOIN region sido ON sido.id = regexp_replace(k.region_id, '\\.[^.]+$', '')
ORDER BY k.source, sido.name NULLS FIRST, r.name NULLS FIRST, k.canonical
""")
sheet(wb, '키워드',
['id', '키워드', '정규화', 'locale', '흡수된 표기(alias)', '의도', '종류', '카테고리',
'출처', '업종ID', '지역ID', '지역', '시도', '사용업체수', '임베딩', '갱신일시'],
kw, [38, 30, 26, 8, 30, 14, 8, 10, 12, 14, 24, 12, 8, 10, 9, 17],
fill_col=8, first=True)
# 2~5) 마스터
sheet(wb, '지역', ['지역ID', '경로(ltree)', '지역명'],
query("SELECT id, path::text, name FROM region ORDER BY path"), [26, 26, 16])
sheet(wb, '업종', ['업종ID', '경로(ltree)', '업종명'],
query("SELECT id, path::text, name FROM industry ORDER BY path"), [22, 22, 16])
sheet(wb, '업체',
['외부ID', '상호', '업종', '지역', '소개', '사이트', '프로필(JSON)'],
query("""SELECT m.external_id, m.name, COALESCE(i.name,''), COALESCE(r.name,''),
m.description, COALESCE(m.site_url,''), m.profile::text
FROM merchant m
LEFT JOIN industry i ON i.id=m.industry_id
LEFT JOIN region r ON r.id=m.region_id
ORDER BY m.external_id"""),
[14, 18, 12, 10, 50, 34, 70])
sheet(wb, 'QA(AEO)', ['업체', '질문', '답변', '상태'],
query("""SELECT m.name, q.question, q.answer, q.status::text
FROM qa_pair q JOIN merchant m ON m.id=q.merchant_id
ORDER BY m.name, q.created_at"""), [16, 44, 70, 10])
# 5-b) 업체↔키워드 연결
sheet(wb, '업체키워드',
['업체', '키워드', '관련도', '상태', '출처', '노출수', '클릭수', 'CTR', '근거'],
query("""SELECT m.name, k.canonical, round(mk.relevance::numeric,2), mk.status::text,
mk.source, mk.impressions, mk.clicks, round(mk.ctr::numeric,4),
COALESCE(mk.rationale,'')
FROM merchant_keyword mk
JOIN merchant m ON m.id=mk.merchant_id
JOIN keyword k ON k.id=mk.keyword_id
ORDER BY m.name, mk.relevance DESC"""),
[16, 30, 9, 10, 10, 10, 9, 9, 28])
# 5-c) 생성 이력 (감사 로그)
sheet(wb, '생성이력',
['업체', 'provider', 'model', '프롬프트버전', '트리거', '상태', '통계', '시작', '종료'],
query("""SELECT COALESCE(m.name,''), g.provider, g.model, g.prompt_version,
g.trigger, g.status, g.stats::text,
to_char(g.started_at,'YYYY-MM-DD HH24:MI'),
COALESCE(to_char(g.finished_at,'YYYY-MM-DD HH24:MI'),'')
FROM generation_run g
LEFT JOIN merchant m ON m.id=g.merchant_id
ORDER BY g.started_at DESC"""),
[16, 10, 20, 14, 12, 10, 60, 17, 17])
# 6) 배포 가이드
counts = collections.Counter(r[8] for r in kw)
guide = [
('무엇이 들어있나', ''),
('', f"벡터 DB(keyword 테이블)에 실제 적재된 {len(kw):,}건 전부. 데이터셋 JSON 이 아니라 DB 가 기준이다."),
('', '출처별: ' + ' · '.join(f'{k} {v:,}' for k, v in counts.most_common())),
('', 'dataset = 군산 상세(매칭 엔진 개발용) / nationwide = 전국 54개 지역'),
('', 'DB 의 7개 테이블을 모두 담았다: keyword / region / industry / merchant /'),
('', 'merchant_keyword / qa_pair / generation_run.'),
('', ''),
('임베딩은 왜 없나', ''),
('', '384개 float × 7천 행이라 엑셀에 담을 수 없고 담아도 못 읽는다.'),
('', '[임베딩] 열은 DB 에 벡터가 있는지만 표시한다.'),
('', '같은 모델(Xenova/multilingual-e5-small)로 다시 만들면 동일한 값이 나오므로'),
('', '텍스트만 있으면 복원된다.'),
('', ''),
('배포 방법 2가지', ''),
('A. pg_dump (권장)', '임베딩 포함 그대로 복원. 재임베딩 불필요.'),
('', ' npm run db:dump → data/ontology-dump.sql.gz'),
('', ' gunzip -c data/ontology-dump.sql.gz | psql $TARGET_URL'),
('B. 재적재', '텍스트에서 임베딩을 다시 만든다. 최초 1회 모델 다운로드(약 50초) + 임베딩 약 15초.'),
('', ' npm run db:migrate && npm run db:seed'),
('', ' npm run dataset:ingest && npm run dataset:ingest-nationwide'),
('', ''),
('⚠ 검색량은 아직 비어있다', ''),
('', '이 키워드는 검색 패턴 생성물이지 실제 검색 데이터가 아니다.'),
('', '네이버 검색광고 키워드도구로 월간검색수를 채우고 월 10 미만을 걷어내야'),
('', '실서비스에 쓸 수 있다. (npm run dataset:import-related 로 CSV 병합)'),
]
ws = sheet(wb, '배포가이드', ['항목', '내용'], guide, [22, 100])
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=2):
if row[0].value and not row[1].value:
row[0].font = Font(size=10, bold=True, color='0D6A60')
row[1].alignment = Alignment(wrap_text=True, vertical='center')
wb.save(OUT)
print(f'{OUT}')
print(f' 시트: ' + ', '.join(s.title for s in wb.worksheets))
print(f' 키워드 {len(kw):,}행 (' + ', '.join(f'{k} {v:,}' for k, v in counts.most_common()) + ')')

View File

@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
"""전국 펜션 키워드 데이터셋 → 엑셀.
python3 scripts/export-xlsx.py
검색량·경쟁도 열은 비워 둔다 네이버 검색광고 키워드도구에서 받아 채우는 자리."""
import json, collections
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
SRC = 'data/nationwide-pension-keywords.json'
OUT = 'data/전국_펜션_SEO_AEO_키워드.xlsx'
INK = '1F2A2B'
ACC = '0D6A60'
HEAD = PatternFill('solid', fgColor='0D6A60')
BAND = PatternFill('solid', fgColor='F1F5F4')
TIER = {'주력': 'DFF0EC', '보조': 'FFFFFF', '롱테일': 'F7F7F5', '태그': 'F6EAD2'}
THIN = Side(style='thin', color='D5DCDB')
BOX = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
SLOT = {
'코어': '메인 페이지 (주력)', '광역': '광역 랜딩',
'의도': '메인 / 예약 페이지', '동반자': '객실 페이지',
'시설': '시설 페이지', '관광지': '주변 여행 페이지',
'시즌': '블로그 · 프로모션', '분위기': '블로그 · 소개',
'여행형태': '블로그 · 코스', '질문형': 'FAQ (AEO · FAQPage)',
'태그': '필터 UI (SEO 아님)',
}
data = json.load(open(SRC, encoding='utf-8'))
items = data['items']
regions = json.load(open('data/regions.json', encoding='utf-8'))['regions']
wb = Workbook()
def style_header(ws, ncols, height=22):
for c in range(1, ncols + 1):
cell = ws.cell(row=1, column=c)
cell.fill = HEAD
cell.font = Font(bold=True, color='FFFFFF', size=10)
cell.alignment = Alignment(horizontal='center', vertical='center')
cell.border = BOX
ws.row_dimensions[1].height = height
ws.freeze_panes = 'A2'
ws.auto_filter.ref = f'A1:{get_column_letter(ncols)}{ws.max_row}'
def widths(ws, ws_widths):
for i, w in enumerate(ws_widths, start=1):
ws.column_dimensions[get_column_letter(i)].width = w
# ────────────────────────────────── 1. 키워드
ws = wb.active
ws.title = '키워드'
cols = ['시도', '지역', '지역키', '지역성격', '키워드', '종류', '의도', '카테고리',
'티어', '관련도', '월간검색수', '경쟁도', '추천 배치', '비고']
ws.append(cols)
for it in items:
ws.append([
it['sido'], it['region'], it['regionKey'], it['regionType'],
it['keyword'], it['kind'], it['intent'], it['category'],
it['tier'], it['relevance'], None, None,
SLOT.get(it['category'], ''), None,
])
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=len(cols)):
fill = PatternFill('solid', fgColor=TIER.get(row[8].value, 'FFFFFF'))
for c in row:
c.font = Font(size=10, color=INK)
c.border = BOX
c.fill = fill
row[9].number_format = '0.00'
row[10].number_format = '#,##0'
row[4].font = Font(size=10, bold=True, color=INK)
style_header(ws, len(cols))
widths(ws, [8, 12, 24, 14, 30, 8, 14, 10, 9, 9, 12, 10, 22, 16])
# ────────────────────────────────── 2. 지역 마스터
ws = wb.create_sheet('지역마스터')
ws.append(['시도', '지역', '지역키', '지역성격', '대표 관광지 (앵커)', '키워드 수'])
cnt = collections.Counter(i['regionKey'] for i in items)
for r in regions:
ws.append([r['sido'], r['name'], r['key'], '·'.join(r['type']),
', '.join(r['spots']), cnt.get(r['key'], 0)])
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=6):
for c in row:
c.font = Font(size=10, color=INK); c.border = BOX
c.alignment = Alignment(vertical='center', wrap_text=(c.column == 5))
style_header(ws, 6)
widths(ws, [8, 14, 26, 14, 70, 10])
# ────────────────────────────────── 3. 지역별 요약
ws = wb.create_sheet('지역별요약')
tiers = ['주력', '보조', '롱테일', '태그']
ws.append(['시도', '지역'] + tiers + ['합계'])
per = collections.defaultdict(collections.Counter)
meta = {}
for i in items:
per[i['regionKey']][i['tier']] += 1
meta[i['regionKey']] = (i['sido'], i['region'])
for key, c in sorted(per.items(), key=lambda kv: (-sum(kv[1].values()))):
sido, name = meta[key]
ws.append([sido, name] + [c[t] for t in tiers] + [sum(c.values())])
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=7):
for c in row:
c.font = Font(size=10, color=INK); c.border = BOX
style_header(ws, 7)
widths(ws, [8, 14, 9, 9, 10, 9, 9])
# ────────────────────────────────── 4. 사용 가이드
ws = wb.create_sheet('사용가이드')
guide = [
('이 파일은 무엇인가', ''),
('', f"전국 {data['regionCount']}개 펜션 수요 지역 × 검색 패턴으로 만든 SEO/AEO 키워드 후보 {len(items):,}건."),
('', '지역 성격(해변·산간·호수·도심·섬)에 맞는 시설 키워드만 전개했다. 산간 지역에 오션뷰 키워드는 없다.'),
('', ''),
('⚠ 반드시 먼저 읽을 것', ''),
('', '이 키워드는 검색 패턴으로 생성한 것이지 실제 검색 데이터가 아니다.'),
('', '네이버 검색광고 > 도구 > 키워드도구 에서 월간검색수를 받아 [월간검색수] 열을 채운 뒤'),
('', '월 10 미만은 걷어내야 한다. 앞선 단일 지역 검증에서 저장분의 89%가 한 번도 쓰이지 않았다.'),
('', ''),
('티어의 뜻', ''),
('주력', '페이지의 대표 키워드 후보. 한 페이지에 1개만 쓴다.'),
('보조', 'h2/h3 와 본문에 배치. 페이지당 3~5개.'),
('롱테일', '블로그·상세 페이지용. 검색량 확인 후 취사선택.'),
('태그', '사이트 필터 UI 용. SEO 키워드가 아니다.'),
('', ''),
('한 페이지에 몇 개를 넣나', ''),
('', 'title 1개 · h1 1개 · meta description 2~3개 · h2/h3 3~5개 · 본문 5~10개'),
('', 'meta keywords 태그는 쓰지 않는다 (구글은 2009년부터 랭킹에 반영하지 않는다).'),
('', '한 페이지에 주력을 여러 개 넣으면 주제가 희석돼 어느 것으로도 안 잡힌다.'),
('', ''),
('AEO (답변엔진)', ''),
('', '[카테고리=질문형] 행이 AEO 용이다. FAQPage 구조화 데이터로 8~15쌍 넣는다.'),
('', '답변은 2~3문장, 업체 정보에 근거한 사실만 쓴다.'),
('', ''),
('다음 단계', ''),
('', '1. 키워드도구로 [월간검색수]·[경쟁도] 채우기'),
('', '2. 월 10 미만 행 제거'),
('', '3. 관련도 높음 + 볼륨 중간 + 경쟁 낮음 조합을 우선 채택'),
('', '4. [추천 배치] 열대로 페이지에 배분'),
]
ws.append(['항목', '내용'])
for a, b in guide:
ws.append([a, b])
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=2):
bold = bool(row[0].value) and not row[1].value
row[0].font = Font(size=10, bold=True, color=ACC if bold else INK)
row[1].font = Font(size=10, color=INK)
row[1].alignment = Alignment(wrap_text=True, vertical='center')
style_header(ws, 2)
widths(ws, [22, 100])
wb.save(OUT)
print(f'{OUT}')
print(f' 시트: ' + ', '.join(s.title for s in wb.worksheets))
print(f' 키워드 {len(items):,}행 / 지역 {len(regions)}')

View File

@ -0,0 +1,90 @@
/**
* · .
* npx tsx scripts/import-related.ts data/related-keywords.csv [--apply]
*
* (CSV) JSON.
* relKeyword, monthlyPcQcCnt, monthlyMobileQcCnt, compIdx
*
* API 이유: 검색광고 API ·HMAC
* . ,
* API .
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize';
const DATASET = 'data/gunsan-pension-keywords.json';
interface Related { keyword: string; volumePc: number; volumeMobile: number; competition: string | null }
function parse(path: string): Related[] {
const raw = readFileSync(path, 'utf8');
if (path.endsWith('.json')) {
return (JSON.parse(raw) as any[]).map(toRelated);
}
const lines = raw.split(/\r?\n/).filter((l) => l.trim() && !l.trimStart().startsWith('#'));
const head = lines.shift()!.split(',').map((h) => h.trim());
return lines.map((line) => {
const cells = line.split(',').map((c) => c.trim());
const o: Record<string, string> = {};
head.forEach((h, i) => (o[h] = cells[i] ?? ''));
return toRelated(o);
});
}
function toRelated(o: any): Related {
const num = (v: unknown) => {
const n = Number(String(v ?? '').replace(/[^0-9]/g, ''));
return Number.isFinite(n) ? n : 0;
};
return {
keyword: String(o.relKeyword ?? o.keyword ?? '').trim(),
volumePc: num(o.monthlyPcQcCnt),
volumeMobile: num(o.monthlyMobileQcCnt),
competition: o.compIdx ? String(o.compIdx).trim() : null,
};
}
function main() {
const file = process.argv[2];
const apply = process.argv.includes('--apply');
if (!file) { console.error('사용법: tsx scripts/import-related.ts <csv|json> [--apply]'); process.exit(1); }
const ds = JSON.parse(readFileSync(DATASET, 'utf8'));
const existing = new Map<string, any>(ds.items.map((i: any) => [normalizeKeyword(i.keyword), i]));
const rows = parse(file).filter((r) => r.keyword);
let added = 0, enriched = 0, skipped = 0;
const newItems: any[] = [];
for (const r of rows) {
const canonical = canonicalizeKeyword(r.keyword);
const norm = normalizeKeyword(canonical);
if (!norm || isBanned(canonical)) { skipped++; continue; }
const volume = r.volumePc + r.volumeMobile;
const hit = existing.get(norm);
if (hit) {
hit.volume = volume; hit.competition = r.competition; hit.volumeSource = 'naver-searchad';
enriched++;
} else {
const item = {
keyword: canonical, intent: 'local', kind: 'keyword', category: '연관',
relevance: 0.7, volume, competition: r.competition, volumeSource: 'naver-searchad',
};
newItems.push(item); existing.set(norm, item); added++;
}
}
console.log(`입력 ${rows.length}건 → 신규 ${added} · 기존 보강 ${enriched} · 제외 ${skipped}`);
if (newItems.length) {
console.log('\n신규 예시');
for (const i of newItems.slice(0, 8)) console.log(` ${i.keyword} (월 ${i.volume}, 경쟁 ${i.competition ?? '-'})`);
}
if (!apply) { console.log('\n파일에 쓰려면 --apply 를 붙일 것.'); return; }
ds.items = [...ds.items, ...newItems];
ds.count = ds.items.length;
writeFileSync(DATASET, JSON.stringify(ds, null, 2) + '\n');
console.log(`\n✅ ${DATASET}${ds.count}`);
}
main();

View File

@ -0,0 +1,103 @@
/**
* data/gunsan-pension-keywords.json pgvector .
* npx tsx scripts/ingest-dataset.ts
*
* 정책: 주기 . 1 .
* ( ) ,
* "검토 목록" . ( README )
*/
import { readFileSync } from 'node:fs';
import { createSql, toVector } from '../src/db/db';
import { normalizeKeyword, canonicalizeKeyword, isBanned } from '../src/keywords/normalize';
import { LocalEmbeddingProvider } from '../src/embedding/local.provider';
import { MockEmbeddingProvider } from '../src/embedding/mock.provider';
import { env } from '../src/config/env';
type Item = { keyword: string; intent: string; kind: string; category: string; relevance: number };
async function main() {
const sql = createSql();
const embedder =
env.embedding.provider === 'mock' ? new MockEmbeddingProvider() : new LocalEmbeddingProvider();
const raw = JSON.parse(readFileSync('data/gunsan-pension-keywords.json', 'utf8'));
const items: Item[] = raw.items;
console.log(`📦 데이터셋 ${items.length}건 · 임베딩 ${embedder.name}`);
// 1) 파일 내 어휘 중복 정리
const byNorm = new Map<string, { item: Item; aliases: string[] }>();
let banned = 0;
for (const it of items) {
const canonical = canonicalizeKeyword(it.keyword);
const norm = normalizeKeyword(it.keyword);
if (!norm || isBanned(canonical)) { banned++; continue; }
const hit = byNorm.get(norm);
if (hit) {
if (!hit.aliases.includes(canonical) && hit.item.keyword !== canonical) hit.aliases.push(canonical);
if (it.relevance > hit.item.relevance) hit.item = it;
} else {
byNorm.set(norm, { item: { ...it, keyword: canonical }, aliases: [] });
}
}
const uniq = [...byNorm.entries()];
console.log(` 어휘 중복제거 → ${uniq.length}건 (병합 ${items.length - uniq.length - banned}, 금칙어 ${banned})`);
// 2) 임베딩 (배치)
const t0 = Date.now();
const vecs = await embedder.embed(uniq.map(([, v]) => v.item.keyword), 'passage');
console.log(` 임베딩 ${vecs.length}건 · ${embedder.dimensions}차원 · ${Date.now() - t0}ms`);
// 3) 적재
const region = 'kr.jeonbuk.gunsan';
const industry = 'stay.pension';
let inserted = 0, updated = 0;
await sql.begin(async (tx) => {
for (let i = 0; i < uniq.length; i++) {
const [norm, v] = uniq[i];
const res = await tx<Array<{ inserted: boolean }>>`
INSERT INTO keyword
(canonical, normalized, locale, aliases, intent, kind, category, source,
industry_id, region_id, embedding)
VALUES (${v.item.keyword}, ${norm}, 'ko-KR', ${v.aliases},
${v.item.intent}::keyword_intent, ${v.item.kind}, ${v.item.category}, 'dataset',
${industry}, ${region}, ${toVector(vecs[i])}::vector)
ON CONFLICT (normalized, locale) DO UPDATE SET
canonical = EXCLUDED.canonical, aliases = EXCLUDED.aliases, intent = EXCLUDED.intent,
kind = EXCLUDED.kind, category = EXCLUDED.category, source = EXCLUDED.source,
embedding = EXCLUDED.embedding, updated_at = now()
RETURNING (xmax = 0) AS inserted`;
res[0]?.inserted ? inserted++ : updated++;
if (i % 100 === 0) process.stdout.write(`\r 적재 ${i}/${uniq.length}`);
}
});
console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `);
// 4) 데이터셋에서 빠진 행 정리.
// upsert 만 하면 재빌드할 때마다 이전 판본 잔여 행이 쌓여 사전이 계속 커진다.
// (실제로 974건 데이터셋인데 사전이 1072건까지 불어 있었다)
const wanted = uniq.map(([norm]) => norm);
const stale = await sql<Array<{ canonical: string }>>`
DELETE FROM keyword
WHERE source = 'dataset' AND locale = 'ko-KR' AND NOT (normalized = ANY(${wanted}))
RETURNING canonical`;
console.log(` 🧹 이전 판본 잔여 ${stale.length}건 삭제` +
(stale.length ? ` (예: ${stale.slice(0, 4).map((r) => r.canonical).join(', ')})` : ''));
const [{ n: total }] = await sql<Array<{ n: number }>>`
SELECT count(*)::int AS n FROM keyword WHERE source = 'dataset'`;
console.log(` 📚 사전 현재 ${total}`);
// 5) 벡터 근접쌍 — 자동 병합하지 않고 검토 목록으로만
const near = await sql<Array<{ a: string; b: string; sim: number }>>`
SELECT k1.canonical AS a, k2.canonical AS b, 1 - (k1.embedding <=> k2.embedding) AS sim
FROM keyword k1 JOIN keyword k2
ON k1.id < k2.id AND k1.embedding <=> k2.embedding < 0.02
WHERE k1.source = 'dataset' AND k2.source = 'dataset'
ORDER BY sim DESC LIMIT 15`;
console.log(`\n🔍 벡터 근접쌍 검토 목록 (cos ≥ 0.98, 자동 병합 안 함) — 상위 ${near.length}`);
for (const n of near) console.log(` ${Number(n.sim).toFixed(4)} ${n.a}${n.b}`);
await sql.end();
}
main().catch((e) => { console.error('❌', e); process.exit(1); });

View File

@ -0,0 +1,105 @@
/**
* pgvector .
* npx tsx scripts/ingest-nationwide.ts
*
* (source='dataset') .
* source='nationwide' , .
*/
import { readFileSync } from 'node:fs';
import { createSql, toVector } from '../src/db/db';
import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize';
import { LocalEmbeddingProvider } from '../src/embedding/local.provider';
import { MockEmbeddingProvider } from '../src/embedding/mock.provider';
import { env } from '../src/config/env';
const SOURCE = 'nationwide';
interface Item {
sido: string; region: string; regionKey: string; regionType: string;
keyword: string; kind: string; intent: string; category: string; tier: string; relevance: number;
}
async function main() {
const sql = createSql();
const embedder =
env.embedding.provider === 'mock' ? new MockEmbeddingProvider() : new LocalEmbeddingProvider();
const ds = JSON.parse(readFileSync('data/nationwide-pension-keywords.json', 'utf8'));
const items: Item[] = ds.items;
const regions = JSON.parse(readFileSync('data/regions.json', 'utf8')).regions as
Array<{ sido: string; name: string; key: string }>;
console.log(`📦 ${items.length}건 / ${ds.regionCount}개 지역 · 임베딩 ${embedder.name}`);
// 1) 지역 계층 심기 (시도 → 시군). ltree 라벨은 ASCII 만 허용한다.
const nodes = new Map<string, string>();
for (const r of regions) {
const sidoKey = r.key.split('.').slice(0, -1).join('.');
nodes.set(sidoKey, r.sido);
nodes.set(r.key, r.name);
}
nodes.set('kr', '대한민국');
for (const [key, name] of [...nodes].sort((a, b) => a[0].length - b[0].length)) {
await sql`INSERT INTO region (id, path, name) VALUES (${key}, ${key}::ltree, ${name})
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`;
}
console.log(` 🗺 지역 노드 ${nodes.size}개 등록`);
// 2) 어휘 중복 정리 — 키는 (지역, 정규화 키워드)
const byKey = new Map<string, { item: Item; norm: string }>();
let banned = 0;
for (const it of items) {
const canonical = canonicalizeKeyword(it.keyword);
const n = normalizeKeyword(canonical);
if (!n || isBanned(canonical)) { banned++; continue; }
const k = `${it.regionKey}|${n}`;
if (!byKey.has(k)) byKey.set(k, { item: { ...it, keyword: canonical }, norm: n });
}
const uniq = [...byKey.values()];
console.log(` 어휘 중복제거 → ${uniq.length}건 (금칙어 ${banned})`);
// 3) 임베딩
const t0 = Date.now();
const vecs = await embedder.embed(uniq.map((u) => u.item.keyword), 'passage');
console.log(` 임베딩 ${vecs.length}건 · ${embedder.dimensions}차원 · ${Date.now() - t0}ms`);
// 4) 적재.
// keyword.normalized 는 (normalized, locale) 유니크다. 지역이 달라도 같은 문자열이면
// 한 행으로 합쳐진다 — '오션뷰' 같은 태그가 그렇다. 지역 고유 키워드는 지명이 들어가
// 자연히 구분되므로 문제되지 않는다.
let inserted = 0, updated = 0;
await sql.begin(async (tx) => {
for (let i = 0; i < uniq.length; i++) {
const { item, norm } = uniq[i];
const res = await tx<Array<{ inserted: boolean }>>`
INSERT INTO keyword
(canonical, normalized, locale, aliases, intent, kind, category, source,
industry_id, region_id, embedding)
VALUES (${item.keyword}, ${norm}, 'ko-KR', ${[]},
${item.intent}::keyword_intent, ${item.kind}, ${item.category}, ${SOURCE},
'stay.pension', ${item.kind === 'tag' ? null : item.regionKey},
${toVector(vecs[i])}::vector)
ON CONFLICT (normalized, locale) DO UPDATE SET
canonical = EXCLUDED.canonical, intent = EXCLUDED.intent, kind = EXCLUDED.kind,
category = EXCLUDED.category, source = EXCLUDED.source,
region_id = COALESCE(keyword.region_id, EXCLUDED.region_id),
embedding = EXCLUDED.embedding, updated_at = now()
RETURNING (xmax = 0) AS inserted`;
res[0]?.inserted ? inserted++ : updated++;
if (i % 500 === 0) process.stdout.write(`\r 적재 ${i}/${uniq.length}`);
}
});
console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `);
// 5) 이 출처 안에서만 잔여 정리
const wanted = uniq.map((u) => u.norm);
const stale = await sql`
DELETE FROM keyword WHERE source = ${SOURCE} AND NOT (normalized = ANY(${wanted})) RETURNING id`;
console.log(` 🧹 이전 판본 잔여 ${stale.length}건 삭제`);
const counts = await sql<Array<{ source: string; n: number }>>`
SELECT source, count(*)::int AS n FROM keyword GROUP BY source ORDER BY n DESC`;
console.log(' 📚 사전 현황: ' + counts.map((c) => `${c.source} ${c.n}`).join(' · '));
await sql.end();
}
main().catch((e) => { console.error('❌', e); process.exit(1); });

View File

@ -0,0 +1,35 @@
/**
* .
* npx tsx scripts/purge-nondataset.ts [--apply]
*
* (keyword) . generate
* source='llm' .
*/
import { createSql } from '../src/db/db';
async function main() {
const apply = process.argv.includes('--apply');
const sql = createSql();
const rows = await sql<Array<{ source: string; n: number; sample: string[] }>>`
SELECT source, count(*)::int AS n, (array_agg(canonical ORDER BY canonical))[1:6] AS sample
FROM keyword GROUP BY source ORDER BY n DESC`;
console.log('출처별 현황');
for (const r of rows) console.log(` ${r.source.padEnd(10)} ${String(r.n).padStart(5)} ${r.sample.join(', ')}`);
const doomed = await sql<Array<{ n: number }>>`
SELECT count(*)::int AS n FROM keyword WHERE source NOT IN ('dataset', 'manual')`;
const n = doomed[0]?.n ?? 0;
if (n === 0) { console.log('\n정리 대상 없음'); await sql.end(); return; }
if (!apply) {
console.log(`\n정리 대상 ${n}건. 실제로 지우려면 --apply 를 붙일 것.`);
await sql.end();
return;
}
const del = await sql`DELETE FROM keyword WHERE source NOT IN ('dataset', 'manual') RETURNING id`;
console.log(`\n✅ ${del.length}건 삭제 (merchant_keyword 는 CASCADE)`);
await sql.end();
}
main().catch((e) => { console.error('❌', e); process.exit(1); });

View File

@ -0,0 +1,3 @@
# 엑셀 산출 스크립트용 (scripts/export-xlsx.py, export-db-xlsx.py)
# pip3 install -r scripts/requirements.txt
openpyxl>=3.1

41
ontology/scripts/setup.sh Executable file
View File

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# 클론 직후 로컬 세팅 한 번에.
# npm run setup
set -euo pipefail
cd "$(dirname "$0")/.."
step() { printf '\n\033[1m▶ %s\033[0m\n' "$1"; }
step "사전 점검"
command -v docker >/dev/null || { echo "❌ docker 가 필요합니다"; exit 1; }
docker info >/dev/null 2>&1 || { echo "❌ Docker Desktop 을 실행해 주세요"; exit 1; }
node -e 'process.exit(+process.versions.node.split(".")[0] >= 20 ? 0 : 1)' \
|| { echo "❌ Node 20 이상이 필요합니다 (현재 $(node -v))"; exit 1; }
echo " docker ok · node $(node -v)"
step ".env 준비"
if [ -f .env ]; then echo " 이미 있음 — 건너뜀"; else cp .env.example .env; echo " .env.example → .env"; fi
step "컨테이너 기동 (postgres+pgvector, redis)"
docker compose up -d --wait
step "스키마 마이그레이션"
npm run --silent db:migrate
step "기준 데이터 시드 (업종·지역·데모 업체)"
npm run --silent db:seed
step "키워드 적재 — 군산 상세"
echo " 최초 1회 임베딩 모델을 내려받습니다 (약 120MB, 1~2분)"
npm run --silent dataset:ingest 2>&1 | grep -vE '^\s*적재 [0-9]+/' || true
step "키워드 적재 — 전국 54개 지역"
npm run --silent dataset:ingest-nationwide 2>&1 | grep -vE '^\s*적재 [0-9]+/' || true
step "완료"
cat <<'MSG'
npm start → http://localhost:3100
http://localhost:3100/demo 매칭 콘솔 (입력창에 "스테이 머뭄")
엑셀 산출이 필요하면: pip3 install -r scripts/requirements.txt
MSG

103
ontology/scripts/smoke.ts Normal file
View File

@ -0,0 +1,103 @@
/**
* .
* npm run db:reset && npm start ( )
* npm run smoke
*/
const BASE = process.env.BASE_URL ?? 'http://localhost:3100';
const j = async (method: string, path: string, body?: unknown) => {
const res = await fetch(`${BASE}${path}`, {
method,
headers: body ? { 'content-type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (!res.ok) throw new Error(`${method} ${path}${res.status} ${text}`);
return text ? JSON.parse(text) : null;
};
const h = (t: string) => console.log(`\n\x1b[1m${t}\x1b[0m`);
async function main() {
h('0. health');
console.log(' ', await j('GET', '/health'));
h('1. site-1001 생성 (첫 업체 — 전부 신규)');
const a = await j('POST', '/v1/merchants/site-1001/generate?sync=true');
printStats(a);
h('2. site-1002 생성 (같은 강남/미용실 — 중복제거 발동)');
const b = await j('POST', '/v1/merchants/site-1002/generate?sync=true');
printStats(b);
printDetails(b);
h('3. publish 웹훅 + 표기 변형 (trigram 단계)');
const c = await j('POST', '/v1/merchants/publish', {
externalId: 'site-1003',
name: '강남 뷰티랩',
industryId: 'beauty.hair',
regionId: 'kr.seoul.gangnam',
description: '강남 미용실. 염색 전문.',
profile: { services: ['뿌리염색약', '여성펌'], features: ['주차가능'] },
sync: true,
});
printStats(c.generation);
printDetails(c.generation);
h('4. SEO payload');
const seo = await j('GET', '/v1/sites/site-1001/seo?limit=8');
console.log(' title :', seo.title);
console.log(' description:', seo.description);
console.log(' keywords :', seo.keywords.join(', '));
h('5. AEO payload');
const aeo = await j('GET', '/v1/sites/site-1001/aeo?limit=3');
for (const f of aeo.faqs) console.log(` Q. ${f.question}\n A. ${f.answer}`);
h('6. 의미 기반 키워드 검색');
const found = await j('POST', '/v1/keywords/search', { query: '강남 미용실 예약하고 싶어요', limit: 5 });
for (const r of found) console.log(` ${r.score.toFixed(3)} ${r.canonical} (${r.intent}, ${r.usage_count}개 업체)`);
h('7. 성과 피드백 → 저성과 강등');
console.log(
' ',
await j('POST', '/v1/sites/site-1001/performance', {
items: [
{ keyword: '강남 미용실 후기', impressions: 500, clicks: 0 },
{ keyword: '강남 미용실', impressions: 300, clicks: 40 },
],
}),
);
h('8. 비동기 큐 (BullMQ)');
console.log(' ', await j('POST', '/v1/merchants/site-2001/generate'));
for (let i = 0; i < 30; i++) {
const s = await j('GET', '/v1/sites/site-2001/seo?limit=5');
if (s.keywords.length) {
console.log(' 워커 처리 완료 →', s.keywords.join(', '));
break;
}
await new Promise((r) => setTimeout(r, 500));
}
console.log('\n✅ smoke 완료');
}
function printStats(s: any) {
console.log(
` 후보 ${s.candidates} → 신규 ${s.created} / 중복(정확 ${s.matchedExact}, 표기 ${s.matchedTrigram}, 의미 ${s.matchedVector})` +
` / 차단 ${s.rejected} / 연결 ${s.linked} / QA ${s.qaCreated} (${s.durationMs}ms)`,
);
}
function printDetails(s: any) {
for (const d of s.details ?? []) {
const sim = d.similarity != null ? ` (sim=${d.similarity.toFixed(3)} → '${d.matchedTo}')` : '';
console.log(` ${d.action.padEnd(16)} ${d.candidate}${sim}`);
}
}
main().catch((e) => {
console.error('\n❌', e.message);
process.exit(1);
});

View File

@ -0,0 +1,36 @@
import { BullModule } from '@nestjs/bullmq';
import { Controller, Get, Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { env } from './config/env';
import { DbModule } from './db/db.module';
import { EmbeddingModule } from './embedding/embedding.module';
import { GenerationModule } from './generation/generation.module';
import { MerchantsHttpModule } from './merchants/merchants.controller.module';
import { ServingModule } from './serving/serving.module';
@Controller()
class HealthController {
@Get('health')
health() {
return {
status: 'ok',
llmProvider: env.llm.provider,
embeddingProvider: env.embedding.provider,
ts: new Date().toISOString(),
};
}
}
@Module({
imports: [
DbModule,
EmbeddingModule,
ScheduleModule.forRoot(),
BullModule.forRoot({ connection: { host: env.redis.host, port: env.redis.port } }),
GenerationModule,
MerchantsHttpModule,
ServingModule,
],
controllers: [HealthController],
})
export class AppModule {}

View File

@ -0,0 +1,34 @@
import 'dotenv/config';
const num = (v: string | undefined, d: number) => (v === undefined || v === '' ? d : Number(v));
export const env = {
port: num(process.env.PORT, 3100),
databaseUrl: process.env.DATABASE_URL ?? 'postgres://ontology:ontology@localhost:55432/ontology',
redis: {
host: process.env.REDIS_HOST ?? 'localhost',
port: num(process.env.REDIS_PORT, 56379),
},
llm: {
provider: (process.env.LLM_PROVIDER ?? 'mock') as 'mock' | 'openai',
apiKey: process.env.OPENAI_API_KEY ?? '',
model: process.env.OPENAI_MODEL ?? 'gpt-4.1-mini',
embeddingModel: process.env.OPENAI_EMBEDDING_MODEL ?? 'text-embedding-3-small',
},
embedding: {
provider: (process.env.EMBEDDING_PROVIDER ?? 'local') as 'mock' | 'local' | 'openai',
localModel: process.env.EMBEDDING_LOCAL_MODEL ?? 'Xenova/multilingual-e5-small',
},
dedup: {
cosineThreshold: num(process.env.DEDUP_COSINE_THRESHOLD, 0.99),
trigramThreshold: num(process.env.DEDUP_TRIGRAM_THRESHOLD, 0.6),
candidateLimit: num(process.env.DEDUP_CANDIDATE_LIMIT, 20),
},
generation: {
targetKeywords: num(process.env.GENERATION_TARGET_KEYWORDS, 15),
refreshIntervalDays: num(process.env.REFRESH_INTERVAL_DAYS, 30),
},
} as const;
export const EMBEDDING_DIM = 384;
export const PROMPT_VERSION = 'kw-v1';

View File

@ -0,0 +1,16 @@
import { Global, Module, OnModuleDestroy } from '@nestjs/common';
import { createSql, Sql } from './db';
export const PG = Symbol('PG');
@Global()
@Module({
providers: [{ provide: PG, useFactory: () => createSql() }],
exports: [PG],
})
export class DbModule implements OnModuleDestroy {
constructor() {}
async onModuleDestroy() {}
}
export type { Sql };

17
ontology/src/db/db.ts Normal file
View File

@ -0,0 +1,17 @@
import postgres from 'postgres';
import { env } from '../config/env';
export type Sql = postgres.Sql<{}>;
export const createSql = (): Sql =>
postgres(env.databaseUrl, {
max: 10,
// pgvector 컬럼은 텍스트로 주고받는다 ('[0.1,0.2,...]')
transform: { undefined: null },
});
/** number[] -> pgvector 리터럴 */
export const toVector = (v: number[]): string => `[${v.join(',')}]`;
/** postgres.js 의 JSONValue 타입 제약 우회용 캐스트 */
export const asJson = (v: unknown) => v as Parameters<Sql['json']>[0];

View File

@ -0,0 +1,24 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { createSql } from './db';
async function main() {
const sql = createSql();
const dir = join(process.cwd(), 'drizzle');
const files = readdirSync(dir).filter((f) => f.endsWith('.sql')).sort();
for (const file of files) {
const ddl = readFileSync(join(dir, file), 'utf8');
process.stdout.write(`▶ applying ${file} ... `);
await sql.unsafe(ddl);
process.stdout.write('done\n');
}
await sql.end();
console.log('✅ migration complete');
}
main().catch((e) => {
console.error('❌ migration failed:', e);
process.exit(1);
});

119
ontology/src/db/seed.ts Normal file
View File

@ -0,0 +1,119 @@
import { asJson, createSql } from './db';
const industries = [
['beauty', 'beauty', '뷰티'],
['beauty.hair', 'beauty.hair', '미용실'],
['beauty.nail', 'beauty.nail', '네일샵'],
['food', 'food', '음식점'],
['food.korean', 'food.korean', '한식당'],
['health', 'health', '의료'],
['health.dental', 'health.dental', '치과'],
['stay', 'stay', '숙박'],
['stay.pension', 'stay.pension', '펜션'],
];
const regions = [
['kr', 'kr', '대한민국'],
['kr.seoul', 'kr.seoul', '서울'],
['kr.seoul.gangnam', 'kr.seoul.gangnam', '강남'],
['kr.seoul.mapo', 'kr.seoul.mapo', '마포'],
['kr.busan', 'kr.busan', '부산'],
['kr.busan.haeundae', 'kr.busan.haeundae', '해운대'],
['kr.jeonbuk', 'kr.jeonbuk', '전북'],
['kr.jeonbuk.gunsan', 'kr.jeonbuk.gunsan', '군산'],
];
const merchants = [
{
externalId: 'site-1001',
name: '레브살롱',
industryId: 'beauty.hair',
regionId: 'kr.seoul.gangnam',
description: '강남역 3번 출구 앞 프라이빗 헤어살롱. 1:1 디자이너 전담 시스템.',
siteUrl: 'https://rev-salon.example.com',
profile: {
services: ['남자 커트', '여성 펌', '뿌리염색', '두피 클리닉'],
features: ['주차 가능', '심야 영업', '예약제'],
priceRange: '30,000~120,000원',
},
},
{
externalId: 'site-1002',
name: '헤어랩 강남점',
industryId: 'beauty.hair',
regionId: 'kr.seoul.gangnam',
description: '강남 대형 헤어샵. 염색과 클리닉 전문.',
siteUrl: 'https://hairlab.example.com',
profile: {
services: ['뿌리 염색', '여성 펌', '두피클리닉'],
features: ['주차가능', '단체 예약'],
priceRange: '25,000~150,000원',
},
},
{
externalId: 'site-2001',
name: '해운대 소담한상',
industryId: 'food.korean',
regionId: 'kr.busan.haeundae',
description: '해운대 해변 인근 한정식집. 제철 해산물 코스 제공.',
siteUrl: 'https://sodam.example.com',
profile: {
services: ['한정식 코스', '점심 특선', '단체 예약'],
features: ['오션뷰', '룸 완비', '발렛파킹'],
priceRange: '25,000~80,000원',
},
},
{
// 실제 업체. 공개 정보로 확인된 항목만 넣는다.
// 확인됨 : 상호, 군산 원도심(신흥동 말랭이마을 인근), 독채 2개 동, 기준 2인·최대 4인
// 미확인 : 가격, 바베큐/스파/주차/애견동반 여부 ← 사업자 확인 후 채울 것
externalId: 'site-3001',
name: '스테이머뭄',
industryId: 'stay.pension',
regionId: 'kr.jeonbuk.gunsan',
description:
'군산 원도심 말랭이마을 옆에 자리한 독채 스테이. A동·B동 두 채를 통째로 쓰며 기준 2인, 최대 4인.',
siteUrl: 'https://www.instagram.com/staymeomoom/',
profile: {
services: ['독채 대여', 'A동', 'B동'],
features: ['독채', '프라이빗', '2인 기준', '최대 4인', '원도심', '감성숙소'],
audiences: ['커플', '친구', '가족', '혼자'],
nearby: ['말랭이마을', '신흥동 일본식가옥', '동국사', '초원사진관', '이성당',
'경암동 철길마을', '근대역사박물관', '월명공원', '시간여행마을'],
address: '전북특별자치도 군산시 절골길 18 (신흥동)',
capacity: { standard: 2, max: 4 },
buildings: 2,
unverified: ['가격', '바베큐', '스파', '주차', '애견동반'],
},
},
];
async function main() {
const sql = createSql();
for (const [id, path, name] of industries) {
await sql`INSERT INTO industry (id, path, name) VALUES (${id}, ${path}::ltree, ${name})
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`;
}
for (const [id, path, name] of regions) {
await sql`INSERT INTO region (id, path, name) VALUES (${id}, ${path}::ltree, ${name})
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`;
}
for (const m of merchants) {
await sql`
INSERT INTO merchant (external_id, name, industry_id, region_id, description, profile, site_url)
VALUES (${m.externalId}, ${m.name}, ${m.industryId}, ${m.regionId},
${m.description}, ${sql.json(asJson(m.profile))}, ${m.siteUrl})
ON CONFLICT (external_id) DO UPDATE SET
name = EXCLUDED.name, description = EXCLUDED.description,
profile = EXCLUDED.profile, updated_at = now()`;
}
await sql.end();
console.log(`✅ seed: industry=${industries.length} region=${regions.length} merchant=${merchants.length}`);
}
main().catch((e) => {
console.error('❌ seed failed:', e);
process.exit(1);
});

View File

@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { env } from '../config/env';
import { LocalEmbeddingProvider } from './local.provider';
import { MockEmbeddingProvider } from './mock.provider';
import { OpenAiEmbeddingProvider } from './openai.provider';
import { EmbeddingProvider } from './types';
const IMPL = {
local: LocalEmbeddingProvider,
openai: OpenAiEmbeddingProvider,
mock: MockEmbeddingProvider,
} as const;
@Global()
@Module({
providers: [{ provide: EmbeddingProvider, useClass: IMPL[env.embedding.provider] }],
exports: [EmbeddingProvider],
})
export class EmbeddingModule {}

View File

@ -0,0 +1,48 @@
import { Injectable, Logger } from '@nestjs/common';
import { EMBEDDING_DIM, env } from '../config/env';
import { EmbedKind, EmbeddingProvider } from './types';
/** CommonJS 빌드에서 ESM 전용 패키지를 로드하기 위한 우회 (TS 가 require 로 바꾸지 못하게 한다) */
const esmImport = new Function('s', 'return import(s)') as (s: string) => Promise<any>;
/**
* multilingual-e5-small (384, onnxruntime CPU).
* 1 .
*/
@Injectable()
export class LocalEmbeddingProvider extends EmbeddingProvider {
readonly name = 'local:multilingual-e5-small';
readonly dimensions = EMBEDDING_DIM;
private readonly logger = new Logger(LocalEmbeddingProvider.name);
private extractor: any | null = null;
private loading: Promise<any> | null = null;
private async pipe() {
if (this.extractor) return this.extractor;
if (!this.loading) {
this.loading = (async () => {
const t0 = Date.now();
const { pipeline } = await esmImport('@huggingface/transformers');
const fe = await pipeline('feature-extraction', env.embedding.localModel);
this.logger.log(`model ready: ${env.embedding.localModel} (${Date.now() - t0}ms)`);
this.extractor = fe;
return fe;
})();
}
return this.loading;
}
async embed(texts: string[], kind: EmbedKind = 'passage'): Promise<number[][]> {
if (texts.length === 0) return [];
const fe = await this.pipe();
const prefixed = texts.map((t) => `${kind}: ${t}`);
const out: number[][] = [];
const BATCH = 64;
for (let i = 0; i < prefixed.length; i += BATCH) {
const slice = prefixed.slice(i, i + BATCH);
const res = await fe(slice, { pooling: 'mean', normalize: true });
out.push(...(res.tolist() as number[][]));
}
return out;
}
}

View File

@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
import { EMBEDDING_DIM } from '../config/env';
import { hashEmbedding } from '../llm/mock.provider';
import { EmbedKind, EmbeddingProvider } from './types';
/** 모델 다운로드 없이 파이프라인을 돌리기 위한 문자 bigram 해싱 임베딩. 의미는 잡지 못한다. */
@Injectable()
export class MockEmbeddingProvider extends EmbeddingProvider {
readonly name = 'mock:bigram-hash';
readonly dimensions = EMBEDDING_DIM;
async embed(texts: string[], _kind: EmbedKind = 'passage'): Promise<number[][]> {
return texts.map((t) => hashEmbedding(t, EMBEDDING_DIM));
}
}

View File

@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import OpenAI from 'openai';
import { EMBEDDING_DIM, env } from '../config/env';
import { EmbedKind, EmbeddingProvider } from './types';
@Injectable()
export class OpenAiEmbeddingProvider extends EmbeddingProvider {
readonly name = `openai:${env.llm.embeddingModel}`;
readonly dimensions = EMBEDDING_DIM;
private readonly client = new OpenAI({ apiKey: env.llm.apiKey });
async embed(texts: string[], _kind: EmbedKind = 'passage'): Promise<number[][]> {
if (texts.length === 0) return [];
const res = await this.client.embeddings.create({
model: env.llm.embeddingModel,
input: texts,
dimensions: EMBEDDING_DIM, // 스키마와 차원을 맞춘다
});
return res.data.map((d) => d.embedding as number[]);
}
}

View File

@ -0,0 +1,8 @@
/** e5 계열은 query 와 passage 를 비대칭으로 인코딩한다 — 검색 품질에 직접 영향. */
export type EmbedKind = 'query' | 'passage';
export abstract class EmbeddingProvider {
abstract readonly name: string;
abstract readonly dimensions: number;
abstract embed(texts: string[], kind?: EmbedKind): Promise<number[][]>;
}

View File

@ -0,0 +1,20 @@
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { KeywordsModule } from '../keywords/keywords.module';
import { LlmModule } from '../llm/llm.module';
import { MerchantsModule } from '../merchants/merchants.module';
import { GenerationProcessor } from './generation.processor';
import { GENERATION_QUEUE, GenerationQueue } from './generation.queue';
import { GenerationService } from './generation.service';
@Module({
imports: [
BullModule.registerQueue({ name: GENERATION_QUEUE }),
LlmModule,
KeywordsModule,
MerchantsModule,
],
providers: [GenerationService, GenerationQueue, GenerationProcessor],
exports: [GenerationService, GenerationQueue],
})
export class GenerationModule {}

View File

@ -0,0 +1,21 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { GenerationService } from './generation.service';
import { GENERATION_QUEUE, GenerationJob } from './generation.queue';
@Processor(GENERATION_QUEUE, { concurrency: 2 })
export class GenerationProcessor extends WorkerHost {
private readonly logger = new Logger(GenerationProcessor.name);
constructor(private readonly generation: GenerationService) {
super();
}
async process(job: Job<GenerationJob>) {
const { merchantId, trigger } = job.data;
this.logger.log(`processing ${job.id} (${trigger})`);
const stats = await this.generation.runForMerchant(merchantId, trigger);
return { ...stats, details: undefined };
}
}

View File

@ -0,0 +1,50 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Queue } from 'bullmq';
import { env } from '../config/env';
import { MerchantsService } from '../merchants/merchants.service';
import { GenerationTrigger } from './generation.service';
export const GENERATION_QUEUE = 'keyword-generation';
export interface GenerationJob {
merchantId: string;
trigger: GenerationTrigger;
}
@Injectable()
export class GenerationQueue {
private readonly logger = new Logger(GenerationQueue.name);
constructor(
@InjectQueue(GENERATION_QUEUE) private readonly queue: Queue<GenerationJob>,
private readonly merchants: MerchantsService,
) {}
async enqueue(merchantId: string, trigger: GenerationTrigger): Promise<string> {
// 짧은 시간 내 같은 업체가 여러 번 발행돼도 한 번만 처리 (60초 dedupe 창)
const job = await this.queue.add(
'generate',
{ merchantId, trigger },
{
deduplication: { id: `${merchantId}-${trigger}`, ttl: 60_000 },
removeOnComplete: 100,
removeOnFail: 500,
attempts: 3,
backoff: { type: 'exponential', delay: 5_000 },
},
);
return String(job.id);
}
/** 주기 리프레시: 매일 03:00, N일 지난 업체를 큐에 적재 */
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async scheduleRefresh() {
const stale = await this.merchants.findStale(env.generation.refreshIntervalDays, 200);
for (const m of stale) {
await this.enqueue(m.id, 'scheduled');
}
if (stale.length) this.logger.log(`scheduled refresh queued: ${stale.length} merchants`);
}
}

View File

@ -0,0 +1,216 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { PG } from '../db/db.module';
import { asJson, Sql, toVector } from '../db/db';
import { env, PROMPT_VERSION } from '../config/env';
import { DedupAction, DedupService } from '../keywords/dedup.service';
import { canonicalizeKeyword, normalizeKeyword } from '../keywords/normalize';
import { EmbeddingProvider } from '../embedding/types';
import { LlmProvider, MerchantContext } from '../llm/types';
import { MerchantsService } from '../merchants/merchants.service';
export type GenerationTrigger = 'published' | 'scheduled' | 'manual';
export interface GenerationStats {
runId: string;
merchantId: string;
merchantName: string;
provider: string;
model: string;
candidates: number;
created: number;
matchedExact: number;
matchedTrigram: number;
matchedVector: number;
rejected: number;
linked: number;
qaCreated: number;
durationMs: number;
details: Array<{ candidate: string; action: DedupAction; matchedTo?: string; similarity?: number }>;
}
@Injectable()
export class GenerationService {
private readonly logger = new Logger(GenerationService.name);
constructor(
@Inject(PG) private readonly sql: Sql,
private readonly merchants: MerchantsService,
private readonly llm: LlmProvider,
private readonly embedder: EmbeddingProvider,
private readonly dedup: DedupService,
) {}
async runForMerchant(
idOrExternalId: string,
trigger: GenerationTrigger = 'manual',
targetCount = env.generation.targetKeywords,
): Promise<GenerationStats> {
const startedAt = Date.now();
const merchant = await this.merchants.findWithTaxonomy(idOrExternalId);
const runRows = await this.sql<Array<{ id: string }>>`
INSERT INTO generation_run (merchant_id, provider, model, prompt_version, trigger, status, input)
VALUES (${merchant.id}, ${this.llm.name}, ${this.llm.model}, ${PROMPT_VERSION},
${trigger}, 'running', ${this.sql.json(asJson({ externalId: merchant.external_id }))})
RETURNING id`;
const runId = runRows[0].id;
try {
const existing = await this.existingKeywordsFor(merchant.id, merchant.industry_id);
const ctx: MerchantContext = {
externalId: merchant.external_id,
name: merchant.name,
description: merchant.description,
industryName: merchant.industry_name,
industryPath: merchant.industry_path,
regionName: merchant.region_name,
regionPath: merchant.region_path,
profile: merchant.profile ?? {},
existingKeywords: existing,
targetCount,
};
const output = await this.llm.generate(ctx);
// 임베딩은 한 번에 배치 호출 (후보 수만큼 왕복하지 않는다)
const texts = output.keywords.map((k) => canonicalizeKeyword(k.keyword));
const embeddings = texts.length ? await this.embedder.embed(texts, 'passage') : [];
const stats: GenerationStats = {
runId,
merchantId: merchant.id,
merchantName: merchant.name,
provider: this.llm.name,
model: output.model,
candidates: output.keywords.length,
created: 0,
matchedExact: 0,
matchedTrigram: 0,
matchedVector: 0,
rejected: 0,
linked: 0,
qaCreated: 0,
durationMs: 0,
details: [],
};
for (let i = 0; i < output.keywords.length; i++) {
const cand = output.keywords[i];
const result = await this.dedup.resolve({
raw: cand.keyword,
intent: cand.intent,
embedding: embeddings[i],
locale: 'ko-KR',
industryId: merchant.industry_id,
regionId: merchant.region_id,
});
stats.details.push({
candidate: canonicalizeKeyword(cand.keyword),
action: result.action,
matchedTo: result.matchedTo,
similarity: result.similarity,
});
switch (result.action) {
case 'created': stats.created++; break;
case 'matched_exact': stats.matchedExact++; break;
case 'matched_trigram': stats.matchedTrigram++; break;
case 'matched_vector': stats.matchedVector++; break;
case 'rejected_banned': stats.rejected++; break;
}
if (result.keywordId) {
const linked = await this.linkKeyword(merchant.id, result.keywordId, cand.relevance, cand.rationale);
if (linked) stats.linked++;
}
}
stats.qaCreated = await this.upsertQaPairs(merchant.id, output.qaPairs);
await this.merchants.markGenerated(merchant.id);
stats.durationMs = Date.now() - startedAt;
await this.sql`
UPDATE generation_run
SET status = 'succeeded',
output = ${this.sql.json(asJson(output))},
stats = ${this.sql.json(asJson({ ...stats, details: undefined }))},
finished_at = now()
WHERE id = ${runId}`;
this.logger.log(
`[${merchant.name}] cand=${stats.candidates} new=${stats.created} ` +
`dup(exact/trg/vec)=${stats.matchedExact}/${stats.matchedTrigram}/${stats.matchedVector} ` +
`rejected=${stats.rejected} qa=${stats.qaCreated} ${stats.durationMs}ms`,
);
return stats;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.sql`
UPDATE generation_run
SET status = 'failed', error = ${message}, finished_at = now()
WHERE id = ${runId}`;
throw err;
}
}
/** 프롬프트에 넣을 "이미 보유한 키워드": 자기 것 + 같은 업종에서 많이 쓰는 것 */
private async existingKeywordsFor(merchantId: string, industryId: string | null): Promise<string[]> {
const rows = await this.sql<Array<{ canonical: string }>>`
SELECT DISTINCT k.canonical
FROM keyword k
LEFT JOIN merchant_keyword mk ON mk.keyword_id = k.id AND mk.merchant_id = ${merchantId}
WHERE mk.merchant_id IS NOT NULL
OR (${industryId}::text IS NOT NULL AND k.industry_id = ${industryId} AND k.usage_count > 0)
ORDER BY k.canonical
LIMIT 100`;
return rows.map((r) => r.canonical);
}
private async linkKeyword(
merchantId: string,
keywordId: string,
relevance: number,
rationale: string,
): Promise<boolean> {
const status = relevance >= 0.5 ? 'active' : 'candidate';
const rows = await this.sql<Array<{ inserted: boolean }>>`
INSERT INTO merchant_keyword (merchant_id, keyword_id, relevance, source, status, rationale)
VALUES (${merchantId}, ${keywordId}, ${relevance}, 'llm', ${status}, ${rationale})
ON CONFLICT (merchant_id, keyword_id) DO UPDATE SET
relevance = GREATEST(merchant_keyword.relevance, EXCLUDED.relevance),
rationale = COALESCE(EXCLUDED.rationale, merchant_keyword.rationale),
updated_at = now()
RETURNING (xmax = 0) AS inserted`;
if (rows[0]?.inserted) {
await this.sql`UPDATE keyword SET usage_count = usage_count + 1 WHERE id = ${keywordId}`;
return true;
}
return false;
}
private async upsertQaPairs(
merchantId: string,
pairs: Array<{ question: string; answer: string }>,
): Promise<number> {
if (pairs.length === 0) return 0;
const embeddings = await this.embedder.embed(pairs.map((p) => p.question), 'passage');
let created = 0;
for (let i = 0; i < pairs.length; i++) {
const p = pairs[i];
const nq = normalizeKeyword(p.question);
if (!nq) continue;
const rows = await this.sql<Array<{ inserted: boolean }>>`
INSERT INTO qa_pair (merchant_id, question, answer, normalized_question, embedding)
VALUES (${merchantId}, ${canonicalizeKeyword(p.question)}, ${p.answer.trim()},
${nq}, ${toVector(embeddings[i])}::vector)
ON CONFLICT (merchant_id, normalized_question) DO UPDATE SET
answer = EXCLUDED.answer, updated_at = now()
RETURNING (xmax = 0) AS inserted`;
if (rows[0]?.inserted) created++;
}
return created;
}
}

View File

@ -0,0 +1,113 @@
import { Injectable, Logger } from '@nestjs/common';
import { env } from '../config/env';
import { KeywordIntent } from '../llm/types';
import { KeywordRepository } from './keyword.repository';
import { canonicalizeKeyword, isBanned, normalizeKeyword } from './normalize';
export type DedupAction =
| 'created' // 새 키워드
| 'matched_exact' // 1단계: 정규화 해시 일치
| 'matched_trigram' // 2단계: 표기 변형/오타
| 'matched_vector' // 3단계: 의미 중복 → alias 흡수
| 'rejected_banned'; // 금칙어
export interface DedupResult {
action: DedupAction;
keywordId: string | null;
canonical: string;
matchedTo?: string;
similarity?: number;
}
export interface ResolveInput {
raw: string;
intent: KeywordIntent;
embedding: number[];
locale: string;
industryId: string | null;
regionId: string | null;
}
/**
* 4 .
* , .
*/
@Injectable()
export class DedupService {
private readonly logger = new Logger(DedupService.name);
constructor(private readonly repo: KeywordRepository) {}
async resolve(input: ResolveInput): Promise<DedupResult> {
const canonical = canonicalizeKeyword(input.raw);
const normalized = normalizeKeyword(input.raw);
// 0단계 — 금칙어/과장광고 차단
if (!normalized || isBanned(canonical)) {
return { action: 'rejected_banned', keywordId: null, canonical };
}
// 1단계 — 정규화 완전 일치 (공백/구두점 차이 흡수)
const exact = await this.repo.findByNormalized(normalized, input.locale);
if (exact) {
await this.repo.absorbAlias(exact.id, canonical);
return {
action: 'matched_exact',
keywordId: exact.id,
canonical: exact.canonical,
matchedTo: exact.canonical,
similarity: 1,
};
}
// 2~3단계 — trigram 후보 + 벡터 ANN 후보를 모아 최고 유사도 판정
//
// 주의: 짧은 한글 키워드에서는 문장 임베딩의 절대 코사인이 변별력이 약하다.
// 실측(multilingual-e5-small): '선유도 펜션' ↔ '새만금 펜션' = 0.936,
// '군산 펜션' ↔ '군산 호텔' = 0.970 — 전혀 다른 키워드인데도 높게 나온다.
// 반면 어순만 바뀐 진짜 중복('군산 키즈룸 펜션' ↔ '군산 펜션 키즈룸')은 0.999 대에 몰린다.
// 그래서 임계값을 0.99 로 올려 잡고, 자동 병합의 주력은 1~2단계(어휘)에 둔다.
const candidates = await this.repo.findDedupCandidates(
input.embedding,
normalized,
input.locale,
env.dedup.candidateLimit,
);
const trigramHit = candidates.find((c) => c.trg >= env.dedup.trigramThreshold);
if (trigramHit) {
await this.repo.absorbAlias(trigramHit.id, canonical);
return {
action: 'matched_trigram',
keywordId: trigramHit.id,
canonical: trigramHit.canonical,
matchedTo: trigramHit.canonical,
similarity: trigramHit.trg,
};
}
const best = candidates[0];
if (best && best.cosine >= env.dedup.cosineThreshold) {
await this.repo.absorbAlias(best.id, canonical);
return {
action: 'matched_vector',
keywordId: best.id,
canonical: best.canonical,
matchedTo: best.canonical,
similarity: best.cosine,
};
}
// 4단계 — 신규 등록
const created = await this.repo.insert({
canonical,
normalized,
locale: input.locale,
intent: input.intent,
embedding: input.embedding,
industryId: input.industryId,
regionId: input.regionId,
});
return { action: 'created', keywordId: created.id, canonical: created.canonical };
}
}

View File

@ -0,0 +1,124 @@
import { Inject, Injectable } from '@nestjs/common';
import { PG } from '../db/db.module';
import { Sql, toVector } from '../db/db';
import { KeywordIntent } from '../llm/types';
export interface KeywordRow {
id: string;
canonical: string;
normalized: string;
aliases: string[];
intent: KeywordIntent;
usage_count: number;
}
export interface CandidateRow {
id: string;
canonical: string;
normalized: string;
cosine: number;
trg: number;
}
@Injectable()
export class KeywordRepository {
constructor(@Inject(PG) private readonly sql: Sql) {}
async findByNormalized(normalized: string, locale: string): Promise<KeywordRow | null> {
const rows = await this.sql<KeywordRow[]>`
SELECT id, canonical, normalized, aliases, intent, usage_count
FROM keyword
WHERE normalized = ${normalized} AND locale = ${locale}
LIMIT 1`;
return rows[0] ?? null;
}
/**
* 수집: trigram + ANN N .
* .
*/
async findDedupCandidates(
embedding: number[],
normalized: string,
locale: string,
limit: number,
): Promise<CandidateRow[]> {
const vec = toVector(embedding);
const rows = await this.sql<CandidateRow[]>`
(
SELECT id, canonical, normalized,
1 - (embedding <=> ${vec}::vector) AS cosine,
similarity(normalized, ${normalized}) AS trg
FROM keyword
WHERE locale = ${locale}
AND embedding IS NOT NULL
AND normalized % ${normalized}
ORDER BY trg DESC
LIMIT ${limit}
)
UNION ALL
(
SELECT id, canonical, normalized,
1 - (embedding <=> ${vec}::vector) AS cosine,
0::real AS trg
FROM keyword
WHERE locale = ${locale}
AND embedding IS NOT NULL
ORDER BY embedding <=> ${vec}::vector
LIMIT ${limit}
)`;
const best = new Map<string, CandidateRow>();
for (const r of rows) {
const prev = best.get(r.id);
if (!prev || r.trg > prev.trg) best.set(r.id, { ...r, cosine: Number(r.cosine), trg: Number(r.trg) });
}
return [...best.values()].sort((a, b) => b.cosine - a.cosine);
}
async insert(input: {
canonical: string;
normalized: string;
locale: string;
intent: KeywordIntent;
embedding: number[];
industryId: string | null;
regionId: string | null;
}): Promise<KeywordRow> {
const rows = await this.sql<KeywordRow[]>`
INSERT INTO keyword (canonical, normalized, locale, intent, embedding, industry_id, region_id, usage_count)
VALUES (${input.canonical}, ${input.normalized}, ${input.locale}, ${input.intent},
${toVector(input.embedding)}::vector, ${input.industryId}, ${input.regionId}, 0)
ON CONFLICT (normalized, locale) DO UPDATE SET updated_at = now()
RETURNING id, canonical, normalized, aliases, intent, usage_count`;
return rows[0];
}
/** 표기 변형을 기존 키워드에 흡수 (롱테일 검색어 보존) */
async absorbAlias(keywordId: string, alias: string): Promise<void> {
await this.sql`
UPDATE keyword
SET aliases = (
SELECT ARRAY(SELECT DISTINCT unnest(aliases || ARRAY[${alias}]::text[]))
),
updated_at = now()
WHERE id = ${keywordId}
AND NOT (${alias} = ANY(aliases))
AND canonical <> ${alias}`;
}
async bumpUsage(keywordId: string): Promise<void> {
await this.sql`
UPDATE keyword SET usage_count = usage_count + 1, updated_at = now() WHERE id = ${keywordId}`;
}
async searchByVector(embedding: number[], locale: string, limit: number) {
const vec = toVector(embedding);
return this.sql<Array<{ id: string; canonical: string; intent: string; usage_count: number; score: number }>>`
SELECT id, canonical, intent, usage_count, 1 - (embedding <=> ${vec}::vector) AS score
FROM keyword
WHERE locale = ${locale} AND embedding IS NOT NULL
ORDER BY embedding <=> ${vec}::vector
LIMIT ${limit}`;
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DedupService } from './dedup.service';
import { KeywordRepository } from './keyword.repository';
@Module({
providers: [KeywordRepository, DedupService],
exports: [KeywordRepository, DedupService],
})
export class KeywordsModule {}

View File

@ -0,0 +1,33 @@
/**
* .
* NFKC .
* "강남 미용실" "강남미용실" .
*/
const ZERO_WIDTH = /[\u200B-\u200D\uFEFF]/g;
const PUNCT = /[!-\/:-@\[-`{-~·ㆍ、。「-』]/g;
export function normalizeKeyword(raw: string): string {
return raw
.normalize('NFKC')
.toLowerCase()
.replace(ZERO_WIDTH, '')
.replace(PUNCT, '')
.replace(/\s+/g, '');
}
/** 표시용 정리: 앞뒤/중복 공백만 정리하고 원문 표기는 보존 */
export function canonicalizeKeyword(raw: string): string {
return raw.normalize('NFKC').replace(ZERO_WIDTH, '').replace(/\s+/g, ' ').trim();
}
/** 과장광고·금칙 표현 필터 (광고심의 리스크 차단) */
const BANNED = [
'최고', '1위', '일등', '넘버원', 'no.1', '100%', '무조건', '완치', '부작용없',
'영구', '평생보장', '유일한', '최저가보장', '전국최대',
];
const BANNED_NORMALIZED = BANNED.map(normalizeKeyword);
export function isBanned(text: string): boolean {
const n = normalizeKeyword(text);
return BANNED_NORMALIZED.some((b) => b.length > 0 && n.includes(b));
}

View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { env } from '../config/env';
import { MockLlmProvider } from './mock.provider';
import { OpenAiLlmProvider } from './openai.provider';
import { LlmProvider } from './types';
@Module({
providers: [
{
provide: LlmProvider,
useClass: env.llm.provider === 'openai' ? OpenAiLlmProvider : MockLlmProvider,
},
],
exports: [LlmProvider],
})
export class LlmModule {}

View File

@ -0,0 +1,160 @@
import { Injectable } from '@nestjs/common';
import { EMBEDDING_DIM } from '../config/env';
import { normalizeKeyword } from '../keywords/normalize';
import {
GenerationOutput,
KeywordCandidate,
KeywordIntent,
LlmProvider,
MerchantContext,
QaCandidate,
} from './types';
/**
* API ( ) .
*
* embed(): bigram + L2 .
* "비슷한 문자열이면 비슷한 벡터"
* .
*/
@Injectable()
export class MockLlmProvider extends LlmProvider {
readonly name = 'mock';
readonly model = 'mock-keyword-v1';
async generate(ctx: MerchantContext): Promise<GenerationOutput> {
const region = ctx.regionName ?? '';
const industry = ctx.industryName ?? '업체';
const p = ctx.profile;
const services = toStringArray(p['services']);
const features = toStringArray(p['features']);
const audiences = toStringArray(p['audiences']);
const nearby = toStringArray(p['nearby']);
const seasons = toStringArray(p['seasons']);
const MODIFIERS = ['추천', '예약', '가격', '후기', '저렴한곳', '깨끗한', '인기', '순위', '위치', '실시간예약'];
const out: Array<[string, KeywordIntent, number]> = [];
const push = (k: string, intent: KeywordIntent, rel: number) => out.push([k, intent, rel]);
// 실제 로컬 검색 패턴을 프로필 배열의 조합으로 전개한다.
push(ctx.name, 'brand', 0.99);
push(`${region} ${ctx.name}`, 'brand', 0.97);
push(`${region} ${industry}`, 'local', 0.95);
push(`${region} ${industry} 추천`, 'local', 0.93);
for (const m of MODIFIERS) {
push(`${region} ${industry} ${m}`, intentOf(m), 0.86);
}
for (const a of audiences) {
push(`${region} ${a} ${industry}`, 'local', 0.88);
for (const m of MODIFIERS.slice(0, 4)) push(`${region} ${a} ${industry} ${m}`, intentOf(m), 0.74);
push(`${a} ${industry} 추천`, 'informational', 0.62);
}
for (const f of features) {
push(`${region} ${f} ${industry}`, 'local', 0.84);
push(`${industry} ${f}`, 'informational', 0.6);
push(`${region} ${industry} ${f}`, 'local', 0.7);
}
for (const s of services) {
push(`${region} ${s}`, 'local', 0.82);
for (const m of MODIFIERS.slice(0, 4)) push(`${s} ${m}`, intentOf(m), 0.66);
}
for (const n of nearby) {
push(`${n} 근처 ${industry}`, 'local', 0.8);
push(`${n} ${industry} 추천`, 'local', 0.76);
push(`${n} 숙소`, 'local', 0.68);
}
for (const s of seasons) {
push(`${s} ${region} ${industry}`, 'local', 0.72);
push(`${region} ${s} ${industry} 예약`, 'transactional', 0.64);
}
// 동반자 × 시설 롱테일 — 여기서부터 검색량이 급격히 얇아진다
for (const a of audiences) {
for (const f of features) push(`${region} ${a} ${f} ${industry}`, 'local', 0.42);
}
for (const a of audiences) {
for (const s of services) push(`${a} ${s}`, 'informational', 0.38);
}
// 질문형 (AEO 유입)
for (const a of audiences) push(`${region} ${a} ${industry} 어디가 좋을까요`, 'informational', 0.5);
for (const n of nearby) push(`${n} 여행 ${industry} 어디`, 'informational', 0.44);
const seen = new Set<string>();
const keywords: KeywordCandidate[] = [];
for (const [raw, intent, relevance] of out) {
const k = raw.replace(/\s+/g, ' ').trim();
if (!k || seen.has(k)) continue;
seen.add(k);
keywords.push({ keyword: k, intent, relevance, rationale: `mock: ${intent}` });
if (keywords.length >= ctx.targetCount) break;
}
const qaPairs: QaCandidate[] = [
{
question: `${ctx.name}은(는) 어디에 있나요?`,
answer: `${ctx.name}은(는) ${region || '해당 지역'}에 위치한 ${industry}입니다.`,
},
{
question: `${ctx.name} 예약은 어떻게 하나요?`,
answer: `${ctx.name}은(는) 사이트 예약 페이지 또는 전화로 예약할 수 있습니다.`,
},
{
question: `${ctx.name}의 주요 서비스는 무엇인가요?`,
answer: services.length
? `주요 서비스는 ${services.join(', ')} 입니다.`
: `${industry} 관련 서비스를 제공합니다.`,
},
{
question: `${ctx.name} 근처에 가볼 만한 곳은 어디인가요?`,
answer: nearby.length
? `${nearby.join(', ')} 등이 가깝습니다.`
: `${region} 주요 명소가 인근에 있습니다.`,
},
{
question: `${ctx.name}${audiences[0] ?? '반려동물'}도 갈 수 있나요?`,
answer: features.length
? `${features.join(', ')} 조건을 제공합니다. 예약 전 상세 조건을 확인해 주세요.`
: `예약 전 상세 조건을 확인해 주세요.`,
},
];
return { keywords, qaPairs, model: this.model, provider: this.name };
}
}
function intentOf(modifier: string): KeywordIntent {
if (modifier === '예약' || modifier === '실시간예약' || modifier === '가격') return 'transactional';
if (modifier === '후기' || modifier === '순위') return 'informational';
return 'local';
}
function toStringArray(v: unknown): string[] {
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
}
/** 문자 bigram 해싱 임베딩 (결정적, L2 정규화) */
export function hashEmbedding(text: string, dim = EMBEDDING_DIM): number[] {
const s = ` ${normalizeKeyword(text)} `;
const vec = new Float64Array(dim);
for (let i = 0; i < s.length - 1; i++) {
const gram = s.slice(i, i + 2);
const h = fnv1a(gram);
vec[h % dim] += 1;
// 부호 해싱으로 충돌 편향 완화
vec[(h >>> 8) % dim] += h & 1 ? 1 : -1;
}
let norm = 0;
for (let i = 0; i < dim; i++) norm += vec[i] * vec[i];
norm = Math.sqrt(norm) || 1;
return Array.from(vec, (x) => x / norm);
}
function fnv1a(str: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}

View File

@ -0,0 +1,114 @@
import { Injectable, Logger } from '@nestjs/common';
import OpenAI from 'openai';
import { env } from '../config/env';
import { GenerationOutput, LlmProvider, MerchantContext } from './types';
/** Structured Outputs 로 강제하는 응답 스키마 — 자유 텍스트 파싱 금지 */
const RESPONSE_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['keywords', 'qa_pairs'],
properties: {
keywords: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['keyword', 'intent', 'relevance', 'rationale'],
properties: {
keyword: { type: 'string' },
intent: {
type: 'string',
enum: ['informational', 'navigational', 'transactional', 'local', 'brand'],
},
relevance: { type: 'number' },
rationale: { type: 'string' },
},
},
},
qa_pairs: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['question', 'answer'],
properties: {
question: { type: 'string' },
answer: { type: 'string' },
},
},
},
},
} as const;
@Injectable()
export class OpenAiLlmProvider extends LlmProvider {
readonly name = 'openai';
readonly model = env.llm.model;
private readonly logger = new Logger(OpenAiLlmProvider.name);
private readonly client = new OpenAI({ apiKey: env.llm.apiKey });
async generate(ctx: MerchantContext): Promise<GenerationOutput> {
const res = await this.client.chat.completions.create({
model: this.model,
temperature: 0.7,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: buildUserPrompt(ctx) },
],
response_format: {
type: 'json_schema',
json_schema: { name: 'seo_aeo_keywords', strict: true, schema: RESPONSE_SCHEMA as any },
},
});
const content = res.choices[0]?.message?.content ?? '{}';
const parsed = JSON.parse(content) as {
keywords?: GenerationOutput['keywords'];
qa_pairs?: GenerationOutput['qaPairs'];
};
return {
keywords: parsed.keywords ?? [],
qaPairs: parsed.qa_pairs ?? [],
model: this.model,
provider: this.name,
usage: {
prompt_tokens: res.usage?.prompt_tokens ?? 0,
completion_tokens: res.usage?.completion_tokens ?? 0,
total_tokens: res.usage?.total_tokens ?? 0,
},
};
}
}
const SYSTEM_PROMPT = `당신은 한국 로컬 비즈니스 SEO/AEO 전문가입니다.
,
(AI ) - .
:
- .
- + + .
- (, 1, 100%, , ) .
- "이미 보유한 키워드" .
- relevance 0.0~1.0 .
- (answer) 2~3, .`;
function buildUserPrompt(ctx: MerchantContext): string {
return [
`# 업체 정보`,
`- 상호: ${ctx.name}`,
`- 업종: ${ctx.industryName ?? '미상'} (${ctx.industryPath ?? '-'})`,
`- 지역: ${ctx.regionName ?? '미상'} (${ctx.regionPath ?? '-'})`,
`- 소개: ${ctx.description || '없음'}`,
`- 상세: ${JSON.stringify(ctx.profile, null, 2)}`,
``,
`# 이미 보유한 키워드 (이것들과 겹치지 않는 새 후보만 생성)`,
ctx.existingKeywords.length ? ctx.existingKeywords.map((k) => `- ${k}`).join('\n') : '- (없음)',
``,
`# 요청`,
`- 키워드 ${ctx.targetCount}`,
`- 질문-답변 쌍 5개`,
].join('\n');
}

46
ontology/src/llm/types.ts Normal file
View File

@ -0,0 +1,46 @@
export type KeywordIntent =
| 'informational'
| 'navigational'
| 'transactional'
| 'local'
| 'brand';
export interface MerchantContext {
externalId: string;
name: string;
description: string;
industryName?: string | null;
industryPath?: string | null;
regionName?: string | null;
regionPath?: string | null;
profile: Record<string, unknown>;
/** 이미 보유한 키워드 — 프롬프트에 넣어 중복 후보 생성 자체를 줄인다 */
existingKeywords: string[];
targetCount: number;
}
export interface KeywordCandidate {
keyword: string;
intent: KeywordIntent;
relevance: number; // 0..1
rationale: string;
}
export interface QaCandidate {
question: string;
answer: string;
}
export interface GenerationOutput {
keywords: KeywordCandidate[];
qaPairs: QaCandidate[];
model: string;
provider: string;
usage?: Record<string, number>;
}
export abstract class LlmProvider {
abstract readonly name: string;
abstract readonly model: string;
abstract generate(ctx: MerchantContext): Promise<GenerationOutput>;
}

17
ontology/src/main.ts Normal file
View File

@ -0,0 +1,17 @@
import 'reflect-metadata';
import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { env } from './config/env';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.enableCors();
await app.listen(env.port);
new Logger('bootstrap').log(
`o2o-site-ontology listening on http://localhost:${env.port} (llm=${env.llm.provider})`,
);
}
bootstrap();

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { GenerationModule } from '../generation/generation.module';
import { MerchantsController } from './merchants.controller';
import { MerchantsModule } from './merchants.module';
@Module({
imports: [MerchantsModule, GenerationModule],
controllers: [MerchantsController],
})
export class MerchantsHttpModule {}

View File

@ -0,0 +1,53 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { GenerationService } from '../generation/generation.service';
import { GenerationQueue } from '../generation/generation.queue';
import { MerchantsService, UpsertMerchantDto } from './merchants.service';
@Controller('v1/merchants')
export class MerchantsController {
constructor(
private readonly merchants: MerchantsService,
private readonly generation: GenerationService,
private readonly queue: GenerationQueue,
) {}
@Get()
list() {
return this.merchants.list();
}
@Get(':id')
get(@Param('id') id: string) {
return this.merchants.findWithTaxonomy(id);
}
/** o2o-site-AEO 사이트 발행 웹훅: 업체 등록 + 키워드 생성 예약 */
@Post('publish')
async publish(@Body() dto: UpsertMerchantDto & { generate?: boolean; sync?: boolean }) {
const merchant = await this.merchants.upsert(dto);
if (dto.generate === false) return { merchant, generation: 'skipped' };
if (dto.sync) {
const stats = await this.generation.runForMerchant(merchant.id, 'published');
return { merchant, generation: stats };
}
const jobId = await this.queue.enqueue(merchant.id, 'published');
return { merchant, generation: { queued: true, jobId } };
}
/** 수동 재생성 */
@Post(':id/generate')
async generate(
@Param('id') id: string,
@Query('sync') sync?: string,
@Query('count') count?: string,
) {
const target = count ? Math.min(Math.max(1, Number(count)), 500) : undefined;
if (sync === 'true' || sync === '1') {
return this.generation.runForMerchant(id, 'manual', target);
}
const m = await this.merchants.findWithTaxonomy(id);
const jobId = await this.queue.enqueue(m.id, 'manual');
return { queued: true, jobId };
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MerchantsService } from './merchants.service';
@Module({
providers: [MerchantsService],
exports: [MerchantsService],
})
export class MerchantsModule {}

View File

@ -0,0 +1,92 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { PG } from '../db/db.module';
import { asJson, Sql } from '../db/db';
export interface MerchantRow {
id: string;
external_id: string;
name: string;
industry_id: string | null;
region_id: string | null;
description: string;
profile: Record<string, unknown>;
site_url: string | null;
last_generated_at: Date | null;
}
export interface MerchantWithTaxonomy extends MerchantRow {
industry_name: string | null;
industry_path: string | null;
region_name: string | null;
region_path: string | null;
}
export interface UpsertMerchantDto {
externalId: string;
name: string;
industryId?: string | null;
regionId?: string | null;
description?: string;
profile?: Record<string, unknown>;
siteUrl?: string | null;
}
@Injectable()
export class MerchantsService {
constructor(@Inject(PG) private readonly sql: Sql) {}
/** o2o-site-AEO 가 사이트를 발행할 때 호출하는 진입점 */
async upsert(dto: UpsertMerchantDto): Promise<MerchantRow> {
const rows = await this.sql<MerchantRow[]>`
INSERT INTO merchant (external_id, name, industry_id, region_id, description, profile, site_url)
VALUES (${dto.externalId}, ${dto.name}, ${dto.industryId ?? null}, ${dto.regionId ?? null},
${dto.description ?? ''}, ${this.sql.json(asJson(dto.profile ?? {}))}, ${dto.siteUrl ?? null})
ON CONFLICT (external_id) DO UPDATE SET
name = EXCLUDED.name,
industry_id = EXCLUDED.industry_id,
region_id = EXCLUDED.region_id,
description = EXCLUDED.description,
profile = EXCLUDED.profile,
site_url = EXCLUDED.site_url,
updated_at = now()
RETURNING *`;
return rows[0];
}
async findWithTaxonomy(idOrExternalId: string): Promise<MerchantWithTaxonomy> {
const rows = await this.sql<MerchantWithTaxonomy[]>`
SELECT m.*,
i.name AS industry_name, i.path::text AS industry_path,
r.name AS region_name, r.path::text AS region_path
FROM merchant m
LEFT JOIN industry i ON i.id = m.industry_id
LEFT JOIN region r ON r.id = m.region_id
WHERE m.external_id = ${idOrExternalId}
OR (${isUuid(idOrExternalId)} AND m.id::text = ${idOrExternalId})
LIMIT 1`;
if (!rows[0]) throw new NotFoundException(`merchant not found: ${idOrExternalId}`);
return rows[0];
}
async list(limit = 50) {
return this.sql<MerchantRow[]>`
SELECT * FROM merchant ORDER BY created_at DESC LIMIT ${limit}`;
}
/** 주기 리프레시 대상: 한 번도 생성 안 됐거나 N일 지난 업체 */
async findStale(intervalDays: number, limit: number) {
return this.sql<Array<{ id: string; external_id: string }>>`
SELECT id, external_id FROM merchant
WHERE last_generated_at IS NULL
OR last_generated_at < now() - (${intervalDays} || ' days')::interval
ORDER BY last_generated_at NULLS FIRST
LIMIT ${limit}`;
}
async markGenerated(merchantId: string) {
await this.sql`UPDATE merchant SET last_generated_at = now() WHERE id = ${merchantId}`;
}
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isUuid = (v: string) => UUID_RE.test(v);

View File

@ -0,0 +1,13 @@
import { Controller, Get, Header } from '@nestjs/common';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
/** 로컬 확인용 매칭 데모 페이지. 빌드 산출물이 아니라 public/ 에서 직접 읽는다. */
@Controller()
export class DemoController {
@Get('demo')
@Header('content-type', 'text/html; charset=utf-8')
demo(): string {
return readFileSync(join(process.cwd(), 'public', 'demo.html'), 'utf8');
}
}

View File

@ -0,0 +1,182 @@
/**
* .
*
* .
* · ( )
* · SQL/
* ( "비슷함" "최대 4인 < 단체" )
*/
export interface MerchantFacts {
name: string;
region: string | null;
industry: string | null;
description: string;
address: string | null;
areaGroup: AreaGroup | null;
capacityMax: number | null;
services: string[];
features: string[];
audiences: string[];
nearby: string[];
amenities: Set<string>; // 정규화된 보유 시설
unverified: Set<string>; // 미확인 — 배제하지 않고 보류 처리
/** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. 사업자가 쓰는 말과 다르므로 별도 레인으로 둔다 */
signals: string[];
}
export type AreaGroup = '해안·도서' | '원도심' | '시내';
export interface Lane {
key: string;
label: string;
weight: number;
text: string;
}
// ──────────────────────────────────────────── 권역 판정
const AREA_TERMS: Record<AreaGroup, string[]> = {
'해안·도서': ['선유도', '무녀도', '장자도', '대장도', '신시도', '야미도', '고군산군도', '새만금',
'비응항', '비응도', '오식도', '해수욕장', '몽돌', '오션뷰', '바다뷰', '해변', '섬'],
'원도심': ['원도심', '신흥동', '영화동', '월명', '말랭이마을', '동국사', '초원사진관', '이성당',
'경암동', '근대역사', '근대문화', '시간여행', '해망굴', '째보선창', '뜬다리', '일본식가옥'],
'시내': ['나운동', '수송동', '미룡동', '조촌동', '은파호수공원', '군산역', '시외버스'],
};
export function detectAreaGroup(text: string): AreaGroup | null {
const hits = (Object.entries(AREA_TERMS) as [AreaGroup, string[]][])
.map(([g, terms]) => [g, terms.filter((t) => text.includes(t)).length] as const)
.filter(([, n]) => n > 0)
.sort((a, b) => b[1] - a[1]);
return hits[0]?.[0] ?? null;
}
/** 키워드가 어느 권역에 속하는지 (해당 없으면 null = 권역 중립) */
export function keywordAreaGroup(keyword: string): AreaGroup | null {
return detectAreaGroup(keyword);
}
// ──────────────────────────────────────────── 수용 인원 모순
const GROUP_TERMS = ['단체', '워크샵', '워크숍', 'mt', '엠티', '20인', '15인', '10인',
'세미나', '단합', '대형', '펜션동', '전체대관'];
const GROUP_MIN_CAPACITY = 8;
export function violatesCapacity(keyword: string, capacityMax: number | null): boolean {
if (capacityMax === null || capacityMax >= GROUP_MIN_CAPACITY) return false;
const k = keyword.toLowerCase();
return GROUP_TERMS.some((t) => k.includes(t));
}
// ──────────────────────────────────────────── 시설 요구 조건
/** 키워드에 이 말이 있으면 해당 시설을 실제로 보유해야 한다 */
const AMENITY_REQUIRED: Array<[RegExp, string]> = [
[/바베큐|바비큐|바베큐장|그릴/, '바베큐'],
[/수영장|풀빌라|인피니티풀|온수풀|야외수영/, '수영장'],
[/스파|자쿠지|월풀|반신욕/, '스파'],
[/애견|반려|펫/, '애견동반'],
[/주차/, '주차'],
[/노래방/, '노래방'],
[/조식/, '조식'],
[/키즈룸|트램폴린/, '키즈시설'],
[/불멍|화로대|캠프파이어/, '불멍'],
[/빔프로젝터|넷플릭스/, '미디어'],
[/세미나실/, '세미나실'],
];
/** 시설 표기 흔들림을 통제 어휘로 모은다 */
const AMENITY_SYNONYMS: Array<[RegExp, string]> = [
[/바베큐|바비큐|그릴/, '바베큐'],
[/수영장|풀|풀빌라/, '수영장'],
[/스파|자쿠지|월풀|욕조/, '스파'],
[/애견|반려|펫/, '애견동반'],
[/주차/, '주차'],
[/노래방/, '노래방'],
[/조식|아침/, '조식'],
[/키즈|트램폴린|유아/, '키즈시설'],
[/불멍|화로|캠프파이어/, '불멍'],
[/넷플릭스|빔프로젝터|ott/i, '미디어'],
[/세미나/, '세미나실'],
];
export function normalizeAmenities(raw: string[]): Set<string> {
const out = new Set<string>();
for (const r of raw) {
for (const [re, canon] of AMENITY_SYNONYMS) if (re.test(r)) out.add(canon);
}
return out;
}
export type AmenityVerdict = { ok: true } | { ok: false; hold: boolean; amenity: string };
export function checkAmenity(keyword: string, facts: MerchantFacts): AmenityVerdict {
for (const [re, amenity] of AMENITY_REQUIRED) {
if (!re.test(keyword)) continue;
if (facts.amenities.has(amenity)) return { ok: true };
// 사업자가 확인해주지 않은 항목은 "없음"이 아니라 "모름" — 배제하지 않고 보류
const unverified = [...facts.unverified].some((u) =>
AMENITY_SYNONYMS.some(([sre, canon]) => canon === amenity && sre.test(u)));
return { ok: false, hold: unverified, amenity };
}
return { ok: true };
}
// ──────────────────────────────────────────── 서브 질의 빌더
const STAY_TYPE_HINTS = ['독채', '풀빌라', '스테이', '펜션', '글램핑', '카라반', '한옥', '민박', '감성'];
const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모|수용/;
/**
*
* 1. . "군산 펜션" ,
* RRF "여러 레인에 두루 걸린 generic 키워드" .
* + .
* 2. . "군산 펜션"
* generic ( 6 '~예약' ).
* 3. . .
*/
export function buildLanes(f: MerchantFacts): Lane[] {
const lanes: Lane[] = [];
// 토큰 단위로 중복을 제거한다. 문자열 단위 Set 만으로는 '신흥동' 과
// '신흥동 일본식가옥' 이 서로 다른 원소라 같은 낱말이 두 번 실리고,
// 그 낱말 쪽으로 레인이 쏠린다 (실제로 말랭이마을이 밀려났다).
const push = (key: string, label: string, weight: number, parts: (string | null | undefined)[]) => {
const seen = new Set<string>();
const words: string[] = [];
for (const part of parts) {
if (!part) continue;
for (const w of String(part).split(/\s+/)) {
const k = w.trim();
if (!k || seen.has(k)) continue;
seen.add(k);
words.push(k);
}
}
const text = words.join(' ');
if (text) lanes.push({ key, label, weight, text });
};
const isType = (x: string) => STAY_TYPE_HINTS.some((h) => x.includes(h));
const typeWords = [...f.features, ...f.services].filter(isType).slice(0, 3);
// 시설: 유형어·수용인원·권역어를 걷어낸 나머지 + 정규화된 보유 시설
const amenityWords = [
...f.amenities,
...f.features.filter((x) => !isType(x) && !CAPACITY_TOKEN.test(x) && !keywordAreaGroup(x)),
].slice(0, 6);
// 권역과 인근을 한 레인으로 합친다. 나눠 두면 '신흥동' 같은 토큰이 두 레인에 겹쳐
// 같은 위치 키워드가 두 번 가산되고, 상위가 전부 위치 키워드로 쓸려 나간다.
push('type', '유형', 1.0, [f.region, f.industry, ...typeWords]);
push('place', '위치', 0.7, [f.areaGroup, districtOf(f.address), ...f.nearby.slice(0, 4), '근처']);
push('audience', '동반자', 0.6, f.audiences.slice(0, 4));
push('amenity', '시설', 0.6, amenityWords);
// 고객 언어는 사업자 표현보다 검색어에 가깝다 — 데이터가 있으면 높게 잡는다
push('signal', '고객언어', 0.9, f.signals.slice(0, 10));
return lanes;
}
function districtOf(address: string | null): string | null {
if (!address) return null;
const m = address.match(/([가-힣]+(?:동|읍|면|리))/);
return m?.[1] ?? null;
}

View File

@ -0,0 +1,253 @@
import { Inject, Injectable } from '@nestjs/common';
import { Sql, toVector } from '../db/db';
import { PG } from '../db/db.module';
import { EmbeddingProvider } from '../embedding/types';
import { normalizeKeyword } from '../keywords/normalize';
import { MerchantWithTaxonomy } from '../merchants/merchants.service';
import {
AreaGroup, Lane, MerchantFacts, buildLanes, checkAmenity, detectAreaGroup,
keywordAreaGroup, normalizeAmenities, violatesCapacity,
} from './match.rules';
// RRF 상수를 관례값 60 대신 20 으로 낮춘다. 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라
// 깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 상위를 차지한다. 20 이면 2.9배로 벌어진다.
const RRF_K = 20;
const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다
const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다
// 매칭 후보로 인정하는 출처. 고정 데이터셋 정책상 LLM 생성물은 사전에 섞이면 안 된다.
const MATCH_SOURCES = ['dataset', 'nationwide', 'manual'];
interface Hit {
id: string; canonical: string; intent: string; kind: string;
category: string | null; aliases: string[]; score: number;
}
export interface MatchRow extends Hit {
rrf: number;
status: 'ok' | 'hold';
holdReason?: string;
lanes: Array<{ key: string; label: string; rank: number; score: number }>;
linked: boolean;
}
@Injectable()
export class MatchService {
constructor(
@Inject(PG) private readonly sql: Sql,
private readonly embedder: EmbeddingProvider,
) {}
/** 속성별 서브 질의 → 가중 RRF 융합 → 사실 기반 필터 */
async fusion(rawQuery: string, limit: number) {
const query = rawQuery.trim();
const merchant = await this.resolveMerchant(query);
const facts = merchant ? toFacts(merchant) : null;
const lanes: Lane[] = facts
? buildLanes(facts)
: [{ key: 'free', label: '입력문', weight: 1.0, text: query }];
const vectors = await this.embedder.embed(lanes.map((l) => l.text), 'query');
// 레인별 검색.
// 후보 풀을 업체 업종으로 좁힌다. 사전 전체를 뒤지면 '강남 미용실' 같은
// 다른 업종 키워드가 후보에 섞인다 (실제로 섞여 있었다).
const perLane = await Promise.all(
vectors.map((v) => this.laneSearch(v, LANE_DEPTH, merchant?.industry_id ?? null)),
);
// 가중 RRF 융합
const acc = new Map<string, { hit: Hit; rrf: number; lanes: MatchRow['lanes'] }>();
perLane.forEach((hits, li) => {
const lane = lanes[li];
hits.forEach((hit, idx) => {
if (hit.score < LANE_FLOOR) return;
const rank = idx + 1;
const contrib = lane.weight / (RRF_K + rank);
const cur = acc.get(hit.id) ?? { hit, rrf: 0, lanes: [] };
cur.rrf += contrib;
cur.lanes.push({ key: lane.key, label: lane.label, rank, score: hit.score });
if (hit.score > cur.hit.score) cur.hit = hit;
acc.set(hit.id, cur);
});
});
// 사실 기반 필터
const kept: MatchRow[] = [];
const excluded: Array<{ canonical: string; reason: string }> = [];
for (const { hit, rrf, lanes: ls } of acc.values()) {
if (facts) {
if (violatesCapacity(hit.canonical, facts.capacityMax)) {
excluded.push({ canonical: hit.canonical, reason: `최대 ${facts.capacityMax}인 — 단체 키워드` });
continue;
}
const kwArea = keywordAreaGroup(hit.canonical);
if (kwArea && facts.areaGroup && kwArea !== facts.areaGroup) {
excluded.push({ canonical: hit.canonical, reason: `권역 불일치 — ${kwArea} (업체는 ${facts.areaGroup})` });
continue;
}
const am = checkAmenity(hit.canonical, facts);
if (!am.ok && !am.hold) {
excluded.push({ canonical: hit.canonical, reason: `미보유 시설 — ${am.amenity}` });
continue;
}
kept.push({
...hit, rrf,
lanes: ls.sort((a, b) => a.rank - b.rank),
status: am.ok ? 'ok' : 'hold',
holdReason: am.ok ? undefined : `${am.amenity} 미확인 — 사업자 확인 필요`,
linked: false,
});
} else {
kept.push({ ...hit, rrf, lanes: ls.sort((a, b) => a.rank - b.rank), status: 'ok', linked: false });
}
}
kept.sort((a, b) => b.rrf - a.rrf);
const top = kept.slice(0, limit);
// 레인별 상위 — SEO 페이지 배분은 평평한 순위가 아니라 이쪽을 쓴다.
// (주력 키워드는 유형 레인 1위, 주변 여행 페이지는 위치 레인 상위)
const keptById = new Map(kept.map((k) => [k.id, k]));
const byLane = lanes.map((lane, li) => ({
key: lane.key, label: lane.label, weight: lane.weight, text: lane.text,
items: perLane[li]
.map((h) => keptById.get(h.id))
.filter((x): x is MatchRow => Boolean(x))
.slice(0, 8),
}));
await this.markLinked([...top, ...byLane.flatMap((l) => l.items)], merchant?.id ?? null);
return {
mode: 'fusion' as const,
input: query,
resolved: merchant ? publicMerchant(merchant) : null,
facts: facts && {
areaGroup: facts.areaGroup,
capacityMax: facts.capacityMax,
amenities: [...facts.amenities],
unverified: [...facts.unverified],
},
lanes: lanes.map((l, i) => ({
...l, top: perLane[i][0]?.canonical ?? null, topScore: perLane[i][0]?.score ?? null,
})),
embeddingProvider: this.embedder.name,
total: await this.dictionarySize(),
matches: top,
byLane,
excluded: excluded.slice(0, 40),
excludedTotal: excluded.length,
};
}
private async laneSearch(
embedding: number[], limit: number, industryId: string | null,
): Promise<Hit[]> {
const vec = toVector(embedding);
const rows = await this.sql<Hit[]>`
SELECT id, canonical, intent, kind, category, aliases,
1 - (embedding <=> ${vec}::vector) AS score
FROM keyword
WHERE embedding IS NOT NULL
AND source = ANY(${MATCH_SOURCES})
AND (${industryId}::text IS NULL OR industry_id IS NULL OR industry_id = ${industryId})
ORDER BY embedding <=> ${vec}::vector
LIMIT ${limit}`;
return rows.map((r) => ({ ...r, score: Number(r.score) }));
}
private async markLinked(rows: MatchRow[], merchantId: string | null) {
if (!merchantId || rows.length === 0) return;
const ids = rows.map((r) => r.id);
const linked = await this.sql<Array<{ keyword_id: string }>>`
SELECT keyword_id FROM merchant_keyword
WHERE merchant_id = ${merchantId} AND keyword_id = ANY(${ids}::uuid[])`;
const set = new Set(linked.map((l) => l.keyword_id));
for (const r of rows) r.linked = set.has(r.id);
}
async resolveMerchant(query: string) {
const norm = normalizeKeyword(query);
if (!norm) return null;
const rows = await this.sql<Array<MerchantWithTaxonomy & { sim: number }>>`
SELECT m.*,
i.name AS industry_name, i.path::text AS industry_path,
r.name AS region_name, r.path::text AS region_path,
similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) AS sim
FROM merchant m
LEFT JOIN industry i ON i.id = m.industry_id
LEFT JOIN region r ON r.id = m.region_id
WHERE regexp_replace(lower(m.name), '\\s', '', 'g') = ${norm}
OR m.external_id = ${query}
OR similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) >= 0.45
ORDER BY sim DESC NULLS LAST
LIMIT 1`;
return rows[0] ?? null;
}
async dictionarySize() {
const [row] = await this.sql<Array<{ n: number }>>`
SELECT count(*)::int AS n FROM keyword
WHERE embedding IS NOT NULL AND source = ANY(${MATCH_SOURCES})`;
return row?.n ?? 0;
}
}
function str(v: unknown): string[] {
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
}
function toFacts(m: MerchantWithTaxonomy): MerchantFacts {
const p = (m.profile ?? {}) as Record<string, unknown>;
const features = str(p['features']);
const services = str(p['services']);
const nearby = str(p['nearby']);
const address = typeof p['address'] === 'string' ? p['address'] : null;
const cap = p['capacity'] as { max?: number } | undefined;
const areaSource = [address ?? '', ...nearby, m.description].join(' ');
const areaGroup: AreaGroup | null = detectAreaGroup(areaSource);
return {
name: m.name,
region: m.region_name,
industry: m.industry_name,
description: m.description,
address,
areaGroup,
capacityMax: typeof cap?.max === 'number' ? cap.max : null,
services, features,
audiences: str(p['audiences']),
nearby,
amenities: normalizeAmenities([...features, ...services]),
unverified: new Set(str(p['unverified'])),
signals: collectSignals(p),
};
}
/**
* .
* hashtags : ["#군산독채", "#군산감성숙소", ...]
* reviewSignals: [{ term: "바베큐", count: 47 }, ...]
* (·). .
*/
function collectSignals(p: Record<string, unknown>): string[] {
const tags = str(p['hashtags']).map((t) => t.replace(/^#/, '').trim()).filter(Boolean);
const raw = Array.isArray(p['reviewSignals']) ? p['reviewSignals'] : [];
const reviews = raw
.filter((r): r is { term: string; count: number } =>
Boolean(r) && typeof (r as any).term === 'string')
.sort((a, b) => (b.count ?? 0) - (a.count ?? 0))
.map((r) => r.term.trim())
.filter(Boolean);
return [...new Set([...reviews, ...tags])];
}
function publicMerchant(m: MerchantWithTaxonomy) {
return {
id: m.id, externalId: m.external_id, name: m.name,
region: m.region_name, industry: m.industry_name,
description: m.description, siteUrl: m.site_url, profile: m.profile ?? {},
};
}

View File

@ -0,0 +1,56 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { MatchService } from './match.service';
import { ServingService } from './serving.service';
@Controller('v1')
export class ServingController {
constructor(
private readonly serving: ServingService,
private readonly matcher: MatchService,
) {}
/** 발행된 사이트가 렌더링 시 호출 — SEO 메타 */
@Get('sites/:id/seo')
seo(@Param('id') id: string, @Query('limit') limit?: string) {
return this.serving.seo(id, clamp(limit, 20, 50));
}
/** 발행된 사이트가 렌더링 시 호출 — AEO(답변엔진) 소스 */
@Get('sites/:id/aeo')
aeo(@Param('id') id: string, @Query('limit') limit?: string) {
return this.serving.aeo(id, clamp(limit, 10, 30));
}
/** 어드민: 의미 기반 키워드 검색 */
@Post('keywords/search')
search(@Body() body: { query: string; limit?: number }) {
return this.serving.searchKeywords(body.query, Math.min(body.limit ?? 10, 50));
}
/**
* (/) .
* mode=fusion () + RRF +
* mode=single ()
*/
@Post('match')
match(@Body() body: { query: string; limit?: number; mode?: 'fusion' | 'single' }) {
const limit = Math.min(body.limit ?? 40, 200);
return body.mode === 'single'
? this.serving.match(body.query ?? '', limit)
: this.matcher.fusion(body.query ?? '', limit);
}
/** 성과 피드백 주입 (Search Console / 유입 로그) */
@Post('sites/:id/performance')
performance(
@Param('id') id: string,
@Body() body: { items: Array<{ keyword: string; impressions: number; clicks: number }> },
) {
return this.serving.applyPerformance(id, body.items ?? []);
}
}
function clamp(v: string | undefined, def: number, max: number): number {
const n = v ? Number(v) : def;
return Number.isFinite(n) ? Math.min(Math.max(1, n), max) : def;
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { KeywordsModule } from '../keywords/keywords.module';
import { MerchantsModule } from '../merchants/merchants.module';
import { DemoController } from './demo.controller';
import { MatchService } from './match.service';
import { ServingController } from './serving.controller';
import { ServingService } from './serving.service';
@Module({
imports: [MerchantsModule, KeywordsModule],
controllers: [ServingController, DemoController],
providers: [ServingService, MatchService],
})
export class ServingModule {}

View File

@ -0,0 +1,230 @@
import { Inject, Injectable } from '@nestjs/common';
import { PG } from '../db/db.module';
import { Sql, toVector } from '../db/db';
import { KeywordRepository } from '../keywords/keyword.repository';
import { normalizeKeyword } from '../keywords/normalize';
import { EmbeddingProvider } from '../embedding/types';
import { MerchantsService, MerchantWithTaxonomy } from '../merchants/merchants.service';
export interface SeoPayload {
merchant: { id: string; externalId: string; name: string; siteUrl: string | null };
title: string;
description: string;
keywords: string[];
tags: Array<{ keyword: string; intent: string; relevance: number; aliases: string[] }>;
generatedAt: string | null;
}
export interface AeoPayload {
merchant: { id: string; externalId: string; name: string };
topics: string[];
faqs: Array<{ question: string; answer: string }>;
/** JSON-LD 는 후속 단계에서 이 payload 를 그대로 매핑해 생성한다 */
structuredDataHints: {
type: 'LocalBusiness';
name: string;
description: string;
areaServed: string | null;
category: string | null;
};
}
@Injectable()
export class ServingService {
constructor(
@Inject(PG) private readonly sql: Sql,
private readonly merchants: MerchantsService,
private readonly keywords: KeywordRepository,
private readonly embedder: EmbeddingProvider,
) {}
async seo(idOrExternalId: string, limit: number): Promise<SeoPayload> {
const m = await this.merchants.findWithTaxonomy(idOrExternalId);
const rows = await this.sql<
Array<{ canonical: string; intent: string; relevance: number; aliases: string[] }>
>`
SELECT k.canonical, k.intent, mk.relevance, k.aliases
FROM merchant_keyword mk
JOIN keyword k ON k.id = mk.keyword_id
WHERE mk.merchant_id = ${m.id} AND mk.status = 'active'
ORDER BY mk.relevance DESC, k.usage_count ASC
LIMIT ${limit}`;
const kws = rows.map((r) => r.canonical);
const locality = [m.region_name, m.industry_name].filter(Boolean).join(' ');
return {
merchant: { id: m.id, externalId: m.external_id, name: m.name, siteUrl: m.site_url },
title: locality ? `${m.name} | ${locality}` : m.name,
description: buildDescription(m.name, m.description, kws),
keywords: kws,
tags: rows.map((r) => ({
keyword: r.canonical,
intent: r.intent,
relevance: Number(r.relevance),
aliases: r.aliases ?? [],
})),
generatedAt: m.last_generated_at ? new Date(m.last_generated_at).toISOString() : null,
};
}
async aeo(idOrExternalId: string, limit: number): Promise<AeoPayload> {
const m = await this.merchants.findWithTaxonomy(idOrExternalId);
const faqs = await this.sql<Array<{ question: string; answer: string }>>`
SELECT question, answer FROM qa_pair
WHERE merchant_id = ${m.id} AND status = 'active'
ORDER BY created_at ASC
LIMIT ${limit}`;
const topics = await this.sql<Array<{ canonical: string }>>`
SELECT k.canonical FROM merchant_keyword mk
JOIN keyword k ON k.id = mk.keyword_id
WHERE mk.merchant_id = ${m.id} AND mk.status = 'active'
AND k.intent IN ('informational', 'local')
ORDER BY mk.relevance DESC LIMIT ${limit}`;
return {
merchant: { id: m.id, externalId: m.external_id, name: m.name },
topics: topics.map((t) => t.canonical),
faqs,
structuredDataHints: {
type: 'LocalBusiness',
name: m.name,
description: m.description,
areaServed: m.region_name,
category: m.industry_name,
},
};
}
/**
* ( ) .
*
* .
*/
async match(rawQuery: string, limit: number) {
const query = rawQuery.trim();
const merchant = await this.resolveMerchant(query);
const queryText = merchant
? [
merchant.name,
merchant.region_name,
merchant.industry_name,
merchant.description,
...toStrings(merchant.profile?.['services']),
...toStrings(merchant.profile?.['features']),
...toStrings(merchant.profile?.['audiences']),
...toStrings(merchant.profile?.['nearby']),
].filter(Boolean).join(' ')
: query;
const [embedding] = await this.embedder.embed([queryText], 'query');
const rows = await this.sql<Array<{
id: string; canonical: string; intent: string; kind: string; category: string | null;
aliases: string[]; usage_count: number; score: number; linked: boolean;
}>>`
SELECT k.id, k.canonical, k.intent, k.kind, k.category, k.aliases, k.usage_count,
1 - (k.embedding <=> ${toVector(embedding)}::vector) AS score,
(mk.merchant_id IS NOT NULL) AS linked
FROM keyword k
LEFT JOIN merchant_keyword mk
ON mk.keyword_id = k.id AND mk.merchant_id = ${merchant?.id ?? null}
WHERE k.embedding IS NOT NULL
ORDER BY k.embedding <=> ${toVector(embedding)}::vector
LIMIT ${limit}`;
return {
mode: 'single' as const,
input: query,
resolved: merchant
? {
id: merchant.id, externalId: merchant.external_id, name: merchant.name,
region: merchant.region_name, industry: merchant.industry_name,
description: merchant.description, siteUrl: merchant.site_url,
profile: merchant.profile ?? {},
}
: null,
queryText,
embeddingProvider: this.embedder.name,
total: await this.dictionarySize(),
matches: rows.map((r) => ({ ...r, score: Number(r.score) })),
};
}
/** 상호는 띄어쓰기가 흔들린다 — 정규화 일치 → trigram 순으로 해석 */
private async resolveMerchant(query: string) {
const norm = normalizeKeyword(query);
if (!norm) return null;
const rows = await this.sql<Array<MerchantWithTaxonomy & { sim: number }>>`
SELECT m.*,
i.name AS industry_name, i.path::text AS industry_path,
r.name AS region_name, r.path::text AS region_path,
similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) AS sim
FROM merchant m
LEFT JOIN industry i ON i.id = m.industry_id
LEFT JOIN region r ON r.id = m.region_id
WHERE regexp_replace(lower(m.name), '\\s', '', 'g') = ${norm}
OR m.external_id = ${query}
OR similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) >= 0.45
ORDER BY sim DESC NULLS LAST
LIMIT 1`;
return rows[0] ?? null;
}
async dictionarySize() {
const [row] = await this.sql<Array<{ n: number }>>`
SELECT count(*)::int AS n FROM keyword WHERE embedding IS NOT NULL`;
return row?.n ?? 0;
}
async searchKeywords(query: string, limit: number) {
const [embedding] = await this.embedder.embed([query], 'query');
return this.keywords.searchByVector(embedding, 'ko-KR', limit);
}
/** Search Console / 유입 로그 피드백 → 저성과 키워드 강등 */
async applyPerformance(
idOrExternalId: string,
items: Array<{ keyword: string; impressions: number; clicks: number }>,
) {
const m = await this.merchants.findWithTaxonomy(idOrExternalId);
let updated = 0;
for (const it of items) {
const rows = await this.sql<Array<{ keyword_id: string }>>`
UPDATE merchant_keyword mk
SET impressions = mk.impressions + ${it.impressions},
clicks = mk.clicks + ${it.clicks},
ctr = CASE WHEN (mk.impressions + ${it.impressions}) > 0
THEN (mk.clicks + ${it.clicks})::real / (mk.impressions + ${it.impressions})
ELSE 0 END,
updated_at = now()
FROM keyword k
WHERE k.id = mk.keyword_id
AND mk.merchant_id = ${m.id}
AND (k.canonical = ${it.keyword} OR ${it.keyword} = ANY(k.aliases))
RETURNING mk.keyword_id`;
updated += rows.length;
}
// 노출은 충분한데 클릭이 없는 키워드는 강등 → 다음 생성 사이클에서 대체
const demoted = await this.sql<Array<{ keyword_id: string }>>`
UPDATE merchant_keyword
SET status = 'demoted', updated_at = now()
WHERE merchant_id = ${m.id} AND status = 'active'
AND impressions >= 100 AND ctr < 0.002
RETURNING keyword_id`;
return { matched: updated, demoted: demoted.length };
}
}
function toStrings(v: unknown): string[] {
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
}
function buildDescription(name: string, desc: string, keywords: string[]): string {
const base = desc?.trim() || `${name} 안내`;
const tail = keywords.slice(0, 3).join(', ');
const full = tail ? `${base} ${tail} 정보를 확인하세요.` : base;
return full.length > 155 ? `${full.slice(0, 152)}...` : full;
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "scripts"]
}

26
ontology/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"declaration": false,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*", "scripts/**/*"],
"exclude": ["node_modules", "dist"]
}

32
package-lock.json generated
View File

@ -13,6 +13,9 @@
"solution/site",
"admin/frontend"
],
"devDependencies": {
"playwright": "^1.63.0"
},
"engines": {
"node": ">=20"
}
@ -7337,6 +7340,35 @@
"pathe": "^2.0.3"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pony-cause": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz",

View File

@ -26,5 +26,8 @@
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"playwright": "^1.63.0"
}
}

View File

@ -93,11 +93,12 @@ CREATE TABLE IF NOT EXISTS public.users (
-- ============================================================
CREATE TABLE IF NOT EXISTS public.jobs (
job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- ★ 이 표만 server_default 가 꼭 필요하다 — 큐 전이가 raw SQL(RETURNING)이라 ORM 의 파이썬 default 가 안 먹는다
job_type SMALLINT NOT NULL, -- JobType: 1=collect 2=vision 3=copy 4=build 5=local_sync 6=ai_check
job_type SMALLINT NOT NULL, -- JobType: 1=collect 2=vision 3=copy 4=build 5=local_sync 6=ai_check 7=song 8=rollback
status SMALLINT NOT NULL DEFAULT 1, -- JobStatus: 1=pending 2=running 3=done 4=dead
priority SMALLINT NOT NULL DEFAULT 100, -- 낮을수록 우선
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
result JSONB NULL,
progress JSONB NULL,
dedupe_key VARCHAR(200) NULL, -- 활성 중복 방지 키(부분 유니크). 지역 이야기는 story:{region_code}
attempts SMALLINT NOT NULL DEFAULT 0, -- claim 시 +1
max_attempts SMALLINT NOT NULL DEFAULT 3, -- 소진되면 DEAD
@ -229,20 +230,24 @@ CREATE TABLE IF NOT EXISTS public.place_facts (
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- ★ 확보된 fact 만 근거로 쓴다 — source_fact_ids 가 비면 발행 게이트가 반려한다.
-- FAQ. 출처(generated_by)마다 근거 요구가 다르다 — LLM 생성분은 source_fact_ids 필수(없으면 저장 안 함),
-- 사장님 입력(OWNER)·20개 채우기 문의 안내(TEMPLATE)는 근거가 없다. 규칙: docs/DECISIONS.md 8절
CREATE TABLE IF NOT EXISTS public.place_faqs (
faq_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,
question VARCHAR(500) NOT NULL,
answer TEXT NOT NULL,
source_fact_ids JSONB NULL, -- ★ 근거 fact_id 배열. 비면 반려
generated_by SMALLINT NOT NULL, -- SourceType: 4=llm 1=owner
source_fact_ids JSONB NULL, -- 근거 fact key 배열(LLM 생성분만)
generated_by SMALLINT NOT NULL, -- SourceType: 1=owner 4=llm 5=template(문의 안내)
status SMALLINT NOT NULL DEFAULT 1, -- FactStatus. 3·4 만 노출
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- migrations/0012 와 같은 문구다 — 바꾸면 둘 다 고친다.
COMMENT ON COLUMN public.place_faqs.generated_by IS 'SourceType: 1=owner(사장님 입력·정정) 4=llm(확인된 fact 로 생성) 5=template(20개 채우기 문의 안내 — 화면만, JSON-LD·llms.txt 제외)';
COMMENT ON COLUMN public.place_faqs.source_fact_ids IS '근거 fact key 배열. llm 생성분만 채운다(없으면 저장하지 않는다). owner·template 은 NULL 일 수 있다.';
-- LLM 이 만든 여행 일정. 기간당 한 행, body 에 코스 5개가 통째로 든다.
-- ★ 키는 (place_id, duration) 이다 — 업소 이름이 프롬프트에 들어가므로 옆집이 나눠 쓸 수 없다.
@ -313,6 +318,25 @@ CREATE TABLE IF NOT EXISTS public.place_area_refs (
-- ★ 정적 빌드 — DB 는 빌드 시점에만 읽고 방문자와 만나지 않는다.
-- ★ 해지는 물리 삭제가 아니라 상태 전이다. 색인된 페이지를 갑자기 404 로 만들지 않는다.
-- ============================================================
CREATE TABLE IF NOT EXISTS public.site_search_status (
site_id uuid PRIMARY KEY,
site_version_id uuid NOT NULL,
property_url TEXT NOT NULL,
page_url TEXT NOT NULL,
published_at TIMESTAMPTZ NOT NULL,
sitemap_submitted_at TIMESTAMPTZ NULL,
inspected_at TIMESTAMPTZ NULL,
first_indexed_at TIMESTAMPTZ NULL,
inspection JSONB NULL,
error_code VARCHAR(100) NULL,
failures INTEGER NOT NULL DEFAULT 0,
next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(),
alerted_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE IF NOT EXISTS public.sites (
site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL, -- 사업장과 1:1

View File

@ -0,0 +1,29 @@
-- 0012 · place_faqs 출처 코드값 5(TEMPLATE) — FAQ 20개 채우기의 문의 안내 문항
--
-- ★ 컬럼·제약 변경은 없다. generated_by 는 CHECK 없는 SMALLINT 라 값 5 가 그대로 들어간다
-- (로컬 DB 제약 조회 2026-09-14: place_faqs_pkey 하나뿐). 이 파일은 코드값의 뜻을 DB 에 남긴다 —
-- psql 로 행을 보는 사람이 5 를 모르는 값으로 읽지 않게.
-- ★ 옛 주석 "source_fact_ids 가 비면 발행 게이트가 반려한다" 는 사실이 아니었다 — 그런 검사는 없고,
-- 근거 강제는 저장 시점(copy_service)에 있다. 규칙: docs/DECISIONS.md 8절
-- ★ 컬럼이 있을 때만 단다. 0005(표 이름 이동) 전 구조의 DB 에서 손으로 돌려도 실패하지 않게 —
-- COMMENT ON 은 대상이 없으면 에러다. 대상이 있으면 덮어쓰기라 재실행해도 안전하다.
-- init.sql 의 place_faqs 뒤에 같은 COMMENT ON 이 있다 — 문구를 바꾸면 둘 다 고친다.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'place_faqs' AND column_name = 'generated_by'
) THEN
COMMENT ON COLUMN public.place_faqs.generated_by IS
'SourceType: 1=owner(사장님 입력·정정) 4=llm(확인된 fact 로 생성) 5=template(20개 채우기 문의 안내 — 화면만, JSON-LD·llms.txt 제외)';
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'place_faqs' AND column_name = 'source_fact_ids'
) THEN
COMMENT ON COLUMN public.place_faqs.source_fact_ids IS
'근거 fact key 배열. llm 생성분만 채운다(없으면 저장하지 않는다). owner·template 은 NULL 일 수 있다.';
END IF;
END $$;

View File

@ -0,0 +1,2 @@
-- 새로고침 후에도 워커가 마지막으로 기록한 단계를 조회한다.
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS progress JSONB NULL;

View File

@ -0,0 +1,19 @@
-- 발행 상태를 바꾸지 않고 Google 제출/검사 결과를 추적한다.
CREATE TABLE IF NOT EXISTS public.site_search_status (
site_id uuid PRIMARY KEY,
site_version_id uuid NOT NULL,
property_url TEXT NOT NULL,
page_url TEXT NOT NULL,
published_at TIMESTAMPTZ NOT NULL,
sitemap_submitted_at TIMESTAMPTZ NULL,
inspected_at TIMESTAMPTZ NULL,
first_indexed_at TIMESTAMPTZ NULL,
inspection JSONB NULL,
error_code VARCHAR(100) NULL,
failures INTEGER NOT NULL DEFAULT 0,
next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(),
alerted_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT false
);

View File

@ -1,4 +1,9 @@
-- SNS: credentials and approval records never enter public payloads.
-- 0015 · SNS 계정 연결
--
-- ★ 번호가 0012·0013 이었는데 main 에 같은 번호가 이미 있어서(0012_place_faqs_template_source,
-- 0013_job_progress) 병합하며 뒤로 밀었다. migrate.py 는 파일명 정렬로 돌고 적용 이력도
-- 파일명(stem)으로 남기므로, 이미 옛 이름으로 적용된 DB 는 이 파일을 한 번 더 돌린다 —
-- 안에 있는 문장이 전부 IF NOT EXISTS 라 두 번 돌아도 결과가 같다(그래서 밀 수 있었다).
CREATE TABLE IF NOT EXISTS public.owner_social_accounts (
account_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,

View File

@ -1,4 +1,9 @@
-- SNS: credentials and approval records never enter public payloads.
-- 0016 · SNS 게재 글
--
-- ★ 번호가 0012·0013 이었는데 main 에 같은 번호가 이미 있어서(0012_place_faqs_template_source,
-- 0013_job_progress) 병합하며 뒤로 밀었다. migrate.py 는 파일명 정렬로 돌고 적용 이력도
-- 파일명(stem)으로 남기므로, 이미 옛 이름으로 적용된 DB 는 이 파일을 한 번 더 돌린다 —
-- 안에 있는 문장이 전부 IF NOT EXISTS 라 두 번 돌아도 결과가 같다(그래서 밀 수 있었다).
CREATE TABLE IF NOT EXISTS public.place_social_posts (
post_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,

View File

@ -16,6 +16,11 @@ WORKDIR /app
# 의존성 먼저 설치 (레이어 캐시 활용)
COPY solution/backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ★ yanolja_adapter.py 가 Playwright 로 페이지를 렌더링한다 — 패키지(pip)만으로는
# 브라우저 실행 파일이 없다. --with-deps 가 Chromium 이 필요로 하는 시스템 라이브러리
# (libnss3 등)까지 apt 로 같이 깐다. 세 프로세스(웹·admin·워커)가 이 이미지 하나를
# 공유하므로 실제로 fetch() 를 호출하는 건 워커뿐이어도 여기서 한 번만 설치한다.
RUN playwright install --with-deps chromium
COPY solution/backend ./solution/backend
COPY admin/backend ./admin/backend

View File

@ -0,0 +1,70 @@
# 워커 전용 이미지 — solution-backend(:9800)·admin-backend(:9801) 와 **다른 이미지**다.
#
# ★ 왜 갈랐나 (프리렌더를 워커 실행으로 통합, 2026-09-15)
# BUILD·ROLLBACK 잡은 이제 컴파일된 Node 렌더러(solution/site)를 subprocess 로 직접
# 돌린다(services/render_service.py) — 예전에 별도 컨테이너(solution-prerender)가 하던
# 일이다. 그 렌더러가 필요한 건 **워커뿐**이다. Dockerfile(api/admin 겸용)에 Node 를
# 같이 넣으면 API 를 여는 요청만 받는 컨테이너에도 쓰지 않는 Node 런타임과 렌더러
# 번들이 실려 나간다 — 이미지가 커지고 공격 표면도 는다.
#
# ★ 기동·잡 실행 중에 npm install 을 하지 않는다. site-builder 스테이지가 이미지 빌드
# 시점에 한 번 굽고, 최종 이미지는 컴파일된 산출물만 COPY 한다.
# ★ node_modules 는 안 들어간다 — solution/site/vite.config.ts 가 SSR 번들에
# `ssr.noExternal: true` 로 react 등 의존성까지 전부 접어 넣는다(실측: node_modules 를
# 지우고 실행해도 그대로 돈다, dist/prerender/prerender.js 단일 파일 1.8MB).
#
# ★ 빌드 컨텍스트는 solution-backend 와 같은 **레포 루트**다 — solution/site 와
# solution/backend 를 한 이미지에 담아야 한다.
# ── 1단계: 렌더러 컴파일 (Node) ──────────────────────────────────────────────
FROM node:24-bookworm AS site-builder
WORKDIR /app
COPY package.json package-lock.json tsconfig.base.json ./
COPY solution/shared/package.json solution/shared/
COPY solution/frontend/package.json solution/frontend/
COPY solution/site/package.json solution/site/
COPY admin/frontend/package.json admin/frontend/
# ★ npm workspaces 는 루트 package.json 의 workspaces 목록에 있는 자리마다 package.json 을
# 요구한다 — site 만 빌드해도 4개를 전부 COPY 해야 npm ci 가 성공한다(nginx/Dockerfile 과 같다).
RUN npm ci
COPY solution/shared ./solution/shared
COPY solution/site ./solution/site
RUN npm run build:client -w @o2o/site \
&& npm run build:prerender -w @o2o/site
# ── 2단계: Node 런타임만 (바이너리를 뽑아 쓴다) ──────────────────────────────
# ★ python:3.12-slim 과 같은 계열(Debian bookworm)의 태그를 써야 한다 — glibc ABI 가
# 어긋나면 바이너리만 복사해서는 못 돈다("cannot execute binary file" 류로 죽는다).
FROM node:24-bookworm-slim AS node-runtime
# ── 3단계: 최종 워커 이미지 ───────────────────────────────────────────────
FROM python:3.12-slim-bookworm
WORKDIR /app
COPY solution/backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ★ yanolja_adapter.py 가 Playwright 로 페이지를 렌더링한다(수집 파이프라인, 워커 전용 경로) —
# 패키지(pip)만으로는 브라우저 실행 파일이 없다. --with-deps 가 Chromium 이 필요로 하는
# 시스템 라이브러리(libnss3 등)까지 apt 로 같이 깐다.
RUN playwright install --with-deps chromium
# Node 바이너리만 옮겨 온다 — npm·corepack 은 쓰지 않는다(빌드는 site-builder 스테이지가
# 이미 끝냈다). /usr/local/bin/node 하나로 `node dist/prerender/prerender.js` 를 돌리는
# 데 충분하다(실측: 아래 COPY 만으로 `node --version` 과 렌더 스모크 테스트가 통과한다).
COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
COPY solution/backend ./solution/backend
# ★ SITE_ROOT(solution/site/scripts/prerender.ts)가 자기 파일 위치 기준 상대경로로
# payloads·songs·out 을 찾는다 — dist·public 이 이 자리(/app/solution/site/)에 있어야
# 워커가 컨테이너 안에서 렌더러를 그대로 실행할 수 있다.
COPY --from=site-builder /app/solution/site/dist ./solution/site/dist
COPY --from=site-builder /app/solution/site/public ./solution/site/public
ENV APP_ENV=local
ENV PYTHONPATH=/app/solution/backend
WORKDIR /app/solution/backend
# 워커는 포트가 없다 — 이미지 공통 HEALTHCHECK 를 두지 않는다(compose 가 disable 한다).
CMD ["python", "worker_main.py"]

View File

@ -280,7 +280,12 @@ class place_facts(MainTableMixin, MAIN_BASE):
class place_faqs(MainTableMixin, MAIN_BASE):
"""FAQ. ★ 확보된 fact 만 근거로 쓴다 — source_fact_ids 가 비면 발행 게이트가 반려한다."""
"""FAQ. 출처(generated_by)가 셋이고, 근거를 요구하는 정도가 다르다.
LLM 확보된 fact 문장 source_fact_ids 근거 key 있다(없으면 저장하지 않는다)
OWNER 사장님이 쓰거나 고친 문장 사람이 출처라 근거 key 없을 있다
TEMPLATE 목표 수를 채운 공통 질문 + 문의 안내 (services/faq_fill) 주장이 없어 근거도 없다.
화면에는 나가지만 FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수에서는 빠진다."""
__tablename__ = "place_faqs"
@ -288,8 +293,8 @@ class place_faqs(MainTableMixin, MAIN_BASE):
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
question = Column(String(500), nullable=False)
answer = Column(Text, nullable=False)
source_fact_ids = Column(JSONB, nullable=True) # ★ 근거 fact_id 배열. 비면 반려
generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM | OWNER
source_fact_ids = Column(JSONB, nullable=True) # 근거 fact key 배열 — LLM 생성분만 채운다
generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM | OWNER | TEMPLATE
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value)
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
@ -449,6 +454,25 @@ class sites(MainTableMixin, MAIN_BASE):
thumbnail_url = Column(String(500), nullable=True)
class site_search_status(MainTableMixin, MAIN_BASE):
"""발행 성공과 Google 색인 성공은 다른 사건이라 별도 보관한다."""
__tablename__ = "site_search_status"
site_id = Column(UUID(as_uuid=True), primary_key=True)
site_version_id = Column(UUID(as_uuid=True), nullable=False)
property_url = Column(Text, nullable=False)
page_url = Column(Text, nullable=False)
published_at = Column(DateTime(timezone=True), nullable=False)
sitemap_submitted_at = Column(DateTime(timezone=True), nullable=True)
inspected_at = Column(DateTime(timezone=True), nullable=True)
first_indexed_at = Column(DateTime(timezone=True), nullable=True)
inspection = Column(JSONB, nullable=True)
error_code = Column(String(100), nullable=True)
failures = Column(Integer, nullable=False, server_default=text("0"))
next_check_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
alerted_at = Column(DateTime(timezone=True), nullable=True)
class site_sections(MainTableMixin, MAIN_BASE):
"""섹션 하나의 콘텐츠. **JSON import/export 의 단위**다.
@ -558,6 +582,7 @@ class jobs(MainTableMixin, MAIN_BASE):
priority = Column(SmallInteger, nullable=False, server_default=text("100"), default=100) # 낮을수록 우선
payload = Column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) # 잡 입력
result = Column(JSONB, nullable=True) # 잡 출력(완료 시)
progress = Column(JSONB, nullable=True) # 워커가 기록한 단계 상태
dedupe_key = Column(String(200), nullable=True) # 활성 중복 방지 키(부분 유니크)
attempts = Column(SmallInteger, nullable=False, server_default=text("0"), default=0) # claim 시 +1
max_attempts = Column(SmallInteger, nullable=False, server_default=text("3"), default=3)

View File

@ -206,13 +206,14 @@ class PlaceStatus(CodeEnum):
class SourceType(CodeEnum):
"""facts.source_type / media.source_type / place_aliases.source_type 공용 코드값.
"""facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값.
값이 어디서 왔는지 모든 사실은 출처를 갖는다."""
OWNER = 1 # 사장님이 직접 입력·업로드
API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity)
CRAWL = 3 # 크롤링
LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다
TEMPLATE = 5 # FAQ 목표 수를 채운 공통 질문 + 문의 안내(services/faq_fill) — ★ FAQ 전용. fact 에는 못 쓴다
class FactStatus(CodeEnum):
@ -317,7 +318,7 @@ class LocalContentType(CodeEnum):
# 코드값 ↔ **타입명**. `area_contents.kind` 와 `site_sections.data.items[].kind` 가 같은 어휘를 쓴다 —
# 개인화 행(거리·숨김)이 어느 공용 실체를 가리키는지 이름만 보고 알 수 있어야 한다.
# ★ STORY 는 여기 없다. 그 다섯(songs·people·chronicle·postcard·quiz)은 kind 가 곧 타입명이고,
# ★ STORY 는 여기 없다. 그것들(songs·people·chronicle·reading·postcard·quiz)은 kind 가 곧 타입명이고,
# 코드값 하나(6)를 나눠 쓴다. 아래 표는 kind 가 비어 있던 장소류를 채우기 위한 것이다.
AREA_KIND = {
LocalContentType.WEATHER.value: "weather",
@ -327,9 +328,9 @@ AREA_KIND = {
LocalContentType.COURSE.value: "course",
}
# 지역 이야기 여섯. `services/prompts/story.py` 의 산출물 키와 같아야 한다.
# 지역 이야기 일곱. `services/prompts/story.py` 의 산출물 키와 같아야 한다.
# ★ 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`).
STORY_KINDS = ("songs", "daily", "people", "chronicle", "postcard", "quiz")
STORY_KINDS = ("songs", "daily", "people", "chronicle", "reading", "postcard", "quiz")
class LocalSource(CodeEnum):
@ -342,6 +343,7 @@ class LocalSource(CodeEnum):
# ★ 지역 이야기 생성분. 출처는 항목 안의 source.url 이고 이 값은 '누가 모았나'다 —
# 화면이 "AI 가 모았습니다"를 밝힐 근거이자, 나중에 통째로 다시 돌릴 때의 선택자다.
LLM = 5
NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강). docs/DECISIONS.md 1-1 예외 — 봇탐지 우회 없이 공개 응답만 읽는다
class LocalContentStatus(CodeEnum):
@ -387,6 +389,7 @@ class PublishAction(CodeEnum):
REBUILD = 3
SUSPEND = 4
RESUME = 5
ROLLBACK = 6 # 예전 버전으로 공개 주소를 되돌림 — services/rollback_service.py
class PublishResult(CodeEnum):
@ -431,9 +434,14 @@ class JobType(CodeEnum):
BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위
LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회)
AI_CHECK = 6 # AI 검색 노출 점검
SOCIAL_DRAFT = 8
SOCIAL_POST = 9
SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). 발행이 이 잡을 건다
ROLLBACK = 8 # 예전 버전 스냅샷으로 다시 굽고 공개 주소를 그 버전으로 되돌림
# ★ 번호를 재사용하지 않는다. 같은 값을 두 번 쓰면 파이썬 enum 이 **조용히 별칭**으로 묶어
# (`JobType.ROLLBACK is JobType.SOCIAL_DRAFT` → True) 한쪽 잡이 남의 핸들러로 간다.
# 실제로 그랬다(2026-09-16, 병합): SNS 가 8·9 를, 롤백이 8 을 각자 가져와 겹쳤다.
# 에러도 안 나고 `HANDLERS` 조회만 조용히 뒤바뀐다 — 다음 번호는 늘 마지막 +1 이다.
SOCIAL_DRAFT = 9 # SNS 소개글 초안 작성 (Gemini) — 사장님이 누를 때만
SOCIAL_POST = 10 # 승인된 글을 사장님 계정으로 게시 (Threads)
class JobStatus(CodeEnum):

View File

@ -0,0 +1,135 @@
"""FAQ 질문 카탈로그 — 생성된 FAQ 가 목표 수에 모자랄 때 채울 업종 공통 질문.
필요한가
COPY 잡은 확인된 fact 로만 FAQ 쓴다. 실측(2026-09-14, 로컬): 스테이머뭄 fact 8,
산하연 풀빌라 4 근거로는 FAQ 4~8개에서 끝난다. 20개를 채우려면 근거 밖의 문항이 필요하다.
공통 **** 주장을 하지 않는다 "…은 숙소로 문의 부탁드립니다" 뿐이다.
예전 업종 시드 FAQ 에는 "숯과 그릴 세트(25,000원)" 같은 가공의 값이 있었고, 사장님이 팔지도 않는
조건이 사이트에 나갔다(frontend canvas/variants/faq/useFaqList.ts). 공통 답에 ·가능 여부를 적으면
같은 사고다. 문의 안내만 쓴다.
resources/*.json 최초 사용 로드·검증한다. fact_keys 업종 스키마에 있는 key 여야 한다
오타가 key 영영 "fact 없음" 으로 읽혀, 답이 있는 질문에 문의 안내가 붙는다.
"""
import json
from dataclasses import dataclass
from pathlib import Path
from common.category_schema import get_schema
from common.enums import PlaceCategory
_RESOURCE_DIR = Path(__file__).parent / "resources"
_catalogs: list | None = None
class FaqCatalogError(RuntimeError):
"""카탈로그 리소스 로드/검증 실패."""
@dataclass(frozen=True)
class CatalogItem:
id: str
question: str
topic: str # 공통 답변 문구에 들어갈 주제("반려동물 동반 가능 여부")
fact_keys: tuple[str, ...] # 이 질문에 답할 수 있는 fact. 하나라도 있으면 공통 답으로 채우지 않는다
keywords: tuple[str, ...] # 기존 FAQ 질문에 이 낱말이 있으면 같은 주제로 본다(공백 없이 비교)
@dataclass(frozen=True)
class FaqCatalog:
name: str
label: str
category: PlaceCategory
exclude_external_categories: tuple[str, ...]
fallback_with_contact: tuple[str, ...]
fallback_without_contact: tuple[str, ...]
items: tuple[CatalogItem, ...]
def applies_to(self, category: int, external_category: str | None) -> bool:
"""업종 코드가 같고, 외부 분류가 제외 목록에 걸리지 않으면 이 카탈로그를 쓴다.
외부 분류가 비어 있으면 **쓴다.** 스테이머뭄처럼 네이버 분류가 없는 펜션이 있다.
호텔은 분류가 "호텔" 오므로 제외 목록이 막는다 호텔에 바비큐·픽업 문항이 붙으면 된다."""
if category != self.category.value:
return False
label = external_category or ""
return not any(word in label for word in self.exclude_external_categories)
def _strings(value, source: str, name: str, *, allow_empty: bool = False) -> tuple[str, ...]:
if not isinstance(value, list) or (not value and not allow_empty):
raise FaqCatalogError(f"{source}: '{name}' 은 비어 있지 않은 목록이어야 함")
if not all(isinstance(v, str) and v for v in value):
raise FaqCatalogError(f"{source}: '{name}' 에 문자열이 아닌 값 — {value}")
return tuple(value)
def _parse(doc: dict, source: str) -> FaqCatalog:
try:
category = PlaceCategory(doc.get("category"))
except ValueError as ex:
raise FaqCatalogError(f"{source}: PlaceCategory 에 없는 category={doc.get('category')}") from ex
schema = get_schema(category)
answers = doc.get("fallback_answers") or {}
with_contact = _strings(answers.get("with_contact"), source, "with_contact")
without_contact = _strings(answers.get("without_contact"), source, "without_contact")
for text in with_contact + without_contact:
if "{topic}" not in text:
raise FaqCatalogError(f"{source}: 공통 답변에 {{topic}} 이 없음 — {text}")
if not all("{contact}" in text for text in with_contact):
raise FaqCatalogError(f"{source}: with_contact 답변에 {{contact}} 가 없음")
items: list[CatalogItem] = []
seen: set[str] = set()
for row in doc.get("items") or []:
for name in ("id", "question", "topic"):
if not isinstance(row.get(name), str) or not row[name].strip():
raise FaqCatalogError(f"{source}: 항목 '{name}' 이 비었음 — {row}")
if row["id"] in seen:
raise FaqCatalogError(f"{source}: id 중복 — {row['id']}")
seen.add(row["id"])
fact_keys = _strings(row.get("fact_keys"), source, f"{row['id']}.fact_keys", allow_empty=True)
unknown = [k for k in fact_keys if not schema.has(k)]
if unknown:
raise FaqCatalogError(f"{source}: {row['id']} 의 fact_keys 가 업종 스키마에 없음 — {unknown}")
keywords = _strings(row.get("keywords"), source, f"{row['id']}.keywords")
if any(" " in k for k in keywords):
raise FaqCatalogError(f"{source}: {row['id']} 의 keywords 에 공백 — 질문은 공백을 지우고 비교한다")
items.append(CatalogItem(row["id"], row["question"], row["topic"], fact_keys,
tuple(k.lower() for k in keywords)))
if not items:
raise FaqCatalogError(f"{source}: items 가 비었음")
return FaqCatalog(
name=str(doc.get("name") or source),
label=str(doc.get("label") or ""),
category=category,
exclude_external_categories=_strings(
doc.get("exclude_external_categories") or [], source, "exclude_external_categories", allow_empty=True),
fallback_with_contact=with_contact,
fallback_without_contact=without_contact,
items=tuple(items),
)
def load_catalogs() -> list[FaqCatalog]:
"""리소스 디렉터리 전체 로드 + 검증. 최초 1회(멱등)."""
global _catalogs
if _catalogs is None:
loaded = []
for path in sorted(_RESOURCE_DIR.glob("*.json")):
try:
doc = json.loads(path.read_text(encoding="utf-8"))
except Exception as ex:
raise FaqCatalogError(f"카탈로그 파일 로드 실패: {path}: {ex}") from ex
loaded.append(_parse(doc, path.name))
_catalogs = loaded
return list(_catalogs)
def find_catalog(category: int, external_category: str | None) -> FaqCatalog | None:
"""이 사업장에 쓸 카탈로그. 없으면 None — 채우지 않는다(카페·음식점은 아직 목록이 없다)."""
return next((c for c in load_catalogs() if c.applies_to(category, external_category)), None)

View File

@ -0,0 +1,50 @@
{
"name": "pension",
"label": "펜션",
"category": 1,
"exclude_external_categories": ["호텔", "모텔", "리조트"],
"fallback_answers": {
"with_contact": [
"{topic} {contact}로 문의해 주시면 안내해 드립니다.",
"{topic} {contact}로 연락 주시면 자세히 알려 드립니다.",
"{topic} 예약 전에 {contact}로 확인해 주세요."
],
"without_contact": [
"{topic} 예약 전에 숙소로 직접 문의 부탁드립니다.",
"{topic} 숙소에 문의해 주시면 안내해 드립니다.",
"{topic} 이용 전에 숙소로 확인 부탁드립니다."
]
},
"items": [
{"id": "check_in", "question": "체크인은 몇 시부터인가요?", "topic": "체크인 시간", "fact_keys": ["check_in_time"], "keywords": ["체크인", "입실시간"]},
{"id": "check_out", "question": "체크아웃은 몇 시까지인가요?", "topic": "체크아웃 시간", "fact_keys": ["check_out_time"], "keywords": ["체크아웃", "퇴실시간"]},
{"id": "late_check_in", "question": "늦은 시간에 도착해도 입실할 수 있나요?", "topic": "늦은 시간 입실 가능 여부", "fact_keys": ["reception_hours"], "keywords": ["늦은시간", "늦게도착", "늦게입실", "늦은입실", "밤늦게"]},
{"id": "luggage", "question": "입실 전이나 퇴실 후에 짐을 맡길 수 있나요?", "topic": "짐 보관 가능 여부", "fact_keys": [], "keywords": ["짐보관", "짐을맡", "짐맡"]},
{"id": "checkout_cleaning", "question": "퇴실할 때 설거지나 분리수거를 해야 하나요?", "topic": "퇴실 시 정리 방법", "fact_keys": [], "keywords": ["설거지", "분리수거", "퇴실청소", "뒷정리"]},
{"id": "cancel", "question": "예약을 취소하면 환불은 어떻게 되나요?", "topic": "취소·환불 규정", "fact_keys": ["cancel_policy"], "keywords": ["취소", "환불"]},
{"id": "weekday_weekend_price", "question": "평일과 주말 요금이 다른가요?", "topic": "평일·주말 요금", "fact_keys": ["weekday_price", "weekend_price"], "keywords": ["평일", "주말요금"]},
{"id": "peak_price", "question": "성수기 요금이 따로 있나요?", "topic": "성수기 요금", "fact_keys": ["peak_price"], "keywords": ["성수기"]},
{"id": "deposit", "question": "보증금이 있나요?", "topic": "보증금 여부", "fact_keys": [], "keywords": ["보증금"]},
{"id": "extra_costs", "question": "숙박 요금 외에 추가로 드는 비용이 있나요?", "topic": "숙박 요금 외 추가 비용", "fact_keys": ["extra_person_fee", "bbq_fee"], "keywords": ["추가비용", "추가로드는", "별도요금"]},
{"id": "capacity", "question": "기준 인원과 최대 인원은 몇 명인가요?", "topic": "기준 인원과 최대 인원", "fact_keys": ["standard_capacity", "max_capacity"], "keywords": ["기준인원", "최대인원"]},
{"id": "extra_person", "question": "인원을 추가하면 요금이 얼마인가요?", "topic": "인원 추가 요금", "fact_keys": ["extra_person_fee"], "keywords": ["인원추가", "추가인원", "인원을추가"]},
{"id": "pet", "question": "반려동물과 함께 묵을 수 있나요?", "topic": "반려동물 동반 가능 여부", "fact_keys": ["pet_allowed"], "keywords": ["반려", "애견", "강아지"]},
{"id": "baby", "question": "아기와 함께 가도 되나요? 유아용품이 있나요?", "topic": "유아 동반과 유아용품", "fact_keys": ["baby_amenities"], "keywords": ["아기", "유아"]},
{"id": "room_types", "question": "어떤 객실(동)이 있나요?", "topic": "객실 구성", "fact_keys": ["room_type", "total_rooms", "building_scale"], "keywords": ["객실종류", "어떤객실", "객실구성"]},
{"id": "bed", "question": "침대는 어떻게 구성되어 있나요?", "topic": "침대 구성", "fact_keys": ["bed_type"], "keywords": ["침대", "침구"]},
{"id": "bathroom", "question": "객실에 욕실은 몇 개인가요?", "topic": "욕실 구성", "fact_keys": ["bathroom_count", "has_bathroom"], "keywords": ["욕실", "화장실"]},
{"id": "toiletries", "question": "수건과 세면도구가 준비되어 있나요?", "topic": "수건·세면도구 제공 여부", "fact_keys": ["has_toiletries"], "keywords": ["수건", "세면도구", "어메니티"]},
{"id": "aircon", "question": "냉난방이 되나요?", "topic": "냉난방 시설", "fact_keys": ["has_aircon"], "keywords": ["냉난방", "에어컨", "난방"]},
{"id": "wifi", "question": "와이파이를 쓸 수 있나요?", "topic": "와이파이 이용 여부", "fact_keys": ["wifi", "has_internet"], "keywords": ["와이파이", "인터넷", "wifi"]},
{"id": "view", "question": "객실에서 어떤 풍경이 보이나요?", "topic": "객실 전망", "fact_keys": ["view"], "keywords": ["풍경", "전망"]},
{"id": "cooking", "question": "객실에서 요리할 수 있나요?", "topic": "객실 내 취사 가능 여부", "fact_keys": ["cooking_allowed", "has_kitchen"], "keywords": ["취사", "요리", "조리"]},
{"id": "bbq", "question": "바비큐를 할 수 있나요?", "topic": "바비큐 이용 가능 여부", "fact_keys": ["bbq_available"], "keywords": ["바비큐", "바베큐"]},
{"id": "bbq_fee", "question": "바비큐 요금과 제공되는 준비물은 무엇인가요?", "topic": "바비큐 요금과 준비물", "fact_keys": ["bbq_fee"], "keywords": ["바비큐요금", "바베큐요금", "바비큐비용", "숯"]},
{"id": "breakfast", "question": "조식이 제공되나요?", "topic": "조식 제공 여부", "fact_keys": ["breakfast"], "keywords": ["조식", "아침식사"]},
{"id": "pool", "question": "수영장이 있나요? 온수로 이용할 수 있나요?", "topic": "수영장·온수 이용 여부", "fact_keys": [], "keywords": ["수영장", "온수풀", "풀장"]},
{"id": "smoking", "question": "흡연할 수 있는 곳이 있나요?", "topic": "흡연 가능 구역", "fact_keys": ["smoking"], "keywords": ["흡연", "담배"]},
{"id": "parking", "question": "주차할 수 있나요? 몇 대까지 가능한가요?", "topic": "주차 가능 여부", "fact_keys": ["parking", "parking_capacity"], "keywords": ["주차"]},
{"id": "pickup", "question": "대중교통으로 가면 픽업이 되나요?", "topic": "픽업 서비스", "fact_keys": ["pickup_service"], "keywords": ["픽업", "대중교통", "셔틀"]},
{"id": "nearby", "question": "근처에 편의점이나 마트가 있나요?", "topic": "주변 편의시설", "fact_keys": [], "keywords": ["편의점", "마트"]}
]
}

View File

@ -0,0 +1,18 @@
"""재시도가 의미 없는 잡 실패.
따로 두나 큐는 실패를 전부 "일시적" 으로 보고 백오프 재큐한다(crud/job_crud.fail).
네트워크가 끊겼거나 외부 API 잠깐 죽은 것이라면 맞는 판단이다. 그런데 사장님이
사업장을 지운 뒤에 남은 , 지원하지 않는 업종 같은 것은 ** 번을 다시 해도 같은 결과**.
실측(2026-09-15): 진행 중이던 소개문 잡이 사업장 삭제 "사업장을 찾을 수 없다"
재시도하고 DEAD 갔다 지연과 DEAD 알림만 늘었다.
도메인의 `*Aborted` 이미 머리주석에 "재시도해도 소용없는 중단" 이라고 적고 있었다.
뜻을 워커가 읽을 있는 자리로 옮긴 것이지, 규칙을 만든 아니다.
services worker 함께 쓰므로 common 둔다 services worker import 하면
의존 방향이 뒤집힌다.
"""
class PermanentJobError(RuntimeError):
"""다시 시도해도 결과가 같은 실패. 워커가 재큐하지 않고 바로 DEAD 로 보낸다."""

Some files were not shown because too many files have changed in this diff Show More