[feat] solution/backend: 업소 조사 — 소개문이 쓸 재료를 출처와 함께 찾아온다
소개문이 "군산시에 있는 스테이,머뭄입니다. 주차 가능." 한 줄이었다. 생성기 잘못이 아니라 **쓸 재료가 그것뿐**이었다 — 네이버 플레이스가 준 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).
This commit is contained in:
parent
ee51d89e83
commit
7b238efffb
@ -616,6 +616,18 @@ async def run_collect(job: dict) -> dict:
|
|||||||
from services import story_service
|
from services import story_service
|
||||||
result["local_job_id"] = await story_service.enqueue_region_job(place)
|
result["local_job_id"] = await story_service.enqueue_region_job(place)
|
||||||
|
|
||||||
|
# ── 업소 조사 — 소개문을 쓸 재료 ──────────────────────────────────
|
||||||
|
# ★ 수집이 끝난 **뒤**에 한다. 앞에서 하면 네이버·TourAPI 가 이미 준 것을 다시 묻는
|
||||||
|
# 꼴이고, 검색 요금이 그만큼 헛돈다. 수집이 얇게 끝났을 때 그 구멍을 메우는 자리다.
|
||||||
|
# ★ fact 를 만들지 않는다(place_research 머리주석) — 소개문 생성의 근거만 쌓는다.
|
||||||
|
# ★ 실패해도 수집은 성공이다. 재료가 적을 뿐 발행은 된다.
|
||||||
|
try:
|
||||||
|
from services import place_research
|
||||||
|
result["research"] = await place_research.research_place(place, place_id)
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
LOG.w(f"[collect] 업소 조사 실패(계속): {type(ex).__name__}: {ex}")
|
||||||
|
result["research"] = {"error": f"{type(ex).__name__}: {ex}"}
|
||||||
|
|
||||||
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
|
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
|
||||||
LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장")
|
LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장")
|
||||||
return result
|
return result
|
||||||
|
|||||||
@ -27,6 +27,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 place_research
|
||||||
from services.external import gemini_text
|
from services.external import gemini_text
|
||||||
from services.fact_service import FactService
|
from services.fact_service import FactService
|
||||||
|
|
||||||
@ -90,15 +91,31 @@ async def run_copy(job: dict) -> dict:
|
|||||||
# 왜 fact 로 넣지 않는가: `intro` 는 allow_llm=True 라 LLM 의 출력 칸이다.
|
# 왜 fact 로 넣지 않는가: `intro` 는 allow_llm=True 라 LLM 의 출력 칸이다.
|
||||||
# 원문을 그 칸에 넣었더니 457자 원문이 발행본의 '숙소 소개' 를 차지했다(2026-08-31).
|
# 원문을 그 칸에 넣었더니 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(
|
l_err, link_rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
place_channels.DBType(),
|
place_channels.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
DBWRType.DB_READ.value,
|
||||||
lambda s: _place_crud.list_links(s, pid, True),
|
lambda s: _place_crud.list_links(s, pid, False),
|
||||||
)
|
)
|
||||||
if l_err == ErrorType.SUCCESS:
|
if l_err == ErrorType.SUCCESS:
|
||||||
for link in (link_rows or []):
|
for link in (link_rows or []):
|
||||||
text = ((link.raw or {}).get("text") or "").strip() if isinstance(link.raw, dict) else ""
|
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:
|
if text:
|
||||||
|
# ★ fact 목록이 아니라 records 로 넘긴다. fact 자리에 넣으면 모델이 값 하나로
|
||||||
|
# 읽고 거의 쓰지 않는다(prompts/copy.build_prompt 머리주석의 실측).
|
||||||
|
records.append(text[:4000])
|
||||||
|
# ground_check 는 여전히 이 글을 근거로 인정해야 한다 — 근거 목록에도 남긴다.
|
||||||
grounded.append(gemini_text.FactInput(
|
grounded.append(gemini_text.FactInput(
|
||||||
key=f"source:{link.link_id}", label="수집 원문", value=text[:4000],
|
key=f"source:{link.link_id}", label="수집 원문", value=text[:4000],
|
||||||
))
|
))
|
||||||
@ -148,6 +165,7 @@ async def run_copy(job: dict) -> dict:
|
|||||||
PlaceCategory(place.category),
|
PlaceCategory(place.category),
|
||||||
grounded,
|
grounded,
|
||||||
unit_summaries=unit_summaries or None,
|
unit_summaries=unit_summaries or None,
|
||||||
|
records=records or None,
|
||||||
model=external_api_config.gemini_text_model,
|
model=external_api_config.gemini_text_model,
|
||||||
)
|
)
|
||||||
except gemini_text.GeminiNotConfigured as ex:
|
except gemini_text.GeminiNotConfigured as ex:
|
||||||
|
|||||||
@ -96,6 +96,7 @@ async def generate_copy(
|
|||||||
facts: list[FactInput],
|
facts: list[FactInput],
|
||||||
*,
|
*,
|
||||||
unit_summaries: Optional[list[dict]] = None,
|
unit_summaries: Optional[list[dict]] = None,
|
||||||
|
records: Optional[list[str]] = None,
|
||||||
max_faqs: int = 8,
|
max_faqs: int = 8,
|
||||||
model: str = DEFAULT_TEXT_MODEL,
|
model: str = DEFAULT_TEXT_MODEL,
|
||||||
max_retries: int = 2,
|
max_retries: int = 2,
|
||||||
@ -124,7 +125,7 @@ async def generate_copy(
|
|||||||
|
|
||||||
body = {
|
body = {
|
||||||
"contents": [{"role": "user", "parts": [{
|
"contents": [{"role": "user", "parts": [{
|
||||||
"text": build_prompt(place_name, category, facts, max_faqs, unit_grounding)
|
"text": build_prompt(place_name, category, facts, max_faqs, unit_grounding, records)
|
||||||
}]}],
|
}]}],
|
||||||
"generationConfig": {
|
"generationConfig": {
|
||||||
"responseMimeType": "application/json",
|
"responseMimeType": "application/json",
|
||||||
|
|||||||
90
solution/backend/services/grounding/place_research.py
Normal file
90
solution/backend/services/grounding/place_research.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"""업소 조사 응답 해석 — 쓸 수 있는 항목만 남긴다.
|
||||||
|
|
||||||
|
`grounding/story.py` 와 같은 규율이다. 다른 점은 하나: 여기서 나온 문장은 **화면에 그대로
|
||||||
|
나가지 않고** 소개문 생성의 근거로만 쓰인다(`copy_service` 의 '수집 원문' 자리). 그래도
|
||||||
|
출처를 똑같이 요구한다 — 근거가 거짓이면 그 근거로 쓴 문장도 거짓이고, ground_check 는
|
||||||
|
"근거에 있는가" 만 보지 "근거가 참인가" 는 못 본다.
|
||||||
|
|
||||||
|
★ 상호 대조를 한다
|
||||||
|
story 와 결정적으로 다른 지점이다. 지역 이야기는 틀려도 "군산 이야기가 조금 부정확한"
|
||||||
|
것이지만, 업소 조사가 틀리면 **남의 가게 이야기가 이 사장님 사이트의 소개문**이 된다 —
|
||||||
|
이 레포에서 가장 비싼 실수다(`collect_service.discover_naver_place` 머리주석).
|
||||||
|
그래서 출처 URL 이나 문장에 상호가 나타나지 않는 항목은 버린다.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
from common.logger import LOG
|
||||||
|
|
||||||
|
_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_text(payload: dict) -> str:
|
||||||
|
choices = payload.get("choices") or []
|
||||||
|
if not choices or not isinstance(choices[0], dict):
|
||||||
|
return ""
|
||||||
|
return ((choices[0].get("message") or {}).get("content")) or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_source(value) -> dict | None:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
url = (value.get("url") or "").strip()
|
||||||
|
if not url.startswith("http"):
|
||||||
|
return None
|
||||||
|
return {"name": (value.get("name") or url).strip(), "url": url}
|
||||||
|
|
||||||
|
|
||||||
|
def _name_tokens(name: str) -> list[str]:
|
||||||
|
"""상호를 대조에 쓸 조각으로. 쉼표·공백·가운뎃점으로 끊는다.
|
||||||
|
|
||||||
|
★ 통짜로 비교하면 안 된다 — '스테이,머뭄' 은 블로그에서 '스테이 머뭄' · '스테이머뭄' 으로
|
||||||
|
적힌다. 두 글자 이상인 조각이 하나라도 걸리면 같은 업소로 본다.
|
||||||
|
"""
|
||||||
|
parts = [p for p in re.split(r"[\s,·・/|]+", name or "") if len(p) >= 2]
|
||||||
|
return parts or ([name] if name else [])
|
||||||
|
|
||||||
|
|
||||||
|
def parse_items(payload: dict, place_name: str, limit: int) -> tuple[list[dict], list[str]]:
|
||||||
|
"""(쓸 수 있는 항목, 버린 이유). 버린 이유는 로그와 잡 결과에 남긴다 — 조용히 버리면
|
||||||
|
"조사가 부실한 것"과 "필터가 과한 것"을 구분할 수 없다."""
|
||||||
|
text = _FENCE_RE.sub("", _payload_text(payload)).strip()
|
||||||
|
if not text:
|
||||||
|
return [], ["응답이 비었다"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
except json.JSONDecodeError as ex:
|
||||||
|
LOG.w(f"[research] JSON 이 아니다: {ex}")
|
||||||
|
return [], [f"JSON 파싱 실패: {ex}"]
|
||||||
|
|
||||||
|
rows = parsed.get("items") if isinstance(parsed, dict) else parsed
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return [], ["items 배열이 없다"]
|
||||||
|
|
||||||
|
tokens = _name_tokens(place_name)
|
||||||
|
items: list[dict] = []
|
||||||
|
dropped: list[str] = []
|
||||||
|
|
||||||
|
for row in rows[: limit * 2]: # 버려질 것을 감안해 넉넉히 보되, 채택은 limit 까지다
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
dropped.append("항목이 객체가 아니다")
|
||||||
|
continue
|
||||||
|
sentence = str(row.get("text") or "").strip()
|
||||||
|
if not sentence:
|
||||||
|
dropped.append("빈 문장")
|
||||||
|
continue
|
||||||
|
source = _clean_source(row.get("source"))
|
||||||
|
if not source:
|
||||||
|
dropped.append(f"출처 없음: {sentence[:30]}")
|
||||||
|
continue
|
||||||
|
# ★ 상호 대조(머리주석). 문장과 출처 주소·이름 어디에도 상호가 없으면 남의 가게다.
|
||||||
|
haystack = f"{sentence} {source['url']} {source['name']}".lower()
|
||||||
|
if not any(tok.lower() in haystack for tok in tokens):
|
||||||
|
dropped.append(f"상호가 없다: {sentence[:30]}")
|
||||||
|
continue
|
||||||
|
items.append({"text": sentence, "source": source})
|
||||||
|
|
||||||
|
return items, dropped
|
||||||
127
solution/backend/services/place_research.py
Normal file
127
solution/backend/services/place_research.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
"""업소 조사 — 소개문을 쓸 **재료**를 공개 웹에서 찾아 근거 자리에 넣는다.
|
||||||
|
|
||||||
|
프롬프트 services/prompts/place_research.py 무엇을 묻나
|
||||||
|
호출 services/llm/perplexity.py HTTP·타임아웃·인증
|
||||||
|
믿을 것인가 services/grounding/place_research.py 출처 필수 · 상호 대조
|
||||||
|
여기 조사 → 근거 적재 (문장은 쓰지 않는다)
|
||||||
|
|
||||||
|
★ 이 모듈은 **문장을 쓰지 않는다.** 소개문은 지금처럼 `copy_service`(Gemini)가 쓴다.
|
||||||
|
여기가 하는 일은 그 생성기에게 줄 재료를 늘리는 것뿐이다. 왜 나누나 —
|
||||||
|
소개문을 검색모델에게 바로 시키면 그 문장의 근거를 우리가 갖지 못하고,
|
||||||
|
`ground_check` 가 근거 없는 문장을 전부 반려해 결국 앙상해진다.
|
||||||
|
|
||||||
|
★ 왜 필요했나 (실측 2026-09-10, 스테이,머뭄)
|
||||||
|
근거 fact 9건으로 생성한 소개문은 "군산시에 있는 스테이,머뭄입니다. 주차 가능." 이었다.
|
||||||
|
네이버 플레이스 3건 · TourAPI 미등록 · 예약 페이지와 인스타는 robots 금지 — 남은 공개
|
||||||
|
출처가 없어서지 생성기 잘못이 아니었다. 그런데 이 업소의 내력(1920년대 고택, 히로쓰 가옥
|
||||||
|
옆, 2024년 리모델링, A동·B동)은 블로그·기사에 있다. 그걸 출처와 함께 가져온다.
|
||||||
|
|
||||||
|
★ 요금은 조사하지 않는다. 블로그의 요금은 대개 옛값이고, 틀리면 예약 클레임이다.
|
||||||
|
fact 는 fact 경로(수집·사장님 확인)로만 들어온다 — 이 모듈은 `place_facts` 를 쓰지 않는다.
|
||||||
|
|
||||||
|
★ 적재 자리: `place_channels.raw` — `copy_service` 가 이미 '수집 원문'을 읽는 그 자리다.
|
||||||
|
새 표를 만들지 않는다. 대신 **확정하지 않는다**(`confirmed_at` NULL) — 조사 출처는
|
||||||
|
이 업소의 공식 채널이 아니라 남이 쓴 글이다. 발행본의 공식 채널·sameAs 에 나가면 안 된다.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.database.model.models import place_channels
|
||||||
|
from common.enums import ErrorType, LinkChannel, SourceType
|
||||||
|
from common.logger import LOG
|
||||||
|
from common.utils.gtime import GTime
|
||||||
|
from crud.place_crud import PlaceCRUD
|
||||||
|
from services.grounding import place_research as grounding
|
||||||
|
from services.llm import perplexity
|
||||||
|
from services.prompts import place_research as prompts
|
||||||
|
|
||||||
|
_place_crud = PlaceCRUD()
|
||||||
|
|
||||||
|
# 검색을 동반해 느리다. 지역 이야기와 같은 값을 쓴다(실측 건당 9~15초).
|
||||||
|
_TIMEOUT = 120.0
|
||||||
|
|
||||||
|
# ★ raw 봉투의 표식. `copy_service` 가 "확정되지 않았지만 근거로는 읽어도 되는 글" 을
|
||||||
|
# 이 값으로 가른다. 크롤 원문(확정 채널)과 섞이지 않게 이름을 붙여 둔다.
|
||||||
|
RAW_KIND = "research"
|
||||||
|
|
||||||
|
|
||||||
|
def _envelope(items: list[dict]) -> dict:
|
||||||
|
"""근거 봉투. text 는 `copy_service` 가 그대로 읽고, sources 는 추적용으로 남긴다.
|
||||||
|
|
||||||
|
★ 문장 뒤에 출처를 붙여 한 덩어리로 만든다 — 생성기가 문장만 보고 쓰더라도,
|
||||||
|
나중에 "이 소개문의 이 대목은 어디서 왔나" 를 raw 만 열어 보면 알 수 있어야 한다.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"kind": RAW_KIND,
|
||||||
|
"text": "\n".join(f"- {row['text']} (출처: {row['source']['name']})" for row in items),
|
||||||
|
"sources": [row["source"] for row in items],
|
||||||
|
"collected_at": GTime.UTC().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def research_place(place, place_id: str) -> dict:
|
||||||
|
"""업소 하나를 조사해 근거를 적재한다. 채택 건수와 버린 이유를 돌려준다.
|
||||||
|
|
||||||
|
실패는 예외로 올리지 않는다 — 조사가 없어도 발행은 되어야 한다(재료가 적을 뿐이다).
|
||||||
|
"""
|
||||||
|
name = (getattr(place, "name", None) or "").strip()
|
||||||
|
address = (getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip()
|
||||||
|
if not name or not address:
|
||||||
|
return {"skipped": "상호·주소를 모른다"}
|
||||||
|
if not perplexity.is_configured():
|
||||||
|
return {"skipped": "PERPLEXITY_API_KEY 미설정"}
|
||||||
|
|
||||||
|
# 업종 이름은 업종 스키마가 단일 출처다(`common/category_schema`) — 여기에 표를 또 적으면
|
||||||
|
# 업종이 늘 때 한쪽만 늘어난다.
|
||||||
|
from common.category_schema import get_schema
|
||||||
|
|
||||||
|
category_label = get_schema(place.category).label
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"model": perplexity.DEFAULT_MODEL,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": prompts.SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": prompts.build_prompt(name, address, category_label)},
|
||||||
|
],
|
||||||
|
"max_tokens": perplexity.DEFAULT_MAX_TOKENS,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
payload = await perplexity.call(body, client=client)
|
||||||
|
except perplexity.PerplexityNotConfigured:
|
||||||
|
return {"skipped": "PERPLEXITY_API_KEY 미설정"}
|
||||||
|
except perplexity.PerplexityError as ex:
|
||||||
|
LOG.w(f"[research] 호출 실패 place={place_id}: {ex}")
|
||||||
|
return {"error": str(ex)}
|
||||||
|
|
||||||
|
items, dropped = grounding.parse_items(payload, name, prompts.MAX_ITEMS)
|
||||||
|
LOG.i(f"[research] '{name}' 조사 {len(items)}건 채택, {len(dropped)}건 버림")
|
||||||
|
if not items:
|
||||||
|
return {"items": 0, "dropped": dropped}
|
||||||
|
|
||||||
|
# 출처 주소 하나를 대표로 링크에 단다 — 없는 URL 을 만들지 않기 위해 첫 출처를 쓴다.
|
||||||
|
url = items[0]["source"]["url"]
|
||||||
|
row = place_channels(
|
||||||
|
link_id=uuid.uuid4(),
|
||||||
|
place_id=uuid.UUID(place_id),
|
||||||
|
channel=LinkChannel.ETC.value,
|
||||||
|
url=url,
|
||||||
|
title=f"{name} 조사 근거",
|
||||||
|
discovered_by=SourceType.API.value,
|
||||||
|
discovered_at=GTime.UTC(),
|
||||||
|
raw=_envelope(items),
|
||||||
|
)
|
||||||
|
err = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[place_channels.DBType()], [lambda s: _place_crud.add_link(s, row)],
|
||||||
|
)
|
||||||
|
if err != ErrorType.SUCCESS:
|
||||||
|
# 이미 같은 URL 이 있으면 raw 만 갱신한다 — 재조사가 행을 늘리면 안 된다.
|
||||||
|
await DB_SESSION_MNG.execute_lambda_claim(
|
||||||
|
place_channels.DBType(),
|
||||||
|
lambda s: _place_crud.set_link_raw(s, uuid.UUID(place_id), url, _envelope(items)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"items": len(items), "dropped": dropped, "source": url}
|
||||||
@ -55,7 +55,16 @@ def build_prompt(
|
|||||||
facts: Sequence[FactLike],
|
facts: Sequence[FactLike],
|
||||||
max_faqs: int,
|
max_faqs: int,
|
||||||
unit_facts: Optional[Sequence[FactLike]] = None,
|
unit_facts: Optional[Sequence[FactLike]] = None,
|
||||||
|
records: Optional[Sequence[str]] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
"""소개문·메타·FAQ 생성 프롬프트.
|
||||||
|
|
||||||
|
★ `records` 는 fact 가 아니라 **글**이다(수집 원문 · 업소 조사 결과).
|
||||||
|
예전에는 이것도 fact 목록에 `- source:<uuid> (수집 원문) = …` 로 섞여 들어갔다.
|
||||||
|
모델은 그걸 값 하나로 읽고 거의 쓰지 않았다 — 실측(2026-09-10, 스테이,머뭄):
|
||||||
|
조사 근거 448자를 넣어도 소개문은 "주방 시설을 갖춘 독채형 객실을 운영하는
|
||||||
|
숙박업소입니다" 에서 한 발도 못 나갔다. 재료를 재료 자리에 놓아야 쓴다.
|
||||||
|
"""
|
||||||
sections = [
|
sections = [
|
||||||
f"'{place_name}'({_CATEGORY_LABEL.get(category, '사업장')})의 공식 홈페이지 문구를 작성한다.",
|
f"'{place_name}'({_CATEGORY_LABEL.get(category, '사업장')})의 공식 홈페이지 문구를 작성한다.",
|
||||||
"",
|
"",
|
||||||
@ -64,16 +73,26 @@ def build_prompt(
|
|||||||
]
|
]
|
||||||
if unit_facts:
|
if unit_facts:
|
||||||
sections.extend(["", "확인된 객실·메뉴 사실:", _fact_lines(unit_facts)])
|
sections.extend(["", "확인된 객실·메뉴 사실:", _fact_lines(unit_facts)])
|
||||||
|
if records:
|
||||||
|
sections.extend([
|
||||||
|
"",
|
||||||
|
"업소에 대해 확인된 기록(출처가 있는 글):",
|
||||||
|
*(f"- {line}" for line in records),
|
||||||
|
])
|
||||||
sections.extend([
|
sections.extend([
|
||||||
"",
|
"",
|
||||||
"출력:",
|
"출력:",
|
||||||
"- intro: 소개문 100~250자",
|
# ★ 100~250자였다. 그 길이로는 확인된 사실을 나열하면 끝나서, 기록이 있어도
|
||||||
|
# 들어갈 자리가 없었다. 시안(/s/stay)의 소개는 3문단 450자 안팎이다.
|
||||||
|
"- intro: 소개문 200~600자. 사실이 충분하면 2~3문단으로 나눈다(문단 사이 빈 줄)",
|
||||||
"- intro_fact_keys: 소개문의 근거 key",
|
"- intro_fact_keys: 소개문의 근거 key",
|
||||||
"- meta_description: 검색 요약 50~120자",
|
"- meta_description: 검색 요약 50~120자",
|
||||||
f"- faqs: 최대 {max_faqs}개, 각 항목에 근거 fact_keys 포함",
|
f"- faqs: 최대 {max_faqs}개, 각 항목에 근거 fact_keys 포함",
|
||||||
"",
|
"",
|
||||||
"규칙:",
|
"규칙:",
|
||||||
"- 위 사실에 없는 숫자·시설·지역 정보를 지어내지 마라.",
|
"- 위 사실에 없는 숫자·시설·지역 정보를 지어내지 마라.",
|
||||||
|
"- **기록 절에 있는 내용은 적극적으로 쓴다.** 그것도 출처가 확인된 사실이다 —"
|
||||||
|
" 건물의 내력·공간 구성·주변과의 관계처럼 이 업소만의 이야기가 거기 있다.",
|
||||||
"- false·불가·없음 값을 가능하다고 표현하지 않는다.",
|
"- false·불가·없음 값을 가능하다고 표현하지 않는다.",
|
||||||
"- 홍보성·평가성 표현을 쓰지 않는다.",
|
"- 홍보성·평가성 표현을 쓰지 않는다.",
|
||||||
"- 근거 없는 FAQ는 만들지 않는다.",
|
"- 근거 없는 FAQ는 만들지 않는다.",
|
||||||
|
|||||||
60
solution/backend/services/prompts/place_research.py
Normal file
60
solution/backend/services/prompts/place_research.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"""업소 조사 프롬프트 — 소개문을 쓸 **근거**를 공개 웹에서 찾아온다.
|
||||||
|
|
||||||
|
★ 무엇을 요구하나
|
||||||
|
"소개문을 써 달라"가 아니다. **사실 조각을 출처와 함께** 달라고 한다.
|
||||||
|
소개문을 모델에게 바로 시키면 그 문장이 어디서 왔는지 알 수 없고, 우리 규칙은
|
||||||
|
근거 없는 문장을 발행하지 않는다(`copy_service.ground_check`). 그래서 이 단계는
|
||||||
|
재료만 모으고, 문장은 기존 생성기(Gemini)가 그 재료로 쓴다.
|
||||||
|
|
||||||
|
★ 왜 이게 필요한가 (실측 2026-09-10, 스테이,머뭄)
|
||||||
|
네이버 플레이스가 주는 fact 는 3건(주차·와이파이·휠체어)뿐이고 TourAPI 는 미등록,
|
||||||
|
예약 페이지와 인스타그램은 robots 가 자동 수집을 금지한다. 그 상태로 소개문을 생성하면
|
||||||
|
"군산시에 있는 스테이,머뭄입니다. 주차 가능." 한 줄이 나온다 — 쓸 재료가 그것뿐이라
|
||||||
|
생성기 잘못이 아니다. 그런데 이 업소에 대한 사실(1920년대 고택 · 히로쓰 가옥 옆 ·
|
||||||
|
2024년 리모델링 · A동/B동)은 블로그·기사에 공개돼 있다. 그걸 **출처와 함께** 가져오는
|
||||||
|
자리가 없었을 뿐이다.
|
||||||
|
|
||||||
|
★ 지어내게 두지 않는다
|
||||||
|
- 항목마다 출처 URL 을 요구한다. 없으면 버린다(`grounding/place_research.py`).
|
||||||
|
- 확인할 수 없는 것은 비우라고 명시한다. 모델은 빈칸을 싫어해서, 안 그러면 채운다.
|
||||||
|
- **가격·객실 수·운영 규정은 묻지 않는다.** 그건 fact 이고, 틀리면 예약 클레임이 난다 —
|
||||||
|
출처가 블로그면 옛 요금이 그대로 올라온다. 이 단계가 모으는 것은 **소개문의 재료**다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = (
|
||||||
|
"당신은 지역 업소를 조사하는 사람이다. 웹에서 확인되는 사실만 적는다. "
|
||||||
|
"확인되지 않으면 그 항목을 아예 빼라 — 추측하거나 일반론으로 채우지 마라. "
|
||||||
|
"출력은 JSON 하나뿐이고 코드펜스를 두르지 않는다."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ★ 최대 개수를 둔다. 많이 받아 봐야 소개문 한 문단이고, 길수록 옛 정보가 섞인다.
|
||||||
|
MAX_ITEMS = 8
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(name: str, address: str, category_label: str) -> str:
|
||||||
|
"""조사 프롬프트. 상호와 주소를 **둘 다** 준다 — 동명 업소를 가르는 유일한 단서다."""
|
||||||
|
return f"""다음 업소에 대해 웹에서 확인되는 사실을 모아라.
|
||||||
|
|
||||||
|
상호: {name}
|
||||||
|
주소: {address}
|
||||||
|
업종: {category_label}
|
||||||
|
|
||||||
|
[무엇을 찾나]
|
||||||
|
- 이 업소만의 특징: 건물의 내력·연식, 공간 구성, 주변 랜드마크와의 관계, 운영 방식
|
||||||
|
- 손님이 실제로 겪는 것: 어떤 사람이 어떤 목적으로 오는가, 무엇이 인상적이라고 말하는가
|
||||||
|
- 시기: 문을 연 때, 고쳐 지은 때
|
||||||
|
|
||||||
|
[적지 않을 것]
|
||||||
|
- 요금·객실 수·체크인 시각·취소 규정 — 이건 다른 경로로 확인한다. 옛 값이 섞이면 위험하다
|
||||||
|
- "아름다운", "최고의" 같은 형용사만 있는 문장
|
||||||
|
- 다른 업소 이야기. 상호와 주소가 위와 일치하는 곳만이다
|
||||||
|
|
||||||
|
[문장 쓰는 법]
|
||||||
|
- **각 문장에 업소 이름을 넣어라.** "이 숙소는…" 처럼 쓰지 마라 — 문장만 떼어 놔도 어느
|
||||||
|
업소 이야기인지 알 수 있어야 한다. 뒤에서 이 문장들만 모아 근거로 쓰기 때문이다.
|
||||||
|
- 한 문장에 사실 하나. 두 가지를 이어 붙이면 한쪽이 틀렸을 때 통째로 버리게 된다.
|
||||||
|
|
||||||
|
[형식] 아래 JSON 만 출력한다. 각 항목에 **그 사실이 적힌 페이지 주소**를 단다.
|
||||||
|
{{"items": [{{"text": "한 문장으로 적은 사실", "source": {{"name": "출처 이름", "url": "https://..."}}}}]}}
|
||||||
|
|
||||||
|
항목은 최대 {MAX_ITEMS}개. 출처를 댈 수 없는 항목은 넣지 마라 — 적게 주는 편이 낫다."""
|
||||||
Loading…
Reference in New Issue
Block a user