[feat] solution,postgres-init,docs: 생성 진행 상태 · 새로 만들기 존중 · 발행본 색인·파비콘

작업트리에 커밋되지 않은 채 쌓여 있던 것과, 오늘 찾은 문제 셋을 함께 담는다.

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

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

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

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

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

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

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

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

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

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

## 4. 발행본 파비콘

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-15 17:29:27 +09:00
parent 4cd756108d
commit 079c93a62a
60 changed files with 1465 additions and 596 deletions

View File

@ -124,6 +124,23 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
활성 유니크는 `(place, unit, key)` 당 **노출값 1건**이다(status 3·4 부분 인덱스).
후보(1·2)와 이력(5·6)은 여러 건 공존한다 — 재수집이 쌓일 수 있어야 하기 때문이다.
**수집값은 빈 자리에 바로 노출값(VERIFIED)으로 들어간다** (2026-09-14, `services/fact_service`).
예전에는 크롤링 값이 전부 UNVERIFIED 후보였다. 그러면 수집 직후 발행이 "확인된 사실 0건" 으로
막혀, 사장님이 한 건씩 승인하기 전에는 사이트가 만들어지지 않았다 — 수집이 끝난 뒤에야 오는
값이라 승인할 화면을 이미 지나가 있었다.
지금 규칙은 **누가 그 자리를 이미 차지했는지**로 갈린다.
| 그 key 의 현재 노출값 | 수집값이 오면 |
|---|---|
| 없음 | 바로 노출값(VERIFIED). `verified_by`**비운다** — 사람이 승인한 이력과 구별된다 |
| 같은 값 | REFRESHED — 확인 시각만 갱신. 검증을 초기화하지 않는다 |
| 사장님이 넣은 값(OWNER) · 정정본(CORRECTED) | 덮지 않는다. PENDING_OWNER **후보**로 쌓여 사람이 고른다 |
| 앞선 수집값 | 새 값이 노출값 자리를 가져간다(옛 값은 EXPIRED 이력) |
즉 자동이 사람을 덮지 못한다는 보호(절대규칙 6)는 그대로이고, 자동끼리는 최신값이 이긴다.
UNVERIFIED 는 이제 공식 API 수집이 빈 자리에 넣을 때 생긴다.
### `place_channels` — 크롤링 대상 URL
`confirmed_at` 이 NULL 이면 **크롤링하지 않는다.** 카카오 로컬로 동일 업소임을 확인한 URL 만 넘긴다.
@ -230,6 +247,9 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다.
### `jobs` — 작업 큐 (PostgreSQL 을 큐로)
COPY 단계는 `jobs.progress`(JSONB)의 `steps`·`attempt`에 기록한다.
생성 화면 복구와 모듈별 책임은 [GENERATION_FLOW.md](GENERATION_FLOW.md).
| `job_type` | 핸들러 | 하는 일 |
|---|---|---|
| 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 |

View File

@ -34,6 +34,25 @@
| 결론이 "불가"일 때 | 폴백 3단계로 간다 — ① 공식 API → ② 사장님이 직접 붙여넣기 → ③ 최소 정보로 생성 + 보완 요청. **생성 자체는 실패시키지 않는다** |
| 확정 사항 | 캡차 우회 · 봇 탐지 우회 · IP 회전은 **결론과 무관하게 금지**. 구현하지 않는다 |
**변경 (2026-09-14 / 확인 2026-09-15) — NOL 전용 어댑터를 등록한다.**
위 표의 "야놀자·여기어때 불가" 와 "Playwright 어댑터는 등록하지 않는다" 를 **한 패턴에 한해**
연다. 무엇을 열고 무엇을 안 여는지는 정확히 이렇다.
| | 지금 |
|---|---|
| `nol.yanolja.com/stay/domestic/<id>` | **전용 어댑터 `yanolja`** 가 Playwright 로 렌더해 읽는다. 기본 활성 |
| 그 밖의 `yanolja.com` · `goodchoice.kr` 전부 | **막는다.** 범용 HTML 어댑터의 `_DENY_HOSTS` 에 그대로 있다 |
| 캡차 우회 · 봇 탐지 우회 · IP 회전 | **여전히 금지.** 차단되면 그대로 실패로 돌린다 |
- 레지스트리가 `yanolja``static_html` 보다 **앞에** 등록하므로 그 한 패턴만 전용 경로로 가고
나머지는 예전처럼 `AdapterNotFound` 로 끊긴다. 순서가 곧 이 경계다.
- ★ 실측(2026-09-15): 어댑터를 들이면서 `static_html``_DENY_HOSTS` 에서 `yanolja.com` ·
`goodchoice.kr` 이 함께 빠져 있었다. 그러면 전용 어댑터가 아니라 **범용 HTML 수집기가**
두 플랫폼을 받는다 — 전용 경로 하나를 여는 것과 범용 수집을 그 플랫폼에 푸는 것은 다른
일이라, 차단 목록과 그 법무 근거 주석을 되돌렸다.
- 민사 10억 선례(서울중앙지법 2021-08)는 그대로다. **재게시 범위는 1-2 가 따로 정한다**
이 항목은 "읽을 수 있나" 까지만 정하고 "다시 실어도 되나" 는 정하지 않는다.
### 1-2. 크롤링한 **이미지**의 재게시 권리
| 항목 | 내용 |

View File

@ -24,6 +24,17 @@
**검증** — 관련 59건 통과. 추가 회귀 23건 통과·기존 발행 검수 실패 1건(변경 전 코드에서도 재현).
## 2026-09-15 — 콘텐츠 생성 진행 상태·새로고침 복구
- COPY의 실제 단계 상태를 DB에 기록하고 Orval 응답으로 표시. 폴링 횟수 기반 진행률 제거.
- URL의 jobId로 조회 재개. 구 URL 복구는 완료·실패 이력까지 재사용해 중복 생성 방지.
- 실행 흐름·단계 메소드·프롬프트·프론트 조회 훅·화면 문구 분리.
- 구조·적용 순서: [GENERATION_FLOW.md](GENERATION_FLOW.md).
**검증** — 백엔드 관련 테스트 34건·브라우저 복구/실패 시나리오 6건 통과. 프론트 타입검사·lint·빌드 통과.
---
## 2026-09-14 — 엽서 쓰기를 발행본에도 넣는다 (사진이 남의 도메인이면 저장·공유는 막힌다)
**무슨 일** — 시연본에만 주입 스크립트로 있던 '엽서 쓰기'(사진 고르기 + 한 마디 + 캔버스 엽서)를

46
docs/GENERATION_FLOW.md Normal file
View File

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

View File

@ -98,6 +98,7 @@ CREATE TABLE IF NOT EXISTS public.jobs (
priority SMALLINT NOT NULL DEFAULT 100, -- 낮을수록 우선
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
result JSONB NULL,
progress JSONB NULL,
dedupe_key VARCHAR(200) NULL, -- 활성 중복 방지 키(부분 유니크). 지역 이야기는 story:{region_code}
attempts SMALLINT NOT NULL DEFAULT 0, -- claim 시 +1
max_attempts SMALLINT NOT NULL DEFAULT 3, -- 소진되면 DEAD

View File

@ -0,0 +1,2 @@
-- 새로고침 후에도 워커가 마지막으로 기록한 단계를 조회한다.
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS progress JSONB NULL;

View File

@ -582,6 +582,7 @@ class jobs(MainTableMixin, MAIN_BASE):
priority = Column(SmallInteger, nullable=False, server_default=text("100"), default=100) # 낮을수록 우선
payload = Column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) # 잡 입력
result = Column(JSONB, nullable=True) # 잡 출력(완료 시)
progress = Column(JSONB, nullable=True) # 워커가 기록한 단계 상태
dedupe_key = Column(String(200), nullable=True) # 활성 중복 방지 키(부분 유니크)
attempts = Column(SmallInteger, nullable=False, server_default=text("0"), default=0) # claim 시 +1
max_attempts = Column(SmallInteger, nullable=False, server_default=text("3"), default=3)

View File

@ -0,0 +1,18 @@
"""재시도가 의미 없는 잡 실패.
따로 두나 큐는 실패를 전부 "일시적" 으로 보고 백오프 재큐한다(crud/job_crud.fail).
네트워크가 끊겼거나 외부 API 잠깐 죽은 것이라면 맞는 판단이다. 그런데 사장님이
사업장을 지운 뒤에 남은 , 지원하지 않는 업종 같은 것은 ** 번을 다시 해도 같은 결과**.
실측(2026-09-15): 진행 중이던 소개문 잡이 사업장 삭제 "사업장을 찾을 수 없다"
재시도하고 DEAD 갔다 지연과 DEAD 알림만 늘었다.
도메인의 `*Aborted` 이미 머리주석에 "재시도해도 소용없는 중단" 이라고 적고 있었다.
뜻을 워커가 읽을 있는 자리로 옮긴 것이지, 규칙을 만든 아니다.
services worker 함께 쓰므로 common 둔다 services worker import 하면
의존 방향이 뒤집힌다.
"""
class PermanentJobError(RuntimeError):
"""다시 시도해도 결과가 같은 실패. 워커가 재큐하지 않고 바로 DEAD 로 보낸다."""

View File

@ -83,7 +83,7 @@ class JobQueue:
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING job_id, job_type, payload, attempts, max_attempts
RETURNING job_id, job_type, payload, attempts, max_attempts, worker_id
""")
async def run(s):
@ -140,6 +140,25 @@ 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 알림을 배로 늘린다."""
sql = text("""
UPDATE jobs SET status = 4, last_error = :err,
lease_until = NULL, worker_id = NULL, updated_at = now()
WHERE job_id = CAST(:id AS uuid) AND status = 2 AND worker_id = :wid
RETURNING job_id
""")
async def run(s):
row = (await s.execute(sql, {"id": job_id, "wid": worker_id, "err": error[:2000]})).first()
return row is not None
return await self._tx(run)
# ---- lease 갱신(heartbeat) / 회수(reaper) ----
async def renew_lease(self, job_id: str, worker_id: str, lease_sec: int = 120) -> bool:
sql = text("""
@ -176,11 +195,41 @@ class JobQueue:
return await self._tx(run)
# ---- 단건 조회 (상태 폴링) ----
async def set_progress(self, job: dict, progress: dict) -> bool:
# 회수된 옛 워커가 새 시도의 진행 상태를 덮지 못하게 한다.
sql = text("""
UPDATE jobs SET progress = CAST(:progress AS jsonb), updated_at = now()
WHERE job_id = CAST(:id AS uuid) AND status = 2
AND worker_id = :wid AND attempts = :attempt
AND lease_until > now()
RETURNING job_id
""")
async def run(s):
row = (await s.execute(sql, {
"id": job["job_id"], "wid": job["worker_id"], "attempt": job["attempts"],
"progress": json.dumps(progress),
})).first()
return row is not None
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
ORDER BY created_at DESC, job_id DESC LIMIT 1
"""), {"dk": dedupe_key})).mappings().first()
return {**row, "job_id": str(row["job_id"])} if row else None
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, last_error, run_after, run_started_at, created_at, updated_at
payload, result, progress, last_error, run_after, run_started_at, created_at, updated_at
FROM jobs WHERE job_id = CAST(:id AS uuid)
""")
@ -190,7 +239,7 @@ class JobQueue:
return None
d = dict(row)
d["job_id"] = str(d["job_id"])
for key in ("payload", "result"):
for key in ("payload", "result", "progress"):
if isinstance(d.get(key), str):
d[key] = json.loads(d[key])
return d

View File

@ -1,6 +1,7 @@
from uuid import UUID
from fastapi import APIRouter, Depends
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireDeveloper
from services.job_service import JobService
@ -27,8 +28,8 @@ async def job_ops(service: JobService = Depends()):
dependencies=[Depends(IsValidAccessToken)],
summary="잡 상태 조회(폴링)",
)
async def get_job(job_id: UUID, service: JobService = Depends()):
return RemoveNoneResponse(await service.get_job(str(job_id)))
async def get_job(job_id: UUID, service: JobService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_job(str(job_id), user_info))
@router.post(

View File

@ -1,6 +1,7 @@
import uuid
from datetime import datetime
from typing import Any, Optional
from typing import Literal
from common.enums import JobStatus, JobType
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
@ -10,13 +11,26 @@ class JobProtocol(WebPacketProtocol):
pass
class JobStep(WebPacketProtocol):
id: str
status: Literal["pending", "running", "done", "skipped", "failed"]
reason: Optional[str] = None
class JobProgress(WebPacketProtocol):
steps: list[JobStep]
attempt: int
class JobData(WebPacketProtocol):
job_id: uuid.UUID
place_id: Optional[uuid.UUID] = None
job_type: JobType
status: JobStatus
attempts: int = 0
max_attempts: int = 3
result: Optional[Any] = None
progress: Optional[JobProgress] = None
last_error: Optional[str] = None
run_after: Optional[datetime] = None
run_started_at: Optional[datetime] = None

View File

@ -59,6 +59,17 @@ class Req_VerifyPlaceByUrl(PlaceProtocol):
url: str = ""
# ★ 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지.
#
# 기본값이 True 인 것은 이 API 를 부르는 다른 자리(주소 재확인 등)의 동작을 바꾸지
# 않기 위해서다. 위저드의 **[새로 크롤링하고 사이트 생성하기]** 는 False 로 보낸다 —
# 사장님이 새로 만들겠다고 누른 것을 서버가 "이미 있으니 그걸 쓰세요" 로 바꿔 버리면
# 같은 화면을 눌러도 기존 에디터가 열린다(실측 2026-09-15: 그게 지금 증상이다).
#
# False 라도 **비어 있는 중복 행은 치운다.** 그건 위저드를 중간에 나갔을 때 남는
# 찌꺼기라 잃을 것이 없다 — 원래 막으려던 것도 그 누적이었다(ba90a19).
reuse_existing: bool = True
class Req_UpdatePlace(PlaceProtocol):
# ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다.
@ -246,7 +257,7 @@ class Req_StartCopy(PlaceProtocol):
확인된 fact 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 한다)."""
pass
resume: bool = False
class Res_StartCopy(Res_WebPacketProtocol):

