From 079c93a62a4fc5be6026e2b4ed7c1dd90113e438 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 15 Sep 2026 17:29:27 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution,postgres-init,docs:=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=A7=84=ED=96=89=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=C2=B7=20=EC=83=88=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20?= =?UTF-8?q?=EC=A1=B4=EC=A4=91=20=C2=B7=20=EB=B0=9C=ED=96=89=EB=B3=B8=20?= =?UTF-8?q?=EC=83=89=EC=9D=B8=C2=B7=ED=8C=8C=EB=B9=84=EC=BD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 작업트리에 커밋되지 않은 채 쌓여 있던 것과, 오늘 찾은 문제 셋을 함께 담는다. ## 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) --- docs/DATA_MODEL.md | 20 + docs/DECISIONS.md | 19 + docs/DEVLOG.md | 11 + docs/GENERATION_FLOW.md | 46 +++ postgres-init/init-data/init.sql | 1 + .../migrations/0013_job_progress.sql | 2 + .../backend/common/database/model/models.py | 1 + solution/backend/common/job_errors.py | 18 + solution/backend/crud/job_crud.py | 55 ++- solution/backend/router/v1/job/job.py | 5 +- solution/backend/router/v1/job/protocol.py | 14 + solution/backend/router/v1/place/protocol.py | 13 +- solution/backend/router/v1/site/protocol.py | 9 +- solution/backend/services/build_service.py | 3 +- solution/backend/services/collect_service.py | 3 +- .../services/collector/static_html_adapter.py | 32 ++ solution/backend/services/copy_service.py | 342 ++---------------- solution/backend/services/copy_steps.py | 327 +++++++++++++++++ solution/backend/services/job_progress.py | 34 ++ solution/backend/services/job_service.py | 28 +- solution/backend/services/place_service.py | 70 +++- solution/backend/services/site_service.py | 43 ++- solution/backend/services/site_slug.py | 8 + solution/backend/services/song_service.py | 3 +- solution/backend/services/vision_service.py | 3 +- solution/backend/tests/test_build_publish.py | 8 +- .../backend/tests/test_collect_pipeline.py | 34 +- solution/backend/tests/test_collector.py | 4 +- solution/backend/tests/test_copy_api.py | 57 +++ solution/backend/tests/test_fact_api.py | 60 +-- .../tests/test_itinerary_llm_service.py | 14 +- solution/backend/tests/test_job_queue.py | 26 ++ solution/backend/tests/test_place_search.py | 30 +- solution/backend/tests/test_site_thumbnail.py | 7 +- solution/backend/worker/runner.py | 7 + .../src/api/generated/model/jobData.ts | 4 + .../src/api/generated/model/jobDataPlaceId.ts | 8 + .../api/generated/model/jobDataProgress.ts | 9 + .../src/api/generated/model/jobProgress.ts | 12 + .../src/api/generated/model/jobStep.ts | 14 + .../src/api/generated/model/jobStepReason.ts | 8 + .../src/api/generated/model/jobStepStatus.ts | 17 + .../src/api/generated/model/reqStartCopy.ts | 2 +- .../generated/model/reqVerifyPlaceByUrl.ts | 2 + solution/frontend/src/api/pollJob.ts | 12 +- .../src/features/onboarding/Step4Template.tsx | 38 +- .../features/onboarding/Step5Generating.tsx | 215 ++--------- .../features/onboarding/ensureServerPlace.ts | 9 +- .../features/onboarding/generationLabels.ts | 13 + .../features/onboarding/useGenerationJob.ts | 75 ++++ .../src/features/publish/usePublishSite.ts | 7 +- solution/frontend/src/stores/builder.ts | 7 - solution/frontend/tests/generation.mjs | 125 +++++++ solution/site/scripts/mockup/inject.js | 35 ++ solution/site/scripts/prerender.ts | 30 ++ solution/site/src/entry-client.tsx | 1 + solution/site/src/seo/directory.ts | 17 + solution/site/src/seo/head.ts | 31 +- solution/site/src/seo/jsonld.ts | 9 + solution/site/src/seo/llms.ts | 4 +- 60 files changed, 1465 insertions(+), 596 deletions(-) create mode 100644 docs/GENERATION_FLOW.md create mode 100644 postgres-init/migrations/0013_job_progress.sql create mode 100644 solution/backend/common/job_errors.py create mode 100644 solution/backend/services/copy_steps.py create mode 100644 solution/backend/services/job_progress.py create mode 100644 solution/frontend/src/api/generated/model/jobDataPlaceId.ts create mode 100644 solution/frontend/src/api/generated/model/jobDataProgress.ts create mode 100644 solution/frontend/src/api/generated/model/jobProgress.ts create mode 100644 solution/frontend/src/api/generated/model/jobStep.ts create mode 100644 solution/frontend/src/api/generated/model/jobStepReason.ts create mode 100644 solution/frontend/src/api/generated/model/jobStepStatus.ts create mode 100644 solution/frontend/src/features/onboarding/generationLabels.ts create mode 100644 solution/frontend/src/features/onboarding/useGenerationJob.ts create mode 100644 solution/frontend/tests/generation.mjs diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index caefd92..6ff366a 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -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·사진 적재 | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 32d0d31..8fcd4eb 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -34,6 +34,25 @@ | 결론이 "불가"일 때 | 폴백 3단계로 간다 — ① 공식 API → ② 사장님이 직접 붙여넣기 → ③ 최소 정보로 생성 + 보완 요청. **생성 자체는 실패시키지 않는다** | | 확정 사항 | 캡차 우회 · 봇 탐지 우회 · IP 회전은 **결론과 무관하게 금지**. 구현하지 않는다 | +**변경 (2026-09-14 / 확인 2026-09-15) — NOL 전용 어댑터를 등록한다.** +위 표의 "야놀자·여기어때 불가" 와 "Playwright 어댑터는 등록하지 않는다" 를 **한 패턴에 한해** +연다. 무엇을 열고 무엇을 안 여는지는 정확히 이렇다. + +| | 지금 | +|---|---| +| `nol.yanolja.com/stay/domestic/` | **전용 어댑터 `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. 크롤링한 **이미지**의 재게시 권리 | 항목 | 내용 | diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index efc7bb6..fc195e7 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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 — 엽서 쓰기를 발행본에도 넣는다 (사진이 남의 도메인이면 저장·공유는 막힌다) **무슨 일** — 시연본에만 주입 스크립트로 있던 '엽서 쓰기'(사진 고르기 + 한 마디 + 캔버스 엽서)를 diff --git a/docs/GENERATION_FLOW.md b/docs/GENERATION_FLOW.md new file mode 100644 index 0000000..78fdb27 --- /dev/null +++ b/docs/GENERATION_FLOW.md @@ -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를 가짜 응답으로 대체한다. diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index dd3dfbf..639f94f 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -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 diff --git a/postgres-init/migrations/0013_job_progress.sql b/postgres-init/migrations/0013_job_progress.sql new file mode 100644 index 0000000..f3c3d06 --- /dev/null +++ b/postgres-init/migrations/0013_job_progress.sql @@ -0,0 +1,2 @@ +-- 새로고침 후에도 워커가 마지막으로 기록한 단계를 조회한다. +ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS progress JSONB NULL; diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index a05d446..c825d0b 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -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) diff --git a/solution/backend/common/job_errors.py b/solution/backend/common/job_errors.py new file mode 100644 index 0000000..26a23dc --- /dev/null +++ b/solution/backend/common/job_errors.py @@ -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 로 보낸다.""" diff --git a/solution/backend/crud/job_crud.py b/solution/backend/crud/job_crud.py index 00fbafe..1318a96 100644 --- a/solution/backend/crud/job_crud.py +++ b/solution/backend/crud/job_crud.py @@ -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 diff --git a/solution/backend/router/v1/job/job.py b/solution/backend/router/v1/job/job.py index 2c2ff2a..8654023 100644 --- a/solution/backend/router/v1/job/job.py +++ b/solution/backend/router/v1/job/job.py @@ -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( diff --git a/solution/backend/router/v1/job/protocol.py b/solution/backend/router/v1/job/protocol.py index 1737d1d..33517a4 100644 --- a/solution/backend/router/v1/job/protocol.py +++ b/solution/backend/router/v1/job/protocol.py @@ -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 diff --git a/solution/backend/router/v1/place/protocol.py b/solution/backend/router/v1/place/protocol.py index 94e4898..4ed64be 100644 --- a/solution/backend/router/v1/place/protocol.py +++ b/solution/backend/router/v1/place/protocol.py @@ -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): diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py index 7082e31..6fe22c1 100644 --- a/solution/backend/router/v1/site/protocol.py +++ b/solution/backend/router/v1/site/protocol.py @@ -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): diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index b09d697..34ff08d 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -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 로 남는다.""" diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index 53c18b5..8474819 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -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 로 남아 운영자가 본다.""" diff --git a/solution/backend/services/collector/static_html_adapter.py b/solution/backend/services/collector/static_html_adapter.py index af7db41..19d3b13 100644 --- a/solution/backend/services/collector/static_html_adapter.py +++ b/solution/backend/services/collector/static_html_adapter.py @@ -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", diff --git a/solution/backend/services/copy_service.py b/solution/backend/services/copy_service.py index 1ce28ad..848a5cd 100644 --- a/solution/backend/services/copy_service.py +++ b/solution/backend/services/copy_service.py @@ -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 diff --git a/solution/backend/services/copy_steps.py b/solution/backend/services/copy_steps.py new file mode 100644 index 0000000..993ec41 --- /dev/null +++ b/solution/backend/services/copy_steps.py @@ -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 diff --git a/solution/backend/services/job_progress.py b/solution/backend/services/job_progress.py new file mode 100644 index 0000000..f392164 --- /dev/null +++ b/solution/backend/services/job_progress.py @@ -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) diff --git a/solution/backend/services/job_service.py b/solution/backend/services/job_service.py index 2ec6c3e..6dd1600 100644 --- a/solution/backend/services/job_service.py +++ b/solution/backend/services/job_service.py @@ -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: diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py index d5e3acf..c796431 100644 --- a/solution/backend/services/place_service.py +++ b/solution/backend/services/place_service.py @@ -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(), diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index c6ffde1..213e328 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -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, { diff --git a/solution/backend/services/site_slug.py b/solution/backend/services/site_slug.py index 08c0424..99623c6 100644 --- a/solution/backend/services/site_slug.py +++ b/solution/backend/services/site_slug.py @@ -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", }) diff --git a/solution/backend/services/song_service.py b/solution/backend/services/song_service.py index 4faae8c..52291ad 100644 --- a/solution/backend/services/song_service.py +++ b/solution/backend/services/song_service.py @@ -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 로 남아 운영자가 본다.""" diff --git a/solution/backend/services/vision_service.py b/solution/backend/services/vision_service.py index 766c219..1862ed3 100644 --- a/solution/backend/services/vision_service.py +++ b/solution/backend/services/vision_service.py @@ -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 로 남는다.""" diff --git a/solution/backend/tests/test_build_publish.py b/solution/backend/tests/test_build_publish.py index f233d98..a3b9493 100644 --- a/solution/backend/tests/test_build_publish.py +++ b/solution/backend/tests/test_build_publish.py @@ -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"}) diff --git a/solution/backend/tests/test_collect_pipeline.py b/solution/backend/tests/test_collect_pipeline.py index 204d021..f3e3410 100644 --- a/solution/backend/tests/test_collect_pipeline.py +++ b/solution/backend/tests/test_collect_pipeline.py @@ -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 diff --git a/solution/backend/tests/test_collector.py b/solution/backend/tests/test_collector.py index bb5fb04..d7f775d 100644 --- a/solution/backend/tests/test_collector.py +++ b/solution/backend/tests/test_collector.py @@ -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/` 한 패턴뿐이다. +# 아래 야놀자 주소가 여전히 막혀야 전용 경로를 연 것과 범용 수집을 푼 것이 갈린다. _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()} — 승인 없이 수집 대상을 늘리지 않는다" ) diff --git a/solution/backend/tests/test_copy_api.py b/solution/backend/tests/test_copy_api.py index 78d0a3d..00f3828 100644 --- a/solution/backend/tests/test_copy_api.py +++ b/solution/backend/tests/test_copy_api.py @@ -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) diff --git a/solution/backend/tests/test_fact_api.py b/solution/backend/tests/test_fact_api.py index efeb68b..1365bf6 100644 --- a/solution/backend/tests/test_fact_api.py +++ b/solution/backend/tests/test_fact_api.py @@ -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): diff --git a/solution/backend/tests/test_itinerary_llm_service.py b/solution/backend/tests/test_itinerary_llm_service.py index 6463012..0ba01f9 100644 --- a/solution/backend/tests/test_itinerary_llm_service.py +++ b/solution/backend/tests/test_itinerary_llm_service.py @@ -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} diff --git a/solution/backend/tests/test_job_queue.py b/solution/backend/tests/test_job_queue.py index ac3adde..7678d6c 100644 --- a/solution/backend/tests/test_job_queue.py +++ b/solution/backend/tests/test_job_queue.py @@ -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명만 가져간다 — 도커에서 워커를 몇 개로 스케일하든 이중 실행이 없다.""" diff --git a/solution/backend/tests/test_place_search.py b/solution/backend/tests/test_place_search.py index fb98581..81ea445 100644 --- a/solution/backend/tests/test_place_search.py +++ b/solution/backend/tests/test_place_search.py @@ -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 diff --git a/solution/backend/tests/test_site_thumbnail.py b/solution/backend/tests/test_site_thumbnail.py index 3d81522..791a6fe 100644 --- a/solution/backend/tests/test_site_thumbnail.py +++ b/solution/backend/tests/test_site_thumbnail.py @@ -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}/") diff --git a/solution/backend/worker/runner.py b/solution/backend/worker/runner.py index 0adf2e6..aa14ebc 100644 --- a/solution/backend/worker/runner.py +++ b/solution/backend/worker/runner.py @@ -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) diff --git a/solution/frontend/src/api/generated/model/jobData.ts b/solution/frontend/src/api/generated/model/jobData.ts index 8731db0..058e42d 100644 --- a/solution/frontend/src/api/generated/model/jobData.ts +++ b/solution/frontend/src/api/generated/model/jobData.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/jobDataPlaceId.ts b/solution/frontend/src/api/generated/model/jobDataPlaceId.ts new file mode 100644 index 0000000..1a89109 --- /dev/null +++ b/solution/frontend/src/api/generated/model/jobDataPlaceId.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/jobDataProgress.ts b/solution/frontend/src/api/generated/model/jobDataProgress.ts new file mode 100644 index 0000000..3920331 --- /dev/null +++ b/solution/frontend/src/api/generated/model/jobDataProgress.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/jobProgress.ts b/solution/frontend/src/api/generated/model/jobProgress.ts new file mode 100644 index 0000000..3dee39b --- /dev/null +++ b/solution/frontend/src/api/generated/model/jobProgress.ts @@ -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; +} diff --git a/solution/frontend/src/api/generated/model/jobStep.ts b/solution/frontend/src/api/generated/model/jobStep.ts new file mode 100644 index 0000000..79d7c88 --- /dev/null +++ b/solution/frontend/src/api/generated/model/jobStep.ts @@ -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; +} diff --git a/solution/frontend/src/api/generated/model/jobStepReason.ts b/solution/frontend/src/api/generated/model/jobStepReason.ts new file mode 100644 index 0000000..dfbe959 --- /dev/null +++ b/solution/frontend/src/api/generated/model/jobStepReason.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/jobStepStatus.ts b/solution/frontend/src/api/generated/model/jobStepStatus.ts new file mode 100644 index 0000000..3260e13 --- /dev/null +++ b/solution/frontend/src/api/generated/model/jobStepStatus.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/reqStartCopy.ts b/solution/frontend/src/api/generated/model/reqStartCopy.ts index 151e6d8..24219d8 100644 --- a/solution/frontend/src/api/generated/model/reqStartCopy.ts +++ b/solution/frontend/src/api/generated/model/reqStartCopy.ts @@ -11,5 +11,5 @@ ★ 확인된 fact 만 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 안 한다). */ export interface ReqStartCopy { - [key: string]: unknown; + resume?: boolean; } diff --git a/solution/frontend/src/api/generated/model/reqVerifyPlaceByUrl.ts b/solution/frontend/src/api/generated/model/reqVerifyPlaceByUrl.ts index 2c9d898..4ec5626 100644 --- a/solution/frontend/src/api/generated/model/reqVerifyPlaceByUrl.ts +++ b/solution/frontend/src/api/generated/model/reqVerifyPlaceByUrl.ts @@ -19,4 +19,6 @@ */ export interface ReqVerifyPlaceByUrl { url?: string; + /** 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지. 기본 true. */ + reuse_existing?: boolean; } diff --git a/solution/frontend/src/api/pollJob.ts b/solution/frontend/src/api/pollJob.ts index e9283da..3ebcfd3 100644 --- a/solution/frontend/src/api/pollJob.ts +++ b/solution/frontend/src/api/pollJob.ts @@ -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; diff --git a/solution/frontend/src/features/onboarding/Step4Template.tsx b/solution/frontend/src/features/onboarding/Step4Template.tsx index fa7dfa9..40030a3 100644 --- a/solution/frontend/src/features/onboarding/Step4Template.tsx +++ b/solution/frontend/src/features/onboarding/Step4Template.tsx @@ -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() { goToStep('collect')} - onNext={() => { - // ★ 아무것도 안 누르고 넘어가는 경우(첫 템플릿이 이미 선택돼 있다)도 서버에 남긴다 — - // 화면이 보여준 그 템플릿이 발행본이 되어야 한다. 같은 값이면 서버가 재빌드 표시도 찍지 않는다. - queueSiteTemplateSave(placeId, templateId); - startGenerating(); - goToStep('generating'); - }} - nextLabel="이 템플릿으로 사이트 생성하기" + onNext={() => void startGeneration()} + nextDisabled={busy} + nextLabel={busy ? '생성 작업을 시작하고 있습니다' : '이 템플릿으로 사이트 생성하기'} /> ); diff --git a/solution/frontend/src/features/onboarding/Step5Generating.tsx b/solution/frontend/src/features/onboarding/Step5Generating.tsx index 930c7fa..3a397c9 100644 --- a/solution/frontend/src/features/onboarding/Step5Generating.tsx +++ b/solution/frontend/src/features/onboarding/Step5Generating.tsx @@ -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 ( -
-
-
- -
- -

