[feat] solution/backend: 수집 뒤 콘텐츠 채울 때 캐치프레이즈·순환 문구 생성 — 숙박만

가게별 첫 화면 문구를 만드는 곳이 없어 모든 숙박이 공통 문구 10개를 돌려 썼다.

- COPY 잡에 catchphrase 단계(prepare→generate→save→catchphrase→faq_fill), 숙박이 아니면 건너뜀
- prompts/catchphrase.py·gemini_text.generate_catchphrases: 캐치프레이즈 1 + 일반 20·계절 3×4·월 12·날씨 2×4.
  문구마다 길이·상호명·ground_check(근거 없는 시설·수치) 검사
- fact tagline·catchphrases(lodging 스키마, allow_llm). 순환 문구는 사장님이 줄 단위로 고치게 글로 저장
  (catchphrase_text: `봄 | ` · `9월 | ` · `비 | ` 머리)
- site_payload: narrative.tagline·catchphrases 로 넘기고 이용정보 표(facts)에서는 뺀다
- 빌더 생성 화면 단계 문구, DATA_MODEL·GENERATION_FLOW

테스트 6건 추가. 관련 20개 파일 386 passed · 실패 102건은 main 과 목록이 같다(기존 실패)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-30 17:13:24 +09:00
parent a0b14dfca1
commit ef0c44c5f2
13 changed files with 377 additions and 8 deletions

View File

@ -276,7 +276,7 @@ COPY 단계는 `jobs.progress`(JSONB)의 `steps`·`attempt`에 기록한다.
|---|---|---| |---|---|---|
| 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 | | 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 |
| 2 VISION | `vision_service.run_vision` | 사진 분류 + alt 생성 | | 2 VISION | `vision_service.run_vision` | 사진 분류 + alt 생성 |
| 3 COPY | `copy_service.run_copy` | 소개문·FAQ (확보된 fact 만 근거) | | 3 COPY | `copy_service.run_copy` | 소개문·FAQ · 숙박은 첫 화면 캐치프레이즈(fact `tagline`)와 순환 문구(fact `catchphrases`, 한 줄에 하나, `봄 \| ` · `9월 \| ` · `비 \| ` 머리) (확보된 fact 만 근거) |
| 4 BUILD | `build_service.run_build` | ★ 정적 빌드 + 발행 게이트 | | 4 BUILD | `build_service.run_build` | ★ 정적 빌드 + 발행 게이트 |
| 5 LOCAL_SYNC | `story_service.run_local_sync` | 지역 이야기 생성(지역당 1회) | | 5 LOCAL_SYNC | `story_service.run_local_sync` | 지역 이야기 생성(지역당 1회) |
| 6 AI_CHECK | 미구현 | reports 모듈이 붙을 때 | | 6 AI_CHECK | 미구현 | reports 모듈이 붙을 때 |

View File

@ -8,7 +8,7 @@
↓ ↓
새로고침 ──────────────────────→ GET /v1/job/{jobId} 새로고침 ──────────────────────→ GET /v1/job/{jobId}
↑ ↑
COPY 워커: prepare → generate → save → faq_fill COPY 워커: prepare → generate → save → catchphrase(숙박만) → faq_fill
각 단계 진입·완료 → jobs.progress(JSONB) 각 단계 진입·완료 → jobs.progress(JSONB)
↓ ↓
화면: 서버 단계 표시 → DONE일 때 데이터 갱신 → editor 화면: 서버 단계 표시 → DONE일 때 데이터 갱신 → editor

View File