View File

@ -241,9 +241,14 @@ class Res_SiteSlug(Res_WebPacketProtocol):
class Req_SiteStatus(SiteProtocol):
"""발행 상태 전이. ★ 해지는 삭제가 아니라 상태 전이다 —
색인된 페이지를 갑자기 404 만들면 자리를 다시 OTA 가져간다."""
색인된 페이지를 갑자기 404 만들면 자리를 다시 OTA 가져간다.
action: PublishAction = PublishAction.SUSPEND
기본값을 두지 않는다. SUSPEND 기본이던 동안에는 필드 이름을 틀리게 보내도
(`{"status": 5}` 처럼) 422 아니라 **발행 중지가 실행됐다** 파괴적인 전이가
'아무것도 안 적었을 때' 자리에 있었다(실측 2026-09-15).
무엇을 할지는 부르는 쪽이 적는다."""
action: PublishAction
class ShowcaseItem(WebPacketProtocol):

View File

@ -42,6 +42,7 @@ from services import (
from services.local_content_service import LocalContentService
from services.site_payload import emit_payload
from services.snapshot import build_snapshot
from common.job_errors import PermanentJobError
_site_crud = SiteCRUD()
_place_crud = PlaceCRUD()
@ -52,7 +53,7 @@ _place_crud = PlaceCRUD()
RENDER_TIMEOUT_SEC = float(os.environ.get("RENDER_TIMEOUT_SEC") or 180)
class BuildAborted(RuntimeError):
class BuildAborted(PermanentJobError):
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남는다."""

View File

@ -19,12 +19,13 @@ from services.collector import yanolja_adapter
from services.external import naver_place_lookup, perplexity, tour_lookup
from services.fact_service import FactService
from router.v1.fact.protocol import Req_UpsertFact
from common.job_errors import PermanentJobError
_place_crud = PlaceCRUD()
_fact_crud = FactCRUD()
class CollectAborted(RuntimeError):
class CollectAborted(PermanentJobError):
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남아 운영자가 본다."""

View File

@ -1,5 +1,31 @@
"""정적 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업종 가장 높다.
@ -42,9 +68,15 @@ 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 은 전용 어댑터가 먼저 받는다
"dailyhotel.com", "catchtable.co.kr",
"airbnb.co.kr", "airbnb.com",
"booking.com", "agoda.com", "expedia.co.kr",

View File

@ -1,325 +1,39 @@
"""소개문·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 부르지 않고 채우기만 한다.
"""
import uuid
from common.category_schema import CategorySchemaError, get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.faq_catalog import FaqCatalog, find_catalog
from common.database.model.models import place_facts, place_faqs, place_channels, places, place_units
from common.enums import (
PUBLISHABLE_FACT_STATUSES,
DBWRType,
ErrorType,
FactStatus,
PlaceCategory,
SourceType,
)
from common.logger import LOG
from common.models.gmodel import UserInfo
from common.utils.gtime import GTime
from config.server_configs import external_api_config
from crud.fact_crud import FactCRUD
from crud.faq_crud import FaqCRUD
from crud.place_crud import PlaceCRUD
from router.v1.fact.protocol import Req_UpsertFact
from services import faq_fill, place_research
"""COPY 흐름. 단계 구현: copy_steps / 프롬프트: prompts/copy / 호출·검증: external/gemini_text."""
from services.copy_steps import CopyAborted, prepare_copy, generate_copy, save_copy, fill_faqs
from services.external import gemini_text
from services.fact_service import FactService
from services.job_progress import JobProgress
_fact_crud = FactCRUD()
_faq_crud = FaqCRUD()
_place_crud = PlaceCRUD()
class CopyAborted(RuntimeError):
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남는다."""
COPY_STEPS = ("prepare", "generate", "save", "faq_fill")
async def run_copy(job: dict) -> dict:
"""COPY 잡 핸들러. payload: {place_id, owner_user_id, requested_by?}"""
progress = JobProgress(job, COPY_STEPS)
payload = job["payload"]
place_id = payload["place_id"]
owner_user_id = payload["owner_user_id"]
async with progress.step("prepare"):
inputs = await prepare_copy(payload["place_id"], payload["owner_user_id"])
err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
)
if err != ErrorType.SUCCESS or place is None:
raise CopyAborted(f"사업장을 찾을 수 없다: {place_id}")
copy = None
note = None
if inputs.ungrounded or not gemini_text.is_configured():
note = "근거로 쓸 확인된 fact 가 없다" if inputs.ungrounded else "GEMINI_API_KEY 미설정"
await progress.skip("generate", "no_facts" if inputs.ungrounded else "not_configured")
if inputs.catalog is None and not inputs.ungrounded:
raise CopyAborted(note)
else:
async with progress.step("generate"):
copy = await generate_copy(inputs)
try:
schema = get_schema(PlaceCategory(place.category))
except (CategorySchemaError, ValueError) as ex:
raise CopyAborted(f"지원하지 않는 업종: {place.category}") from ex
async with progress.step("save"):
result = await save_copy(inputs, copy)
# ★ 노출 가능한 fact 만 근거로 준다. 미검증 값으로 쓴 문장은 그 자체가 미검증이다.
pid = uuid.UUID(place_id)
f_err, fact_rows = await DB_SESSION_MNG.execute_lambda(
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: _fact_crud.list_facts(s, pid, None, None, True, True),
)
if f_err != ErrorType.SUCCESS:
raise CopyAborted(f"fact 조회 실패: {f_err.name}")
grounded = [
gemini_text.FactInput(
key=r.key,
label=(schema.get(r.key).label if schema.get(r.key) else r.key),
value=r.value,
unit=r.unit,
)
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`).
records: list[str] = []
l_err, link_rows = await DB_SESSION_MNG.execute_lambda(
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_links(s, pid, False),
)
if l_err == ErrorType.SUCCESS:
for link in (link_rows or []):
raw = link.raw if isinstance(link.raw, dict) else {}
if link.confirmed_at is None and raw.get("kind") != place_research.RAW_KIND:
continue
text = (raw.get("text") or "").strip()
if text:
# ★ fact 목록이 아니라 records 로 넘긴다. fact 자리에 넣으면 모델이 값 하나로
# 읽고 거의 쓰지 않는다(prompts/copy.build_prompt 머리주석의 실측).
records.append(text[:4000])
# ground_check 는 여전히 이 글을 근거로 인정해야 한다 — 근거 목록에도 남긴다.
grounded.append(gemini_text.FactInput(
key=f"source:{link.link_id}", label="수집 원문", value=text[:4000],
))
# 객실·메뉴 요약도 근거로 넘긴다 — "최대 4명" 같은 수치가 통과하려면 근거에 있어야 한다.
#
# ★ 근거 없음 판정보다 **먼저** 읽는다.
# 예전에는 사업장 fact 가 0건이면 여기까지 오지 못하고 되돌아갔다. 그런데 네이버에
# 요금표만 올라온 모텔은 사업장 fact 가 0건이고 객실 fact 만 있다 — 쓸 근거가 있는데도
# "근거 없음"으로 끝나 소개문·FAQ 가 영구히 생기지 않았다.
u_err, unit_rows = await DB_SESSION_MNG.execute_lambda(
place_units.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_units(s, pid),
)
unit_summaries = []
if u_err == ErrorType.SUCCESS:
by_unit: dict = {}
for r in fact_rows:
if r.unit_id and (r.value or "").strip():
by_unit.setdefault(str(r.unit_id), {})[r.key] = r.value
unit_summaries = [
{
"name": u.name,
"facts": by_unit.get(str(u.unit_id), {}),
# 스키마 라벨·단위를 같이 넘긴다 — 이게 없으면 프롬프트에 'weekday_price' 라는
# 날 key 가 그대로 실려 모델이 그 낱말로 문장을 쓴다.
"labels": {
key: {
"label": schema.get(key).label if schema.get(key) else key,
"unit": schema.get(key).unit if schema.get(key) else None,
}
for key in by_unit.get(str(u.unit_id), {})
},
}
for u in unit_rows
if by_unit.get(str(u.unit_id))
]
# 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()}
# ── LLM 을 부르지 않는 경우 ── 근거가 없거나(환각·유료 호출 방지) 키가 없을 때.
# ★ 그래도 카탈로그가 있으면 FAQ 는 문의 안내로 20개를 채운다 — fact 가 0건이어도 20개(DECISIONS 8절).
ungrounded = not grounded and not unit_summaries
if ungrounded or not gemini_text.is_configured():
note = "근거로 쓸 확인된 fact 가 없다" if ungrounded else "GEMINI_API_KEY 미설정"
if catalog is None:
if not ungrounded:
raise CopyAborted(f"{note} — 소개문·FAQ 를 생성할 수 없다")
return {"place_id": place_id, "intro": False, "faqs": 0, "faq_fill": 0, "note": note}
if ungrounded:
# 근거가 사라졌으니 예전 생성분도 근거가 없다 — 내리고 새로 채운다.
# 키만 없는 경우는 내리지 않는다: 멀쩡한 생성 FAQ 가 키 설정 하나 때문에 사라지면 안 된다.
await DB_SESSION_MNG.execute_lambda_claim(
place_faqs.DBType(),
lambda s: _faq_crud.expire_generated(s, pid, GTime.UTC()),
if inputs.catalog is not None:
async with progress.step("faq_fill"):
result["faq_fill"] = await fill_faqs(
inputs.place.place_id, inputs.catalog, inputs.known_fact_keys, inputs.place.phone,
)
filled = await _fill_faqs(pid, catalog, known_fact_keys, place.phone)
LOG.i(f"[copy] place={place_id} LLM 호출 없음({note}) · 문의 안내 채움 {filled}")
return {
"place_id": place_id, "grounded_facts": len(grounded), "intro": False, "meta": False,
"faqs": 0, "faq_fill": filled, "rejected": [], "note": note,
}
else:
await progress.skip("faq_fill", "no_catalog")
try:
copy = await gemini_text.generate_copy(
place.name,
PlaceCategory(place.category),
grounded,
unit_summaries=unit_summaries or None,
records=records or None,
suggested_questions=faq_fill.suggested_questions(catalog, known_fact_keys) if catalog else None,
max_faqs=faq_fill.FAQ_TARGET,
model=external_api_config.gemini_text_model,
)
except gemini_text.GeminiNotConfigured as ex:
raise CopyAborted(str(ex)) from ex
now = GTime.UTC()
stat = {
"place_id": place_id,
"grounded_facts": len(grounded),
"intro": False,
"meta": False,
"faqs": 0,
"faq_fill": 0, # 목표 수를 채운 문의 안내 문항 수
# ★ 반려된 문장을 그대로 남긴다 — 소개문이 왜 안 나왔는지 운영자가 알아야 한다.
"rejected": [list(r) for r in (copy.rejected or [])][:20],
}
actor = UserInfo(
# ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로
# 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다.
# 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가
# 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다.
user_id=owner_user_id,
id="generator",
role=1,
)
service = FactService(_fact_crud, _place_crud)
# 소개문·메타는 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 작성을 허용하지 않는 필드다. 조용히 건너뛴다.
continue
res = await service.upsert_fact(
actor, place_id,
Req_UpsertFact(
key=key, value=text_value.strip(),
source_type=SourceType.LLM, source_url=f"gemini:{external_api_config.gemini_text_model}",
),
)
if res.result.success:
stat["intro" if key == "intro" else "meta"] = True
else:
stat["rejected"].append([key, res.result.desc])
# 확인 안 된 기존 생성 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 는 저장하지 않는다.
stat["rejected"].append([faq.question, "근거 fact 없음"])
continue
row = place_faqs(
place_id=pid,
question=faq.question,
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,
)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[place_faqs.DBType()],
[lambda s, r=row: _faq_crud.add_faq(s, r)],
)
if run_err == ErrorType.SUCCESS:
stat["faqs"] += 1
# ── 목표 수 채우기 ── 생성분이 모자라면 카탈로그의 겹치지 않는 공통 질문을 문의 안내로 채운다.
if catalog is not None:
stat["faq_fill"] = await _fill_faqs(pid, catalog, known_fact_keys, place.phone)
LOG.i(f"[copy] place={place_id} 소개문 {'O' if stat['intro'] else 'X'} · FAQ {stat['faqs']}건 · "
f"문의 안내 채움 {stat['faq_fill']}건 · 반려 {len(stat['rejected'])}건 (근거 fact {len(grounded)}개)")
return stat
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)."""
l_err, rows = await DB_SESSION_MNG.execute_lambda(
place_faqs.DBType(),
DBWRType.DB_READ.value,
lambda s: _faq_crud.list_faqs(s, pid, True),
)
if l_err != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[copy] FAQ 채우기 건너뜀 — 목록 조회 실패 place={pid} {l_err.name}")
return 0
picks = faq_fill.pick_fill_faqs(
catalog,
[faq_fill.ExistingFaq(r.question, r.source_fact_ids) for r in rows],
known_fact_keys,
phone,
)
next_order = max((r.sort_order for r in rows), default=-1) + 1
added = 0
for offset, pick in enumerate(picks):
row = place_faqs(
place_id=pid,
question=pick.question,
answer=pick.answer,
source_fact_ids=None,
generated_by=SourceType.TEMPLATE.value,
status=FactStatus.VERIFIED.value,
sort_order=next_order + offset,
)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[place_faqs.DBType()],
[lambda s, r=row: _faq_crud.add_faq(s, r)],
)
if run_err == ErrorType.SUCCESS:
added += 1
return added
if note:
result["note"] = note
return result

