From 7b238efffbb471e276a636d45e087906efdf8fc2 Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 10 Sep 2026 14:17:44 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend:=20=EC=97=85=EC=86=8C?= =?UTF-8?q?=20=EC=A1=B0=EC=82=AC=20=E2=80=94=20=EC=86=8C=EA=B0=9C=EB=AC=B8?= =?UTF-8?q?=EC=9D=B4=20=EC=93=B8=20=EC=9E=AC=EB=A3=8C=EB=A5=BC=20=EC=B6=9C?= =?UTF-8?q?=EC=B2=98=EC=99=80=20=ED=95=A8=EA=BB=98=20=EC=B0=BE=EC=95=84?= =?UTF-8?q?=EC=98=A8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 소개문이 "군산시에 있는 스테이,머뭄입니다. 주차 가능." 한 줄이었다. 생성기 잘못이 아니라 **쓸 재료가 그것뿐**이었다 — 네이버 플레이스가 준 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). --- solution/backend/services/collect_service.py | 12 ++ solution/backend/services/copy_service.py | 22 ++- .../backend/services/external/gemini_text.py | 3 +- .../services/grounding/place_research.py | 90 +++++++++++++ solution/backend/services/place_research.py | 127 ++++++++++++++++++ solution/backend/services/prompts/copy.py | 21 ++- .../services/prompts/place_research.py | 60 +++++++++ 7 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 solution/backend/services/grounding/place_research.py create mode 100644 solution/backend/services/place_research.py create mode 100644 solution/backend/services/prompts/place_research.py diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index c5c1950..da8ae51 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -616,6 +616,18 @@ async def run_collect(job: dict) -> dict: from services import story_service 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) LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장") return result diff --git a/solution/backend/services/copy_service.py b/solution/backend/services/copy_service.py index bd208cc..98c6e69 100644 --- a/solution/backend/services/copy_service.py +++ b/solution/backend/services/copy_service.py @@ -27,6 +27,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 place_research from services.external import gemini_text from services.fact_service import FactService @@ -90,15 +91,31 @@ async def run_copy(job: dict) -> dict: # 왜 fact 로 넣지 않는가: `intro` 는 allow_llm=True 라 LLM 의 출력 칸이다. # 원문을 그 칸에 넣었더니 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( place_channels.DBType(), 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: 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: + # ★ fact 목록이 아니라 records 로 넘긴다. fact 자리에 넣으면 모델이 값 하나로 + # 읽고 거의 쓰지 않는다(prompts/copy.build_prompt 머리주석의 실측). + records.append(text[:4000]) + # ground_check 는 여전히 이 글을 근거로 인정해야 한다 — 근거 목록에도 남긴다. grounded.append(gemini_text.FactInput( key=f"source:{link.link_id}", label="수집 원문", value=text[:4000], )) @@ -148,6 +165,7 @@ async def run_copy(job: dict) -> dict: PlaceCategory(place.category), grounded, unit_summaries=unit_summaries or None, + records=records or None, model=external_api_config.gemini_text_model, ) except gemini_text.GeminiNotConfigured as ex: diff --git a/solution/backend/services/external/gemini_text.py b/solution/backend/services/external/gemini_text.py index f5add58..83f03f1 100644 --- a/solution/backend/services/external/gemini_text.py +++ b/solution/backend/services/external/gemini_text.py @@ -96,6 +96,7 @@ async def generate_copy( 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, @@ -124,7 +125,7 @@ async def generate_copy( body = { "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": { "responseMimeType": "application/json", diff --git a/solution/backend/services/grounding/place_research.py b/solution/backend/services/grounding/place_research.py new file mode 100644 index 0000000..628863d --- /dev/null +++ b/solution/backend/services/grounding/place_research.py @@ -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 diff --git a/solution/backend/services/place_research.py b/solution/backend/services/place_research.py new file mode 100644 index 0000000..7172850 --- /dev/null +++ b/solution/backend/services/place_research.py @@ -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} diff --git a/solution/backend/services/prompts/copy.py b/solution/backend/services/prompts/copy.py index 35f3c21..6a5a00d 100644 --- a/solution/backend/services/prompts/copy.py +++ b/solution/backend/services/prompts/copy.py @@ -55,7 +55,16 @@ def build_prompt( facts: Sequence[FactLike], max_faqs: int, unit_facts: Optional[Sequence[FactLike]] = None, + records: Optional[Sequence[str]] = None, ) -> str: + """소개문·메타·FAQ 생성 프롬프트. + + ★ `records` 는 fact 가 아니라 **글**이다(수집 원문 · 업소 조사 결과). + 예전에는 이것도 fact 목록에 `- source: (수집 원문) = …` 로 섞여 들어갔다. + 모델은 그걸 값 하나로 읽고 거의 쓰지 않았다 — 실측(2026-09-10, 스테이,머뭄): + 조사 근거 448자를 넣어도 소개문은 "주방 시설을 갖춘 독채형 객실을 운영하는 + 숙박업소입니다" 에서 한 발도 못 나갔다. 재료를 재료 자리에 놓아야 쓴다. + """ sections = [ f"'{place_name}'({_CATEGORY_LABEL.get(category, '사업장')})의 공식 홈페이지 문구를 작성한다.", "", @@ -64,16 +73,26 @@ def build_prompt( ] if unit_facts: sections.extend(["", "확인된 객실·메뉴 사실:", _fact_lines(unit_facts)]) + if records: + sections.extend([ + "", + "업소에 대해 확인된 기록(출처가 있는 글):", + *(f"- {line}" for line in records), + ]) sections.extend([ "", "출력:", - "- intro: 소개문 100~250자", + # ★ 100~250자였다. 그 길이로는 확인된 사실을 나열하면 끝나서, 기록이 있어도 + # 들어갈 자리가 없었다. 시안(/s/stay)의 소개는 3문단 450자 안팎이다. + "- intro: 소개문 200~600자. 사실이 충분하면 2~3문단으로 나눈다(문단 사이 빈 줄)", "- intro_fact_keys: 소개문의 근거 key", "- meta_description: 검색 요약 50~120자", f"- faqs: 최대 {max_faqs}개, 각 항목에 근거 fact_keys 포함", "", "규칙:", "- 위 사실에 없는 숫자·시설·지역 정보를 지어내지 마라.", + "- **기록 절에 있는 내용은 적극적으로 쓴다.** 그것도 출처가 확인된 사실이다 —" + " 건물의 내력·공간 구성·주변과의 관계처럼 이 업소만의 이야기가 거기 있다.", "- false·불가·없음 값을 가능하다고 표현하지 않는다.", "- 홍보성·평가성 표현을 쓰지 않는다.", "- 근거 없는 FAQ는 만들지 않는다.", diff --git a/solution/backend/services/prompts/place_research.py b/solution/backend/services/prompts/place_research.py new file mode 100644 index 0000000..8bfd0e3 --- /dev/null +++ b/solution/backend/services/prompts/place_research.py @@ -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}개. 출처를 댈 수 없는 항목은 넣지 마라 — 적게 주는 편이 낫다."""