ummaya 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +2 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/prompts/manifest.yaml +2 -2
  5. package/prompts/session_guidance_v1.md +3 -1
  6. package/prompts/system_v1.md +8 -7
  7. package/pyproject.toml +2 -7
  8. package/src/ummaya/context/builder.py +17 -11
  9. package/src/ummaya/engine/engine.py +27 -7
  10. package/src/ummaya/engine/query.py +20 -0
  11. package/src/ummaya/evidence/__init__.py +25 -0
  12. package/src/ummaya/evidence/__main__.py +7 -0
  13. package/src/ummaya/evidence/models.py +58 -0
  14. package/src/ummaya/evidence/runner.py +308 -0
  15. package/src/ummaya/evidence/task_registry.py +264 -0
  16. package/src/ummaya/ipc/frame_schema.py +47 -0
  17. package/src/ummaya/ipc/stdio.py +1349 -90
  18. package/src/ummaya/llm/client.py +132 -56
  19. package/src/ummaya/llm/reasoning.py +84 -0
  20. package/src/ummaya/tools/discovery_bridge.py +17 -1
  21. package/src/ummaya/tools/executor.py +32 -12
  22. package/src/ummaya/tools/geocoding/kakao_client.py +1 -2
  23. package/src/ummaya/tools/kma/apihub_catalog.py +984 -1
  24. package/src/ummaya/tools/kma/apihub_structured_adapter.py +86 -6
  25. package/src/ummaya/tools/kma/apihub_url_adapter.py +593 -0
  26. package/src/ummaya/tools/kma/apihub_url_catalog.py +296 -0
  27. package/src/ummaya/tools/location_adapters.py +8 -6
  28. package/src/ummaya/tools/manifest_metadata.py +16 -3
  29. package/src/ummaya/tools/mvp_surface.py +2 -2
  30. package/src/ummaya/tools/nmc/emergency_search.py +8 -6
  31. package/src/ummaya/tools/register_all.py +9 -0
  32. package/src/ummaya/tools/resolve_location.py +4 -4
  33. package/src/ummaya/tools/search.py +664 -18
  34. package/src/ummaya/tools/verified_data_go_kr/_manifest.py +115 -25
  35. package/src/ummaya/tools/verified_data_go_kr/airkorea_air_quality.py +109 -4
  36. package/src/ummaya/tools/verified_data_go_kr/nmc_aed_site.py +108 -2
  37. package/src/ummaya/tools/verified_data_go_kr/pps_bid_public_info.py +174 -9
  38. package/src/ummaya/tools/verified_data_go_kr/tago_bus_arrival.py +66 -3
  39. package/src/ummaya/tools/verified_data_go_kr/tago_bus_location.py +12 -2
  40. package/src/ummaya/tools/verified_data_go_kr/tago_bus_route.py +8 -2
  41. package/src/ummaya/tools/verified_data_go_kr/tago_bus_route_station.py +114 -0
  42. package/src/ummaya/tools/verified_data_go_kr/tago_bus_station.py +14 -3
  43. package/src/ummaya/tools/verify_canonical_map.py +21 -0
  44. package/tui/package.json +1 -2
  45. package/tui/src/QueryEngine.ts +4 -0
  46. package/tui/src/cli/handlers/auth.ts +1 -1
  47. package/tui/src/cli/handlers/mcp.tsx +3 -3
  48. package/tui/src/cli/print.ts +69 -18
  49. package/tui/src/cli/update.ts +13 -13
  50. package/tui/src/commands/copy/index.ts +1 -1
  51. package/tui/src/commands/cost/cost.ts +2 -2
  52. package/tui/src/commands/init-verifiers.ts +5 -5
  53. package/tui/src/commands/init.ts +30 -30
  54. package/tui/src/commands/insights.ts +43 -43
  55. package/tui/src/commands/install-github-app/install-github-app.tsx +2 -2
  56. package/tui/src/commands/install-github-app/setupGitHubActions.ts +3 -3
  57. package/tui/src/commands/install.tsx +5 -5
  58. package/tui/src/commands/mcp/addCommand.ts +5 -5
  59. package/tui/src/commands/mcp/xaaIdpCommand.ts +2 -2
  60. package/tui/src/commands/plugin/ManageMarketplaces.tsx +2 -2
  61. package/tui/src/commands/reasoning/index.ts +13 -0
  62. package/tui/src/commands/reasoning/reasoning.tsx +177 -0
  63. package/tui/src/commands/thinkback/thinkback.tsx +3 -3
  64. package/tui/src/commands.ts +2 -0
  65. package/tui/src/components/Messages.tsx +2 -1
  66. package/tui/src/components/Spinner.tsx +2 -2
  67. package/tui/src/components/design-system/LoadingState.tsx +2 -2
  68. package/tui/src/ipc/codec.ts +26 -0
  69. package/tui/src/ipc/frames.generated.ts +398 -303
  70. package/tui/src/ipc/llmClient.ts +130 -51
  71. package/tui/src/ipc/llmTypes.ts +16 -1
  72. package/tui/src/ipc/schema/frame.schema.json +1 -3475
  73. package/tui/src/main.tsx +3 -0
  74. package/tui/src/query.ts +467 -2
  75. package/tui/src/screens/REPL.tsx +3 -3
  76. package/tui/src/services/api/claude.ts +54 -25
  77. package/tui/src/services/api/client.ts +33 -12
  78. package/tui/src/services/api/ummaya.ts +70 -16
  79. package/tui/src/skills/bundled/stuck.ts +12 -12
  80. package/tui/src/state/AppStateStore.ts +7 -0
  81. package/tui/src/tools/AdapterTool/AdapterTool.ts +590 -7
  82. package/tui/src/tools/LookupPrimitive/LookupPrimitive.ts +43 -17
  83. package/tui/src/tools/LookupPrimitive/prompt.ts +7 -6
  84. package/tui/src/tools/ResolveLocationPrimitive/ResolveLocationPrimitive.ts +40 -19
  85. package/tui/src/tools/SubmitPrimitive/SubmitPrimitive.ts +25 -9
  86. package/tui/src/tools/VerifyPrimitive/VerifyPrimitive.ts +25 -9
  87. package/tui/src/tools/_shared/citizenUserText.ts +49 -0
  88. package/tui/src/tools/_shared/directPublicDataGuard.ts +362 -0
  89. package/tui/src/tools/_shared/kmaAnalysisGuard.ts +197 -0
  90. package/tui/src/tools/_shared/kmaAviationGuard.ts +70 -0
  91. package/tui/src/tools/_shared/locationInputRepair.ts +112 -0
  92. package/tui/src/tools/_shared/nmcAedGuard.ts +234 -0
  93. package/tui/src/tools/_shared/protectedCheckGuard.ts +207 -0
  94. package/tui/src/tools/_shared/rootPrimitiveInput.ts +67 -0
  95. package/tui/src/tools/_shared/textToolCallGuard.ts +91 -0
  96. package/tui/src/tools/_shared/toolChoiceRepair.ts +866 -0
  97. package/tui/src/utils/attachments.ts +1 -1
  98. package/tui/src/utils/kExaoneReasoning.ts +138 -0
  99. package/tui/src/utils/messages.ts +1 -0
  100. package/tui/src/utils/multiToolLayout.ts +13 -0
  101. package/tui/src/utils/processUserInput/processSlashCommand.tsx +2 -2
  102. package/tui/src/utils/processUserInput/processUserInput.ts +26 -0
  103. package/tui/src/utils/settings/applySettingsChange.ts +4 -0
  104. package/tui/src/utils/settings/types.ts +9 -3
  105. package/tui/src/utils/stats.ts +1 -1
  106. package/uv.lock +1 -15
  107. package/assets/copilot-gate-logo.svg +0 -58
  108. package/assets/govon-logo.svg +0 -40
  109. package/src/ummaya/eval/__init__.py +0 -5
  110. package/src/ummaya/eval/retrieval.py +0 -713
  111. package/tui/src/utils/messageStream.ts +0 -186
