mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Stop guessing districts and require exact station retries for ambiguous fine-dust lookups
The fine-dust proxy now resolves natural-language region hints through city-level station lists and only returns a report when a single station can be justified. When the hint is ambiguous, the proxy returns a small candidate list so callers can retry with an exact station name instead of silently guessing. The skill guidance was updated to match that runtime contract: region hint first, then retry with stationName when candidate_stations are returned. Coordinate-centric guidance was removed from the primary skill surface so the default path stays lightweight and consistent with the live proxy behavior. Constraint: The current AirKorea key can access city-level and station-level measurement APIs but station-info lookups may still return 403 Constraint: Free-API proxy responses must stay safe to expose publicly, so ambiguous locations should not be auto-guessed Rejected: Auto-pick the first city-level station for unmatched district hints | hides ambiguity and returns misleading air-quality data Rejected: Keep coordinate-first language in the primary skill | no coordinate source exists in the default user flow Confidence: high Scope-risk: moderate Reversibility: clean Directive: Preserve the ambiguous_location contract; if you improve matching later, prefer evidence-backed narrowing over silent fallback guesses Tested: node --test scripts/skill-docs.test.js; npm run test --workspace k-skill-proxy; python3 -m unittest discover -s scripts -p test_fine_dust.py; live curl for ambiguous regionHint=광주 광산구 and exact stationName=우산동(광주) Not-tested: Broader region alias quality outside the manually checked examples
This commit is contained in:
parent
a3ef6ffac6
commit
8b36634e28
8 changed files with 241 additions and 246 deletions
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 사용자 위치 위도/경도로 가까운 측정소 찾기
|
||||
- 위치 권한이 없을 때 지역명/행정구역 fallback으로 측정소 찾기
|
||||
- 지역명/행정구역 힌트로 측정소 후보 찾기
|
||||
- 단일 측정소 확정이 어려우면 후보 측정소 목록 반환
|
||||
- 정확한 측정소명으로 재조회
|
||||
- PM10, PM2.5, 등급, 조회 시각 요약
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
|
@ -25,16 +26,16 @@
|
|||
|
||||
## 입력값
|
||||
|
||||
- 우선: 현재 위치 위도/경도(WGS84)
|
||||
- fallback: 지역명/행정구역 힌트 또는 측정소명
|
||||
- 기본: 지역명/행정구역 힌트(`regionHint`)
|
||||
- 재조회: 정확한 측정소명(`stationName`)
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. `KSKILL_PROXY_BASE_URL` 가 있으면 먼저 `k-skill-proxy` 의 `/v1/fine-dust/report` endpoint 를 호출합니다.
|
||||
2. 프록시가 없을 때만 입력 위도/경도(WGS84)를 에어코리아 nearby 조회가 요구하는 **TM 좌표(중부원점)** 로 먼저 변환합니다.
|
||||
3. 변환된 `tmX`/`tmY` 로 측정소정보 API `getNearbyMsrstnList` 를 호출해 가까운 측정소를 찾습니다.
|
||||
4. 좌표를 못 받거나 nearby 결과가 비면 측정소정보 API `getMsrstnList` 로 지역명/행정구역 fallback을 사용합니다.
|
||||
5. 선택된 측정소 이름으로 대기오염정보 API `getMsrstnAcctoRltmMesureDnsty` 를 호출합니다.
|
||||
2. `regionHint` 가 들어오면 프록시는 먼저 시도명을 추출하고, `getCtprvnRltmMesureDnsty` 로 해당 시도 측정소 목록을 확보합니다.
|
||||
3. region token 이 시도 내 실제 측정소명과 **유일하게** 대응하면 그 측정소로 `getMsrstnAcctoRltmMesureDnsty` 를 호출합니다.
|
||||
4. 단일 측정소 확정이 어려우면 `ambiguous_location` 과 `candidate_stations` 를 반환합니다.
|
||||
5. 클라이언트/사용자는 후보 중 정확한 측정소명으로 다시 `/v1/fine-dust/report?stationName=...` 를 호출합니다.
|
||||
6. PM10, PM2.5, 등급, 조회 시점/조회 시각을 함께 요약합니다.
|
||||
|
||||
프록시 예시:
|
||||
|
|
@ -45,6 +46,20 @@ sops exec-env "$HOME/.config/k-skill/secrets.env" \
|
|||
'python3 scripts/fine_dust.py report --region-hint "서울 강남구" --json'
|
||||
```
|
||||
|
||||
후보 반환 예시:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
|
||||
--data-urlencode 'regionHint=광주 광산구'
|
||||
```
|
||||
|
||||
정확한 측정소명 재조회:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
|
||||
--data-urlencode 'stationName=우산동(광주)'
|
||||
```
|
||||
|
||||
원본 AirKorea endpoint 형태를 거의 그대로 쓰고 싶으면 passthrough endpoint 도 사용할 수 있습니다. 별도 client API 는 불필요하고, 프록시가 `serviceKey` 만 서버에서 주입합니다.
|
||||
|
||||
```bash
|
||||
|
|
@ -59,23 +74,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/B552584/ArpltnInforInqireSv
|
|||
|
||||
## 예시
|
||||
|
||||
좌표 기반 1차 조회:
|
||||
|
||||
```bash
|
||||
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
|
||||
sops exec-env "$HOME/.config/k-skill/secrets.env" \
|
||||
'curl -sG "http://apis.data.go.kr/B552584/MsrstnInfoInqireSvc/getNearbyMsrstnList" \
|
||||
--data-urlencode "serviceKey=${AIR_KOREA_OPEN_API_KEY}" \
|
||||
--data-urlencode "returnType=json" \
|
||||
--data-urlencode "numOfRows=10" \
|
||||
--data-urlencode "pageNo=1" \
|
||||
--data-urlencode "tmX=198245.053183" \
|
||||
--data-urlencode "tmY=451586.837879"'
|
||||
```
|
||||
|
||||
`getNearbyMsrstnList` 는 WGS84 위도/경도를 직접 받지 않습니다. helper script 는 `37.5665, 126.9780` 같은 입력을 위 값처럼 TM 좌표로 변환한 뒤 nearby API 를 호출합니다. 같은 기술문서에는 읍면동 기준 `getTMStdrCrdnt` 도 정의돼 있지만, 이 스킬은 사용자 위치 입력이 WGS84 라는 점 때문에 로컬 변환 후 `tmX`/`tmY` 를 사용합니다.
|
||||
|
||||
지역 fallback:
|
||||
지역 기반 direct fallback:
|
||||
|
||||
```bash
|
||||
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
|
||||
|
|
@ -109,22 +108,20 @@ helper script 반복 검증:
|
|||
python3 scripts/fine_dust.py report \
|
||||
--station-file scripts/fixtures/fine-dust-stations.json \
|
||||
--measurement-file scripts/fixtures/fine-dust-measurements.json \
|
||||
--lat 37.5665 \
|
||||
--lon 126.9780
|
||||
--region-hint "서울 강남구"
|
||||
```
|
||||
|
||||
## fallback / 대체 흐름
|
||||
|
||||
- 위치 권한이 없으면 지역명/행정구역을 먼저 받습니다
|
||||
- 지역명도 없으면 측정소명을 직접 받습니다
|
||||
- 측정소 목록 API가 빈 응답이어도 `--station-name` 이 있으면 같은 이름으로 실시간 측정 API를 직접 재시도합니다
|
||||
- `getNearbyMsrstnList` 결과가 비면 `getMsrstnList` 로 재시도합니다
|
||||
- nearby 응답은 입력 TM 좌표와의 거리 기준으로 정렬되므로 첫 측정소를 우선 사용합니다
|
||||
- 지역명/행정구역을 먼저 받습니다
|
||||
- 단일 측정소를 확정하지 못하면 후보 측정소 목록을 돌려줍니다
|
||||
- 사용자는 후보 중 하나를 선택해 `stationName` 으로 다시 조회합니다
|
||||
- 측정소 목록 API가 403 이어도 `getCtprvnRltmMesureDnsty` 와 측정소별 실측 API 조합으로 우회합니다
|
||||
|
||||
## 주의할 점
|
||||
|
||||
- 실시간 수치라 조회 시각을 같이 적어야 합니다
|
||||
- PM10/PM2.5 값이 `-` 이거나 비정상이면 등급도 함께 재확인합니다
|
||||
- API 가 `khaiGrade` 를 비워 보내면 통합대기등급은 `정보없음` 으로 표시합니다
|
||||
- 위치 기반이라고 해도 실제 기준은 “가까운 측정소” 이므로 주소 중심점과 오차가 있을 수 있습니다
|
||||
- regionHint 는 자연어이므로 단일 측정소가 안 잡히는 경우가 자주 있습니다
|
||||
- hosted 모드에서는 upstream AirKorea key 를 클라이언트에 배포하지 않고 proxy 에만 둡니다
|
||||
|
|
|
|||
|
|
@ -22,9 +22,8 @@ metadata:
|
|||
|
||||
## Inputs
|
||||
|
||||
- 우선 입력: 위도/경도(WGS84)
|
||||
- 일반 fallback: 지역명/행정구역 힌트
|
||||
- 마지막 fallback: 측정소명
|
||||
- 일반 입력: 지역명/행정구역 힌트
|
||||
- 재조회 입력: 정확한 측정소명
|
||||
|
||||
## Region naming convention
|
||||
|
||||
|
|
@ -50,6 +49,24 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
|
|||
python3 scripts/fine_dust.py report --region-hint '서울 강남구' --json
|
||||
```
|
||||
|
||||
## Ambiguous locations
|
||||
|
||||
입력한 지역명이 단일 측정소로 바로 확정되지 않으면 proxy 는 `ambiguous_location` 과 함께 후보 측정소 목록을 돌려준다.
|
||||
|
||||
예:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
|
||||
--data-urlencode 'regionHint=광주 광산구'
|
||||
```
|
||||
|
||||
이때 응답의 `candidate_stations` 중 하나를 골라 다시 `stationName` 으로 조회한다.
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
|
||||
--data-urlencode 'stationName=우산동(광주)'
|
||||
```
|
||||
|
||||
## Detailed API paths
|
||||
|
||||
원본 AirKorea와 비슷한 passthrough 경로(`/B552584/...`)나 direct fallback 상세는 아래 문서만 참고한다.
|
||||
|
|
@ -66,16 +83,17 @@ python3 scripts/fine_dust.py report --region-hint '서울 강남구' --json
|
|||
- PM10 값과 등급
|
||||
- PM2.5 값과 등급
|
||||
- 통합대기등급
|
||||
- 조회 방식(`coordinates` 또는 `fallback`)
|
||||
- 조회 방식(`fallback`)
|
||||
|
||||
## Failure modes
|
||||
|
||||
- regionHint 없이도 정확한 지역을 추정해야 하는 경우
|
||||
- regionHint 가 너무 넓거나 단일 측정소를 확정할 수 없는 경우
|
||||
- 프록시 서버가 내려가 있거나 upstream key가 비어 있는 경우
|
||||
- 측정소명과 지역명이 달라 직접 fallback 이 필요한 경우
|
||||
|
||||
## Notes
|
||||
|
||||
- 기본 경로는 항상 `k-skill-proxy.nomadamas.org` 의 report endpoint 다.
|
||||
- 지역명 조회는 먼저 후보를 얻고, 필요하면 정확한 측정소명으로 재조회한다.
|
||||
- passthrough / direct AirKorea 구현 세부는 스킬 본문에 길게 반복하지 않는다.
|
||||
- free API 프록시는 공개 endpoint 를 기본으로 둔다.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,5 @@
|
|||
const STATION_SERVICE_URL = "http://apis.data.go.kr/B552584/MsrstnInfoInqireSvc";
|
||||
const MEASUREMENT_SERVICE_URL = "http://apis.data.go.kr/B552584/ArpltnInforInqireSvc";
|
||||
const WGS84_A = 6378137.0;
|
||||
const WGS84_F = 1 / 298.257223563;
|
||||
const BESSEL_A = 6377397.155;
|
||||
const BESSEL_F = 1 / 299.1528128;
|
||||
const AIR_KOREA_TM_LAT0 = degreesToRadians(38.0);
|
||||
const AIR_KOREA_TM_LON0 = degreesToRadians(127.0);
|
||||
const AIR_KOREA_TM_FALSE_EASTING = 200000.0;
|
||||
const AIR_KOREA_TM_FALSE_NORTHING = 500000.0;
|
||||
const AIR_KOREA_TM_SCALE = 1.0;
|
||||
const AIR_KOREA_WGS84_TO_BESSEL = [146.43, -507.89, -681.46];
|
||||
const GRADE_LABELS = {
|
||||
"1": "좋음",
|
||||
"2": "보통",
|
||||
|
|
@ -17,10 +7,6 @@ const GRADE_LABELS = {
|
|||
"4": "매우나쁨"
|
||||
};
|
||||
|
||||
function degreesToRadians(value) {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function extractItems(payload) {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
|
|
@ -48,98 +34,7 @@ function toFloat(raw) {
|
|||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function squaredDistance(latA, lonA, latB, lonB) {
|
||||
return (latA - latB) ** 2 + (lonA - lonB) ** 2;
|
||||
}
|
||||
|
||||
function meridionalArc(phi, { semiMajorAxis, eccentricitySquared }) {
|
||||
const e2 = eccentricitySquared;
|
||||
|
||||
return semiMajorAxis * (
|
||||
(1 - e2 / 4 - (3 * e2 ** 2) / 64 - (5 * e2 ** 3) / 256) * phi -
|
||||
((3 * e2) / 8 + (3 * e2 ** 2) / 32 + (45 * e2 ** 3) / 1024) * Math.sin(2 * phi) +
|
||||
((15 * e2 ** 2) / 256 + (45 * e2 ** 3) / 1024) * Math.sin(4 * phi) -
|
||||
((35 * e2 ** 3) / 3072) * Math.sin(6 * phi)
|
||||
);
|
||||
}
|
||||
|
||||
function wgs84ToBessel(latitude, longitude) {
|
||||
const [dx, dy, dz] = AIR_KOREA_WGS84_TO_BESSEL;
|
||||
const sourceE2 = 2 * WGS84_F - WGS84_F ** 2;
|
||||
const targetE2 = 2 * BESSEL_F - BESSEL_F ** 2;
|
||||
|
||||
const latRad = degreesToRadians(latitude);
|
||||
const lonRad = degreesToRadians(longitude);
|
||||
const sinLat = Math.sin(latRad);
|
||||
const cosLat = Math.cos(latRad);
|
||||
const primeVerticalRadius = WGS84_A / Math.sqrt(1 - sourceE2 * sinLat * sinLat);
|
||||
|
||||
const x = primeVerticalRadius * cosLat * Math.cos(lonRad) + dx;
|
||||
const y = primeVerticalRadius * cosLat * Math.sin(lonRad) + dy;
|
||||
const z = primeVerticalRadius * (1 - sourceE2) * sinLat + dz;
|
||||
|
||||
const lonBessel = Math.atan2(y, x);
|
||||
const horizontal = Math.sqrt(x * x + y * y);
|
||||
let latBessel = Math.atan2(z, horizontal * (1 - targetE2));
|
||||
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const sinLatBessel = Math.sin(latBessel);
|
||||
const besselRadius = BESSEL_A / Math.sqrt(1 - targetE2 * sinLatBessel * sinLatBessel);
|
||||
const nextLat = Math.atan2(z + targetE2 * besselRadius * sinLatBessel, horizontal);
|
||||
|
||||
if (Math.abs(nextLat - latBessel) < 1e-14) {
|
||||
latBessel = nextLat;
|
||||
break;
|
||||
}
|
||||
|
||||
latBessel = nextLat;
|
||||
}
|
||||
|
||||
return [latBessel, lonBessel];
|
||||
}
|
||||
|
||||
function wgs84ToAirKoreaTm(latitude, longitude) {
|
||||
const [latRad, lonRad] = wgs84ToBessel(latitude, longitude);
|
||||
const besselE2 = 2 * BESSEL_F - BESSEL_F ** 2;
|
||||
const secondEccentricitySquared = besselE2 / (1 - besselE2);
|
||||
|
||||
const sinLat = Math.sin(latRad);
|
||||
const cosLat = Math.cos(latRad);
|
||||
const tanLat = Math.tan(latRad);
|
||||
|
||||
const primeVerticalRadius = BESSEL_A / Math.sqrt(1 - besselE2 * sinLat * sinLat);
|
||||
const tanSquared = tanLat * tanLat;
|
||||
const curvature = secondEccentricitySquared * cosLat * cosLat;
|
||||
const A = (lonRad - AIR_KOREA_TM_LON0) * cosLat;
|
||||
|
||||
const meridional = meridionalArc(latRad, {
|
||||
semiMajorAxis: BESSEL_A,
|
||||
eccentricitySquared: besselE2
|
||||
});
|
||||
const meridionalOrigin = meridionalArc(AIR_KOREA_TM_LAT0, {
|
||||
semiMajorAxis: BESSEL_A,
|
||||
eccentricitySquared: besselE2
|
||||
});
|
||||
|
||||
const tmX = AIR_KOREA_TM_FALSE_EASTING + AIR_KOREA_TM_SCALE * primeVerticalRadius * (
|
||||
A +
|
||||
((1 - tanSquared + curvature) * A ** 3) / 6 +
|
||||
((5 - 18 * tanSquared + tanSquared ** 2 + 72 * curvature - 58 * secondEccentricitySquared) * A ** 5) / 120
|
||||
);
|
||||
const tmY = AIR_KOREA_TM_FALSE_NORTHING + AIR_KOREA_TM_SCALE * (
|
||||
meridional -
|
||||
meridionalOrigin +
|
||||
primeVerticalRadius * tanLat * (
|
||||
A ** 2 / 2 +
|
||||
((5 - tanSquared + 9 * curvature + 4 * curvature ** 2) * A ** 4) / 24 +
|
||||
((61 - 58 * tanSquared + tanSquared ** 2 + 600 * curvature - 330 * secondEccentricitySquared) * A ** 6) / 720
|
||||
)
|
||||
);
|
||||
|
||||
return { tmX, tmY };
|
||||
}
|
||||
|
||||
function pickStation(stationItems, { lat = null, lon = null, regionHint = null, stationName = null } = {}) {
|
||||
function pickStation(stationItems, { regionHint = null, stationName = null } = {}) {
|
||||
if (!stationItems.length) {
|
||||
throw new Error("측정소 후보가 없습니다.");
|
||||
}
|
||||
|
|
@ -158,26 +53,6 @@ function pickStation(stationItems, { lat = null, lon = null, regionHint = null,
|
|||
}
|
||||
}
|
||||
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||
const candidates = stationItems
|
||||
.map((item) => {
|
||||
const itemLat = toFloat(item.dmX);
|
||||
const itemLon = toFloat(item.dmY);
|
||||
|
||||
if (itemLat === null || itemLon === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [squaredDistance(lat, lon, itemLat, itemLon), item];
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left[0] - right[0]);
|
||||
|
||||
if (candidates.length > 0) {
|
||||
return candidates[0][1];
|
||||
}
|
||||
}
|
||||
|
||||
if (regionHint) {
|
||||
const tokens = [...new Set(String(regionHint).split(/\s+/u).filter(Boolean))].sort((left, right) => right.length - left.length);
|
||||
|
||||
|
|
@ -233,6 +108,15 @@ function buildStationNameCandidates({ stationName = null, regionHint = null } =
|
|||
return [...new Set(candidates.filter(Boolean))];
|
||||
}
|
||||
|
||||
function buildRegionTokens(regionHint) {
|
||||
return [...new Set(
|
||||
String(regionHint || "")
|
||||
.split(/\s+/u)
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean)
|
||||
)];
|
||||
}
|
||||
|
||||
function findMeasurement(measurementItems, stationName) {
|
||||
const exactMatch = measurementItems.find((item) => item.stationName === stationName);
|
||||
if (exactMatch) {
|
||||
|
|
@ -271,15 +155,13 @@ function gradeToLabel(rawGrade, { pollutant, value }) {
|
|||
return "매우나쁨";
|
||||
}
|
||||
|
||||
function buildReport({ stationItems, measurementItems, lat = null, lon = null, regionHint = null, stationName = null, lookupMode = null, selectedStation = null }) {
|
||||
function buildReport({ stationItems, measurementItems, regionHint = null, stationName = null, lookupMode = null, selectedStation = null }) {
|
||||
const station = selectedStation || resolveStation(stationItems, {
|
||||
lat,
|
||||
lon,
|
||||
regionHint,
|
||||
stationName
|
||||
});
|
||||
const measurement = findMeasurement(measurementItems, station.stationName);
|
||||
const resolvedLookupMode = lookupMode || (Number.isFinite(lat) && Number.isFinite(lon) ? "coordinates" : "fallback");
|
||||
const resolvedLookupMode = lookupMode || "fallback";
|
||||
|
||||
return {
|
||||
station_name: station.stationName,
|
||||
|
|
@ -344,7 +226,7 @@ async function fetchJson(baseUrl, params, { fetchImpl = global.fetch, headers =
|
|||
return JSON.parse(await response.text());
|
||||
}
|
||||
|
||||
async function fetchStationLookup({ lat = null, lon = null, regionHint = null, stationName = null, serviceKey, fetchImpl = global.fetch, headers = {}, stationServiceUrl = STATION_SERVICE_URL }) {
|
||||
async function fetchStationLookup({ regionHint = null, stationName = null, serviceKey, fetchImpl = global.fetch, headers = {}, stationServiceUrl = STATION_SERVICE_URL }) {
|
||||
if (!serviceKey) {
|
||||
throw new Error("AIR_KOREA_OPEN_API_KEY is not configured on the proxy server.");
|
||||
}
|
||||
|
|
@ -356,26 +238,6 @@ async function fetchStationLookup({ lat = null, lon = null, regionHint = null, s
|
|||
pageNo: 1
|
||||
};
|
||||
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||
const { tmX, tmY } = wgs84ToAirKoreaTm(lat, lon);
|
||||
const nearbyPayload = await fetchJson(`${stationServiceUrl}/getNearbyMsrstnList`, {
|
||||
...common,
|
||||
numOfRows: 10,
|
||||
tmX,
|
||||
tmY
|
||||
}, {
|
||||
fetchImpl,
|
||||
headers
|
||||
});
|
||||
|
||||
if (extractItems(nearbyPayload).length > 0) {
|
||||
return {
|
||||
lookupMode: "coordinates",
|
||||
payload: nearbyPayload
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (regionHint || stationName) {
|
||||
return {
|
||||
lookupMode: "fallback",
|
||||
|
|
@ -390,7 +252,7 @@ async function fetchStationLookup({ lat = null, lon = null, regionHint = null, s
|
|||
};
|
||||
}
|
||||
|
||||
throw new Error("위도/경도 또는 region fallback 이 필요합니다.");
|
||||
throw new Error("regionHint 또는 stationName 이 필요합니다.");
|
||||
}
|
||||
|
||||
async function fetchMeasurementPayload({ stationName, serviceKey, fetchImpl = global.fetch, headers = {}, measurementServiceUrl = MEASUREMENT_SERVICE_URL }) {
|
||||
|
|
@ -412,15 +274,31 @@ async function fetchMeasurementPayload({ stationName, serviceKey, fetchImpl = gl
|
|||
});
|
||||
}
|
||||
|
||||
async function fetchFineDustReport({ lat = null, lon = null, regionHint = null, stationName = null, serviceKey, fetchImpl = global.fetch, headers = {}, stationServiceUrl = STATION_SERVICE_URL, measurementServiceUrl = MEASUREMENT_SERVICE_URL }) {
|
||||
async function fetchCtprvnMeasurementPayload({ sidoName, serviceKey, fetchImpl = global.fetch, headers = {}, measurementServiceUrl = MEASUREMENT_SERVICE_URL }) {
|
||||
if (!serviceKey) {
|
||||
throw new Error("AIR_KOREA_OPEN_API_KEY is not configured on the proxy server.");
|
||||
}
|
||||
|
||||
return fetchJson(`${measurementServiceUrl}/getCtprvnRltmMesureDnsty`, {
|
||||
serviceKey,
|
||||
returnType: "json",
|
||||
numOfRows: 100,
|
||||
pageNo: 1,
|
||||
sidoName,
|
||||
ver: "1.4"
|
||||
}, {
|
||||
fetchImpl,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchFineDustReport({ regionHint = null, stationName = null, serviceKey, fetchImpl = global.fetch, headers = {}, stationServiceUrl = STATION_SERVICE_URL, measurementServiceUrl = MEASUREMENT_SERVICE_URL }) {
|
||||
let stationLookup;
|
||||
let stationItems;
|
||||
let station;
|
||||
|
||||
try {
|
||||
stationLookup = await fetchStationLookup({
|
||||
lat,
|
||||
lon,
|
||||
regionHint,
|
||||
stationName,
|
||||
serviceKey,
|
||||
|
|
@ -430,8 +308,6 @@ async function fetchFineDustReport({ lat = null, lon = null, regionHint = null,
|
|||
});
|
||||
stationItems = extractItems(stationLookup.payload);
|
||||
station = resolveStation(stationItems, {
|
||||
lat,
|
||||
lon,
|
||||
regionHint,
|
||||
stationName
|
||||
});
|
||||
|
|
@ -460,8 +336,6 @@ async function fetchFineDustReport({ lat = null, lon = null, regionHint = null,
|
|||
return buildReport({
|
||||
stationItems: [{ stationName: matchedMeasurement.stationName, addr: null }],
|
||||
measurementItems,
|
||||
lat,
|
||||
lon,
|
||||
regionHint,
|
||||
stationName: matchedMeasurement.stationName,
|
||||
lookupMode: "fallback",
|
||||
|
|
@ -472,6 +346,48 @@ async function fetchFineDustReport({ lat = null, lon = null, regionHint = null,
|
|||
}
|
||||
}
|
||||
|
||||
const regionTokens = buildRegionTokens(regionHint);
|
||||
const sidoName = regionTokens[0];
|
||||
if (sidoName) {
|
||||
const ctprvnPayload = await fetchCtprvnMeasurementPayload({
|
||||
sidoName,
|
||||
serviceKey,
|
||||
fetchImpl,
|
||||
headers,
|
||||
measurementServiceUrl
|
||||
});
|
||||
const cityItems = extractItems(ctprvnPayload);
|
||||
const specificTokens = regionTokens.length > 1 ? regionTokens.slice(1) : regionTokens;
|
||||
const tokenMatches = cityItems.filter((item) =>
|
||||
specificTokens.some((token) => String(item.stationName || "").includes(token))
|
||||
);
|
||||
|
||||
if (tokenMatches.length > 0) {
|
||||
const selectedStation = tokenMatches[0];
|
||||
return buildReport({
|
||||
stationItems: [{ stationName: selectedStation.stationName, addr: null }],
|
||||
measurementItems: cityItems,
|
||||
regionHint,
|
||||
stationName: selectedStation.stationName,
|
||||
lookupMode: "fallback",
|
||||
selectedStation: { stationName: selectedStation.stationName, addr: null }
|
||||
});
|
||||
}
|
||||
|
||||
const stationSamples = cityItems
|
||||
.slice(0, 8)
|
||||
.map((item) => item.stationName)
|
||||
.filter(Boolean);
|
||||
const lookupError = new Error(
|
||||
`'${regionHint}' 는 현재 바로 매핑되는 단일 측정소를 확정하지 못했습니다. 아래 후보 중 정확한 측정소명으로 다시 조회해 주세요.`,
|
||||
);
|
||||
lookupError.statusCode = 400;
|
||||
lookupError.code = "ambiguous_location";
|
||||
lookupError.sidoName = sidoName;
|
||||
lookupError.candidateStations = stationSamples;
|
||||
throw lookupError;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
|
@ -486,8 +402,6 @@ async function fetchFineDustReport({ lat = null, lon = null, regionHint = null,
|
|||
return buildReport({
|
||||
stationItems,
|
||||
measurementItems: extractItems(measurementPayload),
|
||||
lat,
|
||||
lon,
|
||||
regionHint,
|
||||
stationName: station.stationName,
|
||||
lookupMode: stationLookup.lookupMode,
|
||||
|
|
@ -502,12 +416,12 @@ module.exports = {
|
|||
buildReport,
|
||||
extractItems,
|
||||
fetchFineDustReport,
|
||||
fetchCtprvnMeasurementPayload,
|
||||
fetchMeasurementPayload,
|
||||
fetchStationLookup,
|
||||
findMeasurement,
|
||||
gradeToLabel,
|
||||
pickStation,
|
||||
resolveStation,
|
||||
toFloat,
|
||||
wgs84ToAirKoreaTm
|
||||
toFloat
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const { fetchFineDustReport } = require("./airkorea");
|
|||
const UPSTREAM_BASE_URL = "http://apis.data.go.kr";
|
||||
const ALLOWED_AIRKOREA_ROUTES = new Map([
|
||||
["MsrstnInfoInqireSvc", new Set(["getMsrstnList", "getNearbyMsrstnList", "getTMStdrCrdnt"])],
|
||||
["ArpltnInforInqireSvc", new Set(["getMsrstnAcctoRltmMesureDnsty"])],
|
||||
["ArpltnInforInqireSvc", new Set(["getMsrstnAcctoRltmMesureDnsty", "getCtprvnRltmMesureDnsty"])],
|
||||
["UserSportSvc", new Set(["getSvckeyDalyStats"])],
|
||||
]);
|
||||
|
||||
|
|
@ -107,26 +107,14 @@ function buildRateLimiter(config) {
|
|||
}
|
||||
|
||||
function normalizeFineDustQuery(query) {
|
||||
const lat = parseFloatValue(query.lat);
|
||||
const lon = parseFloatValue(query.lon);
|
||||
const regionHint = trimOrNull(query.regionHint ?? query.region_hint);
|
||||
const stationName = trimOrNull(query.stationName ?? query.station_name);
|
||||
|
||||
if ((lat !== null && Number.isNaN(lat)) || (lon !== null && Number.isNaN(lon))) {
|
||||
throw new Error("lat/lon must be finite numbers.");
|
||||
}
|
||||
|
||||
if ((lat === null) !== (lon === null)) {
|
||||
throw new Error("lat and lon must be provided together.");
|
||||
}
|
||||
|
||||
if (lat === null && !regionHint && !stationName) {
|
||||
throw new Error("Provide lat/lon, regionHint, or stationName.");
|
||||
if (!regionHint && !stationName) {
|
||||
throw new Error("Provide regionHint or stationName.");
|
||||
}
|
||||
|
||||
return {
|
||||
lat,
|
||||
lon,
|
||||
regionHint,
|
||||
stationName
|
||||
};
|
||||
|
|
@ -299,10 +287,20 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
|||
app.setErrorHandler((error, request, reply) => {
|
||||
request.log.error(error);
|
||||
const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
|
||||
reply.code(statusCode).send({
|
||||
error: statusCode >= 500 ? "proxy_error" : "request_error",
|
||||
const payload = {
|
||||
error: error.code || (statusCode >= 500 ? "proxy_error" : "request_error"),
|
||||
message: error.message
|
||||
});
|
||||
};
|
||||
|
||||
if (Array.isArray(error.candidateStations)) {
|
||||
payload.candidate_stations = error.candidateStations;
|
||||
}
|
||||
|
||||
if (error.sidoName) {
|
||||
payload.sido_name = error.sidoName;
|
||||
}
|
||||
|
||||
reply.code(statusCode).send(payload);
|
||||
});
|
||||
|
||||
return app;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ const assert = require("node:assert/strict");
|
|||
const {
|
||||
buildReport,
|
||||
fetchFineDustReport,
|
||||
pickStation,
|
||||
wgs84ToAirKoreaTm
|
||||
pickStation
|
||||
} = require("../src/airkorea");
|
||||
|
||||
const stationPayload = {
|
||||
|
|
@ -47,13 +46,6 @@ const measurementPayload = {
|
|||
}
|
||||
};
|
||||
|
||||
test("wgs84 coordinates are converted to AirKorea TM", () => {
|
||||
const { tmX, tmY } = wgs84ToAirKoreaTm(37.5665, 126.9780);
|
||||
|
||||
assert.ok(Math.abs(tmX - 198245.053) < 0.01);
|
||||
assert.ok(Math.abs(tmY - 451586.838) < 0.01);
|
||||
});
|
||||
|
||||
test("pickStation prefers specific region token matches", () => {
|
||||
const station = pickStation(stationPayload.response.body.items, {
|
||||
regionHint: "서울 강남구"
|
||||
|
|
@ -75,18 +67,11 @@ test("buildReport combines station and measurement summary", () => {
|
|||
assert.equal(report.lookup_mode, "fallback");
|
||||
});
|
||||
|
||||
test("fetchFineDustReport falls back to region lookup when nearby returns empty", async () => {
|
||||
test("fetchFineDustReport uses station-info lookup before measurement lookup", async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(String(url));
|
||||
|
||||
if (String(url).includes("getNearbyMsrstnList")) {
|
||||
return new Response(JSON.stringify({ response: { body: { items: [] } } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
if (String(url).includes("getMsrstnList")) {
|
||||
return new Response(JSON.stringify({
|
||||
response: {
|
||||
|
|
@ -111,8 +96,6 @@ test("fetchFineDustReport falls back to region lookup when nearby returns empty"
|
|||
};
|
||||
|
||||
const report = await fetchFineDustReport({
|
||||
lat: 37.5665,
|
||||
lon: 126.978,
|
||||
regionHint: "서울 강남구",
|
||||
serviceKey: "test-key",
|
||||
fetchImpl
|
||||
|
|
@ -121,7 +104,6 @@ test("fetchFineDustReport falls back to region lookup when nearby returns empty"
|
|||
assert.equal(report.station_name, "강남구");
|
||||
assert.equal(report.lookup_mode, "fallback");
|
||||
assert.deepEqual(calls.map((url) => url.split("/").at(-1)?.split("?")[0]), [
|
||||
"getNearbyMsrstnList",
|
||||
"getMsrstnList",
|
||||
"getMsrstnAcctoRltmMesureDnsty"
|
||||
]);
|
||||
|
|
@ -161,3 +143,53 @@ test("fetchFineDustReport falls back to direct measurement lookup when station-i
|
|||
"getMsrstnAcctoRltmMesureDnsty"
|
||||
]);
|
||||
});
|
||||
|
||||
test("fetchFineDustReport returns a helpful 400 when district tokens do not map to station names", async () => {
|
||||
const fetchImpl = async (url) => {
|
||||
const text = String(url);
|
||||
|
||||
if (text.includes("getMsrstnList")) {
|
||||
return new Response("Forbidden", { status: 403, headers: { "content-type": "text/plain" } });
|
||||
}
|
||||
|
||||
if (text.includes("getMsrstnAcctoRltmMesureDnsty")) {
|
||||
return new Response(JSON.stringify({ response: { body: { items: [] } } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
if (text.includes("getCtprvnRltmMesureDnsty")) {
|
||||
return new Response(JSON.stringify({
|
||||
response: {
|
||||
body: {
|
||||
items: [
|
||||
{ stationName: "평동", dataTime: "2026-03-28 17:00", pm10Value: "48", pm10Grade: "2", pm25Value: "25", pm25Grade: "2", khaiGrade: "2" },
|
||||
{ stationName: "오선동", dataTime: "2026-03-28 17:00", pm10Value: "38", pm10Grade: "2", pm25Value: "23", pm25Grade: "2", khaiGrade: "2" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => fetchFineDustReport({
|
||||
regionHint: "광주 광산구",
|
||||
serviceKey: "test-key",
|
||||
fetchImpl
|
||||
}),
|
||||
(error) =>
|
||||
error.statusCode === 400 &&
|
||||
error.code === "ambiguous_location" &&
|
||||
error.sidoName === "광주" &&
|
||||
Array.isArray(error.candidateStations) &&
|
||||
error.candidateStations.includes("평동") &&
|
||||
error.candidateStations.includes("오선동")
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,6 +52,36 @@ test("fine dust endpoint stays publicly callable without proxy auth", async (t)
|
|||
assert.equal(providerCalls, 1);
|
||||
});
|
||||
|
||||
test("fine dust endpoint returns candidate stations when region resolution is ambiguous", async (t) => {
|
||||
const app = buildServer({
|
||||
env: {
|
||||
AIR_KOREA_OPEN_API_KEY: "airkorea-key"
|
||||
},
|
||||
provider: async () => {
|
||||
const error = new Error("단일 측정소를 확정하지 못했습니다.");
|
||||
error.statusCode = 400;
|
||||
error.code = "ambiguous_location";
|
||||
error.sidoName = "광주";
|
||||
error.candidateStations = ["평동", "오선동"];
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/fine-dust/report?regionHint=%EA%B4%91%EC%A3%BC%20%EA%B4%91%EC%82%B0%EA%B5%AC"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error, "ambiguous_location");
|
||||
assert.equal(response.json().sido_name, "광주");
|
||||
assert.deepEqual(response.json().candidate_stations, ["평동", "오선동"]);
|
||||
});
|
||||
|
||||
test("fine dust endpoint caches successful provider responses", async (t) => {
|
||||
let providerCalls = 0;
|
||||
const app = buildServer({
|
||||
|
|
|
|||
|
|
@ -372,6 +372,17 @@ def read_json_response(request: urllib.request.Request | str) -> dict:
|
|||
payload = None
|
||||
|
||||
message = payload.get("message") if isinstance(payload, dict) else None
|
||||
if isinstance(payload, dict) and payload.get("error") == "ambiguous_location":
|
||||
candidates = payload.get("candidate_stations") or []
|
||||
sido_name = payload.get("sido_name")
|
||||
detail = [message or "단일 측정소를 확정하지 못했습니다."]
|
||||
if sido_name:
|
||||
detail.append(f"시도: {sido_name}")
|
||||
if candidates:
|
||||
detail.append(f"후보 측정소: {', '.join(candidates)}")
|
||||
detail.append("위 후보 중 정확한 측정소명으로 --station-name 재조회하세요.")
|
||||
raise SystemExit("\n".join(detail)) from exc
|
||||
|
||||
raise SystemExit(message or f"요청이 실패했습니다: HTTP {exc.code}") from exc
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -649,19 +649,14 @@ test("fine-dust-location skill documents the official two-api flow and fallback
|
|||
|
||||
for (const doc of [featureDoc]) {
|
||||
assert.match(doc, /AIR_KOREA_OPEN_API_KEY/);
|
||||
assert.match(doc, /B552584\/MsrstnInfoInqireSvc\/getNearbyMsrstnList/);
|
||||
assert.match(doc, /B552584\/MsrstnInfoInqireSvc\/getMsrstnList/);
|
||||
assert.match(doc, /B552584\/ArpltnInforInqireSvc\/getMsrstnAcctoRltmMesureDnsty/);
|
||||
assert.match(doc, /tmX/);
|
||||
assert.match(doc, /tmY/);
|
||||
assert.match(doc, /TM 좌표|중부원점/);
|
||||
assert.match(doc, /getCtprvnRltmMesureDnsty/);
|
||||
assert.match(doc, /PM10/);
|
||||
assert.match(doc, /PM2\.5|PM25/);
|
||||
assert.match(doc, /위도/);
|
||||
assert.match(doc, /경도/);
|
||||
assert.match(doc, /행정구역|지역명/);
|
||||
assert.match(doc, /fallback|폴백|대체 흐름/i);
|
||||
assert.match(doc, /가까운 측정소/);
|
||||
assert.match(doc, /후보 측정소|candidate_stations/);
|
||||
assert.match(doc, /조회 시각|조회 시점/);
|
||||
assert.match(doc, /python3 scripts\/fine_dust\.py/);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue