Merge branch 'feature/stay-parity'

This commit is contained in:
hbyang 2026-09-10 16:15:29 +09:00
commit 25f7e5c0c8
25 changed files with 987 additions and 70 deletions

View File

@ -3,7 +3,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import area_contents
from common.enums import ErrorType, LocalContentType
from common.enums import ErrorType, LocalContentType, STORY_KINDS
from common.utils.gtime import GTime
@ -76,12 +76,22 @@ class LocalContentCRUD:
return await DB_SESSION_MNG.add(db, stmt)
async def list_kinds(self, db, region_code: str):
"""지역의 이야기 행 전부(종류당 1행). cache-aside 판단에 쓴다."""
"""지역의 **이야기** 행(종류당 1행). cache-aside 판단에 쓴다.
`kind IS NOT NULL` 고르면 된다. kind 이야기 전용 칸이 아니다
마이그레이션 0008 날씨·축제·명소·맛집에도 kind 채웠기 때문에(AREA_KIND),
그렇게 고르면 **이야기가 건도 없는 지역이 "이미 있다" 판정된다.**
실측(2026-09-10, 전북 군산시): 주변정보 116건이 들어온 뒤로 `has_stories` 참이라
지역 이야기 생성이 영영 건너뛰어졌고, 발행본에서 가요다방·인물열전·시간의 골목·
엽서·퀴즈 다섯 섹션이 통째로 비었다. 잡은 성공으로 끝나고 로그도 조용해서
"생성기가 없는 것" 처럼 보였다.
그래서 STORY_KINDS 명시한다. 종류가 늘면 상수만 늘린다.
"""
return await DB_SESSION_MNG.execute(
db,
select(area_contents).where(
area_contents.region_code == region_code,
area_contents.kind.isnot(None),
area_contents.kind.in_(STORY_KINDS),
area_contents.deleted == False, # noqa: E712
),
)

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_channels, places, place_units
from common.database.model.models import place_channels, place_facts, place_photos, places, place_units, sites
from common.enums import ErrorType
from common.logger import LOG
from common.utils.gtime import GTime
@ -64,6 +64,10 @@ class IPlaceCRUD(ABC):
async def set_link_raw(self, cdb: AsyncSession, place_id, url, raw) -> Tuple[ErrorType, int]:
pass
@abstractmethod
async def find_by_external(self, cdb: AsyncSession, owner_user_id, source, external_place_id) -> Tuple[ErrorType, list]:
pass
class PlaceCRUD(IPlaceCRUD):
async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType:
@ -90,6 +94,42 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def find_by_external(self, cdb: AsyncSession, owner_user_id, source, external_place_id) -> Tuple[ErrorType, list]:
"""같은 사장님이 **이미 갖고 있는** 같은 외부 업소. 중복 사업장 판정용이다.
소유자까지 함께 본다. 외부 id 만으로 찾으면 다른 사장님의 사업장이 걸리고,
그걸 이어 쓰면 남의 가게를 넘겨받는 셈이 된다.
**쌓인 것이 많은 **으로 준다. 부르는 쪽은 앞을 정본으로 삼는다.
한때 `created_at` 오름차순이었는데, 그러면 위저드가 처음 만들었다가 버린 ** **
정본이 되고 정작 fact·객실·사진이 쌓인 행을 접게 된다(실측 2026-09-10: 정본으로
fact 2건짜리 행이 뽑혔다). 나이가 아니라 **내용** 기준이다.
같은 무게면 먼저 만든 쪽이다 시점부터 사장님이 알고 있던 주소이기 때문이다.
"""
try:
def _count(model):
return (
select(func.count())
.select_from(model)
.where(model.place_id == places.place_id, model.deleted == False) # noqa: E712
.scalar_subquery()
)
weight = _count(place_facts) + _count(place_units) + _count(place_photos) + _count(sites)
query = (
select(places)
.where(
places.owner_user_id == owner_user_id,
places.external_source == source,
places.external_place_id == str(external_place_id),
places.deleted == False, # noqa: E712
)
.order_by(weight.desc(), places.created_at.asc())
)
return await DB_SESSION_MNG.execute(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def list_places(
self, cdb: AsyncSession, owner_user_id, search: Optional[str], category: Optional[int],
status: Optional[int], skip: int, limit: int,

View File

@ -89,6 +89,60 @@ async def discover_naver_place(place, place_id: str) -> str:
return "resolved" if added else "already"
async def discover_official_site(place, place_id: str) -> str:
"""네이버 **지역검색**이 주는 업체 자체 홈페이지를 채널로 등록한다.
반환 규약은 `discover_naver_place` 같다("resolved" / "already" / "not_found").
이게 필요한가 (실측 2026-09-10, 스테이,머뭄)
네이버 플레이스는 숙박에서 **fact 3**(주차·와이파이·휠체어)밖에 주지 않는다.
체크인·체크아웃·취소규정·객실은 건도 없다. TourAPI 미등록 업소면 0건이고,
네이버 예약 페이지와 인스타그램은 robots 자동 수집을 금지한다. 그러면 남는
공개 출처는 **업소 자체 홈페이지 하나뿐**인데, 주소를 우리는 이미 받고 있었다
지역검색 응답의 `link` . 그걸 아무도 등록하지 않아 버려지고 있었다
(`external/naver.py` 머리주석: "채널 URL 발견에 쓸 수 있는 부수입이다").
숙박은 자체 홈페이지 보유율이 3업종 가장 높다(표본 25 19).
지어내지 않는다. 검색으로 URL **추측**하는 Perplexity 경로와 다르다
이건 네이버가 업소 레코드에 달아 값이고, 동일 업소 판정(`pick_match`)
통과했을 때만 쓴다. 그래서 `discover_naver_place` 같은 근거로 자동 확정한다.
수집 금지 호스트도 **등록은 한다.** 인스타그램·OTA robots 크롤을 막지만
(`static_html_adapter._DENY_HOSTS`), 발행본의 공식 채널·sameAs 로는 유효한 사실이다.
크롤 대상이 되어도 어댑터가 손들면 링크만 건너뛴다.
"""
from services.external import naver as naver_client
client = naver_client.NaverLocalClient()
if not client.enabled:
return "not_found"
address = place.road_address or place.address or ""
# ★ `naver.region_key()` 를 쓰면 안 된다 — 그건 지명이 아니라 **행정구역 코드**('52군산시')다.
# 검색어에 넣으면 후보가 0건이 된다(실측 2026-09-10). 사람이 읽는 지역 토막을 쓴다.
query = " ".join(x for x in (place.name, naver_place_lookup.region_hint(address)) if x)
try:
candidates = await client.search_local(query)
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
LOG.w(f"[collect] 지역검색 실패(계속) {query!r}: {type(ex).__name__}: {ex}")
return "not_found"
match = naver_client.pick_match(place.name, candidates, address)
if not match.is_matched or not match.place or not match.place.place_url:
LOG.i(f"[collect] 자체 홈페이지 없음 — 네이버 레코드에 link 가 없거나 동일 업소 판정 실패 (place={place_id})")
return "not_found"
url = match.place.place_url
channel = LinkChannel.INSTAGRAM if "instagram.com" in url.lower() else LinkChannel.OFFICIAL_SITE
added = await _add_link(place_id, channel, url, f"{place.name} 공식 채널", SourceType.API, raw={"source": "naver_local"})
await DB_SESSION_MNG.execute_lambda_claim(
place_channels.DBType(),
lambda s: _place_crud.confirm_link_by_url(s, uuid.UUID(place_id), url, place.verified_by, GTime.UTC()),
)
LOG.i(f"[collect] 자체 홈페이지 {'등록·확정' if added else '확정'}{url}")
return "resolved" if added else "already"
async def discover_tour_api(place, place_id: str) -> str:
"""검증된 상호·좌표로 TourAPI 콘텐츠를 찾아 등록한다. 반환값은 discover_naver_place 와 같은 규약이다.
@ -166,6 +220,16 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
LOG.w(f"[collect] TourAPI 조회 실패(계속): {type(ex).__name__}: {ex}")
stat["tour_api"] = "error"
# 자체 홈페이지 — 네이버 지역검색이 이미 준 값이라 추가 요금이 없다(위 함수 머리주석).
# ★ 숙박에서 이게 체크인·취소규정·객실을 가진 유일한 공개 출처인 경우가 많다.
try:
stat["official_site"] = await discover_official_site(place, place_id)
if stat["official_site"] == "resolved":
stat["discovered"] += 1
except Exception as ex: # noqa: BLE001
LOG.w(f"[collect] 자체 홈페이지 조회 실패(계속): {type(ex).__name__}: {ex}")
stat["official_site"] = "error"
# 오직 요청 옵션으로만 연다. 서버 env 로 일괄 활성화하면 일반 크롤링·재수집에서도
# 사용자가 모르는 유료 검색이 반복될 수 있으므로 COLLECT_USE_PERPLEXITY 는 더 쓰지 않는다.
if not include_perplexity:
@ -552,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

View File

@ -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:

View File

@ -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",

View File

@ -52,7 +52,7 @@ def _normalize(text: str) -> str:
return re.sub(r"[\s,·.\-_'\"()&]", "", (text or "")).lower()
def _region_hint(address: Optional[str]) -> str:
def region_hint(address: Optional[str]) -> str:
"""주소에서 검색을 좁힐 지역 토막. 시/군/구까지만 쓴다.
토막만 쓰면 된다 그건 광역시·('경기도') 오히려 넓어진다.
@ -128,7 +128,7 @@ async def find_place_id(name: str, address: Optional[str] = None) -> Optional[st
이름이 일치하는 후보만 받는다. '비슷한 것 중 첫 번째' 고르면 남의 가게를
가게의 공식 채널로 등록하게 된다 제품에서 가장 비싼 실수다.
"""
query = " ".join(x for x in (name, _region_hint(address)) if x)
query = " ".join(x for x in (name, region_hint(address)) if x)
html = await _fetch_search_html(query)
if not html:
return None

View File

@ -167,6 +167,43 @@ def make_client() -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=REQUEST_TIMEOUT)
async def find_image(client: httpx.AsyncClient, keyword: str, region_token: str) -> Optional[str]:
"""이름으로 공공데이터 사진 한 장. 없으면 None.
지역 이야기(연표·엽서) 항목에 사진을 붙이는 자리다. 이야기는 검색모델이 쓰지만
**사진은 모델에게 묻지 않는다** 모델이 이미지 주소는 대개 존재하지 않거나
남의 저작물이다. 공공데이터가 장소의 사진으로 것만 쓴다.
권리는 여기서 끝낸다. 파일의 규칙 그대로 공공누리 Type1·Type3 남긴다
(머리주석). 발행본은 상업적 이용이라 Type2·Type4 싣고, 유형을 모르면 버린다.
지역을 대조한다. `searchKeyword2` 전국에서 이름만 맞으면 주므로, 주소에 지역
토막이 없는 결과는 버린다 '군산항' 찾다가 다른 지역 동명 시설 사진이 붙으면
사진은 지역 이야기와 아무 관계가 없다.
실패는 None 이다. 사진이 없으면 렌더러가 활자만으로 세운다(설계된 폴백)
사진 때문에 이야기 생성을 실패시키지 않는다.
"""
word = (keyword or "").strip()
token = (region_token or "").strip()
if not word:
return None
try:
items, _ = await _call(client, "searchKeyword2", keyword=word, arrange="O")
except Exception: # noqa: BLE001 — 사진은 있으면 좋은 것이지 없으면 안 되는 것이 아니다
return None
for item in items:
image = str(item.get("firstimage") or "").strip()
if not image:
continue
if str(item.get("cpyrhtDivCd") or "").strip().lower() not in _COMMERCIAL_OK_LICENSES:
continue
addr = f"{item.get('addr1') or ''} {item.get('addr2') or ''}"
if token and token not in addr:
continue
return image
return None
async def fetch_nearby(client: httpx.AsyncClient, latitude: float, longitude: float,
*, radius_m: int, content_type_id: str) -> list[dict]:
"""업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다.

View File

@ -0,0 +1,116 @@
"""위키미디어 사진 — 인물 얼굴을 **권리를 확인하고** 가져온다.
위키인가
공공데이터(TourAPI) 관광지·시설 사진을 준다. 사람 얼굴은 주지 않는다 인물 열전
명을 찔러도 0건이다(실측 2026-09-10). 인물 사진이 공개돼 있으면서 **재게시 권리를
기계가 읽을 있는 형태로 알려주는** 곳은 사실상 위키백과·위키공용뿐이다.
크롤링이 아니다. MediaWiki 공식 API 쓴다 robots 거스르지 않고, 페이지를 긁어
파싱하지도 않는다. 우리는 API 주는 파일 이름과 ** 파일의 라이선스 메타데이터** 읽는다.
권리 판정을 여기서 끝낸다 (`tour_api.find_image` 같은 자리)
위키백과에는 자유 저작물만 있는 것이 아니다 인물 사진에는 특히 '공정 이용'(비자유)
파일이 섞인다. 그걸 발행본에 실으면 상업적 이용이라 바로 침해다. 그래서 **상업적 이용을
허용하는 라이선스만** 통과시키고, 판정할 없으면 버린다.
통과한 사진에도 **출처 표시** 따라붙는다(CC BY·BY-SA 조건) `credit` 값이고,
화면에 그대로 찍는다. 표시하지 않을 거면 애초에 쓰지 않는다.
"""
import re
from typing import NamedTuple, Optional
import httpx
from common.logger import LOG
KO_API = "https://ko.wikipedia.org/w/api.php"
COMMONS_API = "https://commons.wikimedia.org/w/api.php"
REQUEST_TIMEOUT = 15.0
# ★ 위키미디어 API 예절: 누가 부르는지 밝힌다. 익명 UA 는 차단 대상이다.
# (우회 장치가 아니다 — 정직하게 신원을 적는 것이 저쪽이 요구하는 방식이다.)
HEADERS = {"User-Agent": "o2o-web4ai/1.0 (+https://web4ai.o2osolution.ai; contact: hbyang@o2o.kr)"}
# 상업적 이용이 허용되는 라이선스만. 소문자로 맞춰 비교한다.
# · public domain / pd — 조건 없음
# · cc0 — 조건 없음
# · cc by, cc by-sa — 출처 표시 조건(우리는 credit 을 화면에 찍는다)
# ★ 여기 없는 것은 전부 버린다. 특히 'fair use'·'non-free'·'nc'(비영리)·'nd' 는 못 쓴다.
_OK_LICENSE_RE = re.compile(r"\b(public domain|pd-|cc0|cc[- ]by(?:[- ]sa)?)\b", re.I)
_DENY_LICENSE_RE = re.compile(r"\b(fair use|non-?free|nc\b|no[nt][- ]commercial|nd\b)", re.I)
class WikiImage(NamedTuple):
url: str
"""화면에 찍을 출처 표시. CC BY·BY-SA 의 조건이라 비워 두면 쓸 수 없다."""
credit: str
"""그 파일의 설명 페이지. 라이선스 원문을 확인할 수 있는 자리다."""
source_url: str
def _strip_tags(value: str) -> str:
"""extmetadata 의 값에는 HTML 이 섞여 온다(링크·줄바꿈). 화면에 그대로 찍을 수 없다."""
text = re.sub(r"<[^>]+>", "", value or "")
return re.sub(r"\s+", " ", text).strip()
def _license_ok(license_name: str, usage_terms: str) -> bool:
blob = f"{license_name} {usage_terms}"
if _DENY_LICENSE_RE.search(blob):
return False
return bool(_OK_LICENSE_RE.search(blob))
async def find_person_image(client: httpx.AsyncClient, name: str) -> Optional[WikiImage]:
"""인물 이름으로 사진 한 장. 없거나 권리가 불확실하면 None.
없는 것이 정상적인 결말이다. 한국 근대 인물은 자유 저작물 사진이 없는 경우가 많고,
그때 인물 자리는 렌더러가 이니셜 활자로 세운다(PeopleItem.imageUrl 주석).
억지로 채우려고 비슷한 이름의 다른 사람 사진을 붙이는 것이 훨씬 나쁘다.
"""
person = (name or "").strip()
if not person:
return None
try:
res = await client.get(KO_API, params={
"action": "query", "format": "json", "titles": person,
"prop": "pageimages", "piprop": "name", "redirects": 1,
})
pages = (res.json().get("query") or {}).get("pages") or {}
page = next(iter(pages.values()), {})
filename = page.get("pageimage")
if not filename:
return None
meta_res = await client.get(COMMONS_API, params={
"action": "query", "format": "json", "titles": f"File:{filename}",
"prop": "imageinfo", "iiprop": "url|extmetadata",
})
meta_pages = (meta_res.json().get("query") or {}).get("pages") or {}
info = ((next(iter(meta_pages.values()), {}) or {}).get("imageinfo") or [{}])[0]
except Exception as ex: # noqa: BLE001 — 사진은 있으면 좋은 것이지 없으면 안 되는 것이 아니다
LOG.w(f"[wiki] '{person}' 조회 실패(계속): {type(ex).__name__}: {ex}")
return None
url = str(info.get("url") or "").strip()
if not url:
return None
meta = info.get("extmetadata") or {}
license_name = _strip_tags((meta.get("LicenseShortName") or {}).get("value") or "")
usage_terms = _strip_tags((meta.get("UsageTerms") or {}).get("value") or "")
if not _license_ok(license_name, usage_terms):
LOG.i(f"[wiki] '{person}' 사진 있으나 라이선스가 상업 이용 불가/불명 — 버린다 ({license_name or '불명'})")
return None
artist = _strip_tags((meta.get("Artist") or {}).get("value") or "")
credit = " · ".join(x for x in (artist or "Wikimedia Commons", license_name) if x)
return WikiImage(
url=url,
credit=credit,
source_url=str(info.get("descriptionurl") or "").strip() or url,
)
def make_client() -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=REQUEST_TIMEOUT, headers=HEADERS, follow_redirects=True)

View 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

View 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}

View File

@ -2,6 +2,8 @@ import uuid
from fastapi import Depends
from sqlalchemy import func, select
from common.category_schema import CategorySchemaError, get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_channels, places, place_units
@ -189,6 +191,44 @@ class PlaceService:
return res
# ---- 동일 업소 검증 ----
async def _is_empty(self, place_id: str) -> bool:
"""이 사업장에 **사장님의 것이 쌓였나.** 중복을 접어도 되는지의 판정이다.
무엇을 세나: fact · 객실 · 사진 · 사이트. 채널은 세지 않는다
채널은 검증 과정에서 자동으로 붙는 것이라 "사장님이 쌓은 것" 아니다.
이걸 세면 방금 만든 행도 비어 있지 않다고 판정돼 중복이 그대로 남는다.
하나라도 있으면 접지 않는다. 지우는 쪽이 틀렸을 때의 비용(사장님이 넣은 값이
사라진다) 남기는 쪽이 틀렸을 때의 비용(목록에 하나 보인다)보다 훨씬 크다.
세지 못하면 **비어 있지 않다고 본다** 모르면 지우지 않는다.
"""
from common.database.model.models import place_facts, place_photos, sites
pid = uuid.UUID(place_id)
def _count(model):
return (
select(func.count())
.select_from(model)
.where(model.place_id == pid, model.deleted == False) # noqa: E712
.scalar_subquery()
)
query = select(
_count(place_facts) + _count(place_units) + _count(place_photos) + _count(sites)
)
err, rows = await DB_SESSION_MNG.execute_lambda(
places.DBType(), DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(s, query),
)
if err != ErrorType.SUCCESS or not rows:
LOG.w(f"[verify_by_url] 중복 판정용 계수 실패 place={place_id} — 접지 않는다")
return False
# ★ 이 세션 헬퍼는 한 칸짜리 select 를 스칼라로 풀어서 준다(행 튜플이 아니다).
# `rows[0][0]` 으로 읽으면 TypeError 로 검증 API 가 통째로 500 이 된다(실측).
row = rows[0]
total = row if isinstance(row, int) else row[0]
return int(total) == 0
async def verify_place_by_url(self, user_info: UserInfo, place_id: str, req: Req_VerifyPlaceByUrl) -> Res_Place:
"""네이버 플레이스 URL → 상호·주소·좌표를 읽어 동일 업소를 확정하고, 그 URL 을 수집 채널로 등록한다.
@ -234,6 +274,50 @@ class PlaceService:
res.msg = "이 주소에서 상호를 찾지 못했습니다."
return res
# ── 중복 사업장 합치기 ────────────────────────────────────────────────
# ★ 왜 여기인가
# 위저드는 **신원을 알기 전에** 사업장을 먼저 만든다(`ensureServerPlace`) — 이름만
# 아는 빈 행이다. 그리고 이 함수에서 비로소 "이 가게가 누구인지"(네이버 place id)를
# 알게 된다. 그 순간이 "이미 갖고 있는 그 가게인가" 를 물을 수 있는 첫 지점이다.
# 여기서 안 묻고 지나가면 위저드를 다시 시작할 때마다 같은 가게가 하나씩 늘어난다 —
# 실측(2026-09-10): 로컬 DB 에 '스테이,머뭄' 이 8개였고 그중 7개가 fact 2건짜리
# 빈 행이었다. 사장님은 목록에서 어느 것이 자기 사이트인지 알 수 없다.
#
# ★ 정본은 **먼저 만든 쪽**이다(`find_by_external` 이 오래된 순으로 준다).
# 나중 것을 정본으로 삼으면 앞서 쌓인 fact·사진·발행 이력이 통째로 버려진다.
#
# ★ 지금 행은 **비어 있을 때만** 지운다. 사장님이 이 행에 뭔가를 쌓았다면(fact·객실·
# 사진·발행) 그건 합치기가 아니라 병합이고, 그건 사람이 판단할 일이다 —
# 그때는 둘 다 남기고 정본만 돌려준다.
err_dup, dup_rows = await DB_SESSION_MNG.execute_lambda(
places.DBType(), DBWRType.DB_READ.value,
lambda sess: self.crud.find_by_external(
sess, uuid.UUID(user_info.user_id), ExternalPlaceSource.NAVER.value, str(naver_id),
),
)
canonical_id = place_id
if err_dup == ErrorType.SUCCESS:
existing = next((r for r in (dup_rows or []) if str(r.place_id) != str(place_id)), None)
if existing is not None:
canonical_id = str(existing.place_id)
if await self._is_empty(place_id):
await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda sess: self.crud.delete_place(
sess, uuid.UUID(user_info.user_id), uuid.UUID(place_id),
),
)
LOG.i(f"[verify_by_url] 같은 업소가 이미 있다 — 빈 행 {place_id} 를 접고 "
f"{canonical_id} 로 잇는다 (naver {naver_id})")
else:
LOG.w(f"[verify_by_url] 같은 업소가 둘이다 — {place_id} 에 쌓인 것이 있어 "
f"지우지 않는다. 정본 {canonical_id} 를 돌려준다 (naver {naver_id})")
place_id = canonical_id
err_type, place = await self._load(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
coord = base.get("coordinate") or {}
verify_req = Req_VerifyPlace(
source=ExternalPlaceSource.NAVER,

View File

@ -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:<uuid> (수집 원문) = ` 섞여 들어갔다.
모델은 그걸 하나로 읽고 거의 쓰지 않았다 실측(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는 만들지 않는다.",

View 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}. 출처를 없는 항목은 넣지 마라 적게 주는 편이 낫다."""

View File

@ -28,14 +28,14 @@
"label": "시간의 골목",
"maxItems": 14,
"task": "[해야 할 일]\n[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다.\n가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다.\n\n[스키마]\n{ \"kind\":\"chronicle\", \"version\":1, \"title\":\"시간의 골목\", \"items\":[\n { \"year\":1899, \"title\":\"사건 이름\", \"summary\":\"두 문장 이내\",\n \"place\":\"지금 가 볼 수 있는 자리\", \"turning\":true,\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
"rules": "\n[이 아이템만의 규칙]\n· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.\n· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.\n· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.\n"
"rules": "\n[이 아이템만의 규칙]\n· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.\n· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.\n· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.\n· 다만 **찾아갈 수 있는 자리가 있으면 반드시 적는다.** 행정 개편처럼 장소가 없어 보이는\n 사건에도 그 일이 남긴 자리가 대개 있다(개항 → 항구, 부두 건설 → 그 부두, 준공 → 그 구조물).\n 이 값으로 공공데이터에서 그 해의 사진을 찾아 붙인다 — 비면 연표가 활자만으로 선다.\n"
},
"postcard": {
"kind": "postcard",
"label": "오늘의 엽서",
"maxItems": 12,
"task": "[해야 할 일]\n[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다.\n사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다.\n\n[스키마]\n{ \"kind\":\"postcard\", \"version\":1, \"title\":\"오늘의 엽서\", \"items\":[\n { \"line\":\"한 문장\", \"hashtags\":[\"#태그\"], \"place\":\"장소\",\n \"postmark\":\"소인에 찍을 짧은 지명\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
"rules": "\n[이 아이템만의 규칙]\n· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.\n· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.\n· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.\n"
"rules": "\n[이 아이템만의 규칙]\n· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.\n· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.\n· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.\n· **같은 대상을 두 번 쓰지 않는다.** 열두 장이 전부 다른 곳·다른 이야기여야 한다 —\n 한 시설의 주소·휴관일·운영시간을 나눠 적으면 엽서가 아니라 안내문 열두 장이 된다\n (실측 2026-09-10: 네 장이 같은 박물관의 관람안내였다).\n· 운영시간·휴관일·주소·요금은 엽서에 적지 않는다. 그건 이용 정보지 엽서 문장이 아니다.\n· place 는 사진을 찾는 열쇠다. **찾아갈 수 있는 곳 이름을 정확히** 적는다 —\n 이 값으로 공공데이터에서 앞면 사진을 찾고, 못 찾으면 그 엽서는 실리지 않는다.\n"
},
"quiz": {
"kind": "quiz",

View File

@ -196,6 +196,21 @@ _DEFAULT_THEME = {
# ── 값 변환 헬퍼 ──────────────────────────────────────────────────────────
def _first_sentence(text: str) -> str:
"""첫 문장. 마침표가 없으면 통째로 돌려준다.
필요한가: 히어로 아래 줄과 meta description 같은 값을 쓴다. ** 문장**
자리라, 문단이 들어가면 히어로는 줄로 부풀고 검색 결과에서는 뒤가 잘린다.
마침표 뒤에 공백이 없어도 자른다("…입니다.일본식" 같은 생성물이 실제로 온다).
다만 숫자 사이의 (1.5km) 자르지 않는다 뒤가 숫자면 문장 끝이 아니다.
"""
import re as _re
match = _re.search(r"[.!?](?![0-9])", text or "")
return text[: match.end()].strip() if match else (text or "").strip()
def _get(row, key, default=None):
"""ORM 행이든 dict 든 같은 방식으로 읽는다(테스트가 가짜 행을 넣기 쉬우라고)."""
if isinstance(row, dict):
@ -859,8 +874,12 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict:
paragraphs = [p.strip() for p in intro.split("\n") if p.strip()] if intro else []
narrative = {
"about": paragraphs,
# 요약은 첫 문단을 그대로 쓴다(요약문을 새로 생성하지 않는다).
"summary": paragraphs[0] if paragraphs else None,
# ★ 요약은 **첫 문장**이다. 문단이 아니다.
# 계약이 "요약 한 문장"이라 적어 뒀는데(shared/site-payload.ts) 첫 문단을 통째로
# 넣고 있었다. 그 값은 두 곳으로 나간다 — 히어로 아래 한 줄과 meta description.
# 문단이 들어가면 히어로가 세 문장을 이고 서고(실측 2026-09-10), meta description 은
# 검색 결과에서 잘린다. 문장을 새로 생성하지는 않는다 — 있는 글의 첫 문장을 뗄 뿐이다.
"summary": _first_sentence(paragraphs[0]) if paragraphs else None,
}
theme_spec = _DEFAULT_THEME.get(category) or _DEFAULT_THEME[PlaceCategory.LODGING.value]

View File

@ -31,6 +31,7 @@ from common.utils.gtime import GTime
from common.logger import LOG
from crud.local_content_crud import LocalContentCRUD
from services.grounding import story as grounding
from services.external import tour_api, wikimedia
from services.llm import perplexity
from services.prompts import story as prompts
@ -73,10 +74,93 @@ async def _generate_kind(client: httpx.AsyncClient, kind: str, region_label: str
return [], [f"호출 실패: {ex}"]
items, dropped = grounding.parse_items(payload, kind, prompts.max_items(kind))
items = await _attach_images(kind, items, region_label)
LOG.i(f"[story] {region_label} {kind}: {len(items)}건 채택, {len(dropped)}건 버림")
return items, dropped
# 사진을 무엇으로 찾을지. 종류마다 출처가 다르다.
#
# chronicle · postcard 공공데이터(TourAPI) — 장소·시설 사진. 공공누리 Type1/Type3 만
# people 위키미디어 — 사람 얼굴. 상업 이용 가능 라이선스만
#
# ★ 가요·퀴즈는 찾지 않는다. 시안에도 그 자리에 사진이 없다.
_PLACE_IMAGE_KEYS = {
"chronicle": ("place", "title"), # 그 해의 장소 → 없으면 사건 이름
"postcard": ("place", "postmark"), # 엽서 앞면이 될 장소
}
# 한 종류에서 사진을 찾아볼 항목 수 상한. 건당 0.3~1초라 열두 개를 다 찌르면 생성이 두 배 걸린다.
_IMAGE_LOOKUP_LIMIT = 12
# ★ 사진이 없으면 항목 자체를 버리는 종류.
# 엽서는 **앞면 사진이 본체**다. 사진이 없으면 뒷면(문장·우표·소인)만 남아 카드가 반쪽이 되고,
# 시안과 나란히 놓으면 빈 카드로 보인다(실측 2026-09-10). 연표는 다르다 — 활자만으로도
# 레일 위에 서므로 사진 없는 항목을 버리면 연표에 구멍이 난다.
_IMAGE_REQUIRED_KINDS = {"postcard"}
def _region_token(region_label: str) -> str:
"""주소 대조에 쓸 지역 토막("전북 군산시""군산"). 시/군/구 접미사를 뗀다 —
TourAPI 주소는 '전북특별자치도 군산시 …' 표기가 우리와 다를 있다."""
for tok in reversed((region_label or "").split()):
if tok.endswith(("", "", "")) and len(tok) > 1:
return tok[:-1]
return (region_label or "").split()[-1] if region_label else ""
async def _attach_images(kind: str, items: list[dict], region_label: str) -> list[dict]:
"""항목에 사진을 붙이고, 사진이 본체인 종류는 못 붙은 항목을 버린다.
**수집하는 자리에서 함께 가져온다.** 이미 DB 있는 사진을 가져다 쓰지 않는다
그건 "이 항목의 사진" 아니라 "마침 우리가 갖고 있던 사진" 이고, 엉뚱한 장소가
해의 사진으로 붙는다.
**같은 사진을 쓰지 않는다.** 항목이 같은 시설을 말하면 검색이 같은 사진을
준다 화면에는 같은 그림 넷이 늘어선다(실측 2026-09-10, 군산근대역사박물관).
번째부터는 사진 없는 것으로 친다.
권리 판정은 부르는 쪽이 아니라 출처 모듈이 한다(tour_api.find_image · wikimedia).
"""
found = 0
used: set[str] = set()
if kind == "people":
async with wikimedia.make_client() as client:
for item in items[:_IMAGE_LOOKUP_LIMIT]:
hit = await wikimedia.find_person_image(client, str(item.get("name") or ""))
if not hit or hit.url in used:
continue
item["imageUrl"], item["imageCredit"] = hit.url, hit.credit
used.add(hit.url)
found += 1
elif kind in _PLACE_IMAGE_KEYS:
fields = _PLACE_IMAGE_KEYS[kind]
token = _region_token(region_label)
async with tour_api.make_client() as client:
for item in items[:_IMAGE_LOOKUP_LIMIT]:
keyword = next((str(item.get(f) or "").strip() for f in fields if item.get(f)), "")
if not keyword:
continue
url = await tour_api.find_image(client, keyword, token)
if not url or url in used:
continue
item["imageUrl"] = url
# 공공누리 제1유형도 출처 표시가 조건이다. 사진을 준 곳을 그대로 적는다.
item["imageCredit"] = "한국관광공사"
used.add(url)
found += 1
if kind in _IMAGE_REQUIRED_KINDS:
kept = [i for i in items if i.get("imageUrl")]
if len(kept) != len(items):
LOG.i(f"[story] {region_label} {kind}: 사진 없는 {len(items) - len(kept)}건 제외 "
f"(이 종류는 사진이 본체다)")
items = kept
if found:
LOG.i(f"[story] {region_label} {kind}: 사진 {found}장 붙임")
return items
async def generate_region_stories(region_code: str, region_label: str, kinds: list[str] | None = None) -> dict:
"""지역 하나의 이야기를 생성해 `area_contents` 에 넣는다. 종류별 채택 건수를 돌려준다.

View File

@ -65,9 +65,13 @@ export async function ensureServerPlace(
}
// ★ 상호·주소는 서버가 읽은 값으로 덮는다. 사장님이 검색창에 친 이름이 아니라
// 네이버에 등록된 공식 표기가 이 사이트의 기준 정보가 되어야 한다.
// ★ **place_id 도 서버가 돌려준 것을 쓴다.** 같은 가게를 이미 갖고 있으면 서버가
// 그 정본을 돌려주고 방금 만든 빈 행을 접는다(`verify_place_by_url` 의 중복 합치기).
// 여기서 우리가 만든 id 를 계속 붙들면, 화면은 **접힌 행**을 편집하게 된다 —
// 저장은 되는데 목록·발행본은 정본을 보므로 "고쳤는데 반영이 안 된다" 가 된다.
return {
...identity,
placeId,
placeId: place.place_id ?? placeId,
name: place.name ?? identity.name,
address: place.road_address ?? place.address ?? identity.address,
phone: place.phone ?? identity.phone,

View File

@ -97,6 +97,11 @@ export interface PeopleItem {
* (`services/external/tour_places.py` `_IMAGE_OK`).
*/
imageUrl?: string;
/**
* . CC BY·BY-SA· 1 ** **
* (`services/external/wikimedia.py`).
*/
imageCredit?: string;
verified?: DataVerified;
source?: DataSource;
}
@ -109,6 +114,11 @@ export interface ChronicleItem {
place?: string;
/** 그 해의 사진. 없으면 연표는 지금처럼 활자만으로 선다 — 자리를 비워 두지 않는다(PeopleItem.imageUrl 과 같은 출처·규칙). */
imageUrl?: string;
/**
* . CC BY·BY-SA· 1 ** **
* (`services/external/wikimedia.py`).
*/
imageCredit?: string;
/** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */
turning?: boolean;
verified?: DataVerified;
@ -136,6 +146,11 @@ export interface PostcardItem {
postmark?: string;
/** 엽서 앞면 사진. 없으면 뒷면(문장·우표·소인)만 있는 지금 모양 그대로다(PeopleItem.imageUrl 과 같은 출처·규칙). */
imageUrl?: string;
/**
* . CC BY·BY-SA· 1 ** **
* (`services/external/wikimedia.py`).
*/
imageCredit?: string;
verified?: DataVerified;
source?: DataSource;
}

View File

@ -151,6 +151,9 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
· turning true . 3~4 .
· . .
· place . .
· ** .**
( , , ).
.
`,
},
@ -173,6 +176,12 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
· 40 . .
· . .
· 3. .
· ** .** ·
··
( 2026-09-10: ).
· ··· . .
· place . ** **
, .
`,
},

View File

@ -59,7 +59,14 @@ export function HomePage() {
rooms: UnitsSection,
menu: UnitsSection,
programs: UnitsSection,
booking: BookingSection,
// ★ 숙박은 예약 섹션이 다른 컴포넌트다(요금·인원·창구 + 날짜/시간 목업).
// 같은 섹션 id 가 업종에 따라 다른 것을 그리는 자리는 여기가 유일하다 —
// 이유는 StayBookingSection 머리주석.
// ★ 이 줄이 64ce467 에서 조용히 사라졌다. 그러자 숙박 사이트의 예약 섹션이 전화번호
// 한 줄짜리 BookingSection 으로 돌아갔고, 화면에서 요금이 사라져 **발행이 통째로
// 막혔다** — JSON-LD 의 makesOffer.price 를 화면이 뒷받침하지 못해 절대규칙 3 대조에
// 걸린다(실측 2026-09-10: 발행본 2개 중 2개 실패).
booking: isLodging ? StayBookingSection : BookingSection,
space: SpaceSection,
inquiry: InquirySection,
exhibition: ExhibitionSection,

View File

@ -1,5 +1,5 @@
import {useEffect, useMemo, useState} from 'react';
import {CalendarDays, Check, Clock, Minus, Phone, Plus, RotateCcw} from 'lucide-react';
import {CalendarDays, Check, ChevronLeft, ChevronRight, Clock, Minus, Phone, Plus, RotateCcw} from 'lucide-react';
import {factText, sanitizeUnits, selectPublishable, type SitePayload} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {unitBaseRate} from '@site/seo/jsonld';
@ -26,32 +26,53 @@ import {unitBaseRate} from '@site/seo/jsonld';
* . ** **, .
*/
/** 달력에 낼 날짜 수. 두 주면 흐름을 보기에 충분하고, 화면도 한 줄에 들어온다. */
const DAY_COUNT = 14;
const WEEKDAY_LABEL = ['일', '월', '화', '수', '목', '금', '토'] as const;
/**
* . .
*
* 이유: 우리는 .
* .
*/
const MONTH_SPAN = 2;
interface DayCell {
iso: string;
month: number;
day: number;
weekday: number;
/** 토·일은 주말 요금이 붙는 날이다. 요금 계산의 근거가 화면에도 보여야 한다. */
isWeekend: boolean;
/** 지난 날짜. **고를 수 없는 유일한 사유**다 — '마감' 같은 표시는 만들지 않는다(머리주석). */
past: boolean;
}
function buildDays(from: Date): DayCell[] {
return Array.from({length: DAY_COUNT}, (_, index) => {
const date = new Date(from.getFullYear(), from.getMonth(), from.getDate() + index);
const weekday = date.getDay();
return {
iso: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`,
month: date.getMonth() + 1,
day: date.getDate(),
weekday,
isWeekend: weekday === 0 || weekday === 6,
};
function isoOf(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
/**
* . 1 ** **
* , .
*/
function buildMonth(year: number, month: number, today: Date): (DayCell | null)[] {
const first = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0).getDate();
const todayIso = isoOf(today);
const cells: (DayCell | null)[] = Array.from({length: first.getDay()}, () => null);
for (let day = 1; day <= lastDay; day += 1) {
const date = new Date(year, month, day);
const iso = isoOf(date);
cells.push({
iso,
day,
weekday: date.getDay(),
isWeekend: date.getDay() === 0 || date.getDay() === 6,
past: iso < todayIso,
});
}
return cells;
}
/**
* . ** fact .**
@ -106,9 +127,22 @@ export function StayBookingDemo() {
);
const slots = useMemo(() => buildArrivalSlots(checkIn), [checkIn]);
/** ★ 서버 렌더에서는 false — 날짜를 HTML 에 굽지 않기 위한 게이트(머리주석). */
const [days, setDays] = useState<DayCell[] | null>(null);
useEffect(() => setDays(buildDays(new Date())), []);
/**
* null HTML ().
* '오늘' .
*/
const [today, setToday] = useState<Date | null>(null);
const [monthOffset, setMonthOffset] = useState(0);
useEffect(() => setToday(new Date()), []);
const cursor = useMemo(
() => (today ? new Date(today.getFullYear(), today.getMonth() + monthOffset, 1) : null),
[today, monthOffset],
);
const cells = useMemo(
() => (today && cursor ? buildMonth(cursor.getFullYear(), cursor.getMonth(), today) : []),
[today, cursor],
);
const [dateIso, setDateIso] = useState<string | null>(null);
const [slot, setSlot] = useState<string | null>(null);
@ -116,7 +150,7 @@ export function StayBookingDemo() {
const [guests, setGuests] = useState(2);
const [submitted, setSubmitted] = useState(false);
const selectedDay = days?.find((day) => day.iso === dateIso) ?? null;
const selectedDay = cells.find((cell): cell is DayCell => cell !== null && cell.iso === dateIso) ?? null;
const selectedUnit = units.find((unit) => unit.unitId === unitId) ?? null;
const maxGuests = selectedUnit?.maxCapacity ?? 8;
@ -146,7 +180,7 @@ export function StayBookingDemo() {
</div>
{/* 서버 렌더 · 자바스크립트 꺼짐: 달력 대신 사실만 내보낸다(머리주석). */}
{days === null ? (
{today === null || cursor === null ? (
<p className="px-4 py-6 text-xs leading-relaxed opacity-60 sm:px-5">
.
.
@ -156,7 +190,7 @@ export function StayBookingDemo() {
payload={payload}
onReset={() => setSubmitted(false)}
summary={[
selectedDay ? `${selectedDay.month}${selectedDay.day}일(${WEEKDAY_LABEL[selectedDay.weekday]})` : null,
selectedDay ? `${cursor.getMonth() + 1}${selectedDay.day}일(${WEEKDAY_LABEL[selectedDay.weekday]})` : null,
slot ? `도착 ${slot}` : null,
selectedUnit?.name ?? null,
`${guests}`,
@ -166,34 +200,80 @@ export function StayBookingDemo() {
/>
) : (
<div className="space-y-5 p-4 sm:p-5">
{/* ── 날짜 ─────────────────────────────────────── */}
{/* ── 날짜 (달력) ──────────────────────────────── */}
<div>
<p className="mb-2 text-[11px] font-semibold opacity-55"></p>
<ul className="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-1">
{days.map((day) => {
const active = day.iso === dateIso;
return (
<li key={day.iso}>
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] font-semibold opacity-55"></p>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setDateIso(day.iso)}
aria-pressed={active}
className="flex w-13 shrink-0 flex-col items-center gap-0.5 rounded-xl border px-2 py-2 text-xs transition-colors"
onClick={() => setMonthOffset((n) => Math.max(0, n - 1))}
disabled={monthOffset === 0}
aria-label="이전 달"
className="flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-30"
>
<ChevronLeft className="size-3.5" />
</button>
<span className="w-24 text-center text-xs font-bold">
{cursor.getFullYear()} {cursor.getMonth() + 1}
</span>
<button
type="button"
onClick={() => setMonthOffset((n) => Math.min(MONTH_SPAN, n + 1))}
disabled={monthOffset >= MONTH_SPAN}
aria-label="다음 달"
className="flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-30"
>
<ChevronRight className="size-3.5" />
</button>
</div>
</div>
{/* 요일 머리. 토·일은 색으로 구분한다 — 주말 요금이 붙는 날이라 정보다. */}
<div className="grid grid-cols-7 gap-1 border-b border-black/8 pb-1.5">
{WEEKDAY_LABEL.map((label, index) => (
<span
key={label}
className="text-center text-[10px] font-semibold"
style={{opacity: index === 0 || index === 6 ? 0.75 : 0.45}}
>
{label}
</span>
))}
</div>
<div className="mt-1.5 grid grid-cols-7 gap-1">
{cells.map((cell, index) =>
cell === null ? (
// 1일 앞의 빈 칸. 요일 열을 맞추는 자리라 버튼이 아니다.
<span key={`pad-${index}`} aria-hidden />
) : (
<button
key={cell.iso}
type="button"
disabled={cell.past}
onClick={() => setDateIso(cell.iso)}
aria-pressed={cell.iso === dateIso}
aria-label={`${cursor.getMonth() + 1}${cell.day}`}
className="flex h-9 items-center justify-center rounded-lg border text-xs transition-colors disabled:cursor-not-allowed"
style={{
borderColor: active ? 'var(--color-brand)' : 'rgba(0,0,0,0.10)',
backgroundColor: active ? 'var(--color-brand)' : 'var(--color-surface-alt)',
color: active ? '#fff' : undefined,
borderColor: cell.iso === dateIso ? 'var(--color-brand)' : 'transparent',
backgroundColor:
cell.iso === dateIso ? 'var(--color-brand)' : 'var(--color-surface-alt)',
color: cell.iso === dateIso ? '#fff' : undefined,
// ★ 지난 날짜만 흐리다. '마감'·'잔여' 는 만들지 않는다 — 우리는 그 값을 모른다.
opacity: cell.past ? 0.25 : 1,
fontWeight: cell.iso === dateIso ? 700 : 400,
}}
>
<span className="text-[10px] opacity-70">{WEEKDAY_LABEL[day.weekday]}</span>
<span className="font-bold">{day.day}</span>
{cell.day}
</button>
</li>
);
})}
</ul>
),
)}
</div>
{selectedDay?.isWeekend && (
<p className="mt-1.5 text-[11px] opacity-55"> .</p>
<p className="mt-2 text-[11px] opacity-55"> .</p>
)}
</div>
@ -287,7 +367,7 @@ export function StayBookingDemo() {
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs opacity-60">
{selectedDay
? `${selectedDay.month}${selectedDay.day}일 · ${selectedUnit?.name ?? ''} · ${guests}`
? `${cursor.getMonth() + 1}${selectedDay.day}일 · ${selectedUnit?.name ?? ''} · ${guests}`
: '날짜를 골라 주세요'}
</span>
{price != null && (

View File

@ -67,7 +67,7 @@ export function ChronicleSection() {
<h3 className="serif text-base font-bold">{item.title}</h3>
{item.summary && <p className="text-[13px] leading-relaxed opacity-80">{item.summary}</p>}
{item.place && <p className="text-[11px] opacity-60"> · {item.place}</p>}
<SourceLine source={item.source} verified={item.verified} />
<SourceLine source={item.source} verified={item.verified} imageCredit={item.imageCredit} />
</div>
</div>
</article>

View File

@ -38,6 +38,7 @@ export function PeopleSection() {
<Rail label="인물 목록" arrows="overlay">
{parsed.items.map((person, index) => (
<article key={`${person.name}-${index}`} className="w-[210px] shrink-0 snap-center">
{/* 사진 출처는 카드 아래 SourceLine 옆에 붙는다(아래) — 라이선스 조건이다. */}
{person.imageUrl ? (
<img
src={person.imageUrl}
@ -69,7 +70,7 @@ export function PeopleSection() {
</p>
{person.oneLine && <p className="mt-2 text-[13px] leading-relaxed opacity-85">{person.oneLine}</p>}
<div className="mt-2">
<SourceLine source={person.source} verified={person.verified} />
<SourceLine source={person.source} verified={person.verified} imageCredit={person.imageCredit} />
</div>
</article>
))}

View File

@ -34,10 +34,16 @@ export function PostcardSection() {
alt={`${card.place || card.postmark || '엽서'} 사진`}
loading="lazy"
decoding="async"
className="mb-3.5 block aspect-3/2 w-full border object-cover"
className="block aspect-3/2 w-full border object-cover"
style={{borderColor: ITEM_BORDER}}
/>
)}
{/* 사진 출처. CC BY·공공누리 제1유형이 **요구하는** 표시라 생략할 수 없다. */}
{card.imageUrl && (
<p className="mt-1 mb-3.5 text-[10px] leading-tight opacity-45">
· {card.imageCredit || '출처 표기 없음'}
</p>
)}
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
{/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */}
<div className="min-w-0 border-r pr-3" style={{borderColor: ITEM_BORDER}}>

View File

@ -191,10 +191,23 @@ export function Rail({
* ( ) .
* . .
*/
export function SourceLine({source, verified}: {source?: DataSource; verified?: DataVerified}) {
export function SourceLine({
source,
verified,
imageCredit,
}: {
source?: DataSource;
verified?: DataVerified;
/**
* . ** ** (CC BY·BY-SA)
* 1 , ** **. .
* .
*/
imageCredit?: string;
}) {
// verified 는 계약에 남겨 둔다(에디터·게이트가 읽는다). 화면에만 안 낸다.
void verified;
if (!source?.name) return null;
if (!source?.name && !imageCredit) return null;
return (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] opacity-70">
{source?.name && (
@ -214,6 +227,7 @@ export function SourceLine({source, verified}: {source?: DataSource; verified?:
)}
</span>
)}
{imageCredit && <span> · {imageCredit}</span>}
</p>
);
}