Compare commits

..

5 Commits

Author SHA1 Message Date
7a37abca67 feat: combine daily weather into single feed item
Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 14:31:02 +00:00
53dbf1ca34 feat: combine hourly weather into single feed item (#100)
* feat: combine hourly weather into single feed item

Co-authored-by: Ona <no-reply@ona.com>

* fix: use worst-case timeRelevance, improve tests

- Use most urgent timeRelevance across hours instead of
  hardcoded Ambient
- Use HourlyWeatherData type in test casts
- Add test for averaged urgency with mixed conditions

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 14:54:14 +01:00
e09c606649 fix: disable strict mode for enhancement JSON schema (#99)
strict: true requires all property names to be known upfront,
which is incompatible with the dynamic-key maps in slotFills.
Also replace type array with anyOf for nullable slot values.
2026-03-28 15:58:57 +00:00
21b7d299a6 fix: move tailscale setup to postStartCommand (#98)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-24 22:59:03 +00:00
1596f2bedf fix: use http protocol for service ports (#97)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-24 22:44:56 +00:00
8 changed files with 204 additions and 54 deletions

View File

@@ -10,8 +10,8 @@
"context": ".", "context": ".",
"dockerfile": "Dockerfile" "dockerfile": "Dockerfile"
}, },
"postCreateCommand": "bun install && ./scripts/setup-tailscale.sh", "postCreateCommand": "bun install",
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh", "postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh && ./scripts/setup-tailscale.sh",
// Features add additional features to your environment. See https://containers.dev/features // Features add additional features to your environment. See https://containers.dev/features
// Beware: features are not supported on all platforms and may have unintended side-effects. // Beware: features are not supported on all platforms and may have unintended side-effects.
"features": { "features": {

View File

@@ -46,7 +46,7 @@ export function createLlmClient(config: LlmClientConfig): LlmClient {
type: "json_schema" as const, type: "json_schema" as const,
jsonSchema: { jsonSchema: {
name: "enhancement_result", name: "enhancement_result",
strict: true, strict: false,
schema: enhancementResultJsonSchema, schema: enhancementResultJsonSchema,
}, },
}, },

View File

@@ -166,11 +166,12 @@ describe("schema sync", () => {
expect(parseEnhancementResult(JSON.stringify(bad))).toBeNull() expect(parseEnhancementResult(JSON.stringify(bad))).toBeNull()
// JSON Schema only allows string or null for slot values // JSON Schema only allows string or null for slot values
const slotValueTypes = const slotValueSchema =
enhancementResultJsonSchema.properties.slotFills.additionalProperties enhancementResultJsonSchema.properties.slotFills.additionalProperties
.additionalProperties.type .additionalProperties
expect(slotValueTypes).toContain("string") expect(slotValueSchema.anyOf).toEqual([
expect(slotValueTypes).toContain("null") { type: "string" },
expect(slotValueTypes).not.toContain("number") { type: "null" },
])
}) })
}) })

View File

@@ -31,7 +31,7 @@ export const enhancementResultJsonSchema = {
additionalProperties: { additionalProperties: {
type: "object", type: "object",
additionalProperties: { additionalProperties: {
type: ["string", "null"], anyOf: [{ type: "string" }, { type: "null" }],
}, },
}, },
}, },

View File

