Merge branch 'feature/stay-parity'
This commit is contained in:
commit
25f7e5c0c8
@ -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.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import area_contents
|
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
|
from common.utils.gtime import GTime
|
||||||
|
|
||||||
|
|
||||||
@ -76,12 +76,22 @@ class LocalContentCRUD:
|
|||||||
return await DB_SESSION_MNG.add(db, stmt)
|
return await DB_SESSION_MNG.add(db, stmt)
|
||||||
|
|
||||||
async def list_kinds(self, db, region_code: str):
|
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(
|
return await DB_SESSION_MNG.execute(
|
||||||
db,
|
db,
|
||||||
select(area_contents).where(
|
select(area_contents).where(
|
||||||
area_contents.region_code == region_code,
|
area_contents.region_code == region_code,
|
||||||
area_contents.kind.isnot(None),
|
area_contents.kind.in_(STORY_KINDS),
|
||||||
area_contents.deleted == False, # noqa: E712
|
area_contents.deleted == False, # noqa: E712
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from sqlalchemy import and_, delete, func, or_, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
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.enums import ErrorType
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
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]:
|
async def set_link_raw(self, cdb: AsyncSession, place_id, url, raw) -> Tuple[ErrorType, int]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def find_by_external(self, cdb: AsyncSession, owner_user_id, source, external_place_id) -> Tuple[ErrorType, list]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PlaceCRUD(IPlaceCRUD):
|
class PlaceCRUD(IPlaceCRUD):
|
||||||
async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType:
|
async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType:
|
||||||
@ -90,6 +94,42 @@ class PlaceCRUD(IPlaceCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, None
|
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(
|
async def list_places(
|
||||||
self, cdb: AsyncSession, owner_user_id, search: Optional[str], category: Optional[int],
|
self, cdb: AsyncSession, owner_user_id, search: Optional[str], category: Optional[int],
|
||||||
status: Optional[int], skip: int, limit: int,
|
status: Optional[int], skip: int, limit: int,
|
||||||
|
|||||||
@ -89,6 +89,60 @@ async def discover_naver_place(place, place_id: str) -> str:
|
|||||||
return "resolved" if added else "already"
|
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:
|
async def discover_tour_api(place, place_id: str) -> str:
|
||||||
"""검증된 상호·좌표로 TourAPI 콘텐츠를 찾아 등록한다. 반환값은 discover_naver_place 와 같은 규약이다.
|
"""검증된 상호·좌표로 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}")
|
LOG.w(f"[collect] TourAPI 조회 실패(계속): {type(ex).__name__}: {ex}")
|
||||||
stat["tour_api"] = "error"
|
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 로 일괄 활성화하면 일반 크롤링·재수집에서도
|
# 오직 요청 옵션으로만 연다. 서버 env 로 일괄 활성화하면 일반 크롤링·재수집에서도
|
||||||
# 사용자가 모르는 유료 검색이 반복될 수 있으므로 COLLECT_USE_PERPLEXITY 는 더 쓰지 않는다.
|
# 사용자가 모르는 유료 검색이 반복될 수 있으므로 COLLECT_USE_PERPLEXITY 는 더 쓰지 않는다.
|
||||||
if not include_perplexity:
|
if not include_perplexity:
|
||||||
@ -552,6 +616,18 @@ async def run_collect(job: dict) -> dict:
|
|||||||
from services import story_service
|
from services import story_service
|
||||||
result["local_job_id"] = await story_service.enqueue_region_job(place)
|
result["local_job_id"] = await story_service.enqueue_region_job(place)
|
||||||
|
|
||||||
|
# ── 업소 조사 — 소개문을 쓸 재료 ──────────────────────────────────
|
||||||
|
# ★ 수집이 끝난 **뒤**에 한다. 앞에서 하면 네이버·TourAPI 가 이미 준 것을 다시 묻는
|
||||||
|
# 꼴이고, 검색 요금이 그만큼 헛돈다. 수집이 얇게 끝났을 때 그 구멍을 메우는 자리다.
|
||||||
|
# ★ fact 를 만들지 않는다(place_research 머리주석) — 소개문 생성의 근거만 쌓는다.
|
||||||
|
# ★ 실패해도 수집은 성공이다. 재료가 적을 뿐 발행은 된다.
|
||||||
|
try:
|
||||||
|
from services import place_research
|
||||||
|
result["research"] = await place_research.research_place(place, place_id)
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
LOG.w(f"[collect] 업소 조사 실패(계속): {type(ex).__name__}: {ex}")
|
||||||
|
result["research"] = {"error": f"{type(ex).__name__}: {ex}"}
|
||||||
|
|
||||||
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
|
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
|
||||||
LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장")
|
LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장")
|
||||||
return result
|
return result
|
||||||
|
|||||||
@ -27,6 +27,7 @@ from crud.fact_crud import FactCRUD
|
|||||||
from crud.faq_crud import FaqCRUD
|
from crud.faq_crud import FaqCRUD
|
||||||
from crud.place_crud import PlaceCRUD
|
from crud.place_crud import PlaceCRUD
|
||||||
from router.v1.fact.protocol import Req_UpsertFact
|
from router.v1.fact.protocol import Req_UpsertFact
|
||||||
|
from services import place_research
|
||||||
from services.external import gemini_text
|
from services.external import gemini_text
|
||||||
from services.fact_service import FactService
|
from services.fact_service import FactService
|
||||||
|
|
||||||
@ -90,15 +91,31 @@ async def run_copy(job: dict) -> dict:
|
|||||||
# 왜 fact 로 넣지 않는가: `intro` 는 allow_llm=True 라 LLM 의 출력 칸이다.
|
# 왜 fact 로 넣지 않는가: `intro` 는 allow_llm=True 라 LLM 의 출력 칸이다.
|
||||||
# 원문을 그 칸에 넣었더니 457자 원문이 발행본의 '숙소 소개' 를 차지했다(2026-08-31).
|
# 원문을 그 칸에 넣었더니 457자 원문이 발행본의 '숙소 소개' 를 차지했다(2026-08-31).
|
||||||
# 근거로만 쓰고 저장은 하지 않는다 — 원문은 화면에 나가지 않는다.
|
# 근거로만 쓰고 저장은 하지 않는다 — 원문은 화면에 나가지 않는다.
|
||||||
|
# ★ 확정 링크만 읽던 것을 **조사 근거까지** 읽게 넓혔다(2026-09-10).
|
||||||
|
# 업소 조사(`place_research`)는 남이 쓴 글이라 확정하지 않는다 — 공식 채널이 아니므로
|
||||||
|
# 발행본의 sameAs·푸터에 나가면 안 된다. 그런데 그것 때문에 여기서도 안 읽혀서,
|
||||||
|
# 조사해 온 재료가 소개문에 한 글자도 닿지 않았다. 확정 여부는 "화면에 채널로
|
||||||
|
# 내보낼 것인가" 의 판단이지 "근거로 읽을 것인가" 의 판단이 아니다.
|
||||||
|
# ★ 다만 아무 미확정 링크나 읽지는 않는다 — raw.kind 가 research 인 것만이다.
|
||||||
|
# 미확정 채널 URL 은 동명 업소일 수 있고(그게 확정 절차의 이유다), 조사 근거는
|
||||||
|
# 상호 대조를 통과한 것만 적재된다(`grounding/place_research.parse_items`).
|
||||||
|
records: list[str] = []
|
||||||
l_err, link_rows = await DB_SESSION_MNG.execute_lambda(
|
l_err, link_rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
place_channels.DBType(),
|
place_channels.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
DBWRType.DB_READ.value,
|
||||||
lambda s: _place_crud.list_links(s, pid, True),
|
lambda s: _place_crud.list_links(s, pid, False),
|
||||||
)
|
)
|
||||||
if l_err == ErrorType.SUCCESS:
|
if l_err == ErrorType.SUCCESS:
|
||||||
for link in (link_rows or []):
|
for link in (link_rows or []):
|
||||||
text = ((link.raw or {}).get("text") or "").strip() if isinstance(link.raw, dict) else ""
|
raw = link.raw if isinstance(link.raw, dict) else {}
|
||||||
|
if link.confirmed_at is None and raw.get("kind") != place_research.RAW_KIND:
|
||||||
|
continue
|
||||||
|
text = (raw.get("text") or "").strip()
|
||||||
if text:
|
if text:
|
||||||
|
# ★ fact 목록이 아니라 records 로 넘긴다. fact 자리에 넣으면 모델이 값 하나로
|
||||||
|
# 읽고 거의 쓰지 않는다(prompts/copy.build_prompt 머리주석의 실측).
|
||||||
|
records.append(text[:4000])
|
||||||
|
# ground_check 는 여전히 이 글을 근거로 인정해야 한다 — 근거 목록에도 남긴다.
|
||||||
grounded.append(gemini_text.FactInput(
|
grounded.append(gemini_text.FactInput(
|
||||||
key=f"source:{link.link_id}", label="수집 원문", value=text[:4000],
|
key=f"source:{link.link_id}", label="수집 원문", value=text[:4000],
|
||||||
))
|
))
|
||||||
@ -148,6 +165,7 @@ async def run_copy(job: dict) -> dict:
|
|||||||
PlaceCategory(place.category),
|
PlaceCategory(place.category),
|
||||||
grounded,
|
grounded,
|
||||||
unit_summaries=unit_summaries or None,
|
unit_summaries=unit_summaries or None,
|
||||||
|
records=records or None,
|
||||||
model=external_api_config.gemini_text_model,
|
model=external_api_config.gemini_text_model,
|
||||||
)
|
)
|
||||||
except gemini_text.GeminiNotConfigured as ex:
|
except gemini_text.GeminiNotConfigured as ex:
|
||||||
|
|||||||
@ -96,6 +96,7 @@ async def generate_copy(
|
|||||||
facts: list[FactInput],
|
facts: list[FactInput],
|
||||||
*,
|
*,
|
||||||
unit_summaries: Optional[list[dict]] = None,
|
unit_summaries: Optional[list[dict]] = None,
|
||||||
|
records: Optional[list[str]] = None,
|
||||||
max_faqs: int = 8,
|
max_faqs: int = 8,
|
||||||
model: str = DEFAULT_TEXT_MODEL,
|
model: str = DEFAULT_TEXT_MODEL,
|
||||||
max_retries: int = 2,
|
max_retries: int = 2,
|
||||||
@ -124,7 +125,7 @@ async def generate_copy(
|
|||||||
|
|
||||||
body = {
|
body = {
|
||||||
"contents": [{"role": "user", "parts": [{
|
"contents": [{"role": "user", "parts": [{
|
||||||
"text": build_prompt(place_name, category, facts, max_faqs, unit_grounding)
|
"text": build_prompt(place_name, category, facts, max_faqs, unit_grounding, records)
|
||||||
}]}],
|
}]}],
|
||||||
"generationConfig": {
|
"generationConfig": {
|
||||||
"responseMimeType": "application/json",
|
"responseMimeType": "application/json",
|
||||||
|
|||||||
@ -52,7 +52,7 @@ def _normalize(text: str) -> str:
|
|||||||
return re.sub(r"[\s,·.\-_'\"()&]", "", (text or "")).lower()
|
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)
|
html = await _fetch_search_html(query)
|
||||||
if not html:
|
if not html:
|
||||||
return None
|
return None
|
||||||
|
|||||||
37
solution/backend/services/external/tour_api.py
vendored
37
solution/backend/services/external/tour_api.py
vendored
@ -167,6 +167,43 @@ def make_client() -> httpx.AsyncClient:
|
|||||||
return httpx.AsyncClient(timeout=REQUEST_TIMEOUT)
|
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,
|
async def fetch_nearby(client: httpx.AsyncClient, latitude: float, longitude: float,
|
||||||
*, radius_m: int, content_type_id: str) -> list[dict]:
|
*, radius_m: int, content_type_id: str) -> list[dict]:
|
||||||
"""업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다.
|
"""업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다.
|
||||||
|
|||||||
116
solution/backend/services/external/wikimedia.py
vendored
Normal file
116
solution/backend/services/external/wikimedia.py
vendored
Normal 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)
|
||||||
90
solution/backend/services/grounding/place_research.py
Normal file
90
solution/backend/services/grounding/place_research.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"""업소 조사 응답 해석 — 쓸 수 있는 항목만 남긴다.
|
||||||
|
|
||||||
|
`grounding/story.py` 와 같은 규율이다. 다른 점은 하나: 여기서 나온 문장은 **화면에 그대로
|
||||||
|
나가지 않고** 소개문 생성의 근거로만 쓰인다(`copy_service` 의 '수집 원문' 자리). 그래도
|
||||||
|
출처를 똑같이 요구한다 — 근거가 거짓이면 그 근거로 쓴 문장도 거짓이고, ground_check 는
|
||||||
|
"근거에 있는가" 만 보지 "근거가 참인가" 는 못 본다.
|
||||||
|
|
||||||
|
★ 상호 대조를 한다
|
||||||
|
story 와 결정적으로 다른 지점이다. 지역 이야기는 틀려도 "군산 이야기가 조금 부정확한"
|
||||||
|
것이지만, 업소 조사가 틀리면 **남의 가게 이야기가 이 사장님 사이트의 소개문**이 된다 —
|
||||||
|
이 레포에서 가장 비싼 실수다(`collect_service.discover_naver_place` 머리주석).
|
||||||
|
그래서 출처 URL 이나 문장에 상호가 나타나지 않는 항목은 버린다.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
from common.logger import LOG
|
||||||
|
|
||||||
|
_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_text(payload: dict) -> str:
|
||||||
|
choices = payload.get("choices") or []
|
||||||
|
if not choices or not isinstance(choices[0], dict):
|
||||||
|
return ""
|
||||||
|
return ((choices[0].get("message") or {}).get("content")) or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_source(value) -> dict | None:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
url = (value.get("url") or "").strip()
|
||||||
|
if not url.startswith("http"):
|
||||||
|
return None
|
||||||
|
return {"name": (value.get("name") or url).strip(), "url": url}
|
||||||
|
|
||||||
|
|
||||||
|
def _name_tokens(name: str) -> list[str]:
|
||||||
|
"""상호를 대조에 쓸 조각으로. 쉼표·공백·가운뎃점으로 끊는다.
|
||||||
|
|
||||||
|
★ 통짜로 비교하면 안 된다 — '스테이,머뭄' 은 블로그에서 '스테이 머뭄' · '스테이머뭄' 으로
|
||||||
|
적힌다. 두 글자 이상인 조각이 하나라도 걸리면 같은 업소로 본다.
|
||||||
|
"""
|
||||||
|
parts = [p for p in re.split(r"[\s,·・/|]+", name or "") if len(p) >= 2]
|
||||||
|
return parts or ([name] if name else [])
|
||||||
|
|
||||||
|
|
||||||
|
def parse_items(payload: dict, place_name: str, limit: int) -> tuple[list[dict], list[str]]:
|
||||||
|
"""(쓸 수 있는 항목, 버린 이유). 버린 이유는 로그와 잡 결과에 남긴다 — 조용히 버리면
|
||||||
|
"조사가 부실한 것"과 "필터가 과한 것"을 구분할 수 없다."""
|
||||||
|
text = _FENCE_RE.sub("", _payload_text(payload)).strip()
|
||||||
|
if not text:
|
||||||
|
return [], ["응답이 비었다"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
except json.JSONDecodeError as ex:
|
||||||
|
LOG.w(f"[research] JSON 이 아니다: {ex}")
|
||||||
|
return [], [f"JSON 파싱 실패: {ex}"]
|
||||||
|
|
||||||
|
rows = parsed.get("items") if isinstance(parsed, dict) else parsed
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return [], ["items 배열이 없다"]
|
||||||
|
|
||||||
|
tokens = _name_tokens(place_name)
|
||||||
|
items: list[dict] = []
|
||||||
|
dropped: list[str] = []
|
||||||
|
|
||||||
|
for row in rows[: limit * 2]: # 버려질 것을 감안해 넉넉히 보되, 채택은 limit 까지다
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
dropped.append("항목이 객체가 아니다")
|
||||||
|
continue
|
||||||
|
sentence = str(row.get("text") or "").strip()
|
||||||
|
if not sentence:
|
||||||
|
dropped.append("빈 문장")
|
||||||
|
continue
|
||||||
|
source = _clean_source(row.get("source"))
|
||||||
|
if not source:
|
||||||
|
dropped.append(f"출처 없음: {sentence[:30]}")
|
||||||
|
continue
|
||||||
|
# ★ 상호 대조(머리주석). 문장과 출처 주소·이름 어디에도 상호가 없으면 남의 가게다.
|
||||||
|
haystack = f"{sentence} {source['url']} {source['name']}".lower()
|
||||||
|
if not any(tok.lower() in haystack for tok in tokens):
|
||||||
|
dropped.append(f"상호가 없다: {sentence[:30]}")
|
||||||
|
continue
|
||||||
|
items.append({"text": sentence, "source": source})
|
||||||
|
|
||||||
|
return items, dropped
|
||||||
127
solution/backend/services/place_research.py
Normal file
127
solution/backend/services/place_research.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
"""업소 조사 — 소개문을 쓸 **재료**를 공개 웹에서 찾아 근거 자리에 넣는다.
|
||||||
|
|
||||||
|
프롬프트 services/prompts/place_research.py 무엇을 묻나
|
||||||
|
호출 services/llm/perplexity.py HTTP·타임아웃·인증
|
||||||
|
믿을 것인가 services/grounding/place_research.py 출처 필수 · 상호 대조
|
||||||
|
여기 조사 → 근거 적재 (문장은 쓰지 않는다)
|
||||||
|
|
||||||
|
★ 이 모듈은 **문장을 쓰지 않는다.** 소개문은 지금처럼 `copy_service`(Gemini)가 쓴다.
|
||||||
|
여기가 하는 일은 그 생성기에게 줄 재료를 늘리는 것뿐이다. 왜 나누나 —
|
||||||
|
소개문을 검색모델에게 바로 시키면 그 문장의 근거를 우리가 갖지 못하고,
|
||||||
|
`ground_check` 가 근거 없는 문장을 전부 반려해 결국 앙상해진다.
|
||||||
|
|
||||||
|
★ 왜 필요했나 (실측 2026-09-10, 스테이,머뭄)
|
||||||
|
근거 fact 9건으로 생성한 소개문은 "군산시에 있는 스테이,머뭄입니다. 주차 가능." 이었다.
|
||||||
|
네이버 플레이스 3건 · TourAPI 미등록 · 예약 페이지와 인스타는 robots 금지 — 남은 공개
|
||||||
|
출처가 없어서지 생성기 잘못이 아니었다. 그런데 이 업소의 내력(1920년대 고택, 히로쓰 가옥
|
||||||
|
옆, 2024년 리모델링, A동·B동)은 블로그·기사에 있다. 그걸 출처와 함께 가져온다.
|
||||||
|
|
||||||
|
★ 요금은 조사하지 않는다. 블로그의 요금은 대개 옛값이고, 틀리면 예약 클레임이다.
|
||||||
|
fact 는 fact 경로(수집·사장님 확인)로만 들어온다 — 이 모듈은 `place_facts` 를 쓰지 않는다.
|
||||||
|
|
||||||
|
★ 적재 자리: `place_channels.raw` — `copy_service` 가 이미 '수집 원문'을 읽는 그 자리다.
|
||||||
|
새 표를 만들지 않는다. 대신 **확정하지 않는다**(`confirmed_at` NULL) — 조사 출처는
|
||||||
|
이 업소의 공식 채널이 아니라 남이 쓴 글이다. 발행본의 공식 채널·sameAs 에 나가면 안 된다.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.database.model.models import place_channels
|
||||||
|
from common.enums import ErrorType, LinkChannel, SourceType
|
||||||
|
from common.logger import LOG
|
||||||
|
from common.utils.gtime import GTime
|
||||||
|
from crud.place_crud import PlaceCRUD
|
||||||
|
from services.grounding import place_research as grounding
|
||||||
|
from services.llm import perplexity
|
||||||
|
from services.prompts import place_research as prompts
|
||||||
|
|
||||||
|
_place_crud = PlaceCRUD()
|
||||||
|
|
||||||
|
# 검색을 동반해 느리다. 지역 이야기와 같은 값을 쓴다(실측 건당 9~15초).
|
||||||
|
_TIMEOUT = 120.0
|
||||||
|
|
||||||
|
# ★ raw 봉투의 표식. `copy_service` 가 "확정되지 않았지만 근거로는 읽어도 되는 글" 을
|
||||||
|
# 이 값으로 가른다. 크롤 원문(확정 채널)과 섞이지 않게 이름을 붙여 둔다.
|
||||||
|
RAW_KIND = "research"
|
||||||
|
|
||||||
|
|
||||||
|
def _envelope(items: list[dict]) -> dict:
|
||||||
|
"""근거 봉투. text 는 `copy_service` 가 그대로 읽고, sources 는 추적용으로 남긴다.
|
||||||
|
|
||||||
|
★ 문장 뒤에 출처를 붙여 한 덩어리로 만든다 — 생성기가 문장만 보고 쓰더라도,
|
||||||
|
나중에 "이 소개문의 이 대목은 어디서 왔나" 를 raw 만 열어 보면 알 수 있어야 한다.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"kind": RAW_KIND,
|
||||||
|
"text": "\n".join(f"- {row['text']} (출처: {row['source']['name']})" for row in items),
|
||||||
|
"sources": [row["source"] for row in items],
|
||||||
|
"collected_at": GTime.UTC().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def research_place(place, place_id: str) -> dict:
|
||||||
|
"""업소 하나를 조사해 근거를 적재한다. 채택 건수와 버린 이유를 돌려준다.
|
||||||
|
|
||||||
|
실패는 예외로 올리지 않는다 — 조사가 없어도 발행은 되어야 한다(재료가 적을 뿐이다).
|
||||||
|
"""
|
||||||
|
name = (getattr(place, "name", None) or "").strip()
|
||||||
|
address = (getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip()
|
||||||
|
if not name or not address:
|
||||||
|
return {"skipped": "상호·주소를 모른다"}
|
||||||
|
if not perplexity.is_configured():
|
||||||
|
return {"skipped": "PERPLEXITY_API_KEY 미설정"}
|
||||||
|
|
||||||
|
# 업종 이름은 업종 스키마가 단일 출처다(`common/category_schema`) — 여기에 표를 또 적으면
|
||||||
|
# 업종이 늘 때 한쪽만 늘어난다.
|
||||||
|
from common.category_schema import get_schema
|
||||||
|
|
||||||
|
category_label = get_schema(place.category).label
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"model": perplexity.DEFAULT_MODEL,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": prompts.SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": prompts.build_prompt(name, address, category_label)},
|
||||||
|
],
|
||||||
|
"max_tokens": perplexity.DEFAULT_MAX_TOKENS,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
payload = await perplexity.call(body, client=client)
|
||||||
|
except perplexity.PerplexityNotConfigured:
|
||||||
|
return {"skipped": "PERPLEXITY_API_KEY 미설정"}
|
||||||
|
except perplexity.PerplexityError as ex:
|
||||||
|
LOG.w(f"[research] 호출 실패 place={place_id}: {ex}")
|
||||||
|
return {"error": str(ex)}
|
||||||
|
|
||||||
|
items, dropped = grounding.parse_items(payload, name, prompts.MAX_ITEMS)
|
||||||
|
LOG.i(f"[research] '{name}' 조사 {len(items)}건 채택, {len(dropped)}건 버림")
|
||||||
|
if not items:
|
||||||
|
return {"items": 0, "dropped": dropped}
|
||||||
|
|
||||||
|
# 출처 주소 하나를 대표로 링크에 단다 — 없는 URL 을 만들지 않기 위해 첫 출처를 쓴다.
|
||||||
|
url = items[0]["source"]["url"]
|
||||||
|
row = place_channels(
|
||||||
|
link_id=uuid.uuid4(),
|
||||||
|
place_id=uuid.UUID(place_id),
|
||||||
|
channel=LinkChannel.ETC.value,
|
||||||
|
url=url,
|
||||||
|
title=f"{name} 조사 근거",
|
||||||
|
discovered_by=SourceType.API.value,
|
||||||
|
discovered_at=GTime.UTC(),
|
||||||
|
raw=_envelope(items),
|
||||||
|
)
|
||||||
|
err = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[place_channels.DBType()], [lambda s: _place_crud.add_link(s, row)],
|
||||||
|
)
|
||||||
|
if err != ErrorType.SUCCESS:
|
||||||
|
# 이미 같은 URL 이 있으면 raw 만 갱신한다 — 재조사가 행을 늘리면 안 된다.
|
||||||
|
await DB_SESSION_MNG.execute_lambda_claim(
|
||||||
|
place_channels.DBType(),
|
||||||
|
lambda s: _place_crud.set_link_raw(s, uuid.UUID(place_id), url, _envelope(items)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"items": len(items), "dropped": dropped, "source": url}
|
||||||
@ -2,6 +2,8 @@ import uuid
|
|||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from common.category_schema import CategorySchemaError, get_schema
|
from common.category_schema import CategorySchemaError, get_schema
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
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, places, place_units
|
||||||
@ -189,6 +191,44 @@ class PlaceService:
|
|||||||
return res
|
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:
|
async def verify_place_by_url(self, user_info: UserInfo, place_id: str, req: Req_VerifyPlaceByUrl) -> Res_Place:
|
||||||
"""네이버 플레이스 URL → 상호·주소·좌표를 읽어 동일 업소를 확정하고, 그 URL 을 수집 채널로 등록한다.
|
"""네이버 플레이스 URL → 상호·주소·좌표를 읽어 동일 업소를 확정하고, 그 URL 을 수집 채널로 등록한다.
|
||||||
|
|
||||||
@ -234,6 +274,50 @@ class PlaceService:
|
|||||||
res.msg = "이 주소에서 상호를 찾지 못했습니다."
|
res.msg = "이 주소에서 상호를 찾지 못했습니다."
|
||||||
return res
|
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 {}
|
coord = base.get("coordinate") or {}
|
||||||
verify_req = Req_VerifyPlace(
|
verify_req = Req_VerifyPlace(
|
||||||
source=ExternalPlaceSource.NAVER,
|
source=ExternalPlaceSource.NAVER,
|
||||||
|
|||||||
@ -55,7 +55,16 @@ def build_prompt(
|
|||||||
facts: Sequence[FactLike],
|
facts: Sequence[FactLike],
|
||||||
max_faqs: int,
|
max_faqs: int,
|
||||||
unit_facts: Optional[Sequence[FactLike]] = None,
|
unit_facts: Optional[Sequence[FactLike]] = None,
|
||||||
|
records: Optional[Sequence[str]] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
"""소개문·메타·FAQ 생성 프롬프트.
|
||||||
|
|
||||||
|
★ `records` 는 fact 가 아니라 **글**이다(수집 원문 · 업소 조사 결과).
|
||||||
|
예전에는 이것도 fact 목록에 `- source:<uuid> (수집 원문) = …` 로 섞여 들어갔다.
|
||||||
|
모델은 그걸 값 하나로 읽고 거의 쓰지 않았다 — 실측(2026-09-10, 스테이,머뭄):
|
||||||
|
조사 근거 448자를 넣어도 소개문은 "주방 시설을 갖춘 독채형 객실을 운영하는
|
||||||
|
숙박업소입니다" 에서 한 발도 못 나갔다. 재료를 재료 자리에 놓아야 쓴다.
|
||||||
|
"""
|
||||||
sections = [
|
sections = [
|
||||||
f"'{place_name}'({_CATEGORY_LABEL.get(category, '사업장')})의 공식 홈페이지 문구를 작성한다.",
|
f"'{place_name}'({_CATEGORY_LABEL.get(category, '사업장')})의 공식 홈페이지 문구를 작성한다.",
|
||||||
"",
|
"",
|
||||||
@ -64,16 +73,26 @@ def build_prompt(
|
|||||||
]
|
]
|
||||||
if unit_facts:
|
if unit_facts:
|
||||||
sections.extend(["", "확인된 객실·메뉴 사실:", _fact_lines(unit_facts)])
|
sections.extend(["", "확인된 객실·메뉴 사실:", _fact_lines(unit_facts)])
|
||||||
|
if records:
|
||||||
|
sections.extend([
|
||||||
|
"",
|
||||||
|
"업소에 대해 확인된 기록(출처가 있는 글):",
|
||||||
|
*(f"- {line}" for line in records),
|
||||||
|
])
|
||||||
sections.extend([
|
sections.extend([
|
||||||
"",
|
"",
|
||||||
"출력:",
|
"출력:",
|
||||||
"- intro: 소개문 100~250자",
|
# ★ 100~250자였다. 그 길이로는 확인된 사실을 나열하면 끝나서, 기록이 있어도
|
||||||
|
# 들어갈 자리가 없었다. 시안(/s/stay)의 소개는 3문단 450자 안팎이다.
|
||||||
|
"- intro: 소개문 200~600자. 사실이 충분하면 2~3문단으로 나눈다(문단 사이 빈 줄)",
|
||||||
"- intro_fact_keys: 소개문의 근거 key",
|
"- intro_fact_keys: 소개문의 근거 key",
|
||||||
"- meta_description: 검색 요약 50~120자",
|
"- meta_description: 검색 요약 50~120자",
|
||||||
f"- faqs: 최대 {max_faqs}개, 각 항목에 근거 fact_keys 포함",
|
f"- faqs: 최대 {max_faqs}개, 각 항목에 근거 fact_keys 포함",
|
||||||
"",
|
"",
|
||||||
"규칙:",
|
"규칙:",
|
||||||
"- 위 사실에 없는 숫자·시설·지역 정보를 지어내지 마라.",
|
"- 위 사실에 없는 숫자·시설·지역 정보를 지어내지 마라.",
|
||||||
|
"- **기록 절에 있는 내용은 적극적으로 쓴다.** 그것도 출처가 확인된 사실이다 —"
|
||||||
|
" 건물의 내력·공간 구성·주변과의 관계처럼 이 업소만의 이야기가 거기 있다.",
|
||||||
"- false·불가·없음 값을 가능하다고 표현하지 않는다.",
|
"- false·불가·없음 값을 가능하다고 표현하지 않는다.",
|
||||||
"- 홍보성·평가성 표현을 쓰지 않는다.",
|
"- 홍보성·평가성 표현을 쓰지 않는다.",
|
||||||
"- 근거 없는 FAQ는 만들지 않는다.",
|
"- 근거 없는 FAQ는 만들지 않는다.",
|
||||||
|
|||||||
60
solution/backend/services/prompts/place_research.py
Normal file
60
solution/backend/services/prompts/place_research.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"""업소 조사 프롬프트 — 소개문을 쓸 **근거**를 공개 웹에서 찾아온다.
|
||||||
|
|
||||||
|
★ 무엇을 요구하나
|
||||||
|
"소개문을 써 달라"가 아니다. **사실 조각을 출처와 함께** 달라고 한다.
|
||||||
|
소개문을 모델에게 바로 시키면 그 문장이 어디서 왔는지 알 수 없고, 우리 규칙은
|
||||||
|
근거 없는 문장을 발행하지 않는다(`copy_service.ground_check`). 그래서 이 단계는
|
||||||
|
재료만 모으고, 문장은 기존 생성기(Gemini)가 그 재료로 쓴다.
|
||||||
|
|
||||||
|
★ 왜 이게 필요한가 (실측 2026-09-10, 스테이,머뭄)
|
||||||
|
네이버 플레이스가 주는 fact 는 3건(주차·와이파이·휠체어)뿐이고 TourAPI 는 미등록,
|
||||||
|
예약 페이지와 인스타그램은 robots 가 자동 수집을 금지한다. 그 상태로 소개문을 생성하면
|
||||||
|
"군산시에 있는 스테이,머뭄입니다. 주차 가능." 한 줄이 나온다 — 쓸 재료가 그것뿐이라
|
||||||
|
생성기 잘못이 아니다. 그런데 이 업소에 대한 사실(1920년대 고택 · 히로쓰 가옥 옆 ·
|
||||||
|
2024년 리모델링 · A동/B동)은 블로그·기사에 공개돼 있다. 그걸 **출처와 함께** 가져오는
|
||||||
|
자리가 없었을 뿐이다.
|
||||||
|
|
||||||
|
★ 지어내게 두지 않는다
|
||||||
|
- 항목마다 출처 URL 을 요구한다. 없으면 버린다(`grounding/place_research.py`).
|
||||||
|
- 확인할 수 없는 것은 비우라고 명시한다. 모델은 빈칸을 싫어해서, 안 그러면 채운다.
|
||||||
|
- **가격·객실 수·운영 규정은 묻지 않는다.** 그건 fact 이고, 틀리면 예약 클레임이 난다 —
|
||||||
|
출처가 블로그면 옛 요금이 그대로 올라온다. 이 단계가 모으는 것은 **소개문의 재료**다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = (
|
||||||
|
"당신은 지역 업소를 조사하는 사람이다. 웹에서 확인되는 사실만 적는다. "
|
||||||
|
"확인되지 않으면 그 항목을 아예 빼라 — 추측하거나 일반론으로 채우지 마라. "
|
||||||
|
"출력은 JSON 하나뿐이고 코드펜스를 두르지 않는다."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ★ 최대 개수를 둔다. 많이 받아 봐야 소개문 한 문단이고, 길수록 옛 정보가 섞인다.
|
||||||
|
MAX_ITEMS = 8
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(name: str, address: str, category_label: str) -> str:
|
||||||
|
"""조사 프롬프트. 상호와 주소를 **둘 다** 준다 — 동명 업소를 가르는 유일한 단서다."""
|
||||||
|
return f"""다음 업소에 대해 웹에서 확인되는 사실을 모아라.
|
||||||
|
|
||||||
|
상호: {name}
|
||||||
|
주소: {address}
|
||||||
|
업종: {category_label}
|
||||||
|
|
||||||
|
[무엇을 찾나]
|
||||||
|
- 이 업소만의 특징: 건물의 내력·연식, 공간 구성, 주변 랜드마크와의 관계, 운영 방식
|
||||||
|
- 손님이 실제로 겪는 것: 어떤 사람이 어떤 목적으로 오는가, 무엇이 인상적이라고 말하는가
|
||||||
|
- 시기: 문을 연 때, 고쳐 지은 때
|
||||||
|
|
||||||
|
[적지 않을 것]
|
||||||
|
- 요금·객실 수·체크인 시각·취소 규정 — 이건 다른 경로로 확인한다. 옛 값이 섞이면 위험하다
|
||||||
|
- "아름다운", "최고의" 같은 형용사만 있는 문장
|
||||||
|
- 다른 업소 이야기. 상호와 주소가 위와 일치하는 곳만이다
|
||||||
|
|
||||||
|
[문장 쓰는 법]
|
||||||
|
- **각 문장에 업소 이름을 넣어라.** "이 숙소는…" 처럼 쓰지 마라 — 문장만 떼어 놔도 어느
|
||||||
|
업소 이야기인지 알 수 있어야 한다. 뒤에서 이 문장들만 모아 근거로 쓰기 때문이다.
|
||||||
|
- 한 문장에 사실 하나. 두 가지를 이어 붙이면 한쪽이 틀렸을 때 통째로 버리게 된다.
|
||||||
|
|
||||||
|
[형식] 아래 JSON 만 출력한다. 각 항목에 **그 사실이 적힌 페이지 주소**를 단다.
|
||||||
|
{{"items": [{{"text": "한 문장으로 적은 사실", "source": {{"name": "출처 이름", "url": "https://..."}}}}]}}
|
||||||
|
|
||||||
|
항목은 최대 {MAX_ITEMS}개. 출처를 댈 수 없는 항목은 넣지 마라 — 적게 주는 편이 낫다."""
|
||||||
@ -28,14 +28,14 @@
|
|||||||
"label": "시간의 골목",
|
"label": "시간의 골목",
|
||||||
"maxItems": 14,
|
"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://...\"} } ] }",
|
"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": {
|
"postcard": {
|
||||||
"kind": "postcard",
|
"kind": "postcard",
|
||||||
"label": "오늘의 엽서",
|
"label": "오늘의 엽서",
|
||||||
"maxItems": 12,
|
"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://...\"} } ] }",
|
"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": {
|
"quiz": {
|
||||||
"kind": "quiz",
|
"kind": "quiz",
|
||||||
|
|||||||
@ -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):
|
def _get(row, key, default=None):
|
||||||
"""ORM 행이든 dict 든 같은 방식으로 읽는다(테스트가 가짜 행을 넣기 쉬우라고)."""
|
"""ORM 행이든 dict 든 같은 방식으로 읽는다(테스트가 가짜 행을 넣기 쉬우라고)."""
|
||||||
if isinstance(row, 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 []
|
paragraphs = [p.strip() for p in intro.split("\n") if p.strip()] if intro else []
|
||||||
narrative = {
|
narrative = {
|
||||||
"about": paragraphs,
|
"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]
|
theme_spec = _DEFAULT_THEME.get(category) or _DEFAULT_THEME[PlaceCategory.LODGING.value]
|
||||||
|
|||||||
@ -31,6 +31,7 @@ from common.utils.gtime import GTime
|
|||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from crud.local_content_crud import LocalContentCRUD
|
from crud.local_content_crud import LocalContentCRUD
|
||||||
from services.grounding import story as grounding
|
from services.grounding import story as grounding
|
||||||
|
from services.external import tour_api, wikimedia
|
||||||
from services.llm import perplexity
|
from services.llm import perplexity
|
||||||
from services.prompts import story as prompts
|
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}"]
|
return [], [f"호출 실패: {ex}"]
|
||||||
|
|
||||||
items, dropped = grounding.parse_items(payload, kind, prompts.max_items(kind))
|
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)}건 버림")
|
LOG.i(f"[story] {region_label} {kind}: {len(items)}건 채택, {len(dropped)}건 버림")
|
||||||
return items, 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:
|
async def generate_region_stories(region_code: str, region_label: str, kinds: list[str] | None = None) -> dict:
|
||||||
"""지역 하나의 이야기를 생성해 `area_contents` 에 넣는다. 종류별 채택 건수를 돌려준다.
|
"""지역 하나의 이야기를 생성해 `area_contents` 에 넣는다. 종류별 채택 건수를 돌려준다.
|
||||||
|
|
||||||
|
|||||||
@ -65,9 +65,13 @@ export async function ensureServerPlace(
|
|||||||
}
|
}
|
||||||
// ★ 상호·주소는 서버가 읽은 값으로 덮는다. 사장님이 검색창에 친 이름이 아니라
|
// ★ 상호·주소는 서버가 읽은 값으로 덮는다. 사장님이 검색창에 친 이름이 아니라
|
||||||
// 네이버에 등록된 공식 표기가 이 사이트의 기준 정보가 되어야 한다.
|
// 네이버에 등록된 공식 표기가 이 사이트의 기준 정보가 되어야 한다.
|
||||||
|
// ★ **place_id 도 서버가 돌려준 것을 쓴다.** 같은 가게를 이미 갖고 있으면 서버가
|
||||||
|
// 그 정본을 돌려주고 방금 만든 빈 행을 접는다(`verify_place_by_url` 의 중복 합치기).
|
||||||
|
// 여기서 우리가 만든 id 를 계속 붙들면, 화면은 **접힌 행**을 편집하게 된다 —
|
||||||
|
// 저장은 되는데 목록·발행본은 정본을 보므로 "고쳤는데 반영이 안 된다" 가 된다.
|
||||||
return {
|
return {
|
||||||
...identity,
|
...identity,
|
||||||
placeId,
|
placeId: place.place_id ?? placeId,
|
||||||
name: place.name ?? identity.name,
|
name: place.name ?? identity.name,
|
||||||
address: place.road_address ?? place.address ?? identity.address,
|
address: place.road_address ?? place.address ?? identity.address,
|
||||||
phone: place.phone ?? identity.phone,
|
phone: place.phone ?? identity.phone,
|
||||||
|
|||||||
@ -97,6 +97,11 @@ export interface PeopleItem {
|
|||||||
* (`services/external/tour_places.py` 의 `_IMAGE_OK`).
|
* (`services/external/tour_places.py` 의 `_IMAGE_OK`).
|
||||||
*/
|
*/
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
/**
|
||||||
|
* 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면
|
||||||
|
* 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`).
|
||||||
|
*/
|
||||||
|
imageCredit?: string;
|
||||||
verified?: DataVerified;
|
verified?: DataVerified;
|
||||||
source?: DataSource;
|
source?: DataSource;
|
||||||
}
|
}
|
||||||
@ -109,6 +114,11 @@ export interface ChronicleItem {
|
|||||||
place?: string;
|
place?: string;
|
||||||
/** 그 해의 사진. 없으면 연표는 지금처럼 활자만으로 선다 — 자리를 비워 두지 않는다(PeopleItem.imageUrl 과 같은 출처·규칙). */
|
/** 그 해의 사진. 없으면 연표는 지금처럼 활자만으로 선다 — 자리를 비워 두지 않는다(PeopleItem.imageUrl 과 같은 출처·규칙). */
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
/**
|
||||||
|
* 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면
|
||||||
|
* 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`).
|
||||||
|
*/
|
||||||
|
imageCredit?: string;
|
||||||
/** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */
|
/** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */
|
||||||
turning?: boolean;
|
turning?: boolean;
|
||||||
verified?: DataVerified;
|
verified?: DataVerified;
|
||||||
@ -136,6 +146,11 @@ export interface PostcardItem {
|
|||||||
postmark?: string;
|
postmark?: string;
|
||||||
/** 엽서 앞면 사진. 없으면 뒷면(문장·우표·소인)만 있는 지금 모양 그대로다(PeopleItem.imageUrl 과 같은 출처·규칙). */
|
/** 엽서 앞면 사진. 없으면 뒷면(문장·우표·소인)만 있는 지금 모양 그대로다(PeopleItem.imageUrl 과 같은 출처·규칙). */
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
/**
|
||||||
|
* 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면
|
||||||
|
* 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`).
|
||||||
|
*/
|
||||||
|
imageCredit?: string;
|
||||||
verified?: DataVerified;
|
verified?: DataVerified;
|
||||||
source?: DataSource;
|
source?: DataSource;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -151,6 +151,9 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
|
|||||||
· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.
|
· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.
|
||||||
· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.
|
· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.
|
||||||
· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.
|
· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.
|
||||||
|
· 다만 **찾아갈 수 있는 자리가 있으면 반드시 적는다.** 행정 개편처럼 장소가 없어 보이는
|
||||||
|
사건에도 그 일이 남긴 자리가 대개 있다(개항 → 항구, 부두 건설 → 그 부두, 준공 → 그 구조물).
|
||||||
|
이 값으로 공공데이터에서 그 해의 사진을 찾아 붙인다 — 비면 연표가 활자만으로 선다.
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -173,6 +176,12 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
|
|||||||
· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.
|
· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.
|
||||||
· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.
|
· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.
|
||||||
· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.
|
· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.
|
||||||
|
· **같은 대상을 두 번 쓰지 않는다.** 열두 장이 전부 다른 곳·다른 이야기여야 한다 —
|
||||||
|
한 시설의 주소·휴관일·운영시간을 나눠 적으면 엽서가 아니라 안내문 열두 장이 된다
|
||||||
|
(실측 2026-09-10: 네 장이 같은 박물관의 관람안내였다).
|
||||||
|
· 운영시간·휴관일·주소·요금은 엽서에 적지 않는다. 그건 이용 정보지 엽서 문장이 아니다.
|
||||||
|
· place 는 사진을 찾는 열쇠다. **찾아갈 수 있는 곳 이름을 정확히** 적는다 —
|
||||||
|
이 값으로 공공데이터에서 앞면 사진을 찾고, 못 찾으면 그 엽서는 실리지 않는다.
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -59,7 +59,14 @@ export function HomePage() {
|
|||||||
rooms: UnitsSection,
|
rooms: UnitsSection,
|
||||||
menu: UnitsSection,
|
menu: UnitsSection,
|
||||||
programs: 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,
|
space: SpaceSection,
|
||||||
inquiry: InquirySection,
|
inquiry: InquirySection,
|
||||||
exhibition: ExhibitionSection,
|
exhibition: ExhibitionSection,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import {useEffect, useMemo, useState} from 'react';
|
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 {factText, sanitizeUnits, selectPublishable, type SitePayload} from '@o2o/shared';
|
||||||
import {useSite} from '@site/lib/site-context';
|
import {useSite} from '@site/lib/site-context';
|
||||||
import {unitBaseRate} from '@site/seo/jsonld';
|
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 WEEKDAY_LABEL = ['일', '월', '화', '수', '목', '금', '토'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고를 수 있는 앞선 달 수. 오늘이 든 달부터 이만큼 앞으로 넘길 수 있다.
|
||||||
|
*
|
||||||
|
* ★ 상한을 두는 이유: 우리는 빈 방을 모른다. 반년 뒤를 고르게 두면 손님은 그 날짜가
|
||||||
|
* 열려 있다고 읽는다 — 모르는 것을 넓게 열어 두는 쪽이 더 나쁜 거짓이다.
|
||||||
|
*/
|
||||||
|
const MONTH_SPAN = 2;
|
||||||
|
|
||||||
interface DayCell {
|
interface DayCell {
|
||||||
iso: string;
|
iso: string;
|
||||||
month: number;
|
|
||||||
day: number;
|
day: number;
|
||||||
weekday: number;
|
weekday: number;
|
||||||
/** 토·일은 주말 요금이 붙는 날이다. 요금 계산의 근거가 화면에도 보여야 한다. */
|
/** 토·일은 주말 요금이 붙는 날이다. 요금 계산의 근거가 화면에도 보여야 한다. */
|
||||||
isWeekend: boolean;
|
isWeekend: boolean;
|
||||||
|
/** 지난 날짜. **고를 수 없는 유일한 사유**다 — '마감' 같은 표시는 만들지 않는다(머리주석). */
|
||||||
|
past: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDays(from: Date): DayCell[] {
|
function isoOf(date: Date): string {
|
||||||
return Array.from({length: DAY_COUNT}, (_, index) => {
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
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')}`,
|
* 한 달치 칸. 1일이 무슨 요일인지에 따라 앞에 빈 칸을 채워 **요일 열을 맞춘다** —
|
||||||
month: date.getMonth() + 1,
|
* 달력은 열이 맞아야 달력이고, 어긋나면 그냥 숫자 목록이다.
|
||||||
day: date.getDate(),
|
*/
|
||||||
weekday,
|
function buildMonth(year: number, month: number, today: Date): (DayCell | null)[] {
|
||||||
isWeekend: weekday === 0 || weekday === 6,
|
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]);
|
const slots = useMemo(() => buildArrivalSlots(checkIn), [checkIn]);
|
||||||
|
|
||||||
/** ★ 서버 렌더에서는 false — 날짜를 HTML 에 굽지 않기 위한 게이트(머리주석). */
|
/**
|
||||||
const [days, setDays] = useState<DayCell[] | null>(null);
|
* ★ 서버 렌더에서는 null — 날짜를 HTML 에 굽지 않기 위한 게이트(머리주석).
|
||||||
useEffect(() => setDays(buildDays(new Date())), []);
|
* '오늘' 을 브라우저에서 정하므로 이 값이 곧 달력의 기준이다.
|
||||||
|
*/
|
||||||
|
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 [dateIso, setDateIso] = useState<string | null>(null);
|
||||||
const [slot, setSlot] = useState<string | null>(null);
|
const [slot, setSlot] = useState<string | null>(null);
|
||||||
@ -116,7 +150,7 @@ export function StayBookingDemo() {
|
|||||||
const [guests, setGuests] = useState(2);
|
const [guests, setGuests] = useState(2);
|
||||||
const [submitted, setSubmitted] = useState(false);
|
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 selectedUnit = units.find((unit) => unit.unitId === unitId) ?? null;
|
||||||
const maxGuests = selectedUnit?.maxCapacity ?? 8;
|
const maxGuests = selectedUnit?.maxCapacity ?? 8;
|
||||||
|
|
||||||
@ -146,7 +180,7 @@ export function StayBookingDemo() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 서버 렌더 · 자바스크립트 꺼짐: 달력 대신 사실만 내보낸다(머리주석). */}
|
{/* 서버 렌더 · 자바스크립트 꺼짐: 달력 대신 사실만 내보낸다(머리주석). */}
|
||||||
{days === null ? (
|
{today === null || cursor === null ? (
|
||||||
<p className="px-4 py-6 text-xs leading-relaxed opacity-60 sm:px-5">
|
<p className="px-4 py-6 text-xs leading-relaxed opacity-60 sm:px-5">
|
||||||
날짜 선택은 브라우저에서 열립니다. 실제 예약 가능 여부와 결제는 아래 예약 창구에서
|
날짜 선택은 브라우저에서 열립니다. 실제 예약 가능 여부와 결제는 아래 예약 창구에서
|
||||||
확인해 주세요.
|
확인해 주세요.
|
||||||
@ -156,7 +190,7 @@ export function StayBookingDemo() {
|
|||||||
payload={payload}
|
payload={payload}
|
||||||
onReset={() => setSubmitted(false)}
|
onReset={() => setSubmitted(false)}
|
||||||
summary={[
|
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,
|
slot ? `도착 ${slot}` : null,
|
||||||
selectedUnit?.name ?? null,
|
selectedUnit?.name ?? null,
|
||||||
`${guests}명`,
|
`${guests}명`,
|
||||||
@ -166,34 +200,80 @@ export function StayBookingDemo() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5 p-4 sm:p-5">
|
<div className="space-y-5 p-4 sm:p-5">
|
||||||
{/* ── 날짜 ─────────────────────────────────────── */}
|
{/* ── 날짜 (달력) ──────────────────────────────── */}
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-[11px] font-semibold opacity-55">날짜</p>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<ul className="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-1">
|
<p className="text-[11px] font-semibold opacity-55">날짜</p>
|
||||||
{days.map((day) => {
|
<div className="flex items-center gap-1">
|
||||||
const active = day.iso === dateIso;
|
|
||||||
return (
|
|
||||||
<li key={day.iso}>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDateIso(day.iso)}
|
onClick={() => setMonthOffset((n) => Math.max(0, n - 1))}
|
||||||
aria-pressed={active}
|
disabled={monthOffset === 0}
|
||||||
className="flex w-13 shrink-0 flex-col items-center gap-0.5 rounded-xl border px-2 py-2 text-xs transition-colors"
|
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={{
|
style={{
|
||||||
borderColor: active ? 'var(--color-brand)' : 'rgba(0,0,0,0.10)',
|
borderColor: cell.iso === dateIso ? 'var(--color-brand)' : 'transparent',
|
||||||
backgroundColor: active ? 'var(--color-brand)' : 'var(--color-surface-alt)',
|
backgroundColor:
|
||||||
color: active ? '#fff' : undefined,
|
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>
|
{cell.day}
|
||||||
<span className="font-bold">{day.day}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</li>
|
),
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
</ul>
|
|
||||||
{selectedDay?.isWeekend && (
|
{selectedDay?.isWeekend && (
|
||||||
<p className="mt-1.5 text-[11px] opacity-55">주말 요금이 적용되는 날짜입니다.</p>
|
<p className="mt-2 text-[11px] opacity-55">주말 요금이 적용되는 날짜입니다.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -287,7 +367,7 @@ export function StayBookingDemo() {
|
|||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<span className="text-xs opacity-60">
|
<span className="text-xs opacity-60">
|
||||||
{selectedDay
|
{selectedDay
|
||||||
? `${selectedDay.month}월 ${selectedDay.day}일 · ${selectedUnit?.name ?? ''} · ${guests}명`
|
? `${cursor.getMonth() + 1}월 ${selectedDay.day}일 · ${selectedUnit?.name ?? ''} · ${guests}명`
|
||||||
: '날짜를 골라 주세요'}
|
: '날짜를 골라 주세요'}
|
||||||
</span>
|
</span>
|
||||||
{price != null && (
|
{price != null && (
|
||||||
|
|||||||
@ -67,7 +67,7 @@ export function ChronicleSection() {
|
|||||||
<h3 className="serif text-base font-bold">{item.title}</h3>
|
<h3 className="serif text-base font-bold">{item.title}</h3>
|
||||||
{item.summary && <p className="text-[13px] leading-relaxed opacity-80">{item.summary}</p>}
|
{item.summary && <p className="text-[13px] leading-relaxed opacity-80">{item.summary}</p>}
|
||||||
{item.place && <p className="text-[11px] opacity-60">지금 이 자리 · {item.place}</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>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export function PeopleSection() {
|
|||||||
<Rail label="인물 목록" arrows="overlay">
|
<Rail label="인물 목록" arrows="overlay">
|
||||||
{parsed.items.map((person, index) => (
|
{parsed.items.map((person, index) => (
|
||||||
<article key={`${person.name}-${index}`} className="w-[210px] shrink-0 snap-center">
|
<article key={`${person.name}-${index}`} className="w-[210px] shrink-0 snap-center">
|
||||||
|
{/* 사진 출처는 카드 아래 SourceLine 옆에 붙는다(아래) — 라이선스 조건이다. */}
|
||||||
{person.imageUrl ? (
|
{person.imageUrl ? (
|
||||||
<img
|
<img
|
||||||
src={person.imageUrl}
|
src={person.imageUrl}
|
||||||
@ -69,7 +70,7 @@ export function PeopleSection() {
|
|||||||
</p>
|
</p>
|
||||||
{person.oneLine && <p className="mt-2 text-[13px] leading-relaxed opacity-85">{person.oneLine}</p>}
|
{person.oneLine && <p className="mt-2 text-[13px] leading-relaxed opacity-85">{person.oneLine}</p>}
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<SourceLine source={person.source} verified={person.verified} />
|
<SourceLine source={person.source} verified={person.verified} imageCredit={person.imageCredit} />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -34,10 +34,16 @@ export function PostcardSection() {
|
|||||||
alt={`${card.place || card.postmark || '엽서'} 사진`}
|
alt={`${card.place || card.postmark || '엽서'} 사진`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
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}}
|
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="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
|
||||||
{/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */}
|
{/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */}
|
||||||
<div className="min-w-0 border-r pr-3" style={{borderColor: ITEM_BORDER}}>
|
<div className="min-w-0 border-r pr-3" style={{borderColor: ITEM_BORDER}}>
|
||||||
|
|||||||
@ -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 는 계약에 남겨 둔다(에디터·게이트가 읽는다). 화면에만 안 낸다.
|
// verified 는 계약에 남겨 둔다(에디터·게이트가 읽는다). 화면에만 안 낸다.
|
||||||
void verified;
|
void verified;
|
||||||
if (!source?.name) return null;
|
if (!source?.name && !imageCredit) return null;
|
||||||
return (
|
return (
|
||||||
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] opacity-70">
|
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] opacity-70">
|
||||||
{source?.name && (
|
{source?.name && (
|
||||||
@ -214,6 +227,7 @@ export function SourceLine({source, verified}: {source?: DataSource; verified?:
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{imageCredit && <span>사진 · {imageCredit}</span>}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user