@ -24,6 +24,8 @@
{ "key": "accommodation_capacity", "label": "수용 인원", "type": "number", "scope": "place", "required": false, "critical": false, "allow_llm": false, "unit": "명" }, { "key": "accommodation_capacity", "label": "수용 인원", "type": "number", "scope": "place", "required": false, "critical": false, "allow_llm": false, "unit": "명" },
{ "key": "building_scale", "label": "규모", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false }, { "key": "building_scale", "label": "규모", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "intro", "label": "숙소 소개", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true }, { "key": "intro", "label": "숙소 소개", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
{ "key": "tagline", "label": "캐치프레이즈", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
{ "key": "catchphrases", "label": "첫 화면 순환 문구", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
{ "key": "room_type", "label": "객실 타입", "type": "text", "scope": "unit", "required": true, "critical": false, "allow_llm": false }, { "key": "room_type", "label": "객실 타입", "type": "text", "scope": "unit", "required": true, "critical": false, "allow_llm": false },
{ "key": "standard_capacity", "label": "기준 인원", "type": "number", "scope": "unit", "required": true, "critical": true, "allow_llm": false, "unit": "명" }, { "key": "standard_capacity", "label": "기준 인원", "type": "number", "scope": "unit", "required": true, "critical": true, "allow_llm": false, "unit": "명" },

View File

@ -0,0 +1,43 @@
"""순환 문구를 fact 한 칸에 담는 글 형식 — 사장님이 빌더에서 줄 단위로 고칠 수 있게 JSON 이 아니라 글로 둔다.
한 줄에 한 문구. 앞에 `봄 | ` · `9월 | ` · `비 | ` 를 붙이면 계절·월·날씨 문구, 없으면 일반 문구다.
"""
import re
SEASONS = ("봄", "여름", "가을", "겨울")
WEATHERS = ("맑음", "흐림", "비", "눈")
_MONTH = re.compile(r"^(1[0-2]|[1-9])월$")
def encode(items: list[dict]) -> str:
lines = []
for item in items:
text = str(item.get("text") or "").strip()
if not text:
continue
kind = item.get("kind") or "general"
tag = {"season": item.get("season"), "month": f"{item.get('month')}월", "weather": item.get("weather")}.get(kind)
lines.append(f"{tag} | {text}" if tag else text)
return "\n".join(lines)
def decode(value: str) -> list[dict]:
items: list[dict] = []
for raw in (value or "").splitlines():
line = raw.strip()
if not line:
continue
tag, sep, rest = line.partition("|")
tag, rest = tag.strip(), rest.strip()
if sep and rest:
if tag in SEASONS:
items.append({"text": rest, "kind": "season", "season": tag})
continue
if tag in WEATHERS:
items.append({"text": rest, "kind": "weather", "weather": tag})
continue
if m := _MONTH.match(tag):
items.append({"text": rest, "kind": "month", "month": int(m.group(1))})
continue
items.append({"text": line, "kind": "general"})
return items

View File

@ -1,11 +1,15 @@
"""COPY 흐름.""" """COPY 흐름."""
from common.enums import PlaceCategory
from common.logger import LOG from common.logger import LOG
from services.copy_steps import CopyAborted, prepare_copy, generate_copy, save_copy, fill_faqs from services.copy_steps import (
CopyAborted, fill_faqs, generate_catchphrases, generate_copy, prepare_copy, save_catchphrases, save_copy,
)
from services.external import gemini_text from services.external import gemini_text
from services.llm import provider from services.llm import provider
from services.llm.errors import LlmError
from services.job_progress import JobProgress from services.job_progress import JobProgress
COPY_STEPS = ("prepare", "generate", "save", "faq_fill") COPY_STEPS = ("prepare", "generate", "save", "catchphrase", "faq_fill")
async def run_copy(job: dict) -> dict: async def run_copy(job: dict) -> dict:
@ -34,6 +38,18 @@ async def run_copy(job: dict) -> dict:
async with progress.step("save"): async with progress.step("save"):
result = await save_copy(inputs, copy) result = await save_copy(inputs, copy)
if inputs.place.category != PlaceCategory.LODGING.value:
await progress.skip("catchphrase", "not_lodging")
elif copy is None:
await progress.skip("catchphrase", "no_facts" if inputs.ungrounded else "not_configured")
else:
try:
async with progress.step("catchphrase"):
result["catchphrase"] = await save_catchphrases(inputs, await generate_catchphrases(inputs))
except LlmError as ex:
LOG.w(f"[copy] 첫 화면 문구 생성 실패, 공통 문구로 계속: {ex}")
await progress.skip("catchphrase", "generation_failed")
if inputs.catalog is not None: if inputs.catalog is not None:
async with progress.step("faq_fill"): async with progress.step("faq_fill"):
result["faq_fill"] = await fill_faqs( result["faq_fill"] = await fill_faqs(

View File

@ -21,7 +21,7 @@ from crud.fact_crud import FactCRUD
from crud.faq_crud import FaqCRUD from crud.faq_crud import FaqCRUD
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from router.v1.fact.protocol import Req_UpsertFact from router.v1.fact.protocol import Req_UpsertFact
from services import faq_fill, place_research from services import catchphrase_text, faq_fill, place_research
from services.external import gemini_text from services.external import gemini_text
from services.llm import provider from services.llm import provider
from services.fact_service import FactService from services.fact_service import FactService
@ -248,6 +248,47 @@ async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None)
return stat return stat
async def generate_catchphrases(inputs: CopyInputs) -> gemini_text.GeneratedCatchphrases:
active_provider = provider.active()
model = (
external_api_config.openai_text_model if active_provider.__name__.endswith("openai")
else external_api_config.gemini_text_model
)
try:
return await gemini_text.generate_catchphrases(
inputs.place.name, inputs.grounded, records=inputs.records or None, model=model,
)
except gemini_text.GeminiNotConfigured as ex:
raise CopyAborted(str(ex)) from ex
async def save_catchphrases(inputs: CopyInputs, generated: gemini_text.GeneratedCatchphrases) -> dict:
place_id = str(inputs.place.place_id)
stat = {"tagline": False, "catchphrases": 0, "rejected": [list(r) for r in generated.rejected][:20]}
actor = UserInfo(user_id=str(inputs.place.owner_user_id), id="generator", role=1)
service = FactService(_fact_crud, _place_crud)
values = (("tagline", generated.tagline), ("catchphrases", catchphrase_text.encode(generated.items)))
for key, value in values:
spec = inputs.schema.get(key)
if not (value or "").strip() or not (spec and spec.allow_llm):
continue
res = await service.upsert_fact(
actor, place_id,
Req_UpsertFact(
key=key, value=value.strip(),
source_type=SourceType.LLM, source_url=f"llm:{generated.source or external_api_config.gemini_text_model}",
),
)
if not res.result.success:
stat["rejected"].append([key, res.result.desc])
elif key == "tagline":
stat["tagline"] = True
else:
stat["catchphrases"] = len(generated.items)
LOG.i(f"[copy] place={place_id} 캐치프레이즈 {'O' if stat['tagline'] else 'X'} · 순환 {stat['catchphrases']}개")
return stat
async def fill_faqs(pid: uuid.UUID, catalog: FaqCatalog, known_fact_keys: set[str], phone: str | None) -> int: async def fill_faqs(pid: uuid.UUID, catalog: FaqCatalog, known_fact_keys: set[str], phone: str | None) -> int:
"""노출 중인 FAQ 가 목표 수에 모자란 만큼 문의 안내 문항을 넣는다.""" """노출 중인 FAQ 가 목표 수에 모자란 만큼 문의 안내 문항을 넣는다."""
l_err, rows = await DB_SESSION_MNG.execute_lambda( l_err, rows = await DB_SESSION_MNG.execute_lambda(

View File

@ -13,6 +13,7 @@ from services.llm import provider
from services.llm.errors import LlmError from services.llm.errors import LlmError
from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput
from services.llm.errors import LlmNotConfigured as GeminiNotConfigured from services.llm.errors import LlmNotConfigured as GeminiNotConfigured
from services.prompts import catchphrase as catchphrase_prompt
from services.prompts.copy import RESPONSE_SCHEMA, build_prompt from services.prompts.copy import RESPONSE_SCHEMA, build_prompt
@ -159,6 +160,90 @@ async def generate_copy(
return result return result
# ── 첫 화면 문구(generate_catchphrases) ─────────────────────────────────
@dataclass
class GeneratedCatchphrases:
tagline: Optional[str] = None
items: list[dict] = field(default_factory=list)
rejected: list[tuple[str, str]] = field(default_factory=list)
source: str = ""
async def generate_catchphrases(
place_name: str,
facts: list[FactInput],
*,
records: Optional[list[str]] = None,
model: Optional[str] = None,
max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None,
) -> GeneratedCatchphrases:
"""고정 캐치프레이즈 하나와 순환 문구 모음 — 문구마다 소개문과 같은 근거 검사를 거친다."""
llm = provider.active()
if not llm.is_configured():
raise GeminiNotConfigured(f"{llm.__name__.rsplit('.', 1)[-1].upper()}_API_KEY 가 설정되지 않았다")
model = model or llm.DEFAULT_MODEL
grounding = list(facts) + [FactInput(key="place_name", label="상호명", value=place_name)]
prompt = catchphrase_prompt.build_prompt(place_name, facts, records)
owns_client = client is None
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
try:
llm_result = await llm.generate(
client, model, prompt=prompt, response_schema=catchphrase_prompt.RESPONSE_SCHEMA,
temperature=0.7, max_retries=max_retries,
)
finally:
if owns_client:
await client.aclose()
parsed = llm_result.json or {}
result = GeneratedCatchphrases(source=f"{llm.__name__.rsplit('.', 1)[-1]}:{model}")
seen: set[str] = set()
def accept(text: object) -> Optional[str]:
line = str(text or "").strip()
if not line or line in seen:
return None
if not (catchphrase_prompt.MIN_LEN <= len(line) <= catchphrase_prompt.MAX_LEN):
result.rejected.append((line, f"길이 {len(line)}자"))
return None
if place_name and place_name in line:
result.rejected.append((line, "상호명 포함"))
return None
ok, reasons = ground_check(line, grounding)
if not ok:
result.rejected.append((line, " / ".join(reasons)))
return None
seen.add(line)
return line
result.tagline = accept(parsed.get("tagline"))
for text in (parsed.get("general") or [])[: catchphrase_prompt.COUNT_GENERAL]:
if line := accept(text):
result.items.append({"text": line, "kind": "general"})
for row in parsed.get("season") or []:
season = (row or {}).get("season")
if season in catchphrase_prompt.SEASONS and (line := accept(row.get("text"))):
result.items.append({"text": line, "kind": "season", "season": season})
for row in parsed.get("month") or []:
month = (row or {}).get("month")
if isinstance(month, int) and 1 <= month <= 12 and (line := accept(row.get("text"))):
result.items.append({"text": line, "kind": "month", "month": month})
for row in parsed.get("weather") or []:
weather = (row or {}).get("weather")
if weather in catchphrase_prompt.WEATHERS and (line := accept(row.get("text"))):
result.items.append({"text": line, "kind": "weather", "weather": weather})
usage = llm_result.usage
LOG.i(
f"[llm-text] '{place_name}' 첫 화면 문구 — 캐치프레이즈 {'O' if result.tagline else 'X'} · "
f"순환 {len(result.items)}개 · 반려 {len(result.rejected)}건 · model={model} · "
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${llm.price(model, usage)}"
)
return result
# ── 요약(summarize_text) ────────────────────────────────────────────────── # ── 요약(summarize_text) ──────────────────────────────────────────────────
_SUMMARY_CACHE: dict[str, str] = {} _SUMMARY_CACHE: dict[str, str] = {}
_SUMMARY_CACHE_MAX = 500 _SUMMARY_CACHE_MAX = 500

View File

@ -0,0 +1,84 @@
"""숙소 첫 화면 문구 — 고정 캐치프레이즈 하나와 그 아래 줄에서 도는 문구 모음."""
from typing import Optional, Sequence
from services.prompts.copy import FactLike, _fact_lines
SEASONS = ("봄", "여름", "가을", "겨울")
WEATHERS = ("맑음", "흐림", "비", "눈")
COUNT_GENERAL = 20
COUNT_PER_SEASON = 3
COUNT_PER_WEATHER = 2
MIN_LEN = 8
MAX_LEN = 30
RESPONSE_SCHEMA = {
"type": "object",
"properties": {
"tagline": {"type": "string"},
"general": {"type": "array", "items": {"type": "string"}},
"season": {
"type": "array",
"items": {
"type": "object",
"properties": {"season": {"type": "string", "enum": list(SEASONS)}, "text": {"type": "string"}},
"required": ["season", "text"],
},
},
"month": {
"type": "array",
"items": {
"type": "object",
"properties": {"month": {"type": "integer"}, "text": {"type": "string"}},
"required": ["month", "text"],
},
},
"weather": {
"type": "array",
"items": {
"type": "object",
"properties": {"weather": {"type": "string", "enum": list(WEATHERS)}, "text": {"type": "string"}},
"required": ["weather", "text"],
},
},
},
"required": ["tagline", "general", "season", "month", "weather"],
}
def build_prompt(
place_name: str,
facts: Sequence[FactLike],
records: Optional[Sequence[str]] = None,
) -> str:
sections = [
f"숙소 '{place_name}' 홈페이지 첫 화면 문구를 쓴다.",
"첫 화면은 숙소 이름 아래 **고정 캐치프레이즈 한 줄**, 그 아래 줄에서 짧은 문구가 몇 초마다 바뀐다.",
"",
"확인된 사실:",
_fact_lines(facts) if facts else "- 없음",
]
if records:
sections.extend(["", "출처가 있는 기록:", *(f"- {line}" for line in records)])
sections.extend([
"",
"출력:",
f"- tagline: 이 숙소만의 캐치프레이즈 1개, {MIN_LEN}~{MAX_LEN}자. 이 집을 한 줄로 알아보게 하는 문장",
f"- general: 계절·날씨와 상관없는 문구 {COUNT_GENERAL}개",
f"- season: 봄·여름·가을·겨울 각 {COUNT_PER_SEASON}개",
"- month: 1월~12월 각 1개",
f"- weather: 맑음·흐림·비·눈 각 {COUNT_PER_WEATHER}개. 그 날씨에 이 집에서 보이고 들리는 것",
f"- 모든 문구는 공백 포함 {MIN_LEN}~{MAX_LEN}자, 한 문장",
"",
"좋은 예(다른 숙소): '백 년 된 집에서 하룻밤' · '담 너머는 히로쓰 가옥입니다' ·"
" '마당 평상에 앉으면 항구 소리가 들립니다' · '이불에서 볕 냄새가 납니다'",
"",
"규칙:",
"- 위 사실과 기록에 있는 것만 쓴다. 없는 시설·경관·숫자·거리·역사를 지어내지 않는다.",
"- 사실이 적으면 머무는 시간·쉼처럼 확인이 필요 없는 말로 쓴다.",
"- 담백하게 쓴다. '최고', '완벽한', '특별한', '힐링' 같은 홍보 수식어와 느낌표를 쓰지 않는다.",
"- 문구끼리 표현이 겹치지 않게 한다. 상호명은 tagline 에도 넣지 않는다.",
"- false·불가·없음 값을 있는 것처럼 쓰지 않는다.",
"- 존댓말 평서문 또는 명사형으로 끝낸다.",
])
return "\n".join(sections)

View File

@ -18,6 +18,7 @@ from common.enums import (
) )
from common.logger import LOG from common.logger import LOG
from common.template_catalog import TEMPLATES, industry_of, resolve_template_id from common.template_catalog import TEMPLATES, industry_of, resolve_template_id
from services import catchphrase_text
from services.intro_summary import summarize_intro from services.intro_summary import summarize_intro
from services.stay_guide import nol_stay_guide from services.stay_guide import nol_stay_guide
from services.weather_notes import weather_notes from services.weather_notes import weather_notes
@ -33,6 +34,9 @@ DEFAULT_PAYLOAD_DIR = "/app/solution/site/payloads"
SITE_HOST_ENV = "SITE_PUBLIC_HOST" SITE_HOST_ENV = "SITE_PUBLIC_HOST"
DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost" DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost"
_HERO_COPY_KEYS = {"tagline", "catchphrases"}
def _scheme(host: str) -> str: def _scheme(host: str) -> str:
"""localhost 는 http 다.""" """localhost 는 http 다."""
return "http" if re.match(r"^(localhost|127\.0\.0\.1)(:\d+)?$", host) else "https" return "http" if re.match(r"^(localhost|127\.0\.0\.1)(:\d+)?$", host) else "https"
@ -596,6 +600,14 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
# 요약은 **첫 문장**이다. # 요약은 **첫 문장**이다.
"summary": _first_sentence(paragraphs[0]) if paragraphs else None, "summary": _first_sentence(paragraphs[0]) if paragraphs else None,
} }
tagline = _text(next((f["value"] for f in place_facts if f["key"] == "tagline" and f["value"]), ""))
if tagline:
narrative["tagline"] = tagline.strip()
rotating = catchphrase_text.decode(
_text(next((f["value"] for f in place_facts if f["key"] == "catchphrases" and f["value"]), ""))
)
if rotating:
narrative["catchphrases"] = {"version": 1, "items": rotating}
theme = _theme(site, category) theme = _theme(site, category)
@ -651,7 +663,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
), ),
"kakaoPlaceId": kakao_place_id or None, "kakaoPlaceId": kakao_place_id or None,
}, },
"facts": place_facts, "facts": [f for f in place_facts if f["key"] not in _HERO_COPY_KEYS],
"units": units, "units": units,
"media": media, "media": media,
"faqs": faqs, "faqs": faqs,

