mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(notifications): AI-enriched digest delivery (Phase 1) Add personalized LLM-generated executive summaries to digest notifications. When AI_DIGEST_ENABLED=1 (default), the digest cron fetches user preferences (watchlist, panels, frameworks), generates a tailored intelligence brief via Groq/OpenRouter, and prepends it to the story list in both text and HTML formats. New infrastructure: - convex/userPreferences: internalQuery for service-to-service access - convex/http: /relay/user-preferences endpoint (RELAY_SHARED_SECRET auth) - scripts/lib/llm-chain.cjs: shared Ollama->Groq->OpenRouter provider chain - scripts/lib/user-context.cjs: user preference extraction + LLM prompt formatting AI summary is cached (1h TTL) per stories+userContext hash. Falls back to raw digest on LLM failure (no regression). Subject line changes to "Intelligence Brief" when AI summary is present. * feat(notifications): per-user AI digest opt-out toggle AI executive summary in digests is now optional per user via alertRules.aiDigestEnabled (default true). Users can toggle it off in Settings > Notifications > Digest > "AI executive summary". Schema: added aiDigestEnabled to alertRules table Backend: Convex mutations, HTTP relay, edge function all forward the field Frontend: toggle in digest settings section with descriptive copy Digest cron: skips LLM call when rule.aiDigestEnabled === false * fix(notifications): address PR review — cache key, HTML replacement, UA 1. Add variant to AI summary cache key to prevent cross-variant poisoning 2. Use replacer function in html.replace() to avoid $-pattern corruption from LLM output containing dollar amounts ($500M, $1T) 3. Use service UA (worldmonitor-llm/1.0) instead of Chrome UA for LLM calls * fix(notifications): skip AI summary without prefs + fix HTML regex 1. Return null from generateAISummary() when fetchUserPreferences() returns null, so users without saved preferences get raw digest instead of a generic LLM summary 2. Fix HTML replace regex to match actual padding value (40px 32px 0) so the executive summary block is inserted in email HTML * fix(notifications): channel check before LLM, omission-safe aiDigest, richer cache key 1. Move channel fetch + deliverability check BEFORE AI summary generation so users with no verified channels don't burn LLM calls every cron run 2. Only patch aiDigestEnabled when explicitly provided (not undefined), preventing stale frontend tabs from silently clearing an opt-out 3. Include severity, phase, and sources in story hash for cache key so the summary invalidates when those fields change
330 lines
9.8 KiB
TypeScript
330 lines
9.8 KiB
TypeScript
import { ConvexError, v } from "convex/values";
|
||
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
|
||
import { channelTypeValidator, digestModeValidator, quietHoursOverrideValidator, sensitivityValidator } from "./constants";
|
||
|
||
export const getAlertRules = query({
|
||
args: {},
|
||
handler: async (ctx) => {
|
||
const identity = await ctx.auth.getUserIdentity();
|
||
if (!identity) return [];
|
||
return await ctx.db
|
||
.query("alertRules")
|
||
.withIndex("by_user", (q) => q.eq("userId", identity.subject))
|
||
.collect();
|
||
},
|
||
});
|
||
|
||
export const setAlertRules = mutation({
|
||
args: {
|
||
variant: v.string(),
|
||
enabled: v.boolean(),
|
||
eventTypes: v.array(v.string()),
|
||
sensitivity: sensitivityValidator,
|
||
channels: v.array(channelTypeValidator),
|
||
aiDigestEnabled: v.optional(v.boolean()),
|
||
},
|
||
handler: async (ctx, args) => {
|
||
const identity = await ctx.auth.getUserIdentity();
|
||
if (!identity) throw new ConvexError("UNAUTHENTICATED");
|
||
const userId = identity.subject;
|
||
|
||
const existing = await ctx.db
|
||
.query("alertRules")
|
||
.withIndex("by_user_variant", (q) =>
|
||
q.eq("userId", userId).eq("variant", args.variant),
|
||
)
|
||
.unique();
|
||
|
||
const now = Date.now();
|
||
|
||
if (existing) {
|
||
const patch: Record<string, unknown> = {
|
||
enabled: args.enabled,
|
||
eventTypes: args.eventTypes,
|
||
sensitivity: args.sensitivity,
|
||
channels: args.channels,
|
||
updatedAt: now,
|
||
};
|
||
if (args.aiDigestEnabled !== undefined) patch.aiDigestEnabled = args.aiDigestEnabled;
|
||
await ctx.db.patch(existing._id, patch);
|
||
} else {
|
||
await ctx.db.insert("alertRules", {
|
||
userId,
|
||
variant: args.variant,
|
||
enabled: args.enabled,
|
||
eventTypes: args.eventTypes,
|
||
sensitivity: args.sensitivity,
|
||
channels: args.channels,
|
||
aiDigestEnabled: args.aiDigestEnabled ?? true,
|
||
updatedAt: now,
|
||
});
|
||
}
|
||
},
|
||
});
|
||
|
||
export const setDigestSettings = mutation({
|
||
args: {
|
||
variant: v.string(),
|
||
digestMode: digestModeValidator,
|
||
digestHour: v.optional(v.number()),
|
||
digestTimezone: v.optional(v.string()),
|
||
},
|
||
handler: async (ctx, args) => {
|
||
const identity = await ctx.auth.getUserIdentity();
|
||
if (!identity) throw new ConvexError("UNAUTHENTICATED");
|
||
const userId = identity.subject;
|
||
|
||
if (args.digestHour !== undefined && (args.digestHour < 0 || args.digestHour > 23 || !Number.isInteger(args.digestHour))) {
|
||
throw new ConvexError("digestHour must be an integer 0–23");
|
||
}
|
||
if (args.digestTimezone !== undefined) {
|
||
try {
|
||
Intl.DateTimeFormat(undefined, { timeZone: args.digestTimezone });
|
||
} catch {
|
||
throw new ConvexError("digestTimezone must be a valid IANA timezone (e.g. America/New_York)");
|
||
}
|
||
}
|
||
|
||
const existing = await ctx.db
|
||
.query("alertRules")
|
||
.withIndex("by_user_variant", (q) =>
|
||
q.eq("userId", userId).eq("variant", args.variant),
|
||
)
|
||
.unique();
|
||
|
||
const now = Date.now();
|
||
const patch = {
|
||
digestMode: args.digestMode,
|
||
digestHour: args.digestHour,
|
||
digestTimezone: args.digestTimezone,
|
||
updatedAt: now,
|
||
};
|
||
|
||
if (existing) {
|
||
await ctx.db.patch(existing._id, patch);
|
||
} else {
|
||
await ctx.db.insert("alertRules", {
|
||
userId,
|
||
variant: args.variant,
|
||
enabled: true,
|
||
eventTypes: [],
|
||
sensitivity: "all",
|
||
channels: [],
|
||
...patch,
|
||
});
|
||
}
|
||
},
|
||
});
|
||
|
||
export const getAlertRulesByUserId = internalQuery({
|
||
args: { userId: v.string() },
|
||
handler: async (ctx, args) => {
|
||
return await ctx.db
|
||
.query("alertRules")
|
||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||
.collect();
|
||
},
|
||
});
|
||
|
||
export const setAlertRulesForUser = internalMutation({
|
||
args: {
|
||
userId: v.string(),
|
||
variant: v.string(),
|
||
enabled: v.boolean(),
|
||
eventTypes: v.array(v.string()),
|
||
sensitivity: sensitivityValidator,
|
||
channels: v.array(channelTypeValidator),
|
||
aiDigestEnabled: v.optional(v.boolean()),
|
||
},
|
||
handler: async (ctx, args) => {
|
||
const { userId, ...rest } = args;
|
||
const existing = await ctx.db
|
||
.query("alertRules")
|
||
.withIndex("by_user_variant", (q) =>
|
||
q.eq("userId", userId).eq("variant", rest.variant),
|
||
)
|
||
.unique();
|
||
const now = Date.now();
|
||
if (existing) {
|
||
const patch: Record<string, unknown> = {
|
||
enabled: rest.enabled,
|
||
eventTypes: rest.eventTypes,
|
||
sensitivity: rest.sensitivity,
|
||
channels: rest.channels,
|
||
updatedAt: now,
|
||
};
|
||
if (rest.aiDigestEnabled !== undefined) patch.aiDigestEnabled = rest.aiDigestEnabled;
|
||
await ctx.db.patch(existing._id, patch);
|
||
} else {
|
||
await ctx.db.insert("alertRules", { userId, ...rest, updatedAt: now });
|
||
}
|
||
},
|
||
});
|
||
|
||
const QUIET_HOURS_ARGS = {
|
||
variant: v.string(),
|
||
quietHoursEnabled: v.boolean(),
|
||
quietHoursStart: v.optional(v.number()),
|
||
quietHoursEnd: v.optional(v.number()),
|
||
quietHoursTimezone: v.optional(v.string()),
|
||
quietHoursOverride: v.optional(quietHoursOverrideValidator),
|
||
} as const;
|
||
|
||
function validateQuietHoursArgs(args: {
|
||
quietHoursStart?: number;
|
||
quietHoursEnd?: number;
|
||
quietHoursTimezone?: string;
|
||
}) {
|
||
if (args.quietHoursStart !== undefined && (args.quietHoursStart < 0 || args.quietHoursStart > 23 || !Number.isInteger(args.quietHoursStart))) {
|
||
throw new ConvexError("quietHoursStart must be an integer 0–23");
|
||
}
|
||
if (args.quietHoursEnd !== undefined && (args.quietHoursEnd < 0 || args.quietHoursEnd > 23 || !Number.isInteger(args.quietHoursEnd))) {
|
||
throw new ConvexError("quietHoursEnd must be an integer 0–23");
|
||
}
|
||
if (args.quietHoursTimezone !== undefined) {
|
||
try {
|
||
Intl.DateTimeFormat(undefined, { timeZone: args.quietHoursTimezone });
|
||
} catch {
|
||
throw new ConvexError("quietHoursTimezone must be a valid IANA timezone (e.g. America/New_York)");
|
||
}
|
||
}
|
||
}
|
||
|
||
export const setQuietHours = mutation({
|
||
args: QUIET_HOURS_ARGS,
|
||
handler: async (ctx, args) => {
|
||
const identity = await ctx.auth.getUserIdentity();
|
||
if (!identity) throw new ConvexError("UNAUTHENTICATED");
|
||
const userId = identity.subject;
|
||
validateQuietHoursArgs(args);
|
||
|
||
const existing = await ctx.db
|
||
.query("alertRules")
|
||
.withIndex("by_user_variant", (q) =>
|
||
q.eq("userId", userId).eq("variant", args.variant),
|
||
)
|
||
.unique();
|
||
|
||
const now = Date.now();
|
||
const patch = {
|
||
quietHoursEnabled: args.quietHoursEnabled,
|
||
quietHoursStart: args.quietHoursStart,
|
||
quietHoursEnd: args.quietHoursEnd,
|
||
quietHoursTimezone: args.quietHoursTimezone,
|
||
quietHoursOverride: args.quietHoursOverride,
|
||
updatedAt: now,
|
||
};
|
||
|
||
if (existing) {
|
||
await ctx.db.patch(existing._id, patch);
|
||
} else {
|
||
await ctx.db.insert("alertRules", {
|
||
userId,
|
||
variant: args.variant,
|
||
enabled: true,
|
||
eventTypes: [],
|
||
sensitivity: "all",
|
||
channels: [],
|
||
...patch,
|
||
});
|
||
}
|
||
},
|
||
});
|
||
|
||
export const setDigestSettingsForUser = internalMutation({
|
||
args: {
|
||
userId: v.string(),
|
||
variant: v.string(),
|
||
digestMode: digestModeValidator,
|
||
digestHour: v.optional(v.number()),
|
||
digestTimezone: v.optional(v.string()),
|
||
},
|
||
handler: async (ctx, args) => {
|
||
const { userId, variant, ...digest } = args;
|
||
if (digest.digestHour !== undefined && (digest.digestHour < 0 || digest.digestHour > 23 || !Number.isInteger(digest.digestHour))) {
|
||
throw new ConvexError("digestHour must be an integer 0–23");
|
||
}
|
||
if (digest.digestTimezone !== undefined) {
|
||
try {
|
||
Intl.DateTimeFormat(undefined, { timeZone: digest.digestTimezone });
|
||
} catch {
|
||
throw new ConvexError("digestTimezone must be a valid IANA timezone (e.g. America/New_York)");
|
||
}
|
||
}
|
||
const existing = await ctx.db
|
||
.query("alertRules")
|
||
.withIndex("by_user_variant", (q) =>
|
||
q.eq("userId", userId).eq("variant", variant),
|
||
)
|
||
.unique();
|
||
const now = Date.now();
|
||
if (existing) {
|
||
await ctx.db.patch(existing._id, { ...digest, updatedAt: now });
|
||
} else {
|
||
await ctx.db.insert("alertRules", {
|
||
userId, variant, enabled: true, eventTypes: [], sensitivity: "all", channels: [],
|
||
...digest, updatedAt: now,
|
||
});
|
||
}
|
||
},
|
||
});
|
||
|
||
export const setQuietHoursForUser = internalMutation({
|
||
args: { userId: v.string(), ...QUIET_HOURS_ARGS },
|
||
handler: async (ctx, args) => {
|
||
const { userId, ...rest } = args;
|
||
validateQuietHoursArgs(rest);
|
||
|
||
const existing = await ctx.db
|
||
.query("alertRules")
|
||
.withIndex("by_user_variant", (q) =>
|
||
q.eq("userId", userId).eq("variant", rest.variant),
|
||
)
|
||
.unique();
|
||
|
||
const now = Date.now();
|
||
const patch = {
|
||
quietHoursEnabled: rest.quietHoursEnabled,
|
||
quietHoursStart: rest.quietHoursStart,
|
||
quietHoursEnd: rest.quietHoursEnd,
|
||
quietHoursTimezone: rest.quietHoursTimezone,
|
||
quietHoursOverride: rest.quietHoursOverride,
|
||
updatedAt: now,
|
||
};
|
||
|
||
if (existing) {
|
||
await ctx.db.patch(existing._id, patch);
|
||
} else {
|
||
await ctx.db.insert("alertRules", {
|
||
userId, variant: rest.variant, enabled: true,
|
||
eventTypes: [], sensitivity: "all", channels: [],
|
||
...patch,
|
||
});
|
||
}
|
||
},
|
||
});
|
||
|
||
/** Returns all enabled rules that have a non-realtime digestMode set. */
|
||
export const getDigestRules = internalQuery({
|
||
args: {},
|
||
handler: async (ctx) => {
|
||
const enabled = await ctx.db
|
||
.query("alertRules")
|
||
.withIndex("by_enabled", (q) => q.eq("enabled", true))
|
||
.collect();
|
||
return enabled.filter(
|
||
(r) => r.digestMode !== undefined && r.digestMode !== "realtime",
|
||
);
|
||
},
|
||
});
|
||
|
||
export const getByEnabled = query({
|
||
args: { enabled: v.boolean() },
|
||
handler: async (ctx, args) => {
|
||
return await ctx.db
|
||
.query("alertRules")
|
||
.withIndex("by_enabled", (q) => q.eq("enabled", args.enabled))
|
||
.collect();
|
||
},
|
||
});
|