o2o-site-AEO/docs/DECISIONS.md
김성경 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

36 KiB

DECISIONS

미결 사항과, 코드가 그 미결을 어떻게 격리해 두고 있는지를 적는다. 결론이 나면 여기에 날짜와 함께 결론을 적고, 해당 플래그/어댑터를 제거한다.


1. 미결 — 결론 전까지 코드로 풀지 않는다

1-1. 야놀자·여기어때·네이버 플레이스 크롤링의 약관·법적 검토

결론 (2026-08-28) — docs/DATA_SOURCE_RESEARCH.md 실측 기준. 아래 원안은 이력으로 남긴다.

대상 판정 근거
야놀자 · 여기어때 불가 풀 브라우저 헤더로도 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 차단
사장님이 확정한 자체 홈페이지 가능 사장님 동의 기반. → StaticHtmlAdapter 등록(2026-08-28)

추가 결론 (2026-08-31) — TourAPI 어댑터를 붙여 같은 표본으로 대조한 결과 (DATA_SOURCE_RESEARCH.md 8-2), 네이버 플레이스는 숙박 fact 2건 · 객실명만 주는데 TourAPI 는 fact 8종 · 객실 20개의 인원/요금/면적을 준다. 정보량도 적고 출처도 못 밝힌다naver_place 어댑터를 유지할 근거가 사실상 사라졌다. 아직 COLLECT_ADAPTERS 기본값에 남아 있으므로, 내리는 것이 남은 일이다.

부수 결론: 네이버·카카오 생태계는 AI 크롤러를 전면 차단한다(플레이스·지도·블로그·예약·카카오맵 모두 Disallow: /). 즉 그 데이터를 긁어와도 AI 검색에는 원래 없던 정보라 GEO 이득이 없고, 출처를 밝힐 수 없어 llms.txt·AnswerBlock의 "출처와 검증 시각" 신호도 못 채운다. 공공데이터(TourAPI·LOCALDATA)가 그 자리를 대신한다.

항목 내용 (원안)
상태 미결결론남 (2026-08-28)
필요한 결론 각 사이트의 이용약관·robots.txt 기준으로 자동 수집이 허용되는 범위
코드 격리 collector 는 어댑터 패턴. Phase 1 은 MockAdapter 만 등록2026-08-28: StaticHtmlAdapter 등록 완료(사장님 확정 URL 한정, robots.txt 준수, 플랫폼 호스트는 _DENY_HOSTS 로 구조적 차단). HeadlessAdapter(Playwright)는 등록하지 않는다 — Cloudflare 를 뚫는 용도가 되므로 금지 항목과 구분되지 않는다
결론이 "불가"일 때 폴백 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 회전 여전히 금지. 차단되면 그대로 실패로 돌린다
  • 레지스트리가 yanoljastatic_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 캐시는 별도로 남을 수 있다.

항목 내용
상태 미결
필요한 결론 OTA/플레이스에 올라간 사진을 우리가 만든 홈페이지에 다시 게시할 수 있는가 (저작권자 = 사장님인가 OTA인가)
파급 막히면 사진 입력원이 "크롤링" → "사장님 업로드"로 바뀌고, Gemini Vision 단계의 입력 자체가 달라진다. 분류·alt 생성 로직은 같아도 수집 경로가 통째로 교체됨
코드 격리 사진은 media 로 들어오되 출처(source_type)를 반드시 남긴다(crawl / owner). 발행 시 source_type 로 필터링할 수 있어야 한다
대기 중 파일 업로드 경로(Azure Blob 클라이언트)는 원본 보일러플레이트에 있으나 아직 복사하지 않았다. 이 결론이 난 뒤 media 모듈과 함께 이식한다
반영됨 (2026-08-26) place.mediasource_type(owner/crawl)과 origin_url 을 NOT NULL / 보존 컬럼으로 두었다. 결론이 "불가"로 나면 발행 시 source_type = CRAWL 을 통째로 제외하는 것으로 대응 가능하다

1-3. 관리자에서 수정 허용 범위

