diff --git a/solution/backend/crud/local_content_crud.py b/solution/backend/crud/local_content_crud.py index 4c0a717..19c4ac4 100644 --- a/solution/backend/crud/local_content_crud.py +++ b/solution/backend/crud/local_content_crud.py @@ -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 ), ) diff --git a/solution/backend/crud/place_crud.py b/solution/backend/crud/place_crud.py index 3ab125a..f2e5da3 100644 --- a/solution/backend/crud/place_crud.py +++ b/solution/backend/crud/place_crud.py @@ -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, diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index 9fef1a3..da8ae51 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -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 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/external/naver_place_lookup.py b/solution/backend/services/external/naver_place_lookup.py index 1efe49a..4ecde97 100644 --- a/solution/backend/services/external/naver_place_lookup.py +++ b/solution/backend/services/external/naver_place_lookup.py @@ -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 diff --git a/solution/backend/services/external/tour_api.py b/solution/backend/services/external/tour_api.py index db90265..a6b066d 100644 --- a/solution/backend/services/external/tour_api.py +++ b/solution/backend/services/external/tour_api.py @@ -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]: """업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다. diff --git a/solution/backend/services/external/wikimedia.py b/solution/backend/services/external/wikimedia.py new file mode 100644 index 0000000..c3df518 --- /dev/null +++ b/solution/backend/services/external/wikimedia.py @@ -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) 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/place_service.py b/solution/backend/services/place_service.py index 1a4961b..34f26d7 100644 --- a/solution/backend/services/place_service.py +++ b/solution/backend/services/place_service.py @@ -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, 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}개. 출처를 댈 수 없는 항목은 넣지 마라 — 적게 주는 편이 낫다.""" diff --git a/solution/backend/services/prompts/section_prompts.json b/solution/backend/services/prompts/section_prompts.json index 0c8408a..533a2df 100644 --- a/solution/backend/services/prompts/section_prompts.json +++ b/solution/backend/services/prompts/section_prompts.json @@ -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", diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index 2c4f061..1f2de94 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -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] diff --git a/solution/backend/services/story_service.py b/solution/backend/services/story_service.py index dcaab57..a06b5dc 100644 --- a/solution/backend/services/story_service.py +++ b/solution/backend/services/story_service.py @@ -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` 에 넣는다. 종류별 채택 건수를 돌려준다. diff --git a/solution/frontend/src/features/onboarding/ensureServerPlace.ts b/solution/frontend/src/features/onboarding/ensureServerPlace.ts index f0d52c9..0c88d1c 100644 --- a/solution/frontend/src/features/onboarding/ensureServerPlace.ts +++ b/solution/frontend/src/features/onboarding/ensureServerPlace.ts @@ -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, diff --git a/solution/shared/src/lib/section-data.ts b/solution/shared/src/lib/section-data.ts index f8d885f..9147a0d 100644 --- a/solution/shared/src/lib/section-data.ts +++ b/solution/shared/src/lib/section-data.ts @@ -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; } diff --git a/solution/shared/src/lib/section-prompts.ts b/solution/shared/src/lib/section-prompts.ts index f67a1bb..ef40843 100644 --- a/solution/shared/src/lib/section-prompts.ts +++ b/solution/shared/src/lib/section-prompts.ts @@ -151,6 +151,9 @@ export const SECTION_PROMPTS: Record = { · turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다. · 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다. · place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다. +· 다만 **찾아갈 수 있는 자리가 있으면 반드시 적는다.** 행정 개편처럼 장소가 없어 보이는 + 사건에도 그 일이 남긴 자리가 대개 있다(개항 → 항구, 부두 건설 → 그 부두, 준공 → 그 구조물). + 이 값으로 공공데이터에서 그 해의 사진을 찾아 붙인다 — 비면 연표가 활자만으로 선다. `, }, @@ -173,6 +176,12 @@ export const SECTION_PROMPTS: Record = { · 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다. · 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다. · 해시태그는 3개까지. 지역명 하나는 반드시 넣는다. +· **같은 대상을 두 번 쓰지 않는다.** 열두 장이 전부 다른 곳·다른 이야기여야 한다 — + 한 시설의 주소·휴관일·운영시간을 나눠 적으면 엽서가 아니라 안내문 열두 장이 된다 + (실측 2026-09-10: 네 장이 같은 박물관의 관람안내였다). +· 운영시간·휴관일·주소·요금은 엽서에 적지 않는다. 그건 이용 정보지 엽서 문장이 아니다. +· place 는 사진을 찾는 열쇠다. **찾아갈 수 있는 곳 이름을 정확히** 적는다 — + 이 값으로 공공데이터에서 앞면 사진을 찾고, 못 찾으면 그 엽서는 실리지 않는다. `, }, diff --git a/solution/site/src/pages/HomePage.tsx b/solution/site/src/pages/HomePage.tsx index 4309529..df4b31c 100644 --- a/solution/site/src/pages/HomePage.tsx +++ b/solution/site/src/pages/HomePage.tsx @@ -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, diff --git a/solution/site/src/sections/StayBookingDemo.tsx b/solution/site/src/sections/StayBookingDemo.tsx index c577e87..b5069e9 100644 --- a/solution/site/src/sections/StayBookingDemo.tsx +++ b/solution/site/src/sections/StayBookingDemo.tsx @@ -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,31 +26,52 @@ 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; } /** @@ -106,9 +127,22 @@ export function StayBookingDemo() { ); const slots = useMemo(() => buildArrivalSlots(checkIn), [checkIn]); - /** ★ 서버 렌더에서는 false — 날짜를 HTML 에 굽지 않기 위한 게이트(머리주석). */ - const [days, setDays] = useState(null); - useEffect(() => setDays(buildDays(new Date())), []); + /** + * ★ 서버 렌더에서는 null — 날짜를 HTML 에 굽지 않기 위한 게이트(머리주석). + * '오늘' 을 브라우저에서 정하므로 이 값이 곧 달력의 기준이다. + */ + const [today, setToday] = useState(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(null); const [slot, setSlot] = useState(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() { {/* 서버 렌더 · 자바스크립트 꺼짐: 달력 대신 사실만 내보낸다(머리주석). */} - {days === null ? ( + {today === null || cursor === null ? (

