feat(backend): add LLM-powered feed enhancement (#58)

* feat(backend): add LLM-powered feed enhancement

Add enhancement harness that fills feed item slots and
generates synthetic items via OpenRouter.

- LLM client with 30s timeout, reusable SDK instance
- Prompt builder with mini calendar and week overview
- arktype schema validation + JSON Schema for structured output
- Pure merge function with clock injection
- Defensive fallback in feed endpoint on enhancement failure
- Skips LLM call when no unfilled slots or no API key

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

* refactor: move feed enhancement into UserSession

Move enhancement logic from HTTP handler into UserSession so the
transport layer has no knowledge of enhancement. UserSession.feed()
handles refresh, enhancement, and caching in one place.

- UserSession subscribes to engine updates and re-enhances eagerly
- Enhancement cache tracks source identity to prevent stale results
- UserSessionManager accepts config object with optional enhancer
- HTTP handler simplified to just call session.feed()

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

* test: add schema sync tests for arktype/JSON Schema drift

Validates reference payloads against both the arktype schema
(parseEnhancementResult) and the OpenRouter JSON Schema structure.
Catches field additions/removals or type changes in either schema.

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

* refactor: rename arktype schemas to match types

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

---------

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-03-05 02:01:30 +00:00
committed by GitHub
parent 31d5aa8d50
commit badc00c43b
20 changed files with 1296 additions and 43 deletions

View File

@@ -0,0 +1,89 @@
import { type } from "arktype"
const SyntheticItem = type({
id: "string",
type: "string",
text: "string",
})
const EnhancementResult = type({
slotFills: "Record<string, Record<string, string | null>>",
syntheticItems: SyntheticItem.array(),
})
export type SyntheticItem = typeof SyntheticItem.infer
export type EnhancementResult = typeof EnhancementResult.infer
/**
* JSON Schema passed to OpenRouter's structured output.
* OpenRouter doesn't support arktype, so this is maintained separately.
*
* ⚠️ Must stay in sync with EnhancementResult above.
* If you add/remove fields, update both schemas.
*/
export const enhancementResultJsonSchema = {
type: "object",
properties: {
slotFills: {
type: "object",
description:
"Map of feed item ID to an object of slot name to filled text content. Use null for slots that cannot be meaningfully filled.",
additionalProperties: {
type: "object",
additionalProperties: {
type: ["string", "null"],
},
},
},
syntheticItems: {
type: "array",
description:
"New feed items to inject (briefings, nudges, cross-source insights). Keep these short and actionable.",
items: {
type: "object",
properties: {
id: {
type: "string",
description: "Unique ID, e.g. 'briefing-morning'",
},
type: {
type: "string",
description: "One of: 'briefing', 'nudge', 'insight'",
},
text: {
type: "string",
description: "Display text, 1-3 sentences",
},
},
required: ["id", "type", "text"],
additionalProperties: false,
},
},
},
required: ["slotFills", "syntheticItems"],
additionalProperties: false,
} as const
/**
* Parses a JSON string into an EnhancementResult.
* Returns null if the input is malformed.
*/
export function parseEnhancementResult(json: string): EnhancementResult | null {
let parsed: unknown
try {
parsed = JSON.parse(json)
} catch {
return null
}
const result = EnhancementResult(parsed)
if (result instanceof type.errors) {
return null
}
return result
}
export function emptyEnhancementResult(): EnhancementResult {
return { slotFills: {}, syntheticItems: [] }
}