fix: 레거시 스킬 토글이 도구를 막던 문제 — 키워드 게이트로 일원화

사용자 확인: 스킬 on/off UI는 예전 것이고 지금은 앱별 게이팅을 쓴다. 그런데
skills_state.json이 tool-scope의 meteorologist/lawyer/presenter 게이트를 통해
여전히 도구를 **막는** 유일한 소비자로 남아 있었고, 아무도 갱신하지 않는 값이라
조용한 기능 상실을 만들고 있었다.

실측된 라이브 영향: papa는 meteorologist:false → "오늘 서울 미세먼지 어때?"에
weather_kma / weather_openmeteo 만 실렸다. weather_kma엔 대기질 데이터가 아예
없으므로(모델 프로필에도 명시돼 있음) 답은 실패 아니면 창작 둘 중 하나였다.
바로 옆에 있던 weather_airkorea(에어코리아, 키 승인·정상)가 죽은 플래그에 막혀 있었음.

- meteorologist/lawyer/presenter 스킬 조건 제거 → 키워드 게이트만 사용
- ToolScopeInput.isSkillEnabledForUser는 유지(앱세션 게이트용, 스킬이 실제
  프롬프트 파일을 갖게 되면 다시 쓸 자리)
- 검증: papa·jasmine 모두 미세먼지/발표자료/판례 도구 정상 노출