날짜 선택은 브라우저에서 열립니다. 실제 예약 가능 여부와 결제는 아래 예약 창구에서 확인해 주세요. @@ -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() { /> ) : (

- {/* ── 날짜 ─────────────────────────────────────── */} + {/* ── 날짜 (달력) ──────────────────────────────── */}
-

날짜

-
    - {days.map((day) => { - const active = day.iso === dateIso; - return ( -
  • - -
  • - ); - })} -
+
+

날짜

+
+ + + {cursor.getFullYear()}년 {cursor.getMonth() + 1}월 + + +
+
+ + {/* 요일 머리. 토·일은 색으로 구분한다 — 주말 요금이 붙는 날이라 정보다. */} +
+ {WEEKDAY_LABEL.map((label, index) => ( + + {label} + + ))} +
+ +
+ {cells.map((cell, index) => + cell === null ? ( + // 1일 앞의 빈 칸. 요일 열을 맞추는 자리라 버튼이 아니다. + + ) : ( + + ), + )} +
+ {selectedDay?.isWeekend && ( -

주말 요금이 적용되는 날짜입니다.

+

주말 요금이 적용되는 날짜입니다.

)}
@@ -287,7 +367,7 @@ export function StayBookingDemo() {
{selectedDay - ? `${selectedDay.month}월 ${selectedDay.day}일 · ${selectedUnit?.name ?? ''} · ${guests}명` + ? `${cursor.getMonth() + 1}월 ${selectedDay.day}일 · ${selectedUnit?.name ?? ''} · ${guests}명` : '날짜를 골라 주세요'} {price != null && ( diff --git a/solution/site/src/sections/items/ChronicleSection.tsx b/solution/site/src/sections/items/ChronicleSection.tsx index 7189dfe..a81f3a3 100644 --- a/solution/site/src/sections/items/ChronicleSection.tsx +++ b/solution/site/src/sections/items/ChronicleSection.tsx @@ -67,7 +67,7 @@ export function ChronicleSection() {

{item.title}

{item.summary &&

{item.summary}

} {item.place &&

지금 이 자리 · {item.place}

} - +
diff --git a/solution/site/src/sections/items/PeopleSection.tsx b/solution/site/src/sections/items/PeopleSection.tsx index 47154a5..0c69c75 100644 --- a/solution/site/src/sections/items/PeopleSection.tsx +++ b/solution/site/src/sections/items/PeopleSection.tsx @@ -38,6 +38,7 @@ export function PeopleSection() { {parsed.items.map((person, index) => (
+ {/* 사진 출처는 카드 아래 SourceLine 옆에 붙는다(아래) — 라이선스 조건이다. */} {person.imageUrl ? ( {person.oneLine &&

{person.oneLine}

}
- +
))} diff --git a/solution/site/src/sections/items/PostcardSection.tsx b/solution/site/src/sections/items/PostcardSection.tsx index 858a107..720b8e8 100644 --- a/solution/site/src/sections/items/PostcardSection.tsx +++ b/solution/site/src/sections/items/PostcardSection.tsx @@ -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 && ( +

+ 사진 · {card.imageCredit || '출처 표기 없음'} +

+ )}
{/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */}
diff --git a/solution/site/src/sections/items/common.tsx b/solution/site/src/sections/items/common.tsx index d07df48..50234d3 100644 --- a/solution/site/src/sections/items/common.tsx +++ b/solution/site/src/sections/items/common.tsx @@ -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 (

{source?.name && ( @@ -214,6 +227,7 @@ export function SourceLine({source, verified}: {source?: DataSource; verified?: )} )} + {imageCredit && 사진 · {imageCredit}}

); }