@qvac/harness 0.7.2 → 0.7.3
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/CHANGELOG.md +26 -1
- package/dist-lib/lib/capabilities/completion.js +18 -0
- package/dist-lib/lib/tools/weather-lookup/geocode.d.ts +26 -0
- package/dist-lib/lib/tools/weather-lookup/geocode.js +110 -0
- package/dist-lib/lib/tools/weather-lookup/place.js +5 -1
- package/dist-lib/lib/tools/weather-lookup.d.ts +2 -1
- package/dist-lib/lib/tools/weather-lookup.js +115 -84
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,30 @@ While the major version is `0`, a breaking change bumps the minor version.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.7.3] - 2026-10-01
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- A tool result may carry `answer: true` beside its `content` to end the turn
|
|
16
|
+
with that content as the reply, without another round through the model.
|
|
17
|
+
The `tool-result` event still fires. `weather_lookup` uses it for an
|
|
18
|
+
ambiguous or unknown place: the question that lists the namesakes, or the
|
|
19
|
+
not-found line, is the reply.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- `weather_lookup` finds the place before it asks for the weather. It looks the
|
|
24
|
+
city up on Open-Meteo's geocoder, `geocoding-api.open-meteo.com`, a new
|
|
25
|
+
outbound host, and asks wttr.in for the forecast at the coordinates of the
|
|
26
|
+
place it picked, which leads the report. A bare "Nassau" now reports Nassau,
|
|
27
|
+
Bahamas. It used to ask wttr.in by name, whose own geocoder put it in
|
|
28
|
+
Westbury, New York. A name that places of a similar size share, such as
|
|
29
|
+
Springfield, gets a question listing up to four of them instead of a
|
|
30
|
+
forecast: any namesake with a tenth of the largest one's population counts.
|
|
31
|
+
A country or state in the request picks among them. When the geocoder is
|
|
32
|
+
blocked, slow or failing, or knows no such place, the lookup asks wttr.in by
|
|
33
|
+
name, as before. The resource label names both hosts.
|
|
34
|
+
|
|
11
35
|
## [0.7.2] - 2026-10-01
|
|
12
36
|
|
|
13
37
|
### Added
|
|
@@ -1041,7 +1065,8 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
|
|
|
1041
1065
|
- GPU stats are read through `bare-gpu-info`, so every dependency installs from
|
|
1042
1066
|
the public npm registry. (#12)
|
|
1043
1067
|
|
|
1044
|
-
[Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.7.
|
|
1068
|
+
[Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.7.3...HEAD
|
|
1069
|
+
[0.7.3]: https://github.com/tetherto/qvac-harness/compare/v0.7.2...v0.7.3
|
|
1045
1070
|
[0.7.2]: https://github.com/tetherto/qvac-harness/compare/v0.7.1...v0.7.2
|
|
1046
1071
|
[0.7.1]: https://github.com/tetherto/qvac-harness/compare/v0.7.0...v0.7.1
|
|
1047
1072
|
[0.7.0]: https://github.com/tetherto/qvac-harness/compare/v0.6.0...v0.7.0
|
|
@@ -232,6 +232,15 @@ function callSucceeded(result, outcome) {
|
|
|
232
232
|
const exitCode = isRecord(result) ? result.exitCode : undefined;
|
|
233
233
|
return typeof exitCode !== 'number' || exitCode === 0;
|
|
234
234
|
}
|
|
235
|
+
function isAnswerResult(result) {
|
|
236
|
+
return (isRecord(result) &&
|
|
237
|
+
result.answer === true &&
|
|
238
|
+
typeof result.content === 'string' &&
|
|
239
|
+
result.content.trim().length > 0);
|
|
240
|
+
}
|
|
241
|
+
function answerOf(result) {
|
|
242
|
+
return isAnswerResult(result) ? result.content : undefined;
|
|
243
|
+
}
|
|
235
244
|
function retryHints(result) {
|
|
236
245
|
if (!result || typeof result !== 'object' || Array.isArray(result))
|
|
237
246
|
return {};
|
|
@@ -1038,6 +1047,7 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
|
|
|
1038
1047
|
];
|
|
1039
1048
|
let cappedCalls = 0;
|
|
1040
1049
|
let progressed = false;
|
|
1050
|
+
let answer;
|
|
1041
1051
|
runOutcomes.beginRound();
|
|
1042
1052
|
for (const { call, tool, args, rawCall, rejection, intent } of resolved) {
|
|
1043
1053
|
if (signal?.aborted)
|
|
@@ -1144,6 +1154,7 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
|
|
|
1144
1154
|
}
|
|
1145
1155
|
else if (execution.executed && callSucceeded(output, executionOutcome)) {
|
|
1146
1156
|
progressed = true;
|
|
1157
|
+
answer ??= answerOf(output);
|
|
1147
1158
|
}
|
|
1148
1159
|
runOutcomes.record(output, executionOutcome);
|
|
1149
1160
|
if (searchCapped) {
|
|
@@ -1197,6 +1208,13 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
|
|
|
1197
1208
|
failed.clear();
|
|
1198
1209
|
blockedRepeats = 0;
|
|
1199
1210
|
}
|
|
1211
|
+
if (answer !== undefined) {
|
|
1212
|
+
yield { type: 'content', text: answer };
|
|
1213
|
+
history.push({ role: 'assistant', content: answer });
|
|
1214
|
+
said = answer;
|
|
1215
|
+
outcome = 'answered';
|
|
1216
|
+
break;
|
|
1217
|
+
}
|
|
1200
1218
|
history.push({ role: 'user', content: TOOL_CONTINUATION_NUDGE });
|
|
1201
1219
|
if (blockedRepeats >= MAX_BLOCKED_REPEATS) {
|
|
1202
1220
|
break;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type AbortSignal as BareAbortSignal } from 'bare-abort-controller';
|
|
2
|
+
import type { FetchFn } from '../http-request.ts';
|
|
3
|
+
import { type HostResolver } from '../url-guard.ts';
|
|
4
|
+
import { type ResolvedPlace } from './place.ts';
|
|
5
|
+
export declare const GEOCODER_HOST = "geocoding-api.open-meteo.com";
|
|
6
|
+
export declare const GEOCODE_TIMEOUT_MS = 5000;
|
|
7
|
+
export interface Candidate extends ResolvedPlace {
|
|
8
|
+
readonly latitude: number;
|
|
9
|
+
readonly longitude: number;
|
|
10
|
+
readonly population: number | undefined;
|
|
11
|
+
}
|
|
12
|
+
export interface Chosen {
|
|
13
|
+
readonly place: Candidate;
|
|
14
|
+
readonly verified: boolean;
|
|
15
|
+
}
|
|
16
|
+
export type Choice = Chosen | {
|
|
17
|
+
readonly contenders: readonly Candidate[];
|
|
18
|
+
};
|
|
19
|
+
export interface GeocodeRequest {
|
|
20
|
+
readonly fetch: FetchFn;
|
|
21
|
+
readonly resolveHost: HostResolver;
|
|
22
|
+
readonly signal: BareAbortSignal;
|
|
23
|
+
readonly timeoutMs: number;
|
|
24
|
+
}
|
|
25
|
+
export declare function geocode(city: string, lookup: GeocodeRequest): Promise<Candidate[]>;
|
|
26
|
+
export declare function choose(city: string, qualifier: string | null, candidates: readonly Candidate[]): Choice | null;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { AbortController } from 'bare-abort-controller';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { errorMessage } from '../../guards.js';
|
|
4
|
+
import { hlog } from '../../log.js';
|
|
5
|
+
import { resolveOutboundUrl } from '../url-guard.js';
|
|
6
|
+
import { checkQualifier, describePlace, fold } from './place.js';
|
|
7
|
+
export const GEOCODER_HOST = 'geocoding-api.open-meteo.com';
|
|
8
|
+
const MAX_BODY_BYTES = 200_000;
|
|
9
|
+
export const GEOCODE_TIMEOUT_MS = 5_000;
|
|
10
|
+
const CONTENDER_RATIO = 10;
|
|
11
|
+
const MAX_LISTED = 4;
|
|
12
|
+
const GeocodeSchema = z.object({
|
|
13
|
+
results: z
|
|
14
|
+
.array(z.object({
|
|
15
|
+
name: z.string(),
|
|
16
|
+
latitude: z.number(),
|
|
17
|
+
longitude: z.number(),
|
|
18
|
+
country: z.string().optional(),
|
|
19
|
+
admin1: z.string().optional(),
|
|
20
|
+
population: z.number().optional()
|
|
21
|
+
}))
|
|
22
|
+
.optional()
|
|
23
|
+
});
|
|
24
|
+
export async function geocode(city, lookup) {
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timeout = setTimeout(() => controller.abort(undefined), lookup.timeoutMs);
|
|
27
|
+
const onLookupAbort = () => controller.abort(undefined);
|
|
28
|
+
lookup.signal.addEventListener('abort', onLookupAbort);
|
|
29
|
+
try {
|
|
30
|
+
return await search(city, lookup, controller.signal);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (lookup.signal.aborted) {
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
hlog('warn: weather geocoder failed, asking wttr.in by name', { err: errorMessage(err) });
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
clearTimeout(timeout);
|
|
41
|
+
lookup.signal.removeEventListener('abort', onLookupAbort);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function search(city, { fetch, resolveHost }, signal) {
|
|
45
|
+
const query = `name=${encodeURIComponent(city)}&count=10&language=en&format=json`;
|
|
46
|
+
const url = new URL(`https://${GEOCODER_HOST}/v1/search?${query}`);
|
|
47
|
+
const check = await resolveOutboundUrl(url, resolveHost, signal);
|
|
48
|
+
if (check.blocked) {
|
|
49
|
+
throw new Error(check.blocked);
|
|
50
|
+
}
|
|
51
|
+
const response = await fetch(url, {
|
|
52
|
+
method: 'GET',
|
|
53
|
+
headers: { Accept: 'application/json' },
|
|
54
|
+
signal,
|
|
55
|
+
redirect: 'manual',
|
|
56
|
+
validatedAddresses: check.addresses,
|
|
57
|
+
maxBytes: MAX_BODY_BYTES
|
|
58
|
+
});
|
|
59
|
+
if (response.status !== 200) {
|
|
60
|
+
throw new Error(`geocoder answered HTTP ${response.status}`);
|
|
61
|
+
}
|
|
62
|
+
const { results = [] } = GeocodeSchema.parse(JSON.parse(await response.text()));
|
|
63
|
+
return results.map((result) => ({
|
|
64
|
+
area: result.name,
|
|
65
|
+
region: result.admin1 ?? '',
|
|
66
|
+
country: result.country ?? '',
|
|
67
|
+
latitude: result.latitude,
|
|
68
|
+
longitude: result.longitude,
|
|
69
|
+
population: result.population
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
export function choose(city, qualifier, candidates) {
|
|
73
|
+
const exact = candidates.filter((candidate) => fold(candidate.area) === fold(city));
|
|
74
|
+
const top = byPopulation(exact)[0] ?? candidates[0];
|
|
75
|
+
if (top === undefined) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (qualifier !== null) {
|
|
79
|
+
return qualified(qualifier, { exact, candidates, top });
|
|
80
|
+
}
|
|
81
|
+
const contenders = contendersOf(exact);
|
|
82
|
+
if (contenders.length > 1) {
|
|
83
|
+
return { contenders };
|
|
84
|
+
}
|
|
85
|
+
return { place: top, verified: exact.includes(top) };
|
|
86
|
+
}
|
|
87
|
+
function qualified(qualifier, { exact, candidates, top }) {
|
|
88
|
+
const named = (pool) => pool.find((candidate) => checkQualifier(qualifier, candidate) === 'match');
|
|
89
|
+
const namesake = named(byPopulation(exact));
|
|
90
|
+
if (namesake !== undefined) {
|
|
91
|
+
return { place: namesake, verified: true };
|
|
92
|
+
}
|
|
93
|
+
return { place: named(candidates) ?? top, verified: false };
|
|
94
|
+
}
|
|
95
|
+
function contendersOf(exact) {
|
|
96
|
+
const pool = byPopulation(exact);
|
|
97
|
+
const top = pool[0]?.population;
|
|
98
|
+
if (top === undefined) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const lines = pool.map(describePlace);
|
|
102
|
+
return pool
|
|
103
|
+
.filter((candidate, index) => candidate.population !== undefined &&
|
|
104
|
+
candidate.population * CONTENDER_RATIO >= top &&
|
|
105
|
+
lines.indexOf(describePlace(candidate)) === index)
|
|
106
|
+
.slice(0, MAX_LISTED);
|
|
107
|
+
}
|
|
108
|
+
function byPopulation(candidates) {
|
|
109
|
+
return [...candidates].sort((a, b) => (b.population ?? -1) - (a.population ?? -1));
|
|
110
|
+
}
|
|
@@ -17,6 +17,8 @@ const QUALIFIER_ALIASES = [
|
|
|
17
17
|
['united arab emirates', 'united arab emirates'],
|
|
18
18
|
['uae', 'united arab emirates'],
|
|
19
19
|
['holland', 'netherlands'],
|
|
20
|
+
['schweiz', 'switzerland'],
|
|
21
|
+
['suisse', 'switzerland'],
|
|
20
22
|
['deutschland', 'germany'],
|
|
21
23
|
['espana', 'spain'],
|
|
22
24
|
['italia', 'italy']
|
|
@@ -103,7 +105,9 @@ export function fold(value) {
|
|
|
103
105
|
.trim();
|
|
104
106
|
}
|
|
105
107
|
export function describePlace(place) {
|
|
106
|
-
return [place.area, place.region, place.country]
|
|
108
|
+
return [place.area, place.region, place.country]
|
|
109
|
+
.filter((part, index, parts) => part.length > 0 && parts.indexOf(part) === index)
|
|
110
|
+
.join(', ');
|
|
107
111
|
}
|
|
108
112
|
export function qualifierOf(location) {
|
|
109
113
|
const comma = location.indexOf(',');
|
|
@@ -4,5 +4,6 @@ import { type HostResolver } from './url-guard.ts';
|
|
|
4
4
|
export interface WeatherLookupToolOptions {
|
|
5
5
|
fetch?: FetchFn;
|
|
6
6
|
resolveHost?: HostResolver;
|
|
7
|
+
geocodeTimeoutMs?: number;
|
|
7
8
|
}
|
|
8
|
-
export declare function createWeatherLookupTool({ fetch
|
|
9
|
+
export declare function createWeatherLookupTool({ fetch, resolveHost, geocodeTimeoutMs }?: WeatherLookupToolOptions): ToolDefinition;
|
|
@@ -4,12 +4,13 @@ import { lookupHost } from '../dns-lookup.js';
|
|
|
4
4
|
import { errorMessage } from '../guards.js';
|
|
5
5
|
import { isRedirectStatus } from './http-request/request.js';
|
|
6
6
|
import { resolveOutboundUrl } from './url-guard.js';
|
|
7
|
+
import { choose, geocode, GEOCODE_TIMEOUT_MS, GEOCODER_HOST } from './weather-lookup/geocode.js';
|
|
7
8
|
import { checkQualifier, cityMatches, describePlace, qualifierOf } from './weather-lookup/place.js';
|
|
8
9
|
import { placeOf, renderReport, WeatherJsonSchema } from './weather-lookup/report.js';
|
|
9
10
|
const WEATHER_HOST = 'wttr.in';
|
|
10
11
|
const LOOKUP_TIMEOUT_MS = 15_000;
|
|
11
12
|
const MAX_BODY_BYTES = 400_000;
|
|
12
|
-
export function createWeatherLookupTool({ fetch
|
|
13
|
+
export function createWeatherLookupTool({ fetch = noRedirectFetch, resolveHost = lookupHost, geocodeTimeoutMs = GEOCODE_TIMEOUT_MS } = {}) {
|
|
13
14
|
return {
|
|
14
15
|
name: 'weather_lookup',
|
|
15
16
|
toolset: 'http-request',
|
|
@@ -17,7 +18,8 @@ export function createWeatherLookupTool({ fetch: fetchImpl, resolveHost = lookup
|
|
|
17
18
|
autoApprove: true,
|
|
18
19
|
description: 'Look up the current weather and a three-day forecast for a place, via wttr.in. ' +
|
|
19
20
|
'Returns the place the service resolved on its first line, then the conditions now and ' +
|
|
20
|
-
'one line per day. Use it for any question about weather, temperature or forecast.'
|
|
21
|
+
'one line per day. Use it for any question about weather, temperature or forecast. ' +
|
|
22
|
+
'When a name matches several places it answers with a question listing them.',
|
|
21
23
|
parameters: {
|
|
22
24
|
type: 'object',
|
|
23
25
|
properties: {
|
|
@@ -30,93 +32,122 @@ export function createWeatherLookupTool({ fetch: fetchImpl, resolveHost = lookup
|
|
|
30
32
|
required: ['location']
|
|
31
33
|
},
|
|
32
34
|
effect: () => 'read',
|
|
33
|
-
resource: () => ({ kind: 'host', label: WEATHER_HOST }),
|
|
34
|
-
|
|
35
|
-
const raw = args.location;
|
|
36
|
-
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
|
37
|
-
return { error: 'location must be a non-empty string' };
|
|
38
|
-
}
|
|
39
|
-
const location = raw.trim();
|
|
40
|
-
if (ctx?.signal?.aborted) {
|
|
41
|
-
return { error: 'weather_lookup cancelled' };
|
|
42
|
-
}
|
|
43
|
-
const url = new URL(`https://${WEATHER_HOST}/${encodeURIComponent(location)}?format=j1`);
|
|
44
|
-
const fetch = fetchImpl ?? noRedirectFetch;
|
|
45
|
-
const controller = new AbortController();
|
|
46
|
-
const timeout = setTimeout(() => controller.abort(undefined), LOOKUP_TIMEOUT_MS);
|
|
47
|
-
const onCallerAbort = () => controller.abort(undefined);
|
|
48
|
-
ctx?.signal?.addEventListener('abort', onCallerAbort);
|
|
49
|
-
try {
|
|
50
|
-
const check = await resolveOutboundUrl(url, resolveHost, ctx?.signal);
|
|
51
|
-
if (check.blocked) {
|
|
52
|
-
return { error: check.blocked };
|
|
53
|
-
}
|
|
54
|
-
const response = await fetch(url, {
|
|
55
|
-
method: 'GET',
|
|
56
|
-
headers: { Accept: 'application/json' },
|
|
57
|
-
signal: controller.signal,
|
|
58
|
-
redirect: 'manual',
|
|
59
|
-
validatedAddresses: check.addresses,
|
|
60
|
-
maxBytes: MAX_BODY_BYTES
|
|
61
|
-
});
|
|
62
|
-
if (isRedirectStatus(response.status)) {
|
|
63
|
-
return { error: `${WEATHER_HOST} redirected the lookup for "${location}"` };
|
|
64
|
-
}
|
|
65
|
-
const body = await response.text();
|
|
66
|
-
if (/location not found/i.test(body)) {
|
|
67
|
-
return { content: notFound(location) };
|
|
68
|
-
}
|
|
69
|
-
if (response.status !== 200) {
|
|
70
|
-
return { error: unavailable(`it answered HTTP ${response.status}`), code: 'transport' };
|
|
71
|
-
}
|
|
72
|
-
let json;
|
|
73
|
-
try {
|
|
74
|
-
json = WeatherJsonSchema.parse(JSON.parse(body));
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
return { error: unavailable('it did not return a forecast') };
|
|
78
|
-
}
|
|
79
|
-
const place = placeOf(json);
|
|
80
|
-
if (place === null) {
|
|
81
|
-
return { content: notFound(location) };
|
|
82
|
-
}
|
|
83
|
-
const qualifier = qualifierOf(location);
|
|
84
|
-
const verdict = qualifier === null ? null : checkQualifier(qualifier, place);
|
|
85
|
-
const named = cityMatches(location, place);
|
|
86
|
-
if (verdict === 'contradiction' && !named) {
|
|
87
|
-
return {
|
|
88
|
-
content: `No such place: "${location}". ${WEATHER_HOST} resolved it to ` +
|
|
89
|
-
`${describePlace(place)}, which is somewhere else.`
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
const verified = qualifier === null ? named : verdict === 'match';
|
|
93
|
-
return { content: renderReport(json, place, { asked: location, verified }) };
|
|
94
|
-
}
|
|
95
|
-
catch (err) {
|
|
96
|
-
if (ctx?.signal?.aborted) {
|
|
97
|
-
return { error: 'weather_lookup cancelled' };
|
|
98
|
-
}
|
|
99
|
-
if (controller.signal.aborted) {
|
|
100
|
-
return {
|
|
101
|
-
error: `weather_lookup timed out after ${LOOKUP_TIMEOUT_MS} ms`,
|
|
102
|
-
code: 'timeout'
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
return {
|
|
106
|
-
error: unavailable(errorMessage(err)),
|
|
107
|
-
code: 'transport'
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
finally {
|
|
111
|
-
clearTimeout(timeout);
|
|
112
|
-
ctx?.signal?.removeEventListener('abort', onCallerAbort);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
35
|
+
resource: () => ({ kind: 'host', label: `${GEOCODER_HOST}, ${WEATHER_HOST}` }),
|
|
36
|
+
execute: (args, ctx) => execute(args.location, ctx?.signal, { fetch, resolveHost, timeoutMs: geocodeTimeoutMs })
|
|
115
37
|
};
|
|
116
38
|
}
|
|
39
|
+
async function execute(raw, callerSignal, deps) {
|
|
40
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
|
41
|
+
return { error: 'location must be a non-empty string' };
|
|
42
|
+
}
|
|
43
|
+
const location = raw.trim();
|
|
44
|
+
if (callerSignal?.aborted) {
|
|
45
|
+
return { error: 'weather_lookup cancelled' };
|
|
46
|
+
}
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const timeout = setTimeout(() => controller.abort(undefined), LOOKUP_TIMEOUT_MS);
|
|
49
|
+
const onCallerAbort = () => controller.abort(undefined);
|
|
50
|
+
callerSignal?.addEventListener('abort', onCallerAbort);
|
|
51
|
+
try {
|
|
52
|
+
return await lookup(location, { ...deps, signal: controller.signal });
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
return failure(err, controller.signal, callerSignal);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
clearTimeout(timeout);
|
|
59
|
+
callerSignal?.removeEventListener('abort', onCallerAbort);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function lookup(location, request) {
|
|
63
|
+
const city = (location.split(',')[0] ?? '').trim();
|
|
64
|
+
const candidates = city.length === 0 ? [] : await geocode(city, request);
|
|
65
|
+
const choice = choose(city, qualifierOf(location), candidates);
|
|
66
|
+
if (choice === null) {
|
|
67
|
+
return byName(location, request);
|
|
68
|
+
}
|
|
69
|
+
if ('contenders' in choice) {
|
|
70
|
+
return reply(which(city, choice.contenders));
|
|
71
|
+
}
|
|
72
|
+
return byCoordinates(location, choice, request);
|
|
73
|
+
}
|
|
74
|
+
async function byCoordinates(location, { place, verified }, request) {
|
|
75
|
+
const fetched = await forecast(`${place.latitude},${place.longitude}`, location, request);
|
|
76
|
+
if (!('json' in fetched)) {
|
|
77
|
+
return fetched;
|
|
78
|
+
}
|
|
79
|
+
return { content: renderReport(fetched.json, place, { asked: location, verified }) };
|
|
80
|
+
}
|
|
81
|
+
async function byName(location, request) {
|
|
82
|
+
const fetched = await forecast(encodeURIComponent(location), location, request);
|
|
83
|
+
if (!('json' in fetched)) {
|
|
84
|
+
return fetched;
|
|
85
|
+
}
|
|
86
|
+
const place = placeOf(fetched.json);
|
|
87
|
+
if (place === null) {
|
|
88
|
+
return reply(notFound(location));
|
|
89
|
+
}
|
|
90
|
+
const qualifier = qualifierOf(location);
|
|
91
|
+
const verdict = qualifier === null ? null : checkQualifier(qualifier, place);
|
|
92
|
+
const named = cityMatches(location, place);
|
|
93
|
+
if (verdict === 'contradiction' && !named) {
|
|
94
|
+
return reply(`No such place: "${location}". ${WEATHER_HOST} resolved it to ` +
|
|
95
|
+
`${describePlace(place)}, which is somewhere else.`);
|
|
96
|
+
}
|
|
97
|
+
const verified = qualifier === null ? named : verdict === 'match';
|
|
98
|
+
return { content: renderReport(fetched.json, place, { asked: location, verified }) };
|
|
99
|
+
}
|
|
100
|
+
async function forecast(path, location, request) {
|
|
101
|
+
const url = new URL(`https://${WEATHER_HOST}/${path}?format=j1`);
|
|
102
|
+
const check = await resolveOutboundUrl(url, request.resolveHost, request.signal);
|
|
103
|
+
if (check.blocked) {
|
|
104
|
+
return { error: check.blocked };
|
|
105
|
+
}
|
|
106
|
+
const response = await request.fetch(url, {
|
|
107
|
+
method: 'GET',
|
|
108
|
+
headers: { Accept: 'application/json' },
|
|
109
|
+
signal: request.signal,
|
|
110
|
+
redirect: 'manual',
|
|
111
|
+
validatedAddresses: check.addresses,
|
|
112
|
+
maxBytes: MAX_BODY_BYTES
|
|
113
|
+
});
|
|
114
|
+
if (isRedirectStatus(response.status)) {
|
|
115
|
+
return { error: `${WEATHER_HOST} redirected the lookup for "${location}"` };
|
|
116
|
+
}
|
|
117
|
+
return readForecast(response.status, await response.text(), location);
|
|
118
|
+
}
|
|
119
|
+
function readForecast(status, body, location) {
|
|
120
|
+
if (/location not found/i.test(body)) {
|
|
121
|
+
return reply(notFound(location));
|
|
122
|
+
}
|
|
123
|
+
if (status !== 200) {
|
|
124
|
+
return { error: unavailable(`it answered HTTP ${status}`), code: 'transport' };
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
return { json: WeatherJsonSchema.parse(JSON.parse(body)) };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return { error: unavailable('it did not return a forecast') };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function failure(err, signal, callerSignal) {
|
|
134
|
+
if (callerSignal?.aborted) {
|
|
135
|
+
return { error: 'weather_lookup cancelled' };
|
|
136
|
+
}
|
|
137
|
+
if (signal.aborted) {
|
|
138
|
+
return { error: `weather_lookup timed out after ${LOOKUP_TIMEOUT_MS} ms`, code: 'timeout' };
|
|
139
|
+
}
|
|
140
|
+
return { error: unavailable(errorMessage(err)), code: 'transport' };
|
|
141
|
+
}
|
|
117
142
|
function unavailable(reason) {
|
|
118
143
|
return `The weather service could not be reached: ${reason}.`;
|
|
119
144
|
}
|
|
145
|
+
function reply(content) {
|
|
146
|
+
return { content, answer: true };
|
|
147
|
+
}
|
|
120
148
|
function notFound(location) {
|
|
121
149
|
return `No such place: "${location}". ${WEATHER_HOST} does not know it.`;
|
|
122
150
|
}
|
|
151
|
+
function which(city, places) {
|
|
152
|
+
return `Which ${city}: ${places.map(describePlace).join('; ')}?`;
|
|
153
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qvac/harness",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"bare": ">=1.34.0"
|
|
@@ -606,6 +606,10 @@
|
|
|
606
606
|
"types": "./dist-lib/lib/tools/weather-lookup.d.ts",
|
|
607
607
|
"default": "./dist-lib/lib/tools/weather-lookup.js"
|
|
608
608
|
},
|
|
609
|
+
"./lib/tools/weather-lookup/geocode.ts": {
|
|
610
|
+
"types": "./dist-lib/lib/tools/weather-lookup/geocode.d.ts",
|
|
611
|
+
"default": "./dist-lib/lib/tools/weather-lookup/geocode.js"
|
|
612
|
+
},
|
|
609
613
|
"./lib/tools/weather-lookup/place.ts": {
|
|
610
614
|
"types": "./dist-lib/lib/tools/weather-lookup/place.d.ts",
|
|
611
615
|
"default": "./dist-lib/lib/tools/weather-lookup/place.js"
|