mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 09:01:19 +00:00
Compare commits
6 Commits
docs/updat
...
feat/feed-
| Author | SHA1 | Date | |
|---|---|---|---|
| f3db2e35e7 | |||
| 46596e53d0 | |||
| bf309fce20 | |||
| 416ee841f6 | |||
| 28d26b3c87 | |||
| 78b0ed94bd |
@@ -18,9 +18,9 @@ import type { FeedItem } from "./feed"
|
|||||||
* return [{
|
* return [{
|
||||||
* id: `weather-${Date.now()}`,
|
* id: `weather-${Date.now()}`,
|
||||||
* type: this.type,
|
* type: this.type,
|
||||||
* priority: 0.5,
|
|
||||||
* timestamp: context.time,
|
* timestamp: context.time,
|
||||||
* data: { temp: data.temperature },
|
* data: { temp: data.temperature },
|
||||||
|
* signals: { urgency: 0.5, timeRelevance: "ambient" },
|
||||||
* }]
|
* }]
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
||||||
|
|
||||||
import { FeedEngine } from "./feed-engine"
|
import { FeedEngine } from "./feed-engine"
|
||||||
import { UnknownActionError, contextKey, contextValue } from "./index"
|
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
|
||||||
|
|
||||||
// No-op action methods for test sources
|
// No-op action methods for test sources
|
||||||
const noActions = {
|
const noActions = {
|
||||||
@@ -100,12 +100,12 @@ function createWeatherSource(
|
|||||||
{
|
{
|
||||||
id: `weather-${Date.now()}`,
|
id: `weather-${Date.now()}`,
|
||||||
type: "weather",
|
type: "weather",
|
||||||
priority: 0.5,
|
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: {
|
data: {
|
||||||
temperature: weather.temperature,
|
temperature: weather.temperature,
|
||||||
condition: weather.condition,
|
condition: weather.condition,
|
||||||
},
|
},
|
||||||
|
signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -131,9 +131,9 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
{
|
{
|
||||||
id: "alert-storm",
|
id: "alert-storm",
|
||||||
type: "alert",
|
type: "alert",
|
||||||
priority: 1.0,
|
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { message: "Storm warning!" },
|
data: { message: "Storm warning!" },
|
||||||
|
signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -322,7 +322,7 @@ describe("FeedEngine", () => {
|
|||||||
expect(items[0]!.type).toBe("weather")
|
expect(items[0]!.type).toBe("weather")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("sorts items by priority descending", async () => {
|
test("returns items in source graph order (no engine-level sorting)", async () => {
|
||||||
const location = createLocationSource()
|
const location = createLocationSource()
|
||||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||||
|
|
||||||
@@ -338,8 +338,12 @@ describe("FeedEngine", () => {
|
|||||||
const { items } = await engine.refresh()
|
const { items } = await engine.refresh()
|
||||||
|
|
||||||
expect(items).toHaveLength(2)
|
expect(items).toHaveLength(2)
|
||||||
expect(items[0]!.type).toBe("alert") // priority 1.0
|
// Items returned in topological order (weather before alert)
|
||||||
expect(items[1]!.type).toBe("weather") // priority 0.5
|
expect(items[0]!.type).toBe("weather")
|
||||||
|
expect(items[1]!.type).toBe("alert")
|
||||||
|
// Signals are preserved for post-processors to consume
|
||||||
|
expect(items[0]!.signals?.urgency).toBe(0.5)
|
||||||
|
expect(items[1]!.signals?.urgency).toBe(1.0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("handles missing upstream context gracefully", async () => {
|
test("handles missing upstream context gracefully", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ActionDefinition } from "./action"
|
import type { ActionDefinition } from "./action"
|
||||||
import type { Context } from "./context"
|
import type { Context } from "./context"
|
||||||
import type { FeedItem } from "./feed"
|
import type { FeedItem } from "./feed"
|
||||||
|
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
|
||||||
import type { FeedSource } from "./feed-source"
|
import type { FeedSource } from "./feed-source"
|
||||||
|
|
||||||
export interface SourceError {
|
export interface SourceError {
|
||||||
@@ -12,6 +13,8 @@ export interface FeedResult<TItem extends FeedItem = FeedItem> {
|
|||||||
context: Context
|
context: Context
|
||||||
items: TItem[]
|
items: TItem[]
|
||||||
errors: SourceError[]
|
errors: SourceError[]
|
||||||
|
/** Item groups produced by post-processors */
|
||||||
|
groupedItems?: ItemGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
|
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
|
||||||
@@ -66,6 +69,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
private subscribers = new Set<FeedSubscriber<TItems>>()
|
private subscribers = new Set<FeedSubscriber<TItems>>()
|
||||||
private cleanups: Array<() => void> = []
|
private cleanups: Array<() => void> = []
|
||||||
private started = false
|
private started = false
|
||||||
|
private postProcessors: FeedPostProcessor[] = []
|
||||||
|
|
||||||
private readonly cacheTtlMs: number
|
private readonly cacheTtlMs: number
|
||||||
private cachedResult: FeedResult<TItems> | null = null
|
private cachedResult: FeedResult<TItems> | null = null
|
||||||
@@ -108,6 +112,23 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a post-processor. Processors run in registration order
|
||||||
|
* after items are collected, on every update path.
|
||||||
|
*/
|
||||||
|
registerPostProcessor(processor: FeedPostProcessor): this {
|
||||||
|
this.postProcessors.push(processor)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters a post-processor by reference.
|
||||||
|
*/
|
||||||
|
unregisterPostProcessor(processor: FeedPostProcessor): this {
|
||||||
|
this.postProcessors = this.postProcessors.filter((p) => p !== processor)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes the feed by running all sources in dependency order.
|
* Refreshes the feed by running all sources in dependency order.
|
||||||
* Calls fetchContext() then fetchItems() on each source.
|
* Calls fetchContext() then fetchItems() on each source.
|
||||||
@@ -150,12 +171,20 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by priority descending
|
|
||||||
items.sort((a, b) => b.priority - a.priority)
|
|
||||||
|
|
||||||
this.context = context
|
this.context = context
|
||||||
|
|
||||||
const result: FeedResult<TItems> = { context, items: items as TItems[], errors }
|
const {
|
||||||
|
items: processedItems,
|
||||||
|
groupedItems,
|
||||||
|
errors: postProcessorErrors,
|
||||||
|
} = await this.applyPostProcessors(items as TItems[], errors)
|
||||||
|
|
||||||
|
const result: FeedResult<TItems> = {
|
||||||
|
context,
|
||||||
|
items: processedItems,
|
||||||
|
errors: postProcessorErrors,
|
||||||
|
...(groupedItems.length > 0 ? { groupedItems } : {}),
|
||||||
|
}
|
||||||
this.updateCache(result)
|
this.updateCache(result)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -263,6 +292,58 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
return actions
|
return actions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async applyPostProcessors(
|
||||||
|
items: TItems[],
|
||||||
|
errors: SourceError[],
|
||||||
|
): Promise<{ items: TItems[]; groupedItems: ItemGroup[]; errors: SourceError[] }> {
|
||||||
|
let currentItems = items
|
||||||
|
const allGroupedItems: ItemGroup[] = []
|
||||||
|
const allErrors = [...errors]
|
||||||
|
|
||||||
|
for (const processor of this.postProcessors) {
|
||||||
|
const snapshot = currentItems
|
||||||
|
try {
|
||||||
|
const enhancement = await processor(currentItems)
|
||||||
|
|
||||||
|
if (enhancement.additionalItems?.length) {
|
||||||
|
// Post-processors operate on FeedItem[] without knowledge of TItems.
|
||||||
|
// Additional items are merged untyped — this is intentional. The
|
||||||
|
// processor contract is "FeedItem in, FeedItem out"; type narrowing
|
||||||
|
// is the caller's responsibility when consuming FeedResult.
|
||||||
|
currentItems = [...currentItems, ...(enhancement.additionalItems as TItems[])]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enhancement.suppress?.length) {
|
||||||
|
const suppressSet = new Set(enhancement.suppress)
|
||||||
|
currentItems = currentItems.filter((item) => !suppressSet.has(item.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enhancement.groupedItems?.length) {
|
||||||
|
allGroupedItems.push(...enhancement.groupedItems)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const sourceId = processor.name || "anonymous"
|
||||||
|
allErrors.push({
|
||||||
|
sourceId,
|
||||||
|
error: err instanceof Error ? err : new Error(String(err)),
|
||||||
|
})
|
||||||
|
currentItems = snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale item IDs from groups and drop empty groups
|
||||||
|
const itemIds = new Set(currentItems.map((item) => item.id))
|
||||||
|
const validGroups = allGroupedItems.reduce<ItemGroup[]>((acc, group) => {
|
||||||
|
const ids = group.itemIds.filter((id) => itemIds.has(id))
|
||||||
|
if (ids.length > 0) {
|
||||||
|
acc.push({ ...group, itemIds: ids })
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { items: currentItems, groupedItems: validGroups, errors: allErrors }
|
||||||
|
}
|
||||||
|
|
||||||
private ensureGraph(): SourceGraph {
|
private ensureGraph(): SourceGraph {
|
||||||
if (!this.graph) {
|
if (!this.graph) {
|
||||||
this.graph = buildGraph(Array.from(this.sources.values()))
|
this.graph = buildGraph(Array.from(this.sources.values()))
|
||||||
@@ -314,12 +395,17 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items.sort((a, b) => b.priority - a.priority)
|
const {
|
||||||
|
items: processedItems,
|
||||||
|
groupedItems,
|
||||||
|
errors: postProcessorErrors,
|
||||||
|
} = await this.applyPostProcessors(items as TItems[], errors)
|
||||||
|
|
||||||
const result: FeedResult<TItems> = {
|
const result: FeedResult<TItems> = {
|
||||||
context: this.context,
|
context: this.context,
|
||||||
items: items as TItems[],
|
items: processedItems,
|
||||||
errors,
|
errors: postProcessorErrors,
|
||||||
|
...(groupedItems.length > 0 ? { groupedItems } : {}),
|
||||||
}
|
}
|
||||||
this.updateCache(result)
|
this.updateCache(result)
|
||||||
|
|
||||||
|
|||||||
443
packages/aris-core/src/feed-post-processor.test.ts
Normal file
443
packages/aris-core/src/feed-post-processor.test.ts
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
import { describe, expect, mock, test } from "bun:test"
|
||||||
|
|
||||||
|
import type { ActionDefinition, FeedItem, FeedPostProcessor, FeedSource } from "./index"
|
||||||
|
|
||||||
|
import { FeedEngine } from "./feed-engine"
|
||||||
|
import { UnknownActionError } from "./index"
|
||||||
|
|
||||||
|
// No-op action methods for test sources
|
||||||
|
const noActions = {
|
||||||
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
async executeAction(actionId: string): Promise<void> {
|
||||||
|
throw new UnknownActionError(actionId)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FEED ITEMS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
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 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarItem(id: string, title: string): CalendarItem {
|
||||||
|
return { id, type: "calendar", timestamp: new Date(), data: { title } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TEST SOURCES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function createWeatherSource(items: WeatherItem[]) {
|
||||||
|
return {
|
||||||
|
id: "aris.weather",
|
||||||
|
...noActions,
|
||||||
|
async fetchContext() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems(): Promise<WeatherItem[]> {
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCalendarSource(items: CalendarItem[]) {
|
||||||
|
return {
|
||||||
|
id: "aris.calendar",
|
||||||
|
...noActions,
|
||||||
|
async fetchContext() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems(): Promise<CalendarItem[]> {
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REGISTRATION
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("FeedPostProcessor", () => {
|
||||||
|
describe("registration", () => {
|
||||||
|
test("registerPostProcessor is chainable", () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
const processor: FeedPostProcessor = async () => ({})
|
||||||
|
const result = engine.registerPostProcessor(processor)
|
||||||
|
expect(result).toBe(engine)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unregisterPostProcessor is chainable", () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
const processor: FeedPostProcessor = async () => ({})
|
||||||
|
const result = engine.unregisterPostProcessor(processor)
|
||||||
|
expect(result).toBe(engine)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unregistered processor does not run", async () => {
|
||||||
|
const processor = mock(async () => ({}))
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(processor)
|
||||||
|
.unregisterPostProcessor(processor)
|
||||||
|
|
||||||
|
await engine.refresh()
|
||||||
|
expect(processor).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ADDITIONAL ITEMS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("additionalItems", () => {
|
||||||
|
test("injects additional items into the feed", async () => {
|
||||||
|
const extra = calendarItem("c1", "Meeting")
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(async () => ({ additionalItems: [extra] }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.items).toHaveLength(2)
|
||||||
|
expect(result.items.find((i) => i.id === "c1")).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SUPPRESS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("suppress", () => {
|
||||||
|
test("removes suppressed items from the feed", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||||
|
.registerPostProcessor(async () => ({ suppress: ["w1"] }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
expect(result.items[0].id).toBe("w2")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("suppressing nonexistent ID is a no-op", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(async () => ({ suppress: ["nonexistent"] }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GROUPED ITEMS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("groupedItems", () => {
|
||||||
|
test("accumulates grouped items on FeedResult", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(
|
||||||
|
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
|
||||||
|
)
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Busy afternoon" }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.groupedItems).toEqual([{ itemIds: ["c1", "c2"], summary: "Busy afternoon" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("multiple processors accumulate groups", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(
|
||||||
|
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
|
||||||
|
)
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
groupedItems: [{ itemIds: ["c1"], summary: "Group A" }],
|
||||||
|
}))
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
groupedItems: [{ itemIds: ["c2"], summary: "Group B" }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.groupedItems).toEqual([
|
||||||
|
{ itemIds: ["c1"], summary: "Group A" },
|
||||||
|
{ itemIds: ["c2"], summary: "Group B" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stale item IDs are removed from groups after suppression", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(
|
||||||
|
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
|
||||||
|
)
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Afternoon" }],
|
||||||
|
}))
|
||||||
|
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.groupedItems).toEqual([{ itemIds: ["c2"], summary: "Afternoon" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("groups with all items suppressed are dropped", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createCalendarSource([calendarItem("c1", "Meeting A")]))
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
groupedItems: [{ itemIds: ["c1"], summary: "Solo" }],
|
||||||
|
}))
|
||||||
|
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.groupedItems).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("groupedItems is omitted when no processors produce groups", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(async () => ({}))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.groupedItems).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PIPELINE ORDERING
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("pipeline ordering", () => {
|
||||||
|
test("each processor sees items as modified by the previous processor", async () => {
|
||||||
|
const seen: string[] = []
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
additionalItems: [calendarItem("c1", "Injected")],
|
||||||
|
}))
|
||||||
|
.registerPostProcessor(async (items) => {
|
||||||
|
seen.push(...items.map((i) => i.id))
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
await engine.refresh()
|
||||||
|
expect(seen).toEqual(["w1", "c1"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("suppression in first processor affects second processor", async () => {
|
||||||
|
const seen: string[] = []
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||||
|
.registerPostProcessor(async () => ({ suppress: ["w1"] }))
|
||||||
|
.registerPostProcessor(async (items) => {
|
||||||
|
seen.push(...items.map((i) => i.id))
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
await engine.refresh()
|
||||||
|
expect(seen).toEqual(["w2"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ERROR HANDLING
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("error handling", () => {
|
||||||
|
test("throwing processor is recorded in errors and pipeline continues", async () => {
|
||||||
|
const seen: string[] = []
|
||||||
|
|
||||||
|
async function failingProcessor(): Promise<never> {
|
||||||
|
throw new Error("processor failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(failingProcessor)
|
||||||
|
.registerPostProcessor(async (items) => {
|
||||||
|
seen.push(...items.map((i) => i.id))
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
|
||||||
|
const ppError = result.errors.find((e) => e.sourceId === "failingProcessor")
|
||||||
|
expect(ppError).toBeDefined()
|
||||||
|
expect(ppError!.error.message).toBe("processor failed")
|
||||||
|
|
||||||
|
// Pipeline continued — observer still saw the original item
|
||||||
|
expect(seen).toEqual(["w1"])
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("anonymous throwing processor uses 'anonymous' as sourceId", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(async () => {
|
||||||
|
throw new Error("anon failed")
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
const ppError = result.errors.find((e) => e.sourceId === "anonymous")
|
||||||
|
expect(ppError).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("non-Error throw is wrapped", async () => {
|
||||||
|
async function failingProcessor(): Promise<never> {
|
||||||
|
throw "string error"
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||||
|
.registerPostProcessor(failingProcessor)
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
const ppError = result.errors.find((e) => e.sourceId === "failingProcessor")
|
||||||
|
expect(ppError).toBeDefined()
|
||||||
|
expect(ppError!.error).toBeInstanceOf(Error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REACTIVE PATHS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("reactive updates", () => {
|
||||||
|
test("post-processors run during reactive context updates", async () => {
|
||||||
|
let callCount = 0
|
||||||
|
|
||||||
|
let triggerUpdate: ((update: Record<string, unknown>) => void) | null = null
|
||||||
|
|
||||||
|
const source: FeedSource = {
|
||||||
|
id: "aris.reactive",
|
||||||
|
...noActions,
|
||||||
|
async fetchContext() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems() {
|
||||||
|
return [weatherItem("w1", 20)]
|
||||||
|
},
|
||||||
|
onContextUpdate(callback, _getContext) {
|
||||||
|
triggerUpdate = callback
|
||||||
|
return () => {
|
||||||
|
triggerUpdate = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(source)
|
||||||
|
.registerPostProcessor(async () => {
|
||||||
|
callCount++
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.start()
|
||||||
|
|
||||||
|
// Wait for initial periodic refresh
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
const countAfterStart = callCount
|
||||||
|
|
||||||
|
// Trigger a reactive context update
|
||||||
|
triggerUpdate!({ foo: "bar" })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
expect(callCount).toBeGreaterThan(countAfterStart)
|
||||||
|
|
||||||
|
engine.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("post-processors run during reactive item updates", async () => {
|
||||||
|
let callCount = 0
|
||||||
|
|
||||||
|
let triggerItemsUpdate: (() => void) | null = null
|
||||||
|
|
||||||
|
const source: FeedSource = {
|
||||||
|
id: "aris.reactive",
|
||||||
|
...noActions,
|
||||||
|
async fetchContext() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems() {
|
||||||
|
return [weatherItem("w1", 20)]
|
||||||
|
},
|
||||||
|
onItemsUpdate(callback, _getContext) {
|
||||||
|
triggerItemsUpdate = callback
|
||||||
|
return () => {
|
||||||
|
triggerItemsUpdate = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(source)
|
||||||
|
.registerPostProcessor(async () => {
|
||||||
|
callCount++
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.start()
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
const countAfterStart = callCount
|
||||||
|
|
||||||
|
// Trigger a reactive items update
|
||||||
|
triggerItemsUpdate!()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
expect(callCount).toBeGreaterThan(countAfterStart)
|
||||||
|
|
||||||
|
engine.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NO PROCESSORS = NO CHANGE
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("no processors", () => {
|
||||||
|
test("engine without post-processors returns raw items unchanged", async () => {
|
||||||
|
const items = [weatherItem("w1", 20), weatherItem("w2", 25)]
|
||||||
|
const engine = new FeedEngine().register(createWeatherSource(items))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
expect(result.items).toHaveLength(2)
|
||||||
|
expect(result.items[0].id).toBe("w1")
|
||||||
|
expect(result.items[1].id).toBe("w2")
|
||||||
|
expect(result.groupedItems).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// COMBINED ENHANCEMENT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("combined enhancement", () => {
|
||||||
|
test("single processor can use all enhancement fields at once", async () => {
|
||||||
|
const engine = new FeedEngine()
|
||||||
|
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||||
|
.registerPostProcessor(async () => ({
|
||||||
|
additionalItems: [calendarItem("c1", "Injected")],
|
||||||
|
suppress: ["w2"],
|
||||||
|
groupedItems: [{ itemIds: ["w1", "c1"], summary: "Related" }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await engine.refresh()
|
||||||
|
|
||||||
|
// w2 suppressed, c1 injected → w1 + c1
|
||||||
|
expect(result.items).toHaveLength(2)
|
||||||
|
expect(result.items.map((i) => i.id)).toEqual(["w1", "c1"])
|
||||||
|
|
||||||
|
// Groups on result
|
||||||
|
expect(result.groupedItems).toEqual([{ itemIds: ["w1", "c1"], summary: "Related" }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
23
packages/aris-core/src/feed-post-processor.ts
Normal file
23
packages/aris-core/src/feed-post-processor.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { FeedItem } from "./feed"
|
||||||
|
|
||||||
|
export interface ItemGroup {
|
||||||
|
/** IDs of items to present together */
|
||||||
|
itemIds: string[]
|
||||||
|
/** Summary text for the group */
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedEnhancement {
|
||||||
|
/** New items to inject into the feed */
|
||||||
|
additionalItems?: FeedItem[]
|
||||||
|
/** Groups of items to present together with a summary */
|
||||||
|
groupedItems?: ItemGroup[]
|
||||||
|
/** Item IDs to remove from the feed */
|
||||||
|
suppress?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that transforms feed items and produces enhancement directives.
|
||||||
|
* Use named functions for meaningful error attribution.
|
||||||
|
*/
|
||||||
|
export type FeedPostProcessor = (items: FeedItem[]) => Promise<FeedEnhancement>
|
||||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
|
|
||||||
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
||||||
|
|
||||||
import { UnknownActionError, contextKey, contextValue } from "./index"
|
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
|
||||||
|
|
||||||
// No-op action methods for test sources
|
// No-op action methods for test sources
|
||||||
const noActions = {
|
const noActions = {
|
||||||
@@ -99,12 +99,12 @@ function createWeatherSource(
|
|||||||
{
|
{
|
||||||
id: `weather-${Date.now()}`,
|
id: `weather-${Date.now()}`,
|
||||||
type: "weather",
|
type: "weather",
|
||||||
priority: 0.5,
|
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: {
|
data: {
|
||||||
temperature: weather.temperature,
|
temperature: weather.temperature,
|
||||||
condition: weather.condition,
|
condition: weather.condition,
|
||||||
},
|
},
|
||||||
|
signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -130,9 +130,9 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
{
|
{
|
||||||
id: "alert-storm",
|
id: "alert-storm",
|
||||||
type: "alert",
|
type: "alert",
|
||||||
priority: 1.0,
|
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { message: "Storm warning!" },
|
data: { message: "Storm warning!" },
|
||||||
|
signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -226,9 +226,6 @@ async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; ite
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by priority descending
|
|
||||||
items.sort((a, b) => b.priority - a.priority)
|
|
||||||
|
|
||||||
return { context, items }
|
return { context, items }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,8 +438,12 @@ describe("FeedSource", () => {
|
|||||||
const { items } = await refreshGraph(graph)
|
const { items } = await refreshGraph(graph)
|
||||||
|
|
||||||
expect(items).toHaveLength(2)
|
expect(items).toHaveLength(2)
|
||||||
expect(items[0]!.type).toBe("alert") // priority 1.0
|
// Items returned in topological order (weather before alert)
|
||||||
expect(items[1]!.type).toBe("weather") // priority 0.5
|
expect(items[0]!.type).toBe("weather")
|
||||||
|
expect(items[1]!.type).toBe("alert")
|
||||||
|
// Signals preserved for post-processors
|
||||||
|
expect(items[0]!.signals?.urgency).toBe(0.5)
|
||||||
|
expect(items[1]!.signals?.urgency).toBe(1.0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("source without location context returns empty items", async () => {
|
test("source without location context returns empty items", async () => {
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Source-provided hints for post-processors.
|
||||||
|
*
|
||||||
|
* Sources express domain-specific relevance without determining final ranking.
|
||||||
|
* Post-processors consume these signals alongside other inputs (user affinity,
|
||||||
|
* time of day, interaction history) to produce the final feed order.
|
||||||
|
*/
|
||||||
|
export const TimeRelevance = {
|
||||||
|
/** Needs attention now (e.g., event starting in minutes, severe alert) */
|
||||||
|
Imminent: "imminent",
|
||||||
|
/** Relevant soon (e.g., event in the next hour, approaching deadline) */
|
||||||
|
Upcoming: "upcoming",
|
||||||
|
/** Background information (e.g., daily forecast, low-priority status) */
|
||||||
|
Ambient: "ambient",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type TimeRelevance = (typeof TimeRelevance)[keyof typeof TimeRelevance]
|
||||||
|
|
||||||
|
export interface FeedItemSignals {
|
||||||
|
/** Source-assessed urgency (0-1). Post-processors use this as one ranking input. */
|
||||||
|
urgency?: number
|
||||||
|
/** How time-sensitive this item is relative to now. */
|
||||||
|
timeRelevance?: TimeRelevance
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single item in the feed.
|
* A single item in the feed.
|
||||||
*
|
*
|
||||||
@@ -8,9 +33,9 @@
|
|||||||
* const item: WeatherItem = {
|
* const item: WeatherItem = {
|
||||||
* id: "weather-123",
|
* id: "weather-123",
|
||||||
* type: "weather",
|
* type: "weather",
|
||||||
* priority: 0.5,
|
|
||||||
* timestamp: new Date(),
|
* timestamp: new Date(),
|
||||||
* data: { temp: 18, condition: "cloudy" },
|
* data: { temp: 18, condition: "cloudy" },
|
||||||
|
* signals: { urgency: 0.5, timeRelevance: "ambient" },
|
||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
@@ -22,10 +47,10 @@ export interface FeedItem<
|
|||||||
id: string
|
id: string
|
||||||
/** Item type, matches the data source type */
|
/** Item type, matches the data source type */
|
||||||
type: TType
|
type: TType
|
||||||
/** Sort priority (higher = more important, shown first) */
|
|
||||||
priority: number
|
|
||||||
/** When this item was generated */
|
/** When this item was generated */
|
||||||
timestamp: Date
|
timestamp: Date
|
||||||
/** Type-specific payload */
|
/** Type-specific payload */
|
||||||
data: TData
|
data: TData
|
||||||
|
/** Source-provided hints for post-processors. Optional — omit if no signals apply. */
|
||||||
|
signals?: FeedItemSignals
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ export type { ActionDefinition } from "./action"
|
|||||||
export { UnknownActionError } from "./action"
|
export { UnknownActionError } from "./action"
|
||||||
|
|
||||||
// Feed
|
// Feed
|
||||||
export type { FeedItem } from "./feed"
|
export type { FeedItem, FeedItemSignals } from "./feed"
|
||||||
|
export { TimeRelevance } from "./feed"
|
||||||
|
|
||||||
// Feed Source
|
// Feed Source
|
||||||
export type { FeedSource } from "./feed-source"
|
export type { FeedSource } from "./feed-source"
|
||||||
|
|
||||||
|
// Feed Post-Processor
|
||||||
|
export type { FeedEnhancement, FeedPostProcessor, ItemGroup } from "./feed-post-processor"
|
||||||
|
|
||||||
// Feed Engine
|
// Feed Engine
|
||||||
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
|
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
|
||||||
export { FeedEngine } from "./feed-engine"
|
export { FeedEngine } from "./feed-engine"
|
||||||
|
|||||||
@@ -72,8 +72,6 @@ export class Reconciler<TItems extends FeedItem = never> {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
items.sort((a, b) => b.priority - a.priority)
|
|
||||||
|
|
||||||
return { items, errors } as ReconcileResult<TItems>
|
return { items, errors } as ReconcileResult<TItems>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,21 +190,22 @@ describe("query() with mocked client", () => {
|
|||||||
expect(imperialTemp).toBeCloseTo(expectedImperial, 2)
|
expect(imperialTemp).toBeCloseTo(expectedImperial, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("assigns priority based on weather conditions", async () => {
|
test("assigns signals based on weather conditions", async () => {
|
||||||
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
const items = await dataSource.query(context)
|
const items = await dataSource.query(context)
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
expect(item.priority).toBeGreaterThanOrEqual(0)
|
expect(item.signals).toBeDefined()
|
||||||
expect(item.priority).toBeLessThanOrEqual(1)
|
expect(item.signals!.urgency).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(item.signals!.urgency).toBeLessThanOrEqual(1)
|
||||||
|
expect(item.signals!.timeRelevance).toBeDefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
|
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
|
||||||
expect(currentItem).toBeDefined()
|
expect(currentItem).toBeDefined()
|
||||||
// Base priority for current is 0.5, may be adjusted for conditions
|
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
||||||
expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("generates unique IDs for each item", async () => {
|
test("generates unique IDs for each item", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Context, DataSource } from "@aris/core"
|
import type { Context, DataSource, FeedItemSignals } from "@aris/core"
|
||||||
|
|
||||||
|
import { TimeRelevance } from "@aris/core"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WeatherFeedItemType,
|
WeatherFeedItemType,
|
||||||
@@ -105,7 +107,7 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_PRIORITY = {
|
const BASE_URGENCY = {
|
||||||
current: 0.5,
|
current: 0.5,
|
||||||
hourly: 0.3,
|
hourly: 0.3,
|
||||||
daily: 0.2,
|
daily: 0.2,
|
||||||
@@ -134,17 +136,17 @@ const MODERATE_CONDITIONS = new Set<ConditionCode>([
|
|||||||
ConditionCode.BlowingSnow,
|
ConditionCode.BlowingSnow,
|
||||||
])
|
])
|
||||||
|
|
||||||
function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
|
function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number {
|
||||||
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
||||||
return Math.min(1, basePriority + 0.3)
|
return Math.min(1, baseUrgency + 0.3)
|
||||||
}
|
}
|
||||||
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
||||||
return Math.min(1, basePriority + 0.15)
|
return Math.min(1, baseUrgency + 0.15)
|
||||||
}
|
}
|
||||||
return basePriority
|
return baseUrgency
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustPriorityForAlertSeverity(severity: Severity): number {
|
function adjustUrgencyForAlertSeverity(severity: Severity): number {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case Severity.Extreme:
|
case Severity.Extreme:
|
||||||
return 1
|
return 1
|
||||||
@@ -153,7 +155,29 @@ function adjustPriorityForAlertSeverity(severity: Severity): number {
|
|||||||
case Severity.Moderate:
|
case Severity.Moderate:
|
||||||
return 0.75
|
return 0.75
|
||||||
case Severity.Minor:
|
case Severity.Minor:
|
||||||
return BASE_PRIORITY.alert
|
return BASE_URGENCY.alert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeRelevanceForCondition(conditionCode: ConditionCode): TimeRelevance {
|
||||||
|
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
||||||
|
return TimeRelevance.Imminent
|
||||||
|
}
|
||||||
|
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
||||||
|
return TimeRelevance.Upcoming
|
||||||
|
}
|
||||||
|
return TimeRelevance.Ambient
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeRelevanceForAlertSeverity(severity: Severity): TimeRelevance {
|
||||||
|
switch (severity) {
|
||||||
|
case Severity.Extreme:
|
||||||
|
case Severity.Severe:
|
||||||
|
return TimeRelevance.Imminent
|
||||||
|
case Severity.Moderate:
|
||||||
|
return TimeRelevance.Upcoming
|
||||||
|
case Severity.Minor:
|
||||||
|
return TimeRelevance.Ambient
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,12 +221,14 @@ function createCurrentWeatherFeedItem(
|
|||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
): CurrentWeatherFeedItem {
|
): CurrentWeatherFeedItem {
|
||||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
|
||||||
|
timeRelevance: timeRelevanceForCondition(current.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-current-${timestamp.getTime()}`,
|
id: `weather-current-${timestamp.getTime()}`,
|
||||||
type: WeatherFeedItemType.current,
|
type: WeatherFeedItemType.current,
|
||||||
priority,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
conditionCode: current.conditionCode,
|
conditionCode: current.conditionCode,
|
||||||
@@ -219,6 +245,7 @@ function createCurrentWeatherFeedItem(
|
|||||||
windGust: convertSpeed(current.windGust, units),
|
windGust: convertSpeed(current.windGust, units),
|
||||||
windSpeed: convertSpeed(current.windSpeed, units),
|
windSpeed: convertSpeed(current.windSpeed, units),
|
||||||
},
|
},
|
||||||
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,12 +255,14 @@ function createHourlyWeatherFeedItem(
|
|||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
): HourlyWeatherFeedItem {
|
): HourlyWeatherFeedItem {
|
||||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
|
||||||
|
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||||
type: WeatherFeedItemType.hourly,
|
type: WeatherFeedItemType.hourly,
|
||||||
priority,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
forecastTime: new Date(hourly.forecastStart),
|
forecastTime: new Date(hourly.forecastStart),
|
||||||
@@ -250,6 +279,7 @@ function createHourlyWeatherFeedItem(
|
|||||||
windGust: convertSpeed(hourly.windGust, units),
|
windGust: convertSpeed(hourly.windGust, units),
|
||||||
windSpeed: convertSpeed(hourly.windSpeed, units),
|
windSpeed: convertSpeed(hourly.windSpeed, units),
|
||||||
},
|
},
|
||||||
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,12 +289,14 @@ function createDailyWeatherFeedItem(
|
|||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
): DailyWeatherFeedItem {
|
): DailyWeatherFeedItem {
|
||||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
||||||
|
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||||
type: WeatherFeedItemType.daily,
|
type: WeatherFeedItemType.daily,
|
||||||
priority,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
forecastDate: new Date(daily.forecastStart),
|
forecastDate: new Date(daily.forecastStart),
|
||||||
@@ -279,16 +311,19 @@ function createDailyWeatherFeedItem(
|
|||||||
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
||||||
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
||||||
},
|
},
|
||||||
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem {
|
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem {
|
||||||
const priority = adjustPriorityForAlertSeverity(alert.severity)
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForAlertSeverity(alert.severity),
|
||||||
|
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-alert-${alert.id}`,
|
id: `weather-alert-${alert.id}`,
|
||||||
type: WeatherFeedItemType.alert,
|
type: WeatherFeedItemType.alert,
|
||||||
priority,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
alertId: alert.id,
|
alertId: alert.id,
|
||||||
@@ -302,5 +337,6 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
|
|||||||
source: alert.source,
|
source: alert.source,
|
||||||
urgency: alert.urgency,
|
urgency: alert.urgency,
|
||||||
},
|
},
|
||||||
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Context } from "@aris/core"
|
import type { Context } from "@aris/core"
|
||||||
|
|
||||||
import { contextValue } from "@aris/core"
|
import { TimeRelevance, contextValue } from "@aris/core"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { readFileSync } from "node:fs"
|
import { readFileSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
import { CalendarKey } from "./calendar-context.ts"
|
import { CalendarKey } from "./calendar-context.ts"
|
||||||
import { CalendarSource, computePriority } from "./calendar-source.ts"
|
import { CalendarSource, computeSignals } from "./calendar-source.ts"
|
||||||
|
|
||||||
function loadFixture(name: string): string {
|
function loadFixture(name: string): string {
|
||||||
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
||||||
@@ -185,7 +185,7 @@ describe("CalendarSource", () => {
|
|||||||
expect(items[0]!.timestamp).toEqual(now)
|
expect(items[0]!.timestamp).toEqual(now)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("assigns priority based on event proximity", async () => {
|
test("assigns signals based on event proximity", async () => {
|
||||||
const objects: Record<string, CalendarDAVObject[]> = {
|
const objects: Record<string, CalendarDAVObject[]> = {
|
||||||
"/cal/work": [
|
"/cal/work": [
|
||||||
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
|
||||||
@@ -203,8 +203,10 @@ describe("CalendarSource", () => {
|
|||||||
const standup = items.find((i) => i.data.title === "Team Standup")
|
const standup = items.find((i) => i.data.title === "Team Standup")
|
||||||
const holiday = items.find((i) => i.data.title === "Company Holiday")
|
const holiday = items.find((i) => i.data.title === "Company Holiday")
|
||||||
|
|
||||||
expect(standup!.priority).toBe(0.7) // within 2 hours
|
expect(standup!.signals!.urgency).toBe(0.7) // within 2 hours
|
||||||
expect(holiday!.priority).toBe(0.3) // all-day
|
expect(standup!.signals!.timeRelevance).toBe(TimeRelevance.Upcoming)
|
||||||
|
expect(holiday!.signals!.urgency).toBe(0.3) // all-day
|
||||||
|
expect(holiday!.signals!.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("handles calendar with non-string displayName", async () => {
|
test("handles calendar with non-string displayName", async () => {
|
||||||
@@ -378,7 +380,7 @@ describe("CalendarSource.fetchContext", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("computePriority", () => {
|
describe("computeSignals", () => {
|
||||||
const now = new Date("2026-01-15T12:00:00Z")
|
const now = new Date("2026-01-15T12:00:00Z")
|
||||||
|
|
||||||
function makeEvent(overrides: Partial<CalendarEventData>): CalendarEventData {
|
function makeEvent(overrides: Partial<CalendarEventData>): CalendarEventData {
|
||||||
@@ -401,73 +403,83 @@ describe("computePriority", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("all-day events get priority 0.3", () => {
|
test("all-day events get urgency 0.3 and ambient relevance", () => {
|
||||||
const event = makeEvent({ isAllDay: true })
|
const event = makeEvent({ isAllDay: true })
|
||||||
expect(computePriority(event, now)).toBe(0.3)
|
const signals = computeSignals(event, now)
|
||||||
|
expect(signals.urgency).toBe(0.3)
|
||||||
|
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("events starting within 30 minutes get priority 0.9", () => {
|
test("events starting within 30 minutes get urgency 0.9 and imminent relevance", () => {
|
||||||
const event = makeEvent({
|
const event = makeEvent({
|
||||||
startDate: new Date("2026-01-15T12:20:00Z"),
|
startDate: new Date("2026-01-15T12:20:00Z"),
|
||||||
})
|
})
|
||||||
expect(computePriority(event, now)).toBe(0.9)
|
const signals = computeSignals(event, now)
|
||||||
|
expect(signals.urgency).toBe(0.9)
|
||||||
|
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("events starting exactly at 30 minutes get priority 0.9", () => {
|
test("events starting exactly at 30 minutes get urgency 0.9", () => {
|
||||||
const event = makeEvent({
|
const event = makeEvent({
|
||||||
startDate: new Date("2026-01-15T12:30:00Z"),
|
startDate: new Date("2026-01-15T12:30:00Z"),
|
||||||
})
|
})
|
||||||
expect(computePriority(event, now)).toBe(0.9)
|
expect(computeSignals(event, now).urgency).toBe(0.9)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("events starting within 2 hours get priority 0.7", () => {
|
test("events starting within 2 hours get urgency 0.7 and upcoming relevance", () => {
|
||||||
const event = makeEvent({
|
const event = makeEvent({
|
||||||
startDate: new Date("2026-01-15T13:00:00Z"),
|
startDate: new Date("2026-01-15T13:00:00Z"),
|
||||||
})
|
})
|
||||||
expect(computePriority(event, now)).toBe(0.7)
|
const signals = computeSignals(event, now)
|
||||||
|
expect(signals.urgency).toBe(0.7)
|
||||||
|
expect(signals.timeRelevance).toBe(TimeRelevance.Upcoming)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("events later today get priority 0.5", () => {
|
test("events later today get urgency 0.5", () => {
|
||||||
const event = makeEvent({
|
const event = makeEvent({
|
||||||
startDate: new Date("2026-01-15T20:00:00Z"),
|
startDate: new Date("2026-01-15T20:00:00Z"),
|
||||||
})
|
})
|
||||||
expect(computePriority(event, now)).toBe(0.5)
|
expect(computeSignals(event, now).urgency).toBe(0.5)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("in-progress events get priority 0.8", () => {
|
test("in-progress events get urgency 0.8 and imminent relevance", () => {
|
||||||
const event = makeEvent({
|
const event = makeEvent({
|
||||||
startDate: new Date("2026-01-15T11:00:00Z"),
|
startDate: new Date("2026-01-15T11:00:00Z"),
|
||||||
endDate: new Date("2026-01-15T13:00:00Z"),
|
endDate: new Date("2026-01-15T13:00:00Z"),
|
||||||
})
|
})
|
||||||
expect(computePriority(event, now)).toBe(0.8)
|
const signals = computeSignals(event, now)
|
||||||
|
expect(signals.urgency).toBe(0.8)
|
||||||
|
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("fully past events get priority 0.2", () => {
|
test("fully past events get urgency 0.2 and ambient relevance", () => {
|
||||||
const event = makeEvent({
|
const event = makeEvent({
|
||||||
startDate: new Date("2026-01-15T09:00:00Z"),
|
startDate: new Date("2026-01-15T09:00:00Z"),
|
||||||
endDate: new Date("2026-01-15T10:00:00Z"),
|
endDate: new Date("2026-01-15T10:00:00Z"),
|
||||||
})
|
})
|
||||||
expect(computePriority(event, now)).toBe(0.2)
|
const signals = computeSignals(event, now)
|
||||||
|
expect(signals.urgency).toBe(0.2)
|
||||||
|
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("events on future days get priority 0.2", () => {
|
test("events on future days get urgency 0.2", () => {
|
||||||
const event = makeEvent({
|
const event = makeEvent({
|
||||||
startDate: new Date("2026-01-16T10:00:00Z"),
|
startDate: new Date("2026-01-16T10:00:00Z"),
|
||||||
})
|
})
|
||||||
expect(computePriority(event, now)).toBe(0.2)
|
expect(computeSignals(event, now).urgency).toBe(0.2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("priority boundaries are correct", () => {
|
test("urgency boundaries are correct", () => {
|
||||||
// 31 minutes from now should be 0.7 (within 2 hours, not within 30 min)
|
// 31 minutes from now should be 0.7 (within 2 hours, not within 30 min)
|
||||||
const event31min = makeEvent({
|
const event31min = makeEvent({
|
||||||
startDate: new Date("2026-01-15T12:31:00Z"),
|
startDate: new Date("2026-01-15T12:31:00Z"),
|
||||||
})
|
})
|
||||||
expect(computePriority(event31min, now)).toBe(0.7)
|
expect(computeSignals(event31min, now).urgency).toBe(0.7)
|
||||||
|
|
||||||
// 2 hours 1 minute from now should be 0.5 (later today, not within 2 hours)
|
// 2 hours 1 minute from now should be 0.5 (later today, not within 2 hours)
|
||||||
const event2h1m = makeEvent({
|
const event2h1m = makeEvent({
|
||||||
startDate: new Date("2026-01-15T14:01:00Z"),
|
startDate: new Date("2026-01-15T14:01:00Z"),
|
||||||
})
|
})
|
||||||
expect(computePriority(event2h1m, now)).toBe(0.5)
|
expect(computeSignals(event2h1m, now).urgency).toBe(0.5)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||||
import { UnknownActionError } from "@aris/core"
|
|
||||||
|
|
||||||
|
import { TimeRelevance, UnknownActionError } from "@aris/core"
|
||||||
import { DAVClient } from "tsdav"
|
import { DAVClient } from "tsdav"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -202,9 +202,9 @@ function computeTimeRange(now: Date, lookAheadDays: number): { start: Date; end:
|
|||||||
return { start, end }
|
return { start, end }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computePriority(event: CalendarEventData, now: Date): number {
|
export function computeSignals(event: CalendarEventData, now: Date): FeedItemSignals {
|
||||||
if (event.isAllDay) {
|
if (event.isAllDay) {
|
||||||
return 0.3
|
return { urgency: 0.3, timeRelevance: TimeRelevance.Ambient }
|
||||||
}
|
}
|
||||||
|
|
||||||
const msUntilStart = event.startDate.getTime() - now.getTime()
|
const msUntilStart = event.startDate.getTime() - now.getTime()
|
||||||
@@ -212,40 +212,41 @@ export function computePriority(event: CalendarEventData, now: Date): number {
|
|||||||
// Event already started
|
// Event already started
|
||||||
if (msUntilStart < 0) {
|
if (msUntilStart < 0) {
|
||||||
const isInProgress = now.getTime() < event.endDate.getTime()
|
const isInProgress = now.getTime() < event.endDate.getTime()
|
||||||
// Currently happening events are high priority; fully past events are low
|
return isInProgress
|
||||||
return isInProgress ? 0.8 : 0.2
|
? { urgency: 0.8, timeRelevance: TimeRelevance.Imminent }
|
||||||
|
: { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Starting within 30 minutes
|
// Starting within 30 minutes
|
||||||
if (msUntilStart <= 30 * 60 * 1000) {
|
if (msUntilStart <= 30 * 60 * 1000) {
|
||||||
return 0.9
|
return { urgency: 0.9, timeRelevance: TimeRelevance.Imminent }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Starting within 2 hours
|
// Starting within 2 hours
|
||||||
if (msUntilStart <= 2 * 60 * 60 * 1000) {
|
if (msUntilStart <= 2 * 60 * 60 * 1000) {
|
||||||
return 0.7
|
return { urgency: 0.7, timeRelevance: TimeRelevance.Upcoming }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Later today (within 24 hours from start of day)
|
// Later today
|
||||||
const startOfDay = new Date(now)
|
const startOfDay = new Date(now)
|
||||||
startOfDay.setUTCHours(0, 0, 0, 0)
|
startOfDay.setUTCHours(0, 0, 0, 0)
|
||||||
const endOfDay = new Date(startOfDay)
|
const endOfDay = new Date(startOfDay)
|
||||||
endOfDay.setUTCDate(endOfDay.getUTCDate() + 1)
|
endOfDay.setUTCDate(endOfDay.getUTCDate() + 1)
|
||||||
|
|
||||||
if (event.startDate.getTime() < endOfDay.getTime()) {
|
if (event.startDate.getTime() < endOfDay.getTime()) {
|
||||||
return 0.5
|
return { urgency: 0.5, timeRelevance: TimeRelevance.Upcoming }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Future days
|
// Future days
|
||||||
return 0.2
|
return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFeedItem(event: CalendarEventData, now: Date): CalendarFeedItem {
|
function createFeedItem(event: CalendarEventData, now: Date): CalendarFeedItem {
|
||||||
return {
|
return {
|
||||||
id: `calendar-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
id: `calendar-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
||||||
type: "calendar-event",
|
type: "calendar-event",
|
||||||
priority: computePriority(event, now),
|
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
data: event,
|
data: event,
|
||||||
|
signals: computeSignals(event, now),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { contextValue, type Context } from "@aris/core"
|
import { TimeRelevance, contextValue, type Context } from "@aris/core"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
||||||
@@ -81,16 +81,17 @@ describe("GoogleCalendarSource", () => {
|
|||||||
expect(allDayItems.length).toBe(1)
|
expect(allDayItems.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("ongoing events get highest priority (1.0)", async () => {
|
test("ongoing events get highest urgency (1.0)", async () => {
|
||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")
|
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")
|
||||||
expect(ongoing).toBeDefined()
|
expect(ongoing).toBeDefined()
|
||||||
expect(ongoing!.priority).toBe(1.0)
|
expect(ongoing!.signals!.urgency).toBe(1.0)
|
||||||
|
expect(ongoing!.signals!.timeRelevance).toBe(TimeRelevance.Imminent)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("upcoming events get higher priority when sooner", async () => {
|
test("upcoming events get higher urgency when sooner", async () => {
|
||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
@@ -99,16 +100,17 @@ describe("GoogleCalendarSource", () => {
|
|||||||
|
|
||||||
expect(soon).toBeDefined()
|
expect(soon).toBeDefined()
|
||||||
expect(later).toBeDefined()
|
expect(later).toBeDefined()
|
||||||
expect(soon!.priority).toBeGreaterThan(later!.priority)
|
expect(soon!.signals!.urgency).toBeGreaterThan(later!.signals!.urgency!)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("all-day events get flat priority (0.4)", async () => {
|
test("all-day events get flat urgency (0.4)", async () => {
|
||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
const allDay = items.find((i) => i.data.eventId === "evt-allday")
|
const allDay = items.find((i) => i.data.eventId === "evt-allday")
|
||||||
expect(allDay).toBeDefined()
|
expect(allDay).toBeDefined()
|
||||||
expect(allDay!.priority).toBe(0.4)
|
expect(allDay!.signals!.urgency).toBe(0.4)
|
||||||
|
expect(allDay!.signals!.timeRelevance).toBe(TimeRelevance.Ambient)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("generates unique IDs for each item", async () => {
|
test("generates unique IDs for each item", async () => {
|
||||||
@@ -280,7 +282,7 @@ describe("GoogleCalendarSource", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("priority ordering", () => {
|
describe("urgency ordering", () => {
|
||||||
test("ongoing > upcoming > all-day", async () => {
|
test("ongoing > upcoming > all-day", async () => {
|
||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
@@ -289,8 +291,8 @@ describe("GoogleCalendarSource", () => {
|
|||||||
const upcoming = items.find((i) => i.data.eventId === "evt-soon")!
|
const upcoming = items.find((i) => i.data.eventId === "evt-soon")!
|
||||||
const allDay = items.find((i) => i.data.eventId === "evt-allday")!
|
const allDay = items.find((i) => i.data.eventId === "evt-allday")!
|
||||||
|
|
||||||
expect(ongoing.priority).toBeGreaterThan(upcoming.priority)
|
expect(ongoing.signals!.urgency).toBeGreaterThan(upcoming.signals!.urgency!)
|
||||||
expect(upcoming.priority).toBeGreaterThan(allDay.priority)
|
expect(upcoming.signals!.urgency).toBeGreaterThan(allDay.signals!.urgency!)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { UnknownActionError } from "@aris/core"
|
import { TimeRelevance, UnknownActionError } from "@aris/core"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ApiCalendarEvent,
|
ApiCalendarEvent,
|
||||||
@@ -35,10 +35,10 @@ import { DefaultGoogleCalendarClient } from "./google-calendar-api"
|
|||||||
|
|
||||||
const DEFAULT_LOOKAHEAD_HOURS = 24
|
const DEFAULT_LOOKAHEAD_HOURS = 24
|
||||||
|
|
||||||
const PRIORITY_ONGOING = 1.0
|
const URGENCY_ONGOING = 1.0
|
||||||
const PRIORITY_UPCOMING_MAX = 0.9
|
const URGENCY_UPCOMING_MAX = 0.9
|
||||||
const PRIORITY_UPCOMING_MIN = 0.3
|
const URGENCY_UPCOMING_MIN = 0.3
|
||||||
const PRIORITY_ALL_DAY = 0.4
|
const URGENCY_ALL_DAY = 0.4
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A FeedSource that provides Google Calendar events and next-event context.
|
* A FeedSource that provides Google Calendar events and next-event context.
|
||||||
@@ -171,9 +171,13 @@ function parseEvent(event: ApiCalendarEvent, calendarId: string): CalendarEventD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function computePriority(event: CalendarEventData, nowMs: number, lookaheadMs: number): number {
|
function computeSignals(
|
||||||
|
event: CalendarEventData,
|
||||||
|
nowMs: number,
|
||||||
|
lookaheadMs: number,
|
||||||
|
): FeedItemSignals {
|
||||||
if (event.isAllDay) {
|
if (event.isAllDay) {
|
||||||
return PRIORITY_ALL_DAY
|
return { urgency: URGENCY_ALL_DAY, timeRelevance: TimeRelevance.Ambient }
|
||||||
}
|
}
|
||||||
|
|
||||||
const startMs = event.startTime.getTime()
|
const startMs = event.startTime.getTime()
|
||||||
@@ -181,17 +185,23 @@ function computePriority(event: CalendarEventData, nowMs: number, lookaheadMs: n
|
|||||||
|
|
||||||
// Ongoing: start <= now < end
|
// Ongoing: start <= now < end
|
||||||
if (startMs <= nowMs && nowMs < endMs) {
|
if (startMs <= nowMs && nowMs < endMs) {
|
||||||
return PRIORITY_ONGOING
|
return { urgency: URGENCY_ONGOING, timeRelevance: TimeRelevance.Imminent }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upcoming: linear decay from PRIORITY_UPCOMING_MAX to PRIORITY_UPCOMING_MIN
|
// Upcoming: linear decay from URGENCY_UPCOMING_MAX to URGENCY_UPCOMING_MIN
|
||||||
const msUntilStart = startMs - nowMs
|
const msUntilStart = startMs - nowMs
|
||||||
if (msUntilStart <= 0) {
|
if (msUntilStart <= 0) {
|
||||||
return PRIORITY_UPCOMING_MIN
|
return { urgency: URGENCY_UPCOMING_MIN, timeRelevance: TimeRelevance.Ambient }
|
||||||
}
|
}
|
||||||
|
|
||||||
const ratio = Math.min(msUntilStart / lookaheadMs, 1)
|
const ratio = Math.min(msUntilStart / lookaheadMs, 1)
|
||||||
return PRIORITY_UPCOMING_MAX - ratio * (PRIORITY_UPCOMING_MAX - PRIORITY_UPCOMING_MIN)
|
const urgency = URGENCY_UPCOMING_MAX - ratio * (URGENCY_UPCOMING_MAX - URGENCY_UPCOMING_MIN)
|
||||||
|
|
||||||
|
// Within 30 minutes = imminent, otherwise upcoming
|
||||||
|
const timeRelevance =
|
||||||
|
msUntilStart <= 30 * 60 * 1000 ? TimeRelevance.Imminent : TimeRelevance.Upcoming
|
||||||
|
|
||||||
|
return { urgency, timeRelevance }
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFeedItem(
|
function createFeedItem(
|
||||||
@@ -199,14 +209,13 @@ function createFeedItem(
|
|||||||
nowMs: number,
|
nowMs: number,
|
||||||
lookaheadMs: number,
|
lookaheadMs: number,
|
||||||
): CalendarFeedItem {
|
): CalendarFeedItem {
|
||||||
const priority = computePriority(event, nowMs, lookaheadMs)
|
|
||||||
const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event
|
const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `calendar-${event.calendarId}-${event.eventId}`,
|
id: `calendar-${event.calendarId}-${event.eventId}`,
|
||||||
type: itemType,
|
type: itemType,
|
||||||
priority,
|
|
||||||
timestamp: new Date(nowMs),
|
timestamp: new Date(nowMs),
|
||||||
data: event,
|
data: event,
|
||||||
|
signals: computeSignals(event, nowMs, lookaheadMs),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,7 +184,8 @@ describe("TflSource", () => {
|
|||||||
expect(typeof item.id).toBe("string")
|
expect(typeof item.id).toBe("string")
|
||||||
expect(item.id).toMatch(/^tfl-alert-/)
|
expect(item.id).toMatch(/^tfl-alert-/)
|
||||||
expect(item.type).toBe("tfl-alert")
|
expect(item.type).toBe("tfl-alert")
|
||||||
expect(typeof item.priority).toBe("number")
|
expect(item.signals).toBeDefined()
|
||||||
|
expect(typeof item.signals!.urgency).toBe("number")
|
||||||
expect(item.timestamp).toBeInstanceOf(Date)
|
expect(item.timestamp).toBeInstanceOf(Date)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -220,29 +221,29 @@ describe("TflSource", () => {
|
|||||||
expect(uniqueIds.size).toBe(ids.length)
|
expect(uniqueIds.size).toBe(ids.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("feed items are sorted by priority descending", async () => {
|
test("feed items are sorted by urgency descending", async () => {
|
||||||
const source = new TflSource({ client: api })
|
const source = new TflSource({ client: api })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
for (let i = 1; i < items.length; i++) {
|
for (let i = 1; i < items.length; i++) {
|
||||||
const prev = items[i - 1]!
|
const prev = items[i - 1]!
|
||||||
const curr = items[i]!
|
const curr = items[i]!
|
||||||
expect(prev.priority).toBeGreaterThanOrEqual(curr.priority)
|
expect(prev.signals!.urgency).toBeGreaterThanOrEqual(curr.signals!.urgency!)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("priority values match severity levels", async () => {
|
test("urgency values match severity levels", async () => {
|
||||||
const source = new TflSource({ client: api })
|
const source = new TflSource({ client: api })
|
||||||
const items = await source.fetchItems(createContext())
|
const items = await source.fetchItems(createContext())
|
||||||
|
|
||||||
const severityPriority: Record<string, number> = {
|
const severityUrgency: Record<string, number> = {
|
||||||
closure: 1.0,
|
closure: 1.0,
|
||||||
"major-delays": 0.8,
|
"major-delays": 0.8,
|
||||||
"minor-delays": 0.6,
|
"minor-delays": 0.6,
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
expect(item.priority).toBe(severityPriority[item.data.severity]!)
|
expect(item.signals!.urgency).toBe(severityUrgency[item.data.severity]!)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -316,9 +317,7 @@ describe("TflSource", () => {
|
|||||||
test("executeAction throws on invalid input", async () => {
|
test("executeAction throws on invalid input", async () => {
|
||||||
const source = new TflSource({ client: api })
|
const source = new TflSource({ client: api })
|
||||||
|
|
||||||
await expect(
|
await expect(source.executeAction("set-lines-of-interest", "not-an-array")).rejects.toThrow()
|
||||||
source.executeAction("set-lines-of-interest", "not-an-array"),
|
|
||||||
).rejects.toThrow()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("executeAction throws for unknown action", async () => {
|
test("executeAction throws for unknown action", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { UnknownActionError, contextValue } from "@aris/core"
|
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
|
||||||
import { LocationKey } from "@aris/source-location"
|
import { LocationKey } from "@aris/source-location"
|
||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
|
|
||||||
@@ -18,12 +18,18 @@ import { TflApi, lineId } from "./tfl-api.ts"
|
|||||||
|
|
||||||
const setLinesInput = lineId.array()
|
const setLinesInput = lineId.array()
|
||||||
|
|
||||||
const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
|
const SEVERITY_URGENCY: Record<TflAlertSeverity, number> = {
|
||||||
closure: 1.0,
|
closure: 1.0,
|
||||||
"major-delays": 0.8,
|
"major-delays": 0.8,
|
||||||
"minor-delays": 0.6,
|
"minor-delays": 0.6,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SEVERITY_TIME_RELEVANCE: Record<TflAlertSeverity, TimeRelevance> = {
|
||||||
|
closure: TimeRelevance.Imminent,
|
||||||
|
"major-delays": TimeRelevance.Imminent,
|
||||||
|
"minor-delays": TimeRelevance.Upcoming,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A FeedSource that provides TfL (Transport for London) service alerts.
|
* A FeedSource that provides TfL (Transport for London) service alerts.
|
||||||
*
|
*
|
||||||
@@ -137,19 +143,26 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
|||||||
closestStationDistance,
|
closestStationDistance,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: SEVERITY_URGENCY[status.severity],
|
||||||
|
timeRelevance: SEVERITY_TIME_RELEVANCE[status.severity],
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
||||||
type: "tfl-alert",
|
type: "tfl-alert",
|
||||||
priority: SEVERITY_PRIORITY[status.severity],
|
|
||||||
timestamp: context.time,
|
timestamp: context.time,
|
||||||
data,
|
data,
|
||||||
|
signals,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sort by severity (desc), then by proximity (asc) if location available
|
// Sort by urgency (desc), then by proximity (asc) if location available
|
||||||
items.sort((a, b) => {
|
items.sort((a, b) => {
|
||||||
if (b.priority !== a.priority) {
|
const aUrgency = a.signals?.urgency ?? 0
|
||||||
return b.priority - a.priority
|
const bUrgency = b.signals?.urgency ?? 0
|
||||||
|
if (bUrgency !== aUrgency) {
|
||||||
|
return bUrgency - aUrgency
|
||||||
}
|
}
|
||||||
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
|
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
|
||||||
return a.data.closestStationDistance - b.data.closestStationDistance
|
return a.data.closestStationDistance - b.data.closestStationDistance
|
||||||
|
|||||||
@@ -145,20 +145,22 @@ describe("WeatherSource", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("assigns priority based on weather conditions", async () => {
|
test("assigns signals based on weather conditions", async () => {
|
||||||
const source = new WeatherSource({ client: mockClient })
|
const source = new WeatherSource({ client: mockClient })
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
const items = await source.fetchItems(context)
|
const items = await source.fetchItems(context)
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
expect(item.priority).toBeGreaterThanOrEqual(0)
|
expect(item.signals).toBeDefined()
|
||||||
expect(item.priority).toBeLessThanOrEqual(1)
|
expect(item.signals!.urgency).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(item.signals!.urgency).toBeLessThanOrEqual(1)
|
||||||
|
expect(item.signals!.timeRelevance).toBeDefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
|
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
|
||||||
expect(currentItem).toBeDefined()
|
expect(currentItem).toBeDefined()
|
||||||
expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5)
|
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("generates unique IDs for each item", async () => {
|
test("generates unique IDs for each item", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { UnknownActionError, contextValue } from "@aris/core"
|
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
|
||||||
import { LocationKey } from "@aris/source-location"
|
import { LocationKey } from "@aris/source-location"
|
||||||
|
|
||||||
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
||||||
@@ -38,7 +38,7 @@ export interface WeatherSourceOptions {
|
|||||||
const DEFAULT_HOURLY_LIMIT = 12
|
const DEFAULT_HOURLY_LIMIT = 12
|
||||||
const DEFAULT_DAILY_LIMIT = 7
|
const DEFAULT_DAILY_LIMIT = 7
|
||||||
|
|
||||||
const BASE_PRIORITY = {
|
const BASE_URGENCY = {
|
||||||
current: 0.5,
|
current: 0.5,
|
||||||
hourly: 0.3,
|
hourly: 0.3,
|
||||||
daily: 0.2,
|
daily: 0.2,
|
||||||
@@ -199,17 +199,17 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
|
function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number {
|
||||||
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
||||||
return Math.min(1, basePriority + 0.3)
|
return Math.min(1, baseUrgency + 0.3)
|
||||||
}
|
}
|
||||||
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
||||||
return Math.min(1, basePriority + 0.15)
|
return Math.min(1, baseUrgency + 0.15)
|
||||||
}
|
}
|
||||||
return basePriority
|
return baseUrgency
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustPriorityForAlertSeverity(severity: Severity): number {
|
function adjustUrgencyForAlertSeverity(severity: Severity): number {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case "extreme":
|
case "extreme":
|
||||||
return 1
|
return 1
|
||||||
@@ -218,7 +218,29 @@ function adjustPriorityForAlertSeverity(severity: Severity): number {
|
|||||||
case "moderate":
|
case "moderate":
|
||||||
return 0.75
|
return 0.75
|
||||||
case "minor":
|
case "minor":
|
||||||
return BASE_PRIORITY.alert
|
return BASE_URGENCY.alert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeRelevanceForCondition(conditionCode: ConditionCode): TimeRelevance {
|
||||||
|
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
||||||
|
return TimeRelevance.Imminent
|
||||||
|
}
|
||||||
|
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
||||||
|
return TimeRelevance.Upcoming
|
||||||
|
}
|
||||||
|
return TimeRelevance.Ambient
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeRelevanceForAlertSeverity(severity: Severity): TimeRelevance {
|
||||||
|
switch (severity) {
|
||||||
|
case "extreme":
|
||||||
|
case "severe":
|
||||||
|
return TimeRelevance.Imminent
|
||||||
|
case "moderate":
|
||||||
|
return TimeRelevance.Upcoming
|
||||||
|
case "minor":
|
||||||
|
return TimeRelevance.Ambient
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,12 +284,14 @@ function createCurrentWeatherFeedItem(
|
|||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
): WeatherFeedItem {
|
): WeatherFeedItem {
|
||||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
|
||||||
|
timeRelevance: timeRelevanceForCondition(current.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-current-${timestamp.getTime()}`,
|
id: `weather-current-${timestamp.getTime()}`,
|
||||||
type: WeatherFeedItemType.current,
|
type: WeatherFeedItemType.current,
|
||||||
priority,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
conditionCode: current.conditionCode,
|
conditionCode: current.conditionCode,
|
||||||
@@ -284,6 +308,7 @@ function createCurrentWeatherFeedItem(
|
|||||||
windGust: convertSpeed(current.windGust, units),
|
windGust: convertSpeed(current.windGust, units),
|
||||||
windSpeed: convertSpeed(current.windSpeed, units),
|
windSpeed: convertSpeed(current.windSpeed, units),
|
||||||
},
|
},
|
||||||
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,12 +318,14 @@ function createHourlyWeatherFeedItem(
|
|||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
): WeatherFeedItem {
|
): WeatherFeedItem {
|
||||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
|
||||||
|
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||||
type: WeatherFeedItemType.hourly,
|
type: WeatherFeedItemType.hourly,
|
||||||
priority,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
forecastTime: new Date(hourly.forecastStart),
|
forecastTime: new Date(hourly.forecastStart),
|
||||||
@@ -315,6 +342,7 @@ function createHourlyWeatherFeedItem(
|
|||||||
windGust: convertSpeed(hourly.windGust, units),
|
windGust: convertSpeed(hourly.windGust, units),
|
||||||
windSpeed: convertSpeed(hourly.windSpeed, units),
|
windSpeed: convertSpeed(hourly.windSpeed, units),
|
||||||
},
|
},
|
||||||
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,12 +352,14 @@ function createDailyWeatherFeedItem(
|
|||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
): WeatherFeedItem {
|
): WeatherFeedItem {
|
||||||
const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
||||||
|
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||||
type: WeatherFeedItemType.daily,
|
type: WeatherFeedItemType.daily,
|
||||||
priority,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
forecastDate: new Date(daily.forecastStart),
|
forecastDate: new Date(daily.forecastStart),
|
||||||
@@ -344,16 +374,19 @@ function createDailyWeatherFeedItem(
|
|||||||
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
||||||
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
||||||
},
|
},
|
||||||
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherFeedItem {
|
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherFeedItem {
|
||||||
const priority = adjustPriorityForAlertSeverity(alert.severity)
|
const signals: FeedItemSignals = {
|
||||||
|
urgency: adjustUrgencyForAlertSeverity(alert.severity),
|
||||||
|
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-alert-${alert.id}`,
|
id: `weather-alert-${alert.id}`,
|
||||||
type: WeatherFeedItemType.alert,
|
type: WeatherFeedItemType.alert,
|
||||||
priority,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
alertId: alert.id,
|
alertId: alert.id,
|
||||||
@@ -367,5 +400,6 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
|
|||||||
source: alert.source,
|
source: alert.source,
|
||||||
urgency: alert.urgency,
|
urgency: alert.urgency,
|
||||||
},
|
},
|
||||||
|
signals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user