View File

@ -0,0 +1,327 @@
"""소개문·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 부르지 않고 채우기만 한다.
"""
import uuid
from dataclasses import dataclass
from common.category_schema import CategorySchema, CategorySchemaError, get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.faq_catalog import FaqCatalog, find_catalog
from common.database.model.models import place_facts, place_faqs, place_channels, places, place_units
from common.enums import (
DBWRType,
ErrorType,
FactStatus,
PlaceCategory,
SourceType,
)
from common.logger import LOG
from common.models.gmodel import UserInfo
from common.utils.gtime import GTime
from config.server_configs import external_api_config
from crud.fact_crud import FactCRUD
from crud.faq_crud import FaqCRUD
from crud.place_crud import PlaceCRUD
from router.v1.fact.protocol import Req_UpsertFact
from services import faq_fill, place_research
from services.external import gemini_text
from services.fact_service import FactService
from common.job_errors import PermanentJobError
_fact_crud = FactCRUD()
_faq_crud = FaqCRUD()
_place_crud = PlaceCRUD()
class CopyAborted(PermanentJobError):
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남는다."""
@dataclass
class CopyInputs:
place: places
schema: CategorySchema
grounded: list[gemini_text.FactInput]
records: list[str]
unit_summaries: list[dict]
catalog: FaqCatalog | None
known_fact_keys: set[str]
@property
def ungrounded(self) -> bool:
return not self.grounded and not self.unit_summaries
async def prepare_copy(place_id: str, owner_user_id: str) -> CopyInputs:
err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
)
if err != ErrorType.SUCCESS or place is None:
raise CopyAborted(f"사업장을 찾을 수 없다: {place_id}")
try:
schema = get_schema(PlaceCategory(place.category))
except (CategorySchemaError, ValueError) as ex:
raise CopyAborted(f"지원하지 않는 업종: {place.category}") from ex
# ★ 노출 가능한 fact 만 근거로 준다. 미검증 값으로 쓴 문장은 그 자체가 미검증이다.
pid = uuid.UUID(place_id)
f_err, fact_rows = await DB_SESSION_MNG.execute_lambda(
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: _fact_crud.list_facts(s, pid, None, None, True, True),
)
if f_err != ErrorType.SUCCESS:
raise CopyAborted(f"fact 조회 실패: {f_err.name}")
grounded = [
gemini_text.FactInput(
key=r.key,
label=(schema.get(r.key).label if schema.get(r.key) else r.key),
value=r.value,
unit=r.unit,
)
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`).
records: list[str] = []
l_err, link_rows = await DB_SESSION_MNG.execute_lambda(
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_links(s, pid, False),
)
if l_err == ErrorType.SUCCESS:
for link in (link_rows or []):
raw = link.raw if isinstance(link.raw, dict) else {}
if link.confirmed_at is None and raw.get("kind") != place_research.RAW_KIND:
continue
text = (raw.get("text") or "").strip()
if text:
# ★ fact 목록이 아니라 records 로 넘긴다. fact 자리에 넣으면 모델이 값 하나로
# 읽고 거의 쓰지 않는다(prompts/copy.build_prompt 머리주석의 실측).
records.append(text[:4000])
# ground_check 는 여전히 이 글을 근거로 인정해야 한다 — 근거 목록에도 남긴다.
grounded.append(gemini_text.FactInput(
key=f"source:{link.link_id}", label="수집 원문", value=text[:4000],
))
# 객실·메뉴 요약도 근거로 넘긴다 — "최대 4명" 같은 수치가 통과하려면 근거에 있어야 한다.
#
# ★ 근거 없음 판정보다 **먼저** 읽는다.
# 예전에는 사업장 fact 가 0건이면 여기까지 오지 못하고 되돌아갔다. 그런데 네이버에
# 요금표만 올라온 모텔은 사업장 fact 가 0건이고 객실 fact 만 있다 — 쓸 근거가 있는데도
# "근거 없음"으로 끝나 소개문·FAQ 가 영구히 생기지 않았다.
u_err, unit_rows = await DB_SESSION_MNG.execute_lambda(
place_units.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_units(s, pid),
)
unit_summaries = []
if u_err == ErrorType.SUCCESS:
by_unit: dict = {}
for r in fact_rows:
if r.unit_id and (r.value or "").strip():
by_unit.setdefault(str(r.unit_id), {})[r.key] = r.value
unit_summaries = [
{
"name": u.name,
"facts": by_unit.get(str(u.unit_id), {}),
# 스키마 라벨·단위를 같이 넘긴다 — 이게 없으면 프롬프트에 'weekday_price' 라는
# 날 key 가 그대로 실려 모델이 그 낱말로 문장을 쓴다.
"labels": {
key: {
"label": schema.get(key).label if schema.get(key) else key,
"unit": schema.get(key).unit if schema.get(key) else None,
}
for key in by_unit.get(str(u.unit_id), {})
},
}
for u in unit_rows
if by_unit.get(str(u.unit_id))
]
# 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()}
return CopyInputs(place, schema, grounded, records, unit_summaries, catalog, known_fact_keys)
async def generate_copy(inputs: CopyInputs) -> gemini_text.GeneratedCopy:
try:
return await gemini_text.generate_copy(
inputs.place.name,
PlaceCategory(inputs.place.category),
inputs.grounded,
unit_summaries=inputs.unit_summaries or None,
records=inputs.records or None,
suggested_questions=faq_fill.suggested_questions(inputs.catalog, inputs.known_fact_keys) if inputs.catalog else None,
max_faqs=faq_fill.FAQ_TARGET,
model=external_api_config.gemini_text_model,
)
except gemini_text.GeminiNotConfigured as ex:
raise CopyAborted(str(ex)) from ex
async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None) -> dict:
pid = inputs.place.place_id
place_id = str(pid)
schema = inputs.schema
now = GTime.UTC()
stat = {
"place_id": place_id,
"grounded_facts": len(inputs.grounded),
"intro": False,
"meta": False,
"faqs": 0,
"faq_fill": 0, # 목표 수를 채운 문의 안내 문항 수
# ★ 반려된 문장을 그대로 남긴다 — 소개문이 왜 안 나왔는지 운영자가 알아야 한다.
"rejected": [list(r) for r in (copy.rejected or [])][:20] if copy else [],
}
if copy is None:
# 키만 없는 경우에는 기존 생성물을 보존한다.
if inputs.ungrounded and inputs.catalog is not None:
await DB_SESSION_MNG.execute_lambda_claim(
place_faqs.DBType(), lambda s: _faq_crud.expire_generated(s, pid, now),
)
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 분기).
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 작성을 허용하지 않는 필드다. 조용히 건너뛴다.
continue
res = await service.upsert_fact(
actor, place_id,
Req_UpsertFact(
key=key, value=text_value.strip(),
source_type=SourceType.LLM, source_url=f"gemini:{external_api_config.gemini_text_model}",
),
)
if res.result.success:
stat["intro" if key == "intro" else "meta"] = True
else:
stat["rejected"].append([key, res.result.desc])
# 확인 안 된 기존 생성 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 는 저장하지 않는다.
stat["rejected"].append([faq.question, "근거 fact 없음"])
continue
row = place_faqs(
place_id=pid,
question=faq.question,
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,
)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[place_faqs.DBType()],
[lambda s, r=row: _faq_crud.add_faq(s, r)],
)
if run_err == ErrorType.SUCCESS:
stat["faqs"] += 1
LOG.i(f"[copy] place={place_id} 소개문 {'O' if stat['intro'] else 'X'} · FAQ {stat['faqs']}건 · "
f"반려 {len(stat['rejected'])}건 (근거 fact {len(inputs.grounded)}개)")
return stat
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)."""
l_err, rows = await DB_SESSION_MNG.execute_lambda(
place_faqs.DBType(),
DBWRType.DB_READ.value,
lambda s: _faq_crud.list_faqs(s, pid, True),
)
if l_err != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[copy] FAQ 채우기 건너뜀 — 목록 조회 실패 place={pid} {l_err.name}")
return 0
picks = faq_fill.pick_fill_faqs(
catalog,
[faq_fill.ExistingFaq(r.question, r.source_fact_ids) for r in rows],
known_fact_keys,
phone,
)
next_order = max((r.sort_order for r in rows), default=-1) + 1
added = 0
for offset, pick in enumerate(picks):
row = place_faqs(
place_id=pid,
question=pick.question,
answer=pick.answer,
source_fact_ids=None,
generated_by=SourceType.TEMPLATE.value,
status=FactStatus.VERIFIED.value,
sort_order=next_order + offset,
)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[place_faqs.DBType()],
[lambda s, r=row: _faq_crud.add_faq(s, r)],
)
if run_err == ErrorType.SUCCESS:
added += 1
return added

View File

@ -0,0 +1,34 @@
"""작업 단계 기록. 실행 순서는 도메인 서비스, 화면 문구는 프론트가 소유한다."""
from contextlib import asynccontextmanager
from crud.job_crud import JobQueue
class JobProgress:
def __init__(self, job: dict, steps: tuple[str, ...]):
self.job = job
self.queue = JobQueue()
self.state = {
"attempt": job["attempts"],
"steps": [{"id": step, "status": "pending"} for step in steps],
}
async def _set(self, step_id: str, status: str, reason: str | None = None):
step = next(step for step in self.state["steps"] if step["id"] == step_id)
step.update(status=status, reason=reason)
if not await self.queue.set_progress(self.job, self.state):
raise RuntimeError("작업 소유권이 만료되어 진행 기록을 중단합니다")
@asynccontextmanager
async def step(self, step_id: str):
await self._set(step_id, "running")
try:
yield
except BaseException:
await self._set(step_id, "failed")
raise
else:
await self._set(step_id, "done")
async def skip(self, step_id: str, reason: str):
await self._set(step_id, "skipped", reason)

View File

@ -1,6 +1,7 @@
from fastapi import Depends
from common.enums import ErrorType, JobType
from common.enums import ErrorType, JobType, UserRole
from common.models.gmodel import UserInfo
from common.logger import LOG
from crud.job_crud import JobQueue
from router.v1.job.protocol import JobData, Res_Job, Res_JobOps
@ -15,13 +16,34 @@ class JobService:
def __init__(self, queue: JobQueue = Depends(JobQueue)):
self.queue = queue
async def get_job(self, job_id: str) -> Res_Job:
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:
res.result.SetResult(ErrorType.JOB_NOT_FOUND)
return res
res.job = JobData(**{k: v for k, v in row.items() if k in JobData.model_fields})
payload = row.get("payload") or {}
if user_info is not None and (user_info.role or 0) < UserRole.DEVELOPER.value:
owner = payload.get("owner_user_id")
if owner is None or str(owner) != str(user_info.user_id):
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).
if payload.get("place_id") is not None:
fields["place_id"] = payload.get("place_id")
res.job = JobData(**fields)
return res
async def ops(self) -> Res_JobOps:

View File

