k-skill/packages/k-skill-proxy/test/airkorea.test.js
Jeffrey (Dongkyu) Kim 8b36634e28 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
2026-03-28 23:28:12 +09:00

195 lines
5.4 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildReport,
fetchFineDustReport,
pickStation
} = require("../src/airkorea");
const stationPayload = {
response: {
body: {
items: [
{
stationName: "강남구",
addr: "서울 강남구 학동로 426",
dmX: 37.5179,
dmY: 127.0473
},
{
stationName: "중구",
addr: "서울 중구 서소문로 124",
dmX: 37.564,
dmY: 126.975
}
]
}
}
};
const measurementPayload = {
response: {
body: {
items: [
{
stationName: "강남구",
dataTime: "2026-03-27 21:00",
pm10Value: "42",
pm10Grade: "2",
pm25Value: "19",
pm25Grade: "2",
khaiGrade: "2"
}
]
}
}
};
test("pickStation prefers specific region token matches", () => {
const station = pickStation(stationPayload.response.body.items, {
regionHint: "서울 강남구"
});
assert.equal(station.stationName, "강남구");
});
test("buildReport combines station and measurement summary", () => {
const report = buildReport({
stationItems: stationPayload.response.body.items,
measurementItems: measurementPayload.response.body.items,
regionHint: "서울 강남구"
});
assert.equal(report.station_name, "강남구");
assert.deepEqual(report.pm10, { value: "42", grade: "보통" });
assert.deepEqual(report.pm25, { value: "19", grade: "보통" });
assert.equal(report.lookup_mode, "fallback");
});
test("fetchFineDustReport uses station-info lookup before measurement lookup", async () => {
const calls = [];
const fetchImpl = async (url) => {
calls.push(String(url));
if (String(url).includes("getMsrstnList")) {
return new Response(JSON.stringify({
response: {
body: {
items: [stationPayload.response.body.items[0]]
}
}
}), {
status: 200,
headers: { "content-type": "application/json" }
});
}
if (String(url).includes("getMsrstnAcctoRltmMesureDnsty")) {
return new Response(JSON.stringify(measurementPayload), {
status: 200,
headers: { "content-type": "application/json" }
});
}
throw new Error(`unexpected URL: ${url}`);
};
const report = await fetchFineDustReport({
regionHint: "서울 강남구",
serviceKey: "test-key",
fetchImpl
});
assert.equal(report.station_name, "강남구");
assert.equal(report.lookup_mode, "fallback");
assert.deepEqual(calls.map((url) => url.split("/").at(-1)?.split("?")[0]), [
"getMsrstnList",
"getMsrstnAcctoRltmMesureDnsty"
]);
});
test("fetchFineDustReport falls back to direct measurement lookup when station-info access is forbidden", async () => {
const calls = [];
const fetchImpl = async (url) => {
const text = String(url);
calls.push(text);
if (text.includes("getMsrstnList")) {
return new Response("Forbidden", { status: 403, headers: { "content-type": "text/plain" } });
}
if (text.includes("getMsrstnAcctoRltmMesureDnsty")) {
return new Response(JSON.stringify(measurementPayload), {
status: 200,
headers: { "content-type": "application/json" }
});
}
throw new Error(`unexpected URL: ${url}`);
};
const report = await fetchFineDustReport({
regionHint: "서울 강남구",
serviceKey: "test-key",
fetchImpl
});
assert.equal(report.station_name, "강남구");
assert.equal(report.station_address, null);
assert.equal(report.lookup_mode, "fallback");
assert.deepEqual(calls.map((url) => url.split("/").at(-1)?.split("?")[0]), [
"getMsrstnList",
"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("오선동")
);
});