tests 463 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs
This commit is contained in:
kim
2026-09-02 15:08:22 +09:00
co-authored by Claude Sonnet 5
parent 797de9b03f
commit 1ef06637a5
2 changed files with 30 additions and 17 deletions
+19 -11
View File
@@ -68,7 +68,6 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
const topicText = String(input.recentUserText || input.message || '');
const sessionId = String(input.sessionId || '');
const userWorkspace = input.userWorkspace;
const isSkillEnabledForUser = input.isSkillEnabledForUser;
// Weather comes in two tiers (2026-07-29). "내일 날씨" is an everyday question, not a
// specialist one, but all nine weather tools sat behind the meteorologist skill — so with the
@@ -77,9 +76,9 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
// weather_openmeteo = everywhere else, both with precipitation chance) for 446 tokens, and
// only when a weather keyword is present. They need no skill toggle.
const basicWeatherToolNames = new Set(['weather_kma', 'weather_openmeteo']);
// The rest stay behind the skill: reanalysis/climate archives (ERA5, CDS, CMIP6, NASA POWER)
// are genuinely specialist, air quality is a separate concern, and weather_search is a
// redundant fallback whose schema costs as much as openmeteo while carrying less data.
// Air quality is a separate concern from the forecast, and weather_search is a redundant
// fallback whose schema costs as much as openmeteo while carrying less data — so they load
// on the weather keyword but are not part of the everyday pair above.
const weatherToolNames = new Set(['weather_search', 'weather_airpollution', 'weather_airkorea']);
// 2026-09-02 (efficiency audit): these four are climate-reanalysis/projection archives, not
// "what's the weather" tools — ERA5 and CDS are Copernicus reanalysis, CMIP6 is scenario
@@ -146,9 +145,18 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
// coverage). Reuses prompt-gates.ts's DISASTER_PATTERN rather than keeping a second copy of
// the same keyword list here, per this file's own header note on gates needing to agree.
const hasDisasterKeyword = DISASTER_PATTERN.test(topicText);
const meteorologistEnabled = isSkillEnabledForUser('meteorologist', userWorkspace);
const lawyerEnabled = isSkillEnabledForUser('lawyer', userWorkspace);
const presenterEnabled = isSkillEnabledForUser('presenter', userWorkspace);
// 2026-09-02: the per-user skills_state.json toggles are legacy — the UI moved to per-app
// gating and nobody maintains these flags any more. They were still the only load-bearing
// consumer of that state, and stale values were silently WITHHOLDING tools: papa has
// meteorologist:false, so "오늘 서울 미세먼지 어때?" got weather_kma/openmeteo only —
// and weather_kma carries no air-quality data at all, so the answer could only be a
// failure or an invention. weather_airkorea (에어코리아, key approved and working) was
// sitting right there, gated off by a flag from a UI the user no longer uses.
//
// The keyword gates below are the real cost control and they are already narrow. A toggle
// that is never updated is not a safety mechanism, it is a trap — so the skill condition is
// dropped and the keyword alone decides. `isSkillEnabledForUser` stays on ToolScopeInput for
// the app-session gates and for whenever skills get real prompt files again.
const hasPptxKeyword = /슬라이드|발표|pptx|ppt\b|프레젠테이션|피피티|덱|presentation/i.test(message);
// 2026-08-25: 논문 도구는 pptx 키워드에만 묶여 있어서, "논문 찾아줘"로는 아예 안 켜지고
// 모델이 web_search로 때웠다(실사용 로그에서 확인). 학술 검색은 web_search보다 확실히
@@ -244,8 +252,8 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
if (name.startsWith('mcp__dental-dict-sqlite__') && !isDentalAppSession) return false;
if ((name.startsWith('mcp__psychotherapy-cases-sqlite__') || name.startsWith('mcp__psychiatry-cases-sqlite__')) && !isMindAppSession) return false;
if (basicWeatherToolNames.has(name) && !isWeatherAppSession && !hasWeatherKeyword) return false;
if (weatherToolNames.has(name) && !isWeatherAppSession && !(meteorologistEnabled && hasWeatherKeyword)) return false;
if (climateToolNames.has(name) && !isWeatherAppSession && !(meteorologistEnabled && hasClimateKeyword)) return false;
if (weatherToolNames.has(name) && !isWeatherAppSession && !hasWeatherKeyword) return false;
if (climateToolNames.has(name) && !isWeatherAppSession && !hasClimateKeyword) return false;
// Windy map screenshots only make sense inside the dedicated weather app tab (the
// model there has already generated the embed.windy.com URL to capture) — unlike the
// other weather tools, not offered via the meteorologist skill in general chat.
@@ -261,7 +269,7 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
if (name === 'weather_map_screenshot' && !isWeatherAppSession && !hasSatelliteKeyword) return false;
if (name === 'nhc_active_storms' && !isWeatherAppSession) return false;
if (ollamaWebToolNames.has(name) && !hasOllamaWebKeyword) return false;
if (legalToolNames.has(name) && !isLawyerAppSession && !(lawyerEnabled && hasLegalKeyword)) return false;
if (legalToolNames.has(name) && !isLawyerAppSession && !hasLegalKeyword) return false;
if ((name === 'excel_read' || name === 'excel_write') && !isAccountantAppSession && !hasExcelKeyword) return false;
if (coderToolNames.has(name) && !hasCodeKeyword) return false;
if (name === 'shell' && !hasCodeKeyword) return false;
@@ -274,7 +282,7 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
// ~1,600 tokens of presentation API surface (create_presentation alone is 833, the largest
// tool schema in the codebase) on every turn including plain greetings.
// Now matches the weather/legal shape directly above: exclude unless skill AND keyword agree.
if (pptxToolNames.has(name) && !(presenterEnabled && hasPptxKeyword)) return false;
if (pptxToolNames.has(name) && !hasPptxKeyword) return false;
if (academicToolNames.has(name) && !hasPptxKeyword && !hasAcademicKeyword) return false;
if (name === 'cms_hospital_compare' && !hasHospitalDataKeyword) return false;
if (emailToolNames.has(name) && !hasEmailKeyword) return false;
+11 -6
View File
@@ -111,13 +111,18 @@ describe('키워드 게이트 — 필요할 때 확실히 나타난다', () => {
}
});
test('전문 날씨 도구는 여전히 스킬을 요구한다', () => {
// 재분석·기후 아카이브(ERA5/CMIP6/CDS/NASA POWER)와 대기질은 일상 질문이 아니다.
const f = filterFor('내일 날씨 어때');
for (const n of ['weather_era5', 'weather_cmip6', 'weather_cds', 'weather_nasa_power', 'weather_airkorea', 'weather_search']) {
assert.equal(f(n), false, `${n} 이(가) 스킬 없이 노출됨`);
// [2026-09-02] 스킬 토글은 레거시가 됐다 — UI가 앱별 게이팅으로 넘어가면서 아무도
// skills_state.json을 갱신하지 않는데, 이 토글이 도구를 **막는** 유일한 소비자로 남아
// 있었다. papa의 meteorologist:false 때문에 "오늘 미세먼지 어때?"가 weather_kma/openmeteo
// 만 받았고, weather_kma엔 대기질 데이터가 아예 없어서 실패 아니면 창작밖에 안 나왔다.
// 이제 키워드만으로 결정한다.
test('대기질 도구는 스킬 토글과 무관하게 날씨 키워드로 열린다', () => {
const f = filterFor('오늘 서울 미세먼지 어때?');
for (const n of ['weather_airkorea', 'weather_airpollution', 'weather_kma', 'weather_openmeteo']) {
assert.equal(f(n), true, `${n} 이(가) 레거시 토글 때문에 막힘`);
}
assert.equal(buildSkillToolFilter({ ...allSkills, message: '내일 날씨 어때' })('weather_airkorea'), true);
// 기후 아카이브는 여전히 별도 키워드를 요구한다
assert.equal(f('weather_era5'), false);
});
// [2026-09-02 효율 감사] ERA5/CDS/CMIP6/NASA POWER는 "오늘 날씨"가 아니라 기후 재분석·시나리오