From 4871e50327329f4a2729acebd3b53b205b87ad59 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 31 Aug 2026 16:42:17 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AC=B8=EC=84=9C:=20=EC=95=B1=EC=9D=84=20?= =?UTF-8?q?=EA=B0=80=EB=A5=B8=20=EB=92=A4=20=EB=82=A1=EC=95=84=EC=A7=84=20?= =?UTF-8?q?=EC=84=9C=EC=88=A0=EC=9D=84=20=EA=B3=A0=EC=B9=98=EA=B3=A0,=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B3=BC=20=EB=AC=B4=EA=B4=80=ED=95=B4?= =?UTF-8?q?=EC=A7=84=20=EA=B8=B0=EB=A1=9D=EC=9D=84=20=EC=A7=80=EC=9A=B4?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지운 것 — 앞으로의 개발에 쓸 데가 없다. - solution/backend/demo_site.html: 어떤 스크립트도 만들지 않는 고아 산출물이고, 손으로 쓴 HTML 이라 "백엔드는 HTML 을 만들지 않는다" 와도 어긋난다. - solution/README.md: front/ · admin/.env · 사이트당 rooms/index.html·sitemap.xml 처럼 지금은 전부 틀린 서술이었다. 살아 있는 두 가지(빌더 CSR vs 발행물 SSG 대비표, 하이드레이션 블롭에 미검증 값이 샜던 실측)는 ARCHITECTURE 로 옮겼다. - docs/API_USAGE.md 의 Claude 개발비 집계: 2026-08-27 스냅샷과 재집계 스크립트는 일회성 지출 기록이라 제품 원가와 성격이 다르다. 문서를 외부 API 원가 하나로 좁혔다. 고친 것 — 코드를 따라가지 못하던 서술. - 코드 경로가 solution/backend 로 옮겨진 뒤 `backend/...` 로 남아 있던 포인터 전부. 가리키는 자리가 없는 경로는 문서가 아니라 함정이다. - ARCHITECTURE: 트리의 front→frontend, 컨테이너 표에 api-admin(:9801)·admin(:3002) 추가. - ★ ARCHITECTURE·AGENTS 의 "admin 전용 라우터가 0개" 는 사실이 아니었다. /v1/admin/local-content 가 admin 전용인데 :9800 에도 마운트돼 있다 — 포트를 가른 논리에 아직 남은 구멍이라 그렇게 적었다. - DECISIONS: 결론난 것을 미결로 두면 함정이 된다. 작업 큐(2026-08-27 결론), 날씨 캐시 TTL 1시간, jobs 테이블, media 조회 API, 수집 체인을 결론으로 옮기고 네이버 플레이스 대 TourAPI 실측(2026-08-31)을 1-1 에 이었다. - API_USAGE: 어댑터가 다 붙고 TourAPI 키도 나왔다. "실호출 0건" 은 낡은 서술이었다. - backend/README: 16→17 테이블(jobs), 없어진 alters/, MockAdapter 만이라는 서술, cd backend 경로, media·local 라우터 누락. --- AGENTS.md | 14 +- README.md | 2 +- docs/API_USAGE.md | 237 ++++++------------------- docs/ARCHITECTURE.md | 49 +++++- docs/COLLECTION_SEO_AEO_FLOW.md | 48 +++--- docs/DATA_SOURCE_RESEARCH.md | 10 +- docs/DECISIONS.md | 30 ++-- docs/DEPLOY.md | 10 +- docs/DEVELOPMENT_DIRECTION.md | 12 +- docs/PRODUCT.md | 6 +- solution/README.md | 153 ----------------- solution/backend/README.md | 45 +++-- solution/backend/demo_site.html | 247 --------------------------- solution/frontend/src/api/README.md | 11 +- solution/site/public/fonts/README.md | 4 +- 15 files changed, 203 insertions(+), 675 deletions(-) delete mode 100644 solution/README.md delete mode 100644 solution/backend/demo_site.html diff --git a/AGENTS.md b/AGENTS.md index a45308a..b848d2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,12 +78,14 @@ admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면) | 사장님 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 | | 내부 API | 9801 | `admin/backend/main.py` → `app.py` | **앱 전체 role >= DEVELOPER** | -`services`·`crud`·`models` 은 그대로 공유한다. admin 전용 라우터가 0개라(세어봤다: -admin 화면이 부르는 건 전부 place·fact 다) 도메인을 복제하지 않고 **같은 router 객체를 -다시 마운트하면서 앱 단위로 권한만 덧건다.** +`services`·`crud`·`models` 은 그대로 공유한다. admin 화면이 부르는 게 사장님 빌더와 거의 +같아서(place·fact, admin 전용은 `local-content` 하나) 도메인을 복제하지 않고 **같은 router +객체를 다시 마운트하면서 앱 단위로 권한만 덧건다.** 경로 접두어(`/v1/admin/...`)가 아니라 **포트**를 가른 이유: 접두어는 같은 프로세스라 사장님이 닿는 서버에 내부 엔드포인트가 존재한다. 포트를 가르면 아예 없다. `ADMIN_API_BIND` 기본값이 `127.0.0.1` 인 것도 같은 이유다 — 0.0.0.0 으로 열면 무의미하다. +⚠️ 단 `/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다 — 남은 구멍이다 +([ARCHITECTURE.md 4절](docs/ARCHITECTURE.md)). ## 실행 @@ -93,13 +95,13 @@ docker compose logs -f worker ``` - 사장님 앱 `:3000`(API :9800) · 내부 운영 `:3002`(API :9801, 로컬호스트에만 열림) -- 발행 사이트: `http://localhost:3000/s/` (front Vite 가 :3001 정적서버로 프록시) +- 발행 사이트: `http://localhost:3000/s/` (빌더 Vite 가 :3001 정적서버로 프록시) - **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf` (후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다) - DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`). 스키마는 `postgres-init/init-data/init.sql` **한 벌**이다 — 누적 ALTER 파일은 없다 - npm 워크스페이스 루트는 **레포 루트**다. `npm install` 은 루트에서 한 번. - `npm run dev:front` / `dev:admin` / `dev:site` + `npm run dev:frontend` / `dev:admin` / `dev:site` - 백엔드 스크립트는 `solution/backend/` 에서 `.venv/bin/python scripts/.py` - 테스트: `solution/backend/` 에서 `.venv/bin/pytest`. **`APP_ENV=test` 면 `.env` 를 읽지 않는다** — 실키가 테스트로 새어 외부 API 요금이 나가는 경로를 막아 뒀다 @@ -112,7 +114,7 @@ Vite 앱은 구조상 **자기 디렉토리의 `.env` 만** 읽는다 — 그래 | 파일 | 담는 것 | |---|---| | `.env` | DB · JWT · 외부 API 키 · `SITE_PUBLIC_HOST` · `INDEXNOW_KEY` | -| `solution/frontend/.env` · `admin/.env` | 그 앱에만 있는 `VITE_*`. admin 은 API 가 :9801 이다 | +| `solution/frontend/.env` · `admin/frontend/.env` | 그 앱에만 있는 `VITE_*`. admin 은 API 가 :9801 이다 | ★ **두 곳에 같은 값을 적지 않는다.** 발행 호스트는 compose 가 루트의 `SITE_PUBLIC_HOST` 를 `VITE_PUBLISH_HOST` 로 흘려보낸다. 각자 적으면 canonical 과 화면 주소가 조용히 갈라진다. diff --git a/README.md b/README.md index 306190d..0539654 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ postgres-init/ 스키마 DDL | [docs/DEPLOY.md](docs/DEPLOY.md) | 서버에 올릴 때 · 배포 후 재발행 절차 | | [docs/DATA_SOURCE_RESEARCH.md](docs/DATA_SOURCE_RESEARCH.md) | 어디서 콘텐츠를 가져올 수 있나 (실측 근거) | | [docs/COLLECTION_SEO_AEO_FLOW.md](docs/COLLECTION_SEO_AEO_FLOW.md) | 수집→LLM→SEO/AEO 현재 구현 | -| [docs/API_USAGE.md](docs/API_USAGE.md) | API 비용 — 제품 원가와 개발비 구분 | +| [docs/API_USAGE.md](docs/API_USAGE.md) | 외부 API 원가 — 사이트 1건당 $1 상한을 어디서 강제하나 | ## 문서 규칙 diff --git a/docs/API_USAGE.md b/docs/API_USAGE.md index 6faafb2..f0fe7f6 100644 --- a/docs/API_USAGE.md +++ b/docs/API_USAGE.md @@ -1,210 +1,81 @@ -# API 사용 이력 · 비용 기록 +# 외부 API 원가 -**집계 시각: 2026-08-27 08:59 KST** -집계 대상: o2o-web4ai 프로젝트 전체 (backend · solution/frontend · solution/site) +**사이트 1건을 만드는 데 나가는 외부 API 비용**만 다룬다. 상한은 **$1(1,400원)** 이고, +문서가 아니라 코드가 강제한다(3절). 제품 제약으로서의 근거는 [PRODUCT.md 8절](PRODUCT.md). + +> ⚠️ 이 상한에 **개발비를 섞지 않는다.** 코드를 짜는 데 든 LLM 요금은 일회성 지출이고, +> 사이트를 만들 때마다 반복해서 나가는 원가와는 성격이 다르다. --- -## 0. 먼저 — 섞으면 안 되는 두 가지 비용 +## 1. 공급자별 상태 (2026-08-31) -| | 무엇 | 언제 나가나 | 현재 | -|---|---|---|---| -| **A. 제품 원가** | 사이트 **1건 만들 때** 나가는 외부 API 비용 (Perplexity·Kakao·Gemini) | 업소 1곳 처리할 때마다 **반복** | **0원** (아직 한 번도 호출 안 함) | -| **B. 개발비** | 이 코드를 **짜는 데** 쓴 Claude API 비용 | 개발 기간 동안 **한 번** | $70.07 | - -**A 의 상한은 $1(1,400원)이다.** B 는 사이트를 만들 때마다 나가는 돈이 아니다 — 코드가 완성되면 끝나는 일회성 지출이다. 이 문서의 2절은 전부 **B** 얘기다. - ---- - -## 0-1. 한 줄 요약 - -| 구분 | 실제 호출 | 비용 | -|---|---|---| -| **외부 유료 API** (Perplexity · Kakao · Gemini · TourAPI) | **0건** | **0원** | -| **개발 도구 API** (Claude Code / Anthropic Messages API) | 348 요청 | **$70.07** (≈ 98,100원, 1,400원/USD 가정) | - -지금까지 나간 돈은 **전부 개발 과정의 Claude API 요금**이고, 제품이 쓸 외부 API는 아직 **한 번도 실제로 때리지 않았다.** - ---- - -## 1. [A] 제품 원가 — 외부 API 계약 현황과 실호출 이력 - -### 1-1. 키 상태 - -| API | 용도 | 키 설정 | 어댑터 | 실호출 | +| API | 용도 | 키 | 붙은 자리 | 과금 | |---|---|---|---|---| -| Perplexity Sonar (`api.perplexity.ai`) | 채널 URL 발견 | ✅ 설정됨 | `backend/services/external/perplexity.py` 구현 완료 | **0건** | -| Kakao Local (`dapi.kakao.com`) | 동일 업소 검증 · 주변 정보 | ⬜ 비어 있음 | 미구현 (설계만) | **0건** | -| Gemini (Google AI Studio) | 사진 분류 · 카피 작성 | ✅ 설정됨 | 미구현 | **0건** | -| TourAPI (`data.go.kr`) | 축제 · 관광지 | ⬜ 비어 있음 | 미구현 | **0건** | -| Open-Meteo | 날씨 | 키 불필요 | `frontend/.../useWeather.ts` | **0건** (프론트 코드만 작성) | +| **TourAPI** (`apis.data.go.kr`) | fact 주력 공급원 — 숙박·음식점 | ✅ | `collector/tour_api_adapter.py` | **무료** (개발계정 1,000건/일) | +| **네이버 지역검색** (`openapi.naver.com`) | 동일 업소 검증 후보 | ✅ | `external/naver.py` | 무료 쿼터 | +| **Perplexity Sonar** | 채널 URL 발견 | ✅ | `external/perplexity.py` | 요청 + 토큰 | +| **Gemini** (AI Studio) | 사진 분류 · 소개문/FAQ · 붙여넣기 추출 | ✅ | `external/gemini.py` `gemini_text.py` `gemini_extract.py` | 토큰 | +| **Kakao Local** (`dapi.kakao.com`) | 주변 정보 | ⬜ 미발급 | `external/kakao.py` | 건당 2원 | +| **Open-Meteo** | 날씨 | 불필요 | `external/open_meteo.py` | 무료 | -> `.env` 의 키가 비어 있으면 해당 어댑터만 비활성되고 서버는 그대로 뜬다 — 설계상 의도된 동작. +`.env` 의 키가 비어 있으면 **해당 어댑터만 비활성되고 서버는 그대로 뜬다** — 부팅이 외부 계약에 +묶이면 안 되기 때문이다. 수집 어댑터는 `COLLECT_ADAPTERS` 로 배포 없이 개별로 끌 수 있다. -### 1-2. 실호출이 0건인 근거 +무엇을 어디서 가져오는지(필드 단위 카탈로그와 실측 충전율)는 +[DATA_SOURCE_RESEARCH.md 8-3](DATA_SOURCE_RESEARCH.md). -1. **코드 레벨** — 전체 세션 트랜스크립트에서 위 호스트로 나간 HTTP 요청이 없다. 등장하는 건 전부 소스 작성·문서 편집 텍스트다. -2. **테스트 레벨** — `backend/tests/` 안에서 실제 네트워크를 타는 코드가 없다. `test_perplexity.py` · `test_collector.py` 는 전부 목(mock) 기반이고, `httpx.get/post` 직접 호출은 0건. -3. **설정 레벨** — `DECISIONS.md` 의 결정대로 **`APP_ENV=test` 면 `.env` 를 읽지 않는다.** 실키가 테스트로 새어 외부 API 요금이 나가는 경로 자체를 막아 뒀다. -4. **DB 레벨** — 어떤 테이블에도 외부 API 응답이 적재된 흔적이 없다 (호출 로그 테이블도 아직 없다 — 3-2 참고). +## 2. 단가표 -### 1-3. 단가표 (호출 시작 시 적용될 요금) +코드의 단일 출처는 `solution/backend/common/cost.py` 의 `RATES` 다. 아래는 그 요약이다. -| API | 과금 방식 | 단가 | +| API | 단가 | 확정 | |---|---|---| -| Perplexity Sonar | **토큰 요금 + 검색 호출 요금이 별도로 붙는다** | `sonar` 기본, `sonar-pro` 는 더 비쌈. 호출당 검색 횟수는 이미 로그로 남기고 있음 (`perplexity.py` 의 `usage.num_search_queries`) | -| Kakao Local | 무료 쿼터 초과분 종량 | 키워드/카테고리 검색 **2원**, 좌표 변환 **0.5원** (키워드가 4배 비싸다) | -| Gemini | 토큰 종량 (Google AI Studio) | 모델·티어별 | -| TourAPI | 공공데이터 무료 (일 트래픽 제한) | 0원 | -| Open-Meteo | 무료 | 0원 | +| Kakao Local | 키워드/카테고리 검색 **2원**, 좌표 변환 **0.5원** | ✅ `.env.example` | +| Perplexity Sonar | 요청 7원 + 1K 토큰당 1.4원 (low context, USD_KRW 1,400) | ✅ 공식 단가표 | +| Gemini | 이미지 1원 · 1K 토큰 0.5원 | ❌ **추정치** | +| TourAPI · Open-Meteo | 0원 | ✅ | -> ⚠️ **Kakao 무료 쿼터 함정** — 무료 쿼터는 개발자 계정의 "첫 번째 활성 앱" 에만 붙는다. dev/stage/prod 앱을 따로 파면 **하나만 무료**다. 앱을 나누기 전에 확인할 것. +★ **좌표 변환이 키워드 검색보다 4배 싸다.** 같은 공급자인데 단가가 다르므로 호출측이 +`kakao_coord` 로 구분해 넘긴다. 지역 정보를 `place_id` 가 아니라 **행정구역 코드로 캐싱**하는 +이유도 이것이다 — 같은 지역에 사이트 50개가 생겨도 외부 조회는 1회다. ---- +⚠️ **Kakao 무료 쿼터 함정** — 무료 쿼터는 개발자 계정의 "첫 번째 활성 앱" 에만 붙는다. +dev/stage/prod 앱을 따로 파면 **하나만 무료**다. 앱을 나누기 전에 확인할 것. -## 2. [B] 개발비 — Claude (Anthropic Messages API) - -> 사이트 1건당 원가가 **아니다.** 코드를 짜는 데 든 일회성 비용이다. - -### 2-1. 총계 - -| 항목 | 값 | -|---|---| -| 기간 | 2026-08-26 16:23 ~ 2026-08-27 08:58 KST (약 16.6시간) | -| 모델 | `claude-opus-5` (1M 컨텍스트) 단일 | -| 과금 요청 수 | **348건** (메시지 ID 기준 중복 제거) | -| 출력 토큰 | 450,509 | -| 캐시 쓰기 (1h TTL) | 2,020,375 | -| 캐시 쓰기 (5m TTL) | 96,213 | -| 캐시 읽기 | 76,004,125 | -| 캐시 미적용 입력 | 690 | -| 입력 환산 합계 | 78,121,403 | -| **총 비용** | **$70.07** | - -### 2-2. 비용 내역 - -| 항목 | 토큰 | 요율 ($/MTok) | 금액 | -|---|---|---|---| -| 캐시 읽기 (기본 입력가 ×0.1) | 76,004,125 | $0.50 | **$38.00** | -| 캐시 쓰기 1h (기본 입력가 ×2) | 2,020,375 | $10.00 | **$20.20** | -| 출력 | 450,509 | $25.00 | **$11.26** | -| 캐시 쓰기 5m (기본 입력가 ×1.25) | 96,213 | $6.25 | **$0.60** | -| 캐시 미적용 입력 | 690 | $5.00 | $0.003 | -| | | **합계** | **$70.07** | - -> 각 항목은 센트 단위 반올림이라, 눈으로 더하면 합계와 1센트 차이가 날 수 있다. - -**캐시 효과** — 프롬프트 캐싱이 없었다면 같은 작업이 **$401.87** 이었다. 즉 **$331.80 (82.6%) 를 캐시로 아꼈다.** -동시에, 비용의 **54%($38.00)가 캐시 *읽기*** 다. 출력 토큰 값($11.26)의 3배가 넘는다 — 긴 컨텍스트를 반복해서 다시 읽는 패턴이 이 프로젝트 비용 구조를 지배한다는 뜻이다. 줄이려면 출력을 줄이는 게 아니라 **컨텍스트를 작게 유지**해야 한다. - -### 2-3. 세션별 내역 - -| 세션 | 종류 | 시각 (KST) | 요청 | 출력 토큰 | 캐시 읽기 | 비용 | -|---|---|---|---|---|---|---| -| `4db7aca5` | 메인 (backend 주력) | 08-26 16:23 ~ 08-27 08:55 | 157 | 266,385 | 35,748,750 | **$32.65** | -| `99be2a6c` | 메인 | 08-26 17:24 ~ 08-27 08:58 | 92 | 124,641 | 16,660,246 | **$19.79** | -| `19be092e` | 메인 (solution/frontend) | 08-27 08:42 ~ 08:58 | 29 | 25,903 | 2,476,298 | $3.37 | -| `agent-a58…` | 서브에이전트 | 08-27 08:51 ~ 08:57 | 13 | 6,173 | 5,741,359 | $3.21 | -| `agent-afa…` | 서브에이전트 | 08-27 08:52 ~ 08:55 | 14 | 423 | 6,132,381 | $3.21 | -| `6052d29a` | 메인 (이 기록 작성 세션) | 08-27 08:52 ~ 08:58 | 21 | 23,936 | 1,678,581 | $2.92 | -| `agent-aa5…` | 서브에이전트 | 08-27 08:52 ~ 08:58 | 9 | 68 | 3,905,434 | $2.07 | -| `agent-ab1…` | 서브에이전트 | 08-27 08:52 ~ 08:57 | 8 | 165 | 3,466,831 | $1.90 | -| `d56c8333` | 메인 | 08-27 08:52 ~ 08:53 | 5 | 2,815 | 194,245 | $0.95 | -| `2ac02dde` | 메인 (중복 세션) | — | 0 | 0 | 0 | $0.00 | - -**읽을 거리 두 가지:** - -- **서브에이전트 4개 합계 $10.39** — Perplexity/Kakao 클라이언트를 병렬로 짠 fork 들이다. 출력 토큰은 다 합쳐 6,829개뿐인데 캐시 읽기가 **1,925만 토큰**이다. **fork 는 부모 컨텍스트를 통째로 들고 시작하기 때문에**, 실제 작업이 짧아도 부모 컨텍스트가 크면 읽기 요금이 그만큼 붙는다. 병렬 fork 를 쓸지 말지는 "일이 병렬인가" 보다 **"부모 컨텍스트가 큰가"** 로 판단하는 편이 비용상 맞다. -- **`2ac02dde` 는 `99be2a6c` 로 이어진 세션(fork/resume)이라 메시지가 전부 중복** — 0원으로 처리했다. 세션 간 중복을 제거하지 않으면 408건 / **$79.16** 이 나와 **$9.09 과대집계**된다. 재집계할 때 메시지 ID 중복 제거를 반드시 넣을 것. - -### 2-4. 이 숫자를 읽을 때 주의할 점 - -- **구독제(Max/Pro)로 쓰고 있다면 이 금액이 그대로 청구되지는 않는다.** 토큰 사용량을 공개 API 요율로 환산한 "이만큼 썼다" 값이다. 종량제 API 키로 같은 작업을 돌렸다면 나왔을 금액. -- 요율 기준: Opus 5 입력 $5 / 출력 $25 per MTok, 캐시 읽기 ×0.1, 캐시 쓰기 5m ×1.25 · 1h ×2. -- 1M 컨텍스트 구간에 별도 프리미엄이 붙는 경우 실제 금액은 이보다 높을 수 있다 — **하한선으로 읽을 것.** -- 원화 환산은 1,400원/USD 가정이다. 실제 청구 환율과 다를 수 있다. -- **이 스냅샷은 찍는 순간에도 움직인다.** 집계 시각 기준으로 `4db7aca5`·`99be2a6c`·`19be092e`·`6052d29a` 등 여러 세션이 동시에 살아 있었다. 4절의 스크립트로 언제든 다시 뽑을 것. - ---- - -## 3. 앞으로 할 일 - -### 3-1. 비용이 실제로 발생하기 시작하는 지점 - -수집 파이프라인이 도는 순간부터다: - -``` -상호명 → [Perplexity: 채널 URL 발견] ← 토큰 + 검색 호출 과금 - → [Kakao Local: 동일 업소 검증] ← 건당 2원 - → 크롤링(무료) - → [Gemini: 사진 분류 + 카피] ← 토큰 과금 -``` - -업소 1건당 Perplexity 1~2회 + Kakao 여러 건 + Gemini(사진 수만큼)가 붙는다. **업소당 원가를 먼저 실측한 뒤 배치를 키우는 순서**가 안전하다. - -### 3-2. 사이트 1건당 예산: **$1 (1,400원)** — 코드로 강제한다 - -제품 제약이 정해졌다: **업소 1곳의 사이트를 만드는 외부 API 비용은 $1 을 넘으면 안 된다.** - -문서에만 적으면 지켜지지 않으므로 가드를 구현했다 — `backend/common/cost.py` (`CostMeter`), 테스트 `backend/tests/test_cost.py` 8건. +## 3. 상한을 코드가 강제한다 ```python meter = CostMeter(place_id=42) -meter.guard(Provider.PERPLEXITY, searches=2, tokens=3000) # 호출 "전" 에 확인 → 넘으면 BudgetExceeded +meter.guard(Provider.PERPLEXITY, searches=2, tokens=3000) # 호출 "전" → 넘으면 BudgetExceeded ...실제 호출... -meter.charge(Provider.PERPLEXITY, searches=2, tokens=3100) # 호출 "후" 에 실측 기록 +meter.charge(Provider.PERPLEXITY, searches=2, tokens=3100) # 호출 "후" 실측 기록 ``` -- `guard()` 가 막으면 **그 호출을 하지 않는다.** 이미 쓴 돈은 못 돌려받지만 손실이 선형으로 늘어나는 걸 끊는다. -- 예산 70% 소진 시 경고 로그, 100% 초과 시 예외. -- 미터 1개 = 사이트 1건. +- `guard()` 가 막으면 **그 호출을 하지 않는다.** 이미 쓴 돈은 못 돌려받지만 손실이 선형으로 + 늘어나는 걸 끊는다. +- 예산 70% 소진 시 경고 로그, 100% 초과 시 예외. **미터 1개 = 사이트 1건.** +- `solution/backend/common/cost.py` · 테스트 `tests/test_cost.py` 8건. -**⚠️ 단가표에 아직 추정치가 섞여 있다.** 확정된 건 카카오뿐이다(레포에 2원/0.5원이 적혀 있다). Perplexity·Gemini 는 공식 단가표를 확인해서 `RATES` 를 교체해야 한다. 그때까지 `assert_rates_confirmed()` 가 실배치를 막는다 — 추정치 위에 "예산 안에 들어온다" 는 결론을 세우지 않기 위해서다. +⚠️ **Gemini 단가가 아직 추정치다.** 그래서 `assert_rates_confirmed()` 가 실배치를 막는다 — +추정치 위에 "예산 안에 들어온다" 는 결론을 세우지 않기 위해서다. 공식 단가표로 `RATES` 를 +교체하면서 `confirmed=True` 로 바꾼다. -### 3-3. 지금 없는 것 — 호출 로그 테이블 +비용이 실제로 붙는 순서: -현재 DB 스키마(`backend/common/database/model/models.py`)에 **API 호출·비용을 적재하는 테이블이 없다.** 지금은 애플리케이션 로그로만 남는다 (`perplexity.py` 가 `tokens=` 와 검색 횟수를 찍는 정도). - -파이프라인을 돌리기 전에 `api_call_logs` 같은 테이블(공급자 · 엔드포인트 · 토큰/호출수 · 추정단가 · place_id · 시각)을 얹어두면, 이 문서를 손으로 갱신하지 않고 쿼리로 뽑을 수 있다. - ---- - -## 4. 재집계 방법 - -Claude 사용액은 아래로 다시 뽑는다 (세션 트랜스크립트 기준, 메시지 ID로 중복 제거): - -```bash -cd ~/.claude/projects && python3 - <<'PY' -import json,glob,collections,os -files=sorted(glob.glob("-Users-minachoi-projects-o2o-web4ai*/*.jsonl") - + glob.glob("-Users-minachoi-projects-o2o-web4ai*/*/subagents/*.jsonl"), - key=lambda p:-os.path.getsize(p)) -IN,OUT,CR,W5,W1H = 5.0/1e6, 25.0/1e6, 0.50/1e6, 6.25/1e6, 10.0/1e6 # Opus 5 요율 -seen=set(); t=collections.Counter() -for f in files: - for line in open(f,encoding="utf-8",errors="replace"): - try: o=json.loads(line) - except: continue - m=o.get("message") or {}; u=m.get("usage") - if not u: continue - rid=m.get("id") - if rid: - if rid in seen: continue # ★ fork/resume 중복 제거 — 빠뜨리면 과대집계 - seen.add(rid) - cc=u.get("cache_creation") or {} - w5=cc.get("ephemeral_5m_input_tokens",0); w1=cc.get("ephemeral_1h_input_tokens",0) - if w5+w1==0: w5=u.get("cache_creation_input_tokens",0) - t["in"]+=u.get("input_tokens",0); t["out"]+=u.get("output_tokens",0) - t["w5"]+=w5; t["w1h"]+=w1; t["cr"]+=u.get("cache_read_input_tokens",0); t["n"]+=1 -cost=t["in"]*IN+t["out"]*OUT+t["w5"]*W5+t["w1h"]*W1H+t["cr"]*CR -print(dict(t)); print(f"요청 {t['n']}건 / ${cost:.2f}") -PY +``` +상호명 → [Perplexity: 채널 URL 발견] ← 요청 + 토큰 + → [TourAPI: fact 수집] ← 무료 + → 크롤링(무료) + → [Gemini: 사진 분류 + 카피] ← 토큰 (사진 수에 비례) ``` -외부 API 실호출 여부 확인: +**업소당 원가를 먼저 실측한 뒤 배치를 키우는 순서**가 안전하다. -```bash -cd ~/.claude/projects && grep -c "api.perplexity.ai\|generativelanguage.googleapis.com\|dapi.kakao.com" \ - -- -Users-minachoi-projects-o2o-web4ai*/*.jsonl -# 매칭이 있어도 대부분 소스 작성 텍스트다 — tool_use 의 command/url 필드인지 확인할 것 -``` +## 4. 아직 없는 것 — 호출 로그 테이블 + +DB(`solution/backend/common/database/model/models.py`, 17테이블)에 **API 호출·비용을 적재하는 +테이블이 없다.** 지금은 애플리케이션 로그로만 남는다(`perplexity.py` 가 토큰과 검색 횟수를 찍는 정도). + +파이프라인을 배치로 돌리기 전에 `api_call_logs`(공급자 · 엔드포인트 · 토큰/호출수 · 추정단가 · +`place_id` · 시각)를 얹어두면, 이 문서를 손으로 갱신하지 않고 쿼리로 뽑을 수 있다. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f9ea487..8cbfc2d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -21,6 +21,12 @@ React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포 계약의 타입은 `solution/shared/src/types/site-payload.ts` 하나다. +⚠️ **payload 는 화면·JSON-LD 뿐 아니라 하이드레이션 블롭으로도 HTML 에 통째로 박힌다.** +그래서 거르는 자리가 `solution/shared/src/lib/facts.ts` **한 곳**이다 — +`sanitizePayloadForPublish()` 를 프리렌더가 먼저 통과시킨 뒤 렌더와 임베드 양쪽에 쓴다. +화면과 JSON-LD 만 걸렀더니 그 블롭에 미검증 값이 남아 **원본 HTML 을 읽는 AI 가 그걸 읽었다** +— 실제로 한 번 그렇게 샜고, 그래서 이 함수가 생겼다. + ## 2. 발행 파이프라인 ``` @@ -95,9 +101,9 @@ nginx 는 `try_files $uri $uri/index.html =404` 로 같은 규칙을 맞춘다 o2o-web4ai/ ├─ solution/ 사장님 — 사이트 만들기·관리 │ ├─ backend/ FastAPI + 워커 · 이 레포의 유일한 백엔드 -│ ├─ front/ 빌더 (위저드 + 에디터 + 발행 게이트) +│ ├─ frontend/ 빌더 (위저드 + 에디터 + 발행 게이트) │ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더) -│ └─ shared/ front·site·백엔드 계약 (SitePayload · slug · 토큰) +│ └─ shared/ frontend·site·백엔드 계약 (SitePayload · slug · 토큰) │ ├─ admin/ 우리 — 전체 사이트 운영 │ ├─ backend/ 내부 API 진입점(:9801). 도메인 코드는 solution/backend @@ -111,6 +117,19 @@ o2o-web4ai/ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 가르는 선례, `lps-admin/` 이 **백엔드 없이 프론트만 가진 최상단 폴더**의 선례다 — `admin/` 이 후자다. +### `frontend`(빌더)와 `site`(발행물)는 요구가 정반대다 + +같은 `solution/` 안에 있지만 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라 +발행 사이트에 그대로 쓰면 크롤러가 `
` 만 읽고 떠난다. + +| | `frontend/` (빌더) | `site/` (발행 사이트) | +|---|---|---| +| 사용자 | 사장님 | 손님 · **검색/AI 크롤러** | +| 렌더링 | CSR SPA | **SSG (정적 HTML)** | +| 색인 | `noindex` | 색인·인용되라고 존재 | +| 런타임 | Vite dev / 정적 호스팅 | **서버 없음.** 파일만 | +| 데이터 | 편집 중 상태(미확인 값 포함) | `SitePayload` — **확인된 값만** | + ### 왜 갈랐나 — 취향이 아니라 셋 다 실제 문제였다 1. **내부 기능이 사장님 번들에 실려 나갔다.** 한 앱이면 `/local-content`, `/places/:id/seo` @@ -132,16 +151,18 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 | 내부 API | **9801** | `admin/backend/main.py` → `app.py` | **앱 전체 `role >= DEVELOPER`** | `services`·`crud`·`models` 은 공유한다 — `admin/backend` 는 진입점 두 파일뿐이고, -도메인 코드는 `PYTHONPATH=/app/solution/backend` 로 그대로 import 한다. **admin 전용 라우터가 0개**이기 때문이다 — 세어봤다: +도메인 코드는 `PYTHONPATH=/app/solution/backend` 로 그대로 import 한다. admin 화면이 부르는 +엔드포인트가 **사장님 빌더가 쓰는 것과 거의 같기 때문**이다 — 세어봤다: ``` useGetPlace · useListPlaces · useListLinks · useConfirmLink → router/v1/place useListFacts · useGetSchema · useTransitionFact → router/v1/fact +/v1/admin/local-content (customFetch 직접 호출) → router/v1/local ← 유일한 admin 전용 ``` -admin 화면이 부르는 게 전부 `place`·`fact` 이고, 그 둘은 사장님 빌더도 쓴다. 자체 백엔드에 -엔드포인트를 새로 쓰면 **같은 DB 의 같은 테이블을 두 벌** 구현하는 것뿐이다. -그래서 같은 router 객체를 다시 마운트하고 **앱 단위로 권한만 덧건다.** +`place`·`fact` 는 사장님 빌더도 쓴다. 자체 백엔드에 엔드포인트를 새로 쓰면 **같은 DB 의 같은 +테이블을 두 벌** 구현하는 것뿐이다. 그래서 같은 router 객체를 다시 마운트하고 +**앱 단위로 권한만 덧건다.** **왜 경로 접두어(`/v1/admin/...`)가 아니라 포트인가.** 접두어는 같은 프로세스 안이라 사장님이 닿는 서버에 내부 엔드포인트가 **존재한다.** 포트를 가르면 사장님이 닿는 @@ -158,11 +179,15 @@ OWNER role=2 → 403 OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다. `auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). +⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`). +위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가 +사장님(USER)을 막고 있을 뿐이라, **가른 의미가 여기서만 절반이다.** 내리는 것이 남은 일이다. + → 대가: `solution/backend` 의 코드에 묶인다. 배포는 갈리지만 소스는 한 벌이다. ### 두 앱이 코드를 나눠 갖는 방식 — `@` 가 solution 을 가리킨다 -`admin/vite.config.ts` 와 `tsconfig.json` 에서 **`@` 는 `solution/frontend/src`** 다. +`admin/frontend/vite.config.ts` 와 `tsconfig.json` 에서 **`@` 는 `solution/frontend/src`** 다. admin 자기 파일만 `@admin` 이다. 왜 복제하지 않았나: 내부 화면이 쓰는 API 클라이언트·UI 프리미티브·수집 배선이 solution 에 @@ -201,6 +226,10 @@ admin 자기 파일만 `@admin` 이다. 그때 `solution/frontend` 의 인증 정책을 다시 본다. - 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을 `127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.** +- **폰트 self-host** — `solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로 + 폴백된다. `@font-face` 가 조용히 실패하는 것이라 빌드는 안 깨진다. +- **이미지 최적화** — 원본 URL 을 그대로 쓴다(`srcset`·WebP 없음). media 파이프라인이 우리 쪽 + 저장소를 갖게 된 뒤의 일이다 ([DEPLOY.md 1절](DEPLOY.md) 의 핫링크 리스크와 같은 항목). ## 5. 산출물 — 사이트 하나 = 한 장 @@ -247,9 +276,11 @@ DB 에 HTML 컬럼은 없다 (`site_versions.snapshot` JSONB 가 원본). | 컨테이너 | 무엇 | 포트 | |---|---|---| -| `o2o-web4ai-api` | FastAPI — 화면이 부르는 API | 9800 | +| `o2o-web4ai-api` | 사장님 API (`solution/backend/web_main.py`) | 9800 | +| `o2o-web4ai-api-admin` | 내부 API (`admin/backend/main.py`) — 앱 전체 `role >= DEVELOPER` | 9801 (기본 `127.0.0.1`) | | `o2o-web4ai-worker` | 잡 러너 (BUILD·수집·생성) + 스케줄러 | — | -| `o2o-web4ai-web` | admin Vite + `watch-payloads` 프리렌더 + `serve-sites`(개발용 :3001) | 3000 | +| `o2o-web4ai-web` | 사장님 빌더 Vite + `watch-payloads` 프리렌더 + `serve-sites`(개발용 :3001) | 3000 | +| `o2o-web4ai-admin` | 내부 운영 화면 Vite | 3002 (기본 `127.0.0.1`) | | `o2o-web4ai-nginx` | **발행 사이트 정적 서빙** — `site-out` 볼륨을 읽기 전용으로 | 80 | DB(PostgreSQL)는 **compose 밖**이다 — 호스트에서 돌고 `host.docker.internal` 로 붙는다. diff --git a/docs/COLLECTION_SEO_AEO_FLOW.md b/docs/COLLECTION_SEO_AEO_FLOW.md index 67e4191..31167ad 100644 --- a/docs/COLLECTION_SEO_AEO_FLOW.md +++ b/docs/COLLECTION_SEO_AEO_FLOW.md @@ -34,8 +34,8 @@ 주요 코드: -- `backend/services/place_service.py` -- `backend/router/v1/place/` +- `solution/backend/services/place_service.py` +- `solution/backend/router/v1/place/` ## 3. 채널 URL 확보 @@ -58,8 +58,8 @@ Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지 주요 코드: -- `backend/services/external/perplexity.py` -- `backend/services/prompts/channel_discovery.py` +- `solution/backend/services/external/perplexity.py` +- `solution/backend/services/prompts/channel_discovery.py` - `solution/frontend/src/features/onboarding/useCollectFlow.ts` - `solution/frontend/src/features/onboarding/ChannelConfirmPanel.tsx` @@ -83,14 +83,14 @@ Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지 - 숙박: 객실, 입·퇴실, 인원, 편의시설 등 - 관광·체험: 프로그램, 장비, 운영 정보 등 -정확한 필드 목록은 `backend/common/category_schema.py`가 관리한다. 어댑터가 값을 찾더라도 업종 스키마에 없는 항목은 정식 fact로 사용하지 않는다. +정확한 필드 목록은 `solution/backend/common/category_schema/`(업종별 JSON + 로더)가 관리한다. 어댑터가 값을 찾더라도 업종 스키마에 없는 항목은 정식 fact로 사용하지 않는다. 주요 코드: -- `backend/services/collect_service.py` -- `backend/services/collector/` -- `backend/common/category_schema.py` -- `backend/services/fact_service.py` +- `solution/backend/services/collect_service.py` +- `solution/backend/services/collector/` +- `solution/backend/common/category_schema/` +- `solution/backend/services/fact_service.py` ## 5. 정보 승인 @@ -117,9 +117,9 @@ Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지 관련 파일: -- `backend/services/external/gemini.py` -- `backend/services/prompts/vision.py` -- `backend/services/vision_service.py` +- `solution/backend/services/external/gemini.py` +- `solution/backend/services/prompts/vision.py` +- `solution/backend/services/vision_service.py` ### Gemini Text @@ -142,9 +142,9 @@ Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지 관련 파일: -- `backend/services/external/gemini_text.py` -- `backend/services/prompts/copy.py` -- `backend/services/copy_service.py` +- `solution/backend/services/external/gemini_text.py` +- `solution/backend/services/prompts/copy.py` +- `solution/backend/services/copy_service.py` ## 7. 정적 빌드와 발행 @@ -166,10 +166,10 @@ Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지 관련 파일: -- `backend/services/build_service.py` -- `backend/services/snapshot.py` -- `backend/services/site_payload.py` -- `backend/services/publish_gate.py` +- `solution/backend/services/build_service.py` +- `solution/backend/services/snapshot.py` +- `solution/backend/services/site_payload.py` +- `solution/backend/services/publish_gate.py` - `solution/site/` ## 8. SEO 구현 @@ -215,11 +215,11 @@ JSON-LD 값은 화면에도 동일하게 존재해야 한다. 렌더러가 양 - `solution/site/src/seo/llms.ts` - `solution/site/src/seo/verify.ts` - `solution/site/src/sections/AnswerBlock.tsx` -- `backend/services/publish_gate.py` +- `solution/backend/services/publish_gate.py` ## 10. SEO·AEO 진단 점수 -관리자 화면은 현재 저장 정보와 최신 발행본을 기준으로 SEO와 AEO 준비도를 각각 100점으로 계산한다. +내부 운영 화면(`admin`)은 현재 저장 정보와 최신 발행본을 기준으로 SEO와 AEO 준비도를 각각 100점으로 계산한다. SEO 항목: @@ -246,9 +246,9 @@ AEO 항목: 관련 파일: -- `backend/services/seo_audit.py` -- `backend/router/v1/site/site.py` -- `solution/frontend/src/pages/SeoAuditPage.tsx` +- `solution/backend/services/seo_audit.py` +- `solution/backend/router/v1/site/site.py` +- `admin/frontend/src/pages/SeoAuditPage.tsx` API: diff --git a/docs/DATA_SOURCE_RESEARCH.md b/docs/DATA_SOURCE_RESEARCH.md index 7bcfd23..9394e8a 100644 --- a/docs/DATA_SOURCE_RESEARCH.md +++ b/docs/DATA_SOURCE_RESEARCH.md @@ -318,11 +318,11 @@ URL → StaticHtmlAdapter (robots 준수) ─┐ | 파일 | 역할 | |---|---| -| `backend/services/collector/static_html_adapter.py` | robots.txt 준수 fetch + JSON-LD/OG 추출. `_DENY_HOSTS` 로 플랫폼 차단 | -| `backend/services/prompts/extract.py` | 추출 프롬프트·응답 스키마 (업종 스키마에서 자동 생성) | -| `backend/services/grounding/extract.py` | **evidence 원문 대조 게이트** | -| `backend/services/external/gemini_extract.py` | 엮는 자리 | -| `backend/tests/test_extract_grounding.py` | 게이트 회귀 테스트 17건 | +| `solution/backend/services/collector/static_html_adapter.py` | robots.txt 준수 fetch + JSON-LD/OG 추출. `_DENY_HOSTS` 로 플랫폼 차단 | +| `solution/backend/services/prompts/extract.py` | 추출 프롬프트·응답 스키마 (업종 스키마에서 자동 생성) | +| `solution/backend/services/grounding/extract.py` | **evidence 원문 대조 게이트** | +| `solution/backend/services/external/gemini_extract.py` | 엮는 자리 | +| `solution/backend/tests/test_extract_grounding.py` | 게이트 회귀 테스트 17건 | 비용: 호출당 약 $0.006 (gemini-3.7-flash, temperature 0.0). diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 34e590f..55675e7 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -18,6 +18,12 @@ | 카카오맵 | **불가** | 상세는 SPA 셸(3.6KB), 내부 API 406 차단 | | **사장님이 확정한 자체 홈페이지** | **가능** | 사장님 동의 기반. → `StaticHtmlAdapter` 등록(2026-08-28) | +**추가 결론 (2026-08-31)** — TourAPI 어댑터를 붙여 같은 표본으로 대조한 결과 +([DATA_SOURCE_RESEARCH.md](DATA_SOURCE_RESEARCH.md) 8-2), 네이버 플레이스는 숙박 fact 2건 · +객실명만 주는데 TourAPI 는 fact 8종 · 객실 20개의 인원/요금/면적을 준다. **정보량도 적고 +출처도 못 밝힌다** — `naver_place` 어댑터를 유지할 근거가 사실상 사라졌다. +아직 `COLLECT_ADAPTERS` 기본값에 남아 있으므로, 내리는 것이 남은 일이다. + 부수 결론: **네이버·카카오 생태계는 AI 크롤러를 전면 차단**한다(플레이스·지도·블로그·예약·카카오맵 모두 `Disallow: /`). 즉 그 데이터를 긁어와도 AI 검색에는 원래 없던 정보라 GEO 이득이 없고, 출처를 밝힐 수 없어 `llms.txt`·`AnswerBlock`의 "출처와 검증 시각" 신호도 못 채운다. 공공데이터(TourAPI·LOCALDATA)가 그 자리를 대신한다. | 항목 | 내용 (원안) | @@ -82,11 +88,13 @@ ## 3. 다음 단계에서 정해야 할 것 (법무 이슈 아님) -- **작업 큐 구현 방식.** 원본 보일러플레이트엔 APScheduler 크론만 있고 작업 큐가 없다. - 수집 + 비전 분석 + 빌드는 동기 요청으로 처리할 수 없다(몇 분). 후보: DB 테이블 기반 잡 큐(추가 인프라 0) / Celery + Redis / arq. - → **DB 잡 테이블 + 상태 폴링 API** 를 기본안으로 제안. 완료 시 알림톡. +- ~~**작업 큐 구현 방식.**~~ → **결론남 (2026-08-27)**: LPS 의 `job` 큐를 그대로 이식했다. + DB 잡 테이블 + 상태 폴링 API. 근거와 세부는 아래 5-2. +- ~~**지역 정보 캐시 TTL.**~~ → **날씨는 결론남 (1시간, `local_content_service.get_weather`)**. + 상류가 죽으면 만료된 값을 `stale` 표시로 내보낸다 — 빈 값을 내보내지 않는다(절대규칙 9). + **축제·관광지는 아직 TTL 이 없다** — 지금은 `sync-festivals` 를 사람이 눌러야 돈다. - **TourAPI areaCode ↔ 카카오 행정구역 코드 매핑 테이블.** 두 체계가 다르므로 매핑을 데이터로 관리할지 코드로 박을지. -- **지역 정보 캐시 TTL.** 행정구역 코드 단위 캐싱은 확정. 날씨/축제/관광지 각각의 갱신 주기만 정하면 된다. + (카카오 키가 아직 미발급이라 실제로 부딪히지 않았다) --- @@ -108,12 +116,12 @@ | `server_default` | 신규 도메인 테이블에만 추가 | ORM `default=` 는 Python 쪽이라 raw INSERT 에 안 먹는다. `create_all`(테스트 DB)과 `init.sql`(실 DB)이 갈라져서 실제로 버그가 났다. **`companies`/`users` 는 원본 그대로 두었다** | | ORM ↔ init.sql 정합성 | `tests/test_schema_ddl.py` 가 파일을 파싱해 대조 | 스키마 정의가 두 곳에 있는 구조(원본 컨벤션)라, 드리프트를 테스트로 막는다 | | 외부 API 키 주입 | 레포 최상위 `.env` + `python-dotenv` | 우선순위 = 실제 환경변수 > `.env` > toml. **`APP_ENV=test` 면 `.env` 를 읽지 않는다** — 실키가 새면 테스트가 외부 API 를 때리고 요금이 나간다 | -| 키 출처 | Perplexity·Gemini 는 `o2o-infinith-backend/.env` 값 재사용. 카카오·TourAPI 는 **미발급** | 사내 어디에도 카카오/TourAPI 키가 없다. 발급 후 `.env` 에 채워야 6번 단계가 돈다 | +| 키 출처 | Perplexity·Gemini 는 `o2o-infinith-backend/.env` 값 재사용. **TourAPI 는 2026-08-31 활용신청 승인**, 카카오는 여전히 **미발급** | 카카오 키가 없으면 `kakao` 어댑터만 비활성이고 주변 정보 블록이 비어 뜬다 | ### 아직 테이블이 없는 것 - **`report` 스키마** — 노출 리포트·유입 통계(GA4 Data API, Search Console API). 작업 순서 6번 이후. -- **작업 큐** — 아래 3번의 미결 사항. 수집·비전분석·빌드를 담을 잡 테이블이 아직 없다. +- ~~**작업 큐**~~ → `job.jobs` 로 생겼다 (2026-08-27, 5-2). 17번째 테이블이다. - **TourAPI areaCode ↔ 카카오 행정구역 코드 매핑** — 테이블 대신 `common/category_schema` 와 같은 리소스 JSON 으로 두는 것을 제안. 3번 참고. --- @@ -169,8 +177,10 @@ ### 5-3. 아직 안 한 것 -- **`검색어 → URL 발견 → 크롤링 → 에셋` 체인은 아직 안 돈다.** 작업순서 4번(collector). - 지금은 URL 을 **받아서** 저장하는 그릇만 있다(Perplexity 자동 호출 없음). -- 사진(`place.media`)은 테이블만 있고 업로드·저장 경로가 없다. 이미지 재게시 권리(1-2)가 미결이라 - Azure Blob 클라이언트를 일부러 아직 이식하지 않았다. +- ~~**`검색어 → URL 발견 → 크롤링 → 에셋` 체인**~~ → **돈다 (2026-08-28~31)**. + Perplexity 채널 발견 + `tour_api`·`naver_place`·`static_html` 어댑터가 붙었다 + ([DATA_SOURCE_RESEARCH.md](DATA_SOURCE_RESEARCH.md) 8-1·8-2). +- 사진(`place.media`)은 **조회는 열렸고**(`/v1/place/{id}/media`, 빌더가 `useListMedia` 로 읽는다) + **업로드·저장 경로가 없다.** 이미지 재게시 권리(1-2)가 미결이라 Azure Blob 클라이언트를 + 일부러 아직 이식하지 않았다. - 카카오 REST API 키 / TourAPI 키가 사내 어디에도 없다. 발급해서 `.env` 에 채워야 실제 연동이 돈다. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 446e4d4..d4f15eb 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -72,7 +72,7 @@ site-out/ 프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 그래서 로컬 out/ 은 재시작만 하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게 -`backend/scripts/republish_all.py` 다. +`solution/backend/scripts/republish_all.py` 다. **규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** @@ -91,8 +91,10 @@ docker compose exec worker python scripts/republish_all.py | 어디 | 무엇 | 기본값 | |---|---|---| -| 백엔드 | `SITE_PUBLIC_HOST` (`site_payload.py`) | `w4ai.o2o.kr` | -| 프론트(admin) | `VITE_PUBLISH_HOST` | 없으면 현재 브라우저 호스트 | +| 백엔드 | `SITE_PUBLIC_HOST` (`site_payload.py` 의 `DEFAULT_HOST`) | `w4ai.o2o.kr` | +| 프론트(빌더·`web` 컨테이너) | `VITE_PUBLISH_HOST` | compose 가 루트의 `SITE_PUBLIC_HOST` 를 흘려보낸다 | + +★ 프론트 `.env` 에 따로 적지 않는다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다. 이 값이 canonical·og:url·sitemap·IndexNow 통보에 전부 들어간다. 다른 주소에 올릴 거면 `SITE_PUBLIC_HOST` 를 먼저 덮는다. 안 고치면 발행은 성공하는데 사이트맵과 색인 통보가 @@ -168,7 +170,7 @@ docker compose exec worker python scripts/republish_all.py # 전체 ### 4-2단계 — 도메인 · TLS · 색인 1. DNS 를 Blob 정적 웹사이트(또는 앞단 CDN)로 연결 2. HTTPS 확인 — 커스텀 도메인 + TLS 는 Blob 단독으로는 안 되고 CDN/Front Door 가 필요하다 -3. `DEFAULT_HOST` 가 실제 도메인과 같은지 재확인 → 다르면 고치고 **전체 재발행** +3. `SITE_PUBLIC_HOST` 가 실제 도메인과 같은지 재확인 → 다르면 고치고 **전체 재발행** 4. 그 다음에야 `INDEXNOW_KEY` 를 채운다. 백엔드와 `o2o-web4ai-web` 이 **같은 값**이어야 한다 (프리렌더가 루트에 `.txt` 를 굽고 검색엔진이 대조한다 — 어긋나면 403) 5. `https://<도메인>/.txt` 와 `https://<도메인>/robots.txt` 가 열리는지 확인 diff --git a/docs/DEVELOPMENT_DIRECTION.md b/docs/DEVELOPMENT_DIRECTION.md index 492b284..f754510 100644 --- a/docs/DEVELOPMENT_DIRECTION.md +++ b/docs/DEVELOPMENT_DIRECTION.md @@ -24,7 +24,7 @@ | Site AEO A1~A9 | **부분 구현** — A3·A5·A6·A7 일부와 A8 중심 | | Brand AEO B1~B9 | **미구현** — 준비도 자체 점수만 있으며 실제 AI 응답 측정은 없음 | | 운영 콘솔 15개 화면 | **부분 구현** — 사업장·빌더·지역정보·SEO/AEO 준비도 중심 | -| 계약 A~G / BFF | **미구현** — 단일 FastAPI API를 admin이 직접 호출 | +| 계약 A~G / BFF | **미구현** — 화면이 FastAPI 를 직접 호출 (BFF 없음) | | 25테이블 append-only Fact Graph | **다른 모델로 구현** — 승인 후보/노출값 중심의 key-value fact 모델 | | 8개 스프린트 일정 | **현재 코드에 바로 적용 불가** — 이미 구현된 것과 방향 충돌 항목이 섞여 있어 재산정 필요 | @@ -41,11 +41,11 @@ | 입력 방식 | 고객 원본 사이트를 depth 3·최대 200페이지 크롤 | 공식 API, 사용자 확정 URL, 정적 HTML; 플랫폼 우회 수집 금지 | 현재의 출처·동의·robots 원칙 유지. 대규모 원본 사이트 수집은 별도 제품 모드로 분리 | | 동적 크롤링 | 정적 우선, Playwright 폴백 | 봇 탐지 우회로 변질될 수 있어 HeadlessAdapter 미등록·금지 | 포괄적인 Playwright 폴백은 채택하지 않음. 소유권이 검증된 고객 도메인에만 허용할지 법무·보안 결정 후 제한적으로 검토 | | 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `w4ai.o2o.kr/s/`, custom domain 경로 미완성 | 설계서 방향이 검색 권위 측면에서 더 적합. 고객 도메인 연결·소유권 검증을 우선 과제로 추가 | -| 백엔드 구조 | Site/Brand 엔진, BFF, 엔진별 DB 분리 | 단일 FastAPI + PostgreSQL + admin 직접 호출 | 파일럿 단계에서는 모듈 경계와 API 계약만 먼저 만들고, 물리 분리는 트래픽·팀 소유권 근거가 생긴 뒤 수행 | +| 백엔드 구조 | Site/Brand 엔진, BFF, 엔진별 DB 분리 | 코드 한 벌 + 진입점 둘(:9800 사장님 / :9801 내부), 단일 PostgreSQL | 청중별 분리는 포트로 끝냈다. 엔진별 물리 분리는 트래픽·팀 소유권 근거가 생긴 뒤 수행 | | 작업 인프라 | Temporal, Redis, Celery 등 공통 인프라 | PostgreSQL 잡 큐 + lease + dead-letter | 현재 DB 큐 유지. 동일 책임의 인프라를 중복 도입하지 않음. 장기 워크플로 보상·분산 추적 요구가 확인될 때 Temporal 재평가 | | Fact Graph | 엔티티·predicate·snapshot, append-only | 업종 스키마 기반 key-value fact, 후보/노출/이력 상태 | 현재 모델은 발행 안전성에 적합. Brand 측정 재현성에 필요한 snapshot과 entity 관계만 점진적으로 확장 | | 점수 | Site AEO Score + 실제 4개 AI 엔진 기반 AVS | 내부 데이터 기반 SEO/AEO **준비도** 점수 | 이름과 의미를 분리 유지. 실제 측정 전 현재 점수를 AVS/가시성 점수라고 부르지 않음 | -| 운영 사용자 | Reviewer/Owner 권한과 15개 통합 화면 | USER/OWNER/DEVELOPER, 사장님 빌더와 운영 화면이 한 앱 | 규제 기능 도입 전 Reviewer 역할·서버 계산 `allowed_actions` 추가. 사장님 앱과 내부 콘솔 분리 계획 수립 | +| 운영 사용자 | Reviewer/Owner 권한과 15개 통합 화면 | USER/OWNER/DEVELOPER, 사장님 앱(:3000)과 내부 콘솔(:3002)이 갈려 있다 | 규제 기능 도입 전 Reviewer 역할·서버 계산 `allowed_actions` 추가 | | 이미지 | 원본 사진·영상이 EEAT 근거 | 이미지 호스팅을 현재 non-goal로 두고 외부 URL 사용 | 저작권 결론과 소유자 업로드 저장소가 먼저. 크롤 이미지 재게시를 전제로 개발하지 않음 | --- @@ -84,8 +84,8 @@ ### 3-3. 콘솔·계약·데이터 -- 현재 admin에는 로그인, 사업장 목록/상세, 빌더, 지역 콘텐츠, SEO 진단 등이 있으나 설계서의 질문 빌더·규제 승인·AI 퍼포먼스·랭킹·인용출처·정기 리포트 화면은 없다. -- 프론트는 단일 API를 직접 호출한다. 설계서의 BFF, 서비스 JWT 교환, 섹션별 부분 실패, 계약 A~G는 없다. +- 내부 콘솔(`admin/frontend`)에는 사업장 목록/상세, 지역 콘텐츠, SEO 진단이 있고 빌더는 사장님 앱(`solution/frontend`)에 있다. 설계서의 질문 빌더·규제 승인·AI 퍼포먼스·랭킹·인용출처·정기 리포트 화면은 없다. +- 화면이 API 를 직접 호출한다. 설계서의 BFF, 서비스 JWT 교환, 섹션별 부분 실패, 계약 A~G는 없다. - DB에는 현재 17개 ORM 모델이 있으며 설계서의 `document`, `predicate_def`, `entity`, `fact_snapshot`, `compliance_rule`, `review`, `publication_question`, `index_state`, `regeneration_request`, `event_outbox`, `audit_log` 등에 해당하는 완성 모델은 없다. - 현재 fact는 수정 잠금과 후보 이력을 보존하지만, 설계서가 요구하는 전체 append-only 불변식·스냅샷 재현성 모델과 같지는 않다. @@ -118,7 +118,7 @@ 3. A7 SimHash 중복도 검사 및 fact 역참조 리포트 4. A9 원본 변경 감지, fact 만료/검토, AI 크롤러 방문 로그 5. 고객 도메인 연결, TLS/DNS 운영 절차 -6. 관리자/사장님 앱 경계 정리와 서버 계산 `allowed_actions` +6. 서버 계산 `allowed_actions` (앱 경계 분리는 2026-08-31 완료) 완료 기준은 “페이지가 만들어진다”가 아니라, **소유권이 확인된 원본에서 출처가 남는 fact를 만들고 두 게이트를 통과해 고객 도메인에 발행되며, 변경과 재방문을 관측할 수 있다**는 것이다. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 8d62ef0..c34a40a 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -48,10 +48,10 @@ | 원하는 것 | 몇 번 눌러서 끝나기 | 무엇이 왜 막혔는지 보이기 | 정확한 사실, 즉시 | | 로그인 | 최소 (막히면 만들어 보지도 못한다) | 엄격 | 없음 | | 화면 성격 | 위저드 + 에디터 | 대시보드 + 목록 | 정적 문서 | -| 현재 코드 | `admin/` `/builder` | `admin/` `/places`, `/local-content` | `site/` | +| 현재 코드 | `solution/frontend` `/builder` | `admin/frontend` `/places`, `/local-content` | `solution/site` | -★ **앞의 둘이 지금 한 앱에 섞여 있다.** 왜 나눠야 하는지와 나누는 방법은 -[ARCHITECTURE.md 4절](ARCHITECTURE.md#4-앱-경계--지금-구조와-나눌-지점). +★ **앞의 둘은 2026-08-31 에 두 앱으로 갈랐다** — 한 앱이면 내부 화면 코드가 사장님 번들에 +그대로 실려 나가기 때문이다. 근거와 경계는 [ARCHITECTURE.md 4절](ARCHITECTURE.md). 권한 코드는 `common/enums.py UserRole`: `1 USER`(사장님) / `2 OWNER`(고객사 최상위) / `3 DEVELOPER`(내부 운영 — **고객사에 존재를 노출하지 않는다**). diff --git a/solution/README.md b/solution/README.md deleted file mode 100644 index 71e03b7..0000000 --- a/solution/README.md +++ /dev/null @@ -1,153 +0,0 @@ -# solution — 사장님 앱 (backend · front · site) - -소상공인 홈페이지 자동 생성 솔루션의 프론트엔드. -`o2o-negosium/negodata/front` 보일러플레이트를 이식했다 — 빌드 도구·구조·프로토콜 규약은 원본과 같다. - -**목표는 예쁜 사이트가 아니라 AI 검색이 이 가게를 공식 홈페이지 기준으로 설명하게 만드는 것**이다 -(백엔드 README 와 같은 문장). 그래서 두 앱의 요구사항이 정반대다. - -| | `front/` (빌더) | `site/` (발행 사이트) | -|---|---|---| -| 사용자 | 사장님 · 운영자 | 손님 · **검색/AI 크롤러** | -| 렌더링 | CSR SPA | **SSG (정적 HTML)** | -| 색인 | `noindex` | 색인·인용되라고 존재 | -| 런타임 | Vite dev / 정적 호스팅 | **서버 없음.** 파일만 | -| 데이터 | 편집 중 상태(미확인 값 포함) | `SitePayload` — **확인된 값만** | - -한 프로젝트에 두 앱을 두되 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라 -발행 사이트에 그대로 쓰면 크롤러가 `
` 만 읽고 떠난다. - -``` -solution/ -├── backend/ FastAPI + 워커. payload JSON 만 떨어뜨린다 -├── shared/ front·site 가 공유 — 도메인 enum · SitePayload 계약 · 디자인 토큰 · fact 필터 -├── front/ 빌더 (위저드 4단계 + 실시간 에디터 + 발행 게이트) -└── site/ 발행 사이트 렌더러 + 프리렌더 스크립트 - -내부 운영 화면은 여기 없다 — 최상단 `admin/` 이다(ARCHITECTURE.md 4절). -``` - -## 실행 - -### Docker Compose (기본 실행 방식) - -```bash -docker compose up -d -``` - -- 발행 사이트: `http://localhost:3000/s/` -- API/Swagger: `http://localhost:9800/docs` -- 정적 사이트 저장 위치: `solution/site/out/s//` -- 프리렌더 입력 payload: `solution/site/payloads/.json` -- 컨테이너 내부 정적 서버는 3001을 사용하지만, Docker가 호스트 3000으로만 공개한다. - -예: `http://localhost:3000/s/grazz` - -```bash -npm install # 워크스페이스 루트에서 한 번 - -npm run dev # 빌더 → http://localhost:3000 -npm run dev:site # 발행 사이트 → http://localhost:3001 (데모 payload 로 CSR) - -npm run build # 전체 타입체크 + 린트 + 빌드 -npm run prerender # ★ 정적 사이트 굽기 → site/out// -``` - -백엔드는 기본 `http://localhost:9800` 으로 본다. 바꾸려면 `admin/.env` 에 `VITE_API_BASE_URL`. -`cp admin/.env.example admin/.env` 로 시작한다. - -## 발행 파이프라인 - -``` -관리자 편집 → [발행 게이트] → SitePayload JSON → prerender → 정적 파일 - ↓ 막히면 발행 불가 - 미검증 fact / 필수 항목 누락 / 고유 콘텐츠 0건 -``` - -```bash -# 데모 payload 로 -npm run prerender - -# 실제 payload 로 (백엔드 BUILD 잡이 부르는 자리) -npm run prerender -- --payload=./payloads --out=/var/www -``` - -사이트 하나당 나오는 것: - -``` -out// -├── index.html 홈 (JSON-LD 4종 + 본문 전체가 HTML 에) -├── rooms/index.html 하위 단위 목록 ← 업종별 경로(rooms/menu/programs) -├── rooms//index.html 단위 상세 -├── guide|location|faq/index.html -├── sitemap.xml -├── robots.txt ★ AI 크롤러 명시 허용 -├── llms.txt ★ LLM 이 읽을 사실 목록 -└── assets/ 하이드레이션 번들 -``` - -## SEO / AEO 가 어디에 박혀 있나 - -| 무엇 | 어디 | 왜 | -|---|---|---| -| 정적 HTML | `site/scripts/prerender.ts` | JS 를 실행 안 하는 AI 크롤러가 본문을 그대로 읽는다 | -| 구조화 데이터 | `site/src/seo/jsonld.ts` | 업종별 Schema.org 타입 + FAQPage + BreadcrumbList + WebPage | -| meta · OG · geo | `site/src/seo/meta.ts` `head.ts` | description 을 **확인된 fact 로 조립**한다(지어내지 않는다) | -| `llms.txt` | `site/src/seo/llms.ts` | 사실만. 형용사 금지. 모르는 건 "정보 없음"이라고 적는다 | -| `robots.txt` | `site/src/seo/robots.ts` | GPTBot · ClaudeBot · PerplexityBot 등 **명시 허용** | -| 핵심 정보 요약 | `site/src/sections/AnswerBlock.tsx` | AI 가 답으로 뽑아 가는 단정문을 상단에 고정 배치 | -| FAQ | `site/src/sections/FaqSection.tsx` | `
` — 접혀 있어도 크롤러가 읽는다 | -| 발행 게이트 | `admin/src/features/publish/publishGate.ts` | 백엔드 `PublishRejectReason` 과 1:1 | - -### 절대규칙 1 이 지켜지는 지점 - -> 확인되지 않은 fact 는 사이트에 나가지 않는다. - -한 곳에서만 거른다 — `shared/src/lib/facts.ts`. - -- `selectPublishable()` — `VERIFIED` · `CORRECTED` 만 통과 -- `sanitizePayloadForPublish()` — **프리렌더가 payload 를 여기 통과시킨 뒤 렌더와 임베드 양쪽에 쓴다** - -두 번째가 중요하다. 정적 HTML 은 하이드레이션용으로 payload 를 통째로 심는데, -화면과 JSON-LD 만 걸러 두면 그 블롭에 미검증 값이 남아 원본 HTML 을 읽는 AI 가 그걸 읽는다. -(실제로 한 번 그렇게 샜고, 그래서 이 함수가 생겼다.) - -## 백엔드 연결 - -API 클라이언트는 **전부 orval 생성물**이다(React Query 훅). 화면은 `@/api` 하나만 import 한다. -자세한 규약은 [admin/src/api/README.md](admin/src/api/README.md). - -```bash -npm run orval -w admin # 백엔드가 떠 있을 때 - -cd ../backend && python scripts/export_openapi.py # 서버 없이 — 스펙을 먼저 뽑고 -cd .. && ORVAL_INPUT=solution/backend/openapi.json npm run orval -``` - -열려 있는 도메인은 `auth` / `place` / `fact` / `job` / `site` 전부다. - -| 화면 | 부르는 것 | -|---|---| -| 사업장 목록·상세 | `useListPlaces` `useGetPlace` `useListFacts` `useListLinks` `useTransitionFact` `useConfirmLink` | -| 위저드 2단계(수집) | `POST /place/{id}/collect` → 잡 폴링 — `useGatherSimulation.ts` | -| 위저드 4단계(생성) | `POST /place/{id}/copy` → 잡 폴링 — `Step4Generating.tsx` | -| 발행 | `POST /place/{id}/site/build {publish:true}` → 잡 폴링 — `features/publish/usePublishSite.ts` | - -**발행 = 빌드다.** 백엔드에 발행 엔드포인트가 따로 없는 것이 맞다 — 발행 검수 게이트가 -빌드 잡 안에 있어서(`services/build_service` → `publish_gate`) 게이트를 우회하는 경로가 없다. -그래서 잡이 DONE 이어도 발행됐다는 뜻이 아니다. `job.result.gate.passed` 를 봐야 한다. - -★ `placeId` 가 없는 데모 경로(`/builder`)는 이 호출을 하나도 하지 않는다 — -로그인 없이 도는 화면이라 예전의 타이머 시뮬레이션으로 떨어진다. 실사업장은 `/builder?placeId=`. - -CORS 는 백엔드 `config.local.toml` 의 `client_url` 이 정한다(쉼표로 여러 오리진). -vite 가 3000 을 못 잡고 3001·3002 로 옮겨 뜨면 거기서 막히므로 개발 포트 대역을 함께 적어 둔다. - -## 아직 안 한 것 - -- **폰트 self-host** — `*/public/fonts/PretendardVariable.woff2` 가 없다. 지금은 Noto Sans KR 로 폴백된다 -- **이미지 최적화** — 원본 URL 을 그대로 쓴다. `srcset`/WebP 변환은 media 파이프라인이 붙은 뒤 -- **admin 번들 분할** — 626KB(gzip 185KB). 라우트 단위 `lazy()` 로 나눌 수 있다 -- **사진(media) 연동** — 백엔드에 media 조회 엔드포인트가 없어 빌더의 사진 탭은 실데이터가 비어 있다 - (`stores/builder.ts` 의 `photos: []`). VISION 잡이 붙인 분류·alt 를 읽을 창구가 열리면 채운다 -- **발행 주소** — `sites.domain` 을 채우는 경로가 아직 없다. 그때까지는 상호 슬러그로 주소를 만든다 diff --git a/solution/backend/README.md b/solution/backend/README.md index b11ec1f..1edc26e 100644 --- a/solution/backend/README.md +++ b/solution/backend/README.md @@ -9,16 +9,21 @@ **수집 → 생성 → 빌드 → 발행 파이프라인이 API 로 다 열렸다.** 프론트(`solution/frontend`)가 이 API 를 붙여 쓴다. +★ **코드는 한 벌인데 진입점이 둘이다** — 사장님 API `web_main.py`(:9800, 엔드포인트별 권한)와 +내부 API `admin/backend/main.py`(:9801, 앱 전체 `role >= DEVELOPER`). 같은 router 객체를 다시 +마운트하고 앱 단위로 권한만 덧건다. 왜 경로 접두어가 아니라 포트인지는 +[../../docs/ARCHITECTURE.md 4절](../../docs/ARCHITECTURE.md). + | 모듈 | 상태 | 역할 | |---|---|---| | `places` | 완료 | 사업장 등록·조회, 동일 업소 검증, 하위 단위, 채널 URL 확정 | | `facts` | 완료 | fact CRUD, 검증 상태 전이, 업종 스키마 조회 | | `faqs` | 완료 | FAQ 목록·승인(검증 상태 전이)·직접 추가 — ★ 승인된 것만 FAQPage JSON-LD 로 나간다 | -| `collector` | 완료 | 수집 파이프라인 (어댑터 패턴 — Phase 1 은 MockAdapter 만) | +| `collector` | 완료 | 수집 파이프라인. 어댑터 `tour_api` · `naver_place` · `static_html` · `mock` — `COLLECT_ADAPTERS` 로 개별 on/off | | `generator` | 완료 | Gemini 호출 — 사진 분류(vision), 소개문·FAQ 작성(copy) | -| `local` | 스키마 완료 / API 미착수 | 지역 정보 (날씨·축제·관광지·맛집) + 행정구역 코드 단위 캐싱 | +| `local` | 완료 | 지역 정보 (날씨·축제·관광지·맛집) + 행정구역 코드 단위 캐싱 | | `sites` | 완료 | 사이트 상태, 정적 빌드, 발행 검수 게이트, 발행 상태 전이 | -| `media` | 스키마 완료 / 조회 API 미착수 | 사진·alt. 수집·비전이 채우지만 아직 읽을 창구가 없다 | +| `media` | 완료 | 사진·alt 조회. 수집·비전이 채우고 빌더가 `useListMedia` 로 읽는다 | | `reports` | 미착수 | 노출 리포트, 유입 통계 | > OpenAPI 스펙은 `python scripts/export_openapi.py` 로 서버 없이 뽑을 수 있다 @@ -74,7 +79,7 @@ UNVERIFIED ──┬─> PENDING_OWNER ──┬─> VERIFIED ──┬─> CORR - **활성 fact 는 (사업장, 단위, key) 당 1건** — DB 부분 유니크 인덱스로 강제. `REJECTED`·`EXPIRED` 는 이력이라 유니크에서 빠진다 -미결 사항(크롤링 법적 검토 · 이미지 재게시 권리 · 관리자 수정 범위 · 해지 정책)은 [../docs/DECISIONS.md](../docs/DECISIONS.md). +미결 사항(크롤링 법적 검토 · 이미지 재게시 권리 · 관리자 수정 범위 · 해지 정책)은 [../../docs/DECISIONS.md](../../docs/DECISIONS.md). ## 디렉토리 구조 @@ -82,10 +87,11 @@ UNVERIFIED ──┬─> PENDING_OWNER ──┬─> VERIFIED ──┬─> CORR o2o-web4ai/ ├── .env.example # 외부 API 키·DB 접속 주입 템플릿 (cp .env.example .env) ├── postgres-init/ -│ ├── init-data/init.sql # 스키마 DDL 전부 (재실행 안전) -│ └── alters/ # 컬럼 추가/타입 변경 누적 (YYYY-MM-DD-<주제>.sql) -└── backend/ - ├── web_main.py # 엔트리포인트 (uvicorn) +│ └── init-data/init.sql # 스키마 DDL 한 벌 (재실행 안전. 누적 ALTER 파일은 없다) +├── admin/backend/ # 내부 API 진입점(:9801). 도메인 코드는 아래를 그대로 import 한다 +└── solution/backend/ + ├── web_main.py # 사장님 API 진입점 :9800 (uvicorn) + ├── worker_main.py # 잡 러너 + 스케줄러 ├── config/ # 환경설정 (APP_ENV 별 toml 로드) ├── conftest.py, tests/ # pytest (test DB 자동 create/drop) — 아래 '테스트' ├── common/ @@ -94,14 +100,14 @@ o2o-web4ai/ │ ├── models/gmodel.py # 프로토콜 베이스 (WebPacketProtocol 등) │ └── database/ │ ├── db_session_manager.py# ★ DB Read/Write + 람다 실행 핵심 - │ └── model/models.py # ORM 모델 (16 테이블) + │ └── model/models.py # ORM 모델 (17 테이블) ├── crud/ # 도메인별 DB 접근 (I*CRUD 인터페이스 + 구현) ├── services/ # 비즈니스 로직 ├── scheduler/ # 배치 크론 (현재 등록된 잡 없음) └── router/ ├── router.py # FastAPI app (CORS 등) └── v1/ # 도메인별 라우터 - └── validator/dependencies.py # ★ JWT 발급·검증, 해시(bcrypt), RemoveNoneResponse, RequireOwner + └── validator/dependencies.py # ★ JWT 발급·검증, 해시(bcrypt), RemoveNoneResponse, RequireOwner/RequireDeveloper ``` ## 핵심 패턴 @@ -130,6 +136,8 @@ o2o-web4ai/ | `/v1/place` | 사업장 등록·조회 · **동일 업소 검증** · 하위 단위(객실·메뉴·프로그램) · 채널 URL 등록/**확정** · **수집 시작** | | `/v1/place/{id}/fact` | 업종 스키마 조회 · fact 기록 · **검증 상태 전이** | | `/v1/place/{id}/faq` | FAQ 목록 · **승인·정정 전이** · 사장님 직접 추가 | +| `/v1/place/{id}/media` | 사진·alt 조회 (수집·비전이 채운 것) | +| `/v1/local` | 지역 콘텐츠 목록 · 축제 동기화 · 노출/종료 전이 | | `/v1/job` | 잡 상태 폴링 · 큐 운영 스냅샷 · DEAD 잡 재큐 | | `/v1/place/{id}/site` | 사이트 상태(재빌드 필요 여부) · **사이트 주소 확인/예약** · **정적 빌드/발행** · 버전 목록 · 발행 기록 · 발행 상태 전이 | @@ -163,7 +171,7 @@ POST /v1/place/{id}/site/slug {slug} → sites.domain 에 저장(사 - ★ **이미 발행된 사이트의 주소는 바꾸지 않는다**(`SITE_SLUG_LOCKED`). 색인된 주소가 바뀌면 AI 검색이 잡아 둔 페이지가 404 가 되고 그 자리를 다시 OTA 가 가져간다 — '해지는 상태 전이지 삭제가 아니다' 와 같은 이유다. -### DB 스키마 (16 테이블) +### DB 스키마 (17 테이블) | schema | 테이블 | |---|---| @@ -172,6 +180,7 @@ POST /v1/place/{id}/site/slug {slug} → sites.domain 에 저장(사 | `fact` | `facts` `faqs` | | `local` | `local_contents` `routes` `nearby_links` | | `site` | `sites` `site_versions` `publish_logs` `ai_check_results` | +| `job` | `jobs` — 원자적 claim(`FOR UPDATE SKIP LOCKED`) + lease + dedupe + dead-letter | 핵심 게이트 3개가 컬럼으로 박혀 있다. @@ -187,7 +196,7 @@ POST /v1/place/{id}/site/slug {slug} → sites.domain 에 저장(사 | 무엇 | 어디 | 커밋 | |---|---|---| -| DB 접속 · JWT · 포트 | `backend/config/config.local.toml` | ✗ (`*.toml` ignore) | +| DB 접속 · JWT · 포트 | `solution/backend/config/config.local.toml` | ✗ (`*.toml` ignore) | | 외부 API 키 | 레포 최상위 `.env` | ✗ | | 템플릿 | `config/config.local.toml.example` · `.env.example` | ✓ | @@ -199,17 +208,17 @@ POST /v1/place/{id}/site/slug {slug} → sites.domain 에 저장(사 DB 준비(최초 1회) — 로컬 PostgreSQL 에 스키마를 적용한다. ```bash -psql -h 127.0.0.1 -p 5432 -U postgres -f ../postgres-init/init-data/init.sql +psql -h 127.0.0.1 -p 5432 -U postgres -f ../../postgres-init/init-data/init.sql # 호스트에 psql 이 없으면 도커 컨테이너의 psql 로 태운다: # docker exec -i -e PGPASSWORD=password negosium-db \ -# psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 < ../postgres-init/init-data/init.sql +# psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 < ../../postgres-init/init-data/init.sql ``` 서버 실행. ```bash -cd .. +cd ../.. # 레포 루트 cp .env.example .env # 최초 1회, 외부 API 키 채우기 -cd backend +cd solution/backend cp config/config.local.toml.example config/config.local.toml # 최초 1회, DB·JWT 채우기 python3 -m venv .venv && source .venv/bin/activate pip install -r requirements.txt @@ -224,7 +233,7 @@ python web_main.py # APP_ENV 기본 local → http://localh test DB(`web4ai_test_db`)는 알아서 만들어졌다 지워지므로 **수동 세팅이 필요 없다.** ```bash -cd backend +cd solution/backend source .venv/bin/activate pip install pytest pytest-asyncio # 테스트 도구(requirements 에 없음) @@ -235,7 +244,7 @@ python -m pytest tests/test_auth.py # 파일 하나만 venv 를 활성화하지 않으면 `.venv/bin/python -m pytest` 로 직접 지정한다. 정상이면 마지막 줄에 `NN passed`. 동작 방식 (전부 [conftest.py](conftest.py) 가 자동 처리): -- `APP_ENV` 를 `test` 로 자동 설정 → [config.test.toml](config/config.test.toml) 의 **`web4ai_test_db`** 사용(dev DB 와 완전 분리). +- `APP_ENV` 를 `test` 로 자동 설정 → `config/config.test.toml`([예제](config/config.test.toml.example)) 의 **`web4ai_test_db`** 사용(dev DB 와 완전 분리). - **세션 시작 시 test DB 를 새로 만들고(CREATE), 끝나면 내린다(DROP).** 매번 현재 모델로 새로 빌드돼 스키마가 낡을 일이 없다. - 테이블은 `create_all` 로 자동 생성, 매 테스트 전 `TRUNCATE` 로 비워 격리. - 안전가드: 이름에 `test` 없는 DB 는 만들지도 지우지도 않는다(실 DB 보호). diff --git a/solution/backend/demo_site.html b/solution/backend/demo_site.html deleted file mode 100644 index b238cba..0000000 --- a/solution/backend/demo_site.html +++ /dev/null @@ -1,247 +0,0 @@ - - - - - - 핑크비치펜션 - 숙박 - - - - - -
- -
-

