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:
commit
58d6249102
13
.env.example
13
.env.example
@ -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=
|
||||
|
||||
58
AGENTS.md
58
AGENTS.md
@ -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
137
deploy.sh
@ -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"
|
||||
|
||||
13
docker-compose.search-console.yml
Normal file
13
docker-compose.search-console.yml
Normal 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
|
||||
@ -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:
|
||||
|
||||
@ -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` 을 비워 두면 발행 잡이 업로드 단계를 건너뛴다.
|
||||
|
||||
@ -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·사진 적재 |
|
||||
|
||||
@ -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`)가 호텔·모텔·리조트가 아닌 곳.
|
||||
분류가 비어도 적용한다(펜션인데 네이버 분류가 없는 곳이 있다). 카페·음식점·체험시설은 카탈로그가 없어 채우지 않는다.
|
||||
|
||||
@ -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).
|
||||
|
||||
120
docs/DEVLOG.md
120
docs/DEVLOG.md
@ -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
46
docs/GENERATION_FLOW.md
Normal 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
32
docs/PUBLISH_VERSION.md
Normal 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
99
docs/SEARCH_CONSOLE.md
Normal 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 실호출 없음. 서비스 계정 권한·실제 제출·채널 수신은 운영 설정 후 검증 대상.
|
||||
71
docs/SEARCH_CONSOLE_CLIENT.md
Normal file
71
docs/SEARCH_CONSOLE_CLIENT.md
Normal 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
11
docs/TEAMS_WEBHOOK.md
Normal 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
17
docs/WEATHER.md
Normal 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
5
ontology/.dockerignore
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
*.log
|
||||
37
ontology/.env.example
Normal file
37
ontology/.env.example
Normal 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
8
ontology/.gitignore
vendored
Normal 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
23
ontology/Dockerfile
Normal 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
429
ontology/README.md
Normal 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` 로 바꿔 다시 생성하면 된다.
|
||||
7008
ontology/data/gunsan-pension-keywords.json
Normal file
7008
ontology/data/gunsan-pension-keywords.json
Normal file
File diff suppressed because it is too large
Load Diff
85557
ontology/data/nationwide-pension-keywords.json
Normal file
85557
ontology/data/nationwide-pension-keywords.json
Normal file
File diff suppressed because it is too large
Load Diff
803
ontology/data/regions.json
Normal file
803
ontology/data/regions.json
Normal 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": [
|
||||
"중문색달해변",
|
||||
"성산일출봉",
|
||||
"쇠소깍",
|
||||
"천지연폭포",
|
||||
"우도"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
8
ontology/data/related-keywords.sample.csv
Normal file
8
ontology/data/related-keywords.sample.csv
Normal file
@ -0,0 +1,8 @@
|
||||
# 네이버 검색광고 > 도구 > 키워드도구 에서 내려받은 CSV 를 이 형태로 두면 된다.
|
||||
# (아래 숫자는 형식 예시용 더미값 — 실제 데이터로 교체할 것)
|
||||
relKeyword,monthlyPcQcCnt,monthlyMobileQcCnt,compIdx
|
||||
군산독채펜션,210,1830,중간
|
||||
군산감성숙소,90,760,낮음
|
||||
군산2인펜션,40,310,낮음
|
||||
군산뚜벅이여행,30,240,낮음
|
||||
말랭이마을숙소,10,90,낮음
|
||||
|
BIN
ontology/data/배포용_키워드_DB덤프.xlsx
Normal file
BIN
ontology/data/배포용_키워드_DB덤프.xlsx
Normal file
Binary file not shown.
BIN
ontology/data/전국_펜션_SEO_AEO_키워드.xlsx
Normal file
BIN
ontology/data/전국_펜션_SEO_AEO_키워드.xlsx
Normal file
Binary file not shown.
33
ontology/docker-compose.yml
Normal file
33
ontology/docker-compose.yml
Normal 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:
|
||||
714
ontology/docs/architecture.html
Normal file
714
ontology/docs/architecture.html
Normal 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 < 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><=></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 && 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>
|
||||
BIN
ontology/docs/site-ontology 아키텍쳐.pptx
Normal file
BIN
ontology/docs/site-ontology 아키텍쳐.pptx
Normal file
Binary file not shown.
124
ontology/drizzle/0000_init.sql
Normal file
124
ontology/drizzle/0000_init.sql
Normal 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);
|
||||
22
ontology/drizzle/0001_embedding_384.sql
Normal file
22
ontology/drizzle/0001_embedding_384.sql
Normal 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
8
ontology/nest-cli.json
Normal 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
6333
ontology/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
ontology/package.json
Normal file
51
ontology/package.json
Normal 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
333
ontology/public/demo.html
Normal 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 =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
$('#f').addEventListener('submit', (e) => { e.preventDefault(); run(); });
|
||||
health(); run();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
204
ontology/scripts/build-dataset.mjs
Normal file
204
ontology/scripts/build-dataset.mjs
Normal 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'));
|
||||
524
ontology/scripts/build-deck.py
Normal file
524
ontology/scripts/build-deck.py
Normal 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')
|
||||
160
ontology/scripts/build-nationwide-dataset.mjs
Normal file
160
ontology/scripts/build-nationwide-dataset.mjs
Normal 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
10
ontology/scripts/db-dump.sh
Executable 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 확장이 설치돼 있어야 한다)"
|
||||
157
ontology/scripts/export-db-xlsx.py
Normal file
157
ontology/scripts/export-db-xlsx.py
Normal 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()) + ')')
|
||||
155
ontology/scripts/export-xlsx.py
Normal file
155
ontology/scripts/export-xlsx.py
Normal 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)}개')
|
||||
90
ontology/scripts/import-related.ts
Normal file
90
ontology/scripts/import-related.ts
Normal 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();
|
||||
103
ontology/scripts/ingest-dataset.ts
Normal file
103
ontology/scripts/ingest-dataset.ts
Normal 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); });
|
||||
105
ontology/scripts/ingest-nationwide.ts
Normal file
105
ontology/scripts/ingest-nationwide.ts
Normal 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); });
|
||||
35
ontology/scripts/purge-nondataset.ts
Normal file
35
ontology/scripts/purge-nondataset.ts
Normal 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); });
|
||||
3
ontology/scripts/requirements.txt
Normal file
3
ontology/scripts/requirements.txt
Normal 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
41
ontology/scripts/setup.sh
Executable 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
103
ontology/scripts/smoke.ts
Normal 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);
|
||||
});
|
||||
36
ontology/src/app.module.ts
Normal file
36
ontology/src/app.module.ts
Normal 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 {}
|
||||
34
ontology/src/config/env.ts
Normal file
34
ontology/src/config/env.ts
Normal 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';
|
||||
16
ontology/src/db/db.module.ts
Normal file
16
ontology/src/db/db.module.ts
Normal 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
17
ontology/src/db/db.ts
Normal 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];
|
||||
24
ontology/src/db/migrate.ts
Normal file
24
ontology/src/db/migrate.ts
Normal 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
119
ontology/src/db/seed.ts
Normal 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);
|
||||
});
|
||||
19
ontology/src/embedding/embedding.module.ts
Normal file
19
ontology/src/embedding/embedding.module.ts
Normal 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 {}
|
||||
48
ontology/src/embedding/local.provider.ts
Normal file
48
ontology/src/embedding/local.provider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
14
ontology/src/embedding/mock.provider.ts
Normal file
14
ontology/src/embedding/mock.provider.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
21
ontology/src/embedding/openai.provider.ts
Normal file
21
ontology/src/embedding/openai.provider.ts
Normal 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[]);
|
||||
}
|
||||
}
|
||||
8
ontology/src/embedding/types.ts
Normal file
8
ontology/src/embedding/types.ts
Normal 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[][]>;
|
||||
}
|
||||
20
ontology/src/generation/generation.module.ts
Normal file
20
ontology/src/generation/generation.module.ts
Normal 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 {}
|
||||
21
ontology/src/generation/generation.processor.ts
Normal file
21
ontology/src/generation/generation.processor.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
50
ontology/src/generation/generation.queue.ts
Normal file
50
ontology/src/generation/generation.queue.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
216
ontology/src/generation/generation.service.ts
Normal file
216
ontology/src/generation/generation.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
113
ontology/src/keywords/dedup.service.ts
Normal file
113
ontology/src/keywords/dedup.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
124
ontology/src/keywords/keyword.repository.ts
Normal file
124
ontology/src/keywords/keyword.repository.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
9
ontology/src/keywords/keywords.module.ts
Normal file
9
ontology/src/keywords/keywords.module.ts
Normal 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 {}
|
||||
33
ontology/src/keywords/normalize.ts
Normal file
33
ontology/src/keywords/normalize.ts
Normal 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));
|
||||
}
|
||||
16
ontology/src/llm/llm.module.ts
Normal file
16
ontology/src/llm/llm.module.ts
Normal 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 {}
|
||||
160
ontology/src/llm/mock.provider.ts
Normal file
160
ontology/src/llm/mock.provider.ts
Normal 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;
|
||||
}
|
||||
114
ontology/src/llm/openai.provider.ts
Normal file
114
ontology/src/llm/openai.provider.ts
Normal 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
46
ontology/src/llm/types.ts
Normal 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
17
ontology/src/main.ts
Normal 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();
|
||||
10
ontology/src/merchants/merchants.controller.module.ts
Normal file
10
ontology/src/merchants/merchants.controller.module.ts
Normal 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 {}
|
||||
53
ontology/src/merchants/merchants.controller.ts
Normal file
53
ontology/src/merchants/merchants.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
8
ontology/src/merchants/merchants.module.ts
Normal file
8
ontology/src/merchants/merchants.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MerchantsService } from './merchants.service';
|
||||
|
||||
@Module({
|
||||
providers: [MerchantsService],
|
||||
exports: [MerchantsService],
|
||||
})
|
||||
export class MerchantsModule {}
|
||||
92
ontology/src/merchants/merchants.service.ts
Normal file
92
ontology/src/merchants/merchants.service.ts
Normal 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);
|
||||
13
ontology/src/serving/demo.controller.ts
Normal file
13
ontology/src/serving/demo.controller.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
182
ontology/src/serving/match.rules.ts
Normal file
182
ontology/src/serving/match.rules.ts
Normal 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;
|
||||
}
|
||||
253
ontology/src/serving/match.service.ts
Normal file
253
ontology/src/serving/match.service.ts
Normal 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 ?? {},
|
||||
};
|
||||
}
|
||||
56
ontology/src/serving/serving.controller.ts
Normal file
56
ontology/src/serving/serving.controller.ts
Normal 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;
|
||||
}
|
||||
14
ontology/src/serving/serving.module.ts
Normal file
14
ontology/src/serving/serving.module.ts
Normal 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 {}
|
||||
230
ontology/src/serving/serving.service.ts
Normal file
230
ontology/src/serving/serving.service.ts
Normal 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;
|
||||
}
|
||||
4
ontology/tsconfig.build.json
Normal file
4
ontology/tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "scripts"]
|
||||
}
|
||||
26
ontology/tsconfig.json
Normal file
26
ontology/tsconfig.json
Normal 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
32
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -26,5 +26,8 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "^1.63.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
29
postgres-init/migrations/0012_place_faqs_template_source.sql
Normal file
29
postgres-init/migrations/0012_place_faqs_template_source.sql
Normal 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 $$;
|
||||
2
postgres-init/migrations/0013_job_progress.sql
Normal file
2
postgres-init/migrations/0013_job_progress.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- 새로고침 후에도 워커가 마지막으로 기록한 단계를 조회한다.
|
||||
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS progress JSONB NULL;
|
||||
19
postgres-init/migrations/0014_search_console.sql
Normal file
19
postgres-init/migrations/0014_search_console.sql
Normal 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
|
||||
);
|
||||
@ -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,
|
||||
@ -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,
|
||||
@ -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
|
||||
|
||||
70
solution/backend/Dockerfile.worker
Normal file
70
solution/backend/Dockerfile.worker
Normal 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"]
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
135
solution/backend/common/faq_catalog/__init__.py
Normal file
135
solution/backend/common/faq_catalog/__init__.py
Normal 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)
|
||||
50
solution/backend/common/faq_catalog/resources/pension.json
Normal file
50
solution/backend/common/faq_catalog/resources/pension.json
Normal 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": ["편의점", "마트"]}
|
||||
]
|
||||
}
|
||||
18
solution/backend/common/job_errors.py
Normal file
18
solution/backend/common/job_errors.py
Normal 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
Loading…
Reference in New Issue
Block a user