Compare commits

...

234 Commits
Docs ... main

Author SHA1 Message Date
79962b93e2 Merge branch 'feature/site-features-and-mockup' into main 2026-09-23 13:19:19 +09:00
5e2def200b [chore] site/mockup: 번들 갱신·문서 정리 · 2안(사진) 빌더 스크립트 추가
- vendor/ 옛 해시 번들을 retired/ 로 옮기고 새 해시로 교체
- build_photo6.py 신설 — /s/stay6 "2안(사진)" 판, 슬러그별로 다시 구울 수 있다
  (python3 build_photo6.py <slug>)
- build_pension.py·build_reading.py·build_stay6.py·patch_stay.py 정리
- AUTOPLAY.md·README.md·audit-all.mjs 갱신
2026-09-23 13:18:51 +09:00
a74f7918c6 [feat] solution/site: "고택" 템플릿 레이아웃 신설 — stay2 DOM 이식 1차
기존 template-look 시스템(색·서체 토큰)만으로는 stay2 실물과 구조 자체가 달랐다.
reservation/oasi/studio/pastel/editorial 다섯 레이아웃과 같은 자리(Shell·Hero·Rooms·
SectionHead)에 paper 를 여섯 번째로 추가 — layoutOf(templateId) 로 갈아 끼운다.

- layouts/paper/{Shell,Hero,Rooms,SectionHead}.tsx 신설
  Shell: 세리프 워드마크+미니플레이어(SongPlayer)+전화, 앵커 탭 헤더, 얇은 텍스트 푸터
  Hero: 풀블리드 회전 캐러셀(HeroPension 뼈대) + 왼쪽 정렬 카피, 가격띠 없음
  Rooms: 유닛마다 전체폭 판 + 캐러셀(카드 격자 아님)
- lib/layout.ts: LayoutId 에 'paper', stay/restaurant/cafe-paper → paper 매핑
- App.tsx·HeroSection.tsx·UnitsSection.tsx·lib/ui/Section.tsx: 기존 5분기에 paper 추가

★ 실물 대조 결과 아직 stay2 와 다르다(사장님 지적) — 탭 개수(4개 vs 지금 섹션 수만큼),
  히어로가 여백 있는 박스가 아니라 풀블리드, 객실 카드가 2열 나란히가 아니라 전체폭
  세로 배치, 영문 눈썹 라벨 없음. 다음 커밋에서 이어서 맞춘다.

★ 배포 함정 실측: prerender.ts 의 "성공한 버전은 불변" 규칙 때문에, 같은 site_version 은
  렌더러 코드를 바꿔도 재빌드 때 옛 HTML 을 그대로 재사용한다 — 버전 캐시를 지워야
  강제로 다시 구워진다(운영 영향은 별도 확인 필요).
2026-09-23 13:18:21 +09:00
b7b8cb856c [fix] solution/site: 흐린 보조 글자 대비 올림 · 예약 달력 기본으로 펼침
여러 섹션의 opacity-55~70 짜리 보조 텍스트가 밝은 배경에서 너무 흐렸다 — 85~100 으로
올림(Carousel 카운터·SectionHead 리드문·SNS 소식·주변 안내 거리·공식 채널 링크).
StayBookingDemo: "날짜·시간 선택" 토글을 없애고 달력을 기본으로 펼쳐 보여준다 —
접어 두면 손님이 그 버튼을 눌러야만 날짜를 볼 수 있었다.
2026-09-23 13:18:02 +09:00
362c76d6c9 [fix] solution/frontend: 뒤로가기 후 재검색이 옛 사업장을 되살림 — placeId URL 우선순위 정정
주소창의 placeId 가 store 보다 우선이라(BuilderPage.tsx), 뒤로가기로 검색 단계에
돌아와도 그 placeId 가 그대로 남아 있으면 usePlaceSync 가 옛 사업장을 다시 확정
정보로 채워 넣었다 — 다른 상호로 재검색해도 이전 확정 화면이 그대로 떴다.

- BuilderPage.tsx: step === 'search' 일 땐 주소창 placeId 를 안 쓴다
- Step2PlaceSearch.tsx: 뒤로가기 가드가 store 뿐 아니라 주소창 placeId·flow 도 지운다

[feat] solution/frontend: "고택" 템플릿 — 숙박·요식업·카페 공용, stay2 목업 색·서체 이식

industryData.ts 에 LOOK.paper(크림 종이·가는 명조·그림자 없음, stay2 실물에서 추출)와
paperTemplate() 을 추가해 stay·restaurant·cafe 세 업종의 네 번째 템플릿으로 등록.
DOM 구조 이식은 solution/site 커밋에서 별도로 한다.
2026-09-23 13:17:39 +09:00
951e451ef8 [fix] solution/backend: 네이버 플레이스 수집이 음식점·카페 요금표를 통째로 반려 — 업종별 unit key 분기
_to_unit_facts() 가 업종을 안 가리고 room_type·weekday_price/weekend_price 로만 냈다.
그 key 는 숙박 스키마에만 있어서, 음식점·카페·클리닉은 메뉴/프로그램 요금표가
FACT_INVALID_KEY 로 전량 거부됐다(실측: "도플로" fact 19건 중 19건 반려).

- naver_place_adapter.py: _UNIT_NAME_KEY·_UNIT_PRICE_KEY 로 업종별 key 매핑
  (숙박 room_type/weekday·weekend_price, 카페·음식점 menu_name/menu_price,
  클리닉 program_name/price_adult)
- SourceAdapter.fetch() 계약에 category 파라미터 추가, 어댑터 5개 시그니처 반영
- collect_service.py: fetch_one() 에 place.category 전달

검증: 재수집 후 stored 0 → 35(음식점), test_collector·test_category_schema·
test_fact_api·test_tour_api_adapter 173 passed
2026-09-23 13:17:28 +09:00
c366361513 Merge remote-tracking branch 'origin/main' into feature/site-features-and-mockup 2026-09-23 10:17:18 +09:00
0f58e1485a [fix] site/mockup: stay 날씨 문구 교체 — 없는 시설·어색한 표현 제거
대표 지적: "벽난로와 따뜻한 물이 있는 날씨입니다" 처럼 말이 성립하지 않는 문장.
확인해 보니 벽난로는 사진 설명이 전부 "벽난로 조명" 이라 장식이고, 테라스는
발행본 어디에도 근거가 없었다. 쓰지 않는 표현("실내로 도는", "묶어 도는",
"국물이 도는")과 펜션과 무관한 일반 안전 안내도 섞여 있었다.

- local.weather.noteSets 45줄 · tempNoteSets 25줄 전면 교체
- notes · tempNotes(한 줄짜리 기본값)도 같은 묶음 첫 줄로 맞춤
- 근거 없는 시설(벽난로 · 테라스 · 대문 안쪽 우산) 삭제
- 시각을 못 박던 줄("흐릿하게 보이는 아침입니다") 정리 — 문구는 하루 중 아무 때나 뜬다
- .gitignore: backup/ · king-stay2/ · build6p*/ · siann6/ · _sub*.mjs
  (발행본 원본은 도커 볼륨이라 레포에 사본을 두면 원본이 둘이 된다)

검증: 70줄 중복 0 · 쓴 낱말 31개 전부 발행본에 존재 · 크롬 렌더 오류 0
킹서버 반영 완료(/s/stay e1c0d8ff, 이전본 index.html.bak-20260923 보관)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 10:17:10 +09:00
36dbe26c91 [feat] solution/backend: 미니 블로그 글을 배정 날짜에 맞는 소재로 만든다
예전에는 소재 목록을 순서대로 뽑아 날짜에 차례로 붙여서, 9월 날짜에
'한겨울' 글이나 끝난 축제 글이 붙을 수 있었다. 이제 날짜를 먼저 정하고
그 날짜에 맞는 소재를 고른다.

- 축제: 시작 14일 전 ~ 종료일 사이만 / 계절: 게시일 절기 하나
  / 날씨: 그 달에 있을 법한 것만(눈 12~2월, 소나기 6~8월) / 주변: 늘 후보
- 프롬프트에 게시일을 넣고, 날씨를 단정하지 않게 문구를 고쳤다
- 계절·날씨·축제 주제 키에 연(월)을 넣어 해마다 다시 쓸 수 있게 했다
- 자동·구간·개별 세 경로가 _compose_for_dates 하나로 만든다

같이 고친 버그: materials 가 스냅샷에 없는 local.festivals·attractions 를
읽어 축제·주변 소재가 늘 비어 있었다. 원문 행(local.contents)을 읽는다.
2026-09-23 09:41:23 +09:00
be3e43162a [chore] solution/backend: 미니 블로그 새벽 자동 생성 잡을 잠시 끈다
사장님이 빌더에서 '생성'을 눌러야 만들어지는 흐름으로 간다. 등록 두 줄만
주석으로 막고 잡 함수는 그대로 둔다. 09:00 메일 발송 잡은 사장님이 만든
글도 보내야 해서 켜 둔다.
2026-09-23 09:41:23 +09:00
324a329b9e [chore] solution/backend: 카톡 요청에 callbackUrl·블록 이름 로그 — 콜백이 왜 안 먹는지 눈에 보이게
"콜백을 켰는데 그대로" 를 로그 없이 추측으로 좁히고 있었다. 스킬이 폴백이 아닌
다른 블록에 붙어 있으면 그 블록에는 콜백 설정이 없어 조용히 동기로 돈다 —
어느 블록이 도는지까지 같이 남긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 17:01:56 +09:00
bbcdf78c62 [fix] solution/backend: 카톡 응답이 5초 벽에 계속 걸리던 것 — 프롬프트 축소
실사용에서 모든 발화가 4.5초 상한에 걸려 "확인하는 데 시간이 조금 걸리네요" 만
반복됐다(킹서버 로그: 4.60s · 4.51s · 4.51s).

- 이미 연결된 사람이 코드를 또 보내면 LLM 을 부르지 않는다. 실제로 그랬고,
  6자리가 그냥 발화로 넘어가 유료 호출 + 대기만 쌓였다
- 사이트 상태를 프롬프트에서 뺀다. 그 한 줄 때문에 매 턴 사이트 조회 + 슬러그
  계산이 돌았고, 정작 필요할 때는 get_site_status 도구를 부르면 된다
- fact 는 key:value 만, 상한 30개. label 은 항목 목록에 이미 있어 두 번 보내면
  프롬프트만 커지고 모델이 얻는 것이 없다
- 항목 목록도 JSON 대신 `key: 이름` 줄로

★ 근본 해결은 콜백이다(f500210). 오픈빌더에서 '콜백 사용' 이 꺼져 있으면
callbackUrl 이 안 와서 조용히 동기 경로로만 돈다 — 지금 로그가 그 상태다.

test_kakao_webhook·test_agent_runtime 43 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:52:24 +09:00
f500210cd8 [fix] solution/backend: 카톡 5초 벽을 콜백으로 넘는다
실사용 첫날 "시설 편의에서 바비큐 이용 문구 빼줘" 가 타임아웃으로 끝났다.

★ 작은 표본으로 잰 수치를 상한 근거로 삼은 것이 틀렸다. 개발 중 잰 1.3~2.4초는
업종 필드 두 개짜리 장난감 프롬프트였고, 진짜 요청에는 필드 43개 + fact 수십 개가
실린다. "여유가 있다" 고 적어 둔 판단이 하루 만에 깨졌다.

- userRequest.callbackUrl 이 오면 {"useCallback": true} 로 즉답하고 백그라운드에서
  답을 만든 뒤 그 주소로 POST. 콜백 주소는 1분·1회라 재시도하지 않는다 —
  두 번째 POST 는 거절되고 사장님에게는 이미 "확인하고 있어요" 가 가 있다
- 콜백이 꺼져 있으면 예전처럼 동기, 상한만 4.0 → 4.5 (카카오가 5초에 끊는다)

★ 오픈빌더 스킬 설정에서 '콜백 사용' 을 켜야 열린다. 안 켜면 callbackUrl 이 안 와서
조용히 예전 경로로만 돈다.

test_kakao_webhook.py 24 passed(콜백 3건 추가)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:33:16 +09:00
1300652b09 [feat] solution/backend: 카톡 대화에 홈페이지 목록·가게 고르기
실제로 붙여 보니 빠진 것이 드러났다 — 연결은 됐는데 **어느 홈페이지를 다루는
대화인지** 말해 주지 않았다. 가게가 하나면 말없이 자동 선택돼 더 모호했다.

- 연결 직후 목록을 보여준다. 하나면 이름+발행 여부를, 여럿이면 바로가기 버튼으로
- 목록 줄에 발행 여부를 적는다 — 안 그러면 고친 것이 손님에게 보이는 줄 안다
- "목록"·"가게 바꿔줘" 로 언제든 돌아와 바꾼다. ★ 이 경로는 LLM 을 부르지 않는다:
  대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록에 돈을 쓸 이유가 없다
- 사업장 목록이 아니라 list_my_sites 를 쓴다 — 사장님이 알아야 하는 건
  "가게가 있다" 가 아니라 "발행돼 있나" 다(/sites 화면이 같은 이유로 그걸 쓴다)

test_kakao_webhook.py 21 passed(목록·전환 4건 추가).
전체 845 passed / 53 failed — 53 은 이번 변경 전과 동일

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:26:40 +09:00
d32df10cf7 [feat] solution/backend: 카카오 채널 웹훅 — 에이전트 4단계
런타임은 한 줄도 안 바뀌었다. 채널을 모르게 만들어 둔 것이 여기서 값을 했다 —
새로 생긴 것은 형식 변환(kakao_bot)과 대화 상태(channel)뿐이다.

★★ 오픈빌더는 서명을 주지 않는다. URL 만 알면 누구나 때릴 수 있고
userRequest.user.id 를 위조하면 그 사장님 행세를 한다 — 1단계의 신원 연결이
통째로 무의미해지는 자리다. 공유 시크릿(헤더 X-Agent-Secret, compare_digest)
+ 선택적 KAKAO_BOT_ID 대조로 막고, 시크릿이 없으면 엔드포인트가 404 다
(401 은 "여기 뭔가 있다" 를 알려 준다).

- router/v1/agent/kakao_bot: 카카오 형식을 아는 유일한 파일. 헤더·경로 두 경로
- services/agent/channel: 신원(★ 토큰을 발급하지 않는다) · 가게 고르기 · 확인
- 0022: owner_kakao_links 에 current_place_id · pending_*

빌더 화면과 다른 것 셋:
- 로그인 토큰이 없다 → 발화자 키로 사장님을 찾는다
- place_id 가 URL 에 없다 → 여럿이면 추측하지 않고 되묻는다. 임의로 첫 가게를
  고르면 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다
- 확인을 되돌려 줄 프론트가 없다 → 서버가 pending 을 든다. ★ 3분 만료가 없으면
  한참 뒤의 "네" 한 마디에 묵은 발행이 돈다

5초 벽은 DEADLINE_SEC=4.0 으로 끊고, 어떤 실패도 200+안내다 —
메신저에서는 500 도 침묵으로 보인다.

밟은 것: execute_lambda 는 람다 반환값을 그대로 준다(CRUD 관례가 (ErrorType,값)).
우리 람다가 객체만 돌려주자 언패킹 TypeError 가 났고, 라우터가 예외를 삼켜
화면에는 안내 한 줄만 보였다 — 원인이 안 보이는 종류다.

test_kakao_webhook.py 17 passed. 전체 841 passed / 53 failed(이전과 동일).
npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:13:33 +09:00
95350cfdbf Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-22 14:17:53 +09:00
5b54daac10 [chore] solution/backend: 에이전트 대화창 다시 염 — AGENT_CHAT_ENABLED 기본 1
카카오톡 채널의 통신사 인증이 끝나 어제 걸어 둔 보류를 푼다. 코드는 어제도 오늘도
그대로고 값만 바꿨다 — 닫고 여는 일이 커밋을 되짚는 일이 되면 안 된다는 어제
판단이 하루 만에 값을 쳤다.

- config/agent_config: 기본값 0 → 1
- ★ 켜도 LLM 키가 없으면 안 열린다(runtime.is_configured 가 스위치와 키를 둘 다
  본다). 키 없는 환경에서 켜 둔 채 잊어도 "눌러도 안 되는 입구" 가 안 생긴다
- 스위치 테스트를 새 기본값에 맞춰 갱신 — 키가 없을 때도 안 열리는 것을 함께 검사

카카오 연결 카드는 아직 감춰져 있다(KAKAO_CHANNEL_PUBLIC_ID 미설정). 채우면 코드는
발급되지만 소비할 웹훅(4단계)이 없어 연결이 완성되지 않는다.

test_agent_runtime·test_kakao_link 34 passed. npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:17:48 +09:00
ed0137c9da Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-22 14:11:13 +09:00
27426dd51c [feat] solution/backend: 이메일 승인 확인 화면에서 발행 사이트로 5초 뒤 자동 이동
사장님 지시: "이메일 승인후에 블로그가 올라가는 페이지에 5초 정도 이후에 연결".
승인만 하고 실제로 어디 올라갔는지 못 찾는 걸 줄인다. 재발행(BUILD 잡)은 몇 분
걸리므로 5초 뒤 반영을 보장하진 않지만, 어디로 가면 되는지는 바로 알려준다.
자동 이동을 못 믿어도 되게 같은 주소를 안내 문구의 링크로도 남긴다.

- post_service._blog_url: 발행된 사이트가 있으면 그 미니블로그 자리(#blog) 주소,
  없으면 None — 호출부가 자동 이동 없이 확인 문구만 보여준다
- router/v1/site/post.py: <meta http-equiv="refresh"> + 안내 링크 추가.
  redirect_url은 서버가 site_payload.publish_url()로 만드는 고정 오리진+slugify
  통과 값이라 사용자 입력은 아니지만, HTML 속성에 꽂는 자리라 html.escape 적용
  (자동 보안 리뷰 지적 반영)
- 신규 테스트 2건(발행 사이트 있을 때/없을 때), 관련 스위트 전체 49 passed

로컬 solution-backend·solution-worker 재빌드해 반영 확인함
2026-09-22 14:10:52 +09:00
acd5383b0f Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-22 11:44:00 +09:00
df1556f2b1 [fix] solution/backend: 쓰레드 게시 잡이 job_type 번호가 어긋나 워커에 조용히 씹혔다
큐 삽입 쪽(social_crud.decide, social_service.create_draft/publish_reused_text)이
JobType enum(SOCIAL_DRAFT=9, SOCIAL_POST=10)을 안 쓰고 숫자를 하드코딩(8, 9)해서,
워커 디스패처(worker/handlers.py)가 그 숫자로 엉뚱한 핸들러를 불렀다 — "게시" 잡(9)은
run_draft로, "초안" 잡(8)은 run_rollback으로. 둘 다 대상 상태 조건이 안 맞아 에러 없이
{"skipped": true}로 끝나 DONE 처리됐다 — 승인해도 실제로는 한 번도 게시되지 않는데
로그만 보면 정상으로 보이는 조용한 실패였다. SOCIAL_POSTING_ENABLED가 계속 꺼져 있어
지금까지 드러나지 않았다(2026-09-14부터 있던 버그).

- 세 호출부를 JobType.SOCIAL_DRAFT.value/SOCIAL_POST.value로 교체
- 기존 테스트의 job_type 기대값(8→9, 9→10)도 실제 enum에 맞게 수정
- 신규: 디스패처 매핑 정적 대조 + 실제 큐 삽입값으로 하는 엔드투엔드 회귀 테스트
  (되돌려서 새 테스트가 실패하는 것까지 확인함)

전체 회귀 70 passed
2026-09-22 11:36:21 +09:00
627fb1e141 Merge remote-tracking branch 'origin/main' into feature/owner-kakao-link 2026-09-22 11:15:09 +09:00
a553e41197 [chore] solution/backend,frontend: 에이전트 화면 보류 — 설정으로 닫고 코드는 남긴다
카카오톡 채널 개설이 법인폰 본인인증에 걸려 보류됐다. 채널이 없으면 대화창은
사장님에게 **어디에도 닿지 않는 입구**이고, 열려 있으면 "되는 기능" 으로 오해한다.

- config/agent_config: AGENT_CHAT_ENABLED 신설(기본 0)
- runtime.is_configured(): 스위치와 LLM 키를 둘 다 본다 — 화면을 우회해 API 를
  직접 불러도 AGENT_NOT_CONFIGURED 다
- AgentChatDock · KakaoChannelCard: 조건 미충족이면 통째로 감춘다(return null).
  연결 카드는 connection_enabled 가 기준이라 설정만 채우면 그대로 다시 나타난다
- ★ 코드를 지우지 않았다 — 되돌릴 때 커밋을 되짚지 않고 값 둘만 채우면 된다

★ Threads 카드와 판단이 갈린 것이 맞다. 저쪽은 사장님이 곧 쓸 수 있는 기능이라
자리를 두고 버튼만 죽였고, 이쪽은 언제 열릴지 말해 줄 수 없다.

test_agent_runtime(스위치 2건 추가)·test_kakao_link 34 passed. npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:15:03 +09:00
09d07ec0dd [fix] solution: Threads OAuth 콜백 함수명이 React useCallback과 충돌 — 빌드 실패
solution-site 빌드가 TS2440(Import declaration conflicts with local
declaration of 'useCallback')로 실패했다. FastAPI 라우트 핸들러 함수명이
`callback`이라 orval이 만든 React Query 훅 이름이 `useCallback`이 됐고,
같은 파일에서 import한 React의 useCallback과 이름이 겹쳤다.

- router/v1/social/oauth.py: 함수명 callback → oauth_callback
- 백엔드 재빌드 후 npm run orval 로 재생성 → useOauthCallback,
  callbackParams.ts → oauthCallbackParams.ts

docker compose build solution-site 로 재검증, 정상 빌드 확인
2026-09-22 09:58:27 +09:00
23c9dd6fcd [chore] solution: "지금 발송하기" 버튼 이름을 "승인 알림보내기"로
사장님 지시. 같은 문구를 인용하던 API summary·주석도 같이 맞췄다
(생성 파일 site.ts 는 손대지 않음 — 백엔드 재빌드 후 orval 로 재생성).
2026-09-22 09:51:42 +09:00
81a61b50f5 Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-22 08:53:53 +09:00
81ea676546 [feat] solution: 미니블로그 빌더 기능 확장 — 알림 이메일 분리·삭제·즉시발송·공급자 무관 생성
빌더 앱에서 미니블로그를 운영하는 데 필요했던 몇 가지를 묶었다.

- notify_email: 업장별 승인 메일 수신자를 계정 로그인 이메일과 분리(places.notify_email,
  migrations/0021, PlaceCRUD·protocol.py·place_service.py 검증, BlogPostsPage.tsx 설정 UI)
- 글 삭제(soft delete): 상태 제한 없이 지우고, 이미 게재된 글이면 재발행 잡까지 큐에 넣는다
  (post_crud.py, router/v1/site/post.py DELETE, BlogPostsPage.tsx 삭제 버튼)
- 지금 발송하기: 아침 9시 스윕을 안 기다리고 바로 발송(post_crud.next_due_for_mail,
  BlogPostsPage.tsx 버튼)
- blog_service.generate_one: 하드코딩된 Gemini 대신 services/llm/provider.py(LLM_PROVIDER,
  기본 openai)를 타도록 전환, 업종별 분기 구조(현재 숙소만 구현) 추가
- site_payload.publish_url(place, site): 발행 주소 조합을 한 곳에 모은 헬퍼
- 프론트: orval 로 재생성한 API 클라이언트(notify_email·삭제·즉시발송·social 엔드포인트 반영)

관련 스위트는 별도 커밋(쓰레드 연동 작업)에서 이미 PASS 확인함
2026-09-22 08:37:38 +09:00
adb3460c37 [fix] solution/backend: site_payload 날씨 라벨도 코드별 고유값으로 — 앞 커밋 누락분
직전 커밋(cd77171)에서 문서·프론트만 옮기고 서버 쪽 표(site_payload._weather_condition)를
빠뜨렸다. use-live-weather.ts 와 같은 표를 봐야 하이드레이션 전후 문구가 안 바뀐다는
불변식이 절반만 적용된 상태였다 — 여기서 마저 맞춘다.
2026-09-22 08:36:52 +09:00
cd771719df [fix] site,solution/backend: 날씨 상태 라벨을 코드별 고유값으로 — 구간 뭉치기 제거
WMO weather_code 51~57(이슬비 3단계), 61~67(비/어는비 혼재) 등을 "이슬비"·"비"
같은 큰 구간으로 뭉쳐 표시하던 것을 코드 하나당 고유 라벨로 바꿨다. 서버
프리렌더 스냅샷(site_payload._WEATHER_CONDITION_BY_CODE)과 브라우저 재조회
(use-live-weather.ts WEATHER_CONDITION_BY_CODE)가 같은 표를 봐야 하이드레이션
전후로 문구가 안 바뀐다는 기존 불변식은 유지한다.

- docs/WEATHER.md: 27개 코드 전체를 "구간→분류" 표에서 "코드→고유 라벨" 표로 재작성
- weather_notes.json: 코드별 문구 갱신
- use-live-weather.ts/.test.ts, weather.test.tsx: 새 라벨 반영
2026-09-22 08:36:09 +09:00
47da2f29b3 [feat] solution/backend,docs: 미니블로그 승인 → 쓰레드 자동 게재, 쓰레드 초안 생성 OpenAI 전환
사장님 지시: "쓰레드에 연동되어 있으면 같이 업로드 되는 기능". 미니블로그의 두
승인 경로(이메일 GET 토큰, 로그인 "바로 발행")를 공통 메서드로 묶고, 그 끝에서
쓰레드 연동을 시도한다. 미니블로그 승인 자체가 발화 동의로 취급되므로 쓰레드
쪽 별도 승인은 묻지 않는다(DECISIONS 7-1-2 개정, 문구를 그대로 재사용하는
경우에 한정). 겸사겸사 쓰레드 초안 생성(generate_social_post)이 LLM_PROVIDER
를 안 타고 Gemini 를 직접 호출하던 것도 다른 생성 함수와 같은 추상화로 맞췄다.

- blog_jobs._published_places: sites.domain IS NOT NULL 조건 추가(쓰레드 기준과 통일)
- social_service.publish_reused_text: 연동 없음/게시 비활성/domain 미확정이면 스킵,
  정상이면 APPROVED 삽입 + run_post(job_type=9) enqueue — 새 게시 로직은 안 만든다
- post_service: decide/approve_by_owner → _approve_and_publish 로 공통화,
  _try_social_share 는 실패를 전부 삼켜 미니블로그 승인을 막지 않는다
- gemini_text.generate_social_post: services.llm.provider.active() 로 전환,
  Gemini 전용 import 제거
- DECISIONS.md 7-1-2, MINI_BLOG.md 5·8절, SOCIAL.md 갱신

test_blog_owner.py·test_social.py 다수 추가/수정, 관련 스위트 전체 PASS
2026-09-22 08:33:40 +09:00
59df616a7b [fix] solution/backend: 응답 스키마 대문자 타입 — openai 에서만 400 으로 터지던 것
OpenAI strict 모드는 'STRING' 을 거부한다:
  Invalid schema for response_format: 'STRING' is not valid under any of
  the given schemas
Gemini 는 대소문자를 둘 다 받아서, 대문자로 써 두면 **공급자를 openai 로 바꾸는
순간에만** 터진다. LLM_PROVIDER 기본값이 openai 인데 두 파일만 대문자로 남아
있었다 — 대화창은 첫 발화부터 502 였고 SNS 초안도 같은 이유로 못 돌았다.

- prompts/agent: 소문자로. args 는 strict 가 전 프로퍼티를 required 로 만드므로
  안 쓰는 인자가 빈 문자열로 온다 — 도구는 "" 를 '없음' 으로 읽는다
- prompts/social: 같은 수정. 나머지 프롬프트는 원래 소문자였다

실측 확인: "체크인 시간 3시로 바꿔줘" → set_fact(check_in_time, 15:00)
test_agent_runtime·test_social 34 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:29:29 +09:00
4dd0c604ee [fix] solution/backend: 키 미설정 문구가 엉뚱한 공급자를 가리키던 것
공급자를 openai 로 바꾼 뒤에도 호출측에 "GEMINI_API_KEY 미설정" 이 문자열로
박혀 있어서, **없는 것은 OPENAI_API_KEY 인데 화면은 Gemini 를 탓했다**
(실측 2026-09-21: 로컬에서 소개문이 안 나와 Gemini 키를 한참 들여다봤다).
원인을 정확히 반대로 가리키는 종류다.

- llm/provider: missing_key() 추가 — 활성 공급자에게 필요한 env 이름을 돌려준다
- copy_service·collect_service·song_service: 하드코딩 문구를 그 함수로 교체
- enums: GENERATOR_NOT_CONFIGURED 주석도 공급자 중립으로

관련 테스트 55 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:19:29 +09:00
b1a34ba58d [feat] solution/backend,frontend: 에이전트 도구·런타임·빌더 채팅창 — 2단계
런타임이 채널을 모르므로 채널·챗봇 심사 없이 에이전트 전체를 빌더 화면에서
검증할 수 있다. 웹훅 핸들러 안에 짜면 빌더에서 같은 걸 못 쓰고, 심사가 끝나야
무엇 하나 확인되지 않는다 — 카톡은 나중에 붙는 두 번째 입구다.

- services/agent/tools.py: 도구 넷 + 등급 셋(READ·REVERSIBLE·SEMI).
  ★ 도구는 반드시 services/* 를 통과한다 — crud 를 직접 부르면 스키마 검증·
  출처 필수·정정본 보호가 아무 증상 없이 사라진다. 테스트가 소스로 검사한다
- services/agent/runtime.py: 발화 → 도구 선택(LLM 1콜) → 실행 → 응답
- services/prompts/agent.py: LLM 네 겹 규약대로 프롬프트만 여기
- router/v1/agent/chat.py + features/agent/AgentChatDock.tsx(/sites 우하단)

모델에게 맡기지 않은 셋:
- 등급 — 응답 스키마에 칸 자체가 없다. 모델이 정하면 프롬프트에 끼어든 한 줄이
  확인 절차를 건너뛴다
- 결과 문구 — 도구가 만든다. 모델이 쓰면 하지 않은 일을 했다고 말할 수 있고
  사장님에게는 그 말이 사실로 보인다
- key — set_fact 의 key 는 업종 스키마가 최종 판정이다

확인(SEMI)은 실행하지 않고 되묻는다. 돌아온 confirm 값을 믿지 않고 도구는
레지스트리에서 다시 찾고 인자는 도구가 다시 검증한다 — 확인 절차가 검증을
건너뛰는 구멍이 되면 안 된다.

값을 고치면 재발행 안내를 함께 낸다 — fact 는 바뀌어도 사이트는 안 바뀐다.

test_agent_runtime.py 17 passed(LLM 은 monkeypatch, 실제 모델 호출 없음).
전체 796 passed / 50 failed — 그 50건은 HEAD 에서도 동일한 기존 이슈.
npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 16:18:00 +09:00
16b17bc91c [feat] solution/backend,frontend: 카카오톡 채널 신원 연결 — 에이전트 1단계
카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**라 우리 user_id 와 관계가 없다.
다른 엔드포인트는 전부 place_crud.get_place(s, owner_user_id, place_id) 로 소유자 범위를
지키는데 채널 발화에는 그 owner_user_id 를 줄 근거가 없다 — 매핑이 없으면 채널
진입점만 소유자 범위 밖에 놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.

- postgres-init: owner_kakao_links(0021 + init.sql). 부분 유니크 셋 중
  uq_kakao_link_channel_key(한 카카오 계정 = 한 사장님)가 없으면 "어느 가게
  이야기냐" 가 대화가 아니라 DB 에서 갈라진다
- services/kakao_link_service: 일회성은 코드 값이 아니라 WHERE status='PENDING'
  CAS 한 문장이 보장한다. 실패는 전부 같은 에러 — 없는 코드·만료·시도초과를
  구분해 답하면 6자리의 유효성을 밖에서 탐색할 수 있다
- 코드는 sha256 만 저장. 손으로 치는 짧은 값이라 평문이면 DB 를 읽는 쪽이 곧
  연결 권한이다. 글자에서 0·O·1·I·L 제외 — 잘못 읽으면 원인이 화면에 안 보인다
- router/v1/agent/kakao: 셋 다 no-store·no-referrer·noindex.
  ★ 소비(redeem) 엔드포인트는 일부러 없다 — 웹훅 서명 검증 전에 공개 소비 경로를
  열면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다
- config/agent_config: social_config 와 일부러 가름. SNS 게재는 되돌릴 수 없는
  대외 발화, 에이전트는 자기 사이트를 고치는 창구 — 승인 강도가 다르다
- frontend/features/agent: /sites 의 Threads 카드 옆. 연결은 사람 단위라 같은 자리다
- docs/AGENT.md 신설, CLAUDE.md 색인·함정, DEVLOG

test_kakao_link.py 15 passed. 전체 780 passed / 50 failed —
그 50건은 HEAD 에서도 동일(워크트리 대조), 기존 이슈로 이번 변경과 무관.
npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 16:01:58 +09:00
94ae4a825a Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-21 15:42:16 +09:00
4513fae23e Merge branch 'feature/site-features-and-mockup' 2026-09-21 14:20:44 +09:00
c26bfb6951 [fix] solution/frontend: 랜딩 입력창 감춤 시 헤드라인·쇼케이스 간격 붕괴 수정
폼을 감췄더니 그 폼을 담던 relative 박스가 높이 0으로 접혀, top-1/2 로 그 박스
가운데에 앉던 쇼케이스 띠가 헤드라인 바로 아래로 붙어버렸다(사장님 스크린샷 실측).
폼이 없을 때만 min-h 로 그 자리를 대신 잡아준다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 14:20:40 +09:00
00e318d639 Merge branch 'feature/site-features-and-mockup' 2026-09-21 14:16:13 +09:00
5952f0db62 [fix] solution/frontend: 랜딩 부제("가게 이름 한 줄로...")도 ?build=1 뒤로 감춘다
입력 폼만 감췄더니 그 위 부제가 입력창 없는 화면에 혼자 남아 어색했다.
같은 buildMode 로 감싼다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 14:16:10 +09:00
d061a0e6f8 Merge branch 'feature/site-features-and-mockup' 2026-09-21 14:04:19 +09:00
2252588bf2 [fix] solution/frontend: 랜딩(/) 입력창·로그인 진입점을 ?build=1 뒤로 감춘다
/ 를 그냥 열면 히어로 입력 폼과 헤더 로그인 링크가 항상 떠 있었다. 사장님 지시로
?build=1 쿼리가 있을 때만 두 진입점을 보여주고, 평소 / 는 보여주기 화면으로만 쓴다.

- LandingPage.tsx: useSearchParams 로 buildMode 계산, 히어로 <form> 을 그 값으로 감싼다
- MarketingShell.tsx: showAuthCta prop 추가(기본 true) — 랜딩만 buildMode 를 그대로 넘긴다

SSR 출력으로 확인: / 는 "가게 이름을 입력하세요"·"로그인" 둘 다 없음,
/?build=1 은 둘 다 있음. tsc 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 14:04:13 +09:00
24dc9345de Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-21 14:03:27 +09:00
8d6e6d7cd0 Merge branch 'feature/site-features-and-mockup' 2026-09-21 13:57:43 +09:00
b386982f34 [fix] site: 모달·갤러리·소식 스크롤 잠금을 훅 하나로 통합 — 복사된 스크롤 버그 재발 방지
모달 스크롤 버그를 Modal.tsx 한 곳만 고쳤더니 같은 잠금·복귀 코드가 그대로 복사돼
있던 GallerySection·EventSection 에는 버그가 그대로 남아 있었다(2026-09-21 실측).
복사한 코드는 복사한 곳마다 따로 고쳐야 하므로 훅 하나로 뺀다.

- lib/ui/use-scroll-lock.ts: 스크롤 잠금·복귀 로직 신규 — scroll-behavior:auto 강제 포함
- lib/ui/Modal.tsx, sections/GallerySection.tsx, sections/items/EventSection.tsx:
  중복 잠금 코드 제거, useScrollLock() 호출로 교체
- lib/ui/index.ts: useScrollLock export 추가

tsc 통과, vitest 100 passed(무관한 날씨 조건 테스트 2건은 이 변경 전부터 실패 중)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 13:57:33 +09:00
d358ff4b93 Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-21 13:02:44 +09:00
05dd9a3ce7 [docs] SERVERS: 킹서버 접속이 경유 없이 14445 직행으로 바뀌었다
인프라가 킹서버 전용 문(59.14.81.3:14445 → 22)을 열어 줘서 `ProxyJump` 가 없어졌다.
문서는 아직 옛 경로(14444 경유)를 가리키고 있어, 새로 합류하는 사람이 그대로 따라 하면
안 붙는다.

★ 14444 와 14445 는 **서로 다른 서버로 가는 문**이다. 14444 는 `.21` 로 가고, 예전에는
  거기서 킹서버로 한 번 더 건너뛰었다. 그래서 `Confluence`(14444) 항목을 14445 로 고치면
  `.21` 쪽이 끊긴다 — 가장 밟기 쉬운 자리라 문서에 못 박았다.

- 접속 표와 `~/.ssh/config` 예시를 직행 경로로. 옛 경로는 `King_admin_jump` 로 남겼다
  (14445 가 막혔을 때의 길이 없으면 곤란하다)
- 비밀번호 로그인은 안 된다(키 등록분만)는 사실을 적었다 — 이번에 그것 때문에 한 바퀴 돌았다
- 첫 접속의 호스트 키 프롬프트가 "서버가 바뀐 게 아니라 대상 이름이 바뀐 것" 임을 지문과 함께
  남겼다. 실측 2026-09-21: SSH 가 known_hosts 의 172.30.1.36 과 같은 키라고 스스로 알려 준다

검증: 새 경로로 접속 확인(`hostname` = king · 26일 가동 · 컨테이너 정상)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:15:01 +09:00
2f674352b0 Merge branch 'feature/site-features-and-mockup' 2026-09-18 17:24:05 +09:00
4260e20a70 [fix] site: 모달 닫기 스크롤 버그 수정 — 이용안내 체크인·체크아웃 병합, UI 다듬기
모달 onClose 가 inline 함수라 부모 리렌더마다 useEffect 가 다시 걸려
scrollY 를 0으로 덮어써 X 버튼으로 닫으면 페이지가 맨 위로 튀었다.

- lib/ui/Modal.tsx: onClose 를 ref 로 들고 [open] 에만 의존하도록 수정
  (BlogSection·ReviewSection 등 Modal 쓰는 7곳 전부 적용)
- sections/EssentialInfoSection.tsx: 체크인·체크아웃 행을 한 줄로 병합
- sections/GallerySection.tsx, UnitsSection.tsx, MobileTabBar.tsx, SiteFooter.tsx,
  items/*: 표시 폭·간격·라벨 정리
- lib/ui/Carousel.tsx: loop 이음매 간격 재점검
- scripts/prerender.ts: countUniqueContent 가 socialPosts(자체 출력)를 세지
  않도록 — 콘텐츠 0건 사이트가 게이트를 우회하던 경로

tsc 통과, vitest 100/102 passed (use-live-weather 실패 2건은 기존·무관)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 17:23:20 +09:00
07c53d30bf Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-18 13:38:59 +09:00
09e8a7c881 [feat] 연동테스트 2026-09-18 13:38:44 +09:00
b38cff0c0f [fix] solution/backend: Threads 연결이 매번 실패하던 것 — OAuth 엔드포인트는 Bearer 를 안 읽는다
연결을 누르면 Meta 인가까지는 가는데 콜백에서 늘 실패했다. 사유를 로그에 붙이고 나서야 보였다:
`THREADS_REJECTED_400[4279019] Session key invalid` — 첫 단계(코드→단기 토큰)는 지나고
**장기 토큰 교환**에서 떨어지고 있었다.

★ OAuth 토큰 엔드포인트는 데이터 엔드포인트와 규칙이 다르다. `/me`·`/me/threads` 는 Bearer
  헤더로 되지만, 토큰을 발급·교환·갱신하는 자리는 **버전 접두어가 없고 토큰을 쿼리 파라미터로**
  받는다. 같은 토큰으로 두 형식을 나란히 불러 확인했다(2026-09-18):
    /v1.0/refresh_access_token + Bearer → "The parameter access_token is required."
    /refresh_access_token + access_token= → 새 토큰 정상 반환
  즉 헤더를 **읽지도 않는다.** 그래서 "세션 키가 잘못됐다" 는, 원인과 한참 떨어진 말이 돌아왔다.

- exchange: 장기 토큰 교환을 `OAUTH_BASE` + `access_token` 쿼리로
- refresh: 같은 수정. ★ 이건 연결 때는 안 드러나고 **60일 뒤 갱신에서** 터지는 종류다 —
  그때는 계정이 조용히 만료돼 게재만 멈춘다
- ★ debug_token 의 `app_id` 대조를 뺐다. Threads 응답에는 그 필드가 **없다**
  (실측: is_valid·scopes·type·user_id·application 뿐). 없는 값을 `str(None)` 과 비교해
  **항상 불일치**였다 — 장기 토큰을 제대로 받아도 다음 줄에서 반드시 떨어지는, 통과할 수 없는
  검사였다. 있으면 대조하도록 남겨 뒀다

검증: SNS 테스트 14건 통과. 실제 토큰으로 refresh 두 형식 대조 · debug_token 응답 필드 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:31:42 +09:00
6badd5c9f0 [fix] solution/backend: 플랫폼 거절에 사유를 붙인다 — THREADS_REJECTED_400 만으로는 못 고친다
연동이 실패했는데 로그에 `THREADS_REJECTED_400` 만 남았다. 코드가 만료됐는지, 리디렉션
URI 가 안 맞는지, 권한이 모자란지 구별이 안 돼 원인을 세 번 헛짚었다(실측 2026-09-18).
Meta 는 응답 본문에 `error.message` 와 `error_subcode` 로 이유를 정확히 말해 주는데,
우리가 그걸 읽고 버리고 있었다.

- external/threads._read: 거절 코드 뒤에 `[subcode] message` 를 붙인다
- ★ 담는 것은 message·subcode 뿐이다. 토큰·시크릿·인가 code 는 담지 않는다 —
  이 문자열은 로그로 가고 로그는 우리가 아닌 사람도 본다. message 는 160자에서 끊는다

검증: SNS 테스트 14건 통과. 실제 호출로 엔드포인트·자격증명이 정상임을 먼저 확인했다
(더미 code 로 `Invalid verification code` 응답 · debug_token·장기토큰 교환 호출 모양 정상)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:26:38 +09:00
176a77a231 Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-18 13:01:41 +09:00
921c85df25 [feat] solution: SNS 연동 확인용 테스트 게시 기능 추가 2026-09-18 13:00:03 +09:00
13cc8cc830 [fix] site: sitemap.xml 오리진이 임의의 옛 사이트로 고정되는 버그 수정
킹서버에서 방금 발행한 사이트도 sitemap.xml 에 죽은 옛 호스트(w4ai.o2o.kr)로 나가고
있었다 — Google Search Console 스윕에 URL_NOT_IN_SITEMAP 으로 잡혀서 발견했다.

원인: solution-worker 는 기동할 때마다(=배포할 때마다) `--seed-assets` 로 prerender 를
한 번 돌리는데, 이 경로는 payload 를 안 읽어서 오리진을 얻을 곳이 없다 — 그래서
findBakedOrigin() 이 out/s/ 를 훑어 **가장 먼저 발견한** 사이트의 baked canonical 을 그대로
쓴다. readdirSync 순서는 보장이 없고, SITE_PUBLIC_HOST 를 web4ai.o2osolution.ai 로 옮긴 뒤
한 번도 재발행 안 한 옛 사이트(목업 포함) 하나가 그 자리에 걸리면, 통합 sitemap.xml
전체가 죽은 옛 호스트로 통째로 구워진다 — 오늘 새로 발행한 사이트(sono, 자기 페이지의
canonical 은 정상)까지 사이트맵에서는 옛 호스트로 나갔다.

고침: 처음 찾은 것 대신 **가장 최근에 구워진(mtime 최신)** 사이트의 오리진을 쓴다 —
최근에 발행된 사이트일수록 지금 SITE_PUBLIC_HOST 를 반영했을 확률이 높다.

검증: tsc --noEmit 통과. 킹서버 solution-worker 재시작 후 sitemap.xml 재확인 예정.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 11:16:30 +09:00
114a6b47d3 [fix] deploy: GSC 서비스 계정 키를 컨테이너에 실제로 마운트
GSC_ENABLED=1 로 킹서버에 켜져 있었는데도 search-console 스윕(10분마다)이
ValueError(GSC_CONFIG_MISSING) 로 매번 조용히 실패하고 있었다 — 로그엔 잡혀 있던
BATCH_FAILED 문구만 남고 원인은 안 보이는 종류였다.

원인: search_console_settings.load_settings() 는 GSC_CREDENTIALS_FILE(컨테이너 경로)을
읽는데, docker-compose.yml 이 GSC_CREDENTIALS_HOST_FILE(.env 의 호스트 경로)을 컨테이너로
마운트하는 줄도, GSC_CREDENTIALS_FILE 을 채워주는 줄도 없었다 — 두 변수 다 문서(.env.example
· SEARCH_CONSOLE.md)에만 있고 컴포즈에 실제로 연결된 적이 없었다. 킹서버엔 서비스 계정 키
파일(/home/o2oadmin/.secrets/search-console.json)도 이미 있었다 — 배선만 빠져 있었다.

고침: solution-backend 서비스에 볼륨 마운트 + 고정 컨테이너 경로 env 추가. 호스트 경로가
비어 있으면(GSC 를 안 켠 환경) /dev/null 을 대신 마운트해 컴포즈 문법이 안 깨지게 했다.

검증: docker compose config --quiet 통과. 킹서버에서 GSC_CONFIG_MISSING 없이 배치가 도는지는
배포 후 로그로 재확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 11:10:38 +09:00
d77f2aa14c [fix] solution/backend: 머지로 들어온 SNS 게재 기능의 죽은 코드 3건 수정
feature/social-post 를 feature/site-features-and-mockup 에 머지한 뒤 실제 배포·테스트로
잡았다 — 두 브랜치가 각자 독립적으로 LLM 공급자 리팩터(services/llm/) 이전/이후 상태로
갈라져 있어 SNS 쪽 코드가 옛 인터페이스를 그대로 참조하고 있었다.

- gemini_text.generate_social_post: 모듈 최상단에 call·DEFAULT_MODEL·extract_text 를
  services.llm.gemini 에서 들여오지 않아 실제 게시 시도가 전부 NameError/AttributeError 로
  죽는 상태였다(테스트도 gemini_text.call 을 monkeypatch 하려다 AttributeError). json 모듈도
  import 가 빠져 있었다.
- common/enums.py JobType: SOCIAL_DRAFT=8 이 기존 ROLLBACK=8 과 값이 겹쳐 있었다 — Python
  enum 은 값이 같으면 뒤 멤버가 앞 멤버의 별칭이 되므로, 워커의 핸들러 등록표에서
  HANDLERS[8] 이 SOCIAL_DRAFT 핸들러로 먼저 채워지고 ROLLBACK 은 "이미 등록됨" 판정으로
  덮어써지지 않았다 — 롤백 요청이 SNS 초안 핸들러로 잘못 라우팅돼 KeyError('post_id') 로
  죽었다. SOCIAL_DRAFT=9, SOCIAL_POST=10 으로 재배정. init.sql 의 job_type 주석도 갱신.

검증: test_social.py 14 passed, test_rollback.py 4 passed, 전체 백엔드 763 passed(기존
LLM 공급자 전환 관련 무관 실패 44건 제외 동일).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 09:44:40 +09:00
b78486b845 Merge commit '46171b4f24f2ddc3b9212b9ad6769925bcaceb03' into feature/site-features-and-mockup
# Conflicts:
#	.env.example
#	solution/backend/common/enums.py
#	solution/backend/requirements.txt
#	solution/backend/services/site_payload.py
#	solution/backend/services/snapshot.py
#	solution/backend/services/weather_notes.json
#	solution/shared/src/types/site-payload.ts
#	solution/site/src/layouts/editorial/Shell.tsx
#	solution/site/src/lib/use-live-weather.ts
#	solution/site/src/pages/HomePage.tsx
#	solution/site/src/sections/SiteFooter.tsx
#	solution/site/src/sections/WeatherSection.tsx
#	solution/site/src/sections/index.ts
2026-09-18 09:44:10 +09:00
872d00f3c4 [feat] solution: 미니 블로그·이용후기·예약요청 추가, /s/stay 목업·발행 사이트 UI 다수 수정
인앱 미니 블로그(AI 자동 포스트, 이메일 승인)·이용후기(즉시 게시)·예약 요청(메일 발송)을
새로 붙였고, 병행해서 /s/stay 목업과 발행 사이트 공통 렌더러(UnitsSection·FestivalSection·
LocalGuideSection·WeatherSection 등)의 UI 버그를 다수 고쳤다. 범위가 넓지만 한 주 분량
작업을 한 커밋으로 묶어 달라는 요청에 따라 하나로 묶는다.

- solution/backend: post/review/booking_request 라우터·서비스·CRUD 추가, 스케줄러에
  블로그 초안 생성(새벽 4:10)·발송(아침 9:00) cron 등록, 마이그레이션 4건 추가
- solution/frontend, admin/frontend: 생성된 API 클라이언트 갱신, 리뷰 모더레이션·
  블로그 글 관리 페이지 추가
- solution/site/src: 객실 상세+실시간예약(날짜선택·연락처 폼)을 모달로 통합, 축제·
  주변안내 카드 클릭 시 모달 전환, 후기 목록 카드 UI, 공용 Modal 컴포넌트 신설,
  날씨 문구 동기화 버그 수정(하늘줄·기온줄 한 타이머로), 시설·편의 가능/불가 아이콘
  색상 하이라이트, 헤더 메뉴 순서를 실제 섹션 순서에 맞춤, 하단 탭바 아이콘 정렬 버그
  (line-height) 수정, 추천일정 점선 연결+데스크톱 자동펼침/모바일 축소, 채널 라벨에
  크롤링 원문("NOL")이 새던 것을 bookingLabel() 로 교체
- solution/site/scripts/mockup: /s/stay 패치 스크립트·주입 CSS·JS 다수 수정, stay4~6
  빌드 스크립트 추가(다른 세션 작업)

테스트: solution/site `npx tsc --noEmit` 통과, `npx vitest run` 93 passed,
solution/backend `pytest tests/test_booking_request.py` 6 passed(로컬 DB 대상).
예약 요청 메일은 실제 발송까지 확인(place 66894a1b 소유자 이메일 누락을 DB에서 보정).
2026-09-18 09:03:18 +09:00
46171b4f24 Merge branch 'feature/social-post' 2026-09-18 09:00:07 +09:00
1f26bc7065 [feat] solution: 날씨 조건 세분화,
공식채널 단일화, 한일옥 거리 반영
날씨 조건을 7종으로 세분화,
축제 종료 여부와 무관하게 상시 노출,
'지역 읽기'갈래 축소, 야놀자(NOL) 브랜드명 제거.
2026-09-17 17:02:48 +09:00
29af37f158 [chore] site/mockup: 목업 스크립트·벤더 번들 정리 — stay2/stay3 빌드 도구 추가
patch_stay.py·inject.js/css·audit-all.mjs 갱신, 벤더 번들 교체(index-D9cPXCE3 →
index-D4jgGFP4 등), stay3 백업 스냅샷과 stay2/stay3 빌드 스크립트(build_stay2.py·
build_stay3.py) 및 원본 템플릿(tpl-story·tpl-creative) 추가. 옛 번들은
vendor/retired/ 에 보관.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:02:11 +09:00
dbae2d4f35 [feat] solution/backend: LLM 공급자를 OpenAI 기본값으로 전환, Perplexity 실비용 계측 추가
## 1. Gemini -> OpenAI 공급자 추상화

Gemini 쿼터/인증 실패로 COPY 잡(소개문·FAQ 생성)이 반복 DEAD 되는 걸 보고, 공급자를
OpenAI로 바꾸되 설정 하나로 되돌릴 수 있게 했다.

- services/llm/errors.py·types.py(신규): 공급자 무관 예외·Usage·ImagePart·LlmResult
- services/llm/gemini.py: 기존 call() 은 그대로 두고 generate() 인터페이스 추가
- services/llm/openai.py(신규): OpenAI Chat Completions 구현. 실측(2026-09-16):
  gpt-5.6-luna 는 temperature 커스텀 값을 거부한다("Only the default (1) value is
  supported") — 아예 안 보낸다.
- services/llm/provider.py(신규): LLM_PROVIDER 설정(기본 openai, 모르는 값은 gemini)으로
  둘 중 하나를 고른다.
- gemini_text.py·gemini.py(vision)·gemini_extract.py: 공개 함수 이름은 그대로 두고
  내부만 provider.active() 로 배선 — vision_service.py 등 6개 호출부는 무변경.
  단 model 선택 로직(vision_service.py·copy_steps.py)은 공급자에 맞는 모델명을 고르도록 한 줄씩 고쳤다.
- config_models.py: llm_provider·openai_api_key·openai_text_model·openai_vision_model 추가.

## 2. Perplexity 실비용 계측 추가

OpenAI 전환 김에 실제 발행 파이프라인(스테이,머뭄 기준)을 끝까지 돌려 LLM 비용을 재보니,
services/llm/perplexity.py 에는 애초에 토큰·비용 계측이 없었다. 추가하는 과정에서
실측(2026-09-16, 실제 API 응답): `usage.cost` 는 문서 예시(평평한 숫자)와 달리
`{input_tokens_cost, output_tokens_cost, request_cost, total_cost}` 객체였다 — 그대로
가정하고 배포했다가 지역 이야기 생성(LOCAL_SYNC) 잡이 재시도 3회 후 DEAD 로 떨어지는 걸
라이브에서 확인하고 고쳤다. 어떤 모양이 와도 예외를 던지지 않게 방어했다.

- services/llm/perplexity.py: Usage·read_usage() 추가(usage.cost.total_cost 를 그대로 읽는다
  — 토큰 단가표로 역산하지 않는다. 검색 컨텍스트 요금까지 포함된 진짜 값이라서다)
- external/perplexity.py·place_research.py·story_service.py·itinerary_llm_service.py·
  external/restaurant_discovery.py: 각 호출부에 tokens/비용 로그 추가

실측(스테이,머뭄 1건 발행, 지역 콘텐츠는 캐시): Perplexity $0.050(일정 생성이 절반 이상),
OpenAI $0.019(비전 $0.015 + 소개문·FAQ $0.003 + 가사 $0.0006).

검증: 신규/영향받은 테스트 전부 통과(services/llm 신규 3파일, gemini_extract 최초 HTTP
계층 테스트, perplexity 비용 계측 등). 실 OpenAI/Perplexity API로 사업장 수집→비전→
소개문·FAQ→발행까지 라이브로 왕복 확인.

## 3. site/EssentialInfoSection.tsx

미확인 항목 개수 안내 문구 제거(별도 작업, 스테이징된 상태 그대로 포함).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:30:11 +09:00
33d27980c4 docs(DEVLOG): Teams 웹훅 수신자 고장 원인·해결 기록 2026-09-16 16:32:52 +09:00
b4085a0e0f [fix] solution: 온보딩 생성·크롤링 진단·장애 알림 묶음
운영 번들 자동 로그인 자격증명 유출, 온보딩 COPY 잡이 Gemini 429 로 죽던 것,
크롤링 실패가 로그에만 남던 것을 한 번에 정리한다. 실측(2026-09-15 밤, 킹서버):
사진분석 배치가 Gemini 분당 쿼터를 다 써서 같은 키를 쓰는 온보딩 COPY 잡도 같이
429 를 맞고 DEAD 로 갔다 — 확인된 fact 만으로도 편집·발행이 되는데 잡을 죽일
이유가 없었다.

- solution/frontend: `VITE_AUTO_LOGIN_ID`·`PW` 를 운영 진입점에 안 넘긴다(자동 로그인은
  dev 서버 전용) + `Step5Generating` 겉모습을 이전 카드 스타일로, 데이터는 실제 잡
  진행(useGenerationJob) 그대로
- solution/backend: copy_service — Gemini 호출 실패해도 잡을 안 죽이고 fact 만으로 계속.
  db_session_manager — 유니크 제약 충돌(정상 경로) 로그를 ERROR → WARN.
  worker/runner + alert_service + teams_webhook — 잡 dead-letter·발행 실패·큐 정체를
  Teams 로 알림(영구 저장 + 재시도 + dedupe). `/readyz` 추가.
  collect_diagnostics(신규) — 크롤링 채널별 실패를 jobs.result 에 구조화해서 싣는다.
- postgres-init: 0015(users token_version) · 0016(alert_outbox) 마이그레이션

검증: 백엔드 pytest 759 passed. tsc(solution/frontend) 통과. Teams 알림 실채널 수신 확인.
2026-09-16 16:25:02 +09:00
b76144746b [feat] solution/frontend,site: 파비콘을 PNG 로 — 발행본·앱 양쪽에 건다
주신 32x32 PNG 를 브랜드 자산으로 넣고 앱과 발행본이 같이 쓴다.
SVG 는 뒤에 남겨 둔다 — PNG 를 못 읽는 자리는 없지만, 고해상도 탭에서는 SVG 가 낫다.
브라우저는 앞의 것부터 보고 처리할 수 있는 것을 고른다.

- public/brand/favicon-w4a.png (신규, 32x32)
- site/seo/head.ts · frontend/root.tsx: PNG 를 먼저, SVG 를 뒤에

검증: site vitest 86건 통과 · tsc(site·frontend) 통과.
발행본 반영에는 전체 재굽기가 필요하다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:32:03 +09:00
079c93a62a [feat] solution,postgres-init,docs: 생성 진행 상태 · 새로 만들기 존중 · 발행본 색인·파비콘
작업트리에 커밋되지 않은 채 쌓여 있던 것과, 오늘 찾은 문제 셋을 함께 담는다.

## 1. 콘텐츠 생성 진행 상태 (작업트리에 있던 것)

COPY 잡의 실제 단계를 DB에 기록하고 응답으로 내보낸다. 폴링 횟수로 진행률을 흉내 내던
것을 걷어냈다. 새로고침·재접속해도 jobId 로 이어서 본다.

- services/copy_steps.py · services/job_progress.py · common/job_errors.py (신규)
- postgres-init/migrations/0013_job_progress.sql + init.sql
- 프론트: useGenerationJob · generationLabels (신규), Step5Generating·pollJob 배선,
  orval 모델 갱신(jobProgress · jobStep · jobStepStatus · jobStepReason)
- docs/GENERATION_FLOW.md (신규)

## 2. 발행된 사이트만 색인한다

실측(2026-09-15): 디스크의 발행본 33곳 중 **15곳이 draft 인데 `index, follow`** 였고
사이트맵에도 올라가 있었다. 사장님이 발행 버튼을 누른 적 없는 사이트가 짓다 만 상태로
구글에 실려 있었다는 뜻이다.

head.ts 가 robots 를 하드코딩하고 payload 의 `site.status` 를 보지 않았다.
"색인을 막을 이유가 없다"는 주석은 굽는 것이 곧 발행이던 시절의 말인데, 지금은 빌더
미리보기만 눌러도 draft 로 구워진다.

- seo/head.ts: PUBLISHED 일 때만 index, 아니면 `noindex, follow`
- 사이트맵·`/s` 목록·llms.txt 에서도 함께 빠진다 — 그쪽은 구운 HTML 의 robots 를 읽어
  거른다(seo/directory.ts readBakedNoindex). 규칙을 두 자리에 두지 않으려고 한 곳에 뒀다

## 3. [새로 크롤링하고 사이트 생성하기] 를 뒤집지 않는다

ba90a19 의 중복 합치기가 **일부러 다시 만들려는 경우까지** 기존 사업장으로 끌고 갔다 —
새로 만들기를 눌렀는데 기존 에디터가 열린다(사장님 보고 2026-09-15).

- Req_VerifyPlaceByUrl.reuse_existing (기본 True — 다른 호출자의 동작은 그대로)
- place_service.verify_place_by_url: 끄면 이어붙이지 않는다. 다만 **비어 있는 중복 행은
  계속 치운다** — 원래 막으려던 누적이 그것이고 빈 행은 잃을 것이 없다
- ensureServerPlace: 위저드는 새로 만들기 경로에서만 오므로 False 로 보낸다

## 4. 발행본 파비콘

발행본에 파비콘 링크가 아예 없어 브라우저 탭에 기본 아이콘이 떴다. 파일은 오리진 루트의
공용 자산이라 사이트마다 복사하지 않고 루트 절대경로로 가리킨다.

검증: site vitest 84건 통과 · tsc(site·frontend) · eslint 통과.
백엔드 pytest 는 로컬 DB 비밀번호가 맞지 않아 돌리지 못했다(a5b8701 과 같은 자리).
발행본 반영에는 전체 재굽기가 필요하다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:29:27 +09:00
4cd756108d Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-15 17:21:40 +09:00
217d0853bc [fix] solution: 썸네일 저장소 없이도 내 사이트·랜딩 카드에 그림을 채운다
Azure 썸네일 저장소가 안 꺼져 있으면(로컬 개발 등) sites.thumbnail_url 이
계속 비어 있어 발행된 사이트도 카드가 아이콘으로 떨어졌다. 그 대신 빌더가
이미 쓰는 대표 사진(place_photos)을 한 번이라도 발행한 줄에 채운다 —
내 사이트 목록과 랜딩 쇼케이스 둘 다 같은 규칙.

EssentialInfoSection 예약 공지 라벨에서 채널명(NOL)을 뺀다.
2026-09-15 17:19:07 +09:00
a6ddeccdff [feat] solution: 맛집 카드 사진 필터링·지도검색 연결
사진(imageUrl) 없는 맛집은 카드 목록에서 제외한다(명소는 이름 대체 규칙 유지).
맛집·명소 카드의 "검색으로 열기"는 네이버 웹검색 대신 네이버 지도검색으로 연결한다.
빌더 관리자 미리보기(LocalGuide)도 실제 사이트와 동일하게 맞춘다(축제 카드는 기존 웹검색 유지).

검증: site 81건(신규 회귀 3건 포함) 통과. site·frontend 모두 tsc --noEmit 통과.
2026-09-15 16:28:39 +09:00
820a2e9e35 feat(site): 오늘의 날씨 문구 로딩과 조건별 순환 추가
목업에만 있던 날씨 문구 계약을 제품 payload와 렌더러에 연결한다. 군산 전용 장소를 다른 사업장에 복사하지 않도록 공통 안내를 별도 JSON으로 관리한다.

날씨 변환 단위 테스트 3건, 렌더링 테스트 3건 및 TypeScript·ESLint 통과. 전체 발행 테스트는 깨끗한 renderer 재빌드 후 별도 확인.
2026-09-15 16:23:53 +09:00
f2087aad5e feat(solution): 발행 워커·버전 관리와 예약·미리보기 정리
상시 프리렌더와 중복 예약 안내를 없애고, 검수된 발행 버전을 보존한다. 미리보기는 실제 렌더 완료까지 스피너를 표시한다.

사이트 81건, 발행·롤백·서치콘솔 45건, 프로세스 수명 3건 통과. 빌더·사이트 빌드 및 compose 설정 검증 통과.
2026-09-15 16:12:16 +09:00
0f7d22750f [fix] solution/backend: Teams 카드 필수 필드 보완
웹훅 요청의 contentUrl 및 스키마 선언 누락을 공식 형식에 맞춤. HTTP 202와 채널 게시 성공을 구분하도록 검증 한계 기록.

검증: Teams 요청 형식 회귀 테스트 1건 통과. 워크플로 실제 수신은 별도 확인 필요.
2026-09-15 15:55:59 +09:00
3f47d5ecd2 [feat] solution/backend: 서치콘솔 자동 제출·색인 상태 추적 추가
사이트 발행 성공과 Google 색인 관측은 별도 상태다. 외부 API 장애로 발행이 실패하거나 재시작 때 추적 정보가 사라지지 않도록 분리.

- Google 클라이언트·배치·DB·Teams 알림 모듈 분리
- 기존 스케줄러 연결, 재시도·중복 실행 방지와 선택 설정 추가
- ORM·초기 DDL·마이그레이션·운영 설정 문서 동시 갱신

검증: 관련 59건 통과, compose 설정·diff 검사 통과. 추가 회귀 23건 통과, 기존 발행 검수 실패 1건은 변경 전 코드에서도 재현. 운영 배포·Google/Teams 실호출 미실행.
2026-09-15 14:50:30 +09:00
9773bc0496 [fix] solution: 엽서 자리·판 색·푸터 표기, 재발행이 노래를 다시 만들지 않게
대표 지적 여섯을 한 번에 고친다.

- **재발행이 Suno 를 다시 부르던 것**(`song_service.ensure_song`). `build_service` 가
  `publish=True` 마다 이 함수를 불렀는데 가드가 없었다 — 내용이 하나도 안 바뀐 재발행에도
  Gemini 가사 1회 + Suno 작곡 1회가 그대로 나갔고, 다섯 번 누르면 유료 호출 다섯 번에
  `place_songs` 행 다섯 개다. 화면은 최신 READY 한 곡만 쓰므로 나머지는 돈만 쓴다.
  → READY 곡이 있으면 건너뛴다. 일부러 다시 만드는 길은 SONG 잡(`force=True`)으로 남긴다
- **엽서 쓰기 자리**를 계절별 축제 바로 앞으로(시연본과 같게). 목록 맨 뒤라 FAQ 보다도
  아래였다. 축제가 없는 사이트는 종전대로 맨 뒤
- **엽서가 모바일에서 화면을 밀어내던 것** — 그리드 칸의 기본 `min-width:auto` 가
  사진 레일의 최소 너비(실측 390px 화면에서 2,056px)를 그대로 받아 칸이 밖으로 나갔다.
  `min-w-0` 과 캔버스 비율 고정(`aspect-square h-auto`). 실측 넘침 0px · 캔버스 358×358
- **가요 다방 판이 한 색이던 것** — 폴백 팔레트를 시연본(`/s/stay`)이 쓰는 23색 그대로
  옮겼다(대표: "그냥 정해놔 · /s/stay 보고 맞춰라"). 목록을 새로 짜면 시연본과 갈린다
- **푸터에 만든 곳 표기** — `AI O2O의 Web4Ai로 만든 사이트입니다.`(o2osolution.ai 링크).
  발행본(`SiteFooter`)과 시연본(`patch_stay.py`) 둘 다
- **에디터 캔버스의 푸터·모바일 탭바 제거** — 발행본이 iframe 안에서 둘 다 이미 그린다
  (`SiteFooter` · `MobileTabBar`). 바깥에 한 벌 더 그려 두 번 서 있었다
- **시연본 자동재생 끔**(`inject.js`, 대표: "클릭하고 나서 되니까 사람들이 에러로 보잖아").
  세 번째 뒤집기고 이번 이유는 다르다 — 정책을 못 이겨서가 아니라 정책에 걸린 모습이
  고장으로 읽혀서다. 함수는 남기고 호출만 뺐다. 제품 렌더러엔 원래 없다

검증: tsc·eslint 통과(site·frontend), site 79 passed. 실제로 구워서 확인 —
섹션 순서 `…오시는 길 → 엽서쓰기 → 계절별 축제 → …`, 푸터에 o2osolution.ai 링크,
모바일 가로 넘침 0. 킹서버 실측: 재발행 안 한 사이트(joyyy·bbbb)도 중계로 공유 버튼이 뜬다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 10:22:26 +09:00
f0c4d5f413 [fix] solution: 엽서·가요 다방 손질 — 재굽기 전 사이트도 공유되게 사진을 중계한다
배포 뒤 대표 지적 넷을 한 번에 고친다.

- 사진 중계(`backend/router/v1/media/relay.py`, `/v1/image/relay`) — 굽는 쪽이 사진을
  우리 자리로 옮기는 건 **다시 굽는 사이트에만** 적용된다. 아직 재발행 안 한 사이트는
  캔버스가 오염된 채라 저장·공유가 막혀 있다. 같은 오리진으로 바이트를 흘려보내 그 사이를
  메운다. `postcard-canvas.loadImage` 가 CORS 실패 때만 두 번째로 탄다 —
  이미 우리 자리에 있는 사진은 첫 시도에서 끝난다.
  ★ 열린 프록시가 되지 않게: https · 호스트 allowlist · 이미지 타입 · 8MB ·
    리다이렉트 후 호스트 재검사 · IP 주소 거절. 실측: pstatic 사진 158KB 통과,
    http·목록 밖·127.0.0.1·169.254.169.254 전부 거절
- 엽서 미리보기 360px (320 → 240 은 작다 하셔서 그 사이로)
- 엽서 사진 목록에 슬라이더가 없던 것 — `.slider-viewport` 마크업만 손으로 찍어서
  embla 가 안 붙어 있었다(그건 스크립트 붙기 전의 기본 상태다). 진짜 `Carousel` 로 바꾼다
- 가요 다방 판이 전부 주朱색이던 것 — **실측 67곡 중 63곡에 `labelColor` 가 없다.**
  모델이 공통 규칙 2("확인 안 된 값은 뺀다")를 색에까지 적용한다. 색은 사실이 아니라
  디자인 값이다: 프롬프트에 그렇게 못 박고, 비었을 때는 제목 해시로 레코드 라벨색 8종에서
  고른다(무작위가 아니다 — 하이드레이션이 어긋나면 안 되고, 새로고침마다 바뀌어도 안 된다)

tsc·eslint 통과(site), site 79 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 09:51:53 +09:00
6b9e01d876 [feat] solution: 지역 읽기 섹션 · 엽서 공유를 풀고 · 기존 사이트는 자산 주소만 갈아 끼운다
세 가지가 한 줄기다 — 목업에만 있던 것을 제품으로 옮기면서, 그게 이미 나가 있는
사이트를 건드리지 않게 하는 데까지가 한 변경이다.

① 지역 읽기(mockup/README T7) — 목업은 주입 스크립트로 그렸고 렌더러엔 없었다.
   새로 발행한 업장에서는 영영 빈자리였다(`daily` 가 프롬프트를 빌더에만 둬서 서버가
   그 종류를 몰랐던 것과 같은 사고).
   · shared: `ReadingItem` · `SECTION_ITEM_REQUIRED_KEY.reading` · `reading` 프롬프트
     (프롬프트 단일 출처는 `section-prompts.ts` 하나다 — 코드에 문장을 박지 않는다)
   · backend: STORY_KINDS 등록. `_SEARCH_LINK_KINDS` — 이 종류는 모델의 URL 을 안 받고
     제목으로 만든 네이버 검색 링크를 코드가 붙인다(주소를 짐작해 적으면 없는 문서로 간다)
   · site: '지역 이야기' 여섯 번째 탭. 구운 HTML 은 앞에서 여섯 꼭지, 붙은 뒤 한 번 섞는다
   · 탭 이름은 `{지명} 읽기` — '군산' 을 코드에 박지 않는다

② 엽서 공유가 모든 발행 사이트에서 막혀 있던 것. 사진이 `*.pstatic.net` ·
   `tong.visitkorea.or.kr` 에 있고 그쪽이 `Access-Control-Allow-Origin` 을 안 준다
   (실측 세 곳 모두 없음) — 캔버스가 오염돼 `toBlob` 이 죽는다. 클라이언트에서는 못 넘는다.
   · `prerender.ts mirrorMedia`: 굽기 전에 `s/<slug>/img/<주소해시>.<확장자>` 로 받고
     payload 주소를 우리 오리진 절대주소로 바꾼다(og:image·JSON-LD 도 같은 값을 쓴다)
   · 못 받으면 원래 주소를 쓴다. 파일명이 주소 해시라 다시 구워도 안 받는다
   · `originUrl`·`sourceType` 은 그대로 — DECISIONS 1-2 가 "불가" 면 CRAWL 제외가 먹어야 한다
   · 엽서 미리보기를 240px 로 묶었다(대표: "엽서 ui 너무 큼")

③ **기동이 전부 다시 굽지 않는다** (대표: "전체 재굽기 할 필요가 없어, 사장님이
   재발행하면 끝인데 / css js만 안 깨지게 하란 말이야").
   렌더러를 한 줄 고칠 때마다 이미 나가 있는 사이트의 HTML 이 통째로 바뀌던 자리다.
   · `watch-payloads.mjs`: 기동 = `--refresh-assets` 하나. 한 번도 안 구워진 payload 만 굽는다
   · `prerender.ts refreshBakedAssets`: 구워진 HTML 의 `assets/index-<해시>.css|js` 파일명만
     새 번들로 바꾼다. 내용·payload·접두사는 그대로. 보호 슬러그는 건너뛴다
   · 그래서 ①②는 **다음 발행 때** 그 사이트에 들어간다

문서: AGENTS.md 함정 둘(사진 내려받기 · 기동은 안 굽는다) 추가, 전체 재굽기를 전제하던
옛 항목 둘을 고쳤다. mockup/README T7 은 "제품에 들어갔다" 로, DATA_MODEL 의 STORY kind 목록 갱신.

검증: tsc·eslint 통과(site·frontend), site 79 passed(읽기 4건 추가).
실측 — buru 굽기: 사진 10장 내려받고 og:image 가 우리 주소, 재굽기 때 0건;
`--refresh-assets`: 옛 해시로 바꿔 둔 index.html 1곳이 새 번들 주소로 바뀌고 내용은 그대로.
백엔드 테스트는 이 기계의 5432 가 다른 터널에 물려 있어 못 돌렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 09:31:19 +09:00
a1a416ca4a [fix] site,docs: 굽기 제외는 stay 하나로 — stay2 는 실제 사이트가 쓰고 있다
앞 커밋(7425cb4)이 stay2·stay3 까지 막아 버터브루(slug=stay2) 재굽기가 실패했다
(실측 2026-09-15: `✗ stay2 — 슬러그 'stay2' 는 시연본이…`, 프리렌더 exit 1).
시연본은 `/s/stay` 하나다(mockup/README 2.1) — 자산도 `/s/stay/` 밑으로 자급자족이라
다른 사이트를 다 구워도 안 깨진다.

- prerender.ts: PROTECTED_SLUGS 기본값 `stay`
- AGENTS.md: 같은 내용

tsc·eslint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 08:28:41 +09:00
7425cb409e [fix] site,docs: 목업 슬러그는 굽지 않는다 — 발행 하나에 /s/stay 시연본이 통째로 덮였다
실측(2026-09-15): 누가 빌더에서 슬러그 `stay` 로 발행해 `payloads/stay.json` 이 생기자
프리렌더가 기동하며 그 payload 로 `/s/stay` 를 구웠다 — 손으로 만든 유일본이 사라졌다
(캐치프레이즈 100개 · 미니 플레이어 · 날씨 문구 · 주입분 전부). 백업에서 되돌렸다.
"payload 가 없으면 안 굽는다"(AGENTS.md 함정 1)는 목업을 못 지킨다 — payload 가 생기는
순간 덮인다.

- prerender.ts: PROTECTED_SLUGS(기본 stay,stay2,stay3 · PRERENDER_PROTECTED_SLUGS 로 덮어씀).
  그 슬러그는 굽지 않고 실패 보고서를 쓴다 — 사장님 화면에 사유가 뜨고 목업은 남는다
- AGENTS.md: 함정 1에 이 경로와 payloads-mockup-hold 를 적는다

tsc·eslint 통과 · vitest 75 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 08:22:53 +09:00
93bd5435ac [feat] solution/site,docs: 엽서 쓰기를 발행본에 넣는다 — 그리기 규칙은 한 파일로
시연본에만 주입 스크립트로 있던 엽서 쓰기를 발행본 컴포넌트로 옮겼다(2026-09-14 대표:
"웹빌드 해도 엽서쓰기 나오게"). 사진이 있는 사이트면 섹션이 나간다.

★ 캔버스가 남의 도메인 사진에 오염되면 저장·공유가 SecurityError 로 죽는다 — 미리보기는
  보이는데 내보내기만 막히는, 눈으로는 못 찾는 종류다. 실측: 발행본 사진은 네이버 CDN 에 있고
  그쪽은 Access-Control-Allow-Origin 을 주지 않는다(curl -I 확인). CORS 로 먼저 받아 보고
  실패하면 CORS 없이 받아 **미리보기만** 세우고 저장·공유 단추를 감춘다.

- lib/postcard-canvas.ts(신규): 그리기만 한다. 여백 56 · 우표 칸 190 · 최대 4줄은 시연본과 같은 값
- sections/items/PostcardMakerSection.tsx(신규): 사진 고르기·입력(4줄 제한)·공유/저장
- HomePage: 섹션 설정에 자리가 없는 기능이라 오시는 길과 같은 방식으로 직접 낸다
- package-lock.json: playwright 선언에 맞춰 잠금 갱신(어제 커밋에서 lock 을 안 맞춰 npm ci 가 죽었다)
- DEVLOG: 근본 해결(사진을 우리 오리진으로 옮기기)은 아직 안 했다고 적어 둔다

검증: tsc 통과 · 발행본(/s/statata)에서 사진·문구·우표·소인·서명 줄이 그려지고,
      외부 사진이라 저장·공유 대신 안내문이 뜬다(스크린샷 확인)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 20:16:24 +09:00
3b4c5c98bc [chore] package.json,site/mockup: 점검 도구(playwright)를 선언하고 도는 자리를 적는다
`audit-all.mjs`·`rails-test.mjs` 가 playwright 로 도는데 **package.json 어디에도 선언이 없었다** —
내 PC 의 node_modules 에만 있어서, 새로 클론한 사람은 `npm install` 을 해도 점검을 못 돌린다.

- package.json: devDependencies 에 playwright ^1.63.0 (실제 쓰던 판)
- README: 점검은 **개발 PC에서 주소를 주고** 돈다 — 킹서버엔 node 가 없다.
  도커에 넣지 않는 이유도 적었다(점검은 배포물이 아니라 개발 도구다)

검증: node audit-all.mjs https://web4ai.o2osolution.ai/s/stay → 45개 전부 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 20:07:23 +09:00
2824dedd42 [fix] site/mockup: 번들을 머지본으로 갈아 끼우고, 점검을 그날 결정에 맞춘다
머지는 됐는데 이미지를 안 구워 번들이 머지 이전 것이었다 — 소스에는 StorySection·DailySection 이
있는데 번들에는 없어, payload 에 값이 있어도 화면이 비었다. 목업은 자기 번들을 들고 다니므로
렌더러를 고치면 **vendor/ 의 css·js 한 벌만** 갈아 끼우면 된다(다시 만드는 게 아니다).

점검 6건이 실패했는데 그중 5건은 코드가 아니라 **점검이 낡은 것**이었다. 2026-09-14 결정:
오늘의 한 장은 탭을 세우지 않고(HIDE_TABS), 도시 엽서 4장은 손님이 쓰는 엽서로 바뀌었고,
군산 읽기는 무작위 5~6꼭지 카로셀이다(신문 조판은 걷어냈다). 점검만 옛 기준을 보고 있었다.

- vendor/: index-DQreaXOf.css · index-D9cPXCE3.js 로 교체, 옛 번들은 vendor/retired/
- audit-all.mjs: 군산 읽기(재료·화면·출처) · 오늘의 한 장 숨김 · 엽서 쓰기로 항목 정리.
  선택자도 실제 id(#w4d-reading-panel)로 — 옛 #w4d-reading 을 보느라 0꼭지로 세고 있었다
- README: 번들 갈아 끼우는 절차와 "점검 기준이 결정을 따라가야 한다" 표 추가

검증: node audit-all.mjs 45개 전부 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 20:01:28 +09:00
dd3ad86715 Merge branch 'main' into feature/crawler 2026-09-14 19:45:25 +09:00
a093921b62 feat(backend,site): NOL 수집 및 숙박 안내 구조화와 지역 콘텐츠 연동
수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다.

- NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가
- 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호
- 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시
- 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가
- 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선

검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
2026-09-14 19:40:04 +09:00
01098835e9 [chore] docker-compose,ontology: 온톨로지를 이 레포로 들여 compose 한 벌로 띄운다 — 앱 Dockerfile 신설
발행이 SiteOntology 를 부르는데 서버는 따로 띄워야 했다. 실측(2026-09-14): 서버가 없으면
`[seo] SiteOntology 실패 — 키워드 없이 발행: ConnectError` 로 빌드는 성공하고 메타만 빈다 —
화면으로는 안 보이는 종류다. 한 벌로 묶어 "코드는 올라갔는데 서버가 없는" 상태를 없앤다.

- ontology/: gitea.o2o.kr/Web4ai/o2o-site-ontology 를 이 레포로 편입(그 원격은 그대로 남는다)
- ontology/Dockerfile(신규): 베이스는 node:22-slim. alpine 은 임베딩 런타임(onnxruntime)이
  musl 바이너리를 안 줘서 적재가 ERR_DLOPEN_FAILED 로 죽는다 — 빌드는 성공하고 실행에서만 터진다
- docker-compose.yml: ontology · ontology-postgres(pgvector) · ontology-redis 추가.
  자체 DB 를 쓰는 이유는 pgvector 확장 때문이다 — web4ai_db 를 남의 서비스 확장에 묶지 않는다
- 임베딩 모델(120MB)은 이미지에 굽지 않고 볼륨(ontology-model)에 남긴다
- 컨테이너끼리는 `http://ontology:3100` 으로 만난다. `.env` 의 127.0.0.1 은 컨테이너 자기 자신이라 안 닿는다

검증: 3개 기동 · 백엔드 컨테이너에서 ontology:3100/demo HTTP 200 · 마이그레이션·시드 완료

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 17:41:03 +09:00
67a1db3cb9 feat(frontend): 캔버스 소개 요약값 배선(placeAdapter, common)
factField()가 FactData.summary를 InfoField.summary로 옮기고,
common.ts에 introSummary()를 추가한다. 아직 어느 변형도 이 값을
쓰지 않는다 — IntroSideBySide/IntroStory 연결은 다음 커밋.
2026-09-14 17:12:30 +09:00
afd41b425a feat(shared,frontend): InfoField/FactData에 요약 필드 추가
전체 orval 재생성은 이 브랜치의 다른 미완료 백엔드 작업(야놀자·맛집·
지역가이드 등)까지 끌어와 무관한 대규모 diff가 생겨서, summary 필드에
필요한 최소 변경만 손으로 반영했다(orval이 만들 결과와 동일한 모양).
2026-09-14 17:11:24 +09:00
bdc69a80f2 Merge branch 'feature/stay-mockup-postcard-reading' 2026-09-14 17:09:51 +09:00
ff8d3653af Merge branch 'fix/backend-snapshot-null-read' 2026-09-14 17:09:48 +09:00
ab6a6b74e9 [fix] solution/backend: 발행 미리보기가 지역 정보 한 칸 때문에 통째로 500 나던 것
_select() 는 조회 실패를 빈 목록으로 삼키도록 짜여 있었는데, DB 계층 기본값이
raise_error=True 라 그 처리가 실행될 기회조차 없이 예외가 먼저 터졌다. 실측:
place_songs 테이블이 없던 동안 미리보기가 통째로 HTTP 500 이었다 — 노래 한 칸이
빠진 화면 대신 아무것도 못 보는 화면이 나갔다.

- snapshot.py: _local_contents · _site_places · _select 의 DB 조회에
  raise_error=False 를 명시 — 실패는 그 칸만 비우고 나머지는 그대로 그린다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYUb8ZfNiDd15tZRYrusCX
2026-09-14 17:07:23 +09:00
0da9b92423 [feat] site/mockup: 시연본 갱신 — 주변 맛집 31곳·국가유산 야행·차 동선 분기·번들 최신화
/s/stay 시연본에 오늘 쌓인 변경을 한꺼번에 반영한다. 원본 8곳이 전부 467m 안이라
'걸어서 10분 이상' 탭의 맛집 칸이 비었던 것, xlsx 대조로 사이트에 없던 축제(국가유산
야행)가 빠져 있던 것, 차로 온 손님도 걸어온 손님과 같은 동선(짐 맡기고 저녁에 복귀)을
따르던 것, 번들이 2026-09-09 판이라 숙박 예약 섹션이 아예 안 그려지던 것 — 전부 발견한
순서대로 고쳤다.

- patch_stay.py: 날짜 값(UPDATED_AT) 한 곳으로 모음 · 날씨 문구 케이스당 1개→5개
  무작위 · '오늘의 엽서' 탭 비움(엽서 쓰기 섹션으로 대체) · 주변 맛집 8→31곳 +
  사진 없는 집에 시연 전용 사진 채움 · 군산 국가유산 야행 추가(날짜 미확정이라
  기간 값은 안 넣음) · 오늘의 한 장 탭 숨김 · 예약 섹션 켬 · head 메타의 날짜도
  같이 갱신 · 번들을 최신 빌드로 교체하고 `<body class="site">` 보정(빠지면 카로셀
  overflow 가 죽어 가로 스크롤이 생겼다) · 군산 읽기/엽서 카로셀에 쓸
  embla-carousel UMD 번들 도입
- build_itinerary.py: 차로 온 손님의 마지막 날은 복귀 칸 없이 마지막 정거장에서
  끝나도록 동선을 가름(도보 뼈대에서 복귀 칸만 떼어 파생 — 두 벌로 안 둔다)
- build_story.py: 진희경은 익산 출생·군산에서 통학 — "군산 출신"으로 뭉뚱그리지 않게 정정
- fetch_restaurants.py · fill_restaurant_photos.py · restaurants.json ·
  restaurant-photos.json · img/mirror/*: TourAPI 로 받은 맛집 31곳과, 사진 없는
  집에 채운 시연 전용 사진(공공누리 아님 — 제품으로는 안 옮긴다)
- vendor/: embla-carousel.umd.js 추가, 번들을 index-C3fFNl3r.css·index-DDxwteyn.js
  로 교체하고 옛 파일은 vendor/retired/ 로 보관
- audit-all.mjs · audit_schedule.py · SCHEDULE.md: 위 변경에 맞춘 점검 항목·산출물 갱신
- AUTOPLAY.md: 새로고침 직후 배경음악 자동재생을 시도한 것과 실측값 — 사람 입력 없이는
  브라우저가 막아서 안 된다는 결론 기록

검증: `python3 patch_stay.py` 정상 종료, 킹서버 반영 후 md5 대조.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYUb8ZfNiDd15tZRYrusCX
2026-09-14 17:07:01 +09:00
민헌
33b7b94300 Merge branch 'feature/faq' — FAQ 20개 채우기(펜션 공통 질문 + 문의 안내) 2026-09-14 17:05:56 +09:00
민헌
8a09af6599 [feat] solution,postgres-init: FAQ 를 20개까지 채운다 — 펜션 공통 질문 30개 + 문의 안내
COPY 잡은 확인된 fact 로만 FAQ 를 써서 4~8개에서 끝났다(실측 로컬: 스테이머뭄 fact 8건,
산하연 풀빌라 fact 4건 · FAQ 4건). fact 가 0건이면 start_copy 가 FAQ_UNGROUNDED 로 잡을 만들지 않아 0개였다.
생성 상한을 20으로 올리고, 모자라면 펜션 카탈로그에서 겹치지 않는 질문을 **문의 안내** 답으로 채운다.
공통 답에 값·가능 여부를 적으면 업종 시드 FAQ 가 가공의 가격을 사이트에 내보낸 사고와 같다 —
답은 "…은 전화(…)로 문의해 주시면 안내해 드립니다" 뿐이고, 그래서 화면에만 나간다.

- common/faq_catalog(신규): 로더 + resources/pension.json 30문항. fact_keys 가 업종 스키마에 없으면 로드 시 예외
- services/faq_fill.py(신규): 고르기 규칙 — fact 로 답할 수 있는 질문 · 기존 FAQ 와 근거 key 또는 질문 키워드가
  겹치는 질문은 건너뛴다(LLM 은 "주차 및 와이파이" 처럼 묶어 쓰고, 사장님 입력은 근거 key 가 없다)
- copy_service: max_faqs=20, 생성 뒤 _fill_faqs. 근거가 없거나 키가 없으면 LLM 없이 채우기만
- place_service.start_copy: 카탈로그가 있으면 fact 0건이어도 잡 생성(FAQ_UNGROUNDED 는 카탈로그 없는 업종만)
- SourceType.TEMPLATE=5(백엔드·shared·orval 모델). fact_service 규칙 4 로 fact 에는 못 쓴다
- faq_crud.expire_generated: TEMPLATE 도 재생성 때 내린다 — 새 fact 로 답이 생긴 주제에 옛 문의 안내가 남지 않게
- prompts/copy: fact 로 답할 수 있는 카탈로그 질문을 싣고 "한 문항 한 주제" 규칙(생성 FAQ 4건 중 3건이 묶여 있었다)
- shared selectAnsweredFaqs · jsonld · llms · prerender(↔ conftest) · seo_audit: 문의 안내는 FAQPage JSON-LD ·
  llms.txt · 고유 콘텐츠 계수 · FAQ 점수에서 뺀다 — 모든 펜션에 같은 문구라 세면 빈 사이트가 게이트를 통과한다
- site FaqSection: 문의 안내가 섞이면 "모두 사업자가 확인한 내용" 문구를 달지 않는다
- frontend FaqPanel "노출 N건 (문의 안내 M)" · notifyCopy 가 faq_fill 을 본다
- postgres-init: 컬럼 변경 없음(CHECK 없는 SMALLINT). 0012 + init.sql 에 generated_by·source_fact_ids COMMENT ON,
  0012 는 컬럼이 있을 때만(DO $$ IF EXISTS). init.sql 의 "비면 발행 게이트가 반려" 주석은 사실이 아니어서 고쳤다
- docs/DECISIONS.md 8절 · DATA_MODEL.md · DEVLOG.md

백엔드 664 passed(신규 test_faq_fill 10건 · test_copy_api 3건). 실패 2건은 이 변경 전 HEAD 에서도 같다:
test_rate_limit_closes_the_tap · test_사이트_디렉터리_밖의_thumbs_에_올린다
site·frontend·admin tsc 통과 · site vitest 63 passed · FaqPanel·collectNotify eslint 통과
로컬 실사업장(하늘물빛정원, fact 4건): 생성 4건 + 문의 안내 16건 = 20건, 질문 중복 0
0012: 새 DB(init.sql → migrate 규칙)와 로컬 DB 사본 양쪽에서 두 번씩 적용 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011yLDuinzgyCxmqAutE1tse
2026-09-14 17:05:43 +09:00
8920e9b9d0 feat(backend): fact 목록 응답에 intro/room_intro AI 요약 부착
/v1/place/{id}/fact/list 가 intro·room_intro fact 중 200자를 넘는 것만
Gemini 로 요약해 FactData.summary 에 실어 보낸다. 짧은 값은 API를
부르지 않고, DB에도 저장하지 않는다(응답 전용).
2026-09-14 17:03:40 +09:00
0500ab3a3f [feat] site/mockup: /s/stay 에 엽서 쓰기 · 새 군산 읽기를 넣는다 — 탭 전환 버그 수정
'오늘의 엽서'는 미리 써 둔 문구만 보여줬다 — 손님이 사진 고르고 직접 한마디를 써서
카드를 만들고 저장·공유까지 하게 바꿨다. 군산 읽기는 xlsx 원본 대비 2줄짜리 항목이
많아 부실했고(gunsan_365_story_db.xlsx 365행은 52개 주제를 10가지 각도로 복제한
것 — 그대로 실으면 같은 말이 반복된다), 3~5줄로 다시 쓰고 확인필요 배지 대신
네이버 검색 링크로 바꿨다. 그 과정에서 군산 읽기 탭에서 다른 탭(시간의 골목)을
누르면 화면이 사라지는 버그를 잡았다 — React 가 no-op setState 를 건너뛰면서
직접 건드린 DOM 속성(hidden·aria-selected)이 안 돌아오던 것.

- inject.js: 엽서 쓰기 섹션(사진 선택 → 캔버스 카드 → 저장/공유), 4줄 캡+글자 단위
  줄바꿈, 공유는 이미지+링크만(캡션 중복 안 보냄) · 군산 읽기 탭 5~6개 무작위 노출 ·
  탭 전환 시 React 미관리 속성(style.display)으로 전환하고 클릭된 탭에 상태를 직접
  강제해 no-op setState 버그 제거
- inject.css: 엽서 쓰기 버튼·썸네일 카로셀 스타일, 군산 읽기 확인필요 배지 제거,
  768~1023px 구간에서 하단 탭바가 푸터 마지막 줄을 덮는 것 막는 보정
  (SiteFooter.tsx 는 md: 768px 부터, MobileTabBar.tsx 는 lg: 1024px 부터 기준을
  잡아 그 사이가 비었다 — 렌더러 쪽 근본 수정은 별도)
- build_reading.py: 34꼭지 재작성(2줄짜리 21개를 3~5줄로), 출처를 네이버 검색
  링크로 통일, verified 필드 제거
- build_daily.py · dump_text.py: 위에서 뺀 verified 필드를 읽던 자리 방어 처리
- README.md: 1부(T7 군산 읽기 · T8 엽서 쓰기)를 실제 구현 가이드로 다시 씀 —
  히스토리·인용 없이 무엇을 만들어야 하는지만, 엽서는 AI/스키마 작업이 아니라
  UI 그대로 옮기면 되는 것임을 명시

검증: Playwright 로 탭 재전환·엽서 줄바꿈 재현 확인. 킹서버 반영 후
`docker exec o2o-web4ai-solution-site md5sum` 로컬-킹 일치 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYUb8ZfNiDd15tZRYrusCX
2026-09-14 17:01:40 +09:00
3342981bd2 feat(backend): Gemini 기반 소개문 요약 함수 추가
캔버스 미리보기에서 intro/room_intro 원문이 길 때 CSS로 자르는 대신
실제 문장을 줄여 보여주기 위한 첫 단계. DB에는 저장하지 않고 프로세스
메모리 캐시(sha256 키)로 같은 원문의 재호출만 막는다.
2026-09-14 17:01:00 +09:00
a517439c7d [fix] solution/frontend: 연동 카드를 준비 전에도 보여준다 — 숨기면 기능이 없는 것처럼 보인다
앱 자격증명(THREADS_*)이 없으면 카드를 통째로 숨겼다. 근거는 "누를 수 없는 버튼을 세우지
않는다" 였는데, **이 기능을 만든 사람조차 "연동 버튼이 아예 안 보인다" 고 했다**(2026-09-14).
만든 사람이 못 찾으면 사장님은 더더욱 못 찾는다 — 숨기는 것과 "아직 준비 중" 은 다른 말이고,
화면은 그 둘을 구별해 말해야 한다.

- 자리는 늘 보이고 **버튼만 비활성**이다. "연동 준비 중입니다. 열리면 여기서 계정을 연결합니다."
- 통째로 접는 경우는 하나만 남겼다 — 상태를 아직 못 읽었을 때(로그인 직후 한순간).
  그건 '준비 안 됨' 이 아니라 '모름' 이라 다르게 다뤄야 한다

검증: frontend tsc·eslint 통과. 번들에 새 문구가 실린 것과 `connection_enabled=false` 에서
비활성 상태로 그려지는 것을 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:29:31 +09:00
c8dc68536e [feat] solution: SNS 연동을 '내 사이트' 한 자리로 — 연결은 한 번, 게재는 사이트마다
연결 버튼이 발행 모달 안에 있었다. 그런데 계정은 `user × provider` 하나다(표도 그렇게 생겼다)
— 버튼이 사업장 화면에 있으면 사장님은 **업장마다 연결해야 하는 줄 안다.** 사장님 지적:
"여기 내 사이트에 sns 연동 하나 두고 연동해두고, 사이트 발행 후 게재하는 형식으로".
화면이 데이터 모양을 그대로 말해야 한다 — 연결은 한 번, 게재는 사이트마다다.

- `GET /v1/social/account` 신설: 연결 상태만 준다. 사업장을 고르지 않아도 답할 수 있어야 하는
  값인데, 지금까지는 `/place/{id}` 안에만 있어서 사이트를 하나 고르기 전에는 물어볼 수 없었다
- features/social/SocialConnectionCard: '내 사이트' 목록 위의 연동 카드
  (@핸들 · [연결] · [다시 연결] · [연결 해제]). 앱 자격증명이 없으면 **아무것도 안 그린다** —
  누를 수 없는 버튼을 세우면 사장님에게는 고장난 화면이고 우리에게는 문의가 된다
- SocialPanel(발행 화면)에서 연결·해제 버튼 제거. 대신 계정이 없으면 "막다른 문구"가 아니라
  **갈 곳**을 알린다 — [내 사이트] 로 보낸다. 연결 전에도 소개글 복사는 된다
- docs/SOCIAL.md: 사장님 흐름을 '연결(한 번) / 게재(사이트마다)' 로 다시 씀

검증: SNS 테스트 14건 통과 · frontend tsc·eslint 통과. 로컬에서 임시 자격증명으로 카드가
켜지는 것과 인가 URL 조립을 확인하고 값을 되돌렸다(지금은 connection_enabled=false 로 안 뜬다)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:14:11 +09:00
ecafa19d00 [feat] solution/backend,docs: Threads 계정 연동 — 연결 실패 이유를 남기고, 준비 절차를 적는다
자동 게재가 되려면 사장님이 자기 Threads 계정을 연결해야 한다. 연결 코드(인가 URL·코드 교환·
장기 토큰·암호문 저장·해제)는 이미 있었는데, **실제로 연동하려면 무엇을 해야 하는지**가
어디에도 없었고 실패했을 때 이유를 볼 방법도 없었다.

★ 콜백이 예외를 통째로 삼키고 있었다. 화면에는 `?social=failed` 만 뜨고 우리도 원인을 모른다 —
  키가 틀렸는지 · 쿠키가 안 왔는지 · state 가 만료됐는지 구별이 안 된다. 연결이 안 되는데
  로그에 아무것도 없는 것은 이 레포가 가장 싫어하는 종류다.
  → 서버 로그에는 남기고 화면에는 안 내보낸다(OAuth 응답·state 에 자격증명이 들어 있다).
    남기는 것은 예외 종류와 우리가 만든 사유 문자열뿐 — 토큰·code·state 는 찍지 않는다.
    사장님이 인가를 취소한 경우도 고장과 구별되게 따로 남긴다.

- router/v1/social/oauth: 실패 로그 추가(`[social] 계정 연결 실패: …`)
- tests: 가짜 Threads 서버로 **연결 왕복 전체**를 검증한다(코드 교환 → 장기 토큰 → debug_token
  권한 검증 → 저장 → 해제). 실제 연결은 Meta 앱 등록이 끝나야 시험할 수 있는데, 그때 실패하면
  우리 코드가 틀린 건지 앱 설정이 틀린 건지 구별이 안 된다 — 우리 쪽 왕복은 먼저 못 박는다.
  지키는 것 셋: 저장된 것은 암호문이다 · 권한 검증을 건너뛰지 않는다 · 해제하면 토큰이 지워진다
- docs/SOCIAL.md '연동 준비': Meta 앱 콘솔에서 할 일(제품 추가·리디렉션 URI·권한 둘·
  ★심사 전에는 테스터로 추가된 계정만 인가된다) · 사장님 클릭 흐름 · 실패 시 로그 읽는 법
- .env.example: SOCIAL_*·THREADS_*·ALIMTALK_* 항목과 각각의 "비면 무엇이 꺼지는가"

★ 로컬만으로는 연결을 끝까지 검증할 수 없다 — Meta 는 콜백 URI 를 https 로만 받는다.
  터널로 https 주소를 만들거나 킹서버에서 확인해야 한다. 문서에 적어 뒀다.
★ `SOCIAL_TOKEN_SECRET`(Fernet) 이 없으면 연결 기능 자체가 꺼진다. 이 키를 잃으면 저장된
  토큰을 복호화할 수 없어 전원 재연결이다 — 그 사실도 문서에 적었다.

검증: SNS 테스트 14건 통과(신규 1 — 연결 왕복). 로컬에서 키만 넣고 앱 자격증명이 없는 상태를
확인: connection_enabled=false 로 버튼이 안 뜨고, 강제로 불러도 409 SOCIAL_CONNECTION_DISABLED

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:08:22 +09:00
4221dad741 [fix] nginx: 승인 페이지 헤더가 try_files 에서 유실되던 것 — rewrite break 로 같은 블록에 머문다
/approve/ 블록에 Referrer-Policy·no-store·noindex 를 달아 뒀는데 **응답에 하나도 안 붙었다**
(실측 2026-09-14: 200 은 뜨는데 헤더만 없다). `try_files` 의 폴백은 내부 리다이렉트라 요청이
이 블록을 떠나 `location /` 로 다시 들어가고, 거기서 나가는 응답에는 이 블록의 add_header 가
적용되지 않는다. 승인 링크의 nonce 가 Referer 로 새고 검색 색인에 남을 수 있는 상태였다.

- `rewrite ^ /__spa-fallback.html break` 로 바꿨다. break 는 같은 블록에 머물러 헤더가 붙는다.
  승인 링크는 SPA 한 장이라 파일을 찾아 줄 일이 없다 — 곧바로 셸을 준다
- site.conf.example 과 로컬 site.conf 를 같이 고쳤다. ★ 운영 서버의 site.conf 는 gitignore 라
  example 을 고쳐도 따라오지 않는다 — 배포 때 이 블록을 손으로 옮겨야 한다

검증: `curl -D -` 로 Referrer-Policy: no-referrer · Cache-Control: no-store ·
X-Robots-Tag: noindex, nofollow 세 줄 확인. 승인 페이지가 빌더 셸로 뜨는 것도 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:56:33 +09:00
0e0f2cf038 [feat] solution,postgres-init,docs: SNS 게재 — 사장님이 누르면 쓰고, 승인받아, 사장님 계정으로 올린다
발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고 그건
검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [Threads에 알리기] 를 누르면 확인된 fact 로
짧은 글을 쓰고, 승인을 받아 사장님 개인 계정으로 올린다. 올린 글은 발행본 맨 아래에도 실린다.

★ 이 레포가 처음으로 ①외부에 쓰기를 하고 ②남의 계정 자격증명을 보관하고 ③되돌릴 수 없는
  행위를 한다. 아래 결정이 전부 여기서 나왔다.

승인을 다시 둔다 — 7절("승인 없이 나간다")의 예외다(DECISIONS 7-1). 기준은 문장의 참/거짓이
아니라 명의(사장님 계정의 발언) · 회수 가능성(없다) · 무엇이 주로 틀리나(문장이 아니라 링크 —
`_publish_target` 이 계산하므로 앞 게이트가 못 본다)다. 7절의 함정은 구조로 막았다:
시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고, 승인 경로가 둘(화면·알림톡)이며,
미승인은 EXPIRED 로 화면에 보이게 남는다.

★ 게시는 `domain` 이 확정된 사이트에만. 비면 슬러그가 상호명에서 파생돼(`_publish_target`)
  상호를 고치는 순간 주소가 바뀌고, 이미 올라간 글의 링크는 404 가 된다 — 그 글은 수정할 수 없다.
★ 승인은 GET 이 아니라 POST. 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을
  연다. 일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다.
★ 사진은 올리지 않는다 — 1-2 의 격리("나중에 필터로 뺀다")가 SNS 에서는 구조적으로 불가능하다.
  필터가 아니라 첨부 코드를 아예 만들지 않았다.
★ 게시는 기본으로 꺼져 있다(`SOCIAL_POSTING_ENABLED=0`). 플랫폼 계약과 1-4(해지 시 처리)
  결론을 확인한 뒤 사람이 연다 — 1-4 가 이 기능의 전제조건이 됐다.

플랫폼은 스레드다. X 는 URL 이 든 글에 요청당 $0.20 이 안내돼 있어 "계정 단위 고정비" 라는
처음 가정이 틀렸다(사이트마다 나가는 변동비다). 어댑터 경계는 두되 X 어댑터는 넣지 않았다.

- place_social_posts · owner_social_accounts 신설(init.sql + 0012·0013). 승인 대기는 잡이 아니라
  행의 상태다 — 잡으로 매달면 lease 만료로 DEAD 가 된다
- services/social_service · social_account_service · notify_service · external/{threads,alimtalk,social}
- router/v1/social — GET 은 상태를 바꾸지 않고, POST 가 링크·계정을 재검사한 뒤 CAS 한다
- 빌더 SocialPanel(발행 완료 화면) + 무인증 승인 페이지 `/approve/:postId`
- 발행본 SocialPostsSection — 정적 카드 + 원문 링크. 위젯·임베드 없음. 고유 콘텐츠 계수에서 제외
- nginx: `/approve/` 는 no-referrer · no-store · noindex + 액세스 로그 끔

밟은 함정 둘
- ORM 기본값에 쉼표가 딸려 들어갔다: `text("'[]',")` → `DEFAULT '[]', NOT NULL` 로 나가
  CREATE TABLE 이 통째로 실패. 운영 DB 는 init.sql 로 만들어져 안 드러나고 ORM 이 스키마를
  만드는 테스트 DB 에서만 터진다 — 09-10 의 `now()` 기본값 사고와 같은 자리다
- 승인 스윕이 1분 주기라 쓰기 커넥션을 계속 집어 들었다 → 5분. 이 스윕은 만료 표시와 중단 정리뿐이라
  분 단위 정밀도가 필요 없다

검증: 백엔드 645 passed / 5 failed(전부 환경 — 프론트 소스 부재·레이트리밋).
★ 테스트에 실제 API 키가 새면 BUILD 잡이 Suno·Perplexity 를 진짜로 부른다(실측: 한 파일 12분 →
키를 비우면 10초). 키를 비운 상태가 정상 실행 조건이다.
에디터 목록 대조(test_site_theme) 22건 통과 · tsc·eslint 통과 · vitest 62 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:46:44 +09:00
민헌
b2c8bb033e [feat] solution/backend,site: 발행 사이트 제목·keywords 메타에 SiteOntology 키워드 — 이 가게 자료로 거른 것만
숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를
받고, 이 가게의 확인된 자료로 거른 것만 `<meta name="keywords">` 와 제목 업종어 자리에 싣는다.
실측(2026-09-14, 스테이머뭄 프로필): 추천 10건 중 `군산 독채 마당 펜션`·`군산 독채 복층 펜션`·
`군산 커플 프라이빗 펜션` 이 status=ok 로 왔다 — SiteOntology 사실 필터는 수용 인원과 일부 시설만 본다.
사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다. 메타 태그와 제목은 AI 검색이
그대로 읽는 자리라, 키워드의 모든 낱말이 이 가게 자료에 있을 때만 싣는다.
SiteOntology 쪽 함정도 실측으로 막았다 — 없는 regionId 는 500(외래키), 해석 안 된 query 도 201 로
입력 문자열 검색 결과를 준다.

- services/external/site_ontology.py: publish(generate:false) → match 두 번 호출. 500 이면 지역 없이
  재시도, resolved 가 우리 place_id 가 아니면 실패로 본다
- services/seo_keywords.py: 스냅샷 → 프로필(있음=features · 없음=뺌 · 모름=unverified), 낱말 대조 거르기,
  업종어뿐인 단어 제외, 제목은 유형 레인 코어 중 시·군 이름을 품고 예약·추천이 없는 것. 숙박만
- services/build_service.py: 스냅샷 직후 호출해 snapshot["seo"] 에 싣는다(= 발행 기록). 실패해도 발행 계속
- services/site_payload.py · shared site-payload.ts: 선택 필드 `seo` — 옛 payload·목업은 그대로
- site/src/seo/meta.ts · head.ts: 제목 `<상호> · <대표 키워드>`(15자 미만이면 예전 제목), keywords 태그는
  키워드가 있을 때만(빈 태그를 만들지 않는다)
- config_models.py · .env.example: SITE_ONTOLOGY_URL — 비우면 호출하지 않는다
- docs/ARCHITECTURE.md 발행 파이프라인 · docs/DEVLOG.md

pytest tests/test_seo_keywords.py 16 passed · 백엔드 전체 650 passed(실패 2건은 이전부터:
test_rate_limit_closes_the_tap · test_사이트_디렉터리_밖의_thumbs_에_올린다)
site tsc·eslint 통과 · vitest 63 passed · frontend·admin tsc 통과
실제 발행 한 바퀴(로컬 SiteOntology :3100): 스테이머뭄 → `<title>스테이머뭄 · 군산 독채펜션</title>` +
keywords 9건, 렌더 게이트 불일치 0건. SiteOntology 없는 payload 는 제목·head 가 예전 그대로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBR4o9Nth3g4eoQEnL1iat
2026-09-14 14:38:24 +09:00
c07e2bddf4 Merge branch 'main' into feature/scheduler 2026-09-11 17:25:23 +09:00
4184cbe665 [feat] solution/backend: 업소를 여행 일정의 출발·복귀 정거장으로 넣는다 2026-09-11 16:56:05 +09:00
0c2b915973 [feat] solution/backend: 일정 하루 시각을 체크인·체크아웃 흐름으로 강제한다 2026-09-11 16:25:59 +09:00
69032a72c3 [feat] solution/backend: 일정 하루 시각을 체크인·체크아웃 흐름으로 강제한다 2026-09-11 16:14:44 +09:00
ab158be16f Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-11 14:40:07 +09:00
29c1a1f462 [fix] postgres-init: area_contents 상태 인덱스 0011 — init.sql 에만 있고 옛 DB 에 없던 것
킹서버 덤프 복원본에 0000~0010 을 돌린 DB 와 init.sql 로 세운 DB 를 비교하니 컬럼·표는 같고
idx_local_contents_status 하나만 새 DB 에만 있었다. 서버 DB 가 이 인덱스보다 먼저 세워졌다.

- 0011_area_contents_status_index.sql: init.sql 411행 정의 그대로, IF NOT EXISTS

검증: 적용 후 운영 DB 와 init.sql DB 스키마 비교로 확인한다(배포 절차에서)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEQ9auj65yJKk2MnWbtRqU
2026-09-11 14:25:43 +09:00
691b70d599 [fix] postgres-init: 회사 제거 마이그레이션 0000 추가 — 킹서버 DB 가 0005 에서 막히던 것
회사(테넌트) 제거(94551af, 09-08)는 migrations 폴더가 생기기 전 변경이라 init.sql 의 DO 블록으로만
있었고, 09-10 init.sql 재작성 때 사라졌다. 로컬 DB 는 이미 따라와 있어 드러나지 않았다.
실측(킹서버 2026-09-11): schema_migrations 가 없는 옛 구조 DB 에서 0005 의
`DROP SCHEMA company RESTRICT` 가 company.companies(3행) 때문에 실패 — 0001~0010 이 하나도 못 들어간다.

- 0000_drop_companies.sql: 94551af 의 블록 그대로 — owner_user_id 백필(회사의 가장 먼저 만든 계정)
  → 주인 없는 업장 삭제 → NOT NULL → places.company_id · users.company_id · companies 삭제.
  0005 보다 앞이어야 해서 0000. 이미 전부 적용한 DB 에는 마지막에 돌므로 전부 존재 검사로 감쌌다
- README: 번호 규칙의 예외 한 줄

킹서버 덤프 복원본 리허설: 0000~0010 11건 적용, 업장 32곳 owner_null 0 · 삭제 0,
행 수 보존(사진 255 · 사실 86 · 객실 222 · 사이트 22), 적용된 DB 에 0000 재실행 무해.
init.sql 로 세운 DB 와 스키마 비교 차이 1건(idx_local_contents_status) — 다음 커밋

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEQ9auj65yJKk2MnWbtRqU
2026-09-11 14:25:13 +09:00
548ec5c0c1 Merge branch 'fix/sitemap-noindex' — 사이트맵·목록에서 noindex 페이지 제외
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEQ9auj65yJKk2MnWbtRqU
2026-09-11 14:09:16 +09:00
4216a05fca [fix] site: 사이트맵·목록·llms.txt 에서 noindex 페이지 제외 — 목업 복제본이 /s/stay 를 검색에서 밀어낸 것
/s/stay 가 "스테이머뭄" 구글 검색에서 통째로 빠졌다. out/s/ 에 목업·백업(stay2 · stay3 ·
stay.old)이 운영본과 제목·본문이 같은 채(단어 87%) 각자 자기를 canonical 로 가리키고 있었고,
사이트맵·/s 목록이 디렉토리를 훑어 이들을 전부 구글에 제출했다 — 같은 글 여러 벌 중 구글이
하나만 고른다. 목업 셋은 운영 볼륨에서 noindex 로 바꿨고(2026-09-11), 이 커밋은 그런 페이지가
다시 제출되지 않게 한다.

- seo/directory.ts: readBakedNoindex — 구운 HTML 의 robots 메타를 읽는다. 슬러그 이름(.old)으로
  거르지 않는다 — 페이지 자신의 선언이 유일한 출처다
- prerender.ts writeRootMachineFiles: noindex 페이지를 사이트맵·/s 목록·루트 llms.txt 에서 뺀다.
  남겨 두면 서치콘솔이 "제출된 URL 에 noindex" 를 계속 띄운다

vitest directory.test.ts 8 passed · tsc·eslint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEQ9auj65yJKk2MnWbtRqU
2026-09-11 14:08:48 +09:00
8a2964ebf7 [feat] solution: 추천 일정 카드에 출발지(업소명·출발 시각) 한 줄을 얹는다 2026-09-11 14:07:17 +09:00
6760b38fec Merge branch 'main' into feature/scheduler
# Conflicts:
#	solution/backend/services/build_service.py
2026-09-11 14:03:27 +09:00
6fbd0904fa [feat] solution/backend: 일정 컨셉당 2개씩, 총 10개로 확대 — 컨셉 축은 그대로 5개 2026-09-11 13:31:05 +09:00
ae4953cf89 Merge branch 'main' into feature/scheduler 2026-09-11 13:12:32 +09:00
ea4947bb0a [fix] solution/backend: 스냅샷 지역정보에 itineraries 키가 추가된 것을 테스트에 반영 2026-09-11 13:03:41 +09:00
9d4feebb86 [feat] solution: 캔버스도 서버가 만든 일정을 본다 — 발행본과 같은 목록 2026-09-11 11:58:09 +09:00
1dd2dc773e [feat] solution/backend: 일정 생성을 잡에 붙인다 — 에디터 요청을 붙잡지 않는다 2026-09-11 11:51:39 +09:00
8dba691274 [feat] solution/backend: 일정을 스냅샷에서 읽는다 — 즉석 계산을 잠시 쉬게 한다 2026-09-11 11:49:51 +09:00
2824701030 [feat] solution/backend: 일정 생성 — 없는 기간만 부르고 같은 업체는 그대로 쓴다 2026-09-11 11:44:09 +09:00
46e4d091a4 [fix] solution/site: 헤더 플레이어를 /s/stay 목업과 같은 것으로 — 주입 CSS·마크업을 그대로 옮긴다
발행본에 붙인 플레이어가 목업(/s/stay)과 다른 물건이었다. 목업은 카세트 · 현재 곡명 ·
재생 · 목록(음표) 네 자리에 크림색 패널·LP판·EQ 막대인데, 새로 만든 쪽은 lucide 아이콘
하나에 흰 드롭다운이었다. 같은 제품에서 두 화면이 서로 다른 플레이어를 갖고 있었다.

목업 화면은 사장님 확인을 여러 번 거친 것이다 — 카세트를 앞에 세운 것("전혀 뮤직플레이어
같지가 않아"), 목록 아이콘을 햄버거에서 음표로 바꾼 것("메뉴 버튼이랑 헷갈림"), 셔플 제거,
자동재생 제거("그럼 자동 빼"), 모바일 폭(좌우 8px). 새로 그리면 그 합의가 통째로 풀린다.
→ `scripts/mockup/inject.css` 를 **값 그대로** 옮기고, `inject.js` 의 DOM 을 같은 구조로 짰다.

- site/sections/song-player.css: 목업 주입 CSS 이식(w4d-* 클래스·--tpl-* 변수 그대로).
  앞으로는 목업이 아니라 이 파일이 단일 출처다 — 목업은 손으로 만든 한 장이고
  발행되는 모든 사이트는 이 컴포넌트를 쓴다
- SongPlayer: 카세트(재생 중 릴 회전) · 현재 곡명 · 재생/멈춤 · 목록 네 자리.
  패널은 fixed 로 헤더 아래에 붙이고(sticky 헤더라 스크롤해도 자리가 같다) 좁은 화면에서는
  좌우 8px 만 남기고 펼친다. 줄은 LP판 + 제목/가수 + 시간, 재생 중인 줄은 판 대신 EQ 막대
- 시작 곡만 무작위, 그다음은 순차(목업과 같은 규칙). ★ 무작위는 마운트 후에 고른다 —
  서버 렌더에서 뽑으면 HTML 과 하이드레이션 결과가 달라져 화면을 통째로 다시 그린다
- 패널은 여전히 hidden 여닫이다. 조건부 렌더면 닫힌 동안 제목·가사가 DOM 에 없어
  크롤러가 못 읽는다(오디오 안의 말은 어차피 못 듣는다)

검증: tsc --noEmit · eslint · vitest 55 passed. 재굽기 후 /s/스테이머뭄-99a887f8 에서
w4d-mini/tape/now/play/toggle · panel(head·head-n·item·mark·disc-sm·eq·info·t·sub·time)
· lyrics-box 전부 확인, 번들 CSS 에 w4d 규칙 포함 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:36:16 +09:00
0f3861c1e4 [feat] solution/backend: 일정 읽기·덮어쓰기 — 업장×기간 한 행 2026-09-11 11:21:46 +09:00
38bb4be9e5 [feat] solution,postgres-init: 발행하면 이 숙소의 노래가 한 곡 생긴다 — 가사 Gemini · 작곡 Suno
/s/stay 시안의 헤더에는 노래 플레이어가 있는데 그건 손으로 채운 목업이라, 새로 발행한
사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다.

★ 발행이 노래를 기다린다. BUILD 잡이 스냅샷을 뜨기 **전에** 곡을 만든다 —
  먼저 굽고 나중에 붙이면 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다.
  값은 발행이 30~40초(실측, 상한 5분) 늦어지는 것이고 그건 감수한다.
  단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이
  실패하면 노래 없이 발행되고 사유가 빌드 로그와 place_songs.last_error 에 남는다.

★ 가사를 우리가 쓴다. Suno 에 주제만 던지면 가사를 저쪽이 짓고, 거기엔 이 숙소에 없는
  것(수영장·조식)이 섞이는데 검증할 방법이 없다 — 다른 모든 문장은 확인된 fact 로만 쓰면서
  노래만 지어낸 말을 싣는 꼴이다. 소개문과 **같은 재료**로 Gemini 가 쓰고 Suno 는 곡만 붙인다.
  가사에 ground_check 는 걸지 않는다(정서는 fact 로 대응되지 않는다). 대신 프롬프트가
  없는 시설·숫자를 말하지 말라고 못 박는다 — 요금을 노래에 넣으면 틀렸을 때 고쳐 부를 수 없다.

★ Suno 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후엔 재생되고 몇 주 뒤 조용히
  죽는다. mp3 를 받아 보관하고 우리 경로(/s/<slug>/<song_id>.mp3)만 내보낸다.
★ 콜백이 아니라 폴링이다. 우리 백엔드는 Suno 가 닿을 수 있는 주소가 아니라, 콜백을 믿으면
  "요청은 성공했는데 결과가 영영 안 옴" 이 된다.

- services/external/suno.py: 작곡 요청 + record-info 폴링(10초 간격·상한 5분) + 내려받기
- services/external/gemini_text.generate_song · prompts/song.py: 가사·제목·장르
- services/song_service.py: 재료 → 가사 → 작곡 → 파일 보관. ensure_song 을 빌드가 부른다
- build_service: publish 일 때만 ensure_song 을 먼저 부르고 그 뒤 스냅샷(미리보기는 안 만든다 — 유료)
- place_songs 표 신설(init.sql + 0010 마이그레이션 + ORM). 검증 상태가 없다 —
  수집한 사실이 아니라 창작물이라 "맞는가" 가 아니라 "만들어졌는가" 만 묻는다(SongStatus)
- snapshot·site_payload·shared: READY 인 최신 한 곡만 싣는다. audioUrl 은 우리 경로다
- prerender: songs/ 의 파일을 사이트 디렉토리로 복사하고 **지난 발행의 곡은 치운다**
  (발행마다 새 곡이라 안 치우면 1MB 짜리가 쌓이고 블롭에도 그대로 올라간다)
- site/SongPlayer: 헤더의 작은 플레이어. 자동 재생하지 않고, 곡이 없으면 아무것도 안 그린다.
  패널은 hidden 으로 여닫는다 — 조건부 렌더면 닫힌 동안 제목·가사가 DOM 에 없어 크롤러가
  못 읽는다(오디오 안의 말은 어차피 못 듣는다)
- azure_static: .mp3 content-type 과 immutable 캐시. 블롭 업로드는 발행이 사이트째 한다 —
  업로더를 하나 더 두면 같은 컨테이너에 경로·캐시·정리 규칙이 두 벌 생긴다
- compose: solution/site/songs 볼륨. .env.example 에 SUNO_API_KEY·SUNO_CALLBACK_URL

검증: 실제 발행(스테이,머뭄 v15) — 가사 154자 $0.0014 → 작곡 40초 → 1.98MB → 스냅샷(노래 1)
→ 발행 완료. /s/스테이머뭄-99a887f8 200, mp3 200 audio/mpeg, HTML 에 제목·가사·주소 확인,
지난 곡 404. tsc --noEmit · eslint · vitest 55 passed(신규 4) · 백엔드 관련 188 passed
(실패 5건은 전부 컨테이너 환경 유입 — 프론트 소스 부재·SITE_PUBLIC_HOST)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:21:16 +09:00
d80877fe75 [fix] postgres-init: 일정 표 마이그레이션을 0011 로 옮긴다 — FAQ 브랜치와 번호가 겹친다 2026-09-11 11:13:21 +09:00
703f831273 [fix] site/scripts: 끼니 간격·체류 대비 이동을 규칙으로 — 섬 코스가 103분 만에 두 번째 상
사장님 지적("1시간 30분 걸려서 갔는데 짧게 머문다")을 전수로 다시 셌다.
대상은 선유도가 아니었다(150분). 섬에서 돌아오는 길의 **끼니 간격**이다 —
늦은 점심 15:47 종료, 저녁 17:30 시작으로 103분이다. 늦은 점심 창(≤15:00)과
저녁 창(≥17:30)이 둘 다 합법이라 규칙 14종을 그냥 통과했다. 창 밖에 규칙이 따로 필요했다.

- build_itinerary.py: MEAL_GAP=180. `_window_of()` 가 앞 끼니 종료 + 3시간 뒤로 다음 끼니
  창을 민다. 섬 2박3일 둘째 날이 쉼 168분으로 늘고 저녁이 18:47 로 밀렸다
- build_itinerary.py audit(): 규칙 15(끼니 간격) · 16(볼거리는 편도 이동보다 오래 머문다)
  추가. 16은 **끼니를 뺀다** — 밥집은 목적지가 아니라 돌아오는 길에 들르는 자리다
- audit_schedule.py 신설: 구워진 payload 를 표로 펴고, 만들 때 규칙이 **안 보는** 논리
  12가지(L0~L11)를 센다 → SCHEDULE.md
- README: 규칙 14종 → 16종. 장소 대장 프롬프트에 "멀리 있는 곳 근처 식당도 넣어라" 추가 —
  규칙만 고치면 다음 지역에서 또 난다
- README: 일정 프롬프트 위치 정정. shared/section-prompts.ts 가 아니라
  frontend/.../canvas/dataSpec.ts:556 이고, video·event·itinerary 셋은 **빌더에만** 있어
  서버 생성 잡이 만들 줄 모른다(daily 와 같은 사고). 그 프롬프트가 모델에게 시각 계산을
  시키고 있는 것도 적었다 — 시연본 첫 판이 망가진 직접 원인이다
- README: 목업을 지울 때 1부를 docs/ 로 옮기라는 안내

검증: 규칙 16종 위반 0건 · audit_schedule 21/21 검수완료 · audit-all.mjs 34/34

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:10:16 +09:00
18e86a0d12 [feat] solution: 일정을 담을 자리 — 업장·기간당 한 행 2026-09-11 11:09:56 +09:00
b86a055278 [docs] site/scripts: 지침을 README 한 장으로 합치고 일회용 스크립트를 지운다
README · SECTIONS · PROMPTS · REVIEW 넷으로 흩어져 있어서 무엇을 먼저 볼지 알 수 없었다.
특히 "실제 구현하는 사람이 어떤 파일을 열어야 하나" 가 어디에도 없었다 —
목업 폴더가 사양서라는 것과, 코드는 shared 계약 3개 + backend 서비스 4개 + site/src
섹션에서 고친다는 것.

- README.md 한 장으로 합침. 1부 실제 구현(참고할 파일 · 섹션 22개 현황 · 할 일 T1~T6 ·
  프롬프트 전문 6종) / 2부 목업 고치기(굽기·배포·규칙 14종·주입분) / 3부 공통(보고 규칙 ·
  틀렸던 방식 · 파일 목록 · 지울 것 · 남은 판단)
- SECTIONS.md · PROMPTS.md · REVIEW-2026-09-11.md 삭제 — 내용은 전부 README 로
- 일회용 probe 스크립트 24개 삭제. 남긴 것은 audit-all · rails-test · kingcheck 셋
- scripts/mockup/.gitignore 신설 — 중간 산출물(stay-payload-new.json)

검증: node audit-all.mjs 34/34

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:58:21 +09:00
a61d9a278c [feat] solution/backend: 일정 응답 해석 — 같은 정거장 집합인 코스는 버린다 2026-09-11 10:56:47 +09:00
ea1daae576 [fix] site/scripts: 시연본을 레포 안에서 자급자족하게 — 다른 기계에서 흰 화면이던 것
구워진 index.html 이 미러 사진 61장(`/assets/mirror/*`)과 번들 css/js 를 루트 절대경로로
가리켰다. 그 파일들은 레포가 아니라 도커 볼륨에만 있어서, 레포만 받은 사람은 흰 화면을 본다.
번들은 더 나쁘다 — 파일명이 콘텐츠 해시라 그 기계에서 구운 해시가 다르면 404 다.
실측: 구운 html 이 /assets 를 63곳 가리키고 있었다.

- img/mirror/ 61장 · vendor/ 2개를 레포로 끌어들였다
- patch_stay.py: 경로를 `/s/stay/img/mirror/` · `/s/stay/vendor/` 로 바꿔 박고,
  레포에 없는 자산이 있으면 **빌드를 멈춘다**. 끝에 `/assets` 잔재가 남아도 멈춘다
- audit-all.mjs: 히어로 사진 검사를 새 경로로
- SECTIONS.md: 설명서에서 **작업 지시서**로 다시 씀. 프롬프트를 여기 다시 적지 않고
  PROMPTS.md 의 절을 가리킨다(일정은 §3-1/3-2/3-3 세 단계다). 제품 쪽 짝 파일 표 추가
- README.md: "보고 규칙" 절 신설 — 실행하지 않은 것을 됐다고 쓰지 않는다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:51:46 +09:00
a5b8701f8b [fix] solution/backend: conftest 를 되돌린다 — 테스트가 안 돈 건 DB 비밀번호 탓이었다 2026-09-11 10:47:33 +09:00
a134805b3f [docs] site/scripts: 섹션 22개별 "목업은 무엇으로 만들었고 제품은 무엇으로 만드나"
빌더·백엔드에서 이 시연본을 실제 생성 파이프라인으로 옮기려는 사람이,
화면을 보고 역산해야 하는 상태였다. 시연본은 payload 가 없는 목업이라
"화면에 있는 것"과 "제품이 만들 수 있는 것"이 갈라져 있는데 그 간격이 어디에
얼마나 있는지 적힌 곳이 없었다.

섹션마다 값이 오는 payload 자리 · 계약 유무 · 프롬프트 위치 · 목업이 손으로 한 것을
적고, 계약에 아예 없는 셋(히어로 캐치프레이즈 · 자작곡 플레이어 · 일정 규칙)은
추가할 타입과 프롬프트 초안까지 넣었다.

- SECTIONS.md 신설: 22개 섹션표 · 새로 만들어야 할 6가지 · 프롬프트 작성 규칙 · 검증
- README.md: 제품으로 옮기는 사람을 SECTIONS.md 로 보낸다. 감사 항목 수 33 → 34 정정

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:42:33 +09:00
411a4e7d55 [feat] solution/backend: 일정 프롬프트 — 컨셉을 모델에게 맡기지 않는다 2026-09-11 10:41:00 +09:00
f0b5c444bc [chore] site/scripts: /s/stay 시연본 자산 — 사진 45장·음원 5곡
2026-09-07 에 목업 자산이 통째로 404 가 났을 때, 복구한 곳은 서버가 아니라
`stay-mockup` 워크트리였다. 지금 이 사진과 음원은 이 맥과 킹서버 볼륨에만 있다 —
컨테이너를 지우면 같이 간다. 자산 커밋을 따로 떼는 이유는 나중에 히스토리에서
덩치를 걷어낼 일이 생겨도 이 커밋 하나만 건드리면 되기 때문이다.

- img/ 28장: 히어로·소개·객실(야놀자 등록본 A동 12·B동 10)·정거장
- img/people/ 17장: 위키백과 문서 사진. 자유 라이선스가 확인된 것만이고
  저작자 표시를 화면에 남긴다(라이선스 조건이라 지우면 안 된다)
- audio/ 5곡: 사장님이 직접 만든 곡. 헤더 미니 플레이어가 파일로 재생한다
  (프로토타입이라 CDN 을 쓰지 않는다 — nginx `^~ /s/` 가 Range 206 으로 준다)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:29:06 +09:00
43ba33dd65 [feat] site/scripts: /s/stay 시연본 조립 도구 일습 — 일정 생성기·주입분·검증
이 목업은 payload 가 없어 프리렌더 재굽기 대상이 아니다 — `index.html` 한 장이 유일본이고
자산이 지워지면 사람이 되돌려 넣어야 한다(CLAUDE.md 의 ★★ 함정). 지금까지 그 한 장을
이 맥에서만 만들고 있었다. 다른 사람이 이어받을 수 있도록 재료를 전부 올린다.
`build/index.html` 은 생성물이라 이그노어 그대로다 — `patch_stay.py` 로 다시 나온다.

- build_itinerary.py: 테마 21개 일정 생성. 좌표에서 이동시간을 계산하고(도보 4km/h,
  1.5km 초과는 차 25km/h + 주차 5분) 입·퇴실·끼니 창을 맞춘다. 규칙 14종 감사가
  **빌드 안**에 있어 하나라도 어기면 payload 를 쓰지 않고 멈춘다 — 검사가 빌드 밖에
  있던 동안 뼈대를 고칠 때마다 안 보는 규칙이 생겼다(2026-09-11 REVIEW)
- build_story.py: 노래 25곡·인물 57명. 사진은 위키백과 문서 pageimages 만 믿는다
  (이름 검색은 동명이인을 끌고 온다 — 이수현→걸그룹, 박성현→골퍼)
- patch_stay.py: 캐치프레이즈 100개·자작곡 5곡·객실 사진(A동 12/B동 10)을 넣고
  payload 를 갈아 끼운 뒤 inject.css/js 를 `</body>` 앞에 주입해 index.html 을 짠다
- inject.js/css: React 가 다시 그려도 살아남아야 하는 다섯 가지(캐치프레이즈 순환·
  헤더 미니 플레이어·지도 링크·사진 저작자 표시·카로셀 제어). `#root` 밖에 둔다
- audit-all.mjs / rails-test.mjs: 실제 브라우저로 34종 검사, 레일 13개 자동 넘김 전수
- README.md: 이어받는 사람이 먼저 읽는 문서. 배포·함정·§7 "내가 틀렸던 6가지"
- PROMPTS.md / TEXT.md / REVIEW-2026-09-11.md: 문구 생성 프롬프트 · 전체 텍스트 · 검수 보고

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:28:52 +09:00
4d5a77453b [fix] solution/backend,docs: LLM 이 쓴 문장은 승인 없이 나간다 — 소개문이 생성되고도 영영 빈칸이던 것
실측(2026-09-10, 힐튼 가든 인 서울 강남): 로그는 `[copy] 소개문 O` 이고 DB 에도 문장이
있는데 발행본의 소개는 빈칸이었다. status=1(UNVERIFIED) 이라 스냅샷이 담지 않았고,
그 자리를 fact 로 조립한 한 줄("서초구에 있는 …입니다. 체크인 15:00.")이 대신 채워
화면만 보면 생성이 실패한 것처럼 보이지도 않았다.

승인이 안 된 이유는 승인할 화면이 없어서다 — 수집 확인은 07:29 에 끝나는데 소개문은
07:31 에 도착한다. 사장님은 그 화면을 이미 지나간 뒤다.

게이트는 뒤가 아니라 앞에 둔다: 입력이 확인된 fact 뿐이고(근거가 없으면 유료 호출조차
하지 않는다), 근거 없는 FAQ 는 저장되지 않는다. 이미 승인된 사실로 쓴 문장을 한 번 더
승인받는 것은 같은 사실을 두 번 승인하는 일이다.

- fact_service: LLM 출처는 후보가 아니라 노출값으로 앉힌다. API·CRAWL 은 그대로 후보다
- fact_service: 사장님이 고친 문장(CORRECTED)은 LLM 이 못 덮게 잠금을 **명시적으로** 건다.
  지금까지 이 보호는 "자동 출처는 노출값 경로로 못 간다" 는 경로가 대신 해 주고 있었다 —
  LLM 만 경로를 바꾸면 그 보호가 조용히 사라진다(절대규칙 6)
- copy_service: 생성 FAQ 를 VERIFIED 로 저장
- faq_crud: 재생성 대상을 status 가 아니라 generated_by 로 가른다. 생성분이 VERIFIED 로
  들어가면 status 로는 사람이 손댔는지 알 수 없다 — 그대로 뒀다면 재생성이 옛 FAQ 를
  못 내려 같은 질문이 쌓인다. 반려(REJECTED)한 것은 그대로 둔다
- tests: 잡을 하나만 처리하면 지역 이야기 잡에 밀린다 — process_one → drain
- DECISIONS 7절 신설, 6-2 의 "FAQ 에는 넓히지 않는다" 를 결론과 함께 고침. DEVLOG 추가

검증: fact·copy·faq 35 passed(신규 2건 — LLM 문장이 승인 없이 노출값이 되는지 ·
CORRECTED 를 못 덮는지). 전체 577 passed / 8 failed, 8건은 전부 컨테이너 환경변수 유입
(.env 키 · 프론트 소스 부재 · SITE_PUBLIC_HOST)이고 코드 회귀가 아니다.
운영 재기동 후 힐튼으로 재생성: intro status=3 · FAQ 6건 VERIFIED · 옛 7건 EXPIRED 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:03:44 +09:00
fc67962efc [fix] solution/backend: ORM 의 TIMESTAMPTZ 기본값을 init.sql 과 같은 now() 로 — 테스트 잡이 미래에 박히던 것
기본값이 `(now() AT TIME ZONE 'utc')` 였다. timestamptz 에 이걸 쓰면 값이 시간대 없는
벽시계로 떨어졌다가 세션 시간대로 다시 해석돼 **서버 시간대만큼 미래로 밀린다.**
실측(2026-09-10): 잡의 run_after 가 7시간 뒤로 박혀 claim(`run_after <= now()`)에 영영
안 걸렸다. 워커가 잡을 하나도 못 집어 COPY 관련 테스트가 "잡이 PENDING 인 채" 무더기로
실패했고, 원인이 코드가 아니라 스키마라 읽히지 않았다.

운영은 멀쩡했다 — 운영 DB 는 init.sql(`DEFAULT now()`)로 만들어지고, 이 기본값은 ORM 이
스키마를 만들 때만, 즉 테스트 DB(conftest)에서만 쓰인다. 스키마는 init.sql 이 단일 출처다.

- models.py: `_utc_now_sql()` → `now()`. 갈라지면 어떻게 되는지 주석에 실측과 함께 남긴다

검증: 이 한 줄로 fact·copy·faq 35건이 전부 통과로 바뀐다(그 전 4건 실패)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:03:21 +09:00
25f7e5c0c8 Merge branch 'feature/stay-parity' 2026-09-10 16:15:29 +09:00
c8cf704217 [fix] solution/backend: 히어로 아래 한 줄이 소개문 세 문장을 이고 있었다
`narrative.summary` 는 계약이 "요약 **한 문장**"이라 적어 둔 자리인데(shared/site-payload.ts)
첫 **문단**을 통째로 넣고 있었다. 그 값은 두 곳으로 나간다 — 히어로 아래 한 줄과
meta description. 그래서 히어로가 세 문장을 이고 서고(실측 2026-09-10), 검색 결과에서는
설명이 중간에 잘렸다.

- site_payload._first_sentence: 첫 문장만 뗀다. **문장을 새로 생성하지 않는다** —
  있는 글에서 자를 뿐이다(요약을 또 생성하면 근거 검사를 한 번 더 통과해야 한다).
  숫자 사이의 점은 자르지 않는다(1.5km) — 뒤가 숫자면 문장 끝이 아니다.

★ 곁에서 확인한 것 — 생성기가 만든 meta_description 이 **버려지고 있다.**
  copy_service 는 소개문과 함께 50~120자짜리 메타 요약을 만들지만, 업종 스키마에
  `meta_description` 필드가 없어서 "allow_llm 이 아닌 필드" 로 조용히 건너뛴다
  (lodging.json 의 allow_llm 은 intro·room_intro 둘뿐). 지금은 첫 문장으로 대신하지만,
  스키마에 자리를 만들면 생성기가 쓴 요약이 그대로 meta description 이 된다 — 별건이다.

검증: 재발행 후 히어로 한 문장 · meta description 동일. test_snapshot 통과
(site_theme 의 섹션표 parity 4건 실패는 이 변경 전 기준선과 같다).
2026-09-10 15:29:42 +09:00
868127a69a [fix] solution/site: 예약 목업의 날짜를 달력으로 — 14칸 스트립을 걷어낸다
날짜를 한 줄로 펼쳐 놓으니 예약 화면으로 안 읽혔다. 두 주만 보이는 것도 문제였다 —
손님이 다음 달을 잡으려면 방법이 없었다.

- 월 단위 그리드로 바꿨다. 요일 머리(일~토)를 두고 1일 앞을 빈 칸으로 채워 **열을 맞춘다** —
  달력은 열이 맞아야 달력이고, 어긋나면 그냥 숫자 목록이다
- 이전·다음 달. 오늘이 든 달이 하한이고 앞으로 두 달까지다(MONTH_SPAN).
  ★ 상한을 두는 이유: 우리는 빈 방을 모른다. 반년 뒤까지 열어 두면 손님은 그 날짜가
    열려 있다고 읽는다 — 모르는 것을 넓게 열어 두는 쪽이 더 나쁜 거짓이다
- 토·일은 요일 머리와 함께 구분한다. 장식이 아니라 주말 요금이 붙는 날이라는 정보다
- **고를 수 없는 날은 지난 날짜뿐이다.** '마감'·'잔여' 는 여전히 만들지 않는다 —
  우리는 그 값을 모르고, 지어내면 손님이 그걸 보고 다른 날을 고른다

★ 서버 렌더 게이트는 그대로다. '오늘' 을 브라우저에서 정하므로 정적 HTML 에는 달력이
  굽히지 않는다 — 한 달 뒤 크롤러가 지난 날짜를 예약 가능일로 읽는 일이 없다.

site tsc·eslint 통과 · vitest 51 passed(SSR 이 날짜를 굽지 않는지 보는 기존 검사 포함).
2026-09-10 15:24:21 +09:00
a7fcc14e7d [feat] solution: 인물 사진은 위키미디어에서 · 사진 없는 엽서는 싣지 않는다
시안과 나란히 놓으니 세 가지가 달랐다(2026-09-10 실측).
① 엽서 네 장이 **같은 사진**이었다 ② 그 네 장이 전부 같은 박물관의 관람안내(주소·휴관일·
운영시간)였다 ③ 인물 열전은 사진이 한 장도 없었다.

★ 인물 사진 — 위키미디어 공식 API (services/external/wikimedia.py)
  공공데이터는 관광지 사진을 주지 사람 얼굴을 주지 않는다. 인물 사진이 공개돼 있으면서
  **재게시 권리를 기계가 읽을 수 있게** 알려주는 곳은 사실상 위키백과·위키공용뿐이다.
  - 크롤링이 아니라 MediaWiki API 다. 페이지를 긁어 파싱하지 않는다
  - 권리 판정을 여기서 끝낸다: 위키에는 자유 저작물만 있지 않다 — 인물에는 특히 '공정 이용'
    (비자유) 파일이 섞이고, 그걸 발행본에 실으면 상업적 이용이라 바로 침해다.
    PD·CC0·CC BY·CC BY-SA 만 통과시키고, NC·ND·fair use·판정 불가는 버린다
  - 통과한 사진에는 **출처 표시가 따라붙는다**(CC BY 계열의 조건). imageCredit 이 그 값이고
    화면에 찍는다 — 표시하지 않을 거면 애초에 쓰지 않는다
  - 실측: 10명 중 2명(전봉준·신석정류)만 자유 저작물이 있다. 나머지는 없는 것이 정답이고
    그 자리는 렌더러가 이니셜로 세운다. 비슷한 이름의 다른 사람 사진을 붙이는 게 더 나쁘다

★ 사진이 본체인 종류는 사진 없는 항목을 버린다 (_IMAGE_REQUIRED_KINDS = postcard)
  엽서는 앞면 사진이 본체다. 없으면 뒷면만 남아 빈 카드로 보인다. 연표는 다르다 —
  활자만으로도 레일에 서므로 버리면 오히려 구멍이 난다. 실측: 12건 중 6건이 빠졌다

★ 같은 사진을 두 번 쓰지 않는다
  네 항목이 같은 시설을 말하면 검색이 같은 사진을 네 번 준다. 두 번째부터는 없는 것으로 친다

★ 프롬프트(shared 한 벌, export 포함)
  - postcard: "같은 대상을 두 번 쓰지 않는다" · "운영시간·휴관일·주소·요금은 엽서에 적지
    않는다 — 그건 이용 정보지 엽서 문장이 아니다" · place 가 사진을 찾는 열쇠임을 명시
- shared/section-data: PeopleItem·ChronicleItem·PostcardItem 에 imageCredit 추가
- site: SourceLine 이 사진 출처를 함께 찍는다(글 출처가 없어도 사진 출처만으로 한 줄 선다).
  엽서는 사진 바로 아래에 따로 찍는다 — 앞면이 사진이라 거기 붙는 게 맞다

실측(전북 군산시) 발행본: 인물 2장 · 엽서 6장(전부 고유) · 연표 3장(전부 고유),
사진 11장 모두 출처 표시. site vitest 51 passed · story·snapshot 23 passed.
2026-09-10 15:19:23 +09:00
56ed951a6e [feat] solution/backend,shared: 지역 이야기에 사진을 붙인다 — 수집하는 그 자리에서 함께
지역 이야기(연표·엽서)가 활자만으로 서 있었다. 계약(`shared/section-data.ts`)과 렌더러는
`imageUrl` 을 이미 받는데 **아무도 채우지 않았다** — 생성 프롬프트가 사진을 묻지 않고,
붙이는 코드도 없었다.

★ 이미 DB 에 있는 사진을 가져다 쓰지 않는다. 그건 "이 항목의 사진" 이 아니라 "마침 우리가
  갖고 있던 사진" 이고, 엉뚱한 장소가 그 해의 사진으로 붙는다. **이야기를 만드는 그 순간
  그 대상의 이름으로** 찾아온 것만 쓴다.

★ 사진을 모델에게 묻지 않는다. 모델이 주는 이미지 주소는 대개 존재하지 않거나 남의
  저작물이다. 공공데이터(TourAPI)가 그 대상의 사진으로 준 것만 쓴다.

- external/tour_api.find_image: 이름으로 사진 한 장. 권리 판정은 이 파일의 기존 규칙 그대로
  공공누리 **Type1·Type3 만**(발행본은 상업적 이용이라 Type2·Type4 는 못 싣고, 유형을
  모르면 버린다). searchKeyword2 는 전국에서 이름만 맞으면 주므로 **주소에 지역 토막이
  없는 결과는 버린다** — '군산항' 을 찾다 다른 지역 동명 시설이 붙으면 그 사진은 이 이야기와
  아무 관계가 없다. 실패하면 None 이다(사진 한 장 때문에 생성을 실패시키지 않는다)
- story_service._attach_images: 연표(place→title) · 엽서(place→postmark)만 찾는다.
  **인물은 넣지 않았다** — 공공데이터는 관광지 사진을 주지 사람 얼굴을 주지 않는다.
  열 명을 찔러야 0건이고, 그 자리는 렌더러가 이니셜로 세우도록 이미 설계돼 있다
  (PeopleItem.imageUrl 주석). 가요·퀴즈도 시안에 사진이 없다. 종류당 6건 상한
- shared/section-prompts(chronicle): "찾아갈 수 있는 자리가 있으면 **반드시** 적는다" 를
  더했다. 기존 문구가 "없으면 뺀다" 뿐이라 모델이 행정 개편류에 place 를 통째로 비웠고
  (실측: 12건 전부 비었다), 그러면 붙일 사진을 찾을 키워드가 없다.
  프롬프트는 한 벌이라 `npm run export:prompts` 로 백엔드 JSON 도 함께 갱신했다

실측(전북 군산시): 연표 3장 · 엽서 6장이 발행본에 실렸다(공공누리 Type1/Type3).
site tsc·eslint 통과 · story·tour_api 테스트 59 passed.
2026-09-10 15:08:03 +09:00
ba90a193f7 [fix] solution/backend,frontend: 같은 가게가 위저드를 돌 때마다 새로 생기던 것
로컬 DB 에 '스테이,머뭄' 사업장이 8개 있었다. 전부 같은 네이버 place id(1133638931)이고
그중 하나만 내용이 있다. 사장님이 위저드를 중간에 나갔다 다시 시작하면 그때마다 빈 사업장이
하나씩 쌓인다 — "내 사이트" 목록에 같은 이름이 여러 개 뜨고, 사장님은 어느 것이 자기
사이트인지 알 수 없다. 실제로 이번 테스트에서 빌더가 fact 2건짜리 빈 행을 열고 있어서
"소개가 안 나온다" 로 보였다.

★ 왜 verify 시점인가
  위저드는 **신원을 알기 전에** 사업장을 먼저 만든다(ensureServerPlace) — 이름만 아는 빈
  행이다. 네이버 place id 를 알게 되는 건 verify_by_url 뿐이고, 그때가 "이미 갖고 있는
  그 가게인가" 를 물을 수 있는 첫 지점이다.

- crud/place_crud.find_by_external: 같은 사장님의 같은 외부 업소를 찾는다.
  **쌓인 것이 많은 순**으로 준다(fact+객실+사진+사이트). 처음엔 created_at 순이었는데
  그러면 위저드가 만들었다 버린 빈 행이 정본이 됐다(실측: fact 2건짜리가 뽑혔다) —
  나이가 아니라 내용이 기준이다. 소유자까지 함께 보는 이유는 외부 id 만으로 찾으면
  남의 사업장이 걸리기 때문이다
- place_service.verify_by_url: 정본을 찾으면 그걸 돌려주고, 지금 행이 **비어 있을 때만**
  접는다(_is_empty). 사장님이 뭔가 쌓았으면 그건 합치기가 아니라 병합이고 사람이 판단할
  일이다 — 그때는 둘 다 남기고 정본만 돌려주며 경고를 남긴다.
  세지 못하면 비어 있지 않다고 본다 — 모르면 지우지 않는다
- ensureServerPlace: **서버가 돌려준 place_id 를 쓴다.** 우리가 만든 id 를 계속 붙들면
  화면이 접힌 행을 편집하게 되고, 저장은 되는데 목록·발행본은 정본을 봐서
  "고쳤는데 반영이 안 된다" 가 된다

검증: 빈 사업장을 새로 만들어 같은 URL 로 검증 → 응답이 정본(99a887f8, weight 40)을
돌려주고 새 행은 접혔다(로그: "빈 행 … 를 접고 … 로 잇는다").
site vitest 51 passed · frontend tsc·eslint 통과 · 백엔드 pytest 529 passed / 52 failed
(52건은 이 변경 전 기준선과 동일).
2026-09-10 14:39:13 +09:00
6f66199adb Merge remote-tracking branch 'origin/main' into feature/local-story-generation
# Conflicts:
#	postgres-init/migrations/0009_align_with_init_sql.sql
2026-09-10 14:36:34 +09:00
328d9e18ee [feat] solution: 지역 이야기 생성 · 발행본 섹션 손질 · 마이그레이션 주석 축약
- 지역 이야기(가요·인물·연표·엽서·퀴즈) 생성 경로: story_service · grounding/story ·
  section_prompts. 지금까지 만들 자리가 없어 시안에만 손으로 넣은 3만 자였다
- 발행본 섹션: ItinerarySection · Carousel 레일 자동재생(use-rail-autoplay) ·
  Festival · LocalGuide · Weather · Gallery · Header/Footer
- 목업 payload 를 payloads-mockup/ 으로 분리 — 발행 대상과 섞이지 않게
- DB 새 구조 후속: site_payload · local_content_crud 조인 정리 · 테스트
- 마이그레이션 주석 축약: 9개 파일 합계 주석 비율 48% → 25%.
  실측과 밟은 함정만 남기고 논증은 커밋 메시지로 옮겼다

검증: site·frontend 빌드 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:36:00 +09:00
7b238efffb [feat] solution/backend: 업소 조사 — 소개문이 쓸 재료를 출처와 함께 찾아온다
소개문이 "군산시에 있는 스테이,머뭄입니다. 주차 가능." 한 줄이었다. 생성기 잘못이 아니라
**쓸 재료가 그것뿐**이었다 — 네이버 플레이스가 준 fact 3건이 전부이고, TourAPI 는 미등록,
예약 페이지와 인스타그램은 robots 가 자동 수집을 금지한다. 그런데 이 업소의 내력
(1925년 적산가옥 · 히로쓰 가옥 후문 옆 · A동 B동 컨셉)은 블로그·기사에 공개돼 있다.
그걸 가져오는 단계가 없었을 뿐이다.

★ 문장을 검색모델에게 시키지 않는다. 재료만 모으고 소개문은 지금처럼 Gemini 가 쓴다 —
  소개문을 바로 시키면 그 문장의 근거를 우리가 못 갖고, ground_check 가 전부 반려한다.

- services/prompts/place_research.py: 사실 조각을 **출처와 함께** 요구한다. 요금·객실 수·
  체크인·취소 규정은 묻지 않는다 — 그건 fact 이고 블로그의 옛값이 섞이면 예약 클레임이다
- services/grounding/place_research.py: 출처 없는 항목은 버린다(story 와 같은 규율) +
  **상호 대조**를 더한다. 지역 이야기는 틀려도 지역 이야기지만, 업소 조사가 틀리면 남의
  가게 이야기가 이 사장님 소개문이 된다 — 이 레포에서 가장 비싼 실수다
- services/place_research.py: 조사 → place_channels.raw 에 근거 적재. **확정하지 않는다** —
  남이 쓴 글이라 공식 채널·sameAs 로 나가면 안 된다. 새 표를 만들지 않았다
- copy_service: 확정 링크만 읽던 근거를 raw.kind=research 까지 넓혔다. 확정 여부는
  "화면에 채널로 낼 것인가" 의 판단이지 "근거로 읽을 것인가" 의 판단이 아니다
- prompts/copy: 조사 기록을 fact 목록이 아니라 **별도 절**로 준다(fact 자리에 섞으니
  모델이 값 하나로 읽고 안 썼다). 소개문 분량 100~250자 → 200~600자·2~3문단 —
  옛 길이로는 확인된 사실을 나열하면 끝나 기록이 들어갈 자리가 없었다
- collect_service: 수집이 끝난 **뒤** 조사한다. 앞에 두면 네이버·TourAPI 가 이미 준 것을
  다시 묻는 꼴이라 검색 요금이 헛돈다

실측(스테이,머뭄): 조사 8건 채택·0건 버림 → 소개문이
"1925년에 지어진 100년 된 적산가옥을 리노베이션한 숙소 … 히로쓰 가옥 후문 바로 옆" 으로.
발행본 본문 10,798자 → 11,100자. 생성물은 여전히 PENDING_OWNER 로 들어가 사장님이 확인해야
노출된다(절대규칙 1).
2026-09-10 14:17:44 +09:00
ee51d89e83 [fix] solution/backend,site: 지역 이야기가 영영 안 생기던 것 + 자체 홈페이지 자동 등록
스테이,머뭄으로 실제 발행해 시안(/s/stay)과 대조한 결과에서 나온 셋이다.

★ 지역 이야기 다섯이 통째로 비어 있었다 (섹션 13 → 18)
  `has_stories()` 가 "이 지역에 이야기가 있나" 를 `kind IS NOT NULL` 로 판정했다. 그런데
  kind 는 이야기 전용 칸이 아니다 — 마이그레이션 0008 이 날씨·축제·명소·맛집에도 kind 를
  채웠다(AREA_KIND). 그래서 주변정보가 한 건이라도 들어온 지역은 이야기가 0건이어도
  "이미 있다" 로 판정돼 생성이 영영 건너뛰어졌다. 잡은 성공으로 끝나고 로그도 조용해서
  생성기가 없는 것처럼 보였다. STORY_KINDS 를 명시해서 고친다.
  실측(전북 군산시): 고친 뒤 54건 생성(가요 8·인물 10·연표 12·엽서 12·퀴즈 12),
  발행본 섹션 13 → 18, 본문 6,598자 → 10,798자.

★ 업소 자체 홈페이지를 아무도 등록하지 않고 버리고 있었다
  네이버 지역검색 응답의 `link` 가 업체 홈페이지인데(external/naver.py 머리주석이 "채널 URL
  발견에 쓸 수 있는 부수입" 이라 적어 뒀다) 채널로 등록하는 코드가 없었다. 숙박은 자체
  홈페이지 보유율이 3업종 중 가장 높고(표본 25건 중 19건), 네이버 플레이스가 fact 를 3건밖에
  주지 않는 업소에서는 **그게 유일한 공개 출처**다. discover_official_site 로 등록·확정한다.
  - 추측이 아니다. 네이버가 그 업소 레코드에 달아 둔 값이고 동일 업소 판정(pick_match)을
    통과했을 때만 쓴다 — discover_naver_place 와 같은 근거라 자동 확정한다
  - 수집 금지 호스트(인스타·OTA)도 **등록은 한다**. 크롤은 static_html 의 _DENY_HOSTS 와
    robots 가 막지만, 공식 채널·sameAs 로는 유효한 사실이다
  - ★ 검색어에 `naver.region_key()` 를 쓰면 안 된다 — 그건 지명이 아니라 행정구역
    코드('52군산시')라 후보가 0건이 된다(실측). `naver_place_lookup.region_hint` 로 쓴다
  - collect_service 에 남아 있던 옛 표 이름(place_links.DBType) 한 곳도 같이 고쳤다 —
    스키마 재편 때 놓친 자리이고, 실행되는 순간에만 NameError 로 터진다

★ 숙박 예약 분기 복구 — `booking: isLodging ? StayBookingSection : BookingSection`
  64ce467 에서 사라져 숙박 발행이 절대규칙 3 대조에 걸려 통째로 막혀 있었다.

검증: site tsc·eslint·vitest 51 passed. 백엔드 pytest 529 passed / 52 failed —
**52건은 이 변경 전 main 에서도 같은 수로 실패한다**(기준선 확인). 스테이,머뭄 실발행으로
공식 채널·예약 채널 노출과 18섹션 확인.
2026-09-10 13:48:44 +09:00
e0d45eda97 [fix] postgres-init,solution/backend,docs: 스키마 재편이 안 닿은 자리를 전부 잡는다 — init.sql · ORM 인덱스 · 테스트
0005 가 도메인 스키마를 걷어내고 표 이름을 옮겼는데, 문자열로 표 이름을 들고 있던 자리들이
따라오지 않았다. import 도 타입검사도 pyflakes 도 못 잡는 종류라 전부 **실행되는 순간에만**
터졌고, 그동안 pytest 는 569건이 통째로 죽어 있어 아무것도 못 잡고 있었다.

**init.sql 이 새 DB 를 옛 구조로 세우고 있었다**
64ce467 이 이 파일에 94줄을 더하기만 하고 삭제를 0줄 했다. 그래서 이 파일 한 벌로 세운 DB 는
`place.place_links`·`job.jobs` 를 갖고 ORM 은 `public.place_channels`·`public.jobs` 를 찾는다 —
기동은 정상이고 첫 쿼리에서 죽는다. "init.sql 은 새 DB 를 세우는 전체 DDL 이고 계속 최신을
유지한다"(migrations/README.md)는 계약이 깨져 있었다.
- public 한 벌 · 표 14개로 다시 썼다. 옛 스키마가 있는 DB 에서 다시 돌면 RAISE EXCEPTION 으로
  멈춘다 — 그대로 두면 public 에 빈 표가 생기고 0005 가 "relation already exists" 로 실패해
  데이터가 옛 스키마에 갇힌다
- 말미에 **마이그레이션 기준선**을 심는다. 없으면 새 DB 에서 migrate.py 가 0001 부터 다시 돌다가
  `schema "local" does not exist` 로 죽는다

**운영 버그 둘** — 두 DB(새로 세운 것 · 마이그레이션으로 따라온 것)를 pg_dump 로 찍어 비교해 찾았다
- `upsert_weather` 의 ON CONFLICT 술어에 `kind IS NULL` 이 빠져 **날씨 캐시 저장이 계속 실패**하고
  있었다(0007 이 인덱스에 그 조건을 더했다). 캐시라 화면이 안 죽고 로그에만 남았다.
  포스트그레스는 술어가 인덱스 술어를 함의하는지 보고 아니면 "no unique or exclusion constraint
  matching" 으로 거절한다 — 컬럼도 표도 멀쩡해서 눈으로는 원인이 안 보인다
- ORM 의 `area_contents` 인덱스 정의가 0004·0007·0008 을 하나도 안 따라왔다. 테스트 DB 는 이
  모델로 세워지므로 **테스트가 운영과 다른 제약 아래에서 돌고 있었다**

**0009** — 두 DB 비교에서 나온 어긋남 셋(데이터는 안 건드린다)
- `idx_site_contents_site` 가 기존 DB 에만 없었다(0003 이 유니크만 걸었다) — 섹션 조회가 시퀀셜 스캔
- `places.external_place_id` VARCHAR(32) → (64). ORM 은 64 다 — 긴 id 가 잘리면 동일 업소 판정이 틀린다
- RENAME 이 안 따라간 PK 제약 이름 9개(`facts_pkey` → `place_facts_pkey` …)

**테스트를 살린다**
- conftest 의 TRUNCATE 가 표 이름을 **손으로 나열**하고 있었다. 0005 가 이름을 옮기자 전 테스트가
  `relation "place_aliases" does not exist` 로 죽었다 — 이제 ORM 메타데이터에서 뽑아 다시 어긋날 수 없다
- `test_schema_ddl` 이 모델 표를 `"None.users"` 로 조회해 **한 표도 비교하지 않고 통과**하고 있었다.
  init.sql 이 조용히 어긋난 동안 이 테스트는 초록이었다. 비교한 표 수를 세는 단언을 더한다
- 테스트 SQL 15곳의 옛 표 이름, `_run_worker` 1틱 문제(수집 뒤 따라오는 LOCAL_SYNC 를 집어 가
  정작 기다리던 잡이 PENDING 으로 남았다), 지역 캐시 픽스처(읽는 코드가 옳게 거르는데 테스트가 빨개졌다)

**문서**
- `docs/DATA_MODEL.md` 신설 — 표 14개가 무엇을 담고 누가 쓰는지, 값 하나가 DB 에서 페이지까지
  가는 길, 두 번 도는 게이트, **DB 에 없는 것**
- `SERVERS.md` DB 절을 마이그레이션 체계로. 배포에 `migrate.py` 를 넣는다 — 코드만 갈면 컨테이너는
  정상으로 뜨고 가게 등록·수집·발행만 죽는다
- ARCHITECTURE 2절의 프리렌더 컨테이너가 `solution-frontend` 로 적혀 있었다. 굽는 건
  `solution-prerender` 고 전자는 운영에서 뜨지도 않는다 — AGENTS.md 가 함정으로 적어 둔 그 혼동을
  문서가 만들고 있었다
- 옛 표 이름 잔재(`place_links`·`local_contents`·`job.jobs`·`company.users`·`fact.facts`·`ai_check_results`)

검증: 빈 컨테이너에 init.sql 로 세운 DB ↔ 마이그레이션으로 따라온 DB 를 `pg_dump --schema-only`
로 비교 — 표·인덱스·제약·컬럼 전부 동일. pytest 583건 중 581 통과(남은 2건은 `.env` 누수·
레이트리밋 카운터로 환경 문제다). 구글 로그인 21건 포함.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:39:43 +09:00
3910feddfb [fix] postgres-init,solution/backend: init.sql 이 스키마 해체를 안 따라왔다 — 새 DB 가 옛 구조로 섰다
0005 가 도메인 스키마를 걷어내고 표 이름을 옮겼는데 `init.sql` 은 94줄이 **추가**만 됐고
삭제가 0줄이었다. 그래서 이 파일 한 벌로 세운 DB 는 `place.place_links`·`job.jobs` 를 갖고
ORM 은 `public.place_channels`·`public.jobs` 를 찾는다 — 기동은 정상이고 첫 쿼리에서 죽는다.
"init.sql 은 새 DB 를 세우는 전체 DDL 이고 계속 최신을 유지한다"(migrations/README.md)는
계약이 깨져 있었다. 이걸 잡아야 할 test_schema_ddl.py 는 로컬 DB 인증 실패로 5건 전부
error 라 안 돌고 있어서 안 걸렸다.

- init.sql: public 한 벌 · 표 14개로 다시 썼다. 옛 도메인 스키마가 있는 DB 에서 다시 돌면
  RAISE EXCEPTION 으로 멈춘다 — 그대로 두면 public 에 빈 표가 생기고 0005 가 "relation
  already exists" 로 실패해 데이터가 옛 스키마에 갇힌다
- init.sql 말미에 **마이그레이션 기준선**을 심는다. 이게 없으면 새 DB 에서 migrate.py 가
  0001 부터 다시 돌다가 `schema "local" does not exist` 로 죽는다
- 0009: 두 DB 를 실제로 찍어 비교해 나온 어긋남 셋
  · `idx_site_contents_site` 가 기존 DB 에만 없었다(0003 이 유니크만 걸었다) — 섹션 조회가 시퀀셜 스캔
  · `places.external_place_id` VARCHAR(32) → (64). ORM 은 64 다 — 긴 id 가 잘리면 동일 업소 판정이 조용히 틀린다
  · 0005 의 RENAME 이 안 따라간 PK 제약 이름 9개(`facts_pkey` → `place_facts_pkey` …)
- models.py: `area_contents.region_code` 를 nullable 로. 0004 가 DROP NOT NULL 한 것을
  ORM 만 NOT NULL 로 들고 있었다 — 축제·관광지·맛집은 전국 공용이라 지역이 유일성의 근거가 아니다

검증: 빈 컨테이너에 init.sql 로 세운 DB 와 마이그레이션으로 따라온 로컬 DB 를
`pg_dump --schema-only` 로 비교 — 표 14개 · 인덱스 · 제약 · 컬럼 전부 동일.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:14:39 +09:00
3e62d08e39 [docs] docs: 값이 DB 에서 페이지까지 가는 길을 한 장으로 — 옛 표 이름 잔재도 정리
표 14개가 무엇을 담고 누가 쓰는지 적어 둔 곳이 없었다. 컬럼 주석은 models.py 에만 있고,
"이 값이 왜 화면에 안 나오나" 를 짚으려면 snapshot.py·build_service.py·site_payload.py 를
차례로 열어야 했다.

- docs/DATA_MODEL.md 신설. 흐름(등록→검증→수집→에디터→빌드→발행) · 표별 칸과 쓰임 ·
  값 하나가 페이지까지 가는 길(두 번 도는 게이트) · **DB 에 없는 것** · 표를 고칠 때
- 옛 표 이름이 남아 있던 자리를 현재 이름으로. 재편(0005~0008)이 지나간 뒤로 문서만
  옛 이름을 들고 있었다 — `place_links`(COLLECTION) · `local_contents`·`job.jobs`·
  `company.users`·`fact.facts`(DECISIONS) · `ai_check_results`(DEVELOPMENT_DIRECTION,
  0006 이 뗀 표다)
- ARCHITECTURE 2절의 프리렌더 컨테이너 이름이 `solution-frontend` 였다. 실제로 굽는 것은
  `solution-prerender` 고, 전자는 운영에서 뜨지도 않는다 — AGENTS.md 가 함정으로 적어 둔
  바로 그 혼동을 문서가 만들고 있었다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:02:45 +09:00
534122dccf [docs] devlog: 가짜 발행 수정 기록 — 코드가 f2dad65 에 섞여 들어갔다
같은 레포를 동시에 작업하던 다른 세션이 커밋하면서, 스테이지에 올려 둔
`PublishModal.tsx`·`Step3DataReview.tsx` 가 그쪽 커밋에 같이 담겼다. 코드는 남아 있지만
커밋 제목이 그 변경을 가리키지 않아 나중에 왜 그렇게 됐는지 찾을 길이 없다 — 기록만 세운다.
히스토리는 고치지 않는다. 다른 세션이 같은 브랜치에 계속 커밋 중이라 되감으면 그쪽이 깨진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
2026-09-10 10:50:04 +09:00
1e0edeef8f [docs] deploy: 킹서버 DB 절을 마이그레이션 체계로 — 코드만 갈면 스키마가 안 따라온다
이 절은 아직 "`init.sql` 한 벌이 스키마 전부" 이고 "이미 있는 DB 에는 파일 하단의
ALTER 절만 손으로 돌려라" 라고 적혀 있었다. 그 방식은 이번 재편에서 없어졌다 —
`postgres-init/migrations/` + `scripts/migrate.py` 가 대신한다.

- 스키마 파일이 두 벌이라는 것과 둘 다 최신을 유지해야 하는 이유
- 배포 절차에 `migrate.py --dry-run` → `migrate.py` 를 넣는다. `postgres-init/` 은
  이미지에 굽지 않고 마운트하므로 코드 배포와 따로 돌릴 수 있다
- ★ 이번 배포(0005~0008)는 스키마를 통째로 편다. 안 돌리면 컨테이너는 정상으로 뜨고
  가게 등록·수집·발행만 죽는다 — 없는 표를 부르는 코드는 기동을 통과한다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 10:49:23 +09:00
b1386dd3ce [refactor] solution,nginx: 에디터와 발행본을 한 렌더러로 — 보는 그대로 나간다
에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `builder/canvas/variants/*` 25종, 발행본은 `site/src/sections/*`.
Playwright 로 재 보니 아예 다른 물건이었다(2026-09-09, 1024px):

  발행본 15섹션 · 에디터 12섹션 · 겹치는 건 4개뿐, 이름도 달랐다
  (gallery↔photos · location↔map · guide↔local)
  겹치는 4개조차 높이가 달랐다(info 488↔535 · booking 242↔487 · itinerary 881↔383)

소스를 하나로 모은다. 편집·미리보기 둘 다 발행본 렌더러가 그린다.

**데이터도 한 벌** — `GET /v1/place/{id}/site/preview` 가 발행이 굽는 것과 **같은 함수**
(`build_snapshot` → `to_site_payload`)로 payload 를 만든다. DB 도 파일도 건드리지 않는다.

**왜 iframe 인가** — 컴포넌트만 같게 해서는 안 됐다. 미디어 쿼리는 창 폭을 보는데 실제
사이트 폭은 그 안의 프레임이라, 그리드 컬럼 수가 어긋나 섹션이 두 배씩 길어졌다
(festival 2560→6027 · guide 1168→2168). iframe 은 자체 뷰포트를 가져 발행본과 같은 폭을 본다.
폭만이 아니라 **높이도** 준다 — 히어로가 `clamp(24rem, 62vh, 36rem)` 이라 낮은 iframe 에서는
하한에 걸렸다(384 ↔ 발행본 576). 자리에 안 들어가면 transform 으로 줄인다: 크기는 그대로,
그림만 줄여야 미디어 쿼리가 안 흔들린다.

**색·서체도 한 벌** — `themeVars(payload)` · `fontHref(payload)`. 셸에는 발행본 `<head>` 의
폰트 링크가 없어 글자만 기본 산세리프로 떨어졌다(지오메트리는 같은데 픽셀 차이 92%).

**에디터가 저장된 템플릿을 안 읽던 것** — `applyTheme` 이 섹션·색팔레트는 되살리는데
templateId 를 빠뜨렸다. templateId 는 theme JSON 이 아니라 `sites.template_id` **컬럼**이라
저장 경로가 다른데 읽는 쪽이 theme 만 봤다. 사장님이 '옛 항구' 를 골라 발행해도 다시
들어오면 편집 화면만 흰 바탕·고딕이었다.

**고르기는 iframe 안에서** — 같은 오리진이라 안쪽 문서에 직접 리스너를 건다. 어느 섹션인지는
`data-editor-id` 로 안다(화면 id `gallery` ↔ 설정 id `photos`; `display:contents` 라 레이아웃
무영향). 표시는 outline 이다 — 상자 크기를 바꾸지 않아 발행본과 픽셀이 그대로다.

곁들여 정리한 것
- 켤 수 없는 섹션 둘(`pricing`·`planner`)을 뗐다 — 기본표에도 [+섹션 추가]에도 없고 DB 참조 0건.
- 반대로 `event`(소식)는 기본표가 켜서 **발행되는데** 채울 UI 가 없었다. 명세를 넣는다.
  이 아이템만 프롬프트가 "찾아라" 가 아니라 **"옮겨 적어라"** 다 — 이 가게에서 지금 하는
  일이라 모델이 알 수 없고, 지어내면 손님이 없는 행사를 보고 찾아온다.
- 예약 버튼이 "네이버 예약 예약" 이었다. `{bookingLabel} 예약` 을 13개 파일에서 각자 이어
  붙이고 있었다 — `bookingActionLabel()` 하나로 모은다.
- `solution/site` 의 별칭을 `@` → `@site` 로 옮겼다(60파일 195건). 두 앱이 '@' 를 각자 자기
  src 로 두면 발행본 컴포넌트를 빌더에서 부를 때 **조용히 다른 파일을 잡는다.**

검증(Playwright, 같은 사업장·1024px):
  섹션 15 = 15 · 순서 일치 · **한쪽에만 있는 섹션 0개**
  15개 전부 높이·글자 수·제목이 정확히 같다
  편집·미리보기·발행본 셋 다 --tpl-bg #e4dac0 · Gugi
  `/preview` ↔ 발행본 문서 높이 9029 = 9029, 픽셀 차이 2.88%(축제 카드 지연 로딩 타이밍)
tsc -b 통과 · eslint 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:09:28 +09:00
387783b766 [fix] solution/backend: 옛 테이블 이름 잔재로 빌더가 통째로 안 돌던 것
웹빌더가 세 자리에서 연달아 죽었다 — 가게 등록 · 수집 시작 · 수집 완료. 전부 같은 뿌리다:
DB 구조 재편이 테이블 이름을 옮기면서 **참조 세 종류 중 일부만** 따라갔다.

- **생성자 12군데** (`place_links(...)` → `place_channels(...)`)
  import 와 `DBType()` 은 고쳤는데 생성자를 빠뜨렸다. 클래스가 없어도 import 는 통과하므로
  기동은 정상이고, 그 줄이 실제로 실행되는 순간에만 터진다.
- **raw SQL 12군데** (`job.jobs` → `jobs`)
  잡 큐만 raw SQL 이라 ORM 이름 변경에 안 딸려 왔다. 큐가 안 도니 수집·비전·소개문·빌드·
  지역데이터가 하나도 못 들어간다. 화면에는 "버튼만 안 먹는" 것으로 보였다.
- **같은 이름의 속성 5군데** (`source.place_facts` → `source.facts` 등)
  이름만 보고 일괄 치환해 테이블과 무관한 자리까지 바뀌었다. `RawSource` 는 수집기 결과
  객체지 테이블이 아니다.
- **뗀 표를 계속 부르던 5군데** (`ai_check_results`)
  한 번도 쓰지 않아 마이그레이션이 뗀 표다. 부르면 SEO 진단이 통째로 죽는다.

★ 하나씩 터질 때마다 고치다가 멈추고 정적 검사로 남은 것을 한 번에 셌다 — pyflakes 가 19건을
  짚었다. 이 종류는 import 도 타입검사도 안 잡는다. 테이블 이름을 옮긴 뒤에는
  `python -m pyflakes services/ crud/ router/ worker/ common/ | grep "undefined name"` 을 돌린다.

검증: 직접 수집 실행(스테이,머뭄) — 잡 DONE · 재시도 0 · fact 2 · 사진 10 · 채널 2 저장.
정의 안 된 이름 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:51 +09:00
c4af53613e [feat] solution,postgres-init: 지역 이야기를 서버가 채운다 · 공용과 개인화를 이름으로 가른다
가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/s/stay` 시안이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 옛 항구
템플릿을 골라도 그 자리가 비었다. 실측(2026-09-09, 전북 군산시): 생성 54건 · 62초 · 버린 항목 0.

**생성**
- Perplexity 종류당 1회, 지역당 1세트. 순차로 돈다 — 동시에 다섯을 띄웠더니 둘이 HTTP 429 였다
  (같은 키라 한 지역이 자기를 막는다). 순차도 건당 9~15초다. 타임아웃 240s — 가요 다방이
  기본 90s 를 넘겼다(후보를 넓게 훑는 프롬프트다).
- 출처 없는 항목은 버린다. 항목 자신의 출처가 없어 검색 출처로 때운 것은 모델이 "확인" 이라
  우겨도 "확인필요" 로 내린다. 항목 **모양은 검사하지 않는다** — shared 계약을 파이썬에
  한 벌 더 적으면 필드가 는 날 서버가 조용히 떨어뜨린다.
- 프롬프트는 한 벌이다(`shared/section-prompts.ts`). 사장님이 [콘텐츠] 탭에서 복사해 가던
  그 문장을 서버도 그대로 쓴다. `npm run export:prompts` 가 백엔드용 JSON 으로 뽑는다(커밋).
- 트리거는 수집 완료 직후다. 전에는 에디터 캔버스가 주변정보를 처음 부를 때 시작해서
  사장님이 처음 보는 화면이 **늘 절반만 그려진 상태**였다.

**자리 가르기**
    area_*        = 공용. 지역 단위, 여러 사이트가 나눠 쓴다 → 렌더러 모양 그대로.
    site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부(거리·숨김·순서·편집).
- `area_contents.body` 가 TourAPI 원문 이름이라 빌드마다 렌더러 이름으로 바꿔 실었다 —
  같은 변환을 발행할 때마다 다시 하는 셈이었다. 수집 시점에 바꿔 넣는다.
- 거리·숨김은 사이트마다 다르니 `site_sections('local').data.places` 맵으로. **맵이지
  배열이 아니다** — 화면에 순서대로 서는 항목이 아니라 ref → 값 조회표다. 정렬 기준은
  읽는 쪽이 갖는다.
- ★ 유일 인덱스 함정 둘. `uq_local_contents_single` 이 kind 를 안 봐서 이야기 다섯 중
  **첫 종류만 저장되고 잡은 "성공" 으로 끝났고**, backfill 때는 인덱스를 먼저 떼지 않으면
  UPDATE 가 통째로 막힌다(`(gunsan, festival) already exists`). 둘 다 조용히 틀리는 종류다.
- 검수 게이트는 두지 않는다(사장님이 에디터에서 뺀다). 근거는 DECISIONS.md 6절.

검증: 지역 이야기 단위 테스트 12건 통과 · 군산 실행 후 payload.local.story 에
songs 8 · people 10 · chronicle 12 · postcard 12 · quiz 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:31 +09:00
64ce467f21 [refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계
도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.

- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
  같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
  종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
  컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
  받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
  DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
  Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
  더하면 어긋날 자리가 하나 더 생기기 때문이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:02 +09:00
7bbeb068a8 Merge branch 'feature/stay-booking'
숙박 예약 구성(요금·인원·창구) · 네이버 예약 딥링크 · 날짜/시간 목업 · 로컬 발행 함정 셋.

충돌 4건 해결:
- seo/verify.ts STRUCTURAL: main 이 unitCode·numberOfRooms 를 이미 넣었다. main 쪽을 살리고
  "사람이 읽는 unitText 는 넣지 않는다" 만 주석으로 얹었다(같은 결론에 각자 도달했다)
- pages/HomePage.tsx: import 목록만 갈렸다 — StorySection(main) · StayBookingSection(feature)
  둘 다 필요하다
- backend/collect_service.py: 테넌트 제거로 _finish 인자가 company_id → owner_user_id 로
  바뀌었다. main 시그니처를 따르고 _store_booking_link 를 그 앞에 둔다.
  place.verified_by · _add_link 시그니처는 그대로라 예약 링크 경로는 손댈 것이 없었다
- docs/DEVLOG.md: 양쪽 새 항목을 날짜 내림차순으로 합쳤다

검증: site tsc·eslint·vitest 51 passed · frontend tsc·eslint 통과 ·
백엔드 이미지 재빌드 후 collect_service import + LinkChannel.NAVER_BOOKING=7 확인.
2026-09-09 10:42:51 +09:00
c43c4f3620 [fix] solution/frontend: 빌더 캔버스의 예약 섹션을 발행본과 맞춘다 — 실시간 예약 문구 제거
편집 화면이 "네이버 실시간 온라인 예약 / 캘린더에서 바로 확정 예약하실 수 있습니다" 를
그리고 있었다. 우리는 실시간 재고를 갖지 않고(PRODUCT.md 6절), 발행본은 날짜·시간을 고르는
화면이다 — 사장님이 에디터에서 본 것과 발행된 사이트가 서로 다른 물건이었다.
에디터가 보여주는 것이 곧 발행될 것이어야 한다.

- booking/BookingCard: 발행본 구성(날짜 칩·도착 시간·인원·예약 요청·전화 창구)의
  미리보기로 교체. 캔버스 클릭은 섹션 선택이라 상태를 두지 않고 첫 칸 선택 모습으로 고정.
  시간 칸은 발행본과 같은 규칙 — 체크인 fact 가 있을 때만 그린다
- booking/BookingBanner: "실시간 캘린더에서 남은 날짜" → "날짜와 시간을 고르고 예약 창구로"
- rooms/RoomCard: "실시간 예약 신청" → "예약 안내 보기"
- hero/HeroEditorial: "실시간 예약" → "예약 안내"
- api/generated linkChannel: NAVER_BOOKING=7 추가. ★ npm run orval 을 그대로 돌리면
  141파일 6,400줄이 바뀌는데 전부 따옴표·줄바꿈 포매팅 드리프트다 — 생성물을 되돌리고
  스펙 변경분 한 줄만 남겼다

tsc·eslint 통과(frontend·site), vitest 51 passed. solution-site 재빌드 후 번들에서
옛 문구 0건 확인.
2026-09-09 10:37:17 +09:00
0b33f035ef [feat] solution/site: 예약 안내 안에 날짜·시간 목업 — 연동 없이 화면에서만 돈다
예약 흐름을 눈으로 보려고 StayBookingDemo 를 예약 안내 섹션 안에 넣었다. 날짜(2주) ·
도착 시간 · 객실 · 인원을 고르면 확인 화면이 나오고 전화로 잇는다. 재고 조회도 접수도
결제도 없다 — PRODUCT.md 6절은 그대로다.

목업이라도 지킨 선:
- 마감/잔여를 만들지 않는다. 모르는 값을 그럴듯하게 그리면 목업이 아니라 거짓말이다
- 시간 후보는 체크인 fact(16:00)에서 시작한다. fact 가 없으면 시간 선택을 내지 않는다 —
  확인된 값과 어긋나는 선택지는 목업에도 두지 않는다
- 요금은 요금표·JSON-LD 와 같은 출처(unitBaseRate)를 쓴다. 한 페이지가 두 값을 말하지 않게
- 확인 화면은 "접수됐다"고 쓰지 않는다(사실이 아니다). 경고문도 두지 않는다(2026-09-09 결정)

★ 날짜는 브라우저에서 만든다(mounted 게이트). 서버에서 구우면 발행 시각의 날짜가 정적
HTML 에 박혀, 한 달 뒤 크롤러가 지난 날짜를 예약 가능일로 읽는다 — 화면은 멀쩡하고 기계가
읽는 값만 틀리는 종류다. SSR 은 안내 한 줄만 내보낸다.

구조화 데이터·llms.txt 는 그대로다(availability 없음). 목업을 AI 에게 예약 창구로 소개하면
그때부터는 목업이 아니다. 연동을 붙일 자리는 ConfirmPanel 한 곳이다.

tsc·eslint 통과, vitest 51 passed(신규 4). 발행본 재굽기 후 /s/<slug> 확인.
2026-09-09 10:19:07 +09:00
f2dad65792 [fix] site,deploy: 발행본 목록의 정본 주소를 /s 로 — 끝 슬래시 제거
`/s` 는 nginx `location ^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어진다.
그래서 404 가 아니라 **빌더 SPA 셸이 200 으로** 나가고 있었다 — 실측 2026-09-08:
`/s` 3.1KB `<title>Web4Ai</title>` · `/s/` 6.7KB 목록. 404 도 목록도 아닌 세 번째
페이지가 오리진에 있었던 셈이다. 목록만 슬래시가 붙어 있던 이유도 이것 하나였다.

색인 요청·사이트맵이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한
표준 태그가 있음)" 로 분류한다 — 슬러그 쪽에서 이미 밟은 함정인데(prerender.ts
주석) 목록만 반대 형태로 남아 있었다.

- nginx/site.conf.example: `location = /s` 로 목록 index.html 직접 서빙, `/s/` 는 301.
  `^~ /s/` 의 `index index.html` 은 남긴다 — `/s/<slug>/` 가 그걸로 열린다
- 같은 파일: `absolute_redirect off`. TLS 를 앞단 Apache 가 끊어 nginx 의 `$scheme` 는
  늘 `http` 다 — 기본값대로 절대 URL 을 내면 https 페이지가 http 로 내려간다
- prerender.ts: `indexUrl` 의 `+ '/'` 제거. canonical·og:url·사이트맵·llms.txt 가
  이 값 하나를 쓴다
- directory.ts · AGENTS.md · DEVLOG.md: 슬래시 규칙과 근거 갱신

검증: `nginx -t` 통과 · `tsc --noEmit` 통과 · 컨테이너 실측
`/s`→200 목록 · `/s/`→301 `Location: /s`(상대) · `/s/<slug>`→200 · `/s/<slug>/`→200 ·
`/nope`→200 앱 셸(변화 없음)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129XqVdjDk9JmMNFAepBJvs
2026-09-08 13:02:11 +09:00
9f16c3224b [feat] solution/backend,frontend: 내 사이트 목록을 카드로 — 썸네일·주소·시각 · 발행마다 그림 갱신
목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 road_address·created_at·
published_at 을 주는데 화면이 안 썼다. 실측(계정 test): 35줄 중 34줄이 발행 전이고
같은 상호 '버터브루' 가 4줄이라 어느 게 어느 건지 가릴 단서가 화면에 없었다.

리서치 — Wix My Sites 는 이름·URL·Premium·협업자만 두고 검색·그리드/리스트 전환·폴더가 있다.
Sites API 문서가 권하는 조합은 displayName·thumbnail·viewUrl·editUrl 이다.
아임웹 내사이트는 **실제 화면을 열어 봤다**(imweb.me 가이드): 줄 왼쪽에 큰 가로형 썸네일,
상호 아래 도메인, 그리고 도메인/SSL·PG 신청처럼 **안 끝난 설정**을 줄 안에 배지로 늘어놓는다.
공통 원칙은 목록이 ① 구분 ② 상태 ③ 여는 길 셋만 한다는 것 — 통계는 사이트 안 대시보드다.

- protocol·site_service: `MySiteData.thumbnail_url` 추가. 목록이 사이트 행을 이미 조인해
  읽고 있어서 쿼리는 그대로다
- site_thumbnail: 공개 주소에 `?v=<발행 버전>`. 블롭 이름은 고정이고 내용만 덮어쓰므로
  주소가 안 변하면 사진을 바꿔 재발행해도 **캐시에 남은 지난 그림**이 계속 보인다
  (CACHE_CONTROL 60초로는 그 60초를 못 막는다). 이름에 버전을 넣지 않은 이유는
  사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없어서다
- site_thumbnail: 썸네일 전용 저장소 스위치(`THUMBNAIL_BLOB_*`). 예전엔 키 하나가
  사이트 전체 업로드(azure_static)까지 같이 켰다 — 둘은 필요한 저장소가 다르다
  (사이트는 정적 호스팅 `$web`, 썸네일은 이미지 버킷이면 된다)
- SitesPage: 줄 → **카드 그리드**. 썸네일은 16:10(브라우저 창 비율 — 사이트 미리보기를
  1:1 로 자르면 무슨 사이트인지 못 알아본다). 검색(상호·주소, 공백 무시) + 상태 칸
  `전체/발행됨/발행 전` 에 건수. 판정은 `bucketOf` 하나가 소유한다(배지·필터·정렬이 갈라지면
  건수가 어긋나 목록을 못 믿게 된다). 검색 0건 화면을 처음 온 사람의 빈 화면과 분리했다 —
  35개 있는데 "아직 없습니다" 라고 말하던 자리다
- 카드 골격은 **상태와 무관하게 같다**. 발행 전 카드에만 줄이 하나 더 붙어 높이와 버튼
  위치가 어긋났다(사장님 지적). 버튼 문구도 '편집' 하나로 — 하는 일이 같은데 글자만 달랐다

아직 그림이 없는 사이트가 대부분이다. 썸네일은 발행에 성공해야 생긴다.

검증: 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 전 줄에
`thumbnail_url` 키가 아예 없는지, 재발행하면 `?v=1` → `?v=2` 로 주소가 바뀌는지 4건 추가.
프론트 tsc+eslint 통과. 실제 발행으로 블롭 업로드(232KB) → 공개 주소 200 → 목록 반영 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
2026-09-08 13:01:47 +09:00
94551afdaf [refactor] solution/backend,frontend,postgres-init: 회사(테넌트) 제거 — 사장님 계정이 곧 스코프
가입 한 번이 회사를 하나 만들고 사장님이 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고
에디터 헤더에는 "이름 · 회사명" 이 붙었다 — 쓰는 사람은 사장님 한 명인데.
negodata 보일러플레이트의 멀티테넌트 스코프 키를 그대로 물려받은 것이고,
DECISIONS.md 2절이 "대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다.

- gmodel: `UserInfo.company_id` 삭제 — JWT 클레임에서도 사라진다. 스코프 키는 `user_id` 다
- place_crud·site_crud: WHERE 를 `places.owner_user_id` 로. `list_company_sites` → `list_owner_sites`
- place_service: **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 —
  body 로 받으면 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
  실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다
- 워커(collect·copy·build·vision): 잡 페이로드 키 `company_id` → `owner_user_id`.
  잡이 세우는 `UserInfo.user_id` 는 이제 **사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid
  순으로 채웠는데, 그 랜덤 uuid 가 스코프 키가 되는 순간 "남의 사업장" 이라 fact 조회가 0건이 된다
- auth: `Res_Me.company` · `Req_Signup.company_name` · `CompanyData` 삭제
- models·init.sql: `company.companies` 테이블 · `users.company_id` 삭제,
  `places.owner_user_id` NOT NULL. 마이그레이션은 백필 → NOT NULL → DROP 순서다.
  회사에 계정이 여럿이면 **가장 먼저 만든 계정**에게 몰고, 주인을 못 찾은 행은 지운다 —
  스코프가 없으면 아무에게도 안 보이는 유령이다.
  실측(로컬): place 92 → 91(고아 1건 삭제), `demoebf050` 56 · `test` 35
- 프론트: 가입 폼의 상호 칸, 내 정보의 상호 항목, 헤더의 "이름 · 회사명" 삭제
- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나.
  격리는 `auth_headers("o2")` 를 한 번 더 부르면 그게 남이다

남긴 것 — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를
건드려야 해서 이번 변경에 섞지 않았다.

검증: 전체 568 passed(실패 1건은 HEAD 에서도 깨지는 레이트리밋 테스트) ·
프론트 tsc+eslint 통과 · 실제 API 로 가입→사업장→목록→격리→발행 한 바퀴

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
2026-09-08 13:01:14 +09:00
66f81f3631 [feat] solution/backend,site: 예약 버튼을 네이버 예약 화면으로 — 검색 화면이 뜨던 것
발행본의 예약 버튼이 네이버 플레이스 링크를 그대로 열었다. 잘해야 가게 홈이라 예약을 한 번
더 눌러야 하고, 자동 발견이 물어온 URL 이 map.naver.com/p/search/… 인 사장님은 예약하려고
눌렀는데 검색 화면을 봤다. 예약하러 온 손님은 거기서 끝난다.

주소를 지어낼 필요가 없다 — 플레이스 응답의 __APOLLO_STATE__ 가 예약 주소를 직접 준다
(실측 2026-09-08, place 1273971279):
  naverBooking.naverBookingUrl = https://m.booking.naver.com/booking/6/bizes/1067685
★ bookingBusinessId + businessTypeId 로 조립하지 않는다. 조립하면 예약을 안 받는 업소에도
  주소가 생기고, 빈 화면을 본 손님은 그 가게가 예약을 안 받는 줄로 읽는다.

- LinkChannel.NAVER_BOOKING=7 (백엔드·shared·init.sql 주석)
- collector/base: RawSource.booking_url — 채널이 스스로 알려준 예약 주소
- naver_place_adapter._booking_url: ROOT_QUERY.placeDetail(...).naverBooking 에서 읽는다
- collect_service._store_booking_link: 예약 채널로 등록·자동 확정(근거는 discover_naver_place
  와 같다 — 이미 확정된 플레이스가 내놓은 자기 예약 주소다)
- site/seo/jsonld BOOKING_CHANNELS: 순서가 우선순위(예약→야놀자→여기어때→플레이스).
  화면 버튼과 makesOffer.url·potentialAction 이 같은 함수를 쓴다
- site/lib/derive: 같은 순서로 정렬 + 검색 결과 주소 배제. 문구는 bookingCtaLabel 이
  "네이버 예약으로 바로 예약하기"로 낸다("네이버 예약에서 예약"이 되지 않게)
- 빌더도 채널을 안다(useCollectFlow 라벨 · ChannelUrlInput 호스트 판정)

tsc·eslint 통과, vitest 47 passed(신규 4). 어댑터는 실제 네이버 응답으로 확인.
2026-09-08 10:52:36 +09:00
00e13bca7f [feat] solution/site,shared: 발행본을 /s/stay 목업에 맞춘다 — 잃어버린 CSS 토큰 복원 + 섹션 마크업 이식
목업(/s/stay)이 참조하는 스타일시트가 서버에서 사라져(404) 그 화면을 기준으로 삼을 수
없었다. 브라우저 캐시에 남아 있던 규칙을 꺼내 레포에 되돌리고, 섹션 마크업을 목업 HTML
에서 그대로 옮겼다. Playwright 대조로 섹션 래퍼·제목 16/17, 안쪽 구조 14/17 일치.

- index.css: --fs-display~--fs-xs(유동 타이포 7) · --section-space · --color-line/muted ·
  .h2 .h3 .panel .paper .measure .border-line .text-muted .divide-line .slider-viewport/track
  ★ 이 토큰이 없던 동안 컴포넌트가 var(--fs-display)를 써도 브라우저가 조용히 무시했다 —
    히어로 제목이 본문 크기로 나오던 원인
- HeroSection: 가운데 → 좌하단. 사진 위에 글자를 한가운데 얹으면 피사체를 정확히 가린다
- AnswerBlock·EssentialInfo·Units·Gallery·Location·Faq·LocalGuide·Weather·About:
  목업 마크업 그대로. embla 를 scroll-snap 으로 바꿔 스크립트 없이도 레일이 밀린다
- FestivalSection(계절 탭) · StorySection(이야기 탭 래퍼) · items/TripMap(OSM 타일 지도) 신설
  ★ 지도는 iframe 이 아니라 타일을 직접 깐다 — iframe 은 핀을 하나밖에 못 찍어
    "어떤 순서로 도는가" 를 그릴 수 없다
- items: itinerary·event·video kind 추가(파서에 없어 payload 까지 실려 오고도 화면에서
  사라지던 것) · Rail 을 slider-viewport/track 으로 · SongsSection 을 턴테이블로
- color.ts: 선 색을 secondary → mix(bg, text, .2). secondary 는 '본문 다음으로 진한
  글자색' 이라 선으로 쓰면 표와 카드가 격자무늬처럼 새까맣게 그어진다
- seo/head.ts: --tpl-texture. 없으면 색만 갱지고 면은 매끈해서 인쇄물로 안 보인다
- seo/verify.ts: unitCode·numberOfRooms 를 화면 대조에서 뺀다
  ★ unitCode 는 ㎡의 ISO 코드로 priceCurrency 와 같은 성격인데 예외에 없었다. 그래서
    room_size 가 있는 사업장은 발행 게이트가 전부 막았다(실측: 가은채 객실 12개).
    stay 는 객실 2개 + room_size 없음이라 우연히 통과했다
- shared: LocalPlace.imageUrl/distanceMeters · FestivalEntry.imageUrl ·
  ItineraryStop 좌표 · TemplateLook.texture · people/chronicle/postcard imageUrl
  ★ 좌표·사진은 모델이 만드는 칸이 아니다. 공식 API 로 조회해 채운다

검증: tsc 통과 · Playwright 대조(목업 CSS 주입) 섹션 래퍼·제목 16/17, 안쪽 14/17.
남은 차이는 데이터 한계다 — people 사진은 위키에 원본이 없고(original:false),
weather.note 는 정적 페이지에 날씨 문장을 박는 문제라 보류.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP1ykMnWZFtLFpm2mow1Sw
2026-09-08 09:53:19 +09:00
55ee968f9d [fix] deploy: 앱이 부르는 API 주소를 한 오리진으로 — :9800 기본값이 로그인을 막았다
nginx/site.conf 는 /v1 을 같은 오리진으로 프록시하고 주석에도 "앱과 같은 오리진이라
프리플라이트가 아예 발생하지 않는다" 고 적혀 있는데, compose 빌드 인자 기본값이
VITE_API_BASE_URL=http://localhost:9800 이었다. :80 으로 앱을 열면 번들이 :9800 을 부르므로
스스로 크로스 오리진이 되고, CLIENT_URL 기본값(3000~3005)에 http://localhost 가 없어
로그인만 실패한다.

증상이 사람을 속인다 — 서버는 200 에 토큰까지 내려보내고 브라우저가 allow-origin 이 없어
그 응답을 버리므로, 화면에는 "로그인에 실패했습니다" 만 뜬다. 비밀번호를 의심하게 된다.

- docker-compose.yml · .env.example: 기본값을 앱과 같은 오리진(http://localhost)으로.
  CORS 를 허용해 뚫는 게 아니라 크로스 오리진을 만들지 않는다
- .env.example: PUBLIC_API_BASE_URL 을 주석이 아니라 값으로 내놨다 — 주석으로 두면
  compose 기본값이 이기고, 그 기본값이 문제였다

검증: down -v 후 up -d --build → 번들의 localhost:9800 참조 0건 ·
POST http://localhost/v1/auth/login 200(프리플라이트 없음) · 프리렌더 재굽기 2건 ·
/ · /s/ · /s/<slug> 전부 200.
2026-09-07 16:19:34 +09:00
b665c34ac2 [fix] deploy: .env.example 그대로 쓰면 로컬 발행이 안 됐다 — DB_HOST 와 줄끝 주석
문서대로 `cp .env.example .env` → `docker compose up -d` → 발행을 걸면 게이트는 통과하고
발행만 실패한다. 두 함정이 겹쳐 있었다(둘 다 실측).

- DB_HOST=127.0.0.1: 컨테이너 안의 127.0.0.1 은 그 컨테이너다. 증상이 고약하다 — API 는
  /healthz 가 DB 를 안 보므로 200 healthy 로 뜨고 워커만 조용히 재시작을 반복한다.
  compose 기본값(host.docker.internal)을 기본으로 올리고, 127.0.0.1 은 네이티브 실행용이라고
  적었다
- 줄 끝 주석이 값이 된다: env_file 은 `KEY=   # 설명` 을 빈 값으로 읽지 않는다. Azure 를 끈
  로컬에서 is_configured() 가 참이 되어 발행 잡이 업로드를 시도하고 죽었다
  (Connection string is either blank or malformed). 같은 모양 5개를 윗줄로 올리고,
  파일 머리에 규칙을 근거와 함께 박았다

검증: web4ai_db 신규 생성 + init.sql → compose up → demo_build.py 발행 게이트 통과 ·
published:true · http://localhost/s/<slug> 200.
2026-09-07 16:05:01 +09:00
d498d36ccf [feat] solution/site,frontend,backend: 숙박 예약 구성 — 요금·인원·창구를 한자리에
숙박으로 발행하면 서버 기본표가 booking 섹션을 켜는데, 발행본이 읽는 fact
(reservation_required·reservation_channel)가 **숙박 스키마에 없다**. 그래서 펜션·민박의
"실시간 예약" 섹션에는 전화번호 한 줄만 남았다 — 요금도 인원도 취소 규정도 없었다.
숙박은 예약이 곧 매출이고 "얼마예요 / 몇 명까지 / 어떻게 예약해요" 가 이 업종 질의의
대부분인데, 그 답의 근거가 페이지에 없으면 AI 는 OTA 후기에서 추측한다.

★ 예약을 처리하게 만든 게 아니다. 재고도 결제도 갖지 않는다(PRODUCT.md 6절) — 날짜
선택기·예약 폼을 그리지 않았고, "여기서 결제되지 않는다" 를 화면 맨 앞과 llms.txt 에
명시했다. 없는 기능을 흉내내면 손님은 예약한 줄 알고 안 온다.

- site/sections/StayBookingSection: 객실별 요금·인원 / 예약 창구(전화 + 확정 채널) /
  예약 전 확인 8항목. 근거가 없으면 섹션째 안 나간다
- site/lib/derive: stayBookingView() 가 그릴지 말지까지 판단한다 — 내비·탭이 같은 함수를
  본다(각자 판단하면 눌러도 아무 일 없는 탭이 생긴다). 예약 채널은 문의 목록에서 뺀다
- site/seo/jsonld: unitBaseRate() 를 요금 숫자의 단일 출처로. makesOffer(객실별 1박) ·
  potentialAction(확정 채널만) 추가. availability 는 안 넣는다 — 빈 방을 모른다
- site/seo/llms: 숙박 ## 예약 블록을 위쪽에. 아래에만 있으면 답에 안 실린다
- frontend/industryData, backend/site_payload: 기본 섹션명 "실시간 예약" → "예약 안내".
  실시간 예약을 하지 않는데 제목이 그렇게 말했다(두 파일은 parity 테스트가 묶는다)
- site/seo/verify: 데모 payload 가 원래 굽히지 않던 오탐 둘을 고쳤다(main 에서 재현) —
  속성의 &amp; 이스케이프 때문에 화면에 있는 이미지 URL 을 못 찾던 것, ㎡ 의 단위 코드
  MTK 를 본문에서 찾던 것. 되돌린 사본에서도 못 찾으면 그대로 실패다

tsc·eslint 통과, vitest 43 passed(신규 21). 데모 재굽기 성공 → /s/moonlight-stay-jeju 200.
백엔드 pytest 는 venv 가 없어 미실행 — 섹션표 parity 는 같은 방식으로 손대조했다.
2026-09-07 15:27:29 +09:00
6df125d840 [fix] solution/site: 발행본이 참조하는 자산은 기간과 무관하게 남긴다 — 목업이 죽었다
/s/stay · /s/stay2 · /s/stay3 의 CSS·JS·이미지가 전부 404 가 됐고 재굽기로 살아나지 않았다.

out/s/ 에 디렉토리가 8개인데 payload 는 4개뿐이다. 나머지는 손으로 넣은 목업이고, 프리렌더는
payload 를 받은 사이트만 굽는다 — 목업은 재굽기 대상이 아니라서 자산이 한 번 지워지면
영영 복구되지 않는다. 문서 어디에도 목업 얘기가 없어서(grep 0건) 그 존재를 모르고 자산
삭제 코드를 건드렸다.

보관 기간으로는 못 막는다. 기간이 지나면 같은 사고가 난다. 참조가 살아 있으면 남겨야 한다.

- referencedAssets(): 굽기 전에 out/s/**/index.html 을 훑어 /assets/… 참조를 모은다
- pruneAssets: 그 목록은 절대 지우지 않는다 — 보관 기간보다 우선한다
- AGENTS.md: 목업의 존재와 "자산 삭제 코드는 참조를 먼저 뺀다" 를 함정 맨 위에 ★★로
- docs/DEVLOG.md: 사고 기록과 복구 경로

검증: payload 없는 목업을 재현해 재굽기 → 참조 3개 유지. 대장을 60일 전으로 돌려 만료를
강제해도 유지. tsc·eslint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4
2026-09-07 14:06:09 +09:00
f008b24574 [fix] solution/site: 자산 보관 코드를 되살린다 — 목업은 재굽기가 안 되므로 자산이 지워지면 끝이다
되돌렸던 6f4e055 를 그대로 되살린다. out/s/ 에는 payload 가 없는 사이트(목업)가 있고,
그건 재굽기 대상이 아니라서 자산이 한 번 지워지면 영영 복구되지 않는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4
2026-09-07 14:04:50 +09:00
de5ff5186f Revert "[fix] solution/site: 사이트맵 lastmod 를 파일 mtime 에서 뗀다 — 페이지의 dateModified 를 그대로 쓴다"
This reverts commit 09b0538c9c.
2026-09-07 13:33:34 +09:00
b797535b17 Revert "[fix] solution/site: 옛 해시 자산을 30일 남긴다 — 배포와 전체 재굽기를 뗀다"
This reverts commit 8f6ea16f65.
2026-09-07 13:33:34 +09:00
9a4bc0e123 Revert "[fix] solution/site: 대장에 없는 자산을 지우지 않는다 — 첫 배포에 운영 사이트가 끊겼다"
This reverts commit 292cf26fd9.
2026-09-07 13:33:34 +09:00
22447ae041 Revert "[docs] AGENTS,deploy: 재굽기 컨테이너는 solution-prerender 다 — solution-frontend 는 개발용"
This reverts commit 6f4e05517f.
2026-09-07 13:33:34 +09:00
6f4e05517f [docs] AGENTS,deploy: 재굽기 컨테이너는 solution-prerender 다 — solution-frontend 는 개발용
문서 5곳이 `docker compose restart solution-frontend` 라고 적고 있었다. 그 서비스는
profiles: ["dev"] 라 운영에서는 뜨지 않는다 — 사이트를 굽는 건 solution-prerender 다.

이름이 비슷한 데다 명령이 실패하지도 않아서, 재굽기를 했다고 믿고 넘어가게 된다.
실제로 운영 사이트 CSS 복구가 이 명령으로 한 번 헛돌았다(2026-09-07).

- AGENTS.md · docs/DEPLOY.md · docs/DEVLOG.md: 서비스명 교체
- AGENTS.md 함정 목록: 왜 조용히 틀리는지 ★로 박았다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4
2026-09-07 13:30:11 +09:00
292cf26fd9 [fix] solution/site: 대장에 없는 자산을 지우지 않는다 — 첫 배포에 운영 사이트가 끊겼다
직전 커밋(옛 해시 자산 30일 보관)을 배포하자 기존 사이트의 CSS·JS 가 전부 404 가 됐다.
옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.

pruneAssets 가 "대장(.builds.json)에 없는 파일"을 만료로 보고 지웠다. 그런데 대장은 이
기능과 함께 처음 생긴다 — 배포 직후 첫 실행에는 대장이 없으므로, 디스크에 있던 기존 자산이
전부 "대장에 없음"으로 분류돼 한꺼번에 삭제됐다. 아직 다시 굽지 않은 사이트는 그 순간 죽는다.
검증을 out/ 을 비운 상태에서만 돌린 탓에 못 봤다 — 재현했어야 할 것은 빈 디렉토리가 아니라
"옛 자산은 있는데 대장은 없는" 상태, 즉 실제 배포 직전의 서버 모습이었다.

- scripts/prerender.ts: 대장에 없는 파일은 "지금 처음 본 것"으로 입양해 보관 기간을 새로 준다
- AGENTS.md: "기록이 없다"와 "만료됐다"를 같이 묶지 않는다 — 함정 목록에 ★로 박았다
- docs/DEVLOG.md: 사고 기록과 복구 절차(docker compose restart solution-frontend)

검증: 배포 직전 상태 재현 — out/assets 에 옛 해시 파일만 두고 대장 없이 첫 실행하면 옛 파일이
그대로 남고 대장에 입양 항목으로 들어간다. 재실행해도 대장이 늘지 않는다.
tsc·eslint 통과, vitest 22 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4
2026-09-07 13:28:12 +09:00
8f6ea16f65 [fix] solution/site: 옛 해시 자산을 30일 남긴다 — 배포와 전체 재굽기를 뗀다
writeSharedAssets 가 빌드마다 out/assets 를 통째로 지우고 다시 깔았다. HTML 은 자산 경로를
파일명 해시까지 박아 굽기 때문에, 렌더러를 배포하는 순간 아직 다시 굽지 않은 사이트는
전부 CSS·JS 404 였다. 그 구멍을 "기동 시 전체 재굽기"와 "배포하면 반드시 전체 재업로드"라는
규칙으로 막고 있었다 — 규칙으로 막는다는 건 구조가 못 막는다는 뜻이다.

진짜 위험은 방문자가 아니라 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 나중에 돌린다.
그 사이 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다.
유예 창이 필요한 건 통념이고(Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다),
우리 창은 0초였다. 한 벌이 400KB 안팎이라 한 달치를 남겨도 10MB 남짓이다.

- scripts/prerender.ts: assets/ 통째 삭제 제거. 권한 때문에 지웠던 것인데
  copyDirectoryFiles 가 파일마다 먼저 rmSync 하므로 그 문제는 그대로 해결된다
- ASSET_RETENTION_DAYS(30) · ASSET_MIN_BUILDS(2) — 기간이 지나도 직전 빌드는 남는다
- out/assets/.builds.json 대장 — mtime 으로 나이를 재지 않는다(복사·동기화가 시각을 갈아
  버리면 옛 파일이 영원히 젊어지거나 산 파일이 지워진다). 발행마다 이 함수가 도므로
  번들이 그대로면 줄을 늘리지 않고 맨 앞 줄의 시각만 갱신한다
- AGENTS.md 함정 항목 · docs/DEPLOY.md 2절 · docs/DEVLOG.md

남은 것: azure_static._upload_shared 가 매 발행마다 assets/ 전체를 올린다 — 보관 기간만큼
업로드량이 는다. Azure 는 지금 꺼져 있으므로 켤 때 기존 블롭 건너뛰기를 먼저 붙인다.

검증: 세 번 구워 확인 — 번들 해시가 바뀌어도 옛 파일 3개가 남고, 같은 번들로 다시 구우면
대장이 안 늘며(2줄 유지), 대장 마지막 줄을 60일 전으로 돌리자 그 빌드 파일 3개만 정리됐다.
tsc·eslint 통과, vitest 22 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4
2026-09-07 11:39:08 +09:00
09b0538c9c [fix] solution/site: 사이트맵 lastmod 를 파일 mtime 에서 뗀다 — 페이지의 dateModified 를 그대로 쓴다
lastmod 를 구운 index.html 의 mtime 에서 읽었다. 렌더러를 배포하면 번들 해시가 바뀌어
내용이 같은 사이트까지 전부 다시 구워지고, mtime 은 그때마다 오늘이 된다 — 사이트맵이
"전 사이트가 오늘 갱신됨" 을 통보한다.

구글은 lastmod 를 페이지의 실제 수정과 대조해 맞을 때만 쓰고 어긋나면 필드를 아예 무시한다
(Search Central: "the date and time of the last significant update" · "consistently and
verifiably accurate"). 지금 뭘 깨뜨리는 게 아니라, 사장님이 진짜로 내용을 고쳐 재발행한
날의 신호를 미리 죽여 두는 종류다. 사이트가 100개를 넘기면 되돌리는 데 시간이 걸린다.

- seo/directory.ts: readBakedTitle · readBakedLastmod 추가. lastmod 는 head 가 선언한
  dateModified(= payload.site.updatedAt) 그 값이다 — 구글이 대조하는 값과 같아 어긋날 수 없다
- scripts/prerender.ts: 사이트맵 항목에서 mtime 제거, 파일 한 번 읽어 제목·lastmod 동시 추출.
  mtime 은 dateModified 메타가 없던 산출물에만 남는 폴백이다
- seo/directory.test.ts: head.ts 태그와 파서의 커플링 고정 — 모양이 바뀌면 파서가 조용히
  undefined 를 내고 mtime 으로 되돌아간다(빌드도 화면도 멀쩡한 회귀)
- docs/DEVLOG.md

tsc·eslint 통과, vitest 22 passed (신규 5건)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4
2026-09-07 11:39:08 +09:00
533de126fb [feat] solution/frontend,nginx: 랜딩·요금·사례를 프리렌더 — 크롤러가 빈 종이를 받던 것
실측(2026-09-07): `curl /` 가 3,021바이트에 본문 0자·`<a>` 0개였다. 같은 호스트의
발행본은 48,072바이트다. 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규
도메인은 뒤로 밀린다 — 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다.
서치콘솔이 "URL이 Google에 등록되어 있음"이라고 답하면서도 브랜드명 검색에조차 안
걸리던 이유다.

스크립트를 새로 짜지 않았다. react-router 7.17 에 프리렌더가 내장돼 있고
`ssr: false` 와 함께 쓰면 런타임 Node 서버 없이 지정한 경로만 HTML 로 굽는다 —
나머지는 지금까지처럼 SPA 폴백이다. 배포 구조가 그대로다.

- react-router.config.ts: `ssr:false` + `prerender: ['/', '/pricing', '/showcase']`.
  로그인 뒤에만 의미가 있는 화면은 굽지 않는다(구울 내용이 사용자별이다)
- src/root.tsx · src/routes.ts: 예전 index.html + app/router.tsx 가 하던 일.
  가드는 페이지마다 감싸지 않고 RequireAuthLayout 레이아웃 라우트 하나로 모았다
- 랜딩·요금·사례에 meta export: 제목을 브랜드가 아니라 **검색어**로 시작하게 바꿨다.
  예전 제목("Web4Ai · AI 웹 빌더")에는 사람이 치는 말이 한 단어도 없었다.
  랜딩에 Organization JSON-LD 추가 — 발행본에는 있는데 정작 랜딩엔 없었다
- src/lib/site.ts: 발행 호스트의 단일 출처. 모듈 최상위의 `window.location` 폴백을
  전부 걷었다 — 서버 번들은 라우트를 한 파일로 묶어서 프리렌더 대상이 아닌 화면의
  최상위 코드도 빌드 때 실행된다(실측: BuilderPage 에서 빌드가 죽었다)
- LoginPage: homePath 기본값 `/` → `/sites`. 예전엔 router.tsx 가 넘기던 값이라
  라우트 모듈로 옮기면서 그대로 두면 로그인 후 랜딩으로 갔다
- nginx: SPA 폴백을 `/index.html` → `/__spa-fallback.html`. 프리렌더 뒤로
  `/index.html` 은 **랜딩이 구워진 파일**이라, 그리로 넘기면 `/builder` 에 랜딩
  HTML 이 내려가고 클라이언트가 다른 주소로 하이드레이트한다
- nginx/Dockerfile: 산출물이 `dist` → `build/client`. 경로가 어긋나면 COPY 가
  조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다
- site/seo/robots.ts: `/builder` `/login` `/signup` `/sites` `/account` Disallow.
  이 경로들은 빈 SPA 폴백을 받는다 — 긁히면 호스트 전체에 저품질 신호가 쌓인다

검증: tsc·eslint·react-router build 통과.
랜딩 3,021B → 21,799B, 본문 1,278자, 링크 6개.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
2026-09-07 11:30:27 +09:00
9aa93b282c [feat] solution/site,nginx: 발행본 목록 페이지와 루트 llms.txt — 크롤 경로를 둘로 늘린다
서치콘솔 URL 검사(2026-09-07, /s/stay): "참조 페이지: 감지된 페이지 없음".
색인은 됐는데 이 호스트의 어떤 페이지도 발행본을 가리키지 않아, 크롤러가 발행본에
닿는 길이 사이트맵 하나뿐이었다. 사이트맵은 "이런 주소가 있다"만 말하고 볼 가치가
있는지는 말하지 않는다 — 그래서 색인은 되고 순위는 0인 상태가 됐다.

랜딩의 쇼케이스는 API 를 fetch 해 그리는 클라이언트 렌더라(ShowcaseGrid.tsx)
JS 를 실행하지 않는 크롤러에게는 없는 링크다. 그래서 정적 HTML 로 따로 굽는다.

- seo/directory.ts: `/s/` 목록 페이지(CollectionPage + ItemList LD)와 루트 llms.txt.
  목록의 제목은 payload 가 아니라 **구운 index.html 의 <title>** 에서 읽는다 —
  발행은 바뀐 사이트 하나만 굽기 때문에 payload 로 만들면 나머지가 슬러그로 떨어진다
- prerender.ts: 사이트맵에 랜딩과 목록 페이지를 추가. 목록 주소는 끝 슬래시가 있어야
  한다 — nginx 의 `location ^~ /s/` 가 슬래시로만 잡고, 없으면 사장님 앱으로 떨어진다
- nginx: `location ^~ /s/` 에 `index index.html`. try_files 첫 인자가 끝 슬래시면
  nginx 가 디렉토리 검사로 읽고 거기서 멈춰 403 이 된다(=404 로도 안 떨어진다)

★ 루트 llms.txt 의 기대치: 구글은 안 쓴다고 공식 확인했고(2025-07 Illyes) 크롤러
  트래픽으로도 거의 안 잡힌다(90일 5억 방문 중 408건). 두는 이유는 에이전트 경로
  하나다 — 사용자가 AI 에게 "이 사이트 봐줘" 할 때의 fetch 는 봇 집계에 안 잡힌다.
  비용이 함수 하나라 채택되면 이미 있는 쪽을 택했다. 발행본별 llms.txt 는 그대로다.

검증: tsc(solution/site) 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
2026-09-07 09:45:00 +09:00
34e5932fbc [fix] solution/frontend,site: 랜딩 색인 해제 + 사이트맵 URL 을 canonical 과 일치 — 크롤러의 유일한 문을 연다
이 호스트에는 들어오는 링크가 없다. 크롤러가 발행 사이트를 찾는 경로는 사이트맵
하나뿐인데 그 문이 두 군데서 막혀 있었다.

- 랜딩('/')에 `noindex, nofollow` 가 박혀 있었다. 관리자 화면이라고 보고 걸어 둔
  것인데(7a6fee7 로 '/' 관문이 사라져 이제는 랜딩이다), nofollow 때문에
  랜딩→발행 사이트로 이어지는 발견 경로까지 함께 죽어 있었다.
- 사이트맵이 `/s/<slug>/` 를 담는데 페이지 canonical 은 `/s/<slug>` 다
  (shared/lib/slug.ts publishUrl). 서치콘솔은 제출 URL 을 전부 "대체 페이지"로
  분류한다 — 색인은 되는데 제출분 0건으로 보이는, 조용히 틀리는 종류다.

- frontend/index.html: robots 를 index,follow 로. description·canonical·og 추가.
  호스트는 적지 않고 Vite 가 빌드 때 `%VITE_PUBLISH_HOST%` 를 치환한다
  (compose 가 루트 SITE_PUBLIC_HOST 를 흘려보낸다) — 두 곳에 적으면 갈라진다
- site/scripts/prerender.ts: 사이트맵 loc 의 끝 슬래시 제거 + 오리진 루트를 첫 항목으로

남은 것: 랜딩 body 가 빈 SPA 셸이라 렌더링에 기댄다. 랜딩 프리렌더는 별건이다.

검증: tsc(solution/site) 통과 · vite build 로 %VITE_PUBLISH_HOST% 치환 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
2026-09-07 09:07:39 +09:00
7a6fee70c1 [fix] solution/frontend: 로그인해도 랜딩·요금에 갈 길을 낸다 — '/' 관문 제거
로그인하면 '/' 가 무조건 /sites 로 튕겼다. 그래서 **로고를 눌러도 랜딩이 안 뜨고**,
/pricing 은 살아 있는데 링크가 랜딩과 MarketingShell 에만 있어 로그인한 사장님은
주소를 직접 쳐야 했다. 요금은 쓰는 도중에 확인하는 값이지 가입 전에만 보는 값이 아니다.

- app/router.tsx: Home 의 리다이렉트 제거. '/' 는 누구에게나 랜딩이다.
  "로그인 직후엔 내 사이트로" 는 로그인·가입 화면이 직접 보낸다.
- pages/LoginPage.tsx: 도착지를 `homePath` prop 으로 받는다. 기본값 '/' 라
  내부 운영 앱(자기 '/' 가 사업장 목록으로 간다)은 그대로다. 사장님 앱만 '/sites'.
- pages/SignupPage.tsx: 가입 후 '/' → '/sites'. 안 그러면 관문이 없어진 지금 랜딩에 떨어진다.
- 로그인·가입 화면 로고에 '/' 링크. 그 화면에서 빠져나갈 길이 하나도 없었다.
- layout/AppShell.tsx: 로그아웃 → '/login' 이 아니라 '/'. 나간 사람에게 로그인 폼을
  다시 들이밀지 않는다. 사이드바에 [요금] 추가.
- layout/MarketingShell.tsx: 헤더 [무료로 만들기] 제거 — 히어로 입력 카드가 이미 그 자리다.
  시작하는 문이 한 화면에 둘이면 어느 쪽이 진짜인지 고르게 만든다.

검증: tsc·eslint 통과. docker compose up -d --build solution-site 로 띄워 눌러 확인 —
로그인 상태에서 로고 → 랜딩, 헤더는 [이렇게 나옵니다 · 요금 · 내 사이트].
2026-09-04 17:29:13 +09:00
238d4c25c4 [feat] solution: 네이버 플레이스를 검색 단계에서 찾는다 — 로그인은 수집 직전 한 번
사장님이 후보를 고른 뒤에도 "네이버 플레이스를 자동으로 찾지 못했습니다" 가 떴다.
찾을 수 있는데도 그랬다 — 자동 발견이 확정 경로(로그인 뒤)에만 있었고, 공개 검색은
상호·주소만 돌려줬다. 그리고 확정이 사업장 생성을 요구해서, 로그인 없이 시작하기로 한
위저드가 검색 직후부터 막혔다(자동 로그인이 그걸 가리고 있었다).

- place_service.search_places_public: 응답에 naver_place_url 을 싣는다. 넓은 검색어
  한 페이지에서 못 찾은 후보는 그 후보만 겨냥해 다시 찾는다(상위 2건, 429 회피).
  실측: 12개 상호 전부 발견. 전에는 4개 중 2개
- naver_place_lookup._region_hint: 주소에서 시·군·구까지만 뽑아 검색을 좁힌다.
  첫 토막('경기도')만 쓰면 **다른 동네 동명 업소**가 잡히고, 그 id 로 검증하면 남의
  가게가 이 사이트의 기준 정보가 된다 — 실측으로 한 번 겪었다
- ttl_cache(신규) + 공개 검색 10분 캐시: 검색 1회가 네이버를 최대 3번 긁는데 인증이
  없어 새로고침만으로 나간다. 실측 1.38s → 0.005s. **빈 결과는 캐시하지 않는다** —
  일시적 0건을 굳히면 사장님이 10분간 막힌다
- 확정은 서버를 부르지 않는다(usePlaceSearch). 화면에만 남기고, 수집 직전 로그인 뒤
  ensureServerPlace 가 생성 → 검증을 한 번에 한다. 나눠 두면 "사업장은 생겼는데 검증이
  빠진" 상태가 생기고 수집이 PLACE_NOT_VERIFIED 로 조용히 거절된다
- Step3: 수집 버튼이 로그인 모달을 연다(/login 으로 튕기지 않는다 — 위저드 상태가
  주소창에 없어 돌아올 길이 없다). 로그인하면 이어서 돈다
- Step2: 후보 카드에 '네이버 플레이스 찾음' 배지. 붙여넣기 칸은 접는다 —
  펼쳐 두면 시도도 전에 실패한 것으로 읽힌다. 뒤로 오면 처음 화면으로
- ChannelUrlInput: [추가] → [이 주소로 가져오기]. 로그인 전에는 addLink 가 placeId 가
  없어 **조용히 return** 해서 입력칸만 비워졌다(useChannelLinks.ts:37)
- LoginPage: admin/1234 기본값 제거. 배포 번들에 그대로 나가 있었다
- 기본 발행 호스트를 localhost 로(compose 4곳 · site_payload.DEFAULT_HOST · .env.example).
  운영 도메인을 기본값으로 두면 .env 를 안 채운 로컬 빌드가 조용히 운영 주소를 번들에
  굽는다 — 실측: 로컬에서 만든 링크가 킹서버로 갔다. localhost 는 http 로 조립한다

검증: tsc·eslint·vite build 통과. 브라우저로 전 구간 확인(검색 → 확정 → 로그인 →
자동 발견 → 검증 → 수집 fact 27건·사진 10장·메뉴 23건 → 사진 분석).
백엔드 테스트는 이 워크트리에서 못 돌렸다 — config.test.toml 이 없어 DB 인증이 실패한다.
2026-09-03 16:37:12 +09:00
a67169dfa2 [chore] solution/frontend: 네이버 소유확인 파일 추가 — 세 엔진 중 마지막
네이버 서치어드바이저는 DNS TXT 를 안 받는다. HTML 파일 아니면 메타태그뿐이라
구글·빙과 같은 자리에 둔다.

- public/naver71762ee96e2e126623dc1da07ecae493.html

★ 확인은 상태코드가 아니라 **내용**으로 한다 — nginx 의 `try_files ... /index.html` 이
없는 경로에 SPA 를 200 으로 돌려준다.
2026-09-03 11:32:58 +09:00
bce0928385 [chore] deploy,solution,docs: 레포·발행 호스트 교체 — o2o-site-AEO / web4ai.o2osolution.ai
옛 주소 w4ai.o2o.kr 은 앞단에 vhost 가 없어 전 경로가 Apache 자체 404 다(인증서도
CN=actions.o2o.kr, 2024 만료). 그런데 canonical·og:url·sitemap 이 전부 그 주소를
가리키고 있었다 — **화면은 멀쩡하고 기계가 읽는 값만 틀린** 상태라, 검색엔진에
아무리 등록해도 색인이 안 되는 종류다.

- 기본 호스트를 쓰는 자리 전부: site_payload.DEFAULT_HOST · compose 의 `:-` 기본값 4곳 ·
  vite.config.ts allowedHosts · .env.example 둘 · check_search_ready.py · 데모 픽스처
- init.sql: site.sites.thumbnail_url 을 "기존 DB 보정(ALTER)" 절에 추가.
  CREATE TABLE 에만 있어서 **새 DB 는 되고 기존 DB 만 조용히 깨졌다** —
  실측(킹서버): GET /v1/showcase 가 200 인데 내용이 비었다
- docs/SERVERS.md: 배포 경로 ~/data2/o2o-site-AEO · 새 remote · 공개 주소 절 ·
  init.sql 이 DB 최초 생성 때만 돈다는 함정
- docs/DEVLOG.md: 항목 추가

테스트 픽스처의 w4ai.o2o.kr 은 그대로 뒀다 — 자기가 넣은 값을 자기가 검증해서
기본 호스트와 무관하다.

tsc·eslint 통과. vite build 는 도커에서 확인(로컬 node_modules 의 rollup 네이티브 누락).
2026-09-03 11:31:34 +09:00
d6118165c4 [chore] solution/frontend: 구글 소유확인 파일 추가 — 새 호스트 기준으로 다시 받은 것
발행 호스트를 web4ai.o2osolution.ai 로 옮기면서 w4ai.o2o.kr 로 받아 둔 소유확인이
전부 무효가 됐다. HTML 파일 방식으로 다시 받았다.

- public/google60b514c02fd6af4e.html: Bing 것과 같은 자리다. 이미지에 구워야
  컨테이너 재생성에도 살아남는다(docker cp 로 넣으면 다음 배포에 사라진다)

확인은 상태코드가 아니라 **내용**으로 한다 — nginx 가 없는 경로를 index.html 로
떨어뜨려 200 을 준다.
2026-09-03 11:25:11 +09:00
a4ec508ba5 [fix] solution/frontend: 도는 제목이 안 움직이던 것 + 히어로를 입력 카드 구조로 · 헤더 확대
**애니메이션이 안 보이던 진짜 이유** — `.o2o-rotator-track` 이 <span> 이라 display:inline 이었다.
인라인 요소에는 transform 이 적용되지 않는다. 애니메이션은 걸려 있고 화면만 정지였다.
(computed style 로는 animationName 이 보여서 더 헷갈린다.)

- 히어로를 아임웹 구조로: 한 줄 입력창이 아니라 **큰 입력 카드**, 발행 사이트 띠가 그 카드
  **뒤로 full-bleed** 로 지나간다. 카드 아래 따로 두면 첫 화면이 세로로 길어진다
- 카드 안: 브랜드 라벨 + [업종부터 고르기] · 큰 입력 · 하단 업종 안내 + 원형 제출 버튼
- 제목 sm:text-6xl lg:text-7xl, leading 1.15
- 헤더 h-14→h-16, 로고 h-6→h-8, 메뉴 14px, 로그인도 버튼(맨 텍스트면 눌리는 걸로 안 보인다)
- 헤더가 커진 만큼 히어로 높이 계산도 4rem 으로 — 안 바꾸면 스크롤바가 생긴다
2026-09-03 11:02:42 +09:00
ee8ba59036 [fix] solution/frontend: 히어로 검색창을 pill 에서 rounded-xl 로 — 페이지 카드 곡률과 어긋났다 2026-09-03 10:59:13 +09:00
0c3f8dd3b0 [feat] solution/frontend: 히어로를 첫 화면 높이로 · 검색창 pill · 쇼케이스 카드 축소
콘텐츠가 세로로 퍼져서 첫 화면에 임팩트가 없었다.

- 히어로 min-h-[calc(100dvh-3.5rem)] + 세로 가운데. 헤더(h-14)를 빼야 스크롤바가 안 생기고,
  vh 가 아니라 dvh 인 이유는 모바일에서 주소창이 접혔다 펴져 vh 가 흔들리기 때문
- 검색창·버튼을 rounded-full h-13 로. 아이콘 여백도 같이 밀었다
- 마퀴 카드 w-52 → w-40, 간격·글자 축소. 히어로 안에 들어와야 한 화면에 다 담긴다
2026-09-03 10:58:02 +09:00
d8ed0766d6 [feat] solution/frontend: 히어로 문구 여섯으로 · 보조 문구 제거 — 세로로 퍼지던 걸 줄인다
앞말이 넷이라 금방 반복됐고, 그 아래 보조 문구 세 줄이 히어로를 세로로 늘려 임팩트를 깎았다.

- 앞말 6개: SEO · AEO 최적화 / AI가 먼저 찾는 / 챗GPT가 인용하는 / 검색에 바로 걸리는 /
  손님이 먼저 만나는 / 우리 가게가 직접 말하는
- ★ 문구 개수와 index.css 의 o2o-rotate-6 키프레임은 한 몸이다. 늘리면 stop 도 고쳐야 한다 —
  안 고치면 뒤쪽이 영영 안 보이거나 빈 줄이 지나간다
- 보조 문구 3줄 삭제, 제목 sm:text-6xl 로 키우고 세로 여백 축소
2026-09-03 10:53:41 +09:00
d5e9b87e3f [feat] solution/frontend: 히어로 제목의 앞말이 돈다 — '홈페이지' 는 고정
"SEO · AEO 되는 웹사이트" 는 규격 이름이지 문구가 아니었다. 아임웹처럼
[형용사절] + [명사] 구조로 바꾸고, 명사를 고정한 채 앞말만 갈아 끼운다.

- AI가 먼저 찾는 / 챗GPT가 인용하는 / 검색에 바로 걸리는 / 손님이 먼저 만나는
- CSS 만으로 돈다(마퀴와 같은 이유 — 타이머는 백그라운드 탭에서 밀린다)
- 문구를 다섯 줄 쌓고 마지막을 첫 줄의 복제로 둔다. -80% 에서 0% 로 되감을 때 글자가 안 튄다
- 줄 높이를 1.25em 으로 못 박는다 — 창 1줄 · 트랙 5줄이라 한 칸이 정확히 20% 여야 한다
- 복제분은 aria-hidden. SEO·AEO 는 제목에서 빼 보조 문구로 내렸다

tsc·eslint·vite build 통과
2026-09-03 10:45:38 +09:00
4ef8e7d232 [feat] solution/frontend: 히어로 아래 발행 사이트 마퀴 — 입력칸만으론 뭐가 나오는지 모른다
상단에 입력칸 하나만 있으면 무엇이 만들어지는지 알 수 없다. 결과물을 바로 밑에서 흘린다.

- CSS 만으로 돈다(index.css o2o-marquee). setInterval 로 돌리면 탭이 백그라운드일 때
  프레임이 밀려 돌아왔을 때 툭 끊긴 것처럼 보인다
- 같은 목록을 두 벌 그리고 트랙을 -50% 까지만 민다 — 한 벌이면 끝에서 빈 화면이 지나간다
- 개수가 늘어도 흐르는 속도는 그대로(카드 수에 비례해 duration)
- 복제분은 aria-hidden·tabIndex=-1 — 스크린리더가 같은 목록을 두 번 읽지 않게
- hover 하면 멈추고, prefers-reduced-motion 이면 아예 안 움직인다
- CTA 문구를 [확인하기] 로(시안과 같게)

tsc·eslint·vite build 통과
2026-09-03 10:34:02 +09:00
fd716222ad [feat] solution/backend: 썸네일 백필 스크립트 — 이미 발행된 사이트는 채울 길이 없었다
썸네일은 발행 잡이 끝날 때 만들어진다. 그래서 이 기능이 들어오기 전에 발행된 사이트는
thumbnail_url 이 영영 NULL 이고 쇼케이스에서 글자 카드로만 나온다. 재발행을 시키면
채워지지만 사장님 사이트를 우리 사정으로 다시 굽는 건 다른 일이라 썸네일만 따로 만든다.

- 발행본 HTML 을 건드리지 않는다. 읽는 건 snapshot 의 사진 목록, 쓰는 건 thumbs/ 와 컬럼 한 칸
- --dry-run 은 Azure 설정 없이도 돈다(대상이 맞는지 먼저 봐야 한다)
- 한 건 실패가 나머지를 막지 않는다

로컬 dry-run: 발행 15건 전부 대표 사진 있음
2026-09-03 10:31:52 +09:00
af391b4c56 [feat] solution/frontend,docs: 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 없었다
`/` 가 곧장 위저드로 튀어서 이 제품이 무엇을 파는 물건인지 말할 자리가 없었다.
처음 온 사람이 업종 선택 화면부터 만난다.

- router: `/` 는 비로그인 랜딩 · 로그인 /sites. /pricing · /showcase 추가
- MarketingShell: 사이드바 없는 문서형 껍데기. AppShell(작업 화면)과 나눴다
- 랜딩 상단은 상호명 한 칸. 문구는 SEO·AEO 축으로만 쓴다 —
  '쉽게·빠르게'로 말하면 홈페이지 빌더와 같은 자리에서 비교당한다(PRODUCT 1절)
- ShowcaseGrid: 발행 썸네일을 그대로 건다. 예시 데이터로 채우지 않고, 없으면 섹션을 감춘다.
  ★ 생성 클라이언트를 안 쓴다 — 토큰 길목을 지나면 비로그인에서 못 부른다
- 요금은 플랜 하나(70만원/월) + 월 산출물. 비교표를 만들지 않는다

tsc·eslint·vite build 통과
2026-09-03 10:21:16 +09:00
a61f9724ea [feat] solution/frontend: 온보딩을 상호명부터 — 단계를 주소창으로 옮기고 업종은 검색이 정한다
업종을 먼저 고르게 하면 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그런데 상호명은
100% 안다. 그리고 업종은 AI 를 부를 필요가 없다 — 카카오·네이버 검색 응답에 분류가
이미 들어 있고(category_group_code), 지금까지 받아 놓고 안 썼다.

- 단계가 스토어에서 주소창으로: ?step=search|industry|collect|template|generating|editor.
  번호가 아니라 이름인 이유 — 단계가 4→3 으로 줄어 옛 북마크가 다른 화면을 연다
- 시작점이 상호명 검색(Step2PlaceSearch)이다. 업종 선택은 못 정했을 때의 갈래로 남는다
- 업종은 후보의 category 로 잡히고, 못 정하면 고르게 하고, 정해져도 [바꾸기] 로 바꾼다.
  ★ 확정 뒤 변경은 신원 확인부터 다시 받는다 — Req_UpdatePlace 에 category 가 없어
    PATCH 로 못 고치고, placeAdapter 가 리페치마다 덮어써서 조용히 되돌아간다
- 랜딩 진입: ?new=1 · ?q=<상호명> · ?industry=<업종>. 한 번 읽고 replace 로 지운다
- ★ ?new=1 이 setSearchParams({}) 로 **모든 쿼리를 날리던 것**을 고쳤다 — q 가 읽히기 전에 사라졌다
- selectIndustry 가 사장님이 친 상호·위치를 지우던 것도 고쳤다(업종이 첫 화면일 땐 늘 빈 값이라 안 보였다)
- 로고는 어디서나 / 로 간다. 에디터에서는 span 이라 아예 안 눌렸다

tsc·eslint·vite build 통과
2026-09-03 10:20:57 +09:00
41e49c693b Merge branch 'main' into 홈페이지-레이아웃-구상
# Conflicts:
#	docs/DEVLOG.md
#	solution/backend/crud/site_crud.py
#	solution/backend/router/router.py
#	solution/backend/router/v1/site/protocol.py
2026-09-03 09:44:51 +09:00
2026fde80f [feat] solution/backend: 상호명 공개 검색 + 업종 자동 판별 — 랜딩이 로그인 앞에서 부른다
랜딩 첫 화면이 상호명을 받으려면 검색이 로그인 앞에 있어야 하는데, 후보 조회는
place_id 와 토큰을 둘 다 요구했다. 로그인 관문을 에디터 진입 하나로 되돌려 놓고도
(b94daa9) API 는 그대로였다.

업종은 AI 를 한 번 더 부를 필요가 없다 — 카카오·네이버 검색 응답에 분류가 이미
들어 있고(category_group_code / category_name), 지금까지 받아 놓고 안 썼다.

- place.py: GET /v1/place/search 신설(인증 없음). ★ /{place_id} 앞에 둬야 한다 —
  뒤에 두면 "search" 가 place_id 로 잡혀 422 다
- place_category: AD5·CE7·FD6 우선, 없으면 분류 문자열. 못 정하면 None —
  억지로 고르면 틀린 스키마로 시작한다. HP8 은 피부과·성형외과일 때만
- kakao: KakaoPlace 에 category_group_code. 한글 분류는 바뀌어도 코드는 안 바뀐다
- rate_limit: 인증 없이 유료 API 를 부르는 경로라 IP 당 분당 20회(프로세스 메모리)
- 확정 경로(verify/candidates)는 인증 유지 — 남의 place_id 존재 여부를 열지 않는다

전체 562 passed
2026-09-03 09:41:34 +09:00
e8cda02a4b [feat] solution/backend,postgres-init: 발행 썸네일 저장 + 공개 쇼케이스 목록 — 랜딩이 실물을 걸 자리
랜딩의 "이렇게 나옵니다" 섹션이 걸 그림이 없었다. 발행은 되는데 그 사이트가
어떻게 생겼는지 밖에서 알 방법이 payload 안에만 있었다.

★ 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다. 헤드리스 브라우저는
  봇 탐지 우회 우려로 영구 금지돼 있고(DECISIONS 1-1), 워커(python:slim)·
  프리렌더(node:alpine) 어디에도 Chromium 이 없다.

- site_thumbnail: 대표 사진을 받아 <prefix>/thumbs/<slug>.<ext> 로 올린다.
  s/<slug>/ 안에 두지 않는 이유 — _remove_stale_site_files 가 매 발행마다
  그 경로를 프리렌더 산출물로 통째로 교체해 조용히 지운다
- site_payload: primary_media()·publish_origin()·region_label() 공개.
  isPrimary 계산을 한 곳으로 모아 og:image 와 썸네일이 갈릴 수 없게 했다
- build_service: azure publish 직후·IndexNow 전에 저장. 실패해도 발행은 그대로
  (payload 와 같은 원칙). thumbnail_url 은 발행 상태 전이 UPDATE 에 합쳐 1회
- GET /v1/showcase: 인증 없음. 발행된 사이트만, place_id·전화·상세주소는 안 나간다
- conftest: fake_renderer 가 늘 ok=True 라 NO_UNIQUE_CONTENT 되짚기 경로가
  통째로 안 돌고 있었다(기존에 깨져 있던 테스트 4건 포함 수정)

전체 562 passed
2026-09-03 09:41:10 +09:00
a52d166fae Merge branch 'main' into feature/auth 2026-09-02 22:56:27 +09:00
e2d15955b0 [fix] solution/frontend,deploy: :80 에서 로그인이 CORS 로 막히던 것 — API 를 같은 오리진으로
화면은 http://localhost(:80) 인데 번들이 http://localhost:9800 을 직접 불렀다. 백엔드 허용
오리진 기본값은 :3000~3005 뿐이라 브라우저가 막았고, 화면에는 '로그인에 실패했습니다'
(네트워크 예외 문구)만 떴다 — 아이디·비번 문제로 보인다.

nginx 가 이미 /v1 을 프록시한다(site.conf). 그쪽으로 부르면 CORS 를 아예 안 탄다.

- .env: PUBLIC_API_BASE_URL=http://localhost — 번들이 같은 오리진을 보게 한다
- LoginPage: 개발 편의로 admin/1234 기본값. ★ 운영 전에 빈 문자열로 되돌릴 것

브라우저 확인(localhost:80): 로그인 → /builder, 사이드바 '관리자 · 데모대행사'.
2026-09-02 22:56:22 +09:00
b07ade25b2 [fix] solution/frontend,docs: 온보딩 위저드에서 사이드바 제거 — 사이트가 되기 전엔 사이트 메뉴가 없다
사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 그걸 얹으면,
만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다.
아임웹도 사이트 개설 흐름에는 계정 사이드바를 붙이지 않는다.

- BuilderPage: 위저드를 AppShell 대신 얇은 상단 바(로고 + 나가는 길)로. 진행은 WizardSteps 가
  이미 보여준다. 비로그인은 돌아갈 목록이 없어 그 자리에 [로그인] 을 둔다
- BuilderPage: 에디터 헤더에 [← 내 사이트] — "내 사이트 관리가 생기면 그때 잇는다"고
  비워 뒀던 자리다
- DEVLOG: 계정 레벨/사이트 레벨을 가른 근거

검증 — tsc·eslint·vite build 통과. 위저드에 사이드바가 사라진 것은 브라우저에서 확인
2026-09-02 22:43:38 +09:00
282427e10b [feat] solution/frontend: 내 사이트 · 내 정보 — 로그인 후에 갈 곳이 생겼다
로그인해도 갈 곳이 없었다. 사업장 목록은 내부 운영 앱(admin)으로 나갔고 사장님 앱에는 그 경로가
없다. 아임웹도 같은 자리를 계정 레벨(내사이트 · 마이페이지)로 두고, 사이트 레벨(관리자 페이지)과
가른다 — 우리는 그 사이트 레벨이 에디터다.

- pages/SitesPage: 줄을 누르면 에디터로 간다(목록에 온 용건은 열에 아홉 "내 사이트 고치기").
  [사이트 열기] 는 PUBLISHED 일 때만 — 주소는 발행 전에 예약돼서, 주소만 보고 열면 404 다.
  ⋯ 메뉴에는 [발행 내리기] 하나. ★ 삭제는 두지 않았다 — 색인된 페이지를 404 로 만들면
  그 자리를 다시 OTA 가 가져가고 되돌릴 방법이 사장님에게 없다(sites.status 주석)
- pages/AccountPage: PATCH /v1/auth/me 가 받는 것만 그린다. 구글 계정은 비밀번호 칸을 접는다
  (서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다). 상호는 읽기 전용 — Req_UpdateMe 에 없다
- router: `/` 가 로그인 여부로 갈린다. 복구(isRestoring) 전에는 판단하지 않는다 —
  아니면 새로고침마다 위저드가 번쩍이고 목록으로 튄다
- AppShell: 메뉴에 [내 사이트], 계정 이름 자리가 [내 정보] 입구

검증 — tsc·eslint·vite build 통과(frontend·admin)
2026-09-02 22:43:10 +09:00
479edf9403 [feat] solution/backend: 내 사이트 목록 엔드포인트 — places LEFT JOIN sites 단일 질의
로그인한 사장님이 자기 사이트를 볼 화면이 없었다. 사이트는 place_id 로 한 건씩만 읽혀서
(site_crud.get_site_by_place) 사업장 목록으로 그리면 줄마다 사이트를 다시 물어 N+1 이 된다.

- site_crud.list_company_sites: places LEFT JOIN sites LEFT JOIN site_versions 한 번.
  사이트가 아직 없는 사업장(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다
- protocol.MySiteData: 한 줄 = 사업장 + 사이트. render(정적 파일 존재)는 넣지 않았다 —
  보고서 파일을 읽는 값이라 줄 수만큼 파일 IO 가 된다. 단건(Res_Site)이 계속 소유한다
- site_service.list_my_sites: 회사 스코프. needs_rebuild 는 단건과 같은 규칙으로 판정한다
- GET /v1/site/list 는 라우터 객체를 따로 둔다 — 기존 라우터는 접두어에 place_id 가 박혀 있다

테스트 5건 추가(비어 있는 사업장·조인·회사 격리·재빌드 일치·비로그인), 539 passed
(기존 실패 4건은 이 변경 전에도 같다 — build_publish 3 · snapshot 1)
2026-09-02 22:37:53 +09:00
128596fc74 [feat] solution/frontend: 구글 버튼을 로그인 관문에도 · 문구 한글화
"구글 로그인 버튼이 없다" 는 지적이 맞았다. 버튼을 LoginPage·SignupPage 에만 붙여 뒀는데,
이 앱에서 사장님이 실제로 로그인 화면을 만나는 자리는 **에디터 진입 관문**(EditorSignInGate →
SignInForm)이다. 정작 거기엔 없었다.

- features/auth/SignInForm: 구글 버튼 추가. 관문·로그인 화면이 같은 폼을 쓰므로 한 곳만 고치면 된다
- lib/googleIdentity: 스크립트를 ?hl=ko 로 받는다. renderButton 의 locale 옵션은 안 먹었다 —
  'ko'·'ko_KR' 둘 다 'Continue with Google' 이 그대로 나왔다(실측)
- 버튼 문구는 signin_with('Google 계정으로 로그인'). 가입 화면만 signup_with 로 둔다.
  문구 자체는 고를 수 없다 — 구글 브랜드 가이드라 GIS 가 주는 번역을 그대로 쓴다

브라우저 확인(localhost:80): 로그인 화면에 'Google 계정으로 로그인' 한글 노출.
tsc·eslint·vite build 통과.
2026-09-02 22:34:28 +09:00
d376677b86 Merge branch 'main' into 스테이멍뭉아이템만ㅇ들ㅇ어 2026-09-02 21:59:02 +09:00
65705c5bed [feat] solution/shared,site,frontend: 계절별 추천 하루는 지금 계절만 — 간절기엔 두 계절
네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다.
12월에 온 손님에게 봄 벚꽃 코스를 권할 이유가 없다.

- shared/currentSeasons: 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울.
  계절 첫 달의 전반(1~15일)은 간절기로 보고 앞 계절과 함께 둘을 돌려준다 —
  9월 초에 여름만 보이면 지난 계절이고, 가을만 보이면 아직 이른 코스다
- site/PlannerSection: HTML 에는 전 계절을 굽고 화면에서만 접는다(hidden).
  ① 정적 페이지는 한 번 구우면 몇 달 산다. 굽는 시점의 계절을 박으면 12월에도 가을이 걸려서,
     계절 판정을 브라우저에서 한다(일력의 '오늘'과 같은 수법)
  ② 이 사이트의 존재 이유가 인용이다. 지우면 검색·AI 가 나머지 계절을 못 읽는다
  지금 계절에 코스가 없으면 접지 않고 전부 보여준다 — 빈 섹션보다 철 지난 코스가 낫다
- frontend/PlannerPodium: 탭은 그대로 두되 지금 계절로 열리고, '·지금' 표시와
  "손님 화면에는 지금 계절만 나갑니다" 한 줄. 안 적으면 사장님은 손님도 넷을 다 본다고 오해한다

tsc·eslint 통과(frontend·site). 경계 12일자 확인(3/5 겨울·봄 · 9/2 여름·가을 · 9/16 가을).
실물 payload(스테이,머뭄 /s/stay, 9코스 4계절)로 구워 오늘 여름·가을만 보이고 봄·겨울은
hidden, HTML 에는 네 계절 전부 있는 것을 브라우저에서 확인.
2026-09-02 21:58:50 +09:00
e1423418ae [fix] solution/frontend: 비로그인 상태의 사이드바 — 눌러도 아무 일 없는 [로그아웃] 제거
위저드는 로그인 없이 열린다(관문은 에디터 진입이다). 그런데 사이드바는 로그인 여부와
무관하게 [로그아웃]만 그렸다 — 로그인한 적 없는 사람에게는 **이름이 빈 줄로 나오고,
버튼을 눌러도 지울 세션이 없어 아무 일도 일어나지 않았다.** "유저 정보가 어디에도 안 보인다"가
이것이다.

- 비로그인: '로그인하지 않았습니다' + [로그인] 링크
- 로그인: 이름 · 상호 + [로그아웃]. 로그아웃은 스토어만 비우면 화면이 그대로라 눌러도
  아무 일이 없는 것처럼 보인다 — /login 으로 보낸다

브라우저 확인: 비로그인 위저드에서 안내와 [로그인] 노출 → 로그인 후 '김사장 · 달빛스테이' →
[로그아웃] 클릭 시 /login 이동. tsc·eslint·vite build 통과.
2026-09-02 21:38:43 +09:00
cfec47230c Merge branch 'main' into feature/auth
# Conflicts:
#	docs/DEVLOG.md
2026-09-02 21:34:12 +09:00
01c252287e Merge branch 'main' into feature/auth — 관문은 main 의 에디터 진입으로
main 이 5ef3e5a 로 문 앞 게이트(d6a6c8e·b94daa9)를 되돌렸다. 근거가 내가 못 본 것이었다 —
`/` 는 자기 화면 없이 /builder 로 넘기기만 하므로 **문 앞 가드는 곧 루트 가드**이고,
앱을 열자마자 로그인 화면이 된다. 그 결정을 따르고, 되돌리기에 휩쓸린 것만 복구한다.

- app/router: `/signup` 라우트 복구. 라우터를 통째로 되돌리면서 같이 날아갔고,
  그 결과 로그인 화면의 [회원가입] 링크가 404 였다
- features/auth/SignInForm: 토큰 심는 순서(signIn → me)를 lib/session 으로. 이 파일 맨 위
  주석이 경고하던 그 중복이다 — 관문이 되살아나면서 사본도 같이 돌아왔다
- pages/BuilderPage: 에디터 관문(main)과 상단 바 사용자 표시(이쪽)를 함께 둔다.
  충돌은 `authUser`/`user` 이름뿐이었다
- docs/DEVLOG: 인증 항목이 되돌리기에 휩쓸려 사라졌다. 지금 설계(에디터 진입 관문)에 맞춰
  다시 썼다 — 문 앞 가드를 시도했다 되돌린 이력도 함께 남긴다
- docs/ARCHITECTURE: 인증 모델 서술과 '아직 안 한 것' 을 지금 상태로

브라우저 확인: 위저드는 로그인 없이 열림 → 가입 → 사이드바 '김사장 · 달빛스테이' →
에디터 상단 바 동일 표시 → 로그아웃. 구글 버튼 렌더까지 확인(실제 로그인은 client_id 필요).
pytest 534 passed / 4 failed(전부 기존 실패). tsc·eslint·vite build 통과.
2026-09-02 21:33:31 +09:00
71c0c1f6ab [feat] solution/shared,frontend,site,backend: 붙여넣기 아이템 여섯 추가 · 발행본까지 내보내고 템플릿 토큰을 따르게 한다
아이템 넷(가요·일력·승차권·스케줄)만 있었고, 그마저 **발행본에는 하나도 안 나갔다.**
`SectionSetting` 계약에 data 가 없어 사장님이 채운 JSON 이 payload 경계에서 통째로 버려졌다 —
소개문 body 와 같은 사연이다. 빌더에서는 보이는데 발행하면 없는 섹션이었다.
그리고 아이템 전부가 갱지색·주(朱)잉크·간판체를 hex 로 박고 있어, 템플릿을 매거진으로 바꿔도
아이템 섹션만 레트로로 남았다. 발행본은 색만 템플릿을 따랐다(계약에 생김새가 없었다).

- shared/section-data: 읽는 쪽 계약을 계약 패키지로 — 항목 타입 · parseSectionData.
  같은 JSON 을 빌더와 발행본이 읽는다. 파서가 두 벌이면 슬러그 규칙처럼 조용히 어긋난다
- frontend/dataSpec: 아이템 6종 추가 — 인물 열전 · 시간의 골목 · 문학 서가 · 오늘의 엽서 ·
  뒤집어 보는 질문 · 계절별 추천 하루. [+ 섹션 추가] 목록은 dataSpec 에서 파생돼 손댈 곳이 없다
- shared/planDay: 계절별 추천 하루는 시각을 **계산한다**. schedule 과 축이 다르다 —
  저쪽은 사장님이 시각을 적고 여기는 출발 시각·소요 분에서 시각을 만든다.
  조립 규칙을 shared 에 둔 이유는 파서와 같다(빌더와 발행본이 같은 시각을 내야 한다).
  21시를 넘기는 칸은 넣지 않고 뺐다고 화면에 밝힌다 — 숨기면 왜 없는지 사장님이 모른다
- shared/site-payload: SectionSetting.data · SiteTheme.look 추가. backend/site_payload 는
  해석 없이 싣는다 — 모양을 검사하면 프론트가 필드를 늘린 날 조용히 떨어뜨린다
- site/sections/items: 발행본 아이템 10종. **인터랙션은 옮기지 않았다** — 캔버스의 턴테이블은
  '지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다. 인용이 이 사이트의 존재 이유다
- site/prerender: 아이템 항목을 고유 콘텐츠로 계수. 안 세면 "곡을 여덟 개 채웠는데 0건으로
  발행이 막힌다"가 된다(intro.body 와 같은 구멍). 백엔드 fake 도 같은 규칙으로 맞췄다
- 아이템 색·서체를 전부 --tpl-* 토큰으로. retro/common → items/common, RETRO_* → ITEM_*.
  글자 단계는 stone-400/500/600 대신 불투명도로 만든다 — 팔레트가 바뀌어도 위계가 남는다
- site/seo/head: look 을 --tpl-* 로 심고, 웹폰트는 템플릿이 쓰는 것만 내려보낸다.
  전부 항상 실으면 쓰지도 않는 서체가 모든 발행 사이트의 첫 렌더를 늦춘다
- shared/color: deriveSurfaces 를 계약 패키지로. 캔버스·쇼케이스·발행본이 같은 식을 써야
  미리보기가 거짓말을 하지 않는다. 프론트 lib/color 는 재수출만 남겼다

밟은 함정: 강조색을 그대로 쓰면 팔레트에 따라 큰 날짜 숫자와 순위 배지가 사라진다(연한 accent +
밝은 바탕). color-mix(accent 70%, currentColor) 로 색조는 남기고 대비만 확보했다.
'확인/확인필요' 배지는 디자인이 아니라 신호라 신호색을 지키되 둘레 글자색만 섞는다.

tsc·eslint·vite build 통과(frontend·admin·site), site 테스트 17 passed.
실물 프리렌더(레트로 look + 아이템): 열 섹션과 본문 문장 전부 포함, --tpl-font-heading 'Gugi' ·
border-width 2px, family=Gugi&Gowun+Batang 링크, 계절 묶음·순위·계산된 시각(09:30 출발 →
09:45 도착 → 11:15 → 11:25) 확인. 고유 콘텐츠 12건 ok=true.
옛 payload(look 없음)로 다시 구워 예전과 동일하게 나오는 것까지 확인.
백엔드는 이 환경에 PostgreSQL 이 없어 pytest 를 못 돌렸다 — _theme·_sections 는 함수 단위로 확인.
2026-09-02 21:30:25 +09:00
4d6802b4f2 [feat] solution/frontend: 에디터에 로그인 사용자 표시 — 6단계엔 신원도 나가는 길도 없었다
위저드(1~5단계)는 AppShell 사이드바가 사용자와 [로그아웃]을 들고 있는데, 에디터(6단계)는
전체 화면이라 AppShell 을 안 쓴다. 그래서 편집 화면에 들어가는 순간 **누구로 로그인했는지도,
나가는 방법도 화면에서 사라졌다.**

- BuilderPage: 에디터 상단 바 오른쪽에 사용자 · 상호와 [로그아웃] 추가
- stores/auth.userLabel: 이름 → 이메일 → 아이디 순. 구글 계정의 로그인 아이디는
  google_<sub> 라 그대로 보이면 안 된다. AppShell 도 같은 규칙을 쓰게 바꿨다
  (기존 `name ?? id` 는 이름이 빈 문자열이면 그대로 통과시켰다)

브라우저 확인: 가입 → 로그인 → 사이드바 '김사장 · 달빛스테이', 에디터 상단 바 동일 표시,
[로그아웃] 클릭 시 RequireAuth 가 /login 으로 되돌림. tsc·eslint·vite build 통과.
2026-09-02 17:29:02 +09:00
5fa83933e4 [fix] postgres-init: 구글 로그인 컬럼의 인덱스를 ALTER 뒤로 — 기존 DB 에서 스크립트가 멈춘다
실측: dev DB(web4ai_db)에 재적용하니 인덱스 절에서 끊겼다.

  ERROR:  column "provider_uid" does not exist

파일 구조가 CREATE TABLE → 인덱스 → 기존 DB 보정(ALTER) 순이라, 새 DB 에서는 CREATE TABLE 이
컬럼을 만들어 주지만 **기존 DB 에서는 컬럼이 맨 끝 ALTER 로 생긴다.** 인덱스를 위에 두면
ON_ERROR_STOP 에서 나머지 보정까지 통째로 안 돈다 — 새 DB 에서만 테스트하면 안 보이는 종류다.

- uq_users_provider_uid 를 ALTER 섹션 끝으로 옮기고, 왜 거기 있어야 하는지 주석으로 못 박았다

dev DB 재적용으로 확인: ALTER 4건 + CREATE INDEX 통과, company.users 에 provider/provider_uid
생성 및 password NULL 허용 확인.
2026-09-02 17:29:02 +09:00
5ef3e5a7de 업종 4번째를 관광체험 → 피부과·성형외과 로 바꾸고, 로그인 관문을 에디터 진입으로 되돌린다
## 업종 교체 (tour → clinic)

PlaceCategory 코드 4번의 의미를 바꾼다. 아직 배포 전이라 데이터 마이그레이션은 없다.

- category_schema: tour_activity.json → clinic.json. 체험 스키마(안전 유의사항·우천 시
  운영·준비물)를 진료 스키마(진료과목·의료진·상담료·보험 적용·야간/주말진료)로 바꿨다.
  unit 은 프로그램 → 시술이다(마취 방식·회복 기간·권장 횟수·시술 후 주의사항).
- 소개문 계열만 allow_llm 이다. 시술 효과·비용 같은 값은 LLM 이 못 쓴다 —
  이 레포의 "검증 전에는 발행 금지" 규칙이 의료 문구에서 특히 중요하다.
- jsonld: TouristAttraction → MedicalClinic. 프론트 AeoReadiness 의 같은 표도 맞췄다.
- 색 팔레트를 병원 톤(클린 블루·세이지·누드·모노)으로, 아이콘을 Compass → Stethoscope 로.
- mock_adapter 목데이터를 시술 기준으로 교체. 스키마에 없는 key 를 쓰면 수집이 죽는다.
- site_payload 의 기본 섹션표를 에디터(industryData)와 같게 맞췄다 —
  test_site_theme 이 이 둘을 대조한다.

## 로그인 관문 되돌리기 (b94daa9·d6a6c8e revert)

두 커밋이 /builder 를 통째로 RequireAuth 뒤로 옮겨 `/` 가 곧바로 로그인 화면이 됐다.
`/` 는 자기 화면 없이 /builder 로 넘기기만 하므로, 문 앞 가드는 곧 루트 가드다.
위저드를 열어 두고 에디터 진입에서 한 번 받는 969fb67 설계로 되돌린다.
d6a6c8e 가 스스로 "969fb67 과 정면으로 다른 설계"라고 적어 두었다.

## 그 밖

- test_site_theme 의 경로가 solution/front 로 남아 있었다(frontend 개명 누락).
- .dockerignore: 이 머신에 buildx 가 없어 레거시 빌더가 돌고, 그러면
  nginx/Dockerfile.dockerignore 가 무시된다. 루트 것 하나로 두 이미지를 다 커버한다.

검증: frontend·admin·site lint·build 0. 백엔드 534 passed / 4 failed —
그 4개(test_build_publish 3 · test_snapshot 1)는 이 변경 전부터 실패하던 것이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xa8ME5FQJy4VA8pPokTo1a
2026-09-02 15:42:34 +09:00
1bcee68432 Merge branch 'feature/variety-item' into main 2026-09-02 15:17:24 +09:00
38fbe0ee7b [feat] solution/frontend: 여행 스케줄 아이템 — 대합실 시간표
가요다방·일력·승차권에 이어 네 번째 붙여넣기 아이템이다. 승차권(course)은 '어디를 도는가'라
순번이 축인데, 손님이 실제로 묻는 건 '몇 시에 뭘 하나'다. 시각을 축으로 하는 칸이 없었다.

- dataSpec/schedule: ScheduleItem·ScheduleSlot 과 작성 규칙. time 은 "HH:MM" 만,
  장소에 url 대신 searchQuery — 지어낸 주소를 링크하지 않는 규약 그대로다
- registry/schedule.timetable: 역 대합실 플립보드(가운데 접힘선)로 시각을 먼저 읽힌다
- industryData: 레트로 템플릿 시드에 schedule 추가

[+ 섹션 추가] 목록은 dataSpec 에서 파생돼(addable.ts) 따로 손댈 곳이 없다.
tsc·vite build 통과.
2026-09-02 15:17:21 +09:00
8410380769 [fix] solution/backend,shared,site: 에디터에 쓴 소개문이 발행에서 사라지던 구멍 — 계약에 body 추가
사장님이 소개 섹션에 본문을 써도 발행이 "고유 콘텐츠 0건"으로 거부됐다.
본문은 sites.theme 에 저장은 되는데 payload 경계에서 버려졌다 — _sections() 가
저장값에서 id·name·enabled·locked·variantId 다섯 개만 꺼내 새로 만들었다.
그래서 발행본에 안 나오고, 계수에도 안 잡혔다.

거부 문구도 틀렸다. 렌더러가 '고유 콘텐츠 0건'을 JSON-LD 불일치와 같은 VerifyError 의
mismatches 에 실어 던져서, 백엔드가 JSONLD_MISMATCH 로 판정하고 화면에는
"구조화 데이터와 화면 값이 다릅니다" 가 떴다. 구조화 데이터는 멀쩡했다.

- shared/site-payload: SectionSetting.body 추가 — variantId 와 같은 사연
- backend/site_payload: 저장된 body 를 payload 까지 실어 보낸다
- site/derive,AboutSection: 직접 쓴 본문을 그린다. 없으면 intro fact 로 떨어진다
- site/prerender: 켜진 소개 섹션의 8자 이상 본문을 고유 콘텐츠로 계수
- site/prerender: NoUniqueContentError 분리 — mismatches 를 비워 라벨이 안 섞이게.
  계수를 못 잰 실패는 null 로 보고한다(0 으로 적으면 디스크 오류가 같은 사유를 받는다)
- backend/build_service,publish_gate: 렌더 실패가 0건이면 NO_UNIQUE_CONTENT 라벨을 붙인다.
  evaluate() 는 안 건드렸다 — 얇은 콘텐츠로 발행을 막지 않기로 한 결정 그대로다
- backend/router: theme API 설명에 body 반영

테스트 8 failed / 511 passed. 실패 8건은 변경 전(508 passed)과 동일한 기존 실패다
(test_default_sections_match_the_editor 의 solution/front 경로 오타 등).
tsc·site·shared 통과. 실물 검증: 본문만 있는 payload → ok=true, uniqueContentCount=1,
발행 HTML 에 문장 포함. 같은 payload 에서 본문을 빼면 0건으로 거부.
2026-09-02 12:00:16 +09:00
b94daa9dcf [refactor] solution/frontend: 로그인 관문을 문 앞 하나로 — 에디터 관문과 2단계 우회로 제거
같은 날 두 자리에서 같은 문제를 풀어 관문이 두 겹이 됐다. 둘 다 두면 문 앞(RequireAuth)이
먼저 걸려 에디터 관문은 영영 안 뜨는 죽은 코드다. 문 앞을 남긴 이유는 열어 둔 값이
공짜가 아니었기 때문이다.

에디터 관문을 쓰려면 2단계가 토큰 없이 지나가야 했고, 그래서 토큰이 없을 때 서버를 부르지 않고
입력값으로 신원을 세우는 우회로가 생겼다(confirmManual). 그건 이 레포의 단 하나의 규칙
— 검증 전에는 수집·발행 금지 — 을 화면이 비켜 가는 모양이고, 대가는 "로그인 뒤에 검증을 다시"다.
게다가 가입이 이제 그 자리에서 끝나므로(가입 응답에 토큰이 실린다) 문 앞 로그인의 마찰은
"만들어 보기도 전에 막는다" 던 시절보다 훨씬 작다.

- features/auth/EditorSignInGate·SignInForm 삭제. 관문이 하나면 폼도 하나다 —
  SignInForm 이 경고하던 'signIn → me 를 두 벌로 들고 있다' 를 lib/session 한 곳으로 모았다
- Step2PlaceSearch: 토큰 없을 때 검증을 건너뛰던 두 갈래 제거
- usePlaceSearch: confirmManual 제거. 토큰이 없으면 이제 진짜 '만료'다(문 앞을 통과했으므로)
  — 문구를 사실대로 되돌린다
- BuilderPage: 에디터 진입 분기 제거

되돌리려면 app/router 의 RequireAuth 를 벗기고 969fb67·22b7623 을 되살리면 된다.

tsc·eslint·vite build 통과(사장님 앱·admin).
2026-09-02 09:41:28 +09:00
e8e695c5a9 Merge branch 'main' into feature/auth
텍스트 충돌은 없었지만 **로그인 관문이 두 겹**이 됐다. 같은 문제를 오늘 두 자리에서 풀었다.

- main 969fb67 : 에디터 진입(6단계)에서 받는다 — 위저드 1~5단계는 열어 둔다
- 이 브랜치 : /builder 문 앞에서 받는다 — 위저드 진입부터 계정을 요구한다

지금은 문 앞 게이트가 먼저 걸리므로 EditorSignInGate 는 세션이 도중에 끊긴 경우에만 뜬다.
둘 중 하나를 고르는 건 제품 결정이라 코드로 정하지 않았다. 에디터 게이트만 남기려면
app/router.tsx 의 RequireAuth 한 겹을 벗기면 된다.

남은 중복: features/auth/SignInForm 과 lib/session 이 'signIn → me' 순서를 각자 들고 있다.
게이트 결정이 난 뒤 한 벌로 합친다.

pytest 527 passed / 8 failed(전부 기존 실패). tsc·eslint·vite build 통과.
2026-09-02 09:37:11 +09:00
d6a6c8e229 [feat] solution/frontend: 빌더를 로그인 뒤로 — 걸어 들어온 뒤에 막지 않는다
위저드 2단계부터 백엔드를 부르고, 만든 결과는 사업장·사이트로 계정에 귀속된다. 로그인 없이
걸어온 사람은 3단계쯤에서 '로그인이 만료되었습니다' 를 만나고 그때까지 넣은 걸 잃었다 —
만료가 아니라 처음부터 세션이 없었던 것이다. 문 앞에서 막는 편이 낫다.

- app/router: /builder 를 RequireAuth 뒤로
- app/provider: 자동 로그인(AUTO_LOGIN_ID·PW)을 부팅에서 붙인다. 화면 안(useAutoLogin)은
  가드가 먼저 판단하므로 영영 실행되지 않는다 — 그래서 훅을 지웠다
- 자동 로그인이 신원까지 채웠으면 me() 를 두 번 부르지 않는다

⚠️ main 의 969fb67(에디터 진입에서 한 번만 로그인)·22b7623 과 **정면으로 다른 설계**다.
   되돌리려면 router 의 RequireAuth 한 겹만 벗기면 된다.

tsc·eslint·vite build 통과.
2026-09-02 09:34:13 +09:00
48109fdc99 [feat] solution/backend,frontend: id/pw 가입 · 구글 로그인 — 계정을 만들 길이 없던 걸 연다
계정 생성 API 가 아예 없었다(그동안 users 를 손으로 INSERT 했다). 로그인 화면은 있는데
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다.

- auth_service.signup: 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**. users.company_id 가
  NOT NULL 이고 모든 도메인이 company 로 스코프돼서, 회사 없는 계정은 아무것도 못 만든다
- services/external/google_identity: 구글 ID 토큰의 서명·iss·만료에 더해 **aud(우리 client_id)와
  email_verified 를 본다.** aud 검사가 빠지면 남의 앱에 발급된 '진짜' 구글 토큰으로 우리 계정에
  들어온다 — 서명도 발급자도 전부 맞으므로 다른 검사로는 안 걸린다
- users.provider/provider_uid 추가, password NULL 허용, id 20→64자(google_<sub> 가 20자를 넘는다).
  provider 에 server_default 를 같이 준 이유: ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
  NOT NULL 컬럼이면 그 경로가 통째로 깨진다
- attempt_login: 소셜 계정을 먼저 끊는다. 안 끊으면 bcrypt 가 None 해시를 만나 500 이다
- 같은 이메일이라도 id/pw 계정과 구글 계정을 **잇지 않는다.** 이으면 계정 선점이다 —
  남의 이메일로 먼저 만들어 둔 계정에 그 사람의 구글 로그인이 들어간다 → DECISIONS 1-5
- LoginPage 는 admin 과 공유라 selfServe 로 갈랐다. admin 은 가입 링크도 구글 버튼도 안 뜬다
  (admin 라우터에 /signup 이 없어 404 가 난다)
- GOOGLE_CLIENT_ID 는 루트 .env 한 곳. compose 가 VITE_GOOGLE_CLIENT_ID 로 흘려보낸다 —
  두 곳에 적으면 백엔드 aud 대조와 화면 버튼이 조용히 갈라진다

★ 이미 도는 DB 는 init.sql 을 다시 적용해야 한다(말미 ALTER 섹션).

pytest: auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·email_verified·변조
거절 확인) 통과. 전체 527 passed / 8 failed(전부 기존 실패, 인증과 무관).
tsc·eslint·vite build 통과.
2026-09-02 09:33:59 +09:00
22b7623aeb [fix] solution/frontend: 2단계에서 로그인을 요구하지 않는다
로그인은 에디터 진입에서 한 번 받는 것이 이 앱의 흐름인데, 장소 API 가 전부 토큰을 요구해서
(place.py) 2단계가 로그인 벽이 되어 있었다. 토큰이 없으면 서버를 부르지 않고 입력한 상호·주소로
신원을 세워 3단계로 넘어간다. 검증은 로그인 뒤에 다시 할 수 있다.
2026-09-02 09:20:55 +09:00
139a672138 [fix] solution/frontend: 2단계 안내를 사실대로 — 없는 직접 입력을 하라고 말하고 있었다
직접 입력 폼을 되돌리면서 안내 문구는 그대로 뒀다. 화면에 없는 걸 하라고 말하는 상태였다.
지도 검색·URL 확인 모두 서버가 토큰을 요구하므로(place.py 전 엔드포인트 IsValidAccessToken)
이 단계는 로그인 없이 지나갈 수 없다. 문구를 그대로 적는다.
2026-09-02 09:14:16 +09:00
94a083c895 [chore] solution/frontend: LoginPage 원복 — 로그인 화면은 건드릴 이유가 없었다
에디터 관문을 만들면서 로그인 화면까지 공용 폼으로 갈아엎었다. 요청 밖이라 되돌린다.
관문은 features/auth/SignInForm 을 그대로 쓴다.
2026-09-02 09:05:39 +09:00
969fb6773c [feat] solution/frontend: 로그인을 에디터 진입에서 한 번만 받는다
위저드를 걷는 동안 '로그인이 만료되었습니다' 가 떴다. 만료가 아니라 **한 번도 로그인한 적이
없는 것**이었다(자동 로그인 계정이 없으면 ensureAutoSession 이 즉시 끝난다). 문구가 사실과 달라
고장으로 읽혔다.

- features/auth/EditorSignInGate: 에디터에 들어갈 때만 로그인을 받는다. 위저드 1~5단계는
  요구하지 않는다 — 만들어 보기도 전에 막으면 아무도 안 만든다. /login 으로 튕기지 않는 이유는
  위저드에서 쌓은 상태를 들고 돌아올 방법을 사장님이 알 수 없기 때문이다
- features/auth/SignInForm: 로그인 화면과 관문이 같은 폼을 쓴다. 두 벌로 두면 토큰을 심는
  순서(signIn → me)가 한쪽에서만 지켜지고, 그 실수는 "로그인은 됐는데 계속 401" 로 나타난다
- usePlaceSearch: 토큰이 없을 때의 문구에서 '만료' 를 걷어낸다. 검색은 서버가 토큰을 요구하므로
  (place.py 전 엔드포인트가 IsValidAccessToken) 프론트가 없앨 수 있는 제약이 아니다

tsc·eslint·vite build 통과
2026-09-02 09:03:17 +09:00
4debeaed3a [feat] solution/frontend: 템플릿이 색만 바꾸던 걸 끝낸다 — 업종당 5개에서 3개로
업종마다 템플릿이 다섯인데 넷이 "흰 바탕 + 고딕 + 둥근 모서리"에 색조만 달랐다. 고르는 화면의
미리보기도 회색 막대 세 줄 + 색 동그라미라 다섯 장이 전부 같은 그림이었다 — 뭐가 다른지 알 수
없으니 아무거나 골랐다.

- shared/TemplateItem.look: 제목·본문 서체, 모서리, 테두리 두께, 그림자, 제목 자간·굵기, 섹션 여백.
  CSS 에 그대로 들어가는 문자열로 들고 있다 — 숫자로 두면 쓰는 쪽에서 단위를 빠뜨린 곳이 조용히 0 이 된다
- CanvasView: Tailwind v4 의 --radius-* · --shadow-* 를 캔버스 안에서만 덮는다. 변이 파일 40여 개에
  흩어진 rounded-* · shadow-* 를 한 줄도 안 고치고 전부 템플릿을 따르게 된다. 배수는 Tailwind
  기본 비율 그대로라 기준값 0.75rem 이면 지금까지와 픽셀 단위로 같다
- index.css: .site-canvas 는 --tpl-font-heading/body 를 이미 읽고 있었는데 아무도 넣지 않았다.
  서체가 안 갈리던 진짜 이유가 이 빠진 고리다. 제목 굵기도 토큰으로 뺐다 — 간판체(Gugi)는 굵기가
  한 벌뿐이라 700 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다
- industryData: templatesFor() 팩토리 하나로 심플·매거진·레트로 셋. 업종은 accent 하나만 바꾼다 —
  생김새는 업종이 아니라 취향의 문제다. 537줄 → 237줄
- Step4Template: 미리보기를 그 템플릿의 서체·모서리·테두리·그림자로 실제로 그린다
- index.html: Noto Serif KR 추가. 매거진 제목이 Batang 으로 떨어지는데 맥에는 그 서체가 없다
- SectionFrame: 세로 여백을 --tpl-section-space 로. 이 값 하나로 페이지의 호흡이 바뀐다

옛 템플릿 id 가 DB 에 남아 있어도 resolveTemplate 이 첫 템플릿으로 떨어뜨린다.

tsc·eslint·vite build 통과(frontend·admin·site), 템플릿 12벌 look 전량 대조 + 폴백 확인
2026-09-02 08:49:38 +09:00
e6ffdad888 [feat] solution/frontend: 섹션 아이템을 버튼으로 넣는다 — 레트로 템플릿이 기본 구성을 데려온다
시드에 세 아이템을 박아 두니 내용 없는 칸이 목록에 늘 붙어 있었다. 붙여넣기 아이템은
JSON 이 없으면 빈 섹션이라 "쓸 사람만 넣는" 쪽이 맞다.

- canvas/addable.ts: 추가 가능한 섹션 목록. dataSpec 이 단일 출처라 아이템을 만들면 여기 자동으로
  나타난다 — 목록을 따로 들면 만들어 놓고 고를 수 없는 상태가 된다
- SectionListPanel: 하단 [+ 섹션 추가] + 썸네일 목록. 나중에 넣은 섹션만 휴지통으로 뺀다
  (업종 기본 섹션은 스위치로 끄는 것이지 빼는 게 아니다). 내용이 있으면 빼기 전에 한 번 묻는다
- stores/builder: addSection 은 이미 있으면 새로 만들지 않고 켜기만 한다 — 새로 만들면 넣어 둔
  JSON 이 날아간다
- shared/TemplateItem.defaultSectionTypes + 업종마다 레트로 템플릿 하나(옛 항구·옛 다방·노포·시간여행).
  고르면 세 아이템이 함께 들어온다. 넣기만 하고 빼지 않는다 — 템플릿을 눌러 보다 넣어 둔 섹션이
  사라지면 사장님은 그게 템플릿 때문인 줄 모르고 자기가 지웠다고 생각한다
- siteTheme/applyTheme: 저장 payload 에 type 을 싣는다. 시드에 없는 섹션은 id 로 못 찾아 복원 때
  통째로 버려졌다 — 사장님이 채운 JSON 까지 같이 사라지는 자리였다
- industryData: 세 아이템을 시드에서 뺐다

tsc·eslint·vite build 통과(frontend·admin), 추가·삭제·템플릿 연동·저장복원 왕복 12건 확인
2026-09-02 08:35:25 +09:00
23162783f9 [feat] solution/frontend: 붙여넣기 아이템 셋 — 가요 다방·오늘의 한 장·반나절 산책
gunsan_365_story_db.xlsx 365행을 뜯어 보니 고유 주제는 52개이고 한 주제가 7회씩 돈다
(접미사 10개만 회전). 날짜 축으로 카드를 늘어놓으면 이레마다 같은 카드가 돌아온다 —
그래서 묶는 축을 주제로 잡고 날짜는 일력 한 장에만 썼다. 같은 파일 DB_Guide 가 가사·원문
전재를 금지해 가요 스키마에 lyrics 필드를 아예 두지 않았다. 없는 칸은 채울 수 없다.

- canvas/dataSpec.ts: 스키마·예시·프롬프트 단일 표. 레지스트리와 같은 결이라 한 줄을 더하면
  캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다. 파싱은 절대 throw 하지 않는다 — 편집 중인 JSON 은
  늘 깨져 있고 그때 캔버스가 죽으면 고칠 방법이 없다
- shared/types/builder: SectionItem.data 는 파싱본이 아니라 원문 문자열이다. 파싱본만 들면
  JSON 이 깨진 순간 사장님이 쓴 걸 잃는다
- variants/{songs,daily,course}: 턴테이블·일력·승차권. 카드 격자를 쓰지 않고 전부 가로로 넘긴다
- RightTabsPanel: JSON 칸 + 프롬프트 복사/보기·예시 넣기·줄맞춤. 프롬프트에 상호와 주소에서 뽑은
  시·군·구를 박아 내보낸다 — 빈칸을 남기면 못 채우고 그대로 보내고 모델이 엉뚱한 곳을 지어낸다
- dataSpec.locate: JSON.parse 오류가 두 형식이다. position 형만 보면 조각 인용 형에서 위치를
  통째로 잃는다 — 조각을 원문에서 되찾아 센다
- SECTION_DATA_MAX_CHARS: 테마 상한 64KB(site_service._THEME_MAX_BYTES)를 세 섹션이 함께
  넘길 수 있고 거절은 발행 직전에야 드러난다. 화면에서 먼저 끊는다
- industryData: 네 업종에 꺼진 채로 넣는다. 내용 없이 켜져 있으면 발행본에 빈 섹션이 나간다
- index.html: Gugi·Gowun Batang·Nanum Pen Script. 없으면 고딕으로 떨어져 감성이 사라진다

발행 사이트(solution/site)는 아직 variantId 도 data 도 읽지 않는다 — 지금은 빌더 캔버스 전용이다.

tsc·eslint·vite build 통과(frontend·admin), 세 배리에이션 SSR 렌더 확인, 파서 경계 12건 확인
2026-09-01 17:58:58 +09:00
c250b656fd [chore] solution/frontend,docs: Bing 소유확인 파일 추가 — 이미지에 구워야 재생성에도 살아남는다
Bing Webmaster 의 XML 파일 방식은 루트에서 파일을 읽는다. nginx `location /` 이
`/srv/app`(= solution-site 이미지)에서 찾으므로 `public/` 에 두고 굽는 것 말고는 자리가 없다.
`docker cp` 로 밀어 넣으면 컨테이너 재생성 때 사라지고, 검색엔진이 인증을 재확인하는
시점에 조용히 풀린다.

- solution/frontend/public/BingSiteAuth.xml: Bing 이 준 파일 그대로(가공하면 파싱이 깨진다)
- docs/DEPLOY.md 2-2절: 세 검색엔진의 소유확인 방식과 사는 자리.
  ★ 확인은 상태코드가 아니라 내용으로 한다 — `try_files … /index.html` 이라
  파일명이 틀리면 404 가 아니라 빌더 HTML 이 200 으로 나간다

검증: 배포 후 curl 로 본문 대조
2026-09-01 13:33:48 +09:00
db58e94540 [feat] solution/frontend: 자동 로그인 복원 — 계정이 주입돼 있을 때만 붙는다
9b4fe40 이 개발 전용 자동 로그인을 지우면서 빌더 2단계가 막다른 길이 됐다.
로그인한 적도 없는 사람에게 "로그인이 만료되었습니다" 라고 쓰고, 그 화면에는
로그인으로 갈 링크가 없다. 실측(킹서버): 계정이 DB 에 0개라 아무도 통과 못 했다.

- lib/autoSession.ts: 옛 devSession 을 되살리되 `import.meta.env.DEV` 게이트를 뺐다.
  운영 번들에서도 돌아야 한다 — 대신 VITE_AUTO_LOGIN_ID·PW 가 **둘 다** 있을 때만
  움직이고 기본값은 없다. 진행 중인 로그인을 하나의 약속으로 공유하는 구조는 그대로
  가져왔다: 훅 안에만 두면 로그인 전에 누른 검색이 토큰 없이 나가 '만료'로 떨어진다.
- usePlaceSearch: 검색 전에 그 약속을 기다린다(경합을 만료로 오인하던 자리).
- Dockerfile·compose: VITE_* 는 번들에 구워지므로 build args 다. 값을 바꾸면
  `./deploy.sh solution-site` 로 다시 굽는다.

⚠️ 계정이 번들에 그대로 들어간다. 내부 테스트 호스트 전용이고, 사장님에게 열기 전에
   AUTO_LOGIN_* 을 비우고 재빌드해야 한다.

tsc --noEmit · eslint 통과
2026-09-01 12:00:07 +09:00
43483c6c38 [docs] deploy: 한 오리진 구성과 공개 주소를 지금 상태로 — 밖으로 여는 포트는 30030 하나 2026-09-01 11:49:07 +09:00
df6c9bbb54 [fix] deploy: 프론트 이미지 전용 dockerignore — 루트 것은 백엔드용이라 소스를 잘라낸다 2026-09-01 11:46:58 +09:00
fceadd68ce [feat] deploy,solution/frontend: 운영에서 dev 서버를 걷어낸다 — 정적 번들을 nginx 가 서빙
킹서버에 `vite dev` 가 떠 있었다. 요청마다 트랜스파일하고, 컨테이너 기동이 npm install
네트워크에 의존하고, /src 원본과 소스맵이 그대로 나간다. 발행물을 파는 사이트의
진입점이 dev 서버일 이유가 없다.

- nginx/Dockerfile 신규: @o2o/frontend 를 굽는 build 스테이지 + 번들을 담은 nginx.
  발행 사이트는 이미지에 안 넣는다 — 사이트가 늘 때마다 이미지를 다시 굽지 않으려고
  site-out 볼륨에서 읽는다.
- vite.config.ts: build.assetsDir='builder-assets'. 발행본과 같은 오리진이라 `/assets/`
  를 서로 뺏는다 — 안 가르면 빌더 JS·CSS 가 404 인데 화면은 떠서 원인이 안 보인다.
- site.conf: `/` 를 /srv/app 정적으로. index.html 은 no-cache — 번들 해시가 여기 박혀
  있어 캐시되면 재배포해도 옛 번들 주소를 계속 부른다. `/fonts/` 는 발행본 먼저 보고
  없으면 빌더로 떨어뜨린다(둘이 같은 경로를 각자 쓴다).
- compose: solution-frontend(dev 서버)를 profile dev 로 내리고, 굽기만 하는
  solution-prerender 를 기본으로 올린다. VITE_* 는 번들에 구워지므로 build args 다 —
  주소를 바꾸면 재빌드해야 한다.

docker compose config 통과
2026-09-01 11:46:32 +09:00
9fe7f3f7f5 [fix] deploy: IndexNow 키 파일 location 정규식 — nginx 가 {} 를 블록으로 읽는다 2026-09-01 11:40:50 +09:00
36b9e25b92 [feat] deploy,solution/frontend: 공개 진입점을 한 오리진으로 통합 — nginx 가 앱·사이트·API 를 가른다
개발은 Vite 프록시가 `/s`·`/assets` 를 :3001 로 넘겨 한 오리진을 만드는데, 운영에는 그
몫을 받는 자리가 없었다. site.conf 가 정적 서빙 전용이라 `/`(빌더)와 `/v1`(API)이
전부 404 였다 — 실측(w4ai.o2o.kr): DNS·TLS·프록시는 정상인데 우리 404 페이지만 나왔다.

- nginx/site.conf.example: `/`→solution-frontend:3000, `/v1|healthz|docs`→solution-backend:9800
  프록시 추가. `/s/`·`/assets/`·`/fonts/` 는 `^~` 로 잡아 정규식 location 이 못 끼어들게 한다.
  업스트림을 변수+resolver 로 둔 건 기동 시점 이름풀이를 피하려는 것 — 프론트가 아직
  안 떴을 때 nginx 자체가 죽는다. IndexNow 키 파일(`<key>.txt`)도 루트에서 받는다.
- vite.config.ts: allowedHosts 에 VITE_PUBLISH_HOST. Vite 6 는 모르는 Host 를 403
  "Blocked request" 로 막는다 — 프록시는 정상인데 앱만 전부 403 이라 원인이 안 보인다.

nginx -t 통과
2026-09-01 11:39:32 +09:00
45cc80b60a [chore] deploy: 배포 시 origin/main 하드 리셋 — fetch 성공에만 건다
배포 서버의 작업트리는 main 의 사본이지 작업 공간이 아니다. pull(--ff-only)은
force-push 가 나면 막히고, 서버에서 손댄 흔적도 남는다.

- deploy.sh: git pull → fetch + reset --hard origin/$BRANCH.
  ★ fetch 실패 시 리셋하지 않는다. 킹서버엔 gitea 자격증명이 없어 fetch 가 죽는데,
  그 상태의 origin/main 은 낡은 ref 다 — 실측 HEAD=9b4fe40 / origin/main=4871e50,
  믿고 리셋하면 한 커밋 롤백되고 빌드는 성공한다. 조용히 틀리는 종류다.
- git clean 은 넣지 않는다. .env·nginx/site.conf 는 추적되지 않는 파일이라 clean 이 지운다.
- DEPLOY_BRANCH 로 대상 브랜치를 바꿀 수 있다(기본 main).

킹서버에서 실행 검증: fetch 실패 → 리셋 건너뜀, HEAD 9b4fe40 유지,
solution-site 재생성 정상. bash -n 통과.
2026-09-01 10:20:12 +09:00
9b4fe4030b [feat] deploy,backend,site: 킹서버 배포 + 설정을 최상위 .env 하나로 통합
킹서버(o2oadmin@172.30.1.36)에 처음 올리면서, 서버에 올려야만 드러나는 결함 넷을 잡았다.
전부 "화면은 뜨는데 안 되는" 종류라 로컬에서는 끝까지 보이지 않는다.

- CORS 허용 오리진(client_url)만 env override 가 없었다. 도커가 굽던 config.local.toml 은
  플레이스홀더라 허용 목록이 localhost:3000~3005 뿐이고, 배포 주소에서는 모든 API 호출이
  프리플라이트에서 죽었다. 서버 로그에는 400 만 남아 원인이 CORS 라는 게 안 보인다
- .env 경로가 세 단계라 solution/.env(없는 파일)를 보고 있었다. 백엔드를 solution/ 아래로
  옮길 때 안 고쳐진 자리. toml 이 값을 들고 있어 로컬에서 드러나지 않았다
- admin 의 "빌더 열기" 가 VITE_SOLUTION_URL 미주입으로 localhost:3000 을 가리켰다
- PUBLIC_API_BASE_URL 은 브라우저가 부르는 주소인데 기본값이 localhost 라 서버에서 즉시 틀린다

설정 — toml 층 제거, pydantic-settings 로 전환 (FastAPI 공식 방식)
- config_loader.py · config.{local,test}.toml.example 삭제, 기본값은 config_models 로
- BaseSettings + env_file. `_apply_*_env_override` 4개 제거 — 키를 손으로 나열하는 구조라
  하나 빠뜨리면 조용히 틀렸고, 실제로 client_url 이 빠져 있었다
- 환경변수 이름은 validation_alias 로 못 박음. 필드명만 두면 `port` 가 흔한 `PORT` 를 먹는다
- 테스트 DB 분리(web4ai_test_db)는 config.test.toml 이 하던 몫이라 APP_ENV 기본값으로 이관
- lru_cache 로 .env 재읽기 방지. 새 코드는 Depends(get_*) 주입 가능
- 호출부 21개 파일 무변경 — server_configs 가 같은 이름을 계속 내보낸다

배포 — 킹서버는 :80 을 호스트 nginx 가 물고 있고 사내망에 열린 건 30xxx 뿐이다
- 컴포즈 포트를 전부 .env 변수로 추출(기본값은 기존 값 그대로, 로컬 무영향)
- 컨테이너 이름을 폴더 구조에 맞춤 — solution-backend·solution-worker·solution-frontend·
  solution-site·admin-backend·admin-frontend. api·web·nginx 는 어느 폴더 코드인지
  이름만으로 알 수 없었고, 백엔드 셋이 이미지 한 벌을 나눠 써서 특히 헷갈렸다
- worker 에 container_name 을 붙여 `-1` 접미사 제거(동시성은 WORKER_CONCURRENCY 가 맡는다)
- 어드민 앱·API 는 compose 프로필 뒤로 — 지금 안 쓴다. 켤 때 --profile admin
- deploy.sh: 서비스 하나를 지정해도 백엔드 형제를 함께 교체한다. 이미지 한 벌을 나눠 써서
  하나만 바꾸면 옛 코드로 도는 컨테이너가 남는데 `ps` 로는 셋 다 살아 있다
- log.sh: 1=전체, 2번부터 개별. compose v2.20 이 커스텀 --format 을 파싱하지 못해 상태가
  전부 "미기동" 으로 보이던 것도 --services --filter 로 교정
- docs/SERVERS.md 신설(접속·경로·포트·DB·sudo 없음), docs/DEVLOG.md 신설

정리
- 개발 전용 자동 로그인 제거 — 편의 하나에 검색 경로의 비동기 대기가 딸려 있었고,
  평문 비밀번호를 .env 에 두라고 권하는 모양새였다
- API 이름을 디렉토리에 맞춤: 사장님/내부 → 솔루션 API · 어드민 API (21곳)
- .env.example 을 읽는 폴더 기준 구역으로 재편 (solution/backend · solution/frontend ·
  solution/site · compose)
- AGENTS.md 에 negosium 브랜치·커밋 규약 명시

검증(킹서버 실측) — 컨테이너 4개 새 이름으로 기동, 솔루션 API·사장님 앱 200,
발행 사이트 404(발행물 없음, 정상), CORS 허용/차단 각 확인, toml 없이 부팅,
APP_ENV=test 시 web4ai_test_db·실키 미주입 확인.
2026-09-01 10:04:36 +09:00
1428 changed files with 343393 additions and 9822 deletions

View File

@ -1,29 +1,28 @@
# ★ 빌드 컨텍스트가 레포 루트다(solution/backend/Dockerfile 주석 참조).
# 컨텍스트가 넓어진 만큼 여기서 확실히 잘라내야 이미지가 붓지 않는다.
**/__pycache__/
*.pyc
**/.pytest_cache/
# 빌드 컨텍스트는 레포 루트고, 백엔드 이미지와 solution-site 이미지가 이것을 **같이** 쓴다.
# ★ nginx/Dockerfile.dockerignore 는 BuildKit 전용이다. 이 머신엔 buildx 가 없어 레거시
# 빌더가 돌고, 그러면 이 파일만 읽힌다 — 여기서 프론트 소스를 자르면 nginx 빌드가 죽는다.
# (백엔드 이미지는 COPY 대상이 solution/backend·admin/backend 뿐이라 영향이 없다.)
.git/
.venv/
**/.venv/
node_modules/
**/node_modules/
**/dist/
**/.vite/
**/__pycache__/
*.pyc
**/.pytest_cache/
.venv/
**/.venv/
# 발행 산출물은 볼륨에서 온다. 이미지에 구우면 사이트가 늘 때마다 이미지가 붓는다.
solution/site/out/
solution/site/payloads/
# 프론트·문서는 백엔드 이미지에 들어갈 이유가 없다.
solution/frontend/
solution/site/
solution/shared/
admin/frontend/
docs/
nginx/
postgres-init/
**/*.md
solution/backend/tests/
solution/backend/loadtest/
# 시크릿 — 이미지에 굽지 않는다. Dockerfile 이 example 을 복사해 넣고 실값은 compose env 로 준다.
# 시크릿 — 이미지에 굽지 않는다. 백엔드는 example 사본 + compose env, 프론트는 build args.
**/config.local.toml
**/config.test.toml
.env

View File

@ -1,83 +1,203 @@
# o2o-web4ai 환경변수 템플릿.
# cp .env.example .env 후 값을 채운다. .env 는 커밋되지 않는다(.gitignore).
# cp .env.example .env 후 값을 채운다. .env 는 커밋되지 않는다.
# 우선순위: 실제 환경변수(compose) > .env > 코드 기본값(config_models.py)
#
# 우선순위: 실제 환경변수(docker-compose 등) > .env > config/config.{APP_ENV}.toml
# DB 접속·JWT 는 config.local.toml 이 기본값이다. 여기 값을 채우면 그쪽을 덮어쓴다.
# 외부 API 키는 toml 을 비워두고 여기서만 관리하는 것을 권장한다.
# ★ 값 뒤에 주석을 붙이지 않는다. compose 의 `env_file` 은 줄 끝 주석을 **값으로 읽는다** —
# `AZURE_STORAGE_CONNECTION_STRING= # 비우면...` 은 "빈 값"이 아니라 "# 비우면..." 이라는 값이다.
# 실측(2026-09-07): 그래서 Azure 를 끈 로컬에서 발행 잡이 업로드를 시도하고
# "Connection string is either blank or malformed" 로 죽었다. 게이트는 통과했는데 발행만 실패한다.
# 주석은 반드시 **윗줄**에 둔다.
# ── 실행 ─────────────────────────────────────────────────
# ── 공통 solution/backend · admin/backend (server_configs 가 읽는다)
APP_ENV=local
# RELOAD=1 # uvicorn --reload (개발 컨테이너)
# SCHEDULER_ENABLED=1 # 배치 스케줄러 기동. 다중 워커면 1개 프로세스에서만 1
# ── DB (비우면 config.local.toml 의 [MainDBConfig] 사용) ──
# DB_HOST=127.0.0.1
# DB_PORT=5432
# DB_USER=postgres
# DB_PASSWORD=
# DB_NAME=web4ai_db
# ★ compose 로 띄우면 `host.docker.internal` 이다 — 컨테이너 안의 127.0.0.1 은 그 컨테이너다.
# 127.0.0.1 은 백엔드를 **네이티브로**(.venv/bin/python) 돌릴 때만 맞다.
# 이 값을 그대로 두고 `docker compose up` 하면 API 는 healthz 200 으로 멀쩡해 보이는데
# 워커만 조용히 재시작을 반복한다(ConnectionRefusedError 5432) — 발행 잡이 영원히 안 돈다.
DB_HOST=host.docker.internal
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=
DB_NAME=web4ai_db
# ── JWT 서명 키 ───────────────────────────────────────────
# ★ 도커 이미지는 시크릿을 굽지 않으므로(config.local.toml 이 플레이스홀더 사본)
# 컨테이너로 띄울 땐 반드시 여기서 주입해야 한다. 비우면 공개된 플레이스홀더가 서명 키가 된다.
# 비우면 토큰 서명이 안 된다
# 생성: python -c "import secrets; print(secrets.token_urlsafe(48))"
JWT_ACCESS_SECRET=
JWT_REFRESH_SECRET=
# ── 외부 API 키 ───────────────────────────────────────────
# 비어 있으면 해당 어댑터만 비활성된다. 서버는 그대로 뜬다.
# 채널 URL 발견 (api.perplexity.ai, 모델 sonar / sonar-pro)
# ★ 지금은 **기본으로 꺼져 있다**(COLLECT_USE_PERPLEXITY=0). 키가 있어도 호출하지 않는다.
# 실측(2026-08-27 도플로·버터브루·힐튼 가든 인 서울 강남): 야놀자만 물어오고 네이버
# 플레이스는 0건, 필터를 넓히면 네이버 도움말 페이지를 채널로 등록했다. 게다가
# 검색 호출 요금이 토큰 요금과 별도로 붙는다(생성 1건당 15회).
# ★ 답변을 사실로 쓰지 않는다. URL 발견 전용 — 동명 업소가 섞이고 환각이 있다.
# 키가 비면 그 어댑터만 꺼진다. 서버는 뜬다.
PERPLEXITY_API_KEY=
# Perplexity 채널 URL 발견 스위치. 0=끔(기본) / 1=켬.
# 코드는 지우지 않고 여기서만 끈다 — 야놀자·여기어때 어댑터가 붙으면 배포 없이 되켠다.
# 끈 상태에서도 상호 → 네이버 place id 직접 해석은 계속 돈다(유일한 자동 발견 경로).
# Perplexity 채널 발견. 0=끔(기본)
COLLECT_USE_PERPLEXITY=0
# 동일 업소 검증 · 주변 정보 (dapi.kakao.com)
# https://developers.kakao.com > 내 애플리케이션 > 앱 키 > REST API 키
# ★ 무료 쿼터는 개발자 계정의 "첫 번째 활성 앱" 에만 붙는다.
# dev/stage/prod 앱을 따로 파면 하나만 무료다 — 앱을 나누기 전에 확인할 것.
# 초과 단가: 키워드/카테고리 검색 2원, 좌표 변환 0.5원 (키워드가 4배 비싸다)
# 동일 업소 검증 — 네이버 지역검색 (카카오 키 미발급이라 이쪽을 쓴다)
# https://developers.naver.com/apps 검색 API
# ★ 제약: display 최대 5건 · telephone 이 빈 값으로 온다 · 행정구역 코드 없음
# → 카카오보다 동일 업소 판정 근거가 약하다(AMBIGUOUS 가 늘어난다)
NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
# 미발급. 없으면 네이버 지역검색을 쓴다
KAKAO_REST_API_KEY=
# 사진 분류 + 카피 작성 (Google AI Studio)
# https://aistudio.google.com/apikey
GEMINI_API_KEY=
# 축제 · 관광지 (한국관광공사 TourAPI, data.go.kr)
# ★ TourAPI 는 자체 areaCode/sigunguCode 체계를 쓴다 — 카카오 행정구역 코드와 다르므로 매핑이 필요하다.
# 디코딩된 서비스키를 넣는다(인코딩 키를 넣으면 %2B 등이 이중 인코딩된다).
OPENAI_API_KEY=
# 디코딩된 키(인코딩 키는 이중 인코딩된다)
TOUR_API_KEY=
# 발행할 때 이 숙소의 노래를 한 곡 만든다(가사 Gemini → 작곡 Suno).
# 비우면 그 단계만 건너뛴다 — 발행은 그대로 된다.
# ★ 콜백은 쓰지 않고 폴링한다(우리 서버는 Suno 가 닿을 수 있는 주소가 아니다).
# 그래도 API 가 필수로 요구하는 필드라 값을 채워 보낸다.
SUNO_API_KEY=
SUNO_CALLBACK_URL=https://example.com/api/suno/callback
# 발행 사이트 메타 키워드(keywords · 제목)를 받아 올 SiteOntology 주소(o2o-site-ontology, 기본 :3100).
# 비우면 그 단계만 건너뛴다 — 제목·메타가 예전 그대로 나간다.
# ★ 워커가 부르는 주소다. compose 로 띄우면 컨테이너 안에서 보는 주소(http://host.docker.internal:3100),
# 백엔드를 네이티브로 돌리면 http://127.0.0.1:3100
SITE_ONTOLOGY_URL=
# Open-Meteo(날씨)는 API 키가 필요 없다. 좌표만 있으면 된다.
# ── 발행 호스트 ──
# 커스텀 도메인이 없는 사이트의 공개 주소(`https://<이 값>/s/<slug>`).
# canonical·og:url·sitemap·IndexNow 가 전부 이 값을 쓴다. 비우면 w4ai.o2o.kr.
# ★ 프론트(admin)의 VITE_PUBLISH_HOST 와 **같은 값**이어야 화면과 발행본이 갈리지 않는다.
SITE_PUBLIC_HOST=w4ai.o2o.kr
# 프리렌더가 절대 굽지 않는 슬러그(쉼표 구분). 손으로 만든 목업(/s/stay·stay2·stay3·stay4·stay5)
# 이름과 같은 슬러그로 실제 발행이 생기면 그 payload 로 목업을 덮어 구워버린다 — 비우지 않는다.
PRERENDER_PROTECTED_SLUGS=stay,stay2,stay3,stay4,stay5
# Azure Blob 정적 사이트 발행. 비우면 기존 로컬 out/ 발행만 사용한다.
AZURE_STORAGE_CONNECTION_STRING=
AZURE_STORAGE_CONTAINER=$web
AZURE_STORAGE_PREFIX=ai-for-web
# ── SNS 게재(스레드) ────────────────────────────────────────────────
# 사장님이 [SNS에 알리기] 를 누르면 확인된 fact 로 짧은 글을 쓰고, 승인을 받아
# **사장님 개인 계정**으로 올린다. 비우면 그 기능만 꺼진다(서버는 뜬다).
#
# ★ SOCIAL_TOKEN_SECRET 이 없으면 계정 연결 자체를 막는다 — 위임받은 토큰을
# 평문으로 보관하는 길을 열지 않는다. 우리 API 키와 성격이 다르다:
# API 키는 우리 돈이 나가고, 이 토큰은 **사장님 이름으로 글이 나간다.**
SOCIAL_TOKEN_SECRET=
# ★ 실제 게시는 이 값이 '1' 일 때만 열린다. 플랫폼 계약과 해지 안내 페이지 정책
# (DECISIONS 1-4)을 확인하기 전에는 초안·승인까지만 돌린다 — 게시는 되돌릴 수 없다.
SOCIAL_POSTING_ENABLED=0
# 승인 요청의 수명. 지나면 EXPIRED 로 내려가고 화면에 '만료됨 · 다시 보내기' 로 남는다.
SOCIAL_APPROVAL_HOURS=24
# 승인 화면이 열리는 주소(빌더 SPA). 알림톡 버튼이 이 주소로 간다.
SOCIAL_APP_ORIGIN=
THREADS_APP_ID=
THREADS_APP_SECRET=
THREADS_REDIRECT_URI=
# 알림톡(대행사). 비면 발송을 건너뛰고 빌더 화면 승인만 쓴다 — 기능은 그대로 돈다.
# ★ 템플릿 코드는 심사 대상이라 env 로 둔다. 반려로 코드가 바뀌면 배포 없이 고쳐야 한다.
ALIMTALK_API_KEY=
ALIMTALK_API_SECRET=
ALIMTALK_PROFILE_ID=
ALIMTALK_SENDER=
ALIMTALK_TEMPLATE_CODE=
# ── 색인 통보(IndexNow) ──
# 발행 즉시 네이버·Bing·Yandex 에 URL 을 알린다. 비우면 통보를 건너뛴다(발행은 정상).
# 구글은 IndexNow 를 지원하지 않는다 — 구글 쪽은 Search Console 사이트맵 제출이 별도 경로다.
# 값은 8~128자의 영문·숫자·하이픈 아무 문자열이면 된다(비밀이 아니다. 공개되어야 작동한다):
# python3 -c "import uuid; print(uuid.uuid4().hex)"
# ── 사장님 에이전트 · 카카오톡 채널 연결 ──────────────────────────────
# 사장님이 카톡으로 사이트를 고치려면, 채널 발화자(채널 단위 익명 키)를 우리 계정에
# 묶어야 한다. 빌더에서 코드를 받아 채널에 한 번 입력하는 절차다.
# ★ 이 값이 비면 연결 화면이 아예 안 뜬다 — 어디에 코드를 칠지 말해 줄 수 없는데
# 코드만 발급하면 사장님에게는 고장난 화면이다.
# 사장님 대화창(에이전트). 1=사용, 0=감춤.
# ★ 1 이어도 LLM 키가 없으면 안 열린다 — 키 없는 환경에서 켜 둔 채 잊어도
# "눌러도 안 되는 입구" 가 생기지 않는다.
AGENT_CHAT_ENABLED=1
# 카카오톡 채널 웹훅(오픈빌더 스킬 서버). ★ 오픈빌더는 서명을 주지 않는다 —
# URL 만 알면 누구나 때릴 수 있고 발화자 id 를 위조하면 그 사장님 행세를 한다.
# 비우면 웹훅 엔드포인트가 404 다(반쯤 열린 상태를 만들지 않는다).
# 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))"
KAKAO_WEBHOOK_SECRET=
# 우리 봇이 맞는지 한 겹 더. 오발송을 거르는 용도라 비워도 된다.
KAKAO_BOT_ID=
KAKAO_CHANNEL_PUBLIC_ID=
KAKAO_LINK_CODE_TTL_MIN=10
KAKAO_LINK_MAX_ATTEMPTS=5
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
# Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션)
# "승인된 JavaScript 원본" 에 화면 주소를 등록해야 브라우저에서 토큰이 나온다(리디렉션 URI 는 필요 없다).
# ★ 백엔드(aud 대조)와 프론트(버튼)가 **같은 값**을 써야 한다 — compose 가 이 하나를
# VITE_GOOGLE_CLIENT_ID 로 흘려보낸다. 두 곳에 따로 적지 않는다.
# ★ 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site
GOOGLE_CLIENT_ID=
# CORS 허용 오리진. 쉼표로 여럿.
# 서버에 올리면 반드시 적는다. 안 적으면 화면은 뜨고 API 만 막힌다.
# CLIENT_URL=http://172.30.1.36:30031,http://localhost:3002
# LANDING_URL=
# ── solution/frontend 브라우저가 부르는 주소 (compose 가 VITE_* 로 주입)
# ★ 브라우저가 부르는 주소다. 서버에 올리면 localhost 는 즉시 틀린다.
# ★ **앱과 같은 오리진을 적는다.** nginx(:80)가 /v1 을 같은 오리진으로 프록시하므로
# (nginx/site.conf) 앱이 부를 주소는 `:9800` 이 아니라 앱 주소 그 자체다. `:9800` 을 적으면
# 스스로 크로스 오리진을 만들어 CORS 가 붙고, 화면은 뜨는데 **로그인만 계속 실패한다** —
# 서버는 200 에 토큰까지 내려보내고 브라우저가 allow-origin 이 없어 그 응답을 버린다.
# 실측(2026-09-07): 이 기본값 그대로 띄우면 :80 으로 연 앱에서 로그인이 안 된다.
# ★ 값을 바꾸면 번들을 다시 구워야 한다: ./deploy.sh solution-site
PUBLIC_API_BASE_URL=http://localhost
PUBLIC_WEB_BASE_URL=http://localhost
# ── solution/site 발행물 — solution/backend 도 같이 본다
# canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다.
# 로컬은 비워 둔다(기본값 localhost). 서버에 올릴 때만 실제 도메인을 적는다.
# SITE_PUBLIC_HOST=web4ai.o2osolution.ai
# 비우면 색인 통보를 건너뛴다(발행은 정상)
INDEXNOW_KEY=
# Google Search Console — 최초 소유권/서비스 계정 권한 설정 후 켠다 (docs/SEARCH_CONSOLE.md).
GSC_ENABLED=0
GSC_PROPERTY_URL=
GSC_CREDENTIALS_FILE=
GSC_CREDENTIALS_HOST_FILE=
GSC_ALERT_DAYS=7
GSC_ALERT_WEBHOOK_URL=
# 장애 알림(잡 dead-letter·발행 업무 실패·부분 실패·잡 큐 정체) — Teams Workflows 수신 webhook.
# GSC_ALERT_WEBHOOK_URL 과 다른 값이다(그건 색인 감시 전용) — docs/ALERTS.md.
# 비우면 알림은 DB(alert_outbox)에 쌓이기만 하고 안 나간다. 서버 동작에는 영향 없다.
TEAMS_WEBHOOK_URL=
# 재시도마다 중복 스팸을 막는 창(분). 기본 60분 — 같은 사유가 이 시간 안에 또 터지면 다시 안 보낸다.
ALERT_DEDUPE_WINDOW_MIN=60
# 비우면 로컬 발행만 한다
AZURE_STORAGE_CONNECTION_STRING=
AZURE_STORAGE_CONTAINER=
AZURE_STORAGE_PREFIX=
# ── compose 포트 매핑
# 비우면 로컬 기본값. 서버 값은 docs/SERVERS.md
# SITE_HTTP_PORT=80 # 발행 사이트
# WEB_PORT=3000 # 사장님 앱
# API_PORT=9800 # 사장님 API
# ADMIN_PORT=3002 # 내부 화면 (bind 127.0.0.1)
# ADMIN_API_PORT_PUBLIC=9801 # 내부 API (bind 127.0.0.1)
# 자동 로그인 — 위저드 앞에 로그인 화면을 세우지 않으려고 세션을 미리 잡는다.
# ⚠️ 이 값은 **프론트 번들에 구워진다.** 페이지를 연 사람은 누구나 JS 에서 읽는다 —
# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession.ts).
# ★ solution-frontend(--profile dev, vite dev)에서만 읽힌다 — 운영 진입점(solution-site,
# nginx/Dockerfile)은 이 값을 build arg 로 아예 받지 않는다. 여기 채워도 운영 번들에는
# 절대 안 들어간다. 바꾸면 재기동만 하면 된다(운영 이미지 재빌드가 필요 없다).
AUTO_LOGIN_ID=
AUTO_LOGIN_PW=
# ── 메일 발송 (예약 요청 알림) ────────────────────────────────────────────
# 비우면 메일을 보내지 않는다. 서버는 그대로 뜨고, 예약 요청 폼은 "전화로 문의" 로 답한다.
# ★ 1순위는 회사 공용 Azure Communication Services 다(negodata 와 같은 리소스).
# 발신 도메인의 SPF·DKIM 을 그쪽이 관리하므로 메일서버를 새로 세울 필요가 없다.
ACS_EMAIL_ENDPOINT=
ACS_EMAIL_ACCESSKEY=
ACS_EMAIL_SENDER=
# ACS 를 못 쓰는 환경의 폴백. 이쪽을 쓰면 SPF·DKIM 을 직접 걸어야 한다.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
SMTP_FROM_NAME=Web4AI
# starttls(587) · ssl(465) · plain. 비우면 포트로 고른다.
SMTP_TLS=
# SNS — Threads 우선(2026-09-14). SOCIAL_TOKEN_SECRET은 Fernet.generate_key() 형식의 키.
# 키·앱 설정 없으면 연결 비활성, 초안/복사/화면 확인은 동작한다.
SOCIAL_TOKEN_SECRET=
THREADS_APP_ID=
THREADS_APP_SECRET=
THREADS_REDIRECT_URI=https://web4ai.o2osolution.ai/v1/social/oauth/callback
SOCIAL_APP_ORIGIN=https://web4ai.o2osolution.ai
SOCIAL_APPROVAL_HOURS=24
# 앱 심사·테스트 계정 게시·해지 안내 페이지 정책 검증 후 활성화.
SOCIAL_POSTING_ENABLED=0
# 대행사 선택 전 비워 둔다. 현재 어댑터는 SOLAPI 계약이며 교체는 external/alimtalk.py만.
ALIMTALK_API_KEY=
ALIMTALK_API_SECRET=
ALIMTALK_PROFILE_ID=
ALIMTALK_SENDER=
ALIMTALK_TEMPLATE_CODE=

11
.gitignore vendored
View File

@ -26,6 +26,9 @@ solution/backend/openapi.json
# 여기 생긴다. 어느 쪽이든 payload 로 다시 굽는 재생성물이라 git 이 관리할 대상이 아니다.
solution/site/out/
solution/site/dist/
# React Router 프레임워크 모드(solution/frontend)의 산출물
build/
.react-router/
admin/dist/
# nginx 설정: 서버마다 다르므로 실제 파일은 커밋하지 않는다. 템플릿만 커밋한다.
@ -55,3 +58,11 @@ dist/
# 개인용 오버라이드는 레포가 아니라 ~/.claude/CLAUDE.md 나 .claude/settings.local.json 에 둔다.
.claude/settings.local.json
# 목업 작업 산출물 — 발행본 원본은 도커 볼륨(out/s)이라 레포에 두지 않는다
solution/site/scripts/mockup/backup/
solution/site/scripts/mockup/king-stay2/
solution/site/scripts/mockup/build6p/
solution/site/scripts/mockup/build6p-stay2/
solution/site/scripts/mockup/siann6/
solution/site/scripts/mockup/_sub*.mjs

2
.serena/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/cache
/project.local.yml

169
.serena/project.yml Normal file
View File

@ -0,0 +1,169 @@
# the name by which the project can be referenced within Serena/when chatting with the LLM.
project_name: "o2o-site-AEO"
# list of language servers to start when using the LSP backend; choose from:
# ada al angular ansible bash
# bsl clojure cpp cpp_ccls crystal
# csharp csharp_omnisharp cue dart deno
# elixir elm erlang fortran fsharp
# gdscript gleam go groovy haskell
# haxe hlsl html java json
# julia kotlin latex lean4 lua
# luau markdown matlab msl nextflow
# nix ocaml pascal perl php
# php_phpactor php_phpantom powershell python python_basedpyright
# python_jedi python_pyrefly python_ty qml r
# rego ruby ruby_solargraph rust scala
# scss solidity svelte swift systemverilog
# terraform toml typescript typescript_vts vue
# wolfram yaml zig
# (This list may be outdated; generated with scripts/print_language_list.py;
# For the current list, see values of the LanguageServerId enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some language servers require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple language servers, the first language server that supports a given file will be used for that file.
# The first language server is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
language_servers:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
# the command runs in the project root directory and is only executed if the project is trusted
# (see trusted_project_path_patterns in the global configuration).
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
# example: activation_command: "npx nx run-many -t build"
activation_command:
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
# must be a positive number.
activation_command_timeout: 180.0
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
ls_specific_settings: {}
# list of workspace folder paths (LSP backend only).
# These folders will be used to build up Serena's symbol index.
# Paths must be within the project root and should thus be relative to the project root.
# Furthermore, the paths should not be filtered by ignore settings.
# Default setting: The entire project root folder (".") is considered.
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
# ls_workspace_folders:
# - "./subproject1"
# - "./subproject2"
ls_workspace_folders:
- "."
# list of additional workspace folder paths for cross-package reference support.
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries, but these folders are not indexed by Serena,
# i.e. the respective symbols will not be found using Serena's symbol search tools.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
ls_additional_workspace_folders: []
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
# Example:
# ignored_paths:
# - "examples/**"
# - ".worktrees/**"
# - "**/bin/**"
# - "**/obj/**"
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []

333
AGENTS.md
View File

@ -1,5 +1,8 @@
# AGENTS.md — 이 레포에서 작업하기 전에
> 2026-09-15 발행 버전 전환: [docs/PUBLISH_VERSION.md](docs/PUBLISH_VERSION.md)가
> 아래의 상시 프리렌더·재굽기·자산 주소 교체 절차를 대체한다. 목업은 변경하지 않는다.
에이전트와 신규 합류자가 **먼저 읽는 파일**이다. 여기에는 *밟기 쉬운 함정*과 *규약*만 둔다.
설명은 각 문서가 단일 출처다 — 여기로 복사하지 말고 링크한다.
@ -7,9 +10,15 @@
|---|---|
| 이 제품이 뭘 푸나 · **안 하기로 한 것** | [docs/PRODUCT.md](docs/PRODUCT.md) |
| 어떻게 도나 · 앱 경계 | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
| **어느 표 어느 칸**에 담기나 · 값이 페이지까지 가는 길 | [docs/DATA_MODEL.md](docs/DATA_MODEL.md) |
| **다음에 뭘 만드나** (우선순위 P0~P4) | [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) |
| 미결 사항 · 코드가 그걸 어떻게 격리했나 | [docs/DECISIONS.md](docs/DECISIONS.md) |
| 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) |
| 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) |
| **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) |
| 장애가 나면 누가·어떻게 아나 | [docs/ALERTS.md](docs/ALERTS.md) |
| **미니 블로그**(AI 자동 포스트) 기획 | [docs/MINI_BLOG.md](docs/MINI_BLOG.md) |
| **사장님 에이전트**(카톡으로 관리) · 신원 연결 | [docs/AGENT.md](docs/AGENT.md) |
---
@ -23,20 +32,99 @@
밟으면 **조용히 틀린다** — 빌드는 성공하고 화면도 뜨는데 결과가 잘못된 종류다.
- **★★ `out/s/` 에는 payload 가 없는 사이트가 있다 — 목업(`stay` · `stay2` · `stay3` · `*.old`).**
프리렌더는 **payload 를 받은 사이트만** 굽는다. 목업은 손으로 넣은 것이라 재굽기 대상이
아니고, **자산이 한 번 지워지면 영영 복구되지 않는다** — 재굽기를 몇 번 돌려도 안 살아나고
사람이 파일을 되돌려 넣어야 한다. 실측(2026-09-07): 번들 해시가 바뀌자 목업 3개의 CSS·JS·
이미지가 전부 404 가 됐고, 그 파일들은 `stay-mockup` 워크트리에서 손으로 꺼내 복구했다.
**`out/assets` 에서 파일을 지우는 코드는 `out/s/**` 의 HTML 이 참조하는 것을 먼저 뺀다**
(`prerender.ts` `referencedAssets`). 보관 기간으로는 못 막는다 — 기간이 지나면 같은 일이 난다.
→ 목업을 다루는 작업은 `out/s/` 를 먼저 열어 **payload 가 없는 디렉토리가 무엇인지** 본다.
→ ★ **`stay` 는 프리렌더가 굽지 않는다** (`prerender.ts` `PROTECTED_SLUGS`, 기본 `stay` ·
`PRERENDER_PROTECTED_SLUGS` 로 덮어쓴다). "payload 가 없으면 안 굽는다"
는 보호가 못 된다 — **payload 가 생기는 순간** 덮인다. 실측(2026-09-15): 누가 빌더에서
슬러그 `stay` 로 발행해 `payloads/stay.json` 이 생기자 프리렌더가 `/s/stay` 를 그 payload 로
구워 목업을 통째로 날렸다(캐치프레이즈 100개·미니 플레이어·날씨 문구·주입분 전부).
그 payload 는 `solution/site/payloads-mockup-hold/` 로 옮긴다 — 지우면 재발행 때 또 온다.
- **★ 굽기는 네트워크를 탄다 — 사진을 내려받는다** (`prerender.ts` `mirrorMedia`).
`payload.media[].url` 이 남의 도메인이면 `out/s/<slug>/img/<주소해시>.<확장자>` 로 받아 놓고
payload 의 주소를 **우리 오리진 절대주소**로 바꾼 뒤에 굽는다. 이유는 캔버스다 —
수집처(`*.pstatic.net` · `tong.visitkorea.or.kr`)가 `Access-Control-Allow-Origin` 을 안 줘서
그 사진을 캔버스에 그리면 오염돼 `toBlob` 이 막히고, **엽서 쓰기의 저장·공유가 모든 발행
사이트에서 죽어 있었다**(실측 2026-09-15). 클라이언트에서는 못 넘는다.
→ 못 받은 사진은 **원래 주소를 그대로 쓴다**(사진이 사라지는 것보다 낫다). 로그에 한 줄 남는다.
→ 주소가 그대로면 파일명도 그대로라 **다시 구워도 내려받지 않는다.** 처음 한 번만 느리다.
→ ★ **이미 나가 있는 사이트는 그대로 둔다.** 새 기능은 사장님이 **다시 발행할 때** 들어간다
(아래 항목). 그 사이를 메우는 건 **중계**다 — `/v1/image/relay?url=…`
(`backend/router/v1/media/relay.py`). 캔버스가 CORS 로 사진을 못 받으면 같은 오리진의
이 주소로 한 번 더 받아 본다(`site/src/lib/postcard-canvas.ts` `loadImage`).
열린 프록시가 아니다 — https · 호스트 allowlist · 이미지 타입 · 8MB · 리다이렉트 후
호스트 재검사. **호스트를 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는가" 를 먼저 본다.**
`originUrl` · `sourceType` 은 손대지 않는다 — 재게시 권리(DECISIONS 1-2)가 "불가" 로
결론 나면 `sourceType = CRAWL` 을 빼는 그 대응이 그대로 먹어야 한다.
- **★★ 배포해도 기존 사이트를 다시 굽지 않는다 — 자산 주소만 갈아 끼운다.**
(2026-09-15 대표 지시: "전체 재굽기 할 필요가 없어, 사장님이 재발행하면 끝인데 /
css js만 안 깨지게 하란 말이야")
예전에는 `solution-prerender` 가 뜰 때마다 payload 를 **전부 다시 구웠다.** 그러면 렌더러를
한 줄 고칠 때마다 이미 나가 있는 사이트의 HTML 이 통째로 바뀐다 — 사장님은 발행한 적이
없는데 내용이 달라지고, 구글이 다시 읽어 가는 값도 달라진다.
지금 기동이 하는 일은 `prerender.js --refresh-assets` 하나다
(`watch-payloads.mjs` `refreshAssets``prerender.ts` `refreshBakedAssets`):
→ 구워진 `index.html` 안의 `assets/index-<해시>.css|js` **파일명만** 새 번들로 바꾼다.
내용·구조·payload 는 손대지 않는다. 접두사(`/assets` · `/sites/assets`)도 그대로 둔다.
**한 번도 안 구워진 payload 만** 굽는다(볼륨이 비었거나 감시가 꺼진 새 발행).
→ ★ **payload 가 없는 디렉토리는 건드리지 않는다**(목업 `stay3` · `*.old`, 그리고
`PROTECTED_SLUGS`). 손으로 만든 유일본에 최신 번들을 물렸다가 깨지면 되돌릴 수 없다 —
그쪽 번들 교체는 사람이 한다(mockup/README "번들만 갈아 끼운다").
대상은 `--payload-dir``<슬러그>.json` 이 있는 사이트뿐이다.
★ 그래서 **정적 HTML 은 옛 렌더러의 것이고 스크립트는 새 렌더러**다. 어긋나면 리액트가
그 자리에서 다시 그리므로 손님 화면은 새것이지만, **크롤러가 읽는 HTML 은 옛것**이다.
둘을 맞추는 방법은 재발행뿐이고 그건 사장님이 누른다. 급하면 `republish_all.py` 지만
**먼저 묻는다** — 전 사이트의 발행일이 한꺼번에 움직이는 일이다.
- **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로
가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다
(`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다.
- **로컬 `out/assets` 는 빌드마다 통째로 갈린다** (`prerender.ts:573` `rmSync`). 옛 해시 파일이
사라지므로 새 번들로 일부 사이트만 구우면 나머지는 CSS 가 404 다.
→ 프리렌더 기동 시 전체 재굽기가 이 구멍을 메운다.
- **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
`azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 —
**렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.**
`docker compose restart web``python scripts/republish_all.py`
- **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `w4ai.o2o.kr`,
- **옛 해시 자산은 30일 남는다** (`prerender.ts` `ASSET_RETENTION_DAYS`). 예전에는 빌드마다
`out/assets` 를 통째로 갈아서, 새 번들로 일부만 구우면 나머지 사이트가 CSS 404 였다.
지금은 남긴다 — 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌리므로, 그 사이 자산이 사라지면
스타일 없는 페이지를 렌더한 것으로 기록된다. 보관 근거는 `out/assets/.builds.json` 대장이고
파일 mtime 이 아니다.
→ 재굽기는 여전히 필요하지만 **급하지 않다**. 디자인이 반영 안 될 뿐, 깨지지는 않는다.
- **★ 대장(`out/assets/.builds.json`)에 없는 자산은 지우지 않는다** — "지금 처음 본 것" 으로
치고 보관 기간을 새로 준다(`pruneAssets`). **이 규칙을 깨면 운영 사이트가 즉시 끊긴다.**
실제로 그랬다(2026-09-07): 대장은 이 기능과 함께 생겼으므로 **배포 직후 첫 실행에는 대장이
없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다.
옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
→ 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.**
→ 이미 끊겼다면 복구는 `docker compose restart solution-prerender` — 기동이 공용 자산을
다시 깔고 구워진 HTML 의 자산 주소를 맞춘다(전체 재굽기가 아니다, 위 ★★ 항목).
- **★ 사이트를 굽는 컨테이너는 `solution-prerender` 다.** `solution-frontend` 는 **개발용**이라
운영에서는 아예 뜨지 않는다(`docker-compose.yml` `profiles: ["dev"]`). 이름이 비슷해서
`restart solution-frontend` 를 치면 **아무 일도 안 일어나는데 명령은 성공한다**
재굽기를 했다고 믿고 넘어가게 된다. 실제로 그렇게 복구가 한 번 헛돌았다(2026-09-07).
- **★ 프론트(`solution/site`)를 고쳐도 기존 사이트의 내용은 안 바뀐다.** 기동은 자산 주소만
맞춘다(위 ★★ 항목) — 새 렌더러로 다시 그려지는 건 **그 사장님이 다시 발행할 때**다.
Azure 를 쓰는 경우엔 한 겹 더 있다: `azure_static.publish(slug)` 는 공용 자산 + `s/<slug>`
올린다 — 다른 사이트의 블롭은 그대로다.
→ 전 사이트를 한꺼번에 새 렌더러로 맞춰야 할 일이 생기면
`docker compose restart solution-prerender``python scripts/republish_all.py` 인데,
**먼저 묻는다**(발행일이 전부 움직인다).
- **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`,
`site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부
이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더
재실행만으로는 안 되고 **백엔드에서 재발행**해 payload 를 다시 만들어야 한다.
- **`GOOGLE_CLIENT_ID` 도 두 곳에 있고 같아야 한다.** 백엔드(`GOOGLE_CLIENT_ID`) ↔ 프론트
(`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의
수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.**
어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다).
- **`VITE_AUTO_LOGIN_ID`·`PW` 는 운영 진입점(`solution-site`, nginx/Dockerfile)에 절대
넘기지 않는다.** 예전엔 `docker-compose.yml``solution-site` build args 에 이 값이
실제로 흘러가고 있었다 — `.env` 에 채운 채로 배포하면 자동 로그인 계정이 사장님이 여는
운영 번들에 그대로 구워졌다(누구나 JS 에서 읽을 수 있다). 지금은 그 build arg 자체가
없다. `lib/autoSession.ts``import.meta.env.DEV` 가드가 둘째 안전판이다 — 실수로
값이 다시 넘어와도 운영 빌드(`vite build`)에서는 죽은 코드로 접혀 번들에서 빠진다.
자동 로그인이 필요하면 `solution-frontend`(`--profile dev`, `vite dev`)만 쓴다.
- **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데
블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는
CDN 을 앞에 세워야 한다. 아니면 비워라.
@ -44,8 +132,64 @@
- **`AZURE_STORAGE_CONTAINER=$web`** — 셸에서 export 할 땐 반드시 작은따옴표(`'$web'`).
- **슬러그 규칙은 두 곳에 있고 같아야 한다**: `site_payload.publish_slug()`
`solution/shared/src/lib/slug.ts publishUrl`. 어긋나면 발행은 성공하고 주소만 404 다.
- **디렉토리 요청 → `index.html`.** `/s/<slug>`**끝 슬래시 없이** 열려야 한다.
정적 서버를 바꾸든 nginx 설정을 만지든 이 규칙부터 확인한다.
- **★ 발행본 주소는 끝 슬래시가 없다 — 목록 페이지 `/s` 도 마찬가지다.**
canonical · 사이트맵 · llms.txt · 서치콘솔 색인 요청이 전부 이 형태여야 한다. 어긋나면
구글이 제출분을 **"대체 페이지(적절한 표준 태그가 있음)"** 로 분류한다 — 색인은 되는데
제출 URL 은 0건으로 보이는, 눈으로 원인을 못 찾는 종류다.
→ nginx 는 `location = /s` 로 목록 index.html 을 **직접** 주고 `/s/` 는 거기로 301 한다.
이 블록을 지우면 `/s` 가 맨 아래 `location /` 로 떨어져 **빌더 SPA 셸이 200 으로 나간다**
— 404 도 목록도 아닌 세 번째 페이지가 크롤러에 잡힌다(실측 2026-09-08).
→ 리다이렉트는 `absolute_redirect off`**상대 Location** 이어야 한다. TLS 를 앞단
Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다 — 절대 URL 로 내면 https→http 다.
- **★ SNS 게재 승인은 GET 으로 처리하지 않는다.** 메신저의 링크 미리보기 생성기·백신·브라우저
프리페치가 **사람이 누르기 전에** 그 URL 을 연다. GET 승인이면 사장님이 안 눌렀는데 글이
올라가고 로그에는 "승인됨" 으로 남는다 — 눈으로 원인을 못 찾는 종류다.
링크는 확인 화면을 열 뿐이고 게시는 그 화면의 POST 다([DECISIONS 8-3](docs/DECISIONS.md)).
- **★ SNS 게재는 `sites.domain` 이 확정된 사이트에만 허용한다.** `domain` 이 비면 발행 슬러그가
**상호명에서 파생**되고(`_publish_target`), 상호를 고치면 주소가 통째로 바뀐다. `SITE_SLUG_LOCKED`
`domain` 변경만 막으므로 여기엔 안 걸린다 — **이미 올라간 글의 링크는 404 가 되고 그 글은
수정할 수 없다.**
- **★ ORM 의 `server_default=text("'…'")` 에 쉼표를 딸려 보내지 않는다.** `text("'[]',")`
`DEFAULT '[]', NOT NULL` 로 나가 **CREATE TABLE 이 통째로 실패**한다. 운영 DB 는 init.sql 로
만들어져 안 드러나고, **ORM 이 스키마를 만드는 테스트 DB 에서만** 터진다(실측 2026-09-14).
## SNS에서 조용히 틀리는 것 (2026-09-14)
- domain NULL은 임시 주소다. SNS는 PUBLISHED + current_version_id + 확정 domain을 모두 요구한다.
- 승인 GET은 프리페치가 연다. 상태 전이는 POST의 nonce 해시 + PENDING CAS로만 한다.
- Threads는 X의 offline.access/회전 refresh_token 계약을 쓰지 않는다. 장기 access token을 갱신한다.
- 토큰 갱신 저장 실패는 재연결. POSTING 중단·응답 유실은 UNKNOWN이며 자동 재게시 금지.
- 초기 SOCIAL_POSTING_ENABLED=0. [SOCIAL.md](docs/SOCIAL.md)의 실제 게시·해지 안내 페이지 전제를 확인한 뒤 연다.
## 에이전트에서 조용히 틀리는 것 (2026-09-21)
- **도구가 `crud` 를 직접 부르면 게이트가 통째로 뚫린다** — 업종 스키마 검증·출처 필수·정정본
보호가 사라지는데 **아무 증상이 없다**(값은 들어가고 빌드도 성공한다). 도구는 반드시
`services/*` 를 통과한다. `collect_service.store_facts` 가 크롤러에 걸어 둔 그 문이다.
- **카카오 채널 발화자는 우리 `user_id` 가 아니다** — 채널 단위 익명 키다.
`owner_kakao_links` 매핑 없이 발화자를 믿으면 **채널 진입점만 소유자 범위 밖**에 놓인다.
- **★ 카카오 웹훅은 서명이 없다 — 시크릿이 유일한 문이다.** 오픈빌더는 서명을 주지 않아서,
URL 만 알면 누구나 때릴 수 있고 `userRequest.user.id` 를 위조하면 **그 사장님 행세**를 한다.
`KAKAO_WEBHOOK_SECRET` 이 비면 엔드포인트가 **404**(401 은 존재를 알린다).
- **확인 대기에 만료가 없으면 묵은 발행이 돈다** — 카카오톡은 앞선 답을 되돌려 주지 않아
서버가 pending 을 들고 있는다. `pending_expires_at`(3분)을 빼면 한참 뒤의 "네" 한 마디에
실행된다([AGENT.md](docs/AGENT.md)).
- **바로가기 라벨과 '예' 로 읽는 말이 어긋나면 눌러도 안 먹는다** — 사장님은 버튼이 고장난
줄 안다. `channel.py``CONFIRM_LABEL` 상수를 쓰고 문자열을 손으로 적지 않는다.
- **에이전트 대화창은 스위치와 LLM 키를 둘 다 본다**(`AGENT_CHAT_ENABLED`, 기본 `1`).
키만 보면 "잠시 닫아 두기" 가 키를 지우는 일이 되어 소개문·사진분류까지 꺼지고,
스위치만 보면 키 없는 환경에 **눌러도 안 되는 입구**가 생긴다.
카카오 연결 카드는 `KAKAO_CHANNEL_PUBLIC_ID` 가 비면 감춰진다 —
웹훅(4단계)이 없어 코드를 보내도 연결이 완성되지 않기 때문이다([AGENT.md](docs/AGENT.md)).
- **에이전트 등급을 모델이 정하게 두지 않는다** — 확인이 필요한 행위인지는 `services/agent/tools.py`
레지스트리가 못 박는다. 응답 스키마에 그 칸을 만들면 프롬프트에 끼어든 한 줄이 확인 절차를 건너뛴다.
- **실행 결과 문구를 LLM 이 쓰게 두지 않는다** — 모델은 **하지 않은 일을 했다고 말할 수 있고**,
사장님에게는 그 말이 사실로 보인다. 화면의 "바꿨습니다" 는 코드가 보장하는 문장이어야 한다.
- **값을 고친 뒤 재발행 안내를 빠뜨리지 않는다** — fact 는 바뀌어도 사이트는 안 바뀐다.
사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다.
- **코드 소비 경로를 웹훅 서명 검증보다 먼저 열지 않는다** — 누구나 6자리를 대입해 남의
계정에 자기 카톡을 붙일 수 있다. 지금 `redeem()` 이 라우터에 없는 이유다([AGENT.md](docs/AGENT.md)).
## 코드 규약
@ -75,8 +219,8 @@ admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
| | 포트 | 진입점 | 권한 |
|---|---|---|---|
| 사장님 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 |
| 내부 API | 9801 | `admin/backend/main.py``app.py` | **앱 전체 role >= DEVELOPER** |
| 솔루션 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 |
| 어드민 API | 9801 | `admin/backend/main.py``app.py` | **앱 전체 role >= DEVELOPER** |
`services`·`crud`·`models` 은 그대로 공유한다. admin 화면이 부르는 게 사장님 빌더와 거의
같아서(place·fact, admin 전용은 `local-content` 하나) 도메인을 복제하지 않고 **같은 router
@ -90,16 +234,25 @@ admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
## 실행
```bash
docker compose up -d # api :9800 · api-admin :9801 · 워커 · web :3000 · admin :3002 · nginx :80
docker compose logs -f worker
docker compose up -d # api :9800 · 워커 · 프리렌더 · nginx :80
docker compose --profile dev up -d # + 사장님 앱 HMR :3000 (로컬 전용)
docker compose logs -f solution-worker
```
- 사장님 앱 `:3000`(API :9800) · 내부 운영 `:3002`(API :9801, 로컬호스트에만 열림)
- 발행 사이트: `http://localhost:3000/s/<slug>` (빌더 Vite 가 :3001 정적서버로 프록시)
**운영에는 dev 서버를 띄우지 않는다.** `docker compose up -d` 가 올리는 nginx(`:80`)가
사장님 앱(구운 번들) · 발행 사이트 · API 를 **한 오리진**으로 준다. `solution-frontend`
(Vite dev, `:3000`)는 `--profile dev` 로만 뜨고 HMR 이 필요할 때만 쓴다.
- 한 오리진: `http://localhost/` 앱 · `http://localhost/s/<slug>` 발행 사이트 · `/v1/...` API
- 내부 운영 `:3002`(API :9801, 로컬호스트에만 열림) — `--profile admin`
- ★ **`VITE_*` 는 번들에 구워진다.** 주소를 바꾸면 `.env` 만 고쳐선 안 되고
`./deploy.sh solution-site`**다시 구워야** 한다
- **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf`
(후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다)
- DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`).
스키마는 `postgres-init/init-data/init.sql` **한 벌**이다 — 누적 ALTER 파일은 없다
스키마는 `postgres-init/init-data/init.sql`(새 DB 전체 DDL) **+** `postgres-init/migrations/`
(이미 만들어진 DB 보정)다. **둘 다 고친다** — init.sql 만 고치면 서버 DB 에 반영되지 않고,
마이그레이션만 쓰면 새로 세운 DB 에 그 변경이 없다. 적용: `scripts/migrate.py`
- npm 워크스페이스 루트는 **레포 루트**다. `npm install` 은 루트에서 한 번.
`npm run dev:frontend` / `dev:admin` / `dev:site`
- 백엔드 스크립트는 `solution/backend/` 에서 `.venv/bin/python scripts/<name>.py`
@ -119,7 +272,147 @@ Vite 앱은 구조상 **자기 디렉토리의 `.env` 만** 읽는다 — 그래
**두 곳에 같은 값을 적지 않는다.** 발행 호스트는 compose 가 루트의 `SITE_PUBLIC_HOST`
`VITE_PUBLISH_HOST` 로 흘려보낸다. 각자 적으면 canonical 과 화면 주소가 조용히 갈라진다.
## 커밋
## 브랜치 · 커밋
- 한 커밋 = 한 가지 변경. 문서와 그 문서가 설명하는 코드는 같은 커밋에 둔다.
- 커밋 메시지는 한국어로, **무엇을 왜 바꿨는지**. 파일 목록은 `git` 이 이미 안다.
`o2o-negosium` 과 같은 규약이다.
**브랜치는 영문**이다. 한국어 브랜치를 올리지 않는다(워크트리 도구가 한글 이름을 자동으로
만드는데 그대로 push 되기 쉽다).
```
feature/<주제> feat/<주제> 새 기능
fix/<주제> 버그
chore/<주제> 잡일·설정
docs/<주제> 문서
design/<주제> 디자인
```
**커밋 제목: `[type] scope: 요약 — 부연`**
```
[feat] lps/enuri: 오퍼 항목의 판매몰명 식별 — 이미지 CDN 도메인 매핑
[fix] negosium/front: 복기 구멍을 블록 경계에서 끊고 시트 여닫이에 고정
[chore] lps: 구 IQR 이상치 제거 코드 삭제 — 죽은 경로 정리
```
- type: `feat` `fix` `chore` `docs`
- **scope 는 코드 경로**다 — `lps/ai` · `negodata/front` · `postgres-init`.
이 레포라면 `solution/backend` · `site` · `admin/front` · `deploy`.
여러 곳이면 쉼표(`negosium/front,landing`)
- **요약은 명사형으로 끝낸다** — "분리" "추가" "갱신". "~한다" 로 쓰지 않는다
- 부연은 `—` 뒤에 붙인다
**본문은 세 덩이다.**
```
[fix] lps: 행(hang)·병목 가드 — 페이지 상호작용 80s 상한 + AI 호출 타임아웃
crash 로 굳은 페이지는 content() 가 CDP 응답을 상한 없이 기다린다 —
실측(gmarket): 무로그 4분 행 → 잡 데드라인 300s 소진 → 사용자 체감 +5분.
- browser.py: goto+렌더대기+content 를 _fetch_page 로 묶어 80s 단일 상한
- ai/keyword: timeout=45s 명시 — SDK 기본(600s)이 잡 데드라인보다 크다
테스트 4건 추가, 전체 270 passed
```
1. **왜** — 실측값·밟은 함정. 코드를 읽으면 아는 "무엇" 은 쓰지 않는다
2. **변경 항목** — 파일/모듈별 불릿, 항목마다 근거
3. **검증**`tsc·eslint·vite build 통과` · `전체 N passed`
한 커밋 = 한 가지 변경. 문서와 그 문서가 설명하는 코드는 같은 커밋에 둔다.
## 레포 구조
```
solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약)
admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
```
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
묶음 폴더로 쓰지 않는다. 근거와 경계는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md).
**의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src`
가리키고, API 도 solution 백엔드를 본다. 반대 방향이 생기면 가른 의미가 사라진다.
★ **백엔드는 코드 한 벌, 진입점 둘이다.**
| | 포트 | 진입점 | 권한 |
|---|---|---|---|
| 솔루션 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 |
| 어드민 API | 9801 | `admin/backend/main.py``app.py` | **앱 전체 role >= DEVELOPER** |
`services`·`crud`·`models` 은 그대로 공유한다. admin 화면이 부르는 게 사장님 빌더와 거의
같아서(place·fact, admin 전용은 `local-content` 하나) 도메인을 복제하지 않고 **같은 router
객체를 다시 마운트하면서 앱 단위로 권한만 덧건다.**
경로 접두어(`/v1/admin/...`)가 아니라 **포트**를 가른 이유: 접두어는 같은 프로세스라
사장님이 닿는 서버에 내부 엔드포인트가 존재한다. 포트를 가르면 아예 없다.
`ADMIN_API_BIND` 기본값이 `127.0.0.1` 인 것도 같은 이유다 — 0.0.0.0 으로 열면 무의미하다.
⚠️ 단 `/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다 — 남은 구멍이다
([ARCHITECTURE.md 4절](docs/ARCHITECTURE.md)).
## 실행
```bash
docker compose up -d # api :9800 · 워커 · 프리렌더 · nginx :80
docker compose --profile dev up -d # + 사장님 앱 HMR :3000 (로컬 전용)
docker compose logs -f solution-worker
```
**운영에는 dev 서버를 띄우지 않는다.** `docker compose up -d` 가 올리는 nginx(`:80`)가
사장님 앱(구운 번들) · 발행 사이트 · API 를 **한 오리진**으로 준다. `solution-frontend`
(Vite dev, `:3000`)는 `--profile dev` 로만 뜨고 HMR 이 필요할 때만 쓴다.
- 한 오리진: `http://localhost/` 앱 · `http://localhost/s/<slug>` 발행 사이트 · `/v1/...` API
- 내부 운영 `:3002`(API :9801, 로컬호스트에만 열림) — `--profile admin`
- ★ **`VITE_*` 는 번들에 구워진다.** 주소를 바꾸면 `.env` 만 고쳐선 안 되고
`./deploy.sh solution-site`**다시 구워야** 한다
- **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf`
(후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다)
- DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`).
스키마는 `postgres-init/init-data/init.sql`(새 DB 전체 DDL) **+** `postgres-init/migrations/`
(이미 만들어진 DB 보정)다. **둘 다 고친다** — init.sql 만 고치면 서버 DB 에 반영되지 않고,
마이그레이션만 쓰면 새로 세운 DB 에 그 변경이 없다. 적용: `scripts/migrate.py`
- npm 워크스페이스 루트는 **레포 루트**다. `npm install` 은 루트에서 한 번.
`npm run dev:frontend` / `dev:admin` / `dev:site`
- 백엔드 스크립트는 `solution/backend/` 에서 `.venv/bin/python scripts/<name>.py`
- 테스트: `solution/backend/` 에서 `.venv/bin/pytest`. **`APP_ENV=test``.env` 를 읽지 않는다** —
실키가 테스트로 새어 외부 API 요금이 나가는 경로를 막아 뒀다
## .env 는 어디에 두나
**루트 `.env` 가 단일 출처다.** compose 가 이 파일만 읽고(`${...}` 치환 + `env_file`),
Vite 앱은 구조상 **자기 디렉토리의 `.env` 만** 읽는다 — 그래서 나뉘어 있는 것이지 취향이 아니다.
| 파일 | 담는 것 |
|---|---|
| `.env` | DB · JWT · 외부 API 키 · `SITE_PUBLIC_HOST` · `INDEXNOW_KEY` |
| `solution/frontend/.env` · `admin/frontend/.env` | 그 앱에만 있는 `VITE_*`. admin 은 API 가 :9801 이다 |
**두 곳에 같은 값을 적지 않는다.** 발행 호스트는 compose 가 루트의 `SITE_PUBLIC_HOST`
`VITE_PUBLISH_HOST` 로 흘려보낸다. 각자 적으면 canonical 과 화면 주소가 조용히 갈라진다.
## 브랜치 · 커밋
`o2o-negosium` 과 같은 규약이다.
**브랜치는 영문**이다. 한국어 브랜치를 올리지 않는다(워크트리 도구가 한글 이름을 자동으로
만들 수 있는데, 그대로 push 하면 안 된다).
```
feature/<주제> 새 기능 fix/<주제> 버그
chore/<주제> 잡일·설정 docs/<주제> 문서
```
**커밋 메시지는 `type(scope): 한국어 설명`.**
```
feat(site): 발행 모달에 커스텀 도메인 안내를 붙인다
fix(backend/config): 배포 주소에서 CORS 가 막히던 것
chore(deploy): 포트를 .env 로 뽑는다
```
- type: `feat` `fix` `chore` `docs` `refactor`
- scope: 건드린 자리(`backend` `site` `admin/front` `lps` `postgres-init` …). 애매하면 생략한다
- 한 커밋 = 한 가지 변경. 문서와 그 문서가 설명하는 코드는 같은 커밋에 둔다
- 본문에는 **왜** 를 적는다. 파일 목록은 `git` 이 이미 안다

View File

@ -16,16 +16,16 @@
cp .env.example .env # DB_*, JWT_*, 외부 API 키
cp nginx/site.conf.example nginx/site.conf # 빼먹으면 nginx 가 설정 없이 뜬다
docker compose up -d
docker compose logs -f worker
docker compose logs -f solution-worker
```
| | 주소 | 공개 |
|---|---|---|
| 사장님 앱 (빌더) | http://localhost:3000 | 외부 |
| 발행된 사이트 | http://localhost:3000/s/`<slug>` · 운영은 :80 | 외부 |
| 사장님 API 문서 | http://localhost:9800/docs | 외부 |
| 솔루션 API 문서 | http://localhost:9800/docs | 외부 |
| **내부 운영 화면** | http://localhost:3002 | **127.0.0.1 만** |
| **내부 API 문서** | http://localhost:9801/docs | **127.0.0.1 만** |
| **어드민 API 문서** | http://localhost:9801/docs | **127.0.0.1 만** |
내부 두 개를 `0.0.0.0` 으로 열면 앱을 가른 의미가 없다 (`ADMIN_BIND` · `ADMIN_API_BIND`).

View File

@ -1,4 +1,4 @@
"""내부 운영 API. 사장님 API(:9800)와 프로세스·포트가 갈린다.
"""어드민 API. 솔루션 API(:9800)와 프로세스·포트가 갈린다.
도메인 코드는 solution/backend 것을 PYTHONPATH 쓴다 admin 전용 라우터가 0개라
(전부 place·fact) 새로 쓰면 같은 테이블을 구현하는 것뿐이다.
@ -22,6 +22,7 @@ import router.v1.fact.fact
import router.v1.job.job
import router.v1.local.local
import router.v1.place.place
import router.v1.site.review_admin
import router.v1.site.site
from contextlib import asynccontextmanager
@ -84,3 +85,4 @@ app.include_router(router.v1.fact.fact.router, dependencies=_gate)
app.include_router(router.v1.job.job.router, dependencies=_gate)
app.include_router(router.v1.site.site.router, dependencies=_gate)
app.include_router(router.v1.local.local.router, dependencies=_gate)
app.include_router(router.v1.site.review_admin.router, dependencies=_gate)

View File

@ -1,4 +1,4 @@
# 내부 운영 API 서버 (:9801). 근거는 app.py 주석.
# 어드민 API 서버 (:9801). 근거는 app.py 주석.
# PYTHONPATH=../../solution/backend python main.py
import os

View File

@ -1,8 +1,9 @@
import {Building2, CalendarDays} from 'lucide-react';
import {Building2, CalendarDays, MessageSquareQuote} from 'lucide-react';
import {createBrowserRouter, Navigate, Outlet} from 'react-router';
import {AppShell, type NavItem} from '@/components/layout/AppShell';
import {RequireAuth} from '@/components/layout/RequireAuth';
import {LocalContentPage} from '@admin/pages/LocalContentPage';
import {ReviewModerationPage} from '@admin/pages/ReviewModerationPage';
import {LoginPage} from '@/pages/LoginPage';
import {NotFoundPage} from '@/pages/NotFoundPage';
import {PlaceDetailPage} from '@admin/pages/PlaceDetailPage';
@ -16,6 +17,7 @@ import {SeoAuditPage} from '@admin/pages/SeoAuditPage';
const ADMIN_NAV: NavItem[] = [
{to: '/places', match: '/places', label: '사업장', icon: Building2},
{to: '/local-content', match: '/local-content', label: '지역 콘텐츠', icon: CalendarDays},
{to: '/reviews', match: '/reviews', label: '이용 후기', icon: MessageSquareQuote},
];
/**
@ -23,7 +25,8 @@ const ADMIN_NAV: NavItem[] = [
* . .
*/
export const router = createBrowserRouter([
{path: '/login', element: <LoginPage />},
// selfServe=false: 내부 운영 계정은 우리가 만들어 준다 — 가입 링크도 구글 로그인도 두지 않는다.
{path: '/login', element: <LoginPage selfServe={false} />},
{path: '/', element: <Navigate to="/places" replace />},
@ -40,6 +43,7 @@ export const router = createBrowserRouter([
{path: '/places/:placeId', element: <PlaceDetailPage />},
{path: '/places/:placeId/seo', element: <SeoAuditPage />},
{path: '/local-content', element: <LocalContentPage />},
{path: '/reviews', element: <ReviewModerationPage />},
],
},

View File

@ -9,9 +9,13 @@ import {customFetch} from '@/api/mutator/custom-fetch';
import {toast} from 'sonner';
type Status = 1 | 2 | 3;
// LocalContentType — common/enums.py 와 값을 맞춘다. WEATHER(1) 은 사업장 발행본에 실시간으로
// 붙는 별도 흐름이라 이 화면에서는 다루지 않는다(services/local_content_service.get_weather).
type ContentType = 2 | 3 | 4;
type LocalContent = {
id: string;
contentType: ContentType;
title: string;
region: string;
period: string;
@ -24,10 +28,15 @@ type LocalContent = {
};
const STATUS_LABEL: Record<Status, string> = {1: '검수 대기', 2: '발행됨', 3: '종료됨'};
const TYPE_LABEL: Record<ContentType, string> = {2: '축제', 3: '관광지', 4: '맛집'};
const TYPE_BADGE_VARIANT: Record<ContentType, 'accent' | 'default' | 'outline'> = {
2: 'accent', 3: 'default', 4: 'outline',
};
type ApiContent = {
local_content_id: string;
region_code: string;
content_type: ContentType;
external_id?: string;
title?: string;
body: Record<string, unknown>;
@ -43,14 +52,17 @@ function formatDate(value: unknown) {
}
function toContent(item: ApiContent): LocalContent {
// 기간은 축제만 있다(eventstartdate/eventenddate) — 관광지·맛집은 상시 정보라 비어 있는 게 정상이다.
const start = formatDate(item.body.eventstartdate);
const end = formatDate(item.body.eventenddate);
const period = [start, end].filter(Boolean).join(' ~ ') || (item.content_type === 2 ? '기간 미정' : '상시');
return {
id: item.local_content_id,
contentType: item.content_type,
title: item.title || '제목 없음',
region: String(item.body.addr1 || item.region_code),
period: [start, end].filter(Boolean).join(' ~ ') || '기간 미정',
source: `공공데이터포털 전국문화축제표준데이터 · ${item.external_id ?? '-'}`,
period,
source: `한국관광공사 TourAPI · ${item.external_id ?? '-'}`,
status: item.status,
selected: false,
displayStart: item.display_start_at,
@ -95,15 +107,24 @@ export function LocalContentPage() {
} catch { toast.error('발행하지 못했습니다.'); }
};
const sync = async () => {
const regionCode = window.prompt('내부 지역 코드(예: gunsan)를 입력하세요.', 'gunsan')?.trim();
if (!regionCode) return;
// ★ 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다.
// 이 화면의 목록은 아직 지역 캐시(local_contents)를 보여준다. 업장별 목록 화면은 다음 작업이다.
const placeId = window.prompt('사업장 ID(place_id)를 입력하세요. 사업장 목록 주소의 /places/ 뒤 값입니다.')?.trim();
if (!placeId) return;
setSyncing(true);
try {
const res = await customFetch<{result?: {success?: boolean}; msg?: string; collected?: number; skipped?: number}>({
url: '/v1/admin/local-content/sync-festivals', method: 'POST', data: {region_code: regionCode},
});
const res = await customFetch<{
result?: {success?: boolean}; msg?: string;
festivals?: number; attractions?: number; restaurants?: number; changed?: boolean;
}>({url: `/v1/admin/local-content/place/${placeId}/sync`, method: 'POST'});
if (res.result?.success === false) throw new Error(res.msg);
toast.success(`${res.collected ?? 0}건 수집 · ${res.skipped ?? 0}건 중복 제외`);
// ★ 여행코스(코스)는 2026-09-08부터 수집하지 않는다(반경을 넓혀도 데이터가 거의 없었다) — 표기에서 뺀다.
const summary = `축제 ${res.festivals ?? 0} · 관광지 ${res.attractions ?? 0} · 맛집 ${res.restaurants ?? 0}`;
if (!res.changed) {
toast.info(`바뀐 내용이 없습니다 (${summary}, TourAPI 원문 그대로).`);
} else {
toast.success(`${summary} 반영 — 다음 빌드부터 발행본에 실립니다.`);
}
await load();
} catch (error) {
toast.error(error instanceof Error ? error.message : '공공데이터 수집에 실패했습니다.');
@ -138,7 +159,7 @@ export function LocalContentPage() {
return (
<PageContainer
title="지역 콘텐츠"
description="공공데이터에서 지역 축제를 가져와 검수한 뒤 사장님에게 발행합니다."
description="한국관광공사 TourAPI 에서 지역 축제·관광지·맛집을 가져와 자동 발행합니다. 필요하면 여기서 수정하거나 발행을 종료할 수 있습니다."
actions={<Button onClick={sync} disabled={syncing}><Download className="size-4" />{syncing ? '수집 중…' : '공공데이터 수집'}</Button>}
>
<div className="mb-4 grid gap-3 sm:grid-cols-3">
@ -174,6 +195,7 @@ export function LocalContentPage() {
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<h2 className="text-sm font-semibold">{item.title}</h2>
<Badge variant={TYPE_BADGE_VARIANT[item.contentType]}>{TYPE_LABEL[item.contentType]}</Badge>
<Badge variant={item.status === 2 ? 'success' : 'warning'}>{STATUS_LABEL[item.status]}</Badge>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">

View File

@ -24,7 +24,7 @@ const CATEGORY_LABEL: Record<number, string> = {
[PlaceCategory.LODGING]: '숙박',
[PlaceCategory.CAFE]: '카페',
[PlaceCategory.RESTAURANT]: '음식점',
[PlaceCategory.TOUR_ACTIVITY]: '관광체험',
[PlaceCategory.CLINIC]: '피부과·성형외과',
};
const STATUS_LABEL: Record<number, string> = {

View File

@ -0,0 +1,169 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
import {RefreshCw, X} from 'lucide-react';
import {PageContainer} from '@/components/layout/AppShell';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card, CardContent} from '@/components/ui/card';
import {customFetch} from '@/api/mutator/custom-fetch';
import {toast} from 'sonner';
/**
* . **** ( ).
*
* (2026-09-16 : "그냥 뜨게 하지").
* , . .
*/
type Review = {
review_id: string;
place_id: string;
place_name: string;
body: string;
nickname: string;
status: number;
created_at: string;
};
const STATUS_TABS: {value: number; label: string}[] = [
{value: 2, label: '게재된 후기'},
{value: 3, label: '내린 글'},
];
export function ReviewModerationPage() {
const [status, setStatus] = useState(2);
const [posts, setPosts] = useState<Review[]>([]);
const [picked, setPicked] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await customFetch<{items: Review[]}>({
url: `/v1/admin/review/list?status=${status}&limit=200`,
method: 'GET',
});
setPosts(res.items ?? []);
setPicked(new Set());
} catch {
toast.error('목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}, [status]);
useEffect(() => {
void load();
}, [load]);
const byPlace = useMemo(() => {
const map = new Map<string, Review[]>();
for (const post of posts) {
const key = post.place_name || post.place_id;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(post);
}
return [...map.entries()];
}, [posts]);
const toggle = (id: string) => {
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const decide = async (approve: boolean) => {
const ids = [...picked];
if (ids.length === 0) return;
try {
await customFetch({url: '/v1/admin/review/decide', method: 'POST', data: {review_ids: ids, publish: approve}});
toast.success(approve ? `${ids.length}건을 다시 올렸습니다.` : `${ids.length}건을 내렸습니다.`);
await load();
} catch {
toast.error('처리하지 못했습니다.');
}
};
return (
<PageContainer
title="이용 후기"
description="손님이 남기면 바로 사이트에 올라갑니다. 문제가 있는 글만 여기서 내립니다."
actions={
<Button variant="outline" onClick={() => void load()} disabled={loading}>
<RefreshCw className="size-4" />
</Button>
}
>
<div className="mb-4 flex flex-wrap gap-1.5">
{STATUS_TABS.map((tab) => (
<Button
key={tab.value}
size="sm"
variant={tab.value === status ? 'primary' : 'outline'}
onClick={() => setStatus(tab.value)}
>
{tab.label}
</Button>
))}
</div>
{status === 2 && (
<div className="mb-4 flex flex-wrap items-center gap-2">
<span className="text-muted-foreground text-sm">{picked.size} </span>
<Button size="sm" variant="outline" onClick={() => void decide(false)} disabled={picked.size === 0}>
<X className="size-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setPicked(new Set(posts.map((post) => post.review_id)))}
disabled={posts.length === 0}
>
</Button>
</div>
)}
{posts.length === 0 && !loading && (
<p className="text-muted-foreground py-10 text-center text-sm"> .</p>
)}
<div className="flex flex-col gap-5">
{byPlace.map(([placeName, rows]) => (
<section key={placeName}>
<h2 className="mb-2 text-sm font-bold">
{placeName} <span className="text-muted-foreground font-normal">{rows.length}</span>
</h2>
<div className="flex flex-col gap-2">
{rows.map((post) => (
<Card key={post.review_id}>
<CardContent className="flex items-start gap-3 p-4">
{status === 2 && (
<input
type="checkbox"
id={`review-${post.review_id}`}
checked={picked.has(post.review_id)}
onChange={() => toggle(post.review_id)}
className="mt-1 size-4 shrink-0"
/>
)}
<div className="min-w-0 flex-1">
<div className="mb-1.5 flex flex-wrap items-center gap-2">
<Badge variant="accent">{post.nickname || '손님'}</Badge>
<span className="text-muted-foreground text-xs tabular-nums">{post.body.length}</span>
</div>
<label htmlFor={`review-${post.review_id}`} className="block text-sm leading-relaxed">
{post.body}
</label>
</div>
</CardContent>
</Card>
))}
</div>
</section>
))}
</div>
</PageContainer>
);
}

40
deploy.sh Executable file
View File

@ -0,0 +1,40 @@
#!/usr/bin/env bash
# 서버 변경과 꺼진 admin을 보존한다. 렌더러 배포도 기존 사이트를 재굽지 않는다.
set -euo pipefail
cd "$(dirname "$0")"
PULL=1
ONLY=0
TARGETS=()
for arg in "$@"; do
case "$arg" in
--no-pull) PULL=0 ;;
--only) ONLY=1 ;;
-h|--help) echo "사용법: ./deploy.sh [--no-pull] [--only] [서비스...]"; exit 0 ;;
-*) echo "모르는 옵션: $arg" >&2; exit 2 ;;
*) TARGETS+=("${arg#o2o-web4ai-}") ;;
esac
done
if [ "$PULL" = 1 ]; then
git diff --quiet && git diff --cached --quiet || { echo "서버 변경을 먼저 정리하세요" >&2; exit 1; }
git pull --ff-only origin "${DEPLOY_BRANCH:-main}"
fi
[ ${#TARGETS[@]} -gt 0 ] || TARGETS=(solution-backend solution-worker solution-site)
RUNNING=$(docker compose ps --services --status running)
if [ "$ONLY" = 0 ]; then
case " ${TARGETS[*]} " in
*" solution-backend "*|*" solution-worker "*)
TARGETS+=(solution-backend solution-worker)
if grep -qx admin-backend <<<"$RUNNING"; then TARGETS+=(admin-backend); fi ;;
esac
fi
SERVICES=()
for target in "${TARGETS[@]}"; do
case " ${SERVICES[*]-} " in *" $target "*) ;; *) SERVICES+=("$target");; esac
done
# site의 렌더러도 worker 이미지에 들어간다.
case " ${SERVICES[*]} " in
*" solution-site "*) case " ${SERVICES[*]} " in *" solution-worker "*) ;; *) SERVICES+=(solution-worker);; esac ;;
esac
docker compose build "${SERVICES[@]}"
docker compose up -d --no-deps --force-recreate "${SERVICES[@]}"
docker compose ps

View File

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

View File

@ -5,15 +5,15 @@ name: o2o-web4ai
# DB 는 compose 밖이다(호스트 PostgreSQL).
# ★ 컨테이너 이름이 negosium-db 인 건 베낀 흔적이 아니다 — web4ai 는 DB 인스턴스를 따로 띄우지
# 않고, negosium 이 쓰는 postgres(5432) 안에 web4ai_db 라는 database 만 새로 만들어 쓴다
# (DECISIONS.md 3절). 그래서 아래 명령의 컨테이너 이름이 negosium-db 다.
# 최초 1회:
# docker exec -i -e PGPASSWORD=password negosium-db \
# (DECISIONS.md 3절). 그래서 아래 명령의 컨테이너 이름이 우리 것이 아니다.
# 최초 1회 — 컨테이너 이름은 환경마다 다르다(로컬 negosium-db · 킹서버 king_postgres_container):
# docker exec -i -e PGPASSWORD=password <postgres 컨테이너> \
# psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 < postgres-init/init-data/init.sql
x-common-env: &common-env
APP_ENV: local
PYTHONUNBUFFERED: "1"
# 이미지 안 config.local.toml 은 플레이스홀더다 — 아래 값이 없으면 접속·서명이 실패한다.
# 설정 파일은 없다 — 값은 전부 여기(=최상위 .env)서 온다. 비우면 config_models 의 기본값이다.
DB_HOST: ${DB_HOST:-host.docker.internal}
DB_PORT: ${DB_PORT:-5432}
DB_USER: ${DB_USER:-postgres}
@ -21,20 +21,29 @@ x-common-env: &common-env
DB_NAME: ${DB_NAME:-web4ai_db}
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:-}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-}
# ★ 프론트(VITE_GOOGLE_CLIENT_ID)와 같은 값이어야 한다 — 백엔드는 이 값으로 구글 토큰의
# 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
# ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다.
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr}
SITE_PAYLOAD_DIR: /app/out/payloads
SITE_OUTPUT_DIR: /app/out/sites
# ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
# 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-localhost}
# ★ 렌더러(solution/site/scripts/prerender.ts)가 songs·out 디렉토리를 **자기 파일 위치
# 기준 상대경로**로 찾는다(SITE_ROOT = dist/prerender/../..) — 워커 컨테이너 안에서 그
# 렌더러를 직접 띄우므로(render_service.py) 세 디렉토리가 실제 설치 자리
# (/app/solution/site/) 아래 나란히 있어야 한다. 아래 볼륨 마운트와 짝이 맞아야 한다.
SITE_PAYLOAD_DIR: /app/solution/site/payloads
SITE_OUTPUT_DIR: /app/solution/site/out
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
services:
api:
solution-backend:
build:
context: .
dockerfile: solution/backend/Dockerfile
image: o2o-web4ai-backend
container_name: o2o-web4ai-api
container_name: o2o-web4ai-solution-backend
command: ["python", "web_main.py"]
env_file:
- .env
@ -42,10 +51,26 @@ services:
<<: *common-env
# ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다.
SCHEDULER_ENABLED: "1"
# ★ GSC_CREDENTIALS_HOST_FILE(호스트 경로, .env)을 아래 볼륨으로 이 컨테이너 안에 마운트한
# 고정 자리다. search_console_settings.load_settings() 가 실제로 읽는 건 이 값이다 —
# 호스트 경로를 코드에 그대로 넘기면 컨테이너 안에서 그 경로가 없어 실패한다.
# 실측(2026-09-18): 이 줄이 없어서 GSC_ENABLED=1 인데도 10분마다
# ValueError(GSC_CONFIG_MISSING) 로 조용히 실패하고 있었다 — 로그엔 BATCH_FAILED 만 남아
# 원인이 안 보였다.
GSC_CREDENTIALS_FILE: /app/secrets/gsc-credentials.json
volumes:
- ./solution/site/payloads:/app/out/payloads
- ./solution/site/payloads:/app/solution/site/payloads
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 워커가 렌더러를
# 돌릴 때 사이트 디렉토리로 복사된다(services/song_service · site/scripts/prerender.ts).
- ./solution/site/songs:/app/solution/site/songs
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
- ./postgres-init:/app/postgres-init:ro
# ★ GSC_CREDENTIALS_HOST_FILE 이 비어 있으면 /dev/null 을 마운트한다 — 빈 문자열을 그대로
# 쓰면 컴포즈 볼륨 문법이 깨진다. GSC_ENABLED=0 이면 이 파일은 아예 안 읽으므로 무해하다.
- ${GSC_CREDENTIALS_HOST_FILE:-/dev/null}:/app/secrets/gsc-credentials.json:ro
ports:
- "${API_BIND:-0.0.0.0}:9800:9800"
- "${API_BIND:-0.0.0.0}:${API_PORT:-9800}:9800"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
@ -53,12 +78,17 @@ services:
driver: json-file
options: { max-size: "10m", max-file: "5" }
worker:
# ★ BUILD·ROLLBACK 잡이 렌더러(solution/site)를 subprocess 로 직접 돌린다
# (services/render_service.py) — 예전에 solution-prerender 컨테이너가 하던 일이다.
# 그래서 이 서비스만 **다른 이미지**(Dockerfile.worker, Node 런타임 + 컴파일된 렌더러
# 포함)를 쓴다. solution-backend·admin-backend 는 그 Node 를 쓸 일이 없다.
solution-worker:
build:
context: .
dockerfile: solution/backend/Dockerfile
image: o2o-web4ai-backend
command: ["python", "worker_main.py"]
dockerfile: solution/backend/Dockerfile.worker
image: o2o-web4ai-worker
container_name: o2o-web4ai-solution-worker
command: ["sh", "-c", "node /app/solution/site/dist/prerender/prerender.js --seed-assets --out=/app/solution/site/out && exec python worker_main.py"]
env_file:
- .env
environment:
@ -67,28 +97,39 @@ services:
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1}
JOB_DEADLINE_SEC: ${JOB_DEADLINE_SEC:-900}
JOB_LEASE_SEC: ${JOB_LEASE_SEC:-120}
# 렌더러 subprocess 가 굽는 동안 기다리는 시간(사진 내려받기 포함). BUILD 의
# job_deadline_sec 보다 짧아야 한다 — 안 그러면 잡 전체가 먼저 타임아웃된다.
RENDER_TIMEOUT_SEC: ${RENDER_TIMEOUT_SEC:-180}
# 이미지의 HEALTHCHECK 는 API 용(HTTP :9800)이다. 워커는 포트가 없어 그대로 두면 늘 unhealthy 다.
healthcheck:
disable: true
volumes:
- ./solution/site/payloads:/app/out/payloads
- site-out:/app/out/sites:ro
- ./solution/site/payloads:/app/solution/site/payloads
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 렌더러가
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
- ./solution/site/songs:/app/solution/site/songs
# ★ 이제 이 컨테이너가 굽는 쪽이다 — 읽기전용이 아니다(예전 solution-prerender 가
# 쓰던 자리를 그대로 이어받는다).
- site-out:/app/solution/site/out
extra_hosts:
- "host.docker.internal:host-gateway"
stop_grace_period: 300s
depends_on:
- api
- solution-backend
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 내부 운영 API(:9801). 진입점은 admin/backend/{main,app}.py 이고 도메인 코드는
# 어드민 API(:9801). 진입점은 admin/backend/{main,app}.py 이고 도메인 코드는
# solution/backend 것을 PYTHONPATH 로 그대로 쓴다 — place·fact 를 두 번 구현하지 않으면서
# 프로세스와 포트만 가른다. 여기 붙는 모든 엔드포인트는 role >= DEVELOPER 다.
api-admin:
admin-backend:
image: o2o-web4ai-backend
container_name: o2o-web4ai-api-admin
container_name: o2o-web4ai-admin-backend
# ★ 기본으로 뜨지 않는다. 어드민 화면을 안 쓰는 동안은 이 API 도 부를 사람이 없다.
# 켤 때: docker compose --profile admin up -d
profiles: ["admin"]
working_dir: /app/admin/backend
command: ["python", "main.py"]
env_file:
@ -106,66 +147,72 @@ services:
start_period: 20s
retries: 3
volumes:
- ./solution/site/payloads:/app/out/payloads
- ./solution/site/payloads:/app/solution/site/payloads
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
- ./solution/site/songs:/app/solution/site/songs
ports:
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
- "${ADMIN_API_BIND:-127.0.0.1}:9801:9801"
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- api
- solution-backend
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 사장님 앱(:3000) + 발행 사이트 프리렌더·정적서버(:3001, `/s/*` 프록시)
web:
# 사장님 앱 dev 서버 + 발행본 정적서버(:3001). **로컬 전용**이다 — `--profile dev`.
# 운영에서 이게 뜨면 안 된다(위 solution-prerender 주석).
solution-frontend:
image: node:24-alpine
container_name: o2o-web4ai-web
container_name: o2o-web4ai-solution-frontend
profiles: ["dev"]
working_dir: /app
command:
- sh
- -c
- |
cd /app
# ★ `-d node_modules` 로 판단하면 안 된다. 익명 볼륨은 빈 디렉토리로 이미 존재해서
# 설치를 건너뛰고 `vite: not found`(exit 127)로 죽는다.
[ -x node_modules/.bin/vite ] || npm install
node solution/site/scripts/serve-sites.mjs &
node solution/site/scripts/watch-payloads.mjs &
exec npm run dev -w @o2o/frontend
environment:
PORT: 3001
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
# ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를
# 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr}
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
# ★ 이 둘은 **브라우저가** 부르는 주소다. 컨테이너 안에서 보는 주소가 아니라
# 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다.
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
# 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처).
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
volumes:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
- ./tsconfig.base.json:/app/tsconfig.base.json
- ./solution:/app/solution
- ./admin:/app/admin
# ★ node_modules 는 컨테이너 것을 쓴다. 호스트가 macOS(arm64-darwin)라 그 안의
# rollup·esbuild 네이티브 바이너리를 리눅스 컨테이너가 못 쓴다.
- /app/node_modules
- /app/solution/site/node_modules
- /app/solution/frontend/node_modules
- /app/admin/frontend/node_modules
# ★ 산출물은 named volume. 호스트 경로면 재배포로 코드를 갈아엎는 순간 전 사이트가 404 다.
- site-out:/app/solution/site/out
ports:
- "3000:3000"
- "${WEB_BIND:-0.0.0.0}:${WEB_PORT:-3000}:3000"
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 내부 운영 앱(:3002). 사장님 번들과 갈라 두는 것이 이 서비스의 존재 이유다.
admin:
admin-frontend:
image: node:24-alpine
container_name: o2o-web4ai-admin
container_name: o2o-web4ai-admin-frontend
profiles: ["admin"]
working_dir: /app
command:
- sh
@ -175,8 +222,11 @@ services:
[ -x node_modules/.bin/vite ] || npm install
exec npm run dev -w @o2o/admin
environment:
# ★ 내부 API(:9801)를 본다. :9800 을 보면 API 를 가른 의미가 없다.
# ★ 어드민 API(:9801)를 본다. :9800 을 보면 API 를 가른 의미가 없다.
VITE_API_BASE_URL: ${ADMIN_API_BASE_URL:-http://localhost:9801}
# 사장님 앱은 다른 오리진이라 절대 URL 로 연다(admin/frontend/src/lib/solutionUrl.ts).
# 이걸 안 넘기면 "빌더 열기" 가 서버에서 localhost:3000 을 가리켜 죽은 링크가 된다.
VITE_SOLUTION_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
volumes:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
@ -189,27 +239,101 @@ services:
- /app/admin/frontend/node_modules
ports:
# ★ 운영에서는 내부망에만 연다. 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- "${ADMIN_BIND:-127.0.0.1}:3002:3002"
depends_on:
- web
- "${ADMIN_BIND:-127.0.0.1}:${ADMIN_PORT:-3002}:3002"
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 발행 사이트 정적 서빙. 굽는 쪽(web)과 분리 — 볼륨 하나를 사이에 두고 서로를 모른다.
nginx:
image: nginx:alpine
container_name: o2o-web4ai-nginx
# 공개 진입점 하나. 사장님 앱(구운 번들) · 발행 사이트(볼륨) · API 프록시를 한 오리진으로 준다.
solution-site:
build:
context: .
dockerfile: nginx/Dockerfile
args:
# ★ VITE_* 는 **번들에 구워진다.** .env 를 고쳐도 재빌드 전엔 안 바뀐다
# → 주소를 바꿨으면 `./deploy.sh solution-site`.
# ★ 기본값이 앱과 **같은 오리진**이다. nginx 가 /v1 을 프록시하므로 :9800 을 박으면
# 스스로 크로스 오리진을 만들어 로그인만 조용히 실패한다(위 주석 · .env.example).
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost}
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
# 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다.
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
# ★ VITE_AUTO_LOGIN_ID·PW 는 여기 없다 — nginx/Dockerfile 이 그 ARG 를 아예 안 받는다.
# 자동 로그인이 필요하면 solution-frontend(--profile dev)를 쓴다.
image: o2o-web4ai-solution-site
container_name: o2o-web4ai-solution-site
volumes:
- site-out:/srv/sites:ro
# ★ 클론 직후: cp nginx/site.conf.example nginx/site.conf
# 파일이 없으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- ./nginx/site.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "80:80"
# ★ :80 이 비어 있다는 보장이 없다(킹서버는 호스트 nginx 가 물고 있다).
# 앞단 프록시를 세울 거면 여기서 포트만 옮기고 프록시가 이쪽을 가리키게 한다.
- "${SITE_HTTP_BIND:-0.0.0.0}:${SITE_HTTP_PORT:-80}:80"
depends_on:
- web
- solution-backend
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# ── 온톨로지(o2o-site-ontology) — 발행본 메타 키워드·제목 업종어 ──────────────
# ★ 자체 DB 가 따로 있다. 호스트 postgres 를 같이 쓰지 않는 이유는 pgvector 확장이 필요해서다.
# 우리 DB 에 확장을 걸면 web4ai_db 가 그 확장에 묶인다 — 남의 서비스 사정을 우리 DB 가 떠안는다.
ontology-postgres:
image: pgvector/pgvector:pg16
container_name: o2o-web4ai-ontology-postgres
environment:
POSTGRES_USER: ontology
POSTGRES_PASSWORD: ontology
POSTGRES_DB: ontology
volumes:
- ontology-pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ontology -d ontology']
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
ontology-redis:
image: redis:7-alpine
container_name: o2o-web4ai-ontology-redis
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
ontology:
build:
context: ./ontology
container_name: o2o-web4ai-ontology
environment:
PORT: "3100"
# 컨테이너끼리는 서비스 이름으로 만난다 — 호스트 포트(55432·56379)는 사람이 들여다볼 때만 쓴다.
DATABASE_URL: postgres://ontology:ontology@ontology-postgres:5432/ontology
REDIS_HOST: ontology-redis
REDIS_PORT: "6379"
# API 키 없이 도는 기본값(로컬 임베딩 + mock LLM). 키를 쓰려면 .env 에서 덮어쓴다.
EMBEDDING_PROVIDER: ${ONTOLOGY_EMBEDDING_PROVIDER:-local}
EMBEDDING_LOCAL_MODEL: ${ONTOLOGY_EMBEDDING_MODEL:-Xenova/multilingual-e5-small}
LLM_PROVIDER: ${ONTOLOGY_LLM_PROVIDER:-mock}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
volumes:
# 임베딩 모델 캐시. 볼륨이 없으면 컨테이너를 새로 만들 때마다 120MB 를 다시 받는다.
- ontology-model:/app/.cache
ports:
- "127.0.0.1:3100:3100"
depends_on:
ontology-postgres:
condition: service_healthy
ontology-redis:
condition: service_healthy
restart: unless-stopped
logging:
driver: json-file
@ -218,3 +342,6 @@ services:
volumes:
# ★ `down -v` 만 지운다. 재생성물이라 백업 대상이 아니다 — 날아가도 payload 로 다시 굽는다.
site-out:
# 온톨로지 DB 와 임베딩 모델 캐시. 모델 캐시가 날아가면 첫 요청이 120MB 를 다시 받는다.
ontology-pgdata:
ontology-model:

273
docs/AGENT.md Normal file
View File

@ -0,0 +1,273 @@
# 사장님 에이전트 — 신원 연결 · 도구 · 런타임
사장님이 말로 사이트를 운영하는 것이 목표다 — 내용 고치기, 사진 내리기, 발행, SNS 게재까지.
**에이전트는 카카오톡 안에 있지 않다.** 카톡은 입구 하나이고, 같은 에이전트가 빌더 화면에도
붙는다. 그래야 채널·챗봇 심사 전에 전부 검증된다.
1단계(신원 연결) · 2단계(도구·런타임·빌더 채팅창) · **4단계(카카오 웹훅)** 을 만들었다.
남은 것은 오픈빌더 챗봇 등록(우리가 못 하는 일)과 도구 늘리기다.
## 화면 스위치
| 화면 | 여는 조건 | 지금 |
|---|---|---|
| 대화창(`AgentChatDock`) | `AGENT_CHAT_ENABLED=1`(기본) **그리고** LLM 키 | 열림 |
| 연결 카드(`KakaoChannelCard`) | `KAKAO_CHANNEL_PUBLIC_ID` 가 채워짐 | 채널 ID 미설정 |
★ 스위치와 키를 **둘 다** 본다(`runtime.is_configured`). 키만 보면 "잠시 닫아 두기" 를 키를
지워서 해야 하고 그러면 소개문·사진분류까지 같이 꺼진다. 스위치만 보면 키 없는 환경에서
**눌러도 안 되는 입구**가 생긴다.
★ 2026-09-21 에 카카오 채널 개설이 법인폰 본인인증에 걸려 한 번 닫았고,
인증이 끝나 2026-09-22 에 다시 열었다. **그때도 코드는 지우지 않고 값만 바꿨다**
닫고 여는 일이 커밋을 되짚는 일이 되면 안 된다.
★ 연결 카드를 '감추는' 쪽으로 둔 것은 Threads 카드('자리는 두고 버튼만 죽인다')와 반대
판단인데 의도한 차이다. 저쪽은 사장님이 곧 쓸 수 있는 기능이라 존재를 알려야 했고,
이쪽은 웹훅(4단계)이 없어 아직 연결이 **완성되지 않는다**.
## 왜 신원 연결이 먼저인가
카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**다. 우리 `user_id` 와 아무 관계가 없다.
이 레포의 모든 엔드포인트는 `place_crud.get_place(s, owner_user_id, place_id)`
"없는 것과 남의 것을 똑같이 `PLACE_NOT_FOUND` 로 답하는" 관례를 지킨다. 채널에서 온 발화에는
`owner_user_id` 를 줄 근거가 없다 — **연결 절차가 없으면 채널 진입점만 소유자 범위 밖에
놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.**
## 절차 — 사장님은 두 번 누른다
1. `/sites` **내 사이트** 화면의 `카카오톡으로 관리 · 채널 연결` 카드 → **[카카오톡 연결]**
2. 화면에 뜬 6자리 코드를 카카오톡 채널에 보낸다
**연결 버튼을 사업장 화면에 두지 않는다.** 연결은 `user` 단위인데 버튼이 사업장 안에 있으면
사장님은 업장마다 연결해야 하는 줄 안다(`SocialConnectionCard` 가 같은 이유로 거기 있다).
`KAKAO_CHANNEL_PUBLIC_ID` 가 비면 **카드는 그리되 버튼이 죽는다.** 어디에 코드를 칠지
말해 줄 수 없는데 코드만 발급하면 사장님에게는 고장난 화면이다. 숨기지는 않는다 — 숨기면
기능이 없는 것처럼 보인다(2026-09-14 Threads 카드에서 실제로 겪었다).
## 표 — `owner_kakao_links` (마이그레이션 0021)
`user_id · channel_user_key · code_sha · code_expires_at · code_attempts · status · linked_at · last_seen_at`
| 인덱스 | 무엇을 막나 |
|---|---|
| `uq_kakao_link_user` (PENDING·LINKED) | 한 사장님에 활성 연결 하나. 다시 눌러도 행이 늘지 않고 코드만 바뀐다 |
| `uq_kakao_link_channel_key` (LINKED) | ★ 한 카카오 계정은 한 사장님에만. 없으면 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다 |
| `uq_kakao_link_code` (PENDING) | 코드 한 행 지목 |
**코드는 평문으로 저장하지 않는다**(`code_sha`). 사장님이 손으로 치는 짧은 값이라, 평문이면
DB 를 읽을 수 있는 쪽이 곧 연결 권한을 갖는다. 그래서 **화면에 한 번 뜨고 다시 볼 수 없다**
카드는 항상 [코드 다시 받기] 를 함께 둔다.
**코드 글자에서 `0·O·1·I·L` 을 뺐다.** 잘못 읽어 실패하면 원인이 화면에 안 보이고
"연결이 안 된다" 로만 보인다.
## 일회성은 값이 아니라 CAS 가 보장한다
```sql
UPDATE owner_kakao_links
SET status='LINKED', channel_user_key=:key, linked_at=now(), code_sha=NULL
WHERE code_sha=:sha AND deleted=false AND status='PENDING'
AND code_expires_at > now() AND code_attempts < :max
RETURNING user_id;
```
조회 후 갱신으로 나누면 같은 코드가 두 번 먹는다(승인 흐름이 같은 이유로 한 문장이다).
**실패는 전부 같은 에러다**(`KAKAO_LINK_CODE_INVALID`). "없는 코드"·"만료"·"시도 초과" 를
구분해 답하면 6자리 코드의 유효성을 외부에서 탐색할 수 있다.
## 코드는 웹훅에서만 소비된다
`redeem()`**공개 라우터에 붙어 있지 않다.** 시크릿 검증을 통과한 웹훅 안에서만 불린다 —
검증 없는 공개 소비 경로가 있으면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다.
## API
| 메서드/경로 | 역할 |
|---|---|
| `GET /v1/agent/kakao/link` | 연결 상태. ★ 코드 평문은 주지 않는다 |
| `POST /v1/agent/kakao/link/code` | 일회용 코드 발급. 평문은 이 응답에서 한 번만 |
| `POST /v1/agent/kakao/link/disconnect` | 해제. 행은 `REVOKED` 로 남긴다 |
셋 다 `Cache-Control: no-store` · `Referrer-Policy: no-referrer` · `X-Robots-Tag: noindex` 다.
## 설정
```
KAKAO_CHANNEL_PUBLIC_ID= # 비면 연결 기능이 꺼진다(카드는 보이고 버튼만 죽는다)
KAKAO_LINK_CODE_TTL_MIN=10
KAKAO_LINK_MAX_ATTEMPTS=5
```
`config/agent_config.py``social_config.py`**일부러 갈랐다.** SNS 게재는 되돌릴 수 없는
대외 발화이고, 에이전트는 사장님이 자기 사이트를 고치는 창구다. 한 파일에 섞이면
"이 값이 무엇을 여는가" 가 흐려진다.
---
# 2단계 — 도구 · 런타임 · 빌더 채팅창
`/sites` 화면 오른쪽 아래 **[말로 고치기]** 를 누르면 대화창이 열린다.
카카오 심사 없이 **에이전트 전체가 여기서 검증된다.**
## 겹
```
router/v1/agent/chat.py 빌더 화면 입구
router/v1/social/kakao_bot.py (4단계) 카톡 입구 — 같은 runtime.chat() 을 부른다
services/agent/runtime.py 발화 → 도구 선택 → 실행 → 응답. ★ 채널을 모른다
services/agent/tools.py 레지스트리 — 할 수 있는 일의 전부 + 등급
services/fact_service.py · site_service.py ★ 게이트가 사는 곳
```
`services/prompts/agent.py` 가 "무엇을 묻는가" 를 갖는다(LLM 네 겹 규약, `services/llm/__init__.py`).
## 도구와 등급
| 등급 | 도구 | 대화에서 |
|---|---|---|
| `READ` | `get_site_status` · `list_facts` | 바로 답한다 |
| `REVERSIBLE` | `set_fact` | 실행하고 알린다 |
| `SEMI` | `publish` | **실행 전에 한 번 묻는다** |
**등급은 레지스트리가 못 박는다.** 모델이 정하게 두면 프롬프트에 끼어든 한 줄이 확인
절차를 건너뛴다. 그래서 응답 스키마에 등급 칸 자체가 없고, 도구 목록에도 등급을 싣지 않는다.
**결과 문구는 도구가 만든다.** LLM 이 쓰게 두면 **하지 않은 일을 했다고 말할 수 있고**,
사장님에게는 그 말이 사실로 보인다. 모델 문장은 '되묻기' 에만 쓴다.
**값을 고치면 재발행 안내를 함께 낸다.** fact 는 바뀌어도 사이트는 안 바뀐다 —
이 한 줄이 빠지면 사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다.
**모호하면 실행하지 않고 되묻는다.** 티오더가 "유사한 메뉴가 2개 이상이면 후보 목록을 제시"
로 푼 문제와 같다 — 추측으로 고르면 사장님이 그걸 못 알아채고 넘어간다.
## 확인(SEMI) 한 바퀴
1. 발화 → 런타임이 `publish` 를 고른다 → **실행하지 않고** `needs_confirm=true` + 확인 문구
2. 화면이 [네, 해주세요] 를 띄운다
3. 누르면 `{confirm:{tool,args}}` 로 다시 POST → LLM 을 부르지 않고 그 도구를 실행
★ 서버는 돌아온 값을 **믿지 않는다.** 도구 이름은 레지스트리에서 다시 찾고, 인자는 도구가
다시 검증한다. 확인 절차가 오히려 검증을 건너뛰는 구멍이 되면 안 된다.
`READ` 등급은 확인 경로로 들어올 수 없다(`AGENT_UNKNOWN_TOOL`).
## API
| 메서드/경로 | 역할 |
|---|---|
| `GET /v1/agent/status` | 대화창을 열 수 있는지(LLM 키 유무) |
| `POST /v1/agent/chat/{place_id}` | `{message}` 또는 `{confirm:{tool,args}}` |
소유자 범위는 다른 엔드포인트와 같다 — 남의 `place_id` 는 **없는 것과 똑같이**
`PLACE_NOT_FOUND` 다. 대화창이 소유자 스코프를 우회하는 유일한 입구가 되면 안 된다.
## 다음 단계
| | 내용 | 심사 |
|---|---|---|
| 3 | 도구를 더 연다 — 사진 내리기 · 섹션 켜고 끄기 · 검색 노출 조회 | 없음 |
| 4 | 카카오 채널 웹훅을 **입구로 추가**(서명 검증 + `redeem` 연결) | 채널 + 챗봇 |
★ 도구를 늘릴 때도 **반드시 `services/*` 를 통과한다.** `crud` 를 직접 부르면 업종 스키마
검증·출처 필수·정정본 보호가 **아무 증상 없이** 사라진다.
`collect_service.store_facts` 가 크롤러에 걸어 둔 문과 같은 문이고,
`tests/test_agent_runtime.py` 가 소스에서 그 호출이 없는지 실제로 검사한다.
---
# 4단계 — 카카오 채널 웹훅
```
router/v1/agent/kakao_bot.py 시크릿 검증 · 카카오 형식 ↔ 우리 모양 ← 카카오를 아는 유일한 파일
services/agent/channel.py 신원 · 가게 고르기 · 확인 이어받기 ← 카카오를 모른다
services/agent/runtime.py 그대로 — 채널을 모른다
```
## ★★ 인증 — 오픈빌더는 서명을 주지 않는다
URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, `userRequest.user.id` 를 아무 값이나 넣으면
**그 사장님 행세를 한다.** 신원 연결이 통째로 무의미해지는 자리다.
| 겹 | 방법 |
|---|---|
| 1 | 공유 시크릿 — 헤더 `X-Agent-Secret` (`hmac.compare_digest`) |
| 2 | `KAKAO_BOT_ID` 대조 (시크릿이 아니라 오발송을 거르는 용도, 비워도 됨) |
| 3 | 헤더를 못 넣을 때만 경로 시크릿 `/webhook/{secret}`**최후 수단**, 경로는 로그에 남는다 |
`KAKAO_WEBHOOK_SECRET` 이 비면 **엔드포인트가 404 다.** 401 로 답하면 "여기 뭔가 있다" 를
알려 준다. 반쯤 열린 상태를 만들지 않는 것은 Threads 연결과 같은 규칙이다.
## 빌더 화면과 다른 것 셋
| | 빌더 화면 | 카카오톡 |
|---|---|---|
| 신원 | 로그인 토큰 | 연결된 발화자 키 → `user_id` (★ **토큰을 발급하지 않는다**) |
| 대상 | `place_id` 가 URL 에 | 대화에서 고르고 `current_place_id` 에 기억 |
| 확인 | 프론트가 `{confirm}` 을 되돌려 줌 | **서버가 무엇을 물었는지 들고 있는다** |
**연결되자마자 홈페이지 목록을 보여준다.** 연결만 알리고 끝내면 사장님은 어느 홈페이지를
다루는 대화인지 모른 채 말을 걸게 된다. 목록에는 **발행 여부**를 같이 적는다 — 안 그러면
고친 것이 손님에게 보이는 줄 안다.
★ 가게가 여럿이면 **바로가기 버튼으로 고르게 한다.** 이름을 외워 치게 하지 않는다.
임의로 첫 가게를 고르지도 않는다 — 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다.
"목록"·"가게 바꿔줘" 같은 말로 **언제든 돌아와 바꿀 수 있고**, 이 경로는 LLM 을 부르지 않는다
(대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록 보기에 돈을 쓸 이유도 없다).
`pending_expires_at`(3분)이 없으면 **한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.**
다른 말을 하면 그 말이 우선이고, 묵은 확인은 그 자리에서 치운다.
**바로가기 라벨과 '예' 로 읽는 말이 같아야 한다**(`CONFIRM_LABEL` 등 상수). 어긋나면
눌러도 안 먹고, 사장님은 버튼이 고장난 줄 안다.
## 5초 벽 — 콜백으로 넘는다
오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 **말없이 실패하는 봇**이 된다.
**실측(2026-09-22): 실제 프롬프트는 4초를 넘겼다.** 개발 중 잰 1.3~2.4초는 항목 두 개짜리
장난감 프롬프트였고, 진짜는 업종 필드 43개 + fact 수십 개가 실린다. 작은 표본으로 잰 수치를
상한 근거로 삼으면 이렇게 틀린다.
→ 오픈빌더 스킬 설정에서 **콜백 사용**을 켜면 요청에 `userRequest.callbackUrl` 이 실려 온다.
```
카카오 → 우리 발화 + callbackUrl
우리 → 카카오 {"version":"2.0","useCallback":true,"data":{"text":"확인하고 있어요…"}} (즉답)
… 백그라운드에서 답을 만든다 (상한 45초)
우리 → 카카오 POST callbackUrl {"version":"2.0","template":{…}} (완성분)
```
★ 콜백 주소는 **1분 · 1회**만 유효하다. 전송에 실패해도 **재시도하지 않는다** — 두 번째 POST 는
어차피 거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다.
★ 콜백이 꺼져 있으면 예전처럼 동기로 답하되 `DEADLINE_SEC = 4.5` 로 끊는다.
무거운 잡(BUILD)은 큐에 넣고 즉답하는 구조라 어느 쪽에서도 걸리지 않는다.
★ 어떤 실패도 **HTTP 200 + 안내 문구**로 답한다. 메신저에서는 500 도 침묵으로 보인다.
## 설정
```
KAKAO_WEBHOOK_SECRET= # 비면 웹훅이 404. python -c "import secrets; print(secrets.token_urlsafe(32))"
KAKAO_BOT_ID= # 선택
KAKAO_CHANNEL_PUBLIC_ID= # 채워야 연결 카드가 뜬다(채널 검색용 아이디, `_` 로 시작)
```
## 오픈빌더에 등록할 주소
```
https://<발행호스트>/v1/agent/kakao/webhook
```
★ 스킬 설정에서 **커스텀 헤더**를 넣을 수 있으면 `X-Agent-Secret` 을 쓰고, 못 넣으면
`/v1/agent/kakao/webhook/<시크릿>` 을 쓴다.
**채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이다.** 채팅만 켜면 발화가 우리에게
오지 않는다 — 웹훅이 붙는 쪽은 챗봇이고, 봇을 만들어 채널에 연결해야 한다.

61
docs/ALERTS.md Normal file
View File

@ -0,0 +1,61 @@
# 장애 알림 (2026-09-15)
구현: `services/alert_service.py`(적재·재시도·중복 억제) · `services/teams_webhook.py`(전송) ·
`worker/runner.py` · `services/build_service.py` · `services/rollback_service.py`(발생 지점) ·
`scheduler/jobs.py`(발송·큐 정체 스윕). 전용 컨테이너 없음 — 기존 API·워커 프로세스가 한다.
## 무엇을 알리나
| kind | 언제 | dedupe_key |
|---|---|---|
| `job_dead` | 잡이 재시도를 소진해 DEAD | `job_dead:{JobType}:{place_id 또는 job_id}` |
| `build_failed` | BUILD·ROLLBACK 이 **게이트 반려가 아닌** 렌더·인프라 실패로 끝남 | `build_failed:{place_id}` |
| `partial_failure` | 노래 등 곁가지 생성 실패(발행 자체는 계속) | `song_failed:{place_id}` |
| `queue_stuck` | dead-letter 누적·좀비 실행·PENDING 30분 이상 정체 | `queue_health` |
| `recovery` | 위 dedupe_key 가 다음 정상 상태에서 풀릴 때 한 번 | 없음(매번 새 행) |
**게이트 반려는 알리지 않는다.** 사장님이 fact 를 안 채웠거나 고유 콘텐츠가 없어서 막힌 건
운영자가 손댈 일이 아니다 — `build_service._fail(reason, gate=None)` 일 때만 `build_failed`.
## 중복 억제·재시도
`send_alert(kind, title, detail, dedupe_key)` — 같은 dedupe_key 로 "안 풀린"(resolved_at
NULL) 알림이 이미 있으면 새로 만들지 않는다. `resolve_alert(dedupe_key, ...)` 가 그 알림을
풀고 복구 알림을 한 번 보낸다. 실제 전송은 `scheduler.jobs.sweep_alert_outbox`(1분마다) —
실패하면 `crud/job_crud.compute_backoff` 와 같은 백오프로 최대 5회 재시도 후 `FAILED`(소진)로
멈춘다. `TEAMS_WEBHOOK_URL` 이 비어 있으면 적재만 되고 전송은 안 나간다(서버 동작엔 영향 없음).
`detail` 은 저장 **전에** `alert_service._scrub` 이 쿼리스트링 키·Bearer 토큰·`password=` 류·
이메일을 마스킹한다 — 외부 API 예외 메시지가 URL 에 키를 실어 보내는 경우가 있다.
## 설정
```
TEAMS_WEBHOOK_URL= # Teams Workflows 수신 webhook. 비우면 알림이 DB(alert_outbox)에
# 쌓이기만 하고 안 나간다 — 서버는 그대로 뜬다.
ALERT_DEDUPE_WINDOW_MIN=60
```
`GSC_ALERT_WEBHOOK_URL`(search_console_alerts.py)과는 **다른 값**이다 — 색인 감시 전용과
이 잡 큐·발행 알림은 목적이 달라 의도적으로 분리했다(services/teams_webhook.py 머리주석).
## 서버·DB 전체 장애 — 이 알림 체계로는 못 잡는다
`alert_service`·`scheduler`가 도는 프로세스 자체가 죽으면(서버 다운·DB 완전 단절) 이 체계는
자기 장애를 자기가 못 알린다. **외부 감시가 필요하다** — uptime 모니터 등에서 주기적으로
`GET /readyz` 를 찌른다(`router/router.py`). `/healthz` 와 다르다: `/healthz` 는 프로세스
생존만(항상 200), `/readyz`**DB 에 실제로 `SELECT 1` 을 던져** 200/503 을 가른다.
절차:
1. 외부 모니터가 `https://<host>/readyz` 를 1~5분 간격으로 확인한다.
2. 2xx 가 아니거나 타임아웃이면 **그 모니터 자신의 채널**로 알린다 — 이 레포의
`TEAMS_WEBHOOK_URL` 로 보내면 안 된다(webhook 이 죽은 서버 안에 있을 수 있다).
3. 이 모니터의 실제 설정(어느 서비스·어느 채널)은 이 세션에서 만들지 않았다 — 운영 계정·
외부 서비스 연결은 사용자 승인 후 진행한다.
## 아직 안 한 것 — 운영 미적용
- 실제 Teams Workflows webhook 생성·채널 지정 — mock 테스트만 했다(tests/test_alert_service.py).
- 외부 uptime 모니터 실제 연결(2절 3번).
- 마이그레이션(`0016_alert_outbox.sql`) 서버 적용.
- `alert_outbox` 오래된 SENT/FAILED 행 보관 정책(지금은 무기한 보관 — 운영 부하를 보고 정한다).

View File

@ -79,3 +79,19 @@ DB(`solution/backend/common/database/model/models.py`, 17테이블)에 **API 호
파이프라인을 배치로 돌리기 전에 `api_call_logs`(공급자 · 엔드포인트 · 토큰/호출수 · 추정단가 ·
`place_id` · 시각)를 얹어두면, 이 문서를 손으로 갱신하지 않고 쿼리로 뽑을 수 있다.
## 5. SNS 비용 (2026-09-14)
사용자 결정: X 대신 Threads. [Meta 공식 컬렉션](https://www.postman.com/meta/threads/documentation/dht3nzz/threads-api)에 직접 API 건당 과금·유료 티어가 안내돼 있지 않다. 현재 0원으로 분리 기록하되 영구 무료로 약속하지 않는다.
X는 현재 [URL 포함 생성 $0.20/요청](https://docs.x.com/x-api/getting-started/pricing)을 안내하므로 고정비라는 기존 계획을 폐기했다. X 어댑터는 넣지 않는다.
| 비용 | 처리 |
|---|---|
| 사이트당 변동비 | Gemini 초안, 알림톡 발송. 초안 최대 3회 재요청, 버전당 원고 1건. 알림톡 단가 미확정이라 활성화 전 계약 확인 |
| 계정/계약당 고정비 | 계약에 있다면 별도 운영비. 사이트 생성 CostMeter에 배분하지 않음 |
| 개발비 | 일회성 구현·심사 대응 비용. 사이트 원가와 분리 |
`Provider.THREADS`는 0원, 공개 가격 확정 근거가 없어 confirmed=False로 기록한다.
월 고정비라는 잘못된 근거로 confirmed=True를 넣지 않는다. `assert_rates_confirmed()`에 Threads를 포함하는
실배치는 운영 과금 확인 전 차단된다. 현재 SNS 서비스는 생성 호출 횟수 상한만 강제하며 누적 사이트 예산 연동은
별도 보완이 필요하다. Gemini 비용을 무료로 간주하지 않는다.

View File

@ -1,5 +1,8 @@
# ARCHITECTURE
> 2026-09-15: 현재 발행 실행·산출물 버전·배포는 [PUBLISH_VERSION.md](PUBLISH_VERSION.md).
> 아래의 별도 프리렌더 컨테이너·폴링·전체 재굽기 설명은 이전 구조다.
제품 판단은 [PRODUCT.md](PRODUCT.md), 배포 절차는 [DEPLOY.md](DEPLOY.md),
에이전트가 밟기 쉬운 함정 목록은 [AGENTS.md](../AGENTS.md). 여기는 **구조와 경계**만 다룬다.
@ -13,8 +16,14 @@
```
backend (Python) ──쓴다──▶ out/payloads/<slug>.json ◀──읽는다── prerender (Node)
out/payloads/.status/<slug>.json ──보고──▶
──쓴다──▶ out/songs/<song_id>.mp3 ◀──복사──
```
★ 노래(mp3)도 **같은 약속**을 쓴다(2026-09-11). 백엔드는 발행물 디렉토리를 모른 채 파일을
`out/songs/` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s/<slug>/` 로 복사한다. 백엔드가 발행물
디렉토리에 직접 쓰기 시작하면 이 경계가 무너진다 — 그때부터 두 쪽이 out/ 의 모양을 함께
알아야 한다.
이 경계가 있어서 렌더링을 통째로 갈아엎어도 백엔드는 안 건드린다. 반대도 같다.
**둘을 직접 붙이자는 제안은 이 문서를 근거로 거절한다** — 붙이는 순간 파이썬 프로세스가
React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다.
@ -31,11 +40,14 @@ React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포
```
BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
├ build_snapshot → site_versions 행 insert (원본 데이터, JSONB)
├ build_snapshot → 원본 데이터(JSONB)
├ seo_keywords.fetch() → SiteOntology 추천을 이 가게 자료로 거른 키워드 → snapshot["seo"]
│ (숙박만 · 설정 없거나 실패하면 생략 · 발행은 계속)
│ → site_versions 행 insert
├ 1차 게이트 (DB 사실 기준) → publish_gate.evaluate()
├ site_payload.emit_payload() → out/payloads/<slug>.json ★ 백엔드의 유일한 산출물
│ ┌ [별도 컨테이너 o2o-web4ai-web]
│ ┌ [별도 컨테이너 solution-prerender] ★ solution-frontend 가 아니다(개발용)
│ │ scripts/watch-payloads.mjs — payloads/ 2초 폴링, 바뀐 것만
│ │ └ node dist/prerender/prerender.js --payload=<file>
│ │ → out/s/<slug>/** + out/assets, out/fonts, out/robots.txt …
@ -45,21 +57,32 @@ BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
├ render_report.wait_for() → .status/<slug>.json 폴링
├ 2차 게이트 (**실제 구워진 HTML** 기준: JSON-LD 불일치 · 고유 콘텐츠 수)
├ azure_static.publish(slug) → Azure Blob `$web` (설정됐을 때만 — 3절)
├ site_thumbnail.store(slug, …) → Blob `<prefix>/thumbs/<slug>.<ext>` → sites.thumbnail_url
└ indexnow.submit(slug) → 네이버·Bing·Yandex 통보 (구글 미지원)
```
썸네일은 **스크린샷이 아니라 그 사이트의 대표 사진(og:image)** 이다 — 헤드리스 브라우저는
영구 금지고([DECISIONS 1-1](DECISIONS.md)) 워커·프리렌더 이미지에 Chromium 이 없다.
대표 사진 선정은 `site_payload.primary_media()` 한 곳뿐이라 og:image 와 항상 같은 사진이다.
★ 블롭 경로가 `s/<slug>/` **밖**인 이유: `azure_static._remove_stale_site_files()` 가 매 발행마다
사이트 경로를 프리렌더 산출물로 통째로 교체한다 — 그 안에 두면 다음 발행에서 조용히 사라진다.
게이트가 **두 번** 도는 게 핵심이다. 1차는 DB 의 사실을, 2차는 **정말로 그렇게 구워졌는지**를 본다.
1차만 있으면 "데이터는 맞는데 HTML 은 틀린" 상태를 발행한다.
## 3. 서빙 — 테스트 서버가 정적 파일을 직접 서빙한다
Google 추적은 발행 잡 밖에서 실행한다. 기존 API 스케줄러가 발행 완료 DB를 감지해
사이트맵 제출·색인 조회·알림을 수행하고 `site_search_status`에 저장한다.
선택 설정/인증/재시도 경계는 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md)가 단일 출처다.
**결정 (2026-08-31).** 발행 사이트는 **서버 안에서 nginx 가 정적 파일로 서빙한다.**
Azure Blob 업로드 경로(`azure_static.py`)는 코드에 있고 동작하지만 **지금은 켜지 않는다**
`AZURE_STORAGE_CONNECTION_STRING` 을 비워 두면 발행 잡이 업로드 단계를 건너뛴다.
클라우드는 고도화 시점에 붙인다.
```
프리렌더(o2o-web4ai-web) ──쓴다──▶ named volume `site-out` ◀──읽는다(ro)── nginx(:80)
프리렌더(o2o-web4ai-solution-frontend) ──쓴다──▶ named volume `site-out` ◀──읽는다(ro)── nginx(:80)
```
굽는 쪽과 서빙하는 쪽이 **볼륨 하나를 사이에 두고 서로를 모른다.** 그래서 재배포로 코드를
@ -106,7 +129,7 @@ o2o-web4ai/
│ └─ shared/ frontend·site·백엔드 계약 (SitePayload · slug · 토큰)
├─ admin/ 우리 — 전체 사이트 운영
│ ├─ backend/ 내부 API 진입점(:9801). 도메인 코드는 solution/backend
│ ├─ backend/ 어드민 API 진입점(:9801). 도메인 코드는 solution/backend
│ └─ frontend/ 내부 운영 화면
├─ docs/ nginx/ postgres-init/ docker-compose.yml package.json(워크스페이스 루트)
@ -137,8 +160,11 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
`UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다.
라우트 가드는 화면을 가리지 **번들은 못 가린다.**
★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다.
2. **인증 모델이 갈라진다.** 빌더는 일부러 로그인을 안 세운다("만들어 보기도 전에 막힌다").
내부 화면은 전부 `RequireAuth` 뒤다. 한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
2. **인증 모델이 갈라진다.** 빌더는 위저드를 열어 두고 **에디터 진입에서 한 번** 받는다
("만들어 보기도 전에 막힌다"). 내부 화면은 전부 `RequireAuth` 뒤다. 계정이 생기는 방식도
다르다 — 사장님은 스스로 가입하고 구글로도 들어오지만, 내부 운영 계정은 우리가 만들고
role >= DEVELOPER 여야 한다(`LoginPage` 의 `selfServe` 플래그가 그 차이를 한 곳에서 드러낸다).
한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
→ 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 /
내부: 실패가 곧 차단).
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
@ -147,8 +173,8 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
| | 포트 | 진입점 | 권한 |
|---|---|---|---|
| 사장님 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 |
| 내부 API | **9801** | `admin/backend/main.py``app.py` | **앱 전체 `role >= DEVELOPER`** |
| 솔루션 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 |
| 어드민 API | **9801** | `admin/backend/main.py``app.py` | **앱 전체 `role >= DEVELOPER`** |
`services`·`crud`·`models` 은 공유한다 — `admin/backend` 는 진입점 두 파일뿐이고,
도메인 코드는 `PYTHONPATH=/app/solution/backend` 로 그대로 import 한다. admin 화면이 부르는
@ -176,8 +202,11 @@ USER role=1 → 403 DEVELOPER role=3 → 200
OWNER role=2 → 403
```
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다).
OWNER 가 막히는 게 핵심이다 — 내부 운영 화면을 볼 권한이 아니다.
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도
같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고
`places.owner_user_id` 가 자기 계정인 사업장만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
(2026-09-08 전에는 이 스코프가 회사(테넌트)였다 — DECISIONS.md 2절)
⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner`
@ -222,8 +251,11 @@ admin 자기 파일만 `@admin` 이다.
### 아직 안 한 것
- 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 —
그때 `solution/frontend` 의 인증 정책을 다시 본다.
- 사장님 **"내 사이트 관리"** 화면(내 사업장 목록). 로그인 후 도착지가 아직
`/builder?new=1`(새로 만들기) 하나뿐이다.
- **계정 연결** — 같은 사람의 id/pw 계정과 구글 계정을 잇는 경로. 지금은 잇지 않고
거절한다 → [DECISIONS.md 1-5](DECISIONS.md)
- **비밀번호 재설정** — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다.
- 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을
`127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.**
- **폰트 self-host**`solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로
@ -276,12 +308,12 @@ DB 에 HTML 컬럼은 없다 (`site_versions.snapshot` JSONB 가 원본).
| 컨테이너 | 무엇 | 포트 |
|---|---|---|
| `o2o-web4ai-api` | 사장님 API (`solution/backend/web_main.py`) | 9800 |
| `o2o-web4ai-api-admin` | 내부 API (`admin/backend/main.py`) — 앱 전체 `role >= DEVELOPER` | 9801 (기본 `127.0.0.1`) |
| `o2o-web4ai-worker` | 잡 러너 (BUILD·수집·생성) + 스케줄러 | — |
| `o2o-web4ai-web` | 사장님 빌더 Vite + `watch-payloads` 프리렌더 + `serve-sites`(개발용 :3001) | 3000 |
| `o2o-web4ai-admin` | 내부 운영 화면 Vite | 3002 (기본 `127.0.0.1`) |
| `o2o-web4ai-nginx` | **발행 사이트 정적 서빙**`site-out` 볼륨을 읽기 전용으로 | 80 |
| `o2o-web4ai-solution-backend` | 솔루션 API (`solution/backend/web_main.py`) | 9800 |
| `o2o-web4ai-admin-frontend-backend` | 어드민 API (`admin/backend/main.py`) — 앱 전체 `role >= DEVELOPER` | 9801 (기본 `127.0.0.1`) |
| `o2o-web4ai-solution-worker` | 잡 러너 (BUILD·수집·생성) + 스케줄러 | — |
| `o2o-web4ai-solution-frontend` | 사장님 빌더 Vite + `watch-payloads` 프리렌더 + `serve-sites`(개발용 :3001) | 3000 |
| `o2o-web4ai-admin-frontend` | 내부 운영 화면 Vite | 3002 (기본 `127.0.0.1`) |
| `o2o-web4ai-solution-site` | **발행 사이트 정적 서빙**`site-out` 볼륨을 읽기 전용으로 | 80 |
DB(PostgreSQL)는 **compose 밖**이다 — 호스트에서 돌고 `host.docker.internal` 로 붙는다.
데이터 수명이 컨테이너 수명과 달라야 해서다.

View File

@ -52,7 +52,7 @@
- 검색 대상: 야놀자, 여기어때, 네이버 플레이스 등 허용된 도메인
- 제외 대상: 서비스 홈, 목록, 블로그·카페 후기
Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지 않는다. URL 후보만 `place_links`에 미확정 상태로 저장한다.
Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지 않는다. URL 후보만 `place_channels`에 미확정 상태로 저장한다.
사용자가 `내 채널 맞아요`로 확인한 링크만 크롤링과 사이트 노출에 사용한다.

366
docs/DATA_MODEL.md Normal file
View File

@ -0,0 +1,366 @@
# DATA MODEL — 값이 DB 에서 페이지까지 가는 길
**이 문서 하나만 읽고도 "이 값이 어느 표 어느 칸에 있고, 왜 화면에 나왔거나 안 나왔는지" 를
짚을 수 있어야 한다.**
- 파이프라인의 *마지막 구간*(BUILD 잡 내부)은 [ARCHITECTURE 2절](ARCHITECTURE.md)이 단일 출처다.
여기서는 그 앞뒤를 잇는다.
- 수집이 **무엇을 어디서 가져오는지**는 [COLLECTION_SEO_AEO_FLOW.md](COLLECTION_SEO_AEO_FLOW.md).
- 표를 고치는 절차는 [postgres-init/migrations/README.md](../postgres-init/migrations/README.md).
- Google 제출/색인 관측은 `site_search_status`의 별도 상태다. 발행 상태와 섞지 않는다.
컬럼 의미·조회·설정은 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md).
정의는 두 곳이고 **둘 다 최신이어야 한다** — ORM(`solution/backend/common/database/model/models.py`)
과 DDL(`postgres-init/init-data/init.sql` + `migrations/`). 컬럼 주석은 ORM 이 더 자세하다.
---
## 0. 표 17개, 스키마는 `public` 한 벌
도메인별 스키마(`company`·`place`·`fact`·`local`·`site`·`job`)는 2026-09-09 에 걷어냈다.
스키마 한정자가 붙는 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
소속은 **이름**이 말한다(`place_*` · `site_*` · `area_*`).
```
users 사장님 계정
├ places 사업장 — 모든 것의 스코프 키
│ ├ place_channels 채널 URL(네이버·TourAPI·홈페이지) — 크롤링 대상
│ ├ place_units 객실 · 메뉴 · 프로그램
│ ├ place_photos 사진
│ ├ place_facts ★ 사실. 이 제품의 심장
│ ├ place_faqs FAQ
│ ├ place_songs 이 숙소의 노래 — 발행할 때마다 한 곡(가사 Gemini → 작곡 Suno)
│ ├ place_social_posts SNS 게재 글 — 초안 → 승인 → 게시 (사장님이 누를 때만)
│ └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김)만
├ area_contents ★ 지역 콘텐츠 실체 — 키가 region_code 다(place_id 아님)
└ sites 발행 사이트 — 사업장당 1개
├ site_sections 섹션 콘텐츠(사장님이 넣은 것 · 서버가 채운 것)
├ site_versions ★ 빌드 버전 — snapshot 박제
└ site_publish_logs 발행 시도 기록(반려 사유 포함)
owner_social_accounts 사장님이 연결한 SNS 계정 — ★ 위임받은 토큰을 보관하는 유일한 표
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기 · 노래 · SNS
```
**FK 제약은 걸지 않는다**(관계 컬럼만 둔다). 삭제는 전부 소프트 삭제(`deleted`)이고,
자연키 유니크는 `deleted = false` 부분 인덱스로 건다 — `jobs` 만 예외다(잡은 이력이라 안 지운다).
---
## 1. 한 장으로 보는 흐름
```
[사장님 화면] [백엔드] [산출물]
가게 이름 입력
└ POST /v1/place ─────────→ places 행 생성 (status=DRAFT)
카카오 로컬에서 내 가게 선택
└ POST .../verify ────────→ places.verified_at · latitude/longitude
· region_code · external_category 박제
★ verified_at 이 NULL 이면 이 아래로 못 간다
[수집 시작] ────────────────→ jobs(COLLECT) 적재
worker: services/collect_service.run_collect
├ 네이버 플레이스·TourAPI 직접 해석 → place_channels
├ (선택) Perplexity 로 URL 후보 발견 → place_channels.raw
├ 확정된 URL 만 크롤링 → place_facts · place_photos
└ 하위 단위 자동 생성 → place_units
이어서 jobs(VISION) → place_photos.label/alt_text/status
jobs(COPY) → place_faqs · 소개문 place_facts
jobs(LOCAL_SYNC) → area_contents (지역당 1회)
[에디터]
템플릿 고르기 ─────────────→ sites.template_id
색·서체·섹션 순서/on-off ──→ sites.theme (JSONB)
섹션 내용 편집 ────────────→ site_sections.data (JSONB, 섹션당 1행)
주변정보 숨김·거리 ────────→ place_area_refs.hidden / distance_m
미리보기 ──────────────────→ GET /v1/place/{id}/site/preview
★ 발행과 **같은 함수**로 payload 를 만든다(DB 를 안 건드린다)
[발행하기] ─────────────────→ jobs(BUILD, publish=true)
services/build_service.run_build ── 아래 3절
└ out/payloads/<slug>.json ★ 백엔드의 유일한 산출물
│ (컨테이너 경계)
solution-prerender 컨테이너
scripts/watch-payloads.mjs → prerender.ts
out/s/<slug>/index.html · llms.txt
out/sitemap.xml · robots.txt · /s (목록)
```
**컨테이너 이름을 헷갈리지 않는다.** 굽는 것은 `solution-prerender` 다.
`solution-frontend` 는 개발용(`profiles: ["dev"]`)이라 운영에서 아예 뜨지 않는다 —
`restart solution-frontend` 는 **아무 일도 안 하면서 성공한다.**
---
## 2. 표별 — 무엇을 담나 · 누가 쓰나 · 어디로 나가나
### `places` — 모든 것의 스코프 키
| 칸 | 무엇 | 쓰이는 곳 |
|---|---|---|
| `owner_user_id` | 사장님 계정 | **스코프 키.** 조회는 전부 이 값으로 좁힌다(회사/테넌트를 걷어내고 이 컬럼이 그 자리를 받았다) |
| `category` | 업종 코드 | 업종 스키마 선택(`common/category_schema`) — 어떤 fact key 가 허용되는지, 어떤 섹션을 기본으로 켜는지 |
| `verified_at` | 카카오 로컬 검증 시각 | ★ **NULL 이면 수집도 발행도 금지.** 검증 없이 수집하면 남의 가게가 섞인다 |
| `latitude`/`longitude` | 좌표 | 빌드 시점 TourAPI 반경 조회(주변 맛집·축제·관광지) |
| `region_code` | 행정구역 코드 | ★ **지역 콘텐츠 캐시 키.** 같은 지역에 사이트 50개가 생겨도 외부 조회는 1회 |
| `external_category` | 외부 DB 분류 원문 | 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준 |
| `content_updated_at` | 노출값이 마지막으로 바뀐 시각 | 개별 재빌드 대상 판별 — `site_versions.built_at < content_updated_at` 인 사이트만 다시 굽는다 |
### `place_facts` — 이 제품의 심장
모든 사실은 **값과 함께 출처·수집시각·검증상태**를 갖는다. 출처 없는 사실은 규칙 위반이다.
| 칸 | 무엇 | 쓰이는 곳 |
|---|---|---|
| `key` | 업종 스키마에 정의된 필드 키 | 스키마에 없는 key 는 저장 자체가 거부된다 |
| `value` · `unit` | 값과 단위 | 화면 · JSON-LD · llms.txt 가 **같은 값**을 쓴다 |
| `status` | 1 UNVERIFIED / 2 PENDING_OWNER / **3 VERIFIED** / **4 CORRECTED** / 5 REJECTED / 6 EXPIRED | ★ **3·4 만 사이트에 나간다**(`PUBLISHABLE_FACT_STATUSES`). 4 는 사장님이 고친 값이라 **잠긴다** — 재수집이 덮어쓰지 못한다 |
| `source_type` · `source_url` | 출처 | payload 에 그대로 실어 화면이 "언제 무엇으로 확인된 값인지" 를 보여준다 |
| `unit_id` | NULL 이면 사업장 fact, 있으면 객실·메뉴 fact | 객실별 요금·정원이 여기로 들어간다 |
| `expires_at` | 유효기간 | 지나면 EXPIRED 로 내려 재수집 대상이 된다 |
활성 유니크는 `(place, unit, key)` 당 **노출값 1건**이다(status 3·4 부분 인덱스).
후보(1·2)와 이력(5·6)은 여러 건 공존한다 — 재수집이 쌓일 수 있어야 하기 때문이다.
**수집값은 빈 자리에 바로 노출값(VERIFIED)으로 들어간다** (2026-09-14, `services/fact_service`).
예전에는 크롤링 값이 전부 UNVERIFIED 후보였다. 그러면 수집 직후 발행이 "확인된 사실 0건" 으로
막혀, 사장님이 한 건씩 승인하기 전에는 사이트가 만들어지지 않았다 — 수집이 끝난 뒤에야 오는
값이라 승인할 화면을 이미 지나가 있었다.
지금 규칙은 **누가 그 자리를 이미 차지했는지**로 갈린다.
| 그 key 의 현재 노출값 | 수집값이 오면 |
|---|---|
| 없음 | 바로 노출값(VERIFIED). `verified_by`**비운다** — 사람이 승인한 이력과 구별된다 |
| 같은 값 | REFRESHED — 확인 시각만 갱신. 검증을 초기화하지 않는다 |
| 사장님이 넣은 값(OWNER) · 정정본(CORRECTED) | 덮지 않는다. PENDING_OWNER **후보**로 쌓여 사람이 고른다 |
| 앞선 수집값 | 새 값이 노출값 자리를 가져간다(옛 값은 EXPIRED 이력) |
즉 자동이 사람을 덮지 못한다는 보호(절대규칙 6)는 그대로이고, 자동끼리는 최신값이 이긴다.
UNVERIFIED 는 이제 공식 API 수집이 빈 자리에 넣을 때 생긴다.
### `place_channels` — 크롤링 대상 URL
`confirmed_at` 이 NULL 이면 **크롤링하지 않는다.** 카카오 로컬로 동일 업소임을 확인한 URL 만 넘긴다.
`raw` 에는 Perplexity 응답을 통째로 박제하지만 **사실 근거로 쓰지 않는다** — 환각 추적용이다.
### `place_photos` — 사진
`status``APPROVED`(2) 인 것만 사이트에 나간다. Gemini Vision 신뢰도가 낮으면
`PENDING_REVIEW`(1) 로 남아 빌드에서 빠진다. `source_type`·`origin_url` 을 반드시 남긴다 —
크롤링 이미지의 재게시 권리가 미결이라([DECISIONS 1-2](DECISIONS.md)) 결론에 따라 걸러낼 수 있어야 한다.
### `place_faqs` — FAQ
출처(`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` — 이 숙소의 노래
발행할 때마다 한 곡 만든다. 가사는 소개문과 **같은 재료**(확인된 fact + 조사 근거 + 소개문)로
Gemini 가 쓰고, 곡은 Suno 가 붙인다.
**검증 상태(`FactStatus`)가 없다.** 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
"맞는가" 를 물을 대상이 아니다. 상태는 "만들어졌는가" 하나다(`SongStatus`:
`GENERATING` · `READY` · `FAILED`). 스냅샷은 **`READY` 만** 싣는다.
**`origin_url`(Suno 가 준 주소)은 발행본에 나가지 않는다.** 만료되는 주소라 그대로 실으면
발행 직후에는 재생되고 몇 주 뒤 조용히 죽는다. mp3 를 받아 `solution/site/songs/<song_id>.mp3`
에 두고, 프리렌더가 사이트 디렉토리로 복사한 것(`/s/<slug>/<song_id>.mp3`)만 나간다.
표에는 추적용으로만 남긴다.
★ 새 곡이 실패해도 직전 곡이 그대로 남는다 — `latest_ready``READY` 중 최신 하나를 고른다.
### `place_social_posts` · `owner_social_accounts` — SNS 게재
사장님이 [SNS에 알리기] 를 누를 때만 생긴다. 발행의 부수효과가 아니다 — 발행은 우리 화면을
굽는 일이고, 이건 **사장님이 자기 이름으로 하는 말**이다(DECISIONS 8절).
**승인 대기는 잡이 아니라 이 표의 상태다.** 잡으로 매달면 lease(120초)가 만료돼 reaper 가
회수하고 attempts 가 올라 결국 DEAD 가 된다. 큐는 "지금 할 일" 만 표현한다.
상태: `DRAFTING → PENDING_APPROVAL → APPROVED → POSTING → POSTED`(+ `DECLINED`·`EXPIRED`·
`FAILED`·`UNKNOWN`). **발행본에는 `POSTED` 만 나간다.**
`POSTING` 이 10분 넘게 남아 있으면 `UNKNOWN` 으로 내린다 — **시간을 근거로 `APPROVED`
되돌리지 않는다.** 외부가 이미 받았을 수 있고, 되돌리면 같은 글이 두 번 올라간다.
`approval_token_sha`**해시만** 저장한다(원문은 링크에만 있다). 일회성은 토큰이 아니라
`status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다(DECISIONS 8-3).
`owner_social_accounts`**place 가 아니라 user 에 붙는다.** 계정은 사람의 것이고, 사장님이
업장을 둘 가져도 계정은 하나다. 토큰은 `SOCIAL_TOKEN_SECRET` 으로 암호화해 넣는다 —
이 표만이 위임받은 자격증명을 담는다(`place_channels` 는 공개 URL 목록이라 섞지 않는다).
### `area_contents` + `place_area_refs` — 지역 콘텐츠
**키가 `region_code` 다.** 같은 지역에 사이트가 몇 개 생기든 외부 조회는 1회.
| 종류(`content_type`) | 출처 | `kind` |
|---|---|---|
| 1 WEATHER | Open-Meteo | — |
| 2 FESTIVAL · 3 ATTRACTION · 4 RESTAURANT · 5 COURSE | TourAPI (좌표 반경) | — |
| 6 STORY | Perplexity | `songs` `daily` `people` `chronicle` `reading` `postcard` `quiz` |
`body`(JSONB)에 항목이 들어간다. **지역 이야기는 종류당 한 행**이고 항목들은 `body.items` 안에 있다.
`place_area_refs` 에는 **업장마다 다른 것만** 둔다 — `distance_m`(정렬·도보시간의 원값)과
`hidden`. 예전에는 이 표가 값을 통째로 들고 있어서(`place_contents`) 업장마다 TourAPI 응답이
복제됐다 — 실측(2026-09-09) 한 곳에 144행. `hidden` 은 재수집이 덮어쓰지 않는다.
**외부 API 가 실패해도 이 행을 지우거나 비우지 않는다.** 직전 값을 유지하고 알림만 낸다.
### `sites` — 발행 사이트(사업장당 1개)
| 칸 | 무엇 | 왜 서버에 두나 |
|---|---|---|
| `template_id` | 사장님이 고른 템플릿 키 | 서버는 **해석하지 않고 보관·반환만** 한다. 템플릿 목록은 프론트가 소유하므로, 서버가 검증하면 템플릿을 늘릴 때마다 백엔드를 고쳐야 한다 |
| `theme` (JSONB) | 색·서체·**섹션 순서/on-off/배리에이션** | 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본으로 굽고, 고른 디자인과 발행본이 갈린다. 컬럼으로 펼치지 않는 이유는 항목이 늘 때마다 마이그레이션이 따라오기 때문 |
| `status` | 1 DRAFT / 2 REVIEW / 3 PUBLISHED / 4 SUSPENDED / 5 UNPUBLISHED | ★ 해지는 **물리 삭제가 아니라 상태 전이**다 — 색인된 페이지를 갑자기 404 로 만들지 않는다 |
| `current_version_id` | 지금 나가 있는 버전 | |
| `thumbnail_url` | 쇼케이스 카드 그림 | ★ **발행에 성공한 뒤에만** 채운다. 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다 — 헤드리스 브라우저는 영구 금지 |
`templateId``theme` 안에 넣지 않는다. 두 곳에 두면 어느 쪽이 진짜인지 갈린다.
### `site_sections` — 섹션 콘텐츠 (`sites.theme` 와 역할이 다르다)
**`theme` 은 모양, 여기는 내용.** 2026-09-09 에 갈랐다 — 실측(`/s/stay`): `theme` 42,150 B 중
디자인이 636 B(1.5%), 콘텐츠가 39,645 B(94%)였다. 크기가 아니라 **쓰기 단위**가 문제였다:
영상 주소 하나(592 B)를 고쳐도 42 KB 를 통째로 다시 쓰고, 둘이 만지면 나중 쓰기가 앞을 덮고,
항목마다 "누가 넣었나 · 확인됐나" 를 물을 자리가 없었다.
`(site_id, section_id)` 당 1행. `section_id``songs` `itinerary` `video` `people` `local` ….
`data` 는 shared 의 `XxxItem[]` 계약을 그대로 담는다.
`shared_ref` 가 있으면 값을 복제하지 않고 원본(`area_contents`)을 가리키고, 발행할 때 펼친다.
`section_id = 'local'``data.places` 는 **배열이 아니라 맵**이다 — 화면에 순서대로 서는
항목이 아니라 `ref → 값` 조회표다. 정렬 기준은 읽는 쪽이 갖는다.
### `site_versions` — 빌드 버전, 그리고 정적 빌드의 경계
| 칸 | 무엇 |
|---|---|
| `snapshot` (JSONB) | ★ **빌드 시점 데이터 박제.** 방문자는 DB 와 만나지 않는다 |
| `jsonld` | 렌더러가 **실제로 내보낸** 구조화 데이터. 백엔드가 따로 계산하지 않는다 |
| `unique_content_count` | 렌더러가 센 고유 콘텐츠 수. **0 이면 발행 거부**(스팸 판정 대상) |
| `build_status` | PENDING → BUILDING → BUILT / FAILED |
| `build_error` | 실패 사유 원문 |
| `built_at` | `places.content_updated_at` 과 비교해 재빌드 대상을 고른다 |
`snapshot` 이 감사 기록이기도 하다 — fact 마다 `status`·`source_type`·`source_url`·`verified_at`
을 같이 싣는다. "왜 이 값이 나갔나" 를 나중에 되짚을 수 있어야 하기 때문이다.
### `site_publish_logs` — 발행 시도 기록
게이트가 막았으면 `result=REJECTED` + `reject_reason` + `detail`(막힌 항목 목록)을 남긴다.
화면의 반려 카드가 이 사유 코드로 문구를 고른다 — 전부 "렌더 실패" 로 뭉개면 사장님이
손댈 곳을 모른다.
### `jobs` — 작업 큐 (PostgreSQL 을 큐로)
COPY 단계는 `jobs.progress`(JSONB)의 `steps`·`attempt`에 기록한다.
생성 화면 복구와 모듈별 책임은 [GENERATION_FLOW.md](GENERATION_FLOW.md).
| `job_type` | 핸들러 | 하는 일 |
|---|---|---|
| 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 |
| 2 VISION | `vision_service.run_vision` | 사진 분류 + alt 생성 |
| 3 COPY | `copy_service.run_copy` | 소개문·FAQ (확보된 fact 만 근거) |
| 4 BUILD | `build_service.run_build` | ★ 정적 빌드 + 발행 게이트 |
| 5 LOCAL_SYNC | `story_service.run_local_sync` | 지역 이야기 생성(지역당 1회) |
| 6 AI_CHECK | 미구현 | reports 모듈이 붙을 때 |
- 할당은 **단일 문장 원자 claim**(`FOR UPDATE SKIP LOCKED` + 같은 UPDATE + `RETURNING`) —
워커가 몇 개든 이중 할당이 불가능하다.
- 복구는 타임아웃 추측이 아니라 **`lease_until` 만료 소유권**이다. 컨테이너를 재시작해도
진행 중이던 잡이 증발하지 않는다.
- `dedupe_key` 로 활성 중복(PENDING/RUNNING)을 막는다 — 지역 이야기는 `story:{region_code}`
같은 지역 숙소 50곳이 동시에 열어도 잡은 하나다.
- ★ 이 표만 raw SQL 경로가 있다. 표 이름을 옮기면 ORM 이름 변경이 **여기까지 안 따라온다**
2026-09-09 에 `job.jobs``jobs` 를 놓쳐 큐가 통째로 멈췄다(화면에는 "버튼만 안 먹는" 것으로 보였다).
---
## 3. 값 하나가 페이지까지 가는 길
```
place_facts (status=3 or 4) ← 이 필터가 snapshot.py 한 곳에만 있다
└ build_snapshot() services/snapshot.py:59
· fact : VERIFIED / CORRECTED 만
· 사진 : APPROVED 만
· FAQ : VERIFIED / CORRECTED 만
· 지역 : PUBLISHED + 노출기간 안 + 종류별 20건까지
└ site_versions.snapshot 에 박제
└ to_site_payload() services/site_payload.py:708
★ 여기서 DB 를 다시 읽지 않는다 — 입력은 박제된 스냅샷뿐이다.
다시 읽으면 발행 시점과 렌더 시점 사이에 값이 바뀌어 "스냅샷과 다른 페이지" 가 나온다
└ out/payloads/<slug>.json
└ prerender.ts → out/s/<slug>/index.html
화면 · JSON-LD · llms.txt 가 **같은 값**에서 나온다
```
게이트는 **두 번** 돈다.
1. **1차 (렌더 전, DB 사실 기준)** — 상호명·업종·미검증 fact.
payload 를 쓰기 **전에** 막는다. 렌더러에 넘긴 뒤 막으면 검증 안 된 값이 디스크에 한 번 나갔다 온다.
2. **2차 (렌더 후, 실제로 구워진 HTML 기준)** — JSON-LD 불일치 · 고유 콘텐츠 수.
1차만 있으면 "데이터는 맞는데 HTML 은 틀린" 상태를 발행한다.
★ 지역 정보(주변 맛집·축제)는 **빌드 시점에 업장 좌표로 새로 받는다.** 실패해도 빌드는 계속한다 —
곁들이 정보가 사장님 사이트 발행을 막을 이유가 없고, 직전 값이 그대로 있다.
---
## 4. DB 에 **없는**
경계를 아는 것이 표를 아는 것만큼 중요하다.
| 것 | 어디 있나 |
|---|---|
| HTML · 사이트맵 · llms.txt | `out/` 디렉토리. **백엔드는 HTML 을 만들지 않는다** |
| 렌더링 결과 보고서 | `out/payloads/.status/<slug>.json` (프리렌더 → 백엔드 단방향) |
| 섹션 목록 · 배리에이션 키 · 색 토큰 이름 | 프론트가 소유. 서버는 `theme` JSONB 로 통째로 보관만 |
| 빈 방 재고 · 예약 접수 · 결제 | **어디에도 없다.** 예약 섹션은 화면 목업이고 연동이 없다 |
| 방문자 세션 | 없다. 정적 페이지라 방문자는 DB 와 만나지 않는다 |
---
## 5. 표를 고칠 때
1. ORM(`models.py`) 과 `init.sql` **둘 다** 고친다.
2. 이미 데이터가 든 DB 를 위해 `postgres-init/migrations/NNNN_*.sql` 을 더한다.
3. 적용: `cd solution/backend && .venv/bin/python scripts/migrate.py`
(서버는 [SERVERS.md `## DB`](SERVERS.md) 참조)
**표 이름을 옮겼으면 정적 검사를 돌린다.** import 도 타입검사도 안 잡는 자리가 셋 있다 —
raw SQL, 클래스 생성자, 그리고 표와 이름만 같은 속성.
```bash
cd solution/backend && python -m pyflakes services/ crud/ router/ worker/ common/ | grep "undefined name"
```
2026-09-09 에 이걸 안 돌려서 19건이 남았고, 가게 등록 · 수집 시작 · 수집 완료 세 곳이 연달아
죽었다. 기동은 정상이라 로그를 열기 전에는 안 보였다.
## SNS (2026-09-14)
| 표 | 키·범위 | 데이터·인덱스 |
|---|---|---|
| owner_social_accounts (0012) | account_id, user_id/provider | provider_user_id·handle·profile_url, 암호화 access/refresh token·만료·scopes·status·last_error. deleted=false, linked/needs_reauth인 user/provider 부분 유니크 |
| place_social_posts (0013) | post_id, place_id/user_id/site_version_id | 승인 계정 account_id, provider·본문·고정 URL·grounded_facts, nonce 해시·시각·채널, 게시 ID·permalink·posted_at·last_error. 같은 place/version은 삭제 전까지 유니크. POSTED 최신 조회 인덱스 |
Provider 1=X 예약값(구현 없음), 2=Threads. 상태는 DRAFTING/DRAFT/PENDING_APPROVAL/APPROVED/POSTING/POSTED/DECLINED/EXPIRED/FAILED/UNKNOWN.
DRAFT는 복사 가능한 작성 완료 원고, UNKNOWN은 중복 방지를 위한 수동 확인 상태다.
SNS 승인 CAS와 잡 삽입은 같은 트랜잭션. SOCIAL_DRAFT=8, SOCIAL_POST=9, 승인 대기는 잡이 아니다.
POSTED 최신 3건만 snapshot → payload.socialPosts로 전달한다. 자격증명·nonce·근거 원문은 제외한다.

View File

@ -15,7 +15,7 @@
|---|---|---|
| 야놀자 · 여기어때 | **불가** | 풀 브라우저 헤더로도 HTTP 403 + Cloudflare 챌린지. 뚫으려면 봇 탐지 우회가 필요한데 그건 영구 금지 영역이다. 법적으로도 야놀자 v 여기어때 = 형사 무죄(대법원 2022-05-12)지만 **민사 10억 배상 + 복제·저장 금지**(서울중앙지법 2021-08). 우리는 재게시까지 하므로 노출이 더 크다 |
| 네이버 플레이스 | **robots.txt 기준 불허** | `m.place.naver.com/robots.txt` = `User-agent: * / Disallow: /`. 현재 `naver_place` 어댑터는 위반 상태로 동작 중이다. 사장님 본인 업소 1건·사장님 동의·대량 DB 복제 아님이라 야놀자 사건과는 양상이 다르지만, **이 위에 제품을 세우지 않는다** — 대체 소스가 붙는 대로 발행 payload에서 빼고 대조용 참고값으로 내린다 |
| 카카오맵 | **불가** | 상세는 SPA 셸(3.6KB), 내부 API 406 차단 |
| 카카오맵 | **불가** | 상세는 SPA 셸(3.6KB), 어드민 API 406 차단 |
| **사장님이 확정한 자체 홈페이지** | **가능** | 사장님 동의 기반. → `StaticHtmlAdapter` 등록(2026-08-28) |
**추가 결론 (2026-08-31)** — TourAPI 어댑터를 붙여 같은 표본으로 대조한 결과
@ -34,8 +34,29 @@
| 결론이 "불가"일 때 | 폴백 3단계로 간다 — ① 공식 API → ② 사장님이 직접 붙여넣기 → ③ 최소 정보로 생성 + 보완 요청. **생성 자체는 실패시키지 않는다** |
| 확정 사항 | 캡차 우회 · 봇 탐지 우회 · IP 회전은 **결론과 무관하게 금지**. 구현하지 않는다 |
**변경 (2026-09-14 / 확인 2026-09-15) — NOL 전용 어댑터를 등록한다.**
위 표의 "야놀자·여기어때 불가" 와 "Playwright 어댑터는 등록하지 않는다" 를 **한 패턴에 한해**
연다. 무엇을 열고 무엇을 안 여는지는 정확히 이렇다.
| | 지금 |
|---|---|
| `nol.yanolja.com/stay/domestic/<id>` | **전용 어댑터 `yanolja`** 가 Playwright 로 렌더해 읽는다. 기본 활성 |
| 그 밖의 `yanolja.com` · `goodchoice.kr` 전부 | **막는다.** 범용 HTML 어댑터의 `_DENY_HOSTS` 에 그대로 있다 |
| 캡차 우회 · 봇 탐지 우회 · IP 회전 | **여전히 금지.** 차단되면 그대로 실패로 돌린다 |
- 레지스트리가 `yanolja``static_html` 보다 **앞에** 등록하므로 그 한 패턴만 전용 경로로 가고
나머지는 예전처럼 `AdapterNotFound` 로 끊긴다. 순서가 곧 이 경계다.
- ★ 실측(2026-09-15): 어댑터를 들이면서 `static_html``_DENY_HOSTS` 에서 `yanolja.com` ·
`goodchoice.kr` 이 함께 빠져 있었다. 그러면 전용 어댑터가 아니라 **범용 HTML 수집기가**
두 플랫폼을 받는다 — 전용 경로 하나를 여는 것과 범용 수집을 그 플랫폼에 푸는 것은 다른
일이라, 차단 목록과 그 법무 근거 주석을 되돌렸다.
- 민사 10억 선례(서울중앙지법 2021-08)는 그대로다. **재게시 범위는 1-2 가 따로 정한다**
이 항목은 "읽을 수 있나" 까지만 정하고 "다시 실어도 되나" 는 정하지 않는다.
### 1-2. 크롤링한 **이미지**의 재게시 권리
2026-09-14: SNS 사본은 나중에 필터링해 회수할 수 없어 기존 격리를 적용할 수 없다. 미디어 첨부는 구현하지 않는다. 링크 카드의 og:image 캐시는 별도로 남을 수 있다.
| 항목 | 내용 |
|---|---|
| 상태 | **미결** |
@ -58,6 +79,8 @@
### 1-4. 해지 시 사이트 처리 정책
2026-09-14: SNS 운영 게재의 선행조건으로 승격. 외부 링크는 남으므로 UNPUBLISHED는 안내+연락처 페이지여야 한다. 현재 상태 전이만 있고 안내 페이지 생성은 미구현이므로 자동 게재 플래그는 기본 OFF다. 사장님 글을 자동 삭제하지 않는다. 함께 삭제할지는 별도 명시적 선택이며 현재 삭제 API는 제공하지 않는다.
| 항목 | 내용 |
|---|---|
| 상태 | **미결** |
@ -65,6 +88,17 @@
| 코드 격리 | `site` 에 발행 상태를 두고, 해지 처리는 **삭제가 아니라 상태 전이**로만 구현한다. 물리 삭제 경로를 만들지 않는다 |
| 반영됨 (2026-08-26) | `SiteStatus.SUSPENDED`(해지 유예 — 페이지 살아 있음) / `UNPUBLISHED`(내림)를 분리했다. `PublishAction.SUSPEND`·`RESUME` 으로 `publish_logs` 에 남는다. 유예 기간 길이만 정하면 된다 |
### 1-5. 계정 연결 — 같은 사람의 id/pw 계정과 구글 계정을 이을 것인가
| 항목 | 내용 |
|---|---|
| 상태 | **미결** (2026-09-02 구글 로그인 붙이면서 생김) |
| 필요한 결론 | 이미 id/pw 로 가입한 사람이 같은 이메일의 구글로 로그인했을 때, 같은 계정으로 이을 것인가. 이으려면 **먼저 가입한 쪽의 소유 증명**(비밀번호 재입력 또는 이메일 인증)을 어디에 둘 것인가 |
| 왜 지금 안 푸나 | 이메일만 보고 자동으로 이으면 **계정 선점**이 된다 — 공격자가 남의 이메일로 id/pw 계정을 먼저 만들어 두면, 그 사람이 구글로 로그인하는 순간 공격자가 비밀번호를 아는 계정 안으로 들어간다. 소유 증명 절차 없이 열 수 있는 문이 아니다 |
| 코드 격리 | `users.provider`(AuthProvider) 로 계정마다 수단을 하나만 둔다. 이메일이 이미 쓰이고 있으면 **잇지도 만들지도 않고** `ACCOUNT_PROVIDER_CONFLICT` 로 거절하고, 화면은 "처음 가입할 때 쓴 방법으로 로그인" 을 안내한다. 반대 방향(구글 계정에 비밀번호 설정)도 `update_me` 에서 같은 코드로 막는다 |
| 결론이 "잇는다" 일 때 | `provider`·`provider_uid` 를 users 에서 별도 테이블(`user_identities`)로 빼고, 계정 하나에 수단 여러 개를 매단다. 지금 구조가 그 이행을 막지 않는다 |
| 확정 사항 | **구글 ID 토큰의 `aud`(우리 client_id)와 `email_verified` 검증은 결론과 무관하게 필수다.** `tests/test_google_identity.py` 가 이 둘을 고정한다 |
---
## 2. 이식하면서 내린 결정 (2026-08-26)
@ -76,10 +110,10 @@
| 레이어 구조 | 원본 그대로 — `router``service``crud`, 람다 DB 실행, `Req_*`/`Res_*` 프로토콜, `RemoveNoneResponse` | "기존 컨벤션을 그대로 따른다" |
| 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 |
| DB | `web4ai_db` (테스트 `web4ai_test_db`), 기존 로컬 postgres(`negosium-db` 컨테이너, 5432) 안의 **별도 database** | 원본과 같은 인스턴스·다른 DB. 스키마 네임스페이스 컨벤션 유지 |
| 마이그레이션 | Alembic 안 씀. `postgres-init/init-data/init.sql` **한 벌**(전체 DDL, 재실행 안전) | 2026-08-31: 누적 ALTER 파일(`alters/`)을 없앴다. 아직 git·서버 어디에도 안 올라가 **보정할 기존 DB 가 없다** — init.sql 에 이미 전부 반영돼 있어 두 벌을 유지할 이유가 없었다. 운영 DB 가 생기는 순간 다시 필요해진다 |
| 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `company.companies`/`company.users` · auth 라우터 · 스케줄러 껍데기 · conftest(테스트 DB 자동 생성/삭제) | 전 모듈이 공통으로 쓰는 인프라. 인증은 places·facts·sites 전부가 `IsValidAccessToken` 에 의존한다 |
| 마이그레이션 | Alembic 안 씀. `init-data/init.sql`(새 DB 전체 DDL) **+** `postgres-init/migrations/NNNN_*.sql`(기존 DB 보정), 적용기 `scripts/migrate.py` | 2026-08-31 에 누적 ALTER 를 없애며 "운영 DB 가 생기는 순간 다시 필요해진다" 고 적어 뒀다. **2026-09-09 그 순간이 왔다** — init.sql 은 DB 를 처음 만들 때만 도는데 서버·로컬에 이미 데이터가 있어서, `local.place_contents` 테이블과 `places.external_category` 컬럼이 실제 DB 에만 빠져 있었다. TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고 화면에는 "그냥 안 나오는 것"으로만 보였다. Alembic 을 안 쓰는 이유는 그대로다 — ORM·init.sql 두 곳에 스키마가 있고 `test_schema_ddl.py` 가 대조하는 구조라, 세 번째 정의를 더하면 어긋날 자리가 하나 더 생긴다 |
| 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `users` · auth 라우터 · 스케줄러 껍데기 · conftest(테스트 DB 자동 생성/삭제) | 전 모듈이 공통으로 쓰는 인프라. 인증은 places·facts·sites 전부가 `IsValidAccessToken` 에 의존한다 |
| 뺀 것 | quotation · supplier · item · card · dashboard · statistics · learning · renegotiation · landing · admin · notification · LPS 연동 · anchoring · 초청메일(ACS/SMTP) · Azure Blob 클라이언트 | negodata 고유 도메인. Blob 클라이언트만 1-2 결론 후 media 모듈과 함께 재이식 예정 |
| `companies` 테이블 유지 | 유지 | 보일러플레이트의 멀티테넌트 스코프 키(`UserInfo.company_id`)가 전 계층에 박혀 있다. 대행사/운영사 단위로 그대로 쓴다 |
| `companies` 테이블 유지 | **2026-09-08 철회 — 걷어냈다** | 보일러플레이트를 그대로 둔 결정이었는데, 이 제품의 사용자는 사장님 한 명이다. 가입 한 번이 회사를 만들고 사장님이 자기 회사의 직원이 되는 구조가 화면에까지 나왔다(가입 폼의 "상호", 헤더의 "이름 · 회사명"). 스코프 키를 `places.owner_user_id` 로 옮기고 `company.companies` 테이블 · `users.company_id` · `UserInfo.company_id` 를 삭제했다. 스키마 이름 `company` 만 남았다 — rename 은 모든 모델의 `__table_args__` 를 건드려서 따로 둔다 |
| ErrorType 구간 | 계정 = 1100. 도메인 구간 예약 — places 1200 / facts 1300 / collector 1400 / generator 1500 / local 1600 / sites 1700 / reports 1800 | 원본이 구간을 나눠 쓰는 방식 유지 |
| 외부 API 키 | `[ExternalApiConfig]` 로 toml + env override. **키가 비면 해당 어댑터만 비활성, 서버는 그대로 뜬다** | 부팅이 외부 계약에 묶이면 안 됨 |
| 백그라운드 작업 | 원본에 전용 작업 큐 없음(APScheduler 크론만). 수집·비전분석·빌드는 몇 분 걸리므로 **큐를 새로 얹어야 한다** — 방식 미정 | 원본에 없는 것이라 팀 컨벤션 확인 필요. 아래 3번 참고 |
@ -110,18 +144,18 @@
| 유니크 인덱스 2분할 | `unit_id IS NULL` / `IS NOT NULL` 로 나눠 건다 | Postgres 에서 NULL 끼리는 유니크가 안 걸린다. 나누지 않으면 사업장 단위 fact 가 중복된다 |
| `critical` 플래그 | 업종 스키마 필드 속성으로 도입 | 절대규칙 1(미검증 fact 노출 금지)의 대상 목록이 코드가 아니라 데이터에 있어야 업종 추가 시 자동으로 따라온다 |
| `allow_llm` 플래그 | 기본 `False`. `True` 는 소개문 계열 2개뿐 | 절대규칙 7(LLM 은 사실을 만들지 않는다)을 스키마 레벨에서 강제. 테스트가 `required` 필드의 `allow_llm=True` 를 금지한다 |
| 지역 정보 캐시 키 | `local_contents.region_code` (place_id 아님) | 같은 지역에 사이트 50개가 생겨도 외부 조회는 1회. 카카오 키워드 검색이 좌표 변환보다 4배 비싸다 |
| 지역 정보 캐시 키 | `area_contents.region_code` (place_id 아님) | 같은 지역에 사이트 50개가 생겨도 외부 조회는 1회. 카카오 키워드 검색이 좌표 변환보다 4배 비싸다 |
| 스키마 네임스페이스 | `place` / `fact` / `local` / `site` | 원본의 도메인별 schema 컨벤션. `local` 은 Postgres 비예약어라 그대로 쓸 수 있다(확인함) |
| 테이블명 | 복수형 (`places`, `facts`) | 원본이 복수형(`companies`, `users`, `quotations`). 스펙 문서의 단수 표기는 엔티티 이름으로 읽었다 |
| `server_default` | 신규 도메인 테이블에만 추가 | ORM `default=` 는 Python 쪽이라 raw INSERT 에 안 먹는다. `create_all`(테스트 DB)과 `init.sql`(실 DB)이 갈라져서 실제로 버그가 났다. **`companies`/`users` 는 원본 그대로 두었다** |
| ORM ↔ init.sql 정합성 | `tests/test_schema_ddl.py` 가 파일을 파싱해 대조 | 스키마 정의가 두 곳에 있는 구조(원본 컨벤션)라, 드리프트를 테스트로 막는다 |
| 외부 API 키 주입 | 레포 최상위 `.env` + `python-dotenv` | 우선순위 = 실제 환경변수 > `.env` > toml. **`APP_ENV=test``.env` 를 읽지 않는다** — 실키가 새면 테스트가 외부 API 를 때리고 요금이 나간다 |
| 설정 주입 | 레포 최상위 `.env` + `pydantic-settings` | FastAPI 공식 방식(BaseSettings + env_file). 우선순위 = 실제 환경변수 > `.env` > 코드 기본값. toml 층은 없앴다(2026-09-01) — 키를 손으로 나열하다 `client_url` 이 빠져 배포 주소의 CORS 가 막혔다. **`APP_ENV=test``.env` 를 읽지 않는다** |
| 키 출처 | Perplexity·Gemini 는 `o2o-infinith-backend/.env` 값 재사용. **TourAPI 는 2026-08-31 활용신청 승인**, 카카오는 여전히 **미발급** | 카카오 키가 없으면 `kakao` 어댑터만 비활성이고 주변 정보 블록이 비어 뜬다 |
### 아직 테이블이 없는 것
- **`report` 스키마** — 노출 리포트·유입 통계(GA4 Data API, Search Console API). 작업 순서 6번 이후.
- ~~**작업 큐**~~`job.jobs` 로 생겼다 (2026-08-27, 5-2). 17번째 테이블이다.
- ~~**작업 큐**~~`jobs` 로 생겼다 (2026-08-27, 5-2). 지금은 14개 표 중 하나다([DATA_MODEL.md](DATA_MODEL.md)).
- **TourAPI areaCode ↔ 카카오 행정구역 코드 매핑** — 테이블 대신 `common/category_schema` 와 같은 리소스 JSON 으로 두는 것을 제안. 3번 참고.
---
@ -161,7 +195,7 @@
| 승인 | 후보 → 노출값, 옛 값 EXPIRED | `PUBLISHED_REPLACED` |
| 수정(사람 직접) | 즉시 노출값 교체 | `PUBLISHED_REPLACED` |
스키마: `postgres-init/init-data/init.sql` (`fact.facts` 활성 유니크 + 후보 상태)
스키마: `postgres-init/init-data/init.sql` (`place_facts` 활성 유니크 + 후보 상태)
### 5-2. 그 밖의 결정
@ -184,3 +218,191 @@
**업로드·저장 경로가 없다.** 이미지 재게시 권리(1-2)가 미결이라 Azure Blob 클라이언트를
일부러 아직 이식하지 않았다.
- 카카오 REST API 키 / TourAPI 키가 사내 어디에도 없다. 발급해서 `.env` 에 채워야 실제 연동이 돈다.
## 6. 지역 이야기 생성 (2026-09-09, 일력 추가 2026-09-10)
가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/s/stay` 시안이 이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 같은 템플릿을
골라도 그 자리가 비었다. 그래서 서버가 채운다.
**2026-09-10 — 일력(`daily`)을 여섯 번째로 넣는다.** 이 종류만 프롬프트가 빌더
(`canvas/dataSpec.ts`)에 손으로 적혀 있어 `shared/section-prompts.ts` 에 없었고, 서버는
그 종류의 존재 자체를 몰랐다. 렌더러에는 '오늘의 한 장' 탭 자리가 있고 '옛 항구' 템플릿
설명도 일력을 약속하는데 채우는 쪽만 없어서, 그 탭은 손으로 넣은 시안에만 있었다.
→ 종류 목록의 단일 출처는 `STORY_KINDS` 하나다. 뽑는 스크립트도 그 상수를 읽는다
(`export-prompts.mjs` 가 배열을 한 벌 더 들고 있어서 이 어긋남이 생겼다).
### 6-1. 키는 업장이 아니라 지역이다
이 다섯은 업장의 사실이 아니라 **도시의 사실**이다. 군산 이야기는 군산 숙소가 같이 쓴다.
`place_id` 를 키로 잡으면 같은 지역에 숙소 50곳이 들어올 때 같은 곡 목록을 50번 만든다 —
`area_contents``region_code` 를 키로 두는 것과 같은 이유이고, 그 표를 그대로 쓴다.
**사이트별 `sections[].data` 로 복사하지 않는다.** payload 에서는 `local.story` 로 따로 싣고,
화면이 **읽는 순간에만** 사장님이 붙여넣은 것과 한 배열로 잇는다(`site/src/lib/derive.ts` `sectionItems`).
복사해 두면 지역 하나를 고칠 때 사이트 수만큼 고쳐야 한다.
### 6-2. 검수 게이트를 두지 않는다
생성분은 `PUBLISHED` 로 저장해 **바로 발행본에 나간다.** 공공데이터(맛집·관광지)를 검수 없이
싣는 2026-09-03 결정과 같은 규약이다.
- 대신 항목마다 `verified`(확인 · 확인필요)와 `source`(열리는 URL)가 실린다. 출처가 없는 항목은
저장 단계에서 버리고, 항목 자신의 출처가 없어 검색 출처로 때운 항목은 `확인` 이라고 우겨도
`확인필요` 로 내린다(`grounding/story.py`).
- 틀린 항목은 **사장님이 에디터에서 뺀다.** 별도 운영자 검수 화면을 만들지 않는다.
이건 "미검증 값 노출 금지" 에 STORY 만 예외를 두는 것이다. 근거: 이 값들은 fact 가 아니라
공적 지식이고, 화면이 확신도와 출처를 함께 밝히며, 틀려도 예약·요금처럼 손님이 손해를 보는
종류가 아니다. **fact·사진에는 이 예외를 넓히지 않는다.**
(2026-09-10: FAQ 와 소개문에는 넓혔다 — 7절이 근거다. 수집 fact 와 사진은 그대로다.)
### 6-3. 프롬프트는 한 벌이다
사장님이 [콘텐츠] 탭에서 복사해 가는 프롬프트와 서버가 도는 프롬프트가 같아야 한다.
단일 출처는 `solution/shared/src/lib/section-prompts.ts` 이고,
`npm run export:prompts``solution/backend/services/prompts/section_prompts.json` 으로 뽑는다(커밋).
백엔드 컨테이너에 node 를 넣지 않으려고 산출물을 커밋한다 — `scripts/export_openapi.py` 의 반대 방향이다.
### 6-4. Perplexity 한 곳이다
이 값들은 **출처가 붙어야** 쓸 수 있다. Gemini 는 검색을 안 해서 URL 을 지어내고,
Perplexity 는 실제로 읽은 `search_results` 를 함께 준다. 구조는 프롬프트의 `[스키마]` 블록이
잡고 파이썬은 항목 모양을 다시 적지 않는다 — 적으면 프론트가 필드를 하나 늘린 날 서버가
그걸 조용히 떨어뜨린다.
**종류당 1회, 지역당 1세트.** 다섯을 한 프롬프트에 넣으면 출력이 잘리고, 한 종이 실패하면
전부 다시 돌고, 검색 출처가 어느 항목 것인지 섞인다. 항목당 1회는 반대로 낭비다.
---
## 7. LLM 이 쓴 문장은 승인 없이 나간다 (2026-09-10)
소개문·FAQ 는 생성된 뒤 **사장님 승인**을 받아야 발행본에 나갔다(`UNVERIFIED` → `VERIFIED`).
그 단계를 없앤다. 생성 즉시 노출값이다.
**왜 — 승인받을 화면이 없었다.**
실측(2026-09-10, 힐튼 가든 인 서울 강남): 수집 확인이 07:29 에 끝나고 소개문은 07:31 에 도착했다.
사장님은 이미 확인 화면을 지나간 뒤였다. 로그는 `[copy] 소개문 O` 인데 발행본의 소개는 빈칸이고,
그 자리를 fact 로 조립한 한 줄("서초구에 있는 …입니다. 체크인 15:00.")이 대신 채우고 있었다.
**생성은 되는데 영영 안 나가는** 상태였고, 화면 어디에도 그 이유가 보이지 않았다.
**왜 안전한가 — 게이트가 뒤가 아니라 앞에 있다.**
- 입력이 **확인된 fact 뿐이다**. `copy_service``PUBLISHABLE` 만 근거로 넘기고, 근거가 하나도
없으면 유료 호출조차 하지 않는다. 즉 이 문장은 이미 승인된 사실로만 쓰였다 —
한 번 더 승인받는 것은 같은 사실을 두 번 승인하는 일이다.
- 근거 fact 가 없는 FAQ 는 저장되지 않는다. `ground_check` 가 반려한 문장도 마찬가지다.
- 6-2(지역 이야기)와 같은 규약이다. 그때 "fact·사진·FAQ 에는 넓히지 않는다" 고 적었는데,
**FAQ 와 소개문에는 넓힌다** 로 바꾼다. 근거는 위 두 줄이다: 이 둘은 수집된 *사실*이 아니라
이미 확인된 사실로 쓴 *문장*이다. 수집 fact(체크인·반려동물·취소 규정)와 사진에는
여전히 넓히지 않는다 — 그건 틀리면 예약 클레임이 나는 값이고 근거가 우리 밖에 있다.
**남겨 둔 잠금.** 사장님이 고친 문장(`CORRECTED`)은 재생성이 덮지 않는다(절대규칙 6).
지금까지 이 잠금은 "자동 출처는 노출값 경로로 못 간다"는 구조가 대신 지켜 줬다 —
LLM 만 그 경로를 지나가게 되면서 `fact_service.upsert_fact` 에 잠금을 **명시적으로 다시 걸었다.**
**FAQ 재생성의 기준이 바뀐다.** 생성분이 `VERIFIED` 로 들어가므로 status 만으로는 사람이
손댔는지 알 수 없다. `expire_generated``generated_by` 로 가른다 — 사장님이 정정하면
`faq_service` 가 그 값을 `OWNER` 로 바꾼다(책임 주체의 기록이고, 원래부터 있던 자리다).
반려(`REJECTED`)한 FAQ 는 그대로 둔다.
---
## 7-1. 사장님 명의의 SNS 발화는 별도 승인 (2026-09-14)
Threads 우선. 상세 흐름·활성화 전제는 [SOCIAL.md](SOCIAL.md).
| 기준 | 우리 발행본(7절) | SNS 게재 |
|---|---|---|
| 명의 | 우리 사이트 | 사장님 개인 계정 |
| 회수 | 에디터 수정 후 재빌드 | 플랫폼 사본·인용·캐시를 회수할 수 없음 |
| 주요 오류 | 문장 내용, 앞의 사실 게이트 | 명의·주소, LLM이 결정하지 않는 값 |
폰에서 로그인 없이 확인하고, 화면과 알림톡 두 경로를 둔다. 미승인은 EXPIRED로 남기고
게시/발송 실패도 카드에 남긴다. 초안 생성과 발송을 별도 요청으로 나눠 알림톡 실패를
초안 생성 성공으로 숨기지 않는다. GET은 승인하지 않는다. 토큰은 nonce와 DB 해시이며 JWT가 아니다.
POSTING 중단은 UNKNOWN으로 격리한다. 10분 지났다고 자동 재시도하는 설계는 취소한다.
게시할 때 승인된 account_id·본문·주소를 재검사한다. 계정 없이 확인한 원고는 나중에 연결해도
자동으로 게재하지 않고 다시 승인받는다. 사진 첨부 코드는 없다.
### 7-1-1. 게시는 주소가 확정된 사이트에만 — ★ 이 기능에서 가장 위험한 자리
`sites.domain` 이 비어 있어도 사이트는 발행된다. 그때 슬러그는 `_publish_target` 이 만드는
임시값이고 **`place.name` 에서 파생된다.** 상호를 고치면 **발행 주소가 통째로 바뀐다.**
`set_slug``SITE_SLUG_LOCKED``domain` 컬럼 변경만 막으므로 여기엔 안 걸린다.
→ 이미 올라간 글의 옛 주소는 404 가 되고, **그 글은 수정할 수 없다.**
그래서 전제조건을 코드가 강제한다(`social_service.target`):
`status == PUBLISHED` **AND** `current_version_id IS NOT NULL` **AND** `domain IS NOT NULL`.
임시 슬러그는 "아직 이름이 정해지지 않았다" 는 뜻이지 주소가 아니다.
### 7-1-2. 승인 링크 — 일회성은 토큰이 아니라 CAS 가 보장한다
JWT 를 쓰지 않는 이유는 JWT 가 서명만 검증하고 **소비를 못 세기** 때문이다. 승인은
`status='PENDING_APPROVAL'` 조건이 붙은 **단일 UPDATE ... RETURNING** 이고 두 번째 클릭은 0행이다.
**승인은 GET 으로 처리하지 않는다.** 메신저의 링크 미리보기 생성기·백신·브라우저 프리페치가
**사람이 누르기 전에** 그 URL 을 연다. GET 승인이면 사장님이 안 눌렀는데 글이 올라가고 로그에는
"승인됨" 으로 남는다 — 눈으로 원인을 못 찾는 종류다.
**2026-09-21 개정 — 미니블로그 문구 재사용은 예외.** 미니블로그 승인(이메일 GET 토큰 또는
로그인 "바로 발행")은 "이 문구를 공개해도 좋다"는 사장님의 명시적 의사표시이고, 같은 문구를
같은 시점에 다른 채널(쓰레드)에도 내보내는 것뿐이므로 별도 승인은 중복 확인이다. 이 예외는
**미니블로그 문구를 그대로 재사용하는 경우에 한정**한다 — `social_service.publish_reused_text`
`decided_via='mini_blog'`로 곧장 `APPROVED` 처리한다. 쓰레드 전용으로 새로 짓거나 내용을
바꾸는 경로(`create_draft`/`request_approval`)는 위 CAS 승인을 그대로 거친다.
**기존 액세스 토큰을 승인 링크에 얹지 않는다.** 지금 JWT 는 `sub``UserInfo` 통짜(role 포함)를
넣는다 — 그게 링크에 실리면 카톡 전달 한 번이 **빌더 전체 권한 양도**다.
### 7-1-3. 사진은 올리지 않는다 — 1-2 의 격리가 여기서는 불가능하다
1-2(크롤링 이미지 재게시)의 격리는 "결론이 불가면 `source_type=CRAWL` 을 발행 payload 에서
빼면 된다" 즉 **되돌릴 수 있다**는 전제 위에 있다. SNS 는 그 전제가 깨진다 — 플랫폼 서버에
사본이 생기고, 핫링크를 줘도 플랫폼이 자기 CDN 에 캐시한다. 게다가 지금은 **OWNER 사진이
존재할 수 없다**(업로드 경로가 없다, 5-3).
`source_type` 필터가 아니라 **미디어 첨부 코드를 아예 만들지 않는다.** 필터로 만들면 1-2 가
풀리기 전에 OWNER 업로드가 붙는 날 자동으로 열린다.
### 7-1-4. 실제 게시는 기본으로 꺼져 있다 — 그리고 1-4 가 전제조건이 됐다
`SOCIAL_POSTING_ENABLED=1` 일 때만 열린다. 초안·승인까지는 계약 없이 돌지만 **게시는
되돌릴 수 없어서**, 플랫폼 계약과 **1-4(해지 시 사이트 처리)** 결론을 확인한 뒤 사람이 연다.
★ 외부에 영구 링크를 뿌리는 순간 "유예 기간 미정" 이 **"죽은 링크 정책 미정"** 이 된다.
색인은 시간이 지나면 사라지지만 사장님 타임라인에 박힌 링크는 우리가 손댈 수 없다.
`UNPUBLISHED` 를 404 로 두면 SNS 에서 온 손님은 빈 화면을 본다.
그리고 **우리가 사장님 글을 자동으로 지우지 않는다** — 지우는 것도 사장님 명의의 행위다.
남은 정책: 만료 24시간의 최종 근거, 야간 발송(현재 화면 채널만 사용), 다계정 선택,
장기 미사용 계정의 사전 토큰 갱신. 계정은 현재 user/provider당 하나다.
---
## 8. FAQ 는 20개를 채운다 — 모자란 만큼 공통 질문 + 문의 안내 (2026-09-14)
**왜** — 확인된 fact 로만 쓰면 FAQ 가 4~8개에서 끝난다. 실측(2026-09-14, 로컬): 스테이머뭄 fact 8건,
산하연 풀빌라 fact 4건 · FAQ 4건.
**어떻게**
- 생성 상한 `max_faqs` 8 → 20 (`services/faq_fill.FAQ_TARGET`).
- 노출 중 FAQ 가 20개에 모자라면 업종 카탈로그(`common/faq_catalog/resources/pension.json`, 30문항)에서
**겹치지 않는** 질문을 카탈로그 순서대로 고른다. 건너뛰는 것:
- 답할 fact 가 있는 질문 — LLM 이 fact 로 답할 자리다. 프롬프트에 그 질문들을 실어 먼저 쓰게 한다.
- 기존 FAQ(생성분·사장님 입력·정정분)와 **근거 fact key** 가 겹치거나 **질문 키워드**가 겹치는 질문.
key 만 보면 사장님 입력(근거 없음)을 놓치고, 키워드만 보면 "주차 및 와이파이" 처럼 묶인 문항의 한쪽을 놓친다.
- 답은 **문의 안내뿐**이다("…은 전화(…)로 문의해 주시면 안내해 드립니다"). 값·가능 여부를 적지 않는다.
업종 시드 FAQ 가 "숯과 그릴 세트(25,000원)" 같은 가공의 값을 사이트에 내보낸 일을 반복하지 않기 위해서다
(`frontend … canvas/variants/faq/useFaqList.ts` 주석).
- 출처는 `SourceType.TEMPLATE`(5). 재생성 때 LLM 생성분과 함께 내리고 다시 고른다. fact 에는 쓸 수 없다.
- ★ **fact 가 0건이어도 20개다.** 근거가 없으면 LLM 은 부르지 않고(환각·유료 호출 방지) 채우기만 돈다.
그 경로는 API 키도 필요 없다. 예전에는 `start_copy``FAQ_UNGROUNDED` 로 잡을 만들지 않아 FAQ 가 0개였다 —
이제 그 거절은 **카탈로그가 없는 업종**(카페·음식점·호텔)에만 남는다.
**어디에 안 나가나** — 사이트 화면에는 나간다. FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수 · SEO 감사 FAQ
점수에서는 뺀다. 답이 없는 문답을 구조화 데이터로 내보내면 AI 검색에 잡음이고, 모든 펜션에 같은 문구라
고유 콘텐츠로 세면 내용 없는 사이트가 발행 게이트를 통과한다.
**적용 범위** — 숙박 업종이면서 외부 분류(`places.external_category`)가 호텔·모텔·리조트가 아닌 곳.
분류가 비어도 적용한다(펜션인데 네이버 분류가 없는 곳이 있다). 카페·음식점·체험시설은 카탈로그가 없어 채우지 않는다.

View File

@ -1,5 +1,8 @@
# 배포 · 스토리지
> 2026-09-15 이후 절차는 [PUBLISH_VERSION.md](PUBLISH_VERSION.md)를 따른다.
> 기존 사이트 전체 재굽기는 하지 않는다. 최초 전환 때 구 프리렌더를 중지한다.
> **현재 결정 (2026-08-31): 발행 사이트는 서버 안에서 nginx 가 정적 서빙한다.**
> Azure Blob 은 코드에 있으나 **켜지 않는다**(`AZURE_STORAGE_CONNECTION_STRING` 비움).
> 클라우드는 고도화 때 붙인다 — 근거는 [ARCHITECTURE.md 3절](ARCHITECTURE.md).
@ -66,20 +69,30 @@ site-out/
```
프론트 수정 → 새 번들(새 해시)
로컬 out/assets : 통째로 교체 (옛 해시 삭제) ← 재굽기 안 한 사이트는 CSS 404
Azure : 새 해시 추가, 옛 해시 유지 ← 안 깨지지만 옛 디자인 그대로 박제
로컬 out/assets : 새 해시 추가, 옛 해시 30일 보관 ← 안 깨진다. 옛 디자인으로 뜰 뿐
Azure : 새 해시 추가, 옛 해시 유지 ← 같다
```
프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 그래서 로컬 out/ 은 재시작만
하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게
`solution/backend/scripts/republish_all.py` 다.
**2026-09-07 이전에는 로컬 `out/assets` 를 통째로 갈았다.** 그래서 재굽기 전까지 나머지 사이트가
CSS 404 였다 — 하필 크롤러가 그 순간 렌더하면 스타일 없는 페이지를 본 것으로 기록된다.
지금은 `ASSET_RETENTION_DAYS`(30일) 동안 옛 해시를 남긴다. 보관 근거는 `out/assets/.builds.json`
대장이다(파일 mtime 이 아니다 — 복사·동기화가 시각을 갈아 버린다).
**그래서 재굽기는 여전히 필요하지만 급하지는 않다.** 안 하면 그 사이트만 옛 디자인으로 뜬다.
프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 하지만 Azure 는 발행 잡이 도는
사이트 하나씩만 올린다 — 그 짝을 맞추는 게 `solution/backend/scripts/republish_all.py` 다.
⚠️ 남은 것: `azure_static._upload_shared` 는 매 발행마다 `assets/` **전체**를 다시 올린다.
옛 해시를 남기기 시작했으므로 보관 기간만큼 업로드량이 는다. Azure 를 켤 때는 이미 있는
블롭(해시 파일이라 이름이 같으면 내용도 같다)을 건너뛰도록 먼저 고친다.
**규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
```bash
docker compose restart web # 기동하며 전체 재굽기
docker compose logs -f web # "[watch] 기동" 배치가 끝날 때까지 대기
docker compose exec worker python scripts/republish_all.py
docker compose restart solution-prerender # 기동하며 전체 재굽기
docker compose logs -f solution-frontend # "[watch] 기동" 배치가 끝날 때까지 대기
docker compose exec solution-worker python scripts/republish_all.py
```
## 3. 서버에 올리는 순서 (지금 밟는 경로)
@ -91,7 +104,7 @@ docker compose exec worker python scripts/republish_all.py
| 어디 | 무엇 | 기본값 |
|---|---|---|
| 백엔드 | `SITE_PUBLIC_HOST` (`site_payload.py` 의 `DEFAULT_HOST`) | `w4ai.o2o.kr` |
| 백엔드 | `SITE_PUBLIC_HOST` (`site_payload.py` 의 `DEFAULT_HOST`) | `web4ai.o2osolution.ai` |
| 프론트(빌더·`web` 컨테이너) | `VITE_PUBLISH_HOST` | compose 가 루트의 `SITE_PUBLIC_HOST` 를 흘려보낸다 |
★ 프론트 `.env` 에 따로 적지 않는다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
@ -104,14 +117,28 @@ docker compose exec worker python scripts/republish_all.py
프리렌더만 다시 돌리면 옛 주소가 그대로 나온다. 반드시 **백엔드에서 재발행**해 payload 를
다시 만들어야 한다.
### 0단계-b — 구글 로그인도 주소가 정해져야 켜진다
`GOOGLE_CLIENT_ID` 하나를 루트 `.env` 에 적으면 compose 가 백엔드와 프론트
(`VITE_GOOGLE_CLIENT_ID`) 양쪽에 흘려보낸다. **두 곳에 따로 적지 않는다.**
- Google Cloud Console > 사용자 인증 정보 > **OAuth 2.0 클라이언트 ID(웹 애플리케이션)**
- **승인된 JavaScript 원본**에 화면을 여는 주소를 그대로 넣는다(포트까지). 리디렉션 URI 는
쓰지 않는다 — 브라우저가 ID 토큰을 바로 받는 방식(GIS)이다.
- 주소가 바뀌면 원본 목록도 같이 고친다. 안 고치면 **버튼은 뜨는데 눌러도 아무 일이 없다.**
- 비워 두면 구글 로그인만 꺼진다(버튼 자체가 안 뜬다). id/pw 로그인·가입은 그대로 된다.
`VITE_*` 라서 **번들에 구워진다** — 값을 넣거나 바꾸면 `./deploy.sh solution-site` 로 다시 굽는다.
### 1단계 — 서버에서 "굽기"만 재현 (Azure 끔)
배포 대상 서버(접속·경로·이미 물린 포트)는 [SERVERS.md](SERVERS.md) 가 단일 출처다.
```bash
# 서버에서
cp .env.example .env # DB_*, JWT_*, 외부 API 키 채우기
# AZURE_STORAGE_CONNECTION_STRING 은 비워 둔다 ← 이번 단계에서는 안 올린다
# INDEXNOW_KEY 도 비워 둔다 ← 없는 주소를 색인 통보하지 않는다
docker compose up -d
docker compose logs -f worker
docker compose logs -f solution-worker
```
확인: 사이트 1개 발행 → `curl -I http://<서버>:3000/s/<slug>` 200 ·
`out/s/<slug>/index.html` 생성 · `out/payloads/.status/<slug>.json``ok: true`.
@ -119,7 +146,7 @@ docker compose logs -f worker
여기서 막히면 Azure 문제가 아니다. **DB 연결 / payload 디렉토리 마운트 / node_modules** 셋 중 하나다.
### 2단계 — 정적 서빙을 nginx 로 교체 (구현됨)
`serve-sites.mjs` 는 개발용이다. 운영은 `o2o-web4ai-nginx` 컨테이너가 맡는다(`nginx/site.conf`).
`serve-sites.mjs` 는 개발용이다. 운영은 `o2o-web4ai-solution-site` 컨테이너가 맡는다(`nginx/site.conf`).
**산출물은 named volume `site-out` 에 있다.** 프리렌더가 쓰고 nginx·워커가 읽는다.
호스트 경로가 등장하지 않으므로 재배포로 코드를 갈아엎어도 사이트가 죽지 않는다.
@ -127,7 +154,7 @@ docker compose logs -f worker
```bash
docker compose up -d # nginx 포함 전부
docker compose exec nginx ls /usr/share/nginx/html/s # 들여다볼 때
docker compose exec solution-site ls /usr/share/nginx/html/s # 들여다볼 때
```
확인은 스크립트가 한다. 색인을 기다리지 않고 **지금 볼 수 있는 것만** 본다 —
@ -136,7 +163,7 @@ docker compose exec nginx ls /usr/share/nginx/html/s # 들여다볼 때
브라우저로는 절대 안 보인다).
```bash
docker compose exec worker python scripts/check_search_ready.py https://<도메인>
docker compose exec solution-worker python scripts/check_search_ready.py https://<도메인>
```
`/s/<slug>` (끝 슬래시 없음)이 열리는지도 이 스크립트가 본다 — 사장님이 주소창에 치는 형태가 그거다.
@ -145,6 +172,29 @@ docker compose exec worker python scripts/check_search_ready.py https://<도메
`nginx/site.conf` 에 443 과 인증서 볼륨을 추가한다. Blob 단독으로는 커스텀 도메인 TLS 가
안 되지만 nginx 는 Let's Encrypt 로 끝난다 — CDN 을 억지로 끼울 이유가 없다.
### 2-2단계 — 검색엔진 소유확인
**오리진이 하나라 루트에서 한 번만 하면 `/s/<slug>` 전부가 딸려온다.** 사장님이 늘어도
반복하지 않는다 — 오리진을 안 가른 이유가 이거다.
| | 방식 | 어디에 사는가 |
|---|---|---|
| 구글 Search Console | DNS TXT (도메인 속성) | DNS. **이 레포 밖이고 재배포와 무관하다** |
| Bing Webmaster | `BingSiteAuth.xml` | `solution/frontend/public/` → 이미지에 구워진다 |
| 네이버 서치어드바이저 | 메타태그 / HTML 파일 (**DNS TXT 를 안 받는다**) | 아직 안 붙였다 |
**파일 방식은 재배포가 필요하다.** `public/``solution-site` 이미지에 구워지므로
`docker cp` 로 밀어 넣으면 컨테이너 재생성 때 사라진다 — 검색엔진은 인증을 주기적으로
재확인하므로 그때 조용히 풀린다.
**확인은 상태코드가 아니라 내용으로 한다.** `location /``try_files $uri … /index.html`
이라 파일명이 한 글자만 틀려도 404 가 아니라 **빌더 앱 HTML 이 200 으로** 나간다.
검색엔진은 "확인 실패" 만 뱉고 이유를 안 알려준다.
```bash
curl -s https://<도메인>/BingSiteAuth.xml # HTML 이 나오면 파일이 없는 것이다
```
---
## 4. 나중에 — Azure Blob 을 켤 때
@ -161,8 +211,8 @@ AZURE_STORAGE_CONTAINER='$web'
AZURE_STORAGE_PREFIX=
```
```bash
docker compose exec worker python scripts/republish_all.py --dry-run # 대상 확인
docker compose exec worker python scripts/republish_all.py # 전체 업로드
docker compose exec solution-worker python scripts/republish_all.py --dry-run # 대상 확인
docker compose exec solution-worker python scripts/republish_all.py # 전체 업로드
```
확인: `https://<account>.z*.web.core.windows.net/s/<slug>`**CSS 까지 입혀서** 뜨는지.
스타일이 없으면 접두사/경로 문제다(2절 참고).
@ -171,12 +221,12 @@ docker compose exec worker python scripts/republish_all.py # 전체
1. DNS 를 Blob 정적 웹사이트(또는 앞단 CDN)로 연결
2. HTTPS 확인 — 커스텀 도메인 + TLS 는 Blob 단독으로는 안 되고 CDN/Front Door 가 필요하다
3. `SITE_PUBLIC_HOST` 가 실제 도메인과 같은지 재확인 → 다르면 고치고 **전체 재발행**
4. 그 다음에야 `INDEXNOW_KEY` 를 채운다. 백엔드와 `o2o-web4ai-web` 이 **같은 값**이어야 한다
4. 그 다음에야 `INDEXNOW_KEY` 를 채운다. 백엔드와 `o2o-web4ai-solution-frontend` 이 **같은 값**이어야 한다
(프리렌더가 루트에 `<key>.txt` 를 굽고 검색엔진이 대조한다 — 어긋나면 403)
5. `https://<도메인>/<key>.txt``https://<도메인>/robots.txt` 가 열리는지 확인
6. 구글은 IndexNow 미지원 → Search Console 에 `https://<도메인>/sitemap.xml` 수동 제출
## 5. 되돌리기
`out/` 은 재생성물이라 백업이 필요 없다. 문제가 생기면
`docker compose restart web` → 전체 재굽기 → `republish_all.py`.
`docker compose restart solution-prerender` → 전체 재굽기 → `republish_all.py`.
지켜야 할 건 **DB 와 `out/payloads/`** 뿐이다.

View File

@ -40,7 +40,7 @@
| 1차 업종 | 숙박, 법무법인, 성형외과 | 숙박, 카페, 음식점, 관광체험 | **반드시 사업 결정 필요.** 법률·의료를 넣으면 데이터 스키마뿐 아니라 규제·승인·감사 체계가 선행되어야 함 |
| 입력 방식 | 고객 원본 사이트를 depth 3·최대 200페이지 크롤 | 공식 API, 사용자 확정 URL, 정적 HTML; 플랫폼 우회 수집 금지 | 현재의 출처·동의·robots 원칙 유지. 대규모 원본 사이트 수집은 별도 제품 모드로 분리 |
| 동적 크롤링 | 정적 우선, Playwright 폴백 | 봇 탐지 우회로 변질될 수 있어 HeadlessAdapter 미등록·금지 | 포괄적인 Playwright 폴백은 채택하지 않음. 소유권이 검증된 고객 도메인에만 허용할지 법무·보안 결정 후 제한적으로 검토 |
| 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `w4ai.o2o.kr/s/<slug>`, custom domain 경로 미완성 | 설계서 방향이 검색 권위 측면에서 더 적합. 고객 도메인 연결·소유권 검증을 우선 과제로 추가 |
| 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `web4ai.o2osolution.ai/s/<slug>`, custom domain 경로 미완성 | 설계서 방향이 검색 권위 측면에서 더 적합. 고객 도메인 연결·소유권 검증을 우선 과제로 추가 |
| 백엔드 구조 | Site/Brand 엔진, BFF, 엔진별 DB 분리 | 코드 한 벌 + 진입점 둘(:9800 사장님 / :9801 내부), 단일 PostgreSQL | 청중별 분리는 포트로 끝냈다. 엔진별 물리 분리는 트래픽·팀 소유권 근거가 생긴 뒤 수행 |
| 작업 인프라 | Temporal, Redis, Celery 등 공통 인프라 | PostgreSQL 잡 큐 + lease + dead-letter | 현재 DB 큐 유지. 동일 책임의 인프라를 중복 도입하지 않음. 장기 워크플로 보상·분산 추적 요구가 확인될 때 Temporal 재평가 |
| Fact Graph | 엔티티·predicate·snapshot, append-only | 업종 스키마 기반 key-value fact, 후보/노출/이력 상태 | 현재 모델은 발행 안전성에 적합. Brand 측정 재현성에 필요한 snapshot과 entity 관계만 점진적으로 확장 |
@ -64,7 +64,7 @@
| A6 JSON-LD | **구현** | 업종별 JSON-LD, FAQPage, Breadcrumb, WebPage, sameAs | 법률·의료 도입 시 타입·자격·저자 스키마 확장 |
| A7 3-way 일치성 | **부분 구현** | JSON-LD↔표시 텍스트 검증, 생성문↔fact 근거 검사, publish gate | 원본↔발행본 SimHash 중복도, 모든 사실 문장의 fact ID 역참조 보고서 |
| A8 배포 | **대부분 구현** | 프리렌더 정적 HTML, canonical, sitemap, robots, llms.txt, IndexNow, nginx/Azure 경로 | 고객 도메인 서브패스·서브도메인 연결, TLS/DNS 자동화, Search Console 제출 자동화 여부 |
| A9 모니터링·변경 감지 | **미구현** | `ai_check_results` 테이블과 `AI_CHECK` enum은 있으나 worker handler·보고 모듈 없음 | CDN 크롤러 로그 수집, 원본 hash 비교, fact 만료/재검토, 재생성 큐 |
| A9 모니터링·변경 감지 | **미구현** | `AI_CHECK` enum 만 있고 worker handler·보고 모듈 없음. 표(`ai_check_results`)는 한 번도 안 써서 마이그레이션 0006 이 뗐다 — 붙일 때 다시 만든다 | CDN 크롤러 로그 수집, 원본 hash 비교, fact 만료/재검토, 재생성 큐 |
### 3-2. Brand AEO B1~B9

1718
docs/DEVLOG.md Normal file

File diff suppressed because it is too large Load Diff

46
docs/GENERATION_FLOW.md Normal file
View File

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

262
docs/MINI_BLOG.md Normal file
View File

@ -0,0 +1,262 @@
# 미니 블로그 — AI 자동 포스트 생성기 (2026-09-16 기획, 2026-09-17 검수 흐름 개편)
숙소 소개 아래에 붙는 짧은 글 게시판. 사장님에게 최종 결정권이 있다 — **팀 사전검수 단계는
없다.** 메일 링크는 여전히 로그인 없이 쓰고, 대신 빌더 앱에 로그인하면 이번 달 생성된 글
전체를 볼 수 있다.
```
스케줄러(한 달치 생성) → 금칙 필터(자동) → 메일 발송(업장당 하루 한 통, 승인·수정 두 링크)
→ 사장님이 승인(즉시 게재) / 수정(빌더 앱 자동 로그인 모달) → 재발행 → 정적 HTML에 글 추가
※ 두 링크 다 그날 자정(KST) 만료 — 그 뒤엔 로그인해서 빌더 앱에서 처리
(병행) 빌더 앱 로그인 → 블로그 글 화면(탭: 이번 주 · 달력 · 생성 이력) → 언제든 수정·승인
```
## 확정된 것
- 스테이 DB의 숙소 정보로 **140~150자** 홍보 문구를 AI가 만든다 (2026-09-16)
- **텍스트만.** 사진은 넣지 않는다 (2026-09-16)
- 숙소 소개 하단 **미니 블로그** 형식, 글이 쌓이면 **페이지 번호**로 넘긴다 (2026-09-16)
- 갈래를 나눠 생성하고 **이전에 다룬 주제와 중복되지 않게** 한다 (2026-09-16)
- ★ **팀 사전검수 폐지** — 검수는 사장님이 한다. 금칙 필터(자동)를 통과하면 바로 발송
대상이다 (2026-09-17)
- ★ **한 달치를 미리 쌓아 두고, 업장당 하루 한 통씩** 메일로 내보낸다 (2026-09-17)
- ★ 메일의 **승인** 링크는 로그인 없음(토큰이 신원) — 누르는 즉시 승인된다(2026-09-17,
사장님 지시: "승인은 바로 승인 되게 그 링크만 클릭하면"). **수정** 링크는 반대로
로그인 흐름이다 — 그날짜리 자동 로그인 토큰을 실어 보내 빌더 앱의 편집 모달을 그대로
연다(2026-09-17, 사장님 지시: "수정하기는 해당 수정하기 페이지로 가게(모달) 로그인도
크레덴셜로 자동으로 되게"). **두 링크 다 그날 자정(KST) 만료**(2026-09-17, 사장님 지시:
"승인이랑 수정모두 자정에 만료") — 넘기면 로그인해서 빌더 앱에서 처리한다
- ★ 사장님이 문구를 **직접 고쳐서** 승인할 수 있다 — 메일의 수정 링크, 빌더 앱에서도 동일
(2026-09-17)
- ★ 글마다 **배정일(scheduled_date)** 이 있다 — "언제 만들어졌나"만 있고 "언제 낼 것인가"가
없으면 달력 화면이 근거 없는 날짜를 지어내야 한다(2026-09-17). 생성 시 그 업장의 다음
빈 날부터 하루 한 건씩 순서대로 배정한다
## 1. 데이터 — 표 하나
`postgres-init/init-data/init.sql``postgres-init/migrations/` **둘 다** 고친다.
| 칸 | 타입 | 무엇 |
|---|---|---|
| `post_id` | uuid pk | |
| `place_id` | uuid | 어느 업장 |
| `body` | varchar(400) | 본문 140~150자 |
| `topic_kind` | smallint | weather · festival · season · nearby · guide |
| `topic_key` | varchar(120) | 축제 id · 절기 · 장소 id — **중복 방지의 축** |
| `status` | smallint | DRAFT → REVIEWED → SENT → APPROVED → PUBLISHED / SKIPPED |
| `scheduled_date` | date | 이 업장 몫 배정일(KST). 하루 한 통 — 생성 시 순서대로 채운다 (2026-09-17) |
| `generation_meta` | jsonb | 생성 이력 상세 — 지금은 `{"model": "..."}` 하나뿐(사장님 지시: "생성이력도 상세하게
기록해놓으셈 어느 모델썼는지 등등" → "Jsonb 하나팟거 컬럼", 새 컬럼을 안 늘리고 여기 얹는다, 2026-09-17) |
| `approve_token_hash` | varchar(64) | sha256. 평문은 메일에만 |
| `token_expires_at` | timestamptz | 발송 당일 자정(KST) — 수정 링크(day-pass)도 동일(2026-09-17, 이전엔 발송+14일) |
| `sent_at` · `approved_at` · `published_at` | timestamptz | |
| `published_version_id` | uuid | `site_versions` 참조 — 롤백 때 필요 |
유니크: `(place_id, topic_key)` — 같은 축제로 두 번 쓰지 않는다.
유니크: `(place_id, scheduled_date)` — 같은 업장이 같은 날짜를 두 번 차지하지 않는다.
## 2. 생성 — 스케줄러 잡
`scheduler/__init__.py``add_job` 한 줄, 로직은 `scheduler/jobs.py``services/blog_service.py`.
- **주기**: 하루 1회(KST 새벽 04:10). `SCHEDULER_ENABLED=1` 인 프로세스에서만 돈다(이미 그 규약이다)
- **대상**: 발행된 사이트 중 재고(DRAFT+REVIEWED)가 `REFILL_BELOW`(30) 미만인 업장 —
하루 한 통씩 나간다고 보면 한 달치를 채우는 셈이다
- **한 번에 `BATCH_SIZE`(30)건**씩. 앞 회차의 `topic_key` 목록을 프롬프트에 넣어 중복을 막는다
(한 달치를 한 호출로 뽑으면 중복 검사가 안 된다)
- **배정일**: 그 업장의 `MAX(scheduled_date)` 다음날부터(없으면 오늘부터, KST) 하루 한 건씩
순서대로(`blog_jobs._next_scheduled_date`가 아니라 인라인 계산 — `_generate_for_place`).
"지금 생성하기"(수동 트리거)는 사장님이 직접 고른 구간을 채운다 — 같은 소재 선별·게이트
로직을 재사용하지만 배정일이 "다음날부터 자동"이 아니라 "그 구간"이다(`generate_range`,
7절)
- **갈래 분기**: 날씨·축제·계절·주변장소·이용안내. 갈래마다 프롬프트가 다르고,
근거가 되는 값도 다르다(날씨=`local.weather`, 축제=`local.festivals`, 주변=`local.attractions`)
- 프롬프트는 `shared/src/lib/section-prompts.ts` 규약을 따른다 → `npm run export:prompts`
### 게이트를 통과하는 문구만 만든다 — 팀 검수를 대신하는 자리
발행 게이트 규칙 1은 **미검증 fact 를 화면에 내지 않는 것**이다(`services/publish_gate.py`).
홍보 문구가 가격·시설·운영시간을 주장하면 그 주장을 뒷받침할 fact 가 없어 규칙과 부딪힌다.
★ 팀 사전검수가 없어진 지금, 이 필터가 유일한 자동 관문이다.
- 프롬프트에 금칙을 건다: 숫자로 된 가격·시간·인원·전화번호를 쓰지 않는다
- 생성 뒤 기계로 한 번 더 거른다(`blog_service.is_publishable_body`) — 통과하면 곧장
`REVIEWED` 로 쌓인다(사람이 올릴 필요 없음). 실패분은 로그만 남고 버려진다
- 통과한 문구는 **고유 콘텐츠**라 오히려 규칙 2(고유 콘텐츠 ≥ 1)에 보탬이 된다
- 수정 화면(메일·빌더 앱 공통)에서 사장님이 고친 본문도 저장 전에 **같은 필터**를 다시 탄다 —
로그인했다고 우회되지 않는다
## 3. 검수 — 사장님이 한다 (2026-09-17, 팀 사전검수 폐지)
`admin`(:9801)의 1차 검수 화면(`blog_admin.py`·`BlogReviewPage`)은 삭제했다. 최종 판단은
사장님 몫이고, 그 판단은 두 군데서 이뤄진다.
1. **메일** — 업장당 하루 한 통, 승인/수정/넘기기 (4·5절)
2. **빌더 앱 로그인** — 이번 달 생성된 글 전체를 미리 보고 메일이 오기 전에 바로
승인·수정할 수 있다 (7절 "빌더 앱 화면")
## 4. 발송 — 메일
`services/mail_service.py`(2026-09-16 완성, ACS 우선 · SMTP 폴백)를 그대로 쓴다.
- **업장당 하루 한 통.** `PostCRUD.due_for_mail``scheduled_date <= 오늘` 이면서
`DISTINCT ON (place_id)` 로 업장 하나가 밀려 있어도 그날은 가장 이른 배정일 한 통만
고른다(`blog_jobs.send_reviewed`) — 미래 배정일 글은 그날이 오기 전엔 안 나간다
- 본문: 문구 전문 + 승인 링크 + 수정 링크(`blog_jobs._mail_body`)
- **승인 링크**: `GET /v1/site/post/approve?t=<토큰>` — 로그인 없음, 토큰이 신원. **누르는
즉시 승인된다**(확인 화면 없음, 2026-09-17 사장님 지시). 토큰은 32바이트 랜덤 → DB 엔
sha256 만, **단회용 · 그날 자정(KST) 만료**(`blog_service.issue_token`). 승인 확인
화면은 그 업장의 발행된 사이트(미니 블로그 자리, `#blog`)로 5초 뒤 자동 이동한다
(2026-09-22 사장님 지시 — `router/v1/site/post.py _page`, `PostService._blog_url`).
재발행(BUILD 잡)은 몇 분 걸리므로 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다 —
그래도 "어디로 가면 보이는지"는 바로 알려준다. 발행된 사이트가 없으면 자동 이동 없이
확인 문구만 보여준다
- **수정 링크**: `{origin}/blog?placeId=&postId=&auto=<그날짜리 JWT>` — 로그인 흐름이다.
`CreateDayPassToken`(`router/v1/validator/dependencies.py`)이 자정까지만 사는 접근
토큰을 찍고, 빌더 앱이 그 토큰으로 로그인해 그 글의 편집 모달을 바로 연다
(`solution/frontend/src/app/provider.tsx` 세션 복구 단계에서 처리 — `BlogPostsPage` 안이
아니라 라우트 가드보다 먼저인 지점이어야 한다, 2026-09-17 실측: 늦게 처리하면
`RequireAuth` 가 이미 `/login` 으로 튕긴 뒤였다)
- 메일은 평문으로 흐른다 → 승인 링크로 할 수 있는 일은 **그 글 한 건의 게재**뿐이고,
수정 링크로 할 수 있는 일은 **그 글 한 건의 편집·승인**뿐이다(day-pass 토큰도 `user_id`
까지만 담아, 그 사장님의 다른 글은 못 건드리지 않는다 — `PostService.get_post`
`place_id` 불일치를 걸러낸다)
## 5. 승인·수정
- **게재**: 이메일의 승인 링크(로그인 없음, 누르면 즉시 승인) 또는 빌더 앱에 로그인해
"바로 발행" 버튼을 눌러도 승인된다(2026-09-21, 사장님 지시: "이메일 승인으로도 발행
가능하고 바로발행버튼으로도 발행 가능하도록") — 두 경로 다 열려 있다(`post_service.
PostService._approve_and_publish`). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다.
- **승인**: 이메일 GET 은 로그인 없이 즉시 승인, "바로 발행" 은 로그인 세션이 신원 →
둘 다 `status = APPROVED` → BUILD 잡 큐. 이메일 링크의 만료·재사용은 "처리할 수 없는
링크입니다" 안내로 끝낸다(오류 화면을 주지 않는다)
- **쓰레드 연동**: 승인되는 순간(경로 무관) 그 업장이 쓰레드에 연결돼 있으면 같은 문구에
발행 링크를 붙여 쓰레드에도 즉시 게시한다(2026-09-21) — 별도 승인 없음(`docs/DECISIONS.md`
7-1-2 개정, `docs/SOCIAL.md`). 연동 안 돼 있거나 `SOCIAL_POSTING_ENABLED=0`이거나 사이트
domain이 미확정이면 조용히 건너뛴다. 실패해도 미니블로그 승인 자체는 막지 않는다
(`post_service.PostService._try_social_share`)
- **수정**: 빌더 앱 편집 모달에서 저장 → `is_publishable_body` 재검사 → 통과 시 본문만
갱신한다. **승인 전환은 하지 않는다** — 실패하면 사유를 보여주고 다시 고치게 한다,
통과해도 두 승인 경로 중 하나를 눌러야 사이트에 반영된다
- **알림 이메일**: 승인 메일 수신자는 `places.notify_email`(비면 `users.email`) —
계정 로그인 이메일과 분리해서 업장별로 다른 담당자에게 보낼 수 있다(빌더 앱 미니블로그
관리 화면에서 수정, `PATCH /v1/place/{place_id}`)
- ★ BUILD 잡 payload 에는 반드시 `owner_user_id` 가 있어야 한다(`build_service.run_build`
`payload["owner_user_id"]` 를 무조건 읽는다) — 토큰/day-pass 흐름은 일반 로그인
세션과 달라 `post_service.PostService._enqueue_build``place_id` 로 직접 조회해
채운다. 이게 빠져 있던 게 2026-09-17 발견된 버그였다(회귀 테스트: `test_blog_post.py
test_approve_enqueues_build_with_owner_user_id`)
## 6. 게재 — 재발행
`docs/PUBLISH_VERSION.md` 의 파이프라인을 그대로 탄다. payload 에 `posts[]` 를 실어
**그 사이트 하나만** 다시 굽고 새 버전으로 링크를 전환한다. 전체 재굽기가 아니다.
⚠️ **발행일(`publishedAt`)이 움직인다.** 글 한 건 때문에 사이트 갱신일이 바뀌는 것이
맞는지 합의가 필요하다 — 색인에는 유리하지만 "사장님이 발행한 적 없는데 날짜가 바뀐다"는
기존 원칙과 부딪힌다.
## 7. 화면
### 발행된 사이트 — 미니 블로그
`solution/site/src/sections/BlogSection.tsx`, 숙소 소개(`intro`) 바로 아래.
- **글 전부가 HTML 안에 있고 JS 가 10건씩 보여준다.** 페이지를 눌렀을 때 더 불러오지 않는다 —
크롤러는 2페이지를 못 본다
- 사이트 하나 = 한 장 규칙은 유지한다. 주소를 늘리지 않는다
- 글이 100건을 넘으면 그때 별도 주소를 다시 논의한다
- 군산 읽기 전체 노출도 같은 페이지네이션을 쓴다 — 컴포넌트를 한 벌만 만든다
### 빌더 앱 — 이번 달 생성된 글 (2026-09-17)
`solution/frontend/src/pages/BlogPostsPage.tsx`. "내 사이트" 카드의 **관리 메뉴 →
미니블로그 관리**에서 `?placeId=` 를 들고 들어온다(전역 메뉴 하나로는 어느 사이트인지
못 고른다 — 사장님 한 명이 사이트 여럿을 가질 수 있다).
- 백엔드: `GET/PUT /v1/place/{place_id}/post`(`router/v1/site/post.py` `owner_router`,
:9800). 로그인 세션(`IsValidAccessToken`)이 신원이고, `PlaceCRUD.get_place` 로 소유권을
매번 확인한다 — 토큰 흐름과 인증 방식이 다를 뿐 편집 가드(`is_publishable_body`)는 같다
- **아직 메일이 안 나간 `REVIEWED` 글도 여기서 바로 승인·수정할 수 있다**
`PostCRUD._EDITABLE = (SENT, REVIEWED)`. 메일을 기다릴 필요가 없다
- 월 단위 조회(`month=YYYY-MM`, 기본 이번 달, KST 기준) — `scheduled_date` 기준으로 그 달에
배정된 글을 가져온다
- **화면은 탭 둘뿐이다** (2026-09-17, 사장님 지시: "탭을 왜 이번주 달력 이렇게 나누고
지랄이야 내가 언제그러라그랬어 달력위에 이번주 카드들 보여주라고 했지" — 카로셀·달력은
같은 화면에 **항상 같이** 뜬다, "생성 이력"만 별도 탭이다)
1. **블로그(카로셀 + 달력, 항상 같이 보인다).**
- **카로셀** — "오늘·내일 등 일주일치를 보기 편하게" 모은 것(사장님 표현). 달력(월
단위)과 무관하게 **항상 오늘부터 7일치**(`GET .../post/upcoming?days=7`,
`PostService.list_upcoming`, 날짜 오름차순). 카드가 겹쳐 쌓여 있고 가로로 넘기면
하나씩 앞으로 나온다(`PostCarousel`). 마우스를 올린 카드는 안 가려지게 z-index 를
맨 앞으로 올린다. 카드를 누르면 그 자리에서 고치는 게 아니라 **모달**을 연다(사장님
지시: "카드클릭해도 모달나와서 수정가능하게 해야지 왜 바로수정하게해") — 카드 자체는
미리보기(`PostPreviewCard`)뿐이고, 수정·바로 발행은 모달 안(`PostCard`)에서만 한다.
카드마다 배정일을 전부 쓰고, 오늘·내일인 카드에는 그 위에 "오늘"/"내일" chip 을 더 단다
- **달력****이전 달 · 월 · 다음 달** 이 달력 바로 위에 있다(사장님 지시). 이번 달부터
1년 뒤까지만 넘겨볼 수 있다(그 전·그 뒤는 볼 이유가 없다). 글이 0건이어도 칸은 항상
뜬다 — 배정일이 없으면 "이 달에 뭐가 있나"를 훑어볼 기준 자체가 없다. 칸마다 본문
앞부분 스니펫과 **발행완료 · 발행실패 · 발송완료 배지만** 보여준다 — 검수 대기처럼
아직 메일도 안 나간 상태는 아무 표시도 하지 않는다(사장님 지시: "발행전인건 표시하지
말고"), 메일 발송 여부는 크론잡이 실제로 돌았다는 확인이라 따로 보여준다(사장님 지시:
"달력에 발송완료 된거는 되었다고 적으라고"). **칸을 누르면 모달**로 그 글 전체 내용과
편집·발행 버튼을 보여준다
- **빈 날짜(오늘 이후만) 개별 생성** (2026-09-17, 사장님 지시: "그리고 개별적으로 새로
만들수있게 해줘") — 글이 없는 칸을 누르면 `POST .../post/generate-one?date=`
(`PostService.generate_for_date` → `blog_jobs.generate_one_for_date`)가 그 날짜 하나만
채운다. 재고 상한(`REFILL_BELOW`)을 안 본다 — 콕 집은 요청이라 상한이 끼어들 자리가
아니다. 이미 그 날짜에 글이 있으면(유니크 충돌) 조용히 덮지 않고 실패로 답한다.
지난 날짜는 만들 이유가 없어 클릭 자체를 막는다. 성공하면 그 자리에서 모달이 열린다
2. **생성 이력.** 언제 몇 건, 어느 모델로 만들었는지(사장님 지시: "생성이력도 있어야해
몇개 생성했는지" / "생성이력도 상세하게 기록해놓으셈 어느 모델썼는지 등등") —
`GET .../post/history`(`PostCRUD.generation_batches`). 새 컬럼 없이 기존 `created_at`
으로 회차를 묶는다(같은 트랜잭션 안의 `add_many` 는 DB `now()` 가 전부 같다). 모델명은
`generation_meta->>'model'` 의 대표값(`MAX`) 하나 — 한 회차 = 한 모델이 정상이다
- **발행실패 판정**: `PostService._latest_build_failed` — 그 업장의 가장 최근 BUILD 잡이
`JobStatus.DEAD`(재시도 소진)면, APPROVED 인데 아직 안 나간 글에 `build_failed=true`
단다. 글 단위가 아니라 "이 업장 재발행이 지금 막혀 있나" 를 보는 것이다 — BUILD 잡 하나가
그 업장의 승인분 전부를 한 번에 굽기 때문
- ⚠️ **`scheduled_date` 마이그레이션(0019) 전에 만들어진 글은 그 컬럼이 비어 있다.**
월별·주간 조회 둘 다 `scheduled_date` 로 거르므로, 비어 있으면 화면 어디에도 안 뜬다
(실측 2026-09-17: "지금 생성하기"로 만든 실제 글 13건이 이렇게 사라져 보였다). 배포
직후 한 번은 기존 NULL 행에 날짜를 채우는 백필이 필요하다 — 업장별로 `created_at` 순서를
살려 오늘부터 하루씩 순서대로 채운다(1회성, 스크립트로 남기지 않았다).
- **지금 생성하기** 버튼 — `POST /v1/place/{place_id}/post/generate?start=&end=`(사장님 지시:
"지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까"). 버튼을 누르면 시작일·끝일을
캘린더 입력(`<input type="date">`)으로 고르는 다이얼로그가 뜬다(사장님 지시: "캘린더
UI로 날짜받게"). 재고 상한(`REFILL_BELOW`)을 안 본다 — 개별 생성과 같은 이유로, 직접
고른 구간에 상한 로직이 끼어들 자리가 아니다(`blog_jobs.generate_range`). 이미 글이 있는
날짜는 LLM 을 부르지 않고 건너뛰고, 구간 안 소재가 떨어지면 그 자리에서 멈춘다 — 응답에
`requested`(구간 일수)·`created`(실제로 채운 일수)를 같이 줘서 "N일 중 M일만 채웠습니다"로
보여준다. 발행 전 사업장은 애초에 생성 스윕 대상이 아니라(`_published_places`) 여기서도
0건이다. domain 이 아직 확정되지 않은(임시 주소) 사이트도 마찬가지다(2026-09-21 —
쓰레드 연동 요구사항과 맞췄다, `docs/SOCIAL.md` 7-1-1과 동일 기준)
## 8. 진행 (2026-09-17)
| | 자리 | 상태 |
|---|---|---|
| 표 + 마이그레이션 | `migrations/0017_place_posts.sql` · `init.sql` | 완료 |
| 생성 + 금칙 필터 | `services/blog_service.py` | 완료 |
| 생성·발송 스윕 | `services/blog_jobs.py` · `scheduler/jobs.py` | 완료 (새벽 4:10 생성 · 아침 9:00 발송, 업장당 하루 한 통) |
| ~~어드민 검수~~ | ~~`router/v1/site/blog_admin.py`~~ | **폐지(2026-09-17)** — 검수는 사장님이 한다 |
| 메일 + 승인·수정 | `services/mail_service.py` · `services/post_service.py` · `router/v1/site/post.py` | 완료 |
| 빌더 앱 로그인 화면(달력) | `router/v1/site/post.py owner_router` · `site/pages/BlogPostsPage.tsx` | 완료 |
| 배정일(scheduled_date) | `migrations/0019_*.sql` · `blog_jobs._generate_for_place` | 완료 |
| 생성 이력 상세(모델명, generation_meta) | `migrations/0020_*.sql` · `blog_service.generate_one` | 완료 |
| 개별 생성(빈 날짜 하나) | `POST .../post/generate-one` · `blog_jobs.generate_one_for_date` | 완료 |
| payload + 화면 | `site_payload.posts[]` · `site/src/sections/BlogSection.tsx` | 완료 |
| 재발행 연결 | `build_service``mark_published`, `owner_user_id` 버그 수정 | 완료 |
| 쓰레드 자동 게재 | `services/social_service.publish_reused_text` · `post_service._try_social_share` | 완료 |
남은 것: 운영 ACS 에 발신 도메인 등록(지금은 negodata 리소스를 빌려 쓴다),
그리고 6절의 발행일 갱신 합의.
## 안 하는 것
- 사진 첨부 (2026-09-16 회의 확정)
- 글마다 별도 URL·목록 페이지 — 한 장 규칙을 깬다
- 예약 요청 관리 화면 — `booking_request.py` 는 요청을 DB 에 남기지 않는다(2026-09-16
대표 지시). 목록을 만들려면 그 결정부터 바꿔야 한다

View File

@ -92,6 +92,8 @@
## 8. 제약
비용은 사이트당 변동비·계정/계약당 고정비·일회성 개발비로 구분한다. SNS의 Gemini 생성·알림톡 건당 발송은 변동비다. Threads 직접 API에 공개 과금은 확인되지 않았다. 고정비를 사이트 생성 미터에 배분해 배치 크기에 따라 게이트 판정이 달라지게 하지 않는다. [API_USAGE 5절](API_USAGE.md#5-sns-비용-2026-09-14) 참조.
- **제품 원가 상한: 사이트 1건당 $1 (약 1,400원).** Perplexity·Kakao·Gemini 호출 합계.
이 상한이 "LLM 을 몇 번 부를 수 있나"를 정한다. 현황: [API_USAGE.md](API_USAGE.md)
(★ 개발비와 섞지 말 것 — 그건 일회성이다)

32
docs/PUBLISH_VERSION.md Normal file
View File

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

99
docs/SEARCH_CONSOLE.md Normal file
View File

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

View File

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

202
docs/SERVERS.md Normal file
View File

@ -0,0 +1,202 @@
# 킹서버 — 배포 대상 정의
배포 **절차**는 [DEPLOY.md](DEPLOY.md) 3절이다. 이 문서는 그 절차가 "서버에서" 라고만 부르는
**그 서버가 무엇인지**만 적는다. 값은 2026-08-31 에 직접 붙어서 확인한 것이다.
## 접속
```bash
ssh King_admin # ~/.ssh/config 에 정의됨
```
| | |
|---|---|
| 호스트명 | `king` (`172.30.1.36`) — **사설 IP다. 직접 못 닿는다** |
| 계정 | `o2oadmin` |
| 들어가는 문 | `59.14.81.3:14445` → 킹서버 22 **(2026-09-21 신설)** |
★ **14444 와 14445 는 서로 다른 서버로 가는 문이다.**
`14444`**`.21` 서버**로 간다 — 예전에는 그리로 들어가 킹서버로 한 번 더 건너뛰었다
(`ProxyJump`). 인프라가 킹서버 전용 문 `14445` 를 열어 줘서 경유가 없어졌다.
`Confluence`(14444) 항목을 14445 로 **고치면 안 된다.** 그쪽은 `.21` 이 계속 쓴다.
`~/.ssh/config` 는 레포 밖이다. 새로 합류하면 아래를 직접 넣어야 붙는다.
```
Host King_admin
HostName 59.14.81.3
Port 14445
User o2oadmin
IdentityFile ~/.ssh/<본인 >
IdentitiesOnly yes
# 14445 가 막혔을 때의 옛 경로. 경유 서버를 거친다.
Host King_admin_jump
HostName 172.30.1.36
User o2oadmin
ProxyJump Confluence
IdentityFile ~/.ssh/<본인 >
IdentitiesOnly yes
Host Confluence
HostName 59.14.81.3
Port 14444
User o2oadmin
```
**비밀번호로는 못 들어간다.** 키 등록분만 받는다(연구소 인원). 새 사람이 붙으려면 공개키를
등록해야 하고, 그건 이 레포 밖의 일이다.
★ 처음 붙으면 호스트 키 확인을 묻는다. `[59.14.81.3]:14445` 가 SSH 에게는 새 대상이기 때문이다 —
**서버가 바뀐 게 아니다.** 지문이 아래와 같으면 같은 서버다(실측 2026-09-21, SSH 가
`known_hosts``172.30.1.36` 항목과 같은 키라고 스스로 알려 준다).
```
ED25519 SHA256:oa/Nz42Liu0pFPJRnhjeVtfl+ov65aRwC6oVbgXe0/Y
ECDSA SHA256:ZzgVwQvycWW0Id0+4NHbHV/6RM7nu38aXU7jNhAJRgk
```
## 무엇이 올라가 있나
Ubuntu 18.04.6 LTS · 24 core · RAM 125G · Docker 24.0.2 · Docker Compose v2.20.3.
컨테이너 34개가 이미 돈다 (negosium · iquote · triple-pick · infinith · gitea · persona 등).
**우리만 쓰는 서버가 아니다** — 포트와 디스크를 남의 것과 나눠 쓴다.
## 어디에 두나 — `/home/o2oadmin/data2/o2o-site-AEO`
레포는 홈이 아니라 **`data2` 밑**에 둔다. 홈이 있는 루트 디스크와 다른 물리 디스크다.
```
/ 1.8T 중 228G 남음 (87% 사용) ← 여기에 두면 곧 찬다
/mnt/data2 3.6T 중 2.7T 남음 (22% 사용) ← 다른 o2o 프로젝트가 전부 여기 있다
```
`o2o-negosium` · `o2o-iquote` · `o2o-triple-pick` 이 모두 `/home/o2oadmin/data2/<레포명>` 이다.
같은 규약을 따른다. (`git remote` 는 `https://gitea.o2o.kr/Web4ai/o2o-site-AEO.git` — gitea 도
이 서버의 컨테이너다.)
★ 2026-09-03 에 레포를 `castad/o2o-web4ai` 에서 옮겼다. **compose 프로젝트명이
`name: o2o-web4ai` 로 박혀 있어**(docker-compose.yml:3) 디렉토리를 옮겨도 컨테이너·네트워크·
`site-out` 볼륨 이름이 그대로다 — 그래서 발행 산출물이 살아남는다. 반대로 **두 디렉토리에서
동시에 `up` 하면 서로 잡아먹는다.** 옛 `~/data2/o2o-web4ai` 는 롤백용으로 남겨 뒀다.
## 포트 — 이 서버에서 우리가 잡은 자리
**`:80` 은 호스트 nginx 가 이미 물고 있다**(bible-chatbot·o2sound-voucher 를 라우팅 중).
그래서 컴포즈의 포트를 전부 `.env` 로 뽑아 두고, 킹서버에서는 30xxx 대역을 쓴다.
로컬 기본값은 그대로다 — `.env` 를 안 채우면 예전과 똑같이 뜬다.
| 서비스 | 컨테이너 포트 | 킹서버 | bind | 누가 보나 |
|---|---|---|---|---|
| `solution-site` (**공개 진입점**) | 80 | **30030** | 0.0.0.0 | 방문자·사장님 |
| `solution-backend` (솔루션 API) | 9800 | 30032 | **127.0.0.1** | nginx 만 |
| `solution-prerender` (굽기) | — | — | — | — |
| `solution-worker` (잡 러너) | — | — | — | — |
| `admin-frontend` (어드민 화면) | 3002 | 3002 | **127.0.0.1** | 우리 |
| `admin-backend` (어드민 API) | 9801 | 9801 | **127.0.0.1** | 우리 |
**밖으로 열린 포트는 30030 하나다.** 사장님 앱 · 발행 사이트 · API 가 전부 그 뒤에 있다
(`nginx/site.conf`). API 를 따로 열지 않는 이유: 같은 오리진이면 CORS 가 아예 없고,
`robots.txt`·`sitemap.xml` 은 RFC 9309 상 **오리진 루트에서만** 읽힌다.
| 경로 | 어디로 |
|---|---|
| `/` | 사장님 앱 — 이미지에 구워 넣은 정적 번들(`/srv/app`) |
| `/s/<slug>` · `/assets/` · `/robots.txt` · `/sitemap.xml` | `site-out` 볼륨 |
| `/v1/...` · `/healthz` · `/docs` | `solution-backend:9800` |
★ 어드민 둘은 **기본 기동에서 빠져 있다**(compose 프로필). 켤 때는
`docker compose --profile admin up -d`.
내부 둘은 로컬호스트에만 연다 — 포트를 가른 이유가 그거다([AGENTS.md](../AGENTS.md)).
밖에서 볼 땐 터널을 판다:
```bash
ssh -N -L 3002:127.0.0.1:3002 -L 9801:127.0.0.1:9801 King_admin
# → http://localhost:3002
```
**30xxx 는 사내망(172.30.1.x)에서만 닿는다.** 밖에서는 안 열린다 —
`59.14.81.3:30010` 같은 이웃 프로젝트 포트도 바깥에서 막혀 있는 걸 확인했다.
공개하려면 앞단(59.14.81.3)에 포워딩/프록시를 걸어야 하고, 그건 이 레포 밖이다.
**`PUBLIC_API_BASE_URL` 은 브라우저가 부르는 주소다.** 컨테이너 안 주소가 아니다.
비워 두면 `http://localhost:9800` 이라 **서버에 올리는 순간 틀린다**
화면은 뜨는데 API 만 안 되고, 콘솔을 열기 전에는 안 보인다.
## 배포 · 로그
레포 루트에 스크립트 두 개가 있다. 서버에서 실행한다.
```bash
cd ~/data2/o2o-site-AEO
./deploy.sh # 전체 (git pull → build → up -d)
./deploy.sh api # 그 서비스만
./log.sh # 1=전체, 2번부터 개별 컨테이너
./log.sh 1 # 메뉴 없이 바로
```
`deploy.sh api` 는 worker·api-admin 도 함께 갈아끼운다. 셋이 이미지 한 벌을 나눠 쓰기 때문이다 —
안 그러면 옛 코드로 도는 컨테이너가 남는데 셋 다 "살아 있음" 이라 눈으로는 구분이 안 된다.
★ 새 클론(`o2o-site-AEO`)은 gitea 자격증명이 통해서 `./deploy.sh` 를 서버에서 그대로 쓴다.
`o2o-web4ai` 는 안 됐다 — fetch 가 죽으면 deploy.sh 가 리셋을 건너뛰고 디스크 코드로 간다.
## DB
호스트에 PostgreSQL 15(pgvecto)가 `king_postgres_container` 로 떠 있고 `5432` 가 호스트에 열려 있다.
컴포즈가 `DB_HOST` 기본값을 `host.docker.internal` 로 두고 `extra_hosts: host-gateway`
붙여 두었으므로 **컴포즈를 고치지 않고 그대로 닿는다.**
스키마 파일은 **두 벌**이고 둘 다 최신을 유지한다 —
`postgres-init/init-data/init.sql`**새 DB 를 세우는 전체 DDL**,
`postgres-init/migrations/NNNN_*.sql`**이미 데이터가 든 DB** 를 거기까지 끌어올린다.
한쪽만 고치면 새로 세운 DB 와 서버 DB 가 조용히 갈라진다.
**`init.sql` 은 DB 를 처음 만들 때만 돈다**(postgres 이미지의 초기화 훅). 파일에 컬럼을
더해도 서버 DB 에는 들어가지 않는다. 빠뜨리면 **HTTP 는 200 인데 기능만 죽는다**
실측(2026-09-03): `users.provider` 없음 → 로그인 전부 실패, `sites.thumbnail_url` 없음 →
쇼케이스 전부 실패. 실측(2026-09-09): `local.place_contents` 없음 → TourAPI 가 주변 정보를
받아 와도 저장할 곳이 없어 축제·맛집 0건. 셋 다 화면이 아니라 로그를 봐야 보인다.
### 배포할 때 — 코드만 갈면 스키마는 안 따라온다
`postgres-init/` 은 이미지에 굽지 않고 백엔드 컨테이너에 마운트한다(`docker-compose.yml`).
코드 배포와 별개로 돌릴 수 있어야 하기 때문이다.
```bash
cd ~/data2/o2o-site-AEO
./deploy.sh api
docker compose exec solution-backend python scripts/migrate.py --dry-run # 뭐가 돌지 먼저 본다
docker compose exec solution-backend python scripts/migrate.py
```
적용 기록은 `public.schema_migrations` 에 남고 이미 있는 번호는 건너뛴다. 파일은 재실행
안전하게(`IF NOT EXISTS`) 쓰므로 손으로 한 번 더 돌려도 된다.
규칙은 [postgres-init/migrations/README.md](../postgres-init/migrations/README.md).
**2026-09-10 배포는 스키마가 통째로 바뀐다**(`0005`~`0008`). 도메인별 스키마
(`company` · `place` · `fact` · `local` · `site` · `job`)를 걷어내 `public` 한 벌로 폈고
표 이름도 옮겼다 — `place_links``place_channels`, `job.jobs``jobs`, 공용 콘텐츠는
`area_contents` 한 벌, 개인화는 `site_sections` 로 모았다.
**마이그레이션을 안 돌리면 컨테이너는 정상으로 뜨고 가게 등록 · 수집 · 발행만 죽는다** —
없는 표를 부르는 코드는 import 도 기동도 통과하고 그 줄이 실행되는 순간에만 터진다.
## 공개 주소 — `https://web4ai.o2osolution.ai` (2026-09-03 기준)
```
DNS A web4ai.o2osolution.ai → 59.14.81.3
└ 앞단 Apache(2.4.18) → http://172.30.1.36:30030 → solution-site
```
★ 옛 `w4ai.o2o.kr`**쓰지 않는다.** DNS 는 아직 살아 있지만 앞단에 vhost 가 없어
전 경로가 Apache 자체 404 다(인증서도 `CN=actions.o2o.kr`, 2024 만료).
`59.14.81.3` 은 사내망 입구다(`gitea.o2o.kr` 과 같은 IP). 킹서버의 `172.30.1.36` 은 **사설
IP라 DNS 에 못 적는다** — 앞단 vhost 와 인증서는 이 레포 밖이고 인프라 담당이 잡는다.
이 값이 `SITE_PUBLIC_HOST` 이고 canonical·og:url·sitemap·IndexNow 에 전부 들어간다.
바꾸면 **payload JSON 에 구워진 `origin` 까지 재발행**해야 한다([DEPLOY.md 0단계](DEPLOY.md)).
**`.env` 를 고쳐도 화면 주소는 안 바뀐다.** `VITE_*``solution-site` 이미지의 번들에
구워진다 — `./deploy.sh solution-site` 로 다시 굽는다.

141
docs/SOCIAL.md Normal file
View File

@ -0,0 +1,141 @@
# SNS 게재 — Threads
2026-09-14: 사용자 결정으로 X 구현을 제거하고 Threads를 첫 플랫폼으로 선택했다.
API 직접 연동에 공개된 건당 요금·유료 티어는 확인되지 않았다. 영구 무료를 보장한다는 뜻은 아니다.
[Meta 공식 API 컬렉션](https://www.postman.com/meta/threads/documentation/dht3nzz/threads-api)은
앱 생성·사용자 인가·장기 토큰·텍스트 컨테이너/게시 API를 설명한다.
Meta 개발자 문서 일부는 조사 시 429를 반환했다. 실제 앱 권한·최신 한도는 앱 콘솔에서 최종 확인한다.
## 사용 흐름
발행 모달의 **Threads에 알리기 → 소개글 쓰기**로 시작한다. 발행에 자동으로 붙지 않는다.
확인된 fact가 없거나, 사이트가 미발행이거나, 확정 domain/current_version_id가 없으면 생성하지 않는다.
본문은 완결된 짧은 문장과 서버가 계산한 발행 URL이다. 500자에는 링크도 포함한다.
문자열은 NFC로 정규화하고 초과하면 최대 3번 다시 요청한다. 잘라서 게시하지 않는다.
같은 사업장·발행 버전은 성공 이후에도 원고 1건만 유지한다. 초안 생성 실패만 같은 행으로 재시도한다.
미니블로그 승인(이메일 링크 또는 빌더 앱 "바로 발행")도 계정이 연결돼 있으면 같은 문구를
그대로 쓰레드에 낸다(`decided_via='mini_blog'`) — 이 경로는 승인 요청·알림톡을 거치지 않고
바로 `APPROVED`로 들어간다. 미니블로그 승인 자체가 발화 동의로 취급되기 때문이다(2026-09-21,
DECISIONS 7-1-2 개정 — 문구를 그대로 재사용하는 경우에 한정). 쓰레드 전용으로 새로 짓거나
내용을 바꾸는 경로(위 "발행 모달 → 소개글 쓰기")는 여전히 계정 연결 → 승인 요청 → 명시적
승인을 그대로 거친다.
- 계약 없이: 초안 작성, 복사, 화면에서 내용 확인/거절, 만료 후 재요청.
- 실제 연결 이후: Threads 계정 연결 → 게재 승인 요청 → 화면 또는 알림톡 확인 → 명시적 POST 승인 → 게시.
- 계정 미연결 상태의 내용 확인은 게시를 예약하지 않는다. 연결한 뒤 계정을 보여주고 다시 승인받는다.
- 알림톡이 없거나 번호가 없으면 화면만 사용한다. 야간 21:00~08:00 KST에는 화면만 사용한다.
- 발송 실패는 HTTP 502와 카드 오류로 남는다. 초안/승인은 보존하고 재요청은 nonce를 교체한다.
알림톡은 별도 명시적 요청에서 보내므로 초안 잡의 성공이 알림톡 성공을 뜻하지 않는다.
- 사이트 섹션 `social`은 기본 OFF. 켜면 모든 Shell의 main 마지막, footer 앞에 최신 3건을 굽는다.
Threads 글 삭제와 별개인 같은 원고의 사본이며 링크 문구는 **SNS에 올린 글 보기**다.
## 연동 준비 — 한 번만 하는 일
계정 연결은 **두 쪽이 나뉜다.** 우리가 한 번 준비하고(앱 등록), 사장님은 버튼 두 번을 누른다.
### 1. 우리가 한 번 (Meta 앱 콘솔)
1. 개발자 콘솔에서 앱을 만들고 **Threads API** 제품을 추가한다.
★ Threads 자격증명은 페이스북·인스타그램 앱의 것과 **별개**다. Threads 쪽 앱 ID·시크릿을 쓴다.
2. **리디렉션 콜백 URL**`https://<발행호스트>/v1/social/oauth/callback` 을 등록한다.
**https 여야 한다.** `http://localhost` 는 콜백으로 등록되지 않는다 — 로컬에서 끝까지
돌려보려면 터널(cloudflared·ngrok)로 https 주소를 만들어 그 주소를 등록하거나,
https 가 붙어 있는 킹서버에서 확인한다. 이 제약 때문에 **연결만은 로컬 단독으로 검증되지 않는다.**
3. 권한은 `threads_basic` · `threads_content_publish` 둘이다(`services/external/threads.py SCOPES`).
4. **심사 전에는 앱 역할에 추가된 계정만 인가된다.** 시험할 사장님 Threads 계정을 테스터로
먼저 추가한다 — 이걸 빼먹으면 인가 화면까지 가서 거절당하고, 화면에는 `?social=failed` 만 뜬다.
5. 루트 `.env` 에 셋을 채우고 백엔드·워커를 다시 띄운다.
```
THREADS_APP_ID=…
THREADS_APP_SECRET=…
THREADS_REDIRECT_URI=https://<발행호스트>/v1/social/oauth/callback
```
`SOCIAL_TOKEN_SECRET`(Fernet 키)이 없으면 **연결 기능 자체가 꺼진다.** 평문으로 토큰을
보관하는 길은 만들지 않았다. 만드는 법: `python -c "from cryptography.fernet import Fernet;
print(Fernet.generate_key().decode())"`
★ 이 키를 잃어버리면 저장된 토큰을 복호화할 수 없다 — 모든 사장님이 **다시 연결**해야 한다
(그때 `TOKEN_KEY_CHANGED``needs_reauth` 가 된다).
셋 중 하나라도 비면 `connection_enabled=false` 로 내려가 **연결 버튼이 아예 안 뜬다.**
버튼을 눌러도 서버는 `409 SOCIAL_CONNECTION_DISABLED` 로 거절한다 — 반쯤 연결된 상태를 만들지 않는다.
### 2. 사장님이 하는 일 — 연결은 [내 사이트], 게재는 사이트마다
**연결(한 번)**: `/sites` **내 사이트** 화면 위의 `SNS 연동 · Threads` 카드 →
[Threads 계정 연결] → Meta 인가 화면에서 허용 → 돌아오면 카드에 `@핸들` 이 뜬다.
**게재(사이트마다)**: 발행한 사이트의 발행 화면 → [소개글 쓰기] → [승인 요청] → 승인.
**연결 버튼을 사업장 화면에 두지 않는다.** 계정은 `user × provider` 하나인데 버튼이
사업장 안에 있으면 사장님은 **업장마다 연결해야 하는 줄 안다.** 연결은 한 번, 게재는
사이트마다다 — 화면이 그 모양을 그대로 말해야 한다.
★ 앱 자격증명이 없으면 이 카드는 **아예 안 그려진다**(`GET /v1/social/account` 의
`connection_enabled`). 누를 수 없는 버튼을 세워 두면 사장님에게는 고장난 화면이다.
### 3. 연결이 안 될 때 — 어디를 보나
콜백은 **화면에 이유를 내보내지 않는다**(OAuth 응답·state 에 자격증명이 들어 있다).
대신 서버 로그에 남는다:
```
docker compose logs -f solution-backend | grep "\[social\]"
[social] 계정 연결 실패: SocialError: INVALID_OAUTH_STATE ← 쿠키 유실·10분 만료
[social] 계정 연결 실패: SocialError: THREADS_REJECTED_400 ← 앱 ID/시크릿·리디렉션 URI 불일치
[social] 계정 연결 중단(제공자 응답): access_denied ← 사장님이 인가를 취소함
```
★ 쿠키는 `Secure` 다. https 가 아닌 호스트(예: 사내 IP 로 직접 접속)에서는 브라우저가 쿠키를
저장하지 않아 **항상 `INVALID_OAUTH_STATE`** 가 된다. 원인이 화면에 안 보이는 종류라 여기 적어 둔다.
## 보완한 안전장치
**POSTING 10분 경과는 UNKNOWN**이다. APPROVED로 되돌리면 응답 유실/프로세스 종료 때 중복 게시한다.
동일하게 API 성공 이후 DB 커밋 실패도 UNKNOWN으로 남긴다. 사람이 Threads에서 실제 결과를 확인해야 한다.
UNKNOWN에는 재게시 버튼이 없다. 플랫폼이 명확히 거절한 FAILED만 새 승인을 받을 수 있다.
게시 ID를 받았으면 permalink 조회 실패에도 POSTED로 기록하고 링크 없이 소식을 보여준다.
승인은 nonce 32바이트의 SHA-256과 PENDING_APPROVAL 조건부 UPDATE를 쓴다.
JWT_ACCESS_SECRET·로그인 토큰·사용자 role은 승인 URL에 들어가지 않는다.
승인 전이와 SOCIAL_POST 큐 삽입은 한 트랜잭션이다. GET은 만료 상태를 변경하지 않는다.
화면에서도 시각으로 만료를 표시하므로 스윕 지연이 승인 가능 표시로 이어지지 않는다.
재연결로 account_id가 바뀌면 옛 승인으로 게시할 수 없다. 게시 직전 소유자·사이트 상태·주소·계정을 재검사한다.
계정은 user에 붙는다. 연결 교체·해제·갱신·게시는 user/provider DB 잠금을 공유한다.
토큰은 Fernet 암호문만 저장하고 키가 없거나 형식이 잘못되면 연결하지 않는다.
OAuth state도 암호화하고 10분 TTL과 HttpOnly/Secure/SameSite 쿠키로 요청 브라우저에 묶는다.
Threads는 X의 offline.access/refresh_token을 쓰지 않는다. 장기 access token 자체를 갱신하며
만료·거절·저장 실패는 재연결 대상으로 처리한다. 자동 주기 갱신은 아직 없으므로 장기 미사용 뒤에는 다시 연결한다.
연결 해제는 보관 토큰을 제거하고 모든 업장의 이후 게시를 막는다. Threads에 이미 쓴 글은 지우지 않는다.
## 활성화 전 확인
기본 `SOCIAL_POSTING_ENABLED=0`. 지금 실행하지 않은 외부 작업은 다음과 같다.
1. Meta 앱 등록, 사용자 계정용 `threads_basic`·`threads_content_publish` 권한 심사와 테스트 계정 실게시.
2. HTTPS OAuth callback과 동일 오리진 쿠키 동작, 보안 키 보관·복원 절차 확인.
3. DECISIONS 1-4의 해지 안내 페이지 구현/검증. 현재 사이트 상태 전이만으로는 안내 HTML이 재생성되지 않는다.
이 선행조건을 해결하기 전에 운영 자동 게재를 활성화하지 않는다.
4. 알림톡 대행사 확정·발신프로필·템플릿 심사. 초안 리소스는 `services/resources/social_approval.json`.
승인 주소는 `#{승인주소}` 버튼 변수에 연결하고 문구는 심사본과 맞춘다. 아직 심사받은 템플릿이 아니다.
5. 발신번호·단가·야간 정책·24시간 만료를 운영 정책으로 확정.
`nginx/site.conf.example``/approve/`·`/v1/social/` 블록을 실제 설정에도 반영한다.
앞단 프록시도 query string을 기록하지 않아야 한다. 앱 승인 페이지와 API는 no-store/no-referrer다.
마이그레이션 0012/0013 적용 후 빌더/API/워커/프리렌더를 배포한다.
발행 렌더러 변경은 전체 재굽기와 `republish_all.py`가 필요하며 payload 없는 목업은 대상이 아니다.
## API
| 메서드/경로 | 역할 |
|---|---|
| GET /v1/social/place/{place_id} | 소유자 범위 원고 목록·계정 표시 |
| POST /v1/social/place/{place_id}/draft | 초안/잡 원자 생성, 같은 버전 재사용 |
| POST /v1/social/posts/{post_id}/request-approval | nonce 발급·계정 고정·선택적 알림톡 |
| POST /v1/social/posts/{post_id}/decision | 로그인한 소유자의 화면 승인/거절 |
| GET /v1/social/approval/{post_id}?t=… | 무인증 읽기 전용 확인 |
| POST /v1/social/approval/{post_id}/decision | `{t, approve}` 일회성 결정 |
| GET /v1/social/account | 연결 상태만 — 사업장을 안 고르고 답한다(내 사이트 카드) |
| POST /v1/social/oauth/connect | Threads 인가 URL·브라우저 쿠키 발급 |
| GET /v1/social/oauth/callback | 코드 교환·암호문 보관 |
| POST /v1/social/oauth/disconnect | user 단위 모든 사업장 연결 해제 |

11
docs/TEAMS_WEBHOOK.md Normal file
View File

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

81
docs/WEATHER.md Normal file
View File

@ -0,0 +1,81 @@
# 오늘의 날씨
`Open-Meteo → /v1/local/weather → useLiveWeather → WeatherSection`
관측값은 기존 API를 사용하며 브라우저에서 10분마다 갱신한다. 조회 실패 시 마지막 관측값과
관측 시각을 유지한다. 날씨 문구는 API 요청마다 생성하지 않는다. **API 키 없음** — Open-Meteo
는 키 발급 없이 쓰는 무료 공개 엔드포인트다(`services/external/open_meteo.py`).
## Open-Meteo 응답 → 내부 스냅샷
`GET https://api.open-meteo.com/v1/forecast?latitude=&longitude=&current=temperature_2m,weather_code,wind_speed_10m&timezone=auto`
원본 `current` 블록(`temperature_2m`·`weather_code`·`wind_speed_10m`·`time`)을 어댑터가
`{temperature, weather_code, wind_speed, observed_at, timezone, latitude, longitude}`
정규화한다(`open_meteo.py:fetch_current_weather`). `weather_code`는 WMO 표준 정수 코드 그대로
저장·전달되고, 하늘 상태 문구로 바꾸는 건 아래 두 곳뿐이다 — **반드시 같은 표여야 한다**
(하이드레이션 전엔 백엔드 값, 후엔 브라우저 값을 쓰는데 표가 다르면 같은 날씨인데 문구가 바뀐다):
- 서버: `site_payload._WEATHER_CONDITION_BY_CODE` (조회는 `_weather_condition()`) — 프리렌더 스냅샷에 쓰인다.
- 브라우저: `use-live-weather.ts:WEATHER_CONDITION_BY_CODE` (조회는 `condition()`) — 10분마다 재조회할 때 쓰인다.
둘 다 **코드마다 고유 라벨**을 반환하는 딕셔너리 조회다(구간 검사가 아니다) — 코드 하나가
분류 하나에 정확히 대응하므로 "51~57 은 다 이슬비" 식으로 뭉치지 않는다.
| 코드 | WMO 의미(영어) | 분류(=조건 라벨) |
|---|---|---|
| 0 | Clear sky | 맑음 |
| 1 | Mainly clear | 대체로 맑음 |
| 2 | Partly cloudy | 구름 조금 |
| 3 | Overcast | 흐림 |
| 45 | Fog | 안개 |
| 48 | Depositing rime fog | 착빙성 안개 |
| 51 | Drizzle: Light intensity | 가벼운 이슬비 |
| 53 | Drizzle: Moderate intensity | 보통 이슬비 |
| 55 | Drizzle: Dense intensity | 강한 이슬비 |
| 56 | Freezing Drizzle: Light intensity | 가벼운 착빙성 이슬비 |
| 57 | Freezing Drizzle: Dense intensity | 강한 착빙성 이슬비 |
| 61 | Rain: Slight intensity | 약한 비 |
| 63 | Rain: Moderate intensity | 보통 비 |
| 65 | Rain: Heavy intensity | 강한 비 |
| 66 | Freezing Rain: Light intensity | 약한 착빙성 비 |
| 67 | Freezing Rain: Heavy intensity | 강한 착빙성 비 |
| 71 | Snow fall: Slight intensity | 약한 눈 |
| 73 | Snow fall: Moderate intensity | 보통 눈 |
| 75 | Snow fall: Heavy intensity | 강한 눈 |
| 77 | Snow grains | 싸라기눈 |
| 80 | Rain showers: Slight | 약한 소나기 |
| 81 | Rain showers: Moderate | 보통 소나기 |
| 82 | Rain showers: Violent | 강한 소나기 |
| 85 | Snow showers: Slight | 약한 소나기눈 |
| 86 | Snow showers: Heavy | 강한 소나기눈 |
| 95 | Thunderstorm: Slight or moderate | 뇌우 |
| 96 | Thunderstorm with slight hail | 약한 우박 뇌우 |
| 99 | Thunderstorm with heavy hail | 강한 우박 뇌우 |
이 28개가 Open-Meteo `weather_code`의 전체 정의 값이다 — 표에 없는 값(파싱 실패 포함)만
안전하게 `흐림`으로 떨어진다(실제로는 도달하지 않는 방어 분기).
`weatherMood()`(`derive.ts`)는 위 28종을 화면 배경 그림용으로 다시 4종(맑음/흐림/비/눈)으로
뭉친다 — 정규식 기반이라 새 분류를 추가해도 대개 자동으로 걸린다(예: "가벼운 착빙성 이슬비"는
`/비|우|소나기/` 패턴에 "비"가 들어 있어 `비`로 걸리고, "약한 소나기눈"은 `/눈|설/` 이 먼저 걸려
`눈`이 된다 — 검사 순서가 그래서 중요하다). `WeatherSection``skyKey`는 노트에 그 조건 키가
실제로 있으면(`notes?.noteSets?.[condition]`) 그 조건 그대로 쓰고, 없으면(옛 payload 등)
`mood`로 내려간다 — 화이트리스트를 따로 유지하지 않는 일반화된 조회다.
`weather_notes.json → weather_notes.py → site_payload._weather → noteSets/tempNoteSets`
하늘 28종(위 표의 "분류" 열 전체)·기온 5구간에 각 5문구를 싣는다(28×5+5×5=165줄, 전부
고유해야 순환이 막히지 않는다). 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고,
브라우저에서는 무작위 시작 후 20초마다 하늘·기온 두 줄을 한 타이머로 같이 골라 한 바퀴 안에서
중복 없이 순환한다(`useWeatherNotes`). 기온 구간은 기존 `weatherBand`의 30·25·20·10도다.
**세분화 원칙**: 강도(약/보통/강)만 다른 코드도 문구를 따로 쓴다 — 약한 비는 "우산 하나면
충분", 강한 비는 "이동을 미루라"처럼 안내 자체가 달라지기 때문이다. 착빙성(어는 비/이슬비)은
안개·비·이슬비와 별도로 갈랐다 — 노면 결빙이라는, 세기와는 다른 축의 위험이라 "도로가
얼어붙을 수 있으니" 식의 안전 안내가 필요하다(2026-09-18).
목업 README 2.3의 순환 계약을 제품으로 옮겼다. 군산 전용 시설·장소를 다른 사업장에 복사하지
않도록 기본 문구는 장소·시설·영업시간을 주장하지 않는 공통 안내로 구성한다. **LLM 생성이 아니다.**
지역별 장소 추천을 자동 생성하는 작업은 포함하지 않았다. 목업의 수기 문구·산출물은 변경하지 않는다.
옛 단일 `note`·`notes`·`tempNotes` payload도 계속 지원한다. 이미 발행된 사이트는 재발행해야 반영된다.

87
log.sh Executable file
View File

@ -0,0 +1,87 @@
#!/usr/bin/env bash
# 로그 보기.
#
# ./log.sh 메뉴에서 고른다
# ./log.sh 1 1번 = 전체 로그
# ./log.sh 4 4번 컨테이너
# ./log.sh solution-backend 이름으로 바로
#
# -n <줄> 처음에 보여줄 줄 수 (기본 200)
# --no-f 따라가지 않고 지금까지만 찍고 끝낸다
set -euo pipefail
cd "$(dirname "$0")"
TAIL=200
FOLLOW=1
PICK=""
while [ $# -gt 0 ]; do
case "$1" in
-n) TAIL="$2"; shift ;;
--no-f|--no-follow) FOLLOW=0 ;;
-h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; exit 0 ;;
-*) echo "모르는 옵션: $1" >&2; exit 2 ;;
*) PICK="$1" ;;
esac
shift
done
# 번호가 흔들리면 손이 기억한 번호가 다른 컨테이너를 연다 — 순서를 코드로 못 박는다.
ORDER=(solution-backend solution-worker solution-frontend solution-site admin-backend admin-frontend)
AVAILABLE=$(docker compose config --services)
SVCS=()
for s in "${ORDER[@]}"; do grep -qx "$s" <<<"$AVAILABLE" && SVCS+=("$s"); done
# compose 에 서비스가 늘면 뒤에 붙는다(빠뜨리지 않으려고).
while read -r s; do
[ -n "$s" ] || continue
printf '%s\n' "${SVCS[@]}" | grep -qx "$s" || SVCS+=("$s")
done <<<"$AVAILABLE"
# 살아 있는지 한눈에 — 죽은 걸 고르고 "로그가 안 나온다" 하는 자리를 없앤다.
# ★ --format '{{.Service}}' 은 쓰지 않는다. compose v2.20 이 커스텀 템플릿을 파싱하지 못해
# 전부 "미기동" 으로 보인다(명령은 성공해서 틀린 줄 모른다).
RUNNING=$(docker compose ps --services --filter status=running 2>/dev/null || true)
status() {
grep -qx "$1" <<<"$RUNNING" && printf '실행중' || printf '멈춤'
}
menu() {
echo
echo " o2o-web4ai 로그"
echo " ─────────────────────────────"
printf " 1) %-18s %s\n" "전체" ""
local i=2
for s in "${SVCS[@]}"; do
printf " %2d) %-18s %s\n" "$i" "$s" "[$(status "$s")]"
i=$((i + 1))
done
echo " q) 나가기"
echo
}
resolve() { # 번호 또는 이름 → docker compose logs 인자
local pick="$1"
if [ "$pick" = "1" ]; then TARGET=(); return; fi
if [[ "$pick" =~ ^[0-9]+$ ]]; then
local idx=$((pick - 2))
[ "$idx" -ge 0 ] && [ "$idx" -lt "${#SVCS[@]}" ] || { echo "그런 번호가 없다: $pick" >&2; return 1; }
TARGET=("${SVCS[$idx]}"); return
fi
local name="${pick#o2o-web4ai-}"
printf '%s\n' "${SVCS[@]}" | grep -qx "$name" || { echo "그런 컨테이너가 없다: $pick" >&2; return 1; }
TARGET=("$name")
}
if [ -z "$PICK" ]; then
menu
read -rp " 번호: " PICK
[ "$PICK" = "q" ] && exit 0
fi
TARGET=()
resolve "$PICK" || exit 2
ARGS=(--tail="$TAIL")
[ "$FOLLOW" = 1 ] && ARGS+=(-f)
echo "▶ docker compose logs ${ARGS[*]} ${TARGET[*]:-(전체)}"
exec docker compose logs "${ARGS[@]}" ${TARGET+"${TARGET[@]}"}

47
nginx/Dockerfile Normal file
View File

@ -0,0 +1,47 @@
# 공개 진입점 이미지 — 사장님 앱 정적 번들을 구워 넣고, nginx 가 그것과 발행 사이트를 함께 준다.
#
# ★ 왜 dev 서버를 안 띄우나
# 운영에 `vite dev` 를 두면 요청마다 트랜스파일하고, 기동이 `npm install` 네트워크에 의존하고,
# `/src/*` 원본과 소스맵이 그대로 나간다. 실측(킹서버): 컨테이너 기동에 npm install 이 걸려
# 재기동 시간이 네트워크 상태에 좌우됐다.
#
# ★ 발행 사이트(`/s/...`)는 이 이미지에 안 들어간다. named volume(site-out)에서 읽는다 —
# 사이트가 늘 때마다 이미지를 다시 굽지 않는다.
FROM node:24-alpine AS build
WORKDIR /app
# 의존성 레이어를 소스와 분리한다. 소스만 바뀌면 npm ci 를 건너뛴다.
COPY package.json package-lock.json tsconfig.base.json ./
COPY solution/shared/package.json solution/shared/
COPY solution/frontend/package.json solution/frontend/
COPY solution/site/package.json solution/site/
COPY admin/frontend/package.json admin/frontend/
RUN npm ci
COPY solution ./solution
COPY admin ./admin
# ★ VITE_* 는 **빌드 시점에 번들로 구워진다.** 런타임 환경변수로는 못 바꾼다 —
# 주소를 바꾸면 이 이미지를 다시 빌드해야 한다(`./deploy.sh solution-site`).
ARG VITE_API_BASE_URL
ARG VITE_PUBLISH_HOST
ARG VITE_SITE_PREVIEW_URL
# 구글 OAuth 클라이언트 ID. 비밀이 아니라 번들에 들어가도 된다 — 다만 백엔드 GOOGLE_CLIENT_ID 와
# 같은 값이어야 한다(백엔드가 이 값으로 토큰의 aud 를 대조한다).
ARG VITE_GOOGLE_CLIENT_ID
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \
VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL \
VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
# ★ VITE_AUTO_LOGIN_ID·PW 를 여기서 **절대 받지 않는다.** 이 이미지가 사장님에게 열리는
# 운영 진입점(solution-site)이다 — 자동 로그인 계정이 번들에 구워지면 페이지를 연 누구나
# JS 에서 그대로 읽는다. 내부 테스트용 자동 로그인은 solution-frontend(--profile dev,
# vite dev)에만 있다 — 그쪽은 이 Dockerfile 을 타지 않는다(lib/autoSession.ts 의 DEV 가드도
# 같은 이유로 있다 — 이 ARG 가 실수로 되돌아와도 프로덕션 빌드에서는 죽은 코드가 된다).
RUN npm run build -w @o2o/frontend
FROM nginx:alpine
# ★ 프레임워크 모드(React Router)의 산출물은 `build/client` 다 — 예전 `dist` 가 아니다.
# 경로가 어긋나면 COPY 가 조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다.
COPY --from=build /app/solution/frontend/build/client /srv/app

View File

@ -0,0 +1,29 @@
# ★ 루트 `.dockerignore` 는 **백엔드 이미지용**이라 solution/frontend·site·shared 를 통째로
# 잘라낸다. 같은 컨텍스트를 쓰는 이 이미지가 그걸 물려받으면 COPY 가 "not found" 로 죽는다.
# BuildKit 은 `<Dockerfile 경로>.dockerignore` 를 먼저 보므로 여기서 따로 잡는다.
.git/
node_modules/
**/node_modules/
**/dist/
**/.vite/
# 파이썬 백엔드는 이 이미지에 들어갈 이유가 없다.
solution/backend/
postgres-init/
**/__pycache__/
*.pyc
**/.pytest_cache/
.venv/
**/.venv/
# 발행 산출물은 볼륨에서 온다. 이미지에 구우면 사이트가 늘 때마다 이미지가 붓는다.
solution/site/out/
solution/site/payloads/
docs/
**/*.md
# 시크릿 — 이미지에 굽지 않는다. VITE_* 는 build args 로 준다.
.env
.env.*
!.env.example

View File

@ -1,79 +0,0 @@
# 발행 사이트 정적 서빙.
#
# ★ 산출물은 named volume(site-out)으로 들어온다. 프리렌더 컨테이너가 쓰고 여기서 읽기만 한다 —
# 호스트 경로가 등장하지 않으므로 재배포로 코드를 갈아엎어도 사이트가 죽지 않는다.
#
# ★ 규칙은 하나뿐이다: 디렉토리 요청 → index.html.
# `/s/butter` (끝 슬래시 없음)가 사장님이 주소창에 치는 형태다. 이게 404 면
# "발행했는데 안 나온다"가 된다.
server {
listen 80;
listen [::]:80;
server_name _;
# ★ nginx 이미지의 기본 문서 루트(/usr/share/nginx/html)를 쓰지 않는다.
# named volume 을 거기 마운트하면 Docker 가 **이미지에 들어 있던 index.html 을
# 빈 볼륨으로 복사한다** — 그러면 오리진 루트가 "Welcome to nginx!" 를 띄우고,
# 그게 크롤러에 잡힌다. 빈 경로에 마운트하면 복사될 것이 없다.
root /srv/sites;
# index 지시를 끈다. 오리진 루트에는 페이지가 없다(사이트는 /s/<slug> 아래에 있다).
index index.html;
charset utf-8;
server_tokens off;
# 텍스트 산출물은 압축이 크게 먹는다(HTML 55KB → 10KB 안팎).
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_vary on;
gzip_types
text/plain text/css text/xml
application/javascript application/json application/xml
image/svg+xml;
# ── 공용 번들 ──────────────────────────────────────────────
# 파일명에 해시가 박혀 있다. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다.
# ★ expires 와 add_header 를 함께 쓰면 Cache-Control 헤더가 두 줄로 나간다.
# 합쳐서 읽히긴 하지만 의도가 흐려지므로 add_header 하나로 통일한다.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
try_files $uri =404;
}
location /fonts/ {
add_header Cache-Control "public, max-age=604800";
access_log off;
try_files $uri =404;
}
# ── 크롤러가 읽는 파일 ─────────────────────────────────────
# 발행하면 곧바로 반영되어야 한다. 길게 캐시하면 새 사업장이 사이트맵에 들어가도
# 크롤러가 옛 파일을 계속 본다.
location = /robots.txt {
add_header Cache-Control "public, max-age=300, must-revalidate";
}
location = /sitemap.xml {
add_header Cache-Control "public, max-age=300, must-revalidate";
}
# ── 발행 사이트 ────────────────────────────────────────────
location / {
# $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를
# 한 번 더 타야 하므로 index.html 을 바로 준다.
try_files $uri $uri/index.html =404;
add_header Cache-Control "public, max-age=300, must-revalidate";
}
# 발행되지 않은 주소. 사장님이 오타를 냈을 때 흰 화면 대신 이유를 보여준다.
error_page 404 /404.html;
location = /404.html {
internal;
return 404 '<!doctype html><html lang="ko"><meta charset="utf-8"><title>페이지를 찾을 수 없습니다</title><body style="font-family:system-ui;padding:3rem;text-align:center"><h1>페이지를 찾을 수 없습니다</h1><p>주소를 다시 확인해 주세요.</p></body></html>';
add_header Content-Type "text/html; charset=utf-8";
}
}

View File

@ -1,11 +1,17 @@
# 발행 사이트 정적 서빙.
# 공개 진입점 하나. 사장님 앱 · 발행 사이트 · API 가 **같은 오리진**을 쓴다.
#
# ★ 산출물은 named volume(site-out)으로 들어온다. 프리렌더 컨테이너가 쓰고 여기서 읽기만 한다 —
# / → 사장님 앱 (이미지에 구워 넣은 정적 번들, /srv/app)
# /s/<slug> → 발행 사이트 (site-out 볼륨에서 정적)
# /assets/ → 발행본 공용 번들 (정적)
# /robots.txt · /sitemap.xml → 크롤러가 읽는 파일 (정적)
# /v1/... /healthz → API(solution-backend:9800)
#
# ★ 오리진을 가르지 않는 이유: robots.txt·sitemap.xml 은 RFC 9309 상 **오리진 루트에서만**
# 읽힌다. 앱과 사이트를 다른 호스트에 두면 인증서도 DNS 도 두 벌이 되고 CORS 가 붙는다.
# 개발에서는 Vite 프록시가 같은 일을 한다(solution/frontend/vite.config.ts).
#
# ★ 산출물은 named volume(site-out)으로 들어온다. 프리렌더가 쓰고 여기서 읽기만 한다 —
# 호스트 경로가 등장하지 않으므로 재배포로 코드를 갈아엎어도 사이트가 죽지 않는다.
#
# ★ 규칙은 하나뿐이다: 디렉토리 요청 → index.html.
# `/s/butter` (끝 슬래시 없음)가 사장님이 주소창에 치는 형태다. 이게 404 면
# "발행했는데 안 나온다"가 된다.
server {
listen 80;
@ -18,11 +24,14 @@ server {
# 그게 크롤러에 잡힌다. 빈 경로에 마운트하면 복사될 것이 없다.
root /srv/sites;
# index 지시를 끈다. 오리진 루트에는 페이지가 없다(사이트는 /s/<slug> 아래에 있다).
index index.html;
charset utf-8;
server_tokens off;
client_max_body_size 20m;
# ★ Docker 내장 DNS. 업스트림을 변수로 두면 nginx 가 **기동할 때** 이름을 풀지 않는다 —
# 안 그러면 solution-backend 가 아직 안 떴을 때 nginx 자체가 죽는다.
resolver 127.0.0.11 valid=10s ipv6=off;
set $api http://solution-backend:9800;
# 텍스트 산출물은 압축이 크게 먹는다(HTML 55KB → 10KB 안팎).
gzip on;
@ -34,19 +43,84 @@ server {
application/javascript application/json application/xml
image/svg+xml;
# ── 공용 번들 ──────────────────────────────────────────────
# 승인 nonce 가 액세스 로그·Referer·검색 색인으로 새지 않게 이 자리만 따로 준다.
#
# ★ `try_files` 를 쓰면 헤더가 사라진다. try_files 의 폴백은 **내부 리다이렉트**라
# 요청이 이 블록을 떠나 `location /` 로 다시 들어가고, 거기서 나가는 응답에는
# 아래 add_header 가 하나도 붙지 않는다(실측 2026-09-14: 200 은 뜨는데 헤더만 없다).
# `rewrite ... break` 는 같은 블록 안에 머문다 — 그래서 이 모양이어야 한다.
# ★ 승인 링크는 SPA 한 장이라 파일을 찾아 줄 일이 없다. 곧바로 셸을 준다.
location ^~ /approve/ {
root /srv/app;
access_log off;
add_header Referrer-Policy "no-referrer" always;
add_header Cache-Control "no-store" always;
add_header X-Robots-Tag "noindex, nofollow" always;
rewrite ^ /__spa-fallback.html break;
}
# ── 발행 사이트 ────────────────────────────────────────────
# ★ 리다이렉트는 상대 Location 으로 낸다. 기본값(absolute_redirect on)은 `$scheme` 로
# 절대 URL 을 만드는데, TLS 는 앞단 Apache 가 끊으므로 여기 `$scheme` 는 늘 `http` 다 —
# `/s/` 를 접으면 https 페이지가 http 로 내려가는 리다이렉트가 나간다.
absolute_redirect off;
# ★ 발행본 목록의 정본 주소는 **`/s`** 다 — 슬러그 페이지(`/s/<slug>`)와 형태를 맞춘다.
# 이 블록이 없으면 `/s` 는 `^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어지고
# **빌더 SPA 셸이 200 으로 나간다.** 404 도 목록도 아닌 세 번째 페이지가 크롤러에
# 잡힌다(실측 2026-09-08: `/s` 3.1KB 앱 셸 · `/s/` 6.7KB 목록).
location = /s {
root /srv/sites;
try_files /s/index.html =404;
add_header Cache-Control "public, max-age=300, must-revalidate";
}
# 옛 주소. 사이트맵·서치콘솔에 `/s/` 로 제출된 것이 남아 있다.
location = /s/ {
return 301 /s;
}
# ^~ 로 잡아 아래 정규식 location 들이 끼어들지 못하게 한다.
location ^~ /s/ {
# ★ `/s/<slug>/`(끝 슬래시) 를 위해 필요하다. try_files 의 첫 인자 `$uri` 가 끝
# 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 —
# index 지시자가 없으면 그 순간 403 이다(=404 로도 안 떨어진다).
index index.html;
# $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를
# 한 번 더 타야 하므로 index.html 을 바로 준다.
try_files $uri $uri/index.html =404;
add_header Cache-Control "public, max-age=300, must-revalidate";
}
# 파일명에 해시가 박혀 있다. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다.
# ★ expires 와 add_header 를 함께 쓰면 Cache-Control 헤더가 두 줄로 나간다.
# 합쳐서 읽히긴 하지만 의도가 흐려지므로 add_header 하나로 통일한다.
location /assets/ {
# 빌더 미리보기 셸. **발행본 번들**을 띄우는 CSR 한 장이다
# (`solution/site/scripts/prerender.writePreviewShell`).
# ★ 빌더 SPA(`location /`)로 떨어지면 안 된다 — 거기로 가면 미리보기 안에 빌더가 또 뜬다.
# ★ iframe 으로 여는 이유는 뷰포트다. 빌더 안에 직접 그리면 미디어 쿼리가 창 폭을 봐서
# 그리드 컬럼 수가 발행본과 달라진다(실측 89% 픽셀 차이 — SitePreview 머리주석).
location = /preview {
root /srv/sites;
try_files /preview/index.html =404;
add_header Cache-Control "no-store" always;
}
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
try_files $uri =404;
}
location /fonts/ {
# ★ 빌더와 발행본이 `/fonts/` 를 **각자** 쓴다(vite.config.ts 주석). 발행본을 먼저 보고
# 없으면 빌더 것으로 떨어뜨린다 — 한쪽만 잡으면 다른 쪽 폰트가 조용히 404 다.
location ^~ /fonts/ {
root /srv/sites;
add_header Cache-Control "public, max-age=604800";
access_log off;
try_files $uri @app_fonts;
}
location @app_fonts {
root /srv/app;
access_log off;
try_files $uri =404;
}
@ -55,18 +129,71 @@ server {
# 크롤러가 옛 파일을 계속 본다.
location = /robots.txt {
add_header Cache-Control "public, max-age=300, must-revalidate";
try_files $uri =404;
}
location = /sitemap.xml {
add_header Cache-Control "public, max-age=300, must-revalidate";
try_files $uri =404;
}
# ── 발행 사이트 ────────────────────────────────────────────
# ── API ────────────────────────────────────────────────────
# 앱과 같은 오리진이라 프리플라이트가 아예 발생하지 않는다.
location ^~ /v1/social/ {
access_log off;
add_header Cache-Control "no-store" always;
add_header Referrer-Policy "no-referrer" always;
proxy_pass $api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
# 발행·수집 잡은 분 단위다. 기본 60s 면 게이트웨이가 먼저 끊는다.
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location ~ ^/(v1/|healthz$|openapi\.json$|docs|redoc) {
proxy_pass $api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
# 발행·수집 잡은 분 단위다. 기본 60s 면 게이트웨이가 먼저 끊는다.
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# IndexNow 키 파일. 프리렌더가 루트에 <key>.txt 를 굽고 검색엔진이 대조한다.
# ★ `{8,128}` 같은 수량자는 못 쓴다 — nginx 는 `{`·`}` 를 블록 구분자로 먼저 읽는다.
location ~ ^/[A-Za-z0-9_-]+\.txt$ {
try_files $uri =404;
}
# ── 사장님 앱 (그 외 전부) ─────────────────────────────────
# 해시가 박힌 번들. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다.
# ★ 발행본 `/assets/` 와 겹치지 않게 빌더만 `builder-assets` 로 뺐다
# (solution/frontend/vite.config.ts 의 build.assetsDir).
location ^~ /builder-assets/ {
root /srv/app;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
try_files $uri =404;
}
# ★ 폴백은 `/index.html` 이 아니라 `__spa-fallback.html` 이다.
# 프리렌더를 켠 뒤로 `/index.html` 은 **랜딩이 구워진 파일**이다 — 여기로 넘기면
# `/builder` 를 열었는데 랜딩 HTML 이 내려가고, 클라이언트 라우터는 다른 주소로
# 하이드레이트한다. 화면은 뜨는데 한 번 깜빡이고 마크업이 어긋나는 종류다.
# 구워진 경로(`/` `/pricing` `/showcase`)는 그 앞의 `$uri/index.html` 이 먼저 잡는다.
# ★ HTML 은 캐시하지 않는다 — 번들 해시가 박혀 있어서, 캐시되면 새로 배포해도
# 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면).
location / {
# $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를
# 한 번 더 타야 하므로 index.html 을 바로 준다.
try_files $uri $uri/index.html =404;
add_header Cache-Control "public, max-age=300, must-revalidate";
root /srv/app;
try_files $uri $uri/index.html /__spa-fallback.html;
add_header Cache-Control "no-cache";
}
# 발행되지 않은 주소. 사장님이 오타를 냈을 때 흰 화면 대신 이유를 보여준다.

5
ontology/.dockerignore Normal file
View File

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

37
ontology/.env.example Normal file
View File

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

8
ontology/.gitignore vendored Normal file
View File

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

23
ontology/Dockerfile Normal file
View File

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

429
ontology/README.md Normal file
View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

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

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

6333
ontology/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
ontology/package.json Normal file
View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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