항목 내용
상태 미결
필요한 결론 사장님이 직접 고칠 수 있는 fact 의 범위. 특히 체크인·취사·반려동물·취소 규정처럼 틀리면 예약 클레임이 나는 항목을 자유 입력으로 열 것인가
확정 사항 수정한 값은 잠긴다(status = CORRECTED). 자동 갱신이 사장님 수정본을 덮어쓰지 않는다
코드 격리 fact 상태 전이에서 CORRECTED 는 자동 수집(api/crawl/llm)이 갱신할 수 없는 종착 상태로 둔다
반영됨 (2026-08-26) LOCKED_FACT_STATUSES = {CORRECTED} · FACT_STATUS_TRANSITIONS[CORRECTED] = {CORRECTED, REJECTED} — 자동 갱신 경로(UNVERIFIED·VERIFIED)로 돌아갈 수 없다. tests/test_fact_schema.py 가 이 규칙을 고정한다
남은 결정 어떤 필드까지 자유 입력을 허용할 것인가. 업종 스키마의 critical: true 필드가 후보 목록이다 — 숙박 14 / 카페 12 / 음식점 15 / 관광체험 17개

1-4. 해지 시 사이트 처리 정책

2026-09-14: SNS 운영 게재의 선행조건으로 승격. 외부 링크는 남으므로 UNPUBLISHED는 안내+연락처 페이지여야 한다. 현재 상태 전이만 있고 안내 페이지 생성은 미구현이므로 자동 게재 플래그는 기본 OFF다. 사장님 글을 자동 삭제하지 않는다. 함께 삭제할지는 별도 명시적 선택이며 현재 삭제 API는 제공하지 않는다.

항목 내용
상태 미결
필요한 결론 해지하면 사이트를 즉시 내리는가 / 유예를 두는가 / 도메인은 어떻게 되는가. AI 검색이 이미 색인한 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다
코드 격리 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)

원본: o2o-negosium/negodata/backend 보일러플레이트.

항목 결정 이유
레이어 구조 원본 그대로 — routerservicecrud, 람다 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 안 씀. 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 테이블 유지 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번 참고

3. 다음 단계에서 정해야 할 것 (법무 이슈 아님)

  • 작업 큐 구현 방식.결론남 (2026-08-27): LPS 의 job 큐를 그대로 이식했다. DB 잡 테이블 + 상태 폴링 API. 근거와 세부는 아래 5-2.
  • 지역 정보 캐시 TTL.날씨는 결론남 (1시간, local_content_service.get_weather). 상류가 죽으면 만료된 값을 stale 표시로 내보낸다 — 빈 값을 내보내지 않는다(절대규칙 9). 축제·관광지는 아직 TTL 이 없다 — 지금은 sync-festivals 를 사람이 눌러야 돈다.
  • TourAPI areaCode ↔ 카카오 행정구역 코드 매핑 테이블. 두 체계가 다르므로 매핑을 데이터로 관리할지 코드로 박을지. (카카오 키가 아직 미발급이라 실제로 부딪히지 않았다)

4. Phase 2 에서 내린 결정 (2026-08-26)

작업 순서 2번 — 4개 업종 스키마 정의 + 마이그레이션.

