Merge branch 'main' into feature/crawler
This commit is contained in:
commit
dd3ad86715
@ -42,6 +42,11 @@ TOUR_API_KEY=
|
|||||||
# 그래도 API 가 필수로 요구하는 필드라 값을 채워 보낸다.
|
# 그래도 API 가 필수로 요구하는 필드라 값을 채워 보낸다.
|
||||||
SUNO_API_KEY=
|
SUNO_API_KEY=
|
||||||
SUNO_CALLBACK_URL=https://example.com/api/suno/callback
|
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=
|
||||||
|
|
||||||
|
|
||||||
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
|
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
|
||||||
|
|||||||
@ -300,6 +300,68 @@ services:
|
|||||||
driver: json-file
|
driver: json-file
|
||||||
options: { max-size: "10m", max-file: "5" }
|
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
|
||||||
|
options: { max-size: "10m", max-file: "5" }
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# ★ `down -v` 만 지운다. 재생성물이라 백업 대상이 아니다 — 날아가도 payload 로 다시 굽는다.
|
# ★ `down -v` 만 지운다. 재생성물이라 백업 대상이 아니다 — 날아가도 payload 로 다시 굽는다.
|
||||||
site-out:
|
site-out:
|
||||||
|
# 온톨로지 DB 와 임베딩 모델 캐시. 모델 캐시가 날아가면 첫 요청이 120MB 를 다시 받는다.
|
||||||
|
ontology-pgdata:
|
||||||
|
ontology-model:
|
||||||
|
|||||||
@ -37,7 +37,10 @@ React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포
|
|||||||
|
|
||||||
```
|
```
|
||||||
BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
|
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()
|
├ 1차 게이트 (DB 사실 기준) → publish_gate.evaluate()
|
||||||
├ site_payload.emit_payload() → out/payloads/<slug>.json ★ 백엔드의 유일한 산출물
|
├ site_payload.emit_payload() → out/payloads/<slug>.json ★ 백엔드의 유일한 산출물
|
||||||
│
|
│
|
||||||
|
|||||||
@ -135,8 +135,16 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
|
|||||||
|
|
||||||
### `place_faqs` — FAQ
|
### `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` — 이 숙소의 노래
|
### `place_songs` — 이 숙소의 노래
|
||||||
|
|
||||||
|
|||||||
@ -282,3 +282,32 @@ LLM 만 그 경로를 지나가게 되면서 `fact_service.upsert_fact` 에 잠
|
|||||||
손댔는지 알 수 없다. `expire_generated` 는 `generated_by` 로 가른다 — 사장님이 정정하면
|
손댔는지 알 수 없다. `expire_generated` 는 `generated_by` 로 가른다 — 사장님이 정정하면
|
||||||
`faq_service` 가 그 값을 `OWNER` 로 바꾼다(책임 주체의 기록이고, 원래부터 있던 자리다).
|
`faq_service` 가 그 값을 `OWNER` 로 바꾼다(책임 주체의 기록이고, 원래부터 있던 자리다).
|
||||||
반려(`REJECTED`)한 FAQ 는 그대로 둔다.
|
반려(`REJECTED`)한 FAQ 는 그대로 둔다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`)가 호텔·모텔·리조트가 아닌 곳.
|
||||||
|
분류가 비어도 적용한다(펜션인데 네이버 분류가 없는 곳이 있다). 카페·음식점·체험시설은 카탈로그가 없어 채우지 않는다.
|
||||||
|
|||||||
@ -5,6 +5,79 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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-11 — 발행하면 이 숙소의 노래가 한 곡 생긴다 (가사 Gemini → 작곡 Suno)
|
## 2026-09-11 — 발행하면 이 숙소의 노래가 한 곡 생긴다 (가사 Gemini → 작곡 Suno)
|
||||||
|
|
||||||
**무슨 일** — `/s/stay` 시안에는 헤더에 노래 플레이어가 있는데, 그건 손으로 채운 목업이라
|
**무슨 일** — `/s/stay` 시안에는 헤더에 노래 플레이어가 있는데, 그건 손으로 채운 목업이라
|
||||||
|
|||||||
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"]
|
||||||
|
}
|
||||||
@ -229,20 +229,24 @@ CREATE TABLE IF NOT EXISTS public.place_facts (
|
|||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
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 (
|
CREATE TABLE IF NOT EXISTS public.place_faqs (
|
||||||
faq_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
faq_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
place_id uuid NOT NULL,
|
place_id uuid NOT NULL,
|
||||||
question VARCHAR(500) NOT NULL,
|
question VARCHAR(500) NOT NULL,
|
||||||
answer TEXT NOT NULL,
|
answer TEXT NOT NULL,
|
||||||
source_fact_ids JSONB NULL, -- ★ 근거 fact_id 배열. 비면 반려
|
source_fact_ids JSONB NULL, -- 근거 fact key 배열(LLM 생성분만)
|
||||||
generated_by SMALLINT NOT NULL, -- SourceType: 4=llm 1=owner
|
generated_by SMALLINT NOT NULL, -- SourceType: 1=owner 4=llm 5=template(문의 안내)
|
||||||
status SMALLINT NOT NULL DEFAULT 1, -- FactStatus. 3·4 만 노출
|
status SMALLINT NOT NULL DEFAULT 1, -- FactStatus. 3·4 만 노출
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
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개가 통째로 든다.
|
-- LLM 이 만든 여행 일정. 기간당 한 행, body 에 코스 5개가 통째로 든다.
|
||||||
-- ★ 키는 (place_id, duration) 이다 — 업소 이름이 프롬프트에 들어가므로 옆집이 나눠 쓸 수 없다.
|
-- ★ 키는 (place_id, duration) 이다 — 업소 이름이 프롬프트에 들어가므로 옆집이 나눠 쓸 수 없다.
|
||||||
|
|||||||
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 $$;
|
||||||
@ -280,7 +280,12 @@ class place_facts(MainTableMixin, MAIN_BASE):
|
|||||||
|
|
||||||
|
|
||||||
class place_faqs(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"
|
__tablename__ = "place_faqs"
|
||||||
|
|
||||||
@ -288,8 +293,8 @@ class place_faqs(MainTableMixin, MAIN_BASE):
|
|||||||
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
question = Column(String(500), nullable=False)
|
question = Column(String(500), nullable=False)
|
||||||
answer = Column(Text, nullable=False)
|
answer = Column(Text, nullable=False)
|
||||||
source_fact_ids = Column(JSONB, nullable=True) # ★ 근거 fact_id 배열. 비면 반려
|
source_fact_ids = Column(JSONB, nullable=True) # 근거 fact key 배열 — LLM 생성분만 채운다
|
||||||
generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM | OWNER
|
generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM | OWNER | TEMPLATE
|
||||||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value)
|
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)
|
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
|
||||||
|
|
||||||
|
|||||||
@ -206,13 +206,14 @@ class PlaceStatus(CodeEnum):
|
|||||||
|
|
||||||
|
|
||||||
class SourceType(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 # 사장님이 직접 입력·업로드
|
OWNER = 1 # 사장님이 직접 입력·업로드
|
||||||
API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity)
|
API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity)
|
||||||
CRAWL = 3 # 크롤링
|
CRAWL = 3 # 크롤링
|
||||||
LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다
|
LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다
|
||||||
|
TEMPLATE = 5 # FAQ 목표 수를 채운 공통 질문 + 문의 안내(services/faq_fill) — ★ FAQ 전용. fact 에는 못 쓴다
|
||||||
|
|
||||||
|
|
||||||
class FactStatus(CodeEnum):
|
class FactStatus(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": ["편의점", "마트"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -136,6 +136,9 @@ class ExternalApiConfig(BaseSettings):
|
|||||||
# ★ 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다.
|
# ★ 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다.
|
||||||
# 그래도 API 가 필수로 요구하는 필드라 값을 들고 있는다(services/external/suno.py 주석).
|
# 그래도 API 가 필수로 요구하는 필드라 값을 들고 있는다(services/external/suno.py 주석).
|
||||||
suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL")
|
suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL")
|
||||||
|
# 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology). 비면 그 단계만
|
||||||
|
# 건너뛴다 — 제목·메타가 예전 그대로 나간다(services/seo_keywords).
|
||||||
|
site_ontology_url: str = Field("", validation_alias="SITE_ONTOLOGY_URL")
|
||||||
|
|
||||||
|
|
||||||
# .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다.
|
# .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다.
|
||||||
|
|||||||
@ -220,9 +220,11 @@ def fake_renderer(monkeypatch, tmp_path_factory):
|
|||||||
for unit in payload.get("units") or []:
|
for unit in payload.get("units") or []:
|
||||||
facts.extend(unit.get("facts") or [])
|
facts.extend(unit.get("facts") or [])
|
||||||
count += sum(1 for f in facts if f.get("type") == "text" and long(f.get("value")))
|
count += sum(1 for f in facts if f.get("type") == "text" and long(f.get("value")))
|
||||||
|
# 문의 안내(sourceType=TEMPLATE)는 모든 가게에 같은 문구라 고유 콘텐츠가 아니다.
|
||||||
count += sum(
|
count += sum(
|
||||||
1 for q in payload.get("faqs") or []
|
1 for q in payload.get("faqs") or []
|
||||||
if str(q.get("question") or "").strip() and long(q.get("answer"))
|
if q.get("sourceType") != 5
|
||||||
|
and str(q.get("question") or "").strip() and long(q.get("answer"))
|
||||||
)
|
)
|
||||||
count += sum(1 for m in payload.get("media") or [] if long(m.get("alt")))
|
count += sum(1 for m in payload.get("media") or [] if long(m.get("alt")))
|
||||||
return count
|
return count
|
||||||
|
|||||||
@ -89,7 +89,9 @@ class FaqCRUD(IFaqCRUD):
|
|||||||
.where(
|
.where(
|
||||||
place_faqs.place_id == place_id,
|
place_faqs.place_id == place_id,
|
||||||
place_faqs.deleted == False, # noqa: E712
|
place_faqs.deleted == False, # noqa: E712
|
||||||
place_faqs.generated_by == SourceType.LLM.value,
|
# 목표 수를 채운 공통 질문(TEMPLATE)도 자동 산출물이다 — 재생성마다 다시 고른다.
|
||||||
|
# 안 내리면 fact 가 새로 생겨 LLM 이 답한 주제에 옛 문의 안내가 겹쳐 남는다.
|
||||||
|
place_faqs.generated_by.in_((SourceType.LLM.value, SourceType.TEMPLATE.value)),
|
||||||
# 이미 내려간 것(EXPIRED)과 사장님이 반려한 것(REJECTED)은 그대로 둔다 —
|
# 이미 내려간 것(EXPIRED)과 사장님이 반려한 것(REJECTED)은 그대로 둔다 —
|
||||||
# 반려는 판단의 기록이라 재생성이 지울 이유가 없다.
|
# 반려는 판단의 기록이라 재생성이 지울 이유가 없다.
|
||||||
place_faqs.status.not_in((FactStatus.EXPIRED.value, FactStatus.REJECTED.value)),
|
place_faqs.status.not_in((FactStatus.EXPIRED.value, FactStatus.REJECTED.value)),
|
||||||
|
|||||||
@ -29,7 +29,16 @@ from common.logger import LOG
|
|||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
from crud.site_crud import SiteCRUD
|
from crud.site_crud import SiteCRUD
|
||||||
from crud.place_crud import PlaceCRUD
|
from crud.place_crud import PlaceCRUD
|
||||||
from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail, song_service
|
from services import (
|
||||||
|
azure_static,
|
||||||
|
indexnow,
|
||||||
|
publish_gate,
|
||||||
|
render_report,
|
||||||
|
seo_keywords,
|
||||||
|
site_payload,
|
||||||
|
site_thumbnail,
|
||||||
|
song_service,
|
||||||
|
)
|
||||||
from services.local_content_service import LocalContentService
|
from services.local_content_service import LocalContentService
|
||||||
from services.site_payload import emit_payload
|
from services.site_payload import emit_payload
|
||||||
from services.snapshot import build_snapshot
|
from services.snapshot import build_snapshot
|
||||||
@ -157,6 +166,19 @@ async def run_build(job: dict) -> dict:
|
|||||||
|
|
||||||
snapshot = await build_snapshot(place)
|
snapshot = await build_snapshot(place)
|
||||||
|
|
||||||
|
# ★ 메타 태그용 검색 키워드(SiteOntology). **스냅샷에 싣는다** — payload 는 스냅샷만 보고 만들고,
|
||||||
|
# "이 버전에 어떤 키워드가 나갔나" 가 site_versions.snapshot 에 남는다(services/seo_keywords 머리주석).
|
||||||
|
# ★ 실패해도 빌드는 계속한다. 주변 정보·노래와 같은 규칙이다 — 키워드 없이 예전 제목·메타로 나간다.
|
||||||
|
# ★ 재빌드(publish=False)에도 부른다. 로컬 임베딩이라 비용이 없고, 재빌드한 버전과 발행한 버전의
|
||||||
|
# 제목이 갈리면 "눌러 본 것과 나간 것이 다르다" 가 된다.
|
||||||
|
seo: dict | None = None
|
||||||
|
try:
|
||||||
|
seo = await seo_keywords.fetch(place_id, snapshot)
|
||||||
|
except Exception as ex: # noqa: BLE001 — 키워드 실패가 발행을 죽이면 안 된다
|
||||||
|
LOG.w(f"[build] place={place_id} 검색 키워드 실패(키워드 없이 발행): {type(ex).__name__}: {ex}")
|
||||||
|
if seo:
|
||||||
|
snapshot["seo"] = seo
|
||||||
|
|
||||||
v_err, version_no = await DB_SESSION_MNG.execute_lambda(
|
v_err, version_no = await DB_SESSION_MNG.execute_lambda(
|
||||||
site_versions.DBType(), DBWRType.DB_READ.value, lambda s: _site_crud.next_version_no(s, site.site_id)
|
site_versions.DBType(), DBWRType.DB_READ.value, lambda s: _site_crud.next_version_no(s, site.site_id)
|
||||||
)
|
)
|
||||||
@ -180,6 +202,9 @@ async def run_build(job: dict) -> dict:
|
|||||||
# 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다.
|
# 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다.
|
||||||
if song_result is not None:
|
if song_result is not None:
|
||||||
result["song"] = song_result
|
result["song"] = song_result
|
||||||
|
# "제목이 왜 예전 그대로인가" 도 같다 — 키워드가 실렸으면 잡 결과에 보인다(없으면 로그의 [seo] 줄).
|
||||||
|
if seo is not None:
|
||||||
|
result["seo"] = seo
|
||||||
now = GTime.UTC()
|
now = GTime.UTC()
|
||||||
|
|
||||||
async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None):
|
async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None):
|
||||||
|
|||||||
@ -9,11 +9,15 @@
|
|||||||
실제로는 그 화면이 닫힌 뒤에 문장이 도착해 발행본이 영영 빈칸이었다
|
실제로는 그 화면이 닫힌 뒤에 문장이 도착해 발행본이 영영 빈칸이었다
|
||||||
(근거·실측: services/fact_service.upsert_fact · docs/DECISIONS.md 7절).
|
(근거·실측: services/fact_service.upsert_fact · docs/DECISIONS.md 7절).
|
||||||
- 사장님이 고친 문장(CORRECTED)은 재생성이 덮지 않는다. 그 잠금은 그대로다.
|
- 사장님이 고친 문장(CORRECTED)은 재생성이 덮지 않는다. 그 잠금은 그대로다.
|
||||||
|
- FAQ 가 목표 수(20)에 모자라면 업종 카탈로그에서 겹치지 않는 공통 질문을 **문의 안내** 답으로 채운다
|
||||||
|
(services/faq_fill · common/faq_catalog). 답에 값·가능 여부를 적지 않으므로 사실을 만들지 않는다.
|
||||||
|
★ fact 가 0건이어도(또는 API 키가 없어도) 채운다 — 그때는 LLM 을 부르지 않고 채우기만 한다.
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from common.category_schema import CategorySchemaError, get_schema
|
from common.category_schema import CategorySchemaError, get_schema
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.faq_catalog import FaqCatalog, find_catalog
|
||||||
from common.database.model.models import place_facts, place_faqs, place_channels, places, place_units
|
from common.database.model.models import place_facts, place_faqs, place_channels, places, place_units
|
||||||
from common.enums import (
|
from common.enums import (
|
||||||
PUBLISHABLE_FACT_STATUSES,
|
PUBLISHABLE_FACT_STATUSES,
|
||||||
@ -31,7 +35,7 @@ from crud.fact_crud import FactCRUD
|
|||||||
from crud.faq_crud import FaqCRUD
|
from crud.faq_crud import FaqCRUD
|
||||||
from crud.place_crud import PlaceCRUD
|
from crud.place_crud import PlaceCRUD
|
||||||
from router.v1.fact.protocol import Req_UpsertFact
|
from router.v1.fact.protocol import Req_UpsertFact
|
||||||
from services import place_research
|
from services import faq_fill, place_research
|
||||||
from services.external import gemini_text
|
from services.external import gemini_text
|
||||||
from services.fact_service import FactService
|
from services.fact_service import FactService
|
||||||
|
|
||||||
@ -50,9 +54,6 @@ async def run_copy(job: dict) -> dict:
|
|||||||
place_id = payload["place_id"]
|
place_id = payload["place_id"]
|
||||||
owner_user_id = payload["owner_user_id"]
|
owner_user_id = payload["owner_user_id"]
|
||||||
|
|
||||||
if not gemini_text.is_configured():
|
|
||||||
raise CopyAborted("GEMINI_API_KEY 미설정 — 소개문·FAQ 를 생성할 수 없다")
|
|
||||||
|
|
||||||
err, place = await DB_SESSION_MNG.execute_lambda(
|
err, place = await DB_SESSION_MNG.execute_lambda(
|
||||||
places.DBType(),
|
places.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
DBWRType.DB_READ.value,
|
||||||
@ -159,9 +160,33 @@ async def run_copy(job: dict) -> dict:
|
|||||||
if by_unit.get(str(u.unit_id))
|
if by_unit.get(str(u.unit_id))
|
||||||
]
|
]
|
||||||
|
|
||||||
if not grounded and not unit_summaries:
|
# FAQ 채우기에 쓸 업종 카탈로그. 없으면(카페·음식점·호텔) 채우지 않는다.
|
||||||
# 근거가 없으면 아무것도 쓰지 않는다 — 유료 호출조차 하지 않는다.
|
catalog = find_catalog(place.category, place.external_category)
|
||||||
return {"place_id": place_id, "intro": False, "faqs": 0, "note": "근거로 쓸 확인된 fact 가 없다"}
|
# 사업장·객실 fact 를 가리지 않는다 — "기준 인원" 은 객실 fact 로 답한다.
|
||||||
|
known_fact_keys = {r.key for r in fact_rows if (r.value or "").strip()}
|
||||||
|
|
||||||
|
# ── LLM 을 부르지 않는 경우 ── 근거가 없거나(환각·유료 호출 방지) 키가 없을 때.
|
||||||
|
# ★ 그래도 카탈로그가 있으면 FAQ 는 문의 안내로 20개를 채운다 — fact 가 0건이어도 20개(DECISIONS 8절).
|
||||||
|
ungrounded = not grounded and not unit_summaries
|
||||||
|
if ungrounded or not gemini_text.is_configured():
|
||||||
|
note = "근거로 쓸 확인된 fact 가 없다" if ungrounded else "GEMINI_API_KEY 미설정"
|
||||||
|
if catalog is None:
|
||||||
|
if not ungrounded:
|
||||||
|
raise CopyAborted(f"{note} — 소개문·FAQ 를 생성할 수 없다")
|
||||||
|
return {"place_id": place_id, "intro": False, "faqs": 0, "faq_fill": 0, "note": note}
|
||||||
|
if ungrounded:
|
||||||
|
# 근거가 사라졌으니 예전 생성분도 근거가 없다 — 내리고 새로 채운다.
|
||||||
|
# 키만 없는 경우는 내리지 않는다: 멀쩡한 생성 FAQ 가 키 설정 하나 때문에 사라지면 안 된다.
|
||||||
|
await DB_SESSION_MNG.execute_lambda_claim(
|
||||||
|
place_faqs.DBType(),
|
||||||
|
lambda s: _faq_crud.expire_generated(s, pid, GTime.UTC()),
|
||||||
|
)
|
||||||
|
filled = await _fill_faqs(pid, catalog, known_fact_keys, place.phone)
|
||||||
|
LOG.i(f"[copy] place={place_id} LLM 호출 없음({note}) · 문의 안내 채움 {filled}건")
|
||||||
|
return {
|
||||||
|
"place_id": place_id, "grounded_facts": len(grounded), "intro": False, "meta": False,
|
||||||
|
"faqs": 0, "faq_fill": filled, "rejected": [], "note": note,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
copy = await gemini_text.generate_copy(
|
copy = await gemini_text.generate_copy(
|
||||||
@ -170,6 +195,8 @@ async def run_copy(job: dict) -> dict:
|
|||||||
grounded,
|
grounded,
|
||||||
unit_summaries=unit_summaries or None,
|
unit_summaries=unit_summaries or None,
|
||||||
records=records or None,
|
records=records or None,
|
||||||
|
suggested_questions=faq_fill.suggested_questions(catalog, known_fact_keys) if catalog else None,
|
||||||
|
max_faqs=faq_fill.FAQ_TARGET,
|
||||||
model=external_api_config.gemini_text_model,
|
model=external_api_config.gemini_text_model,
|
||||||
)
|
)
|
||||||
except gemini_text.GeminiNotConfigured as ex:
|
except gemini_text.GeminiNotConfigured as ex:
|
||||||
@ -182,6 +209,7 @@ async def run_copy(job: dict) -> dict:
|
|||||||
"intro": False,
|
"intro": False,
|
||||||
"meta": False,
|
"meta": False,
|
||||||
"faqs": 0,
|
"faqs": 0,
|
||||||
|
"faq_fill": 0, # 목표 수를 채운 문의 안내 문항 수
|
||||||
# ★ 반려된 문장을 그대로 남긴다 — 소개문이 왜 안 나왔는지 운영자가 알아야 한다.
|
# ★ 반려된 문장을 그대로 남긴다 — 소개문이 왜 안 나왔는지 운영자가 알아야 한다.
|
||||||
"rejected": [list(r) for r in (copy.rejected or [])][:20],
|
"rejected": [list(r) for r in (copy.rejected or [])][:20],
|
||||||
}
|
}
|
||||||
@ -245,6 +273,53 @@ async def run_copy(job: dict) -> dict:
|
|||||||
if run_err == ErrorType.SUCCESS:
|
if run_err == ErrorType.SUCCESS:
|
||||||
stat["faqs"] += 1
|
stat["faqs"] += 1
|
||||||
|
|
||||||
|
# ── 목표 수 채우기 ── 생성분이 모자라면 카탈로그의 겹치지 않는 공통 질문을 문의 안내로 채운다.
|
||||||
|
if catalog is not None:
|
||||||
|
stat["faq_fill"] = await _fill_faqs(pid, catalog, known_fact_keys, place.phone)
|
||||||
|
|
||||||
LOG.i(f"[copy] place={place_id} 소개문 {'O' if stat['intro'] else 'X'} · FAQ {stat['faqs']}건 · "
|
LOG.i(f"[copy] place={place_id} 소개문 {'O' if stat['intro'] else 'X'} · FAQ {stat['faqs']}건 · "
|
||||||
f"반려 {len(stat['rejected'])}건 (근거 fact {len(grounded)}개)")
|
f"문의 안내 채움 {stat['faq_fill']}건 · 반려 {len(stat['rejected'])}건 (근거 fact {len(grounded)}개)")
|
||||||
return stat
|
return stat
|
||||||
|
|
||||||
|
|
||||||
|
async def _fill_faqs(pid: uuid.UUID, catalog: FaqCatalog, known_fact_keys: set[str], phone: str | None) -> int:
|
||||||
|
"""노출 중인 FAQ 가 목표 수에 모자란 만큼 문의 안내 문항을 넣는다. 넣은 건수를 돌려준다.
|
||||||
|
|
||||||
|
★ 기존 FAQ 는 **노출 중인 것 전부**로 센다 — 방금 넣은 생성분만이 아니라 재생성이 남긴
|
||||||
|
사장님 입력·정정분까지. 그래야 사장님이 이미 답한 주제에 문의 안내가 겹쳐 붙지 않는다.
|
||||||
|
★ 바로 노출값(VERIFIED)으로 넣는다. 답이 주장을 하지 않아 확인할 대상이 없다 —
|
||||||
|
대신 JSON-LD · llms.txt · 고유 콘텐츠 계수에서는 빠진다(shared selectAnsweredFaqs)."""
|
||||||
|
l_err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
place_faqs.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: _faq_crud.list_faqs(s, pid, True),
|
||||||
|
)
|
||||||
|
if l_err != ErrorType.SUCCESS:
|
||||||
|
LOG.e_no_callstack(f"[copy] FAQ 채우기 건너뜀 — 목록 조회 실패 place={pid} {l_err.name}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
picks = faq_fill.pick_fill_faqs(
|
||||||
|
catalog,
|
||||||
|
[faq_fill.ExistingFaq(r.question, r.source_fact_ids) for r in rows],
|
||||||
|
known_fact_keys,
|
||||||
|
phone,
|
||||||
|
)
|
||||||
|
next_order = max((r.sort_order for r in rows), default=-1) + 1
|
||||||
|
added = 0
|
||||||
|
for offset, pick in enumerate(picks):
|
||||||
|
row = place_faqs(
|
||||||
|
place_id=pid,
|
||||||
|
question=pick.question,
|
||||||
|
answer=pick.answer,
|
||||||
|
source_fact_ids=None,
|
||||||
|
generated_by=SourceType.TEMPLATE.value,
|
||||||
|
status=FactStatus.VERIFIED.value,
|
||||||
|
sort_order=next_order + offset,
|
||||||
|
)
|
||||||
|
run_err = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[place_faqs.DBType()],
|
||||||
|
[lambda s, r=row: _faq_crud.add_faq(s, r)],
|
||||||
|
)
|
||||||
|
if run_err == ErrorType.SUCCESS:
|
||||||
|
added += 1
|
||||||
|
return added
|
||||||
|
|||||||
@ -98,6 +98,7 @@ async def generate_copy(
|
|||||||
*,
|
*,
|
||||||
unit_summaries: Optional[list[dict]] = None,
|
unit_summaries: Optional[list[dict]] = None,
|
||||||
records: Optional[list[str]] = None,
|
records: Optional[list[str]] = None,
|
||||||
|
suggested_questions: Optional[list[str]] = None,
|
||||||
max_faqs: int = 8,
|
max_faqs: int = 8,
|
||||||
model: str = DEFAULT_TEXT_MODEL,
|
model: str = DEFAULT_TEXT_MODEL,
|
||||||
max_retries: int = 2,
|
max_retries: int = 2,
|
||||||
@ -126,7 +127,7 @@ async def generate_copy(
|
|||||||
|
|
||||||
body = {
|
body = {
|
||||||
"contents": [{"role": "user", "parts": [{
|
"contents": [{"role": "user", "parts": [{
|
||||||
"text": build_prompt(place_name, category, facts, max_faqs, unit_grounding, records)
|
"text": build_prompt(place_name, category, facts, max_faqs, unit_grounding, records, suggested_questions)
|
||||||
}]}],
|
}]}],
|
||||||
"generationConfig": {
|
"generationConfig": {
|
||||||
"responseMimeType": "application/json",
|
"responseMimeType": "application/json",
|
||||||
|
|||||||
87
solution/backend/services/external/site_ontology.py
vendored
Normal file
87
solution/backend/services/external/site_ontology.py
vendored
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"""SiteOntology(o2o-site-ontology) 클라이언트 — 이 가게에 맞는 검색 키워드를 받아 온다.
|
||||||
|
|
||||||
|
SiteOntology 는 펜션 SEO/AEO 키워드 사전(pgvector)을 들고, 업체 프로필에 맞는 단어를 골라 주는
|
||||||
|
사내 서비스다. 여기서는 창구 두 개만 쓴다(실측 2026-09-14, 로컬 :3100).
|
||||||
|
|
||||||
|
POST /v1/merchants/publish {externalId, name, industryId, regionId, description, profile, generate:false}
|
||||||
|
→ 201 {merchant, generation: "skipped"} 업체를 저장만 한다. 키워드는 주지 않는다
|
||||||
|
POST /v1/match {query: <externalId>, limit}
|
||||||
|
→ 201 {resolved, lanes, matches[], byLane[], excluded[]} 저장된 프로필로 추천한다
|
||||||
|
|
||||||
|
★ 두 번 부르는 이유: "프로필을 주면 키워드를 돌려주는" 창구가 한 번에는 없다. match 는 query 문자열만
|
||||||
|
받고, externalId 를 넣으면 저장된 업체로 해석해 그 프로필로 추천한다. 그래서 먼저 publish 로 프로필을
|
||||||
|
이번 빌드의 스냅샷 값으로 맞춘다.
|
||||||
|
★ generate:false 를 반드시 보낸다. 빠뜨리면 SiteOntology 가 LLM 키워드 생성을 큐에 넣는다 —
|
||||||
|
그 결과는 우리가 쓰지 않는 창구(/seo)로만 나가고, 로컬은 mock LLM 이라 가짜 단어가 사전에 쌓인다.
|
||||||
|
★ regionId 가 SiteOntology 의 region 표에 없으면 **500** 이다(외래키 위반, 실측). 그 표는 적재한 데이터셋에
|
||||||
|
따라 달라서(군산만 / 전국 54개) 우리가 알 수 없다 — 500 이면 regionId 를 비워 한 번 더 보낸다.
|
||||||
|
지역이 비면 유형 추천에서 "군산" 이 빠져 품질이 떨어지지만(실측: 1위가 `독채펜션`), 없는 것보다 낫고
|
||||||
|
지역 없는 단어는 호출측 거르기(seo_keywords)가 제목에서 뺀다.
|
||||||
|
★ externalId 가 해석되지 않으면 match 는 에러가 아니라 **입력 문자열 자체로 검색한 결과**를 201 로 준다
|
||||||
|
(실측: "no-such-place-id" → `나운동 숙소`·`선유도 숙소 예약 언제 해야 하나요`). 그걸 쓰면 남의 동네 단어가
|
||||||
|
나간다 — resolved 가 우리 externalId 가 아니면 실패로 본다.
|
||||||
|
★ 실패는 발행을 막지 않는다. 여기서는 SiteOntologyError 로 올리고, 호출측이 잡아 키워드 없이 굽는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from common.logger import LOG
|
||||||
|
from config.server_configs import external_api_config
|
||||||
|
|
||||||
|
# 로컬 임베딩 모델이라 호출당 1~2초다. 넉넉히 잡되 발행 잡 데드라인(900s)을 잡아먹지 않게 끊는다.
|
||||||
|
TIMEOUT_SEC = 15.0
|
||||||
|
# 거르기(seo_keywords)에서 절반 넘게 떨어진다(실측: 10건 → 4건). 메타 태그 10개를 채우려면 넉넉히 받는다.
|
||||||
|
DEFAULT_LIMIT = 40
|
||||||
|
|
||||||
|
PUBLISH_PATH = "/v1/merchants/publish"
|
||||||
|
MATCH_PATH = "/v1/match"
|
||||||
|
|
||||||
|
|
||||||
|
class SiteOntologyError(RuntimeError):
|
||||||
|
"""SiteOntology 호출 실패(네트워크·타임아웃·4xx/5xx·업체 미해석). 발행은 계속된다."""
|
||||||
|
|
||||||
|
|
||||||
|
def base_url() -> str:
|
||||||
|
return (external_api_config.site_ontology_url or "").strip().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
return bool(base_url())
|
||||||
|
|
||||||
|
|
||||||
|
async def _post(client: httpx.AsyncClient, path: str, body: dict) -> dict:
|
||||||
|
res = await client.post(f"{base_url()}{path}", json=body)
|
||||||
|
if res.status_code >= 400:
|
||||||
|
raise SiteOntologyError(f"{path} HTTP {res.status_code}: {res.text[:200]}")
|
||||||
|
try:
|
||||||
|
data = res.json()
|
||||||
|
except ValueError as ex:
|
||||||
|
raise SiteOntologyError(f"{path} 응답이 JSON 이 아니다: {res.text[:200]}") from ex
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise SiteOntologyError(f"{path} 응답 모양이 다르다: {type(data).__name__}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def match_for_merchant(merchant: dict, limit: int = DEFAULT_LIMIT) -> dict:
|
||||||
|
"""업체를 저장하고, 그 업체로 해석된 추천 결과(/v1/match 응답)를 돌려준다.
|
||||||
|
|
||||||
|
merchant 는 publish 요청 본문이다(generate 는 여기서 붙인다). 실패하면 SiteOntologyError."""
|
||||||
|
body = {**merchant, "generate": False}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT_SEC) as client:
|
||||||
|
try:
|
||||||
|
await _post(client, PUBLISH_PATH, body)
|
||||||
|
except SiteOntologyError as ex:
|
||||||
|
if not body.get("regionId"):
|
||||||
|
raise
|
||||||
|
LOG.w(f"[site-ontology] regionId={body['regionId']} 로 저장하지 못했다 — 지역 없이 다시 보낸다: {ex}")
|
||||||
|
body = {**body, "regionId": None}
|
||||||
|
await _post(client, PUBLISH_PATH, body)
|
||||||
|
result = await _post(client, MATCH_PATH, {"query": merchant["externalId"], "limit": limit})
|
||||||
|
except httpx.HTTPError as ex:
|
||||||
|
raise SiteOntologyError(f"{type(ex).__name__}: {ex}") from ex
|
||||||
|
|
||||||
|
resolved = result.get("resolved") or {}
|
||||||
|
if resolved.get("externalId") != merchant["externalId"]:
|
||||||
|
raise SiteOntologyError(f"업체가 해석되지 않았다 — externalId={merchant['externalId']}")
|
||||||
|
return result
|
||||||
@ -276,6 +276,12 @@ class FactService:
|
|||||||
res.result.SetResult(ErrorType.FACT_INVALID_KEY)
|
res.result.SetResult(ErrorType.FACT_INVALID_KEY)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
# 규칙 4 — TEMPLATE 은 FAQ 문의 안내 전용 출처다(services/faq_fill). fact 에는 쓸 수 없다.
|
||||||
|
# OWNER 도 자동 수집도 아니라서, 막지 않으면 아래 분기에서 사람 입력처럼 바로 노출값이 된다.
|
||||||
|
if req.source_type == SourceType.TEMPLATE:
|
||||||
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||||
|
return res
|
||||||
|
|
||||||
pid = uuid.UUID(place_id)
|
pid = uuid.UUID(place_id)
|
||||||
pub_err, published = await DB_SESSION_MNG.execute_lambda(
|
pub_err, published = await DB_SESSION_MNG.execute_lambda(
|
||||||
place_facts.DBType(),
|
place_facts.DBType(),
|
||||||
|
|||||||
98
solution/backend/services/faq_fill.py
Normal file
98
solution/backend/services/faq_fill.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
"""FAQ 목표 수 채우기 — 생성된 FAQ 가 모자라면 카탈로그에서 겹치지 않는 공통 질문을 고른다.
|
||||||
|
|
||||||
|
순수 함수만 둔다(DB·네트워크 없음). 무엇이 왜 골렸는지를 이 파일만 읽고 답할 수 있어야 한다.
|
||||||
|
|
||||||
|
고르는 규칙 (카탈로그 순서 = 우선순위)
|
||||||
|
1. 답할 fact 가 있는 질문은 고르지 않는다 — 그건 LLM 이 fact 로 답할 자리다.
|
||||||
|
"주차할 수 있나요?" 에 parking=true 가 있는데 "문의 부탁드립니다" 가 붙으면 아는 것을 숨긴 셈이다.
|
||||||
|
2. 기존 FAQ 가 이미 다룬 주제는 고르지 않는다. 기존 FAQ 에는 LLM 생성분 · 사장님 입력 · 정정분이 모두 든다.
|
||||||
|
- 근거 fact key 가 겹치면 같은 주제다. LLM 은 "주차 및 와이파이" 처럼 두 주제를 한 문항에 묶고
|
||||||
|
근거에 [parking, wifi] 를 적는다 — 낱말 대조만으로는 둘 중 하나를 놓친다.
|
||||||
|
- 질문에 카탈로그 키워드가 들어 있으면 같은 주제다(사장님 입력은 근거 key 가 없다).
|
||||||
|
3. 공통 답은 문의 안내뿐이다. 값·가능 여부를 적지 않는다(common/faq_catalog 머리주석).
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
|
from common.faq_catalog import FaqCatalog
|
||||||
|
|
||||||
|
# ★ 사이트에 싣는 FAQ 목표 수. 생성 상한(max_faqs)도 이 값을 쓴다.
|
||||||
|
FAQ_TARGET = 20
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExistingFaq:
|
||||||
|
question: str
|
||||||
|
source_fact_ids: Optional[list] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FillFaq:
|
||||||
|
catalog_id: str
|
||||||
|
question: str
|
||||||
|
answer: str
|
||||||
|
|
||||||
|
|
||||||
|
def _compact(text: str) -> str:
|
||||||
|
return re.sub(r"\s+", "", text or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _base_key(key: str) -> str:
|
||||||
|
"""객실 근거는 "A동:max_capacity" 로 적힌다 — 주제 비교에는 key 만 쓴다."""
|
||||||
|
return str(key).rsplit(":", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _with_topic_particle(topic: str) -> str:
|
||||||
|
"""주제 뒤에 은/는 을 붙인다. 받침이 있으면 '은'."""
|
||||||
|
last = topic.strip()[-1:]
|
||||||
|
if "가" <= last <= "힣":
|
||||||
|
return topic + ("은" if (ord(last) - ord("가")) % 28 else "는")
|
||||||
|
return topic + "은(는)"
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_answer(catalog: FaqCatalog, index: int, topic: str, phone: Optional[str]) -> str:
|
||||||
|
"""문의 안내 문구. 문구를 번갈아 써서 스무 줄이 전부 같은 문장이 되지 않게 한다."""
|
||||||
|
phone = (phone or "").strip()
|
||||||
|
templates = catalog.fallback_with_contact if phone else catalog.fallback_without_contact
|
||||||
|
template = templates[index % len(templates)]
|
||||||
|
return template.format(topic=_with_topic_particle(topic), contact=f"전화({phone})")
|
||||||
|
|
||||||
|
|
||||||
|
def pick_fill_faqs(
|
||||||
|
catalog: FaqCatalog,
|
||||||
|
existing: Iterable[ExistingFaq],
|
||||||
|
fact_keys: Iterable[str],
|
||||||
|
phone: Optional[str] = None,
|
||||||
|
target: int = FAQ_TARGET,
|
||||||
|
) -> list[FillFaq]:
|
||||||
|
"""목표 수까지 모자란 만큼 카탈로그 질문을 고른다. 모자라지 않으면 빈 목록."""
|
||||||
|
existing = list(existing)
|
||||||
|
need = target - len(existing)
|
||||||
|
if need <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
asked = [_compact(faq.question) for faq in existing]
|
||||||
|
covered_keys = {_base_key(k) for faq in existing for k in (faq.source_fact_ids or [])}
|
||||||
|
known_keys = {_base_key(k) for k in fact_keys}
|
||||||
|
|
||||||
|
picked: list[FillFaq] = []
|
||||||
|
for item in catalog.items:
|
||||||
|
if len(picked) >= need:
|
||||||
|
break
|
||||||
|
if known_keys.intersection(item.fact_keys):
|
||||||
|
continue # 규칙 1
|
||||||
|
if covered_keys.intersection(item.fact_keys):
|
||||||
|
continue # 규칙 2 — 근거 key
|
||||||
|
if any(keyword in question for keyword in item.keywords for question in asked):
|
||||||
|
continue # 규칙 2 — 질문 낱말
|
||||||
|
answer = fallback_answer(catalog, len(picked), item.topic, phone)
|
||||||
|
picked.append(FillFaq(item.id, item.question, answer))
|
||||||
|
return picked
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_questions(catalog: FaqCatalog, fact_keys: Iterable[str]) -> list[str]:
|
||||||
|
"""fact 로 답할 수 있는 카탈로그 질문 — 프롬프트에 실어 LLM 이 이 질문들부터 쓰게 한다.
|
||||||
|
채우기(규칙 1)가 이 질문들을 건너뛰므로, LLM 이 안 쓰면 그 주제는 비게 된다."""
|
||||||
|
known = {_base_key(k) for k in fact_keys}
|
||||||
|
return [item.question for item in catalog.items if known.intersection(item.fact_keys)]
|
||||||
@ -904,22 +904,21 @@ class PlaceService:
|
|||||||
async def start_copy(self, user_info: UserInfo, place_id: str, req: Req_StartCopy) -> Res_StartCopy:
|
async def start_copy(self, user_info: UserInfo, place_id: str, req: Req_StartCopy) -> Res_StartCopy:
|
||||||
"""소개문·FAQ 생성을 큐에 넣는다.
|
"""소개문·FAQ 생성을 큐에 넣는다.
|
||||||
|
|
||||||
★ 근거로 쓸 확인된 fact 가 없으면 잡을 만들지 않는다 —
|
★ 근거로 쓸 확인된 fact 가 없으면 LLM 은 부르지 않는다 —
|
||||||
근거 없이 문장을 쓰면 그게 환각이고, 유료 호출만 낭비된다."""
|
근거 없이 문장을 쓰면 그게 환각이고, 유료 호출만 낭비된다.
|
||||||
|
★ 그래도 FAQ 카탈로그가 있는 업종(펜션)이면 잡을 만든다 — fact 가 0건이어도 FAQ 는
|
||||||
|
문의 안내로 20개를 채운다(DECISIONS.md 8절). 그 경로는 LLM 을 안 쓰므로 API 키도 필요 없다."""
|
||||||
from common.database.model.models import place_facts as facts_model
|
from common.database.model.models import place_facts as facts_model
|
||||||
|
from common.faq_catalog import find_catalog
|
||||||
from crud.fact_crud import FactCRUD
|
from crud.fact_crud import FactCRUD
|
||||||
from services.external import gemini_text
|
from services.external import gemini_text
|
||||||
|
|
||||||
res = Res_StartCopy()
|
res = Res_StartCopy()
|
||||||
err_type, _place = await self._load(user_info, place_id)
|
err_type, place = await self._load(user_info, place_id)
|
||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
if not gemini_text.is_configured():
|
|
||||||
res.result.SetResult(ErrorType.GENERATOR_NOT_CONFIGURED)
|
|
||||||
return res
|
|
||||||
|
|
||||||
crud = FactCRUD()
|
crud = FactCRUD()
|
||||||
f_err, rows = await DB_SESSION_MNG.execute_lambda(
|
f_err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
facts_model.DBType(),
|
facts_model.DBType(),
|
||||||
@ -930,9 +929,15 @@ class PlaceService:
|
|||||||
res.result.SetResult(f_err)
|
res.result.SetResult(f_err)
|
||||||
return res
|
return res
|
||||||
res.grounded_facts = sum(1 for r in rows if r.unit_id is None and (r.value or "").strip())
|
res.grounded_facts = sum(1 for r in rows if r.unit_id is None and (r.value or "").strip())
|
||||||
if res.grounded_facts == 0:
|
# 객실 fact 만 있어도 LLM 이 쓴다(copy_service 의 unit_summaries) — 키 검사는 그 기준으로 한다.
|
||||||
|
needs_llm = any((r.value or "").strip() for r in rows)
|
||||||
|
if not needs_llm and find_catalog(place.category, place.external_category) is None:
|
||||||
|
# 쓸 근거도, 채울 공통 질문도 없다(카페·음식점·호텔).
|
||||||
res.result.SetResult(ErrorType.FAQ_UNGROUNDED)
|
res.result.SetResult(ErrorType.FAQ_UNGROUNDED)
|
||||||
return res
|
return res
|
||||||
|
if needs_llm and not gemini_text.is_configured():
|
||||||
|
res.result.SetResult(ErrorType.GENERATOR_NOT_CONFIGURED)
|
||||||
|
return res
|
||||||
|
|
||||||
job_id, created = await enqueue_job(
|
job_id, created = await enqueue_job(
|
||||||
self.queue, JobType.COPY,
|
self.queue, JobType.COPY,
|
||||||
|
|||||||
@ -56,6 +56,7 @@ def build_prompt(
|
|||||||
max_faqs: int,
|
max_faqs: int,
|
||||||
unit_facts: Optional[Sequence[FactLike]] = None,
|
unit_facts: Optional[Sequence[FactLike]] = None,
|
||||||
records: Optional[Sequence[str]] = None,
|
records: Optional[Sequence[str]] = None,
|
||||||
|
suggested_questions: Optional[Sequence[str]] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""소개문·메타·FAQ 생성 프롬프트.
|
"""소개문·메타·FAQ 생성 프롬프트.
|
||||||
|
|
||||||
@ -79,6 +80,14 @@ def build_prompt(
|
|||||||
"업소에 대해 확인된 기록(출처가 있는 글):",
|
"업소에 대해 확인된 기록(출처가 있는 글):",
|
||||||
*(f"- {line}" for line in records),
|
*(f"- {line}" for line in records),
|
||||||
])
|
])
|
||||||
|
if suggested_questions:
|
||||||
|
# 업종 카탈로그 중 위 사실로 답할 수 있는 질문들(services/faq_fill.suggested_questions).
|
||||||
|
# ★ 채우기는 fact 가 있는 질문을 건너뛴다 — 여기서 모델이 안 쓰면 그 주제는 비어 버린다.
|
||||||
|
sections.extend([
|
||||||
|
"",
|
||||||
|
"FAQ 로 먼저 쓸 질문(위 사실로 답할 수 있는 것):",
|
||||||
|
*(f"- {q}" for q in suggested_questions),
|
||||||
|
])
|
||||||
sections.extend([
|
sections.extend([
|
||||||
"",
|
"",
|
||||||
"출력:",
|
"출력:",
|
||||||
@ -96,6 +105,9 @@ def build_prompt(
|
|||||||
"- false·불가·없음 값을 가능하다고 표현하지 않는다.",
|
"- false·불가·없음 값을 가능하다고 표현하지 않는다.",
|
||||||
"- 홍보성·평가성 표현을 쓰지 않는다.",
|
"- 홍보성·평가성 표현을 쓰지 않는다.",
|
||||||
"- 근거 없는 FAQ는 만들지 않는다.",
|
"- 근거 없는 FAQ는 만들지 않는다.",
|
||||||
|
# ★ 실측(2026-09-14, 로컬): 노출 중인 생성 FAQ 4건 중 3건이 "체크인 및 체크아웃" 처럼 두 주제를 묶었다.
|
||||||
|
# 묶으면 문항 수는 그대로인데 다룬 주제가 줄고, 채우기의 겹침 판정도 두 주제를 함께 지운다.
|
||||||
|
"- FAQ 한 문항에는 주제 하나만 묻는다(예: 체크인과 체크아웃을 한 문항에 묶지 않는다).",
|
||||||
"- 한국어 존댓말을 사용한다.",
|
"- 한국어 존댓말을 사용한다.",
|
||||||
])
|
])
|
||||||
return "\n".join(sections)
|
return "\n".join(sections)
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
"""
|
"""
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from common.enums import SourceType
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AuditCheck:
|
class AuditCheck:
|
||||||
@ -31,7 +33,8 @@ def _check(id, group, label, points, passed, detail, recommendation=None, partia
|
|||||||
def evaluate(snapshot: dict, *, verified: bool, site=None, version=None, ai_checks: list | None = None) -> dict:
|
def evaluate(snapshot: dict, *, verified: bool, site=None, version=None, ai_checks: list | None = None) -> dict:
|
||||||
place = snapshot.get("place") or {}
|
place = snapshot.get("place") or {}
|
||||||
facts = snapshot.get("facts") or []
|
facts = snapshot.get("facts") or []
|
||||||
faqs = snapshot.get("faqs") or []
|
# 문의 안내(TEMPLATE)는 답이 아니다 — "질문형 FAQ" 에 세면 채우기만으로 만점이 난다.
|
||||||
|
faqs = [q for q in (snapshot.get("faqs") or []) if q.get("generated_by") != SourceType.TEMPLATE.value]
|
||||||
media = snapshot.get("media") or []
|
media = snapshot.get("media") or []
|
||||||
built = bool(version and getattr(version, "built_at", None))
|
built = bool(version and getattr(version, "built_at", None))
|
||||||
unique_count = int(getattr(version, "unique_content_count", 0) or 0) if version else 0
|
unique_count = int(getattr(version, "unique_content_count", 0) or 0) if version else 0
|
||||||
|
|||||||
294
solution/backend/services/seo_keywords.py
Normal file
294
solution/backend/services/seo_keywords.py
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
"""발행 사이트 메타 태그용 검색 키워드 — SiteOntology 추천을 이 가게의 확인된 자료로 한 번 더 거른다.
|
||||||
|
|
||||||
|
스냅샷 → 업체 프로필(build_merchant) → SiteOntology publish + match → 거르기(select)
|
||||||
|
→ snapshot["seo"] → payload.seo → <meta name="keywords"> · <title> 의 업종어 자리
|
||||||
|
|
||||||
|
★ 스냅샷에 싣는다(site_versions.snapshot). payload 는 스냅샷만 보고 만든다는 원칙(site_payload 머리주석)을
|
||||||
|
그대로 지키고, "이 버전에 어떤 키워드가 나갔나" 가 발행 기록으로 남는다. SiteOntology 쪽에는 남기지 않는다.
|
||||||
|
★ 숙박만 부른다. SiteOntology 사전은 펜션 키워드뿐이다(2026-09-14 기준 industry=stay.pension).
|
||||||
|
다른 업종으로 부르면 펜션 단어가 카페 사이트의 메타 태그에 붙는다.
|
||||||
|
|
||||||
|
★ 거르기 규칙은 하나다 — **키워드의 모든 낱말이 이 가게의 확인된 자료에 있어야 한다.**
|
||||||
|
SiteOntology 의 사실 필터는 수용 인원과 일부 시설(바베큐·수영장·스파…)만 본다. 실측(2026-09-14, 스테이머뭄
|
||||||
|
프로필)에서 `군산 독채 마당 펜션`·`군산 독채 복층 펜션` 이 status=ok 로 왔다 — 마당·복층은 확인된 적이 없다.
|
||||||
|
사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다. 낱말 대조 하나로 셋이 함께 걸린다.
|
||||||
|
메타 태그와 제목은 AI 검색이 그대로 읽는 자리라, 확인 안 된 시설을 광고하는 단어는 지어낸 문장과 같다
|
||||||
|
(solution/site/src/seo/meta.ts 머리주석 — description 은 지어내지 않는다).
|
||||||
|
"자료" 는 상호·소개문·주소·확인된 시설/객실 fact·발행되는 주변 관광지다. 미확인(unverified)은 자료가 아니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
from common.enums import LocalContentType, PlaceCategory
|
||||||
|
from common.logger import LOG
|
||||||
|
from services.external import site_ontology
|
||||||
|
from services.site_payload import _parse_address_parts
|
||||||
|
|
||||||
|
LODGING_INDUSTRY = "stay.pension"
|
||||||
|
MAX_KEYWORDS = 10
|
||||||
|
MAX_NEARBY = 8
|
||||||
|
|
||||||
|
# ★ SiteOntology 의 시설 어휘(match.rules.ts AMENITY_SYNONYMS)로 옮긴다 — 저쪽이 이 낱말로 보유 시설을 판정한다.
|
||||||
|
# 값이 "true" 면 있음 → features, "false" 면 **없음** → 어디에도 넣지 않는다(그래야 저쪽이 그 시설 키워드를 배제한다),
|
||||||
|
# fact 가 아예 없으면 **모름** → unverified(저쪽이 배제하지 않고 보류한다 — 없음과 모름은 다르다).
|
||||||
|
_AMENITY_FACTS = {
|
||||||
|
"bbq_available": "바베큐",
|
||||||
|
"parking": "주차",
|
||||||
|
"pet_allowed": "애견동반",
|
||||||
|
"breakfast": "조식",
|
||||||
|
}
|
||||||
|
# 사장님이 확인한 글자 그대로 싣는 fact(객실 단위 포함). "독채"·"오션뷰" 같은 유형·전망어가 여기서 온다.
|
||||||
|
_FEATURE_TEXT_KEYS = ("room_type", "building_scale", "facilities", "view")
|
||||||
|
_CAPACITY_KEYS = ("max_capacity", "accommodation_capacity")
|
||||||
|
|
||||||
|
# (ISO 3166-2 시·도, 시·군) → SiteOntology region 키(data/regions.json 54개).
|
||||||
|
# ★ 시·도를 함께 본다 — 고성군은 강원과 경남에 둘 다 있다.
|
||||||
|
# ★ 을왕리(kr.incheon.yeongjong)는 넣지 않는다. 행정구역으로는 인천 중구인데 중구 전체를 을왕리로 보낼 수 없다.
|
||||||
|
_REGION_KEYS = {
|
||||||
|
("KR-41", "가평군"): "kr.gyeonggi.gapyeong", ("KR-41", "양평군"): "kr.gyeonggi.yangpyeong",
|
||||||
|
("KR-41", "포천시"): "kr.gyeonggi.pocheon", ("KR-41", "파주시"): "kr.gyeonggi.paju",
|
||||||
|
("KR-28", "강화군"): "kr.incheon.ganghwa",
|
||||||
|
("KR-42", "춘천시"): "kr.gangwon.chuncheon", ("KR-42", "홍천군"): "kr.gangwon.hongcheon",
|
||||||
|
("KR-42", "인제군"): "kr.gangwon.inje", ("KR-42", "평창군"): "kr.gangwon.pyeongchang",
|
||||||
|
("KR-42", "정선군"): "kr.gangwon.jeongseon", ("KR-42", "강릉시"): "kr.gangwon.gangneung",
|
||||||
|
("KR-42", "속초시"): "kr.gangwon.sokcho", ("KR-42", "양양군"): "kr.gangwon.yangyang",
|
||||||
|
("KR-42", "고성군"): "kr.gangwon.goseong", ("KR-42", "동해시"): "kr.gangwon.donghae",
|
||||||
|
("KR-42", "삼척시"): "kr.gangwon.samcheok", ("KR-42", "태백시"): "kr.gangwon.taebaek",
|
||||||
|
("KR-43", "단양군"): "kr.chungbuk.danyang", ("KR-43", "제천시"): "kr.chungbuk.jecheon",
|
||||||
|
("KR-43", "충주시"): "kr.chungbuk.chungju", ("KR-43", "괴산군"): "kr.chungbuk.goesan",
|
||||||
|
("KR-44", "태안군"): "kr.chungnam.taean", ("KR-44", "보령시"): "kr.chungnam.boryeong",
|
||||||
|
("KR-44", "서산시"): "kr.chungnam.seosan", ("KR-44", "공주시"): "kr.chungnam.gongju",
|
||||||
|
("KR-44", "부여군"): "kr.chungnam.buyeo",
|
||||||
|
("KR-45", "군산시"): "kr.jeonbuk.gunsan", ("KR-45", "부안군"): "kr.jeonbuk.buan",
|
||||||
|
("KR-45", "전주시"): "kr.jeonbuk.jeonju", ("KR-45", "무주군"): "kr.jeonbuk.muju",
|
||||||
|
("KR-45", "남원시"): "kr.jeonbuk.namwon",
|
||||||
|
("KR-46", "여수시"): "kr.jeonnam.yeosu", ("KR-46", "순천시"): "kr.jeonnam.suncheon",
|
||||||
|
("KR-46", "담양군"): "kr.jeonnam.damyang", ("KR-46", "구례군"): "kr.jeonnam.gurye",
|
||||||
|
("KR-46", "해남군"): "kr.jeonnam.haenam", ("KR-46", "완도군"): "kr.jeonnam.wando",
|
||||||
|
("KR-46", "보성군"): "kr.jeonnam.boseong",
|
||||||
|
("KR-47", "경주시"): "kr.gyeongbuk.gyeongju", ("KR-47", "포항시"): "kr.gyeongbuk.pohang",
|
||||||
|
("KR-47", "안동시"): "kr.gyeongbuk.andong", ("KR-47", "영덕군"): "kr.gyeongbuk.yeongdeok",
|
||||||
|
("KR-47", "울진군"): "kr.gyeongbuk.uljin", ("KR-47", "문경시"): "kr.gyeongbuk.mungyeong",
|
||||||
|
("KR-48", "거제시"): "kr.gyeongnam.geoje", ("KR-48", "통영시"): "kr.gyeongnam.tongyeong",
|
||||||
|
("KR-48", "남해군"): "kr.gyeongnam.namhae", ("KR-48", "하동군"): "kr.gyeongnam.hadong",
|
||||||
|
("KR-48", "사천시"): "kr.gyeongnam.sacheon", ("KR-48", "산청군"): "kr.gyeongnam.sancheong",
|
||||||
|
("KR-26", "기장군"): "kr.busan.gijang",
|
||||||
|
("KR-49", "제주시"): "kr.jeju.jejusi", ("KR-49", "서귀포시"): "kr.jeju.seogwipo",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 자료에 없어도 되는 낱말 — 업종어와 "근처" 류. 무엇을 주장하지 않는다.
|
||||||
|
_GENERIC_WORDS = frozenset({"펜션", "숙소", "숙박", "스테이", "근처", "가까운", "주변", "인근", "예약", "추천"})
|
||||||
|
# "독채펜션"·"감성숙소" 처럼 붙여 쓴 업종어는 떼고 앞부분만 자료와 대조한다.
|
||||||
|
_GENERIC_SUFFIXES = ("펜션", "숙소", "스테이")
|
||||||
|
# 제목에는 싣지 않는 낱말. `스테이,머뭄 · 군산 독채펜션 예약` 은 검색어로는 맞아도 가게 이름 옆에서는 광고 문구다.
|
||||||
|
_TITLE_BLOCKED_WORDS = frozenset({"예약", "추천"})
|
||||||
|
# 한 글자 낱말("봄"·"뷰")은 소개문 어딘가에 우연히 들어 있어 대조가 무의미하다 — 통과시키지 않는다.
|
||||||
|
_MIN_CORE_LEN = 2
|
||||||
|
|
||||||
|
_NON_WORD = re.compile(r"[^\w]")
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value) -> str:
|
||||||
|
return str(value).strip() if value is not None else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _compact(text: str) -> str:
|
||||||
|
"""대조용 표기 — 공백·구두점을 지우고 소문자로. "스테이,머뭄" 과 "스테이 머뭄" 이 같아진다."""
|
||||||
|
return _NON_WORD.sub("", unicodedata.normalize("NFKC", text or "").lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def region_key(*addresses: str | None) -> str | None:
|
||||||
|
"""주소 → SiteOntology region 키. 표에 없으면 None(지어내지 않는다)."""
|
||||||
|
parts = _parse_address_parts(*addresses)
|
||||||
|
locality = (parts.get("addressLocality") or "").split()
|
||||||
|
if not locality:
|
||||||
|
return None
|
||||||
|
return _REGION_KEYS.get((parts["addressRegionCode"], locality[0]))
|
||||||
|
|
||||||
|
|
||||||
|
def _locality_word(*addresses: str | None) -> str:
|
||||||
|
"""사람이 검색창에 치는 시·군 이름 — "군산시" → "군산". 제목 키워드가 이 낱말을 품어야 한다."""
|
||||||
|
locality = (_parse_address_parts(*addresses).get("addressLocality") or "").split()
|
||||||
|
if not locality:
|
||||||
|
return ""
|
||||||
|
word = locality[0]
|
||||||
|
return word[:-1] if len(word) > 2 and word.endswith(("시", "군", "구")) else word
|
||||||
|
|
||||||
|
|
||||||
|
def build_merchant(place_id: str, snapshot: dict) -> dict | None:
|
||||||
|
"""스냅샷 → SiteOntology publish 요청 본문. 숙박이 아니거나 상호가 없으면 None.
|
||||||
|
|
||||||
|
★ 스냅샷만 읽는다 — 스냅샷에는 노출 가능한 값(VERIFIED/CORRECTED fact · 발행 창 안의 지역 정보)만 있다.
|
||||||
|
미검증 fact 를 프로필에 실으면 그걸 근거로 고른 키워드가 메타 태그로 나간다."""
|
||||||
|
place = (snapshot or {}).get("place") or {}
|
||||||
|
if _number(place.get("category")) != PlaceCategory.LODGING.value:
|
||||||
|
return None
|
||||||
|
name = _text(place.get("name"))
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
values: dict[str, list[str]] = {}
|
||||||
|
for fact in (snapshot or {}).get("facts") or []:
|
||||||
|
if not isinstance(fact, dict):
|
||||||
|
continue
|
||||||
|
value = _text(fact.get("value"))
|
||||||
|
if value:
|
||||||
|
values.setdefault(_text(fact.get("key")), []).append(value)
|
||||||
|
|
||||||
|
features: list[str] = []
|
||||||
|
unverified: list[str] = []
|
||||||
|
for key, label in _AMENITY_FACTS.items():
|
||||||
|
found = values.get(key)
|
||||||
|
if not found:
|
||||||
|
unverified.append(label)
|
||||||
|
elif "true" in found:
|
||||||
|
features.append(label)
|
||||||
|
for key in _FEATURE_TEXT_KEYS:
|
||||||
|
for value in values.get(key, []):
|
||||||
|
if value not in features:
|
||||||
|
features.append(value)
|
||||||
|
|
||||||
|
capacities = [n for key in _CAPACITY_KEYS for n in map(_number, values.get(key, [])) if n]
|
||||||
|
intro = next(iter(values.get("intro", [])), "")
|
||||||
|
description = next((p.strip() for p in intro.split("\n") if p.strip()), "")
|
||||||
|
|
||||||
|
nearby: list[str] = []
|
||||||
|
for row in ((snapshot or {}).get("local") or {}).get("contents") or []:
|
||||||
|
if not isinstance(row, dict) or row.get("content_type") != LocalContentType.ATTRACTION.value:
|
||||||
|
continue
|
||||||
|
attraction = _text((row.get("body") or {}).get("name")) or _text(row.get("title"))
|
||||||
|
if attraction and attraction not in nearby:
|
||||||
|
nearby.append(attraction)
|
||||||
|
if len(nearby) >= MAX_NEARBY:
|
||||||
|
break
|
||||||
|
|
||||||
|
road_address, address = place.get("road_address"), place.get("address")
|
||||||
|
profile = {
|
||||||
|
"address": _text(road_address) or _text(address),
|
||||||
|
"capacity": {"max": max(capacities)} if capacities else None,
|
||||||
|
"features": features,
|
||||||
|
"nearby": nearby,
|
||||||
|
"unverified": unverified,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"externalId": place_id,
|
||||||
|
"name": name,
|
||||||
|
"industryId": LODGING_INDUSTRY,
|
||||||
|
"regionId": region_key(road_address, address),
|
||||||
|
"description": description,
|
||||||
|
"profile": {key: value for key, value in profile.items() if value},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence(merchant: dict) -> str:
|
||||||
|
"""이 가게의 확인된 자료를 한 덩어리로. 키워드 낱말은 여기에 들어 있어야 한다."""
|
||||||
|
profile = merchant.get("profile") or {}
|
||||||
|
parts = [
|
||||||
|
merchant.get("name"), merchant.get("description"), profile.get("address"),
|
||||||
|
*(profile.get("features") or []), *(profile.get("nearby") or []),
|
||||||
|
]
|
||||||
|
return _compact(" ".join(_text(p) for p in parts if p))
|
||||||
|
|
||||||
|
|
||||||
|
def _supported(keyword: str, evidence: str) -> bool:
|
||||||
|
"""모든 낱말이 자료에 있고, **자료로 확인한 낱말이 하나는 있어야** 한다.
|
||||||
|
|
||||||
|
★ 뒤 조건이 없으면 `숙소` 처럼 업종어뿐인 단어가 "주장하는 게 없다" 는 이유로 통과한다 —
|
||||||
|
실측(2026-09-14, 실제 발행 한 바퀴)에서 메타 10칸 중 한 칸이 `숙소` 였다. 어느 가게에나 붙는 단어라
|
||||||
|
이 가게를 설명하지 못한다."""
|
||||||
|
specific = False
|
||||||
|
for word in keyword.split():
|
||||||
|
if word in _GENERIC_WORDS:
|
||||||
|
continue
|
||||||
|
core = next((word[: -len(s)] for s in _GENERIC_SUFFIXES if word.endswith(s) and len(word) > len(s)), word)
|
||||||
|
core = _compact(core)
|
||||||
|
if len(core) < _MIN_CORE_LEN or core not in evidence:
|
||||||
|
return False
|
||||||
|
specific = True
|
||||||
|
return specific
|
||||||
|
|
||||||
|
|
||||||
|
def _is_question(item: dict) -> bool:
|
||||||
|
canonical = _text(item.get("canonical"))
|
||||||
|
return item.get("category") == "질문형" or "?" in canonical or canonical.endswith(("요", "까"))
|
||||||
|
|
||||||
|
|
||||||
|
def _usable(item, evidence: str) -> str | None:
|
||||||
|
"""메타 태그에 실어도 되는 추천이면 그 표기를, 아니면 None."""
|
||||||
|
if not isinstance(item, dict) or item.get("status") != "ok" or _is_question(item):
|
||||||
|
return None
|
||||||
|
canonical = _text(item.get("canonical"))
|
||||||
|
return canonical if canonical and _supported(canonical, evidence) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _title_keyword(result: dict, evidence: str, locality: str) -> str | None:
|
||||||
|
"""제목 업종어 자리에 넣을 대표 키워드 — 유형 레인에서 고른다.
|
||||||
|
|
||||||
|
★ SiteOntology 설계상 "한 페이지의 주력 키워드는 1개, 유형 레인 1위가 메인 페이지 주력" 이다.
|
||||||
|
다만 1위가 `군산 펜션 독채`(시설) 이고 2위가 `군산 독채펜션`(코어) 인 식으로 오므로 코어를 앞에 둔다.
|
||||||
|
★ 시·군 이름을 품어야 한다. `독채펜션` 만 남으면 지금 제목(`… 군산시 숙소`)보다 지역 신호가 약해진다."""
|
||||||
|
lane = next(
|
||||||
|
(l for l in result.get("byLane") or [] if isinstance(l, dict) and l.get("key") == "type"),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
items = [i for i in lane.get("items") or [] if isinstance(i, dict)]
|
||||||
|
for item in sorted(items, key=lambda i: i.get("category") != "코어"):
|
||||||
|
canonical = _usable(item, evidence)
|
||||||
|
if not canonical or _TITLE_BLOCKED_WORDS.intersection(canonical.split()):
|
||||||
|
continue
|
||||||
|
if locality and locality not in canonical:
|
||||||
|
continue
|
||||||
|
return canonical
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def select(result: dict, merchant: dict) -> dict:
|
||||||
|
"""/v1/match 응답 → {keywords, titleKeyword?}. 순위는 SiteOntology 의 융합 순위를 그대로 따른다."""
|
||||||
|
evidence = _evidence(merchant)
|
||||||
|
keywords: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in result.get("matches") or []:
|
||||||
|
canonical = _usable(item, evidence)
|
||||||
|
if not canonical or _compact(canonical) in seen:
|
||||||
|
continue
|
||||||
|
seen.add(_compact(canonical))
|
||||||
|
keywords.append(canonical)
|
||||||
|
if len(keywords) >= MAX_KEYWORDS:
|
||||||
|
break
|
||||||
|
|
||||||
|
seo: dict = {"keywords": keywords}
|
||||||
|
title = _title_keyword(result, evidence, _locality_word((merchant.get("profile") or {}).get("address")))
|
||||||
|
if title:
|
||||||
|
seo["titleKeyword"] = title
|
||||||
|
return seo
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch(place_id: str, snapshot: dict) -> dict | None:
|
||||||
|
"""스냅샷에 실을 seo 값. 못 만들면 None — 예외를 올리지 않는다(키워드는 발행을 막지 않는다)."""
|
||||||
|
if not site_ontology.is_configured():
|
||||||
|
return None
|
||||||
|
merchant = build_merchant(place_id, snapshot)
|
||||||
|
if merchant is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = await site_ontology.match_for_merchant(merchant)
|
||||||
|
except site_ontology.SiteOntologyError as ex:
|
||||||
|
LOG.w(f"[seo] place={place_id} SiteOntology 실패 — 키워드 없이 발행: {ex}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
seo = select(result, merchant)
|
||||||
|
LOG.i(
|
||||||
|
f"[seo] place={place_id} 추천 {len(result.get('matches') or [])}건 → 메타 {len(seo['keywords'])}건"
|
||||||
|
f" · 제목 {seo.get('titleKeyword') or '(기존 제목)'}"
|
||||||
|
)
|
||||||
|
if not seo["keywords"] and "titleKeyword" not in seo:
|
||||||
|
return None
|
||||||
|
return seo
|
||||||
@ -986,9 +986,26 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict:
|
|||||||
],
|
],
|
||||||
"narrative": narrative,
|
"narrative": narrative,
|
||||||
"theme": theme,
|
"theme": theme,
|
||||||
|
# ★ 검색 키워드(SiteOntology). 스냅샷에 있을 때만 싣는다 — 옛 스냅샷·SiteOntology 가 꺼진 빌드에는 없고,
|
||||||
|
# 그때 렌더러는 제목·메타를 예전 그대로 굽는다(solution/site/src/seo/meta.ts).
|
||||||
|
**_seo_entry(snapshot.get("seo")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _seo_entry(value) -> dict:
|
||||||
|
"""스냅샷의 seo(services/seo_keywords) → payload 의 `seo`. 없으면 키 자체를 만들지 않는다.
|
||||||
|
|
||||||
|
★ 여기서 다시 거르지 않는다 — 이 가게 자료로 거르는 곳은 seo_keywords 한 곳이다. 모양만 확인한다.
|
||||||
|
★ 빈 seo 를 만들지 않는다. 빈 배열은 '받았는데 비었다' 로 읽힌다(itineraries 와 같은 규칙)."""
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
keywords = [_text(k) for k in value.get("keywords") or [] if _text(k)]
|
||||||
|
title = _text(value.get("titleKeyword"))
|
||||||
|
if not keywords and not title:
|
||||||
|
return {}
|
||||||
|
return {"seo": {"keywords": keywords, **({"titleKeyword": title} if title else {})}}
|
||||||
|
|
||||||
|
|
||||||
def _as_float(value):
|
def _as_float(value):
|
||||||
try:
|
try:
|
||||||
return float(value) if value not in (None, "") else None
|
return float(value) if value not in (None, "") else None
|
||||||
|
|||||||
@ -253,7 +253,7 @@ async def _local_contents(place) -> dict:
|
|||||||
.order_by(area_contents.content_type.asc(), area_contents.collected_at.desc())
|
.order_by(area_contents.content_type.asc(), area_contents.collected_at.desc())
|
||||||
)
|
)
|
||||||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
area_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query)
|
area_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query, raise_error=False)
|
||||||
)
|
)
|
||||||
if err != ErrorType.SUCCESS:
|
if err != ErrorType.SUCCESS:
|
||||||
# ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다.
|
# ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다.
|
||||||
@ -278,7 +278,7 @@ async def _local_contents(place) -> dict:
|
|||||||
)
|
)
|
||||||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
area_contents.DBType(), DBWRType.DB_READ.value,
|
area_contents.DBType(), DBWRType.DB_READ.value,
|
||||||
lambda s: DB_SESSION_MNG.execute(s, shared_q),
|
lambda s: DB_SESSION_MNG.execute(s, shared_q, raise_error=False),
|
||||||
)
|
)
|
||||||
if err != ErrorType.SUCCESS:
|
if err != ErrorType.SUCCESS:
|
||||||
LOG.w(f"[snapshot] 주변 정보 조회 실패 place={place_id}: {err.name}")
|
LOG.w(f"[snapshot] 주변 정보 조회 실패 place={place_id}: {err.name}")
|
||||||
@ -354,7 +354,7 @@ async def _site_places(place_id) -> dict:
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
site_sections.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, q)
|
site_sections.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, q, raise_error=False)
|
||||||
)
|
)
|
||||||
if err != ErrorType.SUCCESS or not rows:
|
if err != ErrorType.SUCCESS or not rows:
|
||||||
return {}
|
return {}
|
||||||
@ -407,9 +407,16 @@ def _iso(value) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
async def _select(query) -> list:
|
async def _select(query) -> list:
|
||||||
|
"""조회 실패는 **빈 목록**이다 — 미리보기·발행이 조각 하나 때문에 통째로 죽지 않게.
|
||||||
|
|
||||||
|
★ raise_error=False 가 핵심이다 (2026-09-14). 이 함수는 원래 실패를 [] 로 삼키도록
|
||||||
|
썼는데, DB 계층이 기본값 raise_error=True 로 **예외를 던져** 그 처리가 실행될 기회조차
|
||||||
|
없었다. 실측: place_songs 테이블이 없던 동안 미리보기가 통째로 HTTP 500 이었다 —
|
||||||
|
노래 한 칸이 빠진 화면 대신 아무것도 못 보는 화면이 나갔다.
|
||||||
|
"""
|
||||||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
place_facts.DBType(),
|
place_facts.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
DBWRType.DB_READ.value,
|
||||||
lambda s: DB_SESSION_MNG.execute(s, query),
|
lambda s: DB_SESSION_MNG.execute(s, query, raise_error=False),
|
||||||
)
|
)
|
||||||
return list(rows) if err == ErrorType.SUCCESS else []
|
return list(rows) if err == ErrorType.SUCCESS else []
|
||||||
|
|||||||
@ -52,7 +52,8 @@ async def _place_with_facts(client, h, n_verified=5):
|
|||||||
async def _faq_rows(db_engine, pid):
|
async def _faq_rows(db_engine, pid):
|
||||||
async with db_engine.begin() as c:
|
async with db_engine.begin() as c:
|
||||||
return (await c.execute(
|
return (await c.execute(
|
||||||
text("SELECT question, answer, source_fact_ids, status FROM place_faqs WHERE place_id = :p ORDER BY sort_order"),
|
text("SELECT question, answer, source_fact_ids, status, generated_by FROM place_faqs "
|
||||||
|
"WHERE place_id = :p ORDER BY sort_order"),
|
||||||
{"p": uuid.UUID(pid)},
|
{"p": uuid.UUID(pid)},
|
||||||
)).all()
|
)).all()
|
||||||
|
|
||||||
@ -86,22 +87,29 @@ async def test_copy_generates_intro_and_faq_as_published(auth_headers, client, d
|
|||||||
assert intro[0]["source_type"] == SourceType.LLM.value
|
assert intro[0]["source_type"] == SourceType.LLM.value
|
||||||
|
|
||||||
|
|
||||||
async def test_copy_refuses_without_verified_facts(auth_headers, client, monkeypatch):
|
async def _must_not_call_llm(*args, **kwargs):
|
||||||
"""검증: 확인된 fact 가 하나도 없는 사업장에서 생성을 시도한다.
|
raise AssertionError("★ 근거 없이 LLM 을 불렀다")
|
||||||
기대결과: FAQ_UNGROUNDED — ★ 잡을 만들지 않는다. 근거 없이 쓰면 환각이고 유료 호출만 낭비다."""
|
|
||||||
|
|
||||||
|
async def test_copy_refuses_without_facts_when_no_catalog(auth_headers, client, monkeypatch):
|
||||||
|
"""검증: 확인된 fact 가 하나도 없는 **호텔**(펜션 카탈로그 제외 대상)에서 생성을 시도한다.
|
||||||
|
기대결과: FAQ_UNGROUNDED — ★ 잡을 만들지 않는다. 쓸 근거도, 채울 공통 질문도 없다."""
|
||||||
monkeypatch.setattr(gemini_text, "is_configured", lambda: True)
|
monkeypatch.setattr(gemini_text, "is_configured", lambda: True)
|
||||||
h = await auth_headers("u1")
|
h = await auth_headers("u1")
|
||||||
pid = (await client.post("/v1/place", headers=h, json={"name": "빈펜션", "category": 1})).json()["place"]["place_id"]
|
pid = (await client.post("/v1/place", headers=h, json={"name": "빈호텔", "category": 1})).json()["place"]["place_id"]
|
||||||
await client.post(f"/v1/place/{pid}/verify", headers=h, json={"source": 2, "road_address": "빈주소"})
|
await client.post(f"/v1/place/{pid}/verify", headers=h,
|
||||||
|
json={"source": 2, "road_address": "빈호텔주소", "category_name": "호텔"})
|
||||||
|
|
||||||
r = await client.post(f"/v1/place/{pid}/copy", headers=h, json={})
|
r = await client.post(f"/v1/place/{pid}/copy", headers=h, json={})
|
||||||
assert r.json()["result"]["code"] == ErrorType.FAQ_UNGROUNDED.value
|
assert r.json()["result"]["code"] == ErrorType.FAQ_UNGROUNDED.value
|
||||||
|
|
||||||
|
|
||||||
async def test_unverified_facts_are_not_used_as_grounding(auth_headers, client, monkeypatch):
|
async def test_unverified_facts_are_not_used_as_grounding(auth_headers, client, db_engine, monkeypatch):
|
||||||
"""검증: 크롤링으로 들어온 미검증 fact 만 있는 사업장.
|
"""검증: 크롤링으로 들어온 미검증 fact 만 있는 펜션.
|
||||||
기대결과: 근거로 안 쳐서 FAQ_UNGROUNDED — 미검증 값으로 쓴 문장도 미검증이다."""
|
기대결과: ★ 근거 0건으로 친다 — LLM 을 부르지 않는다(미검증 값으로 쓴 문장도 미검증이다).
|
||||||
|
FAQ 는 문의 안내로 20개를 채우고, 미검증인 체크인도 '답이 있는 주제' 로 치지 않는다."""
|
||||||
monkeypatch.setattr(gemini_text, "is_configured", lambda: True)
|
monkeypatch.setattr(gemini_text, "is_configured", lambda: True)
|
||||||
|
monkeypatch.setattr(gemini_text, "generate_copy", _must_not_call_llm)
|
||||||
h = await auth_headers("u1")
|
h = await auth_headers("u1")
|
||||||
pid = (await client.post("/v1/place", headers=h, json={"name": "미검증펜션", "category": 1})).json()["place"]["place_id"]
|
pid = (await client.post("/v1/place", headers=h, json={"name": "미검증펜션", "category": 1})).json()["place"]["place_id"]
|
||||||
await client.post(f"/v1/place/{pid}/verify", headers=h, json={"source": 2, "road_address": "미검증주소"})
|
await client.post(f"/v1/place/{pid}/verify", headers=h, json={"source": 2, "road_address": "미검증주소"})
|
||||||
@ -109,8 +117,36 @@ async def test_unverified_facts_are_not_used_as_grounding(auth_headers, client,
|
|||||||
"key": "check_in_time", "value": "15:00",
|
"key": "check_in_time", "value": "15:00",
|
||||||
"source_type": SourceType.CRAWL.value, "source_url": "https://ota.test/1"})
|
"source_type": SourceType.CRAWL.value, "source_url": "https://ota.test/1"})
|
||||||
|
|
||||||
r = await client.post(f"/v1/place/{pid}/copy", headers=h, json={})
|
body = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json()
|
||||||
assert r.json()["result"]["code"] == ErrorType.FAQ_UNGROUNDED.value
|
assert body["result"]["success"] is True and body["grounded_facts"] == 0
|
||||||
|
await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain()
|
||||||
|
job = (await client.get(f"/v1/job/{body['job_id']}", headers=h)).json()["job"]
|
||||||
|
assert job["status"] == JobStatus.DONE.value, job.get("last_error")
|
||||||
|
assert job["result"]["faqs"] == 0 and job["result"]["faq_fill"] == 20
|
||||||
|
|
||||||
|
live = [r for r in await _faq_rows(db_engine, pid) if r[3] in _PUBLISHABLE]
|
||||||
|
assert len(live) == 20 and all(r[4] == SourceType.TEMPLATE.value for r in live)
|
||||||
|
assert any("체크인" in r[0] for r in live)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zero_facts_still_fill_twenty_without_api_key(auth_headers, client, db_engine, monkeypatch):
|
||||||
|
"""검증: fact 0건 · GEMINI 키 없음인 펜션(분류 없음 — 스테이머뭄처럼).
|
||||||
|
기대결과: ★ 잡이 만들어지고 FAQ 가 정확히 20건 — 채우기는 LLM 을 안 쓰므로 키가 필요 없다."""
|
||||||
|
monkeypatch.setattr(gemini_text, "is_configured", lambda: False)
|
||||||
|
monkeypatch.setattr(gemini_text, "generate_copy", _must_not_call_llm)
|
||||||
|
h = await auth_headers("u1")
|
||||||
|
pid = (await client.post("/v1/place", headers=h, json={"name": "무근거펜션", "category": 1})).json()["place"]["place_id"]
|
||||||
|
await client.post(f"/v1/place/{pid}/verify", headers=h, json={"source": 2, "road_address": "무근거주소"})
|
||||||
|
|
||||||
|
body = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json()
|
||||||
|
assert body["result"]["success"] is True, body["result"]
|
||||||
|
await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain()
|
||||||
|
job = (await client.get(f"/v1/job/{body['job_id']}", headers=h)).json()["job"]
|
||||||
|
assert job["status"] == JobStatus.DONE.value, job.get("last_error")
|
||||||
|
assert job["result"]["faq_fill"] == 20
|
||||||
|
|
||||||
|
live = [r for r in await _faq_rows(db_engine, pid) if r[3] in _PUBLISHABLE]
|
||||||
|
assert len(live) == 20 and len({r[0] for r in live}) == 20
|
||||||
|
|
||||||
|
|
||||||
async def test_faq_without_grounding_is_dropped(auth_headers, client, db_engine, monkeypatch):
|
async def test_faq_without_grounding_is_dropped(auth_headers, client, db_engine, monkeypatch):
|
||||||
@ -128,7 +164,10 @@ async def test_faq_without_grounding_is_dropped(auth_headers, client, db_engine,
|
|||||||
job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]
|
job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]
|
||||||
assert job["result"]["faqs"] == 1
|
assert job["result"]["faqs"] == 1
|
||||||
assert any("수영장" in str(r) for r in job["result"]["rejected"])
|
assert any("수영장" in str(r) for r in job["result"]["rejected"])
|
||||||
assert len(await _faq_rows(db_engine, pid)) == 1
|
rows = await _faq_rows(db_engine, pid)
|
||||||
|
assert len([r for r in rows if r[4] == SourceType.LLM.value]) == 1
|
||||||
|
# 채우기가 넣은 문의 안내에도 '수영장 있나요?' 의 거짓 답("네 있습니다")은 없다.
|
||||||
|
assert not any("있습니다" in r[1] for r in rows if r[4] == SourceType.TEMPLATE.value)
|
||||||
|
|
||||||
|
|
||||||
async def test_rejected_sentences_are_reported(auth_headers, client, monkeypatch):
|
async def test_rejected_sentences_are_reported(auth_headers, client, monkeypatch):
|
||||||
@ -161,9 +200,10 @@ async def test_regeneration_keeps_faq_the_owner_corrected(auth_headers, client,
|
|||||||
# 사장님이 문구를 고쳐 승인 — 잠긴 상태(CORRECTED)가 되고 책임 주체가 사람으로 넘어온다.
|
# 사장님이 문구를 고쳐 승인 — 잠긴 상태(CORRECTED)가 되고 책임 주체가 사람으로 넘어온다.
|
||||||
async with db_engine.begin() as c:
|
async with db_engine.begin() as c:
|
||||||
await c.execute(
|
await c.execute(
|
||||||
text("UPDATE place_faqs SET status = :s, generated_by = :g, question = :q WHERE place_id = :p"),
|
text("UPDATE place_faqs SET status = :s, generated_by = :g, question = :q "
|
||||||
|
"WHERE place_id = :p AND generated_by = :llm"),
|
||||||
{"s": FactStatus.CORRECTED.value, "g": SourceType.OWNER.value,
|
{"s": FactStatus.CORRECTED.value, "g": SourceType.OWNER.value,
|
||||||
"q": "체크인 시간이 어떻게 되나요?", "p": uuid.UUID(pid)},
|
"q": "체크인 시간이 어떻게 되나요?", "p": uuid.UUID(pid), "llm": SourceType.LLM.value},
|
||||||
)
|
)
|
||||||
|
|
||||||
_patch(monkeypatch, _copy(faqs=[gemini_text.GeneratedFaq("새 질문?", "새 답변", ["check_in_time"])]))
|
_patch(monkeypatch, _copy(faqs=[gemini_text.GeneratedFaq("새 질문?", "새 답변", ["check_in_time"])]))
|
||||||
@ -177,6 +217,55 @@ async def test_regeneration_keeps_faq_the_owner_corrected(auth_headers, client,
|
|||||||
assert any(r[0] == "새 질문?" and r[3] == FactStatus.VERIFIED.value for r in rows)
|
assert any(r[0] == "새 질문?" and r[3] == FactStatus.VERIFIED.value for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
_PUBLISHABLE = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_copy_fills_faqs_to_target_without_duplicates(auth_headers, client, db_engine, monkeypatch):
|
||||||
|
"""검증: 생성된 FAQ 가 1건뿐이고, 사장님이 직접 쓴 FAQ 가 1건 있다.
|
||||||
|
기대결과: 노출 중 FAQ 가 정확히 20건 —
|
||||||
|
· 모자란 만큼은 문의 안내(TEMPLATE)로 채운다
|
||||||
|
· fact 로 답할 수 있는 주제(체크인·체크아웃·취소·취사·반려동물)에는 문의 안내를 붙이지 않는다
|
||||||
|
· 사장님이 이미 쓴 주제(보증금)도 다시 넣지 않는다"""
|
||||||
|
_patch(monkeypatch, _copy())
|
||||||
|
h = await auth_headers("u1")
|
||||||
|
pid = await _place_with_facts(client, h)
|
||||||
|
owner = await client.post(f"/v1/place/{pid}/faq", headers=h, json={"question": "보증금이 있나요?", "answer": "없습니다."})
|
||||||
|
assert owner.json()["result"]["success"] is True
|
||||||
|
|
||||||
|
job_id = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json()["job_id"]
|
||||||
|
await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain()
|
||||||
|
job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]
|
||||||
|
assert job["status"] == JobStatus.DONE.value, job.get("last_error")
|
||||||
|
assert job["result"]["faqs"] == 1
|
||||||
|
assert job["result"]["faq_fill"] == 18
|
||||||
|
|
||||||
|
live = [r for r in await _faq_rows(db_engine, pid) if r[3] in _PUBLISHABLE]
|
||||||
|
assert len(live) == 20
|
||||||
|
assert len({r[0] for r in live}) == 20, "같은 질문이 두 번 들어갔다"
|
||||||
|
|
||||||
|
fills = [r for r in live if r[4] == SourceType.TEMPLATE.value]
|
||||||
|
assert len(fills) == 18
|
||||||
|
fill_text = " ".join(r[0] for r in fills)
|
||||||
|
for topic in ("체크인", "체크아웃", "취소", "요리", "반려동물", "보증금"):
|
||||||
|
assert topic not in fill_text, f"★ 이미 답이 있는 주제 '{topic}' 에 문의 안내가 붙었다"
|
||||||
|
assert all("문의" in r[1] or "확인" in r[1] for r in fills), "문의 안내가 아닌 답이 섞였다"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_regeneration_replaces_fill_faqs(auth_headers, client, db_engine, monkeypatch):
|
||||||
|
"""검증: 생성을 두 번 돌린다.
|
||||||
|
기대결과: 노출 중 FAQ 는 여전히 20건 — 옛 문의 안내는 내려가고 다시 고른다(쌓이지 않는다)."""
|
||||||
|
_patch(monkeypatch, _copy())
|
||||||
|
h = await auth_headers("u1")
|
||||||
|
pid = await _place_with_facts(client, h)
|
||||||
|
for _ in range(2):
|
||||||
|
await client.post(f"/v1/place/{pid}/copy", headers=h, json={})
|
||||||
|
await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain()
|
||||||
|
|
||||||
|
live = [r for r in await _faq_rows(db_engine, pid) if r[3] in _PUBLISHABLE]
|
||||||
|
assert len(live) == 20
|
||||||
|
assert len({r[0] for r in live}) == 20
|
||||||
|
|
||||||
|
|
||||||
async def test_copy_requires_api_key(auth_headers, client, monkeypatch):
|
async def test_copy_requires_api_key(auth_headers, client, monkeypatch):
|
||||||
"""검증: GEMINI_API_KEY 없이 생성을 시도한다.
|
"""검증: GEMINI_API_KEY 없이 생성을 시도한다.
|
||||||
기대결과: GENERATOR_NOT_CONFIGURED — 잡을 만들지 않는다."""
|
기대결과: GENERATOR_NOT_CONFIGURED — 잡을 만들지 않는다."""
|
||||||
|
|||||||
104
solution/backend/tests/test_faq_fill.py
Normal file
104
solution/backend/tests/test_faq_fill.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
"""FAQ 목표 수 채우기 — ★ 공통 답은 문의 안내뿐이고, 이미 다룬 주제는 다시 넣지 않는다.
|
||||||
|
|
||||||
|
이 기능이 절대 하면 안 되는 것:
|
||||||
|
- 공통 답에 값·가능 여부를 적는 것 (가게마다 다르다 — 틀리면 예약 클레임)
|
||||||
|
- fact 로 답할 수 있는 질문에 "문의 부탁드립니다" 를 붙이는 것 (아는 것을 숨긴다)
|
||||||
|
- 기존 FAQ 와 같은 주제를 다시 넣는 것
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from common.enums import ErrorType, PlaceCategory
|
||||||
|
from common.faq_catalog import find_catalog
|
||||||
|
from services import faq_fill
|
||||||
|
from services.faq_fill import ExistingFaq
|
||||||
|
|
||||||
|
|
||||||
|
def _pension():
|
||||||
|
catalog = find_catalog(PlaceCategory.LODGING.value, "펜션")
|
||||||
|
assert catalog is not None
|
||||||
|
return catalog
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(picks):
|
||||||
|
return {p.catalog_id for p in picks}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pension_catalog_has_thirty_questions():
|
||||||
|
"""검증: 카탈로그 로드. fact_keys 가 업종 스키마에 없으면 로더가 예외를 던진다."""
|
||||||
|
catalog = _pension()
|
||||||
|
assert len(catalog.items) == 30
|
||||||
|
assert len({item.id for item in catalog.items}) == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_scope():
|
||||||
|
"""검증: 호텔·카페에는 펜션 질문을 붙이지 않는다. 분류가 비어 있는 숙박업(스테이머뭄)에는 붙인다."""
|
||||||
|
assert find_catalog(PlaceCategory.LODGING.value, "호텔") is None
|
||||||
|
assert find_catalog(PlaceCategory.CAFE.value, None) is None
|
||||||
|
assert find_catalog(PlaceCategory.LODGING.value, None) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fills_only_the_shortfall():
|
||||||
|
"""검증: 기존 FAQ 5건(카탈로그와 무관한 질문). 기대결과: 15건만 고른다. 20건이면 0건."""
|
||||||
|
catalog = _pension()
|
||||||
|
picks = faq_fill.pick_fill_faqs(catalog, [ExistingFaq(f"기타 질문 {i}") for i in range(5)], set())
|
||||||
|
assert len(picks) == 15 and len(_ids(picks)) == 15
|
||||||
|
assert faq_fill.pick_fill_faqs(catalog, [ExistingFaq(f"질문 {i}") for i in range(20)], set()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_topics_answerable_by_facts():
|
||||||
|
"""검증: 체크인·반려동물 fact 가 있다(객실 fact 는 'A동:max_capacity' 로 적힌다).
|
||||||
|
기대결과: 그 질문들은 문의 안내로 채우지 않는다 — LLM 이 fact 로 답할 자리다."""
|
||||||
|
picks = faq_fill.pick_fill_faqs(_pension(), [], {"check_in_time", "pet_allowed", "A동:max_capacity"}, target=30)
|
||||||
|
assert not _ids(picks) & {"check_in", "pet", "capacity"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_topics_already_asked_by_keyword():
|
||||||
|
"""검증: 사장님이 근거 key 없이 쓴 FAQ 가 있다. 기대결과: 질문 낱말로 같은 주제를 알아본다."""
|
||||||
|
existing = [ExistingFaq("반려견 데려가도 되나요?"), ExistingFaq("주차 되나요?")]
|
||||||
|
picks = faq_fill.pick_fill_faqs(_pension(), existing, set(), target=30)
|
||||||
|
assert not _ids(picks) & {"pet", "parking"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_topics_covered_by_source_fact_keys():
|
||||||
|
"""검증: LLM FAQ 의 질문에는 키워드가 없지만 근거 key 가 [wifi, A동:max_capacity] 다.
|
||||||
|
기대결과: 와이파이·인원 질문을 넣지 않는다 — LLM 은 두 주제를 한 문항에 묶기도 한다."""
|
||||||
|
existing = [ExistingFaq("편의 안내가 궁금해요", ["wifi", "A동:max_capacity"])]
|
||||||
|
picks = faq_fill.pick_fill_faqs(_pension(), existing, set(), target=30)
|
||||||
|
assert not _ids(picks) & {"wifi", "capacity"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_answers_are_inquiry_only():
|
||||||
|
"""검증: 공통 답 문구.
|
||||||
|
기대결과: ★ 연락처가 없으면 숫자가 하나도 없다(가격·시각을 지어내지 않는다). 연락처가 있으면 그 번호만 들어간다."""
|
||||||
|
catalog = _pension()
|
||||||
|
for pick in faq_fill.pick_fill_faqs(catalog, [], set(), target=30):
|
||||||
|
assert not re.search(r"\d", pick.answer), pick.answer
|
||||||
|
assert "숙소" in pick.answer
|
||||||
|
for pick in faq_fill.pick_fill_faqs(catalog, [], set(), phone="010-1234-5678", target=30):
|
||||||
|
assert "전화(010-1234-5678)로" in pick.answer
|
||||||
|
assert not re.search(r"\d", pick.answer.replace("010-1234-5678", "")), pick.answer
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_particle_and_rotation():
|
||||||
|
"""검증: 주제 뒤 조사(은/는)와 문구 돌려쓰기."""
|
||||||
|
catalog = _pension()
|
||||||
|
assert faq_fill.fallback_answer(catalog, 0, "보증금 여부", None).startswith("보증금 여부는 ")
|
||||||
|
assert faq_fill.fallback_answer(catalog, 0, "주변 편의시설", None).startswith("주변 편의시설은 ")
|
||||||
|
assert len({faq_fill.fallback_answer(catalog, i, "주제", None) for i in range(3)}) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggested_questions_are_the_answerable_ones():
|
||||||
|
"""검증: 프롬프트에 실을 질문 = fact 로 답할 수 있는 카탈로그 질문."""
|
||||||
|
questions = faq_fill.suggested_questions(_pension(), {"parking", "check_out_time"})
|
||||||
|
assert questions == ["체크아웃은 몇 시까지인가요?", "주차할 수 있나요? 몇 대까지 가능한가요?"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_template_source_cannot_write_fact(auth_headers, client):
|
||||||
|
"""검증: fact 를 TEMPLATE 출처로 쓴다.
|
||||||
|
기대결과: INVALID_REQUEST_DATA — 막지 않으면 사람 입력처럼 바로 노출값이 된다(fact_service 규칙 4)."""
|
||||||
|
h = await auth_headers("u1")
|
||||||
|
pid = (await client.post("/v1/place", headers=h, json={"name": "틀펜션", "category": 1})).json()["place"]["place_id"]
|
||||||
|
await client.post(f"/v1/place/{pid}/verify", headers=h, json={"source": 2, "road_address": "틀주소"})
|
||||||
|
r = await client.post(f"/v1/place/{pid}/fact", headers=h, json={
|
||||||
|
"key": "check_in_time", "value": "15:00", "source_type": 5, "source_url": "template:test"})
|
||||||
|
assert r.json()["result"]["code"] == ErrorType.INVALID_REQUEST_DATA.value
|
||||||
321
solution/backend/tests/test_seo_keywords.py
Normal file
321
solution/backend/tests/test_seo_keywords.py
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
"""발행 사이트 메타 태그 키워드(SiteOntology).
|
||||||
|
|
||||||
|
이 경로가 절대 하면 안 되는 것:
|
||||||
|
- 이 가게 자료에 없는 낱말이 든 키워드를 메타 태그·제목에 싣는 것
|
||||||
|
(확인 안 된 시설 · 다른 권역 · 가격 주장)
|
||||||
|
- SiteOntology 실패로 발행을 막는 것 — 키워드는 곁들이다
|
||||||
|
- 해석되지 않은 업체의 추천(입력 문자열로 검색한 결과)을 쓰는 것
|
||||||
|
- 숙박이 아닌 업종에 펜션 키워드를 붙이는 것
|
||||||
|
|
||||||
|
★ MATCH 의 matches 1~10 · byLane 은 실제 응답에서 옮겼다 — 2026-09-14 로컬 SiteOntology(:3100) 에
|
||||||
|
스테이머뭄 프로필(features 독채·프라이빗, audiences 커플·가족, nearby 말랭이마을·동국사)을 보낸 결과.
|
||||||
|
11~13 은 같은 사전에 실제로 있는 단어로, 거르기 규칙을 보려고 붙였다.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from common.enums import JobStatus, LocalContentType, MediaStatus, PlaceCategory, SiteStatus, SourceType
|
||||||
|
from crud.job_crud import JobQueue
|
||||||
|
from services import seo_keywords
|
||||||
|
from services.external import site_ontology
|
||||||
|
from worker.handlers import build_handler
|
||||||
|
from worker.runner import Worker
|
||||||
|
|
||||||
|
|
||||||
|
def _kw(canonical, category, intent="local", status="ok"):
|
||||||
|
return {"canonical": canonical, "category": category, "intent": intent, "kind": "keyword", "status": status}
|
||||||
|
|
||||||
|
|
||||||
|
MATCH = {
|
||||||
|
"mode": "fusion",
|
||||||
|
"matches": [
|
||||||
|
_kw("군산 펜션 독채", "시설"),
|
||||||
|
_kw("군산 독채펜션", "코어"),
|
||||||
|
_kw("군산 독채 마당 펜션", "롱테일"),
|
||||||
|
_kw("군산 독채 복층 펜션", "롱테일"),
|
||||||
|
_kw("군산 커플 프라이빗 펜션", "롱테일"),
|
||||||
|
_kw("군산 독채펜션 예약", "코어", intent="transactional"),
|
||||||
|
_kw("군산에 독채 펜션 있나요", "질문형", intent="informational"),
|
||||||
|
_kw("군산 가족 독채펜션", "동반자"),
|
||||||
|
_kw("말랭이마을 근처 숙소", "관광지"),
|
||||||
|
_kw("군산 프라이빗한 독채", "분위기"),
|
||||||
|
_kw("선유도 독채펜션", "권역"),
|
||||||
|
_kw("군산 펜션 최저가", "의도", intent="transactional"),
|
||||||
|
_kw("군산 주차 펜션", "시설", status="hold"),
|
||||||
|
],
|
||||||
|
"byLane": [
|
||||||
|
{"key": "type", "label": "유형", "items": [
|
||||||
|
_kw("군산 펜션 독채", "시설"),
|
||||||
|
_kw("군산 독채펜션", "코어"),
|
||||||
|
_kw("군산 독채 마당 펜션", "롱테일"),
|
||||||
|
]},
|
||||||
|
{"key": "place", "label": "위치", "items": [_kw("말랭이마을 근처 숙소", "관광지")]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fact(key, value, unit_id=None):
|
||||||
|
return {"key": key, "value": value, "scope": "unit" if unit_id else "place", "unit_id": unit_id, "status": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot(**place):
|
||||||
|
return {
|
||||||
|
"place": {
|
||||||
|
"name": "스테이,머뭄", "category": PlaceCategory.LODGING.value,
|
||||||
|
"road_address": "전북특별자치도 군산시 절골길 18", "address": None, **place,
|
||||||
|
},
|
||||||
|
"facts": [
|
||||||
|
_fact("intro", "군산 원도심 말랭이마을 옆 독채 스테이.\n두 번째 문단은 싣지 않는다."),
|
||||||
|
_fact("bbq_available", "false"),
|
||||||
|
_fact("parking", "true"),
|
||||||
|
_fact("room_type", "독채", unit_id="u1"),
|
||||||
|
_fact("max_capacity", "4", unit_id="u1"),
|
||||||
|
_fact("max_capacity", "2", unit_id="u2"),
|
||||||
|
],
|
||||||
|
"local": {"contents": [
|
||||||
|
{"content_type": LocalContentType.ATTRACTION.value, "title": "말랭이마을", "body": {"name": "말랭이마을"}},
|
||||||
|
{"content_type": LocalContentType.RESTAURANT.value, "title": "이성당", "body": {"name": "이성당"}},
|
||||||
|
{"content_type": LocalContentType.ATTRACTION.value, "title": "동국사", "body": {}},
|
||||||
|
]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 요청 만들기 ─────────────────────────────────────────────────────────────
|
||||||
|
def test_스냅샷으로_업체_프로필을_만든다():
|
||||||
|
"""검증: 확인된 fact·지역 정보가 든 숙박 스냅샷.
|
||||||
|
기대결과: 있음(true)은 features, 모름(fact 없음)은 unverified, 없음(false)은 어디에도 없다."""
|
||||||
|
merchant = seo_keywords.build_merchant("place-1", _snapshot())
|
||||||
|
|
||||||
|
assert merchant == {
|
||||||
|
"externalId": "place-1",
|
||||||
|
"name": "스테이,머뭄",
|
||||||
|
"industryId": "stay.pension",
|
||||||
|
"regionId": "kr.jeonbuk.gunsan",
|
||||||
|
"description": "군산 원도심 말랭이마을 옆 독채 스테이.",
|
||||||
|
"profile": {
|
||||||
|
"address": "전북특별자치도 군산시 절골길 18",
|
||||||
|
"capacity": {"max": 4},
|
||||||
|
"features": ["주차", "독채"],
|
||||||
|
# 음식점(이성당)은 주변 관광지가 아니다.
|
||||||
|
"nearby": ["말랭이마을", "동국사"],
|
||||||
|
# ★ 바베큐는 false(없음)라 여기 없다 — 넣으면 SiteOntology 가 배제 대신 보류한다.
|
||||||
|
"unverified": ["애견동반", "조식"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_숙박이_아니면_부르지_않는다():
|
||||||
|
assert seo_keywords.build_merchant("place-1", _snapshot(category=PlaceCategory.CAFE.value)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_표에_없는_지역은_지역을_비운다():
|
||||||
|
"""검증: SiteOntology 지역 표(54개)에 없는 시·군.
|
||||||
|
기대결과: 지어내지 않고 None — 없는 키를 보내면 SiteOntology 가 500 이다."""
|
||||||
|
merchant = seo_keywords.build_merchant("place-1", _snapshot(road_address="서울특별시 강남구 테헤란로 1"))
|
||||||
|
assert merchant["regionId"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_같은_이름의_시군은_시도로_가른다():
|
||||||
|
assert seo_keywords.region_key("강원특별자치도 고성군 토성면 1") == "kr.gangwon.goseong"
|
||||||
|
assert seo_keywords.region_key("경상남도 고성군 고성읍 1") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 거르기 ─────────────────────────────────────────────────────────────────
|
||||||
|
def test_자료가_뒷받침하는_키워드만_남긴다():
|
||||||
|
"""검증: 실제 추천 결과를 이 가게 자료(주차·독채·원도심·말랭이마을·동국사)로 거른다.
|
||||||
|
기대결과: 마당·복층·커플·가족·프라이빗(확인 안 됨), 선유도(다른 권역), 최저가(가격 주장),
|
||||||
|
질문형, 보류(hold)가 모두 빠진다. 순위는 SiteOntology 순서 그대로."""
|
||||||
|
merchant = seo_keywords.build_merchant("place-1", _snapshot())
|
||||||
|
seo = seo_keywords.select(MATCH, merchant)
|
||||||
|
|
||||||
|
assert seo["keywords"] == ["군산 펜션 독채", "군산 독채펜션", "군산 독채펜션 예약", "말랭이마을 근처 숙소"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_제목_키워드는_유형_레인의_코어를_고른다():
|
||||||
|
merchant = seo_keywords.build_merchant("place-1", _snapshot())
|
||||||
|
assert seo_keywords.select(MATCH, merchant)["titleKeyword"] == "군산 독채펜션"
|
||||||
|
|
||||||
|
|
||||||
|
def test_제목에는_예약_추천과_지역_없는_단어를_쓰지_않는다():
|
||||||
|
"""검증: 유형 레인 코어가 `… 예약` 이고, 지역명이 없는 태그가 섞였다.
|
||||||
|
기대결과: 둘을 건너뛰고 `군산 펜션 독채` — 상호 옆에서 광고 문구가 되거나 지역 신호를 잃지 않는다."""
|
||||||
|
merchant = seo_keywords.build_merchant("place-1", _snapshot())
|
||||||
|
result = {"matches": [], "byLane": [{"key": "type", "items": [
|
||||||
|
_kw("군산 독채펜션 예약", "코어", intent="transactional"),
|
||||||
|
_kw("독채펜션", "태그"),
|
||||||
|
_kw("군산 펜션 독채", "시설"),
|
||||||
|
]}]}
|
||||||
|
assert seo_keywords.select(result, merchant) == {"keywords": [], "titleKeyword": "군산 펜션 독채"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_업종어뿐인_단어는_싣지_않는다():
|
||||||
|
"""검증: 낱말이 전부 업종어·"근처" 류인 추천(실측: 실제 발행에서 `숙소` 가 메타 한 칸을 차지했다).
|
||||||
|
기대결과: 뺀다 — 어느 가게에나 붙는 단어라 이 가게를 설명하지 못한다. 자료 낱말이 섞이면 남긴다."""
|
||||||
|
merchant = seo_keywords.build_merchant("place-1", _snapshot())
|
||||||
|
result = {"matches": [_kw("숙소", "태그"), _kw("펜션 예약", "의도"), _kw("원도심", "태그")], "byLane": []}
|
||||||
|
assert seo_keywords.select(result, merchant)["keywords"] == ["원도심"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_표기만_다른_중복은_하나로():
|
||||||
|
merchant = seo_keywords.build_merchant("place-1", _snapshot())
|
||||||
|
result = {"matches": [_kw("군산 독채펜션", "코어"), _kw("군산 독채 펜션", "태그")], "byLane": []}
|
||||||
|
assert seo_keywords.select(result, merchant)["keywords"] == ["군산 독채펜션"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 클라이언트 ────────────────────────────────────────────────────────────
|
||||||
|
def _response(status, body, url):
|
||||||
|
return httpx.Response(status, json=body, request=httpx.Request("POST", url))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ontology_url(monkeypatch):
|
||||||
|
monkeypatch.setattr(site_ontology.external_api_config, "site_ontology_url", "http://onto.test/")
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_ontology(sent, *, reject_region=False, resolve=True):
|
||||||
|
async def fake_post(self, url, json=None, **kwargs):
|
||||||
|
sent.append((url, json))
|
||||||
|
if url.endswith(site_ontology.PUBLISH_PATH):
|
||||||
|
if reject_region and json.get("regionId"):
|
||||||
|
# 실측: region 표에 없는 키 → 외래키 위반 → 500
|
||||||
|
return _response(500, {"statusCode": 500, "message": "Internal server error"}, url)
|
||||||
|
return _response(201, {"merchant": {"external_id": json["externalId"]}, "generation": "skipped"}, url)
|
||||||
|
resolved = {"externalId": json["query"]} if resolve else None
|
||||||
|
return _response(201, {**MATCH, "resolved": resolved}, url)
|
||||||
|
|
||||||
|
return fake_post
|
||||||
|
|
||||||
|
|
||||||
|
async def test_업체를_저장한_뒤_그_업체로_추천을_받는다(ontology_url, monkeypatch):
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology(sent))
|
||||||
|
merchant = seo_keywords.build_merchant("place-1", _snapshot())
|
||||||
|
|
||||||
|
await site_ontology.match_for_merchant(merchant)
|
||||||
|
|
||||||
|
assert [url for url, _ in sent] == ["http://onto.test/v1/merchants/publish", "http://onto.test/v1/match"]
|
||||||
|
# ★ generate:false 가 빠지면 SiteOntology 가 LLM 키워드 생성을 큐에 넣는다.
|
||||||
|
assert sent[0][1] == {**merchant, "generate": False}
|
||||||
|
assert sent[1][1] == {"query": "place-1", "limit": site_ontology.DEFAULT_LIMIT}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_지역_키가_거절되면_지역_없이_다시_보낸다(ontology_url, monkeypatch):
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology(sent, reject_region=True))
|
||||||
|
merchant = seo_keywords.build_merchant("place-1", _snapshot())
|
||||||
|
|
||||||
|
await site_ontology.match_for_merchant(merchant)
|
||||||
|
|
||||||
|
publishes = [body for url, body in sent if url.endswith(site_ontology.PUBLISH_PATH)]
|
||||||
|
assert [b["regionId"] for b in publishes] == ["kr.jeonbuk.gunsan", None]
|
||||||
|
assert sent[-1][0].endswith(site_ontology.MATCH_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_업체가_해석되지_않은_추천은_쓰지_않는다(ontology_url, monkeypatch):
|
||||||
|
"""검증: match 가 resolved=null 로 201 을 준다(입력 문자열로 검색한 결과).
|
||||||
|
기대결과: 실패로 본다 — 그 결과는 남의 동네 단어다(실측: `나운동 숙소`)."""
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology([], resolve=False))
|
||||||
|
merchant = seo_keywords.build_merchant("place-1", _snapshot())
|
||||||
|
|
||||||
|
with pytest.raises(site_ontology.SiteOntologyError):
|
||||||
|
await site_ontology.match_for_merchant(merchant)
|
||||||
|
assert await seo_keywords.fetch("place-1", _snapshot()) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_주소가_비면_부르지_않는다(monkeypatch):
|
||||||
|
monkeypatch.setattr(site_ontology.external_api_config, "site_ontology_url", "")
|
||||||
|
|
||||||
|
async def boom(self, url, json=None, **kwargs):
|
||||||
|
raise AssertionError("설정이 비었는데 SiteOntology 를 불렀다")
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", boom)
|
||||||
|
assert await seo_keywords.fetch("place-1", _snapshot()) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_연결이_안_되면_예외를_올리지_않는다(ontology_url, monkeypatch):
|
||||||
|
async def refused(self, url, json=None, **kwargs):
|
||||||
|
raise httpx.ConnectError("connection refused", request=httpx.Request("POST", url))
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", refused)
|
||||||
|
assert await seo_keywords.fetch("place-1", _snapshot()) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 발행 한 바퀴 ───────────────────────────────────────────────────────────
|
||||||
|
REQUIRED = {
|
||||||
|
"check_in_time": "15:00", "check_out_time": "11:00",
|
||||||
|
"cancel_policy": "이용 7일 전 100% 환불, 3일 전 50% 환불",
|
||||||
|
"cooking_allowed": "true", "pet_allowed": "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _published_place(client, h, db_engine):
|
||||||
|
pid = (await client.post("/v1/place", headers=h, json={"name": "하조대독채", "category": 1})).json()["place"]["place_id"]
|
||||||
|
await client.post(f"/v1/place/{pid}/verify", headers=h, json={
|
||||||
|
"source": 2, "road_address": f"강원특별자치도 양양군 현북면 하조대3길 {uuid.uuid4().hex[:3]}",
|
||||||
|
"phone": "033-672-0000", "latitude": "38.0219217", "longitude": "128.7221449",
|
||||||
|
})
|
||||||
|
for k, v in {**REQUIRED, "intro": "하조대 해변 도보 3분 거리의 독채 펜션입니다.", "facilities": "독채"}.items():
|
||||||
|
await client.post(f"/v1/place/{pid}/fact", headers=h, json={"key": k, "value": v})
|
||||||
|
async with db_engine.begin() as c:
|
||||||
|
await c.execute(
|
||||||
|
text("INSERT INTO place_photos (media_id, place_id, url, origin_url, source_type, status, alt_text, label, sort_order) "
|
||||||
|
"VALUES (:m,:p,:u,:u,:s,:st,:a,:l,0)"),
|
||||||
|
{"m": uuid.uuid4(), "p": uuid.UUID(pid), "u": f"https://cdn.test/{uuid.uuid4().hex[:6]}.jpg",
|
||||||
|
"s": SourceType.CRAWL.value, "st": MediaStatus.APPROVED.value, "a": "2층 목조 건물 외관", "l": "외관"},
|
||||||
|
)
|
||||||
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
async def _build(client, h, pid) -> dict:
|
||||||
|
job_id = (await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True})).json()["job_id"]
|
||||||
|
assert await Worker("w", JobQueue(), build_handler(), job_deadline_sec=60).process_one() is True
|
||||||
|
job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]
|
||||||
|
assert job["status"] == JobStatus.DONE.value, job.get("last_error")
|
||||||
|
return job["result"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_발행_payload_에_키워드가_실린다(auth_headers, client, db_engine, ontology_url, monkeypatch):
|
||||||
|
"""검증: SiteOntology 가 켜진 채로 숙박 사업장을 발행한다.
|
||||||
|
기대결과: 스냅샷으로 만든 프로필이 나가고, 거른 키워드가 payload.seo 로 실린다."""
|
||||||
|
h = await auth_headers("u1")
|
||||||
|
pid = await _published_place(client, h, db_engine)
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def fake_match(merchant, limit=site_ontology.DEFAULT_LIMIT):
|
||||||
|
sent.append(merchant)
|
||||||
|
return {"resolved": {"externalId": merchant["externalId"]},
|
||||||
|
"matches": [_kw("양양 독채펜션", "코어"), _kw("양양 독채 마당 펜션", "롱테일")],
|
||||||
|
"byLane": [{"key": "type", "items": [_kw("양양 독채펜션", "코어")]}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(site_ontology, "match_for_merchant", fake_match)
|
||||||
|
r = await _build(client, h, pid)
|
||||||
|
|
||||||
|
assert r["published"] is True
|
||||||
|
assert sent[0]["externalId"] == pid
|
||||||
|
assert sent[0]["regionId"] == "kr.gangwon.yangyang"
|
||||||
|
assert "독채" in sent[0]["profile"]["features"]
|
||||||
|
payload = json.loads(open(r["payload_path"], encoding="utf-8").read())
|
||||||
|
assert payload["seo"] == {"keywords": ["양양 독채펜션"], "titleKeyword": "양양 독채펜션"}
|
||||||
|
assert r["seo"] == payload["seo"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_SiteOntology_가_죽어도_발행된다(auth_headers, client, db_engine, ontology_url, monkeypatch):
|
||||||
|
h = await auth_headers("u1")
|
||||||
|
pid = await _published_place(client, h, db_engine)
|
||||||
|
|
||||||
|
async def down(merchant, limit=site_ontology.DEFAULT_LIMIT):
|
||||||
|
raise site_ontology.SiteOntologyError("ConnectError: connection refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr(site_ontology, "match_for_merchant", down)
|
||||||
|
r = await _build(client, h, pid)
|
||||||
|
|
||||||
|
assert r["published"] is True
|
||||||
|
assert "seo" not in r
|
||||||
|
site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()
|
||||||
|
assert site["site"]["status"] == SiteStatus.PUBLISHED.value
|
||||||
|
payload = json.loads(open(r["payload_path"], encoding="utf-8").read())
|
||||||
|
assert "seo" not in payload
|
||||||
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* facts.source_type / media.source_type / place_aliases.source_type 공용 코드값.
|
* facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값.
|
||||||
값이 어디서 왔는지 — 모든 사실은 출처를 갖는다.
|
값이 어디서 왔는지 — 모든 사실은 출처를 갖는다.
|
||||||
*/
|
*/
|
||||||
export type SourceType = typeof SourceType[keyof typeof SourceType];
|
export type SourceType = typeof SourceType[keyof typeof SourceType];
|
||||||
@ -18,4 +18,5 @@ export const SourceType = {
|
|||||||
API: 2,
|
API: 2,
|
||||||
CRAWL: 3,
|
CRAWL: 3,
|
||||||
LLM: 4,
|
LLM: 4,
|
||||||
|
TEMPLATE: 5,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user