fix: 날씨 도구가 도시 단위 대기질을 정확히 조회하도록 수정
"오늘 구미 미세먼지"에 포항 측정소(우현동/장흥동/장량동/대도동) 수치가 구미로
제시됐다. 지적 후 재시도했더니 이번엔 영주·경산 측정소를 구미라고 답했다.
원인은 실시간 대기질 API가 시/도 단위 목록만 주고 응답에 sidoName만 있다는 것.
측정소 이름에는 소속 시/군이 없어("우현동"은 포항, "중방동"은 경산) 모델이
도 전체 목록에서 임의로 골라 특정 도시 값으로 제시할 수밖에 없었다.
- 측정소정보 서비스(MsrstnInfoInqireSvc) 연동. 주소 필드로 시/군을 매칭해
해당 도시 측정소만 반환한다(구미 4곳, 포항 13곳 확인). 목록은 하루 캐시.
이 서비스는 data.go.kr에서 별도 활용신청이 필요하며 2026-08-09 승인됐다.
- 필터가 실패하면 필터된 척하지 않고 "필터 실패, 전체 표시"로 밝히고 경고를 남긴다.
- 실시간 조회 numOfRows 20 → 200. 경북만 53개 측정소라 20행에서는 포항이 잘려
도시 필터가 조용히 빈 결과를 냈다.
- 504 SERVICETIMEOUT에 4회 재시도 추가. 측정 결과 업스트림이 무작위로 실패하며
(연속 8회 중 7성공 / 8초 간격 6회 중 4성공) 호출 간격과 무관해 쿨다운이 아니다.
단발 호출이던 기존 코드는 그때마다 도구가 통째로 실패했다. 4xx는 재시도 안 함.
또한 6개 날씨 도구가 공유하던 "위치를 찾을 수 없습니다"를 실행 가능한 에러로
교체했다. 도시 비교 요청에 모델이 "London, Berlin, Paris, Rome, Madrid"처럼
묶어 넘기면 지오코딩이 실패하는데, 기존 메시지엔 고칠 방법이 없어 같은 인자로
무한 재시도했다. 이제 도시 목록과 재호출 예시, 재시도가 무의미함을 함께 알린다.
첫 두 조각이 각각 지오코딩될 때만 목록으로 판정하므로 "Seoul, Korea"·"London,GB"·
좌표는 영향받지 않는다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* weather-multi-location.test.ts
|
||||
*
|
||||
* What a weather tool says when `location` holds several cities instead of one.
|
||||
*
|
||||
* Asked to compare cities, models batch them into a single argument
|
||||
* ("London, Berlin, Paris, Rome, Madrid"). That geocodes to nothing, and the old error —
|
||||
* a bare "위치를 찾을 수 없습니다" — gave the model no reason to do anything but reissue the
|
||||
* identical call, so the turn looped until it ran out of rounds (observed live 2026-08-09 09:09).
|
||||
*
|
||||
* The property under test is not "does it fail" but "does the failure tell the model how to
|
||||
* recover", plus its mirror: inputs that are legitimately one place must not be mistaken for
|
||||
* a list. These tests hit the real Open-Meteo geocoding API, so they are skipped when it is
|
||||
* unreachable rather than failing the suite on a network blip.
|
||||
*/
|
||||
|
||||
import { test, describe, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { weatherOpenMeteoTool } from '../src/tools/weather';
|
||||
|
||||
const ask = (location: string) =>
|
||||
weatherOpenMeteoTool.execute({ location, variables: 'temperature_2m_max' }) as Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
let online = false;
|
||||
before(async () => {
|
||||
try {
|
||||
const res = await fetch('https://geocoding-api.open-meteo.com/v1/search?name=Seoul&count=1', {
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
online = res.ok;
|
||||
} catch {
|
||||
online = false;
|
||||
}
|
||||
if (!online) console.log(' (geocoding API 접속 불가 — 네트워크 의존 테스트 건너뜀)');
|
||||
});
|
||||
|
||||
describe('배치 입력은 복구 방법을 알려준다', () => {
|
||||
test('여러 도시를 한 번에 넘기면 도구별 호출을 지시한다', async (t) => {
|
||||
if (!online) return t.skip('offline');
|
||||
const r = await ask('London, Berlin, Paris, Rome, Madrid');
|
||||
|
||||
assert.equal(r.success, false);
|
||||
const err = r.error ?? '';
|
||||
// 무엇이 잘못됐는지 + 어떻게 고치는지 + 재시도가 무의미하다는 것, 세 가지가 다 있어야
|
||||
// 모델이 같은 호출을 반복하지 않는다.
|
||||
assert.match(err, /한 번에 한 곳만/, '단일 위치 제약을 밝혀야 함');
|
||||
assert.match(err, /weather_openmeteo/, '어떤 도구를 다시 부를지 알려야 함');
|
||||
assert.match(err, /"location":"London"/, '구체적인 재호출 예시가 있어야 함');
|
||||
assert.match(err, /다시 호출하면 똑같이 실패/, '동일 재시도가 무의미함을 밝혀야 함');
|
||||
// 파싱한 도시를 전부 되돌려줘야 모델이 남은 도시를 안 빠뜨린다.
|
||||
for (const city of ['London', 'Berlin', 'Paris', 'Rome', 'Madrid']) {
|
||||
assert.ok(err.includes(city), `${city}가 목록에 없음`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('단일 위치는 목록으로 오해하지 않는다', () => {
|
||||
test('"City, Country"는 정상 조회된다', async (t) => {
|
||||
if (!online) return t.skip('offline');
|
||||
assert.equal((await ask('Seoul, Korea')).success, true);
|
||||
});
|
||||
|
||||
test('"City,CountryCode"는 정상 조회된다', async (t) => {
|
||||
if (!online) return t.skip('offline');
|
||||
assert.equal((await ask('London,GB')).success, true);
|
||||
});
|
||||
|
||||
test('쉼표가 있는 좌표는 정상 조회된다', async (t) => {
|
||||
if (!online) return t.skip('offline');
|
||||
assert.equal((await ask('37.5,127.0')).success, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('진짜 없는 지명은 기존 메시지를 유지한다', () => {
|
||||
test('배치가 아닌 미확인 지명에는 배치 안내를 붙이지 않는다', async (t) => {
|
||||
if (!online) return t.skip('offline');
|
||||
const r = await ask('Zzxqwv');
|
||||
|
||||
assert.equal(r.success, false);
|
||||
assert.match(r.error ?? '', /위치를 찾을 수 없습니다/);
|
||||
assert.doesNotMatch(r.error ?? '', /한 번에 한 곳만/, '단일 지명에 배치 안내가 붙으면 안 됨');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user