항목 결정 이유
업종 스키마 위치 common/category_schema/resources/*.json + 로더 원본의 common/anchoring/resources/ 패턴 그대로. 업종 추가 = 파일 1개 + enum 1줄
fact 저장 key-value (facts.key / value / unit) 업종마다 필드가 완전히 달라 컬럼으로 못 편다
활성 fact 유니크 (place_id, unit_id, key) 당 1건. REJECTED·EXPIRED 는 제외 같은 항목에 두 값이 동시 노출되는 것을 DB 가 막는다. 틀린 값·만료 값은 이력으로 남겨야 하므로 유니크에서 뺀다
유니크 인덱스 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 를 금지한다
지역 정보 캐시 키 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 가 파일을 파싱해 대조 스키마 정의가 두 곳에 있는 구조(원본 컨벤션)라, 드리프트를 테스트로 막는다
설정 주입 레포 최상위 .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번 이후.
  • 작업 큐jobs 로 생겼다 (2026-08-27, 5-2). 지금은 14개 표 중 하나다(DATA_MODEL.md).
  • TourAPI areaCode ↔ 카카오 행정구역 코드 매핑 — 테이블 대신 common/category_schema 와 같은 리소스 JSON 으로 두는 것을 제안. 3번 참고.

5. Phase 3 에서 내린 결정 (2026-08-27)

작업 순서 3번 — places · facts API(검증 상태 전이) + 작업 큐.

5-1. fact 를 '노출값 1건 + 후보 N건' 으로 바꿨다

처음엔 활성 유니크를 status IN (1,2,3,4) 로 걸었다. 재수집(업데이트)을 넣어 보니 세 가지가 깨졌다 — 실제로 돌려서 확인한 결과다:

상황 깨진 것
값이 그대로인데 재수집 검증(VERIFIED)이 초기화돼 사이트에서 사실이 사라졌다
값이 바뀐 재수집 확인된 노출값이 즉시 밀려나 사이트가 비었다
정정본(CORRECTED)에 재수집 FACT_LOCKED 로 크롤링 값을 통째로 버려 OTA 불일치 신호가 사라졌다

원인은 하나 — 유니크가 "노출값"과 "새로 들어온 값"을 동시에 못 갖게 막고 있었다.

결정: 유니크를 노출 상태(VERIFIED·CORRECTED)에만 건다.

노출값  VERIFIED / CORRECTED        (place, unit, key) 당 1건   ← 사이트에 나감
후보    UNVERIFIED / PENDING_OWNER  여러 건 공존               ← 재수집이 쌓임
이력    REJECTED / EXPIRED

이걸로 세 프로세스가 분리됐다.

프로세스 동작 outcome
생성 자동 수집 → 후보 → 사람 승인 → 노출 CANDIDATE_CREATED
업데이트(값 같음) 검증 유지, 확인 시각만 갱신 REFRESHED
업데이트(값 다름) 노출값 유지 + 후보 적재 CANDIDATE_CREATED
승인 후보 → 노출값, 옛 값 EXPIRED PUBLISHED_REPLACED
수정(사람 직접) 즉시 노출값 교체 PUBLISHED_REPLACED

스키마: postgres-init/init-data/init.sql (place_facts 활성 유니크 + 후보 상태)

5-2. 그 밖의 결정

항목 결정 이유
사람 직접 입력 즉시 노출값(VERIFIED)으로 들어간다 넣은 사람이 곧 출처이자 책임 주체다. 입력→확인 2단계로 만들면 실무에서 안 쓴다. 검수 게이트의 실제 목적은 자동 수집을 거르는 것
정정본 잠금의 의미 "덮어쓰기 금지"이지 "기록 금지"가 아니다 크롤링 값을 버리면 OTA 가 아직 다른 값이라는 신호를 잃는다. 후보로 남겨 사람이 본다
places.content_updated_at 노출값이 실제로 바뀔 때만 찍는다 개별 재빌드 대상 판별(site_versions.built_at < content_updated_at). 후보만 쌓였을 땐 안 찍힌다 — 사이트 내용이 안 바뀌었으니 재빌드가 불필요
작업 큐 LPS(o2o-negosium/lps)의 job 큐를 그대로 이식 사내 선례이고 도커에서 검증된 구조. 원자적 claim(FOR UPDATE SKIP LOCKED) + lease 소유권 + dedupe + dead-letter. Redis/Celery 를 안 쓰는 이유는 운영 컴포넌트가 늘면 장애 지점이 늘기 때문
큐 세션 진입점 DB_SESSION_MNG.execute_lambda_write 를 새로 추가 큐 전이는 UPDATE ... RETURNING 이라 조회/변경을 못 나눈다. 기존 execute_lambda_run(ErrorType만) / execute_lambda_claim(rowcount만) 로는 값을 못 받는다
jobs.job_id server_default 이 테이블만 PK 에 gen_random_uuid() 를 건다 큐만 raw SQL 로 INSERT 해서 ORM 의 Python default 가 안 먹는다. create_all(테스트 DB)과 init.sql(실 DB)이 갈라져 실제로 버그가 났다
수집 중복 방지 dedupe_key = "collect:{place_id}" 사업장당 활성 수집 잡 1건. 버튼을 두 번 눌러도 두 번 돌지 않는다

5-3. 아직 안 한 것

  • 검색어 → URL 발견 → 크롤링 → 에셋 체인돈다 (2026-08-28~31). Perplexity 채널 발견 + tour_api·naver_place·static_html 어댑터가 붙었다 (DATA_SOURCE_RESEARCH.md 8-1·8-2).
  • 사진(place.media)은 조회는 열렸고(/v1/place/{id}/media, 빌더가 useListMedia 로 읽는다) 업로드·저장 경로가 없다. 이미지 재게시 권리(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_contentsregion_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:promptssolution/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 는 생성된 뒤 사장님 승인을 받아야 발행본에 나갔다(UNVERIFIEDVERIFIED). 그 단계를 없앤다. 생성 즉시 노출값이다.

왜 — 승인받을 화면이 없었다. 실측(2026-09-10, 힐튼 가든 인 서울 강남): 수집 확인이 07:29 에 끝나고 소개문은 07:31 에 도착했다. 사장님은 이미 확인 화면을 지나간 뒤였다. 로그는 [copy] 소개문 O 인데 발행본의 소개는 빈칸이고, 그 자리를 fact 로 조립한 한 줄("서초구에 있는 …입니다. 체크인 15:00.")이 대신 채우고 있었다. 생성은 되는데 영영 안 나가는 상태였고, 화면 어디에도 그 이유가 보이지 않았다.

왜 안전한가 — 게이트가 뒤가 아니라 앞에 있다.

  • 입력이 확인된 fact 뿐이다. copy_servicePUBLISHABLE 만 근거로 넘기고, 근거가 하나도 없으면 유료 호출조차 하지 않는다. 즉 이 문장은 이미 승인된 사실로만 쓰였다 — 한 번 더 승인받는 것은 같은 사실을 두 번 승인하는 일이다.
  • 근거 fact 가 없는 FAQ 는 저장되지 않는다. ground_check 가 반려한 문장도 마찬가지다.
  • 6-2(지역 이야기)와 같은 규약이다. 그때 "fact·사진·FAQ 에는 넓히지 않는다" 고 적었는데, FAQ 와 소개문에는 넓힌다 로 바꾼다. 근거는 위 두 줄이다: 이 둘은 수집된 사실이 아니라 이미 확인된 사실로 쓴 문장이다. 수집 fact(체크인·반려동물·취소 규정)와 사진에는 여전히 넓히지 않는다 — 그건 틀리면 예약 클레임이 나는 값이고 근거가 우리 밖에 있다.

남겨 둔 잠금. 사장님이 고친 문장(CORRECTED)은 재생성이 덮지 않는다(절대규칙 6). 지금까지 이 잠금은 "자동 출처는 노출값 경로로 못 간다"는 구조가 대신 지켜 줬다 — LLM 만 그 경로를 지나가게 되면서 fact_service.upsert_fact 에 잠금을 명시적으로 다시 걸었다.

FAQ 재생성의 기준이 바뀐다. 생성분이 VERIFIED 로 들어가므로 status 만으로는 사람이 손댔는지 알 수 없다. expire_generatedgenerated_by 로 가른다 — 사장님이 정정하면 faq_service 가 그 값을 OWNER 로 바꾼다(책임 주체의 기록이고, 원래부터 있던 자리다). 반려(REJECTED)한 FAQ 는 그대로 둔다.


7-1. 사장님 명의의 SNS 발화는 별도 승인 (2026-09-14)

Threads 우선. 상세 흐름·활성화 전제는 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_slugSITE_SLUG_LOCKEDdomain 컬럼 변경만 막으므로 여기엔 안 걸린다. → 이미 올라간 글의 옛 주소는 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_textdecided_via='mini_blog'로 곧장 APPROVED 처리한다. 쓰레드 전용으로 새로 짓거나 내용을 바꾸는 경로(create_draft/request_approval)는 위 CAS 승인을 그대로 거친다.

기존 액세스 토큰을 승인 링크에 얹지 않는다. 지금 JWT 는 subUserInfo 통짜(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_copyFAQ_UNGROUNDED 로 잡을 만들지 않아 FAQ 가 0개였다 — 이제 그 거절은 카탈로그가 없는 업종(카페·음식점·호텔)에만 남는다.

어디에 안 나가나 — 사이트 화면에는 나간다. FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수 · SEO 감사 FAQ 점수에서는 뺀다. 답이 없는 문답을 구조화 데이터로 내보내면 AI 검색에 잡음이고, 모든 펜션에 같은 문구라 고유 콘텐츠로 세면 내용 없는 사이트가 발행 게이트를 통과한다.

적용 범위 — 숙박 업종이면서 외부 분류(places.external_category)가 호텔·모텔·리조트가 아닌 곳. 분류가 비어도 적용한다(펜션인데 네이버 분류가 없는 곳이 있다). 카페·음식점·체험시설은 카탈로그가 없어 채우지 않는다.