[feat] solution,postgres-init,docs: 생성 진행 상태 · 새로 만들기 존중 · 발행본 색인·파비콘
작업트리에 커밋되지 않은 채 쌓여 있던 것과, 오늘 찾은 문제 셋을 함께 담는다. ## 1. 콘텐츠 생성 진행 상태 (작업트리에 있던 것) COPY 잡의 실제 단계를 DB에 기록하고 응답으로 내보낸다. 폴링 횟수로 진행률을 흉내 내던 것을 걷어냈다. 새로고침·재접속해도 jobId 로 이어서 본다. - services/copy_steps.py · services/job_progress.py · common/job_errors.py (신규) - postgres-init/migrations/0013_job_progress.sql + init.sql - 프론트: useGenerationJob · generationLabels (신규), Step5Generating·pollJob 배선, orval 모델 갱신(jobProgress · jobStep · jobStepStatus · jobStepReason) - docs/GENERATION_FLOW.md (신규) ## 2. 발행된 사이트만 색인한다 실측(2026-09-15): 디스크의 발행본 33곳 중 **15곳이 draft 인데 `index, follow`** 였고 사이트맵에도 올라가 있었다. 사장님이 발행 버튼을 누른 적 없는 사이트가 짓다 만 상태로 구글에 실려 있었다는 뜻이다. head.ts 가 robots 를 하드코딩하고 payload 의 `site.status` 를 보지 않았다. "색인을 막을 이유가 없다"는 주석은 굽는 것이 곧 발행이던 시절의 말인데, 지금은 빌더 미리보기만 눌러도 draft 로 구워진다. - seo/head.ts: PUBLISHED 일 때만 index, 아니면 `noindex, follow` - 사이트맵·`/s` 목록·llms.txt 에서도 함께 빠진다 — 그쪽은 구운 HTML 의 robots 를 읽어 거른다(seo/directory.ts readBakedNoindex). 규칙을 두 자리에 두지 않으려고 한 곳에 뒀다 ## 3. [새로 크롤링하고 사이트 생성하기] 를 뒤집지 않는다ba90a19의 중복 합치기가 **일부러 다시 만들려는 경우까지** 기존 사업장으로 끌고 갔다 — 새로 만들기를 눌렀는데 기존 에디터가 열린다(사장님 보고 2026-09-15). - Req_VerifyPlaceByUrl.reuse_existing (기본 True — 다른 호출자의 동작은 그대로) - place_service.verify_place_by_url: 끄면 이어붙이지 않는다. 다만 **비어 있는 중복 행은 계속 치운다** — 원래 막으려던 누적이 그것이고 빈 행은 잃을 것이 없다 - ensureServerPlace: 위저드는 새로 만들기 경로에서만 오므로 False 로 보낸다 ## 4. 발행본 파비콘 발행본에 파비콘 링크가 아예 없어 브라우저 탭에 기본 아이콘이 떴다. 파일은 오리진 루트의 공용 자산이라 사이트마다 복사하지 않고 루트 절대경로로 가리킨다. 검증: site vitest 84건 통과 · tsc(site·frontend) · eslint 통과. 백엔드 pytest 는 로컬 DB 비밀번호가 맞지 않아 돌리지 못했다(a5b8701과 같은 자리). 발행본 반영에는 전체 재굽기가 필요하다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4cd756108d
commit
079c93a62a
@ -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·사진 적재 |
|
||||
|
||||
@ -34,6 +34,25 @@
|
||||
| 결론이 "불가"일 때 | 폴백 3단계로 간다 — ① 공식 API → ② 사장님이 직접 붙여넣기 → ③ 최소 정보로 생성 + 보완 요청. **생성 자체는 실패시키지 않는다** |
|
||||
| 확정 사항 | 캡차 우회 · 봇 탐지 우회 · IP 회전은 **결론과 무관하게 금지**. 구현하지 않는다 |
|
||||
|
||||
**변경 (2026-09-14 / 확인 2026-09-15) — NOL 전용 어댑터를 등록한다.**
|
||||
위 표의 "야놀자·여기어때 불가" 와 "Playwright 어댑터는 등록하지 않는다" 를 **한 패턴에 한해**
|
||||
연다. 무엇을 열고 무엇을 안 여는지는 정확히 이렇다.
|
||||
|
||||
| | 지금 |
|
||||
|---|---|
|
||||
| `nol.yanolja.com/stay/domestic/<id>` | **전용 어댑터 `yanolja`** 가 Playwright 로 렌더해 읽는다. 기본 활성 |
|
||||
| 그 밖의 `yanolja.com` · `goodchoice.kr` 전부 | **막는다.** 범용 HTML 어댑터의 `_DENY_HOSTS` 에 그대로 있다 |
|
||||
| 캡차 우회 · 봇 탐지 우회 · IP 회전 | **여전히 금지.** 차단되면 그대로 실패로 돌린다 |
|
||||
|
||||
- 레지스트리가 `yanolja` 를 `static_html` 보다 **앞에** 등록하므로 그 한 패턴만 전용 경로로 가고
|
||||
나머지는 예전처럼 `AdapterNotFound` 로 끊긴다. 순서가 곧 이 경계다.
|
||||
- ★ 실측(2026-09-15): 어댑터를 들이면서 `static_html` 의 `_DENY_HOSTS` 에서 `yanolja.com` ·
|
||||
`goodchoice.kr` 이 함께 빠져 있었다. 그러면 전용 어댑터가 아니라 **범용 HTML 수집기가**
|
||||
두 플랫폼을 받는다 — 전용 경로 하나를 여는 것과 범용 수집을 그 플랫폼에 푸는 것은 다른
|
||||
일이라, 차단 목록과 그 법무 근거 주석을 되돌렸다.
|
||||
- 민사 10억 선례(서울중앙지법 2021-08)는 그대로다. **재게시 범위는 1-2 가 따로 정한다** —
|
||||
이 항목은 "읽을 수 있나" 까지만 정하고 "다시 실어도 되나" 는 정하지 않는다.
|
||||
|
||||
### 1-2. 크롤링한 **이미지**의 재게시 권리
|
||||
|
||||
| 항목 | 내용 |
|
||||
|
||||
@ -24,6 +24,17 @@
|
||||
|
||||
**검증** — 관련 59건 통과. 추가 회귀 23건 통과·기존 발행 검수 실패 1건(변경 전 코드에서도 재현).
|
||||
|
||||
## 2026-09-15 — 콘텐츠 생성 진행 상태·새로고침 복구
|
||||
|
||||
- COPY의 실제 단계 상태를 DB에 기록하고 Orval 응답으로 표시. 폴링 횟수 기반 진행률 제거.
|
||||
- URL의 jobId로 조회 재개. 구 URL 복구는 완료·실패 이력까지 재사용해 중복 생성 방지.
|
||||
- 실행 흐름·단계 메소드·프롬프트·프론트 조회 훅·화면 문구 분리.
|
||||
- 구조·적용 순서: [GENERATION_FLOW.md](GENERATION_FLOW.md).
|
||||
|
||||
**검증** — 백엔드 관련 테스트 34건·브라우저 복구/실패 시나리오 6건 통과. 프론트 타입검사·lint·빌드 통과.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-14 — 엽서 쓰기를 발행본에도 넣는다 (사진이 남의 도메인이면 저장·공유는 막힌다)
|
||||
|
||||
**무슨 일** — 시연본에만 주입 스크립트로 있던 '엽서 쓰기'(사진 고르기 + 한 마디 + 캔버스 엽서)를
|
||||
|
||||
46
docs/GENERATION_FLOW.md
Normal file
46
docs/GENERATION_FLOW.md
Normal file
@ -0,0 +1,46 @@
|
||||
# 콘텐츠 생성 · 진행 복구
|
||||
|
||||
2026-09-15. `builder?step=generating`은 COPY(소개문·FAQ) 작업이다.
|
||||
사진 분석은 VISION, 정적 사이트·노래 생성은 발행 BUILD에 속한다.
|
||||
|
||||
```text
|
||||
템플릿 선택 → POST /v1/place/{placeId}/copy → jobId를 URL에 기록
|
||||
↓
|
||||
새로고침 ──────────────────────→ GET /v1/job/{jobId}
|
||||
↑
|
||||
COPY 워커: prepare → generate → save → faq_fill
|
||||
각 단계 진입·완료 → jobs.progress(JSONB)
|
||||
↓
|
||||
화면: 서버 단계 표시 → DONE일 때 데이터 갱신 → editor
|
||||
```
|
||||
|
||||
| 책임 | 파일 |
|
||||
|---|---|
|
||||
| 실행 순서 | `solution/backend/services/copy_service.py` |
|
||||
| 단계 구현 | `solution/backend/services/copy_steps.py` — `prepare_copy`, `generate_copy`, `save_copy`, `fill_faqs` |
|
||||
| 프롬프트·응답 스키마 | `solution/backend/services/prompts/copy.py` |
|
||||
| 모델 호출·생성물 검증 | `solution/backend/services/external/gemini_text.py` → `llm/gemini.py`, `grounding/copy.py` |
|
||||
| 단계 기록 | `solution/backend/services/job_progress.py` → `crud/job_crud.py` |
|
||||
| API 계약 | `solution/backend/router/v1/job/protocol.py` → OpenAPI → Orval |
|
||||
| 조회·복구·완료 전환 | `solution/frontend/src/features/onboarding/useGenerationJob.ts` |
|
||||
| 화면 / 문구 | 같은 폴더의 `Step5Generating.tsx` / `generationLabels.ts` |
|
||||
|
||||
- `jobs.status`는 작업 전체 상태, `progress.steps[].status`는 단계 상태다.
|
||||
단계는 `pending/running/done/skipped/failed`. 시간으로 퍼센트나 단계를 올리지 않는다.
|
||||
- `progress.attempt`는 워커 시도 번호다. 재시도는 단계를 처음부터 다시 기록한다.
|
||||
기록은 실행 중인 워커·시도 번호·유효한 lease가 일치할 때만 허용한다.
|
||||
- 새로고침은 GET만 한다. jobId가 없는 구 URL은 `POST copy {resume: true}`로
|
||||
해당 사업장의 최근 COPY를 찾는다. DONE·DEAD도 반환하므로 완료됐다고 새 작업을 만들지 않는다.
|
||||
권한 검사는 사업장 조회가 먼저 한다. URL로 조회하는 COPY도 소유자를 검사한다.
|
||||
- 템플릿의 생성 버튼을 명시적으로 누르면 기본 POST로 새 작업을 요청한다.
|
||||
같은 사업장의 활성 작업이 있으면 기존 중복 방지 규칙으로 그 작업에 연결한다.
|
||||
- 통신 오류는 상태 재조회, DEAD는 이전 단계 또는 편집기로 직접 이동할 수 있다.
|
||||
오류·대기·미설정 상태를 가짜 진행이나 완료 화면으로 바꾸지 않는다.
|
||||
- 노래 단계는 이번 COPY 흐름에 추가하지 않았다. 발행 BUILD 진행 표시 확장은 별도다.
|
||||
|
||||
적용: 마이그레이션 `0013_job_progress.sql`을 먼저 적용한 뒤 API·워커·빌더를 배포한다.
|
||||
기존 잡의 `progress`는 NULL이다. 이 경우 단계 목록을 지어내지 않고 전체 상태만 표시한다.
|
||||
|
||||
검증: `tests/test_copy_api.py`, `tests/test_job_queue.py`, `tests/test_schema_ddl.py`.
|
||||
프론트는 개발 서버를 켜고 `node solution/frontend/tests/generation.mjs <개발 URL>` 실행.
|
||||
브라우저 테스트는 모든 API를 가짜 응답으로 대체한다.
|
||||
@ -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
|
||||
|
||||
2
postgres-init/migrations/0013_job_progress.sql
Normal file
2
postgres-init/migrations/0013_job_progress.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- 새로고침 후에도 워커가 마지막으로 기록한 단계를 조회한다.
|
||||
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS progress JSONB NULL;
|
||||
@ -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)
|
||||
|
||||
18
solution/backend/common/job_errors.py
Normal file
18
solution/backend/common/job_errors.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""재시도가 의미 없는 잡 실패.
|
||||
|
||||
★ 왜 따로 두나 — 큐는 실패를 전부 "일시적" 으로 보고 백오프 재큐한다(crud/job_crud.fail).
|
||||
네트워크가 끊겼거나 외부 API 가 잠깐 죽은 것이라면 맞는 판단이다. 그런데 사장님이
|
||||
사업장을 지운 뒤에 남은 잡, 지원하지 않는 업종 같은 것은 **몇 번을 다시 해도 같은 결과**다.
|
||||
실측(2026-09-15): 진행 중이던 소개문 잡이 사업장 삭제 뒤 "사업장을 찾을 수 없다" 로
|
||||
세 번 재시도하고 DEAD 로 갔다 — 큐 지연과 DEAD 알림만 늘었다.
|
||||
|
||||
★ 각 도메인의 `*Aborted` 는 이미 머리주석에 "재시도해도 소용없는 중단" 이라고 적고 있었다.
|
||||
그 뜻을 워커가 읽을 수 있는 자리로 옮긴 것이지, 새 규칙을 만든 게 아니다.
|
||||
|
||||
★ services 와 worker 가 함께 쓰므로 common 에 둔다 — services 가 worker 를 import 하면
|
||||
의존 방향이 뒤집힌다.
|
||||
"""
|
||||
|
||||
|
||||
class PermanentJobError(RuntimeError):
|
||||
"""다시 시도해도 결과가 같은 실패. 워커가 재큐하지 않고 바로 DEAD 로 보낸다."""
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 로 남는다."""
|
||||
|
||||
|
||||
|
||||
@ -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 로 남아 운영자가 본다."""
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
327
solution/backend/services/copy_steps.py
Normal file
327
solution/backend/services/copy_steps.py
Normal file
@ -0,0 +1,327 @@
|
||||
"""소개문·FAQ 생성 — COPY 잡이 하는 일.
|
||||
|
||||
★ LLM 은 사실을 만들지 않는다. 문장만 쓴다.
|
||||
- 입력은 **확보된 fact(노출 가능한 것)만**. 미검증 값으로 문장을 쓰면 그 문장도 미검증이다.
|
||||
- 생성물은 `ground_check` 를 통과한 것만 저장한다(클라이언트가 이미 걸러 보내지만 근거를 다시 요구한다).
|
||||
- 소개문·FAQ 는 **바로 노출값**이다(VERIFIED). 승인 단계를 두지 않는다 — 2026-09-10 결정.
|
||||
게이트는 앞에 있다: 입력이 확인된 fact 뿐이고, 근거 없는 FAQ 는 저장조차 하지 않는다.
|
||||
확인된 사실로 쓴 문장을 한 번 더 승인받게 하면 같은 사실을 두 번 승인하는 셈이고,
|
||||
실제로는 그 화면이 닫힌 뒤에 문장이 도착해 발행본이 영영 빈칸이었다
|
||||
(근거·실측: services/fact_service.upsert_fact · docs/DECISIONS.md 7절).
|
||||
- 사장님이 고친 문장(CORRECTED)은 재생성이 덮지 않는다. 그 잠금은 그대로다.
|
||||
- FAQ 가 목표 수(20)에 모자라면 업종 카탈로그에서 겹치지 않는 공통 질문을 **문의 안내** 답으로 채운다
|
||||
(services/faq_fill · common/faq_catalog). 답에 값·가능 여부를 적지 않으므로 사실을 만들지 않는다.
|
||||
★ fact 가 0건이어도(또는 API 키가 없어도) 채운다 — 그때는 LLM 을 부르지 않고 채우기만 한다.
|
||||
"""
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from common.category_schema import CategorySchema, CategorySchemaError, get_schema
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.faq_catalog import FaqCatalog, find_catalog
|
||||
from common.database.model.models import place_facts, place_faqs, place_channels, places, place_units
|
||||
from common.enums import (
|
||||
DBWRType,
|
||||
ErrorType,
|
||||
FactStatus,
|
||||
PlaceCategory,
|
||||
SourceType,
|
||||
)
|
||||
from common.logger import LOG
|
||||
from common.models.gmodel import UserInfo
|
||||
from common.utils.gtime import GTime
|
||||
from config.server_configs import external_api_config
|
||||
from crud.fact_crud import FactCRUD
|
||||
from crud.faq_crud import FaqCRUD
|
||||
from crud.place_crud import PlaceCRUD
|
||||
from router.v1.fact.protocol import Req_UpsertFact
|
||||
from services import faq_fill, place_research
|
||||
from services.external import gemini_text
|
||||
from services.fact_service import FactService
|
||||
from common.job_errors import PermanentJobError
|
||||
|
||||
_fact_crud = FactCRUD()
|
||||
_faq_crud = FaqCRUD()
|
||||
_place_crud = PlaceCRUD()
|
||||
|
||||
|
||||
class CopyAborted(PermanentJobError):
|
||||
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남는다."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CopyInputs:
|
||||
place: places
|
||||
schema: CategorySchema
|
||||
grounded: list[gemini_text.FactInput]
|
||||
records: list[str]
|
||||
unit_summaries: list[dict]
|
||||
catalog: FaqCatalog | None
|
||||
known_fact_keys: set[str]
|
||||
|
||||
@property
|
||||
def ungrounded(self) -> bool:
|
||||
return not self.grounded and not self.unit_summaries
|
||||
|
||||
|
||||
async def prepare_copy(place_id: str, owner_user_id: str) -> CopyInputs:
|
||||
|
||||
err, place = await DB_SESSION_MNG.execute_lambda(
|
||||
places.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
|
||||
)
|
||||
if err != ErrorType.SUCCESS or place is None:
|
||||
raise CopyAborted(f"사업장을 찾을 수 없다: {place_id}")
|
||||
|
||||
try:
|
||||
schema = get_schema(PlaceCategory(place.category))
|
||||
except (CategorySchemaError, ValueError) as ex:
|
||||
raise CopyAborted(f"지원하지 않는 업종: {place.category}") from ex
|
||||
|
||||
# ★ 노출 가능한 fact 만 근거로 준다. 미검증 값으로 쓴 문장은 그 자체가 미검증이다.
|
||||
pid = uuid.UUID(place_id)
|
||||
f_err, fact_rows = await DB_SESSION_MNG.execute_lambda(
|
||||
place_facts.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: _fact_crud.list_facts(s, pid, None, None, True, True),
|
||||
)
|
||||
if f_err != ErrorType.SUCCESS:
|
||||
raise CopyAborted(f"fact 조회 실패: {f_err.name}")
|
||||
|
||||
grounded = [
|
||||
gemini_text.FactInput(
|
||||
key=r.key,
|
||||
label=(schema.get(r.key).label if schema.get(r.key) else r.key),
|
||||
value=r.value,
|
||||
unit=r.unit,
|
||||
)
|
||||
for r in fact_rows
|
||||
if r.unit_id is None and (r.value or "").strip()
|
||||
]
|
||||
# ★ 수집 원문도 근거로 넘긴다 — fact 가 아니라 place_channels.raw 에 박제된 글이다.
|
||||
#
|
||||
# 왜 필요한가: 소개 원문(TourAPI overview·네이버 description)에만 있는 정보가 있다.
|
||||
# '전면 통창 실내 온수풀', '판교역에서 3분' 같은 것들인데, 이게 근거에 없으면
|
||||
# ground_check 가 그 문장을 전부 반려해 소개문·FAQ 가 앙상해진다.
|
||||
#
|
||||
# 왜 fact 로 넣지 않는가: `intro` 는 allow_llm=True 라 LLM 의 출력 칸이다.
|
||||
# 원문을 그 칸에 넣었더니 457자 원문이 발행본의 '숙소 소개' 를 차지했다(2026-08-31).
|
||||
# 근거로만 쓰고 저장은 하지 않는다 — 원문은 화면에 나가지 않는다.
|
||||
# ★ 확정 링크만 읽던 것을 **조사 근거까지** 읽게 넓혔다(2026-09-10).
|
||||
# 업소 조사(`place_research`)는 남이 쓴 글이라 확정하지 않는다 — 공식 채널이 아니므로
|
||||
# 발행본의 sameAs·푸터에 나가면 안 된다. 그런데 그것 때문에 여기서도 안 읽혀서,
|
||||
# 조사해 온 재료가 소개문에 한 글자도 닿지 않았다. 확정 여부는 "화면에 채널로
|
||||
# 내보낼 것인가" 의 판단이지 "근거로 읽을 것인가" 의 판단이 아니다.
|
||||
# ★ 다만 아무 미확정 링크나 읽지는 않는다 — raw.kind 가 research 인 것만이다.
|
||||
# 미확정 채널 URL 은 동명 업소일 수 있고(그게 확정 절차의 이유다), 조사 근거는
|
||||
# 상호 대조를 통과한 것만 적재된다(`grounding/place_research.parse_items`).
|
||||
records: list[str] = []
|
||||
l_err, link_rows = await DB_SESSION_MNG.execute_lambda(
|
||||
place_channels.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: _place_crud.list_links(s, pid, False),
|
||||
)
|
||||
if l_err == ErrorType.SUCCESS:
|
||||
for link in (link_rows or []):
|
||||
raw = link.raw if isinstance(link.raw, dict) else {}
|
||||
if link.confirmed_at is None and raw.get("kind") != place_research.RAW_KIND:
|
||||
continue
|
||||
text = (raw.get("text") or "").strip()
|
||||
if text:
|
||||
# ★ fact 목록이 아니라 records 로 넘긴다. fact 자리에 넣으면 모델이 값 하나로
|
||||
# 읽고 거의 쓰지 않는다(prompts/copy.build_prompt 머리주석의 실측).
|
||||
records.append(text[:4000])
|
||||
# ground_check 는 여전히 이 글을 근거로 인정해야 한다 — 근거 목록에도 남긴다.
|
||||
grounded.append(gemini_text.FactInput(
|
||||
key=f"source:{link.link_id}", label="수집 원문", value=text[:4000],
|
||||
))
|
||||
|
||||
# 객실·메뉴 요약도 근거로 넘긴다 — "최대 4명" 같은 수치가 통과하려면 근거에 있어야 한다.
|
||||
#
|
||||
# ★ 근거 없음 판정보다 **먼저** 읽는다.
|
||||
# 예전에는 사업장 fact 가 0건이면 여기까지 오지 못하고 되돌아갔다. 그런데 네이버에
|
||||
# 요금표만 올라온 모텔은 사업장 fact 가 0건이고 객실 fact 만 있다 — 쓸 근거가 있는데도
|
||||
# "근거 없음"으로 끝나 소개문·FAQ 가 영구히 생기지 않았다.
|
||||
u_err, unit_rows = await DB_SESSION_MNG.execute_lambda(
|
||||
place_units.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: _place_crud.list_units(s, pid),
|
||||
)
|
||||
unit_summaries = []
|
||||
if u_err == ErrorType.SUCCESS:
|
||||
by_unit: dict = {}
|
||||
for r in fact_rows:
|
||||
if r.unit_id and (r.value or "").strip():
|
||||
by_unit.setdefault(str(r.unit_id), {})[r.key] = r.value
|
||||
unit_summaries = [
|
||||
{
|
||||
"name": u.name,
|
||||
"facts": by_unit.get(str(u.unit_id), {}),
|
||||
# 스키마 라벨·단위를 같이 넘긴다 — 이게 없으면 프롬프트에 'weekday_price' 라는
|
||||
# 날 key 가 그대로 실려 모델이 그 낱말로 문장을 쓴다.
|
||||
"labels": {
|
||||
key: {
|
||||
"label": schema.get(key).label if schema.get(key) else key,
|
||||
"unit": schema.get(key).unit if schema.get(key) else None,
|
||||
}
|
||||
for key in by_unit.get(str(u.unit_id), {})
|
||||
},
|
||||
}
|
||||
for u in unit_rows
|
||||
if by_unit.get(str(u.unit_id))
|
||||
]
|
||||
|
||||
# FAQ 채우기에 쓸 업종 카탈로그. 없으면(카페·음식점·호텔) 채우지 않는다.
|
||||
catalog = find_catalog(place.category, place.external_category)
|
||||
# 사업장·객실 fact 를 가리지 않는다 — "기준 인원" 은 객실 fact 로 답한다.
|
||||
known_fact_keys = {r.key for r in fact_rows if (r.value or "").strip()}
|
||||
|
||||
return CopyInputs(place, schema, grounded, records, unit_summaries, catalog, known_fact_keys)
|
||||
|
||||
|
||||
async def generate_copy(inputs: CopyInputs) -> gemini_text.GeneratedCopy:
|
||||
try:
|
||||
return await gemini_text.generate_copy(
|
||||
inputs.place.name,
|
||||
PlaceCategory(inputs.place.category),
|
||||
inputs.grounded,
|
||||
unit_summaries=inputs.unit_summaries or None,
|
||||
records=inputs.records or None,
|
||||
suggested_questions=faq_fill.suggested_questions(inputs.catalog, inputs.known_fact_keys) if inputs.catalog else None,
|
||||
max_faqs=faq_fill.FAQ_TARGET,
|
||||
model=external_api_config.gemini_text_model,
|
||||
)
|
||||
except gemini_text.GeminiNotConfigured as ex:
|
||||
raise CopyAborted(str(ex)) from ex
|
||||
|
||||
|
||||
async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None) -> dict:
|
||||
pid = inputs.place.place_id
|
||||
place_id = str(pid)
|
||||
schema = inputs.schema
|
||||
now = GTime.UTC()
|
||||
stat = {
|
||||
"place_id": place_id,
|
||||
"grounded_facts": len(inputs.grounded),
|
||||
"intro": False,
|
||||
"meta": False,
|
||||
"faqs": 0,
|
||||
"faq_fill": 0, # 목표 수를 채운 문의 안내 문항 수
|
||||
# ★ 반려된 문장을 그대로 남긴다 — 소개문이 왜 안 나왔는지 운영자가 알아야 한다.
|
||||
"rejected": [list(r) for r in (copy.rejected or [])][:20] if copy else [],
|
||||
}
|
||||
|
||||
if copy is None:
|
||||
# 키만 없는 경우에는 기존 생성물을 보존한다.
|
||||
if inputs.ungrounded and inputs.catalog is not None:
|
||||
await DB_SESSION_MNG.execute_lambda_claim(
|
||||
place_faqs.DBType(), lambda s: _faq_crud.expire_generated(s, pid, now),
|
||||
)
|
||||
return stat
|
||||
|
||||
actor = UserInfo(
|
||||
# ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로
|
||||
# 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다.
|
||||
# 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가
|
||||
# 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다.
|
||||
user_id=str(inputs.place.owner_user_id),
|
||||
id="generator",
|
||||
role=1,
|
||||
)
|
||||
service = FactService(_fact_crud, _place_crud)
|
||||
|
||||
# 소개문·메타는 fact 로 들어간다 — FactService 가 allow_llm 을 다시 확인하고(뒷문 없음),
|
||||
# LLM 출처라 후보가 아니라 노출값으로 앉힌다(upsert_fact 의 LLM 분기).
|
||||
for key, text_value in (("intro", copy.intro), ("meta_description", copy.meta_description)):
|
||||
if not (text_value or "").strip():
|
||||
continue
|
||||
if not (schema.get(key) and schema.get(key).allow_llm):
|
||||
# 이 업종 스키마가 LLM 작성을 허용하지 않는 필드다. 조용히 건너뛴다.
|
||||
continue
|
||||
res = await service.upsert_fact(
|
||||
actor, place_id,
|
||||
Req_UpsertFact(
|
||||
key=key, value=text_value.strip(),
|
||||
source_type=SourceType.LLM, source_url=f"gemini:{external_api_config.gemini_text_model}",
|
||||
),
|
||||
)
|
||||
if res.result.success:
|
||||
stat["intro" if key == "intro" else "meta"] = True
|
||||
else:
|
||||
stat["rejected"].append([key, res.result.desc])
|
||||
|
||||
# 확인 안 된 기존 생성 FAQ 는 내리고 새로 넣는다. 사람이 확인한 FAQ 는 건드리지 않는다.
|
||||
await DB_SESSION_MNG.execute_lambda_claim(
|
||||
place_faqs.DBType(),
|
||||
lambda s: _faq_crud.expire_generated(s, pid, now),
|
||||
)
|
||||
for order, faq in enumerate(copy.faqs or []):
|
||||
if not faq.fact_keys:
|
||||
# ★ 근거 없는 FAQ 는 저장하지 않는다.
|
||||
stat["rejected"].append([faq.question, "근거 fact 없음"])
|
||||
continue
|
||||
row = place_faqs(
|
||||
place_id=pid,
|
||||
question=faq.question,
|
||||
answer=faq.answer,
|
||||
source_fact_ids=list(faq.fact_keys),
|
||||
generated_by=SourceType.LLM.value,
|
||||
# ★ 바로 노출한다 (2026-09-10 결정 — fact_service.upsert_fact 주석이 근거).
|
||||
# 근거 fact 가 없으면 위에서 이미 버렸으므로, 여기 남은 것은 전부 확인된 사실로 쓴 문장이다.
|
||||
status=FactStatus.VERIFIED.value,
|
||||
sort_order=order,
|
||||
)
|
||||
run_err = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[place_faqs.DBType()],
|
||||
[lambda s, r=row: _faq_crud.add_faq(s, r)],
|
||||
)
|
||||
if run_err == ErrorType.SUCCESS:
|
||||
stat["faqs"] += 1
|
||||
|
||||
LOG.i(f"[copy] place={place_id} 소개문 {'O' if stat['intro'] else 'X'} · FAQ {stat['faqs']}건 · "
|
||||
f"반려 {len(stat['rejected'])}건 (근거 fact {len(inputs.grounded)}개)")
|
||||
return stat
|
||||
|
||||
|
||||
async def fill_faqs(pid: uuid.UUID, catalog: FaqCatalog, known_fact_keys: set[str], phone: str | None) -> int:
|
||||
"""노출 중인 FAQ 가 목표 수에 모자란 만큼 문의 안내 문항을 넣는다. 넣은 건수를 돌려준다.
|
||||
|
||||
★ 기존 FAQ 는 **노출 중인 것 전부**로 센다 — 방금 넣은 생성분만이 아니라 재생성이 남긴
|
||||
사장님 입력·정정분까지. 그래야 사장님이 이미 답한 주제에 문의 안내가 겹쳐 붙지 않는다.
|
||||
★ 바로 노출값(VERIFIED)으로 넣는다. 답이 주장을 하지 않아 확인할 대상이 없다 —
|
||||
대신 JSON-LD · llms.txt · 고유 콘텐츠 계수에서는 빠진다(shared selectAnsweredFaqs)."""
|
||||
l_err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
place_faqs.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: _faq_crud.list_faqs(s, pid, True),
|
||||
)
|
||||
if l_err != ErrorType.SUCCESS:
|
||||
LOG.e_no_callstack(f"[copy] FAQ 채우기 건너뜀 — 목록 조회 실패 place={pid} {l_err.name}")
|
||||
return 0
|
||||
|
||||
picks = faq_fill.pick_fill_faqs(
|
||||
catalog,
|
||||
[faq_fill.ExistingFaq(r.question, r.source_fact_ids) for r in rows],
|
||||
known_fact_keys,
|
||||
phone,
|
||||
)
|
||||
next_order = max((r.sort_order for r in rows), default=-1) + 1
|
||||
added = 0
|
||||
for offset, pick in enumerate(picks):
|
||||
row = place_faqs(
|
||||
place_id=pid,
|
||||
question=pick.question,
|
||||
answer=pick.answer,
|
||||
source_fact_ids=None,
|
||||
generated_by=SourceType.TEMPLATE.value,
|
||||
status=FactStatus.VERIFIED.value,
|
||||
sort_order=next_order + offset,
|
||||
)
|
||||
run_err = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[place_faqs.DBType()],
|
||||
[lambda s, r=row: _faq_crud.add_faq(s, r)],
|
||||
)
|
||||
if run_err == ErrorType.SUCCESS:
|
||||
added += 1
|
||||
return added
|
||||
34
solution/backend/services/job_progress.py
Normal file
34
solution/backend/services/job_progress.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""작업 단계 기록. 실행 순서는 도메인 서비스, 화면 문구는 프론트가 소유한다."""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from crud.job_crud import JobQueue
|
||||
|
||||
|
||||
class JobProgress:
|
||||
def __init__(self, job: dict, steps: tuple[str, ...]):
|
||||
self.job = job
|
||||
self.queue = JobQueue()
|
||||
self.state = {
|
||||
"attempt": job["attempts"],
|
||||
"steps": [{"id": step, "status": "pending"} for step in steps],
|
||||
}
|
||||
|
||||
async def _set(self, step_id: str, status: str, reason: str | None = None):
|
||||
step = next(step for step in self.state["steps"] if step["id"] == step_id)
|
||||
step.update(status=status, reason=reason)
|
||||
if not await self.queue.set_progress(self.job, self.state):
|
||||
raise RuntimeError("작업 소유권이 만료되어 진행 기록을 중단합니다")
|
||||
|
||||
@asynccontextmanager
|
||||
async def step(self, step_id: str):
|
||||
await self._set(step_id, "running")
|
||||
try:
|
||||
yield
|
||||
except BaseException:
|
||||
await self._set(step_id, "failed")
|
||||
raise
|
||||
else:
|
||||
await self._set(step_id, "done")
|
||||
|
||||
async def skip(self, step_id: str, reason: str):
|
||||
await self._set(step_id, "skipped", reason)
|
||||
@ -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:
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
{
|
||||
|
||||
@ -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",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -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 로 남아 운영자가 본다."""
|
||||
|
||||
|
||||
|
||||
@ -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 로 남는다."""
|
||||
|
||||
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -32,6 +32,8 @@ from services.collector.registry import REGISTRY
|
||||
# 여기 남은 것은 **수집 불가 결론이 난 곳**이다(docs/DATA_SOURCE_RESEARCH.md):
|
||||
# 야놀자·여기어때는 403 + Cloudflare 로 막혀 있고 민사 10억 선례가 있다.
|
||||
# 카카오맵은 내부 API 406. 인스타는 Graph API(사장님 OAuth)로만 간다.
|
||||
# ★ NOL 전용 어댑터(2026-09-14)가 받는 것은 `nol.yanolja.com/stay/domestic/<id>` 한 패턴뿐이다.
|
||||
# 아래 야놀자 주소가 여전히 막혀야 전용 경로를 연 것과 범용 수집을 푼 것이 갈린다.
|
||||
_REAL_URLS = (
|
||||
"https://www.yanolja.com/pension/1000",
|
||||
"https://www.goodchoice.kr/product/detail/1000",
|
||||
@ -174,7 +176,7 @@ def test_registers_only_reviewed_adapters():
|
||||
|
||||
기대결과: 검토를 거쳐 명시적으로 승인한 것만 있다. 이 목록이 늘어나는 것은
|
||||
**의도된 결정이어야** 하므로, 코드가 몰래 늘어나면 여기서 깨진다."""
|
||||
assert adapter_ids() == ["naver_place", "tour_api", "mock", "static_html"], (
|
||||
assert adapter_ids() == ["naver_place", "tour_api", "yanolja", "mock", "static_html"], (
|
||||
f"예상 밖 어댑터가 등록됐다: {adapter_ids()} — 승인 없이 수집 대상을 늘리지 않는다"
|
||||
)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
|
||||
@ -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명만 가져간다 — 도커에서 워커를 몇 개로 스케일하든 이중 실행이 없다."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}/")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
12
solution/frontend/src/api/generated/model/jobProgress.ts
Normal file
12
solution/frontend/src/api/generated/model/jobProgress.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Web4Ai API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { JobStep } from "./jobStep";
|
||||
|
||||
export interface JobProgress {
|
||||
steps: JobStep[];
|
||||
attempt: number;
|
||||
}
|
||||
14
solution/frontend/src/api/generated/model/jobStep.ts
Normal file
14
solution/frontend/src/api/generated/model/jobStep.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Web4Ai API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { JobStepStatus } from "./jobStepStatus";
|
||||
import type { JobStepReason } from "./jobStepReason";
|
||||
|
||||
export interface JobStep {
|
||||
id: string;
|
||||
status: JobStepStatus;
|
||||
reason?: JobStepReason;
|
||||
}
|
||||
@ -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;
|
||||
17
solution/frontend/src/api/generated/model/jobStepStatus.ts
Normal file
17
solution/frontend/src/api/generated/model/jobStepStatus.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Web4Ai API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type JobStepStatus = (typeof JobStepStatus)[keyof typeof JobStepStatus];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const JobStepStatus = {
|
||||
pending: "pending",
|
||||
running: "running",
|
||||
done: "done",
|
||||
skipped: "skipped",
|
||||
failed: "failed",
|
||||
} as const;
|
||||
@ -11,5 +11,5 @@
|
||||
★ 확인된 fact 만 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 안 한다).
|
||||
*/
|
||||
export interface ReqStartCopy {
|
||||
[key: string]: unknown;
|
||||
resume?: boolean;
|
||||
}
|
||||
|
||||
@ -19,4 +19,6 @@
|
||||
*/
|
||||
export interface ReqVerifyPlaceByUrl {
|
||||
url?: string;
|
||||
/** 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지. 기본 true. */
|
||||
reuse_existing?: boolean;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import {Check} from 'lucide-react';
|
||||
import {useRef, useState} from 'react';
|
||||
import type {TemplateItem} from '@o2o/shared';
|
||||
import {getAccessToken, startCopy} from '@/api';
|
||||
import {notifyApiError} from '@/lib/notify';
|
||||
import {INDUSTRY_CONFIGS} from '@/data/industryData';
|
||||
import {queueSiteTemplateSave} from '@/features/publish/siteTemplate';
|
||||
import {cn} from '@/lib/utils';
|
||||
@ -87,7 +90,29 @@ export function Step4Template() {
|
||||
const placeId = useBuilderStore((s) => s.placeId);
|
||||
const selectTemplate = useBuilderStore((s) => s.selectTemplate);
|
||||
const [, goToStep] = useWizardStep();
|
||||
const startGenerating = useBuilderStore((s) => s.startGenerating);
|
||||
const starting = useRef(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const startGeneration = async () => {
|
||||
if (starting.current) return;
|
||||
queueSiteTemplateSave(placeId, templateId);
|
||||
if (!placeId || !getAccessToken()) {
|
||||
goToStep('editor');
|
||||
return;
|
||||
}
|
||||
starting.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await startCopy(placeId, {});
|
||||
if (response.result?.success !== true || !response.job_id) throw {data: response};
|
||||
goToStep('generating', {params: {placeId, jobId: response.job_id}});
|
||||
} catch (error) {
|
||||
notifyApiError(error, '소개문 생성을 시작하지 못했습니다.');
|
||||
} finally {
|
||||
starting.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const config = INDUSTRY_CONFIGS[industry];
|
||||
|
||||
@ -168,14 +193,9 @@ export function Step4Template() {
|
||||
|
||||
<WizardFooter
|
||||
onPrev={() => goToStep('collect')}
|
||||
onNext={() => {
|
||||
// ★ 아무것도 안 누르고 넘어가는 경우(첫 템플릿이 이미 선택돼 있다)도 서버에 남긴다 —
|
||||
// 화면이 보여준 그 템플릿이 발행본이 되어야 한다. 같은 값이면 서버가 재빌드 표시도 찍지 않는다.
|
||||
queueSiteTemplateSave(placeId, templateId);
|
||||
startGenerating();
|
||||
goToStep('generating');
|
||||
}}
|
||||
nextLabel="이 템플릿으로 사이트 생성하기"
|
||||
onNext={() => void startGeneration()}
|
||||
nextDisabled={busy}
|
||||
nextLabel={busy ? '생성 작업을 시작하고 있습니다' : '이 템플릿으로 사이트 생성하기'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,193 +1,58 @@
|
||||
import {useCallback, useEffect} from 'react';
|
||||
import {Check, Loader2} from 'lucide-react';
|
||||
import {JobStatus} from '@o2o/shared';
|
||||
import {delay, getAccessToken, pollJob, startCopy} from '@/api';
|
||||
import {Progress} from '@/components/ui/progress';
|
||||
import {notify, notifyApiError} from '@/lib/notify';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {useBuilderStore} from '@/stores/builder';
|
||||
import {EDITOR_STEP, useWizardStep} from './wizardUrl';
|
||||
|
||||
const BUILD_STEPS = [
|
||||
'수집된 사진 분류 및 대체 텍스트 생성',
|
||||
'브랜드 컬러 시스템 및 타이포그래피 조합',
|
||||
'확인된 사실만으로 소개 콘텐츠 구성',
|
||||
'위치 기반 길찾기 및 주변 정보 연동',
|
||||
'구조화 데이터(JSON-LD) 준비',
|
||||
];
|
||||
|
||||
const STAGE_MS = 600;
|
||||
|
||||
/**
|
||||
* ★ 잡은 PENDING/RUNNING/DONE 세 상태만 준다 — 단계별 progress 필드가 없다.
|
||||
* 그래서 문구는 "지금 서버가 이 근처를 하고 있다"는 안내이고, 진짜 신호는 잡 상태다.
|
||||
* RUNNING 동안 마지막 단계 직전까지만 올리고, 완료는 오직 DONE 이 만든다
|
||||
* — 100%를 먼저 그려 놓고 기다리게 하지 않는다.
|
||||
*/
|
||||
const RUNNING_STAGE_CEILING = BUILD_STEPS.length - 1;
|
||||
import {useGenerationJob} from './useGenerationJob';
|
||||
import {GENERATION_LABELS, SKIP_REASONS} from './generationLabels';
|
||||
|
||||
export function Step5Generating() {
|
||||
const storeName = useBuilderStore((s) => s.storeName);
|
||||
const stage = useBuilderStore((s) => s.generateStage);
|
||||
const setGenerateStage = useBuilderStore((s) => s.setGenerateStage);
|
||||
const placeId = useBuilderStore((s) => s.placeId);
|
||||
const [, goToStep] = useWizardStep();
|
||||
|
||||
/**
|
||||
* 생성이 끝나면 곧바로 에디터다.
|
||||
*
|
||||
* ★ 중간에 다른 화면을 끼우지 않는다. 위저드를 끝까지 걸어온 사람의 다음 용건은
|
||||
* 자기 사이트를 보는 것이고, 목록을 한 번 거치게 하면 그게 한 번 더 클릭이 된다.
|
||||
* 사업장은 이미 2단계에서 등록돼 목록에 들어가 있다 — '목록에 생기는 것'과
|
||||
* '목록으로 보내는 것'은 다른 얘기다.
|
||||
*/
|
||||
const finishOnboarding = useCallback(() => {
|
||||
// ★ `flow=onboarding` 도 같이 뗀다. 남겨 두면 이 주소를 새로고침했을 때 위저드 중인
|
||||
// 사업장으로 읽혀 신원이 다시 세워진다 — 편집 중인 사람에게는 아무 의미가 없는 일이다.
|
||||
goToStep(EDITOR_STEP, {params: {flow: null}, replace: true});
|
||||
}, [goToStep]);
|
||||
|
||||
/**
|
||||
* 소개문·FAQ 생성(JobType.COPY). `POST /v1/place/{id}/copy` 로 잡을 넣고 폴링한다.
|
||||
*
|
||||
* ★ 정적 빌드(JobType.BUILD)는 여기가 아니라 발행 모달이 부른다 — 빌드는 발행 검수
|
||||
* 게이트와 한 몸이고(services/build_service), 여기서 미리 구워 봐야 편집 전 스냅샷이라
|
||||
* 버릴 버전만 쌓인다.
|
||||
* ★ 폴백: placeId(데모 경로) 나 토큰이 없으면 백엔드를 아예 부르지 않고 타이머로 돈다.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
/** 타이머 시뮬레이션. 폴백 경로 전용 — 백엔드가 없어도 위저드는 끝까지 간다. */
|
||||
const runSimulation = async () => {
|
||||
for (let next = 1; next <= BUILD_STEPS.length; next += 1) {
|
||||
await delay(STAGE_MS, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setGenerateStage(next);
|
||||
}
|
||||
await delay(STAGE_MS, controller.signal);
|
||||
if (!controller.signal.aborted) finishOnboarding();
|
||||
};
|
||||
|
||||
/** RUNNING 인 동안 문구만 천천히 밀어 준다. 완료 판정은 하지 않는다. */
|
||||
const creep = () =>
|
||||
setGenerateStage(
|
||||
Math.min(RUNNING_STAGE_CEILING, useBuilderStore.getState().generateStage + 1),
|
||||
);
|
||||
|
||||
const runCopy = async (id: string) => {
|
||||
let jobId: string;
|
||||
try {
|
||||
const started = await startCopy(id, {}, undefined, controller.signal);
|
||||
// ★ 근거로 쓸 확인된 fact 가 0 이면 서버는 아무것도 쓰지 않는다 — LLM 은 사실을 만들지 않는다.
|
||||
if (started.grounded_facts === 0) {
|
||||
notify.warn(
|
||||
'확인된 정보가 없어 소개문을 쓰지 않았습니다.',
|
||||
'[확인] 탭에서 수집된 정보를 확정하면 그 사실만으로 소개문을 씁니다.',
|
||||
);
|
||||
}
|
||||
if (!started.job_id) {
|
||||
await runSimulation();
|
||||
return;
|
||||
}
|
||||
jobId = started.job_id;
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
notifyApiError(error, '소개문 생성을 시작하지 못했습니다.');
|
||||
await runSimulation();
|
||||
return;
|
||||
}
|
||||
|
||||
setGenerateStage(1);
|
||||
const outcome = await pollJob(jobId, {
|
||||
signal: controller.signal,
|
||||
onTick: (job) => {
|
||||
if (job.status === JobStatus.RUNNING) creep();
|
||||
},
|
||||
});
|
||||
|
||||
if (outcome.kind === 'aborted') return;
|
||||
if (outcome.kind === 'dead') {
|
||||
notify.error('소개문을 만들지 못했습니다.', outcome.job.last_error ?? undefined);
|
||||
} else if (outcome.kind === 'unreachable') {
|
||||
notifyApiError(outcome.error, '생성 상태를 확인하지 못했습니다.');
|
||||
} else if (outcome.kind === 'timeout') {
|
||||
notify.warn('생성이 예상보다 오래 걸립니다.', '생성은 계속 진행됩니다.');
|
||||
}
|
||||
|
||||
// ★ 실패해도 편집기로는 보낸다. 소개문은 없어도 확인된 fact 로 편집·발행이 되고,
|
||||
// 여기서 멈추면 사장님은 되돌아갈 길이 없는 화면에 갇힌다.
|
||||
setGenerateStage(BUILD_STEPS.length);
|
||||
await delay(STAGE_MS, controller.signal);
|
||||
if (!controller.signal.aborted) finishOnboarding();
|
||||
};
|
||||
|
||||
if (!placeId || !getAccessToken()) void runSimulation();
|
||||
else void runCopy(placeId);
|
||||
|
||||
return () => controller.abort();
|
||||
}, [setGenerateStage, finishOnboarding, placeId]);
|
||||
|
||||
const progress = Math.min(100, Math.round((stage / BUILD_STEPS.length) * 100));
|
||||
const {job, error, checkAgain, openEditor, goBack} = useGenerationJob(placeId);
|
||||
const failed = job?.status === JobStatus.DEAD;
|
||||
const waiting = job?.status === JobStatus.PENDING;
|
||||
const steps = job?.progress?.steps ?? [];
|
||||
const title = error ? '진행 상태 확인이 필요합니다'
|
||||
: failed ? '콘텐츠 생성을 완료하지 못했습니다'
|
||||
: waiting ? (job.attempts ? '생성을 다시 시도할 예정입니다' : '생성 순서를 기다리고 있습니다')
|
||||
: job?.status === JobStatus.DONE ? '콘텐츠 생성을 마쳤습니다'
|
||||
: '소개문과 FAQ를 만들고 있습니다';
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-muted/30 px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-md flex-col items-center text-center">
|
||||
<div className="mb-6 flex size-16 items-center justify-center rounded-2xl border border-border bg-card">
|
||||
<Loader2 className="size-8 animate-spin text-primary" />
|
||||
</div>
|
||||
|
||||
<h1 className="mb-2 text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
웹사이트를 생성하고 있습니다
|
||||
</h1>
|
||||
<p className="mb-8 max-w-md text-xs text-muted-foreground sm:text-sm">
|
||||
<strong className="font-semibold text-foreground">{storeName}</strong>의 확인된 정보만
|
||||
담아 정적 페이지로 굽고 있어요.
|
||||
<div className="flex flex-1 items-center justify-center bg-muted/30 px-4 py-16">
|
||||
<div className="w-full max-w-md space-y-6 text-center">
|
||||
{!error && !failed && <Loader2 className="mx-auto size-8 animate-spin text-primary" />}
|
||||
<h1 className="text-xl font-bold" aria-live="polite">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error || <><strong>{storeName}</strong>의 확인된 정보를 바탕으로 작성합니다.</>}
|
||||
</p>
|
||||
|
||||
<div className="w-full space-y-4 rounded-2xl border border-border bg-card p-6 text-left">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-bold">{BUILD_STEPS[Math.min(stage - 1, BUILD_STEPS.length - 1)]}</span>
|
||||
<span className="font-mono font-bold">{progress}%</span>
|
||||
</div>
|
||||
|
||||
<Progress value={progress} aria-label="사이트 생성 진행률" />
|
||||
|
||||
<ol className="space-y-2.5 pt-2">
|
||||
{BUILD_STEPS.map((text, index) => {
|
||||
const num = index + 1;
|
||||
const isDone = stage > num;
|
||||
const isCurrent = stage === num;
|
||||
|
||||
{steps.length > 0 && (
|
||||
<ol className="space-y-4 rounded-2xl border border-border bg-card p-6 text-left text-sm">
|
||||
{steps.map((step) => {
|
||||
const state = step.status === 'running' && failed ? 'failed' : step.status;
|
||||
const label = state === 'done' ? '완료' : state === 'skipped' ? '건너뜀'
|
||||
: state === 'failed' ? '중단' : state === 'running' ? (waiting ? '재시도 대기' : '진행 중') : '대기';
|
||||
return (
|
||||
<li
|
||||
key={text}
|
||||
className={cn(
|
||||
'flex items-center gap-3 text-xs transition-colors',
|
||||
isDone || isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold',
|
||||
isDone || isCurrent
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'border border-border bg-muted',
|
||||
isCurrent && 'ring-2 ring-primary/20',
|
||||
)}
|
||||
>
|
||||
{isDone ? <Check className="size-2.5" strokeWidth={3} /> : num}
|
||||
</span>
|
||||
<span className="truncate">{text}</span>
|
||||
<li key={step.id} className="flex items-center gap-3">
|
||||
{state === 'done' ? <Check className="size-4 shrink-0 text-success" />
|
||||
: state === 'running' && !waiting && !error ? <Loader2 className="size-4 shrink-0 animate-spin" />
|
||||
: <span className="size-4 shrink-0 rounded-full border border-border" />}
|
||||
<div className="flex-1">
|
||||
{GENERATION_LABELS[step.id] ?? '콘텐츠 처리'}
|
||||
{step.reason && <p className="mt-1 text-xs text-muted-foreground">{SKIP_REASONS[step.reason] ?? '이 단계는 생략했습니다'}</p>}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-xs text-muted-foreground">
|
||||
생성이 끝나면 실시간 에디터로 자동 전환됩니다.
|
||||
</p>
|
||||
)}
|
||||
{!error && !failed && <p className="text-xs text-muted-foreground">새로고침해도 같은 작업의 진행 상태를 이어서 확인합니다.</p>}
|
||||
{(error || failed) && <div className="flex flex-wrap justify-center gap-2">
|
||||
{error && <Button onClick={checkAgain}>상태 다시 확인</Button>}
|
||||
<Button variant="outline" onClick={goBack}>이전 단계로</Button>
|
||||
<Button variant="outline" onClick={openEditor}>편집기로 이동</Button>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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}, '그 주소에서 가게 정보를 읽지 못했습니다.');
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
/** 화면 문구만 둔다. 단계 순서·완료 여부는 jobs.progress가 보낸다. */
|
||||
export const GENERATION_LABELS: Record<string, string> = {
|
||||
prepare: '확인된 정보와 수집 원문 준비',
|
||||
generate: '소개문·FAQ 생성 및 근거 검증',
|
||||
save: '소개문·FAQ 저장',
|
||||
faq_fill: '부족한 FAQ 문의 안내 보완',
|
||||
};
|
||||
|
||||
export const SKIP_REASONS: Record<string, string> = {
|
||||
no_facts: '확인된 정보 없음',
|
||||
not_configured: '생성 서비스 미설정',
|
||||
no_catalog: '공통 질문 대상 업종 아님',
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useSearchParams} from 'react-router';
|
||||
import {useQueryClient} from '@tanstack/react-query';
|
||||
import {JobStatus, JobType} from '@o2o/shared';
|
||||
import {getAccessToken, startCopy, useGetJob} from '@/api';
|
||||
import {EDITOR_STEP, useWizardStep} from './wizardUrl';
|
||||
|
||||
/** URL의 jobId 복구 → 서버 상태 조회 → 완료 시 편집기. 시간으로 단계를 추정하지 않는다. */
|
||||
export function useGenerationJob(placeId: string | null) {
|
||||
const [params] = useSearchParams();
|
||||
const [, goToStep] = useWizardStep();
|
||||
const jobId = params.get('jobId');
|
||||
const queryClient = useQueryClient();
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const authenticated = Boolean(getAccessToken());
|
||||
|
||||
const query = useGetJob(jobId ?? '', {query: {
|
||||
enabled: Boolean(placeId && jobId && authenticated),
|
||||
retry: 2,
|
||||
refetchInterval: (current) => {
|
||||
const status = current.state.data?.job?.status;
|
||||
return current.state.status === 'error' || status === JobStatus.DONE || status === JobStatus.DEAD
|
||||
? false : 2000;
|
||||
},
|
||||
}});
|
||||
const job = query.data?.job;
|
||||
const wrongJob = Boolean(job && (job.job_type !== JobType.COPY || job.place_id !== placeId));
|
||||
|
||||
const openEditor = useCallback(() => {
|
||||
goToStep(EDITOR_STEP, {params: {flow: null, jobId: null}, replace: true});
|
||||
}, [goToStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!placeId || jobId || !authenticated) return;
|
||||
const controller = new AbortController();
|
||||
// 구 주소이거나 시작 응답을 받기 전에 새로고침했으면 완료 이력까지 찾아 이어 본다.
|
||||
void startCopy(placeId, {resume: true}, undefined, controller.signal).then((response) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (response.result?.success !== true || !response.job_id) {
|
||||
setStartError('생성을 시작하지 못했습니다. 정보를 확인한 뒤 다시 시도해 주세요.');
|
||||
return;
|
||||
}
|
||||
goToStep('generating', {params: {jobId: response.job_id, placeId}, replace: true});
|
||||
}).catch(() => {
|
||||
if (!controller.signal.aborted) setStartError('서버에 연결하지 못했습니다. 다시 확인해 주세요.');
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [placeId, jobId, authenticated, goToStep, retry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wrongJob || job?.status !== JobStatus.DONE) return;
|
||||
let active = true;
|
||||
void queryClient.invalidateQueries({predicate: (cached) =>
|
||||
typeof cached.queryKey[0] === 'string' && cached.queryKey[0].startsWith(`/v1/place/${placeId}`),
|
||||
}).then(() => { if (active) openEditor(); });
|
||||
return () => { active = false; };
|
||||
}, [job?.status, wrongJob, placeId, queryClient, openEditor]);
|
||||
|
||||
const error = !placeId ? '사업장 정보가 없습니다. 이전 단계에서 사업장을 선택해 주세요.'
|
||||
: !authenticated ? '로그인이 필요합니다. 편집기로 이동해 로그인해 주세요.'
|
||||
: wrongJob ? '이 화면의 생성 작업이 아닙니다. 이전 단계에서 다시 시작해 주세요.'
|
||||
: startError ?? (query.isError || (query.isSuccess && !job)
|
||||
? '진행 상태를 확인하지 못했습니다. 서버 작업은 계속될 수 있습니다.' : null);
|
||||
|
||||
return {
|
||||
job, error, openEditor,
|
||||
checkAgain: () => {
|
||||
setStartError(null);
|
||||
if (jobId) void query.refetch();
|
||||
else setRetry((value) => value + 1);
|
||||
},
|
||||
goBack: () => goToStep('template', {params: {jobId: null}}),
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -74,7 +74,6 @@ interface BuilderState {
|
||||
// 템플릿 · 생성
|
||||
templateId: string;
|
||||
colorPaletteId: string | null;
|
||||
generateStage: number;
|
||||
|
||||
// ── 편집 대상 데이터 ───────────────────────────────────
|
||||
sections: SectionItem[];
|
||||
@ -123,8 +122,6 @@ interface BuilderState {
|
||||
selectTemplate: (templateId: string) => void;
|
||||
selectColorPalette: (paletteId: string | null) => void;
|
||||
/** 생성 화면에 들어가기 직전, 진행 표시를 처음으로 되돌린다. 화면 이동은 주소창이 한다. */
|
||||
startGenerating: () => void;
|
||||
setGenerateStage: (stage: number) => void;
|
||||
|
||||
selectSection: (id: string | null) => void;
|
||||
toggleSection: (id: string) => void;
|
||||
@ -300,7 +297,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
isGathering: false,
|
||||
gatherStage: 1,
|
||||
gatherCompleted: false,
|
||||
generateStage: 1,
|
||||
|
||||
selectedSectionId: null,
|
||||
viewport: 'pc',
|
||||
@ -514,8 +510,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
persistTheme();
|
||||
},
|
||||
// 화면 이동은 부르는 쪽이 주소창으로 한다(`?step=generating`) — 여기서는 진행 표시만 되감는다.
|
||||
startGenerating: () => set({generateStage: 1}),
|
||||
setGenerateStage: (generateStage) => set({generateStage}),
|
||||
|
||||
selectSection: (selectedSectionId) => set({selectedSectionId}),
|
||||
|
||||
@ -785,7 +779,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
isGathering: false,
|
||||
gatherStage: 1,
|
||||
gatherCompleted: false,
|
||||
generateStage: 1,
|
||||
selectedSectionId: null,
|
||||
isPreviewMode: false,
|
||||
isPublishModalOpen: false,
|
||||
|
||||
125
solution/frontend/tests/generation.mjs
Normal file
125
solution/frontend/tests/generation.mjs
Normal file
@ -0,0 +1,125 @@
|
||||
// 실행: 개발 서버를 켠 뒤 node solution/frontend/tests/generation.mjs [URL]
|
||||
// 모든 API는 가짜 응답으로 막는다. 외부 생성 API·실제 사업장에는 쓰지 않는다.
|
||||
import assert from 'node:assert/strict';
|
||||
import {chromium} from 'playwright';
|
||||
|
||||
const origin = process.argv[2] ?? 'http://127.0.0.1:3015';
|
||||
const placeId = '10000000-0000-4000-8000-000000000001';
|
||||
const jobId = '20000000-0000-4000-8000-000000000001';
|
||||
const result = {success: true, code: 0};
|
||||
const browser = await chromium.launch({channel: 'chrome'});
|
||||
let passed = 0;
|
||||
|
||||
async function scenario(name, run) {
|
||||
const page = await browser.newPage();
|
||||
page.on('pageerror', (error) => console.error(error.message));
|
||||
const state = {
|
||||
posts: [], gets: 0, unavailable: false,
|
||||
job: {job_id: jobId, place_id: placeId, job_type: 3, status: 2, attempts: 1,
|
||||
progress: {attempt: 1, steps: [
|
||||
{id: 'prepare', status: 'done'}, {id: 'generate', status: 'running'},
|
||||
{id: 'save', status: 'pending'}, {id: 'faq_fill', status: 'pending'},
|
||||
]}},
|
||||
};
|
||||
await page.addInitScript(() => localStorage.setItem('o2o-web4ai.accessToken', 'test-token'));
|
||||
await page.route('**/*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (!url.pathname.startsWith('/v1/')) {
|
||||
return url.origin === origin ? route.continue() : route.abort();
|
||||
}
|
||||
let body = {result};
|
||||
if (url.pathname === `/v1/job/${jobId}`) {
|
||||
state.gets += 1;
|
||||
if (state.unavailable) return route.fulfill({status: 503, json: {}});
|
||||
body.job = state.job;
|
||||
} else if (url.pathname.endsWith('/copy')) {
|
||||
state.posts.push(route.request().postDataJSON());
|
||||
body = {...body, job_id: jobId, created: true, status: state.job.status};
|
||||
} else if (url.pathname === `/v1/place/${placeId}`) {
|
||||
body.place = {place_id: placeId, name: '진행 복구 테스트', category: 1, status: 3,
|
||||
verified_at: '2026-09-15T00:00:00Z', road_address: '서울 강남구'};
|
||||
} else if (url.pathname.endsWith('/schema')) body.fields = [];
|
||||
else if (url.pathname.endsWith('/fact/list')) body.facts = [];
|
||||
else if (url.pathname.endsWith('/media')) body.media = [];
|
||||
else if (url.pathname.endsWith('/site')) body.site = {site_id: 'test-site', status: 1, template_id: 'stay.oasi'};
|
||||
else if (url.pathname.endsWith('/auth/me')) body = {...body, user_id: 'test-owner', id: 'test', role: 1};
|
||||
await route.fulfill({json: body});
|
||||
});
|
||||
const open = (suffix = `&jobId=${jobId}`) => page.goto(`${origin}/builder?step=generating&flow=onboarding&placeId=${placeId}${suffix}`);
|
||||
try {
|
||||
await run(page, state, open);
|
||||
console.log(`PASS ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.error(await page.locator('body').innerText());
|
||||
console.error(JSON.stringify({gets: state.gets, posts: state.posts}));
|
||||
throw error;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await scenario('새로고침은 같은 잡 조회만, 서버가 멈추면 표시도 그대로', async (page, state, open) => {
|
||||
await open();
|
||||
await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor();
|
||||
await page.reload();
|
||||
await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor();
|
||||
await page.waitForFunction(() => document.querySelectorAll('ol li').length === 4);
|
||||
await page.waitForTimeout(2300);
|
||||
assert.equal(state.posts.length, 0);
|
||||
assert.ok(state.gets >= 3);
|
||||
assert.match(await page.locator('ol li').nth(1).innerText(), /진행 중/);
|
||||
assert.match(await page.locator('ol li').nth(2).innerText(), /대기/);
|
||||
assert.equal(await page.getByText(/\d+%/).count(), 0);
|
||||
});
|
||||
|
||||
await scenario('구 URL은 resume으로 복구하고 잡 ID를 주소에 보존', async (page, state, open) => {
|
||||
await open('');
|
||||
await page.waitForURL(`**jobId=${jobId}`);
|
||||
assert.ok(state.posts.length > 0);
|
||||
assert.ok(state.posts.every((body) => body.resume === true));
|
||||
const count = state.posts.length;
|
||||
await page.reload();
|
||||
await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor();
|
||||
assert.equal(state.posts.length, count);
|
||||
});
|
||||
|
||||
await scenario('완료된 잡 재접속은 재생성 없이 편집기로', async (page, state, open) => {
|
||||
state.job.status = 3;
|
||||
await open();
|
||||
await page.waitForURL('**step=editor**');
|
||||
assert.equal(new URL(page.url()).searchParams.has('jobId'), false);
|
||||
assert.equal(state.posts.length, 0);
|
||||
});
|
||||
|
||||
await scenario('실패는 완료로 표시하거나 자동으로 편집기로 보내지 않음', async (page, state, open) => {
|
||||
state.job.status = 4;
|
||||
await open();
|
||||
await page.getByRole('heading', {name: '콘텐츠 생성을 완료하지 못했습니다'}).waitFor();
|
||||
assert.equal(new URL(page.url()).searchParams.get('step'), 'generating');
|
||||
assert.match(await page.locator('ol li').nth(1).innerText(), /중단/);
|
||||
assert.equal(state.posts.length, 0);
|
||||
});
|
||||
|
||||
await scenario('통신 오류 뒤에는 같은 잡 조회만 재시도', async (page, state, open) => {
|
||||
state.unavailable = true;
|
||||
await open();
|
||||
await page.getByRole('button', {name: '상태 다시 확인'}).waitFor();
|
||||
state.unavailable = false;
|
||||
await page.getByRole('button', {name: '상태 다시 확인'}).click();
|
||||
await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor();
|
||||
assert.equal(state.posts.length, 0);
|
||||
});
|
||||
|
||||
await scenario('다른 사업장의 잡 ID는 완료여도 이동 금지', async (page, state, open) => {
|
||||
state.job.place_id = 'other-place';
|
||||
state.job.status = 3;
|
||||
await open();
|
||||
await page.getByText('이 화면의 생성 작업이 아닙니다. 이전 단계에서 다시 시작해 주세요.').waitFor();
|
||||
assert.equal(new URL(page.url()).searchParams.get('step'), 'generating');
|
||||
});
|
||||
console.log(`${passed} scenarios passed`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@ -2011,6 +2011,37 @@
|
||||
}
|
||||
console.log('[w4d] 사진 갤러리 — 넓은 화면 ' + GAL_PER_PAGE + '장씩');
|
||||
} catch (err) { console.warn('[w4d] 갤러리 쪽 넘김 실패', err); }
|
||||
/* ⑩ 푸터 '만든 곳' 한 줄 — 렌더러에 **있는데** 이 목업이 문 번들에는 없다.
|
||||
★ 왜 주입인가 (2026-09-15 대표: "푸터 하단에 Web4ai ai o2o 얘기도 적어주셈 / 반영반영")
|
||||
`patch_stay.py` 가 정적 HTML 에 같은 줄을 박아 두지만, 이 한 장이 물고 있는 번들은
|
||||
2026-09-09 판이라 그 줄을 모른다 — 하이드레이션이 끝나는 순간 리액트가 **지운다**
|
||||
(실측 2026-09-15: HTML 에는 있는데 화면에는 0건). 번들을 갈아 끼우면 풀리지만,
|
||||
그건 목업 전체를 최신 렌더러로 다시 그리는 일이라 이 한 장을 깨뜨릴 수 있다
|
||||
(AGENTS.md 함정 1). 그래서 렌더러에 없는 것들과 **같은 방식**으로 얹는다.
|
||||
★ 마크업은 `SiteFooter.tsx` 가 내보내는 것과 같다 — 번들을 갈아 끼우는 날
|
||||
리액트가 그리는 줄과 겹치면 안 되므로, 이미 있으면 얹지 않는다. */
|
||||
function startMadeBy() {
|
||||
var HREF = 'https://www.o2osolution.ai/';
|
||||
function put() {
|
||||
var footer = document.querySelector('footer');
|
||||
if (!footer) return false;
|
||||
if (footer.querySelector('a[href="' + HREF + '"]')) return true; // 번들이 이미 그렸다
|
||||
var box = footer.querySelector('.shell') || footer.firstElementChild || footer;
|
||||
var p = document.createElement('p');
|
||||
p.className = 'pt-3 text-[length:var(--fs-xs)] opacity-55';
|
||||
p.innerHTML = '<a href="' + HREF + '" target="_blank" rel="noopener noreferrer" ' +
|
||||
'class="underline-offset-2 hover:underline">AI O2O</a>의 Web4Ai로 만든 사이트입니다.';
|
||||
box.appendChild(p);
|
||||
return true;
|
||||
}
|
||||
put();
|
||||
// 리액트가 #root 를 다시 그리면 사라진다 — 감시해서 다시 얹는다(플레이어와 같은 수법).
|
||||
if (window.MutationObserver) {
|
||||
new MutationObserver(function () { put(); })
|
||||
.observe(document.getElementById('root') || document.body, {childList: true, subtree: true});
|
||||
}
|
||||
}
|
||||
|
||||
window.setTimeout(function () {
|
||||
try {
|
||||
watchLinks();
|
||||
@ -2024,5 +2055,9 @@
|
||||
startWeatherNote();
|
||||
console.log('[w4d] 날씨 문구 — 하늘 5 · 기온대 5, 케이스마다 다섯 벌 중 하나');
|
||||
} catch (err) { console.warn('[w4d] 날씨 문구 실패', err); }
|
||||
try {
|
||||
startMadeBy();
|
||||
console.log('[w4d] 푸터 — AI O2O · Web4Ai');
|
||||
} catch (err) { console.warn('[w4d] 푸터 표기 실패', err); }
|
||||
}, 1200);
|
||||
})();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -92,6 +92,7 @@ async function renderPreview(placeId: string) {
|
||||
<PreviewReady />
|
||||
</StrictMode>,
|
||||
);
|
||||
signalPreviewPainted(true);
|
||||
}
|
||||
|
||||
const previewPlaceId = new URLSearchParams(window.location.search).get('placeId');
|
||||
|
||||
@ -56,6 +56,23 @@ export function readBakedLastmod(html: string): string | undefined {
|
||||
return value && !Number.isNaN(Date.parse(value)) ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구운 `index.html` 의 canonical 에서 **오리진**을 꺼낸다. 없으면 빈 문자열.
|
||||
*
|
||||
* ★ 왜 필요한가 — 오리진은 payload 에서 온다. 그런데 기동 때 도는 자산 주소 갱신
|
||||
* (`prerender --refresh-assets`)은 payload 를 하나도 읽지 않아서 오리진을 모른다.
|
||||
* 그 경로에서 루트 색인 파일(sitemap.xml · llms.txt · `/s` 목록)을 다시 쓰지 못하는 동안
|
||||
* **내려간 사이트가 사이트맵에 그대로 남았다** — 페이지는 404 인데 구글은 계속 긁고,
|
||||
* 목록에는 열리지 않는 카드가 남는다(실측 2026-09-15).
|
||||
* ★ 환경변수로 받지 않는다. 이미 나가 있는 페이지가 **자기 주소로 선언한 값**이 유일한
|
||||
* 정답이다 — 호스트 설정이 바뀐 뒤라면 새 값으로 색인을 다시 쓰는 편이 오히려 틀린다
|
||||
* (그 페이지들의 canonical 은 여전히 옛 주소다).
|
||||
*/
|
||||
export function readBakedOrigin(html: string): string {
|
||||
const match = /<link rel="canonical" href="(https?:\/\/[^/"]+)/.exec(html);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* `/s/` 발행 사이트 목록 페이지.
|
||||
*
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {deriveSurfaces, type SitePayload} from '@o2o/shared';
|
||||
import {deriveSurfaces, SiteStatus, type SitePayload} from '@o2o/shared';
|
||||
import {collectJsonLd} from './jsonld';
|
||||
import type {PageMeta} from './meta';
|
||||
|
||||
@ -153,11 +153,36 @@ export function renderHead({payload, meta, scriptSrc, cssHrefs = []}: HeadOption
|
||||
*/
|
||||
...(meta.keywords ? [tag('meta', {name: 'keywords', content: meta.keywords})] : []),
|
||||
tag('link', {rel: 'canonical', href: meta.canonical}),
|
||||
// 색인을 막을 이유가 없다 — 이 사이트는 색인되라고 존재한다.
|
||||
/*
|
||||
* ★ **발행된 사이트만** 색인된다.
|
||||
*
|
||||
* 예전엔 `index, follow` 를 박아 두고 "색인을 막을 이유가 없다"고 적어 뒀는데, 그건
|
||||
* 굽는 것이 곧 발행이던 시절의 말이다. 지금은 사장님이 빌더에서 미리보기를 누르면
|
||||
* draft 상태로도 구워진다 — 그 결과가 그대로 검색에 나갔다.
|
||||
*
|
||||
* 실측(2026-09-15): 디스크의 발행본 33곳 중 **15곳이 draft 인데 `index, follow`** 였고
|
||||
* 사이트맵에도 올라가 있었다. 사장님이 발행 버튼을 누른 적 없는 사이트가,
|
||||
* 짓다 만 상태로 구글에 실려 있었다는 뜻이다.
|
||||
*
|
||||
* ★ 여기만 고치면 사이트맵·`/s` 목록·llms.txt 에서도 함께 빠진다 —
|
||||
* 그쪽은 구운 HTML 의 robots 를 읽어 거른다(seo/directory.ts readBakedNoindex).
|
||||
* 두 자리에 규칙을 두지 않기 위해 판정을 이 한 곳에 둔다.
|
||||
*/
|
||||
tag('meta', {
|
||||
name: 'robots',
|
||||
content: 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1',
|
||||
content:
|
||||
site.status === SiteStatus.PUBLISHED
|
||||
? 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1'
|
||||
: // follow 는 남긴다 — 색인은 막되 링크는 타게 둔다(자산·하위 경로 발견용).
|
||||
'noindex, follow',
|
||||
}),
|
||||
/*
|
||||
* ★ 파비콘. 발행본에는 아예 없어서 브라우저 탭에 기본 아이콘이 떴다(실측 2026-09-15).
|
||||
* 파일은 오리진 루트의 공용 자산이라 사이트마다 복사하지 않는다 — `basePath` 가 아니라
|
||||
* 루트 절대경로로 가리킨다(robots·sitemap 과 같은 자리다).
|
||||
*/
|
||||
tag('link', {rel: 'icon', type: 'image/svg+xml', href: '/brand/favicon-w4a.svg'}),
|
||||
tag('link', {rel: 'apple-touch-icon', href: '/apple-touch-icon.png'}),
|
||||
tag('meta', {'http-equiv': 'content-language', content: 'ko-KR'}),
|
||||
/*
|
||||
* ★ 노치·상태바를 템플릿 색으로 채운다 (2026-09-09, 사장님: "모바일에서 노치가 투명으로 되던데")
|
||||
|
||||
@ -366,6 +366,11 @@ function priceRange(payload: SitePayload): string | undefined {
|
||||
return min === max ? `${fmt(min)}원` : `${fmt(min)}원 ~ ${fmt(max)}원`;
|
||||
}
|
||||
|
||||
/** FAQ 가 화면에 그려지는가 — 섹션이 켜져 있을 때만이다(HomePage 가 enabled 로 고른다). */
|
||||
function isFaqOnScreen(payload: SitePayload): boolean {
|
||||
return payload.theme.sections.some((section) => section.id === 'faq' && section.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* FAQPage — AEO 에서 가장 크게 먹히는 마크업.
|
||||
* "체크인 몇 시예요?" 같은 질문에 이 홈페이지가 답으로 잡히는 자리다.
|
||||
@ -373,6 +378,10 @@ function priceRange(payload: SitePayload): string | undefined {
|
||||
* ★ 문의 안내(TEMPLATE)는 싣지 않는다 — 답이 없는 문답이다(shared selectAnsweredFaqs).
|
||||
*/
|
||||
export function faqJsonLd(payload: SitePayload): Json | null {
|
||||
// ★ 화면에 FAQ 가 없으면 내보내지 않는다 (2026-09-14). 섹션이 꺼져 있으면 HomePage 가 FaqSection 을
|
||||
// 아예 그리지 않는데, 여기서만 실으면 **구조화 데이터가 화면 값과 다르다**로 발행이 통째로 막힌다.
|
||||
// 실측: FAQ 를 20개까지 채우자(COPY) 섹션을 꺼 둔 사이트에서 12건 불일치로 발행 실패.
|
||||
if (!isFaqOnScreen(payload)) return null;
|
||||
const faqs = selectAnsweredFaqs(payload.faqs);
|
||||
if (faqs.length === 0) return null;
|
||||
|
||||
|
||||
@ -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('');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user