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>
This commit is contained in:
kim
2026-08-10 18:01:45 +09:00
co-authored by Claude Opus 5
parent 57f2a89529
commit e6916dde42
3 changed files with 82 additions and 34 deletions
+42 -14
View File
@@ -685,18 +685,7 @@ export const weatherOpenMeteoTool = {
// not maxima at all. Route them before the request instead.
const { hourly: hourlyVars, daily: dailyVars } = splitOpenMeteoVariables(vars);
try {
let lat: number, lon: number, displayName: string;
const coords = parseCoords(location);
if (coords) {
[lat, lon] = coords;
displayName = location;
} else {
const geo = await geocodeCity(location);
if (!geo) return await locationNotFoundError(location, 'weather_openmeteo');
({ lat, lon, displayName } = geo);
}
const fetchOneCity = async (lat: number, lon: number, displayName: string) => {
const params = new URLSearchParams({
latitude: String(lat),
longitude: String(lon),
@@ -717,7 +706,6 @@ export const weatherOpenMeteoTool = {
const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: AbortSignal.timeout(15_000) });
if (!res.ok) throw new Error(`Open-Meteo API error ${res.status}`);
const data: any = await res.json();
const text = formatOpenMeteoReport({
displayName,
timezone: data.timezone || 'auto',
@@ -735,7 +723,47 @@ export const weatherOpenMeteoTool = {
return String(v);
},
});
return { success: true, stdout: text, data: { location: displayName, lat, lon, raw: data } };
return { text, lat, lon, raw: data };
};
try {
const coords = parseCoords(location);
if (coords) {
const [lat, lon] = coords;
const { text, raw } = await fetchOneCity(lat, lon, location);
return { success: true, stdout: text, data: { location, lat, lon, raw } };
}
const geo = await geocodeCity(location);
if (geo) {
const { text, raw } = await fetchOneCity(geo.lat, geo.lon, geo.displayName);
return { success: true, stdout: text, data: { location: geo.displayName, lat: geo.lat, lon: geo.lon, raw } };
}
// 2026-08-10: the error path below already tells the model exactly how to retry
// ("call once per city"), but a real incident showed that guidance only gets partially
// followed — asked for 5 cities, it retried 2 and silently dropped 3 (no fabrication,
// just incomplete: it said "let me know if you need the others" instead of finishing the
// list it had already committed to). Rather than trust N follow-up calls to all happen,
// fetch every city here and return one combined report — there is no follow-up to skip.
const cities = await looksLikeMultipleLocations(location);
if (!cities) return await locationNotFoundError(location, 'weather_openmeteo');
const settled = await Promise.allSettled(cities.map(async (c) => {
const g = await geocodeCity(c);
if (!g) throw new Error(`${c}: 위치를 찾을 수 없음`);
const { text } = await fetchOneCity(g.lat, g.lon, g.displayName);
return { city: c, text };
}));
const ok = settled.filter((r): r is PromiseFulfilledResult<{ city: string; text: string }> => r.status === 'fulfilled').map(r => r.value);
const failed = settled
.map((r, i) => (r.status === 'rejected' ? cities[i] : null))
.filter((c): c is string => c !== null);
if (!ok.length) return { success: false, error: `${cities.length}개 도시 전부 조회 실패: ${cities.join(', ')}` };
const combined = ok.map(r => r.text).join('\n\n' + '─'.repeat(40) + '\n\n')
+ (failed.length ? `\n\n(다음 도시는 조회 실패: ${failed.join(', ')})` : '');
return { success: true, stdout: combined, data: { locations: ok.map(r => r.city), failed } };
} catch (err: any) {
return { success: false, error: err.message };
}