openclaw-server 0.1.0
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.
- package/package.json +29 -0
- package/packs/default/faq.yaml +8 -0
- package/packs/default/intents.yaml +19 -0
- package/packs/default/pack.yaml +12 -0
- package/packs/default/policies.yaml +1 -0
- package/packs/default/scenarios.yaml +1 -0
- package/packs/default/synonyms.yaml +1 -0
- package/packs/default/templates.yaml +16 -0
- package/packs/default/tools.yaml +1 -0
- package/readme.md +1219 -0
- package/src/auth.ts +24 -0
- package/src/better-sqlite3.d.ts +17 -0
- package/src/config.ts +63 -0
- package/src/core/matcher.ts +214 -0
- package/src/core/normalizer.test.ts +37 -0
- package/src/core/normalizer.ts +183 -0
- package/src/core/pack-loader.ts +97 -0
- package/src/core/reply-engine.test.ts +76 -0
- package/src/core/reply-engine.ts +256 -0
- package/src/core/request-adapter.ts +65 -0
- package/src/core/session-store.ts +48 -0
- package/src/core/stream-renderer.ts +237 -0
- package/src/core/tool-engine.ts +60 -0
- package/src/debug-log.ts +211 -0
- package/src/index.ts +23 -0
- package/src/openai.ts +79 -0
- package/src/response-api.ts +107 -0
- package/src/routes/admin.ts +32 -0
- package/src/routes/chat-completions.ts +173 -0
- package/src/routes/health.ts +7 -0
- package/src/routes/models.ts +21 -0
- package/src/routes/request-validation.ts +33 -0
- package/src/routes/responses.ts +182 -0
- package/src/routes/tasks.ts +138 -0
- package/src/runtime-stats.ts +80 -0
- package/src/server.test.ts +776 -0
- package/src/server.ts +108 -0
- package/src/tasks/chat-integration.ts +70 -0
- package/src/tasks/service.ts +320 -0
- package/src/tasks/store.test.ts +183 -0
- package/src/tasks/store.ts +602 -0
- package/src/tasks/time-parser.test.ts +94 -0
- package/src/tasks/time-parser.ts +610 -0
- package/src/tasks/timezone.ts +171 -0
- package/src/tasks/types.ts +128 -0
- package/src/types.ts +202 -0
- package/src/weather/chat-integration.ts +56 -0
- package/src/weather/location-catalog.ts +166 -0
- package/src/weather/open-meteo-provider.ts +221 -0
- package/src/weather/parser.test.ts +23 -0
- package/src/weather/parser.ts +102 -0
- package/src/weather/service.test.ts +54 -0
- package/src/weather/service.ts +188 -0
- package/src/weather/types.ts +56 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { WeatherLocation } from "./types.js";
|
|
2
|
+
|
|
3
|
+
type BuiltInWeatherLocation = WeatherLocation & {
|
|
4
|
+
aliases: string[];
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const builtInWeatherLocations: BuiltInWeatherLocation[] = [
|
|
8
|
+
{
|
|
9
|
+
name: "天津",
|
|
10
|
+
latitude: 39.0851,
|
|
11
|
+
longitude: 117.1994,
|
|
12
|
+
timezone: "Asia/Shanghai",
|
|
13
|
+
country: "CN",
|
|
14
|
+
admin1: "天津",
|
|
15
|
+
aliases: ["天津", "天津市", "tianjin"],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "北京",
|
|
19
|
+
latitude: 39.9042,
|
|
20
|
+
longitude: 116.4074,
|
|
21
|
+
timezone: "Asia/Shanghai",
|
|
22
|
+
country: "CN",
|
|
23
|
+
admin1: "北京",
|
|
24
|
+
aliases: ["北京", "北京市", "beijing"],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "上海",
|
|
28
|
+
latitude: 31.2304,
|
|
29
|
+
longitude: 121.4737,
|
|
30
|
+
timezone: "Asia/Shanghai",
|
|
31
|
+
country: "CN",
|
|
32
|
+
admin1: "上海",
|
|
33
|
+
aliases: ["上海", "上海市", "shanghai"],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "重庆",
|
|
37
|
+
latitude: 29.4316,
|
|
38
|
+
longitude: 106.9123,
|
|
39
|
+
timezone: "Asia/Shanghai",
|
|
40
|
+
country: "CN",
|
|
41
|
+
admin1: "重庆",
|
|
42
|
+
aliases: ["重庆", "重庆市", "chongqing"],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "广州",
|
|
46
|
+
latitude: 23.1291,
|
|
47
|
+
longitude: 113.2644,
|
|
48
|
+
timezone: "Asia/Shanghai",
|
|
49
|
+
country: "CN",
|
|
50
|
+
admin1: "广东",
|
|
51
|
+
aliases: ["广州", "广州市", "guangzhou"],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "深圳",
|
|
55
|
+
latitude: 22.5431,
|
|
56
|
+
longitude: 114.0579,
|
|
57
|
+
timezone: "Asia/Shanghai",
|
|
58
|
+
country: "CN",
|
|
59
|
+
admin1: "广东",
|
|
60
|
+
aliases: ["深圳", "深圳市", "shenzhen"],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "成都",
|
|
64
|
+
latitude: 30.5728,
|
|
65
|
+
longitude: 104.0668,
|
|
66
|
+
timezone: "Asia/Shanghai",
|
|
67
|
+
country: "CN",
|
|
68
|
+
admin1: "四川",
|
|
69
|
+
aliases: ["成都", "成都市", "chengdu"],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "杭州",
|
|
73
|
+
latitude: 30.2741,
|
|
74
|
+
longitude: 120.1551,
|
|
75
|
+
timezone: "Asia/Shanghai",
|
|
76
|
+
country: "CN",
|
|
77
|
+
admin1: "浙江",
|
|
78
|
+
aliases: ["杭州", "杭州市", "hangzhou"],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "南京",
|
|
82
|
+
latitude: 32.0603,
|
|
83
|
+
longitude: 118.7969,
|
|
84
|
+
timezone: "Asia/Shanghai",
|
|
85
|
+
country: "CN",
|
|
86
|
+
admin1: "江苏",
|
|
87
|
+
aliases: ["南京", "南京市", "nanjing"],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "武汉",
|
|
91
|
+
latitude: 30.5928,
|
|
92
|
+
longitude: 114.3055,
|
|
93
|
+
timezone: "Asia/Shanghai",
|
|
94
|
+
country: "CN",
|
|
95
|
+
admin1: "湖北",
|
|
96
|
+
aliases: ["武汉", "武汉市", "wuhan"],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "西安",
|
|
100
|
+
latitude: 34.3416,
|
|
101
|
+
longitude: 108.9398,
|
|
102
|
+
timezone: "Asia/Shanghai",
|
|
103
|
+
country: "CN",
|
|
104
|
+
admin1: "陕西",
|
|
105
|
+
aliases: ["西安", "西安市", "xian", "xi an"],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "香港",
|
|
109
|
+
latitude: 22.3193,
|
|
110
|
+
longitude: 114.1694,
|
|
111
|
+
timezone: "Asia/Hong_Kong",
|
|
112
|
+
country: "HK",
|
|
113
|
+
admin1: "香港",
|
|
114
|
+
aliases: ["香港", "hong kong"],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "台北",
|
|
118
|
+
latitude: 25.033,
|
|
119
|
+
longitude: 121.5654,
|
|
120
|
+
timezone: "Asia/Taipei",
|
|
121
|
+
country: "TW",
|
|
122
|
+
admin1: "台北",
|
|
123
|
+
aliases: ["台北", "taipei"],
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
function normalizeAlias(text: string): string {
|
|
128
|
+
return text
|
|
129
|
+
.normalize("NFKC")
|
|
130
|
+
.toLowerCase()
|
|
131
|
+
.replace(/[^\p{L}\p{N}]+/gu, "");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function matchBuiltInWeatherLocation(text: string): WeatherLocation | undefined {
|
|
135
|
+
const normalized = normalizeAlias(text);
|
|
136
|
+
if (!normalized) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let bestMatch: BuiltInWeatherLocation | undefined;
|
|
141
|
+
let bestLength = 0;
|
|
142
|
+
for (const location of builtInWeatherLocations) {
|
|
143
|
+
for (const alias of location.aliases) {
|
|
144
|
+
const normalizedAlias = normalizeAlias(alias);
|
|
145
|
+
if (!normalizedAlias || !normalized.includes(normalizedAlias)) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (normalizedAlias.length <= bestLength) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
bestMatch = location;
|
|
152
|
+
bestLength = normalizedAlias.length;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!bestMatch) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { aliases: _aliases, ...location } = bestMatch;
|
|
161
|
+
return location;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function listBuiltInWeatherAliases(): string[] {
|
|
165
|
+
return builtInWeatherLocations.flatMap((location) => location.aliases);
|
|
166
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { matchBuiltInWeatherLocation } from "./location-catalog.js";
|
|
2
|
+
import type { WeatherDay, WeatherForecast, WeatherLocation, WeatherProvider } from "./types.js";
|
|
3
|
+
|
|
4
|
+
type FetchLike = typeof fetch;
|
|
5
|
+
|
|
6
|
+
type OpenMeteoGeocodeResponse = {
|
|
7
|
+
results?: Array<{
|
|
8
|
+
name?: string;
|
|
9
|
+
latitude?: number;
|
|
10
|
+
longitude?: number;
|
|
11
|
+
timezone?: string;
|
|
12
|
+
country_code?: string;
|
|
13
|
+
admin1?: string;
|
|
14
|
+
}>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type OpenMeteoForecastResponse = {
|
|
18
|
+
daily?: {
|
|
19
|
+
time?: string[];
|
|
20
|
+
weather_code?: number[];
|
|
21
|
+
temperature_2m_max?: number[];
|
|
22
|
+
temperature_2m_min?: number[];
|
|
23
|
+
precipitation_probability_max?: number[];
|
|
24
|
+
precipitation_sum?: number[];
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function dayOffset(day: WeatherDay): number {
|
|
29
|
+
switch (day) {
|
|
30
|
+
case "today":
|
|
31
|
+
return 0;
|
|
32
|
+
case "tomorrow":
|
|
33
|
+
return 1;
|
|
34
|
+
case "day_after_tomorrow":
|
|
35
|
+
return 2;
|
|
36
|
+
case "in_three_days":
|
|
37
|
+
return 3;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function dayLabel(day: WeatherDay): string {
|
|
42
|
+
switch (day) {
|
|
43
|
+
case "today":
|
|
44
|
+
return "今天";
|
|
45
|
+
case "tomorrow":
|
|
46
|
+
return "明天";
|
|
47
|
+
case "day_after_tomorrow":
|
|
48
|
+
return "后天";
|
|
49
|
+
case "in_three_days":
|
|
50
|
+
return "大后天";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function describeWeatherCode(code: number | undefined): string {
|
|
55
|
+
switch (code) {
|
|
56
|
+
case 0:
|
|
57
|
+
return "晴";
|
|
58
|
+
case 1:
|
|
59
|
+
return "晴间多云";
|
|
60
|
+
case 2:
|
|
61
|
+
return "多云";
|
|
62
|
+
case 3:
|
|
63
|
+
return "阴";
|
|
64
|
+
case 45:
|
|
65
|
+
case 48:
|
|
66
|
+
return "有雾";
|
|
67
|
+
case 51:
|
|
68
|
+
case 53:
|
|
69
|
+
case 55:
|
|
70
|
+
return "毛毛雨";
|
|
71
|
+
case 56:
|
|
72
|
+
case 57:
|
|
73
|
+
return "冻毛毛雨";
|
|
74
|
+
case 61:
|
|
75
|
+
case 63:
|
|
76
|
+
case 65:
|
|
77
|
+
return "有雨";
|
|
78
|
+
case 66:
|
|
79
|
+
case 67:
|
|
80
|
+
return "冻雨";
|
|
81
|
+
case 71:
|
|
82
|
+
case 73:
|
|
83
|
+
case 75:
|
|
84
|
+
return "有雪";
|
|
85
|
+
case 77:
|
|
86
|
+
return "阵雪";
|
|
87
|
+
case 80:
|
|
88
|
+
case 81:
|
|
89
|
+
case 82:
|
|
90
|
+
return "阵雨";
|
|
91
|
+
case 85:
|
|
92
|
+
case 86:
|
|
93
|
+
return "阵雪";
|
|
94
|
+
case 95:
|
|
95
|
+
return "雷暴";
|
|
96
|
+
case 96:
|
|
97
|
+
case 99:
|
|
98
|
+
return "强对流";
|
|
99
|
+
default:
|
|
100
|
+
return "天气多变";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function readJson<T>(response: Response): Promise<T> {
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
throw new Error(`Weather upstream failed with status ${response.status}`);
|
|
107
|
+
}
|
|
108
|
+
return (await response.json()) as T;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderLocationName(location: WeatherLocation): string {
|
|
112
|
+
if (location.admin1 && location.admin1 !== location.name) {
|
|
113
|
+
return `${location.name}`;
|
|
114
|
+
}
|
|
115
|
+
return location.name;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class OpenMeteoWeatherProvider implements WeatherProvider {
|
|
119
|
+
constructor(
|
|
120
|
+
private readonly options: {
|
|
121
|
+
fetchImpl?: FetchLike;
|
|
122
|
+
geocodingBaseUrl?: string;
|
|
123
|
+
forecastBaseUrl?: string;
|
|
124
|
+
timeoutMs?: number;
|
|
125
|
+
} = {},
|
|
126
|
+
) {}
|
|
127
|
+
|
|
128
|
+
async lookupForecast(params: {
|
|
129
|
+
locationQuery: string;
|
|
130
|
+
day: WeatherDay;
|
|
131
|
+
}): Promise<WeatherForecast> {
|
|
132
|
+
const location = await this.resolveLocation(params.locationQuery);
|
|
133
|
+
const offset = dayOffset(params.day);
|
|
134
|
+
const forecastUrl = new URL(
|
|
135
|
+
this.options.forecastBaseUrl ?? "https://api.open-meteo.com/v1/forecast",
|
|
136
|
+
);
|
|
137
|
+
forecastUrl.searchParams.set("latitude", String(location.latitude));
|
|
138
|
+
forecastUrl.searchParams.set("longitude", String(location.longitude));
|
|
139
|
+
forecastUrl.searchParams.set(
|
|
140
|
+
"daily",
|
|
141
|
+
"weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum",
|
|
142
|
+
);
|
|
143
|
+
forecastUrl.searchParams.set("forecast_days", String(offset + 1));
|
|
144
|
+
forecastUrl.searchParams.set("timezone", location.timezone || "auto");
|
|
145
|
+
|
|
146
|
+
const forecast = await this.fetchJson<OpenMeteoForecastResponse>(forecastUrl);
|
|
147
|
+
const daily = forecast.daily;
|
|
148
|
+
const localDate = daily?.time?.[offset];
|
|
149
|
+
const temperatureMax = daily?.temperature_2m_max?.[offset];
|
|
150
|
+
const temperatureMin = daily?.temperature_2m_min?.[offset];
|
|
151
|
+
if (
|
|
152
|
+
!localDate ||
|
|
153
|
+
temperatureMax === undefined ||
|
|
154
|
+
temperatureMin === undefined
|
|
155
|
+
) {
|
|
156
|
+
throw new Error(`Missing ${dayLabel(params.day)} weather data for ${params.locationQuery}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
day: params.day,
|
|
161
|
+
localDate,
|
|
162
|
+
summary: describeWeatherCode(daily?.weather_code?.[offset]),
|
|
163
|
+
temperatureMin,
|
|
164
|
+
temperatureMax,
|
|
165
|
+
precipitationProbabilityMax: daily?.precipitation_probability_max?.[offset],
|
|
166
|
+
precipitationSum: daily?.precipitation_sum?.[offset],
|
|
167
|
+
location: {
|
|
168
|
+
...location,
|
|
169
|
+
name: renderLocationName(location),
|
|
170
|
+
},
|
|
171
|
+
source: "open-meteo",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async resolveLocation(locationQuery: string): Promise<WeatherLocation> {
|
|
176
|
+
const builtIn = matchBuiltInWeatherLocation(locationQuery);
|
|
177
|
+
if (builtIn) {
|
|
178
|
+
return builtIn;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const geocodeUrl = new URL(
|
|
182
|
+
this.options.geocodingBaseUrl ?? "https://geocoding-api.open-meteo.com/v1/search",
|
|
183
|
+
);
|
|
184
|
+
geocodeUrl.searchParams.set("name", locationQuery.trim());
|
|
185
|
+
geocodeUrl.searchParams.set("count", "5");
|
|
186
|
+
geocodeUrl.searchParams.set("language", "zh");
|
|
187
|
+
geocodeUrl.searchParams.set("format", "json");
|
|
188
|
+
|
|
189
|
+
const geocode = await this.fetchJson<OpenMeteoGeocodeResponse>(geocodeUrl);
|
|
190
|
+
const result = geocode.results?.find(
|
|
191
|
+
(item) =>
|
|
192
|
+
typeof item.name === "string" &&
|
|
193
|
+
typeof item.latitude === "number" &&
|
|
194
|
+
typeof item.longitude === "number",
|
|
195
|
+
);
|
|
196
|
+
if (!result || !result.name) {
|
|
197
|
+
throw new Error(`Location not found: ${locationQuery}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
name: result.name,
|
|
202
|
+
latitude: result.latitude!,
|
|
203
|
+
longitude: result.longitude!,
|
|
204
|
+
timezone: result.timezone,
|
|
205
|
+
country: result.country_code,
|
|
206
|
+
admin1: result.admin1,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async fetchJson<T>(url: URL): Promise<T> {
|
|
211
|
+
const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;
|
|
212
|
+
const timeoutMs = this.options.timeoutMs ?? 8_000;
|
|
213
|
+
const response = await fetchImpl(url, {
|
|
214
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
215
|
+
headers: {
|
|
216
|
+
Accept: "application/json",
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
return readJson<T>(response);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { extractWeatherDay, extractWeatherLocation, parseWeatherQuery } from "./parser.js";
|
|
3
|
+
|
|
4
|
+
describe("weather parser", () => {
|
|
5
|
+
it("parses a city and day from direct Chinese weather queries", () => {
|
|
6
|
+
const query = parseWeatherQuery("天津明天天气如何");
|
|
7
|
+
expect(query.isWeatherQuery).toBe(true);
|
|
8
|
+
expect(query.location).toBe("天津");
|
|
9
|
+
expect(query.day).toBe("tomorrow");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("keeps day parsing even when only the date is provided", () => {
|
|
13
|
+
expect(extractWeatherDay("明天")).toBe("tomorrow");
|
|
14
|
+
expect(extractWeatherLocation("明天")).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("extracts English city names from weather prompts", () => {
|
|
18
|
+
const query = parseWeatherQuery("weather in tianjin tomorrow");
|
|
19
|
+
expect(query.isWeatherQuery).toBe(true);
|
|
20
|
+
expect(query.location).toBe("tianjin");
|
|
21
|
+
expect(query.day).toBe("tomorrow");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { listBuiltInWeatherAliases } from "./location-catalog.js";
|
|
2
|
+
import type { WeatherDay, WeatherQuery } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const WEATHER_HINT_PATTERN =
|
|
5
|
+
/(天气预报|天气情况|天气|气温|温度|weather forecast|forecast|weather|temperature|rain|snow)/iu;
|
|
6
|
+
|
|
7
|
+
const DAY_PATTERNS: Array<{ pattern: RegExp; day: WeatherDay }> = [
|
|
8
|
+
{ pattern: /大后天/iu, day: "in_three_days" },
|
|
9
|
+
{ pattern: /后天|day after tomorrow|the day after tomorrow/iu, day: "day_after_tomorrow" },
|
|
10
|
+
{ pattern: /明天|tomorrow/iu, day: "tomorrow" },
|
|
11
|
+
{ pattern: /今天|today/iu, day: "today" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function cleanupCandidate(text: string): string {
|
|
15
|
+
return text
|
|
16
|
+
.normalize("NFKC")
|
|
17
|
+
.replace(
|
|
18
|
+
/(帮我|帮忙|麻烦你|麻烦|请问|问下|问问|查一下|查查|查个|查|看下|看看|想知道|我想知道|告诉我一下|告诉我|请帮我|请帮忙)/giu,
|
|
19
|
+
" ",
|
|
20
|
+
)
|
|
21
|
+
.replace(
|
|
22
|
+
/(天气预报|天气情况|天气怎么样|天气如何|天气咋样|天气怎样|天气|气温|温度|weather forecast|forecast|weather|temperature)/giu,
|
|
23
|
+
" ",
|
|
24
|
+
)
|
|
25
|
+
.replace(/(今天|明天|后天|大后天|today|tomorrow|day after tomorrow|the day after tomorrow)/giu, " ")
|
|
26
|
+
.replace(/(如何|怎么样|咋样|怎样|情况|会不会下雨|下雨吗|下雪吗|会下雪吗|多少度)/giu, " ")
|
|
27
|
+
.replace(/\b(in|for|at|on)\b/giu, " ")
|
|
28
|
+
.replace(/[??!!,,.。::"'`]/g, " ")
|
|
29
|
+
.replace(/\s+/g, " ")
|
|
30
|
+
.trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function extractLocationFromCatalog(text: string): string | undefined {
|
|
34
|
+
const normalized = text
|
|
35
|
+
.normalize("NFKC")
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[^\p{L}\p{N}]+/gu, "");
|
|
38
|
+
if (!normalized) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let bestMatch = "";
|
|
43
|
+
for (const alias of listBuiltInWeatherAliases()) {
|
|
44
|
+
const normalizedAlias = alias
|
|
45
|
+
.normalize("NFKC")
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.replace(/[^\p{L}\p{N}]+/gu, "");
|
|
48
|
+
if (!normalizedAlias || !normalized.includes(normalizedAlias)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (normalizedAlias.length <= bestMatch.length) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
bestMatch = alias;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return bestMatch || undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractLooseLocation(text: string): string | undefined {
|
|
61
|
+
const candidate = cleanupCandidate(text);
|
|
62
|
+
if (!candidate) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const chineseChunks = candidate.match(/[\u3400-\u9fff]{2,12}(?:市|区|县|州|省)?/gu);
|
|
67
|
+
if (chineseChunks?.length) {
|
|
68
|
+
return chineseChunks.sort((left, right) => right.length - left.length)[0]?.trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const english = candidate
|
|
72
|
+
.replace(/\b(the|city|forecast|weather)\b/giu, " ")
|
|
73
|
+
.replace(/\s+/g, " ")
|
|
74
|
+
.trim();
|
|
75
|
+
if (!english) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return english;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function extractWeatherDay(text: string): WeatherDay | undefined {
|
|
83
|
+
for (const item of DAY_PATTERNS) {
|
|
84
|
+
if (item.pattern.test(text)) {
|
|
85
|
+
return item.day;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function extractWeatherLocation(text: string): string | undefined {
|
|
92
|
+
return extractLocationFromCatalog(text) ?? extractLooseLocation(text);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseWeatherQuery(text: string): WeatherQuery {
|
|
96
|
+
const isWeatherQuery = WEATHER_HINT_PATTERN.test(text);
|
|
97
|
+
return {
|
|
98
|
+
isWeatherQuery,
|
|
99
|
+
location: extractWeatherLocation(text),
|
|
100
|
+
day: extractWeatherDay(text),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { WeatherService } from "./service.js";
|
|
3
|
+
import type { WeatherProvider } from "./types.js";
|
|
4
|
+
|
|
5
|
+
function createProvider(): WeatherProvider {
|
|
6
|
+
return {
|
|
7
|
+
lookupForecast: vi.fn().mockResolvedValue({
|
|
8
|
+
day: "tomorrow",
|
|
9
|
+
localDate: "2026-03-13",
|
|
10
|
+
summary: "多云",
|
|
11
|
+
temperatureMin: 4,
|
|
12
|
+
temperatureMax: 12,
|
|
13
|
+
precipitationProbabilityMax: 20,
|
|
14
|
+
location: {
|
|
15
|
+
name: "天津",
|
|
16
|
+
latitude: 39.0851,
|
|
17
|
+
longitude: 117.1994,
|
|
18
|
+
timezone: "Asia/Shanghai",
|
|
19
|
+
},
|
|
20
|
+
source: "open-meteo",
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("WeatherService", () => {
|
|
26
|
+
it("asks for the missing day before looking up weather", async () => {
|
|
27
|
+
const provider = createProvider();
|
|
28
|
+
const service = new WeatherService(provider);
|
|
29
|
+
|
|
30
|
+
const firstInspection = service.inspectMessage({ userId: "u1", text: "天津天气怎么样" });
|
|
31
|
+
expect(firstInspection.shouldHandle).toBe(true);
|
|
32
|
+
expect(firstInspection.missing).toBe("day");
|
|
33
|
+
|
|
34
|
+
const firstReply = await service.processMessage({ userId: "u1", text: "天津天气怎么样" });
|
|
35
|
+
expect(firstReply.intent).toBe("clarify");
|
|
36
|
+
expect(firstReply.reply).toContain("天津");
|
|
37
|
+
expect(provider.lookupForecast).not.toHaveBeenCalled();
|
|
38
|
+
|
|
39
|
+
const secondReply = await service.processMessage({ userId: "u1", text: "明天" });
|
|
40
|
+
expect(secondReply.intent).toBe("weather_forecast");
|
|
41
|
+
expect(secondReply.reply).toContain("天津明天多云");
|
|
42
|
+
expect(provider.lookupForecast).toHaveBeenCalledWith({
|
|
43
|
+
locationQuery: "天津",
|
|
44
|
+
day: "tomorrow",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("asks for both slots when the user only asks about weather", async () => {
|
|
49
|
+
const service = new WeatherService(createProvider());
|
|
50
|
+
const reply = await service.processMessage({ userId: "u2", text: "天气怎么样" });
|
|
51
|
+
expect(reply.intent).toBe("clarify");
|
|
52
|
+
expect(reply.reply).toContain("哪个城市");
|
|
53
|
+
});
|
|
54
|
+
});
|