mirror of
https://github.com/kennethnym/aris.git
synced 2026-04-02 08:01:18 +01:00
Compare commits
1 Commits
fix/tfl-em
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
ad24265892
|
@@ -26,12 +26,6 @@ services:
|
|||||||
commands:
|
commands:
|
||||||
start: |
|
start: |
|
||||||
gitpod --context environment environment port open 3000 --name "Aelis Backend" --protocol http
|
gitpod --context environment environment port open 3000 --name "Aelis Backend" --protocol http
|
||||||
TS_IP=$(tailscale ip -4)
|
|
||||||
echo ""
|
|
||||||
echo "------------------ Bun Debugger ------------------"
|
|
||||||
echo "https://debug.bun.sh/#${TS_IP}:6499"
|
|
||||||
echo "------------------ Bun Debugger ------------------"
|
|
||||||
echo ""
|
|
||||||
cd apps/aelis-backend && bun run dev
|
cd apps/aelis-backend && bun run dev
|
||||||
|
|
||||||
admin-dashboard:
|
admin-dashboard:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.ts",
|
"main": "src/server.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch --inspect=0.0.0.0:6499 src/server.ts",
|
"dev": "bun run --watch src/server.ts",
|
||||||
"start": "bun run src/server.ts",
|
"start": "bun run src/server.ts",
|
||||||
"test": "bun test src/",
|
"test": "bun test src/",
|
||||||
"db:generate": "bunx drizzle-kit generate",
|
"db:generate": "bunx drizzle-kit generate",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { registerLocationHttpHandlers } from "./location/http.ts"
|
|||||||
import { LocationSourceProvider } from "./location/provider.ts"
|
import { LocationSourceProvider } from "./location/provider.ts"
|
||||||
import { UserSessionManager } from "./session/index.ts"
|
import { UserSessionManager } from "./session/index.ts"
|
||||||
import { registerSourcesHttpHandlers } from "./sources/http.ts"
|
import { registerSourcesHttpHandlers } from "./sources/http.ts"
|
||||||
import { TflSourceProvider } from "./tfl/provider.ts"
|
|
||||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
@@ -46,7 +45,6 @@ function main() {
|
|||||||
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }),
|
|
||||||
],
|
],
|
||||||
feedEnhancer,
|
feedEnhancer,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export class TflApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
||||||
const lineIds = lines?.length ? lines : ALL_LINE_IDS
|
const lineIds = lines ?? ALL_LINE_IDS
|
||||||
const data = await this.fetch<unknown>(`/Line/${lineIds.join(",")}/Status`)
|
const data = await this.fetch<unknown>(`/Line/${lineIds.join(",")}/Status`)
|
||||||
|
|
||||||
const parsed = lineResponseArray(data)
|
const parsed = lineResponseArray(data)
|
||||||
@@ -101,8 +101,8 @@ export class TflApi {
|
|||||||
return this.stationsCache
|
return this.stationsCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch stations for all lines in parallel, tolerating individual failures
|
// Fetch stations for all lines in parallel
|
||||||
const results = await Promise.allSettled(
|
const responses = await Promise.all(
|
||||||
ALL_LINE_IDS.map(async (id) => {
|
ALL_LINE_IDS.map(async (id) => {
|
||||||
const data = await this.fetch<unknown>(`/Line/${id}/StopPoints`)
|
const data = await this.fetch<unknown>(`/Line/${id}/StopPoints`)
|
||||||
const parsed = lineStopPointsArray(data)
|
const parsed = lineStopPointsArray(data)
|
||||||
@@ -116,12 +116,7 @@ export class TflApi {
|
|||||||
// Merge stations, combining lines for shared stations
|
// Merge stations, combining lines for shared stations
|
||||||
const stationMap = new Map<string, StationLocation>()
|
const stationMap = new Map<string, StationLocation>()
|
||||||
|
|
||||||
for (const result of results) {
|
for (const { lineId: currentLineId, stops } of responses) {
|
||||||
if (result.status === "rejected") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const { lineId: currentLineId, stops } = result.value
|
|
||||||
for (const stop of stops) {
|
for (const stop of stops) {
|
||||||
const existing = stationMap.get(stop.naptanId)
|
const existing = stationMap.get(stop.naptanId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -140,15 +135,8 @@ export class TflApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only cache if all requests succeeded — partial results shouldn't persist
|
this.stationsCache = Array.from(stationMap.values())
|
||||||
const allSucceeded = results.every((r) => r.status === "fulfilled")
|
return this.stationsCache
|
||||||
const stations = Array.from(stationMap.values())
|
|
||||||
|
|
||||||
if (allSucceeded) {
|
|
||||||
this.stationsCache = stations
|
|
||||||
}
|
|
||||||
|
|
||||||
return stations
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
|||||||
throw new Error("Either client or apiKey must be provided")
|
throw new Error("Either client or apiKey must be provided")
|
||||||
}
|
}
|
||||||
this.client = options.client ?? new TflApi(options.apiKey!)
|
this.client = options.client ?? new TflApi(options.apiKey!)
|
||||||
this.lines = options.lines?.length ? options.lines : [...TflSource.DEFAULT_LINES_OF_INTEREST]
|
this.lines = options.lines ?? [...TflSource.DEFAULT_LINES_OF_INTEREST]
|
||||||
}
|
}
|
||||||
|
|
||||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export interface HourlyWeatherFeedItem extends FeedItem<
|
|||||||
HourlyWeatherData
|
HourlyWeatherData
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
export type DailyWeatherEntry = {
|
export type DailyWeatherData = {
|
||||||
forecastDate: Date
|
forecastDate: Date
|
||||||
conditionCode: ConditionCode
|
conditionCode: ConditionCode
|
||||||
maxUvIndex: number
|
maxUvIndex: number
|
||||||
@@ -71,10 +71,6 @@ export type DailyWeatherEntry = {
|
|||||||
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
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export {
|
|||||||
type HourlyWeatherEntry,
|
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"
|
||||||
|
|||||||
@@ -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, HourlyForecast, DailyForecast } from "./weatherkit"
|
import type { WeatherKitClient, WeatherKitResponse, HourlyForecast } from "./weatherkit"
|
||||||
|
|
||||||
import fixture from "../fixtures/san-francisco.json"
|
import fixture from "../fixtures/san-francisco.json"
|
||||||
import { WeatherFeedItemType, type DailyWeatherData, type HourlyWeatherData } from "./feed-items"
|
import { WeatherFeedItemType, 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"
|
||||||
|
|
||||||
@@ -133,8 +133,7 @@ describe("WeatherSource", () => {
|
|||||||
|
|
||||||
expect(hourlyItems.length).toBe(1)
|
expect(hourlyItems.length).toBe(1)
|
||||||
expect((hourlyItems[0]!.data as HourlyWeatherData).hours.length).toBe(3)
|
expect((hourlyItems[0]!.data as HourlyWeatherData).hours.length).toBe(3)
|
||||||
expect(dailyItems.length).toBe(1)
|
expect(dailyItems.length).toBe(2)
|
||||||
expect((dailyItems[0]!.data as DailyWeatherData).days.length).toBe(2)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("produces a single hourly item with hours array", async () => {
|
test("produces a single hourly item with hours array", async () => {
|
||||||
@@ -193,65 +192,6 @@ describe("WeatherSource", () => {
|
|||||||
expect(hourlyItem!.signals!.timeRelevance).toBe("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 () => {
|
||||||
const source = new WeatherSource({ client: mockClient })
|
const source = new WeatherSource({ client: mockClient })
|
||||||
const queryTime = new Date("2026-01-17T12:00:00Z")
|
const queryTime = new Date("2026-01-17T12:00:00Z")
|
||||||
|
|||||||
@@ -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 DailyWeatherEntry, type HourlyWeatherEntry, type WeatherFeedItem } from "./feed-items"
|
import { WeatherFeedItemType, 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 {
|
||||||
@@ -181,8 +181,11 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
|||||||
|
|
||||||
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)
|
||||||
if (days.length > 0) {
|
for (let i = 0; i < days.length; i++) {
|
||||||
items.push(createDailyForecastFeedItem(days, timestamp, this.units, this.id))
|
const day = days[i]
|
||||||
|
if (day) {
|
||||||
|
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units, this.id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,18 +370,24 @@ function createHourlyForecastFeedItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDailyForecastFeedItem(
|
function createDailyWeatherFeedItem(
|
||||||
dailyForecasts: DailyForecast[],
|
daily: DailyForecast,
|
||||||
|
index: number,
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
): WeatherFeedItem {
|
): WeatherFeedItem {
|
||||||
const days: DailyWeatherEntry[] = []
|
const signals: FeedItemSignals = {
|
||||||
let totalUrgency = 0
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
||||||
let worstTimeRelevance: TimeRelevance = TimeRelevance.Ambient
|
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
for (const daily of dailyForecasts) {
|
return {
|
||||||
days.push({
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||||
|
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,
|
||||||
@@ -390,27 +399,7 @@ function createDailyForecastFeedItem(
|
|||||||
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user