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:
Jeffrey (Dongkyu) Kim 2026-03-28 23:28:12 +09:00
commit 8b36634e28
8 changed files with 241 additions and 246 deletions

View file

@ -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 에만 둡니다

View file

@ -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 를 기본으로 둔다.

View file

@ -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
};

View file

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

View file

@ -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("오선동")
);
});

View file

@ -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({

View file

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

View file

@ -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/);
}