Files
homeclaw/tests/weather-multi-location.test.ts
kimandClaude Opus 5 e6916dde42 fix: weather_openmeteo가 여러 도시를 직접 전부 조회해 하나로 합침
실제 사고(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>
2026-08-10 18:01:45 +09:00

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 ?? '', /한 번에 한 곳만/, '단일 지명에 배치 안내가 붙으면 안 됨');
});
});