View File

@ -0,0 +1,26 @@
"""순환 문구 글 형식 — 사장님이 줄 단위로 고친 글이 그대로 계절·월·날씨 문구로 읽혀야 한다."""
from services import catchphrase_text
def test_round_trips_every_kind():
items = [
{"text": "백 년 된 집에서 하룻밤", "kind": "general"},
{"text": "봄에는 대문을 열어 둡니다", "kind": "season", "season": "봄"},
{"text": "새해 첫 아침을 이 집에서", "kind": "month", "month": 1},
{"text": "기와에 빗소리가 납니다", "kind": "weather", "weather": "비"},
]
text = catchphrase_text.encode(items)
assert text.splitlines() == [
"백 년 된 집에서 하룻밤",
"봄 | 봄에는 대문을 열어 둡니다",
"1월 | 새해 첫 아침을 이 집에서",
"비 | 기와에 빗소리가 납니다",
]
assert catchphrase_text.decode(text) == items
def test_unknown_tag_stays_a_general_line():
assert catchphrase_text.decode("장마 | 비가 옵니다\n\n 13월 | 없는 달 ") == [
{"text": "장마 | 비가 옵니다", "kind": "general"},
{"text": "13월 | 없는 달", "kind": "general"},
]

View File

@ -30,6 +30,15 @@ def _patch(monkeypatch, copy):
monkeypatch.setattr(gemini_text, "generate_copy", _gen) monkeypatch.setattr(gemini_text, "generate_copy", _gen)
async def _phrases(place_name, facts, **kw):
return gemini_text.GeneratedCatchphrases(
tagline="체크인 오후 세 시, 조용한 집",
items=[{"text": "짐을 풀면 골목부터 걸어 보세요", "kind": "general"},
{"text": "봄에는 창을 열어 둡니다", "kind": "season", "season": "봄"}],
)
monkeypatch.setattr(gemini_text, "generate_catchphrases", _phrases)
async def _place_with_facts(client, h, n_verified=5): async def _place_with_facts(client, h, n_verified=5):
pid = (await client.post("/v1/place", headers=h, json={"name": "카피펜션", "category": 1})).json()["place"]["place_id"] pid = (await client.post("/v1/place", headers=h, json={"name": "카피펜션", "category": 1})).json()["place"]["place_id"]
@ -96,7 +105,7 @@ async def test_resume_reuses_completed_job(auth_headers, client, db_engine, monk
job = (await client.get(f"/v1/job/{first['job_id']}", headers=h)).json()["job"] job = (await client.get(f"/v1/job/{first['job_id']}", headers=h)).json()["job"]
assert job["place_id"] == pid assert job["place_id"] == pid
assert [(s["id"], s["status"]) for s in job["progress"]["steps"]] == [ assert [(s["id"], s["status"]) for s in job["progress"]["steps"]] == [
("prepare", "done"), ("generate", "done"), ("save", "done"), ("faq_fill", "done"), ("prepare", "done"), ("generate", "done"), ("save", "done"), ("catchphrase", "done"), ("faq_fill", "done"),
] ]
fresh = (await client.post(path, headers=h, json={})).json() fresh = (await client.post(path, headers=h, json={})).json()
assert fresh["job_id"] != first["job_id"] assert fresh["job_id"] != first["job_id"]
@ -111,7 +120,7 @@ async def test_copy_progress_is_visible_during_generation(auth_headers, client,
async def generate(*args, **kwargs): async def generate(*args, **kwargs):
row = await JobQueue().get(started["job_id"]) row = await JobQueue().get(started["job_id"])
assert row["status"] == JobStatus.RUNNING.value assert row["status"] == JobStatus.RUNNING.value
assert [step["status"] for step in row["progress"]["steps"]] == ["done", "running", "pending", "pending"] assert [step["status"] for step in row["progress"]["steps"]] == ["done", "running", "pending", "pending", "pending"]
return _copy() return _copy()
monkeypatch.setattr(gemini_text, "generate_copy", generate) monkeypatch.setattr(gemini_text, "generate_copy", generate)
@ -303,3 +312,21 @@ async def test_copy_requires_api_key(auth_headers, client, monkeypatch):
pid = await _place_with_facts(client, h) pid = await _place_with_facts(client, h)
r = await client.post(f"/v1/place/{pid}/copy", headers=h, json={}) r = await client.post(f"/v1/place/{pid}/copy", headers=h, json={})
assert r.json()["result"]["code"] == ErrorType.GENERATOR_NOT_CONFIGURED.value assert r.json()["result"]["code"] == ErrorType.GENERATOR_NOT_CONFIGURED.value
async def test_copy_writes_one_catchphrase_and_the_rotating_lines(auth_headers, client, db_engine, monkeypatch):
_patch(monkeypatch, _copy())
h = await auth_headers("catchphrase-owner")
pid = await _place_with_facts(client, h)
job_id = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json()["job_id"]
await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain()
job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]
assert job["result"]["catchphrase"]["tagline"] is True
assert job["result"]["catchphrase"]["catchphrases"] == 2
async with db_engine.begin() as c:
rows = dict((await c.execute(
text("SELECT key, value FROM place_facts WHERE place_id = :p AND key IN ('tagline', 'catchphrases')"),
{"p": uuid.UUID(pid)},
)).all())
assert rows["tagline"] == "체크인 오후 세 시, 조용한 집"
assert rows["catchphrases"] == "짐을 풀면 골목부터 걸어 보세요\n봄 | 봄에는 창을 열어 둡니다"

View File

@ -377,3 +377,34 @@ async def test_summarize_empty_text_returns_none():
"""검증: 빈 문자열.""" """검증: 빈 문자열."""
result = await gt.summarize_text(" ") result = await gt.summarize_text(" ")
assert result is None assert result is None
# ── 첫 화면 문구 ───────────────────────────────────────────────────────────
def _phrase_payload(body):
return {
"candidates": [{"content": {"parts": [{"text": json.dumps(body, ensure_ascii=False)}]}, "finishReason": "STOP"}],
"usageMetadata": {"promptTokenCount": 800, "candidatesTokenCount": 400},
}
async def test_catchphrases_keep_only_grounded_lines_of_the_right_shape():
body = {
"tagline": "주차하고 바로 쉬는 집",
"general": ["짐을 풀면 골목부터 걸어 보세요", "오션뷰 수영장이 있습니다", "짧다", "하조대펜션에서 쉬세요"],
"season": [{"season": "봄", "text": "봄에는 창을 열어 둡니다"}, {"season": "장마", "text": "비가 오래 오는 계절입니다"}],
"month": [{"month": 1, "text": "새해 첫 아침을 조용히"}, {"month": 13, "text": "없는 달의 문구입니다"}],
"weather": [{"weather": "비", "text": "빗소리를 들으며 쉬세요"}],
}
async with _client(_ok(_phrase_payload(body))) as c:
res = await gt.generate_catchphrases("하조대펜션", FACTS, client=c)
assert res.tagline == "주차하고 바로 쉬는 집"
assert res.items == [
{"text": "짐을 풀면 골목부터 걸어 보세요", "kind": "general"},
{"text": "봄에는 창을 열어 둡니다", "kind": "season", "season": "봄"},
{"text": "새해 첫 아침을 조용히", "kind": "month", "month": 1},
{"text": "빗소리를 들으며 쉬세요", "kind": "weather", "weather": "비"},
]
reasons = dict(res.rejected)
assert "오션뷰 수영장이 있습니다" in reasons
assert reasons["짧다"].startswith("길이")
assert reasons["하조대펜션에서 쉬세요"] == "상호명 포함"

View File

@ -3,6 +3,7 @@ export const GENERATION_LABELS: Record<string, string> = {
prepare: '수집된 사진 분류 및 대체 텍스트 생성', prepare: '수집된 사진 분류 및 대체 텍스트 생성',
generate: '브랜드 컬러 시스템 및 타이포그래피 조합', generate: '브랜드 컬러 시스템 및 타이포그래피 조합',
save: '확인된 사실만으로 소개 콘텐츠 구성', save: '확인된 사실만으로 소개 콘텐츠 구성',
catchphrase: '첫 화면 캐치프레이즈와 순환 문구 작성',
faq_fill: '위치 기반 길찾기 연동 및 구조화 데이터(JSON-LD) 준비', faq_fill: '위치 기반 길찾기 연동 및 구조화 데이터(JSON-LD) 준비',
}; };
@ -10,5 +11,6 @@ export const SKIP_REASONS: Record<string, string> = {
no_facts: '확인된 정보 없음', no_facts: '확인된 정보 없음',
not_configured: '생성 서비스 미설정', not_configured: '생성 서비스 미설정',
no_catalog: '공통 질문 대상 업종 아님', no_catalog: '공통 질문 대상 업종 아님',
not_lodging: '숙박 업종만 해당',
generation_failed: '생성 서비스 응답 실패 — 확인된 정보만으로 계속합니다', generation_failed: '생성 서비스 응답 실패 — 확인된 정보만으로 계속합니다',
}; };