핑크비치펜션

-

숙박

-
-
-

하조대 해변에서 도보 3분 거리에 있는 2개 동 규모의 펜션입니다. 모든 객실에서 바다가 보이며, 취사가 가능하고 야외 바비큐 공간을 갖추고 있습니다.

-
-
-

위치·연락처

-
-
주소
강원특별자치도 양양군 현북면 하조대3길 11
-
전화
033-672-0000
-
-
-
-

이용 정보

-
-
체크인 시간
15:00
-
체크아웃 시간
11:00
-
취소·환불 규정
이용 7일 전 100% 환불, 3일 전 50% 환불, 당일 취소 불가
-
취사 가능
가능
-
반려동물 동반
불가
-
흡연 가능
불가
-
프런트 운영시간
09:00 - 21:00
-
주차 가능
가능
-
주차 대수
6대
-
와이파이
가능
-
바비큐 이용
가능
-
바비큐 이용료
20,000원
-
조식 제공
불가
-
-
-
-

객실

-
-

A동 스탠다드

-
-
객실 타입
스탠다드
-
기준 인원
2명
-
최대 인원
4명
-
침대 구성
퀸 1
-
주방 여부
가능
-
전망
오션뷰
-
주중 요금
150,000원
-
-
-
-

B동 복층

-
-
객실 타입
복층
-
기준 인원
4명
-
최대 인원
6명
-
침대 구성
퀸 1 + 싱글 2
-
주방 여부
가능
-
전망
오션뷰
-
주중 요금
220,000원
-
-
-
-
-

