실제 사고(2026-08-10): "오늘 유럽 대도시 최고 기온"에 모델이 5개
도시("London, Paris, Berlin, Madrid, Rome")를 한 번에 넘겨 실패
(도구별로 나눠 부르라는 안내 자체는 이미 08-09에 잘 만들어져 있었음),
그 뒤 로마·런던 2개만 재시도하고 파리·베를린·마드리드는 빠뜨렸다.
지어내진 않았지만("다른 도시 필요하시면 말씀해주세요") 불완전했다 —
같은 날 news_search/weather_kma에서 겪은 것과 같은 패턴: 프롬프트/
에러 메시지가 정확히 뭘 해야 하는지 알려줘도 여러 번의 후속 호출을
전부 완수하지는 못했다.
같은 해법 적용: 전체 문자열 지오코딩이 실패하면 배치인지 확인하고,
배치면 모델에게 재시도를 맡기는 대신 도구가 직접 각 도시를 병렬로
조회해서 구분선으로 나눈 하나의 결과로 합쳐 반환한다. 일부 도시가
실패해도 나머지는 정상 반환하고 실패한 도시만 목록에 남긴다 — 전체
호출이 부분 실패로 죽지 않는다.
부수적으로 OUTPUT FORMAT의 날씨 지침에 "여러 도시 비교 시 표만 던지지
말고 어디가 제일 덥/추운지, 특이한 도시가 있는지 코멘트를 붙일 것"을
추가 — 실제 로그에서 표 하나에 문장 하나짜리 부실한 답변이 관찰됨.
기존 배치 테스트(2026-08-09, success=false 검증)를 새 동작에 맞게
갱신(success=true + 도시별 결과 포함 검증) — 일부 실패 케이스 테스트도
추가.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
107 lines
5.0 KiB
TypeScript
107 lines
5.0 KiB
TypeScript
/**
|
|
* weather-multi-location.test.ts
|
|
*
|
|
* What weather_openmeteo does 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.
|
|
*
|
|
* First fix (2026-08-09): 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 09:09). Rewrote the error to name every city and tell the model to call once
|
|
* per city instead.
|
|
*
|
|
* That undershot too (2026-08-10, real incident): asked for 5 cities, the model retried only 2
|
|
* after the improved error and silently dropped the other 3 — no fabrication, just an
|
|
* incomplete answer ("다른 도시가 더 필요하시면 말씀해 주세요" instead of finishing the list it
|
|
* had already committed to). Prose telling the model what to do kept being followed partially,
|
|
* not completely, so weather_openmeteo now fetches every city itself on a detected batch and
|
|
* returns one combined report — there's no follow-up call left for the model to skip.
|
|
*
|
|
* These tests hit the real Open-Meteo geocoding/forecast 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;
|
|
stdout?: string;
|
|
data?: { locations?: string[]; failed?: 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');
|
|
|
|
// 2026-08-10 이전엔 여기서 success=false + 도시별로 다시 부르라는 안내였다. 그 안내를
|
|
// 모델이 부분적으로만 따라서(5개 중 2개만 재시도) 나온 실제 사고 이후, 도구가 직접 전부
|
|
// 조회하도록 바꿨다 — 모델이 빠뜨릴 재시도 자체가 없어야 한다.
|
|
assert.equal(r.success, true);
|
|
const stdout = r.stdout ?? '';
|
|
for (const city of ['London', 'Berlin', 'Paris', 'Rome', 'Madrid']) {
|
|
assert.ok(r.data?.locations?.some(l => l.includes(city)) || stdout.includes(city), `${city} 결과가 빠짐`);
|
|
}
|
|
// 5개 도시 결과가 구분 없이 뒤섞이면 안 되므로 구분선이 있어야 함.
|
|
assert.ok(stdout.includes('─'), '도시별 구분선이 없음');
|
|
});
|
|
|
|
test('일부 도시만 실패해도 나머지는 정상 반환된다', async (t) => {
|
|
if (!online) return t.skip('offline');
|
|
// looksLikeMultipleLocations는 배치 판별을 위해 첫 두 세그먼트가 각각 지오코딩되는지만
|
|
// 본다(그래야 "Springfield, Freedonia" 같은 단일 지명+미확인 수식어를 목록으로 오인하지
|
|
// 않음) — 그래서 실패할 도시는 세 번째 자리에 둔다.
|
|
const r = await ask('London, Paris, Zzxqwv');
|
|
assert.equal(r.success, true, '일부 실패로 전체가 실패하면 안 됨');
|
|
assert.ok(r.data?.failed?.some(f => f.includes('Zzxqwv')), '실패한 도시가 failed 목록에 있어야 함');
|
|
assert.ok(r.data?.locations?.some(l => l.includes('London')));
|
|
assert.ok(r.data?.locations?.some(l => l.includes('Paris')));
|
|
});
|
|
});
|
|
|
|
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 ?? '', /한 번에 한 곳만/, '단일 지명에 배치 안내가 붙으면 안 됨');
|
|
});
|
|
});
|