[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:
parent
a0b14dfca1
commit
ef0c44c5f2
@ -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 모듈이 붙을 때 |
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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": "명" },
|
||||||
|
|||||||
43
solution/backend/services/catchphrase_text.py
Normal file
43
solution/backend/services/catchphrase_text.py
Normal 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
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
84
solution/backend/services/prompts/catchphrase.py
Normal file
84
solution/backend/services/prompts/catchphrase.py
Normal 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)
|
||||||
@ -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,
|
||||||
|
|||||||
26
solution/backend/tests/test_catchphrase_text.py
Normal file
26
solution/backend/tests/test_catchphrase_text.py
Normal 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"},
|
||||||
|
]
|
||||||
@ -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봄 | 봄에는 창을 열어 둡니다"
|
||||||
|
|||||||
@ -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["하조대펜션에서 쉬세요"] == "상호명 포함"
|
||||||
|
|||||||
@ -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: '생성 서비스 응답 실패 — 확인된 정보만으로 계속합니다',
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user