diff --git a/admin/backend/app.py b/admin/backend/app.py index be45cca..8b94ae7 100644 --- a/admin/backend/app.py +++ b/admin/backend/app.py @@ -1,10 +1,4 @@ -"""어드민 API. 솔루션 API(:9800)와 프로세스·포트가 갈린다. - -도메인 코드는 solution/backend 것을 PYTHONPATH 로 쓴다 — admin 전용 라우터가 0개라 -(전부 place·fact) 새로 쓰면 같은 테이블을 두 벌 구현하는 것뿐이다. -경로 접두어가 아니라 포트를 가른 이유: 접두어는 같은 프로세스라 사장님이 닿는 서버에 -내부 엔드포인트가 존재한다. 포트를 가르면 아예 없다. -""" +"""어드민 API.""" import time @@ -32,7 +26,7 @@ API_SERVER_START_TIME = GTime.UTCStr() @asynccontextmanager async def lifespan(app: FastAPI): - # 크론은 :9800 담당. 여기서도 돌리면 같은 시각에 중복 실행된다. + # 크론은 :9800 담당. yield await DB_SESSION_MNG.dispose_all() diff --git a/admin/backend/main.py b/admin/backend/main.py index 62c6810..f8c4318 100644 --- a/admin/backend/main.py +++ b/admin/backend/main.py @@ -1,5 +1,4 @@ -# 어드민 API 서버 (:9801). 근거는 app.py 주석. -# PYTHONPATH=../../solution/backend python main.py +# 어드민 API 서버 (:9801). import os diff --git a/admin/frontend/eslint.config.js b/admin/frontend/eslint.config.js index b669fb4..aeaed98 100644 --- a/admin/frontend/eslint.config.js +++ b/admin/frontend/eslint.config.js @@ -1,6 +1,4 @@ // 최소 게이트 — 전체 스타일 린트가 아니라, tsc 가 못 잡는 런타임 크래시 버그만 막는다. -// (negodata 보일러플레이트에서 이식. 도입 계기는 로컬 const 가 동명 import 를 가려 -// zustand 셀렉터가 TDZ 참조 → 프로덕션 크래시. 그 케이스는 tsc --noEmit 도 통과했다.) import tseslint from 'typescript-eslint'; import reactHooks from 'eslint-plugin-react-hooks'; @@ -18,7 +16,7 @@ export default tseslint.config({ // 훅 호출 순서 위반은 런타임 크래시라 error, deps 누락은 기존 코드가 많아 warn. 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', - // 선언보다 위에서 변수를 쓰는 것(TDZ) 차단. 함수/타입 호이스팅은 안전하므로 허용. + // 선언보다 위에서 변수를 쓰는 것(TDZ) 차단. 'no-use-before-define': 'off', '@typescript-eslint/no-use-before-define': [ 'error', diff --git a/admin/frontend/src/app/provider.tsx b/admin/frontend/src/app/provider.tsx index 416c3b0..734582c 100644 --- a/admin/frontend/src/app/provider.tsx +++ b/admin/frontend/src/app/provider.tsx @@ -5,14 +5,7 @@ import {getAccessToken, me} from '@/api'; import {queryClient} from '@/lib/query-client'; import {toAuthUser, useAuthStore} from '@/stores/auth'; -/** - * 저장된 액세스 토큰으로 세션을 복구한다. - * - * ★ 사장님 앱과 결정적으로 다른 점: **여기서는 실패가 곧 차단이다.** - * 빌더는 로그인 없이도 돌아야 해서 인증 실패를 삼키지만(solution/frontend/app/provider.tsx), - * 내부 운영 화면은 전부 RequireAuth 뒤에 있다. 두 정책을 한 앱에 두면 실수가 늘 - * 느슨한 쪽으로 나기 때문에 앱을 갈랐다. - */ +/** 저장된 액세스 토큰으로 세션을 복구한다. */ function useRestoreSession() { const setUser = useAuthStore((s) => s.setUser); const finishRestore = useAuthStore((s) => s.finishRestore); @@ -31,7 +24,7 @@ function useRestoreSession() { setUser(toAuthUser(res)); }) .catch(() => { - /* 토큰이 죽었으면 비로그인 상태로 떨어뜨린다. RequireAuth 가 로그인으로 보낸다. */ + /* 토큰이 죽었으면 비로그인 상태로 떨어뜨린다. */ }) .finally(() => { if (alive) finishRestore(); diff --git a/admin/frontend/src/app/router.tsx b/admin/frontend/src/app/router.tsx index 6a7e5b5..09a8af3 100644 --- a/admin/frontend/src/app/router.tsx +++ b/admin/frontend/src/app/router.tsx @@ -10,20 +10,14 @@ import {PlaceDetailPage} from '@admin/pages/PlaceDetailPage'; import {PlaceListPage} from '@admin/pages/PlaceListPage'; import {SeoAuditPage} from '@admin/pages/SeoAuditPage'; -/** - * ★ 내부 메뉴는 여기 있다. AppShell(사장님 앱 소유)에 두면 이 경로 이름들이 - * 사장님 번들에 문자열로 남는다 — 앱을 가른 이유가 사라진다. - */ +/** 내부 메뉴는 여기 있다. */ 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}, ]; -/** - * 내부 운영 화면. **전부 RequireAuth 뒤에 둔다** — 예외를 하나 두는 순간 - * 그 예외가 기본값이 된다. 사장님 앱과 앱을 가른 이유가 이 규칙을 지키기 위해서다. - */ +/** 내부 운영 화면. */ export const router = createBrowserRouter([ // selfServe=false: 내부 운영 계정은 우리가 만들어 준다 — 가입 링크도 구글 로그인도 두지 않는다. {path: '/login', element: }, diff --git a/admin/frontend/src/lib/solutionUrl.ts b/admin/frontend/src/lib/solutionUrl.ts index 3dc1c86..0aa1c88 100644 --- a/admin/frontend/src/lib/solutionUrl.ts +++ b/admin/frontend/src/lib/solutionUrl.ts @@ -1,7 +1,4 @@ -/** - * 사장님 앱 오리진. 빌더는 다른 오리진이라(:3000 vs :3002) react-router Link 로 두면 - * admin 안에서 404 다. 절대 URL + 새 탭으로 연다. - */ +/** 사장님 앱 오리진. */ const ORIGIN = import.meta.env.VITE_SOLUTION_URL ?? 'http://localhost:3000'; export function builderUrl(params?: {placeId?: string; isNew?: boolean}): string { diff --git a/admin/frontend/src/pages/LocalContentPage.tsx b/admin/frontend/src/pages/LocalContentPage.tsx index 8f53c55..70f98ec 100644 --- a/admin/frontend/src/pages/LocalContentPage.tsx +++ b/admin/frontend/src/pages/LocalContentPage.tsx @@ -9,8 +9,7 @@ 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). +// LocalContentType — common/enums.py 와 값을 맞춘다. type ContentType = 2 | 3 | 4; type LocalContent = { @@ -107,8 +106,7 @@ export function LocalContentPage() { } catch { toast.error('발행하지 못했습니다.'); } }; const sync = async () => { - // ★ 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다. - // 이 화면의 목록은 아직 지역 캐시(local_contents)를 보여준다. 업장별 목록 화면은 다음 작업이다. + // 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다. const placeId = window.prompt('사업장 ID(place_id)를 입력하세요. 사업장 목록 주소의 /places/ 뒤 값입니다.')?.trim(); if (!placeId) return; setSyncing(true); @@ -118,7 +116,6 @@ export function LocalContentPage() { 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); - // ★ 여행코스(코스)는 2026-09-08부터 수집하지 않는다(반경을 넓혀도 데이터가 거의 없었다) — 표기에서 뺀다. const summary = `축제 ${res.festivals ?? 0} · 관광지 ${res.attractions ?? 0} · 맛집 ${res.restaurants ?? 0}건`; if (!res.changed) { toast.info(`바뀐 내용이 없습니다 (${summary}, TourAPI 원문 그대로).`); diff --git a/admin/frontend/src/pages/PlaceDetailPage.tsx b/admin/frontend/src/pages/PlaceDetailPage.tsx index be63602..d7f27fb 100644 --- a/admin/frontend/src/pages/PlaceDetailPage.tsx +++ b/admin/frontend/src/pages/PlaceDetailPage.tsx @@ -45,8 +45,7 @@ export function PlaceDetailPage() { const transition = useTransitionFact({ mutation: { onSuccess: (res) => { - // ★ 백엔드는 거절도 200 + result.success=false 로 준다 — 여기서 안 걸러내면 - // 저장되지 않은 값이 '확인됨'으로 보인다. + // 백엔드는 거절도 200 + result.success=false 로 준다 — 여기서 안 걸러내면 저장되지 않은 값이 '확인됨'으로 보인다. if (res.result?.success === false) { notifyApiError({data: res}, '허용되지 않는 상태 전이입니다.'); return; @@ -177,8 +176,7 @@ export function PlaceDetailPage() {
- {/* ★ 재수집은 오른쪽 열 맨 위다. 이 화면에 온 사장님의 두 가지 용건이 - "확인 대기 값을 처리한다"(왼쪽)와 "값을 다시 가져온다"(여기)라서다. */} + {/* 재수집은 오른쪽 열 맨 위다. */} diff --git a/admin/frontend/src/pages/PlaceListPage.tsx b/admin/frontend/src/pages/PlaceListPage.tsx index dd870e5..ae26cd3 100644 --- a/admin/frontend/src/pages/PlaceListPage.tsx +++ b/admin/frontend/src/pages/PlaceListPage.tsx @@ -35,16 +35,7 @@ const STATUS_LABEL: Record = { [PlaceStatus.SUSPENDED]: '중지', }; -/** - * 사업장 목록 — 이 제품의 허브다. - * - * 흐름은 하나뿐이다: - * 빌더(위저드)로 만든다 → **여기 생긴다** → 여기서 에디터로 들어가 고친다 → 재발행하면 HTML 이 다시 구워진다. - * - * ★ 그래서 줄을 누르면 사업장 상세가 아니라 **에디터**로 간다. 목록에 온 사장님의 - * 용건은 열에 아홉 "내 사이트 고치기"다. fact 를 하나씩 확인하는 상세 화면은 - * [정보 확인] 으로 따로 둔다 — 발행 게이트에 걸렸을 때 가는 곳이다. - */ +/** 사업장 목록 — 이 제품의 허브다. */ export function PlaceListPage() { const [search, setSearch] = useState(''); const [deletingId, setDeletingId] = useState(null); @@ -155,7 +146,7 @@ export function PlaceListPage() { > {STATUS_LABEL[place.status] ?? '알 수 없음'} - {/* ★ verified_at 이 NULL 이면 수집·발행 진입 금지. 목록에서 바로 보이게 둔다. */} + {/* verified_at 이 NULL 이면 수집·발행 진입 금지. */} {place.verified_at ? ( diff --git a/admin/frontend/src/pages/ReviewModerationPage.tsx b/admin/frontend/src/pages/ReviewModerationPage.tsx index aebaa50..26733d0 100644 --- a/admin/frontend/src/pages/ReviewModerationPage.tsx +++ b/admin/frontend/src/pages/ReviewModerationPage.tsx @@ -7,12 +7,7 @@ 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; diff --git a/admin/frontend/vite.config.ts b/admin/frontend/vite.config.ts index 20a2c29..c392ffc 100644 --- a/admin/frontend/vite.config.ts +++ b/admin/frontend/vite.config.ts @@ -3,13 +3,7 @@ import react from '@vitejs/plugin-react'; import path from 'path'; import {defineConfig} from 'vite'; -/** - * 내부 운영 화면. 사장님 앱(solution/frontend)과 번들이 갈린다. - * - * `@` 를 이 앱이 아니라 사장님 앱 src 로 겨눈다 — 내부 화면이 쓰는 API·UI·수집 배선이 - * 거기 한 벌만 있고 그 파일들끼리도 `@/...` 로 서로를 부른다(자기 src 로 잡으면 TS2307 14건). - * 이 앱 고유 파일은 `@admin`. 의존 방향은 admin → solution 한 쪽뿐이다. - */ +/** 내부 운영 화면. */ export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index d022d6e..59ab905 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -6,6 +6,49 @@ 존재하지 않는 템플릿 id를 가리켰고, 음식점 강조색에 오타가 있었고, 섹션 간격이 빌더와 서버에서 달랐다. 병원 "클린" 템플릿은 이름과 실제 모양이 맞지 않았다. +- 템플릿 목록은 `solution/shared/src/data/templates.json` 하나다. TS와 파이썬이 같은 파일을 읽는다. +- 템플릿 id에서 업종을 뗐다. `stay-retro` → `retro`. 기존 DB 값은 마이그레이션 `0023`으로 바꾼다(운영 미적용). +- 모르는 템플릿 id는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다. +- 레이아웃은 `basic`과 `paper` 둘만 남겼다. 연결 안 된 레이아웃 5개, 배치 고르기, 서체 선택, 빌더 캔버스를 지웠다. +- 템플릿을 바꾸면 이전 템플릿이 켠 섹션은 꺼지고, 빌더에 그 안내가 뜬다. +- 바뀐 동작: 저장된 모양(look)과 배치 선택은 무시한다. 레트로 사진은 캐러셀에서 그리드로 바뀐다. + 병원은 날씨·주변 정보가 기본으로 꺼진다. 모두 재발행할 때부터 적용된다. + +- 프로젝트 코드 주석을 한 줄로 줄이고 히스토리 주석을 지웠다(파일 478개). 파이썬은 정리 전후 문법 + 트리가 같은지, TS는 주석을 뺀 토큰이 같은지 대조했다. + +구조와 새 템플릿 추가 방법은 [TEMPLATES.md](TEMPLATES.md). + +**검증** — shared·site·frontend·admin `tsc`, site `eslint`·`vitest` 105개, frontend `vite build` 통과. +백엔드는 DB 없이 도는 테스트 41개 통과, DB가 필요한 테스트는 로컬 DB 접속 문제로 못 돌렸다. + +## 2026-09-23 — 개발자 전용 사이트관리·유저관리를 solution 앱에 경량으로 + +admin/frontend(:9801)를 새 메뉴로 키우려면 새 도메인이 필요하고 아직 그럴 기능도 안 +갖춰졌다(대표 지시) — 그래서 대신 solution 앱(:9800)에 얹었다. `UserRole.DEVELOPER` 게이트 +하나로, 회사 스코프를 걷어낸(2026-09-08, DECISIONS.md) 전 계정 사이트·유저 목록(읽기 전용)을 본다. + +- **백엔드**: `router/v1/ops/ops.py`(`GET /v1/ops/sites`, `GET /v1/ops/users`, 전부 + `RequireDeveloper`) + `services/ops_service.py` + `crud/site_crud.py:list_all_sites` / + `crud/user_crud.py:list_users`. 유저 목록은 USER/OWNER 만 — 개발자 계정은 여기서도 뺀다 + (`UserRole` 주석 원칙을 내부 화면에도 지킨다). +- **프론트**: `pages/OpsSitesPage.tsx` · `OpsUsersPage.tsx`(`/ops/sites` · `/ops/users`). + `AppShell.tsx` 의 기본 nav(`OWNER_NAV`)에 `role===DEVELOPER` 일 때만 두 줄을 더 붙인다. + ★ 이 문자열은 role 과 무관하게 사장님에게 나가는 번들에도 실린다(런타임 조건부 렌더일 뿐, + 빌드 타임에 갈라지지 않는다) — AppShell 주석의 "메뉴가 섞이면 새어 나간다"가 그대로 적용된다. + 실제 데이터 접근은 백엔드 게이트가 막으므로 새는 것은 경로 이름 정도다. +- 액션(재발행·상태 토글·강제 로그아웃 등)은 다음 단계 — 이번엔 조회만. + +**검증** — DB 접속이 안 되는 환경이라 pytest 는 못 돌렸다: `app.openapi()` 로 라우터 임포트· +스키마 생성 확인, `scripts/export_openapi.py` → `orval` 코드젠 성공, 프론트 `tsc --noEmit` · +`eslint src` 통과. 실제 DB 조회 동작은 미검증 — docker compose 로 띄운 뒤 확인 필요. + +## 2026-09-28 — 템플릿 정의를 한 파일로 모았다 + +템플릿 정보가 빌더, 렌더러, 백엔드에 따로따로 적혀 있어서 서로 어긋나 있었다. 백엔드 기본값이 +존재하지 않는 템플릿 id를 가리켰고, 음식점 강조색에 오타가 있었고, 섹션 간격이 빌더와 서버에서 +달랐다. 병원 "클린" 템플릿은 이름과 실제 모양이 맞지 않았다. + - 템플릿 목록은 `solution/shared/src/data/templates.json` 하나다. TS와 파이썬이 같은 파일을 읽는다. - 템플릿 id에서 업종을 뗐다. `stay-retro` → `retro`. 기존 DB 값은 마이그레이션 `0023`으로 바꾼다(운영 미적용). - 모르는 템플릿 id는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다. diff --git a/ontology/scripts/build-dataset.mjs b/ontology/scripts/build-dataset.mjs index 9595273..bd2152a 100644 --- a/ontology/scripts/build-dataset.mjs +++ b/ontology/scripts/build-dataset.mjs @@ -1,11 +1,4 @@ -/** - * "군산 펜션" SEO/AEO 키워드·태그 데이터셋 1,000건 생성. - * node scripts/build-dataset.mjs → data/gunsan-pension-keywords.json - * - * 어휘는 실제 군산 지명·관광지·숙소 시설 용어로 구성했고, - * 패턴은 한국 로컬 숙박 검색에서 실제로 쓰이는 조합만 전개한다. - * 가치가 높은 순으로 방출하므로 1,000건에서 잘라도 상위 의도가 남는다. - */ +/** "군산 펜션" SEO/AEO 키워드·태그 데이터셋 1,000건 생성. */ import { writeFileSync, mkdirSync } from 'node:fs'; // ──────────────────────────────────────────────── 어휘 (실제 군산 기반) diff --git a/ontology/scripts/build-deck.py b/ontology/scripts/build-deck.py index 35770fe..43288f3 100644 --- a/ontology/scripts/build-deck.py +++ b/ontology/scripts/build-deck.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- -"""docs/architecture.html 의 내용을 PPTX 로 다시 만든다. - python3 scripts/build-deck.py -도식은 이미지가 아니라 네이티브 도형으로 그리므로 PowerPoint 에서 그대로 편집된다.""" +"""docs/architecture.html 의 내용을 PPTX 로 다시 만든다.""" from pptx import Presentation from pptx.util import Inches, Pt @@ -10,7 +8,7 @@ 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 문서와 동일) +# --------------------------------------------------------------- 팔레트 (HTML 문서와 동일) INK = RGBColor(0x10, 0x18, 0x19) INK_SOFT = RGBColor(0x3D, 0x4C, 0x4E) MUTED = RGBColor(0x63, 0x75, 0x7A) @@ -32,7 +30,7 @@ W, H = 13.333, 7.5 MX = 0.75 # 좌우 여백 -# ---------------------------------------------------------------- 저수준 헬퍼 +# --------------------------------------------------------------- 저수준 헬퍼 def _ea(run, name): """한글이 라틴 폰트로 떨어지지 않도록 동아시아 typeface 를 함께 지정.""" rPr = run._r.get_or_add_rPr() @@ -135,7 +133,7 @@ def label(sl, x, y, text, size=9, color=MUTED, font=MONO, align=PP_ALIGN.LEFT, w 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) diff --git a/ontology/scripts/build-nationwide-dataset.mjs b/ontology/scripts/build-nationwide-dataset.mjs index 80b1f25..bcc8171 100644 --- a/ontology/scripts/build-nationwide-dataset.mjs +++ b/ontology/scripts/build-nationwide-dataset.mjs @@ -1,14 +1,4 @@ -/** - * 전국 지역별 펜션 SEO/AEO 키워드 데이터셋. - * node scripts/build-nationwide-dataset.mjs → data/nationwide-pension-keywords.json - * - * 설계 원칙 - * · 조합 폭발을 하지 않는다. 군산 단일 지역 974건을 54개 지역에 곱하면 5만 건이 되고 - * 대부분 검색량 0이 된다 (실측: 저장분의 89% 미사용). - * · 지역 성격(해변/산간/호수/도심/섬)에 맞는 시설 키워드만 전개한다. - * 산간 지역에 '오션뷰 펜션'을 만들지 않는다. - * · 티어를 매겨 주력/보조/롱테일을 구분한다. SEO 는 페이지당 주력 1개다. - */ +/** 전국 지역별 펜션 SEO/AEO 키워드 데이터셋. */ import { readFileSync, writeFileSync } from 'node:fs'; const { regions } = JSON.parse(readFileSync('data/regions.json', 'utf8')); @@ -69,7 +59,7 @@ 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 ? ['스키', '스키장 근처', '보드'] : []), @@ -77,8 +67,6 @@ for (const r of regions) { ]); // T1 코어 — 주력 후보. - // 별칭(대천/보령 처럼 같은 지역의 다른 검색 표기)도 코어·의도 계층까지는 함께 전개한다. - // 전 계층에 곱하면 두 배가 되므로 상위 티어에만 적용한다. const names = [R, ...(r.aliases ?? [])]; for (const N of names) { add(r, `${N} 펜션`, { category: '코어', tier: '주력', relevance: N === R ? 0.98 : 0.94 }); @@ -136,7 +124,7 @@ for (const r of regions) { 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)); diff --git a/ontology/scripts/export-db-xlsx.py b/ontology/scripts/export-db-xlsx.py index 589c197..094990d 100644 --- a/ontology/scripts/export-db-xlsx.py +++ b/ontology/scripts/export-db-xlsx.py @@ -1,8 +1,5 @@ # -*- coding: utf-8 -*- -"""벡터 DB 에 실제로 적재된 내용을 그대로 엑셀로 뽑는다 (배포용). - python3 scripts/export-db-xlsx.py -데이터셋 JSON 이 아니라 DB 가 기준이다. 임베딩은 엑셀에 담지 않는다 — -384개 float × 7천 행이라 의미가 없고, 같은 모델로 재생성하면 동일하게 복원된다.""" +"""벡터 DB 에 실제로 적재된 내용을 그대로 엑셀로 뽑는다 (배포용).""" import csv, io, subprocess, collections from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side diff --git a/ontology/scripts/export-xlsx.py b/ontology/scripts/export-xlsx.py index 62550ca..fa28354 100644 --- a/ontology/scripts/export-xlsx.py +++ b/ontology/scripts/export-xlsx.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- -"""전국 펜션 키워드 데이터셋 → 엑셀. - python3 scripts/export-xlsx.py -검색량·경쟁도 열은 비워 둔다 — 네이버 검색광고 키워드도구에서 받아 채우는 자리.""" +"""전국 펜션 키워드 데이터셋 → 엑셀.""" import json, collections from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side diff --git a/ontology/scripts/import-related.ts b/ontology/scripts/import-related.ts index fe03af5..bc2172b 100644 --- a/ontology/scripts/import-related.ts +++ b/ontology/scripts/import-related.ts @@ -1,14 +1,4 @@ -/** - * 외부 연관키워드·검색량을 데이터셋에 병합한다. - * 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'; diff --git a/ontology/scripts/ingest-dataset.ts b/ontology/scripts/ingest-dataset.ts index 0e50243..166ab90 100644 --- a/ontology/scripts/ingest-dataset.ts +++ b/ontology/scripts/ingest-dataset.ts @@ -1,11 +1,4 @@ -/** - * data/gunsan-pension-keywords.json 을 pgvector 에 적재한다. - * npx tsx scripts/ingest-dataset.ts - * - * 정책: 주기 수집 없음. 고정 데이터셋 1회 적재. - * 중복제거는 어휘 단계(정규화 완전일치)만 자동 병합하고, - * 벡터 유사도는 자동 병합하지 않고 "검토 목록"으로만 뽑는다. (이유는 README 참조) - */ +/** data/gunsan-pension-keywords.json 을 pgvector 에 적재한다. */ import { readFileSync } from 'node:fs'; import { createSql, toVector } from '../src/db/db'; import { normalizeKeyword, canonicalizeKeyword, isBanned } from '../src/keywords/normalize'; @@ -73,8 +66,6 @@ async function main() { console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `); // 4) 데이터셋에서 빠진 행 정리. - // upsert 만 하면 재빌드할 때마다 이전 판본 잔여 행이 쌓여 사전이 계속 커진다. - // (실제로 974건 데이터셋인데 사전이 1072건까지 불어 있었다) const wanted = uniq.map(([norm]) => norm); const stale = await sql>` DELETE FROM keyword diff --git a/ontology/scripts/ingest-nationwide.ts b/ontology/scripts/ingest-nationwide.ts index 140ea41..68f6930 100644 --- a/ontology/scripts/ingest-nationwide.ts +++ b/ontology/scripts/ingest-nationwide.ts @@ -1,10 +1,4 @@ -/** - * 전국 지역별 펜션 키워드를 pgvector 에 적재한다. - * npx tsx scripts/ingest-nationwide.ts - * - * 군산 상세 데이터셋(source='dataset')과 공존시킨다. - * 이쪽은 source='nationwide' 로 넣고, 잔여 정리도 그 출처 안에서만 한다. - */ +/** 전국 지역별 펜션 키워드를 pgvector 에 적재한다. */ import { readFileSync } from 'node:fs'; import { createSql, toVector } from '../src/db/db'; import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize'; @@ -30,7 +24,7 @@ async function main() { Array<{ sido: string; name: string; key: string }>; console.log(`📦 ${items.length}건 / ${ds.regionCount}개 지역 · 임베딩 ${embedder.name}`); - // 1) 지역 계층 심기 (시도 → 시군). ltree 라벨은 ASCII 만 허용한다. + // 1) 지역 계층 심기 (시도 → 시군). const nodes = new Map(); for (const r of regions) { const sidoKey = r.key.split('.').slice(0, -1).join('.'); @@ -63,9 +57,6 @@ async function main() { 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++) { diff --git a/ontology/scripts/purge-nondataset.ts b/ontology/scripts/purge-nondataset.ts index 7282318..7e2218e 100644 --- a/ontology/scripts/purge-nondataset.ts +++ b/ontology/scripts/purge-nondataset.ts @@ -1,10 +1,4 @@ -/** - * 고정 데이터셋 정책 위반분 정리. - * npx tsx scripts/purge-nondataset.ts [--apply] - * - * 사전(keyword)에는 큐레이션된 데이터셋만 남아야 한다. 과거 generate 테스트가 - * 만든 source='llm' 행이 섞여 있으면 다른 업종 키워드가 매칭 후보에 들어온다. - */ +/** 고정 데이터셋 정책 위반분 정리. */ import { createSql } from '../src/db/db'; async function main() { diff --git a/ontology/scripts/smoke.ts b/ontology/scripts/smoke.ts index 367afe7..61e90ae 100644 --- a/ontology/scripts/smoke.ts +++ b/ontology/scripts/smoke.ts @@ -1,8 +1,4 @@ -/** - * 로컬 엔드투엔드 점검 스크립트. - * 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) => { diff --git a/ontology/src/db/seed.ts b/ontology/src/db/seed.ts index 82a826f..e017f0c 100644 --- a/ontology/src/db/seed.ts +++ b/ontology/src/db/seed.ts @@ -64,9 +64,7 @@ const merchants = [ }, }, { - // 실제 업체. 공개 정보로 확인된 항목만 넣는다. - // 확인됨 : 상호, 군산 원도심(신흥동 말랭이마을 인근), 독채 2개 동, 기준 2인·최대 4인 - // 미확인 : 가격, 바베큐/스파/주차/애견동반 여부 ← 사업자 확인 후 채울 것 + // 실제 업체. externalId: 'site-3001', name: '스테이머뭄', industryId: 'stay.pension', diff --git a/ontology/src/embedding/local.provider.ts b/ontology/src/embedding/local.provider.ts index 07b1dbb..eaf2bb0 100644 --- a/ontology/src/embedding/local.provider.ts +++ b/ontology/src/embedding/local.provider.ts @@ -5,10 +5,7 @@ import { EmbedKind, EmbeddingProvider } from './types'; /** CommonJS 빌드에서 ESM 전용 패키지를 로드하기 위한 우회 (TS 가 require 로 바꾸지 못하게 한다) */ const esmImport = new Function('s', 'return import(s)') as (s: string) => Promise; -/** - * 로컬 multilingual-e5-small (384차원, onnxruntime CPU). - * 최초 1회 모델을 내려받아 캐시하며 그 뒤로는 오프라인 동작한다. - */ +/** 로컬 multilingual-e5-small (384차원, onnxruntime CPU). */ @Injectable() export class LocalEmbeddingProvider extends EmbeddingProvider { readonly name = 'local:multilingual-e5-small'; diff --git a/ontology/src/embedding/mock.provider.ts b/ontology/src/embedding/mock.provider.ts index 2d77849..214455d 100644 --- a/ontology/src/embedding/mock.provider.ts +++ b/ontology/src/embedding/mock.provider.ts @@ -3,7 +3,7 @@ import { EMBEDDING_DIM } from '../config/env'; import { hashEmbedding } from '../llm/mock.provider'; import { EmbedKind, EmbeddingProvider } from './types'; -/** 모델 다운로드 없이 파이프라인을 돌리기 위한 문자 bigram 해싱 임베딩. 의미는 잡지 못한다. */ +/** 모델 다운로드 없이 파이프라인을 돌리기 위한 문자 bigram 해싱 임베딩. */ @Injectable() export class MockEmbeddingProvider extends EmbeddingProvider { readonly name = 'mock:bigram-hash'; diff --git a/ontology/src/keywords/dedup.service.ts b/ontology/src/keywords/dedup.service.ts index 2c2089d..b2ddf04 100644 --- a/ontology/src/keywords/dedup.service.ts +++ b/ontology/src/keywords/dedup.service.ts @@ -28,10 +28,7 @@ export interface ResolveInput { regionId: string | null; } -/** - * 4단계 계단식 중복제거. - * 값비싼 벡터 비교는 마지막에, 후보 집합 안에서만 수행한다. - */ +/** 4단계 계단식 중복제거. */ @Injectable() export class DedupService { private readonly logger = new Logger(DedupService.name); @@ -61,12 +58,6 @@ export class DedupService { } // 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, diff --git a/ontology/src/keywords/keyword.repository.ts b/ontology/src/keywords/keyword.repository.ts index 56a5104..b87952a 100644 --- a/ontology/src/keywords/keyword.repository.ts +++ b/ontology/src/keywords/keyword.repository.ts @@ -33,10 +33,7 @@ export class KeywordRepository { return rows[0] ?? null; } - /** - * 중복 후보 수집: trigram 인덱스 히트 + 벡터 ANN 상위 N 을 합집합으로 가져온다. - * 벡터 비교는 이 후보 집합 안에서만 하므로 전수 비교가 일어나지 않는다. - */ + /** 중복 후보 수집: trigram 인덱스 히트 + 벡터 ANN 상위 N 을 합집합으로 가져온다. */ async findDedupCandidates( embedding: number[], normalized: string, diff --git a/ontology/src/keywords/normalize.ts b/ontology/src/keywords/normalize.ts index c6ad2fe..890d483 100644 --- a/ontology/src/keywords/normalize.ts +++ b/ontology/src/keywords/normalize.ts @@ -1,8 +1,4 @@ -/** - * 중복 판정용 정규화. - * NFKC → 소문자 → 제로폭 문자 제거 → 구두점 제거 → 공백 전부 제거. - * "강남 미용실" 과 "강남미용실" 을 같은 키로 취급하기 위해 공백을 없앤다. - */ +/** 중복 판정용 정규화. */ const ZERO_WIDTH = /[\u200B-\u200D\uFEFF]/g; const PUNCT = /[!-\/:-@\[-`{-~·ㆍ、。「-』]/g; diff --git a/ontology/src/llm/mock.provider.ts b/ontology/src/llm/mock.provider.ts index 7d28e24..7e108a3 100644 --- a/ontology/src/llm/mock.provider.ts +++ b/ontology/src/llm/mock.provider.ts @@ -10,13 +10,7 @@ import { QaCandidate, } from './types'; -/** - * API 키 없이 로컬에서 전체 파이프라인(생성 → 중복제거 → 서빙)을 돌리기 위한 대체 구현. - * - * embed(): 문자 bigram 해싱 + L2 정규화. - * 랜덤이 아니라 "비슷한 문자열이면 비슷한 벡터"가 나오므로 - * 코사인 임계값 기반 중복제거 동작을 실제와 유사하게 검증할 수 있다. - */ +/** API 키 없이 로컬에서 전체 파이프라인(생성 → 중복제거 → 서빙)을 돌리기 위한 대체 구현. */ @Injectable() export class MockLlmProvider extends LlmProvider { readonly name = 'mock'; diff --git a/ontology/src/serving/demo.controller.ts b/ontology/src/serving/demo.controller.ts index 52ea5aa..c2ab2ca 100644 --- a/ontology/src/serving/demo.controller.ts +++ b/ontology/src/serving/demo.controller.ts @@ -2,7 +2,7 @@ import { Controller, Get, Header } from '@nestjs/common'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -/** 로컬 확인용 매칭 데모 페이지. 빌드 산출물이 아니라 public/ 에서 직접 읽는다. */ +/** 로컬 확인용 매칭 데모 페이지. */ @Controller() export class DemoController { @Get('demo') diff --git a/ontology/src/serving/match.rules.ts b/ontology/src/serving/match.rules.ts index 2a43e47..c7a491b 100644 --- a/ontology/src/serving/match.rules.ts +++ b/ontology/src/serving/match.rules.ts @@ -1,11 +1,4 @@ -/** - * 매칭 규칙 테이블. - * - * 두 종류가 있다. - * · 서브 질의 빌더 — 프로필을 속성별로 쪼개 각각 임베딩한다 (통짜로 넣으면 속성이 희석된다) - * · 사실 기반 필터 — 벡터가 못 거르는 모순을 SQL/코드 조건으로 배제한다 - * (임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다) - */ +/** 매칭 규칙 테이블. */ export interface MerchantFacts { name: string; @@ -21,7 +14,7 @@ export interface MerchantFacts { nearby: string[]; amenities: Set; // 정규화된 보유 시설 unverified: Set; // 미확인 — 배제하지 않고 보류 처리 - /** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. 사업자가 쓰는 말과 다르므로 별도 레인으로 둔다 */ + /** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. */ signals: string[]; } @@ -124,20 +117,10 @@ export function checkAmenity(keyword: string, facts: MerchantFacts): AmenityVerd const STAY_TYPE_HINTS = ['독채', '풀빌라', '스테이', '펜션', '글램핑', '카라반', '한옥', '민박', '감성']; const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모|수용/; -/** - * 레인 설계 원칙 - * 1. 레인끼리 겹치지 않게 한다. 모든 레인에 "군산 펜션"을 넣으면 레인이 상관되고, - * 그러면 RRF 가 "여러 레인에 두루 걸린 generic 키워드"를 상위로 올린다. - * 지역+업종 앵커는 유형 레인에만 둔다. - * 2. 브랜드 레인은 두지 않는다. 상호는 사전에 없으므로 결국 "군산 펜션"만 남아 - * 가장 generic 한 것들을 끌어온다 (실측에서 상위 6개가 전부 '~예약'으로 도배됐다). - * 3. 수용 인원은 레인에 넣지 않는다. 필터 전용이다. - */ +/** 레인 설계 원칙 1. 레인끼리 겹치지 않게 한다. */ export function buildLanes(f: MerchantFacts): Lane[] { const lanes: Lane[] = []; - // 토큰 단위로 중복을 제거한다. 문자열 단위 Set 만으로는 '신흥동' 과 - // '신흥동 일본식가옥' 이 서로 다른 원소라 같은 낱말이 두 번 실리고, - // 그 낱말 쪽으로 레인이 쏠린다 (실제로 말랭이마을이 밀려났다). + // 토큰 단위로 중복을 제거한다. const push = (key: string, label: string, weight: number, parts: (string | null | undefined)[]) => { const seen = new Set(); const words: string[] = []; @@ -163,8 +146,7 @@ export function buildLanes(f: MerchantFacts): Lane[] { ...f.features.filter((x) => !isType(x) && !CAPACITY_TOKEN.test(x) && !keywordAreaGroup(x)), ].slice(0, 6); - // 권역과 인근을 한 레인으로 합친다. 나눠 두면 '신흥동' 같은 토큰이 두 레인에 겹쳐 - // 같은 위치 키워드가 두 번 가산되고, 상위가 전부 위치 키워드로 쓸려 나간다. + // 권역과 인근을 한 레인으로 합친다. push('type', '유형', 1.0, [f.region, f.industry, ...typeWords]); push('place', '위치', 0.7, [f.areaGroup, districtOf(f.address), ...f.nearby.slice(0, 4), '근처']); push('audience', '동반자', 0.6, f.audiences.slice(0, 4)); diff --git a/ontology/src/serving/match.service.ts b/ontology/src/serving/match.service.ts index 2a46081..10df7b3 100644 --- a/ontology/src/serving/match.service.ts +++ b/ontology/src/serving/match.service.ts @@ -9,12 +9,11 @@ import { keywordAreaGroup, normalizeAmenities, violatesCapacity, } from './match.rules'; -// RRF 상수를 관례값 60 대신 20 으로 낮춘다. 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라 -// 깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 상위를 차지한다. 20 이면 2.9배로 벌어진다. +// RRF 상수를 관례값 60 대신 20 으로 낮춘다. const RRF_K = 20; const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다 const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다 -// 매칭 후보로 인정하는 출처. 고정 데이터셋 정책상 LLM 생성물은 사전에 섞이면 안 된다. +// 매칭 후보로 인정하는 출처. const MATCH_SOURCES = ['dataset', 'nationwide', 'manual']; interface Hit { @@ -50,8 +49,6 @@ export class MatchService { const vectors = await this.embedder.embed(lanes.map((l) => l.text), 'query'); // 레인별 검색. - // 후보 풀을 업체 업종으로 좁힌다. 사전 전체를 뒤지면 '강남 미용실' 같은 - // 다른 업종 키워드가 후보에 섞인다 (실제로 섞여 있었다). const perLane = await Promise.all( vectors.map((v) => this.laneSearch(v, LANE_DEPTH, merchant?.industry_id ?? null)), ); @@ -107,7 +104,6 @@ export class MatchService { const top = kept.slice(0, limit); // 레인별 상위 — SEO 페이지 배분은 평평한 순위가 아니라 이쪽을 쓴다. - // (주력 키워드는 유형 레인 1위, 주변 여행 페이지는 위치 레인 상위) const keptById = new Map(kept.map((k) => [k.id, k])); const byLane = lanes.map((lane, li) => ({ key: lane.key, label: lane.label, weight: lane.weight, text: lane.text, @@ -226,12 +222,7 @@ function toFacts(m: MerchantWithTaxonomy): MerchantFacts { }; } -/** - * 고객 언어 신호를 모은다. - * hashtags : ["#군산독채", "#군산감성숙소", ...] 인스타 등 - * reviewSignals: [{ term: "바베큐", count: 47 }, ...] 리뷰 원문이 아닌 빈도 집계 - * 리뷰 원문은 받지 않는다 (저작권·개인정보). 빈도만으로 충분하다. - */ +/** 고객 언어 신호를 모은다. */ function collectSignals(p: Record): string[] { const tags = str(p['hashtags']).map((t) => t.replace(/^#/, '').trim()).filter(Boolean); const raw = Array.isArray(p['reviewSignals']) ? p['reviewSignals'] : []; diff --git a/ontology/src/serving/serving.controller.ts b/ontology/src/serving/serving.controller.ts index ce2d54f..11584c8 100644 --- a/ontology/src/serving/serving.controller.ts +++ b/ontology/src/serving/serving.controller.ts @@ -27,11 +27,7 @@ export class ServingController { return this.serving.searchKeywords(body.query, Math.min(body.limit ?? 10, 50)); } - /** - * 자유 입력(업체명/문장) → 적재된 사전에서 잘 맞는 키워드. - * mode=fusion (기본) — 속성별 서브 질의 + 가중 RRF + 사실 기반 필터 - * mode=single — 프로필을 통짜로 한 벡터에 넣는 이전 방식 (비교용) - */ + /** 자유 입력(업체명/문장) → 적재된 사전에서 잘 맞는 키워드. */ @Post('match') match(@Body() body: { query: string; limit?: number; mode?: 'fusion' | 'single' }) { const limit = Math.min(body.limit ?? 40, 200); diff --git a/ontology/src/serving/serving.service.ts b/ontology/src/serving/serving.service.ts index 6a7fb1e..b3f15ea 100644 --- a/ontology/src/serving/serving.service.ts +++ b/ontology/src/serving/serving.service.ts @@ -96,11 +96,7 @@ export class ServingService { }; } - /** - * 자유 입력(업체명 또는 문장) → 적재된 키워드 사전에서 잘 맞는 것을 골라준다. - * 업체명이면 먼저 업체를 해석해 프로필 전체를 질의문으로 쓴다 — - * 상호만으로 임베딩하면 브랜드명 하나로 검색하는 것과 같아 매칭이 얕아진다. - */ + /** 자유 입력(업체명 또는 문장) → 적재된 키워드 사전에서 잘 맞는 것을 골라준다. */ async match(rawQuery: string, limit: number) { const query = rawQuery.trim(); const merchant = await this.resolveMerchant(query); diff --git a/solution/backend/common/authz.py b/solution/backend/common/authz.py index 6e7b1a3..6d4d028 100644 --- a/solution/backend/common/authz.py +++ b/solution/backend/common/authz.py @@ -2,8 +2,5 @@ from common.enums import UserRole def is_owner_or_admin(resource_user_id, user_id, role) -> bool: - """변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자 이상(OWNER/DEVELOPER)이면 True. - - 프론트의 버튼 게이팅과 같은 규칙을 백엔드에서 강제하는 단일 출처. - 소유자 없는 공용 리소스(예: user_id NULL 공용카드)는 이 판정 대상이 아니다(도메인별 별도 처리).""" + """변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자 이상(OWNER/DEVELOPER)이면 True.""" return str(resource_user_id) == str(user_id) or (role or 0) >= UserRole.OWNER.value diff --git a/solution/backend/common/category_schema/__init__.py b/solution/backend/common/category_schema/__init__.py index 2f52d55..771453d 100644 --- a/solution/backend/common/category_schema/__init__.py +++ b/solution/backend/common/category_schema/__init__.py @@ -1,10 +1,4 @@ -"""업종별 fact 스키마 패키지. - -업종마다 필드가 완전히 다르므로(숙박=체크인시간, 카페=브레이크타임) facts 는 key-value 로 두고, -'어떤 key 가 존재하는가'는 업종별 JSON 스키마가 정의한다. - -**업종 추가 = resources/ 에 JSON 파일 1개 추가 + PlaceCategory 에 코드 1줄.** 로직 수정 없음. -""" +"""업종별 fact 스키마 패키지.""" from common.category_schema.loader import ( CategorySchema, CategorySchemaError, diff --git a/solution/backend/common/category_schema/loader.py b/solution/backend/common/category_schema/loader.py index 97a4317..c6d0c44 100644 --- a/solution/backend/common/category_schema/loader.py +++ b/solution/backend/common/category_schema/loader.py @@ -1,23 +1,4 @@ -"""업종별 fact 스키마 — 업종마다 어떤 key 가 존재하는지의 유일한 소스. - -resources/*.json 을 최초 사용 시 메모리에 로드한다. DB 에 저장하지 않으며 런타임에 수정하지 않는다. -**업종 추가 = resources/ 에 JSON 파일 1개 추가 + PlaceCategory 에 코드 1줄 추가.** 코드 수정은 없다. - -검증 실패 시 예외를 던진다(요청 실패가 아니라 잘못된 리소스 배포를 조기에 드러내기 위함 — -파일은 코드와 함께 배포되므로 정상 배포에선 실패하지 않는다). - -필드 속성 - key : facts.key 에 저장되는 식별자. 업종 안에서 유일해야 한다 - label : 화면·프롬프트에 쓰는 한글 이름 - type : text | number | bool | time - scope : place(사업장 단위) | unit(객실·메뉴·프로그램 단위) - required : 발행 검수 게이트의 필수 항목. 빠지면 PUBLISH_REQUIRED_FACT_MISSING - critical : ★ 틀리면 손님이 헛걸음하거나 예약 클레임이 나는 항목. - 미검증 상태로는 절대 노출하지 않는다(절대규칙 1) - allow_llm : LLM 이 값을 만들어도 되는 필드인가. **False 가 기본** — LLM 은 사실을 만들지 않는다. - True 인 것은 소개문처럼 '문장' 자체가 산출물인 필드뿐이다(절대규칙 7) - unit : 값의 단위(원·명·분…). 없으면 null -""" +"""업종별 fact 스키마 — 업종마다 어떤 key 가 존재하는지의 유일한 소스.""" import json from pathlib import Path @@ -36,7 +17,7 @@ class CategorySchemaError(RuntimeError): class FieldSpec: - """업종 스키마의 필드 1개. JSON 한 행에 대응한다.""" + """업종 스키마의 필드 1개.""" __slots__ = ("key", "label", "type", "scope", "required", "critical", "allow_llm", "unit") @@ -108,17 +89,16 @@ class CategorySchema: return [k for k, f in self.fields.items() if f.required and (scope is None or f.scope == scope)] def critical_keys(self) -> list[str]: - """★ 미검증 상태로 노출하면 안 되는 key 목록(체크인·취사·반려동물·취소 규정 등).""" + """미검증 상태로 노출하면 안 되는 key 목록(체크인·취사·반려동물·취소 규정 등).""" return [k for k, f in self.fields.items() if f.critical] def llm_writable_keys(self) -> list[str]: - """LLM 이 값을 만들어도 되는 key 목록. 나머지는 LLM 이 값을 채울 수 없다.""" + """LLM 이 값을 만들어도 되는 key 목록.""" return [k for k, f in self.fields.items() if f.allow_llm] def load_schemas() -> None: - """리소스 디렉터리 전체 로드 + 검증. 최초 1회 호출(멱등). - 파일을 하나 추가하면 그대로 새 업종이 된다 — 로더 코드는 건드리지 않는다.""" + """리소스 디렉터리 전체 로드 + 검증.""" global _schemas if _schemas is not None: return @@ -146,7 +126,7 @@ def load_schemas() -> None: def get_schema(category) -> CategorySchema: - """업종 코드(int 또는 PlaceCategory) → 스키마. 없는 업종이면 CategorySchemaError.""" + """업종 코드(int 또는 PlaceCategory) → 스키마.""" if _schemas is None: load_schemas() code = category.value if isinstance(category, PlaceCategory) else category @@ -157,14 +137,14 @@ def get_schema(category) -> CategorySchema: def all_schemas() -> dict: - """전 업종 스키마. {code: CategorySchema}""" + """전 업종 스키마.""" if _schemas is None: load_schemas() return dict(_schemas) def is_valid_key(category, key: str) -> bool: - """해당 업종에 존재하는 fact key 인지. facts 쓰기 전 검증에 쓴다(FACT_INVALID_KEY).""" + """해당 업종에 존재하는 fact key 인지.""" try: return get_schema(category).has(key) except CategorySchemaError: diff --git a/solution/backend/common/collect_diagnostics.py b/solution/backend/common/collect_diagnostics.py index 5831ede..be9e482 100644 --- a/solution/backend/common/collect_diagnostics.py +++ b/solution/backend/common/collect_diagnostics.py @@ -1,8 +1,4 @@ -"""수집(크롤링) 중 실패를 jobs.result 에 구조화해서 싣는다 — 워커 로그 grep 없이 확인용. - -★ contextvars 로 든다 — 실패 지점이 흩어진 여러 함수에 리스트를 관통시키지 않는다. - 자세한 배경은 DEVLOG.md 참고. -""" +"""수집(크롤링) 중 실패를 jobs.result 에 구조화해서 싣는다 — 워커 로그 grep 없이 확인용.""" from contextlib import contextmanager from contextvars import ContextVar from dataclasses import asdict, dataclass @@ -11,8 +7,7 @@ from common.logger import LOG _current: ContextVar[list["CollectIssue"] | None] = ContextVar("_collect_issues", default=None) -# jobs.result 는 DB 에 그대로 쌓인다 — 예외 메시지가 길어지는(HTML 응답 전체를 문 등) 경우가 -# 있어 상한을 둔다. 잘린 메시지도 원인 파악엔 충분하고, 전체는 여전히 로그에 남는다. +# jobs.result 는 DB 에 그대로 쌓인다 — 예외 메시지가 길어지는(HTML 응답 전체를 문 등) 경우가 있어 상한을 둔다. _MAX_MESSAGE = 500 _MAX_TARGET = 200 @@ -27,7 +22,7 @@ class CollectIssue: @contextmanager def collecting(): - """run_collect() 진입부에서 한 번 연다. 중첩 호출은 바깥 것을 그대로 쓴다.""" + """run_collect() 진입부에서 한 번 연다.""" token = _current.set([]) try: yield @@ -36,10 +31,7 @@ def collecting(): def note_issue(stage: str, target: str, ex: Exception) -> CollectIssue: - """실패 한 건을 기록하고 기존과 같은 형식으로 로그도 남긴다. - - collecting() 없이 불러도 죽지 않는다 — 그때는 기록만 안 되고 로그는 그대로 남는다 - (단발 호출·테스트 호환).""" + """실패 한 건을 기록하고 기존과 같은 형식으로 로그도 남긴다.""" issue = CollectIssue( stage=stage, target=target[:_MAX_TARGET], @@ -54,6 +46,6 @@ def note_issue(stage: str, target: str, ex: Exception) -> CollectIssue: def snapshot() -> list[dict]: - """지금까지 쌓인 실패 목록. run_collect() 가 끝에서 jobs.result 에 싣는다.""" + """지금까지 쌓인 실패 목록.""" issues = _current.get() return [asdict(i) for i in issues] if issues else [] diff --git a/solution/backend/common/cost.py b/solution/backend/common/cost.py index 10a5e7b..57e6703 100644 --- a/solution/backend/common/cost.py +++ b/solution/backend/common/cost.py @@ -1,20 +1,4 @@ -"""사이트 1건 생성 원가 미터 — **$1 예산을 코드로 강제한다.** - -★ 제품 제약: **업소 1곳의 사이트를 만드는 데 드는 외부 API 비용은 $1(1,400원)을 넘으면 안 된다.** - 이 모듈은 그 예산을 "문서에 적힌 목표" 가 아니라 **호출을 막는 가드**로 만든다. - - 쓰는 법 — 사이트 1건 = 미터 1개: - - meter = CostMeter(place_id=42) - meter.guard(Provider.PERPLEXITY, calls=1, tokens=3000) # 호출 "전" 에 물어본다 - ...실제 호출... - meter.charge(Provider.PERPLEXITY, calls=1, tokens=3100) # 호출 "후" 에 실측을 적는다 - - `guard()` 가 BudgetExceeded 를 던지면 **그 호출은 하지 않는다.** 이미 쓴 돈은 못 돌려받지만 - 다음 호출을 막아 손실이 선형으로 늘어나는 것을 끊는다. - -비용 리스크 순서(큰 것부터): Gemini(사진 장수에 비례) > Perplexity(토큰+요청 요금) > Kakao(건당 2원). -""" +"""사이트 1건 생성 원가 미터 — **$1 예산을 코드로 강제한다.**""" from dataclasses import dataclass, field from enum import Enum @@ -22,9 +6,9 @@ from enum import Enum from common.logger import LOG # ── 예산 ──────────────────────────────────────────────────────── -USD_KRW = 1400.0 # 환산 환율. 실제 청구 환율과 다를 수 있다. -SITE_BUDGET_KRW = 1400.0 # ★ 사이트 1건당 상한 = $1 -# 예산의 몇 %까지 차면 경고를 남길지. 넘겨도 막지는 않는다 — 막는 건 100% 지점이다. +USD_KRW = 1400.0 # 환산 환율. +SITE_BUDGET_KRW = 1400.0 # 사이트 1건당 상한 = $1 +# 예산의 몇 %까지 차면 경고를 남길지. WARN_RATIO = 0.7 @@ -39,12 +23,7 @@ class Provider(Enum): @dataclass(frozen=True) class Rate: - """공급자별 단가(원 기준). - - confirmed=False 는 **아직 공식 단가표로 확인하지 않은 추정치**라는 뜻이다. - 추정치로 예산을 계산하면 "예산 안" 이라는 결론 자체가 추정이 된다 — - 실제 배치를 돌리기 전에 `assert_rates_confirmed()` 로 막는다. - """ + """공급자별 단가(원 기준).""" per_call_krw: float = 0.0 # 호출 1건당 고정비 per_search_krw: float = 0.0 # 검색 1회당 (Perplexity 는 토큰과 별도 과금) @@ -55,26 +34,22 @@ class Rate: # ── 단가표 ────────────────────────────────────────────────────── -# ★ 확정된 것만 confirmed=True 다. 나머지는 자리만 잡아둔 추정치이므로 -# 공식 단가표를 확인해서 교체하기 전에는 실배치를 돌리면 안 된다. RATES: dict[Provider, Rate] = { Provider.THREADS: Rate(confirmed=False, source="공개 과금 미확인 — API_USAGE 5절; 계정 계약비는 별도"), # 레포에 확정값이 있다(.env.example): 키워드/카테고리 검색 2원, 좌표 변환 0.5원. - # 좌표 변환은 per_call 로 따로 세지 않고 호출측이 kakao_coord 로 구분해 넘긴다. Provider.KAKAO: Rate( per_call_krw=2.0, confirmed=True, source=".env.example — 키워드/카테고리 검색 2원 (무료 쿼터 초과분)", ), # Sonar 기본 search_context_size=low: 요청 $5/1K + 입력/출력 각각 $1/1M. - # 2026-08-28 환산(USD_KRW=1,400)이다. 내부 검색 횟수에는 별도 요금이 없다. Provider.PERPLEXITY: Rate( per_call_krw=7.0, per_1k_token_krw=1.4, confirmed=True, source="https://docs.perplexity.ai/docs/getting-started/pricing — Sonar low context", ), - # ★ 미확인 — Google AI Studio 단가표 확인 후 교체할 것. + # 미확인 — Google AI Studio 단가표 확인 후 교체할 것. Provider.GEMINI: Rate( per_image_krw=1.0, per_1k_token_krw=0.5, @@ -91,7 +66,6 @@ KAKAO_COORD_KRW = 0.5 class BudgetExceeded(Exception): - """사이트 1건 예산을 넘겼다. 호출측은 **더 호출하지 말고** 작업을 중단한다.""" def __init__(self, place_id: int | None, spent_krw: float, would_add_krw: float): self.place_id = place_id @@ -108,10 +82,7 @@ class UnconfirmedRate(Exception): def assert_rates_confirmed(*providers: Provider) -> None: - """실배치 직전에 호출한다. 추정 단가가 섞여 있으면 막는다. - - ★ 이걸 건너뛰면 "예산 안에 들어온다" 는 결론이 추정 위에 서게 된다. - """ + """실배치 직전에 호출한다.""" bad = [p for p in (providers or tuple(RATES)) if not RATES[p].confirmed] if bad: names = ", ".join(p.value for p in bad) @@ -129,7 +100,7 @@ def estimate_krw( images: int = 0, kakao_coord_calls: int = 0, ) -> float: - """이번 호출의 원가(원)를 계산한다. 실측이든 예상이든 같은 식을 쓴다.""" + """이번 호출의 원가(원)를 계산한다.""" rate = RATES[provider] krw = ( rate.per_call_krw * calls @@ -144,7 +115,7 @@ def estimate_krw( @dataclass class CostMeter: - """사이트 1건(업소 1곳)의 원가 누적기. **미터 1개 = 사이트 1건**이다.""" + """사이트 1건(업소 1곳)의 원가 누적기.""" place_id: int | None = None budget_krw: float = SITE_BUDGET_KRW @@ -162,20 +133,14 @@ class CostMeter: return self.spent_krw / USD_KRW def guard(self, provider: Provider, **units) -> float: - """호출 **전** 에 예산을 확인한다. 넘으면 BudgetExceeded — 호출하지 마라. - - 돌려주는 값은 이번 호출의 예상 원가(원)다. - """ + """호출 **전** 에 예산을 확인한다.""" krw = estimate_krw(provider, **units) if self.spent_krw + krw > self.budget_krw: raise BudgetExceeded(self.place_id, self.spent_krw, krw) return krw def charge(self, provider: Provider, **units) -> float: - """호출 **후** 에 실측 사용량을 적는다. 적고 나서 예산을 넘었으면 예외를 던진다. - - ★ 이미 나간 호출은 되돌릴 수 없다 — 예외의 목적은 **다음 호출을 막는 것**이다. - """ + """이미 나간 호출은 되돌릴 수 없다 — 예외의 목적은 **다음 호출을 막는 것**이다.""" krw = estimate_krw(provider, **units) self.spent_krw += krw self.calls += 1 diff --git a/solution/backend/common/database/db_session_manager.py b/solution/backend/common/database/db_session_manager.py index a2c08f6..1098d42 100644 --- a/solution/backend/common/database/db_session_manager.py +++ b/solution/backend/common/database/db_session_manager.py @@ -13,16 +13,7 @@ from config.server_configs import main_db_config class DBSessionManager(Singleton): - """DB 세션/엔진 관리자 (싱글톤). - - 핵심 패턴 - - DBType(논리 DB) x DBWRType(Read/Write) 조합마다 별도 async 엔진을 둔다. - => 조회는 Read 복제본, 변경은 Write 주 DB 로 자연스럽게 분리된다. - - 비즈니스 로직(service)은 직접 세션을 열지 않고 "람다"를 넘긴다. - execute_lambda : 단일 쿼리 (주로 조회) - execute_lambda_run : 동일 DB 의 여러 변경 쿼리를 한 트랜잭션으로 commit - 세션 open/close 와 commit/rollback 은 매니저가 책임진다. - """ + """DB 세션/엔진 관리자 (싱글톤).""" def __init__(self): if DBSessionManager.is_init(): @@ -33,7 +24,7 @@ class DBSessionManager(Singleton): self.__DB_URL_MAP = {"postgresql": "postgresql+asyncpg"} # 종료 시 dispose 하기 위해 생성한 엔진을 모아둔다. self.__engines = [] - # 논리 DB -> config. DB 가 늘어나면 여기에 추가만 하면 된다. + # 논리 DB -> config. self.__db_type_map = { DBType.MAIN.value: main_db_config, } @@ -61,7 +52,7 @@ class DBSessionManager(Singleton): db_url = f"{self.__DB_URL_MAP[db_config.db_type]}://{db_config.write_id}{pw}@{db_config.write_host}:{db_config.write_port}/{db_config.name}" LOG.i(f"Write DB create engine url : {db_url}") - # SSL/TLS: 관리형 DB(RDS/Aurora/Azure)는 보통 TLS 필수. sslmode 가 설정되면 asyncpg 에 전달. + # SSL/TLS: 관리형 DB(RDS/Aurora/Azure)는 보통 TLS 필수. connect_args = {} sslmode = (getattr(db_config, "sslmode", "") or "").lower() if sslmode and sslmode != "disable": @@ -88,13 +79,11 @@ class DBSessionManager(Singleton): return db_type in self.__db_type_map async def dispose_all(self): - """모든 엔진의 커넥션 풀을 정리한다. 앱 종료/테스트 종료 시 호출한다. - 호출하지 않으면 풀 커넥션이 이벤트 루프 종료 후 GC 되며 경고를 남긴다. - """ + """모든 엔진의 커넥션 풀을 정리한다.""" for engine in self.__engines: await engine.dispose() - # ---- 세션 lifecycle ------------------------------------------------- + # 세션 lifecycle async def start_session(self, db_type: int, db_wr_type: int) -> AsyncSession: if db_wr_type == DBWRType.DB_WRITE.value: return self.__write_session[db_type]() @@ -106,16 +95,14 @@ class DBSessionManager(Singleton): else: await self.__read_session[db_type].remove() - # ---- 저수준 DB 연산 (crud 에서 호출) -------------------------------- + # 저수준 DB 연산 (crud 에서 호출) async def run(self, db: AsyncSession, err_msg="DB Run Failed", raise_error=True) -> ErrorType: try: await db.commit() return ErrorType.SUCCESS except IntegrityError as ex: await db.rollback() - # ★ 유니크 제약 충돌은 호출부가 "이미 있음"으로 처리하는 정상 경로다 - # (services/collect_service.py `_add_link`). ERROR 로 찍지 않는다 — 진짜 못 - # 보던 무결성 오류는 아래 일반 Exception 갈래로 간다. + # 유니크 제약 충돌은 호출부가 "이미 있음"으로 처리하는 정상 경로다 (services/collect_service.py `_add_link`). LOG.w(f"duplicated. {ex}") return ErrorType.DB_ALREADY_SAME_KEY except Exception as ex: @@ -164,8 +151,7 @@ class DBSessionManager(Singleton): return err_type async def add_with_rowcount(self, db: AsyncSession, query, err_msg="DB Operation Failed") -> tuple[ErrorType, int]: - """update/delete 등 비-select 쿼리 실행 후 (ErrorType, 영향행수) 반환. - 조건부 갱신(WHERE 로 상태를 거른 UPDATE)이 실제로 적용됐는지 판별하는 동시처리 가드용.""" + """update/delete 등 비-select 쿼리 실행 후 (ErrorType, 영향행수) 반환.""" try: if hasattr(query, "column_descriptions"): raise RuntimeError("DO NOT USE SELECT QUERY IN DBJOB") @@ -196,9 +182,9 @@ class DBSessionManager(Singleton): raise RuntimeError(err_type.name, err_msg) return err_type, [] - # ---- 람다 실행 진입점 (service 에서 호출) --------------------------- + # 람다 실행 진입점 (service 에서 호출) async def execute_lambda(self, db_type: int, db_wr_type: int, func): - """단일 쿼리 호출. func(session) 한 개를 실행하고 결과를 그대로 반환.""" + """단일 쿼리 호출.""" s = await self.start_session(db_type, db_wr_type) try: return await func(s) @@ -206,9 +192,7 @@ class DBSessionManager(Singleton): await self.end_session(db_type, db_wr_type) async def execute_lambda_run(self, db_type_list: list[int], func_list: list): - """동일 DB 의 변경 쿼리 여러 개를 한 트랜잭션으로 실행 후 commit. - 하나라도 SUCCESS 가 아니면 즉시 중단(rollback)된다. - """ + """동일 DB 의 변경 쿼리 여러 개를 한 트랜잭션으로 실행 후 commit.""" temp_list = list(set(db_type_list)) if len(temp_list) != 1: return ErrorType.DB_INVALID_TYPE @@ -228,12 +212,7 @@ class DBSessionManager(Singleton): await self.end_session(db_type, DBWRType.DB_WRITE.value) async def execute_lambda_write(self, db_type: int, func): - """Write 세션에서 func(session) 을 실행하고 commit 한 뒤 **func 의 반환값을 그대로** 돌려준다. - - execute_lambda_run 은 ErrorType 만, execute_lambda_claim 은 (ErrorType, 적용행수) 만 돌려준다. - 작업 큐처럼 "변경하면서 값을 받아와야" 하는 경우(RETURNING 절)를 위한 진입점이다 — - 원자적 claim(FOR UPDATE SKIP LOCKED + UPDATE + RETURNING)은 조회/변경을 나눌 수 없다. - 예외는 rollback 후 그대로 전파한다(호출측이 잡 실패로 처리).""" + """Write 세션에서 func(session) 을 실행하고 commit 한 뒤 **func 의 반환값을 그대로** 돌려준다.""" s = await self.start_session(db_type, DBWRType.DB_WRITE.value) try: result = await func(s) @@ -246,9 +225,7 @@ class DBSessionManager(Singleton): await self.end_session(db_type, DBWRType.DB_WRITE.value) async def execute_lambda_claim(self, db_type: int, func) -> tuple[ErrorType, int]: - """조건부 변경 쿼리 1건을 한 트랜잭션으로 실행/commit 하고 (ErrorType, 적용행수) 반환. - 동시처리 가드용 — func(session) -> (ErrorType, rowcount). 적용행수 0 이면 다른 호출자가 이미 처리한 것. - (Postgres READ COMMITTED 에서 같은 행 UPDATE 는 행 잠금으로 직렬화되어, 진 호출자는 0 을 받는다.)""" + """조건부 변경 쿼리 1건을 한 트랜잭션으로 실행/commit 하고 (ErrorType, 적용행수) 반환.""" s = await self.start_session(db_type, DBWRType.DB_WRITE.value) try: err_type, rowcount = await func(s) diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index e65846e..e549fe6 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -22,27 +22,13 @@ from common.enums import ( JobStatus, ) -# 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다. +# 모든 ORM 모델의 베이스. MAIN_BASE = declarative_base() -# 공통 mixin -# DB 계약(_DBTypeMixin)과 ERD 공통 컬럼(MainTableMixin)을 분리해 둔다. +# 공통 mixin DB 계약(_DBTypeMixin)과 ERD 공통 컬럼(MainTableMixin)을 분리해 둔다. def _utc_now_sql(): - """TIMESTAMPTZ 컬럼의 기본값. **init.sql 과 같은 `now()` 여야 한다.** - - ★ 예전 값은 `(now() AT TIME ZONE 'utc')` 였는데, 이건 timestamptz 에 쓰면 틀린다. - AT TIME ZONE 'utc' 는 timestamptz 를 **시간대 없는 벽시계 값**으로 떨어뜨리고, - 그 값이 timestamptz 컬럼에 들어가며 세션 시간대로 다시 해석된다 — 서버 시간대만큼 - 미래(또는 과거)로 밀린 시각이 저장된다. - - ★ 운영에서는 안 드러났다. 운영 DB 는 init.sql(`DEFAULT now()`)로 만들어지고, 이 기본값은 - **ORM 이 스키마를 만들 때만** 쓰이기 때문이다 — 즉 테스트 DB 뿐이다(conftest). - 실측(2026-09-10): 테스트에서 잡의 run_after 가 7시간 뒤로 박혀 claim 조건 - (`run_after <= now()`)에 영영 안 걸렸다. 워커가 잡을 하나도 못 집어 COPY 관련 테스트가 - "잡이 PENDING 인 채" 로 무더기 실패했고, 원인이 코드가 아니라 스키마라 읽히지 않았다. - → 스키마는 init.sql 이 단일 출처다. ORM 기본값이 그것과 다르면 이런 식으로 갈라진다. - """ + """TIMESTAMPTZ 컬럼의 기본값.""" return text("now()") @@ -66,11 +52,8 @@ class users(MainTableMixin, MAIN_BASE): __tablename__ = "users" user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - # 20자였다. 구글 계정의 로그인 아이디를 `google_`(최대 28자)로 만들면서 넓혔다 — - # sub 를 잘라 쓰면 앞자리가 같은 두 계정이 한 아이디로 겹친다. id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디 - # 소셜 계정은 비밀번호가 없다(NULL). 더미 해시를 넣으면 "비번이 있는 계정" 처럼 보여 - # id/pw 로그인 경로가 그 계정을 상대로 계속 시도된다. + # 소셜 계정은 비밀번호가 없다(NULL). password = Column(String(255), nullable=True) # bcrypt 해시 (ERD VARCHAR(30)→255 확장) name = Column(String(50), nullable=True) email = Column(String(255), nullable=True) @@ -78,25 +61,16 @@ class users(MainTableMixin, MAIN_BASE): last_accessed_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value) role = Column(SmallInteger, nullable=False, default=UserRole.USER.value) - # server_default 를 함께 준다 — ORM default 는 raw INSERT(테스트 시드·수동 SQL)에 안 먹어서 - # 컬럼이 NOT NULL 이면 그 경로가 통째로 깨진다(init.sql 의 DEFAULT 1 과 같은 값). + # server_default 를 함께 준다 — ORM default 는 raw INSERT(테스트 시드·수동 SQL)에 안 먹어서 컬럼이 NOT NULL 이면 그 경로가 통째로 깨진다(init.sql 의 DEFAULT 1 과 같은 값). provider = Column(SmallInteger, nullable=False, server_default=text("1"), default=AuthProvider.LOCAL.value) provider_uid = Column(String(255), nullable=True) # 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키 - # ★ refresh 토큰 무효화 키. JWT(access·refresh 둘 다)의 sub 에 이 값을 같이 싣는다 - # (common/models/gmodel.py UserInfo). refresh_token() 이 DB 의 지금 값과 대조해서, - # 달라졌으면(비밀번호 변경 등으로 bump_token_version 이 불렸으면) 재발급을 거절한다. - # ★ access 토큰 자체는 검사하지 않는다 — 그건 30분짜리라 노출 창이 이미 좁다. 문제는 - # refresh 토큰(7일)이 DB 를 한 번도 안 보고 계속 access 토큰을 찍어 내던 것이었다. + # refresh 토큰 무효화 키. token_version = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) -# ============================================================ # place : 사업장 / 별칭 / 채널 링크 / 객실·메뉴·프로그램 / 사진 -# ============================================================ class places(MainTableMixin, MAIN_BASE): - """사업장. 상호명 하나로 시작해서, 카카오 로컬 검증을 통과해야 수집이 열린다. - - ★ verified_at 이 NULL 이면 collector 진입 금지 — 검증 없이 수집하면 남의 가게가 섞인다.""" + """사업장.""" __tablename__ = "places" __table_args__ = ( @@ -104,15 +78,13 @@ class places(MainTableMixin, MAIN_BASE): ) place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - # ★ 스코프 키. 사장님 한 명이 자기 가게만 본다 — 회사(테넌트)를 걷어내면서 이 컬럼이 그 자리를 받았다. + # 스코프 키. owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(users) name = Column(String(200), nullable=False) # 상호명(입력값) category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키 status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value) - # ---- 카카오 로컬 검증 산출물 (동일 업소 판정) ---- - # 동일 업소 판정 키. 소스에 따라 있을 수도 없을 수도 있다 — - # 카카오는 고유 id 를 주지만 네이버는 안 준다(그 경우 상호명+도로명주소가 대체 키). + # 카카오 로컬 검증 산출물 (동일 업소 판정) external_source = Column(SmallInteger, nullable=True) # ExternalPlaceSource external_place_id = Column(String(64), nullable=True) road_address = Column(String(255), nullable=True) @@ -121,26 +93,19 @@ class places(MainTableMixin, MAIN_BASE): latitude = Column(Numeric(10, 7), nullable=True) longitude = Column(Numeric(10, 7), nullable=True) region_code = Column(String(10), nullable=True) # 행정구역 코드 — ★ 지역정보 캐시 키(사이트 50개여도 조회 1회) - # 외부 장소 DB 가 준 분류 문자열 원문(카카오 "음식점 > 한식 > 육류" · 네이버 "펜션"). 검증 때 박제한다. - # ★ 쓰임: 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준. TourAPI 에 등록된 업장이면 그쪽 분류가 우선이고, - # 이 값은 그 폴백이다(services/local_content_service._own_food_class). + # 외부 장소 DB 가 준 분류 문자열 원문(카카오 "음식점 > 한식 > 육류" · 네이버 "펜션"). external_category = Column(String(200), nullable=True) - verified_at = Column(DateTime(timezone=True), nullable=True) # ★ NULL = 미검증 → 수집·발행 금지 + verified_at = Column(DateTime(timezone=True), nullable=True) # NULL = 미검증 → 수집·발행 금지 verified_by = Column(UUID(as_uuid=True), nullable=True) - # ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 — - # site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다. + # 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. content_updated_at = Column(DateTime(timezone=True), nullable=True) - # 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체(services/blog_jobs.py send_reviewed) — - # 사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다. + # 미니 블로그 승인 메일 수신 주소. notify_email = Column(String(255), nullable=True) class place_channels(MainTableMixin, MAIN_BASE): - """Perplexity 가 발견한 채널 URL. - - ★ confirmed_at 이 NULL 이면 크롤링 대상이 아니다 — 카카오 로컬로 동일 업소임을 확인한 URL만 넘긴다. - raw 에 Perplexity 응답(본문 + search_results)을 통째로 남긴다. 환각 추적용이며 사실 근거로 쓰지 않는다.""" + """Perplexity 가 발견한 채널 URL.""" __tablename__ = "place_channels" __table_args__ = ( @@ -160,14 +125,13 @@ class place_channels(MainTableMixin, MAIN_BASE): title = Column(String(300), nullable=True) # 발견 시 제목/스니펫 discovered_by = Column(SmallInteger, nullable=False) # SourceType (API=Perplexity, OWNER=직접 입력) discovered_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) - confirmed_at = Column(DateTime(timezone=True), nullable=True) # ★ NULL = 미확정, 크롤링 금지 + confirmed_at = Column(DateTime(timezone=True), nullable=True) # NULL = 미확정, 크롤링 금지 confirmed_by = Column(UUID(as_uuid=True), nullable=True) raw = Column(JSONB, nullable=True) # Perplexity 응답 원문(본문 + search_results) class place_units(MainTableMixin, MAIN_BASE): - """업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램. - 가변 필드는 facts(scope=unit)로 들어가고, 여기에는 목록 렌더에 필요한 뼈대만 둔다.""" + """업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램.""" __tablename__ = "place_units" @@ -178,11 +142,7 @@ class place_units(MainTableMixin, MAIN_BASE): class place_photos(MainTableMixin, MAIN_BASE): - """사진. Gemini Vision 이 분류 라벨과 alt 를 만든다. - - ★ source_type 을 반드시 남긴다 — 크롤링 이미지의 재게시 권리가 미결이라(docs/DECISIONS.md 1-2), - 결론에 따라 발행 시 source_type 으로 걸러낼 수 있어야 한다. - ★ vision_confidence 가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 사람 확인 큐에 둔다.""" + """사진.""" __tablename__ = "place_photos" @@ -203,12 +163,7 @@ class place_photos(MainTableMixin, MAIN_BASE): class place_songs(MainTableMixin, MAIN_BASE): - """이 숙소의 노래. 발행할 때마다 한 곡 만든다 — 가사는 Gemini, 작곡은 Suno. - - ★ 검증 상태(FactStatus)가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라 - "맞는가" 를 물을 대상이 아니다. 상태는 "만들어졌는가" 하나다(SongStatus). - ★ origin_url(Suno 가 준 주소)은 **사이트에 싣지 않는다.** 만료되는 주소라 그대로 두면 - 몇 주 뒤 재생만 조용히 죽는다 — 받아서 보관한 file_name 만 발행본으로 나간다.""" + """이 숙소의 노래.""" __tablename__ = "place_songs" @@ -219,30 +174,20 @@ class place_songs(MainTableMixin, MAIN_BASE): style = Column(String(200), nullable=True) # Suno 에 넘긴 장르·분위기 provider = Column(String(40), nullable=False, server_default=text("'suno'"), default="suno") provider_task_id = Column(String(120), nullable=True) # Suno taskId — 폴링의 유일한 열쇠 - origin_url = Column(String(1000), nullable=True) # ★ 만료되는 주소. 보관용 기록일 뿐이다 + origin_url = Column(String(1000), nullable=True) # 만료되는 주소. file_name = Column(String(200), nullable=True) # solution/site/songs/<이것> duration_sec = Column(Numeric(6, 2), nullable=True) status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SongStatus.GENERATING.value) last_error = Column(Text, nullable=True) -# ============================================================ # fact : 사실 / FAQ -# ============================================================ class place_facts(MainTableMixin, MAIN_BASE): - """★ 가장 중요한 테이블. 모든 사실은 값과 함께 출처·수집시각·검증상태를 갖는다. - - - key 는 업종 스키마(common/category_schema)에 정의된 것만 허용한다. - - unit_id 가 NULL 이면 사업장 단위 fact, 있으면 객실·메뉴·프로그램 단위 fact. - - ★ VERIFIED / CORRECTED 만 사이트에 노출한다(PUBLISHABLE_FACT_STATUSES). - - ★ CORRECTED(사장님 수정본)는 잠긴다 — 자동 갱신이 덮어쓰지 않는다. - - 활성 유니크: 같은 (place, unit, key) 로 살아있는 fact 는 1건. REJECTED/EXPIRED 는 이력으로 남기므로 제외한다.""" + """가장 중요한 테이블.""" __tablename__ = "place_facts" __table_args__ = ( # unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 place 단위 / unit 단위를 나눠 건다. - # 노출값은 (사업장, 단위, key) 당 1건. 후보(1,2)·이력(5,6)은 제외 — 재수집이 쌓일 수 있게. Index( "uq_facts_published_place_key", "place_id", @@ -291,12 +236,7 @@ class place_facts(MainTableMixin, MAIN_BASE): class place_faqs(MainTableMixin, MAIN_BASE): - """FAQ. 출처(generated_by)가 셋이고, 근거를 요구하는 정도가 다르다. - - LLM 확보된 fact 로 쓴 문장 — source_fact_ids 에 근거 key 가 있다(없으면 저장하지 않는다) - OWNER 사장님이 쓰거나 고친 문장 — 사람이 곧 출처라 근거 key 가 없을 수 있다 - TEMPLATE 목표 수를 채운 공통 질문 + 문의 안내 답(services/faq_fill) — 주장이 없어 근거도 없다. - ★ 화면에는 나가지만 FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수에서는 빠진다.""" + """FAQ.""" __tablename__ = "place_faqs" @@ -311,22 +251,7 @@ class place_faqs(MainTableMixin, MAIN_BASE): class place_itineraries(MainTableMixin, MAIN_BASE): - """LLM 이 만든 여행 일정. **기간당 한 행**이고 `body` 에 코스 5개가 통째로 든다. - - ★ 왜 area_contents 가 아닌가 - 그 표의 유일성 근거는 셋 다 지역·출처 기준이다((source, external_id) · - (region_code, kind) · (region_code, content_type)). 이 값은 **업장 하나에 붙는다** — - 업소 이름이 프롬프트에 들어가고, 같은 지역 옆집이 나눠 쓸 수 없다. - 넷째 근거를 그 표에 더하면 0004·0007 에서 겪은 "제약이 겹쳐 조용히 틀리는" 사고를 - 다시 만든다(area_contents.__table_args__ 주석). - - ★ body 는 렌더러 계약 그대로다(`shared/lib/section-data.ts` 의 ItineraryItem[]). - 읽는 쪽이 모양을 다시 바꾸지 않아야 사장님이 손으로 붙여넣은 것과 갈리지 않는다 — - 지역 이야기가 body 에 봉투째 담는 것과 같은 이유다. - - ★ 코스마다 한 행으로 쪼개지 않는다. 다시 생성할 때 그 한 행을 덮어쓰면 되고, - 쪼개면 "5개를 받았는데 3개만 갱신된" 상태가 생긴다. - """ + """LLM 이 만든 여행 일정.""" __tablename__ = "place_itineraries" __table_args__ = ( @@ -340,7 +265,7 @@ class place_itineraries(MainTableMixin, MAIN_BASE): place_itinerary_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) place_id = Column(UUID(as_uuid=True), nullable=False, index=True) - # '1박 2일' · '2박 3일'. 화면 탭이 되는 값이라 표기를 바꾸지 않는다(prompts/itinerary.DURATIONS). + # '1박 2일' · '2박 3일'. duration = Column(String(20), nullable=False) body = Column(JSONB, nullable=False) # ItineraryItem[] generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM @@ -348,26 +273,13 @@ class place_itineraries(MainTableMixin, MAIN_BASE): generated_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) -# ============================================================ # local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변 -# ============================================================ class area_contents(MainTableMixin, MAIN_BASE): - """지역 정보 캐시. ★ 키는 place_id 가 아니라 region_code 다 — - 같은 지역에 사이트 50개가 생겨도 외부 조회는 1회여야 한다. - - ★ 외부 API 실패 시 이 행을 지우거나 비우지 않는다 — 직전 값을 그대로 유지하고 내부 알림만 낸다.""" + """지역 정보 캐시.""" __tablename__ = "area_contents" - # ★ 유일성의 근거가 셋이고 **서로 겹치면 안 된다.** 겹쳐서 조용히 틀린 적이 있다 — - # 지역 이야기 다섯 종이 (region_code, content_type=6) 하나를 두고 부딪쳐 **첫 종류만 - # 저장되고 잡은 "성공" 으로 끝났다**(실측 2026-09-09, 52군산시: 생성 54건 · 저장 1종류). - # 그래서 조건에 external_id / kind 의 유무를 넣어 셋이 각자 자기 몫만 보게 가른다. - # ★ 이 세 정의는 init.sql · migrations(0004·0007·0008) 과 **같아야 한다.** 테스트 DB 는 - # 이 모델로 세워지므로, 어긋나면 테스트가 운영과 다른 제약 아래에서 돈다 — - # 실제로 그랬다: 여기만 옛 정의로 남아, 운영 DB 가 허용하는 행을 테스트가 거부했다. __table_args__ = ( - # 축제·관광지·맛집: 출처가 준 id 하나면 한 행이다. **지역과 무관하다** — - # 같은 축제가 시군구마다 한 행씩 생기면 "공용 한 벌" 이 아니다(0004). + # 축제·관광지·맛집: 출처가 준 id 하나면 한 행이다. Index( "uq_local_contents_external", "source", @@ -383,7 +295,7 @@ class area_contents(MainTableMixin, MAIN_BASE): unique=True, postgresql_where=text("deleted = false AND kind IS NOT NULL AND external_id IS NULL"), ), - # 날씨: 지역 × 종류당 한 행. kind 가 있는 행은 위가 책임지므로 여기서 뺀다(0007). + # 날씨: 지역 × 종류당 한 행. Index( "uq_local_contents_single", "region_code", @@ -394,9 +306,7 @@ class area_contents(MainTableMixin, MAIN_BASE): ) local_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - # ★ nullable 이다. 축제·관광지·맛집은 **전국 공용**이라 지역이 유일성의 근거가 아니다 — - # 같은 축제가 시군구마다 한 행씩 생기면 "한 벌" 이 아니다(migrations/0004). - # 지역 이야기·날씨만 이 값을 키로 쓴다. + # nullable 이다. region_code = Column(String(10), nullable=True, index=True) # 카카오 행정구역 코드 content_type = Column(SmallInteger, nullable=False) # LocalContentType source = Column(SmallInteger, nullable=False) # LocalSource @@ -410,18 +320,14 @@ class area_contents(MainTableMixin, MAIN_BASE): display_end_at = Column(DateTime(timezone=True), nullable=True) collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) expires_at = Column(DateTime(timezone=True), nullable=True) # TTL — 지나면 갱신 대상(값은 유지) - # ★ 0004 에서 늘렸다. 좌표는 body 안에도 있지만 거리 계산이 행마다 JSON 을 펴야 해서 꺼냈다. latitude = Column(Numeric(10, 7), nullable=True) longitude = Column(Numeric(10, 7), nullable=True) - # 지역 이야기(songs·people·chronicle·postcard·quiz)의 종류. 장소류는 NULL. + # 지역 이야기(songs·people·chronicle·postcard·quiz)의 종류. kind = Column(String(50), nullable=True) class place_area_refs(MainTableMixin, MAIN_BASE): - """업장 ↔ 지역 콘텐츠. 업장별로 다른 것은 거리와 숨김뿐이다. - - ★ 예전엔 값을 통째로 들고 키가 place_id 라 업장마다 복제됐다(한 곳에 144행). - ★ hidden 은 재수집이 덮어쓰지 않는다.""" + """업장 ↔ 지역 콘텐츠.""" __tablename__ = "place_area_refs" @@ -433,11 +339,7 @@ class place_area_refs(MainTableMixin, MAIN_BASE): class place_posts(MainTableMixin, MAIN_BASE): - """미니 블로그 글 하나. 기획: docs/MINI_BLOG.md - - ★ 승인 토큰은 해시만 둔다 — 평문은 메일 본문에만 있다. - ★ (place_id, topic_key) 가 유니크라 같은 주제로 두 번 만들어지지 않는다. - ★ (place_id, scheduled_date) 도 유니크다 — 하루 한 통 배정이라 같은 날을 두 번 못 쓴다.""" + """미니 블로그 글 하나.""" __tablename__ = "place_posts" __table_args__ = ( @@ -454,11 +356,8 @@ class place_posts(MainTableMixin, MAIN_BASE): topic_kind = Column(SmallInteger, nullable=False) topic_key = Column(String(120), nullable=False) status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PostStatus.DRAFT.value) - # 이 업장 몫 하루 한 통 배정일(KST). 생성 시 순서대로 채운다(blog_jobs._next_scheduled_date). + # 이 업장 몫 하루 한 통 배정일(KST). scheduled_date = Column(Date, nullable=True) - # 생성 당시 부가정보(모델명 등) — 컬럼을 늘리지 않고 JSONB 한 칸에 담는다(2026-09-17, - # 사장님 지시: "생성이력도 상세하게 기록해놓으셈 어느 모델썼는지 등등" → "Jsonb 하나 - # 파서 컬럼"). 새 필드가 늘어도 마이그레이션이 안 따라온다. generation_meta = Column(JSONB, nullable=True) approve_token_hash = Column(String(64), nullable=True) token_expires_at = Column(DateTime(timezone=True), nullable=True) @@ -469,11 +368,7 @@ class place_posts(MainTableMixin, MAIN_BASE): class place_reviews(MainTableMixin, MAIN_BASE): - """손님이 남긴 이용 후기. - - ★ 사진도 별점도 받지 않는다(2026-09-16 회의). 사진은 호스팅 non-goal 을 여는 일이고, - 별점은 자체 수집 후기라 구조화 데이터로 나갈 수 없다. - ★ IP 는 해시로만 둔다 — 도배를 세는 데는 충분하고 개인정보는 남지 않는다.""" + """손님이 남긴 이용 후기.""" __tablename__ = "place_reviews" __table_args__ = ( @@ -491,8 +386,7 @@ class place_reviews(MainTableMixin, MAIN_BASE): class sites(MainTableMixin, MAIN_BASE): - """발행 대상 사이트. 사업장당 1개. - ★ 해지는 물리 삭제가 아니라 status 전이로만 처리한다 — 색인된 페이지를 갑자기 404 로 만들지 않는다.""" + """발행 대상 사이트.""" __tablename__ = "sites" __table_args__ = ( @@ -504,15 +398,14 @@ class sites(MainTableMixin, MAIN_BASE): place_id = Column(UUID(as_uuid=True), nullable=False) domain = Column(String(255), nullable=True) path_prefix = Column(String(100), nullable=True) - # 템플릿 id(solution/shared/src/data/templates.json). NULL이면 업종 기본 템플릿으로 굽는다. + # 템플릿 id(solution/shared/src/data/templates.json). template_id = Column(String(100), nullable=True) - # 색·섹션(순서·on/off·본문). 내용 키는 프론트가 소유하므로 jsonb로 통째로 담는다. + # 색·섹션(순서·on/off·본문). theme = Column(JSONB, nullable=True) status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SiteStatus.DRAFT.value) current_version_id = Column(UUID(as_uuid=True), nullable=True) # site_versions.site_version_id published_at = Column(DateTime(timezone=True), nullable=True) - # 발행 썸네일(Azure Blob 공개 URL). ★ 발행에 성공한 뒤에만 채운다 — 굽다 만 사이트의 그림을 - # 쇼케이스에 걸면 없는 페이지로 보낸다. 만들지 못하면 NULL 이고, 화면은 글자 카드로 떨어진다. + # 발행 썸네일(Azure Blob 공개 URL). thumbnail_url = Column(String(500), nullable=True) @@ -536,14 +429,7 @@ class site_search_status(MainTableMixin, MAIN_BASE): class alert_outbox(MainTableMixin, MAIN_BASE): - """장애 알림 발송함 — services/alert_service.py 가 쓰고 읽는다. - - ★ 왜 영구 저장하나: 워커 프로세스가 죽으면 메모리에만 쌓아 둔 알림은 그대로 사라진다. - 장애가 나서 죽었는데 그 장애를 알릴 메시지까지 같이 잃으면 본말전도다. - ★ dedupe_key + 최근 전송 시각으로 재시도마다 중복 스팸을 막는다(alert_service.send_alert) — - 같은 사유가 몇 분 간격으로 계속 터져도 사람에게는 한 통만 간다. - ★ resolved_at 은 "복구 알림"의 근거다 — 이 키로 마지막에 안 풀린 알림이 있으면 - 다음 정상 상태에서 복구 메시지를 한 번 보내고 이 값을 채운다.""" + """장애 알림 발송함 — services/alert_service.py 가 쓰고 읽는다.""" __tablename__ = "alert_outbox" alert_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -559,17 +445,7 @@ class alert_outbox(MainTableMixin, MAIN_BASE): class site_sections(MainTableMixin, MAIN_BASE): - """섹션 하나의 콘텐츠. **JSON import/export 의 단위**다. - - ★ 왜 theme 에서 꺼냈나 (2026-09-09) - 색·서체(디자인)와 섹션 콘텐츠가 `sites.theme` JSONB 한 칸에 같이 있었다. - 실측(/s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 콘텐츠가 39,645 B(94%)다. - 크기가 문제가 아니라 **쓰기 단위**가 문제였다 — 영상 주소 하나(592 B)를 고쳐도 - 42 KB 를 통째로 다시 쓰고, 둘이 만지면 나중 쓰기가 앞을 덮고, 항목마다 - "누가 넣었나 · 확인됐나"를 물을 자리가 없었다. - ★ 순서·on/off·배리에이션은 여전히 theme 이 갖는다. 여기는 **내용만** 든다. - ★ shared_ref 가 있으면 값을 복제하지 않고 원본(region_stories 등)을 가리킨다 — - 발행할 때 펼쳐 payload 에 싣는다.""" + """섹션 하나의 콘텐츠.""" __tablename__ = "site_sections" __table_args__ = ( @@ -592,10 +468,7 @@ class site_sections(MainTableMixin, MAIN_BASE): class site_versions(MainTableMixin, MAIN_BASE): - """빌드 버전. ★ 정적 빌드 — snapshot 에 빌드 시점 데이터를 박제하고, 방문자는 DB 와 만나지 않는다. - ★ 개별 재빌드 단위다. 사이트 1,000개에서 전체 재빌드는 못 쓴다. - ★ jsonld 값은 화면에 보이는 값과 같아야 한다 — 불일치면 빌드 실패(PUBLISH_JSONLD_MISMATCH). - ★ unique_content_count 가 0 이면 발행 API 가 거부한다(스팸 판정 대상).""" + """빌드 버전.""" __tablename__ = "site_versions" __table_args__ = ( @@ -608,13 +481,13 @@ class site_versions(MainTableMixin, MAIN_BASE): build_status = Column(SmallInteger, nullable=False, server_default=text("1"), default=BuildStatus.PENDING.value) snapshot = Column(JSONB, nullable=True) # 빌드 시점 데이터 박제 jsonld = Column(JSONB, nullable=True) # 구조화 데이터 - unique_content_count = Column(Integer, nullable=False, server_default=text("0"), default=0) # ★ 0 이면 발행 거부 + unique_content_count = Column(Integer, nullable=False, server_default=text("0"), default=0) # 0 이면 발행 거부 build_error = Column(Text, nullable=True) built_at = Column(DateTime(timezone=True), nullable=True) class site_publish_logs(MainTableMixin, MAIN_BASE): - """발행 시도 기록. 검수 게이트가 막았으면 result=REJECTED + reject_reason 을 남긴다.""" + """발행 시도 기록.""" __tablename__ = "site_publish_logs" @@ -630,18 +503,7 @@ class site_publish_logs(MainTableMixin, MAIN_BASE): class jobs(MainTableMixin, MAIN_BASE): - """작업 큐. 수집·비전분석·빌드는 몇 분 걸려 동기 요청으로 처리할 수 없다. - - - 할당은 **단일 문장 원자 claim**: FOR UPDATE SKIP LOCKED 서브쿼리 + 같은 UPDATE + RETURNING. - 워커 컨테이너가 몇 개든 같은 잡 이중 할당이 불가능하다. - - 복구는 타임아웃 추측이 아니라 **lease 만료 소유권** — 워커가 죽어도 reaper 가 회수한다. - (도커에서 컨테이너를 재시작해도 진행 중이던 잡이 증발하지 않는다.) - - 재시도·백오프·dead-letter 를 큐에 내장한다. - - dedupe_key 로 활성 중복(PENDING/RUNNING)을 막는다 — 같은 사업장 수집이 두 번 돌지 않게. - - ※ 이 테이블만 MainTableMixin 의 deleted 를 쓰지 않는다(잡은 이력이지 소프트 삭제 대상이 아니다). - 그래도 컬럼은 남겨 공통 규약을 깨지 않는다. - """ + """작업 큐.""" __tablename__ = "jobs" __table_args__ = ( @@ -658,9 +520,7 @@ class jobs(MainTableMixin, MAIN_BASE): ), ) - # ★ 이 테이블만 PK 에 server_default 가 필요하다 — 큐 전이는 raw SQL(RETURNING) 이라 - # ORM 의 Python 쪽 default(uuid.uuid4)가 적용되지 않는다. init.sql 의 DEFAULT gen_random_uuid() 와 맞춘다. - # (다른 테이블은 ORM 으로만 INSERT 하므로 원본 보일러플레이트대로 Python default 만 둔다.) + # 이 테이블만 PK 에 server_default 가 필요하다 — 큐 전이는 raw SQL(RETURNING) 이라 ORM 의 Python 쪽 default(uuid.uuid4)가 적용되지 않는다. job_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), default=uuid.uuid4) job_type = Column(SmallInteger, nullable=False) # JobType status = Column(SmallInteger, nullable=False, server_default=text("1"), default=JobStatus.PENDING.value) @@ -696,13 +556,7 @@ class owner_social_accounts(MainTableMixin, MAIN_BASE): class owner_kakao_links(MainTableMixin, MAIN_BASE): - """카카오톡 채널 발화자 ↔ 우리 user_id. - - ★ channel_user_key 는 **채널 단위 익명 키**라 우리 계정과 아무 관계가 없다. 이 표가 - 없으면 채널 진입점만 소유자 범위 밖에 놓인다 — 다른 엔드포인트가 전부 - place_crud.get_place(s, owner_user_id, place_id) 로 지키는 경계다. - ★ 코드는 sha256 만 둔다. 사장님이 카톡에 손으로 치는 짧은 값이라, 평문으로 들고 있으면 - DB 를 읽는 쪽이 곧 연결 권한을 갖는다.""" + """카카오톡 채널 발화자 ↔ 우리 user_id.""" __tablename__ = "owner_kakao_links" link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -714,8 +568,7 @@ class owner_kakao_links(MainTableMixin, MAIN_BASE): status = Column(String(16), nullable=False, server_default=text("'PENDING'")) linked_at = Column(DateTime(timezone=True), nullable=True) last_seen_at = Column(DateTime(timezone=True), nullable=True) - # 대화 상태 — 카카오톡은 앞선 답을 되돌려 주지 않는다(빌더 화면은 프론트가 이어 줬다). - # ★ pending_expires_at 이 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다. + # pending_expires_at 이 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다. current_place_id = Column(UUID(as_uuid=True), nullable=True) pending_tool = Column(String(40), nullable=True) pending_args = Column(JSONB, nullable=True) diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index af41736..5c1d976 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -15,12 +15,7 @@ class CodeEnum(Enum): class ErrorType(Enum): - """서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다. - HTTP status 와 겹치지 않도록 구간을 분리해서 관리한다. - - 도메인 코드는 모듈이 붙을 때 구간을 새로 열어 추가한다 - (places 1200 / facts 1300 / collector 1400 / generator 1500 / local 1600 / sites 1700 / reports 1800 예약). - """ + """서버 전역 결과 코드.""" SUCCESS = 0 FAIL = 1 @@ -55,28 +50,28 @@ class ErrorType(Enum): ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절) OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다 OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패 - ACCOUNT_SESSION_REVOKED = auto() # ★ refresh 토큰의 token_version 이 지금 DB 값과 다르다 — 그 뒤로 무효화됐다(비밀번호 변경 등) + ACCOUNT_SESSION_REVOKED = auto() # 사업장(places) 관련 에러 PLACE_NOT_FOUND = 1200 PLACE_ALREADY_EXIST = auto() # 같은 회사 안에 같은 카카오 장소 ID — 중복 등록 - PLACE_NOT_VERIFIED = auto() # ★ 동일 업소 검증 전 — 수집·발행 진입 금지 + PLACE_NOT_VERIFIED = auto() # 동일 업소 검증 전 — 수집·발행 진입 금지 PLACE_VERIFY_NO_CANDIDATE = auto() # 카카오 로컬에서 후보를 못 찾음 PLACE_VERIFY_AMBIGUOUS = auto() # 동명 업소 다수 — 사람이 골라야 함 PLACE_INVALID_CATEGORY = auto() # 지원하지 않는 업종 코드 UNIT_NOT_FOUND = auto() LINK_NOT_FOUND = auto() - LINK_NOT_CONFIRMED = auto() # ★ 확정 안 된 URL — 크롤링 대상 아님 + LINK_NOT_CONFIRMED = auto() # 확정 안 된 URL — 크롤링 대상 아님 MEDIA_NOT_FOUND = auto() # fact 관련 에러 FACT_NOT_FOUND = 1300 FACT_INVALID_KEY = auto() # 업종 스키마에 없는 key FACT_INVALID_TRANSITION = auto() # 허용되지 않은 검증 상태 전이 - FACT_LOCKED = auto() # ★ CORRECTED(사장님 수정본) — 자동 갱신이 덮어쓸 수 없다 + FACT_LOCKED = auto() # CORRECTED(사장님 수정본) — 자동 갱신이 덮어쓸 수 없다 FACT_SOURCE_REQUIRED = auto() # source_type 이 owner 가 아닌데 source_url 이 없음 FAQ_NOT_FOUND = auto() - FAQ_UNGROUNDED = auto() # ★ 확보된 fact 로 뒷받침되지 않는 문장 — 반려 + FAQ_UNGROUNDED = auto() # 확보된 fact 로 뒷받침되지 않는 문장 — 반려 # 수집(collector) 관련 에러 COLLECT_ADAPTER_NOT_FOUND = 1400 # 해당 URL 을 처리할 어댑터 없음 @@ -93,17 +88,17 @@ class ErrorType(Enum): # 지역 정보(local) 관련 에러 LOCAL_NOT_CONFIGURED = 1600 # KAKAO_REST_API_KEY / TOUR_API_KEY 미설정 LOCAL_REGION_UNKNOWN = auto() # 좌표 → 행정구역 코드 변환 실패 - LOCAL_FETCH_FAILED = auto() # ★ 실패해도 직전 값을 유지한다 — 빈 값을 내보내지 않는다 + LOCAL_FETCH_FAILED = auto() # 실패해도 직전 값을 유지한다 — 빈 값을 내보내지 않는다 # 사이트(sites) 관련 에러 SITE_NOT_FOUND = 1700 SITE_VERSION_NOT_FOUND = auto() SITE_BUILD_FAILED = auto() - PUBLISH_UNVERIFIED_FACT = auto() # ★ 미검증 fact 포함 — 발행 거부 - PUBLISH_NO_UNIQUE_CONTENT = auto() # ★ 고유 콘텐츠 0건 — 발행 거부(스팸 판정 대상) - PUBLISH_JSONLD_MISMATCH = auto() # ★ 구조화 데이터 값 != 화면 값 — 빌드 실패 + PUBLISH_UNVERIFIED_FACT = auto() # 미검증 fact 포함 — 발행 거부 + PUBLISH_NO_UNIQUE_CONTENT = auto() # 고유 콘텐츠 0건 — 발행 거부(스팸 판정 대상) + PUBLISH_JSONLD_MISMATCH = auto() # 구조화 데이터 값 != 화면 값 — 빌드 실패 PUBLISH_REQUIRED_FACT_MISSING = auto() # 업종 스키마의 required 필드 누락 - SITE_SLUG_LOCKED = auto() # ★ 이미 발행된 사이트의 주소 변경 — 색인된 페이지가 404 가 된다 + SITE_SLUG_LOCKED = auto() # 이미 발행된 사이트의 주소 변경 — 색인된 페이지가 404 가 된다 # 리포트(reports) 관련 에러 REPORT_NOT_FOUND = 1800 @@ -132,15 +127,13 @@ EXCEPTION_HTTP_INVALID_TOKEN_ACCESS = HTTPException(status_code=ErrorType.HTTP_I class DBType(Enum): - """논리 DB 구분. 모델마다 DBType() 으로 자신이 속한 DB 를 반환한다. - DB 가 늘어나면 여기에 추가하고 db_session_manager 의 맵에 등록만 하면 된다. - """ + """논리 DB 구분.""" MAIN = 1 class DBWRType(Enum): - """Read / Write 접속 구분. 조회는 DB_READ, 변경은 DB_WRITE 엔진을 사용한다.""" + """Read / Write 접속 구분.""" DB_READ = 1 DB_WRITE = 2 @@ -155,9 +148,7 @@ class UserStatus(CodeEnum): class UserRole(CodeEnum): - """users.role 코드값. - 1=일반, 2=최고관리자(고객사 최상위), 3=개발자(우리 내부 운영 계정). - 개발자 계정은 고객사에 존재를 노출하지 않는다 — 회원 목록에서 빼고 총계에도 넣지 않는다.""" + """users.role 코드값.""" USER = 1 OWNER = 2 # 최고관리자: 자기 회사 계정 관리 + 회사 설정 @@ -165,11 +156,7 @@ class UserRole(CodeEnum): class AuthProvider(CodeEnum): - """users.provider 코드값. 이 계정이 무엇으로 신원을 증명하는가. - - 한 계정은 수단 하나다 — 같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다. - 이으려면 "먼저 가입한 쪽의 소유"를 증명받아야 하는데, 그 증명 없이 이메일만 보고 이으면 - 남이 먼저 만들어 둔 계정에 내 구글 로그인이 들어간다(계정 선점). 보류 사유는 DECISIONS.md 1절.""" + """users.provider 코드값.""" LOCAL = 1 # id/pw GOOGLE = 2 # 구글 ID 토큰 @@ -183,8 +170,7 @@ class CompanyStatus(CodeEnum): class PlaceCategory(CodeEnum): - """places.category 코드값. 업종 — 스키마 파일(common/category_schema/resources/*.json)과 1:1. - 업종 추가 = 여기에 코드 추가 + 스키마 파일 1개 추가.""" + """places.category 코드값.""" LODGING = 1 # 숙박 CAFE = 2 # 카페 @@ -193,18 +179,14 @@ class PlaceCategory(CodeEnum): class ExternalPlaceSource(CodeEnum): - """places.external_source 코드값. 동일 업소 검증에 쓴 외부 장소 DB. - - 카카오는 안정적인 고유 place id 를 준다 → 그걸로 중복 등록을 막는다. - 네이버는 고유 id 가 없다(응답의 link 는 업체 홈페이지다) → 상호명+도로명주소로 막는다.""" + """places.external_source 코드값.""" KAKAO = 1 # dapi.kakao.com — 고유 place id O · 전화번호 O · 행정구역 코드 O NAVER = 2 # openapi.naver.com 지역검색 — 고유 id X · 전화번호 X · 5건 제한 class PlaceStatus(CodeEnum): - """places.status 코드값. 사업장 생애주기. - 해지는 삭제가 아니라 SUSPENDED 로의 상태 전이다(색인된 페이지를 갑자기 404 로 만들지 않는다).""" + """places.status 코드값.""" DRAFT = 1 # 등록만 됨 — 동일 업소 검증 전 COLLECTING = 2 # 수집 진행 중 @@ -214,19 +196,17 @@ class PlaceStatus(CodeEnum): class SourceType(CodeEnum): - """facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값. - 값이 어디서 왔는지 — 모든 사실은 출처를 갖는다.""" + """facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값.""" OWNER = 1 # 사장님이 직접 입력·업로드 API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity) CRAWL = 3 # 크롤링 LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다 - TEMPLATE = 5 # FAQ 목표 수를 채운 공통 질문 + 문의 안내(services/faq_fill) — ★ FAQ 전용. fact 에는 못 쓴다 + TEMPLATE = 5 # FAQ 목표 수를 채운 공통 질문 + 문의 안내(services/faq_fill) — ★ FAQ 전용. class FactStatus(CodeEnum): - """facts.status / faqs.status / routes.status 공용 검증 상태. - ★ VERIFIED 와 CORRECTED 만 사이트에 노출한다(PUBLISHABLE_FACT_STATUSES).""" + """facts.status / faqs.status / routes.status 공용 검증 상태.""" UNVERIFIED = 1 # 수집됐으나 아무도 확인 안 함 PENDING_OWNER = 2 # 사장님 확인 대기 @@ -236,27 +216,26 @@ class FactStatus(CodeEnum): EXPIRED = 6 # 유효기간 지남 — 노출 안 함, 재수집 대상 -# ★ 절대규칙 1: 이 두 상태만 사이트에 노출한다. 발행 게이트가 이 집합으로 필터링한다. +# 절대규칙 1: 이 두 상태만 사이트에 노출한다. PUBLISHABLE_FACT_STATUSES = {FactStatus.VERIFIED, FactStatus.CORRECTED} -# 후보 — 재수집이 올려놓은 확인 대기 항목. 노출값과 달리 (place, unit, key) 당 여러 건 공존한다. +# 후보 — 재수집이 올려놓은 확인 대기 항목. CANDIDATE_FACT_STATUSES = {FactStatus.UNVERIFIED, FactStatus.PENDING_OWNER} -# ★ 절대규칙 6: 자동 수집(api/crawl/llm)이 덮어쓸 수 없는 상태. 사장님 수정본은 후보로만 도전받는다. +# 절대규칙 6: 자동 수집(api/crawl/llm)이 덮어쓸 수 없는 상태. LOCKED_FACT_STATUSES = {FactStatus.CORRECTED} class FactWriteOutcome(CodeEnum): - """fact 기록 결과. 재수집(업데이트)이 무엇을 했는지 호출측이 알아야 한다 — - 특히 사이트 재빌드가 필요한 경우(PUBLISHED_REPLACED)를 구분해야 한다.""" + """fact 기록 결과.""" - PUBLISHED_CREATED = 1 # 노출값이 없던 자리에 사람이 직접 넣어 바로 노출됐다 - PUBLISHED_REPLACED = 2 # ★ 노출값이 교체됐다 — 사이트 재빌드 대상 + PUBLISHED_CREATED = 1 + PUBLISHED_REPLACED = 2 REFRESHED = 3 # 재수집했는데 값이 그대로 — 검증 유지, 확인 시각만 갱신 - CANDIDATE_CREATED = 4 # 노출값과 다른 값이 들어와 후보로 쌓였다(사람 확인 대기) - CANDIDATE_UPDATED = 5 # 같은 출처의 기존 후보를 새 수집값으로 갱신했다 + CANDIDATE_CREATED = 4 + CANDIDATE_UPDATED = 5 -# 검증 상태 전이 허용표. 여기에 없는 전이는 FACT_INVALID_TRANSITION 으로 거부한다. +# 검증 상태 전이 허용표. FACT_STATUS_TRANSITIONS = { FactStatus.UNVERIFIED: {FactStatus.PENDING_OWNER, FactStatus.VERIFIED, FactStatus.REJECTED, FactStatus.EXPIRED}, FactStatus.PENDING_OWNER: {FactStatus.VERIFIED, FactStatus.CORRECTED, FactStatus.REJECTED, FactStatus.EXPIRED}, @@ -268,7 +247,7 @@ FACT_STATUS_TRANSITIONS = { class LinkChannel(CodeEnum): - """place_channels.channel 코드값. Perplexity 가 발견하는 채널 종류.""" + """place_channels.channel 코드값.""" YANOLJA = 1 # 야놀자 GOODCHOICE = 2 # 여기어때 @@ -276,16 +255,13 @@ class LinkChannel(CodeEnum): INSTAGRAM = 4 OFFICIAL_SITE = 5 # 사장님 자체 홈페이지 BLOG = 6 - # ★ 플레이스와 가른 이유: 이건 **예약 화면 그 자체**다. - # 플레이스 홈은 예약 버튼을 한 번 더 눌러야 하고, 자동 발견이 검색 URL 을 물어온 - # 경우에는 아예 검색 결과가 뜬다 — 발행본의 "예약" 버튼이 그리로 가면 손님은 - # 예약을 포기한다. 주소는 지어내지 않는다: 플레이스 응답의 naverBookingUrl 그대로다. + # 플레이스와 가른 이유: 이건 **예약 화면 그 자체**다. NAVER_BOOKING = 7 # 네이버 예약(m.booking.naver.com) ETC = 99 class MediaStatus(CodeEnum): - """media.status 코드값. 비전 결과 신뢰도가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 둔다.""" + """media.status 코드값.""" PENDING_REVIEW = 1 # 사람 확인 큐 — Vision 분석 전이거나 신뢰도가 낮다 APPROVED = 2 # 사람이 확인함(또는 Vision 신뢰도가 충분히 높음) @@ -293,41 +269,31 @@ class MediaStatus(CodeEnum): class SongStatus(CodeEnum): - """place_songs.status 코드값. + """place_songs.status 코드값.""" - ★ fact·사진과 달리 검증 상태가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라 - "맞는가" 를 물을 대상이 아니다. 물을 것은 "만들어졌는가" 하나다. - ★ 사이트에는 READY 만 나간다 — 생성 중인 곡을 실으면 재생 버튼이 없는 파일을 가리킨다.""" - - GENERATING = 1 # Suno 가 작곡 중(또는 파일을 아직 못 받았다) - READY = 2 # 파일까지 받아 뒀다 — 사이트에 나간다 - FAILED = 3 # 생성 실패. 발행은 그대로 진행된다(노래만 없다) + GENERATING = 1 + READY = 2 + FAILED = 3 # 생성 실패. -# ★ Vision 결과를 자동 반영해도 되는 신뢰도 하한. 이 아래는 사람 확인 큐(PENDING_REVIEW)로 남긴다. -# "신뢰도 낮은 항목은 자동 반영하지 말고 사람 확인 큐로 보낸다" 를 한 곳에서만 판단한다. +# Vision 결과를 자동 반영해도 되는 신뢰도 하한. VISION_AUTO_APPROVE_CONFIDENCE = 0.7 class LocalContentType(CodeEnum): - """local_contents.content_type 코드값. 행정구역 코드 단위로 캐싱되는 지역 정보 종류.""" + """local_contents.content_type 코드값.""" WEATHER = 1 # 날씨 (Open-Meteo) — local_contents(지역 캐시) - # ↓ 2~5 는 place_contents(업장 반경 캐시). TourAPI locationBasedList2 contentTypeId 와 짝: 15·12·39·25 + # ↓ 2~5 는 place_contents(업장 반경 캐시). FESTIVAL = 2 # 축제/공연/행사 (15) ATTRACTION = 3 # 관광지 (12) RESTAURANT = 4 # 음식점 (39) - COURSE = 5 # 여행코스 (25) — 백엔드만. 렌더러 자리는 아직 없다 - # ★ 지역 이야기(가요·일력·인물·연표·엽서·퀴즈). 위 넷과 달리 **좌표가 아니라 행정구역**에 붙는다 — - # 군산 이야기는 군산 숙소가 같이 쓴다. 여섯을 한 코드로 두고 `area_contents.kind` 로 가르는 이유는, - # 종류마다 코드를 주면 종류가 늘 때마다 enum·상한표·읽는 쪽이 함께 늘기 때문이다. + COURSE = 5 # 여행코스 (25) — 백엔드만. + # 지역 이야기(가요·일력·인물·연표·엽서·퀴즈). STORY = 6 -# 코드값 ↔ **타입명**. `area_contents.kind` 와 `site_sections.data.items[].kind` 가 같은 어휘를 쓴다 — -# 개인화 행(거리·숨김)이 어느 공용 실체를 가리키는지 이름만 보고 알 수 있어야 한다. -# ★ STORY 는 여기 없다. 그것들(songs·people·chronicle·reading·postcard·quiz)은 kind 가 곧 타입명이고, -# 코드값 하나(6)를 나눠 쓴다. 아래 표는 kind 가 비어 있던 장소류를 채우기 위한 것이다. +# 코드값 ↔ **타입명**. AREA_KIND = { LocalContentType.WEATHER.value: "weather", LocalContentType.FESTIVAL.value: "festival", @@ -336,22 +302,20 @@ AREA_KIND = { LocalContentType.COURSE.value: "course", } -# 지역 이야기 일곱. `services/prompts/story.py` 의 산출물 키와 같아야 한다. -# ★ 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`). +# 지역 이야기 일곱. STORY_KINDS = ("songs", "daily", "people", "chronicle", "reading", "postcard", "quiz") class LocalSource(CodeEnum): - """local_contents.source 코드값. 어느 외부 API 에서 왔는지.""" + """local_contents.source 코드값.""" - OPEN_METEO = 1 # 날씨. API 키 불필요 - TOUR_API = 2 # 한국관광공사. ★ 자체 areaCode 체계 — 카카오 행정구역 코드와 다르다 + OPEN_METEO = 1 # 날씨. + TOUR_API = 2 # 한국관광공사. KAKAO_LOCAL = 3 OFFICIAL_WEB = 4 # 지자체·행사 공식 홈페이지에서 운영자가 검수해 등록 - # ★ 지역 이야기 생성분. 출처는 항목 안의 source.url 이고 이 값은 '누가 모았나'다 — - # 화면이 "AI 가 모았습니다"를 밝힐 근거이자, 나중에 통째로 다시 돌릴 때의 선택자다. + # 지역 이야기 생성분. LLM = 5 - NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강). docs/DECISIONS.md 1-1 예외 — 봇탐지 우회 없이 공개 응답만 읽는다 + NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강). class LocalContentStatus(CodeEnum): @@ -363,7 +327,7 @@ class LocalContentStatus(CodeEnum): class TransportType(CodeEnum): - """routes.transport 코드값. 가는 길 수단.""" + """routes.transport 코드값.""" CAR = 1 PUBLIC = 2 @@ -371,7 +335,7 @@ class TransportType(CodeEnum): class SiteStatus(CodeEnum): - """sites.status 코드값. ★ 해지는 물리 삭제가 아니라 상태 전이로만 처리한다.""" + """sites.status 코드값.""" DRAFT = 1 REVIEW = 2 # 검수 게이트 대기 @@ -381,7 +345,7 @@ class SiteStatus(CodeEnum): class BuildStatus(CodeEnum): - """site_versions.build_status 코드값. 정적 빌드는 개별 재빌드 단위로 돈다.""" + """site_versions.build_status 코드값.""" PENDING = 1 BUILDING = 2 @@ -397,7 +361,7 @@ class PublishAction(CodeEnum): REBUILD = 3 SUSPEND = 4 RESUME = 5 - ROLLBACK = 6 # 예전 버전으로 공개 주소를 되돌림 — services/rollback_service.py + ROLLBACK = 6 class PublishResult(CodeEnum): @@ -409,7 +373,7 @@ class PublishResult(CodeEnum): class PublishRejectReason(CodeEnum): - """publish_logs.reject_reason 코드값. 검수 게이트가 발행을 막은 이유(절대규칙 1~3).""" + """publish_logs.reject_reason 코드값.""" UNVERIFIED_FACT = 1 # 미검증 fact 포함 NO_UNIQUE_CONTENT = 2 # 고유 콘텐츠 0건 @@ -418,7 +382,7 @@ class PublishRejectReason(CodeEnum): class AiEngine(CodeEnum): - """ai_check_results.engine 코드값. AI 검색이 우리 사이트를 근거로 답하는지 측정할 대상.""" + """ai_check_results.engine 코드값.""" CHATGPT = 1 PERPLEXITY = 2 @@ -427,14 +391,9 @@ class AiEngine(CodeEnum): ETC = 99 -# ============================================================ # 작업 큐 (LPS 의 job 큐 구조를 이식 — PostgreSQL 을 큐로 쓴다) -# ============================================================ class JobType(CodeEnum): - """jobs.job_type 코드값. 수집·비전·빌드는 몇 분씩 걸려 동기 요청으로 처리할 수 없다. - - 무거운 잡(브라우저 필요)과 가벼운 잡(HTTP API 만)을 코드로 갈라 둔다 — - 크롤링 법무 결론이 나면 무거운 잡만 별도 워커 이미지로 분리한다.""" + """jobs.job_type 코드값.""" COLLECT = 1 # 수집 파이프라인: Perplexity 채널 발견 → 카카오 검증 → 크롤링 VISION = 2 # 사진 분류 + alt 생성 (Gemini Vision, 20~50장 배치) @@ -442,30 +401,27 @@ class JobType(CodeEnum): BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위 LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회) AI_CHECK = 6 # AI 검색 노출 점검 - SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). 발행이 이 잡을 건다 - ROLLBACK = 8 # 예전 버전 스냅샷으로 다시 굽고 공개 주소를 그 버전으로 되돌림 + SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). + ROLLBACK = 8 SOCIAL_DRAFT = 9 # SNS 초안 작성(Gemini) — 확보된 fact 만 근거로 SOCIAL_POST = 10 # 승인된 SNS 초안을 실제 게시 class JobStatus(CodeEnum): - """jobs.status 코드값. 작업 큐 상태. + """jobs.status 코드값.""" - 전이는 전부 조건부 원자 UPDATE(CAS)로만 한다. 실패는 재시도 가능하면 - PENDING(run_after=백오프)으로 되돌리고, 소진되면 DEAD(dead-letter).""" - - PENDING = 1 # 대기(claim 가능). run_after <= now() 일 때만 실제 claim 대상 - RUNNING = 2 # 워커가 점유 중(lease_until 까지 소유). 만료 시 reaper 가 회수 + PENDING = 1 # 대기(claim 가능). + RUNNING = 2 # 워커가 점유 중(lease_until 까지 소유). DONE = 3 # 완료 DEAD = 4 # dead-letter — max_attempts 소진(수동 개입/알림 대상) -# claim 대상이 되는 활성 상태. dedupe 부분 유니크 인덱스의 조건과 같아야 한다. +# claim 대상이 되는 활성 상태. ACTIVE_JOB_STATUSES = {JobStatus.PENDING, JobStatus.RUNNING} class PostTopicKind(CodeEnum): - """place_posts.topic_kind — 어떤 갈래로 쓴 글인가. 갈래마다 근거로 삼는 값이 다르다.""" + """place_posts.topic_kind — 어떤 갈래로 쓴 글인가.""" WEATHER = 1 # local.weather FESTIVAL = 2 # local.festivals @@ -475,20 +431,20 @@ class PostTopicKind(CodeEnum): class PostStatus(CodeEnum): - """place_posts.status — 글 하나의 일생. 어디서 멈췄는지가 운영 질문의 전부다.""" + """place_posts.status — 글 하나의 일생.""" - DRAFT = 1 # AI 가 만들었고 아직 아무도 안 봤다 + DRAFT = 1 REVIEWED = 2 # 우리가 검수해 내보내도 된다고 판단 - SENT = 3 # 사장님에게 메일이 나갔다 - APPROVED = 4 # 사장님이 눌렀다 — 재발행 대기 - PUBLISHED = 5 # 사이트에 올라갔다 + SENT = 3 + APPROVED = 4 + PUBLISHED = 5 SKIPPED = 6 # 반려(우리) 또는 넘김(사장님) class ReviewStatus(CodeEnum): - """place_reviews.status — 손님이 쓴 글의 일생. 검수를 통과해야 화면에 나간다.""" + """place_reviews.status — 손님이 쓴 글의 일생.""" - PENDING = 1 # 손님이 막 남겼다 + PENDING = 1 PUBLISHED = 2 # 검수 통과 — 다음 굽기에 실린다 REJECTED = 3 # 반려 @@ -499,14 +455,11 @@ class SocialProvider(CodeEnum): class KakaoLinkStatus(str, Enum): - """owner_kakao_links.status. + """owner_kakao_links.status.""" - ★ 코드는 PENDING 행에만 산다. 연결이 끝나면 code_sha 를 비워 같은 코드가 두 번 - 먹지 않게 한다 — 일회성은 값이 아니라 `WHERE status='PENDING'` CAS 가 보장한다.""" - - PENDING = "PENDING" # 코드는 냈고 아직 카톡에서 입력되지 않았다 - LINKED = "LINKED" # channel_user_key 가 붙었다 - REVOKED = "REVOKED" # 사장님이 해제했다. 행은 남겨 이력을 잃지 않는다 + PENDING = "PENDING" + LINKED = "LINKED" + REVOKED = "REVOKED" class SocialPostStatus(str, Enum): @@ -523,7 +476,7 @@ class SocialPostStatus(str, Enum): class AlertStatus(CodeEnum): - """alert_outbox.status 코드값. services/alert_service.py 가 이 상태로 재시도를 판단한다.""" + """alert_outbox.status 코드값.""" PENDING = 1 # 아직 안 보냄(다음 process_outbox 스윕에서 시도) SENT = 2 # 전송 성공 diff --git a/solution/backend/common/faq_catalog/__init__.py b/solution/backend/common/faq_catalog/__init__.py index 1b5efc6..ef99e61 100644 --- a/solution/backend/common/faq_catalog/__init__.py +++ b/solution/backend/common/faq_catalog/__init__.py @@ -1,17 +1,4 @@ -"""FAQ 질문 카탈로그 — 생성된 FAQ 가 목표 수에 모자랄 때 채울 업종 공통 질문. - -★ 왜 필요한가 - COPY 잡은 확인된 fact 로만 FAQ 를 쓴다. 실측(2026-09-14, 로컬): 스테이머뭄 fact 8건, - 산하연 풀빌라 4건 — 그 근거로는 FAQ 가 4~8개에서 끝난다. 20개를 채우려면 근거 밖의 문항이 필요하다. - -★ 공통 **답**은 주장을 하지 않는다 — "…은 숙소로 문의 부탁드립니다" 뿐이다. - 예전 업종 시드 FAQ 에는 "숯과 그릴 세트(25,000원)" 같은 가공의 값이 있었고, 사장님이 팔지도 않는 - 조건이 사이트에 나갔다(frontend canvas/variants/faq/useFaqList.ts). 공통 답에 값·가능 여부를 적으면 - 같은 사고다. 문의 안내만 쓴다. - -resources/*.json 을 최초 사용 시 로드·검증한다. fact_keys 는 업종 스키마에 있는 key 여야 한다 — -오타가 난 key 는 영영 "fact 없음" 으로 읽혀, 답이 있는 질문에 문의 안내가 붙는다. -""" +"""FAQ 질문 카탈로그 — 생성된 FAQ 가 목표 수에 모자랄 때 채울 업종 공통 질문.""" import json from dataclasses import dataclass from pathlib import Path @@ -33,7 +20,7 @@ class CatalogItem: id: str question: str topic: str # 공통 답변 문구에 들어갈 주제("반려동물 동반 가능 여부") - fact_keys: tuple[str, ...] # 이 질문에 답할 수 있는 fact. 하나라도 있으면 공통 답으로 채우지 않는다 + fact_keys: tuple[str, ...] # 이 질문에 답할 수 있는 fact. keywords: tuple[str, ...] # 기존 FAQ 질문에 이 낱말이 있으면 같은 주제로 본다(공백 없이 비교) @@ -48,10 +35,7 @@ class FaqCatalog: items: tuple[CatalogItem, ...] def applies_to(self, category: int, external_category: str | None) -> bool: - """업종 코드가 같고, 외부 분류가 제외 목록에 걸리지 않으면 이 카탈로그를 쓴다. - - ★ 외부 분류가 비어 있으면 **쓴다.** 스테이머뭄처럼 네이버 분류가 없는 펜션이 있다. - 호텔은 분류가 "호텔" 로 오므로 제외 목록이 막는다 — 호텔에 바비큐·픽업 문항이 붙으면 안 된다.""" + """업종 코드가 같고, 외부 분류가 제외 목록에 걸리지 않으면 이 카탈로그를 쓴다.""" if category != self.category.value: return False label = external_category or "" @@ -116,7 +100,7 @@ def _parse(doc: dict, source: str) -> FaqCatalog: def load_catalogs() -> list[FaqCatalog]: - """리소스 디렉터리 전체 로드 + 검증. 최초 1회(멱등).""" + """리소스 디렉터리 전체 로드 + 검증.""" global _catalogs if _catalogs is None: loaded = [] @@ -131,5 +115,5 @@ def load_catalogs() -> list[FaqCatalog]: def find_catalog(category: int, external_category: str | None) -> FaqCatalog | None: - """이 사업장에 쓸 카탈로그. 없으면 None — 채우지 않는다(카페·음식점은 아직 목록이 없다).""" + """이 사업장에 쓸 카탈로그.""" return next((c for c in load_catalogs() if c.applies_to(category, external_category)), None) diff --git a/solution/backend/common/job_errors.py b/solution/backend/common/job_errors.py index 26a23dc..2522921 100644 --- a/solution/backend/common/job_errors.py +++ b/solution/backend/common/job_errors.py @@ -1,18 +1,5 @@ -"""재시도가 의미 없는 잡 실패. - -★ 왜 따로 두나 — 큐는 실패를 전부 "일시적" 으로 보고 백오프 재큐한다(crud/job_crud.fail). - 네트워크가 끊겼거나 외부 API 가 잠깐 죽은 것이라면 맞는 판단이다. 그런데 사장님이 - 사업장을 지운 뒤에 남은 잡, 지원하지 않는 업종 같은 것은 **몇 번을 다시 해도 같은 결과**다. - 실측(2026-09-15): 진행 중이던 소개문 잡이 사업장 삭제 뒤 "사업장을 찾을 수 없다" 로 - 세 번 재시도하고 DEAD 로 갔다 — 큐 지연과 DEAD 알림만 늘었다. - -★ 각 도메인의 `*Aborted` 는 이미 머리주석에 "재시도해도 소용없는 중단" 이라고 적고 있었다. - 그 뜻을 워커가 읽을 수 있는 자리로 옮긴 것이지, 새 규칙을 만든 게 아니다. - -★ services 와 worker 가 함께 쓰므로 common 에 둔다 — services 가 worker 를 import 하면 - 의존 방향이 뒤집힌다. -""" +"""재시도가 의미 없는 잡 실패.""" class PermanentJobError(RuntimeError): - """다시 시도해도 결과가 같은 실패. 워커가 재큐하지 않고 바로 DEAD 로 보낸다.""" + """다시 시도해도 결과가 같은 실패.""" diff --git a/solution/backend/common/logger.py b/solution/backend/common/logger.py index 802f664..1acf9c9 100644 --- a/solution/backend/common/logger.py +++ b/solution/backend/common/logger.py @@ -4,9 +4,7 @@ from datetime import datetime, timezone class _Logger: - """원본 DerbyServer LOG 인터페이스를 간소화한 버전. - LOG.i / LOG.d / LOG.w / LOG.e_no_callstack / LOG.SetPrefix 를 제공한다. - """ + """원본 DerbyServer LOG 인터페이스를 간소화한 버전.""" def __init__(self): self._prefix = "" diff --git a/solution/backend/common/models/gmodel.py b/solution/backend/common/models/gmodel.py index c9c9e8c..6d3e67b 100644 --- a/solution/backend/common/models/gmodel.py +++ b/solution/backend/common/models/gmodel.py @@ -14,7 +14,7 @@ class StructModel: class ErrorInfo(BaseModel, StructModel): - """모든 응답에 공통으로 실리는 결과 정보. result.success / code / desc 로 내려간다.""" + """모든 응답에 공통으로 실리는 결과 정보.""" success: Optional[bool] = True code: Optional[int] = ErrorType.SUCCESS.value @@ -27,11 +27,7 @@ class ErrorInfo(BaseModel, StructModel): self.desc = enum.name -# ---- Protocol 규약 ------------------------------------------------------- -# 모든 통신 패킷은 WebPacketProtocol 을 상속한다. -# 요청 : Req_xxx (WebPacketProtocol) -# 응답 : Res_xxx (Res_WebPacketProtocol) - 항상 result 필드를 가진다. -# 각 라우터 폴더의 protocol.py 에 Req_/Res_ 를 정의한다. +# Protocol 규약 class WebPacketProtocol(BaseModel, StructModel): pass @@ -54,7 +50,7 @@ class Res_PageProtocol(Res_WebPacketProtocol): class PageParams: - # 목록 엔드포인트 공용 쿼리 파라미터. 라우터에서 Depends() 로 주입한다. + # 목록 엔드포인트 공용 쿼리 파라미터. def __init__(self, page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100)): self.page = page self.size = size @@ -67,16 +63,14 @@ class PageParams: class UserInfo(StructModel): """JWT subject 로 인코딩되는 유저 식별 정보.""" - user_id: str # users.user_id (uuid) — 데이터 스코프 키. 사업장은 owner_user_id 로 이 값에 매인다 + user_id: str # users.user_id (uuid) — 데이터 스코프 키. id: str # users.id (로그인 아이디) — get_me 재조회 키 role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키 token_version: int # users.token_version — refresh 토큰 무효화 키(auth_service.refresh_token 이 대조) def __init__(self, *args, **kwargs) -> None: super().__init__() - # 구버전 토큰(role·token_version 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 - # 덮어쓴다. token_version 기본값은 DB 컬럼 기본값(1)과 같아야 한다 — 배포 순간 옛 - # 토큰이 전부 "버전이 다르다"로 거절되는 것을 막는다. + # 구버전 토큰(role·token_version 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 덮어쓴다. self.role = UserRole.USER.value self.token_version = 1 for dictionary in args: diff --git a/solution/backend/common/template_catalog.py b/solution/backend/common/template_catalog.py index fe8c5c2..4881472 100644 --- a/solution/backend/common/template_catalog.py +++ b/solution/backend/common/template_catalog.py @@ -1,4 +1,4 @@ -"""업종·템플릿 정의. 프론트와 같은 파일(solution/shared/src/data/templates.json)을 읽는다.""" +"""업종·템플릿 정의.""" import json from pathlib import Path diff --git a/solution/backend/common/utils/geo.py b/solution/backend/common/utils/geo.py index 94630c4..9ccf17b 100644 --- a/solution/backend/common/utils/geo.py +++ b/solution/backend/common/utils/geo.py @@ -1,19 +1,11 @@ -"""좌표 거리 — 공용 한 벌. - -★ 같은 하버사인 공식이 tour_lookup(500m 동일업소 판정)·itinerary(일정 반경)·tour_api(축제 20km 필터) - 세 곳에 각각 복사돼 있었다(2026-09-08 정리). 지구 반지름·단위가 파일마다 달라지면 같은 두 점의 - 거리가 모듈마다 다르게 나온다 — 거리로 무엇을 넣고 뺄지 정하는 코드가 셋이라 한 벌이어야 한다. - -국내 범위라 하버사인(구면 근사)이면 충분하다. 오차는 수 m 수준으로, 우리가 쓰는 판정 -(500m 이내·5~20km 반경)에서 결과를 바꾸지 않는다. -""" +"""좌표 거리 — 공용 한 벌.""" import math EARTH_RADIUS_M = 6_371_000.0 def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float: - """두 좌표(위도, 경도) 사이의 거리(m). ★ 인자 순서는 (위도, 경도) — mapx/mapy 는 (경도, 위도)라 뒤집어 넣는다.""" + """두 좌표(위도, 경도) 사이의 거리(m).""" p1, p2 = math.radians(lat1), math.radians(lat2) dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1) a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 diff --git a/solution/backend/common/utils/gtime.py b/solution/backend/common/utils/gtime.py index 937513b..2c25779 100644 --- a/solution/backend/common/utils/gtime.py +++ b/solution/backend/common/utils/gtime.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta class GTime: - """서버 전역에서 UTC 기준 시간을 사용하기 위한 유틸. (원본 DerbyServer 패턴 축약)""" + """서버 전역에서 UTC 기준 시간을 사용하기 위한 유틸.""" @staticmethod def UTC() -> datetime: diff --git a/solution/backend/common/utils/rate_limit.py b/solution/backend/common/utils/rate_limit.py index b4e3ad6..9c35600 100644 --- a/solution/backend/common/utils/rate_limit.py +++ b/solution/backend/common/utils/rate_limit.py @@ -1,12 +1,4 @@ -"""IP 단위 호출 제한 — 프로세스 메모리에만 있는 아주 단순한 것. - -★ 한계를 먼저 적는다. 프로세스가 여럿이면 한도가 그 수만큼 곱해지고, 재시작하면 리셋된다. - 제대로 하려면 앞단(nginx `limit_req`)이나 공유 저장소가 필요하다. - -★ 그런데도 두는 이유: 이걸 쓰는 곳이 **인증 없이 유료 외부 API 를 부르는 경로**다. - 방어가 0 이면 새로고침을 누르고 있는 것만으로 요금이 나간다 - (카카오 키워드 검색은 무료 한도를 넘기면 건당 2원 — services/external/kakao.py 주석). -""" +"""IP 단위 호출 제한 — 프로세스 메모리에만 있는 아주 단순한 것.""" import time from collections import defaultdict, deque @@ -23,7 +15,7 @@ def allow(key: str, limit: int, window_sec: float) -> bool: if len(bucket) >= limit: return False bucket.append(now) - # 안 쓰는 키가 쌓이는 걸 막는다. 호출이 뜸하면 자연히 비워진다. + # 안 쓰는 키가 쌓이는 걸 막는다. if not bucket: _hits.pop(key, None) return True diff --git a/solution/backend/common/utils/ttl_cache.py b/solution/backend/common/utils/ttl_cache.py index 26fc4b9..4152f1e 100644 --- a/solution/backend/common/utils/ttl_cache.py +++ b/solution/backend/common/utils/ttl_cache.py @@ -1,9 +1,4 @@ -"""TTL 캐시 — 프로세스 메모리에만 있다. rate_limit 과 같은 한계를 갖는다. - -★ 두는 이유는 속도가 아니라 **차단**이다. 공개 검색 1회가 네이버를 최대 3번 긁는데 - (넓은 검색 1 + 겨냥 2), 인증 없는 경로라 새로고침만으로도 나간다. - 실측(2026-09-03): 테스트를 반복하다 m.place.naver.com 에서 429 를 받았다. -""" +"""TTL 캐시 — 프로세스 메모리에만 있다.""" import time from typing import Any, Optional diff --git a/solution/backend/config/agent_config.py b/solution/backend/config/agent_config.py index 5fd4107..fc093b4 100644 --- a/solution/backend/config/agent_config.py +++ b/solution/backend/config/agent_config.py @@ -1,10 +1,4 @@ -"""사장님 에이전트 설정 — 루트 .env 하나만 읽는다(APP_ENV=test 면 .env 를 읽지 않는다). - -★ SNS 게재(social_config)와 파일을 가른 이유는 도메인이 다르기 때문이다. - SNS 게재는 **되돌릴 수 없는** 대외 발화이고, 에이전트는 사장님이 자기 사이트를 - 고치는 창구다. 승인 강도도 보관하는 것도 다르다 — 설정이 한 파일에 섞이면 - "이 값이 무엇을 여는가" 가 흐려진다. -""" +"""사장님 에이전트 설정 — 루트 .env 하나만 읽는다(APP_ENV=test 면 .env 를 읽지 않는다).""" from pydantic_settings import BaseSettings @@ -14,30 +8,19 @@ from config.config_models import _BASE class AgentConfig(BaseSettings): model_config = _BASE - # 카카오톡 채널 공개 ID(`_xaBcD` 형태). 사장님이 채널을 찾아 코드를 입력해야 하므로 - # ★ 이 값이 없으면 연결 화면 자체를 열지 않는다 — 어디에 코드를 칠지 말해 줄 수 - # 없는데 코드만 발급하면, 사장님에게는 고장난 화면이다(Threads 카드와 같은 규칙). + # 카카오톡 채널 공개 ID(`_xaBcD` 형태). KAKAO_CHANNEL_PUBLIC_ID: str = "" - # 코드 수명. 사장님이 화면을 보고 카톡을 열어 치는 동작이라 짧아도 된다. + # 코드 수명. KAKAO_LINK_CODE_TTL_MIN: int = 10 - # 코드가 짧아서(사람이 손으로 친다) 무차별 대입이 가능하다. 시도 수로 끊는다. + # 코드가 짧아서(사람이 손으로 친다) 무차별 대입이 가능하다. KAKAO_LINK_MAX_ATTEMPTS: int = 5 - # 빌더 화면의 대화창. 2026-09-21 에 한 번 닫았다가(카카오 채널 보류) 채널 인증이 - # 끝나 다시 열었다(2026-09-22). - # ★ 이 값이 "1" 이어도 **LLM 키가 없으면 안 열린다**(runtime.is_configured 가 둘 다 본다) — - # 키 없는 환경에서 켜 둔 채 잊어도 "눌러도 안 되는 입구" 가 생기지 않는다. - # 다시 닫을 일이 생기면 이 값만 "0" 으로 되돌린다. 코드를 되짚지 않는다. + # 빌더 화면의 대화창. AGENT_CHAT_ENABLED: str = "1" - # ★ 카카오 웹훅 인증. **오픈빌더는 서명을 주지 않는다** — URL 만 알면 누구나 이 엔드포인트를 - # 때릴 수 있고, user.id 를 아무 값이나 넣으면 **그 사장님 행세를 한다.** 신원 연결 - # (owner_kakao_links)이 통째로 무의미해진다. - # 그래서 이 값이 없으면 **엔드포인트 자체를 띄우지 않는다**(404). 반쯤 열린 상태를 - # 만들지 않는 것은 Threads 연결과 같은 규칙이다. - # 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))" + # 카카오 웹훅 인증. KAKAO_WEBHOOK_SECRET: str = "" - # 우리 봇이 맞는지 한 겹 더 본다. 시크릿이 아니라 오발송을 거르는 용도라 비워도 된다. + # 우리 봇이 맞는지 한 겹 더 본다. KAKAO_BOT_ID: str = "" @@ -58,6 +41,6 @@ def kakao_link_enabled() -> bool: def channel_url() -> str: - """사장님이 눌러서 채널로 가는 주소. 공개 ID 가 없으면 빈 문자열이다.""" + """사장님이 눌러서 채널로 가는 주소.""" public_id = get("KAKAO_CHANNEL_PUBLIC_ID") return f"http://pf.kakao.com/{public_id}" if public_id else "" diff --git a/solution/backend/config/config_models.py b/solution/backend/config/config_models.py index 1fe8480..02aba1d 100644 --- a/solution/backend/config/config_models.py +++ b/solution/backend/config/config_models.py @@ -1,8 +1,4 @@ -"""설정 모델 — 값은 전부 환경변수(최상위 .env 포함)에서 온다. - -★ 환경변수 이름은 validation_alias 로 못 박는다. 필드명만 두면 `port` 가 흔한 `PORT` 를 - 주워 먹어 엉뚱한 포트로 뜬다. -""" +"""설정 모델 — 값은 전부 환경변수(최상위 .env 포함)에서 온다.""" from functools import lru_cache from typing import Optional @@ -12,16 +8,16 @@ from pydantic_settings import BaseSettings, SettingsConfigDict import os -# 레포 최상위 .env. 여기서 네 단계 위다 — 세 단계로 두면 solution/.env(없는 파일)를 본다. +# 레포 최상위 .env. _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) _DOTENV = os.path.join(_REPO_ROOT, ".env") APP_ENV = os.environ.get("APP_ENV", "local") -# ★ APP_ENV=test 면 .env 를 읽지 않는다. 실키가 새면 테스트가 실제 외부 API 를 때린다. +# APP_ENV=test 면 .env 를 읽지 않는다. _ENV_FILE = None if APP_ENV == "test" else _DOTENV -# ★ 테스트는 별도 DB. conftest 가 "이름에 test 없으면 중단" 으로 dev DB 를 지킨다. +# 테스트는 별도 DB. _DEFAULT_DB_NAME = "web4ai_test_db" if APP_ENV == "test" else "web4ai_db" _BASE = SettingsConfigDict(env_file=_ENV_FILE, env_file_encoding="utf-8", extra="ignore", case_sensitive=False) @@ -35,7 +31,7 @@ class WebServerConfig(BaseSettings): process_count: int = Field(1, validation_alias="WEB_PROCESS_COUNT") is_ssl: bool = Field(False, validation_alias="WEB_IS_SSL") is_test: bool = Field(False, validation_alias="WEB_IS_TEST") - # CORS 허용 오리진(쉼표로 여럿). vite 는 3000 이 막히면 3001, 3002… 로 옮겨 뜬다. + # CORS 허용 오리진(쉼표로 여럿). client_url: str = Field( "http://localhost:3000,http://localhost:3001,http://localhost:3002," "http://localhost:3003,http://localhost:3004,http://localhost:3005", @@ -52,7 +48,7 @@ class LogConfig(BaseSettings): class MainDBConfig(BaseSettings): - """DB read/write 분리. 읽기 접속을 안 주면 쓰기와 같은 곳을 본다(복제 없는 환경이 기본).""" + """DB read/write 분리.""" model_config = _BASE @@ -71,7 +67,6 @@ class MainDBConfig(BaseSettings): show_log: bool = Field(False, validation_alias="DB_SHOW_LOG") # 동시 커넥션 상한 = (pool_size + max_overflow) x 엔진수(R/W=2) x 워커수. - # PostgreSQL max_connections 를 넘기면 안 된다. pool_size: int = Field(10, validation_alias="DB_POOL_SIZE") max_overflow: int = Field(20, validation_alias="DB_MAX_OVERFLOW") # ""/"disable"=로컬 · "require"|"verify-ca"|"verify-full"=관리형 DB @@ -100,12 +95,7 @@ class JwtToken(BaseSettings): class GoogleOAuthConfig(BaseSettings): - """구글 로그인. client_id 가 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다. - - ★ client_id 는 비밀이 아니다(프론트 번들에 그대로 들어간다). 서버가 이 값을 갖는 이유는 - 숨기려는 게 아니라 **수신자(aud) 대조** 때문이다 — 남의 앱에 발급된 구글 토큰을 그대로 - 들고 와도 우리 계정이 되지 않게 막는 유일한 검사다. - ★ client_secret 은 쓰지 않는다. 프론트가 ID 토큰을 받아 오는 방식(GIS)이라 코드 교환이 없다.""" + """구글 로그인.""" model_config = _BASE @@ -119,8 +109,6 @@ class ExternalApiConfig(BaseSettings): perplexity_api_key: str = Field("", validation_alias="PERPLEXITY_API_KEY") # 동일 업소 검증 — 둘 다 있으면 카카오 우선. - # 카카오: 15건 · 전화번호 O · 고유 id O · 행정구역 코드 O - # 네이버: 5건 · 전화번호 X · 고유 id 불확실 · 행정구역 코드 X → AMBIGUOUS 가 는다 kakao_rest_api_key: str = Field("", validation_alias="KAKAO_REST_API_KEY") naver_client_id: str = Field("", validation_alias="NAVER_CLIENT_ID") naver_client_secret: str = Field("", validation_alias="NAVER_CLIENT_SECRET") @@ -135,17 +123,15 @@ class ExternalApiConfig(BaseSettings): # 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다. vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD") tour_api_key: str = Field("", validation_alias="TOUR_API_KEY") - # 발행할 때 이 숙소의 노래를 한 곡 만든다(services/song_service). 비면 그 단계만 건너뛴다. + # 발행할 때 이 숙소의 노래를 한 곡 만든다(services/song_service). suno_api_key: str = Field("", validation_alias="SUNO_API_KEY") - # ★ 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다. - # 그래도 API 가 필수로 요구하는 필드라 값을 들고 있는다(services/external/suno.py 주석). + # 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다. suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL") - # 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology). 비면 그 단계만 - # 건너뛴다 — 제목·메타가 예전 그대로 나간다(services/seo_keywords). + # 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology). site_ontology_url: str = Field("", validation_alias="SITE_ONTOLOGY_URL") -# .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다. +# .env 를 요청마다 다시 읽지 않는다. @lru_cache def get_web_server_config() -> WebServerConfig: return WebServerConfig() diff --git a/solution/backend/config/social_config.py b/solution/backend/config/social_config.py index a3354e8..c541e09 100644 --- a/solution/backend/config/social_config.py +++ b/solution/backend/config/social_config.py @@ -1,4 +1,4 @@ -"""SNS도 루트 .env만 읽는다. APP_ENV=test에서는 기존 설정 규칙대로 .env를 읽지 않는다.""" +"""SNS도 루트 .env만 읽는다.""" from pydantic_settings import BaseSettings from config.config_models import _BASE diff --git a/solution/backend/conftest.py b/solution/backend/conftest.py index 7e70da2..0b4c612 100644 --- a/solution/backend/conftest.py +++ b/solution/backend/conftest.py @@ -1,6 +1,4 @@ # 테스트는 APP_ENV=test 로 실행한다 (DB 이름 기본값이 web4ai_test_db 로 갈린다, dev DB 와 분리). -# 이 픽스처들은 TRUNCATE 를 하므로 dev DB(web4ai_db)와 절대 공유하면 안 된다(아래 db_engine 안전가드 참고). -# config.server_configs 가 import 되는 순간 config..toml 을 읽으므로 가장 먼저 설정. import os os.environ.setdefault("APP_ENV", "test") @@ -18,14 +16,7 @@ from common.enums import UserRole, UserStatus from config.server_configs import main_db_config -# ★ 스키마는 public 한 벌이다 — 도메인 스키마(company·place·fact·local·site·job)는 -# 2026-09-09 에 걷어냈다(migrations/0005). 그래서 여기서 스키마를 만들지도, search_path 를 -# 얹지도 않는다. -# -# ★ 비울 표는 **ORM 이 아는 것**에서 뽑는다. 예전에는 이름을 손으로 나열했는데, -# 0005 가 표 이름을 옮겼을 때 이 문자열만 옛 이름으로 남아 테스트 13건이 통째로 -# `relation "place_aliases" does not exist` 로 죽었다 — 문자열이라 import 도 타입검사도 -# pyflakes 도 잡지 못한다. 모델에서 뽑으면 다시 어긋날 수 없다. +# 비울 표는 **ORM 이 아는 것**에서 뽑는다. def _truncate_sql() -> str: names = ", ".join(t.name for t in MAIN_BASE.metadata.sorted_tables) return f"TRUNCATE TABLE {names} RESTART IDENTITY CASCADE" @@ -37,14 +28,13 @@ def _write_url(cfg) -> str: def _admin_url(cfg) -> str: - """DB 생성용 관리 접속. CREATE DATABASE 는 대상 DB 안에서 못 하므로 기본 'postgres' DB 로 붙는다.""" + """DB 생성용 관리 접속.""" pw = f":{cfg.write_pw}" if cfg.write_pw else "" return f"postgresql+asyncpg://{cfg.write_id}{pw}@{cfg.write_host}:{cfg.write_port}/postgres" async def _drop_test_db(*, recreate: bool): - """test DB 를 지운다(있으면). recreate=True 면 지운 뒤 새로 만든다. - WITH (FORCE): 남아있는 커넥션을 끊고 drop (PG13+). 관리 접속은 기본 'postgres' DB.""" + """test DB 를 지운다(있으면).""" engine = create_async_engine(_admin_url(main_db_config), isolation_level="AUTOCOMMIT") try: async with engine.connect() as conn: @@ -57,12 +47,7 @@ async def _drop_test_db(*, recreate: bool): @pytest_asyncio.fixture(scope="session", autouse=True) async def _test_db_lifecycle(): - """테스트 세션 동안만 test DB 를 만들고, 끝나면 내린다. - - 매 세션 '깨끗한 새 DB'로 시작하므로 스키마 낡음(드리프트)이 원천 차단되고, 끝나면 남는 DB 도 없다. - (테이블 구조는 db_engine 의 create_all 이 현재 모델 기준으로 채운다.) - 안전가드: 이름에 'test' 있는 DB 만 만들고/지운다(dev DB 보호). - """ + """테스트 세션 동안만 test DB 를 만들고, 끝나면 내린다.""" assert "test" in main_db_config.name, ( f"비-test DB('{main_db_config.name}') 는 만들거나 지우지 않는다. APP_ENV=test 로 실행하세요." ) @@ -77,13 +62,8 @@ async def _test_db_lifecycle(): @pytest_asyncio.fixture async def db_engine(_test_db_lifecycle): - """테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다. - - ⚠ 이 픽스처는 TRUNCATE 한다 → dev DB(web4ai_db)를 가리키면 실데이터가 날아간다. - 그래서 test 전용 DB(이름에 'test')가 아니면 즉시 중단한다(APP_ENV=test). - 앱(DB_SESSION_MNG)도 APP_ENV=test 면 같은 test DB 에 접속하므로 여기서 만든 스키마를 공유한다. - """ - # 안전가드: dev DB 오염 방지. web4ai_test_db 이외엔 절대 실행하지 않는다. + """테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다.""" + # 안전가드: dev DB 오염 방지. assert "test" in main_db_config.name, ( f"테스트가 비-test DB('{main_db_config.name}')를 가리킵니다. " "APP_ENV=test 로 실행하세요. dev DB 보호를 위해 중단합니다." @@ -99,11 +79,7 @@ async def db_engine(_test_db_lifecycle): @pytest_asyncio.fixture async def owner_id(db_engine) -> str: - """사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다. - - ★ 예전엔 `company_id`(소속사)였다. 회사(테넌트)를 걷어내면서 사업장이 `owner_user_id` 로 - 계정에 직접 매이게 됐다 — DB 를 직접 시드하는 테스트가 place 에 넣을 주인이 이 값이다. - """ + """사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다.""" uid = uuid.uuid4() async with db_engine.begin() as conn: # status·role 은 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시. @@ -130,13 +106,7 @@ async def client(db_engine): @pytest_asyncio.fixture async def auth_headers(db_engine, client): - """테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리. - - 계정 생성 API 가 없으므로 users 행을 직접 INSERT(비번 bcrypt 해시)한 뒤 /v1/auth/login 으로 토큰을 받는다. - ★ 회사 인자가 없다. 스코프가 계정 자체이므로 **다른 login_id 로 한 번 더 부르면 그게 남**이다 - — 격리 테스트는 `await auth_headers("o2")` 하나면 된다. - 호출: `h = await auth_headers("user1")`. - """ + """테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리.""" from router.v1.validator.dependencies import GetHashedPW async def _make(login_id, *, password="pw1234", role=UserRole.USER.value, name="n"): @@ -162,18 +132,7 @@ async def auth_headers(db_engine, client): # ── 렌더러 스텁 ────────────────────────────────────────────────────────────── @pytest.fixture(autouse=True) def fake_renderer(monkeypatch, tmp_path_factory): - """정적 렌더러(solution/site) 대역. - - ★ 왜 필요한가 - 발행 게이트는 이제 **실제로 나갈 HTML** 을 보고 판정한다. 그 HTML 은 Node 렌더러가 - 굽고, BUILD 잡은 렌더러 subprocess 를 직접 돌린다(services/render_service.render_site). - 파이썬 테스트 환경에는 Node 렌더러가 없으므로, payload 를 읽어 보고서를 만들어 주는 - 대역을 끼운다 — 여기서 검사하려는 건 **백엔드가 보고서를 어떻게 처리하는가** 다. - - ★ 구조화 데이터 ↔ 화면 값 대조 자체는 렌더러 쪽 테스트가 본다 - (solution/site/src/seo/verify.test.ts). 그 규칙을 여기서 다시 구현하지 않는다 — - 두 벌로 두면 어긋나고, 어긋난 걸 아무도 모르는 게 원래 문제였다. - """ + """정적 렌더러(solution/site) 대역.""" import json from pathlib import Path @@ -242,11 +201,7 @@ def fake_renderer(monkeypatch, tmp_path_factory): ) place = payload.get("place") or {} count = _count(payload) - # ★ 고유 콘텐츠 0건이면 렌더러는 **페이지를 쓰지 않는다** - # (prerender.ts NoUniqueContentError — 백엔드가 나중에 거부해도 그 전에 디스크에 - # 나가 있으면 크롤러가 읽는다). 대역이 늘 ok=True 를 주면 백엔드가 그 실패를 - # NO_UNIQUE_CONTENT 로 되짚는 경로가 통째로 테스트되지 않는다. - # mismatches 는 비워 둔다 — 사유가 JSONLD_MISMATCH 로 섞이면 화면 문구가 틀린다. + # 고유 콘텐츠 0건이면 렌더러는 **페이지를 쓰지 않는다** (prerender.ts NoUniqueContentError — 백엔드가 나중에 거부해도 그 전에 디스크에 나가 있으면 크롤러가 읽는다). if count <= 0: return { "schemaVersion": 1, diff --git a/solution/backend/crud/alert_crud.py b/solution/backend/crud/alert_crud.py index bb4a651..da477ec 100644 --- a/solution/backend/crud/alert_crud.py +++ b/solution/backend/crud/alert_crud.py @@ -1,4 +1,4 @@ -"""alert_outbox 원장 접근. services/alert_service.py 가 부른다.""" +"""alert_outbox 원장 접근.""" from sqlalchemy import func, select, update from common.database.model.models import alert_outbox @@ -7,10 +7,7 @@ from common.utils.gtime import GTime async def latest_unresolved(session, dedupe_key: str): - """이 dedupe_key 로 아직 안 풀린(resolved_at IS NULL) 가장 최근 알림. 없으면 None. - - ★ send_alert 의 중복 억제와 resolve_alert 의 "지금 알람 상태인가" 판정이 **같은 질의**를 - 쓴다 — 따로 구현하면 두 판단이 어긋날 수 있다.""" + """이 dedupe_key 로 아직 안 풀린(resolved_at IS NULL) 가장 최근 알림.""" result = await session.execute( select(alert_outbox) .where(alert_outbox.dedupe_key == dedupe_key, alert_outbox.deleted.is_(False), @@ -29,10 +26,7 @@ async def insert(session, values: dict) -> alert_outbox: async def due_pending(session, limit: int = 20): - """★ `next_attempt_at <= func.now()` — **DB 서버의** 지금 시각과 비교한다. 파이썬에서 계산한 - GTime.UTC() 와 비교하면 앱 서버와 DB 서버의 시계가 몇 십 ms 만 어긋나도(흔하다 — 별도 - 컨테이너) send_alert 직후 process_outbox 를 부르는 자리에서 방금 넣은 행이 안 잡힐 수 - 있다(실측: 로컬에서 그렇게 재현됐다). 비교를 DB 쪽 시계 하나로 통일하면 이 경합이 없다.""" + """`next_attempt_at <= func.now()` — **DB 서버의** 지금 시각과 비교한다.""" result = await session.execute( select(alert_outbox) .where(alert_outbox.status == AlertStatus.PENDING.value, alert_outbox.deleted.is_(False), diff --git a/solution/backend/crud/fact_crud.py b/solution/backend/crud/fact_crud.py index f055940..8f97b2e 100644 --- a/solution/backend/crud/fact_crud.py +++ b/solution/backend/crud/fact_crud.py @@ -10,10 +10,9 @@ from common.enums import ErrorType, FactStatus from common.logger import LOG from common.utils.gtime import GTime -# ★ 사이트에 나가는 상태. PUBLISHABLE_FACT_STATUSES 와 같은 집합이어야 한다. -# 유니크 인덱스(uq_facts_published_*)의 조건과도 같아야 한다. +# 사이트에 나가는 상태. _PUBLISHED = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value) -# 후보 — 재수집이 올려놓은 확인 대기 항목. 여러 건 공존한다. +# 후보 — 재수집이 올려놓은 확인 대기 항목. _CANDIDATE = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value) # 화면에 보이는 것 전체(이력 제외). _ACTIVE = _PUBLISHED + _CANDIDATE @@ -24,7 +23,7 @@ def _unit_cond(unit_id): return place_facts.unit_id.is_(None) if unit_id is None else place_facts.unit_id == unit_id -# fact CRUD. 항상 place_id 로 스코프한다. +# fact CRUD. class IFactCRUD(ABC): @abstractmethod async def add_fact(self, cdb: AsyncSession, fact: place_facts) -> ErrorType: @@ -96,10 +95,7 @@ class FactCRUD(IFactCRUD): self, cdb: AsyncSession, place_id, unit_id=None, status: Optional[int] = None, publishable_only: bool = False, active_only: bool = True, ) -> Tuple[ErrorType, list]: - """fact 목록. - publishable_only=True → ★ VERIFIED·CORRECTED 만 (사이트 렌더·발행 게이트가 쓰는 경로) - active_only=True → REJECTED·EXPIRED 이력 제외 (관리 화면 기본: 노출값 + 후보) - """ + """fact 목록.""" try: conditions = [place_facts.place_id == place_id, place_facts.deleted == False] # noqa: E712 if unit_id is not None: @@ -111,7 +107,7 @@ class FactCRUD(IFactCRUD): elif active_only: conditions.append(place_facts.status.in_(_ACTIVE)) - # 노출값이 먼저, 그 아래 후보. 같은 key 끼리 붙어 보이게 정렬한다. + # 노출값이 먼저, 그 아래 후보. query = select(place_facts).where(and_(*conditions)).order_by( place_facts.key.asc(), place_facts.status.desc(), place_facts.collected_at.desc() ) @@ -122,8 +118,7 @@ class FactCRUD(IFactCRUD): return ErrorType.DB_RUN_FAILED, [] async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, place_facts]: - """★ 지금 사이트에 나가고 있는 값. 없으면 (SUCCESS, None). - 유니크 인덱스가 1건만 허용하므로 결과는 0 또는 1건이다.""" + """지금 사이트에 나가고 있는 값.""" try: query = ( select(place_facts) @@ -145,7 +140,7 @@ class FactCRUD(IFactCRUD): return ErrorType.DB_RUN_FAILED, None async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, place_facts]: - """같은 출처가 이미 올려둔 후보. 재수집이 같은 후보를 계속 쌓지 않도록 갱신 대상을 찾는다.""" + """같은 출처가 이미 올려둔 후보.""" try: query = ( select(place_facts) @@ -169,9 +164,7 @@ class FactCRUD(IFactCRUD): return ErrorType.DB_RUN_FAILED, None async def refresh_collected(self, cdb: AsyncSession, fact_id, source_type, source_url, ts) -> Tuple[ErrorType, int]: - """★ 재수집했는데 값이 그대로일 때 — 검증 상태를 건드리지 않고 '언제 다시 확인했는지'만 갱신한다. - - 이게 없으면 값이 안 바뀌었는데도 재수집마다 검증이 초기화돼 사이트에서 사실이 사라진다.""" + """재수집했는데 값이 그대로일 때 — 검증 상태를 건드리지 않고 '언제 다시 확인했는지'만 갱신한다.""" try: values = {"collected_at": ts, "updated_at": ts} if source_url: @@ -183,7 +176,7 @@ class FactCRUD(IFactCRUD): return ErrorType.DB_RUN_FAILED, 0 async def update_candidate(self, cdb: AsyncSession, fact_id, value, source_url, status: int, ts) -> Tuple[ErrorType, int]: - """기존 후보를 새 수집값으로 갱신. 같은 출처의 후보가 계속 쌓이는 것을 막는다.""" + """기존 후보를 새 수집값으로 갱신.""" try: query = ( update(place_facts) @@ -196,10 +189,7 @@ class FactCRUD(IFactCRUD): return ErrorType.DB_RUN_FAILED, 0 async def transition(self, cdb: AsyncSession, fact_id, from_statuses, to_status: int, data: dict) -> Tuple[ErrorType, int]: - """검증 상태 전이 — **출발 상태를 WHERE 에 걸어** 조건부로만 바꾼다. - - 적용행수 0 = 그 사이 다른 사람이 이미 상태를 바꿨다는 뜻(동시 처리 가드). - 허용 전이 판정 자체는 service 가 FACT_STATUS_TRANSITIONS 로 먼저 한다.""" + """검증 상태 전이 — **출발 상태를 WHERE 에 걸어** 조건부로만 바꾼다.""" try: query = ( update(place_facts) @@ -216,9 +206,7 @@ class FactCRUD(IFactCRUD): return ErrorType.DB_RUN_FAILED, 0 async def expire_published(self, cdb: AsyncSession, place_id, unit_id, key, ts, except_fact_id=None) -> Tuple[ErrorType, int]: - """현재 노출값을 EXPIRED 로 내려 자리를 비운다(후보 승격·직접 교체 직전에 호출). - - 지우지 않고 이력으로 남긴다 — 예전에 뭐가 나갔는지 추적할 수 있어야 한다.""" + """현재 노출값을 EXPIRED 로 내려 자리를 비운다(후보 승격·직접 교체 직전에 호출).""" try: conditions = [ place_facts.place_id == place_id, @@ -236,9 +224,7 @@ class FactCRUD(IFactCRUD): return ErrorType.DB_RUN_FAILED, 0 async def reject_candidates(self, cdb: AsyncSession, place_id, unit_id, key, ts, except_fact_id=None) -> Tuple[ErrorType, int]: - """남은 후보를 REJECTED 로 정리한다(하나를 승격시켰으니 나머지는 판정된 셈). - - 후보를 그대로 두면 사람 확인 큐에 이미 처리된 항목이 계속 남는다.""" + """남은 후보를 REJECTED 로 정리한다(하나를 승격시켰으니 나머지는 판정된 셈).""" try: conditions = [ place_facts.place_id == place_id, diff --git a/solution/backend/crud/faq_crud.py b/solution/backend/crud/faq_crud.py index 6de4445..bd3576a 100644 --- a/solution/backend/crud/faq_crud.py +++ b/solution/backend/crud/faq_crud.py @@ -14,7 +14,7 @@ _PUBLISHABLE = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value) _ACTIVE = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value) + _PUBLISHABLE -# FAQ CRUD. fact 와 같은 검증 상태 흐름을 탄다 — 생성된 문장도 사람이 확인해야 나간다. +# FAQ CRUD. class IFaqCRUD(ABC): @abstractmethod async def list_faqs(self, cdb: AsyncSession, place_id, publishable_only: bool) -> Tuple[ErrorType, list]: @@ -72,17 +72,7 @@ class FaqCRUD(IFaqCRUD): return ErrorType.DB_RUN_FAILED async def expire_generated(self, cdb: AsyncSession, place_id, ts) -> Tuple[ErrorType, int]: - """재생성 전에 **LLM 이 쓴** FAQ 를 내린다. - - ★ 사람이 정정한 FAQ 는 건드리지 않는다 — 재생성이 사람의 판단을 덮어쓰면 - fact 쪽 규칙과 어긋난다. - - ★ 가르는 기준이 status 에서 generated_by 로 바뀌었다 (2026-09-10). - 생성분이 UNVERIFIED 로 들어가던 시절에는 status 만으로 "사람이 손댔는가" 를 알 수 - 있었다. 이제 생성분도 VERIFIED 로 들어가므로(copy_service) status 로는 둘이 구분되지 - 않는다 — 그대로 두면 재생성이 옛 FAQ 를 못 내리고 같은 질문이 쌓인다. - 책임 주체는 원래부터 여기 적혀 있었다: 사장님이 정정하면 faq_service 가 - generated_by 를 OWNER 로 바꾼다.""" + """재생성 전에 **LLM 이 쓴** FAQ 를 내린다.""" try: query = ( update(place_faqs) @@ -90,10 +80,8 @@ class FaqCRUD(IFaqCRUD): place_faqs.place_id == place_id, place_faqs.deleted == False, # noqa: E712 # 목표 수를 채운 공통 질문(TEMPLATE)도 자동 산출물이다 — 재생성마다 다시 고른다. - # 안 내리면 fact 가 새로 생겨 LLM 이 답한 주제에 옛 문의 안내가 겹쳐 남는다. place_faqs.generated_by.in_((SourceType.LLM.value, SourceType.TEMPLATE.value)), - # 이미 내려간 것(EXPIRED)과 사장님이 반려한 것(REJECTED)은 그대로 둔다 — - # 반려는 판단의 기록이라 재생성이 지울 이유가 없다. + # 이미 내려간 것(EXPIRED)과 사장님이 반려한 것(REJECTED)은 그대로 둔다 — 반려는 판단의 기록이라 재생성이 지울 이유가 없다. place_faqs.status.not_in((FactStatus.EXPIRED.value, FactStatus.REJECTED.value)), ) .values(status=FactStatus.EXPIRED.value, updated_at=ts) diff --git a/solution/backend/crud/job_crud.py b/solution/backend/crud/job_crud.py index 35dbe67..a96736e 100644 --- a/solution/backend/crud/job_crud.py +++ b/solution/backend/crud/job_crud.py @@ -1,14 +1,4 @@ -"""작업 큐 CRUD — PostgreSQL 을 '제대로' 큐로 쓴다. (LPS `crud/job_crud.py` 이식) - - - 할당은 **단일 문장 원자 claim**: FOR UPDATE SKIP LOCKED 서브쿼리 + 같은 UPDATE + RETURNING. - → 워커 컨테이너가 몇 개든 같은 잡 이중 할당이 원천 불가. fetch 와 claim 을 분리하지 않는다. - - 모든 전이는 **조건부 CAS**(WHERE 에 status/worker_id 가드) + RETURNING. - - 복구는 timeout 추측이 아니라 **lease 만료 소유권**(reaper 가 회수). - - 재시도/백오프/dead-letter 를 큐에 내장. - -전이가 조회/변경으로 나뉘지 않으므로(RETURNING) execute_lambda_write 로 실행한다. -큐 전이만 raw SQL 이다 — 다른 crud 는 전부 SQLAlchemy 표현식을 쓴다. -""" +"""작업 큐 CRUD — PostgreSQL 을 '제대로' 큐로 쓴다.""" import json @@ -23,7 +13,7 @@ JOB_NOTIFY_CHANNEL = "web4ai_job" def compute_backoff(attempts: int, base: float = 5.0, cap: float = 600.0) -> float: - """지수 백오프(초). attempts 회 시도 후 다음 재시도까지 대기 = base * 2^(attempts-1), cap 상한.""" + """지수 백오프(초).""" return min(cap, base * (2 ** max(0, attempts - 1))) @@ -34,7 +24,7 @@ class JobQueue: """쓰기 트랜잭션 — 값 반환이 필요한 큐 전이 전용 진입점.""" return await DB_SESSION_MNG.execute_lambda_write(self.DB, fn) - # ---- 적재 ---- + # 적재 async def enqueue( self, job_type: int, @@ -43,7 +33,7 @@ class JobQueue: dedupe_key: str | None = None, max_attempts: int = 3, ) -> str | None: - """잡 적재. dedupe_key 가 활성(PENDING/RUNNING) 중복이면 삽입 없이 None 반환.""" + """잡 적재.""" sql = text(""" INSERT INTO jobs (job_type, priority, payload, dedupe_key, max_attempts) VALUES (:t, :p, CAST(:payload AS jsonb), :dk, :ma) @@ -64,10 +54,9 @@ class JobQueue: return await self._tx(run) - # ---- 원자적 claim ---- + # 원자적 claim async def claim(self, worker_id: str, lease_sec: int = 120) -> dict | None: - """대기 잡 1건을 원자적으로 점유. 없으면 None. - FOR UPDATE SKIP LOCKED 로 잠근 행을 같은 UPDATE 에서 RUNNING 으로 전이 → 이중 할당 불가.""" + """대기 잡 1건을 원자적으로 점유.""" sql = text(""" UPDATE jobs SET status = 2, @@ -98,7 +87,7 @@ class JobQueue: return await self._tx(run) - # ---- 완료/실패 (소유권 가드) ---- + # 완료/실패 (소유권 가드) async def complete(self, job_id: str, worker_id: str, result: dict | None = None) -> bool: sql = text(""" UPDATE jobs SET status = 3, result = CAST(:result AS jsonb), @@ -117,8 +106,7 @@ class JobQueue: return await self._tx(run) async def fail(self, job_id: str, worker_id: str, error: str, backoff_sec: float = 5.0) -> int | None: - """실패 처리. 시도 남으면 PENDING(run_after=백오프)으로 재큐, 소진되면 DEAD(dead-letter). - 전이 후 status(JobStatus 값)를 반환. 소유 불일치면 None.""" + """실패 처리.""" sql = text(""" UPDATE jobs SET status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END, @@ -141,11 +129,7 @@ class JobQueue: return await self._tx(run) async def fail_permanent(self, job_id: str, worker_id: str, error: str) -> bool: - """재시도 없이 바로 DEAD. 시도 횟수가 남아 있어도 보내지 않는다. - - ★ 다시 해도 같은 결과인 실패에 쓴다(common/job_errors.PermanentJobError). - 백오프 재큐는 '일시적 장애' 라는 판단인데, 사업장이 지워졌거나 업종이 없는 잡은 - 그 판단이 틀렸다 — 큐만 붙들고 DEAD 알림을 세 배로 늘린다.""" + """재시도 없이 바로 DEAD.""" sql = text(""" UPDATE jobs SET status = 4, last_error = :err, lease_until = NULL, worker_id = NULL, updated_at = now() @@ -159,7 +143,7 @@ class JobQueue: return await self._tx(run) - # ---- lease 갱신(heartbeat) / 회수(reaper) ---- + # lease 갱신(heartbeat) / 회수(reaper) async def renew_lease(self, job_id: str, worker_id: str, lease_sec: int = 120) -> bool: sql = text(""" UPDATE jobs SET lease_until = now() + make_interval(secs => :lease), updated_at = now() @@ -174,11 +158,7 @@ class JobQueue: return await self._tx(run) async def reap(self) -> list[dict]: - """만료된 lease(워커 사망 등)의 RUNNING 잡을 회수. 시도 남으면 즉시 재큐, 소진되면 DEAD. - - 회수된 잡마다 {job_id, job_type, status, last_error} 를 돌려준다 — worker/runner.py 의 - run_reaper 가 이 중 DEAD(4) 로 떨어진 것만 골라 알린다(alert_service). job_id 목록만 - 돌려주던 예전 모양보다 한 겹 더 있는 이유가 그것뿐이다.""" + """만료된 lease(워커 사망 등)의 RUNNING 잡을 회수.""" sql = text(""" UPDATE jobs SET status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END, @@ -200,7 +180,7 @@ class JobQueue: return await self._tx(run) - # ---- 단건 조회 (상태 폴링) ---- + # 단건 조회 (상태 폴링) async def set_progress(self, job: dict, progress: dict) -> bool: # 회수된 옛 워커가 새 시도의 진행 상태를 덮지 못하게 한다. sql = text(""" @@ -221,7 +201,7 @@ class JobQueue: return await self._tx(run) async def find_latest(self, dedupe_key: str) -> dict | None: - """복구는 완료·실패 이력도 찾는다. 활성 중복 방지와 다른 조회다.""" + """복구는 완료·실패 이력도 찾는다.""" async def run(s): row = (await s.execute(text(""" SELECT job_id, status FROM jobs WHERE dedupe_key = :dk @@ -232,7 +212,7 @@ class JobQueue: return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run) async def get(self, job_id: str) -> dict | None: - """잡 단건 조회(읽기). 없으면 None. status 는 정수(JobStatus 값).""" + """잡 단건 조회(읽기).""" sql = text(""" SELECT job_id, job_type, status, priority, attempts, max_attempts, payload, result, progress, last_error, run_after, run_started_at, created_at, updated_at @@ -253,8 +233,7 @@ class JobQueue: return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run) async def find_active(self, dedupe_key: str) -> dict | None: - """dedupe_key 로 활성(PENDING/RUNNING) 잡을 찾는다. - enqueue 가 중복으로 None 을 돌려줬을 때, 이미 돌고 있는 잡의 id 를 알려주기 위함.""" + """dedupe_key 로 활성(PENDING/RUNNING) 잡을 찾는다.""" sql = text(""" SELECT job_id, job_type, status FROM jobs WHERE dedupe_key = :dk AND status IN (1, 2) @@ -271,7 +250,7 @@ class JobQueue: return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run) - # ---- 관측(관리 API/알림용) ---- + # 관측(관리 API/알림용) async def counts(self) -> dict[str, int]: """상태별 잡 개수.""" async def run(s): @@ -282,11 +261,7 @@ class JobQueue: return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run) async def ops(self) -> dict: - """운영 스냅샷(모니터링·알림용): 상태별 카운트 + 큐 지연(가장 오래된 PENDING 나이) + - 최근 1시간 DEAD + stuck(좀비 신호). - - stuck 은 두 축 — lease 만료(워커 사망인데 reaper 미회수) OR 실행 10분 초과(핸들러 행 — - heartbeat 가 lease 를 계속 갱신해 lease 축엔 안 잡히므로 run_started_at 으로 따로 본다).""" + """운영 스냅샷(모니터링·알림용): 상태별 카운트 + 큐 지연(가장 오래된 PENDING 나이) + 최근 1시간 DEAD + stuck(좀비 신호).""" sql = text(""" SELECT count(*) FILTER (WHERE status = 1) AS pending, @@ -310,8 +285,7 @@ class JobQueue: return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run) async def requeue(self, job_id: str) -> str | None: - """DEAD 잡 재큐(관리자 액션): attempts 리셋 + PENDING 전이 + 워커 깨움. - DEAD 가 아니거나 없으면 None. 같은 dedupe_key 의 활성 잡이 있으면 부분 유니크 위반.""" + """DEAD 잡 재큐(관리자 액션): attempts 리셋 + PENDING 전이 + 워커 깨움.""" sql = text(""" UPDATE jobs SET status = 1, attempts = 0, run_after = now(), lease_until = NULL, worker_id = NULL, run_started_at = NULL, diff --git a/solution/backend/crud/local_content_crud.py b/solution/backend/crud/local_content_crud.py index 19c4ac4..d185564 100644 --- a/solution/backend/crud/local_content_crud.py +++ b/solution/backend/crud/local_content_crud.py @@ -9,8 +9,7 @@ from common.utils.gtime import GTime class LocalContentCRUD: async def list(self, db, status: int | None = None, region_code: str | None = None): - """축제·관광지·맛집·날씨 전 종류. ★ 예전엔 FESTIVAL 로 고정돼 있어 sync_region 이 받은 - 관광지·맛집이 이 목록에 영영 안 보였다(admin 화면이 축제만 검수/발행하는 줄 알게 됨).""" + """축제·관광지·맛집·날씨 전 종류.""" conds = [area_contents.deleted == False] # noqa: E712 if status is not None: conds.append(area_contents.status == status) @@ -43,20 +42,11 @@ class LocalContentCRUD: return await self.update(db, content_id, {"status": 3}) async def upsert_kind(self, db, values: dict): - """지역 이야기 한 종류(가요·인물·…)의 삽입/갱신. - - ★ `uq_local_contents_kind`(region_code, kind — kind IS NOT NULL)에 태운다. - 이 표의 규약은 **한 지역에 종류당 한 벌**이다(migrations/0004). 항목마다 한 행이 아니라 - `body.items` 에 통째로 담긴다 — 사장님이 붙여넣는 같은 종류의 JSON 과 모양을 맞추기 - 위해서다. 다시 생성하면 그 한 행을 덮어쓴다. - ★ external_id 는 넣지 않는다. 넣으면 `uq_local_contents_external`(source, external_id)에도 - 걸려, 종류가 다른 두 행이 같은 키로 충돌한다.""" + """지역 이야기 한 종류(가요·인물·…)의 삽입/갱신.""" stmt = pg_insert(area_contents).values(**values) stmt = stmt.on_conflict_do_update( index_elements=[area_contents.region_code, area_contents.kind], - # ★ 조건은 인덱스와 **글자 그대로** 같아야 한다. 포스트그레스는 ON CONFLICT 술어가 - # 인덱스 술어를 함의하는지 보고, 아니면 "no unique or exclusion constraint matching" - # 으로 거절한다 — 컬럼도 표도 멀쩡해서 눈으로는 원인이 안 보이는 종류다. + # 조건은 인덱스와 **글자 그대로** 같아야 한다. index_where=and_( area_contents.deleted == False, # noqa: E712 area_contents.kind.isnot(None), @@ -76,17 +66,7 @@ class LocalContentCRUD: return await DB_SESSION_MNG.add(db, stmt) async def list_kinds(self, db, region_code: str): - """지역의 **이야기** 행(종류당 1행). cache-aside 판단에 쓴다. - - ★ `kind IS NOT NULL` 로 고르면 안 된다. kind 는 이야기 전용 칸이 아니다 — - 마이그레이션 0008 이 날씨·축제·명소·맛집에도 kind 를 채웠기 때문에(AREA_KIND), - 그렇게 고르면 **이야기가 한 건도 없는 지역이 "이미 있다"로 판정된다.** - 실측(2026-09-10, 전북 군산시): 주변정보 116건이 들어온 뒤로 `has_stories` 가 늘 참이라 - 지역 이야기 생성이 영영 건너뛰어졌고, 발행본에서 가요다방·인물열전·시간의 골목· - 엽서·퀴즈 다섯 섹션이 통째로 비었다. 잡은 성공으로 끝나고 로그도 조용해서 - "생성기가 없는 것" 처럼 보였다. - ★ 그래서 STORY_KINDS 를 명시한다. 종류가 늘면 그 상수만 늘린다. - """ + """지역의 **이야기** 행(종류당 1행).""" return await DB_SESSION_MNG.execute( db, select(area_contents).where( @@ -112,8 +92,6 @@ class LocalContentCRUD: stmt = pg_insert(area_contents).values(**values) stmt = stmt.on_conflict_do_update( index_elements=[area_contents.region_code, area_contents.content_type], - # ★ `kind IS NULL` 이 빠져 있어 이 upsert 가 통째로 실패하고 있었다(0007 이 인덱스에 - # 그 조건을 더했다). 날씨는 캐시라 실패해도 화면이 안 죽어서 **로그에만 남았다.** index_where=and_( area_contents.deleted == False, # noqa: E712 area_contents.external_id.is_(None), diff --git a/solution/backend/crud/media_crud.py b/solution/backend/crud/media_crud.py index 7f0a7d2..b39df39 100644 --- a/solution/backend/crud/media_crud.py +++ b/solution/backend/crud/media_crud.py @@ -11,7 +11,7 @@ from common.logger import LOG from common.utils.gtime import GTime -# 사진 CRUD. 항상 place_id 로 스코프한다. +# 사진 CRUD. class IMediaCRUD(ABC): @abstractmethod async def list_media( @@ -32,18 +32,7 @@ class MediaCRUD(IMediaCRUD): async def list_media( self, cdb: AsyncSession, place_id, status=None, unlabeled_only: bool = False, unit_id=None, alt_required: bool = False ) -> Tuple[ErrorType, list]: - """사진 목록. unlabeled_only=True 면 아직 Vision 분석이 안 된 것만(재분석 비용 절약). - - ★ "분석 안 됨"의 기준은 **alt_text 가 비었는가**다. label 이 아니다. - 수집 어댑터가 페이지에서 주운 캡션을 label 에 넣어 두기 때문에(base.CollectedMedia - 주석: "Vision 이 확정하기 전의 후보 라벨"), label 로 판정하면 캡션이 있는 사진은 - 전부 '이미 분석됨'으로 건너뛴다 — 실제로 네이버에서 긁은 사진 10장이 통째로 - 그렇게 빠져 Vision 이 "분석할 사진이 없다"로 끝났고, alt 가 없어 발행도 못 했다. - alt 는 Vision 만 채우고 발행 조건이기도 하므로 기준으로 삼기에 정확하다. - - unit_id 는 객실·메뉴 단위 사진만 추린다(빌더가 객실 카드에 붙일 사진을 고를 때). - ★ alt_required=True 는 status 필터와 짝으로만 쓴다 — alt 가 빈 사진은 빌더가 렌더하지 - 않으므로(services/snapshot.py), '발행되면 실릴 것'을 물었을 때 승인만 보면 답이 틀린다.""" + """사진 목록.""" try: conditions = [place_photos.place_id == place_id, place_photos.deleted == False] # noqa: E712 if status is not None: @@ -66,10 +55,7 @@ class MediaCRUD(IMediaCRUD): return ErrorType.DB_RUN_FAILED, [] async def apply_vision(self, cdb: AsyncSession, media_id, label, alt_text, confidence, status: int, ts) -> Tuple[ErrorType, int]: - """Vision 분석 결과를 반영한다. - - ★ 신뢰도가 낮으면 status 를 PENDING_REVIEW 로 남긴다 — 자동 반영하지 않는다. - 라벨·alt 는 저장하되(사람이 보고 고칠 재료), 승인 상태로 올리지 않는 게 핵심이다.""" + """Vision 분석 결과를 반영한다.""" try: query = ( update(place_photos) diff --git a/solution/backend/crud/place_content_crud.py b/solution/backend/crud/place_content_crud.py index 37bd6e1..9ea327a 100644 --- a/solution/backend/crud/place_content_crud.py +++ b/solution/backend/crud/place_content_crud.py @@ -7,18 +7,10 @@ from common.utils.gtime import GTime class PlaceContentCRUD: - """업장 주변의 지역 콘텐츠. - - ★ 실체와 관계가 갈려 있다 (2026-09-09). - 예전에는 한 테이블이 값을 통째로 들고 있었고 키가 place_id 라, 업장마다 TourAPI - 응답이 복제됐다 — 실측 조이모텔 한 곳에 144행이고 같은 축제가 업장 수만큼 늘었다. - 지금은 실체가 `area_contents` 에 한 행(전국 공용, external_id 로 유일)이고 - `place_area_refs` 에는 그 업장에서만 다른 것 — 거리와 숨김 — 만 남는다. - 그래서 읽을 때 조인이 하나 는다. 그 값으로 복제를 없앴다. - """ + """업장 주변의 지역 콘텐츠.""" async def list_by_place(self, db, place_id, *, include_hidden: bool = True): - """이 업장 주변의 콘텐츠. 실체(area_contents)와 거리(place_area_refs)를 함께 준다.""" + """이 업장 주변의 콘텐츠.""" conds = [ place_area_refs.place_id == place_id, place_area_refs.deleted == False, # noqa: E712 @@ -38,7 +30,7 @@ class PlaceContentCRUD: area_contents.latitude, area_contents.longitude, area_contents.display_end_at, - # ★ 이름을 옛 컬럼과 맞춘다 — 읽는 쪽(snapshot)이 행을 그대로 쓰던 모양이다. + # 이름을 옛 컬럼과 맞춘다 — 읽는 쪽(snapshot)이 행을 그대로 쓰던 모양이다. place_area_refs.distance_m.label("distance_m"), place_area_refs.hidden.label("hidden"), ) @@ -51,11 +43,7 @@ class PlaceContentCRUD: ) async def upsert_content(self, db, values: dict): - """공용 콘텐츠 한 건. (source, external_id) 가 같으면 갱신한다 — 지역과 무관하게 한 벌이다. - - ★ RETURNING 을 쓰지 않는다. 세션 매니저의 execute 는 SELECT 만 받고 - ("DO NOT USE NON-SELECT QUERY IN DBJOB"), 쓰기는 add 로 간다. id 는 뒤이어 조회한다. - """ + """공용 콘텐츠 한 건.""" stmt = pg_insert(area_contents).values(**values) stmt = stmt.on_conflict_do_update( index_elements=[area_contents.source, area_contents.external_id], @@ -88,7 +76,7 @@ class PlaceContentCRUD: ) async def upsert_ref(self, db, place_id, local_content_id, distance_m): - """업장 ↔ 콘텐츠 관계. ★ hidden 은 건드리지 않는다 — 운영자가 숨긴 것을 재수집이 되살리면 안 된다.""" + """업장 ↔ 콘텐츠 관계.""" stmt = pg_insert(place_area_refs).values( place_id=place_id, local_content_id=local_content_id, distance_m=distance_m, deleted=False, ) @@ -99,8 +87,7 @@ class PlaceContentCRUD: return await DB_SESSION_MNG.add(db, stmt) async def soft_delete_missing(self, db, place_id, keep_ids: set): - """이번 응답에 없는 **관계**를 끊는다. 실체(area_contents)는 지우지 않는다 — - 다른 업장이 같은 장소를 가리키고 있을 수 있다.""" + """이번 응답에 없는 **관계**를 끊는다.""" err, rows = await DB_SESSION_MNG.execute( db, select(place_area_refs.local_content_id).where( @@ -109,7 +96,6 @@ class PlaceContentCRUD: ), ) # 단일 컬럼 SELECT 라 행이 스칼라로 온다. - # ★ 단일 컬럼 SELECT 는 세션 매니저가 scalars() 로 편다 — 행이 곧 값이다. gone = [r for r in (rows or []) if r not in keep_ids] if not gone: return err, 0 @@ -121,7 +107,7 @@ class PlaceContentCRUD: ) async def set_hidden(self, db, place_id, local_content_id, hidden: bool): - """이 업장에서만 숨긴다. 실체는 그대로라 다른 업장에는 계속 보인다.""" + """이 업장에서만 숨긴다.""" return await DB_SESSION_MNG.add_with_rowcount( db, update(place_area_refs) diff --git a/solution/backend/crud/place_crud.py b/solution/backend/crud/place_crud.py index f2e5da3..68a8ad5 100644 --- a/solution/backend/crud/place_crud.py +++ b/solution/backend/crud/place_crud.py @@ -11,7 +11,7 @@ from common.logger import LOG from common.utils.gtime import GTime -# 사업장 CRUD. 모든 조회는 owner_user_id(사장님)로 스코프한다 — 남의 가게가 보이면 안 된다. +# 사업장 CRUD. class IPlaceCRUD(ABC): @abstractmethod async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType: @@ -95,16 +95,7 @@ class PlaceCRUD(IPlaceCRUD): return ErrorType.DB_RUN_FAILED, None async def find_by_external(self, cdb: AsyncSession, owner_user_id, source, external_place_id) -> Tuple[ErrorType, list]: - """같은 사장님이 **이미 갖고 있는** 같은 외부 업소. 중복 사업장 판정용이다. - - ★ 소유자까지 함께 본다. 외부 id 만으로 찾으면 다른 사장님의 사업장이 걸리고, - 그걸 이어 쓰면 남의 가게를 넘겨받는 셈이 된다. - ★ **쌓인 것이 많은 순**으로 준다. 부르는 쪽은 맨 앞을 정본으로 삼는다. - 한때 `created_at` 오름차순이었는데, 그러면 위저드가 처음 만들었다가 버린 **빈 행**이 - 정본이 되고 정작 fact·객실·사진이 쌓인 행을 접게 된다(실측 2026-09-10: 정본으로 - fact 2건짜리 행이 뽑혔다). 나이가 아니라 **내용**이 기준이다. - 같은 무게면 먼저 만든 쪽이다 — 그 시점부터 사장님이 알고 있던 주소이기 때문이다. - """ + """같은 사장님이 **이미 갖고 있는** 같은 외부 업소.""" try: def _count(model): return ( @@ -167,7 +158,7 @@ class PlaceCRUD(IPlaceCRUD): return ErrorType.DB_RUN_FAILED, [], 0 async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]: - """회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다. (ErrorType, 적용행수).""" + """회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다.""" try: if not data: return ErrorType.SUCCESS, 0 @@ -182,7 +173,7 @@ class PlaceCRUD(IPlaceCRUD): return ErrorType.DB_RUN_FAILED, 0 async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]: - """사업장을 실제 삭제한다. 회사 스코프 밖의 행은 건드리지 않는다.""" + """사업장을 실제 삭제한다.""" try: query = ( delete(places) @@ -231,7 +222,7 @@ class PlaceCRUD(IPlaceCRUD): return ErrorType.DB_RUN_FAILED async def list_links(self, cdb: AsyncSession, place_id, confirmed_only: bool = False) -> Tuple[ErrorType, list]: - """채널 URL 목록. confirmed_only=True 면 ★ 크롤링 대상(확정된 URL)만.""" + """채널 URL 목록.""" try: conditions = [place_channels.place_id == place_id, place_channels.deleted == False] # noqa: E712 if confirmed_only: @@ -251,11 +242,7 @@ class PlaceCRUD(IPlaceCRUD): return ErrorType.DB_RUN_FAILED async def confirm_link_by_url(self, cdb: AsyncSession, place_id, url, user_id, ts) -> Tuple[ErrorType, int]: - """URL 로 확정한다 — 방금 넣은 링크의 link_id 를 다시 조회하지 않기 위해서다. - - (place_id, url) 은 유니크라 대상이 한 건으로 정해진다. 이미 확정된 건 rowcount 0. - ★ 쓰는 곳은 상호 일치로 찾은 네이버 플레이스 링크 하나뿐이다 — 근거 없이 확정하는 - 경로를 늘리지 않으려고 일부러 좁게 열어 둔다(collect_service.discover_naver_place).""" + """URL 로 확정한다 — 방금 넣은 링크의 link_id 를 다시 조회하지 않기 위해서다.""" try: query = ( update(place_channels) @@ -273,17 +260,7 @@ class PlaceCRUD(IPlaceCRUD): return ErrorType.DB_RUN_FAILED, 0 async def set_link_raw(self, cdb: AsyncSession, place_id, url, raw) -> Tuple[ErrorType, int]: - """수집한 원문을 링크에 박제한다. - - ★ 왜 fact 가 아니라 여기인가 (2026-08-31) - TourAPI 의 `overview` 같은 소개 원문은 **사실 목록이 아니라 글**이다. - 이걸 `intro` fact 로 넣었더니 457자 원문이 그대로 VERIFIED 가 되어 - 사장님 사이트의 '숙소 소개' 자리를 차지했다 — LLM 이 쓴 소개문은 뒤에서 대기 중인데. - `intro` 는 allow_llm=True, 즉 **LLM 의 출력 칸**이라 수집물이 들어가면 안 된다. - - 그렇다고 버리면 소개문·FAQ 의 근거가 사라진다(부대시설·주변 거리 같은 정보가 - 여기에만 있다). 그래서 **발행되지 않는 자리**에 원문을 남기고, - 생성 시점에만 근거로 넘긴다(services/copy_service.py).""" + """수집한 원문을 링크에 박제한다.""" try: query = ( update(place_channels) diff --git a/solution/backend/crud/place_itinerary_crud.py b/solution/backend/crud/place_itinerary_crud.py index d6ea49a..8779c17 100644 --- a/solution/backend/crud/place_itinerary_crud.py +++ b/solution/backend/crud/place_itinerary_crud.py @@ -8,8 +8,7 @@ from common.utils.gtime import GTime class PlaceItineraryCRUD: async def list_by_place(self, db, place_id): - """업장의 일정 전부(기간별 한 행). 정렬은 기간 이름 순이 아니라 저장 순이 아니다 — - 화면 탭 순서는 읽는 쪽(`snapshot._local_contents`)이 DURATIONS 순으로 정한다.""" + """업장의 일정 전부(기간별 한 행).""" return await DB_SESSION_MNG.execute( db, select(place_itineraries).where( @@ -19,13 +18,7 @@ class PlaceItineraryCRUD: ) async def upsert(self, db, values: dict): - """업장 × 기간 한 행의 삽입/갱신. - - ★ `uq_place_itineraries`(place_id, duration — deleted = false)에 태운다. - ON CONFLICT 술어는 인덱스 술어와 **글자 그대로** 같아야 한다. 아니면 포스트그레스가 - "no unique or exclusion constraint matching" 으로 거절한다 — 표도 컬럼도 멀쩡해서 - 눈으로는 원인이 안 보인다(`local_content_crud.upsert_kind` 주석과 같은 함정). - """ + """업장 × 기간 한 행의 삽입/갱신.""" stmt = pg_insert(place_itineraries).values(**values) stmt = stmt.on_conflict_do_update( index_elements=[place_itineraries.place_id, place_itineraries.duration], diff --git a/solution/backend/crud/post_crud.py b/solution/backend/crud/post_crud.py index 10636a7..e73d214 100644 --- a/solution/backend/crud/post_crud.py +++ b/solution/backend/crud/post_crud.py @@ -1,4 +1,4 @@ -"""place_posts 접근. 미니 블로그 글의 일생을 이 표 하나로 본다(docs/MINI_BLOG.md).""" +"""place_posts 접근.""" from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -9,8 +9,7 @@ from common.utils.gtime import GTime class PostCRUD: async def add_many(self, cdb: AsyncSession, rows: list[dict]) -> ErrorType: - """생성분 적재. 같은 주제가 이미 있거나 같은 날짜를 이미 썼으면 그 건만 건너뛴다 — - 회차 전체를 버리지 않는다(topic_key 유니크와 scheduled_date 유니크가 각각 막는다).""" + """생성분 적재.""" for row in rows: try: cdb.add(place_posts(**row)) @@ -20,12 +19,7 @@ class PostCRUD: return ErrorType.SUCCESS async def add_one(self, cdb: AsyncSession, row: dict) -> dict | None: - """개별 생성(빈 날짜 하나 채우기) 전용 — `add_many` 와 달리 성공하면 삽입된 값 - (post_id 포함)을 그대로 돌려준다. 사장님이 콕 집은 날짜라 "이미 있어서 조용히 - 건너뜀" 으로 끝내면 안 된다. - - ★ ORM 객체를 그대로 돌려주지 않는다 — 호출측이 commit 뒤에 속성을 읽으면 - detached 라 깨진다. flush() 직후(아직 세션이 살아있을 때) 값만 뽑아 dict 로 준다.""" + """개별 생성(빈 날짜 하나 채우기) 전용 — `add_many` 와 달리 성공하면 삽입된 값 (post_id 포함)을 그대로 돌려준다.""" try: obj = place_posts(**row) cdb.add(obj) @@ -40,7 +34,7 @@ class PostCRUD: return None async def max_scheduled_date(self, cdb: AsyncSession, place_id): - """이 업장이 이미 배정한 가장 늦은 날짜. 없으면 None(오늘부터 채운다).""" + """이 업장이 이미 배정한 가장 늦은 날짜.""" result = await cdb.execute( select(func.max(place_posts.scheduled_date)) .where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712 @@ -48,8 +42,7 @@ class PostCRUD: return result.scalar() async def due_for_mail(self, cdb: AsyncSession, status: int, today, limit: int): - """배정일이 오늘까지 온 것 중 업장당 1건만, 이른 날짜순. 업장 하나가 밀려 있어도 - 하루 한 통만 나간다(규모가 작아 DISTINCT ON 결과를 파이썬에서 정렬해도 무리 없다).""" + """배정일이 오늘까지 온 것 중 업장당 1건만, 이른 날짜순.""" result = await cdb.execute( select(place_posts) .where( @@ -63,8 +56,7 @@ class PostCRUD: return ErrorType.SUCCESS, rows[:limit] async def next_due_for_mail(self, cdb: AsyncSession, place_id, status: int, today): - """이 업장의 오늘 몫 글 하나 — 사장님이 '승인 알림보내기'를 눌렀을 때 쓴다. 없으면 None. - due_for_mail 과 같은 조건(배정일이 오늘까지 온 것)을 이 업장 하나로 좁힌 것뿐이다.""" + """이 업장의 오늘 몫 글 하나 — 사장님이 '승인 알림보내기'를 눌렀을 때 쓴다.""" result = await cdb.execute( select(place_posts) .where( @@ -99,10 +91,7 @@ class PostCRUD: return result.scalars().first() async def generation_batches(self, cdb: AsyncSession, place_id, limit: int = 30): - """생성 이력 — 한 번의 생성 스윕(같은 트랜잭션의 created_at)을 한 회차로 묶는다. - 새 컬럼 없이 기존 created_at 만으로 센다 — add_many 가 한 트랜잭션 안에서 넣으므로 - 같은 회차의 created_at 은 DB now() 기준으로 전부 같다. 모델명은 같은 회차 안에서도 - 전부 같아야 정상이지만(한 스윕 = 한 모델), `max()` 로 대표값 하나만 뽑는다.""" + """생성 이력 — 한 번의 생성 스윕(같은 트랜잭션의 created_at)을 한 회차로 묶는다.""" result = await cdb.execute( select( place_posts.created_at, @@ -124,7 +113,7 @@ class PostCRUD: return [row[0] for row in result.all()] async def published(self, cdb: AsyncSession, place_id, limit: int = 200): - """화면에 나갈 글. 최신순이고, 게재된 것만.""" + """화면에 나갈 글.""" result = await cdb.execute( select(place_posts) .where( @@ -153,8 +142,7 @@ class PostCRUD: ) return ErrorType.SUCCESS - # 메일(SENT)뿐 아니라 아직 안 보낸 재고(REVIEWED)도 고칠·승인할 수 있다 — 사장님이 - # 빌더 앱에 로그인해 이번 달 글 목록에서 직접 고를 때는 메일이 먼저 나가 있을 필요가 없다. + # 메일(SENT)뿐 아니라 아직 안 보낸 재고(REVIEWED)도 고칠·승인할 수 있다 — 사장님이 빌더 앱에 로그인해 이번 달 글 목록에서 직접 고를 때는 메일이 먼저 나가 있을 필요가 없다. _EDITABLE = (PostStatus.SENT.value, PostStatus.REVIEWED.value) async def update_body(self, cdb: AsyncSession, post_id, body: str) -> ErrorType: @@ -167,7 +155,7 @@ class PostCRUD: return ErrorType.SUCCESS async def approve(self, cdb: AsyncSession, post_id) -> ErrorType: - """★ 토큰을 지우면서 승인한다 — 같은 링크를 두 번 눌러도 두 번 게재되지 않는다.""" + """토큰을 지우면서 승인한다 — 같은 링크를 두 번 눌러도 두 번 게재되지 않는다.""" await cdb.execute( update(place_posts) .where(place_posts.post_id == post_id, place_posts.status.in_(self._EDITABLE)) @@ -198,8 +186,7 @@ class PostCRUD: return ErrorType.SUCCESS async def delete(self, cdb: AsyncSession, post_id) -> ErrorType: - """소프트 삭제. (place_id, topic_key)·(place_id, scheduled_date) 유니크가 deleted=false - 행만 보므로, 지우면 그 날짜·주제가 바로 재생성 대상으로 풀린다.""" + """소프트 삭제.""" await cdb.execute( update(place_posts) .where(place_posts.post_id == post_id) diff --git a/solution/backend/crud/site_crud.py b/solution/backend/crud/site_crud.py index c2fe5fe..2b082ba 100644 --- a/solution/backend/crud/site_crud.py +++ b/solution/backend/crud/site_crud.py @@ -12,12 +12,7 @@ from common.utils.gtime import GTime def _primary_photo_subquery(): - """place_photos 에서 대표 사진 한 장의 url 만 고르는 상관 서브쿼리(사업장당 1행). - - site_payload.primary_media 와 같은 규칙 — 객실·메뉴 사진(unit_id 있음)이 아닌 첫 장, - sort_order 순. `.correlate(places)` 라서 바깥 쿼리가 `places` 를 셀렉트에 들고 있어야 한다. - sites.thumbnail_url 이 비어 있을 때(Azure 썸네일 저장소 미설정 등) 서비스 계층이 이걸로 - 대신 채운다 — 여기서는 후보만 얹고, 언제 쓸지는 서비스 계층 몫이다.""" + """place_photos 에서 대표 사진 한 장의 url 만 고르는 상관 서브쿼리(사업장당 1행).""" return ( select(place_photos.url) .where( @@ -33,7 +28,7 @@ def _primary_photo_subquery(): ) -# 사이트/버전/발행로그 CRUD. 항상 place_id 또는 site_id 로 스코프한다. +# 사이트/버전/발행로그 CRUD. class ISiteCRUD(ABC): @abstractmethod async def get_site_by_place(self, cdb: AsyncSession, place_id) -> Tuple[ErrorType, sites]: @@ -50,8 +45,7 @@ class ISiteCRUD(ABC): @abstractmethod async def list_all_sites(self, cdb: AsyncSession, skip, limit) -> Tuple[ErrorType, list, int]: - """전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용. - (ErrorType, [(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name)], 총건수).""" + """전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용.""" pass @abstractmethod @@ -113,10 +107,7 @@ class SiteCRUD(ISiteCRUD): return ErrorType.DB_RUN_FAILED, None async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]: - """주소(도메인 라벨)의 주인을 찾는다. 없으면 (SUCCESS, None). - - uq_sites_domain(deleted=false AND domain IS NOT NULL)과 같은 조건으로 본다 — - 인덱스가 막는 것과 조회가 막는 것이 다르면 "확인은 통과, 저장은 실패"가 난다.""" + """주소(도메인 라벨)의 주인을 찾는다.""" try: query = select(sites).where(sites.domain == domain, sites.deleted == False).limit(1) # noqa: E712 err_type, rows = await DB_SESSION_MNG.execute(cdb, query) @@ -128,16 +119,7 @@ class SiteCRUD(ISiteCRUD): return ErrorType.DB_RUN_FAILED, None async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]: - """사장님의 사업장 + 사이트 + 마지막 빌드 시각 + 빌더 대표 사진. - (ErrorType, [(place, site, built_at, primary_photo_url)], 총건수). - - 따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장 - (위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다. - - ★ primary_photo_url 은 site_payload.primary_media 와 같은 규칙(사진 중 객실·메뉴가 아닌 - 첫 장, sort_order 순)으로 고른 place_photos.url 이다 — sites.thumbnail_url 이 비어 있을 때 - (Azure 썸네일 저장소 미설정 등으로 재호스팅에 실패한 경우) 서비스 계층이 이걸로 대신 채운다. - 여기서는 후보만 얹고, "발행한 적 있는 줄에만 쓴다"는 판단은 서비스 계층 몫이다.""" + """사장님의 사업장 + 사이트 + 마지막 빌드 시각 + 빌더 대표 사진.""" try: where = and_(places.deleted == False, places.owner_user_id == owner_user_id) # noqa: E712 @@ -164,8 +146,7 @@ class SiteCRUD(ISiteCRUD): return ErrorType.DB_RUN_FAILED, [], 0 async def list_all_sites(self, cdb: AsyncSession, skip: int, limit: int) -> Tuple[ErrorType, list, int]: - """list_owner_sites 와 같은 조인이되 owner_user_id 필터가 없다 — 소유자 계정 정보를 같이 얹는다. - (ErrorType, [(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name)], 총건수).""" + """list_owner_sites 와 같은 조인이되 owner_user_id 필터가 없다 — 소유자 계정 정보를 같이 얹는다.""" try: where = places.deleted == False # noqa: E712 @@ -193,7 +174,7 @@ class SiteCRUD(ISiteCRUD): return ErrorType.DB_RUN_FAILED, [], 0 async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: - """후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다.""" + """후보 주소들 중 이미 쓰이는 것만 추린다.""" try: if not domains: return ErrorType.SUCCESS, set() @@ -216,7 +197,7 @@ class SiteCRUD(ISiteCRUD): return ErrorType.DB_RUN_FAILED async def next_version_no(self, cdb: AsyncSession, site_id) -> Tuple[ErrorType, int]: - """다음 버전 번호. 1부터 시작한다.""" + """다음 버전 번호.""" try: query = select(func.max(site_versions.version)).where( site_versions.site_id == site_id, site_versions.deleted == False # noqa: E712 @@ -259,10 +240,7 @@ class SiteCRUD(ISiteCRUD): return ErrorType.DB_RUN_FAILED, None async def get_version_by_number(self, cdb: AsyncSession, site_id, version: int) -> Tuple[ErrorType, site_versions]: - """롤백 대상 조회 — site_versions.version(사람이 보는 번호) 로 찾는다. - - ★ site_version_id(uuid) 가 아니다. 화면·API 는 버전 번호로 고르는 게 자연스럽고, - 그 번호가 곧 out/versions/// 디렉토리 이름이다(prerender.ts).""" + """롤백 대상 조회 — site_versions.version(사람이 보는 번호) 로 찾는다.""" try: query = ( select(site_versions) @@ -330,15 +308,7 @@ class SiteCRUD(ISiteCRUD): return ErrorType.DB_RUN_FAILED, [] async def list_published(self, cdb: AsyncSession, limit: int = 12) -> Tuple[ErrorType, list]: - """발행된 사이트 + 그 사업장 + 빌더 대표 사진을 최신순으로. 랜딩 쇼케이스가 읽는 목록이다. - (ErrorType, [(site, place, primary_photo_url)]). - - ★ 회사 스코프가 없는 **유일한** 사이트 조회다(비로그인 API 가 쓴다). 그래서 행을 통째로 - 돌려주고, 무엇이 밖으로 나갈지는 services/showcase_service 한 곳에서만 고른다 — - 여기서 열을 골라 두면 나중에 필드를 늘릴 때 공개 여부를 판단할 자리가 사라진다. - - ★ primary_photo_url 은 list_owner_sites 와 같은 서브쿼리(_primary_photo_subquery) — - sites.thumbnail_url 이 비어 있을 때 showcase_service 가 이걸로 대신 채운다.""" + """발행된 사이트 + 그 사업장 + 빌더 대표 사진을 최신순으로.""" try: query = ( select(sites, places, _primary_photo_subquery()) diff --git a/solution/backend/crud/site_section_crud.py b/solution/backend/crud/site_section_crud.py index 997d5e7..4aa326c 100644 --- a/solution/backend/crud/site_section_crud.py +++ b/solution/backend/crud/site_section_crud.py @@ -1,15 +1,4 @@ -"""site_sections — **개인화 데이터**의 단일 자리. - -★ 규칙(2026-09-09) - area_* = 공용. 지역 단위, 여러 사이트가 나눠 쓴다. 렌더러 모양 그대로. - site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부 — 거리·숨김·순서·사장님 편집. - -★ 이 표는 이미 있었는데 **아무도 읽지 않았다**(실측 2026-09-09: 10행이 마이그레이션 0003 으로 - 들어간 뒤 방치, 발행 파이프라인은 `sites.theme.sections[].data` 만 봤다). 그 자리를 정본으로 - 세우면서 CRUD 를 붙인다. - -★ 유일성은 `(site_id, section_id)` 다 — 섹션당 한 행. 그래서 upsert 가 갱신을 겸한다. -""" +"""site_sections — **개인화 데이터**의 단일 자리.""" from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert as pg_insert @@ -29,12 +18,7 @@ class SiteSectionCRUD: ) async def upsert(self, db, values: dict): - """섹션 하나의 개인화 값을 넣거나 갱신한다. - - ★ `uq_site_contents_section (site_id, section_id) WHERE deleted = false` 에 태운다. - ★ source_type 은 갱신하지 않는다 — 사장님이 손으로 고친 섹션(OWNER)을 수집이 - API 값으로 되돌리면, 고쳐 둔 것이 다음 수집에 조용히 사라진다. - """ + """섹션 하나의 개인화 값을 넣거나 갱신한다.""" stmt = pg_insert(site_sections).values(**values) return await DB_SESSION_MNG.add( db, diff --git a/solution/backend/crud/social_crud.py b/solution/backend/crud/social_crud.py index fd8605b..7c0fe74 100644 --- a/solution/backend/crud/social_crud.py +++ b/solution/backend/crud/social_crud.py @@ -52,7 +52,7 @@ async def sweep(): text("""UPDATE place_social_posts SET status='EXPIRED', updated_at=now() WHERE deleted=false AND status='PENDING_APPROVAL' AND approval_expires_at<=now()""") ) - # POSTING은 외부가 받았을 수 있다. 시간을 근거로 APPROVED로 돌리지 않는다. + # POSTING은 외부가 받았을 수 있다. await s.execute( text("""UPDATE place_social_posts SET status='UNKNOWN', last_error='POST_RESULT_UNKNOWN', updated_at=now() diff --git a/solution/backend/crud/song_crud.py b/solution/backend/crud/song_crud.py index 1b4400c..c24db12 100644 --- a/solution/backend/crud/song_crud.py +++ b/solution/backend/crud/song_crud.py @@ -7,7 +7,7 @@ from common.utils.gtime import GTime class SongCRUD: - """place_songs 접근. 발행본이 읽는 것은 `latest_ready` 하나뿐이다.""" + """place_songs 접근.""" async def insert(self, db, row): return await DB_SESSION_MNG.insert(db, row) @@ -21,11 +21,7 @@ class SongCRUD: ) async def latest_ready(self, db, place_id): - """이 업장의 **가장 최근에 완성된** 곡 하나. - - ★ READY 만 본다. 발행마다 새 곡을 만들므로 GENERATING 행이 함께 있을 수 있는데, - 그걸 집으면 아직 없는 파일을 사이트가 가리킨다. 실패(FAILED)도 마찬가지다 — - 새 곡이 실패하면 사이트는 **직전 곡을 그대로 유지**한다(빈 플레이어보다 낫다).""" + """이 업장의 **가장 최근에 완성된** 곡 하나.""" return await DB_SESSION_MNG.execute( db, select(place_songs) diff --git a/solution/backend/crud/user_crud.py b/solution/backend/crud/user_crud.py index be53a47..c5a0084 100644 --- a/solution/backend/crud/user_crud.py +++ b/solution/backend/crud/user_crud.py @@ -23,8 +23,6 @@ def _place_count_subquery(): # CRUD 는 인터페이스(I*) 와 구현(*) 으로 분리한다. -# - service 는 인터페이스 타입에 의존하고 Depends 로 구현을 주입받는다 (테스트/교체 용이). -# - 모든 메서드는 (session, ...) 을 받는다. session 은 람다 호출 시 매니저가 넘겨준다. class IUserCRUD(ABC): @abstractmethod async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]: @@ -62,7 +60,7 @@ class IUserCRUD(ABC): async def list_users( self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None ) -> Tuple[ErrorType, list, int]: - """내부 운영(DEVELOPER) 전용 전체 계정 목록. (ErrorType, [(user, place_count)], 총건수).""" + """내부 운영(DEVELOPER) 전용 전체 계정 목록.""" pass @@ -81,8 +79,7 @@ class UserCRUD(IUserCRUD): return ErrorType.DB_RUN_FAILED, None async def get_user_by_provider_uid(self, cdb: AsyncSession, provider: int, provider_uid: str) -> Tuple[ErrorType, users]: - """소셜 계정 조회 키는 provider_uid(구글 sub) 다 — 이메일이 아니다. - 구글은 이메일 변경을 허용하고, 이메일로 찾으면 그때 같은 사람에게 계정이 하나 더 생긴다.""" + """소셜 계정 조회 키는 provider_uid(구글 sub) 다 — 이메일이 아니다.""" try: query = ( select(users) @@ -100,8 +97,7 @@ class UserCRUD(IUserCRUD): return ErrorType.DB_RUN_FAILED, None async def get_user_by_email(self, cdb: AsyncSession, email: str) -> Tuple[ErrorType, users]: - """이메일로 1건. "이미 다른 수단으로 가입돼 있다" 판정에만 쓴다. - 이메일에는 유니크 제약이 없다(옛 데이터) — 여러 건이면 가장 먼저 만들어진 것을 본다.""" + """이메일로 1건.""" try: query = ( select(users) @@ -173,8 +169,7 @@ class UserCRUD(IUserCRUD): async def list_users( self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None ) -> Tuple[ErrorType, list, int]: - """role 이 roles 안에 있는 계정만 본다 — 개발자 계정은 호출측이 roles 에서 뺀다 - (UserRole 주석: "개발자 계정은 고객사에 존재를 노출하지 않는다" 원칙을 내부 화면에서도 지킨다).""" + """role 이 roles 안에 있는 계정만 본다 — 개발자 계정은 호출측이 roles 에서 뺀다 (UserRole 주석: "개발자 계정은 고객사에 존재를 노출하지 않는다" 원칙을 내부 화면에서도 지킨다).""" try: where = and_(users.deleted == False, users.role.in_(roles)) # noqa: E712 if search: diff --git a/solution/backend/router/router.py b/solution/backend/router/router.py index bb3a81c..472ef5b 100644 --- a/solution/backend/router/router.py +++ b/solution/backend/router/router.py @@ -48,11 +48,6 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Web4Ai API", lifespan=lifespan) # CORS — 관리자 프론트(client_url) + 랜딩(landing_url, 미설정이면 제외). -# -# ★ client_url 은 쉼표로 여러 오리진을 받는다. 로컬 개발에서 vite 는 3000 이 막혀 있으면 -# 3001, 3002… 로 옮겨 뜨는데(--port 는 희망값이지 고정이 아니다), 그때마다 서버 설정을 -# 고치게 하면 원인이 CORS 라는 걸 알아내는 데만 반나절이 든다. 개발 포트 몇 개를 한 줄에 적어 둔다. -# 운영은 실제 도메인 하나만 적으면 된다. def _origins(*values: str) -> list[str]: seen: list[str] = [] for value in values: @@ -98,14 +93,7 @@ async def healthz(): @app.get(path="/readyz", responses={404: {"description": "Not found"}, 503: {"description": "Not ready"}}) async def readyz(response: Response): - """★ healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200), - 이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다). - - ★ 왜 필요한가: 이 서버·DB 가 통째로 죽으면 우리 알림(alert_service, Teams webhook)도 - 같이 죽는다 — 자기 장애를 자기가 알릴 수 없다. 외부 감시(uptime 모니터 등)가 이 경로를 - 주기적으로 찔러야 전체 다운을 잡는다. DEPLOY.md·SERVERS.md 에 붙일 절차: 이 경로가 - 2xx 가 아니면(또는 응답이 없으면) 그 감시 서비스 **자신의** 채널로 알린다 — Teams - webhook 이 죽은 원인 그 자체일 수 있으므로 같은 경로로 알리면 안 된다.""" + """healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200), 이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다).""" try: async def _ping(s): await s.execute(text("SELECT 1")) @@ -119,19 +107,18 @@ async def readyz(response: Response): return {"ok": False, "db": "down"} -# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.. 를 import 후 include. +# 각 도메인 라우터를 등록한다. app.include_router(router.v1.auth.account.router) app.include_router(router.v1.place.place.router) app.include_router(router.v1.fact.fact.router) app.include_router(router.v1.faq.faq.router) app.include_router(router.v1.media.media.router) -# ★ 인증 없는 공개 중계. 발행본(정적 페이지)이 캔버스에 사진을 그릴 때 부른다 — -# 남의 CDN 이 CORS 를 안 줘서 캔버스가 오염되는 것을 피하는 유일한 길이다(relay.py). +# 인증 없는 공개 중계. app.include_router(router.v1.media.relay.router) app.include_router(router.v1.job.job.router) app.include_router(router.v1.site.site.router) app.include_router(router.v1.site.site.my_router) -# ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다. +# 인증 없는 공개 목록. app.include_router(router.v1.site.showcase.router) app.include_router(router.v1.site.booking_request.router) app.include_router(router.v1.site.post.router) diff --git a/solution/backend/router/v1/agent/chat.py b/solution/backend/router/v1/agent/chat.py index 8d15618..9cdeca0 100644 --- a/solution/backend/router/v1/agent/chat.py +++ b/solution/backend/router/v1/agent/chat.py @@ -1,8 +1,4 @@ -"""사장님 에이전트 대화 — 빌더 화면의 입구. - -★ 카카오톡 웹훅이 생겨도 이 파일은 안 바뀐다. 런타임이 채널을 모르고, 웹훅은 그저 - 같은 `runtime.chat()` 을 부르는 두 번째 입구가 된다(docs/AGENT.md). -""" +"""사장님 에이전트 대화 — 빌더 화면의 입구.""" from uuid import UUID @@ -27,10 +23,7 @@ _STATUS = { class Confirm(BaseModel): - """직전 답의 확인 버튼이 그대로 돌려보내는 값. - - ★ 서버는 이 값을 믿지 않는다 — 도구 이름은 레지스트리에서 다시 찾고, 인자는 도구가 - 다시 검증한다. 확인 절차가 오히려 검증을 건너뛰는 구멍이 되면 안 된다.""" + """직전 답의 확인 버튼이 그대로 돌려보내는 값.""" tool: str = Field(min_length=1, max_length=40) args: dict = {} @@ -43,7 +36,7 @@ class Req_Chat(BaseModel): @router.get("/status") async def status(response: Response, user: UserInfo = Depends(IsValidAccessToken)): - """대화창을 열 수 있는지. 키가 없으면 화면은 자리를 두고 입력만 죽인다.""" + """대화창을 열 수 있는지.""" response.headers["Cache-Control"] = "no-store" return {"enabled": runtime.is_configured()} diff --git a/solution/backend/router/v1/agent/kakao.py b/solution/backend/router/v1/agent/kakao.py index 727cd2a..5a0be99 100644 --- a/solution/backend/router/v1/agent/kakao.py +++ b/solution/backend/router/v1/agent/kakao.py @@ -1,9 +1,4 @@ -"""카카오톡 채널 연결 — 빌더에서 코드를 받아 채널에 한 번 입력한다. - -★ 소비(redeem) 엔드포인트는 여기 없다. 코드를 소비하는 쪽은 채널 웹훅이고, 그 웹훅은 - 자체 서명 검증을 갖춘 뒤에야 열 수 있다. 검증 없는 공개 소비 경로를 먼저 만들면 - 누구나 코드를 대입해 남의 계정에 자기 카톡을 붙일 수 있다 — 이 표가 막으려던 바로 그 일이다. -""" +"""카카오톡 채널 연결 — 빌더에서 코드를 받아 채널에 한 번 입력한다.""" from uuid import UUID @@ -26,14 +21,14 @@ def private_response(response: Response): @router.get("/link") async def link_state(response: Response, user: UserInfo = Depends(IsValidAccessToken)): - """연결 상태. 사업장을 고르지 않아도 답할 수 있어야 하는 값이다 — 계정은 사람에 붙는다.""" + """연결 상태.""" private_response(response) return await service.state(UUID(user.user_id)) @router.post("/link/code") async def issue_code(response: Response, user: UserInfo = Depends(IsValidAccessToken)): - """일회용 코드를 낸다. ★ 평문 코드는 이 응답에서 한 번만 나가고 DB 에는 sha256 만 남는다.""" + """일회용 코드를 낸다.""" private_response(response) try: return await service.issue_code(UUID(user.user_id)) diff --git a/solution/backend/router/v1/agent/kakao_bot.py b/solution/backend/router/v1/agent/kakao_bot.py index 458c0a2..81415b6 100644 --- a/solution/backend/router/v1/agent/kakao_bot.py +++ b/solution/backend/router/v1/agent/kakao_bot.py @@ -1,23 +1,4 @@ -"""카카오톡 채널 웹훅(오픈빌더 스킬 서버) — 카카오 형식은 **이 파일 밖으로 나가지 않는다**. - -`version: "2.0"` · `simpleText` · `quickReplies` 같은 모양이 서비스 계층에 새면, 다른 채널을 -붙일 때 그걸 전부 걷어내야 한다. 알림톡 어댑터에 건 것과 같은 규칙이다. - -★★ **오픈빌더는 서명을 주지 않는다.** URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, - `userRequest.user.id` 를 아무 값이나 넣으면 **그 사장님 행세를 한다** — 신원 연결 - (`owner_kakao_links`)이 통째로 무의미해진다. 그래서 공유 시크릿을 우리가 직접 댄다. - 시크릿이 없으면 **엔드포인트 자체를 띄우지 않는다(404)** — 반쯤 열린 상태를 만들지 않는 것은 - Threads 연결과 같은 규칙이다. - -★ 5초 벽: 오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 사장님에게는 - **말없이 실패하는 봇**이 된다. - → 오픈빌더 스킬 설정에서 **콜백 사용**을 켜면 요청에 `userRequest.callbackUrl` 이 실려 온다. - 그때는 `{"useCallback": true}` 로 **즉답**하고, 답을 다 만든 뒤 그 주소로 따로 보낸다. - 콜백 주소는 **1분 · 1회**만 유효하다. - → 콜백이 꺼져 있으면 예전처럼 동기로 답하되 `DEADLINE_SEC` 로 끊는다. 실측(2026-09-22): - 필드 43개 + fact 수십 개가 실린 실제 프롬프트는 4초를 넘겼다 — 개발 중 재본 - 1.3~2.4초는 항목 두 개짜리 장난감 프롬프트였다. -""" +"""카카오톡 채널 웹훅(오픈빌더 스킬 서버) — 카카오 형식은 **이 파일 밖으로 나가지 않는다**.""" import asyncio import hmac @@ -31,10 +12,9 @@ from services.agent import channel router = APIRouter(prefix="/v1/agent/kakao", tags=["Agent"]) -# 콜백이 꺼져 있을 때만 쓰는 상한. 카카오가 5초에 끊으므로 그보다 살짝 앞에서 우리가 끊는다 — -# 침묵보다 "잠시 뒤 다시" 가 낫다. +# 콜백이 꺼져 있을 때만 쓰는 상한. DEADLINE_SEC = 4.5 -# 콜백이 켜져 있을 때의 상한. 콜백 주소가 1분간 유효하므로 그 안에서 넉넉히 잡는다. +# 콜백이 켜져 있을 때의 상한. CALLBACK_DEADLINE_SEC = 45.0 _TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요." @@ -43,11 +23,10 @@ _WAIT_TEXT = "확인하고 있어요. 잠시만 기다려 주세요." def _reply(text: str, quick_replies=None) -> dict: - """오픈빌더 스킬 응답(SkillResponse). ★ 카카오 형식을 아는 유일한 함수다.""" + """오픈빌더 스킬 응답(SkillResponse).""" payload: dict = {"outputs": [{"simpleText": {"text": text}}]} if quick_replies: - # 바로가기는 최대 10개. 누르면 그 라벨이 **다음 발화로 그대로 들어온다** — - # channel.py 의 _YES/_NO 가 같은 문자열을 알고 있어야 먹는다. + # 바로가기는 최대 10개. payload["quickReplies"] = [ {"label": label, "action": "message", "messageText": label} for label in quick_replies[:10] ] @@ -57,14 +36,14 @@ def _reply(text: str, quick_replies=None) -> dict: def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict) -> None: expected = config.webhook_secret() if not expected: - # 설정이 없으면 이 기능은 존재하지 않는다. 401 로 답하면 엔드포인트의 존재를 알린다. + # 설정이 없으면 이 기능은 존재하지 않는다. raise HTTPException(404) given = header_secret or secret_in_path or "" if not hmac.compare_digest(given, expected): LOG.w("[agent/kakao] 웹훅 시크릿 불일치 — 거절") raise HTTPException(404) - # 한 겹 더. 시크릿이 아니라 오발송을 거르는 용도라 비워 두면 검사하지 않는다. + # 한 겹 더. bot_id = config.get("KAKAO_BOT_ID") if bot_id and (body.get("bot") or {}).get("id") != bot_id: LOG.w("[agent/kakao] 다른 봇의 요청 — 거절") @@ -72,7 +51,7 @@ def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict async def _answer(utterance: str, speaker: str, deadline: float) -> dict: - """대화 한 턴을 SkillResponse 로. 어떤 실패도 문구로 바꾼다.""" + """대화 한 턴을 SkillResponse 로.""" try: answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=deadline) except asyncio.TimeoutError: @@ -85,10 +64,7 @@ async def _answer(utterance: str, speaker: str, deadline: float) -> dict: async def _push(callback_url: str, utterance: str, speaker: str) -> None: - """답을 다 만든 뒤 콜백 주소로 보낸다. - - ★ 주소는 1분 · 1회만 유효하다. 실패해도 재시도하지 않는다 — 두 번째 POST 는 어차피 - 거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다.""" + """답을 다 만든 뒤 콜백 주소로 보낸다.""" payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC) try: async with httpx.AsyncClient(timeout=10.0) as client: @@ -104,13 +80,12 @@ async def _handle(body: dict, tasks: BackgroundTasks) -> dict: utterance = request.get("utterance") or "" speaker = (request.get("user") or {}).get("id") or "" if not speaker: - # 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다. + # 발화자를 모르면 누구의 가게인지도 모른다. return _reply("사용자를 확인하지 못했어요.") - # ★ 콜백이 켜져 있으면 5초 벽을 넘을 수 있다. 즉답하고 뒤에서 마저 만든다. + # 콜백이 켜져 있으면 5초 벽을 넘을 수 있다. callback_url = request.get("callbackUrl") - # ★ "콜백을 켰는데 왜 안 되나" 를 눈으로 가릴 수 있게 남긴다. 어느 블록이 도는지도 같이 — - # 스킬이 폴백이 아닌 다른 블록에 붙어 있으면 콜백 설정이 그 블록에 없어 조용히 동기로 돈다. + # "콜백을 켰는데 왜 안 되나" 를 눈으로 가릴 수 있게 남긴다. LOG.i(f"[agent/kakao] 요청 — callbackUrl={'있음' if callback_url else '없음'} " f"block={(request.get('block') or {}).get('name')!r}") if callback_url: @@ -126,7 +101,7 @@ async def webhook( tasks: BackgroundTasks, x_agent_secret: str | None = Header(default=None), ): - """헤더로 시크릿을 받는 쪽. 스킬 설정에서 커스텀 헤더를 넣을 수 있으면 이쪽을 쓴다.""" + """헤더로 시크릿을 받는 쪽.""" body = await request.json() _authorize(None, x_agent_secret, body) return await _handle(body, tasks) @@ -139,9 +114,7 @@ async def webhook_with_path_secret( tasks: BackgroundTasks, x_agent_secret: str | None = Header(default=None), ): - """헤더를 못 넣는 경우의 대안. - - ★ 최후 수단이다 — 경로는 액세스 로그·앞단 프록시에 남는다. 헤더를 쓸 수 있으면 위를 쓴다.""" + """헤더를 못 넣는 경우의 대안.""" body = await request.json() _authorize(secret, x_agent_secret, body) return await _handle(body, tasks) diff --git a/solution/backend/router/v1/auth/account.py b/solution/backend/router/v1/auth/account.py index bc8108a..9b23b42 100644 --- a/solution/backend/router/v1/auth/account.py +++ b/solution/backend/router/v1/auth/account.py @@ -8,7 +8,7 @@ from .protocol import Req_GoogleLogin, Req_Login, Req_Signup, Req_UpdateMe, Res_ security = HTTPBearer() -# 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당. +# 라우터(MVC 의 컨트롤러). router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"description": "Not found"}}) diff --git a/solution/backend/router/v1/auth/protocol.py b/solution/backend/router/v1/auth/protocol.py index 425e9cf..f52afd5 100644 --- a/solution/backend/router/v1/auth/protocol.py +++ b/solution/backend/router/v1/auth/protocol.py @@ -15,11 +15,7 @@ class Req_Login(AuthProtocol): class Req_Signup(AuthProtocol): - """id/pw 가입. 가입 = 계정 1개다. - - ★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면 - 한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라 - **중복 판정용** 이다.""" + """id/pw 가입.""" id: str = "" password: str = "" @@ -28,10 +24,7 @@ class Req_Signup(AuthProtocol): class Req_GoogleLogin(AuthProtocol): - """구글 로그인. 프론트(GIS)가 받은 ID 토큰을 그대로 넘긴다. - - 필드 이름이 `credential` 인 이유는 GIS 콜백이 주는 이름 그대로이기 때문이다 — - `access_token`/`id_token` 으로 바꿔 부르면 우리 토큰과 헷갈린다.""" + """구글 로그인.""" credential: str = "" @@ -43,11 +36,11 @@ class Res_Login(Res_WebPacketProtocol): class Req_UpdateMe(AuthProtocol): - # 본인 정보 수정. role·id 는 받지 않는다(자기 권한 변경 불가). + # 본인 정보 수정. name: Optional[str] = None email: Optional[str] = None contact_number: Optional[str] = None - password: Optional[str] = None # 비밀번호 변경(옵션). 비우면 유지 + password: Optional[str] = None # 비밀번호 변경(옵션). class Res_RefreshToken(Res_WebPacketProtocol): @@ -62,6 +55,5 @@ class Res_Me(Res_WebPacketProtocol): email: Optional[str] = None contact_number: Optional[str] = None role: UserRole = UserRole.USER - # 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다) - # 내 정보 화면이 붙을 때 이 값으로 갈라야 한다. + # 이 계정이 무엇으로 로그인하는가. provider: AuthProvider = AuthProvider.LOCAL diff --git a/solution/backend/router/v1/fact/fact.py b/solution/backend/router/v1/fact/fact.py index 9ce6ec7..772194a 100644 --- a/solution/backend/router/v1/fact/fact.py +++ b/solution/backend/router/v1/fact/fact.py @@ -11,7 +11,7 @@ from .protocol import ( Res_CategorySchema, Res_ExtractFacts, Res_Fact, Res_FactList, ) -# fact 라우터. 사업장(place_id) 하위 리소스이며, 회사 스코프는 service 가 사업장 조회로 강제한다. +# fact 라우터. router = APIRouter(prefix="/v1/place/{place_id}/fact", tags=["Fact"], responses={404: {"description": "Not found"}}) diff --git a/solution/backend/router/v1/fact/protocol.py b/solution/backend/router/v1/fact/protocol.py index a4c0b4d..596faa6 100644 --- a/solution/backend/router/v1/fact/protocol.py +++ b/solution/backend/router/v1/fact/protocol.py @@ -13,9 +13,7 @@ class FactProtocol(WebPacketProtocol): class Req_UpsertFact(FactProtocol): - """fact 기록. key 는 사업장 업종의 스키마에 있는 것만 허용한다. - - ★ source_type 이 owner 가 아니면 source_url 이 필수다 — 출처 없는 사실은 받지 않는다.""" + """fact 기록.""" key: str = "" value: Optional[str] = None @@ -26,14 +24,7 @@ class Req_UpsertFact(FactProtocol): class Req_ExtractFacts(FactProtocol): - """사장님이 붙여넣은 원문에서 fact 를 뽑는다. - - ★ 왜 이 입구가 필요한가 (2026-08-31) - TourAPI 에 없고 네이버에도 요금표뿐인 업소가 흔하다(실측: 조이모텔 — 수집 fact 6건이 - 전부 대실·숙박 요금이었다). 그런 업소는 자동 수집만으로는 발행 근거가 영영 안 찬다. - 폴백 3단계의 2번(사장님이 직접 붙여넣기)이 여기다. - - ★ 뽑은 값은 전부 **후보(UNVERIFIED)** 로 들어간다. 사장님이 확인해야 사이트에 나간다.""" + """사장님이 붙여넣은 원문에서 fact 를 뽑는다.""" text: str = "" @@ -48,20 +39,17 @@ class Res_ExtractedFact(WebPacketProtocol): class Res_ExtractFacts(Res_WebPacketProtocol): - """뽑힌 것과 버려진 것을 **둘 다** 돌려준다. - - ★ 조용히 버리지 않는다 — 사장님이 "내가 쓴 체크인 시간이 왜 안 들어갔지" 를 - 화면에서 바로 확인할 수 있어야 한다.""" + """뽑힌 것과 버려진 것을 **둘 다** 돌려준다.""" stored: int = 0 rejected: int = 0 facts: list[Res_ExtractedFact] = [] - # (버린 항목, 사유). 모델이 지어낸 값·스키마 밖 key 가 여기로 온다. + # (버린 항목, 사유). rejections: list[list[str]] = [] class Req_TransitionFact(FactProtocol): - """검증 상태 전이. 허용 전이는 FACT_STATUS_TRANSITIONS 가 유일한 소스다.""" + """검증 상태 전이.""" status: FactStatus = FactStatus.VERIFIED value: Optional[str] = None # CORRECTED 로 갈 때 고친 값(다른 전이에선 무시) @@ -75,8 +63,7 @@ class FactData(WebPacketProtocol): unit_id: Optional[uuid.UUID] = None key: str value: Optional[str] = None - # ★ 캔버스 미리보기용 축약문. intro/room_intro 원문이 길 때만 채운다 — DB 에는 없다(응답 전용, - # FactService._attach_summaries 가 요청마다 계산해 붙인다). + # 캔버스 미리보기용 축약문. summary: Optional[str] = None unit: Optional[str] = None source_type: SourceType @@ -96,20 +83,20 @@ class FieldSpecData(WebPacketProtocol): type: str scope: str required: bool - critical: bool # ★ 미검증 노출 금지 대상 + critical: bool # 미검증 노출 금지 대상 allow_llm: bool unit: Optional[str] = None class Res_FactList(Res_WebPacketProtocol): facts: list[FactData] = [] - publishable: int = 0 # ★ 사이트에 나갈 수 있는 fact 수(VERIFIED·CORRECTED) + publishable: int = 0 # 사이트에 나갈 수 있는 fact 수(VERIFIED·CORRECTED) pending_review: int = 0 # 재수집이 올려놓은 확인 대기 후보 수(관리 화면 배지) class Res_Fact(Res_WebPacketProtocol): fact: Optional[FactData] = None - # 기록/전이가 무엇을 했는지. PUBLISHED_REPLACED 면 사이트 재빌드 대상이다. + # 기록/전이가 무엇을 했는지. outcome: Optional[FactWriteOutcome] = None diff --git a/solution/backend/router/v1/faq/faq.py b/solution/backend/router/v1/faq/faq.py index a50e0f2..6a4f3ba 100644 --- a/solution/backend/router/v1/faq/faq.py +++ b/solution/backend/router/v1/faq/faq.py @@ -7,7 +7,7 @@ from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneRespo from services.faq_service import FaqService from .protocol import Req_CreateFaq, Req_TransitionFaq, Res_Faq, Res_FaqList -# FAQ 라우터. 사업장(place_id) 하위 리소스이며, 회사 스코프는 service 가 사업장 조회로 강제한다. +# FAQ 라우터. router = APIRouter(prefix="/v1/place/{place_id}/faq", tags=["Faq"], responses={404: {"description": "Not found"}}) diff --git a/solution/backend/router/v1/faq/protocol.py b/solution/backend/router/v1/faq/protocol.py index bdb7aa6..7c48f07 100644 --- a/solution/backend/router/v1/faq/protocol.py +++ b/solution/backend/router/v1/faq/protocol.py @@ -13,10 +13,7 @@ class FaqProtocol(WebPacketProtocol): class Req_CreateFaq(FaqProtocol): - """사장님이 직접 쓴 FAQ. - - ★ generated_by 를 요청으로 받지 않는다 — 받으면 LLM 생성물을 사람이 쓴 것처럼 올려 - 승인 절차를 통째로 건너뛸 수 있다. 출처는 서버가 OWNER 로 고정한다.""" + """사장님이 직접 쓴 FAQ.""" question: str = "" answer: str = "" @@ -24,9 +21,7 @@ class Req_CreateFaq(FaqProtocol): class Req_TransitionFaq(FaqProtocol): - """검증 상태 전이. 허용 전이는 fact 와 같은 표(FACT_STATUS_TRANSITIONS)가 유일한 소스다. - - CORRECTED 로 갈 때는 고친 question / answer 중 하나 이상이 필요하다(다른 전이에선 무시).""" + """검증 상태 전이.""" status: FactStatus = FactStatus.VERIFIED question: Optional[str] = None @@ -40,8 +35,7 @@ class FaqData(WebPacketProtocol): place_id: uuid.UUID question: str answer: str - # 근거 목록. 컬럼명은 ids 지만 copy 잡이 담는 값은 fact 의 **key** 다 — - # 사람이 승인 화면에서 "무슨 사실로 쓴 문장인지" 읽을 수 있어야 하기 때문이다. + # 근거 목록. source_fact_ids: Optional[list[str]] = None generated_by: SourceType status: FactStatus @@ -52,11 +46,9 @@ class FaqData(WebPacketProtocol): class Res_FaqList(Res_WebPacketProtocol): faqs: list[FaqData] = [] - # ★ 사이트에 나갈 수 있는 건수(VERIFIED·CORRECTED). FAQPage JSON-LD 는 이 건수만큼만 나간다. + # 사이트에 나갈 수 있는 건수(VERIFIED·CORRECTED). publishable: int = 0 # 승인 대기 건수 — 관리 화면의 '검토할 것' 배지. - # fact 와 달리 UNVERIFIED 도 포함한다: FAQ 에는 '재수집 후보' 개념이 없고, - # LLM 이 만들어 둔 UNVERIFIED 가 곧 사장님 승인 대기 큐다. pending_review: int = 0 diff --git a/solution/backend/router/v1/job/job.py b/solution/backend/router/v1/job/job.py index 8654023..dd06d05 100644 --- a/solution/backend/router/v1/job/job.py +++ b/solution/backend/router/v1/job/job.py @@ -7,7 +7,7 @@ from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneRespo from services.job_service import JobService from .protocol import Res_Job, Res_JobOps -# 작업 큐 라우터. 수집·비전분석·빌드는 몇 분 걸리므로 클라이언트가 여기를 폴링한다. +# 작업 큐 라우터. router = APIRouter(prefix="/v1/job", tags=["Job"], responses={404: {"description": "Not found"}}) diff --git a/solution/backend/router/v1/job/protocol.py b/solution/backend/router/v1/job/protocol.py index 33517a4..3dab866 100644 --- a/solution/backend/router/v1/job/protocol.py +++ b/solution/backend/router/v1/job/protocol.py @@ -39,7 +39,7 @@ class JobData(WebPacketProtocol): class Res_Job(Res_WebPacketProtocol): - """잡 상태 폴링 응답. 수집·빌드는 몇 분 걸리므로 클라이언트가 이 엔드포인트를 폴링한다.""" + """잡 상태 폴링 응답.""" job: Optional[JobData] = None diff --git a/solution/backend/router/v1/local/local.py b/solution/backend/router/v1/local/local.py index 7099753..4785890 100644 --- a/solution/backend/router/v1/local/local.py +++ b/solution/backend/router/v1/local/local.py @@ -41,7 +41,7 @@ async def list_place_contents(place_id: uuid.UUID, service: LocalContentService @router.post("/place/{place_id}/sync", response_model=ResSyncPlace, summary="업장 주변정보 재수집 (TourAPI 반경)") async def sync_place(place_id: uuid.UUID, service: LocalContentService = Depends(), _user=Depends(RequireOwner)): - """빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다. 무인 갱신(스케줄러)은 아직 없다.""" + """빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다.""" return RemoveNoneResponse(await service.sync_place_by_id(place_id)) @@ -50,7 +50,7 @@ async def hide_place_content( place_content_id: uuid.UUID, req: ReqHidePlaceContent, service: LocalContentService = Depends(), _user=Depends(RequireOwner), ): - """숨긴 항목은 재수집이 되살리지 않는다. 다음 빌드부터 발행본에서 빠진다.""" + """숨긴 항목은 재수집이 되살리지 않는다.""" return RemoveNoneResponse(await service.set_hidden(place_content_id, req.hidden)) diff --git a/solution/backend/router/v1/local/protocol.py b/solution/backend/router/v1/local/protocol.py index b8a617d..d86def0 100644 --- a/solution/backend/router/v1/local/protocol.py +++ b/solution/backend/router/v1/local/protocol.py @@ -66,7 +66,7 @@ class ResLocalContentList(Res_WebPacketProtocol): class ResSyncPlace(Res_WebPacketProtocol): - """업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤). changed 는 값이 바뀌었는지.""" + """업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤).""" festivals: int = 0 attractions: int = 0 @@ -76,15 +76,13 @@ class ResSyncPlace(Res_WebPacketProtocol): class ResLocalGuide(Res_WebPacketProtocol): - """에디터 캔버스가 그리는 지역 가이드. ★ 항목 모양은 발행 payload 의 LocalContents 와 **동일**하다 - (services/site_payload._local 을 그대로 거친다) — 캔버스와 발행본이 다른 목록을 보이면 안 된다.""" + """에디터 캔버스가 그리는 지역 가이드.""" attractions: list[dict[str, Any]] = [] restaurants: list[dict[str, Any]] = [] festivals: list[dict[str, Any]] = [] courses: list[dict[str, Any]] = [] - # ★ 1박2일·2박3일 각 5개(services/itinerary_llm_service). 발행본 payload.local.itineraries 와 - # **같은 값**이다 — 캔버스가 다른 목록을 보이면 "미리보기와 다르다"가 된다. + # 1박2일·2박3일 각 5개(services/itinerary_llm_service). itineraries: list[dict[str, Any]] = [] synced_at: str | None = None diff --git a/solution/backend/router/v1/media/media.py b/solution/backend/router/v1/media/media.py index aa451a0..a72a42f 100644 --- a/solution/backend/router/v1/media/media.py +++ b/solution/backend/router/v1/media/media.py @@ -8,7 +8,7 @@ from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneRespo from services.media_service import MediaService from .protocol import Res_MediaList -# 사진 라우터. 사업장(place_id) 하위 리소스이며, 회사 스코프는 service 가 사업장 조회로 강제한다. +# 사진 라우터. router = APIRouter(prefix="/v1/place/{place_id}/media", tags=["Media"], responses={404: {"description": "Not found"}}) diff --git a/solution/backend/router/v1/media/protocol.py b/solution/backend/router/v1/media/protocol.py index dd18bc3..3f38b6c 100644 --- a/solution/backend/router/v1/media/protocol.py +++ b/solution/backend/router/v1/media/protocol.py @@ -15,12 +15,7 @@ class MediaProtocol(WebPacketProtocol): class MediaData(WebPacketProtocol): - """사진 1건. - - ★ source_type 과 origin_url 을 반드시 함께 내려보낸다 — 크롤링 이미지의 재게시 권리가 - 아직 미결이라(docs/DECISIONS.md 1-2), 결론이 '불가'로 나면 발행에서 source_type = CRAWL 을 - 통째로 제외해야 한다. 화면이 출처를 모르면 무엇이 빠질지도 미리 보여줄 수 없다. - origin_url 은 그때 '이 사진은 어디서 왔는가'를 증명하는 유일한 근거다.""" + """사진 1건.""" model_config = ConfigDict(from_attributes=True) @@ -28,8 +23,8 @@ class MediaData(WebPacketProtocol): place_id: uuid.UUID unit_id: Optional[uuid.UUID] = None # 객실·메뉴 사진이면 연결 url: str # 우리가 보관하는 접근 URL - origin_url: Optional[str] = None # ★ 수집 원본 이미지 URL — 권리 판단의 근거 - source_type: SourceType # ★ OWNER 업로드 / CRAWL 수집 — 발행 필터 키 + origin_url: Optional[str] = None # 수집 원본 이미지 URL — 권리 판단의 근거 + source_type: SourceType # OWNER 업로드 / CRAWL 수집 — 발행 필터 키 source_url: Optional[str] = None # 수집한 페이지 URL label: Optional[str] = None # Vision 분류 라벨 (예: "A동 침실") alt_text: Optional[str] = None # Vision 생성 alt @@ -40,13 +35,11 @@ class MediaData(WebPacketProtocol): sort_order: int = 0 created_at: Optional[datetime] = None # DB 컬럼이 아니라 계산값이다 — '지금 발행하면 이 사진이 사이트에 실리는가'. - # 판단 기준을 services/snapshot.py 와 똑같이 맞춘다(승인 + alt 있음). 화면이 "왜 이 사진은 - # 안 나오나"를 사장님에게 설명할 수 있어야 하는데, 그 답이 상태 하나로는 안 나오기 때문이다. publishable: bool = False class Res_MediaList(Res_WebPacketProtocol): media: list[MediaData] = [] - publishable: int = 0 # ★ 실제로 사이트에 나갈 수 있는 사진 수(승인 + alt 있음) + publishable: int = 0 # 실제로 사이트에 나갈 수 있는 사진 수(승인 + alt 있음) pending_review: int = 0 # 사람 확인 큐에 남은 사진 수(관리 화면 배지) - crawled: int = 0 # ★ 재게시 권리 미결(1-2) — 결론이 '불가'면 통째로 빠질 사진 수 + crawled: int = 0 # 재게시 권리 미결(1-2) — 결론이 '불가'면 통째로 빠질 사진 수 diff --git a/solution/backend/router/v1/media/relay.py b/solution/backend/router/v1/media/relay.py index 38697f3..43429be 100644 --- a/solution/backend/router/v1/media/relay.py +++ b/solution/backend/router/v1/media/relay.py @@ -1,21 +1,4 @@ -"""사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다. - -★ 왜 필요한가 (2026-09-15, 실측) - 수집한 사진은 `*.pstatic.net` · `tong.visitkorea.or.kr` 에 있고 그쪽은 - `Access-Control-Allow-Origin` 을 주지 않는다. 그 사진을 캔버스에 그리면 캔버스가 **오염돼** - `toBlob` 이 막힌다 — 엽서 쓰기의 저장·공유가 죽는다. 브라우저 정책이라 클라이언트에서는 - 못 넘는다. CORS 없는 `fetch` 도 같은 벽이다. **같은 오리진에서 바이트가 와야** 풀린다. - -★ 굽는 쪽(`site/scripts/prerender.ts` mirrorMedia)이 이미 사진을 내려받아 사이트 폴더에 - 놓는다. 그게 근본이다. 다만 그건 **다시 굽는 사이트에만** 적용된다 — 이미 나가 있는 - 사이트는 사장님이 재발행할 때까지 옛 주소를 문다. 이 중계가 그 사이를 메운다. - -★ 열린 프록시가 되지 않게 좁혀 둔다. 이건 "아무 주소나 가져다주는 통로" 가 아니다: - · https 만 - · 호스트가 `_ALLOWED_SUFFIXES` 에 있는 것만 (우리 수집기가 쓰는 사진 CDN) - · 응답이 이미지가 아니면 거절, 크기 상한 - · 리다이렉트를 따라가되 최종 호스트도 다시 검사한다 — 안 그러면 allowlist 를 우회한다 -""" +"""사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다.""" import ipaddress from urllib.parse import urlparse @@ -26,8 +9,7 @@ from common.logger import LOG router = APIRouter(prefix="/v1/image", tags=["Image"]) -# 우리 수집기가 사진을 가져오는 곳. 여기 없는 호스트는 중계하지 않는다. -# ★ 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는 곳인가" 를 먼저 본다. +# 우리 수집기가 사진을 가져오는 곳. _ALLOWED_SUFFIXES = ( ".pstatic.net", "tong.visitkorea.or.kr", @@ -37,7 +19,7 @@ _ALLOWED_SUFFIXES = ( _MAX_BYTES = 8 * 1024 * 1024 _TIMEOUT = httpx.Timeout(10.0, connect=5.0) -# 기본 UA 를 거절하는 CDN 이 있다. 탐지 우회가 아니라 평범한 브라우저로 보이게 하는 것뿐이다. +# 기본 UA 를 거절하는 CDN 이 있다. _HEADERS = {"user-agent": "Mozilla/5.0 (compatible; o2o-web4ai/1.0)"} @@ -57,8 +39,7 @@ def _allowed(url: str) -> bool: @router.get("/relay", summary="사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다") async def relay(url: str = Query(min_length=8, max_length=2000)): - """인증을 요구하지 않는다. 발행본은 로그인 없이 열리는 정적 페이지이고, 여기서 나가는 - 것은 **그 페이지가 이미 화면에 싣고 있는 사진**뿐이다(allowlist 가 그걸 보장한다).""" + """인증을 요구하지 않는다.""" if not _allowed(url): raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다") diff --git a/solution/backend/router/v1/ops/ops.py b/solution/backend/router/v1/ops/ops.py index 16031fa..56f7d22 100644 --- a/solution/backend/router/v1/ops/ops.py +++ b/solution/backend/router/v1/ops/ops.py @@ -6,9 +6,6 @@ from services.ops_service import OpsService from .protocol import Res_OpsSites, Res_OpsUsers # 내부 운영(개발자) 전용 조회 — 회사 스코프를 걷어낸 전 계정 사이트·유저 목록. -# admin/frontend(:9801)를 새 도메인으로 키우는 대신 solution 앱(:9800)에 경량으로 얹은 것이다 -# (2026-09-23 기획). RequireDeveloper 가 유일한 문이다 — OWNER(사장님)는 관리할 하위 계정이 -# 없어(2026-09-08 회사/테넌트 걷어냄) 이 화면을 볼 이유가 없다. router = APIRouter(prefix="/v1/ops", tags=["Ops"]) diff --git a/solution/backend/router/v1/ops/protocol.py b/solution/backend/router/v1/ops/protocol.py index 361f8d0..ac6cc3c 100644 --- a/solution/backend/router/v1/ops/protocol.py +++ b/solution/backend/router/v1/ops/protocol.py @@ -7,9 +7,7 @@ from common.models.gmodel import Res_PageProtocol, WebPacketProtocol class OpsSiteData(WebPacketProtocol): - """전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자. - - ★ MySiteData(내 사이트 목록)와 같은 모양에 소유자 식별자만 얹었다 — 화면이 다를 뿐 값의 뜻은 같다.""" + """전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자.""" place_id: uuid.UUID name: str @@ -34,7 +32,7 @@ class Res_OpsSites(Res_PageProtocol): class OpsUserData(WebPacketProtocol): - """전 계정 목록의 한 줄. ★ role 은 항상 USER/OWNER 다 — 개발자 계정은 서비스가 걸러낸다.""" + """전 계정 목록의 한 줄.""" user_id: uuid.UUID login_id: str diff --git a/solution/backend/router/v1/place/place.py b/solution/backend/router/v1/place/place.py index 67622c9..d29b15b 100644 --- a/solution/backend/router/v1/place/place.py +++ b/solution/backend/router/v1/place/place.py @@ -31,7 +31,7 @@ from .protocol import ( Res_UnitList, ) -# 사업장 라우터. 모든 조회·변경은 토큰의 사장님(places.owner_user_id)으로 스코프된다. +# 사업장 라우터. router = APIRouter(prefix="/v1/place", tags=["Place"], responses={404: {"description": "Not found"}}) @@ -61,8 +61,7 @@ async def search_places_public( service: PlaceService = Depends(), q: str = Query(..., min_length=2, max_length=100, description="상호명"), ): - # ★ 이 라우트는 반드시 `/{place_id}` **앞에** 있어야 한다. FastAPI 는 등록 순서로 매칭해서, - # 뒤에 두면 "search" 가 place_id 로 잡혀 422 가 난다 — 조용히 틀리는 종류다. + # 이 라우트는 반드시 `/{place_id}` **앞에** 있어야 한다. client_ip = request.client.host if request.client else "unknown" return RemoveNoneResponse(await service.search_places_public(q, client_ip)) diff --git a/solution/backend/router/v1/place/protocol.py b/solution/backend/router/v1/place/protocol.py index ce5a843..9c4291b 100644 --- a/solution/backend/router/v1/place/protocol.py +++ b/solution/backend/router/v1/place/protocol.py @@ -15,24 +15,18 @@ class PlaceProtocol(WebPacketProtocol): class Req_CreatePlace(PlaceProtocol): - # 상호명 하나로 시작한다. 나머지는 카카오 로컬 검증이 채운다. + # 상호명 하나로 시작한다. name: str = "" category: PlaceCategory = PlaceCategory.LODGING - # ★ 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place). 여기로 받으면 - # 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다. + # 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place). class Req_VerifyPlace(PlaceProtocol): - """카카오 로컬 조회 결과를 사업장에 박제한다(동일 업소 확정). - - ★ 이 단계를 통과해야 수집이 열린다. - external_place_id 는 소스에 따라 없을 수 있다 — 네이버는 고유 장소 id 를 주지 않는다. - 그 경우 상호명 + 도로명주소가 중복 판정 키가 되므로 road_address 를 반드시 채워야 한다.""" + """카카오 로컬 조회 결과를 사업장에 박제한다(동일 업소 확정).""" source: ExternalPlaceSource = ExternalPlaceSource.NAVER external_place_id: str = "" - # 외부 장소 DB 가 함께 준 업체 홈페이지 URL. 있으면 공식 홈페이지 채널로 자동 등록한다. - # (네이버 지역검색 응답의 link 가 이것 — Perplexity 로는 잘 안 잡히는 채널이라 여기서 건진다.) + # 외부 장소 DB 가 함께 준 업체 홈페이지 URL. place_url: Optional[str] = None road_address: Optional[str] = None address: Optional[str] = None @@ -40,42 +34,24 @@ class Req_VerifyPlace(PlaceProtocol): latitude: Optional[Decimal] = None longitude: Optional[Decimal] = None region_code: Optional[str] = None - # 외부 장소 DB 의 분류 문자열(후보의 category_name). 주변 맛집에서 같은 업태(경쟁 업소)를 빼는 기준으로 박제한다. + # 외부 장소 DB 의 분류 문자열(후보의 category_name). category_name: Optional[str] = None class Req_VerifyPlaceByUrl(PlaceProtocol): - """네이버 플레이스 URL 하나로 동일 업소를 확정한다. - - ★ 왜 이 경로가 필요한가 - 상호 검색으로 place id 를 자동 해석하는 경로는 실패한다(실측: '롯데호텔 서울'). - Perplexity 도 네이버 플레이스를 못 찾는다 — 안내 페이지를 물어온 적도 있다. - 그런데 사장님은 **자기 가게 주소를 이미 알고 있다.** 붙여넣게 하는 것이 가장 - 정확하고 빠르며, 그 붙여넣기 자체가 "이 가게가 맞다"는 사람의 확인이다. - - 서버는 그 URL 로 네이버 상세를 읽어 상호·주소·좌표를 가져온다 — 사장님이 손으로 - 옮겨 적게 하지 않는다(오타가 곧 남의 가게가 된다). - """ + """네이버 플레이스 URL 하나로 동일 업소를 확정한다.""" url: str = "" - # ★ 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지. - # - # 기본값이 True 인 것은 이 API 를 부르는 다른 자리(주소 재확인 등)의 동작을 바꾸지 - # 않기 위해서다. 위저드의 **[새로 크롤링하고 사이트 생성하기]** 는 False 로 보낸다 — - # 사장님이 새로 만들겠다고 누른 것을 서버가 "이미 있으니 그걸 쓰세요" 로 바꿔 버리면 - # 같은 화면을 눌러도 기존 에디터가 열린다(실측 2026-09-15: 그게 지금 증상이다). - # - # False 라도 **비어 있는 중복 행은 치운다.** 그건 위저드를 중간에 나갔을 때 남는 - # 찌꺼기라 잃을 것이 없다 — 원래 막으려던 것도 그 누적이었다(ba90a19). + # 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지. reuse_existing: bool = True class Req_UpdatePlace(PlaceProtocol): - # ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다. + # 주인은 못 바꾼다(위 Req_CreatePlace 주석). name: Optional[str] = None status: Optional[PlaceStatus] = None - # 미니 블로그 승인 메일 수신 주소. 빈 문자열이면 지운다(계정 이메일로 되돌린다). + # 미니 블로그 승인 메일 수신 주소. notify_email: Optional[str] = None @@ -108,8 +84,8 @@ class PlaceData(WebPacketProtocol): longitude: Optional[Decimal] = None region_code: Optional[str] = None verified_at: Optional[datetime] = None - content_updated_at: Optional[datetime] = None # ★ 노출값 변경 시각 — 개별 재빌드 대상 판별 - notify_email: Optional[str] = None # 미니 블로그 승인 메일 수신 주소. 비면 계정 이메일 사용 + content_updated_at: Optional[datetime] = None # 노출값 변경 시각 — 개별 재빌드 대상 판별 + notify_email: Optional[str] = None # 미니 블로그 승인 메일 수신 주소. created_at: Optional[datetime] = None @@ -132,7 +108,7 @@ class LinkData(WebPacketProtocol): title: Optional[str] = None discovered_by: SourceType discovered_at: Optional[datetime] = None - confirmed_at: Optional[datetime] = None # ★ NULL = 크롤링 대상 아님 + confirmed_at: Optional[datetime] = None # NULL = 크롤링 대상 아님 class Res_PlaceList(Res_PageProtocol): @@ -161,31 +137,29 @@ class Res_Link(Res_WebPacketProtocol): class Req_StartCollect(PlaceProtocol): - """수집 시작. 몇 분 걸리므로 동기로 처리하지 않고 잡을 적재한 뒤 즉시 응답한다.""" + """수집 시작.""" - # 확정된 채널 URL 만 크롤링한다. 비우면 이 사업장의 확정 링크 전체. + # 확정된 채널 URL 만 크롤링한다. link_ids: list[uuid.UUID] = [] - # 이미 확보한 fact 를 다시 긁을지. 기본은 아니오(외부 API 호출 비용을 아낀다). + # 이미 확보한 fact 를 다시 긁을지. force: bool = False # 상호·주소로 공개 채널 URL 을 Perplexity 에서 추가 탐색할지. - # 기본 False — 사용자가 화면에서 명시적으로 선택한 회차에만 유료 검색을 실행한다. discover_channels: bool = False class Res_StartCollect(Res_WebPacketProtocol): - """수집 잡 적재 결과. 클라이언트는 job_id 로 GET /v1/job/{job_id} 를 폴링한다.""" + """수집 잡 적재 결과.""" job_id: Optional[uuid.UUID] = None status: Optional[JobStatus] = None - # 이미 같은 사업장 수집이 돌고 있어 새로 만들지 않았다면 False (기존 잡의 id 를 돌려준다). created: bool = True confirmed_links: int = 0 class Req_StartVision(PlaceProtocol): - """사진 분석 시작. 사진 20~50장이라 몇 분 걸린다 — 잡으로 처리한다.""" + """사진 분석 시작.""" - # 이미 분석된 사진도 다시 태울지. 기본은 아니오(같은 사진 재분석은 요금만 나간다). + # 이미 분석된 사진도 다시 태울지. force: bool = False @@ -196,9 +170,9 @@ class Res_StartVision(Res_WebPacketProtocol): pending_media: int = 0 # 분석 대상 사진 수 -# ---- 동일 업소 후보 (UI 가 사람에게 고르게 한다) ---------------------------- +# 동일 업소 후보 (UI 가 사람에게 고르게 한다) class PlaceCandidate(WebPacketProtocol): - """외부 장소 DB 에서 찾은 후보 1건. UI 가 이걸 카드로 그려 사람이 고른다.""" + """외부 장소 DB 에서 찾은 후보 1건.""" external_place_id: Optional[str] = None # 네이버는 안 준다 name: str = "" @@ -209,14 +183,11 @@ class PlaceCandidate(WebPacketProtocol): longitude: Optional[Decimal] = None category_name: Optional[str] = None place_url: Optional[str] = None # 업체 홈페이지 — 확정 시 공식 채널로 등록된다 - naver_place_url: Optional[str] = None # 자동 발견한 네이버 플레이스. 없으면 UI가 URL 입력을 요청한다 + naver_place_url: Optional[str] = None # 자동 발견한 네이버 플레이스. class Res_VerifyCandidates(Res_WebPacketProtocol): - """동일 업소 후보 목록. - - ★ 자동 판정을 신뢰하지 않는다. outcome 이 MATCHED 여도 후보를 전부 내려보내 - UI 가 사람에게 확인시킬 수 있게 한다 — 남의 가게가 섞이면 그게 제일 비싼 실수다.""" + """동일 업소 후보 목록.""" source: Optional[ExternalPlaceSource] = None outcome: str = "" # matched | ambiguous | no_candidate @@ -225,40 +196,28 @@ class Res_VerifyCandidates(Res_WebPacketProtocol): candidates: list[PlaceCandidate] = [] -# ---- 공개 상호명 검색 (랜딩 첫 화면) ---------------------------------------- +# 공개 상호명 검색 (랜딩 첫 화면) class PlaceSearchItem(WebPacketProtocol): - """공개 검색 결과 1건. - - ★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은 - 하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다. - ★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고, - 확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다.""" + """공개 검색 결과 1건.""" name: str = "" road_address: Optional[str] = None category_name: Optional[str] = None # 외부 DB 의 분류 문자열(예: "숙박>펜션") - # 추정 업종. ★ None 이면 못 정한 것이다 — 화면이 사장님에게 직접 고르게 한다. - # 값이 있어도 확정이 아니다. 화면은 언제나 바꿀 수 있게 둔다(경계 업종이 실제로 있다). + # 추정 업종. category: Optional[PlaceCategory] = None - # 자동으로 찾은 네이버 플레이스 주소. ★ 공개 페이지에서 읽은 값이라 우리 DB 것이 아니다. - # 못 찾으면 None — 화면이 그때만 사장님에게 지도 주소를 묻는다. + # 자동으로 찾은 네이버 플레이스 주소. naver_place_url: Optional[str] = None class Res_PlaceSearch(Res_WebPacketProtocol): - """상호명 공개 검색 결과. - - ★ 인증이 없다. 랜딩 첫 화면에서 상호명을 치면 바로 부른다 — - 만들어 보기도 전에 로그인을 요구하지 않기로 한 결정(로그인 관문은 에디터 진입 하나)의 연장이다.""" + """상호명 공개 검색 결과.""" source: Optional[ExternalPlaceSource] = None items: list[PlaceSearchItem] = [] class Req_StartCopy(PlaceProtocol): - """소개문·FAQ 생성 시작. - - ★ 확인된 fact 만 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 안 한다).""" + """소개문·FAQ 생성 시작.""" resume: bool = False diff --git a/solution/backend/router/v1/site/booking_request.py b/solution/backend/router/v1/site/booking_request.py index 88e5eea..5edf8a1 100644 --- a/solution/backend/router/v1/site/booking_request.py +++ b/solution/backend/router/v1/site/booking_request.py @@ -1,11 +1,4 @@ -"""발행본의 예약 요청 폼 → 사장님 메일. - -★ 로그인 없는 공개 엔드포인트다. 손님은 계정이 없다. -★ DB 에 남기지 않는다(2026-09-16 대표 지시). 예약자 연락처는 메일 본문에만 실리고, - 보내고 나면 우리 쪽에 남는 것은 로그 한 줄뿐이다 — 보관하지 않으니 파기 절차도 없다. -★ 예약을 처리하지 않는다. 빈 방도 결제도 우리 것이 아니다(PRODUCT.md 6절). 받는 것은 - **연락 요청**이고, 화면도 그렇게 말한다. -""" +"""발행본의 예약 요청 폼 → 사장님 메일.""" import time import uuid from collections import defaultdict, deque @@ -19,11 +12,11 @@ from services.booking_request_service import BookingRequestService router = APIRouter(prefix="/v1/site", tags=["Site"]) -# 한 아이피가 한 시간에 보낼 수 있는 통수. 같은 업장으로 몰리는 것도 따로 센다. +# 한 아이피가 한 시간에 보낼 수 있는 통수. IP_LIMIT_PER_HOUR = 5 PLACE_LIMIT_PER_HOUR = 30 WINDOW_SEC = 3600 -# 폼을 연 뒤 이만큼은 지나야 사람으로 친다. 봇은 즉시 제출한다. +# 폼을 연 뒤 이만큼은 지나야 사람으로 친다. MIN_ELAPSED_MS = 1500 _hits: dict[str, deque] = defaultdict(deque) @@ -49,7 +42,7 @@ class ReqBookingRequest(BaseModel): guests: str | None = Field(default=None, max_length=30) message: str | None = Field(default=None, max_length=1000) consent: bool - # 봇 잡이. 사람에게는 안 보이는 칸이라 값이 있으면 사람이 아니다. + # 봇 잡이. company: str | None = Field(default=None, max_length=100) elapsed_ms: int = 0 @@ -72,7 +65,7 @@ async def send_booking_request( client_ip = (request.headers.get("x-forwarded-for", "").split(",")[0].strip() or (request.client.host if request.client else "unknown")) - # 봇 두 겹. 걸려도 실패로 알리지 않는다 — 무엇에 걸렸는지 알려 주면 다음 시도가 그걸 피한다. + # 봇 두 겹. if body.company or body.elapsed_ms < MIN_ELAPSED_MS: LOG.w("[booking-request] 봇 의심 요청을 버렸다") return RemoveNoneResponse(ResBookingRequest(success=True, message="요청을 보냈습니다.")) diff --git a/solution/backend/router/v1/site/post.py b/solution/backend/router/v1/site/post.py index 08fde0e..32a68f5 100644 --- a/solution/backend/router/v1/site/post.py +++ b/solution/backend/router/v1/site/post.py @@ -1,19 +1,4 @@ -"""미니 블로그 승인 — 사장님이 메일에서 누르는 자리, 그리고 빌더 앱 로그인 화면. 기획: docs/MINI_BLOG.md - -★ /approve 는 로그인이 없다. 링크에 실린 토큰 하나가 신원이고, 누르는(GET) 순간 바로 - 승인된다(2026-09-17, 사장님 지시: "승인은 바로 승인 되게 그 링크만 클릭하면"). ★★ 이건 - 메일 클라이언트의 링크 미리 열기(아웃룩 안전 링크 스캔 등)에 그대로 노출된다는 뜻이다 — - 예전에는 이걸 막으려고 GET=확인 화면 / POST=승인 확정으로 나눴었다. 사장님이 그 위험을 - 알고도 즉시 승인을 택했다. -★ "수정하기" 는 반대로 로그인 흐름을 탄다 — 메일에 그날 자정(KST)까지만 사는 접근 토큰을 - 실어 보내고(services/blog_jobs.py _mail_body), 빌더 앱이 그 토큰으로 로그인한 뒤 이번 - 글 편집 모달을 바로 연다(BlogPostsPage.tsx). 별도 공개 편집 화면을 두지 않는다. -★ owner_router 는 로그인 세션이 신원이다 — 빌더 앱의 "이번 달 생성된 글" 화면. -★★ 2026-09-21, 사장님 지시: 게재는 두 경로 다 열려 있다 — 이 파일 위쪽의 /approve - (이메일 토큰, 로그인 없음)와, 아래 owner_router 의 POST .../approve(로그인 세션, - "바로 발행" — 수정 없이 그대로 승인). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다 — - 승인은 이 두 경로 중 하나를 명시적으로 눌러야 한다. -""" +"""미니 블로그 승인 — 사장님이 메일에서 누르는 자리, 그리고 빌더 앱 로그인 화면.""" import html from datetime import date from uuid import UUID @@ -44,9 +29,7 @@ h1{{font-size:21px;margin:0 0 6px}} p{{margin:0 0 14px}} def _page(title: str, content: str, *, redirect_url: str | None = None) -> HTMLResponse: - # ★ redirect_url 은 항상 서버가 site_payload.publish_url() 로 만든 값(고정 오리진 + - # slugify 통과 슬러그)이라 사용자 입력이 아니지만, HTML 속성에 그대로 꽂는 자리라 - # escape 를 걸어 둔다 — 이 함수가 나중에 다른 값을 받게 되더라도 안전하게. + # redirect_url 은 항상 서버가 site_payload.publish_url() 로 만든 값(고정 오리진 + slugify 통과 슬러그)이라 사용자 입력이 아니지만, HTML 속성에 그대로 꽂는 자리라 escape 를 걸어 둔다 — 이 함수가 나중에 다른 값을 받게 되더라도 안전하게. redirect = ( f'' if redirect_url else "" @@ -67,9 +50,7 @@ async def approve_page(t: str = Query(min_length=8, max_length=200), service: Po if not result["success"]: return _expired_page() redirect_url = result.get("redirect_url") - # ★ 재발행은 몇 분 걸린다(BUILD 잡) — 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다. - # 그래도 "어디로 가면 보이는지" 를 알려주는 게 사장님 입장에서 "눌렀는데 어디 갔지" 보다 - # 낫다(2026-09-22, 사장님 지시). 링크 자체는 안내 문구에도 남겨 자동 이동을 못 믿어도 되게 한다. + # 재발행은 몇 분 걸린다(BUILD 잡) — 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다. extra = ( f"

{_REDIRECT_DELAY_SEC}초 뒤 자동으로 이동합니다. " f"바로 가려면 여기를 눌러주세요.

" diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py index 087ef04..2ffccf6 100644 --- a/solution/backend/router/v1/site/protocol.py +++ b/solution/backend/router/v1/site/protocol.py @@ -105,7 +105,7 @@ class PublishLogData(WebPacketProtocol): class Req_SiteTemplate(SiteProtocol): - """템플릿 선택 저장. 업종 허용 목록(solution/shared/src/data/templates.json)에 없는 id는 거절한다.""" + """템플릿 선택 저장.""" template_id: str = "" @@ -171,7 +171,7 @@ class Req_SiteSlug(SiteProtocol): class Res_SlugCheck(Res_WebPacketProtocol): - """주소 사용 가능 확인. UI 가 타이핑 중에 호출한다.""" + """주소 사용 가능 확인.""" available: bool = False # 불가 사유(services/site_slug 의 REASON_*): INVALID_LENGTH / INVALID_FORMAT / RESERVED / TAKEN. @@ -181,7 +181,7 @@ class Res_SlugCheck(Res_WebPacketProtocol): class Res_SiteSlug(Res_WebPacketProtocol): - """주소 저장 결과. 거부됐으면 왜/대안을 check 와 같은 코드로 돌려준다.""" + """주소 저장 결과.""" site: Optional[SiteData] = None reason: Optional[str] = None diff --git a/solution/backend/router/v1/site/review.py b/solution/backend/router/v1/site/review.py index c9f7136..7bd2bc0 100644 --- a/solution/backend/router/v1/site/review.py +++ b/solution/backend/router/v1/site/review.py @@ -1,9 +1,4 @@ -"""이용 후기 접수 — 발행본에서 손님이 남긴다. - -★ 로그인 없는 공개 엔드포인트다. 예약 요청(booking_request.py)과 같은 방어를 쓴다 — - 허니팟 · 최소 체류시간 · 레이트리밋. -★ 검수를 통과해야 화면에 나간다. 그래서 접수 응답이 "게시됐다"고 말하지 않는다. -""" +"""이용 후기 접수 — 발행본에서 손님이 남긴다.""" import uuid from fastapi import APIRouter, Depends, Query, Request @@ -70,6 +65,5 @@ async def submit_review(body: ReqReview, request: Request, service: ReviewServic @router.get(path="/reviews", response_model=ResPublicReviews, summary="게재된 후기 — 발행본이 붙은 뒤 받아 간다") async def list_reviews(place_id: uuid.UUID = Query(), service: ReviewService = Depends()): - """날씨(/v1/local/weather)와 같은 공개 조회다. 구운 HTML 에는 굽는 시점의 후기가 들어 있고, - 화면은 붙은 뒤 이 주소로 최신을 받아 덮는다.""" + """날씨(/v1/local/weather)와 같은 공개 조회다.""" return RemoveNoneResponse(await service.list_public(place_id)) diff --git a/solution/backend/router/v1/site/showcase.py b/solution/backend/router/v1/site/showcase.py index 54c0a42..06ae0c0 100644 --- a/solution/backend/router/v1/site/showcase.py +++ b/solution/backend/router/v1/site/showcase.py @@ -4,8 +4,7 @@ from router.v1.validator.dependencies import RemoveNoneResponse from services.showcase_service import ShowcaseService from .protocol import Res_Showcase -# 발행 사이트 쇼케이스. ★ 인증이 없다 — 랜딩(비로그인)이 부른다. -# ★ :9801(어드민 진입점)에는 마운트하지 않는다. 내부 화면이 쓸 목록이 아니다. +# 발행 사이트 쇼케이스. router = APIRouter(prefix="/v1/showcase", tags=["Showcase"], responses={404: {"description": "Not found"}}) diff --git a/solution/backend/router/v1/social/oauth.py b/solution/backend/router/v1/social/oauth.py index f09c96e..a9b2155 100644 --- a/solution/backend/router/v1/social/oauth.py +++ b/solution/backend/router/v1/social/oauth.py @@ -43,11 +43,7 @@ async def oauth_callback( await service.finish(state, request.cookies.get(COOKIE), code) ok = True except Exception as ex: # noqa: BLE001 - # ★ 화면에는 원문을 내보내지 않는다 — OAuth 응답·state 에는 자격증명이 들어 있다. - # 대신 **서버 로그에는 반드시 남긴다.** 예전에는 통째로 삼켜서, 연결이 안 될 때 - # 화면에 `?social=failed` 만 뜨고 우리도 이유를 알 방법이 없었다 - # (키가 틀렸는지 · 쿠키가 안 왔는지 · state 가 만료됐는지 구별이 안 된다). - # ★ 남기는 것은 **예외 종류와 우리가 만든 사유 문자열**뿐이다. 토큰·code·state 는 찍지 않는다. + # 화면에는 원문을 내보내지 않는다 — OAuth 응답·state 에는 자격증명이 들어 있다. LOG.w(f"[social] 계정 연결 실패: {type(ex).__name__}: {ex}") elif error: # 사장님이 Meta 화면에서 취소한 경우도 여기로 온다 — 고장과 구별되게 남긴다. diff --git a/solution/backend/router/v1/validator/dependencies.py b/solution/backend/router/v1/validator/dependencies.py index 53189ae..4381a56 100644 --- a/solution/backend/router/v1/validator/dependencies.py +++ b/solution/backend/router/v1/validator/dependencies.py @@ -24,11 +24,7 @@ from config.server_configs import jwt_token_config security = HTTPBearer() -# ---- 비밀번호 해시 (bcrypt) ------------------------------------------------ -# bcrypt 는 CPU 바운드 동기 작업이라 그대로 호출하면 asyncio 이벤트 루프를 막아 -# 같은 워커의 다른 요청(healthz 등)까지 멈춘다. 스레드풀(asyncio.to_thread)로 보낸다. -# bcrypt 는 해싱 중 GIL 을 해제하므로 스레드들이 여러 코어에서 실제 병렬로 돈다. -# 입력은 최대 72 bytes 까지만 사용하므로 사전에 잘라준다. +# 비밀번호 해시 (bcrypt) def _hash_pw(pw: str) -> str: return bcrypt.hashpw(pw.encode("utf-8")[:72], bcrypt.gensalt()).decode("utf-8") @@ -48,7 +44,7 @@ async def VerifyPW(pw: str, hashed_pw: str) -> bool: return await asyncio.to_thread(_verify_pw, pw, hashed_pw) -# ---- JWT 토큰 발급/검증 ---------------------------------------------------- +# JWT 토큰 발급/검증 JWT_ALGORITHM = "HS256" JWT_ACCESS_SECRET = jwt_token_config.access_key JWT_REFRESH_SECRET = jwt_token_config.refresh_key @@ -73,11 +69,7 @@ def CreateRefreshToken(subject: UserInfo) -> str: def CreateDayPassToken(subject: UserInfo) -> str: - """그날 자정(KST)까지만 사는 접근 토큰 — 미니 블로그 메일의 "수정하기" 링크 전용. - - ★ 일반 로그인 세션과 다르다 — 사장님이 메일에서 그 글 하나를 고치러 들어오는 맥락에서만 - 쓰이고, 유효기간도 그만큼 짧다(2026-09-17, 사장님 지시: "로그인도 크레덴셜로 자동으로 - 되게 (그날까지만)"). refresh 토큰은 안 준다 — 그날이 지나면 다시 메일을 받아야 한다.""" + """그날 자정(KST)까지만 사는 접근 토큰 — 미니 블로그 메일의 "수정하기" 링크 전용.""" now_kst = datetime.now(timezone(timedelta(hours=9))) midnight_kst = (now_kst + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) expire_min = max(1, int((midnight_kst - now_kst).total_seconds() // 60)) @@ -103,8 +95,7 @@ def DecodeRefreshToken(jwt_token: str) -> UserInfo: return __decode_token(jwt_token, JWT_REFRESH_SECRET, EXCEPTION_REFRESH_TOKEN_EXPIRED) -# ---- Depends 용 토큰 검증기 ------------------------------------------------ -# 보호된 엔드포인트에서 dependencies=[Depends(IsValidAccessToken)] 로 사용. +# Depends 용 토큰 검증기 async def IsValidAccessToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo: return DecodeAccessToken(credentials.credentials) @@ -113,24 +104,21 @@ async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depend return DecodeRefreshToken(credentials.credentials) -# 최고관리자 이상(OWNER/DEVELOPER) 게이트. 회원관리·회사설정에 건다. +# 최고관리자 이상(OWNER/DEVELOPER) 게이트. async def RequireOwner(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo: if (user_info.role or 0) < UserRole.OWNER.value: raise EXCEPTION_FORBIDDEN return user_info -# 개발자(내부 운영) 전용 게이트. 회사 스코프를 넘어 전 고객사 데이터를 보는 /v1/admin 에만 건다. -# OWNER 는 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니라서 여기선 막힌다. +# 개발자(내부 운영) 전용 게이트. async def RequireDeveloper(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo: if (user_info.role or 0) < UserRole.DEVELOPER.value: raise EXCEPTION_FORBIDDEN return user_info -# ---- ResponseNone 처리 ----------------------------------------------------- -# 응답 객체에서 값이 None 인 필드를 재귀적으로 제거하여 페이로드를 줄인다. -# 모든 라우터는 return RemoveNoneResponse(await service....) 형태로 반환한다. +# ResponseNone 처리 def RemoveNoneValues(obj: Any) -> Any: if isinstance(obj, dict): return {k: RemoveNoneValues(v) for k, v in obj.items() if v is not None} @@ -140,7 +128,5 @@ def RemoveNoneValues(obj: Any) -> Any: def RemoveNoneResponse(obj) -> JSONResponse: - # mode="json": uuid/datetime 등 DB 네이티브 타입(asyncpg.UUID 포함)을 pydantic 단에서 - # JSON 안전한 문자열로 변환한다. content 가 이미 JSON-safe dict 이므로 표준 JSONResponse 사용 - # (ORJSONResponse 는 최신 FastAPI 에서 deprecated). + # mode="json": uuid/datetime 등 DB 네이티브 타입(asyncpg.UUID 포함)을 pydantic 단에서 JSON 안전한 문자열로 변환한다. return JSONResponse(content=RemoveNoneValues(obj.model_dump(mode="json"))) diff --git a/solution/backend/scheduler/__init__.py b/solution/backend/scheduler/__init__.py index 2549225..5836a14 100644 --- a/solution/backend/scheduler/__init__.py +++ b/solution/backend/scheduler/__init__.py @@ -1,22 +1,4 @@ -"""백그라운드 스케줄러(크론) 패키지 — '언제'(when) 담당. - -다중 워커(운영)에서 잡이 워커마다 중복 실행되면 안 되므로 SCHEDULER_ENABLED=1 인 프로세스에서만 등록한다. - -등록된 잡 — - · SNS 승인 만료·중단 복구 : 5분 간격 (scheduler/jobs.sweep_social_posts) - -붙을 잡 — -등록된 잡: - · Search Console (GSC_ENABLED=1, 10분마다) - · 알림 발송 스윕 (1분마다) — alert_outbox 의 PENDING 을 실제로 보낸다 - · 잡 큐 정체 점검 (5분마다) — dead-letter 누적·좀비 실행·오래 밀린 PENDING 을 본다 - 둘 다 무조건 등록한다 — TEAMS_WEBHOOK_URL 이 비어 있으면 알림은 쌓이기만 하고 안 나간다 - (services/teams_webhook.is_configured), 서버 동작에는 영향이 없다. - 붙을 잡 — - · 지역정보 갱신 : 축제 주 1회 / 관광정보 월 1회 / 날씨 시간 단위 — 행정구역 코드 단위 캐시 갱신 - · 수집 재시도 : 실패한 수집 작업 재시도 (외부 API 실패 시 직전 값 유지 + 내부 알림) - · 사이트 재빌드 : 검증 상태가 바뀐 place 만 개별 재빌드 (전체 재빌드 금지) -""" +"""백그라운드 스케줄러(크론) 패키지 — '언제'(when) 담당.""" import os from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -33,7 +15,7 @@ def _is_enabled() -> bool: def start_scheduler(): - """lifespan startup 에서 호출. SCHEDULER_ENABLED=1 일 때만 스케줄러를 띄운다.""" + """lifespan startup 에서 호출.""" global _scheduler if not _is_enabled(): LOG.i("[scheduler] disabled (SCHEDULER_ENABLED != 1)") @@ -41,12 +23,9 @@ def start_scheduler(): if _scheduler is not None: return - # 한국시간 기준. 잡은 scheduler/jobs.py 에 정의하고 여기서 add_job 으로 등록한다. + # 한국시간 기준. _scheduler = AsyncIOScheduler(timezone="Asia/Seoul") - # ★ 1분이 아니라 5분이다. 이 스윕이 하는 일은 "만료 표시" 와 "중단된 초안 정리" 뿐이라 - # 분 단위 정밀도가 필요 없고, 주기가 짧으면 쓰기 커넥션을 계속 집어 든다 — - # 실측(2026-09-14): 1분 주기로 두자 같은 컨테이너에서 도는 테스트가 커넥션을 못 받아 - # TimeoutError 로 무더기 실패했다. 운영에서도 같은 풀을 발행·수집과 나눠 쓴다. + # 1분이 아니라 5분이다. from scheduler.jobs import sweep_social_posts _scheduler.add_job(sweep_social_posts, 'interval', minutes=5, max_instances=1, coalesce=True) if os.environ.get("GSC_ENABLED") == "1": @@ -60,11 +39,6 @@ def start_scheduler(): _scheduler.add_job(sweep_queue_health, "interval", minutes=5, id="queue-health", max_instances=1, coalesce=True) # 미니 블로그 — 새벽에 재고를 채우고, 아침에 검수 통과분을 보낸다(docs/MINI_BLOG.md). - # LLM 키나 메일 설정이 없으면 두 잡 모두 아무 일도 안 하고 돌아온다. - # 자동 생성은 잠시 끈다 — 사장님이 빌더에서 '생성'을 눌러야 만들어지는 흐름으로 간다(2026-09-23). - # 되살리려면 위 import 에 sweep_blog_drafts 를 다시 넣고 아래 두 줄 주석을 푼다. - # _scheduler.add_job(sweep_blog_drafts, "cron", hour=4, minute=10, - # id="blog-drafts", max_instances=1, coalesce=True) _scheduler.add_job(sweep_blog_mail, "cron", hour=9, minute=0, id="blog-mail", max_instances=1, coalesce=True) _scheduler.start() diff --git a/solution/backend/scheduler/jobs.py b/solution/backend/scheduler/jobs.py index 720f999..7a0d341 100644 --- a/solution/backend/scheduler/jobs.py +++ b/solution/backend/scheduler/jobs.py @@ -1,8 +1,4 @@ -"""스케줄 잡 로직(what). '언제 도느냐'(scheduler/__init__.py)와 분리된, 잡이 실제로 하는 일. - -잡은 '대상을 고르는 것'까지만 하고, 실제 처리는 도메인 service 가 책임진다. -(지역정보 갱신 · 수집 재시도 · 개별 사이트 재빌드가 여기로 들어온다.) -""" +"""스케줄 잡 로직(what).""" from common.logger import LOG """예약 실행 진입점. 복구 전이는 DB 조건부 UPDATE로 여러 프로세스에서도 안전하다.""" @@ -22,12 +18,7 @@ async def sweep_alert_outbox(): async def sweep_queue_health(): - """잡 큐가 막혔는지 주기적으로 본다 — dead-letter 누적·좀비 실행·오래 밀린 PENDING. - - ★ 왜 필요한가: 개별 잡의 DEAD 전이는 worker/runner.py 가 그 자리에서 바로 알린다. 이건 - 그것과 다른 신호다 — 잡 하나하나는 재시도 중(아직 DEAD 아님)인데 **큐 전체가 정체**된 - 경우(워커 프로세스가 죽었거나 DB 순단이 길어지는 경우)는 개별 잡 알림만으로는 안 보인다. - ★ 복구되면 한 번만 알린다 — send_alert/resolve_alert 의 dedupe_key 가 그 판단을 한다.""" + """잡 큐가 막혔는지 주기적으로 본다 — dead-letter 누적·좀비 실행·오래 밀린 PENDING.""" from crud.job_crud import JobQueue from services import alert_service @@ -37,8 +28,7 @@ async def sweep_queue_health(): LOG.w(f"[scheduler] 큐 상태 조회 실패: {type(ex).__name__}: {ex}") return - # 기준값: dead-letter 가 최근 1시간에 쌓였거나, 좀비 실행이 있거나, 가장 오래된 PENDING 이 - # 30분 넘게 안 집혔다(정상 워커라면 대기 잡을 몇 초 안에 claim 한다). + # 기준값: dead-letter 가 최근 1시간에 쌓였거나, 좀비 실행이 있거나, 가장 오래된 PENDING 이 30분 넘게 안 집혔다(정상 워커라면 대기 잡을 몇 초 안에 claim 한다). problems = [] if snap.get("dead_1h", 0) > 0: problems.append(f"최근 1시간 dead-letter {snap['dead_1h']}건") diff --git a/solution/backend/scripts/apply_crawled_facts.py b/solution/backend/scripts/apply_crawled_facts.py index 97b2123..a10f404 100644 --- a/solution/backend/scripts/apply_crawled_facts.py +++ b/solution/backend/scripts/apply_crawled_facts.py @@ -1,4 +1,4 @@ -"""기존 크롤링 후보를 현재 기록 정책으로 다시 적용한다. --apply 없이는 조회만 한다.""" +"""기존 크롤링 후보를 현재 기록 정책으로 다시 적용한다.""" import argparse import asyncio import json diff --git a/solution/backend/scripts/backfill_thumbnails.py b/solution/backend/scripts/backfill_thumbnails.py index 0253be5..fb56cc0 100644 --- a/solution/backend/scripts/backfill_thumbnails.py +++ b/solution/backend/scripts/backfill_thumbnails.py @@ -1,17 +1,4 @@ -"""썸네일이 없는 발행 사이트에 썸네일을 채운다. - - python scripts/backfill_thumbnails.py (backend/ 에서 실행) - python scripts/backfill_thumbnails.py --dry-run (올리지 않고 대상만 본다) - python scripts/backfill_thumbnails.py --all (이미 있는 것도 다시 만든다) - -★ 왜 필요한가 — 썸네일은 **발행 잡이 끝날 때** 만들어진다(build_service). 그래서 이 기능이 - 들어오기 전에 발행된 사이트는 thumbnail_url 이 영영 NULL 이고, 쇼케이스에서 글자 카드로만 - 나온다. 재발행을 시키면 채워지지만 사장님 사이트를 우리 사정으로 다시 굽는 건 다른 일이다 - — 썸네일만 따로 만든다. - -★ 발행본 HTML 을 건드리지 않는다. 읽는 건 site_versions.snapshot 의 사진 목록뿐이고, - 쓰는 건 Blob 의 thumbs/ 와 sites.thumbnail_url 한 칸이다. -""" +"""썸네일이 없는 발행 사이트에 썸네일을 채운다.""" import argparse, asyncio, os, sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -29,7 +16,7 @@ _crud = SiteCRUD() def _targets(only_missing: bool): - """발행된 사이트 + 그 사이트의 현재 버전 스냅샷. 슬러그 계산에 places 가 필요하다.""" + """발행된 사이트 + 그 사이트의 현재 버전 스냅샷.""" stmt = ( select(sites, places, site_versions) .join(places, places.place_id == sites.place_id) @@ -49,7 +36,7 @@ def _targets(only_missing: bool): async def run(dry_run: bool, only_missing: bool) -> None: - # --dry-run 은 목록만 본다 — Azure 설정 없이도 대상이 맞는지 확인할 수 있어야 한다. + # -dry-run 은 목록만 본다 — Azure 설정 없이도 대상이 맞는지 확인할 수 있어야 한다. if not dry_run and not site_thumbnail.is_configured(): raise SystemExit( "AZURE_STORAGE_CONNECTION_STRING 이 없습니다 — 업로드할 곳이 없어 아무것도 하지 않습니다." @@ -80,7 +67,6 @@ async def run(dry_run: bool, only_missing: bool) -> None: continue if not url: - # 대표 사진이 없거나 받지 못했다. 정상적인 경우다 — 사진 없는 가게가 있다. skipped += 1 continue diff --git a/solution/backend/scripts/check_search_ready.py b/solution/backend/scripts/check_search_ready.py index 3678311..cf69125 100644 --- a/solution/backend/scripts/check_search_ready.py +++ b/solution/backend/scripts/check_search_ready.py @@ -1,20 +1,4 @@ -"""발행본이 **인터넷에서** 검색엔진에 읽힐 준비가 됐는지 확인한다. - - python scripts/check_search_ready.py (기본 https://web4ai.o2osolution.ai) - python scripts/check_search_ready.py https://web4ai.o2osolution.ai - python scripts/check_search_ready.py --slug butter (특정 사이트만) - -★ 왜 필요한가 — 색인 여부는 며칠~몇 주 뒤에나 알 수 있다. 그때까지 기다렸다가 - "robots.txt 가 안 올라가 있었다" 같은 걸 알게 되면 그 기간을 통째로 날린다. - 색인을 기다리지 않고 **지금 당장 확인할 수 있는 것**만 여기서 본다: - 크롤러가 접근할 수 있는가 · 읽을 파일이 그 자리에 있는가 · 내용이 들어 있는가. - -★ 로컬 out/ 이 아니라 **실제 도메인**을 친다. 로컬에 파일이 있어도 업로드가 안 됐거나 - 도메인이 안 붙었으면 검색엔진에는 없는 것과 같다 — 그 차이를 잡는 게 목적이다. - -★ 크롤러 UA 로도 받아본다. CDN·WAF 가 봇을 막는 설정이 기본값인 경우가 있어서, - 브라우저로는 열리는데 Googlebot 에게는 403 이 나가는 상태를 눈으로 볼 방법이 없다. -""" +"""발행본이 **인터넷에서** 검색엔진에 읽힐 준비가 됐는지 확인한다.""" import argparse import json import os @@ -76,10 +60,7 @@ def check_robots(client: httpx.Client, origin: str) -> None: def check_sitemap(client: httpx.Client, origin: str) -> list[str]: - """루트 사이트맵 — 이 호스트의 모든 사이트가 여기 한 파일에 들어 있다. - - ★ 사이트가 한 장짜리라 사이트맵 인덱스를 쓰지 않는다. 사이트별 sitemap.xml 을 두면 - URL 한 줄짜리 파일이 사이트 수만큼 생기고 크롤러 왕복만 두 배가 된다.""" + """루트 사이트맵 — 이 호스트의 모든 사이트가 여기 한 파일에 들어 있다.""" url = urljoin(origin, "/sitemap.xml") res = get(client, url) if res is None or res.status_code != 200: diff --git a/solution/backend/scripts/demo_build.py b/solution/backend/scripts/demo_build.py index 1c8266b..65e796a 100644 --- a/solution/backend/scripts/demo_build.py +++ b/solution/backend/scripts/demo_build.py @@ -1,9 +1,4 @@ -"""생성 사이트 데모 — 실 DB 에 데이터가 갖춰진 사업장을 만들고 빌드해서 HTML 을 파일로 뽑는다. - - python scripts/demo_build.py (backend/ 에서 실행) - -★ 실 DB(web4ai_db)에 데모 회사·계정·사업장을 만든다. 개발 DB 에서만 쓸 것. -""" +"""생성 사이트 데모 — 실 DB 에 데이터가 갖춰진 사업장을 만들고 빌드해서 HTML 을 파일로 뽑는다.""" import asyncio, os, sys, uuid sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) os.environ["APP_ENV"] = "local" @@ -122,8 +117,7 @@ async def main(): print("게이트 사유:", r.get("gate")) return - # ★ HTML 은 백엔드가 만들지 않는다. payload JSON 을 쓰는 것까지가 백엔드의 일이고, - # 그걸 정적 페이지로 굽는 것은 solution/site 의 렌더러다(그게 방문자가 보는 유일한 페이지). + # HTML 은 백엔드가 만들지 않는다. print(f"\npayload: {r.get('payload_path')}") print(f"페이지: {r.get('routes')}개 · JSON-LD 노드 {len(r.get('mismatches') or []) == 0 and '검증 통과' or '불일치'}") print("정적 파일: solution/site/out/s// (렌더러가 굽는다)") diff --git a/solution/backend/scripts/demo_pipeline.py b/solution/backend/scripts/demo_pipeline.py index 29b758e..697a3f0 100644 --- a/solution/backend/scripts/demo_pipeline.py +++ b/solution/backend/scripts/demo_pipeline.py @@ -1,10 +1,4 @@ -"""파이프라인 데모 — 실 DB(web4ai_db)에 사업장 하나를 만들어 끝까지 돌린다. - - python scripts/demo_pipeline.py (backend/ 에서 실행) - -★ 실 DB 에 데모 회사·계정·사업장을 만든다. 개발 DB 에서만 쓸 것. -★ 네이버 지역검색과 Perplexity 를 **실제로 호출**한다(요금 발생). -""" +"""파이프라인 데모 — 실 DB(web4ai_db)에 사업장 하나를 만들어 끝까지 돌린다.""" import asyncio, json, os, urllib.parse, urllib.request, uuid os.environ["APP_ENV"] = "local" diff --git a/solution/backend/scripts/export_openapi.py b/solution/backend/scripts/export_openapi.py index cf0131e..e5ce57f 100644 --- a/solution/backend/scripts/export_openapi.py +++ b/solution/backend/scripts/export_openapi.py @@ -1,17 +1,4 @@ -"""OpenAPI 스펙을 파일로 뽑는다 — 서버를 띄우지 않고. - - python scripts/export_openapi.py (backend/ 에서 실행) - python scripts/export_openapi.py -o /tmp/spec.json - -프론트의 API 클라이언트는 이 스펙에서 orval 이 생성한다. 서버가 떠 있으면 -`npm run orval -w admin` 이 http://localhost:9800/openapi.json 을 직접 읽지만, -서버(와 DB)를 띄우기 싫을 때가 더 많다 — 그때 이 파일을 쓴다: - - python scripts/export_openapi.py - cd ../frontend && ORVAL_INPUT=../../backend/openapi.json npm run orval -w admin - -★ 산출물(backend/openapi.json)은 커밋하지 않는다(.gitignore). 라우터가 유일한 소스다. -""" +"""OpenAPI 스펙을 파일로 뽑는다 — 서버를 띄우지 않고.""" import argparse import json import os @@ -29,7 +16,7 @@ def main() -> None: parser.add_argument("-o", "--out", default=DEFAULT_OUT, help=f"출력 경로 (기본: {DEFAULT_OUT})") args = parser.parse_args() - # import 시점에 app 이 만들어진다. DB 커넥션은 lifespan 에서 열리므로 여기선 DB 가 없어도 된다. + # import 시점에 app 이 만들어진다. import router.router spec = router.router.app.openapi() diff --git a/solution/backend/scripts/generate_lodging_catchphrases.py b/solution/backend/scripts/generate_lodging_catchphrases.py index a71a881..164f7e9 100644 --- a/solution/backend/scripts/generate_lodging_catchphrases.py +++ b/solution/backend/scripts/generate_lodging_catchphrases.py @@ -1,4 +1,4 @@ -"""숙박 공통 감성 문구를 한 번 생성한다. 방문 시에는 저장된 문구만 순환한다.""" +"""숙박 공통 감성 문구를 한 번 생성한다.""" import asyncio import json import sys diff --git a/solution/backend/scripts/import_yanolja_test_place.py b/solution/backend/scripts/import_yanolja_test_place.py index 731b2d2..76a0f26 100644 --- a/solution/backend/scripts/import_yanolja_test_place.py +++ b/solution/backend/scripts/import_yanolja_test_place.py @@ -1,31 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -야놀자 크롤링 결과를 테스트 place 하나에 적재하는 1회성 스크립트. -================================================================= - -★ 운영 파이프라인이 아니다 — services/collector/registry.py 에 등록하지 않고, - worker 잡큐에도 연결하지 않는다. 개발자가 수동으로 실행하는 CLI 전용. - (registry.py·static_html_adapter.py 의 야놀자 관련 구조적 차단은 그대로 유효하다 — - 이 스크립트는 그 결론을 뒤집지 않는다. 테스트 목적 한정, 실제 배포 전 별도 협의 필요.) - -동작 - 1. place_id 로 places 테이블에서 업장을 조회한다. 주소는 따로 입력받지 않고 - 그 업장의 road_address(없으면 address)를 그대로 검색어로 쓴다. - 2. verified_at 이 비어 있으면 중단한다(운영 run_collect 와 같은 가드 — - 동일 업소 검증 전에는 수집하지 않는다). - 3. scripts/yanolja_search_and_crawl.py 로 그 주소를 검색·크롤링한다. - 4. 결과를 CollectedFact/CollectedMedia 로 매핑해서, 기존 collect_service.py 의 - ensure_units/store_facts/store_media 를 그대로 호출한다 — FactService 를 그대로 - 통과하므로 새 값은 UNVERIFIED/PENDING_OWNER/PENDING_REVIEW 로만 만들어진다. - 이 상태를 이 스크립트가 직접 VERIFIED/APPROVED 로 바꾸는 일은 없다. - -사용법 (solution/backend 에서, 가상환경 안에서) - python scripts/import_yanolja_test_place.py --place-id (dry-run: 매핑만 출력) - python scripts/import_yanolja_test_place.py --place-id --commit (실제 DB 적재) - - PGSSLMODE=disable DB_PASSWORD=... 를 dev-env-quirks 메모대로 주입해야 한다(APP_ENV=local 기본). -""" +"""야놀자 크롤링 결과를 테스트 place 하나에 적재하는 1회성 스크립트.""" from __future__ import annotations import argparse @@ -35,8 +10,7 @@ import re import sys import uuid -# scripts/ 는 solution/backend 바로 아래이므로, backend 자체(부모 디렉터리)를 -# sys.path 에 넣어야 common/services 등을 import 할 수 있다. +# scripts/ 는 solution/backend 바로 아래이므로, backend 자체(부모 디렉터리)를 sys.path 에 넣어야 common/services 등을 import 할 수 있다. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) os.environ.setdefault("APP_ENV", "local") @@ -62,10 +36,7 @@ def _parse_capacity(capacity: str | None) -> tuple[str | None, str | None]: def stay_data_to_source(data: StayData) -> RawSource: - """StayData → RawSource(facts/media). lodging.json 의 scope=unit 필드 중 - 크롤러가 실제로 채울 수 있는 것만 매핑한다(room_type/standard_capacity/max_capacity 는 - required=true 라 반드시 채운다. weekday_price 등 요금은 크롤러가 안 모으므로 비워둔다). - """ + """StayData → RawSource(facts/media).""" facts: list[CollectedFact] = [] media: list[CollectedMedia] = [] diff --git a/solution/backend/scripts/migrate.py b/solution/backend/scripts/migrate.py index 4597674..036d8ee 100644 --- a/solution/backend/scripts/migrate.py +++ b/solution/backend/scripts/migrate.py @@ -1,23 +1,4 @@ -"""스키마 마이그레이션 — 이미 만들어진 DB 를 init.sql 최신으로 끌어올린다. - - cd solution/backend && .venv/bin/python scripts/migrate.py - cd solution/backend && .venv/bin/python scripts/migrate.py --dry-run - -★ 왜 필요한가 (2026-09-09) - `init-data/init.sql` 은 **DB 를 처음 만들 때만** 돈다(postgres 이미지의 초기화 훅). - 그래서 파일에 컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다. - 실제로 로컬 DB 에 `local.place_contents` 테이블과 `place.places.external_category` - 컬럼이 없었고, TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없어 축제·맛집이 0건이었다. - 화면에는 "그냥 안 나오는 것"으로만 보여서 원인을 짚는 데 한참 걸렸다. - DECISIONS.md 가 예고한 그대로다 — "운영 DB 가 생기는 순간 다시 필요해진다". - -★ Alembic 을 쓰지 않는다. 이 레포는 ORM 과 init.sql 두 곳에 스키마를 두고 - `tests/test_schema_ddl.py` 로 대조하는 구조다. 거기에 세 번째 정의(Alembic 리비전)를 - 더하면 어긋날 자리가 하나 더 생긴다. 필요한 건 "안 돌린 SQL 을 순서대로 돌린다" 뿐이다. - -★ 적용 기록은 `public.schema_migrations` 에 남는다. 이미 있는 번호는 건너뛴다. - 파일은 재실행 안전하게 쓰므로(IF NOT EXISTS), 기록이 날아가도 다시 돌리면 그만이다. -""" +"""스키마 마이그레이션 — 이미 만들어진 DB 를 init.sql 최신으로 끌어올린다.""" import argparse import asyncio import os @@ -34,7 +15,6 @@ from common.enums import DBWRType # noqa: E402 from common.database.model.models import places # noqa: E402 # parents[3] = 레포 루트 (scripts → backend → solution → 루트). -# ★ test_schema_ddl.py 와 같은 계산이다 — 폴더를 옮기면 둘 다 고친다. MIGRATIONS_DIR = Path(__file__).resolve().parents[3] / "postgres-init" / "migrations" _LEDGER_DDL = """ @@ -46,7 +26,7 @@ CREATE TABLE IF NOT EXISTS public.schema_migrations ( def pending(applied: set[str]) -> list[Path]: - """아직 안 돌린 파일. 파일명 순서가 곧 적용 순서다.""" + """아직 안 돌린 파일.""" files = sorted(p for p in MIGRATIONS_DIR.glob("*.sql")) return [p for p in files if p.stem not in applied] @@ -79,12 +59,7 @@ async def main(dry_run: bool) -> int: sql = path.read_text(encoding="utf-8") print(f"\n▶ {path.stem}") try: - # ★ 파일 하나를 한 트랜잭션으로 돌린다 — 중간에 실패하면 그 파일은 통째로 되돌아간다. - # 반쯤 적용된 파일이 기록에 남으면 다음 실행이 그것을 건너뛴다. - # ★ asyncpg 드라이버 커넥션으로 직접 보낸다. SQLAlchemy 의 text() 는 prepared - # statement 가 되는데, asyncpg 는 거기에 문장을 여러 개 못 넣는다 - # ("cannot insert multiple commands into a prepared statement"). - # 마이그레이션 파일은 본래 여러 문장이라 이 경로가 맞다. + # 파일 하나를 한 트랜잭션으로 돌린다 — 중간에 실패하면 그 파일은 통째로 되돌아간다. raw = await (await db.connection()).get_raw_connection() await raw.driver_connection.execute(sql) await db.execute( diff --git a/solution/backend/scripts/pin_gunsan_hanilok.py b/solution/backend/scripts/pin_gunsan_hanilok.py index 916c225..04f29ef 100644 --- a/solution/backend/scripts/pin_gunsan_hanilok.py +++ b/solution/backend/scripts/pin_gunsan_hanilok.py @@ -1,25 +1,4 @@ -"""군산 공통 맛집 한일옥 등록. 기본은 조회, --apply로 현재 설정 DB에 반영한다. - -네이버 ID는 body에 보관하여 자동 수집(NAVER_CRAWL)의 (source, external_id) 갱신과는 분리한다 -— source 가 OFFICIAL_WEB 으로 다르므로 자동 크롤링이 이 행을 건드리지 않는다. - -★ 거리(2026-09-17 추가): 처음엔 external_id 없이 "지역 공통"(모든 군산 업장에 거리 없이 노출) - 으로만 등록했다. 하지만 한일옥은 실존 업소라 업장마다 실제 거리가 다르고, 지역 공통 캐시 - 경로(services/snapshot.py::_local_contents, external_id IS NULL)는 거리를 업장마다 못 담는 - 설계라 거리가 안 나갔다(2026-09-17 확인). 그래서 external_id 를 네이버 place id 로 채워 - 그 경로에서 빠지게 하고, TourAPI·NAVER_CRAWL 맛집과 같은 개인화 경로(place_area_refs + - site_sections, services/local_restaurant_enrichment.py 와 동일한 패턴)로 업장별 거리를 얹는다. - ★ 대가: 더는 "새 군산 업장에 자동으로 붙는" 지역 공통이 아니다 — 새 업장이 생기면 - 이 스크립트를 다시 돌려야 그 업장에도 한일옥이 연결된다. - -사용법 (solution/backend 에서, 가상환경 안에서) — 호스트(Windows) 실행은 PGSSLMODE=disable 필수 -(한글 홈 경로 탓에 asyncpg 인증서 로딩이 깨진다, dev-env-quirks 메모): - PowerShell: $env:PGSSLMODE = "disable"; python scripts/pin_gunsan_hanilok.py [--apply] - -배포서버 실행방법 - docker compose exec solution-backend python scripts/pin_gunsan_hanilok.py # 드라이런 먼저 - docker compose exec solution-backend python scripts/pin_gunsan_hanilok.py --apply # 반영 -""" +"""군산 공통 맛집 한일옥 등록.""" import argparse import asyncio import json @@ -60,8 +39,7 @@ def _as_float(value) -> float | None: async def _fetch_coordinates() -> tuple[float, float] | None: - """한일옥 좌표. TourAPI 에 없는 수기 등록이라 네이버 상세 페이지에서 가져온다 - (services/local_restaurant_enrichment.py 가 자동 크롤링 맛집에 쓰는 것과 같은 어댑터).""" + """한일옥 좌표.""" summary = await NaverPlaceAdapter().fetch_summary(naver_place_lookup.place_url(NAVER_ID)) if not summary: return None @@ -98,7 +76,7 @@ async def main(apply: bool): values = dict( local_content_id=content_id, region_code=REGION, content_type=LocalContentType.RESTAURANT.value, kind='restaurant', - # 사용자 확인을 거친 수기 웹 등록. 크롤링한 값으로 표시하지 않는다. + # 사용자 확인을 거친 수기 웹 등록. source=LocalSource.OFFICIAL_WEB.value, external_id=NAVER_ID, title='한일옥', body={**(rows[0]['body'] if rows else {}), 'name': '한일옥', 'searchQuery': '군산 한일옥', @@ -152,8 +130,7 @@ async def main(apply: bool): }, default=str, ensure_ascii=False)) if apply: - # ★ 업장마다 다른 거리라 사이트 개인화 맵(site_sections)에도 얹어야 캔버스·발행본이 읽는다 - # (services/local_content_service.py::_write_site_places 규약과 동일). + # 업장마다 다른 거리라 사이트 개인화 맵(site_sections)에도 얹어야 캔버스·발행본이 읽는다 (services/local_content_service.py::_write_site_places 규약과 동일). service = LocalContentService() for row in targets: place_id = row['place_id'] diff --git a/solution/backend/scripts/republish_all.py b/solution/backend/scripts/republish_all.py index bf65ec3..0b27c77 100644 --- a/solution/backend/scripts/republish_all.py +++ b/solution/backend/scripts/republish_all.py @@ -1,22 +1,4 @@ -"""발행된 사이트 **전부**를 Azure Blob(`$web`)에 다시 올린다. - - python scripts/republish_all.py (backend/ 에서 실행) - python scripts/republish_all.py --dry-run (올리지 않고 목록만 본다) - -★ 왜 필요한가 — 렌더러(solution/site)를 고쳐 배포하면 번들 파일명이 바뀐다 - (`assets/index-DvNTmLhy.css` → `assets/index-<새해시>.css`). 그런데 평소 업로드 경로 - (services/azure_static.publish)는 **방금 발행한 사이트 하나**만 올린다. 그래서 - 나머지 사이트의 HTML 은 Blob 에 옛 해시를 가리킨 채로 남는다. 옛 자산 블롭은 지워지지 - 않으니 화면이 깨지진 않지만, **디자인 수정이 그 사이트들에 영영 도달하지 않는다.** - 프리렌더는 기동할 때 out/ 을 전부 다시 굽는다(watch-payloads.mjs) — 그 결과를 - Blob 으로 밀어 넣는 짝이 없었다. 이 스크립트가 그 짝이다. - -★ 순서: 프리렌더가 out/ 을 다 구운 **뒤에** 돌린다. 굽는 중에 돌리면 반쯤 구워진 - HTML 이 올라간다. - -★ 공용 자산(assets/·fonts/·robots.txt·sitemap.xml)은 한 번만 올린다. - azure_static.publish 를 사이트마다 부르면 수백 KB 번들을 사이트 수만큼 다시 올린다. -""" +"""발행된 사이트 **전부**를 Azure Blob(`$web`)에 다시 올린다.""" import argparse, os, sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -63,9 +45,7 @@ def main() -> None: shared = azure_static._upload_shared(container, root, prefix) print(f"[republish] 공용 {len(shared)}개 (컨테이너 {container_name} · 접두사 {prefix or '(없음)'})") - # ── 사이트별 ── - # 한 사이트가 실패해도 나머지는 계속 올린다. 여기서 멈추면 절반만 새 번들을 가리키는 - # 어중간한 상태로 남는다 — 어디까지 됐는지 로그로 남기고 끝까지 간다. + # ── 사이트별 ── 한 사이트가 실패해도 나머지는 계속 올린다. failed: list[tuple[str, str]] = [] for i, slug in enumerate(slugs, 1): try: diff --git a/solution/backend/scripts/search_console_status.py b/solution/backend/scripts/search_console_status.py index 518195d..b1d43ea 100644 --- a/solution/backend/scripts/search_console_status.py +++ b/solution/backend/scripts/search_console_status.py @@ -1,4 +1,4 @@ -"""저장된 관측값만 출력한다. Google 호출/색인 요청은 하지 않는다.""" +"""저장된 관측값만 출력한다.""" import asyncio import json import sys diff --git a/solution/backend/scripts/verify_yanolja_test_place.py b/solution/backend/scripts/verify_yanolja_test_place.py index 72a3653..15e7ca0 100644 --- a/solution/backend/scripts/verify_yanolja_test_place.py +++ b/solution/backend/scripts/verify_yanolja_test_place.py @@ -1,17 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -테스트 place에 야놀자 크롤링으로 넣은 객실 fact/사진을 **수동으로 검수·승인**해서, -실제 미리보기 렌더러(site_payload.to_site_payload → UnitsSection)에 뜨는지 확인하는 -1회성 스크립트. - -★ 이것도 운영 파이프라인이 아니다 — 사람이 눈으로 확인하려고 이번 테스트 place 하나에만 - 쓰는 수동 검수 도구다. 실제 서비스에서 크롤링 값을 이렇게 자동 승인하면 안 된다 - (fact는 사람이 [맞아요]를 눌러야, 사진은 Vision/사람이 봐야 승인 상태가 된다). - -사용법 (solution/backend 에서, 가상환경 안): - PGSSLMODE=disable DB_PASSWORD=... python scripts/verify_yanolja_test_place.py --place-id -""" +"""테스트 place에 야놀자 크롤링으로 넣은 객실 fact/사진을 **수동으로 검수·승인**해서, 실제 미리보기 렌더러(site_payload.to_site_payload → UnitsSection)에 뜨는지 확인하는 1회성 스크립트.""" from __future__ import annotations import argparse diff --git a/solution/backend/scripts/yanolja_search_and_crawl.py b/solution/backend/scripts/yanolja_search_and_crawl.py index 7b73882..03dcc14 100644 --- a/solution/backend/scripts/yanolja_search_and_crawl.py +++ b/solution/backend/scripts/yanolja_search_and_crawl.py @@ -1,35 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -야놀자(NOL) 주소 검색 + 숙소 상세페이지 크롤러 -========================================== - -★ 운영 collector 파이프라인(services/collector/registry.py)에 등록되지 않은 수동 도구다. - registry.py·static_html_adapter.py 에 야놀자를 구조적으로 막아둔 이유(HTTP 403 회피 - 목적의 봇 탐지 우회 금지, 재게시 관련 민사 판례)는 그대로 유효하다. 이 스크립트는 - 개발자가 필요할 때 수동으로만 실행하는 진단·백필용 도구이며, worker/collect_service - 에서 자동으로 호출되지 않는다. - -동작 순서 - 1. https://nol.yanolja.com/ 접속 - 2. 검색창에 주소를 입력하고 검색 실행 - 3. 검색결과 리스트에서 첫번째 업체(숙소)의 상세페이지 href 를 읽어 바로 이동 - 4. 이동한 상세페이지에서 객실/숙소소개/시설·서비스/이용안내/예약공지 + 객실별 사진을 크롤링 - -사전 준비 - pip install playwright - playwright install chromium - -사용법 (solution/backend 에서) - python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" -o result.json - python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" --print - python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" --headful - -주의 - - 검색 페이지, 상세페이지 모두 Next.js 기반 CSR 페이지라서 Playwright로 - 실제 브라우저를 띄워 렌더링을 끝낸 뒤 DOM에서 데이터를 추출한다. - - 페이지 구조(클래스명 등)는 야놀자 쪽에서 언제든 바뀔 수 있다. -""" +"""야놀자(NOL) 주소 검색 + 숙소 상세페이지 크롤러""" from __future__ import annotations @@ -44,10 +15,9 @@ from playwright.sync_api import Page, TimeoutError as PWTimeoutError, sync_playw SEARCH_URL = "https://nol.yanolja.com/" -# 검색결과 카드는 +# 검색결과 카드는 tuple[str, Optional[str]]: - """주소로 검색한 뒤, 첫번째 검색결과 카드의 href를 추출해서 상세페이지로 이동한다. - - 카드를 클릭하지 않고 href를 직접 읽어 page.goto()로 이동한다. 클릭 시 - 새 탭이 뜨거나 배너/모달에 클릭이 가로채이는 문제를 피하기 위함이다. - """ + """주소로 검색한 뒤, 첫번째 검색결과 카드의 href를 추출해서 상세페이지로 이동한다.""" page.goto(SEARCH_URL, wait_until="domcontentloaded") search_box = page.get_by_role("combobox", name="검색어 입력") @@ -147,19 +113,13 @@ def _get_section_text(page: Page, element_id: str) -> str: return "" -# img src 중 실제 사진(/v5/.../*.jpg 형태)만 남기고 UI 아이콘(static/images/... 의 침대·와이파이 -# 아이콘, no-image 플레이스홀더 등)은 제외한다. +# img src 중 실제 사진(/v5/.../*.jpg 형태)만 남기고 UI 아이콘(static/images/... def _is_real_photo_url(src: str) -> bool: return bool(src) and "static/images" not in src def _get_room_images_by_name(page: Page, element_id: str) -> list[tuple[str, list[str]]]: - """PLACE_SECTION 안에서 각 객실 카드의 사진을, 그 카드의

(객실명) 앞에 나오는 - 들로 묶어 [(객실명, [사진 URL, ...]), ...] 순서대로 돌려준다. - - 카드 구조가 "사진 캐러셀 →

객실명

→ 설명" 순이라, h2 를 만나기 전까지 - 쌓인 이미지가 그 h2 의 몫이다. - """ + """PLACE_SECTION 안에서 각 객실 카드의 사진을, 그 카드의

(객실명) 앞에 나오는 들로 묶어 [(객실명, [사진 URL, ...]), ...] 순서대로 돌려준다.""" try: page.evaluate( f"""() => {{ @@ -225,31 +185,7 @@ def _get_stay_name(page: Page) -> Optional[str]: def _parse_rooms_from_section_text(section_text: str) -> list[RoomInfo]: - """ - PLACE_SECTION.innerText 는 대략 아래 패턴이 객실 카드 수만큼 반복됩니다: - - 1 - / - 10 - B동 - (모던한 현대식 컨셉으로 꾸며진 따뜻한 공간) - 기준 2인 / 최대 4인 - 킹 침대 1개 - 숙박 - 체크인 - 15:00 - ~ 체크아웃 - 11:00 - 취소 및 환불 불가 - 상세보기 - 198,000 - 원 - NOL 머니 결제 시 최대 3,960P 적립 - 예약하기 - - 우리가 필요한 건 이름/설명/기준·최대인원/침대 뿐이므로 가격 이하는 무시한다. - 사이트 구조가 바뀌면 이 정규식도 함께 손봐야 한다. - """ + """PLACE_SECTION.innerText 는 대략 아래 패턴이 객실 카드 수만큼 반복됩니다:""" rooms: list[RoomInfo] = [] # "N / M" (사진 장수) 로 카드 시작 지점을 나눈다 chunks = re.split(r"\n?\d+\s*\n/\n\d+\n", section_text) @@ -293,8 +229,7 @@ def crawl_stay(page: Page, url: str) -> StayData: rooms_text = _get_section_text(page, SECTION_IDS["rooms"]) data.rooms = _parse_rooms_from_section_text(rooms_text) - # 카드 순서대로 이미지를 묶어서 뽑은 뒤, 순서가 맞아떨어지면 그대로 붙이고 - # (카드 수가 어긋나면 사이트 구조가 달라진 것이니) 이름이 같은 것끼리 다시 맞춘다. + # 카드 순서대로 이미지를 묶어서 뽑은 뒤, 순서가 맞아떨어지면 그대로 붙이고 (카드 수가 어긋나면 사이트 구조가 달라진 것이니) 이름이 같은 것끼리 다시 맞춘다. room_images = _get_room_images_by_name(page, SECTION_IDS["rooms"]) if len(room_images) == len(data.rooms): for room, (_, images) in zip(data.rooms, room_images): diff --git a/solution/backend/services/agent/channel.py b/solution/backend/services/agent/channel.py index a2bacaf..bac43e7 100644 --- a/solution/backend/services/agent/channel.py +++ b/solution/backend/services/agent/channel.py @@ -1,15 +1,4 @@ -"""메신저 대화 한 턴 — 신원 · 가게 고르기 · 확인 이어받기. - -★★ **카카오를 모른다.** `version: "2.0"` · `simpleText` 같은 형식은 한 글자도 여기 없다. - 그건 `router/v1/agent/kakao_bot.py` 안에서 끝난다 — 새면 다른 채널을 붙일 때 전부 - 걷어내야 하고, 알림톡 어댑터에 건 것과 같은 규칙이다. - -★ 빌더 화면과 무엇이 다른가 — 셋뿐이다. - 1. 로그인 토큰이 없다 → 연결된 발화자 키로 사장님을 찾는다 - 2. place_id 가 URL 에 없다 → 대화에서 고르고 기억한다 - 3. 확인을 되돌려 줄 프론트가 없다 → 무엇을 물었는지 서버가 들고 있는다 - 나머지(도구·등급·게이트)는 `runtime.chat()` 그대로다. -""" +"""메신저 대화 한 턴 — 신원 · 가게 고르기 · 확인 이어받기.""" import re import uuid @@ -36,11 +25,10 @@ from services.kakao_link_service import KakaoLinkError # 연결 코드 모양(kakao_link_service._CODE_ALPHABET 과 같은 글자 집합). CODE_PATTERN = re.compile(r"[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}") -# 확인 대기 수명. ★ 이게 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다. +# 확인 대기 수명. PENDING_MINUTES = 3 -# ★ 바로가기 라벨과 '예' 로 읽는 말이 어긋나면 **눌러도 안 먹는다** — 사장님은 버튼이 -# 고장난 줄 안다. 라벨을 상수로 두고 _YES 가 그것을 포함하게 묶는다. +# 바로가기 라벨과 '예' 로 읽는 말이 어긋나면 **눌러도 안 먹는다** — 사장님은 버튼이 고장난 줄 안다. CONFIRM_LABEL = "네, 해주세요" PUBLISH_LABEL = "네, 발행해주세요" DECLINE_LABEL = "아니요" @@ -48,8 +36,7 @@ DECLINE_LABEL = "아니요" _YES = {CONFIRM_LABEL, PUBLISH_LABEL, "네", "예", "응", "그래", "네 해주세요", "해주세요", "좋아", "ㅇㅇ", "확인"} _NO = {DECLINE_LABEL, "아니", "아니오", "안할래", "취소", "나중에", "ㄴㄴ"} -# 언제든 목록으로 돌아오는 말. ★ LLM 을 부르지 않는다 — 목록 보기에 돈을 쓸 이유가 없고, -# "지금 어느 가게냐" 는 대화가 막혔을 때 가장 먼저 찾는 길이라 늘 통해야 한다. +# 언제든 목록으로 돌아오는 말. _LIST_WORDS = { "목록", "가게 목록", "사이트 목록", "내 사이트", "홈페이지 목록", "가게 바꿔줘", "가게 변경", "다른 가게", "사이트 바꿔줘", "사이트 변경", @@ -66,17 +53,13 @@ def _say(text: str, quick: list[str] | None = None) -> dict: async def _user_info(user_id) -> UserInfo | None: - """user_id → UserInfo. ★ 토큰을 발급하지 않는다. - - 프로세스 안에서 쓸 객체만 만든다 — 카톡 경로에서 JWT 가 나오면 그게 곧 권한 탈취 - 경로다(docs/AGENT.md).""" + """user_id → UserInfo.""" async def run(s): row = (await s.execute(select(users).where(users.user_id == user_id, users.deleted.is_(False)))).scalars().first() return ErrorType.SUCCESS, row - # ★ execute_lambda 는 람다 반환값을 **그대로** 준다. CRUD 관례(ErrorType, 값)를 따라 - # 우리 람다도 같은 모양으로 돌려준다 — 안 맞추면 여기서 TypeError 로 조용히 죽는다. + # execute_lambda 는 람다 반환값을 **그대로** 준다. err, row = await DB_SESSION_MNG.execute_lambda(users.DBType(), DBWRType.DB_READ.value, run) if err != ErrorType.SUCCESS or row is None: return None @@ -125,19 +108,14 @@ async def _clear_pending(key): async def _sites(user: UserInfo) -> list: - """사장님의 가게 + 그 사이트 상태를 한 번에. - - ★ 사업장 목록이 아니라 **사이트 목록**을 쓴다. 대화에서 사장님이 알아야 하는 것은 - "가게가 있다" 가 아니라 "발행돼 있나 · 주소가 뭔가" 다 — `/sites` 화면이 같은 이유로 - `list_my_sites` 를 쓴다.""" + """사장님의 가게 + 그 사이트 상태를 한 번에.""" service = SiteService(SiteCRUD(), PlaceCRUD(), JobQueue()) res = await service.list_my_sites(user, PageParams(page=1, size=20)) return list(res.sites or []) def _line(row) -> str: - """목록 한 줄. ★ 발행 여부를 같이 말한다 — 안 그러면 사장님은 고친 것이 손님에게 - 보이는 줄 안다.""" + """목록 한 줄.""" if row.status == SiteStatus.PUBLISHED and row.published_at: when = row.published_at.strftime("%m월 %d일") return f"· {row.name} — {when} 발행" @@ -152,17 +130,12 @@ def _list_reply(rows: list, head: str) -> dict: async def _pick_place(user: UserInfo, row, utterance: str): - """어느 가게 이야기인지 정한다. - - ★ 여럿인데 안 정해졌으면 **되묻는다.** 임의로 첫 가게를 고르면, 사장님은 엉뚱한 가게를 - 고쳐 놓고도 그 사실을 모른다 — 화면과 달리 대화에는 "지금 보고 있는 가게" 가 없다. - - 반환: (place_id, 되물을 답 or None)""" + """어느 가게 이야기인지 정한다.""" rows = await _sites(user) if not rows: return None, _say("아직 등록된 가게가 없어요. 홈페이지를 먼저 만들어 주세요.") - # ★ 언제든 목록으로 돌아올 수 있어야 한다. 대화가 막혔을 때 처음 찾는 길이다. + # 언제든 목록으로 돌아올 수 있어야 한다. if utterance in _LIST_WORDS: await _update_link(row.channel_user_key, current_place_id=None, pending_tool=None, pending_args=None, pending_expires_at=None) @@ -188,7 +161,7 @@ async def _pick_place(user: UserInfo, row, utterance: str): async def handle(utterance: str, channel_user_key: str) -> dict: - """대화 한 턴. 예외를 던지지 않는다 — 메신저에서는 500 도 침묵으로 보인다.""" + """대화 한 턴.""" utterance = (utterance or "").strip() if not utterance: return _say("무엇을 도와드릴까요?") @@ -203,11 +176,10 @@ async def handle(utterance: str, channel_user_key: str) -> dict: try: user_id = await link_service.redeem(utterance, channel_user_key) except KakaoLinkError: - # ★ 없는 코드·만료·시도 초과를 구분해 답하지 않는다(kakao_link_service 주석). + # 없는 코드·만료·시도 초과를 구분해 답하지 않는다(kakao_link_service 주석). return _say("코드가 맞지 않거나 시간이 지났어요. 새 코드를 받아 다시 보내 주세요.") - # ★ 연결만 알리고 끝내지 않는다. 사장님은 **어느 홈페이지를 다루는 대화인지** 모른 채 - # 말을 걸게 되고, 가게가 둘 이상이면 첫 마디부터 되묻기에 걸린다. + # 연결만 알리고 끝내지 않는다. user = await _user_info(user_id) rows = await _sites(user) if user else [] if not rows: @@ -224,8 +196,6 @@ async def handle(utterance: str, channel_user_key: str) -> dict: if user is None: return _say("계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.") - # ★ 이미 연결된 사람이 코드를 또 보내는 일이 실제로 있었다(2026-09-22). 그대로 두면 - # 6자리가 그냥 발화로 모델에 넘어가 유료 호출 + 대기만 쌓인다 — 여기서 끊는다. if CODE_PATTERN.fullmatch(utterance.upper()): return _say("이미 연결되어 있어요. 바로 말씀하시면 됩니다.\n예) 체크인 시간 3시로 바꿔줘") @@ -234,7 +204,7 @@ async def handle(utterance: str, channel_user_key: str) -> dict: if row.pending_tool and row.pending_expires_at and row.pending_expires_at > _now(): pending = {"tool": row.pending_tool, "args": row.pending_args or {}} elif row.pending_tool: - # 만료. 조용히 흘리지 않고 치운다 — 남아 있으면 다음 "네" 가 그걸 집는다. + # 만료. await _clear_pending(channel_user_key) if pending is not None: @@ -245,7 +215,7 @@ async def handle(utterance: str, channel_user_key: str) -> dict: if utterance in _NO: await _clear_pending(channel_user_key) return _say("알겠습니다. 그대로 두겠습니다.") - # 다른 말을 했으면 그 말이 우선이다. 묵은 확인을 들고 있지 않는다. + # 다른 말을 했으면 그 말이 우선이다. await _clear_pending(channel_user_key) # ── 가게 고르기 ────────────────────────────────────────────────────── diff --git a/solution/backend/services/agent/runtime.py b/solution/backend/services/agent/runtime.py index ee1e352..ec1022c 100644 --- a/solution/backend/services/agent/runtime.py +++ b/solution/backend/services/agent/runtime.py @@ -1,15 +1,4 @@ -"""에이전트 런타임 — 발화 → 도구 선택 → 실행 → 응답. - -★★ **채널을 모른다.** 빌더 화면에서 왔는지 카카오톡에서 왔는지 알 필요가 없다. - 이걸 웹훅 핸들러 안에 짜면 빌더에서 같은 걸 못 쓰고, 카카오 심사가 끝나야 - 무엇 하나 검증되지 않는다(docs/AGENT.md). - -★ 확인이 필요한지는 **레지스트리의 등급**이 정한다. 모델이 정하게 두면 프롬프트에 - 끼어든 한 줄이 확인 절차를 건너뛴다. - -★ 실행 결과 문구는 도구가 만든다(tools.py). LLM 문장은 '되묻기' 에만 쓴다 — - 모델이 결과를 쓰면 하지 않은 일을 했다고 말할 수 있다. -""" +"""에이전트 런타임 — 발화 → 도구 선택 → 실행 → 응답.""" import uuid @@ -32,30 +21,23 @@ from services.llm.errors import LlmError from services.prompts import agent as prompt from common.logger import LOG -# 발화 길이 상한. 프롬프트 비용은 입력 토큰에 비례하고, 사장님이 한 번에 치는 말은 길지 않다. +# 발화 길이 상한. MAX_MESSAGE = 500 -# 도구 선택은 짧은 프롬프트라 빠르다. 카카오 웹훅의 5초 벽 안에 들어가야 한다(docs/AGENT.md). +# 도구 선택은 짧은 프롬프트라 빠르다. REQUEST_TIMEOUT = httpx.Timeout(20.0, connect=5.0) class AgentError(RuntimeError): - """라우터가 HTTP 로 옮길 도메인 예외. 코드 문자열만 담는다(social 과 같은 규약).""" + """라우터가 HTTP 로 옮길 도메인 예외.""" def is_configured() -> bool: - """대화창을 열 수 있나 — 스위치와 LLM 키를 **둘 다** 본다. - - ★ 스위치(`AGENT_CHAT_ENABLED`)와 키를 **둘 다** 보는 이유: 키만 보면 "잠시 닫아 두기" 를 - 키를 지워서 해야 하는데 그러면 소개문·사진분류까지 같이 꺼진다. 스위치만 보면 - 키 없는 환경에서 **눌러도 안 되는 입구**가 생긴다. - 실제로 2026-09-21 에 카카오 채널 보류로 한 번 닫았고, 채널 인증이 끝나 다시 열었다.""" + """대화창을 열 수 있나 — 스위치와 LLM 키를 **둘 다** 본다.""" return config.chat_enabled() and provider.active().is_configured() async def _load_place(user: UserInfo, place_id: str): - """★ 소유자 범위. 없는 것과 남의 것을 똑같이 PLACE_NOT_FOUND 로 답한다(레포 관례). - - 에이전트가 이 관례를 벗어나면 대화창이 소유자 스코프를 우회하는 유일한 입구가 된다.""" + """소유자 범위.""" err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, @@ -67,23 +49,21 @@ async def _load_place(user: UserInfo, place_id: str): async def _context_facts(user: UserInfo, place_id: str, place) -> list[dict]: - """모델에게 줄 '지금 값'. 이게 없으면 "3시로 바꿔줘" 가 무엇을 바꾸는지 모델이 모른다.""" + """모델에게 줄 '지금 값'.""" res = await FactService(FactCRUD(), PlaceCRUD()).list_facts(user, place_id, publishable_only=True) schema = get_schema(PlaceCategory(place.category)) out = [] for f in (res.facts or []): spec = schema.get(f.key) if spec and spec.scope == "place" and (f.value or "").strip(): - # ★ label 은 싣지 않는다 — 아래 '항목 목록' 에 이미 key↔label 이 있다. - # 같은 표를 두 번 보내면 프롬프트만 커지고 모델이 얻는 것은 없다. + # label 은 싣지 않는다 — 아래 '항목 목록' 에 이미 key↔label 이 있다. out.append({f.key: f.value}) - # ★ 상한을 둔다. 실측(2026-09-22): 필드 43 + fact 수십 개가 실린 프롬프트가 5초 벽을 - # 넘겼다. 무한정 싣지 않는다 — 대화 한 턴에 필요한 맥락은 그렇게 많지 않다. + # 상한을 둔다. return out[:30] async def _choose(place, fields, facts, message) -> dict: - """LLM 한 번. 고른 도구 이름과 인자만 받는다.""" + """LLM 한 번.""" active = provider.active() async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: result = await active.generate( @@ -103,12 +83,7 @@ async def _choose(place, fields, facts, message) -> dict: async def chat(user: UserInfo, place_id: str, message: str, confirm: dict | None = None) -> dict: - """대화 한 번. - - confirm 이 오면 LLM 을 부르지 않는다 — 사장님이 직전에 본 확인 문구에 '네' 를 누른 것이고, - 그 문장이 가리키는 도구를 그대로 실행한다. **인자는 다시 검증한다** — 화면에서 온 값을 - 믿고 실행하면, 확인 절차가 오히려 검증을 건너뛰는 구멍이 된다. - """ + """대화 한 번.""" message = (message or "").strip() if confirm is None and not message: raise AgentError("AGENT_EMPTY_MESSAGE") @@ -129,8 +104,7 @@ async def chat(user: UserInfo, place_id: str, message: str, confirm: dict | None fields = registry.fields_of(place) facts = await _context_facts(user, place_id, place) - # ★ 사이트 상태는 프롬프트에 싣지 않는다. 그 한 줄 때문에 매 턴 사이트 조회 + 슬러그 계산이 - # 돌았고, 정작 모델이 필요할 때는 `get_site_status` 도구를 부르면 된다. + # 사이트 상태는 프롬프트에 싣지 않는다. try: choice = await _choose(place, fields, facts, message) @@ -141,7 +115,6 @@ async def chat(user: UserInfo, place_id: str, message: str, confirm: dict | None name = (choice.get("tool") or "").strip() tool = registry.REGISTRY.get(name) if tool is None: - # ★ 모르는 이름을 지어냈거나 모델이 되묻기를 골랐다. 둘 다 '실행하지 않는다' 로 같다. return { "reply": (choice.get("message") or "").strip() or "무엇을 도와드릴까요?", "tool": None, @@ -150,7 +123,7 @@ async def chat(user: UserInfo, place_id: str, message: str, confirm: dict | None args = choice.get("args") or {} if tool.grade == ToolGrade.SEMI: - # 실행하지 않는다. 사장님이 한 번 더 눌러야 한다. + # 실행하지 않는다. return {"reply": tool.confirm, "tool": tool.name, "args": args, "needs_confirm": True} return await _execute(ctx, tool, args) diff --git a/solution/backend/services/agent/tools.py b/solution/backend/services/agent/tools.py index 587a270..b61d685 100644 --- a/solution/backend/services/agent/tools.py +++ b/solution/backend/services/agent/tools.py @@ -1,17 +1,4 @@ -"""도구 레지스트리 — 에이전트가 할 수 있는 일의 **전부**가 여기 있다. - -★★ 도구는 반드시 `services/*` 를 통과한다. `crud`·`models` 를 직접 부르면 업종 스키마 - 검증 · 출처 필수 · 정정본 보호 · 소유자 범위가 통째로 사라지는데, **아무 증상이 없다** — - 값은 들어가고 빌드는 성공하고 화면도 뜬다. `collect_service.store_facts` 가 - "크롤러가 우회할 수 있는 뒷문을 만들지 않는다" 로 막아 둔 그 문이고, 에이전트에게만 - 열어 줄 이유가 없다. - -★ 결과 문구는 도구가 만든다. LLM 이 쓰게 두면 **하지 않은 일을 했다고 말할 수 있고**, - 사장님에게는 그 말이 사실로 보인다. - -★ 등급은 여기서 못 박는다. LLM 이 정하게 두면 프롬프트에 끼어든 한 줄이 확인 절차를 - 건너뛴다 — 되돌릴 수 없는 행위일수록 그 값을 모델에 맡기면 안 된다. -""" +"""도구 레지스트리 — 에이전트가 할 수 있는 일의 **전부**가 여기 있다.""" import uuid from dataclasses import dataclass, field @@ -36,7 +23,7 @@ class ToolGrade(str, Enum): """되돌릴 수 있느냐가 승인 강도를 정한다 — 분류가 아니라 동작을 가르는 값이다.""" READ = "READ" # 승인 없음 - REVERSIBLE = "REVERSIBLE" # 실행하고 알린다. 사장님이 다시 고치면 된다 + REVERSIBLE = "REVERSIBLE" # 실행하고 알린다. SEMI = "SEMI" # 실행 전에 한 번 묻는다(되돌릴 수는 있으나 그 사이 밖에서 읽힌다) @@ -59,10 +46,7 @@ class Tool: def _services(): - """서비스는 매 호출 새로 만든다 — 라우터가 Depends 로 받는 것과 같은 수명이다. - - ★ Depends 기본값에 기대지 않고 의존을 손으로 넣는다. FastAPI 밖에서 부르면 - 기본값이 `Depends(...)` 객체 그대로라 서비스가 조용히 엉뚱한 것을 들고 돈다.""" + """서비스는 매 호출 새로 만든다 — 라우터가 Depends 로 받는 것과 같은 수명이다.""" place_crud = PlaceCRUD() return FactService(FactCRUD(), place_crud), SiteService(SiteCRUD(), place_crud, JobQueue()) @@ -75,8 +59,7 @@ async def _get_site_status(ctx: ToolContext, args: dict) -> str: site = res.site if site is None or site.published_at is None: return "아직 발행 전입니다. 준비가 되면 발행해 드릴게요." - # ★ 주소는 site_payload 의 함수로 만든다. 문자열로 조립하면 canonical 과 갈린다 - # (CLAUDE.md '슬러그 규칙은 두 곳에 있고 같아야 한다'). + # 주소는 site_payload 의 함수로 만든다. url = f"{site_payload.publish_origin()}/s/{site_payload.publish_slug(ctx.place, site)}" when = site.published_at.strftime("%Y-%m-%d %H:%M") return f"발행되어 있습니다.\n주소: {url}\n마지막 발행: {when}" @@ -109,23 +92,21 @@ async def _set_fact(ctx: ToolContext, args: dict) -> str: schema = get_schema(PlaceCategory(ctx.place.category)) spec = schema.get(key) - # ★ LLM 이 없는 key 를 지어낼 수 있다. 스키마가 최종 판정이다. + # LLM 이 없는 key 를 지어낼 수 있다. if spec is None: raise ToolRejected("그 항목은 이 가게에서 쓰지 않는 정보라 고칠 수 없어요.") if spec.scope != "place": raise ToolRejected(f"{spec.label} 은 객실·메뉴마다 다른 값이라 대화로는 아직 고칠 수 없어요.") fact_service, _site = _services() - # ★ FactService 를 그대로 통과시킨다. source_type=OWNER 라 노출값을 즉시 교체하고, - # 정정본 잠금·업종 스키마 검증이 전부 거기서 걸린다. + # FactService 를 그대로 통과시킨다. res = await fact_service.upsert_fact( ctx.user, ctx.place_id, Req_UpsertFact(key=key, value=value, source_type=SourceType.OWNER) ) if not res.result.success: raise ToolRejected("그 값을 저장하지 못했습니다. 형식을 확인해 주세요.") - # ★ fact 는 바뀌었지만 사이트는 안 바뀐다. 이 한 줄이 빠지면 사장님은 반영된 줄 알고 - # 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다. + # fact 는 바뀌었지만 사이트는 안 바뀐다. return f"{spec.label} 을(를) {value} 로 바꿨습니다. 사이트에 반영하려면 다시 발행해야 해요 — 지금 할까요?" @@ -180,7 +161,7 @@ REGISTRY: dict[str, Tool] = { def describe() -> list[dict]: - """프롬프트에 실을 도구 목록. ★ 등급은 싣지 않는다 — 모델이 알 필요도, 정할 이유도 없다.""" + """프롬프트에 실을 도구 목록.""" return [{"name": t.name, "설명": t.summary, "args": t.args} for t in REGISTRY.values()] diff --git a/solution/backend/services/alert_service.py b/solution/backend/services/alert_service.py index 4cd5a5a..87ea763 100644 --- a/solution/backend/services/alert_service.py +++ b/solution/backend/services/alert_service.py @@ -1,27 +1,4 @@ -"""장애 알림 — 영구 저장 + 재시도 + 중복 억제. - -★ 왜 이 모양인가 - 잡 큐 소진(JobStatus.DEAD) · BUILD 잡의 업무 실패(게이트 반려가 아닌 렌더·인프라 실패) · - 노래 같은 곁가지의 부분 실패 · 잡 큐 정체를 Teams 로 알린다. 알림을 만드는 자리(worker/runner.py · - build_service.py · scheduler)는 이 모듈의 send_alert() 하나만 부르면 된다 — 언제 실제로 - 보낼지, 같은 사유를 몇 번이나 다시 보낼지는 전부 여기서 정한다. - -★ 재시도마다 중복 스팸을 내지 않는다 (dedupe) - 같은 dedupe_key 로 "아직 안 풀린" 알림이 있으면 새로 만들지 않는다 — 잡이 몇 번을 실패하며 - 재큐되든 사람에게는 처음 한 통만 간다. 문제가 사라지면(resolve_alert) 그 dedupe_key 는 - 다시 "풀린" 상태가 되고, 다음에 같은 사유가 또 터지면 새로 알린다. - -★ 영구 저장 + 재시도 (outbox) - webhook 전송이 그 자리에서 실패해도(네트워크 순단 등) 알림 자체를 잃지 않는다 — DB 에 - PENDING 으로 남기고 process_outbox() 가 백오프를 두고 다시 시도한다. 워커·API 프로세스가 - 재시작돼도 이 표만 보면 뭐가 안 나갔는지 안다. - -★ 비밀·개인정보를 남기지 않는다 (scrub) - detail 은 저장 **전에** 한 번 걸러진다 — 외부 API 예외 메시지가 쿼리스트링에 키를 실어 - 보내는 경우가 있다(TourAPI·Suno 등). 전화번호·API 키·bearer 토큰·이메일을 마스킹한다. - -★ webhook 미설정이면 조용히 아무 일도 안 한다(teams_webhook.is_configured). 서버는 그대로 뜬다. -""" +"""장애 알림 — 영구 저장 + 재시도 + 중복 억제.""" import os import re from datetime import timedelta @@ -34,10 +11,10 @@ from crud import alert_crud from crud.job_crud import compute_backoff from services import teams_webhook -# 중복 억제 창(분). 이 시간 안에 같은 dedupe_key 로 또 send_alert 가 불리면 새로 만들지 않는다. +# 중복 억제 창(분). DEDUPE_WINDOW_MIN_ENV = "ALERT_DEDUPE_WINDOW_MIN" DEFAULT_DEDUPE_WINDOW_MIN = 60 -# 재시도 상한. 소진되면 AlertStatus.FAILED — 더 자동으로는 안 보낸다. +# 재시도 상한. MAX_ATTEMPTS = 5 _DETAIL_MAX_LEN = 2000 @@ -52,8 +29,7 @@ _RE_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") def _scrub(text: str) -> str: - """저장 전에 반드시 한 번 거친다. 순서가 중요하다 — 쿼리스트링을 먼저 지워야 - 그 값이 이메일 형태여도 뒤의 이메일 마스킹이 이중으로 손대지 않는다.""" + """저장 전에 반드시 한 번 거친다.""" if not text: return "" out = _RE_QUERY_SECRET.sub(r"\1***", text) @@ -71,12 +47,7 @@ def _dedupe_window_min() -> int: async def send_alert(kind: str, title: str, detail: str = "", dedupe_key: str | None = None) -> None: - """알림을 큐에 넣는다(즉시 보내지 않는다 — process_outbox 가 보낸다). - - ★ 즉시 안 보내는 이유: 이 함수는 워커의 실패 처리 경로(예외 발생 지점)에서 불린다. - 여기서 동기적으로 webhook 을 때리면 그 지연·재시도가 잡 처리 자체를 늦춘다. 큐에 - 적재만 하고 별도 스윕(scheduler)이 실제 전송을 맡는다 — 알림 발송 실패가 발행 - 파이프라인에 영향을 주지 않는다(파일 머리주석의 관심사 분리).""" + """알림을 큐에 넣는다(즉시 보내지 않는다 — process_outbox 가 보낸다).""" try: async def _op(session): if dedupe_key: @@ -96,10 +67,7 @@ async def send_alert(kind: str, title: str, detail: str = "", dedupe_key: str | async def resolve_alert(dedupe_key: str, title: str, detail: str = "") -> None: - """이 dedupe_key 로 안 풀린 알림이 있으면 "복구됨" 을 한 번 알리고 풀린 것으로 남긴다. - - ★ 안 풀린 알림이 없으면(애초에 문제가 없었다) 아무것도 하지 않는다 — 정상 상태마다 - "복구됨" 을 보내면 그게 새로운 스팸이 된다.""" + """이 dedupe_key 로 안 풀린 알림이 있으면 "복구됨" 을 한 번 알리고 풀린 것으로 남긴다.""" try: async def _op(session): existing = await alert_crud.latest_unresolved(session, dedupe_key) @@ -119,10 +87,7 @@ async def resolve_alert(dedupe_key: str, title: str, detail: str = "") -> None: async def process_outbox(limit: int = 20) -> dict: - """PENDING 알림을 실제로 보낸다. 스케줄러가 주기적으로 부른다(scheduler/jobs.py). - - ★ 잡 큐의 백오프·소진 규칙(crud/job_crud.compute_backoff)을 그대로 재사용한다 — - "몇 번 실패하면 얼마나 쉬고 언제 포기하나" 를 두 번 설계하지 않는다.""" + """PENDING 알림을 실제로 보낸다.""" sent = failed = 0 try: async def _load(session): diff --git a/solution/backend/services/auth_service.py b/solution/backend/services/auth_service.py index 110890e..0fe4943 100644 --- a/solution/backend/services/auth_service.py +++ b/solution/backend/services/auth_service.py @@ -31,47 +31,37 @@ from services.external.google_identity import ( verify_id_token, ) -# 로그인 아이디: 영문으로 시작하는 4~20자. 대소문자를 구분하지 않고 저장은 입력 그대로 한다. +# 로그인 아이디: 영문으로 시작하는 4~20자. _LOGIN_ID_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9._-]{3,19}$") # 형식만 본다 — 이메일 소유 증명은 여기 없다(Req_Signup 주석). _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") _MIN_PASSWORD_LEN = 8 -# 구글 계정의 로그인 아이디 접두어. 사람이 이 접두어로 가입해 두면 같은 이름의 구글 계정이 -# 영영 못 만들어진다(유니크 충돌) — 가입 단계에서 막는다. +# 구글 계정의 로그인 아이디 접두어. _SOCIAL_ID_PREFIX = "google_" def _google_login_id(sub: str) -> str: - """구글 계정의 로그인 아이디. sub 를 그대로 붙인다 — 자르면 앞자리가 같은 두 계정이 겹친다.""" + """구글 계정의 로그인 아이디.""" return f"{_SOCIAL_ID_PREFIX}{sub}" def _fit(value: str | None, limit: int) -> str | None: - """컬럼 길이에 맞춰 자른다. 구글 표시 이름이나 사장님 입력이 길면 INSERT 가 통째로 터지는데, - 그건 "이름이 길다" 가 아니라 "가입이 안 된다" 로 보인다. 표시용 값이라 자르는 편이 낫다.""" + """컬럼 길이에 맞춰 자른다.""" if value is None: return None return value[:limit] class AuthService: - """비즈니스 로직 계층 (MVC 의 컨트롤러-서비스 분리에서 서비스). - - - CRUD 는 Depends 로 인터페이스 타입으로 주입받는다. - - DB 접근은 DB_SESSION_MNG 의 람다 실행으로만 한다. - 조회 = execute_lambda(..., DB_READ, lambda s: crud.xxx(s, ...)) - 변경 = execute_lambda_run([DBType], [lambda s: crud.xxx(s, ...)]) - - 모든 메서드는 Res_* 를 만들어 result 에 ErrorType 을 세팅해 반환한다. - """ + """비즈니스 로직 계층 (MVC 의 컨트롤러-서비스 분리에서 서비스).""" def __init__(self, user_crud: IUserCRUD = Depends(UserCRUD)): self.user_crud = user_crud @staticmethod def _user_info(user: users) -> UserInfo: - # uuid → str (JWT json 직렬화 위해). 기능 라우터는 user_id 로 스코프한다 - # — 사업장이 places.owner_user_id 로 이 값에 매여 있다. + # uuid → str (JWT json 직렬화 위해). return UserInfo( user_id=str(user.user_id), id=user.id, @@ -80,10 +70,7 @@ class AuthService: ) async def _finish_login(self, user: users) -> Res_Login: - """신원이 확인된 뒤의 마지막 단계 — id/pw 와 구글이 공유한다. - - 상태 확인 → 우리 토큰 발급 → 마지막 접속 갱신. 어느 수단으로 들어왔든 여기서부터는 - 같은 세션이다(구글 토큰을 세션으로 들고 다니지 않는다).""" + """신원이 확인된 뒤의 마지막 단계 — id/pw 와 구글이 공유한다.""" res = Res_Login() if user.status != UserStatus.ACTIVE.value: @@ -119,8 +106,6 @@ class AuthService: user: users # 2) 소셜 계정에는 대조할 비밀번호가 없다. - # 여기서 끊지 않으면 VerifyPW 가 None 해시를 만나 500 이 난다. - # '아이디/비번 오류' 로 뭉개지 않는 이유: 화면이 "구글로 로그인하세요" 를 안내해야 한다. if user.provider != AuthProvider.LOCAL.value or not user.password: res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) return res @@ -132,7 +117,7 @@ class AuthService: return await self._finish_login(user) - # ---- 가입 -------------------------------------------------------------- + # 가입 async def _create_account( self, *, @@ -143,12 +128,7 @@ class AuthService: provider: AuthProvider, provider_uid: str | None, ) -> tuple[ErrorType, users]: - """계정 1개를 만든다. - - ★ 예전엔 가입 한 번이 **회사(테넌트) 하나**를 같이 만들었고 모든 도메인이 그 회사로 - 스코프됐다. 쓰는 사람은 사장님 혼자인데 자기 회사에 소속된 직원이 되는 구조라 - 걷어냈다(2026-09-08) — 이제 사업장이 `places.owner_user_id` 로 이 계정에 직접 매인다. - ★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서 그 전에 읽으면 None 이다.""" + """계정 1개를 만든다.""" user = users( user_id=uuid.uuid4(), id=login_id, @@ -177,8 +157,7 @@ class AuthService: return err_type == ErrorType.SUCCESS and existing is not None async def signup(self, req: Req_Signup) -> Res_Login: - """id/pw 가입. 성공하면 곧바로 로그인 상태로 만든다(토큰을 실어 보낸다) — - 가입 직후 로그인 화면으로 되돌리면 방금 정한 비밀번호를 또 치게 된다.""" + """id/pw 가입.""" res = Res_Login() login_id = req.id.strip() email = (req.email or "").strip().lower() @@ -194,7 +173,7 @@ class AuthService: res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) return res - # 아이디 중복. is_user 는 "없으면 SUCCESS" 다 — 있으면 DB_ALREADY_SAME_KEY 를 준다. + # 아이디 중복. dup = await DB_SESSION_MNG.execute_lambda( users.DBType(), DBWRType.DB_READ.value, @@ -224,9 +203,9 @@ class AuthService: LOG.i(f"SIGNUP : id={login_id}") return await self._finish_login(user) - # ---- 구글 로그인 -------------------------------------------------------- + # 구글 로그인 async def google_login(self, req: Req_GoogleLogin) -> Res_Login: - """구글 ID 토큰 → 우리 세션. 처음 온 계정이면 그 자리에서 만든다(별도 가입 절차 없음).""" + """구글 ID 토큰 → 우리 세션.""" res = Res_Login() try: @@ -248,9 +227,7 @@ class AuthService: LOG.i(f"LOGIN(google) : sub={account.sub}") return await self._finish_login(user) - # 2) 같은 이메일이 다른 수단으로 이미 가입돼 있으면 **잇지 않는다.** - # 자동으로 이으면, 남의 이메일로 먼저 만들어 둔 id/pw 계정에 그 사람의 구글 로그인이 - # 그대로 들어간다(계정 선점). 소유 증명 없이 잇는 건 로그인 하나를 통째로 넘기는 것이다. + # 2) 같은 이메일이 다른 수단으로 이미 가입돼 있으면 **잇지 않는다.** 자동으로 이으면, 남의 이메일로 먼저 만들어 둔 id/pw 계정에 그 사람의 구글 로그인이 그대로 들어간다(계정 선점). if account.email and await self._email_taken(account.email): res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) return res @@ -300,8 +277,7 @@ class AuthService: # 비밀번호: 값 있으면 해시 교체, 비었으면 변경 안 함. if data.get("password"): - # 구글 계정에 비밀번호를 심으면 id/pw 로도 열리는 반쪽 계정이 된다 — - # 로그인 수단이 둘인데 어느 쪽도 상대를 모르는 상태다. 받지 않는다. + # 구글 계정에 비밀번호를 심으면 id/pw 로도 열리는 반쪽 계정이 된다 — 로그인 수단이 둘인데 어느 쪽도 상대를 모르는 상태다. if len(data["password"]) < _MIN_PASSWORD_LEN: res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) return res @@ -317,9 +293,7 @@ class AuthService: res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) return res data["password"] = await GetHashedPW(data["password"]) - # ★ 비밀번호를 바꾸면 그 전에 나간 refresh 토큰을 전부 무효화한다 — 안 그러면 - # 누군가 비번을 훔쳐 넣어 둔 refresh 토큰이 이 사람이 비번을 바꾼 뒤로도 - # 계속 살아 있다(auth_service.refresh_token 이 이 값을 대조한다). + # 비밀번호를 바꾸면 그 전에 나간 refresh 토큰을 전부 무효화한다 — 안 그러면 누군가 비번을 훔쳐 넣어 둔 refresh 토큰이 이 사람이 비번을 바꾼 뒤로도 계속 살아 있다(auth_service.refresh_token 이 이 값을 대조한다). data["token_version"] = (me.token_version or 1) + 1 else: data.pop("password", None) @@ -341,12 +315,7 @@ class AuthService: return await self.get_me(user_info) async def refresh_token(self, refresh_token: str) -> Res_RefreshToken: - """refresh 토큰 → 새 access 토큰. - - ★ 서명·만료만 보고 DB 를 한 번도 안 읽던 자리다 — 비밀번호를 바꾸거나 계정을 - 막아도, 이미 나간 refresh 토큰(7일)은 만료 전까지 계속 새 access 토큰을 찍어냈다. - 여기서 최신 DB 상태를 한 번 대조한다: 이 토큰의 token_version 이 지금 값과 - 다르면(bump_token_version 이 불렸다는 뜻) 재발급을 거절한다.""" + """refresh 토큰 → 새 access 토큰.""" res = Res_RefreshToken() # refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨(서명·만료). user_info = DecodeRefreshToken(refresh_token) @@ -364,12 +333,11 @@ class AuthService: if user.status != UserStatus.ACTIVE.value: res.result.SetResult(ErrorType.ACCOUNT_BLOCKED_USER) return res - # ★ 구버전 토큰(token_version 없이 발급됨)은 UserInfo 기본값 1 로 읽힌다 — DB 컬럼 - # 기본값도 1 이라 배포 직후에는 전부 통과한다. bump 가 불린 뒤에만 갈린다. + # 구버전 토큰(token_version 없이 발급됨)은 UserInfo 기본값 1 로 읽힌다 — DB 컬럼 기본값도 1 이라 배포 직후에는 전부 통과한다. if user_info.token_version != user.token_version: res.result.SetResult(ErrorType.ACCOUNT_SESSION_REVOKED) return res - # ★ 최신 DB 값으로 다시 만든다 — role 이 바뀌었으면 그것도 여기서 따라온다. + # 최신 DB 값으로 다시 만든다 — role 이 바뀌었으면 그것도 여기서 따라온다. res.access_token = CreateAccessToken(self._user_info(user)) return res diff --git a/solution/backend/services/azure_static.py b/solution/backend/services/azure_static.py index b2a37b8..d06e3f2 100644 --- a/solution/backend/services/azure_static.py +++ b/solution/backend/services/azure_static.py @@ -9,7 +9,7 @@ from azure.storage.blob import BlobServiceClient, ContentSettings DEFAULT_CONTAINER = "$web" DEFAULT_PREFIX = "ai-for-web" -# 사이트별 산출물이 놓이는 디렉터리(out/s/). 나머지 루트는 전부 공용이다. +# 사이트별 산출물이 놓이는 디렉터리(out/s/). SITE_ROOT_DIR = "s" @@ -33,8 +33,7 @@ def _content_type(path: Path) -> str: ".json": "application/json; charset=utf-8", ".xml": "application/xml; charset=utf-8", ".txt": "text/plain; charset=utf-8", - # 노래. guess_type 도 audio/mpeg 를 주지만 플랫폼마다 갈려서 못 박아 둔다 — - # octet-stream 으로 올라가면 브라우저가 재생 대신 내려받기로 처리한다. + # 노래. ".mp3": "audio/mpeg", } return overrides.get(path.suffix.lower()) or mimetypes.guess_type(path.name)[0] or "application/octet-stream" @@ -46,11 +45,9 @@ def _cache_control(path: Path) -> str: if head == "assets": return "public, max-age=31536000, immutable" # 폰트는 이름이 고정이라 immutable 은 못 쓰지만(교체하면 갱신되어야 한다) 거의 안 바뀐다. - # 60초로 두면 방문자가 수 MB 짜리 폰트를 계속 다시 받는다. if head == "fonts": return "public, max-age=604800" # 노래 파일명은 song_id(UUID)다 — 곡이 바뀌면 이름도 바뀌므로 영구 캐시가 안전하다. - # 1MB 남짓한 파일을 60초마다 다시 받게 두면 헤더 버튼 하나가 트래픽을 먹는다. if path.suffix.lower() == ".mp3": return "public, max-age=31536000, immutable" # HTML · robots.txt · sitemap.xml — 발행하면 곧바로 반영되어야 한다. @@ -84,26 +81,13 @@ def _upload_tree(container, root: Path, relative_root: str, prefix: str) -> set[ def _upload_shared(container, root: Path, prefix: str) -> set[str]: - """out/ 루트의 공용 산출물 — 사이트별 파일(`s/` 아래)을 뺀 전부. - - ★ 왜 이게 필요한가: 예전에는 `assets/` 만 올렸다. 그래서 프리렌더가 굽는 루트 - `robots.txt` 와 사이트맵 인덱스(`sitemap.xml`)가 **한 번도 올라간 적이 없다**. - 크롤러는 robots.txt 를 오리진 루트에서만 읽으므로(RFC 9309), AI 크롤러 명시 허용도 - `Sitemap:` 지시도 전달되지 않았다 — 사이트맵을 굽기만 하고 그 존재를 알릴 방법이 - 없었으니 크롤러가 새 사이트를 찾아올 경로 자체가 없었다. - - ★ 왜 이름을 하나씩 적지 않고 `s/` 만 빼는가: 루트에 무엇이 놓이는지는 프리렌더가 - 정한다(`writeSharedAssets` 가 `site/public/` 을 통째로 루트에 복사한다 — 폰트를 - 넣으면 폰트가 는다). 여기에 파일 목록을 두면 나중에 늘어난 파일이 조용히 빠진다. - 지금 고치는 버그가 정확히 그것이므로 같은 모양을 다시 만들지 않는다. - """ + """out/ 루트의 공용 산출물 — 사이트별 파일(`s/` 아래)을 뺀 전부.""" if not root.is_dir(): raise AzurePublishError(f"정적 산출물 디렉터리가 없습니다: {root}") uploaded: set[str] = set() for entry in sorted(root.iterdir()): # `s/` 는 사이트별 디렉터리다 — 발행한 사이트 하나만 따로 올린다. - # (여기서 함께 올리면 한 명이 발행할 때마다 전체 사이트를 다시 올리게 된다.) if entry.name.startswith(".") or entry.name in {SITE_ROOT_DIR, "versions"}: continue if entry.is_dir(): @@ -130,8 +114,7 @@ def _publish_sync(slug: str) -> dict: service = BlobServiceClient.from_connection_string(connection_string) container = service.get_container_client(container_name) - # 공용 산출물은 덮어써도 안전하다(번들은 해시 파일이고, robots·사이트맵은 매 발행마다 - # 프리렌더가 현재 발행본 전체를 보고 다시 쓴다). 사이트 경로만 현재 발행본으로 교체한다. + # 공용 산출물은 덮어써도 안전하다(번들은 해시 파일이고, robots·사이트맵은 매 발행마다 프리렌더가 현재 발행본 전체를 보고 다시 쓴다). shared = _upload_shared(container, root, prefix) site = _upload_tree(container, root, f"{SITE_ROOT_DIR}/{slug}", prefix) site_prefix = "/".join(part for part in (prefix, SITE_ROOT_DIR, slug) if part) @@ -150,7 +133,7 @@ def _publish_sync(slug: str) -> dict: async def publish(slug: str) -> dict | None: - """설정된 경우에만 업로드한다. SDK의 동기 I/O는 별도 스레드에서 실행한다.""" + """설정된 경우에만 업로드한다.""" if not is_configured(): return None return await asyncio.to_thread(_publish_sync, slug) diff --git a/solution/backend/services/blog_jobs.py b/solution/backend/services/blog_jobs.py index b915680..5b921ba 100644 --- a/solution/backend/services/blog_jobs.py +++ b/solution/backend/services/blog_jobs.py @@ -1,13 +1,4 @@ -"""미니 블로그의 두 스윕 — 만들기와 보내기. 기획: docs/MINI_BLOG.md - -★ 잡은 '대상을 고르는 것'까지만 하고 실제 일은 서비스가 한다(scheduler/jobs.py 규약). -★ 한 번에 BATCH_SIZE 건씩 만든다. 한 달치를 한 호출로 뽑으면 앞 회차 주제를 프롬프트에 - 못 넣어 중복이 막히지 않는다. -★ 팀 사전검수 없음 — 금칙 필터(blog_service.is_publishable_body)를 통과하면 바로 REVIEWED 로 - 쌓이고, send_reviewed() 가 업장당 하루 한 통씩 그대로 사장님에게 보낸다. -★ 글마다 scheduled_date(KST) 를 하나씩 배정한다 — "언제 만들어졌나"만 있고 "언제 낼 - 것인가"가 없으면 달력 화면이 근거 없는 날짜를 지어내야 한다(2026-09-17). -""" +"""미니 블로그의 두 스윕 — 만들기와 보내기.""" import uuid from datetime import date, datetime, timedelta, timezone @@ -39,8 +30,7 @@ def _today_kst() -> date: async def _published_places() -> list: - """(place, user) 쌍 — user 전체를 준다. 메일에 email 뿐 아니라(대상 판정) 로그인 - day-pass 토큰(id·role·token_version)도 만들어야 해서 email 만으로는 부족하다.""" + """(place, user) 쌍 — user 전체를 준다.""" def query(session): return session.execute( select(places, users) @@ -74,13 +64,7 @@ async def _pending_count(place_id) -> int: async def _compose_for_dates(place, dates: list[date]) -> list[dict]: - """날짜마다 그 날짜에 맞는 소재(blog_service.materials(snapshot, d))로 한 편씩 만든다 — - 세 생성 경로(자동·구간·개별)가 같이 쓴다. 저장은 부르는 쪽이 한다. - - ★ 날짜를 먼저 정하고 소재를 고른다(2026-09-23). 예전에는 소재 목록을 순서대로 뽑아 날짜에 - 차례로 붙여서, 글 내용이 배정된 날짜와 무관했다. - ★ 그 날짜에 맞는 소재가 없으면 그 날짜만 비워 두고 다음 날짜로 간다 — 뒤 날짜엔 축제가 걸릴 수 있다. - ★ LLM 이 없거나 실패하면(None) 그 자리에서 멈춘다 — 날짜마다 소재를 전부 돌며 헛호출하지 않는다.""" + """날짜마다 그 날짜에 맞는 소재(blog_service.materials(snapshot, d))로 한 편씩 만든다 — 세 생성 경로(자동·구간·개별)가 같이 쓴다.""" used = await DB_SESSION_MNG.execute_lambda( place_posts.DBType(), DBWRType.DB_READ.value, lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid), @@ -109,14 +93,14 @@ async def _compose_for_dates(place, dates: list[date]) -> list[dict]: rows.append({ "place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key, "scheduled_date": target, "generation_meta": {"model": model}, - "status": PostStatus.REVIEWED.value, # 금칙 필터를 이미 통과했다 — 팀 사전검수 없음 + "status": PostStatus.REVIEWED.value, }) break return rows async def _generate_for_place(place) -> int: - """업장 하나. 재고가 이미 REFILL_BELOW 이상이면 아무것도 안 만든다(만든 수 0).""" + """업장 하나.""" if await _pending_count(place.place_id) >= REFILL_BELOW: return 0 @@ -134,7 +118,7 @@ async def _generate_for_place(place) -> int: async def generate_drafts() -> int: - """재고가 모자란 업장마다 최대 BATCH_SIZE 건. 만든 수를 돌려준다.""" + """재고가 모자란 업장마다 최대 BATCH_SIZE 건.""" made = 0 for place, _user in await _published_places(): made += await _generate_for_place(place) @@ -142,12 +126,7 @@ async def generate_drafts() -> int: async def generate_range(place_id: str, start_date: date, end_date: date) -> dict: - """사장님이 빌더 화면에서 직접 누르는 즉시 생성 — 이번엔 구간을 직접 고른다 - (2026-09-17, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까"). - 재고 상한(REFILL_BELOW)을 안 본다 — 개별 생성과 같은 이유로, 직접 고른 구간에 - 상한 로직이 끼어들 자리가 아니다. 이미 글이 있는 날짜는 LLM 을 부르지 않고 건너뛴다 — - 매번 새로 만들고 유니크 충돌로 버리면 호출만 낭비된다. 그 날짜에 맞는 소재가 없으면 - 그 날짜는 빈 날짜로 남는다(_compose_for_dates).""" + """사장님이 빌더 화면에서 직접 누르는 즉시 생성 — 이번엔 구간을 직접 고른다.""" place = None for p, _user in await _published_places(): if str(p.place_id) == str(place_id): @@ -177,9 +156,7 @@ async def generate_range(place_id: str, start_date: date, end_date: date) -> dic async def generate_one_for_date(place_id: str, target_date: date) -> dict | None: - """개별 생성 — 달력에서 빈 날짜 하나를 사장님이 콕 집어 채운다(2026-09-17, 사장님 지시: - "개별적으로 새로 만들수있게 해줘"). 재고 상한(REFILL_BELOW)을 안 본다 — 특정 날짜를 - 지정한 요청이라 상한 로직이 끼어들 자리가 아니다. 그 날짜가 이미 차 있으면 None.""" + """개별 생성 — 달력에서 빈 날짜 하나를 사장님이 콕 집어 채운다.""" place = None for p, _user in await _published_places(): if str(p.place_id) == str(place_id): @@ -193,15 +170,11 @@ async def generate_one_for_date(place_id: str, target_date: date) -> dict | None return None return await DB_SESSION_MNG.execute_lambda_write( place_posts.DBType(), lambda s, r=rows[0]: _crud.add_one(s, r), - ) # None 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다 + ) def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str) -> str: - """승인(누르면 바로 게재) · 수정(빌더 앱 로그인 상태로 그 글 편집 모달) 두 링크만 둔다 - (2026-09-17, 사장님 지시: "승인이랑 수정하기 있어야해"). 둘 다 오늘 자정(KST)에 - 만료된다(2026-09-17, 사장님 지시: "승인이랑 수정모두 자정에 만료") — 그 뒤로는 - 로그인해서 빌더 앱에서 처리한다. 수정 링크는 토큰 하나짜리 공개 편집 화면 대신, - 실제 로그인 세션으로 빌더 앱의 편집 모달을 그대로 연다.""" + """승인(누르면 바로 게재) · 수정(빌더 앱 로그인 상태로 그 글 편집 모달) 두 링크만 둔다.""" user_info = UserInfo(user_id=str(user.user_id), id=user.id, role=user.role, token_version=user.token_version) auto_token = CreateDayPassToken(user_info) edit_link = f"{origin}/blog?placeId={post.place_id}&postId={post.post_id}&auto={auto_token}" @@ -217,25 +190,17 @@ def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str) def _notify_address(place, user) -> str: - # notify_email 이 있으면 그 업장 전용 수신자다 — 없으면 계정 이메일(users.email)로 대체한다 - # (사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다). + # notify_email 이 있으면 그 업장 전용 수신자다 — 없으면 계정 이메일(users.email)로 대체한다 (사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다). return place.notify_email or user.email def _app_origin() -> str: - """메일의 승인·수정 링크가 향할 곳 — 빌더 앱(과 그 앞의 API)이 사는 오리진. - - ★ site_payload.publish_origin() 을 쓰면 안 된다 — 그건 발행된 고객 사이트(/s/) - 전용이다. 로컬에선 그게 solution-site 정적 서버(포트 80)라, 메일의 "수정하려면" - 링크(/blog?...)가 거기로 가서 404 났다(2026-09-21 실측). SNS 알림(notify_service.py)이 - 이미 같은 목적으로 쓰는 SOCIAL_APP_ORIGIN 을 그대로 재사용한다 — 설정을 두 벌 안 둔다. - 비어 있으면(로컬에서 안 채웠으면) publish_origin() 으로 폴백해 링크가 아예 상대경로로 - 깨지는 것보다는 낫게 한다.""" + """메일의 승인·수정 링크가 향할 곳 — 빌더 앱(과 그 앞의 API)이 사는 오리진.""" return social_config.get("SOCIAL_APP_ORIGIN") or site_payload.publish_origin() async def _send_one(place, user, post) -> bool: - """토큰 발급 → 메일 본문 조립 → 발송 → 성공하면 SENT 로 표시. 실패하면 DB 를 안 건드린다.""" + """토큰 발급 → 메일 본문 조립 → 발송 → 성공하면 SENT 로 표시.""" token, token_hash, expires = blog_service.issue_token() body = _mail_body( place_name=place.name, post=post, user=user, @@ -252,7 +217,7 @@ async def _send_one(place, user, post) -> bool: async def send_reviewed() -> int: - """검수를 통과한 글을 사장님에게 한 통씩 보낸다. 보낸 수를 돌려준다.""" + """검수를 통과한 글을 사장님에게 한 통씩 보낸다.""" if not mail_service.is_configured(): return 0 @@ -278,9 +243,7 @@ async def send_reviewed() -> int: async def send_now_for_place(place_id: str) -> dict: - """사장님이 빌더 화면에서 누르는 즉시 발송 — 아침 9시 스윕을 기다리지 않고 이 업장의 - 오늘 몫을 지금 보낸다(2026-09-21, 사장님 요청: "지금 바로 발송할 수 있도록"). - '하루 한 통' 원칙은 그대로다 — 이미 오늘 보냈으면(REVIEWED 가 아니면) 보낼 게 없다.""" + """사장님이 빌더 화면에서 누르는 즉시 발송 — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 지금 보낸다.""" if not mail_service.is_configured(): return {"sent": False, "reason": "MAIL_NOT_CONFIGURED"} diff --git a/solution/backend/services/blog_service.py b/solution/backend/services/blog_service.py index 49eb017..ca5c11c 100644 --- a/solution/backend/services/blog_service.py +++ b/solution/backend/services/blog_service.py @@ -1,10 +1,4 @@ -"""미니 블로그 — AI 자동 포스트. 기획: docs/MINI_BLOG.md - -★ 발행 게이트와 부딪히지 않게 만든다. 규칙 1(미검증 fact 는 화면에 내지 않는다)은 홍보 문구에도 - 그대로 걸린다 — 가격·시간·인원을 문구가 주장하면 그 주장을 뒷받침할 fact 가 없다. - 프롬프트로 금지하고, 생성 뒤 `is_publishable_body()` 로 한 번 더 거른다. -★ 중복은 프롬프트가 아니라 DB 가 막는다 — (place_id, topic_key) 유니크. -""" +"""미니 블로그 — AI 자동 포스트.""" import hashlib import re import secrets @@ -13,13 +7,13 @@ from datetime import date, datetime, timedelta, timezone from common.enums import LocalContentType, PlaceCategory, PostStatus, PostTopicKind from common.logger import LOG -# 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. 벗어나면 버린다. +# 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. MIN_LEN = 120 MAX_LEN = 170 _KST = timezone(timedelta(hours=9)) -# 문구가 주장하면 안 되는 것. 게이트가 잡기 전에 여기서 버린다. +# 문구가 주장하면 안 되는 것. _FORBIDDEN = ( re.compile(r"\d{1,3},\d{3}\s*원"), # 198,000원 re.compile(r"\d+\s*원"), # 50000원 · 3만원 은 아래에서 @@ -34,7 +28,7 @@ _FORBIDDEN = ( def is_publishable_body(text: str) -> tuple[bool, str]: - """(통과 여부, 사유). 사유는 로그·검수 화면에 그대로 쓴다.""" + """(통과 여부, 사유).""" body = (text or "").strip() if not body: return False, "빈 글" @@ -52,11 +46,7 @@ def hash_token(token: str) -> str: def issue_token() -> tuple[str, str, object]: - """(평문, 해시, 만료시각=오늘 자정 KST). 평문은 메일 본문에만 나가고 DB 에는 해시만 둔다. - - ★ 승인·수정 두 링크 다 그날까지만 산다(2026-09-17, 사장님 지시: "승인이랑 수정모두 - 자정에 만료"). 그 뒤로는 로그인해서 빌더 앱에서 처리한다 — 메일 링크는 "오늘 온 것을 - 오늘 처리하라"는 뜻이지 보관함이 아니다.""" + """(평문, 해시, 만료시각=오늘 자정 KST).""" token = secrets.token_urlsafe(32) now_kst = datetime.now(_KST) midnight_kst = (now_kst + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) @@ -66,7 +56,7 @@ def issue_token() -> tuple[str, str, object]: # 숙소(LODGING) 기본 갈래 규칙 — 업종별 규칙이 없을 때의 폴백이기도 하다. TOPIC_RULES: dict[int, str] = { - # ★ 게시일의 실제 날씨는 모른다(글은 며칠·몇 주 앞서 만든다) — "오늘은 비가 옵니다"라고 단정하게 두지 않는다. + # 게시일의 실제 날씨는 모른다(글은 며칠·몇 주 앞서 만든다) — "오늘은 비가 옵니다"라고 단정하게 두지 않는다. PostTopicKind.WEATHER.value: "소재로 주어진 날씨인 날, 이 숙소에서 하기 좋은 일을 한 장면으로 적는다. 게시일의 날씨를 단정하지 않는다.", PostTopicKind.FESTIVAL.value: @@ -79,7 +69,7 @@ TOPIC_RULES: dict[int, str] = { "확인된 이용 안내 하나를 손님이 알아두면 좋은 말투로 풀어 적는다.", } -# 업종별 분기 — 지금은 숙소만 채워져 있다. 새 업종을 넣으려면 여기 두 딕셔너리에만 항목을 더한다. +# 업종별 분기 — 지금은 숙소만 채워져 있다. _BUSINESS_NOUN_BY_CATEGORY: dict[int, str] = { PlaceCategory.LODGING.value: "숙소", } @@ -110,7 +100,7 @@ _WEEKDAYS = "월화수목금토일" def season_term(on: date) -> str: - """게시일 → 절기 이름(materials 의 계절 소재와 같은 말). 달로만 가른다.""" + """게시일 → 절기 이름(materials 의 계절 소재와 같은 말).""" return { 3: "봄", 4: "봄", 5: "봄", 6: "초여름", 7: "한여름", 8: "한여름", @@ -125,8 +115,7 @@ def _date_line(on: date) -> str: def build_prompt(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str], place_category: int = PlaceCategory.LODGING.value, post_date: date | None = None) -> str: - """갈래 하나에 대한 프롬프트 한 벌. 프롬프트를 두 곳에 적지 않으려고 여기서만 만든다. - post_date 가 있으면 게시일을 알려 준다 — 글이 그 날짜의 계절·행사와 맞게 쓰이도록(2026-09-23).""" + """갈래 하나에 대한 프롬프트 한 벌.""" used = ", ".join(used_topics[:40]) or "없음" noun = _business_noun(place_category) rules = _topic_rules(place_category) @@ -141,7 +130,7 @@ def build_prompt(*, place_name: str, region: str, topic_kind: int, material: str def filter_drafts(rows: list[dict]) -> tuple[list[dict], list[tuple[str, str]]]: - """(통과한 것, 버린 것[(본문앞부분, 사유)]). 버린 이유를 세어 프롬프트를 고칠 근거로 남긴다.""" + """(통과한 것, 버린 것[(본문앞부분, 사유)]).""" kept, dropped = [], [] seen_keys = set() for row in rows: @@ -167,17 +156,7 @@ def filter_drafts(rows: list[dict]) -> tuple[list[dict], list[tuple[str, str]]]: async def generate_one(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str], place_category: int = PlaceCategory.LODGING.value, post_date: date | None = None, client=None) -> tuple[str, str] | None: - """(문구, 모델명) 한 쌍. LLM 이 없거나 실패하면 None — 생성 실패가 잡을 죽이지 않는다. - 모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 데 쓴다(2026-09-17, 사장님 지시). - - ★ 발행 링크는 여기서 붙이지 않는다 — 호출부가 길이 게이트(is_publishable_body/ - filter_drafts, MIN_LEN~MAX_LEN)를 이 반환값 그대로에 건다. 링크까지 포함해서 - 길이를 재면 정상 문구도 게이트에 걸려 버려진다. 링크는 게이트를 통과한 뒤 호출부가 - 붙인다. - - ★ 공급자는 LLM_PROVIDER 설정을 따른다(services/llm/provider.py) — Gemini 로 고정하지 - 않는다. generate_social_post(services/external/gemini_text.py)와 달리 구조화 출력 - 재시도 루프가 없는 단순 텍스트 생성이라 공급자를 가려도 된다.""" + """(문구, 모델명) 한 쌍.""" from services.llm import provider from services.llm.errors import LlmError @@ -204,7 +183,7 @@ async def generate_one(*, place_name: str, region: str, topic_kind: int, materia await client.aclose() -# 축제 글을 시작일 며칠 전부터 낼 수 있나. 끝난 축제는 내지 않는다. +# 축제 글을 시작일 며칠 전부터 낼 수 있나. FESTIVAL_LEAD_DAYS = 14 # 그 달에 말이 되는 날씨만 소재로 쓴다 — 여름에 "눈인 날" 글이 나가지 않게. @@ -223,17 +202,7 @@ def _ymd(value) -> date | None: def materials(snapshot: dict, on: date) -> list[tuple[int, str, str]]: - """게시일 on 에 맞는 (갈래, topic_key, 소재). 앞에 있을수록 먼저 고른다 — 축제 → 계절 → 주변 → 날씨. - 소재가 없는 갈래는 아예 만들지 않는다 — 지어내지 않는다. - - ★ 날짜에 맞춘다(2026-09-23). 예전에는 날짜와 무관한 한 줄 목록이라, 9월 날짜에 '한겨울' 글이나 - 이미 끝난 축제 글이 붙을 수 있었다. - - 축제: 시작 FESTIVAL_LEAD_DAYS 일 전 ~ 끝나는 날 사이에만. 기간을 모르는 축제는 쓰지 않는다. - - 계절: 게시일의 절기 하나. 키에 연도를 넣어 해마다 한 번씩 다시 쓸 수 있다. - - 날씨: 그 달에 있을 법한 것만. 키에 연·월을 넣어 달마다 다시 쓸 수 있다. - - 주변 장소: 날짜와 무관해 늘 후보다. - ★ 스냅샷의 지역 정보는 원문 행 목록(snapshot["local"]["contents"])이다. 예전 코드는 - site_payload 모양(local.festivals·attractions)을 읽어 축제·주변 소재가 늘 비어 있었다.""" + """게시일 on 에 맞는 (갈래, topic_key, 소재).""" contents = (snapshot.get("local") or {}).get("contents") or [] by_type: dict[int, list[dict]] = {} for row in contents: diff --git a/solution/backend/services/booking_request_service.py b/solution/backend/services/booking_request_service.py index 7214b5c..05408ba 100644 --- a/solution/backend/services/booking_request_service.py +++ b/solution/backend/services/booking_request_service.py @@ -1,8 +1,4 @@ -"""예약 요청 메일 — 발행본 폼이 보낸 것을 사장님에게 전달한다. - -★ 저장하지 않는다. place_id 로 받는 사람만 찾고, 나머지는 전부 메일 본문으로 나간다. -★ 발행된 사이트의 업장만 받는다 — place_id 를 손으로 바꿔 아무 업장에나 메일을 쏘는 길을 막는다. -""" +"""예약 요청 메일 — 발행본 폼이 보낸 것을 사장님에게 전달한다.""" from sqlalchemy import select from common.database.db_session_manager import DB_SESSION_MNG @@ -42,7 +38,7 @@ class BookingRequestService: return ResBookingRequest(success=True, message=SENT) async def _target(self, place_id) -> tuple[str, str] | None: - """(상호명, 사장님 이메일). 발행된 사이트가 없으면 None.""" + """(상호명, 사장님 이메일).""" def query(session): return session.execute( select(places.name, users.email) diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index 66798f2..f209d02 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -70,7 +70,7 @@ class BuildAborted(PermanentJobError): async def ensure_site(place_id: str) -> "sites": - """사업장의 사이트 행을 보장한다(없으면 만든다). 사업장당 1개.""" + """사업장의 사이트 행을 보장한다(없으면 만든다).""" pid = uuid.UUID(place_id) err, site = await DB_SESSION_MNG.execute_lambda( sites.DBType(), DBWRType.DB_READ.value, lambda s: _site_crud.get_site_by_place(s, pid) @@ -103,7 +103,7 @@ async def load_channel_links(place_id: str) -> list: async def _log(site_id, version_id, action: PublishAction, result: PublishResult, gate=None, actor=None): - """발행 시도를 기록한다. 거부됐으면 사유와 상세를 그대로 남긴다 — 운영자가 뭘 고칠지 알아야 한다.""" + """발행 시도를 기록한다.""" row = site_publish_logs( site_id=site_id, site_version_id=version_id, @@ -207,7 +207,7 @@ async def run_build(job: dict) -> dict: now = GTime.UTC() async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None): - """버전을 FAILED 로 남기고 사유를 기록한다. 발행하지 않는다.""" + """버전을 FAILED 로 남기고 사유를 기록한다.""" await DB_SESSION_MNG.execute_lambda_claim( site_versions.DBType(), lambda s: _site_crud.finish_version( @@ -221,7 +221,6 @@ async def run_build(job: dict) -> dict: result["build_status"] = "FAILED" result["error"] = reason LOG.w(f"[build] place={place_id} v{version_no} 실패: {reason}") - # 게이트 반려(gate is not None)는 알리지 않는다 — 사장님이 값을 안 채웠다고 운영자를 부르면 안 된다. if gate is None: await alert_service.send_alert( kind="build_failed", @@ -231,7 +230,7 @@ async def run_build(job: dict) -> dict: ) return result - # ---- 1차 게이트: 렌더 없이 판정 가능한 것 ---- + # 1차 게이트: 렌더 없이 판정 가능한 것 place_name = str((snapshot.get("place") or {}).get("name") or "").strip() if not place_name: return await _fail("상호명이 없다 — 사이트를 만들 수 없다") @@ -248,7 +247,7 @@ async def run_build(job: dict) -> dict: except UnknownTemplate as ex: return await _fail(str(ex)) - # ---- 렌더러에 넘긴다 ---- + # 렌더러에 넘긴다 if want_publish: site.status = SiteStatus.PUBLISHED.value site.published_at = site.published_at or now @@ -261,7 +260,7 @@ async def run_build(job: dict) -> dict: slug = site_payload.publish_slug(place, site) - # ---- 렌더러를 직접 돌린다 ---- + # 렌더러를 직접 돌린다 try: report = await render_service.render_site(payload_path, version_no, RENDER_TIMEOUT_SEC) except render_service.RenderFailed as ex: @@ -276,7 +275,7 @@ async def run_build(job: dict) -> dict: result["mismatches"] = mismatches[:20] stamp = {"jsonld": jsonld, "unique_content_count": unique_count} - # ---- 2차 게이트: 렌더 산출물 기준 ---- + # 2차 게이트: 렌더 산출물 기준 gate = publish_gate.evaluate( PlaceCategory(place.category), snapshot["facts"], unique_count_raw, mismatches ) @@ -314,7 +313,7 @@ async def run_build(job: dict) -> dict: if indexnow_result: result["indexnow"] = indexnow_result - # ---- 빌드 성공 ---- + # 빌드 성공 await DB_SESSION_MNG.execute_lambda_claim( site_versions.DBType(), lambda s: _site_crud.finish_version( @@ -330,7 +329,6 @@ async def run_build(job: dict) -> dict: ) result["build_status"] = "BUILT" result["routes"] = report.get("routes") - # 빌드가 렌더·인프라 실패 없이 끝났다 — 직전에 build_failed 알림이 안 풀린 채 있었으면 지금 풀렸다는 뜻이다(정상 발행이 재개됐다). await alert_service.resolve_alert(f"build_failed:{place_id}", f"발행 재개 — {place_name or place_id}") if want_publish: @@ -352,12 +350,10 @@ async def run_build(job: dict) -> dict: s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value} ), ) - # 승인된 미니 블로그 글은 이 굽기에 실렸다 — 이제 게재로 넘긴다(docs/MINI_BLOG.md). await DB_SESSION_MNG.execute_lambda_run( [place_posts.DBType()], [lambda s: _post_crud.mark_published(s, uuid.UUID(place_id), version.site_version_id)], ) - # 검수를 통과한 후기도 이 버전에 실렸다 — 어느 굽기에 들어갔는지 남긴다. await DB_SESSION_MNG.execute_lambda_run( [place_reviews.DBType()], [lambda s: _stamp_reviews(s, uuid.UUID(place_id), version.site_version_id)], diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index 1957dcc..8e8d576 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -1,7 +1,4 @@ -"""채널 발견 → 확정 URL 크롤링 → fact·사진 후보 저장. - -수집 결과는 사용자가 승인하기 전까지 사이트에 노출하지 않는다. -""" +"""채널 발견 → 확정 URL 크롤링 → fact·사진 후보 저장.""" import re import uuid @@ -31,9 +28,9 @@ class CollectAborted(PermanentJobError): """재시도해도 소용없는 중단 — 잡의 last_error 로 남아 운영자가 본다.""" -# ---- 단계 1: 채널 URL 발견 ------------------------------------------------- +# 단계 1: 채널 URL 발견 async def _add_link(place_id: str, channel, url: str, title: str, discovered_by, raw=None) -> bool: - """링크 한 건 적재. 이미 있으면 False(유니크 충돌은 재수집의 정상 경로다).""" + """링크 한 건 적재.""" err = await DB_SESSION_MNG.execute_lambda_run( [place_channels.DBType()], [lambda s: _place_crud.add_link(s, place_channels( @@ -45,30 +42,12 @@ async def _add_link(place_id: str, channel, url: str, title: str, discovered_by, async def discover_naver_place(place, place_id: str) -> str: - """상호·주소로 네이버 플레이스 링크를 직접 찾아 등록한다. - - 반환값은 결과 상태다 — 호출측이 "자동으로 못 찾았다"를 사장님 화면까지 올려야 하므로 - 성공/실패를 bool 로 뭉개지 않는다: - "resolved" 상호가 일치하는 place id 를 찾아 새로 등록·확정했다 - "already" 같은 URL 이 이미 있었다(재수집의 정상 경로) — 확정만 다시 걸었다 - "not_found" 상호가 일치하는 후보가 없다 → **자동 등록하지 않는다**(남의 가게 방지) - - ★ 왜 Perplexity 에 맡기지 않나 - 실측(2026-08-27 '도플로'·'버터브루'·'힐튼 가든 인 서울 강남'): Perplexity 는 야놀자만 - 물어오고 네이버 플레이스는 **한 건도** 못 찾았다. 도메인 필터를 넓혀도 안내 페이지 - (pages.map.naver.com/useful-tips)가 걸릴 뿐이었다. 검색 언어모델은 "이 가게의 공식 - 플레이스 주소"를 안정적으로 집어내지 못한다 — 게다가 검색 1회당 요금이 붙는다. - - 그런데 우리는 이미 **이 가게가 누구인지 안다**(동일 업소 검증을 통과한 상호·주소). - 추측할 이유가 없으므로 상호가 일치하는 place id 를 직접 해석한다. 일치하지 않으면 - 등록하지 않는다 — 남의 가게를 공식 채널로 붙이는 것이 여기서 가장 비싼 실수다. - """ + """상호·주소로 네이버 플레이스 링크를 직접 찾아 등록한다.""" place_id_naver = await naver_place_lookup.find_place_id( place.name, place.road_address or place.address ) if not place_id_naver: - # ★ 조용히 넘어가지 않는다 — 이 실패가 곧 "사장님이 주소를 직접 넣어야 한다"는 신호다. - # 실측: '롯데호텔 서울' 이 여기서 떨어졌다(네이버 표기와 상호가 갈린다). + # 조용히 넘어가지 않는다 — 이 실패가 곧 "사장님이 주소를 직접 넣어야 한다"는 신호다. LOG.i(f"[collect] 네이버 플레이스 자동 해석 실패 — 사장님이 주소를 붙여넣어야 한다 (place={place_id})") return "not_found" @@ -78,14 +57,7 @@ async def discover_naver_place(place, place_id: str) -> str: f"{place.name} 네이버 플레이스", SourceType.API, ) - # ★ 이 링크만은 자동 확정한다. - # - # 보통 확정은 사람이 한다 — 검색모델이 물어온 URL 은 "비슷한 이름의 다른 가게"일 수 있어서, - # 그걸 자동 확정하면 남의 가게 페이지를 긁어 이 가게 사이트에 싣게 된다. - # 그런데 이 링크는 그 경로로 온 것이 아니다. **상호가 정확히 일치할 때만** 등록되고 - # (find_place_id 가 불일치면 None), 그 상호는 이미 동일 업소 검증을 통과한 값이다. - # 근거가 사람 확인과 같은 수준이므로 클릭을 한 번 더 받는 것은 이득 없이 막기만 한다 — - # 실제로 이 클릭 때문에 힐튼·도플로가 "수집했는데 0건"으로 끝났다. + # 이 링크만은 자동 확정한다. err, _rows = await DB_SESSION_MNG.execute_lambda_claim( place_channels.DBType(), lambda s: _place_crud.confirm_link_by_url(s, uuid.UUID(place_id), url, place.verified_by, GTime.UTC()), @@ -95,24 +67,7 @@ async def discover_naver_place(place, place_id: str) -> str: async def discover_official_site(place, place_id: str) -> str: - """네이버 **지역검색**이 주는 업체 자체 홈페이지를 채널로 등록한다. - - 반환 규약은 `discover_naver_place` 와 같다("resolved" / "already" / "not_found"). - - ★ 왜 이게 필요한가 (실측 2026-09-10, 스테이,머뭄) - 네이버 플레이스는 숙박에서 **fact 3건**(주차·와이파이·휠체어)밖에 주지 않는다. - 체크인·체크아웃·취소규정·객실은 한 건도 없다. TourAPI 는 미등록 업소면 0건이고, - 네이버 예약 페이지와 인스타그램은 robots 가 자동 수집을 금지한다. 그러면 남는 - 공개 출처는 **업소 자체 홈페이지 하나뿐**인데, 그 주소를 우리는 이미 받고 있었다 — - 지역검색 응답의 `link` 다. 그걸 아무도 등록하지 않아 버려지고 있었다 - (`external/naver.py` 머리주석: "채널 URL 발견에 쓸 수 있는 부수입이다"). - 숙박은 자체 홈페이지 보유율이 3업종 중 가장 높다(표본 25건 중 19건). - - ★ 지어내지 않는다. 검색으로 URL 을 **추측**하는 Perplexity 경로와 다르다 — - 이건 네이버가 그 업소 레코드에 달아 둔 값이고, 동일 업소 판정(`pick_match`)을 - 통과했을 때만 쓴다. 그래서 `discover_naver_place` 와 같은 근거로 자동 확정한다. - - """ + """네이버 **지역검색**이 주는 업체 자체 홈페이지를 채널로 등록한다.""" from services.external import naver as naver_client client = naver_client.NaverLocalClient() @@ -120,8 +75,7 @@ async def discover_official_site(place, place_id: str) -> str: return "not_found" address = place.road_address or place.address or "" - # ★ `naver.region_key()` 를 쓰면 안 된다 — 그건 지명이 아니라 **행정구역 코드**('52군산시')다. - # 검색어에 넣으면 후보가 0건이 된다(실측 2026-09-10). 사람이 읽는 지역 토막을 쓴다. + # `naver.region_key()` 를 쓰면 안 된다 — 그건 지명이 아니라 **행정구역 코드**('52군산시')다. query = " ".join(x for x in (place.name, naver_place_lookup.region_hint(address)) if x) try: candidates = await client.search_local(query) @@ -146,19 +100,7 @@ async def discover_official_site(place, place_id: str) -> str: async def discover_tour_api(place, place_id: str) -> str: - """검증된 상호·좌표로 TourAPI 콘텐츠를 찾아 등록한다. 반환값은 discover_naver_place 와 같은 규약이다. - - ★ 왜 필요한가 (2026-08-31) - `tour_api` 어댑터를 등록해도 **아무도 TourAPI URL 을 만들어주지 않아** 영영 안 불렸다. - 채널 발견 프롬프트는 "야놀자·여기어때·네이버 플레이스" 만 찾기 때문이다. - 실측: 롯데호텔 월드가 네이버 플레이스 하나로만 수집돼 fact 4건 · FAQ 2건에서 끝났다. - 같은 업소를 TourAPI 로 받으면 **fact 93건 · 객실 11개 · 사진 30장**이다. - - ★ 자동 확정하는 이유는 네이버 플레이스와 같다 — 추측이 아니라 해석이기 때문이다. - tour_lookup 이 **상호 일치 + 좌표 500m 이내**를 모두 통과할 때만 id 를 돌려준다 - (부산 좌표로 '롯데호텔 월드' 를 조회하면 314km 떨어져 None 이 된다). - 근거가 사람 확인과 같은 수준이라 클릭을 한 번 더 받는 것은 막기만 한다. - """ + """검증된 상호·좌표로 TourAPI 콘텐츠를 찾아 등록한다.""" if not tour_lookup.is_configured(): return "not_configured" @@ -192,10 +134,7 @@ async def discover_tour_api(place, place_id: str) -> str: def _same_business(place_name: str, picked_name: str) -> bool: - """상호 문자열이 같은 업소를 가리키는지 느슨하게 판정한다. - - ★ 야놀자는 상호로 정확히 질의하는 공식 API 가 없다 — 주소 검색 첫 결과가 진짜 - 이 업소인지 이 상호 비교로 한 번 더 확인한 뒤에만 자동 확정한다.""" + """상호 문자열이 같은 업소를 가리키는지 느슨하게 판정한다.""" def norm(s: str) -> str: return re.sub(r"[\s,.\-·]+", "", s or "").lower() p, q = norm(place_name), norm(picked_name) @@ -203,13 +142,7 @@ def _same_business(place_name: str, picked_name: str) -> bool: async def discover_yanolja(place, place_id: str) -> str: - """주소로 야놀자(NOL) 상세페이지를 검색해 등록한다. 반환 규약은 discover_naver_place 와 같다. - - ★ 상호로 직접 질의하는 공식 API 가 없어 주소 검색 결과에 의존한다. 그래서 - `discover_naver_place`·`discover_tour_api` 처럼 "해석"이라 부르기엔 근거가 약하다 — - 검색 결과 상호가 place.name 과 겹치는지(`_same_business`) 확인했을 때만 자동 확정하고, - 아니면 등록하지 않는다(남의 가게가 섞이는 것을 막는다). - """ + """주소로 야놀자(NOL) 상세페이지를 검색해 등록한다.""" address = place.road_address or place.address if not address: return "not_found" @@ -239,18 +172,10 @@ async def discover_yanolja(place, place_id: str) -> str: async def discover_links(place, place_id: str, *, include_perplexity: bool = False) -> dict: - """채널 URL 을 찾아 place_channels 에 적재한다(미확정 상태). - - ★ 순서가 곧 신뢰도다. 지금 자동 발견 경로는 **네이버 플레이스 직접 해석 하나뿐**이고, - Perplexity 는 사용자가 추가 채널 탐색 옵션을 고른 회차에만 실행한다. - 둘 다 실패하면 그건 정상적인 결말이다 — 사장님이 네이버 플레이스 주소를 붙여넣는 - 경로가 1순위이기 때문이다(화면 3단계가 그 입력을 맨 위에 둔다). - - ★ Perplexity 답변을 사실로 쓰지 않는다 — URL 발견 전용이다. 본문은 raw 에 박제만 한다.""" + """채널 URL 을 찾아 place_channels 에 적재한다(미확정 상태).""" stat = {"discovered": 0, "skipped_duplicate": 0, "searches": 0, "enabled": False} # 네이버 플레이스는 검색모델에 맡기지 않고 직접 해석한다(위 주석 참고). - # Perplexity 설정 여부와 무관하게 먼저 시도한다 — 키가 없어도 이건 된다. try: stat["naver_place"] = await discover_naver_place(place, place_id) if stat["naver_place"] == "resolved": @@ -260,7 +185,6 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal stat["naver_place"] = "error" # TourAPI 도 같은 성격의 '직접 해석' 이다 — 검색모델을 거치지 않고, 키가 있으면 항상 시도한다. - # ★ 네이버보다 훨씬 많은 fact 를 준다(실측 롯데호텔 월드: 네이버 4건 vs TourAPI 93건). try: stat["tour_api"] = await discover_tour_api(place, place_id) if stat["tour_api"] == "resolved": @@ -270,7 +194,6 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal stat["tour_api"] = "error" # 자체 홈페이지 — 네이버 지역검색이 이미 준 값이라 추가 요금이 없다(위 함수 머리주석). - # ★ 숙박에서 이게 체크인·취소규정·객실을 가진 유일한 공개 출처인 경우가 많다. try: stat["official_site"] = await discover_official_site(place, place_id) if stat["official_site"] == "resolved": @@ -289,8 +212,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal collect_diagnostics.note_issue("yanolja", place.name, ex) stat["yanolja"] = "error" - # 오직 요청 옵션으로만 연다. 서버 env 로 일괄 활성화하면 일반 크롤링·재수집에서도 - # 사용자가 모르는 유료 검색이 반복될 수 있으므로 COLLECT_USE_PERPLEXITY 는 더 쓰지 않는다. + # 오직 요청 옵션으로만 연다. if not include_perplexity: LOG.i("[collect] 추가 채널 자동 발견 미선택 — Perplexity URL 검색 건너뜀") return stat @@ -310,13 +232,13 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal stat["enabled"] = False return stat except perplexity.PerplexityError as ex: - # ★ 실패해도 파이프라인을 죽이지 않는다 — 이미 등록된 링크로 크롤링은 계속한다. + # 실패해도 파이프라인을 죽이지 않는다 — 이미 등록된 링크로 크롤링은 계속한다. collect_diagnostics.note_issue("perplexity_discover", place.name, ex) stat["error"] = str(ex)[:200] return stat stat["searches"] = found.search_count - # 필터 탈락 내역 — 조용히 버리지 않는다. 운영자가 "왜 이 URL 이 빠졌나" 를 볼 수 있어야 한다. + # 필터 탈락 내역 — 조용히 버리지 않는다. if hasattr(found, "reason_counts"): stat["filtered_out"] = found.reason_counts() now = GTime.UTC() @@ -343,15 +265,9 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal return stat -# ---- 단계 2: 크롤링 대상 확정 ---------------------------------------------- +# 단계 2: 크롤링 대상 확정 async def confirm_targets(place, place_id: str, only_link_ids: list[str] | None) -> tuple[list, dict]: - """크롤링할 링크를 고른다. - - ★ 사업장이 카카오 로컬로 동일 업소 검증을 통과해야만 여기까지 온다(호출측이 막는다). - 검증된 사업장에 대해 도메인 필터를 통과한 URL 이므로 자동 확정한다. - **그래도 여기서 나온 값은 전부 후보로 들어간다** — 사람이 승인해야 사이트에 나간다. - 이 2중 방어가 '남의 가게 정보가 사이트에 실리는 것'을 막는 실제 장치다. - """ + """크롤링할 링크를 고른다.""" stat = {"confirmed": 0, "already": 0, "unsupported": 0} err, links = await DB_SESSION_MNG.execute_lambda( place_channels.DBType(), @@ -388,11 +304,9 @@ async def confirm_targets(place, place_id: str, only_link_ids: list[str] | None) return targets, stat -# ---- 단계 3: 크롤링 ------------------------------------------------------- +# 단계 3: 크롤링 async def coverage(place, place_id: str) -> dict: - """이 사업장이 사이트를 만들 만큼 정보를 갖췄는지 — 업종 스키마의 필수 항목 기준. - - 이미 확보한 fact(노출값·후보 모두)의 key 를 세어 required 를 얼마나 덮었는지 본다.""" + """이 사업장이 사이트를 만들 만큼 정보를 갖췄는지 — 업종 스키마의 필수 항목 기준.""" schema = get_schema(PlaceCategory(place.category)) required = set(schema.required_keys("place")) @@ -413,7 +327,7 @@ async def coverage(place, place_id: str) -> dict: async def fetch_one(link, category=None): - """링크 하나를 긁는다. 실패해도 예외를 던지지 않는다 — 나머지 링크가 살아야 한다.""" + """링크 하나를 긁는다.""" try: adapter = REGISTRY.get_adapter(link.url) except (AdapterNotFound, AdapterDisabled) as ex: @@ -431,11 +345,9 @@ async def fetch_one(link, category=None): return source, "fetched" -# ---- 단계 4: 하위 단위 시드 ------------------------------------------------ +# 단계 4: 하위 단위 시드 async def ensure_units(place_id: str, sources: list) -> dict: - """수집된 unit_name(객실·메뉴·프로그램)을 place.units 에 맞춰 {이름: unit_id} 를 만든다. - - 수집 시점엔 unit_id 를 모르므로 이름으로 매핑한다. 없는 이름만 새로 만든다.""" + """수집된 unit_name(객실·메뉴·프로그램)을 place.units 에 맞춰 {이름: unit_id} 를 만든다.""" names = [] for source in sources: for name in source.unit_names(): @@ -468,13 +380,9 @@ async def ensure_units(place_id: str, sources: list) -> dict: return existing -# ---- 단계 5: fact / 사진 적재 ---------------------------------------------- +# 단계 5: fact / 사진 적재 async def store_facts(user_info, place_id: str, sources: list, unit_map: dict) -> dict: - """수집값을 fact 로 적재한다. - - ★ FactService 를 그대로 통과시킨다 — 업종 스키마 검증 · 출처 필수 · LLM 제한 · - 정정본 보호 · 노출값 유지가 전부 거기 있다. 크롤러가 우회할 수 있는 뒷문을 만들지 않는다. - """ + """수집값을 fact 로 적재한다.""" service = FactService(_fact_crud, _place_crud) stat = {"stored": 0, "refreshed": 0, "candidate": 0, "rejected": 0, "by_reason": {}} @@ -506,11 +414,7 @@ async def store_facts(user_info, place_id: str, sources: list, unit_map: dict) - async def store_media(place_id: str, sources: list, unit_map: dict) -> dict: - """수집한 사진을 적재한다. 같은 origin_url 은 다시 넣지 않는다(재수집 멱등). - - ★ source_type=CRAWL 과 origin_url 을 반드시 남긴다 — 이미지 재게시 권리가 미결이라 - 결론에 따라 발행 시 통째로 걸러낼 수 있어야 한다(docs/DECISIONS.md 1-2). - ★ Vision 분석 전이므로 status 는 PENDING_REVIEW 다. 사람 확인 큐로 간다.""" + """수집한 사진을 적재한다.""" from sqlalchemy import select err, rows = await DB_SESSION_MNG.execute_lambda( @@ -549,9 +453,9 @@ async def store_media(place_id: str, sources: list, unit_map: dict) -> dict: return stat -# ---- 오케스트레이션 -------------------------------------------------------- +# 오케스트레이션 async def run_collect(job: dict) -> dict: - """COLLECT 잡 핸들러. 반환값이 jobs.result 에 저장돼 폴링·감사에 쓰인다.""" + """COLLECT 잡 핸들러.""" with collect_diagnostics.collecting(): result = await _run_collect(job) issues = collect_diagnostics.snapshot() @@ -572,7 +476,7 @@ async def _run_collect(job: dict) -> dict: ) if err != ErrorType.SUCCESS or place is None: raise CollectAborted(f"사업장을 찾을 수 없다: {place_id}") - # ★ 동일 업소 검증 게이트 — 잡 실행 시점에도 다시 확인한다(적재 후 취소됐을 수 있다). + # 동일 업소 검증 게이트 — 잡 실행 시점에도 다시 확인한다(적재 후 취소됐을 수 있다). if place.verified_at is None: raise CollectAborted("동일 업소 검증(verify) 전에는 수집하지 않는다 — 남의 가게가 섞인다") @@ -584,12 +488,7 @@ async def _run_collect(job: dict) -> dict: ) targets, result["confirm"] = await confirm_targets(place, place_id, payload.get("link_ids")) - # ★ 자동 해석 실패를 사장님에게 알리는 유일한 창구. - # 지금 네이버 플레이스는 "URL 만 있으면 100%, 상호로 찾는 건 4곳 중 3곳" 이다. - # 못 찾았을 때 조용히 넘어가면 사장님은 "수집했는데 아무것도 안 나왔다"만 본다 — - # 실제로 해야 할 일(네이버 지도에서 내 가게 주소를 복사해 붙여넣기)을 화면이 말해줄 수 - # 있도록 결과에 싣는다. 판정 기준은 "지금 긁을 네이버 플레이스 링크가 있느냐" 다: - # 자동 해석이 실패해도 사장님이 이미 붙여넣었으면 알릴 이유가 없다. + # 자동 해석 실패를 사장님에게 알리는 유일한 창구. result["naver_place_missing"] = not any( link.channel == LinkChannel.NAVER_PLACE.value for link in targets ) @@ -599,7 +498,7 @@ async def _run_collect(job: dict) -> dict: await _finish(place_id, owner_user_id, PlaceStatus.REVIEW) return result - # ★ 이미 충분하면 크롤링 자체를 건너뛴다(force 가 아닐 때). + # 이미 충분하면 크롤링 자체를 건너뛴다(force 가 아닐 때). before = await coverage(place, place_id) if before["enough"] and not payload.get("force"): result["coverage"] = before @@ -611,18 +510,13 @@ async def _run_collect(job: dict) -> dict: from common.models.gmodel import UserInfo actor = UserInfo( - # ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로 - # 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다. - # 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가 - # 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다. + # 잡이 쓰는 신원. user_id=owner_user_id, id="collector", role=1, ) - # ★ 링크를 하나씩 긁고 **바로 적재한 뒤** 충분한지 본다. - # 전부 긁어놓고 나중에 적재하면 "이미 충분한데 더 긁는" 낭비를 못 막는다. - # 단계마다 커밋되므로 중간에 죽어도 여기까지의 결과는 남는다. + # 링크를 하나씩 긁고 **바로 적재한 뒤** 충분한지 본다. fetch_stat = {"fetched": 0, "failed": 0, "no_adapter": 0, "skipped_enough": 0, "stopped_early": False} facts_stat = {"stored": 0, "refreshed": 0, "candidate": 0, "rejected": 0, "by_reason": {}} media_stat = {"stored": 0, "skipped_duplicate": 0} @@ -643,8 +537,7 @@ async def _run_collect(job: dict) -> dict: if source is None: continue - # ★ 수집 원문을 링크에 남긴다 — fact 가 아니라 '생성 근거' 자리다. - # intro 같은 allow_llm 필드에 원문을 넣으면 발행본이 원문으로 덮인다(2026-08-31 사고). + # 수집 원문을 링크에 남긴다 — fact 가 아니라 '생성 근거' 자리다. if (source.text or "").strip(): await DB_SESSION_MNG.execute_lambda_claim( place_channels.DBType(), @@ -673,22 +566,14 @@ async def _run_collect(job: dict) -> dict: result["coverage"] = await coverage(place, place_id) # 사진이 들어왔으면 분석을 이어서 건다 — 수집과 분석은 각각 몇 분이라 한 잡에 묶지 않는다. - # (묶으면 분석에서 죽었을 때 수집까지 다시 하게 되고, 유료 API 를 두 번 태운다.) if result["media"]["stored"] > 0: result["vision_job_id"] = await _enqueue_vision(place_id, owner_user_id) - # ★ 지역 데이터(주변 맛집·관광지·축제 + 지역 이야기)를 **여기서** 건다. - # 수집이 끝난 시점이 좌표·행정구역이 확정되는 가장 이른 자리다. 사장님이 템플릿을 고르는 - # 동안(Step4) 백그라운드로 돌아, 생성 단계(Step5)에 닿을 즈음이면 대개 끝나 있다 — - # 전에는 에디터에 들어간 뒤에야 시작해서 첫 화면이 늘 절반만 그려졌다. + # 지역 데이터(주변 맛집·관광지·축제 + 지역 이야기)를 **여기서** 건다. from services import story_service result["local_job_id"] = await story_service.enqueue_region_job(place) # ── 업소 조사 — 소개문을 쓸 재료 ────────────────────────────────── - # ★ 수집이 끝난 **뒤**에 한다. 앞에서 하면 네이버·TourAPI 가 이미 준 것을 다시 묻는 - # 꼴이고, 검색 요금이 그만큼 헛돈다. 수집이 얇게 끝났을 때 그 구멍을 메우는 자리다. - # ★ fact 를 만들지 않는다(place_research 머리주석) — 소개문 생성의 근거만 쌓는다. - # ★ 실패해도 수집은 성공이다. 재료가 적을 뿐 발행은 된다. try: from services import place_research result["research"] = await place_research.research_place(place, place_id) @@ -702,23 +587,7 @@ async def _run_collect(job: dict) -> dict: async def _store_booking_link(place, place_id: str, source) -> bool: - """수집 중 채널이 알려준 예약 주소를 **예약 채널 링크**로 남긴다. - - ★ 왜 필요한가 (실측 2026-09-08) - 발행본의 "예약" 버튼이 네이버 플레이스 링크를 그대로 열었다. 그 링크는 잘해야 플레이스 - 홈이라 예약까지 한 번 더 눌러야 하고, 자동 발견이 검색 URL(`map.naver.com/p/search/…`)을 - 물어온 경우에는 **검색 결과 화면**이 뜬다. 예약하려고 누른 손님이 검색 결과를 만나면 - 거기서 끝난다. - - ★ 주소를 만들지 않는다. 플레이스 응답의 `naverBookingUrl` 을 그대로 쓴다 - (naver_place_adapter._booking_url 머리주석). 예약을 받지 않는 업소에는 이 값이 없고, - 없으면 링크도 없다 — 없는 예약 창구를 만들어내지 않는다. - - ★ 자동 확정한다. 근거는 `discover_naver_place` 와 같다 — 이 URL 은 **이미 확정된** - 플레이스 페이지가 자기 예약 주소로 내놓은 값이라, 남의 가게가 섞일 경로가 없다. - 여기서 클릭을 한 번 더 받으면 사장님이 확정을 안 한 사이트는 예약 버튼이 계속 - 검색 화면으로 간다. - """ + """수집 중 채널이 알려준 예약 주소를 **예약 채널 링크**로 남긴다.""" url = (getattr(source, "booking_url", None) or "").strip() if not url: return False @@ -746,7 +615,7 @@ async def _finish(place_id: str, owner_user_id: str, status: PlaceStatus): async def _enqueue_vision(place_id: str, owner_user_id: str) -> str | None: - """사진 분석 잡을 적재한다. 키가 없거나 중복이면 조용히 건너뛴다(수집 자체는 성공이다).""" + """사진 분석 잡을 적재한다.""" from common.enums import JobType from crud.job_crud import JobQueue from services.external import gemini diff --git a/solution/backend/services/collector/__init__.py b/solution/backend/services/collector/__init__.py index 76a5ac3..662e6a4 100644 --- a/solution/backend/services/collector/__init__.py +++ b/solution/backend/services/collector/__init__.py @@ -1,12 +1,4 @@ -"""수집 파이프라인의 소스 어댑터 계층. - -어댑터는 URL 하나를 받아 RawSource 하나를 돌려준다. 어디서 긁어오든(가짜든 실제든) -결과 모양이 같으므로, 그 뒤 단계(fact 후보 적재 · 사람 확인 큐 · 사이트 빌드)는 -수집 방식이 바뀌어도 손대지 않는다. - -★ Phase 1 은 MockAdapter 만 등록한다 — 크롤링 법적 검토 대기(docs/DECISIONS.md 1-1). - 캡차 우회 · 봇 탐지 우회 · IP 회전은 검토 결과와 무관하게 영구 금지다. -""" +"""수집 파이프라인의 소스 어댑터 계층.""" from services.collector.base import ( AdapterDisabled, AdapterNotFound, diff --git a/solution/backend/services/collector/base.py b/solution/backend/services/collector/base.py index 3ef27b2..8c488af 100644 --- a/solution/backend/services/collector/base.py +++ b/solution/backend/services/collector/base.py @@ -1,12 +1,4 @@ -"""수집 어댑터 계약 — '어디서 긁어오든 결과 모양은 하나' 를 강제한다. - -어댑터는 URL 하나를 받아 RawSource 하나를 돌려준다. 그 안에는 원문과 함께 -**출처(source_url)** 가 반드시 들어 있다. 수집된 값이 fact 로 넘어갈 때 -출처 없이 넘어가면 그 사실은 검증도 추적도 불가능해지기 때문에, -여기서 구조적으로 막는다(RawSource 가 생성 시점에 모든 fact/media 에 출처를 찍는다). - -어댑터를 늘리는 방법은 registry.py 에 등록하는 것뿐이다. 이 파일은 계약만 정의한다. -""" +"""수집 어댑터 계약 — '어디서 긁어오든 결과 모양은 하나' 를 강제한다.""" from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Optional, Protocol, runtime_checkable @@ -14,34 +6,27 @@ from typing import Optional, Protocol, runtime_checkable from common.enums import LinkChannel, PlaceCategory -# ---- 도메인 예외 ----------------------------------------------------------- +# 도메인 예외 class CollectError(RuntimeError): """수집 계층 공통 예외.""" class AdapterNotFound(CollectError): - """이 URL 을 처리할 어댑터가 없다. → ErrorType.COLLECT_ADAPTER_NOT_FOUND""" + """이 URL 을 처리할 어댑터가 없다.""" class AdapterDisabled(CollectError): - """어댑터는 존재하나 이 환경에서 꺼져 있다(법무 검토 전 등). → ErrorType.COLLECT_ADAPTER_DISABLED""" + """어댑터는 존재하나 이 환경에서 꺼져 있다(법무 검토 전 등).""" class FetchFailed(CollectError): - """가져오기 자체가 실패했다(네트워크·파싱). → ErrorType.COLLECT_FETCH_FAILED""" + """가져오기 자체가 실패했다(네트워크·파싱)""" -# ---- 수집 결과 구성요소 ----------------------------------------------------- +# 수집 결과 구성요소 @dataclass class CollectedFact: - """수집된 fact 후보 1건. - - scope='place' 면 사업장 단위, 'unit' 이면 객실·메뉴·프로그램 단위다. - unit 단위는 같은 key 가 단위 수만큼 반복되므로(A동 기준인원 / B동 기준인원) - dict 가 아니라 리스트로 모은다. unit_name 은 place.units.name 과 맞춰 매핑하는 힌트다. - - source_url 은 RawSource 가 생성 시점에 찍어준다 — 비워둔 채로 만들어도 출처 없이 흘러가지 않는다. - """ + """수집된 fact 후보 1건.""" key: str value: Optional[str] @@ -58,23 +43,13 @@ class CollectedFact: @dataclass class CollectedMedia: - """수집된 사진 1장. - - origin_url 은 ★ 반드시 남긴다 — 크롤링 이미지의 재게시 권리가 미결이라(docs/DECISIONS.md 1-2), - 결론에 따라 출처별로 걸러낼 수 있어야 한다. - label 은 Gemini Vision 이 확정하기 전의 후보 라벨(페이지에서 주운 캡션 등)일 뿐이다. - - license 는 **출처가 라이선스를 명시한 경우에만** 채운다(예: TourAPI 의 공공누리 `Type1`/`Type3`). - ★ 왜 필요한가 — 공공누리 제3·4유형은 **변경 금지**다. 크롭·리사이즈도 변형에 해당할 수 있어 - 썸네일을 만들면 조건을 어긴다. 어느 사진이 손대면 안 되는 사진인지는 수집 시점에만 알 수 있으므로 - 여기서 들고 나간다. 값이 None 이면 '라이선스 미상' 이며, 재게시 판단은 여전히 1-2 결론을 따른다. - """ + """수집된 사진 1장.""" origin_url: str label: Optional[str] = None unit_name: Optional[str] = None source_url: str = "" # RawSource.__post_init__ 이 채운다 - license: Optional[str] = None # 출처가 밝힌 이용 조건 코드. 미상이면 None + license: Optional[str] = None # 출처가 밝힌 이용 조건 코드. def __post_init__(self): if not (self.origin_url or "").strip(): @@ -83,11 +58,7 @@ class CollectedMedia: @dataclass class RawSource: - """어댑터 1회 수집 결과. - - ok=False 면 error 만 의미가 있다(facts/media 는 비어 있다). 부분 실패도 결과로 돌려주고 - 예외를 던지지 않는 것이 기본이다 — 채널 하나가 막혀도 나머지 수집은 이어져야 한다. - """ + """어댑터 1회 수집 결과.""" url: str adapter_id: str @@ -99,9 +70,7 @@ class RawSource: text: Optional[str] = None # 태그 걷어낸 본문 facts: list[CollectedFact] = field(default_factory=list) media: list[CollectedMedia] = field(default_factory=list) - # ★ 수집 중 **그 채널이 스스로 알려준** 예약 주소. 우리가 만든 주소가 아니다. - # 네이버 플레이스 응답의 naverBookingUrl 이 여기 실린다 — 발행본의 "예약" 버튼이 - # 플레이스 홈(한 번 더 눌러야 한다)이나 검색 결과가 아니라 예약 화면으로 바로 가게 하는 값. + # 수집 중 **그 채널이 스스로 알려준** 예약 주소. booking_url: Optional[str] = None def __post_init__(self): @@ -109,20 +78,18 @@ class RawSource: raise CollectError("RawSource.url 이 비었다 — 출처 없는 수집 결과는 만들 수 없다") if not (self.adapter_id or "").strip(): raise CollectError("RawSource.adapter_id 가 비었다") - # ★ 출처 강제: 개별 항목이 출처를 안 들고 있으면 여기서 찍는다. - # fact 로 변환될 때 source_url 이 비면 FACT_SOURCE_REQUIRED 로 거부되므로, - # 그 전에 구조가 보장한다. + # 출처 강제: 개별 항목이 출처를 안 들고 있으면 여기서 찍는다. for item in (*self.facts, *self.media): if not item.source_url: item.source_url = self.url @property def source_url(self) -> str: - """이 결과 전체의 출처. facts/media 의 source_url 과 같은 값이다.""" + """이 결과 전체의 출처.""" return self.url def fact_map(self) -> dict: - """사업장 단위 fact 만 {key: value} 로. 단위 스코프는 key 가 겹치므로 제외한다.""" + """사업장 단위 fact 만 {key: value} 로.""" return {f.key: f.value for f in self.facts if f.scope == "place"} def unit_names(self) -> list[str]: @@ -135,20 +102,14 @@ class RawSource: @classmethod def failure(cls, url: str, adapter_id: str, error: str, channel: LinkChannel = LinkChannel.ETC) -> "RawSource": - """실패 결과. 호출측이 예외 처리 없이 ok 만 보고 넘어갈 수 있게 한다.""" + """실패 결과.""" return cls(url=url, adapter_id=adapter_id, channel=channel, ok=False, error=error) -# ---- 어댑터 계약 ----------------------------------------------------------- +# 어댑터 계약 @runtime_checkable class SourceAdapter(Protocol): - """수집 소스 어댑터. - - id : 레지스트리 키이자 로그 식별자 - can_handle : 이 URL 을 처리할 수 있는가(부수효과 없이 즉시 판정) - fetch : 실제 수집. 실패는 RawSource.failure 로 돌려주는 것이 기본이고, - 호출 자체가 불가능한 경우(꺼진 어댑터 등)에만 예외를 던진다 - """ + """수집 소스 어댑터.""" id: str diff --git a/solution/backend/services/collector/mock_adapter.py b/solution/backend/services/collector/mock_adapter.py index 733b9ac..8dc565d 100644 --- a/solution/backend/services/collector/mock_adapter.py +++ b/solution/backend/services/collector/mock_adapter.py @@ -1,19 +1,4 @@ -"""MockAdapter — 네트워크를 타지 않는 가짜 수집기. **Phase 1 에 등록된 유일한 어댑터.** - -크롤링 법적 검토가 끝나지 않아(docs/DECISIONS.md 1-1) 실제 수집 어댑터를 등록할 수 없다. -그렇다고 수집 이후 단계(fact 검증 전이 · 사람 확인 큐 · 사이트 빌드)를 못 만들 이유는 없으므로, -같은 계약(SourceAdapter)을 만족하는 가짜 소스를 두고 파이프라인 전체를 여기에 물려 돌린다. - -**뱉는 fact key 는 전부 업종 스키마(common/category_schema)에 있는 것만 쓴다.** -스키마에 없는 key 는 fact 기록 단계에서 FACT_INVALID_KEY 로 전부 거부되므로, -그런 목데이터는 파이프라인을 검증하지 못하는 무의미한 데이터가 된다. -그래서 이 파일은 기동 시 자기 목데이터를 스키마와 대조하고, 어긋나면 즉시 예외를 던진다. - -URL 규약 (업종을 URL 에서 읽어 결정적으로 동작한다) - mock://lodging/pension-1 - mock://cafe/cafe-1?channel=naver_place - https://mock.test/restaurant/r-1 -""" +"""MockAdapter — 네트워크를 타지 않는 가짜 수집기.""" from typing import Optional from urllib.parse import parse_qs, urlparse @@ -25,7 +10,7 @@ from services.collector.base import CollectedFact, CollectedMedia, CollectError, _SCHEMES = ("mock",) _HOSTS = ("mock.test",) -# URL 에 쓰는 업종 이름 → PlaceCategory. category_schema 의 `name` 과 같은 표기를 쓴다. +# URL 에 쓰는 업종 이름 → PlaceCategory. _CATEGORY_BY_NAME = { "lodging": PlaceCategory.LODGING, "cafe": PlaceCategory.CAFE, @@ -36,9 +21,7 @@ _CATEGORY_BY_NAME = { _CHANNEL_BY_NAME = {c.name.lower(): c for c in LinkChannel} -# ---- 업종별 목데이터 -------------------------------------------------------- -# (key, value) — 전부 해당 업종 스키마에 존재하는 key 여야 한다. -# 소개문 계열(intro/room_intro/…)은 넣지 않는다. 그건 수집물이 아니라 generator 가 쓰는 문장이다. +# 업종별 목데이터 _PLACE_FACTS = { PlaceCategory.LODGING: [ ("check_in_time", "15:00"), @@ -152,7 +135,7 @@ _UNIT_FACTS = { }, } -# 사진 — (라벨 후보, 단위 이름). 단위 이름이 None 이면 사업장 공용 사진. +# 사진 — (라벨 후보, 단위 이름). _MEDIA = { PlaceCategory.LODGING: [ ("외관", None), @@ -183,10 +166,7 @@ _MEDIA = { def _validate_mock_data(): - """목데이터의 모든 key 가 업종 스키마에 있는지 확인한다. 최초 import 시 1회. - - 어긋나면 즉시 예외 — 스키마에 없는 key 로 만든 목데이터는 fact 기록에서 전부 거부되므로, - '테스트는 도는데 실제로는 하나도 안 들어가는' 상태를 배포 전에 드러낸다.""" + """목데이터의 모든 key 가 업종 스키마에 있는지 확인한다.""" for category, rows in _PLACE_FACTS.items(): schema = get_schema(category) for key, _value in rows: @@ -211,11 +191,7 @@ _validate_mock_data() class MockAdapter: - """가짜 수집기. 같은 URL 이면 항상 같은 결과를 돌려준다(fetched_at 만 다르다). - - 업종은 URL 에서 읽는다 — SourceAdapter.fetch(url) 계약을 그대로 지키면서 - 업종별 목데이터를 고를 수 있게 하기 위함이다. - """ + """가짜 수집기.""" id = "mock" @@ -229,10 +205,7 @@ class MockAdapter: return parsed.scheme in ("http", "https") and parsed.hostname in _HOSTS async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource: - """URL 에서 업종을 읽어 그 업종의 목데이터를 돌려준다. - - 업종을 못 읽으면 예외가 아니라 실패 결과(ok=False)로 돌려준다 — - 채널 하나가 이상해도 나머지 수집이 멈추면 안 된다.""" + """URL 에서 업종을 읽어 그 업종의 목데이터를 돌려준다.""" if not self.can_handle(url): return RawSource.failure(url, self.id, f"MockAdapter 가 처리할 수 없는 URL: {url}") @@ -265,10 +238,10 @@ class MockAdapter: media=media, ) - # ---- URL 파싱 ---------------------------------------------------------- + # URL 파싱 @staticmethod def _category_of(url: str): - """mock://lodging/... 또는 https://mock.test/lodging/... 에서 업종을 읽는다.""" + """mock://lodging/...""" parsed = urlparse(url) # mock:// 는 첫 조각이 hostname 으로, https://mock.test/ 는 path 첫 조각으로 들어온다. candidates = [parsed.hostname or ""] + [p for p in parsed.path.split("/") if p] @@ -280,12 +253,12 @@ class MockAdapter: @staticmethod def _channel_of(url: str) -> LinkChannel: - """?channel=yanolja 로 채널을 지정할 수 있다. 없으면 ETC.""" + """?channel=yanolja 로 채널을 지정할 수 있다.""" query = parse_qs(urlparse(url).query) name = (query.get("channel") or [""])[0].lower() return _CHANNEL_BY_NAME.get(name, LinkChannel.ETC) - # ---- 편의 --------------------------------------------------------------- + # 편의 @staticmethod def url_for(category, slug: str = "1", channel: str | None = None) -> str: """이 어댑터가 처리할 수 있는 mock URL 을 만든다(테스트·시드용).""" diff --git a/solution/backend/services/collector/naver_place_adapter.py b/solution/backend/services/collector/naver_place_adapter.py index 71f2cf1..5051074 100644 --- a/solution/backend/services/collector/naver_place_adapter.py +++ b/solution/backend/services/collector/naver_place_adapter.py @@ -1,19 +1,4 @@ -"""네이버 플레이스 어댑터 — 모바일 상세 페이지의 __APOLLO_STATE__ 를 읽는다. - -★ 왜 GraphQL(pcmap-api)이 아니라 모바일 페이지인가 - `o2o-castad-backend/app/utils/nvMapScraper.py` 는 pcmap-api GraphQL 을 직접 친다. - 그 경로는 익명 요청을 IP 단위로 429 로 막는다(호스트·컨테이너 양쪽에서 확인). - 반면 모바일 상세 페이지는 200 을 주고, 같은 데이터가 `window.__APOLLO_STATE__` 에 - 통째로 들어 있다 — 브라우저가 받는 것과 같은 응답을 한 번 받아 파싱할 뿐이다. - 쿠키도 우회 장치도 필요 없다. - -**금지 (docs/DECISIONS.md 1-1, 결론과 무관하게 영구)** - 캡차 우회 · 봇 탐지 우회 · IP 회전. 이 어댑터는 공개 엔드포인트에 평범한 요청 한 번을 - 보내고, 막히면 그대로 실패로 돌려준다. 우회하지 않는다. - -수집물은 전부 **후보**로 들어간다(UNVERIFIED). 사장님이 확인해야 사이트에 나간다 — -그 게이트는 fact 계층이 담당하므로 여기서는 값과 출처만 정확히 만든다. -""" +"""네이버 플레이스 어댑터 — 모바일 상세 페이지의 __APOLLO_STATE__ 를 읽는다.""" import json import os import re @@ -29,7 +14,7 @@ DETAIL_URL = "https://m.place.naver.com/{kind}/{place_id}/home" REQUEST_TIMEOUT = 30 MAX_MEDIA = 30 -# 업종별 상세 경로. 숙박이 아니면 네이버가 다른 경로를 쓴다 — 순서대로 시도한다. +# 업종별 상세 경로. DETAIL_KINDS = ("accommodation", "place", "restaurant") _APOLLO = re.compile(r"window\.__APOLLO_STATE__\s*=\s*(\{.*?\});", re.S) @@ -37,9 +22,7 @@ _APOLLO = re.compile(r"window\.__APOLLO_STATE__\s*=\s*(\{.*?\});", re.S) _HOSTS = ("map.naver.com", "pcmap.place.naver.com", "m.place.naver.com", "place.naver.com", "naver.me") _PLACE_ID = re.compile(r"/place/(\d+)") _PCMAP_ID = re.compile(r"pcmap\.place\.naver\.com/[a-z]+/(\d+)") -# ★ naver.me 단축주소는 /place/ 로 가지 않는다. 앱 딥링크로 리다이렉트되며 id 가 -# 쿼리스트링에 담긴다: m.map.naver.com/appLink.naver?pinId=1273971279&…&id=1273971279 -# 이 형태를 안 보면 사장님이 [공유]로 복사한 주소가 통째로 실패한다(실측). +# naver.me 단축주소는 /place/ 로 가지 않는다. _QUERY_ID = re.compile(r"[?&](?:pinId|id|entryId)=(\d{6,12})") HEADERS = { @@ -49,19 +32,10 @@ HEADERS = { "Referer": "https://m.place.naver.com/", } -# 부정 표현. ★ 이걸 먼저 보지 않으면 "반려동물 동반 **불가**" 가 pet_allowed=true 로 뒤집힌다. -# pet_allowed 는 critical 항목이라(틀리면 예약 클레임) 반드시 부호를 먼저 판정한다. +# 부정 표현. _NEGATIONS = ("불가", "없음", "없슴", "미제공", "제공하지", "안됨", "안 됨", "불가능", "금지") # 네이버가 주는 편의시설 문자열 → 업종 스키마의 bool key. -# -# ★ 표기가 업종마다 다르다. 숙박은 "와이파이", 호텔은 "무선 인터넷", 카페는 "무선인터넷" 으로 온다 — -# 한 표기만 넣으면 있는 시설을 통째로 놓친다(실측: 힐튼에서 wifi 를 못 잡아 fact 가 2건이었다). -# 그래서 실제로 관측된 표기를 전부 적는다. 여기 없는 표현은 fact 로 만들지 않는다 — -# 억지 매핑은 "없다"를 "있다"로 바꾼다. -# -# 여러 업종 스키마에 같은 key 가 있으므로(parking·wifi·pet_allowed) 업종을 가리지 않고 쓴다. -# 스키마에 없는 key 는 fact 기록 단계에서 거부되므로 잘못 들어가도 화면에는 안 나온다. _CONVENIENCE_BOOL = { "주차": "parking", "발렛파킹": "parking", @@ -85,18 +59,13 @@ _CONVENIENCE_BOOL = { "콘센트": "power_outlet", "휠체어": "wheelchair_accessible", "장애인": "wheelchair_accessible", - # ★ "단체 이용 가능"은 여기 넣지 않는다. 스키마의 group_seat_max 는 **인원수**(number)라 - # true 를 넣으면 "단체석 최대 true명"이 된다. 값의 형이 다르면 매핑하지 않는다. + # "단체 이용 가능"은 여기 넣지 않는다. } -# 업종을 가리지 않고 "영업시간" 자리를 차지하는 key. 스키마에 없는 것은 기록 단계에서 걸러진다. +# 업종을 가리지 않고 "영업시간" 자리를 차지하는 key. _HOURS_KEYS = ("business_hours", "operating_hours", "reception_hours") -# ★ 요금표(`Menu:*`) 항목 이름 앞에 붙는 요일 구분. -# 네이버는 모텔·펜션의 요금을 "평일 대실 / 주말(금,토) 숙박" 처럼 **요일 × 상품** 으로 준다. -# 이걸 이름 그대로 단위로 만들면 같은 상품이 요일 수만큼 쪼개져 "객실 4개"가 된다 — -# 실제로는 상품 2개(대실·숙박)에 주중·주말 요금이 각각 붙은 것이다. -# 그래서 요일 토큰을 떼어 **상품 이름으로 묶고**, 요금은 weekday/weekend 로 갈라 담는다. +# 요금표(`Menu:*`) 항목 이름 앞에 붙는 요일 구분. _WEEKDAY_TOKENS = ("평일", "주중") _WEEKEND_TOKENS = ("주말", "금토", "토일", "공휴일") # 요일 토큰과, 바로 뒤에 붙는 괄호 보충설명("주말(금,토)")까지 한 번에 걷어낸다. @@ -121,12 +90,7 @@ class NaverPlaceAdapter: id = "naver_place" def _headers(self) -> dict: - """요청 헤더. 쿠키는 있으면 얹고 없으면 그냥 간다. - - ★ NAVER_COOKIES 는 **우회 장치가 아니다**. 로그인 없이도 응답이 오지만 익명 요청은 - 자주 429 로 막히므로, 정상적으로 보유한 세션을 그대로 쓰는 통로만 열어둔다. - IP 회전·핑거프린트 위조 같은 봇 탐지 우회는 하지 않는다(docs/DECISIONS.md 1-1). - """ + """요청 헤더.""" headers = dict(HEADERS) cookies = os.getenv("NAVER_COOKIES", "").strip() if cookies: @@ -167,8 +131,7 @@ class NaverPlaceAdapter: url=url, adapter_id=self.id, channel=channel, - # ★ 소개 원문은 fact 가 아니라 여기로 나간다 — 생성 근거로만 쓰인다. - # 업소가 직접 쓴 description 을 앞에, 한 줄 요약(microReviews)을 뒤에 붙인다. + # 소개 원문은 fact 가 아니라 여기로 나간다 — 생성 근거로만 쓰인다. text=" ".join( x for x in ( str(base.get("description") or "").strip(), @@ -181,15 +144,7 @@ class NaverPlaceAdapter: ) async def fetch_summary(self, url: str) -> Optional[dict]: - """이름·주소·좌표·대표사진 요약 — area_contents(주변 맛집 카드) 적재용. - - ★ fetch()와 별개 계약이다. fetch()는 fact/media(사업장 자신의 사실)를 돌려주고, - 이건 "이 업체가 누구인가"만 필요한 호출자(주변 맛집 보강)를 위한 것이다. - ★ services/place_service.py 의 verify_place_by_url 이 이미 같은 필드 - (name/roadAddress/address/coordinate.x·y)를 같은 방식으로 읽는다 — 필드명은 거기서 확인됐다. - ★ 사진은 fetch()가 쓰는 _to_media()를 그대로 재사용한다 — 이미 받아온 state 에서 - 꺼낼 뿐이라 네트워크 호출이 추가로 들지 않는다. - """ + """이름·주소·좌표·대표사진 요약 — area_contents(주변 맛집 카드) 적재용.""" try: place_id = await self._resolve_place_id(url) state = await self._load_state(place_id) @@ -218,22 +173,11 @@ class NaverPlaceAdapter: summary["imageUrl"] = media[0].origin_url return summary - # ---- 내부 ---------------------------------------------------------- + # 내부 @staticmethod def _booking_url(state: dict) -> Optional[str]: - """네이버 예약 화면 주소. **네이버가 준 값 그대로**다 — 조립하지 않는다. - - ★ 왜 조립하지 않나 - 응답에는 `bookingBusinessId`(1067685)와 `businessTypeId`(6)가 같이 있어서 - `m.booking.naver.com/booking/{type}/bizes/{id}` 를 만들 수 있을 것처럼 보인다. - 그러면 예약을 받지 않는 업소에도 그럴듯한 주소가 생기고, 눌렀는데 빈 화면이 - 나오면 손님은 그 가게가 예약을 안 받는 줄로 읽는다. 응답이 `naverBookingUrl` 을 - 줄 때만, 준 그대로 쓴다. 없으면 없는 것이다(실측: 예약 미사용 업소는 null). - - ★ 값은 ROOT_QUERY 의 placeDetail 응답 안에 있다. 키에 질의 인자가 통째로 박혀 있어 - (`placeDetail({"input":{...}})`) 이름으로 못 찾는다 — 접두사로 찾는다. - """ + """네이버 예약 화면 주소.""" root = state.get("ROOT_QUERY") if not isinstance(root, dict): return None @@ -246,19 +190,12 @@ class NaverPlaceAdapter: url = str(booking.get("naverBookingUrl") or "").strip() return url or None async def _resolve_place_id(self, url: str) -> str: - """URL 에서 place id 를 뽑는다. - - ★ 형태가 세 가지다 — 하나라도 빠지면 사장님이 복사한 주소가 통째로 실패한다. - .../place/1133638931 지도·플레이스 상세 - pcmap.place.naver.com/accommodation/… PC 지도 - ?pinId=1273971279 naver.me 단축주소가 풀린 앱 딥링크 - 단축주소는 서버가 한 번 따라가서 최종 주소를 본다. - """ + """URL 에서 place id 를 뽑는다.""" found = self._match_id(url) if found: return found - # 단축주소·리다이렉트. naver.me 뿐 아니라 다른 짧은 형태도 한 번은 따라가 본다. + # 단축주소·리다이렉트. try: async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT, follow_redirects=True) as client: res = await client.get(url, headers=self._headers()) @@ -276,11 +213,7 @@ class NaverPlaceAdapter: return m.group(1) if m else None async def _load_state(self, place_id: str) -> dict: - """모바일 상세 페이지에서 __APOLLO_STATE__ 를 뽑는다. - - 업종에 따라 경로가 달라서(accommodation / place / restaurant) 200 이 올 때까지 순서대로 친다. - ★ 막히면 그대로 실패로 돌려준다 — 재시도 폭주도 우회도 하지 않는다. - """ + """모바일 상세 페이지에서 __APOLLO_STATE__ 를 뽑는다.""" last = "알 수 없는 오류" try: async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT, follow_redirects=True) as client: @@ -306,17 +239,12 @@ class NaverPlaceAdapter: raise RuntimeError(last) def _to_facts(self, base: dict, state: dict, category: Optional[PlaceCategory] = None) -> list[CollectedFact]: - """★ 스키마에 있는 key 만 만든다. 없는 key 는 fact 기록 단계에서 통째로 거부된다.""" + """스키마에 있는 key 만 만든다.""" facts: list[CollectedFact] = [] - # ★ 소개문은 두 자리에 온다. microReviews(짧은 한 줄 요약)가 없어도 - # description(업소가 직접 쓴 소개글)이 차 있는 경우가 있다 — 조이모텔은 둘 다 - # 비었지만, 한쪽만 보면 있는 소개문을 통째로 놓친다. 순서는 업소가 쓴 글이 우선이다. - # ★ intro(소개문)는 수집하지 않는다 — allow_llm=True, 즉 LLM 의 출력 칸이다(절대규칙 7). - # 수집한 원문은 RawSource.text 로 나가 생성 근거로만 쓰인다. + # 소개문은 두 자리에 온다. - # ★ 숙박은 체크인·체크아웃이 별도 블록에 있다(Naverhotel3HotelData). 이 둘은 critical 이라 - # 놓치면 사장님이 손으로 채워야 하고, 비면 AI 검색이 OTA 설명을 대신 인용한다. + # 숙박은 체크인·체크아웃이 별도 블록에 있다(Naverhotel3HotelData). hotel = next((v for k, v in state.items() if k.startswith("Naverhotel3HotelData")), None) if hotel: for field, key in (("checkInStr", "check_in_time"), ("checkOutStr", "check_out_time")): @@ -324,16 +252,13 @@ class NaverPlaceAdapter: if value: facts.append(CollectedFact(key=key, value=value)) - # 영업시간. 업종마다 key 가 달라 스키마에 있는 것만 남는다(없는 key 는 기록 단계에서 거부). + # 영업시간. hours = self._business_hours(state) if hours: for key in _HOURS_KEYS: facts.append(CollectedFact(key=key, value=hours)) - # 편의시설 신호는 두 곳에 흩어져 있다 — - # base.conveniences 간단 태그("주차") - # InformationFacilities:* 상세 시설 목록("와이파이", "개별샤워실", …) - # 둘을 합쳐서 본다. 한쪽만 보면 있는 시설을 놓친다. + # 편의시설 신호는 두 곳에 흩어져 있다 — base.conveniences 간단 태그("주차") InformationFacilities:* 상세 시설 목록("와이파이", "개별샤워실", …) 둘을 합쳐서 본다. labels = [str(x) for x in (base.get("conveniences") or [])] labels += [ str(v.get("name") or v.get("i18nName") or "") @@ -342,10 +267,6 @@ class NaverPlaceAdapter: ] # 항목 하나에서 (key, 부호)를 읽는다. - # - # ★ 목록에 없다고 "없다"로 단정하지 않는다 — 네이버가 안 적었을 뿐일 수 있다. - # 반대로 "불가/없음"이 적혀 있으면 그건 명시적인 '아니오'이므로 false 로 담는다. - # 둘을 구분하지 못하면 "반려동물 동반 불가"가 "동반 가능"으로 뒤집힌다. seen: set[str] = set() for text in labels: if not text: @@ -361,22 +282,7 @@ class NaverPlaceAdapter: return facts def _to_unit_facts(self, state: dict, category: Optional[PlaceCategory] = None) -> list[CollectedFact]: - """요금표(`Menu:*`) → 단위(객실·메뉴·프로그램) 스코프 fact. - - ★ 왜 필요한가 - 이 자리를 아무도 읽지 않아서 place.units 가 전 사업장 0건이었다 — 발행본의 - '객실 안내' 가 영구히 비어 있었다. 수집 파이프라인(ensure_units)은 이미 - unit 스코프 fact 의 unit_name 으로 단위를 만든다. 없던 것은 그 입력뿐이다. - - ★ 요일 × 상품을 상품으로 묶는다. - "평일 대실 / 주말(금,토) 대실 / 평일 숙박 / 주말(금,토) 숙박" 은 객실 4개가 아니라 - 상품 2개(대실·숙박)다. 요일 토큰을 떼어 묶고 요금만 주중·주말로 나눠 담는다. - - ★ 요일 표기가 없는 요금은 **담지 않는다.** - 스키마의 요금 key 는 weekday_price·weekend_price 뿐이라, 요일이 안 적힌 값을 - 아무 쪽에나 넣으면 출처에 없는 조건을 우리가 지어내는 것이 된다. 손님이 그 금액으로 - 오면 클레임이다 — 이름(room_type)만 담고 요금은 사장님이 채운다. - """ + """요금표(`Menu:*`) → 단위(객실·메뉴·프로그램) 스코프 fact.""" rows = [v for k, v in state.items() if k.startswith("Menu") and isinstance(v, dict)] rows.sort(key=lambda v: int(v.get("index") or 0)) @@ -390,7 +296,7 @@ class NaverPlaceAdapter: if not raw_name: continue - # 상품 이름 = 요일 토큰을 걷어낸 나머지. "평일" 처럼 요일뿐이면 이름이 없다 → 버린다. + # 상품 이름 = 요일 토큰을 걷어낸 나머지. unit_name = _DAY_TAG.sub("", raw_name).strip(" -·/") or "" if not unit_name: continue @@ -401,7 +307,7 @@ class NaverPlaceAdapter: key=name_key, value=unit_name, scope="unit", unit_name=unit_name, )) - # 요금. 네이버는 문자열 숫자("20000")로 준다 — 표기는 렌더 단계(format_value)가 만든다. + # 요금. price = str(row.get("price") or "").strip().replace(",", "") if not price.isdigit(): continue @@ -424,11 +330,7 @@ class NaverPlaceAdapter: return out def _business_hours(self, state: dict) -> str: - """영업시간 한 줄. 네이버가 요일별로 쪼개 주면 그대로 이어 붙인다. - - ★ 파싱해서 재조립하지 않는다 — "매일 09:00~21:00" 과 "평일만" 을 구조화하려다 - 틀리면 손님이 헛걸음한다. 원문 표기를 그대로 옮기고 판단은 사장님이 한다. - """ + """영업시간 한 줄.""" rows = [v for k, v in state.items() if "BusinessHour" in k or "NewBusinessHour" in k] parts: list[str] = [] for row in rows: @@ -442,8 +344,7 @@ class NaverPlaceAdapter: return " · ".join(parts)[:500] def _to_media(self, state: dict) -> list[CollectedMedia]: - """상단 사진. 업소가 올린 것(mediaSource='business')을 앞에 둔다 — - 방문자 리뷰 사진보다 재게시 근거가 분명하다(docs/DECISIONS.md 1-2).""" + """상단 사진.""" items = [v for k, v in state.items() if k.startswith("PlaceDetailTopPhotoItem")] items.sort(key=lambda v: 0 if v.get("mediaSource") == "business" else 1) diff --git a/solution/backend/services/collector/registry.py b/solution/backend/services/collector/registry.py index a5f320c..e1a6fa4 100644 --- a/solution/backend/services/collector/registry.py +++ b/solution/backend/services/collector/registry.py @@ -1,25 +1,4 @@ -"""어댑터 레지스트리 — 'URL 하나에 어댑터 하나' 를 결정하는 유일한 지점. - -등록된 어댑터 - naver_place 네이버 플레이스 상세(pcmap GraphQL). 이식 출처는 - o2o-castad-backend/app/utils/nvMapScraper.py — 파싱 규칙을 두 벌 두지 않는다. - tour_api 한국관광공사 TourAPI(KorService2). 숙박 32 · 음식점 39. 공식 API 라 재게시가 자유롭고 - 사진마다 공공누리 유형이 붙어 온다 — 상업적 이용 불가 유형은 수집 단계에서 버린다. - static_html 사장님이 확정한 자기 홈페이지. JSON-LD·OpenGraph 만 읽고 robots.txt 를 따른다. - docs/DECISIONS.md 1-1 이 보류했던 어댑터로, 2026-08-28 실측 결론 - (docs/DATA_SOURCE_RESEARCH.md)에 따라 **사장님 확정 URL 한정**으로 등록했다. - yanolja 야놀자(NOL) 국내숙소 상세페이지. Next.js CSR 페이지라 Playwright 로 렌더링해 - 읽는다. 캡차 우회·IP 회전 등은 하지 않으며, 차단되면 그대로 실패로 돌린다. - mock mock:// 전용. 네트워크 없이 파이프라인 전체를 돌리는 테스트용. - - 켜고 끄는 것은 환경변수 COLLECT_ADAPTERS 다(쉼표 구분). 코드를 고치지 않고 한 채널만 - 내릴 수 있어야 한다 — 특정 사이트가 막히거나 정책이 바뀌었을 때 배포 없이 멈추기 위해서다. - -**금지 (검토 결과와 무관하게 영구)** - 캡차 우회 · 봇 탐지 우회 · IP 회전. 어떤 어댑터도 이걸 구현하지 않는다. - 막히면 폴백 3단계로 간다 — 공식 API → 사장님이 직접 붙여넣기 → 최소 정보로 생성 + 보완 요청. - 수집이 막혔다고 생성 자체를 실패시키지 않는다. -""" +"""어댑터 레지스트리 — 'URL 하나에 어댑터 하나' 를 결정하는 유일한 지점.""" import os from typing import Optional @@ -31,17 +10,14 @@ from services.collector.static_html_adapter import StaticHtmlAdapter from services.collector.tour_api_adapter import TourApiAdapter from services.collector.yanolja_adapter import YanoljaAdapter -# 이 환경에서 켜둘 어댑터 id 집합. 등록돼 있어도 여기 없으면 AdapterDisabled 로 막힌다. +# 이 환경에서 켜둘 어댑터 id 집합. ENABLED_ADAPTERS: frozenset = frozenset( x.strip() for x in os.getenv("COLLECT_ADAPTERS", "mock,naver_place,tour_api,static_html,yanolja").split(",") if x.strip() ) class AdapterRegistry: - """등록 순서대로 can_handle 을 물어 첫 번째로 손드는 어댑터를 쓴다. - - 순서가 곧 우선순위다 — 좁은 어댑터(특정 도메인)를 먼저, 넓은 어댑터(범용 HTML)를 뒤에 둔다. - """ + """등록 순서대로 can_handle 을 물어 첫 번째로 손드는 어댑터를 쓴다.""" def __init__(self, enabled: Optional[frozenset] = None): self._adapters: list[SourceAdapter] = [] @@ -60,7 +36,7 @@ class AdapterRegistry: return [a.id for a in self._adapters if a.id in self._enabled] def get_adapter(self, url: str) -> SourceAdapter: - """URL 을 처리할 어댑터. 없으면 AdapterNotFound, 꺼져 있으면 AdapterDisabled.""" + """URL 을 처리할 어댑터.""" for adapter in self._adapters: if not adapter.can_handle(url): continue @@ -80,9 +56,7 @@ class AdapterRegistry: def _register_default() -> AdapterRegistry: registry = AdapterRegistry() - # 좁은 어댑터(특정 도메인)를 먼저, 넓은 어댑터를 뒤에. mock 은 mock:// 스킴 전용이라 순서 무관. - # ★ static_html 은 http(s) 를 통째로 받는 넓은 어댑터라 **반드시 맨 뒤**다. - # 앞에 두면 네이버 플레이스 URL 까지 이쪽으로 빨려 들어간다. + # 좁은 어댑터(특정 도메인)를 먼저, 넓은 어댑터를 뒤에. registry.register(NaverPlaceAdapter()) registry.register(TourApiAdapter()) registry.register(YanoljaAdapter()) diff --git a/solution/backend/services/collector/static_html_adapter.py b/solution/backend/services/collector/static_html_adapter.py index 00036e7..9710d27 100644 --- a/solution/backend/services/collector/static_html_adapter.py +++ b/solution/backend/services/collector/static_html_adapter.py @@ -1,40 +1,4 @@ -"""정적 HTML 어댑터 — **사장님이 확정한 자기 홈페이지** 전용. - -★ 왜 이 어댑터가 지금 등록되는가 (docs/DATA_SOURCE_RESEARCH.md, 2026-08-28) - docs/DECISIONS.md 1-1 이 "약관·robots.txt 기준 허용 범위" 결론 전까지 등록을 보류했던 - 그 어댑터다. 실측 결론은 이렇다. - - 야놀자·여기어때 HTTP 403 + Cloudflare 챌린지. 기술적으로 막혔고, 같은 행위에 - 민사 10억 배상 선례가 있다(야놀자 v 여기어때, 서울중앙지법 2021-08). - 네이버·카카오 robots.txt 가 `Disallow: /`. 명시적 불허. - 사장님 자체 홈페이지 사장님이 URL 을 확정해 주고, 그 사실의 주인도 사장님이다. **가능.** - - 즉 이 어댑터의 정당성은 전부 "사장님이 확정한 URL 만 본다" 에서 나온다. - 그 전제가 깨지면(플랫폼 URL 이 흘러들어오면) 정당성도 같이 깨지므로, - 아래 _DENY_HOSTS 로 **구조적으로** 막는다. 운영자가 실수로 넣어도 안 긁힌다. - -★ NOL(nol.yanolja.com) 은 이 어댑터가 아니라 전용 어댑터가 받는다(2026-09-14). - 범용 HTML 수집을 OTA 로 넓힌 것이 아니다 — 레지스트리가 yanolja 를 이 어댑터보다 - **앞에** 등록하므로 그 한 패턴만 전용 경로로 가고, 나머지 야놀자·여기어때 주소는 - 여기 _DENY_HOSTS 에서 그대로 막힌다. - ★ 실측(2026-09-15): 이 deny 목록에서 두 호스트가 빠져 있던 동안 범용 HTML 수집이 - `www.yanolja.com` · `goodchoice.kr` 을 받았다. 전용 어댑터를 들이는 것과 - 범용 수집기를 그 플랫폼에 푸는 것은 다른 일이다. - -**금지 (docs/DECISIONS.md 1-1, 결론과 무관하게 영구)** - 캡차 우회 · 봇 탐지 우회 · IP 회전. 여기에 하나 더 — - **robots.txt 를 확인하고 그대로 따른다.** 사장님 홈페이지라도 예외 없다. - 막히면 실패로 돌려주고 폴백 3단계로 간다(공식 API → 사장님 붙여넣기 → 최소 정보 생성). - -★ 표본 근거 — 왜 이 어댑터가 숙박에서 특히 값이 큰가 - 네이버 지역검색 `link` 필드 충전율(업종별 25건 표본): 숙박 96% 중 자체 도메인 19건, - 음식점 64%, 카페 92% 중 인스타 17건. 숙박은 자체 홈페이지 보유율이 3업종 중 가장 높다. - -★ 값을 만드는 원칙 - 구조화된 것(JSON-LD schema.org, OpenGraph)만 fact 로 올린다. 본문 텍스트에서 - 키워드를 주워 억지로 매핑하지 않는다 — 유일한 예외가 체크인·체크아웃 시각인데, - 숙박에서 critical 이고 "체크인" 이라는 낱말과 시각이 붙어 있는 형태만 좁게 본다. -""" +"""정적 HTML 어댑터 — **사장님이 확정한 자기 홈페이지** 전용.""" import asyncio import json import re @@ -51,14 +15,12 @@ from common.enums import LinkChannel, PlaceCategory from common.logger import LOG from services.collector.base import CollectedFact, CollectedMedia, RawSource -# 우리를 밝히는 UA. ★ 브라우저인 척하지 않는다 — robots.txt 판정도 이 이름으로 받는다. -# ★ ASCII 만 쓴다. HTTP 헤더는 latin-1 로 인코딩되므로 한글을 넣으면 -# 요청이 나가기도 전에 UnicodeEncodeError 로 죽는다(실측). +# 우리를 밝히는 UA. BOT_NAME = "o2o-web4ai-collector" USER_AGENT = f"{BOT_NAME}/1.0 (+owner-confirmed URL only; contact: owner of the listed business)" REQUEST_TIMEOUT = 20 -MAX_BYTES = 3 * 1024 * 1024 # 본문 상한. 넘으면 거기까지만 읽는다. +MAX_BYTES = 3 * 1024 * 1024 # 본문 상한. MAX_MEDIA = 30 ROBOTS_TTL = 3600 # 호스트당 robots.txt 캐시 수명(초) @@ -68,15 +30,11 @@ HEADERS = { "Accept-Language": "ko-KR,ko;q=0.9", } -# ★ 이 호스트들은 이 어댑터가 절대 건드리지 않는다. -# - 전용 어댑터가 따로 있거나(naver_place · yanolja) -# - 실측·판례로 수집 불가 결론이 난 곳이거나(야놀자·여기어때·카카오맵) -# - 공식 OAuth 로만 가져와야 하는 곳(인스타그램)이다. -# can_handle 에서 걸러 AdapterNotFound 로 떨어뜨린다. +# 이 호스트들은 이 어댑터가 절대 건드리지 않는다. _DENY_HOSTS = ( "naver.com", "naver.me", # 플레이스·지도·블로그·예약 — robots Disallow: / "kakao.com", "daum.net", # 카카오맵 — robots Disallow, 내부 API 406 - "yanolja.com", "goodchoice.kr", # OTA — 403 + 민사 10억 선례. NOL 은 전용 어댑터가 먼저 받는다 + "yanolja.com", "goodchoice.kr", # OTA — 403 + 민사 10억 선례. "dailyhotel.com", "catchtable.co.kr", "airbnb.co.kr", "airbnb.com", "booking.com", "agoda.com", "expedia.co.kr", @@ -85,7 +43,7 @@ _DENY_HOSTS = ( "diningcode.com", "siksinhot.com", "mangoplate.com", ) -# schema.org 타입 → 우리가 관심 있는 업소인가. 목록에 없는 타입은 fact 를 만들지 않는다. +# schema.org 타입 → 우리가 관심 있는 업소인가. _LB_TYPES = { "localbusiness", "lodgingbusiness", "hotel", "motel", "resort", "hostel", "bedandbreakfast", "campground", "vacationrental", @@ -93,12 +51,10 @@ _LB_TYPES = { "touristattraction", "store", } -# 부정 표현. ★ naver_place_adapter 와 같은 판정을 쓴다 — "주차 불가" 가 parking=true 로 뒤집히면 -# 업종을 가리지 않고 같은 사고가 난다. +# 부정 표현. _NEGATIONS = ("불가", "없음", "없슴", "미제공", "제공하지", "안됨", "안 됨", "불가능", "금지", "not available") -# 편의시설 표기 → 업종 스키마의 bool key. 사장님 홈페이지는 표기가 제각각이라 -# 한국어·영어를 같이 본다. ★ 여기 없는 표현은 fact 로 만들지 않는다. +# 편의시설 표기 → 업종 스키마의 bool key. _AMENITY_BOOL = { "주차": "parking", "발렛": "parking", "parking": "parking", "와이파이": "wifi", "무선인터넷": "wifi", "무선 인터넷": "wifi", "wifi": "wifi", "wi-fi": "wifi", @@ -117,12 +73,10 @@ _AMENITY_BOOL = { "휠체어": "wheelchair_accessible", "장애인": "wheelchair_accessible", } -# 업종마다 "영업시간" 자리를 차지하는 key. 스키마에 없는 것은 fact 기록 단계에서 거부된다. +# 업종마다 "영업시간" 자리를 차지하는 key. _HOURS_KEYS = ("business_hours", "operating_hours", "reception_hours") -# ★ 본문에서 유일하게 허용하는 정규식 — 체크인·체크아웃. -# 숙박에서 critical 이고(틀리면 예약 클레임) 비면 AI 검색이 OTA 설명을 대신 인용한다. -# "체크인" 이라는 낱말 **바로 뒤**의 시각만 본다. 떨어져 있으면 잡지 않는다. +# 본문에서 유일하게 허용하는 정규식 — 체크인·체크아웃. _CHECKIN = re.compile(r"체크\s*인[^0-9]{0,12}((?:오전|오후)?\s*\d{1,2}\s*[:시]\s*\d{0,2})") _CHECKOUT = re.compile(r"체크\s*아웃[^0-9]{0,12}((?:오전|오후)?\s*\d{1,2}\s*[:시]\s*\d{0,2})") @@ -130,10 +84,7 @@ _robots_cache: dict[str, tuple[float, Optional[RobotFileParser]]] = {} class _Extract(HTMLParser): - """한 번 훑으면서 JSON-LD · meta · 본문 텍스트를 같이 걷는다. - - 표준 라이브러리만 쓴다 — 이것 하나 때문에 파서 의존성을 새로 얹지 않는다. - """ + """한 번 훑으면서 JSON-LD · meta · 본문 텍스트를 같이 걷는다.""" def __init__(self): super().__init__(convert_charrefs=True) @@ -188,10 +139,7 @@ class StaticHtmlAdapter: id = "static_html" def can_handle(self, url: str) -> bool: - """http(s) 이고, 전용 어댑터·수집 불가 결론이 난 호스트가 아니면 손든다. - - ★ 이 어댑터는 **넓다**. 레지스트리에서 반드시 좁은 어댑터 뒤에 등록해야 한다. - """ + """http(s) 이고, 전용 어댑터·수집 불가 결론이 난 호스트가 아니면 손든다.""" u = (url or "").strip().lower() if not u.startswith(("http://", "https://")): return False @@ -205,7 +153,7 @@ class StaticHtmlAdapter: allowed, why = await self._robots_allows(url) if not allowed: - # ★ 우회하지 않는다. 사장님 홈페이지라도 robots 가 막으면 그대로 실패다. + # 우회하지 않는다. return RawSource.failure(url, self.id, f"robots.txt 가 수집을 허용하지 않는다: {why}", channel) try: @@ -240,13 +188,9 @@ class StaticHtmlAdapter: media=media, ) - # ---- robots --------------------------------------------------------- + # robots async def _robots_allows(self, url: str) -> tuple[bool, str]: - """robots.txt 를 우리 UA 이름으로 판정한다. - - ★ 못 가져오면 허용으로 본다(관례). 하지만 **가져왔는데 막고 있으면 무조건 따른다.** - 호스트 단위로 캐시해서 페이지마다 다시 묻지 않는다. - """ + """robots.txt 를 우리 UA 이름으로 판정한다.""" parts = urlparse(url) origin = f"{parts.scheme}://{parts.netloc}" now = time.monotonic() @@ -270,7 +214,7 @@ class StaticHtmlAdapter: res = await client.get(f"{origin}/robots.txt", headers=HEADERS) if res.status_code != 200: return None - # HTML 을 돌려주는 서버가 있다(robots.txt 가 없어서 SPA 로 떨어지는 경우). 그건 규칙이 아니다. + # HTML 을 돌려주는 서버가 있다(robots.txt 가 없어서 SPA 로 떨어지는 경우). body = res.text if body.lstrip()[:15].lower().startswith((" str: - """HTML 본문. text/html 이 아니거나 상한을 넘으면 거기서 멈춘다. - - ★ 재시도하지 않는다. 429·403 은 그대로 실패로 올린다 — 재시도 폭주가 곧 차단 사유다. - """ + """HTML 본문.""" try: async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT, follow_redirects=True) as client: async with client.stream("GET", url, headers=HEADERS) as res: @@ -312,10 +253,10 @@ class StaticHtmlAdapter: except httpx.HTTPError as ex: raise RuntimeError(f"네트워크 오류: {ex}") - # ---- JSON-LD -------------------------------------------------------- + # JSON-LD @staticmethod def _ld_nodes(raw_blocks: list[str]) -> list[dict]: - """JSON-LD 블록들을 평평한 dict 목록으로. @graph·배열·중첩을 모두 편다.""" + """JSON-LD 블록들을 평평한 dict 목록으로.""" out: list[dict] = [] def walk(node): @@ -340,7 +281,7 @@ class StaticHtmlAdapter: @staticmethod def _pick_business(nodes: list[dict]) -> Optional[dict]: - """업소를 가리키는 노드 하나. 여러 개면 필드가 가장 많은 것을 쓴다.""" + """업소를 가리키는 노드 하나.""" cands = [] for n in nodes: types = n.get("@type") @@ -349,24 +290,21 @@ class StaticHtmlAdapter: cands.append(n) return max(cands, key=len) if cands else None - # ---- fact ----------------------------------------------------------- + # fact def _to_facts(self, biz: Optional[dict], parser: _Extract) -> list[CollectedFact]: - """★ 스키마에 있는 key 만 만든다. 없는 key 는 fact 기록 단계에서 통째로 거부된다.""" + """스키마에 있는 key 만 만든다.""" facts: list[CollectedFact] = [] biz = biz or {} - # ★ 소개문(intro)은 수집하지 않는다 — allow_llm=True, LLM 의 출력 칸이다(절대규칙 7). - # JSON-LD description·og:description 원문은 RawSource.text 로 나가 - # 생성 근거로만 쓰인다(services/copy_service.py). + # 소개문(intro)은 수집하지 않는다 — allow_llm=True, LLM 의 출력 칸이다(절대규칙 7). - # 영업시간. ★ 재조립하지 않고 원문 표기를 그대로 옮긴다 — - # "매일 09:00~21:00" 과 "평일만" 을 구조화하려다 틀리면 손님이 헛걸음한다. + # 영업시간. hours = self._hours(biz) if hours: for key in _HOURS_KEYS: facts.append(CollectedFact(key=key, value=hours)) - # 체크인·체크아웃. JSON-LD 가 우선이고, 없을 때만 본문 정규식으로 좁게 본다. + # 체크인·체크아웃. for ld_key, our_key, pattern in ( ("checkinTime", "check_in_time", _CHECKIN), ("checkoutTime", "check_out_time", _CHECKOUT), @@ -389,11 +327,7 @@ class StaticHtmlAdapter: return facts def _bool_facts(self, biz: dict) -> list[CollectedFact]: - """편의시설 → bool fact. - - ★ 목록에 없다고 "없다"로 단정하지 않는다 — 사장님이 안 적었을 뿐일 수 있다. - 반대로 "불가/없음"이 적혀 있으면 명시적인 '아니오'이므로 false 로 담는다. - """ + """편의시설 → bool fact.""" labels: list[str] = [] for item in self._as_list(biz.get("amenityFeature")): @@ -407,7 +341,7 @@ class StaticHtmlAdapter: elif isinstance(item, str): labels.append(item) - # schema.org 가 전용 필드로 주는 것들. bool 이면 부호를 그대로 쓴다. + # schema.org 가 전용 필드로 주는 것들. for ld_key, our_key in (("petsAllowed", "pet_allowed"), ("smokingAllowed", "smoking")): raw = biz.get(ld_key) if isinstance(raw, bool): @@ -430,11 +364,7 @@ class StaticHtmlAdapter: return out def _menu_facts(self, biz: dict) -> list[CollectedFact]: - """hasMenu → 단위(메뉴) 스코프 fact. - - ★ 이름 없는 항목은 담지 않는다 — unit_name 이 비면 base 계약이 예외를 던진다. - 가격은 통화 기호 없이 숫자만 오는 경우가 많아 원문 표기를 그대로 옮긴다. - """ + """hasMenu → 단위(메뉴) 스코프 fact.""" sections = self._as_list(biz.get("hasMenu")) + self._as_list(biz.get("menu")) items: list[dict] = [] for sec in sections: @@ -459,8 +389,7 @@ class StaticHtmlAdapter: if price: out.append(CollectedFact(key="menu_price", value=price[:40], scope="unit", unit_name=name)) - # ★ menu_intro 도 allow_llm=True 라 수집하지 않는다 — 메뉴 설명 문장은 - # LLM 이 쓰는 칸이다. 원문은 RawSource.text 로 나가 근거로만 쓰인다. + # menu_intro 도 allow_llm=True 라 수집하지 않는다 — 메뉴 설명 문장은 LLM 이 쓰는 칸이다. return out def _hours(self, biz: dict) -> str: @@ -482,13 +411,9 @@ class StaticHtmlAdapter: parts.append(text) return " · ".join(parts)[:500] - # ---- 사진 ----------------------------------------------------------- + # 사진 def _to_media(self, biz: Optional[dict], parser: _Extract, base_url: str) -> list[CollectedMedia]: - """JSON-LD image 를 앞에, OpenGraph og:image 를 뒤에. - - ★ origin_url 은 절대 URL 로 만들어 둔다 — 상대경로로 저장하면 나중에 출처를 되짚을 수 없고, - docs/DECISIONS.md 1-2(이미지 재게시 권리) 결론에 따라 걸러낼 수도 없게 된다. - """ + """JSON-LD image 를 앞에, OpenGraph og:image 를 뒤에.""" raw: list[str] = [] for item in self._as_list((biz or {}).get("image")): if isinstance(item, str): @@ -514,10 +439,10 @@ class StaticHtmlAdapter: break return out - # ---- 잡동사니 -------------------------------------------------------- + # 잡동사니 @staticmethod def _channel(url: str) -> LinkChannel: - """사장님 확정 URL 이므로 기본은 공식 홈페이지다. 블로그만 따로 표시한다.""" + """사장님 확정 URL 이므로 기본은 공식 홈페이지다.""" u = (url or "").lower() if any(b in u for b in ("blog.", "/blog", "tistory.com", "brunch.co.kr")): return LinkChannel.BLOG @@ -531,7 +456,7 @@ class StaticHtmlAdapter: @staticmethod def _clean(value) -> str: - """문자열로 정규화. 숫자·bool 도 받아서 표기를 그대로 남긴다.""" + """문자열로 정규화.""" if value is None or isinstance(value, (dict, list)): return "" text = unescape(str(value)) diff --git a/solution/backend/services/collector/tour_api_adapter.py b/solution/backend/services/collector/tour_api_adapter.py index 90089ca..4d011e5 100644 --- a/solution/backend/services/collector/tour_api_adapter.py +++ b/solution/backend/services/collector/tour_api_adapter.py @@ -1,32 +1,4 @@ -"""한국관광공사 TourAPI(KorService2) 어댑터 — 숙박·음식점. - -★ 왜 이 어댑터가 다른 어댑터보다 우선인가 (docs/DATA_SOURCE_RESEARCH.md) - 네이버·카카오·OTA 에서 긁어온 값은 **출처를 밝힐 수 없다**. robots 를 어겼거나 재게시 근거가 없어서다. - 출처를 못 밝히는 사실은 `llms.txt`·`AnswerBlock` 의 "출처와 검증 시각" 신호를 채우지 못하고, - AI 검색이 인용할 이유도 없다(GEO 연구: 인용률을 올리는 요인 1위가 '출처 인용'이다). - - TourAPI 는 반대다 — **무료·이용허락범위 제한없음**이라 재게시에 제약이 없고, 출처를 당당히 밝힐 수 있다. - 같은 사실이라도 여기서 온 것이 값이 더 크다. - (단 '대한민국구석구석' 에 contentId 로 열리는 공개 페이지는 **없다** — 아래 SOURCE_URL 주석 참고.) - -★ 수집 경로 - detailCommon2 개요·홈페이지·좌표·대표사진 - detailIntro2 업종별 소개정보(체크인·주차·취사 / 영업시간·대표메뉴 …) - detailInfo2 숙박은 **객실 단위** 정보(인원·요금·면적·객실사진), 음식점은 반복정보 - detailImage2 추가 사진 - -★ 이미지 저작권 — 여기서 끝낸다 (docs/DECISIONS.md 1-2) - TourAPI 는 사진마다 공공누리 유형(`cpyrhtDivCd`)을 준다. - Type1 출처표시 상업적 이용 O · 변형 O - Type2 출처표시 + 상업적이용금지 상업적 이용 X ← 버린다 - Type3 출처표시 + 변경금지 상업적 이용 O · 변형 X - Type4 출처표시 + 상업적이용금지 + 변경금지 ← 버린다 - 사장님 홈페이지는 **상업적 이용**이므로 Type2·Type4 는 수집 단계에서 통째로 버린다. - Type3 는 담되 `license` 에 표시해 둔다 — 뒤에서 크롭·리사이즈하면 '변경금지' 를 어긴다. - -**금지 (docs/DECISIONS.md 1-1, 결론과 무관하게 영구)** - 캡차 우회 · 봇 탐지 우회 · IP 회전. 여기는 공식 API 라 해당 사항이 없고, 앞으로도 만들지 않는다. -""" +"""한국관광공사 TourAPI(KorService2) 어댑터 — 숙박·음식점.""" import re from typing import Optional from urllib.parse import unquote, urlencode @@ -40,23 +12,12 @@ from services.collector.base import CollectedFact, CollectedMedia, RawSource BASE_URL = "https://apis.data.go.kr/B551011/KorService2" -# fact 의 출처로 남기는 주소. **인증키를 뺀** 해당 레코드의 API 주소다. -# -# ★ 왜 '대한민국구석구석' 페이지가 아닌가 (2026-08-31 실측) -# 처음엔 korean.visitkorea.or.kr/detail/ms_detail.do?cotid={contentId} 를 썼는데 **틀렸다.** -# 구석구석의 `cotid` 는 TourAPI 의 `contentid` 와 다른 체계(GUID)라, 어떤 contentId 를 넣어도 -# 같은 크기(7,702B)의 "안내 페이지" 가 돌아온다. 검색 결과 페이지도 전부 JS 렌더라 -# contentId 로 바로 열리는 공개 URL 이 존재하지 않는다. -# -# 그래서 **그 레코드를 정확히 가리키면서 재현 가능한 주소**를 쓴다. 키가 없으면 데이터 대신 -# 인증 오류가 오지만, 자기 키를 가진 사람은 같은 값을 그대로 받아볼 수 있다 — -# 출처의 목적(어디서 왔고 어떻게 확인하는가)은 이것으로 충족된다. -# ★ 인증키는 절대 넣지 않는다. 이 주소는 DB 에 저장되고 발행본에도 나간다. +# fact 의 출처로 남기는 주소. SOURCE_URL = ( "https://apis.data.go.kr/B551011/KorService2/detailCommon2" "?contentId={content_id}&MobileOS=ETC&MobileApp=o2o-web4ai&_type=json" ) -# 화면에 적을 출처 이름. URL 만으로는 무엇인지 알 수 없어서 함께 남긴다. +# 화면에 적을 출처 이름. SOURCE_LABEL = "한국관광공사 TourAPI" REQUEST_TIMEOUT = 25 @@ -66,14 +27,12 @@ CONTENT_TYPE_LODGING = "32" CONTENT_TYPE_RESTAURANT = "39" _SUPPORTED = (CONTENT_TYPE_LODGING, CONTENT_TYPE_RESTAURANT) -# ★ 상업적 이용이 허용된 공공누리 유형만 담는다. 목록에 없는 코드는 '모른다' 로 보고 버린다 — -# 새 코드가 생겼을 때 조용히 통과시키는 것보다 빠뜨리는 쪽이 안전하다. +# 상업적 이용이 허용된 공공누리 유형만 담는다. COMMERCIAL_OK_LICENSES = frozenset({"type1", "type3"}) -# 변형(크롭·리사이즈)까지 금지된 유형. 뒤 단계가 이걸 보고 원본 그대로 써야 한다. +# 변형(크롭·리사이즈)까지 금지된 유형. NO_DERIVATIVE_LICENSES = frozenset({"type3", "type4"}) -# 이 어댑터가 받는 주소. 사장님이 붙여넣는 것은 구석구석 주소이고, -# tour:// 는 내부에서 콘텐츠 id 를 바로 지정할 때 쓴다(채널 발견·재수집). +# 이 어댑터가 받는 주소. _COTID = re.compile(r"[?&]cotid=([0-9a-zA-Z\-]+)", re.I) _TOUR_SCHEME = re.compile(r"^tour://(\d+)/(\d+)$", re.I) _HOSTS = ("visitkorea.or.kr",) @@ -81,13 +40,10 @@ _HOSTS = ("visitkorea.or.kr",) _BR = re.compile(r"", re.I) _TAGS = re.compile(r"<[^>]+>") _HREF = re.compile(r'href=["\']([^"\']+)["\']', re.I) -# "13실", "16명", "대지 면적 11,570㎡" 처럼 숫자에 단위가 붙어 온다. number 필드는 숫자만 남긴다. +# "13실", "16명", "대지 면적 11,570㎡" 처럼 숫자에 단위가 붙어 온다. _LEADING_NUM = re.compile(r"^\s*([\d,]+(?:\.\d+)?)") -# ★ "가능 / 불가" 로 시작하는 필드들. 뒤에 괄호 설명이 붙는다: -# parkinglodging = "가능 (객실당 1대 무료 주차)" -# parkingfood = "불가 (인근 주차장 이용)" ← 뒤에 '이용' 이 있다고 true 로 읽으면 안 된다 -# 그래서 **맨 앞 토큰**으로만 부호를 판정한다. 문자열 전체를 훑으면 뒤집힌다(실측). +# "가능 / 불가" 로 시작하는 필드들. _NEG_HEAD = ("불가", "없음", "미제공", "불가능", "없슴") _POS_HEAD = ("가능", "있음", "제공", "무료", "부분") @@ -97,7 +53,7 @@ class TourApiAdapter: id = "tour_api" - # ---- 계약 ---------------------------------------------------------- + # 계약 def can_handle(self, url: str) -> bool: u = (url or "").strip().lower() if _TOUR_SCHEME.match(u): @@ -166,13 +122,9 @@ class TourApiAdapter: media=media, ) - # ---- HTTP ---------------------------------------------------------- + # HTTP async def _call(self, client: httpx.AsyncClient, key: str, op: str, **params) -> list[dict]: - """오퍼레이션 1회. 결과가 없으면 빈 목록 — 없는 것과 실패를 구분한다. - - ★ 포털의 'Encoding' 키를 그대로 넣어도 이중 인코딩되지 않도록 원문으로 되돌린다 - (services/external/tour_api.py 와 같은 처리다). - """ + """오퍼레이션 1회.""" query = urlencode( {"serviceKey": unquote(key), "MobileOS": "ETC", "MobileApp": "o2o-web4ai", "_type": "json", "numOfRows": "50", "pageNo": "1", **params}, @@ -185,7 +137,7 @@ class TourApiAdapter: try: payload = res.json() except ValueError: - # 인증 실패·쿼터 초과는 XML 로 온다. 본문 앞부분을 그대로 올려 원인을 감추지 않는다. + # 인증 실패·쿼터 초과는 XML 로 온다. raise RuntimeError(f"{op} 응답이 JSON 이 아니다: {res.text[:160]}") header = payload.get("response", {}).get("header", {}) @@ -207,10 +159,10 @@ class TourApiAdapter: m = _COTID.search(url or "") return (m.group(1), "") if m else None - # ---- 값 다듬기 ------------------------------------------------------- + # 값 다듬기 @classmethod def _plain(cls, value) -> str: - """
은 줄바꿈으로, 나머지 태그는 제거. ★ 글자는 지우지 않는다 — '불가' 가 사라지면 부호가 뒤집힌다.""" + """
은 줄바꿈으로, 나머지 태그는 제거.""" if value is None: return "" text = _TAGS.sub("", _BR.sub("\n", str(value))) @@ -218,7 +170,7 @@ class TourApiAdapter: @classmethod def _yn(cls, value) -> Optional[bool]: - """'Y'/'N' 또는 '1'/'0'. 그 외에는 판단하지 않는다(None).""" + """'Y'/'N' 또는 '1'/'0'.""" v = str(value or "").strip().upper() if v in ("Y", "1"): return True @@ -228,10 +180,7 @@ class TourApiAdapter: @classmethod def _head_bool(cls, value) -> Optional[bool]: - """'가능 (…)' / '불가 (…)' 처럼 **맨 앞 토큰**이 부호인 필드. - - ★ 문자열 전체를 훑으면 "불가 (인근 주차장 이용)" 이 true 로 뒤집힌다(실측). - """ + """'가능 (…)' / '불가 (…)' 처럼 **맨 앞 토큰**이 부호인 필드.""" text = cls._plain(value) if not text: return None @@ -244,7 +193,7 @@ class TourApiAdapter: @classmethod def _number(cls, value) -> Optional[str]: - """'13실' · '11,570㎡' → '13' · '11570'. 숫자로 시작하지 않으면 담지 않는다.""" + """'13실' · '11,570㎡' → '13' · '11570'.""" m = _LEADING_NUM.match(cls._plain(value)) if not m: return None @@ -264,32 +213,13 @@ class TourApiAdapter: def _collect(cls, pairs) -> list[CollectedFact]: return [f for f in (cls._fact(*p[:2], unit=p[2] if len(p) > 2 else None) for p in pairs) if f] - # ---- 업종별 매핑 ----------------------------------------------------- + # 업종별 매핑 def _common_facts(self, common: dict) -> list[CollectedFact]: - """모든 업종 공통. - - ★ overview 를 `intro` fact 로 만들지 않는다 (2026-08-31 수정). - `intro` 는 스키마에서 allow_llm=True — **LLM 이 쓰는 칸**이지 수집하는 사실이 아니다 - (절대규칙 7, tests/test_collector.py::test_adapters_do_not_collect_llm_written_fields). - - 한 번 어겼더니 이렇게 됐다: TourAPI overview 457자가 그대로 VERIFIED 로 들어가 - 사장님 사이트의 '숙소 소개' 를 차지하고, 정작 Gemini 가 쓴 162자 소개문은 - PENDING_OWNER 로 뒤에 밀렸다. 원문을 그대로 싣는 것은 GEO 관점에서도 복제 콘텐츠다. - - 원문은 버리지 않는다 — RawSource.text 로 나가 place_channels.raw 에 박제되고, - 소개문·FAQ 생성 시점에만 근거로 쓰인다(services/copy_service.py). - """ + """모든 업종 공통.""" return [] def _lodging_facts(self, intro: dict) -> list[CollectedFact]: - """숙박 detailIntro2 → 사업장 단위 fact. - - ★ 스키마에 없는 값은 만들지 않는다. foodplace('식음료장 있음')는 lodging 스키마에 - 자리가 없어서 **일부러 버린다** — 억지로 breakfast 에 넣으면 '조식 제공' 으로 둔갑한다. - ★ roomcount·scalelodging·accomcountlodging·subfacility 는 2026-09-07 에 자리를 만들었다 - (total_rooms·building_scale·accommodation_capacity·facilities). 실측(오블로모프 3103191)에서 - TourAPI 가 준 값의 절반이 자리가 없어 버려지고 있었다. - """ + """숙박 detailIntro2 → 사업장 단위 fact.""" return self._collect([ ("check_in_time", self._plain(intro.get("checkintime"))[:40]), ("check_out_time", self._plain(intro.get("checkouttime"))[:40]), @@ -306,16 +236,7 @@ class TourApiAdapter: ]) def _restaurant_facts(self, intro: dict) -> list[CollectedFact]: - """음식점 detailIntro2 → 사업장 단위 fact. - - ★ 일부러 매핑하지 않는 필드 — 뜻이 미묘하게 다른 것들이다. - kidsfacility '어린이놀이방 유무' 이지 '아이 동반 가능' 이 아니다. - 놀이방이 없다고 kids_allowed=false 로 올리면 아이를 데려올 수 있는 가게를 - '아이 동반 불가' 로 만든다(실측: 가문 → kids_allowed=false 로 잘못 나왔다). - chkcreditcardfood '가능/없음' 이라 payment_methods(결제수단 **목록**)에 넣을 수 없다. - smoking '모두 금연석' 은 restaurant 스키마에 자리가 없다. - infocenterfood 문의 전화일 뿐 예약 채널이라는 근거가 없다. - """ + """음식점 detailIntro2 → 사업장 단위 fact.""" facts = self._collect([ ("business_hours", self._plain(intro.get("opentimefood"))[:500]), ("closed_days", self._plain(intro.get("restdatefood"))[:200]), @@ -325,14 +246,13 @@ class TourApiAdapter: ("reservation_required", self._bool_str(self._head_bool(intro.get("reservationfood")))), ]) # treatmenu 는 "돔베고기 / 멸치국수 / 물만두 등" 처럼 취급 메뉴를 이어 붙여 준다. - # ★ 가격이 없으므로 이름만 단위로 만든다. 이름 없는 단위는 base 계약이 막는다. for name in self._split_menu(intro.get("treatmenu")): facts.append(CollectedFact(key="menu_name", value=name, scope="unit", unit_name=name)) return facts @classmethod def _split_menu(cls, value) -> list[str]: - """'게살해물요리 / 난자완스 / 특색냉채 등' → 3건. 꼬리의 '등' 은 메뉴가 아니다.""" + """'게살해물요리 / 난자완스 / 특색냉채 등' → 3건.""" text = cls._plain(value) if not text: return [] @@ -344,14 +264,7 @@ class TourApiAdapter: return out[:20] def _room_facts(self, rooms: list[dict]) -> list[CollectedFact]: - """숙박 detailInfo2 → **객실 단위** fact. - - ★ 이 자리가 비어 있어서 place.units 가 전 사업장 0건이었다(naver_place_adapter 주석 참조). - TourAPI 는 객실을 행으로 주므로 여기서 가장 깨끗하게 채워진다. - ★ 면적은 roomsize2(㎡)를 쓴다 — 스키마의 room_size 단위가 ㎡ 다. roomsize1 은 평이라 섞으면 3배 틀린다. - ★ 요금은 비수기 최소요금(offseason)을 주중·주말로, 성수기(peak) 주중값을 peak_price 로 담는다. - '최소요금' 이라는 것을 사장님이 확인 화면에서 보고 고칠 수 있게 후보로만 올린다. - """ + """숙박 detailInfo2 → **객실 단위** fact.""" facts: list[CollectedFact] = [] seen: list[str] = [] for row in rooms: @@ -369,7 +282,7 @@ class TourApiAdapter: ("peak_price", self._number(row.get("roompeakseasonminfee1")), name), ("has_kitchen", self._bool_str(self._yn(row.get("roomcook"))), name), ("has_aircon", self._bool_str(self._yn(row.get("roomaircondition"))), name), - # 객실 편의시설 Y/N. ★ 빈 값은 '없음'이 아니라 '모름'이다 — _yn 이 None 을 주면 fact 를 만들지 않는다. + # 객실 편의시설 Y/N. ("has_bathroom", self._bool_str(self._yn(row.get("roombathfacility"))), name), ("has_tv", self._bool_str(self._yn(row.get("roomtv"))), name), ("has_internet", self._bool_str(self._yn(row.get("roominternet"))), name), @@ -384,12 +297,12 @@ class TourApiAdapter: @staticmethod def _bool_str(value: Optional[bool]) -> Optional[str]: - """★ None(모름)과 False(아니오)를 구분한다. 모르는 것을 '아니오' 로 만들지 않는다.""" + """None(모름)과 False(아니오)를 구분한다.""" return None if value is None else ("true" if value else "false") - # ---- 사진 ----------------------------------------------------------- + # 사진 def _media(self, common: dict, images: list[dict], rooms: list[dict]) -> list[CollectedMedia]: - """대표사진 + 추가사진 + 객실사진. 상업적 이용이 막힌 유형은 여기서 버린다.""" + """대표사진 + 추가사진 + 객실사진.""" out: list[CollectedMedia] = [] seen: set[str] = set() dropped = 0 @@ -400,7 +313,7 @@ class TourApiAdapter: if not url or url in seen: return code = str(license_code or "").strip().lower() - # ★ 라이선스를 모르면 담지 않는다. 상업 사이트에 실을 사진이라 '모름' 은 위험 쪽이다. + # 라이선스를 모르면 담지 않는다. if code not in COMMERCIAL_OK_LICENSES: dropped += 1 return diff --git a/solution/backend/services/collector/yanolja_adapter.py b/solution/backend/services/collector/yanolja_adapter.py index 73896d9..e48fe57 100644 --- a/solution/backend/services/collector/yanolja_adapter.py +++ b/solution/backend/services/collector/yanolja_adapter.py @@ -1,20 +1,4 @@ -"""야놀자(NOL) 국내숙소 어댑터 — Playwright 로 상세페이지를 렌더링해 객실·사진을 수집한다. - -★ nol.yanolja.com 은 Next.js CSR 페이지라 httpx(정적 HTML)로는 못 읽는다 — 그래서 - static_html_adapter 가 아니라 이 어댑터가 Playwright 로 실제 브라우저 렌더링을 거친다. - 이건 봇 탐지 우회가 아니라 JS 렌더링이 필요한 페이지를 읽는 통상적인 방법이다 — - 캡차 우회·IP 회전·지문 위장 같은 건 하지 않는다(registry.py 의 영구 금지 원칙 그대로 유지). - 차단(403·챌린지 등)을 만나면 그대로 실패로 돌려주고 재시도·우회하지 않는다. - -수집 원칙 - - 페이지에 보이는 값만 옮긴다. 가격은 수집하지 않는다(불안정하고 예약 시점에 따라 바뀐다). - - 이미지는 원본 URL 그대로만 남긴다(origin_url) — 재게시 여부는 발행 게이트가 판단한다. - - 객실(unit)별 값은 scope="unit" 로 담는다. 숙소소개·시설/서비스·이용안내·예약공지는 - 전부 크롤링하지만 fact 로 만들지 않는다 — 숙소소개(intro)·객실소개(room_intro)는 - allow_llm=True 필드라 그대로 넣으면 절대규칙 7을 어기고, 나머지 셋은 스키마의 - 특정 필드와 1:1로 안 맞는다. 넷 다 RawSource.text 로 보존한다. 시설/서비스·이용안내· - 예약공지는 확정 링크의 payload.links[].stayGuide로도 전달해 원문을 표시한다. -""" +"""야놀자(NOL) 국내숙소 어댑터 — Playwright 로 상세페이지를 렌더링해 객실·사진을 수집한다.""" from __future__ import annotations import re @@ -30,7 +14,7 @@ from services.collector.base import CollectedFact, CollectedMedia, RawSource SEARCH_URL = "https://nol.yanolja.com/" DETAIL_URL_RE = re.compile(r"nol\.yanolja\.com/stay/domestic/\d+", re.IGNORECASE) -# 검색결과 카드는
+# 검색결과 카드는 tuple[Optional[str], Optional[st def _parse_rooms_from_section_text(section_text: str) -> list[_RoomInfo]: - """PLACE_SECTION.innerText 는 "N / M"(사진 장수) 로 객실 카드 수만큼 반복된다. - 가격·예약 버튼 이하는 무시한다. 사이트 구조가 바뀌면 이 정규식도 손봐야 한다.""" + """PLACE_SECTION.innerText 는 "N / M"(사진 장수) 로 객실 카드 수만큼 반복된다.""" rooms: list[_RoomInfo] = [] chunks = re.split(r"\n?\d+\s*\n/\n\d+\n", section_text) for chunk in chunks[1:]: @@ -137,8 +120,7 @@ async def _get_section_text(page: Page, element_id: str) -> str: async def _get_room_images_by_name(page: Page, element_id: str) -> list[tuple[str, list[str]]]: - """카드 구조가 "사진 캐러셀 →

객실명

→ 설명" 순이라, h2 를 만나기 전까지 - 쌓인 이미지가 그 h2 의 몫이다.""" + """카드 구조가 "사진 캐러셀 →

객실명

→ 설명" 순이라, h2 를 만나기 전까지 쌓인 이미지가 그 h2 의 몫이다.""" try: await page.evaluate( "(id) => { const el = document.getElementById(id); if (el) el.scrollIntoView({block:'center'}); }", @@ -207,11 +189,7 @@ async def _new_page(): async def search_by_address(address: str, timeout_ms: int = 15000) -> Optional[tuple[str, str]]: - """주소로 검색해 첫 검색결과의 (상세 URL, 업체명)을 돌려준다. 못 찾으면 None. - - discover 단계(주소만 아는 상태에서 링크를 찾는 쪽)가 쓴다. 카드를 클릭하지 않고 - href 를 직접 읽어 이동한다 — 클릭 시 새 탭이 뜨거나 배너에 가로채이는 문제를 피한다. - """ + """주소로 검색해 첫 검색결과의 (상세 URL, 업체명)을 돌려준다.""" pw, browser, page = await _new_page() try: await page.goto(SEARCH_URL, wait_until="domcontentloaded") @@ -256,7 +234,7 @@ class YanoljaAdapter: try: await page.wait_for_selector("h1", timeout=15000) except PWTimeoutError: - # ★ 우회하지 않는다 — 차단·비정상 응답이면 그대로 실패로 돌린다. + # 우회하지 않는다 — 차단·비정상 응답이면 그대로 실패로 돌린다. pass name = await _get_stay_name(page) @@ -279,10 +257,7 @@ class YanoljaAdapter: gallery = await _get_section_images(page, SECTION_IDS["overview"]) - # 숙소소개·시설서비스·이용안내·예약공지 — fact 로 만들 스키마 필드가 없어 - # RawSource.text 로만 싣는다(생성 근거). intro 계열은 allow_llm=True 라 - # fact 로 만들면 안 된다(절대규칙 7, 2026-08-31 사고: TourAPI 가 원문을 그대로 - # intro 로 밀어넣어 LLM 소개문을 영영 못 보이게 만들었다). + # 숙소소개·시설서비스·이용안내·예약공지 — fact 로 만들 스키마 필드가 없어 RawSource.text 로만 싣는다(생성 근거). section_texts: list[str] = [] for section_key, label in _TEXT_ONLY_SECTIONS: body = await _get_section_text(page, SECTION_IDS[section_key]) @@ -310,7 +285,7 @@ class YanoljaAdapter: facts.append(CollectedFact(key="max_capacity", value=max_cap, scope="unit", unit_name=room.name)) if room.bed: facts.append(CollectedFact(key="bed_type", value=room.bed, scope="unit", unit_name=room.name)) - # ★ room_intro 도 allow_llm=True 라 수집하지 않는다(위 intro 와 같은 이유). + # room_intro 도 allow_llm=True 라 수집하지 않는다(위 intro 와 같은 이유). for src in room.images: media.append(CollectedMedia(origin_url=src, label="객실 사진", unit_name=room.name)) diff --git a/solution/backend/services/copy_service.py b/solution/backend/services/copy_service.py index 3f8f0ae..d4affca 100644 --- a/solution/backend/services/copy_service.py +++ b/solution/backend/services/copy_service.py @@ -1,4 +1,4 @@ -"""COPY 흐름. 단계 구현: copy_steps / 프롬프트: prompts/copy / 호출·검증: external/gemini_text.""" +"""COPY 흐름.""" from common.logger import LOG from services.copy_steps import CopyAborted, prepare_copy, generate_copy, save_copy, fill_faqs from services.external import gemini_text @@ -26,9 +26,7 @@ async def run_copy(job: dict) -> dict: async with progress.step("generate"): copy = await generate_copy(inputs) except gemini_text.GeminiError as ex: - # ★ 호출 실패는 미설정과 같은 취급이다 — fact 만으로도 편집·발행이 되고 - # (publish_gate.check_unique_content), 발행은 고유 콘텐츠 0건으로 막지 않는다. - # 자세한 배경은 DEVLOG.md 참고. + # 호출 실패는 미설정과 같은 취급이다 — fact 만으로도 편집·발행이 되고 (publish_gate.check_unique_content), 발행은 고유 콘텐츠 0건으로 막지 않는다. note = f"생성 호출 실패: {ex}" LOG.w(f"[copy] 생성 실패, fact 만으로 계속: {ex}") await progress.skip("generate", "generation_failed") diff --git a/solution/backend/services/copy_steps.py b/solution/backend/services/copy_steps.py index bbafb86..48121b1 100644 --- a/solution/backend/services/copy_steps.py +++ b/solution/backend/services/copy_steps.py @@ -1,18 +1,4 @@ -"""소개문·FAQ 생성 — COPY 잡이 하는 일. - -★ LLM 은 사실을 만들지 않는다. 문장만 쓴다. - - 입력은 **확보된 fact(노출 가능한 것)만**. 미검증 값으로 문장을 쓰면 그 문장도 미검증이다. - - 생성물은 `ground_check` 를 통과한 것만 저장한다(클라이언트가 이미 걸러 보내지만 근거를 다시 요구한다). - - 소개문·FAQ 는 **바로 노출값**이다(VERIFIED). 승인 단계를 두지 않는다 — 2026-09-10 결정. - 게이트는 앞에 있다: 입력이 확인된 fact 뿐이고, 근거 없는 FAQ 는 저장조차 하지 않는다. - 확인된 사실로 쓴 문장을 한 번 더 승인받게 하면 같은 사실을 두 번 승인하는 셈이고, - 실제로는 그 화면이 닫힌 뒤에 문장이 도착해 발행본이 영영 빈칸이었다 - (근거·실측: services/fact_service.upsert_fact · docs/DECISIONS.md 7절). - - 사장님이 고친 문장(CORRECTED)은 재생성이 덮지 않는다. 그 잠금은 그대로다. - - FAQ 가 목표 수(20)에 모자라면 업종 카탈로그에서 겹치지 않는 공통 질문을 **문의 안내** 답으로 채운다 - (services/faq_fill · common/faq_catalog). 답에 값·가능 여부를 적지 않으므로 사실을 만들지 않는다. - ★ fact 가 0건이어도(또는 API 키가 없어도) 채운다 — 그때는 LLM 을 부르지 않고 채우기만 한다. -""" +"""소개문·FAQ 생성 — COPY 잡이 하는 일.""" import uuid from dataclasses import dataclass @@ -80,7 +66,7 @@ async def prepare_copy(place_id: str, owner_user_id: str) -> CopyInputs: except (CategorySchemaError, ValueError) as ex: raise CopyAborted(f"지원하지 않는 업종: {place.category}") from ex - # ★ 노출 가능한 fact 만 근거로 준다. 미검증 값으로 쓴 문장은 그 자체가 미검증이다. + # 노출 가능한 fact 만 근거로 준다. pid = uuid.UUID(place_id) f_err, fact_rows = await DB_SESSION_MNG.execute_lambda( place_facts.DBType(), @@ -100,23 +86,7 @@ async def prepare_copy(place_id: str, owner_user_id: str) -> CopyInputs: for r in fact_rows if r.unit_id is None and (r.value or "").strip() ] - # ★ 수집 원문도 근거로 넘긴다 — fact 가 아니라 place_channels.raw 에 박제된 글이다. - # - # 왜 필요한가: 소개 원문(TourAPI overview·네이버 description)에만 있는 정보가 있다. - # '전면 통창 실내 온수풀', '판교역에서 3분' 같은 것들인데, 이게 근거에 없으면 - # ground_check 가 그 문장을 전부 반려해 소개문·FAQ 가 앙상해진다. - # - # 왜 fact 로 넣지 않는가: `intro` 는 allow_llm=True 라 LLM 의 출력 칸이다. - # 원문을 그 칸에 넣었더니 457자 원문이 발행본의 '숙소 소개' 를 차지했다(2026-08-31). - # 근거로만 쓰고 저장은 하지 않는다 — 원문은 화면에 나가지 않는다. - # ★ 확정 링크만 읽던 것을 **조사 근거까지** 읽게 넓혔다(2026-09-10). - # 업소 조사(`place_research`)는 남이 쓴 글이라 확정하지 않는다 — 공식 채널이 아니므로 - # 발행본의 sameAs·푸터에 나가면 안 된다. 그런데 그것 때문에 여기서도 안 읽혀서, - # 조사해 온 재료가 소개문에 한 글자도 닿지 않았다. 확정 여부는 "화면에 채널로 - # 내보낼 것인가" 의 판단이지 "근거로 읽을 것인가" 의 판단이 아니다. - # ★ 다만 아무 미확정 링크나 읽지는 않는다 — raw.kind 가 research 인 것만이다. - # 미확정 채널 URL 은 동명 업소일 수 있고(그게 확정 절차의 이유다), 조사 근거는 - # 상호 대조를 통과한 것만 적재된다(`grounding/place_research.parse_items`). + # 수집 원문도 근거로 넘긴다 — fact 가 아니라 place_channels.raw 에 박제된 글이다. records: list[str] = [] l_err, link_rows = await DB_SESSION_MNG.execute_lambda( place_channels.DBType(), @@ -130,8 +100,7 @@ async def prepare_copy(place_id: str, owner_user_id: str) -> CopyInputs: continue text = (raw.get("text") or "").strip() if text: - # ★ fact 목록이 아니라 records 로 넘긴다. fact 자리에 넣으면 모델이 값 하나로 - # 읽고 거의 쓰지 않는다(prompts/copy.build_prompt 머리주석의 실측). + # fact 목록이 아니라 records 로 넘긴다. records.append(text[:4000]) # ground_check 는 여전히 이 글을 근거로 인정해야 한다 — 근거 목록에도 남긴다. grounded.append(gemini_text.FactInput( @@ -139,11 +108,6 @@ async def prepare_copy(place_id: str, owner_user_id: str) -> CopyInputs: )) # 객실·메뉴 요약도 근거로 넘긴다 — "최대 4명" 같은 수치가 통과하려면 근거에 있어야 한다. - # - # ★ 근거 없음 판정보다 **먼저** 읽는다. - # 예전에는 사업장 fact 가 0건이면 여기까지 오지 못하고 되돌아갔다. 그런데 네이버에 - # 요금표만 올라온 모텔은 사업장 fact 가 0건이고 객실 fact 만 있다 — 쓸 근거가 있는데도 - # "근거 없음"으로 끝나 소개문·FAQ 가 영구히 생기지 않았다. u_err, unit_rows = await DB_SESSION_MNG.execute_lambda( place_units.DBType(), DBWRType.DB_READ.value, @@ -159,8 +123,7 @@ async def prepare_copy(place_id: str, owner_user_id: str) -> CopyInputs: { "name": u.name, "facts": by_unit.get(str(u.unit_id), {}), - # 스키마 라벨·단위를 같이 넘긴다 — 이게 없으면 프롬프트에 'weekday_price' 라는 - # 날 key 가 그대로 실려 모델이 그 낱말로 문장을 쓴다. + # 스키마 라벨·단위를 같이 넘긴다 — 이게 없으면 프롬프트에 'weekday_price' 라는 날 key 가 그대로 실려 모델이 그 낱말로 문장을 쓴다. "labels": { key: { "label": schema.get(key).label if schema.get(key) else key, @@ -173,7 +136,7 @@ async def prepare_copy(place_id: str, owner_user_id: str) -> CopyInputs: if by_unit.get(str(u.unit_id)) ] - # FAQ 채우기에 쓸 업종 카탈로그. 없으면(카페·음식점·호텔) 채우지 않는다. + # FAQ 채우기에 쓸 업종 카탈로그. catalog = find_catalog(place.category, place.external_category) # 사업장·객실 fact 를 가리지 않는다 — "기준 인원" 은 객실 fact 로 답한다. known_fact_keys = {r.key for r in fact_rows if (r.value or "").strip()} @@ -214,7 +177,7 @@ async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None) "meta": False, "faqs": 0, "faq_fill": 0, # 목표 수를 채운 문의 안내 문항 수 - # ★ 반려된 문장을 그대로 남긴다 — 소개문이 왜 안 나왔는지 운영자가 알아야 한다. + # 반려된 문장을 그대로 남긴다 — 소개문이 왜 안 나왔는지 운영자가 알아야 한다. "rejected": [list(r) for r in (copy.rejected or [])][:20] if copy else [], } @@ -227,23 +190,19 @@ async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None) return stat actor = UserInfo( - # ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로 - # 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다. - # 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가 - # 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다. + # 잡이 쓰는 신원. user_id=str(inputs.place.owner_user_id), id="generator", role=1, ) service = FactService(_fact_crud, _place_crud) - # 소개문·메타는 fact 로 들어간다 — FactService 가 allow_llm 을 다시 확인하고(뒷문 없음), - # LLM 출처라 후보가 아니라 노출값으로 앉힌다(upsert_fact 의 LLM 분기). + # 소개문·메타는 fact 로 들어간다 — FactService 가 allow_llm 을 다시 확인하고(뒷문 없음), LLM 출처라 후보가 아니라 노출값으로 앉힌다(upsert_fact 의 LLM 분기). for key, text_value in (("intro", copy.intro), ("meta_description", copy.meta_description)): if not (text_value or "").strip(): continue if not (schema.get(key) and schema.get(key).allow_llm): - # 이 업종 스키마가 LLM 작성을 허용하지 않는 필드다. 조용히 건너뛴다. + # 이 업종 스키마가 LLM 작성을 허용하지 않는 필드다. continue res = await service.upsert_fact( actor, place_id, @@ -257,14 +216,14 @@ async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None) else: stat["rejected"].append([key, res.result.desc]) - # 확인 안 된 기존 생성 FAQ 는 내리고 새로 넣는다. 사람이 확인한 FAQ 는 건드리지 않는다. + # 확인 안 된 기존 생성 FAQ 는 내리고 새로 넣는다. await DB_SESSION_MNG.execute_lambda_claim( place_faqs.DBType(), lambda s: _faq_crud.expire_generated(s, pid, now), ) for order, faq in enumerate(copy.faqs or []): if not faq.fact_keys: - # ★ 근거 없는 FAQ 는 저장하지 않는다. + # 근거 없는 FAQ 는 저장하지 않는다. stat["rejected"].append([faq.question, "근거 fact 없음"]) continue row = place_faqs( @@ -273,8 +232,7 @@ async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None) answer=faq.answer, source_fact_ids=list(faq.fact_keys), generated_by=SourceType.LLM.value, - # ★ 바로 노출한다 (2026-09-10 결정 — fact_service.upsert_fact 주석이 근거). - # 근거 fact 가 없으면 위에서 이미 버렸으므로, 여기 남은 것은 전부 확인된 사실로 쓴 문장이다. + # 바로 노출한다. status=FactStatus.VERIFIED.value, sort_order=order, ) @@ -291,12 +249,7 @@ async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None) async def fill_faqs(pid: uuid.UUID, catalog: FaqCatalog, known_fact_keys: set[str], phone: str | None) -> int: - """노출 중인 FAQ 가 목표 수에 모자란 만큼 문의 안내 문항을 넣는다. 넣은 건수를 돌려준다. - - ★ 기존 FAQ 는 **노출 중인 것 전부**로 센다 — 방금 넣은 생성분만이 아니라 재생성이 남긴 - 사장님 입력·정정분까지. 그래야 사장님이 이미 답한 주제에 문의 안내가 겹쳐 붙지 않는다. - ★ 바로 노출값(VERIFIED)으로 넣는다. 답이 주장을 하지 않아 확인할 대상이 없다 — - 대신 JSON-LD · llms.txt · 고유 콘텐츠 계수에서는 빠진다(shared selectAnsweredFaqs).""" + """노출 중인 FAQ 가 목표 수에 모자란 만큼 문의 안내 문항을 넣는다.""" l_err, rows = await DB_SESSION_MNG.execute_lambda( place_faqs.DBType(), DBWRType.DB_READ.value, diff --git a/solution/backend/services/external/alimtalk.py b/solution/backend/services/external/alimtalk.py index 76983c0..9165439 100644 --- a/solution/backend/services/external/alimtalk.py +++ b/solution/backend/services/external/alimtalk.py @@ -1,4 +1,4 @@ -"""알림톡 대행사 계약은 여기 한 곳에만 둔다. 승인 URL·전화번호·응답 원문을 로깅하지 않는다.""" +"""알림톡 대행사 계약은 여기 한 곳에만 둔다.""" import hashlib import hmac diff --git a/solution/backend/services/external/gemini.py b/solution/backend/services/external/gemini.py index a40935a..f17aba8 100644 --- a/solution/backend/services/external/gemini.py +++ b/solution/backend/services/external/gemini.py @@ -1,15 +1,4 @@ -"""사진 라벨·접근성 alt 생성 — 겹들을 엮어 결과를 만드는 자리. - -이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다: - - 무엇을 묻는가 services/prompts/vision.py 프롬프트·응답 스키마 - 어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용 - 무엇을 돌려주는가 여기 배치 나누기 → 호출 → ref 매칭 → 조립 - -결과는 순서가 아니라 `ref` 로 매칭하고, 신뢰도가 낮으면 사람 확인 대상으로 남긴다. -(문장 생성과 달리 여기엔 grounding 겹이 없다 — 사진 설명은 대조할 fact 가 없고, - 대신 신뢰도 임계값과 사람 확인 큐가 그 몫을 한다.) -""" +"""사진 라벨·접근성 alt 생성 — 겹들을 엮어 결과를 만드는 자리.""" from dataclasses import dataclass, field from typing import Optional @@ -41,24 +30,24 @@ _MAGIC = ( @dataclass class ImageInput: - """분석할 사진 1장. origin_url 이 결과 매칭 키다(place.media.origin_url 과 같은 값).""" + """분석할 사진 1장.""" origin_url: str - data: Optional[bytes] = None # 이미 받아둔 바이트. 없으면 fetch_url 에서 내려받는다 - fetch_url: Optional[str] = None # 내려받을 주소. 비면 origin_url 을 쓴다 + data: Optional[bytes] = None # 이미 받아둔 바이트. + fetch_url: Optional[str] = None # 내려받을 주소. mime_type: Optional[str] = None # 없으면 시그니처로 판별 unit_name_hint: Optional[str] = None # "A동 스탠다드" 같은 힌트가 있으면 라벨에 반영 @dataclass class VisionResult: - """사진 1장의 분석 결과. 입력과 1:1 로 대응하며 실패해도 자리를 지킨다.""" + """사진 1장의 분석 결과.""" origin_url: str label: Optional[str] = None alt_text: Optional[str] = None confidence: float = 0.0 - needs_review: bool = True # ★ 기본이 '사람 확인 필요'다. 확신이 있을 때만 내려간다 + needs_review: bool = True # 기본이 '사람 확인 필요'다. ok: bool = False error: Optional[str] = None @@ -82,9 +71,7 @@ def _sniff_mime(data: bytes) -> str: async def _load_bytes(client: httpx.AsyncClient, image: ImageInput) -> bytes: - """이미지 바이트 확보. 이미 있으면 그대로, 없으면 내려받는다. - - 직접 내려받는 이유: 타임아웃을 우리가 통제하고, 사진별 실패를 개별로 보고하기 위해서다.""" + """이미지 바이트 확보.""" if image.data: return image.data url = image.fetch_url or image.origin_url @@ -105,9 +92,7 @@ async def _run_batch( max_retries: int, usage: _Usage, ) -> dict[str, VisionResult]: - """배치 1개 처리. 반환 {origin_url: VisionResult}. - - ★ 배치가 통째로 실패해도 예외를 밖으로 던지지 않는다 — 호출측이 나머지 배치를 계속 돌려야 한다.""" + """배치 1개 처리.""" out: dict[str, VisionResult] = {} ref_map: dict[str, ImageInput] = {} images_payload: list[ImagePart] = [] @@ -117,7 +102,7 @@ async def _run_batch( try: data = await _load_bytes(client, image) except Exception as ex: - # 이 사진만 실패. 배치의 나머지는 그대로 보낸다. + # 이 사진만 실패. out[image.origin_url] = VisionResult( origin_url=image.origin_url, ok=False, needs_review=True, error=f"이미지 로드 실패: {type(ex).__name__}: {ex}", @@ -156,13 +141,13 @@ async def _run_batch( usage.input_tokens += batch_usage.input_tokens usage.output_tokens += batch_usage.output_tokens - # ★ 순서가 아니라 ref 로 매칭한다. + # 순서가 아니라 ref 로 매칭한다. seen: set[str] = set() for item in parsed.get("items") or []: ref = str(item.get("ref", "")).strip() image = ref_map.get(ref) if image is None: - continue # 모델이 없는 ref 를 지어냈다 — 버린다 + continue seen.add(ref) try: confidence = float(item.get("confidence") or 0.0) @@ -176,7 +161,7 @@ async def _run_batch( label=label, alt_text=alt, confidence=confidence, - # ★ 신뢰도 미달이거나 라벨/alt 가 비면 자동 반영하지 않는다. + # 신뢰도 미달이거나 라벨/alt 가 비면 자동 반영하지 않는다. needs_review=confidence < confidence_threshold or not label or not alt, ok=True, ) @@ -202,12 +187,7 @@ async def analyze_images( max_retries: int = 2, client: Optional[httpx.AsyncClient] = None, ) -> list[VisionResult]: - """사진들을 분류하고 alt 를 만든다. - - ★ 반환 길이는 항상 입력과 같다. 실패분도 ok=False 로 자리를 지킨다 — - 호출측이 길이나 순서로 매칭하다 어긋나면 엉뚱한 사진에 alt 가 붙는다. - ★ needs_review=True 인 항목은 자동 반영하지 말고 사람 확인 큐(MediaStatus.PENDING_REVIEW)로 보낸다. - """ + """사진들을 분류하고 alt 를 만든다.""" llm_provider = provider.active() if not llm_provider.is_configured(): raise GeminiNotConfigured("API 키가 설정되지 않았다") diff --git a/solution/backend/services/external/gemini_extract.py b/solution/backend/services/external/gemini_extract.py index 65fd87b..da03f6d 100644 --- a/solution/backend/services/external/gemini_extract.py +++ b/solution/backend/services/external/gemini_extract.py @@ -1,20 +1,4 @@ -"""원문 텍스트 → fact 후보 추출 — 겹들을 엮어 결과를 만드는 자리. - -이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다: - - 무엇을 묻는가 services/prompts/extract.py 프롬프트·응답 스키마 - 어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용 - 답을 믿을 것인가 services/grounding/extract.py evidence 원문 대조 - 무엇을 돌려주는가 여기 호출 → 검증 → CollectedFact 조립 - -★ 입력이 무엇이든 여기로 모인다 - 사장님이 붙여넣은 텍스트도, static_html 어댑터가 받아온 페이지 본문도 - 똑같이 '원문 문자열' 하나다. 도메인마다 파서를 짜는 대신 여기 한 곳을 쓴다. - -★ 나가는 값은 전부 후보다 - 통과한 fact 도 UNVERIFIED 로 들어간다. 사장님이 확인해야 사이트에 나간다 — - 그 게이트는 fact 계층이 담당한다. 여기서는 '원문에 있었다' 까지만 보장한다. -""" +"""원문 텍스트 → fact 후보 추출 — 겹들을 엮어 결과를 만드는 자리.""" from dataclasses import dataclass, field from typing import Optional @@ -30,18 +14,13 @@ from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput from services.llm.errors import LlmNotConfigured as GeminiNotConfigured from services.prompts.extract import RESPONSE_SCHEMA, build_prompt -# 이보다 짧은 원문은 호출하지 않는다. 메뉴판 한 줄도 안 되는 분량에서 나올 fact 는 없고, -# 호출비만 나간다. +# 이보다 짧은 원문은 호출하지 않는다. MIN_SOURCE_CHARS = 80 @dataclass class ExtractResult: - """추출 결과. facts 는 검증을 통과한 것만 담긴다. - - rejected 에는 (항목, 사유) 가 들어간다 — 조용히 버리지 않는다. - 운영자가 "왜 체크인 시간이 안 들어왔나" 를 이 목록으로 읽는다. - """ + """추출 결과.""" facts: list[CollectedFact] = field(default_factory=list) rejected: list[tuple[str, str]] = field(default_factory=list) @@ -61,12 +40,7 @@ async def extract_facts( max_retries: int = 2, client: Optional[httpx.AsyncClient] = None, ) -> ExtractResult: - """원문 텍스트에서 업종 스키마 fact 를 뽑는다. - - ★ source_url 은 필수다. 출처 없는 fact 는 FACT_SOURCE_REQUIRED 로 거부되므로 - 여기서 구조적으로 찍어 둔다(사장님 붙여넣기면 'owner:paste' 같은 식별자라도 넣는다). - ★ 원문이 짧으면 **API 를 호출하지 않는다** — 근거가 없는데 부르면 그게 곧 환각 유발이다. - """ + """원문 텍스트에서 업종 스키마 fact 를 뽑는다.""" llm = provider.active() if not llm.is_configured(): raise GeminiNotConfigured("API 키가 설정되지 않았다") @@ -84,7 +58,7 @@ async def extract_facts( try: llm_result = await llm.generate( client, model, prompt=build_prompt(place_name, category, text), - # ★ 0.0 — 옮겨 적는 작업이다. 창의성이 개입할 자리가 없다. + # 0.0 — 옮겨 적는 작업이다. response_schema=RESPONSE_SCHEMA, temperature=0.0, max_retries=max_retries, ) finally: @@ -95,7 +69,7 @@ async def extract_facts( if not isinstance(rows, list): raise GeminiInvalidOutput(f"facts 가 배열이 아니다: {type(rows).__name__}") - # ★ 여기가 관문이다. 모델이 뭘 적어 냈든 원문과 대조해서 통과한 것만 나간다. + # 여기가 관문이다. passed, rejected = verify(rows, source_text=text, schema=get_schema(category)) facts = [ diff --git a/solution/backend/services/external/gemini_text.py b/solution/backend/services/external/gemini_text.py index 90b05a1..204d57f 100644 --- a/solution/backend/services/external/gemini_text.py +++ b/solution/backend/services/external/gemini_text.py @@ -1,15 +1,4 @@ -"""소개문·메타설명·FAQ 생성 — 겹들을 엮어 결과를 만드는 자리. - -이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다: - - 무엇을 묻는가 services/prompts/copy.py 프롬프트·응답 스키마 - 어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용 - 답을 믿을 것인가 services/grounding/copy.py ground_check · faq_polarity_ok - 무엇을 돌려주는가 여기 근거 모으기 → 호출 → 검증 → 조립 - -한때 이 네 가지가 한 파일 500줄에 뭉쳐 있었다. "FAQ 답이 이상하다" 를 고치러 와도 -어디를 봐야 할지가 파일 안에서 갈리지 않았다. -""" +"""소개문·메타설명·FAQ 생성 — 겹들을 엮어 결과를 만드는 자리.""" import hashlib import json from dataclasses import dataclass, field @@ -41,30 +30,18 @@ class GeneratedFaq: @dataclass class GeneratedCopy: - """생성 결과. 검증을 통과한 것만 담긴다. - - rejected 에는 (버린 내용, 사유) 가 들어간다 — 조용히 버리지 않는다. - 운영자가 "왜 소개문이 안 나왔나" 를 이 목록으로 읽는다.""" + """생성 결과.""" intro: Optional[str] = None intro_fact_keys: list[str] = field(default_factory=list) meta_description: Optional[str] = None faqs: list[GeneratedFaq] = field(default_factory=list) rejected: list[tuple[str, str]] = field(default_factory=list) - source: str = "" # ★ "openai:gpt-5.6-luna" 형식 — copy_steps.py 가 fact 출처 표기에 쓴다 + source: str = "" # "openai:gpt-5.6-luna" 형식 — copy_steps.py 가 fact 출처 표기에 쓴다 def _unit_facts(unit_summaries: Optional[list[dict]]) -> list[FactInput]: - """객실·프로그램 요약을 근거 fact 로 펼친다. - - {"name": "A동", "facts": {"max_capacity": "4"}} → FactInput("A동:max_capacity", …) - 이렇게 해야 "최대 4명" 같은 문장이 근거 있는 것으로 통과한다. - - ★ `labels` 가 함께 오면 스키마 라벨·단위를 쓴다({key: {"label","unit"}}). - 이 목록은 프롬프트에도 그대로 실리므로, 라벨이 없으면 모델이 'weekday_price' 라는 - 날 key 를 보고 글을 쓴다 — "weekday_price는 20000입니다" 같은 문장이 나온다. - 없으면 지금까지처럼 key 를 라벨 자리에 둔다(호출측이 스키마를 모를 수 있다). - """ + """객실·프로그램 요약을 근거 fact 로 펼친다.""" out: list[FactInput] = [] for unit in unit_summaries or []: name = str(unit.get("name") or "").strip() @@ -102,19 +79,12 @@ async def generate_copy( max_retries: int = 2, client: Optional[httpx.AsyncClient] = None, ) -> GeneratedCopy: - """확보된 fact 만으로 소개문·메타설명·FAQ 를 만든다. - - ★ facts 가 비면 **API 를 호출하지 않고** 빈 결과를 돌려준다 — - 근거 없이 문장을 쓰면 그게 곧 환각이다. - ★ 생성 결과는 전부 ground_check 를 통과한 것만 담긴다. 통과 못 한 항목은 rejected 로 간다. - ★ 생성 대상 필드는 업종 스키마의 allow_llm=True 인 것뿐이다(호출측이 필터링해서 넘긴다). - """ + """확보된 fact 만으로 소개문·메타설명·FAQ 를 만든다.""" llm = provider.active() if not llm.is_configured(): raise GeminiNotConfigured(f"{llm.__name__.rsplit('.', 1)[-1].upper()}_API_KEY 가 설정되지 않았다") model = model or llm.DEFAULT_MODEL - # ★ 사업장 fact 가 없어도 객실·메뉴 근거가 있으면 쓴다. 요금표만 있는 모텔이 그 경우다 — - # "대실 20,000원" 은 근거 있는 사실이고, 손님이 가장 먼저 묻는 것이기도 하다. + # 사업장 fact 가 없어도 객실·메뉴 근거가 있으면 쓴다. unit_grounding = _unit_facts(unit_summaries) if not facts and not unit_grounding: LOG.i(f"[llm-text] '{place_name}' 근거 fact 0건 — 생성하지 않는다(호출 없음)") @@ -161,7 +131,7 @@ async def generate_copy( else: result.rejected.append((meta_desc, " / ".join(reasons))) - # ── FAQ ── 항목마다 따로 검사한다. 하나가 걸려도 나머지는 산다. + # ── FAQ ── 항목마다 따로 검사한다. for item in (parsed.get("faqs") or [])[:max_faqs]: question = (item.get("question") or "").strip() answer = (item.get("answer") or "").strip() @@ -169,11 +139,11 @@ async def generate_copy( continue keys = _valid_keys(item.get("fact_keys"), allowed_keys) if not keys: - # ★ 근거를 못 대는 FAQ 는 버린다 — 사실인지 확인할 방법이 없다. + # 근거를 못 대는 FAQ 는 버린다 — 사실인지 확인할 방법이 없다. result.rejected.append((question, "근거 fact_keys 가 없다")) continue ok, reasons = ground_check(f"{question} {answer}", grounding) - # 질문은 주장이 아니라 값-반대 판정에서 빠진다. 그 빈틈은 답변 쪽에서 따로 막는다. + # 질문은 주장이 아니라 값-반대 판정에서 빠진다. polar_ok, polar_reasons = faq_polarity_ok(question, answer, grounding) if not ok or not polar_ok: result.rejected.append((question, " / ".join(reasons + polar_reasons))) @@ -190,9 +160,6 @@ async def generate_copy( # ── 요약(summarize_text) ────────────────────────────────────────────────── -# ★ generate_copy 와 다르다: 여기서 압축하는 문장은 **이미 승인된 값**이다(fact 로 저장된 intro· -# room_intro). 새 사실을 만드는 게 아니라 같은 내용을 짧게 쓰는 것뿐이라 ground_check 를 다시 -# 걸지 않는다 — "사실을 더하지 마라"는 프롬프트 지시로 충분하다. _SUMMARY_CACHE: dict[str, str] = {} _SUMMARY_CACHE_MAX = 500 _SUMMARY_PROMPT = ( @@ -210,11 +177,7 @@ async def summarize_text( max_retries: int = 2, client: Optional[httpx.AsyncClient] = None, ) -> Optional[str]: - """캔버스 미리보기용 축약문. 실패해도 예외를 올리지 않는다 — 호출측은 None 이면 원문을 쓴다. - - ★ DB 에 남기지 않는다. 같은 원문은 프로세스 메모리 캐시(sha256 키)로 재호출을 막는다 - (서버 재시작하면 비워진다 — 요구사항: "DB 저장은 생략하고 프론트 응답에만 실어준다"). - """ + """캔버스 미리보기용 축약문.""" stripped = text.strip() if not stripped: return None @@ -256,7 +219,7 @@ async def summarize_text( @dataclass class GeneratedSong: - """가사 생성 결과. 곡은 여기서 만들지 않는다 — 작곡은 services/external/suno 다.""" + """가사 생성 결과.""" title: str lyrics: str @@ -274,14 +237,7 @@ async def generate_song( max_retries: int = 2, client: Optional[httpx.AsyncClient] = None, ) -> GeneratedSong: - """이 업소의 노래 가사를 쓴다. - - ★ `ground_check` 를 걸지 않는다. 가사는 사실 진술이 아니라 정서라 문장 단위로 근거를 - 맞추면 전부 반려된다("밤이 깊어도 불이 켜져 있다" 에 대응하는 fact 는 없다). - 대신 프롬프트가 **없는 시설·숫자를 말하지 말라**고 못 박는다(services/prompts/song 머리주석). - ★ 재료가 하나도 없으면 부르지 않는다 — 소개문과 같은 규칙이다. 상호와 지역만으로 쓴 노래는 - 어느 숙소에 붙여도 말이 되는 노래이고, 그건 이 기능이 하려던 일이 아니다. - """ + """이 업소의 노래 가사를 쓴다.""" llm = provider.active() if not llm.is_configured(): raise GeminiNotConfigured("API 키가 설정되지 않았다") @@ -321,12 +277,7 @@ async def generate_song( async def generate_social_post(place_name, facts, link_url, provider=2, *, client=None): - """실제 게시 문자열을 검증한다. 초과·근거 실패 시 다시 받고 문장을 자르지 않는다. - - ★ 2026-09-21: 다른 생성 함수(generate_copy 등)와 같은 이유로 공급자 선택을 탄다 - (`LLM_PROVIDER`, 기본 openai) — Gemini 고정을 없앴다. 인자 이름 `provider` 는 - SNS 플랫폼(쓰레드=2)을 가리키는 기존 값이라, LLM 공급자 모듈은 `llm_provider` 로 - 따로 들여와 이름이 겹치지 않게 한다.""" + """실제 게시 문자열을 검증한다.""" from services.prompts import social from services.external.social import adapter, weighted_length, URL from services.llm import provider as llm_provider diff --git a/solution/backend/services/external/google_identity.py b/solution/backend/services/external/google_identity.py index d967986..d7e9309 100644 --- a/solution/backend/services/external/google_identity.py +++ b/solution/backend/services/external/google_identity.py @@ -1,23 +1,4 @@ -"""구글 ID 토큰 검증 — "이 토큰이 정말 구글이 **우리 앱에** 발급한 것인가" 만 본다. - -프론트(Google Identity Services)가 받아 온 ID 토큰을 그대로 우리 백엔드로 보내면, -여기서 구글 공개키로 서명을 확인하고 신원(sub·email)을 꺼낸다. 그 뒤로는 우리 JWT 다 — -구글 토큰을 세션으로 들고 다니지 않는다. - -★ 왜 client_secret 이 없나 - 코드 교환(authorization code flow)을 하지 않기 때문이다. GIS 는 브라우저에서 ID 토큰을 - 바로 준다. 서버가 할 일은 교환이 아니라 **검증**이고, 검증에 필요한 건 공개키와 client_id 뿐이다. - -★ 반드시 남겨야 할 검사 세 가지 (하나만 빠져도 조용히 뚫린다) - 1. 서명 — 구글 JWKS 의 공개키로. 이게 없으면 아무나 JSON 을 만들어 보낸다. - 2. aud — 우리 client_id 와 같아야 한다. 없으면 **다른 서비스에 발급된 진짜 구글 토큰**을 - 그대로 들고 와서 우리 계정이 된다(가장 흔한 구멍이다). - 3. iss — accounts.google.com. 서명과 함께 발급자를 못 박는다. - email_verified 도 함께 본다 — 미인증 이메일을 신원으로 쓰면 이메일 기반 판단이 전부 흔들린다. - -★ 공개키는 돌아간다(rotation). kid 가 캐시에 없으면 한 번 다시 받는다 — - TTL 만 믿고 있으면 키가 바뀐 직후 몇 분 동안 전원 로그인 실패다. -""" +"""구글 ID 토큰 검증 — "이 토큰이 정말 구글이 **우리 앱에** 발급한 것인가" 만 본다.""" import asyncio import time @@ -29,13 +10,13 @@ from jose import jwt, JWTError from common.logger import LOG from config.server_configs import google_oauth_config -# 구글 공개키(JWKS). OpenID discovery 를 매번 타지 않고 고정 주소를 쓴다 — 구글이 바꾸지 않는 주소다. +# 구글 공개키(JWKS). _JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs" -# 구글은 두 표기를 모두 쓴다. 한쪽만 받으면 어느 날 갑자기 전원 로그인 실패다. +# 구글은 두 표기를 모두 쓴다. _ISSUERS = ("accounts.google.com", "https://accounts.google.com") -# 캐시 수명. 구글 응답의 Cache-Control 은 보통 수 시간이라 1시간은 넉넉히 보수적이다. +# 캐시 수명. _JWKS_TTL_SEC = 3600 _HTTP_TIMEOUT_SEC = 5.0 @@ -47,7 +28,7 @@ _jwks_lock = asyncio.Lock() class GoogleNotConfigured(RuntimeError): - """GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다. 서버는 뜬다.""" + """GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다.""" class GoogleTokenInvalid(RuntimeError): @@ -56,9 +37,9 @@ class GoogleTokenInvalid(RuntimeError): @dataclass class GoogleAccount: - """ID 토큰에서 꺼낸 신원. 여기 없는 값은 쓰지 않는다.""" + """ID 토큰에서 꺼낸 신원.""" - sub: str # 구글 계정의 영구 식별자. 이메일이 바뀌어도 유지된다 — 계정 매칭 키는 이것뿐이다. + sub: str # 구글 계정의 영구 식별자. email: str name: str @@ -118,8 +99,7 @@ async def verify_id_token(id_token: str) -> GoogleAccount: algorithms=["RS256"], audience=google_oauth_config.client_id, issuer=_ISSUERS, - # at_hash 는 access_token 과 짝일 때만 의미가 있다. GIS 크리덴셜에는 access_token 이 - # 없으므로 켜 두면 "access_token 이 없다"는 이유로 정상 토큰이 거부된다. + # at_hash 는 access_token 과 짝일 때만 의미가 있다. options={"verify_at_hash": False}, ) except JWTError as ex: diff --git a/solution/backend/services/external/kakao.py b/solution/backend/services/external/kakao.py index c581890..af926f6 100644 --- a/solution/backend/services/external/kakao.py +++ b/solution/backend/services/external/kakao.py @@ -1,32 +1,4 @@ -"""카카오 로컬 API 클라이언트 — 동일 업소 검증 + 지역 정보. - -이 서비스에서 카카오 로컬이 하는 일은 두 가지다. - -1. **동일 업소 검증** (가장 중요) - Perplexity 가 찾아온 채널 URL 이 정말 그 가게 것인지 확인하는 유일한 근거다. - 이 단계가 없으면 동명 업소 정보가 섞이고, 남의 가게 체크인 시간이 우리 사이트로 나간다. - ★ 애매하면 자동 판정하지 않고 사람에게 넘긴다(pick_match 참고). - -2. **지역 정보** (주변 맛집·시설) - 좌표 → 행정구역 코드로 바꾸고, 그 코드 단위로 주변 정보를 모은다. - -── 비용 (2026-08 기준) ─────────────────────────────────────────────── -무료 쿼터: 키워드 검색 일 10만 · 좌표 변환 일 10만 · 전체 월 300만 -초과 단가: 키워드/카테고리 검색 **2원** · 좌표 변환 **0.5원** - → ★ 키워드/카테고리 검색이 좌표 변환보다 **4배 비싸다** - -★ 무료 쿼터는 개발자 계정의 '첫 번째 활성 앱' 에만 붙는다. dev/stage/prod 앱을 따로 파면 하나만 무료다. - -그래서 호출 정책이 이렇다. - - 키워드 검색(search_keyword) : 사업장 등록·재검증 때만. 비싸다 - - 좌표 변환(coord_to_region) : 싸다. 사업장당 1회면 충분(좌표는 안 바뀐다) - - 카테고리 검색(search_category): 비싸다. ★ **행정구역 코드 단위로 캐싱**해야 한다. - 같은 지역에 사이트가 50개 생겨도 조회는 1회여야 한다. - 캐싱 자체는 지역 모듈(area_contents, 캐시 키 = region_code)이 책임진다 — - 이 클라이언트는 캐시를 두지 않는다. 호출 전에 캐시를 먼저 보라는 뜻이다. - -호출 횟수는 전부 LOG.i 로 남긴다(비용 추적). _CALL_COUNTS 로 프로세스 누적도 볼 수 있다. -""" +"""카카오 로컬 API 클라이언트 — 동일 업소 검증 + 지역 정보.""" import re from collections import Counter @@ -45,13 +17,13 @@ _COORD2REGION_URL = f"{_BASE_URL}/v2/local/geo/coord2regioncode.json" _CATEGORY_URL = f"{_BASE_URL}/v2/local/search/category.json" _ADDRESS_URL = f"{_BASE_URL}/v2/local/search/address.json" -# 초과 단가(원). 로그에 함께 남겨 어떤 호출이 비싼지 바로 보이게 한다. +# 초과 단가(원). _UNIT_COST_KRW = {"keyword": 2.0, "category": 2.0, "coord2region": 0.5, "address": 0.5} # 프로세스 누적 호출 수 — 생성 1건당 검색 횟수를 세는 근거. _CALL_COUNTS: Counter = Counter() -# 카테고리 그룹 코드(카카오 정의). 지역 정보에서 쓰는 것만 추려 둔다. +# 카테고리 그룹 코드(카카오 정의). CATEGORY_RESTAURANT = "FD6" # 음식점 CATEGORY_CAFE = "CE7" # 카페 CATEGORY_ATTRACTION = "AT4" # 관광명소 @@ -61,34 +33,24 @@ CATEGORY_PARKING = "PK6" # 주차장 class KakaoNotConfigured(RuntimeError): - """KAKAO_REST_API_KEY 미설정 — 이 어댑터만 비활성이다. - - 서버 부팅을 막지 않는다(외부 계약에 부팅이 묶이면 안 된다). 호출측이 잡아 - ErrorType.LOCAL_NOT_CONFIGURED 로 응답한다.""" + """KAKAO_REST_API_KEY 미설정 — 이 어댑터만 비활성이다.""" class KakaoRequestFailed(RuntimeError): - """카카오 로컬 호출 실패(네트워크·타임아웃·5xx·인증오류). - - ★ 실패했다고 빈 값을 내보내면 안 된다 — 호출측은 직전 값을 유지하고 내부 알림만 낸다.""" + """카카오 로컬 호출 실패(네트워크·타임아웃·5xx·인증오류).""" class MatchOutcome(str, Enum): - """동일 업소 판정 결과. - - ※ 이 서비스 안에서만 쓰는 판정 결과라 모듈 지역 enum 으로 둔다. - 라우터 응답으로 내보낼 일이 생기면 common/enums.py 로 올려야 한다.""" + """동일 업소 판정 결과.""" MATCHED = "matched" # 이 가게가 맞다고 확정 - AMBIGUOUS = "ambiguous" # ★ 동명 업소 등 — 사람이 골라야 한다 + AMBIGUOUS = "ambiguous" # 동명 업소 등 — 사람이 골라야 한다 NO_CANDIDATE = "no_candidate" # 카카오에서 후보를 못 찾음 @dataclass(frozen=True) class KakaoPlace: - """카카오 로컬이 돌려준 장소 1건. - - ★ 카카오 응답의 x 는 경도(longitude), y 는 위도(latitude) 다. 뒤집으면 엉뚱한 지역이 된다.""" + """카카오 로컬이 돌려준 장소 1건.""" kakao_place_id: str # id — 동일 업소 판정의 유일 키 name: str # place_name @@ -98,8 +60,7 @@ class KakaoPlace: latitude: Optional[float] # y longitude: Optional[float] # x category_name: Optional[str] - # ★ 업종 자동 판별의 입력. 한글 분류(category_name)는 카카오가 언제든 바꾸지만 이 코드는 안 바뀐다 — - # AD5 숙박 · CE7 카페 · FD6 음식점 · HP8 병원. 매핑은 services/place_category.py 가 소유한다. + # 업종 자동 판별의 입력. category_group_code: Optional[str] place_url: Optional[str] @@ -111,8 +72,8 @@ class KakaoPlace: road_address=(doc.get("road_address_name") or None), address=(doc.get("address_name") or None), phone=(doc.get("phone") or None), - latitude=_to_float(doc.get("y")), # ★ y = 위도 - longitude=_to_float(doc.get("x")), # ★ x = 경도 + latitude=_to_float(doc.get("y")), # y = 위도 + longitude=_to_float(doc.get("x")), # x = 경도 category_name=(doc.get("category_name") or None), category_group_code=(doc.get("category_group_code") or None), place_url=(doc.get("place_url") or None), @@ -121,7 +82,7 @@ class KakaoPlace: @dataclass(frozen=True) class RegionCode: - """행정구역 코드. ★ 지역 정보 캐시의 키다(같은 지역 사이트 50개여도 조회 1회).""" + """행정구역 코드.""" code: str # 행정동/법정동 코드 region_1depth_name: str # 시·도 @@ -147,10 +108,7 @@ class RegionCode: @dataclass class MatchResult: - """동일 업소 판정 결과 + **판정 근거**. - - 근거를 같이 들고 다니는 이유: ambiguous 로 떨어졌을 때 사람이 무엇을 보고 골라야 하는지 - 알아야 하고, matched 로 확정됐을 때도 나중에 '왜 이 가게로 붙었나' 를 추적해야 한다.""" + """동일 업소 판정 결과 + **판정 근거**.""" outcome: MatchOutcome place: Optional[KakaoPlace] = None # MATCHED 일 때만 채워진다 @@ -163,16 +121,13 @@ class MatchResult: return self.outcome == MatchOutcome.MATCHED -# ---- 문자열 정규화 ------------------------------------------------------- -# ★ 과하게 정규화하면 다른 가게가 같은 이름으로 보인다. 공백/대소문자까지만 건드린다. +# 문자열 정규화 _WS_RE = re.compile(r"\s+") _DIGIT_RE = re.compile(r"\D") def normalize_name(name: str) -> str: - """상호명 비교용 정규화 — 공백 제거 + 소문자화. 그 이상은 하지 않는다. - - '하조대 펜션' 과 '하조대펜션' 은 같게 보되, '하조대펜션' 과 '하조대펜션 별관' 은 다르게 본다.""" + """상호명 비교용 정규화 — 공백 제거 + 소문자화.""" return _WS_RE.sub("", (name or "")).lower() @@ -188,20 +143,9 @@ def _to_float(value) -> Optional[float]: return None -# ---- 동일 업소 판정 ------------------------------------------------------ +# 동일 업소 판정 def pick_match(name: str, candidates: list[KakaoPlace], phone: Optional[str] = None) -> MatchResult: - """★ 검색 결과에서 '이 가게가 맞다' 를 판정한다. 이 서비스에서 가장 비싼 실수가 나는 지점이다. - - 판정 순서 - 1. 후보 0건 → NO_CANDIDATE - 2. 전화번호가 주어졌고 정확히 1건 일치 → MATCHED (가장 강한 근거) - 전화번호 일치가 2건 이상 → 그 부분집합으로 좁혀 상호명 판정을 이어간다 - 3. 정규화 상호명이 정확히 1건 일치 → MATCHED - 4. 정규화 상호명이 2건 이상 일치 → AMBIGUOUS (동명 업소) - 5. 정확히 일치하는 상호명이 없음 → AMBIGUOUS (부분일치만으로는 확정하지 않는다) - - ★ 애매하면 반드시 AMBIGUOUS 로 떨어뜨린다. 억지로 하나 고르면 남의 가게 정보가 섞이고, - 그건 사이트가 발행된 뒤에야 드러난다(그때는 이미 예약 클레임이 난 뒤다).""" + """검색 결과에서 '이 가게가 맞다' 를 판정한다.""" if not candidates: return MatchResult( outcome=MatchOutcome.NO_CANDIDATE, @@ -226,7 +170,7 @@ def pick_match(name: str, candidates: list[KakaoPlace], phone: Optional[str] = N detail=f"전화번호({phone})가 정확히 1건과 일치합니다: {phone_hits[0].name}", ) if len(phone_hits) > 1: - # 전화번호까지 같은 후보가 여럿 — 지점 등록 등. 그 안에서 상호명으로 다시 본다. + # 전화번호까지 같은 후보가 여럿 — 지점 등록 등. pool = phone_hits phone_note = f" (전화번호 일치 {len(phone_hits)}건으로 좁힘)" @@ -266,24 +210,21 @@ def pick_match(name: str, candidates: list[KakaoPlace], phone: Optional[str] = N ) -# ---- 클라이언트 ---------------------------------------------------------- +# 클라이언트 class KakaoLocalClient: - """카카오 로컬 API 호출기. - - 키가 없으면 생성은 되지만 호출 시 KakaoNotConfigured 를 던진다 — - 부팅이 외부 계약에 묶이지 않게 하기 위함이다(설정이 비면 이 어댑터만 비활성).""" + """카카오 로컬 API 호출기.""" def __init__(self, api_key: Optional[str] = None, transport=None, timeout: float = 10.0): - # api_key 를 명시하지 않으면 설정에서 읽는다. 테스트는 transport 를 주입한다. + # api_key 를 명시하지 않으면 설정에서 읽는다. self._api_key = external_api_config.kakao_rest_api_key if api_key is None else api_key self._transport = transport self._timeout = timeout self._client: Optional[httpx.AsyncClient] = None - # ---- 내부 ---- + # 내부 @property def enabled(self) -> bool: - """키가 설정돼 있는지. 호출 전에 확인해 조용히 건너뛸 수 있게 한다.""" + """키가 설정돼 있는지.""" return bool(self._api_key) def _headers(self) -> dict: @@ -307,7 +248,7 @@ class KakaoLocalClient: self._client = None async def _get(self, url: str, params: dict, kind: str) -> dict: - """공통 GET. 호출 1건마다 비용을 로그로 남긴다.""" + """공통 GET.""" headers = self._headers() # 키 없으면 여기서 KakaoNotConfigured _CALL_COUNTS[kind] += 1 LOG.i( @@ -322,7 +263,6 @@ class KakaoLocalClient: raise KakaoRequestFailed(f"카카오 로컬 {kind} 요청 실패: {type(ex).__name__}: {ex}") from ex if resp.status_code == 401: - # 키가 있지만 잘못됐다 — 설정 문제라 재시도해도 소용없다. raise KakaoNotConfigured(f"카카오 로컬 인증 실패(401) — REST API 키를 확인하세요: {resp.text[:200]}") if resp.status_code != 200: raise KakaoRequestFailed(f"카카오 로컬 {kind} 응답 오류 status={resp.status_code} body={resp.text[:200]}") @@ -332,16 +272,11 @@ class KakaoLocalClient: except ValueError as ex: raise KakaoRequestFailed(f"카카오 로컬 {kind} 응답 파싱 실패: {ex}") from ex - # ---- 1) 상호명 → 주소·좌표·전화 ---- + # 1) 상호명 → 주소·좌표·전화 async def search_keyword( self, name: str, x: Optional[float] = None, y: Optional[float] = None, size: int = 15 ) -> list[KakaoPlace]: - """상호명으로 장소를 찾는다. **동일 업소 검증의 입력**이다. - - ★ 비싸다(초과 시 건당 2원). 사업장 등록·재검증 때만 부른다. - x/y 를 주면 그 좌표 근처를 우선한다(x=경도, y=위도). 지역을 아는 경우 후보가 훨씬 깨끗해진다. - - 반환된 목록은 그대로 pick_match 에 넘긴다 — 여기서 하나를 고르지 않는다.""" + """상호명으로 장소를 찾는다.""" params: dict = {"query": name, "size": max(1, min(size, 15))} if x is not None and y is not None: params["x"] = str(x) # 경도 @@ -349,12 +284,9 @@ class KakaoLocalClient: data = await self._get(_KEYWORD_URL, params, "keyword") return [KakaoPlace.from_document(d) for d in (data.get("documents") or [])] - # ---- 2) 좌표 → 행정구역 코드 ---- + # 2) 좌표 → 행정구역 코드 async def coord_to_region(self, lat: float, lon: float) -> RegionCode: - """좌표를 행정구역 코드로 바꾼다. ★ 이 코드가 지역 정보 캐시의 키다. - - 싸다(초과 시 건당 0.5원, 키워드 검색의 1/4). 좌표는 안 바뀌므로 사업장당 1회면 충분하다. - 행정동(H)을 우선 반환하고, 없으면 첫 문서를 쓴다.""" + """좌표를 행정구역 코드로 바꾼다.""" data = await self._get(_COORD2REGION_URL, {"x": str(lon), "y": str(lat)}, "coord2region") docs = data.get("documents") or [] if not docs: @@ -363,15 +295,9 @@ class KakaoLocalClient: picked = next((d for d in docs if d.get("region_type") == "H"), docs[0]) return RegionCode.from_document(picked) - # ---- 2-1) 주소 → 좌표 ---- + # 2-1) 주소 → 좌표 async def geocode_address(self, address: str) -> Optional[tuple[float, float]]: - """도로명·지번 주소 → (위도, 경도). 결과가 없으면 None — 좌표를 지어내지 않는다. - - ★ 쓰는 곳: 좌표 없이 검증된 사업장의 주변 정보 수집(local_content_service.sync_place). - 동일 업소 검증(카카오 후보·네이버 상세)은 좌표를 같이 주므로 보통은 비어 있지 않다 — - 비는 건 옛 데이터나 좌표 없는 후보를 고른 경우다. 그때 주소로 한 번 더 찾는다. - 싸다(초과 시 건당 0.5원). 결과는 places 에 박제하므로 사업장당 1회다. - """ + """도로명·지번 주소 → (위도, 경도).""" query = (address or "").strip() if not query: return None @@ -380,12 +306,12 @@ class KakaoLocalClient: if not docs: LOG.i(f"[kakao] 주소 → 좌표 결과 없음: {query[:60]}") return None - lat, lon = _to_float(docs[0].get("y")), _to_float(docs[0].get("x")) # ★ y=위도 · x=경도 + lat, lon = _to_float(docs[0].get("y")), _to_float(docs[0].get("x")) # y=위도 · x=경도 if lat is None or lon is None: return None return lat, lon - # ---- 3) 주변 맛집·시설 ---- + # 3) 주변 맛집·시설 async def search_category( self, region_x: float, @@ -395,17 +321,7 @@ class KakaoLocalClient: size: int = 15, region_code: Optional[str] = None, ) -> list[KakaoPlace]: - """좌표 반경 안의 카테고리 장소(주변 맛집·카페·관광지 등)를 찾는다. - - ★ 비싸다(초과 시 건당 2원 — 좌표 변환의 4배). **반드시 행정구역 코드 단위로 캐싱해서 부른다.** - 같은 지역에 사이트가 50개 생겨도 이 호출은 1회여야 한다. - - 캐싱은 이 클라이언트가 하지 않는다 — 지역 모듈이 area_contents 에 - `region_code + content_type` 키로 저장하고, 캐시 미스일 때만 여기를 부른다. - `region_code` 인자는 그 캐시 키를 호출 지점에서 명시하게 하려고 받는다(로그에도 남는다). - 호출측이 region_code 를 못 주면 캐시 없이 부르고 있다는 뜻이라 경고를 남긴다. - - region_x = 경도(longitude), region_y = 위도(latitude). 그 지역의 중심 좌표를 넘긴다.""" + """좌표 반경 안의 카테고리 장소(주변 맛집·카페·관광지 등)를 찾는다.""" if not region_code: LOG.w( "[kakao] search_category 를 region_code 없이 호출했습니다 — " @@ -424,7 +340,7 @@ class KakaoLocalClient: LOG.i(f"[kakao] category={category_group_code} region_code={region_code or '미지정'} → {len(docs)}건") return docs - # ---- 조합: 상호명 하나로 동일 업소까지 ---- + # 조합: 상호명 하나로 동일 업소까지 async def verify_place( self, name: str, @@ -432,15 +348,7 @@ class KakaoLocalClient: *, search_query: Optional[str] = None, ) -> MatchResult: - """상호명(+전화번호)으로 검색해서 동일 업소를 판정한다. - - 키워드 검색 1회만 쓴다. 결과가 MATCHED 가 아니면 사람이 골라야 한다 — - 호출측은 AMBIGUOUS → ErrorType.PLACE_VERIFY_AMBIGUOUS, - NO_CANDIDATE → ErrorType.PLACE_VERIFY_NO_CANDIDATE 로 응답한다. - - ★ name 에는 상호명만 넣는다 — 판정이 정확일치를 보기 때문이다. - 지역이 섞인 검색어는 search_query 로 넘긴다(naver.verify_place 와 같은 규약). - """ + """상호명(+전화번호)으로 검색해서 동일 업소를 판정한다.""" candidates = await self.search_keyword(search_query or name) result = pick_match(name, candidates, phone) detail = f"'{name}'" if not search_query or search_query == name else f"'{name}' (검색어 '{search_query}')" diff --git a/solution/backend/services/external/naver.py b/solution/backend/services/external/naver.py index 34bbda4..49c8707 100644 --- a/solution/backend/services/external/naver.py +++ b/solution/backend/services/external/naver.py @@ -1,36 +1,4 @@ -"""네이버 지역검색 API 클라이언트 — 동일 업소 검증 + 지역 정보. - -카카오 REST 키가 없어서 네이버로 간다. `services/external/kakao.py` 와 **같은 형태**로 만들어 -나중에 카카오 키가 나오면 갈아끼울 수 있게 한다(판정 결과 enum 은 카카오 것을 그대로 쓴다). - -── 카카오 대비 제약 (실측 확인, 2026-08-27) ───────────────────────────── - 후보 수 : **최대 5건** (카카오 15건). display 를 6·30 으로 줘도 400 이 아니라 - 조용히 5건으로 잘린다 — 그래서 클라이언트에서 명시적으로 5 로 클램프한다. - 전화번호 : **항상 빈 문자열** — 동명 업소를 가르는 가장 강한 근거가 없다. - 고유 place id: **없다.** `link` 는 네이버 플레이스 URL 이 아니라 **업체 자체 홈페이지**다 - (예: 스타벅스 → http://www.starbucks.co.kr/). 그래서 naver_place_id 는 보통 None 이다. - 행정구역 코드: **없다.** 대신 도로명주소에서 region_key() 로 캐시 키를 만든다. - -→ ★ 판정 근거가 카카오보다 약하다. 그만큼 pick_match 가 **더 쉽게 AMBIGUOUS 로 떨어진다.** - 후보를 억지로 하나 고르면 남의 가게 정보가 우리 사이트로 나가고, 그건 발행된 뒤에야 드러난다. - -── 응답 필드 (실측) ───────────────────────────────────────────────────── - title : `` 태그와 HTML 엔티티가 섞여 온다 → strip_tags 로 반드시 벗긴다 - link : 업체 홈페이지 URL (없으면 빈 문자열). 채널 URL 발견에 쓸 수 있는 부수입이다 - category : "숙박>펜션" 처럼 '>' 로 구분된 문자열 - telephone : 항상 "" - address : 지번 주소 / roadAddress : 도로명 주소 - mapx, mapy : **WGS84 를 1e7 배한 정수 문자열**. mapx=경도, mapy=위도. - 네이버가 2021년 이후 TM128(KATEC) 에서 WGS84*1e7 로 바꿨다. - 검산(2026-08-27): 서울특별시청 mapx=1269783882 mapy=375666103 → 126.97839, 37.56661 - (실제 37.5663, 126.9779 / 오차 0.0005 이내). 경복궁·해운대해수욕장도 동일하게 일치. - → 그래서 단순히 1e7 로 나눈다. 별도 좌표계 변환이 필요 없다. - -── 비용 ───────────────────────────────────────────────────────────────── -네이버 검색 API 는 무료지만 **일 25,000회 쿼터**가 있다(애플리케이션당). -호출 횟수는 LOG.i 로 남긴다 — 생성 1건당 검색 횟수를 세는 근거. -주변 정보는 반경 검색이 없어 "지역명 + 키워드" 로 찾으므로, ★ 반드시 지역 캐시를 거쳐 부른다. -""" +"""네이버 지역검색 API 클라이언트 — 동일 업소 검증 + 지역 정보.""" import html import re @@ -48,7 +16,7 @@ from services.external.kakao import MatchOutcome _LOCAL_URL = "https://openapi.naver.com/v1/search/local.json" -# ★ 네이버 지역검색은 최대 5건이다. 더 요청해도 조용히 5건으로 잘린다(실측). +# 네이버 지역검색은 최대 5건이다. _MAX_DISPLAY = 5 # 프로세스 누적 호출 수 — 생성 1건당 검색 횟수를 세는 근거. @@ -56,35 +24,27 @@ _CALL_COUNTS: Counter = Counter() class NaverNotConfigured(RuntimeError): - """NAVER_CLIENT_ID / NAVER_CLIENT_SECRET 미설정 — 이 어댑터만 비활성이다. - - 서버 부팅을 막지 않는다(외부 계약에 부팅이 묶이면 안 된다). 호출측이 잡아 - ErrorType.LOCAL_NOT_CONFIGURED 로 응답한다.""" + """NAVER_CLIENT_ID / NAVER_CLIENT_SECRET 미설정 — 이 어댑터만 비활성이다.""" class NaverRequestFailed(RuntimeError): - """네이버 지역검색 호출 실패(네트워크·타임아웃·5xx·파싱오류). - - ★ 실패했다고 빈 값을 내보내면 안 된다 — 호출측은 직전 값을 유지하고 내부 알림만 낸다.""" + """네이버 지역검색 호출 실패(네트워크·타임아웃·5xx·파싱오류).""" -# ---- 값 객체 ------------------------------------------------------------- +# 값 객체 @dataclass(frozen=True) class NaverPlace: - """네이버 지역검색이 돌려준 장소 1건. KakaoPlace 와 같은 필드 이름을 쓴다(갈아끼우기용). - - ★ phone 은 거의 항상 None 이다(네이버가 telephone 을 빈 값으로 준다). - ★ place_url 은 **업체 자체 홈페이지**다 — 네이버 플레이스 페이지가 아니다.""" + """네이버 지역검색이 돌려준 장소 1건.""" name: str # title 에서 태그·엔티티를 벗긴 값 road_address: Optional[str] # roadAddress address: Optional[str] # address (지번) - phone: Optional[str] # telephone — 실측상 항상 None + phone: Optional[str] latitude: Optional[float] # mapy / 1e7 longitude: Optional[float] # mapx / 1e7 category_name: Optional[str] # "숙박>펜션" place_url: Optional[str] # link — 업체 홈페이지(네이버 플레이스 아님) - naver_place_id: Optional[str] # link 가 네이버 플레이스 URL 일 때만. 보통 None + naver_place_id: Optional[str] # link 가 네이버 플레이스 URL 일 때만. @classmethod def from_item(cls, item: dict) -> "NaverPlace": @@ -94,8 +54,8 @@ class NaverPlace: road_address=(item.get("roadAddress") or None), address=(item.get("address") or None), phone=(item.get("telephone") or None), # 빈 문자열 → None - latitude=_scaled_coord(item.get("mapy")), # ★ mapy = 위도 - longitude=_scaled_coord(item.get("mapx")), # ★ mapx = 경도 + latitude=_scaled_coord(item.get("mapy")), # mapy = 위도 + longitude=_scaled_coord(item.get("mapx")), # mapx = 경도 category_name=(item.get("category") or None), place_url=link, naver_place_id=_extract_place_id(link), @@ -104,10 +64,7 @@ class NaverPlace: @dataclass class MatchResult: - """동일 업소 판정 결과 + **판정 근거**. KakaoMatchResult 와 구조가 같다. - - 근거를 같이 들고 다니는 이유: ambiguous 로 떨어졌을 때 사람이 무엇을 보고 골라야 하는지 - 알아야 하고, matched 로 확정됐을 때도 나중에 '왜 이 가게로 붙었나' 를 추적해야 한다.""" + """동일 업소 판정 결과 + **판정 근거**.""" outcome: MatchOutcome place: Optional[NaverPlace] = None # MATCHED 일 때만 채워진다 @@ -120,33 +77,25 @@ class MatchResult: return self.outcome == MatchOutcome.MATCHED -# ---- 문자열 정리 --------------------------------------------------------- +# 문자열 정리 _TAG_RE = re.compile(r"<[^>]+>") _WS_RE = re.compile(r"\s+") -# 네이버 플레이스 URL 에서 place id 를 뽑는다. link 는 보통 업체 홈페이지라 대개 안 걸린다. +# 네이버 플레이스 URL 에서 place id 를 뽑는다. _PLACE_ID_RE = re.compile(r"(?:place\.naver\.com|map\.naver\.com)[^\s]*?/(\d{6,})") def strip_tags(text: str) -> str: - """title 에서 `` 하이라이트 태그와 HTML 엔티티를 벗긴다. - - ★ 순서가 중요하다 — 태그를 먼저 지우고 그 다음에 엔티티를 푼다. - 엔티티를 먼저 풀면 본문에 있던 '<b>'(진짜 텍스트)가 태그로 둔갑해 지워진다.""" + """title 에서 `` 하이라이트 태그와 HTML 엔티티를 벗긴다.""" return html.unescape(_TAG_RE.sub("", text or "")).strip() def normalize_name(name: str) -> str: - """상호명 비교용 정규화 — 공백 제거 + 소문자화. 그 이상은 하지 않는다. - - '하조대 펜션' 과 '하조대펜션' 은 같게 보되, '하조대펜션' 과 '하조대펜션 별관' 은 다르게 본다. - 과하게 정규화하면 다른 가게가 같은 이름으로 보인다.""" + """상호명 비교용 정규화 — 공백 제거 + 소문자화.""" return _WS_RE.sub("", strip_tags(name)).lower() def _scaled_coord(value) -> Optional[float]: - """mapx/mapy(정수 문자열) → WGS84 도(degree). - - 네이버는 WGS84 를 1e7 배한 정수로 준다(2021년 TM128 에서 전환). 검산은 모듈 독스트링 참고.""" + """mapx/mapy(정수 문자열) → WGS84 도(degree).""" try: return int(value) / 1e7 except (TypeError, ValueError): @@ -160,18 +109,13 @@ def _extract_place_id(link: Optional[str]) -> Optional[str]: return m.group(1) if m else None -# ---- 지역 캐시 키 -------------------------------------------------------- -# ★ 네이버는 행정구역 코드를 주지 않는다. 도로명주소에서 '시도 + 시군구' 를 뽑아 캐시 키를 만든다. -# 이 키가 area_contents.region_code(VARCHAR(10)) 에 들어간다 — 길이를 반드시 지켜야 한다. -# -# 시도 이름은 흔들린다(강원도 ↔ 강원특별자치도). 별칭을 전부 같은 코드로 모아야 -# 같은 지역이 두 키로 갈리지 않는다 — 갈리면 캐시가 무의미해진다. +# 지역 캐시 키 _SIDO_CODES = { "서울특별시": "11", "서울": "11", "부산광역시": "26", "부산": "26", "대구광역시": "27", "대구": "27", "인천광역시": "28", "인천": "28", - "광주광역시": "29", # ※ '광주' 단독은 광역시. 경기도 광주시는 시도 토큰이 '경기도'라 안 겹친다 + "광주광역시": "29", # ※ '광주' 단독은 광역시. "대전광역시": "30", "대전": "30", "울산광역시": "31", "울산": "31", "세종특별자치시": "36", "세종시": "36", "세종": "36", @@ -193,22 +137,7 @@ _REGION_KEY_MAX = 10 # local_contents.region_code = VARCHAR(10) def region_key(road_address: Optional[str]) -> Optional[str]: - """도로명주소 → 지역 캐시 키(시도코드 2자리 + 시군구명). 못 만들면 None. - - ★ 이 키 단위로 지역 정보(날씨·축제·관광지·맛집)를 캐싱한다. - 같은 지역에 사이트가 50개 생겨도 외부 조회는 1회여야 한다. - - 예) - "강원특별자치도 양양군 현북면 하조대3길 25" → "51양양군" - "강원도 양양군 ..." → "51양양군" (옛 이름도 같은 키) - "서울특별시 강남구 도산대로57길 24" → "11강남구" - "세종특별자치시 한누리대로 2130" → "36세종" (시군구 단층제) - "경기도 성남시 분당구 ..." → "41성남시" (일반구는 시 단위로 묶는다) - "Tokyo, Japan" → None - - 일반구(성남시 분당구 등)를 시 단위로 묶는 이유: 날씨·축제·관광지는 구 단위로 다르지 않고, - 묶을수록 캐시 히트율이 올라간다. 특별시·광역시의 자치구(강남구 등)는 시도 바로 다음 토큰이라 - 그대로 구 단위로 남는다 — 생활권이 실제로 다르다.""" + """도로명주소 → 지역 캐시 키(시도코드 2자리 + 시군구명).""" if not road_address: return None tokens = _WS_RE.sub(" ", road_address.strip()).split(" ") @@ -230,28 +159,14 @@ def region_key(road_address: Optional[str]) -> Optional[str]: key = f"{sido_code}{tokens[1]}" if len(key) > _REGION_KEY_MAX: - # 실측상 최대 8자라 여기 오지 않는다. 와도 DB 가 자르기 전에 우리가 자르고 남긴다. LOG.w(f"[naver] 지역 키가 {len(key)}자라 {_REGION_KEY_MAX}자로 자릅니다: {key}") key = key[:_REGION_KEY_MAX] return key -# ---- 동일 업소 판정 ------------------------------------------------------ +# 동일 업소 판정 def pick_match(name: str, candidates: list[NaverPlace], address_hint: Optional[str] = None) -> MatchResult: - """★ 검색 결과에서 '이 가게가 맞다' 를 판정한다. 이 서비스에서 가장 비싼 실수가 나는 지점이다. - - 카카오보다 **보수적이다.** 네이버는 전화번호를 주지 않아 동명 업소를 가를 결정적 근거가 - 하나 없고, 후보도 5건까지만 온다. 그만큼 더 쉽게 AMBIGUOUS 로 떨어뜨린다. - - 판정 순서 - 1. 후보 0건 → NO_CANDIDATE - 2. 정규화 상호명이 정확히 1건 일치 → MATCHED (name_exact) - 3. 정확 일치가 2건 이상 → address_hint 로 좁혀 1건이면 MATCHED (name_address) - 그래도 여럿이면 AMBIGUOUS (name_duplicate) - 4. 정확히 일치하는 상호명이 없음 → AMBIGUOUS (name_no_exact) - ★ 부분일치로는 절대 확정하지 않는다 - - ★ 억지로 하나 고르면 남의 가게 정보가 섞이고, 그건 사이트가 발행된 뒤에야 드러난다.""" + """검색 결과에서 '이 가게가 맞다' 를 판정한다.""" if not candidates: return MatchResult( outcome=MatchOutcome.NO_CANDIDATE, @@ -274,7 +189,7 @@ def pick_match(name: str, candidates: list[NaverPlace], address_hint: Optional[s ) if len(name_hits) > 1: - # 동명 업소 — 주소 힌트로 좁혀 본다. 전화번호가 없으니 이게 유일한 추가 근거다. + # 동명 업소 — 주소 힌트로 좁혀 본다. narrowed = _narrow_by_address(name_hits, address_hint) if len(narrowed) == 1: return MatchResult( @@ -310,10 +225,7 @@ def pick_match(name: str, candidates: list[NaverPlace], address_hint: Optional[s def _narrow_by_address(candidates: list[NaverPlace], address_hint: Optional[str]) -> list[NaverPlace]: - """주소 힌트에 들어 있는 토큰으로 후보를 좁힌다. - - 힌트의 각 토큰(시군구·읍면동 등)이 후보 주소에 들어 있는지만 본다 — 도로명·번지까지 - 정확히 맞추라고 하면 표기 차이(괄호 법정동, 건물명)로 다 떨어진다.""" + """주소 힌트에 들어 있는 토큰으로 후보를 좁힌다.""" if not address_hint: return list(candidates) tokens = [t for t in _WS_RE.sub(" ", address_hint.strip()).split(" ") if len(t) >= 2] @@ -331,12 +243,9 @@ def _narrow_by_address(candidates: list[NaverPlace], address_hint: Optional[str] return [c for h, c in scored if h == best] -# ---- 클라이언트 ---------------------------------------------------------- +# 클라이언트 class NaverLocalClient: - """네이버 지역검색 API 호출기. - - 키가 없으면 생성은 되지만 호출 시 NaverNotConfigured 를 던진다 — - 부팅이 외부 계약에 묶이지 않게 하기 위함이다(설정이 비면 이 어댑터만 비활성).""" + """네이버 지역검색 API 호출기.""" def __init__( self, @@ -345,17 +254,17 @@ class NaverLocalClient: transport=None, timeout: float = 10.0, ): - # 명시하지 않으면 설정에서 읽는다. 테스트는 transport 를 주입한다. + # 명시하지 않으면 설정에서 읽는다. self._client_id = external_api_config.naver_client_id if client_id is None else client_id self._client_secret = external_api_config.naver_client_secret if client_secret is None else client_secret self._transport = transport self._timeout = timeout self._client: Optional[httpx.AsyncClient] = None - # ---- 내부 ---- + # 내부 @property def enabled(self) -> bool: - """키가 설정돼 있는지. 호출 전에 확인해 조용히 건너뛸 수 있게 한다.""" + """키가 설정돼 있는지.""" return bool(self._client_id and self._client_secret) def _headers(self) -> dict: @@ -383,7 +292,7 @@ class NaverLocalClient: self._client = None async def _get(self, params: dict, kind: str) -> dict: - """공통 GET. 호출 1건마다 누적 횟수를 로그로 남긴다(쿼터 추적).""" + """공통 GET.""" headers = self._headers() # 키 없으면 여기서 NaverNotConfigured _CALL_COUNTS[kind] += 1 LOG.i(f"[naver] {kind} 호출 (누적 {_CALL_COUNTS[kind]}회, 일 25,000회 쿼터) display={params.get('display')}") @@ -410,14 +319,9 @@ class NaverLocalClient: except ValueError as ex: raise NaverRequestFailed(f"네이버 지역검색 {kind} 응답 파싱 실패: {ex}") from ex - # ---- 1) 상호명 → 주소·좌표 ---- + # 1) 상호명 → 주소·좌표 async def search_local(self, query: str, display: int = _MAX_DISPLAY, sort: str = "random") -> list[NaverPlace]: - """지역검색. **동일 업소 검증의 입력**이다. - - ★ display 는 5 가 상한이다. 더 요청해도 조용히 5건으로 잘리므로 여기서 명시적으로 클램프한다 - — '15건 요청했는데 5건만 왔다' 를 장애로 오해하지 않게 하려는 것이다. - - 반환된 목록은 그대로 pick_match 에 넘긴다 — 여기서 하나를 고르지 않는다.""" + """지역검색.""" params = { "query": query, "display": max(1, min(display, _MAX_DISPLAY)), @@ -426,7 +330,7 @@ class NaverLocalClient: data = await self._get(params, "local") return [NaverPlace.from_item(it) for it in (data.get("items") or [])] - # ---- 2) 주변 맛집·시설 ---- + # 2) 주변 맛집·시설 async def search_nearby( self, region_name: str, @@ -434,16 +338,7 @@ class NaverLocalClient: display: int = _MAX_DISPLAY, region_key_hint: Optional[str] = None, ) -> list[NaverPlace]: - """주변 맛집·시설을 찾는다. - - ★ 네이버 지역검색에는 **반경 검색이 없다.** 그래서 좌표가 아니라 "지역명 + 키워드" - (예: "양양군 맛집") 로 찾는다. 카카오의 category 검색과 결과 성격이 다르다 — - 거리순이 아니고, 그 지역 안이라는 것만 보장된다. - - ★ 반드시 지역 캐시를 거쳐 부른다. 같은 지역에 사이트가 50개 생겨도 조회는 1회여야 한다. - 캐싱은 이 클라이언트가 하지 않는다 — 지역 모듈이 area_contents 에 - `region_code + content_type` 키로 저장하고, 캐시 미스일 때만 여기를 부른다. - `region_key_hint` 는 그 캐시 키를 호출 지점에서 명시하게 하려고 받는다(로그에도 남는다).""" + """주변 맛집·시설을 찾는다.""" if not region_key_hint: LOG.w( "[naver] search_nearby 를 지역 키 없이 호출했습니다 — " @@ -453,7 +348,7 @@ class NaverLocalClient: LOG.i(f"[naver] 주변검색 '{region_name} {keyword}' region_key={region_key_hint or '미지정'} → {len(places)}건") return places - # ---- 조합: 상호명 하나로 동일 업소까지 ---- + # 조합: 상호명 하나로 동일 업소까지 async def verify_place( self, name: str, @@ -461,21 +356,7 @@ class NaverLocalClient: *, search_query: Optional[str] = None, ) -> MatchResult: - """상호명(+주소 힌트)으로 검색해서 동일 업소를 판정한다. - - 지역검색 1회만 쓴다. 주소 힌트가 있으면 검색어에도 섞어 후보를 깨끗하게 만든다 - (5건 상한이라 후보 품질이 카카오보다 중요하다). - - ★ **name 에는 상호명만 넣는다.** pick_match 가 후보 상호명과 정확일치를 보는데, - 여기에 지역까지 붙은 검색어('타코튜즈데이 성수 서울 성동구')를 넣으면 후보명 - ('타코튜즈데이 성수 본점')과 정확히 같을 수가 없다 — 판정이 구조적으로 언제나 - AMBIGUOUS(name_no_exact) 로 떨어진다. 실측(2026-08-28) 10건 전부 그랬고, - 후보가 단 1건일 때도 "비슷한 이름의 가게가 여럿입니다" 가 떴다. - 검색은 넓게, 판정은 좁게 — 그래서 검색어를 따로 받는다. - - 결과가 MATCHED 가 아니면 사람이 골라야 한다 — 호출측은 - AMBIGUOUS → ErrorType.PLACE_VERIFY_AMBIGUOUS, - NO_CANDIDATE → ErrorType.PLACE_VERIFY_NO_CANDIDATE 로 응답한다.""" + """상호명(+주소 힌트)으로 검색해서 동일 업소를 판정한다.""" query = search_query or (f"{address_hint} {name}".strip() if address_hint else name) candidates = await self.search_local(query) if not candidates and query != name: diff --git a/solution/backend/services/external/naver_place_lookup.py b/solution/backend/services/external/naver_place_lookup.py index 4ecde97..862607d 100644 --- a/solution/backend/services/external/naver_place_lookup.py +++ b/solution/backend/services/external/naver_place_lookup.py @@ -1,17 +1,4 @@ -"""상호·주소 → 네이버 플레이스 id. - -★ 왜 필요한가 - 채널 URL 발견은 Perplexity 가 맡는데, 네이버 플레이스만은 잘 못 찾는다. - 실측(2026-08-27 '도플로'·'버터브루'): 발견 URL 이 전부 야놀자·인스타였고, 필터를 - map.naver.com 까지 넓힌 뒤에도 네이버 쪽은 `pages.map.naver.com/useful-tips` 같은 - 안내 페이지가 걸렸다. 검색 언어모델에 맡기기엔 결과가 불안정하고 검색 요금도 든다. - - 그런데 우리는 이미 **이 가게가 누구인지 알고 있다**(동일 업소 검증을 통과한 상호·주소). - 그러면 추측할 이유가 없다 — 통합검색 결과에서 상호가 일치하는 place id 를 직접 고른다. - -★ 우회하지 않는다. 공개 검색 결과 페이지를 한 번 받아 id 를 읽을 뿐이고, - 막히면 그대로 빈 값을 돌려준다(호출측이 다른 경로로 간다). -""" +"""상호·주소 → 네이버 플레이스 id.""" import re from html import unescape from typing import Optional @@ -32,42 +19,25 @@ HEADERS = { _PLACE_ID = re.compile(r"(?:place\.naver\.com/[a-z]+/|/place/|\"placeId\"\s*:\s*\"?)(\d{8,12})") # id 주변에서 상호를 찾을 때 훑는 범위. -# -# ★ `"name":"…"` JSON 필드를 뽑아 비교하면 안 된다. 그 자리에 실제로 들어 있는 것은 -# 리뷰 키워드("주차하기 편해요")나 블로거 닉네임이고, 상호는 다른 형태로 박혀 있다. -# 그래서 **정규화한 원문 조각에 상호가 들어 있는지**로 판정한다 — 마크업 모양이 바뀌어도 버틴다. _CONTEXT_BEFORE = 600 _CONTEXT_AFTER = 300 def _normalize(text: str) -> str: - """상호 비교용 정규화. 네이버는 '스테이,머뭄'처럼 구두점을 넣어 표기한다. - - ★ `&` 도 지운다. 검색 결과 원문에는 `&` 로 실려 오기 때문에, 엔티티를 풀어도 - `&` 가 남으면 지역검색이 준 상호(`누에베 풀빌라&리조트`)와 원문 조각의 표기가 - 어긋난다. 실측(2026-08-28): 이 한 글자 때문에 place id 를 못 찾아 사장님이 - 네이버 지도 주소를 손으로 붙여넣어야 했다 — 10건 중 1건. - """ + """상호 비교용 정규화.""" return re.sub(r"[\s,·.\-_'\"()&]", "", (text or "")).lower() def region_hint(address: Optional[str]) -> str: - """주소에서 검색을 좁힐 지역 토막. 시/군/구까지만 쓴다. - - ★ 첫 토막만 쓰면 안 된다 — 그건 광역시·도('경기도')라 오히려 넓어진다. - 실측(2026-09-03 '버터브루'): '버터브루 경기도' 로 찾으면 **다른 동네 동명 업소**의 - id 가 잡히고, 그 id 로 검증하면 남의 가게가 이 사이트의 기준 정보가 된다. - '버터브루 성남시 중원구' 로 좁히면 정확히 잡힌다. - ★ 도로명·번지는 넣지 않는다. 검색 결과가 그 주소를 언급한 블로그로 채워져 id 가 사라진다. - """ + """주소에서 검색을 좁힐 지역 토막.""" tokens = (address or "").split() picked = [t for t in tokens if t.endswith(("시", "군", "구"))] return " ".join(picked[:2]) async def _fetch_search_html(query: str) -> Optional[str]: - """통합검색 결과 페이지 원문. 실패는 None — 호출측이 조용히 폴백한다.""" + """통합검색 결과 페이지 원문.""" try: async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT, follow_redirects=True) as client: res = await client.get(SEARCH_URL, params={"query": query, "where": "m"}, headers=HEADERS) @@ -81,19 +51,12 @@ async def _fetch_search_html(query: str) -> Optional[str]: def _match_in_html(html: str, name: str) -> Optional[str]: - """검색 결과 원문에서 이 상호에 해당하는 place id 를 고른다. - - ★ `"name":"…"` 를 뽑아 비교하지 않는다. 그 자리에 실제로 들어 있는 것은 리뷰 키워드 - ("주차하기 편해요")나 블로거 닉네임이라 상호가 아니다. 그래서 **id 주변 원문을 - 정규화해 상호가 들어 있는지**로 판정한다 — 마크업이 바뀌어도 버틴다. - """ + """검색 결과 원문에서 이 상호에 해당하는 place id 를 고른다.""" target = _normalize(name) if not target: return None for match in _PLACE_ID.finditer(html): - # ★ 엔티티를 먼저 푼다. 원문에는 상호가 `누에베 풀빌라&리조트` 처럼 인코딩돼 있어, - # 그대로 정규화하면 `amp` 라는 없는 글자가 상호 한가운데 남는다. - # 조각(≈900자)에만 적용한다 — 1.3MB 원문 전체를 후보마다 푸는 것은 낭비다. + # 엔티티를 먼저 푼다. window = _normalize( unescape(html[max(0, match.start() - _CONTEXT_BEFORE): match.start() + _CONTEXT_AFTER]) ) @@ -103,14 +66,7 @@ def _match_in_html(html: str, name: str) -> Optional[str]: async def find_place_ids(query: str, names: list[str]) -> dict[str, str]: - """후보 상호들에 대해 {상호: place_id} 를 채운다. 못 찾은 상호는 빠진다. - - ★ 왜 후보 목록에 id 를 실어야 하나: 사장님이 후보를 고르는 순간 네이버 플레이스 id 가 - 확정되면, 나중에 수집 단계에서 상호를 다시 맞춰 볼 필요가 없다. 이름 맞추기는 - 동명 업소·지점명 표기 차이에서 틀리고, 틀리면 남의 가게를 긁는다. - - 검색은 **한 번만** 한다 — 후보 5건에 5번 요청하면 네이버가 막는다(429). - """ + """후보 상호들에 대해 {상호: place_id} 를 채운다.""" html = await _fetch_search_html(query) if not html: return {} @@ -123,11 +79,7 @@ async def find_place_ids(query: str, names: list[str]) -> dict[str, str]: async def find_place_id(name: str, address: Optional[str] = None) -> Optional[str]: - """상호(+주소)로 네이버 플레이스 id 를 찾는다. 확신이 없으면 None. - - ★ 이름이 일치하는 후보만 받는다. '비슷한 것 중 첫 번째'를 고르면 남의 가게를 - 이 가게의 공식 채널로 등록하게 된다 — 이 제품에서 가장 비싼 실수다. - """ + """상호(+주소)로 네이버 플레이스 id 를 찾는다.""" query = " ".join(x for x in (name, region_hint(address)) if x) html = await _fetch_search_html(query) if not html: diff --git a/solution/backend/services/external/open_meteo.py b/solution/backend/services/external/open_meteo.py index ba1340e..6b6cad3 100644 --- a/solution/backend/services/external/open_meteo.py +++ b/solution/backend/services/external/open_meteo.py @@ -1,8 +1,4 @@ -"""Open-Meteo current-weather adapter. - -The adapter only knows the upstream protocol. Cache policy belongs to -LocalContentService so callers cannot accidentally bypass it. -""" +"""Open-Meteo current-weather adapter.""" from datetime import datetime from typing import Any diff --git a/solution/backend/services/external/perplexity.py b/solution/backend/services/external/perplexity.py index 80987dc..05cb39d 100644 --- a/solution/backend/services/external/perplexity.py +++ b/solution/backend/services/external/perplexity.py @@ -1,15 +1,4 @@ -"""채널 URL 발견 — 겹들을 엮어 결과를 만드는 자리. - -이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다: - - 무엇을 묻는가 services/prompts/channel_discovery.py 프롬프트·응답 스키마 - 어떻게 부르는가 services/llm/perplexity.py HTTP·타임아웃·인증 - 답을 믿을 것인가 services/grounding/channels.py 채널 판정·상세 페이지 필터 - 무엇을 돌려주는가 여기 호출 → 필터 → 결과 조립 - -★ Perplexity 응답을 **사실로 쓰지 않는다.** 산출물은 "여기를 보라"는 URL 포인터일 뿐이고, - 사실은 그 URL 을 크롤링해 얻는다. 원문(raw)은 감사·환각 추적용으로 통째로 박제한다. -""" +"""채널 URL 발견 — 겹들을 엮어 결과를 만드는 자리.""" from dataclasses import dataclass, field import httpx @@ -50,15 +39,7 @@ SEARCH_COUNT_WARN_THRESHOLD = 8 @dataclass class ChannelDiscovery: - """채널 발견 결과. - - links : 상세 페이지로 판정돼 살아남은 후보 URL. 동일 업소 검증을 통과해야 크롤링 대상이 된다 - raw : 응답 원문(본문 + search_results). place_channels.raw 에 통째로 박제한다 - search_count : 이번 호출에서 발생한 검색 횟수 — **검색 요금이 토큰 요금과 별도**라 추적한다 - filtered_out : 걸러낸 URL 과 사유 [(url, reason), ...]. - ★ 조용히 버리지 않는다 — 운영자가 "왜 이 URL 이 빠졌나"를 볼 수 있어야 - 필터가 과했는지(진짜 채널을 버렸는지) 판단할 수 있다 - """ + """채널 발견 결과.""" links: list[DiscoveredLink] = field(default_factory=list) raw: dict = field(default_factory=dict) @@ -66,7 +47,7 @@ class ChannelDiscovery: filtered_out: list[tuple[str, str]] = field(default_factory=list) def reason_counts(self) -> dict[str, int]: - """탈락 사유별 건수. 로그·운영 화면에서 쓴다.""" + """탈락 사유별 건수.""" counts: dict[str, int] = {} for _url, reason in self.filtered_out: counts[reason] = counts.get(reason, 0) + 1 @@ -82,22 +63,7 @@ async def discover_channels( include_blogs: bool = False, client: httpx.AsyncClient | None = None, ) -> ChannelDiscovery: - """상호명으로 채널 URL 후보를 찾는다. **URL 발견 전용 — 답변을 사실로 쓰지 마라.** - - 돌려주는 URL 은 아직 '이 가게의 것'이라는 보장이 없다. 동일 업소 검증을 통과해 - 확정(place_channels.confirmed_at)된 URL 만 크롤링 대상이 된다. - - 발견된 URL 중 **상세 페이지가 아닌 것(루트·목록·SEO 랜딩)과 블로그는 걸러낸다.** - 걸러낸 목록은 `filtered_out` 에 사유와 함께 남는다 — 조용히 버리지 않는다. - - args: - include_blogs : 블로그·카페 URL 도 후보로 남길지. **기본 False** — - 블로그는 채널이 아니라 후기라 크롤링해도 공식 정보가 아니다 - - raises: - PerplexityNotConfigured : 키 미설정(이 어댑터만 비활성) - PerplexityError : 타임아웃·5xx·응답 파싱 불가 - """ + """상호명으로 채널 URL 후보를 찾는다.""" if not (name or "").strip(): raise PerplexityError("상호명이 비어 있다") @@ -113,7 +79,7 @@ async def discover_channels( "max_tokens": DEFAULT_MAX_TOKENS, "temperature": 0, # URL 수집이라 창의성이 해롭다 "response_format": RESPONSE_SCHEMA, - "search_domain_filter": SEARCH_DOMAIN_FILTER, # ★ 야놀자·여기어때·네이버로 한정 + "search_domain_filter": SEARCH_DOMAIN_FILTER, # 야놀자·여기어때·네이버로 한정 } payload = await call(body, client=client) @@ -126,7 +92,7 @@ async def discover_channels( links=links, raw=payload, search_count=searches, filtered_out=filtered_out ) - # 내부 검색 횟수는 품질·지연 관측값이다. Sonar 과금은 토큰 + 요청 컨텍스트 요금이다. + # 내부 검색 횟수는 품질·지연 관측값이다. usage = read_usage(payload) reasons = result.reason_counts() reason_text = " ".join(f"{k}{v}" for k, v in sorted(reasons.items())) or "없음" diff --git a/solution/backend/services/external/restaurant_discovery.py b/solution/backend/services/external/restaurant_discovery.py index a3cddcb..6819b11 100644 --- a/solution/backend/services/external/restaurant_discovery.py +++ b/solution/backend/services/external/restaurant_discovery.py @@ -1,10 +1,4 @@ -"""지역명으로 맛집 후보 이름을 찾는다. - -★ services/external/perplexity.py(채널 발견: 단일 업소 → URL)와는 다른 용도다 — - 여기는 "이 지역에 뭐가 있나"를 묻는 지역 목록 검색이다. 원칙은 같다: - Perplexity 응답을 사실로 쓰지 않는다. 이름만 후보로 받고, 실제 값은 이후 - 네이버 크롤링(NaverPlaceAdapter)이 확정한다. -""" +"""지역명으로 맛집 후보 이름을 찾는다.""" import json from common.logger import LOG @@ -33,10 +27,7 @@ def _parse_names(payload: dict) -> list[str]: async def search_region_restaurants( region_label: str, *, model: str = DEFAULT_MODEL, client=None, ) -> list[str]: - """지역명으로 맛집 상위 10곳의 이름만 받는다. - - 실패(미설정·타임아웃·5xx)하면 빈 목록 — 호출측이 TourAPI 결과만으로 계속 진행한다. - """ + """지역명으로 맛집 상위 10곳의 이름만 받는다.""" if not (region_label or "").strip(): return [] body = { diff --git a/solution/backend/services/external/site_ontology.py b/solution/backend/services/external/site_ontology.py index 03ab606..ed92f47 100644 --- a/solution/backend/services/external/site_ontology.py +++ b/solution/backend/services/external/site_ontology.py @@ -1,36 +1,12 @@ -"""SiteOntology(o2o-site-ontology) 클라이언트 — 이 가게에 맞는 검색 키워드를 받아 온다. - -SiteOntology 는 펜션 SEO/AEO 키워드 사전(pgvector)을 들고, 업체 프로필에 맞는 단어를 골라 주는 -사내 서비스다. 여기서는 창구 두 개만 쓴다(실측 2026-09-14, 로컬 :3100). - - POST /v1/merchants/publish {externalId, name, industryId, regionId, description, profile, generate:false} - → 201 {merchant, generation: "skipped"} 업체를 저장만 한다. 키워드는 주지 않는다 - POST /v1/match {query: , limit} - → 201 {resolved, lanes, matches[], byLane[], excluded[]} 저장된 프로필로 추천한다 - -★ 두 번 부르는 이유: "프로필을 주면 키워드를 돌려주는" 창구가 한 번에는 없다. match 는 query 문자열만 - 받고, externalId 를 넣으면 저장된 업체로 해석해 그 프로필로 추천한다. 그래서 먼저 publish 로 프로필을 - 이번 빌드의 스냅샷 값으로 맞춘다. -★ generate:false 를 반드시 보낸다. 빠뜨리면 SiteOntology 가 LLM 키워드 생성을 큐에 넣는다 — - 그 결과는 우리가 쓰지 않는 창구(/seo)로만 나가고, 로컬은 mock LLM 이라 가짜 단어가 사전에 쌓인다. -★ regionId 가 SiteOntology 의 region 표에 없으면 **500** 이다(외래키 위반, 실측). 그 표는 적재한 데이터셋에 - 따라 달라서(군산만 / 전국 54개) 우리가 알 수 없다 — 500 이면 regionId 를 비워 한 번 더 보낸다. - 지역이 비면 유형 추천에서 "군산" 이 빠져 품질이 떨어지지만(실측: 1위가 `독채펜션`), 없는 것보다 낫고 - 지역 없는 단어는 호출측 거르기(seo_keywords)가 제목에서 뺀다. -★ externalId 가 해석되지 않으면 match 는 에러가 아니라 **입력 문자열 자체로 검색한 결과**를 201 로 준다 - (실측: "no-such-place-id" → `나운동 숙소`·`선유도 숙소 예약 언제 해야 하나요`). 그걸 쓰면 남의 동네 단어가 - 나간다 — resolved 가 우리 externalId 가 아니면 실패로 본다. -★ 실패는 발행을 막지 않는다. 여기서는 SiteOntologyError 로 올리고, 호출측이 잡아 키워드 없이 굽는다. -""" +"""SiteOntology(o2o-site-ontology) 클라이언트 — 이 가게에 맞는 검색 키워드를 받아 온다.""" import httpx from common.logger import LOG from config.server_configs import external_api_config -# 로컬 임베딩 모델이라 호출당 1~2초다. 넉넉히 잡되 발행 잡 데드라인(900s)을 잡아먹지 않게 끊는다. +# 로컬 임베딩 모델이라 호출당 1~2초다. TIMEOUT_SEC = 15.0 -# 거르기(seo_keywords)에서 절반 넘게 떨어진다(실측: 10건 → 4건). 메타 태그 10개를 채우려면 넉넉히 받는다. DEFAULT_LIMIT = 40 PUBLISH_PATH = "/v1/merchants/publish" @@ -38,7 +14,7 @@ MATCH_PATH = "/v1/match" class SiteOntologyError(RuntimeError): - """SiteOntology 호출 실패(네트워크·타임아웃·4xx/5xx·업체 미해석). 발행은 계속된다.""" + """SiteOntology 호출 실패(네트워크·타임아웃·4xx/5xx·업체 미해석).""" def base_url() -> str: @@ -63,9 +39,7 @@ async def _post(client: httpx.AsyncClient, path: str, body: dict) -> dict: async def match_for_merchant(merchant: dict, limit: int = DEFAULT_LIMIT) -> dict: - """업체를 저장하고, 그 업체로 해석된 추천 결과(/v1/match 응답)를 돌려준다. - - merchant 는 publish 요청 본문이다(generate 는 여기서 붙인다). 실패하면 SiteOntologyError.""" + """업체를 저장하고, 그 업체로 해석된 추천 결과(/v1/match 응답)를 돌려준다.""" body = {**merchant, "generate": False} try: async with httpx.AsyncClient(timeout=TIMEOUT_SEC) as client: diff --git a/solution/backend/services/external/social.py b/solution/backend/services/external/social.py index 696df17..16c38bf 100644 --- a/solution/backend/services/external/social.py +++ b/solution/backend/services/external/social.py @@ -1,4 +1,4 @@ -"""플랫폼 교체 경계. 전송 결과 불명은 재시도 가능한 실패와 절대 섞지 않는다.""" +"""플랫폼 교체 경계.""" import re import unicodedata @@ -22,7 +22,7 @@ def weighted_length(text: str, provider: int = 1) -> int: if provider == 2: return len(text) - # 보수적으로 이모지 조합의 각 코드포인트도 센다. 공식 가중치보다 작게 세지 않는다. + # 보수적으로 이모지 조합의 각 코드포인트도 센다. def weight(c): n = ord(c) return ( diff --git a/solution/backend/services/external/suno.py b/solution/backend/services/external/suno.py index b646836..9ae15b5 100644 --- a/solution/backend/services/external/suno.py +++ b/solution/backend/services/external/suno.py @@ -1,24 +1,4 @@ -"""Suno API — 가사를 받아 40초짜리 곡 한 편을 만든다. - - API 문서 https://docs.sunoapi.org - 가사 services/external/gemini_text.generate_song (여기는 작곡만 한다) - 쓰는 곳 services/song_service (잡 흐름·저장) - -★ **콜백을 쓰지 않고 폴링한다.** - Suno 는 완료 시 `callBackUrl` 로 POST 를 보내 주는데, 그러려면 Suno 쪽에서 우리 백엔드에 - 닿아야 한다. 이 서버는 로컬(:9800)이거나 사내망(킹서버)이라 그런 주소가 없다 — - 콜백을 믿게 만들어 두면 "요청은 성공했는데 결과가 영영 안 옴" 이 되고, 그건 화면상 - 아무 일도 안 일어나는 종류의 실패다. 그래서 `generate` 로 taskId 를 받고 `record-info` 를 - 직접 물어본다. 잡 워커에서 도는 코드라 몇 분 기다리는 것이 문제가 되지 않는다. - (`callBackUrl` 은 API 가 필수로 요구해서 값만 채워 보낸다. 우리는 그 주소를 듣지 않는다.) - -★ **한 요청에 곡이 두 편 온다.** Suno 는 같은 가사로 변주 두 개를 만들어 준다(sunoData 배열). - 우리는 **첫 번째 한 곡만** 쓴다 — 사장님에게 고르라고 묻는 화면이 없고, 두 곡을 다 실으면 - 손님이 무엇을 듣는지 우리도 모른다. - -★ **오디오 주소는 만료된다.** 여기서 돌려주는 `audio_url` 을 그대로 사이트에 싣지 않는다. - 받는 쪽(song_service)이 파일을 내려받아 우리 쪽에 보관한다. -""" +"""Suno API — 가사를 받아 40초짜리 곡 한 편을 만든다.""" import asyncio from typing import Any, Optional @@ -29,15 +9,12 @@ from config.server_configs import external_api_config BASE_URL = "https://api.sunoapi.org/api/v1" -# 실측(참고 프로젝트 o2o-castad-backend): 스트림 주소는 30~40초, 내려받을 수 있는 주소는 2~3분. -# 우리는 파일을 받아야 하므로 뒤쪽 기준으로 기다린다. POLL_INTERVAL_SEC = 10 POLL_TIMEOUT_SEC = 300 REQUEST_TIMEOUT = httpx.Timeout(60.0, connect=10.0) -# 40초짜리를 만든다. 헤더의 작은 플레이어에서 듣는 곡이라 길 이유가 없고, -# 길수록 생성 시간과 요금이 같이 는다. +# 40초짜리를 만든다. SONG_SECONDS = 40 MODEL = "V5" @@ -47,7 +24,7 @@ class SunoNotConfigured(RuntimeError): class SunoError(RuntimeError): - """호출 실패·거절. 잡의 last_error 로 남는다.""" + """호출 실패·거절.""" def is_configured() -> bool: @@ -62,12 +39,7 @@ def _headers() -> dict: async def generate(lyrics: str, *, title: str, style: str, client: httpx.AsyncClient) -> str: - """작곡 요청. taskId 를 돌려준다. - - ★ `customMode=True` 다 — prompt 를 '주제' 가 아니라 **가사 그대로** 쓰라는 뜻이다. - false 로 두면 Suno 가 가사를 자기가 새로 쓴다. 우리는 이 숙소의 사실로 쓴 가사를 - 넘기는 것이므로, 그걸 버리면 이 기능의 의미가 없다. - """ + """작곡 요청.""" if not is_configured(): raise SunoNotConfigured("SUNO_API_KEY 미설정") @@ -79,7 +51,7 @@ async def generate(lyrics: str, *, title: str, style: str, client: httpx.AsyncCl "prompt": f"[Song Duration: Around {SONG_SECONDS} seconds]\n{lyrics}", "title": title[:80], "style": style, - # 듣지 않는 주소다(머리주석). 비워서 보내면 거절당한다. + # 듣지 않는 주소다(머리주석). "callBackUrl": external_api_config.suno_callback_url or "https://example.com/api/suno/callback", } @@ -102,11 +74,7 @@ async def generate(lyrics: str, *, title: str, style: str, client: httpx.AsyncCl def _first_clip(payload: dict) -> Optional[dict]: - """완성된 클립 하나. 아직이면 None. - - ★ 상태 문자열을 믿기 전에 **주소가 실제로 있는지** 본다. SUCCESS 인데 audioUrl 이 - 아직 비어 오는 응답을 참고 프로젝트가 겪었다(스트림만 먼저 나오는 구간). - """ + """완성된 클립 하나.""" data = (payload or {}).get("data") or {} status = (data.get("status") or "").upper() if status in {"CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED", "CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR"}: @@ -120,11 +88,7 @@ def _first_clip(payload: dict) -> Optional[dict]: async def wait_for_clip(task_id: str, *, client: httpx.AsyncClient) -> dict[str, Any]: - """완성될 때까지 물어본다. 돌려주는 것은 첫 클립 하나. - - ★ 상한(POLL_TIMEOUT_SEC)을 둔다. Suno 가 영영 안 끝내는 경우 잡이 그대로 매달리면 - 워커 한 자리를 계속 차지한다 — 노래 하나 때문에 다른 사업장의 수집이 멈춘다. - """ + """완성될 때까지 물어본다.""" waited = 0 while waited < POLL_TIMEOUT_SEC: await asyncio.sleep(POLL_INTERVAL_SEC) @@ -152,7 +116,7 @@ async def wait_for_clip(task_id: str, *, client: httpx.AsyncClient) -> dict[str, async def download(url: str, *, client: httpx.AsyncClient) -> bytes: - """오디오 파일을 받아 온다. 보관은 부르는 쪽이 한다.""" + """오디오 파일을 받아 온다.""" res = await client.get(url, timeout=httpx.Timeout(180.0, connect=10.0), follow_redirects=True) res.raise_for_status() return res.content diff --git a/solution/backend/services/external/threads.py b/solution/backend/services/external/threads.py index cef1a21..37b684c 100644 --- a/solution/backend/services/external/threads.py +++ b/solution/backend/services/external/threads.py @@ -1,4 +1,4 @@ -"""Meta 공식 Threads API. 장기 액세스 토큰을 갱신하며 X의 refresh-token 계약을 요구하지 않는다.""" +"""Meta 공식 Threads API.""" from config import social_config as config from urllib.parse import urlencode, urlparse @@ -8,10 +8,7 @@ import httpx from services.external.social import SocialError, SocialOutcomeUnknown, weighted_length BASE = "https://graph.threads.net/v1.0" -# ★ OAuth 토큰 엔드포인트는 **버전 접두어가 없고 토큰을 쿼리 파라미터로 받는다.** -# 데이터 엔드포인트(/me, /me/threads)와 규칙이 다르다 — 거기서만 Bearer 헤더가 통한다. -# 실측(2026-09-18): 장기 토큰 교환을 `/v1.0/access_token` + Bearer 로 부르자 -# `[4279019] Session key invalid` 로 거절당해 연결이 매번 실패했다. +# OAuth 토큰 엔드포인트는 **버전 접두어가 없고 토큰을 쿼리 파라미터로 받는다.** 데이터 엔드포인트(/me, /me/threads)와 규칙이 다르다 — 거기서만 Bearer 헤더가 통한다. OAUTH_BASE = "https://graph.threads.net" SCOPES = {"threads_basic", "threads_content_publish"} @@ -28,7 +25,7 @@ def weighted_limit(): def authorize_url(state, verifier): - # Threads는 서버측 코드 교환이다. X 전용 PKCE 파라미터를 전송하지 않는다. + # Threads는 서버측 코드 교환이다. return "https://threads.net/oauth/authorize?" + urlencode( dict( client_id=config.required("THREADS_APP_ID"), @@ -47,12 +44,7 @@ def _read(res): raise SocialError("THREADS_INVALID_RESPONSE") from ex if res.status_code >= 400 or data.get("error"): error = data.get("error") or {} - # ★ 플랫폼이 준 사유를 코드에 붙인다. `THREADS_REJECTED_400` 만으로는 무엇이 틀렸는지 - # 알 수 없어서 — 코드가 만료됐는지, 리디렉션 URI 가 안 맞는지, 권한이 모자란지 — - # 실제로 원인을 좁히지 못했다(실측 2026-09-18: 연결 실패가 400 이라는 것만 알고 - # Meta 가 뭐라고 했는지는 어디에도 안 남아 세 번을 헛짚었다). - # ★ 남기는 것은 message·subcode 뿐이다. 토큰·시크릿·code 는 담지 않는다 — - # 이 문자열은 로그로 가고, 로그는 우리가 아닌 사람도 본다. + # 플랫폼이 준 사유를 코드에 붙인다. detail = str(error.get("message") or "")[:160] subcode = error.get("error_subcode") or error.get("code") raise SocialError( @@ -97,13 +89,7 @@ async def exchange(code, verifier, *, client): }, ) )["data"] - # ★ `app_id` 로 우리 앱 토큰인지 대조하던 줄을 뺐다. Threads 의 debug_token 응답에는 - # 그 필드가 **없다**(실측 2026-09-18: is_valid·scopes·type·user_id·application 뿐). - # 없는 값을 `str(None)` 과 비교하니 **항상 불일치**였다 — 장기 토큰을 제대로 받아도 - # 바로 다음 줄에서 THREADS_SCOPES_REQUIRED 로 떨어지는, 통과할 수 없는 검사였다. - # ★ 그렇다고 검사를 통째로 버리지 않는다: 응답에 app_id 가 있으면(다른 플랫폼·향후 변경) - # 그때는 대조한다. 이 토큰은 우리 client_secret 으로 우리가 교환해 받은 것이라 - # 남의 앱 토큰이 섞일 경로가 이 함수 안에는 없다. + # 그렇다고 검사를 통째로 버리지 않는다: 응답에 app_id 가 있으면(다른 플랫폼·향후 변경) 그때는 대조한다. app_id = debug.get("app_id") if ( not debug.get("is_valid") @@ -118,11 +104,7 @@ async def exchange(code, verifier, *, client): async def refresh(token, *, client): - # ★ 갱신도 OAuth 엔드포인트다 — 버전 접두어 없이, 토큰은 쿼리 파라미터로. - # Bearer 헤더로 보내면 플랫폼이 **헤더를 읽지 않고** `The parameter access_token is - # required.` 로 거절한다(실측 2026-09-18, 같은 토큰으로 두 형식 대조). - # ★ 이건 연결 당시에는 안 드러나고 **60일 뒤 갱신에서** 터지는 종류다 — - # 그때는 사장님 계정이 조용히 만료돼 게재만 멈춘다. + # 갱신도 OAuth 엔드포인트다 — 버전 접두어 없이, 토큰은 쿼리 파라미터로. result = _read( await client.get( f"{OAUTH_BASE}/refresh_access_token", @@ -154,7 +136,7 @@ async def publish(text, token, *, client): if weighted_length(text, 2) > weighted_limit(): raise SocialError("TEXT_TOO_LONG") headers = {"Authorization": f"Bearer {token}"} - # 컨테이너 생성은 아직 게시가 아니다. auto_publish_text를 켜면 이 구분이 사라진다. + # 컨테이너 생성은 아직 게시가 아니다. try: container = _read( await client.post( @@ -201,6 +183,5 @@ async def publish(text, token, *, client): ): raise ValueError() except (httpx.TransportError, SocialError, ValueError, KeyError, TypeError): - # 게시 ID는 확보했다. 링크 조회 실패를 게시 실패로 취급하면 사장님이 다시 올린다. permalink = None return {"id": post_id, "permalink": permalink} diff --git a/solution/backend/services/external/tour_api.py b/solution/backend/services/external/tour_api.py index 4a4a448..4ca1ecc 100644 --- a/solution/backend/services/external/tour_api.py +++ b/solution/backend/services/external/tour_api.py @@ -1,35 +1,4 @@ -"""한국관광공사 TourAPI(KorService2) — **업장 반경** 주변정보 수집 클라이언트. - -collector/tour_api_adapter.py 가 '사업장 1곳'의 fact 를 캐는 쪽이라면, 여기는 -업장 좌표 반경 안의 곁들이 정보(맛집·관광지·축제·여행코스)를 긁는 쪽이다. -결과는 place_contents(업장 단위 캐시)에 들어가 발행본·캔버스의 지역 정보 섹션이 된다. - -★ 왜 행정구역이 아니라 좌표인가 (2026-09-04, specs/2026-09-04-tourapi-radius-spike.md) - 처음엔 법정동 코드로 areaBasedList2 를 불렀다. 그건 '그 시군구에 있는 것'이지 - '이 업장에서 가까운 것'이 아니다 — 양양군 업장 옆 5km 속초 관광지가 빠지고, - 같은 시군구 반대편 30km 맛집이 붙는다. locationBasedList2 는 좌표+반경으로 묻고 - 거리(dist)까지 준다. 실측(군산 절골길 18): 10km 안 133건, 5km 안 100건. - -★ 호출 수 — 업장당 종류별 1회 (맛집·관광지·축제) - 2026-09-08 부터 종류마다 **따로** 부른다(맛집 5km · 관광지 10km · 축제는 시도 전체, 반경 없음) — - 한 걸음에 갈 맛집과 차 타고 갈 축제를 같은 반경으로 재는 게 맞지 않았다. - 여행코스(25)는 실측 결과 반경을 넓혀도 데이터가 거의 없어(전북 전체 3건) 뺐다. - -★ 축제는 locationBasedList2 가 아니라 searchFestival2 를 쓴다 (2026-09-08 교체) - locationBasedList2(contentTypeId=15) 의 위치 색인은 못 믿는다 — 실측(군산 절골길 18): - 반경 20km 를 아무리 넓혀도 2023년에 끝난 서천 전시 1건만 나오고, 코앞 500m 의 - 진행 예정 축제(군산시간여행축제 등)는 끝내 안 잡혔다. searchFestival2 는 법정동(시도) - 단위로 묻지만 정확하고 기간까지 함께 준다 — 그래서 시도 전체를 받아 우리가 거리로 거른다. - eventStartDate 는 파라미터로 준 날짜 **이후 시작하는** 행사만 거른다(이전에 시작해 아직 - 진행 중인 행사는 잡히지 않는다 — 실측). 그래서 항상 **그 해 1월 1일**로 고정해 부른다. - 종료된 행사도 그대로 싣는다(2026-09-17 결정) — 시작일을 2020년으로 당겨 실측해도 API 자체가 - 옛 행사를 추가로 주지 않아(최근~예정 위주) 여기서 더 거를 실익이 없고, 이미 끝난 축제를 - 보여줄지는 노출 단계(local_content_service)의 몫으로 넘긴다. - -★ 이미지 저작권 — 수집 단계에서 끝낸다 (collector/tour_api_adapter.py 와 같은 규칙) - firstimage 는 공공누리 Type1(출처표시)·Type3(출처표시+변경금지)만 남긴다. - 발행본은 상업적 이용이라 Type2·Type4 는 싣지 못한다. 유형을 모르면 버린다. -""" +"""한국관광공사 TourAPI(KorService2) — **업장 반경** 주변정보 수집 클라이언트.""" from datetime import date from typing import Optional from urllib.parse import unquote, urlencode @@ -43,11 +12,9 @@ from config.server_configs import external_api_config BASE_URL = "https://apis.data.go.kr/B551011/KorService2" REQUEST_TIMEOUT = 25 PAGE_SIZE = 100 -# 반경 10km 도심은 300건을 넘지 않는다(실측 133건). 그 이상은 어차피 종류별 20건 상한에 안 든다. MAX_PAGES = 4 -# TourAPI contentTypeId ↔ 우리 종류 코드(locationBasedList2 용). 축제(15)는 여기 없다 — -# searchFestival2 로 따로 받는다(위 모듈 docstring 참고). 여행코스(25)도 뺐다(데이터 부족). +# TourAPI contentTypeId ↔ 우리 종류 코드(locationBasedList2 용). CONTENT_TYPE_MAP = { "39": LocalContentType.RESTAURANT.value, "12": LocalContentType.ATTRACTION.value, @@ -76,11 +43,7 @@ def _service_key() -> str: async def _call(client: httpx.AsyncClient, op: str, **params) -> tuple[list[dict], int]: - """오퍼레이션 1회 → (항목, totalCount). 결과 없음은 빈 목록 — 없는 것과 실패를 구분한다. - - ★ 포털의 'Encoding' 키를 그대로 넣어도 이중 인코딩되지 않도록 원문으로 되돌린다 - (collector/tour_api_adapter._call 과 같은 처리). - """ + """오퍼레이션 1회 → (항목, totalCount).""" query = urlencode( {"serviceKey": unquote(_service_key()), "MobileOS": "ETC", "MobileApp": "o2o-web4ai", "_type": "json", "numOfRows": str(PAGE_SIZE), "pageNo": "1", **params}, @@ -92,10 +55,10 @@ async def _call(client: httpx.AsyncClient, op: str, **params) -> tuple[list[dict try: payload = res.json() except ValueError: - # 인증 실패·쿼터 초과는 XML 로 온다. 본문 앞부분을 그대로 올려 원인을 감추지 않는다. + # 인증 실패·쿼터 초과는 XML 로 온다. raise TourApiRequestFailed(f"{op} 응답이 JSON 이 아니다: {res.text[:160]}") - # 게이트웨이 오류(미등록 키 등)는 200 + JSON 이지만 response 가 없다. 그것도 원인을 드러낸다. + # 게이트웨이 오류(미등록 키 등)는 200 + JSON 이지만 response 가 없다. if "response" not in payload: raise TourApiRequestFailed(f"{op} 게이트웨이 오류: {str(payload)[:160]}") header = payload["response"].get("header", {}) @@ -121,15 +84,7 @@ def _int(value) -> Optional[int]: def _normalize(item: dict) -> Optional[dict]: - """locationBasedList2 항목 1건 → 정규화 dict. contentid·title·거리·종류 없으면 버린다. - - ★ 여기서 **렌더러가 읽는 이름**으로 바꾼다(`name`·`location`·`imageUrl`). 예전에는 TourAPI - 원문 이름(`title`·`addr1`·`firstimage`)을 그대로 저장하고 빌드마다 바꿔 실었다 — - 같은 변환을 발행할 때마다 다시 하는 셈이었고, 캔버스와 발행본이 각자 바꾸면 갈릴 자리였다. - ★ 저장 자리가 갈리는 값은 여기서 **평평하게** 내보내기만 한다. 어느 컬럼·어느 테이블로 - 가는지는 부르는 쪽(local_content_service.sync_place)이 정한다: - distance_m → 사이트 개인화(site_sections) 좌표 → area_contents 컬럼 - """ + """locationBasedList2 항목 1건 → 정규화 dict.""" content_id = str(item.get("contentid") or "").strip() title = str(item.get("title") or "").strip() kind = CONTENT_TYPE_MAP.get(str(item.get("contenttypeid") or "").strip()) @@ -139,23 +94,23 @@ def _normalize(item: dict) -> Optional[dict]: out = { "contentid": content_id, "content_type": kind, "distance_m": distance, - # 렌더러 계약(LocalPlace). searchQuery 는 이름 그대로다 — 우리가 URL 을 지어내지 않는다. + # 렌더러 계약(LocalPlace). "name": title, "searchQuery": title, } address = str(item.get("addr1") or "").strip() if address: out["location"] = address - # 좌표는 컬럼으로 간다. mapX=경도 · mapY=위도 (뒤집으면 엉뚱한 지역이 붙는다). + # 좌표는 컬럼으로 간다. for src, dst in (("mapx", "longitude"), ("mapy", "latitude")): value = str(item.get(src) or "").strip() if value: out[dst] = value - # ★ 중분류만 남긴다. 렌더러는 안 쓰지만 서버가 주변 맛집에서 같은 업태(경쟁 업소)를 뺄 때 쓴다. + # 중분류만 남긴다. cls = str(item.get("lclsSystm2") or "").strip() if cls: out["lclsSystm2"] = cls - # 사진은 상업적 이용이 허용된 공공누리 유형일 때만 싣는다. 유형을 모르면 버린다. + # 사진은 상업적 이용이 허용된 공공누리 유형일 때만 싣는다. image = str(item.get("firstimage") or "").strip() license_code = str(item.get("cpyrhtDivCd") or "").strip().lower() if image and license_code in _COMMERCIAL_OK_LICENSES: @@ -170,20 +125,7 @@ def make_client() -> httpx.AsyncClient: async def find_image(client: httpx.AsyncClient, keyword: str, region_token: str) -> Optional[str]: - """이름으로 공공데이터 사진 한 장. 없으면 None. - - 지역 이야기(연표·엽서)의 항목에 사진을 붙이는 자리다. 이야기는 검색모델이 쓰지만 - **사진은 모델에게 묻지 않는다** — 모델이 준 이미지 주소는 대개 존재하지 않거나 - 남의 저작물이다. 공공데이터가 그 장소의 사진으로 준 것만 쓴다. - - ★ 권리는 여기서 끝낸다. 이 파일의 규칙 그대로 공공누리 Type1·Type3 만 남긴다 - (머리주석). 발행본은 상업적 이용이라 Type2·Type4 는 못 싣고, 유형을 모르면 버린다. - ★ 지역을 대조한다. `searchKeyword2` 는 전국에서 이름만 맞으면 주므로, 주소에 지역 - 토막이 없는 결과는 버린다 — '군산항' 을 찾다가 다른 지역 동명 시설 사진이 붙으면 - 그 사진은 이 지역 이야기와 아무 관계가 없다. - ★ 실패는 None 이다. 사진이 없으면 렌더러가 활자만으로 세운다(설계된 폴백) — - 사진 한 장 때문에 이야기 생성을 실패시키지 않는다. - """ + """이름으로 공공데이터 사진 한 장.""" word = (keyword or "").strip() token = (region_token or "").strip() if not word: @@ -208,10 +150,7 @@ async def find_image(client: httpx.AsyncClient, keyword: str, region_token: str) async def fetch_nearby(client: httpx.AsyncClient, latitude: float, longitude: float, *, radius_m: int, content_type_id: str) -> list[dict]: - """업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다. - - ★ mapX=경도 · mapY=위도. 뒤집으면 엉뚱한 지역이 붙는다(카카오와 같은 함정). - """ + """업장 좌표 반경 안의 한 종류(정규화, 거리순).""" out: list[dict] = [] seen: set[str] = set() for page in range(1, MAX_PAGES + 1): @@ -232,14 +171,7 @@ async def fetch_nearby(client: httpx.AsyncClient, latitude: float, longitude: fl def _normalize_festival(item: dict, distance_m: int) -> Optional[dict]: - """searchFestival2 항목 1건 → 정규화 dict. locationBasedList2 와 달리 `dist` 를 안 주므로 - (호출측이 haversine 으로 잰 값을) 그대로 받는다. - - ★ `_normalize` 와 같은 규약이다 — 렌더러 이름으로 바꿔 내보내고, 저장 자리는 부르는 쪽이 정한다. - ★ 기간(eventstartdate/enddate)은 **원값 그대로** 남긴다. 화면 문자열("2026.10.01 ~ …")로 미리 - 구워 두면 정렬·계절 산출(site_payload._festival)이 읽을 값이 없어진다. - 날짜는 사실이고 문장은 표기다 — 사실만 저장한다. - """ + """searchFestival2 항목 1건 → 정규화 dict.""" content_id = str(item.get("contentid") or "").strip() title = str(item.get("title") or "").strip() if not content_id or not title: @@ -270,19 +202,7 @@ def _normalize_festival(item: dict, distance_m: int) -> Optional[dict]: async def fetch_festivals_in_sido(client: httpx.AsyncClient, latitude: float, longitude: float, *, sido_code: str, today: date) -> list[dict]: - """업장이 속한 시도의 축제 **전부**(정규화, 거리순, 종료 여부와 무관하게 전부). 반경으로 자르지 않는다. - - ★ 반경을 안 두는 이유(2026-09-08 결정): 축제는 차로 가는 행사라 20km 로 자르면 시도 안의 - 큰 축제가 빠진다. 시도 전체를 그대로 싣고, 거리는 정렬·표시용으로만 잰다. - (종류별 노출 상한은 스냅샷이 20건으로 자른다 — 사진 있는 것 우선 → 가까운 순.) - ★ locationBasedList2 의 위치 색인은 못 믿어서 searchFestival2 를 쓴다(위 모듈 docstring). - eventStartDate 는 그 해 1월 1일로 **고정** — "오늘" 을 넣으면 그 이전에 시작해 아직 진행 중인 - 축제가 파라미터 자체에서 빠진다(실측). 연초부터 전부 받는다. - ★ 종료된 축제도 거르지 않고 그대로 싣는다(2026-09-17 결정) — 시작일을 2020년으로 당겨 실측해도 - API 가 옛 행사를 추가로 주지 않아 더 거를 실익이 없었고, 실제로 보여줄지는 노출 단계 - (local_content_service.py — area_contents.display_end_at)가 정한다. - ★ 좌표 없는 항목은 뺀다 — distance_m 이 NOT NULL 이고, 거리 없는 카드는 도보 필터에 못 얹는다. - """ + """업장이 속한 시도의 축제 **전부**(정규화, 거리순, 종료 여부와 무관하게 전부).""" start_date = date(today.year, 1, 1).strftime("%Y%m%d") out: list[dict] = [] seen: set[str] = set() @@ -309,7 +229,7 @@ async def fetch_festivals_in_sido(client: httpx.AsyncClient, latitude: float, lo def _mapxy(item: dict) -> Optional[tuple[float, float]]: - """(경도, 위도). 좌표가 없거나 숫자가 아니면 None — 거리를 잴 수 없는 항목은 반경으로 못 거른다.""" + """(경도, 위도).""" try: return float(item.get("mapx")), float(item.get("mapy")) except (TypeError, ValueError): @@ -317,11 +237,7 @@ def _mapxy(item: dict) -> Optional[tuple[float, float]]: async def fetch_content_class(client: httpx.AsyncClient, content_id: str) -> Optional[str]: - """콘텐츠 1건의 중분류 코드(lclsSystm2). 못 구하면 None. - - 업장 자신이 TourAPI 에 등록돼 있을 때(place_channels 의 tour:// 링크) 그 업장의 업태를 여기서 읽는다 — - 주변 맛집에서 같은 중분류를 빼기 위해서다. 외부 분류 문자열 매핑보다 이 값이 우선이다(같은 체계라 오차가 없다). - """ + """콘텐츠 1건의 중분류 코드(lclsSystm2).""" items, _ = await _call(client, "detailCommon2", contentId=content_id) if not items: return None diff --git a/solution/backend/services/external/tour_lookup.py b/solution/backend/services/external/tour_lookup.py index 91884a5..e40ea28 100644 --- a/solution/backend/services/external/tour_lookup.py +++ b/solution/backend/services/external/tour_lookup.py @@ -1,21 +1,4 @@ -"""TourAPI 콘텐츠 조회 — 검증된 상호·좌표로 contentId 를 직접 해석한다. - -★ 왜 Perplexity 에 맡기지 않나 (services/collect_service.discover_naver_place 와 같은 이유) - 우리는 이미 **이 가게가 누구인지 안다** — 동일 업소 검증을 통과한 상호와 좌표가 있다. - 추측할 이유가 없다. 게다가 채널 발견 프롬프트는 야놀자·여기어때·네이버만 찾으므로 - TourAPI URL 은 애초에 그 경로로 들어올 수 없다. - -★ 남의 가게를 붙이지 않는 것이 여기서 가장 비싼 실수다 - 상호만 비슷한 다른 업소를 공식 채널로 등록하면, 그 집 객실 요금이 우리 사장님 사이트에 실린다. - 그래서 두 관문을 **모두** 통과해야 등록한다: - ① 정규화한 상호가 일치(포함 관계 허용 — "롯데호텔 월드" ↔ "롯데호텔월드") - ② 좌표 거리가 MAX_DISTANCE_M 이내(좌표를 모르면 이 관문은 건너뛴다) - 하나라도 어긋나면 None 을 돌려준다. 자동 등록하지 않는다. - -★ 업종 매핑 - TourAPI 는 카페를 별도 타입으로 두지 않는다 — 음식점(39)의 소분류(cat3=A05020900)다. - 그래서 카페·음식점은 같은 contentTypeId 로 조회하고, 어느 쪽인지는 우리 업종 코드가 정한다. -""" +"""TourAPI 콘텐츠 조회 — 검증된 상호·좌표로 contentId 를 직접 해석한다.""" import math import re from typing import Optional @@ -26,45 +9,33 @@ import httpx from common.enums import PlaceCategory from common.logger import LOG from config.server_configs import external_api_config -# ★ 출처 주소 규칙은 어댑터 한 곳에서만 정의한다. 두 곳이 다른 URL 을 만들면 -# 같은 레코드가 다른 출처로 기록돼 재수집 때 중복 fact 가 생긴다. +# 출처 주소 규칙은 어댑터 한 곳에서만 정의한다. from services.collector.tour_api_adapter import SOURCE_URL BASE_URL = "https://apis.data.go.kr/B551011/KorService2" REQUEST_TIMEOUT = 20 -# 우리 업종 → TourAPI contentTypeId. 관광체험은 타입이 여러 개로 갈려(12·14·28) 단정할 수 없으므로 뺀다. +# 우리 업종 → TourAPI contentTypeId. CATEGORY_TO_CONTENT_TYPE = { PlaceCategory.LODGING: "32", PlaceCategory.CAFE: "39", PlaceCategory.RESTAURANT: "39", } -# 같은 업소로 볼 좌표 거리 상한. 대형 호텔은 등록 좌표가 정문/로비로 갈려 수백 m 벌어진다 — -# 너무 좁히면 맞는 업소를 놓치고, 너무 넓히면 옆 건물 가게가 붙는다. +# 같은 업소로 볼 좌표 거리 상한. MAX_DISTANCE_M = 500 -# 상호를 짧혀가며 다시 물어보는 최대 횟수. ★ 무한정 짧히면 "그래비티" 같은 한 토큰까지 가서 -# 전혀 다른 업소가 후보로 올라온다 — 게이트가 막아주긴 하지만 호출만 낭비된다. +# 상호를 짧혀가며 다시 물어보는 최대 횟수. MAX_QUERY_ATTEMPTS = 4 def normalize(text: str) -> str: - """상호 대조용 정규화. 공백·기호를 걷어내고 소문자로. - - ★ naver_place_lookup._normalize 와 같은 규칙을 쓴다 — 두 조회가 다른 기준으로 - '일치' 를 판정하면 한쪽만 붙는 업소가 생긴다. - """ + """상호 대조용 정규화.""" return re.sub(r"[\s,·.\-_'\"()&]", "", (text or "")).lower() def _name_matches(query_name: str, candidate: str) -> bool: - """정규화 후 한쪽이 다른 쪽을 포함하면 같은 업소로 본다. - - TourAPI 표기가 우리 상호보다 길거나 짧은 경우가 흔하다 - (실측: '가재와곰' ↔ '가재와곰펜션', '롯데호텔 월드' ↔ '롯데호텔월드'). - ★ 다만 너무 짧은 상호는 포함 판정이 헐거워지므로 2자 이하면 완전 일치만 인정한다. - """ + """정규화 후 한쪽이 다른 쪽을 포함하면 같은 업소로 본다.""" a, b = normalize(query_name), normalize(candidate) if not a or not b: return False @@ -74,7 +45,7 @@ def _name_matches(query_name: str, candidate: str) -> bool: def _distance_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float: - """두 좌표의 거리(m). 국내 범위라 하버사인이면 충분하다.""" + """두 좌표의 거리(m).""" r = 6_371_000 p1, p2 = math.radians(lat1), math.radians(lat2) dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1) @@ -87,7 +58,7 @@ def is_configured() -> bool: def content_url(content_id: str) -> str: - """이 레코드를 가리키는 출처 주소(인증키 없음). 어댑터의 SOURCE_URL 과 같은 규칙이다.""" + """이 레코드를 가리키는 출처 주소(인증키 없음).""" return SOURCE_URL.format(content_id=content_id) @@ -117,19 +88,7 @@ async def _search(client: httpx.AsyncClient, key: str, **params) -> list[dict]: def _query_candidates(name: str) -> list[str]: - """검색어 후보 — 전체 상호부터 시작해 **뒤 토큰을 하나씩 떼며** 짧게 만든다. - - ★ 왜 필요한가 (2026-08-31 실측) - searchKeyword2 는 토큰을 AND 로 묶는 것처럼 동작한다. 그래서 우리 상호가 등록명보다 - 길면 **0건**이 나온다: - "그래비티 조선 서울 판교 오토그래프 컬렉션" → 0건 - "그래비티 조선" → 1건 (등록명 '그래비티 조선 서울 판교') - 브랜드 수식어(오토그래프 컬렉션·컬렉션 바이 …)가 뒤에 붙는 호텔에서 늘 생기는 문제라 - 한 번 실패하고 마는 대신 짧혀가며 다시 묻는다. - - ★ 짧아질수록 남의 가게가 걸릴 위험이 커지지만, 최종 판정은 여전히 - 상호 일치 + 좌표 게이트가 한다. 여기서는 후보를 넓히기만 한다. - """ + """검색어 후보 — 전체 상호부터 시작해 **뒤 토큰을 하나씩 떼며** 짧게 만든다.""" tokens = [t for t in re.split(r"\s+", (name or "").strip()) if t] if not tokens: return [] @@ -147,12 +106,7 @@ _TYPE_LABEL = {"32": "숙박", "39": "음식점·카페", "12": "관광지", "14 async def _warn_if_other_content_type(key: str, name: str, expected: str) -> None: - """같은 상호가 **다른 콘텐츠 타입**으로 등록돼 있으면 로그로 알린다. - - ★ 조회 결과를 바꾸지 않는다. 업종이 다르면 스키마도 달라서, 숙박 fact 를 카페 사업장에 - 밀어 넣어봐야 대부분 스키마 밖 key 로 거부된다(실측: 3건 중 1건만 저장됐다). - 고쳐야 할 것은 조회가 아니라 **사업장 업종 등록**이므로, 사람이 볼 수 있게 남기기만 한다. - """ + """같은 상호가 **다른 콘텐츠 타입**으로 등록돼 있으면 로그로 알린다.""" try: async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: for query in _query_candidates(name): @@ -180,11 +134,7 @@ async def find_content_id( latitude: Optional[float] = None, longitude: Optional[float] = None, ) -> Optional[tuple[str, str]]: - """(contentId, contentTypeId) 또는 None. - - ★ 상호가 일치하지 않거나 좌표가 멀면 **찾지 못한 것으로 처리한다.** - 틀린 업소를 붙이느니 안 붙이는 편이 낫다 — 사장님이 직접 주소를 넣는 경로가 살아 있다. - """ + """(contentId, contentTypeId) 또는 None.""" key = (external_api_config.tour_api_key or "").strip() if not key: return None @@ -228,9 +178,7 @@ async def find_content_id( best = (distance, item) if best is None: - # ★ 타입을 안 걸고 한 번 더 본다 — 업종을 잘못 등록하면 여기서만 알 수 있다. - # 실측(2026-08-31): 같은 호텔을 '카페' 로 등록했더니 39(음식점)로 조회돼 0건이었다. - # TourAPI 에는 32(숙박)로 있었다. 조용히 '없음' 으로 끝내면 원인을 못 찾는다. + # 타입을 안 걸고 한 번 더 본다 — 업종을 잘못 등록하면 여기서만 알 수 있다. await _warn_if_other_content_type(key, name, content_type) LOG.i(f"[tour_lookup] '{name}' 일치하는 TourAPI 콘텐츠 없음 (후보 {len(items)}건)") return None diff --git a/solution/backend/services/external/wikimedia.py b/solution/backend/services/external/wikimedia.py index c3df518..4035ad6 100644 --- a/solution/backend/services/external/wikimedia.py +++ b/solution/backend/services/external/wikimedia.py @@ -1,20 +1,4 @@ -"""위키미디어 사진 — 인물 얼굴을 **권리를 확인하고** 가져온다. - -★ 왜 위키인가 - 공공데이터(TourAPI)는 관광지·시설 사진을 준다. 사람 얼굴은 주지 않는다 — 인물 열전 - 열 명을 찔러도 0건이다(실측 2026-09-10). 인물 사진이 공개돼 있으면서 **재게시 권리를 - 기계가 읽을 수 있는 형태로 알려주는** 곳은 사실상 위키백과·위키공용뿐이다. - -★ 크롤링이 아니다. MediaWiki 공식 API 를 쓴다 — robots 를 거스르지 않고, 페이지를 긁어 - 파싱하지도 않는다. 우리는 API 가 주는 파일 이름과 **그 파일의 라이선스 메타데이터**를 읽는다. - -★ 권리 판정을 여기서 끝낸다 (`tour_api.find_image` 와 같은 자리) - 위키백과에는 자유 저작물만 있는 것이 아니다 — 인물 사진에는 특히 '공정 이용'(비자유) - 파일이 섞인다. 그걸 발행본에 실으면 상업적 이용이라 바로 침해다. 그래서 **상업적 이용을 - 허용하는 라이선스만** 통과시키고, 판정할 수 없으면 버린다. - 통과한 사진에도 **출처 표시**가 따라붙는다(CC BY·BY-SA 의 조건) — `credit` 이 그 값이고, - 화면에 그대로 찍는다. 표시하지 않을 거면 애초에 쓰지 않는다. -""" +"""위키미디어 사진 — 인물 얼굴을 **권리를 확인하고** 가져온다.""" import re from typing import NamedTuple, Optional @@ -26,15 +10,10 @@ KO_API = "https://ko.wikipedia.org/w/api.php" COMMONS_API = "https://commons.wikimedia.org/w/api.php" REQUEST_TIMEOUT = 15.0 -# ★ 위키미디어 API 예절: 누가 부르는지 밝힌다. 익명 UA 는 차단 대상이다. -# (우회 장치가 아니다 — 정직하게 신원을 적는 것이 저쪽이 요구하는 방식이다.) +# 위키미디어 API 예절: 누가 부르는지 밝힌다. HEADERS = {"User-Agent": "o2o-web4ai/1.0 (+https://web4ai.o2osolution.ai; contact: hbyang@o2o.kr)"} -# 상업적 이용이 허용되는 라이선스만. 소문자로 맞춰 비교한다. -# · public domain / pd — 조건 없음 -# · cc0 — 조건 없음 -# · cc by, cc by-sa — 출처 표시 조건(우리는 credit 을 화면에 찍는다) -# ★ 여기 없는 것은 전부 버린다. 특히 'fair use'·'non-free'·'nc'(비영리)·'nd' 는 못 쓴다. +# 상업적 이용이 허용되는 라이선스만. _OK_LICENSE_RE = re.compile(r"\b(public domain|pd-|cc0|cc[- ]by(?:[- ]sa)?)\b", re.I) _DENY_LICENSE_RE = re.compile(r"\b(fair use|non-?free|nc\b|no[nt][- ]commercial|nd\b)", re.I) @@ -48,7 +27,7 @@ class WikiImage(NamedTuple): def _strip_tags(value: str) -> str: - """extmetadata 의 값에는 HTML 이 섞여 온다(링크·줄바꿈). 화면에 그대로 찍을 수 없다.""" + """extmetadata 의 값에는 HTML 이 섞여 온다(링크·줄바꿈).""" text = re.sub(r"<[^>]+>", "", value or "") return re.sub(r"\s+", " ", text).strip() @@ -61,12 +40,7 @@ def _license_ok(license_name: str, usage_terms: str) -> bool: async def find_person_image(client: httpx.AsyncClient, name: str) -> Optional[WikiImage]: - """인물 이름으로 사진 한 장. 없거나 권리가 불확실하면 None. - - ★ 없는 것이 정상적인 결말이다. 한국 근대 인물은 자유 저작물 사진이 없는 경우가 많고, - 그때 인물 자리는 렌더러가 이니셜 활자로 세운다(PeopleItem.imageUrl 주석). - 억지로 채우려고 비슷한 이름의 다른 사람 사진을 붙이는 것이 훨씬 나쁘다. - """ + """인물 이름으로 사진 한 장.""" person = (name or "").strip() if not person: return None diff --git a/solution/backend/services/fact_service.py b/solution/backend/services/fact_service.py index c1ebd31..1aa839e 100644 --- a/solution/backend/services/fact_service.py +++ b/solution/backend/services/fact_service.py @@ -36,43 +36,21 @@ from services.external.gemini_extract import extract_facts from services.intro_summary import summarize_intro from services.llm.gemini import GeminiError, GeminiNotConfigured -# 사장님이 붙여넣은 원문의 출처 표기. ★ 실제 URL 이 아니라 경로 식별자다 — -# fact 는 출처가 비면 거부되는데(FACT_SOURCE_REQUIRED), 이 값은 웹 주소가 없다. -# '어디서 왔는가' 는 여전히 명확하다: 사장님이 화면에 직접 붙여넣었다. +# 사장님이 붙여넣은 원문의 출처 표기. OWNER_PASTE_SOURCE = "owner:paste" -# 자동 출처. CRAWL과 LLM 문장은 아래의 즉시 노출 경로를 먼저 거친다. +# 자동 출처. _AUTO_SOURCES = (SourceType.API, SourceType.CRAWL, SourceType.LLM) class FactService: - """fact 기록 + 검증 상태 전이. - - ── 세 갈래 프로세스 ──────────────────────────────────────────────── - 생성 : 크롤링 → 노출값(VERIFIED), API → 후보 → 사람이 승인 - 업데이트: 같은 값이면 검증 유지(REFRESHED). 다른 CRAWL 값은 바로 교체한다. - API 값이나 사람 입력·정정본과 충돌하는 값은 후보(PENDING_OWNER)로 적재한다. - 수정 : 사람이 직접 입력 → 노출값 즉시 교체. 정정(CORRECTED)은 잠금 표시가 붙는다 - 문장 : LLM 이 쓴 소개문·메타(allow_llm 필드) → 승인 없이 바로 노출값 - (사실이 아니라 **이미 승인된 사실로 쓴 문장**이다 — upsert_fact 주석) - - 핵심은 재수집이 노출 중인 사실을 밀어내지 않는다는 것이다. 밀어내면 사이트에서 - 체크인 시간 같은 항목이 사라지고, 그 사이 방문자는 정보를 못 본다. - - 지켜야 하는 규칙: - 1. key 는 사업장 업종 스키마에 있는 것만 (FACT_INVALID_KEY) - 2. owner 가 아닌 출처는 source_url 필수 (FACT_SOURCE_REQUIRED) - 3. LLM 은 스키마가 허용한 문장 필드에만 쓴다 (절대규칙 7) - 4. 크롤링은 승인 없이 노출한다. 사람 입력·정정본은 덮지 않는다. - LLM 문장도 즉시 노출하되 CORRECTED는 보존한다. - 5. 상태 전이는 FACT_STATUS_TRANSITIONS 에 있는 것만 - """ + """fact 기록 + 검증 상태 전이.""" def __init__(self, crud: IFactCRUD = Depends(FactCRUD), place_crud: PlaceCRUD = Depends(PlaceCRUD)): self.crud = crud self.place_crud = place_crud - # ---- 사업장 로드(회사 스코프) ---- + # 사업장 로드(회사 스코프) async def _load_place(self, user_info: UserInfo, place_id: str): err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), @@ -84,8 +62,6 @@ class FactService: return ErrorType.SUCCESS, place async def _mark_content_updated(self, place_id: str, ts): - """★ 노출값이 실제로 바뀌었다 — 이 사업장만 다시 빌드하면 된다는 표시. - 부가 효과라 실패해도 본 흐름을 막지 않는다(다음 변경 때 다시 찍힌다).""" err = await DB_SESSION_MNG.execute_lambda_run( [places.DBType()], [lambda s: self._touch(s, place_id, ts)], @@ -99,7 +75,7 @@ class FactService: query = update(places).where(places.place_id == uuid.UUID(place_id)).values(content_updated_at=ts, updated_at=ts) return await DB_SESSION_MNG.add(s, query) - # ---- 업종 스키마 노출(관리 화면 폼 생성용) ---- + # 업종 스키마 노출(관리 화면 폼 생성용) async def get_category_schema(self, user_info: UserInfo, place_id: str) -> Res_CategorySchema: res = Res_CategorySchema() err_type, place = await self._load_place(user_info, place_id) @@ -116,7 +92,7 @@ class FactService: res.fields = [FieldSpecData(**spec.to_dict()) for spec in schema.fields.values()] return res - # ---- 조회 ---- + # 조회 async def list_facts(self, user_info: UserInfo, place_id: str, unit_id=None, publishable_only: bool = False) -> Res_FactList: res = Res_FactList() err_type, _place = await self._load_place(user_info, place_id) @@ -134,37 +110,20 @@ class FactService: return res res.facts = [FactData.model_validate(r) for r in rows] await self._attach_summaries(res.facts) - # ★ 사이트에 나갈 수 있는 건수. 발행 게이트가 보는 숫자와 같은 기준이다. + # 사이트에 나갈 수 있는 건수. res.publishable = sum(1 for r in rows if FactStatus(r.status) in PUBLISHABLE_FACT_STATUSES) # 재수집이 올려놓은 확인 대기 건수 — 관리 화면의 '검토할 것' 배지. res.pending_review = sum(1 for r in rows if FactStatus(r.status) == FactStatus.PENDING_OWNER) return res async def _attach_summaries(self, facts: list) -> None: - """intro/room_intro 원문이 길면 캔버스 미리보기용 요약을 얹는다. - - ★ place_facts 에는 저장하지 않는다 — 이 응답(FactData.summary)에만 실린다. - 원문이 짧으면 API 를 부르지 않는다(summarize_intro).""" + """intro/room_intro 원문이 길면 캔버스 미리보기용 요약을 얹는다.""" for f in facts: f.summary = await summarize_intro(f.key, f.value) - # ---- 기록 ---- + # 기록 async def extract_from_text(self, user_info: UserInfo, place_id: str, text: str) -> Res_ExtractFacts: - """사장님이 붙여넣은 원문 → fact 후보. - - ★ 폴백 3단계의 2번이다. TourAPI 에 없고 네이버에도 요금표뿐인 업소 - (실측: 조이모텔)는 이 입구가 없으면 발행 근거가 영영 안 찬다. - - ★ 안전장치는 두 겹이다. - ① services/grounding/extract.py 가 **모델이 적은 근거 문장이 원문에 실제로 있는지** - 대조한다. 없으면 버린다(지어낸 값 차단). - ② 통과한 값도 여기서 upsert_fact 를 그대로 탄다 — 스키마 검사·CORRECTED 잠금 등 - 기존 관문을 우회하는 뒷문을 만들지 않는다. - - ★ source_type 은 CRAWL 이 아니라 **OWNER 가 아니다**. 사장님이 붙여넣긴 했지만 - 값을 고른 것은 모델이므로 LLM 으로 남긴다 — 출처를 사람으로 위장하면 - "사장님이 확인한 값" 과 구분이 사라진다. - """ + """사장님이 붙여넣은 원문 → fact 후보.""" res = Res_ExtractFacts() err_type, place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -193,8 +152,7 @@ class FactService: res.rejections = [[label, why] for label, why in extracted.rejected] res.rejected = len(extracted.rejected) - # 단위(객실·메뉴) 이름을 실제 unit 으로 매핑한다. 없는 단위는 만들지 않는다 — - # 단위 생성은 수집 파이프라인(ensure_units)의 일이라 여기서 늘리지 않는다. + # 단위(객실·메뉴) 이름을 실제 unit 으로 매핑한다. unit_map = await self._unit_map(place_id) for fact in extracted.facts: @@ -231,7 +189,7 @@ class FactService: return res async def _unit_map(self, place_id: str) -> dict: - """단위 이름 → unit_id. 붙여넣기 값이 어느 객실 것인지 잇는 데만 쓴다.""" + """단위 이름 → unit_id.""" err, rows = await DB_SESSION_MNG.execute_lambda( place_units.DBType(), DBWRType.DB_READ.value, @@ -243,12 +201,7 @@ class FactService: async def upsert_fact(self, user_info: UserInfo, place_id: str, req: Req_UpsertFact) -> Res_Fact: - """fact 를 기록한다. 출처에 따라 경로가 갈린다. - - 사람(owner) → 노출값을 직접 교체한다(수정 프로세스) - 크롤링 → 원값을 바로 노출, 직접 입력·정정본과 충돌하면 후보로 보존 - API → 노출값과 같으면 확인 시각만 갱신, 다르면 후보로 적재 - """ + """fact 를 기록한다.""" res = Res_Fact() err_type, place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -271,13 +224,12 @@ class FactService: res.result.SetResult(ErrorType.FACT_SOURCE_REQUIRED) return res - # 규칙 3 — ★ LLM 은 사실을 만들지 않는다. 스키마가 허용한 문장 필드에만 쓸 수 있다. + # 규칙 3 — ★ LLM 은 사실을 만들지 않는다. if req.source_type == SourceType.LLM and not spec.allow_llm: res.result.SetResult(ErrorType.FACT_INVALID_KEY) return res - # 규칙 4 — TEMPLATE 은 FAQ 문의 안내 전용 출처다(services/faq_fill). fact 에는 쓸 수 없다. - # OWNER 도 자동 수집도 아니라서, 막지 않으면 아래 분기에서 사람 입력처럼 바로 노출값이 된다. + # 규칙 4 — TEMPLATE 은 FAQ 문의 안내 전용 출처다(services/faq_fill). if req.source_type == SourceType.TEMPLATE: res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) return res @@ -295,8 +247,6 @@ class FactService: now = GTime.UTC() same_value = published is not None and (published.value or "") == (req.value or "") - # 2026-09-14: 크롤링 원값을 바로 표시한다. 사람 입력의 출처까지 바뀌지 않게 - # 동일값 갱신보다 먼저 보호한다. if req.source_type == SourceType.CRAWL and published is not None and ( published.source_type == SourceType.OWNER.value or FactStatus(published.status) in LOCKED_FACT_STATUSES @@ -318,20 +268,7 @@ class FactService: res.outcome = FactWriteOutcome.REFRESHED return await self._reload(res, pid, published.fact_id) - # ★ LLM 이 쓴 문장은 후보로 두지 않고 **바로 노출값**이다 (2026-09-10 결정). - # - # 왜 예외인가 — 후보 단계는 '이 값이 사실인가' 를 사람에게 묻는 자리다. 체크인 시각이 - # 틀리면 예약 클레임이 나므로 그 물음이 필요하다. 그런데 소개문은 사실이 아니라 문장이고, - # 그 재료는 **이미 노출값인 fact**뿐이다(규칙 3 + copy_service 가 grounded 만 넘긴다). - # 확인된 사실로 쓴 문장을 한 번 더 확인받게 하면, 같은 사실을 두 번 승인하는 셈이다. - # - # 실측(2026-09-10, 힐튼 가든 인 서울 강남): 수집 확인이 07:29 에 끝나고 소개문이 07:31 에 - # 도착했다. 사장님이 확인 화면을 지나간 뒤에 오는 값이라 승인할 화면 자체가 없었고, - # 소개문은 생성됐는데(`[copy] 소개문 O`) 발행본은 영영 빈칸이었다. - # → 지역 이야기를 검수 없이 PUBLISHED 로 싣는 것과 같은 규약이다(DECISIONS.md 6절). - # ★ 단 사장님이 고친 문장(CORRECTED)은 덮지 않는다. 지금까지 이 잠금은 "자동 출처는 - # 노출값에 손을 못 댄다" 는 경로 자체가 지켜 줬는데(_write_candidate), LLM 만 경로를 - # 바꾸면 그 보호가 사라진다 — 잠금은 여기서 명시적으로 다시 건다(절대규칙 6). + # LLM 이 쓴 문장은 후보로 두지 않고 **바로 노출값**이다. if req.source_type in (SourceType.LLM, SourceType.CRAWL): if published is not None and FactStatus(published.status) in LOCKED_FACT_STATUSES: return await self._write_candidate(res, pid, req, published, now, spec) @@ -341,10 +278,7 @@ class FactService: return await self._replace_published(res, place_id, pid, req, published, now, spec, user_info) async def _write_candidate(self, res, pid, req, published, now, spec): - """자동 수집 — 노출값은 건드리지 않고 후보로 적재한다. - - ★ 이게 업데이트 프로세스의 핵심이다. 노출값을 밀어내면 사이트에서 사실이 사라진다. - 노출값이 이미 있으면 PENDING_OWNER(사람 확인 대기), 없으면 UNVERIFIED.""" + """자동 수집 — 노출값은 건드리지 않고 후보로 적재한다.""" target_status = FactStatus.PENDING_OWNER if published is not None else FactStatus.UNVERIFIED cand_err, candidate = await DB_SESSION_MNG.execute_lambda( @@ -394,10 +328,7 @@ class FactService: return res async def _replace_published(self, res, place_id, pid, req, published, now, spec, user_info): - """직접 입력·크롤링·허용된 LLM 문장을 노출값으로 교체한다. - - 크롤링 자동 노출은 verified_by를 비워 사람이 승인한 이력과 구별한다. - 기존 노출값은 지우지 않고 EXPIRED 이력으로 남긴다.""" + """직접 입력·크롤링·허용된 LLM 문장을 노출값으로 교체한다.""" fact = place_facts( place_id=pid, unit_id=req.unit_id, @@ -425,7 +356,6 @@ class FactService: res.result.SetResult(run_err) return res - # ★ 노출값이 바뀌었다 → 이 사업장만 재빌드 대상이 된다. await self._mark_content_updated(place_id, now) res.outcome = FactWriteOutcome.PUBLISHED_REPLACED if published is not None else FactWriteOutcome.PUBLISHED_CREATED res.fact = FactData.model_validate(fact) @@ -445,12 +375,9 @@ class FactService: res.fact = FactData.model_validate(row) if row is not None else None return res - # ---- 검증 상태 전이 ---- + # 검증 상태 전이 async def transition(self, user_info: UserInfo, place_id: str, fact_id: str, req: Req_TransitionFact) -> Res_Fact: - """검증 상태를 바꾼다. 허용 전이는 FACT_STATUS_TRANSITIONS 가 유일한 소스다. - - 후보를 노출 상태로 승격시키면 **기존 노출값을 EXPIRED 로 내리고 남은 후보를 정리**한다 — - 이게 업데이트 프로세스의 마지막 단계(사람의 승인)다.""" + """검증 상태를 바꾼다.""" res = Res_Fact() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -519,7 +446,6 @@ class FactService: return err_type async def _transition_ok(self, s, fid, current, target, data): - """전이가 0행이면 그 사이 다른 사람이 상태를 바꿨다는 뜻 — 트랜잭션을 중단시킨다.""" err_type, rowcount = await self.crud.transition(s, fid, (current.value,), target.value, data) if err_type != ErrorType.SUCCESS: return err_type diff --git a/solution/backend/services/faq_fill.py b/solution/backend/services/faq_fill.py index b6ca2ad..59cffda 100644 --- a/solution/backend/services/faq_fill.py +++ b/solution/backend/services/faq_fill.py @@ -1,23 +1,11 @@ -"""FAQ 목표 수 채우기 — 생성된 FAQ 가 모자라면 카탈로그에서 겹치지 않는 공통 질문을 고른다. - -순수 함수만 둔다(DB·네트워크 없음). 무엇이 왜 골렸는지를 이 파일만 읽고 답할 수 있어야 한다. - -고르는 규칙 (카탈로그 순서 = 우선순위) - 1. 답할 fact 가 있는 질문은 고르지 않는다 — 그건 LLM 이 fact 로 답할 자리다. - "주차할 수 있나요?" 에 parking=true 가 있는데 "문의 부탁드립니다" 가 붙으면 아는 것을 숨긴 셈이다. - 2. 기존 FAQ 가 이미 다룬 주제는 고르지 않는다. 기존 FAQ 에는 LLM 생성분 · 사장님 입력 · 정정분이 모두 든다. - - 근거 fact key 가 겹치면 같은 주제다. LLM 은 "주차 및 와이파이" 처럼 두 주제를 한 문항에 묶고 - 근거에 [parking, wifi] 를 적는다 — 낱말 대조만으로는 둘 중 하나를 놓친다. - - 질문에 카탈로그 키워드가 들어 있으면 같은 주제다(사장님 입력은 근거 key 가 없다). - 3. 공통 답은 문의 안내뿐이다. 값·가능 여부를 적지 않는다(common/faq_catalog 머리주석). -""" +"""FAQ 목표 수 채우기 — 생성된 FAQ 가 모자라면 카탈로그에서 겹치지 않는 공통 질문을 고른다.""" import re from dataclasses import dataclass from typing import Iterable, Optional from common.faq_catalog import FaqCatalog -# ★ 사이트에 싣는 FAQ 목표 수. 생성 상한(max_faqs)도 이 값을 쓴다. +# 사이트에 싣는 FAQ 목표 수. FAQ_TARGET = 20 @@ -44,7 +32,7 @@ def _base_key(key: str) -> str: def _with_topic_particle(topic: str) -> str: - """주제 뒤에 은/는 을 붙인다. 받침이 있으면 '은'.""" + """주제 뒤에 은/는 을 붙인다.""" last = topic.strip()[-1:] if "가" <= last <= "힣": return topic + ("은" if (ord(last) - ord("가")) % 28 else "는") @@ -52,7 +40,7 @@ def _with_topic_particle(topic: str) -> str: def fallback_answer(catalog: FaqCatalog, index: int, topic: str, phone: Optional[str]) -> str: - """문의 안내 문구. 문구를 번갈아 써서 스무 줄이 전부 같은 문장이 되지 않게 한다.""" + """문의 안내 문구.""" phone = (phone or "").strip() templates = catalog.fallback_with_contact if phone else catalog.fallback_without_contact template = templates[index % len(templates)] @@ -66,7 +54,7 @@ def pick_fill_faqs( phone: Optional[str] = None, target: int = FAQ_TARGET, ) -> list[FillFaq]: - """목표 수까지 모자란 만큼 카탈로그 질문을 고른다. 모자라지 않으면 빈 목록.""" + """목표 수까지 모자란 만큼 카탈로그 질문을 고른다.""" existing = list(existing) need = target - len(existing) if need <= 0: @@ -92,7 +80,6 @@ def pick_fill_faqs( def suggested_questions(catalog: FaqCatalog, fact_keys: Iterable[str]) -> list[str]: - """fact 로 답할 수 있는 카탈로그 질문 — 프롬프트에 실어 LLM 이 이 질문들부터 쓰게 한다. - 채우기(규칙 1)가 이 질문들을 건너뛰므로, LLM 이 안 쓰면 그 주제는 비게 된다.""" + """fact 로 답할 수 있는 카탈로그 질문 — 프롬프트에 실어 LLM 이 이 질문들부터 쓰게 한다.""" known = {_base_key(k) for k in fact_keys} return [item.question for item in catalog.items if known.intersection(item.fact_keys)] diff --git a/solution/backend/services/faq_service.py b/solution/backend/services/faq_service.py index c5f7142..8c1d2df 100644 --- a/solution/backend/services/faq_service.py +++ b/solution/backend/services/faq_service.py @@ -1,15 +1,4 @@ -"""FAQ 조회 + 승인(검증 상태 전이). - -COPY 잡이 faqs 를 UNVERIFIED 로 쌓아두지만, 절대규칙 1 때문에 확인 전에는 사이트에 나가지 못한다 -(snapshot 이 PUBLISHABLE 만 싣는다). 즉 **승인 창구가 없으면 FAQPage JSON-LD 는 영영 안 나간다** — -이 서비스가 그 창구다. - -fact 와 같은 규칙을 따른다(다른 표를 쓰면 두 도메인의 승인 의미가 갈라진다): - 1. 회사 스코프는 사업장 조회로 강제 — 남의 회사 place 의 FAQ 는 보이지도 만져지지도 않는다 - 2. 상태 전이는 FACT_STATUS_TRANSITIONS 에 있는 것만 (FACT_INVALID_TRANSITION) - 3. CORRECTED 는 잠긴 종착 상태(LOCKED_FACT_STATUSES) — 재생성이 사장님 문구를 덮어쓰지 못한다 - 4. 사람이 직접 쓴 FAQ 는 바로 노출값이다 — 사람이 곧 출처이자 책임 주체다 -""" +"""FAQ 조회 + 승인(검증 상태 전이).""" import uuid from fastapi import Depends @@ -38,10 +27,9 @@ class FaqService: self.crud = crud self.place_crud = place_crud - # ---- 사업장 로드(사장님 스코프) ---- + # 사업장 로드(사장님 스코프) async def _load_place(self, user_info: UserInfo, place_id: str): - """owner_user_id 를 WHERE 에 걸어 조회한다 — 남의 place_id 를 넣으면 PLACE_NOT_FOUND. - '없다'와 '권한 없다'를 구분해 주지 않는 것도 의도다(존재 여부를 흘리지 않는다).""" + """owner_user_id 를 WHERE 에 걸어 조회한다 — 남의 place_id 를 넣으면 PLACE_NOT_FOUND.""" err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, @@ -52,9 +40,6 @@ class FaqService: return ErrorType.SUCCESS, place async def _mark_content_updated(self, place_id: str, ts): - """★ 사이트에 나가는 내용이 바뀌었다 — 이 사업장만 다시 빌드하면 된다는 표시. - FAQ 도 페이지에 렌더되고 FAQPage JSON-LD 로 나가므로 fact 와 같은 취급이다. - 부가 효과라 실패해도 본 흐름을 막지 않는다(다음 변경 때 다시 찍힌다).""" err = await DB_SESSION_MNG.execute_lambda_run([places.DBType()], [lambda s: self._touch(s, place_id, ts)]) if err != ErrorType.SUCCESS: LOG.e_no_callstack(f"[faq] content_updated_at 갱신 실패 place={place_id}") @@ -74,10 +59,9 @@ class FaqService: res.faq = FaqData.model_validate(row) if row is not None else None return res - # ---- 조회 ---- + # 조회 async def list_faqs(self, user_info: UserInfo, place_id: str, publishable_only: bool = False) -> Res_FaqList: - """publishable_only=True → ★ 사이트에 나갈 수 있는 것만(빌드가 보는 것과 같은 집합). - 기본(False)은 승인 대기까지 함께 준다 — 관리 화면이 승인할 대상을 봐야 하기 때문이다.""" + """publishable_only=True → ★ 사이트에 나갈 수 있는 것만(빌드가 보는 것과 같은 집합).""" res = Res_FaqList() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -99,12 +83,9 @@ class FaqService: res.pending_review = sum(1 for r in rows if FactStatus(r.status) in CANDIDATE_FACT_STATUSES) return res - # ---- 직접 추가 ---- + # 직접 추가 async def create_faq(self, user_info: UserInfo, place_id: str, req: Req_CreateFaq) -> Res_Faq: - """사장님이 직접 쓴 FAQ 를 넣는다 — fact 의 owner 입력과 같은 철학으로 바로 노출값(VERIFIED)이 된다. - - 근거 fact(source_fact_ids)를 요구하지 않는 것도 그래서다. 근거를 요구하는 이유는 - LLM 이 지어냈는지 확인하기 위해서인데, 여기서는 사람 본인이 출처다.""" + """사장님이 직접 쓴 FAQ 를 넣는다 — fact 의 owner 입력과 같은 철학으로 바로 노출값(VERIFIED)이 된다.""" res = Res_Faq() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -142,17 +123,12 @@ class FaqService: res.result.SetResult(run_err) return res - # 바로 노출 대상이 늘었다 → 재빌드 대상 표시. await self._mark_content_updated(place_id, GTime.UTC()) return await self._reload(res, pid, row.faq_id) - # ---- 검증 상태 전이(승인 · 정정 · 반려) ---- + # 검증 상태 전이(승인 · 정정 · 반려) async def transition(self, user_info: UserInfo, place_id: str, faq_id: str, req: Req_TransitionFaq) -> Res_Faq: - """생성된 FAQ 를 승인하거나, 문구를 고쳐 승인하거나, 반려한다. - - 고쳐서 승인하면 CORRECTED — ★ 잠긴 상태다. 이후 재생성(expire_generated)은 미확인 FAQ 만 - 건드리므로 사장님이 고친 문구는 살아남는다. generated_by 도 OWNER 로 바꿔 둔다: - 문장의 책임 주체가 LLM 에서 사람으로 넘어왔다는 기록이다.""" + """생성된 FAQ 를 승인하거나, 문구를 고쳐 승인하거나, 반려한다.""" res = Res_Faq() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -196,7 +172,6 @@ class FaqService: [lambda s: self._transition_ok(s, fid, current, target, data)], ) if run_err != ErrorType.SUCCESS: - # 적용행수 0 = 그 사이 다른 사람이 상태를 바꿨다 — 요청한 전이는 더 이상 유효하지 않다. res.result.SetResult(ErrorType.FACT_INVALID_TRANSITION if run_err == ErrorType.DB_EMPTY_DATA else run_err) return res @@ -206,8 +181,7 @@ class FaqService: return await self._reload(res, pid, fid) async def _transition_ok(self, s, fid, current: FactStatus, target: FactStatus, data: dict): - """출발 상태를 WHERE 에 걸어 조건부로만 바꾼다(동시 처리 가드). - execute_lambda_run 은 ErrorType 만 받으므로 적용행수 0 을 DB_EMPTY_DATA 로 바꿔 트랜잭션을 중단시킨다.""" + """출발 상태를 WHERE 에 걸어 조건부로만 바꾼다(동시 처리 가드).""" err_type, rowcount = await self.crud.transition(s, fid, (current.value,), target.value, data) if err_type != ErrorType.SUCCESS: return err_type diff --git a/solution/backend/services/grounding/__init__.py b/solution/backend/services/grounding/__init__.py index 705015f..840fa05 100644 --- a/solution/backend/services/grounding/__init__.py +++ b/solution/backend/services/grounding/__init__.py @@ -1,13 +1 @@ -"""LLM 이 내놓은 답을 믿어도 되는지 판정하는 자리. - -★ 여기에는 외부 호출이 없다. 전부 순수 함수다 — - 입력(생성된 문장 + 근거)만 주면 같은 답이 나오므로, 실호출 없이 테스트할 수 있다. - -★ 왜 따로 두나 - "지어내지 마라" 는 프롬프트로 막히지 않는다. 모델은 종종 어기고, 그 문장이 그대로 - 사장님 사이트에 실리면 틀린 요금·없는 시설이 손님에게 나간다. 그래서 프롬프트를 - 1차 방어로 두고, 실제 방어는 여기서 코드로 한다. - - 호출·재시도 코드와 섞여 있으면 "멀쩡한 답이 왜 반려되지" 를 고치러 갈 때 - HTTP 코드를 헤치고 들어가야 한다. 그래서 겹을 갈랐다(services/llm/__init__.py 참조). -""" +"""LLM 이 내놓은 답을 믿어도 되는지 판정하는 자리.""" diff --git a/solution/backend/services/grounding/channels.py b/solution/backend/services/grounding/channels.py index 5d8fbbc..e3e4926 100644 --- a/solution/backend/services/grounding/channels.py +++ b/solution/backend/services/grounding/channels.py @@ -1,15 +1,4 @@ -"""채널 URL 후보를 믿어도 되는지 판정하는 자리. - -Perplexity 가 돌려준 URL 목록에서 **상세 페이지가 아닌 것과 후기 블로그를 걸러낸다.** -외부 호출이 없는 순수 함수라 실호출 없이 테스트할 수 있다. - -★ 왜 호출 코드에서 떼어냈나 - "왜 이 URL 이 빠졌나 / 왜 이 쓰레기가 남았나" 는 필터 규칙 문제지 HTTP 문제가 아니다. - 규칙만 따로 읽고 따로 고칠 수 있어야 한다(services/llm/__init__.py 의 겹 설명 참조). - -★ 조용히 버리지 않는다. 걸러낸 것은 전부 (url, 사유) 로 돌려주고, 호출측이 로그와 - 운영 화면에 싣는다 — 필터가 과해서 진짜 채널을 버렸는지 사람이 판단할 수 있어야 한다. -""" +"""채널 URL 후보를 믿어도 되는지 판정하는 자리.""" import json from dataclasses import dataclass from urllib.parse import urlparse @@ -22,7 +11,7 @@ MAX_LINKS = 6 MAX_LINKS_PER_CHANNEL = 3 -# URL 호스트 → 채널 코드. 접미사 매칭이라 서브도메인(place.naver.com 등)도 잡힌다. +# URL 호스트 → 채널 코드. _HOST_CHANNEL = ( ("yanolja.com", LinkChannel.YANOLJA), ("goodchoice.kr", LinkChannel.GOODCHOICE), @@ -31,15 +20,13 @@ _HOST_CHANNEL = ( ("naver.me", LinkChannel.NAVER_PLACE), ("m.place.naver.com", LinkChannel.NAVER_PLACE), # 사람이 실제로 공유하는 주소가 이 호스트다(map.naver.com/p/entry/place/...). - # 블로그·카페는 위 _NAVER_BLOG_HOSTS 가 먼저 걸러내므로 여기 넣어도 후기가 섞이지 않는다. ("map.naver.com", LinkChannel.NAVER_PLACE), ("instagram.com", LinkChannel.INSTAGRAM), ) # 네이버 도메인 중 블로그·카페는 플레이스가 아니다 — 채널 판정을 분리한다. _NAVER_BLOG_HOSTS = ( - # ★ 안내·도움말 페이지. 가게 채널이 아니다 — 실측으로 '도플로' 수집에 - # pages.map.naver.com/useful-tips 가 유일한 네이버 링크로 등록돼 사진 0장이 됐다. + # 안내·도움말 페이지. "pages.map.naver.com", "help.naver.com", "guide.naver.com", "blog.naver.com", "cafe.naver.com", "m.blog.naver.com", "m.cafe.naver.com", "post.naver.com", "m.post.naver.com", "in.naver.com", @@ -47,15 +34,8 @@ _NAVER_BLOG_HOSTS = ( # ── 상세 페이지 판정 ────────────────────────────────────────────────────── -# ★ 실측(2026-08-27 '핑크비치펜션', 발견 15건)에서 확인된 쓰레기 유형: -# https://nol.yanolja.com/ 루트 — 이 가게와 무관 -# https://www.goodchoice.kr/ 루트 -# https://nol.yanolja.com/sub-home/pension 카테고리(업종 목록) -# https://nol.yanolja.com/programmatic/... SEO 랜딩 -# https://blog.naver.com/... 후기 4건 -# 이런 URL 을 확정 대기 목록에 남기면 사람이 15건을 일일이 봐야 한다. -# 상세 페이지일 수 없는 경로 첫 조각. 숫자 ID 가 뒤에 붙어도 상세가 아니다(SEO 랜딩·목록). +# 상세 페이지일 수 없는 경로 첫 조각. _NON_DETAIL_PREFIXES = frozenset({ "sub-home", "sub_home", "programmatic", "search", "event", "promotion", "category", "theme", "curation", "magazine", "notice", "help", "login", "signup", @@ -64,7 +44,7 @@ _NON_DETAIL_PREFIXES = frozenset({ # 상세 URL 이 숫자 ID 를 갖지 않는 예외 호스트(단축 링크·지도). _ID_OPTIONAL_HOSTS = ("naver.me", "map.naver.com", "instagram.com") -# 상세 판정에 숫자 ID 를 요구하는 채널. OTA 상세는 항상 숫자 ID 를 갖는다. +# 상세 판정에 숫자 ID 를 요구하는 채널. _ID_REQUIRED_CHANNELS = (LinkChannel.YANOLJA, LinkChannel.GOODCHOICE, LinkChannel.NAVER_PLACE) # 탈락 사유 코드 — 운영자가 "왜 빠졌나"를 보는 값이라 문자열을 고정한다. @@ -79,7 +59,7 @@ REASON_OVERFLOW = "overflow" # 상한 초과로 잘림 — 버린 게 아 @dataclass(frozen=True) class DiscoveredLink: - """발견된 채널 URL 1건. **사실이 아니라 '여기를 보라'는 포인터다.**""" + """발견된 채널 URL 1건.""" url: str channel: LinkChannel @@ -87,7 +67,7 @@ class DiscoveredLink: def classify_url(url: str) -> LinkChannel: - """URL → 채널 코드. 모르는 도메인은 ETC 로 떨어뜨린다(버리지 않는다).""" + """URL → 채널 코드.""" try: host = (urlparse(url).hostname or "").lower() except (ValueError, AttributeError): @@ -106,7 +86,7 @@ def classify_url(url: str) -> LinkChannel: def _path_segments(url: str) -> list[str]: - """URL 경로를 조각으로 나눈다. 빈 조각은 버린다.""" + """URL 경로를 조각으로 나눈다.""" try: path = urlparse(url).path or "" except (ValueError, AttributeError): @@ -115,11 +95,7 @@ def _path_segments(url: str) -> list[str]: def _detail_reject_reason(url: str, channel: LinkChannel) -> str | None: - """이 URL 이 '이 가게의 상세 페이지' 인가. 아니면 탈락 사유를, 맞으면 None 을 돌려준다. - - ★ 판정을 못 하겠으면 통과시킨다(사람이 확정 단계에서 본다). 여기서 과하게 버리면 - 진짜 채널을 잃는데, 그건 필터가 없는 것보다 나쁘다. - """ + """이 URL 이 '이 가게의 상세 페이지' 인가.""" segments = _path_segments(url) # 루트('/' 또는 경로 없음)는 업소와 무관한 서비스 첫 화면이다. @@ -138,7 +114,7 @@ def _detail_reject_reason(url: str, channel: LinkChannel) -> str | None: if any(host == h or host.endswith("." + h) for h in _ID_OPTIONAL_HOSTS): return None - # OTA 상세 URL 은 항상 숫자 ID 를 갖는다. 없으면 목록·카테고리다. + # OTA 상세 URL 은 항상 숫자 ID 를 갖는다. if channel in _ID_REQUIRED_CHANNELS: if not any(seg.isdigit() for seg in segments): return REASON_CATEGORY @@ -147,16 +123,12 @@ def _detail_reject_reason(url: str, channel: LinkChannel) -> str | None: def filter_links(links: list[DiscoveredLink], include_blogs: bool) -> tuple[list[DiscoveredLink], list[tuple[str, str]]]: - """발견된 URL 을 '이 가게의 상세 페이지' 인 것만 남긴다. - - ★ 전부 걸러지면 빈 목록을 돌려준다. 억지로 하나 남기지 않는다 — - '못 찾음'은 정상 결과이고, 사장님 직접 입력으로 폴백한다. - """ + """발견된 URL 을 '이 가게의 상세 페이지' 인 것만 남긴다.""" kept: list[DiscoveredLink] = [] dropped: list[tuple[str, str]] = [] per_channel: dict[LinkChannel, int] = {} for link in links: - # 블로그·카페는 채널이 아니라 후기다. 크롤링해도 이 가게의 공식 정보가 아니다. + # 블로그·카페는 채널이 아니라 후기다. if link.channel is LinkChannel.BLOG and not include_blogs: dropped.append((link.url, REASON_BLOG)) continue @@ -164,8 +136,7 @@ def filter_links(links: list[DiscoveredLink], include_blogs: bool) -> tuple[list if reason: dropped.append((link.url, reason)) continue - # ★ 상한 — 같은 채널에서 서로 다른 업소가 우수수 딸려오는 것을 막는다. - # links 는 '모델이 고른 것 → search_results' 순으로 들어오므로 앞쪽이 더 믿을 만하다. + # 상한 — 같은 채널에서 서로 다른 업소가 우수수 딸려오는 것을 막는다. used = per_channel.get(link.channel, 0) if used >= MAX_LINKS_PER_CHANNEL or len(kept) >= MAX_LINKS: dropped.append((link.url, REASON_OVERFLOW)) @@ -176,7 +147,7 @@ def filter_links(links: list[DiscoveredLink], include_blogs: bool) -> tuple[list def _parse_content_links(content: str) -> list[dict]: - """구조화 출력(JSON) 본문에서 links 배열을 꺼낸다. 깨져 있으면 빈 목록.""" + """구조화 출력(JSON) 본문에서 links 배열을 꺼낸다.""" if not content: return [] try: @@ -189,12 +160,7 @@ def _parse_content_links(content: str) -> list[dict]: def collect_links(payload: dict) -> tuple[list[DiscoveredLink], list[tuple[str, str]]]: - """모델이 고른 links + search_results(실제 검색 출처)를 합쳐 URL 목록을 만든다. - - search_results 를 함께 쓰는 이유: 모델 답변은 환각이 섞이지만 search_results 는 - 실제로 조회된 URL 이라 더 믿을 만하다. 어차피 다음 단계(동일 업소 검증)가 걸러낸다. - - 반환: (URL 목록, [(버린 URL, 사유), ...]). 중복·비 http 도 사유로 남긴다.""" + """모델이 고른 links + search_results(실제 검색 출처)를 합쳐 URL 목록을 만든다.""" seen: set[str] = set() out: list[DiscoveredLink] = [] dropped: list[tuple[str, str]] = [] @@ -230,7 +196,7 @@ def collect_links(payload: dict) -> tuple[list[DiscoveredLink], list[tuple[str, def search_count(payload: dict) -> int: - """이번 호출의 검색 횟수. usage 에 없으면 search_results 개수로 대체한다.""" + """이번 호출의 검색 횟수.""" usage = payload.get("usage") or {} for key in ("num_search_queries", "search_queries", "num_searches"): value = usage.get(key) diff --git a/solution/backend/services/grounding/copy.py b/solution/backend/services/grounding/copy.py index edc9e5e..f8321d5 100644 --- a/solution/backend/services/grounding/copy.py +++ b/solution/backend/services/grounding/copy.py @@ -1,25 +1,9 @@ -"""소개문·FAQ 의 근거 검증 — LLM 이 지어낸 문장을 걸러내는 규칙. - -★ 왜 코드로 또 검사하나 - 프롬프트에 "주어진 사실만 써라" 를 몇 번이나 못박아도 모델은 종종 어긴다. - 프롬프트는 1차 방어일 뿐이고, 실제로 손님에게 나갈 문장을 지키는 건 여기다. - 틀린 요금·없는 시설이 사이트에 실리면 그건 그대로 예약 클레임이 된다. - -★ 왜 호출 코드에서 떼어냈나 - 한때 이 규칙이 Gemini HTTP 호출과 같은 파일에 500줄로 뭉쳐 있었다. - "FAQ 가 멀쩡한데 반려된다" 를 고치려면 호출·재시도 코드를 헤치고 들어가야 했다. - 검증 규칙은 외부 호출이 전혀 없는 순수 함수라 여기서 따로 읽고 따로 테스트한다. - - 프롬프트를 고치려면 services/prompts/copy.py, 호출을 고치려면 services/llm/gemini.py 다. -""" +"""소개문·FAQ 의 근거 검증 — LLM 이 지어낸 문장을 걸러내는 규칙.""" import re from dataclasses import dataclass from typing import Optional -# ── 시설 어휘 사전 ──────────────────────────────────────────────────────── -# 문장에 이 낱말이 나오면 대응 fact key 중 하나가 **반드시** 있어야 한다. -# 어느 업종 스키마에도 없는 key(pool/sauna/elevator …)로 매핑된 낱말은 언제나 반려된다 — -# 그게 바로 "fact 에 없는 시설을 지어낸" 경우다. +# ── 시설 어휘 사전 ──────────────────────────────────────────────────────── 문장에 이 낱말이 나오면 대응 fact key 중 하나가 **반드시** 있어야 한다. _FACILITY_VOCAB: dict[str, tuple[str, ...]] = { # 실제로 스키마에 있는 것 "바비큐": ("bbq_available", "bbq_fee"), @@ -75,26 +59,24 @@ _FACILITY_VOCAB: dict[str, tuple[str, ...]] = { "조식뷔페": ("breakfast",), } -# 값이 '아니다'를 뜻하는 표기. 이 값인데 문장이 긍정으로 쓰면 반려한다. +# 값이 '아니다'를 뜻하는 표기. _FALSY = {"false", "0", "no", "n", "불가", "불가능", "없음", "미제공", "제공안함", "안됨", "없습니다"} -# 부정 표현. 시설 낱말 뒤 이 범위 안에 있으면 '없다'고 말한 것으로 본다. +# 부정 표현. _NEGATION = ("불가", "안 됩", "안됩", "안 돼", "안돼", "없습니다", "없음", "않습니다", "않으", "제한", "금지", "미제공", "어렵습니다") _NEGATION_WINDOW = 24 -# 문장 분리용. 의문문은 '주장' 이 아니라서 값 반대 판정(3번)에서 제외한다 — -# "반려동물 동반이 가능한가요?" 는 사실을 주장하는 게 아니라 묻는 것이다(실호출에서 오탐 확인). +# 문장 분리용. _SENT_SPLIT = re.compile(r"(?<=[.!?。])\s*|\n+") -# 홍보성·과장 표현. fact 값에 그대로 들어있지 않으면 반려한다. -# (객실명이 "프리미엄 스위트" 라면 fact 값에 있으므로 통과한다.) +# 홍보성·과장 표현. _PROMOTIONAL = ( "최고", "최상", "최적", "최대한", "완벽", "국내 최", "업계 1위", "1위", "명품", "럭셔리", "최고급", "독보적", "비교불가", "단연", "손꼽히는", "자랑하는", "환상적", "황홀", "잊지 못할", "특별한 추억", "아름다운", "쾌적한", "넓고", "저렴한", "합리적인 가격", ) -# 숫자 뒤 한글 자릿수. "2만원" 을 20000 으로도 본다. +# 숫자 뒤 한글 자릿수. _SCALE = {"만": 10_000, "천": 1_000, "억": 100_000_000} _NUM_RE = re.compile(r"(\d[\d,]*)\s*([만천억])?") @@ -102,7 +84,7 @@ _NUM_RE = re.compile(r"(\d[\d,]*)\s*([만천억])?") @dataclass class FactInput: - """생성 근거로 넘기는 확보된 fact 1건. 검증도 이 목록으로만 한다.""" + """생성 근거로 넘기는 확보된 fact 1건.""" key: str label: str @@ -112,14 +94,7 @@ class FactInput: # ── 근거 검증 ───────────────────────────────────────────────────────────── def _number_occurrences(text: str) -> list[tuple[str, set[str]]]: - """문자열의 숫자를 **등장 단위로** 뽑는다. (표기, 같은 값으로 볼 수 있는 후보들) - - "2만원" → ("2만", {"2", "20000"}) ← 둘 중 하나만 fact 에 있으면 근거가 있는 것이다 - "20,000" → ("20,000", {"20000"}) - "15:00" → ("15", {"15"}), ("00", {"0"}) - - 등장 단위로 묶는 이유: "2만원" 을 {"2","20000"} 로 평평하게 펴면 - fact 에 20000 이 있어도 "2" 가 근거 없다며 반려된다.""" + """문자열의 숫자를 **등장 단위로** 뽑는다.""" out: list[tuple[str, set[str]]] = [] for raw, scale in _NUM_RE.findall(text or ""): digits = raw.replace(",", "") @@ -136,7 +111,7 @@ def _number_occurrences(text: str) -> list[tuple[str, set[str]]]: def _fact_number_tokens(facts: list[FactInput]) -> set[str]: - """fact 값에 등장하는 모든 숫자 표현. 여기서는 평평하게 펴도 된다(대조 대상이라서).""" + """fact 값에 등장하는 모든 숫자 표현.""" tokens: set[str] = set() for f in facts: for _raw, alts in _number_occurrences(f.value or ""): @@ -160,10 +135,7 @@ def _negated_near(text: str, pos: int, word_len: int) -> bool: def _is_question_at(text: str, pos: int) -> bool: - """그 위치가 의문문 안인지. 의문문은 사실을 주장하지 않는다. - - FAQ 의 질문("반려동물 동반이 가능한가요?")을 긍정 주장으로 오해하면 - 멀쩡한 문답이 통째로 버려진다 — 실호출에서 실제로 겪은 오탐이다.""" + """그 위치가 의문문 안인지.""" start = 0 for match in _SENT_SPLIT.finditer(text): if match.start() > pos: @@ -178,10 +150,7 @@ def _is_question_at(text: str, pos: int) -> bool: def faq_polarity_ok(question: str, answer: str, facts: list[FactInput]) -> tuple[bool, list[str]]: - """FAQ 전용 — 질문이 '불가한 시설' 을 물었으면 **답변이 반드시 부정해야** 한다. - - 질문은 주장이 아니라 ground_check 의 값-반대 판정에서 빠진다. 그 빈틈을 여기서 막는다: - pet_allowed=false 인데 "가능한가요?" 라 묻고 "네, 가능합니다" 라 답하면 잡아야 한다.""" + """FAQ 전용 — 질문이 '불가한 시설' 을 물었으면 **답변이 반드시 부정해야** 한다.""" key_map = _fact_key_map(facts) reasons: list[str] = [] lowered_q = (question or "").lower() @@ -197,19 +166,7 @@ def faq_polarity_ok(question: str, answer: str, facts: list[FactInput]) -> tuple def ground_check(text: str, facts: list[FactInput]) -> tuple[bool, list[str]]: - """생성된 문장이 fact 로 뒷받침되는지 검사한다. - - ★ 이게 이 모듈의 핵심이다. 프롬프트로 "지어내지 마라" 라고 해도 모델은 종종 지어낸다. - 그래서 결과를 코드로 검증하고, 통과 못 하면 문장을 버린다. - - 잡아내는 것: - 1. 문장에 나온 숫자·시각·금액이 fact 값에 없다 → 근거 없는 수치 - 2. fact 에 없는 시설을 언급했다 (수영장·사우나·엘리베이터 …) - 3. fact 값이 '불가/없음' 인데 긍정문으로 썼다 (pet_allowed=false 인데 "반려동물 동반 가능") - 4. 홍보성 과장 표현 ("국내 최고의", "완벽한") - - 반환: (통과 여부, 사유 목록) - """ + """생성된 문장이 fact 로 뒷받침되는지 검사한다.""" if not (text or "").strip(): return False, ["빈 문장"] @@ -237,8 +194,6 @@ def ground_check(text: str, facts: list[FactInput]) -> tuple[bool, list[str]]: if not present: reasons.append(f"근거 없는 시설 언급 '{word}' — 해당 fact 가 없다") continue - # 값이 '아니다'인데 부정 없이 **주장**했다 → 반대로 말한 것. - # 의문문은 주장이 아니므로 제외한다(FAQ 질문이 통째로 버려지는 것을 막는다). if ( all(_is_falsy(key_map[k].value) for k in present) and not _negated_near(text, pos, len(word)) diff --git a/solution/backend/services/grounding/extract.py b/solution/backend/services/grounding/extract.py index 7347f83..d720fab 100644 --- a/solution/backend/services/grounding/extract.py +++ b/solution/backend/services/grounding/extract.py @@ -1,22 +1,9 @@ -"""추출 결과 검증 — "AI 가 원문에서 찾아온 것인가, 지어낸 것인가" 를 코드로 가른다. - -★ 이 파일이 없으면 URL·붙여넣기 추출은 쓸 수 없다. - 프롬프트로 "지어내지 마라" 를 아무리 적어도 그건 부탁이지 보장이 아니다. - 보장은 하나뿐이다 — **모델이 적어 낸 근거 문장이 원문에 실제로 있는지 대조하는 것.** - 없으면 그 항목을 버린다. 통과한 것만 fact 후보(UNVERIFIED)로 올라가고, - 그 다음 관문은 여전히 사장님 확인이다. - -★ 왜 '값' 이 아니라 '근거 문장' 을 대조하는가 - 값만 대조하면 "3" 같은 짧은 값이 원문 아무 데나 있다는 이유로 통과한다 - ("최대 3인" 을 못 찾아도 "3층" 이 있으면 통과해 버린다). - 근거 문장을 통째로 대조하면 그 값이 **그 맥락에서** 나왔다는 것까지 확인된다. -""" +"""추출 결과 검증 — "AI 가 원문에서 찾아온 것인가, 지어낸 것인가" 를 코드로 가른다.""" import re from common.category_schema import CategorySchema # 원문 대조 전 정규화: 공백·따옴표·괄호 종류 차이로 어긋나는 것을 막는다. -# ★ 글자 자체는 지우지 않는다 — 지우기 시작하면 "불가" 가 사라져 부정이 긍정으로 뒤집힌다. _WS = re.compile(r"\s+") _QUOTES = str.maketrans({ "“": '"', "”": '"', "‘": "'", "’": "'", """: '"', @@ -27,8 +14,6 @@ _QUOTES = str.maketrans({ # evidence 가 이보다 짧으면 대조가 의미 없다("3" 은 원문 어디에나 있다). MIN_EVIDENCE = 6 # 값이 evidence 안에 있는지까지 볼 필요가 없는 타입. -# bool 은 "true"/"false" 라 원문에 그 글자가 있을 리 없고, -# 부호 판정은 아래 _bool_ok 가 따로 본다. _SKIP_VALUE_CHECK = {"bool"} _FALSE_WORDS = ("false", "불가", "없음", "없슴", "미제공", "제공하지", "안됨", "안 됨", "불가능", "금지", "않습니다", "제한") @@ -38,29 +23,17 @@ _NUMBER_OK = re.compile(r"^\d+(\.\d+)?$") def normalize(text: str) -> str: - """대조용 정규화. 공백을 하나로, 특수 문장부호를 표준형으로.""" + """대조용 정규화.""" return _WS.sub(" ", (text or "").translate(_QUOTES)).strip().lower() def loose(text: str) -> str: - """값 대조 전용 — 공백과 자릿수 쉼표까지 지운다. - - ★ 왜 따로 두는가 (실측) - number 필드는 규칙상 "19000" 으로 오는데 원문 표기는 "19,000원" 이다. - normalize 만으로 대조하면 **모든 메뉴 가격이 '고쳐 쓴 값' 으로 반려된다** — - 속초항아리물회 홈페이지에서 가격 5건이 통째로 날아갔다. - 쉼표·공백은 같은 수를 다르게 적은 것일 뿐 값을 바꾸지 않으므로 여기서만 지운다. - ★ 글자는 여전히 지우지 않는다 — "불가" 가 사라지면 부호가 뒤집힌다. - """ + """값 대조 전용 — 공백과 자릿수 쉼표까지 지운다.""" return normalize(text).replace(",", "").replace(" ", "") def _bool_ok(value: str, evidence: str) -> tuple[bool, str]: - """bool 값의 부호가 근거 문장과 맞는가. - - ★ 부호가 뒤집히면 "반려동물 동반 불가" 가 "동반 가능" 이 된다 — - 숙박·음식점 모두 critical 항목이라 예약 클레임으로 직결된다. - """ + """bool 값의 부호가 근거 문장과 맞는가.""" v = value.strip().lower() if v not in ("true", "false"): return False, f"bool 필드인데 값이 'true'/'false' 가 아니다: {value!r}" @@ -69,7 +42,6 @@ def _bool_ok(value: str, evidence: str) -> tuple[bool, str]: has_false = any(w in ev for w in _FALSE_WORDS) has_true = any(w in ev for w in _TRUE_WORDS) - # 근거에 부정 표현이 있는데 true 로 올렸다면 뒤집힌 것이다. 그 반대도 같다. if v == "true" and has_false and not has_true: return False, "근거 문장은 부정인데 true 로 올렸다" if v == "false" and has_true and not has_false: @@ -83,13 +55,7 @@ def verify( source_text: str, schema: CategorySchema, ) -> tuple[list[dict], list[tuple[str, str]]]: - """추출 결과를 걸러 (통과, 반려) 로 나눈다. - - 반려는 조용히 버리지 않고 (항목, 사유) 로 남긴다 — - 운영자가 "왜 이 값이 안 들어왔나" 를 이 목록으로 읽는다. - - 통과한 dict 는 {key, value, scope, unit_name, evidence} 형태다. - """ + """추출 결과를 걸러 (통과, 반려) 로 나눈다.""" haystack = normalize(source_text) passed: list[dict] = [] rejected: list[tuple[str, str]] = [] @@ -102,8 +68,7 @@ def verify( evidence = str(row.get("evidence") or "").strip() label = f"{key}={value[:30]}" if key else "(key 없음)" - # 1) 업종 스키마에 있는 key 인가. ★ 없는 key 는 fact 기록 단계에서도 거부되지만, - # 여기서 먼저 걸러야 반려 사유가 남는다. + # 1) 업종 스키마에 있는 key 인가. spec = schema.get(key) if spec is None: rejected.append((label, f"업종 스키마에 없는 key: {key!r}")) @@ -112,14 +77,14 @@ def verify( rejected.append((label, "값이 비었다")) continue - # 2) 스코프 계약. unit 스코프인데 이름이 없으면 어느 객실 것인지 알 수 없다. + # 2) 스코프 계약. if spec.scope == "unit" and not unit_name: rejected.append((label, "unit 스코프인데 unit_name 이 없다")) continue if spec.scope == "place" and unit_name: unit_name = "" # place 스코프에 이름이 붙어 온 것은 무시하고 진행한다 - # 3) 근거 문장이 원문에 실제로 있는가. ★ 이 검사가 이 파일의 존재 이유다. + # 3) 근거 문장이 원문에 실제로 있는가. if len(evidence) < MIN_EVIDENCE: rejected.append((label, f"근거 문장이 너무 짧다({len(evidence)}자) — 대조할 수 없다")) continue @@ -140,8 +105,6 @@ def verify( value = value.replace(",", "") # 5) 값이 근거 안에 실제로 적혀 있는가(bool 제외). - # ★ 근거는 원문에서 가져왔는데 값이 그 안에 없다면, 값 쪽을 모델이 고쳐 쓴 것이다 - # — "오후 3시" 를 "15:00" 으로 환산한 경우가 여기서 걸린다. if spec.type not in _SKIP_VALUE_CHECK and loose(value) not in loose(evidence): rejected.append((label, "값이 근거 문장 안에 없다 — 원문 표기를 고쳐 쓴 것으로 본다")) continue diff --git a/solution/backend/services/grounding/itinerary.py b/solution/backend/services/grounding/itinerary.py index ed1b2c8..d488ec5 100644 --- a/solution/backend/services/grounding/itinerary.py +++ b/solution/backend/services/grounding/itinerary.py @@ -1,46 +1,11 @@ -"""일정 응답 해석 — 모델이 준 JSON 에서 **화면에 설 수 있는 코스만** 남긴다. - -★ 스키마 검증을 하지 않는다 - 항목 모양의 단일 출처는 `shared/lib/section-data.ts` 다. 그 모양을 파이썬에 한 벌 더 적으면 - 프론트가 필드를 하나 늘린 날 서버가 그걸 조용히 떨어뜨린다(`grounding/story.py` 와 같은 판단). - 여기서 보는 것은 셋뿐이다 — 이름이 있나 · 정거장이 하나라도 있나 · 앞 코스와 같은 코스인가. - -★ 같은 코스를 버린다 (2026-09-11 결정) - 정거장 **겹침은 허용**이다. 다만 정거장 집합이 완전히 같으면 순서만 바꾼 것이고, 손님 눈에는 - 같은 코스 둘이다. 프롬프트 규칙 7 로도 막지만 그건 부탁이지 보장이 아니다 — - 실제로 컨셉을 지정하기 전에는 5개 중 4개가 같은 집합이었다(스파이크 실측). - -★ duration 은 우리가 덮어쓴다 - 이 값이 화면 탭을 가른다(`ItinerarySection` 이 `duration` 으로 탭을 세운다). - 모델이 "반나절" 이라고 적어 버리면 1박 2일을 요청해 받은 코스가 엉뚱한 탭에 선다. - -★ 출처가 없어도 코스는 살린다 - 지역 이야기는 출처 없는 항목을 버린다 — 그건 '사실' 이라서다. 일정은 '제안' 이고, - 출처를 이유로 버리면 화면이 통째로 빈다. 대신 Perplexity 가 실제로 읽은 첫 출처를 붙여 준다. - -★ 하루 시작·종료 시각을 우리가 강제한다 (2026-09-11 결정) - 체크인 15시·체크아웃 11~14시라는 실제 숙박 흐름에 맞춰 `DAY_SCHEDULE`(기간별 하루 시작·종료)을 - 고정했다. 프롬프트로 이 시각표를 지시하지만(`prompts.itinerary._schedule_text`), duration 과 - 같은 이유로 **모델의 응답을 믿지 않는다** — 실측(2026-09-11)에서 컨셉 개수 지시도 안정적으로 - 안 지켜졌다. 그래서 `startTime`은 그대로 덮어쓰고, 종료 시각을 넘기는 정거장은 뒤에서부터 - 잘라낸다(`_apply_schedule`). 하루가 통째로 비면(첫 정거장부터 시간을 넘기면) 그 코스는 버린다 - — 반쪽짜리 하루를 빈 카드로 보여주지 않는다. - -★ 하루 시작·종료 시각을 우리가 강제한다 (2026-09-11 결정) - 체크인 15시·체크아웃 11~14시라는 실제 숙박 흐름에 맞춰 `DAY_SCHEDULE`(기간별 하루 시작·종료)을 - 고정했다. 프롬프트로 이 시각표를 지시하지만(`prompts.itinerary._schedule_text`), duration 과 - 같은 이유로 **모델의 응답을 믿지 않는다** — 실측(2026-09-11)에서 컨셉 개수 지시도 안정적으로 - 안 지켜졌다. 그래서 `startTime`은 그대로 덮어쓰고, 종료 시각을 넘기는 정거장은 뒤에서부터 - 잘라낸다(`_apply_schedule`). 하루가 통째로 비면(첫 정거장부터 시간을 넘기면) 그 코스는 버린다 - — 반쪽짜리 하루를 빈 카드로 보여주지 않는다. -""" +"""일정 응답 해석 — 모델이 준 JSON 에서 **화면에 설 수 있는 코스만** 남긴다.""" import json import re from common.logger import LOG from services.prompts.itinerary import DAY_SCHEDULE -# 코드펜스를 두르고 오는 경우가 있다. 규칙 1 로 금지했지만 모델은 종종 어긴다. +# 코드펜스를 두르고 오는 경우가 있다. _FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE) @@ -52,7 +17,7 @@ def _payload_text(payload: dict) -> str: def _first_source(payload: dict) -> dict | None: - """Perplexity 가 실제로 읽은 첫 출처. 코스에 source 가 없을 때의 대체값.""" + """Perplexity 가 실제로 읽은 첫 출처.""" for row in payload.get("search_results") or []: if isinstance(row, dict) and (row.get("url") or "").startswith("http"): return {"name": row.get("title") or row["url"], "url": row["url"]} @@ -60,8 +25,7 @@ def _first_source(payload: dict) -> dict | None: def _clean_source(value) -> dict | None: - """모델이 준 source. url 이 http 로 시작하지 않으면 없는 것으로 친다 — - "검색결과 참조" 같은 문자열이 그대로 링크가 되면 눌러도 아무 데도 안 간다.""" + """모델이 준 source.""" if not isinstance(value, dict): return None url = (value.get("url") or "").strip() @@ -77,8 +41,7 @@ def _minutes(hhmm: str) -> int: def _as_positive_int(value, default: int) -> int: - """모델이 준 분(minutes·moveMinutes)을 정수로. 못 읽으면 기본값 — 프론트 `planDay` 의 - `Math.max(1, stop.minutes ?? 60)` 과 같은 방어다.""" + """모델이 준 분(minutes·moveMinutes)을 정수로.""" try: return max(0, int(value)) except (TypeError, ValueError): @@ -86,11 +49,7 @@ def _as_positive_int(value, default: int) -> int: def _fit_stops(stops: list, start_minutes: int, end_minutes: int) -> list[dict]: - """정거장을 순서대로 태워 보고, 종료 시각을 넘기는 지점부터 잘라낸다. - - 프론트 shared `planDay()` 와 같은 산수다(시작 + 이동 + 머무는 시간). 여기서 먼저 잘라 - 두면 화면은 이미 맞는 시간표만 받는다 — 발행본이 21시 컷을 또 거는 건 이중 안전망일 뿐이다. - """ + """정거장을 순서대로 태워 보고, 종료 시각을 넘기는 지점부터 잘라낸다.""" clock = start_minutes kept = [] for stop in stops: @@ -107,15 +66,7 @@ def _fit_stops(stops: list, start_minutes: int, end_minutes: int) -> list[dict]: def _lodging_stop(place_name: str, place_lat: float | None, place_lng: float | None) -> dict: - """업소 자신을 정거장 모양으로. 모델이 지어낼 값이 아니다 — `places` 테이블 값 그대로다. - - ★ minutes·moveMinutes 는 0 이다. 여기 "머무는" 게 아니라 출발·복귀 지점일 뿐이고, - 실제 이동 시간은 이미 다음 정거장의 moveMinutes 에 있다(그게 "업소에서 나서는 시간"이다 — - prompts.itinerary 규칙). 복귀 쪽은 걸린 시간을 모르니 지어내지 않고 0으로 둔다 — - 대신 프롬프트가 종료 시각 30분 전에는 마지막 정거장을 끝내라고 미리 시킨다. - ★ 좌표는 **아는 곳만** 싣는다(`PlannerStop.latitude` 주석과 같은 규칙) — 업장에 좌표가 - 없으면 그 칸은 시간표에만 서고 지도에는 안 찍힌다. - """ + """업소 자신을 정거장 모양으로.""" stop = {"name": place_name, "minutes": 0, "moveMinutes": 0, "searchQuery": place_name} if place_lat is not None and place_lng is not None: stop["latitude"] = place_lat @@ -127,14 +78,7 @@ def _apply_schedule( course: dict, duration: str, place_name: str, place_lat: float | None, place_lng: float | None, ) -> dict | None: - """`course["days"]` 를 DAY_SCHEDULE 시각으로 강제하고, 못 맞추는 하루가 있으면 코스를 버린다. - - ★ startTime·label 은 그대로 덮어쓴다(모델이 뭐라 적든). 종료는 뒤 정거장을 잘라 맞춘다. - ★ 일수가 기간과 안 맞으면(둘째 날이 통째로 없다 등) 버린다 — 빈 날을 카드로 보여주지 않는다. - ★ 업소를 정거장 맨 앞(출발)에 넣는다. `returns` 인 날은 맨 뒤(복귀)에도 넣는다 - (2026-09-11 결정 — "하루 3~5곳" 규칙과는 별개로 얹는다. 모델이 고른 정거장 수를 - 세는 쪽(`_fit_stops`)은 이 값을 더하기 **전**의 것만 본다). - """ + """`course["days"]` 를 DAY_SCHEDULE 시각으로 강제하고, 못 맞추는 하루가 있으면 코스를 버린다.""" schedule = DAY_SCHEDULE[duration] raw_days = course.get("days") if not isinstance(raw_days, list): @@ -159,7 +103,7 @@ def _apply_schedule( new_days.append(day) if len(new_days) < len(schedule): - return None # 기간에 맞는 일수를 못 채웠다 + return None return {**course, "days": new_days} @@ -179,9 +123,7 @@ def _stop_names(course: dict) -> list[str]: def stop_signature(course: dict) -> frozenset[str]: - """코스의 정거장 집합 — 중복 판정 기준. 호출 하나를 넘어 여러 번의 재시도에 걸쳐 - 같은 코스를 다시 채택하지 않으려면, 이전에 채택한 코스의 signature 를 다음 호출의 - `already_seen` 에 실어 보내야 한다(`itinerary_llm_service` 가 그렇게 누적한다).""" + """코스의 정거장 집합 — 중복 판정 기준.""" return frozenset(_stop_names(course)) @@ -190,18 +132,7 @@ def parse_courses( place_lat: float | None = None, place_lng: float | None = None, already_seen: set[frozenset[str]] | None = None, ) -> tuple[list[dict], list[str]]: - """(쓸 수 있는 코스, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다. - - 한 코스가 잘못돼도 나머지를 살린다. 기간당 5개인데 한 줄 때문에 전부 버리면 - 그 업장은 다음 재생성까지 빈 채로 남는다. - - ★ place_name·place_lat·place_lng 는 업소를 정거장으로 넣을 때 쓴다(`_apply_schedule`) — - 모델에게 묻지 않는다. 좌표가 없으면(아직 지오코딩 전) 이름만 들어가고 핀은 안 찍힌다. - - ★ already_seen 은 **이전 호출**(재시도)에서 이미 채택한 코스들의 `stop_signature` 다. - 10개를 채우려고 같은 프롬프트로 다시 부르면 모델이 앞서 낸 것과 겹치는 코스를 또 - 낼 수 있다 — 이 payload 안에서만 중복을 보면 그걸 새로 채택해 버린다. - """ + """(쓸 수 있는 코스, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다.""" text = _FENCE_RE.sub("", _payload_text(payload)).strip() if not text: return [], ["응답이 비었다"] @@ -236,8 +167,7 @@ def parse_courses( course["name"] = name course["duration"] = duration - # ★ dedup 보다 먼저 적용한다 — 손님이 실제로 보는 것은 시각표를 통과한 뒤의 정거장이라, - # "같은 코스인가" 도 그 기준으로 판단해야 한다. + # dedup 보다 먼저 적용한다 — 손님이 실제로 보는 것은 시각표를 통과한 뒤의 정거장이라, "같은 코스인가" 도 그 기준으로 판단해야 한다. course = _apply_schedule(course, duration, place_name, place_lat, place_lng) if course is None: dropped.append(f"{name}: 하루 시각표를 못 채운다(정거장이 시간을 못 맞추거나 일수가 모자란다)") diff --git a/solution/backend/services/grounding/place_research.py b/solution/backend/services/grounding/place_research.py index 628863d..1f6f72c 100644 --- a/solution/backend/services/grounding/place_research.py +++ b/solution/backend/services/grounding/place_research.py @@ -1,16 +1,4 @@ -"""업소 조사 응답 해석 — 쓸 수 있는 항목만 남긴다. - -`grounding/story.py` 와 같은 규율이다. 다른 점은 하나: 여기서 나온 문장은 **화면에 그대로 -나가지 않고** 소개문 생성의 근거로만 쓰인다(`copy_service` 의 '수집 원문' 자리). 그래도 -출처를 똑같이 요구한다 — 근거가 거짓이면 그 근거로 쓴 문장도 거짓이고, ground_check 는 -"근거에 있는가" 만 보지 "근거가 참인가" 는 못 본다. - -★ 상호 대조를 한다 - story 와 결정적으로 다른 지점이다. 지역 이야기는 틀려도 "군산 이야기가 조금 부정확한" - 것이지만, 업소 조사가 틀리면 **남의 가게 이야기가 이 사장님 사이트의 소개문**이 된다 — - 이 레포에서 가장 비싼 실수다(`collect_service.discover_naver_place` 머리주석). - 그래서 출처 URL 이나 문장에 상호가 나타나지 않는 항목은 버린다. -""" +"""업소 조사 응답 해석 — 쓸 수 있는 항목만 남긴다.""" import json import re @@ -36,18 +24,13 @@ def _clean_source(value) -> dict | None: def _name_tokens(name: str) -> list[str]: - """상호를 대조에 쓸 조각으로. 쉼표·공백·가운뎃점으로 끊는다. - - ★ 통짜로 비교하면 안 된다 — '스테이,머뭄' 은 블로그에서 '스테이 머뭄' · '스테이머뭄' 으로 - 적힌다. 두 글자 이상인 조각이 하나라도 걸리면 같은 업소로 본다. - """ + """상호를 대조에 쓸 조각으로.""" parts = [p for p in re.split(r"[\s,·・/|]+", name or "") if len(p) >= 2] return parts or ([name] if name else []) def parse_items(payload: dict, place_name: str, limit: int) -> tuple[list[dict], list[str]]: - """(쓸 수 있는 항목, 버린 이유). 버린 이유는 로그와 잡 결과에 남긴다 — 조용히 버리면 - "조사가 부실한 것"과 "필터가 과한 것"을 구분할 수 없다.""" + """(쓸 수 있는 항목, 버린 이유).""" text = _FENCE_RE.sub("", _payload_text(payload)).strip() if not text: return [], ["응답이 비었다"] @@ -80,7 +63,7 @@ def parse_items(payload: dict, place_name: str, limit: int) -> tuple[list[dict], if not source: dropped.append(f"출처 없음: {sentence[:30]}") continue - # ★ 상호 대조(머리주석). 문장과 출처 주소·이름 어디에도 상호가 없으면 남의 가게다. + # 상호 대조(머리주석). haystack = f"{sentence} {source['url']} {source['name']}".lower() if not any(tok.lower() in haystack for tok in tokens): dropped.append(f"상호가 없다: {sentence[:30]}") diff --git a/solution/backend/services/grounding/story.py b/solution/backend/services/grounding/story.py index 161238a..fc20b3b 100644 --- a/solution/backend/services/grounding/story.py +++ b/solution/backend/services/grounding/story.py @@ -1,19 +1,4 @@ -"""지역 이야기 응답 해석 — 모델이 준 JSON 에서 **쓸 수 있는 항목만** 남긴다. - -★ 왜 스키마 검증을 하지 않나 - 항목 모양의 단일 출처는 `shared/lib/section-data.ts` 다. 그 모양을 파이썬에 한 벌 더 적으면, - 프론트가 필드를 하나 늘린 날 서버가 그걸 조용히 떨어뜨린다 — `site_payload._sections` 가 - 붙여넣기 아이템을 파싱하지 않는 것과 같은 이유다. - 그래서 여기서는 **그 항목이 화면에 설 수 있는가**만 본다: 종류마다 하나씩 있는 '이름 칸'. - -★ 출처는 두 곳에서 온다 - 모델이 항목에 단 `source` 가 1순위다. 그게 없으면 Perplexity 가 실제로 읽은 - `search_results` 의 첫 줄을 붙인다 — 모델 답변은 환각이 섞이지만 search_results 는 - 실제로 검색된 주소다(`grounding/channels.py` 와 같은 판단). - 둘 다 없으면 항목을 버린다. 출처 없는 사실은 이 레포의 규칙 위반이다. - ★ 예외가 하나 있다 — `_SEARCH_LINK_KINDS`. 거기 있는 종류는 **모델의 URL 을 아예 안 받고** - 제목으로 찾아가는 검색 링크를 코드가 만든다. -""" +"""지역 이야기 응답 해석 — 모델이 준 JSON 에서 **쓸 수 있는 항목만** 남긴다.""" import json import re from urllib.parse import quote @@ -31,23 +16,19 @@ _TITLE_KEY = { "quiz": "question", } -# ★ 출처를 **모델에게 받지 않고 코드가 만드는** 종류. -# 개별 문서 주소(`terms.naver.com/entry.naver?docId=…`)는 짐작해서 적으면 없는 문서로 -# 이어진다 — 레포 절대규칙(없는 사실을 짓지 않는다)에 걸린다. 검색 링크는 제목을 그대로 -# 넘기니 항상 관련 결과로 뜬다. 2026-09-14 대표 지시("확인필요 없애고 링크는 네이버 링크로"), -# 시연본 구현은 `site/scripts/mockup/build_reading.py` 의 `naver()`. +# 출처를 **모델에게 받지 않고 코드가 만드는** 종류. _SEARCH_LINK_KINDS = {"reading"} def _search_source(title: str, region_label: str) -> dict: - """제목으로 찾아가는 검색 링크. 지역명이 제목에 없으면 붙여 검색 정확도를 올린다.""" + """제목으로 찾아가는 검색 링크.""" region = (region_label or "").split()[-1] if region_label else "" query = title if (not region or region[:-1] in title or region in title) else f"{title} {region}" return {"name": "네이버에서 찾아보기", "url": "https://search.naver.com/search.naver?query=" + quote(query)} -# 코드펜스를 두르고 오는 경우가 있다. 규칙 1 로 금지했지만 모델은 종종 어긴다. +# 코드펜스를 두르고 오는 경우가 있다. _FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE) @@ -59,7 +40,7 @@ def _payload_text(payload: dict) -> str: def _first_source(payload: dict) -> dict | None: - """Perplexity 가 실제로 읽은 첫 출처. 항목에 source 가 없을 때의 대체값.""" + """Perplexity 가 실제로 읽은 첫 출처.""" for row in payload.get("search_results") or []: if isinstance(row, dict) and (row.get("url") or "").startswith("http"): return {"name": row.get("title") or row.get("url"), "url": row["url"]} @@ -67,8 +48,7 @@ def _first_source(payload: dict) -> dict | None: def _clean_source(value) -> dict | None: - """모델이 준 source. url 이 http 로 시작하지 않으면 없는 것으로 친다 — - "검색결과 참조" 같은 문자열이 그대로 링크가 되면 눌러도 아무 데도 안 간다.""" + """모델이 준 source.""" if not isinstance(value, dict): return None url = (value.get("url") or "").strip() @@ -80,11 +60,7 @@ def _clean_source(value) -> dict | None: def parse_items( payload: dict, kind: str, limit: int, region_label: str = "" ) -> tuple[list[dict], list[str]]: - """(쓸 수 있는 항목, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다. - - 한 항목이 잘못돼도 나머지를 살린다. 지역 하나에 8~14건인데 한 줄 때문에 전부 버리면 - 그 지역은 다음 재생성까지 빈 채로 남는다. - """ + """(쓸 수 있는 항목, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다.""" title_key = _TITLE_KEY.get(kind) if title_key is None: raise ValueError(f"모르는 지역 이야기 종류: {kind}") @@ -124,8 +100,7 @@ def parse_items( item[title_key] = title if kind in _SEARCH_LINK_KINDS: - # 출처는 코드가 만든다(위 _SEARCH_LINK_KINDS). 모델이 URL 을 적어 왔어도 버린다 — - # 짐작해 적은 주소가 섞이는 통로를 아예 남기지 않는다. 검수 배지도 함께 뺀다. + # 출처는 코드가 만든다(위 _SEARCH_LINK_KINDS). item["source"] = _search_source(title, region_label) item.pop("verified", None) out.append(item) @@ -137,8 +112,7 @@ def parse_items( continue item["source"] = source - # ★ 모델이 "확인" 이라고 우겨도, 대체 출처로 때운 항목은 확인필요다 — - # 그 URL 은 이 항목이 아니라 이번 검색 전체의 출처다. + # 모델이 "확인" 이라고 우겨도, 대체 출처로 때운 항목은 확인필요다 — 그 URL 은 이 항목이 아니라 이번 검색 전체의 출처다. if item.get("verified") not in ("확인", "확인필요"): item["verified"] = "확인필요" elif _clean_source(raw.get("source")) is None: diff --git a/solution/backend/services/indexnow.py b/solution/backend/services/indexnow.py index 74ba052..5cc69bf 100644 --- a/solution/backend/services/indexnow.py +++ b/solution/backend/services/indexnow.py @@ -1,23 +1,4 @@ -"""발행본 URL 을 IndexNow 로 알린다 — 크롤러가 찾아올 때까지 기다리지 않는다. - -★ 어디에 닿고 어디에 안 닿는지가 이 파일의 존재 이유다. - - 닿는다 네이버(2023-07 부터 지원) · Bing · Yandex · Seznam. - 네이버가 소상공인 검색 트래픽의 주력이라 여기가 핵심이고, - Bing 은 ChatGPT 검색의 상류라 AEO 로도 값이 있다. - - 안 닿는다 **구글**. 구글은 IndexNow 를 지원하지 않는다(2021 년부터 테스트만 하고 채택 안 함). - 구글 색인 요청 API(Indexing API)도 JobPosting·BroadcastEvent 전용이라 우리는 못 쓴다 — - URL 을 받아 200 을 주지만 그 밖의 타입은 그냥 버린다. - 구글 쪽은 Search Console 사이트맵 제출이 유일한 자동화 경로다. - -★ 보낼 URL 을 여기서 다시 계산하지 않는다. 사이트의 `sitemap.xml` 을 읽는다 — - 프리렌더가 실제로 구운 페이지 목록이 거기 있다. 라우트 규칙을 두 군데 두면 - 사이트맵에 없는 URL 을 통보하게 되고, 그건 404 통보라 신뢰만 깎는다. - -★ 실패해도 발행을 되돌리지 않는다. 색인 통보는 발행의 **부수 효과**다. - 여기서 예외를 올리면 정적 파일이 이미 올라간 뒤에 발행이 실패로 뒤집힌다. -""" +"""발행본 URL 을 IndexNow 로 알린다 — 크롤러가 찾아올 때까지 기다리지 않는다.""" import os import xml.etree.ElementTree as ET @@ -31,7 +12,7 @@ from common.logger import LOG ENDPOINT = "https://api.indexnow.org/indexnow" SITEMAP_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}" TIMEOUT_SEC = 10.0 -# 규격 상한은 한 번에 10,000 개다. 사이트 하나는 수십 개라 넉넉하다. +# 규격 상한은 한 번에 10,000 개다. MAX_URLS = 10_000 @@ -62,10 +43,7 @@ def site_urls(slug: str) -> list[str]: def _payload(urls: list[str]) -> dict | None: - """IndexNow 요청 본문. 호스트는 URL 에서 뽑는다(커스텀 도메인도 그대로 맞는다). - - 한 요청의 URL 은 전부 같은 호스트여야 한다(규격). 섞여 있으면 422 를 받으므로 - 첫 URL 의 호스트에 속한 것만 보낸다.""" + """IndexNow 요청 본문.""" host = urlsplit(urls[0]).netloc if not host: return None @@ -80,7 +58,7 @@ def _payload(urls: list[str]) -> dict | None: async def submit(slug: str) -> dict | None: - """설정된 경우에만 통보한다. 실패는 로그로 남기고 삼킨다(발행을 되돌리지 않는다).""" + """설정된 경우에만 통보한다.""" if not is_configured(): return None @@ -101,8 +79,7 @@ async def submit(slug: str) -> dict | None: LOG.w(f"[indexnow] 통보 실패 {slug}: {type(ex).__name__}: {ex}") return {"ok": False, "error": f"{type(ex).__name__}: {ex}", "urls": len(body['urlList'])} - # 200 OK · 202 Accepted 가 정상이다. 그 밖은 규격상 원인이 정해져 있다: - # 400 형식 · 403 키 불일치 · 422 호스트 불일치 · 429 과다 요청 + # 200 OK · 202 Accepted 가 정상이다. ok = res.status_code in (200, 202) if ok: LOG.i(f"[indexnow] {slug} — URL {len(body['urlList'])}개 통보 (HTTP {res.status_code})") diff --git a/solution/backend/services/intro_summary.py b/solution/backend/services/intro_summary.py index eb24caf..d439211 100644 --- a/solution/backend/services/intro_summary.py +++ b/solution/backend/services/intro_summary.py @@ -3,7 +3,7 @@ from services.external import gemini_text async def summarize_intro(key: str, value: str | None) -> str | None: - # 짧은 소개까지 유료 호출하지 않는다. 원문은 DB에 그대로 두고 응답만 보강한다. + # 짧은 소개까지 유료 호출하지 않는다. text = (value or "").strip() if key not in {"intro", "room_intro"} or len(text) <= 80: return None diff --git a/solution/backend/services/itinerary.py b/solution/backend/services/itinerary.py index 4fa3be3..c63d277 100644 --- a/solution/backend/services/itinerary.py +++ b/solution/backend/services/itinerary.py @@ -1,25 +1,12 @@ -"""여행 일정(1박2일·2박3일) 생성 — 순수 함수 모듈. DB·HTTP 없음. - -빌드(payload) 시점에 그 지역의 발행된 지역정보(관광지·맛집·축제)와 업체 좌표로 -일정을 **즉석 계산**한다. 저장하지 않는 이유: 재료(local_contents)가 갱신되면 -다음 빌드에서 일정도 저절로 최신이 된다 — 따로 저장하면 그 동기화를 또 만들어야 한다. -(docs/superpowers/specs/2026-09-03-local-tourapi-sync-design.md) - -★ 지어내지 않는 규칙은 여기도 적용된다. - 재료가 부족하면 채울 수 있는 만큼만 담고, 하루도 못 채우면 일정 자체를 내지 않는다. - 좌표 없는 항목은 거리를 잴 수 없으므로 후보에서 뺀다 — 동선을 보장 못 하는 추천은 틀린 추천이다. - -하루의 뼈대: 관광지 2 + 맛집 2(점심·저녁). 진행 중 축제가 있으면 그날 관광지 한 자리를 대신한다. -일자 배분은 업체에서 가까운 순으로 후보를 끊고, 일자 안에서는 최근접 이웃 순서로 동선을 만든다. -""" +"""여행 일정(1박2일·2박3일) 생성 — 순수 함수 모듈.""" from typing import Optional from common.utils.geo import haversine_km -# 하루 구성 정원. 관광지 자리는 축제가 하나 대신할 수 있다. +# 하루 구성 정원. _SPOTS_PER_DAY = 2 _MEALS_PER_DAY = 2 -# 후보 반경(km). 업체에서 이보다 먼 곳은 '근처'가 아니다 — 1박2일 생활권을 넘는다. +# 후보 반경(km). _MAX_RADIUS_KM = 30.0 _STOP_ATTRACTION = "attraction" @@ -36,11 +23,7 @@ def _as_float(value) -> Optional[float]: def _candidates(rows: list[dict], stop_type: str, base_lat: float, base_lng: float) -> list[dict]: - """payload 지역정보 행 → 거리 오름차순 후보. 좌표가 없거나 반경 밖이면 뺀다. - - 행 모양은 스냅샷 local.contents 항목이다. 좌표는 **항목 최상단**의 latitude/longitude 다 — - 2026-09-09 에 body 에서 컬럼으로 옮겼다(body 는 렌더러가 읽는 것만 담는다). - """ + """payload 지역정보 행 → 거리 오름차순 후보.""" out = [] for row in rows: body = row.get("body") or {} @@ -75,19 +58,14 @@ def _take(pool: list[dict], count: int) -> list[dict]: return taken -# 일자 이름. 화면이 탭 라벨로 그대로 쓴다. +# 일자 이름. _DAY_LABEL = {1: "첫째 날", 2: "둘째 날", 3: "셋째 날"} -# 며칠짜리인가 → 사람이 읽는 말. 화면은 이 값으로 일정을 가른다(ItineraryItem.duration). +# 며칠짜리인가 → 사람이 읽는 말. _DURATION = {2: "1박 2일", 3: "2박 3일"} def _to_stop(cand: dict) -> dict: - """후보 → 렌더러의 PlannerStop. - - ★ 머무는 시간·이동 시간은 **비운다.** 우리가 재지 않은 값이라, 넣으면 화면의 시각표가 - 지어낸 숫자 위에 세워진다. 화면은 없으면 자기 기본값으로 계산한다. - ★ URL 을 만들지 않는다 — 지도 검색어(searchQuery)만 준다(LocalPlace 와 같은 규약). - """ + """후보 → 렌더러의 PlannerStop.""" return { "name": cand["name"], "searchQuery": cand["name"], @@ -98,7 +76,7 @@ def _to_stop(cand: dict) -> dict: def _plan_days(days: int, attractions: list[dict], restaurants: list[dict], festivals: list[dict], base_lat: float, base_lng: float) -> Optional[dict]: - """일자별 계획. 첫날 하루도 못 채우면 None — 반쪽짜리 일정은 내지 않는다.""" + """일자별 계획.""" spots = list(attractions) meals = list(restaurants) fests = list(festivals) @@ -118,19 +96,14 @@ def _plan_days(days: int, attractions: list[dict], restaurants: list[dict], }) if not plan: return None - # ★ 렌더러 계약(`shared` ItineraryItem)의 모양으로 낸다. 예전에는 {days, plan[{day, stops}]} - # 라는 우리끼리의 모양이었고, 화면(ItinerarySection)은 그걸 못 읽어 **일정이 통째로 - # 안 나왔다** — 수집·계산은 다 됐는데 화면만 비어 있었다(실측 2026-09-09, 조이모텔). + # 렌더러 계약(`shared` ItineraryItem)의 모양으로 낸다. return {"name": _DURATION[days], "duration": _DURATION[days], "days": plan} def build_itineraries(base_lat: Optional[float], base_lng: Optional[float], attractions: list[dict], restaurants: list[dict], festivals: list[dict]) -> list[dict]: - """업체 좌표 기준 1박2일(2일)·2박3일(3일) 일정. 좌표가 없으면 빈 배열. - - 입력 행 모양은 스냅샷 local.contents 항목({title, latitude, longitude, body:{name, …}})이다. - """ + """업체 좌표 기준 1박2일(2일)·2박3일(3일) 일정.""" if base_lat is None or base_lng is None: return [] spot_pool = _candidates(attractions, _STOP_ATTRACTION, base_lat, base_lng) diff --git a/solution/backend/services/itinerary_llm_service.py b/solution/backend/services/itinerary_llm_service.py index 728701f..bdaa4df 100644 --- a/solution/backend/services/itinerary_llm_service.py +++ b/solution/backend/services/itinerary_llm_service.py @@ -1,21 +1,4 @@ -"""여행 일정 생성 — 업장 × 기간마다 한 번 부르고 그 결과를 그대로 쓴다. - -★ 왜 저장하나 - 예전 일정(services/itinerary.py)은 저장하지 않고 빌드마다 즉석 계산했다 — 거리 계산은 - 공짜니까 그게 맞았다. LLM 은 건당 20~50초·유료다. 매 빌드 재생성은 성립하지 않는다. - -★ 왜 에디터 요청 안에서 부르지 않나 - 두 기간 합쳐 50~100초다. 지역 이야기가 잡으로 도는 이유와 같다 — - "에디터가 화면을 그리려고 부른 요청 안에서 1분을 붙잡으면 화면이 멈춘 것으로 보인다" - (`local_content_service._ensure_region_stories`). 이 모듈은 **잡과 빌드에서만** 불린다. - -★ 기간 둘을 순차로 부른다 - 동시에 띄우면 같은 키로 나가는 호출이라 429 로 떨어진다 — 한 업장이 자기 자신을 막는다 - (story_service 가 다섯 종을 순차로 부르는 것과 같은 실측 근거). - -★ 실패는 예외로 올리지 않는다 - 일정은 업장의 사실이 아니라 곁들이는 정보다. 빌드도 에디터도 이것 때문에 멈추지 않는다. -""" +"""여행 일정 생성 — 업장 × 기간마다 한 번 부르고 그 결과를 그대로 쓴다.""" import uuid import httpx @@ -30,27 +13,20 @@ from services.grounding import itinerary as grounding from services.llm import perplexity from services.prompts import itinerary as prompts -# ★ 이야기(240초)와 같은 값이다. 2박 3일이 48초까지 갔고 변동이 크다(실측 2026-09-11). +# 이야기(240초)와 같은 값이다. _TIMEOUT = httpx.Timeout(240.0, connect=10.0) -# ★ 10개(컨셉당 2개) — 프롬프트가 요구하는 개수와 같다(`prompts.itinerary._TASK`). 프롬프트만으로는 -# 보장이 안 돼(모델이 5개로 회귀할 때가 있다, 위 파일 주석 참고) 여기서 재시도로 채운다. +# 10개(컨셉당 2개) — 프롬프트가 요구하는 개수와 같다(`prompts.itinerary._TASK`). TARGET_COURSES = 10 -# ★ 사장님 지시(2026-09-14): 2회로 제한한다. 늘릴수록 10개를 채울 확률은 오르지만 건당 -# 20~50초가 배로 늘어난다 — 못 채우면 채운 만큼만 저장하고 note 로 남긴다(아래 _generate_one). +# 사장님 지시: 2회로 제한한다. MAX_ATTEMPTS = 2 _CRUD = PlaceItineraryCRUD() def region_label_of(place) -> str: - """프롬프트에 넣을 지명("전북특별자치도 군산시"). - - ★ 주소 앞 두 토막이 사람이 부르는 이름이다. 주소가 없으면 빈 문자열이고, - 그때는 **부르지 않는다** — 지역을 모른 채 물으면 모델이 아무 도시나 고른다 - (`story_service.region_label_of` 와 같은 판단). - """ + """프롬프트에 넣을 지명("전북특별자치도 군산시").""" address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip() if not address: return "" @@ -59,7 +35,7 @@ def region_label_of(place) -> str: async def _stored(place_id) -> dict[str, list]: - """기간 → 코스 목록. 읽지 못하면 빈 dict(모르는 상태로 유료 호출을 걸지 않는다).""" + """기간 → 코스 목록.""" err, rows = await DB_SESSION_MNG.execute_lambda( place_itineraries.DBType(), DBWRType.DB_READ.value, lambda s: _CRUD.list_by_place(s, place_id), @@ -75,19 +51,13 @@ async def _stored(place_id) -> dict[str, list]: async def missing_durations(place_id) -> list[str]: - """아직 없는 기간. 잡 가드(`_ensure_region_stories`)와 생성이 같은 기준을 본다. - - ★ "한 행이라도 있으면 건너뛴다" 로 쓰지 않는다. 기간이 늘어난 날 기존 업장이 옛 목록에 - 멈춘다 — story_service 가 `has_stories` 하나로 판단하다 `daily` 를 영영 못 받던 것과 - 같은 함정이다(`story_service.missing_kinds` 주석). - """ + """아직 없는 기간.""" have = await _stored(place_id) return [d for d in prompts.DURATIONS if not have.get(d)] async def get_itineraries(place_id) -> list[dict]: - """저장된 코스 전부. **DURATIONS 순**으로 이어 붙인다 — - 화면 탭 순서(`ItinerarySection` 은 적힌 순서를 탭 순서로 쓴다)가 저장 순서에 흔들리면 안 된다.""" + """저장된 코스 전부.""" have = await _stored(place_id) out: list[dict] = [] for duration in prompts.DURATIONS: @@ -106,14 +76,7 @@ async def _generate_one( client: httpx.AsyncClient, place_name: str, region: str, duration: str, place_lat: float | None = None, place_lng: float | None = None, ): - """기간 하나. 실패는 예외로 올리지 않고 (코스 목록, 이유) 로 돌려준다. - - ★ TARGET_COURSES 개를 채울 때까지 같은 프롬프트로 최대 MAX_ATTEMPTS 번 다시 부른다 — - 한 번의 호출로 10개가 안정적으로 안 나온다(`prompts.itinerary` 실측 주석). 이전 시도에서 - 이미 채택한 코스는 `already_seen` 으로 다음 시도에 넘겨, 재시도가 같은 코스를 또 - 채택해 개수만 부풀리지 않게 한다(`grounding.stop_signature`). - ★ place_lat·place_lng 는 업소를 정거장(출발·복귀)으로 넣을 때 쓴다(`grounding.parse_courses`) — - 모델에게 묻지 않는다. 없으면 이름만 들어가고 지도 핀은 안 찍힌다.""" + """기간 하나.""" body = { "model": perplexity.DEFAULT_MODEL, "messages": [ @@ -166,11 +129,7 @@ async def _generate_one( async def ensure_generated(place) -> dict: - """없는 기간만 만들어 저장한다. 기간별 코스 수를 돌려준다. - - ★ 이미 있는 기간은 부르지 않는다 — 같은 업체는 그대로 재사용한다(2026-09-11 결정). - ★ 기존 행을 먼저 지우지 않는다. 이번 호출이 부실하다고 지난번 결과를 날리지 않는다. - """ + """없는 기간만 만들어 저장한다.""" place_id = getattr(place, "place_id", None) result: dict = {"place_id": str(place_id) if place_id else None, "counts": {}, "notes": []} if place_id is None: @@ -225,15 +184,10 @@ async def ensure_generated(place) -> dict: async def ensure_generated_by_id(place_id) -> dict: - """잡이 쓰는 입구 — payload 에는 place_id 만 있다. - - ★ 주인(owner_user_id)으로 스코프하지 않는다. 잡은 이미 그 업장의 빌드/수집을 하는 중이고, - 여기서 주인을 요구하면 잡 payload 에 주인을 실어 보내야 한다(`sync_place_by_id` 와 같은 판단). - """ + """잡이 쓰는 입구 — payload 에는 place_id 만 있다.""" from sqlalchemy import select - # ★ `local_content_service._load_place` 와 같은 방식이다 — 단건 조회 헬퍼는 없고 - # execute(...).limit(1) 로 받아 첫 행을 쓴다. + # `local_content_service._load_place` 와 같은 방식이다 — 단건 조회 헬퍼는 없고 execute(...).limit(1) 로 받아 첫 행을 쓴다. err, rows = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute( diff --git a/solution/backend/services/job_progress.py b/solution/backend/services/job_progress.py index f392164..f08d103 100644 --- a/solution/backend/services/job_progress.py +++ b/solution/backend/services/job_progress.py @@ -1,4 +1,4 @@ -"""작업 단계 기록. 실행 순서는 도메인 서비스, 화면 문구는 프론트가 소유한다.""" +"""작업 단계 기록.""" from contextlib import asynccontextmanager from crud.job_crud import JobQueue diff --git a/solution/backend/services/job_service.py b/solution/backend/services/job_service.py index 6dd1600..a9b8e9f 100644 --- a/solution/backend/services/job_service.py +++ b/solution/backend/services/job_service.py @@ -8,25 +8,13 @@ from router.v1.job.protocol import JobData, Res_Job, Res_JobOps class JobService: - """작업 큐 조회·관리. 적재는 각 도메인 서비스가 JobQueue 로 직접 한다. - - 수집·비전분석·빌드는 몇 분 걸린다 — API 는 잡만 넣고 즉시 응답하고, - 클라이언트는 GET /v1/job/{id} 를 폴링한다.""" + """작업 큐 조회·관리.""" def __init__(self, queue: JobQueue = Depends(JobQueue)): self.queue = queue async def get_job(self, job_id: str, user_info: UserInfo | None = None) -> Res_Job: - """잡 단건. ★ 주인이 아니면 **없는 것으로** 답한다(JOB_NOT_FOUND). - - ★ 잡 id 하나만 알면 남의 작업 결과가 열렸다(실측 2026-09-15). BUILD 결과에는 - site_id · 게이트 상세(비어 있는 필수 항목 목록) · payload 경로가 들어 있다. - 예전에는 COPY 만 주인을 봤는데, 가려야 할 것은 잡 종류가 아니라 **남의 사업장**이다. - ★ 주인을 알 수 없는 잡(내부 동기화·노래 등 payload 에 owner_user_id 가 없는 것)은 - 사장님에게 열지 않는다 — '주인이 없으니 아무나' 가 아니라 '확인할 수 없으니 닫는다' 다. - ★ user_info 가 None 인 호출은 내부 경로다(운영자 전용 requeue) — 그쪽은 이미 - RequireDeveloper 가 막는다. - """ + """잡 단건.""" res = Res_Job() row = await self.queue.get(job_id) if row is None: @@ -39,8 +27,7 @@ class JobService: res.result.SetResult(ErrorType.JOB_NOT_FOUND) return res fields = {k: v for k, v in row.items() if k in JobData.model_fields} - # 어느 사업장의 작업인지는 종류를 가리지 않고 싣는다 — 화면이 "이 화면의 작업이 맞나" 를 - # 이 값으로 판단한다(useGenerationJob 의 wrongJob). + # 어느 사업장의 작업인지는 종류를 가리지 않고 싣는다 — 화면이 "이 화면의 작업이 맞나" 를 이 값으로 판단한다(useGenerationJob 의 wrongJob). if payload.get("place_id") is not None: fields["place_id"] = payload.get("place_id") res.job = JobData(**fields) @@ -74,10 +61,7 @@ async def enqueue_job( dedupe_key: str | None = None, priority: int = 100, ) -> tuple[str | None, bool]: - """도메인 서비스가 잡을 넣을 때 쓰는 공용 진입점. - - 반환: (job_id, 새로 만들었는가). 활성 중복이면 기존 잡의 id 와 False 를 돌려준다 — - 같은 사업장 수집을 두 번 눌러도 잡이 두 번 돌지 않는다.""" + """도메인 서비스가 잡을 넣을 때 쓰는 공용 진입점.""" job_id = await queue.enqueue(job_type.value, payload, priority=priority, dedupe_key=dedupe_key) if job_id is not None: return job_id, True diff --git a/solution/backend/services/kakao_link_service.py b/solution/backend/services/kakao_link_service.py index 211343c..acad180 100644 --- a/solution/backend/services/kakao_link_service.py +++ b/solution/backend/services/kakao_link_service.py @@ -1,13 +1,4 @@ -"""카카오톡 채널 발화자를 우리 user_id 에 묶는다 — 에이전트의 모든 도구가 이 매핑 위에 선다. - -★ 이 파일이 없으면 채널 진입점만 소유자 범위 밖에 놓인다. 다른 엔드포인트는 전부 - place_crud.get_place(s, owner_user_id, place_id) 로 "없는 것과 남의 것을 똑같이 - PLACE_NOT_FOUND 로" 답하는데, 채널에서 온 발화에는 그 owner_user_id 를 줄 근거가 - 없다 — 카카오가 주는 것은 **채널 단위 익명 키**뿐이다. - -★ 일회성은 코드 값이 아니라 `WHERE status='PENDING'` CAS 가 보장한다. 조회 후 갱신으로 - 나누면 같은 코드가 두 번 먹는다(승인 흐름이 같은 이유로 한 문장이다). -""" +"""카카오톡 채널 발화자를 우리 user_id 에 묶는다 — 에이전트의 모든 도구가 이 매핑 위에 선다.""" import hashlib import secrets @@ -21,14 +12,13 @@ from common.database.model.models import owner_kakao_links as Link from common.enums import KakaoLinkStatus from config import agent_config as config -# 사장님이 카톡 대화창에 손으로 친다. 혼동하는 글자(0·O·1·I·L)는 뺀다 — -# 잘못 읽어 실패하면 원인이 화면에 안 보이고 "연결이 안 된다" 로만 보인다. +# 사장님이 카톡 대화창에 손으로 친다. _CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" _CODE_LENGTH = 6 class KakaoLinkError(RuntimeError): - """도메인 예외. 코드 문자열만 담고 HTTP 변환은 라우터가 한다(social 과 같은 규약).""" + """도메인 예외.""" def __init__(self, code="KAKAO_LINK_FAILED"): super().__init__(code) @@ -51,10 +41,7 @@ def _new_code() -> str: async def _lock_user(s, user_id): - """연결·재발급·해제가 같은 잠금을 공유한다(social_account_service.lock_user 와 같은 방식). - - 행 잠금이 아니라 advisory 인 이유: PENDING 행이 아직 없을 수도 있어서, 잠글 행 자체가 - 없는 순간이 존재한다.""" + """연결·재발급·해제가 같은 잠금을 공유한다(social_account_service.lock_user 와 같은 방식).""" await s.execute( text("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))"), {"key": f"kakao_link:{user_id}"}, @@ -74,7 +61,7 @@ async def _active(s, user_id): async def state(user_id: UUID) -> dict: - """빌더 카드가 읽는 값. ★ 코드 평문은 여기서 절대 돌려주지 않는다 — 발급 응답에서 한 번만 준다.""" + """빌더 카드가 읽는 값.""" async def run(s): row = await _active(s, user_id) @@ -94,10 +81,7 @@ async def state(user_id: UUID) -> dict: async def issue_code(user_id: UUID) -> dict: - """일회용 코드를 낸다. 이미 PENDING 이면 **같은 행의 코드만 교체**한다. - - ★ 행을 새로 만들지 않는 이유는 uq_kakao_link_user 때문만이 아니다 — 사장님이 버튼을 - 두 번 눌렀을 때 옛 코드가 살아 있으면, 둘 중 어느 것이 먹을지 화면이 말해 줄 수 없다.""" + """일회용 코드를 낸다.""" if not enabled(): raise KakaoLinkError("KAKAO_LINK_DISABLED") @@ -121,17 +105,12 @@ async def issue_code(user_id: UUID) -> dict: async def redeem(code: str, channel_user_key: str) -> UUID: - """채널에서 들어온 코드를 소비하고 user_id 를 돌려준다. 실패는 전부 같은 에러다. - - ★ "없는 코드" 와 "남의 코드" 와 "만료" 를 구분해 답하지 않는다 — 구분해 주면 짧은 - 코드의 유효성을 외부에서 탐색할 수 있다. - ★ 아직 공개 엔드포인트가 아니다. 채널 웹훅(4단계)이 이 함수를 부르고, 그 웹훅은 - 자체 서명 검증을 따로 갖춰야 한다.""" + """채널에서 들어온 코드를 소비하고 user_id 를 돌려준다.""" sha = _sha(code) max_attempts = int(config.get("KAKAO_LINK_MAX_ATTEMPTS", 5)) async def run(s): - # ★ 한 문장 CAS. 조회 후 갱신으로 나누면 같은 코드가 두 번 먹는다. + # 한 문장 CAS. row = ( await s.execute( text("""UPDATE owner_kakao_links @@ -144,7 +123,7 @@ async def redeem(code: str, channel_user_key: str) -> UUID: ) ).first() if row is None: - # 맞는 코드가 없으면 셀 행도 없다. 있는 코드에 대한 오입력만 세어진다. + # 맞는 코드가 없으면 셀 행도 없다. await s.execute( text("""UPDATE owner_kakao_links SET code_attempts = code_attempts + 1, updated_at=now() WHERE code_sha=:sha AND deleted=false AND status='PENDING'"""), @@ -157,9 +136,7 @@ async def redeem(code: str, channel_user_key: str) -> UUID: async def resolve(channel_user_key: str) -> UUID | None: - """채널 발화자 → user_id. 매핑이 없으면 None 이고, 호출측은 거기서 멈춰야 한다. - - ★ None 을 "아무 사장님" 으로 흘려보내면 이 기능 전체가 무의미해진다.""" + """채널 발화자 → user_id.""" async def run(s): row = ( @@ -180,9 +157,7 @@ async def resolve(channel_user_key: str) -> UUID | None: async def disconnect(user_id: UUID) -> None: - """연결을 끊는다. 행은 REVOKED 로 남긴다 — 지우면 누가 언제 연결했는지가 사라진다. - - ★ channel_user_key 도 남긴다. 부분 유니크가 status='LINKED' 조건이라 재연결을 막지 않는다.""" + """연결을 끊는다.""" async def run(s): await _lock_user(s, user_id) diff --git a/solution/backend/services/llm/__init__.py b/solution/backend/services/llm/__init__.py index 05dc9eb..7527464 100644 --- a/solution/backend/services/llm/__init__.py +++ b/solution/backend/services/llm/__init__.py @@ -1,23 +1 @@ -"""LLM 이 들어가는 자리. - -★ 규칙: **이 패키지 밖에서 LLM API 를 직접 부르지 않는다.** - `generativelanguage.googleapis.com` · `api.perplexity.ai` 같은 주소가 다른 파일에 - 나타나면 그건 이 규칙을 어긴 것이다. 한 곳에만 있어야 재시도·타임아웃·인증·비용을 - 한 번만 고치면 되고, "우리가 LLM 을 몇 번 부르는가"를 세는 자리도 하나가 된다. - - 실제로 한때 Gemini 호출 코드가 vision 용·copy 용 두 벌로 복사돼 있었다. - 타임아웃을 바꾸려면 두 곳을 찾아야 했고, 한쪽만 고치면 조용히 갈렸다. - -★ LLM 기능 하나는 네 겹으로 나뉜다. 고칠 것이 생기면 **그 겹으로 바로 간다**: - - 무엇을 묻는가 services/prompts/<기능>.py 프롬프트·응답 스키마 - 어떻게 부르는가 services/llm/<제공자>.py ← 여기 (HTTP·재시도·비용) - 답을 어떻게 믿는가 services/grounding/<기능>.py 근거 검증·필터 - 무엇을 돌려주는가 services/external/<기능>.py 위 셋을 엮어 결과를 만든다 - - 예) "FAQ 답이 이상하다" 는 물음은 이렇게 갈린다 — - 질문을 잘못 시켰나 → prompts/copy.py - 멀쩡한 답이 반려됐나 → grounding/copy.py - 호출이 실패·지연되나 → llm/gemini.py - 결과를 잘못 엮었나 → external/gemini_text.py -""" +"""LLM 이 들어가는 자리.""" diff --git a/solution/backend/services/llm/errors.py b/solution/backend/services/llm/errors.py index 1e1224e..3c651db 100644 --- a/solution/backend/services/llm/errors.py +++ b/solution/backend/services/llm/errors.py @@ -1,7 +1,4 @@ -"""공급자 무관 LLM 예외. Gemini·OpenAI 구현이 둘 다 이 클래스를 던진다. - -★ 이름을 'Llm*'으로 새로 지었지만 services/llm/gemini.py 가 GeminiError = LlmError 식으로 - 같은 클래스를 재노출한다 — 기존 6개 파일의 `except GeminiNotConfigured` 는 한 글자도 안 바뀐다.""" +"""공급자 무관 LLM 예외.""" class LlmError(RuntimeError): diff --git a/solution/backend/services/llm/gemini.py b/solution/backend/services/llm/gemini.py index 0846c11..f4efbe7 100644 --- a/solution/backend/services/llm/gemini.py +++ b/solution/backend/services/llm/gemini.py @@ -1,12 +1,4 @@ -"""Gemini 호출 — 이 프로젝트에서 Gemini 로 나가는 **유일한 통로**. - -사진 분석(services/external/gemini.py)도 소개문·FAQ 생성(services/external/gemini_text.py)도 -전부 여기를 통한다. 두 기능이 각자 HTTP 코드를 들고 있던 시절에는 `_post` 와 `_extract_text` 가 -글자 그대로 두 벌 복사돼 있었고, 타임아웃·재시도·인증 처리를 고치려면 두 곳을 다 찾아야 했다. - -여기가 책임지는 것: 주소·인증 헤더·재시도·응답 파싱·토큰 집계·비용 계산. -여기가 책임지지 않는 것: 무엇을 물을지(services/prompts/), 답을 믿을지(services/grounding/). -""" +"""Gemini 호출 — 이 프로젝트에서 Gemini 로 나가는 **유일한 통로**.""" import asyncio import base64 import json @@ -24,16 +16,16 @@ _BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models" DEFAULT_MODEL = "gemini-3.7-flash" -# 100만 토큰당 USD. 모르는 모델은 0 으로 잡는다 — 비용을 지어내느니 0 이 낫다(로그가 이상하면 눈에 띈다). +# 100만 토큰당 USD. _PRICE_PER_1M_INPUT = {"gemini-3.7-flash": 0.75, "gemini-3.6-flash": 0.75, "gemini-2.5-flash": 0.30} _PRICE_PER_1M_OUTPUT = {"gemini-3.7-flash": 3.75, "gemini-3.6-flash": 3.75, "gemini-2.5-flash": 2.50} -# 일시적 장애만 재시도한다. 4xx 는 요청 자체가 잘못된 것이라 다시 보내도 같다. +# 일시적 장애만 재시도한다. _RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504} def is_configured() -> bool: - """키가 있는지 — 어댑터 등록/스킵 판단용. 예외를 던지지 않는다.""" + """키가 있는지 — 어댑터 등록/스킵 판단용.""" return bool(external_api_config.gemini_api_key) @@ -43,14 +35,7 @@ async def call( body: dict, max_retries: int = 2, ) -> dict: - """★ LLM 이 실제로 불리는 지점. generateContent 1회 + 지수 백오프 재시도. - - 재시도는 5xx·429·타임아웃만 한다. 401/403 은 키 문제이므로 GeminiNotConfigured 로 - 구분해 올린다 — 호출측이 "설정이 없어서 못 한 것"과 "불렀는데 실패한 것"을 다르게 다룬다. - - 응답 본문(dict)을 그대로 돌려준다. 해석은 부르는 쪽 몫이다 — 사진 분석과 문장 생성이 - 같은 응답 구조에서 서로 다른 것을 꺼내 쓰기 때문이다. - """ + """LLM 이 실제로 불리는 지점.""" url = f"{_BASE_URL}/{model}:generateContent" headers = {"Content-Type": "application/json", "x-goog-api-key": external_api_config.gemini_api_key} last = None @@ -77,10 +62,7 @@ async def call( def extract_text(payload: dict) -> str: - """응답에서 텍스트 파트만 이어붙인다. - - ★ 파트에 thoughtSignature 가 함께 실려 오므로(실호출에서 확인) text 키가 있는 것만 고른다. - 전부 이어붙이면 모델의 사고 흔적이 결과 문자열에 섞인다.""" + """응답에서 텍스트 파트만 이어붙인다.""" candidates = payload.get("candidates") or [] if not candidates: raise GeminiInvalidOutput("candidates 가 비었다(안전 필터 차단 가능)") @@ -92,7 +74,7 @@ def extract_text(payload: dict) -> str: def read_usage(payload: dict) -> Usage: - """응답의 usageMetadata → Usage. 없으면 0 이다(과금 안 된 호출도 있다).""" + """응답의 usageMetadata → Usage.""" meta = payload.get("usageMetadata") or {} return Usage( input_tokens=int(meta.get("promptTokenCount") or 0), @@ -101,7 +83,7 @@ def read_usage(payload: dict) -> Usage: def price(model: str, usage: Usage) -> float: - """USD. 로그에만 쓴다 — 과금 근거가 아니라 "이 잡이 얼마짜리였나"를 눈으로 보는 값이다.""" + """USD.""" inp = _PRICE_PER_1M_INPUT.get(model, 0.0) * usage.input_tokens / 1_000_000 out = _PRICE_PER_1M_OUTPUT.get(model, 0.0) * usage.output_tokens / 1_000_000 return round(inp + out, 4) @@ -117,7 +99,7 @@ async def generate( temperature: float = 0.2, max_retries: int = 2, ) -> LlmResult: - """공급자 무관 인터페이스. services/llm/openai.py 가 같은 시그니처로 구현한다.""" + """공급자 무관 인터페이스.""" parts: list[dict] = [{"text": prompt}] for image in images or []: if image.label: diff --git a/solution/backend/services/llm/openai.py b/solution/backend/services/llm/openai.py index 90e3d19..b1f7614 100644 --- a/solution/backend/services/llm/openai.py +++ b/solution/backend/services/llm/openai.py @@ -1,7 +1,4 @@ -"""OpenAI Chat Completions 호출 — services/llm/gemini.py 와 같은 자리, 다른 공급자. - -여기가 책임지는 것: 주소·인증 헤더·재시도·구조화 출력 스키마 변환·응답 파싱·토큰 집계·비용 계산. -여기가 책임지지 않는 것: 무엇을 물을지(services/prompts/), 답을 믿을지(services/grounding/).""" +"""OpenAI Chat Completions 호출 — services/llm/gemini.py 와 같은 자리, 다른 공급자.""" import asyncio import base64 import json @@ -27,11 +24,7 @@ def is_configured() -> bool: def _to_strict_schema(schema: dict) -> dict: - """OpenAI strict 모드 요구사항(모든 object 에 additionalProperties:false, - 모든 property 가 required)을 만족하도록 재귀 변환한다. - - ★ Gemini용 RESPONSE_SCHEMA(OpenAPI 서브셋, services/prompts/*.py)를 그대로 받아 - 변환한다 — 두 벌 관리하지 않는다.""" + """OpenAI strict 모드 요구사항(모든 object 에 additionalProperties:false, 모든 property 가 required)을 만족하도록 재귀 변환한다.""" schema = dict(schema) if schema.get("type") == "object" and "properties" in schema: schema["properties"] = {k: _to_strict_schema(v) for k, v in schema["properties"].items()} @@ -62,9 +55,6 @@ async def generate( temperature: float = 0.2, max_retries: int = 2, ) -> LlmResult: - # ★ 실측(2026-09-16): gpt-5.6-luna 는 temperature 커스텀 값을 거부한다 - # ("Only the default (1) value is supported" — 400). Gemini 와 달리 이 파라미터를 - # 그냥 안 보낸다 — 공급자가 강제하는 값이라 우리가 흉내 낼 방법이 없다. body: dict = { "model": model, "messages": _build_messages(prompt, images), diff --git a/solution/backend/services/llm/perplexity.py b/solution/backend/services/llm/perplexity.py index 90f3ed7..f792b96 100644 --- a/solution/backend/services/llm/perplexity.py +++ b/solution/backend/services/llm/perplexity.py @@ -1,13 +1,4 @@ -"""Perplexity 호출 — 이 프로젝트에서 Perplexity 로 나가는 **유일한 통로**. - -여기가 책임지는 것: 주소·인증 헤더·타임아웃·응답 파싱. -여기가 책임지지 않는 것: 무엇을 물을지(services/prompts/channel_discovery.py), -답으로 받은 URL 을 믿을지(services/grounding/channels.py). - -★ Perplexity 답변을 **사실로 쓰지 않는다.** 이 어댑터의 산출물은 "여기를 보라"는 URL 포인터일 - 뿐이고, 실제 사실은 그 URL 을 크롤링해서 얻는다. 그래서 응답 원문(raw)을 통째로 박제해 - 나중에 환각을 추적할 수 있게 한다. -""" +"""Perplexity 호출 — 이 프로젝트에서 Perplexity 로 나가는 **유일한 통로**.""" import json from dataclasses import dataclass @@ -25,28 +16,21 @@ DEFAULT_MAX_TOKENS = 2048 class PerplexityError(RuntimeError): - """Perplexity 호출 실패. 호출측(collector)은 ErrorType.COLLECT_FETCH_FAILED 로 매핑한다.""" + """Perplexity 호출 실패.""" class PerplexityNotConfigured(PerplexityError): - """PERPLEXITY_API_KEY 미설정 — 이 어댑터만 비활성. **서버 부팅을 막지 않는다.**""" + """PERPLEXITY_API_KEY 미설정 — 이 어댑터만 비활성.""" def is_configured() -> bool: - """키가 설정돼 있는지. 어댑터 등록 여부를 판단할 때 쓴다.""" + """키가 설정돼 있는지.""" return bool((external_api_config.perplexity_api_key or "").strip()) @dataclass class Usage: - """호출 1회의 실제 사용량·비용. - - ★ cost 는 우리가 계산한 값이 아니라 Perplexity 가 응답에 직접 실어주는 실제 청구액(USD)이다 - (`usage.cost.total_cost`) — 검색 컨텍스트 요금(request_cost)까지 포함된 진짜 값이라, - Gemini·OpenAI 처럼 토큰 단가표로 역산하는 것보다 정확하다. - ★ 실측(2026-09-16): `usage.cost` 는 평평한 숫자가 아니라 - `{"input_tokens_cost", "output_tokens_cost", "request_cost", "total_cost"}` 객체다 — - 문서 예시(평평한 숫자)와 다르다. num_search_queries 는 비용은 아니지만 검색 남용 감시용으로 같이 둔다.""" + """호출 1회의 실제 사용량·비용.""" input_tokens: int = 0 output_tokens: int = 0 @@ -55,7 +39,7 @@ class Usage: def read_usage(payload: dict) -> Usage: - """응답의 usage 를 읽는다. 필드가 없거나 모양이 다르면 0 — 계측 실패가 본 기능을 막으면 안 된다.""" + """응답의 usage 를 읽는다.""" u = payload.get("usage") or {} cost_field = u.get("cost") if isinstance(cost_field, dict): @@ -71,14 +55,7 @@ def read_usage(payload: dict) -> Usage: async def call(body: dict, *, client: httpx.AsyncClient | None = None) -> dict: - """★ LLM 이 실제로 불리는 지점. chat/completions 1회. - - Gemini 쪽(services/llm/gemini.py)과 달리 재시도하지 않는다 — 이 호출은 검색을 동반해 - 한 번이 비싸고 느리다. 실패하면 수집 파이프라인이 등록된 링크로 그냥 진행한다 - (services/collect_service.py 의 discover_links 참조). - - 응답 본문(dict)을 그대로 돌려준다. 해석은 부르는 쪽 몫이다. - """ + """LLM 이 실제로 불리는 지점.""" api_key = (external_api_config.perplexity_api_key or "").strip() if not api_key: raise PerplexityNotConfigured("PERPLEXITY_API_KEY 미설정 — 채널 발견을 건너뛴다") diff --git a/solution/backend/services/llm/provider.py b/solution/backend/services/llm/provider.py index 3e9b23c..a90a9bb 100644 --- a/solution/backend/services/llm/provider.py +++ b/solution/backend/services/llm/provider.py @@ -1,7 +1,4 @@ -"""LLM_PROVIDER 설정으로 gemini/openai 구현 중 하나를 고른다. - -★ 모르는 값은 gemini 로 떨어진다 — 오타 하나로 사진분류·소개문·FAQ 가 전부 - 조용히 꺼지는 것보다, 기존에 검증된 공급자로 계속 도는 쪽이 안전하다.""" +"""LLM_PROVIDER 설정으로 gemini/openai 구현 중 하나를 고른다.""" from config.server_configs import external_api_config from services.llm import gemini, openai @@ -11,10 +8,5 @@ def active(): def missing_key() -> str: - """지금 활성인 공급자에게 필요한 env 이름. 키가 없을 때 **그 공급자를** 가리키려고 쓴다. - - ★ 예전에는 호출측이 "GEMINI_API_KEY 미설정" 을 문자열로 박아 뒀다. 공급자를 openai 로 - 바꾼 뒤에도 그 문구가 그대로 나가서, **없는 것은 OPENAI_API_KEY 인데 화면은 Gemini 를 - 탓했다**(실측 2026-09-21: 로컬에서 소개문이 안 나와 Gemini 키를 한참 들여다봤다). - 원인을 정확히 반대로 가리키는 종류라, 문구를 공급자에서 끌어오게 바꿨다.""" + """지금 활성인 공급자에게 필요한 env 이름.""" return "OPENAI_API_KEY" if active() is openai else "GEMINI_API_KEY" diff --git a/solution/backend/services/llm/types.py b/solution/backend/services/llm/types.py index b39366e..7d5d008 100644 --- a/solution/backend/services/llm/types.py +++ b/solution/backend/services/llm/types.py @@ -1,4 +1,4 @@ -"""공급자 무관 값 타입. gemini.py·openai.py 가 동일하게 이 타입을 쓰고 돌려준다.""" +"""공급자 무관 값 타입.""" from dataclasses import dataclass from typing import Optional @@ -18,10 +18,7 @@ class ImagePart: @dataclass class LlmResult: - """generate() 의 반환값. - - json: response_schema 를 줬을 때 파싱된 결과(스키마 없이 부르면 None). - text: 원문 텍스트(요약처럼 스키마 없는 호출에서 이걸 쓴다).""" + """generate() 의 반환값.""" json: Optional[dict] text: str diff --git a/solution/backend/services/local_content_service.py b/solution/backend/services/local_content_service.py index 8731089..c2af792 100644 --- a/solution/backend/services/local_content_service.py +++ b/solution/backend/services/local_content_service.py @@ -28,21 +28,15 @@ from services.external.open_meteo import OpenMeteoRequestFailed, fetch_current_w from services.external.tour_api import TourApiNotConfigured, TourApiRequestFailed from services.place_category import guess_food_class -# collect_service.discover_tour_api 가 등록하는 업장 자신의 TourAPI 링크. (contentTypeId, contentId) +# collect_service.discover_tour_api 가 등록하는 업장 자신의 TourAPI 링크. _TOUR_LINK = re.compile(r"^tour://(\d+)/(\d+)$", re.I) -# 업종별 기본 중분류 — 외부 분류도 TourAPI 링크도 없을 때의 마지막 폴백. 카페는 카페(FD05)를 뺀다. -# 음식점은 어떤 음식인지 모르면 아무것도 빼지 않는다(한식당에서 양식집을 빼면 안 된다). +# 업종별 기본 중분류 — 외부 분류도 TourAPI 링크도 없을 때의 마지막 폴백. _DEFAULT_FOOD_CLASS = {PlaceCategory.CAFE.value: "FD05"} -# 축제 노출 종료를 KST 그 날 자정으로 잡기 위한 시간대. 행사 날짜는 한국 날짜다. +# 축제 노출 종료를 KST 그 날 자정으로 잡기 위한 시간대. _KST = timezone(timedelta(hours=9)) -# 업장 반경(m). 2026-09-04 실측(군산 절골길 18)으로 정했다 — specs/2026-09-04-tourapi-radius-spike.md -# 관광지·축제·여행코스 10km: 5km 는 관광지 16건, 10km 는 38건. 원도심 밖 명소가 10km 에서 잡힌다. -# 맛집 5km: 10km 에서도 66건 중 59건이 5km 안이다. 밥은 동네에서 먹는다. -# 종류마다 반경이 다르다(2026-09-08) — 걸어갈 맛집과 차로 갈 관광지를 같은 반경으로 재지 않는다. -# 축제는 반경이 없다 — 업장이 속한 시도 전체를 그대로 싣는다(tour_api.fetch_festivals_in_sido). -# 공용 실체(area_contents.body)에 넣지 않는 키. 컬럼이나 사이트 쪽에 이미 자리가 있는 것들이다. +# 업장 반경(m). _BODY_DROP = ("contentid", "content_type", "distance_m", "latitude", "longitude") RESTAURANT_RADIUS_M = 5_000 @@ -73,17 +67,7 @@ class LocalContentService: # ── 업장 반경 주변정보 ─────────────────────────────────────────────── async def sync_place(self, place) -> ResSyncPlace: - """업장 좌표 반경의 맛집·관광지·축제를 TourAPI 에서 받아 place_area_refs 를 맞춘다. - 종류마다 따로 부른다(맛집 5km · 관광지 10km · 축제는 시도 전체, 2026-09-08). - 여행코스(25)는 뺐다 — 반경을 넓혀도 데이터가 거의 없다(전북 전체 3건 실측). - - 빌드가 매번 부른다(services/build_service.run_build) — 발행본은 정적이라 이때 채운 값이 실린다. - ★ 실패해도 기존 행을 지우지 않는다 — 직전 값 유지가 이 캐시의 규약이다(모델 주석). - ★ 응답에 없는 행은 소프트 삭제한다 — 반경 밖으로 밀렸거나 TourAPI 가 내린 것이다. - hidden(운영자 숨김)은 재수집이 덮어쓰지 않는다(crud.upsert). 이 변경으로 기존에 저장된 - 여행코스 행도 다음 재수집 때 자연스레 소프트 삭제된다(더는 keep 목록에 없으므로). - ★ 공공데이터는 검수 없이 그대로 싣는다(2026-09-03 결정). 틀린 항목은 운영자가 숨긴다. - """ + """업장 좌표 반경의 맛집·관광지·축제를 TourAPI 에서 받아 place_area_refs 를 맞춘다.""" res = ResSyncPlace() place_id = getattr(place, "place_id", None) if place_id is None: @@ -91,8 +75,7 @@ class LocalContentService: return res lat, lng = _as_float(getattr(place, "latitude", None)), _as_float(getattr(place, "longitude", None)) if lat is None or lng is None: - # ★ 좌표가 비면 주소로 한 번 더 찾는다(카카오 주소검색). 주변 정보는 TourAPI 에 이 업소가 - # 등록돼 있느냐와 무관하다 — 필요한 건 좌표뿐이다. 찾으면 places 에 박제해 다음부터는 안 부른다. + # 좌표가 비면 주소로 한 번 더 찾는다(카카오 주소검색). found = await self._geocode_and_store(place) if found is None: res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) @@ -105,9 +88,7 @@ class LocalContentService: # 종류마다 반경이 달라 따로 부른다 — 맛집은 걸어갈 거리, 축제는 차로 갈 거리다. restaurants = await tour_api.fetch_nearby( client, lat, lng, radius_m=RESTAURANT_RADIUS_M, content_type_id="39") - # ★ 업종별 제외(2026-09-08 결정): 음식점·카페 업장은 **같은 중분류(경쟁 업소)** 를 뺀다 — - # 카페 사이트에 옆 카페를, 한식당 사이트에 옆 한식당을 추천할 이유가 없다. - # 숙박 업장은 숙박(32)을 빼야 하는데 애초에 요청하지 않으므로 여기서 할 일이 없다. + # 업종별 제외: 음식점·카페 업장은 **같은 중분류(경쟁 업소)** 를 뺀다 — 카페 사이트에 옆 카페를, 한식당 사이트에 옆 한식당을 추천할 이유가 없다. own_class = await self._own_food_class(client, place) if own_class: before = len(restaurants) @@ -116,9 +97,6 @@ class LocalContentService: attractions = await tour_api.fetch_nearby( client, lat, lng, radius_m=ATTRACTION_RADIUS_M, content_type_id="12") - # ★ 축제는 locationBasedList2 가 아니라 searchFestival2 를 쓴다(2026-09-08 교체) — - # 위치 색인을 못 믿는다(실측: 반경 20km 를 넓혀도 몇 년 전에 끝난 전시만 잡히고, - # 500m 옆 진행 예정 축제는 안 잡혔다). 시도 코드가 있어야 부를 수 있다. sido_code = str(getattr(place, "region_code", None) or "")[:2] or None if not sido_code: address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "") @@ -152,8 +130,7 @@ class LocalContentService: existing = {(int(r.content_type), r.external_id): r for r in (rows or [])} # 조인 결과(area_contents + 거리) now = datetime.now(timezone.utc) - # 공용 콘텐츠에 실어 둘 지역. 유일성의 근거는 external_id 이고 이건 조회 편의다 — - # 없으면 NULL 로 둔다(지어내지 않는다). + # 공용 콘텐츠에 실어 둘 지역. region_code = str(getattr(place, "region_code", None) or "").strip() or None changed = 0 kept_ids: set = set() @@ -161,11 +138,7 @@ class LocalContentService: for body in kept: key = (body["content_type"], body["contentid"]) prev = existing.get(key) - # ★ 실체는 전국 공용이다 — 다른 업장이 이미 넣어 뒀으면 그 행을 그대로 쓴다. - # (source, external_id) 로 upsert 하고 돌려받은 id 로 관계만 잇는다. - # ★ 공용 실체에는 **렌더러가 읽는 것만** 담는다(2026-09-09). 거리는 사이트마다 다르고, - # 좌표·외부 id 는 컬럼이 이미 그 자리다 — body 에 또 두면 한쪽만 갱신되는 날이 온다. - # `lclsSystm2` 만 예외로 남긴다: 렌더러는 안 쓰지만 주변 맛집에서 같은 업태를 뺄 때 쓴다. + # 실체는 전국 공용이다 — 다른 업장이 이미 넣어 뒀으면 그 행을 그대로 쓴다. shared_body = {k: v for k, v in body.items() if k not in _BODY_DROP} content_values = { "source": LocalSource.TOUR_API.value, @@ -177,10 +150,9 @@ class LocalContentService: "latitude": _as_float(body.get("latitude")), "longitude": _as_float(body.get("longitude")), "region_code": region_code, - # ★ has_image 컬럼은 두지 않는다 — body.firstimage 가 이미 그 사실이다. - # 같은 값을 두 곳에 두면 한쪽만 갱신되는 날이 온다. + # has_image 컬럼은 두지 않는다 — body.firstimage 가 이미 그 사실이다. "status": LocalContentStatus.PUBLISHED.value, - # ★ 축제도 종료일과 무관하게 노출한다(2026-09-17 결정) — display_end_at 을 두지 않는다. + # 축제도 종료일과 무관하게 노출한다 — display_end_at 을 두지 않는다. "display_end_at": None, "collected_at": now, } @@ -204,8 +176,7 @@ class LocalContentService: [place_area_refs.DBType()], [lambda s, cid=content_id, d=body["distance_m"]: self.place_crud.upsert_ref(s, place_id, cid, d)], ) - # 사이트 개인화(거리·숨김)는 아래에서 한 번에 쓴다 — 항목마다 UPDATE 하면 - # 같은 행을 N 번 쓰게 된다(섹션당 한 행이다). + # 사이트 개인화(거리·숨김)는 아래에서 한 번에 쓴다 — 항목마다 UPDATE 하면 같은 행을 N 번 쓰게 된다(섹션당 한 행이다). personal[str(content_id)] = { "kind": AREA_KIND.get(body["content_type"]), "distanceMeters": body["distance_m"], @@ -214,11 +185,7 @@ class LocalContentService: if prev is None or prev.body != shared_body: changed += 1 - # ★ TourAPI 가 만들지 않은 연결(예: NAVER_CRAWL — services/local_restaurant_enrichment.py)은 - # 이 함수가 모르는 소스다. "이번 응답에 없으니 끊는다"를 그대로 적용하면 다른 파이프라인이 - # 붙여 둔 것까지 매 재수집마다 지웠다가 그쪽이 다시 채우는 낭비가 생긴다(2026-09-14 실측: - # 네이버로 크롤링해 둔 맛집이 다음 TourAPI 재수집 때마다 끊겼다 붙었다 했다). - # 그래서 TourAPI 소스가 아닌 기존 연결은 항상 kept_ids/personal 에 그대로 얹어 보존한다. + # TourAPI 가 만들지 않은 연결(예: NAVER_CRAWL — services/local_restaurant_enrichment.py)은 이 함수가 모르는 소스다. for key, row in existing.items(): if int(getattr(row, "source", LocalSource.TOUR_API.value)) == LocalSource.TOUR_API.value: continue @@ -229,13 +196,12 @@ class LocalContentService: "hidden": bool(row.hidden), }) - # 이번 응답에 없는 **관계**만 끊는다. 실체는 남긴다 — 다른 업장이 가리키고 있을 수 있다. + # 이번 응답에 없는 **관계**만 끊는다. _, removed = await DB_SESSION_MNG.execute_lambda_claim( place_area_refs.DBType(), lambda s: self.place_crud.soft_delete_missing(s, place_id, kept_ids) ) - # ★ 개인화는 사이트 쪽에 쓴다. 이번 응답에 없는 항목은 자연히 빠진다 — 맵을 통째로 갈아 - # 끼우기 때문이다. 숨김은 위에서 옛 값을 물려받았으므로 재수집이 되살리지 않는다. + # 개인화는 사이트 쪽에 쓴다. await self._write_site_places(place_id, personal) counts = {k: 0 for k in (LocalContentType.FESTIVAL.value, LocalContentType.ATTRACTION.value, @@ -251,15 +217,7 @@ class LocalContentService: return res async def _own_food_class(self, client, place) -> str | None: - """음식점·카페 업장 자신의 TourAPI 중분류(FD01~FD05). 숙박·병원은 None(제외할 게 없다). - - 우선순위 — 정확한 쪽부터: - 1. 업장이 TourAPI 에 등록돼 있으면(place_channels 의 tour:// 링크) 그 콘텐츠의 lclsSystm2. - 주변 항목과 **같은 체계**라 오차가 없다. 링크는 수집(COLLECT)이 상호+좌표 검증을 거쳐 붙인다. - 2. 검증 때 박제한 외부 분류 문자열(places.external_category)을 키워드로 매핑. - 3. 그래도 모르면 업종 기본값 — 카페는 FD05. 음식점은 None(무엇을 빼야 할지 모른다). - 실패는 전부 '제외 없음'으로 떨어진다 — 경쟁 업소가 섞이는 것이 맛집 섹션이 통째로 비는 것보다 낫다. - """ + """음식점·카페 업장 자신의 TourAPI 중분류(FD01~FD05).""" category = getattr(place, "category", None) if category not in (PlaceCategory.CAFE.value, PlaceCategory.RESTAURANT.value): return None @@ -287,7 +245,7 @@ class LocalContentService: return _DEFAULT_FOOD_CLASS.get(category) async def _geocode_and_store(self, place) -> tuple[float, float] | None: - """주소 → 좌표(카카오). 찾으면 places.latitude/longitude 에 박제한다. 키가 없거나 실패하면 None.""" + """주소 → 좌표(카카오).""" address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip() if not address: return None @@ -321,7 +279,7 @@ class LocalContentService: return lat, lng async def _load_place(self, place_id): - """회사 스코프 없이 사업장 1건. ★ 공개 조회(guide)와 운영자 화면이 쓴다 — 사장님 API 는 place_service 를 탄다.""" + """회사 스코프 없이 사업장 1건.""" err, rows = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute( @@ -357,14 +315,7 @@ class LocalContentService: return res async def get_guide(self, place_id: uuid.UUID) -> ResLocalGuide: - """에디터 캔버스용 주변 가이드(맛집·명소·축제·코스). - - ★ 스냅샷 필터(숨김·노출창)와 payload 변환을 **그대로 재사용**한다 — - 캔버스가 발행본과 다른 목록을 보이면 사장님이 "미리보기와 다르다"고 읽는다. - 그래서 여기서 DB 를 따로 읽지 않고 발행 파이프라인의 두 함수를 잇기만 한다. - ★ 일정(itineraries)도 함께 내려보낸다 — 캔버스 `ItineraryTickets` 가 섹션 데이터가 비었을 때 - 이 값으로 떨어진다(발행본 ItinerarySection 과 같은 폴백). 예전에는 그릴 자리가 없어 뺐다. - """ + """에디터 캔버스용 주변 가이드(맛집·명소·축제·코스).""" # 순환 import 회피 — snapshot·site_payload 는 발행 파이프라인 모듈이라 서비스 최상단에서 끌어오지 않는다. from services.site_payload import _local from services.snapshot import _local_contents @@ -375,9 +326,7 @@ class LocalContentService: res.result.SetResult(ErrorType.DB_EMPTY_DATA) return res - # ★ 아직 한 번도 수집하지 않은 사업장은 **지금** 채운다(cache-aside — 날씨와 같은 규약). - # 빌드 때만 채우면 방금 만든 사업장은 첫 빌드 전까지 캔버스가 계속 "준비 중"이다(2026-09-07 실측). - # 행이 하나라도 있으면 부르지 않는다 — 갱신은 빌드·운영자 재수집이 맡는다. + # 아직 한 번도 수집하지 않은 사업장은 **지금** 채운다(cache-aside — 날씨와 같은 규약). err, rows = await DB_SESSION_MNG.execute_lambda( place_area_refs.DBType(), DBWRType.DB_READ.value, lambda s: self.place_crud.list_by_place(s, place_id), @@ -387,10 +336,7 @@ class LocalContentService: if not synced.result.success: LOG.w(f"[local] place={place_id} 첫 조회 수집 실패(빈 채로 응답): {synced.msg}") - # ★ 지역 이야기(가요·인물·연표·엽서·퀴즈)도 같은 규약으로 채운다 — 다만 **잡으로** 돌린다. - # 위 TourAPI 는 수 초면 끝나지만 이건 검색을 동반한 LLM 호출 다섯이라 분 단위다. - # 에디터를 여는 요청을 그만큼 붙잡아 두면 사장님에게는 화면이 멈춘 것으로 보인다. - # 이번 응답에는 안 실리고, 다음에 열 때(또는 발행 빌드 때) 들어온다. + # 지역 이야기(가요·인물·연표·엽서·퀴즈)도 같은 규약으로 채운다 — 다만 **잡으로** 돌린다. await self._ensure_region_stories(place) snapshot_local = await _local_contents(place) @@ -404,14 +350,7 @@ class LocalContentService: return res async def _write_site_places(self, place_id, places_map: dict) -> None: - """주변 항목의 **사이트별 값**(거리·숨김)을 `site_sections` 한 행에 쓴다. - - ★ 배열이 아니라 ref → 값 **맵**이다. 화면에 순서대로 서는 항목(songs·people…)이 아니라 - "공용 항목 하나에 이 사이트가 덧붙인 값" 조회표라, 읽을 때마다 훑을 이유가 없다. - 정렬 기준(가까운 순·사진 있는 것 먼저)은 읽는 쪽이 갖는다. - ★ 사이트가 없으면 만들지 않고 건너뛴다 — 사이트를 세우는 건 발행 쪽 결정이다 - (`build_service.ensure_site`). 다음 수집이 사이트가 생긴 뒤 다시 쓴다. - """ + """주변 항목의 **사이트별 값**(거리·숨김)을 `site_sections` 한 행에 쓴다.""" from services.build_service import ensure_site try: @@ -437,23 +376,13 @@ class LocalContentService: LOG.w(f"[local] place={place_id} 사이트 개인화 저장 실패: {err.name}") async def _ensure_region_stories(self, place) -> None: - """지역 데이터·일정이 비어 있으면 잡을 하나 넣는다 — **보험 경로**다. - - ★ 정규 경로는 위저드다: 수집이 끝날 때(collect_service)와 생성 단계(place_service)가 - 같은 잡을 걸고, 사장님은 **에디터에 들어가기 전에** 다 채워진 화면을 본다. - 여기는 그 경로를 안 거친 업장(옛 데이터·수집을 건너뛴 경우)을 위한 자리다. - ★ 잡으로 돌린다. 이 함수는 에디터가 화면을 그리려고 부른 요청 안에 있어서, - 여기서 1분을 붙잡으면 사장님에게는 화면이 멈춘 것으로 보인다. - """ + """지역 데이터·일정이 비어 있으면 잡을 하나 넣는다 — **보험 경로**다.""" if not perplexity.is_configured(): return region_code = str(getattr(place, "region_code", None) or "").strip() place_id = getattr(place, "place_id", None) - # ★ "한 건이라도 있으면 건너뛴다" 가 아니라 **없는 것이 있으면 돈다.** - # 종류가 늘어난 날 기존 지역이 옛 목록에 멈추는 것을 막는다(story_service.missing_kinds). - # ★ 일정도 같은 잡이 채운다(2026-09-11). 이야기만 보면, 이야기가 이미 다 찬 업장은 - # 일정을 영영 못 받는다 — daily 가 안 들어가던 것과 똑같은 함정이다. + # "한 건이라도 있으면 건너뛴다" 가 아니라 **없는 것이 있으면 돈다.** 종류가 늘어난 날 기존 지역이 옛 목록에 멈추는 것을 막는다(story_service.missing_kinds). stories_done = bool(region_code) and not await story_service.missing_kinds(region_code) if stories_done: from services.itinerary_llm_service import missing_durations diff --git a/solution/backend/services/local_restaurant_enrichment.py b/solution/backend/services/local_restaurant_enrichment.py index 3e93fac..dba278c 100644 --- a/solution/backend/services/local_restaurant_enrichment.py +++ b/solution/backend/services/local_restaurant_enrichment.py @@ -1,16 +1,4 @@ -"""주변 맛집 보강 — TourAPI 데이터가 중심이고, Perplexity+네이버 크롤링은 부수적인 보강이다. - -★ TourAPI 로 이미 있는 맛집은 몇 건이든(군산 절골길 18처럼 59건이어도) 그대로 전부 보여준다 — - 이 모듈은 그중 어떤 것도 지우거나 숨기지 않는다(2026-09-14 사용자 확정). Perplexity 지역검색 - 상위 10개 이름 중 TourAPI(또는 이전에 이미 크롤링해 둔 것)에 없는 이름만 네이버에서 크롤링해 - **추가**한다 — "상위 10개"는 Perplexity 검색 후보의 상한일 뿐, 최종 화면에 보이는 개수의 - 상한이 아니다. -★ 네이버 URL 확보는 `services/external/naver_place_lookup.py`(상호명+지역으로 네이버 자체 검색 → - place id)를 그대로 재사용한다 — Perplexity 도메인필터 재검색으로 시도했다가 실측(2026-09-14, - 군산시 6곳 중 0곳 성공)에서 명중률이 낮아 이 기존 모듈로 바꿨다. -★ docs/DECISIONS.md 1-1 예외 처리. 설계: tmp/superpowers/specs/2026-09-14-nearby-restaurant-naver-enrichment-design.md - 봇 탐지 우회는 하지 않는다 — 막히면 그 업체만 포기한다. -""" +"""주변 맛집 보강 — TourAPI 데이터가 중심이고, Perplexity+네이버 크롤링은 부수적인 보강이다.""" import re from datetime import datetime, timezone @@ -30,16 +18,12 @@ _BODY_DROP = ("contentid", "content_type", "distance_m", "latitude", "longitude" def normalize_name(name: str) -> str: - """비교용 정규화. 공백·구두점·괄호를 지우고 소문자로 낮춘다.""" + """비교용 정규화.""" return _NORM_STRIP.sub("", (name or "")).lower() def is_same_restaurant(a: str, b: str) -> bool: - """이름 유사도 판정. 표기 차이와 지점명 접미사("...본점")는 같은 곳으로 본다. - - ★ 부분 문자열 포함으로 판정한다 — 완전 일치만 보면 "이든식당"과 "이든식당 본점"이 - 다른 곳으로 갈려 TourAPI에 이미 있는 곳을 중복으로 다시 크롤링한다. - """ + """이름 유사도 판정.""" na, nb = normalize_name(a), normalize_name(b) if not na or not nb: return False @@ -47,12 +31,7 @@ def is_same_restaurant(a: str, b: str) -> bool: def to_area_content_body(summary: dict) -> dict: - """NaverPlaceAdapter.fetch_summary() 결과 → tour_api._normalize()와 같은 모양의 dict. - - ★ distance_m 은 항상 None 이다 — 지역명 검색으로 찾은 업체라 업장 좌표 기준 거리를 - 모른다. local_content_service 의 body["distance_m"] 직접 접근 규약을 지키려면 - 키 자체는 있어야 한다(값만 비운다). - """ + """NaverPlaceAdapter.fetch_summary() 결과 → tour_api._normalize()와 같은 모양의 dict.""" out = { "contentid": summary["place_id"], "content_type": LocalContentType.RESTAURANT.value, @@ -79,11 +58,7 @@ def _as_float(value) -> float | None: def _distance_to_place(place_coords: tuple[float, float] | None, summary: dict) -> int | None: - """업장 좌표 ↔ 크롤링한 맛집 좌표 거리(m). 둘 중 하나라도 없으면 None. - - ★ 외부 호출 없는 순수 계산이다 — 좌표는 이미 fetch_summary()가 같은 응답에서 받아 온 - 값이라 이 계산에 드는 비용은 없다(common.utils.geo.haversine_m 재사용). - """ + """업장 좌표 ↔ 크롤링한 맛집 좌표 거리(m).""" if place_coords is None: return None lat, lng = _as_float(summary.get("latitude")), _as_float(summary.get("longitude")) @@ -93,10 +68,7 @@ def _distance_to_place(place_coords: tuple[float, float] | None, summary: dict) async def _place_coordinates(place_id) -> tuple[float, float] | None: - """이 place 자신의 좌표. 크롤링한 맛집과의 거리 계산용으로만 쓴다. - - ★ 순환 import 회피: story_service → 이 모듈로 이어지는 사슬이 있어 지연 import 한다. - """ + """이 place 자신의 좌표.""" from services.local_content_service import LocalContentService place = await LocalContentService()._load_place(place_id) @@ -109,19 +81,7 @@ async def _place_coordinates(place_id) -> tuple[float, float] | None: async def _sync_site_personalization(place_id, restaurant_refs: list) -> None: - """`place_area_refs` 기준 맛집 연결 중 사이트 개인화 맵(site_sections.local)에 없는 - 것만 채운다 — 이미 있는 값(거리·숨김)은 건드리지 않는다. - - restaurant_refs: [(content_id, distance_m, hidden), ...] — 기존 연결 + 이번에 새로 - 크롤링한 것 전부. 기존 것이 이미 맵에 있으면 손대지 않고, 없는 것(새로 추가한 것, - 또는 과거에 맵 갱신 없이 만들어진 것)만 채운다 — 그래서 자연히 자가복구도 된다. - - ★ 캔버스(스냅샷)는 `place_area_refs`가 아니라 이 맵만 읽는다(services/snapshot.py:: - _site_places). `place_area_refs`에만 쓰고 여기를 안 채우면, DB에는 들어가도 화면에는 - 안 나온다. - ★ 순환 import 회피: local_content_service → story_service → 이 모듈로 이어지는 사슬이 있어 - 지연 import 한다(story_service.run_local_sync 의 관례와 동일). - """ + """`place_area_refs` 기준 맛집 연결 중 사이트 개인화 맵(site_sections.local)에 없는 것만 채운다 — 이미 있는 값(거리·숨김)은 건드리지 않는다.""" if not restaurant_refs: return from services.local_content_service import LocalContentService @@ -140,13 +100,7 @@ async def _sync_site_personalization(place_id, restaurant_refs: list) -> None: async def enrich_place_restaurants(place_id, region_label: str, region_code: str | None = None) -> dict: - """이 place 의 기존 맛집(TourAPI 등)은 그대로 두고, Perplexity 지역검색 상위 10개 이름 중 - 아직 없는 곳만 네이버에서 크롤링해 추가한다. 기존 연결을 지우거나 숨기지 않는다. - - 흐름: ① Perplexity 로 이 지역 맛집 상위 10개 이름을 받는다 → ② 이름마다 이 place 에 이미 - 연결된 맛집(TourAPI 또는 이전 크롤링분)과 유사도 매칭 — 있으면 건너뛰고(중복 크롤링 방지), - 없으면 네이버에서 크롤링해 새로 연결한다. 실패해도 예외를 던지지 않는다. - """ + """이 place 의 기존 맛집(TourAPI 등)은 그대로 두고, Perplexity 지역검색 상위 10개 이름 중 아직 없는 곳만 네이버에서 크롤링해 추가한다.""" stats = {"matched": 0, "added": 0, "checked": 0, "skipped": ""} if not perplexity.is_configured(): @@ -188,8 +142,7 @@ async def enrich_place_restaurants(place_id, region_label: str, region_code: str summary = await NaverPlaceAdapter().fetch_summary(naver_place_lookup.place_url(naver_id)) if not summary: - # ★ naver_place_lookup 이 찾은 id가 실제 상세 페이지가 아닐 수 있다(검색 원문에서 - # 상호 근처의 다른 숫자를 잘못 집은 경우) — 조용히 넘어가면 왜 스킵됐는지 안 보인다. + # naver_place_lookup 이 찾은 id가 실제 상세 페이지가 아닐 수 있다(검색 원문에서 상호 근처의 다른 숫자를 잘못 집은 경우) — 조용히 넘어가면 왜 스킵됐는지 안 보인다. LOG.w(f"[restaurant_enrich] '{name}' place={naver_id} 상세 조회 실패 — 포기") continue diff --git a/solution/backend/services/mail_service.py b/solution/backend/services/mail_service.py index 9258d6b..6d5d1ce 100644 --- a/solution/backend/services/mail_service.py +++ b/solution/backend/services/mail_service.py @@ -1,10 +1,4 @@ -"""메일 한 통을 보낸다 — Azure Communication Services 우선, SMTP 폴백. - -★ 설정이 없으면 보내지 않고 False 를 돌려준다(teams_webhook 과 같은 규약). 값이 없어도 서버는 뜬다. -★ 이 파일은 "메일 한 통 보내기" 만 안다 — 무엇을 언제 보낼지는 부르는 쪽이 정한다. -★ ACS 를 1순위로 두는 이유: 회사가 이미 공용 리소스를 쓰고 있고(negodata), 발신 도메인의 - SPF·DKIM 을 그쪽이 관리한다. SMTP 는 그 리소스를 못 쓰는 환경의 폴백이다. -""" +"""메일 한 통을 보낸다 — Azure Communication Services 우선, SMTP 폴백.""" import os import re import smtplib @@ -58,7 +52,7 @@ def _port() -> int: def _mode() -> str: - """starttls | ssl | plain. 기본은 starttls(587)이고 465 는 ssl 로 떨어진다.""" + """starttls | ssl | plain.""" value = _env(TLS_ENV).lower() if value in {"ssl", "starttls", "plain"}: return value @@ -92,10 +86,7 @@ def _send_acs(*, to: str, subject: str, text: str, reply_to: str | None) -> bool def send(*, to: str, subject: str, text: str, reply_to: str | None = None) -> bool: - """보냈으면 True. 설정이 없거나 실패하면 False — 예외를 밖으로 던지지 않는다. - - ★ 손님이 누른 버튼 하나가 메일 서버 장애로 500 이 되면 안 된다. 부르는 쪽이 False 를 보고 - "전달하지 못했다"를 손님 화면의 말로 바꾼다.""" + """보냈으면 True.""" if not is_configured(): LOG.w("[mail] SMTP 미설정 — 보내지 않는다") return False diff --git a/solution/backend/services/media_service.py b/solution/backend/services/media_service.py index cd5706b..c87d12d 100644 --- a/solution/backend/services/media_service.py +++ b/solution/backend/services/media_service.py @@ -12,31 +12,18 @@ from router.v1.media.protocol import MediaData, Res_MediaList def _is_publishable(row) -> bool: - """이 사진이 지금 사이트에 실릴 수 있는가. - - ★ 판단 기준을 services/snapshot.py 와 한 글자도 다르지 않게 맞춘다 — - 관리 화면이 '나간다'고 표시한 사진이 발행에서 빠지면 그게 제일 설명하기 어려운 버그다. - 승인(APPROVED)만으로는 부족하다. alt 가 빈 사진은 빌더가 렌더 자체를 하지 않는다.""" + """이 사진이 지금 사이트에 실릴 수 있는가.""" return row.status == MediaStatus.APPROVED.value and bool((row.alt_text or "").strip()) and bool((row.url or "").strip()) class MediaService: - """사진 조회. - - ★ 이 서비스가 지키는 규칙은 둘이다. - 1. 회사 스코프 — 사업장을 먼저 회사 스코프로 로드해서 남의 회사 사진에 닿지 못하게 한다. - (fact/site 와 같은 _load_place 패턴. 없는 것과 남의 것은 똑같이 PLACE_NOT_FOUND 로 답한다) - 2. 출처 보존 — source_type / origin_url 을 절대 응답에서 빼지 않는다. - 크롤링 이미지 재게시 권리가 미결이고(docs/DECISIONS.md 1-2), 결론이 '불가'면 - 발행에서 source_type = CRAWL 을 통째로 제외해야 한다. 그 필터를 화면이 미리 - 보여주려면 출처가 목록에 실려 있어야 한다. - """ + """사진 조회.""" def __init__(self, crud: IMediaCRUD = Depends(MediaCRUD), place_crud: PlaceCRUD = Depends(PlaceCRUD)): self.crud = crud self.place_crud = place_crud - # ---- 사업장 로드(회사 스코프) ---- + # 사업장 로드(회사 스코프) async def _load_place(self, user_info: UserInfo, place_id: str): err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), @@ -47,16 +34,9 @@ class MediaService: return ErrorType.PLACE_NOT_FOUND, None return ErrorType.SUCCESS, place - # ---- 조회 ---- + # 조회 async def list_media(self, user_info: UserInfo, place_id: str, unit_id=None, publishable_only: bool = False) -> Res_MediaList: - """사진 목록. 관리자 빌더 캔버스와 사장님 확인 화면이 같은 엔드포인트를 쓴다. - - publishable_only=True 는 '발행하면 실제로 실릴 것'만 — 승인 + alt 있음. - alt 조건을 여기서 같이 거는 게 중요하다. 승인만 보고 목록을 그리면 캔버스에는 - 사진이 보이는데 발행된 사이트엔 없는 상태가 되고, 원인을 찾는 데 반나절이 든다. - - 사진이 0장인 것은 오류가 아니다 — 수집 전이거나 Vision 이 아직 안 돌았을 뿐이라 - 빈 배열을 그대로 돌려준다(호출자가 '수집을 돌리세요'를 띄울 수 있게).""" + """사진 목록.""" res = Res_MediaList() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -85,7 +65,6 @@ class MediaService: res.publishable = sum(1 for x in items if x.publishable) # 사람 확인 큐에 남은 수 — 관리 화면의 '검토할 것' 배지. res.pending_review = sum(1 for r in rows if r.status == MediaStatus.PENDING_REVIEW.value) - # ★ 재게시 권리(1-2)가 '불가'로 결론나면 통째로 빠질 사진 수. 미리 보여줘야 사장님이 - # 직접 올릴 사진을 몇 장 준비해야 하는지 안다. + # 재게시 권리(1-2)가 '불가'로 결론나면 통째로 빠질 사진 수. res.crawled = sum(1 for r in rows if r.source_type == SourceType.CRAWL.value) return res diff --git a/solution/backend/services/notify_service.py b/solution/backend/services/notify_service.py index 2c50f0c..c7cafed 100644 --- a/solution/backend/services/notify_service.py +++ b/solution/backend/services/notify_service.py @@ -29,7 +29,7 @@ async def request_approval(post_id, user_id, token, path): number, name = await db.transaction(load) if not alimtalk.is_configured() or not number: return - # 야간 발송 정책이 미결이라 밤에는 화면 채널만 쓴다. 조용히 다음 날 자동 발송하지 않는다. + # 야간 발송 정책이 미결이라 밤에는 화면 채널만 쓴다. if not 8 <= datetime.now(ZoneInfo("Asia/Seoul")).hour < 21: return origin = config.get("SOCIAL_APP_ORIGIN", "").rstrip("/") @@ -65,5 +65,5 @@ async def request_approval(post_id, user_id, token, path): await db.transaction(save) if error: - # 발송 요청 자체가 실패했음을 호출자에게 알린다. 초안·화면 승인은 그대로 남는다. + # 발송 요청 자체가 실패했음을 호출자에게 알린다. raise RuntimeError(error) diff --git a/solution/backend/services/ops_service.py b/solution/backend/services/ops_service.py index 3ea5fa0..bf853f7 100644 --- a/solution/backend/services/ops_service.py +++ b/solution/backend/services/ops_service.py @@ -10,10 +10,7 @@ from router.v1.ops.protocol import OpsSiteData, OpsUserData, Res_OpsSites, Res_O class OpsService: - """내부 운영(개발자) 전용 조회 — 회사 스코프 없는 전 계정 사이트·유저 목록. - - admin/frontend 의 사업장 화면(사실 검증)과 역할이 다르다 — 여기는 발행 인프라 상태와 - 계정 상태만 본다. 게이트는 라우터가 RequireDeveloper 로 건다(여기서는 다시 확인하지 않는다).""" + """내부 운영(개발자) 전용 조회 — 회사 스코프 없는 전 계정 사이트·유저 목록.""" def __init__(self, site_crud: ISiteCRUD = Depends(SiteCRUD), user_crud: IUserCRUD = Depends(UserCRUD)): self.site_crud = site_crud @@ -37,7 +34,7 @@ class OpsService: @staticmethod def _site_row(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name) -> OpsSiteData: - # ★ 재빌드 판별은 site_service._my_site_row 와 같은 규칙이어야 한다 — 다르면 화면마다 다른 답을 한다. + # 재빌드 판별은 site_service._my_site_row 와 같은 규칙이어야 한다 — 다르면 화면마다 다른 답을 한다. changed = place.content_updated_at ever_published = site is not None and getattr(site, "published_at", None) is not None thumbnail_url = getattr(site, "thumbnail_url", None) or (primary_photo_url if ever_published else None) diff --git a/solution/backend/services/place_category.py b/solution/backend/services/place_category.py index 25db0da..97793c5 100644 --- a/solution/backend/services/place_category.py +++ b/solution/backend/services/place_category.py @@ -1,21 +1,10 @@ -"""외부 장소 DB 의 분류 → 우리 업종(PlaceCategory). - -★ 업종 판별에 LLM 을 부르지 않는다. 상호명으로 카카오·네이버를 부르는 건 어차피 하는 일이고, - 그 응답에 분류가 함께 온다(`category_name`, 카카오는 `category_group_code` 까지). - 지금까지 받아 놓고 쓰지 않았을 뿐이다 — 여기서 그 값을 업종으로 옮긴다. - -★ 못 정하면 None 이다. 억지로 하나를 고르지 않는다. 업종은 수집 스키마와 JSON-LD 타입을 - 통째로 정하는 값이라, 틀린 업종으로 시작하면 되돌리는 비용이 크다. - None 이면 화면이 사장님에게 직접 묻는다. -""" +"""외부 장소 DB 의 분류 → 우리 업종(PlaceCategory).""" from typing import Optional from common.enums import PlaceCategory -# 카카오 category_group_code. 한글 분류 문자열은 카카오가 언제든 바꾸지만 이 코드는 안 바뀐다. -# ★ HP8(병원)은 여기 없다 — 병원 전체가 아니라 피부과·성형외과만 열려 있어서, -# 코드만으로는 우리 업종인지 알 수 없다. 아래 키워드로 한 번 더 좁힌다. +# 카카오 category_group_code. _BY_GROUP_CODE = { "AD5": PlaceCategory.LODGING, "CE7": PlaceCategory.CAFE, @@ -23,12 +12,9 @@ _BY_GROUP_CODE = { } _HOSPITAL_GROUP_CODE = "HP8" -# 분류 문자열 키워드. 네이버는 group_code 를 주지 않아 이 경로만 탄다. -# ★ 순서가 결과를 바꾼다. 카카오·네이버 모두 카페를 "음식점 > 카페" 아래 두기 때문에 -# CAFE 를 RESTAURANT 보다 먼저 봐야 한다. 뒤집으면 카페가 전부 음식점이 된다. +# 분류 문자열 키워드. _KEYWORDS: list[tuple[PlaceCategory, tuple[str, ...]]] = [ - # ★ "피부"·"성형" 이 아니라 "피부과"·"성형외과" 다. 앞의 둘로 보면 피부관리실(에스테틱)이 - # 병원으로 걸린다 — 의료 광고 규제가 걸리는 업종이라 잘못 붙이면 가장 비싸다. + # "피부"·"성형" 이 아니라 "피부과"·"성형외과" 다. (PlaceCategory.CLINIC, ("피부과", "성형외과")), (PlaceCategory.LODGING, ("숙박", "펜션", "호텔", "모텔", "리조트", "게스트하우스", "민박")), (PlaceCategory.CAFE, ("카페", "커피", "베이커리", "제과", "디저트")), @@ -40,22 +26,17 @@ _KEYWORDS: list[tuple[PlaceCategory, tuple[str, ...]]] = [ def guess_category( category_name: Optional[str], group_code: Optional[str] = None ) -> Optional[PlaceCategory]: - """분류 문자열(+ 카카오 그룹코드)로 업종을 추정한다. 모르면 None. - - category_name 예 — 카카오 "가정,생활 > 숙박 > 펜션" · 네이버 "숙박>펜션" - """ + """분류 문자열(+ 카카오 그룹코드)로 업종을 추정한다.""" text = (category_name or "").replace(" ", "") code = (group_code or "").strip().upper() if code == _HOSPITAL_GROUP_CODE: - # 병원이라는 것까지만 안다. 진료과가 문자열에 없으면 우리 업종인지 알 수 없다. + # 병원이라는 것까지만 안다. return PlaceCategory.CLINIC if _match(text) is PlaceCategory.CLINIC else None if code in _BY_GROUP_CODE: by_code = _BY_GROUP_CODE[code] - # ★ 그룹코드를 문자열로 덮는 경우가 하나 있다: 카카오가 베이커리·브런치 가게를 - # FD6(음식점)으로 주면서 분류 문자열엔 "카페"를 다는 일이 있다. 어느 쪽이 맞는지는 - # 실측하지 않았다 — 사장님이 바꿀 수 있으니 이름이 더 구체적인 쪽을 기본값으로 둔다. + # 그룹코드를 문자열로 덮는 경우가 하나 있다: 카카오가 베이커리·브런치 가게를 FD6(음식점)으로 주면서 분류 문자열엔 "카페"를 다는 일이 있다. if by_code is PlaceCategory.RESTAURANT and _match(text) is PlaceCategory.CAFE: return PlaceCategory.CAFE return by_code @@ -70,15 +51,7 @@ def _match(text: str) -> Optional[PlaceCategory]: return None -# ── 음식 중분류(TourAPI lclsSystm2) 추정 ───────────────────────────────── -# 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준이다(2026-09-08 결정). -# TourAPI 분류체계(lclsSystmCode2 실측) — FD01 한식 · FD02 외국식(중·일·서양·기타외국·퓨전) -# · FD03 간이음식(제과·피자/햄버거/샌드위치·치킨·김밥분식·이동음식) · FD04 주점 · FD05 카페/찻집. -# -# ★ 순서가 결과를 바꾼다. 카카오는 카페를 "음식점 > 카페 > …" 아래 두므로 "음식점"이 항상 붙어 있다 — -# 그래서 "음식점" 은 판정어로 쓰지 않고, 구체적인 업태(카페·주점·간이·외국식)를 한식보다 먼저 본다. -# ★ 한식은 마지막이고 판정어가 좁다("한식"·"한정식"·"백반"·"국밥"…). 고기·회 같은 재료명은 넣지 않는다 — -# "양식 > 스테이크" 를 고기라고 한식으로 넣으면 서양식 스테이크집이 한식이 된다. +# ── 음식 중분류(TourAPI lclsSystm2) 추정 ───────────────────────────────── 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준이다. _FOOD_CLASS_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [ ("FD05", ("카페", "커피", "찻집", "디저트", "음료", "주스", "빙수", "케이크", "브런치")), ("FD04", ("주점", "술집", "호프", "맥주", "이자카야", "포차", "와인바", "칵테일", "펍")), @@ -92,11 +65,7 @@ _FOOD_CLASS_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [ def guess_food_class(category_name: Optional[str]) -> Optional[str]: - """외부 분류 문자열 → TourAPI 음식 중분류 코드(FD01~FD05). 모르면 None(제외 없음). - - 예 — 카카오 "음식점 > 카페 > 커피전문점" → FD05 · 네이버 "카페,디저트" → FD05 · - 카카오 "음식점 > 한식 > 육류,고기" → FD01 · "음식점 > 양식 > 스테이크,립" → FD02 - """ + """외부 분류 문자열 → TourAPI 음식 중분류 코드(FD01~FD05).""" text = (category_name or "").replace(" ", "") if not text: return None diff --git a/solution/backend/services/place_research.py b/solution/backend/services/place_research.py index c0c5722..9d4623e 100644 --- a/solution/backend/services/place_research.py +++ b/solution/backend/services/place_research.py @@ -1,28 +1,4 @@ -"""업소 조사 — 소개문을 쓸 **재료**를 공개 웹에서 찾아 근거 자리에 넣는다. - - 프롬프트 services/prompts/place_research.py 무엇을 묻나 - 호출 services/llm/perplexity.py HTTP·타임아웃·인증 - 믿을 것인가 services/grounding/place_research.py 출처 필수 · 상호 대조 - 여기 조사 → 근거 적재 (문장은 쓰지 않는다) - -★ 이 모듈은 **문장을 쓰지 않는다.** 소개문은 지금처럼 `copy_service`(Gemini)가 쓴다. - 여기가 하는 일은 그 생성기에게 줄 재료를 늘리는 것뿐이다. 왜 나누나 — - 소개문을 검색모델에게 바로 시키면 그 문장의 근거를 우리가 갖지 못하고, - `ground_check` 가 근거 없는 문장을 전부 반려해 결국 앙상해진다. - -★ 왜 필요했나 (실측 2026-09-10, 스테이,머뭄) - 근거 fact 9건으로 생성한 소개문은 "군산시에 있는 스테이,머뭄입니다. 주차 가능." 이었다. - 네이버 플레이스 3건 · TourAPI 미등록 · 예약 페이지와 인스타는 robots 금지 — 남은 공개 - 출처가 없어서지 생성기 잘못이 아니었다. 그런데 이 업소의 내력(1920년대 고택, 히로쓰 가옥 - 옆, 2024년 리모델링, A동·B동)은 블로그·기사에 있다. 그걸 출처와 함께 가져온다. - -★ 요금은 조사하지 않는다. 블로그의 요금은 대개 옛값이고, 틀리면 예약 클레임이다. - fact 는 fact 경로(수집·사장님 확인)로만 들어온다 — 이 모듈은 `place_facts` 를 쓰지 않는다. - -★ 적재 자리: `place_channels.raw` — `copy_service` 가 이미 '수집 원문'을 읽는 그 자리다. - 새 표를 만들지 않는다. 대신 **확정하지 않는다**(`confirmed_at` NULL) — 조사 출처는 - 이 업소의 공식 채널이 아니라 남이 쓴 글이다. 발행본의 공식 채널·sameAs 에 나가면 안 된다. -""" +"""업소 조사 — 소개문을 쓸 **재료**를 공개 웹에서 찾아 근거 자리에 넣는다.""" import uuid import httpx @@ -39,20 +15,15 @@ from services.prompts import place_research as prompts _place_crud = PlaceCRUD() -# 검색을 동반해 느리다. 지역 이야기와 같은 값을 쓴다(실측 건당 9~15초). +# 검색을 동반해 느리다. _TIMEOUT = 120.0 -# ★ raw 봉투의 표식. `copy_service` 가 "확정되지 않았지만 근거로는 읽어도 되는 글" 을 -# 이 값으로 가른다. 크롤 원문(확정 채널)과 섞이지 않게 이름을 붙여 둔다. +# raw 봉투의 표식. RAW_KIND = "research" def _envelope(items: list[dict]) -> dict: - """근거 봉투. text 는 `copy_service` 가 그대로 읽고, sources 는 추적용으로 남긴다. - - ★ 문장 뒤에 출처를 붙여 한 덩어리로 만든다 — 생성기가 문장만 보고 쓰더라도, - 나중에 "이 소개문의 이 대목은 어디서 왔나" 를 raw 만 열어 보면 알 수 있어야 한다. - """ + """근거 봉투.""" return { "kind": RAW_KIND, "text": "\n".join(f"- {row['text']} (출처: {row['source']['name']})" for row in items), @@ -62,10 +33,7 @@ def _envelope(items: list[dict]) -> dict: async def research_place(place, place_id: str) -> dict: - """업소 하나를 조사해 근거를 적재한다. 채택 건수와 버린 이유를 돌려준다. - - 실패는 예외로 올리지 않는다 — 조사가 없어도 발행은 되어야 한다(재료가 적을 뿐이다). - """ + """업소 하나를 조사해 근거를 적재한다.""" name = (getattr(place, "name", None) or "").strip() address = (getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip() if not name or not address: @@ -73,8 +41,7 @@ async def research_place(place, place_id: str) -> dict: if not perplexity.is_configured(): return {"skipped": "PERPLEXITY_API_KEY 미설정"} - # 업종 이름은 업종 스키마가 단일 출처다(`common/category_schema`) — 여기에 표를 또 적으면 - # 업종이 늘 때 한쪽만 늘어난다. + # 업종 이름은 업종 스키마가 단일 출처다(`common/category_schema`) — 여기에 표를 또 적으면 업종이 늘 때 한쪽만 늘어난다. from common.category_schema import get_schema category_label = get_schema(place.category).label diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py index a058aa1..d709135 100644 --- a/solution/backend/services/place_service.py +++ b/solution/backend/services/place_service.py @@ -43,34 +43,29 @@ from router.v1.place.protocol import ( ) from router.v1.job.protocol import JobData, Res_Job -# 공개 검색이 한 번에 가져오는 후보 수. 카카오 키워드 검색은 무료 한도를 넘기면 건당 과금이라 -# 확정 경로(15건)보다 좁게 잡는다 — 랜딩에서 사람이 훑는 목록은 다섯이면 충분하다. +# 공개 검색이 한 번에 가져오는 후보 수. _PUBLIC_SEARCH_SIZE = 5 -# IP 당 분당 허용 횟수. 사람이 상호명을 고쳐 가며 치는 속도를 넘지 않게 잡았다. +# IP 당 분당 허용 횟수. _PUBLIC_SEARCH_PER_MIN = 20 -# 도로명주소 → 지역 캐시 키. 외부 장소 DB 는 행정구역 코드를 주지 않으므로 여기서 만든다. +# 도로명주소 → 지역 캐시 키. from services.external.naver import region_key from services.job_service import enqueue_job -# 겨냥 조회를 몇 건까지 할지. 후보 전부(5건)를 부르면 통합검색이 429 를 준다. +# 겨냥 조회를 몇 건까지 할지. _TARGETED_LOOKUP_LIMIT = 2 -# 공개 검색 캐시 수명. 가게 정보가 이 안에 바뀔 일은 없다. +# 공개 검색 캐시 수명. _PUBLIC_SEARCH_CACHE_SEC = 600 class PlaceService: - """사업장 등록·조회·동일 업소 검증. - - ★ 이 서비스의 핵심 규칙: `verified_at` 이 NULL 인 사업장은 수집이 열리지 않는다. - 카카오 로컬로 동일 업소임을 확인하지 않으면 남의 가게 정보가 섞인다. - """ + """사업장 등록·조회·동일 업소 검증.""" def __init__(self, crud: IPlaceCRUD = Depends(PlaceCRUD), queue: JobQueue = Depends(JobQueue)): self.crud = crud self.queue = queue - # ---- 조회 ---- + # 조회 async def list_places(self, user_info: UserInfo, pg: PageParams, search=None, category=None, status=None) -> Res_PlaceList: res = Res_PlaceList(page=pg.page, size=pg.size) uid = uuid.UUID(user_info.user_id) @@ -101,7 +96,7 @@ class PlaceService: return res async def _load(self, user_info: UserInfo, place_id: str): - """회사 스코프로 사업장 1건. 없으면 PLACE_NOT_FOUND(남의 회사 것도 '없음'으로 응답).""" + """회사 스코프로 사업장 1건.""" err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, @@ -111,7 +106,7 @@ class PlaceService: return ErrorType.PLACE_NOT_FOUND, None return ErrorType.SUCCESS, place - # ---- 등록 ---- + # 등록 async def create_place(self, user_info: UserInfo, req: Req_CreatePlace) -> Res_Place: res = Res_Place() if not req.name.strip(): @@ -125,10 +120,6 @@ class PlaceService: return res place = places( - # ★ 주인은 **토큰이 정한다.** 예전엔 요청 body 의 owner_user_id 를 그대로 넣었는데, - # 그 값은 아무도 안 보내서 92건 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다. - # 회사를 걷어내면서 이 컬럼이 스코프 키가 됐다 — body 로 남의 계정을 적을 수 있으면 - # 만들자마자 남의 목록에 들어간다. owner_user_id=uuid.UUID(user_info.user_id), name=req.name.strip(), category=req.category.value, @@ -197,17 +188,9 @@ class PlaceService: res.job = JobData(**row) return res - # ---- 동일 업소 검증 ---- + # 동일 업소 검증 async def _is_empty(self, place_id: str) -> bool: - """이 사업장에 **사장님의 것이 쌓였나.** 중복을 접어도 되는지의 판정이다. - - ★ 무엇을 세나: fact · 객실 · 사진 · 사이트. 채널은 세지 않는다 — - 채널은 검증 과정에서 자동으로 붙는 것이라 "사장님이 쌓은 것" 이 아니다. - 이걸 세면 방금 만든 빈 행도 비어 있지 않다고 판정돼 중복이 그대로 남는다. - ★ 하나라도 있으면 접지 않는다. 지우는 쪽이 틀렸을 때의 비용(사장님이 넣은 값이 - 사라진다)이 남기는 쪽이 틀렸을 때의 비용(목록에 하나 더 보인다)보다 훨씬 크다. - ★ 세지 못하면 **비어 있지 않다고 본다** — 모르면 지우지 않는다. - """ + """이 사업장에 **사장님의 것이 쌓였나.** 중복을 접어도 되는지의 판정이다.""" from common.database.model.models import place_facts, place_photos, sites pid = uuid.UUID(place_id) @@ -230,22 +213,13 @@ class PlaceService: if err != ErrorType.SUCCESS or not rows: LOG.w(f"[verify_by_url] 중복 판정용 계수 실패 place={place_id} — 접지 않는다") return False - # ★ 이 세션 헬퍼는 한 칸짜리 select 를 스칼라로 풀어서 준다(행 튜플이 아니다). - # `rows[0][0]` 으로 읽으면 TypeError 로 검증 API 가 통째로 500 이 된다(실측). + # 이 세션 헬퍼는 한 칸짜리 select 를 스칼라로 풀어서 준다(행 튜플이 아니다). row = rows[0] total = row if isinstance(row, int) else row[0] return int(total) == 0 async def verify_place_by_url(self, user_info: UserInfo, place_id: str, req: Req_VerifyPlaceByUrl) -> Res_Place: - """네이버 플레이스 URL → 상호·주소·좌표를 읽어 동일 업소를 확정하고, 그 URL 을 수집 채널로 등록한다. - - ★ 한 번에 세 가지를 끝낸다: 신원 확정(verified_at) · 채널 등록 · 확정. - 쪼개 놓으면 사장님이 같은 판단을 세 번 하게 된다 — URL 을 붙여넣은 시점에 - "이 가게가 맞다"와 "이 채널이 내 것이다"가 동시에 확인된 것이다. - - ★ 실패는 조용히 넘기지 않는다. URL 이 잘못됐거나 네이버가 막으면 그대로 알려야 - 사장님이 다른 주소를 넣는다 — 빈 사이트를 만들어 놓고 나중에 발견하면 늦다. - """ + """네이버 플레이스 URL → 상호·주소·좌표를 읽어 동일 업소를 확정하고, 그 URL 을 수집 채널로 등록한다.""" from decimal import Decimal from common.enums import ExternalPlaceSource, LinkChannel, SourceType @@ -282,35 +256,13 @@ class PlaceService: return res # ── 중복 사업장 합치기 ──────────────────────────────────────────────── - # ★ 왜 여기인가 - # 위저드는 **신원을 알기 전에** 사업장을 먼저 만든다(`ensureServerPlace`) — 이름만 - # 아는 빈 행이다. 그리고 이 함수에서 비로소 "이 가게가 누구인지"(네이버 place id)를 - # 알게 된다. 그 순간이 "이미 갖고 있는 그 가게인가" 를 물을 수 있는 첫 지점이다. - # 여기서 안 묻고 지나가면 위저드를 다시 시작할 때마다 같은 가게가 하나씩 늘어난다 — - # 실측(2026-09-10): 로컬 DB 에 '스테이,머뭄' 이 8개였고 그중 7개가 fact 2건짜리 - # 빈 행이었다. 사장님은 목록에서 어느 것이 자기 사이트인지 알 수 없다. - # - # ★ 정본은 **먼저 만든 쪽**이다(`find_by_external` 이 오래된 순으로 준다). - # 나중 것을 정본으로 삼으면 앞서 쌓인 fact·사진·발행 이력이 통째로 버려진다. - # - # ★ 지금 행은 **비어 있을 때만** 지운다. 사장님이 이 행에 뭔가를 쌓았다면(fact·객실· - # 사진·발행) 그건 합치기가 아니라 병합이고, 그건 사람이 판단할 일이다 — - # 그때는 둘 다 남기고 정본만 돌려준다. err_dup, dup_rows = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, lambda sess: self.crud.find_by_external( sess, uuid.UUID(user_info.user_id), ExternalPlaceSource.NAVER.value, str(naver_id), ), ) - # - # ★ **사장님이 새로 만들겠다고 누른 것은 뒤집지 않는다** (2026-09-15) - # 위 합치기는 "위저드를 다시 시작하면 빈 행이 쌓인다" 를 막으려던 것인데, - # [새로 크롤링하고 사이트 생성하기] 와 구분이 없어서 **일부러 다시 만들려는 경우도 - # 기존 사업장으로 끌고 갔다** — 새로 만들기를 눌렀는데 기존 에디터가 열린다. - # 그래서 이어붙이기는 `reuse_existing` 이 켜져 있을 때만 한다. - # - # 끄더라도 **비어 있는 중복 행은 치운다.** 원래 막으려던 누적이 그것이고, - # 빈 행은 잃을 것이 없다. 지금 행은 앞으로 채워질 것이므로 대상이 아니다. + # 끄더라도 **비어 있는 중복 행은 치운다.** 원래 막으려던 누적이 그것이고, 빈 행은 잃을 것이 없다. others = [] if err_dup == ErrorType.SUCCESS: others = [r for r in (dup_rows or []) if str(r.place_id) != str(place_id)] @@ -361,7 +313,7 @@ class PlaceService: phone=base.get("phone") or base.get("virtualPhone") or None, latitude=Decimal(str(coord.get("y"))) if coord.get("y") else None, longitude=Decimal(str(coord.get("x"))) if coord.get("x") else None, - # 네이버 상세의 분류("펜션"·"카페,디저트"). 주변 맛집 경쟁업소 제외의 폴백 근거(실측 2026-09-08: 있음). + # 네이버 상세의 분류("펜션"·"카페,디저트"). category_name=str(base.get("category") or "").strip() or None, ) verified = await self.verify_place(user_info, place_id, verify_req) @@ -380,7 +332,7 @@ class PlaceService: if verified.place: verified.place.name = official - # 붙여넣은 URL 을 채널로 등록·확정한다. 사장님이 직접 가져온 주소라 추가 확인이 필요 없다. + # 붙여넣은 URL 을 채널로 등록·확정한다. canonical = f"https://m.place.naver.com/place/{naver_id}/home" await self.create_link( user_info, place_id, @@ -397,12 +349,7 @@ class PlaceService: return verified async def verify_place(self, user_info: UserInfo, place_id: str, req: Req_VerifyPlace) -> Res_Place: - """외부 장소 DB(카카오/네이버) 조회 결과를 박제해 동일 업소를 확정한다. - - ★ 이걸 통과해야 수집이 열린다(verified_at). - - 식별 근거가 하나도 없으면 확정하지 않는다 — 외부 고유 id(카카오) 또는 도로명주소(네이버) - 중 하나는 있어야 '이 가게가 그 가게'라고 말할 수 있다.""" + """외부 장소 DB(카카오/네이버) 조회 결과를 박제해 동일 업소를 확정한다.""" res = Res_Place() external_id = req.external_place_id.strip() road_address = (req.road_address or "").strip() @@ -425,23 +372,11 @@ class PlaceService: "phone": req.phone, "latitude": req.latitude, "longitude": req.longitude, - # ★ 지역 코드는 **서버가 유도한다**. 외부 장소 DB(카카오·네이버)는 행정구역 코드를 - # 주지 않으므로 후보에도 없고, 그래서 프론트가 보낼 수가 없다 — 클라이언트가 - # 못 채우는 값을 클라이언트에 맡겨 두면 영원히 NULL 로 남는다(실측: 모든 사업장). - # - # region_code 가 비면 지역 정보 캐시를 찾을 키가 없어서 - # 날씨·축제·주변 관광지가 통째로 빈다(local_contents 의 키가 이 값이다). - # 발행본에는 날씨 섹션이 아무것도 그리지 않고, 하이드레이션 뒤 실시간 조회도 - # 막힌다(use-live-weather 가 regionCode 없이는 fetch 하지 않는다). - # - # ★ 지어내지 않는다. 도로명주소에서 '시도 + 시군구' 를 뽑는 것뿐이고, - # 주소가 없거나 형식이 다르면 None 이다(그때는 지역 정보가 비는 게 맞다). - # 요청이 값을 실어 보냈으면 그쪽이 이긴다. + # 지역 코드는 **서버가 유도한다**. "region_code": req.region_code or region_key(road_address), "verified_at": now, "verified_by": uuid.UUID(user_info.user_id), } - # ★ 값이 왔을 때만 덮는다 — 네이버 URL 재검증이 분류를 못 읽었다고 카카오가 준 값을 지우면 안 된다. if (req.category_name or "").strip(): data["external_category"] = req.category_name.strip()[:200] err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( @@ -456,8 +391,6 @@ class PlaceService: return res # 외부 장소 DB 가 준 업체 홈페이지를 공식 홈페이지 채널로 등록해 둔다. - # 실측상 Perplexity 는 이 채널을 잘 못 찾는다 — 검증 단계에서 건지는 게 확실하다. - # 등록만 하고 확정하지는 않는다(확정은 수집 잡이 어댑터 유무를 보고 판단). if (req.place_url or "").strip(): link = place_channels( place_id=uuid.UUID(place_id), @@ -473,7 +406,7 @@ class PlaceService: ) return await self.get_place(user_info, place_id) - # ---- 하위 단위(객실·메뉴·프로그램) ---- + # 하위 단위(객실·메뉴·프로그램) async def list_units(self, user_info: UserInfo, place_id: str) -> Res_UnitList: res = Res_UnitList() err_type, _place = await self._load(user_info, place_id) @@ -512,7 +445,7 @@ class PlaceService: res.unit = UnitData.model_validate(unit) return res - # ---- 채널 링크 ---- + # 채널 링크 async def list_links(self, user_info: UserInfo, place_id: str, confirmed_only: bool = False) -> Res_LinkList: res = Res_LinkList() err_type, _place = await self._load(user_info, place_id) @@ -532,9 +465,7 @@ class PlaceService: return res async def create_link(self, user_info: UserInfo, place_id: str, req: Req_CreateLink) -> Res_Link: - """채널 URL 등록. Perplexity 가 발견한 것도, 사장님이 직접 붙여넣은 것도 여기로 들어온다. - - ★ 확정(confirmed_at)은 별도 액션이다 — 등록만으로 크롤링 대상이 되지 않는다.""" + """채널 URL 등록.""" res = Res_Link() if not req.url.strip(): res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) @@ -563,7 +494,7 @@ class PlaceService: return res async def confirm_link(self, user_info: UserInfo, place_id: str, link_id: str) -> Res_Link: - """★ 동일 업소로 확인된 URL 만 크롤링 대상이 된다. 사업장 검증이 끝나야 확정할 수 있다.""" + """동일 업소로 확인된 URL 만 크롤링 대상이 된다.""" res = Res_Link() err_type, place = await self._load(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -591,17 +522,9 @@ class PlaceService: res.link = next((x for x in links.links if str(x.link_id) == str(link_id)), None) return res - # ---- 수집 시작 ---- + # 수집 시작 async def start_collect(self, user_info: UserInfo, place_id: str, req: Req_StartCollect) -> Res_StartCollect: - """수집 파이프라인을 큐에 넣고 즉시 응답한다. - - 한 건에 몇 분(Perplexity 10~30s + 크롤링 + Vision 사진 배치)이라 동기로 처리할 수 없다. - 클라이언트는 돌려받은 job_id 로 GET /v1/job/{job_id} 를 폴링한다. - - ★ 진입 게이트는 하나 — 동일 업소 검증(verified_at). 검증 없이 긁으면 남의 가게가 섞인다. - 채널 URL 발견(Perplexity)과 확정은 잡 안에서 순서대로 일어난다: - Perplexity URL 발견 → 확정 → 확정된 URL 만 크롤링 → fact/사진 후보 적재 - """ + """수집 파이프라인을 큐에 넣고 즉시 응답한다.""" res = Res_StartCollect() err_type, place = await self._load(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -613,7 +536,6 @@ class PlaceService: return res # 채널 URL 발견(Perplexity)은 **잡의 첫 단계**다 — 링크가 하나도 없어도 수집을 시작할 수 있다. - # 여기서는 이미 확정된 링크 수만 세어 응답에 실어준다(진행 상황 표시용). link_err, links = await DB_SESSION_MNG.execute_lambda( place_channels.DBType(), DBWRType.DB_READ.value, @@ -636,13 +558,10 @@ class PlaceService: "place_id": place_id, "owner_user_id": user_info.user_id, "category": place.category, - # 명시적으로 고른 링크가 있을 때만 대상을 제한한다. 기본 요청에서 현재 - # 확정 링크를 복사하면, 잡의 discover 단계가 새로 확정한 네이버 링크가 - # wanted 필터에서 빠져 사진·정보 수집이 0건으로 끝난다. + # 명시적으로 고른 링크가 있을 때만 대상을 제한한다. "link_ids": [str(x) for x in req.link_ids], "force": req.force, - # 유료 검색은 요청자가 선택한 한 회차에만 실행한다. 이후 확정 링크 크롤링이나 - # 재수집이 이 값을 암묵적으로 물려받으면 같은 URL 을 찾는 데 계속 과금된다. + # 유료 검색은 요청자가 선택한 한 회차에만 실행한다. "discover_channels": req.discover_channels, "requested_by": user_info.user_id, } @@ -658,7 +577,7 @@ class PlaceService: res.status = JobStatus.PENDING res.created = created - # 수집 진행 중임을 사업장 상태에 반영(관리 화면 배지). 실패해도 잡은 이미 들어갔다. + # 수집 진행 중임을 사업장 상태에 반영(관리 화면 배지). if created and place.status == PlaceStatus.DRAFT.value: await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), @@ -669,12 +588,9 @@ class PlaceService: ) return res - # ---- 사진 분석 시작 ---- + # 사진 분석 시작 async def start_vision(self, user_info: UserInfo, place_id: str, req: Req_StartVision) -> Res_StartVision: - """Gemini Vision 사진 분석을 큐에 넣고 즉시 응답한다. - - 수집이 사진을 저장하면 자동으로 걸리지만, 사장님이 사진을 직접 올린 뒤 다시 돌리거나 - force 로 재분석할 때 이 엔드포인트를 쓴다.""" + """Gemini Vision 사진 분석을 큐에 넣고 즉시 응답한다.""" from sqlalchemy import func, select from common.database.model.models import place_photos @@ -692,9 +608,7 @@ class PlaceService: conds = [place_photos.place_id == uuid.UUID(place_id), place_photos.deleted == False] # noqa: E712 if not req.force: - # ★ 미분석 기준은 alt_text 다 — label 은 수집 어댑터가 페이지 캡션으로 채운다. - # label 로 세면 캡션 있는 사진이 전부 '분석됨'으로 빠져 pending 0 이 된다 - # (crud/media_crud.list_media 의 같은 주석 참고). + # 미분석 기준은 alt_text 다 — label 은 수집 어댑터가 페이지 캡션으로 채운다. from sqlalchemy import func as sa_func from sqlalchemy import or_ as sa_or conds.append(sa_or(place_photos.alt_text.is_(None), sa_func.btrim(place_photos.alt_text) == "")) @@ -721,21 +635,9 @@ class PlaceService: res.created = created return res - # ---- 동일 업소 후보 조회 (UI 가 사람에게 고르게 한다) ---- - # ---- 공개 상호명 검색 (랜딩 첫 화면) ---- + # 동일 업소 후보 조회 (UI 가 사람에게 고르게 한다) async def search_places_public(self, query: str, client_ip: str) -> Res_PlaceSearch: - """상호명으로 외부 장소 DB 를 찾아 그대로 돌려준다. **인증도, 사업장 행도 없다.** - - ★ find_candidates 와 무엇이 다른가: 저쪽은 이미 만들어진 사업장의 신원을 확정하는 - 경로라 place_id 와 로그인이 필요하다. 여기는 아직 아무것도 만들지 않은 사람이 - "내 가게가 있나" 를 보는 경로다 — 만들어 보기도 전에 로그인을 요구하지 않기로 한 - 결정(로그인 관문은 에디터 진입 하나)이 API 까지 내려온 것이다. - - ★ pick_match 를 돌리지 않는다. 자동 판정은 확정 경로에서만 의미가 있고, - 여기서는 사람이 목록에서 고르는 게 전부다. - - ★ DB 를 읽지도 쓰지도 않는다. 나가는 값은 외부 장소 DB 가 공개적으로 주는 것뿐이다. - """ + """상호명으로 외부 장소 DB 를 찾아 그대로 돌려준다.""" from common.enums import ExternalPlaceSource from common.utils import rate_limit, ttl_cache from services.external import kakao as kakao_client @@ -749,16 +651,14 @@ class PlaceService: res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) return res - # 같은 검색어는 캐시로 받는다. 사장님이 상호를 고쳐 가며 대여섯 번 치는 동안 - # 네이버를 매번 세 번씩 긁지 않게 한다. + # 같은 검색어는 캐시로 받는다. cache_key = f"place-search:{q}" cached = ttl_cache.get(cache_key) if cached is not None: res.source, res.items = cached return res - # ★ 인증이 없는데 유료 외부 API 를 부른다 — 방어가 0 이면 새로고침만으로 요금이 나간다. - # 프로세스 메모리 기반이라 완전하지 않다(common/utils/rate_limit.py 주석). + # 인증이 없는데 유료 외부 API 를 부른다 — 방어가 0 이면 새로고침만으로 요금이 나간다. if not rate_limit.allow(f"place-search:{client_ip}", _PUBLIC_SEARCH_PER_MIN, 60.0): res.result.SetResult(ErrorType.HTTP_TO_MANY_REQUEST) return res @@ -785,11 +685,7 @@ class PlaceService: res.result.SetResult(ErrorType.LOCAL_FETCH_FAILED) return res - # ★ 여기서 네이버 플레이스 주소까지 찾아 실어 보낸다. - # 전에는 확정 경로(verify/candidates)에서만 찾았는데, 그건 로그인 뒤라 사장님이 - # 후보를 고를 때는 "이 가게의 플레이스가 있는지" 를 알 수 없었다. 화면은 그걸 - # "자동으로 못 찾았다" 로 읽고 지도 주소를 물었다 — 찾을 수 있는데도. - # 실패는 조용히 넘긴다(None). 유료 API 가 아니라 공개 페이지 조회다. + # 여기서 네이버 플레이스 주소까지 찾아 실어 보낸다. naver_ids: dict[str, str] = {} try: naver_ids = await naver_place_lookup.find_place_ids(q, [r.name for r in rows]) @@ -816,8 +712,7 @@ class PlaceService: for row in rows ] if not res.items: - # ★ 빈 결과는 캐시하지 않는다. 네이버가 잠깐 막아서 0건이 나온 것을 굳히면 - # 사장님은 TTL 이 끝날 때까지 아무것도 못 한다. + # 빈 결과는 캐시하지 않는다. res.result.SetResult(ErrorType.PLACE_VERIFY_NO_CANDIDATE) return res @@ -825,12 +720,7 @@ class PlaceService: return res async def find_candidates(self, user_info: UserInfo, place_id: str, query: str | None = None) -> Res_VerifyCandidates: - """외부 장소 DB 에서 이 상호명의 후보를 찾아 그대로 내려준다. - - ★ 서버가 자동으로 확정하지 않는다. outcome 이 MATCHED 여도 후보 전체를 돌려줘 - UI 가 사람에게 보여주고 고르게 한다 — 남의 가게를 붙이는 게 이 서비스에서 제일 비싼 실수다. - 자동 판정은 'UI 가 한 번만 물어봐도 되는가'(auto_selectable)를 알려주는 힌트일 뿐이다. - """ + """외부 장소 DB 에서 이 상호명의 후보를 찾아 그대로 내려준다.""" from common.enums import ExternalPlaceSource from services.external import kakao as kakao_client from services.external import naver as naver_client @@ -841,12 +731,7 @@ class PlaceService: res.result.SetResult(err_type) return res - # ★ 검색어와 판정용 상호명은 다른 값이다. - # 화면은 '상호명 + 위치'를 합쳐 query 로 보낸다(후보를 좁히려고). 그런데 판정 - # (pick_match)은 후보 상호명과 **정확일치**를 보므로, 지역이 붙은 문자열을 그대로 - # 넘기면 정확일치가 영영 성립하지 않는다 — 실측(2026-08-28) 10건 전부 - # AMBIGUOUS(name_no_exact) 였고, 후보가 1건뿐인 경우까지 그랬다. - # 상호명은 places.name 이 들고 있다(위저드가 검색 직전에 상호만 PATCH 한다). + # 검색어와 판정용 상호명은 다른 값이다. search_query = (query or place.name or "").strip() name = (place.name or "").strip() or search_query if not search_query: @@ -884,24 +769,16 @@ class PlaceService: # MATCHED 여도 후보를 전부 내려보낸다 — 사람이 다른 걸 고를 수 있어야 한다. rows = match.candidates or ([match.place] if match.place else []) - # 지역검색 API는 네이버 Place ID를 주지 않는다. 후보를 보여주기 전에 모바일 통합검색에서 - # 상호가 정확히 일치하는 ID를 한 번 찾아, 화면이 "지역 후보 발견"과 "플레이스 발견"을 - # 구분할 수 있게 한다. 실패는 정상적인 fallback이므로 후보 조회 자체는 실패시키지 않는다. + # 지역검색 API는 네이버 Place ID를 주지 않는다. from services.external import naver_place_lookup try: - # ★ 여기는 **넓은 검색어**를 쓴다. 통합검색은 한 번만 부르고(5번 부르면 429) 그 - # 한 페이지 안에서 후보들의 id 를 찾는 구조라, 지역이 붙어 결과가 그 동네로 - # 좁혀질수록 찾을 확률이 올라간다. 판정(pick_match)과는 요구가 정반대다. + # 여기는 **넓은 검색어**를 쓴다. naver_ids = await naver_place_lookup.find_place_ids(search_query, [c.name for c in rows]) except Exception as ex: # noqa: BLE001 — 지도 URL 직접 입력으로 이어진다 LOG.w(f"[verify] 네이버 플레이스 자동 발견 실패(후보는 유지): {type(ex).__name__}: {ex}") naver_ids = {} - # ★ 넓은 검색어 한 페이지에서 못 찾은 후보는 **그 후보만 겨냥해** 한 번 더 찾는다. - # 넓은 검색은 결과가 다른 지점으로 채워져 이름이 아예 안 실릴 때가 있다 — - # 실측(2026-09-03 '스타벅스 판교역점'): 페이지에 id 6개가 있는데 지역검색이 준 - # 5개 지점명은 하나도 그 근처에 없었다. 상호+지역으로 겨냥하면 4/4 로 찾는다. - # 호출은 상위 후보 몇 건으로 끊는다 — 5건을 다 부르면 429 를 받는다. + # 넓은 검색어 한 페이지에서 못 찾은 후보는 **그 후보만 겨냥해** 한 번 더 찾는다. for c in rows[:_TARGETED_LOOKUP_LIMIT]: if c.name in naver_ids: continue @@ -934,14 +811,9 @@ class PlaceService: res.result.SetResult(ErrorType.PLACE_VERIFY_NO_CANDIDATE) return res - # ---- 소개문·FAQ 생성 시작 ---- + # 소개문·FAQ 생성 시작 async def start_copy(self, user_info: UserInfo, place_id: str, req: Req_StartCopy) -> Res_StartCopy: - """소개문·FAQ 생성을 큐에 넣는다. - - ★ 근거로 쓸 확인된 fact 가 없으면 LLM 은 부르지 않는다 — - 근거 없이 문장을 쓰면 그게 환각이고, 유료 호출만 낭비된다. - ★ 그래도 FAQ 카탈로그가 있는 업종(펜션)이면 잡을 만든다 — fact 가 0건이어도 FAQ 는 - 문의 안내로 20개를 채운다(DECISIONS.md 8절). 그 경로는 LLM 을 안 쓰므로 API 키도 필요 없다.""" + """소개문·FAQ 생성을 큐에 넣는다.""" from common.database.model.models import place_facts as facts_model from common.faq_catalog import find_catalog from crud.fact_crud import FactCRUD diff --git a/solution/backend/services/post_service.py b/solution/backend/services/post_service.py index c571eb9..16cb9db 100644 --- a/solution/backend/services/post_service.py +++ b/solution/backend/services/post_service.py @@ -1,11 +1,4 @@ -"""미니 블로그 승인 처리. 기획: docs/MINI_BLOG.md - -★ 메일 링크는 소유권 검사가 토큰 하나다. 그래서 토큰으로 할 수 있는 일을 한 건의 게재로 - 못 박는다 — post_id 를 바꿔 넣을 자리가 없고(토큰 해시로 글을 찾는다), 다른 API 를 - 부르지도 못한다. -★ 빌더 앱 로그인 화면(list_for_owner/edit_by_owner)은 반대로 세션이 신원이다 — place_id 가 - 그 사장님 소유인지를 매번 PlaceCRUD.get_place 로 확인한다. -""" +"""미니 블로그 승인 처리.""" import uuid from datetime import date, datetime, timedelta, timezone @@ -34,8 +27,7 @@ _KST = timezone(timedelta(hours=9)) def _month_range(month: str | None) -> tuple[date, date]: - """"YYYY-MM"(KST 기준, 없으면 이번 달) → 날짜 경계 [시작, 다음달 시작). scheduled_date 가 - 타임존 없는 순수 DATE 라 KST 로 자른 뒤 다시 UTC 로 바꿀 필요가 없다.""" + """"YYYY-MM"(KST 기준, 없으면 이번 달) → 날짜 경계 [시작, 다음달 시작).""" now_kst = datetime.now(_KST) year, mon = (int(part) for part in month.split("-")) if month else (now_kst.year, now_kst.month) start = date(year, mon, 1) @@ -50,8 +42,7 @@ class PostService: self.queue = JobQueue() async def find_by_token(self, token: str): - """살아 있는 토큰이면 글, 아니면 None. 만료와 이미 처리됨을 구분하지 않는다 — - 둘 다 손님(사장님)에게는 '못 쓰는 링크' 하나다.""" + """살아 있는 토큰이면 글, 아니면 None.""" token_hash = blog_service.hash_token(token) post = await DB_SESSION_MNG.execute_lambda( place_posts.DBType(), DBWRType.DB_READ.value, @@ -83,10 +74,7 @@ class PostService: } async def _blog_url(self, place_id) -> str | None: - """이 업장의 발행된 사이트에서 미니 블로그가 보이는 자리. 승인 확인 화면이 몇 초 - 뒤 여기로 자동 연결한다(2026-09-22, 사장님 지시) — 사장님이 승인만 하고 실제로 - 어디에 올라갔는지 못 찾는 걸 줄인다. 사이트가 없거나 아직 미발행이면 None — - 호출부가 자동 연결 없이 확인 문구만 보여준다.""" + """이 업장의 발행된 사이트에서 미니 블로그가 보이는 자리.""" def query(session): return session.execute( select(places, sites) @@ -117,7 +105,7 @@ class PostService: return ErrorType.SUCCESS, place async def list_for_owner(self, user_info: UserInfo, place_id: str, month: str | None) -> Res_MyPosts: - """빌더 앱 — 이번 달(또는 고른 달) 생성된 글 전체. 소유 아니면 빈 목록으로 끝낸다.""" + """빌더 앱 — 이번 달(또는 고른 달) 생성된 글 전체.""" res = Res_MyPosts() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -129,8 +117,7 @@ class PostService: return res async def list_upcoming(self, user_info: UserInfo, place_id: str, days: int = 7) -> Res_MyPosts: - """빌더 앱 상단 카로셀 — 오늘부터 days 일치, 날짜 오름차순. 달력(월 단위)과 별개로 - "당장 챙길 것"만 보여준다(2026-09-17, 사장님 지시).""" + """빌더 앱 상단 카로셀 — 오늘부터 days 일치, 날짜 오름차순.""" res = Res_MyPosts() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -143,9 +130,7 @@ class PostService: return res async def get_post(self, user_info: UserInfo, place_id: str, post_id: str) -> Res_MyPosts: - """메일 '수정하기' 링크(자동 로그인) 전용 — postId 하나로 바로 찾는다. 다른 업장 - 글이면(place_id 불일치) 빈 목록으로 끝낸다 — day-pass 토큰 소유자와 업장이 - 어긋나면 그 링크로 남의 글을 못 보게 한다.""" + """메일 '수정하기' 링크(자동 로그인) 전용 — postId 하나로 바로 찾는다.""" res = Res_MyPosts() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -161,7 +146,7 @@ class PostService: return res async def generation_history(self, user_info: UserInfo, place_id: str) -> Res_GenerationHistory: - """생성 이력 — 언제 몇 건 만들었는지(2026-09-17, 사장님 지시).""" + """생성 이력 — 언제 몇 건 만들었는지.""" res = Res_GenerationHistory() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -185,8 +170,7 @@ class PostService: ) posts = [PostData.model_validate(row) for row in (rows or [])] - # 승인됐는데 아직 안 나간 글이 있을 때만 잡을 들여다본다 — 화면은 발행완료/발행실패만 - # 보여주면 되고(사장님 지시), 그 판정에 필요한 만큼만 조회한다. + # 승인됐는데 아직 안 나간 글이 있을 때만 잡을 들여다본다 — 화면은 발행완료/발행실패만 보여주면 되고(사장님 지시), 그 판정에 필요한 만큼만 조회한다. if any(post.status == PostStatus.APPROVED.value for post in posts): if await self._latest_build_failed(place_id): for post in posts: @@ -195,10 +179,7 @@ class PostService: return posts async def _latest_build_failed(self, place_id) -> bool: - """이 업장의 가장 최근 BUILD 잡이 dead-letter 로 끝났는가. - - ★ BUILD 잡 하나가 그 업장의 승인분 전부를 한 번에 굽는다 — 글 단위 성공/실패가 - 아니라 "이 업장 재발행이 지금 막혀 있나"를 본다.""" + """이 업장의 가장 최근 BUILD 잡이 dead-letter 로 끝났는가.""" def query(session): return session.execute( select(jobs_table.status) @@ -217,10 +198,7 @@ class PostService: async def edit_by_owner( self, user_info: UserInfo, place_id: str, post_id: str, body: str ) -> Res_WebPacketProtocol: - """로그인 세션으로 직접 고치기 — 저장만 한다. ★ 승인은 여기서 하지 않는다(2026-09-21, - 사장님 지시: "승인되야 올라가도록 해야 한다") — 저장 후에는 이메일 승인 링크 - (router/v1/site/post.py approve_page → decide) 또는 바로 아래 approve_by_owner - ("바로 발행" 버튼)를 명시적으로 눌러야 게재된다.""" + """로그인 세션으로 직접 고치기 — 저장만 한다.""" res = Res_WebPacketProtocol() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -241,9 +219,7 @@ class PostService: return res async def approve_by_owner(self, user_info: UserInfo, place_id: str, post_id: str) -> Res_WebPacketProtocol: - """로그인 세션으로 바로 발행 — 고치지 않고 그대로, 또는 방금 edit_by_owner 로 고친 - 그대로 승인한다(2026-09-21, 사장님 지시: "이메일 승인으로도 발행 가능하고 - 바로발행버튼으로도 발행 가능하도록"). 이메일 승인 링크와 별개의 두 번째 경로다.""" + """로그인 세션으로 바로 발행 — 고치지 않고 그대로, 또는 방금 edit_by_owner 로 고친 그대로 승인한다.""" res = Res_WebPacketProtocol() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -256,8 +232,7 @@ class PostService: return res async def delete_by_owner(self, user_info: UserInfo, place_id: str, post_id: str) -> Res_WebPacketProtocol: - """소프트 삭제 — 상태 제한 없이 지운다. 이미 게재된 글이면 그 자리에서 빠지도록 - 재발행 잡까지 큐에 넣는다(그 외 상태는 사이트에 나간 적이 없어 재발행이 필요 없다).""" + """소프트 삭제 — 상태 제한 없이 지운다.""" res = Res_WebPacketProtocol() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -283,8 +258,7 @@ class PostService: return res async def generate_range(self, user_info: UserInfo, place_id: str, start_date: date, end_date: date) -> Res_GenerateNow: - """새벽 크론(04:10)을 기다리지 않고, 사장님이 고른 구간을 그 자리에서 채운다 - (2026-09-17, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까").""" + """새벽 크론(04:10)을 기다리지 않고, 사장님이 고른 구간을 그 자리에서 채운다.""" res = Res_GenerateNow() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -307,8 +281,7 @@ class PostService: return res async def generate_for_date(self, user_info: UserInfo, place_id: str, target_date: date) -> Res_GenerateOne: - """개별 생성 — 달력에서 빈 날짜 하나를 콕 집어 채운다(2026-09-17, 사장님 지시: - "개별적으로 새로 만들수있게 해줘").""" + """개별 생성 — 달력에서 빈 날짜 하나를 콕 집어 채운다.""" res = Res_GenerateOne() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -332,9 +305,7 @@ class PostService: } async def send_now(self, user_info: UserInfo, place_id: str) -> Res_WebPacketProtocol: - """빌더 화면의 '승인 알림보내기' — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 - 바로 보낸다(2026-09-21, 사장님 요청). 보낼 게 없는 건 오류가 아니다 — generate_range - 의 '이미 다 있거나 소재가 없다'와 같은 결의 안내로 끝낸다.""" + """빌더 화면의 '승인 알림보내기' — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 바로 보낸다.""" res = Res_WebPacketProtocol() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -369,8 +340,7 @@ class PostService: await self._try_social_share(post_id, place_id) async def _try_social_share(self, post_id, place_id) -> None: - """쓰레드 연동 — 실패해도 미니블로그 승인은 이미 끝난 뒤라 예외를 밖으로 던지지 - 않는다(2026-09-21, DECISIONS 7-1-2 개정).""" + """쓰레드 연동 — 실패해도 미니블로그 승인은 이미 끝난 뒤라 예외를 밖으로 던지지 않는다.""" try: post = await DB_SESSION_MNG.execute_lambda( place_posts.DBType(), DBWRType.DB_READ.value, @@ -384,11 +354,7 @@ class PostService: LOG.w(f"[blog] post={post_id} 쓰레드 연동 실패 — 미니블로그 승인은 유지") async def _enqueue_build(self, post_id, place_id) -> None: - """게재 = 그 사이트 하나를 다시 굽는 것. 전체 재굽기가 아니다(docs/PUBLISH_VERSION.md). - - ★ owner_user_id 없이 넣으면 build_service.run_build 가 payload["owner_user_id"] 를 - 그대로 읽다 KeyError 로 죽는다 — 정상 발행 경로(site_service.py)는 로그인 세션에서 - 채우지만, 이 경로는 토큰뿐이라 place 에서 직접 찾아야 한다.""" + """게재 = 그 사이트 하나를 다시 굽는 것.""" owner_user_id = await self._owner_user_id(place_id) if owner_user_id is None: LOG.w(f"[blog] place={place_id} owner_user_id 를 못 찾아 재발행 잡을 만들지 않는다") diff --git a/solution/backend/services/prompts/agent.py b/solution/backend/services/prompts/agent.py index a1bd76b..a1d6131 100644 --- a/solution/backend/services/prompts/agent.py +++ b/solution/backend/services/prompts/agent.py @@ -1,25 +1,14 @@ -"""사장님 에이전트 — LLM 은 **무엇을 부를지만** 고른다. - -★ 문장을 짓게 하지 않는다. 실행 결과를 사장님께 알리는 문구는 도구가 직접 만든다 - (services/agent/tools.py). LLM 이 결과 문장을 쓰면 **하지 않은 일을 했다고 말할 수 있고**, - 그 말이 사장님에게는 사실로 보인다. 화면에 뜨는 "바꿨습니다" 는 코드가 보장하는 문장이어야 한다. - -★ LLM 은 등급(확인이 필요한지)도 정하지 않는다. 등급은 레지스트리가 못 박는다 — - 모델이 정하게 두면 프롬프트에 끼어든 한 줄이 확인 절차를 건너뛸 수 있다. -""" +"""사장님 에이전트 — LLM 은 **무엇을 부를지만** 고른다.""" import json RESPONSE_SCHEMA = { - # ★ 타입 이름은 **소문자**다. OpenAI strict 모드가 대문자('STRING')를 거부한다 — - # `Invalid schema for response_format: 'STRING' is not valid under any of the given schemas`. - # Gemini 는 둘 다 받아서, 대문자로 써 두면 공급자를 openai 로 바꾸는 순간에만 터진다. + # 타입 이름은 **소문자**다. "type": "object", "properties": { - # 부를 도구 이름. 못 고르겠으면 빈 문자열. + # 부를 도구 이름. "tool": {"type": "string"}, - # ★ strict 모드는 모든 프로퍼티를 required 로 만든다(llm/openai._to_strict_schema). - # 그래서 안 쓰는 인자는 빈 문자열로 온다 — 도구는 "" 를 '없음' 으로 읽는다. + # strict 모드는 모든 프로퍼티를 required 로 만든다(llm/openai._to_strict_schema). "args": { "type": "object", "properties": { @@ -37,11 +26,7 @@ RESPONSE_SCHEMA = { def build_prompt(*, place_name: str, tools: list[dict], fields: list[dict], facts: list[dict], message: str) -> str: - """사장님 발화 → 도구 하나. - - ★ 모호하면 실행하지 말고 되물으라고 명시한다. 티오더가 "유사한 메뉴가 2개 이상이면 - 후보 목록을 제시" 로 푼 문제와 같다 — 추측으로 고르면 사장님이 승인 화면에서 - 그걸 못 알아채고 넘어간다.""" + """사장님 발화 → 도구 하나.""" return f'''너는 "{place_name}" 사장님의 홈페이지를 관리하는 도우미다. 사장님의 한국어 요청을 읽고 **아래 도구 중 하나**를 골라 JSON 으로 답한다. diff --git a/solution/backend/services/prompts/copy.py b/solution/backend/services/prompts/copy.py index b4d127f..b25900d 100644 --- a/solution/backend/services/prompts/copy.py +++ b/solution/backend/services/prompts/copy.py @@ -58,14 +58,7 @@ def build_prompt( records: Optional[Sequence[str]] = None, suggested_questions: Optional[Sequence[str]] = None, ) -> str: - """소개문·메타·FAQ 생성 프롬프트. - - ★ `records` 는 fact 가 아니라 **글**이다(수집 원문 · 업소 조사 결과). - 예전에는 이것도 fact 목록에 `- source: (수집 원문) = …` 로 섞여 들어갔다. - 모델은 그걸 값 하나로 읽고 거의 쓰지 않았다 — 실측(2026-09-10, 스테이,머뭄): - 조사 근거 448자를 넣어도 소개문은 "주방 시설을 갖춘 독채형 객실을 운영하는 - 숙박업소입니다" 에서 한 발도 못 나갔다. 재료를 재료 자리에 놓아야 쓴다. - """ + """소개문·메타·FAQ 생성 프롬프트.""" sections = [ f"'{place_name}'({_CATEGORY_LABEL.get(category, '사업장')})의 공식 홈페이지 문구를 작성한다.", "", @@ -82,7 +75,6 @@ def build_prompt( ]) if suggested_questions: # 업종 카탈로그 중 위 사실로 답할 수 있는 질문들(services/faq_fill.suggested_questions). - # ★ 채우기는 fact 가 있는 질문을 건너뛴다 — 여기서 모델이 안 쓰면 그 주제는 비어 버린다. sections.extend([ "", "FAQ 로 먼저 쓸 질문(위 사실로 답할 수 있는 것):", @@ -91,8 +83,6 @@ def build_prompt( sections.extend([ "", "출력:", - # ★ 100~250자였다. 그 길이로는 확인된 사실을 나열하면 끝나서, 기록이 있어도 - # 들어갈 자리가 없었다. 시안(/s/stay)의 소개는 3문단 450자 안팎이다. "- intro: 소개문 200~600자. 사실이 충분하면 2~3문단으로 나눈다(문단 사이 빈 줄)", "- intro_fact_keys: 소개문의 근거 key", "- meta_description: 검색 요약 50~120자", @@ -105,8 +95,6 @@ def build_prompt( "- false·불가·없음 값을 가능하다고 표현하지 않는다.", "- 홍보성·평가성 표현을 쓰지 않는다.", "- 근거 없는 FAQ는 만들지 않는다.", - # ★ 실측(2026-09-14, 로컬): 노출 중인 생성 FAQ 4건 중 3건이 "체크인 및 체크아웃" 처럼 두 주제를 묶었다. - # 묶으면 문항 수는 그대로인데 다룬 주제가 줄고, 채우기의 겹침 판정도 두 주제를 함께 지운다. "- FAQ 한 문항에는 주제 하나만 묻는다(예: 체크인과 체크아웃을 한 문항에 묶지 않는다).", "- 한국어 존댓말을 사용한다.", ]) diff --git a/solution/backend/services/prompts/extract.py b/solution/backend/services/prompts/extract.py index 2899d40..6f26e2b 100644 --- a/solution/backend/services/prompts/extract.py +++ b/solution/backend/services/prompts/extract.py @@ -1,24 +1,8 @@ -"""원문 텍스트 → 업종 스키마 fact 추출 프롬프트·응답 스키마. - - 무엇을 묻는가 여기 - 어떻게 부르는가 services/llm/gemini.py - 답을 믿을 것인가 services/grounding/extract.py evidence 대조 - 무엇을 돌려주는가 services/external/gemini_extract.py - -★ 왜 사이트별 파서 대신 이걸 쓰는가 - 사장님 홈페이지는 카페24·아임웹·워드프레스·수제 HTML 이 전부 제각각이고 - JSON-LD 가 있는 곳이 드물다. 도메인마다 파서를 짜는 것은 끝이 없다. - 대신 **원문에서 찾아 오게 하고, 찾았다는 근거를 원문과 대조**한다. - -★ 이 프롬프트의 유일한 임무는 "옮겨 적기" 다. 생성이 아니다. - 값을 추론·환산·보정하지 못하게 막는 문장이 규칙의 대부분인 이유다. - 실제 안전장치는 프롬프트가 아니라 evidence 대조 코드에 있다(grounding/extract.py). -""" +"""원문 텍스트 → 업종 스키마 fact 추출 프롬프트·응답 스키마.""" from common.category_schema import get_schema from common.enums import PlaceCategory -# Gemini responseSchema(OpenAPI subset). ★ evidence 는 required 다 — -# 근거를 못 적는 값은 애초에 받지 않는다. +# Gemini responseSchema(OpenAPI subset). RESPONSE_SCHEMA = { "type": "object", "properties": { @@ -61,35 +45,25 @@ _RULES = """규칙 def _field_lines(category: PlaceCategory) -> str: - """업종 스키마를 그대로 프롬프트에 편다. - - ★ 여기서 목록을 따로 관리하지 않는다 — resources/*.json 이 유일한 출처다. - 업종 필드가 늘면 프롬프트도 자동으로 같이 는다. - """ + """업종 스키마를 그대로 프롬프트에 편다.""" schema = get_schema(category) lines = [] for spec in schema.fields.values(): - # ★ allow_llm 필드(소개문 등)는 목록에 넣지 않는다 — LLM 이 **쓰는** 칸이지 - # 원문에서 옮겨 담는 사실이 아니다. 여기에 원문을 채우면 발행본의 소개가 - # 수집 원문으로 덮인다(2026-08-31 사고, tests/test_collector.py 참고). + # allow_llm 필드(소개문 등)는 목록에 넣지 않는다 — LLM 이 **쓰는** 칸이지 원문에서 옮겨 담는 사실이 아니다. if spec.allow_llm: continue bits = [f"- {spec.key} ({spec.label})", f"type={spec.type}", f"scope={spec.scope}"] if spec.unit: bits.append(f"단위={spec.unit}") if spec.critical: - # ★ 틀리면 예약 클레임이 나는 항목. 애매하면 비우라고 명시한다. + # 틀리면 예약 클레임이 나는 항목. bits.append("※중요-확실할 때만") lines.append(" · ".join(bits)) return "\n".join(lines) def build_prompt(place_name: str, category: PlaceCategory, source_text: str, *, max_chars: int = 30000) -> str: - """추출 프롬프트. - - source_text 는 앞에서 잘라 넣는다 — 사장님 홈페이지 한 페이지 본문은 대개 이 안에 들어오고, - 넘치면 뒤쪽은 대개 푸터·저작권·다른 페이지 링크라 fact 가 거의 없다. - """ + """추출 프롬프트.""" schema = get_schema(category) text = (source_text or "").strip()[:max_chars] diff --git a/solution/backend/services/prompts/itinerary.py b/solution/backend/services/prompts/itinerary.py index 11efff7..e124865 100644 --- a/solution/backend/services/prompts/itinerary.py +++ b/solution/backend/services/prompts/itinerary.py @@ -1,55 +1,8 @@ -"""여행 일정 프롬프트 — "1박 2일·2박 3일을 각각 5개 컨셉 × 2개씩, 총 10개로". +"""여행 일정 프롬프트 — "1박 2일·2박 3일을 각각 5개 컨셉 × 2개씩, 총 10개로".""" -★ 왜 shared 에 두지 않나 - `shared/src/lib/section-prompts.ts` 의 단일 출처 규칙은 "사장님이 복사해 가는 프롬프트와 - 서버가 도는 프롬프트가 **같은 문장**일 때" 의 규칙이다. 이 둘은 하는 일이 다르다 — - 사장님용(`canvas/dataSpec.ts`)은 "3~5개, 반나절·1박2일·2박3일 섞기" 고, 이쪽은 - "기간 고정 + 지정된 컨셉 5개 × 2개" 다. 같은 문장을 두 벌 두는 게 아니므로 daily 때의 분기 - 사고와 성격이 다르다. 문장 자체는 사장님용에서 가져와 변형했다. - -★ 컨셉을 **우리가** 준다 (스파이크 2026-09-11, 스테이,머뭄) - 모델에 맡기면 2박 3일에서 5개 코스 중 **4개가 정거장 집합 100% 동일**이었다(고유 장소 13곳). - 이름만 '가족 체험'·'사진 골목'·'도보 미식' 이고 내용은 같은 8곳의 재배열이다. - 컨셉을 지정하자 고유 장소가 24곳으로 늘고 같은 집합이 사라졌다. - -★ 컨셉 축은 **테마**여야 한다 - '차 없이 걸어서' 를 축으로 넣었더니 '처음 온 손님(대표 명소)' 과 91% 겹쳤다 — - 원도심이 곧 도보권이라 같은 장소로 수렴한다. 제약은 축이 될 수 없다. - -★ 10개(컨셉당 2개)로 늘렸다 (2026-09-11, 사장님 지시: "중복 허용하고 10개로") - 컨셉을 5개 더 늘려 서로 안 겹치게 짜는 대신, 기존 5개 테마 각각에서 2개씩 뽑기로 했다 — - 같은 테마 안에서의 겹침(예: 자연 풍경 코스 둘이 비슷한 종류의 장소를 쓰는 것)은 허용하고, - **정거장 집합이 완전히 같은 것만**(규칙 7) 막는다. 장소가 둘째 코스를 못 채우면 그 컨셉은 - 1개만 낸다 — 지어내지 않는다 원칙의 연장이다. - `MAX_TOKENS` 도 12000 → 16000 으로 올렸다(완성 토큰이 코스 수에 비례해 늘어난다). - ★ 실측(2026-09-11): 컨셉당 2개 지시를 모델이 안정적으로 안 따른다 — 같은 코드로 - "웨스틴 조선 서울" 1박 2일은 5개(컨셉당 1개로 회귀), 2박 3일은 10개가 나왔다. - grounding 은 "0건 버림"이라 우리 쪽 드롭이 아니라 모델이 애초에 적게 낸 것이다. - 개수는 이 프롬프트만으로 완전히 보장되지 않는다 — 아래 시각 강제와 달리, 개수는 - grounding 단계에서 강제할 방법이 없다(장소를 지어낼 수는 없다). 알려진 한계로 둔다. - -★ 하루 시각도 우리가 정한다 (2026-09-11, 사장님 지시) - 체크인 15시·체크아웃 11~14시라는 실제 숙박 흐름에 맞춰 하루의 시작·종료를 고정했다 - (`DAY_SCHEDULE`). 모델에게는 이 시각표를 그대로 따르라고 지시하지만, **강제는 grounding 이 - 한다** — `duration` 을 덮어쓰는 것과 같은 판단이다. 종료 시각을 넘기는 정거장은 - `services/grounding/itinerary._apply_schedule` 이 뒤에서부터 잘라낸다. 이유는 위 컨셉 - 개수와 같다 — 모델의 시각 계산도 안정적이라고 믿을 근거가 없다. - -★ 업소는 출발지에 포함이다 (2026-09-11, 사장님 지시: "업소는 출발지에 포함이야") - 업소 자신을 화면 라벨이 아니라 **정거장(stops[])** 으로 넣는다 — 지도·경로에도 실려야 - 하기 때문이다. 다만 모델에게 만들라고 시키지 않는다. 상호명·좌표는 이미 DB 에 있는 값이라 - 모델이 지어낼 이유가 없다 — grounding 이 `places` 값을 그대로 꽂는다(2026-09-11 결정, - 스키마 검증 없이 셋만 본다는 원칙과 같은 결로: 알 수 있는 값은 우리가 채운다). - `returns=True` 인 날은 정거장 맨 앞(출발)과 맨 뒤(복귀) 둘 다, 마지막 날(체크아웃)은 - 맨 앞(출발)만 넣는다. LLM 이 고른 3~5곳과는 별도로 얹는다 — "하루 3~5곳" 규칙을 세지 않는다. -""" - -# 화면 탭이 되는 값이다(`ItineraryItem.duration`). 표기를 바꾸면 사장님이 적은 일정과 탭이 갈린다 -# — `canvas/dataSpec.ts` 의 options(['반나절','1박 2일','2박 3일'])와 같은 문자열이어야 한다. +# 화면 탭이 되는 값이다(`ItineraryItem.duration`). DURATIONS: tuple[str, str] = ("1박 2일", "2박 3일") -# 2박 3일 5코스가 completion 3,922 토큰까지 갔다(실측). 10개(컨셉당 2개)면 그 두 배 안팎이라 -# 여유를 넉넉히 둔다 — 기본값 2048 은 물론 12000 도 10개에서는 잘릴 수 있다. MAX_TOKENS = 16000 SYSTEM_PROMPT = ( @@ -63,12 +16,7 @@ _CONCEPTS = """1. 역사·근대건축 — 박물관·옛 건물·유적 위주 4. 아이와 함께 — 체험·동물·놀이·넓은 공원 위주 5. 야외활동 — 걷기·자전거·물놀이·전망처럼 몸으로 즐기는 것 위주""" -# 하루하루의 시작·종료 시각(2026-09-11, 사장님 지시). 체크인 15시·체크아웃 11~14시라는 -# 실제 숙박 흐름에 맞췄다. 모델에게 이대로 지시하지만 **grounding 이 강제로 되돌린다** — -# `duration` 을 덮어쓰는 것과 같은 판단이다(모듈 docstring 참고). -# ★ "returns" — 그 날이 끝날 때 업소로 돌아오는가. 마지막 날(체크아웃)만 False 다. -# `grounding/itinerary._apply_schedule` 이 이 값을 보고 업소를 정거장 맨 앞(출발)에, -# returns=True 인 날은 맨 뒤(복귀)에도 넣는다(2026-09-11, 사장님 지시: "업소는 출발지에 포함"). +# 하루하루의 시작·종료 시각. DAY_SCHEDULE: dict[str, tuple[dict[str, object], ...]] = { "1박 2일": ( {"label": "첫째 날", "start": "15:00", "end": "19:00", "returns": True}, @@ -83,12 +31,7 @@ DAY_SCHEDULE: dict[str, tuple[dict[str, object], ...]] = { def _schedule_text(duration: str) -> str: - """DAY_SCHEDULE 을 프롬프트에 박을 문장으로. returns=False 인 날만 "종료(체크아웃)"이고 - 나머지는 "업소 복귀" — 실제 손님의 동선과 같다. - - ★ 업소 자신을 정거장으로 넣으라는 지시는 여기 없다 — 그건 모델이 아니라 grounding 이 - 실제 DB 값(상호명·좌표)으로 직접 채운다. 모델에게는 여전히 "[업소] 자신은 정거장에 - 넣지 않는다"고 시킨다(아래 [해야 할 일]) — 지어낼 여지를 아예 안 준다.""" + """DAY_SCHEDULE 을 프롬프트에 박을 문장으로.""" lines = [] for slot in DAY_SCHEDULE[duration]: ending = "업소 복귀" if slot["returns"] else "종료(체크아웃, 복귀 없음)" @@ -96,13 +39,7 @@ def _schedule_text(duration: str) -> str: return "\n".join(lines) -# ★ verified 를 요구하지 않는다. 화면에 안 나오고(`SourceLine` 이 `void verified`), -# 모델은 좌표가 1.7km 틀린 항목에도 "확인" 을 붙였다 — 자기 신고는 믿을 값이 아니다. -# -# ★ `str.format` 을 쓰지 않고 `%` 치환을 쓴다. 이 문자열은 JSON 이라 리터럴 중괄호가 가득한데, -# format 은 그것을 필드명으로 읽고 터진다(`{ "kind":"itinerary"` 를 키로 해석한다). -# 중괄호를 `{{`/`}}` 로 이중화하는 길도 있지만, 프롬프트 본문이 눈으로 읽히지 않게 된다 — -# 스파이크가 검증한 방식(%)을 그대로 써서 보내는 바이트를 같게 유지한다. +# verified 를 요구하지 않는다. _SCHEMA = """{ "kind":"itinerary", "version":1, "title":"추천 일정", "items":[ { "name":"코스 이름(컨셉이 드러나게)", "duration":"%(duration)s", @@ -157,11 +94,7 @@ _TASK = """[업소] %(place)s def build_prompt(place_name: str, region_label: str, duration: str) -> str: - """업소 하나 × 기간 하나의 프롬프트. - - ★ region_label 을 반드시 넣는다. 지역을 모른 채로 물으면 모델이 아무 도시나 고른다 - (`story_service.region_label_of` 와 같은 판단). 부르는 쪽이 빈 지역을 걸러 준다. - """ + """업소 하나 × 기간 하나의 프롬프트.""" if duration not in DURATIONS: raise ValueError(f"모르는 기간: {duration}") ctx = {"place": place_name, "region": region_label, "duration": duration} diff --git a/solution/backend/services/prompts/place_research.py b/solution/backend/services/prompts/place_research.py index 8bfd0e3..da7842c 100644 --- a/solution/backend/services/prompts/place_research.py +++ b/solution/backend/services/prompts/place_research.py @@ -1,25 +1,4 @@ -"""업소 조사 프롬프트 — 소개문을 쓸 **근거**를 공개 웹에서 찾아온다. - -★ 무엇을 요구하나 - "소개문을 써 달라"가 아니다. **사실 조각을 출처와 함께** 달라고 한다. - 소개문을 모델에게 바로 시키면 그 문장이 어디서 왔는지 알 수 없고, 우리 규칙은 - 근거 없는 문장을 발행하지 않는다(`copy_service.ground_check`). 그래서 이 단계는 - 재료만 모으고, 문장은 기존 생성기(Gemini)가 그 재료로 쓴다. - -★ 왜 이게 필요한가 (실측 2026-09-10, 스테이,머뭄) - 네이버 플레이스가 주는 fact 는 3건(주차·와이파이·휠체어)뿐이고 TourAPI 는 미등록, - 예약 페이지와 인스타그램은 robots 가 자동 수집을 금지한다. 그 상태로 소개문을 생성하면 - "군산시에 있는 스테이,머뭄입니다. 주차 가능." 한 줄이 나온다 — 쓸 재료가 그것뿐이라 - 생성기 잘못이 아니다. 그런데 이 업소에 대한 사실(1920년대 고택 · 히로쓰 가옥 옆 · - 2024년 리모델링 · A동/B동)은 블로그·기사에 공개돼 있다. 그걸 **출처와 함께** 가져오는 - 자리가 없었을 뿐이다. - -★ 지어내게 두지 않는다 - - 항목마다 출처 URL 을 요구한다. 없으면 버린다(`grounding/place_research.py`). - - 확인할 수 없는 것은 비우라고 명시한다. 모델은 빈칸을 싫어해서, 안 그러면 채운다. - - **가격·객실 수·운영 규정은 묻지 않는다.** 그건 fact 이고, 틀리면 예약 클레임이 난다 — - 출처가 블로그면 옛 요금이 그대로 올라온다. 이 단계가 모으는 것은 **소개문의 재료**다. -""" +"""업소 조사 프롬프트 — 소개문을 쓸 **근거**를 공개 웹에서 찾아온다.""" SYSTEM_PROMPT = ( "당신은 지역 업소를 조사하는 사람이다. 웹에서 확인되는 사실만 적는다. " @@ -27,12 +6,12 @@ SYSTEM_PROMPT = ( "출력은 JSON 하나뿐이고 코드펜스를 두르지 않는다." ) -# ★ 최대 개수를 둔다. 많이 받아 봐야 소개문 한 문단이고, 길수록 옛 정보가 섞인다. +# 최대 개수를 둔다. MAX_ITEMS = 8 def build_prompt(name: str, address: str, category_label: str) -> str: - """조사 프롬프트. 상호와 주소를 **둘 다** 준다 — 동명 업소를 가르는 유일한 단서다.""" + """조사 프롬프트.""" return f"""다음 업소에 대해 웹에서 확인되는 사실을 모아라. 상호: {name} diff --git a/solution/backend/services/prompts/social.py b/solution/backend/services/prompts/social.py index 406ea94..08f1cfe 100644 --- a/solution/backend/services/prompts/social.py +++ b/solution/backend/services/prompts/social.py @@ -1,8 +1,6 @@ import json -# ★ 타입 이름은 소문자다. OpenAI strict 모드가 대문자('STRING')를 거부한다 — -# Gemini 는 둘 다 받아서, 대문자로 두면 **공급자를 openai 로 바꾸는 순간에만** 터진다 -# (실측 2026-09-21: LLM_PROVIDER 기본값이 openai 인데 이 파일만 대문자로 남아 있었다). +# 타입 이름은 소문자다. RESPONSE_SCHEMA = {'type': 'object', 'properties': { 'body': {'type': 'string'}, 'fact_keys': {'type': 'array', 'items': {'type': 'string'}}, diff --git a/solution/backend/services/prompts/song.py b/solution/backend/services/prompts/song.py index 22a9991..6bd94fc 100644 --- a/solution/backend/services/prompts/song.py +++ b/solution/backend/services/prompts/song.py @@ -1,21 +1,6 @@ -"""이 숙소의 노래 — 가사 프롬프트와 응답 스키마. +"""이 숙소의 노래 — 가사 프롬프트와 응답 스키마.""" -★ 왜 가사를 **우리가** 쓰고 Suno 에는 작곡만 시키나 - Suno 에 "군산 한옥 숙소 노래" 라고만 던지면 가사를 저쪽이 지어낸다. 그 가사에는 이 숙소에 - 없는 것(수영장·조식·오션뷰)이 섞이고, 우리는 그걸 검증할 방법이 없다 — 사이트의 다른 - 모든 문장은 확인된 fact 로만 쓰는데 노래만 지어낸 말을 싣는 꼴이 된다. - 그래서 가사는 소개문과 **같은 재료**(확인된 fact + 소개문)로 여기서 쓰고, Suno 는 그 가사에 - 곡을 붙이기만 한다. - -★ 그래도 가사는 사실 진술이 아니다 - "밤이 깊어도 불이 켜져 있다" 같은 줄은 fact 가 아니라 분위기다. 그래서 `ground_check` 를 - 걸지 않는다 — 대신 프롬프트가 **없는 시설·없는 숫자를 말하지 말라**고 못 박는다. - 요금·전화번호·주소를 가사에 넣지 않는 것도 같은 이유다(틀리면 예약 클레임이고, 노래는 - 고쳐 부르기도 어렵다). -""" - -# Suno 가 받는 style 문자열. 장르를 모델이 고르게 두되 후보를 좁힌다 — -# 열어 두면 숙소 사이트에 어울리지 않는 것(하드록·트랩)이 나온다. +# Suno 가 받는 style 문자열. STYLE_CHOICES = [ "acoustic ballad", "city pop", "folk pop", "lo-fi", "bossa nova", "soft rock", "jazz", ] @@ -32,7 +17,7 @@ RESPONSE_SCHEMA = { def build_prompt(place_name: str, category_label: str, region: str, grounding: list[str], intro: str) -> str: - """가사 프롬프트. 재료는 소개문 생성과 같은 것을 받는다.""" + """가사 프롬프트.""" material = "\n".join(f"- {line}" for line in grounding) or "- (확인된 항목 없음)" intro_block = f"\n[이 숙소 소개문]\n{intro.strip()}\n" if (intro or "").strip() else "" diff --git a/solution/backend/services/prompts/story.py b/solution/backend/services/prompts/story.py index 760cea0..5f7f5b9 100644 --- a/solution/backend/services/prompts/story.py +++ b/solution/backend/services/prompts/story.py @@ -1,11 +1,4 @@ -"""지역 이야기 프롬프트 계약 — 문장은 여기서 만들지 않고 **읽어 온다**. - -★ 단일 출처는 `solution/shared/src/lib/section-prompts.ts` 다. - 사장님이 [콘텐츠] 탭에서 복사해 가는 프롬프트와 서버가 도는 프롬프트가 같아야 한다 — - 갈리면 "빌더에서 뽑은 것과 자동으로 채워진 것의 모양이 다르다"가 조용히 생긴다. - 이 파일이 읽는 `section_prompts.json` 은 `npm run export:prompts` 산출물이고 커밋된다. - **손으로 고치지 않는다.** -""" +"""지역 이야기 프롬프트 계약 — 문장은 여기서 만들지 않고 **읽어 온다**.""" import json from functools import lru_cache from pathlib import Path @@ -20,7 +13,7 @@ SYSTEM_PROMPT = ( @lru_cache(maxsize=1) def _spec() -> dict: - """산출물 로드. 없으면 즉시 터뜨린다 — 프롬프트 없이 도는 생성 잡은 빈 값을 쓴다.""" + """산출물 로드.""" try: return json.loads(_SPEC_PATH.read_text(encoding="utf-8")) except FileNotFoundError as ex: # pragma: no cover - 배포 누락은 기동 시 바로 드러난다 @@ -42,12 +35,7 @@ def max_items(kind: str) -> int: def build_prompt(kind: str, region: str) -> str: - """지역 하나 × 종류 하나의 프롬프트. - - ★ 업소 이름을 넣지 않는다. 이 값은 **지역**에 붙어 같은 지역 사이트가 나눠 쓴다 — - 업소 하나를 골라 넣으면 그 집 이야기가 옆집 사이트에 실린다. - `shared/section-prompts.ts` 의 `buildSectionPrompt` 가 같은 분기를 갖고 있다. - """ + """지역 하나 × 종류 하나의 프롬프트.""" spec = _spec()["specs"].get(kind) if spec is None: raise ValueError(f"모르는 지역 이야기 종류: {kind}") diff --git a/solution/backend/services/publish_gate.py b/solution/backend/services/publish_gate.py index 6ff9ef7..549369b 100644 --- a/solution/backend/services/publish_gate.py +++ b/solution/backend/services/publish_gate.py @@ -1,16 +1,4 @@ -"""발행 검수 게이트 — 사이트가 나가기 전에 반드시 통과해야 하는 검사. - -이 파일이 절대규칙 1~3 을 코드로 강제하는 유일한 자리다. 여기를 우회하는 발행 경로를 만들면 안 된다. - - ★ 규칙 1 미검증 fact 는 응답에 포함하지 않는다. - 특히 체크인·취사·반려동물·취소 규정 — 틀린 채로 발행되면 실제 예약 클레임이 난다. - ★ 규칙 2 고유 콘텐츠가 1건도 없으면 발행 API 가 거부한다. 같은 템플릿 대량 생성은 스팸 판정 대상. - ★ 규칙 3 구조화 데이터(JSON-LD) 값 = 화면에 보이는 값. 불일치 시 빌드 실패. - + 업종 스키마의 required 필드가 비면 발행하지 않는다(빈 껍데기 페이지 방지). - -게이트는 **판정만** 한다. 스냅샷을 만들거나 빌드하지 않는다 — 그래야 테스트가 쉽고, -빌드 경로가 바뀌어도 규칙은 한 곳에 남는다. -""" +"""발행 검수 게이트 — 사이트가 나가기 전에 반드시 통과해야 하는 검사.""" from dataclasses import dataclass, field from common.category_schema import get_schema @@ -19,7 +7,7 @@ from common.enums import PUBLISHABLE_FACT_STATUSES, FactStatus, PlaceCategory, P @dataclass class GateResult: - """검수 결과. passed 가 False 면 reason 과 detail 이 publish_logs 에 그대로 실린다.""" + """검수 결과.""" passed: bool reason: PublishRejectReason | None = None @@ -30,10 +18,7 @@ class GateResult: def check_facts_verified(facts: list) -> GateResult: - """★ 규칙 1 — 노출 대상 fact 가 전부 검증됐는가. - - facts 는 '사이트에 실을 예정인' fact 행 목록이다. 하나라도 VERIFIED/CORRECTED 가 아니면 거부한다. - 호출측이 이미 필터링했더라도 여기서 다시 본다 — 필터를 빠뜨린 경로가 생겨도 여기서 막힌다.""" + """규칙 1 — 노출 대상 fact 가 전부 검증됐는가.""" bad = [ {"key": f["key"] if isinstance(f, dict) else f.key, "status": FactStatus(f["status"] if isinstance(f, dict) else f.status).name} @@ -46,7 +31,7 @@ def check_facts_verified(facts: list) -> GateResult: def check_required_fields(category: PlaceCategory, facts: list) -> GateResult: - """업종 스키마의 required 필드가 다 있는가. 없으면 빈 껍데기 페이지가 된다.""" + """업종 스키마의 required 필드가 다 있는가.""" schema = get_schema(category) required = set(schema.required_keys("place")) have = { @@ -64,17 +49,7 @@ def check_required_fields(category: PlaceCategory, facts: list) -> GateResult: def check_unique_content(unique_content_count: int | None) -> GateResult: - """렌더러가 '고유 콘텐츠 0건' 으로 거부했을 때 사유 코드를 붙이는 자리. - - evaluate() 는 이걸 부르지 않는다(얇은 콘텐츠로 발행을 막지 않기로 했다). - 부르는 곳은 build_service — 렌더 보고서가 실패로 왔을 때 그 이유를 되짚는다. - - 이 가게에만 있는 것(소개문·FAQ·객실 설명·사진 alt·템플릿 아닌 fact 값)이 0이면 - 같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다. - - ★ None 은 '0건' 이 아니라 '재지 못했다' 다. 렌더러가 디스크·번들 문제로 죽으면 계수가 - 없는 채로 보고서가 온다 — 그걸 0 으로 읽으면 디스크 오류에 NO_UNIQUE_CONTENT 라는 - 엉뚱한 사유가 붙는다. 판정하지 않고 통과시키고, 진짜 사유는 report.error 가 말한다.""" + """렌더러가 '고유 콘텐츠 0건' 으로 거부했을 때 사유 코드를 붙이는 자리.""" if unique_content_count is None: return GateResult(True) if unique_content_count <= 0: @@ -83,10 +58,7 @@ def check_unique_content(unique_content_count: int | None) -> GateResult: def check_jsonld_matches(mismatches: list) -> GateResult: - """★ 규칙 3 — 구조화 데이터 값이 화면 값과 같은가. - - JSON-LD 는 AI 검색이 읽는 값이고 화면은 사람이 읽는 값이다. 둘이 다르면 - '검색엔진에만 다른 말을 하는' 상태가 된다 — 클로킹으로 취급될 수 있고, 무엇보다 거짓이다.""" + """규칙 3 — 구조화 데이터 값이 화면 값과 같은가.""" if mismatches: return GateResult(False, PublishRejectReason.JSONLD_MISMATCH, {"mismatches": list(mismatches)[:20]}) return GateResult(True) @@ -95,20 +67,7 @@ def check_jsonld_matches(mismatches: list) -> GateResult: def evaluate( category: PlaceCategory, facts: list, unique_content_count: int | None, mismatches: list ) -> GateResult: - """게이트 전체. **처음 걸린 것에서 멈춘다** — 운영자가 하나씩 고치게 사유를 하나만 준다. - - 순서는 심각도 순: 미검증(클레임) → JSON-LD 불일치(거짓). - - ★ unique_content_count 는 받되 여기서 막지 않는다 — 얇은 콘텐츠는 '틀린 것' 이 아니다 - (test_evaluate_does_not_block_thin_content). 실제로 페이지 쓰기를 거부하는 쪽은 - 렌더러이고, 그 사유는 build_service 가 check_unique_content 로 되짚어 라벨을 붙인다. - - ★ 업종 필수 항목 누락은 **막지 않는다**(2026-08-27 결정). - 막아야 할 것은 "틀린 정보가 나가는 것"이지 "정보가 덜 찬 것"이 아니다. - 영업시간이 비어 있어도 주소·전화가 확인된 페이지는 그 자체로 쓸모가 있고, - 사장님은 발행 뒤에 언제든 채워 넣을 수 있다(채우면 재빌드된다). - 대신 무엇이 비었는지는 계속 알려준다 — warnings 로 내려보내 화면이 띄운다. - """ + """게이트 전체.""" for result in ( check_facts_verified(facts), check_jsonld_matches(mismatches), diff --git a/solution/backend/services/render_report.py b/solution/backend/services/render_report.py index 7c642fa..ad8d4e7 100644 --- a/solution/backend/services/render_report.py +++ b/solution/backend/services/render_report.py @@ -1,28 +1,14 @@ -"""렌더 보고서 — SSG 가 실제로 페이지를 구웠는지 확인하는 창구. - -★ 왜 필요한가 - BUILD 잡은 payload JSON 을 쓰고 렌더러를 직접 돌린다(services/render_service). 렌더러는 - 굽는 동안 있었던 일(성공/실패, 구조화 데이터 대조, 고유 콘텐츠 수)을 사이트마다 - `/.status/.json` 에 남긴다 — 백엔드에 마운트된 디렉토리가 payload - 디렉토리뿐이라 보고서도 그 안에 둔다. - -★ 여기서 하는 일 — 그 보고서를 읽는다. - render_service 가 렌더러 subprocess 를 기다린 **직후** 한 번 읽어 발행 여부를 정하고, - 사이트 조회 API(render_status)는 아무 때나 다시 읽어 "지금 이 버전이 구워져 있나" 를 보여준다. - -★ 읽기 전용이다. 보고서가 없거나 깨져 있어도 예외를 밖으로 내보내지 않는다 — - 사이트 조회가 부수 산출물 때문에 실패하면 안 된다. -""" +"""렌더 보고서 — SSG 가 실제로 페이지를 구웠는지 확인하는 창구.""" import json from pathlib import Path from common.logger import LOG from services.site_payload import payload_dir -# 프리렌더가 쓰는 보고서 스키마 버전. 모양이 바뀌면 prerender.ts 와 같이 올린다. +# 프리렌더가 쓰는 보고서 스키마 버전. REPORT_SCHEMA_VERSION = 1 -# 보고서 디렉토리 이름. prerender.ts 의 writeReport 와 맞춰야 한다. +# 보고서 디렉토리 이름. STATUS_DIR = ".status" def report_path(slug: str) -> Path: @@ -31,9 +17,7 @@ def report_path(slug: str) -> Path: def read_report(slug: str) -> dict | None: - """렌더 보고서를 읽는다. 없거나 깨졌으면 None. - - ★ 없는 것과 깨진 것을 구분하지 않는다. 호출측이 할 일은 같다 — '아직 못 구웠다'로 본다.""" + """렌더 보고서를 읽는다.""" if not slug: return None path = report_path(slug) @@ -55,13 +39,7 @@ def read_report(slug: str) -> dict | None: def render_status(slug: str, site_version: int | None = None) -> dict: - """사이트 조회 API 가 그대로 내보내는 렌더 상태. - - state PENDING 아직 안 구웠다(보고서 없음) - STALE 구웠지만 지금 버전이 아니다(발행 후 프리렌더 대기 중) - OK 현재 버전이 구워져 있다 - FAILED 프리렌더가 실패했다 — 페이지가 없거나 낡았다 - """ + """사이트 조회 API 가 그대로 내보내는 렌더 상태.""" report = read_report(slug) if report is None: return {"state": "PENDING", "rendered_at": None, "error": None, "rendered_version": None} diff --git a/solution/backend/services/render_service.py b/solution/backend/services/render_service.py index f9dc15a..9eb83ae 100644 --- a/solution/backend/services/render_service.py +++ b/solution/backend/services/render_service.py @@ -1,21 +1,4 @@ -"""렌더러 실행 — Python 워커가 미리 컴파일된 Node 렌더러를 subprocess 로 직접 돌린다. - -★ 왜 (프리렌더를 워커 실행으로 통합, 2026-09-15) - 예전에는 `solution-prerender` 라는 별도 상시 컨테이너가 payloads/ 디렉토리를 2초마다 - 폴링하며 바뀐 파일을 구웠고, BUILD 잡은 `.status/.json` 이 나타나기를 또 폴링했다 - (render_report.wait_for). 컨테이너 하나·폴링 두 겹이 전부 "굽는 걸 어떻게 아느냐"는 - 같은 문제를 풀고 있었다 — 워커가 굽기를 직접 돌리면 둘 다 필요 없다: subprocess 가 - 끝나는 순간이 곧 "구워졌다"는 신호다. - -★ 이 파일은 payload → 렌더러 실행 → 구조화된 결과, 그 경계만 다룬다. - Python 은 여전히 HTML 을 만들지 않는다(ARCHITECTURE 1절) — 여기서 하는 일은 미리 빌드된 - JS 파일 하나를 `node` 로 실행하고 그 결과 보고서(.status/.json, prerender.ts 가 쓴다) - 를 읽어 돌려주는 것뿐이다. 렌더러 코드 자체는 solution/site 소유 그대로다. - -★ 워커 이미지에는 컴파일된 렌더러(`dist/client` · `dist/prerender/prerender.js`)와 `public/` - 만 있으면 된다 — node_modules 는 필요 없다(vite.config.ts `ssr.noExternal: true`, 실측: - node_modules 를 지우고 실행해도 그대로 돈다). 기동·잡 실행 중에 npm install 을 하지 않는다. -""" +"""렌더러 실행 — Python 워커가 미리 컴파일된 Node 렌더러를 subprocess 로 직접 돌린다.""" import asyncio import fcntl import os @@ -24,25 +7,20 @@ from pathlib import Path from common.logger import LOG from services import render_report -# 컴파일된 렌더러 진입점. Dockerfile 의 site-builder 스테이지가 여기에 굽는다. +# 컴파일된 렌더러 진입점. RENDERER_ENTRY_ENV = "RENDERER_ENTRY" DEFAULT_RENDERER_ENTRY = "/app/solution/site/dist/prerender/prerender.js" NODE_BIN_ENV = "NODE_BIN" DEFAULT_NODE_BIN = "node" -# 렌더러가 굽는 산출물 루트(out/). services/azure_static·indexnow 와 **같은 env** 를 본다 — -# 렌더러가 쓴 자리와 백엔드가 훑는 자리가 어긋나면 "구웠는데 없다"는 조용한 실패가 난다. +# 렌더러가 굽는 산출물 루트(out/). OUTPUT_DIR_ENV = "SITE_OUTPUT_DIR" DEFAULT_OUTPUT_DIR = "/app/solution/site/out" class RenderFailed(RuntimeError): - """렌더러 프로세스 자체가 죽었거나(비정상 종료) 보고서를 남기지 못했다. - - ★ 게이트 반려(구조화 데이터 불일치·고유 콘텐츠 0건)는 여기 안 걸린다 — 그건 exit code - 1 이어도 보고서가 정상적으로 남으므로 호출측이 report.ok 로 판단한다. 이 예외는 - "무슨 일이 있었는지조차 모른다" 는 경우만 위한 것이다.""" + """게이트 반려(구조화 데이터 불일치·고유 콘텐츠 0건)는 여기 안 걸린다 — 그건 exit code 1 이어도 보고서가 정상적으로 남으므로 호출측이 report.ok 로 판단한다.""" def renderer_entry() -> str: @@ -74,10 +52,7 @@ async def _run_subprocess(args: list[str], timeout_sec: float) -> tuple[int | No async def _run_unlocked(args: list[str], timeout_sec: float) -> tuple[int | None, str]: - """렌더러 프로세스를 돌리고 (exit_code, stderr 일부) 를 돌려준다. - - ★ 타임아웃이면 프로세스를 죽이고 회수한다 — 좀비로 남겨 워커 컨테이너의 프로세스 표를 - 채우면 안 된다. exit_code 는 None 으로 남아 "시간 안에 안 끝났다" 를 구분한다.""" + """렌더러 프로세스를 돌리고 (exit_code, stderr 일부) 를 돌려준다.""" proc = await asyncio.create_subprocess_exec( *args, stdout=asyncio.subprocess.PIPE, @@ -100,13 +75,7 @@ async def _run_unlocked(args: list[str], timeout_sec: float) -> tuple[int | None async def render_site(payload_path: str, site_version: int, timeout_sec: float) -> dict: - """payload 파일 하나를 굽는다. 끝나면 렌더 보고서(dict)를 돌려준다. - - ★ 게이트 판정은 여기서 하지 않는다 — 반환된 보고서의 `ok`·`mismatches`· - `uniqueContentCount` 를 build_service.run_build 가 그대로 판단한다(예전과 같은 계약, - render_report.wait_for 가 폴링해서 얻던 것과 같은 모양이다). - ★ 멱등이다 — 같은 payload 파일로 다시 부르면 같은 결과가 나온다(재시도 안전). 렌더러 - 자신이 사이트별로 실패를 격리하므로, 이미 성공한 사이트를 다시 구워도 결과는 같다.""" + """payload 파일 하나를 굽는다.""" slug = Path(payload_path).stem # 재시도에서 같은 버전의 옛 성공 보고서를 이번 실행 결과로 읽지 않는다. render_report.report_path(slug).unlink(missing_ok=True) @@ -126,9 +95,6 @@ async def render_site(payload_path: str, site_version: int, timeout_sec: float) if code in (0, 1) and report is not None and report.get("siteVersion") == site_version: return report - # ★ 프로세스가 죽거나 시간 초과였는데 보고서도 없으면(사이트를 하나도 돌기 전에 죽은 경우) - # 대신 실패로 남긴다 — 안 그러면 build_service 가 "렌더 결과를 못 받았다" 는 것만 알고 - # 진짜 사유(모듈 누락·OOM 등)는 워커 로그에만 남는다. reason = ( f"렌더러가 {timeout_sec:.0f}초 안에 끝나지 않았다" if code is None diff --git a/solution/backend/services/review_service.py b/solution/backend/services/review_service.py index 6c06a7d..d9895bd 100644 --- a/solution/backend/services/review_service.py +++ b/solution/backend/services/review_service.py @@ -1,13 +1,4 @@ -"""이용 후기 — 손님이 남기면 **그 자리에서 뜬다**. - -★ 정적 사이트인데도 즉시 보이는 방법은 날씨(useLiveWeather)가 이미 쓰는 것이다 — - 구운 HTML 에는 굽는 시점까지의 후기가 들어가고, 브라우저가 붙은 뒤 최신 목록을 한 번 더 - 받아 덮는다. 크롤러는 구운 것을 읽고 손님은 최신을 본다. -★ 그래서 사람 검수를 앞에 두지 않는다(2026-09-16 대표: "그냥 뜨게 하지"). 대신 기계가 - 거른다 — 길이·연락처. 문제 글은 어드민에서 **내린다**(사후 대응). -★ 사진은 받지 않는다. 받는 순간 남의 파일을 우리가 호스팅한다. -★ JSON-LD 로 내보내지 않는다 — 자체 수집 후기는 구글 리치결과 대상이 아니다. -""" +"""이용 후기 — 손님이 남기면 **그 자리에서 뜬다**.""" import hashlib import os import re @@ -95,7 +86,7 @@ class ReviewService: return bool(result and result.first()) async def list_public(self, place_id, limit: int = 200): - """화면이 붙은 뒤 받아 가는 최신 목록. 내려간 글은 여기서 빠진다.""" + """화면이 붙은 뒤 받아 가는 최신 목록.""" from router.v1.site.review import PublicReview, ResPublicReviews def query(session): diff --git a/solution/backend/services/rollback_service.py b/solution/backend/services/rollback_service.py index 4b6a005..c2553c5 100644 --- a/solution/backend/services/rollback_service.py +++ b/solution/backend/services/rollback_service.py @@ -1,24 +1,4 @@ -"""예전 버전으로 되돌리기 — ROLLBACK 잡이 하는 일. - - 대상 버전 스냅샷 로드 → payload 재조립 → 렌더 → 게이트 → 공개 주소 전환 - -★ 재수집·재생성을 하지 않는다. run_build 와 다른 점이 이것 하나다 — 대상 버전이 - 박제해 둔 snapshot(site_versions.snapshot)을 **그대로** 다시 payload 로 옮길 뿐이다. - 사장님이 그때 이미 확인하고 발행했던 값이므로, 지금 fact 상태가 바뀌었어도 롤백은 - 그 시점의 값을 그대로 복원한다 — "롤백했는데 최신 수정이 섞여 있다"를 막는다. - -★ 그래도 게이트는 다시 돈다. 스냅샷은 그대로지만 **렌더러는 그때와 다를 수 있다** - (그 사이 배포됐을 수 있다) — 새 렌더러가 옛 스냅샷을 어떻게 굽는지는 다시 확인해야 한다. - -★ 재굽기가 필요 없을 수도 있다 — 대상 버전 디렉토리가 보관 기간 안에 아직 디스크에 있으면 - 렌더러가 그 사실을 알아서 이용한다(prerender.ts publishVersion 은 렌더 자체는 매번 다시 - 하지만, 결과가 같으면 파일 내용도 같다 — 멱등이다). 디스크에서 지워졌어도 문제없다 — - snapshot 이 있으니 처음부터 다시 굽는다. - -★ 실패하면 지금 공개된 버전은 그대로다. render_service.render_site 가 실패를 던지거나 - report.ok=False 면 심볼릭 링크를 아예 안 돌린다(prerender.ts publishVersion 은 성공한 - 렌더 결과에만 닿는다) — 발행 파이프라인과 같은 원자성 보장이다. -""" +"""대상 버전 스냅샷 로드 → payload 재조립 → 렌더 → 게이트 → 공개 주소 전환""" import os import uuid @@ -64,7 +44,7 @@ async def _log(site_id, version_id, result: PublishResult, gate=None, actor=None async def run_rollback(job: dict) -> dict: - """ROLLBACK 잡 핸들러. payload: {place_id, owner_user_id, target_version, requested_by?}""" + """ROLLBACK 잡 핸들러.""" payload = job["payload"] place_id = payload["place_id"] owner_user_id = payload["owner_user_id"] @@ -104,8 +84,7 @@ async def run_rollback(job: dict) -> dict: result["rolled_back"] = False result["error"] = reason LOG.w(f"[rollback] place={place_id} v{target_version} 실패: {reason}") - # ★ 게이트 반려는 알리지 않는다 — build_service._fail 과 같은 규칙(운영자를 부를 - # 일이 아니다). 렌더·전환·업로드가 죽은 경우만 알린다. + # 게이트 반려는 알리지 않는다 — build_service._fail 과 같은 규칙(운영자를 부를 일이 아니다). if gate is None: await alert_service.send_alert( kind="build_failed", @@ -115,7 +94,7 @@ async def run_rollback(job: dict) -> dict: ) return result - # ★ snapshot 을 그대로 옮긴다 — 재수집하지 않는다(파일 머리주석 참조). + # snapshot 을 그대로 옮긴다 — 재수집하지 않는다(파일 머리주석 참조). links = await load_channel_links(place_id) payload_dict = await site_payload.prepare_site_payload( place, version.snapshot, site, version, links, publish=True @@ -131,9 +110,7 @@ async def run_rollback(job: dict) -> dict: mismatches = list(report.get("mismatches") or []) unique_count_raw = report.get("uniqueContentCount") - # ★ 렌더러가 그때와 달라졌을 수 있으므로 게이트를 다시 돈다(파일 머리주석 참조) — - # fact 검증 게이트(1차)는 건너뛴다. snapshot 은 이미 확인된 값만 담아 발행했던 것이라 - # 다시 물을 것이 없다 — 여기서 또 물으면 "그때는 됐는데 지금은 안 된다"는 모순이 난다. + # 렌더러가 그때와 달라졌을 수 있으므로 게이트를 다시 돈다(파일 머리주석 참조) — fact 검증 게이트(1차)는 건너뛴다. gate = publish_gate.evaluate( PlaceCategory(place.category), version.snapshot.get("facts") or [], unique_count_raw, mismatches ) diff --git a/solution/backend/services/search_console_alerts.py b/solution/backend/services/search_console_alerts.py index 13ad6bd..2212482 100644 --- a/solution/backend/services/search_console_alerts.py +++ b/solution/backend/services/search_console_alerts.py @@ -1,4 +1,4 @@ -"""Teams Workflow 수신용. 원문 오류/인증 정보는 메시지에 싣지 않는다.""" +"""Teams Workflow 수신용.""" from datetime import timedelta import httpx diff --git a/solution/backend/services/search_console_client.py b/solution/backend/services/search_console_client.py index 111d43b..12c49a8 100644 --- a/solution/backend/services/search_console_client.py +++ b/solution/backend/services/search_console_client.py @@ -1,15 +1,4 @@ -"""Google Search Console REST 클라이언트 — 사이트맵 제출 · URL 색인 상태 조회. - - PUT https://www.googleapis.com/webmasters/v3/sites/{siteUrl}/sitemaps/{feedpath} - POST https://searchconsole.googleapis.com/v1/urlInspection/index:inspect - -자세한 계약은 docs/SEARCH_CONSOLE_CLIENT.md. - -★ `SearchConsoleError` 는 `code` 문자열만 담는다 — Google 응답 본문·토큰·키·원본 - 예외 메시지는 절대 싣지 않는다. -★ `inspectionResult`·`indexStatusResult` 가 없거나 빈 응답은 "미색인"으로 - 넘겨짚지 않고 예외로 끊는다 — 검사 실패와 색인 결과를 구분 못하면 오판이 된다. -""" +"""Google Search Console REST 클라이언트 — 사이트맵 제출 · URL 색인 상태 조회.""" from __future__ import annotations @@ -42,10 +31,7 @@ def _service_account_credentials(credentials_file: str): def _refresh_sync(credentials) -> None: - """동기 토큰 갱신. `asyncio.to_thread` 로 감싸 부른다. - - `Request.__call__` 기본 타임아웃(120s)을 그대로 두면 만료된 키·막힌 네트워크에서 - 오래 걸릴 수 있다 — 매 요청에 상한을 강제로 덮어씌운다(호출측이 넘긴 값 포함).""" + """동기 토큰 갱신.""" import requests from google.auth.transport.requests import Request diff --git a/solution/backend/services/search_console_service.py b/solution/backend/services/search_console_service.py index c1efd8a..9147901 100644 --- a/solution/backend/services/search_console_service.py +++ b/solution/backend/services/search_console_service.py @@ -1,4 +1,4 @@ -"""발행 감지 → 사이트맵 제출 → 색인 조회 → 알림. 발행 잡과 별도 트랜잭션이다.""" +"""발행 감지 → 사이트맵 제출 → 색인 조회 → 알림.""" import asyncio import xml.etree.ElementTree as ET from datetime import datetime, timedelta, timezone @@ -80,7 +80,7 @@ async def submit_sitemap(client, row, submitted: set): if row.sitemap_submitted_at: return sitemap_url = site_payload.publish_origin() + "/sitemap.xml" - # 디스크가 아니라 실제 공개 URL을 확인한다. 업로드 지연 시 Google에 먼저 알리지 않는다. + # 디스크가 아니라 실제 공개 URL을 확인한다. urls = await read_sitemap(sitemap_url) if row.page_url not in urls: raise SearchConsoleError("URL_NOT_IN_SITEMAP") diff --git a/solution/backend/services/search_console_settings.py b/solution/backend/services/search_console_settings.py index 8836feb..f396aa2 100644 --- a/solution/backend/services/search_console_settings.py +++ b/solution/backend/services/search_console_settings.py @@ -1,4 +1,4 @@ -"""Google 로그인 설정과 분리한다. 서비스 계정 키는 서버 파일로만 읽는다.""" +"""Google 로그인 설정과 분리한다.""" import os from dataclasses import dataclass from urllib.parse import urlsplit diff --git a/solution/backend/services/seo_audit.py b/solution/backend/services/seo_audit.py index fe947c6..11c0881 100644 --- a/solution/backend/services/seo_audit.py +++ b/solution/backend/services/seo_audit.py @@ -1,9 +1,4 @@ -"""발행 사이트 SEO/AEO 준비도 채점. - -점수는 검색 순위를 보장하는 외부 공인 점수가 아니라, 우리 빌더가 통제할 수 있는 신호의 -완성도다. 같은 입력은 언제나 같은 결과를 내도록 순수 함수로 두어 UI와 발행 흐름이 서로 -다른 기준을 말하지 않게 한다. -""" +"""발행 사이트 SEO/AEO 준비도 채점.""" from dataclasses import dataclass from common.enums import SourceType diff --git a/solution/backend/services/seo_keywords.py b/solution/backend/services/seo_keywords.py index a9d2e13..d0d5133 100644 --- a/solution/backend/services/seo_keywords.py +++ b/solution/backend/services/seo_keywords.py @@ -1,21 +1,4 @@ -"""발행 사이트 메타 태그용 검색 키워드 — SiteOntology 추천을 이 가게의 확인된 자료로 한 번 더 거른다. - - 스냅샷 → 업체 프로필(build_merchant) → SiteOntology publish + match → 거르기(select) - → snapshot["seo"] → payload.seo → · 의 업종어 자리 - -★ 스냅샷에 싣는다(site_versions.snapshot). payload 는 스냅샷만 보고 만든다는 원칙(site_payload 머리주석)을 - 그대로 지키고, "이 버전에 어떤 키워드가 나갔나" 가 발행 기록으로 남는다. SiteOntology 쪽에는 남기지 않는다. -★ 숙박만 부른다. SiteOntology 사전은 펜션 키워드뿐이다(2026-09-14 기준 industry=stay.pension). - 다른 업종으로 부르면 펜션 단어가 카페 사이트의 메타 태그에 붙는다. - -★ 거르기 규칙은 하나다 — **키워드의 모든 낱말이 이 가게의 확인된 자료에 있어야 한다.** - SiteOntology 의 사실 필터는 수용 인원과 일부 시설(바베큐·수영장·스파…)만 본다. 실측(2026-09-14, 스테이머뭄 - 프로필)에서 `군산 독채 마당 펜션`·`군산 독채 복층 펜션` 이 status=ok 로 왔다 — 마당·복층은 확인된 적이 없다. - 사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다. 낱말 대조 하나로 셋이 함께 걸린다. - 메타 태그와 제목은 AI 검색이 그대로 읽는 자리라, 확인 안 된 시설을 광고하는 단어는 지어낸 문장과 같다 - (solution/site/src/seo/meta.ts 머리주석 — description 은 지어내지 않는다). - "자료" 는 상호·소개문·주소·확인된 시설/객실 fact·발행되는 주변 관광지다. 미확인(unverified)은 자료가 아니다. -""" +"""발행 사이트 메타 태그용 검색 키워드 — SiteOntology 추천을 이 가게의 확인된 자료로 한 번 더 거른다.""" import re import unicodedata @@ -29,22 +12,18 @@ LODGING_INDUSTRY = "stay.pension" MAX_KEYWORDS = 10 MAX_NEARBY = 8 -# ★ SiteOntology 의 시설 어휘(match.rules.ts AMENITY_SYNONYMS)로 옮긴다 — 저쪽이 이 낱말로 보유 시설을 판정한다. -# 값이 "true" 면 있음 → features, "false" 면 **없음** → 어디에도 넣지 않는다(그래야 저쪽이 그 시설 키워드를 배제한다), -# fact 가 아예 없으면 **모름** → unverified(저쪽이 배제하지 않고 보류한다 — 없음과 모름은 다르다). +# SiteOntology 의 시설 어휘(match.rules.ts AMENITY_SYNONYMS)로 옮긴다 — 저쪽이 이 낱말로 보유 시설을 판정한다. _AMENITY_FACTS = { "bbq_available": "바베큐", "parking": "주차", "pet_allowed": "애견동반", "breakfast": "조식", } -# 사장님이 확인한 글자 그대로 싣는 fact(객실 단위 포함). "독채"·"오션뷰" 같은 유형·전망어가 여기서 온다. +# 사장님이 확인한 글자 그대로 싣는 fact(객실 단위 포함). _FEATURE_TEXT_KEYS = ("room_type", "building_scale", "facilities", "view") _CAPACITY_KEYS = ("max_capacity", "accommodation_capacity") # (ISO 3166-2 시·도, 시·군) → SiteOntology region 키(data/regions.json 54개). -# ★ 시·도를 함께 본다 — 고성군은 강원과 경남에 둘 다 있다. -# ★ 을왕리(kr.incheon.yeongjong)는 넣지 않는다. 행정구역으로는 인천 중구인데 중구 전체를 을왕리로 보낼 수 없다. _REGION_KEYS = { ("KR-41", "가평군"): "kr.gyeonggi.gapyeong", ("KR-41", "양평군"): "kr.gyeonggi.yangpyeong", ("KR-41", "포천시"): "kr.gyeonggi.pocheon", ("KR-41", "파주시"): "kr.gyeonggi.paju", @@ -77,11 +56,11 @@ _REGION_KEYS = { ("KR-49", "제주시"): "kr.jeju.jejusi", ("KR-49", "서귀포시"): "kr.jeju.seogwipo", } -# 자료에 없어도 되는 낱말 — 업종어와 "근처" 류. 무엇을 주장하지 않는다. +# 자료에 없어도 되는 낱말 — 업종어와 "근처" 류. _GENERIC_WORDS = frozenset({"펜션", "숙소", "숙박", "스테이", "근처", "가까운", "주변", "인근", "예약", "추천"}) # "독채펜션"·"감성숙소" 처럼 붙여 쓴 업종어는 떼고 앞부분만 자료와 대조한다. _GENERIC_SUFFIXES = ("펜션", "숙소", "스테이") -# 제목에는 싣지 않는 낱말. `스테이,머뭄 · 군산 독채펜션 예약` 은 검색어로는 맞아도 가게 이름 옆에서는 광고 문구다. +# 제목에는 싣지 않는 낱말. _TITLE_BLOCKED_WORDS = frozenset({"예약", "추천"}) # 한 글자 낱말("봄"·"뷰")은 소개문 어딘가에 우연히 들어 있어 대조가 무의미하다 — 통과시키지 않는다. _MIN_CORE_LEN = 2 @@ -94,7 +73,7 @@ def _text(value) -> str: def _compact(text: str) -> str: - """대조용 표기 — 공백·구두점을 지우고 소문자로. "스테이,머뭄" 과 "스테이 머뭄" 이 같아진다.""" + """대조용 표기 — 공백·구두점을 지우고 소문자로.""" return _NON_WORD.sub("", unicodedata.normalize("NFKC", text or "").lower()) @@ -106,7 +85,7 @@ def _number(value) -> int | None: def region_key(*addresses: str | None) -> str | None: - """주소 → SiteOntology region 키. 표에 없으면 None(지어내지 않는다).""" + """주소 → SiteOntology region 키.""" parts = _parse_address_parts(*addresses) locality = (parts.get("addressLocality") or "").split() if not locality: @@ -115,7 +94,7 @@ def region_key(*addresses: str | None) -> str | None: def _locality_word(*addresses: str | None) -> str: - """사람이 검색창에 치는 시·군 이름 — "군산시" → "군산". 제목 키워드가 이 낱말을 품어야 한다.""" + """사람이 검색창에 치는 시·군 이름 — "군산시" → "군산".""" locality = (_parse_address_parts(*addresses).get("addressLocality") or "").split() if not locality: return "" @@ -124,10 +103,7 @@ def _locality_word(*addresses: str | None) -> str: def build_merchant(place_id: str, snapshot: dict) -> dict | None: - """스냅샷 → SiteOntology publish 요청 본문. 숙박이 아니거나 상호가 없으면 None. - - ★ 스냅샷만 읽는다 — 스냅샷에는 노출 가능한 값(VERIFIED/CORRECTED fact · 발행 창 안의 지역 정보)만 있다. - 미검증 fact 를 프로필에 실으면 그걸 근거로 고른 키워드가 메타 태그로 나간다.""" + """스냅샷 → SiteOntology publish 요청 본문.""" place = (snapshot or {}).get("place") or {} if _number(place.get("category")) != PlaceCategory.LODGING.value: return None @@ -189,7 +165,7 @@ def build_merchant(place_id: str, snapshot: dict) -> dict | None: def _evidence(merchant: dict) -> str: - """이 가게의 확인된 자료를 한 덩어리로. 키워드 낱말은 여기에 들어 있어야 한다.""" + """이 가게의 확인된 자료를 한 덩어리로.""" profile = merchant.get("profile") or {} parts = [ merchant.get("name"), merchant.get("description"), profile.get("address"), @@ -199,11 +175,7 @@ def _evidence(merchant: dict) -> str: def _supported(keyword: str, evidence: str) -> bool: - """모든 낱말이 자료에 있고, **자료로 확인한 낱말이 하나는 있어야** 한다. - - ★ 뒤 조건이 없으면 `숙소` 처럼 업종어뿐인 단어가 "주장하는 게 없다" 는 이유로 통과한다 — - 실측(2026-09-14, 실제 발행 한 바퀴)에서 메타 10칸 중 한 칸이 `숙소` 였다. 어느 가게에나 붙는 단어라 - 이 가게를 설명하지 못한다.""" + """모든 낱말이 자료에 있고, **자료로 확인한 낱말이 하나는 있어야** 한다.""" specific = False for word in keyword.split(): if word in _GENERIC_WORDS: @@ -230,11 +202,7 @@ def _usable(item, evidence: str) -> str | None: def _title_keyword(result: dict, evidence: str, locality: str) -> str | None: - """제목 업종어 자리에 넣을 대표 키워드 — 유형 레인에서 고른다. - - ★ SiteOntology 설계상 "한 페이지의 주력 키워드는 1개, 유형 레인 1위가 메인 페이지 주력" 이다. - 다만 1위가 `군산 펜션 독채`(시설) 이고 2위가 `군산 독채펜션`(코어) 인 식으로 오므로 코어를 앞에 둔다. - ★ 시·군 이름을 품어야 한다. `독채펜션` 만 남으면 지금 제목(`… 군산시 숙소`)보다 지역 신호가 약해진다.""" + """제목 업종어 자리에 넣을 대표 키워드 — 유형 레인에서 고른다.""" lane = next( (l for l in result.get("byLane") or [] if isinstance(l, dict) and l.get("key") == "type"), {}, @@ -251,7 +219,7 @@ def _title_keyword(result: dict, evidence: str, locality: str) -> str | None: def select(result: dict, merchant: dict) -> dict: - """/v1/match 응답 → {keywords, titleKeyword?}. 순위는 SiteOntology 의 융합 순위를 그대로 따른다.""" + """/v1/match 응답 → {keywords, titleKeyword?}.""" evidence = _evidence(merchant) keywords: list[str] = [] seen: set[str] = set() @@ -272,7 +240,7 @@ def select(result: dict, merchant: dict) -> dict: async def fetch(place_id: str, snapshot: dict) -> dict | None: - """스냅샷에 실을 seo 값. 못 만들면 None — 예외를 올리지 않는다(키워드는 발행을 막지 않는다).""" + """스냅샷에 실을 seo 값.""" if not site_ontology.is_configured(): return None merchant = build_merchant(place_id, snapshot) diff --git a/solution/backend/services/showcase_service.py b/solution/backend/services/showcase_service.py index 8f0cb1d..516420b 100644 --- a/solution/backend/services/showcase_service.py +++ b/solution/backend/services/showcase_service.py @@ -1,12 +1,4 @@ -"""랜딩(비로그인)이 읽는 발행 사이트 목록. - -★ 이 파일이 따로 있는 이유는 **경계를 눈에 보이게 두기 위해서**다. 나머지 site 서비스는 - 전부 로그인 + 회사 스코프 안에서 돈다. 여기만 아무나 부른다 — 그래서 무엇을 내보낼지 - 고르는 자리를 한 곳으로 모았다. 사이트 한 곳을 여는 것과 발행 업소 명단을 통째로 - 긁는 것은 다른 일이라, 페이지에 이미 적혀 있는 것만 나간다. - - 나가지 않는 것: place_id · 소유자 · site_id · 전화번호 · 상세 주소 · 좌표. -""" +"""랜딩(비로그인)이 읽는 발행 사이트 목록.""" from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import sites @@ -34,12 +26,9 @@ class ShowcaseService: name=place.name, category=place.category, region=site_payload.region_label(place.road_address, place.address), - # ★ 주소 규칙은 site_payload 한 곳뿐이다 — 여기서 다시 만들면 - # 카드가 가리키는 곳과 실제 발행 주소가 갈린다(CLAUDE.md '슬러그 규칙은 두 곳'). + # 주소 규칙은 site_payload 한 곳뿐이다 — 여기서 다시 만들면 카드가 가리키는 곳과 실제 발행 주소가 갈린다(CLAUDE.md '슬러그 규칙은 두 곳'). url=f"/s/{site_payload.publish_slug(place, site)}", - # ★ sites.thumbnail_url 은 Azure 썸네일 저장소가 꺼져 있으면(로컬 개발 등) 비어 - # 있다 — 이 목록은 이미 PUBLISHED 만 걷었으므로, 그때는 빌더가 쓰는 대표 사진 - # (primary_photo_url)으로 대신 채운다(services/site_service._my_site_row 와 같은 규칙). + # sites.thumbnail_url 은 Azure 썸네일 저장소가 꺼져 있으면(로컬 개발 등) 비어 있다 — 이 목록은 이미 PUBLISHED 만 걷었으므로, 그때는 빌더가 쓰는 대표 사진 (primary_photo_url)으로 대신 채운다(services/site_service._my_site_row 와 같은 규칙). thumbnail_url=site.thumbnail_url or primary_photo_url, ) for site, place, primary_photo_url in rows diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index fec8d19..8228b5d 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -34,7 +34,7 @@ SITE_HOST_ENV = "SITE_PUBLIC_HOST" DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost" def _scheme(host: str) -> str: - """localhost 는 http 다. shared/lib/slug.ts publishUrl 과 같은 규칙.""" + """localhost 는 http 다.""" return "http" if re.match(r"^(localhost|127\.0\.0\.1)(:\d+)?$", host) else "https" # 링크 제목이 비었을 때 채우는 채널 이름. @@ -180,7 +180,7 @@ def _fact_entry(spec, row: dict) -> dict: def _theme(site, category: int) -> dict: - """SiteTheme. 모양(look)은 템플릿 정의가, 색·섹션은 저장된 theme이 정한다. 잘못된 templateId면 UnknownTemplate.""" + """SiteTheme.""" template_id = resolve_template_id(category, _text(_get(site, "template_id"))) template = TEMPLATES[template_id] saved = _get(site, "theme") @@ -280,7 +280,7 @@ def _weather_condition(code) -> str: def _weather(row: dict): - """WeatherSnapshot. 값이 없으면 None — 없는 날씨를 지어내지 않는다.""" + """WeatherSnapshot.""" body = row.get("body") or {} temperature = _as_float(body.get("temperature")) observed_at = _text(body.get("observed_at")) @@ -309,7 +309,7 @@ _SEASON_BY_MONTH = { def _festival(row: dict): - """FestivalEntry. 이름이 없으면 버린다 — 이름 없는 행사는 화면에 걸 수 없다.""" + """FestivalEntry.""" body = row.get("body") or {} name = _text(body.get("name")) or _text(row.get("title")) if not name: @@ -376,7 +376,7 @@ def _local_place(row: dict, category: str): def _put_distance(entry: dict, body: dict) -> None: - """distanceMeters → distanceText("850m") + distanceMeters(850). 값이 없거나 음수면 둘 다 넣지 않는다.""" + """distanceMeters → distanceText("850m") + distanceMeters(850).""" # 원값은 사이트 개인화(site_sections.data.places[].distanceMeters)에서 온다 — 공용 실체에는 거리가 없다(업장마다 다르다). meters = body.get("distanceMeters") distance = _distance_text(meters) @@ -387,7 +387,7 @@ def _put_distance(entry: dict, body: dict) -> None: def _distance_text(meters) -> str: - """850 → "850m", 1234 → "1.2km". 없으면 빈 문자열.""" + """850 → "850m", 1234 → "1.2km".""" try: m = int(meters) except (TypeError, ValueError): @@ -726,7 +726,7 @@ def _as_float(value): # ── 파일 출력 ───────────────────────────────────────────────────────────── def payload_dir() -> Path: - """payload 출력 디렉토리. 컨테이너 밖 볼륨을 붙이기 쉬우라고 env 로 뺀다.""" + """payload 출력 디렉토리.""" return Path(os.environ.get(PAYLOAD_DIR_ENV) or DEFAULT_PAYLOAD_DIR) @@ -746,7 +746,7 @@ def write_payload(payload: dict) -> str: async def prepare_site_payload(place, snapshot: dict, site, version, links, publish: bool = False) -> dict: - """미리보기·발행 공통 보강. 요약은 DB 스냅샷이 아니라 응답/산출물에만 싣는다.""" + """미리보기·발행 공통 보강.""" payload = to_site_payload(place, snapshot, site, version, links, publish) for fact in payload["facts"]: if fact["key"] != "intro" or fact["status"] not in {s.value for s in PUBLISHABLE_FACT_STATUSES}: diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index cabcb95..e907d23 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -96,7 +96,7 @@ class SiteService: return ErrorType.SUCCESS, place async def _get_site(self, place_id: str): - """사업장의 사이트 행. 없으면 None.""" + """사업장의 사이트 행.""" pid = uuid.UUID(place_id) err_type, site = await DB_SESSION_MNG.execute_lambda( sites.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.get_site_by_place(s, pid) @@ -104,7 +104,7 @@ class SiteService: return site if err_type == ErrorType.SUCCESS else None async def get_seo_audit(self, user_info: UserInfo, place_id: str) -> Res_SeoAudit: - """현재 저장값 기준 SEO/AEO 준비도. 조회할 때마다 계산해 오래된 점수를 보여주지 않는다.""" + """현재 저장값 기준 SEO/AEO 준비도.""" from sqlalchemy import select from services.seo_audit import evaluate from services.snapshot import build_snapshot @@ -148,10 +148,10 @@ class SiteService: # version 은 None 이다. return await prepare_site_payload(place, snapshot, site, None, links) - # ---- 사이트 주소(네임스페이스) --------------------------------------- 규칙(정규식·예약어)은 services/site_slug 한 곳에만 있다. + # --- 사이트 주소(네임스페이스) --------------------------------------- 규칙(정규식·예약어)은 services/site_slug 한 곳에만 있다. async def _domain_owner(self, slug: str): - """이 주소를 이미 쓰는 사업장 id. 아무도 안 쓰면 None.""" + """이 주소를 이미 쓰는 사업장 id.""" err_type, site = await DB_SESSION_MNG.execute_lambda( sites.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.get_site_by_domain(s, slug) ) @@ -160,7 +160,7 @@ class SiteService: return str(site.place_id) async def _suggest(self, slug: str): - """`-2`, `-3` … 중 실제로 비어 있는 것 하나. 없으면 None.""" + """`-2`, `-3` … 중 실제로 비어 있는 것 하나.""" candidates = site_slug.suggestions(slug) if not candidates: return None @@ -214,7 +214,7 @@ class SiteService: return res async def set_slug(self, user_info: UserInfo, place_id: str, req: Req_SiteSlug) -> Res_SiteSlug: - """주소를 확정해 sites.domain 에 저장한다. 사이트 행이 없으면 만든다.""" + """주소를 확정해 sites.domain 에 저장한다.""" # 사이트 생성은 빌드와 같은 경로를 쓴다(사업장당 1개 보장) — 지역 import 로 빌드 스택을 웹에 얹지 않는다. from services.build_service import BuildAborted, ensure_site @@ -258,7 +258,6 @@ class SiteService: sites.DBType(), lambda s: self.crud.update_site(s, site.site_id, {"domain": value}) ) if u_err != ErrorType.SUCCESS: - # uq_sites_domain 이 막았다면 확인과 저장 사이에 남이 먼저 가져간 것이다 — 대안까지 같이 준다. res.result.SetResult(u_err) if u_err == ErrorType.DB_ALREADY_SAME_KEY: res.reason = site_slug.REASON_TAKEN @@ -269,10 +268,9 @@ class SiteService: res.site = SiteData.model_validate(saved if saved is not None else site) return res - # ---- 템플릿(디자인) --------------------------------------------------- + # 템플릿(디자인) async def _mark_content_updated(self, place_id: str, ts): - """발행본과 달라졌다 — 이 사업장만 다시 빌드하면 된다는 표시.""" err = await DB_SESSION_MNG.execute_lambda_run([places.DBType()], [lambda s: self._touch(s, place_id, ts)]) if err != ErrorType.SUCCESS: LOG.e_no_callstack(f"[site] content_updated_at 갱신 실패 place={place_id}") @@ -319,13 +317,12 @@ class SiteService: res.result.SetResult(u_err) return res - # 한 번이라도 구워진 사이트에서 디자인이 바뀌었다면 지금 나가 있는 페이지와 달라졌다 — 재빌드 대상으로 표시한다. if before != after and (site.published_at is not None or site.current_version_id is not None): await self._mark_content_updated(place_id, GTime.UTC()) return await self.get_site(user_info, place_id) - # ---- 테마(색·서체·섹션) ----------------------------------------------- + # 테마(색·서체·섹션) async def set_theme(self, user_info: UserInfo, place_id: str, req: Req_SiteTheme) -> Res_Site: """에디터가 정한 디자인을 sites.theme에 저장한다.""" @@ -361,7 +358,6 @@ class SiteService: res.result.SetResult(u_err) return res - # 한 번이라도 구워진 사이트에서 디자인이 바뀌었다면 지금 나가 있는 페이지와 달라졌다 — 재빌드 대상으로 표시한다. if before != after and (site.published_at is not None or site.current_version_id is not None): await self._mark_content_updated(place_id, GTime.UTC()) @@ -478,7 +474,7 @@ class SiteService: ) async def start_build(self, user_info: UserInfo, place_id: str, req: Req_StartBuild) -> Res_StartBuild: - """빌드를 큐에 넣는다. 빌드는 몇 십 초 걸린다 — 동기로 처리하지 않는다.""" + """빌드를 큐에 넣는다.""" res = Res_StartBuild() err_type, place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: diff --git a/solution/backend/services/site_slug.py b/solution/backend/services/site_slug.py index b724b3c..1614f22 100644 --- a/solution/backend/services/site_slug.py +++ b/solution/backend/services/site_slug.py @@ -1,35 +1,20 @@ -"""사이트 주소(네임스페이스) 규칙 — 확인(check)과 저장(POST)이 함께 쓰는 단 하나의 정의. - -★ 주소는 한 번 정하면 AI 검색이 색인하는 영구 식별자다. 그래서 - - 규칙은 이 파일 한 곳에만 둔다. 확인과 저장이 서로 다른 규칙을 쓰면 - "쓸 수 있다고 해놓고 저장에서 튕기는" 최악의 화면이 나온다. - - 클라이언트 검증은 믿지 않는다. 저장 직전에 서버가 이 규칙으로 다시 본다. - -★ ASCII 소문자·숫자·하이픈만 받는다. - 한글 상호를 그대로 주소로 쓰면 경로는 퍼센트 인코딩(/s/%EC%8A%A4%ED%85%8C%EC%9D%B4), - 서브도메인은 퓨니코드(xn--…)가 된다 — 사장님이 전화로 불러줄 수도, 명함에 적을 수도 없는 주소다. - (프론트 `shared/src/lib/slug.ts` 의 publishUrl 이 ASCII 슬러그면 서브도메인으로 낸다.) -""" +"""사이트 주소(네임스페이스) 규칙 — 확인(check)과 저장(POST)이 함께 쓰는 단 하나의 정의.""" import re from typing import Optional -# 3~50자. 처음과 끝은 영문 소문자·숫자여야 하고, 하이픈은 가운데에만 올 수 있다. -# (1 + 1~48 + 1 = 3~50자) +# 3~50자. SLUG_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]{1,48}[a-z0-9])$") SLUG_MIN_LEN = 3 SLUG_MAX_LEN = 50 -# 불가 사유 코드. 응답의 reason 으로 그대로 나가고, 프론트가 문구를 고른다. +# 불가 사유 코드. REASON_LENGTH = "INVALID_LENGTH" # 3~50자를 벗어남 REASON_FORMAT = "INVALID_FORMAT" # 한글·대문자·언더스코어·연속 하이픈·양끝 하이픈 REASON_RESERVED = "RESERVED" # 서비스가 선점한 이름 REASON_TAKEN = "TAKEN" # 다른 사업장이 이미 쓰는 주소 -REASON_LOCKED = "LOCKED" # ★ 이미 발행됨 — 색인된 주소는 바꾸지 않는다 +REASON_LOCKED = "LOCKED" # 이미 발행됨 — 색인된 주소는 바꾸지 않는다 -# 예약어. 두 부류가 섞여 있다. -# 1) 서비스가 실제로 쓰는 경로·서브도메인(/s, /healthz, api. …) — 내주면 사이트가 서로를 가린다. -# 2) 관용적으로 시스템을 뜻하는 이름(admin, root, www …) — 사장님 사이트가 이 주소를 갖고 있으면 -# 방문자도 크롤러도 공식 홈페이지로 읽지 않는다. +# 예약어. RESERVED_SLUGS = frozenset({ # 서비스 경로 / 인프라 서브도메인 "s", "site", "sites", "api", "app", "admin", "www", "www2", "static", "assets", "cdn", "media", @@ -48,32 +33,17 @@ RESERVED_SLUGS = frozenset({ "search", "index", "home", "main", "new", "edit", "delete", "create", "update", # 값이 비었을 때 프론트가 문자열로 흘려보내는 것들 — 주소로 들어오면 버그의 흔적이다 "null", "undefined", "none", "nan", "true", "false", - # ★ 손으로 만든 목업이 쓰는 주소(solution/site/out/s, mockup/README). - # payload 가 없어 프리렌더가 굽지 않는 자리인데, 사장님이 이 주소로 발행하면 - # **payload 가 생기는 순간** 덮여서 유일본이 영영 사라진다(AGENTS.md ★★ 항목). - # 실측(2026-09-15): `stay` 는 주인이 있어 우연히 TAKEN 이었을 뿐이고 - # `stay2` · `stay3` 는 주인도 예약도 없어 "쓸 수 있다" 로 나갔다 — 이 판단이 틀렸다. - # 실측(2026-09-18): `stay2` 는 이미 버터브루가 쓰고 있는 라이브 사이트였다(payload - # `stay2.json`, 2026-09-14 발행). 여기 예약은 **새 배정만** 막지 이미 있던 배정은 - # 못 되돌린다 — 그 상태에서 목업 배포 스크립트가 같은 자리에 손으로 덮어써 버터브루의 - # 실제 사이트가 시연용 목업으로 바뀌었다(백업에서 복구). 그래서 예약어를 추가할 때는 - # "지금 비어 있다"만 보지 말고 `solution/site/payloads/<slug>.json` 로 실제 배정 - # 여부를 먼저 확인한다. - # ★ 굽기 쪽 보호(prerender PROTECTED_SLUGS)와 겹쳐 두는 것이지 대신하는 게 아니다 — - # 저쪽은 이미 나간 발행을 막고, 여기는 애초에 고르지 못하게 한다. + # 손으로 만든 목업이 쓰는 주소(solution/site/out/s, mockup/README). "stay", "stay2", "stay3", "stay4", "stay5", "stay6", }) def validate_slug(slug: Optional[str]) -> Optional[str]: - """형식·예약어만 본다(중복은 DB 를 봐야 하므로 서비스가 판단한다). - - 쓸 수 있으면 None, 아니면 REASON_* 를 돌려준다.""" + """형식·예약어만 본다(중복은 DB 를 봐야 하므로 서비스가 판단한다).""" value = (slug or "").strip() if len(value) < SLUG_MIN_LEN or len(value) > SLUG_MAX_LEN: return REASON_LENGTH # 하이픈 연속은 정규식으로 막지 않는다(가운데 문자 집합이라 통과한다) — 여기서 따로 거른다. - # a--b 는 퓨니코드 접두(xn--)와 모양이 겹쳐서 서브도메인으로 냈을 때 오해를 산다. if "--" in value: return REASON_FORMAT if not SLUG_PATTERN.fullmatch(value): @@ -84,15 +54,12 @@ def validate_slug(slug: Optional[str]) -> Optional[str]: def suggestions(slug: Optional[str], count: int = 9) -> list[str]: - """`-2`, `-3` … 을 붙인 대안 후보. 실제로 쓸 수 있는지(중복)는 서비스가 DB 로 거른다. - - ★ 음차하거나 상호를 마음대로 줄이지 않는다 — 서버가 고른 주소는 사장님이 기억하지 못한다. - 사장님이 적어낸 이름을 그대로 두고 뒤에 숫자만 붙인다.""" + """`-2`, `-3` … 을 붙인 대안 후보.""" value = (slug or "").strip() result: list[str] = [] for n in range(2, 2 + max(count, 0)): suffix = f"-{n}" - # 50자 상한을 넘기지 않도록 앞을 자른다. 자른 끝이 하이픈이면 `a--2` 가 되므로 떼어낸다. + # 50자 상한을 넘기지 않도록 앞을 자른다. base = value[: SLUG_MAX_LEN - len(suffix)].rstrip("-") candidate = f"{base}{suffix}" if validate_slug(candidate) is None: diff --git a/solution/backend/services/site_thumbnail.py b/solution/backend/services/site_thumbnail.py index 376b476..6494a53 100644 --- a/solution/backend/services/site_thumbnail.py +++ b/solution/backend/services/site_thumbnail.py @@ -1,18 +1,4 @@ -"""발행한 사이트의 썸네일을 Azure Blob 에 남긴다 — 랜딩 쇼케이스 카드가 쓰는 그림. - -★ **스크린샷이 아니다.** 헤드리스 브라우저는 이 레포에서 영구 금지고(docs/DECISIONS.md 1-1), - 워커(python:3.12-slim)에도 프리렌더(node:24-alpine)에도 Chromium 이 없다. 그걸 넣으면 - 이미지가 수백 MB 늘고, 금지해 둔 도구가 다른 목적으로 상비되는 셈이 된다. - 대신 **그 사이트의 대표 사진(og:image)** 을 그대로 옮긴다 — 검색 결과에 뜨는 그림과 - 쇼케이스 카드가 같은 사진이 된다. 대표 사진 선정은 site_payload.primary_media 한 곳뿐이다. - -★ 블롭 경로는 사이트 디렉터리(`s/<slug>/`) **밖**이다. - azure_static._remove_stale_site_files 가 매 발행마다 `s/<slug>/` 를 프리렌더 산출물로 - 통째로 교체하므로, 그 안에 두면 다음 발행에서 조용히 사라진다. - -★ 실패해도 발행을 되돌리지 않는다(emit_payload·indexnow 와 같은 원칙). 그림이 없으면 - 쇼케이스가 글자 카드로 떨어질 뿐이고, 발행 자체는 이미 정확하다. -""" +"""발행한 사이트의 썸네일을 Azure Blob 에 남긴다 — 랜딩 쇼케이스 카드가 쓰는 그림.""" import asyncio import os @@ -23,11 +9,10 @@ from azure.storage.blob import BlobClient, BlobServiceClient, ContentSettings from common.logger import LOG from services import azure_static, site_payload -# 사이트 경로 밖의 전용 디렉터리. 여기는 발행이 지우지 않는다. +# 사이트 경로 밖의 전용 디렉터리. THUMB_DIR = "thumbs" -# 허용 content-type → 저장 확장자. 목록 밖이면 받지 않는다 — -# 이미지가 아닌 응답(HTML 오류 페이지 등)을 그대로 올리면 카드가 깨진 그림이 된다. +# 허용 content-type → 저장 확장자. _EXT_BY_TYPE = { "image/jpeg": "jpg", "image/png": "png", @@ -35,11 +20,11 @@ _EXT_BY_TYPE = { "image/webp": "webp", } -# 남의 CDN 을 부르는 길이다. 상한이 없으면 발행 잡이 여기서 굳는다. +# 남의 CDN 을 부르는 길이다. TIMEOUT_SEC = 10.0 # 리다이렉트는 따라가되 무한정은 안 된다(CDN 은 보통 1~2회). MAX_REDIRECTS = 3 -# 5MB. 사진 한 장이 이보다 크면 카드에 쓸 그림이 아니라 다른 것이 왔다고 본다. +# 5MB. MAX_BYTES = 5 * 1024 * 1024 # 썸네일은 재발행마다 바뀔 수 있고 주소는 그대로다 — 길게 캐시하면 옛 그림이 계속 뜬다. @@ -47,23 +32,9 @@ CACHE_CONTROL = "public, max-age=60, must-revalidate" # ── 썸네일 전용 저장소 ──────────────────────────────────────────────────────── -# ★ 왜 스위치를 따로 두나 -# 원래는 `AZURE_STORAGE_CONNECTION_STRING` 하나가 사이트 업로드(azure_static)와 썸네일을 -# **같이** 켰다. 그런데 그 둘은 필요한 저장소가 다르다 — 사이트는 정적 호스팅(`$web`)이고 -# 썸네일은 그냥 이미지 버킷이면 된다. 하나로 묶어 두면 "썸네일 좀 보자" 고 키를 꽂는 순간 -# **발행할 때마다 사이트 전체가 그 버킷에 업로드된다.** 지금 우리가 빌려 쓰는 곳은 -# negodata·infinith 와 공용인 미디어 컨테이너라 그렇게 되면 안 된다. -# -# ★ 값 출처: o2o-negosium/negodata/backend/config/config.local.toml `[StorageConfig]`. -# 같은 계정/컨테이너를 root 디렉터리로만 가른다(negodata/ · infinith/ · web4ai/) — -# 그쪽 관례를 그대로 따른 것이지 우리 계정이 아니다. -# -# ⚠️ **임시다.** 이 컨테이너는 정적 사이트 호스팅이 아니라 발행본을 못 올린다. 그리고 SAS 가 -# 컨테이너 전체에 racwdl(삭제 포함)이라, 남의 프로젝트 파일에 닿을 수 있는 자리다 — -# web4ai 전용 스토리지 계정이 생기면 이 블록을 걷고 azure_static 쪽으로 되돌린다. _BASE_ENV = "THUMBNAIL_BLOB_BASE_URL" # https://<계정>.blob.core.windows.net/<컨테이너> _SAS_ENV = "THUMBNAIL_BLOB_SAS_TOKEN" # `?sv=...` (앞의 물음표는 있어도 없어도 된다) -_ROOT_ENV = "THUMBNAIL_BLOB_ROOT" # 컨테이너 안에서 우리가 쓰는 디렉터리. 예: web4ai +_ROOT_ENV = "THUMBNAIL_BLOB_ROOT" # 컨테이너 안에서 우리가 쓰는 디렉터리. def _blob_base() -> str: @@ -79,7 +50,7 @@ def _blob_root() -> str: def uses_blob_store() -> bool: - """썸네일 전용 저장소를 쓰는가. 아니면 예전대로 azure_static 설정을 따른다.""" + """썸네일 전용 저장소를 쓰는가.""" return bool(_blob_base() and _blob_sas()) @@ -96,25 +67,15 @@ def blob_name(slug: str, ext: str) -> str: def public_url(slug: str, ext: str, version: int | None = None) -> str: - """공개 주소. 발행 사이트와 같은 오리진이다 — 접두사는 오리진 경로로 흡수된다 - (CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다'). - - ★ `?v=<버전>` 은 캐시 무효화다. 블롭 이름은 발행마다 그대로고 내용만 덮어쓰므로 - (overwrite=True), 주소가 안 변하면 브라우저·CDN 이 **옛 그림을 계속 보여준다.** - 아래 CACHE_CONTROL(60초)만으로는 부족하다 — 그 60초 동안 사장님은 방금 바꾼 사진이 - 아니라 지난 발행의 사진을 본다. 버전을 붙이면 발행 즉시 새 주소가 된다. - ★ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이고, 지우는 코드가 없다. - ★ version 이 None 이면 붙이지 않는다 — 옛 발행분을 사후에 채우는 경로 - (scripts/backfill_thumbnails.py)에는 그 시점의 버전이 없다.""" - # ★ 저장소가 발행 오리진 밖이면 주소도 그쪽이다. 여기서 publish_origin 을 쓰면 - # 그림은 블롭에 올라가 있는데 카드는 우리 사이트 주소를 가리켜 전부 404 다. + """공개 주소.""" + # 저장소가 발행 오리진 밖이면 주소도 그쪽이다. base = f"{_blob_base()}/{blob_name(slug, ext)}" if uses_blob_store() \ else f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}" return f"{base}?v={version}" if version is not None else base async def _fetch(url: str) -> tuple[bytes, str, str] | None: - """대표 사진을 받아온다. (바이트, content-type, 확장자) 또는 None.""" + """대표 사진을 받아온다.""" if not url.lower().startswith(("http://", "https://")): LOG.w(f"[thumbnail] 받아올 수 없는 주소다: {url[:120]}") return None @@ -165,9 +126,7 @@ def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str: settings = ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL) if uses_blob_store(): - # ★ SAS 는 연결 문자열이 아니다 — from_connection_string 이 못 받는다. - # 블롭 주소에 토큰을 붙여 그 한 파일에만 붙는다(컨테이너 클라이언트를 만들지 않는다: - # 남의 디렉터리를 훑을 수 있는 핸들을 굳이 들고 있지 않는다). + # SAS 는 연결 문자열이 아니다 — from_connection_string 이 못 받는다. BlobClient.from_blob_url(f"{_blob_base()}/{name}?{_blob_sas()}").upload_blob( data, overwrite=True, content_settings=settings ) @@ -186,10 +145,7 @@ def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str: async def store(slug: str, snapshot: dict, version: int | None = None) -> str | None: - """대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다. 못 하면 None(발행은 그대로 간다). - - SDK 의 동기 I/O 는 별도 스레드에서 돈다 — azure_static.publish 와 같은 이유로, - 이벤트 루프를 붙잡으면 같은 워커의 다른 잡이 통째로 멈춘다.""" + """대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다.""" if not is_configured(): return None diff --git a/solution/backend/services/snapshot.py b/solution/backend/services/snapshot.py index 439d83e..4611a8b 100644 --- a/solution/backend/services/snapshot.py +++ b/solution/backend/services/snapshot.py @@ -1,20 +1,4 @@ -"""빌드 스냅샷 조립 — DB 에서 '사이트에 나갈 것만' 골라 빌더 입력을 만든다. - -★ 정적 빌드의 경계다. DB 는 **빌드 시점에만** 읽고, 방문자는 DB 와 만나지 않는다. - 여기서 만든 스냅샷이 site_versions.snapshot 에 박제되고, 그 뒤로는 그것만 렌더된다. - -★ 필터링이 여기 한 곳에만 있다: - fact — VERIFIED / CORRECTED 만 - 사진 — APPROVED 만 (Vision 신뢰도 미달은 PENDING_REVIEW 로 남아 여기서 빠진다) - FAQ — VERIFIED / CORRECTED 만 - 지역 — PUBLISHED 만 + 노출 기간 안에 있는 것만 (운영자가 검수해 발행한 것만 나간다) - 게이트(publish_gate)가 뒤에서 한 번 더 보지만, 애초에 미검증 값이 스냅샷에 들어오면 안 된다. - -★ 지역 정보가 왜 여기서 읽히나(services/site_payload 가 아니라). - site_payload 는 "DB 를 다시 읽지 않는다 — 입력은 박제된 스냅샷뿐"이 원칙이다. 거기서 지역 캐시를 - 읽으면 발행 시점과 렌더 시점 사이에 지역 정보가 바뀌었을 때 '스냅샷과 다른 페이지'가 나온다. - 그래서 지역 정보도 다른 재료와 똑같이 여기서 걸러 스냅샷에 박제하고, site_payload 는 모양만 바꾼다. -""" +"""빌드 스냅샷 조립 — DB 에서 '사이트에 나갈 것만' 골라 빌더 입력을 만든다.""" import uuid from datetime import datetime, timezone @@ -46,22 +30,16 @@ from services.external.naver import region_key _PUBLISHABLE = tuple(s.value for s in PUBLISHABLE_FACT_STATUSES) # 지역 정보를 종류별로 몇 건까지 박제할지. -# ★ 관광지·축제·코스 노출 상한. 맛집은 수집된 전체를 발행한다(2026-09-14). -# 스냅샷은 site_versions.snapshot 에 통째로 들어가므로 반경 안 수백 건을 다 박제하면 버전 행마다 복사된다. -# 두 캐시(지역 수기 항목 + 업장 반경)를 **합쳐서** 센다 — 따로 세면 최대 40건이 나간다. _LOCAL_MAX_PER_TYPE = 20 -# ★ 지역 이야기는 종류당 **한 행**이다(항목은 body.items 안에 있다 — migrations/0004 -# `uq_local_contents_kind`). 그래서 다섯 종류가 위 상한 안에서 나란히 선다. +# 지역 이야기는 종류당 **한 행**이다(항목은 body.items 안에 있다 — migrations/0004 `uq_local_contents_kind`). # 지역 원문(body)에서 스냅샷으로 옮기지 않는 키. -# ★ TourAPI 원본을 통째로 담은 필드라 정규화된 값과 100% 중복이고, 축제 1건의 크기를 두 배로 만든다. -# site_payload 는 정규화된 키만 읽는다. _LOCAL_BODY_DROP = ("raw",) async def build_snapshot(place) -> dict: - """사업장 1건의 빌드 스냅샷을 만든다. 노출 가능한 것만 담는다.""" + """사업장 1건의 빌드 스냅샷을 만든다.""" pid = place.place_id if isinstance(place.place_id, uuid.UUID) else uuid.UUID(str(place.place_id)) category = PlaceCategory(place.category) schema = get_schema(category) @@ -84,7 +62,7 @@ async def build_snapshot(place) -> dict: place_faqs.status.in_(_PUBLISHABLE), ).order_by(place_faqs.sort_order.asc()) ) - # ★ 승인된 사진만. Vision 신뢰도가 낮아 확인 큐에 남은 사진은 사이트에 안 나간다. + # 승인된 사진만. media_rows = await _select( select(place_photos).where( place_photos.place_id == pid, @@ -93,8 +71,7 @@ async def build_snapshot(place) -> dict: ).order_by(place_photos.sort_order.asc()) ) - # ★ 완성(READY)된 최신 곡 하나. 발행마다 새 곡을 만들므로 생성 중인 행이 함께 있을 수 있는데, - # 그걸 실으면 사이트가 아직 없는 파일을 가리킨다. 새 곡이 실패하면 직전 곡이 그대로 남는다. + # 완성(READY)된 최신 곡 하나. song_rows = await _select( select(place_songs).where( place_songs.place_id == pid, @@ -103,7 +80,7 @@ async def build_snapshot(place) -> dict: ).order_by(place_songs.created_at.desc()).limit(1) ) - # 미니 블로그 — 사장님이 승인한 글과 이미 게재된 글. 승인분은 이번 굽기에 처음 실린다. + # 미니 블로그 — 사장님이 승인한 글과 이미 게재된 글. post_rows = await _select( select(place_posts).where( place_posts.place_id == pid, @@ -112,7 +89,7 @@ async def build_snapshot(place) -> dict: ).order_by(place_posts.created_at.desc()).limit(200) ) - # 이용 후기 — 검수를 통과한 것만. 대기·반려는 발행본에 나가지 않는다. + # 이용 후기 — 검수를 통과한 것만. review_rows = await _select( select(place_reviews).where( place_reviews.place_id == pid, @@ -148,8 +125,7 @@ async def build_snapshot(place) -> dict: "unit_id": str(r.unit_id) if r.unit_id else None, # 게이트가 다시 볼 수 있게 상태를 함께 싣는다(스냅샷은 감사 기록이기도 하다). "status": r.status, - # ★ 출처와 확인 시각도 박제한다. 발행 payload(FactEntry)가 이 값을 그대로 싣고, - # 화면은 "언제 무엇으로 확인된 값인지"를 보여준다 — 출처 없는 사실은 우리 규칙 위반이다. + # 출처와 확인 시각도 박제한다. "source_type": r.source_type, "source_url": r.source_url, "collected_at": _iso(r.collected_at), @@ -171,9 +147,7 @@ async def build_snapshot(place) -> dict: } for r in faq_rows ], - # ★ alt 가 없는 사진은 넣지 않는다 — 빌더가 렌더하지 않고, 접근성·AI 검색 신호도 잃는다. - # ★ source_type/origin_url 을 반드시 남긴다 — 크롤링 이미지의 재게시 권리가 미결이라 - # (docs/DECISIONS.md 1-2) 결론이 나면 출처로 걸러내야 한다. 여기서 버리면 재수집밖에 답이 없다. + # alt 가 없는 사진은 넣지 않는다 — 빌더가 렌더하지 않고, 접근성·AI 검색 신호도 잃는다. "media": [ { "media_id": str(r.media_id), @@ -190,20 +164,13 @@ async def build_snapshot(place) -> dict: for r in media_rows if (r.alt_text or "").strip() ], - # ★ 지역 정보. 캐시 키가 place_id 가 아니라 region_code 라 사업장의 지역 코드로 찾는다 - # (같은 지역 사이트 50개여도 외부 조회는 1회 — 그게 이 테이블이 region_code 로 묶인 이유다). - # region_code 가 비어 있으면 조회할 키가 없으므로 빈 목록이다. 그 경우 지어내지 않는다 — - # 지역 코드는 수집 파이프라인이 채우는 값이고, 없으면 아직 지역을 특정하지 못한 사업장이다. - # ★ 원문(body)을 거의 그대로 싣는다. 렌더러 타입으로의 변환은 site_payload 가 한다 — - # fact·사진과 같은 분업이다(여기는 '무엇이 나갈 수 있는가', 거기는 '어떤 모양으로 나가는가'). + # 지역 정보. "local": local_rows, # 승인 토큰·계정 자격증명·근거 원문은 공개 스냅샷으로 보내지 않는다. "social_posts": [{"post_id": str(r.post_id), "provider": r.provider, "body": r.body, "permalink": r.permalink, "posted_at": r.posted_at.isoformat()} for r in social_rows], - # ★ 이 숙소의 노래. 파일은 DB 가 아니라 `solution/site/songs/<file_name>` 에 있고, - # 프리렌더가 그걸 사이트 디렉토리로 복사한다(services/song_service 머리주석). - # ★ origin_url(Suno 주소)은 싣지 않는다 — 만료되는 주소라 발행본에 나가면 안 된다. + # 이 숙소의 노래. "songs": [ { "song_id": str(r.song_id), @@ -216,7 +183,7 @@ async def build_snapshot(place) -> dict: for r in song_rows if (r.file_name or "").strip() ], - # ★ 본문과 날짜만 싣는다. 토큰·상태는 운영 값이라 발행본에 나가면 안 된다. + # 본문과 날짜만 싣는다. "posts": [ { "post_id": str(r.post_id), @@ -226,7 +193,7 @@ async def build_snapshot(place) -> dict: } for r in post_rows ], - # ★ 손님이 적은 표시 이름만 싣는다. IP 해시·상태는 운영 값이라 발행본에 나가면 안 된다. + # 손님이 적은 표시 이름만 싣는다. "reviews": [ { "review_id": str(r.review_id), @@ -246,37 +213,11 @@ async def build_snapshot(place) -> dict: async def _local_contents(place) -> dict: - """사업장의 노출 가능한 지역·주변 정보. {"region_code", "contents":[...]} - - 두 캐시를 합친다 — - area_contents (region_code) 날씨 + 운영자가 수기로 발행한 항목 - place_area_refs (place_id) TourAPI 반경 수집분(맛집·관광지·축제·여행코스). 숨김만 제외 - (축제는 종료 여부와 무관하게 노출, 2026-09-17 결정) - - ★ 노출 가능 = PUBLISHED + 노출 기간 안. - area_contents.status 는 운영 관리자의 검수 결과다(REVIEW=1 · PUBLISHED=2 · ENDED=3). - REVIEW 는 아직 사람이 확인하지 않은 외부 API 원문이고, ENDED 는 내린 것이다. - 둘 중 하나라도 사이트로 새면 '미검증 값 노출 금지'가 깨진다 — fact 를 VERIFIED/CORRECTED 로, - 사진을 APPROVED 로 거르는 것과 같은 규칙을 같은 이유로 적용한다. - display_start_at/display_end_at 은 운영자가 수기로 정한 노출 창이다(운영자가 걸어 둔 항목에만 - 쓰인다 — TourAPI 로 긁은 축제는 종료 여부와 무관하게 둘 다 NULL, 2026-09-17 결정: - services/external/tour_api.py, local_content_service.py 참고). - - ★ expires_at 은 보지 않는다. 모델 주석대로 그건 '갱신 대상'이라는 표시지 '못 쓰는 값'이 아니다 - (외부 API 가 죽어도 직전 값을 유지하는 게 이 캐시의 규약이다). 게다가 날씨는 렌더러가 - 하이드레이션 뒤 최신값으로 덮어쓴다(solution/site/src/lib/use-live-weather.ts). - """ - # ★ getattr 로 읽는다 — 이 함수는 ORM 행뿐 아니라 테스트의 가짜 place 객체도 받는다. + """사업장의 노출 가능한 지역·주변 정보.""" + # getattr 로 읽는다 — 이 함수는 ORM 행뿐 아니라 테스트의 가짜 place 객체도 받는다. region_code = str(getattr(place, "region_code", None) or "").strip() if not region_code: - # ★ 저장된 값이 없으면 도로명주소에서 즉석에서 유도한다. - # places.region_code 를 채우는 곳은 신원 확정(place_service.verify) 한 곳뿐이라, - # 그 코드가 생기기 전에 만들어진 사업장은 영영 NULL 로 남는다(실측: 28곳 중 25곳). - # 그 사업장은 날씨·축제·주변 관광지가 통째로 비고, 발행본에서 날씨 섹션이 아예 - # 사라진다 — 에디터에는 보이는데(폴백값을 그리므로) 사이트에는 없는 그 자리다. - # 여기서 유도하면 신원을 다시 확정하지 않아도 다음 발행부터 지역 정보가 붙는다. - # ★ 지어내지 않는 규칙은 그대로다. region_key 는 주소에서 뽑을 뿐이고, - # 주소가 없거나 형식이 다르면 None 이다(그때는 비는 게 맞다). + # 저장된 값이 없으면 도로명주소에서 즉석에서 유도한다. region_code = region_key( str(getattr(place, "road_address", None) or getattr(place, "address", None) or "") ) or "" @@ -291,10 +232,7 @@ async def _local_contents(place) -> dict: .where( area_contents.region_code == region_code, area_contents.deleted == False, # noqa: E712 - # ★ **지역 단위 항목만** 본다 — 날씨와 지역 이야기다(external_id 없이 지역에 한 벌). - # 관광지·맛집·축제는 같은 표에 있지만 업장마다 거리가 달라, 아래 사이트 쪽에서 - # 개인화 값과 함께 읽는다. 여기서 같이 긁으면 거리 없는 항목이 먼저 들어와 - # 종류별 상한을 채워 버린다(실측 2026-09-09: 주변 12건이 전부 거리 없이 나갔다). + # **지역 단위 항목만** 본다 — 날씨와 지역 이야기다(external_id 없이 지역에 한 벌). area_contents.external_id.is_(None), area_contents.status == LocalContentStatus.PUBLISHED.value, or_(area_contents.display_start_at.is_(None), area_contents.display_start_at <= now), @@ -306,16 +244,12 @@ async def _local_contents(place) -> dict: area_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query, raise_error=False) ) if err != ErrorType.SUCCESS: - # ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다. + # 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다. LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}") rows = [] contents += _local_rows(rows or [], seen) # ── 업장 주변: 공용 실체(area_contents) × 사이트 개인화(site_sections) ── - # ★ 2026-09-09 에 자리를 갈랐다. 공용 실체는 지역이 나눠 쓰고(거리를 담을 수 없다), - # 거리·숨김은 사이트마다 다르다. 그래서 관계 테이블이 아니라 **사이트 섹션**에서 읽는다. - # 정렬은 여기가 한다 — 사진 있는 것 먼저, 그다음 가까운 순(2026-09-07 결정). - # 저장 쪽에 정렬을 구워 두면 기준이 바뀔 때 전 사이트를 다시 써야 한다. place_id = getattr(place, "place_id", None) if place_id is not None: personal = await _site_places(place_id) @@ -340,7 +274,7 @@ async def _local_contents(place) -> dict: if mine.get("hidden"): continue body = dict(row.body if isinstance(row.body, dict) else {}) - # 거리만 얹는다. 공용 실체는 이미 렌더러 모양이라 여기서 이름을 바꾸지 않는다. + # 거리만 얹는다. if mine.get("distanceMeters") is not None: body["distanceMeters"] = mine["distanceMeters"] merged.append((row, body, mine.get("distanceMeters"))) @@ -351,8 +285,6 @@ async def _local_contents(place) -> dict: ) # ── 여행 일정(place_itineraries) — 업장마다 기간당 한 행 ────────── - # ★ 여기서 **읽기만** 한다. 생성은 잡·빌드가 한다(services/itinerary_llm_service). - # 스냅샷 조립 안에서 LLM 을 부르면 발행 한 번이 1분 늘고, 캔버스 조회도 같은 길을 탄다. itineraries: list[dict] = [] if place_id is not None: from services.itinerary_llm_service import get_itineraries @@ -367,7 +299,7 @@ async def _local_contents(place) -> dict: class _Row: - """area_contents 행 + 사이트 값이 얹힌 body. `_local_rows` 가 두 캐시를 같은 모양으로 읽게 한다.""" + """area_contents 행 + 사이트 값이 얹힌 body.""" __slots__ = ("content_type", "source", "title", "body", "collected_at", "kind", "latitude", "longitude") @@ -387,11 +319,7 @@ def _is_uuid(value: str) -> bool: async def _site_places(place_id) -> dict: - """이 사이트의 주변 개인화 맵(ref → {kind, distanceMeters, hidden}). - - ★ 사이트가 없으면 빈 맵이다 — 발행 전 업장은 주변 정보가 안 나간다. 그건 옳다. - 개인화 값이 없다는 건 "이 사이트에 그 항목이 붙은 적이 없다"는 뜻이다. - """ + """이 사이트의 주변 개인화 맵(ref → {kind, distanceMeters, hidden}).""" q = ( select(site_sections.data) .join(sites, sites.site_id == site_sections.site_id) @@ -413,8 +341,7 @@ async def _site_places(place_id) -> dict: def _local_rows(rows, seen: dict[int, int], source: int | None = None) -> list[dict]: - """행 → 스냅샷 항목. 맛집 외 종류별 상한은 들어온 순서(정렬)대로 자른다. - seen 은 호출측이 넘겨 두 캐시에 걸쳐 누적한다.""" + """행 → 스냅샷 항목.""" out = [] for row in rows: content_type = int(row.content_type) @@ -434,8 +361,7 @@ def _local_rows(rows, seen: dict[int, int], source: int | None = None) -> list[d } if kind: entry["kind"] = kind - # ★ 좌표는 **컬럼**에서 온다(2026-09-09). 예전에는 body.mapx/mapy 였는데, 같은 값이 - # 컬럼에도 있어 한쪽만 갱신될 자리였다. 일정 조립(services/itinerary)이 이걸 읽는다. + # 좌표는 **컬럼**에서 온다. for key, value in (("latitude", getattr(row, "latitude", None)), ("longitude", getattr(row, "longitude", None))): if value is not None: @@ -445,10 +371,7 @@ def _local_rows(rows, seen: dict[int, int], source: int | None = None) -> list[d def _iso(value) -> str | None: - """datetime → ISO8601 문자열. - - ★ 스냅샷은 JSONB 컬럼에 그대로 들어간다 — datetime 을 그대로 넣으면 직렬화에서 터진다. - DB 의 timestamptz 는 naive UTC 로 올라오므로(GTime 규약) UTC 를 명시해 둔다.""" + """datetime → ISO8601 문자열.""" if value is None: return None if value.tzinfo is None: @@ -457,13 +380,7 @@ def _iso(value) -> str | None: async def _select(query) -> list: - """조회 실패는 **빈 목록**이다 — 미리보기·발행이 조각 하나 때문에 통째로 죽지 않게. - - ★ raise_error=False 가 핵심이다 (2026-09-14). 이 함수는 원래 실패를 [] 로 삼키도록 - 썼는데, DB 계층이 기본값 raise_error=True 로 **예외를 던져** 그 처리가 실행될 기회조차 - 없었다. 실측: place_songs 테이블이 없던 동안 미리보기가 통째로 HTTP 500 이었다 — - 노래 한 칸이 빠진 화면 대신 아무것도 못 보는 화면이 나갔다. - """ + """조회 실패는 **빈 목록**이다 — 미리보기·발행이 조각 하나 때문에 통째로 죽지 않게.""" err, rows = await DB_SESSION_MNG.execute_lambda( place_facts.DBType(), DBWRType.DB_READ.value, diff --git a/solution/backend/services/social_account_service.py b/solution/backend/services/social_account_service.py index 179cd2d..c7677ea 100644 --- a/solution/backend/services/social_account_service.py +++ b/solution/backend/services/social_account_service.py @@ -1,4 +1,4 @@ -"""위임 토큰은 암호문만 저장한다. 갱신·연결 해제·게시가 같은 계정 잠금을 사용한다.""" +"""위임 토큰은 암호문만 저장한다.""" import json from config import social_config as config @@ -47,7 +47,7 @@ def begin(user_id, provider): raise SocialError("SOCIAL_CONNECTION_DISABLED") browser = secrets.token_urlsafe(32) verifier = secrets.token_urlsafe(32) - # 서명만 된 state는 PKCE verifier를 URL로 공개한다. 암호화하고 브라우저 쿠키에도 묶는다. + # 서명만 된 state는 PKCE verifier를 URL로 공개한다. state = encrypt( json.dumps( { @@ -140,7 +140,7 @@ async def get_usable_token(s, row, client): result = await adapter(row.provider).refresh( decrypt(row.refresh_token) if row.refresh_token else token, client=client ) - # 회전 토큰 저장은 한 UPDATE. 호출측은 이 트랜잭션을 커밋한 뒤에만 게시를 시작한다. + # 회전 토큰 저장은 한 UPDATE. await s.execute( update(Account) .where(Account.account_id == row.account_id) diff --git a/solution/backend/services/social_service.py b/solution/backend/services/social_service.py index cc78abb..1a6e2f8 100644 --- a/solution/backend/services/social_service.py +++ b/solution/backend/services/social_service.py @@ -1,4 +1,4 @@ -"""SNS 초안·승인·게시. 일반 발행과 분리해 사장님의 명시적인 요청만 처리한다.""" +"""SNS 초안·승인·게시.""" import hashlib import json @@ -127,12 +127,7 @@ async def list_posts(user_id, place_id): async def account_state(user_id, provider=2): - """사장님의 SNS 계정 연결 상태. **사업장과 무관하다.** - - ★ 왜 place 경로와 따로 두나 — 계정은 `user × provider` 단위라(표도 그렇게 생겼다) - 사이트마다 물어볼 값이 아니다. 연결 화면이 사업장 안에 있으면 사장님은 업장 수만큼 - 연결해야 하는 줄 안다. 연결은 한 번, 게재는 사이트마다다. - """ + """사장님의 SNS 계정 연결 상태.""" async def run(s): row = await accounts.account(s, user_id, provider) return { @@ -150,12 +145,7 @@ def posting_enabled(): async def test_post(user_id, text, provider=2): - """사장님이 자기 계정 연결이 실제로 되는지 확인하려고 한 줄 올려 보는 것. - - ★ 왜 posting_enabled()·승인 절차를 거치지 않는가 — 그 게이트는 승인 기반 - "소식 발행" 상품을 계약·해지 안내 정책이 끝나기 전에 열지 않으려는 것이다. - 여기서 사장님이 자기 계정에 테스트 한 줄을 올리는 것은 그 상품과 무관하다. - """ + """사장님이 자기 계정 연결이 실제로 되는지 확인하려고 한 줄 올려 보는 것.""" async def run(s): await accounts.lock_user(s, user_id, provider) @@ -231,7 +221,7 @@ async def create_draft(user_id, place_id, provider=2): ) ) ).scalar_one() # noqa: E712 - # 생성 실패는 같은 원고 행을 재시도한다. 이미 쓴 글은 유료 재생성하지 않는다. + # 생성 실패는 같은 원고 행을 재시도한다. if not row_id and row.status == "FAILED" and not row.body: row.status, row.last_error = "DRAFTING", None row.updated_at = datetime.now(timezone.utc) @@ -313,7 +303,7 @@ async def request_approval(user_id, post_id): if not initial or initial.deleted or initial.user_id != user_id: raise HTTPException(404, "PLACE_NOT_FOUND") _, _, url = await target(s, user_id, initial.place_id) - # 생성/승인/게시 모두 site → post 순서로 잠근다. 반대면 동시 재요청이 교착된다. + # 생성/승인/게시 모두 site → post 순서로 잠근다. row = ( await s.execute( select(Post) @@ -410,7 +400,7 @@ async def approval(post_id, token, *, approve=None): async def owner_decision(user_id, post_id, approve): - # 화면은 비밀 링크를 저장하지 않아도 승인할 수 있다. 신원 범위만 다르고 CAS는 같다. + # 화면은 비밀 링크를 저장하지 않아도 승인할 수 있다. async def load(s): row = await s.get(Post, post_id) if not row or row.deleted or row.user_id != user_id: @@ -583,10 +573,7 @@ async def run_post(job): async def publish_reused_text(user_id, place_id, body: str): - """미니블로그 승인 문구를 그대로 쓰레드에 낸다 — 승인 자체가 발화 동의라 별도 승인을 - 또 묻지 않는다(2026-09-21, DECISIONS 7-1-2 개정: 미니블로그 문구를 그대로 재사용하는 - 경우에 한정한 예외). 연동 안 돼 있거나 조건 미달이면 조용히 None을 돌려준다 — 호출부가 - 실패로 취급하지 않는다.""" + """미니블로그 승인 문구를 그대로 쓰레드에 낸다 — 승인 자체가 발화 동의라 별도 승인을 또 묻지 않는다.""" if not posting_enabled(): return None diff --git a/solution/backend/services/song_service.py b/solution/backend/services/song_service.py index d84a829..2c3a939 100644 --- a/solution/backend/services/song_service.py +++ b/solution/backend/services/song_service.py @@ -1,42 +1,4 @@ -"""이 숙소의 노래 — SONG 잡이 하는 일. - - 가사 services/external/gemini_text.generate_song (확인된 fact + 소개문으로 쓴다) - 작곡 services/external/suno (폴링 · 40초 · 한 곡) - 여기 재료 모으기 → 가사 → 작곡 → 파일 보관 (발행이 이걸 기다린다) - -★ **발행은 노래를 기다린다** (2026-09-11 결정). - BUILD 잡이 스냅샷을 뜨기 **전에** 여기를 부른다(`build_service.run_build`). 그래서 발행된 - 사이트에는 처음부터 노래가 들어 있다 — 사장님이 [사이트 열기] 를 눌러 본 화면과 손님이 - 보는 화면이 같다. - 값은 발행이 그만큼 늦어지는 것이다(실측 30초~3분, 상한 5분). 먼저 굽고 나중에 붙이는 - 방식도 만들어 봤지만, 그러면 발행 직후의 사이트에는 노래가 없고 몇 분 뒤 조용히 생긴다 — - "발행했는데 그 기능이 없다" 를 사장님이 먼저 본다. - -★ 잡 타입(JobType.SONG)은 그대로 둔다. - 발행과 무관하게 **곡만 다시 만들** 때 쓰는 길이다(운영자가 잡을 직접 넣는다). - 발행 경로와 같은 함수(`ensure_song`)를 부르므로 둘이 갈라지지 않는다. - -★ 왜 파일을 받아서 보관하나 - Suno 가 주는 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후에는 재생되고 몇 주 뒤 - 조용히 죽는다 — 아무도 안 누르면 죽은 줄도 모르는 종류다. 그래서 mp3 를 받아 - `solution/site/songs/` 에 두고, 프리렌더가 사이트 디렉토리로 복사한다. - (백엔드는 여전히 HTML 을 만들지 않는다 — 파일과 payload 를 같은 약속된 자리에 둘 뿐이다.) - -★ Blob Storage 로는 **발행이 올린다** — 여기서 따로 올리지 않는다. - 파일이 `out/s/<slug>/` 안에 있으므로 `azure_static.publish(slug)` 가 사이트를 통째로 - 올릴 때 함께 올라간다(content-type `audio/mpeg`, 해시 파일명이라 immutable 캐시). - 지난 곡은 `_remove_stale_site_files` 가 블롭에서도 지운다. - → 업로더를 하나 더 두면 같은 컨테이너에 **두 규칙**이 생긴다(경로·캐시·정리 주체). - 참고 프로젝트(o2o-castad-backend)는 사이트가 없어 직접 올릴 수밖에 없었지만, - 여기서는 노래가 사이트의 일부라 사이트와 같은 길로 나가는 것이 맞다. - 덕분에 주소도 같은 오리진(`/s/<slug>/<song_id>.mp3`)이라 CORS·혼합콘텐츠 문제가 없다. - -★ 실패는 발행을 막지 않는다 - 키가 없거나(SUNO_API_KEY·GEMINI_API_KEY), 재료가 없거나, Suno 가 늦으면(상한 5분) - **노래만 없이** 발행된다. 기다리는 것과 막는 것은 다르다 — 음악 API 가 느린 날 - 사장님 사이트가 아예 안 나가는 것은 맞바꿀 수 없는 손해다. - 사유는 place_songs.last_error 와 빌드 로그에 남는다. -""" +"""이 숙소의 노래 — SONG 잡이 하는 일.""" import uuid from pathlib import Path @@ -61,9 +23,7 @@ _fact_crud = FactCRUD() _place_crud = PlaceCRUD() _song_crud = SongCRUD() -# payload 와 나란히 두는 자리. 프리렌더가 이 디렉토리에서 파일을 찾는다. -# payload_dir 이 `/app/out/payloads` 면 여기는 `/app/out/songs` 다 — 컴포즈가 둘 다 호스트의 -# `solution/site/` 아래로 붙인다. 한 디렉토리 약속(ARCHITECTURE 1절)을 노래에도 그대로 쓴다. +# payload 와 나란히 두는 자리. SONGS_DIRNAME = "songs" @@ -76,11 +36,7 @@ def songs_dir() -> Path: async def _grounding(place, place_id: str) -> tuple[list[str], str]: - """가사 재료 — 확인된 fact + 수집/조사 원문, 그리고 소개문. - - ★ 소개문 생성과 **같은 재료**를 쓴다(copy_service). 노래만 다른 출처를 보면 사이트의 - 글과 노래가 서로 다른 숙소를 말하게 된다. - """ + """가사 재료 — 확인된 fact + 수집/조사 원문, 그리고 소개문.""" schema = get_schema(PlaceCategory(place.category)) pid = uuid.UUID(place_id) @@ -126,28 +82,14 @@ async def _grounding(place, place_id: str) -> tuple[list[str], str]: async def run_song(job: dict) -> dict: - """SONG 잡 핸들러 — 발행과 무관하게 곡만 다시 만들 때 쓴다. - - 발행 경로는 이 잡을 거치지 않고 `ensure_song` 을 직접 부른다(build_service).""" + """SONG 잡 핸들러 — 발행과 무관하게 곡만 다시 만들 때 쓴다.""" payload = job["payload"] - # ★ 이 잡은 "다시 만들어 달라" 는 요청이다 — 기존 곡이 있어도 만든다(ensure_song 주석). + # 이 잡은 "다시 만들어 달라" 는 요청이다 — 기존 곡이 있어도 만든다(ensure_song 주석). return await ensure_song(payload["place_id"], payload["owner_user_id"], force=True) async def ensure_song(place_id: str, owner_user_id: str, *, force: bool = False) -> dict: - """이 업장의 노래를 한 곡 만든다. 돌려주는 dict 가 곧 잡 결과이자 빌드 로그다. - - ★ **이미 완성된 곡이 있으면 만들지 않는다**(2026-09-15 대표: "재발행할 때 노래 다시 - 생성하면 안 되거든"). 예전에는 `build_service` 가 `publish=True` 마다 이 함수를 불렀고 - 여기에 가드가 없어서, 내용이 하나도 안 바뀐 재발행에도 **Gemini 가사 1회 + Suno 작곡 - 1회**가 그대로 나갔다. 사장님이 발행을 다섯 번 누르면 유료 호출 다섯 번에 - `place_songs` 행이 다섯 개다. 화면은 최신 READY 한 곡만 쓰므로(`SongCRUD.latest_ready`) - 나머지는 값을 만들지 않고 돈만 쓴다. - ★ 곡을 **일부러 다시 만드는 길은 남긴다** — `force=True`. 그 길은 발행이 아니라 - SONG 잡(`run_song`)이고, 사람이 눌러야 돈다. - ★ 실패를 예외로 올리지 않는다(사업장을 못 찾는 것 같은 진짜 고장만 예외다). - 발행이 이 함수를 기다리는데 여기서 예외가 나면 **노래 때문에 발행이 통째로 실패**한다. - 음악 API 가 느린 날 사장님 사이트가 안 나가는 것은 맞바꿀 수 없는 손해다.""" + """이 업장의 노래를 한 곡 만든다.""" if not suno.is_configured(): # 키가 없는 것은 고장이 아니라 설정이다 — 예외로 올려 재시도·DEAD 로 만들지 않는다. LOG.i(f"[song] place={place_id} 건너뜀 — SUNO_API_KEY 미설정") @@ -245,8 +187,7 @@ async def ensure_song(place_id: str, owner_user_id: str, *, force: bool = False) except httpx.HTTPError as ex: return await _fail(f"오디오 내려받기 실패: {type(ex).__name__}: {ex}") - # ── 3. 보관 ─────────────────────────────────────────────── - # 파일명은 song_id 다 — 발행마다 새 곡이 생기므로 이름이 겹치면 옛 곡이 새 곡으로 바뀐다. + # ── 3. 보관 ─────────────────────────────────────────────── 파일명은 song_id 다 — 발행마다 새 곡이 생기므로 이름이 겹치면 옛 곡이 새 곡으로 바뀐다. file_name = f"{row.song_id}.mp3" directory = songs_dir() try: @@ -272,8 +213,7 @@ async def ensure_song(place_id: str, owner_user_id: str, *, force: bool = False) LOG.i(f"[song] place={place_id} '{song.title}' ({song.style}) 완성 — {len(content)} bytes · {file_name}") - # ★ 여기서 재빌드를 걸지 않는다. 발행이 이 함수를 **기다리고 있고**, 돌아가면 바로 그 - # 스냅샷에 이 곡이 실린다. 잡으로 따로 돌 때(운영자 재생성)도 같다 — 다음 발행에 실린다. + # 여기서 재빌드를 걸지 않는다. return { "place_id": place_id, "song_id": str(row.song_id), "title": song.title, "style": song.style, "file": file_name, diff --git a/solution/backend/services/stay_guide.py b/solution/backend/services/stay_guide.py index 4d01219..44ca376 100644 --- a/solution/backend/services/stay_guide.py +++ b/solution/backend/services/stay_guide.py @@ -17,7 +17,7 @@ def nol_stay_guide(url: str, raw) -> dict: return {} labels = {"시설/서비스": "service", "이용 안내": "policy", "예약 공지": "reservation"} - # 수집기가 붙인 경계로만 나눈다. 규정 안의 괄호나 문장은 분리하지 않는다. + # 수집기가 붙인 경계로만 나눈다. sections = re.split(r"(?m)^\[(숙소 소개|시설/서비스|이용 안내|예약 공지)\]\s*\n", text) guide = {} for label, body in zip(sections[1::2], sections[2::2]): @@ -26,10 +26,9 @@ def nol_stay_guide(url: str, raw) -> dict: lines = body.strip().splitlines() if lines and lines[0].strip() == label: lines.pop(0) - # 제목 중복과 UI 버튼만 제외한다. 요약·추론 없이 수집 문장을 보존한다. + # 제목 중복과 UI 버튼만 제외한다. body = "\n".join(line for line in lines if line.strip() != "전체보기").strip() - # NOL 은 원 플랫폼 브랜드다 — 사장님 사이트에 그대로 실으면 남의 고객센터·정책을 - # 우리 것처럼 안내하게 된다. 문장 자체는 그대로 두고 그 단어만 지운다. + # NOL 은 원 플랫폼 브랜드다 — 사장님 사이트에 그대로 실으면 남의 고객센터·정책을 우리 것처럼 안내하게 된다. body = re.sub(r"NOL\s*", "", body) if body: guide[labels[label]] = body @@ -39,7 +38,7 @@ def nol_stay_guide(url: str, raw) -> dict: def structured_fields(guide: dict) -> list[dict]: - """확실한 표기만 구조화한다. 매칭되지 않은 내용은 안내 원문에 남는다.""" + """확실한 표기만 구조화한다.""" policy = guide.get("policy", "") service = guide.get("service", "") reservation = guide.get("reservation", "") diff --git a/solution/backend/services/story_service.py b/solution/backend/services/story_service.py index 1b78578..79df581 100644 --- a/solution/backend/services/story_service.py +++ b/solution/backend/services/story_service.py @@ -1,25 +1,4 @@ -"""지역 이야기 생성 — 가요·인물·연표·엽서·퀴즈를 **지역 단위로 한 번** 채운다. - -★ 왜 지역 단위인가 - 이 다섯은 업장의 사실이 아니라 도시의 사실이다. 군산 이야기는 군산 숙소가 같이 쓴다. - 키를 place_id 로 잡으면 같은 지역에 숙소 50곳이 들어올 때 같은 곡 목록을 50번 만든다 - — `area_contents` 가 region_code 를 키로 두는 것과 같은 이유이고, 여기가 그 표를 쓴다. - -★ 왜 종류마다 따로 부르나 - 다섯을 한 프롬프트에 넣으면 (1) 출력이 길어 잘리고 (2) 한 종이 실패하면 전부 다시 돌고 - (3) 검색 출처가 어느 항목 것인지 섞인다. 종류당 1회, 한 번에 그 종류 전부다 — - 항목당 1회는 반대로 낭비다(검색이 한 번에 여러 건을 답한다). - -★ 왜 Perplexity 한 곳인가 - 이 값들은 **출처가 붙어야** 쓸 수 있다(항목의 `source.url`). Gemini 는 검색을 안 해서 - 주소를 지어내고, Perplexity 는 실제로 읽은 `search_results` 를 함께 준다. - 구조는 프롬프트의 [스키마] 블록이 잡고, 파이썬은 모양을 다시 적지 않는다 - (`grounding/story.py` 머리주석). - -★ 검수 게이트를 두지 않는다 (2026-09-09 결정 — docs/DECISIONS.md) - 생성분은 PUBLISHED 로 저장한다. 대신 항목마다 `verified`·`source` 가 실려 화면이 그걸 밝히고, - 틀린 항목은 사장님이 에디터에서 뺀다. 공공데이터(맛집·관광지)를 검수 없이 싣는 것과 같은 규약이다. -""" +"""지역 이야기 생성 — 가요·인물·연표·엽서·퀴즈를 **지역 단위로 한 번** 채운다.""" import uuid import httpx @@ -36,28 +15,21 @@ from services.llm import perplexity from services.local_restaurant_enrichment import enrich_place_restaurants from services.prompts import story as prompts -# ★ 다섯을 **순차로** 부른다. 처음엔 동시에 띄웠는데 실측(2026-09-09, 전북 군산시)에서 -# 다섯 중 둘이 HTTP 429 로 떨어졌다 — 같은 키로 나가는 호출이라 한 지역이 자기 자신을 막는다. -# 순차로 돌려도 건당 9~15초라 다섯이 1분 안이고(같은 실측), 이건 잡이라 사람이 기다리지 않는다. -# "빨리 끝내려다 절반을 잃는" 교환이 성립하지 않는다. +# 다섯을 **순차로** 부른다. -# ★ 채널 발견(90s)보다 길게 잡는다. 같은 실측에서 가요 다방이 90초를 넘겼다 — -# "이 도시를 노래한 곡" 은 후보를 넓게 훑어야 해서 검색 왕복이 더 많다. +# 채널 발견(90s)보다 길게 잡는다. _TIMEOUT = httpx.Timeout(240.0, connect=10.0) -# 생성분에는 노출 종료가 없다. 축제와 달리 "지난 것"이 되지 않는다 — -# 1966년 곡은 내년에도 1966년 곡이다. 갱신은 운영자가 다시 돌릴 때만 일어난다. +# 생성분에는 노출 종료가 없다. _DISPLAY_END = None -# 봉투 버전. 사장님이 붙여넣는 JSON 의 `version` 과 같은 자리다 — 읽는 쪽이 둘을 구분하지 -# 않아야 하므로 값도 같게 둔다(`shared/lib/section-data.ts`). +# 봉투 버전. _ENVELOPE_VERSION = 1 async def _generate_kind(client: httpx.AsyncClient, kind: str, region_label: str) -> tuple[list[dict], list[str]]: - """종류 하나. 실패는 예외로 올리지 않고 빈 목록으로 돌려준다 — - 한 종류가 죽어도 나머지 넷은 채워야 한다.""" + """종류 하나.""" body = { "model": perplexity.DEFAULT_MODEL, "messages": [ @@ -84,39 +56,24 @@ async def _generate_kind(client: httpx.AsyncClient, kind: str, region_label: str return items, dropped -# 종류별 응답 토큰 상한. 기본(2048)으로 모자란 종류만 적는다. -# -# ★ 왜 (실측 2026-09-15) `reading` 은 30~34꼭지 × 서너 문장이라 다른 종류의 서너 배다. -# 2048 로 부르면 응답이 **문장 한가운데서 잘려** 오고, 파서는 그걸 "JSON 이 아니다" 로 -# 통째로 버린다 — 여섯 지역 전부 0건이었다. 잘린 것은 재시도해도 같은 자리에서 잘린다. -# ★ 상한만 올린다. 꼭지 수를 줄이면 화면이 매번 5~6개만 뽑는 의미가 없어진다 -# (`site/sections/items/ReadingSection.tsx`). +# 종류별 응답 토큰 상한. _MAX_TOKENS = {"reading": 8000} -# 사진을 무엇으로 찾을지. 종류마다 출처가 다르다. -# -# chronicle · postcard 공공데이터(TourAPI) — 장소·시설 사진. 공공누리 Type1/Type3 만 -# people 위키미디어 — 사람 얼굴. 상업 이용 가능 라이선스만 -# -# ★ 가요·퀴즈는 찾지 않는다. 시안에도 그 자리에 사진이 없다. +# 사진을 무엇으로 찾을지. _PLACE_IMAGE_KEYS = { "chronicle": ("place", "title"), # 그 해의 장소 → 없으면 사건 이름 "postcard": ("place", "postmark"), # 엽서 앞면이 될 장소 } -# 한 종류에서 사진을 찾아볼 항목 수 상한. 건당 0.3~1초라 열두 개를 다 찌르면 생성이 두 배 걸린다. +# 한 종류에서 사진을 찾아볼 항목 수 상한. _IMAGE_LOOKUP_LIMIT = 12 -# ★ 사진이 없으면 항목 자체를 버리는 종류. -# 엽서는 **앞면 사진이 본체**다. 사진이 없으면 뒷면(문장·우표·소인)만 남아 카드가 반쪽이 되고, -# 시안과 나란히 놓으면 빈 카드로 보인다(실측 2026-09-10). 연표는 다르다 — 활자만으로도 -# 레일 위에 서므로 사진 없는 항목을 버리면 연표에 구멍이 난다. +# 사진이 없으면 항목 자체를 버리는 종류. _IMAGE_REQUIRED_KINDS = {"postcard"} def _region_token(region_label: str) -> str: - """주소 대조에 쓸 지역 토막("전북 군산시" → "군산"). 시/군/구 접미사를 뗀다 — - TourAPI 주소는 '전북특별자치도 군산시 …' 라 표기가 우리와 다를 수 있다.""" + """주소 대조에 쓸 지역 토막("전북 군산시" → "군산").""" for tok in reversed((region_label or "").split()): if tok.endswith(("시", "군", "구")) and len(tok) > 1: return tok[:-1] @@ -124,16 +81,7 @@ def _region_token(region_label: str) -> str: async def _attach_images(kind: str, items: list[dict], region_label: str) -> list[dict]: - """항목에 사진을 붙이고, 사진이 본체인 종류는 못 붙은 항목을 버린다. - - ★ **수집하는 그 자리에서 함께 가져온다.** 이미 DB 에 있는 사진을 가져다 쓰지 않는다 — - 그건 "이 항목의 사진" 이 아니라 "마침 우리가 갖고 있던 사진" 이고, 엉뚱한 장소가 - 그 해의 사진으로 붙는다. - ★ **같은 사진을 두 번 쓰지 않는다.** 네 항목이 같은 시설을 말하면 검색이 같은 사진을 - 네 번 준다 — 화면에는 같은 그림 넷이 늘어선다(실측 2026-09-10, 군산근대역사박물관). - 두 번째부터는 사진 없는 것으로 친다. - ★ 권리 판정은 부르는 쪽이 아니라 각 출처 모듈이 한다(tour_api.find_image · wikimedia). - """ + """항목에 사진을 붙이고, 사진이 본체인 종류는 못 붙은 항목을 버린다.""" found = 0 used: set[str] = set() @@ -158,7 +106,7 @@ async def _attach_images(kind: str, items: list[dict], region_label: str) -> lis if not url or url in used: continue item["imageUrl"] = url - # 공공누리 제1유형도 출처 표시가 조건이다. 사진을 준 곳을 그대로 적는다. + # 공공누리 제1유형도 출처 표시가 조건이다. item["imageCredit"] = "한국관광공사" used.add(url) found += 1 @@ -176,11 +124,7 @@ async def _attach_images(kind: str, items: list[dict], region_label: str) -> lis async def generate_region_stories(region_code: str, region_label: str, kinds: list[str] | None = None) -> dict: - """지역 하나의 이야기를 생성해 `area_contents` 에 넣는다. 종류별 채택 건수를 돌려준다. - - ★ 기존 행을 먼저 지우지 않는다. 순번 키로 덮어쓰므로, 새로 받은 것이 적으면 뒤쪽 옛 행이 - 남는다 — 그건 의도다. 이번 검색이 부실했다고 지난번에 확인된 항목까지 날리지 않는다. - """ + """지역 하나의 이야기를 생성해 `area_contents` 에 넣는다.""" wanted = kinds or prompts.kinds() crud = LocalContentCRUD() result: dict[str, int] = {} @@ -195,9 +139,7 @@ async def generate_region_stories(region_code: str, region_label: str, kinds: li if not items: continue - # ★ 한 지역 × 한 종류 = 한 행이다(`uq_local_contents_kind`, migrations/0004). - # 항목마다 행을 만들면 같은 곡이 두 번 서거나 재생성이 옛 행을 못 덮는다. - # 봉투 모양은 사장님이 붙여넣는 JSON 과 **같다** — 읽는 쪽이 둘을 구분하지 않는다. + # 한 지역 × 한 종류 = 한 행이다(`uq_local_contents_kind`, migrations/0004). label = prompts.label(kind) values = { "local_content_id": uuid.uuid4(), @@ -212,8 +154,7 @@ async def generate_region_stories(region_code: str, region_label: str, kinds: li "display_end_at": _DISPLAY_END, "collected_at": GTime.UTC(), } - # ★ execute_lambda_run 이다(claim 아님). claim 은 func 이 (ErrorType, 행수)를 돌려주길 - # 기대하는데 upsert 는 ErrorType 만 준다 — sync_place 가 공용 콘텐츠를 넣는 방식과 같다. + # execute_lambda_run 이다(claim 아님). err = await DB_SESSION_MNG.execute_lambda_run( [area_contents.DBType()], [lambda s, v=values: crud.upsert_kind(s, v)], ) @@ -226,15 +167,7 @@ async def generate_region_stories(region_code: str, region_label: str, kinds: li async def missing_kinds(region_code: str) -> list[str]: - """이 지역에 아직 없는 이야기 종류. cache-aside 판단용. - - ★ 예전엔 `has_stories` 하나였다 — **한 건이라도 있으면** 다시 부르지 않았다. - 그 가드는 "같은 지역 두 번째 숙소"만 생각한 것이라, **종류가 늘어난 날** 정확히 반대로 - 동작한다: 이미 다섯이 들어 있는 지역은 여섯 번째(`daily`)를 영영 못 받는다. - 새 지역에서만 여섯이 채워지고 기존 지역은 다섯에 멈춰, 같은 템플릿을 골라도 지역에 - 따라 탭 수가 다른 상태가 된다(실측 2026-09-10, 52군산시). - ★ 요금 가드는 그대로다 — 없는 종류만 부른다. 이미 있는 종류는 여전히 한 번도 다시 안 부른다. - """ + """이 지역에 아직 없는 이야기 종류.""" err, rows = await DB_SESSION_MNG.execute_lambda( area_contents.DBType(), DBWRType.DB_READ.value, lambda s: crud_list(s, region_code), @@ -251,25 +184,7 @@ async def crud_list(session, region_code: str): async def run_local_sync(job: dict) -> dict: - """LOCAL_SYNC 잡 핸들러 — **에디터에 들어가기 전에 지역 데이터를 다 채운다.** - - payload: {place_id?, region_code, region_label, kinds?} - - ★ 왜 셋을 한 잡에 묶나 - 업장 반경(TourAPI 맛집·관광지·축제)·여행 일정(LLM)·지역 이야기(LLM)는 성격이 다르지만, - 사장님에게는 "주변 이야기가 채워졌나" 하나다. 잡을 나누면 위저드가 여럿을 따로 기다려야 - 하고, 하나만 끝난 상태로 에디터에 들어가면 절반만 그려진 화면을 보게 된다. - 사진 분석(VISION)을 수집에서 떼어 낸 것과는 사정이 다르다 — 그건 각각 몇 분이라 실패 - 비용이 컸지만, 이 셋은 합쳐 1~2분대이고 유료 재호출도 아래 가드가 막는다. - - ★ 업장 것과 지역 것의 반복 단위가 다르다 - 반경 수집·여행 일정은 **업장마다** 해야 한다(좌표·업소 이름이 다르다). 이야기는 - **지역에 한 번**이면 된다 — 같은 지역 두 번째 숙소는 이미 있는 것을 그대로 쓴다. - 그래서 이야기 쪽만 가드가 붙는다. - - ★ 멱등하다. 이야기는 순번이 아니라 (region_code, kind) 한 행을 덮어쓰고, 반경 수집은 - external_id 로 upsert 한다 — lease 만료로 다시 돌아도 행이 늘지 않는다. - """ + """LOCAL_SYNC 잡 핸들러 — **에디터에 들어가기 전에 지역 데이터를 다 채운다.**""" payload = job["payload"] region_code = (payload.get("region_code") or "").strip() region_label = (payload.get("region_label") or "").strip() @@ -293,19 +208,11 @@ async def run_local_sync(job: dict) -> dict: LOG.w(f"[story] place={place_id} 반경 수집 실패(이야기는 계속한다): {synced.msg}") # ── 1.5 주변 맛집 보강(Perplexity + 네이버) — 업장마다 ───────────── - # ★ TourAPI 블록 바로 뒤다. 그쪽이 이미 만든 place_area_refs 개수를 기준으로 - # "10건 미만이면 채운다"를 판단하기 때문이다(services/local_restaurant_enrichment.py). out["restaurant_enrichment"] = await enrich_place_restaurants( uuid.UUID(str(place_id)), region_label, region_code, ) # ── 2. 여행 일정(LLM) — 업장마다 ────────────────────────────────── - # ★ 이야기와 같은 잡에 둔다. 사장님에게는 "주변이 채워졌나" 하나이고, 둘 다 Perplexity 라 - # 같은 키로 나간다 — 잡을 나누면 두 잡이 동시에 떠서 서로를 429 로 막는다. - # ★ 이야기는 지역에 한 번이면 되지만 일정은 **업장마다** 필요하다(업소 이름이 프롬프트에 든다) - # — 반경 수집과 같은 반복 단위다. - # ★ 이야기 블록 **앞**이다. 저쪽은 "이미 있다" 로 조기 반환하므로, 뒤에 두면 이야기가 다 찬 - # 업장이 일정을 영영 못 받는다. if place_id: from services.itinerary_llm_service import ensure_generated_by_id @@ -315,8 +222,7 @@ async def run_local_sync(job: dict) -> dict: if not perplexity.is_configured(): out["stories"] = {"skipped": "PERPLEXITY_API_KEY 미설정"} return out - # ★ 잡이 종류를 지정했으면 그대로 따른다(재생성·보정용). 아니면 **없는 것만** 채운다 — - # 같은 지역 두 번째 숙소는 부를 것이 없어 곧바로 빠져나간다. + # 잡이 종류를 지정했으면 그대로 따른다(재생성·보정용). wanted = payload.get("kinds") or await missing_kinds(region_code) if not wanted: out["stories"] = {"skipped": "이미 있다"} @@ -327,13 +233,7 @@ async def run_local_sync(job: dict) -> dict: def region_label_of(place) -> str: - """프롬프트에 넣을 지명("전북특별자치도 군산시"). - - ★ region_code("52군산시")를 그대로 넣지 않는다 — 숫자가 붙은 문자열을 지명으로 주면 - 모델이 그걸 지명의 일부로 읽는다. 주소 앞 두 토큰이 사람이 부르는 이름이다. - ★ 주소가 없으면 빈 문자열이다. 지역을 모르면 부르지 않는다 — 어디 이야기인지 모르는 - 채로 물으면 모델이 아무 도시나 고른다. - """ + """프롬프트에 넣을 지명("전북특별자치도 군산시").""" address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip() if not address: return "" @@ -342,13 +242,7 @@ def region_label_of(place) -> str: async def enqueue_region_job(place) -> str | None: - """업장의 지역 데이터 잡을 큐에 넣고 job_id 를 돌려준다. 지역을 모르면 넣지 않는다. - - ★ dedupe 는 **업장 단위**다(`local:{place_id}`). 반경 수집이 업장마다 필요해서다 — - 지역 이야기의 중복 호출은 잡 안의 `missing_kinds` 가드가 막는다. - ★ 부르는 곳이 둘이다: 수집 완료 직후(collect_service)와 위저드의 생성 단계(place_service). - 먼저 넣은 잡이 아직 살아 있으면 enqueue_job 이 그 id 를 돌려준다 — 위저드는 그걸 기다린다. - """ + """업장의 지역 데이터 잡을 큐에 넣고 job_id 를 돌려준다.""" from crud.job_crud import JobQueue from services.job_service import enqueue_job diff --git a/solution/backend/services/teams_webhook.py b/solution/backend/services/teams_webhook.py index ee0dba5..2231324 100644 --- a/solution/backend/services/teams_webhook.py +++ b/solution/backend/services/teams_webhook.py @@ -1,15 +1,4 @@ -"""Microsoft Teams Workflows(수신 webhook) 로 어댑티브 카드 한 장을 보낸다. - -★ 이 파일은 "HTTP 로 카드 하나 보내기" 딱 그것만 안다 — 언제 보낼지·무엇을 보낼지는 - services/alert_service.py 가 정한다(관심사 분리, 재사용). search_console_alerts.py 가 - 쓰는 별도의 좁은 어댑터와는 다른 자리다 — 그건 색인 감시 전용이고 이건 잡 큐·발행 전반의 - 장애 알림 전용이다. 카드 포맷이 같은 이유로 합치자는 제안이 오면, 두 기능의 배포 주기가 - 다르다는 것과(색인 감시는 스케줄러 전용, 이건 워커 코드 곳곳에서 부른다) 지금 결합 이득이 - 적다는 것을 근거로 우선 보류한다. - -★ webhook 이 설정 안 됐으면 보내지 않는다(is_configured). 값이 없어도 서버는 그대로 뜬다 — - 운영 연결(실제 채널 지정)은 사용자 승인 후 별도로 한다. -""" +"""Microsoft Teams Workflows(수신 webhook) 로 어댑티브 카드 한 장을 보낸다.""" import os import httpx @@ -49,10 +38,7 @@ def _card(title: str, detail: str) -> dict: async def send(title: str, detail: str) -> bool: - """설정된 경우에만 보낸다. 실패는 로그로 남기고 삼킨다 — 호출측(alert_service)이 재시도를 관리한다. - - ★ webhook 주소 자체가 인증 수단이다(URL 에 서명이 박혀 있다) — 예외 문자열에 그 URL 이 - 실릴 수 있어 로그에는 안 남긴다(search_console_alerts.py 와 같은 규칙).""" + """설정된 경우에만 보낸다.""" url = _webhook_url() if not url: return False diff --git a/solution/backend/services/vision_service.py b/solution/backend/services/vision_service.py index ed3d382..fb15471 100644 --- a/solution/backend/services/vision_service.py +++ b/solution/backend/services/vision_service.py @@ -1,12 +1,4 @@ -"""사진 분류 + alt 생성 — VISION 잡이 하는 일. - -수집된 사진을 Gemini Vision 에 넘겨 분류 라벨과 alt 텍스트를 받아 media 에 반영한다. - -★ 신뢰도가 낮은 항목은 자동 반영하지 않는다. 라벨·alt 는 저장하되(사람이 보고 고칠 재료) - status 는 PENDING_REVIEW 로 남겨 사람 확인 큐에 둔다. 임계값은 설정값 하나로만 판단한다. -★ 사진 20~50장을 한 번에 처리하므로 배치·재시도·부분 실패는 클라이언트가 담당한다. - 여기서는 "결과를 어떻게 반영할 것인가"만 판단한다. -""" +"""사진 분류 + alt 생성 — VISION 잡이 하는 일.""" import uuid from common.database.db_session_manager import DB_SESSION_MNG @@ -30,7 +22,7 @@ class VisionAborted(PermanentJobError): async def run_vision(job: dict) -> dict: - """VISION 잡 핸들러. payload: {place_id, owner_user_id, force?}""" + """VISION 잡 핸들러.""" payload = job["payload"] place_id = payload["place_id"] owner_user_id = payload["owner_user_id"] @@ -101,10 +93,10 @@ async def run_vision(job: dict) -> dict: if row is None: continue if not result.ok: - # 분석 실패 — 사진은 그대로 두고 사람 확인 큐에 남긴다. 잡을 실패시키지 않는다. + # 분석 실패 — 사진은 그대로 두고 사람 확인 큐에 남긴다. stat["failed"] += 1 continue - # ★ 신뢰도 미달이면 라벨은 저장하되 승인 상태로 올리지 않는다. + # 신뢰도 미달이면 라벨은 저장하되 승인 상태로 올리지 않는다. approved = not result.needs_review status = MediaStatus.APPROVED.value if approved else MediaStatus.PENDING_REVIEW.value run_err, _rc = await DB_SESSION_MNG.execute_lambda_claim( diff --git a/solution/backend/services/weather_notes.py b/solution/backend/services/weather_notes.py index 47cec5f..4300b0e 100644 --- a/solution/backend/services/weather_notes.py +++ b/solution/backend/services/weather_notes.py @@ -1,4 +1,4 @@ -"""날씨 안내 계약. 관측값과 분리하고, 확인되지 않은 시설·영업시간은 쓰지 않는다.""" +"""날씨 안내 계약.""" import json from functools import lru_cache from pathlib import Path diff --git a/solution/backend/tests/test_agent_runtime.py b/solution/backend/tests/test_agent_runtime.py index 2ef663f..400fded 100644 --- a/solution/backend/tests/test_agent_runtime.py +++ b/solution/backend/tests/test_agent_runtime.py @@ -1,10 +1,4 @@ -"""사장님 에이전트 런타임. - -여기서 지키는 것 셋 — 나머지 검사는 전부 이 셋을 지탱한다. - 1. 도구는 서비스 계층을 통과한다(게이트가 살아 있다) - 2. 등급은 레지스트리가 정한다 — 모델이 확인 절차를 건너뛸 수 없다 - 3. 모호하면 실행하지 않고 되묻는다 -""" +"""사장님 에이전트 런타임.""" import uuid from types import SimpleNamespace @@ -50,9 +44,7 @@ async def user_of(client, headers, place_id): # ── 1. 게이트가 살아 있다 ──────────────────────────────────────────────── def test_모든_도구는_서비스_계층을_통과한다(): - """★ 도구가 crud 를 직접 부르면 스키마 검증·출처·정정본 보호가 조용히 사라진다. - - 소스에 `_crud.` 직접 호출이 없는지 본다 — 주석이 아니라 코드로 못 박는 자리다.""" + """도구가 crud 를 직접 부르면 스키마 검증·출처·정정본 보호가 조용히 사라진다.""" import inspect source = inspect.getsource(tools) @@ -71,7 +63,7 @@ def test_없는_항목은_스키마가_막는다(db_engine): # ── 2. 등급은 레지스트리가 정한다 ──────────────────────────────────────── def test_등급은_프롬프트에_실리지_않는다(): - """모델이 등급을 알면 그 값을 골라 보려 한다. 알 필요도, 정할 이유도 없다.""" + """모델이 등급을 알면 그 값을 골라 보려 한다.""" described = tools.describe() assert described for row in described: @@ -90,7 +82,6 @@ async def test_발행은_묻기_전에_실행되지_않는다(client, auth_heade body = res.json() assert body["needs_confirm"] is True assert body["tool"] == "publish" - # ★ 실행되지 않았다. 확인 문구만 돌아왔다. started.assert_not_awaited() @@ -140,7 +131,7 @@ async def test_없는_항목을_고르면_거절하고_이유를_말한다(clien # ── 소유자 범위 ────────────────────────────────────────────────────────── async def test_남의_가게는_없는_것과_똑같이_답한다(client, auth_headers, choose, db_engine): - """★ 대화창이 소유자 스코프를 우회하는 유일한 입구가 되면 안 된다.""" + """대화창이 소유자 스코프를 우회하는 유일한 입구가 되면 안 된다.""" _mine, pid = await seed(client, auth_headers, "내가게") other = await auth_headers("agent-outsider") choose({"tool": "list_facts", "args": {}, "message": ""}) @@ -157,7 +148,6 @@ async def test_로그인_없이는_열리지_않는다(client): # ── 실행 결과 문구 ─────────────────────────────────────────────────────── async def test_값을_바꾸면_재발행이_필요하다고_말한다(client, auth_headers, choose, db_engine): - """★ 이 한 줄이 빠지면 사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 본다.""" h, pid = await seed(client, auth_headers) choose({"tool": "set_fact", "args": {"key": "check_in_time", "value": "15:00"}, "message": ""}) body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "체크인 3시로"})).json() @@ -176,7 +166,6 @@ async def test_값을_바꾸면_재발행이_필요하다고_말한다(client, a async def test_결과_문구는_모델이_쓰지_않는다(client, auth_headers, choose, db_engine): - """모델이 결과를 쓰면 하지 않은 일을 했다고 말할 수 있다.""" h, pid = await seed(client, auth_headers) choose({ "tool": "set_fact", @@ -228,11 +217,10 @@ def test_읽기_도구는_확인을_요구하지_않는다(): # ── 보류 스위치 ───────────────────────────────────────────────────────── def test_스위치와_키를_둘_다_본다(monkeypatch): - """★ 키만 보면 '잠시 닫아 두기' 를 키를 지워서 해야 하고, 그러면 소개문·사진분류까지 - 같이 꺼진다. 스위치만 보면 키 없는 환경에서 **눌러도 안 되는 입구**가 생긴다.""" + """키만 보면 '잠시 닫아 두기' 를 키를 지워서 해야 하고, 그러면 소개문·사진분류까지 같이 꺼진다.""" monkeypatch.setattr(runtime.provider, "active", lambda: SimpleNamespace(is_configured=lambda: True)) monkeypatch.delenv("AGENT_CHAT_ENABLED", raising=False) - assert runtime.is_configured() is True # 기본은 켜짐(2026-09-22) + assert runtime.is_configured() is True # 기본은 켜짐 monkeypatch.setenv("AGENT_CHAT_ENABLED", "0") assert runtime.is_configured() is False # 스위치로 닫을 수 있다 diff --git a/solution/backend/tests/test_alert_service.py b/solution/backend/tests/test_alert_service.py index 9944f08..0851a5f 100644 --- a/solution/backend/tests/test_alert_service.py +++ b/solution/backend/tests/test_alert_service.py @@ -1,10 +1,4 @@ -"""알림 발송함 — 적재(dedupe) · 발송(재시도/소진) · 복구 · 비밀 마스킹. - -★ 이 파일이 절대 하면 안 되는 것 확인: - - 재시도마다 중복 스팸을 내는 것 (dedupe) - - webhook URL·비밀번호·이메일 원문을 detail 에 그대로 남기는 것 (scrub) - - TEAMS_WEBHOOK_URL 이 비었을 때 예외를 던지는 것 (미설정 시 정상 동작) -""" +"""알림 발송함 — 적재(dedupe) · 발송(재시도/소진) · 복구 · 비밀 마스킹.""" import json import httpx @@ -45,13 +39,12 @@ async def test_send_alert_creates_pending_row(db_engine): async def test_send_alert_dedupes_within_window(db_engine): - """검증: 같은 dedupe_key 로 두 번 연속 보낸다. - 기대결과: ★ 행이 하나만 생긴다 — 재시도마다 중복 스팸을 내면 안 된다.""" + """검증: 같은 dedupe_key 로 두 번 연속 보낸다.""" await alert_service.send_alert("build_failed", "발행 실패", "1차", dedupe_key="k2") await alert_service.send_alert("build_failed", "발행 실패", "2차", dedupe_key="k2") rows = await _rows(db_engine, "build_failed") assert len(rows) == 1 - assert rows[0]["detail"] == "1차" # 처음 것만 남는다(두 번째는 만들지 않았다) + assert rows[0]["detail"] == "1차" async def test_send_alert_without_dedupe_key_always_creates(db_engine): @@ -76,16 +69,14 @@ async def test_resolve_alert_marks_resolved_and_sends_recovery_notice(db_engine) async def test_resolve_alert_is_noop_when_nothing_unresolved(db_engine): - """검증: 알린 적 없는 dedupe_key 를 resolve. - 기대결과: ★ 아무 행도 안 생긴다 — 정상 상태마다 "복구됨" 을 보내면 그게 새 스팸이다.""" + """검증: 알린 적 없는 dedupe_key 를 resolve.""" await alert_service.resolve_alert("never-alerted", "정상") rows = await _rows(db_engine, "recovery") assert rows == [] async def test_send_alert_after_resolve_creates_new_row(db_engine): - """검증: 한 번 풀린(resolved) dedupe_key 로 다시 보낸다. - 기대결과: 새 문제로 보고 새 행을 만든다 — 옛 resolved 행과 헷갈리지 않는다.""" + """검증: 한 번 풀린(resolved) dedupe_key 로 다시 보낸다.""" await alert_service.send_alert("build_failed", "실패1", "x", dedupe_key="k4") await alert_service.resolve_alert("k4", "복구1") await alert_service.send_alert("build_failed", "실패2", "y", dedupe_key="k4") @@ -146,29 +137,25 @@ async def test_process_outbox_sends_and_marks_sent(db_engine, monkeypatch): async def test_process_outbox_noop_when_webhook_unconfigured(db_engine, monkeypatch): - """검증: TEAMS_WEBHOOK_URL 이 비어 있을 때 스윕을 돌린다. - 기대결과: ★ 예외 없이 끝난다 — HTTP 호출 자체를 안 한다(teams_webhook.is_configured).""" + """검증: TEAMS_WEBHOOK_URL 이 비어 있을 때 스윕을 돌린다.""" monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False) await alert_service.send_alert("job_dead", "잡 실패", "사유", dedupe_key="k6") result = await alert_service.process_outbox() assert result["sent"] == 0 - # 실패로 잡혀 재시도 카운트가 올라간다(다음 스윕에서 다시 시도) — 예외로 죽지 않았다. rows = await _rows(db_engine, "job_dead") assert rows[0]["status"] == 1 # 여전히 PENDING(백오프 대기) assert rows[0]["attempts"] == 1 async def test_process_outbox_exhausts_after_max_attempts(db_engine, monkeypatch): - """검증: 계속 실패하는 webhook 으로 MAX_ATTEMPTS 만큼 스윕한다. - 기대결과: ★ 상한에 닿으면 FAILED 로 남고 더는 재시도 대상이 아니다.""" + """검증: 계속 실패하는 webhook 으로 MAX_ATTEMPTS 만큼 스윕한다.""" monkeypatch.setenv("TEAMS_WEBHOOK_URL", "https://example.test/webhook") _mock_webhook(monkeypatch, lambda req: httpx.Response(500)) await alert_service.send_alert("job_dead", "잡 실패", "사유", dedupe_key="k7") for _ in range(alert_service.MAX_ATTEMPTS): - # next_attempt_at 이 미래로 밀려도 여기선 process_outbox 가 직접 대상을 스윕하므로 - # 시간 경과를 흉내 낼 필요 없이 next_attempt_at 을 매번 과거로 되돌린다. + # next_attempt_at 이 미래로 밀려도 여기선 process_outbox 가 직접 대상을 스윕하므로 시간 경과를 흉내 낼 필요 없이 next_attempt_at 을 매번 과거로 되돌린다. async with db_engine.begin() as c: await c.execute(text("UPDATE alert_outbox SET next_attempt_at = now() - interval '1 second'")) await alert_service.process_outbox() diff --git a/solution/backend/tests/test_auth.py b/solution/backend/tests/test_auth.py index 13d69d0..4827097 100644 --- a/solution/backend/tests/test_auth.py +++ b/solution/backend/tests/test_auth.py @@ -1,12 +1,8 @@ -"""auth 도메인 e2e — 로그인 / 내정보 / 인증거부 흐름. - -유저 시드/로그인은 auth_headers 픽스처. -""" +"""auth 도메인 e2e — 로그인 / 내정보 / 인증거부 흐름.""" async def test_login_and_me_flow(auth_headers, client): - """검증: 시드된 유저가 로그인해 받은 토큰으로 /me 호출. - 기대결과: 200, 본인 id·name 이 그대로 반환.""" + """검증: 시드된 유저가 로그인해 받은 토큰으로 /me 호출.""" h = await auth_headers("user1", name="홍길동") r = await client.get("/v1/auth/me", headers=h) @@ -14,13 +10,12 @@ async def test_login_and_me_flow(auth_headers, client): me = r.json() assert me["id"] == "user1" assert me["name"] == "홍길동" - # ★ 소속사 필드는 없다. 회사(테넌트)를 걷어냈다(2026-09-08) — 쓰는 사람은 사장님 혼자다. + # 소속사 필드는 없다. assert "company" not in me async def test_login_with_wrong_password(auth_headers, client): - """검증: 존재하는 계정에 '틀린 비밀번호'로 로그인. - 기대결과: 로그인 실패 — success=False, code=1100(ACCOUNT_INVALID_INFO), 토큰 빈 문자열.""" + """검증: 존재하는 계정에 '틀린 비밀번호'로 로그인.""" await auth_headers("user2") # pw1234 로 시드 r = await client.post("/v1/auth/login", json={"id": "user2", "password": "wrong"}) @@ -31,15 +26,13 @@ async def test_login_with_wrong_password(auth_headers, client): async def test_login_nonexistent_account(client): - """검증: 존재하지 않는 계정으로 로그인. - 기대결과: 실패 — success=False (계정 유무를 '틀린 비번'과 구분해 흘리지 않음).""" + """검증: 존재하지 않는 계정으로 로그인.""" r = await client.post("/v1/auth/login", json={"id": "ghost", "password": "whatever"}) assert r.json()["result"]["success"] is False async def test_me_without_token_is_rejected(client): - """검증: 토큰 없이 보호 엔드포인트 /me 호출. - 기대결과: 인증 단계에서 거부 — HTTP 401 또는 403.""" + """검증: 토큰 없이 보호 엔드포인트 /me 호출.""" r = await client.get("/v1/auth/me") assert r.status_code in (401, 403) @@ -49,8 +42,7 @@ _SIGNUP = {"id": "sajang1", "password": "pw12345678", "name": "김사장", "emai async def test_signup_creates_account_and_logs_in(client, db_engine): - """검증: 가입 → 받은 토큰으로 곧바로 /me. - 기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원을 돌려준다.""" + """검증: 가입 → 받은 토큰으로 곧바로 /me.""" r = await client.post("/v1/auth/signup", json=_SIGNUP) body = r.json() assert body["result"]["success"] is True @@ -63,24 +55,21 @@ async def test_signup_creates_account_and_logs_in(client, db_engine): async def test_signup_rejects_duplicate_id(client, db_engine): - """검증: 같은 아이디로 두 번 가입. - 기대결과: 두 번째는 1101(ACCOUNT_ALREADY_EXIST) — 이메일만 달라도 막힌다.""" + """검증: 같은 아이디로 두 번 가입.""" await client.post("/v1/auth/signup", json=_SIGNUP) r = await client.post("/v1/auth/signup", json={**_SIGNUP, "email": "other@example.com"}) assert r.json()["result"]["code"] == 1101 async def test_signup_rejects_duplicate_email(client, db_engine): - """검증: 아이디는 다른데 이메일이 같은 가입. - 기대결과: 1101 — 한 사람에게 계정이 둘 생기는 걸 이메일에서 끊는다.""" + """검증: 아이디는 다른데 이메일이 같은 가입.""" await client.post("/v1/auth/signup", json=_SIGNUP) r = await client.post("/v1/auth/signup", json={**_SIGNUP, "id": "sajang2"}) assert r.json()["result"]["code"] == 1101 async def test_signup_rejects_weak_input(client, db_engine): - """검증: 짧은 비밀번호 / 규칙에 안 맞는 아이디 / 형식이 아닌 이메일. - 기대결과: 전부 101(INVALID_REQUEST_DATA) — 서버가 마지막 방어선이다(화면 검사만 믿지 않는다).""" + """검증: 짧은 비밀번호 / 규칙에 안 맞는 아이디 / 형식이 아닌 이메일.""" for bad in ( {**_SIGNUP, "password": "short"}, {**_SIGNUP, "id": "1abc"}, # 영문으로 시작해야 한다 @@ -94,7 +83,7 @@ async def test_signup_rejects_weak_input(client, db_engine): # ── 구글 로그인 ────────────────────────────────────────────────────────────── def _stub_google(monkeypatch, *, sub="1234567890", email="g@example.com", name="구글유저"): - """ID 토큰 검증을 대역으로 바꾼다. 서명 검증 자체는 test_google_identity.py 가 본다.""" + """ID 토큰 검증을 대역으로 바꾼다.""" from services.external.google_identity import GoogleAccount async def _verify(_credential): @@ -104,8 +93,7 @@ def _stub_google(monkeypatch, *, sub="1234567890", email="g@example.com", name=" async def test_google_login_creates_then_reuses_account(client, db_engine, monkeypatch): - """검증: 같은 구글 계정으로 두 번 로그인. - 기대결과: 첫 번째에 계정이 생기고, 두 번째는 **같은 user_id** 로 붙는다(계정이 늘지 않는다).""" + """검증: 같은 구글 계정으로 두 번 로그인.""" _stub_google(monkeypatch) first = (await client.post("/v1/auth/google", json={"credential": "x"})).json() @@ -120,8 +108,7 @@ async def test_google_login_creates_then_reuses_account(client, db_engine, monke async def test_google_login_follows_sub_not_email(client, db_engine, monkeypatch): - """검증: 같은 sub 인데 구글 쪽 이메일이 바뀐 경우. - 기대결과: 같은 계정으로 들어온다 — 매칭 키가 이메일이 아니라 sub 라서.""" + """검증: 같은 sub 인데 구글 쪽 이메일이 바뀐 경우.""" _stub_google(monkeypatch, email="before@example.com") first = (await client.post("/v1/auth/google", json={"credential": "x"})).json() me1 = (await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {first['access_token']}"})).json() @@ -133,8 +120,7 @@ async def test_google_login_follows_sub_not_email(client, db_engine, monkeypatch async def test_google_login_refuses_to_link_existing_local_account(client, db_engine, monkeypatch): - """검증: id/pw 로 이미 가입된 이메일로 구글 로그인. - 기대결과: 1105(ACCOUNT_PROVIDER_CONFLICT) — 소유 증명 없이 잇지 않는다(계정 선점 방지).""" + """검증: id/pw 로 이미 가입된 이메일로 구글 로그인.""" await client.post("/v1/auth/signup", json=_SIGNUP) _stub_google(monkeypatch, email=_SIGNUP["email"]) @@ -143,8 +129,7 @@ async def test_google_login_refuses_to_link_existing_local_account(client, db_en async def test_password_login_against_google_account_is_refused(client, db_engine, monkeypatch): - """검증: 구글로 만들어진 계정에 id/pw 로그인 시도. - 기대결과: 1105 — 500 이 아니다(대조할 비밀번호가 없는 계정이라 해시 검증에 들어가면 터진다).""" + """검증: 구글로 만들어진 계정에 id/pw 로그인 시도.""" _stub_google(monkeypatch) await client.post("/v1/auth/google", json={"credential": "x"}) @@ -153,16 +138,14 @@ async def test_password_login_against_google_account_is_refused(client, db_engin async def test_google_login_is_off_when_client_id_is_empty(client, db_engine): - """검증: GOOGLE_CLIENT_ID 가 비어 있을 때(테스트 기본값) 구글 로그인 호출. - 기대결과: 1106(OAUTH_NOT_CONFIGURED) — 네트워크를 타지 않고 즉시 끊긴다.""" + """검증: GOOGLE_CLIENT_ID 가 비어 있을 때(테스트 기본값) 구글 로그인 호출.""" r = await client.post("/v1/auth/google", json={"credential": "anything"}) assert r.json()["result"]["code"] == 1106 # ── refresh 토큰 무효화(token_version) ──────────────────────────────────────── async def test_refresh_token_reissues_access_token(auth_headers, client): - """검증: 정상적인 refresh 토큰으로 재발급. - 기대결과: 200, 새 access 토큰이 실려 온다.""" + """검증: 정상적인 refresh 토큰으로 재발급.""" h = await auth_headers("refuser1") login = (await client.post("/v1/auth/login", json={"id": "refuser1", "password": "pw1234"})).json() refresh_token = login["refresh_token"] @@ -178,9 +161,7 @@ async def test_refresh_token_reissues_access_token(auth_headers, client): async def test_refresh_token_is_revoked_after_password_change(auth_headers, client): - """검증: refresh 토큰을 받은 **뒤에** 비밀번호를 바꾼다. - 기대결과: ★ ACCOUNT_SESSION_REVOKED — 예전 refresh 토큰으로는 더 이상 access 토큰을 못 찍는다. - (비밀번호를 훔쳐 넣어 둔 refresh 토큰이 있어도 비번을 바꾸면 끊긴다는 것이 이 테스트의 요점이다.)""" + """검증: refresh 토큰을 받은 **뒤에** 비밀번호를 바꾼다.""" h = await auth_headers("refuser2") login = (await client.post("/v1/auth/login", json={"id": "refuser2", "password": "pw1234"})).json() old_refresh_token = login["refresh_token"] @@ -194,15 +175,14 @@ async def test_refresh_token_is_revoked_after_password_change(auth_headers, clie assert body["result"]["code"] == 1108, body # ACCOUNT_SESSION_REVOKED assert body.get("access_token", "") == "" - # ★ 새로 로그인하면(새 비밀번호로) 새 refresh 토큰은 당연히 먹힌다. + # 새로 로그인하면(새 비밀번호로) 새 refresh 토큰은 당연히 먹힌다. relogin = (await client.post("/v1/auth/login", json={"id": "refuser2", "password": "newpassword123"})).json() r2 = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {relogin['refresh_token']}"}) assert r2.json()["result"]["success"] is True async def test_refresh_token_is_revoked_when_account_blocked(auth_headers, client, db_engine): - """검증: refresh 토큰을 받은 뒤 계정이 차단(UserStatus.INACTIVE)된다. - 기대결과: ★ ACCOUNT_BLOCKED_USER — 로그인만 막는 게 아니라 이미 나간 refresh 토큰도 막는다.""" + """검증: refresh 토큰을 받은 뒤 계정이 차단(UserStatus.INACTIVE)된다.""" from sqlalchemy import text h = await auth_headers("refuser3") diff --git a/solution/backend/tests/test_azure_static.py b/solution/backend/tests/test_azure_static.py index b19b361..c6042d5 100644 --- a/solution/backend/tests/test_azure_static.py +++ b/solution/backend/tests/test_azure_static.py @@ -30,14 +30,14 @@ def test_ai_for_web_아래에_사이트와_공용산출물을_업로드(tmp_path (tmp_path / "assets" / "index-abc.js").write_text("console.log(1)") (tmp_path / "fonts").mkdir() (tmp_path / "fonts" / "Pretendard.woff2").write_bytes(b"font") - # ★ 오리진 루트 파일 — 크롤러가 robots.txt 를 읽는 유일한 자리다(RFC 9309). + # 오리진 루트 파일 — 크롤러가 robots.txt 를 읽는 유일한 자리다(RFC 9309). (tmp_path / "robots.txt").write_text("User-agent: *\nAllow: /\n") (tmp_path / "sitemap.xml").write_text("<sitemapindex/>") site_dir = tmp_path / "s" / "butter" (site_dir / "about").mkdir(parents=True) (site_dir / "index.html").write_text("<h1>Butter</h1>") (site_dir / "about" / "index.html").write_text("<h1>About</h1>") - # 다른 사업장의 발행본. 이번 발행 대상이 아니므로 올라가면 안 된다. + # 다른 사업장의 발행본. (tmp_path / "s" / "joy").mkdir(parents=True) (tmp_path / "s" / "joy" / "index.html").write_text("<h1>Joy</h1>") @@ -77,8 +77,7 @@ def test_ai_for_web_아래에_사이트와_공용산출물을_업로드(tmp_path def test_루트_robots_와_사이트맵은_짧게_캐시된다(): - # 해시가 박힌 번들만 영구 캐시다. robots·사이트맵이 길게 캐시되면 - # 새 사업장이 사이트맵 인덱스에 들어가도 크롤러가 옛 파일을 계속 본다. + # 해시가 박힌 번들만 영구 캐시다. assert "immutable" in azure_static._cache_control(Path("assets/index-abc.js")) assert azure_static._cache_control(Path("robots.txt")) == "public, max-age=60, must-revalidate" assert azure_static._cache_control(Path("sitemap.xml")) == "public, max-age=60, must-revalidate" diff --git a/solution/backend/tests/test_blog_owner.py b/solution/backend/tests/test_blog_owner.py index e8d7eef..8551dba 100644 --- a/solution/backend/tests/test_blog_owner.py +++ b/solution/backend/tests/test_blog_owner.py @@ -1,13 +1,4 @@ -"""미니 블로그 — 빌더 앱 로그인 화면(이번 달 생성된 글). 기획: docs/MINI_BLOG.md - -★ 이 파일이 지키는 것: - - 로그인한 사장님은 자기 사업장의 글만 본다(남의 가게 글이 섞이면 안 된다) - - 아직 메일이 안 나간 REVIEWED 글도 로그인 화면에서 바로 고칠 수 있다 — 단, 저장만 - 한다. 승인은 이메일 승인 링크 또는 로그인 "바로 발행" 버튼, 두 경로 중 하나를 - 명시적으로 눌러야 한다(2026-09-21, 사장님 지시: "이메일 승인으로도 발행 가능하고 - 바로발행버튼으로도 발행 가능하도록") - - 여기서도 금칙 게이트는 그대로 탄다 — 로그인했다고 우회되지 않는다 -""" +"""미니 블로그 — 빌더 앱 로그인 화면(이번 달 생성된 글).""" import uuid from datetime import date, timedelta, timezone @@ -72,8 +63,7 @@ async def test_owner_cannot_see_someone_elses_posts(client, db_engine, auth_head async def test_owner_edit_saves_body_without_approving(client, db_engine, auth_headers): - """로그인 화면에서 바로 고칠 수는 있지만, 저장만 한다 — 승인은 이메일 링크로만 일어난다 - (2026-09-21, 사장님 지시: "승인되야 올라가도록 해야 한다").""" + """로그인 화면에서 바로 고칠 수는 있지만, 저장만 한다 — 승인은 이메일 링크로만 일어난다.""" h = await auth_headers("blogowner4") place_id = await _place(client, h) post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED) @@ -136,8 +126,7 @@ async def test_owner_cannot_delete_someone_elses_post(client, db_engine, auth_he async def test_deleting_a_post_frees_its_date_for_regeneration(client, db_engine, auth_headers, monkeypatch): - """삭제는 소프트 삭제라 (place_id, scheduled_date) 유니크가 풀린다 — 지운 날짜에 - 바로 다시 생성할 수 있어야 한다.""" + """삭제는 소프트 삭제라 (place_id, scheduled_date) 유니크가 풀린다 — 지운 날짜에 바로 다시 생성할 수 있어야 한다.""" from services import blog_service async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): @@ -197,8 +186,7 @@ async def test_deleting_a_draft_post_does_not_enqueue_rebuild(client, db_engine, async def test_generate_now_creates_posts_for_published_site(client, db_engine, auth_headers, monkeypatch): - """새벽 크론(04:10)을 기다리지 않고, 사장님이 고른 구간을 그 자리에서 채운다(2026-09-17, - 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까") — 발행된 사이트일 때만.""" + """새벽 크론(04:10)을 기다리지 않고, 사장님이 고른 구간을 그 자리에서 채운다 — 발행된 사이트일 때만.""" from services import blog_service async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): @@ -242,8 +230,7 @@ async def test_generate_now_creates_posts_for_published_site(client, db_engine, async def test_generate_now_writes_each_post_for_its_own_date(client, db_engine, auth_headers, monkeypatch): - """글마다 배정된 날짜를 게시일로 받아 쓰고, 그 날짜의 절기 소재가 붙는다(2026-09-23, - 사장님 지시: "날짜에 맞는 글이 생성 되도록").""" + """글마다 배정된 날짜를 게시일로 받아 쓰고, 그 날짜의 절기 소재가 붙는다.""" from services import blog_service calls = [] @@ -282,8 +269,7 @@ async def test_generate_now_writes_each_post_for_its_own_date(client, db_engine, async def test_generate_now_does_not_append_a_publish_link(client, db_engine, auth_headers, monkeypatch): - """미니 블로그에 올라가는 글에는 링크를 붙이지 않는다(2026-09-21, 사장님 지시). - site_payload.publish_url() 자체는 남겨둔다 — 나중에 쓰레드 연동에서 따로 쓸 수 있게.""" + """미니 블로그에 올라가는 글에는 링크를 붙이지 않는다.""" from services import blog_service async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): @@ -342,9 +328,7 @@ async def test_send_reviewed_only_mails_posts_due_today(client, db_engine, auth_ async def test_send_reviewed_prefers_place_notify_email_over_account_email(client, db_engine, auth_headers, monkeypatch): - """places.notify_email 이 있으면 계정 로그인 이메일(users.email) 대신 그 주소로 보낸다 - (2026-09-21, 사장님 요청 — 사장님 한 명이 사이트를 여러 개 가질 수 있어 업장별로 - 다른 담당자에게 보낼 수 있어야 한다).""" + """places.notify_email 이 있으면 계정 로그인 이메일(users.email) 대신 그 주소로 보낸다.""" from services import blog_jobs, mail_service monkeypatch.setattr(mail_service, "is_configured", lambda: True) @@ -380,8 +364,7 @@ async def test_update_place_rejects_malformed_notify_email(client, db_engine, au async def test_send_now_mails_todays_due_post_immediately(client, db_engine, auth_headers, monkeypatch): - """빌더 화면의 '승인 알림보내기' — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 - 바로 보낸다(2026-09-21, 사장님 요청).""" + """빌더 화면의 '승인 알림보내기' — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 바로 보낸다.""" from services import blog_jobs, mail_service monkeypatch.setattr(mail_service, "is_configured", lambda: True) @@ -460,8 +443,7 @@ async def test_send_now_cannot_be_triggered_for_someone_elses_place(client, db_e async def _day_pass_headers(db_engine, login_id: str) -> dict: - """메일의 '수정하려면' 링크가 주는 것과 같은 종류의 day-pass 토큰(2026-09-21, - 사장님 지시: "메일 링크에서 들어와 수정한 후에는 승인할 수 있어야 한다").""" + """메일의 '수정하려면' 링크가 주는 것과 같은 종류의 day-pass 토큰.""" from common.models.gmodel import UserInfo from router.v1.validator.dependencies import CreateDayPassToken @@ -475,8 +457,7 @@ async def _day_pass_headers(db_engine, login_id: str) -> dict: async def test_approve_by_owner_shares_to_threads_when_linked(client, db_engine, auth_headers, monkeypatch): - """'바로 발행' 버튼으로 승인하면, 쓰레드가 연동돼 있을 때 같은 문구가 쓰레드로도 나간다 - (2026-09-21, 사장님 지시: "쓰레드에 연동되어 있으면 같이 업로드 되는 기능").""" + """'바로 발행' 버튼으로 승인하면, 쓰레드가 연동돼 있을 때 같은 문구가 쓰레드로도 나간다.""" from services import post_service calls = [] @@ -550,9 +531,7 @@ async def test_threads_share_failure_does_not_block_approval(client, db_engine, async def test_owner_can_publish_as_is_without_editing(client, db_engine, auth_headers): - """'바로 발행' — 로그인 세션만으로, 본문을 안 고쳐도 승인되고 재발행 잡이 걸린다 - (2026-09-21, 사장님 지시: "이메일 승인으로도 발행 가능하고 바로발행버튼으로도 - 발행 가능하도록") — 이메일 승인 링크와 별개의 두 번째 경로다.""" + """'바로 발행' — 로그인 세션만으로, 본문을 안 고쳐도 승인되고 재발행 잡이 걸린다 — 이메일 승인 링크와 별개의 두 번째 경로다.""" h = await auth_headers("blogowner6") place_id = await _place(client, h) post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED) @@ -581,8 +560,7 @@ async def test_owner_cannot_publish_someone_elses_post(client, db_engine, auth_h async def test_daypass_session_can_approve_after_editing(client, db_engine, auth_headers): - """메일 '수정하려면' 링크(day-pass)로 들어와 고친 뒤에는, 다시 메일을 뒤지지 않고 - 그 자리에서 승인할 수 있어야 한다(2026-09-21, 사장님 지시).""" + """메일 '수정하려면' 링크(day-pass)로 들어와 고친 뒤에는, 다시 메일을 뒤지지 않고 그 자리에서 승인할 수 있어야 한다.""" h = await auth_headers("blogowner6b") place_id = await _place(client, h) post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED) @@ -634,9 +612,7 @@ async def test_generate_now_is_noop_for_unpublished_site(client, db_engine, auth async def test_generate_now_is_noop_for_site_without_domain(client, db_engine, auth_headers, monkeypatch): - """domain 미확정(임시 주소) 사이트도 생성 스윕 대상이 아니다 — 쓰레드 연동 요구사항과 - 맞춘다(2026-09-21). PUBLISHED 여도 domain 이 없으면 0건이어야 한다. generate_one 을 - 실제로 성공하도록 목킹해 둬야 "그냥 LLM 이 설정 안 돼서 0건"과 구분된다.""" + """domain 미확정(임시 주소) 사이트도 생성 스윕 대상이 아니다 — 쓰레드 연동 요구사항과 맞춘다.""" from services import blog_service async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): @@ -678,8 +654,7 @@ async def test_generate_now_rejects_end_before_start(client, db_engine, auth_hea async def test_approved_post_flags_build_failed_when_job_dead(client, db_engine, auth_headers): - """화면은 발행완료/발행실패만 본다(사장님 지시) — 승인됐는데 BUILD 잡이 dead-letter 면 - build_failed=true, 그 외(대기 중인 잡·아직 승인 전)에는 false 로 남는다.""" + """화면은 발행완료/발행실패만 본다(사장님 지시) — 승인됐는데 BUILD 잡이 dead-letter 면 build_failed=true, 그 외(대기 중인 잡·아직 승인 전)에는 false 로 남는다.""" h = await auth_headers("bloggen4") place_id = await _place(client, h, name="실패펜션") failed_post = await _seed_post(db_engine, place_id, status=PostStatus.APPROVED) @@ -714,7 +689,7 @@ async def test_pending_build_job_does_not_flag_failure(client, db_engine, auth_h async def test_upcoming_only_returns_next_week_in_date_order(client, db_engine, auth_headers): - """상단 카로셀 — 오늘부터 N일치만, 날짜 오름차순. 그 뒤 배정분은 안 보인다.""" + """상단 카로셀 — 오늘부터 N일치만, 날짜 오름차순.""" h = await auth_headers("bloggen6") place_id = await _place(client, h, name="주간펜션") far = await _seed_post(db_engine, place_id, scheduled=date.today() + timedelta(days=20)) @@ -756,8 +731,7 @@ async def test_get_post_by_id_scoped_to_owner(client, db_engine, auth_headers): async def test_generation_history_counts_by_batch(client, db_engine, auth_headers, monkeypatch): - """생성 이력 — 한 번에 몇 건 · 어느 모델(사장님 지시: "생성이력도 있어야해 몇개 - 생성했는지" / "어느 모델썼는지 등등" → JSONB 한 칸(generation_meta)에 담는다).""" + """생성 이력 — 한 번에 몇 건 · 어느 모델(사장님 지시: "생성이력도 있어야해 몇개 생성했는지" / "어느 모델썼는지 등등" → JSONB 한 칸(generation_meta)에 담는다).""" from services import blog_service async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): @@ -789,8 +763,7 @@ async def test_generation_history_counts_by_batch(client, db_engine, auth_header async def test_mail_has_one_click_approve_and_autologin_edit_links(client, db_engine, auth_headers, monkeypatch): - """사장님 지시: "승인이랑 수정하기 있어야해" — 승인은 토큰 링크 하나, 수정은 - 그날짜리 자동 로그인 토큰을 실은 빌더 앱 링크.""" + """사장님 지시: "승인이랑 수정하기 있어야해" — 승인은 토큰 링크 하나, 수정은 그날짜리 자동 로그인 토큰을 실은 빌더 앱 링크.""" from services import blog_jobs, mail_service monkeypatch.setattr(mail_service, "is_configured", lambda: True) @@ -822,9 +795,7 @@ async def test_mail_has_one_click_approve_and_autologin_edit_links(client, db_en async def test_mail_links_use_the_builder_app_origin_not_the_published_site_origin( client, db_engine, auth_headers, monkeypatch, ): - """수정·승인 링크는 빌더 앱(SOCIAL_APP_ORIGIN)으로 가야 한다 — 발행된 사이트 오리진 - (site_payload.publish_origin, 로컬에선 solution-site 정적 서버 포트 80)으로 가면 - 404가 난다(2026-09-21 실측: 메일의 '수정하려면' 링크가 거기로 가서 404).""" + """수정·승인 링크는 빌더 앱(SOCIAL_APP_ORIGIN)으로 가야 한다 — 발행된 사이트 오리진 (site_payload.publish_origin, 로컬에선 solution-site 정적 서버 포트 80)으로 가면 404가 난다.""" from services import blog_jobs, mail_service, site_payload monkeypatch.setattr(mail_service, "is_configured", lambda: True) diff --git a/solution/backend/tests/test_blog_post.py b/solution/backend/tests/test_blog_post.py index 1bc97ab..a7f0fd6 100644 --- a/solution/backend/tests/test_blog_post.py +++ b/solution/backend/tests/test_blog_post.py @@ -1,14 +1,4 @@ -"""미니 블로그 — 문구 필터와 메일 승인. - -★ 이 파일이 지키는 것: - - 확인되지 않은 주장(요금·시간·인원·전화)이 든 문구는 화면에 못 간다 - - 같은 링크를 두 번 눌러도 두 번 올라가지 않는다 - - 만료된 링크는 오류가 아니라 안내로 끝난다 - -★ 2026-09-17 이후 GET 이 곧 승인이다(사장님 지시: "승인은 바로 승인 되게 그 링크만 - 클릭하면"). 메일 클라이언트의 링크 미리 열기에 그대로 노출된다는 걸 알고도 택한 것 — - 예전처럼 "GET 은 확인 화면만" 을 기대하는 테스트를 두지 않는다. -""" +"""미니 블로그 — 문구 필터와 메일 승인.""" import uuid from datetime import date, timezone @@ -75,10 +65,10 @@ def _festival_row(name, start, end): def test_materials_follow_the_post_date(): - """게시일에 맞는 소재만 나온다(2026-09-23) — 9월 글에 '한겨울'·'눈'·끝난 축제가 붙지 않는다.""" + """게시일에 맞는 소재만 나온다 — 9월 글에 '한겨울'·'눈'·끝난 축제가 붙지 않는다.""" snapshot = _snapshot_with( _festival_row("시간여행축제", "20261002", "20261004"), # 곧 열린다(9일 뒤) - _festival_row("벚꽃축제", "20260401", "20260405"), # 이미 끝났다 + _festival_row("벚꽃축제", "20260401", "20260405"), _festival_row("겨울빛축제", "20261220", "20261231"), # 아직 한참 멀다 {"content_type": LocalContentType.ATTRACTION.value, "title": "경암동 철길마을", "body": {"name": "경암동 철길마을", "description": "철길 옆 골목"}}, @@ -114,10 +104,7 @@ def test_build_prompt_tells_the_post_date(): async def test_generate_one_forwards_place_category_into_the_prompt(monkeypatch): - """generate_one 은 place_category 를 build_prompt 로 그대로 넘긴다 — 링크는 여기서 - 붙이지 않는다(호출부의 길이 게이트가 이 반환값에 그대로 걸리기 때문에, 붙이면 - 게이트가 링크까지 세어 정상 문구도 버려진다). 공급자는 provider.active() 를 통해 - 고르므로(Gemini 로 고정하지 않는다) 가짜 공급자로 검증한다.""" + """generate_one 은 place_category 를 build_prompt 로 그대로 넘긴다 — 링크는 여기서 붙이지 않는다(호출부의 길이 게이트가 이 반환값에 그대로 걸리기 때문에, 붙이면 게이트가 링크까지 세어 정상 문구도 버려진다).""" from services.llm import provider from services.llm.types import LlmResult, Usage @@ -152,12 +139,7 @@ async def _seed_post(db_engine, owner_id, *, status=PostStatus.SENT, expires_day if expires_days < 0: from common.utils.gtime import GTime expires = GTime.AddDays(expires_days) - # ★ 함정: 이 시각은 naive-UTC 다. raw text() 로 바인딩하면 asyncpg 가 컬럼 타입 정보 없이 - # 드라이버 기본값(로컬 시스템 시간대)으로 해석해 KST(UTC+9) 서버에서는 9시간 밀린다 — - # 14일짜리 옛 만료값에서는 안 드러났는데(그래도 미래), 자정 단위(issue_token 2026-09-17 - # 개편)로 정밀해지자 자정 만료가 "9시간 전에 이미 만료"로 뒤집혔다. ORM 경로(update()/ - # insert())는 컬럼의 DateTime(timezone=True) 프로세서를 타서 이 문제가 없다 — raw text() - # 로 timestamptz 컬럼에 naive datetime 을 바인딩할 때만 명시적으로 UTC 를 달아야 한다. + # 함정: 이 시각은 naive-UTC 다. aware_expires = expires.replace(tzinfo=timezone.utc) async with db_engine.begin() as conn: await conn.execute( @@ -194,9 +176,7 @@ async def test_get_approves_immediately(client, db_engine, owner_id): async def test_approve_page_redirects_to_the_published_blog_section(client, db_engine, owner_id): - """승인 확인 화면은 5초 뒤 그 업장의 발행된 사이트(미니 블로그 자리)로 자동 이동한다 - (2026-09-22, 사장님 지시: "이메일 승인후에 블로그가 올라가는 페이지에 5초 정도 - 이후에 연결되도록"). 자동 이동을 못 믿어도 되게 같은 주소를 링크로도 남긴다.""" + """승인 확인 화면은 5초 뒤 그 업장의 발행된 사이트(미니 블로그 자리)로 자동 이동한다.""" token, post_id = await _seed_post(db_engine, owner_id) async with db_engine.begin() as conn: place_id = ( @@ -240,9 +220,6 @@ async def test_expired_link_is_an_answer_not_an_error(client, db_engine, owner_i async def test_approve_enqueues_build_with_owner_user_id(client, db_engine, owner_id): - """회귀: BUILD 잡 payload 에 owner_user_id 가 없으면 build_service.run_build 가 - payload["owner_user_id"] 에서 KeyError 로 죽는다 — 승인 클릭이 실제로는 사이트를 - 재발행하지 않는 채로 '올렸습니다' 만 보여주고 있었다.""" token, post_id = await _seed_post(db_engine, owner_id) res = await client.get(f"/v1/site/post/approve?t={token}") diff --git a/solution/backend/tests/test_booking_request.py b/solution/backend/tests/test_booking_request.py index ac8a79f..cc5ea54 100644 --- a/solution/backend/tests/test_booking_request.py +++ b/solution/backend/tests/test_booking_request.py @@ -1,11 +1,4 @@ -"""예약 요청 폼 → 사장님 메일. - -★ 이 파일이 지키는 것: - - 발행된 사이트의 업장만 받는다 (place_id 를 바꿔 아무 업장에나 메일을 쏘지 못한다) - - DB 에 아무것도 남기지 않는다 - - 봇(허니팟·즉시 제출)은 실패로 알리지 않고 조용히 버린다 - - SMTP 미설정이면 500 이 아니라 "전화로 문의" 로 답한다 -""" +"""예약 요청 폼 → 사장님 메일.""" import uuid import pytest @@ -84,7 +77,6 @@ async def test_honeypot_and_instant_submit_are_dropped(client, db_engine, owner_ bot = await client.post("/v1/site/booking-request", json=_form(place_id, company="광고회사")) fast = await client.post("/v1/site/booking-request", json=_form(place_id, elapsed_ms=10)) - # 봇에게는 걸렸다고 알리지 않는다 — 알리면 다음 시도가 그 조건을 피한다. assert bot.json()["success"] is True assert fast.json()["success"] is True assert sent == [] diff --git a/solution/backend/tests/test_build_publish.py b/solution/backend/tests/test_build_publish.py index ce82f35..460ebbf 100644 --- a/solution/backend/tests/test_build_publish.py +++ b/solution/backend/tests/test_build_publish.py @@ -1,16 +1,4 @@ -"""정적 빌드 + 발행 — 상호명에서 발행까지 한 바퀴. - -★ 이 경로가 절대 하면 안 되는 것: - - 미검증 fact 를 실은 사이트를 발행하는 것 - - 고유 콘텐츠 0건짜리 템플릿 페이지를 발행하는 것 - - JSON-LD 와 화면이 다른 사이트를 발행하는 것 - - 렌더 결과를 확인하지 않고 "발행됨" 으로 남기는 것 (페이지 없는 발행) - - 해지를 물리 삭제로 처리하는 것 - -★ 게이트는 이제 **렌더러가 실제로 구운 결과**를 보고 판정한다. 여기서는 그 보고서를 - 백엔드가 어떻게 처리하는지를 본다 — 구조화 데이터 ↔ 화면 값 대조 자체는 - 렌더러 쪽 테스트가 본다(solution/site/src/seo/verify.test.ts). -""" +"""정적 빌드 + 발행 — 상호명에서 발행까지 한 바퀴.""" import uuid from sqlalchemy import text @@ -67,8 +55,7 @@ async def _approved_media(db_engine, pid, alt="2층 목조 건물 외관"): async def test_full_build_and_publish(auth_headers, client, db_engine): - """검증: 필수 fact + 승인 사진이 갖춰진 사업장을 빌드·발행한다. - 기대결과: 게이트 통과 → BUILT → PUBLISHED. 버전과 발행 기록이 남는다.""" + """검증: 필수 fact + 승인 사진이 갖춰진 사업장을 빌드·발행한다.""" h = await auth_headers("u1") pid = await _place(client, h) await _verified_facts(client, h, pid, {"intro": "하조대 해변 도보 3분 거리의 펜션입니다."}) @@ -84,7 +71,7 @@ async def test_full_build_and_publish(auth_headers, client, db_engine): assert r["build_status"] == "BUILT" assert r["published"] is True assert r["unique_content_count"] > 0 - # ★ 백엔드는 HTML 을 굽지 않는다. payload 를 쓰고, 렌더러가 굽은 결과를 받아 판정한다. + # 백엔드는 HTML 을 굽지 않는다. assert r["payload_path"], "렌더러에 넘길 payload 가 없다" assert r["routes"] > 0, "렌더러가 페이지를 하나도 굽지 않았다" @@ -94,14 +81,7 @@ async def test_full_build_and_publish(auth_headers, client, db_engine): async def test_unverified_fact_blocks_publish(auth_headers, client, db_engine): - """검증: 미검증 fact 가 섞인 채로 빌드한다. - - 기대결과: ★ 스냅샷에서 전부 걸러져 페이지에 남는 내용이 없다 — NO_UNIQUE_CONTENT 로 막힌다. - (필수 항목 누락은 2026-08-27 부터 막지 않는다. 그래서 여기서 걸리는 사유가 바뀌었다.) - - ★ 미검증값은 공식 API 수집으로 만든다. 크롤링은 2026-09-14 부터 빈 자리에 바로 - 노출값으로 들어가므로 더는 '미검증 fact' 를 만드는 경로가 아니다 — - 그래도 UNVERIFIED 가 페이지에 새어 나가면 안 된다는 것은 그대로다.""" + """검증: 미검증 fact 가 섞인 채로 빌드한다.""" h = await auth_headers("u1") pid = await _place(client, h, "미검증펜션") # 공식 API 로 들어온 값 = 후보 상태(UNVERIFIED) @@ -123,9 +103,7 @@ async def test_unverified_fact_blocks_publish(auth_headers, client, db_engine): async def test_no_unique_content_blocks_publish(auth_headers, client): - """검증: 필수 항목은 다 채웠지만 값이 전부 짧은 정형값이고 소개문·FAQ·사진이 없다. - 기대결과: ★ NO_UNIQUE_CONTENT — 이 가게를 구분할 문장이 하나도 없으면 - 같은 템플릿 대량 생성으로 보인다(스팸 판정 대상).""" + """검증: 필수 항목은 다 채웠지만 값이 전부 짧은 정형값이고 소개문·FAQ·사진이 없다.""" h = await auth_headers("u1") pid = await _place(client, h, "빈껍데기펜션") # 취소 규정을 한 글자로 — 형식은 갖췄지만 이 가게만의 내용이 없다. @@ -141,8 +119,6 @@ async def test_no_unique_content_blocks_publish(auth_headers, client): async def test_owner_written_intro_counts_as_unique_content(auth_headers, client): - """검증: fact·FAQ·사진 설명은 없지만 사장님이 소개 섹션 본문을 직접 썼다. - 기대결과: 실제 발행본에 표시되는 소개문 1건으로 계수되어 발행된다.""" h = await auth_headers("owner-intro") pid = await _place(client, h, "직접소개펜션") for k, v in {**REQUIRED, "cancel_policy": "X"}.items(): @@ -164,11 +140,7 @@ async def test_owner_written_intro_counts_as_unique_content(auth_headers, client async def test_missing_required_field_warns_but_publishes(auth_headers, client, db_engine): - """검증: 취소 규정 없이 빌드한다. - - 기대결과: ★ **발행된다.** 막아야 할 것은 "틀린 정보가 나가는 것"이지 "덜 찬 것"이 아니다 - (2026-08-27 결정). 대신 무엇이 비었는지는 경고로 알려줘야 한다 — 사장님이 나중에 - 채우면 재빌드된다. 여기서 막으면 주소·전화가 확인된 쓸모 있는 페이지조차 못 낸다.""" + """검증: 취소 규정 없이 빌드한다.""" h = await auth_headers("u1") pid = await _place(client, h, "규정없는펜션") partial = {k: v for k, v in REQUIRED.items() if k != "cancel_policy"} @@ -190,8 +162,7 @@ async def test_missing_required_field_warns_but_publishes(auth_headers, client, async def test_rejected_build_is_logged_with_reason(auth_headers, client): - """검증: 게이트가 막은 뒤 발행 기록. - 기대결과: REJECTED 로 남고 사유가 detail 에 있다 — 운영자가 뭘 고칠지 안다.""" + """검증: 게이트가 막은 뒤 발행 기록.""" h = await auth_headers("u1") pid = await _place(client, h, "기록펜션") for k, v in {**REQUIRED, "cancel_policy": "X"}.items(): @@ -206,8 +177,7 @@ async def test_rejected_build_is_logged_with_reason(auth_headers, client): async def test_build_requires_verified_place(auth_headers, client): - """검증: 동일 업소 검증 전에 빌드한다. - 기대결과: PLACE_NOT_VERIFIED — 주소·좌표가 없으면 JSON-LD 가 성립하지 않는다.""" + """검증: 동일 업소 검증 전에 빌드한다.""" h = await auth_headers("u1") pid = (await client.post("/v1/place", headers=h, json={"name": "미검증", "category": 1})).json()["place"]["place_id"] r = await client.post(f"/v1/place/{pid}/site/build", headers=h, json={}) @@ -215,8 +185,7 @@ async def test_build_requires_verified_place(auth_headers, client): async def test_needs_rebuild_after_content_change(auth_headers, client, db_engine): - """검증: 발행 후 노출값을 바꾼다. - 기대결과: ★ needs_rebuild=true — 이 사업장만 재빌드하면 된다(전체 재빌드 금지).""" + """검증: 발행 후 노출값을 바꾼다.""" h = await auth_headers("u1") pid = await _place(client, h, "재빌드펜션") await _verified_facts(client, h, pid, {"intro": "조용한 펜션입니다."}) @@ -231,8 +200,7 @@ async def test_needs_rebuild_after_content_change(auth_headers, client, db_engin async def test_suspend_is_state_transition_not_delete(auth_headers, client, db_engine): - """검증: 발행된 사이트를 중지하고 다시 재개한다. - 기대결과: ★ 상태만 바뀌고 버전·행은 그대로 — 해지를 삭제로 처리하지 않는다.""" + """검증: 발행된 사이트를 중지하고 다시 재개한다.""" h = await auth_headers("u1") pid = await _place(client, h, "해지펜션") await _verified_facts(client, h, pid, {"intro": "펜션 소개문입니다."}) @@ -252,8 +220,7 @@ async def test_suspend_is_state_transition_not_delete(auth_headers, client, db_e async def test_versions_accumulate(auth_headers, client, db_engine): - """검증: 두 번 빌드한다. - 기대결과: 버전이 1, 2 로 쌓인다 — 예전에 뭐가 나갔는지 추적할 수 있다.""" + """검증: 두 번 빌드한다.""" h = await auth_headers("u1") pid = await _place(client, h, "버전펜션") await _verified_facts(client, h, pid, {"intro": "펜션 소개문입니다."}) @@ -269,8 +236,7 @@ async def test_versions_accumulate(auth_headers, client, db_engine): async def test_site_is_scoped_to_owner(auth_headers, client): - """검증: 다른 사장님 계정으로 남의 사이트를 본다. - 기대결과: PLACE_NOT_FOUND.""" + """검증: 다른 사장님 계정으로 남의 사이트를 본다.""" h1 = await auth_headers("o1") pid = await _place(client, h1, "스코프펜션") h2 = await auth_headers("o2") @@ -294,8 +260,6 @@ async def _build(client, h, pid, publish=True): async def test_렌더가_실패하면_발행하지_않는다(auth_headers, client, db_engine, monkeypatch): - """검증: 렌더러가 페이지를 굽지 못했다고 보고한다. - 기대결과: ★ FAILED. 페이지가 없는데 "발행됨"으로 남으면 사장님은 404 를 눌러야 안다.""" from services import render_service async def _broken(payload_path, site_version, timeout_sec): @@ -313,7 +277,7 @@ async def test_렌더가_실패하면_발행하지_않는다(auth_headers, clien assert r.get("published") is not True assert "번들이 없다" in r["error"] - # ★ 게이트 반려가 아닌 진짜 실패(렌더·인프라)는 알린다 — services/alert_service. + # 게이트 반려가 아닌 진짜 실패(렌더·인프라)는 알린다 — services/alert_service. async with db_engine.begin() as c: rows = (await c.execute(text("SELECT kind FROM alert_outbox WHERE kind = 'build_failed'"))).all() assert len(rows) == 1 @@ -323,8 +287,7 @@ async def test_렌더가_실패하면_발행하지_않는다(auth_headers, clien async def test_구조화데이터가_화면과_다르면_발행하지_않는다(auth_headers, client, db_engine, monkeypatch): - """검증: 렌더러가 JSON-LD 불일치를 보고한다. - 기대결과: ★ JSONLD_MISMATCH — 검색엔진에만 다른 말을 하는 페이지는 나가면 안 된다.""" + """검증: 렌더러가 JSON-LD 불일치를 보고한다.""" from services import render_service async def _mismatch(payload_path, site_version, timeout_sec): @@ -340,19 +303,18 @@ async def test_구조화데이터가_화면과_다르면_발행하지_않는다( r = await _build(client, h, pid) assert r["build_status"] == "FAILED" - # ★ 사유가 "렌더 실패"로 뭉뚱그려지면 운영자가 손댈 곳을 알 수 없다. + # 사유가 "렌더 실패"로 뭉뚱그려지면 운영자가 손댈 곳을 알 수 없다. assert r["gate"]["reason"] == PublishRejectReason.JSONLD_MISMATCH.name assert r["mismatches"] - # ★ 게이트 반려는 알리지 않는다 — 사장님 쪽 문제를 운영자에게 알리면 안 된다. + # 게이트 반려는 알리지 않는다 — 사장님 쪽 문제를 운영자에게 알리면 안 된다. async with db_engine.begin() as c: rows = (await c.execute(text("SELECT kind FROM alert_outbox"))).all() assert rows == [] async def test_렌더_결과가_안_오면_발행하지_않는다(auth_headers, client, db_engine, monkeypatch): - """검증: 렌더러가 죽어 보고서가 오지 않는다(타임아웃). - 기대결과: ★ FAILED. 확인하지 못한 것을 발행됨으로 남기지 않는다.""" + """검증: 렌더러가 죽어 보고서가 오지 않는다(타임아웃).""" from services import render_service async def _timeout(payload_path, site_version, timeout_sec): @@ -369,9 +331,7 @@ async def test_렌더_결과가_안_오면_발행하지_않는다(auth_headers, async def test_렌더_보고서의_값이_그대로_박제된다(auth_headers, client, db_engine): - """검증: site_versions 에 남는 jsonld·고유콘텐츠 수의 출처. - 기대결과: ★ 렌더러가 실제로 내보낸 값이다 — 백엔드가 따로 계산하지 않는다. - (따로 계산하던 시절엔 게이트가 통과시킨 근거와 실제 페이지가 어긋날 수 있었다.)""" + """검증: site_versions 에 남는 jsonld·고유콘텐츠 수의 출처.""" h = await auth_headers("u1") pid = await _built_place(client, h, db_engine) r = await _build(client, h, pid) diff --git a/solution/backend/tests/test_category_schema.py b/solution/backend/tests/test_category_schema.py index c4f3b1b..2f58bc1 100644 --- a/solution/backend/tests/test_category_schema.py +++ b/solution/backend/tests/test_category_schema.py @@ -1,7 +1,4 @@ -"""업종 스키마 — 4개 업종 파일이 규약대로 로드되는지. - -업종 추가 = resources/ 에 JSON 1개 + PlaceCategory 코드 1줄. 이 테스트는 그 규약이 깨지면 잡는다. -""" +"""업종 스키마 — 4개 업종 파일이 규약대로 로드되는지.""" import pytest from common.category_schema import CategorySchemaError, all_schemas, get_schema, is_valid_key @@ -9,8 +6,7 @@ from common.enums import PlaceCategory def test_all_categories_have_schema(): - """검증: PlaceCategory 의 모든 업종에 스키마 파일이 있는지. - 기대결과: 4개 업종(숙박·카페·음식점·피부과·성형외과)이 모두 로드되고 code 가 1:1로 맞는다.""" + """검증: PlaceCategory 의 모든 업종에 스키마 파일이 있는지.""" schemas = all_schemas() assert set(schemas) == {c.value for c in PlaceCategory} for code, schema in schemas.items(): @@ -20,23 +16,20 @@ def test_all_categories_have_schema(): def test_keys_unique_within_category(): - """검증: 업종 안에서 fact key 가 유일한지. - 기대결과: 중복 없음 (facts 의 (place,unit,key) 유니크가 의미를 가지려면 필수).""" + """검증: 업종 안에서 fact key 가 유일한지.""" for schema in all_schemas().values(): keys = list(schema.fields) assert len(keys) == len(set(keys)), f"{schema.name}: key 중복" def test_place_and_unit_scopes_exist(): - """검증: 업종마다 place 스코프 필드가 있는지. - 기대결과: 전 업종에 place 필드 존재. unit 필드는 업종별로 있을 수도 없을 수도 있다.""" + """검증: 업종마다 place 스코프 필드가 있는지.""" for schema in all_schemas().values(): assert schema.keys_by_scope("place"), f"{schema.name}: place 스코프 필드가 없다" def test_lodging_has_claim_critical_fields(): - """검증: 숙박 업종에 예약 클레임 직결 항목이 critical 로 잡혀 있는지. - 기대결과: 체크인·체크아웃·취사·반려동물·취소규정이 모두 critical=True 이고 LLM 이 못 쓴다.""" + """검증: 숙박 업종에 예약 클레임 직결 항목이 critical 로 잡혀 있는지.""" schema = get_schema(PlaceCategory.LODGING) for key in ("check_in_time", "check_out_time", "cooking_allowed", "pet_allowed", "cancel_policy"): spec = schema.get(key) @@ -46,8 +39,7 @@ def test_lodging_has_claim_critical_fields(): def test_llm_writable_fields_are_sentences_only(): - """검증: LLM 이 값을 만들 수 있는 필드가 '문장' 필드뿐인지 (절대규칙 7 — LLM 은 사실을 만들지 않는다). - 기대결과: allow_llm=True 인 필드는 전부 type=text 이고 critical 이 아니다.""" + """검증: LLM 이 값을 만들 수 있는 필드가 '문장' 필드뿐인지 (절대규칙 7 — LLM 은 사실을 만들지 않는다).""" for schema in all_schemas().values(): for key in schema.llm_writable_keys(): spec = schema.get(key) @@ -56,23 +48,20 @@ def test_llm_writable_fields_are_sentences_only(): def test_required_fields_are_never_llm_written(): - """검증: 발행 필수 항목을 LLM 이 채우지 못하는지. - 기대결과: required=True 인 필드는 전부 allow_llm=False.""" + """검증: 발행 필수 항목을 LLM 이 채우지 못하는지.""" for schema in all_schemas().values(): for key in schema.required_keys(): assert schema.get(key).allow_llm is False, f"{schema.name}.{key}: 필수 항목을 LLM 이 채우면 안 된다" def test_is_valid_key_rejects_cross_category_key(): - """검증: 업종에 없는 key 를 걸러내는지 (facts 쓰기 전 FACT_INVALID_KEY 판정). - 기대결과: 숙박의 check_in_time 은 카페 스키마에서 거부된다.""" + """검증: 업종에 없는 key 를 걸러내는지 (facts 쓰기 전 FACT_INVALID_KEY 판정).""" assert is_valid_key(PlaceCategory.LODGING, "check_in_time") is True assert is_valid_key(PlaceCategory.CAFE, "check_in_time") is False assert is_valid_key(PlaceCategory.CAFE, "break_time") is True def test_unknown_category_raises(): - """검증: 지원하지 않는 업종 코드 조회. - 기대결과: CategorySchemaError.""" + """검증: 지원하지 않는 업종 코드 조회.""" with pytest.raises(CategorySchemaError): get_schema(99) diff --git a/solution/backend/tests/test_collect_api.py b/solution/backend/tests/test_collect_api.py index 205a3da..b07a66f 100644 --- a/solution/backend/tests/test_collect_api.py +++ b/solution/backend/tests/test_collect_api.py @@ -1,11 +1,4 @@ -"""수집 시작 e2e — 비동기 잡 적재와 진입 게이트. - -수집은 한 건에 몇 분(Perplexity 10~30s + 크롤링 + Vision 사진 배치)이라 동기로 못 한다. -API 는 잡만 넣고 즉시 응답하고, 클라이언트는 job_id 로 폴링한다. - -★ 진입 게이트는 하나 — 동일 업소 검증. 검증 없이 긁으면 남의 가게가 섞인다. - 채널 URL 발견(Perplexity)과 확정은 잡 안에서 일어나므로, 링크가 없어도 수집은 시작된다. -""" +"""수집 시작 e2e — 비동기 잡 적재와 진입 게이트.""" from common.enums import ErrorType, JobStatus, JobType, LinkChannel, SourceType OTA = "https://www.yanolja.com/pension/1" @@ -27,8 +20,7 @@ async def _confirmed_link(client, h, pid, url=OTA): async def test_collect_requires_verified_place(auth_headers, client): - """검증: 동일 업소 검증 전에 수집을 시작한다. - 기대결과: PLACE_NOT_VERIFIED — ★ 검증 없이는 크롤링이 열리지 않는다.""" + """검증: 동일 업소 검증 전에 수집을 시작한다.""" h = await auth_headers("u1") pid = await _place(client, h) @@ -37,8 +29,7 @@ async def test_collect_requires_verified_place(auth_headers, client): async def test_collect_starts_without_any_link(auth_headers, client): - """검증: 링크가 하나도 없는 검증된 사업장에서 수집을 시작한다. - 기대결과: 시작된다 — 채널 URL 발견(Perplexity)이 잡의 첫 단계이기 때문.""" + """검증: 링크가 하나도 없는 검증된 사업장에서 수집을 시작한다.""" h = await auth_headers("u1") pid = await _place(client, h, kakao="c1") @@ -49,8 +40,7 @@ async def test_collect_starts_without_any_link(auth_headers, client): async def test_targeted_recrawl_needs_confirmed_link(auth_headers, client): - """검증: link_ids 로 특정 링크만 재크롤하라고 했는데 그게 확정 상태가 아니다. - 기대결과: LINK_NOT_CONFIRMED — 콕 집은 대상이 없으면 시작할 이유가 없다.""" + """검증: link_ids 로 특정 링크만 재크롤하라고 했는데 그게 확정 상태가 아니다.""" import uuid as _uuid h = await auth_headers("u1") @@ -62,8 +52,7 @@ async def test_targeted_recrawl_needs_confirmed_link(auth_headers, client): async def test_collect_enqueues_job_and_returns_immediately(auth_headers, client): - """검증: 검증 + 링크 확정이 끝난 뒤 수집을 시작한다. - 기대결과: job_id 를 즉시 돌려주고 잡은 PENDING — 요청이 몇 분을 기다리지 않는다.""" + """검증: 검증 + 링크 확정이 끝난 뒤 수집을 시작한다.""" h = await auth_headers("u1") pid = await _place(client, h, kakao="c3") await _confirmed_link(client, h, pid) @@ -81,8 +70,7 @@ async def test_collect_enqueues_job_and_returns_immediately(auth_headers, client async def test_collect_marks_place_as_collecting(auth_headers, client): - """검증: 수집을 시작한 뒤 사업장 상태. - 기대결과: DRAFT → COLLECTING (관리 화면 배지).""" + """검증: 수집을 시작한 뒤 사업장 상태.""" h = await auth_headers("u1") pid = await _place(client, h, kakao="c4") await _confirmed_link(client, h, pid) @@ -93,8 +81,7 @@ async def test_collect_marks_place_as_collecting(auth_headers, client): async def test_double_click_does_not_run_twice(auth_headers, client): - """검증: 수집 버튼을 두 번 누른다. - 기대결과: 같은 job_id 를 돌려주고 created=False — dedupe_key 로 활성 중복이 막힌다.""" + """검증: 수집 버튼을 두 번 누른다.""" h = await auth_headers("u1") pid = await _place(client, h, kakao="c5") await _confirmed_link(client, h, pid) @@ -157,8 +144,7 @@ async def test_targeted_collect_payload_carries_requested_confirmed_link(auth_he async def test_collect_is_scoped_to_owner(auth_headers, client): - """검증: 다른 사장님 계정으로 남의 사업장 수집을 시작한다. - 기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 수집도 못 건다.""" + """검증: 다른 사장님 계정으로 남의 사업장 수집을 시작한다.""" h1 = await auth_headers("o1") pid = await _place(client, h1, kakao="c7") await _confirmed_link(client, h1, pid) diff --git a/solution/backend/tests/test_collect_pipeline.py b/solution/backend/tests/test_collect_pipeline.py index f3e3410..cf9f65c 100644 --- a/solution/backend/tests/test_collect_pipeline.py +++ b/solution/backend/tests/test_collect_pipeline.py @@ -1,12 +1,4 @@ -"""수집 파이프라인 e2e — 잡이 실제로 돌아 fact·사진이 후보로 쌓이는지. - - 상호명 → (URL 발견) → 확정 → 크롤링 → fact/사진 적재 - -★ 이 파이프라인이 절대 하면 안 되는 것: - - 검증 안 된 사업장을 긁는 것 - - 수집값을 바로 사이트에 노출시키는 것 (전부 후보로 들어가야 한다) - - 사장님 정정본을 덮어쓰는 것 -""" +"""수집 파이프라인 e2e — 잡이 실제로 돌아 fact·사진이 후보로 쌓이는지.""" import uuid from common.enums import FactStatus, JobStatus, JobType, LinkChannel, MediaStatus, PlaceCategory, SourceType @@ -29,13 +21,7 @@ async def _ready_place(client, h, category=PlaceCategory.LODGING, kakao="k1"): async def _run_worker(job_id=None): - """워커를 돌려 큐를 비운다. `job_id` 를 주면 그 잡이 끝날 때까지 돈다. - - ★ 1틱만 돌리면 안 된다. 수집이 끝나면 **지역 이야기 잡(LOCAL_SYNC)이 뒤따라 들어온다** — - 한 틱은 그걸 집어 가고, 정작 기다리던 수집 잡은 PENDING 인 채로 남는다. 테스트는 - `job["result"]` 를 읽다가 KeyError 로 죽는데, 화면에는 "그냥 안 끝난 것" 으로 보인다. - 큐에 뒤따르는 잡이 생길 때마다 이 헬퍼가 조용히 어긋나므로 개수를 세지 않고 비운다. - """ + """워커를 돌려 큐를 비운다.""" worker = Worker("test-worker", JobQueue(), build_handler(), job_deadline_sec=60) ran = 0 for _ in range(10): @@ -46,13 +32,7 @@ async def _run_worker(job_id=None): async def test_pipeline_publishes_collected_facts(auth_headers, client): - """검증: 수집 잡을 끝까지 돌린다. - 기대결과: 빈 자리에 들어온 수집값이 **바로 노출값**이 된다(2026-09-14 결정). - - ★ 예전에는 전부 UNVERIFIED 후보였다. 그러면 수집 직후 발행이 '확인된 사실 0건' 으로 - 막혀, 사장님이 한 건씩 승인하기 전에는 사이트가 만들어지지 않았다. - ★ 사람이 넣은 값·정정본을 덮지 않는다는 보호는 그대로다 — - test_recollect_cannot_overwrite_corrected_value 가 그 자리를 지킨다.""" + """검증: 수집 잡을 끝까지 돌린다.""" h = await auth_headers("u1") pid = await _ready_place(client, h) @@ -73,8 +53,7 @@ async def test_pipeline_publishes_collected_facts(auth_headers, client): async def test_pipeline_records_source_on_every_fact(auth_headers, client): - """검증: 수집된 fact 의 출처. - 기대결과: 전부 source_type=crawl + source_url 이 붙어 있다 — 출처 없는 사실은 없다.""" + """검증: 수집된 fact 의 출처.""" h = await auth_headers("u1") pid = await _ready_place(client, h, kakao="k2") await client.post(f"/v1/place/{pid}/collect", headers=h, json={}) @@ -87,8 +66,7 @@ async def test_pipeline_records_source_on_every_fact(auth_headers, client): async def test_pipeline_creates_units_and_unit_scoped_facts(auth_headers, client): - """검증: 숙박 수집 결과의 객실 단위 fact. - 기대결과: units 가 생기고 객실별 fact 가 각자 붙는다(A동·B동이 각자 기준인원을 갖는다).""" + """검증: 숙박 수집 결과의 객실 단위 fact.""" h = await auth_headers("u1") pid = await _ready_place(client, h, kakao="k3") await client.post(f"/v1/place/{pid}/collect", headers=h, json={}) @@ -105,9 +83,7 @@ async def test_pipeline_creates_units_and_unit_scoped_facts(auth_headers, client async def test_pipeline_stores_media_with_origin_and_pending_review(auth_headers, client): - """검증: 수집된 사진. - 기대결과: origin_url·source_type=crawl 이 남고 PENDING_REVIEW 다 — - ★ 재게시 권리 결론에 따라 통째로 걸러낼 수 있어야 하고, Vision 전이라 사람 확인 큐다.""" + """검증: 수집된 사진.""" from sqlalchemy import text h = await auth_headers("u1") @@ -120,8 +96,7 @@ async def test_pipeline_stores_media_with_origin_and_pending_review(auth_headers async def test_recollect_skips_crawl_when_already_enough(auth_headers, client): - """검증: 필수 항목이 이미 다 찬 사업장에 다시 수집을 건다. - 기대결과: ★ 크롤링을 아예 하지 않는다 — 사이트를 만들 정보가 충분하면 여분의 크롤링은 낭비다.""" + """검증: 필수 항목이 이미 다 찬 사업장에 다시 수집을 건다.""" h = await auth_headers("u1") pid = await _ready_place(client, h, kakao="k5") @@ -142,8 +117,7 @@ async def test_recollect_skips_crawl_when_already_enough(auth_headers, client): async def test_forced_recollect_is_idempotent(auth_headers, client): - """검증: force=true 로 강제 재수집한다(항목이 이미 차 있어도). - 기대결과: 다시 긁되 fact 는 REFRESHED, 사진은 중복 스킵 — 데이터가 부풀지 않는다.""" + """검증: force=true 로 강제 재수집한다(항목이 이미 차 있어도).""" h = await auth_headers("u1") pid = await _ready_place(client, h, kakao="k5f") @@ -164,8 +138,7 @@ async def test_forced_recollect_is_idempotent(auth_headers, client): async def test_coverage_reports_missing_required_fields(auth_headers, client): - """검증: 수집 후 필수 항목 충족도. - 기대결과: coverage 에 required/covered/missing 이 담긴다 — UI 가 '뭐가 비었나'를 보여줄 수 있다.""" + """검증: 수집 후 필수 항목 충족도.""" h = await auth_headers("u1") pid = await _ready_place(client, h, kakao="k5c") @@ -180,11 +153,7 @@ async def test_coverage_reports_missing_required_fields(auth_headers, client): async def test_recollect_does_not_touch_verified_value(auth_headers, client): - """검증: 수집을 두 번 돌린다(같은 목데이터라 값이 같다). - 기대결과: 값이 같으므로 REFRESHED — ★ 사이트에 나가던 사실이 사라지지 않고 늘지도 않는다. - - ★ 재수집이 같은 값을 후보로 또 쌓으면 확인 큐가 중복으로 넘치고, 노출값을 지웠다 - 다시 넣으면 그 사이에 사이트에서 사실이 사라진다. 둘 다 안 일어나야 한다.""" + """검증: 수집을 두 번 돌린다(같은 목데이터라 값이 같다).""" h = await auth_headers("u1") pid = await _ready_place(client, h, kakao="k6") await client.post(f"/v1/place/{pid}/collect", headers=h, json={}) @@ -205,8 +174,7 @@ async def test_recollect_does_not_touch_verified_value(auth_headers, client): async def test_recollect_cannot_overwrite_corrected_value(auth_headers, client): - """검증: 사장님이 정정한 값에 재수집이 다른 값을 들고 온다. - 기대결과: 노출값은 정정본 그대로, 크롤링 값은 후보로만 남는다 — ★ 절대규칙 6.""" + """검증: 사장님이 정정한 값에 재수집이 다른 값을 들고 온다.""" h = await auth_headers("u1") pid = await _ready_place(client, h, kakao="k7") await client.post(f"/v1/place/{pid}/collect", headers=h, json={}) @@ -230,8 +198,7 @@ async def test_recollect_cannot_overwrite_corrected_value(auth_headers, client): async def test_pipeline_refuses_unverified_place(db_engine, owner_id): - """검증: 검증 안 된 사업장의 수집 잡이 큐에 직접 들어간 경우(잡 적재 후 검증이 취소된 상황). - 기대결과: 잡이 실패한다 — ★ 잡 실행 시점에도 게이트를 다시 확인한다.""" + """검증: 검증 안 된 사업장의 수집 잡이 큐에 직접 들어간 경우(잡 적재 후 검증이 취소된 상황).""" from sqlalchemy import text pid = uuid.uuid4() diff --git a/solution/backend/tests/test_collector.py b/solution/backend/tests/test_collector.py index d7f775d..a088e2b 100644 --- a/solution/backend/tests/test_collector.py +++ b/solution/backend/tests/test_collector.py @@ -1,13 +1,4 @@ -"""수집 어댑터 계약 — 어디서 긁어오든 결과 모양이 같은지, 그리고 법무 게이트가 코드로 지켜지는지. - -가장 중요한 것 두 개: - 1. 목데이터의 fact key 가 전부 업종 스키마에 있는가 - — 없는 key 는 FACT_INVALID_KEY 로 전부 거부되므로, 어긋나면 목데이터가 파이프라인을 하나도 검증하지 못한다 - 2. Phase 1 에 실제 크롤 어댑터가 등록돼 있지 않은가 - — 법적 검토 결론 전까지 크롤이 돌면 안 된다(docs/DECISIONS.md 1-1) - -DB 를 쓰지 않는 순수 단위 테스트다. -""" +"""수집 어댑터 계약 — 어디서 긁어오든 결과 모양이 같은지, 그리고 법무 게이트가 코드로 지켜지는지.""" import pytest from common.category_schema import get_schema @@ -27,13 +18,6 @@ from services.collector import ( from services.collector.registry import REGISTRY # 어떤 어댑터도 처리하면 안 되는 URL — 조용히 빈 결과를 주지 않고 AdapterNotFound 로 끊어야 한다. -# ★ 네이버 플레이스는 2026-08-27 부터 naver_place 어댑터가 처리하므로 여기서 뺐다. -# ★ 일반 도메인(example.co.kr)도 2026-08-28 부터 static_html 이 처리하므로 뺐다. -# 여기 남은 것은 **수집 불가 결론이 난 곳**이다(docs/DATA_SOURCE_RESEARCH.md): -# 야놀자·여기어때는 403 + Cloudflare 로 막혀 있고 민사 10억 선례가 있다. -# 카카오맵은 내부 API 406. 인스타는 Graph API(사장님 OAuth)로만 간다. -# ★ NOL 전용 어댑터(2026-09-14)가 받는 것은 `nol.yanolja.com/stay/domestic/<id>` 한 패턴뿐이다. -# 아래 야놀자 주소가 여전히 막혀야 전용 경로를 연 것과 범용 수집을 푼 것이 갈린다. _REAL_URLS = ( "https://www.yanolja.com/pension/1000", "https://www.goodchoice.kr/product/detail/1000", @@ -45,8 +29,7 @@ _REAL_URLS = ( # ── 목데이터 ↔ 업종 스키마 정합성 (제일 중요) ──────────────────────────── @pytest.mark.parametrize("category", list(PlaceCategory)) async def test_mock_fact_keys_exist_in_category_schema(category): - """검증: MockAdapter 가 뱉는 fact key 를 업종 스키마와 대조한다. - 기대결과: 전부 스키마에 존재한다 — 없는 key 는 fact 기록에서 전부 거부돼 목데이터가 무의미해진다.""" + """검증: MockAdapter 가 뱉는 fact key 를 업종 스키마와 대조한다.""" schema = get_schema(category) source = await MockAdapter().fetch(MockAdapter.url_for(category)) @@ -58,9 +41,7 @@ async def test_mock_fact_keys_exist_in_category_schema(category): @pytest.mark.parametrize("category", list(PlaceCategory)) async def test_mock_fact_scope_matches_schema(category): - """검증: fact 의 scope 가 스키마 정의와 같은지. - 기대결과: place 필드는 place 로, unit 필드는 unit(+단위 이름)으로 수집된다. - scope 가 어긋나면 객실별 값이 사업장 값으로 뭉개진다.""" + """검증: fact 의 scope 가 스키마 정의와 같은지.""" schema = get_schema(category) source = await MockAdapter().fetch(MockAdapter.url_for(category)) @@ -72,8 +53,7 @@ async def test_mock_fact_scope_matches_schema(category): @pytest.mark.parametrize("category", list(PlaceCategory)) async def test_mock_does_not_collect_llm_written_fields(category): - """검증: 수집물에 소개문 계열(allow_llm=True) 필드가 섞이는지. - 기대결과: 없다 — 소개문은 수집하는 사실이 아니라 generator 가 쓰는 문장이다(절대규칙 7).""" + """검증: 수집물에 소개문 계열(allow_llm=True) 필드가 섞이는지.""" schema = get_schema(category) source = await MockAdapter().fetch(MockAdapter.url_for(category)) @@ -84,8 +64,7 @@ async def test_mock_does_not_collect_llm_written_fields(category): @pytest.mark.parametrize("category", list(PlaceCategory)) async def test_mock_returns_three_to_five_photos(category): - """검증: 사진 수집 결과. - 기대결과: 3~5장. Gemini Vision 배치 처리를 물려볼 수 있는 최소량이다.""" + """검증: 사진 수집 결과.""" source = await MockAdapter().fetch(MockAdapter.url_for(category)) assert 3 <= len(source.media) <= 5, f"사진 {len(source.media)}장" for item in source.media: @@ -93,8 +72,7 @@ async def test_mock_returns_three_to_five_photos(category): async def test_mock_is_deterministic(): - """검증: 같은 URL 로 두 번 수집한다. - 기대결과: fact·사진이 동일하다 — 목데이터가 흔들리면 파이프라인 테스트가 못 믿을 게 된다.""" + """검증: 같은 URL 로 두 번 수집한다.""" adapter = MockAdapter() url = MockAdapter.url_for(PlaceCategory.LODGING) first, second = await adapter.fetch(url), await adapter.fetch(url) @@ -104,8 +82,7 @@ async def test_mock_is_deterministic(): async def test_mock_reads_category_and_channel_from_url(): - """검증: URL 에서 업종·채널을 읽는지. - 기대결과: 업종별로 다른 목데이터가 나오고, ?channel= 로 채널이 지정된다.""" + """검증: URL 에서 업종·채널을 읽는지.""" adapter = MockAdapter() lodging = await adapter.fetch("mock://lodging/p1") cafe = await adapter.fetch("mock://cafe/c1?channel=naver_place") @@ -117,8 +94,7 @@ async def test_mock_reads_category_and_channel_from_url(): async def test_mock_unknown_category_fails_softly(): - """검증: 업종을 못 읽는 mock URL. - 기대결과: 예외가 아니라 ok=False 결과 — 채널 하나가 이상해도 나머지 수집이 멈추면 안 된다.""" + """검증: 업종을 못 읽는 mock URL.""" source = await MockAdapter().fetch("mock://unknown/x1") assert source.ok is False assert source.error and "업종" in source.error @@ -127,8 +103,7 @@ async def test_mock_unknown_category_fails_softly(): # ── can_handle / 레지스트리 ────────────────────────────────────────────── def test_can_handle_accepts_mock_urls_only(): - """검증: MockAdapter 의 처리 범위. - 기대결과: mock:// 와 mock.test 만 받고 실제 사이트 URL 은 거부한다.""" + """검증: MockAdapter 의 처리 범위.""" adapter = MockAdapter() assert adapter.can_handle("mock://lodging/1") is True assert adapter.can_handle("https://mock.test/cafe/1") is True @@ -138,22 +113,19 @@ def test_can_handle_accepts_mock_urls_only(): def test_registry_resolves_mock_url(): - """검증: 레지스트리로 어댑터를 찾는다. - 기대결과: mock URL 은 MockAdapter 로 해결된다.""" + """검증: 레지스트리로 어댑터를 찾는다.""" assert get_adapter("mock://lodging/1").id == "mock" @pytest.mark.parametrize("url", _REAL_URLS) def test_registry_raises_for_unhandled_url(url): - """검증: 등록된 어댑터가 처리 못 하는 URL 을 조회한다. - 기대결과: AdapterNotFound — 조용히 빈 결과를 돌려주지 않는다.""" + """검증: 등록된 어댑터가 처리 못 하는 URL 을 조회한다.""" with pytest.raises(AdapterNotFound): get_adapter(url) def test_registry_rejects_duplicate_adapter_id(): - """검증: 같은 id 의 어댑터를 두 번 등록한다. - 기대결과: ValueError — 어느 쪽이 쓰이는지 모르는 상태를 만들지 않는다.""" + """검증: 같은 id 의 어댑터를 두 번 등록한다.""" registry = AdapterRegistry() registry.register(MockAdapter()) with pytest.raises(ValueError): @@ -161,8 +133,7 @@ def test_registry_rejects_duplicate_adapter_id(): def test_disabled_adapter_is_blocked(): - """검증: 등록은 됐지만 ENABLED 목록에 없는 어댑터를 조회한다. - 기대결과: AdapterDisabled — 등록과 사용 허가를 분리해 실수로 켜지지 않게 한다.""" + """검증: 등록은 됐지만 ENABLED 목록에 없는 어댑터를 조회한다.""" registry = AdapterRegistry(enabled=frozenset()) registry.register(MockAdapter()) with pytest.raises(AdapterDisabled): @@ -172,31 +143,21 @@ def test_disabled_adapter_is_blocked(): # ── 법무 게이트 (Phase 1) ──────────────────────────────────────────────── def test_registers_only_reviewed_adapters(): - """검증: 기본 레지스트리에 등록된 어댑터 목록. - - 기대결과: 검토를 거쳐 명시적으로 승인한 것만 있다. 이 목록이 늘어나는 것은 - **의도된 결정이어야** 하므로, 코드가 몰래 늘어나면 여기서 깨진다.""" + """검증: 기본 레지스트리에 등록된 어댑터 목록.""" assert adapter_ids() == ["naver_place", "tour_api", "yanolja", "mock", "static_html"], ( f"예상 밖 어댑터가 등록됐다: {adapter_ids()} — 승인 없이 수집 대상을 늘리지 않는다" ) def test_static_html_is_registered_last(): - """검증: 넓은 어댑터(static_html)가 좁은 어댑터보다 뒤에 있는가. - - 기대결과: 목록의 맨 끝. 앞에 있으면 http(s) 를 통째로 받는 static_html 이 - 네이버 플레이스 URL 까지 가로채 naver_place 가 영영 안 불린다.""" + """검증: 넓은 어댑터(static_html)가 좁은 어댑터보다 뒤에 있는가.""" assert adapter_ids()[-1] == "static_html", ( f"static_html 은 맨 뒤여야 한다: {adapter_ids()}" ) def test_static_html_never_touches_blocked_platforms(): - """검증: 수집 불가 결론이 난 플랫폼 URL 을 static_html 에 직접 물어본다. - - 기대결과: 전부 False. static_html 의 정당성은 '사장님이 확정한 자기 홈페이지만 본다' 에서 - 나오므로, 플랫폼 URL 이 흘러들어오면 정당성이 통째로 깨진다. 운영자가 실수로 넣어도 - 구조적으로 막혀야 한다(docs/DATA_SOURCE_RESEARCH.md).""" + """검증: 수집 불가 결론이 난 플랫폼 URL 을 static_html 에 직접 물어본다.""" from services.collector.static_html_adapter import StaticHtmlAdapter adapter = StaticHtmlAdapter() @@ -214,26 +175,13 @@ def test_static_html_never_touches_blocked_platforms(): def test_static_html_takes_owner_domains(): - """검증: 사장님 자체 홈페이지로 보이는 평범한 도메인. - - 기대결과: static_html 이 받는다. 숙박 표본의 76%가 자체 도메인을 쓰므로 - 여기서 놓치면 숙박 수집이 통째로 비어버린다.""" + """검증: 사장님 자체 홈페이지로 보이는 평범한 도메인.""" for url in ("https://gangmunstay.kr/rooms", "https://offinghouse.com/", "https://www.mulhoe.co.kr/"): assert get_adapter(url).id == "static_html", f"static_html 이 받지 않는다: {url}" def test_adapters_do_not_collect_llm_written_fields(): - """검증: **등록된 모든 어댑터**가 만드는 fact key 에 allow_llm=True 필드가 섞이는지. - - 기대결과: 없다. 소개문 계열은 수집하는 사실이 아니라 generator 가 쓰는 문장이다(절대규칙 7). - - ★ 실측 사고 (2026-08-31) — 이 테스트가 MockAdapter 만 보고 있어서 놓쳤다. - tour_api·naver_place 가 `intro` 를 수집했고, TourAPI overview 457자가 그대로 - VERIFIED 로 들어가 사장님 사이트의 '숙소 소개' 를 차지했다. Gemini 가 쓴 162자 - 소개문은 PENDING_OWNER 로 뒤에 밀려 영영 안 보였다. - 원문 그대로 싣는 것은 GEO 에서도 복제 콘텐츠라 손해다. - - 그래서 소스를 훑어 확인한다 — 어댑터가 늘어나도 같은 실수를 반복할 수 없게.""" + """검증: **등록된 모든 어댑터**가 만드는 fact key 에 allow_llm=True 필드가 섞이는지.""" from pathlib import Path banned = set() @@ -256,16 +204,12 @@ def test_adapters_do_not_collect_llm_written_fields(): @pytest.mark.parametrize("url", _REAL_URLS) def test_unregistered_sites_are_not_reachable(url): - """검증: 어댑터가 없는 사이트 URL 로 수집을 시도한다. - 기대결과: can_handle 이 False — 확정 링크 거르기 단계에서 크롤링 대상에 오르지 않는다.""" + """검증: 어댑터가 없는 사이트 URL 로 수집을 시도한다.""" assert REGISTRY.can_handle(url) is False def test_naver_place_hosts_are_handled(): - """검증: 사람이 실제로 공유하는 네이버 플레이스 주소들. - - 기대결과: 전부 naver_place 가 받는다. 하나라도 빠지면 사장님이 붙여넣은 주소가 - '어댑터없음' 으로 조용히 버려진다 — 실제로 map.naver.com 이 빠져서 그랬다.""" + """검증: 사람이 실제로 공유하는 네이버 플레이스 주소들.""" for url in ( "https://map.naver.com/p/entry/place/1133638931", "https://m.place.naver.com/accommodation/1133638931/home", @@ -276,8 +220,7 @@ def test_naver_place_hosts_are_handled(): def test_no_evasion_code_in_collector_package(): - """검증: 수집 패키지에 우회 코드가 들어왔는지 소스를 훑는다. - 기대결과: 캡차 우회·봇 탐지 우회·IP 회전 흔적이 없다(영구 금지, 검토 결과와 무관).""" + """검증: 수집 패키지에 우회 코드가 들어왔는지 소스를 훑는다.""" from pathlib import Path banned = ("captcha", "solve_captcha", "rotate_ip", "proxy_rotate", "stealth", "undetected") @@ -294,8 +237,7 @@ def test_no_evasion_code_in_collector_package(): # ── 출처 강제 ──────────────────────────────────────────────────────────── async def test_every_fact_and_media_carries_source_url(): - """검증: 수집 결과의 모든 fact·사진이 출처를 들고 있는지. - 기대결과: 전부 RawSource.url 과 같다 — 출처 없는 값은 fact 기록에서 거부된다.""" + """검증: 수집 결과의 모든 fact·사진이 출처를 들고 있는지.""" url = MockAdapter.url_for(PlaceCategory.LODGING, "pension-9") source = await MockAdapter().fetch(url) @@ -305,8 +247,7 @@ async def test_every_fact_and_media_carries_source_url(): def test_raw_source_stamps_source_url_on_bare_items(): - """검증: source_url 을 비운 채 항목을 넣어 RawSource 를 만든다. - 기대결과: 생성 시점에 출처가 찍힌다 — 어댑터가 깜빡해도 구조가 막는다.""" + """검증: source_url 을 비운 채 항목을 넣어 RawSource 를 만든다.""" source = RawSource( url="mock://lodging/x", adapter_id="mock", @@ -318,36 +259,31 @@ def test_raw_source_stamps_source_url_on_bare_items(): def test_raw_source_requires_url(): - """검증: url 없이 RawSource 를 만든다. - 기대결과: CollectError — 출처 없는 수집 결과는 존재할 수 없다.""" + """검증: url 없이 RawSource 를 만든다.""" with pytest.raises(CollectError): RawSource(url="", adapter_id="mock") def test_collected_media_requires_origin_url(): - """검증: origin_url 없이 사진을 담는다. - 기대결과: CollectError — 재게시 권리 판단(docs/DECISIONS.md 1-2)에 원본 URL 이 필요하다.""" + """검증: origin_url 없이 사진을 담는다.""" with pytest.raises(CollectError): CollectedMedia(origin_url=" ") def test_unit_scoped_fact_requires_unit_name(): - """검증: 단위 이름 없이 unit 스코프 fact 를 만든다. - 기대결과: CollectError — 어느 객실 값인지 모르면 저장할 수 없다.""" + """검증: 단위 이름 없이 unit 스코프 fact 를 만든다.""" with pytest.raises(CollectError): CollectedFact(key="standard_capacity", value="2", scope="unit") async def test_unit_names_are_listed_in_order(): - """검증: 수집된 단위 이름 목록. - 기대결과: 등록 순서대로 중복 없이 나온다 — place.units 시드에 그대로 쓴다.""" + """검증: 수집된 단위 이름 목록.""" source = await MockAdapter().fetch(MockAdapter.url_for(PlaceCategory.LODGING)) assert source.unit_names() == ["A동 스탠다드", "B동 복층"] async def test_failure_result_has_no_facts(): - """검증: 실패 결과(RawSource.failure)의 모양. - 기대결과: ok=False, error 존재, fact·사진 없음 — 실패를 성공으로 오인할 수 없다.""" + """검증: 실패 결과(RawSource.failure)의 모양.""" source = RawSource.failure("mock://lodging/1", "mock", "타임아웃") assert source.ok is False and source.error == "타임아웃" assert source.facts == [] and source.media == [] diff --git a/solution/backend/tests/test_config.py b/solution/backend/tests/test_config.py index 1cbeb29..b27c2ab 100644 --- a/solution/backend/tests/test_config.py +++ b/solution/backend/tests/test_config.py @@ -1,24 +1,17 @@ -"""설정 로딩 — 우선순위와 테스트 격리. - -우선순위: 실제 환경변수 > 레포 최상위 .env > config.{APP_ENV}.toml -.env 에 APP_ENV=local 이 들어 있어도 테스트는 반드시 test DB 를 봐야 한다 — -그 규칙이 깨지면 db_engine 픽스처의 TRUNCATE 가 dev DB 를 지운다. -""" +"""설정 로딩 — 우선순위와 테스트 격리.""" from config.server_configs import APP_ENV, external_api_config, google_oauth_config, main_db_config def test_tests_run_against_test_db(): - """검증: 테스트 실행 시 바라보는 DB. - 기대결과: APP_ENV=test, DB 이름에 'test' 포함 — .env 의 APP_ENV=local 에 끌려가지 않는다.""" + """검증: 테스트 실행 시 바라보는 DB.""" assert APP_ENV == "test", f"테스트가 APP_ENV={APP_ENV} 로 돈다 — .env 나 실행 환경을 확인할 것" assert "test" in main_db_config.name, f"테스트가 비-test DB('{main_db_config.name}')를 가리킨다" def test_external_api_keys_are_absent_in_tests(): - """검증: 테스트 환경의 외부 API 키. - 기대결과: 전부 빈 값 — 테스트가 실제 외부 API 를 때리지 않는다(수집 경로는 MockAdapter 로 검증).""" + """검증: 테스트 환경의 외부 API 키.""" for name in ("perplexity_api_key", "kakao_rest_api_key", "gemini_api_key", "tour_api_key"): # 값 자체는 출력하지 않는다(실키가 로그·CI 에 남지 않게). assert not getattr(external_api_config, name), f"{name} 이 테스트 환경에 설정돼 있다 — .env 가 새어 들어왔다" - # 구글 client_id 도 같은 규칙이다. 이게 새어 들어오면 구글 로그인 테스트가 진짜 JWKS 를 받으러 나간다. + # 구글 client_id 도 같은 규칙이다. assert not google_oauth_config.client_id, "GOOGLE_CLIENT_ID 가 테스트 환경에 설정돼 있다 — .env 가 새어 들어왔다" diff --git a/solution/backend/tests/test_copy_api.py b/solution/backend/tests/test_copy_api.py index 00f3828..749fbea 100644 --- a/solution/backend/tests/test_copy_api.py +++ b/solution/backend/tests/test_copy_api.py @@ -1,13 +1,4 @@ -"""소개문·FAQ 생성 — ★ LLM 은 사실을 만들지 않는다. - -이 잡이 절대 하면 안 되는 것: - - 미검증 fact 를 근거로 문장을 쓰는 것 (그 문장도 미검증이 된다) - - 근거 없이 생성하는 것 (그게 환각이다) - - 사람이 정정한 FAQ·소개문(CORRECTED)을 재생성이 덮어쓰는 것 - -★ 반대로 '생성물을 바로 노출하는 것' 은 이제 금지가 아니다(2026-09-10 결정). - 게이트는 입력 쪽에 있다 — 확인된 fact 로만 쓰고, 근거 없는 FAQ 는 저장되지 않는다. -""" +"""소개문·FAQ 생성 — ★ LLM 은 사실을 만들지 않는다.""" import uuid from sqlalchemy import text @@ -59,9 +50,7 @@ async def _faq_rows(db_engine, pid): async def test_copy_generates_intro_and_faq_as_published(auth_headers, client, db_engine, monkeypatch): - """검증: 소개문·FAQ 를 생성한다. - 기대결과: ★ 바로 노출값(VERIFIED) — 승인 단계를 두지 않는다(2026-09-10 결정). - 게이트는 앞에 있다: 입력이 확인된 fact 뿐이고 근거 없는 FAQ 는 저장되지 않는다.""" + """검증: 소개문·FAQ 를 생성한다.""" _patch(monkeypatch, _copy()) h = await auth_headers("u1") pid = await _place_with_facts(client, h) @@ -143,8 +132,7 @@ async def test_copy_job_cannot_be_read_by_another_owner(auth_headers, client, mo async def test_copy_refuses_without_facts_when_no_catalog(auth_headers, client, monkeypatch): - """검증: 확인된 fact 가 하나도 없는 **호텔**(펜션 카탈로그 제외 대상)에서 생성을 시도한다. - 기대결과: FAQ_UNGROUNDED — ★ 잡을 만들지 않는다. 쓸 근거도, 채울 공통 질문도 없다.""" + """검증: 확인된 fact 가 하나도 없는 **호텔**(펜션 카탈로그 제외 대상)에서 생성을 시도한다.""" monkeypatch.setattr(gemini_text, "is_configured", lambda: True) h = await auth_headers("u1") pid = (await client.post("/v1/place", headers=h, json={"name": "빈호텔", "category": 1})).json()["place"]["place_id"] @@ -156,9 +144,7 @@ async def test_copy_refuses_without_facts_when_no_catalog(auth_headers, client, async def test_unverified_facts_are_not_used_as_grounding(auth_headers, client, db_engine, monkeypatch): - """검증: 크롤링으로 들어온 미검증 fact 만 있는 펜션. - 기대결과: ★ 근거 0건으로 친다 — LLM 을 부르지 않는다(미검증 값으로 쓴 문장도 미검증이다). - FAQ 는 문의 안내로 20개를 채우고, 미검증인 체크인도 '답이 있는 주제' 로 치지 않는다.""" + """검증: 크롤링으로 들어온 미검증 fact 만 있는 펜션.""" monkeypatch.setattr(gemini_text, "is_configured", lambda: True) monkeypatch.setattr(gemini_text, "generate_copy", _must_not_call_llm) h = await auth_headers("u1") @@ -167,7 +153,7 @@ async def test_unverified_facts_are_not_used_as_grounding(auth_headers, client, await client.post(f"/v1/place/{pid}/fact", headers=h, json={ "key": "check_in_time", "value": "15:00", "source_type": SourceType.CRAWL.value, "source_url": "https://ota.test/1"}) - # CRAWL 은 이제 즉시 노출된다. 이 테스트는 과거에 남은 미검증 행을 명시적으로 준비한다. + # CRAWL 은 이제 즉시 노출된다. async with db_engine.begin() as connection: await connection.execute(text("UPDATE place_facts SET status = :s WHERE place_id = :p"), {"s": FactStatus.UNVERIFIED.value, "p": uuid.UUID(pid)}) @@ -187,8 +173,7 @@ async def test_unverified_facts_are_not_used_as_grounding(auth_headers, client, async def test_zero_facts_still_fill_twenty_without_api_key(auth_headers, client, db_engine, monkeypatch): - """검증: fact 0건 · GEMINI 키 없음인 펜션(분류 없음 — 스테이머뭄처럼). - 기대결과: ★ 잡이 만들어지고 FAQ 가 정확히 20건 — 채우기는 LLM 을 안 쓰므로 키가 필요 없다.""" + """검증: fact 0건 · GEMINI 키 없음인 펜션(분류 없음 — 스테이머뭄처럼).""" monkeypatch.setattr(gemini_text, "is_configured", lambda: False) monkeypatch.setattr(gemini_text, "generate_copy", _must_not_call_llm) h = await auth_headers("u1") @@ -207,8 +192,7 @@ async def test_zero_facts_still_fill_twenty_without_api_key(auth_headers, client async def test_faq_without_grounding_is_dropped(auth_headers, client, db_engine, monkeypatch): - """검증: 근거 fact 가 비어 있는 FAQ 가 생성물에 섞여 온다. - 기대결과: 저장하지 않고 반려 목록에 남는다.""" + """검증: 근거 fact 가 비어 있는 FAQ 가 생성물에 섞여 온다.""" _patch(monkeypatch, _copy(faqs=[ gemini_text.GeneratedFaq("체크인은?", "15시입니다.", ["check_in_time"]), gemini_text.GeneratedFaq("수영장 있나요?", "네 있습니다.", []), @@ -228,8 +212,7 @@ async def test_faq_without_grounding_is_dropped(auth_headers, client, db_engine, async def test_rejected_sentences_are_reported(auth_headers, client, monkeypatch): - """검증: 클라이언트가 반려한 문장이 있다. - 기대결과: 잡 result 에 사유와 함께 실린다 — 소개문이 왜 안 나왔는지 알 수 있어야 한다.""" + """검증: 클라이언트가 반려한 문장이 있다.""" _patch(monkeypatch, _copy(intro=None, rejected=[("수영장을 갖추고 있습니다", "fact 에 없는 시설 '수영장'")])) h = await auth_headers("u1") pid = await _place_with_facts(client, h) @@ -242,12 +225,7 @@ async def test_rejected_sentences_are_reported(auth_headers, client, monkeypatch async def test_regeneration_keeps_faq_the_owner_corrected(auth_headers, client, db_engine, monkeypatch): - """검증: 사장님이 고친 FAQ 가 있는 상태에서 재생성한다. - 기대결과: ★ 고친 FAQ 는 남는다 — 재생성이 사람의 판단을 덮어쓰면 안 된다. - - ★ '승인' 이 아니라 '정정' 으로 검증한다(2026-09-10). 생성분이 곧바로 VERIFIED 로 들어가면서 - status 만으로는 사람이 손댔는지 알 수 없게 됐다. 책임 주체를 적어 두는 자리는 - generated_by 이고, 사장님이 정정하면 faq_service 가 그 값을 OWNER 로 바꾼다.""" + """검증: 사장님이 고친 FAQ 가 있는 상태에서 재생성한다.""" _patch(monkeypatch, _copy()) h = await auth_headers("u1") pid = await _place_with_facts(client, h) @@ -278,11 +256,7 @@ _PUBLISHABLE = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value) async def test_copy_fills_faqs_to_target_without_duplicates(auth_headers, client, db_engine, monkeypatch): - """검증: 생성된 FAQ 가 1건뿐이고, 사장님이 직접 쓴 FAQ 가 1건 있다. - 기대결과: 노출 중 FAQ 가 정확히 20건 — - · 모자란 만큼은 문의 안내(TEMPLATE)로 채운다 - · fact 로 답할 수 있는 주제(체크인·체크아웃·취소·취사·반려동물)에는 문의 안내를 붙이지 않는다 - · 사장님이 이미 쓴 주제(보증금)도 다시 넣지 않는다""" + """검증: 생성된 FAQ 가 1건뿐이고, 사장님이 직접 쓴 FAQ 가 1건 있다.""" _patch(monkeypatch, _copy()) h = await auth_headers("u1") pid = await _place_with_facts(client, h) @@ -309,8 +283,7 @@ async def test_copy_fills_faqs_to_target_without_duplicates(auth_headers, client async def test_regeneration_replaces_fill_faqs(auth_headers, client, db_engine, monkeypatch): - """검증: 생성을 두 번 돌린다. - 기대결과: 노출 중 FAQ 는 여전히 20건 — 옛 문의 안내는 내려가고 다시 고른다(쌓이지 않는다).""" + """검증: 생성을 두 번 돌린다.""" _patch(monkeypatch, _copy()) h = await auth_headers("u1") pid = await _place_with_facts(client, h) @@ -324,8 +297,7 @@ async def test_regeneration_replaces_fill_faqs(auth_headers, client, db_engine, async def test_copy_requires_api_key(auth_headers, client, monkeypatch): - """검증: GEMINI_API_KEY 없이 생성을 시도한다. - 기대결과: GENERATOR_NOT_CONFIGURED — 잡을 만들지 않는다.""" + """검증: GEMINI_API_KEY 없이 생성을 시도한다.""" monkeypatch.setattr(gemini_text, "is_configured", lambda: False) h = await auth_headers("u1") pid = await _place_with_facts(client, h) diff --git a/solution/backend/tests/test_cost.py b/solution/backend/tests/test_cost.py index af1f057..51907a2 100644 --- a/solution/backend/tests/test_cost.py +++ b/solution/backend/tests/test_cost.py @@ -1,8 +1,4 @@ -"""사이트 1건 원가 미터 — $1 예산이 실제로 호출을 막는지. - -★ 이 테스트가 지키는 제품 제약: 업소 1곳의 사이트 생성 외부 API 비용 <= $1(1,400원). - 예산이 "문서에 적힌 목표" 로만 남지 않게, 가드가 실제로 예외를 던지는지 확인한다. -""" +"""사이트 1건 원가 미터 — $1 예산이 실제로 호출을 막는지.""" import pytest from common.cost import ( @@ -19,30 +15,26 @@ from common.cost import ( def test_budget_is_one_dollar(): - """검증: 예산 상한값. - 기대결과: 1,400원 = $1 — 환율 상수와 예산 상수가 어긋나지 않는다.""" + """검증: 예산 상한값.""" assert SITE_BUDGET_KRW == USD_KRW assert CostMeter().budget_krw == SITE_BUDGET_KRW def test_kakao_keyword_search_is_2won(): - """검증: 레포에 확정된 카카오 단가. - 기대결과: 키워드 검색 2원/건, 좌표 변환 0.5원/건 — 좌표가 4배 싸다.""" + """검증: 레포에 확정된 카카오 단가.""" assert estimate_krw(Provider.KAKAO, calls=1) == 2.0 assert estimate_krw(Provider.KAKAO, kakao_coord_calls=1) == 0.5 assert estimate_krw(Provider.KAKAO, calls=10) == 20.0 def test_free_providers_cost_nothing(): - """검증: 무료 API. - 기대결과: TourAPI·Open-Meteo 는 아무리 불러도 0원.""" + """검증: 무료 API.""" assert estimate_krw(Provider.TOUR_API, calls=100) == 0.0 assert estimate_krw(Provider.OPEN_METEO, calls=100) == 0.0 def test_guard_blocks_before_the_call(): - """검증: 예산을 넘기는 호출을 '하기 전' 에 막는가. - 기대결과: guard 가 BudgetExceeded — 호출측은 이 호출을 하지 않는다.""" + """검증: 예산을 넘기는 호출을 '하기 전' 에 막는가.""" meter = CostMeter(place_id=1) meter.charge(Provider.KAKAO, calls=699) # 1,398원 — 상한 코앞 assert meter.remaining_krw == pytest.approx(2.0) @@ -51,13 +43,11 @@ def test_guard_blocks_before_the_call(): with pytest.raises(BudgetExceeded): meter.guard(Provider.KAKAO, calls=2) # 4원 — 넘으므로 차단 - # ★ guard 는 확인만 한다. 막혔다고 이미 쓴 금액이 변하면 안 된다. + # guard 는 확인만 한다. assert meter.spent_krw == pytest.approx(1398.0) def test_charge_raises_after_exceeding_to_stop_the_next_call(): - """검증: 실측을 적었더니 예산을 넘긴 경우. - 기대결과: 금액은 그대로 누적하되 BudgetExceeded — 이미 나간 호출은 못 되돌리고 다음을 막는다.""" meter = CostMeter(place_id=2) with pytest.raises(BudgetExceeded): meter.charge(Provider.KAKAO, calls=1000) # 2,000원 — 한 번에 초과 @@ -66,8 +56,7 @@ def test_charge_raises_after_exceeding_to_stop_the_next_call(): def test_meter_tracks_spend_per_provider(): - """검증: 공급자별 누적. - 기대결과: 합계와 공급자별 금액이 일치하고, $ 환산이 맞는다.""" + """검증: 공급자별 누적.""" meter = CostMeter(place_id=3) meter.charge(Provider.KAKAO, calls=5) # 10원 meter.charge(Provider.PERPLEXITY, calls=1, tokens=3000) # 7 + 4.2 = 11.2원 @@ -79,8 +68,7 @@ def test_meter_tracks_spend_per_provider(): def test_unconfirmed_rates_block_a_real_batch(): - """검증: 추정 단가로 실배치를 돌리려 할 때. - 기대결과: UnconfirmedRate — 추정치 위에 세운 '예산 안' 결론을 막는다.""" + """검증: 추정 단가로 실배치를 돌리려 할 때.""" assert_rates_confirmed( Provider.KAKAO, Provider.PERPLEXITY, Provider.TOUR_API, Provider.OPEN_METEO ) # 확정분은 통과 @@ -89,8 +77,7 @@ def test_unconfirmed_rates_block_a_real_batch(): def test_rate_table_declares_its_source(): - """검증: 단가표의 출처 표기. - 기대결과: 확정 단가에는 출처가 적혀 있다 — 어디서 온 숫자인지 추적 가능해야 한다.""" + """검증: 단가표의 출처 표기.""" for provider, rate in RATES.items(): if rate.confirmed: assert rate.source, f"{provider.value} 단가가 확정인데 출처가 비어 있다" diff --git a/solution/backend/tests/test_extract_grounding.py b/solution/backend/tests/test_extract_grounding.py index 2ce0304..b5dde82 100644 --- a/solution/backend/tests/test_extract_grounding.py +++ b/solution/backend/tests/test_extract_grounding.py @@ -1,14 +1,4 @@ -"""추출 검증 게이트 — "AI 가 원문에서 찾아온 것인가, 지어낸 것인가". - -URL·붙여넣기 추출(services/external/gemini_extract.py)의 안전장치는 프롬프트가 아니라 -services/grounding/extract.py 의 evidence 대조다. 프롬프트로 "지어내지 마라" 를 아무리 적어도 -그건 부탁이지 보장이 아니다. - -**이 파일이 깨지면 추출 경로를 끄는 것이 맞다.** 통과 못 한 값이 fact 후보로 올라가면 -사장님이 확인 화면에서 "네" 를 누르는 순간 환각이 발행본에 실린다. - -DB 도 네트워크도 쓰지 않는 순수 단위 테스트다. -""" +"""추출 검증 게이트 — "AI 가 원문에서 찾아온 것인가, 지어낸 것인가".""" import pytest from common.category_schema import get_schema @@ -35,8 +25,7 @@ def _one(row: dict, *, source: str = _SOURCE, schema=_LODGING): # ── 통과해야 하는 것 ────────────────────────────────────────────────────── def test_accepts_value_copied_verbatim(): - """검증: 원문 표기 그대로 옮긴 값. - 기대결과: 통과. 이게 추출의 정상 경로다.""" + """검증: 원문 표기 그대로 옮긴 값.""" passed, rejected = _one({ "key": "check_in_time", "value": "오후 3시", "evidence": "체크인은 오후 3시, 체크아웃은 오전 11시입니다.", @@ -47,10 +36,7 @@ def test_accepts_value_copied_verbatim(): def test_accepts_number_written_with_comma_in_source(): - """검증: 원문은 "20,000원" 인데 number 필드라 값은 "20000" 으로 온다. - - 기대결과: 통과. ★ 실측 회귀 — loose() 가 없던 때 속초항아리물회 홈페이지에서 - 메뉴 가격 5건이 통째로 '고쳐 쓴 값' 으로 반려됐다. 쉼표는 같은 수를 다르게 적은 것뿐이다.""" + """검증: 원문은 "20,000원" 인데 number 필드라 값은 "20000" 으로 온다.""" passed, rejected = _one({ "key": "bbq_fee", "value": "20000", "evidence": "바비큐장은 별도 요금 20,000원으로 이용하실 수 있습니다.", @@ -60,8 +46,7 @@ def test_accepts_number_written_with_comma_in_source(): def test_accepts_unit_scope_with_name(): - """검증: 객실 단위 fact 가 unit_name 을 달고 온다. - 기대결과: 통과하고 scope/unit_name 이 보존된다 — ensure_units 가 이걸로 객실을 만든다.""" + """검증: 객실 단위 fact 가 unit_name 을 달고 온다.""" passed, rejected = _one({ "key": "standard_capacity", "value": "2", "unit_name": "오션뷰룸", "evidence": "오션뷰룸은 기준 2인, 최대 4인까지 이용 가능합니다.", @@ -71,8 +56,7 @@ def test_accepts_unit_scope_with_name(): def test_accepts_negative_bool_when_source_is_negative(): - """검증: 원문이 "불가" 이고 값도 false. - 기대결과: 통과. '없다' 도 사실이다 — 명시된 부정은 담는다.""" + """검증: 원문이 "불가" 이고 값도 false.""" passed, rejected = _one({ "key": "pet_allowed", "value": "false", "evidence": "반려동물 동반은 불가합니다.", @@ -83,8 +67,7 @@ def test_accepts_negative_bool_when_source_is_negative(): # ── 막아야 하는 것 (여기가 본론) ────────────────────────────────────────── def test_blocks_evidence_not_in_source(): - """검증: 원문에 없는 문장을 근거로 들이민다(순수 환각). - 기대결과: 반려. **이 검사가 이 게이트의 존재 이유다.**""" + """검증: 원문에 없는 문장을 근거로 들이민다(순수 환각).""" passed, rejected = _one({ "key": "breakfast", "value": "true", "evidence": "조식은 오전 8시부터 제공됩니다.", @@ -94,10 +77,7 @@ def test_blocks_evidence_not_in_source(): def test_blocks_flipped_bool_polarity(): - """검증: 근거는 "불가" 인데 값을 true 로 올린다. - - 기대결과: 반려. ★ 부호가 뒤집히면 "반려동물 동반 불가" 가 "동반 가능" 이 된다 — - critical 항목이라 그대로 예약 클레임이다.""" + """검증: 근거는 "불가" 인데 값을 true 로 올린다.""" passed, rejected = _one({ "key": "pet_allowed", "value": "true", "evidence": "반려동물 동반은 불가합니다.", @@ -107,10 +87,7 @@ def test_blocks_flipped_bool_polarity(): def test_blocks_converted_value(): - """검증: 원문은 "오후 3시" 인데 값을 "15:00" 으로 환산해 올린다. - - 기대결과: 반려. 환산은 옮겨 적기가 아니다 — 한 번 허용하면 단위·통화·시간대까지 - 모델이 손대기 시작하고, 그 오차는 원문 대조로 잡히지 않는다.""" + """검증: 원문은 "오후 3시" 인데 값을 "15:00" 으로 환산해 올린다.""" passed, rejected = _one({ "key": "check_in_time", "value": "15:00", "evidence": "체크인은 오후 3시, 체크아웃은 오전 11시입니다.", @@ -120,8 +97,7 @@ def test_blocks_converted_value(): def test_blocks_key_outside_category_schema(): - """검증: 업종 스키마에 없는 key. - 기대결과: 반려. fact 기록 단계에서도 거부되지만, 여기서 걸러야 반려 사유가 남는다.""" + """검증: 업종 스키마에 없는 key.""" passed, rejected = _one({ "key": "michelin_star", "value": "2", "evidence": "강문스테이는 강릉 경포해변 도보 3분 거리에 있는 독채 펜션입니다.", @@ -131,8 +107,6 @@ def test_blocks_key_outside_category_schema(): def test_blocks_unit_fact_without_unit_name(): - """검증: unit 스코프 필드인데 unit_name 이 비었다. - 기대결과: 반려. 어느 객실 것인지 모르는 값은 화면에 붙일 자리가 없다.""" passed, rejected = _one({ "key": "max_capacity", "value": "4", "evidence": "오션뷰룸은 기준 2인, 최대 4인까지 이용 가능합니다.", @@ -142,8 +116,7 @@ def test_blocks_unit_fact_without_unit_name(): def test_blocks_non_numeric_value_in_number_field(): - """검증: number 필드에 "최대 4인" 같은 문장이 들어온다. - 기대결과: 반려. 화면·JSON-LD 가 숫자로 다루는 자리라 문자열이 섞이면 렌더가 깨진다.""" + """검증: number 필드에 "최대 4인" 같은 문장이 들어온다.""" passed, rejected = _one({ "key": "max_capacity", "value": "최대 4인", "unit_name": "오션뷰룸", "evidence": "오션뷰룸은 기준 2인, 최대 4인까지 이용 가능합니다.", @@ -154,17 +127,14 @@ def test_blocks_non_numeric_value_in_number_field(): @pytest.mark.parametrize("evidence", ["", "3", "펜션"]) def test_blocks_evidence_too_short_to_verify(evidence): - """검증: 근거가 너무 짧아 원문 어디에나 있는 경우. - - 기대결과: 반려. "3" 은 원문 아무 데나 있으므로 대조가 통과해도 아무것도 보장하지 못한다.""" + """검증: 근거가 너무 짧아 원문 어디에나 있는 경우.""" passed, rejected = _one({"key": "check_in_time", "value": "오후 3시", "evidence": evidence}) assert not passed assert len(evidence) < MIN_EVIDENCE def test_blocks_duplicate_key_for_same_unit(): - """검증: 같은 (key, unit) 이 두 번 온다. - 기대결과: 첫 건만 통과. 같은 자리에 두 값이 들어가면 어느 쪽이 발행될지 알 수 없다.""" + """검증: 같은 (key, unit) 이 두 번 온다.""" rows = [ {"key": "check_in_time", "value": "오후 3시", "evidence": "체크인은 오후 3시, 체크아웃은 오전 11시입니다."}, {"key": "check_in_time", "value": "오전 11시", "evidence": "체크인은 오후 3시, 체크아웃은 오전 11시입니다."}, @@ -176,10 +146,7 @@ def test_blocks_duplicate_key_for_same_unit(): # ── 정규화가 부호를 지우지 않는가 ───────────────────────────────────────── def test_normalization_never_drops_negation_words(): - """검증: 대조용 정규화가 "불가" 같은 부정어를 지우지 않는가. - - 기대결과: 남아 있다. ★ 정규화가 글자를 지우기 시작하면 부정이 긍정으로 뒤집힌다 — - normalize/loose 는 공백·문장부호·쉼표만 건드려야 한다.""" + """검증: 대조용 정규화가 "불가" 같은 부정어를 지우지 않는가.""" for text in ("반려동물 동반은 불가합니다.", "취사 불가능", "주차 없음"): for fn in (normalize, loose): out = fn(text) @@ -187,17 +154,14 @@ def test_normalization_never_drops_negation_words(): def test_loose_only_strips_commas_and_spaces(): - """검증: loose() 가 쉼표·공백 외의 글자를 건드리지 않는가. - 기대결과: 숫자와 단위가 그대로 남는다.""" + """검증: loose() 가 쉼표·공백 외의 글자를 건드리지 않는가.""" assert loose("20,000 원") == "20000원" assert loose("최대 4인") == "최대4인" # ── 업종이 다르면 통과 여부도 달라야 한다 ───────────────────────────────── def test_same_key_gated_by_category_schema(): - """검증: 숙박 전용 key 를 음식점 스키마로 검증한다. - - 기대결과: 반려. 업종 스키마가 유일한 출처이므로, 업종이 바뀌면 허용 key 도 바뀐다.""" + """검증: 숙박 전용 key 를 음식점 스키마로 검증한다.""" row = {"key": "check_in_time", "value": "오후 3시", "evidence": "체크인은 오후 3시, 체크아웃은 오전 11시입니다."} ok_lodging, _ = _one(row) diff --git a/solution/backend/tests/test_fact_api.py b/solution/backend/tests/test_fact_api.py index 1365bf6..2ec2fce 100644 --- a/solution/backend/tests/test_fact_api.py +++ b/solution/backend/tests/test_fact_api.py @@ -1,11 +1,4 @@ -"""facts 도메인 e2e — 생성 / 업데이트(재수집) / 수정 세 프로세스. - -이 도메인이 지켜야 하는 것: - 생성 : 자동 수집 → 후보 → 사람 승인 → 노출. 미검증은 절대 사이트에 안 나간다 - 업데이트: ★ 재수집이 노출 중인 사실을 밀어내지 않는다 - 값이 같으면 검증 유지, 다르면 후보로 쌓이고 사람이 승인해야 교체된다 - 수정 : 사람이 직접 넣으면 즉시 노출값 교체 + 재빌드 대상 표시 -""" +"""facts 도메인 e2e — 생성 / 업데이트(재수집) / 수정 세 프로세스.""" from common.enums import ErrorType, FactStatus, FactWriteOutcome, PlaceCategory, SourceType from services.external import gemini_text as gt @@ -37,8 +30,7 @@ async def _facts(client, headers, pid, **params): # ── 입력 검증 ──────────────────────────────────────────────────────────── async def test_schema_endpoint_returns_category_fields(auth_headers, client): - """검증: 숙박 사업장의 fact 스키마 조회. - 기대결과: 업종=lodging, 체크인시간이 critical=True 로 내려온다(관리 화면 폼 생성용).""" + """검증: 숙박 사업장의 fact 스키마 조회.""" h = await auth_headers("u1") pid = await _verified_place(client, h) @@ -50,8 +42,7 @@ async def test_schema_endpoint_returns_category_fields(auth_headers, client): async def test_key_outside_category_schema_is_rejected(auth_headers, client): - """검증: 카페 사업장에 숙박 전용 key(check_in_time)를 기록한다. - 기대결과: FACT_INVALID_KEY — 업종에 없는 필드는 저장되지 않는다.""" + """검증: 카페 사업장에 숙박 전용 key(check_in_time)를 기록한다.""" h = await auth_headers("u1") pid = await _verified_place(client, h, PlaceCategory.CAFE, kakao="c1") @@ -60,8 +51,7 @@ async def test_key_outside_category_schema_is_rejected(auth_headers, client): async def test_non_owner_source_requires_source_url(auth_headers, client): - """검증: crawl 출처인데 source_url 없이 기록한다. - 기대결과: FACT_SOURCE_REQUIRED — 출처 없는 사실은 받지 않는다.""" + """검증: crawl 출처인데 source_url 없이 기록한다.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="s1") @@ -71,8 +61,7 @@ async def test_non_owner_source_requires_source_url(auth_headers, client): async def test_llm_cannot_write_non_sentence_fields(auth_headers, client): - """검증: LLM 출처로 체크인시간(allow_llm=False)을 기록한다. - 기대결과: 거부 — ★ LLM 은 사실을 만들지 않는다. 소개문(intro)에는 쓸 수 있다.""" + """검증: LLM 출처로 체크인시간(allow_llm=False)을 기록한다.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="l1") @@ -84,9 +73,7 @@ async def test_llm_cannot_write_non_sentence_fields(auth_headers, client): async def test_llm_sentence_is_published_without_approval(auth_headers, client): - """검증: LLM 이 소개문(allow_llm)을 기록한다. - 기대결과: ★ 후보가 아니라 바로 노출값(VERIFIED) — 2026-09-10 결정. - 소개문은 사실이 아니라 이미 승인된 사실로 쓴 문장이라 승인을 두 번 받을 이유가 없다.""" + """검증: LLM 이 소개문(allow_llm)을 기록한다.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="l2") @@ -100,11 +87,7 @@ async def test_llm_sentence_is_published_without_approval(auth_headers, client): async def test_llm_cannot_overwrite_corrected_sentence(auth_headers, client): - """검증: 사장님이 고친 소개문(CORRECTED)을 LLM 이 다시 쓴다. - 기대결과: 노출값은 사장님 문장 그대로 — ★ 절대규칙 6. 새 문장은 후보로만 남는다. - - ★ 이 잠금은 지금까지 '자동 출처는 노출값 경로로 못 간다'는 구조가 대신 지켜 줬다. - LLM 만 그 경로를 지나가게 되면서 잠금을 명시적으로 다시 걸어야 했다.""" + """검증: 사장님이 고친 소개문(CORRECTED)을 LLM 이 다시 쓴다.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="l3") @@ -127,13 +110,7 @@ async def test_llm_cannot_overwrite_corrected_sentence(auth_headers, client): # ── 생성 프로세스 ───────────────────────────────────────────────────────── async def test_crawled_fact_publishes_immediately(auth_headers, client): - """검증: 크롤링으로 처음 들어온 값. - 기대결과: 빈 자리이므로 **바로 노출값**이 된다(2026-09-14 결정). - - ★ 예전에는 UNVERIFIED 후보로 남겼다. 수집이 끝난 뒤에야 오는 값이라 사장님이 승인할 - 화면을 이미 지나가 있었고, 확인된 사실이 하나도 없는 채로 발행 게이트에 걸렸다. - ★ 승인 이력과는 verified_by 로 구별한다 — 자동 노출은 비어 있다. 사람이 본 적 없다는 - 사실 자체를 지우면 나중에 '누가 확인했나' 를 되짚을 수 없다.""" + """검증: 크롤링으로 처음 들어온 값.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="p1") @@ -149,11 +126,7 @@ async def test_crawled_fact_publishes_immediately(auth_headers, client): async def test_approving_candidate_publishes_it(auth_headers, client): - """검증: 후보를 VERIFIED 로 승인한다. - 기대결과: verified_at 이 찍히고 그 값이 노출값 자리를 가져간다. - - ★ 후보는 사장님이 직접 넣은 값 위에 다른 수집값이 올 때 생긴다 — 크롤링끼리는 - 뒤에 온 값이 앞의 값을 바로 대체하므로(위 테스트) 여기서 사람 입력을 먼저 둔다.""" + """검증: 후보를 VERIFIED 로 승인한다.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="p2") await _own(client, h, pid, "check_in_time", "15:00") @@ -168,10 +141,7 @@ async def test_approving_candidate_publishes_it(auth_headers, client): async def test_illegal_transition_is_rejected(auth_headers, client): - """검증: UNVERIFIED → CORRECTED 처럼 전이표에 없는 이동. - 기대결과: FACT_INVALID_TRANSITION — 확인을 건너뛴 '정정본'은 만들 수 없다. - - ★ UNVERIFIED 는 이제 공식 API 수집이 빈 자리에 넣을 때 생긴다(크롤링은 바로 노출).""" + """검증: UNVERIFIED → CORRECTED 처럼 전이표에 없는 이동.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="p3") fid = (await _crawl(client, h, pid, "check_in_time", "15:00", SourceType.API))["fact"]["fact_id"] @@ -183,8 +153,7 @@ async def test_illegal_transition_is_rejected(auth_headers, client): # ── 업데이트(재수집) 프로세스 ★ ─────────────────────────────────────────── async def test_recrawl_same_value_keeps_verification(auth_headers, client): - """검증: 확인된 값과 똑같은 값을 재수집한다. - 기대결과: REFRESHED — 검증이 유지되고 사이트에서 사실이 사라지지 않는다.""" + """검증: 확인된 값과 똑같은 값을 재수집한다.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="r1") fid = (await _crawl(client, h, pid, "check_in_time", "15:00"))["fact"]["fact_id"] @@ -198,11 +167,7 @@ async def test_recrawl_same_value_keeps_verification(auth_headers, client): async def test_recrawl_changed_value_keeps_site_and_queues_candidate(auth_headers, client): - """검증: 사장님이 넣은 값과 다른 값을 재수집한다(OTA 가 바뀐 경우). - 기대결과: ★ 노출값은 그대로 살아 있고, 새 값은 PENDING_OWNER 후보로만 쌓인다. - - ★ 자동 수집이 사람이 넣은 값을 밀어내지 못한다는 것이 이 테스트의 본체다. - 크롤링이 직전 크롤링 값을 대체하는 것과는 다른 얘기다(위 즉시 노출 결정).""" + """검증: 사장님이 넣은 값과 다른 값을 재수집한다(OTA 가 바뀐 경우).""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="r2") await _own(client, h, pid, "check_in_time", "15:00") @@ -219,8 +184,7 @@ async def test_recrawl_changed_value_keeps_site_and_queues_candidate(auth_header async def test_approving_candidate_replaces_published_value(auth_headers, client): - """검증: 쌓인 후보를 사람이 승인한다(업데이트의 마지막 단계). - 기대결과: 후보가 노출값이 되고 옛 값은 EXPIRED 이력으로 내려간다. 노출값은 여전히 1건.""" + """검증: 쌓인 후보를 사람이 승인한다(업데이트의 마지막 단계).""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="r3") await _own(client, h, pid, "check_in_time", "15:00") @@ -237,8 +201,7 @@ async def test_approving_candidate_replaces_published_value(auth_headers, client async def test_repeated_recrawl_does_not_pile_up_candidates(auth_headers, client): - """검증: 같은 출처로 재수집을 세 번 반복한다. - 기대결과: 후보가 쌓이지 않고 하나가 갱신된다(사람 확인 큐가 중복으로 넘치지 않게).""" + """검증: 같은 출처로 재수집을 세 번 반복한다.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="r4") await _own(client, h, pid, "check_in_time", "15:00") @@ -254,8 +217,7 @@ async def test_repeated_recrawl_does_not_pile_up_candidates(auth_headers, client async def test_recrawl_cannot_overwrite_corrected_value(auth_headers, client): - """검증: 사람이 정정한 값(CORRECTED)에 다른 값이 재수집된다. - 기대결과: ★ 노출값은 정정본 그대로. 크롤링 값은 버려지지 않고 후보로 남아 불일치가 보인다.""" + """검증: 사람이 정정한 값(CORRECTED)에 다른 값이 재수집된다.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="r5") fid = (await _crawl(client, h, pid, "check_in_time", "15:00"))["fact"]["fact_id"] @@ -277,8 +239,7 @@ async def test_recrawl_cannot_overwrite_corrected_value(auth_headers, client): # ── 수정 프로세스 ──────────────────────────────────────────────────────── async def test_owner_input_publishes_immediately(auth_headers, client): - """검증: 사람이 직접 값을 넣는다. - 기대결과: 즉시 노출값이 된다 — 넣은 사람이 곧 출처이자 책임 주체다.""" + """검증: 사람이 직접 값을 넣는다.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="o1") @@ -289,8 +250,7 @@ async def test_owner_input_publishes_immediately(auth_headers, client): async def test_owner_can_overwrite_own_value(auth_headers, client): - """검증: 사람이 자기가 넣은 값을 다시 고친다. - 기대결과: 노출값이 교체되고(PUBLISHED_REPLACED) 옛 값은 이력으로 내려간다.""" + """검증: 사람이 자기가 넣은 값을 다시 고친다.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="o2") await _own(client, h, pid, "check_in_time", "15:00") @@ -303,8 +263,7 @@ async def test_owner_can_overwrite_own_value(auth_headers, client): async def test_publishing_marks_place_for_rebuild(auth_headers, client): - """검증: 노출값이 바뀐 뒤 사업장의 content_updated_at. - 기대결과: 값이 찍힌다 — ★ 이 사업장만 재빌드하면 된다는 표시(전체 재빌드 금지).""" + """검증: 노출값이 바뀐 뒤 사업장의 content_updated_at.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="o3") assert (await client.get(f"/v1/place/{pid}", headers=h)).json()["place"].get("content_updated_at") is None @@ -315,11 +274,7 @@ async def test_publishing_marks_place_for_rebuild(auth_headers, client): async def test_crawl_only_does_not_mark_rebuild(auth_headers, client): - """검증: 크롤링이 후보만 쌓았을 때 재빌드 표시. - 기대결과: 안 찍힌다 — 사이트에 나가는 내용이 안 바뀌었으니 재빌드가 필요 없다. - - ★ 노출값을 바꾼 수집은 반대로 찍혀야 한다(그게 needs_rebuild 의 뜻이다). - 그래서 사장님 값 위에 올라온 **후보** 경로로 확인한다.""" + """검증: 크롤링이 후보만 쌓았을 때 재빌드 표시.""" h = await auth_headers("u1") pid = await _verified_place(client, h, kakao="o4") await _own(client, h, pid, "check_in_time", "15:00") @@ -331,8 +286,7 @@ async def test_crawl_only_does_not_mark_rebuild(auth_headers, client): async def test_facts_are_scoped_to_owner(auth_headers, client): - """검증: 다른 사장님 계정으로 남의 사업장 fact 를 조회한다. - 기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 fact 도 안 보인다.""" + """검증: 다른 사장님 계정으로 남의 사업장 fact 를 조회한다.""" h1 = await auth_headers("o1") pid = await _verified_place(client, h1, kakao="p6") @@ -343,8 +297,7 @@ async def test_facts_are_scoped_to_owner(auth_headers, client): # ── 캔버스 미리보기 요약 ──────────────────────────────────────────────────── async def test_long_intro_gets_ai_summary_in_list(auth_headers, client, monkeypatch): - """검증: intro 가 요약 임계치(200자)를 넘는다. - 기대결과: /fact/list 응답의 summary 에 축약문이 실리고, value(원문)는 그대로 남는다.""" + """검증: intro 가 요약 임계치(200자)를 넘는다.""" async def _fake_summarize(text, **_kwargs): return "축약된 소개문입니다." monkeypatch.setattr(gt, "summarize_text", _fake_summarize) @@ -361,8 +314,7 @@ async def test_long_intro_gets_ai_summary_in_list(auth_headers, client, monkeypa async def test_short_intro_has_no_summary(auth_headers, client, monkeypatch): - """검증: intro 가 임계치보다 짧다. - 기대결과: summary 가 비어 있고, 요약 API 는 아예 불리지 않는다.""" + """검증: intro 가 임계치보다 짧다.""" calls = [] async def _fake_summarize(text, **_kwargs): calls.append(text) @@ -380,8 +332,7 @@ async def test_short_intro_has_no_summary(auth_headers, client, monkeypatch): async def test_non_intro_fact_never_gets_summary(auth_headers, client, monkeypatch): - """검증: 길어도 intro/room_intro 가 아닌 key(예: cancel_policy). - 기대결과: 대상 key 가 아니므로 summary 를 만들지 않는다.""" + """검증: 길어도 intro/room_intro 가 아닌 key(예: cancel_policy).""" calls = [] async def _fake_summarize(text, **_kwargs): calls.append(text) diff --git a/solution/backend/tests/test_fact_schema.py b/solution/backend/tests/test_fact_schema.py index e4ff3d3..b83a69b 100644 --- a/solution/backend/tests/test_fact_schema.py +++ b/solution/backend/tests/test_fact_schema.py @@ -1,10 +1,4 @@ -"""fact 테이블 제약 — '사이트에 나가는 값은 (사업장, 단위, key) 당 1건' 이 DB 레벨에서 지켜지는지. - -유니크는 **노출 상태(VERIFIED·CORRECTED)에만** 걸린다. - - 걸어야 하는 이유: 안 걸면 체크인 시간이 15시/16시 두 값으로 동시에 노출된다. - - 활성 전체에 걸면 안 되는 이유: 재수집이 올 때마다 확인된 노출값을 밀어내야 하고, - 그 순간 사이트에서 사실이 사라진다. 후보(UNVERIFIED·PENDING_OWNER)는 공존해야 한다. -""" +"""fact 테이블 제약 — '사이트에 나가는 값은 (사업장, 단위, key) 당 1건' 이 DB 레벨에서 지켜지는지.""" import uuid import pytest @@ -55,8 +49,7 @@ async def _insert_fact(db_engine, place_id, key, value, status, unit_id=None): async def test_published_fact_is_unique_per_place_and_key(db_engine, owner_id): - """검증: 같은 사업장·같은 key 로 노출 상태 fact 를 두 번 넣는다. - 기대결과: 두 번째 INSERT 가 유니크 인덱스에 막힌다(체크인 시간이 두 값으로 갈라지지 않는다).""" + """검증: 같은 사업장·같은 key 로 노출 상태 fact 를 두 번 넣는다.""" place_id = await _seed_place(db_engine, owner_id) await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED) @@ -65,8 +58,7 @@ async def test_published_fact_is_unique_per_place_and_key(db_engine, owner_id): async def test_candidates_coexist_with_published_value(db_engine, owner_id): - """검증: 노출값이 있는 상태에서 재수집 후보를 여러 건 넣는다. - 기대결과: 전부 공존한다 — ★ 재수집이 노출 중인 사실을 밀어내지 않는다.""" + """검증: 노출값이 있는 상태에서 재수집 후보를 여러 건 넣는다.""" place_id = await _seed_place(db_engine, owner_id) await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED) await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.PENDING_OWNER) @@ -81,8 +73,7 @@ async def test_candidates_coexist_with_published_value(db_engine, owner_id): async def test_rejected_fact_frees_the_key(db_engine, owner_id): - """검증: 기존 값을 REJECTED 로 내린 뒤 같은 key 를 새로 노출한다. - 기대결과: 통과 — 틀린 값은 이력으로 남고, 새 값이 노출 자리를 차지한다.""" + """검증: 기존 값을 REJECTED 로 내린 뒤 같은 key 를 새로 노출한다.""" place_id = await _seed_place(db_engine, owner_id) await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.REJECTED) await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.VERIFIED) @@ -96,16 +87,14 @@ async def test_rejected_fact_frees_the_key(db_engine, owner_id): async def test_expired_fact_frees_the_key(db_engine, owner_id): - """검증: 유효기간이 지나 EXPIRED 로 내린 값과 새 수집값의 공존. - 기대결과: 통과 — EXPIRED 도 유니크에서 빠진다.""" + """검증: 유효기간이 지나 EXPIRED 로 내린 값과 새 수집값의 공존.""" place_id = await _seed_place(db_engine, owner_id) await _insert_fact(db_engine, place_id, "cancel_policy", "구 규정", FactStatus.EXPIRED) await _insert_fact(db_engine, place_id, "cancel_policy", "새 규정", FactStatus.VERIFIED) async def test_same_key_allowed_across_units(db_engine, owner_id): - """검증: 객실이 다르면 같은 key 를 각각 가질 수 있는지. - 기대결과: 통과 — A동·B동이 각자의 기준 인원을 갖는다.""" + """검증: 객실이 다르면 같은 key 를 각각 가질 수 있는지.""" place_id = await _seed_place(db_engine, owner_id) unit_a, unit_b = uuid.uuid4(), uuid.uuid4() async with db_engine.begin() as conn: @@ -123,8 +112,7 @@ async def test_same_key_allowed_across_units(db_engine, owner_id): async def test_unit_fact_and_place_fact_are_separate(db_engine, owner_id): - """검증: 같은 key 를 사업장 단위와 객실 단위로 동시에 갖는 경우. - 기대결과: 통과 — 부분 인덱스가 unit_id NULL 여부로 갈라져 있다.""" + """검증: 같은 key 를 사업장 단위와 객실 단위로 동시에 갖는 경우.""" place_id = await _seed_place(db_engine, owner_id) unit_id = uuid.uuid4() async with db_engine.begin() as conn: @@ -137,16 +125,14 @@ async def test_unit_fact_and_place_fact_are_separate(db_engine, owner_id): def test_only_verified_and_corrected_are_publishable(): - """검증: 노출 가능 상태 집합(절대규칙 1). - 기대결과: VERIFIED·CORRECTED 뿐. 미검증·반려·만료는 절대 사이트에 나가지 않는다.""" + """검증: 노출 가능 상태 집합(절대규칙 1).""" assert PUBLISHABLE_FACT_STATUSES == {FactStatus.VERIFIED, FactStatus.CORRECTED} for status in (FactStatus.UNVERIFIED, FactStatus.PENDING_OWNER, FactStatus.REJECTED, FactStatus.EXPIRED): assert status not in PUBLISHABLE_FACT_STATUSES def test_corrected_is_locked_against_auto_update(): - """검증: 사람이 고친 값이 잠기는지(절대규칙 6). - 기대결과: CORRECTED 는 잠금 상태이고, 전이표에서 자동 갱신 경로(UNVERIFIED 등)로 못 돌아간다.""" + """검증: 사람이 고친 값이 잠기는지(절대규칙 6).""" assert FactStatus.CORRECTED in LOCKED_FACT_STATUSES allowed = FACT_STATUS_TRANSITIONS[FactStatus.CORRECTED] assert FactStatus.UNVERIFIED not in allowed, "자동 수집이 사장님 수정본을 덮어쓸 수 있으면 안 된다" @@ -154,8 +140,7 @@ def test_corrected_is_locked_against_auto_update(): def test_transition_table_covers_every_status(): - """검증: 전이표가 모든 상태를 다루는지. - 기대결과: 6개 상태 전부 키로 존재하고, 목적지도 전부 유효한 FactStatus.""" + """검증: 전이표가 모든 상태를 다루는지.""" assert set(FACT_STATUS_TRANSITIONS) == set(FactStatus) for src, dests in FACT_STATUS_TRANSITIONS.items(): assert dests, f"{src.name}: 나갈 수 있는 상태가 없다" diff --git a/solution/backend/tests/test_faq_api.py b/solution/backend/tests/test_faq_api.py index b469cee..7935500 100644 --- a/solution/backend/tests/test_faq_api.py +++ b/solution/backend/tests/test_faq_api.py @@ -1,11 +1,4 @@ -"""FAQ 조회·승인 e2e. - -이 도메인이 지켜야 하는 것: - - LLM 이 만든 FAQ 는 ★ 사람이 승인하기 전엔 사이트에 안 나간다(FAQPage JSON-LD 에도 안 실린다) - - 상태 전이는 fact 와 같은 표(FACT_STATUS_TRANSITIONS)만 따른다 - - 고쳐서 승인한 FAQ(CORRECTED)는 잠긴다 — 재생성이 사장님 문구를 덮어쓰지 못한다 - - 사장님이 직접 쓴 FAQ 는 바로 노출값이다(사람이 곧 출처) -""" +"""FAQ 조회·승인 e2e.""" import uuid from sqlalchemy import text @@ -19,10 +12,7 @@ async def _place(client, headers, name="FAQ펜션"): async def _seed_generated_faq(db_engine, pid, question="체크인은 몇 시인가요?", answer="15시입니다.", order=0): - """LLM 출처 · UNVERIFIED 인 FAQ 한 건. - - ★ 2026-09-10 부터 COPY 잡은 VERIFIED 로 넣는다. 이 상태는 그 전에 생성된 행과 - 사장님이 반려·정정을 거치며 지나가는 자리다 — 상태 의미는 그대로 지켜져야 하므로 남긴다.""" + """LLM 출처 · UNVERIFIED 인 FAQ 한 건.""" fid = uuid.uuid4() async with db_engine.begin() as conn: await conn.execute( @@ -44,8 +34,7 @@ async def _transition(client, headers, pid, fid, body): # ── 조회 ───────────────────────────────────────────────────────────────── async def test_generated_faq_is_pending_and_not_publishable(auth_headers, client, db_engine): - """검증: LLM 이 만든 UNVERIFIED FAQ 를 조회한다. - 기대결과: 목록엔 보이되 publishable=0 — ★ 승인 전에는 사이트에 나가지 않는다.""" + """검증: LLM 이 만든 UNVERIFIED FAQ 를 조회한다.""" h = await auth_headers("u1") pid = await _place(client, h) await _seed_generated_faq(db_engine, pid) @@ -60,8 +49,7 @@ async def test_generated_faq_is_pending_and_not_publishable(auth_headers, client async def test_other_owner_cannot_read_or_touch_faq(auth_headers, client, db_engine): - """검증: 남의 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다. - 기대결과: PLACE_NOT_FOUND — 존재 여부조차 알려주지 않는다.""" + """검증: 남의 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다.""" h = await auth_headers("u1") pid = await _place(client, h) fid = await _seed_generated_faq(db_engine, pid) @@ -74,8 +62,7 @@ async def test_other_owner_cannot_read_or_touch_faq(auth_headers, client, db_eng # ── 승인 ───────────────────────────────────────────────────────────────── async def test_approving_faq_makes_it_publishable(auth_headers, client, db_engine): - """검증: UNVERIFIED FAQ 를 VERIFIED 로 승인한다. - 기대결과: 노출 가능해지고 사업장이 재빌드 대상으로 찍힌다(FAQPage 가 바뀌므로).""" + """검증: UNVERIFIED FAQ 를 VERIFIED 로 승인한다.""" h = await auth_headers("u1") pid = await _place(client, h) fid = await _seed_generated_faq(db_engine, pid) @@ -89,8 +76,7 @@ async def test_approving_faq_makes_it_publishable(auth_headers, client, db_engin async def test_illegal_transition_is_rejected(auth_headers, client, db_engine): - """검증: UNVERIFIED → CORRECTED 처럼 전이표에 없는 이동. - 기대결과: FACT_INVALID_TRANSITION — 확인을 건너뛴 '정정본'은 만들 수 없다.""" + """검증: UNVERIFIED → CORRECTED 처럼 전이표에 없는 이동.""" h = await auth_headers("u1") pid = await _place(client, h) fid = await _seed_generated_faq(db_engine, pid) @@ -100,8 +86,7 @@ async def test_illegal_transition_is_rejected(auth_headers, client, db_engine): async def test_correction_requires_new_text(auth_headers, client, db_engine): - """검증: 고친 문구 없이 CORRECTED 로 보낸다. - 기대결과: INVALID_REQUEST_DATA — 고친 게 없으면 그건 정정이 아니라 승인(VERIFIED)이다.""" + """검증: 고친 문구 없이 CORRECTED 로 보낸다.""" h = await auth_headers("u1") pid = await _place(client, h) fid = await _seed_generated_faq(db_engine, pid) @@ -112,8 +97,7 @@ async def test_correction_requires_new_text(auth_headers, client, db_engine): async def test_corrected_faq_is_locked_against_regeneration(auth_headers, client, db_engine): - """검증: 사장님이 문구를 고쳐 승인(CORRECTED)한 뒤 COPY 잡이 재생성을 돌린다. - 기대결과: 고친 문구가 그대로 남는다 — ★ 자동 생성이 사람의 판단을 덮어쓰지 않는다.""" + """검증: 사장님이 문구를 고쳐 승인(CORRECTED)한 뒤 COPY 잡이 재생성을 돌린다.""" from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import place_faqs from common.utils.gtime import GTime @@ -127,7 +111,6 @@ async def test_corrected_faq_is_locked_against_regeneration(auth_headers, client body = await _transition(client, h, pid, fid, {"status": FactStatus.CORRECTED.value, "answer": "15시 이후 입실입니다."}) assert body["faq"]["status"] == FactStatus.CORRECTED.value assert body["faq"]["answer"] == "15시 이후 입실입니다." - # 문장의 책임 주체가 사람으로 넘어왔다는 기록. assert body["faq"]["generated_by"] == SourceType.OWNER.value # 재생성이 미확인 FAQ 를 내리는 단계(COPY 잡이 실제로 부르는 그 함수). @@ -141,8 +124,7 @@ async def test_corrected_faq_is_locked_against_regeneration(auth_headers, client # ── 직접 추가 ──────────────────────────────────────────────────────────── async def test_owner_written_faq_is_published_immediately(auth_headers, client): - """검증: 사장님이 FAQ 를 직접 쓴다. - 기대결과: 바로 노출값(VERIFIED) + 출처는 서버가 OWNER 로 고정 — 사람이 곧 출처다.""" + """검증: 사장님이 FAQ 를 직접 쓴다.""" h = await auth_headers("u1") pid = await _place(client, h) @@ -155,8 +137,7 @@ async def test_owner_written_faq_is_published_immediately(auth_headers, client): async def test_owner_faq_appends_after_existing_ones(auth_headers, client, db_engine): - """검증: 이미 FAQ 가 있는 사업장에 순서 없이 추가한다. - 기대결과: 맨 뒤에 붙는다 — 기존 FAQ 순서를 흔들지 않는다.""" + """검증: 이미 FAQ 가 있는 사업장에 순서 없이 추가한다.""" h = await auth_headers("u1") pid = await _place(client, h) await _seed_generated_faq(db_engine, pid, order=0) @@ -168,8 +149,7 @@ async def test_owner_faq_appends_after_existing_ones(auth_headers, client, db_en async def test_blank_faq_is_rejected(auth_headers, client): - """검증: 공백만 있는 질문으로 FAQ 를 추가한다. - 기대결과: INVALID_REQUEST_DATA — 빈 문답은 렌더도 안 되고 고유 콘텐츠로도 안 세진다.""" + """검증: 공백만 있는 질문으로 FAQ 를 추가한다.""" h = await auth_headers("u1") pid = await _place(client, h) diff --git a/solution/backend/tests/test_faq_fill.py b/solution/backend/tests/test_faq_fill.py index db0a464..e40dc68 100644 --- a/solution/backend/tests/test_faq_fill.py +++ b/solution/backend/tests/test_faq_fill.py @@ -1,10 +1,4 @@ -"""FAQ 목표 수 채우기 — ★ 공통 답은 문의 안내뿐이고, 이미 다룬 주제는 다시 넣지 않는다. - -이 기능이 절대 하면 안 되는 것: - - 공통 답에 값·가능 여부를 적는 것 (가게마다 다르다 — 틀리면 예약 클레임) - - fact 로 답할 수 있는 질문에 "문의 부탁드립니다" 를 붙이는 것 (아는 것을 숨긴다) - - 기존 FAQ 와 같은 주제를 다시 넣는 것 -""" +"""FAQ 목표 수 채우기 — ★ 공통 답은 문의 안내뿐이고, 이미 다룬 주제는 다시 넣지 않는다.""" import re from common.enums import ErrorType, PlaceCategory @@ -24,21 +18,21 @@ def _ids(picks): def test_pension_catalog_has_thirty_questions(): - """검증: 카탈로그 로드. fact_keys 가 업종 스키마에 없으면 로더가 예외를 던진다.""" + """검증: 카탈로그 로드.""" catalog = _pension() assert len(catalog.items) == 30 assert len({item.id for item in catalog.items}) == 30 def test_catalog_scope(): - """검증: 호텔·카페에는 펜션 질문을 붙이지 않는다. 분류가 비어 있는 숙박업(스테이머뭄)에는 붙인다.""" + """검증: 호텔·카페에는 펜션 질문을 붙이지 않는다.""" assert find_catalog(PlaceCategory.LODGING.value, "호텔") is None assert find_catalog(PlaceCategory.CAFE.value, None) is None assert find_catalog(PlaceCategory.LODGING.value, None) is not None def test_fills_only_the_shortfall(): - """검증: 기존 FAQ 5건(카탈로그와 무관한 질문). 기대결과: 15건만 고른다. 20건이면 0건.""" + """검증: 기존 FAQ 5건(카탈로그와 무관한 질문).""" catalog = _pension() picks = faq_fill.pick_fill_faqs(catalog, [ExistingFaq(f"기타 질문 {i}") for i in range(5)], set()) assert len(picks) == 15 and len(_ids(picks)) == 15 @@ -46,30 +40,27 @@ def test_fills_only_the_shortfall(): def test_skips_topics_answerable_by_facts(): - """검증: 체크인·반려동물 fact 가 있다(객실 fact 는 'A동:max_capacity' 로 적힌다). - 기대결과: 그 질문들은 문의 안내로 채우지 않는다 — LLM 이 fact 로 답할 자리다.""" + """검증: 체크인·반려동물 fact 가 있다(객실 fact 는 'A동:max_capacity' 로 적힌다).""" picks = faq_fill.pick_fill_faqs(_pension(), [], {"check_in_time", "pet_allowed", "A동:max_capacity"}, target=30) assert not _ids(picks) & {"check_in", "pet", "capacity"} def test_skips_topics_already_asked_by_keyword(): - """검증: 사장님이 근거 key 없이 쓴 FAQ 가 있다. 기대결과: 질문 낱말로 같은 주제를 알아본다.""" + """검증: 사장님이 근거 key 없이 쓴 FAQ 가 있다.""" existing = [ExistingFaq("반려견 데려가도 되나요?"), ExistingFaq("주차 되나요?")] picks = faq_fill.pick_fill_faqs(_pension(), existing, set(), target=30) assert not _ids(picks) & {"pet", "parking"} def test_skips_topics_covered_by_source_fact_keys(): - """검증: LLM FAQ 의 질문에는 키워드가 없지만 근거 key 가 [wifi, A동:max_capacity] 다. - 기대결과: 와이파이·인원 질문을 넣지 않는다 — LLM 은 두 주제를 한 문항에 묶기도 한다.""" + """검증: LLM FAQ 의 질문에는 키워드가 없지만 근거 key 가 [wifi, A동:max_capacity] 다.""" existing = [ExistingFaq("편의 안내가 궁금해요", ["wifi", "A동:max_capacity"])] picks = faq_fill.pick_fill_faqs(_pension(), existing, set(), target=30) assert not _ids(picks) & {"wifi", "capacity"} def test_answers_are_inquiry_only(): - """검증: 공통 답 문구. - 기대결과: ★ 연락처가 없으면 숫자가 하나도 없다(가격·시각을 지어내지 않는다). 연락처가 있으면 그 번호만 들어간다.""" + """검증: 공통 답 문구.""" catalog = _pension() for pick in faq_fill.pick_fill_faqs(catalog, [], set(), target=30): assert not re.search(r"\d", pick.answer), pick.answer @@ -94,8 +85,7 @@ def test_suggested_questions_are_the_answerable_ones(): async def test_template_source_cannot_write_fact(auth_headers, client): - """검증: fact 를 TEMPLATE 출처로 쓴다. - 기대결과: INVALID_REQUEST_DATA — 막지 않으면 사람 입력처럼 바로 노출값이 된다(fact_service 규칙 4).""" + """검증: fact 를 TEMPLATE 출처로 쓴다.""" h = await auth_headers("u1") pid = (await client.post("/v1/place", headers=h, json={"name": "틀펜션", "category": 1})).json()["place"]["place_id"] await client.post(f"/v1/place/{pid}/verify", headers=h, json={"source": 2, "road_address": "틀주소"}) diff --git a/solution/backend/tests/test_gemini.py b/solution/backend/tests/test_gemini.py index 8c860d8..90ccfd9 100644 --- a/solution/backend/tests/test_gemini.py +++ b/solution/backend/tests/test_gemini.py @@ -1,12 +1,4 @@ -"""Gemini Vision 클라이언트 — 사진 분류·alt 생성의 계약. - -실제 API 를 절대 호출하지 않는다(httpx.MockTransport). 여기서 고정하는 것: - 1. ★ 반환 길이는 항상 입력과 같다 — 실패해도 자리를 지킨다 - 2. ★ 매칭은 순서가 아니라 ref 로 한다 — 순서로 하면 엉뚱한 사진에 남의 alt 가 붙는다 - 3. ★ 신뢰도가 낮으면 needs_review=True — 자동 반영하지 않고 사람 확인 큐로 - 4. 배치 하나가 죽어도 나머지는 산다 - 5. 홍보성 형용사 금지가 프롬프트에 실제로 들어간다 -""" +"""Gemini Vision 클라이언트 — 사진 분류·alt 생성의 계약.""" import base64 import json import struct @@ -26,9 +18,9 @@ from services.external.gemini import ( ) -# ---- 도구 ----------------------------------------------------------------- +# 도구 def _png(rgb=(10, 20, 30), size=8) -> bytes: - """테스트용 최소 PNG. 시그니처 판별(_sniff_mime)까지 같이 확인된다.""" + """테스트용 최소 PNG.""" def chunk(tag, data): body = tag + data return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) @@ -45,8 +37,7 @@ def _inputs(n: int) -> list[ImageInput]: def _reply(items: list[dict], *, prompt_tokens=1000, out_tokens=50) -> dict: - """generateContent 성공 응답 흉내. 실호출에서 확인한 구조 그대로 — - parts 에 text 와 thoughtSignature 가 같이 실린다.""" + """generateContent 성공 응답 흉내.""" return { "candidates": [{ "content": {"parts": [{"text": json.dumps({"items": items}, ensure_ascii=False), @@ -71,10 +62,9 @@ def _api_key(monkeypatch): monkeypatch.setattr(llm.external_api_config, "gemini_api_key", "test-key") -# ---- 기본 경로 ------------------------------------------------------------- +# 기본 경로 async def test_parses_label_alt_and_confidence(): - """검증: 정상 응답 1장. - 기대결과: label·alt_text·confidence 가 파싱되고 ok=True, 신뢰도가 높아 확인 불필요.""" + """검증: 정상 응답 1장.""" def handler(request): return httpx.Response(200, json=_reply([_item("img-0", "침실", "침대와 협탁이 놓인 방", 0.93)])) @@ -90,8 +80,7 @@ async def test_parses_label_alt_and_confidence(): async def test_result_length_always_matches_input(): - """검증: 입력 5장인데 응답에 3장만 온다. - 기대결과: 길이 5 유지 · 빠진 2장은 ok=False, needs_review=True — ★ 조용히 사라지지 않는다.""" + """검증: 입력 5장인데 응답에 3장만 온다.""" def handler(request): return httpx.Response(200, json=_reply([_item("img-0"), _item("img-1"), _item("img-2")])) @@ -108,8 +97,7 @@ async def test_result_length_always_matches_input(): async def test_matches_by_ref_not_by_order(): - """검증: 모델이 순서를 뒤집어 돌려준다(img-2, img-0, img-1). - 기대결과: ★ ref 로 정확히 매칭된다 — 순서로 매칭하면 엉뚱한 사진에 남의 alt 가 붙는다.""" + """검증: 모델이 순서를 뒤집어 돌려준다(img-2, img-0, img-1).""" def handler(request): return httpx.Response(200, json=_reply([ _item("img-2", "주방", "싱크대와 조리대"), @@ -128,8 +116,7 @@ async def test_matches_by_ref_not_by_order(): async def test_unknown_ref_in_response_is_discarded(): - """검증: 모델이 존재하지 않는 ref(img-99)를 지어낸다. - 기대결과: 버려지고, 실제 사진은 '응답 누락'으로 표시된다.""" + """검증: 모델이 존재하지 않는 ref(img-99)를 지어낸다.""" def handler(request): return httpx.Response(200, json=_reply([_item("img-99", "침실")])) @@ -140,10 +127,9 @@ async def test_unknown_ref_in_response_is_discarded(): assert res[0].ok is False and res[0].needs_review is True -# ---- 신뢰도 게이트 --------------------------------------------------------- +# 신뢰도 게이트 async def test_low_confidence_goes_to_review_queue(): - """검증: 신뢰도 0.4 로 돌아온 사진(임계값 0.7). - 기대결과: ok=True 지만 needs_review=True — ★ 자동 반영하지 않고 사람이 본다.""" + """검증: 신뢰도 0.4 로 돌아온 사진(임계값 0.7).""" def handler(request): return httpx.Response(200, json=_reply([_item("img-0", "침실", "흐릿한 실내", 0.4)])) @@ -155,8 +141,7 @@ async def test_low_confidence_goes_to_review_queue(): async def test_threshold_is_configurable(): - """검증: 같은 0.4 응답에 임계값을 0.3 으로 낮춘다. - 기대결과: 확인 불필요로 내려간다 — 임계값이 실제로 파라미터로 동작한다.""" + """검증: 같은 0.4 응답에 임계값을 0.3 으로 낮춘다.""" def handler(request): return httpx.Response(200, json=_reply([_item("img-0", "침실", "실내", 0.4)])) @@ -167,8 +152,7 @@ async def test_threshold_is_configurable(): async def test_empty_label_forces_review(): - """검증: 신뢰도는 높은데 label 이 빈 문자열이다. - 기대결과: needs_review=True — 쓸 수 없는 결과를 자동 반영하지 않는다.""" + """검증: 신뢰도는 높은데 label 이 빈 문자열이다.""" def handler(request): return httpx.Response(200, json=_reply([_item("img-0", "", "설명", 0.99)])) @@ -179,10 +163,9 @@ async def test_empty_label_forces_review(): assert res[0].needs_review is True -# ---- 배치 ----------------------------------------------------------------- +# 배치 async def test_splits_into_batches(): - """검증: 12장을 batch_size=5 로 보낸다. - 기대결과: 3번 호출된다(5+5+2) — 20~50장을 한 번에 밀어넣지 않는다.""" + """검증: 12장을 batch_size=5 로 보낸다.""" calls = [] def handler(request): @@ -201,8 +184,7 @@ async def test_splits_into_batches(): async def test_one_failed_batch_does_not_kill_the_rest(): - """검증: 3배치 중 두 번째만 500 을 반환한다. - 기대결과: ★ 나머지 배치는 살아남고, 실패 배치의 사진만 ok=False 로 표시된다.""" + """검증: 3배치 중 두 번째만 500 을 반환한다.""" state = {"n": 0} def handler(request): @@ -222,10 +204,9 @@ async def test_one_failed_batch_does_not_kill_the_rest(): assert all(r.needs_review for r in res[2:4]) -# ---- 재시도 --------------------------------------------------------------- +# 재시도 async def test_retries_5xx_then_succeeds(): - """검증: 첫 호출 503, 두 번째 200. - 기대결과: 재시도로 성공한다 — 일시적 장애로 사진을 버리지 않는다.""" + """검증: 첫 호출 503, 두 번째 200. 기대결과: 재시도로 성공한다 — 일시적 장애로 사진을 버리지 않는다.""" state = {"n": 0} def handler(request): @@ -242,8 +223,7 @@ async def test_retries_5xx_then_succeeds(): async def test_does_not_retry_4xx(): - """검증: 400(잘못된 요청)을 반환한다. - 기대결과: 재시도하지 않는다 — 같은 요청을 다시 보내도 결과가 같고 요금만 나간다.""" + """검증: 400(잘못된 요청)을 반환한다.""" state = {"n": 0} def handler(request): @@ -258,8 +238,7 @@ async def test_does_not_retry_4xx(): async def test_timeout_is_retried_then_reported(): - """검증: 매번 타임아웃이 난다. - 기대결과: max_retries 만큼 시도한 뒤 해당 사진을 ok=False 로 남긴다(예외를 밖으로 안 던진다).""" + """검증: 매번 타임아웃이 난다.""" state = {"n": 0} def handler(request): @@ -273,10 +252,9 @@ async def test_timeout_is_retried_then_reported(): assert res[0].ok is False and res[0].needs_review is True -# ---- 오류 처리 ------------------------------------------------------------- +# 오류 처리 async def test_broken_json_fails_only_that_batch(): - """검증: 구조화 출력이 깨진 JSON 으로 온다. - 기대결과: 그 배치만 실패 처리된다 — 파싱 실패가 전체를 죽이지 않는다.""" + """검증: 구조화 출력이 깨진 JSON 으로 온다.""" def handler(request): return httpx.Response(200, json={ "candidates": [{"content": {"parts": [{"text": "{items: [oops"}]}, "finishReason": "STOP"}] @@ -290,8 +268,7 @@ async def test_broken_json_fails_only_that_batch(): async def test_safety_blocked_response_is_handled(): - """검증: candidates 가 비어 오는 경우(안전 필터 차단). - 기대결과: 예외가 새지 않고 ok=False 로 남는다.""" + """검증: candidates 가 비어 오는 경우(안전 필터 차단).""" def handler(request): return httpx.Response(200, json={"candidates": []}) @@ -302,8 +279,7 @@ async def test_safety_blocked_response_is_handled(): async def test_missing_key_raises_not_configured(monkeypatch): - """검증: GEMINI_API_KEY 가 비어 있다. - 기대결과: GeminiNotConfigured — ★ 서버 부팅은 막지 않고 이 어댑터만 비활성이다.""" + """검증: GEMINI_API_KEY 가 비어 있다.""" monkeypatch.setattr(llm.external_api_config, "gemini_api_key", "") assert llm.is_configured() is False with pytest.raises(GeminiNotConfigured): @@ -311,8 +287,7 @@ async def test_missing_key_raises_not_configured(monkeypatch): async def test_auth_error_stops_everything(): - """검증: 401 이 돌아온다. - 기대결과: GeminiNotConfigured 로 전체 중단 — 나머지 배치를 태워봐야 똑같이 실패한다.""" + """검증: 401 이 돌아온다.""" def handler(request): return httpx.Response(401, text="unauthorized") @@ -322,8 +297,7 @@ async def test_auth_error_stops_everything(): async def test_image_download_failure_is_isolated(): - """검증: 바이트 없이 URL 만 준 사진의 내려받기가 실패한다. - 기대결과: 그 사진만 ok=False, 같은 배치의 다른 사진은 정상 처리된다.""" + """검증: 바이트 없이 URL 만 준 사진의 내려받기가 실패한다.""" def handler(request): if request.method == "GET": return httpx.Response(404) @@ -342,15 +316,13 @@ async def test_image_download_failure_is_isolated(): async def test_empty_input_returns_empty(): - """검증: 빈 목록을 넘긴다. - 기대결과: 빈 목록. 호출도 하지 않는다(요금 0).""" + """검증: 빈 목록을 넘긴다.""" assert await analyze_images([]) == [] -# ---- 프롬프트 계약 --------------------------------------------------------- +# 프롬프트 계약 async def test_prompt_forbids_promotional_adjectives(): - """검증: 실제로 보내는 요청 본문의 지시문. - 기대결과: ★ 홍보성 형용사 금지와 '지어내지 마라' 가 들어 있다 — LLM 은 사실을 만들지 않는다.""" + """검증: 실제로 보내는 요청 본문의 지시문.""" captured = {} def handler(request): @@ -367,8 +339,7 @@ async def test_prompt_forbids_promotional_adjectives(): async def test_prompt_uses_category_vocabulary(): - """검증: 업종별 라벨 어휘. - 기대결과: 숙박엔 '침실'이, 카페엔 '디저트'가 들어간다 — 라벨이 업종마다 달라야 화면에서 묶인다.""" + """검증: 업종별 라벨 어휘.""" captured = {} def handler(request): @@ -386,8 +357,7 @@ async def test_prompt_uses_category_vocabulary(): async def test_unit_names_are_passed_as_hint(): - """검증: 객실 이름 후보를 넘긴다. - 기대결과: 프롬프트에 실린다 — 라벨을 units 와 맞춰야 나중에 사진이 객실에 붙는다.""" + """검증: 객실 이름 후보를 넘긴다.""" captured = {} def handler(request): @@ -401,8 +371,7 @@ async def test_unit_names_are_passed_as_hint(): async def test_structured_output_schema_is_sent(): - """검증: 요청의 generationConfig. - 기대결과: responseMimeType=application/json + responseSchema 가 실린다 — 파싱 실패를 줄이는 장치.""" + """검증: 요청의 generationConfig.""" captured = {} def handler(request): @@ -421,8 +390,7 @@ async def test_structured_output_schema_is_sent(): async def test_png_mime_is_sniffed_not_guessed(): - """검증: PNG 바이트를 mime_type 없이 넘긴다. - 기대결과: image/png 로 판별된다 — 수집한 URL 의 확장자는 자주 거짓말한다.""" + """검증: PNG 바이트를 mime_type 없이 넘긴다.""" captured = {} def handler(request): @@ -438,8 +406,7 @@ async def test_png_mime_is_sniffed_not_guessed(): async def test_confidence_is_clamped(): - """검증: 모델이 범위 밖 신뢰도(1.7, -0.2)를 준다. - 기대결과: 0.0~1.0 으로 잘린다 — 오염된 값이 아래 로직으로 흘러가지 않는다.""" + """검증: 모델이 범위 밖 신뢰도(1.7, -0.2)를 준다.""" def handler(request): return httpx.Response(200, json=_reply([ _item("img-0", conf=1.7), _item("img-1", conf=-0.2), diff --git a/solution/backend/tests/test_gemini_text.py b/solution/backend/tests/test_gemini_text.py index d8d6659..39cad0a 100644 --- a/solution/backend/tests/test_gemini_text.py +++ b/solution/backend/tests/test_gemini_text.py @@ -1,17 +1,11 @@ -"""Gemini 텍스트 생성 — ★ LLM 이 사실을 만들지 못하게 막는 게 전부다. - -프롬프트로 "지어내지 마라" 라고 부탁하는 것만으로는 부족하다. 모델은 종종 어긴다. -그래서 ground_check 가 결과를 코드로 검증하고, 통과 못 한 문장은 버린다. -이 파일은 그 검증이 실제로 무엇을 잡아내는지 고정한다. -""" +"""Gemini 텍스트 생성 — ★ LLM 이 사실을 만들지 못하게 막는 게 전부다.""" import json import httpx import pytest from common.enums import PlaceCategory -# ★ 겹마다 사는 곳이 다르다(services/llm/__init__.py 의 설명 참조). -# 근거 검증 규칙 → grounding, HTTP 호출·키 → llm, 조립 → external. +# 겹마다 사는 곳이 다르다(services/llm/__init__.py 의 설명 참조). from services.external import gemini_text as gt from services.grounding import copy as grounding_copy from services.llm import gemini as llm @@ -65,8 +59,7 @@ def _configured(monkeypatch): # ── 정상 경로 ───────────────────────────────────────────────────────────── async def test_generates_intro_meta_and_faqs(): - """검증: 근거 있는 문장만 담긴 정상 응답. - 기대결과: 소개문·메타·FAQ 가 그대로 통과하고 근거 key 가 살아 있다.""" + """검증: 근거 있는 문장만 담긴 정상 응답.""" payload = _payload( intro="체크인은 15시, 체크아웃은 11시입니다. 취사가 가능하며 주차도 하실 수 있습니다.", meta="체크인 15시, 취사 가능한 숙소입니다.", @@ -84,8 +77,7 @@ async def test_generates_intro_meta_and_faqs(): async def test_request_body_pins_structured_output(): - """검증: 요청 본문. - 기대결과: responseSchema 로 출력 구조가 고정되고 fact 가 프롬프트에 실린다.""" + """검증: 요청 본문.""" calls = [] async with _client(_ok(_payload(intro="체크인은 15시입니다."), calls)) as c: await gt.generate_copy("하조대펜션", PlaceCategory.LODGING, FACTS, client=c) @@ -101,8 +93,6 @@ async def test_request_body_pins_structured_output(): # ── ★ 환각 차단 ─────────────────────────────────────────────────────────── async def test_ungrounded_number_is_rejected(): - """검증: fact 는 15:00 인데 모델이 '14시' 라고 썼다. - 기대결과: ★ 소개문이 버려지고 rejected 에 사유가 남는다 — 틀린 시각이 사이트로 나가면 클레임이다.""" payload = _payload(intro="체크인은 14시부터 가능합니다.", meta="체크인 15시.") async with _client(_ok(payload)) as c: res = await gt.generate_copy("하조대펜션", PlaceCategory.LODGING, FACTS, client=c) @@ -112,8 +102,6 @@ async def test_ungrounded_number_is_rejected(): async def test_ungrounded_facility_is_rejected(): - """검증: fact 에 없는 시설(수영장)을 언급했다. - 기대결과: ★ 반려 — 없는 시설을 보고 온 손님은 헛걸음한다.""" payload = _payload(intro="수영장을 갖춘 숙소입니다. 체크인은 15시입니다.", meta="체크인 15시.") async with _client(_ok(payload)) as c: res = await gt.generate_copy("하조대펜션", PlaceCategory.LODGING, FACTS, client=c) @@ -123,8 +111,6 @@ async def test_ungrounded_facility_is_rejected(): async def test_contradicting_boolean_fact_is_rejected(): - """검증: pet_allowed=false 인데 '반려동물 동반 가능' 이라고 썼다. - 기대결과: ★ 반려 — 사실을 뒤집은 문장이다.""" payload = _payload(intro="반려동물 동반이 가능한 숙소입니다.", meta="체크인 15시.") async with _client(_ok(payload)) as c: res = await gt.generate_copy("하조대펜션", PlaceCategory.LODGING, FACTS, client=c) @@ -134,8 +120,6 @@ async def test_contradicting_boolean_fact_is_rejected(): async def test_negated_mention_of_false_fact_passes(): - """검증: pet_allowed=false 를 '불가' 라고 바르게 썼다. - 기대결과: 통과 — 부정 표현까지 막으면 사실을 못 알린다.""" payload = _payload(intro="반려동물 동반은 불가합니다. 체크인은 15시입니다.", meta="체크인 15시.") async with _client(_ok(payload)) as c: res = await gt.generate_copy("하조대펜션", PlaceCategory.LODGING, FACTS, client=c) @@ -144,8 +128,7 @@ async def test_negated_mention_of_false_fact_passes(): async def test_promotional_language_is_rejected(): - """검증: 최상급·홍보성 표현. - 기대결과: ★ 반려 — 이 서비스의 글은 광고문이 아니라 사실 전달이다.""" + """검증: 최상급·홍보성 표현.""" payload = _payload(intro="국내 최고의 완벽한 숙소입니다.", meta="체크인 15시.") async with _client(_ok(payload)) as c: res = await gt.generate_copy("하조대펜션", PlaceCategory.LODGING, FACTS, client=c) @@ -155,8 +138,7 @@ async def test_promotional_language_is_rejected(): async def test_scaled_number_matches_fact(): - """검증: fact 는 20000 인데 문장은 '2만원' 이다. - 기대결과: 통과 — 같은 값을 다르게 쓴 것뿐이다(과잉 반려를 막는다).""" + """검증: fact 는 20000 인데 문장은 '2만원' 이다.""" payload = _payload(intro="바비큐 이용료는 2만원입니다. 체크인은 15시입니다.", meta="체크인 15시.") async with _client(_ok(payload)) as c: res = await gt.generate_copy("하조대펜션", PlaceCategory.LODGING, FACTS, client=c) @@ -165,8 +147,7 @@ async def test_scaled_number_matches_fact(): async def test_faq_without_fact_keys_is_dropped(): - """검증: 근거 key 를 못 대는 FAQ. - 기대결과: ★ 버려진다 — 사실인지 확인할 방법이 없는 문답이다.""" + """검증: 근거 key 를 못 대는 FAQ.""" payload = _payload( intro="체크인은 15시입니다.", meta="체크인 15시.", faqs=[ @@ -183,8 +164,6 @@ async def test_faq_without_fact_keys_is_dropped(): async def test_invented_fact_key_is_stripped(): - """검증: 모델이 존재하지 않는 fact key 를 근거로 적었다. - 기대결과: 그 key 만 걸러진다 — 없는 근거를 있는 것처럼 두면 추적이 깨진다.""" payload = _payload( intro="체크인은 15시입니다.", meta="체크인 15시.", intro_keys=["check_in_time", "swimming_pool", "spa_open"], @@ -196,8 +175,6 @@ async def test_invented_fact_key_is_stripped(): async def test_bad_faq_does_not_kill_good_ones(): - """검증: FAQ 3개 중 1개만 근거가 틀렸다. - 기대결과: 그 항목만 버려지고 나머지는 산다 — 하나 때문에 전부 잃으면 안 된다.""" payload = _payload( intro="체크인은 15시입니다.", meta="체크인 15시.", faqs=[ @@ -214,8 +191,7 @@ async def test_bad_faq_does_not_kill_good_ones(): async def test_faq_question_about_unavailable_facility_survives(): - """검증: 'X 가능한가요?' 라 묻고 '불가합니다' 라 답한 FAQ. - 기대결과: ★ 통과 — 질문은 사실을 주장하지 않는다. (실호출에서 멀쩡한 FAQ 가 버려진 오탐을 고친 것)""" + """검증: 'X 가능한가요?' 라 묻고 '불가합니다' 라 답한 FAQ.""" payload = _payload( intro="체크인은 15시입니다.", meta="체크인 15시.", faqs=[{"question": "반려동물 동반이 가능한가요?", "answer": "아니요, 반려동물 동반은 불가합니다.", @@ -228,8 +204,6 @@ async def test_faq_question_about_unavailable_facility_survives(): async def test_faq_answer_contradicting_fact_is_rejected(): - """검증: 'X 가능한가요?' 에 사실과 반대로 '네 가능합니다' 라 답했다. - 기대결과: ★ 반려 — 질문을 주장에서 뺀 빈틈을 답변 극성 검사가 막는다.""" payload = _payload( intro="체크인은 15시입니다.", meta="체크인 15시.", faqs=[{"question": "반려동물 동반이 가능한가요?", "answer": "네, 가능합니다.", @@ -243,8 +217,7 @@ async def test_faq_answer_contradicting_fact_is_rejected(): def test_question_mentioning_missing_facility_is_still_rejected(): - """검증: 없는 시설을 묻는 질문("수영장 있나요?"). - 기대결과: 반려 — 의문문 예외는 값-반대 판정에만 적용되고, 없는 시설 언급은 그대로 잡는다.""" + """검증: 없는 시설을 묻는 질문("수영장 있나요?").""" ok, reasons = grounding_copy.ground_check("수영장이 있나요? 네 있습니다.", FACTS) assert ok is False assert any("수영장" in r for r in reasons) @@ -252,8 +225,7 @@ def test_question_mentioning_missing_facility_is_still_rejected(): # ── 입력 가드 ───────────────────────────────────────────────────────────── async def test_no_facts_means_no_call_and_no_output(): - """검증: 근거 fact 가 하나도 없다. - 기대결과: ★ API 를 호출조차 하지 않고 빈 결과 — 근거 없이 쓰면 그게 환각이다.""" + """검증: 근거 fact 가 하나도 없다.""" calls = [] async with _client(_ok(_payload(intro="아무거나"), calls)) as c: res = await gt.generate_copy("하조대펜션", PlaceCategory.LODGING, [], client=c) @@ -264,8 +236,7 @@ async def test_no_facts_means_no_call_and_no_output(): async def test_missing_api_key_raises(): - """검증: GEMINI_API_KEY 미설정. - 기대결과: GeminiTextNotConfigured — 부팅은 막지 않고 이 어댑터만 비활성이다.""" + """검증: GEMINI_API_KEY 미설정.""" llm.external_api_config.gemini_api_key = "" with pytest.raises(llm.GeminiNotConfigured): await gt.generate_copy("x", PlaceCategory.LODGING, FACTS) @@ -273,8 +244,7 @@ async def test_missing_api_key_raises(): # ── 네트워크 ────────────────────────────────────────────────────────────── async def test_retries_on_5xx_then_succeeds(): - """검증: 첫 호출이 503, 두 번째가 200. - 기대결과: 재시도해서 성공한다 — 일시적 장애로 생성을 포기하지 않는다.""" + """검증: 첫 호출이 503, 두 번째가 200. 기대결과: 재시도해서 성공한다 — 일시적 장애로 생성을 포기하지 않는다.""" calls = [] def handler(request): @@ -291,8 +261,7 @@ async def test_retries_on_5xx_then_succeeds(): async def test_does_not_retry_on_4xx(): - """검증: 400 응답. - 기대결과: 재시도하지 않는다 — 잘못된 요청은 다시 보내도 같고 요금만 나간다.""" + """검증: 400 응답.""" calls = [] def handler(request): @@ -307,16 +276,14 @@ async def test_does_not_retry_on_4xx(): async def test_auth_failure_is_not_configured(): - """검증: 401 응답. - 기대결과: GeminiTextNotConfigured — 재시도가 무의미한 설정 문제다.""" + """검증: 401 응답.""" async with _client(lambda r: httpx.Response(401, text="unauthorized")) as c: with pytest.raises(llm.GeminiNotConfigured): await gt.generate_copy("하조대펜션", PlaceCategory.LODGING, FACTS, client=c) async def test_broken_json_raises_invalid_output(): - """검증: 구조화 출력이 JSON 이 아니다. - 기대결과: GeminiTextInvalidOutput — 조용히 빈 결과로 넘기지 않는다.""" + """검증: 구조화 출력이 JSON 이 아니다.""" payload = {"candidates": [{"content": {"parts": [{"text": "{깨진 json"}]}, "finishReason": "STOP"}]} async with _client(_ok(payload)) as c: with pytest.raises(llm.GeminiInvalidOutput): @@ -324,8 +291,6 @@ async def test_broken_json_raises_invalid_output(): async def test_empty_candidates_raises(): - """검증: 안전 필터 등으로 candidates 가 비어 왔다. - 기대결과: GeminiTextInvalidOutput.""" async with _client(_ok({"candidates": []})) as c: with pytest.raises(llm.GeminiInvalidOutput): await gt.generate_copy("하조대펜션", PlaceCategory.LODGING, FACTS, client=c) @@ -333,31 +298,26 @@ async def test_empty_candidates_raises(): # ── ground_check 단위 ───────────────────────────────────────────────────── def test_ground_check_reports_every_reason(): - """검증: 한 문장에 문제가 여러 개. - 기대결과: 사유가 전부 모여 나온다 — 운영자가 무엇이 문제인지 다 봐야 한다.""" + """검증: 한 문장에 문제가 여러 개.""" ok, reasons = grounding_copy.ground_check("국내 최고의 수영장을 갖춘 3층 건물입니다.", FACTS) assert ok is False assert len(reasons) >= 3 def test_ground_check_rejects_empty_text(): - """검증: 빈 문장. - 기대결과: 반려.""" + """검증: 빈 문장.""" ok, reasons = grounding_copy.ground_check(" ", FACTS) assert ok is False and reasons == ["빈 문장"] def test_ground_check_allows_facility_word_present_in_fact_value(): - """검증: fact 값 안에 그 낱말이 그대로 있다(대표 메뉴 = '수영장 뷰 라떼'). - 기대결과: 통과 — 근거가 fact 에 실제로 있다.""" + """검증: fact 값 안에 그 낱말이 그대로 있다(대표 메뉴 = '수영장 뷰 라떼').""" facts = FACTS + [grounding_copy.FactInput("signature_menu", "대표 메뉴", "수영장 뷰 라떼")] ok, _reasons = grounding_copy.ground_check("대표 메뉴는 수영장 뷰 라떼입니다.", facts) assert ok is True def test_ground_check_grounds_numbers_from_unit_summaries(): - """검증: 객실 요약에만 있는 숫자(최대 4명)를 문장이 인용했다. - 기대결과: 통과 — generate_copy 가 객실 요약도 근거로 펼쳐 넘긴다.""" grounding = FACTS + gt._unit_facts([{"name": "A동", "facts": {"max_capacity": "4"}}]) ok, _reasons = grounding_copy.ground_check("A동은 최대 4명까지 이용하실 수 있습니다.", grounding) assert ok is True @@ -371,8 +331,7 @@ def _clear_summary_cache(): async def test_summarize_returns_shortened_text(): - """검증: 긴 문장을 요약 API 로 축약한다. - 기대결과: 응답 텍스트가 그대로 반환되고, 원문이 요청 프롬프트에 실린다.""" + """검증: 긴 문장을 요약 API 로 축약한다.""" payload = { "candidates": [{"content": {"parts": [{"text": "짧게 줄인 문장입니다."}]}, "finishReason": "STOP"}], "usageMetadata": {"promptTokenCount": 300, "candidatesTokenCount": 20}, @@ -387,16 +346,14 @@ async def test_summarize_returns_shortened_text(): async def test_summarize_skips_when_not_configured(): - """검증: GEMINI_API_KEY 미설정. - 기대결과: 호출 자체를 안 하고 None — 캔버스는 원문으로 폴백한다.""" + """검증: GEMINI_API_KEY 미설정.""" llm.external_api_config.gemini_api_key = "" result = await gt.summarize_text("두 번째 테스트용 원문입니다. " * 20) assert result is None async def test_summarize_caches_repeated_calls(): - """검증: 같은 원문을 두 번 요약 요청한다. - 기대결과: 두 번째는 API 를 다시 부르지 않고 캐시된 값을 돌려준다.""" + """검증: 같은 원문을 두 번 요약 요청한다.""" payload = {"candidates": [{"content": {"parts": [{"text": "캐시 확인용 요약"}]}, "finishReason": "STOP"}]} calls = [] text = "세 번째 테스트용 원문입니다. " * 20 @@ -409,8 +366,7 @@ async def test_summarize_caches_repeated_calls(): async def test_summarize_returns_none_on_repeated_failure(): - """검증: 재시도까지 전부 5xx 로 실패한다. - 기대결과: 예외를 올리지 않고 None — 요약 실패가 캔버스를 깨면 안 된다.""" + """검증: 재시도까지 전부 5xx 로 실패한다.""" async with _client(lambda r: httpx.Response(503, text="unavailable")) as c: result = await gt.summarize_text("네 번째 테스트용 원문입니다. " * 20, client=c, max_retries=1) @@ -418,7 +374,6 @@ async def test_summarize_returns_none_on_repeated_failure(): async def test_summarize_empty_text_returns_none(): - """검증: 빈 문자열. - 기대결과: 호출 없이 None.""" + """검증: 빈 문자열.""" result = await gt.summarize_text(" ") assert result is None diff --git a/solution/backend/tests/test_google_identity.py b/solution/backend/tests/test_google_identity.py index 7ae5926..e4b8414 100644 --- a/solution/backend/tests/test_google_identity.py +++ b/solution/backend/tests/test_google_identity.py @@ -1,13 +1,4 @@ -"""구글 ID 토큰 검증 — 서명·수신자(aud)·발급자(iss)·이메일 인증. - -★ 왜 대역이 아니라 진짜 서명을 쓰나 - 이 파일이 지키는 건 "우리가 받아들이면 안 되는 토큰을 거부하는가" 하나다. 검증 함수를 - 대역으로 바꾸면 그 질문 자체가 사라진다. 그래서 여기서는 테스트용 RSA 키를 만들어 **진짜로 - 서명하고**, 구글 공개키 조회(_fetch_jwks)만 그 키로 바꿔 끼운다. 네트워크는 타지 않는다. - - 특히 aud 검사: 남의 서비스에 발급된 **진짜 구글 토큰**을 그대로 우리 서버에 보내면 - 서명도 발급자도 전부 맞다. 그걸 거르는 검사는 aud 하나뿐이다. -""" +"""구글 ID 토큰 검증 — 서명·수신자(aud)·발급자(iss)·이메일 인증.""" import time @@ -45,7 +36,7 @@ def keypair(): @pytest.fixture(autouse=True) def google_configured(monkeypatch, keypair): - """client_id 를 채우고 JWKS 조회를 테스트 키로 바꾼다. 캐시는 테스트마다 비운다.""" + """client_id 를 채우고 JWKS 조회를 테스트 키로 바꾼다.""" _, jwks = keypair monkeypatch.setattr(google_identity.google_oauth_config, "client_id", _CLIENT_ID) @@ -74,8 +65,7 @@ def _token(keypair, **overrides) -> str: async def test_valid_token_yields_identity(keypair): - """검증: 우리 client_id 로 발급된 정상 토큰. - 기대결과: sub·email·name 이 그대로 나온다.""" + """검증: 우리 client_id 로 발급된 정상 토큰.""" account = await verify_id_token(_token(keypair)) assert account.sub == "1234567890" assert account.email == "boss@example.com" @@ -83,36 +73,31 @@ async def test_valid_token_yields_identity(keypair): async def test_token_for_another_app_is_refused(keypair): - """검증: 서명·발급자는 진짜인데 aud 가 **다른 서비스**인 토큰. - 기대결과: 거부 — 이 검사가 없으면 남의 앱 토큰으로 우리 계정에 들어온다.""" + """검증: 서명·발급자는 진짜인데 aud 가 **다른 서비스**인 토큰.""" with pytest.raises(GoogleTokenInvalid): await verify_id_token(_token(keypair, aud="someone-else.apps.googleusercontent.com")) async def test_token_from_another_issuer_is_refused(keypair): - """검증: 우리 aud 를 달고 있지만 iss 가 구글이 아닌 토큰. - 기대결과: 거부.""" + """검증: 우리 aud 를 달고 있지만 iss 가 구글이 아닌 토큰.""" with pytest.raises(GoogleTokenInvalid): await verify_id_token(_token(keypair, iss="https://evil.example.com")) async def test_expired_token_is_refused(keypair): - """검증: 만료된 토큰. - 기대결과: 거부 — 한 번 새어 나간 토큰이 영원히 열쇠가 되지 않게.""" + """검증: 만료된 토큰.""" with pytest.raises(GoogleTokenInvalid): await verify_id_token(_token(keypair, exp=int(time.time()) - 10)) async def test_unverified_email_is_refused(keypair): - """검증: email_verified=false. - 기대결과: 거부 — 미인증 이메일을 신원으로 쓰면 이메일 기반 중복 판정이 전부 흔들린다.""" + """검증: email_verified=false.""" with pytest.raises(GoogleTokenInvalid): await verify_id_token(_token(keypair, email_verified=False)) async def test_tampered_signature_is_refused(keypair): - """검증: 본문을 바꾼 토큰(서명 불일치). - 기대결과: 거부.""" + """검증: 본문을 바꾼 토큰(서명 불일치).""" head, payload, sig = _token(keypair).split(".") other = _token(keypair, sub="9999999999").split(".")[1] with pytest.raises(GoogleTokenInvalid): @@ -120,8 +105,7 @@ async def test_tampered_signature_is_refused(keypair): async def test_unknown_kid_refetches_keys_once(keypair, monkeypatch): - """검증: 캐시에 없는 kid(키 회전 직후). - 기대결과: JWKS 를 한 번 더 받아 검증에 성공한다 — TTL 만 믿으면 회전 직후 전원 로그인 실패다.""" + """검증: 캐시에 없는 kid(키 회전 직후).""" private_pem, jwks = keypair calls = {"n": 0} @@ -137,8 +121,7 @@ async def test_unknown_kid_refetches_keys_once(keypair, monkeypatch): async def test_missing_client_id_disables_google_login(monkeypatch, keypair): - """검증: GOOGLE_CLIENT_ID 가 비어 있을 때. - 기대결과: GoogleNotConfigured — 검증을 시도조차 하지 않는다(aud 대조 대상이 없다).""" + """검증: GOOGLE_CLIENT_ID 가 비어 있을 때.""" monkeypatch.setattr(google_identity.google_oauth_config, "client_id", "") with pytest.raises(GoogleNotConfigured): await verify_id_token(_token(keypair)) diff --git a/solution/backend/tests/test_healthz.py b/solution/backend/tests/test_healthz.py index 5551cf6..eaf765f 100644 --- a/solution/backend/tests/test_healthz.py +++ b/solution/backend/tests/test_healthz.py @@ -1,21 +1,15 @@ -"""부팅 스모크 — 앱이 뜨고 헬스체크가 응답하는지. - -보일러플레이트 이식 직후의 최소 확인. 도메인 코드가 얹히기 전에도 항상 통과해야 한다. -""" +"""부팅 스모크 — 앱이 뜨고 헬스체크가 응답하는지.""" async def test_healthz(client): - """검증: /healthz 호출. - 기대결과: 200, 서버 기동 시각 문자열(API_SERVER_START_TIME)이 그대로 반환.""" + """검증: /healthz 호출.""" r = await client.get("/healthz") assert r.status_code == 200 assert isinstance(r.json(), str) and len(r.json()) > 0 async def test_readyz_checks_db(client, db_engine): - """검증: /readyz 호출(테스트 DB 가 붙어 있는 정상 상태). - 기대결과: ★ healthz 와 다르게 실제로 DB 에 SELECT 1 을 던져 본 뒤 200 — "프로세스가 - 살아 있다" 가 아니라 "요청을 처리할 수 있다" 를 본다(외부 감시용, router.router.readyz).""" + """검증: /readyz 호출(테스트 DB 가 붙어 있는 정상 상태).""" r = await client.get("/readyz") assert r.status_code == 200 assert r.json() == {"ok": True, "db": "up"} diff --git a/solution/backend/tests/test_indexnow.py b/solution/backend/tests/test_indexnow.py index a9f4679..d798de0 100644 --- a/solution/backend/tests/test_indexnow.py +++ b/solution/backend/tests/test_indexnow.py @@ -1,9 +1,4 @@ -"""발행 URL 의 색인 통보(IndexNow). - -이 경로가 절대 하면 안 되는 것: - - 통보 실패로 발행을 되돌리는 것 — 정적 파일은 이미 올라갔다. 다음 발행에서 다시 보내면 된다. - - 사이트맵에 없는 URL 을 통보하는 것 — 404 통보는 신뢰만 깎는다. -""" +"""발행 URL 의 색인 통보(IndexNow).""" from pathlib import Path import httpx @@ -82,7 +77,7 @@ async def test_거절되어도_예외를_올리지_않는다(tmp_path, monkeypat monkeypatch.setenv("INDEXNOW_KEY", "abc12345") async def fake_post(self, url, json=None, **kwargs): - # 403 = 키 파일 대조 실패. 발행은 이미 끝났으므로 되돌리지 않는다. + # 403 = 키 파일 대조 실패. return httpx.Response(403, text="key not found", request=httpx.Request("POST", url)) monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) diff --git a/solution/backend/tests/test_itinerary_grounding.py b/solution/backend/tests/test_itinerary_grounding.py index 0b7ae19..1e252a4 100644 --- a/solution/backend/tests/test_itinerary_grounding.py +++ b/solution/backend/tests/test_itinerary_grounding.py @@ -1,17 +1,9 @@ -"""일정 응답 해석 — 모델이 준 JSON 에서 화면에 설 수 있는 코스만 남긴다. - -DB 도 네트워크도 쓰지 않는 순수 단위 테스트다. - -★ 스키마 전체를 검증하지 않는다. 항목 모양의 단일 출처는 `shared/lib/section-data.ts` 이고, - 그 모양을 파이썬에 한 벌 더 적으면 프론트가 필드를 늘린 날 서버가 조용히 떨어뜨린다 - (`grounding/story.py` 머리주석과 같은 판단). 여기서는 "화면에 설 수 있는가" 만 본다. -""" +"""일정 응답 해석 — 모델이 준 JSON 에서 화면에 설 수 있는 코스만 남긴다.""" import json from services.grounding import itinerary as grounding -# 이 파일의 모든 parse_courses 호출이 쓰는 업소명 — 실제 값은 안 중요하다(DB 값을 그대로 -# 통과시키는지만 본다). 테스트 실제 좌표가 필요한 곳은 개별 테스트가 지정한다. +# 이 파일의 모든 parse_courses 호출이 쓰는 업소명 — 실제 값은 안 중요하다(DB 값을 그대로 통과시키는지만 본다). _PLACE = "테스트업소" @@ -52,8 +44,7 @@ def _course(name: str, stops: list[str], *, days: int = 2) -> dict: def test_accepts_five_distinct_courses(): - """검증: 정상 응답 5개. - 기대결과: 전부 채택되고 버린 것이 없다.""" + """검증: 정상 응답 5개.""" items = [_course(f"코스{i}", [f"장소{i}-{j}" for j in range(4)]) for i in range(5)] courses, dropped = grounding.parse_courses(_payload(items), "1박 2일", _PLACE) assert len(courses) == 5 @@ -62,16 +53,14 @@ def test_accepts_five_distinct_courses(): def test_strips_code_fence(): - """검증: 코드펜스를 두르고 온 응답. - 기대결과: 규칙 1 로 금지했지만 모델은 종종 어긴다 — 벗겨서 읽는다.""" + """검증: 코드펜스를 두르고 온 응답.""" items = [_course("코스", ["가", "나"])] courses, dropped = grounding.parse_courses(_payload(items, fence=True), "1박 2일", _PLACE) assert len(courses) == 1 def test_forces_duration_even_if_model_wrote_something_else(): - """검증: 모델이 duration 을 다르게 적은 코스. - 기대결과: 우리가 요청한 기간으로 덮어쓴다 — 이 값이 화면 탭을 가르므로 틀리면 탭이 갈린다.""" + """검증: 모델이 duration 을 다르게 적은 코스.""" item = _course("코스", ["가", "나"]) item["duration"] = "반나절" courses, _ = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) @@ -79,8 +68,7 @@ def test_forces_duration_even_if_model_wrote_something_else(): def test_drops_course_without_name(): - """검증: 이름 없는 코스. - 기대결과: 버린다 — 제목 없는 카드가 된다.""" + """검증: 이름 없는 코스.""" item = _course("", ["가", "나"]) courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses == [] @@ -88,8 +76,7 @@ def test_drops_course_without_name(): def test_drops_course_without_stops(): - """검증: days 가 있어도 정거장이 하나도 없는 코스. - 기대결과: 버린다 — 발행본 ItinerarySection 이 어차피 걸러 빈 탭을 만든다.""" + """검증: days 가 있어도 정거장이 하나도 없는 코스.""" item = {"name": "빈 코스", "duration": "1박 2일", "days": [{"label": "1일차", "stops": []}]} courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses == [] @@ -97,9 +84,7 @@ def test_drops_course_without_stops(): def test_drops_a_course_whose_stop_set_duplicates_an_earlier_one(): - """검증: 앞 코스와 정거장 집합이 완전히 같은 코스(순서만 다름). - 기대결과: 뒤에 온 것을 버린다 — 손님 눈에는 같은 코스 둘이다. - ★ 정거장 '겹침' 자체는 허용이다(2026-09-11 결정). 집합이 **같을 때**만 버린다.""" + """검증: 앞 코스와 정거장 집합이 완전히 같은 코스(순서만 다름).""" first = _course("원도심 코스", ["가", "나", "다", "라"]) same = _course("미식 코스", ["라", "다", "나", "가"]) # 순서만 다르다 overlap = _course("자연 코스", ["가", "나", "마", "바"]) # 절반 겹치지만 다른 코스다 @@ -109,16 +94,13 @@ def test_drops_a_course_whose_stop_set_duplicates_an_earlier_one(): def test_attaches_search_result_as_source_when_model_gave_none(): - """검증: 코스에 source 가 없을 때. - 기대결과: Perplexity 가 실제로 읽은 첫 출처를 붙인다 — 모델 답변보다 이게 실재한다. - ★ source 가 아예 없으면 발행본 카드 하단에 빈 점선 띠가 남는다(ItinerarySection 껍데기).""" + """검증: 코스에 source 가 없을 때.""" courses, _ = grounding.parse_courses(_payload([_course("코스", ["가", "나"])]), "1박 2일", _PLACE) assert courses[0]["source"]["url"] == "https://www.gunsan.go.kr/tour/" def test_keeps_model_source_when_it_is_a_real_url(): - """검증: 모델이 준 source 가 http 로 시작할 때. - 기대결과: 그걸 쓴다.""" + """검증: 모델이 준 source 가 http 로 시작할 때.""" item = _course("코스", ["가", "나"]) item["source"] = {"name": "군산시", "url": "https://www.gunsan.go.kr/index.gunsan"} courses, _ = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) @@ -126,9 +108,7 @@ def test_keeps_model_source_when_it_is_a_real_url(): def test_course_survives_without_any_source(): - """검증: 모델 source 도 없고 search_results 도 빈 응답. - 기대결과: 코스는 살린다 — 출처가 없다고 일정을 버리면 화면이 통째로 빈다. - (지역 이야기와 다른 판단이다. 저쪽은 '사실' 이고 이쪽은 '제안' 이다.)""" + """검증: 모델 source 도 없고 search_results 도 빈 응답.""" items = [_course("코스", ["가", "나"])] courses, _ = grounding.parse_courses(_payload(items, search=[]), "1박 2일", _PLACE) assert len(courses) == 1 @@ -136,8 +116,7 @@ def test_course_survives_without_any_source(): def test_returns_empty_on_non_json(): - """검증: JSON 이 아닌 응답. - 기대결과: 빈 목록 + 이유. 예외를 던지지 않는다 — 잡이 죽으면 다른 기간도 못 받는다.""" + """검증: JSON 이 아닌 응답.""" payload = {"choices": [{"message": {"content": "죄송합니다, 일정을 만들 수 없습니다."}}]} courses, dropped = grounding.parse_courses(payload, "1박 2일", _PLACE) assert courses == [] @@ -152,7 +131,7 @@ def test_returns_empty_when_items_is_not_a_list(): assert dropped -# ── 하루 시각표 강제 (2026-09-11, 사장님 지시) ──────────────────────────── +# ── 하루 시각표 강제 ──────────────────────────── def _stop(name: str, minutes: int = 60, move: int = 10) -> dict: return {"name": name, "minutes": minutes, "moveMinutes": move, "searchQuery": name, @@ -160,8 +139,7 @@ def _stop(name: str, minutes: int = 60, move: int = 10) -> dict: def test_overrides_start_time_and_label_regardless_of_model_values(): - """검증: 모델이 startTime·label 을 다르게 적어도. - 기대결과: DAY_SCHEDULE 값(15:00/첫째 날, 09:00/둘째 날)으로 덮어쓴다 — duration 과 같은 판단.""" + """검증: 모델이 startTime·label 을 다르게 적어도.""" item = { "name": "코스", "duration": "1박 2일", "days": [ @@ -177,10 +155,7 @@ def test_overrides_start_time_and_label_regardless_of_model_values(): def test_trims_stops_that_would_run_past_the_days_end_time(): - """검증: 첫째 날(15:00~19:00)에 정거장을 오래 머무는 것부터 채운다. - 기대결과: 예산을 넘기는 뒤쪽 정거장만 잘린다 — 순서는 보존된다. - ★ 업소가 각 날 맨 앞(출발)에, 복귀하는 날(첫째 날)은 맨 뒤에도 선다 — 이건 시간 예산을 - 먹기 전에(트리밍 이후) 얹으므로 트리밍 자체에는 영향이 없다.""" + """검증: 첫째 날(15:00~19:00)에 정거장을 오래 머무는 것부터 채운다.""" item = { "name": "코스", "duration": "1박 2일", "days": [ @@ -198,8 +173,7 @@ def test_trims_stops_that_would_run_past_the_days_end_time(): def test_drops_course_when_first_stop_of_a_day_already_overruns(): - """검증: 첫째 날 첫 정거장부터 종료 시각(19:00)을 넘긴다. - 기대결과: 그 날이 통째로 비어 코스 자체를 버린다 — 빈 날을 카드로 보여주지 않는다.""" + """검증: 첫째 날 첫 정거장부터 종료 시각(19:00)을 넘긴다.""" item = { "name": "코스", "duration": "1박 2일", "days": [ @@ -213,8 +187,7 @@ def test_drops_course_when_first_stop_of_a_day_already_overruns(): def test_drops_course_whose_day_count_is_short_of_the_duration(): - """검증: '1박 2일'인데 하루치 밖에 없다. - 기대결과: 버린다 — 둘째 날이 아예 없는 일정을 내보내지 않는다.""" + """검증: '1박 2일'인데 하루치 밖에 없다.""" item = {"name": "코스", "duration": "1박 2일", "days": [{"stops": [_stop("가")]}]} courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses == [] @@ -222,9 +195,7 @@ def test_drops_course_whose_day_count_is_short_of_the_duration(): def test_lodging_is_the_departure_and_return_point(): - """검증: 업소는 실제 정거장(stops[])이다(2026-09-11 결정: "업소는 출발지에 포함"). - 기대결과: 복귀하는 날은 맨 앞·맨 뒤 둘 다, 체크아웃 날(마지막 날)은 맨 앞만 — LLM 이 고른 - 정거장 수("하루 3~5곳")와는 별개로 얹는다.""" + """검증: 업소는 실제 정거장(stops[])이다.""" item = { "name": "코스", "duration": "2박 3일", "days": [ @@ -242,8 +213,7 @@ def test_lodging_is_the_departure_and_return_point(): def test_lodging_stop_carries_coordinates_only_when_known(): - """검증: 업소 좌표가 있을 때/없을 때. - 기대결과: 있으면 싣고, 없으면 이름만 — 틀린 핀을 찍느니 안 찍는다(PlannerStop 과 같은 규칙).""" + """검증: 업소 좌표가 있을 때/없을 때.""" item = {"name": "코스", "duration": "1박 2일", "days": [{"stops": [_stop("가")]}, {"stops": [_stop("나")]}]} diff --git a/solution/backend/tests/test_itinerary_llm_service.py b/solution/backend/tests/test_itinerary_llm_service.py index 0ba01f9..58b5b22 100644 --- a/solution/backend/tests/test_itinerary_llm_service.py +++ b/solution/backend/tests/test_itinerary_llm_service.py @@ -1,13 +1,4 @@ -"""일정 생성 오케스트레이션 — 언제 부르고 언제 안 부르나. - -★ 실제 Perplexity 를 부르지 않는다. 유료·느리고, APP_ENV=test 는 .env 를 안 읽어 키도 없다. - `perplexity.call` 을 가로채 호출 횟수와 프롬프트를 본다. - -이 서비스가 지켜야 하는 것: - - 이미 있는 기간은 다시 부르지 않는다(같은 업체는 그대로 재사용 — 2026-09-11 결정) - - 지역을 모르면 아예 부르지 않는다(모델이 아무 도시나 고른다) - - 한 기간이 실패해도 다른 기간은 저장한다 -""" +"""일정 생성 오케스트레이션 — 언제 부르고 언제 안 부르나.""" import json import uuid @@ -34,8 +25,7 @@ class _FakePlace: def _response(names: list[str], duration: str) -> dict: - """가짜 Perplexity 응답. `_apply_schedule`(grounding)이 하루 수·시각을 검사하므로 - DAY_SCHEDULE 이 요구하는 일수만큼 하루를 채운다 — 안 채우면 전부 드롭된다(실측 회귀).""" + """가짜 Perplexity 응답.""" items = [ {"name": n, "duration": duration, "audience": "누구에게나", "why": "이유.", "days": [ @@ -84,12 +74,7 @@ async def _rows(place_id): async def test_generates_both_durations(db_engine, spy_perplexity): - """검증: 아무것도 없는 업장. - 기대결과: 기간 둘을 각각 만들고 각각 5개 코스를 저장한다. - - ★ 호출 수는 기간당 MAX_ATTEMPTS 다. 목응답이 5개(< TARGET_COURSES=10)라 서비스가 - 같은 프롬프트로 한 번 더 부르고, 두 번째 응답은 already_seen 에 걸려 전부 버려진다 — - 그래서 호출은 늘어도 저장된 코스는 5개 그대로다.""" + """검증: 아무것도 없는 업장.""" calls, _ = spy_perplexity pid = uuid.uuid4() @@ -104,8 +89,7 @@ async def test_generates_both_durations(db_engine, spy_perplexity): async def test_does_not_call_again_for_durations_already_stored(db_engine, spy_perplexity): - """검증: 한 번 만든 업장을 다시 부른다. - 기대결과: 추가 호출 0회 — 유료 호출을 두 번 하지 않는다(요금 가드).""" + """검증: 한 번 만든 업장을 다시 부른다.""" calls, _ = spy_perplexity pid = uuid.uuid4() await service.ensure_generated(_FakePlace(pid)) @@ -119,8 +103,7 @@ async def test_does_not_call_again_for_durations_already_stored(db_engine, spy_p async def test_fills_only_the_missing_duration(db_engine, spy_perplexity): - """검증: 1박2일만 있는 업장. - 기대결과: 2박3일만 부른다 — missing_durations 가 기준이다.""" + """검증: 1박2일만 있는 업장.""" calls, _ = spy_perplexity pid = uuid.uuid4() await service.ensure_generated(_FakePlace(pid)) @@ -142,8 +125,7 @@ async def test_fills_only_the_missing_duration(db_engine, spy_perplexity): async def test_skips_when_region_is_unknown(db_engine, spy_perplexity): - """검증: 주소가 없는 업장. - 기대결과: 호출하지 않는다 — 지역을 모른 채 물으면 모델이 아무 도시나 고른다.""" + """검증: 주소가 없는 업장.""" calls, _ = spy_perplexity place = _FakePlace(uuid.uuid4(), road_address="") place.address = "" @@ -155,8 +137,7 @@ async def test_skips_when_region_is_unknown(db_engine, spy_perplexity): async def test_skips_when_key_is_missing(db_engine, monkeypatch): - """검증: PERPLEXITY_API_KEY 미설정. - 기대결과: 조용히 건너뛴다 — 키가 없다고 빌드나 에디터를 막지 않는다.""" + """검증: PERPLEXITY_API_KEY 미설정.""" monkeypatch.setattr(perplexity, "is_configured", lambda: False) out = await service.ensure_generated(_FakePlace(uuid.uuid4())) assert out["counts"] == {} @@ -164,8 +145,7 @@ async def test_skips_when_key_is_missing(db_engine, monkeypatch): async def test_one_duration_failing_does_not_lose_the_other(db_engine, spy_perplexity): - """검증: 1박2일 호출이 실패한다. - 기대결과: 2박3일은 저장된다 — 한 기간의 실패가 다른 기간을 끌고 내려가지 않는다.""" + """검증: 1박2일 호출이 실패한다.""" calls, plan = spy_perplexity plan["1박 2일"] = "error" pid = uuid.uuid4() @@ -177,8 +157,7 @@ async def test_one_duration_failing_does_not_lose_the_other(db_engine, spy_perpl async def test_unparseable_response_stores_nothing_for_that_duration(db_engine, spy_perplexity): - """검증: JSON 이 아닌 응답. - 기대결과: 그 기간은 저장하지 않는다(빈 body 로 행을 만들지 않는다).""" + """검증: JSON 이 아닌 응답.""" _calls, plan = spy_perplexity plan["2박 3일"] = "garbage" pid = uuid.uuid4() @@ -190,8 +169,7 @@ async def test_unparseable_response_stores_nothing_for_that_duration(db_engine, async def test_prompt_carries_place_and_region(db_engine, spy_perplexity): - """검증: 실제로 보낸 프롬프트. - 기대결과: 상호와 지역(주소 앞 두 토막)이 들어간다.""" + """검증: 실제로 보낸 프롬프트.""" calls, _ = spy_perplexity await service.ensure_generated(_FakePlace(uuid.uuid4())) @@ -203,9 +181,7 @@ async def test_prompt_carries_place_and_region(db_engine, spy_perplexity): async def test_get_itineraries_returns_courses_in_duration_order(db_engine, spy_perplexity): - """검증: 읽기. - 기대결과: DURATIONS 순(1박2일 → 2박3일)으로 이어 붙인 ItineraryItem[] 이다 — - 화면 탭 순서가 저장 순서에 흔들리지 않아야 한다.""" + """검증: 읽기.""" pid = uuid.uuid4() await service.ensure_generated(_FakePlace(pid)) @@ -217,8 +193,7 @@ async def test_get_itineraries_returns_courses_in_duration_order(db_engine, spy_ async def test_missing_durations_reports_what_is_absent(db_engine, spy_perplexity): - """검증: missing_durations. - 기대결과: 비었을 때 둘 다, 채운 뒤엔 빈 목록 — 잡 가드가 이 값을 본다.""" + """검증: missing_durations.""" pid = uuid.uuid4() assert await service.missing_durations(pid) == list(prompts.DURATIONS) await service.ensure_generated(_FakePlace(pid)) @@ -226,8 +201,7 @@ async def test_missing_durations_reports_what_is_absent(db_engine, spy_perplexit async def test_ensure_generated_by_id_loads_the_place(db_engine, spy_perplexity, owner_id): - """검증: 잡이 쓰는 입구(place_id 만 있다). - 기대결과: places 행을 읽어 같은 일을 한다.""" + """검증: 잡이 쓰는 입구(place_id 만 있다).""" calls, _ = spy_perplexity pid = uuid.uuid4() from sqlalchemy import text @@ -246,8 +220,7 @@ async def test_ensure_generated_by_id_loads_the_place(db_engine, spy_perplexity, async def test_ensure_generated_by_id_on_unknown_place(db_engine, spy_perplexity): - """검증: 없는 업장 id. - 기대결과: 호출하지 않고 이유만 남긴다 — 잡이 죽지 않는다.""" + """검증: 없는 업장 id.""" calls, _ = spy_perplexity out = await service.ensure_generated_by_id(uuid.uuid4()) assert calls == [] @@ -256,9 +229,7 @@ async def test_ensure_generated_by_id_on_unknown_place(db_engine, spy_perplexity async def test_lodging_coordinates_flow_from_place_to_generated_stops(db_engine, spy_perplexity): - """검증: place 에 좌표가 있으면 저장된 일정의 업소 정거장에도 실린다(2026-09-11 결정: - "업소는 출발지에 포함"). 기대결과: ensure_generated 가 place.latitude/longitude 를 - grounding 의 _apply_schedule 까지 넘긴다 — 모델에게 묻지 않는다.""" + """검증: place 에 좌표가 있으면 저장된 일정의 업소 정거장에도 실린다.""" place = _FakePlace(uuid.uuid4()) place.latitude = 35.98642 place.longitude = 126.70612 diff --git a/solution/backend/tests/test_itinerary_payload.py b/solution/backend/tests/test_itinerary_payload.py index b08f0e2..2e91b1f 100644 --- a/solution/backend/tests/test_itinerary_payload.py +++ b/solution/backend/tests/test_itinerary_payload.py @@ -1,9 +1,4 @@ -"""스냅샷의 일정이 payload 로 그대로 나가는가. - -★ `site_payload._local` 은 **순수 함수**다(DB 도 파일도 안 건드린다). 그래서 여기서는 - 스냅샷 dict 를 손으로 만들어 넣는다 — 발행본이 스냅샷과 다른 페이지가 되지 않게 하는 - 그 원칙(`site_payload.to_site_payload` 머리주석)을 테스트도 같은 방식으로 따른다. -""" +"""스냅샷의 일정이 payload 로 그대로 나가는가.""" from services.site_payload import _local @@ -17,8 +12,7 @@ def _course(name: str, duration: str) -> dict: def test_itineraries_pass_through_untouched(): - """검증: 스냅샷에 일정이 있을 때. - 기대결과: payload.local.itineraries 로 모양 그대로 나간다 — 렌더러 계약을 여기서 바꾸지 않는다.""" + """검증: 스냅샷에 일정이 있을 때.""" snapshot_local = { "region_code": "52군산시", "contents": [], @@ -31,31 +25,26 @@ def test_itineraries_pass_through_untouched(): def test_no_itineraries_key_when_empty(): - """검증: 일정이 없을 때. - 기대결과: 키를 만들지 않는다 — 렌더러는 없는 필드를 무시하고, 빈 배열은 '만들었는데 비었다'로 읽힌다.""" + """검증: 일정이 없을 때.""" local, _synced = _local({"region_code": "52군산시", "contents": [], "itineraries": []}, 35.98, 126.70) assert "itineraries" not in local def test_old_snapshot_without_the_key_does_not_break(): - """검증: 이 기능 전에 구운 스냅샷(키가 아예 없다). - 기대결과: 빈 채로 지나간다 — 다음 빌드에서 채워진다.""" + """검증: 이 기능 전에 구운 스냅샷(키가 아예 없다).""" local, _synced = _local({"region_code": "52군산시", "contents": []}, 35.98, 126.70) assert "itineraries" not in local def test_non_dict_entries_are_dropped(): - """검증: body 에 dict 가 아닌 것이 섞였을 때. - 기대결과: 그것만 버리고 나머지는 살린다.""" + """검증: body 에 dict 가 아닌 것이 섞였을 때.""" snapshot_local = {"contents": [], "itineraries": [_course("코스A", "1박 2일"), "깨진 값", None]} local, _synced = _local(snapshot_local, 35.98, 126.70) assert [i["name"] for i in local["itineraries"]] == ["코스A"] def test_distance_based_algorithm_is_not_used_anymore(): - """검증: 좌표와 주변 관광지·맛집이 있어도 알고리즘 일정이 끼지 않는다. - 기대결과: 스냅샷의 itineraries 만 나간다 — services/itinerary.py 는 잠시 쓰지 않는다 - (2026-09-11 결정). 되살릴 때 이 테스트를 함께 고친다.""" + """검증: 좌표와 주변 관광지·맛집이 있어도 알고리즘 일정이 끼지 않는다.""" snapshot_local = { "contents": [ {"content_type": 3, "title": "관광지", "latitude": 35.99, "longitude": 126.71, diff --git a/solution/backend/tests/test_itinerary_prompt.py b/solution/backend/tests/test_itinerary_prompt.py index 6caaf8a..4622ec2 100644 --- a/solution/backend/tests/test_itinerary_prompt.py +++ b/solution/backend/tests/test_itinerary_prompt.py @@ -1,21 +1,11 @@ -"""여행 일정 프롬프트 — 무엇을 반드시 물어야 하는가. - -DB 도 네트워크도 쓰지 않는 순수 단위 테스트다. - -이 파일이 지키는 것(스파이크 2026-09-11 에서 실측으로 얻은 것들): - - 컨셉 5개를 **우리가** 준다. 모델에 맡기면 2박 3일에서 5개 중 4개가 정거장 집합 100% 동일이 된다 - - 컨셉당 2개씩, 총 10개를 요청한다(2026-09-11, 사장님 지시: "중복 허용하고 10개로") - - 업소 자신을 정거장에 넣지 말라고 못 박는다. 안 적으면 "스테이,머뭄" 이 정거장으로 들어온다 - - 지역명을 넣는다. 없으면 모델이 아무 도시나 고른다(story_service.region_label_of 와 같은 이유) -""" +"""여행 일정 프롬프트 — 무엇을 반드시 물어야 하는가.""" import pytest from services.prompts import itinerary as prompts def test_durations_are_two_render_contract_strings(): - """검증: 기간 문자열. - 기대결과: 화면 탭·ItineraryItem.duration 이 되는 값이라 표기가 정확해야 한다.""" + """검증: 기간 문자열.""" assert prompts.DURATIONS == ("1박 2일", "2박 3일") @@ -29,16 +19,14 @@ def test_prompt_carries_place_region_and_duration(): def test_prompt_fixes_five_concepts(): - """검증: 컨셉 5개가 프롬프트에 박혀 있다. - 기대결과: 모델이 컨셉을 고르지 않는다 — 고르게 하면 장소가 수렴한다.""" + """검증: 컨셉 5개가 프롬프트에 박혀 있다.""" text = prompts.build_prompt("업소", "지역", "2박 3일") for concept in ("역사·근대건축", "자연 풍경", "미식", "아이와 함께", "야외활동"): assert concept in text def test_prompt_asks_for_ten_courses_total(): - """검증: 컨셉당 2개씩 총 10개를 요청한다(2026-09-11: 5개 → 10개, 중복 허용). - 기대결과: 컨셉 축 자체는 그대로 5개이고, 개수만 컨셉당 2개로 늘어난다.""" + """검증: 컨셉당 2개씩 총 10개를 요청한다.""" text = prompts.build_prompt("업소", "지역", "1박 2일") assert "5개 컨셉마다 2개씩, 총 10개" in text assert "같은 컨셉 안의 두 코스도 서로 다른 일정이어야 한다" in text @@ -51,22 +39,19 @@ def test_prompt_forbids_the_lodging_itself_as_a_stop(): def test_prompt_requires_coordinates_and_forbids_fences(): - """검증: 좌표는 항상 요구하고, 코드펜스는 금지한다. - 기대결과: 좌표를 '아는 곳만' 으로 두면 정거장 30곳 중 8곳이 좌표 없이 온다(실측).""" + """검증: 좌표는 항상 요구하고, 코드펜스는 금지한다.""" text = prompts.build_prompt("업소", "지역", "1박 2일") assert "모든 정거장에 latitude·longitude 를 적는다" in text assert "코드펜스를 붙이지 않는다" in text def test_prompt_forbids_identical_courses(): - """검증: 같은 정거장 집합 금지 규칙. - 기대결과: 정거장 겹침 자체는 허용이고, '같은 코스' 만 막는다(2026-09-11 결정).""" + """검증: 같은 정거장 집합 금지 규칙.""" text = prompts.build_prompt("업소", "지역", "2박 3일") assert "같은 정거장 집합" in text def test_unknown_duration_is_rejected(): - """검증: 모르는 기간. - 기대결과: ValueError — 화면에 없는 탭을 만드는 값이 조용히 저장되면 안 된다.""" + """검증: 모르는 기간.""" with pytest.raises(ValueError): prompts.build_prompt("업소", "지역", "3박 4일") diff --git a/solution/backend/tests/test_itinerary_trigger.py b/solution/backend/tests/test_itinerary_trigger.py index 60c5613..6918426 100644 --- a/solution/backend/tests/test_itinerary_trigger.py +++ b/solution/backend/tests/test_itinerary_trigger.py @@ -1,12 +1,4 @@ -"""일정 생성은 **언제** 걸리나. - -★ 에디터 요청 안에서 생성하지 않는다. 두 기간 합쳐 50~100초다 — - 지역 이야기가 잡으로 도는 이유와 같다(local_content_service._ensure_region_stories L444). - 캔버스는 잡을 넣고, 이번 응답에는 안 실린다. **다음에 열 때** 보인다. - -★ 가드가 "이야기가 다 찼나" 만 보면 안 된다. 이미 이야기가 찬 업장은 일정을 영영 못 받는다 — - story_service 가 has_stories 하나로 판단하다 daily 를 못 받던 것과 같은 함정이다. -""" +"""일정 생성은 **언제** 걸리나.""" import uuid from services import story_service @@ -36,11 +28,7 @@ class _SyncResult: async def test_local_sync_job_generates_itineraries(monkeypatch, db_engine): - """검증: LOCAL_SYNC 잡이 일정 생성을 부른다. - 기대결과: place_id 로 ensure_generated_by_id 를 부르고 결과를 잡 결과에 싣는다. - - ★ 이야기 블록은 키가 없으면(APP_ENV=test 는 .env 를 안 읽는다) 조기 반환한다 — - 그래서 일정 블록이 **이야기보다 앞**에 있어야 이 테스트가 통과한다. 그게 의도다.""" + """검증: LOCAL_SYNC 잡이 일정 생성을 부른다.""" called: list = [] async def fake_ensure(place_id): @@ -64,9 +52,7 @@ async def test_local_sync_job_generates_itineraries(monkeypatch, db_engine): async def test_canvas_enqueues_job_when_only_itineraries_are_missing(monkeypatch, db_engine): - """검증: 이야기는 다 찼지만 일정이 없는 업장이 캔버스를 연다. - 기대결과: 잡을 넣는다 — 가드가 일정 누락도 보기 때문이다. - ★ 이 테스트가 없으면 "이야기가 찬 업장은 일정을 영영 못 받는" 함정이 조용히 살아난다.""" + """검증: 이야기는 다 찼지만 일정이 없는 업장이 캔버스를 연다.""" enqueued: list = [] async def fake_enqueue(place): @@ -91,8 +77,7 @@ async def test_canvas_enqueues_job_when_only_itineraries_are_missing(monkeypatch async def test_canvas_does_not_enqueue_when_everything_is_filled(monkeypatch, db_engine): - """검증: 이야기도 일정도 다 있는 업장. - 기대결과: 잡을 넣지 않는다 — 유료 호출을 다시 걸지 않는다(요금 가드).""" + """검증: 이야기도 일정도 다 있는 업장.""" enqueued: list = [] async def fake_enqueue(place): diff --git a/solution/backend/tests/test_job_queue.py b/solution/backend/tests/test_job_queue.py index 7f45523..872068d 100644 --- a/solution/backend/tests/test_job_queue.py +++ b/solution/backend/tests/test_job_queue.py @@ -1,11 +1,4 @@ -"""작업 큐 — 도커에서 워커를 여러 개 띄웠을 때 지켜져야 하는 것들. - -수집·비전분석·빌드는 몇 분 걸려 동기 요청으로 못 한다. 그 큐가 실제로: - 1. 같은 잡을 두 워커에 이중 할당하지 않는가 (FOR UPDATE SKIP LOCKED) - 2. 워커가 죽어도 잡이 증발하지 않는가 (lease 만료 → reaper 회수) - 3. 같은 요청을 두 번 눌러도 잡이 두 번 돌지 않는가 (dedupe_key) - 4. 실패가 백오프 재시도 → dead-letter 로 흐르는가 -""" +"""작업 큐 — 도커에서 워커를 여러 개 띄웠을 때 지켜져야 하는 것들.""" import asyncio import uuid @@ -18,8 +11,7 @@ from worker.runner import Worker, run_reaper async def test_enqueue_and_claim(db_engine): - """검증: 잡을 넣고 워커가 claim 한다. - 기대결과: RUNNING 으로 전이되고 attempts 가 1 올라간다.""" + """검증: 잡을 넣고 워커가 claim 한다.""" q = JobQueue() jid = await q.enqueue(JobType.COLLECT.value, {"place_id": "abc"}) assert jid @@ -60,8 +52,7 @@ async def test_progress_rejects_old_worker_and_resets_on_retry(db_engine): async def test_claim_is_atomic_across_workers(db_engine): - """검증: 잡 1건에 워커 5개가 동시에 달려든다. - 기대결과: 정확히 1명만 가져간다 — 도커에서 워커를 몇 개로 스케일하든 이중 실행이 없다.""" + """검증: 잡 1건에 워커 5개가 동시에 달려든다.""" q = JobQueue() jid = await q.enqueue(JobType.COLLECT.value, {"place_id": "race"}) @@ -72,14 +63,12 @@ async def test_claim_is_atomic_across_workers(db_engine): async def test_claim_returns_none_when_empty(db_engine): - """검증: 빈 큐에서 claim. - 기대결과: None — 워커는 LISTEN 대기로 넘어간다.""" + """검증: 빈 큐에서 claim.""" assert await JobQueue().claim("w1") is None async def test_dedupe_key_blocks_duplicate_active_job(db_engine): - """검증: 같은 dedupe_key 로 두 번 적재한다. - 기대결과: 두 번째는 None — 같은 사업장 수집을 두 번 눌러도 잡은 하나다.""" + """검증: 같은 dedupe_key 로 두 번 적재한다.""" q = JobQueue() key = f"collect:{uuid.uuid4()}" first = await q.enqueue(JobType.COLLECT.value, {}, dedupe_key=key) @@ -93,8 +82,7 @@ async def test_dedupe_key_blocks_duplicate_active_job(db_engine): async def test_dedupe_key_frees_after_completion(db_engine): - """검증: 완료된 뒤 같은 dedupe_key 로 다시 적재한다. - 기대결과: 새 잡이 생긴다 — 부분 유니크가 PENDING/RUNNING 에만 걸리기 때문.""" + """검증: 완료된 뒤 같은 dedupe_key 로 다시 적재한다.""" q = JobQueue() key = f"collect:{uuid.uuid4()}" first = await q.enqueue(JobType.COLLECT.value, {}, dedupe_key=key) @@ -106,8 +94,7 @@ async def test_dedupe_key_frees_after_completion(db_engine): async def test_complete_requires_ownership(db_engine): - """검증: 자기가 점유하지 않은 잡을 완료 처리한다. - 기대결과: False — 소유권 가드(worker_id) 때문에 남의 잡을 끝낼 수 없다.""" + """검증: 자기가 점유하지 않은 잡을 완료 처리한다.""" q = JobQueue() await q.enqueue(JobType.BUILD.value, {}) claimed = await q.claim("w1") @@ -117,8 +104,7 @@ async def test_complete_requires_ownership(db_engine): async def test_fail_retries_then_dead_letters(db_engine): - """검증: max_attempts 만큼 계속 실패시킨다. - 기대결과: 시도가 남으면 PENDING(백오프), 소진되면 DEAD — 무한 재시도가 없다.""" + """검증: max_attempts 만큼 계속 실패시킨다.""" q = JobQueue() jid = await q.enqueue(JobType.VISION.value, {}, max_attempts=2) @@ -135,8 +121,7 @@ async def test_fail_retries_then_dead_letters(db_engine): async def test_backoff_delays_next_claim(db_engine): - """검증: 실패 후 백오프가 걸린 잡. - 기대결과: run_after 가 지나기 전에는 claim 되지 않는다.""" + """검증: 실패 후 백오프가 걸린 잡.""" q = JobQueue() jid = await q.enqueue(JobType.COPY.value, {}, max_attempts=5) await q.claim("w1") @@ -146,8 +131,7 @@ async def test_backoff_delays_next_claim(db_engine): async def test_reaper_reclaims_dead_worker_job(db_engine): - """검증: 워커가 잡을 쥔 채 죽는다(도커 컨테이너 재시작). - 기대결과: lease 가 만료되면 reaper 가 회수해 다시 대기로 돌린다 — 잡이 증발하지 않는다.""" + """검증: 워커가 잡을 쥔 채 죽는다(도커 컨테이너 재시작).""" q = JobQueue() jid = await q.enqueue(JobType.COLLECT.value, {}) await q.claim("dead-worker", lease_sec=120) @@ -169,8 +153,7 @@ async def test_reaper_reclaims_dead_worker_job(db_engine): async def test_requeue_only_works_on_dead_jobs(db_engine): - """검증: DEAD 잡과 PENDING 잡에 각각 재큐를 시도한다. - 기대결과: DEAD 만 되살아나고 attempts 가 0으로 초기화된다.""" + """검증: DEAD 잡과 PENDING 잡에 각각 재큐를 시도한다.""" q = JobQueue() jid = await q.enqueue(JobType.COLLECT.value, {}, max_attempts=1) await q.claim("w1") @@ -185,8 +168,7 @@ async def test_requeue_only_works_on_dead_jobs(db_engine): async def test_worker_runs_handler_and_completes(db_engine): - """검증: 워커 루프가 잡을 집어 핸들러를 돌리고 완료 처리한다. - 기대결과: 핸들러 반환값이 result 에 저장되고 status=DONE.""" + """검증: 워커 루프가 잡을 집어 핸들러를 돌리고 완료 처리한다.""" q = JobQueue() jid = await q.enqueue(JobType.COLLECT.value, {"place_id": "p1"}) @@ -206,8 +188,7 @@ async def test_worker_runs_handler_and_completes(db_engine): async def test_worker_deadline_cancels_hung_handler(db_engine): - """검증: 핸들러가 행(hang)에 빠진다. - 기대결과: 데드라인에 취소되고 잡은 재큐된다 — 행이 워커 슬롯을 영구 점유하지 않는다.""" + """검증: 핸들러가 행(hang)에 빠진다.""" q = JobQueue() jid = await q.enqueue(JobType.COLLECT.value, {}, max_attempts=5) @@ -223,8 +204,7 @@ async def test_worker_deadline_cancels_hung_handler(db_engine): async def test_unregistered_job_type_fails_loudly(db_engine): - """검증: 핸들러가 등록되지 않은 JobType 을 처리한다. - 기대결과: UnknownJobType 으로 실패하고 last_error 에 남는다(조용히 성공하지 않는다).""" + """검증: 핸들러가 등록되지 않은 JobType 을 처리한다.""" q = JobQueue() jid = await q.enqueue(JobType.AI_CHECK.value, {}, max_attempts=1) @@ -237,9 +217,7 @@ async def test_unregistered_job_type_fails_loudly(db_engine): async def test_dead_letter_creates_an_alert(db_engine): - """검증: 잡이 재시도를 소진해 DEAD 로 떨어진다. - 기대결과: ★ alert_outbox 에 job_dead 알림이 쌓인다 — 운영자가 잡 하나하나를 눈으로 - 훑지 않아도 dead-letter 를 안다(worker/runner.py _alert_job_dead).""" + """검증: 잡이 재시도를 소진해 DEAD 로 떨어진다.""" q = JobQueue() jid = await q.enqueue(JobType.AI_CHECK.value, {"place_id": "p-alert-1"}, max_attempts=1) @@ -257,8 +235,7 @@ async def test_dead_letter_creates_an_alert(db_engine): async def test_reaper_loop_stops_on_event(db_engine): - """검증: reaper 루프에 stop 이벤트를 건다. - 기대결과: 즉시 빠져나온다(graceful shutdown 이 매달리지 않는다).""" + """검증: reaper 루프에 stop 이벤트를 건다.""" stop = asyncio.Event() task = asyncio.create_task(run_reaper(JobQueue(), stop, interval=0.05)) await asyncio.sleep(0.1) @@ -267,8 +244,7 @@ async def test_reaper_loop_stops_on_event(db_engine): def test_backoff_is_exponential_and_capped(): - """검증: 백오프 계산. - 기대결과: 5초부터 2배씩 늘고 600초에서 멈춘다.""" + """검증: 백오프 계산.""" assert compute_backoff(1) == 5 assert compute_backoff(3) == 20 assert compute_backoff(99) == 600 diff --git a/solution/backend/tests/test_kakao.py b/solution/backend/tests/test_kakao.py index 96b516f..81acc9d 100644 --- a/solution/backend/tests/test_kakao.py +++ b/solution/backend/tests/test_kakao.py @@ -1,12 +1,4 @@ -"""카카오 로컬 클라이언트 — 동일 업소 판정과 응답 파싱. - -★ 실제 API 를 호출하지 않는다(httpx.MockTransport). 카카오 호출은 유료라 테스트가 때리면 안 된다. - -여기서 고정하는 것: - 1. x=경도 / y=위도 — 뒤집으면 엉뚱한 지역의 주변 정보가 붙는다 - 2. 동일 업소 판정이 애매할 때 **반드시 ambiguous 로 떨어지는지** — 이 서비스에서 가장 비싼 실수 - 3. 키 미설정·장애가 조용히 빈 값으로 새지 않는지 -""" +"""카카오 로컬 클라이언트 — 동일 업소 판정과 응답 파싱.""" import httpx import pytest @@ -31,8 +23,8 @@ _KEYWORD_DOC = { "phone": "033-672-0000", "address_name": "강원 양양군 현북면 하광정리 3-1", "road_address_name": "강원 양양군 현북면 하조대해안길 3", - "x": "128.6712345", # ★ 경도 - "y": "38.0451234", # ★ 위도 + "x": "128.6712345", # 경도 + "y": "38.0451234", # 위도 "place_url": "http://place.map.kakao.com/26338954", } @@ -57,7 +49,7 @@ _COORD2REGION_DOCS = [ def _client(handler, api_key: str = "test-kakao-key") -> KakaoLocalClient: - """MockTransport 를 물린 클라이언트. 실제 네트워크를 타지 않는다.""" + """MockTransport 를 물린 클라이언트.""" return KakaoLocalClient(api_key=api_key, transport=httpx.MockTransport(handler)) @@ -71,8 +63,7 @@ def _place(name, kakao_id="1", phone=None, road="강원 양양군 A로 1") -> Ka # ── 1) 키워드 검색 파싱 ─────────────────────────────────────────────────── async def test_search_keyword_parses_documents(): - """검증: 키워드 검색 응답을 KakaoPlace 로 파싱한다. - 기대결과: ★ x 가 경도(longitude), y 가 위도(latitude) 로 들어간다 — 뒤집히면 다른 지역이 된다.""" + """검증: 키워드 검색 응답을 KakaoPlace 로 파싱한다.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: @@ -88,15 +79,14 @@ async def test_search_keyword_parses_documents(): assert p.name == "하조대펜션" assert p.phone == "033-672-0000" assert p.road_address == "강원 양양군 현북면 하조대해안길 3" - assert p.longitude == pytest.approx(128.6712345) # ★ x - assert p.latitude == pytest.approx(38.0451234) # ★ y + assert p.longitude == pytest.approx(128.6712345) # x + assert p.latitude == pytest.approx(38.0451234) # y assert captured["auth"] == "KakaoAK test-kakao-key" assert "search/keyword.json" in captured["url"] async def test_search_keyword_sends_coordinates_when_given(): - """검증: 좌표를 주고 키워드 검색한다. - 기대결과: x=경도, y=위도 로 쿼리에 실린다(지역을 알면 후보가 깨끗해진다).""" + """검증: 좌표를 주고 키워드 검색한다.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: @@ -110,8 +100,7 @@ async def test_search_keyword_sends_coordinates_when_given(): async def test_search_keyword_empty_result(): - """검증: 후보가 없는 검색. - 기대결과: 빈 리스트 — 예외가 아니다(판정은 pick_match 가 한다).""" + """검증: 후보가 없는 검색.""" def handler(request): return httpx.Response(200, json={"documents": []}) @@ -120,8 +109,7 @@ async def test_search_keyword_empty_result(): # ── 2) 좌표 → 행정구역 코드 ─────────────────────────────────────────────── async def test_coord_to_region_prefers_administrative_dong(): - """검증: 좌표를 행정구역 코드로 바꾼다. - 기대결과: 행정동(region_type=H)을 우선 고른다 — 생활권 기준이라 주변 정보와 맞는다.""" + """검증: 좌표를 행정구역 코드로 바꾼다.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: @@ -134,13 +122,12 @@ async def test_coord_to_region_prefers_administrative_dong(): assert region.region_type == "H" assert region.region_1depth_name == "강원도" assert region.full_name == "강원도 양양군 현북면" - assert captured["params"]["x"] == "128.6712345" # ★ 경도 - assert captured["params"]["y"] == "38.0451234" # ★ 위도 + assert captured["params"]["x"] == "128.6712345" # 경도 + assert captured["params"]["y"] == "38.0451234" # 위도 async def test_coord_to_region_falls_back_to_first_document(): - """검증: 행정동(H) 문서가 없는 응답. - 기대결과: 첫 문서(법정동 B)로 폴백한다.""" + """검증: 행정동(H) 문서가 없는 응답.""" def handler(request): return httpx.Response(200, json={"documents": [_COORD2REGION_DOCS[0]]}) @@ -150,8 +137,7 @@ async def test_coord_to_region_falls_back_to_first_document(): async def test_coord_to_region_without_result_raises(): - """검증: 좌표 변환 결과가 비어 있다. - 기대결과: KakaoRequestFailed — ★ 빈 값을 조용히 내보내지 않는다.""" + """검증: 좌표 변환 결과가 비어 있다.""" def handler(request): return httpx.Response(200, json={"documents": []}) @@ -161,8 +147,7 @@ async def test_coord_to_region_without_result_raises(): # ── 3) 주변 카테고리 검색 ───────────────────────────────────────────────── async def test_search_category_sends_expected_params(): - """검증: 주변 맛집(FD6) 검색. - 기대결과: 카테고리 코드·반경·좌표(x=경도, y=위도)가 그대로 실린다.""" + """검증: 주변 맛집(FD6) 검색.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: @@ -182,8 +167,7 @@ async def test_search_category_sends_expected_params(): async def test_search_category_warns_without_region_code(capsys): - """검증: 행정구역 코드 없이 카테고리 검색을 부른다. - 기대결과: 경고 로그 — 캐시를 안 거치면 같은 지역을 사이트 수만큼 반복 조회하게 된다(건당 2원).""" + """검증: 행정구역 코드 없이 카테고리 검색을 부른다.""" def handler(request): return httpx.Response(200, json={"documents": []}) @@ -193,8 +177,7 @@ async def test_search_category_warns_without_region_code(capsys): async def test_search_category_clamps_radius(): - """검증: 카카오 상한(20km)을 넘는 반경을 넘긴다. - 기대결과: 20000 으로 잘린다(400 응답을 미리 막는다).""" + """검증: 카카오 상한(20km)을 넘는 반경을 넘긴다.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: @@ -207,8 +190,7 @@ async def test_search_category_clamps_radius(): # ── 4) ★ 동일 업소 판정 ─────────────────────────────────────────────────── def test_pick_match_no_candidate(): - """검증: 후보가 0건이다. - 기대결과: NO_CANDIDATE — 호출측이 PLACE_VERIFY_NO_CANDIDATE 로 응답한다.""" + """검증: 후보가 0건이다.""" result = pick_match("하조대펜션", []) assert result.outcome == MatchOutcome.NO_CANDIDATE assert result.place is None @@ -216,8 +198,7 @@ def test_pick_match_no_candidate(): def test_pick_match_single_exact_name(): - """검증: 상호명이 정확히 일치하는 후보가 1건이다. - 기대결과: MATCHED — 근거(name_exact)가 함께 남는다.""" + """검증: 상호명이 정확히 일치하는 후보가 1건이다.""" only = _place("하조대펜션", "111") result = pick_match("하조대펜션", [only]) @@ -228,15 +209,13 @@ def test_pick_match_single_exact_name(): def test_pick_match_ignores_whitespace_and_case(): - """검증: '하조대 펜션' 으로 검색하고 후보는 '하조대펜션' 이다. - 기대결과: MATCHED — 공백/대소문자까지만 정규화한다.""" + """검증: '하조대 펜션' 으로 검색하고 후보는 '하조대펜션' 이다.""" result = pick_match("하조대 펜션", [_place("하조대펜션", "111")]) assert result.outcome == MatchOutcome.MATCHED def test_pick_match_duplicate_names_is_ambiguous(): - """검증: ★ 상호명이 같은 업소가 2건이다(동명 업소). - 기대결과: AMBIGUOUS — 억지로 하나 고르면 남의 가게 정보가 섞인다. 사람이 주소로 골라야 한다.""" + """검증: ★ 상호명이 같은 업소가 2건이다(동명 업소).""" a = _place("하조대펜션", "111", road="강원 양양군 A로 1") b = _place("하조대펜션", "222", road="강원 양양군 B로 2") result = pick_match("하조대펜션", [a, b]) @@ -250,8 +229,7 @@ def test_pick_match_duplicate_names_is_ambiguous(): def test_pick_match_partial_name_is_ambiguous(): - """검증: 후보가 1건뿐이지만 상호명이 정확히 일치하지 않는다('하조대펜션' vs '하조대펜션 별관'). - 기대결과: ★ AMBIGUOUS — 부분일치만으로 확정하지 않는다. 별관은 다른 가게일 수 있다.""" + """검증: 후보가 1건뿐이지만 상호명이 정확히 일치하지 않는다('하조대펜션' vs '하조대펜션 별관').""" result = pick_match("하조대펜션", [_place("하조대펜션 별관", "111")]) assert result.outcome == MatchOutcome.AMBIGUOUS @@ -260,8 +238,7 @@ def test_pick_match_partial_name_is_ambiguous(): def test_pick_match_phone_resolves_duplicate_names(): - """검증: 동명 업소 2건인데 전화번호가 주어졌고 1건만 일치한다. - 기대결과: MATCHED — 전화번호가 가장 강한 근거다.""" + """검증: 동명 업소 2건인데 전화번호가 주어졌고 1건만 일치한다.""" a = _place("하조대펜션", "111", phone="033-672-0000") b = _place("하조대펜션", "222", phone="033-672-9999") result = pick_match("하조대펜션", [a, b], phone="0336720000") @@ -272,8 +249,7 @@ def test_pick_match_phone_resolves_duplicate_names(): def test_pick_match_phone_mismatch_falls_back_to_name(): - """검증: 전화번호를 줬지만 어느 후보와도 안 맞는다(동명 2건). - 기대결과: AMBIGUOUS — 전화번호가 안 맞았다고 아무거나 고르지 않는다.""" + """검증: 전화번호를 줬지만 어느 후보와도 안 맞는다(동명 2건).""" a = _place("하조대펜션", "111", phone="033-672-0000") b = _place("하조대펜션", "222", phone="033-672-9999") result = pick_match("하조대펜션", [a, b], phone="02-000-0000") @@ -283,8 +259,7 @@ def test_pick_match_phone_mismatch_falls_back_to_name(): def test_pick_match_phone_narrows_then_name_decides(): - """검증: 전화번호가 같은 후보가 2건이고 그중 상호명이 정확히 맞는 게 1건이다(지점 등록). - 기대결과: MATCHED — 전화번호로 좁힌 뒤 상호명으로 확정하고, 근거에 좁힌 사실이 남는다.""" + """검증: 전화번호가 같은 후보가 2건이고 그중 상호명이 정확히 맞는 게 1건이다(지점 등록).""" a = _place("하조대펜션", "111", phone="033-672-0000") b = _place("하조대펜션 카페", "222", phone="033-672-0000") c = _place("다른펜션", "333", phone="033-999-9999") @@ -296,8 +271,7 @@ def test_pick_match_phone_narrows_then_name_decides(): def test_pick_match_never_guesses_from_many_unrelated(): - """검증: 상호명이 정확히 맞는 후보 없이 비슷한 이름만 여럿이다. - 기대결과: ★ AMBIGUOUS — 이 서비스에서 자동 판정으로 넘어가면 안 되는 구간이다.""" + """검증: 상호명이 정확히 맞는 후보 없이 비슷한 이름만 여럿이다.""" candidates = [_place(f"하조대펜션{i}", str(i)) for i in range(5)] result = pick_match("하조대펜션", candidates) @@ -307,8 +281,7 @@ def test_pick_match_never_guesses_from_many_unrelated(): def test_normalize_helpers(): - """검증: 비교용 정규화. - 기대결과: 상호명은 공백/대소문자만, 전화번호는 숫자만 남는다.""" + """검증: 비교용 정규화.""" assert normalize_name("하조대 펜션") == normalize_name("하조대펜션") assert normalize_name("Beach House") == "beachhouse" assert normalize_name("하조대펜션") != normalize_name("하조대펜션별관") @@ -318,8 +291,7 @@ def test_normalize_helpers(): # ── 5) 조합: verify_place ───────────────────────────────────────────────── async def test_verify_place_matches_single_candidate(): - """검증: 상호명으로 검색해 동일 업소까지 한 번에 판정한다. - 기대결과: 키워드 검색 1회로 MATCHED. 반환된 place 에 카카오 장소 ID 가 실린다.""" + """검증: 상호명으로 검색해 동일 업소까지 한 번에 판정한다.""" def handler(request): return httpx.Response(200, json={"documents": [_KEYWORD_DOC]}) @@ -330,8 +302,7 @@ async def test_verify_place_matches_single_candidate(): async def test_verify_place_ambiguous_when_duplicate_names(): - """검증: 검색 결과에 동명 업소가 2건이다. - 기대결과: AMBIGUOUS + 후보 목록 — 호출측이 PLACE_VERIFY_AMBIGUOUS 로 사람에게 넘긴다.""" + """검증: 검색 결과에 동명 업소가 2건이다.""" doc2 = {**_KEYWORD_DOC, "id": "999", "phone": "033-000-0000", "road_address_name": "강원 양양군 B로 2"} def handler(request): @@ -345,8 +316,7 @@ async def test_verify_place_ambiguous_when_duplicate_names(): # ── 6) 설정·장애 ────────────────────────────────────────────────────────── async def test_missing_api_key_raises_not_configured(): - """검증: KAKAO_REST_API_KEY 가 비어 있다. - 기대결과: KakaoNotConfigured — 이 어댑터만 비활성이고 서버 부팅은 막지 않는다.""" + """검증: KAKAO_REST_API_KEY 가 비어 있다.""" client = KakaoLocalClient(api_key="") assert client.enabled is False @@ -355,8 +325,6 @@ async def test_missing_api_key_raises_not_configured(): async def test_invalid_key_401_raises_not_configured(): - """검증: 키가 있지만 잘못됐다(401). - 기대결과: KakaoNotConfigured — 설정 문제라 재시도해도 소용없다.""" def handler(request): return httpx.Response(401, json={"errorType": "AccessDeniedError"}) @@ -365,8 +333,7 @@ async def test_invalid_key_401_raises_not_configured(): async def test_server_error_raises_request_failed(): - """검증: 카카오가 5xx 를 돌려준다. - 기대결과: KakaoRequestFailed — ★ 빈 결과로 둔갑시키지 않는다(직전 값 유지는 호출측 책임).""" + """검증: 카카오가 5xx 를 돌려준다.""" def handler(request): return httpx.Response(503, text="service unavailable") @@ -375,8 +342,7 @@ async def test_server_error_raises_request_failed(): async def test_timeout_raises_request_failed(): - """검증: 요청이 타임아웃된다. - 기대결과: KakaoRequestFailed — 도메인 예외로 감싸 호출측이 LOCAL_FETCH_FAILED 로 처리한다.""" + """검증: 요청이 타임아웃된다.""" def handler(request): raise httpx.ReadTimeout("timed out", request=request) @@ -385,8 +351,7 @@ async def test_timeout_raises_request_failed(): async def test_malformed_json_raises_request_failed(): - """검증: 200 인데 본문이 JSON 이 아니다. - 기대결과: KakaoRequestFailed — 파싱 실패가 조용히 빈 결과가 되지 않는다.""" + """검증: 200 인데 본문이 JSON 이 아니다.""" def handler(request): return httpx.Response(200, text="<html>not json</html>") @@ -395,8 +360,7 @@ async def test_malformed_json_raises_request_failed(): async def test_call_counts_track_cost(): - """검증: 호출할 때마다 누적 카운터가 오른다. - 기대결과: 생성 1건당 검색 횟수를 셀 수 있다(초과 시 키워드 2원 / 좌표변환 0.5원).""" + """검증: 호출할 때마다 누적 카운터가 오른다.""" from services.external import kakao as kakao_mod kakao_mod.reset_call_counts() diff --git a/solution/backend/tests/test_kakao_link.py b/solution/backend/tests/test_kakao_link.py index d26fd5e..5cd054c 100644 --- a/solution/backend/tests/test_kakao_link.py +++ b/solution/backend/tests/test_kakao_link.py @@ -1,8 +1,4 @@ -"""카카오톡 채널 신원 연결. - -여기서 지키는 것은 하나다 — **연결되지 않은 발화자는 어떤 사장님도 되지 못한다.** -나머지 검사(코드 일회성·만료·시도 제한·재발급)는 전부 그 한 줄을 지탱한다. -""" +"""카카오톡 채널 신원 연결.""" import uuid from datetime import datetime, timedelta, timezone @@ -16,7 +12,7 @@ from services.kakao_link_service import KakaoLinkError @pytest.fixture(autouse=True) def channel(monkeypatch): - """KAKAO_CHANNEL_PUBLIC_ID 가 있어야 기능이 열린다. 없는 경우는 따로 검사한다.""" + """KAKAO_CHANNEL_PUBLIC_ID 가 있어야 기능이 열린다.""" monkeypatch.setenv("KAKAO_CHANNEL_PUBLIC_ID", "_testCh") monkeypatch.setenv("KAKAO_LINK_CODE_TTL_MIN", "10") monkeypatch.setenv("KAKAO_LINK_MAX_ATTEMPTS", "3") @@ -36,14 +32,14 @@ async def test_코드는_한_번만_먹는다(db_engine): code = (await service.issue_code(user_id))["code"] assert await service.redeem(code, key) == user_id - # ★ 두 번째는 실패해야 한다. 같은 코드로 다른 카톡 계정이 붙으면 연결의 의미가 없다. + # 두 번째는 실패해야 한다. with pytest.raises(KakaoLinkError, match="KAKAO_LINK_CODE_INVALID"): await service.redeem(code, "kakao-key-2") async def test_연결된_발화자만_사장님이_된다(db_engine): user_id, key = uuid.uuid4(), "kakao-key-3" - # ★ 이게 이 기능의 전부다 — 연결 전에는 어떤 값도 돌려주지 않는다. + # 이게 이 기능의 전부다 — 연결 전에는 어떤 값도 돌려주지 않는다. assert await service.resolve(key) is None await service.redeem((await service.issue_code(user_id))["code"], key) assert await service.resolve(key) == user_id @@ -63,7 +59,7 @@ async def test_만료된_코드는_안_먹는다(db_engine): async def test_오입력_시도는_상한에서_끊긴다(db_engine): - """짧은 코드(6자리)라 무차별 대입이 가능하다. 시도 수가 유일한 방어다.""" + """짧은 코드(6자리)라 무차별 대입이 가능하다.""" user_id = uuid.uuid4() code = (await service.issue_code(user_id))["code"] async with db_engine.begin() as c: @@ -89,7 +85,7 @@ async def test_재발급은_행을_늘리지_않고_옛_코드를_죽인다(db_e ).scalar_one() assert rows == 1 - # ★ 옛 코드가 살아 있으면 둘 중 어느 것이 먹을지 화면이 말해 줄 수 없다. + # 옛 코드가 살아 있으면 둘 중 어느 것이 먹을지 화면이 말해 줄 수 없다. with pytest.raises(KakaoLinkError, match="KAKAO_LINK_CODE_INVALID"): await service.redeem(first, "kakao-key-6") assert await service.redeem(second, "kakao-key-6") == user_id diff --git a/solution/backend/tests/test_kakao_webhook.py b/solution/backend/tests/test_kakao_webhook.py index 80a8676..ad7e1f5 100644 --- a/solution/backend/tests/test_kakao_webhook.py +++ b/solution/backend/tests/test_kakao_webhook.py @@ -1,10 +1,4 @@ -"""카카오톡 채널 웹훅. - -여기서 지키는 것 셋: - 1. 시크릿 없는 요청은 아무것도 하지 못한다 — 오픈빌더가 서명을 주지 않으므로 이게 유일한 문이다 - 2. 연결되지 않은 발화자는 어떤 사장님도 되지 못한다 - 3. 확인(SEMI)은 **만료되면 안 먹는다** — 한참 뒤의 "네" 한 마디에 묵은 발행이 돌면 안 된다 -""" +"""카카오톡 채널 웹훅.""" import uuid from types import SimpleNamespace @@ -67,7 +61,7 @@ async def link(db_engine, client, auth_headers, speaker, name="대화숙소"): # ── 1. 시크릿이 유일한 문이다 ──────────────────────────────────────────── async def test_시크릿이_없으면_존재를_알리지_않는다(client, monkeypatch): - """★ 401 이 아니라 404 다. 401 은 '여기 뭔가 있다' 를 알려 준다.""" + """401 이 아니라 404 다.""" monkeypatch.setenv("KAKAO_WEBHOOK_SECRET", "") assert (await client.post(PATH, json=body("안녕"))).status_code == 404 @@ -106,7 +100,7 @@ async def test_연결_전에는_어떤_도구도_돌지_않는다(client, monkey async def test_발화자_id_를_위조해도_남의_가게에_닿지_않는다(client, auth_headers, db_engine, monkeypatch): - """★ 신원 연결이 없으면 카톡 진입점만 소유자 범위 밖에 놓인다 — 그걸 막는 자리다.""" + """신원 연결이 없으면 카톡 진입점만 소유자 범위 밖에 놓인다 — 그걸 막는 자리다.""" await link(db_engine, client, auth_headers, "진짜-사장님-키") called = AsyncMock() monkeypatch.setattr(runtime, "chat", called) @@ -149,7 +143,7 @@ async def test_발행은_묻고_바로가기를_준다(client, auth_headers, db_ async def test_만료된_확인에_네_라고_해도_실행되지_않는다(client, auth_headers, db_engine, monkeypatch): - """★ 이게 없으면 한참 뒤의 '네' 한 마디에 **묵은 발행**이 돈다.""" + """이게 없으면 한참 뒤의 '네' 한 마디에 **묵은 발행**이 돈다.""" speaker = "만료-테스트-키" await link(db_engine, client, auth_headers, speaker) async with db_engine.begin() as c: @@ -169,7 +163,7 @@ async def test_만료된_확인에_네_라고_해도_실행되지_않는다(clie async def test_바로가기_라벨은_예로_읽히는_말에_들어_있다(): - """★ 라벨과 _YES 가 어긋나면 **눌러도 안 먹는다** — 사장님은 버튼이 고장난 줄 안다.""" + """라벨과 _YES 가 어긋나면 **눌러도 안 먹는다** — 사장님은 버튼이 고장난 줄 안다.""" assert channel.CONFIRM_LABEL in channel._YES assert channel.PUBLISH_LABEL in channel._YES assert channel.DECLINE_LABEL in channel._NO @@ -178,7 +172,7 @@ async def test_바로가기_라벨은_예로_읽히는_말에_들어_있다(): # ── 가게 고르기 ────────────────────────────────────────────────────────── async def test_가게가_여럿이면_추측하지_않고_되묻는다(client, auth_headers, db_engine, monkeypatch): - """★ 임의로 첫 가게를 고르면 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다.""" + """임의로 첫 가게를 고르면 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다.""" speaker = "다가게-키" h, _pid, _uid = await link(db_engine, client, auth_headers, speaker, "첫째가게") await client.post("/v1/place", headers=h, json={"name": "둘째가게", "category": 1}) @@ -241,11 +235,11 @@ async def test_발화자가_없으면_거기서_끝낸다(client, db_engine): # ── 응답 형식 ──────────────────────────────────────────────────────────── def test_카카오_형식은_이_파일_밖으로_나가지_않는다(): - """★ 서비스 계층에 새면 다른 채널을 붙일 때 전부 걷어내야 한다.""" + """서비스 계층에 새면 다른 채널을 붙일 때 전부 걷어내야 한다.""" import ast import inspect - # ★ 주석·docstring 에 이름이 나오는 것은 '샌' 것이 아니다 — 실제 코드만 본다. + # 주석·docstring 에 이름이 나오는 것은 '샌' 것이 아니다 — 실제 코드만 본다. tree = ast.parse(inspect.getsource(channel)) for node in ast.walk(tree): if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): @@ -267,7 +261,7 @@ def test_바로가기는_열_개를_넘기지_않는다(): # ── 사이트 목록 · 고르기 ───────────────────────────────────────────────── async def test_연결되자마자_홈페이지_목록을_알려준다(client, auth_headers, db_engine): - """★ 연결만 알리고 끝내면 사장님은 **어느 홈페이지를 다루는 대화인지** 모른 채 말을 건다.""" + """연결만 알리고 끝내면 사장님은 **어느 홈페이지를 다루는 대화인지** 모른 채 말을 건다.""" h, pid = await owner_with_place(client, auth_headers, "첫째가게") await client.post("/v1/place", headers=h, json={"name": "둘째가게", "category": 1}) uid = await user_id_of(db_engine, pid) @@ -291,7 +285,7 @@ async def test_가게가_하나면_그_이름을_말해_준다(client, auth_head async def test_목록이라고_하면_언제든_다시_보여주고_가게를_바꿀_수_있다(client, auth_headers, db_engine, monkeypatch): - """★ 대화가 막혔을 때 사장님이 처음 찾는 길이다. LLM 을 부르지 않는다.""" + """대화가 막혔을 때 사장님이 처음 찾는 길이다.""" speaker = "전환-발화자" h, _pid, _uid = await link(db_engine, client, auth_headers, speaker, "첫째가게") await client.post("/v1/place", headers=h, json={"name": "둘째가게", "category": 1}) @@ -313,7 +307,7 @@ async def test_목록이라고_하면_언제든_다시_보여주고_가게를_ async def test_목록은_발행_여부를_같이_말한다(client, auth_headers, db_engine): - """★ 안 그러면 사장님은 고친 것이 손님에게 보이는 줄 안다.""" + """안 그러면 사장님은 고친 것이 손님에게 보이는 줄 안다.""" speaker = "발행표시-발화자" await link(db_engine, client, auth_headers, speaker, "미발행가게") res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("목록", speaker)) @@ -323,8 +317,7 @@ async def test_목록은_발행_여부를_같이_말한다(client, auth_headers, # ── 콜백 (5초 벽 넘기) ─────────────────────────────────────────────────── async def test_콜백이_켜져_있으면_즉답하고_뒤에서_마저_만든다(client, auth_headers, db_engine, monkeypatch): - """★ 오픈빌더 스킬 타임아웃은 5초다. 콜백을 쓰면 '잠시만 기다려 주세요' 를 먼저 주고 - 답이 완성되면 그 주소로 따로 보낸다 — 느린 답이 침묵이 되지 않는다.""" + """오픈빌더 스킬 타임아웃은 5초다.""" import router.v1.agent.kakao_bot as bot sent = {} @@ -356,7 +349,7 @@ async def test_콜백이_없으면_예전처럼_동기로_답한다(client, db_e async def test_콜백_전송이_실패해도_터지지_않는다(monkeypatch): - """★ 주소는 1분 · 1회다. 재시도하지 않는다 — 두 번째 POST 는 어차피 거절된다.""" + """주소는 1분 · 1회다.""" import router.v1.agent.kakao_bot as bot monkeypatch.setattr(bot, "_answer", AsyncMock(return_value=bot._reply("답"))) diff --git a/solution/backend/tests/test_local_guide_itinerary.py b/solution/backend/tests/test_local_guide_itinerary.py index 69d1e4f..dbf5fa2 100644 --- a/solution/backend/tests/test_local_guide_itinerary.py +++ b/solution/backend/tests/test_local_guide_itinerary.py @@ -1,9 +1,4 @@ -"""캔버스 가이드가 일정을 함께 내려보내는가. - -★ 예전에는 일부러 뺐다 — "캔버스에 그릴 자리가 아직 없다"(get_guide docstring). - 자리를 만들었으므로(ItineraryTickets 의 폴백) 이제 실어 보낸다. - 빼 두면 캔버스와 발행본이 다른 목록을 보이고, 그건 이 함수 자신이 경계한 상황이다. -""" +"""캔버스 가이드가 일정을 함께 내려보내는가.""" import uuid from sqlalchemy import text @@ -47,9 +42,8 @@ async def _seed_itinerary(db_engine, place_id, duration="1박 2일", course="원 async def test_guide_returns_stored_itineraries(client, db_engine, owner_id, monkeypatch): - """검증: 저장된 일정이 있는 업장의 캔버스 가이드. - 기대결과: itineraries 가 실려 온다 — 캔버스가 발행본과 같은 목록을 본다.""" - # 생성·수집은 이 테스트의 관심이 아니다. 유료 호출과 잡 등록을 막는다. + """검증: 저장된 일정이 있는 업장의 캔버스 가이드.""" + # 생성·수집은 이 테스트의 관심이 아니다. monkeypatch.setattr("services.llm.perplexity.is_configured", lambda: False) pid = await _seed_place(db_engine, owner_id) @@ -64,8 +58,7 @@ async def test_guide_returns_stored_itineraries(client, db_engine, owner_id, mon async def test_guide_without_itineraries_returns_empty_list(client, db_engine, owner_id, monkeypatch): - """검증: 아직 생성되지 않은 업장. - 기대결과: 빈 배열 — 캔버스는 PasteHint 를 띄운다(에러가 아니다).""" + """검증: 아직 생성되지 않은 업장.""" monkeypatch.setattr("services.llm.perplexity.is_configured", lambda: False) pid = await _seed_place(db_engine, owner_id) diff --git a/solution/backend/tests/test_my_sites.py b/solution/backend/tests/test_my_sites.py index 62c6af4..3a3ecc7 100644 --- a/solution/backend/tests/test_my_sites.py +++ b/solution/backend/tests/test_my_sites.py @@ -1,12 +1,4 @@ -"""내 사이트 목록 — 로그인한 사장님이 자기 사이트 전부를 보는 화면의 뒷단. - -이 경로가 절대 하면 안 되는 것: - - 사이트가 아직 없는 사업장을 빼는 것 — 위저드를 걸어오다 만 가게가 목록에서 사라지면 - 사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다). - - 사장님 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다. - - 단건(GET /v1/place/{id}/site)과 다른 재빌드 판정을 내는 것 — 목록과 에디터가 서로 다른 - 답을 하면 사장님은 어느 쪽을 믿을지 알 수 없다. -""" +"""내 사이트 목록 — 로그인한 사장님이 자기 사이트 전부를 보는 화면의 뒷단.""" import uuid from sqlalchemy import text @@ -24,8 +16,7 @@ async def _list(client, headers, **params): async def test_place_without_site_is_still_listed(auth_headers, client): - """검증: 사이트 행이 없는 사업장(위저드만 걸어온 것)도 목록에 나온다. - 기대결과: 줄은 있고 site_id 는 없다 — 화면이 '만드는 중'으로 그릴 근거다.""" + """검증: 사이트 행이 없는 사업장(위저드만 걸어온 것)도 목록에 나온다.""" h = await auth_headers("my1") await _place(client, h, "아직펜션") @@ -39,8 +30,7 @@ async def test_place_without_site_is_still_listed(auth_headers, client): async def test_site_row_is_joined_into_the_line(auth_headers, client): - """검증: 사업장과 사이트가 한 줄로 합쳐져 온다(줄마다 사이트를 다시 묻지 않는다). - 기대결과: 템플릿·주소가 목록에 그대로 보인다.""" + """검증: 사업장과 사이트가 한 줄로 합쳐져 온다(줄마다 사이트를 다시 묻지 않는다).""" h = await auth_headers("my2") pid = await _place(client, h, "합쳐진펜션") await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "paper"}) @@ -54,11 +44,7 @@ async def test_site_row_is_joined_into_the_line(auth_headers, client): async def test_row_carries_what_the_card_draws(auth_headers, client, db_engine): - """검증: 목록 줄이 카드가 그릴 값을 다 들고 온다 — 주소·생성일·썸네일. - - ★ 왜 이걸 본다: 같은 상호로 만든 사업장이 여러 줄일 때(실측: 한 계정에 '버터브루' 4줄) - 이름과 상태 배지만으로는 어느 게 어느 건지 가릴 수 없다. 가르는 값은 주소와 시각이고, - **한 번이라도 발행한 줄은 그림**이다.""" + """검증: 목록 줄이 카드가 그릴 값을 다 들고 온다 — 주소·생성일·썸네일.""" h = await auth_headers("my2b") pid = await _place(client, h, "카드펜션") async with db_engine.begin() as conn: @@ -80,8 +66,7 @@ async def test_row_carries_what_the_card_draws(auth_headers, client, db_engine): async def test_row_without_a_site_has_no_thumbnail(auth_headers, client): - """검증: 아직 발행 안 한 줄은 그림이 없다(키 자체가 없다). - 기대결과: 화면이 '그림 없음' 자리를 그릴 근거가 된다 — 빈 문자열로 오면 깨진 이미지가 뜬다.""" + """검증: 아직 발행 안 한 줄은 그림이 없다(키 자체가 없다).""" h = await auth_headers("my2c") await _place(client, h, "그림없는펜션") @@ -90,8 +75,7 @@ async def test_row_without_a_site_has_no_thumbnail(auth_headers, client): async def test_other_owners_sites_are_not_listed(auth_headers, client): - """검증: 사장님 스코프. 남의 사업장은 보이지 않는다. - 기대결과: 각자 자기 것만 1건.""" + """검증: 사장님 스코프.""" mine = await auth_headers("my3") theirs = await auth_headers("my3b") await _place(client, mine, "내펜션") @@ -102,11 +86,10 @@ async def test_other_owners_sites_are_not_listed(auth_headers, client): async def test_needs_rebuild_matches_the_single_site_answer(auth_headers, client, db_engine): - """검증: 재빌드 판정이 단건 조회와 같은 답을 낸다. - 기대결과: 노출값이 바뀐 사업장은 목록에서도 needs_rebuild=true.""" + """검증: 재빌드 판정이 단건 조회와 같은 답을 낸다.""" h = await auth_headers("my4") pid = await _place(client, h, "고친펜션") - # 템플릿 저장이 사이트 행을 만든다. 그 뒤 노출값이 바뀐 것으로 표시한다. + # 템플릿 저장이 사이트 행을 만든다. await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "paper"}) async with db_engine.begin() as conn: await conn.execute( @@ -121,6 +104,5 @@ async def test_needs_rebuild_matches_the_single_site_answer(auth_headers, client async def test_list_requires_login(client): - """검증: 내 것을 보는 화면이므로 토큰 없이는 열리지 않는다. - 기대결과: 401.""" + """검증: 내 것을 보는 화면이므로 토큰 없이는 열리지 않는다.""" assert (await client.get("/v1/site/list")).status_code == 401 diff --git a/solution/backend/tests/test_naver.py b/solution/backend/tests/test_naver.py index 5c73b68..91d4697 100644 --- a/solution/backend/tests/test_naver.py +++ b/solution/backend/tests/test_naver.py @@ -1,14 +1,4 @@ -"""네이버 지역검색 클라이언트 — 동일 업소 판정과 응답 파싱. - -★ 실제 API 를 호출하지 않는다(httpx.MockTransport). 테스트가 외부 쿼터를 태우면 안 된다. - -여기서 고정하는 것: - 1. mapx=경도 / mapy=위도, 1e7 로 나눈 값이 WGS84 — 뒤집거나 잘못 나누면 엉뚱한 지역이 붙는다 - 2. 네이버 제약(5건 상한 · 전화번호 없음)을 코드가 실제로 인지하는지 - 3. 동일 업소 판정이 애매할 때 **반드시 ambiguous 로 떨어지는지** — 이 서비스에서 가장 비싼 실수 - 4. 지역 캐시 키가 시도 별칭(강원도/강원특별자치도)을 하나로 모으는지, 그리고 10자를 안 넘는지 - 5. 키 미설정·장애가 조용히 빈 값으로 새지 않는지 -""" +"""네이버 지역검색 클라이언트 — 동일 업소 판정과 응답 파싱.""" import httpx import pytest @@ -25,8 +15,7 @@ from services.external.naver import ( strip_tags, ) -# ── 네이버 실제 응답 모양 (2026-08-27 실측) ──────────────────────────────── -# 서울특별시청: mapx=1269783882 mapy=375666103 → 126.97839, 37.56661 (실제 37.5663, 126.9779) +# ── 네이버 실제 응답 모양 ──────────────────────────────── 서울특별시청: mapx=1269783882 mapy=375666103 → 126.97839, 37.56661 (실제 37.5663, 126.9779) _SEOUL_CITY_HALL = { "title": "<b>서울</b>특별시청", "link": "https://www.seoul.go.kr/", @@ -54,7 +43,7 @@ _PENSION = { def _handler(payload, status: int = 200, capture: dict | None = None): - """MockTransport 핸들러. capture 를 주면 요청 정보를 담아준다.""" + """MockTransport 핸들러.""" def _h(request: httpx.Request) -> httpx.Response: if capture is not None: @@ -69,7 +58,7 @@ def _handler(payload, status: int = 200, capture: dict | None = None): def _client(handler, client_id="test-id", client_secret="test-secret") -> NaverLocalClient: - """MockTransport 를 물린 클라이언트. 실제 네트워크를 타지 않는다.""" + """MockTransport 를 물린 클라이언트.""" return NaverLocalClient( client_id=client_id, client_secret=client_secret, transport=httpx.MockTransport(handler) ) @@ -92,8 +81,7 @@ def _reset(): # ── 응답 파싱 ───────────────────────────────────────────────────────────── async def test_parses_item_and_strips_highlight_tags(): - """검증: title 에 `<b>` 태그와 HTML 엔티티가 섞인 응답을 파싱한다. - 기대결과: 태그·엔티티가 벗겨진 상호명이 나온다('풀라운지펜션&글램핑').""" + """검증: title 에 `<b>` 태그와 HTML 엔티티가 섞인 응답을 파싱한다.""" c = _client(_handler({"items": [_PENSION]})) places = await c.search_local("하조대 펜션") @@ -104,21 +92,18 @@ async def test_parses_item_and_strips_highlight_tags(): async def test_mapx_is_longitude_and_mapy_is_latitude(): - """검증: 실측 응답(서울특별시청)의 mapx/mapy 를 좌표로 바꾼다. - 기대결과: 1e7 로 나눈 값이 실제 위경도(37.5663, 126.9779)와 오차 0.01 이내로 일치한다. - ★ 뒤집히면 위도 126 도가 되어 지구 밖이다.""" + """뒤집히면 위도 126 도가 되어 지구 밖이다.""" c = _client(_handler({"items": [_SEOUL_CITY_HALL]})) place = (await c.search_local("서울특별시청"))[0] - assert place.longitude == pytest.approx(126.9779, abs=0.01) # ★ mapx = 경도 - assert place.latitude == pytest.approx(37.5663, abs=0.01) # ★ mapy = 위도 + assert place.longitude == pytest.approx(126.9779, abs=0.01) # mapx = 경도 + assert place.latitude == pytest.approx(37.5663, abs=0.01) # mapy = 위도 assert 33.0 < place.latitude < 39.0, "위도가 한반도 범위를 벗어났다(mapx/mapy 를 뒤집었을 가능성)" assert 124.0 < place.longitude < 132.0 async def test_empty_telephone_becomes_none(): - """검증: 네이버가 telephone 을 빈 문자열로 주는 실제 동작. - 기대결과: phone 이 None 이다 — 빈 문자열이 '전화번호가 있다'로 오해되면 안 된다.""" + """검증: 네이버가 telephone 을 빈 문자열로 주는 실제 동작.""" c = _client(_handler({"items": [_PENSION]})) place = (await c.search_local("펜션"))[0] @@ -126,9 +111,7 @@ async def test_empty_telephone_becomes_none(): async def test_link_is_business_homepage_not_place_id(): - """검증: link 필드 처리. - 기대결과: place_url 에는 담기지만 naver_place_id 는 None — - 네이버 지역검색의 link 는 플레이스 페이지가 아니라 업체 자체 홈페이지다.""" + """검증: link 필드 처리.""" c = _client(_handler({"items": [_SEOUL_CITY_HALL]})) place = (await c.search_local("서울특별시청"))[0] @@ -137,8 +120,7 @@ async def test_link_is_business_homepage_not_place_id(): async def test_missing_coordinates_do_not_crash(): - """검증: mapx/mapy 가 비었거나 숫자가 아닌 응답. - 기대결과: 좌표만 None 이고 나머지는 정상 파싱된다(파싱 실패로 전체가 죽지 않는다).""" + """검증: mapx/mapy 가 비었거나 숫자가 아닌 응답.""" broken = {**_PENSION, "mapx": "", "mapy": None} c = _client(_handler({"items": [broken]})) place = (await c.search_local("펜션"))[0] @@ -148,16 +130,14 @@ async def test_missing_coordinates_do_not_crash(): async def test_empty_items_returns_empty_list(): - """검증: 검색 결과가 0건인 응답. - 기대결과: 빈 리스트. 예외가 아니다 — '못 찾음'은 장애가 아니라 정상 결과다.""" + """검증: 검색 결과가 0건인 응답.""" c = _client(_handler({"items": []})) assert await c.search_local("없는가게") == [] # ── 네이버 제약: 5건 상한 ────────────────────────────────────────────────── async def test_display_is_clamped_to_five(): - """검증: display 를 15 로 요청한다. - 기대결과: 실제 요청 파라미터가 5 로 잘린다 — 네이버 상한이 5이고, 더 줘도 조용히 5건만 온다.""" + """검증: display 를 15 로 요청한다.""" cap: dict = {} c = _client(_handler({"items": []}, capture=cap)) await c.search_local("카페", display=15) @@ -166,8 +146,7 @@ async def test_display_is_clamped_to_five(): async def test_display_lower_bound_is_one(): - """검증: display 를 0 이하로 요청한다. - 기대결과: 1 로 올라간다(0 은 네이버가 거부한다).""" + """검증: display 를 0 이하로 요청한다.""" cap: dict = {} c = _client(_handler({"items": []}, capture=cap)) await c.search_local("카페", display=0) @@ -176,8 +155,7 @@ async def test_display_lower_bound_is_one(): async def test_auth_headers_are_sent(): - """검증: 요청 헤더. - 기대결과: X-Naver-Client-Id / X-Naver-Client-Secret 가 실린다.""" + """검증: 요청 헤더.""" cap: dict = {} c = _client(_handler({"items": []}, capture=cap)) await c.search_local("카페") @@ -188,8 +166,7 @@ async def test_auth_headers_are_sent(): # ── 동일 업소 판정 ──────────────────────────────────────────────────────── def test_pick_match_no_candidate(): - """검증: 후보가 0건이다. - 기대결과: NO_CANDIDATE — 확정도 애매도 아니고 '못 찾음'이다.""" + """검증: 후보가 0건이다.""" result = pick_match("하조대펜션", []) assert result.outcome == MatchOutcome.NO_CANDIDATE @@ -198,8 +175,7 @@ def test_pick_match_no_candidate(): def test_pick_match_single_exact_name(): - """검증: 상호명이 정확히 1건만 일치한다. - 기대결과: MATCHED(name_exact) — 확정.""" + """검증: 상호명이 정확히 1건만 일치한다.""" candidates = [_place("하조대펜션"), _place("핑크비치펜션"), _place("부커스비치호텔")] result = pick_match("하조대펜션", candidates) @@ -209,16 +185,14 @@ def test_pick_match_single_exact_name(): def test_pick_match_ignores_spacing_in_name(): - """검증: '하조대 펜션' 으로 찾고 후보는 '하조대펜션' 이다. - 기대결과: MATCHED — 공백 차이는 같은 가게로 본다.""" + """검증: '하조대 펜션' 으로 찾고 후보는 '하조대펜션' 이다.""" result = pick_match("하조대 펜션", [_place("하조대펜션")]) assert result.outcome == MatchOutcome.MATCHED def test_pick_match_duplicate_names_is_ambiguous(): - """검증: 상호명이 똑같은 업소가 2건이다(주소 힌트 없음). - 기대결과: ★ AMBIGUOUS — 전화번호가 없어 가를 근거가 없으니 사람이 골라야 한다.""" + """검증: 상호명이 똑같은 업소가 2건이다(주소 힌트 없음).""" candidates = [ _place("하조대펜션", road="강원특별자치도 양양군 현북면 하조대3길 25"), _place("하조대펜션", road="강원특별자치도 양양군 손양면 도리단길 7"), @@ -233,8 +207,7 @@ def test_pick_match_duplicate_names_is_ambiguous(): def test_pick_match_address_hint_narrows_duplicates(): - """검증: 동명 업소 2건에 주소 힌트를 준다. - 기대결과: MATCHED(name_address) — 주소로 1건으로 좁혀지면 확정한다.""" + """검증: 동명 업소 2건에 주소 힌트를 준다.""" candidates = [ _place("하조대펜션", road="강원특별자치도 양양군 현북면 하조대3길 25"), _place("하조대펜션", road="강원특별자치도 양양군 손양면 도리단길 7"), @@ -247,8 +220,7 @@ def test_pick_match_address_hint_narrows_duplicates(): def test_pick_match_address_hint_that_narrows_nothing_stays_ambiguous(): - """검증: 동명 업소 2건인데 주소 힌트가 양쪽에 똑같이 걸린다. - 기대결과: ★ 여전히 AMBIGUOUS — 애매한 걸 억지로 좁혀 확정하지 않는다.""" + """검증: 동명 업소 2건인데 주소 힌트가 양쪽에 똑같이 걸린다.""" candidates = [ _place("하조대펜션", road="강원특별자치도 양양군 현북면 A길 1"), _place("하조대펜션", road="강원특별자치도 양양군 현북면 B길 2"), @@ -259,8 +231,7 @@ def test_pick_match_address_hint_that_narrows_nothing_stays_ambiguous(): def test_pick_match_partial_name_is_ambiguous(): - """검증: 정확히 일치하는 상호명이 없고 부분일치만 있다. - 기대결과: ★ AMBIGUOUS — '하조대펜션' 과 '하조대펜션 별관' 은 다른 가게다. 확정하지 않는다.""" + """검증: 정확히 일치하는 상호명이 없고 부분일치만 있다.""" candidates = [_place("하조대펜션 별관"), _place("하조대펜션앤스파")] result = pick_match("하조대펜션", candidates) @@ -270,8 +241,7 @@ def test_pick_match_partial_name_is_ambiguous(): def test_pick_match_single_partial_candidate_is_still_ambiguous(): - """검증: 후보가 딱 1건인데 상호명이 정확히 일치하지 않는다. - 기대결과: ★ AMBIGUOUS — 후보가 하나뿐이라고 그게 맞는 가게는 아니다.""" + """검증: 후보가 딱 1건인데 상호명이 정확히 일치하지 않는다.""" result = pick_match("하조대펜션", [_place("하조대풀빌라")]) assert result.outcome == MatchOutcome.AMBIGUOUS @@ -279,8 +249,7 @@ def test_pick_match_single_partial_candidate_is_still_ambiguous(): async def test_verify_place_end_to_end(): - """검증: 상호명으로 검색해서 판정까지 한 번에 한다. - 기대결과: 지역검색 1회로 MATCHED 가 나온다.""" + """검증: 상호명으로 검색해서 판정까지 한 번에 한다.""" item = {**_PENSION, "title": "하조대<b>펜션</b>"} c = _client(_handler({"items": [item]})) result = await c.verify_place("하조대펜션", address_hint="강원특별자치도 양양군") @@ -290,8 +259,7 @@ async def test_verify_place_end_to_end(): async def test_verify_place_retries_without_address_when_empty(): - """검증: 주소를 섞어 검색했더니 0건이다. - 기대결과: 상호명만으로 한 번 더 찾아본다 — 주소 표기 차이로 0건이 나는 경우를 흡수한다.""" + """검증: 주소를 섞어 검색했더니 0건이다.""" calls = {"n": 0} def handler(request: httpx.Request) -> httpx.Response: @@ -309,14 +277,12 @@ async def test_verify_place_retries_without_address_when_empty(): # ── 지역 캐시 키 ────────────────────────────────────────────────────────── def test_region_key_basic(): - """검증: 도로명주소에서 시도 + 시군구를 뽑는다. - 기대결과: '51양양군' — 이 키 단위로 지역 정보를 캐싱한다.""" + """검증: 도로명주소에서 시도 + 시군구를 뽑는다.""" assert region_key("강원특별자치도 양양군 현북면 하조대3길 25") == "51양양군" def test_region_key_merges_sido_aliases(): - """검증: 같은 지역인데 시도 이름이 옛 이름/새 이름으로 다르게 온다. - 기대결과: ★ 같은 키 — 갈리면 같은 지역을 두 번 조회하게 되어 캐시가 무의미해진다.""" + """검증: 같은 지역인데 시도 이름이 옛 이름/새 이름으로 다르게 온다.""" assert region_key("강원도 양양군 현북면 하조대3길 25") == region_key( "강원특별자치도 양양군 현북면 하조대3길 25" ) @@ -325,28 +291,24 @@ def test_region_key_merges_sido_aliases(): def test_region_key_metropolitan_district(): - """검증: 특별시·광역시의 자치구. - 기대결과: 구 단위로 남는다('11강남구') — 생활권이 실제로 다르다.""" + """검증: 특별시·광역시의 자치구.""" assert region_key("서울특별시 강남구 도산대로57길 24 (청담동)") == "11강남구" assert region_key("부산광역시 해운대구 우동 1394") == "26해운대구" def test_region_key_general_district_collapses_to_city(): - """검증: 일반구(성남시 분당구)를 가진 주소. - 기대결과: 시 단위로 묶인다('41성남시') — 날씨·축제는 구 단위로 다르지 않아 캐시 히트율을 올린다.""" + """검증: 일반구(성남시 분당구)를 가진 주소.""" assert region_key("경기도 성남시 분당구 판교역로 235") == "41성남시" assert region_key("경기도 성남시 수정구 A로 1") == "41성남시" def test_region_key_single_tier_sejong(): - """검증: 시군구가 없는 세종특별자치시. - 기대결과: '36세종' — 두 번째 토큰이 도로명이라 그대로 쓰면 안 된다.""" + """검증: 시군구가 없는 세종특별자치시.""" assert region_key("세종특별자치시 한누리대로 2130") == "36세종" def test_region_key_returns_none_when_unmappable(): - """검증: 시도를 알 수 없는 주소(해외·빈 값). - 기대결과: None — 억지로 키를 만들지 않고 호출측에 넘긴다.""" + """검증: 시도를 알 수 없는 주소(해외·빈 값).""" assert region_key("Tokyo, Japan") is None assert region_key("") is None assert region_key(None) is None @@ -354,8 +316,7 @@ def test_region_key_returns_none_when_unmappable(): def test_region_key_fits_db_column(): - """검증: 만들어지는 키의 길이. - 기대결과: 전부 10자 이내 — local_contents.region_code 가 VARCHAR(10) 이라 넘치면 잘린다.""" + """검증: 만들어지는 키의 길이.""" addresses = [ "강원특별자치도 양양군 현북면 하조대3길 25", "서울특별시 강남구 도산대로57길 24", @@ -372,22 +333,19 @@ def test_region_key_fits_db_column(): def test_region_key_distinguishes_same_name_across_provinces(): - """검증: 이름이 같은 시군구가 다른 시도에 있다(고성군: 강원·경남). - 기대결과: 다른 키 — 시도 코드가 앞에 붙어 안 겹친다.""" + """검증: 이름이 같은 시군구가 다른 시도에 있다(고성군: 강원·경남).""" assert region_key("강원특별자치도 고성군 A로 1") != region_key("경상남도 고성군 A로 1") # ── 문자열 유틸 ─────────────────────────────────────────────────────────── def test_strip_tags_order_protects_literal_text(): - """검증: 본문에 이스케이프된 태그 문자열이 들어 있다. - 기대결과: 태그를 먼저 지우고 엔티티를 나중에 풀어야 리터럴 '<b>' 가 살아남는다.""" + """검증: 본문에 이스케이프된 태그 문자열이 들어 있다.""" assert strip_tags("<b>카페</b>&베이커리") == "카페&베이커리" assert strip_tags("공식 <b> 표기") == "공식 <b> 표기" def test_normalize_name_only_touches_space_and_case(): - """검증: 상호명 정규화 범위. - 기대결과: 공백·대소문자만 건드린다 — 과하게 정규화하면 다른 가게가 같아 보인다.""" + """검증: 상호명 정규화 범위.""" assert normalize_name("하조대 펜션") == normalize_name("하조대펜션") assert normalize_name("Blue Ocean") == normalize_name("blueocean") assert normalize_name("하조대펜션") != normalize_name("하조대펜션별관") @@ -395,8 +353,6 @@ def test_normalize_name_only_touches_space_and_case(): # ── 설정·장애 처리 ──────────────────────────────────────────────────────── async def test_missing_keys_raise_not_configured(): - """검증: 클라이언트 ID/Secret 이 비었다. - 기대결과: NaverNotConfigured — 이 어댑터만 비활성이고 부팅은 안 막는다.""" c = NaverLocalClient(client_id="", client_secret="") assert c.enabled is False @@ -405,8 +361,7 @@ async def test_missing_keys_raise_not_configured(): async def test_partial_keys_are_treated_as_unconfigured(): - """검증: ID 만 있고 Secret 이 없다. - 기대결과: NaverNotConfigured — 반쪽 설정으로 401 을 맞느니 미설정으로 본다.""" + """검증: ID 만 있고 Secret 이 없다.""" c = NaverLocalClient(client_id="only-id", client_secret="") assert c.enabled is False @@ -415,8 +370,7 @@ async def test_partial_keys_are_treated_as_unconfigured(): async def test_401_raises_not_configured(): - """검증: 키는 있는데 인증이 거부된다(401). - 기대결과: NaverNotConfigured — 설정 문제라 재시도해도 소용없다.""" + """검증: 키는 있는데 인증이 거부된다(401).""" c = _client(_handler({"errorMessage": "Not Exist Client ID"}, status=401)) with pytest.raises(NaverNotConfigured): @@ -424,9 +378,7 @@ async def test_401_raises_not_configured(): async def test_5xx_raises_request_failed(): - """검증: 네이버가 5xx 를 준다. - 기대결과: NaverRequestFailed — ★ 빈 리스트로 둔갑시키지 않는다. - 빈 값을 내보내면 '주변에 맛집이 없다'가 되어 사이트에 거짓이 실린다.""" + """검증: 네이버가 5xx 를 준다.""" c = _client(_handler("upstream error", status=503)) with pytest.raises(NaverRequestFailed): @@ -434,8 +386,7 @@ async def test_5xx_raises_request_failed(): async def test_timeout_raises_request_failed(): - """검증: 요청이 타임아웃된다. - 기대결과: NaverRequestFailed — 조용히 빈 값으로 넘어가지 않는다.""" + """검증: 요청이 타임아웃된다.""" def handler(request: httpx.Request) -> httpx.Response: raise httpx.ReadTimeout("timed out", request=request) @@ -446,8 +397,7 @@ async def test_timeout_raises_request_failed(): async def test_non_json_body_raises_request_failed(): - """검증: 200 인데 본문이 JSON 이 아니다. - 기대결과: NaverRequestFailed — 파싱 실패를 빈 결과로 삼키지 않는다.""" + """검증: 200 인데 본문이 JSON 이 아니다.""" c = _client(_handler("<html>maintenance</html>", status=200)) with pytest.raises(NaverRequestFailed): @@ -455,8 +405,7 @@ async def test_non_json_body_raises_request_failed(): async def test_call_counts_track_usage(): - """검증: 호출 횟수 누적. - 기대결과: 호출한 만큼 세어진다 — 생성 1건당 검색 횟수를 세는 근거(일 25,000 쿼터).""" + """검증: 호출 횟수 누적.""" from services.external.naver import call_counts c = _client(_handler({"items": []})) @@ -467,8 +416,7 @@ async def test_call_counts_track_usage(): async def test_search_nearby_builds_region_scoped_query(): - """검증: 주변 검색은 반경이 없어 '지역명 + 키워드' 로 찾는다. - 기대결과: 질의가 '양양군 맛집' 으로 조립된다.""" + """검증: 주변 검색은 반경이 없어 '지역명 + 키워드' 로 찾는다.""" cap: dict = {} c = _client(_handler({"items": []}, capture=cap)) await c.search_nearby("양양군", "맛집", region_key_hint="51양양군") diff --git a/solution/backend/tests/test_naver_place_lookup.py b/solution/backend/tests/test_naver_place_lookup.py index 0706d4a..844794f 100644 --- a/solution/backend/tests/test_naver_place_lookup.py +++ b/solution/backend/tests/test_naver_place_lookup.py @@ -1,28 +1,15 @@ -"""상호 → 네이버 플레이스 id 해석. - -지역검색 API 가 플레이스 id 를 주지 않아, 모바일 통합검색 결과 원문에서 상호가 일치하는 -id 를 골라낸다. 이 판정이 틀리면 **남의 가게를 이 가게의 공식 채널로 등록**하게 되므로, -"비슷한 것 중 첫 번째"를 고르지 않는 것까지 여기서 지킨다. - -네트워크는 타지 않는다 — 원문 조각을 직접 만들어 판정 규칙만 본다. -""" +"""상호 → 네이버 플레이스 id 해석.""" import pytest from services.external import naver_place_lookup as lookup -# 항목 사이를 채우는 잡음. ★ 조회 창(_CONTEXT_BEFORE + _CONTEXT_AFTER)보다 길어야 한다 — -# 실제 원문은 1.3MB 에 항목이 몇 개뿐이라 항목끼리 멀지만, 픽스처를 촘촘히 만들면 -# 옆 항목의 상호가 창 안에 들어와 엉뚱한 id 가 잡힌다(테스트가 실제로 그렇게 잡아냈다). +# 항목 사이를 채우는 잡음. _FILLER = "<div class=\"noise\">방문자 리뷰 블로그 리뷰 사진 더보기</div>" * 40 def _html(*entries: tuple[str, str]) -> str: - """검색 결과 원문 흉내. (상호, place id) 를 순서대로 심는다. - - 실제 원문은 id 주변에 리뷰 키워드·블로거 닉네임이 잔뜩 끼어 있고 상호는 그 사이에 - 박혀 있다. 그래서 판정도 '조각 안에 상호가 들어 있는가' 로 한다 — 그 모양을 재현한다. - """ + """검색 결과 원문 흉내.""" parts = [] for name, place_id in entries: parts.append( @@ -36,42 +23,32 @@ def _html(*entries: tuple[str, str]) -> str: def test_ampersand_in_name_is_matched_through_the_entity(): - """검증: 상호에 `&` 가 있고 원문에는 `&` 로 인코딩돼 있다. - 기대결과: 찾는다. - - ★ 실측(2026-08-28) '누에베 풀빌라&리조트'. 이 한 글자 때문에 place id 를 못 찾아 - 화면이 '네이버 플레이스 못 찾음' 을 띄웠고, 사장님이 네이버 지도에서 주소를 직접 - 복사해 붙여넣어야만 진행됐다 — 10곳 중 1곳이 여기서 막혔다. - """ + """검증: 상호에 `&` 가 있고 원문에는 `&` 로 인코딩돼 있다.""" html = _html(("누에베 풀빌라&리조트", "1064005604")) assert lookup._match_in_html(html, "누에베 풀빌라&리조트") == "1064005604" def test_plain_name_still_matches(): - """검증: `&` 도 엔티티도 없는 평범한 상호. - 기대결과: 그대로 찾는다 — 위 수정이 기존 경로를 건드리지 않았다.""" + """검증: `&` 도 엔티티도 없는 평범한 상호.""" html = _html(("보사노바 커피로스터스 강릉점", "37093035")) assert lookup._match_in_html(html, "보사노바 커피로스터스 강릉점") == "37093035" def test_punctuation_differences_are_ignored(): - """검증: 네이버는 '스테이,머뭄' 처럼 구두점을 넣어 표기한다. - 기대결과: 구두점·공백 차이는 무시하고 같은 가게로 본다.""" + """검증: 네이버는 '스테이,머뭄' 처럼 구두점을 넣어 표기한다.""" html = _html(("스테이, 머뭄", "1133638931")) assert lookup._match_in_html(html, "스테이머뭄") == "1133638931" assert lookup._match_in_html(html, "스테이,머뭄") == "1133638931" def test_unrelated_name_is_not_matched(): - """검증: 원문에 id 는 있지만 그 상호는 없다. - 기대결과: None — ★ '비슷한 것 중 첫 번째' 를 고르면 남의 가게를 등록하게 된다.""" + """검증: 원문에 id 는 있지만 그 상호는 없다.""" html = _html(("통나무파크", "13149475"), ("도치돌알파카목장", "11111111")) assert lookup._match_in_html(html, "제주양떼목장") is None def test_correct_id_is_picked_among_several(): - """검증: 후보가 여럿 섞인 원문에서 특정 상호를 찾는다. - 기대결과: 그 상호에 붙은 id 를 고른다(앞에 있는 다른 id 가 아니라).""" + """검증: 후보가 여럿 섞인 원문에서 특정 상호를 찾는다.""" html = _html( ("통나무파크입구", "99999999"), ("통나무파크 전기차충전소", "88888888"), @@ -81,16 +58,14 @@ def test_correct_id_is_picked_among_several(): def test_empty_name_never_matches(): - """검증: 상호가 빈 문자열이다. - 기대결과: None — 빈 문자열은 어떤 조각에도 '들어 있으므로' 아무 id 나 잡힌다.""" + """검증: 상호가 빈 문자열이다.""" html = _html(("통나무파크", "13149475")) assert lookup._match_in_html(html, "") is None assert lookup._match_in_html(html, " ") is None async def test_find_place_ids_returns_only_the_ones_it_found(monkeypatch): - """검증: 후보 3건 중 2건만 원문에 있다. - 기대결과: 찾은 2건만 담긴다 — 못 찾은 상호는 빠지고, 검색은 **한 번만** 나간다.""" + """검증: 후보 3건 중 2건만 원문에 있다.""" calls: list[str] = [] async def _fake_fetch(query: str): @@ -109,8 +84,6 @@ async def test_find_place_ids_returns_only_the_ones_it_found(monkeypatch): async def test_find_place_ids_is_empty_when_search_fails(monkeypatch): - """검증: 검색 자체가 실패했다(네이버가 막았거나 네트워크 오류). - 기대결과: 빈 dict — 호출측이 URL 직접 입력 경로로 안내한다. 예외로 터뜨리지 않는다.""" async def _fake_fetch(query: str): return None @@ -129,21 +102,12 @@ async def test_find_place_ids_is_empty_when_search_fails(monkeypatch): ], ) def test_normalize(raw, expected): - """검증: 비교용 정규화 규칙. - 기대결과: 공백·구두점·`&` 가 사라지고 소문자로 떨어진다.""" + """검증: 비교용 정규화 규칙.""" assert lookup._normalize(raw) == expected def test_nearby_entries_can_steal_the_id__known_limit(): - """검증: 두 업소가 조회 창(앞 600자 + 뒤 300자)보다 가깝게 붙어 있다. - 기대결과: **앞 항목의 id 가 잡힌다** — 현재 구현의 알려진 한계다. - - ★ 이건 통과를 축하하는 테스트가 아니라 경계를 적어 두는 테스트다. - 판정이 'id 주변 원문 조각에 상호가 들어 있는가' 라서, 항목이 창보다 촘촘하면 - 옆 가게 상호가 창 안에 들어온다. 실제 검색 원문은 1.3MB 에 항목이 몇 개뿐이라 - 지금은 부딪히지 않지만, 네이버가 결과를 압축해 내려주면 그날로 남의 가게가 잡힌다. - 막으려면 id 와 상호를 같은 항목 안에서 묶어 읽어야 한다(창 기반 판정을 버려야 한다). - """ + """검증: 두 업소가 조회 창(앞 600자 + 뒤 300자)보다 가깝게 붙어 있다.""" dense = ( '<li><a href="/place/11111111">앞가게</a></li>' '<li><a href="/place/22222222">뒷가게</a></li>' diff --git a/solution/backend/tests/test_perplexity.py b/solution/backend/tests/test_perplexity.py index 7691177..a1327f4 100644 --- a/solution/backend/tests/test_perplexity.py +++ b/solution/backend/tests/test_perplexity.py @@ -1,16 +1,10 @@ -"""Perplexity 채널 발견 클라이언트 — 계약 검증(실제 API 호출 없음). - -★ 이 모듈의 계약은 하나다: **URL 만 돌려준다.** - Perplexity 답변에는 동명 업소와 환각이 섞이므로, 상호명·주소·전화 같은 "사실"이 - 반환 구조에 새어나오면 그게 그대로 사이트에 실린다. 그걸 막는 게 여기 테스트의 목적이다. -""" +"""Perplexity 채널 발견 클라이언트 — 계약 검증(실제 API 호출 없음).""" import httpx import pytest from common.enums import LinkChannel -# ★ 겹마다 사는 곳이 다르다(services/llm/__init__.py 의 설명 참조). -# 채널 판정·필터 규칙 → grounding, HTTP 호출 → llm, 조립 → external. +# 겹마다 사는 곳이 다르다(services/llm/__init__.py 의 설명 참조). from services.grounding.channels import ( MAX_LINKS, MAX_LINKS_PER_CHANNEL, @@ -34,7 +28,7 @@ def _api_key(monkeypatch): def _client(handler) -> httpx.AsyncClient: - """MockTransport 로 네트워크를 끊은 클라이언트. 실제 API 를 절대 때리지 않는다.""" + """MockTransport 로 네트워크를 끊은 클라이언트.""" return httpx.AsyncClient(transport=httpx.MockTransport(handler)) @@ -48,8 +42,7 @@ def _ok_payload(content: str, search_results=None, usage=None) -> dict: # ── 정상 파싱 ──────────────────────────────────────────────────────────── async def test_discover_returns_links_raw_and_search_count(): - """검증: 정상 응답을 파싱한다. - 기대결과: links·raw·search_count 가 모두 채워지고, raw 는 응답 원문 그대로다.""" + """검증: 정상 응답을 파싱한다.""" payload = _ok_payload( '{"links": [{"url": "https://www.yanolja.com/pension/1", "title": "하조대펜션"}]}', search_results=[{"url": "https://place.naver.com/restaurant/123", "title": "하조대펜션"}], @@ -72,9 +65,7 @@ async def test_discover_returns_links_raw_and_search_count(): async def test_request_pins_schema_and_domain_filter(): - """검증: 실제로 나가는 요청 본문. - 기대결과: response_format(JSON Schema)로 출력이 고정되고, 도메인이 야놀자·여기어때· - **네이버 플레이스**로 한정된다. naver.com 으로 넓히면 blog.naver.com 후기가 딸려온다(실측).""" + """검증: 실제로 나가는 요청 본문.""" seen = {} async def handler(request): @@ -92,10 +83,7 @@ async def test_request_pins_schema_and_domain_filter(): assert seen["auth"] == "Bearer pplx-test-key" assert body["model"] == "sonar" assert body["response_format"]["type"] == "json_schema", "구조화 출력으로 파싱 실패를 줄여야 한다" - # ★ 네이버는 호스트가 셋으로 나뉜다. place.naver.com 하나만 두면 실측상 네이버 링크가 - # **한 건도 안 들어온다**(2026-08-27 '도플로'·'버터브루'): 사람이 공유하는 주소는 - # map.naver.com/p/entry/place/... 이거나 naver.me 단축주소이고, place.naver.com 은 - # 그 뒤 리다이렉트로만 나타난다. + # 네이버는 호스트가 셋으로 나뉜다. assert set(body["search_domain_filter"]) == { "yanolja.com", "goodchoice.kr", "place.naver.com", "map.naver.com", "naver.me", } @@ -104,8 +92,7 @@ async def test_request_pins_schema_and_domain_filter(): async def test_duplicate_urls_are_collapsed(): - """검증: 모델 답변과 search_results 에 같은 URL 이 중복으로 들어온다. - 기대결과: 1건으로 합쳐진다(place_links 유니크와 충돌하지 않게).""" + """검증: 모델 답변과 search_results 에 같은 URL 이 중복으로 들어온다.""" url = "https://www.yanolja.com/pension/7" async def handler(request): @@ -121,8 +108,7 @@ async def test_duplicate_urls_are_collapsed(): async def test_non_http_urls_are_dropped(): - """검증: 모델이 http(s) 가 아닌 문자열을 URL 자리에 넣는다. - 기대결과: 버려진다 — 크롤러에 쓰레기가 흘러가면 안 된다.""" + """검증: 모델이 http(s) 가 아닌 문자열을 URL 자리에 넣는다.""" async def handler(request): return httpx.Response(200, json=_ok_payload( '{"links": [{"url": "야놀자에서 검색하세요"}, {"url": "javascript:alert(1)"},' @@ -154,15 +140,12 @@ async def test_non_http_urls_are_dropped(): ], ) def test_classify_url(url, expected): - """검증: URL 호스트로 채널을 판정한다. - 기대결과: 야놀자·여기어때·네이버플레이스·인스타는 정확히, 네이버 블로그/카페는 BLOG, - 모르는 도메인은 ETC 로 떨어진다(버리지 않는다).""" + """검증: URL 호스트로 채널을 판정한다.""" assert classify_url(url) is expected async def test_links_carry_channel_code(): - """검증: 발견된 링크에 채널 코드가 붙는지. - 기대결과: place_links.channel 에 그대로 넣을 수 있는 LinkChannel 값이 실린다.""" + """검증: 발견된 링크에 채널 코드가 붙는지.""" async def handler(request): return httpx.Response(200, json=_ok_payload( '{"links": [{"url": "https://www.yanolja.com/pension/1"},' @@ -182,9 +165,7 @@ async def test_links_carry_channel_code(): # ── ★ URL 발견 전용 계약 ───────────────────────────────────────────────── async def test_facts_in_answer_do_not_leak_into_result(): - """검증: 응답 본문에 주소·전화·체크인시간 같은 '사실'이 섞여 들어온다. - 기대결과: 반환 구조(links)에는 url·channel·title 만 있고 사실은 하나도 새어나오지 않는다. - ★ Perplexity 답변을 사실로 쓰면 동명 업소·환각이 그대로 사이트에 실린다.""" + """검증: 응답 본문에 주소·전화·체크인시간 같은 '사실'이 섞여 들어온다.""" async def handler(request): return httpx.Response(200, json=_ok_payload( '{"links": [{"url": "https://www.yanolja.com/pension/1", "title": "하조대펜션"}],' @@ -210,8 +191,7 @@ async def test_facts_in_answer_do_not_leak_into_result(): async def test_prompt_does_not_ask_for_facts(): - """검증: 프롬프트가 무엇을 요구하는지. - 기대결과: URL 만 요구하고, 정보를 쓰지 말라고 명시한다 — 물어보면 모델이 지어낸다.""" + """검증: 프롬프트가 무엇을 요구하는지.""" seen = {} async def handler(request): @@ -233,8 +213,7 @@ async def test_prompt_does_not_ask_for_facts(): # ── 실패 처리 ──────────────────────────────────────────────────────────── async def test_missing_api_key_raises_not_configured(monkeypatch): - """검증: PERPLEXITY_API_KEY 가 비어 있다. - 기대결과: PerplexityNotConfigured — 이 어댑터만 비활성되고 서버 부팅은 막지 않는다.""" + """검증: PERPLEXITY_API_KEY 가 비어 있다.""" monkeypatch.setattr(llm_perplexity.external_api_config, "perplexity_api_key", "") with pytest.raises(PerplexityNotConfigured): @@ -244,15 +223,13 @@ async def test_missing_api_key_raises_not_configured(monkeypatch): async def test_blank_name_is_rejected(): - """검증: 상호명 없이 호출한다. - 기대결과: PerplexityError — 검색 요금이 나가기 전에 막는다.""" + """검증: 상호명 없이 호출한다.""" with pytest.raises(PerplexityError): await discover_channels(" ") async def test_timeout_raises_domain_error(): - """검증: 호출이 타임아웃된다. - 기대결과: PerplexityError(→ COLLECT_FETCH_FAILED) — httpx 예외가 그대로 새어나오지 않는다.""" + """검증: 호출이 타임아웃된다.""" async def handler(request): raise httpx.ReadTimeout("too slow", request=request) @@ -262,8 +239,7 @@ async def test_timeout_raises_domain_error(): async def test_server_error_raises_domain_error(): - """검증: 5xx 응답. - 기대결과: PerplexityError(→ COLLECT_FETCH_FAILED). 잡은 재시도로 흘러간다.""" + """검증: 5xx 응답.""" async def handler(request): return httpx.Response(503, text="service unavailable") @@ -273,8 +249,7 @@ async def test_server_error_raises_domain_error(): async def test_broken_structured_output_falls_back_to_search_results(): - """검증: 구조화 출력이 깨진 JSON 으로 온다. - 기대결과: 예외 없이 search_results 의 URL 로 폴백한다 — 검색 요금을 이미 냈으니 버리지 않는다.""" + """검증: 구조화 출력이 깨진 JSON 으로 온다.""" async def handler(request): return httpx.Response(200, json=_ok_payload( "이건 JSON 이 아닙니다", @@ -288,8 +263,6 @@ async def test_broken_structured_output_falls_back_to_search_results(): async def test_empty_result_is_not_an_error(): - """검증: 아무 채널도 못 찾았다. - 기대결과: 빈 목록 + 예외 없음 — '못 찾음'은 정상 결과다(사장님 직접 입력으로 폴백).""" async def handler(request): return httpx.Response(200, json=_ok_payload('{"links": []}', usage={"num_search_queries": 2})) @@ -301,8 +274,7 @@ async def test_empty_result_is_not_an_error(): async def test_search_count_falls_back_to_search_results_length(): - """검증: usage 에 검색 횟수가 없다. - 기대결과: search_results 개수로 대체한다 — 검색 요금 추적이 0 으로 비면 안 된다.""" + """검증: usage 에 검색 횟수가 없다.""" async def handler(request): return httpx.Response(200, json=_ok_payload( '{"links": []}', @@ -316,8 +288,6 @@ async def test_search_count_falls_back_to_search_results_length(): assert result.search_count == 2 -# ── ★ URL 품질 필터 ────────────────────────────────────────────────────── -# 아래 URL 은 2026-08-27 '핑크비치펜션' 실호출에서 실제로 돌아온 것들이다. _REAL_JUNK = { "https://nol.yanolja.com/": REASON_ROOT, "https://www.goodchoice.kr/": REASON_ROOT, @@ -348,8 +318,7 @@ async def _discover_with(urls, **kwargs): @pytest.mark.parametrize("url,reason", sorted(_REAL_JUNK.items())) async def test_junk_urls_are_filtered_with_reason(url, reason): - """검증: 실호출에서 나온 쓰레기 URL(루트·카테고리·블로그)을 넣는다. - 기대결과: 후보에서 빠지고 filtered_out 에 사유가 남는다 — 조용히 버리지 않는다.""" + """검증: 실호출에서 나온 쓰레기 URL(루트·카테고리·블로그)을 넣는다.""" result = await _discover_with([url]) assert result.links == [], f"{url} 이 후보로 남았다" @@ -358,8 +327,7 @@ async def test_junk_urls_are_filtered_with_reason(url, reason): @pytest.mark.parametrize("url", _REAL_DETAIL) async def test_real_detail_urls_pass(url): - """검증: 실호출에서 나온 진짜 상세 페이지 URL. - 기대결과: 통과한다 — 필터가 과해서 진짜 채널을 버리면 필터가 없느니만 못하다.""" + """검증: 실호출에서 나온 진짜 상세 페이지 URL.""" result = await _discover_with([url]) assert [x.url for x in result.links] == [url] @@ -367,8 +335,7 @@ async def test_real_detail_urls_pass(url): async def test_blogs_pass_when_explicitly_included(): - """검증: include_blogs=True 로 호출한다. - 기대결과: 블로그도 후보로 남는다 — 기본은 제외지만 필요하면 열 수 있어야 한다.""" + """검증: include_blogs=True 로 호출한다.""" url = "https://blog.naver.com/sodam0526/223364495517" default = await _discover_with([url]) @@ -379,8 +346,7 @@ async def test_blogs_pass_when_explicitly_included(): async def test_all_filtered_returns_empty_not_forced_pick(): - """검증: 발견된 URL 이 전부 쓰레기다. - 기대결과: 빈 목록 — ★ 억지로 하나 남기지 않는다. '못 찾음'은 정상 결과다.""" + """검증: 발견된 URL 이 전부 쓰레기다.""" result = await _discover_with(list(_REAL_JUNK)) assert result.links == [] @@ -388,8 +354,7 @@ async def test_all_filtered_returns_empty_not_forced_pick(): async def test_filtered_out_reason_counts(): - """검증: 탈락 사유 집계. - 기대결과: 사유별 건수가 나온다 — 로그 한 줄로 '왜 얼마나 빠졌나'가 보여야 한다.""" + """검증: 탈락 사유 집계.""" result = await _discover_with(list(_REAL_JUNK)) counts = result.reason_counts() @@ -399,9 +364,6 @@ async def test_filtered_out_reason_counts(): async def test_too_many_same_channel_urls_are_capped(): - """검증: 같은 채널 상세 URL 이 우수수 들어온다(실측: 야놀자 12건, 전부 다른 숙소). - 기대결과: 채널당 상한까지만 남고 나머지는 overflow 로 기록된다 — - 사람이 12건을 하나씩 열어보게 만들면 안 된다.""" urls = [f"https://place-site.yanolja.com/places/100{i:04d}" for i in range(10)] result = await _discover_with(urls) @@ -413,8 +375,7 @@ async def test_too_many_same_channel_urls_are_capped(): async def test_total_cap_across_channels(): - """검증: 여러 채널에서 상세 URL 이 많이 들어온다. - 기대결과: 전체 상한(MAX_LINKS)을 넘지 않는다.""" + """검증: 여러 채널에서 상세 URL 이 많이 들어온다.""" urls = ( [f"https://place-site.yanolja.com/places/1{i}" for i in range(4)] + [f"https://place.goodchoice.kr/product/detail/{i}" for i in range(4)] @@ -427,8 +388,7 @@ async def test_total_cap_across_channels(): async def test_prompt_forbids_root_and_listing_pages(): - """검증: 프롬프트가 무엇을 금지하는지. - 기대결과: 첫 화면·목록·블로그를 넣지 말라고 명시한다 — 필터 이전에 애초에 덜 받는 게 싸다.""" + """검증: 프롬프트가 무엇을 금지하는지.""" seen = {} async def handler(request): @@ -446,8 +406,7 @@ async def test_prompt_forbids_root_and_listing_pages(): async def test_high_search_count_is_warned(caplog): - """검증: 검색 횟수가 기준을 넘는다. - 기대결과: 경고가 남는다 — 검색이 과하면 지연과 오탐 후보가 늘 수 있다.""" + """검증: 검색 횟수가 기준을 넘는다.""" from services.external.perplexity import SEARCH_COUNT_WARN_THRESHOLD async def handler(request): diff --git a/solution/backend/tests/test_place.py b/solution/backend/tests/test_place.py index 9c080ed..c45de1d 100644 --- a/solution/backend/tests/test_place.py +++ b/solution/backend/tests/test_place.py @@ -1,9 +1,4 @@ -"""places 도메인 e2e — 등록 / 동일 업소 검증 / 사장님 스코프 / 채널 URL 확정 게이트. - -★ 이 도메인의 핵심 규칙 두 개를 고정한다: - 1. 검증(verify) 전에는 채널 URL 을 확정할 수 없다 → 크롤링이 안 열린다 - 2. 남의 사업장은 '없음'으로 보인다 -""" +"""places 도메인 e2e — 등록 / 동일 업소 검증 / 사장님 스코프 / 채널 URL 확정 게이트.""" import uuid from common.enums import ErrorType, LinkChannel, PlaceCategory, SourceType @@ -16,8 +11,7 @@ async def _create_place(client, headers, name="테스트펜션", category=PlaceC async def test_create_place_starts_unverified(auth_headers, client): - """검증: 상호명만으로 사업장을 등록한다. - 기대결과: 생성되고 status=DRAFT, verified_at 없음 — 아직 수집이 열리지 않은 상태.""" + """검증: 상호명만으로 사업장을 등록한다.""" h = await auth_headers("u1") body = await _create_place(client, h) @@ -28,16 +22,14 @@ async def test_create_place_starts_unverified(auth_headers, client): async def test_create_place_rejects_blank_name(auth_headers, client): - """검증: 빈 상호명으로 등록. - 기대결과: INVALID_REQUEST_DATA(101).""" + """검증: 빈 상호명으로 등록.""" h = await auth_headers("u1") r = await client.post("/v1/place", headers=h, json={"name": " ", "category": 1}) assert r.json()["result"]["code"] == ErrorType.INVALID_REQUEST_DATA.value async def test_verify_place_opens_collection(auth_headers, client): - """검증: 카카오 로컬 결과로 동일 업소를 확정한다. - 기대결과: external_place_id·주소·행정구역코드가 박히고 verified_at 이 찍힌다.""" + """검증: 카카오 로컬 결과로 동일 업소를 확정한다.""" h = await auth_headers("u1") pid = (await _create_place(client, h))["place"]["place_id"] @@ -61,8 +53,7 @@ async def test_verify_place_opens_collection(auth_headers, client): async def test_duplicate_kakao_place_is_allowed(auth_headers, client): - """검증: 같은 사장님이 같은 카카오 장소를 두 사업장에 붙인다. - 기대결과: 둘 다 등록된다 — 한 사용자가 같은 실제 업장으로 여러 프로젝트를 만들 수 있다.""" + """검증: 같은 사장님이 같은 카카오 장소를 두 사업장에 붙인다.""" h = await auth_headers("u1") first = (await _create_place(client, h, "A펜션"))["place"]["place_id"] second = (await _create_place(client, h, "A펜션(중복)"))["place"]["place_id"] @@ -73,8 +64,7 @@ async def test_duplicate_kakao_place_is_allowed(auth_headers, client): async def test_naver_verify_without_external_id(auth_headers, client): - """검증: 네이버로 검증한다 — 고유 장소 id 가 없고 도로명주소만 있다. - 기대결과: 확정된다 — 네이버는 카카오 같은 고유 id 를 주지 않으므로 주소가 식별 근거다.""" + """검증: 네이버로 검증한다 — 고유 장소 id 가 없고 도로명주소만 있다.""" h = await auth_headers("u1") body = await _create_place(client, h, "핑크비치펜션") pid = body["place"]["place_id"] @@ -92,8 +82,7 @@ async def test_naver_verify_without_external_id(auth_headers, client): async def test_naver_duplicate_name_and_address_is_allowed(auth_headers, client): - """검증: 고유 id 없이 같은 상호명·같은 도로명주소로 두 번 등록한다. - 기대결과: 둘 다 등록된다.""" + """검증: 고유 id 없이 같은 상호명·같은 도로명주소로 두 번 등록한다.""" h = await auth_headers("u1") addr = "강원특별자치도 양양군 현북면 하조대3길 11" first = (await _create_place(client, h, "핑크비치펜션"))["place"]["place_id"] @@ -117,8 +106,7 @@ async def test_delete_place_removes_it_from_list(auth_headers, client): async def test_same_address_different_business_is_allowed(auth_headers, client): - """검증: 같은 건물(같은 도로명주소)에 상호명이 다른 두 가게를 등록한다. - 기대결과: 둘 다 통과 — ★ 한 건물에 카페와 식당이 같이 있다. 주소만으로 막으면 안 된다.""" + """검증: 같은 건물(같은 도로명주소)에 상호명이 다른 두 가게를 등록한다.""" h = await auth_headers("u1") addr = "서울특별시 강남구 테헤란로 1" a = (await _create_place(client, h, "1층카페"))["place"]["place_id"] @@ -129,8 +117,7 @@ async def test_same_address_different_business_is_allowed(auth_headers, client): async def test_verify_without_any_identifier_is_rejected(auth_headers, client): - """검증: 고유 id 도 주소도 없이 확정을 시도한다. - 기대결과: PLACE_VERIFY_NO_CANDIDATE — 식별 근거가 없으면 '그 가게'라고 말할 수 없다.""" + """검증: 고유 id 도 주소도 없이 확정을 시도한다.""" h = await auth_headers("u1") pid = (await _create_place(client, h))["place"]["place_id"] @@ -139,8 +126,7 @@ async def test_verify_without_any_identifier_is_rejected(auth_headers, client): async def test_place_is_scoped_to_owner(auth_headers, client): - """검증: 다른 사장님 계정으로 남의 사업장을 조회한다. - 기대결과: PLACE_NOT_FOUND — 존재 자체가 보이지 않는다(IDOR 차단).""" + """검증: 다른 사장님 계정으로 남의 사업장을 조회한다.""" h1 = await auth_headers("owner1") pid = (await _create_place(client, h1))["place"]["place_id"] @@ -150,8 +136,7 @@ async def test_place_is_scoped_to_owner(auth_headers, client): async def test_link_cannot_be_confirmed_before_place_verified(auth_headers, client): - """검증: 동일 업소 검증 전에 채널 URL 을 확정하려 한다. - 기대결과: PLACE_NOT_VERIFIED — ★ 검증 없이는 크롤링이 열리지 않는다.""" + """검증: 동일 업소 검증 전에 채널 URL 을 확정하려 한다.""" h = await auth_headers("u1") pid = (await _create_place(client, h))["place"]["place_id"] @@ -167,8 +152,7 @@ async def test_link_cannot_be_confirmed_before_place_verified(auth_headers, clie async def test_confirmed_link_becomes_crawl_target(auth_headers, client): - """검증: 검증 통과 후 채널 URL 을 확정한다. - 기대결과: confirmed_at 이 찍히고 confirmed_only 목록에 나타난다(= 크롤링 대상).""" + """검증: 검증 통과 후 채널 URL 을 확정한다.""" h = await auth_headers("u1") pid = (await _create_place(client, h))["place"]["place_id"] await client.post(f"/v1/place/{pid}/verify", headers=h, json={"external_place_id": "222"}) @@ -193,8 +177,7 @@ async def test_confirmed_link_becomes_crawl_target(auth_headers, client): async def test_confirm_link_twice_is_rejected(auth_headers, client): - """검증: 이미 확정된 링크를 다시 확정한다. - 기대결과: LINK_NOT_FOUND — 조건부 UPDATE 가 0행이라 동시 처리에도 안전하다.""" + """검증: 이미 확정된 링크를 다시 확정한다.""" h = await auth_headers("u1") pid = (await _create_place(client, h))["place"]["place_id"] await client.post(f"/v1/place/{pid}/verify", headers=h, json={"external_place_id": "333"}) @@ -207,8 +190,7 @@ async def test_confirm_link_twice_is_rejected(auth_headers, client): async def test_unknown_place_returns_not_found(auth_headers, client): - """검증: 없는 place_id 조회. - 기대결과: PLACE_NOT_FOUND.""" + """검증: 없는 place_id 조회.""" h = await auth_headers("u1") r = await client.get(f"/v1/place/{uuid.uuid4()}", headers=h) assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_place_itinerary_crud.py b/solution/backend/tests/test_place_itinerary_crud.py index 273d8f2..dbcc23b 100644 --- a/solution/backend/tests/test_place_itinerary_crud.py +++ b/solution/backend/tests/test_place_itinerary_crud.py @@ -1,9 +1,4 @@ -"""place_itineraries 읽기·덮어쓰기. - -이 표가 지켜야 하는 것: - - 업장 × 기간 = 한 행. 다시 생성하면 그 행을 덮어쓴다(행이 늘지 않는다) - - 다른 업장·다른 기간은 서로를 건드리지 않는다 -""" +"""place_itineraries 읽기·덮어쓰기.""" import uuid from common.database.db_session_manager import DB_SESSION_MNG @@ -44,8 +39,7 @@ async def _rows(place_id): async def test_upsert_then_read(db_engine): - """검증: 넣고 읽는다. - 기대결과: body 가 그대로 돌아온다 — 렌더러 계약을 그 자리에서 담는다.""" + """검증: 넣고 읽는다.""" pid = uuid.uuid4() assert await _upsert(_values(pid, "1박 2일", ["코스A", "코스B"])) == ErrorType.SUCCESS @@ -57,8 +51,7 @@ async def test_upsert_then_read(db_engine): async def test_upsert_overwrites_same_place_and_duration(db_engine): - """검증: 같은 업장·같은 기간을 다시 넣는다. - 기대결과: 행이 늘지 않고 body 가 바뀐다(uq_place_itineraries).""" + """검증: 같은 업장·같은 기간을 다시 넣는다.""" pid = uuid.uuid4() await _upsert(_values(pid, "1박 2일", ["옛 코스"])) await _upsert(_values(pid, "1박 2일", ["새 코스1", "새 코스2"])) @@ -69,8 +62,7 @@ async def test_upsert_overwrites_same_place_and_duration(db_engine): async def test_two_durations_live_side_by_side(db_engine): - """검증: 같은 업장의 두 기간. - 기대결과: 각각 한 행 — 1박2일이 2박3일을 덮지 않는다.""" + """검증: 같은 업장의 두 기간.""" pid = uuid.uuid4() await _upsert(_values(pid, "1박 2일", ["짧은 코스"])) await _upsert(_values(pid, "2박 3일", ["긴 코스"])) @@ -80,8 +72,7 @@ async def test_two_durations_live_side_by_side(db_engine): async def test_other_place_is_untouched(db_engine): - """검증: 다른 업장. - 기대결과: 서로 안 보인다 — 키가 place_id 다.""" + """검증: 다른 업장.""" mine, other = uuid.uuid4(), uuid.uuid4() await _upsert(_values(mine, "1박 2일", ["내 코스"])) await _upsert(_values(other, "1박 2일", ["남의 코스"])) diff --git a/solution/backend/tests/test_place_search.py b/solution/backend/tests/test_place_search.py index 81ea445..0445c7c 100644 --- a/solution/backend/tests/test_place_search.py +++ b/solution/backend/tests/test_place_search.py @@ -1,7 +1,4 @@ -"""상호명 공개 검색 — 로그인 없이 열려 있고, 업종을 LLM 없이 외부 분류로 추정한다. - -★ 인증이 없는 유일한 place 경로다. 우리 DB 값이 하나도 나가지 않는지 여기서 고정한다. -""" +"""상호명 공개 검색 — 로그인 없이 열려 있고, 업종을 LLM 없이 외부 분류로 추정한다.""" from common.enums import ErrorType, PlaceCategory from common.utils import rate_limit from services.external import kakao as kakao_client @@ -54,7 +51,7 @@ def test_naver_has_no_group_code(): def test_unknown_category_is_none(): - """모르면 None. 억지로 고르지 않는다 — 화면이 사장님에게 직접 묻는다.""" + """모르면 None.""" assert guess_category(None) is None assert guess_category("스포츠,레저 > 골프장") is None @@ -74,7 +71,7 @@ async def test_search_needs_no_token(client, monkeypatch): async def test_search_leaks_nothing_of_ours(client, monkeypatch): - """공개 응답이라 우리 DB 값이 새면 그대로 밖이다. 좌표·전화도 뺐다.""" + """공개 응답이라 우리 DB 값이 새면 그대로 밖이다.""" rate_limit.reset() _patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")]) @@ -105,11 +102,7 @@ async def test_search_rejects_short_query(client): async def test_rate_limit_closes_the_tap(client, monkeypatch): - """인증이 없는데 유료 외부 API 를 부른다 — 새로고침만으로 요금이 나가면 안 된다. - - ★ 검색어를 매번 바꾼다. 같은 검색어는 ttl_cache 가 레이트리밋보다 **앞에서** 받아 - 외부 API 를 아예 안 부르므로(요금이 안 나가므로 제한할 이유도 없다), 같은 말을 - 반복하면 이 가드가 아니라 캐시를 시험하게 된다.""" + """인증이 없는데 유료 외부 API 를 부른다 — 새로고침만으로 요금이 나가면 안 된다.""" rate_limit.reset() _patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")]) @@ -126,11 +119,7 @@ async def test_rate_limit_closes_the_tap(client, monkeypatch): async def test_cached_query_does_not_spend_the_rate_limit(client, monkeypatch): - """검증: 같은 검색어를 제한 횟수보다 많이 친다. - 기대결과: 통과한다 — 캐시가 받으면 외부 API 를 안 부르고, 안 부르면 요금도 안 난다. - - ★ 레이트리밋을 캐시보다 앞으로 옮기면 사장님이 상호를 고쳐 가며 치는 평범한 사용이 - 막힌다. 이 순서가 의도라는 것을 여기서 못 박는다.""" + """검증: 같은 검색어를 제한 횟수보다 많이 친다.""" rate_limit.reset() _patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")]) diff --git a/solution/backend/tests/test_publish_gate.py b/solution/backend/tests/test_publish_gate.py index 45c5fb8..8b4a1c3 100644 --- a/solution/backend/tests/test_publish_gate.py +++ b/solution/backend/tests/test_publish_gate.py @@ -1,7 +1,4 @@ -"""발행 검수 게이트 — 절대규칙 1~3 이 실제로 막는가. - -이 게이트를 우회하는 발행 경로가 생기면 안 된다. 여기 테스트가 그 계약이다. -""" +"""발행 검수 게이트 — 절대규칙 1~3 이 실제로 막는가.""" import pytest from common.enums import FactStatus, PlaceCategory, PublishRejectReason @@ -29,8 +26,7 @@ def _lodging_required(): def test_unverified_fact_blocks_publish(): - """검증: 미검증 fact 가 노출 대상에 섞여 있다. - 기대결과: ★ 규칙 1 — UNVERIFIED_FACT 로 거부. 틀린 체크인 시간이 나가면 예약 클레임이 난다.""" + """검증: 미검증 fact 가 노출 대상에 섞여 있다.""" facts = _lodging_required() + [_f("wifi", "true", FactStatus.UNVERIFIED)] r = check_facts_verified(facts) assert r.passed is False @@ -40,20 +36,17 @@ def test_unverified_fact_blocks_publish(): @pytest.mark.parametrize("status", [FactStatus.PENDING_OWNER, FactStatus.REJECTED, FactStatus.EXPIRED]) def test_every_non_publishable_status_blocks(status): - """검증: 노출 불가 상태 전부(확인대기·반려·만료). - 기대결과: 하나도 통과하지 못한다 — VERIFIED/CORRECTED 만 나간다.""" + """검증: 노출 불가 상태 전부(확인대기·반려·만료).""" assert check_facts_verified([_f("wifi", "true", status)]).passed is False def test_corrected_fact_is_publishable(): - """검증: 사람이 정정한 값(CORRECTED). - 기대결과: 통과 — 정정본은 노출 대상이다.""" + """검증: 사람이 정정한 값(CORRECTED).""" assert check_facts_verified([_f("wifi", "true", FactStatus.CORRECTED)]).passed is True def test_missing_required_field_blocks_publish(): - """검증: 숙박인데 취소 규정이 없다. - 기대결과: REQUIRED_FACT_MISSING — 빈 껍데기 페이지를 발행하지 않는다.""" + """검증: 숙박인데 취소 규정이 없다.""" facts = [f for f in _lodging_required() if f["key"] != "cancel_policy"] r = check_required_fields(PlaceCategory.LODGING, facts) assert r.passed is False @@ -63,16 +56,14 @@ def test_missing_required_field_blocks_publish(): def test_blank_value_counts_as_missing(): - """검증: 필수 fact 가 있긴 한데 값이 빈 문자열이다. - 기대결과: 누락으로 본다 — 빈 값을 채운 척하면 안 된다.""" + """검증: 필수 fact 가 있긴 한데 값이 빈 문자열이다.""" facts = _lodging_required() facts[0]["value"] = " " assert check_required_fields(PlaceCategory.LODGING, facts).passed is False def test_zero_unique_content_blocks_publish(): - """검증: 고유 콘텐츠가 0건이다. - 기대결과: ★ 규칙 2 — NO_UNIQUE_CONTENT 로 거부. 템플릿 대량 생성은 스팸 판정 대상이다.""" + """검증: 고유 콘텐츠가 0건이다.""" r = check_unique_content(0) assert r.passed is False assert r.reason == PublishRejectReason.NO_UNIQUE_CONTENT @@ -80,14 +71,11 @@ def test_zero_unique_content_blocks_publish(): def test_unmeasured_unique_content_is_not_zero(): - """검증: 렌더러가 계수를 못 재고(None) 실패했다. - 기대결과: 통과 — 디스크·번들 오류에 NO_UNIQUE_CONTENT 라는 엉뚱한 사유가 붙으면 안 된다.""" assert check_unique_content(None).passed is True def test_jsonld_mismatch_blocks_publish(): - """검증: 구조화 데이터 값이 화면 값과 다르다. - 기대결과: ★ 규칙 3 — JSONLD_MISMATCH 로 거부. 검색엔진에만 다른 말을 하면 안 된다.""" + """검증: 구조화 데이터 값이 화면 값과 다르다.""" r = check_jsonld_matches(["checkinTime: jsonld=14:00 / html=15:00"]) assert r.passed is False assert r.reason == PublishRejectReason.JSONLD_MISMATCH @@ -96,24 +84,21 @@ def test_jsonld_mismatch_blocks_publish(): def test_evaluate_passes_a_complete_site(): - """검증: 모든 조건을 만족하는 사이트. - 기대결과: 통과.""" + """검증: 모든 조건을 만족하는 사이트.""" r = evaluate(PlaceCategory.LODGING, _lodging_required(), unique_content_count=5, mismatches=[]) assert r.passed is True assert r.reason is None def test_evaluate_reports_most_severe_first(): - """검증: 여러 규칙을 동시에 위반한다. - 기대결과: 미검증 fact 가 먼저 잡힌다 — 예약 클레임이 스팸 판정보다 급하다.""" + """검증: 여러 규칙을 동시에 위반한다.""" facts = _lodging_required() + [_f("wifi", "true", FactStatus.UNVERIFIED)] r = evaluate(PlaceCategory.LODGING, facts, unique_content_count=0, mismatches=["x"]) assert r.reason == PublishRejectReason.UNVERIFIED_FACT def test_evaluate_does_not_block_thin_content(): - """검증: 고유 문장이 없어도 발행 게이트는 통과한다. - 콘텐츠 품질은 편집기 경고로 안내하고 사장님의 발행 선택을 막지 않는다.""" + """검증: 고유 문장이 없어도 발행 게이트는 통과한다.""" r = evaluate(PlaceCategory.LODGING, _lodging_required(), unique_content_count=0, mismatches=[]) log = r.as_log() assert r.passed is True @@ -122,8 +107,7 @@ def test_evaluate_does_not_block_thin_content(): @pytest.mark.parametrize("category", list(PlaceCategory)) def test_required_check_works_for_every_category(category): - """검증: 업종 4종 전부에서 필수 검사가 동작하는가. - 기대결과: fact 가 하나도 없으면 전부 거부 — 업종 추가 시 게이트가 자동으로 따라온다.""" + """검증: 업종 4종 전부에서 필수 검사가 동작하는가.""" r = check_required_fields(category, []) assert r.passed is False assert r.detail["missing"] diff --git a/solution/backend/tests/test_render_report.py b/solution/backend/tests/test_render_report.py index 50f995d..6ee869c 100644 --- a/solution/backend/tests/test_render_report.py +++ b/solution/backend/tests/test_render_report.py @@ -1,10 +1,4 @@ -"""렌더 보고서 — ★ 발행 기록(DB)과 실제 페이지(정적 파일)의 간극을 드러낸다. - -이 모듈이 절대 하면 안 되는 것: - 1. 페이지가 없는데 "정상" 이라고 답하는 것 (사장님은 404 를 눌러 보고서야 안다) - 2. 낡은 렌더를 현재 버전인 것처럼 통과시키는 것 - 3. 보고서가 깨졌다고 사이트 조회 자체를 실패시키는 것 (부수 산출물이다) -""" +"""렌더 보고서 — ★ 발행 기록(DB)과 실제 페이지(정적 파일)의 간극을 드러낸다.""" import json import pytest @@ -43,7 +37,7 @@ def _write(payload_dir, slug, **overrides): # ── 렌더 상태 ──────────────────────────────────────────────────────────────── def test_보고서가_없으면_아직_안_구운_것이다(payload_dir): - # ★ "없음" 을 "정상" 으로 답하면 발행 직후 404 를 정상이라고 말하게 된다. + # "없음" 을 "정상" 으로 답하면 발행 직후 404 를 정상이라고 말하게 된다. assert render_report.render_status("joy", 1)["state"] == "PENDING" diff --git a/solution/backend/tests/test_review.py b/solution/backend/tests/test_review.py index 056843b..7c126dd 100644 --- a/solution/backend/tests/test_review.py +++ b/solution/backend/tests/test_review.py @@ -1,11 +1,4 @@ -"""이용 후기 — 접수·검수·게재. - -★ 이 파일이 지키는 것: - - 발행된 사이트에만 후기를 받는다 - - 전화번호·이메일이 든 글은 받지 않는다(받지 않기로 한 값이 글로 들어오는 경로를 막는다) - - 검수를 통과해야 화면에 나간다 - - 사장님이 닿는 :9800 에 검수 API 가 없다 -""" +"""이용 후기 — 접수·검수·게재.""" import uuid from sqlalchemy import text diff --git a/solution/backend/tests/test_rollback.py b/solution/backend/tests/test_rollback.py index 0056152..22e1bf3 100644 --- a/solution/backend/tests/test_rollback.py +++ b/solution/backend/tests/test_rollback.py @@ -1,10 +1,4 @@ -"""롤백 — 예전 버전으로 공개 주소를 되돌린다. - -★ 이 경로가 절대 하면 안 되는 것: - - 재수집·재생성으로 스냅샷을 새로 만드는 것 (그 버전이 발행됐던 그대로여야 한다) - - 실패했는데 site.current_version_id 를 바꾸는 것 (직전 공개본이 그대로 있어야 한다) - - 지금 공개 중인 버전으로 "롤백"을 허용하는 것 (의미 없는 잡을 만든다) -""" +"""이 경로가 절대 하면 안 되는 것:""" import uuid from common.enums import BuildStatus, JobStatus, SiteStatus @@ -27,8 +21,7 @@ async def _publish(client, h, pid, intro="하조대 해변 도보 3분 거리의 async def test_rollback_to_earlier_version(auth_headers, client, db_engine): - """검증: v1 발행 → v2 발행 → v1 로 롤백. - 기대결과: 공개 버전이 다시 v1 이 된다. 롤백은 새 site_versions 행을 만들지 않는다.""" + """검증: v1 발행 → v2 발행 → v1 로 롤백.""" h = await auth_headers("u1") pid = await _place(client, h) await _approved_media(db_engine, pid) @@ -55,15 +48,12 @@ async def test_rollback_to_earlier_version(auth_headers, client, db_engine): assert site["current_version"]["build_status"] == BuildStatus.BUILT.value versions = (await client.get(f"/v1/place/{pid}/site/version/list", headers=h)).json()["versions"] - # ★ 롤백은 새 버전을 만들지 않는다 — v1·v2 두 개뿐이어야 한다. + # 롤백은 새 버전을 만들지 않는다 — v1·v2 두 개뿐이어야 한다. assert len(versions) == 2 async def test_rollback_rejects_current_version(auth_headers, client, db_engine): - """검증: 지금 공개 중인 버전으로 롤백을 시도한다. - 기대결과: ★ 성공으로 끝나지 않는다(last_error) — 의미 없는 재굽기를 하지 않는다. - (BuildAborted 와 같은 패턴 — 재시도해도 소용없는 중단이라 PENDING(백오프)으로 남고, - 소진되면 DEAD 된다. 여기서 보는 건 "그래도 site 상태는 안 바뀐다"는 것이다.)""" + """검증: 지금 공개 중인 버전으로 롤백을 시도한다.""" h = await auth_headers("u1") pid = await _place(client, h) await _approved_media(db_engine, pid) @@ -82,8 +72,7 @@ async def test_rollback_rejects_current_version(auth_headers, client, db_engine) async def test_rollback_rejects_unknown_version(auth_headers, client, db_engine): - """검증: 존재하지 않는 버전 번호로 롤백을 시도한다. - 기대결과: ★ 잡이 성공으로 끝나지 않는다 — 공개 상태는 그대로다.""" + """검증: 존재하지 않는 버전 번호로 롤백을 시도한다.""" h = await auth_headers("u1") pid = await _place(client, h) await _approved_media(db_engine, pid) @@ -102,8 +91,7 @@ async def test_rollback_rejects_unknown_version(auth_headers, client, db_engine) async def test_failed_rollback_keeps_current_published_version(auth_headers, client, db_engine, monkeypatch): - """검증: 롤백 대상 렌더가 실패한다(구조화 데이터 불일치로 시뮬레이션). - 기대결과: ★ site.current_version_id 는 바뀌지 않는다 — 직전 공개본이 그대로 서비스된다.""" + """검증: 롤백 대상 렌더가 실패한다(구조화 데이터 불일치로 시뮬레이션).""" from services import render_service h = await auth_headers("u1") diff --git a/solution/backend/tests/test_schema_ddl.py b/solution/backend/tests/test_schema_ddl.py index 44923e0..7678a4b 100644 --- a/solution/backend/tests/test_schema_ddl.py +++ b/solution/backend/tests/test_schema_ddl.py @@ -1,15 +1,10 @@ -"""init.sql ↔ ORM 모델 정합성. - -스키마 정의가 두 곳(마이그레이션 SQL / SQLAlchemy 모델)에 있으므로 둘이 어긋나면 -"테스트는 통과하는데 실서버에서 컬럼이 없는" 상황이 난다. DB 없이 파일만 비교해 그걸 잡는다. -""" +"""init.sql ↔ ORM 모델 정합성.""" import re from pathlib import Path from common.database.model.models import MAIN_BASE # parents[3] = 레포 루트 (tests → backend → solution → 루트). -# ★ 백엔드가 solution/ 안으로 들어가면서 한 칸 깊어졌다 — 폴더를 옮기면 여기부터 깨진다. _INIT_SQL = Path(__file__).resolve().parents[3] / "postgres-init" / "init-data" / "init.sql" _CREATE_TABLE_RE = re.compile( @@ -36,32 +31,23 @@ def _parse_init_sql() -> dict: return out -# init.sql 에는 있고 ORM 모델은 없는 표. 마이그레이션 대장은 scripts/migrate.py 가 소유하고 -# 애플리케이션 코드가 읽지 않는다 — 모델을 만들면 도메인 표처럼 보인다. +# init.sql 에는 있고 ORM 모델은 없는 표. _NOT_ORM = {"public.schema_migrations"} def _model_tables() -> set: - """ORM 모델 → {"public.table", ...} - - ★ 모델은 스키마를 적지 않는다(`Table.schema is None`) — 2026-09-09 에 도메인 스키마를 - 걷어내고 public 한 벌로 폈기 때문이다(migrations/0005). init.sql 은 `public.` 을 - 명시하므로 여기서 같은 모양으로 맞춰 준다. `t.schema` 를 그대로 쓰면 "None.users" 가 - 되어 **모든 표가 누락으로 잡힌다** — 실제로 그렇게 이 테스트가 통째로 빨개졌다. - """ + """ORM 모델 → {"public.table", ...}""" return {f"{t.schema or 'public'}.{t.name}" for t in MAIN_BASE.metadata.sorted_tables} def test_init_sql_is_readable(): - """검증: init.sql 을 찾고 파싱할 수 있는지. - 기대결과: 파일이 존재하고 CREATE TABLE 이 1개 이상 파싱된다.""" + """검증: init.sql 을 찾고 파싱할 수 있는지.""" assert _INIT_SQL.exists(), f"init.sql 이 없다: {_INIT_SQL}" assert _parse_init_sql(), "init.sql 에서 CREATE TABLE 을 하나도 파싱하지 못했다" def test_every_model_table_exists_in_init_sql(): - """검증: ORM 모델의 모든 테이블이 init.sql 에도 있는지. - 기대결과: 누락 없음 — 모델만 추가하고 마이그레이션을 안 쓴 경우를 잡는다.""" + """검증: ORM 모델의 모든 테이블이 init.sql 에도 있는지.""" sql_tables = set(_parse_init_sql()) model_tables = _model_tables() missing = sorted(model_tables - sql_tables) @@ -69,8 +55,7 @@ def test_every_model_table_exists_in_init_sql(): def test_every_init_sql_table_has_a_model(): - """검증: init.sql 의 모든 테이블에 ORM 모델이 있는지. - 기대결과: 누락 없음 — 마이그레이션만 쓰고 모델을 안 만든 경우를 잡는다.""" + """검증: init.sql 의 모든 테이블에 ORM 모델이 있는지.""" sql_tables = set(_parse_init_sql()) - _NOT_ORM model_tables = _model_tables() missing = sorted(sql_tables - model_tables) @@ -78,8 +63,7 @@ def test_every_init_sql_table_has_a_model(): def test_columns_match_between_model_and_init_sql(): - """검증: 테이블마다 컬럼 집합이 양쪽에서 같은지. - 기대결과: 완전 일치 — 한쪽에만 추가된 컬럼을 잡는다.""" + """검증: 테이블마다 컬럼 집합이 양쪽에서 같은지.""" sql_tables = _parse_init_sql() problems = [] compared = 0 @@ -95,16 +79,14 @@ def test_columns_match_between_model_and_init_sql(): f"{name}: 모델에만 {sorted(model_cols - sql_cols)} / init.sql 에만 {sorted(sql_cols - model_cols)}" ) assert not problems, "컬럼 불일치:\n" + "\n".join(problems) - # ★ 한 표도 못 찾으면 이 테스트는 아무것도 검사하지 않고 통과한다. 실제로 그랬다 — - # 이름을 "None.users" 로 만들어 전부 건너뛰었고, 그동안 init.sql 이 조용히 어긋났다. + # 한 표도 못 찾으면 이 테스트는 아무것도 검사하지 않고 통과한다. assert compared == len(MAIN_BASE.metadata.sorted_tables), ( f"init.sql 에서 {compared}/{len(MAIN_BASE.metadata.sorted_tables)} 개만 찾았다 — 이름 규칙이 어긋났다" ) def test_every_table_has_soft_delete_columns(): - """검증: 모든 테이블이 공통 컬럼(created_at·updated_at·deleted)을 갖는지. - 기대결과: MainTableMixin 을 빠뜨린 모델이 없다 — 소프트 삭제 전제가 깨지면 유니크 부분 인덱스도 깨진다.""" + """검증: 모든 테이블이 공통 컬럼(created_at·updated_at·deleted)을 갖는지.""" for table in MAIN_BASE.metadata.sorted_tables: cols = {c.name for c in table.columns} assert {"created_at", "updated_at", "deleted"} <= cols, f"{table.schema}.{table.name}: 공통 컬럼 누락" diff --git a/solution/backend/tests/test_search_console_client.py b/solution/backend/tests/test_search_console_client.py index 4eb839f..bcdbd5e 100644 --- a/solution/backend/tests/test_search_console_client.py +++ b/solution/backend/tests/test_search_console_client.py @@ -1,17 +1,4 @@ -"""Search Console 클라이언트 — 사이트맵 제출 · URL 검사. - -★ 실제 Google API 를 호출하지 않는다(httpx.MockTransport). google-auth 도 이 -저장소의 의존성이 아니라서(Codex 가 추가) 인증은 모듈 함수 -`_service_account_credentials` / `_refresh_sync` 를 monkeypatch 해서 흉내 낸다 — -설치 여부와 무관하게 이 테스트가 돌아야 한다. - -여기서 고정하는 것: - 1. `inspect_url` 은 `inspectionResult.indexStatusResult` 만 돌려준다 - 2. ★ 그 필드가 없는 응답을 "미색인"으로 짐작하지 않고 예외를 올린다 - 3. 오류는 전부 `SearchConsoleError(code)` 로 정규화되고, 원본 예외 메시지·응답 - 본문이 `code` 에 섞여 나오지 않는다 - 4. 사이트맵 제출 URL 은 property/sitemap 주소를 완전 percent-encode 한다 -""" +"""Search Console 클라이언트 — 사이트맵 제출 · URL 검사.""" import asyncio import httpx @@ -59,8 +46,7 @@ def _client(handler, monkeypatch, **auth_kwargs) -> SearchConsoleClient: # ── 1) 사이트맵 제출 ────────────────────────────────────────────────────── async def test_사이트맵_제출은_URL을_완전_퍼센트인코딩한다(monkeypatch): - """검증: property/sitemap 주소를 PUT 경로에 싣는다. - 기대결과: 두 주소 모두 `/` `:` 까지 percent-encode 되어 경로 세그먼트 하나로 들어간다.""" + """검증: property/sitemap 주소를 PUT 경로에 싣는다.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: @@ -82,8 +68,7 @@ async def test_사이트맵_제출은_URL을_완전_퍼센트인코딩한다(mon async def test_사이트맵_제출_성공은_아무것도_돌려주지_않는다(monkeypatch): - """검증: 2xx 응답(빈 본문). - 기대결과: 예외 없이 끝난다 — 반환값은 None.""" + """검증: 2xx 응답(빈 본문).""" def handler(request): return httpx.Response(200) @@ -94,8 +79,7 @@ async def test_사이트맵_제출_성공은_아무것도_돌려주지_않는다 # ── 2) URL 검사 ─────────────────────────────────────────────────────────── async def test_URL_검사는_indexStatusResult만_돌려준다(monkeypatch): - """검증: 응답에 indexStatusResult 외에 mobileUsabilityResult 등 다른 필드도 있다. - 기대결과: indexStatusResult 만 뽑아 돌려준다.""" + """검증: 응답에 indexStatusResult 외에 mobileUsabilityResult 등 다른 필드도 있다.""" def handler(request: httpx.Request) -> httpx.Response: assert request.method == "POST" assert scc.INSPECT_URL in str(request.url) @@ -114,8 +98,7 @@ async def test_URL_검사는_indexStatusResult만_돌려준다(monkeypatch): async def test_URL_검사_요청_본문은_inspectionUrl과_siteUrl이다(monkeypatch): - """검증: 검사 요청 본문. - 기대결과: {"inspectionUrl": 검사할 페이지, "siteUrl": 프로퍼티} 그대로.""" + """검증: 검사 요청 본문.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: @@ -132,9 +115,7 @@ async def test_URL_검사_요청_본문은_inspectionUrl과_siteUrl이다(monkey async def test_inspectionResult가_없으면_미색인으로_넘겨짚지_않는다(monkeypatch): - """검증: ★ 응답 본문이 비어 있다({}). - 기대결과: SearchConsoleError("missing_inspection_result") — 빈 dict 를 돌려주지 않는다. - (호출측이 빈 dict 를 "미색인"으로 오판할 수 있으므로 여기서 예외로 끊는다.)""" + """검증: ★ 응답 본문이 비어 있다({}).""" def handler(request): return httpx.Response(200, json={}) @@ -147,8 +128,7 @@ async def test_inspectionResult가_없으면_미색인으로_넘겨짚지_않는 async def test_indexStatusResult가_없으면_예외(monkeypatch): - """검증: inspectionResult 는 있는데 indexStatusResult 가 없다(다른 결과만 있음). - 기대결과: SearchConsoleError("missing_index_status_result").""" + """검증: inspectionResult 는 있는데 indexStatusResult 가 없다(다른 결과만 있음).""" def handler(request): return httpx.Response(200, json={"inspectionResult": {"mobileUsabilityResult": {}}}) @@ -161,8 +141,7 @@ async def test_indexStatusResult가_없으면_예외(monkeypatch): async def test_indexStatusResult가_빈_객체면_예외(monkeypatch): - """검증: indexStatusResult 는 있지만 빈 dict({}) — 실제 검사가 안 된 응답이다. - 기대결과: SearchConsoleError("missing_index_status_result") — 빈 결과를 색인 상태로 돌려주지 않는다.""" + """검증: indexStatusResult 는 있지만 빈 dict({}) — 실제 검사가 안 된 응답이다.""" def handler(request): return httpx.Response(200, json={"inspectionResult": {"indexStatusResult": {}}}) @@ -175,8 +154,7 @@ async def test_indexStatusResult가_빈_객체면_예외(monkeypatch): async def test_비정상_JSON_본문은_invalid_json(monkeypatch): - """검증: 200 인데 본문이 JSON 이 아니다. - 기대결과: SearchConsoleError("invalid_json") — 파싱 실패가 조용히 넘어가지 않는다.""" + """검증: 200 인데 본문이 JSON 이 아니다.""" def handler(request): return httpx.Response(200, text="<html>not json</html>") @@ -194,8 +172,7 @@ async def test_비정상_JSON_본문은_invalid_json(monkeypatch): [(401, "unauthorized"), (403, "forbidden"), (429, "rate_limited"), (500, "server_error"), (503, "server_error")], ) async def test_HTTP_오류_상태코드는_code로_정규화된다(monkeypatch, status, code): - """검증: 401/403/429/5xx 응답. - 기대결과: SearchConsoleError.code 가 상태별로 정규화되고, Google 응답 본문은 code 에 섞이지 않는다.""" + """검증: 401/403/429/5xx 응답.""" def handler(request): return httpx.Response(status, text="google 응답 본문(비밀은 아니지만 새면 안 된다)") @@ -210,8 +187,7 @@ async def test_HTTP_오류_상태코드는_code로_정규화된다(monkeypatch, # ── 4) 전송 오류 ───────────────────────────────────────────────────────── async def test_타임아웃은_timeout으로_정규화(monkeypatch): - """검증: 요청이 타임아웃된다. - 기대결과: SearchConsoleError("timeout").""" + """검증: 요청이 타임아웃된다.""" def handler(request): raise httpx.ReadTimeout("timed out", request=request) @@ -224,8 +200,7 @@ async def test_타임아웃은_timeout으로_정규화(monkeypatch): async def test_연결_실패는_transport_error로_정규화(monkeypatch): - """검증: 연결 자체가 끊긴다(DNS 실패 등). - 기대결과: SearchConsoleError("transport_error").""" + """검증: 연결 자체가 끊긴다(DNS 실패 등).""" def handler(request): raise httpx.ConnectError("연결 실패", request=request) @@ -239,8 +214,7 @@ async def test_연결_실패는_transport_error로_정규화(monkeypatch): # ── 5) 인증 실패 ───────────────────────────────────────────────────────── async def test_키파일_로딩_실패는_invalid_credentials_file(monkeypatch): - """검증: 서비스 계정 키 파일을 못 읽는다(없음·손상). - 기대결과: SearchConsoleError("invalid_credentials_file") — 원본 예외 메시지는 안 실린다.""" + """검증: 서비스 계정 키 파일을 못 읽는다(없음·손상).""" def handler(request): raise AssertionError("인증에 실패했으면 네트워크를 타면 안 된다") @@ -254,8 +228,7 @@ async def test_키파일_로딩_실패는_invalid_credentials_file(monkeypatch): async def test_토큰_갱신_실패는_auth_failed(monkeypatch): - """검증: 키 파일은 읽었지만 Google 토큰 발급이 거부된다(폐기된 키 등). - 기대결과: SearchConsoleError("auth_failed") — 원본 예외 메시지는 안 실린다.""" + """검증: 키 파일은 읽었지만 Google 토큰 발급이 거부된다(폐기된 키 등).""" def handler(request): raise AssertionError("인증에 실패했으면 네트워크를 타면 안 된다") @@ -269,8 +242,7 @@ async def test_토큰_갱신_실패는_auth_failed(monkeypatch): async def test_토큰이_비면_auth_failed(monkeypatch): - """검증: 갱신은 예외 없이 끝났지만 credentials.token 이 비어 있다. - 기대결과: SearchConsoleError("auth_failed") — 빈 토큰으로 요청을 보내지 않는다.""" + """검증: 갱신은 예외 없이 끝났지만 credentials.token 이 비어 있다.""" def handler(request): raise AssertionError("빈 토큰으로 네트워크를 타면 안 된다") @@ -283,9 +255,7 @@ async def test_토큰이_비면_auth_failed(monkeypatch): async def test_인증정보_로딩과_유효한_토큰_갱신은_한_번만_한다(monkeypatch): - """검증: 같은 클라이언트로 두 번 호출한다. - 기대결과: 키 파일 로딩 1회 · 토큰 갱신 1회 — 두 번째 호출은 여전히 유효한(`credentials.valid`) - 토큰을 그대로 재사용한다.""" + """검증: 같은 클라이언트로 두 번 호출한다.""" def handler(request): return httpx.Response(200) @@ -300,9 +270,7 @@ async def test_인증정보_로딩과_유효한_토큰_갱신은_한_번만_한 async def test_동시_호출은_토큰_갱신을_한_번만_한다(monkeypatch): - """검증: 아직 토큰이 없는 클라이언트를 두 요청이 동시에 부른다. - 기대결과: 갱신 1회 — 잠금 없이 두 번째 요청이 갱신 중인 자격을 다시 갱신하면 - Google 토큰 엔드포인트를 요청 수만큼 때리게 된다.""" + """검증: 아직 토큰이 없는 클라이언트를 두 요청이 동시에 부른다.""" import time as _time def handler(request): @@ -330,8 +298,7 @@ async def test_동시_호출은_토큰_갱신을_한_번만_한다(monkeypatch): # ── 6) 닫기 ────────────────────────────────────────────────────────────── async def test_async_컨텍스트매니저는_내부_httpx_클라이언트를_닫는다(monkeypatch): - """검증: `async with` 블록을 빠져나간다. - 기대결과: 내부 httpx.AsyncClient 가 닫힌다 — 커넥션을 남겨두지 않는다.""" + """검증: `async with` 블록을 빠져나간다.""" def handler(request): return httpx.Response(200) diff --git a/solution/backend/tests/test_search_console_service.py b/solution/backend/tests/test_search_console_service.py index e46e9a8..86a7af1 100644 --- a/solution/backend/tests/test_search_console_service.py +++ b/solution/backend/tests/test_search_console_service.py @@ -259,8 +259,7 @@ def test_existing_scheduler_registers_optional_job(monkeypatch, enabled, count): monkeypatch.setenv("SCHEDULER_ENABLED", "1") monkeypatch.setenv("GSC_ENABLED", enabled) scheduler.start_scheduler() - # ★ 알림 스윕 둘(alert-outbox·queue-health)은 GSC_ENABLED 와 무관하게 항상 등록된다 - # (scheduler/__init__.py, docs/ALERTS.md) — search-console 잡만 옵션이다. + # 알림 스윕 둘(alert-outbox·queue-health)은 GSC_ENABLED 와 무관하게 항상 등록된다 (scheduler/__init__.py, docs/ALERTS.md) — search-console 잡만 옵션이다. always_on = {kw["id"] for _a, kw in jobs} - {"search-console"} assert always_on == {"alert-outbox", "queue-health"} assert len(jobs) == count + 2 diff --git a/solution/backend/tests/test_seo_keywords.py b/solution/backend/tests/test_seo_keywords.py index af53bf2..7490d2b 100644 --- a/solution/backend/tests/test_seo_keywords.py +++ b/solution/backend/tests/test_seo_keywords.py @@ -1,16 +1,4 @@ -"""발행 사이트 메타 태그 키워드(SiteOntology). - -이 경로가 절대 하면 안 되는 것: - - 이 가게 자료에 없는 낱말이 든 키워드를 메타 태그·제목에 싣는 것 - (확인 안 된 시설 · 다른 권역 · 가격 주장) - - SiteOntology 실패로 발행을 막는 것 — 키워드는 곁들이다 - - 해석되지 않은 업체의 추천(입력 문자열로 검색한 결과)을 쓰는 것 - - 숙박이 아닌 업종에 펜션 키워드를 붙이는 것 - -★ MATCH 의 matches 1~10 · byLane 은 실제 응답에서 옮겼다 — 2026-09-14 로컬 SiteOntology(:3100) 에 - 스테이머뭄 프로필(features 독채·프라이빗, audiences 커플·가족, nearby 말랭이마을·동국사)을 보낸 결과. - 11~13 은 같은 사전에 실제로 있는 단어로, 거르기 규칙을 보려고 붙였다. -""" +"""발행 사이트 메타 태그 키워드(SiteOntology).""" import json import uuid @@ -86,8 +74,7 @@ def _snapshot(**place): # ── 요청 만들기 ───────────────────────────────────────────────────────────── def test_스냅샷으로_업체_프로필을_만든다(): - """검증: 확인된 fact·지역 정보가 든 숙박 스냅샷. - 기대결과: 있음(true)은 features, 모름(fact 없음)은 unverified, 없음(false)은 어디에도 없다.""" + """검증: 확인된 fact·지역 정보가 든 숙박 스냅샷.""" merchant = seo_keywords.build_merchant("place-1", _snapshot()) assert merchant == { @@ -102,7 +89,7 @@ def test_스냅샷으로_업체_프로필을_만든다(): "features": ["주차", "독채"], # 음식점(이성당)은 주변 관광지가 아니다. "nearby": ["말랭이마을", "동국사"], - # ★ 바베큐는 false(없음)라 여기 없다 — 넣으면 SiteOntology 가 배제 대신 보류한다. + # 바베큐는 false(없음)라 여기 없다 — 넣으면 SiteOntology 가 배제 대신 보류한다. "unverified": ["애견동반", "조식"], }, } @@ -113,8 +100,7 @@ def test_숙박이_아니면_부르지_않는다(): def test_표에_없는_지역은_지역을_비운다(): - """검증: SiteOntology 지역 표(54개)에 없는 시·군. - 기대결과: 지어내지 않고 None — 없는 키를 보내면 SiteOntology 가 500 이다.""" + """검증: SiteOntology 지역 표(54개)에 없는 시·군.""" merchant = seo_keywords.build_merchant("place-1", _snapshot(road_address="서울특별시 강남구 테헤란로 1")) assert merchant["regionId"] is None @@ -126,9 +112,7 @@ def test_같은_이름의_시군은_시도로_가른다(): # ── 거르기 ───────────────────────────────────────────────────────────────── def test_자료가_뒷받침하는_키워드만_남긴다(): - """검증: 실제 추천 결과를 이 가게 자료(주차·독채·원도심·말랭이마을·동국사)로 거른다. - 기대결과: 마당·복층·커플·가족·프라이빗(확인 안 됨), 선유도(다른 권역), 최저가(가격 주장), - 질문형, 보류(hold)가 모두 빠진다. 순위는 SiteOntology 순서 그대로.""" + """검증: 실제 추천 결과를 이 가게 자료(주차·독채·원도심·말랭이마을·동국사)로 거른다.""" merchant = seo_keywords.build_merchant("place-1", _snapshot()) seo = seo_keywords.select(MATCH, merchant) @@ -141,8 +125,6 @@ def test_제목_키워드는_유형_레인의_코어를_고른다(): def test_제목에는_예약_추천과_지역_없는_단어를_쓰지_않는다(): - """검증: 유형 레인 코어가 `… 예약` 이고, 지역명이 없는 태그가 섞였다. - 기대결과: 둘을 건너뛰고 `군산 펜션 독채` — 상호 옆에서 광고 문구가 되거나 지역 신호를 잃지 않는다.""" merchant = seo_keywords.build_merchant("place-1", _snapshot()) result = {"matches": [], "byLane": [{"key": "type", "items": [ _kw("군산 독채펜션 예약", "코어", intent="transactional"), @@ -153,8 +135,6 @@ def test_제목에는_예약_추천과_지역_없는_단어를_쓰지_않는다( def test_업종어뿐인_단어는_싣지_않는다(): - """검증: 낱말이 전부 업종어·"근처" 류인 추천(실측: 실제 발행에서 `숙소` 가 메타 한 칸을 차지했다). - 기대결과: 뺀다 — 어느 가게에나 붙는 단어라 이 가게를 설명하지 못한다. 자료 낱말이 섞이면 남긴다.""" merchant = seo_keywords.build_merchant("place-1", _snapshot()) result = {"matches": [_kw("숙소", "태그"), _kw("펜션 예약", "의도"), _kw("원도심", "태그")], "byLane": []} assert seo_keywords.select(result, merchant)["keywords"] == ["원도심"] @@ -181,7 +161,6 @@ def _fake_ontology(sent, *, reject_region=False, resolve=True): sent.append((url, json)) if url.endswith(site_ontology.PUBLISH_PATH): if reject_region and json.get("regionId"): - # 실측: region 표에 없는 키 → 외래키 위반 → 500 return _response(500, {"statusCode": 500, "message": "Internal server error"}, url) return _response(201, {"merchant": {"external_id": json["externalId"]}, "generation": "skipped"}, url) resolved = {"externalId": json["query"]} if resolve else None @@ -198,7 +177,7 @@ async def test_업체를_저장한_뒤_그_업체로_추천을_받는다(ontolog await site_ontology.match_for_merchant(merchant) assert [url for url, _ in sent] == ["http://onto.test/v1/merchants/publish", "http://onto.test/v1/match"] - # ★ generate:false 가 빠지면 SiteOntology 가 LLM 키워드 생성을 큐에 넣는다. + # generate:false 가 빠지면 SiteOntology 가 LLM 키워드 생성을 큐에 넣는다. assert sent[0][1] == {**merchant, "generate": False} assert sent[1][1] == {"query": "place-1", "limit": site_ontology.DEFAULT_LIMIT} @@ -216,8 +195,7 @@ async def test_지역_키가_거절되면_지역_없이_다시_보낸다(ontolog async def test_업체가_해석되지_않은_추천은_쓰지_않는다(ontology_url, monkeypatch): - """검증: match 가 resolved=null 로 201 을 준다(입력 문자열로 검색한 결과). - 기대결과: 실패로 본다 — 그 결과는 남의 동네 단어다(실측: `나운동 숙소`).""" + """검증: match 가 resolved=null 로 201 을 준다(입력 문자열로 검색한 결과).""" monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology([], resolve=False)) merchant = seo_keywords.build_merchant("place-1", _snapshot()) @@ -279,8 +257,7 @@ async def _build(client, h, pid) -> dict: async def test_발행_payload_에_키워드가_실린다(auth_headers, client, db_engine, ontology_url, monkeypatch): - """검증: SiteOntology 가 켜진 채로 숙박 사업장을 발행한다. - 기대결과: 스냅샷으로 만든 프로필이 나가고, 거른 키워드가 payload.seo 로 실린다.""" + """검증: SiteOntology 가 켜진 채로 숙박 사업장을 발행한다.""" h = await auth_headers("u1") pid = await _published_place(client, h, db_engine) sent = [] diff --git a/solution/backend/tests/test_showcase_api.py b/solution/backend/tests/test_showcase_api.py index 331240b..34ab64a 100644 --- a/solution/backend/tests/test_showcase_api.py +++ b/solution/backend/tests/test_showcase_api.py @@ -1,10 +1,4 @@ -"""공개 쇼케이스 목록 — 랜딩이 로그인 없이 부르는 유일한 사이트 조회. - -이 경로가 절대 하면 안 되는 것: - - 발행되지 않은 사이트를 보여주는 것 — 열면 404 다. - - 사업장 식별자·전화번호·상세 주소를 흘리는 것. 사이트 한 곳을 여는 것과 - 발행 업소 명단을 통째로 긁는 것은 다른 일이다. -""" +"""공개 쇼케이스 목록 — 랜딩이 로그인 없이 부르는 유일한 사이트 조회.""" import uuid from datetime import datetime, timedelta, timezone @@ -35,8 +29,7 @@ async def _publish(db_engine, owner_id, name, *, status, domain, thumb=None, min async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, owner_id): - """검증: 발행본 1개 + 미발행(DRAFT) 1개를 두고 인증 헤더 없이 부른다. - 기대결과: 발행본만 나온다.""" + """검증: 발행본 1개 + 미발행(DRAFT) 1개를 두고 인증 헤더 없이 부른다.""" await _publish(db_engine, owner_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae", thumb="https://w4ai.o2o.kr/thumbs/hajodae.jpg") await _publish(db_engine, owner_id, "아직펜션", status=SiteStatus.DRAFT.value, domain="notyet") @@ -52,8 +45,7 @@ async def test_발행된_사이트만_로그인_없이_보인다(client, db_engi async def test_개인정보와_내부값은_나가지_않는다(client, db_engine, owner_id): - """검증: 응답 항목의 키를 그대로 본다. - 기대결과: 상호명·업종·지역·주소·썸네일뿐. 지역은 시·군까지고 상세 주소는 없다.""" + """검증: 응답 항목의 키를 그대로 본다.""" await _publish(db_engine, owner_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae") item = (await client.get("/v1/showcase")).json()["items"][0] @@ -67,8 +59,7 @@ async def test_개인정보와_내부값은_나가지_않는다(client, db_engin async def test_썸네일이_없으면_키가_없다(client, db_engine, owner_id): - """★ 썸네일은 발행의 부수 효과라 실패할 수 있다(대표 사진이 없거나 CDN 이 죽었거나). - 그때 카드는 글자로 떨어져야지 목록에서 사라지면 안 된다.""" + """썸네일은 발행의 부수 효과라 실패할 수 있다(대표 사진이 없거나 CDN 이 죽었거나).""" await _publish(db_engine, owner_id, "그림없는집", status=SiteStatus.PUBLISHED.value, domain="nopic") item = (await client.get("/v1/showcase")).json()["items"][0] diff --git a/solution/backend/tests/test_site_slug.py b/solution/backend/tests/test_site_slug.py index a589d0b..c685a36 100644 --- a/solution/backend/tests/test_site_slug.py +++ b/solution/backend/tests/test_site_slug.py @@ -1,10 +1,4 @@ -"""사이트 주소(네임스페이스) 확인·예약. - -이 경로가 절대 하면 안 되는 것: - - 한글·대문자 주소를 통과시키는 것 — 주소가 퍼센트 인코딩 덩어리가 되어 사장님이 불러줄 수 없다 - - 확인은 통과시키고 저장에서 튕기는 것 — 규칙이 두 곳에 있으면 반드시 생긴다 - - ★ 이미 발행돼 색인된 주소를 바꾸는 것 — AI 검색이 잡아 둔 페이지가 404 가 된다 -""" +"""사이트 주소(네임스페이스) 확인·예약.""" import uuid import pytest @@ -47,14 +41,12 @@ async def _reserve(client, headers, pid, slug): ], ) def test_slug_rules(slug, reason): - """검증: 형식·예약어 규칙(확인과 저장이 같이 쓰는 단 하나의 정의). - 기대결과: 쓸 수 있으면 None, 아니면 사유 코드.""" + """검증: 형식·예약어 규칙(확인과 저장이 같이 쓰는 단 하나의 정의).""" assert site_slug.validate_slug(slug) == reason async def test_check_then_reserve(auth_headers, client): - """검증: 주소를 확인하고 예약한다. 사이트 행이 없어도 만들어진다. - 기대결과: available → 저장 → 자기 주소이므로 다시 확인해도 available.""" + """검증: 주소를 확인하고 예약한다.""" h = await auth_headers("slug1") pid = await _place(client, h) @@ -64,14 +56,13 @@ async def test_check_then_reserve(auth_headers, client): assert saved["result"]["code"] == ErrorType.SUCCESS.value assert saved["site"]["domain"] == "doflo" - # ★ 자기 자신은 중복이 아니다 — 저장해 둔 화면을 다시 열었을 때 '중복'이라고 하면 안 된다. + # 자기 자신은 중복이 아니다 — 저장해 둔 화면을 다시 열었을 때 '중복'이라고 하면 안 된다. again = await _check(client, h, pid, "doflo") assert again["available"] is True and "reason" not in again async def test_invalid_slug_is_refused_on_save_too(auth_headers, client): - """검증: 확인에서 막힌 값은 저장에서도 막힌다(클라이언트 검증을 믿지 않는다). - 기대결과: 두 경로가 같은 사유를 돌려준다.""" + """검증: 확인에서 막힌 값은 저장에서도 막힌다(클라이언트 검증을 믿지 않는다).""" h = await auth_headers("slug2") pid = await _place(client, h) @@ -85,8 +76,7 @@ async def test_invalid_slug_is_refused_on_save_too(auth_headers, client): async def test_taken_by_other_place_offers_suggestion(auth_headers, client): - """검증: 남이 쓰는 주소는 못 쓴다. 대신 쓸 수 있는 대안을 하나 준다. - 기대결과: available=false / TAKEN / suggestion=doflo-2.""" + """검증: 남이 쓰는 주소는 못 쓴다.""" h = await auth_headers("slug3") mine = await _place(client, h, "도플로") other = await _place(client, h, "도플로2호점") @@ -106,8 +96,7 @@ async def test_taken_by_other_place_offers_suggestion(auth_headers, client): async def test_published_site_slug_is_locked(auth_headers, client, db_engine): - """검증: ★ 이미 발행된 사이트의 주소는 바꿀 수 없다. - 기대결과: SITE_SLUG_LOCKED. 같은 값 재전송은 변경이 아니므로 통과.""" + """검증: ★ 이미 발행된 사이트의 주소는 바꿀 수 없다.""" h = await auth_headers("slug4") pid = await _place(client, h) await _reserve(client, h, pid, "published-stay") @@ -130,8 +119,7 @@ async def test_published_site_slug_is_locked(auth_headers, client, db_engine): async def test_other_owners_place_is_blocked(auth_headers, client): - """검증: 남의 사업장 주소는 확인도 예약도 못 한다. - 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다).""" + """검증: 남의 사업장 주소는 확인도 예약도 못 한다.""" h = await auth_headers("slug5") intruder = await auth_headers("slug6") pid = await _place(client, h) diff --git a/solution/backend/tests/test_site_template.py b/solution/backend/tests/test_site_template.py index d7c7849..dfeeae4 100644 --- a/solution/backend/tests/test_site_template.py +++ b/solution/backend/tests/test_site_template.py @@ -1,10 +1,4 @@ -"""템플릿 선택 저장. - -이 경로가 절대 하면 안 되는 것: - - 업종 허용 목록(templates.json)에 없는 템플릿을 저장하는 것. - - 발행됐다고 템플릿을 잠그는 것 — 디자인은 바뀌어도 URL이 그대로다. - - 바꿔 놓고 재빌드 표시를 안 하는 것. -""" +"""템플릿 선택 저장.""" import uuid from sqlalchemy import text diff --git a/solution/backend/tests/test_site_theme.py b/solution/backend/tests/test_site_theme.py index c7c1bd9..d5d59de 100644 --- a/solution/backend/tests/test_site_theme.py +++ b/solution/backend/tests/test_site_theme.py @@ -1,13 +1,4 @@ -"""디자인(색·서체·섹션) 저장과 발행 payload 반영. - -이 경로가 절대 하면 안 되는 것: - - 고른 디자인을 브라우저에만 두는 것 — 발행 잡이 읽을 곳이 없어 업종 기본 모양으로 굽는다. - 사장님이 섹션을 끄고 순서를 바꿔도 발행본이 안 바뀌던 것이 이 API 가 생긴 이유다. - - ★ 잠긴 섹션(SEO·필수 마크업)을 끈 채로 내보내는 것 — 끄기로도, 목록에서 빼기로도 막아야 한다. - 한쪽만 막으면 "끄는 대신 빼면 그만"이라 잠금이 무의미해진다. - - 템플릿 정의(templates.json)에 없는 모양·템플릿으로 굽는 것. - - templateId 를 theme 안에 같이 보관하는 것 — sites.template_id 와 갈린다. -""" +"""디자인(색·서체·섹션) 저장과 발행 payload 반영.""" import uuid import pytest @@ -18,7 +9,7 @@ from common.enums import ErrorType, PlaceCategory, SiteStatus from common.template_catalog import INDUSTRIES, TEMPLATES, UnknownTemplate from services.site_payload import to_site_payload -# 계약 그대로의 최소 테마. 배열 순서가 곧 섹션 순서다(별도 order 필드가 없다). +# 계약 그대로의 최소 테마. _THEME = { "colors": {"accent": "#c2410c"}, "colorPaletteId": "warm-sand", @@ -44,9 +35,7 @@ async def _get_site(client, headers, pid): async def test_set_theme_creates_site_row_and_round_trips(auth_headers, client): - """검증: 사이트 행이 없어도 디자인을 먼저 고를 수 있고, 저장한 모양 그대로 다시 읽힌다. - 기대결과: SUCCESS + 조회 응답의 site.theme 가 보낸 것과 같다. - ★ 다시 읽히지 않으면 저장은 됐는데 에디터는 기본값으로 돌아간다.""" + """검증: 사이트 행이 없어도 디자인을 먼저 고를 수 있고, 저장한 모양 그대로 다시 읽힌다.""" h = await auth_headers("thm1") pid = await _place(client, h) @@ -69,8 +58,7 @@ async def test_unknown_sections_are_accepted(auth_headers, client): async def test_oversized_theme_is_refused(auth_headers, client): - """검증: 해석하지 않는 값이라 크기만은 막는다 — 안 막으면 jsonb 하나가 DB 와 스냅샷을 부풀린다. - 기대결과: INVALID_REQUEST_DATA.""" + """검증: 해석하지 않는 값이라 크기만은 막는다 — 안 막으면 jsonb 하나가 DB 와 스냅샷을 부풀린다.""" h = await auth_headers("thm3") pid = await _place(client, h) @@ -80,8 +68,7 @@ async def test_oversized_theme_is_refused(auth_headers, client): async def test_template_id_is_not_stored_inside_theme(auth_headers, client): - """검증: templateId 는 sites.template_id 컬럼이 소유한다. - 기대결과: theme 안에 실려 와도 저장되지 않는다 — 두 곳에 두면 어느 쪽이 진짜인지 갈린다.""" + """검증: templateId 는 sites.template_id 컬럼이 소유한다.""" h = await auth_headers("thm4") pid = await _place(client, h) @@ -92,8 +79,7 @@ async def test_template_id_is_not_stored_inside_theme(auth_headers, client): async def test_empty_theme_clears_to_default(auth_headers, client): - """검증: 빈 값은 '고르지 않음'이다 — NULL 로 되돌아가 업종 기본으로 떨어진다. - 기대결과: 저장 후 {} 를 보내면 theme 가 응답에서 사라진다(None).""" + """검증: 빈 값은 '고르지 않음'이다 — NULL 로 되돌아가 업종 기본으로 떨어진다.""" h = await auth_headers("thm5") pid = await _place(client, h) await _set_theme(client, h, pid, _THEME) @@ -105,8 +91,7 @@ async def test_empty_theme_clears_to_default(auth_headers, client): async def test_published_site_theme_is_not_locked(auth_headers, client, db_engine): - """검증: ★ 발행된 사이트도 디자인은 바꿀 수 있다(주소와 다르다 — URL 이 그대로라 색인이 안 깨진다). - 기대결과: SUCCESS + 재빌드 필요 표시(needs_rebuild).""" + """검증: ★ 발행된 사이트도 디자인은 바꿀 수 있다(주소와 다르다 — URL 이 그대로라 색인이 안 깨진다).""" h = await auth_headers("thm6") pid = await _place(client, h) await _set_theme(client, h, pid, _THEME) @@ -124,8 +109,7 @@ async def test_published_site_theme_is_not_locked(auth_headers, client, db_engin async def test_other_owners_place_is_blocked(auth_headers, client): - """검증: 남의 사업장의 디자인은 바꿀 수 없다. - 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다).""" + """검증: 남의 사업장의 디자인은 바꿀 수 없다.""" h = await auth_headers("thm7") intruder = await auth_headers("thm8") pid = await _place(client, h) @@ -156,9 +140,7 @@ def test_payload_without_saved_theme_uses_industry_default(): def test_payload_follows_saved_order_and_toggles(): - """검증: 저장된 배열 순서와 on/off 가 발행본에 그대로 간다. - 기대결과: 저장 순서대로 앞자리를 채우고, 끈 섹션은 꺼진 채로 나간다. - ★ 예전엔 enabled 가 True 로 박혀 있어 끈 섹션이 그대로 발행됐다.""" + """검증: 저장된 배열 순서와 on/off 가 발행본에 그대로 간다.""" theme = _payload_theme(_THEME) assert [s["id"] for s in theme["sections"]][:3] == ["faq", "hero", "rooms"] assert next(s for s in theme["sections"] if s["id"] == "rooms")["enabled"] is False @@ -172,25 +154,20 @@ def test_payload_drops_variant_id(): def test_payload_ignores_saved_look(): - """검증: 모양(look)은 템플릿 정의가 정한다. 저장값의 look은 무시한다.""" + """검증: 모양(look)은 템플릿 정의가 정한다.""" theme = _payload_theme({"look": {"fontHeading": "Comic Sans"}}) assert theme["look"] == TEMPLATES["retro"]["look"] def test_locked_section_cannot_be_published_disabled(): - """검증: ★ 잠긴 섹션을 껐다고 저장한 경우. - 기대결과: 켜서 내보낸다 — SEO·필수 마크업 때문에 잠긴 것이라 꺼진 채로 나갈 수 없다. - ★ 저장값의 locked:false 도 믿지 않는다. 믿으면 클라이언트가 locked 를 내려 보내는 것만으로 - 필수 섹션을 끌 수 있어 잠금 자체가 무의미해진다.""" + """검증: ★ 잠긴 섹션을 껐다고 저장한 경우.""" theme = _payload_theme({"sections": [{"id": "hero", "name": "히어로", "enabled": False, "locked": False}]}) hero = next(s for s in theme["sections"] if s["id"] == "hero") assert hero["enabled"] is True and hero["locked"] is True def test_locked_section_omitted_from_saved_list_comes_back_enabled(): - """검증: ★ 잠긴 섹션을 아예 목록에서 뺀 경우. - 기대결과: 끝에 켜서 덧붙는다 — 끄기만 막고 빼기를 통과시키면 - "끄는 대신 빼면 그만"이라 위 규칙이 그대로 뚫린다.""" + """검증: ★ 잠긴 섹션을 아예 목록에서 뺀 경우.""" theme = _payload_theme({"sections": [{"id": "faq", "name": "FAQ", "enabled": True, "locked": False}]}) for sid in ("hero", "info", "map"): entry = next(s for s in theme["sections"] if s["id"] == sid) @@ -198,13 +175,7 @@ def test_locked_section_omitted_from_saved_list_comes_back_enabled(): def test_section_missing_from_saved_list_comes_back_enabled(): - """검증: 저장값에 없는 기본 섹션(= 나중에 추가한 섹션). - 기대결과: ★ 끝에 **켜서** 덧붙는다. - - 에디터에는 섹션을 빼는 기능이 없다(toggleSection·reorderSection 뿐). - 그래서 저장값에 없다는 건 "사장님이 뺐다"가 아니라 "저장할 당시 그 섹션이 없었다"는 뜻이다. - 꺼서 붙이면 새로 만든 섹션이 기존 사업장에 영원히 안 나온다 — 에디터에는 보이는데 - 발행본에는 없는 상태가 된다(실측: 날씨 섹션이 그렇게 빠져 있었다).""" + """검증: 저장값에 없는 기본 섹션(= 나중에 추가한 섹션).""" theme = _payload_theme({"sections": [{"id": "faq", "name": "FAQ", "enabled": True, "locked": False}]}) photos = next(s for s in theme["sections"] if s["id"] == "photos") assert photos["enabled"] is True @@ -214,15 +185,13 @@ def test_section_missing_from_saved_list_comes_back_enabled(): def test_owner_disabled_section_stays_disabled(): - """검증: 사장님이 **끈** 섹션(목록에는 있고 enabled=False). - 기대결과: 꺼진 채로 나간다 — 위 규칙이 "끈 것"까지 켜버리면 안 된다.""" + """검증: 사장님이 **끈** 섹션(목록에는 있고 enabled=False).""" theme = _payload_theme({"sections": [{"id": "photos", "name": "사진", "enabled": False, "locked": False}]}) assert next(s for s in theme["sections"] if s["id"] == "photos")["enabled"] is False def test_owner_written_section_body_reaches_publish_payload(): - """검증: 에디터에서 직접 쓴 소개 본문. - 기대결과: 발행 payload 에 그대로 남는다 — 저장만 되고 경계에서 버려지면 발행본과 계수에 못 쓴다.""" + """검증: 에디터에서 직접 쓴 소개 본문.""" theme = _payload_theme({"sections": [{ "id": "intro", "name": "소개", "enabled": True, "locked": False, "body": "바다를 보며 조용히 쉬어가는 작은 숙소입니다.", @@ -233,8 +202,7 @@ def test_owner_written_section_body_reaches_publish_payload(): def test_partial_colors_are_filled_from_category_default(): - """검증: 저장된 색이 일부 키만 담고 있을 때. - 기대결과: 빠진 자리는 템플릿 색이 메운다(렌더러는 6개를 모두 요구한다).""" + """검증: 저장된 색이 일부 키만 담고 있을 때.""" colors = _payload_theme({"colors": {"accent": "#c2410c"}})["colors"] assert colors["accent"] == "#c2410c" assert colors["bg"] == TEMPLATES["retro"]["colors"]["bg"] @@ -242,9 +210,7 @@ def test_partial_colors_are_filled_from_category_default(): def test_color_palette_id_never_reaches_the_payload(): - """검증: colorPaletteId 는 에디터 복원 전용이다. - 기대결과: 저장·반환은 되지만 발행 payload 의 theme 에는 없다 — - 발행 계약(SitePayload.SiteTheme)에 없는 필드를 흘리지 않는다.""" + """검증: colorPaletteId 는 에디터 복원 전용이다.""" assert "colorPaletteId" not in _payload_theme(_THEME) diff --git a/solution/backend/tests/test_site_thumbnail.py b/solution/backend/tests/test_site_thumbnail.py index 791a6fe..66f442d 100644 --- a/solution/backend/tests/test_site_thumbnail.py +++ b/solution/backend/tests/test_site_thumbnail.py @@ -1,13 +1,4 @@ -"""발행 썸네일 — 사이트의 대표 사진을 Azure Blob 으로 옮긴다. - -★ 스크린샷이 아니다(헤드리스 브라우저는 영구 금지, docs/DECISIONS.md 1-1). - 그래서 여기서 볼 것은 "무엇을 대표로 고르는가"와 "무엇을 받지 않는가" 두 가지다. - -이 경로가 절대 하면 안 되는 것: - - 썸네일 실패로 발행을 되돌리는 것 — 정적 파일은 이미 올라갔다. - - 이미지가 아닌 응답을 그대로 올리는 것 — 카드가 깨진 그림이 된다. - - 사이트 디렉터리(`s/<slug>/`) 안에 두는 것 — 다음 발행이 통째로 지운다. -""" +"""발행 썸네일 — 사이트의 대표 사진을 Azure Blob 으로 옮긴다.""" from types import SimpleNamespace import httpx @@ -21,7 +12,7 @@ def _snapshot(*rows): def _transport(monkeypatch, handler): - """사진을 내려주는 CDN 대역. 테스트가 실제 네트워크를 부르지 않게 한다.""" + """사진을 내려주는 CDN 대역.""" real = httpx.AsyncClient def factory(**kwargs): @@ -66,16 +57,14 @@ def test_설정이_없으면_아무것도_하지_않는다(monkeypatch): async def test_대표사진이_없으면_None_이고_발행은_그대로(blob): - """검증: 승인 사진이 객실 전용뿐이라 대표가 없다. - 기대결과: None 을 돌려주고 업로드하지 않는다(예외를 올리지 않는다).""" + """검증: 승인 사진이 객실 전용뿐이라 대표가 없다.""" snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/room.jpg", "unit_id": "u1"}) assert await site_thumbnail.store("butter", snapshot) is None assert blob.uploads == {} async def test_이미지가_아니면_받지_않는다(blob, monkeypatch): - """검증: 사진 URL 이 HTML(오류 페이지)을 돌려준다. - 기대결과: 거부하고 None. 깨진 그림을 쇼케이스에 올리지 않는다.""" + """검증: 사진 URL 이 HTML(오류 페이지)을 돌려준다.""" _transport(monkeypatch, lambda req: httpx.Response(200, headers={"content-type": "text/html"}, content=b"<html>")) snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None}) @@ -103,20 +92,13 @@ async def test_네트워크_실패는_삼킨다(blob, monkeypatch): async def test_사이트_디렉터리_밖의_thumbs_에_올린다(blob, monkeypatch): - """검증: 대표 사진을 받아 업로드한다. - 기대결과: `<prefix>/thumbs/<slug>.<ext>` — `s/<slug>/` 안이 아니다. - ★ 사이트 경로는 매 발행마다 프리렌더 산출물로 통째로 교체된다 - (azure_static._remove_stale_site_files) — 그 안에 두면 다음 발행에서 조용히 사라진다.""" + """검증: 대표 사진을 받아 업로드한다.""" _transport(monkeypatch, lambda req: httpx.Response(200, headers={"content-type": "image/jpeg"}, content=b"jpegbytes")) snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None}) url = await site_thumbnail.store("butter", snapshot, 3) - # ★ `?v=` 는 캐시 무효화다. 블롭 이름은 그대로 덮어쓰므로 주소가 안 변하면 - # 브라우저·CDN 이 지난 발행의 그림을 계속 보여준다(site_thumbnail.public_url). - # ★ 호스트를 박아 두지 않는다. 발행 오리진은 SITE_PUBLIC_HOST 에서 오고 기본값이 - # localhost 라(운영 주소를 기본으로 두면 로컬 빌드가 조용히 운영 주소를 굽는다), - # 테스트가 특정 도메인을 적으면 환경이 바뀔 때마다 여기서 깨진다. + # `?v=` 는 캐시 무효화다. from services import site_payload assert url == f"{site_payload.publish_origin()}/thumbs/butter.jpg?v=3" @@ -132,8 +114,7 @@ async def test_사이트_디렉터리_밖의_thumbs_에_올린다(blob, monkeypa assert settings.cache_control == site_thumbnail.CACHE_CONTROL -# ── 발행 경로와의 배선 ──────────────────────────────────────────────────── -# 아래 두 건은 "발행이 썸네일에 매달리지 않는가" 를 본다. 썸네일 만들기 자체는 위에서 봤다. +# ── 발행 경로와의 배선 ──────────────────────────────────────────────────── 아래 두 건은 "발행이 썸네일에 매달리지 않는가" 를 본다. async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_headers, client, db_engine, monkeypatch): from services import build_service from tests.test_build_publish import _approved_media, _place, _run, _verified_facts @@ -155,8 +136,7 @@ async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_head assert site["thumbnail_url"].startswith("https://w4ai.o2o.kr/thumbs/") assert site["thumbnail_url"].endswith("?v=1") - # ★ 재발행하면 주소가 바뀌어야 한다. 블롭 이름은 그대로 덮어쓰므로, 주소가 그대로면 - # 사장님은 사진을 바꾸고 다시 발행해도 캐시에 남은 **지난 그림**을 계속 본다. + # 재발행하면 주소가 바뀌어야 한다. await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True}) await _run() again = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"] @@ -165,8 +145,6 @@ async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_head async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers, client, db_engine, monkeypatch): - """★ 썸네일은 발행의 부수 효과다 — 정적 파일은 이미 올라갔다. 여기서 되돌리면 - 사장님 사이트가 그림 하나 때문에 안 나간다.""" from services import build_service from tests.test_build_publish import _approved_media, _place, _run, _verified_facts diff --git a/solution/backend/tests/test_snapshot.py b/solution/backend/tests/test_snapshot.py index 5c2864e..18611df 100644 --- a/solution/backend/tests/test_snapshot.py +++ b/solution/backend/tests/test_snapshot.py @@ -1,7 +1,4 @@ -"""빌드 스냅샷 — ★ 사이트에 나가면 안 되는 것이 스냅샷에 들어오지 않는가. - -스냅샷이 정적 빌드의 경계다. 여기 들어온 것은 그대로 발행되므로, 필터링이 여기서 새면 끝이다. -""" +"""빌드 스냅샷 — ★ 사이트에 나가면 안 되는 것이 스냅샷에 들어오지 않는가.""" import uuid from sqlalchemy import text @@ -54,8 +51,7 @@ class _Place: async def test_only_publishable_facts_enter_snapshot(db_engine, owner_id): - """검증: 여러 상태의 fact 를 섞어 넣는다. - 기대결과: ★ VERIFIED·CORRECTED 만 스냅샷에 담긴다 — 미검증 값이 사이트로 새지 않는다.""" + """검증: 여러 상태의 fact 를 섞어 넣는다.""" pid = await _seed(db_engine, owner_id) await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED) await _fact(db_engine, pid, "wifi", "true", FactStatus.CORRECTED) @@ -70,8 +66,7 @@ async def test_only_publishable_facts_enter_snapshot(db_engine, owner_id): async def test_only_approved_media_enters_snapshot(db_engine, owner_id): - """검증: 승인/확인대기/반려 사진을 섞어 넣는다. - 기대결과: ★ APPROVED 만 담긴다 — Vision 신뢰도가 낮아 확인 큐에 남은 사진은 안 나간다.""" + """검증: 승인/확인대기/반려 사진을 섞어 넣는다.""" pid = await _seed(db_engine, owner_id) await _media(db_engine, pid, "https://cdn.test/ok.jpg", MediaStatus.APPROVED) await _media(db_engine, pid, "https://cdn.test/pending.jpg", MediaStatus.PENDING_REVIEW) @@ -82,8 +77,7 @@ async def test_only_approved_media_enters_snapshot(db_engine, owner_id): async def test_media_without_alt_is_excluded(db_engine, owner_id): - """검증: 승인됐지만 alt 텍스트가 없는 사진. - 기대결과: 빠진다 — alt 없는 이미지는 접근성도 AI 검색 신호도 없다.""" + """검증: 승인됐지만 alt 텍스트가 없는 사진.""" pid = await _seed(db_engine, owner_id) await _media(db_engine, pid, "https://cdn.test/noalt.jpg", MediaStatus.APPROVED, alt="") await _media(db_engine, pid, "https://cdn.test/withalt.jpg", MediaStatus.APPROVED, alt="침실 사진") @@ -93,8 +87,7 @@ async def test_media_without_alt_is_excluded(db_engine, owner_id): async def test_fact_labels_come_from_category_schema(db_engine, owner_id): - """검증: 스냅샷의 fact 라벨. - 기대결과: 업종 스키마의 한글 라벨이 붙는다 — 화면이 key 를 그대로 노출하지 않게.""" + """검증: 스냅샷의 fact 라벨.""" pid = await _seed(db_engine, owner_id) await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED) @@ -105,8 +98,7 @@ async def test_fact_labels_come_from_category_schema(db_engine, owner_id): async def test_unit_scoped_facts_carry_unit_id(db_engine, owner_id): - """검증: 객실 단위 fact. - 기대결과: unit_id 가 실려 빌더가 객실별로 묶을 수 있다.""" + """검증: 객실 단위 fact.""" pid = await _seed(db_engine, owner_id) uid = uuid.uuid4() async with db_engine.begin() as c: @@ -121,8 +113,7 @@ async def test_unit_scoped_facts_carry_unit_id(db_engine, owner_id): async def test_empty_place_gives_empty_snapshot(db_engine, owner_id): - """검증: 아무것도 없는 사업장. - 기대결과: 빈 스냅샷 — 게이트가 고유 콘텐츠 0건으로 거부할 재료가 된다.""" + """검증: 아무것도 없는 사업장.""" pid = await _seed(db_engine, owner_id) snap = await build_snapshot(_Place(pid)) assert snap["facts"] == [] and snap["media"] == [] and snap["faqs"] == [] @@ -130,19 +121,9 @@ async def test_empty_place_gives_empty_snapshot(db_engine, owner_id): # ── 지역 정보 ───────────────────────────────────────────────────────────── -# ★ 지역 정보가 스냅샷에 담기는 이유: site_payload 는 DB 를 다시 읽지 않는다. -# 거기서 지역 캐시를 읽으면 발행 시점과 렌더 시점 사이에 값이 바뀌어 '스냅샷과 다른 페이지'가 나온다. async def _local(db_engine, region_code, kind, status, title="지역이야기", **cols): - """지역 캐시(`area_contents`)에 **지역 단위** 항목 한 행. - - ★ `external_id` 를 넣지 않는다. 그 값이 있는 행(축제·관광지·맛집)은 업장마다 거리가 달라 - 개인화(`site_sections`)를 거쳐 들어오고, 스냅샷의 지역 캐시 경로는 그것들을 일부러 - 건너뛴다(`services/snapshot._local_contents`). 예전 이 픽스처는 uuid 를 external_id 로 - 넣고 있어서, **읽는 코드가 옳게 걸러내는데도 테스트가 빨개졌다**. - ★ 한 지역에 같은 kind 는 한 행이다(`uq_local_contents_kind`). 여러 행이 필요한 테스트는 - kind 를 달리 준다 — 지역 이야기 다섯 종이 그 자리다. - """ + """지역 캐시(`area_contents`)에 **지역 단위** 항목 한 행.""" from common.enums import LocalContentType, LocalSource async with db_engine.begin() as c: @@ -159,7 +140,7 @@ async def _local(db_engine, region_code, kind, status, title="지역이야기", class _RegionPlace(_Place): - """region_code 를 가진 사업장. 지역 캐시의 키는 place_id 가 아니라 region_code 다.""" + """region_code 를 가진 사업장.""" def __init__(self, pid, region_code): super().__init__(pid) @@ -167,9 +148,7 @@ class _RegionPlace(_Place): async def test_only_published_local_content_enters_snapshot(db_engine, owner_id): - """검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다. - 기대결과: ★ PUBLISHED 만 담긴다 — 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면 - '미검증 값 노출 금지'가 깨진다(fact 를 VERIFIED 로 거르는 것과 같은 규칙).""" + """검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다.""" from common.enums import LocalContentStatus pid = await _seed(db_engine, owner_id) @@ -183,8 +162,7 @@ async def test_only_published_local_content_enters_snapshot(db_engine, owner_id) async def test_local_content_outside_display_window_is_excluded(db_engine, owner_id): - """검증: 발행됐지만 노출 기간을 벗어난 지역 정보. - 기대결과: 빠진다 — 끝난 축제를 '이번 주말 행사'로 걸어두는 것도 틀린 정보다.""" + """검증: 발행됐지만 노출 기간을 벗어난 지역 정보.""" from datetime import datetime, timedelta, timezone from common.enums import LocalContentStatus @@ -202,8 +180,7 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, owner async def test_local_content_is_scoped_to_the_places_region(db_engine, owner_id): - """검증: 지역 캐시는 region_code 로 묶인다. - 기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다.""" + """검증: 지역 캐시는 region_code 로 묶인다.""" from common.enums import LocalContentStatus pid = await _seed(db_engine, owner_id) @@ -215,9 +192,7 @@ async def test_local_content_is_scoped_to_the_places_region(db_engine, owner_id) async def test_region_code_is_derived_from_the_address_when_missing(db_engine, owner_id): - """검증: region_code 가 비어 있지만 도로명주소는 있는 사업장. - 기대결과: 주소에서 지역 키를 유도해 그 지역 콘텐츠를 담는다 — places.region_code 를 채우는 - 코드가 생기기 전에 만들어진 사업장(실측 28곳 중 25곳)이 영영 지역 정보 없이 발행되지 않게 한다.""" + """검증: region_code 가 비어 있지만 도로명주소는 있는 사업장.""" from common.enums import LocalContentStatus from services.external.naver import region_key @@ -231,8 +206,7 @@ async def test_region_code_is_derived_from_the_address_when_missing(db_engine, o async def test_place_without_any_region_key_gets_no_local_content(db_engine, owner_id): - """검증: 지역 코드도 읽을 만한 주소도 없는 사업장. - 기대결과: 빈 목록 — 조회할 캐시 키가 없다. 지어내지 않는다.""" + """검증: 지역 코드도 읽을 만한 주소도 없는 사업장.""" from common.enums import LocalContentStatus pid = await _seed(db_engine, owner_id) @@ -241,6 +215,5 @@ async def test_place_without_any_region_key_gets_no_local_content(db_engine, own place = _RegionPlace(pid, "") place.road_address = None snap = await build_snapshot(place) - # ★ itineraries 는 항상 있는 키다(2026-09-11) — 이 업장은 place_id 는 있으니 - # place_itineraries 를 조회하고, 아직 생성된 게 없어 빈 목록으로 온다. + # itineraries 는 항상 있는 키다 — 이 업장은 place_id 는 있으니 place_itineraries 를 조회하고, 아직 생성된 게 없어 빈 목록으로 온다. assert snap["local"] == {"region_code": None, "contents": [], "itineraries": []} diff --git a/solution/backend/tests/test_social.py b/solution/backend/tests/test_social.py index b94a3a5..34d257d 100644 --- a/solution/backend/tests/test_social.py +++ b/solution/backend/tests/test_social.py @@ -92,15 +92,6 @@ async def test_draft_dedup_owner_scope(client, auth_headers, db_engine): async def test_social_job_types_dispatch_to_the_right_handler(): - """실측(2026-09-22): 큐에 넣는 쪽(social_crud.decide, social_service.create_draft/ - publish_reused_text)은 job_type 숫자를 하드코딩(8/9)하고, 디스패처(worker/handlers.py)는 - JobType.SOCIAL_DRAFT(9)/SOCIAL_POST(10) enum 값으로 등록돼 있었다. 번호가 어긋나 있어서 - "쓰레드에 게시" 잡이 run_draft 로, "초안 생성" 잡이 run_rollback 으로 잘못 배달됐다 — - 잡은 에러 없이 DONE 으로 끝나지만 아무 일도 안 일어나는 조용한 실패였다. 큐 삽입 값만 - 보던 기존 테스트들(job_type=N 카운트)은 그 N 이 실제 핸들러와 맞는지는 확인하지 - 않아서 이 어긋남을 못 잡았다. 여기서는 워커가 실제로 쓰는 배달 경로 - (worker.handlers.HANDLERS)가 큐 삽입 쪽이 쓰는 것과 같은 JobType enum 값을 가리키는지 - 직접 대조한다 — 숫자가 다시 어긋나면(둘 중 하나가 하드코딩으로 되돌아가면) 여기서 잡힌다.""" from common.enums import JobType from services.social_service import run_draft, run_post from worker.handlers import HANDLERS @@ -112,11 +103,7 @@ async def test_social_job_types_dispatch_to_the_right_handler(): async def test_draft_and_post_jobs_enqueue_with_dispatchable_job_types( client, auth_headers, db_engine, monkeypatch ): - """create_draft 가 넣는 job_type 이 실제로 run_draft 로, decide 가 넣는 job_type 이 - 실제로 run_post 로 배달되는지 엔드투엔드로 확인한다(위 테스트의 정적 대조를 실제 - 큐 삽입 값으로 한 번 더 검증). 같은 사업장에 site_version 을 새로 하나 더 발급해 - (place_id, site_version_id) 유니크 인덱스와 안 부딪히게 한다 — seed() 를 두 번 부르면 - 도메인('social-stay') 유니크 인덱스와 부딪힌다.""" + """create_draft 가 넣는 job_type 이 실제로 run_draft 로, decide 가 넣는 job_type 이 실제로 run_post 로 배달되는지 엔드투엔드로 확인한다(위 테스트의 정적 대조를 실제 큐 삽입 값으로 한 번 더 검증).""" from worker.handlers import HANDLERS monkeypatch.setattr(service, "posting_enabled", lambda: True) @@ -234,9 +221,7 @@ async def test_no_facts_no_paid_call(monkeypatch): async def test_long_draft_regenerates(monkeypatch): - """generate_social_post 는 LLM_PROVIDER 추상화(services/llm/provider.py)를 탄다 - (2026-09-21, Gemini 하드코딩 제거) — 여기서는 provider.active() 가 돌려주는 공급자 - 자체를 가짜로 바꿔 길이 초과 → 재요청 → 통과 흐름만 검증한다.""" + """generate_social_post 는 LLM_PROVIDER 추상화(services/llm/provider.py)를 탄다 — 여기서는 provider.active() 가 돌려주는 공급자 자체를 가짜로 바꿔 길이 초과 → 재요청 → 통과 흐름만 검증한다.""" from services.llm import provider as llm_provider from services.llm.types import LlmResult, Usage @@ -491,16 +476,7 @@ async def test_test_post_publishes_immediately_bypassing_approval( async def test_oauth_roundtrip_saves_encrypted_account(db_engine, monkeypatch): - """검증: 인가 코드를 받아 계정을 연결하고, 해제까지 한 바퀴 돈다. - - ★ 왜 가짜 서버로 미리 도는가 — 실제 연결은 Meta 앱 등록(리디렉션 URI·권한·테스터 추가)이 - 끝나야 시험할 수 있다. 그때 실패하면 우리 코드가 틀린 건지 앱 설정이 틀린 건지 구별이 - 안 된다. 우리 쪽 왕복(코드 교환 → 장기토큰 → 검증 → 저장 → 해제)은 여기서 먼저 못 박는다. - ★ 이 검사가 지키는 것 셋: - 1. 저장된 것은 **암호문**이다 — 토큰 원문이 DB 에 남으면 안 된다. - 2. 장기 토큰 교환과 `debug_token` 검증을 건너뛰지 않는다(권한이 모자란 연결을 만들지 않는다). - 3. 해제하면 토큰이 **지워진다** — 행만 남기고 토큰을 두면 지운 줄 알고 계속 쓰게 된다. - """ + """검증: 인가 코드를 받아 계정을 연결하고, 해제까지 한 바퀴 돈다.""" import json as _json import uuid as _uuid @@ -521,7 +497,7 @@ async def test_oauth_roundtrip_saves_encrypted_account(db_engine, monkeypatch): if path.endswith("/oauth/access_token"): return httpx.Response(200, json={"access_token": "short-token", "user_id": "1"}) if path.endswith("/access_token"): - # 장기 토큰 교환. 60일짜리를 준다 — 하루 미만이면 코드가 거절해야 한다. + # 장기 토큰 교환. return httpx.Response(200, json={"access_token": long_lived, "expires_in": 5184000}) if path.endswith("/debug_token"): return httpx.Response(200, json={"data": { @@ -553,7 +529,7 @@ async def test_oauth_roundtrip_saves_encrypted_account(db_engine, monkeypatch): assert row is not None, "연결이 저장되지 않았다" assert row.handle == "mumum" assert row.status == "linked" - # ★ 원문이 DB 에 있으면 안 된다. + # 원문이 DB 에 있으면 안 된다. assert long_lived not in row.access_token assert accounts.decrypt(row.access_token) == long_lived scopes = row.scopes if isinstance(row.scopes, list) else _json.loads(row.scopes) diff --git a/solution/backend/tests/test_story_generation.py b/solution/backend/tests/test_story_generation.py index a9f2590..3f3b013 100644 --- a/solution/backend/tests/test_story_generation.py +++ b/solution/backend/tests/test_story_generation.py @@ -1,8 +1,4 @@ -"""지역 이야기 생성 — 응답 해석과 payload 경계. - -★ 실호출은 하지 않는다. `APP_ENV=test` 면 .env 를 안 읽어 키가 비고, 이 테스트가 검증하는 건 - "모델이 뭐라고 답했을 때 무엇을 남기는가" 다 — 그건 고정 응답으로 전부 재현된다. -""" +"""지역 이야기 생성 — 응답 해석과 payload 경계.""" import os os.environ.setdefault("APP_ENV", "test") @@ -28,7 +24,7 @@ def test_프롬프트는_shared_산출물에서_온다(): assert "[지역] 전북 군산시" in text assert "[지역]을 노래한 대중가요" in text # task 원문 assert "[공통 규칙]" in text - # ★ 빈칸이 남으면 모델이 그걸 지명으로 읽는다. + # 빈칸이 남으면 모델이 그걸 지명으로 읽는다. assert "(주소를" not in text and "(가게" not in text @@ -38,11 +34,7 @@ def test_지역_생성은_업소를_가리키지_않는다(): def test_생성_목록과_읽는_목록이_같다(): - """산출물(section_prompts.json) · 파이썬 상수 · 항목 이름칸 셋이 어긋나면 조용히 틀린다. - - ★ `daily` 가 실제로 이렇게 빠져 있었다 — 프롬프트가 빌더에만 손으로 적혀 있어서 - 서버는 그 종류를 몰랐고, 렌더러의 탭 자리는 영영 빈칸이었다(2026-09-10). - """ + """산출물(section_prompts.json) · 파이썬 상수 · 항목 이름칸 셋이 어긋나면 조용히 틀린다.""" assert tuple(prompts.kinds()) == STORY_KINDS assert set(grounding._TITLE_KEY) == set(STORY_KINDS) @@ -134,7 +126,7 @@ def test_JSON_이_아니면_전부_버리고_이유를_남긴다(): # ── payload 경계 ───────────────────────────────────────────────────────── def test_스냅샷의_이야기가_payload_로_나간다(): - """★ 항목 모양을 바꾸지 않는다 — 사장님이 붙여넣은 같은 종류의 JSON 과 한 배열로 이어진다.""" + """항목 모양을 바꾸지 않는다 — 사장님이 붙여넣은 같은 종류의 JSON 과 한 배열로 이어진다.""" snapshot = { "region_code": "52군산시", "contents": [ @@ -158,6 +150,5 @@ def test_스냅샷의_이야기가_payload_로_나간다(): def test_이야기가_없으면_story_키_자체가_없다(): - """빈 배열을 만들지 않는다 — 렌더러가 '있는데 비었다'와 '없다'를 구분한다.""" local, _ = _local({"region_code": "52군산시", "contents": []}, None, None) assert "story" not in local diff --git a/solution/backend/tests/test_tour_api_adapter.py b/solution/backend/tests/test_tour_api_adapter.py index a99fa63..ab571c4 100644 --- a/solution/backend/tests/test_tour_api_adapter.py +++ b/solution/backend/tests/test_tour_api_adapter.py @@ -1,13 +1,4 @@ -"""TourAPI 어댑터 — 값 매핑과 저작권 게이트. - -네트워크를 타지 않는다. TourAPI 실제 응답에서 **그대로 복사한** 표본으로 매핑만 검증한다 -(2026-08-31 실측: 가재와곰펜션 contentId=2708335, 가문 contentId=1052741). - -가장 중요한 것 두 개: - 1. 상업적 이용이 막힌 사진이 수집물에 섞이지 않는가 (docs/DECISIONS.md 1-2) - 2. "불가 (인근 주차장 이용)" 같은 값의 부호가 뒤집히지 않는가 - — 뒤집히면 주차 안 되는 집이 '주차 가능' 으로 발행된다 -""" +"""TourAPI 어댑터 — 값 매핑과 저작권 게이트.""" import pytest from common.category_schema import get_schema @@ -61,8 +52,7 @@ def _map(facts): ("https://www.yanolja.com/pension/1", False), ]) def test_can_handle(url, expected): - """검증: 어떤 주소를 받는가. - 기대결과: 구석구석 상세주소와 tour:// 만. 다른 어댑터 영역을 넘보지 않는다.""" + """검증: 어떤 주소를 받는가.""" assert ADAPTER.can_handle(url) is expected @@ -72,30 +62,25 @@ def test_can_handle(url, expected): ("가능 (객실당 1대 무료 주차)", True), ("부분 가능", True), ("불가", False), - ("불가 (인근 주차장 이용)", False), # ★ 뒤에 '이용' 이 있다고 true 가 되면 안 된다 + ("불가 (인근 주차장 이용)", False), # 뒤에 '이용' 이 있다고 true 가 되면 안 된다 ("없음", False), ("", None), ("문의", None), # 모르면 None — '아니오' 로 단정하지 않는다 ]) def test_head_bool_reads_leading_token_only(raw, expected): - """검증: '가능/불가 (설명)' 형태의 부호 판정. - - 기대결과: **맨 앞 토큰**으로만 판정한다. 문자열 전체를 훑으면 - "불가 (인근 주차장 이용)" 이 true 로 뒤집혀 주차 안 되는 집이 '주차 가능' 으로 발행된다.""" + """검증: '가능/불가 (설명)' 형태의 부호 판정.""" assert ADAPTER._head_bool(raw) is expected def test_unknown_value_becomes_no_fact_not_false(): - """검증: 판정할 수 없는 값. - 기대결과: fact 자체를 만들지 않는다. '모름' 과 '아니오' 는 다르다.""" + """검증: 판정할 수 없는 값.""" facts = _map(ADAPTER._lodging_facts({"chkcooking": "문의 바랍니다"})) assert ("cooking_allowed", None) not in facts # ── 숙박 매핑 ───────────────────────────────────────────────────────────── def test_lodging_place_facts(): - """검증: 숙박 detailIntro2 → 사업장 fact. - 기대결과: 체크인·환불규정·취사·주차·픽업·바비큐가 스키마 key 로 들어온다.""" + """검증: 숙박 detailIntro2 → 사업장 fact.""" f = _map(ADAPTER._lodging_facts(_LODGING_INTRO)) assert f[("check_in_time", None)] == "14:00" assert f[("check_out_time", None)] == "12:00" @@ -107,15 +92,7 @@ def test_lodging_place_facts(): def test_lodging_drops_fields_without_schema_slot(): - """검증: 스키마에 자리가 없는 TourAPI 필드(foodplace). - - 기대결과: 아무 key 로도 들어오지 않는다. ★ 억지로 다른 key 에 넣으면 - '식음료장 있음' 이 '조식 제공' 으로 둔갑한다. - - ★ roomcount·scalelodging 은 여기서 빠져 있었다. 2026-09-07 에 lodging 스키마에 - 자리가 생겨서(total_rooms·building_scale) 이제 **버리지 않는다** — 자리가 없어 - 버려지던 값이 TourAPI 응답의 절반이었다(실측 오블로모프 3103191). - "자리가 없으면 버린다" 는 규칙은 그대로고, 자리가 생겼을 뿐이다.""" + """검증: 스키마에 자리가 없는 TourAPI 필드(foodplace).""" facts = _map(ADAPTER._lodging_facts(_LODGING_INTRO)) values = set(facts.values()) assert "있음" not in values, "foodplace 는 자리가 없다 — 버려야 한다" @@ -127,10 +104,7 @@ def test_lodging_drops_fields_without_schema_slot(): def test_room_facts_are_unit_scoped_and_use_square_meters(): - """검증: detailInfo2 → 객실 단위 fact. - - 기대결과: unit_name 이 객실명이고, 면적은 roomsize2(㎡)를 쓴다. - ★ roomsize1 은 평이라 섞으면 3배 틀린 면적이 발행된다.""" + """검증: detailInfo2 → 객실 단위 fact.""" f = _map(ADAPTER._room_facts([_ROOM_ROW])) assert f[("room_type", "황토방")] == "황토방" assert f[("standard_capacity", "황토방")] == "2" @@ -143,8 +117,7 @@ def test_room_facts_are_unit_scoped_and_use_square_meters(): def test_room_facts_keys_exist_in_lodging_schema(): - """검증: 객실 fact 의 key 가 전부 숙박 스키마에 있는가. - 기대결과: 전부 존재. 없으면 FACT_INVALID_KEY 로 조용히 버려져 화면이 빈다.""" + """검증: 객실 fact 의 key 가 전부 숙박 스키마에 있는가.""" schema = get_schema(PlaceCategory.LODGING) for fact in ADAPTER._room_facts([_ROOM_ROW]) + ADAPTER._lodging_facts(_LODGING_INTRO): spec = schema.get(fact.key) @@ -154,8 +127,7 @@ def test_room_facts_keys_exist_in_lodging_schema(): # ── 음식점 매핑 ─────────────────────────────────────────────────────────── def test_restaurant_facts(): - """검증: 음식점 detailIntro2 → 사업장 fact. - 기대결과: 영업시간의 <br> 이 풀리고, 주차는 '불가' 로 정확히 읽힌다.""" + """검증: 음식점 detailIntro2 → 사업장 fact.""" f = _map(ADAPTER._restaurant_facts(_RESTAURANT_INTRO)) assert f[("business_hours", None)].startswith("- 08:30~20:00") assert "<br>" not in f[("business_hours", None)] @@ -166,22 +138,17 @@ def test_restaurant_facts(): def test_restaurant_never_maps_kids_facility_to_kids_allowed(): - """검증: kidsfacility=0 인 음식점. - - 기대결과: kids_allowed fact 를 만들지 않는다. ★ 실측 회귀 — - '어린이놀이방 없음' 을 '아이 동반 불가' 로 올려 발행하면 손님을 돌려보내게 된다.""" + """검증: kidsfacility=0 인 음식점.""" assert ("kids_allowed", None) not in _map(ADAPTER._restaurant_facts(_RESTAURANT_INTRO)) def test_restaurant_never_maps_credit_card_flag_to_payment_methods(): - """검증: chkcreditcardfood='없음'. - 기대결과: payment_methods 로 들어오지 않는다 — 그 필드는 결제수단 '목록' 이다.""" + """검증: chkcreditcardfood='없음'.""" assert ("payment_methods", None) not in _map(ADAPTER._restaurant_facts(_RESTAURANT_INTRO)) def test_treat_menu_splits_and_drops_trailing_marker(): - """검증: treatmenu '게살해물요리 / 난자완스 / 특색냉채 등'. - 기대결과: 메뉴 3건. 꼬리의 '등' 은 메뉴가 아니다.""" + """검증: treatmenu '게살해물요리 / 난자완스 / 특색냉채 등'.""" names = [f.unit_name for f in ADAPTER._restaurant_facts(_RESTAURANT_INTRO) if f.key == "menu_name"] assert names == ["게살해물요리", "난자완스", "특색냉채"] @@ -197,19 +164,13 @@ def test_restaurant_facts_keys_exist_in_schema(): # ── 저작권 게이트 (docs/DECISIONS.md 1-2) ───────────────────────────────── def test_license_constants_match_kogl(): - """검증: 공공누리 유형 상수. - - 기대결과: 상업적 이용 가능은 1·3유형뿐이고, 변경금지는 3·4유형이다. - (2·4유형 = 상업적 이용금지 / 3·4유형 = 변경금지)""" + """검증: 공공누리 유형 상수.""" assert COMMERCIAL_OK_LICENSES == {"type1", "type3"} assert NO_DERIVATIVE_LICENSES == {"type3", "type4"} def test_media_drops_non_commercial_license(): - """검증: 같은 객실에 Type3 사진과 Type2 사진이 섞여 온다. - - 기대결과: Type3 만 담고 Type2 는 버린다. ★ 2유형은 상업적 이용금지라 - 사장님 홈페이지(=상업적 이용)에 실으면 조건 위반이다.""" + """검증: 같은 객실에 Type3 사진과 Type2 사진이 섞여 온다.""" media = ADAPTER._media({}, [], [_ROOM_ROW]) urls = [m.origin_url for m in media] assert "http://tong.visitkorea.or.kr/a.jpg" in urls # Type3 @@ -218,20 +179,14 @@ def test_media_drops_non_commercial_license(): def test_media_drops_unknown_license(): - """검증: 저작권 유형이 비었거나 처음 보는 코드. - - 기대결과: 버린다. 상업 사이트에 실을 사진이라 '모름' 은 안전한 쪽이 아니다 — - 새 코드가 생겼을 때 조용히 통과시키는 것보다 빠뜨리는 쪽이 낫다.""" + """검증: 저작권 유형이 비었거나 처음 보는 코드.""" rows = [{"roomtitle": "A", "roomimg1": "http://x/1.jpg", "cpyrhtDivCd1": ""}, {"roomtitle": "B", "roomimg1": "http://x/2.jpg", "cpyrhtDivCd1": "Type9"}] assert ADAPTER._media({}, [], rows) == [] def test_media_carries_license_so_no_derivative_can_be_honored(): - """검증: 담긴 사진이 라이선스를 들고 나가는가. - - 기대결과: license 가 채워진다. ★ Type3 는 **변경금지**라 뒤에서 크롭·리사이즈하면 - 조건을 어긴다. 어느 사진이 손대면 안 되는지는 수집 시점에만 알 수 있다.""" + """검증: 담긴 사진이 라이선스를 들고 나가는가.""" media = ADAPTER._media({}, [], [_ROOM_ROW]) assert media[0].license == "type3" assert media[0].license in NO_DERIVATIVE_LICENSES @@ -239,8 +194,7 @@ def test_media_carries_license_so_no_derivative_can_be_honored(): def test_media_keeps_room_photo_attached_to_its_room(): - """검증: 객실 사진의 unit_name. - 기대결과: 그 객실 이름이 붙는다 — 안 붙으면 전부 대표사진 더미로 섞인다.""" + """검증: 객실 사진의 unit_name.""" assert {m.unit_name for m in ADAPTER._media({}, [], [_ROOM_ROW])} == {"황토방"} @@ -250,31 +204,24 @@ def test_media_keeps_room_photo_attached_to_its_room(): ("16명", "16"), ("", None), ("문의", None), ("0", None), ]) def test_number_strips_units_and_commas(raw, expected): - """검증: '13실'·'11,570㎡' 같은 단위 붙은 숫자. - 기대결과: 숫자만 남는다. 숫자로 시작하지 않으면 담지 않는다.""" + """검증: '13실'·'11,570㎡' 같은 단위 붙은 숫자.""" assert ADAPTER._number(raw) == expected def test_plain_unwraps_br_but_keeps_negation_words(): - """검증: <br> 제거가 글자를 지우지 않는가. - 기대결과: '불가' 가 남는다 — 태그 제거하다 부정어가 사라지면 부호가 뒤집힌다.""" + """검증: <br> 제거가 글자를 지우지 않는가.""" assert "불가" in ADAPTER._plain("주차<br>불가<br>합니다") assert "<br>" not in ADAPTER._plain("주차<br>불가") -# ── 콘텐츠 조회 (services/external/tour_lookup.py) ──────────────────────── -# 네트워크를 타지 않는다. 검색어 생성과 상호 대조 규칙만 본다. +# ── 콘텐츠 조회 (services/external/tour_lookup.py) ──────────────────────── 네트워크를 타지 않는다. from services.external.tour_lookup import ( # noqa: E402 MAX_QUERY_ATTEMPTS, _name_matches, _query_candidates, normalize, ) def test_query_candidates_shortens_from_the_tail(): - """검증: 등록명보다 긴 상호. - - 기대결과: 전체 → 뒤 토큰을 하나씩 뗀 순서. ★ 실측 회귀 — - '그래비티 조선 서울 판교 오토그래프 컬렉션' 은 searchKeyword2 에서 0건인데 - '그래비티 조선 서울 판교' 로 줄이면 잡힌다. 축약이 없으면 이 호텔은 영영 못 찾는다.""" + """검증: 등록명보다 긴 상호.""" got = _query_candidates("그래비티 조선 서울 판교 오토그래프 컬렉션") assert got[0] == "그래비티 조선 서울 판교 오토그래프 컬렉션" assert "그래비티 조선 서울 판교" in got @@ -287,8 +234,7 @@ def test_query_candidates_shortens_from_the_tail(): (" ", []), ]) def test_query_candidates_edge_cases(name, expected): - """검증: 토큰이 하나거나 비어 있는 상호. - 기대결과: 불필요한 호출을 만들지 않는다.""" + """검증: 토큰이 하나거나 비어 있는 상호.""" assert _query_candidates(name) == expected @@ -300,15 +246,11 @@ def test_query_candidates_edge_cases(name, expected): ("코트야드 바이 메리어트 서울 판교", "그래비티 조선 서울 판교", False), # 같은 동네 다른 호텔 ]) def test_name_matches(ours, theirs, match): - """검증: 상호 대조. - - 기대결과: 포함 관계면 같은 업소로 본다. ★ 다른 지점을 같다고 하면 - 남의 가게 객실 요금이 우리 사장님 사이트에 실린다 — 여기가 가장 비싼 실수다.""" + """검증: 상호 대조.""" assert _name_matches(ours, theirs) is match def test_normalize_matches_naver_lookup_rule(): - """검증: 상호 정규화 규칙. - 기대결과: 공백·기호를 걷어내고 소문자로 — naver_place_lookup 과 같은 기준이다.""" + """검증: 상호 정규화 규칙.""" assert normalize("롯데호텔 월드") == "롯데호텔월드" assert normalize("A-1 Cafe & Bar") == "a1cafebar" diff --git a/solution/backend/tests/test_verify_candidates.py b/solution/backend/tests/test_verify_candidates.py index 275236b..aa12257 100644 --- a/solution/backend/tests/test_verify_candidates.py +++ b/solution/backend/tests/test_verify_candidates.py @@ -1,8 +1,4 @@ -"""동일 업소 후보 조회 — ★ 서버가 자동 확정하지 않고 사람이 고르게 한다. - -남의 가게를 붙이는 게 이 서비스에서 제일 비싼 실수다(체크인 시간이 틀리면 실제 예약 클레임). -그래서 자동 판정이 MATCHED 여도 후보를 전부 내려보내 UI 가 확인시킬 수 있게 한다. -""" +"""동일 업소 후보 조회 — ★ 서버가 자동 확정하지 않고 사람이 고르게 한다.""" from common.enums import ErrorType from services.external import kakao as kakao_client from services.external import naver as naver_client @@ -27,7 +23,7 @@ async def _place(client, h, name="핑크비치펜션"): def _patch_naver(monkeypatch, result): - """네이버 판정을 고정값으로 바꾼다. 호출 인자를 그대로 돌려줘 호출측 규약도 볼 수 있게 한다.""" + """네이버 판정을 고정값으로 바꾼다.""" monkeypatch.setattr(kakao_client.KakaoLocalClient, "enabled", property(lambda self: False)) monkeypatch.setattr(naver_client.NaverLocalClient, "enabled", property(lambda self: True)) @@ -47,8 +43,7 @@ def _patch_naver(monkeypatch, result): async def test_single_match_is_auto_selectable(auth_headers, client, monkeypatch): - """검증: 정확히 1건 일치하는 상호명. - 기대결과: auto_selectable=true 로 내려오되 **후보는 그대로 내려온다** — UI 가 한 번 확인시킬 수 있다.""" + """검증: 정확히 1건 일치하는 상호명.""" only = _np("핑크비치펜션", "강원특별자치도 양양군 현북면 하조대3길 11", "http://pinkbeach.kr") _patch_naver(monkeypatch, _match(kakao_client.MatchOutcome.MATCHED, only, [only], "name_exact")) @@ -64,8 +59,7 @@ async def test_single_match_is_auto_selectable(auth_headers, client, monkeypatch async def test_ambiguous_returns_all_candidates_for_ui(auth_headers, client, monkeypatch): - """검증: 동명 업소가 여러 건이라 자동 판정이 안 된다. - 기대결과: ★ auto_selectable=false + 후보 전체 — UI 가 목록을 그려 사람이 고른다.""" + """검증: 동명 업소가 여러 건이라 자동 판정이 안 된다.""" rows = [ _np("하조대펜션", "강원특별자치도 양양군 현북면 하조대3길 11"), _np("하조대펜션", "강원특별자치도 양양군 현북면 하조대2길 48"), @@ -86,8 +80,7 @@ async def test_ambiguous_returns_all_candidates_for_ui(auth_headers, client, mon async def test_no_candidate_is_reported(auth_headers, client, monkeypatch): - """검증: 외부 장소 DB 에 후보가 하나도 없다. - 기대결과: PLACE_VERIFY_NO_CANDIDATE — 사장님이 직접 입력하는 경로로 안내해야 한다.""" + """검증: 외부 장소 DB 에 후보가 하나도 없다.""" _patch_naver(monkeypatch, _match(kakao_client.MatchOutcome.NO_CANDIDATE, None, [], "empty")) h = await auth_headers("u1") @@ -97,8 +90,7 @@ async def test_no_candidate_is_reported(auth_headers, client, monkeypatch): async def test_picked_candidate_can_be_confirmed(auth_headers, client, monkeypatch): - """검증: 후보 목록에서 하나를 골라 확정한다(UI 흐름 전체). - 기대결과: 확정되고, 후보가 준 홈페이지가 공식 채널로 자동 등록된다.""" + """검증: 후보 목록에서 하나를 골라 확정한다(UI 흐름 전체).""" rows = [ _np("하조대펜션", "강원특별자치도 양양군 현북면 하조대3길 11", "http://hajodae-a.kr"), _np("하조대펜션", "강원특별자치도 양양군 손양면 동명로 5", "http://hajodae-b.kr"), @@ -109,7 +101,7 @@ async def test_picked_candidate_can_be_confirmed(auth_headers, client, monkeypat pid = await _place(client, h, "하조대펜션") cands = (await client.get(f"/v1/place/{pid}/verify/candidates", headers=h)).json()["candidates"] - picked = cands[1] # 사람이 두 번째를 골랐다 + picked = cands[1] r = (await client.post(f"/v1/place/{pid}/verify", headers=h, json={ "source": 2, "road_address": picked["road_address"], "latitude": picked["latitude"], "longitude": picked["longitude"], @@ -125,8 +117,7 @@ async def test_picked_candidate_can_be_confirmed(auth_headers, client, monkeypat async def test_candidates_require_configured_source(auth_headers, client, monkeypatch): - """검증: 카카오도 네이버도 키가 없다. - 기대결과: LOCAL_NOT_CONFIGURED — 조용히 빈 목록을 주지 않는다.""" + """검증: 카카오도 네이버도 키가 없다.""" monkeypatch.setattr(kakao_client.KakaoLocalClient, "enabled", property(lambda self: False)) monkeypatch.setattr(naver_client.NaverLocalClient, "enabled", property(lambda self: False)) @@ -137,8 +128,7 @@ async def test_candidates_require_configured_source(auth_headers, client, monkey async def test_candidates_scoped_to_owner(auth_headers, client, monkeypatch): - """검증: 다른 사장님 계정으로 남의 사업장 후보를 조회한다. - 기대결과: PLACE_NOT_FOUND.""" + """검증: 다른 사장님 계정으로 남의 사업장 후보를 조회한다.""" _patch_naver(monkeypatch, _match(kakao_client.MatchOutcome.MATCHED, _np("a", "b"), [], "x")) h1 = await auth_headers("o1") pid = await _place(client, h1) @@ -148,14 +138,7 @@ async def test_candidates_scoped_to_owner(auth_headers, client, monkeypatch): async def test_region_stays_in_query_and_out_of_the_match_name(auth_headers, client, monkeypatch): - """검증: 화면이 '상호명 + 위치'를 합쳐 query 로 보낸다. - 기대결과: 검색어에는 지역이 그대로 실리고, 판정용 상호명에는 **상호만** 간다. - - ★ 왜 이걸 못 박아 두나: 판정(pick_match)은 후보 상호명과 정확일치를 본다. 지역이 붙은 - 문자열을 판정에 그대로 넘기면 정확일치가 구조적으로 성립할 수 없어, 후보가 단 1건인 - 경우까지 AMBIGUOUS 로 떨어진다 — 사장님은 매번 "비슷한 가게가 여럿입니다"를 읽는다. - 실측(2026-08-28) 10건 전부 그랬다. 검색은 넓게, 판정은 좁게. - """ + """검증: 화면이 '상호명 + 위치'를 합쳐 query 로 보낸다.""" only = _np("보사노바 커피로스터스 강릉점", "강원 강릉시 창해로14번길 28") seen = _patch_naver( monkeypatch, _match(kakao_client.MatchOutcome.MATCHED, only, [only], "name_exact") @@ -177,8 +160,7 @@ async def test_region_stays_in_query_and_out_of_the_match_name(auth_headers, cli async def test_match_name_falls_back_to_query_when_place_has_no_name(auth_headers, client, monkeypatch): - """검증: 사업장 상호가 비어 있다(직접 입력 등으로 이름 없이 만들어진 경우). - 기대결과: 검색어를 판정용 상호명으로도 쓴다 — 조회 자체가 실패하지는 않는다.""" + """검증: 사업장 상호가 비어 있다(직접 입력 등으로 이름 없이 만들어진 경우).""" only = _np("핑크비치펜션", "강원특별자치도 양양군") seen = _patch_naver( monkeypatch, _match(kakao_client.MatchOutcome.MATCHED, only, [only], "name_exact") diff --git a/solution/backend/tests/test_vision_api.py b/solution/backend/tests/test_vision_api.py index 5851682..0c5ce1d 100644 --- a/solution/backend/tests/test_vision_api.py +++ b/solution/backend/tests/test_vision_api.py @@ -1,10 +1,4 @@ -"""사진 분석(VISION) — 잡 배선과 ★ 신뢰도 게이트. - -★ 이 잡이 절대 하면 안 되는 것: - - 신뢰도 낮은 라벨을 자동 반영하는 것 (사람 확인 큐를 건너뛰는 것) - - 한 장 실패로 잡 전체를 실패시키는 것 - - 같은 사진을 재분석해 요금을 두 번 내는 것 -""" +"""사진 분석(VISION) — 잡 배선과 ★ 신뢰도 게이트.""" import uuid import pytest @@ -54,8 +48,7 @@ def _fake_analyze(results): async def test_high_confidence_is_auto_applied(auth_headers, client, db_engine, monkeypatch): - """검증: 신뢰도 높은 분석 결과. - 기대결과: 라벨·alt 가 반영되고 status 가 APPROVED 로 올라간다.""" + """검증: 신뢰도 높은 분석 결과.""" h = await auth_headers("u1") pid = await _place_with_media(client, h, db_engine, n=2) urls = [r[0] for r in await _media_rows(db_engine, pid)] @@ -79,8 +72,7 @@ async def test_high_confidence_is_auto_applied(auth_headers, client, db_engine, async def test_low_confidence_goes_to_review_queue(auth_headers, client, db_engine, monkeypatch): - """검증: 신뢰도가 임계값 미만인 결과. - 기대결과: ★ 라벨은 저장되지만 status 는 PENDING_REVIEW 로 남는다 — 자동 반영하지 않는다.""" + """검증: 신뢰도가 임계값 미만인 결과.""" h = await auth_headers("u1") pid = await _place_with_media(client, h, db_engine, n=1, kakao="v2") url = (await _media_rows(db_engine, pid))[0][0] @@ -103,8 +95,7 @@ async def test_low_confidence_goes_to_review_queue(auth_headers, client, db_engi async def test_partial_failure_does_not_fail_the_job(auth_headers, client, db_engine, monkeypatch): - """검증: 3장 중 1장만 분석에 실패한다. - 기대결과: 잡은 DONE, 실패한 장만 확인 큐에 남는다 — 한 장이 나머지를 죽이지 않는다.""" + """검증: 3장 중 1장만 분석에 실패한다.""" h = await auth_headers("u1") pid = await _place_with_media(client, h, db_engine, n=3, kakao="v3") urls = [r[0] for r in await _media_rows(db_engine, pid)] @@ -126,8 +117,7 @@ async def test_partial_failure_does_not_fail_the_job(auth_headers, client, db_en async def test_second_run_skips_already_analyzed(auth_headers, client, db_engine, monkeypatch): - """검증: 분석이 끝난 사업장에 다시 분석을 건다. - 기대결과: MEDIA_NOT_FOUND — 같은 사진 재분석은 요금만 나간다. force 로만 다시 돈다.""" + """검증: 분석이 끝난 사업장에 다시 분석을 건다.""" h = await auth_headers("u1") pid = await _place_with_media(client, h, db_engine, n=1, kakao="v4") url = (await _media_rows(db_engine, pid))[0][0] @@ -147,8 +137,7 @@ async def test_second_run_skips_already_analyzed(auth_headers, client, db_engine async def test_vision_requires_api_key(auth_headers, client, db_engine, monkeypatch): - """검증: GEMINI_API_KEY 없이 분석을 건다. - 기대결과: GENERATOR_NOT_CONFIGURED — 잡을 만들지 않는다(만들어봐야 DEAD 로 간다).""" + """검증: GEMINI_API_KEY 없이 분석을 건다.""" h = await auth_headers("u1") pid = await _place_with_media(client, h, db_engine, n=1, kakao="v5") monkeypatch.setattr(gemini, "is_configured", lambda: False) @@ -158,8 +147,7 @@ async def test_vision_requires_api_key(auth_headers, client, db_engine, monkeypa async def test_collect_chains_vision_job(auth_headers, client, monkeypatch): - """검증: 수집이 사진을 저장하면 사진 분석 잡이 이어서 걸리는가. - 기대결과: 수집 잡 result 에 vision_job_id 가 담기고 그 잡이 큐에 있다 — 파이프라인이 안 끊긴다.""" + """검증: 수집이 사진을 저장하면 사진 분석 잡이 이어서 걸리는가.""" from services.collector import MockAdapter monkeypatch.setattr(gemini, "is_configured", lambda: True) diff --git a/solution/backend/tests/test_weather_notes.py b/solution/backend/tests/test_weather_notes.py index be5c3b1..8a15f59 100644 --- a/solution/backend/tests/test_weather_notes.py +++ b/solution/backend/tests/test_weather_notes.py @@ -7,7 +7,7 @@ def test_all_weather_cases_have_five_unique_lines(): assert set(notes["noteSets"]) == {"맑음", "구름많음", "흐림", "안개", "이슬비", "비", "소나기", "눈", "뇌우"} assert set(notes["tempNoteSets"]) == {"혹서", "더움", "선선", "쌀쌀", "추움"} lines = [line for key in ("noteSets", "tempNoteSets") for group in notes[key].values() for line in group] - # 하늘 9 · 기온대 5, 케이스마다 다섯 줄. 같은 문장이 두 번 나오면 순환이 멈춘 것처럼 보인다. + # 하늘 9 · 기온대 5, 케이스마다 다섯 줄. assert len(lines) == len(set(lines)) == 70 diff --git a/solution/backend/web_main.py b/solution/backend/web_main.py index 70490c6..e40d77e 100644 --- a/solution/backend/web_main.py +++ b/solution/backend/web_main.py @@ -1,10 +1,4 @@ -# 실행 방법 -# pip install -r requirements.txt -# python web_main.py # 기본 local 환경 -# APP_ENV=dev python web_main.py # 환경 지정 -# -# 또는 uvicorn 직접 실행: -# uvicorn router.router:app --reload --host=0.0.0.0 --port=9800 +# 실행 방법 pip install -r requirements.txt python web_main.py # 기본 local 환경 APP_ENV=dev python web_main.py # 환경 지정 import os @@ -23,7 +17,7 @@ if __name__ == "__main__": LOG.i(f"Server Port : {web_server_config.port}") LOG.i(f"API Server start time : {router.router.API_SERVER_START_TIME}") - # RELOAD=1 (개발 컨테이너) → 소스 변경 시 자동 재기동. reload 와 workers(다중) 는 함께 못 쓰므로 분기. + # RELOAD=1 (개발 컨테이너) → 소스 변경 시 자동 재기동. reload = os.environ.get("RELOAD") == "1" run_kwargs = dict( diff --git a/solution/backend/worker/handlers.py b/solution/backend/worker/handlers.py index ea33c3f..b5aec88 100644 --- a/solution/backend/worker/handlers.py +++ b/solution/backend/worker/handlers.py @@ -1,24 +1,4 @@ -"""잡 핸들러 레지스트리 — JobType 별로 '무엇을 하는가'. - -핸들러 규약: `async def handler(job: dict) -> dict` - job : {"job_id", "job_type", "payload", "attempts", "max_attempts"} - 반환값 : jobs.result 에 JSONB 로 저장된다(관측·디버깅용) - 예외 : 워커가 잡아 백오프 재큐(소진 시 DEAD). 재시도해도 소용없는 실패는 - 예외 메시지에 이유를 남긴다 — last_error 로 남아 운영자가 본다 - -핸들러는 **재시도 안전(멱등)** 해야 한다. lease 만료·워커 재시작으로 같은 잡이 다시 돌 수 있다. -수집은 이미 확보한 fact 를 다시 덮어쓰지 않고(특히 CORRECTED), 사진은 origin_url 로 중복을 거른다. - -현재 등록된 핸들러: - JobType.COLLECT ✓ services/collect_service.run_collect — Phase 1 은 MockAdapter 만 등록돼 있다 - JobType.VISION ✓ services/vision_service.run_vision — Gemini Vision 사진 분류 + alt - JobType.COPY ✓ services/copy_service.run_copy — 소개문·FAQ (확보된 fact 만 근거) - JobType.BUILD ✓ services/build_service.run_build — 정적 빌드 + 발행 검수 게이트 - JobType.LOCAL_SYNC ✓ services/story_service.run_local_sync — 지역 이야기 생성(지역당 1회) - JobType.SONG ✓ services/song_service.run_song — 이 숙소의 노래 한 곡(가사 Gemini → 작곡 Suno) - JobType.ROLLBACK ✓ services/rollback_service.run_rollback — 예전 버전으로 공개 주소를 되돌림 - JobType.AI_CHECK → reports 모듈이 붙을 때 -""" +"""잡 핸들러 레지스트리 — JobType 별로 '무엇을 하는가'.""" from common.enums import JobType from common.logger import LOG @@ -33,12 +13,7 @@ HANDLERS: dict[int, object] = {} def register(job_type: JobType): - """핸들러 등록 데코레이터. 모듈이 붙을 때 자기 핸들러를 여기에 건다. - - @register(JobType.COLLECT) - async def handle_collect(job: dict) -> dict: - ... - """ + """핸들러 등록 데코레이터.""" def _deco(fn): if job_type.value in HANDLERS: raise RuntimeError(f"JobType.{job_type.name} 핸들러가 이미 등록돼 있다") @@ -49,7 +24,7 @@ def register(job_type: JobType): def build_handler(): - """등록된 핸들러로 디스패처를 만든다. 워커에 주입한다.""" + """등록된 핸들러로 디스패처를 만든다.""" async def dispatch(job: dict) -> dict: fn = HANDLERS.get(job["job_type"]) @@ -74,9 +49,7 @@ def log_registry(): LOG.w("[worker] 등록된 핸들러가 없습니다 — 적재되는 잡은 모두 UnknownJobType 으로 DEAD 됩니다") -# ---- 등록 ------------------------------------------------------------------ -# import 부작용으로 등록한다(모듈을 읽는 것만으로 워커가 처리 능력을 갖는다). -# 순환 import 를 피하려고 파일 맨 아래에서 붙인다. +# 등록 def _register_builtin(): from services.collect_service import run_collect from services.build_service import run_build diff --git a/solution/backend/worker/notify.py b/solution/backend/worker/notify.py index 85646ad..4847a28 100644 --- a/solution/backend/worker/notify.py +++ b/solution/backend/worker/notify.py @@ -1,8 +1,4 @@ -"""LISTEN/NOTIFY 리스너 — 잡 적재 시 워커를 즉시 깨운다(폴링 낭비 제거). (LPS `worker/notify.py` 이식) - -전용 asyncpg 연결로 LISTEN 한다(SQLAlchemy 풀과 분리). 알림이 오면 이벤트를 세팅하고, -워커는 claim 이 비었을 때 wait() 로 알림 또는 짧은 타임아웃(안전망/reaper)까지 대기한다. -""" +"""LISTEN/NOTIFY 리스너 — 잡 적재 시 워커를 즉시 깨운다(폴링 낭비 제거).""" import asyncio @@ -34,7 +30,7 @@ class JobListener: self._event.set() async def wait(self, timeout: float) -> bool: - """알림이 오거나 timeout 까지 대기. 알림으로 깨면 True, 타임아웃이면 False.""" + """알림이 오거나 timeout 까지 대기.""" try: await asyncio.wait_for(self._event.wait(), timeout) return True diff --git a/solution/backend/worker/runner.py b/solution/backend/worker/runner.py index 4802cd1..00ffac5 100644 --- a/solution/backend/worker/runner.py +++ b/solution/backend/worker/runner.py @@ -1,14 +1,4 @@ -"""워커 루프 + reaper. (LPS `worker/runner.py` 이식) - -워커는 큐에서 잡을 원자적으로 claim → 핸들러 실행 → complete/fail 한다. -- 처리 중 heartbeat 로 lease 를 갱신(긴 잡이 reaper 에 회수되지 않게). - 수집·비전분석은 몇 분이 정상이라 heartbeat 없이는 lease 가 먼저 만료된다. -- 핸들러엔 데드라인(job_deadline_sec)을 건다 — heartbeat 가 lease 를 계속 갱신하므로 - 핸들러가 행하면 reaper 로는 영원히 회수 불가. - 초과 시 취소 후 fail 처리 → 백오프 재큐(소진 시 DEAD), 워커 슬롯은 즉시 다음 잡으로. -- claim 이 비면 LISTEN 알림 또는 짧은 타임아웃까지 대기(폴링 최소화). -- 핸들러는 주입식(async def(job)->dict) — 프로덕션은 수집 파이프라인, 테스트는 fake. -""" +"""워커 루프 + reaper.""" import asyncio @@ -27,11 +17,7 @@ def _job_type_name(job_type: int) -> str: async def _alert_job_dead(job: dict, error: str) -> None: - """잡이 dead-letter 로 떨어졌다 — 재시도를 소진했다는 뜻이다(수동 개입 대상). - - ★ dedupe_key 는 "같은 대상이 반복해서 죽는가" 를 잡는다. job_id 는 잡마다 새로 생기므로 - 쓰지 않는다 — payload 의 place_id(대부분의 잡이 갖는 자연키)가 있으면 그걸 쓰고, - 없으면 job_type 만으로 묶는다(어느 쪽이든 완벽하진 않지만, 없는 것보다는 낫다).""" + """dedupe_key 는 "같은 대상이 반복해서 죽는가" 를 잡는다.""" job_type = job.get("job_type") type_name = _job_type_name(job_type) payload = job.get("payload") or {} @@ -62,11 +48,10 @@ class Worker: self.lease_sec = lease_sec self.backoff_fn = backoff_fn # 잡 1건 처리 시간 상한(0 이면 무제한 — 테스트용). - # 수집 파이프라인은 Perplexity(10~30s) + 크롤링 + Vision(사진 20~50장) 이라 15분을 기본으로 둔다. self.job_deadline_sec = job_deadline_sec async def process_one(self) -> bool: - """대기 잡 1건을 claim·처리. 처리했으면 True, 없으면 False.""" + """대기 잡 1건을 claim·처리.""" job = await self.queue.claim(self.worker_id, self.lease_sec) if not job: return False @@ -74,20 +59,20 @@ class Worker: return True async def drain(self) -> int: - """큐가 빌 때까지 처리(테스트/일회성 배치용). 처리한 잡 수 반환.""" + """큐가 빌 때까지 처리(테스트/일회성 배치용).""" n = 0 while await self.process_one(): n += 1 return n async def run(self, listener=None, stop: asyncio.Event | None = None, idle_timeout: float = 5.0): - """상시 루프. stop 이 설정될 때까지 처리하고, 유휴 시 알림/타임아웃까지 대기.""" + """상시 루프.""" stop = stop or asyncio.Event() while not stop.is_set(): try: worked = await self.process_one() except Exception as ex: - # claim 자체가 실패(DB 순단 등) — 루프를 죽이지 않는다. 잠깐 쉬고 다시 시도. + # claim 자체가 실패(DB 순단 등) — 루프를 죽이지 않는다. LOG.e_no_callstack(f"[{self.worker_id}] claim 실패(계속): {type(ex).__name__}: {ex}") worked = False if not worked: @@ -108,7 +93,6 @@ class Worker: LOG.d(f"[{self.worker_id}] done {jid}") except (asyncio.TimeoutError, TimeoutError): # 데드라인 초과 — wait_for 가 핸들러 태스크를 취소한 뒤 여기로 온다. - # 행이 워커 슬롯을 영구 점유하는 것보다 낫다. backoff = self.backoff_fn(job["attempts"]) reason = f"JobDeadlineExceeded: {self.job_deadline_sec:.0f}s" st = await self.queue.fail(jid, self.worker_id, reason, backoff) @@ -117,11 +101,7 @@ class Worker: if st == JobStatus.DEAD.value: await _alert_job_dead(job, reason) except PermanentJobError as ex: - # ★ 재시도하지 않는다 — 다시 해도 같은 결과다(common/job_errors 주석). - # 실측(2026-09-15): 사업장이 지워진 뒤 남은 소개문 잡이 "사업장을 찾을 수 없다" 로 - # 세 번 돌고 DEAD 로 갔다. 결과는 같고 큐 지연과 알림만 늘었다. - # ★ fail_permanent 는 재시도 없이 곧장 DEAD 다 — 위 일반 실패 경로처럼 상태를 다시 - # 조회해 분기할 필요 없이 바로 알린다(job_crud.fail_permanent 주석 참고). + # 재시도하지 않는다 — 다시 해도 같은 결과다(common/job_errors 주석). reason = f"{type(ex).__name__}: {ex}" await self.queue.fail_permanent(jid, self.worker_id, reason) LOG.w(f"[{self.worker_id}] fail {jid} → DEAD (재시도 안 함: {reason})") @@ -147,14 +127,12 @@ class Worker: try: await self.queue.renew_lease(jid, self.worker_id, self.lease_sec) except Exception as ex: - # 갱신 실패는 치명적이지 않다(다음 틱 재시도). 계속 실패하면 lease 만료 → reaper 회수. + # 갱신 실패는 치명적이지 않다(다음 틱 재시도). LOG.w(f"[{self.worker_id}] lease 갱신 실패(계속): {type(ex).__name__}") async def run_reaper(queue: JobQueue, stop: asyncio.Event, interval: float = 30.0): - """만료 lease(워커 사망) 잡을 주기적으로 회수. 재시도 남으면 재큐, 소진되면 DEAD. - - 도커에서 워커 컨테이너가 재시작되면 진행 중이던 잡이 여기서 되살아난다.""" + """만료 lease(워커 사망) 잡을 주기적으로 회수.""" while not stop.is_set(): try: reclaimed = await queue.reap() diff --git a/solution/backend/worker_main.py b/solution/backend/worker_main.py index 8e79f55..6b3c7c3 100644 --- a/solution/backend/worker_main.py +++ b/solution/backend/worker_main.py @@ -1,10 +1,4 @@ # o2o-web4ai 워커 프로세스 진입점 (API 와 분리 실행 — 코드베이스 공유, 독립 스케일). -# python worker_main.py -# WORKER_CONCURRENCY=3 python worker_main.py -# -# 수집·비전분석·빌드는 몇 분씩 걸려 동기 요청으로 처리할 수 없다. API 는 잡만 적재하고 즉시 응답하며, -# 실제 처리는 이 프로세스가 한다. 큐는 PostgreSQL(jobs) — 원자적 claim + lease 소유권이라 -# 워커를 몇 개 띄우든(docker compose --scale) 같은 잡이 두 번 돌지 않는다. import asyncio import os @@ -20,10 +14,9 @@ from worker.runner import Worker, run_reaper LOG.SetPrefix(f"{web_server_config.server_name}-worker") -# 잡 1건 처리 시간 상한. Perplexity(10~30s) + 크롤링 + Vision(사진 20~50장 배치)을 감안한 값. +# 잡 1건 처리 시간 상한. JOB_DEADLINE_SEC = float(os.environ.get("JOB_DEADLINE_SEC", "900")) -# lease 임대 시간. heartbeat 가 lease_sec/3 마다 갱신하므로 짧아도 되지만, -# 워커가 죽었을 때 이만큼 지나야 reaper 가 회수한다. +# lease 임대 시간. LEASE_SEC = int(os.environ.get("JOB_LEASE_SEC", "120")) @@ -36,8 +29,7 @@ async def main(concurrency: int = 1): listeners: list[JobListener] = [] tasks: list[asyncio.Task] = [] - # ── graceful shutdown: SIGINT(Ctrl+C)/SIGTERM(docker stop) → stop 이벤트 ── - # 하던 잡은 마무리하고 새 잡은 받지 않는다. 강제 종료돼도 lease 만료 후 reaper 가 재큐한다. + # ── graceful shutdown: SIGINT(Ctrl+C)/SIGTERM(docker stop) → stop 이벤트 ── 하던 잡은 마무리하고 새 잡은 받지 않는다. def _request_stop(sig_name: str): if not stop.is_set(): LOG.i(f"{sig_name} 수신 — graceful 종료: 새 잡 중단, 하던 잡 마무리 (한 번 더 = 강제 종료)") diff --git a/solution/frontend/eslint.config.js b/solution/frontend/eslint.config.js index b669fb4..aeaed98 100644 --- a/solution/frontend/eslint.config.js +++ b/solution/frontend/eslint.config.js @@ -1,6 +1,4 @@ // 최소 게이트 — 전체 스타일 린트가 아니라, tsc 가 못 잡는 런타임 크래시 버그만 막는다. -// (negodata 보일러플레이트에서 이식. 도입 계기는 로컬 const 가 동명 import 를 가려 -// zustand 셀렉터가 TDZ 참조 → 프로덕션 크래시. 그 케이스는 tsc --noEmit 도 통과했다.) import tseslint from 'typescript-eslint'; import reactHooks from 'eslint-plugin-react-hooks'; @@ -18,7 +16,7 @@ export default tseslint.config({ // 훅 호출 순서 위반은 런타임 크래시라 error, deps 누락은 기존 코드가 많아 warn. 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', - // 선언보다 위에서 변수를 쓰는 것(TDZ) 차단. 함수/타입 호이스팅은 안전하므로 허용. + // 선언보다 위에서 변수를 쓰는 것(TDZ) 차단. 'no-use-before-define': 'off', '@typescript-eslint/no-use-before-define': [ 'error', diff --git a/solution/frontend/orval.config.ts b/solution/frontend/orval.config.ts index c615bf9..e2d434d 100644 --- a/solution/frontend/orval.config.ts +++ b/solution/frontend/orval.config.ts @@ -1,17 +1,6 @@ import {defineConfig} from 'orval'; -/** - * Orval config - * - * Backend: FastAPI (o2o-web4ai API) — OpenAPI 스키마를 `${BACKEND_URL}/openapi.json` 에 자동 노출한다. - * 포트 9800 (negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호). - * - * 네이티브 `fetch` 기반 React Query 클라이언트를 만든다(axios 불필요). - * 실행: `npm run orval` - * - * 서버를 안 띄우고 생성하려면 백엔드가 뽑아둔 파일을 지정한다: - * ORVAL_INPUT=../backend/openapi.json npm run orval - */ +/** Orval config */ export default defineConfig({ web4ai: { input: { @@ -22,14 +11,11 @@ export default defineConfig({ target: './src/api/generated', schemas: './src/api/generated/model', client: 'react-query', - // httpClient: 'fetch' 를 쓰면 오퍼레이션마다 Response200/404/Success/Error/Response - // 상태코드별 유니온 타입이 강제로 쏟아진다. 우리 mutator 는 본문(data)만 반환하므로 - // 기본 mutator 방식(config-object)으로 두어 함수가 본문 타입을 바로 반환하게 한다. + // httpClient: 'fetch' 를 쓰면 오퍼레이션마다 Response200/404/Success/Error/Response 상태코드별 유니온 타입이 강제로 쏟아진다. clean: true, prettier: true, override: { - // FastAPI 기본 operationId("list_places_v1_place_list_get")에서 "_v1_" 앞부분만 취해 - // camelCase 로 정리 → listPlaces/useListPlaces 등으로 복원(백엔드 무수정). + // FastAPI 기본 operationId("list_places_v1_place_list_get")에서 "_v1_" 앞부분만 취해 camelCase 로 정리 → listPlaces/useListPlaces 등으로 복원(백엔드 무수정). operationName: (operation) => { const id = operation.operationId ?? ''; const base = id.split('_v1_')[0] || id; diff --git a/solution/frontend/react-router.config.ts b/solution/frontend/react-router.config.ts index 0273023..4777b70 100644 --- a/solution/frontend/react-router.config.ts +++ b/solution/frontend/react-router.config.ts @@ -1,23 +1,8 @@ import type {Config} from '@react-router/dev/config'; -/** - * 프리렌더 설정. - * - * ★ 왜 하나 — 랜딩은 `<div id="root"></div>` 만 내보내는 CSR 이었다. 실측(2026-09-07): - * `curl /` 는 3,021바이트에 `<a>` 0개·본문 0자. 같은 호스트의 발행본은 48,072바이트다. - * 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규 도메인은 뒤로 밀린다 — - * 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다. - * - * ★ `ssr: false` 와 함께 쓴다. 런타임 Node 서버를 두지 않는다는 뜻이다 — - * 아래 목록만 HTML 로 굽고, 나머지(`/builder` `/login` `/sites` `/account`)는 - * 지금까지처럼 SPA 폴백으로 나간다. nginx 설정도 배포 구조도 그대로다. - * - * ★ 로그인 뒤에만 의미가 있는 화면은 굽지 않는다. 구울 내용이 없고(데이터가 사용자별), - * robots.txt 로 막을 대상이기도 하다. - */ +/** 프리렌더 설정. */ export default { // 이 레포는 소스가 `src/` 밑이다(기본값 `app/` 이 아니다). - // root.tsx · routes.ts 를 여기서 찾는다. appDirectory: 'src', ssr: false, prerender: ['/', '/pricing', '/showcase'], diff --git a/solution/frontend/src/api/index.ts b/solution/frontend/src/api/index.ts index 312fc24..110cd80 100644 --- a/solution/frontend/src/api/index.ts +++ b/solution/frontend/src/api/index.ts @@ -1,17 +1,4 @@ -/** - * API 단일 입구. - * - * 알맹이는 전부 `generated/` — 백엔드 OpenAPI 에서 orval 이 뽑은 React Query 훅과 모델이다. - * **손으로 고치지 않는다.** 백엔드가 바뀌면 재생성한다: - * - * ```bash - * npm run orval -w admin # 백엔드가 떠 있을 때 - * ORVAL_INPUT=../../backend/openapi.json npm run orval -w admin # 서버 없이 - * ``` - * - * 이 파일이 하는 일은 재export 뿐이다 — 화면이 `@/api/generated/place/place` 같은 - * 태그 경로를 외우지 않게. 태그가 늘면 여기 한 줄만 는다. - */ +/** API 단일 입구. */ export * from './generated/auth/auth'; export * from './generated/place/place'; export * from './generated/fact/fact'; @@ -22,6 +9,6 @@ export * from './generated/faq/faq'; export * from './generated/ops/ops'; export * from './generated/model'; -// 잡 폴링(수집·비전·생성·빌드 공용). 생성물이 아니라 그 위에 얹은 얇은 유틸이다. +// 잡 폴링(수집·비전·생성·빌드 공용). export * from './pollJob'; export {ApiError, getAccessToken, setTokens, clearTokens} from './mutator/custom-fetch'; diff --git a/solution/frontend/src/api/mutator/custom-fetch.ts b/solution/frontend/src/api/mutator/custom-fetch.ts index 28cef81..169dd06 100644 --- a/solution/frontend/src/api/mutator/custom-fetch.ts +++ b/solution/frontend/src/api/mutator/custom-fetch.ts @@ -1,10 +1,4 @@ -// Orval mutator (기본 client 방식). axios 인터셉터를 대체하는 단일 길목 — -// 토큰 주입 / 에러 throw / 401·434 처리 / baseURL 을 여기서 처리한다. -// -// orval.config.ts 의 override.mutator 가 이 함수를 모든 생성 API 호출에 꽂는다. -// httpClient: 'fetch' 를 쓰지 않으므로 orval 은 config-object 시그니처로 호출한다: -// customFetch<T>({ url, method, params, data, signal, headers }, requestOptions) -// → 함수가 응답 본문(T)을 바로 반환하고, 상태코드별 ResponseXXX 유니온 타입은 생성되지 않는다. +// Orval mutator (기본 client 방식). // baseURL — .env 의 VITE_API_BASE_URL 로 주입, 없으면 9800 폴백(o2o-web4ai 백엔드 기본 포트). const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9800'; @@ -18,7 +12,7 @@ function readStoredToken(key: string): string | null { try { return localStorage.getItem(key); } catch { - // 사파리 프라이빗 모드 등에서 localStorage 접근이 throw 한다. 메모리 토큰으로만 동작. + // 사파리 프라이빗 모드 등에서 localStorage 접근이 throw 한다. return null; } } @@ -118,8 +112,7 @@ export const customFetch = async <T>( const requestUrl = (url.startsWith('http') ? url : `${BASE_URL}${url}`) + (queryString ? `?${queryString}` : ''); - // 파일 업로드(FormData)는 ① 브라우저가 boundary 포함 Content-Type 을 직접 설정해야 하고 - // ② 본문을 JSON.stringify 하면 "{}" 가 되어 파일이 통째로 누락된다. 분기 처리. + // 파일 업로드(FormData)는 ① 브라우저가 boundary 포함 Content-Type 을 직접 설정해야 하고 ② 본문을 JSON.stringify 하면 "{}" 가 되어 파일이 통째로 누락된다. const isFormData = typeof FormData !== 'undefined' && data instanceof FormData; // [요청 인터셉터] 토큰을 받아 헤더를 구성하고 한 번 호출(만료 재시도 시 새 토큰으로 재구성). @@ -145,7 +138,7 @@ export const customFetch = async <T>( let response = await doFetch(accessToken); - // 434(액세스 토큰 만료) → refresh 로 재발급 후 1회 재시도. auth 엔드포인트 자신은 제외(무한루프 방지). + // 434(액세스 토큰 만료) → refresh 로 재발급 후 1회 재시도. const isAuthEndpoint = url.includes('/v1/auth/refresh_token') || url.includes('/v1/auth/login'); if ((response.status === 434 || response.status === 401) && !isAuthEndpoint) { diff --git a/solution/frontend/src/api/pollJob.ts b/solution/frontend/src/api/pollJob.ts index 3ebcfd3..393fde9 100644 --- a/solution/frontend/src/api/pollJob.ts +++ b/solution/frontend/src/api/pollJob.ts @@ -1,49 +1,24 @@ -/** - * 잡 폴링 단일 구현. - * - * 수집·비전·생성·빌드는 전부 같은 모양이다 — `POST` 로 job_id 를 받고 - * `GET /v1/job/{job_id}` 를 DONE/DEAD 까지 물어본다(백엔드 README 의 "잡을 적재하고 즉시 응답"). - * 그래서 화면마다 while 루프를 다시 쓰지 않고 여기 한 곳만 둔다. - * - * ★ 폴링에는 React Query 훅을 쓰지 않는다. 훅은 렌더 트리에 묶여 있어서 - * "버튼을 누른 뒤 끝날 때까지"라는 명령형 흐름(진행 문구·단계 전환)을 표현하지 못한다. - * 대신 orval 이 만든 같은 `getJob` 함수를 쓴다 — 토큰·baseURL·401 재시도는 mutator 가 처리한다. - */ +/** 잡 폴링 단일 구현. */ import {JobStatus} from '@o2o/shared'; import {getJob} from './generated/job/job'; import type {JobData} from './generated/model'; -/** 잡 폴링 간격. 수집·빌드는 분 단위로 도는 작업이라 더 촘촘히 물어봐도 얻는 게 없다. */ +/** 잡 폴링 간격. */ export const JOB_POLL_MS = 2000; -/** - * ★ 폴링 총 제한시간. 워커가 죽어 잡이 RUNNING 에 박제되면 여기서 끊는다 — 무한 폴링 금지. - * - * ★ 실제 잡 시간보다 넉넉해야 한다. 3분이던 동안 정상적으로 끝난 발행이 '실패' 로 보였다 — - * 실측(2026-09-15, 완료된 잡 전체): BUILD 최대 181s · VISION 최대 213s · SONG 최대 157s. - * `publish=true` 는 노래·일정 생성을 빌드 안에서 **먼저** 돌리므로 더 길어진다. - * 워커 쪽 상한은 JOB_DEADLINE_SEC(기본 900s)이니 화면이 그보다 먼저 접으면 - * 아직 도는 잡을 죽은 것으로 보고한다. - */ +/** 폴링 총 제한시간. */ export const JOB_POLL_TIMEOUT_MS = 10 * 60 * 1000; -/** ★ 연속 실패 허용치. 잠깐 끊긴 건 넘기고, 계속 실패하면 제한시간을 다 기다리지 않고 접는다. */ +/** 연속 실패 허용치. */ export const JOB_MAX_POLL_ERRORS = 3; -/** - * 폴링이 끝난 이유. - * - * ★ `done` 이 "성공"은 아니다. 잡은 끝났다는 뜻일 뿐이고, 게이트 거부처럼 도메인 판정은 - * `job.result` 안에 있다(빌드는 build_status·gate). 호출측이 그걸 읽어야 한다. - */ +/** 폴링이 끝난 이유. */ export type JobOutcome = | {kind: 'done'; job: JobData} | {kind: 'dead'; job: JobData} - /** 제한시간 초과. 잡은 서버에서 계속 돈다 — 화면만 풀어 준다. */ + /** 제한시간 초과. */ | {kind: 'timeout'} - /** 언마운트·재시작으로 끊겼다. 조용히 끝낸다(토스트 금지). */ | {kind: 'aborted'} - /** 연속 실패로 상태를 못 읽었다. 잡의 성패는 알 수 없다. */ | {kind: 'unreachable'; error: unknown}; export interface PollJobOptions { @@ -51,11 +26,11 @@ export interface PollJobOptions { intervalMs?: number; timeoutMs?: number; maxErrors?: number; - /** 폴링 1회마다 호출. 진행 문구·단계 표시를 여기서 갱신한다. */ + /** 폴링 1회마다 호출. */ onTick?: (job: JobData) => void; } -/** 폴링 간격 대기. abort 되면 즉시 깨어난다 — 죽은 타이머를 남기지 않는다. */ +/** 폴링 간격 대기. */ export function delay(ms: number, signal: AbortSignal) { return new Promise<void>((resolve) => { if (signal.aborted) return resolve(); diff --git a/solution/frontend/src/app/provider.tsx b/solution/frontend/src/app/provider.tsx index 4a45ffa..723e5c7 100644 --- a/solution/frontend/src/app/provider.tsx +++ b/solution/frontend/src/app/provider.tsx @@ -6,16 +6,7 @@ import {decodeJwtSubject} from '@/lib/jwt'; import {queryClient} from '@/lib/query-client'; import {toAuthUser, useAuthStore} from '@/stores/auth'; -/** - * 저장된 액세스 토큰으로 세션을 복구한다. - * 실패해도 앱은 뜬다 — 빌더는 로그인 없이도 도는 화면이라, 인증 실패가 - * 전체를 막으면 데모조차 못 본다. 백엔드가 필요한 화면만 가드가 막는다. - * - * ★ 미니 블로그 메일의 "수정하기" 링크(`?auto=<그날짜리 토큰>`)도 여기서 받는다 — - * `RequireAuth` 가 라우트 단에서 로그인 여부를 판단하므로, 그보다 늦게(예: BlogPostsPage - * 안에서) 로그인 처리를 하면 판단이 이미 끝나 `/login` 으로 튕긴 뒤다(2026-09-17 실측: - * "메일온거 클릭했더니 로그인하라고 뜨는데?"). 세션 복구보다 먼저 처리해야 한다. - */ +/** 저장된 액세스 토큰으로 세션을 복구한다. */ function useRestoreSession() { const setUser = useAuthStore((s) => s.setUser); const finishRestore = useAuthStore((s) => s.finishRestore); @@ -39,8 +30,7 @@ function useRestoreSession() { void me() .then((res) => { if (!alive || res.result?.success === false) return; - // ★ RemoveNoneResponse 라 신원 필드가 통째로 빠져 올 수 있다. - // 반쪽짜리 사용자를 세우면 화면은 로그인된 것처럼 굴면서 요청은 401 이 난다 — 세우지 않는다. + // RemoveNoneResponse 라 신원 필드가 통째로 빠져 올 수 있다. if (!res.user_id || !res.id) return; setUser(toAuthUser(res)); }) diff --git a/solution/frontend/src/components/auth/GoogleSignInButton.tsx b/solution/frontend/src/components/auth/GoogleSignInButton.tsx index 75b47d0..7e51058 100644 --- a/solution/frontend/src/components/auth/GoogleSignInButton.tsx +++ b/solution/frontend/src/components/auth/GoogleSignInButton.tsx @@ -1,14 +1,7 @@ import {useEffect, useRef, useState} from 'react'; import {GOOGLE_CLIENT_ID, isGoogleLoginEnabled, loadGoogleIdentity} from '@/lib/googleIdentity'; -/** - * 구글이 그려 주는 버튼을 그대로 쓴다. - * - * ★ 우리가 직접 만든 버튼을 쓰지 않는 이유: 구글 브랜드 가이드가 로고·문구·비율을 정해 두고, - * 그걸 어긋나게 그리면 심사에서 걸린다. renderButton 이 그 규격을 지켜 준다. - * ★ 스크립트가 안 오면(광고 차단기·사내망) 빈 칸이 남는다 — 안내 문구로 바꿔 준다. - * "눌러도 아무 일이 없는 자리"가 화면에 남는 게 제일 나쁘다. - */ +/** 구글이 그려 주는 버튼을 그대로 쓴다. */ export function GoogleSignInButton({ onCredential, text = 'signin_with', @@ -19,8 +12,7 @@ export function GoogleSignInButton({ const holder = useRef<HTMLDivElement>(null); const [failed, setFailed] = useState(false); - // 콜백은 ref 로 들고 간다 — GIS 는 initialize 시점의 함수를 붙잡고 있어서, - // 의존성에 넣으면 렌더마다 버튼을 다시 그리게 된다. + // 콜백은 ref 로 들고 간다 — GIS 는 initialize 시점의 함수를 붙잡고 있어서, 의존성에 넣으면 렌더마다 버튼을 다시 그리게 된다. const handler = useRef(onCredential); handler.current = onCredential; @@ -36,8 +28,7 @@ export function GoogleSignInButton({ callback: (res) => { if (res.credential) handler.current(res.credential); }, - // 화면을 열자마자 마지막 계정으로 자동 로그인되는 동작은 끈다 — - // 계정이 여러 개인 사람이 원하지 않은 계정으로 들어간다. + // 화면을 열자마자 마지막 계정으로 자동 로그인되는 동작은 끈다 — 계정이 여러 개인 사람이 원하지 않은 계정으로 들어간다. auto_select: false, }); api.renderButton(holder.current, { @@ -46,11 +37,9 @@ export function GoogleSignInButton({ size: 'large', shape: 'rectangular', text, - // ★ 'ko' 로는 영문('Continue with Google')이 그대로 나왔다. 지역까지 줘야 한국어다. - // 문구는 우리가 못 정한다 — 구글 브랜드 가이드라 GIS 가 주는 번역을 그대로 쓴다. locale: 'ko_KR', logo_alignment: 'center', - // GIS 는 숫자 px 만 받는다(최대 400). 컨테이너 폭이 잡히기 전이면 최소값으로 그린다. + // GIS 는 숫자 px 만 받는다(최대 400). width: holder.current.offsetWidth || 320, }); }) diff --git a/solution/frontend/src/components/layout/AppShell.tsx b/solution/frontend/src/components/layout/AppShell.tsx index f999814..63c1a0e 100644 --- a/solution/frontend/src/components/layout/AppShell.tsx +++ b/solution/frontend/src/components/layout/AppShell.tsx @@ -7,42 +7,21 @@ import {userLabel, useAuthStore} from '@/stores/auth'; export type NavItem = { to: string; - /** 활성 표시용 경로. 쿼리스트링이 붙은 `to` 로는 pathname 을 비교할 수 없다. */ + /** 활성 표시용 경로. */ match: string; label: string; icon: ComponentType<{className?: string}>; }; -/** - * ★ 메뉴를 이 파일에 하드코딩하지 않는다 — 앱마다 다르고, **섞이면 새어 나간다.** - * 내부 메뉴(`/places`, `/local-content`)를 여기 두면 그 경로 이름이 사장님 번들에 - * 문자열로 남는다(실측: 앱을 가른 뒤에도 dist 에서 `local-content` 가 나왔다). - * 앱을 가른 이유가 그거라 메뉴도 앱이 들고 온다. 내부 메뉴는 `admin/src/app/router.tsx`. - * - * ★ 빌더는 `?new=1` 로 간다. 그냥 `/builder` 로 보내면 저장된 위저드 상태가 복원돼 - * 지난번에 편집하던 가게의 에디터가 뜬다(BuilderPage 주석) — 새로 만들러 누른 사람에게는 - * 남의 화면이다. - */ +/** 빌더는 `?new=1` 로 간다. */ const OWNER_NAV: NavItem[] = [ {to: '/sites', match: '/sites', label: '내 사이트', icon: Store}, {to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2}, - /* - * ★ 요금은 **로그인한 뒤에도** 갈 길이 있어야 한다 (2026-09-04, 사장님 지적) - * /pricing 은 살아 있는데 링크가 MarketingShell 과 랜딩에만 있었다. 둘 다 로그인하면 - * 안 보이는 화면이라(로그인하면 / 가 /sites 로 간다), 사장님은 주소를 직접 쳐야 했다. - * 요금은 쓰는 도중에 확인하는 값이지 가입 전에만 보는 값이 아니다. - * ★ 목적지가 MarketingShell(사이드바 없는 문서형)이라 사이드바가 사라진다. 그래도 갇히지 - * 않는다 — 저쪽 헤더가 로그인 상태를 알아보고 [내 사이트] 버튼을 세운다. - */ + /* 목적지가 MarketingShell(사이드바 없는 문서형)이라 사이드바가 사라진다. */ {to: '/pricing', match: '/pricing', label: '요금', icon: Receipt}, ]; -/** - * 개발자 전용 두 줄(사이트관리·유저관리, 2026-09-23 기획). admin/frontend 를 새 도메인으로 - * 키우는 대신 solution 앱에 경량으로 얹은 것 — 그래서 위 주석의 "메뉴가 섞이면 새어 나간다"가 - * 그대로 적용된다: 이 문자열은 role 과 무관하게 사장님에게 나가는 번들에도 실린다(런타임에만 - * 숨긴다). 실제 데이터 접근은 백엔드 RequireDeveloper 가 막으므로 노출은 경로 이름 정도다. - */ +/** 개발자 전용 두 줄. */ const DEVELOPER_NAV: NavItem[] = [ {to: '/ops/sites', match: '/ops/sites', label: '사이트관리', icon: Globe2}, {to: '/ops/users', match: '/ops/users', label: '유저관리', icon: Users}, @@ -58,8 +37,7 @@ export function AppShell({children, nav}: {children: ReactNode; nav?: NavItem[]} return ( <div className="flex h-screen w-screen overflow-hidden bg-background text-foreground"> <aside className="hidden w-56 shrink-0 flex-col border-r border-sidebar-border bg-sidebar md:flex"> - {/* ★ 로고는 언제나 홈(/)이다. 메뉴 첫 항목으로 보내면 앱마다 목적지가 달라지고, - 로고를 눌러 첫 화면으로 가려던 사람이 엉뚱한 목록에 떨어진다. */} + {/* 로고는 언제나 홈(/)이다. */} <Link to="/" className="flex items-center gap-2 px-4 py-4"> <img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-7 w-auto" /> </Link> @@ -84,9 +62,6 @@ export function AppShell({children, nav}: {children: ReactNode; nav?: NavItem[]} ))} </nav> - {/* ★ 위저드는 로그인 없이도 열린다(관문은 에디터 진입이다) — 그래서 이 자리는 - **비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만 - 남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */} <div className="border-t border-sidebar-border p-3"> {/* 이름 자리가 곧 [내 정보] 입구다 — 메뉴를 한 줄 더 늘리지 않는다(아임웹의 프로필과 같은 자리). */} {user ? ( @@ -104,11 +79,10 @@ export function AppShell({children, nav}: {children: ReactNode; nav?: NavItem[]} {user ? ( <button type="button" - // 로그아웃은 **눈에 보이는 결과**가 있어야 한다. 스토어만 비우면 화면은 그대로라 - // 눌러도 아무 일이 없는 것처럼 보인다 — 로그인 화면으로 보낸다. + // 로그아웃은 **눈에 보이는 결과**가 있어야 한다. onClick={() => { signOut(); - // ★ 로그인 화면이 아니라 랜딩으로. 나간 사람에게 다시 로그인 폼을 들이밀지 않는다. + // 로그인 화면이 아니라 랜딩으로. navigate('/'); }} className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60" diff --git a/solution/frontend/src/components/layout/MarketingShell.tsx b/solution/frontend/src/components/layout/MarketingShell.tsx index 6ad75cd..fde484b 100644 --- a/solution/frontend/src/components/layout/MarketingShell.tsx +++ b/solution/frontend/src/components/layout/MarketingShell.tsx @@ -3,15 +3,7 @@ import {Link, NavLink} from 'react-router'; import {cn} from '@/lib/utils'; import {useAuthStore} from '@/stores/auth'; -/** - * 로그인 전 화면(랜딩·요금·쇼케이스)의 껍데기. - * - * ★ AppShell 과 나눠 둔 이유: 저쪽은 **사이드바가 있는 작업 화면**이다. 아직 아무것도 만들지 - * 않은 사람에게 사이드바를 보여 주면 비어 있는 메뉴만 남는다(b07ade2 가 온보딩에서 사이드바를 - * 뺀 것과 같은 판단). - * - * ★ 메뉴는 셋을 넘기지 않는다. 소상공인 대상 화면에서 드롭다운은 "못 찾는 메뉴"가 된다. - */ +/** 로그인 전 화면(랜딩·요금·쇼케이스)의 껍데기. */ const NAV = [ {to: '/showcase', label: '이렇게 나옵니다'}, {to: '/pricing', label: '요금'}, @@ -52,12 +44,7 @@ export function MarketingShell({ </nav> <div className="ml-auto flex items-center gap-3"> - {/* ★ 이미 사이트를 가진 사장님에게 [로그인] 을 다시 보여주지 않는다 — 갈 곳은 내 사이트다. */} - {/* - ★ [무료로 만들기] 를 뺐다 (2026-09-04, 사장님 지시) - 히어로의 큰 입력 카드가 이미 그 자리다 — 시작하는 문이 한 화면에 둘이면 - 어느 쪽이 진짜인지 고르게 만든다. 헤더는 로그인만 받는다. - */} + {/* 이미 사이트를 가진 사장님에게 [로그인] 을 다시 보여주지 않는다 — 갈 곳은 내 사이트다. */} {showAuthCta && (user ? ( <Link to="/sites" @@ -95,7 +82,7 @@ export function MarketingShell({ ); } -/** 랜딩의 한 켜. 섹션마다 여백을 손으로 적지 않게 한 곳에 모은다. */ +/** 랜딩의 한 켜. */ export function Section({ children, className, diff --git a/solution/frontend/src/components/layout/RequireAuthLayout.tsx b/solution/frontend/src/components/layout/RequireAuthLayout.tsx index 75eb24f..68a1449 100644 --- a/solution/frontend/src/components/layout/RequireAuthLayout.tsx +++ b/solution/frontend/src/components/layout/RequireAuthLayout.tsx @@ -1,10 +1,7 @@ import {Outlet} from 'react-router'; import {RequireAuth} from './RequireAuth'; -/** - * 인증이 필요한 라우트들의 부모. 예전 `router.tsx` 가 페이지마다 <RequireAuth> 로 - * 감싸던 것을 레이아웃 라우트 하나로 모았다 — 가드가 한 자리에 있어야 빠뜨리지 않는다. - */ +/** 인증이 필요한 라우트들의 부모. */ export default function RequireAuthLayout() { return ( <RequireAuth> diff --git a/solution/frontend/src/components/ui/dialog.tsx b/solution/frontend/src/components/ui/dialog.tsx index 2fec654..489268f 100644 --- a/solution/frontend/src/components/ui/dialog.tsx +++ b/solution/frontend/src/components/ui/dialog.tsx @@ -8,7 +8,7 @@ interface DialogProps { title: string; description?: string; children: ReactNode; - /** 항상 아래에 붙어 있어야 하는 영역(주요 버튼). 본문이 길어도 잘리지 않는다. */ + /** 항상 아래에 붙어 있어야 하는 영역(주요 버튼). */ footer?: ReactNode; className?: string; } @@ -43,9 +43,7 @@ export function Dialog({open, onClose, title, description, children, footer, cla aria-modal="true" aria-label={title} className={cn( - // ★ 내용이 화면보다 길어도 헤더·본문만 늘어나고 하단 버튼은 잘리지 않아야 한다. - // 실제로 발행 모달이 화면을 넘겨 [발행하기] 가 화면 밖으로 밀려났다. - // 그래서 다이얼로그 높이를 뷰포트로 묶고, 넘치는 것은 본문이 스크롤한다. + // 내용이 화면보다 길어도 헤더·본문만 늘어나고 하단 버튼은 잘리지 않아야 한다. 'relative z-10 flex max-h-[calc(100dvh-2rem)] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border bg-card shadow-xl', className, )} diff --git a/solution/frontend/src/features/agent/AgentChatDock.tsx b/solution/frontend/src/features/agent/AgentChatDock.tsx index 6f5a5cb..f41f64a 100644 --- a/solution/frontend/src/features/agent/AgentChatDock.tsx +++ b/solution/frontend/src/features/agent/AgentChatDock.tsx @@ -3,19 +3,7 @@ import {Loader2, MessageSquare, Send, X} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {agentApi} from './api'; -/** - * 사장님 에이전트 대화창 — **빌더 화면의 입구**. - * - * ★ 카카오톡보다 이걸 먼저 만든다. 런타임이 채널을 모르므로(services/agent/runtime.py), - * 채널·챗봇 심사 없이 여기서 에이전트 전체를 검증할 수 있다. 카톡은 나중에 붙는 - * 두 번째 입구다(docs/AGENT.md). - * - * ★ 가게를 먼저 고르게 한다. 사업장이 여럿인 사장님에게 "어느 가게 이야기인지" 를 - * 화면이 말하지 않으면, 엉뚱한 가게를 고쳐 놓고도 그 사실을 모른다. - * - * ★ 확인이 필요한 답(needs_confirm)은 **버튼으로만** 진행한다. 서버가 도구와 인자를 - * 다시 검증하므로 여기서 값을 만들지 않고 받은 것을 그대로 돌려보낸다. - */ +/** 사장님 에이전트 대화창 — **빌더 화면의 입구**. */ type Reply = { reply: string; tool: string | null; @@ -51,12 +39,7 @@ export function AgentChatDock({sites}: {sites: Site[]}) { endRef.current?.scrollIntoView({behavior: 'smooth'}); }, [turns, open]); - // ★ 꺼져 있으면 **통째로 감춘다**(서버 `AGENT_CHAT_ENABLED`, 기본 꺼짐). - // 카카오톡 채널이 준비되기 전에는 이 대화창이 어디에도 닿지 않는 입구다 — - // 보이면 사장님은 "되는 기능" 으로 오해한다. - // ★ Threads 카드와 반대 판단인 것이 맞다. 저쪽은 사장님이 **곧 쓸 수 있는** 기능이라 - // 자리를 두고 버튼만 죽였고, 이쪽은 아직 제품이 아니다. - // 가게가 없을 때와 상태를 못 읽었을 때도 접는다. + // 꺼져 있으면 **통째로 감춘다**(서버 `AGENT_CHAT_ENABLED`, 기본 꺼짐). if (!enabled || sites.length === 0) return null; async function send(body: {message?: string; confirm?: {tool: string; args: Record<string, unknown>}}, echo: string) { diff --git a/solution/frontend/src/features/agent/KakaoChannelCard.tsx b/solution/frontend/src/features/agent/KakaoChannelCard.tsx index ffb2ecc..17ac7e3 100644 --- a/solution/frontend/src/features/agent/KakaoChannelCard.tsx +++ b/solution/frontend/src/features/agent/KakaoChannelCard.tsx @@ -3,26 +3,7 @@ import {Loader2, MessageCircle, Unlink} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {agentApi, type KakaoLinkCode, type KakaoLinkState} from './api'; -/** - * 카카오톡 채널 연결 — **'내 사이트' 화면에 한 자리**. - * - * ★ 왜 사업장 화면이 아닌가 - * 연결은 `user` 단위다(표도 그렇게 생겼다 — `owner_kakao_links`). 버튼이 사업장 안에 - * 있으면 사장님은 **업장 수만큼 연결해야 하는 줄 안다.** 연결은 한 번이다. - * SocialConnectionCard 가 같은 이유로 여기 있다. - * - * ★ 코드는 화면에만 한 번 뜬다. - * 서버는 sha256 만 들고 있어서 **다시 보여줄 수 없다.** 새로고침하면 사라지므로 - * "다시 받기" 를 항상 옆에 둔다 — 못 보여주는 것과 잃어버린 것은 다른 상태이고, - * 화면이 그 둘을 구별해 말해야 한다. - * - * ★ 채널이 없으면 **통째로 감춘다**(`connection_enabled=false`). - * Threads 카드는 반대로 '자리는 두고 버튼만 죽이는' 쪽을 골랐는데, 저쪽은 사장님이 - * **곧 쓸 수 있는** 기능이라 존재를 알려야 했다. 이쪽은 카카오톡 채널 개설이 법인폰 - * 본인인증에 걸려 보류됐고(2026-09-21), 언제 열릴지 말해 줄 수 없다 — - * 그런 카드는 사장님에게 "눌러도 안 되는 버튼" 하나일 뿐이다. - * 채널이 준비돼 `KAKAO_CHANNEL_PUBLIC_ID` 를 채우면 이 카드가 그대로 다시 나타난다. - */ +/** 카카오톡 채널 연결 — **'내 사이트' 화면에 한 자리**. */ export function KakaoChannelCard() { const [state, setState] = useState<KakaoLinkState | null>(null); const [issued, setIssued] = useState<KakaoLinkCode | null>(null); @@ -130,7 +111,7 @@ export function KakaoChannelCard() { </div> )} - {/* 코드를 냈는데 화면을 새로 열어 코드가 사라진 경우. '기다리는 중' 을 숨기지 않는다. */} + {/* 코드를 냈는데 화면을 새로 열어 코드가 사라진 경우. */} {!issued && state.status === 'PENDING' && ( <p className="mt-3 text-xs text-muted-foreground"> 보낸 코드를 기다리고 있습니다. 코드를 잃어버렸다면 다시 받아 주세요. diff --git a/solution/frontend/src/features/agent/api.ts b/solution/frontend/src/features/agent/api.ts index c5afd7d..1a41180 100644 --- a/solution/frontend/src/features/agent/api.ts +++ b/solution/frontend/src/features/agent/api.ts @@ -1,12 +1,6 @@ import {getAccessToken} from '@/api'; -/** - * 사장님 에이전트 — 카카오톡 채널 연결. - * - * ★ social 과 파일을 가른 이유는 도메인이 다르기 때문이다. SNS 게재는 되돌릴 수 없는 - * 대외 발화이고, 이건 사장님이 자기 사이트를 고치는 창구다. 한 사전에 섞으면 - * 에러 문구가 어느 기능의 것인지 화면에서 구별되지 않는다. - */ +/** 사장님 에이전트 — 카카오톡 채널 연결. */ export type KakaoLinkState = { connection_enabled: boolean; channel_url: string; diff --git a/solution/frontend/src/features/auth/EditorSignInGate.tsx b/solution/frontend/src/features/auth/EditorSignInGate.tsx index c432851..db704ec 100644 --- a/solution/frontend/src/features/auth/EditorSignInGate.tsx +++ b/solution/frontend/src/features/auth/EditorSignInGate.tsx @@ -1,11 +1,4 @@ -/** - * 에디터 앞의 로그인 관문. - * - * ★ 위저드(1~5단계)는 로그인을 요구하지 않는다 — 만들어 보기도 전에 막으면 아무도 안 만든다. - * 에디터부터는 편집한 것을 저장하고 발행해야 하는데 그게 전부 토큰을 쓴다. 토큰 없이 들여보내면 - * 저장이 조용히 실패하고 사장님은 발행하고 나서야 안다. - * ★ /login 으로 튕기지 않는다. 위저드에서 쌓은 상태를 들고 돌아올 방법을 사장님이 알 수 없다. - */ +/** 에디터 앞의 로그인 관문. */ import {SignInForm} from './SignInForm'; export function EditorSignInGate({onBack}: {onBack: () => void}) { diff --git a/solution/frontend/src/features/auth/SignInForm.tsx b/solution/frontend/src/features/auth/SignInForm.tsx index 0979015..68bbcb3 100644 --- a/solution/frontend/src/features/auth/SignInForm.tsx +++ b/solution/frontend/src/features/auth/SignInForm.tsx @@ -1,9 +1,4 @@ -/** - * 로그인 폼 한 벌. - * - * ★ 로그인 화면과 에디터 진입 관문이 같은 폼을 쓴다. 두 벌로 두면 토큰을 심는 순서 - * (signIn → me)가 한쪽에서만 지켜지고, 그 실수는 "로그인은 됐는데 계속 401" 로 나타난다. - */ +/** 로그인 폼 한 벌. */ import {useState, type FormEvent, type ReactNode} from 'react'; import {LogIn} from 'lucide-react'; import {googleLogin, login} from '@/api'; @@ -15,7 +10,7 @@ import {isGoogleLoginEnabled} from '@/lib/googleIdentity'; import {establishSession} from '@/lib/session'; interface SignInFormProps { - /** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */ + /** 폼 위에 붙는 제목·설명. */ header: ReactNode; /** 폼 아래 각주(빌더로 돌아가기 등). */ footer?: ReactNode; @@ -58,8 +53,7 @@ export function SignInForm({header, footer, submitLabel = '로그인', onSignedI notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.'); return; } - // 토큰 심는 순서(signIn → me)는 lib/session 한 곳에만 둔다 — 이 파일 맨 위 주석이 - // 경고하던 그 중복이다. 로그인 화면·가입 화면·자동 로그인이 전부 같은 함수를 쓴다. + // 토큰 심는 순서(signIn → me)는 lib/session 한 곳에만 둔다 — 이 파일 맨 위 주석이 경고하던 그 중복이다. if (!(await establishSession(res, id))) { notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.'); return; @@ -112,8 +106,7 @@ export function SignInForm({header, footer, submitLabel = '로그인', onSignedI <span>{submitLabel}</span> </Button> - {/* ★ 로그인 화면이 여기 하나만 있는 게 아니다 — 에디터 관문·2단계도 이 폼을 쓴다. - 구글 버튼을 LoginPage 에만 붙여 두면 정작 사장님이 만나는 자리엔 없다. */} + {/* 로그인 화면이 여기 하나만 있는 게 아니다 — 에디터 관문·2단계도 이 폼을 쓴다. */} {isGoogleLoginEnabled() && ( <> <div className="flex items-center gap-2"> diff --git a/solution/frontend/src/features/builder/CanvasView.tsx b/solution/frontend/src/features/builder/CanvasView.tsx index 0c97772..5ee18e3 100644 --- a/solution/frontend/src/features/builder/CanvasView.tsx +++ b/solution/frontend/src/features/builder/CanvasView.tsx @@ -8,12 +8,7 @@ import {useBuilderStore, useCurrentTemplate} from '@/stores/builder'; import {SitePreview} from './SitePreview'; import {PUBLISH_HOST} from '@/lib/site'; -// ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은 -// 미리보기에서 본 주소와 발행 후 안내받는 주소가 달라 어느 쪽이 진짜인지 알 수 없다. -// 같은 규칙(VITE_PUBLISH_HOST → 없으면 현재 호스트)을 쓴다. -// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 -// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. -// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다) +// 호스트를 상수로 박지 않는다. const VIEWPORT_FRAME: Record<ViewportMode, string> = { pc: 'w-full max-w-5xl shadow-md', @@ -59,15 +54,7 @@ export function CanvasView() { }; // 템플릿 색은 CSS 변수로 내려보낸다 — 관리자 토큰(--primary 등)과 이름이 겹치지 않게 --tpl-* 접두어. - // ★ 면 토큰(--tpl-surface / -alt / inverse / border)은 템플릿 색에서 유도한다. - // 이걸 안 내려보내면 SectionFrame 이 폴백(고정 stone 색)으로 떨어져서, - // 템플릿을 바꿔도 화면 면적의 대부분이 그대로다 — 쇼케이스와 같은 식(lib/color.ts)을 쓴다. - /* - * ★ 색·서체 토큰을 여기서 만들지 않는다(2026-09-09). - * 캔버스가 빌더 상태로 --tpl-* 를 따로 만들던 동안, 그 값이 발행본과 갈라졌다 — - * 실측: 편집 캔버스 #ffffff·Pretendard ↔ 발행본 #e4dac0·Gugi. - * 이제 미리보기·편집 둘 다 iframe 안 발행본 렌더러가 그리고, 토큰은 payload 하나에서 온다. - */ + /* 색·서체 토큰을 여기서 만들지 않는다. */ return ( <div className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-muted"> diff --git a/solution/frontend/src/features/builder/EditorHeader.tsx b/solution/frontend/src/features/builder/EditorHeader.tsx index 16f9a82..06d6fed 100644 --- a/solution/frontend/src/features/builder/EditorHeader.tsx +++ b/solution/frontend/src/features/builder/EditorHeader.tsx @@ -17,12 +17,7 @@ export function EditorHeader() { const unverifiedCount = useUnverifiedFields().length; - /** - * 한 번이라도 발행했는가 — 버튼 문구가 [사이트 발행] 인지 [사이트 재발행] 인지를 가른다. - * - * ★ usePlaceSync 가 이미 읽는 같은 쿼리다(같은 키 → 같은 캐시). 여기서 다시 부른다고 - * 요청이 한 번 더 나가지 않는다. - */ + /** 한 번이라도 발행했는가 — 버튼 문구가 [사이트 발행] 인지 [사이트 재발행] 인지를 가른다. */ const siteQuery = useGetSite(placeId ?? '', { query: {enabled: Boolean(placeId) && Boolean(getAccessToken())}, }); @@ -31,17 +26,9 @@ export function EditorHeader() { return ( <header className="z-30 flex h-14 shrink-0 select-none items-center justify-between border-b border-border bg-card px-4 sm:px-6"> <div className="flex min-w-0 items-center gap-3.5"> - {/* ★ 실사업장을 편집 중이면 로고는 **아무 데도 가지 않는다.** - 예전엔 사업장 목록(/places)으로 갔지만 그 화면은 내부 운영 앱(admin)으로 나갔다 — - 여기 링크를 남기면 사장님 앱에서 404 이고, 내부 경로 이름이 사장님 번들에도 남는다. - reset() 을 부르지 않는 이유는 그대로다: 편집하던 가게를 떠나 데모 위저드로 - 떨어지면 사장님 눈에는 자기 가게가 사라진 것으로 보인다. - (사장님용 "내 사이트 관리"가 생기면 그때 그리로 잇는다 — ARCHITECTURE.md 4절) - ★ 데모에서 돌아가는 길은 `?new=1` 이다 — 스토어를 직접 비우면 화면만 위저드로 바뀌고 - 주소는 그대로라, 뒤로가기가 편집기로 돌아오지 않는다(단계는 주소창이 소유한다). */} + {/* 데모에서 돌아가는 길은 `?new=1` 이다 — 스토어를 직접 비우면 화면만 위저드로 바뀌고 주소는 그대로라, 뒤로가기가 편집기로 돌아오지 않는다(단계는 주소창이 소유한다). */} {placeId ? ( // 편집 중이어도 로고는 홈으로 간다 — 눌리지 않는 로고는 고장으로 읽힌다. - // 입력값은 서버에 저장되므로 나갔다 들어와도 그대로다. <Link to="/" title="처음으로 이동" className="group flex items-center gap-2"> <LogoMark /> </Link> @@ -76,7 +63,7 @@ export function EditorHeader() { <span>{isPreviewMode ? '편집 모드' : '미리보기'}</span> </Button> - {/* 발행 = 빌드다. 누르면 HTML 을 다시 굽는다(usePublishSite 주석 참고). */} + {/* 발행 = 빌드다. */} <Button variant="primary" size="sm" onClick={openPublishModal}> <Send /> <span>{isRepublish ? '사이트 재발행' : '사이트 발행'}</span> diff --git a/solution/frontend/src/features/builder/EditorLayout.tsx b/solution/frontend/src/features/builder/EditorLayout.tsx index 3eecc0a..a854510 100644 --- a/solution/frontend/src/features/builder/EditorLayout.tsx +++ b/solution/frontend/src/features/builder/EditorLayout.tsx @@ -59,7 +59,7 @@ export function EditorLayout() { <CanvasView /> ) : ( <> - {/* ★ shrink-0 — 가운데가 넓어져도 이 패널은 절대 줄지 않는다. */} + {/* shrink-0 — 가운데가 넓어져도 이 패널은 절대 줄지 않는다. */} <div className={cn( 'h-full lg:flex lg:w-[250px] lg:shrink-0', @@ -73,13 +73,7 @@ export function EditorLayout() { /> </div> - {/* - ★ min-w-0 이 반드시 있어야 한다. - flex 아이템의 기본값은 `min-width: auto` 라 **콘텐츠 최소 너비 아래로 줄지 않는다**. - 캔버스 툴바(해상도 버튼 + 동기화 표시)가 그 최소 너비를 밀어올리면 이 칸이 - 남은 공간을 다 먹고, 오른쪽 패널이 통째로 화면 밖으로 밀려난다 - (바깥이 overflow-hidden 이라 스크롤도 안 생겨 그냥 사라진다 — 실제로 그랬다). - */} + {/* min-w-0 이 반드시 있어야 한다. */} <div className={cn( 'h-full min-w-0 flex-1 lg:flex', @@ -89,7 +83,7 @@ export function EditorLayout() { <CanvasView /> </div> - {/* ★ shrink-0 — 오른쪽 패널이 잘리던 자리. 폭을 양보하지 않는다. */} + {/* shrink-0 — 오른쪽 패널이 잘리던 자리. */} <div className={cn( 'h-full lg:flex lg:w-[290px] lg:shrink-0', diff --git a/solution/frontend/src/features/builder/FaqPanel.tsx b/solution/frontend/src/features/builder/FaqPanel.tsx index 7d0b41d..bd75739 100644 --- a/solution/frontend/src/features/builder/FaqPanel.tsx +++ b/solution/frontend/src/features/builder/FaqPanel.tsx @@ -9,20 +9,7 @@ import {Input} from '@/components/ui/input'; import {cn} from '@/lib/utils'; import {useBuilderStore} from '@/stores/builder'; -/** - * FAQ 관리 패널. - * - * ★ 왜 별도 탭인가 - * FAQ 는 AI 검색이 가장 잘 인용하는 형식이다(FAQPage 구조화 데이터). 그런데 지금까지 - * 관리자에는 FAQ 를 **볼 자리조차** 없었다 — COPY 잡이 만들어 둔 FAQ 가 `UNVERIFIED` 로 - * 쌓여 있는데 승인할 화면이 없으니 영원히 발행되지 않았다. 실제로 8건이 대기 중인 - * 사업장이 있고, FAQ 가 나가는 사업장은 한 곳뿐이었다. - * - * ★ 이 패널이 하는 일은 둘이다. - * 1) 사장님이 **직접 질문·답변을 쓴다** → 서버가 OWNER 출처로 바로 노출값(VERIFIED)에 넣는다. - * 쓴 사람이 곧 출처이자 책임 주체이기 때문이다(fact 직접 입력과 같은 규칙). - * 2) LLM 이 만들어 둔 대기 FAQ 를 **승인한다** → VERIFIED 로 전이시켜 사이트에 내보낸다. - */ +/** FAQ 관리 패널. */ export function FaqPanel() { const placeId = useBuilderStore((s) => s.placeId); @@ -52,14 +39,14 @@ function FaqEditor({placeId}: {placeId: string}) { const createFaq = useCreateFaq(); const transitionFaq = useTransitionFaq(); - /** 목록을 다시 읽는다. 추가·승인 뒤 화면이 옛 상태로 남으면 사장님이 같은 걸 두 번 누른다. */ + /** 목록을 다시 읽는다. */ const refresh = () => queryClient.invalidateQueries({queryKey: getListFaqsQueryKey(placeId)}); const faqs = listQuery.data?.faqs ?? []; const publishable = listQuery.data?.publishable ?? 0; const pending = listQuery.data?.pending_review ?? 0; - // 20개를 채운 공통 질문. 답이 "숙소로 문의" 뿐이라 따로 센다 — 실제 답이 몇 개인지 보여야 채울 마음이 든다. + // 20개를 채운 공통 질문. const inquiry = faqs.filter((faq) => faq.generated_by === SourceType.TEMPLATE).length; const submit = async () => { diff --git a/solution/frontend/src/features/builder/ItemFormEditor.tsx b/solution/frontend/src/features/builder/ItemFormEditor.tsx index c4625fe..d7895ae 100644 --- a/solution/frontend/src/features/builder/ItemFormEditor.tsx +++ b/solution/frontend/src/features/builder/ItemFormEditor.tsx @@ -16,7 +16,7 @@ function readAt(row: Row, key: string): unknown { }, row); } -/** 값을 쓴다. 빈 값이면 **키를 지운다** — 빈 문자열을 남기면 "확인 안 된 값"이 발행본에 나간다. */ +/** 값을 쓴다. */ function writeAt(row: Row, key: string, value: unknown): Row { const [head, ...rest] = key.split('.'); const next = {...row}; @@ -138,7 +138,7 @@ export function ItemFormEditor({ const items = Array.isArray(envelope.items) ? (envelope.items as Row[]) : []; - /** 항목 배열을 다시 JSON 문자열로. 봉투(kind·title·subtitle)는 그대로 둔다. */ + /** 항목 배열을 다시 JSON 문자열로. */ const commit = (next: Row[]) => onChange(JSON.stringify({...envelope, kind: spec.kind, items: next}, null, 2)); diff --git a/solution/frontend/src/features/builder/RightTabsPanel.tsx b/solution/frontend/src/features/builder/RightTabsPanel.tsx index 98fd2af..77bd3cb 100644 --- a/solution/frontend/src/features/builder/RightTabsPanel.tsx +++ b/solution/frontend/src/features/builder/RightTabsPanel.tsx @@ -200,7 +200,7 @@ function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionT } }; - /** 유효한 JSON 만 2칸 들여쓰기로 다시 쓴다. 깨져 있으면 손대지 않는다. */ + /** 유효한 JSON 만 2칸 들여쓰기로 다시 쓴다. */ const tidy = () => { try { updateSectionData(sectionId, JSON.stringify(JSON.parse(raw), null, 2)); diff --git a/solution/frontend/src/features/builder/SectionListPanel.tsx b/solution/frontend/src/features/builder/SectionListPanel.tsx index e4eb5b0..469c8d0 100644 --- a/solution/frontend/src/features/builder/SectionListPanel.tsx +++ b/solution/frontend/src/features/builder/SectionListPanel.tsx @@ -134,7 +134,7 @@ function SortableSection({section, index, onAfterSelect}: SortableSectionProps) ); } -/** [+ 섹션 추가] 가 여는 목록. 이미 들어 있는 것은 '넣음' 으로 표시만 하고 다시 넣지 않는다. */ +/** [+ 섹션 추가] 가 여는 목록. */ function AddSectionPicker({onClose, onAfterSelect}: {onClose: () => void; onAfterSelect?: () => void}) { const sections = useBuilderStore((s) => s.sections); const addSection = useBuilderStore((s) => s.addSection); diff --git a/solution/frontend/src/features/builder/SitePreview.tsx b/solution/frontend/src/features/builder/SitePreview.tsx index 2cdf5a9..fe8509c 100644 --- a/solution/frontend/src/features/builder/SitePreview.tsx +++ b/solution/frontend/src/features/builder/SitePreview.tsx @@ -1,12 +1,4 @@ -/** - * 미리보기 — 발행본을 iframe으로 띄운다. iframe이 여는 주소는 `/preview?placeId=…` - * (site/scripts/prerender.writePreviewShell이 굽는 CSR 셸, 발행본 앱이 그 안에서 그대로 돈다). - * 같은 오리진이라 토큰(localStorage)을 iframe이 그대로 읽는다. - * - * iframe으로 띄우는 이유: 빌더 안에 직접 그렸을 때 렌더러·payload·토큰을 다 맞춰도 - * 레이아웃 폭이 어긋났다 — 미디어쿼리는 창 폭을 보는데 실제 사이트 폭은 프레임 폭이라서다. - * iframe은 자체 뷰포트를 가지므로 폭을 390/768/1024로 주면 발행본과 같은 미디어쿼리가 걸린다. - */ +/** 미리보기 — 발행본을 iframe으로 띄운다. */ import {useCallback, useEffect, useRef, useState} from 'react'; import type {ViewportMode} from '@o2o/shared'; import {LoaderCircle} from 'lucide-react'; @@ -15,7 +7,7 @@ import {onSiteThemeSaved} from '@/features/publish/siteTheme'; // 넘으면 로딩 표시를 걷고 iframe을 그대로 보여준다(구 번들이라 painted 신호가 안 올 수 있음). const PAINT_TIMEOUT_MS = 12000; -// 해상도별 iframe 크기. 높이도 줘야 vh 쓰는 자리(히어로 등)가 발행본과 맞는다. +// 해상도별 iframe 크기. const FRAME_SIZE: Record<ViewportMode, {w: number; h: number}> = { pc: {w: 1024, h: 800}, tablet: {w: 768, h: 1024}, @@ -31,7 +23,7 @@ export function SitePreview({ }: { placeId: string | null; viewport: ViewportMode; - /** 편집 모드 — 섹션을 눌러 고를 수 있게 한다. 미리보기에서는 끈다. */ + /** 편집 모드 — 섹션을 눌러 고를 수 있게 한다. */ interactive?: boolean; selectedId?: string | null; onSelect?: (sectionId: string) => void; @@ -42,23 +34,13 @@ export function SitePreview({ const [painted, setPainted] = useState(false); /** 저장이 끝날 때마다 올린다 — 값이 바뀌면 iframe 을 다시 띄워 새 payload 를 받는다. */ const [reloadToken, setReloadToken] = useState(0); - /** 첫 로드인지. 첫 로드만 화면을 덮고, 편집 중 새로고침은 얇은 띠만 보여준다. */ + /** 첫 로드인지. */ const firstPaintDone = useRef(false); - /** - * 편집이 서버에 저장되면 미리보기를 다시 그린다. - * - * ★ 이게 없으면 섹션을 드래그해 순서를 바꿔도 **캔버스가 그대로**다. 왼쪽 목록만 움직이고 - * 사장님에게는 "드래그가 안 먹는" 것으로 보인다(실측 2026-09-15). 섹션 켜고 끄기· - * 배리에이션·색도 전부 같았다 — iframe 은 한 번 뜬 뒤 다시 그릴 계기가 없었다. - * ★ 스토어가 아니라 **저장 완료**를 듣는 이유는 siteTheme.onSiteThemeSaved 주석에 있다. - */ + /** 편집이 서버에 저장되면 미리보기를 다시 그린다. */ useEffect(() => onSiteThemeSaved(() => setReloadToken((n) => n + 1)), []); - /** - * 다시 그릴 때 보던 자리를 지킨다. 맨 위로 튀면 사장님은 방금 옮긴 섹션을 다시 찾아야 한다. - * ★ 첫 로드에는 복원할 것이 없으므로 건너뛴다. - */ + /** 다시 그릴 때 보던 자리를 지킨다. */ const scrollTop = useRef(0); useEffect(() => { if (!reloadToken) return; @@ -69,15 +51,7 @@ export function SitePreview({ frame.contentWindow.location.reload(); }, [reloadToken]); - /** - * 미리보기가 **실제로 그려졌는지**. iframe 의 `load` 로는 알 수 없다 — - * 그때는 셸만 뜬 상태이고 payload fetch·웹폰트 대기가 남아 있어 화면은 아직 희다 - * (`site/src/entry-client.tsx` signalPreviewPainted 의 같은 주석). - * - * ★ 신호를 받지 못해도 상한(PAINT_TIMEOUT_MS)에서 걷는다. 구 번들이 올라가 있으면 - * 신호 자체가 없는데, 그 경우 로딩바가 영영 안 사라진다. - * ★ 출처를 확인한다 — 같은 오리진에서 온 우리 iframe 의 메시지만 받는다. - */ + /** 미리보기가 **실제로 그려졌는지**. */ useEffect(() => { setPainted(false); const frame = ref.current; @@ -101,12 +75,7 @@ export function SitePreview({ }; }, [placeId, viewport, reloadToken]); - /* - * ★ iframe 은 **진짜 폭(1024·768·390)** 을 유지하고, 자리에 안 들어가면 축소해서 넣는다. - * 폭을 줄여 맞추면 미디어 쿼리가 그 좁은 폭을 보고 발행본과 다른 그리드가 된다 — - * 그걸 피하려고 iframe 을 쓴 것이라 여기서 무너뜨리면 안 된다. - * 기기 미리보기 도구가 쓰는 방식과 같다: 크기는 그대로, 그림만 줄인다. - */ + /* iframe 은 **진짜 폭(1024·768·390)** 을 유지하고, 자리에 안 들어가면 축소해서 넣는다. */ useEffect(() => { const box = boxRef.current; if (!box) return; @@ -120,13 +89,7 @@ export function SitePreview({ return () => ro.disconnect(); }, [viewport]); - /** - * 편집 모드에서 iframe 안 섹션을 고를 수 있게 한다. - * - * ★ 같은 오리진이라 안쪽 문서를 그대로 만질 수 있다. 굳이 postMessage 를 쓰지 않는다. - * ★ 어느 섹션인지는 `data-editor-id` 로 안다 — 화면 id(`gallery`)와 설정 id(`photos`)가 - * 달라서, 그 다리를 발행본 렌더러가 놓아 준다(`site/pages/HomePage`). - */ + /** 편집 모드에서 iframe 안 섹션을 고를 수 있게 한다. */ const wire = useCallback(() => { const doc = ref.current?.contentDocument; if (!doc || !interactive) return; @@ -137,7 +100,7 @@ export function SitePreview({ style.id = 'editor-outline'; doc.head.appendChild(style); } - // ★ outline 을 쓴다(border 아님). 상자 크기를 바꾸지 않아 발행본과 레이아웃이 그대로다. + // outline 을 쓴다(border 아님). style.textContent = ` [data-editor-id] > * { cursor: pointer; } [data-editor-id]:hover > * { outline: 2px dashed rgb(59 130 246 / .5); outline-offset: -2px; } @@ -173,10 +136,7 @@ export function SitePreview({ <div ref={boxRef} className="flex w-full justify-center overflow-hidden"> {/* 축소한 만큼 실제 차지하는 크기도 줄여 준다 — 안 그러면 아래쪽에 빈 공간이 남는다. */} <div className="relative" style={{width: w * scale, height: h * scale}}> - {/* ★ iframe 을 덮는다(자리를 밀어내지 않는다). 레이아웃이 움직이면 그려지는 순간 - 화면이 튀고, 그 튐이 곧 "로딩이 끝났다" 보다 먼저 눈에 들어온다. - ★ 처음 들어올 때만 덮는다. 편집 중에는 옛 그림을 그대로 두고 위에 띠만 올린다 — - 섹션을 옮길 때마다 화면이 하얘지면 방금 무엇을 옮겼는지 놓친다. */} + {/* iframe 을 덮는다(자리를 밀어내지 않는다). */} {!painted && (firstPaintDone.current ? ( <div className="absolute inset-x-0 top-0 z-10" role="status" aria-live="polite"> @@ -195,12 +155,11 @@ export function SitePreview({ <iframe ref={ref} onLoad={wire} - // ★ key 에 해상도를 넣어 바뀌면 새로 띄운다. 같은 문서를 리사이즈만 하면 이미 지나간 - // 미디어 쿼리 분기(그리드 컬럼 수)가 그대로 남는 경우가 있다. + // key 에 해상도를 넣어 바뀌면 새로 띄운다. key={`${placeId}:${viewport}`} title="발행본 미리보기" src={`/preview?placeId=${encodeURIComponent(placeId)}`} - // ★ 발행본과 같은 오리진이라 sandbox 를 걸지 않는다 — 걸면 토큰을 못 읽는다. + // 발행본과 같은 오리진이라 sandbox 를 걸지 않는다 — 걸면 토큰을 못 읽는다. className="block shrink-0 border-0 bg-white" style={{ width: w, diff --git a/solution/frontend/src/features/builder/factSave.ts b/solution/frontend/src/features/builder/factSave.ts index cb5260f..dd36624 100644 --- a/solution/frontend/src/features/builder/factSave.ts +++ b/solution/frontend/src/features/builder/factSave.ts @@ -1,19 +1,4 @@ -/** - * [정보]·[확인] 탭 편집 → 서버 저장. - * - * 사장님이 [맞아요] 를 눌렀는데 로컬 상태만 바뀌면, 리페치 한 번에 되돌아가고 - * 발행 게이트는 저장되지도 않은 검증을 근거로 "발행 가능"이라고 말한다 — 판단 자체가 허상이다. - * 그래서 화면은 즉시 바꾸되(낙관적), 같은 편집을 fact 전이 API 로 올리고, 실패하면 되돌린다. - * - * ★ 왜 훅이 아니라 스토어 액션이 부르나: updateField/verifyField 의 시그니처는 그대로 둬야 하고 - * (호출부 계약), 액션이 특정 컴포넌트의 마운트에 기대게 만들면 안 되기 때문이다. - * react-query 는 그대로 쓴다 — 뮤테이션 캐시로 실행하고, PlaceDetailPage 와 같은 키 - * (['facts', placeId])를 같은 방식으로 무효화한다. 두 화면이 같은 캐시를 본다. - * - * ★ 왜 팩토리인가: 이 모듈은 스토어를 읽고 써야 하는데, 스토어도 이 모듈을 부른다. - * `useBuilderStore` 를 직접 import 하면 순환이 된다. 스토어를 **인자로 받으면** - * 의존이 한쪽으로만 흐르고(스토어 → factSave), 테스트에서 가짜 스토어를 끼울 수도 있다. - */ +/** [정보]·[확인] 탭 편집 → 서버 저장. */ import type {StoreApi} from 'zustand'; import type {InfoField} from '@o2o/shared'; import {FactStatus, isPublishableFact} from '@o2o/shared'; @@ -23,7 +8,7 @@ import {notify, notifyApiError} from '@/lib/notify'; import {queryClient} from '@/lib/query-client'; import type {FactRef} from '@/stores/builderTypes'; -/** factSave 가 스토어에서 실제로 쓰는 부분만. 전체 BuilderState 를 알 필요가 없다. */ +/** factSave 가 스토어에서 실제로 쓰는 부분만. */ export interface FactSaveStoreSlice { placeId: string | null; factRefs: Record<string, FactRef>; @@ -32,7 +17,6 @@ export interface FactSaveStoreSlice { } export interface FactSaver { - /** [정보] 탭 타이핑 — 잠깐 모았다가 마지막 값 하나만 올린다. */ queue: (fieldId: string, value: string, before?: InfoField) => void; /** [확인] 탭 버튼 — 기다릴 이유가 없으므로 바로 올린다. */ saveNow: (fieldId: string, correctedValue?: string, before?: InfoField) => void; @@ -43,36 +27,28 @@ export interface FactSaver { } export function createFactSaver(store: StoreApi<FactSaveStoreSlice>): FactSaver { - /** 타이핑 한 글자마다 서버를 두드리지 않는다. 멈추면 마지막 값 하나만 올린다. */ + /** 타이핑 한 글자마다 서버를 두드리지 않는다. */ const FACT_SAVE_DEBOUNCE_MS = 600; - /** 낙관적으로 먼저 바꿔놓는 부분. 리페치가 와도 이 만큼은 다시 얹는다. */ + /** 낙관적으로 먼저 바꿔놓는 부분. */ type FieldPatch = Partial<Pick<InfoField, 'value' | 'isVerified'>>; interface PendingFactSave { - /** 이 필드의 마지막 편집 번호. 늦게 끝난 응답이 새 편집을 덮어쓰지 못하게 하는 표. */ + /** 이 필드의 마지막 편집 번호. */ seq: number; patch: FieldPatch; /** 실패하면 여기로 되돌린다 — 연속 편집이 시작되기 직전의 값. */ before?: InfoField; - /** 성공을 토스트로 알릴 것인가([확인] 탭 버튼만. 타이핑까지 알리면 시끄럽다). */ + /** 성공을 토스트로 알릴 것인가([확인] 탭 버튼만. */ announce: boolean; timer?: ReturnType<typeof setTimeout>; } - /** - * 아직 서버 응답을 못 받은 편집들. 스토어 state 가 아니라 모듈 변수인 이유는 - * 화면에 그려지는 값이 아니어서다 — state 에 넣으면 글자 하나마다 전체 구독자가 깨어난다. - */ + /** 아직 서버 응답을 못 받은 편집들. */ const pendingSaves = new Map<string, PendingFactSave>(); let factSaveSeq = 0; - /** - * 리페치 결과 위에 아직 저장 중인 편집을 다시 얹는다. - * - * ★ 이게 없으면 applyPlace 가 낙관적 반영을 덮어쓴다. query-client 는 staleTime 0 이라 - * 리페치가 자주 오고, 그때마다 방금 누른 [맞아요] 가 '확인 필요'로 되튄다. - */ + /** 리페치 결과 위에 아직 저장 중인 편집을 다시 얹는다. */ function withPendingEdits(fields: InfoField[]): InfoField[] { if (pendingSaves.size === 0) return fields; return fields.map((field) => { @@ -104,12 +80,7 @@ export function createFactSaver(store: StoreApi<FactSaveStoreSlice>): FactSaver }); } - /** - * 이 줄을 서버에 올릴 수 있는가. - * - * ★ 제1 계약 — placeId 가 없으면(데모 경로) null 이다. 여기서 끊기므로 네트워크를 한 번도 타지 않는다. - * fact 참조가 없는 줄(상호·주소·전화)도 null 이다 — place 자체의 값이라 전이시킬 fact 가 없다. - */ + /** 이 줄을 서버에 올릴 수 있는가. */ function factSaveTarget(fieldId: string): {placeId: string; ref: FactRef} | null { const {placeId, factRefs} = store.getState(); if (!placeId) return null; @@ -117,12 +88,7 @@ export function createFactSaver(store: StoreApi<FactSaveStoreSlice>): FactSaver return ref ? {placeId, ref} : null; } - /** - * 화면 문자열 → 서버에 저장할 원래 값(formatFactValue 의 역). - * - * ★ 정보 표가 보여주는 건 사람이 읽는 문장이다('15,000원', '예'). 그대로 올리면 다음 렌더에서 - * 단위가 두 번 붙고('15,000원원'), bool 이 '예'라는 문자열로 굳는다. 되돌려서 올린다. - */ + /** 화면 문자열 → 서버에 저장할 원래 값(formatFactValue 의 역). */ function toFactValue(display: string, ref: FactRef): string { let text = display.trim(); if (ref.unit && text.endsWith(ref.unit)) text = text.slice(0, -ref.unit.length).trim(); @@ -131,15 +97,7 @@ export function createFactSaver(store: StoreApi<FactSaveStoreSlice>): FactSaver return text; } - /** - * 지금 상태에서 밟을 전이 경로. 빈 배열이면 올릴 것이 없다. - * - * ★ 허용 전이는 백엔드 FACT_STATUS_TRANSITIONS(common/enums.py)가 유일한 소스다. - * 특히 UNVERIFIED → CORRECTED 는 그 표에 없다. 수집 직후 값(UNVERIFIED)을 사장님이 고치는 건 - * [확인] 탭에서 제일 흔한 동작인데, 한 번에 보내면 전부 FACT_INVALID_TRANSITION 으로 거절된다. - * 그래서 표에 실제로 있는 두 변(UNVERIFIED → PENDING_OWNER → CORRECTED)을 차례로 밟는다. - * 중간에서 끊겨도 PENDING_OWNER 는 발행 불가 상태라 미검증 값이 새어나가지 않는다(절대규칙 1). - */ + /** 지금 상태에서 밟을 전이 경로. */ function transitionPath(current: FactStatus, hasCorrection: boolean): FactStatus[] { if (!hasCorrection) { // [맞아요] — 이미 노출 가능한 값이면 보낼 것이 없다(VERIFIED → VERIFIED 는 표에 없다). @@ -151,13 +109,7 @@ export function createFactSaver(store: StoreApi<FactSaveStoreSlice>): FactSaver : [FactStatus.CORRECTED]; } - /** - * 전이 1건. PlaceDetailPage 의 useMutation 과 같은 옵션 모양을 렌더 트리 밖에서 쓰려고 - * 뮤테이션 캐시로 직접 실행한다. - * - * scope 를 fact 별로 주면 같은 fact 의 전이가 겹치지 않고 보낸 순서대로 실행된다 — - * 두 단계(PENDING_OWNER → CORRECTED)가 뒤집히면 두 번째가 거절당한다. - */ + /** 전이 1건. */ function runTransition( placeId: string, factId: string, @@ -170,9 +122,7 @@ export function createFactSaver(store: StoreApi<FactSaveStoreSlice>): FactSaver scope: {id: `fact-transition:${factId}`}, mutationFn: (req) => transitionFact(placeId, factId, req), onSuccess: (res) => { - // ★ 백엔드는 거절도 200 + result.success=false 로 준다(PlaceDetailPage 와 같은 규약). - // 여기서 throw 하지 않으면 거절이 성공으로 둔갑해, 저장 안 된 값이 '확인됨'으로 남는다. - // notifyApiError 가 읽는 자리(error.data)에 응답을 그대로 실어 보낸다. + // 백엔드는 거절도 200 + result.success=false 로 준다(PlaceDetailPage 와 같은 규약). if (res.result?.success === false) { throw new ApiError(200, 'FACT_TRANSITION_REJECTED', res); } @@ -193,8 +143,7 @@ export function createFactSaver(store: StoreApi<FactSaveStoreSlice>): FactSaver const entry: PendingFactSave = { seq: factSaveSeq, patch: {...existing?.patch, ...patch}, - // 되돌릴 지점은 '연속 편집이 시작되기 직전'이다 — 중간값으로 되돌리면 - // 저장에 실패했는데도 사장님이 쓰던 값이 확인된 것처럼 남는다. + // 되돌릴 지점은 '연속 편집이 시작되기 직전'이다 — 중간값으로 되돌리면 저장에 실패했는데도 사장님이 쓰던 값이 확인된 것처럼 남는다. before: existing?.before ?? before, announce: existing?.announce || announce, timer: existing?.timer, @@ -211,7 +160,6 @@ export function createFactSaver(store: StoreApi<FactSaveStoreSlice>): FactSaver })); } - /** 저장이 끝났다(성공이든 실패든). 그 사이 새 편집이 올라탔으면 그쪽이 끝낼 때까지 둔다. */ function settleFactSave(fieldId: string, seq: number) { const entry = pendingSaves.get(fieldId); if (!entry || entry.seq !== seq) return; @@ -220,13 +168,11 @@ export function createFactSaver(store: StoreApi<FactSaveStoreSlice>): FactSaver markSaving(fieldId, false); } - /** [정보] 탭 타이핑 — 잠깐 모았다가 마지막 값 하나만 올린다. */ function queueFactSave(fieldId: string, value: string, before?: InfoField) { const target = factSaveTarget(fieldId); if (!target) return; const next = toFactValue(value, target.ref); - // ★ 빈 값을 CORRECTED 로 올리면 서버의 노출값이 지워진다. 지우려는 건지 다시 쓰려고 - // 잠깐 비운 건지 구분할 수 없으므로 올리지 않는다(다음 리페치가 서버 값으로 되돌린다). + // 빈 값을 CORRECTED 로 올리면 서버의 노출값이 지워진다. if (!next) return; const entry = stageFactSave(fieldId, {value, isVerified: true}, before, false); if (entry.timer) clearTimeout(entry.timer); @@ -240,9 +186,7 @@ export function createFactSaver(store: StoreApi<FactSaveStoreSlice>): FactSaver function saveFactNow(fieldId: string, correctedValue?: string, before?: InfoField) { const target = factSaveTarget(fieldId); if (!target) { - // ★ 실사업장인데 올릴 fact 가 없다 = 상호·주소·전화다. 조용히 넘어가면 서버에 없는 승인을 - // 화면만 '확인됨'으로 들고 있게 된다 — 발행 판단이 허상이 되는 바로 그 경우라, 알린다. - // (이 값들을 확정하는 창구는 백엔드에 동일 업소 검증뿐이다. 데모에서는 아무 말도 하지 않는다.) + // 실사업장인데 올릴 fact 가 없다 = 상호·주소·전화다. if (store.getState().placeId) { notify.warn( '이 항목은 여기서 저장되지 않습니다', @@ -291,26 +235,22 @@ export function createFactSaver(store: StoreApi<FactSaveStoreSlice>): FactSaver status = step; } - // 다음 편집이 올바른 출발 상태에서 시작하도록 참조도 같이 옮긴다 - // (연달아 고치면 CORRECTED → CORRECTED 로 가야 한다). + // 다음 편집이 올바른 출발 상태에서 시작하도록 참조도 같이 옮긴다 (연달아 고치면 CORRECTED → CORRECTED 로 가야 한다). store.setState((state) => ({ factRefs: {...state.factRefs, [fieldId]: {...target.ref, status}}, })); - if (pendingSaves.get(fieldId)?.seq !== seq) return; // 새 편집이 올라탔다 — 그쪽이 마무리한다 + if (pendingSaves.get(fieldId)?.seq !== seq) return; if (entry.announce) notify.success('반영했습니다'); - // ★ 무효화 타이밍 — 리페치가 끝난 뒤에 낙관적 표시를 푼다. 먼저 풀면 새 응답이 도착하기 - // 전까지 옛 서버 값이 잠깐 그려진다. 키는 생성물이 정한 것을 그대로 쓴다 - // (getListFactsQueryKey) — usePlaceSync·PlaceDetailPage 와 같은 캐시 항목이어야 한다. + // 무효화 타이밍 — 리페치가 끝난 뒤에 낙관적 표시를 푼다. await queryClient .invalidateQueries({queryKey: getListFactsQueryKey(target.placeId)}) .catch(() => undefined); settleFactSave(fieldId, seq); } catch (error) { if (pendingSaves.get(fieldId)?.seq !== seq) return; - // ★ 실패했는데 화면에 '확인됨'으로 남으면, 검증 안 된 값이 검증된 것처럼 발행된다. - // 그래서 편집 직전 값으로 되돌리고 반드시 알린다. + // 실패했는데 화면에 '확인됨'으로 남으면, 검증 안 된 값이 검증된 것처럼 발행된다. rollbackField(fieldId, entry.before); settleFactSave(fieldId, seq); notifyApiError(error, '저장하지 못했습니다. 값을 되돌렸습니다.'); diff --git a/solution/frontend/src/features/builder/palette.ts b/solution/frontend/src/features/builder/palette.ts index 2d5d022..2956327 100644 --- a/solution/frontend/src/features/builder/palette.ts +++ b/solution/frontend/src/features/builder/palette.ts @@ -2,7 +2,7 @@ import {mix} from '@/lib/color'; /** 팔레트 → 디자인 토큰 유도. */ -/** `#rrggbb` 로 정규화. 못 읽으면 null. */ +/** `#rrggbb` 로 정규화. */ function normalizeHex(raw: string): string | null { const m = raw.trim().replace(/^#/, ''); if (/^[0-9a-fA-F]{3}$/.test(m)) { @@ -16,7 +16,7 @@ function rgb(hex: string): [number, number, number] { return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } -/** 상대 휘도(WCAG). 0=검정 1=흰색. 대비 판단의 기준. */ +/** 상대 휘도(WCAG). */ export function luminance(hex: string): number { const [r, g, b] = rgb(hex).map((v) => { const c = v / 255; @@ -25,7 +25,7 @@ export function luminance(hex: string): number { return 0.2126 * r + 0.7152 * g + 0.0722 * b; } -/** HSL 채도. 회색인지 색이 있는지 가른다. */ +/** HSL 채도. */ function saturation(hex: string): number { const [r, g, b] = rgb(hex).map((v) => v / 255); const max = Math.max(r, g, b); @@ -38,13 +38,13 @@ function saturation(hex: string): number { /** 본문 크기 글자의 최소 대비(WCAG AA). */ const MIN_CONTRAST = 4.5; -/** WCAG 명도 대비비. 4.5 이상이면 본문 크기 글자가 읽힌다. */ +/** WCAG 명도 대비비. */ export function contrast(a: string, b: string): number { const [hi, lo] = luminance(a) > luminance(b) ? [luminance(a), luminance(b)] : [luminance(b), luminance(a)]; return (hi + 0.05) / (lo + 0.05); } -/** 흰 글씨를 얹을 수 있는가. primary·inverse 가 지켜야 하는 조건이다. */ +/** 흰 글씨를 얹을 수 있는가. */ export function carriesWhiteText(hex: string): boolean { return contrast(hex, '#ffffff') >= MIN_CONTRAST; } diff --git a/solution/frontend/src/features/builder/placeAdapter.ts b/solution/frontend/src/features/builder/placeAdapter.ts index 611cf48..fd46c74 100644 --- a/solution/frontend/src/features/builder/placeAdapter.ts +++ b/solution/frontend/src/features/builder/placeAdapter.ts @@ -1,19 +1,11 @@ -/** - * 백엔드 응답 → 캔버스 데이터. - * - * ★ 순수 함수만 둔다. 스토어도 네트워크도 모른다 — 입력(place·fact·media·스키마)이 같으면 - * 출력이 항상 같다. 그래서 "화면이 왜 저 값을 보여주나"를 이 파일만 읽고 답할 수 있다. - * - * ★ 필드명은 지어내지 않는다. 기준은 backend/router/v1/place/protocol.py 의 PlaceData 와 - * backend/router/v1/fact/protocol.py 의 FactData · FieldSpecData 다. - */ +/** 백엔드 응답 → 캔버스 데이터. */ import type {IndustryType, InfoField, PhotoItem} from '@o2o/shared'; import {isPublishableFact, PlaceCategory, SourceType} from '@o2o/shared'; import type {FactData, FieldSpecData, MediaData, PlaceData} from '@/api'; import {FALLBACK_INDUSTRY} from '@/data/industryData'; import type {FactRef, LivePlaceInput} from '@/stores/builderTypes'; -/** places.category → 빌더 업종. 어떤 업종 시드(섹션·템플릿·문구)를 깔지가 여기서 갈린다. */ +/** places.category → 빌더 업종. */ export const CATEGORY_TO_INDUSTRY: Record<number, IndustryType> = { [PlaceCategory.LODGING]: 'stay', [PlaceCategory.CAFE]: 'cafe', @@ -21,7 +13,7 @@ export const CATEGORY_TO_INDUSTRY: Record<number, IndustryType> = { [PlaceCategory.CLINIC]: 'clinic', }; -/** facts.source_type — 사장님이 [맞아요] 를 누를 판단 근거. 출처 없는 값은 보여주지 않는다. */ +/** facts.source_type — 사장님이 [맞아요] 를 누를 판단 근거. */ const SOURCE_LABEL: Record<number, string> = { [SourceType.OWNER]: '사장님 입력', [SourceType.API]: '외부 API', @@ -29,7 +21,7 @@ const SOURCE_LABEL: Record<number, string> = { [SourceType.LLM]: 'AI 초안', }; -/** 출처 한 줄. URL 은 통째로 쓰면 패널을 밀어내므로 호스트만 남긴다. */ +/** 출처 한 줄. */ function factSource(fact: FactData): string { const label = SOURCE_LABEL[fact.source_type] ?? '출처 미상'; if (!fact.source_url) return label; @@ -40,12 +32,7 @@ function factSource(fact: FactData): string { } } -/** - * fact 값을 사람이 읽는 문장으로. - * - * bool 은 'true' 가 그대로 화면에 나가면 안 되고, number 는 천 단위 구분이 없으면 - * 사장님도 AI 도 자릿수를 세야 한다(shared/lib/facts.ts 의 factText 와 같은 규칙). - */ +/** fact 값을 사람이 읽는 문장으로. */ function formatFactValue(fact: FactData, spec?: FieldSpecData): string { const raw = (fact.value ?? '').trim(); if (!raw) return ''; @@ -58,18 +45,12 @@ function formatFactValue(fact: FactData, spec?: FieldSpecData): string { return `${raw}${unit}`; } -/** place 가 소유하는 줄. fact 가 아니므로 전이시킬 대상이 없다(id 가 겹치면 안 된다). */ +/** place 가 소유하는 줄. */ const IDENTITY_FIELD_IDS = new Set(['name', 'address', 'phone']); -/** - * place 응답의 신원 정보(상호·주소·전화). - * - * fact 가 아직 하나도 없어도 이건 채워진다 — 수집 전 사업장을 열어도 캔버스가 빈 껍데기가 - * 되지 않게. id 는 데모 시드와 같은 이름을 쓴다(캔버스 푸터가 'address' 를 이름으로 찾는다). - */ +/** place 응답의 신원 정보(상호·주소·전화). */ function identityFields(place: PlaceData): InfoField[] { - // ★ verified_at 이 NULL 이면 동일 업소 검증 전이다 — 주소·전화가 동명 업소의 것일 수 있다. - // 그래서 '확인 필요'로 올린다: 캔버스가 가리고, 발행 게이트가 잡는다(절대규칙 1). + // verified_at 이 NULL 이면 동일 업소 검증 전이다 — 주소·전화가 동명 업소의 것일 수 있다. const verified = Boolean(place.verified_at); const address = place.road_address ?? place.address ?? ''; @@ -109,12 +90,7 @@ function identityFields(place: PlaceData): InfoField[] { return fields; } -/** - * key 당 대표 fact 1건(scope='unit' 인 값은 뺀다). - * - * ★ 정보 표와 저장 배선이 반드시 같은 fact 를 가리켜야 한다 — 서로 다른 걸 고르면 - * 화면에서 보고 있는 줄과 실제로 전이시키는 fact 가 어긋나, 엉뚱한 값이 '확인됨'이 된다. - */ +/** key 당 대표 fact 1건(scope='unit' 인 값은 뺀다). */ function placeFactsByKey(facts: FactData[]): Map<string, FactData> { const byKey = new Map<string, FactData>(); for (const fact of facts) { @@ -124,19 +100,19 @@ function placeFactsByKey(facts: FactData[]): Map<string, FactData> { return byKey; } -/** 정보 표의 줄 id → 어떤 fact 를 전이시킬지. 화면과 같은 규칙(placeFactsByKey)으로 고른다. */ +/** 정보 표의 줄 id → 어떤 fact 를 전이시킬지. */ function toFactRefs(facts: FactData[], specs: FieldSpecData[]): Record<string, FactRef> { const specByKey = new Map(specs.map((spec) => [spec.key, spec])); const refs: Record<string, FactRef> = {}; for (const [key, fact] of placeFactsByKey(facts)) { - // 신원 줄과 id 가 겹치면 사장님이 보는 줄과 다른 fact 를 고치게 된다. 그럴 바엔 로컬로 둔다. + // 신원 줄과 id 가 겹치면 사장님이 보는 줄과 다른 fact 를 고치게 된다. if (IDENTITY_FIELD_IDS.has(key)) continue; const spec = specByKey.get(key); refs[key] = { factId: fact.fact_id, status: fact.status, type: spec?.type, - // 단위는 fact 가 우선이다(수집 시점의 값). 없으면 업종 스키마의 단위. + // 단위는 fact 가 우선이다(수집 시점의 값). unit: fact.unit ?? spec?.unit ?? undefined, }; } @@ -150,7 +126,7 @@ function factField(fact: FactData, spec?: FieldSpecData): InfoField { id: fact.key, label: spec?.label ?? fact.key, value: formatFactValue(fact, spec), - // ★ 절대규칙 1 — VERIFIED · CORRECTED 가 아니면 캔버스가 가린다. + // 절대규칙 1 — VERIFIED · CORRECTED 가 아니면 캔버스가 가린다. requiresVerification: !publishable, isVerified: publishable, source: factSource(fact), @@ -160,15 +136,7 @@ function factField(fact: FactData, spec?: FieldSpecData): InfoField { }; } -/** - * place + fact + 업종 스키마 → 캔버스의 infoFields. - * - * 줄 순서는 업종 스키마(FieldSpecData)가 정한다. 수집된 순서대로 늘어놓으면 재수집이 - * 돌 때마다 표의 줄 순서가 바뀐다 — 사장님 눈에는 값이 바뀐 것처럼 보인다. - * - * ★ scope='unit' 인 fact(객실·메뉴별 값)는 넣지 않는다. 같은 key 가 단위 수만큼 들어와 - * '기본 정보' 표에 같은 라벨이 여러 줄 겹친다 — 단위별 값은 객실/메뉴 섹션의 몫이다. - */ +/** place + fact + 업종 스키마 → 캔버스의 infoFields. */ function toInfoFields(place: PlaceData, facts: FactData[], specs: FieldSpecData[]): InfoField[] { const placeFacts = placeFactsByKey(facts); @@ -190,20 +158,14 @@ function toInfoFields(place: PlaceData, facts: FactData[], specs: FieldSpecData[ return [...identityFields(place), ...ordered.filter((f) => f.value !== '')]; } -/** - * 서버 media → 캔버스 사진. - * - * ★ **발행 가능한 것만** 넘긴다(승인 + alt 있음). 미승인 사진을 캔버스에 그리면 - * 사장님은 그게 사이트에 나갈 것으로 읽는데, 발행 스냅샷은 그걸 걸러낸다 — - * 화면과 발행본이 갈리는 자리다. `publishable` 은 서버가 판정해 내려준다. - */ +/** 서버 media → 캔버스 사진. */ function toPhotos(media: MediaData[]): PhotoItem[] { return media .filter((m) => m.publishable !== false) .map((m, index) => ({ id: m.media_id, url: m.url ?? m.origin_url ?? '', - // alt 는 Vision 이 붙인 설명이다. 없으면 라벨(분류)로 떨어뜨린다. + // alt 는 Vision 이 붙인 설명이다. title: m.alt_text || m.label || '수집된 사진', category: m.label ?? '기타', isPrimary: index === 0, diff --git a/solution/frontend/src/features/builder/sections/addable.ts b/solution/frontend/src/features/builder/sections/addable.ts index 6a8c0d9..fe5484c 100644 --- a/solution/frontend/src/features/builder/sections/addable.ts +++ b/solution/frontend/src/features/builder/sections/addable.ts @@ -22,7 +22,7 @@ const META: Record<string, {description: string; thumb: ThumbKey}> = { itinerary: {description: '표 한 장이 정거장 하나인 승차권 일정.', thumb: 'carousel'}, }; -// dataSpec이 단일 출처다. 아이템을 하나 더 만들면 여기에도 자동으로 나타난다. +// dataSpec이 단일 출처다. export const ADDABLE_SECTIONS: AddableSection[] = Object.values(SECTION_DATA_SPEC).map((spec) => ({ type: spec.kind, name: spec.label, @@ -34,7 +34,7 @@ export function addableSection(type: string): AddableSection | undefined { return ADDABLE_SECTIONS.find((item) => item.type === type); } -// id는 타입과 같다. 같은 아이템을 두 번 넣지 않는다. +// id는 타입과 같다. export function newSectionOf(type: string): SectionItem | undefined { const addable = addableSection(type); if (!addable) return undefined; diff --git a/solution/frontend/src/features/builder/sections/dataSpec.ts b/solution/frontend/src/features/builder/sections/dataSpec.ts index c4697a2..9509b87 100644 --- a/solution/frontend/src/features/builder/sections/dataSpec.ts +++ b/solution/frontend/src/features/builder/sections/dataSpec.ts @@ -3,12 +3,12 @@ import {SECTION_PROMPT_RULES, SECTION_PROMPTS} from '@o2o/shared'; export interface SectionDataSpec { - /** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */ + /** JSON 봉투의 `kind`. */ kind: string; label: string; /** [예시 넣기] 가 그대로 넣는 값. */ sample: string; - /** 프롬프트의 '무엇을 시키나' 부분. 머리·공통규칙은 buildPrompt 가 붙인다. */ + /** 프롬프트의 '무엇을 시키나' 부분. */ task: string; /** 그 아이템에만 걸리는 금지·형식 규칙. */ rules: string; @@ -19,14 +19,14 @@ export interface SectionDataSpec { } export interface ItemField { - /** 항목 객체의 키. `@o2o/shared` 의 아이템 타입에 있는 이름 그대로다. */ + /** 항목 객체의 키. */ key: string; label: string; /** text = 한 줄 · area = 여러 줄 · number = 숫자 · tags = 쉼표로 끊는 배열 · color = 색 */ type?: 'text' | 'area' | 'number' | 'tags' | 'color'; - /** 칸 아래 회색 한 줄. 무엇을 적는 칸인지 애매할 때만 적는다. */ + /** 칸 아래 회색 한 줄. */ hint?: string; - /** 고르는 값이면 목록. 계절처럼 오탈자가 곧 버그가 되는 칸에 쓴다. */ + /** 고르는 값이면 목록. */ options?: string[]; /** 두 칸씩 나란히 놓는다 — 짧은 값(연도·시각·분)이 한 줄을 다 먹지 않게. */ half?: boolean; @@ -44,9 +44,9 @@ export const SECTION_DATA_MAX_CHARS = 12000; export interface PromptContext { storeName: string; - /** 사업장 주소 원문. 여기서 시·군·구를 뽑아 '지역'으로 쓴다. */ + /** 사업장 주소 원문. */ location: string; - /** 업종 이름('숙박' · '카페' …). 어떤 손님에게 쓰는 글인지 알려 준다. */ + /** 업종 이름('숙박' · '카페' …). */ industryLabel: string; } diff --git a/solution/frontend/src/features/marketing/ShowcaseGrid.tsx b/solution/frontend/src/features/marketing/ShowcaseGrid.tsx index 3ec3925..8149590 100644 --- a/solution/frontend/src/features/marketing/ShowcaseGrid.tsx +++ b/solution/frontend/src/features/marketing/ShowcaseGrid.tsx @@ -18,22 +18,12 @@ const CATEGORY_LABEL: Record<number, string> = { [PlaceCategory.CLINIC]: '피부과 · 성형외과', }; -/** - * 발행 사이트 주소는 루트 상대경로로 온다(`/s/<slug>`). - * - * ★ window 로 떨어지지 않는다. 이 컴포넌트는 랜딩(`/`)과 `/showcase` 에 있고 그 둘은 - * 프리렌더 대상이다 — 빌드 때 브라우저 없이 한 번 그려지므로 window 를 만지면 죽는다. - */ +/** 발행 사이트 주소는 루트 상대경로로 온다(`/s/<slug>`). */ function siteHref(url: string): string { return `${ORIGIN}${url}`; } -/** - * 실제로 발행된 사이트를 그대로 건다. - * - * ★ 예시 데이터로 채우지 않는다. 이 섹션이 파는 건 "진짜로 나갔다"는 사실 하나이고, - * 가짜를 걸면 그 자리에서 가치가 0 이 된다. 발행본이 없으면 섹션을 통째로 감춘다. - */ +/** 실제로 발행된 사이트를 그대로 건다. */ export function ShowcaseGrid({limit = 6}: {limit?: number}) { const [items, setItems] = useState<ShowcaseItem[] | null>(null); @@ -76,7 +66,7 @@ function ShowcaseCard({item}: {item: ShowcaseItem}) { className="size-full object-cover transition-transform duration-300 group-hover:scale-[1.02]" /> ) : ( - // ★ 썸네일은 발행에 성공한 뒤에만 채워진다 — 없는 건 정상이다(사진 없는 가게). + // 썸네일은 발행에 성공한 뒤에만 채워진다 — 없는 건 정상이다(사진 없는 가게). <ImageOff className="size-6 text-muted-foreground/50" aria-hidden /> )} </div> @@ -105,15 +95,7 @@ function SkeletonCard() { ); } -/** - * 히어로 아래 발행 사이트 마퀴 — 옆으로 계속 흐른다. - * - * ★ 애니메이션은 **CSS 만으로** 돈다(index.css `o2o-marquee`). setInterval 로 인덱스를 돌리면 - * 탭이 백그라운드일 때 프레임이 밀려 돌아왔을 때 툭 끊긴 것처럼 보인다. - * ★ 같은 목록을 두 벌 그린다 — 트랙을 -50% 까지만 밀면 이음매 없이 이어진다. - * 한 벌만 그리면 끝에서 빈 화면이 지나간다. - * ★ 여기도 실물이다. 발행된 사이트가 없으면 아무것도 그리지 않는다. - */ +/** 히어로 아래 발행 사이트 마퀴 — 옆으로 계속 흐른다. */ export function ShowcasePeeks({limit = 10}: {limit?: number}) { const [items, setItems] = useState<ShowcaseItem[]>([]); diff --git a/solution/frontend/src/features/marketing/showcaseApi.ts b/solution/frontend/src/features/marketing/showcaseApi.ts index e2e9ea2..0af8f3a 100644 --- a/solution/frontend/src/features/marketing/showcaseApi.ts +++ b/solution/frontend/src/features/marketing/showcaseApi.ts @@ -1,13 +1,7 @@ import type {IndustryType} from '@o2o/shared'; import {PlaceCategory} from '@o2o/shared'; -/** - * 발행된 사이트 목록 — 랜딩의 "이렇게 나옵니다" 가 쓴다. - * - * ★ 생성 클라이언트(@/api)를 쓰지 않는다. 그쪽은 토큰을 꽂는 길목(api/mutator)을 지나는데 - * 이 엔드포인트는 **인증이 없고 비로그인 방문자가 부른다** — 토큰이 없어도, 만료됐어도 - * 똑같이 나와야 한다. 여기서 fetch 를 직접 쓰는 이유가 그거다. - */ +/** 발행된 사이트 목록 — 랜딩의 "이렇게 나옵니다" 가 쓴다. */ const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9800'; export type ShowcaseItem = { diff --git a/solution/frontend/src/features/onboarding/ChannelConfirmPanel.tsx b/solution/frontend/src/features/onboarding/ChannelConfirmPanel.tsx index 807e6e3..759196a 100644 --- a/solution/frontend/src/features/onboarding/ChannelConfirmPanel.tsx +++ b/solution/frontend/src/features/onboarding/ChannelConfirmPanel.tsx @@ -5,13 +5,7 @@ import {Button} from '@/components/ui/button'; import {cn} from '@/lib/utils'; import {CHANNEL_LABEL} from './useCollectFlow'; -/** - * 수집 잡이 찾아온 채널 URL 을 사장님이 확정하는 패널. - * - * ★ 이 화면이 파이프라인에서 사람이 반드시 개입해야 하는 지점이다. - * 확정 없이 긁으면 이름이 비슷한 남의 가게 페이지가 이 가게의 공식 홈페이지에 실린다. - * 그래서 기본값은 '확정 안 함'이고, 누른 것만 크롤링 대상이 된다. - */ +/** 수집 잡이 찾아온 채널 URL 을 사장님이 확정하는 패널. */ export function ChannelConfirmPanel({ links, confirmedCount, diff --git a/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx b/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx index 6f19365..760c155 100644 --- a/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx +++ b/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx @@ -5,26 +5,9 @@ import {LinkChannel} from '@o2o/shared'; import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; -/** - * 채널 URL 직접 등록 — **수집의 1순위 경로다.** 폴백이 아니다. - * - * ★ 왜 자동 발견보다 이게 먼저인가 (실측 2026-08-27) - * · Perplexity 는 네이버 플레이스를 **한 건도** 못 찾았다(도플로·버터브루·힐튼 가든 인 - * 서울 강남 전부 야놀자만 물어옴). 도메인 필터를 넓혔더니 이번엔 네이버 도움말 페이지 - * (pages.map.naver.com/useful-tips)를 이 가게 채널로 등록했다. 그래서 기본 경로에서 뺐다. - * · 상호로 place id 를 자동 해석하는 경로도 4곳 중 3곳만 성공한다('롯데호텔 서울' 실패). - * · 반면 **네이버 플레이스 URL 만 있으면 100% 된다** — 어댑터가 fact 5~7건 + 사진 10장을 - * 안정적으로 가져온다(힐튼·스테이머뭄·롯데월드에서 검증). - * 그리고 사장님은 자기 가게 주소를 이미 알고 있다. 추측하게 두는 것보다 물어보는 것이 - * 빠르고 정확하고 공짜다. - * - * 그래서 이 컴포넌트는 "네이버 지도 주소"를 전제로 말한다 — 다른 채널도 받지만, - * 사장님이 따라 할 동작 하나는 네이버 지도에서 주소를 복사하는 것이다. - * - * 붙여넣은 URL 은 그 자체로 사장님이 확인한 값이므로 등록과 동시에 확정한다. - */ +/** 채널 URL 직접 등록 — **수집의 1순위 경로다.** 폴백이 아니다. */ -/** 호스트로 채널을 판정한다. 서버도 같은 규칙을 쓰지만, 화면이 먼저 라벨을 보여줘야 한다. */ +/** 호스트로 채널을 판정한다. */ function guessChannel(url: string): {channel: LinkChannelCode; label: string} | null { const u = url.toLowerCase(); if (u.includes('naver.com') || u.includes('naver.me')) { @@ -32,8 +15,7 @@ function guessChannel(url: string): {channel: LinkChannelCode; label: string} | if (u.includes('blog.naver') || u.includes('cafe.naver')) return null; return {channel: LinkChannel.NAVER_PLACE, label: '네이버 플레이스'}; } - // 네이버 예약은 플레이스보다 먼저 본다 — 호스트가 booking.naver.com 이라 플레이스 판정에 안 걸리지만, - // 순서를 명시해 두지 않으면 나중에 판정 조건이 넓어질 때 조용히 플레이스로 흡수된다. + // 네이버 예약은 플레이스보다 먼저 본다 — 호스트가 booking.naver.com 이라 플레이스 판정에 안 걸리지만, 순서를 명시해 두지 않으면 나중에 판정 조건이 넓어질 때 조용히 플레이스로 흡수된다. if (u.includes('booking.naver.com')) return {channel: LinkChannel.NAVER_BOOKING, label: '네이버 예약'}; if (u.includes('yanolja.com')) return {channel: LinkChannel.YANOLJA, label: '야놀자'}; if (u.includes('goodchoice.kr') || u.includes('yeogi.com')) { @@ -94,8 +76,7 @@ export function ChannelUrlInput({ 까지 그대로 가져옵니다. </p> - {/* ★ 여기에 바로가기가 없으면 사장님은 새 탭을 열고 상호를 다시 타이핑해야 한다. - "찾아 보세요" 라고만 하고 갈 곳을 안 주면 그 자리에서 멈춘다. */} + {/* 여기에 바로가기가 없으면 사장님은 새 탭을 열고 상호를 다시 타이핑해야 한다. */} {searchQuery?.trim() && ( <a href={`https://map.naver.com/p/search/${encodeURIComponent(searchQuery.trim())}`} @@ -132,8 +113,7 @@ export function ChannelUrlInput({ placeholder="https://map.naver.com/p/entry/place/1273971279" disabled={isBusy} /> - {/* ★ '추가' 가 아니다. 이 버튼은 등록에서 끝나지 않고 **그 주소에서 정보를 가져온다.** - '추가' 라고 쓰면 사장님은 목록에 한 줄 늘기를 기다리다가 아무 변화가 없어 실패로 읽는다. */} + {/* '추가' 가 아니다. */} <Button size="sm" variant="primary" onClick={() => void submit()} disabled={isBusy || !url.trim()}> {isBusy ? <Loader2 className="animate-spin" /> : <Search />} <span>{isBusy ? '가져오는 중' : '이 주소로 가져오기'}</span> diff --git a/solution/frontend/src/features/onboarding/PasteFactsPanel.tsx b/solution/frontend/src/features/onboarding/PasteFactsPanel.tsx index eb926d2..fa9c817 100644 --- a/solution/frontend/src/features/onboarding/PasteFactsPanel.tsx +++ b/solution/frontend/src/features/onboarding/PasteFactsPanel.tsx @@ -7,26 +7,7 @@ import {Button} from '@/components/ui/button'; import {notify, notifyApiError} from '@/lib/notify'; import {cn} from '@/lib/utils'; -/** - * 붙여넣기로 정보 채우기 — **폴백 3단계의 2번**이다. - * - * ★ 왜 필요한가 (실측 2026-08-31) - * 자동 수집만으로는 근거가 안 차는 업소가 흔하다. - * · 조이모텔: TourAPI 미등록(모텔은 관광공사 대상이 아니다) + 네이버엔 요금표뿐 → - * 수집 fact 6건이 전부 '대실 20,000 / 숙박 40,000' 같은 요금이었다. - * 체크인·주차·와이파이가 하나도 없어 소개문도 FAQ 도 만들 수 없었다. - * 이런 업소는 사장님이 이미 알고 있는 내용을 **붙여넣게 하는 것이 가장 빠르다.** - * 홈페이지 URL 이 있으면 static_html 어댑터가 대신 읽지만, 그것도 없는 업소가 많다. - * - * ★ 사장님이 쓴 글을 그대로 싣지 않는다 - * 붙여넣은 원문은 저장되지 않는다. 서버가 업종 스키마 필드만 **뽑아서** 후보로 넣고, - * 그 값의 근거 문장이 원문에 실제로 있는지까지 대조한다(services/grounding/extract.py). - * 그래서 "조식 제공합니다" 를 안 썼는데 조식이 켜지는 일이 없다. - * - * ★ 결과를 통과분만 보여주지 않는다 - * 버려진 항목과 사유를 같이 띄운다 — 사장님이 "내가 적은 체크인 시간이 왜 안 들어갔지" - * 를 화면에서 바로 알 수 있어야 한다. 조용히 버리면 신뢰를 잃는다. - */ +/** 붙여넣기로 정보 채우기 — **폴백 3단계의 2번**이다. */ const PLACEHOLDER = `가게 소개, 이용 안내, 객실·메뉴 정보를 그대로 붙여넣어 주세요. @@ -45,7 +26,7 @@ export function PasteFactsPanel({placeId, className}: {placeId: string | null; c const queryClient = useQueryClient(); const extract = useExtractFactsFromText(); - // 데모(placeId 없음)에는 저장할 서버 상태가 없다. 눌러도 아무 일이 없는 버튼을 두지 않는다. + // 데모(placeId 없음)에는 저장할 서버 상태가 없다. if (!placeId) return null; const tooShort = text.trim().length < MIN_CHARS; @@ -133,7 +114,7 @@ export function PasteFactsPanel({placeId, className}: {placeId: string | null; c </ul> )} - {/* ★ 버린 것도 보여준다. 사장님이 왜 안 들어갔는지 알아야 다시 쓸 수 있다. */} + {/* 버린 것도 보여준다. */} {result.rejections.length > 0 && ( <details className="text-[10px] text-muted-foreground"> <summary className="cursor-pointer select-none">제외된 항목 보기</summary> diff --git a/solution/frontend/src/features/onboarding/PlaceUrlBox.tsx b/solution/frontend/src/features/onboarding/PlaceUrlBox.tsx index 9635b5c..5156b24 100644 --- a/solution/frontend/src/features/onboarding/PlaceUrlBox.tsx +++ b/solution/frontend/src/features/onboarding/PlaceUrlBox.tsx @@ -3,16 +3,7 @@ import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {cn} from '@/lib/utils'; -/** - * 자동 상호 검색이 실패했을 때 네이버 지도에서 직접 찾아 URL로 확정하는 fallback. - * - * ★ 왜 이 fallback 이 필요한가 - * 상호 검색은 동명 업소·지점명 표기 차이에서 실패한다(실측: '롯데호텔 서울'). - * 검색모델(Perplexity)은 네이버 플레이스를 아예 못 찾고 안내 페이지를 물어온 적도 있다. - * 반면 사장님은 자기 가게 주소를 이미 안다 — 붙여넣는 순간 신원과 수집 채널이 동시에 - * 확정되고, 상호·주소·좌표는 서버가 그 URL 에서 직접 읽는다. - * (선례: o2o-castad 도 네이버 지도 URL 직접 입력을 주 경로로 둔다.) - */ +/** 자동 상호 검색이 실패했을 때 네이버 지도에서 직접 찾아 URL로 확정하는 fallback. */ export function PlaceUrlBox({ value, onChange, diff --git a/solution/frontend/src/features/onboarding/RecollectPanel.tsx b/solution/frontend/src/features/onboarding/RecollectPanel.tsx index 713ccf0..5395193 100644 --- a/solution/frontend/src/features/onboarding/RecollectPanel.tsx +++ b/solution/frontend/src/features/onboarding/RecollectPanel.tsx @@ -4,14 +4,7 @@ import {Button} from '@/components/ui/button'; import {cn} from '@/lib/utils'; import {useCollectFlow, type RecollectTask} from './useCollectFlow'; -/** - * 세 가지 재수집 동작. - * - * ★ 문구가 "다시 가져옵니다"로 읽히면 안 된다. 자동 수집(CRAWL)은 **노출 중인 값을 못 바꾼다** — - * 달라진 값은 확인 대기 후보로 쌓이고, 사장님이 [맞아요]를 눌러야 교체된다. 사장님이 직접 - * 고친 값(CORRECTED)은 아예 잠겨 있다(fact_service 의 업데이트 프로세스). - * 그래서 hint 는 전부 "후보로 올립니다 / 확인 뒤 나갑니다"로 끝난다. - */ +/** 세 가지 재수집 동작. */ const ACTIONS: { task: RecollectTask; label: string; @@ -42,32 +35,22 @@ const ACTIONS: { }, ]; -/** - * 재수집 패널 — 사업장 상세와 빌더 에디터가 같이 쓴다. - * - * ★ 왜 필요한가: 재수집 버튼은 지금까지 위저드 3단계에만 있었다. 위저드를 끝내고 나면 - * **이미 만든 사업장을 다시 긁을 길이 없어서** 사장님 눈에는 "재크롤링 기능이 없다"였다. - * 그래서 사업장을 다시 여는 두 자리(상세 · 에디터)에 같은 패널을 붙인다. - * - * ★ 배선은 위저드와 **같은 훅**(useCollectFlow)이다. 수집 경로를 두 벌 만들면 확정 게이트 - * 같은 규칙이 한쪽에서만 지켜진다 — 다른 건 force 플래그뿐이다. - */ +/** 재수집 패널 — 사업장 상세와 빌더 에디터가 같이 쓴다. */ export function RecollectPanel({placeId, className}: {placeId: string | null; className?: string}) { const enabled = Boolean(placeId); // 이미 다른 화면이 읽어둔 캐시를 그대로 본다(같은 쿼리 키) — 이 패널 때문에 새로 요청하지 않는다. const placeQuery = useGetPlace(placeId ?? '', {query: {enabled}}); const collect = useCollectFlow(placeId); - // 데모(placeId 없음)에는 재수집할 서버 상태 자체가 없다. 눌러도 아무 일이 없는 버튼을 두지 않는다. + // 데모(placeId 없음)에는 재수집할 서버 상태 자체가 없다. if (!placeId) return null; const isVerified = Boolean(placeQuery.data?.place?.verified_at); const busy = collect.recollectingTask; - /** 지금 이 동작을 못 누르는 이유. 없으면 누를 수 있다. ★ 이유 없이 회색으로만 두지 않는다. */ + /** 지금 이 동작을 못 누르는 이유. */ const blockedReason = (task: RecollectTask): string | null => { // 검증·확정 게이트는 크롤링에만 걸린다(place_service.start_collect). - // 사진 분석·소개문 생성은 이미 쌓인 데이터를 다루므로 서버가 따로 막지 않는다. if (task !== 'facts') return null; if (!isVerified) return '동일 업소 검증을 끝내야 수집이 열립니다.'; if (collect.confirmedCount === 0) return '확정한 채널 URL 이 없습니다. 먼저 채널을 확정해 주세요.'; @@ -110,13 +93,13 @@ export function RecollectPanel({placeId, className}: {placeId: string | null; cl </div> {busy && ( - // 잡은 몇 분짜리다. "누르긴 눌렀는데 아무 일도 안 난다"로 읽히지 않게 소요 시간을 적는다. + // 잡은 몇 분짜리다. <p className="rounded-lg border border-border bg-muted/40 p-2.5 text-[11px] leading-relaxed"> 몇 분 걸립니다. 끝나면 이 화면이 새 값으로 바뀝니다 — 그때까지 화면을 열어두세요. </p> )} - {/* ★ 유료 API 재호출 안내. 겁주지 않되 숨기지도 않는다 — 누르면 돈이 나가는 버튼이다. */} + {/* 유료 API 재호출 안내. */} <p className="flex items-start gap-1.5 text-[10px] leading-relaxed text-muted-foreground"> <TriangleAlert className="mt-px size-3 shrink-0" /> <span> diff --git a/solution/frontend/src/features/onboarding/Step1Industry.tsx b/solution/frontend/src/features/onboarding/Step1Industry.tsx index e42eeaf..fbaa3ee 100644 --- a/solution/frontend/src/features/onboarding/Step1Industry.tsx +++ b/solution/frontend/src/features/onboarding/Step1Industry.tsx @@ -11,17 +11,7 @@ import {WizardSteps} from './WizardSteps'; const INDUSTRY_ORDER: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic']; -/** - * 업종 직접 고르기 — `?step=industry`. - * - * ★ 이 화면은 더 이상 위저드의 첫 화면이 아니다. 업종은 상호 검색 결과의 분류가 정하고 - * (services/place_category.py), 여기는 **못 정했을 때와 바꿀 때만** 들른다. - * 진행 표시에서 1번을 유지하는 이유도 같다 — 이건 '내 가게 확인' 안의 갈래다. - * - * ★ 못 정해서 들어온 경우에는 아무 카드도 선택된 것으로 보이지 않게 한다. 스토어에는 언제나 - * 기본 업종이 들어 있어서, 그걸 그대로 칠하면 사장님은 자기가 고르지도 않은 업종이 - * 이미 정해진 것으로 읽는다. - */ +/** 업종 직접 고르기 — `?step=industry`. */ export function Step1Industry() { const industry = useBuilderStore((s) => s.industry); const pendingPick = useBuilderStore((s) => s.pendingPick); @@ -39,12 +29,7 @@ export function Step1Industry() { selectIndustry(id); }; - /** - * 고른 업종을 들고 검색 화면으로 돌아간다. - * - * ★ 후보를 들고 왔으면 그 후보에 업종을 찍어 돌려보낸다 — 확정(사업장 생성)은 검색 화면 - * 한 곳에서만 한다. 두 화면이 각자 확정하면 같은 가게가 두 번 만들어지는 길이 생긴다. - */ + /** 고른 업종을 들고 검색 화면으로 돌아간다. */ const goBack = () => { if (pendingPick) setPendingPick({...pendingPick, industry}); goToStep('search'); diff --git a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx index 961ae28..3869794 100644 --- a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx +++ b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx @@ -32,16 +32,7 @@ const STAGE_DESCRIPTION: Record<string, string> = { confirmed: '이제 이 가게의 공개 채널에서 정보를 수집합니다.', }; -/** - * 1단계 — 내 가게 확인. **위저드의 시작점이다.** - * - * ★ 예전에는 업종 선택이 앞에 있었다. 그런데 사장님이 100% 아는 건 자기 가게 **이름**이고, - * 업종은 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그래서 순서를 뒤집었다 — - * 상호명을 먼저 받고, 업종은 검색 결과의 분류가 정한다(services/place_category.py). - * 못 정했을 때만 업종 화면(`?step=industry`)으로 넘긴다. - * - * 한 화면에 한 가지만 묻는다: 상호를 묻거나 · 기다리거나 · 후보를 고르게 하거나 · 확정을 보여준다. - */ +/** 1단계 — 내 가게 확인. */ export function Step2PlaceSearch() { const industry = useBuilderStore((s) => s.industry); const storeName = useBuilderStore((s) => s.storeName); @@ -59,9 +50,7 @@ export function Step2PlaceSearch() { const search = usePlaceSearch(confirmedIdentity?.placeId ?? null); const [, goToStep] = useWizardStep(); const [placeUrl, setPlaceUrl] = useState(''); - /** ★ 후보가 있으면 붙여넣기 칸을 접는다. 펼쳐 두면 "자동으로 못 찾았다"는 신호로 읽힌다 — - * 이 시점에는 자동 발견을 시도조차 안 했다(수집 직전에 서버가 한다). 목록에 내 가게가 - * 없을 때만 여는 탈출구다. */ + /** 후보가 있으면 붙여넣기 칸을 접는다. */ const [urlBoxOpen, setUrlBoxOpen] = useState(false); /** 고른 후보가 들고 온 네이버 플레이스 주소(검색 단계에서 서버가 찾아 준 값). */ const naverUrlRef = useRef<string | null>(null); @@ -71,15 +60,7 @@ export function Step2PlaceSearch() { const canSearch = storeName.trim().length > 0 && search.phase !== 'searching'; - /** - * 확정된 사업장을 주소창에 남기며 다음 단계로. - * - * ★ `?placeId=` 가 새로고침을 넘기는 유일한 수단이다. 위저드 상태는 브라우저에 저장하지 않는다 - * (stores/builder 주석): 저장하면 새 가게를 만들러 들어와도 지난 가게가 확정된 것으로 떠 버린다. - * 주소창에 두면 새로고침은 서버에서 복원되고(usePlaceSync), 주소를 새로 열면 깨끗하다. - * ★ 단계와 사업장을 **한 번에** 바꾼다. 나눠 쓰면 그 사이에 "placeId 는 붙었는데 아직 1단계"인 - * 주소가 히스토리에 한 칸 생기고, 뒤로가기가 그 칸에 걸린다. - */ + /** 확정된 사업장을 주소창에 남기며 다음 단계로. */ const advance = (placeId: string | null) => { goToStep('collect', { params: {placeId, flow: placeId ? 'onboarding' : null}, @@ -97,12 +78,7 @@ export function Step2PlaceSearch() { advance(identity.placeId); }; - /** - * 업종 화면을 다녀온 후보를 이어서 확정한다. - * - * ★ 업종이 **찍혀서** 돌아온 것만 이어간다. 뒤로가기로 돌아온 경우(업종을 안 고른 경우)까지 - * 확정하면 사장님이 고르지도 않은 기본 업종으로 사업장이 만들어진다. - */ + /** 업종 화면을 다녀온 후보를 이어서 확정한다. */ const resumed = useRef(false); useEffect(() => { const industryFromPicker = pendingPick?.industry; @@ -112,20 +88,13 @@ export function Step2PlaceSearch() { // eslint-disable-next-line react-hooks/exhaustive-deps -- 한 번만 이어붙이는 일이라 pendingPick 만 본다 }, [pendingPick]); - /** - * 후보 하나를 골랐다 — 여기서 업종이 정해진다. - * - * ★ `category` 는 서버가 외부 분류에서 **추정한** 값이고, 못 정하면 키 자체가 없다 - * (RemoveNoneResponse 가 null 필드를 지운다). 억지로 하나를 고르지 않고 사장님에게 묻는다 — - * 업종은 수집 스키마와 JSON-LD 타입을 통째로 정하는 값이라 틀리면 되돌리는 값이 비싸다. - */ + /** `category` 는 서버가 외부 분류에서 **추정한** 값이고, 못 정하면 키 자체가 없다 (RemoveNoneResponse 가 null 필드를 지운다). */ const choose = (item: PlaceSearchItem) => { const pick: PendingPick = { name: item.name ?? '', address: item.road_address ?? '', }; - // ★ 검색 단계에서 서버가 이미 찾아 둔 플레이스 주소를 그대로 물고 간다. - // 이게 있으면 수집 직전에 다시 찾을 필요가 없고, 사장님에게 지도 주소를 묻지도 않는다. + // 검색 단계에서 서버가 이미 찾아 둔 플레이스 주소를 그대로 물고 간다. naverUrlRef.current = item.naver_place_url ?? null; const guessed = item.category != null ? (CATEGORY_TO_INDUSTRY[item.category] ?? null) : null; if (!guessed) { @@ -160,14 +129,7 @@ export function Step2PlaceSearch() { advance(identity.placeId); }; - /** - * 업종을 바꾸러 간다. - * - * ★ **서버에 사업장이 이미 만들어졌으면 신원 확인부터 다시 받는다.** places.category 는 만들 때 - * 정해지고 PATCH 로 못 고친다(Req_UpdatePlace 에 category 가 없다) — 화면에서만 바꾸면 - * 리페치 한 번에 서버 값으로 되돌아가서, 사장님 눈에는 바꾼 게 씹힌 것으로 보인다. - * 확정 전(로그인 전 포함)에는 서버에 아무것도 없으니 업종만 갈아도 된다. - */ + /** 업종을 바꾸러 간다. */ const identityIsOnServer = Boolean(confirmedIdentity?.placeId); const changeIndustry = () => { if (identityIsOnServer) { @@ -178,8 +140,7 @@ export function Step2PlaceSearch() { goToStep('industry'); }; - // ── 화면 고르기. 위에서부터 먼저 맞는 것 하나만 그린다 ────────────── - // + // ── 화면 고르기. const stage = confirmedIdentity ? 'confirmed' : search.phase === 'searching' @@ -190,15 +151,7 @@ export function Step2PlaceSearch() { ? 'picking' : 'input'; - /** - * 2단계에서 **뒤로 오면 처음(상호 입력)으로 되돌린다.** - * - * ★ 왜 확정 화면을 안 띄우나: 뒤로가기는 "다시 고르겠다" 는 뜻인데, 확정 화면만 뜨면 - * 다른 가게로 바꾸러 온 사장님이 목록을 못 찾는다. 후보 목록을 되살리는 방법도 있지만 - * 공개 검색이 유료 외부 API 라 뒤로 올 때마다 다시 부르게 된다. - * ★ 이 화면을 **정상적으로 지나간** 경우(확정 직후 2단계로 넘어감)에는 돌지 않는다 — - * `justConfirmed` 가 그 한 번을 막는다. 아니면 확정하자마자 스스로 지워 버린다. - */ + /** 2단계에서 **뒤로 오면 처음(상호 입력)으로 되돌린다.** */ useEffect(() => { if (!confirmedIdentity) return; if (justConfirmed.current) { @@ -326,8 +279,7 @@ export function Step2PlaceSearch() { )} {/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */} <IndustryBadge category={item.category} /> - {/* ★ 플레이스를 찾았는지 **여기서** 보여준다. 데이터만 싣고 표시를 안 하면 - 사장님은 자동으로 찾았는지 알 길이 없어 지도 주소를 또 찾으러 간다. */} + {/* 플레이스를 찾았는지 **여기서** 보여준다. */} {item.naver_place_url && ( <Badge variant="success" className="gap-1 text-[10px]"> <Check className="size-3" /> @@ -491,7 +443,7 @@ export function Step2PlaceSearch() { ); } -/** 후보 카드의 추정 업종. 못 정한 후보는 "고르면 묻는다"를 미리 알려 준다. */ +/** 후보 카드의 추정 업종. */ function IndustryBadge({category}: {category: PickedCategory}) { const industry = category != null ? (CATEGORY_TO_INDUSTRY[category] ?? null) : null; return industry ? ( @@ -508,12 +460,7 @@ function IndustryBadge({category}: {category: PickedCategory}) { /** PlaceSearchItem.category — 서버가 못 정하면 키 자체가 없다(RemoveNoneResponse). */ type PickedCategory = PlaceSearchItem['category']; -/** - * 지금 어떤 업종으로 진행 중인지 + 바꾸는 길. - * - * ★ 업종을 자동으로 정하기로 한 이상, **정해진 값이 화면에 보여야 한다.** 안 보이면 사장님은 - * 숙박 스키마로 카페 사이트를 만들고 있다는 걸 수집 결과를 보고서야 안다. - */ +/** 지금 어떤 업종으로 진행 중인지 + 바꾸는 길. */ function IndustryLine({ industry, onChange, diff --git a/solution/frontend/src/features/onboarding/Step3DataReview.tsx b/solution/frontend/src/features/onboarding/Step3DataReview.tsx index 110cbac..ff37a3c 100644 --- a/solution/frontend/src/features/onboarding/Step3DataReview.tsx +++ b/solution/frontend/src/features/onboarding/Step3DataReview.tsx @@ -57,21 +57,14 @@ export function Step3DataReview() { /** 찾아 둔 주소가 있어도 사장님이 직접 넣겠다고 하면 펼친다. */ const [urlBoxOpen, setUrlBoxOpen] = useState(false); - /** - * 수집 시작 — **위저드에서 서버를 처음 부르는 지점.** - * - * ★ 1단계의 확정은 화면에만 남는다(usePlaceSearch). 그래서 여기서 세 가지를 순서대로 한다: - * 로그인 → 사업장 생성·검증(ensureServerPlace) → 수집. 로그인을 앞 단계로 올리면 - * 아무것도 못 본 사장님에게 가입을 요구하게 된다(docs/PRODUCT.md "로그인 최소"). - */ - /** 로그인 모달을 띄우기 직전의 신원. 로그인이 끝나면 그대로 이어 돌린다. */ + /** 수집 시작 — **위저드에서 서버를 처음 부르는 지점.** */ + /** 로그인 모달을 띄우기 직전의 신원. */ const pendingAfterSignIn = useRef<typeof confirmedIdentity>(null); const beginCollect = async (identity = confirmedIdentity) => { if (!identity) return; if (!user && !getAccessToken()) { - // ★ 로그인 뒤에 이어서 돌 값을 들고 있는다. 안 들고 있으면 로그인만 하고 제자리로 - // 돌아와, 사장님이 같은 버튼을 두 번 눌러야 한다(실측 2026-09-03). + // 로그인 뒤에 이어서 돌 값을 들고 있는다. pendingAfterSignIn.current = identity; setSigningIn(true); return; @@ -89,13 +82,7 @@ export function Step3DataReview() { }; - /** - * 사장님이 붙여넣은 주소를 받는다. - * - * ★ 서버에 사업장이 없으면 `collect.addLink` 는 **조용히 아무것도 안 한다**(placeId 가 - * 없어서 첫 줄에서 return). 그러면 입력칸만 비워져 사장님은 실패로 읽는다(실측). - * 그래서 그 경우엔 신원에 주소를 싣고 같은 흐름(로그인 → 생성 → 검증 → 수집)으로 보낸다. - */ + /** 사장님이 붙여넣은 주소를 받는다. */ const addUrl = async (url: string, channel: LinkChannelCode, title: string) => { if (confirmedIdentity?.placeId) { await collect.addLink(url, channel, title); @@ -111,7 +98,6 @@ export function Step3DataReview() { const autoCollectStarted = useRef(false); // 2단계에서 네이버 플레이스를 URL로 확정하는 순간 채널도 함께 등록·확정된다. - // 이 화면에 들어오면 추가 버튼 없이 곧바로 그 채널에서 수집을 시작한다. useEffect(() => { if ( autoCollectStarted.current || @@ -138,16 +124,11 @@ export function Step3DataReview() { const config = INDUSTRY_CONFIGS[industry]; const canCollect = confirmedIdentity?.origin === 'external'; - /** ★ 버튼을 언제 열어 두나. - * `confirmedCount` 는 **서버에 사업장이 있어야** 나오는 값이다. 그것만 보면 로그인 전에는 - * 버튼이 영원히 잠겨, 자동 발견이 돌 기회조차 없다(실측 2026-09-03: 후보를 골라도 - * "주소를 먼저 붙여넣어 주세요" 로 잠긴 채 멈춘다). - * 아직 서버에 안 올렸으면(=placeId 없음) 자동 발견을 시도해 볼 여지가 있으므로 열어 둔다. - * 올렸는데도 채널이 0이면 그때는 자동 발견이 실패한 것이라 붙여넣기를 요구한다. */ + /** 버튼을 언제 열어 두나. */ const pendingUrl = confirmedIdentity?.naverPlaceUrl; const autoLookupPending = canCollect && !placeId; const hasSource = collect.confirmedCount > 0 || Boolean(pendingUrl) || autoLookupPending; - /** 아직 실제 수집이 붙지 않은 줄들 — 업종 예시다. 그렇게 부른다. */ + /** 아직 실제 수집이 붙지 않은 줄들 — 업종 예시다. */ const isSample = !confirmedIdentity || confirmedIdentity.origin === 'owner'; const stage = @@ -161,7 +142,7 @@ export function Step3DataReview() { ? 'review' : 'ready'; - // ★ /login 으로 튕기지 않는다. 여기까지 쌓은 위저드 상태는 주소창에 없어서 돌아올 길이 없다. + // /login 으로 튕기지 않는다. if (signingIn) { return ( <div className="flex flex-1 items-center justify-center bg-muted/30 px-4 py-10"> @@ -212,8 +193,6 @@ export function Step3DataReview() { <div className="space-y-3"> <IdentityCard /> - {/* ★ 자동 해석이 실패했다는 사실을 서버가 알려준 경우 — - "수집했는데 아무것도 안 나왔다"로 끝나지 않게 다음 동작을 지목한다. */} {collect.naverPlaceMissing && ( <p className="flex items-start gap-1.5 rounded-xl border border-warning/30 bg-warning/10 p-3 text-[11px] leading-relaxed text-warning"> <TriangleAlert className="mt-px size-3.5 shrink-0" /> @@ -224,8 +203,7 @@ export function Step3DataReview() { </p> )} - {/* ★ 이미 찾아 둔 주소가 있으면 붙여넣기 안내를 접는다. 펼쳐 두면 사장님은 - "또 못 찾았구나" 로 읽고 지도를 열러 간다 — 찾아 놓고 그러는 건 최악이다. */} + {/* 이미 찾아 둔 주소가 있으면 붙여넣기 안내를 접는다. */} {canCollect && pendingUrl && !urlBoxOpen ? ( <div className="rounded-2xl border border-success/40 bg-success/5 p-4"> <p className="flex items-center gap-1.5 text-xs font-bold"> @@ -274,7 +252,7 @@ export function Step3DataReview() { </Button> )} - {/* 부차 경로 — 남겨두되 기대치를 정직하게 적는다. 실측 성공률이 낮다. */} + {/* 부차 경로 — 남겨두되 기대치를 정직하게 적는다. */} {canCollect && ( <div className="rounded-2xl border border-border bg-card p-4"> <p className="text-xs font-bold">주소를 못 찾겠으면</p> @@ -320,11 +298,7 @@ export function Step3DataReview() { 가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면 주소 붙여넣기와 자동 찾기가 모두 열립니다. </p> - {/* ★ 이 길로 나가면 **발행까지 못 간다** — 서버에 사업장이 없어서 구울 대상이 - 없고, 발행 모달은 [내 가게 확인하러 가기] 만 내놓는다(PublishModal - PlaceFirstPanel). 예전에는 아무 말 없이 통과시켜서, 사장님은 사이트를 다 - 만든 뒤 발행을 누르는 자리에서야 그 사실을 알았다. - 버튼은 남긴다 — 검증을 못 통과한 분이 화면을 구경할 길까지 막지는 않는다. */} + {/* 이 길로 나가면 **발행까지 못 간다** — 서버에 사업장이 없어서 구울 대상이 없고, 발행 모달은 [내 가게 확인하러 가기] 만 내놓는다(PublishModal PlaceFirstPanel). */} <p className="rounded-xl border border-border bg-muted/40 p-3 text-[11px] leading-relaxed text-muted-foreground"> <strong className="text-foreground"> 이대로 진행하면 화면은 만들 수 있지만 발행은 되지 않습니다. @@ -517,12 +491,7 @@ export function Step3DataReview() { onPrev={() => goToStep('search')} onNext={() => goToStep('template')} nextLabel="다음: 템플릿 선택" - /** - * ★ 수집 전에는 잠근다. - * 여기서 그냥 넘어가면 fact 도 사진도 없는 채로 템플릿을 고르고 발행까지 가서, - * 서버 게이트가 NO_UNIQUE_CONTENT 로 막는다 — 사장님은 세 화면을 지나온 뒤에야 - * "처음부터 아무것도 없었다"는 사실을 알게 된다. 그 전에 막는 것이 맞다. - */ + /** 수집 전에는 잠근다. */ nextDisabled={stage !== 'review'} /> </div> @@ -570,12 +539,7 @@ function IdentityCard() { ); } -/** - * 수집된 값 한 줄 — 확인이 이 화면의 목적이므로 [맞아요]/[아니에요]가 줄 안에 있다. - * - * ★ 승인은 스토어의 verifyField 가 그대로 fact 전이 API 로 올린다 — - * 화면만 바뀌고 서버가 모르는 승인은 없다. - */ +/** 수집된 값 한 줄 — 확인이 이 화면의 목적이므로 [맞아요]/[아니에요]가 줄 안에 있다. */ function FieldRow({fieldId}: {fieldId: string}) { const field = useBuilderStore((s) => s.infoFields.find((f) => f.id === fieldId)); const savingFieldIds = useBuilderStore((s) => s.savingFieldIds); diff --git a/solution/frontend/src/features/onboarding/Step5Generating.tsx b/solution/frontend/src/features/onboarding/Step5Generating.tsx index ef276c5..da73854 100644 --- a/solution/frontend/src/features/onboarding/Step5Generating.tsx +++ b/solution/frontend/src/features/onboarding/Step5Generating.tsx @@ -7,12 +7,7 @@ import {useBuilderStore} from '@/stores/builder'; import {useGenerationJob} from './useGenerationJob'; import {GENERATION_LABELS, SKIP_REASONS} from './generationLabels'; -/* - * ★ 겉모습만 옛 화면 스타일이고 데이터는 실제 잡 진행 상태다. `useGenerationJob` - * (새로고침해도 jobId 로 이어서 봄, 실패 시 복구 버튼)을 그대로 쓰고, 카드 모양· - * 진행률 바·번호 동그라미 목록만 그 스타일로 그린다. 단계 문구는 실제로 이 COPY - * 잡이 하는 일(prepare/generate/save/faq_fill)만 적는다 — 자세한 배경은 DEVLOG.md. - */ +/* 겉모습만 옛 화면 스타일이고 데이터는 실제 잡 진행 상태다. */ export function Step5Generating() { const storeName = useBuilderStore((s) => s.storeName); const placeId = useBuilderStore((s) => s.placeId); diff --git a/solution/frontend/src/features/onboarding/WizardStage.tsx b/solution/frontend/src/features/onboarding/WizardStage.tsx index e3d9a5f..d8b41d7 100644 --- a/solution/frontend/src/features/onboarding/WizardStage.tsx +++ b/solution/frontend/src/features/onboarding/WizardStage.tsx @@ -1,13 +1,7 @@ import type {ReactNode} from 'react'; import {WizardSteps} from './WizardSteps'; -/** - * 위저드 한 화면. - * - * ★ 한 번에 한 가지만 묻는다. 입력 폼과 결과 목록을 좌우로 같이 띄우면, 사장님은 - * "지금 내가 뭘 해야 하는지"를 화면에서 찾아야 한다. 그래서 단계마다 패널 하나만 세운다 — - * 묻는 것이 바뀌면 화면이 통째로 바뀐다. - */ +/** 위저드 한 화면. */ export function WizardStage({ current, title, @@ -44,7 +38,7 @@ export function WizardStage({ ); } -/** 단계 안에서 기다리는 화면(검색·수집). 스피너 하나와 지금 하는 일만 보여준다. */ +/** 단계 안에서 기다리는 화면(검색·수집). */ export function WizardWaiting({title, description}: {title: string; description?: ReactNode}) { return ( <div className="flex flex-col items-center gap-4 rounded-2xl border border-border bg-card p-10 text-center"> diff --git a/solution/frontend/src/features/onboarding/WizardSteps.tsx b/solution/frontend/src/features/onboarding/WizardSteps.tsx index 6fe4b1c..c2e5d46 100644 --- a/solution/frontend/src/features/onboarding/WizardSteps.tsx +++ b/solution/frontend/src/features/onboarding/WizardSteps.tsx @@ -1,16 +1,7 @@ import {Check} from 'lucide-react'; import {cn} from '@/lib/utils'; -/** - * 위저드 진행 표시. - * - * 1(신원 확인)과 2(사실 확인)가 왜 따로인지 사장님이 한눈에 알게 하는 것이 이 줄의 목적이다 — - * "가게가 맞나" 를 먼저 끝내고 "값이 맞나" 로 넘어간다. - * - * ★ 업종은 더 이상 단계가 아니다. 검색 결과의 분류가 정하고(못 정할 때만 1단계 안에서 묻는다), - * 그래서 네 칸이던 줄이 세 칸이 됐다 — 사장님이 처음 보는 화면이 "우리는 카페인가 음식점인가" - * 가 아니라 "가게 이름"이 되게 하는 것이 이 변경의 목적이다. - */ +/** 위저드 진행 표시. */ const STEPS = [ {step: 1, label: '내 가게 확인'}, {step: 2, label: '수집 정보 확인'}, diff --git a/solution/frontend/src/features/onboarding/collectJobResult.ts b/solution/frontend/src/features/onboarding/collectJobResult.ts index d9dc1c8..31f0679 100644 --- a/solution/frontend/src/features/onboarding/collectJobResult.ts +++ b/solution/frontend/src/features/onboarding/collectJobResult.ts @@ -1,18 +1,7 @@ -/** - * 수집 잡 결과(`job.result`) 판독 — 순수 함수만. - * - * ★ `job.result` 는 백엔드가 관측·디버깅용으로 넣어 둔 JSONB 라 타입이 없다. - * 화면이 그걸 여기저기서 직접 파헤치면, 백엔드가 키 하나를 바꿔도 조용히 0 건으로 보인다. - * 읽는 규칙을 한 곳에 모은다 — 기준은 backend/services/collect_service.py 의 반환값이다. - */ +/** 수집 잡 결과(`job.result`) 판독 — 순수 함수만. */ import type {JobData} from '@/api'; -/** - * 잡 결과에서 숫자 한 칸만 좁혀 읽는다. - * - * `job.result` 는 잡 종류마다 모양이 달라 스키마상 unknown 이다(readNaverPlaceMissing 과 같은 이유). - * 없으면 0 — 서버가 필드를 빼도 토스트가 `undefined 건` 이 되지 않는다. - */ +/** 잡 결과에서 숫자 한 칸만 좁혀 읽는다. */ export function readNumber(source: unknown, key: string): number { if (!source || typeof source !== 'object') return 0; const value = (source as Record<string, unknown>)[key]; @@ -26,12 +15,7 @@ export function readGroup(job: JobData, key: string): unknown { return (result as Record<string, unknown>)[key]; } -/** - * 서버가 남긴 사유 한 줄(`result.note`). - * - * ★ "왜 아무것도 안 들어왔는지"는 서버만 안다("크롤링 대상이 없다" 등). - * 화면이 추측해서 지어내지 않고, 있으면 그대로 보여준다. - */ +/** 서버가 남긴 사유 한 줄(`result.note`). */ export function readNote(job: JobData): string | undefined { const result: unknown = job.result; if (!result || typeof result !== 'object') return undefined; @@ -39,12 +23,7 @@ export function readNote(job: JobData): string | undefined { return typeof note === 'string' && note.trim() ? note : undefined; } -/** - * 잡 결과에서 "네이버 플레이스를 자동으로 못 찾았다" 플래그만 꺼낸다. - * - * `job.result` 는 잡 종류마다 모양이 달라 스키마상 unknown 이다. 통째로 캐스팅하지 않고 - * 이 한 칸만 좁혀서 읽는다 — 서버가 필드를 안 실어도 화면은 그냥 조용해질 뿐이다. - */ +/** `job.result` 는 잡 종류마다 모양이 달라 스키마상 unknown 이다. */ export function readNaverPlaceMissing(job: JobData): boolean { const result: unknown = job.result; if (!result || typeof result !== 'object') return false; diff --git a/solution/frontend/src/features/onboarding/collectJobs.ts b/solution/frontend/src/features/onboarding/collectJobs.ts index 1ddeae1..e0450d5 100644 --- a/solution/frontend/src/features/onboarding/collectJobs.ts +++ b/solution/frontend/src/features/onboarding/collectJobs.ts @@ -1,10 +1,4 @@ -/** - * 수집에 딸린 잡 실행과 캐시 정리 — React 를 모르는 부분. - * - * ★ 훅 상태를 건드리지 않는 일만 모았다. 하는 일은 "잡을 넣고 → 폴링하고 → 캐시를 - * 무효화하고 → 결과를 알린다" 뿐이라 컴포넌트 마운트와 아무 상관이 없다. 훅 안에 두면 - * 수집 흐름(단계 전이)과 잡 실행이 한 덩어리로 보여 어느 쪽을 고치는지 흐려진다. - */ +/** 수집에 딸린 잡 실행과 캐시 정리 — React 를 모르는 부분. */ import type {JobData} from '@/api'; import { getGetPlaceQueryKey, @@ -28,24 +22,18 @@ export const RECOLLECT_LABEL: Record<RecollectTask, string> = { copy: '소개문', }; -/** - * 사진 분석 · 소개문 생성 잡 1회분. - * - * 수집(runCollect)과 달리 훅 상태를 건드리지 않아 모듈 함수로 둔다 — - * 하는 일은 "잡을 넣고 → 폴링하고 → 캐시를 무효화하고 → 결과를 알린다" 뿐이다. - */ +/** 사진 분석 · 소개문 생성 잡 1회분. */ export async function runSideJob(task: 'photos' | 'copy', placeId: string, signal: AbortSignal) { const label = RECOLLECT_LABEL[task]; let jobId: string; try { const started = task === 'photos' - ? // ★ force:true — 이미 alt 가 붙은 사진도 다시 분석한다. 사장님이 일부러 누른 버튼이라 - // "분석할 사진이 없습니다"로 끝나면 재분석이라는 이름값을 못 한다. + ? // force:true — 이미 alt 가 붙은 사진도 다시 분석한다. + // "분석할 사진이 없습니다"로 끝나면 재분석이라는 이름값을 못 한다. await startVision(placeId, {force: true}, undefined, signal) : await startCopy(placeId, {}, undefined, signal); - // 거절도 HTTP 200 이다 — 사진 0장(MEDIA_NOT_FOUND)·근거 부족(FAQ_UNGROUNDED)· - // 키 미설정(GENERATOR_NOT_CONFIGURED)이 전부 이 자리로 온다. + // 거절도 HTTP 200 이다 — 사진 0장(MEDIA_NOT_FOUND)·근거 부족(FAQ_UNGROUNDED)· 키 미설정(GENERATOR_NOT_CONFIGURED)이 전부 이 자리로 온다. if (started.result?.success === false || !started.job_id) { notifyApiError({data: started}, `${label}을 시작하지 못했습니다.`); return; @@ -86,13 +74,7 @@ export async function runSideJob(task: 'photos' | 'copy', placeId: string, signa } } -/** - * 수집이 큐에 넣은 사진 분석 잡을 기다린다. - * - * 잡 id 는 수집 결과(`result.vision_job_id`)에 실려 온다 — 사진이 한 장도 없으면 없다. - * 실패·타임아웃은 조용히 넘어간다: 사진이 늦게 붙을 뿐 수집 자체는 성공이고, - * 여기서 화면을 막으면 사장님이 다음으로 못 간다. - */ +/** 수집이 큐에 넣은 사진 분석 잡을 기다린다. */ export async function waitForVision(placeId: string, job: JobData, signal: AbortSignal) { const visionJobId = (job.result as {vision_job_id?: string} | undefined)?.vision_job_id; if (!visionJobId) return; @@ -100,13 +82,7 @@ export async function waitForVision(placeId: string, job: JobData, signal: Abort if (outcome.kind === 'done') await refreshPlaceCaches(placeId); } -/** - * 수집이 끝난 뒤 서버가 진실인 것들 — place(주소·갱신시각) · 링크 · fact · **사진**. - * - * ★ media 를 빠뜨리면 사진이 서버에 10장 들어와도 화면은 빈 목록을 계속 본다. - * 실제로 그랬다 — "이미지 왜 못 가져오냐"의 원인이 수집이 아니라 여기였다. - * 목록에 뭘 더할 때는 화면이 그 값을 어디서 읽는지 같이 확인해야 한다. - */ +/** 수집이 끝난 뒤 서버가 진실인 것들 — place(주소·갱신시각) · 링크 · fact · **사진**. */ export async function refreshPlaceCaches(placeId: string) { await Promise.all([ queryClient.invalidateQueries({queryKey: getGetPlaceQueryKey(placeId)}), @@ -116,11 +92,7 @@ export async function refreshPlaceCaches(placeId: string) { ]).catch(() => undefined); } -/** - * 캐시 무효화 묶음. 실패해도 삼킨다 — 화면이 한 박자 늦게 갱신될 뿐이고, 잡은 이미 끝났다. - * - * 생성물 쿼리 키는 `as const` 라 안쪽도 readonly 다 — 그 모양 그대로 받는다. - */ +/** 캐시 무효화 묶음. */ export async function invalidate(...queryKeys: readonly (readonly unknown[])[]) { await Promise.all( queryKeys.map((queryKey) => queryClient.invalidateQueries({queryKey})), diff --git a/solution/frontend/src/features/onboarding/collectNotify.ts b/solution/frontend/src/features/onboarding/collectNotify.ts index 7daf712..7145926 100644 --- a/solution/frontend/src/features/onboarding/collectNotify.ts +++ b/solution/frontend/src/features/onboarding/collectNotify.ts @@ -1,22 +1,9 @@ -/** - * 수집·분석·소개문 잡이 끝났을 때의 알림 문구. - * - * ★ "몇 건이 새로 들어왔는가"를 말해 주지 않으면 사장님은 버튼을 눌렀는데 아무 일도 - * 일어나지 않은 것으로 읽는다. 숫자는 job.result 에만 있으므로 판독은 collectJobResult 가 - * 맡고, 여기서는 문구만 만든다. - */ +/** 수집·분석·소개문 잡이 끝났을 때의 알림 문구. */ import type {JobData} from '@/api'; import {notify} from '@/lib/notify'; import {readGroup, readNote, readNumber} from './collectJobResult'; -/** - * 재수집(정보) 결과 알림. - * - * ★ `facts.stored` 하나만 보여주면 "N건이 반영됐다"로 읽힌다 — 사실이 아니다. - * stored 는 **후보로 새로 쌓인 것(candidate)** 과 **값이 같아 다시 확인만 된 것(refreshed)** 의 - * 합이고, 노출 중인 값은 어느 쪽이든 그대로다(fact_service 의 업데이트 프로세스). - * 그래서 둘을 갈라 보여주고, 승인이 남았다는 사실을 같이 적는다. - */ +/** 재수집(정보) 결과 알림. */ export function notifyCollected(job: JobData) { const facts = readGroup(job, 'facts'); const media = readGroup(job, 'media'); @@ -41,7 +28,7 @@ export function notifyCollected(job: JobData) { ); } -/** 재분석(사진) 결과 알림. 신뢰도가 낮은 건은 자동 반영되지 않고 확인 큐에 남는다. */ +/** 재분석(사진) 결과 알림. */ export function notifyVision(job: JobData) { const analyzed = readNumber(job.result, 'analyzed'); if (analyzed === 0) { @@ -55,12 +42,7 @@ export function notifyVision(job: JobData) { ); } -/** - * 재생성(소개문·FAQ) 결과 알림. - * - * ★ `faq_fill` 을 같이 본다. fact 가 0건이면 AI 가 쓴 FAQ(`faqs`)는 0 이지만 문의 안내로 20개가 채워진다 — - * `faqs` 만 보면 "새로 만든 문장이 없습니다" 가 떠서, FAQ 탭에 20개가 있는데 실패로 읽힌다. - */ +/** 재생성(소개문·FAQ) 결과 알림. */ export function notifyCopy(job: JobData) { const faqCount = readNumber(job.result, 'faqs'); const fillCount = readNumber(job.result, 'faq_fill'); diff --git a/solution/frontend/src/features/onboarding/ensureServerPlace.ts b/solution/frontend/src/features/onboarding/ensureServerPlace.ts index 682caa5..c0195a5 100644 --- a/solution/frontend/src/features/onboarding/ensureServerPlace.ts +++ b/solution/frontend/src/features/onboarding/ensureServerPlace.ts @@ -1,13 +1,4 @@ -/** - * 화면에만 있던 신원을 **서버에 올린다** — 사업장 생성 + 네이버 주소 검증. - * - * ★ 왜 여기 모였나 - * 위저드는 로그인을 수집 직전 한 번만 묻는다. 그래서 1단계의 확정(후보 선택·주소 - * 붙여넣기)은 서버를 아예 부르지 않고 화면에만 남는다(usePlaceSearch). 그 값을 - * 서버로 옮기는 일이 이 함수 하나에 모여 있어야, 로그인 뒤 순서(생성 → 검증 → 수집)가 - * 한 곳에서만 정해진다. 나눠 두면 "사업장은 만들어졌는데 검증이 빠진" 상태가 생기고, - * 그러면 수집이 PLACE_NOT_VERIFIED 로 거절되면서 원인은 화면에 안 나온다. - */ +/** 화면에만 있던 신원을 **서버에 올린다** — 사업장 생성 + 네이버 주소 검증. */ import {PlaceCategory as PlaceCategoryEnum, type IndustryType} from '@o2o/shared'; import type {PlaceCategory} from '@/api'; import {createPlace, verifyCandidates, verifyPlaceByUrl} from '@/api'; @@ -21,10 +12,7 @@ const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = { clinic: PlaceCategoryEnum.CLINIC, }; -/** - * 서버에 사업장이 있는 신원을 돌려준다. 이미 있으면 그대로 통과한다. - * 실패는 null — 호출측이 수집을 시작하지 않는다. - */ +/** 서버에 사업장이 있는 신원을 돌려준다. */ export async function ensureServerPlace( identity: ConfirmedIdentity, industry: IndustryType, @@ -42,9 +30,7 @@ export async function ensureServerPlace( return null; } - // ★ 붙여넣은 주소가 없으면 **서버에 자동 발견을 먼저 시켜 본다.** 사장님이 직접 - // 네이버 지도에 들어가 주소를 복사해 오는 건 마지막 수단이어야 한다. - // (실측 2026-09-03: 상호가 정확히 일치하면 여기서 플레이스 주소가 나온다.) + // 붙여넣은 주소가 없으면 **서버에 자동 발견을 먼저 시켜 본다.** 사장님이 직접 네이버 지도에 들어가 주소를 복사해 오는 건 마지막 수단이어야 한다. let url = identity.naverPlaceUrl; if (!url) { const query = [identity.name, identity.address].filter(Boolean).join(' '); @@ -57,25 +43,13 @@ export async function ensureServerPlace( // 그래도 없으면 검증 없이 둔다 — 수집은 열리지 않고, 화면이 붙여넣기를 요청한다. if (!url) return {...identity, placeId}; - /* - * ★ `reuse_existing: false` — 이 함수는 **새로 만들기 경로에서만** 온다 - * (위의 `if (identity.placeId) return identity` 가 이어하기를 이미 걸렀다). - * 빼면 서버가 같은 네이버 place id 를 가진 기존 사업장을 찾아 그쪽으로 돌려보내고, - * 사장님은 [새로 크롤링하고 사이트 생성하기] 를 눌렀는데 **기존 에디터**를 만난다 - * (실측 2026-09-15). 빈 중복 행 정리는 서버가 이 값과 무관하게 계속 한다. - */ const res = await verifyPlaceByUrl(placeId, {url, reuse_existing: false}); const place = res.place; if (!place) { notifyApiError({data: res}, '그 주소에서 가게 정보를 읽지 못했습니다.'); return {...identity, placeId}; } - // ★ 상호·주소는 서버가 읽은 값으로 덮는다. 사장님이 검색창에 친 이름이 아니라 - // 네이버에 등록된 공식 표기가 이 사이트의 기준 정보가 되어야 한다. - // ★ **place_id 도 서버가 돌려준 것을 쓴다.** 같은 가게를 이미 갖고 있으면 서버가 - // 그 정본을 돌려주고 방금 만든 빈 행을 접는다(`verify_place_by_url` 의 중복 합치기). - // 여기서 우리가 만든 id 를 계속 붙들면, 화면은 **접힌 행**을 편집하게 된다 — - // 저장은 되는데 목록·발행본은 정본을 보므로 "고쳤는데 반영이 안 된다" 가 된다. + // 상호·주소는 서버가 읽은 값으로 덮는다. return { ...identity, placeId: place.place_id ?? placeId, diff --git a/solution/frontend/src/features/onboarding/generationLabels.ts b/solution/frontend/src/features/onboarding/generationLabels.ts index d6a4f67..166937b 100644 --- a/solution/frontend/src/features/onboarding/generationLabels.ts +++ b/solution/frontend/src/features/onboarding/generationLabels.ts @@ -1,4 +1,4 @@ -/** 화면 문구만 둔다. 단계 순서·완료 여부는 jobs.progress가 보낸다. */ +/** 화면 문구만 둔다. */ export const GENERATION_LABELS: Record<string, string> = { prepare: '수집된 사진 분류 및 대체 텍스트 생성', generate: '브랜드 컬러 시스템 및 타이포그래피 조합', diff --git a/solution/frontend/src/features/onboarding/index.ts b/solution/frontend/src/features/onboarding/index.ts index c71fcd8..c813d40 100644 --- a/solution/frontend/src/features/onboarding/index.ts +++ b/solution/frontend/src/features/onboarding/index.ts @@ -5,8 +5,7 @@ export {Step2PlaceSearch} from './Step2PlaceSearch'; export {Step3DataReview} from './Step3DataReview'; export {Step4Template} from './Step4Template'; export {Step5Generating} from './Step5Generating'; -// 재수집 패널은 위저드 밖(사업장 상세 · 빌더 에디터)에서 쓰지만 배선(useCollectFlow)이 -// 여기 있어서 같이 둔다 — 수집 경로를 두 곳으로 쪼개지 않는다. +// 재수집 패널은 위저드 밖(사업장 상세 · 빌더 에디터)에서 쓰지만 배선(useCollectFlow)이 여기 있어서 같이 둔다 — 수집 경로를 두 곳으로 쪼개지 않는다. export {RecollectPanel} from './RecollectPanel'; // 붙여넣기 패널도 위저드 밖에서 쓴다 — 자동 수집이 못 채운 업소의 폴백 입구다. export {PasteFactsPanel} from './PasteFactsPanel'; diff --git a/solution/frontend/src/features/onboarding/industryIcons.tsx b/solution/frontend/src/features/onboarding/industryIcons.tsx index 9cc56c2..0fcda5f 100644 --- a/solution/frontend/src/features/onboarding/industryIcons.tsx +++ b/solution/frontend/src/features/onboarding/industryIcons.tsx @@ -1,7 +1,7 @@ import {Building2, Coffee, Stethoscope, UtensilsCrossed} from 'lucide-react'; import type {IndustryType} from '@o2o/shared'; -/** 업종 아이콘. lucide 하나로 통일한다 — 손으로 그린 SVG 세트를 따로 두지 않는다. */ +/** 업종 아이콘. */ export const INDUSTRY_ICONS: Record<IndustryType, typeof Building2> = { stay: Building2, cafe: Coffee, diff --git a/solution/frontend/src/features/onboarding/useChannelLinks.ts b/solution/frontend/src/features/onboarding/useChannelLinks.ts index 7d6a188..c1512f7 100644 --- a/solution/frontend/src/features/onboarding/useChannelLinks.ts +++ b/solution/frontend/src/features/onboarding/useChannelLinks.ts @@ -1,12 +1,4 @@ -/** - * 채널 링크 — 목록 · 직접 등록 · 확정. - * - * ★ 수집 흐름(useCollectFlow)에서 떼어낸 이유: 이건 **단계 전이가 없는** 일이다. - * 등록하고 확정하는 것뿐이고, 수집이 돌고 있든 아니든 똑같이 동작한다. - * 한 훅에 섞여 있으면 "지금 어느 단계라 이 버튼이 되는가"를 매번 따져야 한다. - * - * ★ 확정된 링크만 크롤링 대상이 된다 — 이 훅이 수집 게이트의 앞단이다. - */ +/** 채널 링크 — 목록 · 직접 등록 · 확정. */ import {useCallback, useState} from 'react'; import {LinkChannel, SourceType} from '@o2o/shared'; import type {LinkChannel as LinkChannelCode, LinkData} from '@/api'; @@ -17,7 +9,7 @@ import {queryClient} from '@/lib/query-client'; export function useChannelLinks(placeId: string | null) { const [confirmingId, setConfirmingId] = useState<string | null>(null); const [isAdding, setIsAdding] = useState(false); - /** 서버가 네이버 플레이스를 찾지 못했는지 여부. 수집 결과가 알려주고, 직접 등록하면 내린다. */ + /** 서버가 네이버 플레이스를 찾지 못했는지 여부. */ const [naverPlaceMissing, setNaverPlaceMissing] = useState(false); const enabled = Boolean(placeId); @@ -25,13 +17,7 @@ export function useChannelLinks(placeId: string | null) { const links: LinkData[] = linksQuery.data?.links ?? []; const confirmedCount = linksQuery.data?.confirmed ?? 0; - /** - * 사장님이 직접 붙여넣은 채널 URL 을 등록하고 **동시에 확정**한다. - * - * ★ 발견된 URL 과 달리 확인 절차를 한 번 더 두지 않는다. 사장님이 자기 가게 주소를 - * 직접 가져온 것이므로, 그 붙여넣기 자체가 확인이다. 여기서 또 [내 채널 맞아요]를 - * 요구하면 같은 판단을 두 번 시키는 셈이다. - */ + /** 사장님이 직접 붙여넣은 채널 URL 을 등록하고 **동시에 확정**한다. */ const addLink = useCallback( async (url: string, channel: LinkChannelCode, title: string) => { if (!placeId) return; @@ -42,7 +28,6 @@ export function useChannelLinks(placeId: string | null) { }); const linkId = created.link?.link_id; if (linkId) await confirmLink(placeId, linkId); - // 사장님이 직접 주소를 넣었으면 "자동으로 못 찾았습니다" 안내는 할 일을 다 했다 — 내린다. if (channel === LinkChannel.NAVER_PLACE) setNaverPlaceMissing(false); await queryClient .invalidateQueries({queryKey: getListLinksQueryKey(placeId)}) @@ -57,7 +42,7 @@ export function useChannelLinks(placeId: string | null) { [placeId], ); - /** 사람이 "내 채널 맞다"고 고른 URL 을 확정한다. ★ 확정된 것만 크롤링 대상이 된다. */ + /** 사람이 "내 채널 맞다"고 고른 URL 을 확정한다. */ const confirm = useCallback( async (linkId: string) => { if (!placeId) return; diff --git a/solution/frontend/src/features/onboarding/useCollectFlow.ts b/solution/frontend/src/features/onboarding/useCollectFlow.ts index 7ca926b..af308ba 100644 --- a/solution/frontend/src/features/onboarding/useCollectFlow.ts +++ b/solution/frontend/src/features/onboarding/useCollectFlow.ts @@ -43,7 +43,7 @@ export type CollectPhase = /** 에디터에서 다시 실행할 수집 작업. */ export type RecollectTask = 'facts' | 'photos' | 'copy'; -/** 실패 문구에 쓰는 이름. 잡 종류를 코드값으로 말하지 않는다. */ +/** 실패 문구에 쓰는 이름. */ const RECOLLECT_LABEL: Record<RecollectTask, string> = { facts: '정보 수집', photos: '사진 분석', @@ -117,20 +117,13 @@ export function useCollectFlow(placeId: string | null) { setNaverPlaceMissing, ]); - /** - * 수집 잡 1회분. 끝나면 링크·fact·사진 캐시를 무효화해 화면이 서버 상태를 다시 읽게 한다. - * - * ★ 끝난 잡을 그대로 돌려준다(boolean 아님). 재수집 화면은 "몇 건이 새로 들어왔는지"를 - * 알려야 하는데 그 숫자는 `job.result` 에만 있다 — 성패만 받으면 다시 물어볼 길이 없다. - */ + /** 수집 잡 1회분. */ const runCollect = useCallback( async (id: string, signal: AbortSignal, body: ReqStartCollect = {}): Promise<JobData | null> => { let jobId: string; try { const started = await startCollect(id, body, undefined, signal); - // ★ 거절도 HTTP 200 이다 — 검증 전(PLACE_NOT_VERIFIED)·이미 도는 잡 - // (COLLECT_ALREADY_RUNNING)이 여기로 온다. result 를 안 보면 전부 - // '잡이 만들어지지 않았습니다'로 뭉개져 사장님이 할 일을 못 찾는다. + // 거절도 HTTP 200 이다 — 검증 전(PLACE_NOT_VERIFIED)·이미 도는 잡 (COLLECT_ALREADY_RUNNING)이 여기로 온다. if (started.result?.success === false || !started.job_id) { notifyApiError({data: started}, '수집을 시작하지 못했습니다.'); return null; @@ -165,9 +158,7 @@ export function useCollectFlow(placeId: string | null) { case 'done': await refreshPlaceCaches(id); setNaverPlaceMissing(readNaverPlaceMissing(outcome.job)); - // ★ 수집이 끝나도 사진은 아직 못 쓴다. 사진은 Vision 이 alt 를 붙여야 발행 대상이 - // 되고(snapshot 이 alt 없는 사진을 거른다), Vision 은 수집이 큐에 넣는 **별개 잡**이다. - // 여기서 안 기다리면 서버에 10장이 들어와 있어도 화면은 "사진 0" 으로 보인다. + // 수집이 끝나도 사진은 아직 못 쓴다. await waitForVision(id, outcome.job, signal); return outcome.job; } @@ -175,7 +166,7 @@ export function useCollectFlow(placeId: string | null) { [setGatherStage, setNaverPlaceMissing], ); - /** 1회차 — 채널 URL 찾기. 확정할 URL 이 이미 있으면 곧장 크롤링으로 간다. */ + /** 1회차 — 채널 URL 찾기. */ const discover = useCallback(async () => { if (!placeId) return; running.current?.abort(); @@ -206,7 +197,6 @@ export function useCollectFlow(placeId: string | null) { const found = fresh?.links ?? []; if ((fresh?.confirmed ?? 0) > 0) { - // 이미 확정된 채널이 있다 = 크롤링까지 끝난 회차였다. setPhase('done'); return; } @@ -221,7 +211,7 @@ export function useCollectFlow(placeId: string | null) { setPhase('awaiting-confirm'); }, [placeId, runCollect, startGather, finishGather, discoverChannels]); - /** 2회차 — 확정된 URL 만 긁는다. 여기서 나온 값이 [수집된 데이터]에 들어간다. */ + /** 2회차 — 확정된 URL 만 긁는다. */ const crawl = useCallback(async () => { if (!placeId) return; running.current?.abort(); @@ -238,17 +228,7 @@ export function useCollectFlow(placeId: string | null) { setPhase(job ? 'done' : 'awaiting-confirm'); }, [placeId, runCollect, startGather, finishGather]); - /** - * 재수집 — 이미 만든 사업장을 다시 긁는다. - * - * ★ 크롤링은 `crawl` 과 같은 배선(runCollect)을 그대로 쓴다. 다른 건 `force:true` 뿐이다: - * force 없이는 "이미 필수 항목이 다 차 있다"며 크롤링을 건너뛰어(collect_service.run_collect) - * 사장님 눈에는 버튼을 눌러도 아무 일이 안 일어난 것처럼 보인다. - * - * ★ 그래도 **노출 중인 값은 밀려나지 않는다**. 자동 출처(CRAWL)는 노출값을 직접 못 바꾸고 - * 후보로만 쌓이며(fact_service._write_candidate), 사장님이 고친 값(CORRECTED)은 잠겨 있다. - * 그래서 화면 문구도 "다 새로 가져옵니다"가 아니라 "확인할 후보로 쌓입니다"여야 한다. - */ + /** 재수집 — 이미 만든 사업장을 다시 긁는다. */ const recollect = useCallback( async (task: RecollectTask) => { if (!placeId || recollecting.current) return; @@ -267,8 +247,7 @@ export function useCollectFlow(placeId: string | null) { } await runSideJob(task, placeId, controller.signal); } finally { - // 언마운트로 끊겼어도 ref 는 반드시 푼다 — 안 그러면 다음 마운트가 아니라 - // 이 훅 인스턴스가 영영 잠긴다(같은 화면에서 다시 못 누른다). + // 언마운트로 끊겼어도 ref 는 반드시 푼다 — 안 그러면 다음 마운트가 아니라 이 훅 인스턴스가 영영 잠긴다(같은 화면에서 다시 못 누른다). recollecting.current = null; setRecollectingTask(null); } @@ -276,8 +255,7 @@ export function useCollectFlow(placeId: string | null) { [placeId, runCollect], ); - // ★ 반환 모양은 그대로 둔다. 훅을 쪼갠 건 내부 사정이고, 화면이 보는 계약이 아니다 — - // 여기서 모양이 바뀌면 위저드와 재수집 패널을 같이 고쳐야 한다. + // 반환 모양은 그대로 둔다. return { phase, ...channels, diff --git a/solution/frontend/src/features/onboarding/useGenerationJob.ts b/solution/frontend/src/features/onboarding/useGenerationJob.ts index 99d88cb..f22fac9 100644 --- a/solution/frontend/src/features/onboarding/useGenerationJob.ts +++ b/solution/frontend/src/features/onboarding/useGenerationJob.ts @@ -5,7 +5,7 @@ import {JobStatus, JobType} from '@o2o/shared'; import {getAccessToken, startCopy, useGetJob} from '@/api'; import {EDITOR_STEP, useWizardStep} from './wizardUrl'; -/** URL의 jobId 복구 → 서버 상태 조회 → 완료 시 편집기. 시간으로 단계를 추정하지 않는다. */ +/** URL의 jobId 복구 → 서버 상태 조회 → 완료 시 편집기. */ export function useGenerationJob(placeId: string | null) { const [params] = useSearchParams(); const [, goToStep] = useWizardStep(); diff --git a/solution/frontend/src/features/onboarding/usePlaceSearch.ts b/solution/frontend/src/features/onboarding/usePlaceSearch.ts index 3dc4e68..eb46334 100644 --- a/solution/frontend/src/features/onboarding/usePlaceSearch.ts +++ b/solution/frontend/src/features/onboarding/usePlaceSearch.ts @@ -11,7 +11,7 @@ import type {ConfirmedIdentity} from '@/stores/builder'; import {describeError} from '@/lib/errorMessages'; import {notify, notifyApiError} from '@/lib/notify'; -/** 빌더 업종 → places.category. 반대 방향은 placeAdapter 의 CATEGORY_TO_INDUSTRY 다. */ +/** 빌더 업종 → places.category. */ const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = { stay: PlaceCategoryEnum.LODGING, cafe: PlaceCategoryEnum.CAFE, @@ -24,31 +24,21 @@ export type SearchPhase = | 'idle' /** 공개 검색 중. */ | 'searching' - /** 후보를 받았다(0건일 수도 있다). */ | 'done' - /** - * 장소 DB 를 부를 수 없다 — 키가 없거나, 분당 호출을 넘겼거나, 백엔드가 응답하지 않는다. - * ★ 이때 가짜 후보를 지어내지 않는다. 검색 결과인 척하는 예시 카드는 - * 사장님이 남의 가게를 자기 가게로 확정하게 만드는 가장 빠른 길이다. - */ + /** 장소 DB 를 부를 수 없다 — 키가 없거나, 분당 호출을 넘겼거나, 백엔드가 응답하지 않는다. */ | 'unavailable'; export interface PlaceSearchState { phase: SearchPhase; - /** 공개 검색 후보. 서버가 자동 확정하지 않으므로 항상 사람이 고른다. */ + /** 공개 검색 후보. */ items: PlaceSearchItem[]; - /** 검색을 못 한 이유. phase === 'unavailable' 일 때만 채워진다. */ + /** 검색을 못 한 이유. */ unavailableReason: string; - /** - * 고른 후보를 자동으로 확정하지 못한 이유. - * ★ 실패를 조용히 삼키면 사장님은 "눌렀는데 아무 일도 안 일어난다"만 겪는다 — - * 네이버 플레이스 URL 붙여넣기로 넘어가야 한다는 걸 여기서 말한다. - */ + /** 고른 후보를 자동으로 확정하지 못한 이유. */ pickError: string; } -/** 네이버 플레이스가 확정되지 않았을 때의 유일한 안내. 두 갈래(토큰 없음·자동 발견 실패)가 - * 같은 문구를 써야 사장님이 볼 칸이 하나로 모인다. */ +/** 네이버 플레이스가 확정되지 않았을 때의 유일한 안내. */ const INITIAL: PlaceSearchState = { phase: 'idle', items: [], @@ -56,40 +46,21 @@ const INITIAL: PlaceSearchState = { pickError: '', }; -/** 상호명 비교용. 공백·괄호 표기가 후보마다 달라 그대로 비교하면 같은 가게도 안 맞는다. */ +/** 상호명 비교용. */ function normalizeName(name: string | undefined | null): string { return (name ?? '').replace(/\s+/g, '').toLowerCase(); } -/** - * 상호명 공개 검색 → 사람이 고른 후보로 확정. - * - * ★ 목록을 주는 건 **공개 검색**(`GET /v1/place/search`)이다. 인증이 없어 로그인 전에도 돌고, - * 후보마다 추정 업종(category)이 함께 온다 — 업종을 사장님에게 먼저 묻지 않기로 한 결정이 - * API 까지 내려온 자리다. 인증이 필요한 후보 조회(verify/candidates)는 목록을 그리는 데 - * 쓰지 않고, 고른 뒤 **네이버 플레이스 URL 을 찾는 데만** 쓴다. - * - * 확정은 백엔드 순서 그대로다: - * 1) POST /v1/place 사업장(껍데기) — 업종이 여기서 정해진다 - * 2) GET /v1/place/{id}/verify/candidates?query= 네이버 플레이스 URL 찾기 - * 3) POST /v1/place/{id}/verify/by-url 그 URL 로 동일 업소 확정 - * - * ★ 3 을 통과해야 수집이 열린다(place.py: "동일 업소 검증과 채널 URL 확정이 끝나야 시작할 수 있다"). - * 그래서 로그인 전 경로는 수집 없이 사장님이 고른 상호·주소만으로 사이트를 만든다 — 검증을 우회하지 않는다. - */ +/** 상호명 공개 검색 → 사람이 고른 후보로 확정. */ export function usePlaceSearch(existingPlaceId: string | null = null) { const [state, setState] = useState<PlaceSearchState>(INITIAL); const [isConfirming, setIsConfirming] = useState(false); - /** - * 이 위저드가 만든 사업장. 상호를 고쳐 다시 검색해도 사업장을 새로 만들지 않는다. - * ★ 새로고침을 넘어온 경우 스토어에 남아 있는 사업장을 그대로 이어받는다 — 안 그러면 - * 되살아난 화면이 같은 가게를 한 번 더 만든다. - */ + /** 이 위저드가 만든 사업장. */ const placeIdRef = useRef<string | null>(existingPlaceId); - /** 그 사업장을 만들 때 쓴 업종. 업종이 바뀌면 재사용할 수 없다(아래 ensurePlace). */ + /** 그 사업장을 만들 때 쓴 업종. */ const placeCategoryRef = useRef<PlaceCategory | null>(null); const inflight = useRef<AbortController | null>(null); - /** 마지막으로 확정 시도한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */ + /** 마지막으로 확정 시도한 상호. */ const nameRef = useRef<string>(''); const reset = useCallback(() => { @@ -98,13 +69,7 @@ export function usePlaceSearch(existingPlaceId: string | null = null) { setState(INITIAL); }, []); - /** - * 사업장 껍데기 확보. - * - * ★ 업종이 달라졌으면 **새로 만든다.** places.category 는 생성할 때만 정해지고 PATCH 로 고칠 - * 수 없다(Req_UpdatePlace 에 category 가 없다). 그대로 재사용하면 카페로 만든 사업장에 - * 숙박 스키마로 수집이 돌고 JSON-LD 타입도 어긋난다 — 화면도 빌드도 멀쩡한 채로 틀린다. - */ + /** 사업장 껍데기 확보. */ const ensurePlace = useCallback( async (name: string, category: PlaceCategory, signal: AbortSignal): Promise<string | null> => { const reusable = @@ -124,12 +89,7 @@ export function usePlaceSearch(existingPlaceId: string | null = null) { [], ); - /** - * 상호명으로 후보를 찾는다 — **로그인 없이**. - * - * ★ 사업장을 만들지 않는다. 아직 "내 가게가 여기 있나"를 보는 중이고, 여기서 행을 만들면 - * 검색만 해보고 떠난 사람만큼 빈 사업장이 쌓인다. - */ + /** 상호명으로 후보를 찾는다 — **로그인 없이**. */ const searchPublic = useCallback(async (name: string, location: string) => { const query = [name.trim(), location.trim()].filter(Boolean).join(' '); if (query.length < 2) return; @@ -144,8 +104,7 @@ export function usePlaceSearch(existingPlaceId: string | null = null) { const res = await searchPlacesPublic({q: query}, undefined, controller.signal); if (controller.signal.aborted) return; - // ★ 이 엔드포인트의 거절은 HTTP 200 + result.success=false 로 온다(RemoveNoneResponse). - // 상태코드만 보면 "후보 0건"과 "분당 20회 초과"가 같은 화면이 된다. + // 이 엔드포인트의 거절은 HTTP 200 + result.success=false 로 온다(RemoveNoneResponse). if (res.result?.success === false) { setState({ ...INITIAL, @@ -169,20 +128,11 @@ export function usePlaceSearch(existingPlaceId: string | null = null) { } }, []); - /** - * 네이버 플레이스 URL 로 확정한다. - * - * ★ 이 경로가 가장 확실하다. 상호 검색은 동명 업소·지점명 표기 차이에서 실패하고 - * (실측: '롯데호텔 서울'), 자동 발견은 네이버가 검색 결과에서 플레이스 id 를 빼면서 - * 2026-09-01 부터 아예 안 된다. 반면 사장님은 자기 가게 주소를 이미 안다. - */ + /** 네이버 플레이스 URL 로 확정한다. */ const confirmByUrl = useCallback( async (url: string): Promise<ConfirmedIdentity | null> => { setState((prev) => ({...prev, pickError: ''})); - // ★ 여기서 서버를 부르지 않는다. 사업장 생성·검증은 토큰을 요구하는데, 로그인은 - // **수집 직전 한 번만** 묻기로 했다. 주소만 신원에 실어 두고, 수집을 누를 때 - // 로그인 → 사업장 생성 → 이 주소로 검증 → 수집을 한 번에 보낸다. - // 상호·주소는 그때 서버가 이 주소에서 읽어 덮어쓴다. + // 여기서 서버를 부르지 않는다. return { placeId: placeIdRef.current, name: nameRef.current || '내 가게', @@ -195,12 +145,7 @@ export function usePlaceSearch(existingPlaceId: string | null = null) { [], ); - /** - * 목록에 없거나 로그인 전이라 검증을 못 걸 때 — 사장님이 고른 값으로 신원만 세운다. - * - * ★ 동일 업소 검증(POST /verify)을 하지 않는다. 검증 없이 수집을 열면 남의 가게 URL 을 - * 긁을 수 있다. 그래서 이 경로는 수집 없이 이 값들로만 사이트를 만든다. - */ + /** 목록에 없거나 로그인 전이라 검증을 못 걸 때 — 사장님이 고른 값으로 신원만 세운다. */ const confirmManual = useCallback( (name: string, address: string, sourceLabel = '직접 입력'): ConfirmedIdentity => ({ placeId: placeIdRef.current, @@ -212,15 +157,7 @@ export function usePlaceSearch(existingPlaceId: string | null = null) { [], ); - /** - * 공개 검색에서 고른 한 건을 이 위저드의 사업장으로 확정한다. - * - * ★ 로그인 전에는 서버를 부르지 않는다. 로그인 관문은 에디터 진입 하나이고(b94daa9), - * 사업장 생성·검증은 전부 토큰을 요구한다 — 여기서 부르면 첫 화면이 로그인 벽이 된다. - * 고른 후보의 **공식 상호·주소**로 신원을 세우고 넘어간다(검증은 로그인 뒤에 다시 걸 수 있다). - * ★ 로그인 상태면 공개 후보에 없는 것(네이버 플레이스 URL)을 인증 경로에서 한 번 더 찾아 - * URL 확정까지 간다 — 그래야 수집이 열린다. - */ + /** 공개 검색에서 고른 한 건을 이 위저드의 사업장으로 확정한다. */ const confirmPick = useCallback( async ( pick: {name: string; address: string}, @@ -228,8 +165,7 @@ export function usePlaceSearch(existingPlaceId: string | null = null) { ): Promise<ConfirmedIdentity | null> => { nameRef.current = pick.name.trim(); setState((prev) => ({...prev, pickError: ''})); - // ★ 서버를 부르지 않는다(confirmByUrl 주석). 고른 후보의 상호·주소로 신원만 세우고, - // 사업장 생성·검증은 수집 직전 로그인 뒤에 한 번에 한다. + // 서버를 부르지 않는다(confirmByUrl 주석). return { placeId: placeIdRef.current, name: pick.name.trim(), diff --git a/solution/frontend/src/features/onboarding/wizardUrl.ts b/solution/frontend/src/features/onboarding/wizardUrl.ts index a48ef0f..bc99a09 100644 --- a/solution/frontend/src/features/onboarding/wizardUrl.ts +++ b/solution/frontend/src/features/onboarding/wizardUrl.ts @@ -3,18 +3,11 @@ import {useSearchParams} from 'react-router'; import {useBuilderStore} from '@/stores/builder'; import {editorParams} from './wizardQuery'; -/** - * 위저드 단계는 **주소창이 소유한다** — `/builder?step=<이름>`. - * - * ★ 예전엔 스토어(zustand)의 `step` 이 화면을 골랐다. 브라우저가 보기엔 주소가 한 번도 안 바뀌니 - * 뒤로가기가 이전 단계가 아니라 위저드 **밖으로** 나갔고, 북마크한 주소는 언제나 첫 화면으로 열렸다. - * ★ 번호가 아니라 이름을 쓴다. 단계는 늘고 준다(업종 선택이 첫 화면에서 빠지면서 뒤가 전부 한 칸 - * 당겨졌다) — 번호를 주소에 박아 두면 그날 이후 옛 북마크·랜딩 링크가 조용히 다른 화면을 연다. - */ +/** 위저드 단계는 **주소창이 소유한다** — `/builder?step=<이름>`. */ export const WIZARD_STEPS = [ /** 상호명으로 내 가게 찾기 — 위저드의 시작점이다. */ 'search', - /** 업종 직접 고르기. 검색 결과가 업종을 못 정했을 때, 또는 [바꾸기] 로 들어온다. */ + /** 업종 직접 고르기. */ 'industry', 'collect', 'template', @@ -24,17 +17,10 @@ export const WIZARD_STEPS = [ export type WizardStep = (typeof WIZARD_STEPS)[number]; -/** 위저드가 끝나고 편집기로 넘어가는 단계. 숫자를 화면마다 외우지 않게 한 곳에 둔다. */ +/** 위저드가 끝나고 편집기로 넘어가는 단계. */ export const EDITOR_STEP: WizardStep = 'editor'; -/** - * 진행 표시(WizardSteps)에 찍히는 번호. - * - * ★ 업종 화면은 '내 가게 확인' 안의 **갈래**라 같은 1번이다. 업종은 검색 결과가 정하고 - * 못 정했을 때만 사람에게 묻는 것이라, 별도 단계로 세면 사장님은 안 겪을 수도 있는 단계를 - * 진행 표시에서 계속 보게 된다. - * ★ 생성 화면은 진행 표시를 그리지 않는다(0 은 "표시할 번호가 없다"는 뜻이다). - */ +/** 진행 표시(WizardSteps)에 찍히는 번호. */ export const STEP_NUMBER: Record<WizardStep, number> = { search: 1, industry: 1, @@ -48,13 +34,7 @@ function parseStep(value: string | null): WizardStep | null { return WIZARD_STEPS.includes(value as WizardStep) ? (value as WizardStep) : null; } -/** - * 주소창에 `step` 이 없을 때 열리는 화면. - * - * ★ `?placeId=` 딥링크는 **편집하러 온 것**이다(내 사이트 목록의 [사이트 편집]) — 위저드를 - * 다시 걷게 하지 않는다. 위저드가 방금 만든 사업장(`flow=onboarding`)만 예외다: - * 그건 아직 수집·템플릿을 지나는 중이고 그때는 step 이 항상 함께 붙어 있다. - */ +/** 주소창에 `step` 이 없을 때 열리는 화면. */ export function defaultStep(params: URLSearchParams): WizardStep { return params.get('placeId') && params.get('flow') !== 'onboarding' ? EDITOR_STEP : 'search'; } @@ -62,16 +42,11 @@ export function defaultStep(params: URLSearchParams): WizardStep { interface GoOptions { /** 히스토리에 쌓지 않는다 — 화면이 바뀌지 않는 정정(주소창 정리)일 때만 쓴다. */ replace?: boolean; - /** 같은 이동에서 함께 바꿀 쿼리. null 이면 지운다. 단계와 함께 한 번에 바꿔야 중간 주소가 안 생긴다. */ + /** 같은 이동에서 함께 바꿀 쿼리. */ params?: Record<string, string | null>; } -/** - * 지금 단계와, 단계를 옮기는 함수. - * - * ★ 다른 쿼리(`placeId`·`flow`·`q`)는 건드리지 않고 `step` 만 갈아 끼운다 — 단계를 옮길 때마다 - * 사업장 딥링크가 떨어져 나가면 새로고침 한 번에 어느 가게였는지가 사라진다. - */ +/** 지금 단계와, 단계를 옮기는 함수. */ export function useWizardStep(): [WizardStep, (next: WizardStep, options?: GoOptions) => void] { const [searchParams, setSearchParams] = useSearchParams(); const savedPlaceId = useBuilderStore((state) => state.confirmedIdentity?.placeId ?? state.placeId); diff --git a/solution/frontend/src/features/publish/AeoReadiness.tsx b/solution/frontend/src/features/publish/AeoReadiness.tsx index 662bb70..f26d619 100644 --- a/solution/frontend/src/features/publish/AeoReadiness.tsx +++ b/solution/frontend/src/features/publish/AeoReadiness.tsx @@ -2,7 +2,7 @@ import {Check, Minus} from 'lucide-react'; import type {IndustryType, SectionItem} from '@o2o/shared'; import {cn} from '@/lib/utils'; -/** 업종 → 발행 시 붙는 Schema.org 최상위 타입. `site/src/seo/jsonld.ts` 와 같은 표를 본다. */ +/** 업종 → 발행 시 붙는 Schema.org 최상위 타입. */ export const SCHEMA_TYPE_BY_INDUSTRY: Record<IndustryType, string> = { stay: 'LodgingBusiness', cafe: 'CafeOrCoffeeShop', @@ -16,12 +16,7 @@ interface AeoItem { enabled: boolean; } -/** - * 발행하면 실제로 무엇이 나가는지. - * - * 체크박스가 아니라 *사실 보고서*다 — 켜고 끄는 UI 가 아니라, - * 지금 설정으로 발행하면 AI 검색이 무엇을 읽게 되는지를 보여준다. - */ +/** 발행하면 실제로 무엇이 나가는지. */ export function buildAeoItems(industry: IndustryType, sections: SectionItem[]): AeoItem[] { const enabled = (id: string) => sections.find((s) => s.id === id)?.isEnabled ?? false; diff --git a/solution/frontend/src/features/publish/PublishModal.tsx b/solution/frontend/src/features/publish/PublishModal.tsx index 79c11ab..bd950bc 100644 --- a/solution/frontend/src/features/publish/PublishModal.tsx +++ b/solution/frontend/src/features/publish/PublishModal.tsx @@ -37,31 +37,13 @@ import { type PublishState, } from './usePublishSite'; -// 개발에서는 admin 과 같은 :3000을 공개 주소로 쓴다. `/s` 요청은 Vite가 정적 사이트 -// 서버(:3001)로 프록시한다. 운영에서는 VITE_PUBLISH_HOST로 공개 호스트를 명시할 수 있다. -// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 -// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. -// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다) +// 개발에서는 admin 과 같은 :3000을 공개 주소로 쓴다. -/** - * 가게 확인이 안 끝난 사장님을 돌려보낼 곳. - * - * ★ 단계 이름은 wizardUrl 이 소유한다 — 문자열을 그냥 박아 두면 이름이 바뀌는 날 - * 주소창의 step 을 아무도 못 알아보고 조용히 첫 화면이 열린다(defaultStep). - */ +/** 가게 확인이 안 끝난 사장님을 돌려보낼 곳. */ const PLACE_SEARCH_STEP: WizardStep = 'search'; const PLACE_SEARCH_URL = `/builder?step=${PLACE_SEARCH_STEP}`; -/** - * 발행을 **시작조차 할 수 없는** 상태. 서버를 부를 수 없으므로 발행 버튼을 그리지 않는다. - * - * ★ 예전에는 이 두 경우가 [지금 발행하기] 로 그대로 흘러 들어와, 서버를 한 번도 부르지 않고 - * 성공 토스트를 띄운 뒤 [사이트 열기] 까지 그렸다. 그 주소는 아무것도 굽지 않은 주소라 - * 404 였고 내 사이트 목록에도 없었다 — 사장님만 발행됐다고 믿는 상태가 남았다. - * ★ 'place' 가 진짜 함정이다. 로그인은 했는데(에디터 관문을 지났다) 3단계의 - * [발행 없이 화면만 둘러보기] 로 나오면 서버에 사업장이 없다 — 화면은 끝까지 도는데 - * 발행만 안 된다. - */ +/** 발행을 **시작조차 할 수 없는** 상태. */ type PublishBlocker = 'signin' | 'place'; export function PublishModal() { @@ -78,28 +60,17 @@ export function PublishModal() { const [copied, setCopied] = useState(false); - /** - * 발행 주소. 서버가 이미 확정했으면 그걸 보여주고 잠근다 — 색인된 주소는 바꾸지 않는다. - * 아직이면 사장님이 고른다(상호에서 자동 생성하지 않는다 — SlugField 주석 참고). - */ + /** 발행 주소. */ const [slug, setSlug] = useState(''); const [slugStatus, setSlugStatus] = useState<SlugStatus>({kind: 'idle'}); - /** - * 실사업장이면 진짜 빌드를 태운다(`POST /site/build {publish:true}` → 잡 폴링). - * ★ 그럴 수 없는 상태(로그인 전 · 사업장 미확정)에서는 발행 버튼 자체를 그리지 않는다. - * PublishBlocker 주석 참고. - */ + /** 실사업장이면 진짜 빌드를 태운다(`POST /site/build {publish:true}` → 잡 폴링). */ const publisher = usePublishSite(placeId); const {state, reset} = publisher; const navigate = useNavigate(); - /* - * ★ 토큰은 스토어 밖(custom-fetch)에 있어 구독할 수 없다. 로그인이 auth 스토어의 user 도 - * 함께 심으므로(lib/session.establishSession) 그걸 **재렌더 신호로만** 구독한다 — - * 없으면 아래 로그인 폼으로 로그인을 마쳐도 모달은 계속 "로그인해 주세요" 로 남는다. - */ + /* 토큰은 스토어 밖(custom-fetch)에 있어 구독할 수 없다. */ useAuthStore((s) => s.user); /** 판정 기준은 usePublishSite.isLive 와 같다(placeId + 토큰) — 갈리면 버튼과 실제가 어긋난다. */ @@ -117,10 +88,7 @@ export function PublishModal() { infoFields, photos, sections, - // ★ 게이트의 '고유 콘텐츠'는 수집·생성된 소개문이어야 한다. - // 예전에는 업종 시드의 introText(가공의 문장)를 넣어서, 실제로는 이 가게만의 - // 콘텐츠가 한 글자도 없어도 게이트가 통과했다 — 스팸 판정을 막으라고 둔 검사가 - // 시연용 문장으로 자기 자신을 속이고 있었다. + // 게이트의 '고유 콘텐츠'는 수집·생성된 소개문이어야 한다. uniqueContent: [ infoFields.find((f) => f.id === 'intro' || f.id === 'room_intro')?.value ?? '', ].filter((s) => s.trim()), @@ -129,7 +97,6 @@ export function PublishModal() { ); // 한글 상호는 서브도메인에 못 넣는다(퓨니코드 문제) — publishUrlString 이 경로형으로 떨어뜨린다. - // 서버가 도메인을 확정해 줬으면(sites.domain) 그게 우선이다. const domain = publisher.site?.domain; /** 주소가 이미 서버에 박혀 있는가 — 그러면 입력을 잠근다. */ const isSlugLocked = Boolean(domain); @@ -151,35 +118,18 @@ export function PublishModal() { if (state.phase === 'published') setPublishedUrl(url); }, [state.phase, url, setPublishedUrl]); - // 모달을 닫으면 지난 빌드 결과를 버린다. 다시 열었을 때 옛 거부 사유가 남아 있으면 - // 이미 고친 항목을 다시 고치라고 말하는 꼴이 된다. + // 모달을 닫으면 지난 빌드 결과를 버린다. useEffect(() => { if (!isOpen) reset(); }, [isOpen, reset]); - /** - * ★ 서버가 발행을 확정한 것만 '발행됨'이다. - * 예전에는 `!isLive && publishedUrl` 도 완료로 쳤는데, 그 publishedUrl 을 채우던 것이 - * 서버를 한 번도 부르지 않는 가짜 경로였다 — 굽지도 않은 주소에 [사이트 열기] 가 붙었다. - */ + /** 서버가 발행을 확정한 것만 '발행됨'이다. */ const isDone = state.phase === 'published'; - /** - * 이미 한 번 발행한 사이트인가 — 문구를 '발행'과 '재발행'으로 가른다. - * - * ★ 발행 = 빌드다. 재발행을 누르면 HTML 이 처음부터 다시 구워지고 새 버전이 남는다 - * (usePublishSite 주석). 사장님에게는 그게 "고친 걸 사이트에 반영한다"는 뜻이다. - * ★ 발행이 끝난 뒤(isDone)에는 이 값이 참으로 뒤집히지만, 그때 화면은 완료 분기라 - * 여기 문구를 쓰지 않는다 — 버튼 글자가 도중에 바뀌는 일은 없다. - */ + /** 이미 한 번 발행한 사이트인가 — 문구를 '발행'과 '재발행'으로 가른다. */ const isRepublish = Boolean(publisher.currentVersion?.version); - /** - * 주소 중복 확인. - * - * ★ 서버 판정이 유일한 근거다. 화면 정규식은 왕복을 줄이는 사전 점검일 뿐이고, - * "사용 가능"은 서버가 말해야 한다 — 두 사람이 동시에 같은 주소를 노릴 수 있다. - */ + /** 주소 중복 확인. */ const checkSlug = useCallback(async () => { const local = localValidate(slug); if (local) { @@ -201,12 +151,10 @@ export function PublishModal() { }, [slug, placeId]); const handlePublish = async () => { - // ★ blocker 가 있으면 이 버튼은 그려지지도 않는다. 그래도 한 번 더 막는다 — - // 서버를 못 부르는 상태로 여기를 지나가는 것이 곧 '가짜 발행'이다. + // blocker 가 있으면 이 버튼은 그려지지도 않는다. if (blocker || !gate.canPublish || publisher.isPublishing || !slugReady) return; - // ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고, - // 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다. + // 주소를 먼저 확정하고 빌드한다. if (!isSlugLocked) { try { await reserveSiteSlug(placeId ?? '', slug); @@ -215,7 +163,7 @@ export function PublishModal() { return; } } - // 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다. + // 진짜 게이트는 서버에 있다(services/publish_gate). publisher.publish(); }; @@ -260,15 +208,13 @@ export function PublishModal() { 계속 편집하기 </Button> - {/* ★ 로그인 전에는 여기에 아무 버튼도 두지 않는다 — 다음 행동(로그인)은 본문의 - 폼이고, 옆에 [발행하기] 를 세워 두면 누를 수 있는 것처럼 보인다. */} + {/* 로그인 전에는 여기에 아무 버튼도 두지 않는다 — 다음 행동(로그인)은 본문의 폼이고, 옆에 [발행하기] 를 세워 두면 누를 수 있는 것처럼 보인다. */} {blocker === 'signin' ? null : blocker === 'place' ? ( <Button variant="primary" className="flex-1" onClick={() => { - // 모달을 먼저 닫는다 — 같은 화면(BuilderPage) 안에서 단계만 바뀌는 이동이라 - // 열어 둔 채로 가면 가게 찾기 위에 이 모달이 그대로 떠 있는다. + // 모달을 먼저 닫는다 — 같은 화면(BuilderPage) 안에서 단계만 바뀌는 이동이라 열어 둔 채로 가면 가게 찾기 위에 이 모달이 그대로 떠 있는다. close(); navigate(PLACE_SEARCH_URL); }} @@ -318,7 +264,7 @@ export function PublishModal() { {blocker === 'signin' && <SignInFirstPanel />} {blocker === 'place' && <PlaceFirstPanel />} - {/* 서버가 보는 현재 상태. 편집 중 상태만 보고 판단하지 않게 맨 위에 둔다. */} + {/* 서버가 보는 현재 상태. */} {!blocker && !isDone && ( <SiteStatusRow version={publisher.currentVersion?.version} @@ -327,9 +273,7 @@ export function PublishModal() { /> )} - {/* 주소는 발행의 전제다 — 점검 항목보다 먼저 정해야 [발행하기] 가 열린다. - ★ blocker 상태에서는 그리지 않는다. 주소 중복 확인이 사업장·토큰을 요구하므로 - 입력칸만 열어 두면 확인 버튼이 매번 실패한다. */} + {/* 주소는 발행의 전제다 — 점검 항목보다 먼저 정해야 [발행하기] 가 열린다. */} {!blocker && !isDone && ( <SlugField value={slug} @@ -379,7 +323,7 @@ export function PublishModal() { </div> )} - {/* ★ 서버 판정. 화면 점검(gate)이 통과여도 여기서 막힐 수 있다 — 저장된 fact 기준이라 기준값이 다르다. */} + {/* 서버 판정. */} <ServerVerdict state={state} /> {placeId && (isDone || isRepublish) && <SocialPanel placeId={placeId} />} @@ -404,15 +348,7 @@ export function PublishModal() { ); } -/** - * 로그인 전 — **미리보기라고 말하고, 그 자리에서 로그인을 받는다.** - * - * ★ /login 으로 튕기지 않는다. 위저드·에디터 상태는 브라우저에 저장하지 않으므로 - * (stores/builder 주석) 화면을 떠나는 순간 지금까지 만든 것이 통째로 사라진다. - * 에디터 관문(EditorSignInGate)이 같은 이유로 같은 폼을 그 자리에 놓는다. - * ★ 로그인이 끝나면 폼이 심은 토큰으로 blocker 가 저절로 다시 계산된다 — 이 패널이 사라지고 - * 발행 점검 화면이 뜬다(사업장이 아직 없으면 아래 PlaceFirstPanel 로 넘어간다). - */ +/** 로그인 전 — **미리보기라고 말하고, 그 자리에서 로그인을 받는다.** */ function SignInFirstPanel() { return ( <div className="space-y-3"> @@ -443,13 +379,7 @@ function SignInFirstPanel() { ); } -/** - * 로그인은 했는데 서버에 사업장이 없다 — 3단계에서 수집을 건너뛰고 나온 경로다. - * - * ★ 여기서 사업장을 몰래 만들지 않는다. 생성과 네이버 검증의 순서는 - * features/onboarding/ensureServerPlace 한 곳이 소유한다 — 검증을 건너뛰고 만든 사업장은 - * 수집도 발행도 못 하는 껍데기로 남고, 그 사실은 화면에 안 나온다. - */ +/** 로그인은 했는데 서버에 사업장이 없다 — 3단계에서 수집을 건너뛰고 나온 경로다. */ function PlaceFirstPanel() { return ( <div className="rounded-lg border border-warning/40 bg-warning/8 p-3 text-xs"> @@ -469,16 +399,8 @@ function PlaceFirstPanel() { ); } -/** 서버가 보는 사이트 상태 한 줄. 편집 중 상태(gate)와 저장된 상태를 구분해 준다. */ -/** - * 서버가 보는 현재 발행 상태 한 줄. - * - * ★ '재빌드 필요' 배지는 **발행 이력이 있을 때만** 뜬다. - * 서버의 needs_rebuild 는 발행본이 없을 때도 true 다(site_service: 버전이 없으면 - * `bool(place.content_updated_at)`) — "구울 것이 있다"는 뜻이지 "다시 구워야 한다"가 아니다. - * 그걸 그대로 배지로 그리면 한 번도 발행한 적 없는 사이트가 - * "아직 발행된 버전이 없습니다 · 재빌드 필요" 라고 자기모순을 말한다. - */ +/** 서버가 보는 사이트 상태 한 줄. */ +/** 서버가 보는 현재 발행 상태 한 줄. */ function SiteStatusRow({ version, needsRebuild, @@ -504,12 +426,7 @@ function SiteStatusRow({ ); } -/** - * 빌드 잡이 끝난 뒤의 서버 판정. - * - * ★ 게이트 거부와 빌드 실패를 다르게 그린다. 거부는 "고칠 곳이 있다"이고 - * 실패는 "우리 쪽 문제"다 — 같은 빨간 상자로 묶으면 사장님이 뭘 해야 할지 모른다. - */ +/** 빌드 잡이 끝난 뒤의 서버 판정. */ function ServerVerdict({state}: {state: PublishState}) { if (state.phase === 'building') { return ( @@ -543,7 +460,7 @@ function ServerVerdict({state}: {state: PublishState}) { return null; } -/** 서버 발행 검수 게이트가 막은 이유. `publish_logs.reject_reason` 과 같은 값이다. */ +/** 서버 발행 검수 게이트가 막은 이유. */ function GateRejectCard({gate}: {gate: BuildGateResult}) { const reason = gate.reason ?? ''; const detail = describeGate(gate); @@ -564,7 +481,7 @@ function GateRejectCard({gate}: {gate: BuildGateResult}) { ); } -/** 거부 사유별 상세를 한 줄로. 사유마다 실려 오는 키가 다르다(publish_gate.GateResult.detail). */ +/** 거부 사유별 상세를 한 줄로. */ function describeGate(gate: BuildGateResult): string | null { if (gate.unverified?.length) { const keys = gate.unverified.slice(0, 5).map((f) => f.key).join(' · '); @@ -595,8 +512,7 @@ function FindingCard({finding}: {finding: GateFinding}) { )} > <div className="mb-1 flex items-center justify-between gap-2"> - {/* ★ 거절 사유 코드는 띄우지 않는다 — 사장님 화면에 개발용 식별자가 나갈 자리가 아니다. - (사유는 publish_logs 에 그대로 남으므로 운영 쪽에서 추적할 수 있다.) */} + {/* 거절 사유 코드는 띄우지 않는다 — 사장님 화면에 개발용 식별자가 나갈 자리가 아니다. */} <span className="font-bold">{finding.title}</span> </div> <p className="leading-relaxed text-muted-foreground">{finding.detail}</p> diff --git a/solution/frontend/src/features/publish/SlugField.tsx b/solution/frontend/src/features/publish/SlugField.tsx index 46cb245..0f5429f 100644 --- a/solution/frontend/src/features/publish/SlugField.tsx +++ b/solution/frontend/src/features/publish/SlugField.tsx @@ -5,17 +5,7 @@ import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {cn} from '@/lib/utils'; -/** - * 발행 주소(네임스페이스) 입력. - * - * ★ 왜 사장님이 직접 고르는가 - * 주소는 한 번 발행되면 AI 검색이 색인하는 영구 식별자다. 상호에서 자동 생성하면 - * ① 한글이 그대로 들어가 URL 이 퍼센트 인코딩 범벅이 되고 - * ② 음차하면 규칙이 사람마다 달라 같은 가게가 두 주소를 갖는다. - * 사장님이 고르면 두 문제가 동시에 사라진다 — 대신 **중복 여부를 먼저 확인해야** 한다. - * - * 서버 규칙과 같은 정규식을 쓴다. 다만 최종 판정은 항상 서버다(여기 통과는 왕복을 줄이는 용도). - */ +/** 발행 주소(네임스페이스) 입력. */ const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,48}[a-z0-9])$/; export type SlugStatus = @@ -75,8 +65,7 @@ export function SlugField({ <div className="flex items-center gap-1.5"> <div className="flex min-w-0 flex-1 items-center rounded-lg border border-border bg-card pl-3 focus-within:border-primary"> - {/* 주소는 경로형이다(<host>/s/<slug>) — 접두사를 앞에 붙여 실제 모양 그대로 보여준다. - 서브도메인처럼 보이면 사장님이 열리지 않는 주소를 기대하게 된다. */} + {/* 주소는 경로형이다(<host>/s/<slug>) — 접두사를 앞에 붙여 실제 모양 그대로 보여준다. */} <span className="shrink-0 whitespace-nowrap text-[11px] text-muted-foreground"> {host}/s/ </span> diff --git a/solution/frontend/src/features/publish/publishGate.ts b/solution/frontend/src/features/publish/publishGate.ts index 4a70569..6b9b246 100644 --- a/solution/frontend/src/features/publish/publishGate.ts +++ b/solution/frontend/src/features/publish/publishGate.ts @@ -1,14 +1,7 @@ import type {InfoField, PhotoItem, SectionItem} from '@o2o/shared'; import {PublishRejectReason} from '@o2o/shared'; -/** - * 발행 검수 게이트 — 클라이언트 쪽 사전 점검. - * - * ★ 진짜 게이트는 백엔드에 있다(`publish_logs.reject_reason`). 여기서 막는 건 - * "발행 버튼을 눌렀다가 서버에 거절당하는" 왕복을 없애기 위한 것이고, - * 판정 기준은 백엔드의 PublishRejectReason 과 1:1 로 맞춘다. - * 기준이 갈리면 화면은 통과라는데 서버는 막는 상황이 생긴다 — 그게 제일 나쁘다. - */ +/** 발행 검수 게이트 — 클라이언트 쪽 사전 점검. */ /** 이보다 짧으면 강한 경고를 내지만 발행은 막지 않는다. */ const MIN_UNIQUE_CHARS = 40; @@ -20,7 +13,7 @@ export type GateSeverity = 'block' | 'warn'; export interface GateFinding { id: string; severity: GateSeverity; - /** 백엔드 거절 사유와 대응되는 것만 채운다. SEO 권고 항목은 비어 있다. */ + /** 백엔드 거절 사유와 대응되는 것만 채운다. */ reason?: (typeof PublishRejectReason)[keyof typeof PublishRejectReason]; title: string; detail: string; @@ -34,7 +27,7 @@ export interface GateInput { infoFields: InfoField[]; photos: PhotoItem[]; sections: SectionItem[]; - /** 고유 콘텐츠(소개문 등) 문단. 업종 시드의 introText 가 기본값. */ + /** 고유 콘텐츠(소개문 등) 문단. */ uniqueContent: string[]; } @@ -50,16 +43,8 @@ export function runPublishGate(input: GateInput): GateResult { const findings: GateFinding[] = []; // ── 확인 전 fact ──────────────────────────────────────── - // ★ 발행을 막지 않고, 화면에 알리지도 않는다. - // 확인 전 값은 그냥 **사이트에 안 나갈 뿐**이다 — 사장님이 발행 전에 고쳐야 할 것이 아니다. - // "발행을 막는 항목"으로 띄우면 실제로 막히지도 않는 일로 발행을 겁먹게 만든다. - // (미검증 값이 새어 나가는 것은 백엔드가 payload 를 쓰기 전에 이미 걸러낸다.) // ── 업종 스키마 required 누락 ─────────────────────────── - // ★ 막지 않는다(2026-08-27 백엔드 결정과 같은 기준). - // 막아야 할 것은 "틀린 정보가 나가는 것"이지 "정보가 덜 찬 것"이 아니다. - // 영업시간이 비어 있어도 주소·전화가 있는 페이지는 그 자체로 쓸모가 있고, - // 사장님은 발행 뒤에 언제든 채울 수 있다(채우면 재빌드된다). const emptyRequired = input.infoFields.filter((f) => !f.value?.trim()); if (emptyRequired.length > 0) { findings.push({ @@ -71,10 +56,7 @@ export function runPublishGate(input: GateInput): GateResult { }); } - // ── 고유 콘텐츠 ───────────────────────────────────────── - // OTA 에 있는 문장만 있는 페이지는 AI 검색이 "출처가 여기일 이유"를 못 찾는다. - // - // 콘텐츠 품질은 권고 사항이다. 소개가 짧아도 사장님이 원하면 먼저 발행할 수 있어야 한다. + // ── 고유 콘텐츠 ───────────────────────────────────────── OTA 에 있는 문장만 있는 페이지는 AI 검색이 "출처가 여기일 이유"를 못 찾는다. const uniqueChars = input.uniqueContent.join('').replace(/\s/g, '').length; if (uniqueChars < MIN_UNIQUE_CHARS) { findings.push({ diff --git a/solution/frontend/src/features/publish/siteApi.ts b/solution/frontend/src/features/publish/siteApi.ts index b8ba8b5..aec6b10 100644 --- a/solution/frontend/src/features/publish/siteApi.ts +++ b/solution/frontend/src/features/publish/siteApi.ts @@ -1,17 +1,9 @@ import {getAccessToken} from '@/api'; -/** - * 손으로 쓴 site 엔드포인트의 공통 호출부. - * - * ★ 왜 생성된 API 클라이언트(orval)를 안 쓰나: 여기 있는 엔드포인트들이 백엔드에 갓 생겨서 - * 아직 `npm run orval` 로 재생성하지 않았다. 재생성하면 이 파일과 옆의 siteSlug·siteTemplate 은 - * 지우고 `@/api` 의 생성물로 갈아끼운다 — 손으로 쓴 fetch 를 오래 두지 않는다. - * ★ 토큰·baseURL 은 여기 한 곳에서만 붙인다. 파일마다 fetch 를 복사하면 생성물 mutator - * (api/mutator/custom-fetch)가 바뀔 때 손으로 쓴 쪽만 조용히 뒤처진다. - */ +/** 손으로 쓴 site 엔드포인트의 공통 호출부. */ const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9800'; -/** 백엔드 공통 응답 봉투. ★ 도메인 거절도 HTTP 200 + result.success=false 로 온다. */ +/** 백엔드 공통 응답 봉투. */ export interface ResultEnvelope { result?: {success?: boolean; code?: number; desc?: string}; } diff --git a/solution/frontend/src/features/publish/siteSlug.ts b/solution/frontend/src/features/publish/siteSlug.ts index c926a1f..789eba9 100644 --- a/solution/frontend/src/features/publish/siteSlug.ts +++ b/solution/frontend/src/features/publish/siteSlug.ts @@ -1,10 +1,6 @@ import {callSiteApi} from './siteApi'; -/** - * 사이트 주소(네임스페이스) 확인·예약. - * - * ★ 왜 생성된 API 클라이언트(orval)를 안 쓰나: siteApi.ts 참조(호출부도 거기 있다). - */ +/** 사이트 주소(네임스페이스) 확인·예약. */ export interface SlugCheckResult { available: boolean; reason?: string | null; @@ -19,7 +15,7 @@ export async function checkSiteSlug(placeId: string, slug: string): Promise<Slug ); } -/** 발행 직전에 주소를 확정한다. 발행된 사이트의 주소 변경은 서버가 거부한다. */ +/** 발행 직전에 주소를 확정한다. */ export async function reserveSiteSlug(placeId: string, slug: string): Promise<void> { if (!placeId) return; await callSiteApi(`/v1/place/${placeId}/site/slug`, { diff --git a/solution/frontend/src/features/publish/siteTemplate.ts b/solution/frontend/src/features/publish/siteTemplate.ts index 8b1063f..4d05b9f 100644 --- a/solution/frontend/src/features/publish/siteTemplate.ts +++ b/solution/frontend/src/features/publish/siteTemplate.ts @@ -2,15 +2,7 @@ import {ApiError} from '@/api'; import {notifyApiError} from '@/lib/notify'; import {callSiteApi, type ResultEnvelope} from './siteApi'; -/** - * 위저드에서 고른 템플릿을 서버에 저장한다(POST /v1/place/{id}/site/template). - * - * ★ 왜 저장하나: 고른 templateId 가 브라우저 상태로만 남으면 발행 잡(backend services/site_payload)이 - * 읽을 곳이 없어 업종 기본 템플릿으로 굽는다 — 사장님이 고른 디자인과 실제 발행본이 갈린다. - * ★ 주소(slug)와 달리 발행 뒤에도 저장된다. 서버가 잠그지 않는다 — - * 디자인이 바뀌어도 URL 은 그대로라 색인이 깨지지 않기 때문이다(대신 재빌드 대상으로 표시된다). - * ★ orval 을 쓰지 않는 이유는 siteApi.ts 참조. - */ +/** 위저드에서 고른 템플릿을 서버에 저장한다(POST /v1/place/{id}/site/template). */ export async function saveSiteTemplate(placeId: string, templateId: string): Promise<void> { // placeId 가 없으면(데모 경로) 저장할 사이트가 없다 — 네트워크를 한 번도 타지 않고 조용히 통과한다. if (!placeId) return; @@ -18,27 +10,16 @@ export async function saveSiteTemplate(placeId: string, templateId: string): Pro method: 'POST', body: JSON.stringify({template_id: templateId}), }); - // ★ 거절도 200 으로 온다. 여기서 throw 하지 않으면 저장 안 된 템플릿이 저장된 것처럼 넘어가고, - // 호출측은 알릴 근거를 잃는다. notifyApiError 가 읽는 자리(error.data)에 응답을 그대로 싣는다. + // 거절도 200 으로 온다. if (res.result?.success === false) throw new ApiError(200, 'SITE_TEMPLATE_REJECTED', res); } -/** - * 보낸 순서대로 흘리는 저장 대기줄. - * - * ★ 병렬로 쏘면 먼저 누른 템플릿의 요청이 늦게 도착해 마지막 선택을 덮을 수 있다 — - * 화면은 새 템플릿, 서버는 옛 템플릿이 되고 그 차이는 발행하고 나서야 드러난다. - */ +/** 보낸 순서대로 흘리는 저장 대기줄. */ let saveChain: Promise<void> = Promise.resolve(); /** 마지막 요청만 실패를 알린다 — 뒤엎힌 옛 선택의 실패까지 토스트로 쌓지 않는다. */ let saveSeq = 0; -/** - * 고른 템플릿을 저장하되 **화면을 기다리게 하지 않는다**. - * - * ★ 저장 실패가 위저드 진행을 막으면 안 된다. 템플릿은 주소와 달리 발행 뒤에도 바꿀 수 있는 값이라 - * 여기서 사장님을 세울 이유가 없다 — 알리기만 하고 다음 단계로 보낸다. - */ +/** 고른 템플릿을 저장하되 **화면을 기다리게 하지 않는다**. */ export function queueSiteTemplateSave(placeId: string | null, templateId: string) { if (!placeId || !templateId) return; saveSeq += 1; @@ -47,7 +28,7 @@ export function queueSiteTemplateSave(placeId: string | null, templateId: string try { await saveSiteTemplate(placeId, templateId); } catch (error) { - if (seq !== saveSeq) return; // 이미 다른 선택이 올라탔다 — 그쪽 결과만 알린다. + if (seq !== saveSeq) return; notifyApiError(error, '템플릿 선택을 저장하지 못했습니다. 발행 전에 다시 골라 주세요.'); } }); diff --git a/solution/frontend/src/features/publish/siteTheme.ts b/solution/frontend/src/features/publish/siteTheme.ts index dbc149f..bc6f686 100644 --- a/solution/frontend/src/features/publish/siteTheme.ts +++ b/solution/frontend/src/features/publish/siteTheme.ts @@ -92,7 +92,7 @@ export function queueSiteThemeSave(placeId: string | null, theme: SiteThemePaylo // 뒤에 올라탄 저장이 있으면 그것이 끝날 때 알린다 — 중간 상태로 두 번 그리지 않는다. if (seq === saveSeq) savedListeners.forEach((listener) => listener()); } catch (error) { - if (seq !== saveSeq) return; // 이미 다른 편집이 올라탔다 — 그쪽 결과만 알린다. + if (seq !== saveSeq) return; notifyApiError(error, '디자인 설정을 저장하지 못했습니다. 발행 전에 다시 확인해 주세요.'); } }); diff --git a/solution/frontend/src/features/publish/usePublishSite.ts b/solution/frontend/src/features/publish/usePublishSite.ts index 42165d1..ef66bf1 100644 --- a/solution/frontend/src/features/publish/usePublishSite.ts +++ b/solution/frontend/src/features/publish/usePublishSite.ts @@ -13,19 +13,7 @@ import type {SiteData, SiteVersionData} from '@/api'; import {describeError} from '@/lib/errorMessages'; import {notify, notifyApiError} from '@/lib/notify'; -/** - * 발행 = 빌드다. - * - * 백엔드에 "발행" 엔드포인트가 따로 없는 것이 실수가 아니다 — 발행 검수 게이트는 - * 빌드 잡 안에 있고(services/build_service → publish_gate), 게이트를 우회하는 경로가 - * 생기지 않게 하나로 묶여 있다. 그래서 프론트도 `POST /site/build {publish:true}` 하나만 부른다. - * - * POST /v1/place/{id}/site/build → job_id - * GET /v1/job/{job_id} → DONE 이면 job.result 에 판정이 들어 있다 - * - * ★ 잡이 DONE 이어도 발행됐다는 뜻이 아니다. 게이트가 막으면 잡은 정상 종료하고 - * result.gate.passed 가 false 로 온다 — 그걸 읽지 않으면 거부를 성공으로 보고한다. - */ +/** 발행 = 빌드다. */ /** 빌드 잡이 남기는 결과(services/build_service.run_build 의 반환값). */ export interface BuildJobResult { @@ -34,7 +22,7 @@ export interface BuildJobResult { version?: number; site_version_id?: string; build_status?: 'BUILT' | 'FAILED'; - /** 빌드 자체가 터진 경우. 게이트 거부와 구분된다. */ + /** 빌드 자체가 터진 경우. */ error?: string; unique_content_count?: number; mismatches?: string[]; @@ -65,15 +53,13 @@ export type PublishPhase = /** 잡을 넣고 폴링 중. */ | 'building' | 'published' - /** 게이트가 막았다 — 고칠 곳이 gate 에 들어 있다. */ | 'rejected' - /** 빌드가 터졌거나 상태를 못 읽었다. */ | 'failed'; export interface PublishState { phase: PublishPhase; result?: BuildJobResult; - /** 서버가 준 사람이 읽을 실패 사유. phase 가 failed 일 때만 채워진다. */ + /** 서버가 준 사람이 읽을 실패 사유. */ error?: string; } @@ -88,11 +74,10 @@ export const GATE_REASON_LABEL: Record<string, string> = { }; export interface UsePublishSiteResult { - /** ★ null 이면 데모 경로다 — 이 훅은 네트워크를 한 번도 타지 않는다. */ + /** null 이면 데모 경로다 — 이 훅은 네트워크를 한 번도 타지 않는다. */ isLive: boolean; site?: SiteData; currentVersion?: SiteVersionData; - /** 노출값이 바뀐 뒤 다시 빌드하지 않았다 — 이 사업장만 재빌드하면 된다. */ needsRebuild: boolean; state: PublishState; isPublishing: boolean; @@ -157,8 +142,7 @@ export function usePublishSite(placeId: string | null): UsePublishSiteResult { return; } if (outcome.kind === 'timeout') { - // ★ 실패가 아니다 — 잡은 서버에서 계속 돈다. 화면만 풀어 주고 상태는 idle 로 되돌린다. - // 'failed' 로 두면 "빌드는 계속 진행됩니다" 라고 말하면서 실패 화면을 그린다. + // 실패가 아니다 — 잡은 서버에서 계속 돈다. notify.warn('발행이 예상보다 오래 걸립니다.', '발행은 계속 진행됩니다 — 잠시 뒤 새로고침해 확인해 주세요.'); setState(IDLE); void queryClient.invalidateQueries({queryKey: getGetSiteQueryKey(id)}); @@ -167,8 +151,7 @@ export function usePublishSite(placeId: string | null): UsePublishSiteResult { const result = (outcome.job.result ?? {}) as BuildJobResult; - // ★ 순서가 중요하다. 게이트 거부는 잡이 정상 종료(DONE)하고 build_status 만 FAILED 다 — - // build_status 부터 보면 게이트 거부가 "빌드 실패"로 뭉개진다. + // 순서가 중요하다. if (result.gate && !result.gate.passed) { setState({phase: 'rejected', result}); } else if (result.published) { diff --git a/solution/frontend/src/features/social/SocialConnectionCard.tsx b/solution/frontend/src/features/social/SocialConnectionCard.tsx index 16bf683..d4118d1 100644 --- a/solution/frontend/src/features/social/SocialConnectionCard.tsx +++ b/solution/frontend/src/features/social/SocialConnectionCard.tsx @@ -3,22 +3,7 @@ import {Loader2, Link2, Unlink} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {socialApi, type SocialState} from './api'; -/** - * Threads 계정 연동 — **'내 사이트' 화면에 한 자리**. - * - * ★ 왜 발행 모달이 아니라 여기인가 - * 계정은 `user × provider` 단위다(표도 그렇게 생겼다 — `owner_social_accounts`). - * 연결 버튼이 사업장 안에 있으면 사장님은 **업장 수만큼 연결해야 하는 줄 안다.** - * 연결은 한 번, 게재는 사이트마다다 — 화면이 그 모양을 그대로 말해야 한다. - * 그래서 여기서 미리 연결해 두고, 발행한 뒤 사이트에서 [소개글 쓰기] 를 누른다. - * - * ★ 준비 전에도 **자리는 보여준다.** 단, 버튼은 죽여 둔다. - * 처음에는 `connection_enabled=false` 면 통째로 숨겼는데, 그러면 **기능이 없는 것처럼 보인다** — - * 이 기능을 만든 사람조차 "연동 버튼이 안 보인다" 고 했다(2026-09-14). 사장님은 더더욱 못 찾는다. - * 숨기는 것과 "아직 준비 중" 은 다른 말이고, 화면은 그 둘을 구별해 말해야 한다. - * 앱 자격증명이 없으면 눌러도 409 이므로(`SOCIAL_CONNECTION_DISABLED`) **버튼은 비활성**이다 — - * 보이되 눌리지 않고, 왜 아직인지 한 줄로 말한다(docs/SOCIAL.md '연동 준비'). - */ +/** Threads 계정 연동 — **'내 사이트' 화면에 한 자리**. */ export function SocialConnectionCard() { const [state, setState] = useState<SocialState | null>(null); const [busy, setBusy] = useState(false); @@ -42,7 +27,7 @@ export function SocialConnectionCard() { void load(); }, [load]); - // 상태를 아직 못 읽었을 때만 접는다(로그인 직후 한순간). '준비 안 됨' 과는 다르다. + // 상태를 아직 못 읽었을 때만 접는다(로그인 직후 한순간). if (!state) return null; const ready = state.connection_enabled; @@ -65,7 +50,7 @@ export function SocialConnectionCard() { const connect = () => run(async () => { const {url} = await socialApi<{url: string}>('/oauth/connect', {}); - // 인가 화면은 Meta 쪽이다. 돌아오면 `/sites?social=…` 로 되돌아온다. + // 인가 화면은 Meta 쪽이다. window.location.assign(url); }); diff --git a/solution/frontend/src/features/social/SocialPanel.tsx b/solution/frontend/src/features/social/SocialPanel.tsx index ff90fac..e2e555e 100644 --- a/solution/frontend/src/features/social/SocialPanel.tsx +++ b/solution/frontend/src/features/social/SocialPanel.tsx @@ -32,12 +32,7 @@ export function SocialPanel({placeId}: {placeId: string}) { <div className="flex flex-wrap gap-2"> <Button size="sm" disabled={busy || !state} onClick={() => void action(() => socialApi(`/place/${placeId}/draft`, {}))}>소개글 쓰기</Button> </div> - {/* - ★ 연결 버튼을 여기 두지 않는다 (2026-09-14). 계정은 `user × provider` 하나인데 버튼이 - 사업장 화면에 있으면 사장님은 **업장마다 연결해야 하는 줄 안다.** 연결은 [내 사이트]에 - 한 자리, 게재는 사이트마다다 — 화면이 그 모양을 그대로 말한다. - ★ 그래서 여기서는 "연결이 없다" 를 **막다른 문구가 아니라 갈 곳**으로 알린다. - */} + {/* 연결 버튼을 여기 두지 않는다. */} {state && state.connection_enabled && !state.account && ( <p className="text-xs text-muted-foreground"> 아직 Threads 계정이 연결되지 않았습니다 — <a href="/sites" className="underline underline-offset-4">내 사이트</a> 화면에서 한 번만 연결하면 됩니다. 연결 전에도 소개글을 만들어 복사할 수 있습니다. diff --git a/solution/frontend/src/hooks/useAutoLogin.ts b/solution/frontend/src/hooks/useAutoLogin.ts index 2830f5d..8c3ac88 100644 --- a/solution/frontend/src/hooks/useAutoLogin.ts +++ b/solution/frontend/src/hooks/useAutoLogin.ts @@ -1,12 +1,7 @@ import {useEffect} from 'react'; import {ensureAutoSession} from '@/lib/autoSession'; -/** - * 화면이 열리자마자 세션을 확보한다. - * - * 실제 로직은 `lib/autoSession` 에 있다 — 서버를 부르는 쪽(usePlaceSearch)도 같은 약속을 - * 기다려야 하기 때문에, 훅 바깥에 두고 공유한다. - */ +/** 화면이 열리자마자 세션을 확보한다. */ export function useAutoLogin() { useEffect(() => { void ensureAutoSession(); diff --git a/solution/frontend/src/hooks/usePlaceSync.ts b/solution/frontend/src/hooks/usePlaceSync.ts index 985db01..e52dfd9 100644 --- a/solution/frontend/src/hooks/usePlaceSync.ts +++ b/solution/frontend/src/hooks/usePlaceSync.ts @@ -1,14 +1,4 @@ -/** - * `/builder?placeId=...` 로 열린 빌더를 실제 사업장에 배선한다. - * - * placeId 가 없으면 쿼리가 아예 돌지 않는다(enabled:false) — 데모 경로는 네트워크를 - * 한 번도 타지 않고 지금까지와 똑같이 업종 시드로 돈다. 이게 이 훅의 제1 계약이다. - * - * ★ 스토어에서 분리한 이유: 이건 상태가 아니라 **배선**이다 — 서버 응답을 읽어 스토어에 - * 한 번 얹는 일만 한다. 스토어 파일에 두면 "상태를 담는 곳"과 "서버와 이야기하는 곳"이 - * 한 파일에 섞여, 어디를 고쳐야 할지가 흐려진다. 파생 계산은 전부 effect 안에서 끝난다 - * (셀렉터에서 배열을 만들지 않는다 — stores/builder 파일 상단 주의사항). - */ +/** `/builder?placeId=...` 로 열린 빌더를 실제 사업장에 배선한다. */ import {useEffect, useLayoutEffect} from 'react'; import {useGetPlace, useGetSchema, useGetSite, useListFacts, useListMedia} from '@/api'; import {toLivePlaceInput} from '@/features/builder/placeAdapter'; @@ -21,14 +11,13 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions const enabled = Boolean(placeId); const id = placeId ?? ''; - // 훅이 orval 생성물이라 쿼리 키도 생성물이 정한다(엔드포인트 경로) — - // 사업장 화면(PlaceDetailPage)이 같은 훅을 쓰므로 두 화면이 같은 캐시 항목을 본다. + // 훅이 orval 생성물이라 쿼리 키도 생성물이 정한다(엔드포인트 경로) — 사업장 화면(PlaceDetailPage)이 같은 훅을 쓰므로 두 화면이 같은 캐시 항목을 본다. const placeQuery = useGetPlace(id, {query: {enabled}}); const schemaQuery = useGetSchema(id, {query: {enabled}}); const factsQuery = useListFacts(id, undefined, {query: {enabled}}); // 사진도 서버가 진실이다 — 수집·Vision 결과를 화면이 직접 읽는다. const mediaQuery = useListMedia(id, undefined, {query: {enabled}}); - // 발행 상태(주소·버전). "발행본 사이트 열기" 가 어디로 갈지 여기서 나온다. + // 발행 상태(주소·버전). const siteQuery = useGetSite(id, {query: {enabled}}); const applyPlace = useBuilderStore((s) => s.applyPlace); @@ -40,11 +29,7 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions const specs = schemaQuery.data?.fields; const media = mediaQuery.data?.media; - /** - * ★ useEffect 가 아니라 useLayoutEffect 다. - * 일반 effect 는 페인트 뒤에 돌아서, 응답이 도착한 프레임에 빈 편집기가 한 번 그려졌다가 - * 값이 채워진다(깜빡임). 스토어에 얹는 일은 페인트 전에 끝나야 한다. - */ + /** useEffect 가 아니라 useLayoutEffect 다. */ useLayoutEffect(() => { if (!placeId) { clearPlace(); @@ -52,26 +37,14 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions } if (!place) return; // fact 가 아직 안 왔어도 먼저 온 place 로 상호·주소부터 얹는다. - // fact/스키마가 도착하면 이 effect 가 한 번 더 돌아 정보 표를 채운다. applyPlace(toLivePlaceInput(placeId, place, facts ?? [], specs ?? [], media ?? []), { isOnboarding, }); }, [placeId, place, facts, specs, media, applyPlace, clearPlace, isOnboarding]); - /** - * 서버에 저장된 디자인을 얹는다. - * - * ★ 이게 없으면 저장은 되는데 **읽어오지 않아** 새로고침 한 번에 사장님 눈에는 그대로 사라진다. - * applyPlace 와 분리한 이유는 출처가 다르기 때문이다 — 콘텐츠는 place/fact/media 가 주고, - * 디자인은 site 행이 준다. 한 effect 에 묶으면 사진 리페치 한 번에 디자인까지 되얹힌다. - * - * ★ site 는 아직 orval 재생성 전이라 생성 타입에 theme 이 없다(siteApi.ts 주석 참조). - * `npm run orval` 을 돌리면 이 캐스팅은 지운다. - */ + /** 서버에 저장된 디자인을 얹는다. */ const savedTheme = (siteQuery.data?.site as {theme?: SiteThemePayload} | undefined)?.theme; - // ★ templateId 는 theme 안이 아니라 **sites.template_id 컬럼**이다(Req_SiteTheme 주석). - // 같이 얹지 않으면 에디터가 늘 업종 첫 템플릿으로 그려진다 — 사장님이 '옛 항구'를 골라 - // 발행해도 다시 들어오면 편집 화면만 흰 바탕·고딕이었다(실측 2026-09-09). + // templateId 는 theme 안이 아니라 **sites.template_id 컬럼**이다(Req_SiteTheme 주석). const savedTemplateId = siteQuery.data?.site?.template_id ?? null; useEffect(() => { if (!placeId) return; @@ -79,11 +52,9 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions }, [placeId, savedTheme, savedTemplateId, applyTheme]); return { - /** 아직 다 못 읽었다 — 이 동안 데모 화면을 보여주면 남의 가게를 자기 가게로 오해한다. */ isLoading: enabled && (placeQuery.isPending || factsQuery.isPending || schemaQuery.isPending), isError: enabled && placeQuery.isError, - /** 응답은 왔는데 place 가 비었다 — 없는 사업장이거나 권한 밖이다. */ isNotFound: enabled && placeQuery.isSuccess && !place, error: placeQuery.error, place, diff --git a/solution/frontend/src/hooks/useWeather.ts b/solution/frontend/src/hooks/useWeather.ts index 7113f3e..4d4609f 100644 --- a/solution/frontend/src/hooks/useWeather.ts +++ b/solution/frontend/src/hooks/useWeather.ts @@ -10,7 +10,7 @@ export interface WeatherData { stale?: boolean; } -/** Open-Meteo weathercode → 한국어 상태 + 안내 문구. 코드표는 WMO 4677 기준. */ +/** Open-Meteo weathercode → 한국어 상태 + 안내 문구. */ function describe(code: number): {condition: string; recommendation: string} { if (code === 0) { return { @@ -49,12 +49,7 @@ const FALLBACK: WeatherData = { isFallback: true, }; -/** - * 실시간 날씨. Open-Meteo 는 API 키가 필요 없다 — 좌표만 있으면 된다. - * - * ★ 실패해도 사용자에게 에러를 보이지 않는다. 날씨는 부가 정보라 - * 못 받아오면 조용히 폴백값을 쓴다(isFallback 으로 구분 가능). - */ +/** 실시간 날씨. */ export function useWeather(regionCode?: string, lat?: number, lon?: number): WeatherData { const [weather, setWeather] = useState<WeatherData>(FALLBACK); diff --git a/solution/frontend/src/index.css b/solution/frontend/src/index.css index aa24071..acbba76 100644 --- a/solution/frontend/src/index.css +++ b/solution/frontend/src/index.css @@ -1,11 +1,10 @@ @import "tailwindcss"; @import "tw-animate-css"; -/* 디자인 토큰(색상·폰트·radius)은 shared 단일 소스에서 관리. site 와 공유 가능. */ +/* 디자인 토큰(색상·폰트·radius)은 shared 단일 소스에서 관리. */ @import "@o2o/shared/styles/tokens.css"; @import "@o2o/shared/styles/base.css"; -/* 캔버스가 그리는 것은 발행본과 같은 사이트다 — 시안 토큰·유틸을 같은 파일에서 읽는다. - 규칙은 전부 `.site-canvas` 안으로 스코프돼 있어 관리자 크롬에는 닿지 않는다. */ +/* 캔버스가 그리는 것은 발행본과 같은 사이트다 — 시안 토큰·유틸을 같은 파일에서 읽는다. */ @import "@o2o/shared/styles/site.css"; body { @@ -27,23 +26,19 @@ body { } } -/* 캔버스(발행 사이트 미리보기)는 관리자 토큰이 아니라 템플릿 색을 쓴다. - .site-canvas 안쪽은 --tpl-* 변수로만 색을 잡는다 — 관리자 크롬 색이 새어 들어오지 않게. */ +/* 캔버스(발행 사이트 미리보기)는 관리자 토큰이 아니라 템플릿 색을 쓴다. */ @layer components { .site-canvas { color-scheme: light; background-color: var(--tpl-bg, #ffffff); color: var(--tpl-text, #09090b); - /* 본문 서체도 토큰이다. 변수가 없으면(빌더 캔버스 등) 지금까지처럼 관리자 본문 서체를 쓴다. */ + /* 본문 서체도 토큰이다. */ font-family: var(--tpl-font-body, var(--font-sans)); } .site-canvas .serif-title { font-family: var(--tpl-font-heading, 'Noto Serif KR', 'Batang', 'Times New Roman', serif); } - /* 캔버스 안의 '면'(카드·패널)과 테두리도 토큰을 따른다. - ★ bg-white / bg-stone-* 이 캔버스 안에만 82곳 흩어져 있어 파일마다 고치는 대신 여기서 덮는다. - 폴백이 원래 Tailwind 값이라, --tpl-* 이 없는 곳에서는 지금까지와 완전히 동일하다. - ★ 팔레트를 바꿔도 화면이 안 변하는 원인의 절반이 이 면들이었다(나머지 절반은 섹션 바탕). */ + /* 캔버스 안의 '면'(카드·패널)과 테두리도 토큰을 따른다. */ .site-canvas .bg-white { background-color: var(--tpl-card, #ffffff); } @@ -59,8 +54,7 @@ body { border-color: color-mix(in oklab, var(--tpl-border, var(--color-stone-200)) 30%, transparent); } - /* serif-title 을 안 쓰는 제목도 제목 서체를 따르게 한다. - ★ 폴백이 inherit 이라 --tpl-font-heading 이 없으면 예전과 똑같이 본문 서체를 그대로 물려받는다. */ + /* serif-title 을 안 쓰는 제목도 제목 서체를 따르게 한다. */ .site-canvas h1, .site-canvas h2, .site-canvas h3, @@ -68,23 +62,17 @@ body { font-family: var(--tpl-font-heading, inherit); letter-spacing: var(--tpl-heading-tracking, normal); } - /* ★ 굵기는 폴백을 inherit 로 두지 않는다. 간판체(Gugi)는 굵기가 한 벌뿐이라 700 을 주면 - 브라우저가 가짜 볼드를 씌워 획이 뭉갠다 — 템플릿이 400 을 지정할 수 있어야 한다. */ + /* 굵기는 폴백을 inherit 로 두지 않는다. */ .site-canvas :is(h1, h2, h3, h4).tpl-title { font-weight: var(--tpl-heading-weight, 700); } - /* 카드·패널 테두리 두께도 템플릿이 정한다. 레트로는 2px 라야 인쇄물처럼 보인다. */ + /* 카드·패널 테두리 두께도 템플릿이 정한다. */ .site-canvas .border { border-width: var(--tpl-border-width, 1px); } } -/* 히어로 제목의 앞말만 갈아 끼운다 — '홈페이지' 는 고정이고 수식어가 돈다. - ★ CSS 만으로 돈다(마퀴와 같은 이유). 문구를 **일곱 줄** 쌓는다 — 마지막 줄이 첫 줄의 - 복제라 -85.714% 에서 0% 로 되감을 때 글자가 튀지 않는다. - ★ 줄 높이를 1.25em 으로 못 박는다. 창이 1줄이고 트랙이 7줄이라 한 칸이 정확히 1/7 이어야 한다. - ★ 문구 개수(6)와 이 키프레임은 한 몸이다. 문구를 늘리면 여기 stop 도 같이 고쳐야 한다 — - 안 고치면 마지막 몇 개가 영영 안 보이거나 빈 줄이 지나간다(LandingPage HEADLINES). */ +/* 히어로 제목의 앞말만 갈아 끼운다 — '홈페이지' 는 고정이고 수식어가 돈다. */ @keyframes o2o-rotate-6 { 0%, 14% { transform: translateY(0); } 16.67%, 30.67% { transform: translateY(-14.286%); } @@ -100,8 +88,7 @@ body { overflow: hidden; } .o2o-rotator-track { - /* ★ display:block 이 없으면 아무 일도 안 일어난다. 이 요소는 <span> 이라 기본이 inline 이고, - **인라인 요소에는 transform 이 적용되지 않는다** — 애니메이션은 걸려 있는데 화면은 정지다. */ + /* display:block 이 없으면 아무 일도 안 일어난다. */ display: block; animation: o2o-rotate-6 16s cubic-bezier(0.65, 0, 0.35, 1) infinite; } @@ -114,8 +101,7 @@ body { .o2o-rotator-track { animation: none; } } -/* 랜딩 히어로의 발행 사이트 마퀴. **CSS 만으로 돈다** — 타이머를 쓰면 탭이 백그라운드일 때 - 프레임이 밀려 끊긴 것처럼 보인다. 트랙에 같은 목록을 두 벌 넣고 -50% 까지 밀면 이음매가 없다. */ +/* 랜딩 히어로의 발행 사이트 마퀴. */ @keyframes o2o-marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } @@ -139,8 +125,7 @@ body { animation: o2o-shimmer 1.8s ease-in-out infinite; } -/* 노치/홈 인디케이터 회피 (viewport-fit=cover 와 짝). - 화면 맨 위/맨 아래에 붙는 바에만 붙인다 — 스크롤되는 본문에는 쓰지 않는다. */ +/* 노치/홈 인디케이터 회피 (viewport-fit=cover 와 짝). */ @layer components { .safe-t { padding-top: env(safe-area-inset-top); } .safe-b { padding-bottom: env(safe-area-inset-bottom); } diff --git a/solution/frontend/src/lib/autoSession.ts b/solution/frontend/src/lib/autoSession.ts index 0c7bf65..45e781e 100644 --- a/solution/frontend/src/lib/autoSession.ts +++ b/solution/frontend/src/lib/autoSession.ts @@ -1,24 +1,7 @@ import {getAccessToken, login} from '@/api'; import {establishSession} from '@/lib/session'; -/** - * 계정이 주입돼 있으면 화면을 열 때 조용히 세션을 확보한다. - * - * ★ 왜 훅이 아니라 모듈 싱글턴인가: 로그인은 비동기인데, 화면(2단계 검색)은 그것과 - * 상관없이 언제든 백엔드를 부를 수 있다. 훅 안에만 있으면 "로그인 끝나기 전에 누른 검색"이 - * 토큰 없이 나가 '로그인 만료' 화면으로 떨어진다 — 실제로는 만료가 아니라 경합이다. - * 그래서 진행 중인 로그인을 **하나의 약속으로 공유**하고, 서버를 부르는 쪽이 그걸 기다린다. - * - * ★ 계정이 없으면 즉시 끝난다. 켜는 유일한 방법은 VITE_AUTO_LOGIN_ID·PW 를 주는 것이고, - * 기본값은 없다. - * - * ⚠️ 이 값은 **번들에 구워진다.** 페이지를 연 사람은 누구나 JS 에서 읽을 수 있다 — - * 내부 테스트 호스트에서만 켜고, 사장님에게 여는 순간 반드시 빼야 한다. - * ★ `import.meta.env.DEV` 가드는 둘째 안전판이다. 운영 빌드(vite build)는 이 상수가 - * 컴파일 시점에 `false` 로 접히므로 아래 if 블록째 번들에서 잘려 나간다 — 누군가 실수로 - * 운영 이미지(nginx/Dockerfile)에 VITE_AUTO_LOGIN_ID·PW 를 다시 넘겨도 코드가 죽어 있어 - * 못 켠다. `vite dev`(solution-frontend --profile dev)에서는 DEV=true 라 그대로 켜진다. - */ +/** 계정이 주입돼 있으면 화면을 열 때 조용히 세션을 확보한다. */ let pending: Promise<void> | null = null; export function ensureAutoSession(): Promise<void> { diff --git a/solution/frontend/src/lib/color.ts b/solution/frontend/src/lib/color.ts index 5e0b5fa..119d2ec 100644 --- a/solution/frontend/src/lib/color.ts +++ b/solution/frontend/src/lib/color.ts @@ -1,7 +1,2 @@ -/** - * 색 계산은 계약 패키지가 소유한다(`@o2o/shared/lib/color.ts`). - * - * ★ 발행 사이트도 같은 식으로 면 토큰을 만든다. 두 벌로 두면 캔버스와 발행본의 바탕색이 갈린다. - * 여기는 기존 import 경로(`@/lib/color`)를 지키는 재수출만 남긴다. - */ +/** 색 계산은 계약 패키지가 소유한다(`@o2o/shared/lib/color.ts`). */ export {mix, deriveSurfaces} from '@o2o/shared'; diff --git a/solution/frontend/src/lib/errorMessages.ts b/solution/frontend/src/lib/errorMessages.ts index b5661be..5099569 100644 --- a/solution/frontend/src/lib/errorMessages.ts +++ b/solution/frontend/src/lib/errorMessages.ts @@ -1,12 +1,4 @@ -/** - * 서버 결과 코드 → 사장님이 읽을 문구. - * - * 백엔드는 도메인 거절을 HTTP 200 + `result.desc` 로 준다(`common/enums.ErrorType` 의 **이름**). - * 그대로 띄우면 화면에 `PLACE_NOT_VERIFIED` 가 뜬다 — 그건 개발자용 문자열이지 안내가 아니다. - * - * ★ 여기 없는 코드는 원문을 그대로 보여준다. 모르는 코드를 "알 수 없는 오류"로 뭉개면 - * 운영자가 로그 없이는 원인을 못 찾는다. - */ +/** 서버 결과 코드 → 사장님이 읽을 문구. */ export const ERROR_MESSAGE: Record<string, string> = { // 계정 ACCOUNT_INVALID_INFO: '아이디 또는 비밀번호를 확인해 주세요.', @@ -26,7 +18,7 @@ export const ERROR_MESSAGE: Record<string, string> = { PLACE_NOT_VERIFIED: '동일 업소 검증을 먼저 끝내야 합니다. 검증 전에는 수집·발행이 열리지 않습니다.', PLACE_VERIFY_NO_CANDIDATE: '외부 장소 정보에서 이 상호를 찾지 못했습니다.', PLACE_VERIFY_AMBIGUOUS: '같은 이름의 업소가 여럿입니다. 어느 곳인지 골라 주세요.', - // 상호명 공개 검색(로그인 전 첫 화면)이 만나는 세 가지. 인증이 없는 경로라 분당 상한이 걸려 있다. + // 상호명 공개 검색(로그인 전 첫 화면)이 만나는 세 가지. HTTP_TO_MANY_REQUEST: '검색을 너무 자주 했습니다. 잠시 뒤 다시 시도해 주세요.', LOCAL_NOT_CONFIGURED: '지도 검색이 아직 설정되지 않았습니다. 네이버 지도 주소를 붙여넣어 진행해 주세요.', LOCAL_FETCH_FAILED: '지도에서 가게를 찾지 못했습니다. 잠시 뒤 다시 시도하거나 지도 주소를 붙여넣어 주세요.', @@ -66,12 +58,7 @@ export const ERROR_MESSAGE: Record<string, string> = { HTTP_FORBIDDEN: '이 작업을 할 권한이 없습니다.', }; -/** - * `result.desc` 나 `detail` 에 실려 온 코드 이름을 문구로. 모르는 값은 그대로 돌려준다. - * - * ★ null 도 받는다 — 생성 모델의 optional 필드는 `string | null` 이고, 부르는 쪽마다 - * `?? undefined` 를 붙이게 하면 그걸 빠뜨린 자리에서만 타입이 터진다. - */ +/** `result.desc` 나 `detail` 에 실려 온 코드 이름을 문구로. */ export function describeError(code: string | null | undefined): string | undefined { if (!code) return undefined; return ERROR_MESSAGE[code] ?? code; diff --git a/solution/frontend/src/lib/googleIdentity.ts b/solution/frontend/src/lib/googleIdentity.ts index 4660b90..5a1cacf 100644 --- a/solution/frontend/src/lib/googleIdentity.ts +++ b/solution/frontend/src/lib/googleIdentity.ts @@ -1,15 +1,5 @@ -/** - * 구글 로그인(Google Identity Services) 어댑터. - * - * ★ 스크립트를 index.html 이 아니라 여기서 붙인다. client_id 가 없는 앱(내부 운영 화면)까지 - * 구글 스크립트를 받아 오면, 쓰지도 않는 서드파티 요청이 모든 화면에 붙는다. - * ★ 여기서 받는 건 `credential`(구글 ID 토큰) 하나뿐이다. 그걸 백엔드에 넘기면 그다음부터는 - * 우리 토큰이다 — 구글 토큰을 세션으로 들고 다니지 않는다. - * ★ client_id 는 비밀이 아니다(번들에 그대로 들어간다). 이 값으로 할 수 있는 건 "우리 앱 앞으로" - * 토큰을 받는 것뿐이고, 그 토큰이 우리 계정이 되려면 백엔드의 aud 대조를 통과해야 한다. - */ -// ★ 언어는 **스크립트 URL 의 hl** 로 잡는다. renderButton 의 locale 옵션은 안 먹었다 -// (locale:'ko'·'ko_KR' 둘 다 'Continue with Google' 이 그대로 나왔다 — 실측). +/** 구글 로그인(Google Identity Services) 어댑터. */ +// 언어는 **스크립트 URL 의 hl** 로 잡는다. const SCRIPT_URL = 'https://accounts.google.com/gsi/client?hl=ko'; const SCRIPT_ID = 'google-identity-services'; @@ -50,7 +40,7 @@ declare global { } } -// 스크립트는 한 번만 받는다. 로그인·가입 두 화면이 같은 약속을 나눠 쓴다. +// 스크립트는 한 번만 받는다. let loading: Promise<GoogleIdApi> | null = null; export function loadGoogleIdentity(): Promise<GoogleIdApi> { diff --git a/solution/frontend/src/lib/jwt.ts b/solution/frontend/src/lib/jwt.ts index 0e1457c..112499e 100644 --- a/solution/frontend/src/lib/jwt.ts +++ b/solution/frontend/src/lib/jwt.ts @@ -1,8 +1,3 @@ -/** - * JWT 의 sub 클레임만 읽는다 — 서명 검증은 서버가 이미 했다(이 토큰은 우리 백엔드가 방금 - * 발급해 URL 에 실어 보낸 것). 여기서는 화면 상태(useAuthStore)를 채우는 데 필요한 - * user_id/id/role 만 꺼낸다. - */ export function decodeJwtSubject(token: string): {user_id: string; id: string; role: number} | null { try { const payloadB64 = token.split('.')[1]; diff --git a/solution/frontend/src/lib/notify.ts b/solution/frontend/src/lib/notify.ts index 4c8e781..568658c 100644 --- a/solution/frontend/src/lib/notify.ts +++ b/solution/frontend/src/lib/notify.ts @@ -1,20 +1,11 @@ import {toast} from 'sonner'; import {describeError} from './errorMessages'; -/** - * 토스트 단일 길목. 컴포넌트가 sonner 를 직접 부르지 않는다 — - * 문구 톤과 노출 시간을 한 곳에서 바꿀 수 있게. - */ +/** 토스트 단일 길목. */ const SUCCESS_MS = 1600; const PROBLEM_MS = 5000; -/** - * 성공 토스트는 **같은 id 로 덮어쓴다**. - * - * ★ [맞아요]를 13줄 연속으로 누르면 '반영했습니다'가 13장 쌓여 화면을 덮고, 그 밑의 - * 버튼이 눌리지 않는다(실제로 그렇게 막혔다). 성공은 줄 자체가 '확인됨'으로 바뀌어 - * 이미 보여주므로, 토스트는 한 장이면 된다. - */ +/** 성공 토스트는 **같은 id 로 덮어쓴다**. */ const SUCCESS_TOAST_ID = 'notify-success'; export const notify = { @@ -29,17 +20,10 @@ export const notify = { toast.warning(message, {description, duration: PROBLEM_MS}), }; -/** - * ApiError 를 사람이 읽는 문구로. 서버가 준 사유가 있으면 그걸 쓴다. - * - * ★ 사유가 실리는 자리가 두 곳이다. - * - `result.desc` — 도메인 거절(`common/models/gmodel.ErrorInfo`). HTTP 는 200 이고 본문에 이유가 온다. - * 그래서 `{data: res}` 처럼 응답을 그대로 넘겨 부르는 자리가 있다(거절도 성공 응답이라). - * - `detail` — FastAPI 기본 에러(422 검증 실패 등). - */ +/** ApiError 를 사람이 읽는 문구로. */ export function notifyApiError(error: unknown, fallback = '요청을 처리하지 못했습니다.') { const data = (error as {data?: {result?: {desc?: string}; detail?: unknown}})?.data; const detail = typeof data?.detail === 'string' ? data.detail : undefined; - // ★ desc/detail 은 ErrorType 의 이름(PLACE_NOT_VERIFIED 등)이다 — 문구로 옮겨서 띄운다. + // desc/detail 은 ErrorType 의 이름(PLACE_NOT_VERIFIED 등)이다 — 문구로 옮겨서 띄운다. notify.error(describeError(data?.result?.desc) ?? describeError(detail) ?? fallback); } diff --git a/solution/frontend/src/lib/query-client.ts b/solution/frontend/src/lib/query-client.ts index 325f921..6703717 100644 --- a/solution/frontend/src/lib/query-client.ts +++ b/solution/frontend/src/lib/query-client.ts @@ -1,10 +1,6 @@ import {QueryClient} from '@tanstack/react-query'; // o2o-web4ai 는 서버 응답을 캐시하지 않는다 — 화면에 뜨는 값은 항상 그 시점의 서버 값이다. -// 특히 fact 검증 상태는 캐시하면 안 된다: 사장님이 [맞아요] 를 눌렀는데 캔버스가 -// 옛 UNVERIFIED 를 그리면, 노출 여부 판단이 화면과 서버에서 갈린다. -// staleTime 0 = 받는 즉시 stale 로 보고 다시 읽는다, gcTime 0 = 화면에서 내려가면 바로 버린다. -// 페이지네이션 깜빡임은 각 훅의 placeholderData(keepPreviousData)가 막는다 — 캐시가 아니라 직전 렌더값이다. export const queryClient = new QueryClient({ defaultOptions: { queries: { diff --git a/solution/frontend/src/lib/session.ts b/solution/frontend/src/lib/session.ts index 34cc307..30587d8 100644 --- a/solution/frontend/src/lib/session.ts +++ b/solution/frontend/src/lib/session.ts @@ -2,14 +2,7 @@ import {me, UserRole} from '@/api'; import type {ResLogin} from '@/api'; import {toAuthUser, useAuthStore} from '@/stores/auth'; -/** - * 로그인 응답 → 세션. id/pw · 가입 · 구글이 **같은 마지막 단계**를 쓴다. - * - * ★ 순서가 중요하다. signIn 이 토큰을 먼저 저장해야 뒤이은 me() 가 Authorization 을 달고 나간다 — - * me() 를 먼저 부르면 토큰이 없어 401 로 떨어진다. - * ★ 성공 응답이라도 토큰이 없을 수 있다(RemoveNoneResponse 가 빈 필드를 통째로 지운다). - * 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐르므로 여기서 끊는다. - */ +/** 로그인 응답 → 세션. */ export async function establishSession(res: ResLogin, fallbackId: string): Promise<boolean> { if (!res.access_token || !res.refresh_token) return false; diff --git a/solution/frontend/src/lib/site.ts b/solution/frontend/src/lib/site.ts index eedc0d1..7eb1ee5 100644 --- a/solution/frontend/src/lib/site.ts +++ b/solution/frontend/src/lib/site.ts @@ -1,11 +1,4 @@ -/** - * 이 앱이 서비스되는 공개 주소. - * - * ★ 호스트를 문자열로 두 곳에 적지 않는다 — compose 가 루트 `SITE_PUBLIC_HOST` 를 - * `VITE_PUBLISH_HOST` 로 흘려보낸다(AGENTS.md). 여기가 프론트에서의 유일한 자리다. - * ★ `window.location` 으로 떨어지지 않는다. 프리렌더는 브라우저 없이 도는데, - * 그때 window 를 만지면 빌드가 죽는다. - */ +/** 이 앱이 서비스되는 공개 주소. */ const HOST = import.meta.env.VITE_PUBLISH_HOST || 'web4ai.o2osolution.ai'; /** `https://web4ai.o2osolution.ai` — 끝 슬래시 없음. */ diff --git a/solution/frontend/src/lib/utils.ts b/solution/frontend/src/lib/utils.ts index 99bf4b5..b1a0b87 100644 --- a/solution/frontend/src/lib/utils.ts +++ b/solution/frontend/src/lib/utils.ts @@ -1,6 +1,6 @@ export {cn} from '@o2o/shared'; -/** 숫자에 천 단위 콤마. 값이 없으면 빈 문자열(0 을 찍지 않는다). */ +/** 숫자에 천 단위 콤마. */ export function formatNumber(value: number | string | null | undefined): string { if (value == null || value === '') return ''; const num = typeof value === 'string' ? Number(value.replace(/,/g, '')) : value; @@ -8,13 +8,13 @@ export function formatNumber(value: number | string | null | undefined): string return num.toLocaleString('ko-KR'); } -/** "280000" → "280,000원". 단위 없는 숫자 fact 를 화면에 낼 때. */ +/** "280000" → "280,000원". */ export function formatWon(value: number | string | null | undefined): string { const formatted = formatNumber(value); return formatted ? `${formatted}원` : ''; } -/** ISO 문자열 → "2026년 8월 27일". 화면에 노출하는 기준일자용. */ +/** ISO 문자열 → "2026년 8월 27일". */ export function formatKoreanDate(iso: string | null | undefined): string { if (!iso) return ''; const date = new Date(iso); diff --git a/solution/frontend/src/lib/youtube.ts b/solution/frontend/src/lib/youtube.ts index 8c90266..94e2e18 100644 --- a/solution/frontend/src/lib/youtube.ts +++ b/solution/frontend/src/lib/youtube.ts @@ -1,4 +1,4 @@ -/** YouTube 링크 → 임베드 주소. watch / youtu.be / shorts / 이미 embed 인 것 모두 받는다. */ +/** YouTube 링크 → 임베드 주소. */ export function convertYouTubeToEmbed(url: string): {embedUrl: string; isValid: boolean} { if (!url || typeof url !== 'string') return {embedUrl: '', isValid: false}; diff --git a/solution/frontend/src/pages/AccountPage.tsx b/solution/frontend/src/pages/AccountPage.tsx index 0d07484..0aa24a0 100644 --- a/solution/frontend/src/pages/AccountPage.tsx +++ b/solution/frontend/src/pages/AccountPage.tsx @@ -7,14 +7,7 @@ import {Input} from '@/components/ui/input'; import {notify, notifyApiError} from '@/lib/notify'; import {toAuthUser, useAuthStore} from '@/stores/auth'; -/** - * 내 정보 — `PATCH /v1/auth/me` 한 곳이 받는 것만 그린다. - * - * ★ 상호 칸은 없다. 회사(테넌트)를 걷어내면서(2026-09-08) 계정에 상호가 없어졌다 — - * 가게 이름은 사업장(place)이 갖는다. - * ★ 구글 계정에는 바꿀 비밀번호가 없다(서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다) — - * 입력칸 자체를 그리지 않는다. - */ +/** 내 정보 — `PATCH /v1/auth/me` 한 곳이 받는 것만 그린다. */ export function AccountPage() { const {data, isLoading, refetch} = useMe(); const setUser = useAuthStore((s) => s.setUser); @@ -25,7 +18,7 @@ export function AccountPage() { const [password, setPassword] = useState(''); const [isSaving, setIsSaving] = useState(false); - // 서버 값이 도착하면 한 번 채운다. 타이핑 중에 덮어쓰지 않게 응답이 바뀔 때만 돈다. + // 서버 값이 도착하면 한 번 채운다. useEffect(() => { if (!data) return; setName(data.name ?? ''); diff --git a/solution/frontend/src/pages/BlogPostsPage.tsx b/solution/frontend/src/pages/BlogPostsPage.tsx index 1eb2f6e..1f690c8 100644 --- a/solution/frontend/src/pages/BlogPostsPage.tsx +++ b/solution/frontend/src/pages/BlogPostsPage.tsx @@ -25,8 +25,7 @@ import {cn} from '@/lib/utils'; type BadgeVariant = 'default' | 'outline' | 'warning' | 'success'; -// common/enums.py PostStatus 와 값이 같아야 한다. DB 는 숫자만 준다 — 라벨은 화면 몫이다. -// 카드(카로셀 · 달력 모달) 안에서만 쓴다 — 달력 칸 자체는 발행완료/발행실패만 보여준다. +// common/enums.py PostStatus 와 값이 같아야 한다. const STATUS_LABEL: Record<number, {label: string; variant: BadgeVariant}> = { 1: {label: '생성됨', variant: 'default'}, 2: {label: '발송 대기', variant: 'default'}, @@ -132,12 +131,7 @@ function dateLabel(scheduledDate: string): {text: string; chip: '오늘' | '내 return {text, chip: null}; } -/** - * 달력 칸에 다는 배지. 검수 대기(REVIEWED)처럼 아직 메일도 안 나간 상태는 아무것도 - * 보여주지 않는다(사장님 지시: "발행전인건 표시하지 말고") — 메일 발송 여부는 그 자체가 - * 크론잡이 실제로 돌았다는 확인이라 별도로 보여준다(사장님 지시: "완료되었는지 여부도 - * 출력해야할꺼같아"). - */ +/** 달력 칸에 다는 배지. */ function publishBadge(post: PostData): {label: string; className: string} | null { if (post.status === PUBLISHED_STATUS) return {label: '발행완료', className: 'bg-success/15 text-success'}; if (post.build_failed) return {label: '발행실패', className: 'bg-destructive/10 text-destructive'}; @@ -145,10 +139,7 @@ function publishBadge(post: PostData): {label: string; className: string} | null return null; } -/** - * 수정 폼 — 저장만 한다. 승인은 오직 이메일 링크로만 일어난다(2026-09-21, 사장님 지시: - * "승인되야 올라가도록 해야 한다") — 예전엔 저장이 곧 승인이었지만 그 지름길을 없앴다. - */ +/** 수정 폼 — 저장만 한다. */ function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; onDone: () => void}) { const [body, setBody] = useState(post.body); const editMutation = useEditMyPost(); @@ -212,8 +203,7 @@ function PostCard({placeId, post, onChanged}: {placeId: string; post: PostData; }; const handleDelete = async () => { - // 이미 게재된 글은 지우면 사이트에서도 빠지기까지 재발행이 걸린다 — 되돌릴 수 없는 - // 작업이라 한 번 확인한다(SitesPage.tsx 의 발행 내리기와 같은 관례). + // 이미 게재된 글은 지우면 사이트에서도 빠지기까지 재발행이 걸린다 — 되돌릴 수 없는 작업이라 한 번 확인한다(SitesPage.tsx 의 발행 내리기와 같은 관례). if (!window.confirm('이 글을 삭제할까요? 게재된 글이면 사이트에서도 곧 사라집니다.')) return; try { const res = await deleteMutation.mutateAsync({placeId, postId: post.post_id}); @@ -289,7 +279,7 @@ function PostCard({placeId, post, onChanged}: {placeId: string; post: PostData; ); } -/** 카로셀용 미리보기 카드 — 누르면 모달이 뜬다. 수정·발행은 모달 안에서만 한다. */ +/** 카로셀용 미리보기 카드 — 누르면 모달이 뜬다. */ function PostPreviewCard({post, onClick}: {post: PostData; onClick: () => void}) { const status = STATUS_LABEL[post.status] ?? STATUS_LABEL[1]; const {text, chip} = post.scheduled_date ? dateLabel(post.scheduled_date) : {text: '', chip: null}; @@ -317,12 +307,7 @@ function PostPreviewCard({post, onClick}: {post: PostData; onClick: () => void}) ); } -/** - * 겹쳐 쌓은 글 카드 카로셀 — 오늘부터 일주일치만(당장 챙길 것). 가로로 스크롤하면 카드가 - * 하나씩 앞으로 나온다. 마우스를 올리면 그 카드가 가려지지 않고 맨 앞으로 온다. - * ★ 카드를 눌러도 그 자리에서 고치지 않는다 — 달력 칸과 똑같이 모달을 연다(사장님 지시: - * "카드클릭해도 모달나와서 수정가능하게 해야지"). - */ +/** 겹쳐 쌓은 글 카드 카로셀 — 오늘부터 일주일치만(당장 챙길 것). */ function PostCarousel({posts, onSelectPost}: {posts: PostData[]; onSelectPost: (post: PostData) => void}) { const [hoveredId, setHoveredId] = useState<string | null>(null); @@ -350,13 +335,7 @@ function PostCarousel({posts, onSelectPost}: {posts: PostData[]; onSelectPost: ( ); } -/** - * 달력 한 장. **글이 0건이어도 항상 뜬다** — 그날그날 뭐가 있는지 훑어보는 게 목적이라 - * 목록이 비었다고 화면 자체가 사라지면 "이 달은 아무것도 없다"는 걸 확인할 방법이 없다. - * ★ 여기서는 발행완료/발행실패만 표시한다(사장님 지시) — 칸을 누르면 모달로 전체 내용을 본다. - * ★ 빈 날짜(오늘 이후)를 누르면 그 날짜 하나만 콕 집어 생성한다(사장님 지시: "개별적으로 - * 새로 만들수있게 해줘"). 지난 날짜는 이제 와서 만들 이유가 없어 클릭을 막는다. - */ +/** 달력 한 장. */ function Calendar({month, postsByDay, onSelectPost, onGenerateDay, generatingDay}: { month: string; postsByDay: Map<number, PostData>; @@ -487,11 +466,7 @@ function GenerationHistoryList({placeId}: {placeId: string}) { ); } -/** - * 승인 메일을 받을 주소 — 계정 로그인 이메일(users.email)과 분리한 업장별 설정 - * (2026-09-21, 사장님 요청: "그 이메일은 변경 가능하도록"). 비워서 저장하면 계정 - * 이메일로 되돌아간다. - */ +/** 승인 메일을 받을 주소 — 계정 로그인 이메일(users.email)과 분리한 업장별 설정. */ function NotifyEmailSetting({placeId}: {placeId: string}) { const {data: placeRes} = useGetPlace(placeId, {query: {enabled: !!placeId}}); const savedEmail = placeRes?.place?.notify_email ?? ''; @@ -559,22 +534,12 @@ function NotifyEmailSetting({placeId}: {placeId: string}) { ); } -/** - * 이번 달(또는 고른 달) 생성된 미니 블로그 글. 기획: docs/MINI_BLOG.md - * - * ★ 사이트 하나를 고르고 들어온다 — `/sites` 카드의 관리 메뉴가 `?placeId=` 를 채운다. - * 여러 사이트를 가진 사장님도 있어 전역 메뉴 하나로는 어느 사이트인지 알 수 없다. - * ★ 화면이 둘로 나뉜다 — 위 카로셀은 "당장 챙길 것"(오늘부터 일주일)을 편집·발행하는 곳, - * 아래 달력은 한 달 전체를 훑어보는 곳(칸을 누르면 모달로 본다). - */ +/** 이번 달(또는 고른 달) 생성된 미니 블로그 글. */ export function BlogPostsPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const placeId = searchParams.get('placeId') ?? ''; - // 메일 "수정하기" 링크 — 그날짜리 자동 로그인(auto) 은 provider.tsx 의 세션 복구에서 - // 이미 처리됐다(라우트 가드보다 먼저 처리해야 해서 여기가 아니라 거기다). 여기서는 - // postId 로 그 글을 찾아 모달만 연다. const linkedPostId = searchParams.get('postId'); useEffect(() => { @@ -714,8 +679,7 @@ export function BlogPostsPage() { return ( <AppShell> <PageContainer title="블로그 글" description="AI 가 만든 글이 매일 조금씩 쌓입니다. 마음에 안 들면 고쳐서 올리세요." actions={generateButton}> - {/* 탭은 생성 이력만 따로 뺀다 — 카로셀·달력은 계속 같이 보인다(사장님 지시: "탭으로 - 나누지 말고 달력 위에 이번 주 카드들 보여주라고 했지"). */} + {/* 탭은 생성 이력만 따로 뺀다 — 카로셀·달력은 계속 같이 보인다(사장님 지시: "탭으로 나누지 말고 달력 위에 이번 주 카드들 보여주라고 했지"). */} <div className="mb-4 flex w-fit items-center gap-0.5 rounded-md border border-border p-0.5"> {(Object.keys(TAB_LABEL) as Tab[]).map((key) => ( <button diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index 4462a7a..c779e24 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -20,56 +20,31 @@ import {userLabel, useAuthStore} from '@/stores/auth'; import {useBuilderStore} from '@/stores/builder'; import {ORIGIN} from '@/lib/site'; -/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */ -// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 -// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. +/** 발행 사이트 렌더러의 개발 서버. */ +// window 로 떨어지지 않는다. const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL || ORIGIN; -/** 랜딩이 `?industry=` 로 넘길 수 있는 값. 주소창 값이라 아무 문자열이나 들어올 수 있다. */ +/** 랜딩이 `?industry=` 로 넘길 수 있는 값. */ const INDUSTRY_VALUES: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic']; function parseIndustry(value: string | null): IndustryType | null { return INDUSTRY_VALUES.includes(value as IndustryType) ? (value as IndustryType) : null; } -/** - * "발행본 사이트 열기" 가 향할 주소. - * - * ★ 예전엔 서버 루트(:3001)만 열었다. 사이트가 하나뿐이던 시절의 흔적인데, 지금은 - * 여러 사이트가 `/s/<주소>` 아래 놓여서 루트를 열면 아무것도 안 나온다. - * 사장님이 정한 주소(sites.domain)가 있으면 그 사이트로 보낸다. - * ★ 도메인이 없으면 null 이다 — 예전엔 서버 루트로 떨어뜨렸는데, 그건 발행 전에도 - * 버튼이 열려 있고 누르면 빈 화면이 뜬다는 뜻이었다. 열 곳이 없으면 열지 않는다. - */ +/** "발행본 사이트 열기" 가 향할 주소. */ function siteUrl(domain: string | null | undefined): string | null { return domain ? `${SITE_PREVIEW_URL}/s/${domain}` : null; } export function BuilderPage() { useAutoLogin(); - /** - * ★ 이 화면이 무엇을 그릴지는 **전부 주소창이 정한다.** - * - * ?step= 어느 단계인가(없으면 wizardUrl.defaultStep 이 정한다) - * ?placeId= 어떤 사업장인가 — 라우트(`/builder/:placeId`)로 받지 않는 이유는 - * 빌더가 로그인 없이 도는 경로이고 placeId 는 있을 수도 없을 수도 있어서다. - * ?flow=onboarding 위저드가 방금 만든 사업장이다(딥링크로 편집하러 온 것과 구분한다) - * ?new=1 ?q= ?industry= 랜딩에서 넘어온 입구. 한 번 읽고 주소창에서 지운다. - */ + /** 이 화면이 무엇을 그릴지는 **전부 주소창이 정한다.** */ const [searchParams, setSearchParams] = useSearchParams(); const [step, goToStep] = useWizardStep(); const urlPlaceId = searchParams.get('placeId'); const isOnboarding = searchParams.get('flow') === 'onboarding'; - /** - * 랜딩에서 넘어온 입구를 한 번만 읽는다 — `?new=1` · `?q=` · `?industry=`. - * - * ★ `?new=1` 은 저장된 상태를 비운다. 안 비우면 새 가게를 만들러 온 사람에게 지난번 에디터가 - * 복원돼 뜬다. 읽은 뒤 주소창에서 지우는 이유는 두 가지다 — 새로고침마다 작업하던 내용이 - * 날아가지 않게, 그리고 사장님이 화면에서 고친 상호·업종을 링크 값이 도로 덮지 않게. - * ★ **다른 쿼리는 남긴다.** 예전엔 `setSearchParams({})` 로 통째로 비웠는데, 그러면 - * `?new=1&q=...` 로 들어온 상호가 읽히기도 전에 사라진다. - */ + /** 랜딩에서 넘어온 입구를 한 번만 읽는다 — `?new=1` · `?q=` · `?industry=`. */ const reset = useBuilderStore((s) => s.reset); const selectIndustry = useBuilderStore((s) => s.selectIndustry); const setStoreName = useBuilderStore((s) => s.setStoreName); @@ -95,13 +70,7 @@ export function BuilderPage() { ); }, [isNew, seedQuery, seedIndustry, reset, selectIndustry, setStoreName, setSearchParams]); - /** - * 위저드 1단계에서 확정한 사업장. 주소창에 placeId 가 없어도 이걸로 배선한다. - * - * ★ 이게 없으면 위저드를 끝까지 걸어온 사장님이 에디터에서 **업종 예시값**을 본다 — - * 방금 27건을 확인해 놓고 '독채 3개 동' 같은 남의 가게 값이 뜬다. 실제로 그랬다. - * 딥링크(/builder?placeId=...)가 우선이다 — 사업장 목록에서 다른 가게를 열 수 있어야 한다. - */ + /** 위저드 1단계에서 확정한 사업장. */ const wizardPlaceId = useBuilderStore((s) => s.confirmedIdentity?.placeId ?? null); const placeId = step === 'search' ? null : (urlPlaceId ?? wizardPlaceId); const sync = usePlaceSync(placeId, {isOnboarding}); @@ -110,24 +79,15 @@ export function BuilderPage() { // 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다. const user = useAuthStore((s) => s.user); const signOut = useAuthStore((s) => s.signOut); - // ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다. + // 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다. const isSignedIn = Boolean(user) || Boolean(getAccessToken()); - // 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도 - // 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다. + // 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다. const wiredPlaceId = useBuilderStore((s) => s.placeId); - /** - * 발행본이 실제로 존재하는가. - * - * ★ 주소(domain)만으로는 부족하다 — 주소는 발행 **전에** 예약된다(PublishModal 이 - * 빌드보다 먼저 잡아 둔다). 주소만 보고 버튼을 열면 아직 굽지 않은 사이트로 - * 보내 404 를 띄운다. 사이트 상태가 PUBLISHED 인 것까지 확인한다. - */ + /** 발행본이 실제로 존재하는가. */ const publishedUrl = sync.site?.status === SiteStatus.PUBLISHED ? siteUrl(sync.site.domain) : null; - // 실사업장을 열었는데 아직 못 읽었다 — 이 동안 데모(달빛스테이)를 그리면 - // 사장님은 남의 가게를 자기 가게로 오해한다. 차라리 아무것도 안 그린다. if (placeId && sync.isLoading) { return ( <BuilderNotice title="사업장을 불러오는 중입니다" description={placeId} isLoading /> @@ -148,7 +108,7 @@ export function BuilderPage() { ); } - // 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다. + // 에디터에 들어갈 때 로그인을 받는다. if (step === EDITOR_STEP && !isSignedIn) { return <EditorSignInGate onBack={() => goToStep('template')} />; } @@ -167,7 +127,7 @@ export function BuilderPage() { <span className="truncate rounded bg-success/20 px-2 py-0.5 font-semibold text-success"> 실사업장 · {storeName} </span> - {/* 돌아가는 길. 로그인한 사람에게만 목록이 있다(비로그인은 에디터에 못 들어온다). */} + {/* 돌아가는 길. */} <Link to="/sites" className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-background/70 transition-colors hover:bg-white/10 hover:text-background" @@ -182,9 +142,7 @@ export function BuilderPage() { </span> )} </div> - {/* 캔버스는 미리보기다. 진짜 발행본은 별도 렌더러(site)가 굽는다 — - 같은 화면을 두 번 구현하지 않고, 그쪽을 새 탭으로 연다. - ★ 발행 전에는 열지 않는다 — 굽지 않은 주소를 열면 404 다. */} + {/* 캔버스는 미리보기다. */} <div className="flex shrink-0 items-center gap-2"> {publishedUrl ? ( <a @@ -236,13 +194,7 @@ export function BuilderPage() { ); } - /** - * 위저드는 **사이드바를 쓰지 않는다.** - * - * ★ 사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 사이트 메뉴를 - * 얹으면, 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다. - * 진행은 단계가 이미 보여주므로(WizardSteps) 여기 필요한 건 로고와 **나가는 길** 하나다. - */ + /** 위저드는 **사이드바를 쓰지 않는다.** */ return ( <div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground"> <div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2.5"> @@ -279,7 +231,7 @@ export function BuilderPage() { ); } -/** 실사업장을 못 읽었을 때의 전체 화면. 데모로 돌아갈 길을 항상 같이 준다. */ +/** 실사업장을 못 읽었을 때의 전체 화면. */ function BuilderNotice({ title, description, diff --git a/solution/frontend/src/pages/LandingPage.tsx b/solution/frontend/src/pages/LandingPage.tsx index f32a6da..17adeba 100644 --- a/solution/frontend/src/pages/LandingPage.tsx +++ b/solution/frontend/src/pages/LandingPage.tsx @@ -6,11 +6,7 @@ import {ShowcaseGrid, ShowcasePeeks} from '@/features/marketing/ShowcaseGrid'; import {Button} from '@/components/ui/button'; import type {MetaFunction} from 'react-router'; import {ORIGIN} from '@/lib/site'; -/** - * ★ 제목은 브랜드가 아니라 **사람이 검색창에 치는 말**로 시작한다. "Web4Ai" 는 검색량이 - * 없는 신규 이름이고, 예전 제목("Web4Ai · AI 웹 빌더")에는 검색어가 한 단어도 없었다. - * seo 규칙: 핵심 키워드 앞, 브랜드 뒤, 50~60자. - */ +/** 제목은 브랜드가 아니라 **사람이 검색창에 치는 말**로 시작한다. */ export const meta: MetaFunction = () => [ {title: '가게 홈페이지 만들기 · AI 검색에 인용되는 방식으로 | Web4Ai'}, { @@ -26,8 +22,7 @@ export const meta: MetaFunction = () => [ content: '네이버 플레이스만 있으면 검색·AI 답변이 함께 읽는 우리 가게 홈페이지가 만들어집니다.', }, {property: 'og:url', content: `${ORIGIN}/`}, - // 이 앱이 무엇인지 기계에게 말하는 유일한 자리다. 발행본에는 LodgingBusiness 가 나가는데 - // 정작 랜딩에는 구조화 데이터가 하나도 없었다. + // 이 앱이 무엇인지 기계에게 말하는 유일한 자리다. { 'script:ld+json': { '@context': 'https://schema.org', @@ -43,11 +38,7 @@ export const meta: MetaFunction = () => [ ]; -/** - * 히어로 제목의 앞말. '홈페이지' 는 고정이고 이 여섯이 돌아간다. - * ★ 개수를 바꾸면 index.css 의 `o2o-rotate-6` 키프레임도 같이 고쳐야 한다 — - * 안 고치면 뒤쪽 문구가 영영 안 보이거나 빈 줄이 지나간다. 조용히 틀리는 종류다. - */ +/** 히어로 제목의 앞말. */ const HEADLINES = [ 'SEO · AEO 최적화', 'AI가 먼저 찾는', @@ -58,19 +49,7 @@ const HEADLINES = [ ]; -/** - * 랜딩 — 원페이지. 로그인 전 첫 화면이다. - * - * ★ 파는 것은 "예쁜 홈페이지"가 아니라 **AI 답변에서의 1차 출처 지위**다(PRODUCT.md 1절). - * 문구도 그 축에서만 쓴다 — '쉽게·빠르게·저렴하게'로 말하는 순간 홈페이지 빌더와 - * 같은 자리에서 비교당하고, 그 축에서는 이길 수 없다. - * - * ★ 상단이 받는 건 **상호명**이지 업종이 아니다. 사장님은 자기 가게 이름은 100% 알지만 - * 업종은 경계에서 멈춘다("우리는 카페인가 음식점인가"). 업종은 검색 결과의 분류로 - * 자동으로 정해지고(services/place_category.py), 못 정하면 그때 고르게 한다. - * - * ★ 여기서 로그인을 받지 않는다. 관문은 에디터 진입 하나다(b94daa9). - */ +/** 랜딩 — 원페이지. */ export function LandingPage() { const navigate = useNavigate(); const [query, setQuery] = useState(''); @@ -80,8 +59,7 @@ export function LandingPage() { const submit = (event: FormEvent) => { event.preventDefault(); const q = query.trim(); - // ★ `new=1` 을 함께 보낸다. 안 보내면 지난번에 만들다 만 에디터가 복원돼 뜬다 - // (stores/builder persist — BuilderPage 주석). + // `new=1` 을 함께 보낸다. navigate(q ? `/builder?new=1&q=${encodeURIComponent(q)}` : '/builder?new=1'); }; @@ -91,7 +69,7 @@ export function LandingPage() { <section className="relative flex min-h-[calc(100dvh-4rem)] items-center overflow-hidden border-b border-border"> <div className="mx-auto w-full max-w-6xl px-5 text-center"> <h1 className="mx-auto text-4xl leading-[1.15] font-extrabold tracking-[-0.045em] sm:text-6xl lg:text-7xl"> - {/* 앞말만 돈다. 마지막 항목은 첫 항목의 복제다 — 되감을 때 글자가 튀지 않게(index.css). */} + {/* 앞말만 돈다. */} <span className="o2o-rotator"> <span className="o2o-rotator-track"> {[...HEADLINES, HEADLINES[0]].map((line, index) => ( @@ -109,8 +87,7 @@ export function LandingPage() { </p> )} - {/* ★ 입력 카드와 발행 사이트 띠를 **같은 밴드**에 겹친다(아임웹과 같은 구조). - 띠를 카드 아래 따로 두면 첫 화면이 세로로 길어지고, 카드가 뜬금없이 혼자 뜬다. */} + {/* 입력 카드와 발행 사이트 띠를 **같은 밴드**에 겹친다(아임웹과 같은 구조). */} <div className={`relative mt-14 ${buildMode ? '' : 'min-h-52'}`}> <div className="pointer-events-none absolute top-1/2 left-1/2 w-screen -translate-x-1/2 -translate-y-1/2"> <ShowcasePeeks /> @@ -126,7 +103,7 @@ export function LandingPage() { <span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"> 가게 이름으로 시작 </span> - {/* 업종부터 고르고 싶은 사람을 위한 갈래. 위저드의 업종 단계로 바로 보낸다. */} + {/* 업종부터 고르고 싶은 사람을 위한 갈래. */} <Link to="/builder?new=1&step=industry" className="ml-auto rounded-full border border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:border-foreground/25 hover:text-foreground" diff --git a/solution/frontend/src/pages/LoginPage.tsx b/solution/frontend/src/pages/LoginPage.tsx index 2733f7a..b190e97 100644 --- a/solution/frontend/src/pages/LoginPage.tsx +++ b/solution/frontend/src/pages/LoginPage.tsx @@ -10,35 +10,21 @@ import {notifyApiError} from '@/lib/notify'; import {establishSession} from '@/lib/session'; import {useAuthStore} from '@/stores/auth'; -/** - * 로그인 화면. **사장님 앱과 내부 운영 화면이 같이 쓴다.** - * - * ★ `selfServe` 로 갈린다 — 사장님 앱은 스스로 가입하고 구글로도 들어오지만, 내부 운영 계정은 - * 우리가 만들어 준다. 가입 링크를 두 곳에 다 두면 admin 라우터에 없는 `/signup` 으로 보내 - * 404 가 난다(admin/src/app/router.tsx 에 그 경로는 없다). - */ +/** 로그인 화면. */ export function LoginPage({ selfServe = true, - // 로그인 직후엔 내 사이트로. 예전엔 router.tsx 가 넘기던 값이다. + // 로그인 직후엔 내 사이트로. homePath = '/sites', }: { selfServe?: boolean; - /** - * 로그인 직후 도착지. - * - * ★ 예전엔 '/' 로 보내고 각 앱의 '/' 리다이렉트가 알아서 가르게 했다. 그런데 사장님 앱에서 - * 그 관문이 **로그인한 사람에게 랜딩을 영영 못 보게** 만들었다 — 로고를 눌러도 '/' 가 - * /sites 로 튕긴다(사장님 지적). 관문을 걷고 도착지를 여기서 받는다. - * ★ 기본값은 '/' 다. 내부 운영 앱은 자기 '/' 가 사업장 목록으로 보내므로 그대로 맞는다. - */ + /** 로그인 직후 도착지. */ homePath?: string; }) { const navigate = useNavigate(); const location = useLocation(); const user = useAuthStore((s) => s.user); - // ★ 비워 둔다. 채워 두면 번들에 그대로 구워져 나가고(실측 2026-09-03: 배포본에 - // admin/1234 가 박혀 있었다), 아이디만 고쳐 넣은 사람이 남의 비밀번호로 로그인을 시도한다. + // 비워 둔다. const [id, setId] = useState(''); const [password, setPassword] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); @@ -96,7 +82,7 @@ export function LoginPage({ className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7" > <div className="space-y-1 text-center"> - {/* 로고는 어디서든 랜딩으로 돌아가는 문이다. 로그인 화면에서 나갈 길이 여기뿐이었다. */} + {/* 로고는 어디서든 랜딩으로 돌아가는 문이다. */} <Link to="/" className="mx-auto mb-3 block w-fit"> <img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-9 w-auto" /> </Link> diff --git a/solution/frontend/src/pages/OpsSitesPage.tsx b/solution/frontend/src/pages/OpsSitesPage.tsx index 367f2f2..5c7d5d2 100644 --- a/solution/frontend/src/pages/OpsSitesPage.tsx +++ b/solution/frontend/src/pages/OpsSitesPage.tsx @@ -34,19 +34,13 @@ function ownerLabel(row: OpsSiteData): string { return row.owner_name || row.owner_email || row.owner_login_id; } -/** - * 사이트관리 — 개발자(DEVELOPER) 전용. 전 계정 사이트를 회사 스코프 없이 본다. - * - * ★ admin/frontend 의 사업장 화면(fact 검증)과 역할이 다르다 — 여기는 "발행이 나가 있나 · - * 다시 구워야 하나 · 누구 계정인가"만 본다(2026-09-23 기획). - */ +/** 사이트관리 — 개발자(DEVELOPER) 전용. */ export function OpsSitesPage() { const isRestoring = useAuthStore((s) => s.isRestoring); const role = useAuthStore((s) => s.user?.role); const [page, setPage] = useState(1); const size = 20; - // enabled: role 이 확정되기 전엔 요청하지 않는다 — 어차피 백엔드가 403 으로 막지만, - // 사장님 화면에서 실패 토스트가 먼저 뜨는 것과 새로고침 때 잠깐의 오탐 리다이렉트를 막는다. + // enabled: role 이 확정되기 전엔 요청하지 않는다 — 어차피 백엔드가 403 으로 막지만, 사장님 화면에서 실패 토스트가 먼저 뜨는 것과 새로고침 때 잠깐의 오탐 리다이렉트를 막는다. const {data, isLoading, isError, error} = useListSites( {page, size}, {query: {placeholderData: keepPreviousData, enabled: role === UserRole.DEVELOPER}}, diff --git a/solution/frontend/src/pages/OpsUsersPage.tsx b/solution/frontend/src/pages/OpsUsersPage.tsx index 2ec4ce4..8be9890 100644 --- a/solution/frontend/src/pages/OpsUsersPage.tsx +++ b/solution/frontend/src/pages/OpsUsersPage.tsx @@ -24,12 +24,7 @@ const PROVIDER_LABEL: Record<number, string> = { [AuthProvider.GOOGLE]: '구글', }; -/** - * 유저관리 — 개발자(DEVELOPER) 전용. admin/frontend 엔 아예 없던 화면이다. - * - * ★ 개발자 계정은 목록에 없다 — 백엔드(services/ops_service.list_users)가 USER/OWNER 로만 - * 걸러 준다(UserRole 주석: "개발자 계정은 고객사에 존재를 노출하지 않는다"). - */ +/** 유저관리 — 개발자(DEVELOPER) 전용. */ export function OpsUsersPage() { const isRestoring = useAuthStore((s) => s.isRestoring); const role = useAuthStore((s) => s.user?.role); diff --git a/solution/frontend/src/pages/PricingPage.tsx b/solution/frontend/src/pages/PricingPage.tsx index 98f7d8b..42a9a0b 100644 --- a/solution/frontend/src/pages/PricingPage.tsx +++ b/solution/frontend/src/pages/PricingPage.tsx @@ -17,13 +17,7 @@ export const meta: MetaFunction = () => [ ]; -/** - * 요금 — 플랜 하나다. - * - * ★ 플랜을 여럿 늘어놓지 않는다. 이건 셀프서비스 구독이 아니라 **월 단위로 사람이 붙는 - * 서비스**라 고를 것이 가격대가 아니라 "할 것이냐"뿐이다. 비교표를 만들면 없는 선택지를 - * 지어내게 된다. - */ +/** 요금 — 플랜 하나다. */ const INCLUDED = ['AI 노출 진단 리포트', '최적화 가이드', '영상 콘텐츠 2종', '페이지 제작 1회']; const TERMS: {label: string; value: string}[] = [ diff --git a/solution/frontend/src/pages/ShowcasePage.tsx b/solution/frontend/src/pages/ShowcasePage.tsx index fcafe95..6075a8b 100644 --- a/solution/frontend/src/pages/ShowcasePage.tsx +++ b/solution/frontend/src/pages/ShowcasePage.tsx @@ -17,12 +17,7 @@ export const meta: MetaFunction = () => [ ]; -/** - * 이렇게 나옵니다 — 실제로 발행된 홈페이지 목록. - * - * ★ 템플릿 갤러리가 아니다. 고르라고 보여주는 게 아니라 "진짜로 나갔다"는 증거를 거는 자리다. - * 그래서 예시 데이터로 채우지 않는다(ShowcaseGrid 주석) — 발행본이 없으면 빈 화면이 맞다. - */ +/** 이렇게 나옵니다 — 실제로 발행된 홈페이지 목록. */ export function ShowcasePage() { return ( <MarketingShell> diff --git a/solution/frontend/src/pages/SignupPage.tsx b/solution/frontend/src/pages/SignupPage.tsx index 3c355f6..f1f7d4b 100644 --- a/solution/frontend/src/pages/SignupPage.tsx +++ b/solution/frontend/src/pages/SignupPage.tsx @@ -10,13 +10,7 @@ import {notify, notifyApiError} from '@/lib/notify'; import {establishSession} from '@/lib/session'; import {useAuthStore} from '@/stores/auth'; -/** - * 회원가입. 가입 = **새 회사(테넌트) 1개 + 그 회사의 첫 계정 1개** 다(백엔드 auth_service.signup). - * - * ★ 여기 검사는 서버 규칙(services/auth_service.py 의 _LOGIN_ID_RE·_MIN_PASSWORD_LEN)의 사본이다. - * 두 벌이라 어긋날 수 있지만, 서버가 마지막 방어선이고 여기는 "제출 전에 알려주는" 역할이다. - * 규칙을 바꾸면 두 곳을 같이 고친다. - */ +/** 회원가입. */ const ID_RE = /^[a-zA-Z][a-zA-Z0-9._-]{3,19}$/; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; const MIN_PASSWORD_LEN = 8; diff --git a/solution/frontend/src/pages/SitesPage.tsx b/solution/frontend/src/pages/SitesPage.tsx index 4efbd8d..f8bcc9d 100644 --- a/solution/frontend/src/pages/SitesPage.tsx +++ b/solution/frontend/src/pages/SitesPage.tsx @@ -32,9 +32,6 @@ import {PUBLISH_HOST} from '@/lib/site'; import {cn} from '@/lib/utils'; // 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다. -// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 -// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. -// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다) const CATEGORY_ICON: Record<number, typeof Building2> = { [PlaceCategory.LODGING]: Building2, @@ -43,21 +40,11 @@ const CATEGORY_ICON: Record<number, typeof Building2> = { [PlaceCategory.CLINIC]: Stethoscope, }; -/** - * 줄이 속하는 칸. **배지·필터·정렬이 전부 이 하나로 판정한다.** - * - * ★ 판정을 갈라 쓰면 조용히 틀린다 — '발행됨(1)' 을 눌렀는데 '내림' 배지가 낀 줄이 같이 - * 나오는 종류다. 건수까지 틀리므로 사장님은 목록을 못 믿게 된다. - * ★ 세 칸이 목록을 빈틈없이 나눈다. 하나라도 빠지면 '전체' 건수와 칸 건수의 합이 어긋난다. - */ +/** 줄이 속하는 칸. */ type SiteBucket = 'live' | 'draft'; type SiteFilter = SiteBucket | 'all'; -/** - * ★ 칸은 **둘**이다. 예전엔 '만드는 중'(사이트 행 없음)을 따로 뒀는데, 사장님에게 그 둘은 - * 같은 상태다 — "아직 안 나가 있다". `site_id` 가 있고 없고는 우리 DB 사정이지 사장님의 - * 구분이 아니고, 칸이 셋이면 34개가 어디 있는지 두 번 세게 된다. - */ +/** 칸은 **둘**이다. */ const FILTER_LABEL: Record<SiteFilter, string> = { all: '전체', live: '발행됨', @@ -66,43 +53,31 @@ const FILTER_LABEL: Record<SiteFilter, string> = { const FILTER_TABS: readonly SiteFilter[] = ['all', 'live', 'draft']; -/** 나가 있는 것부터 본다 — 실측(계정 test): 35개 중 34개가 발행 전이라 1개가 묻힌다. */ const BUCKET_ORDER: Record<SiteBucket, number> = {live: 0, draft: 1}; function bucketOf(row: MySiteData): SiteBucket { return row.site_id && row.status === SiteStatus.PUBLISHED ? 'live' : 'draft'; } -/** - * 줄의 상태 배지. **사이트 상태(sites.status)만 본다** — 사업장 상태(places.status)는 - * 수집 단계를 말하는 값이라 사장님이 궁금한 "지금 나가 있나"와 다르다. - */ +/** 줄의 상태 배지. */ function statusBadge(row: MySiteData) { if (bucketOf(row) === 'live') { return row.needs_rebuild ? {label: '수정됨 · 재발행 필요', variant: 'warning' as const} : {label: '발행됨', variant: 'success' as const}; } - // 같은 '발행 전' 이어도 **한 번 나갔다가 내린 것**은 말해 준다 — 되돌리는 일과 처음 내는 일은 - // 사장님이 할 행동이 다르다. 그 외(초안·수집 중)는 전부 '발행 전' 한 마디다. if (row.status === SiteStatus.SUSPENDED) return {label: '중지', variant: 'outline' as const}; if (row.status === SiteStatus.UNPUBLISHED) return {label: '내림', variant: 'outline' as const}; return {label: '발행 전', variant: 'default' as const}; } -/** 발행본이 실제로 열리는 주소. ★ 주소는 발행 전에 예약되므로 PUBLISHED 일 때만 연다 — 아니면 404 다. */ +/** 발행본이 실제로 열리는 주소. */ function publishedUrl(row: MySiteData): string | null { if (row.status !== SiteStatus.PUBLISHED || !row.domain) return null; return publishUrlString(row.domain.split('.')[0], PUBLISH_HOST); } -/** - * 줄 뒤에 붙는 시각. **발행됐으면 발행일, 아니면 만든 날**이다. - * - * ★ 두 개를 같이 걸지 않는다. Wix·아임웹 목록이 시각을 한 칸만 쓰는 이유와 같다 — - * 목록에서 궁금한 건 "이게 언제 나갔나" 하나이고, 아직 안 나간 줄에만 만든 날이 의미가 있다. - * ★ 연도는 올해면 뗀다. 줄이 좁아 주소가 먼저 잘린다. - */ +/** 줄 뒤에 붙는 시각. */ function whenLabel(row: MySiteData): string { const raw = row.published_at ?? row.created_at; if (!raw) return ''; @@ -117,7 +92,7 @@ function whenLabel(row: MySiteData): string { return row.published_at ? `${date} 발행` : `${date} 만듦`; } -/** 정렬용 시각. whenLabel 이 고른 값과 같은 것을 쓴다 — 화면에 보이는 날짜와 순서가 갈라지면 안 된다. */ +/** 정렬용 시각. */ function rowTime(row: MySiteData): number { const raw = row.published_at ?? row.created_at; if (!raw) return 0; @@ -125,10 +100,7 @@ function rowTime(row: MySiteData): number { return Number.isNaN(at) ? 0 : at; } -/** - * 검색 비교용 정규화. **공백을 지운다** — 실측(계정 test)에 같은 상호 '버터브루' 가 4줄인데 - * 사장님은 '버터 브루' 로도 친다. 한국어 상호는 띄어쓰기가 원본마다 다르다. - */ +/** 검색 비교용 정규화. */ function normalizeText(value: string): string { return value.toLowerCase().replace(/\s+/g, ''); } @@ -139,24 +111,12 @@ function matchesQuery(row: MySiteData, needle: string): boolean { return normalizeText(row.name).includes(needle) || normalizeText(row.road_address ?? '').includes(needle); } -/** - * 줄 앞의 그림. 발행에 성공한 사이트만 썸네일이 있고(sites.thumbnail_url), - * 없으면 업종 아이콘으로 떨어진다. - * - * ★ 주소에 `?v=<버전>` 이 붙어 있다 — 발행할 때마다 바뀐다(site_thumbnail.public_url). - * 그래서 <img> 에 캐시 무효화를 따로 걸지 않는다. 여기서 또 붙이면 발행하지 않은 - * 재방문에도 매번 새로 받는다. - * ★ 못 받으면 아이콘으로 되돌린다. 블롭이 지워졌거나 옛 주소인 줄에서 깨진 그림이 - * 뜨는 것보다 낫다. - */ +/** 줄 앞의 그림. */ function SiteThumb({row, Icon}: {row: MySiteData; Icon: typeof Building2}) { const [failed, setFailed] = useState(false); const src = row.thumbnail_url; - // ★ 비율은 **16:10 — 브라우저 창 비율**이다. 이 그림은 사이트 미리보기라 1:1 로 자르면 - // 위아래가 잘려 무슨 사이트인지 알아볼 수 없다(사진첩이 아니다). - // ★ 크기는 아임웹 내사이트 화면을 보고 키웠다(2026-09-08). 거기 썸네일도 줄 높이의 대부분을 - // 차지한다 — 같은 상호가 여러 줄일 때 그림이 유일한 구분자인데 작으면 있으나 마나다. + // 비율은 **16:10 — 브라우저 창 비율**이다. if (!src || failed) { return ( <div className="flex aspect-[16/10] w-full items-center justify-center border-b border-border bg-muted/60"> @@ -175,15 +135,7 @@ function SiteThumb({row, Icon}: {row: MySiteData; Icon: typeof Building2}) { ); } -/** - * 내 사이트 — 로그인한 사장님의 홈이다. - * - * 흐름은 하나다: 위저드로 만든다 → 여기 생긴다 → 눌러서 에디터로 들어가 고친다 → 재발행한다. - * ★ 그래서 줄을 누르면 에디터로 간다. 목록에 온 용건은 열에 아홉 "내 사이트 고치기"다. - * - * ★ 목록이 하는 일은 셋뿐이다 — ① 어느 게 어느 건지 가르고 ② 지금 상태를 말하고 ③ 여는 길을 준다. - * 방문자·주문 같은 숫자는 여기 넣지 않는다(Wix·아임웹도 사이트 안 대시보드에 둔다). - */ +/** 내 사이트 — 로그인한 사장님의 홈이다. */ export function SitesPage() { const navigate = useNavigate(); const {data, isLoading, isError, error, refetch} = useListMySites({size: 50}); @@ -194,16 +146,13 @@ export function SitesPage() { const rows = useMemo(() => data?.sites ?? [], [data]); - // ★ 서버로 안 보낸다. 한 번에 50줄을 이미 다 받아 놓았고(실측 35줄), 서버 검색을 붙이면 - // 글자마다 왕복 + 디바운스 + 늦게 온 응답이 최신 결과를 덮는 경합까지 따라온다. - // → 50줄을 넘어 페이지가 생기는 날이 오면 그때는 서버가 맞다(이 필터는 첫 페이지만 본다). + // 서버로 안 보낸다. const found = useMemo(() => { const needle = normalizeText(query); return rows.filter((row) => matchesQuery(row, needle)); }, [rows, query]); - // ★ 건수는 **검색 결과 위에서** 센다. 검색어를 친 뒤 "발행됨 0 · 만드는 중 3" 이 보여야 - // 찾는 게 어느 칸에 있는지 알 수 있다. 검색 전 건수를 그대로 두면 칸을 눌러 보고서야 안다. + // 건수는 **검색 결과 위에서** 센다. const counts = useMemo(() => { const tally: Record<SiteFilter, number> = {all: found.length, live: 0, draft: 0}; for (const row of found) tally[bucketOf(row)] += 1; @@ -211,7 +160,6 @@ export function SitesPage() { }, [found]); // 서버는 사업장 생성 역순으로만 준다(site_crud.list_owner_sites) — 발행 여부를 모른다. - // 나가 있는 것을 위로 올리는 건 여기서 한다. const visible = useMemo(() => { const picked = filter === 'all' ? found : found.filter((row) => bucketOf(row) === filter); return [...picked].sort( @@ -226,8 +174,7 @@ export function SitesPage() { setFilter('all'); }; - // 발행 내리기만 둔다. ★ 삭제 경로는 만들지 않는다 — 색인된 페이지를 404 로 만들면 - // 그 자리를 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다(sites.status 주석). + // 발행 내리기만 둔다. const handleUnpublish = async (row: MySiteData) => { if (!window.confirm(`'${row.name}' 사이트를 검색에서 내릴까요?\n주소는 그대로 두고 페이지만 내려갑니다.`)) return; setMenuId(null); @@ -341,8 +288,7 @@ export function SitesPage() { </div> )} - {/* ★ 처음 온 사람의 빈 화면과 섞지 않는다 — 사이트가 35개인데 "아직 없습니다" 라고 하면 - 사장님은 목록이 아니라 자기 사이트가 사라진 줄 안다. */} + {/* 처음 온 사람의 빈 화면과 섞지 않는다 — 사이트가 35개인데 "아직 없습니다" 라고 하면 사장님은 목록이 아니라 자기 사이트가 사라진 줄 안다. */} {!isLoading && !isError && rows.length > 0 && visible.length === 0 && ( <EmptyState icon={SearchX} @@ -357,10 +303,7 @@ export function SitesPage() { )} {visible.length > 0 && ( - /* ★ 줄이 아니라 카드다. 아임웹 내사이트 화면을 보고 바꿨다(2026-09-08) — 거기 썸네일은 - 줄 높이의 대부분을 차지한다. 사이트 목록에서 그림은 장식이 아니라 **유일한 구분자**라 - (실측: 같은 상호 '버터브루' 4줄) 작으면 있으나 마나다. - 비율은 16:10 — 사이트 미리보기라 브라우저 창 비율이어야 한다. 1:1 은 사진첩이지 사이트가 아니다. */ + /* 줄이 아니라 카드다. */ <ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"> {visible.map((row) => { const Icon = CATEGORY_ICON[row.category] ?? Building2; @@ -370,9 +313,7 @@ export function SitesPage() { const editHref = `/builder?placeId=${row.place_id}`; return ( - /* ★ 카드는 **상태와 무관하게 같은 골격**이다. 예전엔 발행 전 카드에만 '할 일' - 줄이 하나 더 붙어서 같은 줄의 카드끼리 높이와 버튼 위치가 어긋났다(사장님 지적). - h-full + flex-col + mt-auto 로 액션 줄을 항상 카드 바닥에 붙인다. */ + /* 카드는 **상태와 무관하게 같은 골격**이다. */ <li key={row.place_id} className="group relative flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-shadow hover:shadow-md" @@ -389,8 +330,7 @@ export function SitesPage() { <div className="flex flex-1 flex-col p-3.5"> <Link to={editHref} className="block min-w-0"> <p className="truncate text-sm font-semibold">{row.name}</p> - {/* ★ 나가 있는 카드만 주소를 진하게. 나머지의 이 자리는 "아직 없다"는 안내라 - 같은 색이면 34개의 안내문 사이에 진짜 주소가 묻힌다. */} + {/* 나가 있는 카드만 주소를 진하게. */} <p className={cn( 'mt-1 truncate text-xs', @@ -406,8 +346,6 @@ export function SitesPage() { </Link> <div className="mt-auto flex items-center gap-1.5 border-t border-border pt-3"> - {/* 버튼 문구도 하나로 둔다 — '편집' 과 '이어서 만들기' 는 사장님이 할 일이 - 같은데(에디터를 연다) 글자만 달라 카드마다 폭이 들쭉날쭉했다. */} <Button size="sm" className="flex-1" onClick={() => navigate(editHref)}> <Pencil /> 편집 @@ -437,7 +375,7 @@ export function SitesPage() { {menuId === row.place_id && ( <> - {/* 바깥을 눌러 닫는다. 메뉴가 몇 줄 안 돼 팝오버 라이브러리를 들이지 않는다. */} + {/* 바깥을 눌러 닫는다. */} <button type="button" aria-label="닫기" @@ -471,9 +409,7 @@ export function SitesPage() { type="button" onClick={() => { setMenuId(null); - // ★ 예약 요청은 DB 에 안 남는다(2026-09-16 대표 지시, booking_request.py) — - // 메일로만 가고 우리 쪽엔 로그 한 줄만 남는다. 목록 화면을 만들려면 - // 그 결정부터 뒤집어야 한다 — 지금은 안내만 한다. + // 예약 요청은 DB 에 안 남는다 — 메일로만 가고 우리 쪽엔 로그 한 줄만 남는다. notify.info( '예약 요청은 메일로만 전달됩니다', '연락처를 따로 저장하지 않기로 했습니다(2026-09-16). 목록이 필요하면 먼저 이 방침을 바꿔야 합니다.', @@ -504,8 +440,7 @@ export function SitesPage() { </PageContainer> - {/* 떠 있는 대화창. 목록 위가 아니라 화면 구석인 이유는, 이게 '또 하나의 카드' 가 아니라 - 어느 화면에서든 부를 수 있는 창구이기 때문이다(AgentChatDock 주석). */} + {/* 떠 있는 대화창. */} <AgentChatDock sites={rows.map((row) => ({place_id: String(row.place_id), name: row.name}))} /> </AppShell> ); diff --git a/solution/frontend/src/root.tsx b/solution/frontend/src/root.tsx index 329b9a7..796d424 100644 --- a/solution/frontend/src/root.tsx +++ b/solution/frontend/src/root.tsx @@ -3,14 +3,7 @@ import type {LinksFunction, MetaFunction} from 'react-router'; import {Providers} from '@/app/provider'; import './index.css'; -/** - * 문서 껍데기. 예전 `index.html` 이 하던 일을 여기서 한다 — - * 프레임워크 모드에는 index.html 이 없고, 이 컴포넌트가 <html> 부터 그린다. - * - * ★ 발행 호스트를 여기 문자열로 적지 않는다. `VITE_PUBLISH_HOST` 는 compose 가 루트의 - * `SITE_PUBLIC_HOST` 를 흘려보내는 값이다 — 두 곳에 적으면 canonical 과 화면 주소가 - * 조용히 갈라진다(AGENTS.md). - */ +/** 문서 껍데기. */ const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST || 'web4ai.o2osolution.ai'; export const ORIGIN = `https://${PUBLISH_HOST}`; @@ -24,14 +17,14 @@ export const links: LinksFunction = () => [ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&family=Noto+Serif+KR:wght@400;600;700&display=swap', }, - // 7080 아이템(가요 다방·일력·승차권) 전용. 이 셋이 없으면 간판/명조가 고딕으로 떨어져 감성이 사라진다 + // 7080 아이템(가요 다방·일력·승차권) 전용. { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Gugi&family=Gowun+Batang:wght@400;700&family=Nanum+Pen+Script&display=swap', }, ]; -/** 라우트가 자기 것을 안 내놓을 때의 기본값. 각 페이지는 `meta` 를 export 해 덮어쓴다. */ +/** 라우트가 자기 것을 안 내놓을 때의 기본값. */ export const meta: MetaFunction = () => [ {title: 'Web4Ai'}, {property: 'og:site_name', content: 'Web4Ai'}, diff --git a/solution/frontend/src/routes.ts b/solution/frontend/src/routes.ts index a3a7e45..5d6a8a2 100644 --- a/solution/frontend/src/routes.ts +++ b/solution/frontend/src/routes.ts @@ -20,7 +20,6 @@ export default [ route('account', 'pages/AccountPage.tsx'), // "내 사이트" 카드의 관리 메뉴에서 온다(?placeId= 로 어느 사이트인지 받는다) — 사장님 여럿이 사이트 여럿을 가질 수 있어 전역 메뉴 하나로는 못 고른다. route('blog', 'pages/BlogPostsPage.tsx'), - // 개발자(DEVELOPER) 전용 — admin/frontend 를 새 도메인으로 키우는 대신 여기 경량으로 얹었다. route('ops/sites', 'pages/OpsSitesPage.tsx'), route('ops/users', 'pages/OpsUsersPage.tsx'), ]), diff --git a/solution/frontend/src/stores/auth.ts b/solution/frontend/src/stores/auth.ts index 99fa40f..a110299 100644 --- a/solution/frontend/src/stores/auth.ts +++ b/solution/frontend/src/stores/auth.ts @@ -10,13 +10,7 @@ export interface AuthUser { role: number; } -/** - * `GET /v1/auth/me` 응답 → 화면이 쓰는 사용자. - * - * ★ 생성 모델의 필드가 전부 optional 인 것이 이상해 보이지만 맞다 — 백엔드가 기본값을 준 - * 필드(`user_id: str = ""`)는 OpenAPI 에서 required 가 아니고, 응답은 `RemoveNoneResponse` - * 를 지나며 None 필드가 키째 사라진다. 그래서 없을 수 있다고 보고 여기 한 곳에서만 메운다. - */ +/** `GET /v1/auth/me` 응답 → 화면이 쓰는 사용자. */ export function toAuthUser(res: ResMe): AuthUser { return { userId: res.user_id ?? '', @@ -27,17 +21,14 @@ export function toAuthUser(res: ResMe): AuthUser { }; } -/** - * 화면에 띄울 이름. 구글 계정의 로그인 아이디는 `google_<sub>` 라 그대로 보이면 안 된다 — - * 이름 → 이메일 순으로 떨어뜨리고 아이디는 마지막이다. - */ +/** 화면에 띄울 이름. */ export function userLabel(user: AuthUser): string { return user.name || user.email || user.id; } interface AuthState { user: AuthUser | null; - /** 부팅 시 저장된 토큰을 확인하기 전까지 true. 가드가 이 동안 리다이렉트를 미룬다. */ + /** 부팅 시 저장된 토큰을 확인하기 전까지 true. */ isRestoring: boolean; isAuthenticated: () => boolean; signIn: (tokens: {accessToken: string; refreshToken?: string}, user: AuthUser) => void; @@ -50,8 +41,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({ user: null, isRestoring: true, - // 토큰 보관소는 custom-fetch 가 유일한 소스다. 스토어가 사본을 들지 않는다 — - // 두 곳에 두면 refresh 로 갱신됐을 때 한쪽만 낡는다. + // 토큰 보관소는 custom-fetch 가 유일한 소스다. isAuthenticated: () => Boolean(getAccessToken()) && Boolean(get().user), signIn: (tokens, user) => { diff --git a/solution/frontend/src/stores/builder.ts b/solution/frontend/src/stores/builder.ts index 3ab0c56..9e4cee3 100644 --- a/solution/frontend/src/stores/builder.ts +++ b/solution/frontend/src/stores/builder.ts @@ -53,7 +53,7 @@ interface BuilderState { selectedChannels: string[]; /** 2단계에서 확인된 신원. */ confirmedIdentity: ConfirmedIdentity | null; - /** 업종을 못 정해 업종 화면으로 넘긴 후보. 돌아와서 확정을 이어갈 때만 쓴다. */ + /** 업종을 못 정해 업종 화면으로 넘긴 후보. */ pendingPick: PendingPick | null; // 수집 @@ -71,7 +71,7 @@ interface BuilderState { photos: PhotoItem[]; /** infoFields 의 id → 서버 fact. */ factRefs: Record<string, FactRef>; - /** 지금 서버로 올라가는 중인 줄. 사장님에게 "저장 중"을 보여주는 근거. */ + /** 지금 서버로 올라가는 중인 줄. */ savingFieldIds: string[]; // ── 에디터 UI ───────────────────────────────────────── @@ -108,17 +108,17 @@ interface BuilderState { selectTemplate: (templateId: string) => void; selectColorPalette: (paletteId: string | null) => void; - /** 생성 화면에 들어가기 직전, 진행 표시를 처음으로 되돌린다. 화면 이동은 주소창이 한다. */ + /** 생성 화면에 들어가기 직전, 진행 표시를 처음으로 되돌린다. */ selectSection: (id: string | null) => void; toggleSection: (id: string) => void; reorderSection: (fromIndex: number, toIndex: number) => void; /** [+ 섹션 추가] — 이미 있으면 켜기만 한다. */ addSection: (type: string) => void; - /** 나중에 넣은 섹션만 뺀다. 업종 기본 섹션은 스위치로 끄는 것이지 빼는 게 아니다. */ + /** 나중에 넣은 섹션만 뺀다. */ removeSection: (id: string) => void; updateSectionContent: (sectionId: string, patch: Pick<SectionItem, 'name' | 'description' | 'body'>) => void; - /** 붙여넣기 아이템의 원문 JSON. 깨져 있어도 그대로 담는다 — 판단은 렌더러가 한다. */ + /** 붙여넣기 아이템의 원문 JSON. */ updateSectionData: (sectionId: string, data: string) => void; /** 서버에 저장돼 있던 디자인을 화면에 얹는다(새로고침·다른 기기 복원). */ applyTheme: (theme: SiteThemePayload | null | undefined, savedTemplateId?: string | null) => void; @@ -145,7 +145,7 @@ interface BuilderState { /** 위저드 입력이 소유하는 줄의 id. */ const OWNER_INPUT_FIELD_IDS = {name: 'name', address: 'address'} as const; -/** 사장님이 직접 입력한 값의 출처 표기. 백엔드의 SourceType.OWNER 에 대응한다. */ +/** 사장님이 직접 입력한 값의 출처 표기. */ const OWNER_SOURCE = '직접 입력'; /** 위저드에서 사장님이 친 상호·위치를 정보 카드에 그대로 얹는다. */ @@ -253,12 +253,10 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({ infoFields: withConfirmedIdentity(seed.infoFields, identity), } : { - /** 사장님이 친 상호·위치는 시드가 아니다 — 업종을 바꿨다고 지우면 안 된다. */ storeName: state.storeName, location: state.location, infoFields: withOwnerIdentity(seed.infoFields, state.storeName, state.location), }), - // 업종을 손으로 고르면 화면의 값은 다시 시드다 — 실사업장 배선을 남겨두면 "placeId 가 있다 = 화면 값이 서버에서 왔다"는 약속이 깨진다. placeId: null, gatherCompleted: false, gatherStage: 1, @@ -645,7 +643,7 @@ function persistTheme() { ); } -/** 현재 선택된 템플릿. 없으면 업종의 첫 템플릿으로 떨어진다(빈 화면을 만들지 않는다). */ +/** 현재 선택된 템플릿. */ export function useCurrentTemplate(): TemplateItem { const industry = useBuilderStore((s) => s.industry); const templateId = useBuilderStore((s) => s.templateId); diff --git a/solution/frontend/src/stores/builderTypes.ts b/solution/frontend/src/stores/builderTypes.ts index 0ef7738..03d647f 100644 --- a/solution/frontend/src/stores/builderTypes.ts +++ b/solution/frontend/src/stores/builderTypes.ts @@ -1,39 +1,21 @@ -/** - * 빌더 화면 계약 — 스토어·어댑터·패널이 함께 쓰는 타입. - * - * ★ 스토어 파일에서 뺀 이유: 어댑터(placeAdapter)와 저장 파이프라인(factSave)이 이 타입들을 - * 필요로 하는데, 그것들이 스토어를 import 하면 순환이 된다. 타입만 여기 두면 - * 의존 방향이 한쪽으로만 흐른다(타입 → 스토어 → 기능 모듈). - */ +/** 빌더 화면 계약 — 스토어·어댑터·패널이 함께 쓰는 타입. */ import type {FactStatus, IndustryType, InfoField, PhotoItem} from '@o2o/shared'; -/** - * ★ 위저드 단계는 여기 없다 — 주소창이 소유한다(`features/onboarding/wizardUrl`). - * 스토어에 두면 뒤로가기·북마크가 단계를 못 따라온다. - */ +/** 위저드 단계는 여기 없다 — 주소창이 소유한다(`features/onboarding/wizardUrl`). */ export type RightTab = 'content' | 'design' | 'photos' | 'info' | 'faq' | 'verify'; -/** - * 정보 표의 줄 하나가 서버의 어떤 fact 인지. - * - * ★ InfoField 는 @o2o/shared 의 화면 계약이라 fact_id 를 실을 자리가 없다(넣으면 site 렌더러까지 - * 같이 흔들린다). 그래서 "이 줄을 고치면 어떤 fact 를 전이시켜야 하는가"는 옆에 따로 들고 간다. - */ +/** 정보 표의 줄 하나가 서버의 어떤 fact 인지. */ export interface FactRef { factId: string; - /** 지금 서버 상태. 어떤 전이가 허용되는지가 여기서 갈린다. */ + /** 지금 서버 상태. */ status: FactStatus; /** 화면 문자열을 서버 값으로 되돌릴 때 쓴다(FieldSpecData.type). */ type?: string; - /** 값 뒤에 붙어 있는 단위. 그대로 올리면 단위가 두 번 붙는다. */ + /** 값 뒤에 붙어 있는 단위. */ unit?: string; } -/** - * 실사업장 1곳을 캔버스에 얹기 위한 입력 — 백엔드 응답을 이미 화면 모양으로 옮긴 것. - * - * 스토어는 fetch 를 모른다. "무엇을 그릴지"만 받고, "어디서 왔는지"는 usePlaceSync 가 안다. - */ +/** 실사업장 1곳을 캔버스에 얹기 위한 입력 — 백엔드 응답을 이미 화면 모양으로 옮긴 것. */ export interface LivePlaceInput { placeId: string; industry: IndustryType; @@ -42,15 +24,9 @@ export interface LivePlaceInput { weatherLocation?: WeatherLocation; infoFields: InfoField[]; photos: PhotoItem[]; - /** infoFields 의 id → fact 참조. 편집을 서버로 되돌려 보내는 유일한 통로다. */ + /** infoFields 의 id → fact 참조. */ factRefs: Record<string, FactRef>; - /** - * 이 사업장에 수집된 fact 가 하나라도 있는가. - * - * ★ 신원(상호·주소)은 fact 가 아니라 place 의 값이다. 그래서 "사업장을 읽어왔다"와 - * "수집을 돌렸다"는 다른 사건인데, 이걸 구분하지 않으면 검색만 끝낸 화면이 - * 상호·주소 2줄을 놓고 '수집 완료'라고 말한다 — 사장님은 수집이 실패했다고 읽는다. - */ + /** 이 사업장에 수집된 fact 가 하나라도 있는가. */ hasCollected: boolean; } @@ -60,14 +36,9 @@ export interface WeatherLocation { longitude: number; } -/** - * 2단계에서 **사람이 확인한** 사업장 신원. - * - * 여기 담긴 상호·주소는 "검색으로 찾아진 값"이 아니라 "사장님이 내 가게가 맞다고 고른 값"이다. - * 그래서 3단계 이후 화면은 이걸 신원의 근거로 쓰고, 업종 시드의 예시 상호는 더 이상 보이지 않는다. - */ +/** 2단계에서 **사람이 확인한** 사업장 신원. */ export interface ConfirmedIdentity { - /** 백엔드에 만들어진 사업장. 로그인 없이 도는 데모 경로에서는 null 이다. */ + /** 백엔드에 만들어진 사업장. */ placeId: string | null; name: string; address: string; @@ -77,35 +48,21 @@ export interface ConfirmedIdentity { /** 화면에 붙일 출처 표기('카카오맵' · '네이버 지도' · '직접 입력'). */ sourceLabel: string; externalPlaceId?: string; - /** 후보가 들고 있던 공식 홈페이지. 확정 시 공식 채널로 등록된다. */ + /** 후보가 들고 있던 공식 홈페이지. */ placeUrl?: string; - /** 사장님이 붙여넣은 네이버 플레이스 주소. **아직 서버에 안 보냈다** — - * 수집 직전 로그인한 뒤에 이 값으로 사업장을 검증한다(usePlaceSearch.confirmByUrl). */ + /** 사장님이 붙여넣은 네이버 플레이스 주소. */ naverPlaceUrl?: string; } export interface ApplyPlaceOptions { - /** - * 위저드 안에서 열린 사업장인가(`?flow=onboarding`). - * - * ★ 화면을 옮기지는 않는다 — 그건 주소창(`?step=`)의 일이다. 이 값이 하는 일은 하나뿐: - * 위저드 도중에 새로고침하면 스토어의 확정 신원이 비어 있으므로, 서버에서 읽은 사업장으로 - * 그걸 다시 세운다. 없으면 3단계가 "가게를 아직 안 골랐다"고 판단해 수집을 열지 않는다. - */ + /** 위저드 안에서 열린 사업장인가(`?flow=onboarding`). */ isOnboarding?: boolean; } -/** - * 공개 검색에서 고른, **아직 업종이 안 정해진** 후보. - * - * ★ 업종 화면(`?step=industry`)을 다녀오는 동안만 산다. 사장님이 고른 업종을 들고 돌아와야 - * 그 업종으로 사업장을 만들 수 있어서다(사업장의 category 는 만들 때 정해진다). - * ★ 브라우저에 저장하지 않는다(위저드 상태 규칙). 새로고침하면 사라지고 검색부터 다시 한다 — - * 확정 전이라 서버에도 아직 아무것도 없다. - */ +/** 공개 검색에서 고른, **아직 업종이 안 정해진** 후보. */ export interface PendingPick { name: string; address: string; - /** 업종 화면에서 고른 값. 이게 채워져야 확정이 이어진다. */ + /** 업종 화면에서 고른 값. */ industry?: IndustryType; } diff --git a/solution/frontend/src/vite-env.d.ts b/solution/frontend/src/vite-env.d.ts index bbba3ba..06376a0 100644 --- a/solution/frontend/src/vite-env.d.ts +++ b/solution/frontend/src/vite-env.d.ts @@ -4,10 +4,10 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL?: string; readonly VITE_PUBLISH_HOST?: string; readonly VITE_SITE_PREVIEW_URL?: string; - /** ⚠️ 번들에 구워진다 — 내부 테스트 호스트에서만 채운다(lib/autoSession). */ + /** ️ 번들에 구워진다 — 내부 테스트 호스트에서만 채운다(lib/autoSession). */ readonly VITE_AUTO_LOGIN_ID?: string; readonly VITE_AUTO_LOGIN_PW?: string; - /** 구글 OAuth 클라이언트 ID. 비면 구글 로그인 버튼 자체가 안 뜬다(lib/googleIdentity). */ + /** 구글 OAuth 클라이언트 ID. */ readonly VITE_GOOGLE_CLIENT_ID?: string; } diff --git a/solution/frontend/tests/generation.mjs b/solution/frontend/tests/generation.mjs index 331a1ed..fc372cd 100644 --- a/solution/frontend/tests/generation.mjs +++ b/solution/frontend/tests/generation.mjs @@ -1,5 +1,4 @@ -// 실행: 개발 서버를 켠 뒤 node solution/frontend/tests/generation.mjs [URL] -// 모든 API는 가짜 응답으로 막는다. 외부 생성 API·실제 사업장에는 쓰지 않는다. +// 실행: 개발 서버를 켠 뒤 node solution/frontend/tests/generation.mjs [URL] 모든 API는 가짜 응답으로 막는다. import assert from 'node:assert/strict'; import {chromium} from 'playwright'; diff --git a/solution/frontend/vite.config.ts b/solution/frontend/vite.config.ts index f67755b..e7548a8 100644 --- a/solution/frontend/vite.config.ts +++ b/solution/frontend/vite.config.ts @@ -4,77 +4,42 @@ import path from 'path'; import {defineConfig} from 'vite'; export default defineConfig({ - // ★ @vitejs/plugin-react 를 따로 넣지 않는다 — reactRouter() 가 안에서 켠다. - // 둘 다 넣으면 리프레시 런타임이 두 번 주입돼 HMR 이 깨진다. + // @vitejs/plugin-react 를 따로 넣지 않는다 — reactRouter() 가 안에서 켠다. plugins: [reactRouter(), tailwindcss()], resolve: { alias: { '@': path.resolve(__dirname, 'src'), - // css 는 더 구체적인 별칭이 먼저 와야 한다 — 아래 '@o2o/shared' 접두어 규칙이 - // 먼저 걸리면 shared/src/tokens.css(없는 경로)로 떨어진다. - // 실제 파일은 styles/ 아래에 있고, package.json exports 도 그쪽을 가리킨다. + // css 는 더 구체적인 별칭이 먼저 와야 한다 — 아래 '@o2o/shared' 접두어 규칙이 먼저 걸리면 shared/src/tokens.css(없는 경로)로 떨어진다. '@o2o/shared/tokens.css': path.resolve(__dirname, '../shared/src/styles/tokens.css'), '@o2o/shared/base.css': path.resolve(__dirname, '../shared/src/styles/base.css'), - // 워크스페이스 심볼릭 링크를 타지 않고 소스를 직접 가리킨다 — - // shared 를 고치면 빌드 없이 HMR 이 바로 돈다. + // 워크스페이스 심볼릭 링크를 타지 않고 소스를 직접 가리킨다 — shared 를 고치면 빌드 없이 HMR 이 바로 돈다. '@o2o/shared': path.resolve(__dirname, '../shared/src'), - // ★ 발행본 렌더러를 **그대로** 쓴다(2026-09-09). 미리보기와 발행본이 컴포넌트를 두 벌 - // 두면 같은 데이터로도 다른 그림이 나온다 — 실측으로 캔버스는 소개 섹션을 설명 - // 문구로 채워 그렸는데 발행본은 데이터가 0자라 섹션째 뺐다. 사장님은 채워진 화면을 - // 보고 발행해 절반이 사라진 페이지를 받았다. - // 저쪽 별칭이 '@' 가 아니라 '@site' 인 것도 이 때문이다(solution/site/vite.config.ts). + // 발행본 렌더러를 **그대로** 쓴다. '@site': path.resolve(__dirname, '../site/src'), }, }, - // ★ 산출물이 `dist/` 가 아니라 `build/client/` 로 나간다(프레임워크 모드 기본값). - // nginx/Dockerfile 의 COPY 경로가 이 값과 맞아야 한다 — 어긋나면 빈 이미지가 구워지고 - // 컨테이너는 정상으로 뜬다. + // 산출물이 `dist/` 가 아니라 `build/client/` 로 나간다(프레임워크 모드 기본값). build: { - // ★ 발행본과 **같은 오리진**을 쓰므로 `/assets/` 를 서로 뺏는다. 빌더 번들만 다른 - // 디렉토리로 뺀다 — 안 그러면 nginx 의 `/assets/` 규칙이 발행본 것만 주고 - // 빌더 JS·CSS 가 404 다(화면은 뜨는데 스타일도 동작도 없다). + // 발행본과 **같은 오리진**을 쓰므로 `/assets/` 를 서로 뺏는다. assetsDir: 'builder-assets', }, server: { - // ★ Vite 6 는 모르는 Host 헤더를 403 "Blocked request" 로 막는다(DNS rebinding 방어). - // 운영은 앞단 nginx 가 Host 를 그대로 넘기므로 발행 호스트를 여기 넣어야 화면이 뜬다. - // 빠뜨리면 프록시는 정상인데 앱만 전부 403 이다. + // Vite 6 는 모르는 Host 헤더를 403 "Blocked request" 로 막는다(DNS rebinding 방어). allowedHosts: [process.env.VITE_PUBLISH_HOST ?? 'web4ai.o2osolution.ai'], - // 발행 사이트는 별도 정적 서버(:3001)가 만들고 서빙하지만, 사용자는 admin 과 같은 - // origin(:3000)의 `/s/<slug>` 로 접근한다. 프로세스 포트만 분리하고 공개 URL은 하나로 둔다. + // 발행 사이트는 별도 정적 서버(:3001)가 만들고 서빙하지만, 사용자는 admin 과 같은 origin(:3000)의 `/s/<slug>` 로 접근한다. proxy: { // `'/s'`로 두면 Vite의 `/src/app/main.tsx`까지 매칭되어 에디터가 흰 화면이 된다. - // 발행 사이트 루트(`/s`)와 그 하위 경로(`/s/...`)만 정확히 전달한다. '^/s(?:/|$)': { target: 'http://127.0.0.1:3001', changeOrigin: true, }, - /** - * ★ 발행본의 JS·CSS 번들. - * - * 프리렌더는 사이트마다 번들을 복사하지 않고 `out/assets/` 하나를 **모든 사이트가 공유**한다 - * (캐시 엔트리가 사이트 수만큼 늘지 않는다). 그래서 페이지 HTML 은 `/s/<slug>/assets/...` 가 - * 아니라 루트 절대경로 `/assets/...` 를 가리킨다. - * - * 이 규칙이 없으면 그 요청이 위의 `^/s` 에 걸리지 않아 **관리자 dev 서버**로 떨어지고, - * Vite 는 모르는 경로에 SPA fallback(index.html)을 200 으로 돌려준다 — - * 브라우저는 JS 자리에서 HTML 을 받아 파싱 에러를 내고, CSS 는 MIME 불일치로 무시한다. - * 화면에는 스타일도 상호작용도 없는 맨 HTML 만 남는다. 404 가 아니라 200 이라 - * 네트워크 탭만 봐서는 원인이 잘 안 보이는 자리다. - * - * ★ 운영에서는 nginx·CDN 이 `out/` 을 루트로 서빙하므로 `/assets/...` 가 그대로 맞다 — - * 이 프록시는 개발에서 두 프로세스를 한 origin 으로 보이게 하는 몫만 한다. - * ★ `/fonts` 는 넘기지 않는다. 관리자와 발행본이 **같은 경로를 각자** 쓰고 있어 - * 여기서 넘기면 관리자 자기 폰트가 발행본 쪽으로 샌다. 지금 발행본 HTML 은 - * `/fonts/...` 를 참조하지 않는다(웹폰트는 Google Fonts CDN 에서 받는다). - */ + /** 발행본의 JS·CSS 번들. */ '^/assets/': { target: 'http://127.0.0.1:3001', changeOrigin: true, }, }, - // 도커(Mac) 바인드마운트는 네이티브 inotify 가 컨테이너로 전파되지 않아 - // 파일 감시를 polling 으로 해야 호스트 변경이 감지된다. + // 도커(Mac) 바인드마운트는 네이티브 inotify 가 컨테이너로 전파되지 않아 파일 감시를 polling 으로 해야 호스트 변경이 감지된다. watch: {usePolling: true, interval: 300}, }, }); diff --git a/solution/shared/scripts/export-prompts.mjs b/solution/shared/scripts/export-prompts.mjs index f8bbb63..e572d3d 100644 --- a/solution/shared/scripts/export-prompts.mjs +++ b/solution/shared/scripts/export-prompts.mjs @@ -1,17 +1,4 @@ -/** - * 지역 이야기 프롬프트를 백엔드가 읽을 JSON 으로 뽑는다. - * - * npm run export:prompts (레포 루트에서) - * - * ★ 왜 산출물을 커밋하나 - * 백엔드 컨테이너에는 node 도 워크스페이스도 없다. 빌드 때 뽑게 하면 파이썬 이미지에 - * node 를 넣어야 하고, 그러면 두 런타임의 버전을 같이 맞춰야 한다. 산출물을 커밋해 두면 - * 백엔드는 파일 하나만 읽으면 된다 — `scripts/export_openapi.py` 가 반대 방향으로 하는 것과 같다. - * - * ★ 산출물(`services/prompts/section_prompts.json`)은 손으로 고치지 않는다. - * 고칠 자리는 `shared/src/lib/section-prompts.ts` 하나다. 어긋나면 사장님이 복사해 가는 - * 프롬프트와 서버가 도는 프롬프트가 갈린다. - */ +/** 지역 이야기 프롬프트를 백엔드가 읽을 JSON 으로 뽑는다. */ import {mkdirSync, writeFileSync} from 'node:fs'; import {dirname, resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; @@ -19,8 +6,7 @@ import {fileURLToPath} from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const OUT = resolve(here, '../../backend/services/prompts/section_prompts.json'); -// TS 를 그대로 읽을 수 없으므로 원문에서 값을 떼어 낸다 — 이 파일은 순수 상수 선언이라 -// 파서를 세울 이유가 없다. 모양이 바뀌면 아래 검사에서 즉시 터진다. +// TS 를 그대로 읽을 수 없으므로 원문에서 값을 떼어 낸다 — 이 파일은 순수 상수 선언이라 파서를 세울 이유가 없다. const src = await import('node:fs').then((fs) => fs.readFileSync(resolve(here, '../src/lib/section-prompts.ts'), 'utf8'), ); @@ -33,9 +19,7 @@ function block(name) { const rules = block('SECTION_PROMPT_RULES'); -// ★ 목록을 여기 손으로 적지 않는다. 예전엔 배열 하나를 더 두었는데, `STORY_KINDS` 에 -// 종류를 하나 늘려도 이 배열을 잊으면 **뽑히지 않는다** — 프론트는 아는데 서버만 모르는 -// 상태가 되고, 그 종류의 탭은 영원히 빈칸이다(실측 2026-09-10, `daily`). +// 목록을 여기 손으로 적지 않는다. const kinds = (() => { const m = src.match(/export const STORY_KINDS: StoryKind\[\] = \[([\s\S]*?)\];/); if (!m) throw new Error('STORY_KINDS 를 찾지 못했다 — section-prompts.ts 의 모양이 바뀌었다'); diff --git a/solution/shared/src/lib/color.ts b/solution/shared/src/lib/color.ts index bd4925c..d6804ae 100644 --- a/solution/shared/src/lib/color.ts +++ b/solution/shared/src/lib/color.ts @@ -1,17 +1,11 @@ -/** - * 색 계산 — 디자인 토큰에서 '면 단계'를 만들 때 쓴다. - * - * ★ 빌더 캔버스 · 쇼케이스 · **발행 사이트**가 같은 식을 써야 한다. - * 미리보기에서 예쁜 조합이 발행 화면에서 달라지면 미리보기가 거짓말이 된다. - * 그래서 프론트 lib 이 아니라 계약 패키지에 둔다. - */ +/** 색 계산 — 디자인 토큰에서 '면 단계'를 만들 때 쓴다. */ function rgb(hex: string): [number, number, number] { const n = parseInt(hex.replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } -/** 두 색을 섞는다. ratio=0 이면 a, 1 이면 b. */ +/** 두 색을 섞는다. */ export function mix(a: string, b: string, ratio: number): string { // hex 가 아닌 값(css 변수·색 이름)이 들어오면 계산이 무의미하다 — 원본을 그대로 돌려준다. if (!/^#[0-9a-fA-F]{6}$/.test(a) || !/^#[0-9a-fA-F]{6}$/.test(b)) return a; @@ -22,25 +16,13 @@ export function mix(a: string, b: string, ratio: number): string { return `#${hex(ch(ar, br))}${hex(ch(ag, bg))}${hex(ch(ab, bb))}`; } -/** - * 템플릿 색 6개 → 면 토큰 4개. - * - * 캔버스와 쇼케이스가 공유하는 유도 규칙이다. 섹션 기본(bg) · paper · tint 가 - * 서로 다른 밝기여야 경계선 없이도 섹션이 나뉜다. - */ +/** 템플릿 색 6개 → 면 토큰 4개. */ export function deriveSurfaces(colors: {bg: string; card: string; text: string; secondary: string}) { return { surface: mix(colors.bg, colors.text, 0.06), surfaceAlt: colors.card, inverse: '#1c1917', - /** - * 선은 **바탕에 글자색을 조금 섞어** 만든다. - * - * ★ 예전에는 `colors.secondary` 를 그대로 썼다. secondary 는 보조 **글자색**이라 - * 레트로에서 #4c4739(짙은 갈흑)이 선이 됐다 — 갱지 위에 검은 줄이 그어져 - * 인쇄물이 아니라 표가 됐다. 아티팩트의 선은 #c0b493 이다. - * 바탕에서 20% 만 당기면 어느 팔레트에서도 '한 톤 진한 같은 종이'가 된다. - */ + /** 선은 **바탕에 글자색을 조금 섞어** 만든다. */ border: mix(colors.bg, colors.text, 0.2), }; } diff --git a/solution/shared/src/lib/facts.ts b/solution/shared/src/lib/facts.ts index 26808d2..0e5d8eb 100644 --- a/solution/shared/src/lib/facts.ts +++ b/solution/shared/src/lib/facts.ts @@ -7,36 +7,25 @@ import { type UnitInfo, } from '../types'; -/** - * ★ 절대규칙 1 의 실행부. - * - * 발행 게이트는 백엔드에 있지만, 렌더러도 같은 필터를 한 번 더 건다. - * 이유: 게이트를 통과한 payload 라도 캐시된 옛 버전이거나 손으로 만든 fixture 일 수 있다. - * 미검증 값이 화면 아무 데도, JSON-LD 에도 안 나가는 것이 이 함수 하나에 걸려 있다. - */ +/** 절대규칙 1 의 실행부. */ export function selectPublishable(facts: FactEntry[]): FactEntry[] { return facts.filter((f) => isPublishableFact(f.status) && f.value != null && f.value !== ''); } -/** key → fact 로 조회. 노출 가능한 것만 담긴다. */ +/** key → fact 로 조회. */ export function factMap(facts: FactEntry[]): Map<string, FactEntry> { const map = new Map<string, FactEntry>(); for (const fact of selectPublishable(facts)) map.set(fact.key, fact); return map; } -/** 값 문자열. 노출 불가면 undefined — 호출부가 "빈 값"과 "가려진 값"을 구분하지 않아도 되게 한다. */ +/** 값 문자열. */ export function factValue(facts: FactEntry[], key: string): string | undefined { const fact = factMap(facts).get(key); return fact?.value ?? undefined; } -/** - * 값 + 단위("4대", "76㎡", "280,000원"). - * - * number 타입은 천 단위 구분을 넣는다 — "280000원"은 사람이 자릿수를 세야 하고, - * LLM 이 인용할 때도 그대로 읽혀서 답변 품질이 떨어진다. - */ +/** 값 + 단위("4대", "76㎡", "280,000원"). */ export function factText(facts: FactEntry[], key: string): string | undefined { const fact = factMap(facts).get(key); if (!fact?.value) return undefined; @@ -49,18 +38,8 @@ function formatNumeric(raw: string): string { return Number.isFinite(num) ? num.toLocaleString('ko-KR') : raw; } -/** bool 타입 fact. 미검증이면 undefined — false 로 떨어뜨리지 않는다(가려진 것과 "아니오"는 다르다). */ -/** - * 표에 쓸 fact 이름표. - * - * ★ "주차 가능 | 가능" 을 없앤다 (2026-09-04, 사장님 지적) - * 이름표가 이미 '가능' 으로 끝나는데 값도 '가능' 이라 같은 말이 두 번 섰다. - * 꼬리를 떼면 "주차 | 가능" 이 된다 — 표는 원래 그렇게 읽는다. - * ★ bool 일 때만 뗀다. '바비큐 이용료' 처럼 값이 숫자인 칸은 이름이 곧 뜻이다. - * ★ **shared 에 두는 이유**: 화면(derive)과 구조화 데이터(seo/jsonld)가 같은 이름표를 써야 한다. - * 갈리면 발행 게이트가 "구조화 데이터가 화면 값과 다르다"로 막는다 — 실제로 막혔다. - * 렌더러 쪽 한 곳에 두면 jsonld → derive → jsonld 순환 참조가 된다. - */ +/** bool 타입 fact. */ +/** 표에 쓸 fact 이름표. */ export function displayFactLabel(fact: FactEntry): string { if (fact.type !== 'bool') return fact.label; return fact.label.replace(/\s*(가능|여부)$/, '') || fact.label; @@ -84,20 +63,14 @@ export function missingRequiredFacts(facts: FactEntry[]): FactEntry[] { ); } -/** 노출 가능한 FAQ 만 — 화면에 그리는 목록. 문의 안내(TEMPLATE)도 들어간다. */ +/** 노출 가능한 FAQ 만 — 화면에 그리는 목록. */ export function selectPublishableFaqs(faqs: FaqEntry[]): FaqEntry[] { return faqs .filter((f) => isPublishableFact(f.status) && f.question && f.answer) .sort((a, b) => a.sortOrder - b.sortOrder); } -/** - * 실제로 답하는 FAQ 만 — FAQPage JSON-LD · llms.txt 의 입력. - * - * ★ 문의 안내(TEMPLATE)를 뺀다. FAQ 를 20개로 채우려고 붙인 공통 질문이라 답이 - * "숙소로 문의 부탁드립니다" 뿐이다. 구조화 데이터로 내보내면 AI 검색이 인용할 답이 없는 - * 문항이 섞이고, 같은 문구가 모든 펜션 사이트에 반복된다. - */ +/** 실제로 답하는 FAQ 만 — FAQPage JSON-LD · llms.txt 의 입력. */ export function selectAnsweredFaqs(faqs: FaqEntry[]): FaqEntry[] { return selectPublishableFaqs(faqs).filter((f) => f.sourceType !== SourceType.TEMPLATE); } @@ -109,30 +82,18 @@ export function sanitizeUnits(units: UnitInfo[]): UnitInfo[] { .sort((a, b) => a.sortOrder - b.sortOrder); } -/** - * 발행 산출물에 실을 수 있는 형태로 payload 를 깎는다. - * - * ★ 왜 필요한가 — 정적 HTML 은 하이드레이션을 위해 payload 를 통째로 심는다. - * 화면과 JSON-LD 를 아무리 잘 걸러도, 그 블롭에 미검증 fact 가 남아 있으면 - * 원본 HTML 을 읽는 AI 크롤러는 그걸 그대로 읽는다. 실제로 한 번 그렇게 새어서 - * 이 함수가 생겼다 — 필터를 화면에만 걸면 반쪽이다. - * - * 프리렌더는 이 결과를 서버 렌더와 임베드 **양쪽에** 쓴다. - * 같은 객체를 쓰므로 하이드레이션 불일치도 생기지 않는다. - */ +/** 발행 산출물에 실을 수 있는 형태로 payload 를 깎는다. */ export function sanitizePayloadForPublish(payload: SitePayload): SitePayload { return { ...payload, facts: selectPublishable(payload.facts), units: sanitizeUnits(payload.units), faqs: selectPublishableFaqs(payload.faqs), - // 확정 전 채널 URL 은 동명 업소일 수 있다. 발행물에 남기지 않는다. + // 확정 전 채널 URL 은 동명 업소일 수 있다. links: payload.links.filter((link) => link.confirmed), // 대체 텍스트 없는 이미지는 어차피 렌더하지 않는다 — 목록에서도 뺀다. media: payload.media.filter((item) => item.alt?.trim()), - // 재생 주소가 없는 곡은 버튼만 있고 소리가 없다. 목록에서 뺀다. - // 생성 결과를 고유 콘텐츠로 세면 우리 출력으로 발행 게이트를 우회하게 된다. - // 명시적 허용 필드만 복사해 승인 토큰이 하이드레이션 블롭에 끼어들지 못하게 한다. + // 재생 주소가 없는 곡은 버튼만 있고 소리가 없다. socialPosts: (payload.socialPosts ?? []).filter((post) => post.body?.trim() && Number.isFinite(Date.parse(post.postedAt))).slice(0, 3).map((post) => ({ postId: post.postId, provider: post.provider, body: post.body, postedAt: post.postedAt, permalink: safeSocialPermalink(post.permalink), diff --git a/solution/shared/src/lib/section-data.ts b/solution/shared/src/lib/section-data.ts index 7a480d6..51a2cd9 100644 --- a/solution/shared/src/lib/section-data.ts +++ b/solution/shared/src/lib/section-data.ts @@ -1,13 +1,6 @@ -/** - * 붙여넣기 아이템의 **읽는 쪽 계약** — "이 섹션 타입의 JSON 은 어떤 모양인가". - * - * ★ 왜 shared 인가 - * 같은 JSON 을 두 렌더러가 읽는다 — 빌더 캔버스(solution/frontend)와 발행 사이트(solution/site). - * 파서를 각자 두면 "빌더에서는 보이는데 발행하면 없다"가 조용히 생긴다(슬러그 규칙과 같은 함정). - * 프롬프트·예시·라벨처럼 **쓰는 쪽**만 필요한 것은 빌더에 남는다(`canvas/dataSpec.ts`). - */ +/** 붙여넣기 아이템의 **읽는 쪽 계약** — "이 섹션 타입의 JSON 은 어떤 모양인가". */ -/** 사장님이 스스로 매긴 확신. 미검증 값이 화면·JSON-LD 로 새지 않게 하는 첫 관문이다. */ +/** 사장님이 스스로 매긴 확신. */ export type DataVerified = '확인' | '확인필요'; export interface DataSource { @@ -58,7 +51,7 @@ export interface CourseItem { } export interface ScheduleSlot { - /** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */ + /** 24시간 표기 "09:30". */ time: string; title: string; place?: string; @@ -69,7 +62,7 @@ export interface ScheduleSlot { export interface ScheduleItem { name: string; - /** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */ + /** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). */ audience?: string; season?: string; slots?: ScheduleSlot[]; @@ -79,28 +72,16 @@ export interface ScheduleItem { export interface PeopleItem { name: string; - /** 호·예명. 본명 옆에 나란히 불리는 이름이 있으면 프레임 아래 각인으로 붙는다. */ + /** 호·예명. */ aka?: string; years?: string; role?: string; oneLine?: string; - /** - * 사진 검색어. 사진을 못 구했을 때 어디서 찾을지만 남긴다 — - * 모델이 지어낸 주소를 그대로 링크하면 깨진 사진이 얼굴 자리에 남는다(LocalPlace.searchQuery 와 같은 규약). - */ + /** 사진 검색어. */ imageQuery?: string; - /** - * 사진. 있으면 필름 프레임에 들어가고, 없으면 지금처럼 이니셜 활자가 그 자리를 지킨다 — - * 빈 회색 상자를 만들지 않는다(LocalPlace.imageUrl 과 같은 규칙). - * ★ 우리가 찾아 붙이는 사진이 아니다. 공공데이터(TourAPI)가 그 대상의 사진으로 준 것 중 - * **상업적 이용을 허용하는 저작권 유형만** 수집 단계에서 담는다 - * (`services/external/tour_places.py` 의 `_IMAGE_OK`). - */ + /** 사진. */ imageUrl?: string; - /** - * 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면 - * 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`). - */ + /** 사진의 출처 표시. */ imageCredit?: string; verified?: DataVerified; source?: DataSource; @@ -112,49 +93,34 @@ export interface ChronicleItem { title: string; summary?: string; place?: string; - /** 그 해의 사진. 없으면 연표는 지금처럼 활자만으로 선다 — 자리를 비워 두지 않는다(PeopleItem.imageUrl 과 같은 출처·규칙). */ + /** 그 해의 사진. */ imageUrl?: string; - /** - * 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면 - * 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`). - */ + /** 사진의 출처 표시. */ imageCredit?: string; - /** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */ + /** 도시의 성격을 바꾼 해. */ turning?: boolean; verified?: DataVerified; source?: DataSource; } -/** - * 지역 읽기 — 도시를 갈래로 묶어 한 꼭지씩 넘겨 보는 글. - * - * ★ 왜 일력(daily)과 따로 있나 (2026-09-14 대표 의견: "오늘의 한 장으로 군산을 소개하기는 - * 무리다, 하루에 하나씩밖에 안 알려 주니까") - * 일력은 오늘·어제·내일 **세 장만** 화면에 선다. 도시를 소개하는 그릇으로는 작다. - * 이쪽은 갈래(문학·섬과 바다·역사·장소·음식과 생활)로 묶은 더미에서 매번 몇 개만 뽑아 낸다. - * ★ 이미 제 자리를 가진 것은 여기 넣지 않는다 — 인물·가요·축제·연표·명소·맛집. - * 같은 이야기를 두 번 세우면 페이지만 길어진다(프롬프트 규칙으로도 막는다). - */ +/** 지역 읽기 — 도시를 갈래로 묶어 한 꼭지씩 넘겨 보는 글. */ export interface ReadingItem { - /** 갈래 이름. 카드 위 이름표로만 쓴다 — 갈래별로 묶어 세우지 않는다(뽑기는 갈래를 안 가린다). */ + /** 갈래 이름. */ group?: string; title: string; - /** 서너 문장. 두 문장짜리는 카드가 한 줄로 접혀 빈 카드처럼 보인다(2026-09-14 대표 지적). */ + /** 서너 문장. */ body: string; - /** 연도를 댈 수 있는 꼭지만. 없으면 이름표에 연도를 안 붙인다. */ + /** 연도를 댈 수 있는 꼭지만. */ year?: number; verified?: DataVerified; - /** - * 출처. **모델이 적지 않는다** — 제목으로 찾아가는 검색 링크를 서버가 붙인다 - * (`services/grounding/story.py`). 개별 문서 주소를 짐작해 적으면 없는 문서로 이어진다. - */ + /** 출처. */ source?: DataSource; } export interface LiteratureItem { workTitle: string; author?: string; - /** "1937" · "1937–1938 연재" 처럼 원문 그대로. 숫자로 좁히면 연재물이 안 들어간다. */ + /** "1937" · "1937–1938 연재" 처럼 원문 그대로. */ year?: string; genre?: string; spineColor?: string; @@ -168,14 +134,11 @@ export interface PostcardItem { line: string; hashtags?: string[]; place?: string; - /** 소인에 찍을 짧은 지명. 없으면 place 가 그 자리에 들어간다. */ + /** 소인에 찍을 짧은 지명. */ postmark?: string; - /** 엽서 앞면 사진. 없으면 뒷면(문장·우표·소인)만 있는 지금 모양 그대로다(PeopleItem.imageUrl 과 같은 출처·규칙). */ + /** 엽서 앞면 사진. */ imageUrl?: string; - /** - * 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면 - * 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`). - */ + /** 사진의 출처 표시. */ imageCredit?: string; verified?: DataVerified; source?: DataSource; @@ -183,7 +146,7 @@ export interface PostcardItem { export interface QuizItem { question: string; - /** ★ answer 는 없다. 이 데이터는 검증되지 않은 줄이 더 많아서, 정답을 단정하면 틀린 걸 단정한다. */ + /** answer 는 없다. */ hint?: string; topic?: string; level?: string; @@ -191,35 +154,23 @@ export interface QuizItem { source?: DataSource; } -/** - * 추천 일정 — 반나절 · 1박2일 · 2박3일. - * - * ★ 왜 하나로 합쳤나 - * 한때 코스가 셋이었다 — 반나절 산책(course) · 여행 스케줄(schedule) · 계절별 추천 하루(planner). - * 축이 다르다고 나눠 뒀는데 실제 데이터를 보니 **같은 장소를 세 번 나열**하고 있었다 - * (째보선창이 셋 중 둘에, 초원사진관도 마찬가지). 손님에게는 "군산에서 어디를 도나" - * 하나의 질문이고, 다른 건 **며칠짜리인가** 뿐이다. 그래서 축을 기간으로 바꿨다. - * ★ 순위를 매기지 않는다. 어느 코스가 1위인지는 우리가 정할 일이 아니다. - */ +/** 추천 일정 — 반나절 · 1박2일 · 2박3일. */ export interface ItineraryDay { - /** "첫째 날". 없으면 순번으로 만든다. */ + /** "첫째 날". */ label?: string; - /** 그날 나서는 시각 "HH:MM". 여기서부터 이동·머무는 시간을 더해 칸마다 시각을 박는다. */ + /** 그날 나서는 시각 "HH:MM". */ startTime?: string; stops?: PlannerStop[]; } export interface ItineraryItem { name: string; - /** "반나절" · "1박 2일" · "2박 3일". 이 값이 목록을 가르는 축이다. */ + /** "반나절" · "1박 2일" · "2박 3일". */ duration?: string; audience?: string; - /** 왜 이 일정인가. 고르는 근거 한 문장. */ + /** 왜 이 일정인가. */ why?: string; - /** - * 계절. 적으면 그 계절에만 손님 화면에 나간다(`inSeason`). - * 겨울에 벚꽃 코스를 권하지 않기 위한 것이지, 목록을 계절로 가르기 위한 게 아니다. - */ + /** 계절. */ season?: string; /** 하루 이상이면 이 배열을 쓴다. */ days?: ItineraryDay[]; @@ -231,33 +182,25 @@ export interface ItineraryItem { } export interface VideoItem { - /** 유튜브 주소. watch · youtu.be · shorts · embed 넷 다 받는다. */ + /** 유튜브 주소. */ url: string; caption?: string; verified?: DataVerified; source?: DataSource; } -/** - * 이벤트·소식 — 사장님이 인스타에 올리는 그때그때의 행사. - * - * ★ 인스타를 읽어 오지 않는다. 계정 페이지는 로그인·봇 탐지 뒤에 있고, 우회는 영구 금지다 - * (DECISIONS 1-1). 사장님이 **본인 게시물을 옮겨 적고 원문 주소를 단다** — - * 손님은 우리 요약을 읽고, 정확한 것은 원문에서 본다. - * ★ 날짜는 'YYYY-MM-DD'. 끝 날짜가 없으면 상시로 본다. - */ +/** 이벤트·소식 — 사장님이 인스타에 올리는 그때그때의 행사. */ export interface EventItem { title: string; - /** '이벤트' 면 진행 여부 배지가 붙고, '공지' 면 그냥 공지다. 없으면 이벤트로 본다. */ + /** '이벤트' 면 진행 여부 배지가 붙고, '공지' 면 그냥 공지다. */ kind?: '이벤트' | '공지'; startDate?: string; endDate?: string; - /** 무엇을 주는 행사인가 — 한두 문장. 카드에 보이는 요약이다. */ + /** 무엇을 주는 행사인가 — 한두 문장. */ summary?: string; - /** 긴 본문. 공지는 지켜야 할 것이 여러 줄이라 요약 한두 문장으로는 안 된다. - * 카드에서는 잘려 보이고 모달에서 전부 펴진다 — 잘리는 건 화면뿐, 문서에는 다 있다. */ + /** 긴 본문. */ body?: string; - /** 어떻게 참여하나. "예약 시 요청사항에 '9월 이벤트' 라고 적어 주세요" 같은 것. */ + /** 어떻게 참여하나. */ howTo?: string; /** 원문(인스타 게시물) 주소. */ postUrl?: string; @@ -268,45 +211,36 @@ export interface EventItem { export interface PlannerStop { name: string; - /** 여기서 머무는 시간(분). 없으면 60분으로 본다 — 못 재면 시각을 계산할 수 없다. */ + /** 여기서 머무는 시간(분). */ minutes?: number; - /** 앞 칸에서 여기까지 오는 시간(분). 첫 칸은 업소에서 나서는 시간이다. */ + /** 앞 칸에서 여기까지 오는 시간(분). */ moveMinutes?: number; note?: string; - /** 정거장 사진. 없으면 승차권은 지금처럼 활자만으로 선다(PeopleItem.imageUrl 과 같은 출처·규칙). */ + /** 정거장 사진. */ imageUrl?: string; searchQuery?: string; - /** - * 지도에 핀을 찍을 좌표. **둘 다 있어야** 쓴다 — 하나만 있으면 없는 것으로 본다. - * 사장님이 손으로 적을 값이 아니다(발행 때 지오코딩으로 채울 자리). 없으면 그 칸은 - * 핀 없이 시간표에만 선다 — 틀린 핀을 찍느니 안 찍는다(LocationSection 과 같은 규칙). - */ + /** 지도에 핀을 찍을 좌표. */ latitude?: number; longitude?: number; } export interface PlannerItem { name: string; - /** '봄' · '여름' · '가을' · '겨울'. 화면의 계절 탭이 이 값에서 파생된다. */ + /** '봄' · '여름' · '가을' · '겨울'. */ season?: string; - /** 그 계절 안에서의 순위. 1·2·3 만 쓴다 — 4위부터는 아무도 안 고른다. */ + /** 그 계절 안에서의 순위. */ rank?: number; - /** 하루가 시작하는 시각 "HH:MM". 여기서부터 이동·머무는 시간을 더해 칸마다 시각을 박는다. */ + /** 하루가 시작하는 시각 "HH:MM". */ startTime?: string; audience?: string; - /** 왜 이 계절에 이 코스인가. 순위를 납득시키는 한 문장. */ + /** 왜 이 계절에 이 코스인가. */ why?: string; stops?: PlannerStop[]; verified?: DataVerified; source?: DataSource; } -/** - * 섹션 타입 → 없으면 그 줄을 통째로 버리는 키. - * - * ★ 이 표가 곧 "붙여넣기 아이템이 무엇무엇인가"의 목록이다. 여기 없는 타입의 data 는 파싱하지 않는다. - * 빈 껍데기(제목 없는 줄)가 화면에 줄만 남기는 걸 막는 자리이기도 하다. - */ +/** 섹션 타입 → 없으면 그 줄을 통째로 버리는 키. */ export const SECTION_ITEM_REQUIRED_KEY: Record<string, string> = { songs: 'title', daily: 'title', @@ -324,14 +258,7 @@ export const SECTION_ITEM_REQUIRED_KEY: Record<string, string> = { event: 'title', }; -/** - * 유튜브 주소 → 영상 id. - * - * ★ **유튜브가 아니면 undefined 다.** 아무 주소나 iframe 에 넣으면 남의 페이지를 우리 도메인 - * 안에서 여는 통로가 된다. 알아보는 형식만 재생하고, 나머지는 링크로만 남긴다. - * ★ 네 가지 형식을 받는다 — 사장님이 복사해 오는 자리가 그때그때 다르다: - * `watch?v=ID` · `youtu.be/ID` · `shorts/ID`(모바일 공유) · `embed/ID`. - */ +/** 유튜브 주소 → 영상 id. */ export function youtubeId(url: string | undefined): string | undefined { const text = (url ?? '').trim(); if (!text) return undefined; @@ -342,27 +269,17 @@ export function youtubeId(url: string | undefined): string | undefined { return match?.[1]; } -/** - * 세로 영상인가. - * - * ★ 쇼츠는 9:16 이다. 16:9 틀에 넣으면 좌우가 까맣게 비고 영상이 손톱만 해진다 — - * 주소가 이미 말해 주는 것을 무시하지 않는다. - */ +/** 세로 영상인가. */ export function isVerticalVideo(url: string | undefined): boolean { return /youtube\.com\/shorts\//.test((url ?? '').trim()); } -/** 재생 전에 보여줄 표지. 유튜브가 영상마다 만들어 두는 것이라 우리가 만들지 않는다. */ +/** 재생 전에 보여줄 표지. */ export function youtubePoster(id: string): string { return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; } -/** - * 재생용 주소. - * - * ★ `youtube-nocookie.com` 을 쓴다. 손님이 재생을 누르기 전에는 아무것도 안 붙고, - * 눌러도 광고 추적 쿠키를 먼저 심지 않는다. - */ +/** 재생용 주소. */ export function youtubeEmbed(id: string): string { return `https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0&modestbranding=1`; } @@ -371,14 +288,13 @@ export interface ParsedSectionData<T> { items: T[]; title?: string; subtitle?: string; - /** 섹션 머리에 거는 바깥 링크. 그 섹션을 만든 서비스로 보내는 자리다(예: 영상 → ADO2). */ + /** 섹션 머리에 거는 바깥 링크. */ linkUrl?: string; linkLabel?: string; - /** 사람에게 보여줄 실패 사유. 있으면 items 는 비어 있다. */ + /** 사람에게 보여줄 실패 사유. */ error?: string; - /** 붙여넣은 JSON 의 kind 가 이 섹션과 다르다 — 다른 아이템 것을 넣었다는 뜻. */ kindMismatch?: string; - /** verified 가 '확인' 이 아닌 항목 수. 화면에 각주로 뜬다. */ + /** verified 가 '확인' 이 아닌 항목 수. */ unverified: number; /** source 가 붙은 항목 수. */ sourced: number; @@ -386,13 +302,7 @@ export interface ParsedSectionData<T> { const EMPTY: ParsedSectionData<never> = {items: [], unverified: 0, sourced: 0}; -/** - * JSON.parse 실패를 "몇 번째 줄"로 바꾼다. - * - * ★ V8 은 두 가지 모양으로 던진다 — `position N (line L column C)` 형과, - * 위치 없이 깨진 조각만 인용하는 `Unexpected token 'X', ..."조각" is not valid JSON` 형이다. - * 앞의 것만 보면 후자에서 위치를 통째로 잃는다(실제로 그랬다). 뒤의 것은 조각을 원문에서 되찾아 센다. - */ +/** JSON.parse 실패를 "몇 번째 줄"로 바꾼다. */ function locate(raw: string, message: string): string { const where = (pos: number) => { const before = raw.slice(0, Math.max(0, pos)); @@ -422,12 +332,7 @@ function locate(raw: string, message: string): string { return 'JSON 이 아닙니다. ChatGPT 가 준 답에서 { 로 시작해 } 로 끝나는 부분만 붙여넣어 주세요.'; } -/** - * 붙여넣은 문자열 → 렌더 가능한 항목. - * - * ★ 절대 throw 하지 않는다. 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다. - * 발행 사이트에서도 같다 — 프리렌더가 예외로 죽으면 사이트 전체가 안 구워진다. - */ +/** 붙여넣은 문자열 → 렌더 가능한 항목. */ export function parseSectionData<T extends object>( sectionType: string, raw: string | undefined, @@ -487,26 +392,19 @@ export function parseSectionData<T extends object>( } -/* ───────────────────────────────────────────────────────────── - * 계절별 추천 하루 — 시각을 **계산해서** 짜 준다. - * - * ★ 왜 shared 인가: 파서와 같은 이유다. 빌더 캔버스와 발행 사이트가 같은 코스에서 **같은 시각**을 - * 내놓아야 한다. 조립 규칙이 두 벌이면 사장님이 본 일정과 손님이 보는 일정이 조용히 갈린다. - * ★ 여행 스케줄(schedule)과 축이 다르다 — 저쪽은 사장님이 시각을 적고, 여기는 시각을 계산한다. - * 그래서 사장님은 "몇 분 걸리나"만 알면 되고, 출발 시각을 바꾸면 하루가 통째로 밀린다. - * ───────────────────────────────────────────────────────────── */ +/* ───────────────────────────────────────────────────────────── 계절별 추천 하루 — 시각을 **계산해서** 짜 준다. */ /** 밤 9시를 넘기는 칸은 넣지 않는다 — 짜 준 일정이 손님을 밤까지 끌고 다니면 그 순간 신뢰를 잃는다. */ const PLAN_ENDS_BY = 21 * 60; -/** 머무는 시간을 안 적었을 때. 0 으로 두면 한 시각에 칸이 겹쳐 쌓인다. */ +/** 머무는 시간을 안 적었을 때. */ const PLAN_DEFAULT_STAY = 60; -/** 출발 시각을 안 적었을 때. 체크아웃 뒤 움직이는 시각을 기본으로 잡는다. */ +/** 출발 시각을 안 적었을 때. */ const PLAN_DEFAULT_START = '10:00'; -/** 계절 탭 순서. 데이터에 있는 계절만 이 순서로 세운다 — 붙여넣은 순서대로 두면 겨울이 맨 앞에 온다. */ +/** 계절 탭 순서. */ const SEASON_ORDER = ['봄', '여름', '가을', '겨울']; -/** "HH:MM" → 자정부터의 분. 형식이 아니면 undefined — 지어내지 않는다. */ +/** "HH:MM" → 자정부터의 분. */ export function minutesOfTime(time: string): number | undefined { const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); if (!match) return undefined; @@ -527,28 +425,22 @@ export interface PlannedStop { time: string; /** 떠나는 시각 "HH:MM". */ until: string; - /** 앞 칸에서 오는 데 걸린 분. 0 이면 화면이 이동 줄을 그리지 않는다. */ + /** 앞 칸에서 오는 데 걸린 분. */ move: number; } export interface PlannedDay { stops: PlannedStop[]; - /** 하루가 시작·끝나는 시각. 카드 머리에 "09:30–16:40" 으로 뜬다. */ + /** 하루가 시작·끝나는 시각. */ from: string; to: string; /** 총 소요(분) — 이동 시간까지 포함한다. */ totalMinutes: number; - /** 21시 상한에 걸려 못 넣은 칸 수. 숨기지 않고 화면에 밝힌다. */ + /** 21시 상한에 걸려 못 넣은 칸 수. */ dropped: number; } -/** - * 코스 하나 → 시각이 박힌 하루. - * - * 정거장 순서는 사장님이 적은 그대로다(적은 순서가 곧 도는 순서다). 출발 시각부터 - * 이동·머무는 시간을 누적해 칸마다 도착·출발 시각을 박고, 상한을 넘기는 칸은 버린다 — - * 넘겨서라도 다 넣으면 자정에 끝나는 일정이 나온다. - */ +/** 코스 하나 → 시각이 박힌 하루. */ export function planDay(item: PlannerItem): PlannedDay { const start = minutesOfTime(item.startTime ?? '') ?? minutesOfTime(PLAN_DEFAULT_START) ?? 600; const stops: PlannedStop[] = []; @@ -576,19 +468,10 @@ export function planDay(item: PlannerItem): PlannedDay { }; } -/** - * 지금 계절. **간절기에는 두 개**를 돌려준다. - * - * ★ 왜 둘인가 — 9월 초에 온 손님에게 여름 코스만 보이면 이미 지난 계절이고, 가을 코스만 - * 보이면 아직 이른 코스다. 경계에서는 둘 다 보여야 손님이 고를 수 있다. - * ★ 경계는 계절 첫 달의 전반(1~15일)로 잡는다. 실측이 아니라 규약이라 이 한 곳에만 둔다 — - * 빌더와 발행본이 같은 날 다른 계절을 고르면 사장님이 본 것과 손님이 보는 것이 갈린다. - * ★ 절대 빌드 시각으로 계산하지 않는다. 발행본은 정적이라 한 번 구우면 몇 달을 사는데, - * 구운 날의 계절을 박으면 12월에도 가을 코스가 걸린다(일력이 '오늘'을 다루는 방식과 같다). - */ +/** 지금 계절. */ export function currentSeasons(now: Date = new Date()): string[] { const month = now.getMonth() + 1; - // 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울. 3월을 0 으로 당겨 3으로 끊는다. + // 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울. const index = Math.floor(((month - 3 + 12) % 12) / 3); const season = SEASON_ORDER[index]; const isFirstMonth = month % 3 === 0; @@ -598,18 +481,8 @@ export function currentSeasons(now: Date = new Date()): string[] { return [season]; } -/** 데이터에 실제로 있는 계절만, 봄·여름·가을·겨울 순으로. 그 밖의 값(장마·연중)은 뒤에 붙인다. */ -/** - * 이 항목이 지금 나갈 계절인가. - * - * ★ 계절을 가진 아이템은 셋이다 — 계절별 추천 하루(planner) · 여행 스케줄(schedule) · - * 일력(daily). 한동안 planner 에만 걸려 있었다. "계절별 콘텐츠는 계절 따라 나간다"는 - * 규칙은 아이템 하나가 아니라 계절을 적은 모든 아이템의 규칙이다. - * ★ 계절을 안 적은 항목은 **계절을 타지 않는 것**으로 본다 — 가려서는 안 된다. - * ★ schedule 의 season 은 자유 문구다("장마", "여름 장마"). 네 계절 이름이 섞여 있으면 - * 그 계절로 보고, 우리가 못 읽는 값("장마")은 가리지 않는다 — 분류 못 하는 것을 - * 숨기면 사장님이 쓴 콘텐츠가 아무 계절에도 안 나가는 일이 생긴다. - */ +/** 데이터에 실제로 있는 계절만, 봄·여름·가을·겨울 순으로. */ +/** 이 항목이 지금 나갈 계절인가. */ export function inSeason(season: string | undefined, live: string[]): boolean { const text = season?.trim(); if (!text) return true; @@ -618,13 +491,7 @@ export function inSeason(season: string | undefined, live: string[]): boolean { return named.some((name) => live.includes(name)); } -/** - * 일정 한 건 → 일자별로 시각이 박힌 결과. - * - * ★ `days` 가 없으면 하루짜리로 본다 — 반나절 코스가 그렇다. - * ★ 시각 계산은 `planDay` 한 벌이다. 하루짜리와 여러 날짜리가 다른 식을 쓰면 - * 같은 코스가 자리에 따라 다른 시각을 낸다. - */ +/** 일정 한 건 → 일자별로 시각이 박힌 결과. */ export function itineraryDays(item: ItineraryItem): {label: string; day: PlannedDay}[] { const raw = item.days && item.days.length > 0 @@ -638,12 +505,7 @@ export function itineraryDays(item: ItineraryItem): {label: string; day: Planned .filter((entry) => entry.day.stops.length > 0); } -/** - * 기간별 묶음. 목록의 축이다. - * - * ★ 적힌 순서를 지킨다 — 사장님이 쓴 순서가 곧 권하는 순서다. - * 기간을 안 적은 일정은 맨 뒤 한 덩이로 모은다(빈 소제목을 만들지 않는다). - */ +/** 기간별 묶음. */ export function itineraryDurations(items: ItineraryItem[]): string[] { const seen: string[] = []; for (const item of items) { @@ -666,12 +528,7 @@ export function plannerSeasons(items: PlannerItem[]): string[] { }); } -/** - * 그 계절의 추천 코스 — 순위대로 최대 세 개. - * - * ★ 셋에서 끊는다. 넷째부터는 아무도 안 고르고, 화면에서는 "추천"이 아니라 목록이 된다. - * ★ rank 를 안 적었으면 붙여넣은 순서가 순위다 — 순위 없는 코스를 1위로 올리지 않는다. - */ +/** 그 계절의 추천 코스 — 순위대로 최대 세 개. */ export function plannerTop(items: PlannerItem[], season?: string): PlannerItem[] { const picked = season ? items.filter((item) => item.season?.trim() === season) : [...items]; picked.sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER)); diff --git a/solution/shared/src/lib/section-prompts.ts b/solution/shared/src/lib/section-prompts.ts index 930367e..d7fd92d 100644 --- a/solution/shared/src/lib/section-prompts.ts +++ b/solution/shared/src/lib/section-prompts.ts @@ -1,20 +1,6 @@ -/** - * 지역 이야기 아이템의 **프롬프트** — "이 JSON 을 무엇으로 받아 오나". - * - * ★ 왜 shared 인가 - * 쓰는 쪽이 둘이 됐다. 사장님이 [콘텐츠] 탭에서 복사해 ChatGPT 에 붙여넣는 프롬프트와, - * 서버가 지역 단위로 한 번 돌려 채우는 생성 잡(`services/story_service.py`)이 **같은 문장**을 - * 써야 한다. 두 벌로 두면 "빌더에서 뽑은 것과 서버가 채운 것의 모양이 다르다"가 조용히 생긴다 - * — 읽는 쪽 계약을 shared 에 둔 것(`section-data.ts`)과 같은 이유다. - * - * ★ 백엔드는 이 파일을 직접 못 읽는다(파이썬이다). `npm run export:prompts` 가 - * `solution/backend/services/prompts/section_prompts.json` 으로 뽑고, 백엔드는 그 산출물을 읽는다. - * **손으로 고치지 않는다** — 고칠 자리는 여기 하나다. - * - * 폼 칸(`fields`)·라벨처럼 빌더 UI 만 쓰는 것은 여기 없다(`canvas/dataSpec.ts`). - */ +/** 지역 이야기 아이템의 **프롬프트** — "이 JSON 을 무엇으로 받아 오나". */ -/** 아이템 종류. `area_contents.kind` · JSON 봉투의 `kind` 와 같은 값이다. */ +/** 아이템 종류. */ export type StoryKind = | 'songs' | 'daily' @@ -24,14 +10,7 @@ export type StoryKind = | 'postcard' | 'quiz'; -/** - * 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`). - * - * ★ `daily`(오늘의 한 장)가 한동안 여기 없었다 — 프롬프트가 빌더(`canvas/dataSpec.ts`)에만 - * 손으로 적혀 있어서 서버 생성 잡이 그 종류를 아예 몰랐다. 탭 자리는 렌더러에 있는데 - * 채우는 쪽이 없으니, '옛 항구'가 설명에서 약속한 일력은 손으로 넣은 `/s/stay` 시안에만 - * 있고 새 업장에서는 영영 빈칸이었다(실측 2026-09-10). - */ +/** 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`). */ export const STORY_KINDS: StoryKind[] = [ 'songs', 'daily', @@ -44,22 +23,17 @@ export const STORY_KINDS: StoryKind[] = [ export interface SectionPromptSpec { kind: StoryKind; - /** 화면에 쓰는 이름. 생성 잡의 로그·어드민 목록도 이 이름을 쓴다. */ + /** 화면에 쓰는 이름. */ label: string; - /** 무엇을 시키나. 머리(업소·지역)와 공통 규칙은 `buildSectionPrompt` 가 붙인다. */ + /** 무엇을 시키나. */ task: string; /** 그 아이템에만 걸리는 금지·형식 규칙. */ rules: string; - /** 한 번에 받아 올 항목 수의 상한. 프롬프트의 숫자와 같아야 한다 — 어긋나면 잘라 버리게 된다. */ + /** 한 번에 받아 올 항목 수의 상한. */ maxItems: number; } -/** - * 모든 아이템에 걸리는 규칙. - * - * ★ 2번(빈 값을 지어내지 않는다)과 3번(열리는 출처)이 이 레포의 절대규칙을 프롬프트로 옮긴 것이다. - * 모델이 이걸 어기면 검증기가 뒤에서 걸러야 하는데, 걸러진 항목은 결국 화면에서 빈자리가 된다. - */ +/** 모든 아이템에 걸리는 규칙. */ export const SECTION_PROMPT_RULES = ` [공통 규칙] 1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다. @@ -254,25 +228,17 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = { }; export interface SectionPromptContext { - /** 업소 이름. 빈 값이면 [업소] 줄을 아예 빼고 지역만으로 묻는다. */ + /** 업소 이름. */ storeName?: string; - /** 업종 라벨('숙박' · '카페'). 업소 이름이 있을 때만 쓴다. */ + /** 업종 라벨('숙박' · '카페'). */ industryLabel?: string; - /** 지명("전북 군산시"). 이 값이 없으면 프롬프트를 만들 수 없다. */ + /** 지명("전북 군산시"). */ region: string; - /** 도로명 주소. 지명과 다를 때만 한 줄 더 붙는다. */ + /** 도로명 주소. */ address?: string; } -/** - * 붙여넣으면 바로 답이 나오는 프롬프트. - * - * ★ `[지역]` 같은 빈칸을 남기지 않는다. 빈칸이 있으면 사장님이 못 채우고 그대로 보내고, - * 모델은 빈칸을 지명으로 착각해 엉뚱한 곳 이야기를 지어낸다. - * ★ 서버가 지역 단위로 돌 때는 업소가 없다(같은 지역 사이트가 나눠 쓰는 값이다). - * 그때는 [업소] 줄을 빼고 `connection` 같은 업소 연결 필드는 자연히 비게 둔다 — - * 업소 이름을 하나 골라 넣으면 그 집 이야기가 옆집 사이트에 실린다. - */ +/** 붙여넣으면 바로 답이 나오는 프롬프트. */ export function buildSectionPrompt(spec: SectionPromptSpec, ctx: SectionPromptContext): string { const region = ctx.region.trim(); const store = (ctx.storeName ?? '').trim(); diff --git a/solution/shared/src/lib/slug.ts b/solution/shared/src/lib/slug.ts index 3592006..4b964df 100644 --- a/solution/shared/src/lib/slug.ts +++ b/solution/shared/src/lib/slug.ts @@ -1,10 +1,4 @@ -/** - * 한글 상호를 URL 슬러그로 옮긴다. - * - * 한글을 로마자로 음차하지 않는다 — "달빛"을 "dalbit"으로 바꾸면 검색어와 안 맞고, - * 음차 규칙이 사람마다 달라 같은 가게가 두 주소를 갖게 된다. 한글을 그대로 두고 - * 퍼센트 인코딩에 맡긴다(브라우저·검색엔진 모두 IRI 를 처리한다). - */ +/** 한글 상호를 URL 슬러그로 옮긴다. */ export function toSlug(input: string): string { return input .trim() @@ -26,21 +20,9 @@ export function joinUrl(origin: string, ...parts: string[]): string { return tail ? `${base}/${tail}` : base; } -/** - * 발행 주소를 만든다. **언제나 경로형**(`https://<host>/s/<slug>`)이다. - * - * ★ 왜 서브도메인(`<slug>.<host>`)을 쓰지 않는가 — 운영이 불가능해서다. - * 사이트가 하나 늘 때마다 DNS 레코드를 새로 파고 TLS 인증서를 새로 받아야 한다. - * 사장님이 주소를 고르는 순간(발행 전) 그 둘을 우리가 대신 만들어 줄 방법이 없으므로, - * 서브도메인으로 낸 주소는 화면에만 있고 실제로는 열리지 않는다. - * 경로형은 와일드카드도 발급도 필요 없다 — 호스트 하나에 인증서 하나로 전부 서비스된다. - * (덤으로 슬러그가 ASCII 든 아니든 규칙이 하나다. 서브도메인은 한글이면 퓨니코드로 - * 변환해야 하고 그 변환을 DNS·인증서·크롤러가 제각각 다뤘다 — 그 분기도 같이 사라졌다.) - * - * 커스텀 도메인이 붙으면 이 함수를 안 쓴다 — 그때는 사장님 도메인이 origin 이다. - */ +/** 발행 주소를 만든다. */ export function publishUrl(slug: string, host: string): {origin: string; basePath: string} { - // ★ 로컬 호스트만 http 다. https 로 고정하면 개발 중 [사이트 열기] 가 열리지 않는 주소로 간다. + // 로컬 호스트만 http 다. const scheme = /^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host) ? 'http' : 'https'; return {origin: `${scheme}://${host}`, basePath: `/s/${slug}`}; } diff --git a/solution/shared/src/styles/base.css b/solution/shared/src/styles/base.css index dab2b77..df55e02 100644 --- a/solution/shared/src/styles/base.css +++ b/solution/shared/src/styles/base.css @@ -1,10 +1,6 @@ -/* - * 두 앱(admin / site)이 공유하는 최소 기본 스타일. - * 폰트 로딩 · 스크롤바 · 접근성 유틸만 둔다. 색은 각 앱의 토큰이 정한다. - */ +/* 두 앱(admin / site)이 공유하는 최소 기본 스타일. */ -/* Pretendard 가변폰트 self-host (public/fonts). 한국어 본문 기본 서체. - 파일이 없으면 @font-face 가 조용히 실패하고 다음 폴백으로 넘어간다 — 빌드는 깨지지 않는다. */ +/* Pretendard 가변폰트 self-host (public/fonts). */ @font-face { font-family: 'Pretendard Variable'; font-weight: 45 920; @@ -22,7 +18,7 @@ body { padding: 0; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - /* 한글은 단어 중간에서 끊기면 읽기 나빠진다. 어절 단위로 줄바꿈. */ + /* 한글은 단어 중간에서 끊기면 읽기 나빠진다. */ word-break: keep-all; overflow-wrap: break-word; } @@ -36,21 +32,12 @@ body { scrollbar-width: none; } -/* - * 스크롤바. - * - * ★ `::-webkit-scrollbar` 로 폭을 주면 **macOS 의 오버레이 스크롤바가 꺼진다.** - * 그러면 스크롤바가 자리를 차지하는 옛날 방식으로 바뀌어, 맥에서 화면 오른쪽에 - * 6px 짜리 빈 띠가 늘 생긴다(실측: innerWidth 1440 ↔ clientWidth 1434). - * 표준 속성만 쓰면 맥은 오버레이를 유지하고(자리 차지 0), 윈도우·리눅스에서는 - * 가는 스크롤바가 된다. - */ +/* 스크롤바. */ html { scrollbar-width: thin; } -/* 스크린리더 전용 — 화면에서 감추되 접근성 트리에는 남긴다. - AI 크롤러도 이 텍스트를 읽는다(display:none 이면 안 읽는다). */ +/* 스크린리더 전용 — 화면에서 감추되 접근성 트리에는 남긴다. */ .sr-only { position: absolute; width: 1px; diff --git a/solution/shared/src/styles/site.css b/solution/shared/src/styles/site.css index a1d396a..5eb309d 100644 --- a/solution/shared/src/styles/site.css +++ b/solution/shared/src/styles/site.css @@ -1,32 +1,6 @@ -/* - * 발행본과 캔버스가 함께 읽는 사이트 디자인 토큰·유틸. - * - * ★ 왜 shared 로 뺐나 (2026-09-09) - * 같은 시안을 두 앱이 각자 구현하고 있었다. 발행본(`solution/site`)에는 유동 타이포와 - * `.shell` `.h2` `.panel` 이 있는데 캔버스(`solution/frontend` 빌더)에는 아예 없어서, - * 사장님이 에디터에서 본 화면과 발행된 화면이 **구조부터** 달랐다. 값을 양쪽에 적으면 - * 한쪽만 고쳐지고 다시 갈라진다 — 파일 하나를 둘이 읽는다. - * - * ★ 값은 시안(/s/stay)을 구운 그 값 그대로다. 여기를 손대면 시안과 어긋난다. - * - * ★ 스코프가 두 개인 이유 - * 발행본은 페이지 전체가 사이트다(`<body class="site">`). 캔버스는 관리자 화면 **안의 - * 일부**라(`.site-canvas`), 여기 규칙이 관리자 크롬으로 새면 안 된다. 실제로 `.text-muted` - * 는 관리자 화면 45곳이 쓰고 있어서 전역에 풀면 그쪽 색이 바뀐다. - * - * ★ 색 토큰에 `--site-` 접두어를 쓴다. - * `--color-muted` 는 관리자 토큰(`tokens.css`)이 이미 다른 뜻으로 쓰는 이름이다. - * 같은 이름을 공유 파일에 두면 캔버스를 띄운 화면에서 관리자 색이 조용히 덮인다. - */ +/* 발행본과 캔버스가 함께 읽는 사이트 디자인 토큰·유틸. */ -/* - * 글자 크기는 뷰포트를 따라 흐른다(clamp). - * - * ★ 왜 단계별 `sm:text-3xl lg:text-4xl` 을 그만뒀나 - * 중단점 사이에서 크기가 안 변해, 태블릿 가로(1024px)에서는 모바일 크기의 제목이 - * 1000px 폭에 덩그러니 놓였다. 그리고 본문이 전부 `text-xs`(12px)였다 — - * 데스크톱에서 12px 는 읽으라고 만든 크기가 아니다. 화면 폭과 함께 늘린다. - */ +/* 글자 크기는 뷰포트를 따라 흐른다(clamp). */ :where(.site, .site-canvas) { --fs-display: clamp(1.75rem, 1.25rem + 2.2vw, 3rem); --fs-h2: clamp(1.3125rem, 1.2rem + 0.55vw, 1.625rem); @@ -36,70 +10,44 @@ --fs-sm: clamp(0.96875rem, 0.95rem + 0.1vw, 1rem); --fs-xs: clamp(0.875rem, 0.86rem + 0.06vw, 0.90625rem); - /* - * 섹션 세로 여백. - * 템플릿 값(`--tpl-section-space`)을 상한으로 두고 화면이 좁아지면 줄인다 — - * 매거진의 5rem 을 모바일에 그대로 주면 한 화면에 제목만 남는다. - */ + /* 섹션 세로 여백. */ --section-space: clamp(1.75rem, 4vw, var(--tpl-section-space, 2.75rem)); } -/* - * 선 색은 **둘레 글자색에서 뽑는다.** - * 예전에는 전 섹션이 `border-black/8` 이었다 — 어두운 바탕(푸터·필름·플립보드)에서 - * 검은 선은 그냥 안 보인다. currentColor 를 섞으면 밝은 면에서는 진해지고 - * 어두운 면에서는 밝아져, 어떤 팔레트에서도 선이 남는다. - */ :where(.site, .site-canvas) { --site-line: color-mix(in oklab, currentColor 14%, transparent); --site-line-soft: color-mix(in oklab, currentColor 8%, transparent); - /* 본문 보조 글자. opacity-60 을 반복해 쓰던 자리를 색 하나로 모은다. */ + /* 본문 보조 글자. */ --site-muted: color-mix(in oklab, currentColor 96%, transparent); } -/* 제목 서체. 이름은 'serif' 로 남아 있지만 값은 템플릿이 정한다 — - 레트로면 간판체, 심플이면 고딕이다. 토큰이 없으면 예전처럼 명조로 떨어진다. */ +/* 제목 서체. */ :where(.site, .site-canvas) .serif { font-family: var(--tpl-font-heading, var(--font-serif)); letter-spacing: var(--tpl-heading-tracking, normal); } -/* - * ★ 가짜 굵게를 끈다 (2026-09-04, 사장님: "폰트가 헤더랑 맞지 않는다") - * 제목 서체는 템플릿이 정한다. 그런데 굵기가 한 벌뿐인 얼굴이 있다 — - * 1안의 옛 간판체(Gugi)가 **400 하나뿐**이다. 거기에 font-bold(700)를 걸면 브라우저가 - * 획을 부풀려 그리고(synthetic bold), 같은 서체인데 그 자리만 뭉개져 보인다. - * 실측: 헤더 워드마크 · 아이템 카드 제목 등 13곳이 그 상태였다. - * ★ 마크업을 열세 번 고치지 않고 여기서 끈다. 700 이 실제로 있는 얼굴 - * (Gowun Batang · Noto Serif KR)은 아무것도 달라지지 않는다 — - * 없는 굵기를 지어내지 말라는 말이지, 굵게 쓰지 말라는 말이 아니다. - */ +/* 가짜 굵게를 끈다 제목 서체는 템플릿이 정한다. */ :where(.site, .site-canvas) :is(.serif, .h2, .h3) { font-synthesis-weight: none; } -/* 카드·패널 테두리 두께도 템플릿이 정한다. 레트로는 2px 라야 인쇄물처럼 보인다. */ +/* 카드·패널 테두리 두께도 템플릿이 정한다. */ :where(.site, .site-canvas) .tpl-border { border-width: var(--tpl-border-width, 1px); } -/* 카드 그림자. 레트로는 각진 오프셋, 매거진은 none. */ +/* 카드 그림자. */ :where(.site, .site-canvas) .tpl-shadow { box-shadow: var(--tpl-shadow, none); } -/* - * 섹션 공통 폭. - * - * ★ 1120px 고정이었다. 1440px 화면에서는 좌우가 비고, 그 안에서 다시 `max-w-3xl` 로 - * 묶은 카드들이 한 번 더 좁아져 오른쪽 절반이 통째로 죽었다. - * 폭은 여기 한 곳에서만 정하고, 섹션은 이 폭을 다 쓴다. - */ +/* 섹션 공통 폭. */ :where(.site, .site-canvas) .shell { width: 100%; max-width: 78rem; /* 1248px */ margin-inline: auto; padding-inline: clamp(1rem, 4vw, 2.5rem); } -/* 카드 안 설명 — 넘치면 자른다. 카드 높이가 제각각이면 격자가 무너진다. */ +/* 카드 안 설명 — 넘치면 자른다. */ :where(.site, .site-canvas) .line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; @@ -112,28 +60,12 @@ max-width: 68ch; } -/* - * 바탕 질감(레트로의 갱지 결). - * - * ★ 화면 전체를 덮는 오버레이로 두지 않는다 — multiply 로 깔면 사진까지 누렇게 뜬다. - * `background-image` 는 그 요소의 배경색 **위**, 내용 **아래**에 깔리므로 - * 종이에만 결이 앉고 사진은 그대로다. - */ +/* 바탕 질감(레트로의 갱지 결). */ :where(.site, .site-canvas) .paper { background-image: var(--tpl-texture, none); } -/* - * 카드·패널 한 면. - * - * ★ 바탕색을 팔레트에서 **고르지 않고** 둘레 글자색을 한 겹 씌워 만든다. - * 예전에는 카드가 `--tpl-card` 였는데 그 값이 곧 `--tpl-surface-alt`(섹션 바탕 두 번째)라, - * alt 섹션 위의 카드는 섹션과 **같은 색**이었다 — 테두리 말고는 카드로 안 보였다. - * 반대로 base 섹션 위에서는 팔레트에 따라 카드가 섹션보다 밝기도 어둡기도 했다. - * 글자색을 섞으면 어느 섹션 위에서도 방향이 같다 — 밝은 면에서는 한 단 가라앉고, - * 어두운 면에서는 한 단 뜬다. - * ★ 모서리·테두리 두께·그림자는 템플릿이 정한다(레트로는 각지고 그림자가 각인처럼 찍힌다). - */ +/* 카드·패널 한 면. */ :where(.site, .site-canvas) .panel { background-color: color-mix(in oklab, currentColor 5%, transparent); border: var(--tpl-border-width, 1px) solid var(--site-line); @@ -145,7 +77,7 @@ background-color: color-mix(in oklab, currentColor 6%, transparent); } -/* 제목 3종. 크기는 clamp 가 정하고, 굵기·자간은 템플릿이 정한다. */ +/* 제목 3종. */ :where(.site, .site-canvas) .h2 { font-family: var(--tpl-font-heading, var(--font-serif)); font-size: var(--fs-h2); @@ -160,34 +92,20 @@ letter-spacing: var(--tpl-heading-tracking, -0.005em); line-height: 1.35; } -/* - * 작은 라벨. - * - * ★ `text-transform: uppercase` + 영문 눈썹(ABOUT · GALLERY · LOCATION …)을 걷어냈다. - * 섹션마다 영문 대문자 라벨을 다는 건 **생성형 템플릿의 표식**이고, 국내 사이트는 - * 그렇게 쓰지 않는다 — 한글 제목 하나로 끝낸다. 이 클래스는 표 안의 항목 이름처럼 - * 진짜 라벨이 필요한 자리에만 남긴다. - */ +/* 작은 라벨. */ :where(.site, .site-canvas) .label { font-size: var(--fs-xs); font-weight: 600; color: var(--site-muted); } -/* - * 터치 표적 최소 크기. - * 아이콘 버튼은 눈에 보이는 크기(20px)와 누를 수 있는 크기(44px)가 달라야 한다 — - * iOS·Android 접근성 지침의 하한이 44px 다. - */ +/* 터치 표적 최소 크기. */ :where(.site, .site-canvas) .tap { min-width: 2.75rem; min-height: 2.75rem; } -/* - * 터치 기기에서만 보이는 것(티맵처럼 앱 스킴밖에 없는 링크). - * ★ 화면 폭으로 가르지 않는다 — 좁은 창의 PC 에서도 앱은 없다. 포인터 종류로 가른다. - */ +/* 터치 기기에서만 보이는 것(티맵처럼 앱 스킴밖에 없는 링크). */ :where(.site, .site-canvas) .only-touch { display: none; } @@ -197,18 +115,12 @@ } } -/* 하단 고정 탭 — 아이폰 홈 인디케이터를 피한다. 예전엔 클래스만 있고 정의가 없었다. */ +/* 하단 고정 탭 — 아이폰 홈 인디케이터를 피한다. */ :where(.site, .site-canvas) .safe-b { padding-bottom: calc(0.5rem + env(safe-area-inset-bottom)); } -/* - * 가로 슬라이더. - * - * ★ 스크립트가 뜨기 전(그리고 안 뜨는 크롤러에게)에는 그냥 가로 스크롤 상자다 — - * 내용이 HTML 에 다 있어야 인용된다. embla 가 붙으면 `data-slider="on"` 이 걸리고 - * 그때부터 드래그·화살표가 움직인다. - */ +/* 가로 슬라이더. */ :where(.site, .site-canvas) .slider-viewport { overflow-x: auto; touch-action: pan-y pinch-zoom; @@ -225,9 +137,6 @@ } :where(.site, .site-canvas) .slider-track { display: flex; - /* ★ embla 필수 설정인데 빠져 있었다 — 이게 없으면 레일 위에서 세로로 밀 때 브라우저가 - 가로 드래그로 잡아 **페이지가 안 내려간다**(실측: 레일 4개 위에서 스크롤이 걸림). - pan-y 는 세로 스크롤을 브라우저에 그대로 넘기고, 가로만 스크립트가 가져간다. */ touch-action: pan-y pinch-zoom; } :where(.site, .site-canvas) .slider-track > * { @@ -239,14 +148,7 @@ user-select: none; } -/* - * 흐린 글자·선 유틸. - * - * ★ 발행본에서는 Tailwind 가 `@theme inline` 의 --color-muted/--color-line 으로 같은 유틸을 - * 만들어 준다(값 동일). 캔버스 쪽 Tailwind 는 그 이름을 관리자 뜻으로 이미 쓰고 있어 - * 유틸을 만들어 주지 않으므로 여기에 명시 정의를 둔다 — 없으면 캔버스에서만 - * 흐린 글자와 선이 통째로 죽는다(발행본 실측: text-muted 188곳 · border-line 101곳). - */ +/* 흐린 글자·선 유틸. */ :where(.site, .site-canvas) .text-muted { color: var(--site-muted); } diff --git a/solution/shared/src/styles/tokens.css b/solution/shared/src/styles/tokens.css index c63f27b..2d4f60a 100644 --- a/solution/shared/src/styles/tokens.css +++ b/solution/shared/src/styles/tokens.css @@ -1,20 +1,4 @@ -/* - * Design Tokens (Tailwind v4) — o2o-web4ai 관리자(빌더). - * - * negodata 보일러플레이트의 토큰을 그대로 이식했다. - * 디자인 방향: "인디고 콘솔"(Linear 문법) — 조용한 무채 크롬 + 인디고(#5E6AD2) 액센트 한 곳. - * - * ★ 여기 색은 *관리자 화면*의 색이다. 발행되는 소상공인 사이트의 색이 아니다. - * 발행 사이트는 사장님이 고른 템플릿(SiteTheme.colors)이 CSS 변수로 주입된다 — - * `site/src/seo/head.ts` 의 themeStyle() 참고. 두 색 체계를 섞지 않는다. - * - * 다른 Tailwind v4 프로젝트에서 재사용하려면: - * 1) 이 파일을 복사하고 - * 2) 엔트리 CSS에서 `@import "tailwindcss";` 다음에 `@import "./tokens.css";` - * - * 폰트: --font-sans 는 'Pretendard Variable'(self-host)를 참조한다. - * 폰트 파일이 없으면 'Noto Sans KR' → system-ui 로 폴백된다. - */ +/* Design Tokens (Tailwind v4) — o2o-web4ai 관리자(빌더). */ @custom-variant dark (&:is(.dark *)); @@ -45,7 +29,7 @@ } :root { - /* 뉴트럴은 인디고로 아주 살짝 기운 회색 계열. 캔버스는 거의 백색, 층은 헤어라인 보더로 만든다 */ + /* 뉴트럴은 인디고로 아주 살짝 기운 회색 계열. */ --background: #fcfcfd; --background-rgb: 252, 252, 253; --foreground: #202433; @@ -54,7 +38,7 @@ --border: #e9e9ef; --muted: #f1f1f5; --muted-foreground: #6c7086; - --primary: #18181b; /* 원본 프로토타입의 슬레이트 블랙. 흰 글자 대비 16:1 (AAA) */ + --primary: #18181b; /* 원본 프로토타입의 슬레이트 블랙. */ --primary-foreground: #ffffff; --secondary: #f1f1f5; --secondary-foreground: #33384d; @@ -69,14 +53,14 @@ --popover-foreground: #202433; --accent-foreground: #18181b; --input: #e4e4ec; - /* 차트 카테고리 팔레트 — 고정 순서(식별용), CVD·대비 검증 통과. features 차트와 같은 계열 */ + /* 차트 카테고리 팔레트 — 고정 순서(식별용), CVD·대비 검증 통과. */ --chart-1: #3b82f6; --chart-2: #10b981; --chart-3: #f59e0b; --chart-4: #a855f7; --chart-5: #f43f5e; --radius: 0.5rem; - /* 사이드바 = 캔버스보다 반 단계 어두운 조용한 회색 면. 액센트는 활성 아이콘에만 */ + /* 사이드바 = 캔버스보다 반 단계 어두운 조용한 회색 면. */ --sidebar: #f7f7f9; --sidebar-foreground: #5f6377; --sidebar-primary: #18181b; @@ -96,7 +80,7 @@ --border: #ffffff14; --muted: #24242d; --muted-foreground: #9a9dae; - --primary: #fafafa; /* 다크는 반전 — 밝은 표면에 진한 글자. 원본과 같은 무채색 축 */ + --primary: #fafafa; /* 다크는 반전 — 밝은 표면에 진한 글자. */ --primary-foreground: #16161d; --secondary: #24242d; --secondary-foreground: #e6e6ee; diff --git a/solution/shared/src/types/builder.ts b/solution/shared/src/types/builder.ts index 5511be8..78bdbc7 100644 --- a/solution/shared/src/types/builder.ts +++ b/solution/shared/src/types/builder.ts @@ -14,11 +14,11 @@ export interface InfoField { requiresVerification: boolean; /** [맞아요] 또는 수정 승인을 거쳤는가(VERIFIED / CORRECTED 대응). */ isVerified: boolean; - /** 어디서 수집됐는지 — 사장님이 판단할 근거. 출처 없는 값은 보여주지 않는다. */ + /** 어디서 수집됐는지 — 사장님이 판단할 근거. */ source?: string; placeholder?: string; category?: string; - /** 틀리면 예약 클레임이 나는 항목. 미검증이면 캔버스에서도 가린다. */ + /** 틀리면 예약 클레임이 나는 항목. */ critical?: boolean; } @@ -30,7 +30,7 @@ export interface SectionItem { isLocked: boolean; isEnabled: boolean; description?: string; - /** 사장님이 직접 편집하는 섹션 본문. 수집된 값과 분리해 자동 수집이 덮어쓰지 않는다. */ + /** 사장님이 직접 편집하는 섹션 본문. */ body?: string; /** 붙여넣기 아이템(가요·일력·코스)의 원문 JSON. */ data?: string; @@ -100,7 +100,7 @@ export interface PhotoItem { title: string; category: string; isPrimary?: boolean; - /** 사진 갤러리에 노출할지. undefined는 기존 데이터 호환을 위해 노출로 본다. */ + /** 사진 갤러리에 노출할지. */ isVisible?: boolean; } diff --git a/solution/shared/src/types/domain.ts b/solution/shared/src/types/domain.ts index 16f4749..7dc97df 100644 --- a/solution/shared/src/types/domain.ts +++ b/solution/shared/src/types/domain.ts @@ -1,13 +1,6 @@ -/** - * 백엔드 `common/enums.py` 의 코드값 미러. - * - * ★ 값은 백엔드가 유일한 소스다. 여기 숫자를 바꾸면 DB 와 어긋난다. - * 백엔드 enum 이 바뀌면 이 파일도 같이 고친다(orval 이 생성하는 model 은 - * OpenAPI 스키마를 따라가지만, 아래 상수 집합 — PUBLISHABLE_FACT_STATUSES 등 — - * 은 스키마에 안 나오므로 손으로 맞춘다). - */ +/** 백엔드 `common/enums.py` 의 코드값 미러. */ -/** places.category — 업종. `common/category_schema/resources/*.json` 과 1:1. */ +/** places.category — 업종. */ export const PlaceCategory = { LODGING: 1, CAFE: 2, @@ -16,7 +9,7 @@ export const PlaceCategory = { } as const; export type PlaceCategory = (typeof PlaceCategory)[keyof typeof PlaceCategory]; -/** places.status — 사업장 생애주기. 해지는 삭제가 아니라 SUSPENDED 전이다. */ +/** places.status — 사업장 생애주기. */ export const PlaceStatus = { DRAFT: 1, COLLECTING: 2, @@ -26,13 +19,13 @@ export const PlaceStatus = { } as const; export type PlaceStatus = (typeof PlaceStatus)[keyof typeof PlaceStatus]; -/** facts/media/place_aliases 공용 — 값이 어디서 왔는가. 모든 사실은 출처를 갖는다. */ +/** facts/media/place_aliases 공용 — 값이 어디서 왔는가. */ export const SourceType = { OWNER: 1, API: 2, CRAWL: 3, LLM: 4, - /** FAQ 전용 — 목표 수를 채운 공통 질문 + 문의 안내 답(backend services/faq_fill). 사실이 아니다. */ + /** FAQ 전용 — 목표 수를 채운 공통 질문 + 문의 안내 답(backend services/faq_fill). */ TEMPLATE: 5, } as const; export type SourceType = (typeof SourceType)[keyof typeof SourceType]; @@ -48,19 +41,13 @@ export const FactStatus = { } as const; export type FactStatus = (typeof FactStatus)[keyof typeof FactStatus]; -/** - * ★ 절대규칙 1 — 이 두 상태만 사이트에 나간다. - * - * 프론트에서도 같은 집합을 들고 있어야 하는 이유: 발행 게이트는 백엔드가 막지만, - * 관리자 캔버스는 게이트를 통과하기 *전* 상태를 그린다. 캔버스가 미검증 값을 - * 그대로 보여주면 사장님이 "이미 이렇게 나가고 있다"고 오해한다. - */ +/** 절대규칙 1 — 이 두 상태만 사이트에 나간다. */ export const PUBLISHABLE_FACT_STATUSES: readonly FactStatus[] = [ FactStatus.VERIFIED, FactStatus.CORRECTED, ]; -/** ★ 절대규칙 6 — 자동 수집이 덮어쓸 수 없는 종착 상태(사장님 수정본). */ +/** 절대규칙 6 — 자동 수집이 덮어쓸 수 없는 종착 상태(사장님 수정본). */ export const LOCKED_FACT_STATUSES: readonly FactStatus[] = [FactStatus.CORRECTED]; export function isPublishableFact(status: FactStatus): boolean { @@ -79,7 +66,7 @@ export const LinkChannel = { INSTAGRAM: 4, OFFICIAL_SITE: 5, BLOG: 6, - /** 네이버 예약 화면 그 자체(m.booking.naver.com). 플레이스 홈과 가른다 — 발행본의 예약 버튼이 쓴다. */ + /** 네이버 예약 화면 그 자체(m.booking.naver.com). */ NAVER_BOOKING: 7, ETC: 99, } as const; diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index dfae302..eebf9c8 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -9,7 +9,7 @@ import type { /** SitePayload — 발행 사이트 렌더러(`site/`)의 유일한 입력. */ export interface SitePayload { - /** 스키마 버전. 렌더러가 모르는 버전이면 빌드를 실패시킨다(조용히 반쪽 렌더하지 않는다). */ + /** 스키마 버전. */ schemaVersion: 1; site: SiteMeta; @@ -33,17 +33,17 @@ export interface SitePayload { /** 이 숙소의 노래. */ songs: SongTrack[]; - /** 미니 블로그 글. 전부 HTML 에 들어가고 화면이 나눠 보여준다(docs/MINI_BLOG.md). */ + /** 미니 블로그 글. */ posts?: PostEntry[]; - /** 검수를 통과한 이용 후기. 별점은 없다. */ + /** 검수를 통과한 이용 후기. */ reviews?: ReviewEntry[]; - /** 같은 원고의 발행된 사본. 고유 콘텐츠·SEO 메타의 근거로 세지 않는다. */ + /** 같은 원고의 발행된 사본. */ socialPosts?: SocialPostItem[]; - /** LLM 이 쓴 문장(allow_llm=true 필드). 사실이 아니라 문장이라 fact 와 분리한다. */ + /** LLM 이 쓴 문장(allow_llm=true 필드). */ narrative: Narrative; - /** 템플릿 — 색/서체/섹션 순서. 관리자 에디터가 정한 값이 그대로 온다. */ + /** 템플릿 — 색/서체/섹션 순서. */ theme: SiteTheme; /** 검색 키워드 — SiteOntology 가 고르고 백엔드가 **이 가게 자료로 거른** 것(services/seo_keywords). */ @@ -64,7 +64,7 @@ export interface ReviewEntry { } export interface SiteSeo { - /** `<meta name="keywords">` 로 나간다. SiteOntology 융합 순위 그대로, 최대 10개. */ + /** `<meta name="keywords">` 로 나간다. */ keywords: string[]; titleKeyword?: string; } @@ -73,15 +73,15 @@ export interface SiteMeta { siteId: string; placeId: string; status: SiteStatus; - /** 발행 도메인. 커스텀 도메인이 없으면 `<slug>.web4ai.o2osolution.ai` 같은 기본 호스트. */ + /** 발행 도메인. */ origin: string; - /** URL 경로 프리픽스. 루트 발행이면 '' (빈 문자열). */ + /** URL 경로 프리픽스. */ basePath: string; slug: string; /** ISO8601. `<meta name="dateModified">` 와 JSON-LD `dateModified` 로 나간다 — AI 검색이 신선도를 본다. */ publishedAt: string; updatedAt: string; - /** 사이트 버전. 빌드 산출물 캐시 무효화 키이자 `out/versions/<slug>/<version>/` 산출 경로. */ + /** 사이트 버전. */ version: number; /** 이 렌더가 **공개 주소(`/s/<slug>`)를 이 버전으로 넘겨도 되는가**. */ publish: boolean; @@ -89,18 +89,18 @@ export interface SiteMeta { export interface PlaceInfo { name: string; - /** 영문 표기. 없으면 생략 — 지어내지 않는다. */ + /** 영문 표기. */ englishName?: string; category: PlaceCategory; roadAddress?: string; address?: string; - /** 시·도. **주소 원문의 조각 그대로** ("경기"를 "경기도"로 펴지 않는다 — JSON-LD 가 화면과 대조된다). */ + /** 시·도. */ addressRegion?: string; - /** 시·군·구 ("성남시 중원구", "용산구"). 위와 같은 규칙. */ + /** 시·군·구 ("성남시 중원구", "용산구"). */ addressLocality?: string; - /** 읍·면 ("애월읍"). schema.org 에는 대응 속성이 없어 title·description 에만 쓴다. */ + /** 읍·면 ("애월읍"). */ addressSubLocality?: string; - /** ISO 3166-2:KR 코드("KR-41"). `geo.region` 메타 전용 — 화면 대조 대상이 아니다. */ + /** ISO 3166-2:KR 코드("KR-41"). */ addressRegionCode?: string; postalCode?: string; phone?: string; @@ -108,9 +108,9 @@ export interface PlaceInfo { latitude?: number; longitude?: number; regionCode?: string; - /** 카카오 로컬 place id — 동일 업소 검증의 근거. `sameAs` 로 내보내지 않는다(내부 키). */ + /** 카카오 로컬 place id — 동일 업소 검증의 근거. */ kakaoPlaceId?: string; - /** 하단 법적 고지 — 사업자등록번호·대표자·신고번호 등. 없는 항목은 넣지 않는다. */ + /** 하단 법적 고지 — 사업자등록번호·대표자·신고번호 등. */ legal?: LegalInfo; } @@ -128,7 +128,7 @@ export interface FactEntry { key: string; label: string; value: string | null; - /** 긴 숙소소개를 정보 표에 표시할 축약문. 없으면 value 원문을 표시한다. */ + /** 긴 숙소소개를 정보 표에 표시할 축약문. */ summary?: string; unit?: string | null; type: 'text' | 'number' | 'bool' | 'time' | 'date'; @@ -136,7 +136,7 @@ export interface FactEntry { status: FactStatus; sourceType: SourceType; sourceUrl?: string | null; - /** 틀리면 헛걸음·예약 클레임이 나는 항목. 미검증이면 절대 노출하지 않는다. */ + /** 틀리면 헛걸음·예약 클레임이 나는 항목. */ critical: boolean; required: boolean; /** scope=unit 인 fact 가 어느 단위에 속하는지. */ @@ -157,7 +157,7 @@ export interface UnitInfo { export interface MediaItem { mediaId: string; url: string; - /** 비전 분석이 만든 대체 텍스트. 비어 있으면 이미지 자체를 렌더하지 않는다(빈 alt 는 SEO 감점). */ + /** 비전 분석이 만든 대체 텍스트. */ alt: string; category?: string; caption?: string; @@ -180,7 +180,7 @@ export interface FaqEntry { } export interface ChannelLink { - /** 확정된 NOL 숙소 링크의 수집 안내 원문. 기존 payload는 생략 가능하다. */ + /** 확정된 NOL 숙소 링크의 수집 안내 원문. */ stayGuide?: { fields?: {key: string; label: string; value: string; group: 'rules' | 'facilities'; note?: string}[]; service?: string; @@ -214,11 +214,11 @@ export interface LocalContents { itineraries?: ItineraryItem[]; /** 지역 이야기 — 서버가 지역 단위로 생성한 가요·일력·인물·연표·읽기·엽서·퀴즈. */ story?: LocalStories; - /** 지역 정보를 마지막으로 갱신한 시각. 화면에 그대로 노출한다(오래된 정보를 숨기지 않는다). */ + /** 지역 정보를 마지막으로 갱신한 시각. */ syncedAt?: string; } -/** 지역 이야기 일곱 종. 데이터가 없는 종류는 키 자체가 없다 — 빈 배열을 만들지 않는다. */ +/** 지역 이야기 일곱 종. */ export interface LocalStories { songs?: SongItem[]; daily?: DailyItem[]; @@ -268,7 +268,7 @@ export type WeatherSky = export interface WeatherSnapshot { temperature: number; condition: string; - /** 날씨에 따른 안내 문구. LLM 문장이므로 사실 주장(가격·시간)을 담지 않는다. */ + /** 날씨에 따른 안내 문구. */ note?: string; /** 날씨별 한 줄. */ notes?: Partial<Record<WeatherMood, string>>; @@ -284,7 +284,7 @@ export interface LocalPlace { category: string; /** 수집 출처가 제공한 장소 주소. */ location?: string; - /** 업장 좌표 기준 거리("850m"/"1.2km"). 지역 캐시에서 온 항목엔 없을 수 있다. */ + /** 업장 좌표 기준 거리("850m"/"1.2km"). */ distanceText?: string; /** 업장 좌표 기준 거리(m). */ distanceMeters?: number; @@ -292,7 +292,7 @@ export interface LocalPlace { description?: string; /** 대표 사진. */ imageUrl?: string; - /** 외부 검색으로 보내는 질의어. 우리가 지어낸 URL 을 링크하지 않는다. */ + /** 외부 검색으로 보내는 질의어. */ searchQuery: string; } @@ -306,10 +306,10 @@ export interface FestivalEntry { searchQuery: string; /** 봄·여름·가을·겨울. */ season?: string; - /** 시작·종료일(YYYY-MM-DD). `period` 는 사람이 읽는 문구라 기계가 못 읽는다 — 정렬·계절 산출의 근거. */ + /** 시작·종료일(YYYY-MM-DD). */ startDate?: string; endDate?: string; - /** 대표 사진. `LocalPlace.imageUrl` 과 같은 규칙 — 출처가 준 것만, 저작권 유형이 상업적 이용을 허용할 때만. */ + /** 대표 사진. */ imageUrl?: string; } @@ -322,7 +322,7 @@ export interface RouteEntry { } export interface Narrative { - /** 히어로 둘째 줄에서 순환할 문구. 없으면 렌더러의 공통 문구를 쓴다. */ + /** 히어로 둘째 줄에서 순환할 문구. */ catchphrases?: { version: 1; items: {text: string; kind?: 'general' | 'season' | 'month' | 'weather'}[]; @@ -331,7 +331,7 @@ export interface Narrative { heroHeadline?: string; heroSubline?: string; tagline?: string; - /** 소개문 문단들. allow_llm=true 필드라 LLM 이 썼을 수 있다. */ + /** 소개문 문단들. */ about: string[]; /** 요약 한 문장 — meta description 과 llms.txt 의 첫 줄로 쓴다. */ summary?: string; @@ -340,14 +340,14 @@ export interface Narrative { export interface SongTrack { songId: string; title: string; - /** 가사. 화면에서 펼쳐 볼 수 있게 함께 싣는다 — 무슨 노래인지 읽히지 않으면 아무도 안 누른다. */ + /** 가사. */ lyrics?: string | null; /** 장르·분위기(Suno 에 넘긴 값). */ style?: string | null; durationSec?: number | null; /** 재생 주소. */ audioUrl: string; - /** 프리렌더가 원본을 찾을 때 쓰는 파일명. 렌더러는 쓰지 않는다. */ + /** 프리렌더가 원본을 찾을 때 쓰는 파일명. */ fileName?: string; } diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 86f07cc..694841d 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -43,91 +43,18 @@ import { } from '@site/seo'; import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay'; -/** - * 발행 사이트 프리렌더. - * - * npm run prerender 데모 payload 로 굽는다 - * npm run prerender -- --payload=a.json 파일 하나 - * npm run prerender -- --payload=a.json --payload=b.json 여러 개(바뀐 것만) - * npm run prerender -- --payload=./payloads 디렉토리 안의 *.json 전부 - * npm run prerender -- --out=../../dist 출력 위치 지정 - * - * ★ --payload 은 여러 번 줄 수 있다. 사이트 1,000개에서 한 명이 발행했다고 전부 다시 - * 구우면 못 쓴다 — 감시 프로세스가 바뀐 payload 만 골라 넘긴다. - * - * 산출물(사이트 1개당): - * out/s/<slug>/index.html 사이트 전체(한 장) - * out/s/<slug>/llms.txt 확인된 사실 목록(AEO) - * out/payloads/.status/<slug>.json 렌더 결과 보고서(백엔드가 읽는다) - * - * 오리진 루트(사이트 전체가 공유): - * out/robots.txt · out/sitemap.xml 크롤러가 읽는 유일한 자리 - * out/<indexnow-key>.txt 색인 통보용 키 파일 - * - * 공용 산출물(사이트 전체가 공유): - * out/assets/… 클라이언트 번들(하이드레이션용) - * out/fonts/… 폰트 - * - * ★ 자산을 사이트마다 복사하지 않는다. 같은 해시의 번들을 1,000벌 복사하면 디스크도 - * 낭비지만, 더 나쁜 건 브라우저 캐시가 사이트마다 따로 잡혀 매번 새로 받는다는 점이다. - * (커스텀 도메인 사이트는 예외 — 호스트가 달라 공용 경로를 공유할 수 없다.) - * - * ★ 이 스크립트는 백엔드 BUILD 잡이 부르는 자리다. 잡이 payload JSON 을 써 주고 - * 이걸 실행하면 정적 파일이 나온다. 지금은 payload 를 파일에서 읽지만, - * API 가 열리면 --payload=https://... 를 지원하는 것으로 충분하다. - */ +/** 발행 사이트 프리렌더. */ -/** 공개 주소가 읽는 디렉터리. 발행 주소 `<host>/s/<slug>` 와 같은 모양이다. */ +/** 공개 주소가 읽는 디렉터리. */ const SITE_DIR = 's'; -/** - * 실제로 굽는 자리. `out/s/<slug>` 는 여기를 가리키는 **심볼릭 링크**일 뿐이다. - * - * ★ 왜 (발행 버전 시스템, 2026-09-15) - * 예전에는 `out/s/<slug>/` 를 곧바로 덮어 썼다 — 발행마다 직전 HTML 이 사라지므로 롤백은 - * DB 스냅샷에서 **처음부터 다시 굽는 것**뿐이었고, 그마저도 "지금 공개된 버전"이 뭔지 - * 디렉토리 자체로는 알 길이 없었다(사고 중에는 site_versions 를 읽어야 했다). - * 지금은 버전마다 **자기만의 디렉토리**를 갖는다 — `out/versions/<slug>/<version>/`. - * 발행은 굽기가 **끝난 뒤** `out/s/<slug>` 링크를 그 버전으로 원자적으로 돌리는 것 하나다 - * (publishVersion). 실패하면 링크를 안 돌리므로 **직전 공개본이 그대로 서비스된다.** - * ★ 롤백은 재굽기가 필요 없다 — 대상 버전 디렉토리가 보관 기간 안에 남아 있으면 링크만 - * 되돌린다. 지워졌으면(pruneOldVersions) 백엔드가 site_versions.snapshot 으로 그 버전을 - * 다시 굽고 나서 링크를 돌린다(rollback_service.py) — 같은 publishVersion 경로를 탄다. - */ +/** 실제로 굽는 자리. */ const VERSIONS_DIR = 'versions'; -/** - * 이 코드가 처음 도는 순간 `out/s/<slug>` 가 이미 **일반 디렉토리**(옛 방식의 산출물)일 수 - * 있다. 지우고 새로 구우면 "기존 사이트 전체 재굽기 금지" 를 깬다 — 그 자리를 이 이름으로 - * `out/versions/<slug>/legacy/` 에 옮겨 붙인다(마이그레이션). 보관 대상에서 빼지 않는다 — - * DB 버전 번호와 대응이 없어 롤백 목록에는 안 뜨지만, 자산 참조 스캔·수동 복구용으로 남긴다. - */ +/** 이 코드가 처음 도는 순간 `out/s/<slug>` 가 이미 **일반 디렉토리**(옛 방식의 산출물)일 수 있다. */ const LEGACY_VERSION = 'legacy'; -/** - * 손으로 만든 시연본(목업)이 차지한 슬러그. **이 슬러그는 굽지 않는다.** - * - * ★ 왜 (2026-09-15, 실측) - * `/s/stay` 는 payload 가 없는 목업이고 그 index.html 이 **유일본**이다(AGENTS.md 함정 1). - * 그런데 누군가 빌더에서 슬러그 `stay` 로 발행하자 `payloads/stay.json` 이 생겼고, 프리렌더가 - * 기동하며 그 payload 로 같은 자리를 구워 **목업을 통째로 덮었다** — 캐치프레이즈 100개 · - * 미니 플레이어 · 날씨 문구 · 주입분이 전부 사라졌다. 파일을 백업에서 되돌려야 했다. - * "payload 가 없으면 안 굽는다" 는 목업을 지켜 주지 못한다. **payload 가 생기는 순간** 덮인다. - * - * ★ 슬러그를 막는 것이지 발행을 막는 것이 아니다. 백엔드는 발행에 성공했다고 보고, 여기서 - * 실패 보고서를 써 준다 — 사장님 화면에 사유가 뜨고, 목업은 그대로 남는다. - * - * ★ `stay2` 는 막지 않는다 — 원래 목업 후보였지만 그 슬러그는 이미 실제 사이트가 - * 가져가 발행 중이다(버터브루 = stay2, `payloads/stay2.json`). 막으면 그 사이트가 - * 재굽기에서 빠진다. 시연본은 `/s/stay` 하나다(mockup/README 2.1). - * ★ 실측(2026-09-18): 목업 배포 스크립트가 이 사정을 모르고 `stay2` 자리에 손으로 덮어써 - * 버터브루의 실제 사이트가 시연용 목업으로 바뀌었다(백업에서 복구, `site_slug.py` 주석도 같이 고침). - * 그래서 `stay3` · `stay4`(둘 다 payload 없는 순수 목업, `site_slug.py RESERVED_SLUGS` 로 - * 신규 배정도 막혀 있다)는 여기서도 같이 막는다 — 다음 목업을 늘릴 때도 이 목록에 먼저 추가한다. - * - * ★ 목업을 늘리거나 걷어낼 때는 `PRERENDER_PROTECTED_SLUGS`(쉼표) 로 덮어쓴다. 값을 비우면 - * 보호가 전부 풀린다 — 목업을 정식 사이트로 넘길 때만 그렇게 한다. - */ +/** 손으로 만든 시연본(목업)이 차지한 슬러그. */ const PROTECTED_SLUGS = new Set( (process.env.PRERENDER_PROTECTED_SLUGS ?? 'stay,stay3,stay4,stay5,stay6') .split(',') @@ -137,12 +64,12 @@ const PROTECTED_SLUGS = new Set( const HERE = dirname(fileURLToPath(import.meta.url)); const SITE_ROOT = resolve(HERE, '..', '..'); // dist/prerender → site/ -// 백엔드가 노래 파일을 떨구는 자리. payload 디렉토리와 나란히 둔다(한 디렉토리 약속). +// 백엔드가 노래 파일을 떨구는 자리. const SONGS_DIR = join(SITE_ROOT, 'songs'); const CLIENT_DIR = join(SITE_ROOT, 'dist', 'client'); interface Args { - /** --payload 은 여러 번 줄 수 있다. 비면 데모 payload 로 굽는다. */ + /** -payload 은 여러 번 줄 수 있다. */ payloads: string[]; out: string; /** 아무것도 굽지 않고 공용 자산(out/assets · out/fonts · public/)만 채우고 정리한다. */ @@ -190,13 +117,7 @@ function expandPayloadPaths(paths: string[]): string[] { return [...new Set(files)]; } -/** - * 읽어들인 payload 한 건. 읽기·검증이 실패했으면 payload 대신 error 가 담긴다. - * - * ★ 실패를 여기서 throw 하지 않는다. 예전에는 payload 하나가 깨지면 배치 전체가 죽어서, - * 멀쩡한 사이트까지 못 구웠다(게다가 아무 보고서도 남지 않았다). 실패는 값으로 옮기고 - * main 이 사이트 단위로 격리한다. - */ +/** 읽어들인 payload 한 건. */ interface LoadedPayload { payload?: SitePayload; /** 렌더 결과 보고서를 이 파일 옆에 쓴다 — 백엔드가 보는 유일한 경로다. */ @@ -228,7 +149,7 @@ function loadOne(file: string): LoadedPayload { } } -/** payload 를 읽는다. 파일 / 디렉토리 / 미지정(데모) 셋을 받는다. */ +/** payload 를 읽는다. */ function loadPayloads(paths: string[]): LoadedPayload[] { if (paths.length === 0) { console.log('[prerender] --payload 이 없어 데모 payload 로 굽습니다.'); @@ -255,25 +176,18 @@ function readAssets(): {script: string; css: string[]} { const entry = Object.values(manifest).find((chunk) => chunk.isEntry); if (!entry) throw new Error('manifest 에 엔트리 청크가 없습니다.'); - // ★ 파일명만 돌려준다. 앞에 붙일 경로는 사이트마다 다르다(`/s/<slug>`) — - // 여기서 `/assets/…` 로 굳히면 사이트가 `/s/mmg/` 아래 놓이는 순간 전부 404 가 되고, - // JS 가 안 붙어 하이드레이션이 죽는다(정적 HTML 만 남는다). 실측으로 그렇게 됐다. + // 파일명만 돌려준다. return { script: entry.file.replace(/^assets\//, ''), css: (entry.css ?? []).map((file) => file.replace(/^assets\//, '')), }; } -/** - * 구조화 데이터 대조 실패. ★ 재시도해도 소용없다 — 데이터가 고쳐져야 통과한다. - * - * 일반 렌더 오류(디스크·번들)와 구분해야 한다. 그쪽은 재시도가 의미 있지만 - * 이건 사장님이 값을 고치기 전까지 몇 번을 구워도 같은 결과다. - */ +/** 구조화 데이터 대조 실패. */ class VerifyError extends Error { constructor( readonly mismatches: string[], - /** ★ 실패해도 계수는 보고한다. 백엔드가 NO_UNIQUE_CONTENT 와 JSONLD_MISMATCH 를 갈라야 한다. */ + /** 실패해도 계수는 보고한다. */ readonly uniqueContentCount: number | null = null, ) { super(`구조화 데이터가 화면 값과 다릅니다(${mismatches.length}건): ${mismatches.slice(0, 3).join(' · ')}`); @@ -281,14 +195,7 @@ class VerifyError extends Error { } } -/** - * 고유 콘텐츠 0건. **VerifyError 와 갈라 둔다.** - * - * ★ 예전에는 이 사유를 VerifyError 의 mismatches 에 실어 던졌다. 백엔드 게이트는 - * mismatches 가 비지 않았다는 것만 보고 JSONLD_MISMATCH 로 판정했고, 사장님 화면에는 - * "구조화 데이터와 화면 값이 다릅니다" 라는 **틀린 문구**가 떴다 — 구조화 데이터는 - * 멀쩡했다. 사유가 다르면 예외도 갈라야 라벨이 안 섞인다. - */ +/** 고유 콘텐츠 0건. */ class NoUniqueContentError extends Error { /** 백엔드가 JSONLD_MISMATCH 로 오인하지 않도록 늘 비어 있다. */ readonly mismatches: string[] = []; @@ -299,22 +206,9 @@ class NoUniqueContentError extends Error { } } -/** - * 사이트는 **한 장**이다(2026-08-31). 예전에는 홈·객실·객실상세·주변·오시는길·FAQ 로 - * 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다. - * - * ★ 왜 합쳤나 — 소상공인은 원래 내용이 적다. 쪼갤수록 페이지마다 얇아지고, 검색엔진은 - * 그런 페이지를 색인에서 버린다. 한 장에 모으면 알찬 페이지 하나가 된다. - * 경로형(`/s/<slug>`)이라 얇은 페이지의 평가가 도메인 전체로 번지는 것도 막는다. - */ +/** 사이트는 **한 장**이다. */ -/** - * payload 를 HTML 안에 심을 수 있게 직렬화한다. - * - * ★ `<`, `>`, `&` 를 유니코드 이스케이프한다. 안 하면 사장님이 소개문에 적은 - * `</script>` 한 줄이 스크립트 태그를 닫아 버린다 — 그 뒤 내용이 마크업으로 새고, - * 최악의 경우 임의 스크립트가 된다. U+2028/2029 는 JS 문법상 줄바꿈이라 함께 막는다. - */ +/** payload 를 HTML 안에 심을 수 있게 직렬화한다. */ function serializePayload(payload: SitePayload): string { return JSON.stringify(payload) .replace(/</g, '\\u003c') @@ -330,8 +224,7 @@ function writeFile(outDir: string, relPath: string, content: string) { writeFileSync(full, content, 'utf-8'); } -/** Node 24의 recursive cpSync가 macOS Docker bind mount에서 0바이트·write-only 파일을 - * 남기고 EACCES로 실패하는 경우가 있어, 빌드 자산은 파일 단위로 복사한다. */ +/** Node 24의 recursive cpSync가 macOS Docker bind mount에서 0바이트·write-only 파일을 남기고 EACCES로 실패하는 경우가 있어, 빌드 자산은 파일 단위로 복사한다. */ function copyDirectoryFiles(source: string, destination: string) { mkdirSync(destination, {recursive: true}); for (const entry of readdirSync(source, {withFileTypes: true})) { @@ -346,10 +239,10 @@ function copyDirectoryFiles(source: string, destination: string) { } } -/** 내려받은 사진이 놓이는 폴더. 사이트 디렉토리 안이라 Azure 발행 때 함께 올라간다. */ +/** 내려받은 사진이 놓이는 폴더. */ const MEDIA_DIR = 'img'; -/** 한 장당 상한(바이트). 원본이 통짜 PNG 인 경우가 있어 막아 둔다 — 넘으면 원래 주소로 둔다. */ +/** 한 장당 상한(바이트). */ const MEDIA_MAX_BYTES = 8 * 1024 * 1024; const MEDIA_EXT: Record<string, string> = { @@ -361,32 +254,13 @@ const MEDIA_EXT: Record<string, string> = { 'image/gif': '.gif', }; -/** - * 사진을 **우리 오리진으로 옮긴다.** - * - * ★ 왜 (2026-09-15, 실측) - * 발행본 사진은 수집한 자리(`*.pstatic.net` · `tong.visitkorea.or.kr`)를 그대로 가리켰다. - * 그 호스트들은 `Access-Control-Allow-Origin` 을 주지 않는다 — 그래서 그 사진을 캔버스에 - * 그리면 **캔버스가 오염돼 파일로 못 뽑는다**(브라우저 정책). 엽서 쓰기의 저장·공유가 - * 모든 발행 사이트에서 막혀 있었고("이 사진은 다른 사이트에 올라와 있어…"), - * 화면에는 미리보기만 남았다. 클라이언트에서는 넘을 방법이 없다 — CORS 없는 `fetch` 도 - * 같은 벽에 막힌다. **같은 오리진에 파일이 있어야** 풀린다. - * ★ 덤이 아니라 같이 딸려 오는 것: 남의 CDN 이 핫링크를 끊거나 주소를 바꾸면 사진이 - * 통째로 사라지는데, 옮겨 놓으면 그 일이 우리 사이트를 건드리지 못한다. - * ★ **재게시 권리(DECISIONS 1-2)의 결론을 앞당기지 않는다.** 화면에 이미 싣고 있는 것만 - * 같은 자리로 옮기는 것이고, `originUrl` · `sourceType` 은 그대로 남는다 — - * "불가" 로 결론 나면 `sourceType = CRAWL` 을 발행에서 빼는 그 대응이 그대로 먹는다. - * ★ 실패는 조용히 넘긴다. 못 받은 사진은 **원래 주소를 그대로 쓴다** — 사진이 사라지는 것보다 - * 공유가 막힌 채로 보이는 쪽이 낫다. - */ +/** 사진을 **우리 오리진으로 옮긴다.** */ async function mirrorMedia(payload: SitePayload, siteDir: string) { const items = (payload.media ?? []).filter((item) => /^https?:\/\//i.test(item.url ?? '')); if (items.length === 0) return; const dir = join(siteDir, MEDIA_DIR); - // ★ 절대 주소로 바꾼다. 이 주소는 `<img src>` 뿐 아니라 og:image · JSON-LD 의 image 로도 - // 나가는데, 그 둘은 절대 주소여야 한다(상대 주소를 주면 크롤러마다 다르게 읽는다). - // 수집 주소도 절대였으니 바뀌는 것은 호스트뿐이다. + // 절대 주소로 바꾼다. const publicBase = joinUrl(payload.site.origin, payload.site.basePath); mkdirSync(dir, {recursive: true}); @@ -395,8 +269,7 @@ async function mirrorMedia(payload: SitePayload, siteDir: string) { for (const item of items) { const origin = item.url; - // 파일명은 **주소의 해시**다. 같은 사진이 두 사이트에 있어도 각자 폴더라 부딪히지 않고, - // 주소가 그대로면 이름도 그대로라 다시 구워도 내려받지 않는다. + // 파일명은 **주소의 해시**다. const stem = createHash('sha1').update(origin).digest('hex').slice(0, 16); const hit = readdirSync(dir).find((name) => name.startsWith(`${stem}.`)); if (hit) { @@ -428,8 +301,7 @@ async function mirrorMedia(payload: SitePayload, siteDir: string) { } } - // 지난 발행의 사진은 치운다 — 노래(copySongs)와 같은 이유다. 안 치우면 사장님이 사진을 - // 바꿀 때마다 쌓이고, Azure 발행 때 그대로 같이 올라간다. + // 지난 발행의 사진은 치운다 — 노래(copySongs)와 같은 이유다. for (const name of readdirSync(dir)) { if (!wanted.has(name)) rmSync(join(dir, name), {force: true}); } @@ -450,18 +322,7 @@ function isLegacyFlatSite(publicDir: string): boolean { } } -/** - * 공개 주소(`out/s/<slug>`)를 이 버전으로 **원자적으로** 돌린다. - * - * ★ 왜 심볼릭 링크인가 — 임시 이름으로 링크를 만들고 `renameSync` 로 덮어씌운다. POSIX 에서 - * 같은 디렉토리 안의 rename 은 원자적이다(파일시스템 저널이 "옛 링크"와 "새 링크" 사이의 - * 중간 상태를 방문자에게 보여주지 않는다). 그래서 굽는 동안 크래시가 나거나 게이트가 - * 막아도 **공개 주소는 절대 절반만 바뀐 상태가 되지 않는다** — 직전 버전이거나 이번 - * 버전이거나 둘 중 하나다. - * ★ 첫 발행이 아니고 `out/s/<slug>` 가 아직 **일반 디렉토리**(이 코드 이전 산출물)면, 지우지 - * 않고 `out/versions/<slug>/legacy/` 로 옮겨 붙인다 — 그 자리가 유일한 사본인 사이트가 - * 있을 수 있어서다(옛 굽기는 이력을 안 남겼다). 옮긴 뒤에는 일반 심볼릭 링크 전환과 같다. - */ +/** 공개 주소(`out/s/<slug>`)를 이 버전으로 **원자적으로** 돌린다. */ function publishVersion(outRoot: string, slug: string, version: number): void { const publicDir = join(outRoot, SITE_DIR, slug); const target = versionDir(outRoot, slug, version); @@ -476,8 +337,7 @@ function publishVersion(outRoot: string, slug: string, version: number): void { renameSync(publicDir, legacy); console.log(` (마이그레이션) 옛 산출물을 versions/${slug}/${LEGACY_VERSION}/ 로 보존`); } else { - // legacy 자리가 이미 있다(재시도 등) — 지금 것은 legacy 보다 못 믿을 이유가 없지만 - // 둘을 합치지 않는다. 그대로 둔 채 아래에서 심볼릭 링크로 덮어쓴다(원자적 교체). + // legacy 자리가 이미 있다(재시도 등) — 지금 것은 legacy 보다 못 믿을 이유가 없지만 둘을 합치지 않는다. throw new Error('기존 사이트와 legacy가 함께 있어 자동 전환을 중단합니다'); } } @@ -490,16 +350,7 @@ function publishVersion(outRoot: string, slug: string, version: number): void { renameSync(tmp, publicDir); } -/** - * 오래된 버전 디렉토리를 지운다. **지금 공개된 버전과 `legacy` 는 절대 지우지 않는다.** - * - * ★ 보관 정책 — `out/assets` 의 자산 보관(ASSET_RETENTION_DAYS)과 같은 사고방식이다: - * 최근 N개(ROLLBACK_MIN_VERSIONS) 또는 최근 D일(ROLLBACK_RETENTION_DAYS) 중 **더 오래 - * 남기는 쪽**을 따른다. 둘 다 지난 버전만 지운다 — 롤백 대상은 이 안에서 고른다. - * ★ 지워진 버전도 DB(site_versions.snapshot) 에는 남는다. 그 버전으로 롤백하면 - * `rollback_service.py` 가 snapshot 으로 **다시 굽는다** — 디스크에서 지운 것이지 - * 되돌릴 수 없게 잃은 것이 아니다(자산과 달리 재생성 비용이 있을 뿐이다). - */ +/** 오래된 버전 디렉토리를 지운다. */ const ROLLBACK_MIN_VERSIONS = 5; const ROLLBACK_RETENTION_DAYS = 30; @@ -538,7 +389,6 @@ function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) { const basePath = payload.site.basePath.replace(/\/+$/, ''); const suffix = `/${SITE_DIR}/${payload.site.slug}`; // out/ 이 도메인 루트가 아닌 곳에 마운트돼도 맞도록 basePath 에서 역산한다. - // ('/s/joy' → '' · '/sites/s/joy' → '/sites') const rootPrefix = basePath.endsWith(suffix) ? basePath.slice(0, -suffix.length) : ''; const shared = basePath !== ''; @@ -546,23 +396,12 @@ function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) { shared, /** 자산을 실제로 복사해 넣을 디렉토리. */ dir: shared ? outRoot : siteDir, - /** HTML 이 참조할 접두사. 두 경우 모두 루트 절대경로가 된다. */ + /** HTML 이 참조할 접두사. */ base: `${rootPrefix}/assets`, }; } -/** - * 이 숙소의 노래 파일을 사이트 디렉토리로 옮겨 놓는다. - * - * ★ 왜 백엔드가 직접 out/ 에 쓰지 않나 - * 백엔드는 발행물 디렉토리를 모른다 — payload JSON 을 약속된 자리에 떨구는 것이 경계다 - * (ARCHITECTURE 1절). 노래도 같은 약속을 쓴다: 백엔드는 `site/songs/<파일>` 에 두고, - * 굽는 쪽인 여기가 사이트 안으로 복사한다. 그래야 Azure 발행(`azure_static.publish`)이 - * 사이트 디렉토리를 통째로 올릴 때 노래도 함께 올라간다. - * - * ★ 없으면 조용히 넘어간다. 곡은 발행보다 2~3분 늦게 완성되므로 "아직 없음" 이 정상이고, - * 그때 payload 에 songs 가 비어 있어 화면도 플레이어를 안 그린다. - */ +/** 이 숙소의 노래 파일을 사이트 디렉토리로 옮겨 놓는다. */ function copySongs(payload: SitePayload, siteDir: string) { const wanted = new Set<string>(); @@ -580,16 +419,7 @@ function copySongs(payload: SitePayload, siteDir: string) { copyFileSync(from, to); } - /* - * 지난 발행의 곡은 치운다. - * - * ★ 발행할 때마다 새 곡을 만들고 파일명은 song_id 라, 치우지 않으면 발행 횟수만큼 1MB 짜리 - * 파일이 사이트 디렉토리에 쌓인다. 그리고 그것들은 Azure 발행 때 **함께 올라간다** — - * 아무도 듣지 않는 옛 곡이 계속 쌓이는 종류의 낭비다. - * ★ 자산(out/assets)과 달리 보관 기간을 두지 않는다. 번들은 크롤러가 나중에 렌더할 때 - * 필요하지만(AGENTS.md), 노래는 그 페이지에서 버튼을 눌러야 나는 것이라 옛 HTML 이 - * 가리킬 일이 없다 — payload 에 실린 곡 하나만 남기면 된다. - */ + /* 지난 발행의 곡은 치운다. */ if (!existsSync(siteDir)) return; for (const entry of readdirSync(siteDir, {withFileTypes: true})) { if (!entry.isFile() || !entry.name.endsWith('.mp3')) continue; @@ -604,23 +434,15 @@ function prerenderSite( assets: ReturnType<typeof readAssets>, referenced: Set<string>, ) { - // ★ 여기서 한 번 깎고, 그 뒤로는 깎인 payload 만 쓴다 — - // 서버 렌더 · JSON-LD · llms.txt · HTML 에 심는 블롭이 전부 같은 객체를 본다. - // (블롭만 원본으로 두면 미검증 fact 가 HTML 소스로 새고, AI 크롤러는 그걸 읽는다.) + // 여기서 한 번 깎고, 그 뒤로는 깎인 payload 만 쓴다 — 서버 렌더 · JSON-LD · llms.txt · HTML 에 심는 블롭이 전부 같은 객체를 본다. const payload = sanitizePayloadForPublish(input); - // ★ 이번 버전 전용 디렉토리에 굽는다(`out/s/<slug>` 가 아니다) — publishVersion 주석 참조. - // 실패해도 이 디렉토리만 지저분해질 뿐 공개 주소는 안 건드린다. + // 이번 버전 전용 디렉토리에 굽는다(`out/s/<slug>` 가 아니다) — publishVersion 주석 참조. const siteDir = versionDir(outRoot, payload.site.slug, payload.site.version); const plan = assetPlan(payload, outRoot, siteDir); - /** 브라우저가 자산을 찾아갈 접두사. 파일이 놓인 자리와 같아야 한다. */ + /** 브라우저가 자산을 찾아갈 접두사. */ const assetBase = plan.base; - /** - * ★ 먼저 메모리에 굽고, 검증을 통과한 뒤에야 파일로 쓴다. - * 렌더하면서 바로 쓰면 검증에 걸린 페이지가 이미 디스크에 나가 있게 된다 — - * 그 순간 방문자와 크롤러가 그걸 읽는다. 게이트가 있으나 마나가 된다. - * 실패하면 아무것도 쓰지 않으므로 **직전 버전이 그대로 서비스된다**(빈 사이트가 되지 않는다). - */ + /** 먼저 메모리에 굽고, 검증을 통과한 뒤에야 파일로 쓴다. */ const mismatches: string[] = []; // 실패하더라도 보고서에 실어야 하므로 먼저 센다. const uniqueContentCount = countUniqueContent(payload); @@ -641,9 +463,7 @@ function prerenderSite( ' <head>', head, ' </head>', - /* ★ class="site" 는 장식이 아니다 — 시안 토큰·유틸(shared/styles/site.css)이 이 클래스 - 안에서만 산다. 빼면 유동 타이포와 .shell·.h2 가 통째로 죽어 글자 크기가 본문으로 떨어진다. - 빌더 캔버스는 같은 규칙을 `.site-canvas` 로 받는다. */ + /* class="site" 는 장식이 아니다 — 시안 토큰·유틸(shared/styles/site.css)이 이 클래스 안에서만 산다. */ ' <body class="site">', ` <div id="root">${appHtml}</div>`, ` <script>window.__SITE_PAYLOAD__=${serializePayload(payload)}</script>`, @@ -652,7 +472,7 @@ function prerenderSite( '', ].join('\n'); - /** ★ 절대규칙 3 — 나갈 바로 그 HTML 에 대고 대조한다. */ + /** 절대규칙 3 — 나갈 바로 그 HTML 에 대고 대조한다. */ for (const problem of verifyJsonLd(html, jsonld)) mismatches.push(problem); for (const problem of verifyGeo(jsonld, payload.place.latitude, payload.place.longitude)) { mismatches.push(problem); @@ -662,48 +482,30 @@ function prerenderSite( throw new VerifyError(mismatches, uniqueContentCount); } - /** - * ★ 절대규칙 2 — 이 가게에만 있는 콘텐츠가 0건이면 굽지 않는다. - * - * 같은 템플릿으로 대량 생성한 사이트는 스팸 판정을 받고, 판정되면 사이트가 통째로 무의미해진다. - * 이 검사도 **렌더러 안**에 있어야 한다 — 백엔드가 나중에 거부하더라도 그 전에 이미 - * 페이지가 디스크에 나가 있으면 크롤러가 그걸 읽는다. - */ + /** 절대규칙 2 — 이 가게에만 있는 콘텐츠가 0건이면 굽지 않는다. */ if (uniqueContentCount <= 0) { throw new NoUniqueContentError(uniqueContentCount); } writeFile(siteDir, 'index.html', html); - /** - * 기계용 파일은 `llms.txt` 하나만 남는다. - * - * ★ 사이트별 `sitemap.xml` 을 없앴다 — 한 장짜리 사이트의 사이트맵은 URL 이 하나뿐이라, - * 사이트가 1,000개면 URL 한 줄짜리 파일이 1,000개 생긴다. 루트 사이트맵 하나에 - * 전부 담는다(사이트맵 하나에 URL 50,000개까지 들어간다). - * - * ★ 사이트별 `robots.txt` 도 없앴다 — `<host>/s/<slug>/robots.txt` 는 **아무도 읽지 않는다**. - * 크롤러는 오리진 루트에서만 읽는다(RFC 9309). - */ + /** 기계용 파일은 `llms.txt` 하나만 남는다. */ writeFile(siteDir, 'llms.txt', renderLlmsTxt(payload)); copySongs(payload, siteDir); - // 하이드레이션용 번들. 공용 호스트면 out/ 루트 한 벌을 공유하므로 여기서는 아무것도 안 한다 - // (main 이 사이트를 굽기 전에 한 번 깔아 둔다). 커스텀 도메인일 때만 사이트 안에 복사한다. + // 하이드레이션용 번들. if (!plan.shared) { writeSharedAssets(plan.dir, referenced); } - // 이전 구현이 사이트마다 복사해 둔 자산이 남아 있으면 지운다 — 공용으로 바뀐 뒤에는 - // 아무도 참조하지 않는 죽은 파일이고, 사이트 수만큼 디스크를 계속 먹는다. + // 이전 구현이 사이트마다 복사해 둔 자산이 남아 있으면 지운다 — 공용으로 바뀐 뒤에는 아무도 참조하지 않는 죽은 파일이고, 사이트 수만큼 디스크를 계속 먹는다. if (plan.shared) { rmSync(join(siteDir, 'assets'), {recursive: true, force: true}); rmSync(join(siteDir, 'fonts'), {recursive: true, force: true}); } - // 보고서용 — 실제로 나간 JSON-LD 를 그대로 담는다. 백엔드가 이걸 - // site_versions.jsonld(파이썬 빌더 산출물)와 대조해 두 렌더러의 드리프트를 잡는다. + // 보고서용 — 실제로 나간 JSON-LD 를 그대로 담는다. return { siteDir, payload, @@ -712,32 +514,19 @@ function prerenderSite( }; } -/** - * 이 가게에만 있는 콘텐츠 건수 — 백엔드 `count_unique_content` 와 **같은 규칙**이다. - * - * ★ 두 숫자가 다르면 게이트가 통과시킨 근거와 실제 페이지가 어긋났다는 뜻이다. - * 백엔드가 보고서를 받아 대조한다. 규칙을 바꿀 땐 반드시 양쪽을 같이 고친다. - * (backend/services/builder/render.py 의 MIN_UNIQUE_TEXT · count_unique_content) - */ +/** 이 가게에만 있는 콘텐츠 건수 — 백엔드 `count_unique_content` 와 **같은 규칙**이다. */ const MIN_UNIQUE_TEXT = 8; function countUniqueContent(payload: SitePayload): number { - // socialPosts는 우리 출력이다. 세면 고유 콘텐츠 0건인 사이트가 자기 소개글로 게이트를 우회한다. + // socialPosts는 우리 출력이다. const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT; let count = 0; // 소개 섹션의 직접 입력 본문은 실제 AboutSection 에 표시되는 가게 고유 문장이다. - // ★ enabled 를 함께 본다. 꺼서 페이지에 없는 문장까지 세면 빈 페이지가 게이트를 통과한다. const intro = payload.theme.sections.find((section) => section.id === 'intro'); if (intro?.enabled && long(intro.body)) count += 1; - /** - * 붙여넣기 아이템(가요·일력·승차권·인물…)의 항목도 이 가게에만 있는 문장이다. - * - * ★ 안 세면 "곡을 여덟 개 채웠는데 고유 콘텐츠 0건으로 발행이 막힌다"가 된다 — - * 소개 본문(intro.body)이 계약에 없던 시절과 같은 구멍이다. - * ★ 긴 문장이 한 줄이라도 있는 항목만 센다. 제목·연도만 있는 줄은 가게를 구분하지 못한다. - */ + /** 붙여넣기 아이템(가요·일력·승차권·인물…)의 항목도 이 가게에만 있는 문장이다. */ const hasLongText = (value: unknown): boolean => { if (typeof value === 'string') return long(value); if (Array.isArray(value)) return value.some(hasLongText); @@ -768,16 +557,7 @@ function countUniqueContent(payload: SitePayload): number { return count; } -/** - * 렌더 결과 보고서. payload 파일 옆(`.status/<slug>.json`)에 쓴다. - * - * ★ 왜 필요한가 - * 지금까지 프리렌더는 성공하든 실패하든 아무것도 남기지 않았다. 빌드가 깨지면 - * DB 에는 "발행됨"으로 남고 페이지는 없는 상태가 되는데, 아무도 그걸 모른다. - * 백엔드에 마운트된 유일한 디렉토리가 payload 디렉토리라 보고서도 그 안에 쓴다. - * - * ★ 임시파일 → rename. 백엔드가 반쯤 쓰인 JSON 을 읽지 않게 한다. - */ +/** 렌더 결과 보고서. */ interface RenderReport { schemaVersion: 1; slug: string; @@ -790,7 +570,7 @@ interface RenderReport { bundle: string; uniqueContentCount: number | null; jsonld: unknown[] | null; - /** ★ 절대규칙 3 위반 목록. 비어야 발행 가능하다 — 백엔드 게이트가 이걸 본다. */ + /** 절대규칙 3 위반 목록. */ mismatches: string[]; error: string | null; } @@ -804,35 +584,16 @@ function writeReport(payloadFile: string, report: RenderReport) { renameSync(tmp, target); } -/** - * 옛 자산 보관 기간. - * - * ★ 왜 지우지 않고 남기나 — HTML 은 자산 경로를 **파일명 해시까지 박아** 굽는다 - * (`/assets/index-DvNTmLhy.css`). 렌더러를 배포하면 이름이 바뀌는데, 그 순간 옛 파일을 - * 지우면 아직 다시 굽지 않은 사이트는 CSS·JS 가 **404** 다. 예전 구현이 그랬다. - * - * ★ 더 나쁜 건 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌린다. 그 사이에 - * 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 — 하필 - * 신규 도메인이 평가받는 시기에 그렇게 된다. 유예 창이 필요하다는 게 업계 통념이고 - * (Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다. - * - * 한 벌이 400KB 안팎이라 한 달치를 남겨도 10MB 남짓이다 — 싸게 사는 안전이다. - */ +/** 옛 자산 보관 기간. */ const ASSET_RETENTION_DAYS = 30; /** 하루에 여러 번 배포해도 직전 빌드는 반드시 남는다(보관 기간과 무관). */ const ASSET_MIN_BUILDS = 2; -/** - * 어떤 빌드가 어떤 파일을 깔았는지. **보관 기간의 근거는 이 파일이다.** - * - * ★ 파일 mtime 으로 나이를 재지 않는다 — 복사·동기화가 시각을 갈아 버리면 옛 파일이 - * 영원히 젊어지거나 산 파일이 지워진다. 점(.)으로 시작해 업로드에서 빠진다 - * (azure_static 이 dotfile 을 거른다). - */ +/** 어떤 빌드가 어떤 파일을 깔았는지. */ const ASSET_LEDGER = '.builds.json'; /** 자산 대장 한 줄 — 한 번의 번들 빌드가 깐 파일 목록. */ interface AssetBuild { - /** 이 번들을 마지막으로 깐 시각(ISO). 같은 번들로 다시 구우면 갱신된다. */ + /** 이 번들을 마지막으로 깐 시각(ISO). */ at: string; /** assets/ 기준 상대 경로. */ files: string[]; @@ -849,32 +610,15 @@ function listRelativeFiles(dir: string, prefix = ''): string[] { return found; } -/** - * 발행본이 **지금 실제로 참조하고 있는** 자산. 여기 들어오면 절대 지우지 않는다. - * - * ★ 왜 보관 기간만으로는 부족한가 — `out/s/` 에는 **payload 가 없는 사이트**가 있다(목업). - * 그건 재굽기 대상이 아니다. 프리렌더는 payload 를 받아 그 사이트만 굽고, payload 가 없는 - * 디렉토리는 쳐다보지도 않는다 — 그래서 자산이 한 번 지워지면 **영영 복구되지 않는다.** - * 재굽기를 몇 번을 돌려도 살아나지 않고, 사람이 파일을 손으로 되돌려 넣어야 한다. - * - * 실측(2026-09-07): `/s/stay` · `/s/stay2` · `/s/stay3` 가 번들 해시가 바뀐 순간 - * CSS·JS·이미지 전부 404 가 됐다. 보관 기간(30일)으로는 못 막는다 — 기간이 지나면 - * 똑같은 일이 난다. **참조가 살아 있는 한 남긴다** 가 유일하게 맞는 규칙이다. - * - * ★ 사이트를 굽기 전에 부른다. 그래야 이번에 다시 굽지 않는 사이트의 옛 참조가 잡힌다. - * ★ 공개 자리(`out/s/<slug>`)뿐 아니라 `out/versions/<slug>/<옛 버전>/` 도 훑는다 — 롤백 - * 대상으로 보관 중인 버전(pruneOldVersions 가 아직 안 지운 것)이 가리키는 번들도 살려야 - * 링크만 돌려 롤백할 때 CSS·JS 가 404 가 안 난다. - */ +/** 발행본이 **지금 실제로 참조하고 있는** 자산. */ function isDirLike(parent: string, entry: {name: string; isDirectory(): boolean; isSymbolicLink(): boolean}): boolean { - // `out/s/<slug>` 는 심볼릭 링크다(publishVersion). readdirSync 의 Dirent 는 링크 자체의 - // 타입만 보고하므로, 대상이 디렉토리인지는 statSync 로 한 번 더 확인해야 한다. + // `out/s/<slug>` 는 심볼릭 링크다(publishVersion). if (entry.isDirectory()) return true; if (!entry.isSymbolicLink()) return false; try { return statSync(join(parent, entry.name)).isDirectory(); } catch { - return false; // 끊어진 링크 — 대상이 지워졌다. + return false; } } @@ -918,18 +662,12 @@ function readAssetLedger(assetsDir: string): AssetBuild[] { }; return Array.isArray(parsed.builds) ? parsed.builds : []; } catch { - // 없거나 깨졌으면 빈 대장으로 시작한다. 디스크에 있던 파일은 pruneAssets 가 - // "처음 본 것" 으로 입양하므로 지워지지 않는다 — 그 ★ 주석이 이 실패의 근거다. + // 없거나 깨졌으면 빈 대장으로 시작한다. return []; } } -/** - * 보관 기간이 지난 옛 해시 파일만 지운다. - * - * ★ 발행할 때마다 이 함수가 돈다(사이트 하나만 구울 때도). 번들이 그대로면 대장에 줄이 - * 늘지 않고 맨 앞 줄의 시각만 갱신된다 — 안 그러면 발행 횟수만큼 대장이 자란다. - */ +/** 보관 기간이 지난 옛 해시 파일만 지운다. */ function pruneAssets(assetsDir: string, current: string[], referenced: Set<string>) { const signature = (files: string[]) => [...files].sort().join('\n'); const now = new Date().toISOString(); @@ -940,15 +678,7 @@ function pruneAssets(assetsDir: string, current: string[], referenced: Set<strin ? [head, ...previous.slice(1)] : [head, ...previous]; - /** - * ★ 대장에 없는 파일은 **지우지 않는다.** 언제 깔렸는지 모를 뿐이므로 지금 처음 본 것으로 - * 치고 보관 기간을 새로 준다. - * - * 이 줄이 없어서 실제로 운영 사이트가 끊겼다(2026-09-07). 대장은 이 기능과 함께 생겼으니 - * **배포 직후 첫 실행에는 대장이 없다** — 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" - * 으로 분류돼 한꺼번에 삭제됐고, 아직 다시 굽지 않은 사이트의 CSS 가 통째로 404 가 됐다. - * 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. - */ + /** 대장에 없는 파일은 **지우지 않는다.** 언제 깔렸는지 모를 뿐이므로 지금 처음 본 것으로 치고 보관 기간을 새로 준다. */ const recorded = new Set(builds.flatMap((build) => build.files)); const adopted = listRelativeFiles(assetsDir).filter( (file) => file !== ASSET_LEDGER && !recorded.has(file), @@ -966,7 +696,7 @@ function pruneAssets(assetsDir: string, current: string[], referenced: Set<strin let removed = 0; for (const file of listRelativeFiles(assetsDir)) { - // ★ 참조가 살아 있으면 기간과 무관하게 남긴다 — referencedAssets 주석 참조. + // 참조가 살아 있으면 기간과 무관하게 남긴다 — referencedAssets 주석 참조. if (file === ASSET_LEDGER || alive.has(file) || referenced.has(file)) continue; rmSync(join(assetsDir, file), {force: true}); removed += 1; @@ -982,13 +712,7 @@ function pruneAssets(assetsDir: string, current: string[], referenced: Set<strin } } -/** - * 클라이언트 번들과 public/ 을 대상 디렉토리에 깐다. - * - * ★ assets/ 를 통째로 지우고 다시 깔지 않는다(예전 구현). 옛 해시 파일은 보관 기간까지 - * 남겨야 한다 — 근거는 ASSET_RETENTION_DAYS 주석. 권한 때문에 통째로 지웠던 것인데, - * copyDirectoryFiles 가 **파일마다** 먼저 rmSync 하므로 그 문제는 그대로 해결된다. - */ +/** 클라이언트 번들과 public/ 을 대상 디렉토리에 깐다. */ function writeSharedAssets(destRoot: string, referenced: Set<string>) { const assetsSrc = join(CLIENT_DIR, 'assets'); if (existsSync(assetsSrc)) { @@ -1002,34 +726,8 @@ function writeSharedAssets(destRoot: string, referenced: Set<string>) { } } -/** - * 오리진 루트의 `robots.txt` 와 사이트맵 인덱스. - * - * ★ 왜 필요한가 - * 크롤러는 robots.txt 를 **오리진 루트에서만** 읽는다(RFC 9309). 발행 사이트는 - * `<host>/s/<slug>/` 아래라, 지금까지 구워 온 사이트별 robots.txt 는 한 번도 읽힌 적이 없다 — - * AI 크롤러 명시 허용도, `Sitemap:` 지시도 전달되지 않았다. 사이트맵은 만들어 두고 - * 그 존재를 알릴 방법이 없었으니 크롤러가 사이트를 찾아올 경로 자체가 없었다. - * - * ★ 왜 이번 실행에 온 payload 가 아니라 출력 디렉토리를 훑는가 - * 발행은 **바뀐 사이트 하나만** 굽는다(scripts/watch-payloads.mjs). 이번 실행분만 인덱스에 - * 담으면 나머지 사이트가 인덱스에서 사라진다. 디스크에 실제로 존재하는 발행본이 곧 정답이다. - */ -/** - * 빌더 미리보기가 iframe 으로 띄우는 CSR 셸. - * - * ★ 왜 iframe 인가 (2026-09-09) - * 빌더 안에 발행본 컴포넌트를 **직접** 그려 봤는데, 색·서체·섹션 구성이 같아져도 - * **레이아웃 폭이 어긋났다.** 미디어 쿼리는 창 폭을 보는데 미리보기의 실제 사이트 폭은 - * 그 안의 프레임(max-w-5xl)이기 때문이다. 실측(1400px 창 · 1024px 프레임): - * festival 2560px → 6027px, guide 1168 → 2168, location 586 → 1135. - * 내용(글자 수)은 완전히 같은데 그리드 컬럼 수만 달라 두 배씩 길어졌다. - * iframe 은 자체 뷰포트를 가져서 미디어 쿼리가 발행본과 **정확히 같은 폭**을 본다. - * PC/태블릿/모바일 전환도 iframe 폭만 바꾸면 그대로 맞는다. - * - * ★ payload 는 셸이 아니라 브라우저가 가져온다(`entry-client`). 굽는 시점에는 - * 어느 사업장을 미리 볼지 모르고, 미리보기는 **발행 전 최신 값**을 봐야 한다. - */ +/** 오리진 루트의 `robots.txt` 와 사이트맵 인덱스. */ +/** 빌더 미리보기가 iframe 으로 띄우는 CSR 셸. */ function writePreviewShell(outRoot: string, assets: {script: string; css: string[]}) { const lines = [ '<!doctype html>', @@ -1037,7 +735,7 @@ function writePreviewShell(outRoot: string, assets: {script: string; css: string ' <head>', ' <meta charset="utf-8" />', ' <meta name="viewport" content="width=device-width, initial-scale=1" />', - // ★ 미리보기는 색인 대상이 아니다. 발행 전 값이라 검색에 걸리면 안 된다. + // 미리보기는 색인 대상이 아니다. ' <meta name="robots" content="noindex, nofollow" />', ' <title>미리보기', ...assets.css.map((file) => ` `), @@ -1054,20 +752,7 @@ function writePreviewShell(outRoot: string, assets: {script: string; css: string console.log(' ✓ 빌더 미리보기 셸 (/preview)'); } -/** - * 이미 구워져 있는 사이트에서 오리진을 알아낸다. 한 장도 없으면 빈 문자열. - * - * ★ payload 를 읽지 않는 경로(`--seed-assets` 기동)에서 루트 색인 파일을 다시 쓰려면 - * 오리진이 필요한데, 그 값은 payload 에만 있다. 디스크에 있는 페이지가 자기 canonical 로 - * 선언해 둔 것을 쓴다(readBakedOrigin 주석). - * ★ **가장 최근에 구워진 페이지**의 오리진을 쓴다 — 처음 만난 것을 쓰면(readdirSync 순서는 - * 보장이 없다) 호스트를 옮긴 뒤 한 번도 재발행 안 한 옛 사이트(목업 포함) 하나가 걸릴 때 - * 전체 sitemap.xml 이 죽은 옛 호스트로 통째로 구워진다. 실측(2026-09-18): SITE_PUBLIC_HOST - * 를 web4ai.o2osolution.ai 로 옮긴 지 오래인데, solution-worker 가 재시작(배포)될 때마다 - * 이 함수가 옛 호스트(w4ai.o2o.kr)로 구운 사이트 하나를 집어 사이트맵 전체가 죽은 주소로 - * 덮였다 — 오늘 새로 발행한 사이트까지 사이트맵에서 옛 호스트로 나갔다. 갓 구운 페이지일수록 - * 지금 SITE_PUBLIC_HOST 를 반영했을 확률이 높다. - */ +/** 이미 구워져 있는 사이트에서 오리진을 알아낸다. */ function findBakedOrigin(outRoot: string): string { const sitesDir = join(outRoot, SITE_DIR); if (!existsSync(sitesDir)) return ''; @@ -1089,41 +774,31 @@ function writeRootMachineFiles(outRoot: string, origin: string) { if (!existsSync(sitesDir)) return; const sites: DirectoryEntry[] = readdirSync(sitesDir, {withFileTypes: true}) - // `out/s/` 는 이제 버전 디렉토리를 가리키는 심볼릭 링크다(publishVersion) — 링크 - // 자체는 isDirectory() 가 false 이므로 isDirLike 로 대상까지 확인해야 목록에서 안 빠진다. + // `out/s/` 는 이제 버전 디렉토리를 가리키는 심볼릭 링크다(publishVersion) — 링크 자체는 isDirectory() 가 false 이므로 isDirLike 로 대상까지 확인해야 목록에서 안 빠진다. .filter((entry) => isDirLike(sitesDir, entry)) .map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')})) - // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다. + // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. .filter((entry) => existsSync(entry.file)) // 제목·lastmod·noindex 가 같은 HTML 에서 나온다 — 파일은 한 번만 읽는다. .map((entry) => ({...entry, html: readFileSync(entry.file, 'utf-8')})) - // ★ noindex 를 선언한 페이지(목업·백업)는 싣지 않는다 — readBakedNoindex 주석 참조. + // noindex 를 선언한 페이지(목업·백업)는 싣지 않는다 — readBakedNoindex 주석 참조. .filter((entry) => !readBakedNoindex(entry.html)) .map(({html, ...entry}) => { return { - // ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/` 다(shared/lib/slug.ts - // publishUrl). 사이트맵이 `/s//` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을 - // 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은 - // 0건으로 보이는, 눈으로 원인을 못 찾는 종류다. + // 끝 슬래시를 붙이지 않는다. loc: joinUrl(origin, SITE_DIR, entry.slug), title: readBakedTitle(html) || entry.slug, - // ★ mtime 으로 떨어지는 건 dateModified 메타가 없던 시절의 산출물뿐이다. - // 그 사이트를 한 번 다시 구우면 제 값이 들어온다(readBakedLastmod 주석 참조). + // mtime 으로 떨어지는 건 dateModified 메타가 없던 시절의 산출물뿐이다. lastmod: readBakedLastmod(html) ?? statSync(entry.file).mtime.toISOString(), }; }) .sort((a, b) => a.loc.localeCompare(b.loc)); - // ★ `/s` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 — - // 사이트맵만 있을 때 서치콘솔은 "참조 페이지: 감지된 페이지 없음" 이라고 답했다. - // ★ 끝 슬래시를 붙이지 않는다 — 슬러그 페이지(`/s/`)와 같은 형태여야 한다. - // nginx 가 `location = /s` 로 이 파일을 직접 주고 `/s/` 는 여기로 301 한다 - // (nginx/site.conf). 그 블록이 없으면 `/s` 는 빌더 SPA 셸을 200 으로 내준다. + // `/s` 목록 페이지. const indexUrl = joinUrl(origin, SITE_DIR); writeFileSync(join(sitesDir, 'index.html'), renderSiteIndex(origin, indexUrl, sites), 'utf-8'); - // 사이트맵에는 랜딩·목록 페이지도 담는다. 랜딩은 이 호스트의 첫 페이지이고, - // 목록은 발행본 전부로 이어지는 허브다 — 둘 다 크롤러가 먼저 열어야 하는 자리다. + // 사이트맵에는 랜딩·목록 페이지도 담는다. const entries: SiteEntry[] = [{loc: origin + '/'}, {loc: indexUrl}, ...sites]; writeFileSync(join(outRoot, 'robots.txt'), renderRootRobotsTxt(origin), 'utf-8'); @@ -1133,19 +808,11 @@ function writeRootMachineFiles(outRoot: string, origin: string) { writeIndexNowKey(outRoot); } -/** - * IndexNow 키 파일 — `https:///.txt` 에 키 문자열만 들어 있다. - * - * ★ 이게 없으면 백엔드의 통보가 403 으로 거절된다. 검색엔진은 통보를 받으면 - * 이 URL 을 열어 같은 키가 있는지 보고, 그걸로 "이 호스트를 제어하는 쪽이 보냈다"를 확인한다. - * 비밀이 아니다 — 공개되어야 작동하는 값이다. - * - * ★ 백엔드와 **같은 env 를 본다**. 키를 두 군데 적으면 어긋나는 날 통보가 조용히 다 막힌다. - */ +/** IndexNow 키 파일 — `https:///.txt` 에 키 문자열만 들어 있다. */ function writeIndexNowKey(outRoot: string) { const key = (process.env.INDEXNOW_KEY ?? '').trim(); if (!key) return; - // 규격: 8~128자, 영문·숫자·하이픈. 어긋나면 굽지 않는다(잘못된 파일이 있으면 원인 찾기가 더 어렵다). + // 규격: 8~128자, 영문·숫자·하이픈. if (!/^[A-Za-z0-9-]{8,128}$/.test(key)) { console.warn(' ! INDEXNOW_KEY 형식이 규격(8~128자 영문·숫자·하이픈)에 맞지 않아 건너뛴다'); return; @@ -1168,24 +835,12 @@ async function main() { return; } - /* - * ★ 자산만 시딩(dev 편의) — **굽지 않는다.** 개발 컨테이너(solution-frontend --profile dev)가 - * 기동할 때 `out/assets` · `out/fonts` · `public/` 을 채워 두는 용도다. HTML 을 하나도 - * 건드리지 않으므로 예전 refreshBakedAssets 와 달리 공개된 사이트의 내용·주소 참조에 - * 아무 영향이 없다 — 운영 워커(render_service.py)는 이 모드를 쓰지 않는다(BUILD 잡마다 - * 실제 payload 로 호출하고, 그 경로가 매번 writeSharedAssets 를 이미 돈다). - */ + /* 자산만 시딩(dev 편의) — **굽지 않는다.** 개발 컨테이너(solution-frontend --profile dev)가 기동할 때 `out/assets` · `out/fonts` · `public/` 을 채워 두는 용도다. */ if (args.seedAssets) { console.log(`[prerender] 공용 자산만 시딩 → ${args.out}`); writeSharedAssets(args.out, referencedAssets(args.out)); writePreviewShell(args.out, assets); - /* - * ★ 루트 색인(sitemap.xml · llms.txt · `/s` 목록)은 여기서도 다시 쓴다. - * 이 파일들은 **디스크의 `out/s/` 를 훑어** 만들어지므로 내용은 손대지 않는다 — - * 페이지 HTML 은 그대로고, 사라진 사이트가 목록에서 빠지고 남아 있는 사이트는 그대로다. - * 이게 없던 동안 내려간 사이트가 사이트맵에 계속 남았다: 페이지는 404 인데 구글은 - * 그 주소를 계속 크롤하고, `/s` 목록에는 열리지 않는 카드가 남았다(실측 2026-09-15). - */ + /* 루트 색인(sitemap.xml · llms.txt · `/s` 목록)은 여기서도 다시 쓴다. */ const bakedOrigin = findBakedOrigin(args.out); if (bakedOrigin) writeRootMachineFiles(args.out, bakedOrigin); console.log('[prerender] 완료'); @@ -1196,18 +851,17 @@ async function main() { console.log(`[prerender] 사이트 ${loaded.length}개 → ${args.out}`); - // ★ 굽기 **전에** 참조를 훑는다. 이번에 다시 굽지 않는 사이트(payload 가 없는 목업 포함)가 - // 무엇을 가리키고 있는지는 지금 디스크에 있는 HTML 만 안다. + // 굽기 **전에** 참조를 훑는다. const referenced = referencedAssets(args.out); - // ★ 공용 자산은 사이트를 굽기 전에 딱 한 번 깐다. 사이트마다 복사하던 걸 여기로 뺐다. + // 공용 자산은 사이트를 굽기 전에 딱 한 번 깐다. writeSharedAssets(args.out, referenced); let failed = 0; - /** 루트 기계용 파일을 쓸 오리진. 이 호스트의 사이트는 전부 같은 오리진을 쓴다. */ + /** 루트 기계용 파일을 쓸 오리진. */ let origin = ''; - /** 보고서용 슬러그. payload 를 못 읽었으면 파일명에서 얻는다(백엔드가 `.json` 으로 쓴다). */ + /** 보고서용 슬러그. */ const slugOf = (entry: LoadedPayload) => entry.payload?.site?.slug ?? (entry.file ? basename(entry.file, '.json') : ''); @@ -1238,13 +892,13 @@ async function main() { }; for (const entry of loaded) { - // 읽기·검증 단계에서 이미 실패한 건 굽지 않는다. 다른 사이트는 계속 간다. + // 읽기·검증 단계에서 이미 실패한 건 굽지 않는다. if (entry.error || !entry.payload) { fail(entry, entry.error ?? 'payload 를 읽지 못했습니다'); continue; } - // ★ 목업이 쓰는 슬러그는 굽지 않는다 — 구우면 손으로 만든 유일본을 덮는다(PROTECTED_SLUGS). + // 목업이 쓰는 슬러그는 굽지 않는다 — 구우면 손으로 만든 유일본을 덮는다(PROTECTED_SLUGS). if (PROTECTED_SLUGS.has(entry.payload.site.slug)) { fail( entry, @@ -1254,12 +908,10 @@ async function main() { } try { - // ★ 굽기 **전에** 사진을 우리 자리로 옮긴다 — 렌더·JSON-LD·og:image 가 전부 같은 - // payload 를 보므로, 여기서 주소를 바꿔 놓아야 한 벌로 맞는다. - // 이번 버전 전용 디렉토리에 내려받는다 — 공개 주소는 아직 안 건드린다. + // 굽기 **전에** 사진을 우리 자리로 옮긴다 — 렌더·JSON-LD·og:image 가 전부 같은 payload 를 보므로, 여기서 주소를 바꿔 놓아야 한 벌로 맞는다. const stagingDir = versionDir(args.out, entry.payload.site.slug, entry.payload.site.version); const savedReportPath = join(stagingDir, '.render-report.json'); - // 성공한 버전은 불변이다. 재시도·롤백이 옛 HTML과 번들을 다시 쓰지 않는다. + // 성공한 버전은 불변이다. if (existsSync(savedReportPath) && existsSync(join(stagingDir, 'index.html'))) { const saved = JSON.parse(readFileSync(savedReportPath, 'utf8')); if (!saved.ok || saved.siteId !== entry.payload.site.siteId) throw new Error('저장 버전 불일치'); @@ -1275,8 +927,7 @@ async function main() { const payload = result.payload; origin = origin || payload.site.origin; - // ★ publish=false(미리보기·재빌드)면 여기서 끝난다 — `out/versions/…` 에는 남지만 - // `out/s/` 는 손대지 않는다. BUILD 잡이 publish=false 로 넘긴 경우가 이 길이다. + // publish=false(미리보기·재빌드)면 여기서 끝난다 — `out/versions/…` 에는 남지만 `out/s/` 는 손대지 않는다. if (payload.site.publish && !args.stageOnly) { publishVersion(args.out, payload.site.slug, payload.site.version); pruneOldVersions(args.out, payload.site.slug, payload.site.version); @@ -1297,7 +948,7 @@ async function main() { siteVersion: payload.site.version, ok: true, renderedAt: new Date().toISOString(), - // 사이트당 한 장. 보고서 스키마는 백엔드가 읽으므로 필드는 남긴다. + // 사이트당 한 장. routes: 1, bundle: assets.script, uniqueContentCount: result.uniqueContentCount, @@ -1309,10 +960,7 @@ async function main() { writeReport(entry.file, report); } } catch (ex) { - // ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 — - // 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다. - // ★ 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다. 0 으로 적으면 - // 백엔드가 "고유 콘텐츠 0건" 으로 읽어 또 엉뚱한 사유를 붙인다. + // 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다. const counted = ex instanceof VerifyError || ex instanceof NoUniqueContentError ? ex : null; fail( entry, @@ -1323,8 +971,7 @@ async function main() { } } - // ★ 실패한 사이트가 있어도 루트 파일은 갱신한다 — 성공한 사이트까지 색인에서 빠질 이유가 없다. - // (인덱스는 디스크를 훑으므로 실패한 사이트는 애초에 들어가지 않는다.) + // 실패한 사이트가 있어도 루트 파일은 갱신한다 — 성공한 사이트까지 색인에서 빠질 이유가 없다. writePreviewShell(args.out, assets); if (origin) writeRootMachineFiles(args.out, origin); diff --git a/solution/site/scripts/serve-sites.mjs b/solution/site/scripts/serve-sites.mjs index 50e264d..e8390b6 100644 --- a/solution/site/scripts/serve-sites.mjs +++ b/solution/site/scripts/serve-sites.mjs @@ -1,13 +1,4 @@ -/** - * 발행 사이트 정적 서버. - * - * ★ python -m http.server 를 쓰지 않는 이유 - * `/s/mmg` (끝 슬래시 없음)로 들어오면 404 를 준다. 사장님이 주소창에 치는 형태가 그건데 - * 열리지 않으면 "발행했는데 안 나온다"가 된다. 여기서는 디렉토리면 index.html 로 넘긴다. - * - * ★ 이 서버는 개발용이다. 운영에서는 out/ 을 nginx·CDN 이 그대로 서빙한다 — - * 그때도 같은 규칙(디렉토리 → index.html)만 맞추면 된다. - */ +/** 발행 사이트 정적 서버. */ import {createReadStream, existsSync, statSync} from 'node:fs'; import {createServer} from 'node:http'; import {dirname, extname, join, normalize, resolve} from 'node:path'; diff --git a/solution/site/scripts/watch-payloads.mjs b/solution/site/scripts/watch-payloads.mjs index 7b86c0d..888dc62 100644 --- a/solution/site/scripts/watch-payloads.mjs +++ b/solution/site/scripts/watch-payloads.mjs @@ -1,29 +1,4 @@ -/** - * payload 감시 → 자동 프리렌더. - * - * ★ 왜 필요한가 - * 발행 잡은 payload JSON 까지만 만든다(backend/services/site_payload). 그 뒤 HTML 로 굽는 - * 단계가 수동이라, 사장님이 [발행]을 눌러도 사이트가 없었다 — [사이트 열기] 가 404 였다. - * 검색 → 크롤링 → 발행 → **사이트 이동** 이 끊기는 유일한 자리가 여기다. - * - * ★ 왜 백엔드 워커가 직접 안 굽나 - * 굽는 데 Node 와 이 프로젝트의 의존성이 필요하다. 파이썬 컨테이너에 Node 를 넣으면 - * 백엔드 이미지가 프론트 빌드 도구를 떠안는다. 대신 payload 디렉토리를 사이에 두고 - * 따로 도는 프로세스가 읽는다 — 백엔드는 파일만 쓰고, 여기는 파일만 본다. - * - * ★ 바뀐 사이트만 굽는다 - * 예전에는 payload 디렉토리를 통째로 넘겨서, 한 명이 발행하면 발행된 사이트 전부를 - * 다시 구웠다(게다가 매번 vite 클라이언트 번들까지 새로 만들었다). 사이트가 늘면 - * 그대로 못 쓴다. 지금은 mtime 이 바뀐 payload 만 골라 넘기고, 번들은 기동 때 한 번만 만든다. - * - * ★ 실패를 삼키지 않는다 - * 프리렌더가 깨지면 DB 에는 "발행됨"인데 페이지는 없는 상태가 된다. 실패한 payload 는 - * 백오프를 두고 다시 시도하고, 소진되면 경고로 남긴다. 결과 보고서는 프리렌더가 - * payloads/.status/.json 에 쓴다(백엔드가 그걸 읽는다). - * - * 실행: npm run watch (개발) - * node scripts/watch-payloads.mjs --once (한 번만) - */ +/** payload 감시 → 자동 프리렌더. */ import {spawn} from 'node:child_process'; import {existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync} from 'node:fs'; import {dirname, join, resolve} from 'node:path'; @@ -36,11 +11,11 @@ const PRERENDER_JS = join(ROOT, 'dist', 'prerender', 'prerender.js'); const SITES_DIR = join(ROOT, 'out', 's'); const ONCE = process.argv.includes('--once'); -/** 폴링 간격. 발행은 분 단위 작업이라 2초 지연은 문제가 되지 않는다. */ +/** 폴링 간격. */ const POLL_MS = 2000; -/** 실패한 payload 재시도 횟수. 소진되면 경고만 남기고 다음 변경을 기다린다. */ +/** 실패한 payload 재시도 횟수. */ const MAX_ATTEMPTS = 3; -/** 재시도 백오프(회차별 ms). 디스크 순단·부분 기록 같은 일시 실패를 흡수한다. */ +/** 재시도 백오프(회차별 ms). */ const BACKOFF_MS = [5000, 20000]; function log(msg) { @@ -51,12 +26,7 @@ function warn(msg) { console.error(`[watch ${new Date().toTimeString().slice(0, 8)}] ${msg}`); } -/** - * 자식 프로세스 하나를 돌리고 `{code, stderr}` 를 돌려준다. - * - * ★ stderr 를 흘려보내면서 동시에 모은다. 화면에는 지금까지처럼 그대로 나가야 하고 - * (개발자가 보는 것), 실패 보고서에는 사유가 실려야 한다(백엔드가 읽는 것). - */ +/** 자식 프로세스 하나를 돌리고 `{code, stderr}` 를 돌려준다. */ function run(command, args, label) { return new Promise((done) => { log(`${label} 시작`); @@ -64,7 +34,7 @@ function run(command, args, label) { let stderr = ''; child.stderr.on('data', (chunk) => { process.stderr.write(chunk); - // 사유는 앞부분에 나온다. 스택 전체를 들고 있을 이유가 없다. + // 사유는 앞부분에 나온다. if (stderr.length < 4000) stderr += chunk.toString(); }); child.on('close', (code) => { @@ -79,23 +49,7 @@ function run(command, args, label) { }); } -/** - * 프리렌더가 **자기 보고서를 쓰지도 못하고 죽었을 때** 대신 실패를 남긴다. - * - * ★ 왜 필요한가 - * 프리렌더는 사이트별 실패를 스스로 `.status/.json` 에 적는다. 그런데 프로세스가 - * 사이트를 하나도 돌기 전에 죽으면(의존성 누락·번들 오류) 보고서가 아예 안 생긴다. - * 그러면 백엔드 BUILD 잡은 180초를 꼬박 기다린 뒤 "결과를 못 받았다"로만 실패한다 — - * 진짜 사유(예: Cannot find package 'embla-carousel-react')는 컨테이너 로그에만 남고 - * 사장님 화면에도, 발행 기록에도 안 나타난다. 실제로 그 상태로 47분간 모든 사이트의 - * 재발행이 조용히 죽어 있었다. - * - * ★ 재시도가 소진된 뒤에만 쓴다. 첫 실패에 바로 쓰면 백엔드가 그 보고서를 집어가서 - * 재시도가 성공해도 이미 늦는다 — 백오프(5s+20s)는 180초 안에 끝나므로 여유가 있다. - * - * ★ siteVersion 을 payload 에서 읽어 싣는다. 백엔드는 **버전이 맞는 보고서만** 받으므로 - * (backend/services/render_report.wait_for) 버전이 없으면 이 보고서는 무시된다. - */ +/** 프리렌더가 **자기 보고서를 쓰지도 못하고 죽었을 때** 대신 실패를 남긴다. */ function writeFailureReport(file, error) { let payload; try { @@ -128,52 +82,30 @@ function writeFailureReport(file, error) { } } -/** - * 클라이언트 번들 + 프리렌더 번들을 만든다. **기동 때 한 번만** 부른다. - * - * ★ payload 가 바뀌었다고 번들을 다시 만들 이유가 없다. 데이터만 바뀌었고 코드는 그대로다. - * 코드가 바뀌면 컨테이너가 다시 뜨고, 그때 여기를 지난다. - */ -/** - * 클라이언트 번들 → 프리렌더 번들. 앞이 실패하면 뒤는 돌리지 않는다. - * - * ★ run() 은 `{code, stderr}` 를 돌려준다. 예전에는 이 값을 숫자로 알고 `code === 0` 으로 - * 비교했는데, 객체는 0 과 절대 같지 않아서 **프리렌더 번들이 한 번도 실행되지 않았다.** - * 기동 때마다 "번들 빌드 실패" 로 끝났고, 그래서 DEPLOY.md 가 약속한 '기동 시 전체 - * 재굽기'가 실제로는 일어나지 않았다. - */ +/** 클라이언트 번들 + 프리렌더 번들을 만든다. */ +/** 클라이언트 번들 → 프리렌더 번들. */ async function buildBundles() { const client = await run('npm', ['run', 'build:client'], '클라이언트 번들'); if (client.code !== 0) return client; return run('npm', ['run', 'build:prerender'], '프리렌더 번들'); } -/** payload 파일들을 굽는다. 빈 배열이면 아무것도 하지 않는다. - * ★ 호출부가 `{code, stderr}` 를 구조분해하므로 빈 경우에도 같은 모양을 돌려준다 — - * 숫자 0 을 돌려주면 code 가 undefined 가 되어 성공이 실패로 읽힌다. */ +/** payload 파일들을 굽는다. */ function prerender(files, reason) { if (files.length === 0) return Promise.resolve({code: 0, stderr: ''}); const label = `프리렌더 ${files.length}개 — ${reason}`; return run('node', [PRERENDER_JS, ...files.map((file) => `--payload=${file}`)], label); } -/** - * 기동 때 공용 자산(out/assets · out/fonts · public/)만 채워 둔다. **굽지 않는다.** - * - * ★ 발행 버전 시스템(2026-09-15)으로 바뀌면서 "이미 구워진 HTML 의 자산 주소만 갈아 끼우는" - * 예전 방식(refreshBakedAssets)은 없어졌다 — 버전마다 자기 디렉토리에 굽고 공개 심볼릭 - * 링크(`out/s/`)는 발행할 때만 돈다(prerender.ts publishVersion). 그래서 기동 시 - * 할 일은 아직 하나도 안 구워진 payload 를 굽는 것과, 그 전에 공용 자산을 한 번 깔아 - * 두는 것뿐이다 — HTML 은 하나도 건드리지 않는다. - */ +/** 기동 때 공용 자산(out/assets · out/fonts · public/)만 채워 둔다. */ function seedAssets() { return run('node', [PRERENDER_JS, '--seed-assets'], '공용 자산 시딩 — 기동'); } // ── 큐 ──────────────────────────────────────────────────────────────────── -/** 굽기를 기다리는 payload 경로. 굽는 동안 들어온 변경은 여기에 쌓였다가 이어서 돈다. */ +/** 굽기를 기다리는 payload 경로. */ const pending = new Set(); -/** payload 경로 → 지금까지 실패한 횟수. 성공하면 지운다. */ +/** payload 경로 → 지금까지 실패한 횟수. */ const attempts = new Map(); let running = false; @@ -196,16 +128,13 @@ async function drain() { continue; } - // ★ 배치가 실패하면 어느 사이트가 깨졌는지는 보고서(.status/.json)에 남는다. - // 여기서는 배치 전체를 재시도한다 — 프리렌더는 사이트별로 실패를 격리하므로 - // 이미 성공한 사이트를 다시 구워도 결과는 같다(멱등). + // 배치가 실패하면 어느 사이트가 깨졌는지는 보고서(.status/.json)에 남는다. for (const file of batch) { const tried = (attempts.get(file) ?? 0) + 1; attempts.set(file, tried); if (tried >= MAX_ATTEMPTS) { warn(`${basenameOf(file)} — ${tried}회 실패, 재시도를 멈춥니다.`); - // 프리렌더가 자기 보고서를 못 남기고 죽었을 수 있다 — 그러면 백엔드가 180초를 - // 헛기다린 뒤 사유 없이 실패한다. 여기서 사유를 실어 남긴다(writeFailureReport 주석 참조). + // 프리렌더가 자기 보고서를 못 남기고 죽었을 수 있다 — 그러면 백엔드가 180초를 헛기다린 뒤 사유 없이 실패한다. writeFailureReport(file, stderr.trim()); continue; } @@ -223,12 +152,12 @@ function basenameOf(file) { return file.slice(file.lastIndexOf('/') + 1); } -/** 그 payload 가 이미 구워져 있나. 파일명이 슬러그다(백엔드가 `.json` 으로 쓴다). */ +/** 그 payload 가 이미 구워져 있나. */ function bakedIndexOf(file) { return join(SITES_DIR, basenameOf(file).replace(/\.json$/, ''), 'index.html'); } -/** payload 디렉토리의 *.json 목록. 백엔드가 rename 전에 쓰는 임시파일(.tmp)은 건너뛴다. */ +/** payload 디렉토리의 *.json 목록. */ function listPayloads() { if (!existsSync(PAYLOAD_DIR)) return []; return readdirSync(PAYLOAD_DIR) @@ -247,14 +176,12 @@ async function main() { return; } - // ★ 기동 시 전부 굽지 않는다(seedAssets 주석). 공용 자산만 깔아 두고, 내용은 - // 사장님이 다시 발행할 때(또는 아래 "아직 안 구워진 것") 새 렌더러로 구워진다. + // 기동 시 전부 굽지 않는다(seedAssets 주석). const all = listPayloads(); const seen = new Map(all.map((file) => [file, statSync(file).mtimeMs])); await seedAssets(); - // 아직 한 번도 안 구워진 payload 는 굽는다 — 감시가 꺼져 있는 동안 발행됐거나 볼륨이 - // 비어 있던 경우다. 사이트가 아예 없는 것과 "옛 내용으로 서 있는 것" 은 다른 문제다. + // 아직 한 번도 안 구워진 payload 는 굽는다 — 감시가 꺼져 있는 동안 발행됐거나 볼륨이 비어 있던 경우다. const unbuilt = all.filter((file) => !existsSync(bakedIndexOf(file))); await prerender(unbuilt, '아직 안 구워진 것'); @@ -262,18 +189,14 @@ async function main() { log(`감시 중: ${PAYLOAD_DIR} (바뀐 payload 만 굽습니다)`); - /** - * ★ fs.watch 를 쓰지 않는다. Docker 볼륨(bind mount)을 통해 들어온 변경은 macOS 에서 - * inotify/FSEvents 이벤트가 오지 않는 경우가 있다 — 발행해도 아무 일이 안 일어난다. - * 폴링은 느리지만 확실하다. - */ + /** fs.watch 를 쓰지 않는다. */ setInterval(() => { for (const file of listPayloads()) { let mtime; try { mtime = statSync(file).mtimeMs; } catch { - continue; // 폴링과 rename 이 겹친 순간. 다음 틱에 다시 본다. + continue; // 폴링과 rename 이 겹친 순간. } if (seen.get(file) === mtime) continue; seen.set(file, mtime); diff --git a/solution/site/src/entry-client.tsx b/solution/site/src/entry-client.tsx index 978f964..89e1619 100644 --- a/solution/site/src/entry-client.tsx +++ b/solution/site/src/entry-client.tsx @@ -14,7 +14,6 @@ declare global { const container = document.getElementById('root')!; const injected = window.__SITE_PAYLOAD__; -// 빌더에 그림이 다 그려졌다고 알린다. iframe load는 빈 셸이 뜬 시점이라 쓸 수 없다. function signalPreviewPainted(ok: boolean) { if (window.parent === window) return; const post = () => window.parent.postMessage({type: 'o2o:preview-painted', ok}, window.location.origin); diff --git a/solution/site/src/entry-server.tsx b/solution/site/src/entry-server.tsx index 7c565f8..711c3f6 100644 --- a/solution/site/src/entry-server.tsx +++ b/solution/site/src/entry-server.tsx @@ -3,14 +3,7 @@ import {renderToString} from 'react-dom/server'; import type {SitePayload} from '@o2o/shared'; import {App} from './App'; -/** - * SSR 엔트리. prerender 스크립트가 이 함수를 불러 HTML 을 얻는다. - * - * ★ renderToString 을 쓴다(스트리밍이 아니라). 파일로 굽는 게 목적이라 - * 전체 문자열이 한 번에 필요하고, 스트리밍의 이점이 없다. - * - * ★ 사이트가 한 장이라 라우터도 url 인자도 없다. 섹션 이동은 앵커다. - */ +/** SSR 엔트리. */ export function render(payload: SitePayload): string { return renderToString( diff --git a/solution/site/src/fixtures/moonlight-stay.ts b/solution/site/src/fixtures/moonlight-stay.ts index 75676c5..fec082a 100644 --- a/solution/site/src/fixtures/moonlight-stay.ts +++ b/solution/site/src/fixtures/moonlight-stay.ts @@ -9,18 +9,11 @@ import { type SitePayload, } from '@o2o/shared'; -/** - * 데모 payload — "달빛스테이 제주"(숙박). - * - * 백엔드 sites API 가 붙기 전까지 프리렌더와 개발 서버가 먹는 입력이다. - * ★ 일부러 `pickup_service` 한 건을 UNVERIFIED 로 남겨 두었다. - * 확인 안 된 값이 화면·JSON-LD·llms.txt 어디에도 안 나가는지 눈으로 확인하는 용도다. - * 전부 VERIFIED 인 fixture 로는 그 규칙이 지켜지는지 알 수 없다. - */ +/** 데모 payload — "달빛스테이 제주"(숙박). */ const UPDATED_AT = '2026-08-27T02:00:00+09:00'; -/** fact 한 건을 짧게 만드는 헬퍼. 기본값은 "확인됨". */ +/** fact 한 건을 짧게 만드는 헬퍼. */ function fact( key: string, label: string, @@ -67,7 +60,7 @@ const PLACE_FACTS: FactEntry[] = [ '30000', {type: 'number', unit: '원', critical: true}, ), - // ★ 확인 전 — 화면에도, JSON-LD 에도, llms.txt 에도 나오면 안 된다. + // 확인 전 — 화면에도, JSON-LD 에도, llms.txt 에도 나오면 안 된다. fact('pickup_service', '픽업 서비스', 'true', { type: 'bool', status: FactStatus.UNVERIFIED, @@ -390,7 +383,7 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = { confirmed: true, }, { - // ★ 확정 전 — sameAs 와 푸터 어디에도 나오면 안 된다. + // 확정 전 — sameAs 와 푸터 어디에도 나오면 안 된다. channel: LinkChannel.YANOLJA, url: 'https://www.yanolja.com/pension/0000000', title: '야놀자', @@ -507,14 +500,7 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = { }, ], - /** - * 노래는 비워 둔다. - * - * ★ 이 픽스처의 목적은 "payload 가 이러이러할 때 화면이 이렇게 나온다" 를 눈으로 보는 것이다. - * 노래는 발행 때 Suno 가 만들어 넣는 실제 파일을 가리키므로, 여기에 가짜 주소를 적으면 - * 개발 서버에서 **재생만 안 되는 버튼**이 생긴다. 비어 있을 때 플레이어가 아예 안 그려지는지 - * 확인하는 쪽이 이 픽스처가 할 일에 맞다. - */ + /** 노래는 비워 둔다. */ songs: [], narrative: { diff --git a/solution/site/src/index.css b/solution/site/src/index.css index b2810f9..be03d64 100644 --- a/solution/site/src/index.css +++ b/solution/site/src/index.css @@ -1,19 +1,9 @@ @import "tailwindcss"; @import "@o2o/shared/styles/base.css"; -/* 시안 토큰·유틸(타이포·.shell·.h2·.panel·슬라이더)은 캔버스와 한 파일을 쓴다. - ★ 값을 두 곳에 적으면 한쪽만 고쳐지고 에디터와 발행본이 다시 갈라진다. */ +/* 시안 토큰·유틸(타이포·.shell·.h2·.panel·슬라이더)은 캔버스와 한 파일을 쓴다. */ @import "@o2o/shared/styles/site.css"; -/* - * 발행 사이트의 디자인 토큰. - * - * 색·서체·모서리·테두리·여백은 전부 사장님이 고른 템플릿에서 온다. 프리렌더가 에 - * `--tpl-*` 를 심고(`seo/head.ts` themeStyle) 여기서 Tailwind 토큰으로 연결한다. - * 관리자 토큰(tokens.css)은 이 앱에 들어오지 않는다 — 두 색 체계를 섞지 않는다. - * - * ★ 이 파일에 하드코딩된 색을 두지 않는다. 아래 :root 값은 주입이 없는 - * 개발 서버(`npm run dev:site`)용 폴백이다. - */ +/* 발행 사이트의 디자인 토큰. */ :root { --tpl-primary: #18181b; --tpl-secondary: #52525b; @@ -26,12 +16,7 @@ --tpl-inverse: #1c1917; --tpl-border: #e7e5e4; - /* - * 생김새 폴백 = '심플' 템플릿(admin `industryData.ts` 의 LOOK.simple). - * ★ 예전엔 서체 폴백이 명조였다. 발행 잡은 이제 항상 look 을 실어 보내지만 - * (backend `_DEFAULT_LOOK`), 개발 서버에는 주입이 없어 이 값이 곧 화면이 된다 — - * 폴백이 제품 기본값과 다르면 개발자가 보는 것과 손님이 보는 것이 갈린다. - */ + /* 생김새 폴백 = '심플' 템플릿(admin `industryData.ts` 의 LOOK.simple). */ --tpl-font-heading: 'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif; --tpl-font-body: 'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif; --tpl-radius: 0.75rem; @@ -53,28 +38,12 @@ --font-sans: 'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif; --font-serif: 'Noto Serif KR', 'Batang', 'Times New Roman', serif; - /* - * 선 색은 **둘레 글자색에서 뽑는다.** - * 예전에는 전 섹션이 `border-black/8` 이었다 — 어두운 바탕(푸터·필름·플립보드)에서 - * 검은 선은 그냥 안 보인다. currentColor 를 섞으면 밝은 면에서는 진해지고 - * 어두운 면에서는 밝아져, 어떤 팔레트에서도 선이 남는다. - * - * ★ 여기(@theme inline)에 있어야 하는 이유 — Tailwind 가 이 이름으로 - * `text-muted` · `border-line` · `divide-line` 유틸을 **만들어 준다.** - * 공유 파일(shared/styles/site.css)로 옮겼더니 유틸 생성이 끊겨, 마크업이 188곳에서 - * 쓰는 `text-muted` 와 101곳의 `border-line` 이 통째로 죽었다(실측 2026-09-09). - * 공유 파일에는 캔버스용 같은 값의 명시 정의가 따로 있다 — 값이 같으니 어느 쪽이 이겨도 된다. - */ --color-line: color-mix(in oklab, currentColor 14%, transparent); --color-line-soft: color-mix(in oklab, currentColor 8%, transparent); - /* 본문 보조 글자. opacity-60 을 반복해 쓰던 자리를 색 하나로 모은다. */ + /* 본문 보조 글자. */ --color-muted: color-mix(in oklab, currentColor 96%, transparent); - /* - * 모서리는 템플릿이 정한 값 하나에서 **비율로** 펼친다. - * 하나로 통일하면 칩과 카드가 같은 곡률이라 층이 안 보이고, - * 따로 박으면 레트로(0px)를 골라도 카드만 둥글게 남는다. - */ + /* 모서리는 템플릿이 정한 값 하나에서 **비율로** 펼친다. */ --radius-sm: calc(var(--tpl-radius, 0.75rem) * 0.4); --radius-md: calc(var(--tpl-radius, 0.75rem) * 0.7); --radius-lg: var(--tpl-radius, 0.75rem); @@ -85,7 +54,7 @@ html { scroll-behavior: smooth; - /* 고정 헤더에 앵커가 가리지 않게. 헤더 높이(4rem)+여유. */ + /* 고정 헤더에 앵커가 가리지 않게. */ scroll-padding-top: 5.5rem; } @@ -93,8 +62,7 @@ body { background-color: var(--color-surface); background-image: var(--tpl-texture, none); color: var(--color-ink); - /* ★ 본문 서체도 템플릿이 정한다(theme.look → seo/head.ts). 토큰이 없는 옛 payload 에서는 - 지금까지와 똑같이 Pretendard/Noto Sans KR 로 떨어진다. */ + /* 본문 서체도 템플릿이 정한다(theme.look → seo/head.ts). */ font-family: var(--tpl-font-body, var(--font-sans)); font-size: var(--fs-body); font-weight: 450; @@ -104,13 +72,12 @@ body { padding-bottom: env(safe-area-inset-bottom); } -/* 키보드 사용자에게만 보이는 초점 테두리. 마우스 클릭에는 안 뜬다. */ +/* 키보드 사용자에게만 보이는 초점 테두리. */ :focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } -/* 날씨 문구가 갈릴 때 접었다 편다(WeatherSection.tsx — key 로 다시 태워 재생시킨다). */ @keyframes w4-note-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } diff --git a/solution/site/src/lib/derive.ts b/solution/site/src/lib/derive.ts index 841352f..37b975b 100644 --- a/solution/site/src/lib/derive.ts +++ b/solution/site/src/lib/derive.ts @@ -20,22 +20,17 @@ import {UNIT_SPEC, bookingChannelLinks, primaryChannelLink, publicLinks, unitBas // 링크 거르기는 jsonld 에 산다(순환 의존 회피) — 화면 쪽은 늘 derive 를 통해 쓰므로 여기서 다시 낸다. export {publicLinks, primaryChannelLink}; -/** - * payload → 화면이 바로 쓰는 모양. - * - * 컴포넌트가 fact 배열을 직접 뒤지지 않게 한 층 둔다. 여기 한 곳에서만 - * selectPublishable() 을 통과시키므로, 컴포넌트가 실수로 미검증 값을 그릴 수가 없다. - */ +/** payload → 화면이 바로 쓰는 모양. */ export interface InfoRow { key?: string; label: string; value: string; - /** 부가 설명. 출처가 아니라 사장님이 붙인 보충 문구. */ + /** 부가 설명. */ note?: string; } -/** 사업장 단위 이용 정보 표. 확인된 것만 들어간다. */ +/** 사업장 단위 이용 정보 표. */ export function essentialRows(payload: SitePayload): InfoRow[] { return selectPublishable(payload.facts) .filter((fact) => fact.scope === 'place') @@ -48,7 +43,7 @@ export function essentialRows(payload: SitePayload): InfoRow[] { } function displayValue(fact: FactEntry, all: FactEntry[]): string { - // 두 이용 정보 영역은 같은 소개 요약을 쓴다. 상세 소개와 다른 사실은 원문을 보존한다. + // 두 이용 정보 영역은 같은 소개 요약을 쓴다. if (fact.key === 'intro' && fact.summary?.trim()) return fact.summary.trim(); const text = factText(all, fact.key); if (!text) return ''; @@ -70,12 +65,12 @@ export interface UnitView { rows: InfoRow[]; images: MediaItem[]; priceText?: string; - /** 주중 · 주말 · 성수기 요금. 숙박이 아니면 빈 배열이다. */ + /** 주중 · 주말 · 성수기 요금. */ prices: {label: string; value: string}[]; href: string; } -/** 객실 정보에서 내지 않는 fact. 요금은 요금 섹션 한 곳에서만 말한다. */ +/** 객실 정보에서 내지 않는 fact. */ const UNIT_PRICE_FACT_KEYS = new Set(['weekday_price', 'weekend_price', 'peak_price', 'price_range']); function dedupeRows(rows: InfoRow[]): InfoRow[] { @@ -96,8 +91,7 @@ export function unitViews(payload: SitePayload): UnitView[] { const intro = factText(unit.facts, 'room_intro') ?? factText(unit.facts, 'description'); const chipKeys = payload.place.category === PlaceCategory.LODGING - ? // ★ 기준 인원을 빼 놓았었다. 국내 숙박은 '기준 2인 / 최대 4인' 이 한 쌍이고, - // 최대만 적으면 4인이 기본요금인 줄 알고 예약했다가 추가요금에서 실랑이가 난다. + ? ['standard_capacity', 'max_capacity', 'bed_type', 'room_size'] : ['price', 'volume', 'origin']; @@ -113,12 +107,6 @@ export function unitViews(payload: SitePayload): UnitView[] { return value ? {label, value} : null; }) .filter((chip): chip is {label: string; value: string} => chip !== null), - /* - * ★ 요금 항목은 '자세히' 표에서도 뺀다 (2026-09-07, 사장님 지시: "객실 정보에서 가격 지우기") - * 카드 위의 요금 표만 걷어냈더니 접힌 표 안에 '주중 요금 198,000원' 이 그대로 남아 - * 지시가 반만 먹었다. 객실 정보에서 값은 **한 군데도** 나오지 않는다. - * `prices`·`priceText` 는 그대로 둔다 — 요금 섹션과 예약 안내가 계속 쓴다. - */ rows: dedupeRows( unit.facts .filter((fact) => !UNIT_PRICE_FACT_KEYS.has(fact.key)) @@ -137,20 +125,9 @@ export function unitViews(payload: SitePayload): UnitView[] { }); } -/** - * 카드에 얹는 "얼마부터". - * - * ★ 숫자를 여기서 고르지 않는다 — `unitBaseRate`(seo/jsonld.ts) 하나가 고른 값을 표기만 한다. - * 화면과 JSON-LD 가 각자 계산하면 어긋날 수 있고, 어긋나면 절대규칙 3 위반으로 발행이 막힌다. - */ +/** 카드에 얹는 "얼마부터". */ function unitPriceText(unit: UnitInfo): string | undefined { - /* - * ★ 예약 채널이 **범위로** 파는 방은 범위 그대로 낸다 (2026-09-04) - * 머뭄은 네이버에 "198,000 ~ 350,000원" 으로 걸려 있다. 최저가만 "198,000원부터" 로 - * 내면 손님이 그 값으로 알고 들어왔다가 결제 화면에서 두 배를 본다 — 채널이 말하는 - * 것과 우리가 말하는 것이 갈리면 안 된다. 주중/주말/성수기가 실제로 갈리는 가게는 - * 그쪽 세 칸을 쓰고, 범위만 아는 가게는 이 한 줄을 쓴다. - */ + /* 예약 채널이 **범위로** 파는 방은 범위 그대로 낸다 머뭄은 네이버에 "198,000 ~ 350,000원" 으로 걸려 있다. */ const range = factText(unit.facts, 'price_range'); if (range) return range; @@ -161,15 +138,7 @@ function unitPriceText(unit: UnitInfo): string | undefined { return `${base.toLocaleString('ko-KR')}원부터`; } -/** - * 요금 세 칸 — 주중 · 주말 · 성수기. - * - * ★ 없는 칸을 지우지 않고 '문의' 로 남긴다. 펜션에서 요금은 **비교하는 값**이라, - * 주중 하나만 적어 두면 손님은 주말이 얼마인지 모른 채 떠난다. 세 칸을 세워 두면 - * 비어 있다는 사실 자체가 "전화로 묻는 값" 이라는 안내가 된다. - * ★ 주중 요금이 아예 없으면 표를 만들지 않는다 — 음식점 메뉴(`price`)까지 세 칸이 서고 - * 전부 '문의' 가 되면 없느니만 못하다. - */ +/** 요금 세 칸 — 주중 · 주말 · 성수기. */ const UNIT_PRICE_KEYS = [ ['weekday_price', '주중'], ['weekend_price', '주말'], @@ -186,13 +155,7 @@ function unitPrices(unit: UnitInfo): {label: string; value: string}[] { })); } -/** - * 기상청 문구 → 넷 중 하나. - * - * ★ 조건 문자열은 출처마다 다르다(맑음 · 구름많음 · 흐림 · 비/눈 · 소나기 …). - * 화면이 갈라야 하는 건 **문장과 그림** 둘뿐이라 넷으로 줄인다. - * ★ 순서가 중요하다 — "비/눈" 같은 합성 표기에서 눈을 먼저 본다. - */ +/** 기상청 문구 → 넷 중 하나. */ export function weatherMood(condition: string | undefined): WeatherMood { const text = condition ?? ''; if (/눈|설/.test(text)) return '눈'; @@ -201,12 +164,7 @@ export function weatherMood(condition: string | undefined): WeatherMood { return '맑음'; } -/** - * 기온 → 기온대. 경계 근거는 shared `WeatherBand` 주석에 있다. - * - * ★ 기온은 브라우저에서 갱신된다(use-live-weather) — 그래서 굽는 순간의 기온대를 - * HTML 에 박지 않고 화면이 매번 고른다. `weatherMood` 와 같은 이유다. - */ +/** 기온 → 기온대. */ export function weatherBand(temperature: number): WeatherBand { if (temperature >= 30) return '혹서'; if (temperature >= 25) return '더움'; @@ -224,7 +182,7 @@ export function faqList(payload: SitePayload) { return selectPublishableFaqs(payload.faqs); } -/** 켜져 있는 섹션인지. 순서도 payload 가 정한다. */ +/** 켜져 있는 섹션인지. */ export function enabledSections(payload: SitePayload) { return payload.theme.sections.filter((section) => section.enabled); } @@ -233,34 +191,18 @@ export function isSectionEnabled(payload: SitePayload, id: string): boolean { return payload.theme.sections.find((section) => section.id === id)?.enabled ?? false; } -/** 사장님이 에디터에서 직접 쓴 섹션 본문. 빈 줄을 문단 경계로 쓴다. */ +/** 사장님이 에디터에서 직접 쓴 섹션 본문. */ export function sectionBody(payload: SitePayload, id: string): string[] { const body = payload.theme.sections.find((section) => section.id === id)?.body; return body?.split(/\n\s*\n/).map((paragraph) => paragraph.trim()).filter(Boolean) ?? []; } -/** - * 붙여넣기 아이템의 JSON → 렌더 가능한 항목. - * - * ★ 섹션 id 가 곧 아이템 종류다. 붙여넣기 아이템은 [+ 섹션 추가]가 `id = type` 으로 만든다 - * (frontend `canvas/addable.ts`). 그래서 payload 에 type 이 없어도 id 로 종류를 안다. - * ★ 파서는 shared 한 벌이다 — 빌더와 발행본이 같은 JSON 을 같은 규칙으로 읽어야 - * "빌더에서는 보이는데 발행하면 없다"가 안 생긴다. - */ +/** 붙여넣기 아이템의 JSON → 렌더 가능한 항목. */ export function sectionItems(payload: SitePayload, id: string) { const section = payload.theme.sections.find((entry) => entry.id === id); const parsed = parseSectionData(id, section?.data); - /* - * ★ 사장님이 쓴 것 + 우리가 지역 단위로 만든 것을 **한 배열로 잇는다** (2026-09-09) - * 가요·인물·연표·엽서·퀴즈는 업장의 사실이 아니라 도시의 사실이라 지역에 한 벌만 두고 - * 같은 지역 사이트가 나눠 쓴다(`payload.local.story`, 서버는 `area_contents`). - * 그걸 사이트마다 `sections[].data` JSON 으로 복사해 두면 지역 하나 고칠 때 사이트 수만큼 - * 고쳐야 한다 — 그래서 payload 에서 자리를 나누고 **읽는 순간에만** 합친다. - * ★ 사장님 것이 앞이다. 자기 가게에 대해 자기가 고른 것이 우리가 모아 온 것보다 먼저다. - * ★ 합치는 자리가 여기 하나인 이유: 다섯 섹션이 모두 이 함수를 거친다. 각자 합치게 하면 - * 한 곳을 빠뜨렸을 때 그 탭만 조용히 사장님 것만 보인다. - */ + /* 사장님이 쓴 것 + 우리가 지역 단위로 만든 것을 **한 배열로 잇는다** 가요·인물·연표·엽서·퀴즈는 업장의 사실이 아니라 도시의 사실이라 지역에 한 벌만 두고 같은 지역 사이트가 나눠 쓴다(`payload.local.story`, 서버는 `area_contents`). */ const shared = (payload.local.story as Record | undefined)?.[id]; if (!Array.isArray(shared) || shared.length === 0) return parsed; @@ -271,42 +213,13 @@ export function unitSpec(payload: SitePayload) { return UNIT_SPEC[payload.place.category]; } -/** - * 섹션 제목 — 사장님이 [섹션] 패널에서 붙인 이름(`theme.sections[].name`)을 그대로 쓴다. - * - * ★ 왜 필요한가 - * 지금까지 발행본 소제목은 컴포넌트에 박힌 문자열이었다. 사장님이 "객실 안내"를 - * "우리 방 소개"로 바꿔도 에디터 캔버스만 바뀌고 발행본은 옛 문구로 나갔다 — - * 사장님 입장에서는 고친 게 반영이 안 된 것이고, 실제로 반영이 안 된 게 맞다. - * - * ★ **모든 섹션이 이걸 쓰는 건 아니다.** 기준은 하나다 — 에디터 캔버스가 그 섹션에서 - * 무엇을 제목으로 쓰는가. 두 화면이 같아야 하므로 발행본은 에디터를 따라간다. - * - * 이걸 쓰는 섹션 rules · booking · inquiry · space · exhibition · rooms/menu/programs - * (에디터: `title={section.name}`) - * 쓰지 않는 섹션 intro · info · photos · map · local · faq - * (에디터가 자체 제목을 쓴다: "공간 갤러리", "오시는 길", `${상호} 소개` …) - * - * 한때 발행본에서 여섯 섹션 전부에 이걸 걸었다가 소개 제목이 "조이모텔 소개" 에서 - * "소개" 로 짧아졌다. `theme.sections[].name` 은 사장님이 붙인 이름이기도 하지만, - * 아직 아무것도 안 바꿨으면 서버 기본표(_DEFAULT_THEME)의 짧은 목록 라벨이다 — - * 그 라벨은 좌측 패널의 navigation 용이지

용이 아니다. - * - * ★ 폴백을 두는 이유 - * name 이 비어 있는 payload(옛 버전·손으로 만든 fixture)에서 제목 없는

가 - * 나가면 문서 구조가 무너지고 검색·낭독기가 섹션을 못 읽는다. 제목은 반드시 채운다. - */ +/** 섹션 제목 — 사장님이 [섹션] 패널에서 붙인 이름(`theme.sections[].name`)을 그대로 쓴다. */ export function sectionName(payload: SitePayload, id: string, fallback: string): string { const name = payload.theme.sections.find((section) => section.id === id)?.name?.trim(); return name ? name : fallback; } -/** - * key 목록 순서대로 확인된 place fact 를 표 행으로. - * - * ★ 순서가 곧 화면 순서다. 값이 없거나 미검증인 key 는 조용히 빠진다 — - * "확인 중"이라는 빈 줄을 그리면 손님은 그걸 규정으로 읽는다. - */ +/** key 목록 순서대로 확인된 place fact 를 표 행으로. */ function placeRowsByKeys(payload: SitePayload, keys: readonly string[]): InfoRow[] { const map = new Map( selectPublishable(payload.facts) @@ -321,19 +234,12 @@ function placeRowsByKeys(payload: SitePayload, keys: readonly string[]): InfoRow .filter((row) => row.value !== ''); } -/** 확인된 place fact 한 줄. 없으면 undefined — 섹션이 그 칸을 아예 안 그린다. */ +/** 확인된 place fact 한 줄. */ export function placeRow(payload: SitePayload, key: string): InfoRow | undefined { return placeRowsByKeys(payload, [key])[0]; } -/** - * 이용 규정으로 읽히는 fact key. - * - * ★ 관리자 캔버스의 `builder/canvas/variants/common.ts` RULE_FIELD_IDS 와 같은 목록이다. - * 에디터에서 규정으로 보인 항목이 발행본에서 다른 항목이 되면 사장님은 어느 쪽을 - * 믿어야 할지 모른다. 목록이 바뀌면 양쪽을 같이 고친다. - * ★ 여기 없는 규정은 만들어 내지 않는다. 업종 스키마(lodging.json)에 있는 key 만 적는다. - */ +/** 이용 규정으로 읽히는 fact key. */ export const RULE_FACT_KEYS = [ 'check_in_time', 'check_out_time', @@ -344,28 +250,19 @@ export const RULE_FACT_KEYS = [ 'extra_person_fee', ] as const; -/** 이용 규정 줄. 확인된 fact 에서만 만든다 — 없으면 빈 목록이고 섹션 자체가 안 나간다. */ +/** 이용 규정 줄. */ export function ruleRows(payload: SitePayload): InfoRow[] { return placeRowsByKeys(payload, RULE_FACT_KEYS); } -/** - * 예약 안내에 실을 fact. - * - * 숙박에는 없고(예약은 채널이 받는다) 음식점·피부과·성형외과 스키마에만 있는 key 다. - * 값이 없는 업종에서는 그냥 빠진다. - */ +/** 예약 안내에 실을 fact. */ const BOOKING_FACT_KEYS = ['reservation_required', 'reservation_channel'] as const; export function bookingRows(payload: SitePayload): InfoRow[] { return placeRowsByKeys(payload, BOOKING_FACT_KEYS); } -/** - * 공간 안내에 실을 fact — "자리가 어떻게 생겼나"에 답하는 것만. - * - * ★ 주차·와이파이 같은 편의시설은 넣지 않는다. 그건 이용 정보(info) 표가 이미 낸다. - */ +/** 공간 안내에 실을 fact — "자리가 어떻게 생겼나"에 답하는 것만. */ const SPACE_FACT_KEYS = [ 'seat_count', 'terrace', @@ -380,12 +277,7 @@ export function spaceRows(payload: SitePayload): InfoRow[] { return placeRowsByKeys(payload, SPACE_FACT_KEYS); } -/** - * 안내에 실을 fact — 피부과·성형외과 스키마(clinic.json)의 안내 관련 key. - * - * ★ 준비물·안전 유의사항은 뺐다. 그건 "관람 안내"가 아니라 체험 전 주의사항이고, - * 이용 정보(info) 표에 이미 나간다. - */ +/** 안내에 실을 fact — 피부과·성형외과 스키마(clinic.json)의 안내 관련 key. */ const EXHIBITION_FACT_KEYS = [ 'operating_hours', 'session_times', @@ -398,21 +290,12 @@ export function exhibitionRows(payload: SitePayload): InfoRow[] { return placeRowsByKeys(payload, EXHIBITION_FACT_KEYS); } -/** - * 첫 화면에 세우는 핵심 값 — 손님이 예약을 결정하기 전에 제일 먼저 묻는 것. - * - * ★ 업종마다 묻는 게 다르다. 숙박은 체크인/아웃, 카페·음식점은 영업시간, 병원은 진료시간이다. - * 그래서 key 목록을 업종별로 두지 않고 **우선순위 한 줄**로 두고 앞에서부터 있는 것만 집는다 — - * 없는 업종에서는 그냥 다음 값이 올라온다. - * ★ 최대 넷. 다섯 개부터는 훑는 값이 아니라 표가 되고, 그건 아래 이용 정보가 이미 한다. - */ +/** 첫 화면에 세우는 핵심 값 — 손님이 예약을 결정하기 전에 제일 먼저 묻는 것. */ const HERO_FACT_KEYS = [ 'check_in_time', 'check_out_time', 'operating_hours', 'closed_days', - // ★ 'parking_available' 이라고 적혀 있었다 — 스키마의 key 는 'parking' 이라(lodging.json) - // 주차가 첫 화면 띠에 한 번도 뜬 적이 없다. 숙박에서 주차는 체크인 다음으로 묻는 값이다. 'parking', 'reservation_required', ] as const; @@ -421,13 +304,7 @@ export function heroFacts(payload: SitePayload): InfoRow[] { return placeRowsByKeys(payload, HERO_FACT_KEYS).slice(0, 4); } -/** - * 가장 싼 값. - * - * ★ 숫자를 다시 계산하지 않고 **사장님이 쓴 문자열을 그대로** 고른다. 단위·표기가 - * 가게마다 다르고("15만원~", "150,000원/박"), 우리가 파싱해 다시 쓰면 없던 값이 생긴다. - * 비교는 문자열에서 숫자만 뽑아 하고, 화면에 나가는 건 원문이다. - */ +/** 가장 싼 값. */ export function lowestPrice(payload: SitePayload): string | undefined { const priced = unitViews(payload) .map((unit) => unit.priceText) @@ -437,7 +314,7 @@ export function lowestPrice(payload: SitePayload): string | undefined { return priced.reduce((min, text) => (num(text) < num(min) ? text : min)); } -/** 채널 코드 → 사람이 읽는 이름. link.title 이 있으면 그쪽이 우선이다. */ +/** 채널 코드 → 사람이 읽는 이름. */ export const CHANNEL_LABEL: Record = { [LinkChannel.NAVER_BOOKING]: '네이버 예약', [LinkChannel.YANOLJA]: '야놀자', @@ -453,77 +330,27 @@ export function channelLabel(link: ChannelLink): string { return link.title ?? CHANNEL_LABEL[link.channel] ?? '채널'; } -/** - * 예약 버튼에 적는 이름. - * - * ★ `channelLabel()` 은 링크 제목을 우선하는데, 수집된 제목은 "스테이,머뭄 네이버 플레이스"처럼 - * 사업장 이름을 달고 온다. 버튼에 그대로 실으면 "스테이,머뭄 네이버 플레이스 예약" 이 된다 — - * 버튼이 답해야 하는 건 '어디서 예약하나' 하나다. 목록(예약 안내)에서는 제목이 맞다. - */ +/** 예약 버튼에 적는 이름. */ export function bookingLabel(link: ChannelLink): string { return CHANNEL_LABEL[link.channel] ?? channelLabel(link); } -/** - * 예약을 실제로 받는 채널. - * - * ★ 블로그·인스타그램은 그 목록에 없다. 눌러도 예약 화면이 안 나오는 링크를 "예약하기" - * 자리에 두면 손님이 예약한 줄 알고 안 온다. 공식 사이트도 없다 — 지금 보고 있는 이 - * 사이트가 그 자리라, 자기 자신으로 돌려보내는 버튼이 된다. - * ★ 화면의 예약 버튼과 JSON-LD 의 `makesOffer.url`·`potentialAction` 이 **같은 링크**를 - * 가리켜야 한다. 목록을 두 곳에 적으면 그게 조용히 갈라진다. - */ +/** 예약을 실제로 받는 채널. */ -/** - * 문의를 실제로 받을 수 있는 채널 — 네이버 톡톡·인스타 DM 처럼 말을 걸 수 있는 곳만. - * - * ★ 블로그·공식 사이트·기타(ETC)는 뺀다. 읽기만 되는 링크를 "문의" 버튼으로 두면 - * 손님이 남긴 말이 아무 데도 도착하지 않는다. - */ +/** 문의를 실제로 받을 수 있는 채널 — 네이버 톡톡·인스타 DM 처럼 말을 걸 수 있는 곳만. */ const CONTACT_CHANNELS: readonly number[] = [LinkChannel.NAVER_PLACE, LinkChannel.INSTAGRAM]; -/** 확정된 링크만. 확정 전 URL 은 동명 업소일 수 있다(sanitizePayloadForPublish 와 같은 규칙). */ +/** 확정된 링크만. */ function confirmedLinks(payload: SitePayload, channels: readonly number[]): ChannelLink[] { return payload.links.filter((link) => link.confirmed && channels.includes(link.channel)); } -/** - * 예약 버튼에 낼 채널. **BOOKING_CHANNELS 순서대로** 정렬한다 — 예약 화면으로 바로 가는 - * 채널이 맨 위 버튼이어야 한다. - * - * ★ 필터·순서 판단은 seo/jsonld 에 산다(순환 의존 회피, `bookingChannelLinks`) — 화면과 - * makesOffer.url·potentialAction 이 같은 함수를 써야 어긋나지 않는다. - */ +/** 예약 버튼에 낼 채널. */ export const bookingLinks = bookingChannelLinks; -/** - * ───────────────────────────────────────────────────────────────────────── - * 숙박 예약 — 손님이 "이 방을 이 값에 이 창구로" 예약할 수 있게 하는 데이터. - * ───────────────────────────────────────────────────────────────────────── - * - * ★ 왜 숙박만 따로 만드나 - * `bookingRows()` 가 읽는 `reservation_required`·`reservation_channel` 은 **숙박 스키마에 - * 없는 key** 다(lodging.json 확인). 그래서 숙박으로 발행하면 서버 기본표가 "실시간 예약" - * 섹션을 켜 두는데도(`site_payload._DEFAULT_THEME`) 화면에는 전화번호 한 줄만 남았다 — - * 요금도, 인원도, 취소 규정도, 예약 창구도 없는 "예약" 섹션이었다. - * 펜션·민박은 예약이 곧 매출이고, AI 가 "얼마예요 / 몇 명까지 / 어떻게 예약해요" 에 - * 답할 근거가 이 자리에 있어야 한다. - * - * ★ 우리는 예약을 **처리하지 않는다.** 빈 방 재고도 결제도 갖지 않고(PRODUCT.md 6절), - * 확정된 예약 채널과 전화로 **보낸다.** 그래서 이 구성은 "예약 폼" 이 아니라 - * **"예약에 필요한 사실 + 실제로 예약이 되는 창구"** 다. 없는 기능을 화면으로 흉내내면 - * 손님은 예약한 줄 알고 안 온다. - */ +/** ───────────────────────────────────────────────────────────────────────── 숙박 예약 — 손님이 "이 방을 이 값에 이 창구로" 예약할 수 있게 하는 데이터. */ -/** - * 예약 전에 반드시 확인해야 하는 fact. - * - * ★ 이용 규정(`RULE_FACT_KEYS`)과 목록이 겹친다 — 일부러다. 같은 사실이라도 손님이 그것을 - * 찾는 순간이 다르다(규정은 "어떤 곳인가", 여기는 "예약을 눌러도 되는가"). 두 섹션이 - * 같이 켜져 있으면 값이 두 번 보이는데, 값이 같으므로 거짓이 되지 않는다. - * ★ 프런트 운영시간을 넣는다 — 전화 예약이 1순위인 업소에서 "언제 전화하면 받나" 는 - * 예약 성공 여부를 가르는 값이다. - */ +/** 예약 전에 반드시 확인해야 하는 fact. */ const STAY_BOOKING_NOTICE_KEYS = [ 'check_in_time', 'check_out_time', @@ -539,18 +366,13 @@ const STAY_BOOKING_NOTICE_KEYS = [ export interface StayOffer { unitId: string; name: string; - /** "기준 2명 · 최대 4명". 확인된 값만으로 만들고, 둘 다 없으면 undefined. */ + /** "기준 2명 · 최대 4명". */ capacityText?: string; - /** 주중·주말·성수기 요금. 확인된 것만. */ + /** 주중·주말·성수기 요금. */ rateRows: InfoRow[]; - /** - * 기준 요금 표기("주중 1박 280,000원"). - * - * ★ 숫자는 `unitBaseRate`(seo/jsonld.ts)가 고른 그 값이다 — JSON-LD 의 - * `makesOffer.price` 와 **같은 숫자**여야 화면 ↔ 구조화 데이터 대조를 통과한다. - */ + /** 기준 요금 표기("주중 1박 280,000원"). */ baseRateText?: string; - /** 객실 상세(사진·전체 스펙)는 객실 섹션이 갖고 있다. 한 장 사이트라 앵커다. */ + /** 객실 상세(사진·전체 스펙)는 객실 섹션이 갖고 있다. */ href: string; } @@ -582,20 +404,14 @@ function stayOffers(payload: SitePayload): StayOffer[] { export interface StayBookingView { offers: StayOffer[]; notices: InfoRow[]; - /** 실제로 예약이 되는 채널. 확정된 것만. */ + /** 실제로 예약이 되는 채널. */ links: ChannelLink[]; /** 말을 걸 수 있는 채널(네이버 톡톡·인스타 DM). */ contacts: ChannelLink[]; phone?: string; } -/** - * 숙박 예약 구성에 필요한 것 전부. **근거가 하나도 없으면 null** 이다. - * - * ★ null 을 돌려주는 이유: 섹션을 그릴지 말지를 컴포넌트·상단 내비·하단 탭이 각자 - * 판단하면 세 곳이 갈라진다. 눌러도 아무 일 없는 "예약" 탭은 고장으로 읽힌다. - * 판단은 이 함수 하나가 한다. - */ +/** 숙박 예약 구성에 필요한 것 전부. */ export function stayBookingView(payload: SitePayload): StayBookingView | null { if (payload.place.category !== PlaceCategory.LODGING) return null; @@ -605,9 +421,7 @@ export function stayBookingView(payload: SitePayload): StayBookingView | null { ), notices: placeRowsByKeys(payload, STAY_BOOKING_NOTICE_KEYS), links: bookingLinks(payload), - // ★ 예약 창구로 이미 나가는 채널은 문의에 다시 넣지 않는다. 네이버 플레이스는 두 - // 목록에 모두 들어 있어서, 그대로 두면 같은 링크가 "예약" 과 "문의" 로 두 번 보인다 — - // 손님은 둘이 다른 곳인 줄 알고 어느 쪽을 눌러야 하는지 망설인다. + // 예약 창구로 이미 나가는 채널은 문의에 다시 넣지 않는다. contacts: contactLinks(payload).filter( (contact) => !bookingLinks(payload).some((link) => link.url === contact.url), ), @@ -623,13 +437,7 @@ export function stayBookingView(payload: SitePayload): StayBookingView | null { return empty ? null : view; } -/** - * 섹션 설정에 그 섹션 자체가 있는지. - * - * ★ `isSectionEnabled()` 와 다르다 — "사장님이 껐다" 와 "payload 에 항목이 아예 없다" 는 - * 다른 상태다. 항목이 없는 payload(옛 버전·손으로 만든 fixture)에서는 기본으로 내보내고, - * **명시적으로 끈 것은 존중한다.** 둘을 같이 묶으면 사장님이 끈 섹션이 되살아난다. - */ +/** 섹션 설정에 그 섹션 자체가 있는지. */ export function hasSection(payload: SitePayload, id: string): boolean { return payload.theme.sections.some((section) => section.id === id); } @@ -638,29 +446,15 @@ export function contactLinks(payload: SitePayload): ChannelLink[] { return confirmedLinks(payload, CONTACT_CHANNELS); } -/* ── 숙박 예약(origin/main 의 feature/stay-booking) ───────────────────── - * ★ 이 블록은 예약 작업이 넣은 것이다. DB 재구성 병합 때 derive.ts 를 이쪽 버전으로 - * 가져오면서 빠질 뻔했다 — 예약 화면(StayBookingSection)이 이 셋에 의존한다. */ -/** - * "○○ 예약" 한 줄. 라벨이 이미 '예약' 으로 끝나면 그대로 둔다. - * - * ★ 실측(2026-09-09, 스테이,머뭄): 네이버 예약 채널이 붙자 버튼이 **"네이버 예약 예약"** 이 됐다. - * `{bookingLabel(link)} 예약` 을 네 곳에서 각자 이어 붙이고 있었기 때문이다 — - * 문구를 만드는 자리가 여럿이면 채널이 하나 늘 때 그중 몇 곳만 고쳐진다. - */ +/* ── 숙박 예약(origin/main 의 feature/stay-booking) ───────────────────── */ +/** "○○ 예약" 한 줄. */ export function bookingActionLabel(link: ChannelLink, prefix?: string): string { const name = bookingLabel(link); const phrase = name.endsWith('예약') ? name : `${name} 예약`; return prefix ? `${prefix} ${phrase}` : phrase; } -/** - * 예약 버튼에 찍을 말. - * - * ★ `${channelLabel}에서 예약` 로 일괄 처리하면 네이버 예약이 "네이버 예약에서 예약" 이 된다. - * 그리고 이 채널은 다른 채널과 성격이 다르다 — 누르면 **예약 화면 그 자체**가 뜬다. - * 그 차이를 버튼이 말해 줘야 손님이 한 번 더 눌러야 하는지 아닌지를 안다. - */ +/** 예약 버튼에 찍을 말. */ export function bookingCtaLabel(link: ChannelLink): string { if (link.channel === LinkChannel.NAVER_BOOKING) return '네이버 예약으로 바로 예약하기'; return `${channelLabel(link)}에서 예약`; diff --git a/solution/site/src/lib/format.ts b/solution/site/src/lib/format.ts index 0f3ace5..63a8194 100644 --- a/solution/site/src/lib/format.ts +++ b/solution/site/src/lib/format.ts @@ -1,4 +1,4 @@ -/** "280000" → "280,000원". 숫자가 아니면 원문 그대로 돌려준다. */ +/** "280000" → "280,000원". */ export function formatWon(value: string | number | null | undefined): string { if (value == null || value === '') return ''; const num = typeof value === 'string' ? Number(value.replace(/,/g, '')) : value; @@ -6,7 +6,7 @@ export function formatWon(value: string | number | null | undefined): string { return `${num.toLocaleString('ko-KR')}원`; } -/** ISO → "2026년 8월 27일". 화면에 "언제 기준"을 찍는 데 쓴다. */ +/** ISO → "2026년 8월 27일". */ export function formatKoreanDate(iso: string | null | undefined): string { if (!iso) return ''; const date = new Date(iso); @@ -14,14 +14,7 @@ export function formatKoreanDate(iso: string | null | undefined): string { return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`; } -/** - * ISO → "2026-09-04". 화면에 찍는 날짜다. - * - * ★ 왜 한글 표기가 아닌가 (2026-09-04, AEO 진단 '본문 날짜 표기 없음') - * 답변 엔진은 본문에서 YYYY-MM-DD 를 찾아 최신성을 매긴다. "2026년 9월 4일" 은 - * 사람은 읽지만 그쪽은 못 읽고, `