o2o-site-AEO/docs/DECISIONS.md
민헌 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

29 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 회전은 결론과 무관하게 금지. 구현하지 않는다

1-2. 크롤링한 이미지의 재게시 권리

항목 내용
상태 미결
필요한 결론 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. 해지 시 사이트 처리 정책

항목 내용
상태 미결
필요한 결론 해지하면 사이트를 즉시 내리는가 / 유예를 두는가 / 도메인은 어떻게 되는가. 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 는 그대로 둔다.


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)가 호텔·모텔·리조트가 아닌 곳. 분류가 비어도 적용한다(펜션인데 네이버 분류가 없는 곳이 있다). 카페·음식점·체험시설은 카탈로그가 없어 채우지 않는다.