사진

-
-
- -
외관
-
-
- -
A동 침실
-
-
- -
A동 거실
-
-
- -
바비큐장
-
-
- -
B동 복층
-
-
-
-
-

자주 묻는 질문

-
- 체크인·체크아웃은 몇 시인가요? -

체크인은 15:00, 체크아웃은 11:00 입니다.

-
-
- 반려동물과 함께 갈 수 있나요? -

반려동물 동반은 불가합니다.

-
-
- 객실에서 취사가 가능한가요? -

취사가 가능하며 모든 객실에 주방이 있습니다.

-
-
- 바비큐를 이용하려면 얼마인가요? -

바비큐 이용료는 20,000원입니다.

-
-
- 주차는 가능한가요? -

주차가 가능하며 6대까지 주차할 수 있습니다.

-
-
-
- -
전화하기
- - - diff --git a/solution/frontend/src/api/README.md b/solution/frontend/src/api/README.md index de20869..565c37c 100644 --- a/solution/frontend/src/api/README.md +++ b/solution/frontend/src/api/README.md @@ -20,12 +20,12 @@ import type {PlaceData, FactStatus} from '@/api'; 백엔드 OpenAPI 가 바뀌면 다시 뽑는다. **`generated/` 를 손으로 고치지 않는다.** ```bash -# 백엔드가 떠 있을 때 -npm run orval -w admin +# 레포 루트에서. 백엔드가 떠 있을 때 +npm run orval # 서버 없이 (스펙 파일을 먼저 뽑는다) -cd backend && python scripts/export_openapi.py -cd ../frontend && ORVAL_INPUT=../../backend/openapi.json npm run orval -w admin +cd solution/backend && .venv/bin/python scripts/export_openapi.py +cd ../.. && ORVAL_INPUT=solution/backend/openapi.json npm run orval ``` 생성되는 것 — 태그(도메인)별 훅과 모델. @@ -37,6 +37,9 @@ cd ../frontend && ORVAL_INPUT=../../backend/openapi.json npm run orval -w admin | `fact` | `useGetSchema` `useListFacts` `useUpsertFact` `useTransitionFact` | | `job` | `useGetJob` `useJobOps` `useRequeueJob` | | `site` | `useGetSite` `useStartBuild` `useListVersions` `useListLogs` `useChangeStatus` | +| `faq` | `useListFaqs` `useCreateFaq` `useTransitionFaq` | +| `media` | `useListMedia` | +| `local-content` | `usePublish` `useSyncFestivals` `useUpdateContent` `useEndContent` — 내부 운영 전용 | `orval.config.ts` 의 `operationName` 이 FastAPI 의 `list_places_v1_place_list_get` 를 `listPlaces` 로 되돌린다 — 백엔드는 무수정이다. diff --git a/solution/site/public/fonts/README.md b/solution/site/public/fonts/README.md index 00b1824..3516920 100644 --- a/solution/site/public/fonts/README.md +++ b/solution/site/public/fonts/README.md @@ -11,5 +11,5 @@ curl -L -o PretendardVariable.woff2 \ https://github.com/orioncactus/pretendard/raw/main/packages/pretendard/dist/web/variable/woff2/PretendardVariable.woff2 ``` -★ 발행 사이트(`site/public/fonts/`)에도 같은 파일이 필요하다 — - 프리렌더가 `public/` 을 사이트 폴더로 복사하므로, 두면 자동으로 따라간다. +★ 프리렌더가 `public/` 을 굽는 결과물로 복사하므로, 여기 두면 발행 사이트에 자동으로 따라간다. + 빌더(`solution/frontend/public/fonts/`)는 자기 몫을 따로 갖는다 — 두 앱은 번들이 다르다.