From 571c994f0f59f885d021c54c891801b099a6fda4 Mon Sep 17 00:00:00 2001
From: rrrrok <132427414+rrrrok@users.noreply.github.com>
Date: Thu, 17 Sep 2026 09:30:39 +0900
Subject: [PATCH 1/8] feat(places): collect TourAPI overviews for essential
places
areaBasedList2 carries no overview, so the field was always null. When
essential places lack a Korean overview, the TourAPI run collects
detailCommon2 for up to sixty of them instead of rotating province catalogs,
and the normalizer stores the text without re-upserting the place. Catalog
rows no longer erase a stored overview, an empty source overview is kept as
an empty marker so the place is not requested again, and the reader serves
that marker as null.
---
api/CHANGELOG.md | 1 +
api/KNOWN_GAPS.md | 2 +-
api/app/normalization/places.py | 52 ++++++++-
api/app/readmodels/repository.py | 2 +-
api/app/scheduler/runtime.py | 30 ++++++
api/app/sources/plans.py | 21 ++++
api/app/sources/public_data.py | 2 +-
api/app/sources/registry.py | 9 +-
api/tests/integration/test_public_pipeline.py | 48 +++++++++
.../unit/test_place_normalization_contract.py | 102 ++++++++++++++++++
api/tests/unit/test_public_data_budget.py | 46 ++++++++
api/tests/unit/test_public_data_plans.py | 19 +++-
12 files changed, 326 insertions(+), 8 deletions(-)
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index 4de810d..18574ec 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -2,6 +2,7 @@
## 미배포
+- 관광지 상세의 `overview`가 항상 null이던 문제를 고쳤다. TourAPI 목록(areaBasedList2)에는 소개문이 없으므로, essential 관광지 중 소개문이 없는 곳이 있으면 TourAPI 실행이 시도 목록 대신 상세(detailCommon2)를 한 실행에 60곳씩 수집해 한국어 소개문을 채운다. 원천에 소개문이 없는 관광지는 빈 값으로 표시해 다시 요청하지 않으며 API에서는 null로 낸다. 목록 갱신이 저장된 소개문을 지우지 않는다.
- 원천이 없어 영구 null인 필드(지역 인사이트의 avg_stay_nights·age_index, 방한시장의 passengers, 추천의 estimated_budget_krw) 때문에 세 엔드포인트가 항상 partial이던 판정을 바꿨다. 이 필드들은 "미제공"으로 문서화하고 가용성 판정에서 제외하며, 응답 구조와 값(null)은 그대로다. 미제공 필드 목록은 README의 "Fields That Are Not Provided"에 있다.
- 주변 상권 수집이 6시간마다 관광지 5곳만 돌아 essential 관광지 480곳 중 298곳에 1km 안 상권 행이 없고 한 바퀴에 약 24일이 걸리던 것을, 한 실행에 20곳(응답 약 25KB씩, 런당 요청 예산 22)으로 늘려 일주일 안에 채우도록 했다. 주기와 반경, 저장 방식은 그대로다.
- 시군구 방문 전망의 `holiday`가 항상 null이던 문제를 고쳤다. 공휴일 행은 시도에만 기록되므로 시군구 게시본이 날씨와 같은 방식으로 부모 시도의 공휴일 행을 상속한다.
diff --git a/api/KNOWN_GAPS.md b/api/KNOWN_GAPS.md
index 23e9af6..2b7e6e2 100644
--- a/api/KNOWN_GAPS.md
+++ b/api/KNOWN_GAPS.md
@@ -5,7 +5,7 @@
## 팀 결정이 필요한 항목
- **관광지 원천 5개 재활성화** (`SRC_TOUR_EN`, `SRC_TOUR_JA`, `SRC_TOUR_ZH_CN`, `SRC_KTO_PLACE_HUB`, `SRC_KTO_PLACE_RELATED`): 2026-09-11 범위 결정으로 `DISABLED_SOURCES`에 있다. DB에는 언어별 번역 약 9천 건과 관계 32만 행이 남아 있어 있는 관광지는 그대로 조회된다. 허브·연관은 KTO_TATS 아이디 체계라 TourAPI(KTO_CONTENT) 관광지와 잇는 매핑 코드 없이는 켜도 `hub`, `related_places`가 채워지지 않는다.
-- **관광지 소개문(`overview`)**: TourAPI 목록 API(areaBasedList2)만 호출하며 상세 API(detailCommon)는 호출하지 않는다. 채우려면 essential 관광지당 요청 1회가 추가된다.
+- ~~**관광지 소개문(`overview`)**~~: 2026-09-17 결정으로 구현했다. essential 관광지의 소개문을 detailCommon2로 한 실행에 60곳씩 수집한다(무료 쿼터 1,000/일 안).
- **공지 번역·요약**: `ALERT_ENRICHMENT_BATCH_SIZE=0`으로 유료 LLM 보강이 꺼져 있다. 켜면 시간당 2건, 하루 최대 48건 처리한다.
- **시군구 방문 전망**: 6055a3e 이후 지역 요청은 특정 관광지의 집중률을 지역 값으로 쓰지 않는다. KTO 예측 행은 모두 관광지 단위라 시군구 요청은 공식 예측 없이 `unavailable`이 되고, 시도 요청은 과거 동일 요일 참고값을 쓴다. 시군구 단위 공식 값을 내려면 관광지 집중률의 집계 규칙(예: 평균)을 제품으로 정해야 한다.
- **트렌드의 KTO 관광자원 수요**: `SRC_KTO_RESOURCE_DEMAND` 관측 7천여 행이 social_signal 게시본에 들어가지만 뷰가 선택하지 않는다(`ALWAYS_INCLUDED_SOURCES` 상수가 정의만 되고 미사용). 지역 필터(`area_code`)가 항상 unavailable인 이유의 절반이다.
diff --git a/api/app/normalization/places.py b/api/app/normalization/places.py
index 351af6e..2172377 100644
--- a/api/app/normalization/places.py
+++ b/api/app/normalization/places.py
@@ -4,7 +4,7 @@
from decimal import Decimal
from typing import Any
-from sqlalchemy import String, and_, cast, func, select, tuple_
+from sqlalchemy import String, and_, cast, func, select, tuple_, update
from sqlalchemy.dialects.mysql import insert
from sqlalchemy.orm import Session, sessionmaker
@@ -205,7 +205,8 @@ def _upsert_place(
.on_duplicate_key_update(
title=title[:500],
address=address[:1000] if address else None,
- overview=overview,
+ # Catalog rows carry no overview; keep the detail-sourced text.
+ overview=func.coalesce(overview, PlaceLocalization.overview),
is_fallback=False,
updated_at=now,
)
@@ -415,6 +416,49 @@ def _write_tour_replay_provenance(
session.execute(upsert.on_duplicate_key_update(provenance_id=ProvenanceEdge.provenance_id))
+TOUR_DETAIL_KEY_PREFIX = "detailCommon2:"
+
+
+def _apply_tour_details(
+ session: Session,
+ raw: RawRecord,
+ source_id: str,
+ language: str,
+ rows: list[dict[str, Any]],
+) -> int:
+ """Store the overview of already known places; details never mint places."""
+ normalized = 0
+ now = datetime.now(UTC).replace(tzinfo=None)
+ for row in rows:
+ try:
+ with session.begin_nested():
+ external_id = _text(row, "contentid", required=True) or ""
+ place_id = session.scalar(
+ select(PlaceSourceMap.eden_place_id).where(
+ PlaceSourceMap.source_id == source_id,
+ PlaceSourceMap.external_content_id == external_id,
+ )
+ )
+ if place_id is None:
+ raise ValueError(f"detail for unknown {source_id} content {external_id}")
+ localization_id = _existing_localization_id(session, place_id, language)
+ if localization_id is None:
+ raise ValueError(f"detail for place {place_id} without {language} text")
+ # An empty string records that the source has no overview, so the
+ # place is not requested again on the next detail run.
+ overview = _text(row, "overview") or ""
+ session.execute(
+ update(PlaceLocalization)
+ .where(PlaceLocalization.id == localization_id)
+ .values(overview=overview, updated_at=now)
+ )
+ _place_provenance(session, place_id, localization_id, raw.raw_record_id)
+ normalized += 1
+ except Exception as exc:
+ _add_dead_letter(session, raw, "tour_detail_row_schema", exc)
+ return normalized
+
+
def normalize_tour_catalog_run(
source_id: str,
session_factory: sessionmaker[Session],
@@ -430,6 +474,10 @@ def normalize_tour_catalog_run(
_add_dead_letter(session, raw, "tour_catalog_schema", exc)
session.commit()
continue
+ if str(getattr(raw, "external_key", "") or "").startswith(TOUR_DETAIL_KEY_PREFIX):
+ normalized += _apply_tour_details(session, raw, source_id, language, rows)
+ session.commit()
+ continue
successfully_provenanced_ids = (
_successfully_provenanced_tour_content_ids(
session,
diff --git a/api/app/readmodels/repository.py b/api/app/readmodels/repository.py
index fd66c8d..c2679a1 100644
--- a/api/app/readmodels/repository.py
+++ b/api/app/readmodels/repository.py
@@ -976,7 +976,7 @@ def _fetch_place_detail(self, session: Session, key: str) -> ReadResult:
"availability": "available" if has_location else "unavailable",
"reason": None if has_location else "관광지 좌표가 없습니다.",
},
- "overview": selected.overview,
+ "overview": selected.overview or None,
"hub": hub_data,
"related_places": related_data,
"nearby_shops": nearby_data[: int(scope.get("shops_limit", 5))]
diff --git a/api/app/scheduler/runtime.py b/api/app/scheduler/runtime.py
index 6cf45a6..5e12472 100644
--- a/api/app/scheduler/runtime.py
+++ b/api/app/scheduler/runtime.py
@@ -310,7 +310,37 @@ def _runtime_scope(
return scope
operations = scope.get("operations", [])
if source_id == "SRC_TOUR_KO":
+ from app.sources.plans import TOUR_KO_DETAILS_PER_RUN, tour_detail_operations
+
selected_ids = essential_place_ids(session)
+ missing_overview = list(
+ session.scalars(
+ select(PlaceSourceMap.external_content_id)
+ .join(
+ PlaceLocalization,
+ (PlaceLocalization.eden_place_id == PlaceSourceMap.eden_place_id)
+ & (PlaceLocalization.language == "ko"),
+ )
+ .where(
+ PlaceSourceMap.source_id == source_id,
+ PlaceSourceMap.eden_place_id.in_(selected_ids),
+ PlaceLocalization.overview.is_(None),
+ )
+ .order_by(PlaceSourceMap.eden_place_id)
+ .limit(TOUR_KO_DETAILS_PER_RUN)
+ ).all()
+ )
+ if missing_overview:
+ # Overviews come only from detailCommon2. Serve them before
+ # the next catalog rotation and keep the rotation cursor.
+ scope.update(
+ operations=tour_detail_operations(missing_overview),
+ rotate_operations=False,
+ essential_catalog=False,
+ detail_run=True,
+ next_cursor=cursor,
+ )
+ return scope
maps = session.execute(
select(PlaceSourceMap.external_content_id, Area.administrative_code)
.join(Place, Place.eden_place_id == PlaceSourceMap.eden_place_id)
diff --git a/api/app/sources/plans.py b/api/app/sources/plans.py
index 4475038..c4d3d27 100644
--- a/api/app/sources/plans.py
+++ b/api/app/sources/plans.py
@@ -13,6 +13,10 @@
# One TourAPI province catalog page is up to 1,000 rows (about 650 KB); five
# provinces exceeded the 2 MiB run byte budget and starved the last one.
TOUR_KO_AREAS_PER_RUN = 3
+# detailCommon2 answers about 4 KB per place and is the only operation that
+# carries the overview text. A run that has essential places without an
+# overview collects these instead of the province catalogs.
+TOUR_KO_DETAILS_PER_RUN = 60
# Retries share the per-run request budget with the batch itself. Reserve
# room so one transient failure cannot starve the last operation of a batch.
PUBLIC_DATA_REQUEST_HEADROOM = 2
@@ -529,6 +533,23 @@ def public_data_refresh_scope(
return scope
+def tour_detail_operations(content_ids: list[str]) -> list[dict[str, Any]]:
+ """Build bounded TourAPI detail requests for places whose overview is unknown."""
+ return [
+ {
+ "operation": "detailCommon2",
+ "external_key": f"detailCommon2:content={content_id}",
+ "params": {"MobileOS": "ETC", "MobileApp": "EDEN", "contentId": content_id},
+ "watermark": {"response_field": "modifiedtime", "format": "%Y%m%d%H%M%S"},
+ "max_pages": 1,
+ "paginate": False,
+ # A detail row must not pass through the province catalog selection.
+ "detail": True,
+ }
+ for content_id in content_ids[:TOUR_KO_DETAILS_PER_RUN]
+ ]
+
+
def scheduler_batch_size(source_id: str) -> int:
"""Operations the scheduler hands one run of a province-rotating source."""
return TOUR_KO_AREAS_PER_RUN if source_id == "SRC_TOUR_KO" else KMA_OPERATIONS_PER_RUN
diff --git a/api/app/sources/public_data.py b/api/app/sources/public_data.py
index c1426ab..473d9b8 100644
--- a/api/app/sources/public_data.py
+++ b/api/app/sources/public_data.py
@@ -493,7 +493,7 @@ def fetch(self, scope: dict[str, Any]) -> FetchResult:
else:
authoritative_watermarks.append(source_updated_at)
watermark_basis = "authoritative_source_period"
- if scope.get("essential_catalog"):
+ if scope.get("essential_catalog") and not request.get("detail"):
from app.sources.essential import balanced_catalog_rows
source_rows = public_data_items(parsed)
diff --git a/api/app/sources/registry.py b/api/app/sources/registry.py
index fcf108c..a6a957b 100644
--- a/api/app/sources/registry.py
+++ b/api/app/sources/registry.py
@@ -42,13 +42,18 @@ def _batched_request_budget(source_id: str, configured: int) -> int:
PUBLIC_DATA_REQUEST_HEADROOM,
SEMAS_PLACES_PER_RUN,
TOUR_KO_AREAS_PER_RUN,
+ TOUR_KO_DETAILS_PER_RUN,
)
if source_id == "SRC_KMA_FORECAST":
return max(configured, KMA_OPERATIONS_PER_RUN + PUBLIC_DATA_REQUEST_HEADROOM)
if source_id == "SRC_TOUR_KO":
- # Province catalogs can span two pages each.
- return max(configured, TOUR_KO_AREAS_PER_RUN * 2 + PUBLIC_DATA_REQUEST_HEADROOM)
+ # Province catalogs can span two pages each; a detail run is one
+ # request per place.
+ return max(
+ configured,
+ max(TOUR_KO_AREAS_PER_RUN * 2, TOUR_KO_DETAILS_PER_RUN) + PUBLIC_DATA_REQUEST_HEADROOM,
+ )
if source_id == "SRC_SEMAS_SHOPS":
return max(configured, SEMAS_PLACES_PER_RUN + PUBLIC_DATA_REQUEST_HEADROOM)
return configured
diff --git a/api/tests/integration/test_public_pipeline.py b/api/tests/integration/test_public_pipeline.py
index 01babbf..807c9e8 100644
--- a/api/tests/integration/test_public_pipeline.py
+++ b/api/tests/integration/test_public_pipeline.py
@@ -1904,3 +1904,51 @@ def test_inbound_flights_block_is_available_without_passenger_counts(pipeline: P
assert market["arriving_flights"] is not None
assert market["passengers"] is None
assert market["source_availability"]["flights"] == {"availability": "available", "reason": None}
+
+
+def test_tour_scope_collects_missing_overviews_before_rotating_catalogs(
+ pipeline: Pipeline,
+) -> None:
+ from app.scheduler import runtime
+ from app.sources.plans import TOUR_KO_AREAS_PER_RUN
+
+ with pipeline.session_factory.begin() as session:
+ korean = session.scalar(
+ select(PlaceLocalization).where(
+ PlaceLocalization.eden_place_id == PLACE_ID, PlaceLocalization.language == "ko"
+ )
+ )
+ korean.overview = None
+
+ scope = runtime._runtime_scope("SRC_TOUR_KO", {}, pipeline.session_factory)
+ assert scope["detail_run"] is True
+ assert [op["operation"] for op in scope["operations"]] == ["detailCommon2"]
+ assert scope["operations"][0]["params"]["contentId"] == "tour-1"
+ assert scope["next_cursor"] == 0
+
+ with pipeline.session_factory.begin() as session:
+ korean = session.scalar(
+ select(PlaceLocalization).where(
+ PlaceLocalization.eden_place_id == PLACE_ID, PlaceLocalization.language == "ko"
+ )
+ )
+ korean.overview = "" # the source answered without an overview: do not ask again
+
+ scope = runtime._runtime_scope("SRC_TOUR_KO", {}, pipeline.session_factory)
+ assert "detail_run" not in scope
+ assert all(op["operation"] == "areaBasedList2" for op in scope["operations"])
+ assert len(scope["operations"]) == min(TOUR_KO_AREAS_PER_RUN, 17)
+
+
+def test_empty_source_overview_is_served_as_not_provided(pipeline: Pipeline) -> None:
+ with pipeline.session_factory.begin() as session:
+ korean = session.scalar(
+ select(PlaceLocalization).where(
+ PlaceLocalization.eden_place_id == PLACE_ID, PlaceLocalization.language == "ko"
+ )
+ )
+ korean.overview = ""
+
+ response = pipeline.client.get(f"/v1/places/{PLACE_ID}")
+ assert response.status_code == 200
+ assert response.json()["data"]["overview"] is None
diff --git a/api/tests/unit/test_place_normalization_contract.py b/api/tests/unit/test_place_normalization_contract.py
index fc66e12..25641a4 100644
--- a/api/tests/unit/test_place_normalization_contract.py
+++ b/api/tests/unit/test_place_normalization_contract.py
@@ -140,3 +140,105 @@ def test_retired_tour_area_does_not_overwrite_verified_active_place_area(monkeyp
)
insert = next(statement for statement in statements if statement.table.name == "place")
assert insert.compile().params["area_id"] == "active-area"
+
+
+class _RecordingSession:
+ """Fake session that answers scalars in order and records executed statements."""
+
+ def __init__(self, scalars: tuple[object, ...]) -> None:
+ self.info: dict[str, Any] = {}
+ self.statements: list[Any] = []
+ self._scalars = iter(scalars)
+
+ def scalar(self, _statement: object) -> object:
+ return next(self._scalars)
+
+ def execute(self, statement: object) -> None:
+ self.statements.append(statement)
+
+ def begin_nested(self):
+ from contextlib import nullcontext
+
+ return nullcontext()
+
+
+def _mysql_sql(statement: object) -> str:
+ from sqlalchemy.dialects import mysql
+
+ return str(statement.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True}))
+
+
+def test_tour_detail_rows_update_only_the_overview_of_known_places(monkeypatch) -> None:
+ letters: list[tuple[str, str]] = []
+ monkeypatch.setattr(
+ places,
+ "_add_dead_letter",
+ lambda _session, _raw, code, exc: letters.append((code, str(exc))),
+ )
+ # known place: source map -> place id, localization id; unknown place: no map
+ session = _RecordingSession(("eden_place_ko", 7, None))
+ raw = SimpleNamespace(raw_record_id=91)
+ rows = [
+ {"contentid": "126508", "title": "경복궁", "overview": "조선 왕조의 법궁"},
+ {"contentid": "999", "title": "모르는 곳", "overview": "..."},
+ ]
+
+ normalized = places._apply_tour_details(session, raw, "SRC_TOUR_KO", "ko", rows)
+
+ assert normalized == 1
+ sql = [_mysql_sql(statement) for statement in session.statements]
+ assert any(
+ text.startswith("UPDATE place_localization SET overview='조선 왕조의 법궁'")
+ and "WHERE place_localization.id = 7" in text
+ for text in sql
+ )
+ assert not any(text.startswith("INSERT INTO place ") for text in sql)
+ assert sum("INSERT INTO provenance_edge" in text for text in sql) == 2
+ assert letters == [("tour_detail_row_schema", "detail for unknown SRC_TOUR_KO content 999")]
+
+
+def test_tour_detail_without_overview_stores_an_empty_marker() -> None:
+ session = _RecordingSession(("eden_place_ko", 7))
+ rows = [{"contentid": "126508", "title": "경복궁"}]
+
+ assert places._apply_tour_details(
+ session, SimpleNamespace(raw_record_id=92), "SRC_TOUR_KO", "ko", rows
+ ) == 1
+ assert any(
+ "SET overview=''" in _mysql_sql(statement) for statement in session.statements
+ )
+
+
+def test_catalog_rows_keep_an_existing_overview() -> None:
+ class Session(_RecordingSession):
+ def get(self, _model: object, _identifier: object) -> None:
+ return None
+
+ def flush(self) -> None:
+ return None
+
+ # place map hit, localization id known, provenance timestamp lookups
+ session = Session(("eden_place_ko", 7, None, None))
+ places._upsert_place(
+ session=session,
+ raw=SimpleNamespace(raw_record_id=93, source_updated_at=None),
+ source_id="SRC_TOUR_KO",
+ external_id="126508",
+ area_id="area-11",
+ title="경복궁",
+ language="ko",
+ category="A02010100",
+ lat=None,
+ lng=None,
+ address=None,
+ overview=None,
+ namespace="KTO_CONTENT",
+ )
+
+ localization_upserts = [
+ _mysql_sql(statement)
+ for statement in session.statements
+ if "INSERT INTO place_localization" in _mysql_sql(statement)
+ ]
+ assert localization_upserts
+ assert "overview = coalesce(NULL, place_localization.overview)" in localization_upserts[0]
diff --git a/api/tests/unit/test_public_data_budget.py b/api/tests/unit/test_public_data_budget.py
index ac8d055..40e94a4 100644
--- a/api/tests/unit/test_public_data_budget.py
+++ b/api/tests/unit/test_public_data_budget.py
@@ -190,3 +190,49 @@ def handler(request: httpx.Request) -> httpx.Response:
assert len({item.external_key for item in result.items}) == 2
assert any("rotating_page_batch" in error for error in result.partial_errors)
assert not any("pagination_limit_exceeded" in error for error in result.partial_errors)
+
+
+def test_detail_operations_bypass_the_essential_catalog_selection() -> None:
+ from app.sources.plans import tour_detail_operations
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ assert request.url.path.endswith("/detailCommon2")
+ assert request.url.params.get("contentId") == "126508"
+ payload = {
+ "response": {
+ "header": {"resultCode": "0000", "resultMsg": "OK"},
+ "body": {
+ "totalCount": 1,
+ "items": {
+ "item": [
+ {
+ "contentid": "126508",
+ "title": "경복궁",
+ "overview": "조선 왕조의 법궁",
+ "modifiedtime": "20260520091252",
+ }
+ ]
+ },
+ },
+ }
+ }
+ return httpx.Response(200, json=payload)
+
+ adapter = _adapter(handler)
+ result = adapter.fetch(
+ {
+ "essential_catalog": True,
+ "allowed_content_ids": {"1": ["999"]},
+ "operations": tour_detail_operations(["126508"]),
+ }
+ )
+
+ assert result.status is SourceStatus.AVAILABLE
+ assert len(result.items) == 1
+ item = result.items[0]
+ assert item.external_key == "detailCommon2:content=126508:1"
+ from app.sources.public_data import public_data_items
+
+ rows = public_data_items(item.body["response"])
+ assert rows[0]["overview"] == "조선 왕조의 법궁"
+ assert item.body["response"].get("scope") != "bounded_province_catalog"
diff --git a/api/tests/unit/test_public_data_plans.py b/api/tests/unit/test_public_data_plans.py
index 4d99aa3..1e5e453 100644
--- a/api/tests/unit/test_public_data_plans.py
+++ b/api/tests/unit/test_public_data_plans.py
@@ -213,9 +213,11 @@ def test_batched_sources_reserve_request_headroom_for_retries() -> None:
_batched_request_budget("SRC_KMA_FORECAST", configured)
== KMA_OPERATIONS_PER_RUN + PUBLIC_DATA_REQUEST_HEADROOM
)
+ from app.sources.plans import TOUR_KO_DETAILS_PER_RUN
+
assert (
_batched_request_budget("SRC_TOUR_KO", configured)
- == TOUR_KO_AREAS_PER_RUN * 2 + PUBLIC_DATA_REQUEST_HEADROOM
+ == max(TOUR_KO_AREAS_PER_RUN * 2, TOUR_KO_DETAILS_PER_RUN) + PUBLIC_DATA_REQUEST_HEADROOM
)
assert _batched_request_budget("SRC_KMA_FORECAST", 40) == 40
assert _batched_request_budget("SRC_FESTIVAL", configured) == configured
@@ -247,3 +249,18 @@ def test_embassy_notice_budget_covers_the_waiting_room_and_five_boards() -> None
assert adapter.client.max_requests == EMBASSY_NOTICE_REQUEST_BUDGET >= 13 + 5 * 3
finally:
adapter.client.close()
+
+
+def test_tour_detail_operations_are_bounded_and_marked_as_details() -> None:
+ from app.sources.plans import TOUR_KO_DETAILS_PER_RUN, tour_detail_operations
+
+ content_ids = [str(index) for index in range(TOUR_KO_DETAILS_PER_RUN + 5)]
+ operations = tour_detail_operations(content_ids)
+
+ assert len(operations) == TOUR_KO_DETAILS_PER_RUN
+ first = operations[0]
+ assert first["operation"] == "detailCommon2"
+ assert first["external_key"] == "detailCommon2:content=0"
+ assert first["params"] == {"MobileOS": "ETC", "MobileApp": "EDEN", "contentId": "0"}
+ assert first["watermark"] == {"response_field": "modifiedtime", "format": "%Y%m%d%H%M%S"}
+ assert first["detail"] is True and first["paginate"] is False and first["max_pages"] == 1
From 91a097933c2be7fef392f16e20334848693fb401 Mon Sep 17 00:00:00 2001
From: rrrrok <132427414+rrrrok@users.noreply.github.com>
Date: Thu, 17 Sep 2026 09:34:57 +0900
Subject: [PATCH 2/8] feat(sources): re-enable the language catalogs and KTO
hub/related places
The five place sources paused on 2026-09-11 are back in scope. Language
catalogs reuse the Korean essential selection so they only translate known
places (province batches of three, no new places), the registry's enabled
flag follows the code in both directions, and the scheduler syncs it at
start so a re-enabled source is actually scheduled.
---
api/CHANGELOG.md | 1 +
api/app/reference.py | 22 +++++++++-
api/app/scheduler/runtime.py | 38 +++++++++++++++---
api/app/sources/essential.py | 14 ++++---
api/app/sources/plans.py | 2 +-
api/app/sources/registry.py | 2 +-
api/tests/integration/test_public_pipeline.py | 40 +++++++++++++++++++
api/tests/unit/test_public_data_plans.py | 9 +++++
api/tests/unit/test_reference_seed.py | 17 +++++++-
9 files changed, 130 insertions(+), 15 deletions(-)
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index 18574ec..1c17d37 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -2,6 +2,7 @@
## 미배포
+- 2026-09-11에 껐던 관광지 원천 5개(TourAPI 영어·일본어·중국어 간체, KTO 중심 관광지, KTO 연관 관광지)를 다시 켰다. 언어별 카탈로그는 한국어 카탈로그로 고른 essential 관광지의 번역만 붙이고 새 관광지를 만들지 않는다(시도 3개씩 순환, 새 관광지 한도 0). 레지스트리의 enabled는 코드의 원천 범위를 따르며 스케줄러가 시작할 때 맞춘다.
- 관광지 상세의 `overview`가 항상 null이던 문제를 고쳤다. TourAPI 목록(areaBasedList2)에는 소개문이 없으므로, essential 관광지 중 소개문이 없는 곳이 있으면 TourAPI 실행이 시도 목록 대신 상세(detailCommon2)를 한 실행에 60곳씩 수집해 한국어 소개문을 채운다. 원천에 소개문이 없는 관광지는 빈 값으로 표시해 다시 요청하지 않으며 API에서는 null로 낸다. 목록 갱신이 저장된 소개문을 지우지 않는다.
- 원천이 없어 영구 null인 필드(지역 인사이트의 avg_stay_nights·age_index, 방한시장의 passengers, 추천의 estimated_budget_krw) 때문에 세 엔드포인트가 항상 partial이던 판정을 바꿨다. 이 필드들은 "미제공"으로 문서화하고 가용성 판정에서 제외하며, 응답 구조와 값(null)은 그대로다. 미제공 필드 목록은 README의 "Fields That Are Not Provided"에 있다.
- 주변 상권 수집이 6시간마다 관광지 5곳만 돌아 essential 관광지 480곳 중 298곳에 1km 안 상권 행이 없고 한 바퀴에 약 24일이 걸리던 것을, 한 실행에 20곳(응답 약 25KB씩, 런당 요청 예산 22)으로 늘려 일주일 안에 채우도록 했다. 주기와 반경, 저장 방식은 그대로다.
diff --git a/api/app/reference.py b/api/app/reference.py
index 5e4d88f..fc69350 100644
--- a/api/app/reference.py
+++ b/api/app/reference.py
@@ -113,6 +113,23 @@ def preserve_collection_cursor(
return {**seeded_evidence, "collection_cursor": cursor}
+def source_enabled_by_code(source_id: str) -> bool:
+ return source_id not in EXCLUDED_SOCIAL_SOURCE_IDS | DISABLED_SOURCES
+
+
+def sync_source_enablement(session: Session) -> dict[str, bool]:
+ """Align registry.enabled with the code's source scope; return what changed."""
+ changed: dict[str, bool] = {}
+ now = datetime.now(UTC).replace(tzinfo=None)
+ for source in session.scalars(select(SourceRegistry)).all():
+ expected = source_enabled_by_code(source.source_id)
+ if bool(source.enabled) != expected:
+ source.enabled = expected
+ source.updated_at = now
+ changed[source.source_id] = expected
+ return changed
+
+
def seed_reference_data(session: Session) -> None:
now = datetime.now(UTC)
active_areas = session.execute(
@@ -241,8 +258,9 @@ def seed_reference_data(session: Session) -> None:
"created_at",
}
}
- if source.source_id in EXCLUDED_SOCIAL_SOURCE_IDS | DISABLED_SOURCES:
- source_update["enabled"] = False
+ # Enablement is a code decision (DISABLED_SOURCES); keep the registry in
+ # step in both directions so a re-enabled source actually gets scheduled.
+ source_update["enabled"] = values["enabled"]
session.execute(
insert(SourceRegistry).values(**values).on_duplicate_key_update(**source_update)
)
diff --git a/api/app/scheduler/runtime.py b/api/app/scheduler/runtime.py
index 5e12472..6c4e757 100644
--- a/api/app/scheduler/runtime.py
+++ b/api/app/scheduler/runtime.py
@@ -246,7 +246,9 @@ def _runtime_scope(
from app.sources.plans import social_refresh_scope
return social_refresh_scope(source_id)
- if source_id in {"SRC_KMA_FORECAST", "SRC_TOUR_KO", "SRC_KTO_REGIONAL_VISITORS"}:
+ from app.sources.essential import TOUR_LANGUAGE_SOURCES
+
+ if source_id in {"SRC_KMA_FORECAST", "SRC_KTO_REGIONAL_VISITORS", *TOUR_LANGUAGE_SOURCES}:
from app.repositories.models import RegionalVisitObservation
from app.sources.plans import public_data_refresh_scope
@@ -309,11 +311,13 @@ def _runtime_scope(
).strftime("%Y%m%d")
return scope
operations = scope.get("operations", [])
- if source_id == "SRC_TOUR_KO":
+ if source_id in TOUR_LANGUAGE_SOURCES:
from app.sources.plans import TOUR_KO_DETAILS_PER_RUN, tour_detail_operations
selected_ids = essential_place_ids(session)
- missing_overview = list(
+ # Overviews are collected in Korean only; language catalogs
+ # translate the essential places selected from the Korean catalog.
+ missing_overview = [] if source_id != "SRC_TOUR_KO" else list(
session.scalars(
select(PlaceSourceMap.external_content_id)
.join(
@@ -346,7 +350,9 @@ def _runtime_scope(
.join(Place, Place.eden_place_id == PlaceSourceMap.eden_place_id)
.join(Area, Area.eden_area_id == Place.area_id)
.where(
- Place.eden_place_id.in_(selected_ids), PlaceSourceMap.source_id == source_id
+ Place.eden_place_id.in_(selected_ids),
+ # TourAPI content ids are shared across its language services.
+ PlaceSourceMap.source_id == "SRC_TOUR_KO",
)
).all()
from app.sources.plans import KTO_TOURAPI_AREA_TO_MOIS_PREFIX
@@ -368,7 +374,8 @@ def _runtime_scope(
)
)
scope["allowed_content_ids"] = allowed
- scope["new_places_limit"] = 30
+ # Only the Korean catalog may add places; translations attach to them.
+ scope["new_places_limit"] = 30 if source_id == "SRC_TOUR_KO" else 0
from app.sources.plans import rotating_batch, scheduler_batch_size
batch = rotating_batch(operations, cursor, scheduler_batch_size(source_id))
@@ -1095,6 +1102,26 @@ def run_alert_enrichment(
connection.close()
+def _sync_source_enablement(factory: sessionmaker[Session]) -> dict[str, bool]:
+ """Keep registry.enabled equal to the code's source scope before jobs are registered."""
+ from app.reference import sync_source_enablement
+
+ try:
+ with factory() as session:
+ changes = sync_source_enablement(session)
+ if changes:
+ session.commit()
+ except Exception:
+ logger.exception("source_enablement_sync_failed", extra={"job_type": "source"})
+ return {}
+ if changes:
+ logger.info(
+ "source_enablement_synced",
+ extra={"changes": changes, "job_type": "source"},
+ )
+ return changes
+
+
def start_scheduler(settings: Settings, factory: sessionmaker[Session]) -> SchedulerRuntime | None:
engine: Engine = factory.kw["bind"]
leader_connection = _open_leader_connection(engine)
@@ -1105,6 +1132,7 @@ def start_scheduler(settings: Settings, factory: sessionmaker[Session]) -> Sched
leader_connection.close()
return None
IngestionService(factory).recover_abandoned_runs()
+ _sync_source_enablement(factory)
scheduler = BackgroundScheduler(
timezone=settings.EDEN_TIMEZONE,
executors={
diff --git a/api/app/sources/essential.py b/api/app/sources/essential.py
index 3d0d399..9d773fe 100644
--- a/api/app/sources/essential.py
+++ b/api/app/sources/essential.py
@@ -23,14 +23,13 @@
"SRC_DOUYIN",
"SRC_XIAOHONGSHU",
"SRC_LINE",
- "SRC_TOUR_EN",
- "SRC_TOUR_JA",
- "SRC_TOUR_ZH_CN",
- "SRC_KTO_PLACE_HUB",
- "SRC_KTO_PLACE_RELATED",
"SRC_TOURISM_ADMISSION",
}
)
+# TourAPI language catalogs and KTO hub/related places were paused on
+# 2026-09-11 and re-enabled on 2026-09-17 (team decision): the place detail
+# and recommendation endpoints declare translations, hubs and related places.
+TOUR_LANGUAGE_SOURCES = ("SRC_TOUR_KO", "SRC_TOUR_EN", "SRC_TOUR_JA", "SRC_TOUR_ZH_CN")
EXPANSION_SOURCES = frozenset(
{
"SRC_TOUR_KO",
@@ -46,6 +45,11 @@
SOURCE_INTERVALS = {
"SRC_MOIS_ADMIN_CODES": 30 * DAY,
"SRC_TOUR_KO": DAY, # Three provinces per batch, nationwide within six days.
+ "SRC_TOUR_EN": DAY, # Same rotation; only essential places already known in Korean.
+ "SRC_TOUR_JA": DAY,
+ "SRC_TOUR_ZH_CN": DAY,
+ "SRC_KTO_PLACE_HUB": DAY, # Five sigungu per batch, monthly hub rankings.
+ "SRC_KTO_PLACE_RELATED": DAY, # Five hub places per batch.
"SRC_YOUTUBE": DAY,
"SRC_KMA_FORECAST": 3 * 3600,
"SRC_SEMAS_SHOPS": 6 * 3600, # Twenty places per batch, ~480 within a week.
diff --git a/api/app/sources/plans.py b/api/app/sources/plans.py
index c4d3d27..ec9e098 100644
--- a/api/app/sources/plans.py
+++ b/api/app/sources/plans.py
@@ -552,7 +552,7 @@ def tour_detail_operations(content_ids: list[str]) -> list[dict[str, Any]]:
def scheduler_batch_size(source_id: str) -> int:
"""Operations the scheduler hands one run of a province-rotating source."""
- return TOUR_KO_AREAS_PER_RUN if source_id == "SRC_TOUR_KO" else KMA_OPERATIONS_PER_RUN
+ return TOUR_KO_AREAS_PER_RUN if source_id.startswith("SRC_TOUR_") else KMA_OPERATIONS_PER_RUN
def rotating_batch(operations: list[Any], cursor: int, batch_size: int) -> list[Any]:
diff --git a/api/app/sources/registry.py b/api/app/sources/registry.py
index a6a957b..ab94659 100644
--- a/api/app/sources/registry.py
+++ b/api/app/sources/registry.py
@@ -47,7 +47,7 @@ def _batched_request_budget(source_id: str, configured: int) -> int:
if source_id == "SRC_KMA_FORECAST":
return max(configured, KMA_OPERATIONS_PER_RUN + PUBLIC_DATA_REQUEST_HEADROOM)
- if source_id == "SRC_TOUR_KO":
+ if source_id.startswith("SRC_TOUR_"):
# Province catalogs can span two pages each; a detail run is one
# request per place.
return max(
diff --git a/api/tests/integration/test_public_pipeline.py b/api/tests/integration/test_public_pipeline.py
index 807c9e8..e964a22 100644
--- a/api/tests/integration/test_public_pipeline.py
+++ b/api/tests/integration/test_public_pipeline.py
@@ -1952,3 +1952,43 @@ def test_empty_source_overview_is_served_as_not_provided(pipeline: Pipeline) ->
response = pipeline.client.get(f"/v1/places/{PLACE_ID}")
assert response.status_code == 200
assert response.json()["data"]["overview"] is None
+
+
+def test_scheduler_start_syncs_registry_enablement_with_the_code(pipeline: Pipeline) -> None:
+ from app.reference import sync_source_enablement
+
+ with pipeline.session_factory.begin() as session:
+ session.get(SourceRegistry, "SRC_TOUR_JA").enabled = False
+ session.get(SourceRegistry, "SRC_KTO_PLACE_HUB").enabled = False
+
+ with pipeline.session_factory.begin() as session:
+ changed = sync_source_enablement(session)
+ # the fixture seeds every endpoint source as enabled; code-disabled ones flip off
+ assert changed["SRC_TOUR_JA"] is True and changed["SRC_KTO_PLACE_HUB"] is True
+ assert changed["SRC_NAVER_TREND"] is False
+
+ with pipeline.session_factory() as session:
+ assert session.get(SourceRegistry, "SRC_TOUR_JA").enabled is True
+ assert sync_source_enablement(session) == {}
+
+
+def test_language_catalog_scope_translates_only_the_korean_essential_places(
+ pipeline: Pipeline,
+) -> None:
+ from app.scheduler import runtime
+
+ with pipeline.session_factory.begin() as session:
+ korean = session.scalar(
+ select(PlaceLocalization).where(
+ PlaceLocalization.eden_place_id == PLACE_ID, PlaceLocalization.language == "ko"
+ )
+ )
+ korean.overview = None # would trigger a detail run for the Korean catalog only
+
+ scope = runtime._runtime_scope("SRC_TOUR_JA", {}, pipeline.session_factory)
+
+ assert "detail_run" not in scope
+ assert scope["new_places_limit"] == 0
+ assert all(op["operation"] == "areaBasedList2" for op in scope["operations"])
+ assert scope["allowed_content_ids"]["1"] == ["tour-1"]
+ assert all(not ids for code, ids in scope["allowed_content_ids"].items() if code != "1")
diff --git a/api/tests/unit/test_public_data_plans.py b/api/tests/unit/test_public_data_plans.py
index 1e5e453..2114b78 100644
--- a/api/tests/unit/test_public_data_plans.py
+++ b/api/tests/unit/test_public_data_plans.py
@@ -264,3 +264,12 @@ def test_tour_detail_operations_are_bounded_and_marked_as_details() -> None:
assert first["params"] == {"MobileOS": "ETC", "MobileApp": "EDEN", "contentId": "0"}
assert first["watermark"] == {"response_field": "modifiedtime", "format": "%Y%m%d%H%M%S"}
assert first["detail"] is True and first["paginate"] is False and first["max_pages"] == 1
+
+
+def test_language_catalogs_share_the_tourapi_batch_and_budget() -> None:
+ from app.sources.plans import scheduler_batch_size
+ from app.sources.registry import _batched_request_budget
+
+ for source_id in ("SRC_TOUR_EN", "SRC_TOUR_JA", "SRC_TOUR_ZH_CN"):
+ assert scheduler_batch_size(source_id) == scheduler_batch_size("SRC_TOUR_KO")
+ assert _batched_request_budget(source_id, 5) == _batched_request_budget("SRC_TOUR_KO", 5)
diff --git a/api/tests/unit/test_reference_seed.py b/api/tests/unit/test_reference_seed.py
index 96dcb26..b8d146b 100644
--- a/api/tests/unit/test_reference_seed.py
+++ b/api/tests/unit/test_reference_seed.py
@@ -1,6 +1,7 @@
from __future__ import annotations
-from app.reference import preserve_collection_cursor
+from app.reference import preserve_collection_cursor, source_enabled_by_code
+from app.sources.essential import DISABLED_SOURCES
def test_reseeding_keeps_the_scheduler_rotation_cursor() -> None:
@@ -13,3 +14,17 @@ def test_reseeding_keeps_the_scheduler_rotation_cursor() -> None:
assert preserve_collection_cursor(seeded, {"cadence_tier": "hourly"}) == seeded
assert preserve_collection_cursor(seeded, None) == seeded
assert preserve_collection_cursor(seeded, "not-a-dict") == seeded
+
+
+def test_place_sources_are_enabled_again_and_social_sources_stay_off() -> None:
+ for source_id in (
+ "SRC_TOUR_EN",
+ "SRC_TOUR_JA",
+ "SRC_TOUR_ZH_CN",
+ "SRC_KTO_PLACE_HUB",
+ "SRC_KTO_PLACE_RELATED",
+ ):
+ assert source_id not in DISABLED_SOURCES
+ assert source_enabled_by_code(source_id) is True
+ for source_id in ("SRC_NAVER_TREND", "SRC_INSTAGRAM", "SRC_TOURISM_ADMISSION"):
+ assert source_enabled_by_code(source_id) is False
From e98d6042a4e3f5a26805e8065f7fdc9042b2d325 Mon Sep 17 00:00:00 2001
From: rrrrok <132427414+rrrrok@users.noreply.github.com>
Date: Thu, 17 Sep 2026 09:39:09 +0900
Subject: [PATCH 3/8] feat(places): bridge KTO hub/related places to TourAPI
places
Hub ranks and related places carry KTO TATS codes while the public place and
recommendation endpoints serve TourAPI content ids, so both fields stayed
empty. A TATS row now attaches to the TourAPI place whose normalized Korean
name is unique in the same province and whose coordinates, when both exist,
agree within one kilometre; coordinates alone never merge identities. New
hub/related rows attach directly, a bounded scheduler job retires the
already-minted TATS rows onto their canonical place, and the place detail
and recommendation reads include relations recorded on retired aliases.
---
api/CHANGELOG.md | 1 +
api/KNOWN_GAPS.md | 2 +-
api/app/normalization/place_crosswalk.py | 277 ++++++++++++++++++
api/app/normalization/places.py | 8 +
api/app/observability/metrics.py | 10 +-
api/app/products/recommendations.py | 49 +++-
api/app/readmodels/repository.py | 18 +-
api/app/scheduler/runtime.py | 51 ++++
api/tests/integration/test_public_pipeline.py | 226 ++++++++++++++
9 files changed, 627 insertions(+), 15 deletions(-)
create mode 100644 api/app/normalization/place_crosswalk.py
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index 1c17d37..159f782 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -2,6 +2,7 @@
## 미배포
+- KTO 중심 관광지·연관 관광지가 TourAPI 관광지에 붙지 않아 관광지 상세의 `hub`, `related_places`와 추천의 `related_places`가 항상 비어 있던 문제를 고쳤다. 두 KTO 원천은 TATS 코드로, 상세 API는 TourAPI content id로 관광지를 식별하는데, 같은 시도 안에서 정규화한 한국어 이름이 유일하게 일치하고 좌표가 있으면 1km 안에서 일치할 때만 같은 관광지로 본다(좌표만으로는 합치지 않는다). 새로 수집되는 허브·연관 행은 TourAPI 관광지에 바로 붙고, 이미 만들어진 TATS 관광지 행은 스케줄러 잡(`eden:place:crosswalk`, 6시간마다 최대 3,000건)이 canonical로 이어 붙이며 읽기 경로는 이어 붙인 행의 관계를 함께 조회한다.
- 2026-09-11에 껐던 관광지 원천 5개(TourAPI 영어·일본어·중국어 간체, KTO 중심 관광지, KTO 연관 관광지)를 다시 켰다. 언어별 카탈로그는 한국어 카탈로그로 고른 essential 관광지의 번역만 붙이고 새 관광지를 만들지 않는다(시도 3개씩 순환, 새 관광지 한도 0). 레지스트리의 enabled는 코드의 원천 범위를 따르며 스케줄러가 시작할 때 맞춘다.
- 관광지 상세의 `overview`가 항상 null이던 문제를 고쳤다. TourAPI 목록(areaBasedList2)에는 소개문이 없으므로, essential 관광지 중 소개문이 없는 곳이 있으면 TourAPI 실행이 시도 목록 대신 상세(detailCommon2)를 한 실행에 60곳씩 수집해 한국어 소개문을 채운다. 원천에 소개문이 없는 관광지는 빈 값으로 표시해 다시 요청하지 않으며 API에서는 null로 낸다. 목록 갱신이 저장된 소개문을 지우지 않는다.
- 원천이 없어 영구 null인 필드(지역 인사이트의 avg_stay_nights·age_index, 방한시장의 passengers, 추천의 estimated_budget_krw) 때문에 세 엔드포인트가 항상 partial이던 판정을 바꿨다. 이 필드들은 "미제공"으로 문서화하고 가용성 판정에서 제외하며, 응답 구조와 값(null)은 그대로다. 미제공 필드 목록은 README의 "Fields That Are Not Provided"에 있다.
diff --git a/api/KNOWN_GAPS.md b/api/KNOWN_GAPS.md
index 2b7e6e2..5f5eb74 100644
--- a/api/KNOWN_GAPS.md
+++ b/api/KNOWN_GAPS.md
@@ -4,7 +4,7 @@
## 팀 결정이 필요한 항목
-- **관광지 원천 5개 재활성화** (`SRC_TOUR_EN`, `SRC_TOUR_JA`, `SRC_TOUR_ZH_CN`, `SRC_KTO_PLACE_HUB`, `SRC_KTO_PLACE_RELATED`): 2026-09-11 범위 결정으로 `DISABLED_SOURCES`에 있다. DB에는 언어별 번역 약 9천 건과 관계 32만 행이 남아 있어 있는 관광지는 그대로 조회된다. 허브·연관은 KTO_TATS 아이디 체계라 TourAPI(KTO_CONTENT) 관광지와 잇는 매핑 코드 없이는 켜도 `hub`, `related_places`가 채워지지 않는다.
+- ~~**관광지 원천 5개 재활성화**~~: 2026-09-17 결정으로 다시 켰고, KTO_TATS↔TourAPI 매핑(`app/normalization/place_crosswalk.py`)을 구현했다. 이름이 같은 시도 안에서 유일하지 않거나 좌표가 1km 넘게 어긋나는 행은 매핑하지 않으므로 일부 관광지는 `hub`, `related_places`가 계속 비어 있을 수 있다. 매핑률은 배포 후 운영 DB에서 확인한다.
- ~~**관광지 소개문(`overview`)**~~: 2026-09-17 결정으로 구현했다. essential 관광지의 소개문을 detailCommon2로 한 실행에 60곳씩 수집한다(무료 쿼터 1,000/일 안).
- **공지 번역·요약**: `ALERT_ENRICHMENT_BATCH_SIZE=0`으로 유료 LLM 보강이 꺼져 있다. 켜면 시간당 2건, 하루 최대 48건 처리한다.
- **시군구 방문 전망**: 6055a3e 이후 지역 요청은 특정 관광지의 집중률을 지역 값으로 쓰지 않는다. KTO 예측 행은 모두 관광지 단위라 시군구 요청은 공식 예측 없이 `unavailable`이 되고, 시도 요청은 과거 동일 요일 참고값을 쓴다. 시군구 단위 공식 값을 내려면 관광지 집중률의 집계 규칙(예: 평균)을 제품으로 정해야 한다.
diff --git a/api/app/normalization/place_crosswalk.py b/api/app/normalization/place_crosswalk.py
new file mode 100644
index 0000000..bbb24e9
--- /dev/null
+++ b/api/app/normalization/place_crosswalk.py
@@ -0,0 +1,277 @@
+"""Match KTO hub/related places (KTO_TATS ids) to TourAPI places (KTO_CONTENT ids).
+
+The hub and related-place APIs identify attractions with the KTO TATS code, while
+the place detail and recommendation endpoints serve TourAPI content ids. Without
+a bridge, hub ranks and related places only ever attach to separate TATS-minted
+place rows that no public request reaches. A TATS row is treated as the same
+facility as a TourAPI place when the normalized Korean name is identical inside
+the same province, the match is unambiguous, and any coordinates on both sides
+agree within one kilometre. Coordinates alone never merge identities.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from decimal import Decimal
+from math import asin, cos, radians, sin, sqrt
+from typing import Any
+
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
+
+from app.domain.ids import stable_eden_id
+from app.repositories.models import (
+ Area,
+ Place,
+ PlaceLocalization,
+ PlaceSourceMap,
+ ProvenanceEdge,
+)
+
+TATS_SOURCES = ("SRC_KTO_PLACE_HUB", "SRC_KTO_PLACE_RELATED")
+TOUR_CONTENT_SOURCE = "SRC_TOUR_KO"
+MAX_COORDINATE_DISTANCE_M = 1000.0
+CROSSWALK_FORMULA_VERSION = "place_crosswalk_v1"
+_TITLE_NOISE = re.compile(r"\(.*?\)|\[.*?\]|[\s·・.,'\"\-_/&]+")
+
+
+@dataclass(frozen=True, slots=True)
+class TourCandidate:
+ place_id: str
+ area_id: str
+ lat: Decimal | None
+ lng: Decimal | None
+
+
+def normalized_title(title: str | None) -> str:
+ return _TITLE_NOISE.sub("", title or "").lower()
+
+
+def tats_place_id(external_id: str) -> str:
+ return stable_eden_id("place", "KTO_TATS", external_id)
+
+
+def _distance_m(a: TourCandidate | tuple[Any, Any], b: tuple[Any, Any]) -> float | None:
+ lat1, lng1 = (a.lat, a.lng) if isinstance(a, TourCandidate) else a
+ lat2, lng2 = b
+ if lat1 is None or lng1 is None or lat2 is None or lng2 is None:
+ return None
+ phi1, phi2 = radians(float(lat1)), radians(float(lat2))
+ d_phi = radians(float(lat2) - float(lat1))
+ d_lambda = radians(float(lng2) - float(lng1))
+ value = sin(d_phi / 2) ** 2 + cos(phi1) * cos(phi2) * sin(d_lambda / 2) ** 2
+ return 6_371_008.8 * 2 * asin(sqrt(value))
+
+
+def _province(session: Session, area_id: str) -> str | None:
+ cache = session.info.setdefault("eden_area_province", {})
+ if area_id not in cache:
+ code = session.scalar(select(Area.administrative_code).where(Area.eden_area_id == area_id))
+ cache[area_id] = code[:2] if code else None
+ return cache[area_id]
+
+
+def _province_title_index(session: Session, province: str) -> dict[str, list[TourCandidate]]:
+ cache = session.info.setdefault("eden_tour_title_index", {})
+ if province not in cache:
+ rows = session.execute(
+ select(
+ Place.eden_place_id,
+ Place.area_id,
+ Place.lat,
+ Place.lng,
+ PlaceLocalization.title,
+ )
+ .join(Area, Area.eden_area_id == Place.area_id)
+ .join(
+ PlaceLocalization,
+ (PlaceLocalization.eden_place_id == Place.eden_place_id)
+ & (PlaceLocalization.language == "ko"),
+ )
+ .where(
+ Place.merge_status == "active",
+ Place.canonical_place_id.is_(None),
+ func.substr(Area.administrative_code, 1, 2) == province,
+ select(PlaceSourceMap.id)
+ .where(
+ PlaceSourceMap.eden_place_id == Place.eden_place_id,
+ PlaceSourceMap.source_id == TOUR_CONTENT_SOURCE,
+ )
+ .exists(),
+ )
+ ).all()
+ index: dict[str, list[TourCandidate]] = {}
+ for place_id, area_id, lat, lng, title in rows:
+ key = normalized_title(title)
+ if key:
+ index.setdefault(key, []).append(TourCandidate(place_id, area_id, lat, lng))
+ cache[province] = index
+ return cache[province]
+
+
+def match_tour_place(
+ session: Session,
+ title: str | None,
+ area_id: str,
+ lat: Decimal | None,
+ lng: Decimal | None,
+) -> str | None:
+ """Return the TourAPI place this TATS row names, or None when unsure."""
+ key = normalized_title(title)
+ if not key:
+ return None
+ province = _province(session, area_id)
+ if province is None:
+ return None
+ candidates = _province_title_index(session, province).get(key, [])
+ if lat is not None and lng is not None:
+ candidates = [
+ candidate
+ for candidate in candidates
+ if (distance := _distance_m(candidate, (lat, lng))) is None
+ or distance <= MAX_COORDINATE_DISTANCE_M
+ ]
+ if len(candidates) > 1:
+ same_area = [candidate for candidate in candidates if candidate.area_id == area_id]
+ candidates = same_area or candidates
+ if len(candidates) != 1:
+ return None
+ return candidates[0].place_id
+
+
+def attach_place_alias(
+ session: Session,
+ source_id: str,
+ external_id: str,
+ canonical_id: str,
+ raw_record_id: int | None,
+) -> str:
+ """Point a TATS source identity at its TourAPI place and retire the TATS-minted row."""
+ now = datetime.now(UTC).replace(tzinfo=None)
+ mapping = session.scalar(
+ select(PlaceSourceMap).where(
+ PlaceSourceMap.source_id == source_id,
+ PlaceSourceMap.external_content_id == external_id,
+ )
+ )
+ previous_id = mapping.eden_place_id if mapping is not None else None
+ if mapping is None:
+ session.add(
+ PlaceSourceMap(
+ source_id=source_id,
+ external_content_id=external_id,
+ eden_place_id=canonical_id,
+ created_at=now,
+ updated_at=now,
+ )
+ )
+ elif mapping.eden_place_id != canonical_id:
+ mapping.eden_place_id = canonical_id
+ mapping.updated_at = now
+ minted_id = tats_place_id(external_id)
+ for retired_id in {previous_id, minted_id} - {None, canonical_id}:
+ retired = session.get(Place, retired_id)
+ if retired is None or retired.canonical_place_id is not None:
+ continue
+ if retired_id != minted_id:
+ # Only rows this crosswalk minted are safe to retire; other
+ # identities keep their own history.
+ continue
+ retired.merge_status = "merged"
+ retired.canonical_place_id = canonical_id
+ retired.updated_at = now
+ if raw_record_id is not None:
+ localization_id = session.scalar(
+ select(PlaceLocalization.id).where(
+ PlaceLocalization.eden_place_id == canonical_id,
+ PlaceLocalization.language == "ko",
+ )
+ )
+ outputs = [("place", canonical_id)]
+ if localization_id is not None:
+ outputs.append(("place_localization", str(localization_id)))
+ for output_type, output_id in outputs:
+ exists = session.scalar(
+ select(ProvenanceEdge.provenance_id).where(
+ ProvenanceEdge.output_type == output_type,
+ ProvenanceEdge.output_id == output_id,
+ ProvenanceEdge.raw_record_id == raw_record_id,
+ ProvenanceEdge.formula_version == CROSSWALK_FORMULA_VERSION,
+ )
+ )
+ if exists is None:
+ session.add(
+ ProvenanceEdge(
+ output_type=output_type,
+ output_id=output_id,
+ raw_record_id=raw_record_id,
+ formula_version=CROSSWALK_FORMULA_VERSION,
+ created_at=now,
+ )
+ )
+ session.flush()
+ session.info.setdefault("eden_place_source_map", {})[(source_id, external_id)] = canonical_id
+ return canonical_id
+
+
+def alias_place_ids(session: Session, place_id: str) -> list[str]:
+ """The place id plus every retired row that now points at it."""
+ aliases = list(
+ session.scalars(
+ select(Place.eden_place_id).where(Place.canonical_place_id == place_id)
+ ).all()
+ )
+ return [place_id, *sorted(aliases)]
+
+
+def crosswalk_tats_places(
+ session: Session,
+ *,
+ limit: int = 3000,
+ deadline: float | None = None,
+ clock=None,
+) -> dict[str, int]:
+ """Retire already-minted TATS places whose TourAPI twin can be identified."""
+ from time import monotonic
+
+ clock = clock or monotonic
+ examined = matched = 0
+ rows = session.execute(
+ select(
+ PlaceSourceMap.source_id,
+ PlaceSourceMap.external_content_id,
+ Place.eden_place_id,
+ Place.area_id,
+ Place.lat,
+ Place.lng,
+ PlaceLocalization.title,
+ )
+ .join(Place, Place.eden_place_id == PlaceSourceMap.eden_place_id)
+ .outerjoin(
+ PlaceLocalization,
+ (PlaceLocalization.eden_place_id == Place.eden_place_id)
+ & (PlaceLocalization.language == "ko"),
+ )
+ .where(
+ PlaceSourceMap.source_id.in_(TATS_SOURCES),
+ Place.canonical_place_id.is_(None),
+ Place.merge_status == "active",
+ )
+ .order_by(PlaceSourceMap.id)
+ .limit(limit)
+ ).all()
+ for source_id, external_id, place_id, area_id, lat, lng, title in rows:
+ if deadline is not None and clock() >= deadline:
+ break
+ if place_id != tats_place_id(external_id):
+ # Already attached to a TourAPI place (or another identity).
+ continue
+ examined += 1
+ canonical_id = match_tour_place(session, title, area_id, lat, lng)
+ if canonical_id is None or canonical_id == place_id:
+ continue
+ attach_place_alias(session, source_id, external_id, canonical_id, None)
+ matched += 1
+ return {"examined": examined, "matched": matched}
diff --git a/api/app/normalization/places.py b/api/app/normalization/places.py
index 2172377..2d2fe69 100644
--- a/api/app/normalization/places.py
+++ b/api/app/normalization/places.py
@@ -129,6 +129,14 @@ def _upsert_place(
namespace: str,
) -> str:
now = datetime.now(UTC).replace(tzinfo=None)
+ if namespace == "KTO_TATS":
+ from app.normalization.place_crosswalk import attach_place_alias, match_tour_place
+
+ twin_id = match_tour_place(session, title, area_id, lat, lng)
+ if twin_id is not None:
+ # The hub/related row names a TourAPI place already served publicly;
+ # relations attach there instead of to a separate TATS-only row.
+ return attach_place_alias(session, source_id, external_id, twin_id, raw.raw_record_id)
place_id = _existing_place_id(session, source_id, external_id, lat, lng, namespace)
existing_place = session.get(Place, place_id)
incoming_area = session.get(Area, area_id)
diff --git a/api/app/observability/metrics.py b/api/app/observability/metrics.py
index 4d7f100..0c84078 100644
--- a/api/app/observability/metrics.py
+++ b/api/app/observability/metrics.py
@@ -206,7 +206,15 @@
_RUN_STATUSES = frozenset({"succeeded", "partial", "failed", "skipped_locked"})
_JOB_TYPES = frozenset(
- {"source", "product", "dead_letter", "snapshot_retention", "capacity", "alert"}
+ {
+ "source",
+ "product",
+ "dead_letter",
+ "snapshot_retention",
+ "capacity",
+ "alert",
+ "place_crosswalk",
+ }
)
_LOCK_TYPES = frozenset({"job", "heavy_write"})
_QUERY_OPERATIONS = frozenset(
diff --git a/api/app/products/recommendations.py b/api/app/products/recommendations.py
index e029555..731083b 100644
--- a/api/app/products/recommendations.py
+++ b/api/app/products/recommendations.py
@@ -95,13 +95,24 @@ def build_recommendation_snapshot(
if not places:
return RecommendationProductResult(0, 0)
place_ids = [place.eden_place_id for place in places]
+ # Retired KTO_TATS rows keep their relation history; read it for the
+ # TourAPI place they now point at.
+ canonical_by_alias = {
+ alias_id: canonical_id
+ for alias_id, canonical_id in session.execute(
+ select(Place.eden_place_id, Place.canonical_place_id).where(
+ Place.canonical_place_id.in_(place_ids)
+ )
+ ).all()
+ }
+ relation_source_ids = [*place_ids, *canonical_by_alias]
latest_relation_observed = (
select(
PlaceRelation.from_place_id.label("from_place_id"),
func.max(PlaceRelation.observed_at).label("observed_at"),
)
.where(
- PlaceRelation.from_place_id.in_(place_ids),
+ PlaceRelation.from_place_id.in_(relation_source_ids),
PlaceRelation.relation_type == "related",
)
.group_by(PlaceRelation.from_place_id)
@@ -116,7 +127,7 @@ def build_recommendation_snapshot(
& (latest_relation_observed.c.observed_at == PlaceRelation.observed_at),
)
.where(
- PlaceRelation.from_place_id.in_(place_ids),
+ PlaceRelation.from_place_id.in_(relation_source_ids),
PlaceRelation.relation_type == "related",
)
.order_by(
@@ -126,7 +137,24 @@ def build_recommendation_snapshot(
)
).all()
)
- localization_place_ids = sorted({*place_ids, *(row.to_place_id for row in relations)})
+ target_ids = {row.to_place_id for row in relations}
+ canonical_by_alias.update(
+ {
+ alias_id: canonical_id
+ for alias_id, canonical_id in session.execute(
+ select(Place.eden_place_id, Place.canonical_place_id).where(
+ Place.eden_place_id.in_(target_ids),
+ Place.canonical_place_id.is_not(None),
+ )
+ ).all()
+ }
+ )
+ localization_place_ids = sorted(
+ {
+ *place_ids,
+ *(canonical_by_alias.get(row.to_place_id, row.to_place_id) for row in relations),
+ }
+ )
localizations = list(
session.scalars(
select(PlaceLocalization)
@@ -205,9 +233,12 @@ def build_recommendation_snapshot(
by_place_sources.setdefault(row.eden_place_id, []).append(row)
latest_relations: dict[str, list[PlaceRelation]] = {}
for row in relations:
- current = latest_relations.setdefault(row.from_place_id, [])
+ owner_id = canonical_by_alias.get(row.from_place_id, row.from_place_id)
+ current = latest_relations.setdefault(owner_id, [])
if not current or row.observed_at == current[0].observed_at:
current.append(row)
+ elif row.observed_at > current[0].observed_at:
+ current[:] = [row]
latest_demand: dict[str, RegionalDemandObservation] = {}
for row in demand_rows:
latest_demand.setdefault(row.area_id, row)
@@ -276,14 +307,18 @@ def build_recommendation_snapshot(
"crowd_by_season": crowd_by_area.get(place.area_id, {}),
"related_places": [
{
- "content_id": row.to_place_id,
- "title": title_by_place.get(row.to_place_id),
+ "content_id": canonical_by_alias.get(row.to_place_id, row.to_place_id),
+ "title": title_by_place.get(
+ canonical_by_alias.get(row.to_place_id, row.to_place_id)
+ ),
"relation_type": "related",
"score": row.score,
"score_as_of": _aware(row.source_updated_at).isoformat(),
}
for row in related
- if title_by_place.get(row.to_place_id) is not None and row.score is not None
+ if title_by_place.get(canonical_by_alias.get(row.to_place_id, row.to_place_id))
+ is not None
+ and row.score is not None
],
"sources": sorted(
{
diff --git a/api/app/readmodels/repository.py b/api/app/readmodels/repository.py
index c2679a1..1e72646 100644
--- a/api/app/readmodels/repository.py
+++ b/api/app/readmodels/repository.py
@@ -790,14 +790,17 @@ def _fetch_place_detail(self, session: Session, key: str) -> ReadResult:
).all()
]
+ from app.normalization.place_crosswalk import alias_place_ids
+
+ alias_ids = alias_place_ids(session, place_id)
hub_data = None
hub_row = None
if "hub" in include:
hub_row = session.scalar(
select(PlaceRelation)
.where(
- PlaceRelation.from_place_id == place_id,
- PlaceRelation.to_place_id == place_id,
+ PlaceRelation.from_place_id.in_(alias_ids),
+ PlaceRelation.to_place_id.in_(alias_ids),
PlaceRelation.relation_type == "hub",
)
.order_by(PlaceRelation.observed_at.desc(), PlaceRelation.rank)
@@ -820,7 +823,7 @@ def _fetch_place_detail(self, session: Session, key: str) -> ReadResult:
if "related" in include:
latest_relation_at = session.scalar(
select(func.max(PlaceRelation.observed_at)).where(
- PlaceRelation.from_place_id == place_id,
+ PlaceRelation.from_place_id.in_(alias_ids),
PlaceRelation.relation_type == "related",
)
)
@@ -829,7 +832,7 @@ def _fetch_place_detail(self, session: Session, key: str) -> ReadResult:
session.scalars(
select(PlaceRelation)
.where(
- PlaceRelation.from_place_id == place_id,
+ PlaceRelation.from_place_id.in_(alias_ids),
PlaceRelation.relation_type == "related",
PlaceRelation.observed_at == latest_relation_at,
PlaceRelation.score.is_not(None),
@@ -840,10 +843,13 @@ def _fetch_place_detail(self, session: Session, key: str) -> ReadResult:
)
related_data = []
for relation in relation_rows:
+ target_id = _canonical_place_id(session, relation.to_place_id) or (
+ relation.to_place_id
+ )
title = session.scalar(
select(PlaceLocalization.title)
.where(
- PlaceLocalization.eden_place_id == relation.to_place_id,
+ PlaceLocalization.eden_place_id == target_id,
PlaceLocalization.language.in_((requested_language, "ko")),
)
.order_by((PlaceLocalization.language == requested_language).desc())
@@ -853,7 +859,7 @@ def _fetch_place_detail(self, session: Session, key: str) -> ReadResult:
continue
related_data.append(
{
- "content_id": relation.to_place_id,
+ "content_id": target_id,
"title": title,
"relation_type": relation.relation_type,
"score": float(relation.score),
diff --git a/api/app/scheduler/runtime.py b/api/app/scheduler/runtime.py
index 6c4e757..e7b5bb7 100644
--- a/api/app/scheduler/runtime.py
+++ b/api/app/scheduler/runtime.py
@@ -1057,6 +1057,47 @@ def run_snapshot_retention(
connection.close()
+PLACE_CROSSWALK_BATCH = 3000
+PLACE_CROSSWALK_MAX_SECONDS = 40.0
+
+
+@observe_scheduler_job("place_crosswalk")
+def run_place_crosswalk(
+ settings: Settings, factory: sessionmaker[Session]
+) -> dict[str, int] | None:
+ """Attach KTO hub/related rows minted before the crosswalk to their TourAPI places."""
+ from app.normalization.place_crosswalk import crosswalk_tats_places
+
+ engine: Engine = factory.kw["bind"]
+ connection = _open_job_lock_connection(engine)
+ try:
+ with MariaDBAdvisoryLock(connection, "eden:place:crosswalk") as lock:
+ if not lock.acquired:
+ return None
+ deadline = perf_counter() + PLACE_CROSSWALK_MAX_SECONDS
+ with factory() as session:
+ result = crosswalk_tats_places(
+ session,
+ limit=PLACE_CROSSWALK_BATCH,
+ deadline=deadline,
+ clock=perf_counter,
+ )
+ session.commit()
+ logger.info(
+ "place_crosswalk_batch",
+ extra={**result, "job_type": "place_crosswalk", "outcome": "completed"},
+ )
+ return result
+ except Exception:
+ logger.exception(
+ "place_crosswalk_failed",
+ extra={"job_type": "place_crosswalk", "outcome": "failed"},
+ )
+ return None
+ finally:
+ connection.close()
+
+
@observe_scheduler_job("alert")
def run_alert_enrichment(
settings: Settings,
@@ -1213,6 +1254,16 @@ def start_scheduler(settings: Settings, factory: sessionmaker[Session]) -> Sched
replace_existing=True,
executor="product",
)
+ scheduler.add_job(
+ run_place_crosswalk,
+ "interval",
+ seconds=6 * 3600,
+ args=[settings, factory],
+ id="eden:place:crosswalk",
+ next_run_time=now + timedelta(seconds=300),
+ replace_existing=True,
+ executor="product",
+ )
scheduler.add_job(
verify_scheduler_leadership,
"interval",
diff --git a/api/tests/integration/test_public_pipeline.py b/api/tests/integration/test_public_pipeline.py
index e964a22..a2051fd 100644
--- a/api/tests/integration/test_public_pipeline.py
+++ b/api/tests/integration/test_public_pipeline.py
@@ -1992,3 +1992,229 @@ def test_language_catalog_scope_translates_only_the_korean_essential_places(
assert all(op["operation"] == "areaBasedList2" for op in scope["operations"])
assert scope["allowed_content_ids"]["1"] == ["tour-1"]
assert all(not ids for code, ids in scope["allowed_content_ids"].items() if code != "1")
+
+
+def _tourapi_map_for_museum(session: Session) -> None:
+ """The fixture museum has no TourAPI identity; give it one for crosswalk tests."""
+ now = datetime.now(UTC).replace(tzinfo=None)
+ session.add(
+ PlaceSourceMap(
+ source_id="SRC_TOUR_KO",
+ external_content_id="tour-2",
+ eden_place_id=RELATED_PLACE_ID,
+ created_at=now,
+ updated_at=now,
+ )
+ )
+ session.flush()
+
+
+def _minted_tats_place(
+ session: Session,
+ external_id: str,
+ title: str,
+ *,
+ area_id: str = AREA_ID,
+ lat: Decimal | None = None,
+ lng: Decimal | None = None,
+ source_id: str = "SRC_KTO_PLACE_HUB",
+) -> str:
+ from app.normalization.place_crosswalk import tats_place_id
+
+ now = datetime.now(UTC).replace(tzinfo=None)
+ place_id = tats_place_id(external_id)
+ session.add(
+ Place(
+ eden_place_id=place_id,
+ area_id=area_id,
+ category=None,
+ lat=lat,
+ lng=lng,
+ merge_status="active",
+ created_at=now,
+ updated_at=now,
+ )
+ )
+ session.add(
+ PlaceSourceMap(
+ source_id=source_id,
+ external_content_id=external_id,
+ eden_place_id=place_id,
+ created_at=now,
+ updated_at=now,
+ )
+ )
+ session.add(
+ PlaceLocalization(
+ eden_place_id=place_id,
+ language="ko",
+ title=title,
+ address=None,
+ overview=None,
+ is_fallback=False,
+ created_at=now,
+ updated_at=now,
+ )
+ )
+ session.flush()
+ return place_id
+
+
+def test_crosswalk_matches_a_tats_row_to_its_tourapi_place_only_when_sure(
+ pipeline: Pipeline,
+) -> None:
+ from app.normalization.place_crosswalk import match_tour_place
+
+ with pipeline.session_factory() as session:
+ assert match_tour_place(session, "경복궁", AREA_ID, None, None) == PLACE_ID
+ assert match_tour_place(session, " 경복궁 ", AREA_ID, None, None) == PLACE_ID
+ # coordinates on both sides must agree
+ near = match_tour_place(session, "경복궁", AREA_ID, Decimal("37.5800"), Decimal("126.9775"))
+ far = match_tour_place(session, "경복궁", AREA_ID, Decimal("35.1000"), Decimal("129.0000"))
+ assert near == PLACE_ID and far is None
+ assert match_tour_place(session, "없는관광지", AREA_ID, None, None) is None
+ assert match_tour_place(session, "", AREA_ID, None, None) is None
+
+
+def test_hub_and_related_rows_attach_to_the_tourapi_place(pipeline: Pipeline) -> None:
+ from types import SimpleNamespace
+
+ from app.normalization.place_crosswalk import alias_place_ids, tats_place_id
+ from app.normalization.places import _upsert_place
+
+ with pipeline.session_factory.begin() as session:
+ # A hub row for 경복궁 arrives with the TATS code T-1.
+ place_id = _upsert_place(
+ session,
+ SimpleNamespace(raw_record_id=None, source_updated_at=None),
+ "SRC_KTO_PLACE_HUB",
+ "T-1",
+ AREA_ID,
+ "경복궁",
+ "ko",
+ "자연관광",
+ Decimal("37.5796"),
+ Decimal("126.9770"),
+ None,
+ None,
+ "KTO_TATS",
+ )
+ assert place_id == PLACE_ID
+ mapping = session.scalar(
+ select(PlaceSourceMap).where(
+ PlaceSourceMap.source_id == "SRC_KTO_PLACE_HUB",
+ PlaceSourceMap.external_content_id == "T-1",
+ )
+ )
+ assert mapping.eden_place_id == PLACE_ID
+ assert session.get(Place, tats_place_id("T-1")) is None # never minted
+ place = session.get(Place, PLACE_ID)
+ assert place.category == "A02010100" # TourAPI attributes untouched
+
+ with pipeline.session_factory() as session:
+ assert alias_place_ids(session, PLACE_ID) == [PLACE_ID]
+
+
+def test_backfill_retires_minted_tats_rows_and_reads_serve_their_relations(
+ pipeline: Pipeline,
+) -> None:
+ from app.normalization.place_crosswalk import (
+ alias_place_ids,
+ crosswalk_tats_places,
+ tats_place_id,
+ )
+
+ audit = datetime.now(UTC).replace(tzinfo=None)
+ with pipeline.session_factory.begin() as session:
+ _tourapi_map_for_museum(session)
+ palace_alias = _minted_tats_place(session, "T-palace", "경복궁")
+ museum_alias = _minted_tats_place(
+ session, "T-museum", "국립민속박물관", source_id="SRC_KTO_PLACE_RELATED"
+ )
+ unknown_alias = _minted_tats_place(session, "T-unknown", "이름없는곳")
+ session.add(
+ PlaceRelation(
+ from_place_id=palace_alias,
+ to_place_id=palace_alias,
+ relation_type="hub",
+ rank=1,
+ score=None,
+ **_fact_audit("SRC_KTO_PLACE_HUB", audit),
+ )
+ )
+ session.add(
+ PlaceRelation(
+ from_place_id=palace_alias,
+ to_place_id=museum_alias,
+ relation_type="related",
+ rank=1,
+ score=Decimal("100.0000"),
+ **_fact_audit("SRC_KTO_PLACE_RELATED", audit),
+ )
+ )
+
+ with pipeline.session_factory.begin() as session:
+ result = crosswalk_tats_places(session)
+ assert result == {"examined": 3, "matched": 2}
+
+ with pipeline.session_factory() as session:
+ assert alias_place_ids(session, PLACE_ID) == [PLACE_ID, palace_alias]
+ retired = session.get(Place, palace_alias)
+ assert retired.merge_status == "merged" and retired.canonical_place_id == PLACE_ID
+ assert session.get(Place, museum_alias).canonical_place_id == RELATED_PLACE_ID
+ assert session.get(Place, unknown_alias).canonical_place_id is None
+ assert session.get(Place, tats_place_id("T-unknown")).merge_status == "active"
+ # a second pass finds nothing left to examine among matched rows
+ assert crosswalk_tats_places(session) == {"examined": 1, "matched": 0}
+
+ response = pipeline.client.get(f"/v1/places/{PLACE_ID}", params={"include": ["hub", "related"]})
+ assert response.status_code == 200
+ data = response.json()["data"]
+ assert data["hub"] == {"is_hub": True, "rank": 1, "score_as_of": data["hub"]["score_as_of"]}
+ assert [row["content_id"] for row in data["related_places"]] == [RELATED_PLACE_ID]
+ assert data["related_places"][0]["title"] == "국립민속박물관"
+
+ # the retired TATS id resolves to the TourAPI place
+ alias_response = pipeline.client.get(f"/v1/places/{palace_alias}")
+ assert alias_response.status_code == 200
+ assert alias_response.json()["data"]["content_id"] == PLACE_ID
+
+
+def test_recommendations_credit_relations_recorded_on_retired_aliases(
+ pipeline: Pipeline, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ from app.normalization.place_crosswalk import crosswalk_tats_places
+ from app.products import recommendations
+
+ audit = datetime.now(UTC).replace(tzinfo=None)
+ with pipeline.session_factory.begin() as session:
+ _tourapi_map_for_museum(session)
+ palace_alias = _minted_tats_place(session, "T-palace-2", "경복궁")
+ museum_alias = _minted_tats_place(
+ session, "T-museum-2", "국립민속박물관", source_id="SRC_KTO_PLACE_RELATED"
+ )
+ session.add(
+ PlaceRelation(
+ from_place_id=palace_alias,
+ to_place_id=museum_alias,
+ relation_type="related",
+ rank=1,
+ score=Decimal("100.0000"),
+ **_fact_audit("SRC_KTO_PLACE_RELATED", audit + timedelta(days=1)),
+ )
+ )
+ crosswalk_tats_places(session)
+
+ candidates = []
+ monkeypatch.setattr(
+ recommendations.SnapshotPublisher,
+ "publish",
+ lambda _self, candidate: candidates.append(candidate),
+ )
+ recommendations.build_recommendation_snapshot(pipeline.session_factory)
+
+ feature = next(
+ feature for feature in candidates[0].data["features"] if feature["place_id"] == PLACE_ID
+ )
+ assert [row["content_id"] for row in feature["related_places"]] == [RELATED_PLACE_ID]
+ assert feature["related_places"][0]["title"] == "국립민속박물관"
From 919b0814e397f3e75c58e09295dcc1c6b2af2ebb Mon Sep 17 00:00:00 2001
From: rrrrok <132427414+rrrrok@users.noreply.github.com>
Date: Thu, 17 Sep 2026 09:58:38 +0900
Subject: [PATCH 4/8] feat(products): average attraction forecasts per area and
let the official demand index answer trends
Area forecast requests now return the mean of the area's attraction
concentration rates as the official value, with the attraction count and
basis, instead of unavailable. The trends view includes the KTO resource
demand index whenever it has observations, so Korean attraction keywords and
area filters are answered; default requests report the sources that
answered rather than degrading for the missing YouTube sample.
---
api/CHANGELOG.md | 2 +
api/KNOWN_GAPS.md | 4 +-
api/app/api/documentation.py | 5 +-
api/app/products/forecast_views.py | 53 +++++++++---
api/app/products/trend_views.py | 20 ++++-
api/app/readmodels/repository.py | 3 +
api/tests/unit/test_forecast_views.py | 25 +++---
api/tests/unit/test_read_source_selection.py | 7 ++
api/tests/unit/test_trend_views.py | 85 ++++++++++++++++++++
9 files changed, 177 insertions(+), 27 deletions(-)
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index 159f782..6d392f1 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -2,6 +2,8 @@
## 미배포
+- 시군구 방문 전망이 공식 예측 없이 unavailable로 나오던 것을 고쳤다. KTO 공식 예측은 관광지 단위이므로 지역 요청은 그 지역 관광지들의 공식 집중률 평균을 `official`로 내고, `sample_count`에 평균에 쓴 관광지 수와 `basis`에 근거를 표시한다. 관광지명을 지정한 요청과 시도 참고 전망은 그대로다.
+- 트렌드가 YouTube 고정 키워드 15개 외에는 항상 unavailable이던 것을 보완했다. 이미 수집 중인 KTO 관광자원 수요 지수(관광지명·지역별 월간, 0~100)를 관측이 있을 때 응답에 포함해 한국어 관광지명 키워드와 `area_code` 필터가 동작한다. `area_code`가 있으면 meta.sources에도 이 원천을 표시한다.
- KTO 중심 관광지·연관 관광지가 TourAPI 관광지에 붙지 않아 관광지 상세의 `hub`, `related_places`와 추천의 `related_places`가 항상 비어 있던 문제를 고쳤다. 두 KTO 원천은 TATS 코드로, 상세 API는 TourAPI content id로 관광지를 식별하는데, 같은 시도 안에서 정규화한 한국어 이름이 유일하게 일치하고 좌표가 있으면 1km 안에서 일치할 때만 같은 관광지로 본다(좌표만으로는 합치지 않는다). 새로 수집되는 허브·연관 행은 TourAPI 관광지에 바로 붙고, 이미 만들어진 TATS 관광지 행은 스케줄러 잡(`eden:place:crosswalk`, 6시간마다 최대 3,000건)이 canonical로 이어 붙이며 읽기 경로는 이어 붙인 행의 관계를 함께 조회한다.
- 2026-09-11에 껐던 관광지 원천 5개(TourAPI 영어·일본어·중국어 간체, KTO 중심 관광지, KTO 연관 관광지)를 다시 켰다. 언어별 카탈로그는 한국어 카탈로그로 고른 essential 관광지의 번역만 붙이고 새 관광지를 만들지 않는다(시도 3개씩 순환, 새 관광지 한도 0). 레지스트리의 enabled는 코드의 원천 범위를 따르며 스케줄러가 시작할 때 맞춘다.
- 관광지 상세의 `overview`가 항상 null이던 문제를 고쳤다. TourAPI 목록(areaBasedList2)에는 소개문이 없으므로, essential 관광지 중 소개문이 없는 곳이 있으면 TourAPI 실행이 시도 목록 대신 상세(detailCommon2)를 한 실행에 60곳씩 수집해 한국어 소개문을 채운다. 원천에 소개문이 없는 관광지는 빈 값으로 표시해 다시 요청하지 않으며 API에서는 null로 낸다. 목록 갱신이 저장된 소개문을 지우지 않는다.
diff --git a/api/KNOWN_GAPS.md b/api/KNOWN_GAPS.md
index 5f5eb74..0ac12be 100644
--- a/api/KNOWN_GAPS.md
+++ b/api/KNOWN_GAPS.md
@@ -7,8 +7,8 @@
- ~~**관광지 원천 5개 재활성화**~~: 2026-09-17 결정으로 다시 켰고, KTO_TATS↔TourAPI 매핑(`app/normalization/place_crosswalk.py`)을 구현했다. 이름이 같은 시도 안에서 유일하지 않거나 좌표가 1km 넘게 어긋나는 행은 매핑하지 않으므로 일부 관광지는 `hub`, `related_places`가 계속 비어 있을 수 있다. 매핑률은 배포 후 운영 DB에서 확인한다.
- ~~**관광지 소개문(`overview`)**~~: 2026-09-17 결정으로 구현했다. essential 관광지의 소개문을 detailCommon2로 한 실행에 60곳씩 수집한다(무료 쿼터 1,000/일 안).
- **공지 번역·요약**: `ALERT_ENRICHMENT_BATCH_SIZE=0`으로 유료 LLM 보강이 꺼져 있다. 켜면 시간당 2건, 하루 최대 48건 처리한다.
-- **시군구 방문 전망**: 6055a3e 이후 지역 요청은 특정 관광지의 집중률을 지역 값으로 쓰지 않는다. KTO 예측 행은 모두 관광지 단위라 시군구 요청은 공식 예측 없이 `unavailable`이 되고, 시도 요청은 과거 동일 요일 참고값을 쓴다. 시군구 단위 공식 값을 내려면 관광지 집중률의 집계 규칙(예: 평균)을 제품으로 정해야 한다.
-- **트렌드의 KTO 관광자원 수요**: `SRC_KTO_RESOURCE_DEMAND` 관측 7천여 행이 social_signal 게시본에 들어가지만 뷰가 선택하지 않는다(`ALWAYS_INCLUDED_SOURCES` 상수가 정의만 되고 미사용). 지역 필터(`area_code`)가 항상 unavailable인 이유의 절반이다.
+- ~~**시군구 방문 전망**~~: 2026-09-17 결정. 지역 요청은 지역 내 관광지들의 공식 집중률 평균을 `official`로 내고 `sample_count`와 `basis`로 근거를 밝힌다.
+- ~~**트렌드의 KTO 관광자원 수요**~~: 2026-09-17 결정. 관측이 있으면 응답에 포함되어 한국어 관광지명 키워드와 `area_code` 필터가 동작한다. YouTube 키워드 범위(15개 고정)는 그대로다.
- **대시보드 파라미터**: `compare=previous_period`(기간 대비 증감률)와 `constraints.avoid_crowds`(계절 반영)를 프런트가 보내지 않는다. 백엔드는 준비돼 있다.
## 외부 승인이나 원천 부재로 채울 수 없는 필드
diff --git a/api/app/api/documentation.py b/api/app/api/documentation.py
index b868eed..1fa6cad 100644
--- a/api/app/api/documentation.py
+++ b/api/app/api/documentation.py
@@ -234,7 +234,10 @@
"참고 지수로 인원을 만들지 않습니다."
),
"confidence": "검증된 신뢰도만 허용합니다. 현재 신뢰도 원천이 없어 항상 null.",
- "method": ("official은 공식 전망. historical_weekday_proxy는 방문 관측에 기반한 참고 지수."),
+ "method": (
+ "official은 공식 전망(지역 요청은 지역 내 관광지들의 공식 집중률 평균이며 sample_count에 "
+ "관광지 수를 표시). historical_weekday_proxy는 방문 관측에 기반한 참고 지수."
+ ),
"sample_count": "참고 전망의 분포에 사용한 유효 일별 관측 수. 같은 날짜는 한 번 셉니다.",
"basis": "같은 요일 표본 수와 전체 중앙값 대체 여부 및 장기 연장 설명.",
"posts": "검색 표본의 게시물 또는 영상 관측 수. 날짜별 합계는 고유 게시물 수가 아닙니다.",
diff --git a/api/app/products/forecast_views.py b/api/app/products/forecast_views.py
index 4247e32..90eed33 100644
--- a/api/app/products/forecast_views.py
+++ b/api/app/products/forecast_views.py
@@ -2,7 +2,7 @@
from collections import defaultdict
from datetime import date, datetime, timedelta
-from statistics import median
+from statistics import mean, median
from typing import Any
from zoneinfo import ZoneInfo
@@ -53,17 +53,40 @@ def _weather_block(
def _base_forecast(rows: list[dict[str, Any]], place_name: str | None) -> dict[str, Any] | None:
- candidates = [
+ official = [
row
for row in rows
if row.get("source_id") == "SRC_KTO_VISITOR_FORECAST"
and isinstance(row.get("source_forecast"), dict)
- and (
- row["source_forecast"].get("place_name") == place_name
- if place_name is not None
- else not row.get("place_id") and not row["source_forecast"].get("place_name")
- )
]
+ if place_name is not None:
+ candidates = [
+ row for row in official if row["source_forecast"].get("place_name") == place_name
+ ]
+ else:
+ candidates = [
+ row
+ for row in official
+ if not row.get("place_id") and not row["source_forecast"].get("place_name")
+ ]
+ if not candidates:
+ # The official forecast is published per attraction. An area request
+ # gets the mean of its attractions' rates and says how many were
+ # averaged, never one arbitrary attraction's value.
+ rates = [
+ float(row["source_forecast"]["concentration_rate"])
+ for row in official
+ if row["source_forecast"].get("concentration_rate") is not None
+ ]
+ if not rates:
+ return None
+ return {
+ "place_name": None,
+ "concentration_rate": round(mean(rates), 4),
+ "expected_visitors": None,
+ "sample_count": len(rates),
+ "basis": f"지역 내 관광지 {len(rates)}곳의 공식 집중률 평균",
+ }
if not candidates:
return None
selected = min(
@@ -284,8 +307,20 @@ def covered(source_id: str, target: date) -> bool:
"adjustment_factors": factors,
"method": "official" if has_official else proxy["method"] if proxy else None,
"basis_period": proxy["basis_period"] if proxy else None,
- "sample_count": proxy["sample_count"] if proxy else None,
- "basis": proxy["basis"] if proxy else None,
+ "sample_count": (
+ base.get("sample_count")
+ if has_official and base
+ else proxy["sample_count"]
+ if proxy
+ else None
+ ),
+ "basis": (
+ base.get("basis")
+ if has_official and base
+ else proxy["basis"]
+ if proxy
+ else None
+ ),
"availability": day_availability,
"reason": day_reason,
}
diff --git a/api/app/products/trend_views.py b/api/app/products/trend_views.py
index f11e5b4..48f572f 100644
--- a/api/app/products/trend_views.py
+++ b/api/app/products/trend_views.py
@@ -18,7 +18,10 @@
PERIOD_DAYS = {"7d": 7, "30d": 30, "90d": 90}
SOURCE_NAMES = REQUESTABLE_SOCIAL_SOURCES
-ALWAYS_INCLUDED_SOURCES = {"SRC_NAVER_TREND", "SRC_KTO_RESOURCE_DEMAND"}
+# Official KTO resource demand indexes are keyed by attraction name and area, so
+# they answer Korean keywords and area filters that the social samples cannot.
+# They join a response whenever they have observations for the request.
+ALWAYS_INCLUDED_SOURCES = {"SRC_KTO_RESOURCE_DEMAND"}
RISING_MIN_OBSERVATIONS_PER_WINDOW = 2
@@ -207,14 +210,17 @@ def build_trend_view(
if not isinstance(observations, list):
return None, Availability.UNAVAILABLE, "게시된 social signal 관측이 없습니다."
requested_sources = set(scope.get("social_sources") or ["youtube"])
- selected_source_ids = {SOURCE_NAMES[name] for name in requested_sources if name in SOURCE_NAMES}
+ requested_source_ids = {
+ SOURCE_NAMES[name] for name in requested_sources if name in SOURCE_NAMES
+ }
+ allowed_source_ids = requested_source_ids | ALWAYS_INCLUDED_SOURCES
keyword = normalize_keyword(str(scope["keyword"]))
candidates = [
row
for row in observations
if isinstance(row, dict)
and normalize_keyword(str(row.get("keyword", ""))).casefold() == keyword.casefold()
- and row.get("source_id") in selected_source_ids
+ and row.get("source_id") in allowed_source_ids
and (scope.get("country") == "all" or row.get("country") == scope.get("country"))
and (scope.get("area_code") is None or row.get("area_id") == scope.get("area_code"))
]
@@ -225,6 +231,14 @@ def build_trend_view(
rows = [row for row in candidates if _timestamp(row["bucket_start"]) >= cutoff]
if not rows:
return None, Availability.UNAVAILABLE, "요청 기간의 social signal이 없습니다."
+ observed_source_ids = {str(row.get("source_id")) for row in rows}
+ selected_source_ids = requested_source_ids | (ALWAYS_INCLUDED_SOURCES & observed_source_ids)
+ if scope.get("social_sources") is None:
+ # Nobody asked for a specific social sample: report the sources that
+ # answered instead of degrading the response for the default one.
+ selected_source_ids = {
+ source_id for source_id in selected_source_ids if source_id in observed_source_ids
+ } or selected_source_ids
bucket_scores, source_scores = _source_bucket_scores(rows, scope["time_unit"])
grouped_time: dict[datetime, list[dict[str, Any]]] = defaultdict(list)
diff --git a/api/app/readmodels/repository.py b/api/app/readmodels/repository.py
index 1e72646..4b71c36 100644
--- a/api/app/readmodels/repository.py
+++ b/api/app/readmodels/repository.py
@@ -144,6 +144,9 @@ def _request_source_ids(endpoint: str, scope: dict[str, object]) -> tuple[str, .
for name in names
if isinstance(name, str) and name in TREND_SOCIAL_SOURCES
)
+ if scope.get("area_code"):
+ # Area filters are answered by the official KTO resource demand index.
+ source_ids.add("SRC_KTO_RESOURCE_DEMAND")
return tuple(sorted(source_ids))
if endpoint == "region_insights":
selected = scope.get("include")
diff --git a/api/tests/unit/test_forecast_views.py b/api/tests/unit/test_forecast_views.py
index a3e0eb1..176b15a 100644
--- a/api/tests/unit/test_forecast_views.py
+++ b/api/tests/unit/test_forecast_views.py
@@ -169,7 +169,7 @@ def test_forecast_missing_requested_adjustments_are_null_and_partial() -> None:
assert reason == "일부 날짜에서 요청한 참고 원천이 없습니다."
-def test_area_forecast_does_not_choose_an_arbitrary_attraction() -> None:
+def test_area_forecast_averages_its_attractions_deterministically() -> None:
rows = [
{
"input_id": 2,
@@ -206,9 +206,15 @@ def test_area_forecast_does_not_choose_an_arbitrary_attraction() -> None:
)
assert forward == reverse
- assert forward[0]["daily"][0]["source_concentration_rate"] is None
- assert forward[0]["daily"][0]["method"] is None
- assert forward[1] == Availability.UNAVAILABLE
+ # An area request averages its attractions instead of picking one of them.
+ day = forward[0]["daily"][0]
+ assert day["source_concentration_rate"] == 50.0
+ assert day["demand_score"] == 50.0
+ assert day["method"] == "official"
+ assert day["sample_count"] == 2
+ assert day["basis"] == "지역 내 관광지 2곳의 공식 집중률 평균"
+ assert day["expected_visitors"] is None
+ assert forward[1] == Availability.PARTIAL # reference sources are not covered
named, _, _ = build_forecast_view(
{"area_code": "11", "inputs": rows},
@@ -221,9 +227,9 @@ def test_area_forecast_does_not_choose_an_arbitrary_attraction() -> None:
@pytest.mark.parametrize(
("place_id", "place_name", "expected_score"),
- [(None, None, 40.0), (None, "광화문", None), ("place_a", None, None)],
+ [(None, None, 40.0), (None, "광화문", 40.0), ("place_a", None, 40.0)],
)
-def test_area_forecast_requires_unlinked_and_unnamed_official_data(
+def test_area_forecast_uses_area_rows_or_the_attraction_mean(
place_id: str | None, place_name: str | None, expected_score: float | None
) -> None:
product = {
@@ -322,13 +328,8 @@ def __iter__(self):
}
for offset in range(28)
])
- attraction = {
- "source_id": "SRC_KTO_VISITOR_FORECAST",
- "forecast_date": today.isoformat(),
- "source_forecast": {"place_name": "광화문", "concentration_rate": 80.0},
- }
data, status, _ = build_forecast_view(
- {"area_code": "11", "visits": visits, "inputs": [attraction]},
+ {"area_code": "11", "visits": visits, "inputs": []},
{"days": 30},
today=today,
)
diff --git a/api/tests/unit/test_read_source_selection.py b/api/tests/unit/test_read_source_selection.py
index 7e35c3f..d038e76 100644
--- a/api/tests/unit/test_read_source_selection.py
+++ b/api/tests/unit/test_read_source_selection.py
@@ -70,3 +70,10 @@ def test_excluded_source_watermark_does_not_age_the_response() -> None:
(snapshot,),
("SRC_KTO_VISITOR_FORECAST",),
) == datetime(2026, 8, 29, tzinfo=UTC)
+
+
+def test_trend_area_requests_list_the_official_resource_demand_source() -> None:
+ assert _request_source_ids("trends", {"social_sources": ["youtube"]}) == ("SRC_YOUTUBE",)
+ assert _request_source_ids(
+ "trends", {"social_sources": ["youtube"], "area_code": "eden_area_seoul"}
+ ) == ("SRC_KTO_RESOURCE_DEMAND", "SRC_YOUTUBE")
diff --git a/api/tests/unit/test_trend_views.py b/api/tests/unit/test_trend_views.py
index 3c518f5..8e44dcd 100644
--- a/api/tests/unit/test_trend_views.py
+++ b/api/tests/unit/test_trend_views.py
@@ -190,3 +190,88 @@ def test_trend_view_filters_excluded_observations_from_stale_snapshots() -> None
assert data["sources"] == ["SRC_YOUTUBE"]
assert data["series"][0]["youtube_views"] == 30
assert all(row["source_id"] != "SRC_TIKTOK" for row in data["source_metrics"])
+
+
+def test_official_resource_demand_answers_korean_keywords_and_area_filters() -> None:
+ product = {
+ "observations": [
+ {
+ "source_id": "SRC_KTO_RESOURCE_DEMAND",
+ "keyword": "경복궁",
+ "country": None,
+ "area_id": "eden_area_seoul",
+ "bucket_start": "2026-06-01T00:00:00+00:00",
+ "source_score": 60.0,
+ },
+ {
+ "source_id": "SRC_KTO_RESOURCE_DEMAND",
+ "keyword": "경복궁",
+ "country": None,
+ "area_id": "eden_area_seoul",
+ "bucket_start": "2026-07-01T00:00:00+00:00",
+ "source_score": 80.0,
+ },
+ {
+ "source_id": "SRC_KTO_RESOURCE_DEMAND",
+ "keyword": "해운대",
+ "country": None,
+ "area_id": "eden_area_busan",
+ "bucket_start": "2026-07-01T00:00:00+00:00",
+ "source_score": 90.0,
+ },
+ ]
+ }
+ scope = {
+ "keyword": "경복궁",
+ "country": "all",
+ "area_code": "eden_area_seoul",
+ "social_sources": None,
+ "period": "90d",
+ "time_unit": "month",
+ }
+
+ data, availability, reason = build_trend_view(product, scope)
+
+ assert data is not None
+ assert reason is None and availability == Availability.AVAILABLE
+ assert data["sources"] == ["SRC_KTO_RESOURCE_DEMAND"]
+ assert data["interest_index"] == 70.0
+ assert [point["interest_index"] for point in data["series"]] == [60.0, 80.0]
+ assert set(data["source_availability"]) == {"SRC_KTO_RESOURCE_DEMAND"}
+
+ # an explicit social request keeps reporting that sample as missing
+ explicit, explicit_availability, _ = build_trend_view(
+ product, {**scope, "social_sources": ["youtube"]}
+ )
+ assert explicit_availability == Availability.PARTIAL
+ assert explicit["source_availability"]["SRC_YOUTUBE"]["availability"] == "unavailable"
+
+ other_area = build_trend_view(product, {**scope, "area_code": "eden_area_busan"})
+ assert other_area[0] is None and other_area[1] == Availability.UNAVAILABLE
+
+
+def test_youtube_only_responses_do_not_list_the_official_index_without_observations() -> None:
+ product = {
+ "observations": [
+ {
+ "source_id": "SRC_YOUTUBE",
+ "keyword": "Seoul travel",
+ "country": "US",
+ "bucket_start": "2026-08-29T00:00:00+00:00",
+ "view_count": 30,
+ }
+ ]
+ }
+ scope = {
+ "keyword": "Seoul travel",
+ "country": "US",
+ "area_code": None,
+ "social_sources": ["youtube"],
+ "period": "7d",
+ "time_unit": "day",
+ }
+
+ data, _, _ = build_trend_view(product, scope)
+
+ assert data is not None
+ assert set(data["source_availability"]) == {"SRC_YOUTUBE"}
From d50339c4c2d881bb2021337237712a3ff82f56a2 Mon Sep 17 00:00:00 2001
From: rrrrok <132427414+rrrrok@users.noreply.github.com>
Date: Thu, 17 Sep 2026 10:19:20 +0900
Subject: [PATCH 5/8] feat(inbound): collect country-level passenger counts
The Incheon airport statistics service that already supplies monthly
arrival flights by country also publishes arrivals by passengers through
getTotalNumberOfPassenger. Collect it alongside the flight operation, merge
both facts into the same monthly observation, and publish the sum as the
inbound market's passengers field. KNOWN_GAPS records the source search for
the other unsourced fields: none has a public API.
---
api/CHANGELOG.md | 1 +
api/KNOWN_GAPS.md | 10 +++++++-
api/README.md | 2 +-
api/app/api/documentation.py | 4 ++--
api/app/normalization/inbound_sources.py | 23 +++++++++++++++++++
api/app/products/inbound.py | 7 +++---
api/app/sources/plans.py | 12 +++++++---
api/tests/integration/test_public_pipeline.py | 22 ++++++++++++++++++
.../test_inbound_normalization_contract.py | 12 ++++++++++
api/tests/unit/test_public_data_plans.py | 21 +++++++++++++++++
10 files changed, 104 insertions(+), 10 deletions(-)
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index 6d392f1..1fe8f0b 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -2,6 +2,7 @@
## 미배포
+- 방한시장의 `passengers`를 채운다. 인천공항 국가별 항공통계 서비스의 여객 오퍼레이션(getTotalNumberOfPassenger)을 운항편 오퍼레이션과 함께 수집하고, 같은 국가·월의 관측에 운항편 수와 여객 수를 합쳐 저장한다. 여객 통계를 수집하기 전 달은 null로 남는다.
- 시군구 방문 전망이 공식 예측 없이 unavailable로 나오던 것을 고쳤다. KTO 공식 예측은 관광지 단위이므로 지역 요청은 그 지역 관광지들의 공식 집중률 평균을 `official`로 내고, `sample_count`에 평균에 쓴 관광지 수와 `basis`에 근거를 표시한다. 관광지명을 지정한 요청과 시도 참고 전망은 그대로다.
- 트렌드가 YouTube 고정 키워드 15개 외에는 항상 unavailable이던 것을 보완했다. 이미 수집 중인 KTO 관광자원 수요 지수(관광지명·지역별 월간, 0~100)를 관측이 있을 때 응답에 포함해 한국어 관광지명 키워드와 `area_code` 필터가 동작한다. `area_code`가 있으면 meta.sources에도 이 원천을 표시한다.
- KTO 중심 관광지·연관 관광지가 TourAPI 관광지에 붙지 않아 관광지 상세의 `hub`, `related_places`와 추천의 `related_places`가 항상 비어 있던 문제를 고쳤다. 두 KTO 원천은 TATS 코드로, 상세 API는 TourAPI content id로 관광지를 식별하는데, 같은 시도 안에서 정규화한 한국어 이름이 유일하게 일치하고 좌표가 있으면 1km 안에서 일치할 때만 같은 관광지로 본다(좌표만으로는 합치지 않는다). 새로 수집되는 허브·연관 행은 TourAPI 관광지에 바로 붙고, 이미 만들어진 TATS 관광지 행은 스케줄러 잡(`eden:place:crosswalk`, 6시간마다 최대 3,000건)이 canonical로 이어 붙이며 읽기 경로는 이어 붙인 행의 관계를 함께 조회한다.
diff --git a/api/KNOWN_GAPS.md b/api/KNOWN_GAPS.md
index 0ac12be..1896cc0 100644
--- a/api/KNOWN_GAPS.md
+++ b/api/KNOWN_GAPS.md
@@ -21,12 +21,20 @@
| regions/insights | `demand.avg_stay_nights`, `diversity.age_index` | KTO 원천에 대응 지표 없음, 정규화가 None 고정 (insights는 이 때문에 항상 partial) |
| visitors/timeseries | `concentration_rate`, `attraction_name` 경로 | 작성 경로 없음, `SRC_TOURISM_ADMISSION`은 HTTP 전용이라 어댑터가 unavailable |
| forecasts/visitors | `expected_visitors`, `confidence`, `adjustment_factors` | 원천 없음, `formulas.adjusted_forecast`는 호출되지 않음 |
-| markets/inbound | `passengers`, `social_interest.youtube.score` | 공항공사 월별 자료에 여객 수 없음(DB 245행 모두 null), YouTube는 설계상 국가 신호에서 제외 |
+| markets/inbound | `social_interest.youtube.score` | YouTube는 설계상 국가 신호에서 제외. `passengers`는 2026-09-17 인천공항 국가별 여객 오퍼레이션(getTotalNumberOfPassenger)을 추가해 수집한다 |
| markets/{country}/alerts | `source_scope=local`, `status=inactive`, `source_type=foreign_affairs` | 현지 기관 수집기 없음, 비활성 문서는 삭제되므로 도달 불가 |
| recommendations | `estimated_budget_krw`, `budget_krw`, `days`, `party_size`, 접근성·이동시간 조건 | 검증 원천 없음 |
**결정(2026-09-16):** 위 필드는 "미제공"으로 문서화하고(README "Fields That Are Not Provided", OpenAPI 필드 설명) 가용성 판정에서 제외한다. 필드와 응답 구조는 그대로 두어 대시보드와 클라이언트는 영향을 받지 않는다. 그 결과 insights·inbound·recommendations는 원천이 있는 필드가 모두 채워지면 `available`로 응답한다. 원천이 생기면 해당 필드를 채우고 이 목록에서 빼면 된다.
+## B 항목 원천 조사 결과 (2026-09-17)
+
+- **passengers**: 인천공항 국가별 항공통계 서비스(B551177/AviationStatsByCountry)의 `getTotalNumberOfPassenger`가 국가별 월간 도착·출발 여객 수를 제공한다(2026-07 기준 57개국, 라이브 확인). 같은 서비스 키로 되며 수집을 추가했다.
+- **avg_stay_nights, age_index**: 관광공사 데이터랩 공개 API(AreaTarDemDsService, AreaTarDivService)는 관광체류강도·관광소비강도·관광객 다양성·소비 다양성·국제적 다양성 지수만 준다. 숙박일수와 연령 구성은 데이터랩 웹에만 있고 오픈 API에는 없다. 원천 없음 유지.
+- **estimated_budget_krw**: 관광지 단위 비용 원천은 없다. 지역 단위 관광소비강도 지수(이미 수집)만 있다. 원천 없음 유지.
+- **concentration_rate(시계열), expected_visitors, confidence**: 공개 원천 없음.
+- **destination_searches, search_ratio**: NAVER 데이터랩(상대 검색 비율만 제공)을 켜면 `search_ratio`는 채워진다. 절대 검색 수는 어떤 원천도 주지 않는다.
+
## 데이터 품질로 격리된 항목
- 축제(`SRC_FESTIVAL`) 격리 50행: 종료일이 시작일보다 앞서거나 주소가 여러 지역에 걸치는 원천 데이터다. 코드가 추측하지 않고 제외한 것이며 버그가 아니다.
diff --git a/api/README.md b/api/README.md
index 131ae82..bc63d58 100644
--- a/api/README.md
+++ b/api/README.md
@@ -128,7 +128,7 @@ Some declared fields have no verified source today. They are always `null` (or `
| `/v1/regions/{area_code}/insights` | `demand.avg_stay_nights`, `diversity.age_index` | The official regional statistics publish no such dimension |
| `/v1/visitors/timeseries` | `concentration_rate`, `summary.peak_concentration_rate` | The visitor statistics do not publish concentration |
| `/v1/forecasts/visitors` | `expected_visitors`, `confidence`, `adjustment_factors` | No source forecasts headcounts or confidence; the reference index is not scaled into people |
-| `/v1/markets/inbound` | `passengers`, `social_interest.youtube.score` | Airport statistics publish flight counts only; a search sample is not a country signal |
+| `/v1/markets/inbound` | `social_interest.youtube.score` | A search sample is not a country signal |
| `/v1/trends` | `destination_searches`, `search_ratio`, `sns_mentions` | No connected source; NAVER and the other social platforms need external approval |
| `/v1/recommendations/destinations` | `estimated_budget_krw`, `budget_krw`, `travel_window.days`, `party_size`, `constraints.accessibility_required`, `constraints.max_travel_minutes` | No verified cost, stay, capacity, accessibility or travel-time source |
diff --git a/api/app/api/documentation.py b/api/app/api/documentation.py
index 1fa6cad..e1bc135 100644
--- a/api/app/api/documentation.py
+++ b/api/app/api/documentation.py
@@ -244,8 +244,8 @@
"views": "검색 표본 영상의 조회 수 합계. 실제 국적별 시청자 수가 아닙니다.",
"reactions": "해당 원천 표본의 반응 수. 미제공 반응을 0으로 대체하지 않습니다.",
"passengers": (
- "항공 여객 수(명). 공항공사 국가별 통계는 운항편 수만 제공하므로 항상 null이며 "
- "가용성 판정에서 제외됩니다."
+ "인천공항 국가별 도착 여객 수(명, 유임·무임·환승 합계). 여객 통계를 수집하기 전 "
+ "달은 null입니다."
),
"arriving_flights": "해당 국가에서 도착하는 운항편 수. 여객 수와 다른 지표입니다.",
"completeness_ratio": ("요청 관측 창의 제공 비율(0~1). 누락된 날짜를 0명으로 해석하지 마세요."),
diff --git a/api/app/normalization/inbound_sources.py b/api/app/normalization/inbound_sources.py
index 665c7ad..60353a5 100644
--- a/api/app/normalization/inbound_sources.py
+++ b/api/app/normalization/inbound_sources.py
@@ -106,6 +106,20 @@ def _nonnegative_integer(row: dict[str, Any], *names: str, required: bool = Fals
return int(value)
+def merge_airport_metrics(
+ flights: int | None,
+ passengers: int | None,
+ existing: FlightObservation | None,
+) -> tuple[int | None, int | None]:
+ """Flights and passengers arrive from separate operations; keep the other fact."""
+ if existing is None:
+ return flights, passengers
+ return (
+ flights if flights is not None else existing.arriving_flights,
+ passengers if passengers is not None else existing.passengers,
+ )
+
+
def airport_country_metrics(row: dict[str, Any]) -> tuple[int | None, int | None]:
"""Return flight and passenger facts without substituting one for the other."""
flights = _nonnegative_integer(row, "arrFlight")
@@ -226,6 +240,15 @@ def normalize_airport_country_run(session_factory: sessionmaker[Session], run_id
continue
raise
with session.begin_nested():
+ existing = session.scalar(
+ select(FlightObservation).where(
+ FlightObservation.source_id == AIRPORT_COUNTRY_SOURCE,
+ FlightObservation.country_id == country_id,
+ FlightObservation.period_start == period,
+ FlightObservation.grain == "month",
+ )
+ )
+ flights, passengers = merge_airport_metrics(flights, passengers, existing)
values = {
"country_id": country_id,
"period_start": period,
diff --git a/api/app/products/inbound.py b/api/app/products/inbound.py
index 9c884c8..f4e6287 100644
--- a/api/app/products/inbound.py
+++ b/api/app/products/inbound.py
@@ -260,6 +260,7 @@ def build_inbound_snapshots(
social_rows = [row for row in social_population_rows if row.country_id == country_id]
current_total = _sum_optional([row.visitor_count for row in current_rows])
arriving_flights = _sum_optional([row.arriving_flights for row in current_flights])
+ passengers = _sum_optional([row.passengers for row in current_flights])
visitor_change, visitor_change_reason = _monthly_change_rate(
current_rows, previous_rows, month_count, "visitor_count"
)
@@ -369,8 +370,8 @@ def build_inbound_snapshots(
"availability": availability.value,
"reason": reason,
},
- # passengers has no source (the airport statistics publish flight
- # counts only) and stays null without degrading the block.
+ # passengers comes from the same airport statistics service; a
+ # month collected before that operation existed stays null.
"flights": {
"availability": (
"unavailable"
@@ -428,7 +429,7 @@ def build_inbound_snapshots(
"visitor_completeness_ratio": round(completeness, 6),
"arriving_flights": arriving_flights,
"flight_change_rate": flight_change,
- "passengers": None,
+ "passengers": passengers,
"flight_schedule": (
{
**(schedule.schedule or {}),
diff --git a/api/app/sources/plans.py b/api/app/sources/plans.py
index ec9e098..c8a6d0c 100644
--- a/api/app/sources/plans.py
+++ b/api/app/sources/plans.py
@@ -360,12 +360,12 @@ def _kto_monthly_area_operations(
"rotation_seconds": 24 * 3600,
"operations": [
{
- "operation": "getTotalNumberOfFlight",
- "external_key": f"airport-country:month=$month_minus_{months}",
+ "operation": operation,
+ "external_key": f"{key_prefix}:month=$month_minus_{months}",
"params": {
"from_month": f"$month_minus_{months}",
"to_month": f"$month_minus_{months}",
- "pax_cargo": "Y",
+ **extra_params,
},
"watermark": {"param": "to_month", "format": "%Y%m"},
"response_type_param": "type",
@@ -373,6 +373,12 @@ def _kto_monthly_area_operations(
"pagination_params": False,
}
# Retain existing history, but refresh only the two latest source months.
+ # The same service publishes flights and passengers (arrPassenger)
+ # through separate operations; both feed one monthly observation.
+ for operation, key_prefix, extra_params in (
+ ("getTotalNumberOfFlight", "airport-country", {"pax_cargo": "Y"}),
+ ("getTotalNumberOfPassenger", "airport-country-passengers", {}),
+ )
for months in range(1, 3)
],
},
diff --git a/api/tests/integration/test_public_pipeline.py b/api/tests/integration/test_public_pipeline.py
index a2051fd..17cb32e 100644
--- a/api/tests/integration/test_public_pipeline.py
+++ b/api/tests/integration/test_public_pipeline.py
@@ -2218,3 +2218,25 @@ def test_recommendations_credit_relations_recorded_on_retired_aliases(
)
assert [row["content_id"] for row in feature["related_places"]] == [RELATED_PLACE_ID]
assert feature["related_places"][0]["title"] == "국립민속박물관"
+
+
+def test_inbound_publishes_collected_passenger_counts(pipeline: Pipeline) -> None:
+ with pipeline.session_factory.begin() as session:
+ rows = session.scalars(
+ select(FlightObservation).where(
+ FlightObservation.source_id == "SRC_AIRPORT_COUNTRY",
+ FlightObservation.grain == "month",
+ )
+ ).all()
+ assert rows
+ for row in rows:
+ row.passengers = 1_000
+ build_inbound_snapshots(pipeline.session_factory)
+
+ response = pipeline.client.get(
+ "/v1/markets/inbound", params={"countries": "JP", "period": "3m", "include": "flights"}
+ )
+ assert response.status_code == 200
+ market = response.json()["data"]["markets"][0]
+ assert market["passengers"] == 3_000
+ assert market["source_availability"]["flights"]["availability"] == "available"
diff --git a/api/tests/unit/test_inbound_normalization_contract.py b/api/tests/unit/test_inbound_normalization_contract.py
index dd807aa..1415e83 100644
--- a/api/tests/unit/test_inbound_normalization_contract.py
+++ b/api/tests/unit/test_inbound_normalization_contract.py
@@ -224,3 +224,15 @@ def scalar(self, _statement: object) -> int:
datetime(2026, 9, 10),
}
assert all(row["source_updated_at"] == row["rate_date"] for row in params)
+
+
+def test_airport_operations_merge_into_one_monthly_observation() -> None:
+ existing = SimpleNamespace(arriving_flights=641, passengers=None)
+
+ assert inbound_sources.merge_airport_metrics(None, 46_273, existing) == (641, 46_273)
+ assert inbound_sources.merge_airport_metrics(650, None, existing) == (650, None)
+ assert inbound_sources.merge_airport_metrics(650, 47_000, existing) == (650, 47_000)
+ assert inbound_sources.merge_airport_metrics(None, 46_273, None) == (None, 46_273)
+ assert inbound_sources.airport_country_metrics(
+ {"country": "일본", "arrPassenger": "46,273", "depPassenger": "44,016"}
+ ) == (None, 46_273)
diff --git a/api/tests/unit/test_public_data_plans.py b/api/tests/unit/test_public_data_plans.py
index 2114b78..de44742 100644
--- a/api/tests/unit/test_public_data_plans.py
+++ b/api/tests/unit/test_public_data_plans.py
@@ -273,3 +273,24 @@ def test_language_catalogs_share_the_tourapi_batch_and_budget() -> None:
for source_id in ("SRC_TOUR_EN", "SRC_TOUR_JA", "SRC_TOUR_ZH_CN"):
assert scheduler_batch_size(source_id) == scheduler_batch_size("SRC_TOUR_KO")
assert _batched_request_budget(source_id, 5) == _batched_request_budget("SRC_TOUR_KO", 5)
+
+
+def test_airport_country_scope_collects_flights_and_passengers_for_two_months() -> None:
+ scope = public_data_refresh_scope("SRC_AIRPORT_COUNTRY")
+ operations = scope["operations"]
+
+ assert [op["operation"] for op in operations] == [
+ "getTotalNumberOfFlight",
+ "getTotalNumberOfFlight",
+ "getTotalNumberOfPassenger",
+ "getTotalNumberOfPassenger",
+ ]
+ assert len({op["external_key"] for op in operations}) == 4
+ passenger = operations[2]
+ assert passenger["external_key"] == "airport-country-passengers:month=$month_minus_1"
+ assert passenger["params"] == {
+ "from_month": "$month_minus_1",
+ "to_month": "$month_minus_1",
+ }
+ assert passenger["watermark"] == {"param": "to_month", "format": "%Y%m"}
+ assert len(operations) <= scope["max_operations_per_run"]
From e873a059887e6e2f5ba83e8716ebfd451019e58e Mon Sep 17 00:00:00 2001
From: rrrrok <132427414+rrrrok@users.noreply.github.com>
Date: Thu, 17 Sep 2026 10:36:55 +0900
Subject: [PATCH 6/8] feat(sources): enable NAVER search trends with Korean
province keywords
NAVER answers Korean queries only, so the market keyword set returned no
data. Collect eighteen Korean province travel keywords five per daily run,
store them without a market country, and let default trend requests fill
search_ratio from them. Production still needs NAVER_STORAGE_POLICY_APPROVED.
---
api/CHANGELOG.md | 1 +
api/KNOWN_GAPS.md | 4 +-
api/README.md | 2 +-
api/app/api/documentation.py | 5 +-
api/app/normalization/social.py | 14 ++++--
api/app/products/trend_views.py | 13 +++--
api/app/readmodels/repository.py | 11 +++--
api/app/sources/essential.py | 2 +-
api/app/sources/plans.py | 48 ++++++++++++++++++-
api/tests/integration/test_public_pipeline.py | 2 +-
api/tests/unit/test_public_data_plans.py | 18 +++++++
api/tests/unit/test_read_source_selection.py | 13 ++++-
api/tests/unit/test_reference_seed.py | 3 +-
api/tests/unit/test_trend_views.py | 45 +++++++++++++++--
14 files changed, 155 insertions(+), 26 deletions(-)
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index 1fe8f0b..a645412 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -2,6 +2,7 @@
## 미배포
+- NAVER 검색 트렌드(SRC_NAVER_TREND)를 켰다. NAVER는 한국어 검색만 답하므로 시장별 외국어 키워드 대신 지역 여행 키워드 18개("한국 여행", "서울 여행", …)를 하루 5개씩 순환 수집하고, 국내 검색 관심은 특정 방한시장에 속하지 않으므로 country 없이 저장한다. 트렌드 응답은 social_sources를 지정하지 않은 요청에서 한국어 키워드의 `search_ratio`를 NAVER로 채운다. 운영 반영에는 `NAVER_STORAGE_POLICY_APPROVED=true` 설정이 필요하다.
- 방한시장의 `passengers`를 채운다. 인천공항 국가별 항공통계 서비스의 여객 오퍼레이션(getTotalNumberOfPassenger)을 운항편 오퍼레이션과 함께 수집하고, 같은 국가·월의 관측에 운항편 수와 여객 수를 합쳐 저장한다. 여객 통계를 수집하기 전 달은 null로 남는다.
- 시군구 방문 전망이 공식 예측 없이 unavailable로 나오던 것을 고쳤다. KTO 공식 예측은 관광지 단위이므로 지역 요청은 그 지역 관광지들의 공식 집중률 평균을 `official`로 내고, `sample_count`에 평균에 쓴 관광지 수와 `basis`에 근거를 표시한다. 관광지명을 지정한 요청과 시도 참고 전망은 그대로다.
- 트렌드가 YouTube 고정 키워드 15개 외에는 항상 unavailable이던 것을 보완했다. 이미 수집 중인 KTO 관광자원 수요 지수(관광지명·지역별 월간, 0~100)를 관측이 있을 때 응답에 포함해 한국어 관광지명 키워드와 `area_code` 필터가 동작한다. `area_code`가 있으면 meta.sources에도 이 원천을 표시한다.
diff --git a/api/KNOWN_GAPS.md b/api/KNOWN_GAPS.md
index 1896cc0..b8d4d9d 100644
--- a/api/KNOWN_GAPS.md
+++ b/api/KNOWN_GAPS.md
@@ -15,7 +15,6 @@
| 엔드포인트 | 필드 | 상태 |
| --- | --- | --- |
-| trends | `search_ratio` | NAVER 데이터랩 전용, 소스 비활성 + 저장 정책 미승인 |
| trends, inbound | instagram / facebook / reddit 블록, `sns_mentions` | 어댑터 없음, 외부 승인 필요 |
| trends | `destination_searches` | 어떤 원천도 연결되지 않음 |
| regions/insights | `demand.avg_stay_nights`, `diversity.age_index` | KTO 원천에 대응 지표 없음, 정규화가 None 고정 (insights는 이 때문에 항상 partial) |
@@ -33,7 +32,8 @@
- **avg_stay_nights, age_index**: 관광공사 데이터랩 공개 API(AreaTarDemDsService, AreaTarDivService)는 관광체류강도·관광소비강도·관광객 다양성·소비 다양성·국제적 다양성 지수만 준다. 숙박일수와 연령 구성은 데이터랩 웹에만 있고 오픈 API에는 없다. 원천 없음 유지.
- **estimated_budget_krw**: 관광지 단위 비용 원천은 없다. 지역 단위 관광소비강도 지수(이미 수집)만 있다. 원천 없음 유지.
- **concentration_rate(시계열), expected_visitors, confidence**: 공개 원천 없음.
-- **destination_searches, search_ratio**: NAVER 데이터랩(상대 검색 비율만 제공)을 켜면 `search_ratio`는 채워진다. 절대 검색 수는 어떤 원천도 주지 않는다.
+- **search_ratio**: 2026-09-17 NAVER 검색 트렌드(NCP API Hub)를 켰다. 외국어 시장 키워드에는 데이터가 없어 한국어 지역 여행 키워드 18개("서울 여행" 등)를 하루 5개씩 순환 수집한다. 운영에서 켜지려면 루트 `.env`에 `NAVER_STORAGE_POLICY_APPROVED=true`가 있어야 한다(저장·재게시 권리 확인 플래그).
+- **destination_searches**: 절대 검색 수는 어떤 원천도 주지 않는다.
## 데이터 품질로 격리된 항목
diff --git a/api/README.md b/api/README.md
index bc63d58..f3fbaeb 100644
--- a/api/README.md
+++ b/api/README.md
@@ -129,7 +129,7 @@ Some declared fields have no verified source today. They are always `null` (or `
| `/v1/visitors/timeseries` | `concentration_rate`, `summary.peak_concentration_rate` | The visitor statistics do not publish concentration |
| `/v1/forecasts/visitors` | `expected_visitors`, `confidence`, `adjustment_factors` | No source forecasts headcounts or confidence; the reference index is not scaled into people |
| `/v1/markets/inbound` | `social_interest.youtube.score` | A search sample is not a country signal |
-| `/v1/trends` | `destination_searches`, `search_ratio`, `sns_mentions` | No connected source; NAVER and the other social platforms need external approval |
+| `/v1/trends` | `destination_searches`, `sns_mentions` | No absolute search-count source; the other social platforms need external approval. `search_ratio` comes from NAVER for Korean province travel keywords only |
| `/v1/recommendations/destinations` | `estimated_budget_krw`, `budget_krw`, `travel_window.days`, `party_size`, `constraints.accessibility_required`, `constraints.max_travel_minutes` | No verified cost, stay, capacity, accessibility or travel-time source |
### Understanding the Indicators
diff --git a/api/app/api/documentation.py b/api/app/api/documentation.py
index e1bc135..404b653 100644
--- a/api/app/api/documentation.py
+++ b/api/app/api/documentation.py
@@ -383,7 +383,10 @@
"scope": "참고 정보의 실제 모집단과 해석 범위.",
"score": "원천 또는 계산한 참고 점수(0~100). 근거 부족 시 null 가능한 필드만 null.",
"score_as_of": "거점 또는 관계 점수의 원천 기준 시각. 미보유 시 null.",
- "search_ratio": "해당 검색 원천의 상대 검색 비율. 선택 원천이 제공하지 않으면 null.",
+ "search_ratio": (
+ "NAVER 검색 트렌드의 상대 검색 비율(0~100). 한국어 지역 여행 키워드에만 있으며 "
+ "그 외 키워드는 null."
+ ),
"season": "여행 계절. 계절 혼잡 원천이 있는 경우에만 순위에 반영.",
"semantics": "SNS 지표의 표본 의미. 실제 국적별 시청자 수가 아님.",
"series": "시간순 관측 시계열. 결측은 0으로 채우지 않음.",
diff --git a/api/app/normalization/social.py b/api/app/normalization/social.py
index 7521b78..36d4ae3 100644
--- a/api/app/normalization/social.py
+++ b/api/app/normalization/social.py
@@ -63,11 +63,15 @@ def normalize_social_run(
if row.get("source_id") != source_id:
raise ValueError("social raw record source_id mismatch")
country_code = (_text(row, "country", required=True) or "").upper()
- country_id = session.scalar(
- select(Country.eden_country_id).where(Country.iso_alpha2 == country_code)
- )
- if country_id is None:
- raise ValueError("social country is outside market_cohort_v1")
+ if country_code == "KR":
+ # Domestic search interest (NAVER) belongs to no inbound market.
+ country_id = None
+ else:
+ country_id = session.scalar(
+ select(Country.eden_country_id).where(Country.iso_alpha2 == country_code)
+ )
+ if country_id is None:
+ raise ValueError("social country is outside market_cohort_v1")
period = _date(
_text(row, "bucket_start", required=True) or "",
("%Y-%m-%d",),
diff --git a/api/app/products/trend_views.py b/api/app/products/trend_views.py
index 48f572f..8186a1d 100644
--- a/api/app/products/trend_views.py
+++ b/api/app/products/trend_views.py
@@ -21,7 +21,7 @@
# Official KTO resource demand indexes are keyed by attraction name and area, so
# they answer Korean keywords and area filters that the social samples cannot.
# They join a response whenever they have observations for the request.
-ALWAYS_INCLUDED_SOURCES = {"SRC_KTO_RESOURCE_DEMAND"}
+ALWAYS_INCLUDED_SOURCES = {"SRC_KTO_RESOURCE_DEMAND", "SRC_NAVER_TREND"}
RISING_MIN_OBSERVATIONS_PER_WINDOW = 2
@@ -213,7 +213,12 @@ def build_trend_view(
requested_source_ids = {
SOURCE_NAMES[name] for name in requested_sources if name in SOURCE_NAMES
}
- allowed_source_ids = requested_source_ids | ALWAYS_INCLUDED_SOURCES
+ explicit_request = scope.get("social_sources") is not None
+ # An explicit social_sources request is answered from those samples only;
+ # otherwise the official and domestic indexes may answer too.
+ allowed_source_ids = (
+ requested_source_ids if explicit_request else requested_source_ids | ALWAYS_INCLUDED_SOURCES
+ )
keyword = normalize_keyword(str(scope["keyword"]))
candidates = [
row
@@ -232,8 +237,8 @@ def build_trend_view(
if not rows:
return None, Availability.UNAVAILABLE, "요청 기간의 social signal이 없습니다."
observed_source_ids = {str(row.get("source_id")) for row in rows}
- selected_source_ids = requested_source_ids | (ALWAYS_INCLUDED_SOURCES & observed_source_ids)
- if scope.get("social_sources") is None:
+ selected_source_ids = requested_source_ids | (allowed_source_ids & observed_source_ids)
+ if not explicit_request:
# Nobody asked for a specific social sample: report the sources that
# answered instead of degrading the response for the default one.
selected_source_ids = {
diff --git a/api/app/readmodels/repository.py b/api/app/readmodels/repository.py
index 4b71c36..bdf94ce 100644
--- a/api/app/readmodels/repository.py
+++ b/api/app/readmodels/repository.py
@@ -144,9 +144,14 @@ def _request_source_ids(endpoint: str, scope: dict[str, object]) -> tuple[str, .
for name in names
if isinstance(name, str) and name in TREND_SOCIAL_SOURCES
)
- if scope.get("area_code"):
- # Area filters are answered by the official KTO resource demand index.
- source_ids.add("SRC_KTO_RESOURCE_DEMAND")
+ if not isinstance(requested, list):
+ keyword = str(scope.get("keyword") or "")
+ if scope.get("area_code"):
+ # Area filters are answered by the official KTO resource demand index.
+ source_ids.add("SRC_KTO_RESOURCE_DEMAND")
+ if any("\uac00" <= char <= "\ud7a3" for char in keyword):
+ # Korean keywords are answered by NAVER search trends too.
+ source_ids.add("SRC_NAVER_TREND")
return tuple(sorted(source_ids))
if endpoint == "region_insights":
selected = scope.get("include")
diff --git a/api/app/sources/essential.py b/api/app/sources/essential.py
index 9d773fe..4104463 100644
--- a/api/app/sources/essential.py
+++ b/api/app/sources/essential.py
@@ -13,7 +13,6 @@
MARKETS = ("JP", "CN", "TW", "US", "PH")
DISABLED_SOURCES = frozenset(
{
- "SRC_NAVER_TREND",
"SRC_INSTAGRAM",
"SRC_FACEBOOK",
"SRC_REDDIT",
@@ -51,6 +50,7 @@
"SRC_KTO_PLACE_HUB": DAY, # Five sigungu per batch, monthly hub rankings.
"SRC_KTO_PLACE_RELATED": DAY, # Five hub places per batch.
"SRC_YOUTUBE": DAY,
+ "SRC_NAVER_TREND": DAY, # Five Korean keywords per run; 18 keywords within four days.
"SRC_KMA_FORECAST": 3 * 3600,
"SRC_SEMAS_SHOPS": 6 * 3600, # Twenty places per batch, ~480 within a week.
"SRC_FESTIVAL": 7 * DAY,
diff --git a/api/app/sources/plans.py b/api/app/sources/plans.py
index c8a6d0c..babe441 100644
--- a/api/app/sources/plans.py
+++ b/api/app/sources/plans.py
@@ -57,6 +57,44 @@
for country, keywords in SOCIAL_KEYWORDS.items()
)
+# NAVER search trends only answer Korean queries; the market keywords above
+# return no data there. Track domestic travel interest per province instead.
+NAVER_KEYWORDS = tuple(
+ f"{name} 여행"
+ for name in (
+ "한국",
+ "서울",
+ "부산",
+ "대구",
+ "인천",
+ "광주",
+ "대전",
+ "울산",
+ "세종",
+ "경기",
+ "강원",
+ "충북",
+ "충남",
+ "전북",
+ "전남",
+ "경북",
+ "경남",
+ "제주",
+ )
+)
+NAVER_KEYWORDS_PER_RUN = 5
+
+
+def naver_targets(day_ordinal: int) -> list[dict[str, str]]:
+ """Five Korean keywords per daily run, rotating through the whole list."""
+ batch_count = -(-len(NAVER_KEYWORDS) // NAVER_KEYWORDS_PER_RUN)
+ start = (day_ordinal % batch_count) * NAVER_KEYWORDS_PER_RUN
+ return [
+ {"country": "KR", "keyword": keyword}
+ for keyword in NAVER_KEYWORDS[start : start + NAVER_KEYWORDS_PER_RUN]
+ ]
+
+
SOCIAL_SCOPE_SOURCES = {
"SRC_NAVER_TREND",
"SRC_YOUTUBE",
@@ -600,8 +638,16 @@ def semas_place_operations(
def social_refresh_scope(source_id: str) -> dict[str, Any]:
if source_id not in SOCIAL_SCOPE_SOURCES:
return {}
+ if source_id == "SRC_NAVER_TREND":
+ from datetime import date
+
+ return {
+ "targets": naver_targets(date.today().toordinal()),
+ "lookback_days": 90,
+ "scope_semantics": "Korean domestic search interest; not a market signal",
+ }
return {
"targets": deepcopy(list(SOCIAL_MARKET_TARGETS)),
- "lookback_days": 90 if source_id == "SRC_NAVER_TREND" else 7,
+ "lookback_days": 7,
"scope_semantics": "query-language market proxy; not user geolocation",
}
diff --git a/api/tests/integration/test_public_pipeline.py b/api/tests/integration/test_public_pipeline.py
index 17cb32e..dc121eb 100644
--- a/api/tests/integration/test_public_pipeline.py
+++ b/api/tests/integration/test_public_pipeline.py
@@ -1965,7 +1965,7 @@ def test_scheduler_start_syncs_registry_enablement_with_the_code(pipeline: Pipel
changed = sync_source_enablement(session)
# the fixture seeds every endpoint source as enabled; code-disabled ones flip off
assert changed["SRC_TOUR_JA"] is True and changed["SRC_KTO_PLACE_HUB"] is True
- assert changed["SRC_NAVER_TREND"] is False
+ assert changed["SRC_INSTAGRAM"] is False
with pipeline.session_factory() as session:
assert session.get(SourceRegistry, "SRC_TOUR_JA").enabled is True
diff --git a/api/tests/unit/test_public_data_plans.py b/api/tests/unit/test_public_data_plans.py
index de44742..904a1ea 100644
--- a/api/tests/unit/test_public_data_plans.py
+++ b/api/tests/unit/test_public_data_plans.py
@@ -294,3 +294,21 @@ def test_airport_country_scope_collects_flights_and_passengers_for_two_months()
}
assert passenger["watermark"] == {"param": "to_month", "format": "%Y%m"}
assert len(operations) <= scope["max_operations_per_run"]
+
+
+def test_naver_scope_rotates_korean_province_keywords_five_per_run() -> None:
+ from app.sources.plans import NAVER_KEYWORDS, NAVER_KEYWORDS_PER_RUN, naver_targets
+
+ assert NAVER_KEYWORDS_PER_RUN == 5
+ assert all(keyword.endswith(" 여행") for keyword in NAVER_KEYWORDS)
+ seen: list[str] = []
+ for day in range(4):
+ batch = naver_targets(day)
+ assert 1 <= len(batch) <= NAVER_KEYWORDS_PER_RUN
+ assert all(target["country"] == "KR" for target in batch)
+ seen.extend(target["keyword"] for target in batch)
+ assert seen == list(NAVER_KEYWORDS)
+ assert naver_targets(4) == naver_targets(0)
+ scope = social_refresh_scope("SRC_NAVER_TREND")
+ assert scope["lookback_days"] == 90
+ assert len(scope["targets"]) <= NAVER_KEYWORDS_PER_RUN
diff --git a/api/tests/unit/test_read_source_selection.py b/api/tests/unit/test_read_source_selection.py
index d038e76..0f3b385 100644
--- a/api/tests/unit/test_read_source_selection.py
+++ b/api/tests/unit/test_read_source_selection.py
@@ -74,6 +74,15 @@ def test_excluded_source_watermark_does_not_age_the_response() -> None:
def test_trend_area_requests_list_the_official_resource_demand_source() -> None:
assert _request_source_ids("trends", {"social_sources": ["youtube"]}) == ("SRC_YOUTUBE",)
+ assert _request_source_ids("trends", {"area_code": "eden_area_seoul"}) == (
+ "SRC_KTO_RESOURCE_DEMAND",
+ "SRC_YOUTUBE",
+ )
+ assert _request_source_ids("trends", {"keyword": "제주 여행"}) == (
+ "SRC_NAVER_TREND",
+ "SRC_YOUTUBE",
+ )
+ # explicit social requests are answered from those samples only
assert _request_source_ids(
- "trends", {"social_sources": ["youtube"], "area_code": "eden_area_seoul"}
- ) == ("SRC_KTO_RESOURCE_DEMAND", "SRC_YOUTUBE")
+ "trends", {"social_sources": ["youtube"], "keyword": "제주 여행", "area_code": "x"}
+ ) == ("SRC_YOUTUBE",)
diff --git a/api/tests/unit/test_reference_seed.py b/api/tests/unit/test_reference_seed.py
index b8d146b..b83e96f 100644
--- a/api/tests/unit/test_reference_seed.py
+++ b/api/tests/unit/test_reference_seed.py
@@ -26,5 +26,6 @@ def test_place_sources_are_enabled_again_and_social_sources_stay_off() -> None:
):
assert source_id not in DISABLED_SOURCES
assert source_enabled_by_code(source_id) is True
- for source_id in ("SRC_NAVER_TREND", "SRC_INSTAGRAM", "SRC_TOURISM_ADMISSION"):
+ assert source_enabled_by_code("SRC_NAVER_TREND") is True
+ for source_id in ("SRC_INSTAGRAM", "SRC_REDDIT", "SRC_TOURISM_ADMISSION"):
assert source_enabled_by_code(source_id) is False
diff --git a/api/tests/unit/test_trend_views.py b/api/tests/unit/test_trend_views.py
index 8e44dcd..83328aa 100644
--- a/api/tests/unit/test_trend_views.py
+++ b/api/tests/unit/test_trend_views.py
@@ -239,12 +239,49 @@ def test_official_resource_demand_answers_korean_keywords_and_area_filters() ->
assert [point["interest_index"] for point in data["series"]] == [60.0, 80.0]
assert set(data["source_availability"]) == {"SRC_KTO_RESOURCE_DEMAND"}
- # an explicit social request keeps reporting that sample as missing
- explicit, explicit_availability, _ = build_trend_view(
+ # an explicit social request is answered from that sample only
+ explicit, explicit_availability, explicit_reason = build_trend_view(
product, {**scope, "social_sources": ["youtube"]}
)
- assert explicit_availability == Availability.PARTIAL
- assert explicit["source_availability"]["SRC_YOUTUBE"]["availability"] == "unavailable"
+ assert explicit is None and explicit_availability == Availability.UNAVAILABLE
+ assert explicit_reason == "요청 범위와 일치하는 social signal이 없습니다."
+
+
+def test_naver_search_ratio_answers_korean_keywords_on_default_requests() -> None:
+ product = {
+ "observations": [
+ {
+ "source_id": "SRC_NAVER_TREND",
+ "keyword": "제주 여행",
+ "country": None,
+ "bucket_start": "2026-09-01T00:00:00+00:00",
+ "search_ratio": 10.6,
+ },
+ {
+ "source_id": "SRC_NAVER_TREND",
+ "keyword": "제주 여행",
+ "country": None,
+ "bucket_start": "2026-09-02T00:00:00+00:00",
+ "search_ratio": 9.6,
+ },
+ ]
+ }
+ scope = {
+ "keyword": "제주 여행",
+ "country": "all",
+ "area_code": None,
+ "social_sources": None,
+ "period": "7d",
+ "time_unit": "day",
+ }
+
+ data, availability, reason = build_trend_view(product, scope)
+
+ assert data is not None and availability == Availability.AVAILABLE and reason is None
+ assert data["sources"] == ["SRC_NAVER_TREND"]
+ assert [point["search_ratio"] for point in data["series"]] == [10.6, 9.6]
+ assert data["source_metrics"][0]["search_ratio"] == 10.1
+ assert data["interest_index"] == 10.1
other_area = build_trend_view(product, {**scope, "area_code": "eden_area_busan"})
assert other_area[0] is None and other_area[1] == Availability.UNAVAILABLE
From 78fe23cb2dcdbb4b907088e09b26818de7ab29c4 Mon Sep 17 00:00:00 2001
From: rrrrok <132427414+rrrrok@users.noreply.github.com>
Date: Thu, 17 Sep 2026 10:51:49 +0900
Subject: [PATCH 7/8] docs: record the public API completeness status
---
README.md | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/README.md b/README.md
index e403c27..3b031bf 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,23 @@
| API 문서 | https://api.edenapi.org/docs | 운영 VPS |
| OpenAPI | https://api.edenapi.org/openapi.json | 운영 VPS |
+## API 완성도 현황 (2026-09-17)
+
+공개 엔드포인트 8개를 "설계한 데이터를 실제로 수집·조합·가공해서 내는가" 기준으로 점검한 결과입니다. 응답에 하드코딩된 값은 없습니다. 아래 "미제공"은 어떤 공개 원천도 그 값을 주지 않아 항상 `null`인 필드이며, 가용성 판정에서 제외되어 있습니다. 목록과 사유는 [api/README.md의 Fields That Are Not Provided](api/README.md#fields-that-are-not-provided), 보류한 결정은 [api/KNOWN_GAPS.md](api/KNOWN_GAPS.md)에 있습니다.
+
+| 엔드포인트 | 상태 | 제공하는 것 | 미제공 / 제한 |
+| --- | --- | --- | --- |
+| `GET /v1/trends` | 완성 (수집 범위 안에서) | YouTube 검색 표본(5개 시장 × 3 키워드), KTO 관광자원 수요 지수(한국어 관광지명·지역 필터), NAVER 검색 트렌드(지역 여행 키워드 18개, 2026-09-17 활성화) | 사전 수집된 키워드만 응답. `destination_searches`, `sns_mentions`(Instagram·Facebook·Reddit은 외부 승인 필요) |
+| `GET /v1/regions/{area_code}/insights` | 완성 (시도 단위) | 일별 방문자(내·외국인, 원천 발표 지연 약 30일), 관광 체류·소비 강도, 국적 다양성, `compare=previous_period`로 증감률 | 시군구 코드는 시도 자료로 대체. `avg_stay_nights`, `age_index`는 원천 없음 |
+| `GET /v1/visitors/timeseries` | 완성 (시도 단위, 일·주·월) | 방문자 시계열과 요약 | `concentration_rate`는 원천 없음. `attraction_name`(관광지별)은 원천이 없어 항상 unavailable |
+| `GET /v1/forecasts/visitors` | 완성 | 시군구: 관광지별 KTO 공식 집중률의 평균(`sample_count` 표시). 시도: 과거 동일 요일 참고 지수. 기상청 단기예보, 축제, 공휴일 | `expected_visitors`, `confidence`는 원천 없음 |
+| `GET /v1/markets/inbound` | 완성 | 월별 방한객(발표 지연 약 2개월), 국가별 도착 운항편·여객 수(2026-09-17 추가), 7일 운항 일정, 환율, 관광수지, YouTube 관심도 | `social_interest.youtube.score`는 설계상 국가 신호로 쓰지 않음. Instagram·Facebook·Reddit 없음 |
+| `GET /v1/markets/{country}/alerts` | 완성 (한국어) | K-ETA 공지, KTO 시장 동향, 5개 공관 안전 공지(원문·링크) | 번역·요약은 하지 않기로 결정. `language=en`은 한국어 원문을 `fallback=true`로 반환 |
+| `GET /v1/places/{content_id}` | 완성, 수집 채우는 중 | 한국어 제목·분류·주소·좌표, 소개문, 영어·일본어·중국어(간체) 제목·주소, 중심 관광지 순위, 연관 관광지, 주변 상권 | 2026-09-17 배포 후 소개문(약 8일), 번역(약 6일), 허브·연관 매핑(이름·좌표가 일치하는 곳만), 주변 상권(약 1주)이 순환 수집으로 채워짐. 중국어 번체(TW)는 원천 없음 |
+| `POST /v1/recommendations/destinations` | 완성 | 테마·지역 수요·계절 혼잡도 점수, 근거, 연관 관광지 | `estimated_budget_krw`, `budget_krw`, `days`, `party_size`, 접근성·이동시간 조건은 원천 없음(입력은 `unapplied_inputs`로 알림) |
+
+수집 원천 36개 중 26개가 켜져 있습니다. 꺼진 10개(Instagram·Facebook·Reddit·X·TikTok·Weibo·Douyin·Xiaohongshu·LINE·관광지 입장객)는 어댑터가 없거나 외부 승인이 필요합니다.
+
## 디렉터리
```text
From 75854868225047c18ca96d4755abd7914a7fdb3c Mon Sep 17 00:00:00 2001
From: rrrrok <132427414+rrrrok@users.noreply.github.com>
Date: Thu, 17 Sep 2026 11:17:39 +0900
Subject: [PATCH 8/8] docs: release 0.3.0 notes and data coverage for API
clients
Bump the API version to 0.3.0, turn the pending changelog block into the
release entry, add a Data Coverage and Refresh section to the API README so
client developers can see what each endpoint answers today, and correct the
statements about NAVER storage, usage recording and migrations.
---
README.md | 4 ++--
api/CHANGELOG.md | 4 +++-
api/KNOWN_GAPS.md | 3 ++-
api/README.md | 29 +++++++++++++++++++++++------
api/app/__init__.py | 2 +-
api/pyproject.toml | 2 +-
api/uv.lock | 2 +-
7 files changed, 33 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index 3b031bf..37b9f46 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
| API 문서 | https://api.edenapi.org/docs | 운영 VPS |
| OpenAPI | https://api.edenapi.org/openapi.json | 운영 VPS |
-## API 완성도 현황 (2026-09-17)
+## API 완성도 현황 (2026-09-17, API v0.3.0)
공개 엔드포인트 8개를 "설계한 데이터를 실제로 수집·조합·가공해서 내는가" 기준으로 점검한 결과입니다. 응답에 하드코딩된 값은 없습니다. 아래 "미제공"은 어떤 공개 원천도 그 값을 주지 않아 항상 `null`인 필드이며, 가용성 판정에서 제외되어 있습니다. 목록과 사유는 [api/README.md의 Fields That Are Not Provided](api/README.md#fields-that-are-not-provided), 보류한 결정은 [api/KNOWN_GAPS.md](api/KNOWN_GAPS.md)에 있습니다.
@@ -31,7 +31,7 @@
.agents/ 작업 기록과 로컬 자료, Git 제외
.ops/ run.sh와 deploy.sh, Git 제외
dashboard/ React + TypeScript + Vite
-api/ FastAPI 코드, 마이그레이션, 테스트, Python 의존성
+api/ FastAPI 코드, 마이그레이션, 테스트, Python 의존성 (README, CHANGELOG, KNOWN_GAPS 포함)
.env 공통 환경 설정 원본, Git 제외
.gitignore
README.md
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index a645412..708bd7d 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -1,6 +1,8 @@
# 변경 기록
-## 미배포
+## 0.3.0 (2026-09-17)
+
+운영 배포 release `20260917T014213Z`. 이 판의 목표는 공개 API 8개가 설계한 데이터를 실제로 수집·조합해 내도록 만드는 것이다. 엔드포인트별 현황은 저장소 루트 README의 "API 완성도 현황"에 있다.
- NAVER 검색 트렌드(SRC_NAVER_TREND)를 켰다. NAVER는 한국어 검색만 답하므로 시장별 외국어 키워드 대신 지역 여행 키워드 18개("한국 여행", "서울 여행", …)를 하루 5개씩 순환 수집하고, 국내 검색 관심은 특정 방한시장에 속하지 않으므로 country 없이 저장한다. 트렌드 응답은 social_sources를 지정하지 않은 요청에서 한국어 키워드의 `search_ratio`를 NAVER로 채운다. 운영 반영에는 `NAVER_STORAGE_POLICY_APPROVED=true` 설정이 필요하다.
- 방한시장의 `passengers`를 채운다. 인천공항 국가별 항공통계 서비스의 여객 오퍼레이션(getTotalNumberOfPassenger)을 운항편 오퍼레이션과 함께 수집하고, 같은 국가·월의 관측에 운항편 수와 여객 수를 합쳐 저장한다. 여객 통계를 수집하기 전 달은 null로 남는다.
diff --git a/api/KNOWN_GAPS.md b/api/KNOWN_GAPS.md
index b8d4d9d..e6915c0 100644
--- a/api/KNOWN_GAPS.md
+++ b/api/KNOWN_GAPS.md
@@ -42,4 +42,5 @@
## 운영 참고
-- 6055a3e에서 soak 필수 서비스와 `eden-scheduler.service` 의존성에서 `mariadb.service`를 뺐다. 운영 VPS의 MariaDB는 같은 호스트에 있으므로 부팅 순서는 `Restart=always`가 흡수하지만, soak 증거는 더 이상 MariaDB 재시작을 잡지 않는다.
+- 6055a3e에서 soak 필수 서비스와 `eden-scheduler.service` 의존성에서 `mariadb.service`를 뺐다. 2026-09-17에 옮긴 새 VPS도 MariaDB가 같은 호스트에 있다. 부팅 순서는 `Restart=always`가 흡수하지만 soak 증거는 더 이상 MariaDB 재시작을 잡지 않는다.
+- 2026-09-17 배포(release `20260917T014213Z`) 직후 확인: 다시 켠 원천 5개 실행 성공, 허브·연관 매핑 첫 배치 870곳. NAVER·소개문·여객 수·대사관 공지는 각 원천의 다음 실행(같은 날 오후·저녁) 이후 채워진다.
diff --git a/api/README.md b/api/README.md
index f3fbaeb..a8850c2 100644
--- a/api/README.md
+++ b/api/README.md
@@ -13,7 +13,7 @@
-
+
@@ -37,9 +37,9 @@ EDEN connects regional codes and place identifiers across tourism data sources a | Feature | Available information | | --- | --- | -| Travel trends | Video counts and views for collected YouTube travel keywords | +| Travel trends | YouTube search samples for market keywords, NAVER search ratios for Korean travel keywords, and the official KTO resource demand index by attraction and area | | Regional insights | Regional visitor indicators and comparisons with earlier periods | -| Place details | Names, locations, available translations, nearby shops, and related places | +| Place details | Names, overview, locations, available translations, hub ranking, nearby shops, and related places | | Visitor outlook | Reference demand indices based on past observations, with available weather context | | Visitor history | Daily, weekly, or monthly visitor indicators | | Inbound markets | Visitor, flight, and exchange-rate indicators for Japan, China, Taiwan, the US, and the Philippines | @@ -139,7 +139,24 @@ Some declared fields have no verified source today. They are always `null` (or ` - **Recommendation inputs** that cannot be applied appear in `unapplied_inputs`, with reasons. Season only affects crowd calculations when supporting observations exist. - **Notice translations and summaries** are returned when available. Original notices remain accessible when a translation is missing. -Availability depends on source permissions and collection coverage. Persistent storage of NAVER results is disabled. The API does not fabricate metrics for unsupported social platforms. +Availability depends on source permissions and collection coverage. NAVER search ratios are stored for the Korean travel keywords listed under Data Coverage; other social platforms are not collected, and the API does not fabricate metrics for them. + +## Data Coverage and Refresh + +What a client can rely on today (deployment 2026-09-17). Sources publish with a lag, and some place fields are still being filled by rotating collection; check `meta.freshness` and `meta.sources` rather than assuming a date. + +| Endpoint | Works today | Cadence and lag | Not answered | +| --- | --- | --- | --- | +| `GET /v1/trends` | Keywords that are collected: market keywords (`Korea travel`, `Seoul travel`, `Jeju travel` and their JP/CN/TW translations, per `country`), Korean province travel keywords (`서울 여행` … `제주 여행`, `한국 여행`), and Korean attraction names with `area_code` | YouTube and NAVER daily; KTO resource demand monthly | Free-text keywords outside that list return `unavailable`; `destination_searches` and `sns_mentions` are always `null` | +| `GET /v1/regions/{area_code}/insights` | Province (sido) codes; `period` 7d/30d/90d; add `compare=previous_period` to get `visitors.change_rate` and `comparison` | Daily visitors publish about 30 days late; demand and diversity are monthly, about two months late | Sigungu codes answer with the parent province (`requested_area_code` set, `partial`); `avg_stay_nights`, `age_index` always `null` | +| `GET /v1/visitors/timeseries` | Province codes; day/week/month; 7d/30d/90d/12m | Same daily visitor source | `attraction_name` has no source (`unavailable`); `concentration_rate` always `null` | +| `GET /v1/forecasts/visitors` | Sigungu codes get the official KTO concentration forecast averaged over the area's attractions (`method: official`, `sample_count`); province codes get the historical weekday reference index; weather, festivals and holidays per day | Forecast horizon 30 days; weather refreshed every 3 hours for one grid per province; festivals weekly; holidays monthly | `expected_visitors`, `confidence`, `adjustment_factors` always `null`; `nx`/`ny` other than the province grid are `unavailable` | +| `GET /v1/markets/inbound` | `JP`, `CN`, `TW`, `US`, `PH`; `period` 3m/6m/12m/24m; `include` blocks visitors, flights, flight_schedule, fx, social_interest; `forecast_days` up to 7 | Visitors monthly (about two months late); flights and passengers monthly; 7-day schedule daily; FX daily | `social_interest.youtube.score` is always `null` by design; only YouTube is collected among social sources | +| `GET /v1/markets/{country}/alerts` | Korean originals with source links; `types`, `since`, `limit` | Sources refresh every 12 hours | `language=en` returns the Korean original with `fallback: true` (no translation); `source_scope=local` has no collector; `summary` is `null` | +| `GET /v1/places/{content_id}` | Korean title, category, address, coordinates; `overview`, `en`/`ja`/`zh-CN` titles, `hub`, `related_places`, `nearby_shops` as collection fills them | Overview 60 places per day, translations province by province every six days, hub and related places daily, nearby shops 20 places every six hours | `zh-TW` has no source; places whose KTO name does not match a TourAPI entry keep empty `hub`/`related_places` | +| `POST /v1/recommendations/destinations` | `target_country`, `themes`, `area_code`, `limit`; `constraints.avoid_crowds: true` applies `travel_window.season` to crowd ranking | Feature snapshot refreshed with the product cycle | `budget_krw`, `travel_window.days`, `party_size` are echoed in `unapplied_inputs`; `accessibility_required`, `max_travel_minutes`, `constraints.extra` make the request `unavailable`; `estimated_budget_krw` always `null` | + +Two request options the current dashboard does not send but the API supports: `compare=previous_period` on regional insights and `constraints.avoid_crowds` on recommendations. ## How It Works @@ -149,7 +166,7 @@ External sources -> Collection and normalization -> Published MariaDB data -> Fa Public requests only read published database snapshots. They do not trigger external collection or LLM calls. The recommendation POST is also a read operation. -Collection uses one source worker, request budgets and resource limits. Monthly regional demand and diversity sources are checked weekly. Monthly flight refreshes cover the two most recent months while existing history remains stored. Cleanup keeps the current snapshot and two recent retired versions, preserving their referenced facts and source evidence. Superseded catalog errors and sources outside the maintained scope are quarantined without deleting their raw evidence. API usage statistics are not written to the database. +Collection uses one source worker, request budgets and resource limits. Monthly regional demand and diversity sources are checked weekly. Monthly flight refreshes cover the two most recent months while existing history remains stored. Cleanup keeps the current snapshot and two recent retired versions, preserving their referenced facts and source evidence. Superseded catalog errors and sources outside the maintained scope are quarantined without deleting their raw evidence. The API records pilot usage rows (daily counts per pilot key) and nothing else about requests. `SCHEDULER_ENABLED=false` pauses collection, refresh and automatic cleanup. In production the scheduler runs as its own process (`python -m app.scheduler`, systemd unit `eden-scheduler`) with the same jobs, intervals and locks, while the API process serves requests with the scheduler disabled; the API still records pilot usage with the ingestion account. The unit files and one-time installation steps are in [deploy/README.md](deploy/README.md). `ALERT_ENRICHMENT_BATCH_SIZE=0` independently disables paid translation jobs; original official notices remain available. The deployment currently uses this zero translation budget. Fields that no current source can fill and deferred decisions are listed in [KNOWN_GAPS.md](KNOWN_GAPS.md). @@ -224,6 +241,6 @@ Deployment generates production configuration from the root `.env`. It verifies Schema changes require a separate request. Database backup and restore commands are disabled. Public API documentation is served at `/docs`; internal readiness is available on loopback at `/internal/readiness`. -The alert enrichment worker requires migration `20260911_0009`, which persists retry counts and deadlines. Each revision gets at most three attempts with exponential backoff, allowing later notices to proceed while failed notices wait. Normal deployment applies this additive migration without running the separately gated Phase 1 contract migration. Databases that have already completed that contract advance to the merge revision `20260911_0010`. +The production database is at migration `20260911_0009` (alert enrichment retry state). Deployment does not run migrations; apply new ones with `../.ops/run.sh migrate` before deploying code that needs them. The Phase 1 storage-contract migration `20260829_0007` is gated behind the soak evidence described in the root README and has not been applied; databases that complete it advance to the merge revision `20260911_0010`. diff --git a/api/app/__init__.py b/api/app/__init__.py index a572d33..c355919 100644 --- a/api/app/__init__.py +++ b/api/app/__init__.py @@ -1,3 +1,3 @@ """EDEN API and tourism data platform.""" -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/api/pyproject.toml b/api/pyproject.toml index 0e8bf32..aed8426 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "eden-api" -version = "0.2.0" +version = "0.3.0" description = "EDEN tourism intelligence public API and data pipeline" readme = "README.md" requires-python = ">=3.12" diff --git a/api/uv.lock b/api/uv.lock index 3831a52..5aa160f 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -260,7 +260,7 @@ wheels = [ [[package]] name = "eden-api" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "alembic" },