From ef0c44c5f22ecb9ac993c497497e3cd403dce289 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 30 Sep 2026 17:13:24 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend:=20=EC=88=98=EC=A7=91?= =?UTF-8?q?=20=EB=92=A4=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=B1=84=EC=9A=B8?= =?UTF-8?q?=20=EB=95=8C=20=EC=BA=90=EC=B9=98=ED=94=84=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=C2=B7=EC=88=9C=ED=99=98=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=E2=80=94=20=EC=88=99=EB=B0=95=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가게별 첫 화면 문구를 만드는 곳이 없어 모든 숙박이 공통 문구 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 --- docs/DATA_MODEL.md | 2 +- docs/GENERATION_FLOW.md | 2 +- .../category_schema/resources/lodging.json | 2 + solution/backend/services/catchphrase_text.py | 43 ++++++++++ solution/backend/services/copy_service.py | 20 ++++- solution/backend/services/copy_steps.py | 43 +++++++++- .../backend/services/external/gemini_text.py | 85 +++++++++++++++++++ .../backend/services/prompts/catchphrase.py | 84 ++++++++++++++++++ solution/backend/services/site_payload.py | 14 ++- .../backend/tests/test_catchphrase_text.py | 26 ++++++ solution/backend/tests/test_copy_api.py | 31 ++++++- solution/backend/tests/test_gemini_text.py | 31 +++++++ .../features/onboarding/generationLabels.ts | 2 + 13 files changed, 377 insertions(+), 8 deletions(-) create mode 100644 solution/backend/services/catchphrase_text.py create mode 100644 solution/backend/services/prompts/catchphrase.py create mode 100644 solution/backend/tests/test_catchphrase_text.py diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 77cdb1c..b41b933 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -276,7 +276,7 @@ COPY 단계는 `jobs.progress`(JSONB)의 `steps`·`attempt`에 기록한다. |---|---|---| | 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 | | 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` | ★ 정적 빌드 + 발행 게이트 | | 5 LOCAL_SYNC | `story_service.run_local_sync` | 지역 이야기 생성(지역당 1회) | | 6 AI_CHECK | 미구현 | reports 모듈이 붙을 때 | diff --git a/docs/GENERATION_FLOW.md b/docs/GENERATION_FLOW.md index 78fdb27..99fa5f4 100644 --- a/docs/GENERATION_FLOW.md +++ b/docs/GENERATION_FLOW.md @@ -8,7 +8,7 @@ ↓ 새로고침 ──────────────────────→ GET /v1/job/{jobId} ↑ -COPY 워커: prepare → generate → save → faq_fill +COPY 워커: prepare → generate → save → catchphrase(숙박만) → faq_fill 각 단계 진입·완료 → jobs.progress(JSONB) ↓ 화면: 서버 단계 표시 → DONE일 때 데이터 갱신 → editor diff --git a/solution/backend/common/category_schema/resources/lodging.json b/solution/backend/common/category_schema/resources/lodging.json index 30a5895..7bc0087 100644 --- a/solution/backend/common/category_schema/resources/lodging.json +++ b/solution/backend/common/category_schema/resources/lodging.json @@ -24,6 +24,8 @@ { "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": "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": "standard_capacity", "label": "기준 인원", "type": "number", "scope": "unit", "required": true, "critical": true, "allow_llm": false, "unit": "명" }, diff --git a/solution/backend/services/catchphrase_text.py b/solution/backend/services/catchphrase_text.py new file mode 100644 index 0000000..d0533f2 --- /dev/null +++ b/solution/backend/services/catchphrase_text.py @@ -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 diff --git a/solution/backend/services/copy_service.py b/solution/backend/services/copy_service.py index d4affca..d5d984b 100644 --- a/solution/backend/services/copy_service.py +++ b/solution/backend/services/copy_service.py @@ -1,11 +1,15 @@ """COPY 흐름.""" +from common.enums import PlaceCategory 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.llm import provider +from services.llm.errors import LlmError 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: @@ -34,6 +38,18 @@ async def run_copy(job: dict) -> dict: async with progress.step("save"): 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: async with progress.step("faq_fill"): result["faq_fill"] = await fill_faqs( diff --git a/solution/backend/services/copy_steps.py b/solution/backend/services/copy_steps.py index 48121b1..f1ce516 100644 --- a/solution/backend/services/copy_steps.py +++ b/solution/backend/services/copy_steps.py @@ -21,7 +21,7 @@ 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 import catchphrase_text, faq_fill, place_research from services.external import gemini_text from services.llm import provider from services.fact_service import FactService @@ -248,6 +248,47 @@ async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None) 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: """노출 중인 FAQ 가 목표 수에 모자란 만큼 문의 안내 문항을 넣는다.""" l_err, rows = await DB_SESSION_MNG.execute_lambda( diff --git a/solution/backend/services/external/gemini_text.py b/solution/backend/services/external/gemini_text.py index 2860271..e58fd80 100644 --- a/solution/backend/services/external/gemini_text.py +++ b/solution/backend/services/external/gemini_text.py @@ -13,6 +13,7 @@ from services.llm import provider from services.llm.errors import LlmError from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput 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 @@ -159,6 +160,90 @@ async def generate_copy( 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) ────────────────────────────────────────────────── _SUMMARY_CACHE: dict[str, str] = {} _SUMMARY_CACHE_MAX = 500 diff --git a/solution/backend/services/prompts/catchphrase.py b/solution/backend/services/prompts/catchphrase.py new file mode 100644 index 0000000..ec89c1e --- /dev/null +++ b/solution/backend/services/prompts/catchphrase.py @@ -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) diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index 8228b5d..8503774 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -18,6 +18,7 @@ from common.enums import ( ) from common.logger import LOG 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.stay_guide import nol_stay_guide from services.weather_notes import weather_notes @@ -33,6 +34,9 @@ DEFAULT_PAYLOAD_DIR = "/app/solution/site/payloads" SITE_HOST_ENV = "SITE_PUBLIC_HOST" DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost" +_HERO_COPY_KEYS = {"tagline", "catchphrases"} + + def _scheme(host: str) -> str: """localhost 는 http 다.""" 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, } + 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) @@ -651,7 +663,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = ), "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, "media": media, "faqs": faqs, diff --git a/solution/backend/tests/test_catchphrase_text.py b/solution/backend/tests/test_catchphrase_text.py new file mode 100644 index 0000000..8eb21a2 --- /dev/null +++ b/solution/backend/tests/test_catchphrase_text.py @@ -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"}, + ] diff --git a/solution/backend/tests/test_copy_api.py b/solution/backend/tests/test_copy_api.py index 749fbea..cb87fdb 100644 --- a/solution/backend/tests/test_copy_api.py +++ b/solution/backend/tests/test_copy_api.py @@ -30,6 +30,15 @@ def _patch(monkeypatch, copy): 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): 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"] 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"), + ("prepare", "done"), ("generate", "done"), ("save", "done"), ("catchphrase", "done"), ("faq_fill", "done"), ] fresh = (await client.post(path, headers=h, json={})).json() 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): 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"] + assert [step["status"] for step in row["progress"]["steps"]] == ["done", "running", "pending", "pending", "pending"] return _copy() 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) r = await client.post(f"/v1/place/{pid}/copy", headers=h, json={}) 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봄 | 봄에는 창을 열어 둡니다" diff --git a/solution/backend/tests/test_gemini_text.py b/solution/backend/tests/test_gemini_text.py index 39cad0a..7775efc 100644 --- a/solution/backend/tests/test_gemini_text.py +++ b/solution/backend/tests/test_gemini_text.py @@ -377,3 +377,34 @@ async def test_summarize_empty_text_returns_none(): """검증: 빈 문자열.""" result = await gt.summarize_text(" ") 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["하조대펜션에서 쉬세요"] == "상호명 포함" diff --git a/solution/frontend/src/features/onboarding/generationLabels.ts b/solution/frontend/src/features/onboarding/generationLabels.ts index 166937b..8cc09da 100644 --- a/solution/frontend/src/features/onboarding/generationLabels.ts +++ b/solution/frontend/src/features/onboarding/generationLabels.ts @@ -3,6 +3,7 @@ export const GENERATION_LABELS: Record = { prepare: '수집된 사진 분류 및 대체 텍스트 생성', generate: '브랜드 컬러 시스템 및 타이포그래피 조합', save: '확인된 사실만으로 소개 콘텐츠 구성', + catchphrase: '첫 화면 캐치프레이즈와 순환 문구 작성', faq_fill: '위치 기반 길찾기 연동 및 구조화 데이터(JSON-LD) 준비', }; @@ -10,5 +11,6 @@ export const SKIP_REASONS: Record = { no_facts: '확인된 정보 없음', not_configured: '생성 서비스 미설정', no_catalog: '공통 질문 대상 업종 아님', + not_lodging: '숙박 업종만 해당', generation_failed: '생성 서비스 응답 실패 — 확인된 정보만으로 계속합니다', };