- 웹사이트를 생성하고 있습니다 -

-

- {storeName}의 확인된 정보만 - 담아 정적 페이지로 굽고 있어요. +

+
+ {!error && !failed && } +

{title}

+

+ {error || <>{storeName}의 확인된 정보를 바탕으로 작성합니다.}

- -
-
- {BUILD_STEPS[Math.min(stage - 1, BUILD_STEPS.length - 1)]} - {progress}% -
- - - -
    - {BUILD_STEPS.map((text, index) => { - const num = index + 1; - const isDone = stage > num; - const isCurrent = stage === num; - + {steps.length > 0 && ( +
      + {steps.map((step) => { + const state = step.status === 'running' && failed ? 'failed' : step.status; + const label = state === 'done' ? '완료' : state === 'skipped' ? '건너뜀' + : state === 'failed' ? '중단' : state === 'running' ? (waiting ? '재시도 대기' : '진행 중') : '대기'; return ( -
    1. - - {isDone ? : num} - - {text} +
    2. + {state === 'done' ? + : state === 'running' && !waiting && !error ? + : } +
      + {GENERATION_LABELS[step.id] ?? '콘텐츠 처리'} + {step.reason &&

      {SKIP_REASONS[step.reason] ?? '이 단계는 생략했습니다'}

      } +
      + {label}
    3. ); })}
    -
- -

- 생성이 끝나면 실시간 에디터로 자동 전환됩니다. -

+ )} + {!error && !failed &&

새로고침해도 같은 작업의 진행 상태를 이어서 확인합니다.

} + {(error || failed) &&
+ {error && } + + +
}
); diff --git a/solution/frontend/src/features/onboarding/ensureServerPlace.ts b/solution/frontend/src/features/onboarding/ensureServerPlace.ts index 0c88d1c..682caa5 100644 --- a/solution/frontend/src/features/onboarding/ensureServerPlace.ts +++ b/solution/frontend/src/features/onboarding/ensureServerPlace.ts @@ -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}, '그 주소에서 가게 정보를 읽지 못했습니다.'); diff --git a/solution/frontend/src/features/onboarding/generationLabels.ts b/solution/frontend/src/features/onboarding/generationLabels.ts new file mode 100644 index 0000000..a79d7bf --- /dev/null +++ b/solution/frontend/src/features/onboarding/generationLabels.ts @@ -0,0 +1,13 @@ +/** 화면 문구만 둔다. 단계 순서·완료 여부는 jobs.progress가 보낸다. */ +export const GENERATION_LABELS: Record = { + prepare: '확인된 정보와 수집 원문 준비', + generate: '소개문·FAQ 생성 및 근거 검증', + save: '소개문·FAQ 저장', + faq_fill: '부족한 FAQ 문의 안내 보완', +}; + +export const SKIP_REASONS: Record = { + no_facts: '확인된 정보 없음', + not_configured: '생성 서비스 미설정', + no_catalog: '공통 질문 대상 업종 아님', +}; diff --git a/solution/frontend/src/features/onboarding/useGenerationJob.ts b/solution/frontend/src/features/onboarding/useGenerationJob.ts new file mode 100644 index 0000000..99d88cb --- /dev/null +++ b/solution/frontend/src/features/onboarding/useGenerationJob.ts @@ -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(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}}), + }; +} diff --git a/solution/frontend/src/features/publish/usePublishSite.ts b/solution/frontend/src/features/publish/usePublishSite.ts index 2862ef8..42165d1 100644 --- a/solution/frontend/src/features/publish/usePublishSite.ts +++ b/solution/frontend/src/features/publish/usePublishSite.ts @@ -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; } diff --git a/solution/frontend/src/stores/builder.ts b/solution/frontend/src/stores/builder.ts index 2adaa0d..f7609a5 100644 --- a/solution/frontend/src/stores/builder.ts +++ b/solution/frontend/src/stores/builder.ts @@ -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((set, get) => ({ isGathering: false, gatherStage: 1, gatherCompleted: false, - generateStage: 1, selectedSectionId: null, viewport: 'pc', @@ -514,8 +510,6 @@ export const useBuilderStore = create((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((set, get) => ({ isGathering: false, gatherStage: 1, gatherCompleted: false, - generateStage: 1, selectedSectionId: null, isPreviewMode: false, isPublishModalOpen: false, diff --git a/solution/frontend/tests/generation.mjs b/solution/frontend/tests/generation.mjs new file mode 100644 index 0000000..331a1ed --- /dev/null +++ b/solution/frontend/tests/generation.mjs @@ -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(); +} diff --git a/solution/site/scripts/mockup/inject.js b/solution/site/scripts/mockup/inject.js index d58810b..f65f8ff 100644 --- a/solution/site/scripts/mockup/inject.js +++ b/solution/site/scripts/mockup/inject.js @@ -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 = 'AI O2O의 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); })(); diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 164840b..9dc1905 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -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; } diff --git a/solution/site/src/entry-client.tsx b/solution/site/src/entry-client.tsx index d5e462f..d9634f0 100644 --- a/solution/site/src/entry-client.tsx +++ b/solution/site/src/entry-client.tsx @@ -92,6 +92,7 @@ async function renderPreview(placeId: string) { , ); + signalPreviewPainted(true); } const previewPlaceId = new URLSearchParams(window.location.search).get('placeId'); diff --git a/solution/site/src/seo/directory.ts b/solution/site/src/seo/directory.ts index 67c8948..72ab4db 100644 --- a/solution/site/src/seo/directory.ts +++ b/solution/site/src/seo/directory.ts @@ -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 = / 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; diff --git a/solution/site/src/seo/llms.ts b/solution/site/src/seo/llms.ts index f4b48ec..6825db9 100644 --- a/solution/site/src/seo/llms.ts +++ b/solution/site/src/seo/llms.ts @@ -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('');