mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 00:51:20 +00:00
Compare commits
4 Commits
feat/gpg-c
...
feat/calda
| Author | SHA1 | Date | |
|---|---|---|---|
|
d6a4590b5b
|
|||
| 2d7544500d | |||
| 9dc0cc3d2f | |||
| fe1d261f56 |
@@ -177,7 +177,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
items: processedItems,
|
||||
groupedItems,
|
||||
errors: postProcessorErrors,
|
||||
} = await this.applyPostProcessors(items as TItems[], errors)
|
||||
} = await this.applyPostProcessors(items as TItems[], context, errors)
|
||||
|
||||
const result: FeedResult<TItems> = {
|
||||
context,
|
||||
@@ -294,16 +294,18 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
|
||||
private async applyPostProcessors(
|
||||
items: TItems[],
|
||||
context: Context,
|
||||
errors: SourceError[],
|
||||
): Promise<{ items: TItems[]; groupedItems: ItemGroup[]; errors: SourceError[] }> {
|
||||
let currentItems = items
|
||||
const allGroupedItems: ItemGroup[] = []
|
||||
const allErrors = [...errors]
|
||||
const boostScores = new Map<string, number>()
|
||||
|
||||
for (const processor of this.postProcessors) {
|
||||
const snapshot = currentItems
|
||||
try {
|
||||
const enhancement = await processor(currentItems)
|
||||
const enhancement = await processor(currentItems, context)
|
||||
|
||||
if (enhancement.additionalItems?.length) {
|
||||
// Post-processors operate on FeedItem[] without knowledge of TItems.
|
||||
@@ -321,6 +323,12 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
if (enhancement.groupedItems?.length) {
|
||||
allGroupedItems.push(...enhancement.groupedItems)
|
||||
}
|
||||
|
||||
if (enhancement.boost) {
|
||||
for (const [id, score] of Object.entries(enhancement.boost)) {
|
||||
boostScores.set(id, (boostScores.get(id) ?? 0) + score)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const sourceId = processor.name || "anonymous"
|
||||
allErrors.push({
|
||||
@@ -331,6 +339,12 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply boost reordering: positive-boost first (desc), then zero, then negative (desc).
|
||||
// Stable sort within each tier preserves original relative order.
|
||||
if (boostScores.size > 0) {
|
||||
currentItems = applyBoostOrder(currentItems, boostScores)
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
@@ -399,7 +413,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
items: processedItems,
|
||||
groupedItems,
|
||||
errors: postProcessorErrors,
|
||||
} = await this.applyPostProcessors(items as TItems[], errors)
|
||||
} = await this.applyPostProcessors(items as TItems[], this.context, errors)
|
||||
|
||||
const result: FeedResult<TItems> = {
|
||||
context: this.context,
|
||||
@@ -486,6 +500,47 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function applyBoostOrder<T extends FeedItem>(items: T[], boostScores: Map<string, number>): T[] {
|
||||
const positive: T[] = []
|
||||
const neutral: T[] = []
|
||||
const negative: T[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const raw = boostScores.get(item.id)
|
||||
if (raw === undefined || raw === 0) {
|
||||
neutral.push(item)
|
||||
} else {
|
||||
const clamped = clamp(raw, -1, 1)
|
||||
if (clamped > 0) {
|
||||
positive.push(item)
|
||||
} else if (clamped < 0) {
|
||||
negative.push(item)
|
||||
} else {
|
||||
neutral.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort positive descending by boost, negative descending (least negative first, most negative last)
|
||||
positive.sort((a, b) => {
|
||||
const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1)
|
||||
const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1)
|
||||
return bScore - aScore
|
||||
})
|
||||
|
||||
negative.sort((a, b) => {
|
||||
const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1)
|
||||
const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1)
|
||||
return bScore - aScore
|
||||
})
|
||||
|
||||
return [...positive, ...neutral, ...negative]
|
||||
}
|
||||
|
||||
function buildGraph(sources: FeedSource[]): SourceGraph {
|
||||
const byId = new Map<string, FeedSource>()
|
||||
for (const source of sources) {
|
||||
|
||||
@@ -209,6 +209,163 @@ describe("FeedPostProcessor", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// BOOST
|
||||
// =============================================================================
|
||||
|
||||
describe("boost", () => {
|
||||
test("positive boost moves item before non-boosted items", async () => {
|
||||
const engine = new FeedEngine()
|
||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||
.registerPostProcessor(async () => ({ boost: { w2: 0.8 } }))
|
||||
|
||||
const result = await engine.refresh()
|
||||
expect(result.items.map((i) => i.id)).toEqual(["w2", "w1"])
|
||||
})
|
||||
|
||||
test("negative boost moves item after non-boosted items", async () => {
|
||||
const engine = new FeedEngine()
|
||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||
.registerPostProcessor(async () => ({ boost: { w1: -0.5 } }))
|
||||
|
||||
const result = await engine.refresh()
|
||||
expect(result.items.map((i) => i.id)).toEqual(["w2", "w1"])
|
||||
})
|
||||
|
||||
test("multiple boosted items are sorted by boost descending", async () => {
|
||||
const engine = new FeedEngine()
|
||||
.register(
|
||||
createWeatherSource([
|
||||
weatherItem("w1", 20),
|
||||
weatherItem("w2", 25),
|
||||
weatherItem("w3", 30),
|
||||
]),
|
||||
)
|
||||
.registerPostProcessor(async () => ({
|
||||
boost: { w3: 0.3, w1: 0.9 },
|
||||
}))
|
||||
|
||||
const result = await engine.refresh()
|
||||
// w1 (0.9) first, w3 (0.3) second, w2 (no boost) last
|
||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w3", "w2"])
|
||||
})
|
||||
|
||||
test("multiple processors accumulate boost scores", async () => {
|
||||
const engine = new FeedEngine()
|
||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||
.registerPostProcessor(async () => ({ boost: { w1: 0.3 } }))
|
||||
.registerPostProcessor(async () => ({ boost: { w1: 0.4 } }))
|
||||
|
||||
const result = await engine.refresh()
|
||||
// w1 accumulated boost = 0.7, moves before w2
|
||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2"])
|
||||
})
|
||||
|
||||
test("accumulated boost is clamped to [-1, 1]", async () => {
|
||||
const engine = new FeedEngine()
|
||||
.register(
|
||||
createWeatherSource([
|
||||
weatherItem("w1", 20),
|
||||
weatherItem("w2", 25),
|
||||
weatherItem("w3", 30),
|
||||
]),
|
||||
)
|
||||
.registerPostProcessor(async () => ({ boost: { w1: 0.8, w2: 0.9 } }))
|
||||
.registerPostProcessor(async () => ({ boost: { w1: 0.8 } }))
|
||||
|
||||
const result = await engine.refresh()
|
||||
// w1 accumulated = 1.6 clamped to 1, w2 = 0.9 — w1 still first
|
||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2", "w3"])
|
||||
})
|
||||
|
||||
test("out-of-range boost values are clamped", async () => {
|
||||
const engine = new FeedEngine()
|
||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||
.registerPostProcessor(async () => ({ boost: { w1: 5.0 } }))
|
||||
|
||||
const result = await engine.refresh()
|
||||
// Clamped to 1, still boosted to front
|
||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2"])
|
||||
})
|
||||
|
||||
test("boosting a suppressed item is a no-op", async () => {
|
||||
const engine = new FeedEngine()
|
||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||
.registerPostProcessor(async () => ({
|
||||
suppress: ["w1"],
|
||||
boost: { w1: 1.0 },
|
||||
}))
|
||||
|
||||
const result = await engine.refresh()
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0].id).toBe("w2")
|
||||
})
|
||||
|
||||
test("boosting a nonexistent item ID is a no-op", async () => {
|
||||
const engine = new FeedEngine()
|
||||
.register(createWeatherSource([weatherItem("w1", 20)]))
|
||||
.registerPostProcessor(async () => ({ boost: { nonexistent: 1.0 } }))
|
||||
|
||||
const result = await engine.refresh()
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0].id).toBe("w1")
|
||||
})
|
||||
|
||||
test("items with equal boost retain original relative order", async () => {
|
||||
const engine = new FeedEngine()
|
||||
.register(
|
||||
createWeatherSource([
|
||||
weatherItem("w1", 20),
|
||||
weatherItem("w2", 25),
|
||||
weatherItem("w3", 30),
|
||||
]),
|
||||
)
|
||||
.registerPostProcessor(async () => ({
|
||||
boost: { w1: 0.5, w3: 0.5 },
|
||||
}))
|
||||
|
||||
const result = await engine.refresh()
|
||||
// w1 and w3 have equal boost — original order preserved: w1 before w3
|
||||
expect(result.items.map((i) => i.id)).toEqual(["w1", "w3", "w2"])
|
||||
})
|
||||
|
||||
test("negative boosts preserve relative order among demoted items", async () => {
|
||||
const engine = new FeedEngine()
|
||||
.register(
|
||||
createWeatherSource([
|
||||
weatherItem("w1", 20),
|
||||
weatherItem("w2", 25),
|
||||
weatherItem("w3", 30),
|
||||
]),
|
||||
)
|
||||
.registerPostProcessor(async () => ({
|
||||
boost: { w1: -0.3, w2: -0.3 },
|
||||
}))
|
||||
|
||||
const result = await engine.refresh()
|
||||
// w3 (neutral) first, then w1 and w2 (equal negative) in original order
|
||||
expect(result.items.map((i) => i.id)).toEqual(["w3", "w1", "w2"])
|
||||
})
|
||||
|
||||
test("boost works alongside additionalItems and groupedItems", async () => {
|
||||
const extra = calendarItem("c1", "Meeting")
|
||||
|
||||
const engine = new FeedEngine()
|
||||
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
|
||||
.registerPostProcessor(async () => ({
|
||||
additionalItems: [extra],
|
||||
boost: { c1: 1.0 },
|
||||
groupedItems: [{ itemIds: ["w1", "c1"], summary: "Related" }],
|
||||
}))
|
||||
|
||||
const result = await engine.refresh()
|
||||
// c1 boosted to front
|
||||
expect(result.items[0].id).toBe("c1")
|
||||
expect(result.items).toHaveLength(3)
|
||||
expect(result.groupedItems).toEqual([{ itemIds: ["w1", "c1"], summary: "Related" }])
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// PIPELINE ORDERING
|
||||
// =============================================================================
|
||||
@@ -333,12 +490,10 @@ describe("FeedPostProcessor", () => {
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine()
|
||||
.register(source)
|
||||
.registerPostProcessor(async () => {
|
||||
callCount++
|
||||
return {}
|
||||
})
|
||||
const engine = new FeedEngine().register(source).registerPostProcessor(async () => {
|
||||
callCount++
|
||||
return {}
|
||||
})
|
||||
|
||||
engine.start()
|
||||
|
||||
@@ -377,12 +532,10 @@ describe("FeedPostProcessor", () => {
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine()
|
||||
.register(source)
|
||||
.registerPostProcessor(async () => {
|
||||
callCount++
|
||||
return {}
|
||||
})
|
||||
const engine = new FeedEngine().register(source).registerPostProcessor(async () => {
|
||||
callCount++
|
||||
return {}
|
||||
})
|
||||
|
||||
engine.start()
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Context } from "./context"
|
||||
import type { FeedItem } from "./feed"
|
||||
|
||||
export interface ItemGroup {
|
||||
@@ -14,10 +15,12 @@ export interface FeedEnhancement {
|
||||
groupedItems?: ItemGroup[]
|
||||
/** Item IDs to remove from the feed */
|
||||
suppress?: string[]
|
||||
/** Map of item ID to boost score (-1 to 1). Positive promotes, negative demotes. */
|
||||
boost?: Record<string, number>
|
||||
}
|
||||
|
||||
/**
|
||||
* A function that transforms feed items and produces enhancement directives.
|
||||
* Use named functions for meaningful error attribution.
|
||||
*/
|
||||
export type FeedPostProcessor = (items: FeedItem[]) => Promise<FeedEnhancement>
|
||||
export type FeedPostProcessor = (items: FeedItem[], context: Context) => Promise<FeedEnhancement>
|
||||
|
||||
@@ -4,10 +4,10 @@ import { TimeRelevance, UnknownActionError } from "@aris/core"
|
||||
import { DAVClient } from "tsdav"
|
||||
|
||||
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
|
||||
import { CalDavEventStatus } from "./types.ts"
|
||||
|
||||
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||
import { parseICalEvents } from "./ical-parser.ts"
|
||||
import { CalDavEventStatus, CalDavFeedItemType } from "./types.ts"
|
||||
|
||||
// -- Source options --
|
||||
|
||||
@@ -340,7 +340,7 @@ export function computeSignals(
|
||||
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
|
||||
return {
|
||||
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
||||
type: "caldav-event",
|
||||
type: CalDavFeedItemType.Event,
|
||||
timestamp: now,
|
||||
data: event,
|
||||
signals: computeSignals(event, now, timeZone),
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
AttendeeRole,
|
||||
AttendeeStatus,
|
||||
CalDavEventStatus,
|
||||
CalDavFeedItemType,
|
||||
type CalDavAlarm,
|
||||
type CalDavAttendee,
|
||||
type CalDavDAVCalendar,
|
||||
|
||||
@@ -64,9 +64,17 @@ export interface CalDavEventData extends Record<string, unknown> {
|
||||
recurrenceId: string | null
|
||||
}
|
||||
|
||||
// -- Feed item type --
|
||||
|
||||
export const CalDavFeedItemType = {
|
||||
Event: "caldav-event",
|
||||
} as const
|
||||
|
||||
export type CalDavFeedItemType = (typeof CalDavFeedItemType)[keyof typeof CalDavFeedItemType]
|
||||
|
||||
// -- Feed item --
|
||||
|
||||
export type CalDavFeedItem = FeedItem<"caldav-event", CalDavEventData>
|
||||
export type CalDavFeedItem = FeedItem<typeof CalDavFeedItemType.Event, CalDavEventData>
|
||||
|
||||
// -- DAV client interface --
|
||||
|
||||
|
||||
Reference in New Issue
Block a user