managarten/services/mana-events/src/discovery/feedback.ts
Till JS 2c0d866287 feat(events): Phase 3 — AI tools, Event-Scout template, feedback loop
- Add discover_events (auto) and suggest_event (propose) to shared-ai
  tool catalog. discover_events reads the discovery feed, suggest_event
  creates a proposal to save a discovered event to the user's calendar.
- Add Event-Scout agent template with daily "Events der Woche" mission.
  Policy: discover_events=auto, suggest_event=propose, all else denied.
- Add frontend tool implementations in events/tools.ts — discover_events
  calls the feed API, suggest_event delegates to discoveryStore.saveEvent.
- Add feedback.ts — computes implicit user profile from save/dismiss
  history (category affinity + source quality as 0–2x weight multipliers).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 15:37:28 +02:00

128 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Feedback loop — aggregate save/dismiss ratios per category and source
* to build an implicit user profile for better relevance scoring.
*
* The profile is computed on-demand from discovery_user_actions joined
* with discovered_events. No separate table needed — the action history
* IS the profile.
*/
import { eq, sql } from 'drizzle-orm';
import type { Database } from '../db/connection';
import { discoveryUserActions, discoveredEvents } from '../db/schema/discovery';
/** Per-category affinity based on save/dismiss ratio. */
export interface CategoryAffinity {
category: string;
saves: number;
dismisses: number;
/** 0.02.0 multiplier: >1 means user likes this category, <1 means dislikes. */
weight: number;
}
/** Per-source quality based on save/dismiss ratio. */
export interface SourceQuality {
sourceId: string;
sourceName: string | null;
saves: number;
dismisses: number;
/** 0.02.0 multiplier. Sources with high dismiss rate get penalized. */
weight: number;
}
export interface UserProfile {
categoryAffinities: CategoryAffinity[];
sourceQualities: SourceQuality[];
totalActions: number;
}
/**
* Compute an implicit user profile from their save/dismiss history.
* Returns category affinities and source quality metrics.
*
* Requires at least 5 actions to be meaningful — returns empty profile
* if insufficient data.
*/
export async function computeUserProfile(db: Database, userId: string): Promise<UserProfile> {
// Count total actions to check if we have enough data
const countResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(discoveryUserActions)
.where(eq(discoveryUserActions.userId, userId));
const totalActions = countResult[0]?.count ?? 0;
if (totalActions < 5) {
return { categoryAffinities: [], sourceQualities: [], totalActions };
}
// Category affinities: group by category, count saves/dismisses
const categoryRows = await db.execute(sql`
SELECT
de.category,
COUNT(*) FILTER (WHERE dua.action = 'save') AS saves,
COUNT(*) FILTER (WHERE dua.action = 'dismiss') AS dismisses
FROM event_discovery.discovery_user_actions dua
JOIN event_discovery.discovered_events de ON de.id = dua.event_id
WHERE dua.user_id = ${userId} AND de.category IS NOT NULL
GROUP BY de.category
ORDER BY saves DESC
`);
const categoryAffinities: CategoryAffinity[] = (
categoryRows as unknown as Array<{
category: string;
saves: string;
dismisses: string;
}>
).map((row) => {
const saves = parseInt(row.saves, 10);
const dismisses = parseInt(row.dismisses, 10);
const total = saves + dismisses;
// Weight: ratio of saves to total, scaled to 02 range
// 100% saves → 2.0, 50% → 1.0, 0% → 0.2 (floor)
const ratio = total > 0 ? saves / total : 0.5;
return {
category: row.category,
saves,
dismisses,
weight: Math.max(0.2, ratio * 2),
};
});
// Source quality: group by source, count saves/dismisses
const sourceRows = await db.execute(sql`
SELECT
de.source_id,
de.source_name,
COUNT(*) FILTER (WHERE dua.action = 'save') AS saves,
COUNT(*) FILTER (WHERE dua.action = 'dismiss') AS dismisses
FROM event_discovery.discovery_user_actions dua
JOIN event_discovery.discovered_events de ON de.id = dua.event_id
WHERE dua.user_id = ${userId}
GROUP BY de.source_id, de.source_name
ORDER BY saves DESC
`);
const sourceQualities: SourceQuality[] = (
sourceRows as unknown as Array<{
source_id: string;
source_name: string | null;
saves: string;
dismisses: string;
}>
).map((row) => {
const saves = parseInt(row.saves, 10);
const dismisses = parseInt(row.dismisses, 10);
const total = saves + dismisses;
const ratio = total > 0 ? saves / total : 0.5;
return {
sourceId: row.source_id,
sourceName: row.source_name,
saves,
dismisses,
weight: Math.max(0.2, ratio * 2),
};
});
return { categoryAffinities, sourceQualities, totalActions };
}