From 5ea24b0a1310c2894602180152ba1a4d9cb6c3bf Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sat, 14 Mar 2026 23:51:41 +0000 Subject: [PATCH] feat(core): add sourceId to FeedItem (#72) Each FeedSource implementation now sets sourceId on items it produces, allowing consumers to trace items back to their originating source. Co-authored-by: Ona --- apps/aelis-backend/src/engine/http.test.ts | 2 ++ .../src/enhancement/merge.test.ts | 1 + apps/aelis-backend/src/enhancement/merge.ts | 9 +++++++- .../src/enhancement/prompt-builder.test.ts | 5 +++- .../src/session/user-session.test.ts | 6 +++++ packages/aelis-core/src/data-source.ts | 1 + packages/aelis-core/src/feed-engine.test.ts | 6 +++++ .../src/feed-post-processor.test.ts | 10 ++++++-- packages/aelis-core/src/feed-source.test.ts | 2 ++ packages/aelis-core/src/feed.test.ts | 4 ++++ packages/aelis-core/src/feed.ts | 3 +++ .../src/data-source.ts | 6 +++++ .../src/time-of-day-enhancer.test.ts | 11 +++++++++ .../aelis-source-caldav/src/caldav-source.ts | 10 ++++++-- .../src/google-calendar-source.ts | 4 +++- packages/aelis-source-tfl/src/tfl-source.ts | 1 + .../src/weather-source.ts | 23 +++++++++++++++---- 17 files changed, 92 insertions(+), 12 deletions(-) diff --git a/apps/aelis-backend/src/engine/http.test.ts b/apps/aelis-backend/src/engine/http.test.ts index 8a55b95..21644ec 100644 --- a/apps/aelis-backend/src/engine/http.test.ts +++ b/apps/aelis-backend/src/engine/http.test.ts @@ -64,6 +64,7 @@ describe("GET /api/feed", () => { const items: FeedItem[] = [ { id: "item-1", + sourceId: "test", type: "test", priority: 0.8, timestamp: new Date("2025-01-01T00:00:00.000Z"), @@ -96,6 +97,7 @@ describe("GET /api/feed", () => { const items: FeedItem[] = [ { id: "fresh-1", + sourceId: "test", type: "test", priority: 0.5, timestamp: new Date("2025-06-01T12:00:00.000Z"), diff --git a/apps/aelis-backend/src/enhancement/merge.test.ts b/apps/aelis-backend/src/enhancement/merge.test.ts index 98cc1cb..8fca1c7 100644 --- a/apps/aelis-backend/src/enhancement/merge.test.ts +++ b/apps/aelis-backend/src/enhancement/merge.test.ts @@ -9,6 +9,7 @@ import { mergeEnhancement } from "./merge.ts" function makeItem(overrides: Partial = {}): FeedItem { return { id: "item-1", + sourceId: "test", type: "test", timestamp: new Date("2025-01-01T00:00:00Z"), data: { value: 42 }, diff --git a/apps/aelis-backend/src/enhancement/merge.ts b/apps/aelis-backend/src/enhancement/merge.ts index 1760d1d..600f5ba 100644 --- a/apps/aelis-backend/src/enhancement/merge.ts +++ b/apps/aelis-backend/src/enhancement/merge.ts @@ -2,6 +2,8 @@ import type { FeedItem } from "@aelis/core" import type { EnhancementResult } from "./schema.ts" +const ENHANCEMENT_SOURCE_ID = "aelis.enhancement" + /** * Merges an EnhancementResult into feed items. * @@ -10,7 +12,11 @@ import type { EnhancementResult } from "./schema.ts" * - Returns a new array (no mutation) * - Ignores fills for items/slots that don't exist */ -export function mergeEnhancement(items: FeedItem[], result: EnhancementResult, currentTime: Date): FeedItem[] { +export function mergeEnhancement( + items: FeedItem[], + result: EnhancementResult, + currentTime: Date, +): FeedItem[] { const merged = items.map((item) => { const fills = result.slotFills[item.id] if (!fills || !item.slots) return item @@ -31,6 +37,7 @@ export function mergeEnhancement(items: FeedItem[], result: EnhancementResult, c for (const synthetic of result.syntheticItems) { merged.push({ id: synthetic.id, + sourceId: ENHANCEMENT_SOURCE_ID, type: synthetic.type, timestamp: currentTime, data: { text: synthetic.text }, diff --git a/apps/aelis-backend/src/enhancement/prompt-builder.test.ts b/apps/aelis-backend/src/enhancement/prompt-builder.test.ts index 47d97d9..f066771 100644 --- a/apps/aelis-backend/src/enhancement/prompt-builder.test.ts +++ b/apps/aelis-backend/src/enhancement/prompt-builder.test.ts @@ -7,6 +7,7 @@ import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts" function makeItem(overrides: Partial = {}): FeedItem { return { id: "item-1", + sourceId: "test", type: "test", timestamp: new Date("2025-01-01T00:00:00Z"), data: { value: 42 }, @@ -60,7 +61,9 @@ describe("buildPrompt", () => { expect(parsed.items).toHaveLength(1) expect((parsed.items as Array>)[0]!.id).toBe("item-1") - expect((parsed.items as Array>)[0]!.slots).toEqual({ insight: "Weather insight" }) + expect((parsed.items as Array>)[0]!.slots).toEqual({ + insight: "Weather insight", + }) expect((parsed.items as Array>)[0]!.type).toBeUndefined() expect(parsed.context).toHaveLength(0) }) diff --git a/apps/aelis-backend/src/session/user-session.test.ts b/apps/aelis-backend/src/session/user-session.test.ts index f824772..4d0a374 100644 --- a/apps/aelis-backend/src/session/user-session.test.ts +++ b/apps/aelis-backend/src/session/user-session.test.ts @@ -76,6 +76,7 @@ describe("UserSession.feed", () => { const items: FeedItem[] = [ { id: "item-1", + sourceId: "test", type: "test", timestamp: new Date("2025-01-01T00:00:00.000Z"), data: { value: 42 }, @@ -93,6 +94,7 @@ describe("UserSession.feed", () => { const items: FeedItem[] = [ { id: "item-1", + sourceId: "test", type: "test", timestamp: new Date("2025-01-01T00:00:00.000Z"), data: { value: 42 }, @@ -113,6 +115,7 @@ describe("UserSession.feed", () => { const items: FeedItem[] = [ { id: "item-1", + sourceId: "test", type: "test", timestamp: new Date("2025-01-01T00:00:00.000Z"), data: { value: 42 }, @@ -139,6 +142,7 @@ describe("UserSession.feed", () => { let currentItems: FeedItem[] = [ { id: "item-1", + sourceId: "test", type: "test", timestamp: new Date("2025-01-01T00:00:00.000Z"), data: { version: 1 }, @@ -169,6 +173,7 @@ describe("UserSession.feed", () => { currentItems = [ { id: "item-1", + sourceId: "test", type: "test", timestamp: new Date("2025-01-02T00:00:00.000Z"), data: { version: 2 }, @@ -190,6 +195,7 @@ describe("UserSession.feed", () => { const items: FeedItem[] = [ { id: "item-1", + sourceId: "test", type: "test", timestamp: new Date("2025-01-01T00:00:00.000Z"), data: { value: 42 }, diff --git a/packages/aelis-core/src/data-source.ts b/packages/aelis-core/src/data-source.ts index 2e0561e..f001640 100644 --- a/packages/aelis-core/src/data-source.ts +++ b/packages/aelis-core/src/data-source.ts @@ -17,6 +17,7 @@ import type { FeedItem } from "./feed" * const data = await fetchWeather(location) * return [{ * id: `weather-${Date.now()}`, + * sourceId: "aelis.weather", * type: this.type, * timestamp: context.time, * data: { temp: data.temperature }, diff --git a/packages/aelis-core/src/feed-engine.test.ts b/packages/aelis-core/src/feed-engine.test.ts index 0df1035..40fb85e 100644 --- a/packages/aelis-core/src/feed-engine.test.ts +++ b/packages/aelis-core/src/feed-engine.test.ts @@ -99,6 +99,7 @@ function createWeatherSource( return [ { id: `weather-${Date.now()}`, + sourceId: "weather", type: "weather", timestamp: new Date(), data: { @@ -130,6 +131,7 @@ function createAlertSource(): FeedSource { return [ { id: "alert-storm", + sourceId: "alert", type: "alert", timestamp: new Date(), data: { message: "Storm warning!" }, @@ -423,6 +425,7 @@ describe("FeedEngine", () => { return [ { id: "item-1", + sourceId: "working", type: "test", priority: 0.5, timestamp: new Date(), @@ -746,6 +749,7 @@ describe("FeedEngine", () => { return [ { id: "item-1", + sourceId: "reactive-items", type: "test", priority: 0.5, timestamp: new Date(), @@ -830,6 +834,7 @@ describe("FeedEngine", () => { return [ { id: `item-${fetchCount}`, + sourceId: "counter", type: "test", priority: 0.5, timestamp: new Date(), @@ -895,6 +900,7 @@ describe("FeedEngine", () => { return [ { id: `weather-${Date.now()}`, + sourceId: "weather", type: "weather", priority: 0.5, timestamp: new Date(), diff --git a/packages/aelis-core/src/feed-post-processor.test.ts b/packages/aelis-core/src/feed-post-processor.test.ts index da0107d..7982e98 100644 --- a/packages/aelis-core/src/feed-post-processor.test.ts +++ b/packages/aelis-core/src/feed-post-processor.test.ts @@ -29,11 +29,17 @@ type WeatherItem = FeedItem<"weather", { temp: number }> type CalendarItem = FeedItem<"calendar", { title: string }> function weatherItem(id: string, temp: number): WeatherItem { - return { id, type: "weather", timestamp: new Date(), data: { temp } } + return { id, sourceId: "aelis.weather", type: "weather", timestamp: new Date(), data: { temp } } } function calendarItem(id: string, title: string): CalendarItem { - return { id, type: "calendar", timestamp: new Date(), data: { title } } + return { + id, + sourceId: "aelis.calendar", + type: "calendar", + timestamp: new Date(), + data: { title }, + } } // ============================================================================= diff --git a/packages/aelis-core/src/feed-source.test.ts b/packages/aelis-core/src/feed-source.test.ts index becf57d..9b8461b 100644 --- a/packages/aelis-core/src/feed-source.test.ts +++ b/packages/aelis-core/src/feed-source.test.ts @@ -98,6 +98,7 @@ function createWeatherSource( return [ { id: `weather-${Date.now()}`, + sourceId: "weather", type: "weather", timestamp: new Date(), data: { @@ -129,6 +130,7 @@ function createAlertSource(): FeedSource { return [ { id: "alert-storm", + sourceId: "alert", type: "alert", timestamp: new Date(), data: { message: "Storm warning!" }, diff --git a/packages/aelis-core/src/feed.test.ts b/packages/aelis-core/src/feed.test.ts index cd6977c..7e0893e 100644 --- a/packages/aelis-core/src/feed.test.ts +++ b/packages/aelis-core/src/feed.test.ts @@ -6,6 +6,7 @@ describe("FeedItem slots", () => { test("FeedItem without slots is valid", () => { const item: FeedItem<"test", { value: number }> = { id: "test-1", + sourceId: "test-source", type: "test", timestamp: new Date(), data: { value: 42 }, @@ -17,6 +18,7 @@ describe("FeedItem slots", () => { test("FeedItem with unfilled slots", () => { const item: FeedItem<"weather", { temp: number }> = { id: "weather-1", + sourceId: "aelis.weather", type: "weather", timestamp: new Date(), data: { temp: 18 }, @@ -41,6 +43,7 @@ describe("FeedItem slots", () => { test("FeedItem with filled slots", () => { const item: FeedItem<"weather", { temp: number }> = { id: "weather-1", + sourceId: "aelis.weather", type: "weather", timestamp: new Date(), data: { temp: 18 }, @@ -75,6 +78,7 @@ describe("FeedItem slots", () => { test("FeedItem with empty slots record", () => { const item: FeedItem<"test", { value: number }> = { id: "test-1", + sourceId: "test-source", type: "test", timestamp: new Date(), data: { value: 1 }, diff --git a/packages/aelis-core/src/feed.ts b/packages/aelis-core/src/feed.ts index 515688d..ba8c848 100644 --- a/packages/aelis-core/src/feed.ts +++ b/packages/aelis-core/src/feed.ts @@ -48,6 +48,7 @@ export interface Slot { * * const item: WeatherItem = { * id: "weather-123", + * sourceId: "aelis.weatherkit", * type: "weather", * timestamp: new Date(), * data: { temp: 18, condition: "cloudy" }, @@ -67,6 +68,8 @@ export interface FeedItem< > { /** Unique identifier */ id: string + /** ID of the FeedSource that produced this item */ + sourceId: string /** Item type, matches the data source type */ type: TType /** When this item was generated */ diff --git a/packages/aelis-data-source-weatherkit/src/data-source.ts b/packages/aelis-data-source-weatherkit/src/data-source.ts index a0427b7..4df49de 100644 --- a/packages/aelis-data-source-weatherkit/src/data-source.ts +++ b/packages/aelis-data-source-weatherkit/src/data-source.ts @@ -47,6 +47,8 @@ interface LocationData { const LocationKey: ContextKey = contextKey("aelis.location", "location") +const SOURCE_ID = "aelis.weather" + export class WeatherKitDataSource implements DataSource { private readonly DEFAULT_HOURLY_LIMIT = 12 private readonly DEFAULT_DAILY_LIMIT = 7 @@ -236,6 +238,7 @@ function createCurrentWeatherFeedItem( return { id: `weather-current-${timestamp.getTime()}`, + sourceId: SOURCE_ID, type: WeatherFeedItemType.Current, timestamp, data: { @@ -270,6 +273,7 @@ function createHourlyWeatherFeedItem( return { id: `weather-hourly-${timestamp.getTime()}-${index}`, + sourceId: SOURCE_ID, type: WeatherFeedItemType.Hourly, timestamp, data: { @@ -304,6 +308,7 @@ function createDailyWeatherFeedItem( return { id: `weather-daily-${timestamp.getTime()}-${index}`, + sourceId: SOURCE_ID, type: WeatherFeedItemType.Daily, timestamp, data: { @@ -331,6 +336,7 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath return { id: `weather-alert-${alert.id}`, + sourceId: SOURCE_ID, type: WeatherFeedItemType.Alert, timestamp, data: { diff --git a/packages/aelis-feed-enhancers/src/time-of-day-enhancer.test.ts b/packages/aelis-feed-enhancers/src/time-of-day-enhancer.test.ts index e040793..1ce9c9a 100644 --- a/packages/aelis-feed-enhancers/src/time-of-day-enhancer.test.ts +++ b/packages/aelis-feed-enhancers/src/time-of-day-enhancer.test.ts @@ -40,6 +40,7 @@ function saturday(hour: number, minute = 0): Date { function weatherCurrent(id = "w-current"): FeedItem { return { id, + sourceId: "aelis.weather", type: WeatherFeedItemType.Current, timestamp: new Date(), data: { temperature: 18, precipitationIntensity: 0 }, @@ -49,6 +50,7 @@ function weatherCurrent(id = "w-current"): FeedItem { function weatherCurrentRainy(id = "w-current-rain"): FeedItem { return { id, + sourceId: "aelis.weather", type: WeatherFeedItemType.Current, timestamp: new Date(), data: { temperature: 12, precipitationIntensity: 2.5 }, @@ -58,6 +60,7 @@ function weatherCurrentRainy(id = "w-current-rain"): FeedItem { function weatherCurrentExtreme(id = "w-current-extreme"): FeedItem { return { id, + sourceId: "aelis.weather", type: WeatherFeedItemType.Current, timestamp: new Date(), data: { temperature: -5, precipitationIntensity: 0 }, @@ -67,6 +70,7 @@ function weatherCurrentExtreme(id = "w-current-extreme"): FeedItem { function weatherHourly(id = "w-hourly"): FeedItem { return { id, + sourceId: "aelis.weather", type: WeatherFeedItemType.Hourly, timestamp: new Date(), data: { forecastTime: new Date(), temperature: 20 }, @@ -76,6 +80,7 @@ function weatherHourly(id = "w-hourly"): FeedItem { function weatherDaily(id = "w-daily"): FeedItem { return { id, + sourceId: "aelis.weather", type: WeatherFeedItemType.Daily, timestamp: new Date(), data: { forecastDate: new Date() }, @@ -85,6 +90,7 @@ function weatherDaily(id = "w-daily"): FeedItem { function weatherAlert(id = "w-alert", urgency = 0.9): FeedItem { return { id, + sourceId: "aelis.weather", type: WeatherFeedItemType.Alert, timestamp: new Date(), data: { severity: "extreme" }, @@ -99,6 +105,7 @@ function calendarEvent( ): FeedItem { return { id, + sourceId: "aelis.google-calendar", type: CalendarFeedItemType.Event, timestamp: new Date(), data: { @@ -120,6 +127,7 @@ function calendarEvent( function calendarAllDay(id: string): FeedItem { return { id, + sourceId: "aelis.google-calendar", type: CalendarFeedItemType.AllDay, timestamp: new Date(), data: { @@ -145,6 +153,7 @@ function caldavEvent( ): FeedItem { return { id, + sourceId: "aelis.caldav", type: CalDavFeedItemType.Event, timestamp: new Date(), data: { @@ -170,6 +179,7 @@ function caldavEvent( function tflAlert(id = "tfl-1", urgency = 0.8): FeedItem { return { id, + sourceId: "aelis.tfl", type: TflFeedItemType.Alert, timestamp: new Date(), data: { @@ -185,6 +195,7 @@ function tflAlert(id = "tfl-1", urgency = 0.8): FeedItem { function unknownItem(id = "unknown-1"): FeedItem { return { id, + sourceId: "unknown", type: "some-future-type", timestamp: new Date(), data: { foo: "bar" }, diff --git a/packages/aelis-source-caldav/src/caldav-source.ts b/packages/aelis-source-caldav/src/caldav-source.ts index 888e876..c37e9d9 100644 --- a/packages/aelis-source-caldav/src/caldav-source.ts +++ b/packages/aelis-source-caldav/src/caldav-source.ts @@ -133,7 +133,7 @@ export class CalDavSource implements FeedSource { async fetchItems(context: Context): Promise { const now = context.time const events = await this.fetchEvents(context) - return events.map((event) => createFeedItem(event, now, this.timeZone)) + return events.map((event) => createFeedItem(event, now, this.id, this.timeZone)) } private fetchEvents(context: Context): Promise { @@ -351,9 +351,15 @@ function createEventSlots(): Record { } } -function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem { +function createFeedItem( + event: CalDavEventData, + now: Date, + sourceId: string, + timeZone?: string, +): CalDavFeedItem { return { id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`, + sourceId, type: CalDavFeedItemType.Event, timestamp: now, data: event, diff --git a/packages/aelis-source-google-calendar/src/google-calendar-source.ts b/packages/aelis-source-google-calendar/src/google-calendar-source.ts index 1722a86..d355471 100644 --- a/packages/aelis-source-google-calendar/src/google-calendar-source.ts +++ b/packages/aelis-source-google-calendar/src/google-calendar-source.ts @@ -113,7 +113,7 @@ export class GoogleCalendarSource implements FeedSource { const now = context.time.getTime() const lookaheadMs = this.lookaheadHours * 60 * 60 * 1000 - return events.map((event) => createFeedItem(event, now, lookaheadMs)) + return events.map((event) => createFeedItem(event, now, lookaheadMs, this.id)) } private async resolveCalendarIds(): Promise { @@ -208,11 +208,13 @@ function createFeedItem( event: CalendarEventData, nowMs: number, lookaheadMs: number, + sourceId: string, ): CalendarFeedItem { const itemType = event.isAllDay ? CalendarFeedItemType.AllDay : CalendarFeedItemType.Event return { id: `calendar-${event.calendarId}-${event.eventId}`, + sourceId, type: itemType, timestamp: new Date(nowMs), data: event, diff --git a/packages/aelis-source-tfl/src/tfl-source.ts b/packages/aelis-source-tfl/src/tfl-source.ts index 3d2ecb8..a72667b 100644 --- a/packages/aelis-source-tfl/src/tfl-source.ts +++ b/packages/aelis-source-tfl/src/tfl-source.ts @@ -151,6 +151,7 @@ export class TflSource implements FeedSource { return { id: `tfl-alert-${status.lineId}-${status.severity}`, + sourceId: this.id, type: TflFeedItemType.Alert, timestamp: context.time, data, diff --git a/packages/aelis-source-weatherkit/src/weather-source.ts b/packages/aelis-source-weatherkit/src/weather-source.ts index bdf0237..5fb0fd9 100644 --- a/packages/aelis-source-weatherkit/src/weather-source.ts +++ b/packages/aelis-source-weatherkit/src/weather-source.ts @@ -167,7 +167,9 @@ export class WeatherSource implements FeedSource { const items: WeatherFeedItem[] = [] if (response.currentWeather) { - items.push(createCurrentWeatherFeedItem(response.currentWeather, timestamp, this.units)) + items.push( + createCurrentWeatherFeedItem(response.currentWeather, timestamp, this.units, this.id), + ) } if (response.forecastHourly?.hours) { @@ -175,7 +177,7 @@ export class WeatherSource implements FeedSource { for (let i = 0; i < hours.length; i++) { const hour = hours[i] if (hour) { - items.push(createHourlyWeatherFeedItem(hour, i, timestamp, this.units)) + items.push(createHourlyWeatherFeedItem(hour, i, timestamp, this.units, this.id)) } } } @@ -185,14 +187,14 @@ export class WeatherSource implements FeedSource { for (let i = 0; i < days.length; i++) { const day = days[i] if (day) { - items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units)) + items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units, this.id)) } } } if (response.weatherAlerts?.alerts) { for (const alert of response.weatherAlerts.alerts) { - items.push(createWeatherAlertFeedItem(alert, timestamp)) + items.push(createWeatherAlertFeedItem(alert, timestamp, this.id)) } } @@ -284,6 +286,7 @@ function createCurrentWeatherFeedItem( current: CurrentWeather, timestamp: Date, units: Units, + sourceId: string, ): WeatherFeedItem { const signals: FeedItemSignals = { urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode), @@ -292,6 +295,7 @@ function createCurrentWeatherFeedItem( return { id: `weather-current-${timestamp.getTime()}`, + sourceId, type: WeatherFeedItemType.Current, timestamp, data: { @@ -324,6 +328,7 @@ function createHourlyWeatherFeedItem( index: number, timestamp: Date, units: Units, + sourceId: string, ): WeatherFeedItem { const signals: FeedItemSignals = { urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode), @@ -332,6 +337,7 @@ function createHourlyWeatherFeedItem( return { id: `weather-hourly-${timestamp.getTime()}-${index}`, + sourceId, type: WeatherFeedItemType.Hourly, timestamp, data: { @@ -358,6 +364,7 @@ function createDailyWeatherFeedItem( index: number, timestamp: Date, units: Units, + sourceId: string, ): WeatherFeedItem { const signals: FeedItemSignals = { urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode), @@ -366,6 +373,7 @@ function createDailyWeatherFeedItem( return { id: `weather-daily-${timestamp.getTime()}-${index}`, + sourceId, type: WeatherFeedItemType.Daily, timestamp, data: { @@ -385,7 +393,11 @@ function createDailyWeatherFeedItem( } } -function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherFeedItem { +function createWeatherAlertFeedItem( + alert: WeatherAlert, + timestamp: Date, + sourceId: string, +): WeatherFeedItem { const signals: FeedItemSignals = { urgency: adjustUrgencyForAlertSeverity(alert.severity), timeRelevance: timeRelevanceForAlertSeverity(alert.severity), @@ -393,6 +405,7 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath return { id: `weather-alert-${alert.id}`, + sourceId, type: WeatherFeedItemType.Alert, timestamp, data: {