소개문이 "군산시에 있는 스테이,머뭄입니다. 주차 가능." 한 줄이었다. 생성기 잘못이 아니라 **쓸 재료가 그것뿐**이었다 — 네이버 플레이스가 준 fact 3건이 전부이고, TourAPI 는 미등록, 예약 페이지와 인스타그램은 robots 가 자동 수집을 금지한다. 그런데 이 업소의 내력 (1925년 적산가옥 · 히로쓰 가옥 후문 옆 · A동 B동 컨셉)은 블로그·기사에 공개돼 있다. 그걸 가져오는 단계가 없었을 뿐이다. ★ 문장을 검색모델에게 시키지 않는다. 재료만 모으고 소개문은 지금처럼 Gemini 가 쓴다 — 소개문을 바로 시키면 그 문장의 근거를 우리가 못 갖고, ground_check 가 전부 반려한다. - services/prompts/place_research.py: 사실 조각을 **출처와 함께** 요구한다. 요금·객실 수· 체크인·취소 규정은 묻지 않는다 — 그건 fact 이고 블로그의 옛값이 섞이면 예약 클레임이다 - services/grounding/place_research.py: 출처 없는 항목은 버린다(story 와 같은 규율) + **상호 대조**를 더한다. 지역 이야기는 틀려도 지역 이야기지만, 업소 조사가 틀리면 남의 가게 이야기가 이 사장님 소개문이 된다 — 이 레포에서 가장 비싼 실수다 - services/place_research.py: 조사 → place_channels.raw 에 근거 적재. **확정하지 않는다** — 남이 쓴 글이라 공식 채널·sameAs 로 나가면 안 된다. 새 표를 만들지 않았다 - copy_service: 확정 링크만 읽던 근거를 raw.kind=research 까지 넓혔다. 확정 여부는 "화면에 채널로 낼 것인가" 의 판단이지 "근거로 읽을 것인가" 의 판단이 아니다 - prompts/copy: 조사 기록을 fact 목록이 아니라 **별도 절**로 준다(fact 자리에 섞으니 모델이 값 하나로 읽고 안 썼다). 소개문 분량 100~250자 → 200~600자·2~3문단 — 옛 길이로는 확인된 사실을 나열하면 끝나 기록이 들어갈 자리가 없었다 - collect_service: 수집이 끝난 **뒤** 조사한다. 앞에 두면 네이버·TourAPI 가 이미 준 것을 다시 묻는 꼴이라 검색 요금이 헛돈다 실측(스테이,머뭄): 조사 8건 채택·0건 버림 → 소개문이 "1925년에 지어진 100년 된 적산가옥을 리노베이션한 숙소 … 히로쓰 가옥 후문 바로 옆" 으로. 발행본 본문 10,798자 → 11,100자. 생성물은 여전히 PENDING_OWNER 로 들어가 사장님이 확인해야 노출된다(절대규칙 1).
197 lines
8.4 KiB
Python
197 lines
8.4 KiB
Python
"""소개문·메타설명·FAQ 생성 — 겹들을 엮어 결과를 만드는 자리.
|
|
|
|
이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다:
|
|
|
|
무엇을 묻는가 services/prompts/copy.py 프롬프트·응답 스키마
|
|
어떻게 부르는가 services/llm/gemini.py HTTP·재시도·토큰·비용
|
|
답을 믿을 것인가 services/grounding/copy.py ground_check · faq_polarity_ok
|
|
무엇을 돌려주는가 여기 근거 모으기 → 호출 → 검증 → 조립
|
|
|
|
한때 이 네 가지가 한 파일 500줄에 뭉쳐 있었다. "FAQ 답이 이상하다" 를 고치러 와도
|
|
어디를 봐야 할지가 파일 안에서 갈리지 않았다.
|
|
"""
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
|
|
from common.enums import PlaceCategory
|
|
from common.logger import LOG
|
|
from services.grounding.copy import FactInput, faq_polarity_ok, ground_check
|
|
from services.llm.gemini import (
|
|
DEFAULT_MODEL as DEFAULT_TEXT_MODEL,
|
|
GeminiError,
|
|
GeminiInvalidOutput,
|
|
GeminiNotConfigured,
|
|
Usage,
|
|
call,
|
|
extract_text,
|
|
is_configured,
|
|
price,
|
|
read_usage,
|
|
)
|
|
from services.prompts.copy import RESPONSE_SCHEMA, build_prompt
|
|
|
|
|
|
@dataclass
|
|
class GeneratedFaq:
|
|
question: str
|
|
answer: str
|
|
fact_keys: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class GeneratedCopy:
|
|
"""생성 결과. 검증을 통과한 것만 담긴다.
|
|
|
|
rejected 에는 (버린 내용, 사유) 가 들어간다 — 조용히 버리지 않는다.
|
|
운영자가 "왜 소개문이 안 나왔나" 를 이 목록으로 읽는다."""
|
|
|
|
intro: Optional[str] = None
|
|
intro_fact_keys: list[str] = field(default_factory=list)
|
|
meta_description: Optional[str] = None
|
|
faqs: list[GeneratedFaq] = field(default_factory=list)
|
|
rejected: list[tuple[str, str]] = field(default_factory=list)
|
|
|
|
|
|
def _unit_facts(unit_summaries: Optional[list[dict]]) -> list[FactInput]:
|
|
"""객실·프로그램 요약을 근거 fact 로 펼친다.
|
|
|
|
{"name": "A동", "facts": {"max_capacity": "4"}} → FactInput("A동:max_capacity", …)
|
|
이렇게 해야 "최대 4명" 같은 문장이 근거 있는 것으로 통과한다.
|
|
|
|
★ `labels` 가 함께 오면 스키마 라벨·단위를 쓴다({key: {"label","unit"}}).
|
|
이 목록은 프롬프트에도 그대로 실리므로, 라벨이 없으면 모델이 'weekday_price' 라는
|
|
날 key 를 보고 글을 쓴다 — "weekday_price는 20000입니다" 같은 문장이 나온다.
|
|
없으면 지금까지처럼 key 를 라벨 자리에 둔다(호출측이 스키마를 모를 수 있다).
|
|
"""
|
|
out: list[FactInput] = []
|
|
for unit in unit_summaries or []:
|
|
name = str(unit.get("name") or "").strip()
|
|
labels = unit.get("labels") or {}
|
|
if name:
|
|
out.append(FactInput(key=f"unit:{name}", label="객실·프로그램명", value=name))
|
|
for key, value in (unit.get("facts") or {}).items():
|
|
if value is None or str(value).strip() == "":
|
|
continue
|
|
spec = labels.get(key) or {}
|
|
out.append(FactInput(
|
|
key=f"{name}:{key}" if name else key,
|
|
label=spec.get("label") or key,
|
|
value=str(value),
|
|
unit=spec.get("unit"),
|
|
))
|
|
return out
|
|
|
|
|
|
def _valid_keys(claimed: list, allowed: set[str]) -> list[str]:
|
|
"""모델이 적어준 근거 key 중 실제로 존재하는 것만 남긴다(없는 key 를 지어내기도 한다)."""
|
|
return [k for k in (claimed or []) if isinstance(k, str) and k in allowed]
|
|
|
|
|
|
async def generate_copy(
|
|
place_name: str,
|
|
category: PlaceCategory,
|
|
facts: list[FactInput],
|
|
*,
|
|
unit_summaries: Optional[list[dict]] = None,
|
|
records: Optional[list[str]] = None,
|
|
max_faqs: int = 8,
|
|
model: str = DEFAULT_TEXT_MODEL,
|
|
max_retries: int = 2,
|
|
client: Optional[httpx.AsyncClient] = None,
|
|
) -> GeneratedCopy:
|
|
"""확보된 fact 만으로 소개문·메타설명·FAQ 를 만든다.
|
|
|
|
★ facts 가 비면 **API 를 호출하지 않고** 빈 결과를 돌려준다 —
|
|
근거 없이 문장을 쓰면 그게 곧 환각이다.
|
|
★ 생성 결과는 전부 ground_check 를 통과한 것만 담긴다. 통과 못 한 항목은 rejected 로 간다.
|
|
★ 생성 대상 필드는 업종 스키마의 allow_llm=True 인 것뿐이다(호출측이 필터링해서 넘긴다).
|
|
"""
|
|
if not is_configured():
|
|
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다")
|
|
# ★ 사업장 fact 가 없어도 객실·메뉴 근거가 있으면 쓴다. 요금표만 있는 모텔이 그 경우다 —
|
|
# "대실 20,000원" 은 근거 있는 사실이고, 손님이 가장 먼저 묻는 것이기도 하다.
|
|
unit_grounding = _unit_facts(unit_summaries)
|
|
if not facts and not unit_grounding:
|
|
LOG.i(f"[gemini-text] '{place_name}' 근거 fact 0건 — 생성하지 않는다(호출 없음)")
|
|
return GeneratedCopy(rejected=[("(전체)", "근거 fact 가 없다 — 생성하지 않았다")])
|
|
|
|
# 검증에 쓸 근거 = 넘겨받은 fact + 객실 요약 + 상호명(상호에 숫자가 있어도 근거로 본다)
|
|
grounding = list(facts) + unit_grounding
|
|
grounding.append(FactInput(key="place_name", label="상호명", value=place_name))
|
|
allowed_keys = {f.key for f in facts} | {f.key for f in grounding}
|
|
|
|
body = {
|
|
"contents": [{"role": "user", "parts": [{
|
|
"text": build_prompt(place_name, category, facts, max_faqs, unit_grounding, records)
|
|
}]}],
|
|
"generationConfig": {
|
|
"responseMimeType": "application/json",
|
|
"responseSchema": RESPONSE_SCHEMA,
|
|
"temperature": 0.2,
|
|
},
|
|
}
|
|
|
|
owns_client = client is None
|
|
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
|
|
try:
|
|
payload = await call(client, model, body, max_retries)
|
|
parsed = json.loads(extract_text(payload))
|
|
except json.JSONDecodeError as ex:
|
|
raise GeminiInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex
|
|
finally:
|
|
if owns_client:
|
|
await client.aclose()
|
|
|
|
usage = read_usage(payload)
|
|
|
|
result = GeneratedCopy()
|
|
|
|
# ── 소개문 ──
|
|
intro = (parsed.get("intro") or "").strip()
|
|
if intro:
|
|
ok, reasons = ground_check(intro, grounding)
|
|
if ok:
|
|
result.intro = intro
|
|
result.intro_fact_keys = _valid_keys(parsed.get("intro_fact_keys"), allowed_keys)
|
|
else:
|
|
result.rejected.append((intro, " / ".join(reasons)))
|
|
|
|
# ── 메타 설명 ──
|
|
meta_desc = (parsed.get("meta_description") or "").strip()
|
|
if meta_desc:
|
|
ok, reasons = ground_check(meta_desc, grounding)
|
|
if ok:
|
|
result.meta_description = meta_desc
|
|
else:
|
|
result.rejected.append((meta_desc, " / ".join(reasons)))
|
|
|
|
# ── FAQ ── 항목마다 따로 검사한다. 하나가 걸려도 나머지는 산다.
|
|
for item in (parsed.get("faqs") or [])[:max_faqs]:
|
|
question = (item.get("question") or "").strip()
|
|
answer = (item.get("answer") or "").strip()
|
|
if not question or not answer:
|
|
continue
|
|
keys = _valid_keys(item.get("fact_keys"), allowed_keys)
|
|
if not keys:
|
|
# ★ 근거를 못 대는 FAQ 는 버린다 — 사실인지 확인할 방법이 없다.
|
|
result.rejected.append((question, "근거 fact_keys 가 없다"))
|
|
continue
|
|
ok, reasons = ground_check(f"{question} {answer}", grounding)
|
|
# 질문은 주장이 아니라 값-반대 판정에서 빠진다. 그 빈틈은 답변 쪽에서 따로 막는다.
|
|
polar_ok, polar_reasons = faq_polarity_ok(question, answer, grounding)
|
|
if not ok or not polar_ok:
|
|
result.rejected.append((question, " / ".join(reasons + polar_reasons)))
|
|
continue
|
|
result.faqs.append(GeneratedFaq(question=question, answer=answer, fact_keys=keys))
|
|
|
|
LOG.i(
|
|
f"[gemini-text] '{place_name}' 생성 — 소개문 {'O' if result.intro else 'X'} · "
|
|
f"메타 {'O' if result.meta_description else 'X'} · FAQ {len(result.faqs)}건 · "
|
|
f"반려 {len(result.rejected)}건 · model={model} · "
|
|
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
|
|
)
|
|
return result
|