@ -295,28 +295,55 @@ class PlaceService:
sess, uuid.UUID(user_info.user_id), ExternalPlaceSource.NAVER.value, str(naver_id),
),
)
canonical_id = place_id
#
# ★ **사장님이 새로 만들겠다고 누른 것은 뒤집지 않는다** (2026-09-15)
# 위 합치기는 "위저드를 다시 시작하면 빈 행이 쌓인다" 를 막으려던 것인데,
# [새로 크롤링하고 사이트 생성하기] 와 구분이 없어서 **일부러 다시 만들려는 경우도
# 기존 사업장으로 끌고 갔다** — 새로 만들기를 눌렀는데 기존 에디터가 열린다.
# 그래서 이어붙이기는 `reuse_existing` 이 켜져 있을 때만 한다.
#
# 끄더라도 **비어 있는 중복 행은 치운다.** 원래 막으려던 누적이 그것이고,
# 빈 행은 잃을 것이 없다. 지금 행은 앞으로 채워질 것이므로 대상이 아니다.
others = []
if err_dup == ErrorType.SUCCESS:
existing = next((r for r in (dup_rows or []) if str(r.place_id) != str(place_id)), None)
if existing is not None:
canonical_id = str(existing.place_id)
if await self._is_empty(place_id):
others = [r for r in (dup_rows or []) if str(r.place_id) != str(place_id)]
if others and not req.reuse_existing:
for row in others:
if await self._is_empty(str(row.place_id)):
await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda sess: self.crud.delete_place(
sess, uuid.UUID(user_info.user_id), uuid.UUID(place_id),
lambda sess, rid=row.place_id: self.crud.delete_place(
sess, uuid.UUID(user_info.user_id), rid,
),
)
LOG.i(f"[verify_by_url] 같은 업소가 이미 있다 — 빈 행 {place_id} 를 접고 "
f"{canonical_id} 로 잇는다 (naver {naver_id})")
else:
LOG.w(f"[verify_by_url] 같은 업소가 둘이다 — {place_id} 에 쌓인 것이 있어 "
f"지우지 않는다. 정본 {canonical_id} 를 돌려준다 (naver {naver_id})")
place_id = canonical_id
err_type, place = await self._load(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
LOG.i(f"[verify_by_url] 새로 만들기 — 빈 중복 행 {row.place_id} 를 치운다 "
f"(naver {naver_id})")
LOG.i(f"[verify_by_url] 새로 만들기 — 같은 업소가 {len(others)}개 있지만 "
f"{place_id} 로 새로 간다 (naver {naver_id})")
others = []
canonical_id = place_id
if others:
existing = others[0]
canonical_id = str(existing.place_id)
if await self._is_empty(place_id):
await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda sess: self.crud.delete_place(
sess, uuid.UUID(user_info.user_id), uuid.UUID(place_id),
),
)
LOG.i(f"[verify_by_url] 같은 업소가 이미 있다 — 빈 행 {place_id} 를 접고 "
f"{canonical_id} 로 잇는다 (naver {naver_id})")
else:
LOG.w(f"[verify_by_url] 같은 업소가 둘이다 — {place_id} 에 쌓인 것이 있어 "
f"지우지 않는다. 정본 {canonical_id} 를 돌려준다 (naver {naver_id})")
place_id = canonical_id
err_type, place = await self._load(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
coord = base.get("coordinate") or {}
verify_req = Req_VerifyPlace(
@ -919,6 +946,15 @@ class PlaceService:
res.result.SetResult(err_type)
return res
# URL 에 잡 ID 를 쓰기 전에 응답이 끊겨도 완료된 작업을 다시 만들지 않는다.
if req.resume:
existing = await self.queue.find_latest(f"copy:{place_id}")
if existing:
res.job_id = uuid.UUID(existing["job_id"])
res.status = JobStatus(existing["status"])
res.created = False
return res
crud = FactCRUD()
f_err, rows = await DB_SESSION_MNG.execute_lambda(
facts_model.DBType(),

View File

@ -217,10 +217,15 @@ class SiteService:
return reason, None
value = (slug or "").strip()
# ★ 주인 확인이 예약어보다 먼저다. 예약어 목록은 나중에 늘어나는데(목업 슬러그를 막는
# 것처럼), 늘린 이름을 이미 쓰고 있던 사장님이 자기 편집 화면을 여는 순간
# "이 주소는 못 씁니다" 가 뜬다 — 바꿀 수도 없는 주소다(발행 뒤 SITE_SLUG_LOCKED).
owner = await self._domain_owner(value)
if owner is not None and owner == str(uuid.UUID(place_id)):
return None, None
if reason is None:
owner = await self._domain_owner(value)
# ★ 이미 자기 주소면 쓸 수 있다 — 저장해 둔 화면을 다시 열었을 때 '중복'이라고 하면 안 된다.
if owner is None or owner == str(uuid.UUID(place_id)):
if owner is None:
return None, None
reason = site_slug.REASON_TAKEN
return reason, await self._suggest(value)
@ -228,13 +233,25 @@ class SiteService:
async def check_slug(self, user_info: UserInfo, place_id: str, slug: str) -> Res_SlugCheck:
"""주소를 쓸 수 있는지 미리 본다.
서버가 상호명으로 자동 확정하지 않는다 사람이 고르고, 겹치면 고르기 전에 알려 준다."""
서버가 상호명으로 자동 확정하지 않는다 사람이 고르고, 겹치면 고르기 전에 알려 준다.
확인과 저장은 같은 판정을 해야 한다. 발행 잠금(SITE_SLUG_LOCKED) 여기서 보면
"쓸 수 있습니다" 라고 답해 놓고 저장에서 1707 튕긴다 지금은 발행 모달이
입력칸을 잠가 가려져 있을 뿐이고, API 직접 쓰는 쪽에는 그대로 드러난다."""
res = Res_SlugCheck()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
site = await self._get_site(place_id)
if site is not None and (site.published_at is not None or site.status == SiteStatus.PUBLISHED.value):
if (site.domain or "") != (slug or "").strip():
res.available = False
res.reason = site_slug.REASON_LOCKED
return res
res.available = True
return res
reason, suggestion = await self._judge_slug(place_id, slug)
res.available = reason is None
res.reason = reason
@ -560,6 +577,26 @@ class SiteService:
res.result.SetResult(ErrorType.PLACE_NOT_VERIFIED)
return res
# ★ publish=False 는 **이미 나가 있는 사이트를 다시 굽는** 경로다. 한 번도 발행한 적
# 없는 사업장에는 쓸 수 없다.
# 왜 막나 — 빌드는 게이트를 판정하려고 payload 를 디스크에 쓰고, 프리렌더는 거기
# 생긴 것을 곧바로 공개 페이지로 굽는다(sitemap · llms.txt · /s 목록 포함).
# 그래서 publish=False 인데도 페이지가 공개됐고, DB 는 DRAFT·published_at=NULL 이라
# 빌더 화면은 "발행 전" 으로 보였다. 2차 게이트(고유 콘텐츠 · JSON-LD)는 구운 결과를
# 봐야 판정하므로 **거부돼도 페이지는 이미 나가 있었다**(실측 2026-09-15).
# 발행 전에 보고 싶은 것은 `GET /site/preview` 가 준다 — 그쪽은 디스크를 건드리지 않는다.
site = await self._get_site(place_id)
if not req.publish:
if site is None or site.published_at is None:
res.result.SetResult(ErrorType.SITE_VERSION_NOT_FOUND)
return res
# ★ 주소를 안 골랐어도 막지 않는다. 그때 쓰는 임시 슬러그(`slugify(상호명)-place_id[:8]`)에
# 한글이 남는 것은 실수가 아니라 결정이다 — 음차하면 같은 가게가 두 주소를 갖는다
# (`site_payload.slugify` 주석). 사람이 고르는 주소만 영문으로 제한한다.
# 발행 모달은 주소를 먼저 받게 되어 있고(PublishModal), API 로 건너뛰면 임시 주소로
# 나간 뒤 SITE_SLUG_LOCKED 로 잠긴다는 점은 그대로다.
job_id, created = await enqueue_job(
self.queue, JobType.BUILD,
{

View File

@ -48,6 +48,14 @@ 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` 는 주인도 예약도 없어 "쓸 수 있다" 로 나갔다.
# ★ 굽기 쪽 보호(prerender PROTECTED_SLUGS)와 겹쳐 두는 것이지 대신하는 게 아니다 —
# 저쪽은 이미 나간 발행을 막고, 여기는 애초에 고르지 못하게 한다.
"stay", "stay2", "stay3",
})

View File

@ -54,6 +54,7 @@ from crud.song_crud import SongCRUD
from services import place_research, site_payload
from services.external import gemini_text, suno
from services.llm.gemini import GeminiError, GeminiInvalidOutput, GeminiNotConfigured
from common.job_errors import PermanentJobError
_fact_crud = FactCRUD()
_place_crud = PlaceCRUD()
@ -65,7 +66,7 @@ _song_crud = SongCRUD()
SONGS_DIRNAME = "songs"
class SongAborted(RuntimeError):
class SongAborted(PermanentJobError):
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남아 운영자가 본다."""

View File

@ -18,12 +18,13 @@ from config.server_configs import external_api_config
from crud.media_crud import MediaCRUD
from crud.place_crud import PlaceCRUD
from services.external import gemini
from common.job_errors import PermanentJobError
_media_crud = MediaCRUD()
_place_crud = PlaceCRUD()
class VisionAborted(RuntimeError):
class VisionAborted(PermanentJobError):
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남는다."""

View File

@ -97,10 +97,14 @@ async def test_unverified_fact_blocks_publish(auth_headers, client, db_engine):
"""검증: 미검증 fact 가 섞인 채로 빌드한다.
기대결과: 스냅샷에서 전부 걸러져 페이지에 남는 내용이 없다 NO_UNIQUE_CONTENT 막힌다.
(필수 항목 누락은 2026-08-27 부터 막지 않는다. 그래서 여기서 걸리는 사유가 바뀌었다.)"""
(필수 항목 누락은 2026-08-27 부터 막지 않는다. 그래서 여기서 걸리는 사유가 바뀌었다.)
미검증값은 공식 API 수집으로 만든다. 크롤링은 2026-09-14 부터 자리에 바로
노출값으로 들어가므로 더는 '미검증 fact' 만드는 경로가 아니다
그래도 UNVERIFIED 페이지에 새어 나가면 된다는 것은 그대로다."""
h = await auth_headers("u1")
pid = await _place(client, h, "미검증펜션")
# 크롤링은 바로 노출되는 정책이다. 실제 미검증 상태를 만드는 API 출처로 검사한다.
# 공식 API 로 들어온 값 = 후보 상태(UNVERIFIED)
for k, v in REQUIRED.items():
await client.post(f"/v1/place/{pid}/fact", headers=h, json={
"key": k, "value": v, "source_type": SourceType.API.value, "source_url": "https://api.test/1"})

View File

@ -45,9 +45,14 @@ async def _run_worker(job_id=None):
assert ran, "워커가 집을 잡이 없다"
async def test_pipeline_stores_facts_as_candidates(auth_headers, client):
async def test_pipeline_publishes_collected_facts(auth_headers, client):
"""검증: 수집 잡을 끝까지 돌린다.
기대결과: fact 쌓이되 **전부 후보(UNVERIFIED)** 크롤링 값은 사이트에 나간다."""
기대결과: 자리에 들어온 수집값이 **바로 노출값** 된다(2026-09-14 결정).
예전에는 전부 UNVERIFIED 후보였다. 그러면 수집 직후 발행이 '확인된 사실 0건' 으로
막혀, 사장님이 건씩 승인하기 전에는 사이트가 만들어지지 않았다.
사람이 넣은 ·정정본을 덮지 않는다는 보호는 그대로다
test_recollect_cannot_overwrite_corrected_value 자리를 지킨다."""
h = await auth_headers("u1")
pid = await _ready_place(client, h)
@ -60,8 +65,11 @@ async def test_pipeline_stores_facts_as_candidates(auth_headers, client):
listed = (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json()
assert len(listed["facts"]) > 0
assert listed["publishable"] == 0, "★ 수집값이 바로 사이트에 나가면 안 된다"
assert all(f["status"] == FactStatus.UNVERIFIED.value for f in listed["facts"])
assert listed["publishable"] > 0, "수집값이 노출값 자리에 들어가지 않았다"
assert all(
f["status"] in (FactStatus.VERIFIED.value, FactStatus.UNVERIFIED.value)
for f in listed["facts"]
)
async def test_pipeline_records_source_on_every_fact(auth_headers, client):
@ -172,24 +180,26 @@ async def test_coverage_reports_missing_required_fields(auth_headers, client):
async def test_recollect_does_not_touch_verified_value(auth_headers, client):
"""검증: 수집값을 사람이 승인한 뒤 다시 수집한다.
기대결과: 값이 같으므로 REFRESHED 사이트에 나가던 사실이 사라지지 않는다."""
"""검증: 수집을 두 번 돌린다(같은 목데이터라 값이 같다).
기대결과: 값이 같으므로 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={})
await _run_worker()
facts = (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json()["facts"]
target = next(f for f in facts if f["key"] == "check_in_time")
await client.post(f"/v1/place/{pid}/fact/{target['fact_id']}/transition", headers=h,
json={"status": FactStatus.VERIFIED.value})
assert (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json()["publishable"] == 1
before = (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json()
assert before["publishable"] > 0
check_in_before = [f for f in before["facts"] if f["key"] == "check_in_time"]
assert len(check_in_before) == 1 and check_in_before[0]["status"] == FactStatus.VERIFIED.value
await client.post(f"/v1/place/{pid}/collect", headers=h, json={})
await _run_worker()
after = (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json()
assert after["publishable"] == 1, "★ 재수집이 확인된 값을 밀어냈"
assert after["publishable"] == before["publishable"], "★ 재수집이 노출값 수를 바꿨"
check_in = [f for f in after["facts"] if f["key"] == "check_in_time"]
assert len(check_in) == 1 and check_in[0]["status"] == FactStatus.VERIFIED.value

View File

@ -32,6 +32,8 @@ from services.collector.registry import REGISTRY
# 여기 남은 것은 **수집 불가 결론이 난 곳**이다(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",
@ -174,7 +176,7 @@ def test_registers_only_reviewed_adapters():
기대결과: 검토를 거쳐 명시적으로 승인한 것만 있다. 목록이 늘어나는 것은
**의도된 결정이어야** 하므로, 코드가 몰래 늘어나면 여기서 깨진다."""
assert adapter_ids() == ["naver_place", "tour_api", "mock", "static_html"], (
assert adapter_ids() == ["naver_place", "tour_api", "yanolja", "mock", "static_html"], (
f"예상 밖 어댑터가 등록됐다: {adapter_ids()} — 승인 없이 수집 대상을 늘리지 않는다"
)

View File

@ -91,6 +91,57 @@ async def _must_not_call_llm(*args, **kwargs):
raise AssertionError("★ 근거 없이 LLM 을 불렀다")
async def test_resume_reuses_completed_job(auth_headers, client, db_engine, monkeypatch):
_patch(monkeypatch, _copy())
h = await auth_headers("resume-owner")
pid = await _place_with_facts(client, h)
path = f"/v1/place/{pid}/copy"
first = (await client.post(path, headers=h, json={})).json()
pending = (await client.post(path, headers=h, json={"resume": True})).json()
assert pending["job_id"] == first["job_id"]
await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain()
resumed = (await client.post(path, headers=h, json={"resume": True})).json()
assert resumed["job_id"] == first["job_id"]
assert resumed["status"] == JobStatus.DONE.value
assert resumed["created"] is False
job = (await client.get(f"/v1/job/{first['job_id']}", headers=h)).json()["job"]
assert job["place_id"] == pid
assert [(s["id"], s["status"]) for s in job["progress"]["steps"]] == [
("prepare", "done"), ("generate", "done"), ("save", "done"), ("faq_fill", "done"),
]
fresh = (await client.post(path, headers=h, json={})).json()
assert fresh["job_id"] != first["job_id"]
async def test_copy_progress_is_visible_during_generation(auth_headers, client, db_engine, monkeypatch):
h = await auth_headers("progress-owner")
pid = await _place_with_facts(client, h)
_patch(monkeypatch, _copy())
started = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json()
async def generate(*args, **kwargs):
row = await JobQueue().get(started["job_id"])
assert row["status"] == JobStatus.RUNNING.value
assert [step["status"] for step in row["progress"]["steps"]] == ["done", "running", "pending", "pending"]
return _copy()
monkeypatch.setattr(gemini_text, "generate_copy", generate)
await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain()
row = await JobQueue().get(started["job_id"])
assert row["status"] == JobStatus.DONE.value, row["last_error"]
async def test_copy_job_cannot_be_read_by_another_owner(auth_headers, client, monkeypatch):
_patch(monkeypatch, _copy())
owner = await auth_headers("owner")
other = await auth_headers("other")
pid = await _place_with_facts(client, owner)
started = (await client.post(f"/v1/place/{pid}/copy", headers=owner, json={})).json()
response = (await client.get(f"/v1/job/{started['job_id']}", headers=other)).json()
assert response["result"]["success"] is False
assert not response.get("job")
async def test_copy_refuses_without_facts_when_no_catalog(auth_headers, client, monkeypatch):
"""검증: 확인된 fact 가 하나도 없는 **호텔**(펜션 카탈로그 제외 대상)에서 생성을 시도한다.
기대결과: FAQ_UNGROUNDED 잡을 만들지 않는다. 근거도, 채울 공통 질문도 없다."""
@ -116,6 +167,10 @@ 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 은 이제 즉시 노출된다. 이 테스트는 과거에 남은 미검증 행을 명시적으로 준비한다.
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)})
body = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json()
assert body["result"]["success"] is True and body["grounded_facts"] == 0
@ -123,6 +178,8 @@ async def test_unverified_facts_are_not_used_as_grounding(auth_headers, client,
job = (await client.get(f"/v1/job/{body['job_id']}", headers=h)).json()["job"]
assert job["status"] == JobStatus.DONE.value, job.get("last_error")
assert job["result"]["faqs"] == 0 and job["result"]["faq_fill"] == 20
assert job["progress"]["steps"][1]["status"] == "skipped"
assert job["progress"]["steps"][1]["reason"] == "no_facts"
live = [r for r in await _faq_rows(db_engine, pid) if r[3] in _PUBLISHABLE]
assert len(live) == 20 and all(r[4] == SourceType.TEMPLATE.value for r in live)

View File

@ -126,27 +126,38 @@ async def test_llm_cannot_overwrite_corrected_sentence(auth_headers, client):
# ── 생성 프로세스 ─────────────────────────────────────────────────────────
async def test_crawled_fact_starts_as_candidate_and_is_not_published(auth_headers, client):
async def test_crawled_fact_publishes_immediately(auth_headers, client):
"""검증: 크롤링으로 처음 들어온 값.
기대결과: UNVERIFIED 후보로 남고 사이트에 나간다 절대규칙 1."""
기대결과: 자리이므로 **바로 노출값** 된다(2026-09-14 결정).
예전에는 UNVERIFIED 후보로 남겼다. 수집이 끝난 뒤에야 오는 값이라 사장님이 승인할
화면을 이미 지나가 있었고, 확인된 사실이 하나도 없는 채로 발행 게이트에 걸렸다.
승인 이력과는 verified_by 구별한다 자동 노출은 비어 있다. 사람이 없다는
사실 자체를 지우면 나중에 '누가 확인했나' 되짚을 없다."""
h = await auth_headers("u1")
pid = await _verified_place(client, h, kakao="p1")
body = await _crawl(client, h, pid, "check_in_time", "15:00")
assert body["outcome"] == FactWriteOutcome.CANDIDATE_CREATED.value
assert body["fact"]["status"] == FactStatus.UNVERIFIED.value
assert body["outcome"] == FactWriteOutcome.PUBLISHED_CREATED.value
assert body["fact"]["status"] == FactStatus.VERIFIED.value
# 응답에서 None 필드는 지워져 나간다(RemoveNoneResponse) — 없는 것이 곧 비어 있다는 뜻이다.
assert body["fact"].get("verified_by") is None, "★ 자동 노출을 사람 승인으로 위장하면 안 된다"
listed = await _facts(client, h, pid)
assert listed["publishable"] == 0
assert (await _facts(client, h, pid, publishable_only=True)).get("facts", []) == []
assert listed["publishable"] == 1
assert (await _facts(client, h, pid, publishable_only=True))["facts"][0]["value"] == "15:00"
async def test_approving_candidate_publishes_it(auth_headers, client):
"""검증: 후보를 VERIFIED 로 승인한다.
기대결과: verified_at 찍히고 사이트에 나갈 있게 된다."""
기대결과: verified_at 찍히고 값이 노출값 자리를 가져간다.
후보는 사장님이 직접 넣은 위에 다른 수집값이 생긴다 크롤링끼리는
뒤에 값이 앞의 값을 바로 대체하므로( 테스트) 여기서 사람 입력을 먼저 둔다."""
h = await auth_headers("u1")
pid = await _verified_place(client, h, kakao="p2")
fid = (await _crawl(client, h, pid, "check_in_time", "15:00"))["fact"]["fact_id"]
await _own(client, h, pid, "check_in_time", "15:00")
fid = (await _crawl(client, h, pid, "check_in_time", "16:00"))["fact"]["fact_id"]
body = (await client.post(f"/v1/place/{pid}/fact/{fid}/transition", headers=h,
json={"status": FactStatus.VERIFIED.value})).json()
@ -158,10 +169,12 @@ 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 확인을 건너뛴 '정정본' 만들 없다."""
기대결과: FACT_INVALID_TRANSITION 확인을 건너뛴 '정정본' 만들 없다.
UNVERIFIED 이제 공식 API 수집이 자리에 넣을 생긴다(크롤링은 바로 노출)."""
h = await auth_headers("u1")
pid = await _verified_place(client, h, kakao="p3")
fid = (await _crawl(client, h, pid, "check_in_time", "15:00"))["fact"]["fact_id"]
fid = (await _crawl(client, h, pid, "check_in_time", "15:00", SourceType.API))["fact"]["fact_id"]
r = await client.post(f"/v1/place/{pid}/fact/{fid}/transition", headers=h,
json={"status": FactStatus.CORRECTED.value, "value": "16:00"})
@ -185,12 +198,14 @@ 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 가 바뀐 경우).
기대결과: 노출값은 그대로 살아 있고, 값은 PENDING_OWNER 후보로만 쌓인다.
자동 수집이 사람이 넣은 값을 밀어내지 못한다는 것이 테스트의 본체다.
크롤링이 직전 크롤링 값을 대체하는 것과는 다른 얘기다( 즉시 노출 결정)."""
h = await auth_headers("u1")
pid = await _verified_place(client, h, kakao="r2")
fid = (await _crawl(client, h, pid, "check_in_time", "15:00"))["fact"]["fact_id"]
await client.post(f"/v1/place/{pid}/fact/{fid}/transition", headers=h, json={"status": FactStatus.VERIFIED.value})
await _own(client, h, pid, "check_in_time", "15:00")
body = await _crawl(client, h, pid, "check_in_time", "16:00")
assert body["outcome"] == FactWriteOutcome.CANDIDATE_CREATED.value
@ -208,8 +223,7 @@ 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")
old = (await _crawl(client, h, pid, "check_in_time", "15:00"))["fact"]["fact_id"]
await client.post(f"/v1/place/{pid}/fact/{old}/transition", headers=h, json={"status": FactStatus.VERIFIED.value})
await _own(client, h, pid, "check_in_time", "15:00")
new = (await _crawl(client, h, pid, "check_in_time", "16:00"))["fact"]["fact_id"]
body = (await client.post(f"/v1/place/{pid}/fact/{new}/transition", headers=h,
@ -227,8 +241,7 @@ 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")
fid = (await _crawl(client, h, pid, "check_in_time", "15:00"))["fact"]["fact_id"]
await client.post(f"/v1/place/{pid}/fact/{fid}/transition", headers=h, json={"status": FactStatus.VERIFIED.value})
await _own(client, h, pid, "check_in_time", "15:00")
await _crawl(client, h, pid, "check_in_time", "16:00")
body = await _crawl(client, h, pid, "check_in_time", "17:00")
@ -303,13 +316,18 @@ 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")
before = (await client.get(f"/v1/place/{pid}", headers=h)).json()["place"].get("content_updated_at")
await _crawl(client, h, pid, "check_in_time", "15:00")
await _crawl(client, h, pid, "check_in_time", "16:00")
place = (await client.get(f"/v1/place/{pid}", headers=h)).json()["place"]
assert place.get("content_updated_at") is None
assert place.get("content_updated_at") == before, "후보만 쌓였는데 재빌드 대상이 됐다"
async def test_facts_are_scoped_to_owner(auth_headers, client):

View File

@ -85,13 +85,17 @@ async def _rows(place_id):
async def test_generates_both_durations(db_engine, spy_perplexity):
"""검증: 아무것도 없는 업장.
기대결과: 기간 둘을 각각 번씩 부르고( 2) 각각 5 코스를 저장한다."""
기대결과: 기간 둘을 각각 만들고 각각 5 코스를 저장한다.
호출 수는 기간당 MAX_ATTEMPTS . 목응답이 5(< TARGET_COURSES=10) 서비스가
같은 프롬프트로 부르고, 번째 응답은 already_seen 걸려 전부 버려진다
그래서 호출은 늘어도 저장된 코스는 5 그대로다."""
calls, _ = spy_perplexity
pid = uuid.uuid4()
out = await service.ensure_generated(_FakePlace(pid))
assert len(calls) == 2
assert len(calls) == 2 * service.MAX_ATTEMPTS
assert out["counts"] == {"1박 2일": 5, "2박 3일": 5}
rows = await _rows(pid)
assert {r.duration for r in rows} == {"1박 2일", "2박 3일"}
@ -132,8 +136,8 @@ async def test_fills_only_the_missing_duration(db_engine, spy_perplexity):
out = await service.ensure_generated(_FakePlace(pid))
assert len(calls) == 1
assert "2박 3일" in calls[0]["messages"][1]["content"]
assert len(calls) == service.MAX_ATTEMPTS
assert all("2박 3일" in c["messages"][1]["content"] for c in calls)
assert out["counts"] == {"2박 3일": 5}
@ -237,7 +241,7 @@ async def test_ensure_generated_by_id_loads_the_place(db_engine, spy_perplexity,
out = await service.ensure_generated_by_id(pid)
assert len(calls) == 2
assert len(calls) == 2 * service.MAX_ATTEMPTS
assert out["counts"] == {"1박 2일": 5, "2박 3일": 5}

View File

@ -33,6 +33,32 @@ async def test_enqueue_and_claim(db_engine):
assert row["status"] == JobStatus.RUNNING.value
async def test_progress_rejects_old_worker_and_resets_on_retry(db_engine):
from services.job_progress import JobProgress
q = JobQueue()
jid = await q.enqueue(JobType.COPY.value, {})
old = await q.claim("w1")
progress = JobProgress(old, ("prepare", "generate"))
async with progress.step("prepare"):
pass
try:
async with progress.step("generate"):
raise ValueError("upstream failed")
except ValueError:
pass
row = await q.get(jid)
assert row["progress"]["steps"][1]["status"] == "failed"
await q.fail(jid, "w1", "upstream failed", backoff_sec=0)
new = await q.claim("w2")
assert not await q.set_progress(old, {"steps": [], "attempt": 1})
retried = JobProgress(new, ("prepare", "generate"))
async with retried.step("prepare"):
row = await q.get(jid)
assert row["progress"]["attempt"] == 2
assert [s["status"] for s in row["progress"]["steps"]] == ["running", "pending"]
async def test_claim_is_atomic_across_workers(db_engine):
"""검증: 잡 1건에 워커 5개가 동시에 달려든다.
기대결과: 정확히 1명만 가져간다 도커에서 워커를 개로 스케일하든 이중 실행이 없다."""

View File

@ -105,16 +105,38 @@ async def test_search_rejects_short_query(client):
async def test_rate_limit_closes_the_tap(client, monkeypatch):
"""인증이 없는데 유료 외부 API 를 부른다 — 새로고침만으로 요금이 나가면 안 된다."""
"""인증이 없는데 유료 외부 API 를 부른다 — 새로고침만으로 요금이 나가면 안 된다.
검색어를 매번 바꾼다. 같은 검색어는 ttl_cache 레이트리밋보다 **앞에서** 받아
외부 API 아예 부르므로(요금이 나가므로 제한할 이유도 없다), 같은 말을
반복하면 가드가 아니라 캐시를 시험하게 된다."""
rate_limit.reset()
_patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")])
from services import place_service
monkeypatch.setattr(place_service, "_PUBLIC_SEARCH_PER_MIN", 2)
for _ in range(2):
assert (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()["result"]["success"] is True
for i in range(2):
r = (await client.get("/v1/place/search", params={"q": f"하조대펜션{i}"})).json()
assert r["result"]["success"] is True
blocked = (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()
blocked = (await client.get("/v1/place/search", params={"q": "하조대펜션9"})).json()
assert blocked["result"]["success"] is False
assert blocked["result"]["code"] == ErrorType.HTTP_TO_MANY_REQUEST.value
async def test_cached_query_does_not_spend_the_rate_limit(client, monkeypatch):
"""검증: 같은 검색어를 제한 횟수보다 많이 친다.
기대결과: 통과한다 캐시가 받으면 외부 API 부르고, 부르면 요금도 난다.
레이트리밋을 캐시보다 앞으로 옮기면 사장님이 상호를 고쳐 가며 치는 평범한 사용이
막힌다. 순서가 의도라는 것을 여기서 박는다."""
rate_limit.reset()
_patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")])
from services import place_service
monkeypatch.setattr(place_service, "_PUBLIC_SEARCH_PER_MIN", 2)
for _ in range(5):
r = (await client.get("/v1/place/search", params={"q": "같은검색어"})).json()
assert r["result"]["success"] is True

View File

@ -114,7 +114,12 @@ async def test_사이트_디렉터리_밖의_thumbs_에_올린다(blob, monkeypa
# ★ `?v=` 는 캐시 무효화다. 블롭 이름은 그대로 덮어쓰므로 주소가 안 변하면
# 브라우저·CDN 이 지난 발행의 그림을 계속 보여준다(site_thumbnail.public_url).
assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg?v=3"
# ★ 호스트를 박아 두지 않는다. 발행 오리진은 SITE_PUBLIC_HOST 에서 오고 기본값이
# localhost 라(운영 주소를 기본으로 두면 로컬 빌드가 조용히 운영 주소를 굽는다),
# 테스트가 특정 도메인을 적으면 환경이 바뀔 때마다 여기서 깨진다.
from services import site_payload
assert url == f"{site_payload.publish_origin()}/thumbs/butter.jpg?v=3"
name = f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg"
assert set(blob.uploads) == {name}
assert not name.startswith(f"{azure_static.DEFAULT_PREFIX}/{azure_static.SITE_ROOT_DIR}/")

View File

@ -13,6 +13,7 @@
import asyncio
from common.enums import JobStatus
from common.job_errors import PermanentJobError
from common.logger import LOG
from crud.job_crud import JobQueue, compute_backoff
@ -86,6 +87,12 @@ class Worker:
st = await self.queue.fail(jid, self.worker_id, f"JobDeadlineExceeded: {self.job_deadline_sec:.0f}s", backoff)
LOG.w(f"[{self.worker_id}] deadline {jid}{JobStatus(st).name if st else '?'} "
f"({self.job_deadline_sec:.0f}s 초과, 핸들러 취소)")
except PermanentJobError as ex:
# ★ 재시도하지 않는다 — 다시 해도 같은 결과다(common/job_errors 주석).
# 실측(2026-09-15): 사업장이 지워진 뒤 남은 소개문 잡이 "사업장을 찾을 수 없다" 로
# 세 번 돌고 DEAD 로 갔다. 결과는 같고 큐 지연과 알림만 늘었다.
await self.queue.fail_permanent(jid, self.worker_id, f"{type(ex).__name__}: {ex}")
LOG.w(f"[{self.worker_id}] fail {jid} → DEAD (재시도 안 함: {type(ex).__name__}: {ex})")
except Exception as ex:
backoff = self.backoff_fn(job["attempts"])
st = await self.queue.fail(jid, self.worker_id, f"{type(ex).__name__}: {ex}", backoff)

View File

@ -4,9 +4,11 @@
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { JobDataPlaceId } from "./jobDataPlaceId";
import type { JobType } from "./jobType";
import type { JobStatus } from "./jobStatus";
import type { JobDataResult } from "./jobDataResult";
import type { JobDataProgress } from "./jobDataProgress";
import type { JobDataLastError } from "./jobDataLastError";
import type { JobDataRunAfter } from "./jobDataRunAfter";
import type { JobDataRunStartedAt } from "./jobDataRunStartedAt";
@ -15,11 +17,13 @@ import type { JobDataUpdatedAt } from "./jobDataUpdatedAt";
export interface JobData {
job_id: string;
place_id?: JobDataPlaceId;
job_type: JobType;
status: JobStatus;
attempts?: number;
max_attempts?: number;
result?: JobDataResult;
progress?: JobDataProgress;
last_error?: JobDataLastError;
run_after?: JobDataRunAfter;
run_started_at?: JobDataRunStartedAt;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type JobDataPlaceId = string | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { JobProgress } from "./jobProgress";
export type JobDataProgress = JobProgress | null;

View File

@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { JobStep } from "./jobStep";
export interface JobProgress {
steps: JobStep[];
attempt: number;
}

View File

@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { JobStepStatus } from "./jobStepStatus";
import type { JobStepReason } from "./jobStepReason";
export interface JobStep {
id: string;
status: JobStepStatus;
reason?: JobStepReason;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type JobStepReason = string | null;

View File

@ -0,0 +1,17 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type JobStepStatus = (typeof JobStepStatus)[keyof typeof JobStepStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const JobStepStatus = {
pending: "pending",
running: "running",
done: "done",
skipped: "skipped",
failed: "failed",
} as const;

View File

@ -11,5 +11,5 @@
fact . ( ).
*/
export interface ReqStartCopy {
[key: string]: unknown;
resume?: boolean;
}

View File

@ -19,4 +19,6 @@
*/
export interface ReqVerifyPlaceByUrl {
url?: string;
/** 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지. 기본 true. */
reuse_existing?: boolean;
}

View File

@ -16,8 +16,16 @@ import type {JobData} from './generated/model';
/** 잡 폴링 간격. 수집·빌드는 분 단위로 도는 작업이라 더 촘촘히 물어봐도 얻는 게 없다. */
export const JOB_POLL_MS = 2000;
/** ★ 폴링 총 제한시간. 워커가 죽어 잡이 RUNNING 에 박제되면 여기서 끊는다 — 무한 폴링 금지. */
export const JOB_POLL_TIMEOUT_MS = 3 * 60 * 1000;
/**
* . 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;

View File

@ -1,5 +1,8 @@
import {Check} from 'lucide-react';
import {useRef, useState} from 'react';
import type {TemplateItem} from '@o2o/shared';
import {getAccessToken, startCopy} from '@/api';
import {notifyApiError} from '@/lib/notify';
import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {queueSiteTemplateSave} from '@/features/publish/siteTemplate';
import {cn} from '@/lib/utils';
@ -87,7 +90,29 @@ export function Step4Template() {
const placeId = useBuilderStore((s) => s.placeId);
const selectTemplate = useBuilderStore((s) => s.selectTemplate);
const [, goToStep] = useWizardStep();
const startGenerating = useBuilderStore((s) => s.startGenerating);
const starting = useRef(false);
const [busy, setBusy] = useState(false);
const startGeneration = async () => {
if (starting.current) return;
queueSiteTemplateSave(placeId, templateId);
if (!placeId || !getAccessToken()) {
goToStep('editor');
return;
}
starting.current = true;
setBusy(true);
try {
const response = await startCopy(placeId, {});
if (response.result?.success !== true || !response.job_id) throw {data: response};
goToStep('generating', {params: {placeId, jobId: response.job_id}});
} catch (error) {
notifyApiError(error, '소개문 생성을 시작하지 못했습니다.');
} finally {
starting.current = false;
setBusy(false);
}
};
const config = INDUSTRY_CONFIGS[industry];
@ -168,14 +193,9 @@ export function Step4Template() {
<WizardFooter
onPrev={() => goToStep('collect')}
onNext={() => {
// ★ 아무것도 안 누르고 넘어가는 경우(첫 템플릿이 이미 선택돼 있다)도 서버에 남긴다 —
// 화면이 보여준 그 템플릿이 발행본이 되어야 한다. 같은 값이면 서버가 재빌드 표시도 찍지 않는다.
queueSiteTemplateSave(placeId, templateId);
startGenerating();
goToStep('generating');
}}
nextLabel="이 템플릿으로 사이트 생성하기"
onNext={() => void startGeneration()}
nextDisabled={busy}
nextLabel={busy ? '생성 작업을 시작하고 있습니다' : '이 템플릿으로 사이트 생성하기'}
/>
</div>
);

View File

@ -1,193 +1,58 @@
import {useCallback, useEffect} from 'react';
import {Check, Loader2} from 'lucide-react';
import {JobStatus} from '@o2o/shared';
import {delay, getAccessToken, pollJob, startCopy} from '@/api';
import {Progress} from '@/components/ui/progress';
import {notify, notifyApiError} from '@/lib/notify';
import {cn} from '@/lib/utils';
import {Button} from '@/components/ui/button';
import {useBuilderStore} from '@/stores/builder';
import {EDITOR_STEP, useWizardStep} from './wizardUrl';
const BUILD_STEPS = [
'수집된 사진 분류 및 대체 텍스트 생성',
'브랜드 컬러 시스템 및 타이포그래피 조합',
'확인된 사실만으로 소개 콘텐츠 구성',
'위치 기반 길찾기 및 주변 정보 연동',
'구조화 데이터(JSON-LD) 준비',
];
const STAGE_MS = 600;
/**
* PENDING/RUNNING/DONE progress .
* "지금 서버가 이 근처를 하고 있다" , .
* RUNNING , DONE
* 100% .
*/
const RUNNING_STAGE_CEILING = BUILD_STEPS.length - 1;
import {useGenerationJob} from './useGenerationJob';
import {GENERATION_LABELS, SKIP_REASONS} from './generationLabels';
export function Step5Generating() {
const storeName = useBuilderStore((s) => s.storeName);
const stage = useBuilderStore((s) => s.generateStage);
const setGenerateStage = useBuilderStore((s) => s.setGenerateStage);
const placeId = useBuilderStore((s) => s.placeId);
const [, goToStep] = useWizardStep();
/**
* .
*
* .
* , .
* 2 '목록에 생기는 것'
* '목록으로 보내는 것' .
*/
const finishOnboarding = useCallback(() => {
// ★ `flow=onboarding` 도 같이 뗀다. 남겨 두면 이 주소를 새로고침했을 때 위저드 중인
// 사업장으로 읽혀 신원이 다시 세워진다 — 편집 중인 사람에게는 아무 의미가 없는 일이다.
goToStep(EDITOR_STEP, {params: {flow: null}, replace: true});
}, [goToStep]);
/**
* ·FAQ (JobType.COPY). `POST /v1/place/{id}/copy` .
*
* (JobType.BUILD)
* (services/build_service),
* .
* 폴백: placeId( ) .
*/
useEffect(() => {
const controller = new AbortController();
/** 타이머 시뮬레이션. 폴백 경로 전용 — 백엔드가 없어도 위저드는 끝까지 간다. */
const runSimulation = async () => {
for (let next = 1; next <= BUILD_STEPS.length; next += 1) {
await delay(STAGE_MS, controller.signal);
if (controller.signal.aborted) return;
setGenerateStage(next);
}
await delay(STAGE_MS, controller.signal);
if (!controller.signal.aborted) finishOnboarding();
};
/** RUNNING 인 동안 문구만 천천히 밀어 준다. 완료 판정은 하지 않는다. */
const creep = () =>
setGenerateStage(
Math.min(RUNNING_STAGE_CEILING, useBuilderStore.getState().generateStage + 1),
);
const runCopy = async (id: string) => {
let jobId: string;
try {
const started = await startCopy(id, {}, undefined, controller.signal);
// ★ 근거로 쓸 확인된 fact 가 0 이면 서버는 아무것도 쓰지 않는다 — LLM 은 사실을 만들지 않는다.
if (started.grounded_facts === 0) {
notify.warn(
'확인된 정보가 없어 소개문을 쓰지 않았습니다.',
'[확인] 탭에서 수집된 정보를 확정하면 그 사실만으로 소개문을 씁니다.',
);
}
if (!started.job_id) {
await runSimulation();
return;
}
jobId = started.job_id;
} catch (error) {
if (controller.signal.aborted) return;
notifyApiError(error, '소개문 생성을 시작하지 못했습니다.');
await runSimulation();
return;
}
setGenerateStage(1);
const outcome = await pollJob(jobId, {
signal: controller.signal,
onTick: (job) => {
if (job.status === JobStatus.RUNNING) creep();
},
});
if (outcome.kind === 'aborted') return;
if (outcome.kind === 'dead') {
notify.error('소개문을 만들지 못했습니다.', outcome.job.last_error ?? undefined);
} else if (outcome.kind === 'unreachable') {
notifyApiError(outcome.error, '생성 상태를 확인하지 못했습니다.');
} else if (outcome.kind === 'timeout') {
notify.warn('생성이 예상보다 오래 걸립니다.', '생성은 계속 진행됩니다.');
}
// ★ 실패해도 편집기로는 보낸다. 소개문은 없어도 확인된 fact 로 편집·발행이 되고,
// 여기서 멈추면 사장님은 되돌아갈 길이 없는 화면에 갇힌다.
setGenerateStage(BUILD_STEPS.length);
await delay(STAGE_MS, controller.signal);
if (!controller.signal.aborted) finishOnboarding();
};
if (!placeId || !getAccessToken()) void runSimulation();
else void runCopy(placeId);
return () => controller.abort();
}, [setGenerateStage, finishOnboarding, placeId]);
const progress = Math.min(100, Math.round((stage / BUILD_STEPS.length) * 100));
const {job, error, checkAgain, openEditor, goBack} = useGenerationJob(placeId);
const failed = job?.status === JobStatus.DEAD;
const waiting = job?.status === JobStatus.PENDING;
const steps = job?.progress?.steps ?? [];
const title = error ? '진행 상태 확인이 필요합니다'
: failed ? '콘텐츠 생성을 완료하지 못했습니다'
: waiting ? (job.attempts ? '생성을 다시 시도할 예정입니다' : '생성 순서를 기다리고 있습니다')
: job?.status === JobStatus.DONE ? '콘텐츠 생성을 마쳤습니다'
: '소개문과 FAQ를 만들고 있습니다';
return (
<div className="flex flex-1 flex-col items-center justify-center bg-muted/30 px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
<div className="mx-auto flex w-full max-w-md flex-col items-center text-center">
<div className="mb-6 flex size-16 items-center justify-center rounded-2xl border border-border bg-card">
<Loader2 className="size-8 animate-spin text-primary" />
</div>
<h1 className="mb-2 text-2xl font-bold tracking-tight sm:text-3xl">
</h1>
<p className="mb-8 max-w-md text-xs text-muted-foreground sm:text-sm">
<strong className="font-semibold text-foreground">{storeName}</strong>
.
<div className="flex flex-1 items-center justify-center bg-muted/30 px-4 py-16">
<div className="w-full max-w-md space-y-6 text-center">
{!error && !failed && <Loader2 className="mx-auto size-8 animate-spin text-primary" />}
<h1 className="text-xl font-bold" aria-live="polite">{title}</h1>
<p className="text-sm text-muted-foreground">
{error || <><strong>{storeName}</strong> .</>}
</p>
<div className="w-full space-y-4 rounded-2xl border border-border bg-card p-6 text-left">
<div className="flex items-center justify-between text-xs">
<span className="font-bold">{BUILD_STEPS[Math.min(stage - 1, BUILD_STEPS.length - 1)]}</span>
<span className="font-mono font-bold">{progress}%</span>
</div>
<Progress value={progress} aria-label="사이트 생성 진행률" />
<ol className="space-y-2.5 pt-2">
{BUILD_STEPS.map((text, index) => {
const num = index + 1;
const isDone = stage > num;
const isCurrent = stage === num;
{steps.length > 0 && (
<ol className="space-y-4 rounded-2xl border border-border bg-card p-6 text-left text-sm">
{steps.map((step) => {
const state = step.status === 'running' && failed ? 'failed' : step.status;
const label = state === 'done' ? '완료' : state === 'skipped' ? '건너뜀'
: state === 'failed' ? '중단' : state === 'running' ? (waiting ? '재시도 대기' : '진행 중') : '대기';
return (
<li
key={text}
className={cn(
'flex items-center gap-3 text-xs transition-colors',
isDone || isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground',
)}
>
<span
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold',
isDone || isCurrent
? 'bg-primary text-primary-foreground'
: 'border border-border bg-muted',
isCurrent && 'ring-2 ring-primary/20',
)}
>
{isDone ? <Check className="size-2.5" strokeWidth={3} /> : num}
</span>
<span className="truncate">{text}</span>
<li key={step.id} className="flex items-center gap-3">
{state === 'done' ? <Check className="size-4 shrink-0 text-success" />
: state === 'running' && !waiting && !error ? <Loader2 className="size-4 shrink-0 animate-spin" />
: <span className="size-4 shrink-0 rounded-full border border-border" />}
<div className="flex-1">
{GENERATION_LABELS[step.id] ?? '콘텐츠 처리'}
{step.reason && <p className="mt-1 text-xs text-muted-foreground">{SKIP_REASONS[step.reason] ?? '이 단계는 생략했습니다'}</p>}
</div>
<span className="text-xs text-muted-foreground">{label}</span>
</li>
);
})}
</ol>
</div>
<p className="mt-6 text-xs text-muted-foreground">
.
</p>
)}
{!error && !failed && <p className="text-xs text-muted-foreground"> .</p>}
{(error || failed) && <div className="flex flex-wrap justify-center gap-2">
{error && <Button onClick={checkAgain}> </Button>}
<Button variant="outline" onClick={goBack}> </Button>
<Button variant="outline" onClick={openEditor}> </Button>
</div>}
</div>
</div>
);

View File

@ -57,7 +57,14 @@ export async function ensureServerPlace(
// 그래도 없으면 검증 없이 둔다 — 수집은 열리지 않고, 화면이 붙여넣기를 요청한다.
if (!url) return {...identity, placeId};
const res = await verifyPlaceByUrl(placeId, {url});
/*
* `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}, '그 주소에서 가게 정보를 읽지 못했습니다.');

View File

@ -0,0 +1,13 @@
/** 화면 문구만 둔다. 단계 순서·완료 여부는 jobs.progress가 보낸다. */
export const GENERATION_LABELS: Record<string, string> = {
prepare: '확인된 정보와 수집 원문 준비',
generate: '소개문·FAQ 생성 및 근거 검증',
save: '소개문·FAQ 저장',
faq_fill: '부족한 FAQ 문의 안내 보완',
};
export const SKIP_REASONS: Record<string, string> = {
no_facts: '확인된 정보 없음',
not_configured: '생성 서비스 미설정',
no_catalog: '공통 질문 대상 업종 아님',
};

View File

@ -0,0 +1,75 @@
import {useCallback, useEffect, useState} from 'react';
import {useSearchParams} from 'react-router';
import {useQueryClient} from '@tanstack/react-query';
import {JobStatus, JobType} from '@o2o/shared';
import {getAccessToken, startCopy, useGetJob} from '@/api';
import {EDITOR_STEP, useWizardStep} from './wizardUrl';
/** URL의 jobId 복구 → 서버 상태 조회 → 완료 시 편집기. 시간으로 단계를 추정하지 않는다. */
export function useGenerationJob(placeId: string | null) {
const [params] = useSearchParams();
const [, goToStep] = useWizardStep();
const jobId = params.get('jobId');
const queryClient = useQueryClient();
const [startError, setStartError] = useState<string | null>(null);
const [retry, setRetry] = useState(0);
const authenticated = Boolean(getAccessToken());
const query = useGetJob(jobId ?? '', {query: {
enabled: Boolean(placeId && jobId && authenticated),
retry: 2,
refetchInterval: (current) => {
const status = current.state.data?.job?.status;
return current.state.status === 'error' || status === JobStatus.DONE || status === JobStatus.DEAD
? false : 2000;
},
}});
const job = query.data?.job;
const wrongJob = Boolean(job && (job.job_type !== JobType.COPY || job.place_id !== placeId));
const openEditor = useCallback(() => {
goToStep(EDITOR_STEP, {params: {flow: null, jobId: null}, replace: true});
}, [goToStep]);
useEffect(() => {
if (!placeId || jobId || !authenticated) return;
const controller = new AbortController();
// 구 주소이거나 시작 응답을 받기 전에 새로고침했으면 완료 이력까지 찾아 이어 본다.
void startCopy(placeId, {resume: true}, undefined, controller.signal).then((response) => {
if (controller.signal.aborted) return;
if (response.result?.success !== true || !response.job_id) {
setStartError('생성을 시작하지 못했습니다. 정보를 확인한 뒤 다시 시도해 주세요.');
return;
}
goToStep('generating', {params: {jobId: response.job_id, placeId}, replace: true});
}).catch(() => {
if (!controller.signal.aborted) setStartError('서버에 연결하지 못했습니다. 다시 확인해 주세요.');
});
return () => controller.abort();
}, [placeId, jobId, authenticated, goToStep, retry]);
useEffect(() => {
if (wrongJob || job?.status !== JobStatus.DONE) return;
let active = true;
void queryClient.invalidateQueries({predicate: (cached) =>
typeof cached.queryKey[0] === 'string' && cached.queryKey[0].startsWith(`/v1/place/${placeId}`),
}).then(() => { if (active) openEditor(); });
return () => { active = false; };
}, [job?.status, wrongJob, placeId, queryClient, openEditor]);
const error = !placeId ? '사업장 정보가 없습니다. 이전 단계에서 사업장을 선택해 주세요.'
: !authenticated ? '로그인이 필요합니다. 편집기로 이동해 로그인해 주세요.'
: wrongJob ? '이 화면의 생성 작업이 아닙니다. 이전 단계에서 다시 시작해 주세요.'
: startError ?? (query.isError || (query.isSuccess && !job)
? '진행 상태를 확인하지 못했습니다. 서버 작업은 계속될 수 있습니다.' : null);
return {
job, error, openEditor,
checkAgain: () => {
setStartError(null);
if (jobId) void query.refetch();
else setRetry((value) => value + 1);
},
goBack: () => goToStep('template', {params: {jobId: null}}),
};
}

View File

@ -157,8 +157,11 @@ export function usePublishSite(placeId: string | null): UsePublishSiteResult {
return;
}
if (outcome.kind === 'timeout') {
notify.warn('빌드가 예상보다 오래 걸립니다.', '빌드는 계속 진행됩니다.');
setState({phase: 'failed'});
// ★ 실패가 아니다 — 잡은 서버에서 계속 돈다. 화면만 풀어 주고 상태는 idle 로 되돌린다.
// 'failed' 로 두면 "빌드는 계속 진행됩니다" 라고 말하면서 실패 화면을 그린다.
notify.warn('발행이 예상보다 오래 걸립니다.', '발행은 계속 진행됩니다 — 잠시 뒤 새로고침해 확인해 주세요.');
setState(IDLE);
void queryClient.invalidateQueries({queryKey: getGetSiteQueryKey(id)});
return;
}

View File

@ -74,7 +74,6 @@ interface BuilderState {
// 템플릿 · 생성
templateId: string;
colorPaletteId: string | null;
generateStage: number;
// ── 편집 대상 데이터 ───────────────────────────────────
sections: SectionItem[];
@ -123,8 +122,6 @@ interface BuilderState {
selectTemplate: (templateId: string) => void;
selectColorPalette: (paletteId: string | null) => void;
/** 생성 화면에 들어가기 직전, 진행 표시를 처음으로 되돌린다. 화면 이동은 주소창이 한다. */
startGenerating: () => void;
setGenerateStage: (stage: number) => void;
selectSection: (id: string | null) => void;
toggleSection: (id: string) => void;
@ -300,7 +297,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
isGathering: false,
gatherStage: 1,
gatherCompleted: false,
generateStage: 1,
selectedSectionId: null,
viewport: 'pc',
@ -514,8 +510,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
persistTheme();
},
// 화면 이동은 부르는 쪽이 주소창으로 한다(`?step=generating`) — 여기서는 진행 표시만 되감는다.
startGenerating: () => set({generateStage: 1}),
setGenerateStage: (generateStage) => set({generateStage}),
selectSection: (selectedSectionId) => set({selectedSectionId}),
@ -785,7 +779,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
isGathering: false,
gatherStage: 1,
gatherCompleted: false,
generateStage: 1,
selectedSectionId: null,
isPreviewMode: false,
isPublishModalOpen: false,

View File

@ -0,0 +1,125 @@
// 실행: 개발 서버를 켠 뒤 node solution/frontend/tests/generation.mjs [URL]
// 모든 API는 가짜 응답으로 막는다. 외부 생성 API·실제 사업장에는 쓰지 않는다.
import assert from 'node:assert/strict';
import {chromium} from 'playwright';
const origin = process.argv[2] ?? 'http://127.0.0.1:3015';
const placeId = '10000000-0000-4000-8000-000000000001';
const jobId = '20000000-0000-4000-8000-000000000001';
const result = {success: true, code: 0};
const browser = await chromium.launch({channel: 'chrome'});
let passed = 0;
async function scenario(name, run) {
const page = await browser.newPage();
page.on('pageerror', (error) => console.error(error.message));
const state = {
posts: [], gets: 0, unavailable: false,
job: {job_id: jobId, place_id: placeId, job_type: 3, status: 2, attempts: 1,
progress: {attempt: 1, steps: [
{id: 'prepare', status: 'done'}, {id: 'generate', status: 'running'},
{id: 'save', status: 'pending'}, {id: 'faq_fill', status: 'pending'},
]}},
};
await page.addInitScript(() => localStorage.setItem('o2o-web4ai.accessToken', 'test-token'));
await page.route('**/*', async (route) => {
const url = new URL(route.request().url());
if (!url.pathname.startsWith('/v1/')) {
return url.origin === origin ? route.continue() : route.abort();
}
let body = {result};
if (url.pathname === `/v1/job/${jobId}`) {
state.gets += 1;
if (state.unavailable) return route.fulfill({status: 503, json: {}});
body.job = state.job;
} else if (url.pathname.endsWith('/copy')) {
state.posts.push(route.request().postDataJSON());
body = {...body, job_id: jobId, created: true, status: state.job.status};
} else if (url.pathname === `/v1/place/${placeId}`) {
body.place = {place_id: placeId, name: '진행 복구 테스트', category: 1, status: 3,
verified_at: '2026-09-15T00:00:00Z', road_address: '서울 강남구'};
} else if (url.pathname.endsWith('/schema')) body.fields = [];
else if (url.pathname.endsWith('/fact/list')) body.facts = [];
else if (url.pathname.endsWith('/media')) body.media = [];
else if (url.pathname.endsWith('/site')) body.site = {site_id: 'test-site', status: 1, template_id: 'stay.oasi'};
else if (url.pathname.endsWith('/auth/me')) body = {...body, user_id: 'test-owner', id: 'test', role: 1};
await route.fulfill({json: body});
});
const open = (suffix = `&jobId=${jobId}`) => page.goto(`${origin}/builder?step=generating&flow=onboarding&placeId=${placeId}${suffix}`);
try {
await run(page, state, open);
console.log(`PASS ${name}`);
passed += 1;
} catch (error) {
console.error(await page.locator('body').innerText());
console.error(JSON.stringify({gets: state.gets, posts: state.posts}));
throw error;
} finally {
await page.close();
}
}
try {
await scenario('새로고침은 같은 잡 조회만, 서버가 멈추면 표시도 그대로', async (page, state, open) => {
await open();
await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor();
await page.reload();
await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor();
await page.waitForFunction(() => document.querySelectorAll('ol li').length === 4);
await page.waitForTimeout(2300);
assert.equal(state.posts.length, 0);
assert.ok(state.gets >= 3);
assert.match(await page.locator('ol li').nth(1).innerText(), /진행 중/);
assert.match(await page.locator('ol li').nth(2).innerText(), /대기/);
assert.equal(await page.getByText(/\d+%/).count(), 0);
});
await scenario('구 URL은 resume으로 복구하고 잡 ID를 주소에 보존', async (page, state, open) => {
await open('');
await page.waitForURL(`**jobId=${jobId}`);
assert.ok(state.posts.length > 0);
assert.ok(state.posts.every((body) => body.resume === true));
const count = state.posts.length;
await page.reload();
await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor();
assert.equal(state.posts.length, count);
});
await scenario('완료된 잡 재접속은 재생성 없이 편집기로', async (page, state, open) => {
state.job.status = 3;
await open();
await page.waitForURL('**step=editor**');
assert.equal(new URL(page.url()).searchParams.has('jobId'), false);
assert.equal(state.posts.length, 0);
});
await scenario('실패는 완료로 표시하거나 자동으로 편집기로 보내지 않음', async (page, state, open) => {
state.job.status = 4;
await open();
await page.getByRole('heading', {name: '콘텐츠 생성을 완료하지 못했습니다'}).waitFor();
assert.equal(new URL(page.url()).searchParams.get('step'), 'generating');
assert.match(await page.locator('ol li').nth(1).innerText(), /중단/);
assert.equal(state.posts.length, 0);
});
await scenario('통신 오류 뒤에는 같은 잡 조회만 재시도', async (page, state, open) => {
state.unavailable = true;
await open();
await page.getByRole('button', {name: '상태 다시 확인'}).waitFor();
state.unavailable = false;
await page.getByRole('button', {name: '상태 다시 확인'}).click();
await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor();
assert.equal(state.posts.length, 0);
});
await scenario('다른 사업장의 잡 ID는 완료여도 이동 금지', async (page, state, open) => {
state.job.place_id = 'other-place';
state.job.status = 3;
await open();
await page.getByText('이 화면의 생성 작업이 아닙니다. 이전 단계에서 다시 시작해 주세요.').waitFor();
assert.equal(new URL(page.url()).searchParams.get('step'), 'generating');
});
console.log(`${passed} scenarios passed`);
} finally {
await browser.close();
}

View File

@ -2011,6 +2011,37 @@
}
console.log('[w4d] 사진 갤러리 — 넓은 화면 ' + GAL_PER_PAGE + '장씩');
} catch (err) { console.warn('[w4d] 갤러리 쪽 넘김 실패', err); }
/* ' ' **** .
주입인가 (2026-09-15 대표: "푸터 하단에 Web4ai ai o2o 얘기도 적어주셈 / 반영반영")
`patch_stay.py` 정적 HTML 같은 줄을 박아 두지만, 장이 물고 있는 번들은
2026-09-09 판이라 줄을 모른다 하이드레이션이 끝나는 순간 리액트가 **지운다**
(실측 2026-09-15: HTML 에는 있는데 화면에는 0). 번들을 갈아 끼우면 풀리지만,
그건 목업 전체를 최신 렌더러로 다시 그리는 일이라 장을 깨뜨릴 있다
(AGENTS.md 함정 1). 그래서 렌더러에 없는 것들과 **같은 방식**으로 얹는다.
마크업은 `SiteFooter.tsx` 내보내는 것과 같다 번들을 갈아 끼우는
리액트가 그리는 줄과 겹치면 되므로, 이미 있으면 얹지 않는다. */
function startMadeBy() {
var HREF = 'https://www.o2osolution.ai/';
function put() {
var footer = document.querySelector('footer');
if (!footer) return false;
if (footer.querySelector('a[href="' + HREF + '"]')) return true; // 번들이 이미 그렸다
var box = footer.querySelector('.shell') || footer.firstElementChild || footer;
var p = document.createElement('p');
p.className = 'pt-3 text-[length:var(--fs-xs)] opacity-55';
p.innerHTML = '<a href="' + HREF + '" target="_blank" rel="noopener noreferrer" ' +
'class="underline-offset-2 hover:underline">AI O2O</a>의 Web4Ai로 만든 사이트입니다.';
box.appendChild(p);
return true;
}
put();
// 리액트가 #root 를 다시 그리면 사라진다 — 감시해서 다시 얹는다(플레이어와 같은 수법).
if (window.MutationObserver) {
new MutationObserver(function () { put(); })
.observe(document.getElementById('root') || document.body, {childList: true, subtree: true});
}
}
window.setTimeout(function () {
try {
watchLinks();
@ -2024,5 +2055,9 @@
startWeatherNote();
console.log('[w4d] 날씨 문구 — 하늘 5 · 기온대 5, 케이스마다 다섯 벌 중 하나');
} catch (err) { console.warn('[w4d] 날씨 문구 실패', err); }
try {
startMadeBy();
console.log('[w4d] 푸터 — AI O2O · Web4Ai');
} catch (err) { console.warn('[w4d] 푸터 표기 실패', err); }
}, 1200);
})();

View File

@ -28,6 +28,7 @@ import {
homeMeta,
readBakedLastmod,
readBakedNoindex,
readBakedOrigin,
readBakedTitle,
renderHead,
renderLlmsTxt,
@ -1048,6 +1049,26 @@ function writePreviewShell(outRoot: string, assets: {script: string; css: string
console.log(' ✓ 빌더 미리보기 셸 (/preview)');
}
/**
* . .
*
* payload (`--refresh-assets` )
* , payload . canonical
* (readBakedOrigin ).
*/
function findBakedOrigin(outRoot: string): string {
const sitesDir = join(outRoot, SITE_DIR);
if (!existsSync(sitesDir)) return '';
for (const entry of readdirSync(sitesDir, {withFileTypes: true})) {
if (!entry.isDirectory()) continue;
const file = join(sitesDir, entry.name, 'index.html');
if (!existsSync(file)) continue;
const origin = readBakedOrigin(readFileSync(file, 'utf-8'));
if (origin) return origin;
}
return '';
}
function writeRootMachineFiles(outRoot: string, origin: string) {
const sitesDir = join(outRoot, SITE_DIR);
if (!existsSync(sitesDir)) return;
@ -1143,6 +1164,15 @@ async function main() {
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).
*/
const bakedOrigin = findBakedOrigin(args.out);
if (bakedOrigin) writeRootMachineFiles(args.out, bakedOrigin);
console.log('[prerender] 완료');
return;
}

View File

@ -92,6 +92,7 @@ async function renderPreview(placeId: string) {
<PreviewReady />
</StrictMode>,
);
signalPreviewPainted(true);
}
const previewPlaceId = new URLSearchParams(window.location.search).get('placeId');

View File

@ -56,6 +56,23 @@ export function readBakedLastmod(html: string): string | undefined {
return value && !Number.isNaN(Date.parse(value)) ? value : undefined;
}
/**
* `index.html` canonical **** . .
*
* payload .
* (`prerender --refresh-assets`) payload .
* (sitemap.xml · llms.txt · `/s` )
* ** ** 404 ,
* ( 2026-09-15).
* . ** **
*
* ( canonical ).
*/
export function readBakedOrigin(html: string): string {
const match = /<link rel="canonical" href="(https?:\/\/[^/"]+)/.exec(html);
return match ? match[1] : '';
}
/**
* `/s/` .
*

View File

@ -1,4 +1,4 @@
import {deriveSurfaces, type SitePayload} from '@o2o/shared';
import {deriveSurfaces, SiteStatus, type SitePayload} from '@o2o/shared';
import {collectJsonLd} from './jsonld';
import type {PageMeta} from './meta';
@ -153,11 +153,36 @@ export function renderHead({payload, meta, scriptSrc, cssHrefs = []}: HeadOption
*/
...(meta.keywords ? [tag('meta', {name: 'keywords', content: meta.keywords})] : []),
tag('link', {rel: 'canonical', href: meta.canonical}),
// 색인을 막을 이유가 없다 — 이 사이트는 색인되라고 존재한다.
/*
* ** ** .
*
* `index, follow` "색인을 막을 이유가 없다" ,
* .
* draft .
*
* (2026-09-15): 33 **15 draft `index, follow`**
* . ,
* .
*
* ·`/s` ·llms.txt
* HTML robots (seo/directory.ts readBakedNoindex).
* .
*/
tag('meta', {
name: 'robots',
content: 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1',
content:
site.status === SiteStatus.PUBLISHED
? 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1'
: // follow 는 남긴다 — 색인은 막되 링크는 타게 둔다(자산·하위 경로 발견용).
'noindex, follow',
}),
/*
* . ( 2026-09-15).
* `basePath`
* (robots·sitemap ).
*/
tag('link', {rel: 'icon', type: 'image/svg+xml', href: '/brand/favicon-w4a.svg'}),
tag('link', {rel: 'apple-touch-icon', href: '/apple-touch-icon.png'}),
tag('meta', {'http-equiv': 'content-language', content: 'ko-KR'}),
/*
* · 릿 (2026-09-09, : "모바일에서 노치가 투명으로 되던데")

View File

@ -366,6 +366,11 @@ function priceRange(payload: SitePayload): string | undefined {
return min === max ? `${fmt(min)}` : `${fmt(min)}원 ~ ${fmt(max)}`;
}
/** FAQ 가 화면에 그려지는가 — 섹션이 켜져 있을 때만이다(HomePage 가 enabled 로 고른다). */
function isFaqOnScreen(payload: SitePayload): boolean {
return payload.theme.sections.some((section) => section.id === 'faq' && section.enabled);
}
/**
* FAQPage AEO .
* "체크인 몇 시예요?" .
@ -373,6 +378,10 @@ function priceRange(payload: SitePayload): string | undefined {
* (TEMPLATE) (shared selectAnsweredFaqs).
*/
export function faqJsonLd(payload: SitePayload): Json | null {
// ★ 화면에 FAQ 가 없으면 내보내지 않는다 (2026-09-14). 섹션이 꺼져 있으면 HomePage 가 FaqSection 을
// 아예 그리지 않는데, 여기서만 실으면 **구조화 데이터가 화면 값과 다르다**로 발행이 통째로 막힌다.
// 실측: FAQ 를 20개까지 채우자(COPY) 섹션을 꺼 둔 사이트에서 12건 불일치로 발행 실패.
if (!isFaqOnScreen(payload)) return null;
const faqs = selectAnsweredFaqs(payload.faqs);
if (faqs.length === 0) return null;

View File

@ -89,7 +89,9 @@ export function renderLlmsTxt(payload: SitePayload): string {
// ── FAQ — LLM 이 가장 잘 인용하는 부분 ─────────────────
// ★ 문의 안내(TEMPLATE)는 뺀다. 규칙 3("모르는 것은 정보 없음") 과 달리 질문 자체가 우리가 붙인 것이다.
const faqs = selectAnsweredFaqs(payload.faqs);
// 화면에 없는 것을 llms.txt 에만 적지 않는다 — 같은 이유로 JSON-LD 도 섹션이 켜졌을 때만 낸다.
const faqOnScreen = payload.theme.sections.some((section) => section.id === 'faq' && section.enabled);
const faqs = faqOnScreen ? selectAnsweredFaqs(payload.faqs) : [];
if (faqs.length > 0) {
lines.push('## 자주 묻는 질문');
lines.push('');