@@ -32,7 +32,7 @@ export interface CurrentWeatherFeedItem extends FeedItem<
CurrentWeatherData CurrentWeatherData
> {} > {}
export type HourlyWeatherData = { export type HourlyWeatherEntry = {
forecastTime: Date forecastTime: Date
conditionCode: ConditionCode conditionCode: ConditionCode
daylight: boolean daylight: boolean
@@ -48,12 +48,16 @@ export type HourlyWeatherData = {
windSpeed: number windSpeed: number
} }
export type HourlyWeatherData = {
hours: HourlyWeatherEntry[]
}
export interface HourlyWeatherFeedItem extends FeedItem< export interface HourlyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Hourly, typeof WeatherFeedItemType.Hourly,
HourlyWeatherData HourlyWeatherData
> {} > {}
export type DailyWeatherData = { export type DailyWeatherEntry = {
forecastDate: Date forecastDate: Date
conditionCode: ConditionCode conditionCode: ConditionCode
maxUvIndex: number maxUvIndex: number
@@ -67,6 +71,10 @@ export type DailyWeatherData = {
temperatureMin: number temperatureMin: number
} }
export type DailyWeatherData = {
days: DailyWeatherEntry[]
}
export interface DailyWeatherFeedItem extends FeedItem< export interface DailyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Daily, typeof WeatherFeedItemType.Daily,
DailyWeatherData DailyWeatherData

View File

@@ -8,8 +8,10 @@ export {
type CurrentWeatherData, type CurrentWeatherData,
type HourlyWeatherFeedItem, type HourlyWeatherFeedItem,
type HourlyWeatherData, type HourlyWeatherData,
type HourlyWeatherEntry,
type DailyWeatherFeedItem, type DailyWeatherFeedItem,
type DailyWeatherData, type DailyWeatherData,
type DailyWeatherEntry,
type WeatherAlertFeedItem, type WeatherAlertFeedItem,
type WeatherAlertData, type WeatherAlertData,
} from "./feed-items" } from "./feed-items"

View File

@@ -4,10 +4,10 @@ import { Context } from "@aelis/core"
import { LocationKey } from "@aelis/source-location" import { LocationKey } from "@aelis/source-location"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit" import type { WeatherKitClient, WeatherKitResponse, HourlyForecast, DailyForecast } from "./weatherkit"
import fixture from "../fixtures/san-francisco.json" import fixture from "../fixtures/san-francisco.json"
import { WeatherFeedItemType } from "./feed-items" import { WeatherFeedItemType, type DailyWeatherData, type HourlyWeatherData } from "./feed-items"
import { WeatherKey, type Weather } from "./weather-context" import { WeatherKey, type Weather } from "./weather-context"
import { WeatherSource, Units } from "./weather-source" import { WeatherSource, Units } from "./weather-source"
@@ -131,8 +131,125 @@ describe("WeatherSource", () => {
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly) const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily) const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
expect(hourlyItems.length).toBe(3) expect(hourlyItems.length).toBe(1)
expect(dailyItems.length).toBe(2) expect((hourlyItems[0]!.data as HourlyWeatherData).hours.length).toBe(3)
expect(dailyItems.length).toBe(1)
expect((dailyItems[0]!.data as DailyWeatherData).days.length).toBe(2)
})
test("produces a single hourly item with hours array", async () => {
const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
expect(hourlyItems.length).toBe(1)
const hourlyData = hourlyItems[0]!.data as HourlyWeatherData
expect(Array.isArray(hourlyData.hours)).toBe(true)
expect(hourlyData.hours.length).toBeGreaterThan(0)
expect(hourlyData.hours.length).toBeLessThanOrEqual(12)
})
test("averages urgency across hours with mixed conditions", async () => {
const mildHour: HourlyForecast = {
forecastStart: "2026-01-17T01:00:00Z",
conditionCode: "Clear",
daylight: true,
humidity: 0.5,
precipitationAmount: 0,
precipitationChance: 0,
precipitationType: "clear",
pressure: 1013,
snowfallIntensity: 0,
temperature: 20,
temperatureApparent: 20,
temperatureDewPoint: 10,
uvIndex: 3,
visibility: 20000,
windDirection: 180,
windGust: 10,
windSpeed: 5,
}
const severeHour: HourlyForecast = {
...mildHour,
forecastStart: "2026-01-17T02:00:00Z",
conditionCode: "SevereThunderstorm",
}
const mixedResponse: WeatherKitResponse = {
forecastHourly: { hours: [mildHour, severeHour] },
}
const source = new WeatherSource({ client: createMockClient(mixedResponse) })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const hourlyItem = items.find((i) => i.type === WeatherFeedItemType.Hourly)
expect(hourlyItem).toBeDefined()
// Mild urgency = 0.3, severe urgency = 0.6, average = 0.45
expect(hourlyItem!.signals!.urgency).toBeCloseTo(0.45, 5)
// Worst-case: SevereThunderstorm → Imminent
expect(hourlyItem!.signals!.timeRelevance).toBe("imminent")
})
test("produces a single daily item with days array", async () => {
const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
expect(dailyItems.length).toBe(1)
const dailyData = dailyItems[0]!.data as DailyWeatherData
expect(Array.isArray(dailyData.days)).toBe(true)
expect(dailyData.days.length).toBeGreaterThan(0)
expect(dailyData.days.length).toBeLessThanOrEqual(7)
})
test("averages urgency across days with mixed conditions", async () => {
const mildDay: DailyForecast = {
forecastStart: "2026-01-17T00:00:00Z",
forecastEnd: "2026-01-18T00:00:00Z",
conditionCode: "Clear",
maxUvIndex: 3,
moonPhase: "firstQuarter",
precipitationAmount: 0,
precipitationChance: 0,
precipitationType: "clear",
snowfallAmount: 0,
sunrise: "2026-01-17T07:00:00Z",
sunriseCivil: "2026-01-17T06:30:00Z",
sunriseNautical: "2026-01-17T06:00:00Z",
sunriseAstronomical: "2026-01-17T05:30:00Z",
sunset: "2026-01-17T17:00:00Z",
sunsetCivil: "2026-01-17T17:30:00Z",
sunsetNautical: "2026-01-17T18:00:00Z",
sunsetAstronomical: "2026-01-17T18:30:00Z",
temperatureMax: 15,
temperatureMin: 5,
}
const severeDay: DailyForecast = {
...mildDay,
forecastStart: "2026-01-18T00:00:00Z",
forecastEnd: "2026-01-19T00:00:00Z",
conditionCode: "SevereThunderstorm",
}
const mixedResponse: WeatherKitResponse = {
forecastDaily: { days: [mildDay, severeDay] },
}
const source = new WeatherSource({ client: createMockClient(mixedResponse) })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const dailyItem = items.find((i) => i.type === WeatherFeedItemType.Daily)
expect(dailyItem).toBeDefined()
// Mild urgency = 0.2, severe urgency = 0.5, average = 0.35
expect(dailyItem!.signals!.urgency).toBeCloseTo(0.35, 5)
// Worst-case: SevereThunderstorm → Imminent
expect(dailyItem!.signals!.timeRelevance).toBe("imminent")
}) })
test("sets timestamp from context.time", async () => { test("sets timestamp from context.time", async () => {

View File

@@ -3,7 +3,7 @@ import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core" import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
import { LocationKey } from "@aelis/source-location" import { LocationKey } from "@aelis/source-location"
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items" import { WeatherFeedItemType, type DailyWeatherEntry, type HourlyWeatherEntry, type WeatherFeedItem } from "./feed-items"
import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt" import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt"
import { WeatherKey, type Weather } from "./weather-context" import { WeatherKey, type Weather } from "./weather-context"
import { import {
@@ -174,21 +174,15 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
if (response.forecastHourly?.hours) { if (response.forecastHourly?.hours) {
const hours = response.forecastHourly.hours.slice(0, this.hourlyLimit) const hours = response.forecastHourly.hours.slice(0, this.hourlyLimit)
for (let i = 0; i < hours.length; i++) { if (hours.length > 0) {
const hour = hours[i] items.push(createHourlyForecastFeedItem(hours, timestamp, this.units, this.id))
if (hour) {
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, this.units, this.id))
}
} }
} }
if (response.forecastDaily?.days) { if (response.forecastDaily?.days) {
const days = response.forecastDaily.days.slice(0, this.dailyLimit) const days = response.forecastDaily.days.slice(0, this.dailyLimit)
for (let i = 0; i < days.length; i++) { if (days.length > 0) {
const day = days[i] items.push(createDailyForecastFeedItem(days, timestamp, this.units, this.id))
if (day) {
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units, this.id))
}
} }
} }
@@ -323,24 +317,18 @@ function createCurrentWeatherFeedItem(
} }
} }
function createHourlyWeatherFeedItem( function createHourlyForecastFeedItem(
hourly: HourlyForecast, hourlyForecasts: HourlyForecast[],
index: number,
timestamp: Date, timestamp: Date,
units: Units, units: Units,
sourceId: string, sourceId: string,
): WeatherFeedItem { ): WeatherFeedItem {
const signals: FeedItemSignals = { const hours: HourlyWeatherEntry[] = []
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode), let totalUrgency = 0
timeRelevance: timeRelevanceForCondition(hourly.conditionCode), let worstTimeRelevance: TimeRelevance = TimeRelevance.Ambient
}
return { for (const hourly of hourlyForecasts) {
id: `weather-hourly-${timestamp.getTime()}-${index}`, hours.push({
sourceId,
type: WeatherFeedItemType.Hourly,
timestamp,
data: {
forecastTime: new Date(hourly.forecastStart), forecastTime: new Date(hourly.forecastStart),
conditionCode: hourly.conditionCode, conditionCode: hourly.conditionCode,
daylight: hourly.daylight, daylight: hourly.daylight,
@@ -354,29 +342,43 @@ function createHourlyWeatherFeedItem(
windDirection: hourly.windDirection, windDirection: hourly.windDirection,
windGust: convertSpeed(hourly.windGust, units), windGust: convertSpeed(hourly.windGust, units),
windSpeed: convertSpeed(hourly.windSpeed, units), windSpeed: convertSpeed(hourly.windSpeed, units),
}, })
totalUrgency += adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode)
const rel = timeRelevanceForCondition(hourly.conditionCode)
if (rel === TimeRelevance.Imminent) {
worstTimeRelevance = TimeRelevance.Imminent
} else if (rel === TimeRelevance.Upcoming && worstTimeRelevance !== TimeRelevance.Imminent) {
worstTimeRelevance = TimeRelevance.Upcoming
}
}
const signals: FeedItemSignals = {
urgency: totalUrgency / hours.length,
timeRelevance: worstTimeRelevance,
}
return {
id: `weather-hourly-${timestamp.getTime()}`,
sourceId,
type: WeatherFeedItemType.Hourly,
timestamp,
data: { hours },
signals, signals,
} }
} }
function createDailyWeatherFeedItem( function createDailyForecastFeedItem(
daily: DailyForecast, dailyForecasts: DailyForecast[],
index: number,
timestamp: Date, timestamp: Date,
units: Units, units: Units,
sourceId: string, sourceId: string,
): WeatherFeedItem { ): WeatherFeedItem {
const signals: FeedItemSignals = { const days: DailyWeatherEntry[] = []
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode), let totalUrgency = 0
timeRelevance: timeRelevanceForCondition(daily.conditionCode), let worstTimeRelevance: TimeRelevance = TimeRelevance.Ambient
}
return { for (const daily of dailyForecasts) {
id: `weather-daily-${timestamp.getTime()}-${index}`, days.push({
sourceId,
type: WeatherFeedItemType.Daily,
timestamp,
data: {
forecastDate: new Date(daily.forecastStart), forecastDate: new Date(daily.forecastStart),
conditionCode: daily.conditionCode, conditionCode: daily.conditionCode,
maxUvIndex: daily.maxUvIndex, maxUvIndex: daily.maxUvIndex,
@@ -388,7 +390,27 @@ function createDailyWeatherFeedItem(
sunset: new Date(daily.sunset), sunset: new Date(daily.sunset),
temperatureMax: convertTemperature(daily.temperatureMax, units), temperatureMax: convertTemperature(daily.temperatureMax, units),
temperatureMin: convertTemperature(daily.temperatureMin, units), temperatureMin: convertTemperature(daily.temperatureMin, units),
}, })
totalUrgency += adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode)
const rel = timeRelevanceForCondition(daily.conditionCode)
if (rel === TimeRelevance.Imminent) {
worstTimeRelevance = TimeRelevance.Imminent
} else if (rel === TimeRelevance.Upcoming && worstTimeRelevance !== TimeRelevance.Imminent) {
worstTimeRelevance = TimeRelevance.Upcoming
}
}
const signals: FeedItemSignals = {
urgency: totalUrgency / days.length,
timeRelevance: worstTimeRelevance,
}
return {
id: `weather-daily-${timestamp.getTime()}`,
sourceId,
type: WeatherFeedItemType.Daily,
timestamp,
data: { days },
signals, signals,
} }
} }