@@ -0,0 +1,362 @@
1
+ import type { ToolUseContext, ValidationResult } from '../../Tool.js'
2
+ import { resolveAdapter } from '../../services/api/adapterManifest.js'
3
+ import { isNonSyntheticUserMessageText } from './citizenUserText.js'
4
+ import { textFromContent } from './nmcAedGuard.js'
5
+
6
+ const PPS_BID_RE = /(입찰|나라장터|조달청|공고|전기공사|bid|procurement)/iu
7
+ const AIRKOREA_RE =
8
+ /(미세먼지|초미세먼지|대기질|대기오염|마스크|pm\s*2\.?5|pm\s*10|air\s*korea|airkorea)/iu
9
+ const KMA_CHART_RE = /(일기도|분석일기도|지도\s*자료|비구름|바람\s*흐름|synoptic|weather\s*chart)/iu
10
+ const KMA_WEATHER_RE =
11
+ /(날씨|현재\s*기상|실황|관측|예보|기온|습도|풍속|지금\s*비|비\s*(와|오|올|내리)|우산|강수|소나기|산책|퇴근|current\s+weather|forecast|rain|umbrella|precipitation|temperature)/iu
12
+ const TAGO_BUS_RE =
13
+ /(버스|시내버스|정류장|정류소|노선|도착|언제\s*와|몇\s*분|bus|route|arrival|station)/iu
14
+ const TAGO_ROUTE_NO_RE = /(?:^|[^\d])(\d{1,4}(?:-\d)?)\s*번/u
15
+ const KOREAN_SIDO_ABBREVIATIONS: Record<string, string> = {
16
+ 서울: '서울특별시',
17
+ 부산: '부산광역시',
18
+ 대구: '대구광역시',
19
+ 인천: '인천광역시',
20
+ 광주: '광주광역시',
21
+ 대전: '대전광역시',
22
+ 울산: '울산광역시',
23
+ 세종: '세종특별자치시',
24
+ 경기: '경기도',
25
+ 강원: '강원특별자치도',
26
+ 충북: '충청북도',
27
+ 충남: '충청남도',
28
+ 전북: '전북특별자치도',
29
+ 전남: '전라남도',
30
+ 경북: '경상북도',
31
+ 경남: '경상남도',
32
+ 제주: '제주특별자치도',
33
+ }
34
+
35
+ const AIRKOREA_SIDO_ALIASES: Record<string, string> = {
36
+ 서울특별시: '서울',
37
+ 부산광역시: '부산',
38
+ 대구광역시: '대구',
39
+ 인천광역시: '인천',
40
+ 광주광역시: '광주',
41
+ 대전광역시: '대전',
42
+ 울산광역시: '울산',
43
+ 세종특별자치시: '세종',
44
+ 경기도: '경기',
45
+ 강원특별자치도: '강원',
46
+ 강원도: '강원',
47
+ 충청북도: '충북',
48
+ 충청남도: '충남',
49
+ 전북특별자치도: '전북',
50
+ 전라북도: '전북',
51
+ 전라남도: '전남',
52
+ 경상북도: '경북',
53
+ 경상남도: '경남',
54
+ 제주특별자치도: '제주',
55
+ 제주도: '제주',
56
+ }
57
+
58
+ type DirectTarget = {
59
+ toolIds: readonly string[]
60
+ label: string
61
+ hint: string
62
+ }
63
+
64
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
65
+ return typeof value === 'object' && value !== null
66
+ ? (value as Record<string, unknown>)
67
+ : undefined
68
+ }
69
+
70
+ function messageRecord(message: unknown): Record<string, unknown> | undefined {
71
+ return asRecord(asRecord(message)?.message)
72
+ }
73
+
74
+ function messageRole(message: unknown): string | undefined {
75
+ const outer = asRecord(message)
76
+ const inner = messageRecord(message)
77
+ if (typeof inner?.role === 'string') return inner.role
78
+ if (typeof outer?.role === 'string') return outer.role
79
+ return typeof outer?.type === 'string' ? outer.type : undefined
80
+ }
81
+
82
+ function messageContent(message: unknown): unknown {
83
+ return messageRecord(message)?.content ?? asRecord(message)?.content
84
+ }
85
+
86
+ function latestUserText(context: ToolUseContext): string {
87
+ const messages = Array.isArray(context.messages) ? context.messages : []
88
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
89
+ const message = messages[idx]
90
+ if (messageRole(message) !== 'user') continue
91
+ const text = textFromContent(messageContent(message))
92
+ if (isNonSyntheticUserMessageText(message, text)) return text
93
+ }
94
+ return ''
95
+ }
96
+
97
+ function routeNoFromUserText(text: string): string | undefined {
98
+ const match = TAGO_ROUTE_NO_RE.exec(text)
99
+ return match?.[1]
100
+ }
101
+
102
+ function sidoNameFromUserText(text: string): string | undefined {
103
+ for (const fullName of Object.values(KOREAN_SIDO_ABBREVIATIONS)) {
104
+ if (text.includes(fullName)) return fullName
105
+ }
106
+ for (const [shortName, fullName] of Object.entries(KOREAN_SIDO_ABBREVIATIONS)) {
107
+ const pattern = new RegExp(
108
+ `${shortName}(?:시|도|특별시|광역시|특별자치시|특별자치도)?`,
109
+ 'u',
110
+ )
111
+ if (pattern.test(text)) return fullName
112
+ }
113
+ return undefined
114
+ }
115
+
116
+ function airkoreaSidoName(value: unknown): string | undefined {
117
+ if (typeof value !== 'string') return undefined
118
+ const trimmed = value.trim()
119
+ if (!trimmed) return undefined
120
+ return AIRKOREA_SIDO_ALIASES[trimmed] ?? trimmed
121
+ }
122
+
123
+ function ppsCurrentWeekWindow(now = new Date()): {
124
+ start: string
125
+ end: string
126
+ } {
127
+ const kstNow = new Date(now.getTime() + 9 * 60 * 60 * 1000)
128
+ const day = kstNow.getUTCDay()
129
+ const mondayOffset = (day + 6) % 7
130
+ const weekStart = new Date(
131
+ Date.UTC(
132
+ kstNow.getUTCFullYear(),
133
+ kstNow.getUTCMonth(),
134
+ kstNow.getUTCDate() - mondayOffset,
135
+ 0,
136
+ 0,
137
+ ),
138
+ )
139
+ const todayEnd = new Date(
140
+ Date.UTC(
141
+ kstNow.getUTCFullYear(),
142
+ kstNow.getUTCMonth(),
143
+ kstNow.getUTCDate(),
144
+ 23,
145
+ 59,
146
+ ),
147
+ )
148
+ return { start: formatPpsDateTime(weekStart), end: formatPpsDateTime(todayEnd) }
149
+ }
150
+
151
+ function formatPpsDateTime(date: Date): string {
152
+ const year = String(date.getUTCFullYear()).padStart(4, '0')
153
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0')
154
+ const day = String(date.getUTCDate()).padStart(2, '0')
155
+ const hour = String(date.getUTCHours()).padStart(2, '0')
156
+ const minute = String(date.getUTCMinutes()).padStart(2, '0')
157
+ return `${year}${month}${day}${hour}${minute}`
158
+ }
159
+
160
+ function directTargetForUserText(text: string): DirectTarget | undefined {
161
+ if (KMA_CHART_RE.test(text)) {
162
+ return {
163
+ toolIds: ['kma_apihub_url_analysis_weather_chart_image'],
164
+ label: 'kma_apihub_url_analysis_weather_chart_image',
165
+ hint:
166
+ "params must follow the weather chart/map schema: anal_time is required as UTC YYYYMMDDHHMM. Use the latest completed official analysis slot and include minutes, for example '202605281200', not a 10-digit KST hour.",
167
+ }
168
+ }
169
+ if (PPS_BID_RE.test(text)) {
170
+ return {
171
+ toolIds: ['pps_bid_public_info'],
172
+ label: 'pps_bid_public_info',
173
+ hint:
174
+ "params must include inqry_bgn_dt/inqry_end_dt as valid YYYYMMDDHHMM and inqry_div:'1' unless the citizen asks for opening datetime. Keep each PPS call within a 31-day window. Copy citizen keywords into bid_ntce_nm and use region/industry filters only when the citizen supplied them; do not hard-code 전기공사 or 부산광역시 for unrelated procurement questions.",
175
+ }
176
+ }
177
+ if (AIRKOREA_RE.test(text)) {
178
+ return {
179
+ toolIds: ['airkorea_ctprvn_air_quality'],
180
+ label: 'airkorea_ctprvn_air_quality',
181
+ hint:
182
+ "params should include sido_name:'부산', plus optional page_no/num_of_rows/ver exactly as the adapter schema exposes.",
183
+ }
184
+ }
185
+ if (TAGO_BUS_RE.test(text)) {
186
+ return {
187
+ toolIds: [
188
+ 'tago_bus_station_search',
189
+ 'tago_bus_arrival_search',
190
+ 'tago_bus_route_search',
191
+ 'tago_bus_route_station_search',
192
+ 'tago_bus_location_search',
193
+ ],
194
+ label: 'TAGO bus adapters',
195
+ hint:
196
+ "use TAGO bus schemas for bus arrival/route requests. If a route number is named, call tago_bus_route_search for route_id, then tago_bus_route_station_search with route_id and node_nm to find passing stops. Call tago_bus_arrival_search with city_code, node_id, and route_no or route_id. If node_id is unknown, tago_bus_station_search can list nearby named stops.",
197
+ }
198
+ }
199
+ if (KMA_WEATHER_RE.test(text)) {
200
+ return {
201
+ toolIds: [
202
+ 'kakao_keyword_search',
203
+ 'kakao_address_search',
204
+ 'kakao_coord_to_region',
205
+ 'kma_current_observation',
206
+ 'kma_ultra_short_term_forecast',
207
+ 'kma_short_term_forecast',
208
+ ],
209
+ label: 'KMA weather/location adapters',
210
+ hint:
211
+ 'use a location adapter first when coordinates are missing, then KMA current-observation or forecast schemas for rain/umbrella/current-weather values. Do not use air-quality or bus adapters for ordinary weather.',
212
+ }
213
+ }
214
+ return undefined
215
+ }
216
+
217
+ function hasAnyRegisteredTarget(target: DirectTarget): boolean {
218
+ return target.toolIds.some(toolId => resolveAdapter(toolId))
219
+ }
220
+
221
+ const KMA_CHART_PARAM_NAMES = new Set([
222
+ 'anal_time',
223
+ 'is_typ',
224
+ 'image_type',
225
+ 'group_name',
226
+ 'meta',
227
+ ])
228
+ const TAGO_TOOL_RE = /^tago_bus_/u
229
+
230
+ function validateTargetParams(
231
+ target: DirectTarget,
232
+ toolId: string,
233
+ params: unknown,
234
+ userText: string,
235
+ ): ValidationResult | undefined {
236
+ const record = asRecord(params) ?? {}
237
+ if (target.toolIds[0] === 'kma_apihub_url_analysis_weather_chart_image' && toolId === target.toolIds[0]) {
238
+ const extra = Object.keys(record).filter(key => !KMA_CHART_PARAM_NAMES.has(key))
239
+ const analTime = record.anal_time
240
+ if (extra.length > 0 || typeof analTime !== 'string' || !/^\d{12}$/u.test(analTime)) {
241
+ return {
242
+ result: false,
243
+ message:
244
+ 'KMA analysis weather-chart schema mismatch: call kma_apihub_url_analysis_weather_chart_image with chart params only. ' +
245
+ "Use anal_time as UTC YYYYMMDDHHMM, for example {\"anal_time\":\"202605281200\"}. " +
246
+ `Do not add non-chart fields such as ${extra.length > 0 ? extra.join(', ') : 'org'} or append UTC text inside the value.`,
247
+ errorCode: 1,
248
+ }
249
+ }
250
+ }
251
+
252
+ if (TAGO_TOOL_RE.test(toolId) && /부산|busan/iu.test(userText)) {
253
+ const cityCode = record.city_code
254
+ if (cityCode !== '21') {
255
+ return {
256
+ result: false,
257
+ message:
258
+ 'TAGO city_code mismatch: the citizen request is for Busan, and the official TAGO getCtyCodeList mapping is Busan=21. ' +
259
+ `Re-call the TAGO bus adapter with city_code:"21"; do not use non-Busan city_code ${String(cityCode)} for 부산.`,
260
+ errorCode: 1,
261
+ }
262
+ }
263
+ }
264
+
265
+ if (toolId === 'tago_bus_arrival_search') {
266
+ const routeNo = routeNoFromUserText(userText)
267
+ const routeNoParam = record.route_no
268
+ const routeIdParam = record.route_id
269
+ if (
270
+ routeNo !== undefined &&
271
+ typeof routeNoParam !== 'string' &&
272
+ typeof routeIdParam !== 'string'
273
+ ) {
274
+ return {
275
+ result: false,
276
+ message:
277
+ `TAGO arrival route filter missing: the citizen asked for route ${routeNo}. ` +
278
+ `Re-call tago_bus_arrival_search with route_no:"${routeNo}" or a route_id ` +
279
+ 'from tago_bus_route_search so the result is filtered against TAGO routeno/routeid. ' +
280
+ 'When a place is also named, use tago_bus_route_station_search to find the matching nodeid.',
281
+ errorCode: 1,
282
+ }
283
+ }
284
+ }
285
+
286
+ if (toolId === 'tago_bus_station_search' && typeof record.route_id === 'string') {
287
+ return {
288
+ result: false,
289
+ message:
290
+ 'TAGO station schema mismatch: route_id is for route passing-stop lookup, not stop-name search. ' +
291
+ 'Call tago_bus_route_station_search with city_code, route_id, and node_nm to find nodeid values on a route.',
292
+ errorCode: 1,
293
+ }
294
+ }
295
+
296
+ return undefined
297
+ }
298
+
299
+ export function normalizeDirectPublicDataToolInput(
300
+ toolId: string,
301
+ context: ToolUseContext,
302
+ input: unknown,
303
+ ): unknown {
304
+ const userText = latestUserText(context)
305
+ const record = asRecord(input)
306
+ if (!record) return input
307
+ if (toolId === 'airkorea_ctprvn_air_quality') {
308
+ const normalized: Record<string, unknown> = { ...record }
309
+ const fromInput = airkoreaSidoName(normalized.sido_name)
310
+ const fromUser = airkoreaSidoName(sidoNameFromUserText(userText))
311
+ const sidoName = fromInput ?? fromUser
312
+ if (sidoName) normalized.sido_name = sidoName
313
+ if (
314
+ typeof normalized.num_of_rows !== 'number' ||
315
+ normalized.num_of_rows < 100
316
+ ) {
317
+ normalized.num_of_rows = 100
318
+ }
319
+ return normalized
320
+ }
321
+ if (toolId !== 'pps_bid_public_info') return input
322
+ if (!PPS_BID_RE.test(userText)) return input
323
+ const normalized: Record<string, unknown> = { ...record }
324
+ if (/이번\s*주/u.test(userText)) {
325
+ const window = ppsCurrentWeekWindow()
326
+ normalized.inqry_bgn_dt = window.start
327
+ normalized.inqry_end_dt = window.end
328
+ normalized.inqry_div = '1'
329
+ }
330
+ if (/전기\s*공사/iu.test(userText)) {
331
+ normalized.bid_ntce_nm = '전기공사'
332
+ normalized.indstryty_nm = '전기공사업'
333
+ }
334
+ const regionName = sidoNameFromUserText(userText)
335
+ if (regionName) {
336
+ normalized.prtcpt_lmt_rgn_nm = regionName
337
+ normalized.region_name = regionName
338
+ }
339
+ return normalized
340
+ }
341
+
342
+ export function validateDirectPublicDataToolChoice(
343
+ toolId: string,
344
+ context: ToolUseContext,
345
+ params?: unknown,
346
+ ): ValidationResult | undefined {
347
+ const userText = latestUserText(context)
348
+ const target = directTargetForUserText(userText)
349
+ if (!target) return undefined
350
+ if (target.toolIds.includes(toolId)) {
351
+ return validateTargetParams(target, toolId, params, userText)
352
+ }
353
+ if (!hasAnyRegisteredTarget(target)) return undefined
354
+ return {
355
+ result: false,
356
+ message:
357
+ `Public-data tool-choice mismatch: the latest citizen request matches ${target.label}. ` +
358
+ `Call ${target.label} through the correct primitive instead of ${toolId}. ` +
359
+ target.hint,
360
+ errorCode: 1,
361
+ }
362
+ }
@@ -0,0 +1,197 @@
1
+ import type { ToolUseContext, ValidationResult } from '../../Tool.js'
2
+ import { isNonSyntheticUserMessageText } from './citizenUserText.js'
3
+
4
+ export const KMA_ANALYSIS_CHART_TOOL_NAME = 'kma_apihub_url_analysis_weather_chart_image'
5
+ const KMA_ANALYSIS_POINT_TOOL_NAMES = new Set([
6
+ 'kma_apihub_url_high_resolution_grid_point',
7
+ 'kma_apihub_url_aws_objective_analysis_grid',
8
+ ])
9
+
10
+ const KMA_ANALYSIS_MAP_RE =
11
+ /(일기도|분석일기도|지도\s*자료|비구름|바람\s*흐름|synoptic|weather\s*chart)/iu
12
+ const KMA_ANALYSIS_COMPLETION_PROMPT =
13
+ 'KMA analyzed weather-chart evidence chain complete: kma_apihub_url_analysis_weather_chart_image has already been attempted for this 일기도/지도/비구름/바람 흐름 request. The generic tool error hint is not permission to call or print a different tool in this turn. Do not emit <tool_call> text, JSON tool-call text, JSON with name/arguments, or point-grid/current-weather substitute requests. Write the final Korean answer now from the actual chart-tool result only. If the result is approval_state=approved with content_type=image/png or raw_format=image, say the official KMA chart image lookup succeeded, but do not infer rain-cloud or wind-flow details unless the tool result contains decoded chart semantics. If the result is APIHub approval-required, 403, no-data, or another upstream failure, report that failure directly and cite the official KMA channel as the handoff path.'
14
+ const KMA_ANALYSIS_REPAIR_PROMPT =
15
+ 'KMA analyzed weather-chart final-answer repair: the previous assistant message was invalid because it printed tool-call text, asked for location/tool follow-up, or inferred chart semantics after the chart tool result. Do not call or print any tool. Do not ask for coordinates, a region, or another lookup. Write one Korean prose answer only. Use the existing kma_apihub_url_analysis_weather_chart_image result: if it says approval_state=approved and content_type=image/png or raw_format=image, state that the official KMA analyzed chart image was fetched successfully, and state that this text-only adapter result does not decode the chart pixels into rain-cloud or wind-flow semantics. Do not invent meteorological interpretation from the image.'
16
+ const KMA_ANALYSIS_MISSING_TOOL_PROMPT =
17
+ 'Required KMA analyzed weather-chart lookup: the citizen asked for 전국 기상도, 위성 자료, 비구름 흐름, 바람 흐름, or analyzed map evidence. This is not a place-specific weather question, so do not ask for a location or coordinates. Before final prose, call kma_apihub_url_analysis_weather_chart_image directly if it is available. If only the legacy find primitive is available, call find with tool_id:"kma_apihub_url_analysis_weather_chart_image" and schema-valid chart params, including anal_time as the latest completed UTC YYYYMMDDHHMM slot. If APIHub returns approval-required, no-data, or another upstream failure, report that failure directly.'
18
+ const KMA_ANALYSIS_TOOL_CALL_TEXT_RE =
19
+ /<tool_call>|"name"\s*:\s*"kma_apihub_url_analysis_weather_chart_image"|"arguments"\s*:\s*\{/iu
20
+ const KMA_ANALYSIS_INVALID_FOLLOWUP_TEXT_RE =
21
+ /(현재\s*위치|위치\s*정보|정확한\s*좌표|지역명|알려주시면|어떤\s*방식|카카오|도구를\s*사용|확인하실\s*수\s*있습니다)/iu
22
+
23
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
24
+ return typeof value === 'object' && value !== null
25
+ ? (value as Record<string, unknown>)
26
+ : undefined
27
+ }
28
+
29
+ function messageRecord(message: unknown): Record<string, unknown> | undefined {
30
+ return asRecord(asRecord(message)?.message)
31
+ }
32
+
33
+ function messageRole(message: unknown): string | undefined {
34
+ const record = asRecord(message)
35
+ const inner = messageRecord(message)
36
+ if (typeof inner?.role === 'string') return inner.role
37
+ if (typeof record?.role === 'string') return record.role
38
+ return typeof record?.type === 'string' ? record.type : undefined
39
+ }
40
+
41
+ function messageContent(message: unknown): unknown {
42
+ return messageRecord(message)?.content ?? asRecord(message)?.content
43
+ }
44
+
45
+ function textFromContent(content: unknown): string {
46
+ if (typeof content === 'string') return content
47
+ if (!Array.isArray(content)) return ''
48
+ return content
49
+ .map(block => {
50
+ if (typeof block === 'string') return block
51
+ if (typeof block !== 'object' || block === null) return ''
52
+ const record = block as Record<string, unknown>
53
+ return typeof record.text === 'string' ? record.text : ''
54
+ })
55
+ .filter(Boolean)
56
+ .join('\n')
57
+ }
58
+
59
+ function latestUserText(context: ToolUseContext): string {
60
+ const messages = Array.isArray(context.messages) ? context.messages : []
61
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
62
+ const message = messages[idx]
63
+ if (messageRole(message) !== 'user') continue
64
+ const text = textFromContent(messageContent(message))
65
+ if (isNonSyntheticUserMessageText(message, text)) return text
66
+ }
67
+ return ''
68
+ }
69
+
70
+ function userTextFromMessages(messages: readonly unknown[]): string {
71
+ return messages
72
+ .filter(message => messageRole(message) === 'user')
73
+ .map(message => ({ message, text: textFromContent(messageContent(message)) }))
74
+ .filter(({ message, text }) => isNonSyntheticUserMessageText(message, text))
75
+ .map(({ text }) => text)
76
+ .join('\n')
77
+ }
78
+
79
+ function hasToolUse(messages: readonly unknown[], toolName: string): boolean {
80
+ for (const message of messages) {
81
+ const content = messageContent(message)
82
+ if (!Array.isArray(content)) continue
83
+ for (const block of content) {
84
+ const record = asRecord(block)
85
+ if (record?.type !== 'tool_use') continue
86
+ const input = asRecord(record.input)
87
+ const nestedToolName =
88
+ typeof input?.tool_id === 'string' ? input.tool_id : undefined
89
+ const name = nestedToolName ?? record.name
90
+ if (name === toolName) return true
91
+ }
92
+ }
93
+ return false
94
+ }
95
+
96
+ function latestAssistantText(messages: readonly unknown[]): string {
97
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
98
+ const message = messages[idx]
99
+ if (messageRole(message) !== 'assistant') continue
100
+ const text = textFromContent(messageContent(message))
101
+ if (text.trim()) return text
102
+ }
103
+ return ''
104
+ }
105
+
106
+ function hasRepairPrompt(messages: readonly unknown[]): boolean {
107
+ return messages.some(message =>
108
+ textFromContent(messageContent(message)).includes(
109
+ 'KMA analyzed weather-chart final-answer repair',
110
+ ),
111
+ )
112
+ }
113
+
114
+ function hasMissingToolPrompt(messages: readonly unknown[]): boolean {
115
+ return messages.some(message =>
116
+ textFromContent(messageContent(message)).includes(
117
+ 'Required KMA analyzed weather-chart lookup',
118
+ ),
119
+ )
120
+ }
121
+
122
+ export function isKmaAnalysisMapText(text: string): boolean {
123
+ return KMA_ANALYSIS_MAP_RE.test(text)
124
+ }
125
+
126
+ export function validateKmaAnalysisToolChoice(
127
+ toolId: string,
128
+ context: ToolUseContext,
129
+ ): ValidationResult | undefined {
130
+ if (!KMA_ANALYSIS_POINT_TOOL_NAMES.has(toolId)) return undefined
131
+ const userText = latestUserText(context)
132
+ if (!isKmaAnalysisMapText(userText)) return undefined
133
+ return {
134
+ result: false,
135
+ message:
136
+ 'KMA analysis tool-choice mismatch: the latest citizen request asks for analyzed weather chart/map evidence. Call ' +
137
+ `${KMA_ANALYSIS_CHART_TOOL_NAME} for 일기도/지도/비구름/바람 흐름 requests. ` +
138
+ 'If APIHub returns approval-required or another upstream error, report that failure directly and do not substitute point-grid data.',
139
+ errorCode: 1,
140
+ }
141
+ }
142
+
143
+ export function buildKmaAnalysisCompletionPromptIfNeeded({
144
+ messages,
145
+ }: {
146
+ messages: readonly unknown[]
147
+ }): string | undefined {
148
+ if (!isKmaAnalysisMapText(userTextFromMessages(messages))) return undefined
149
+ if (!hasToolUse(messages, KMA_ANALYSIS_CHART_TOOL_NAME)) return undefined
150
+ return KMA_ANALYSIS_COMPLETION_PROMPT
151
+ }
152
+
153
+ export function buildKmaAnalysisFinalAnswerRepairPromptIfNeeded({
154
+ messages,
155
+ }: {
156
+ messages: readonly unknown[]
157
+ }): string | undefined {
158
+ if (!isKmaAnalysisMapText(userTextFromMessages(messages))) return undefined
159
+ if (!hasToolUse(messages, KMA_ANALYSIS_CHART_TOOL_NAME)) return undefined
160
+ if (hasRepairPrompt(messages)) return undefined
161
+ const assistantText = latestAssistantText(messages)
162
+ if (
163
+ !KMA_ANALYSIS_TOOL_CALL_TEXT_RE.test(assistantText) &&
164
+ !KMA_ANALYSIS_INVALID_FOLLOWUP_TEXT_RE.test(assistantText)
165
+ ) {
166
+ return undefined
167
+ }
168
+ return KMA_ANALYSIS_REPAIR_PROMPT
169
+ }
170
+
171
+ export function buildKmaAnalysisMissingToolPromptIfNeeded({
172
+ messages,
173
+ }: {
174
+ messages: readonly unknown[]
175
+ }): string | undefined {
176
+ if (!isKmaAnalysisMapText(userTextFromMessages(messages))) return undefined
177
+ if (hasToolUse(messages, KMA_ANALYSIS_CHART_TOOL_NAME)) return undefined
178
+ if (hasMissingToolPrompt(messages)) return undefined
179
+ const assistantText = latestAssistantText(messages)
180
+ if (!assistantText.trim()) return undefined
181
+ return KMA_ANALYSIS_MISSING_TOOL_PROMPT
182
+ }
183
+
184
+ export function shouldWithholdKmaAnalysisToolCallText({
185
+ messages,
186
+ candidate,
187
+ }: {
188
+ messages: readonly unknown[]
189
+ candidate: unknown
190
+ }): boolean {
191
+ if (hasRepairPrompt(messages)) return false
192
+ return (
193
+ buildKmaAnalysisFinalAnswerRepairPromptIfNeeded({
194
+ messages: [...messages, candidate],
195
+ }) !== undefined
196
+ )
197
+ }
@@ -0,0 +1,70 @@
1
+ import type { ToolUseContext, ValidationResult } from '../../Tool.js'
2
+ import { listAdapters } from '../../services/api/adapterManifest.js'
3
+ import { textFromContent } from './nmcAedGuard.js'
4
+ import { isNonSyntheticUserMessageText } from './citizenUserText.js'
5
+
6
+ const KMA_AIR_TOOL_NAMES = new Set([
7
+ 'kma_apihub_url_air_amos_minute',
8
+ 'kma_apihub_url_air_metar_decoded',
9
+ ])
10
+ const ORDINARY_WEATHER_TOOL_NAMES = new Set([
11
+ 'kma_current_observation',
12
+ 'kma_ultra_short_term_forecast',
13
+ 'kma_short_term_forecast',
14
+ ])
15
+ const LOCATION_TOOL_NAMES = new Set([
16
+ 'kakao_keyword_search',
17
+ 'kakao_address_search',
18
+ 'kakao_coord_to_region',
19
+ ])
20
+ const AIRPORT_PLACE_RE =
21
+ /(김해|김포|김해공항|김포공항|gimhae|gimpo|rkpk|rkss|\bairport\b|공항)/iu
22
+ const AIRPORT_AVIATION_RE =
23
+ /(비행기|항공편|비행편|운항|이륙|착륙|결항|지연|뜰\s*만|뜨나|뜰\s*수|flight|take\s*off|landing|delay|cancel|metar|speci|amos|rvr|활주로|시정|visibility|공항기상|항공기상)/iu
24
+
25
+ function latestUserText(context: ToolUseContext): string {
26
+ const messages = Array.isArray(context.messages) ? context.messages : []
27
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
28
+ const message = messages[idx] as Record<string, unknown>
29
+ const inner = message.message as Record<string, unknown> | undefined
30
+ const role =
31
+ typeof inner?.role === 'string'
32
+ ? inner.role
33
+ : typeof message.role === 'string'
34
+ ? message.role
35
+ : message.type
36
+ if (role !== 'user') continue
37
+ const text = textFromContent(inner?.content ?? message.content)
38
+ if (isNonSyntheticUserMessageText(message, text)) return text
39
+ }
40
+ return ''
41
+ }
42
+
43
+ function isAirportAviationQuery(text: string): boolean {
44
+ return AIRPORT_PLACE_RE.test(text) && AIRPORT_AVIATION_RE.test(text)
45
+ }
46
+
47
+ function hasAviationAdapter(): boolean {
48
+ return listAdapters().some(entry => KMA_AIR_TOOL_NAMES.has(entry.tool_id))
49
+ }
50
+
51
+ export function validateKmaAviationToolChoice(
52
+ toolId: string,
53
+ context: ToolUseContext,
54
+ ): ValidationResult | undefined {
55
+ if (KMA_AIR_TOOL_NAMES.has(toolId)) return undefined
56
+ if (!LOCATION_TOOL_NAMES.has(toolId) && !ORDINARY_WEATHER_TOOL_NAMES.has(toolId)) {
57
+ return undefined
58
+ }
59
+ const userText = latestUserText(context)
60
+ if (!isAirportAviationQuery(userText)) return undefined
61
+ if (!hasAviationAdapter()) return undefined
62
+ return {
63
+ result: false,
64
+ message:
65
+ 'KMA aviation tool-choice mismatch: the latest citizen request asks for airport METAR/AMOS aviation evidence such as flight operation, wind, runway, RVR, or visibility. ' +
66
+ 'Call kma_apihub_url_air_metar_decoded for airport METAR/시정/풍향/풍속 evidence, or kma_apihub_url_air_amos_minute for AMOS runway-minute evidence when that airport/station is documented. ' +
67
+ 'Do not call locate or ordinary KMA current-observation tools first because they do not provide airport METAR/AMOS visibility evidence.',
68
+ errorCode: 1,
69
+ }
70
+ }