mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:41:08 +02:00
feat(brain): add Semantic Memory, Pattern Extractors, and Correlation Engine
Phase 7 (final) of the Companion Brain architecture. Semantic Memory (companion/memory/): - MemoryFact model with confidence lifecycle (0.3 initial, +0.15 confirm, -0.15 contradict, weekly decay after 30 days, delete below 0.1) - Store with recordFact (upsert by factKey), contradictFact, applyDecay - 3 pattern extractors: day-of-week (recurring days), time-of-day (peak 4h window), frequency (daily average) — all rule-based, no LLM - Runs across all 5 pilot modules (11 extraction rules total) Correlation Engine (data/projections/correlations.ts): - Pearson correlation between 7 daily metrics across 4 modules - Metrics: tasks completed, water ml, coffee count, calories, meals, calendar events, places visited - Only returns cross-module correlations with |r| >= 0.3 and >= 14 days - Natural language sentence generation for each correlation Context Document updated: - Now accepts optional memory facts + correlations - Appends "Bekannte Muster" section (top 6 high-confidence facts) - Appends "Zusammenhaenge" section (top 3 correlations with r-value) This completes all 7 phases of the Companion Brain architecture. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
41357b2541
commit
87a1dd6829
7 changed files with 670 additions and 6 deletions
219
apps/mana/apps/web/src/lib/companion/memory/extractors.ts
Normal file
219
apps/mana/apps/web/src/lib/companion/memory/extractors.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* Pattern Extractors — Analyze event history to discover user patterns.
|
||||
*
|
||||
* Each extractor scans the event store for a specific type of pattern
|
||||
* and records/confirms facts in the memory store. Run periodically
|
||||
* (e.g. daily) or after a batch of events.
|
||||
*
|
||||
* All extractors are rule-based (no LLM). They look for:
|
||||
* - Recurring day-of-week patterns (e.g. "trains Mon/Wed/Fri")
|
||||
* - Time-of-day preferences (e.g. "completes tasks mostly before noon")
|
||||
* - Sequences (e.g. "always logs coffee before first event")
|
||||
* - Frequency patterns (e.g. "drinks 3 coffees per day on average")
|
||||
*/
|
||||
|
||||
import { queryEvents } from '$lib/data/events/event-store';
|
||||
import { memoryStore } from './store';
|
||||
|
||||
const DAY_NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
const LOOKBACK_DAYS = 30;
|
||||
|
||||
function daysAgoISO(n: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - n);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function dayOfWeek(timestamp: string): number {
|
||||
return new Date(timestamp).getDay();
|
||||
}
|
||||
|
||||
function hourOfDay(timestamp: string): number {
|
||||
return new Date(timestamp).getHours();
|
||||
}
|
||||
|
||||
interface DayCount {
|
||||
[day: number]: number;
|
||||
}
|
||||
|
||||
// ── Recurring Day Pattern ───────────────────────────
|
||||
|
||||
/**
|
||||
* Detects which days of the week an event type occurs most.
|
||||
* E.g. "DrinkLogged(coffee) happens mostly on Mon/Tue/Wed/Thu/Fri" → work days.
|
||||
*/
|
||||
async function extractDayOfWeekPattern(
|
||||
eventType: string,
|
||||
label: string,
|
||||
module: string,
|
||||
filterFn?: (payload: Record<string, unknown>) => boolean
|
||||
): Promise<void> {
|
||||
const since = daysAgoISO(LOOKBACK_DAYS);
|
||||
const events = await queryEvents({ type: eventType, since, limit: 500 });
|
||||
const filtered = filterFn
|
||||
? events.filter((e) => filterFn(e.payload as Record<string, unknown>))
|
||||
: events;
|
||||
|
||||
if (filtered.length < 7) return; // Not enough data
|
||||
|
||||
const dayCounts: DayCount = {};
|
||||
for (const e of filtered) {
|
||||
const day = dayOfWeek(e.meta.timestamp);
|
||||
dayCounts[day] = (dayCounts[day] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Find days that have significantly more events than average
|
||||
const total = filtered.length;
|
||||
const avgPerDay = total / 7;
|
||||
const activeDays = Object.entries(dayCounts)
|
||||
.filter(([, count]) => count > avgPerDay * 1.3)
|
||||
.map(([day]) => Number(day))
|
||||
.sort();
|
||||
|
||||
if (activeDays.length === 0 || activeDays.length === 7) return; // No pattern or every day
|
||||
|
||||
const dayLabels = activeDays.map((d) => DAY_NAMES[d]).join('/');
|
||||
const factKey = `pattern:${module}:day_of_week:${eventType}`;
|
||||
|
||||
await memoryStore.recordFact({
|
||||
factKey,
|
||||
category: 'pattern',
|
||||
content: `${label} typischerweise an ${dayLabels}`,
|
||||
sourceModules: [module],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Time of Day Preference ──────────────────────────
|
||||
|
||||
/**
|
||||
* Detects preferred time windows for an event type.
|
||||
* E.g. "Tasks mostly completed between 9-12" → morning productivity.
|
||||
*/
|
||||
async function extractTimePreference(
|
||||
eventType: string,
|
||||
label: string,
|
||||
module: string
|
||||
): Promise<void> {
|
||||
const since = daysAgoISO(LOOKBACK_DAYS);
|
||||
const events = await queryEvents({ type: eventType, since, limit: 500 });
|
||||
|
||||
if (events.length < 10) return;
|
||||
|
||||
const hourCounts: Record<number, number> = {};
|
||||
for (const e of events) {
|
||||
const h = hourOfDay(e.meta.timestamp);
|
||||
hourCounts[h] = (hourCounts[h] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Find the peak 4-hour window
|
||||
let bestStart = 0;
|
||||
let bestCount = 0;
|
||||
for (let start = 5; start <= 20; start++) {
|
||||
let count = 0;
|
||||
for (let h = start; h < start + 4; h++) {
|
||||
count += hourCounts[h] ?? 0;
|
||||
}
|
||||
if (count > bestCount) {
|
||||
bestCount = count;
|
||||
bestStart = start;
|
||||
}
|
||||
}
|
||||
|
||||
const peakPercent = Math.round((bestCount / events.length) * 100);
|
||||
if (peakPercent < 40) return; // No clear peak
|
||||
|
||||
const timeLabel = bestStart < 12 ? 'morgens' : bestStart < 17 ? 'nachmittags' : 'abends';
|
||||
const factKey = `preference:${module}:time_of_day:${eventType}`;
|
||||
|
||||
await memoryStore.recordFact({
|
||||
factKey,
|
||||
category: 'preference',
|
||||
content: `${label} hauptsaechlich ${timeLabel} (${bestStart}:00-${bestStart + 4}:00, ${peakPercent}% aller Eintraege)`,
|
||||
sourceModules: [module],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Frequency Pattern ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Detects average daily frequency of an event type.
|
||||
* E.g. "3 Kaffee pro Tag im Durchschnitt"
|
||||
*/
|
||||
async function extractFrequencyPattern(
|
||||
eventType: string,
|
||||
label: string,
|
||||
module: string,
|
||||
filterFn?: (payload: Record<string, unknown>) => boolean
|
||||
): Promise<void> {
|
||||
const since = daysAgoISO(LOOKBACK_DAYS);
|
||||
const events = await queryEvents({ type: eventType, since, limit: 1000 });
|
||||
const filtered = filterFn
|
||||
? events.filter((e) => filterFn(e.payload as Record<string, unknown>))
|
||||
: events;
|
||||
|
||||
if (filtered.length < 5) return;
|
||||
|
||||
// Count per day
|
||||
const dayCounts: Record<string, number> = {};
|
||||
for (const e of filtered) {
|
||||
const date = e.meta.timestamp.split('T')[0];
|
||||
dayCounts[date] = (dayCounts[date] ?? 0) + 1;
|
||||
}
|
||||
|
||||
const activeDays = Object.keys(dayCounts).length;
|
||||
if (activeDays < 3) return;
|
||||
|
||||
const avg = Math.round((filtered.length / activeDays) * 10) / 10;
|
||||
const factKey = `pattern:${module}:frequency:${eventType}`;
|
||||
|
||||
await memoryStore.recordFact({
|
||||
factKey,
|
||||
category: 'pattern',
|
||||
content: `Durchschnittlich ${avg} ${label} pro Tag (letzte ${activeDays} aktive Tage)`,
|
||||
sourceModules: [module],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Run All Extractors ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Run all pattern extractors. Call once daily (e.g. from a scheduled rule
|
||||
* or on app startup after data has loaded).
|
||||
*/
|
||||
export async function extractAllPatterns(): Promise<void> {
|
||||
await Promise.all([
|
||||
// Todo patterns
|
||||
extractDayOfWeekPattern('TaskCompleted', 'Tasks erledigt', 'todo'),
|
||||
extractTimePreference('TaskCompleted', 'Tasks erledigt', 'todo'),
|
||||
extractTimePreference('TaskCreated', 'Tasks erstellt', 'todo'),
|
||||
|
||||
// Drink patterns
|
||||
extractDayOfWeekPattern(
|
||||
'DrinkLogged',
|
||||
'Kaffee getrunken',
|
||||
'drink',
|
||||
(p) => p.drinkType === 'coffee'
|
||||
),
|
||||
extractFrequencyPattern('DrinkLogged', 'Kaffee', 'drink', (p) => p.drinkType === 'coffee'),
|
||||
extractFrequencyPattern(
|
||||
'DrinkLogged',
|
||||
'Wasser-Eintraege',
|
||||
'drink',
|
||||
(p) => p.drinkType === 'water'
|
||||
),
|
||||
extractTimePreference('DrinkLogged', 'Getraenke geloggt', 'drink'),
|
||||
|
||||
// Calendar patterns
|
||||
extractDayOfWeekPattern('CalendarEventCreated', 'Termine erstellt', 'calendar'),
|
||||
|
||||
// Nutriphi patterns
|
||||
extractTimePreference('MealLogged', 'Mahlzeiten geloggt', 'nutriphi'),
|
||||
extractFrequencyPattern('MealLogged', 'Mahlzeiten', 'nutriphi'),
|
||||
|
||||
// Places patterns
|
||||
extractDayOfWeekPattern('PlaceVisited', 'Orte besucht', 'places'),
|
||||
]);
|
||||
|
||||
// Apply decay to old facts
|
||||
await memoryStore.applyDecay();
|
||||
}
|
||||
3
apps/mana/apps/web/src/lib/companion/memory/index.ts
Normal file
3
apps/mana/apps/web/src/lib/companion/memory/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { memoryStore } from './store';
|
||||
export { extractAllPatterns } from './extractors';
|
||||
export type { MemoryFact, MemoryCategory, Correlation } from './types';
|
||||
148
apps/mana/apps/web/src/lib/companion/memory/store.ts
Normal file
148
apps/mana/apps/web/src/lib/companion/memory/store.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* Memory Store — CRUD + confidence lifecycle for semantic memory facts.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { MemoryFact, MemoryCategory } from './types';
|
||||
|
||||
const TABLE = '_memory';
|
||||
|
||||
const INITIAL_CONFIDENCE = 0.3;
|
||||
const CONFIRM_BOOST = 0.15;
|
||||
const CONTRADICT_PENALTY = 0.15;
|
||||
const DECAY_PER_WEEK = 0.05;
|
||||
const MIN_CONFIDENCE = 0.1;
|
||||
const MAX_CONFIDENCE = 0.95;
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
export const memoryStore = {
|
||||
/**
|
||||
* Record or confirm a fact. If a fact with the same factKey exists,
|
||||
* its confidence is boosted. Otherwise a new fact is created.
|
||||
*/
|
||||
async recordFact(input: {
|
||||
factKey: string;
|
||||
category: MemoryCategory;
|
||||
content: string;
|
||||
sourceModules: string[];
|
||||
}): Promise<MemoryFact> {
|
||||
const now = new Date().toISOString();
|
||||
const existing = await db
|
||||
.table<MemoryFact>(TABLE)
|
||||
.where('id')
|
||||
.above('')
|
||||
.filter((f) => f.factKey === input.factKey && !f.deletedAt)
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
const newConf = clamp(existing.confidence + CONFIRM_BOOST, 0, MAX_CONFIDENCE);
|
||||
const modules = [...new Set([...existing.sourceModules, ...input.sourceModules])];
|
||||
await db.table(TABLE).update(existing.id, {
|
||||
confidence: newConf,
|
||||
confirmations: existing.confirmations + 1,
|
||||
lastConfirmed: now,
|
||||
sourceModules: modules,
|
||||
content: input.content, // Update with latest wording
|
||||
updatedAt: now,
|
||||
});
|
||||
return {
|
||||
...existing,
|
||||
confidence: newConf,
|
||||
confirmations: existing.confirmations + 1,
|
||||
lastConfirmed: now,
|
||||
};
|
||||
}
|
||||
|
||||
const fact: MemoryFact = {
|
||||
id: crypto.randomUUID(),
|
||||
category: input.category,
|
||||
content: input.content,
|
||||
confidence: INITIAL_CONFIDENCE,
|
||||
confirmations: 1,
|
||||
contradictions: 0,
|
||||
sourceModules: input.sourceModules,
|
||||
factKey: input.factKey,
|
||||
firstSeen: now,
|
||||
lastConfirmed: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db.table(TABLE).add(fact);
|
||||
return fact;
|
||||
},
|
||||
|
||||
/** Record a contradiction — lowers confidence. */
|
||||
async contradictFact(factKey: string): Promise<void> {
|
||||
const fact = await db
|
||||
.table<MemoryFact>(TABLE)
|
||||
.where('id')
|
||||
.above('')
|
||||
.filter((f) => f.factKey === factKey && !f.deletedAt)
|
||||
.first();
|
||||
if (!fact) return;
|
||||
|
||||
const newConf = clamp(fact.confidence - CONTRADICT_PENALTY, 0, MAX_CONFIDENCE);
|
||||
await db.table(TABLE).update(fact.id, {
|
||||
confidence: newConf,
|
||||
contradictions: fact.contradictions + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
/** Apply time decay to all facts. Call periodically (e.g. daily). */
|
||||
async applyDecay(): Promise<number> {
|
||||
const now = Date.now();
|
||||
const all = await db.table<MemoryFact>(TABLE).toArray();
|
||||
let cleaned = 0;
|
||||
|
||||
for (const fact of all) {
|
||||
if (fact.deletedAt) continue;
|
||||
const lastMs = new Date(fact.lastConfirmed).getTime();
|
||||
const weeksSince = (now - lastMs) / (7 * 86400000);
|
||||
if (weeksSince < 4) continue; // No decay within first month
|
||||
|
||||
const decay = Math.floor(weeksSince - 4) * DECAY_PER_WEEK;
|
||||
const newConf = clamp(fact.confidence - decay, 0, MAX_CONFIDENCE);
|
||||
|
||||
if (newConf < MIN_CONFIDENCE) {
|
||||
await db.table(TABLE).update(fact.id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
cleaned++;
|
||||
} else if (newConf !== fact.confidence) {
|
||||
await db.table(TABLE).update(fact.id, {
|
||||
confidence: newConf,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
},
|
||||
|
||||
/** Get all active facts above a confidence threshold. */
|
||||
async getFacts(minConfidence = 0.3): Promise<MemoryFact[]> {
|
||||
const all = await db.table<MemoryFact>(TABLE).toArray();
|
||||
return all
|
||||
.filter((f) => !f.deletedAt && f.confidence >= minConfidence)
|
||||
.sort((a, b) => b.confidence - a.confidence);
|
||||
},
|
||||
|
||||
/** Get facts for a specific category. */
|
||||
async getFactsByCategory(category: MemoryCategory, minConfidence = 0.3): Promise<MemoryFact[]> {
|
||||
const all = await this.getFacts(minConfidence);
|
||||
return all.filter((f) => f.category === category);
|
||||
},
|
||||
|
||||
/** Delete a specific fact. */
|
||||
async deleteFact(id: string): Promise<void> {
|
||||
await db.table(TABLE).update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
48
apps/mana/apps/web/src/lib/companion/memory/types.ts
Normal file
48
apps/mana/apps/web/src/lib/companion/memory/types.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Semantic Memory types — Extracted user knowledge that persists across sessions.
|
||||
*
|
||||
* Memory facts represent patterns, preferences, and context inferred from
|
||||
* the event stream. They are more durable and compact than raw events —
|
||||
* "User trains Mon/Wed/Fri evenings" is one fact vs. hundreds of events.
|
||||
*
|
||||
* Confidence lifecycle:
|
||||
* New fact → 0.3
|
||||
* Confirmed again → +0.15 (cap 0.95)
|
||||
* Contradicted → -0.15
|
||||
* Not seen 30 days → -0.05/week
|
||||
* Below 0.1 → deleted
|
||||
*/
|
||||
|
||||
export interface MemoryFact {
|
||||
id: string;
|
||||
category: MemoryCategory;
|
||||
/** Human-readable description of the fact */
|
||||
content: string;
|
||||
/** 0.0 to 1.0 — rises with confirmations, decays with time/contradictions */
|
||||
confidence: number;
|
||||
confirmations: number;
|
||||
contradictions: number;
|
||||
/** Which modules contributed to this fact */
|
||||
sourceModules: string[];
|
||||
/** Machine-readable key for deduplication (e.g. 'pattern:drink:morning_coffee') */
|
||||
factKey: string;
|
||||
firstSeen: string;
|
||||
lastConfirmed: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
export type MemoryCategory = 'pattern' | 'preference' | 'context';
|
||||
|
||||
/** Correlation between two daily metrics */
|
||||
export interface Correlation {
|
||||
id: string;
|
||||
factorA: { module: string; metric: string; label: string };
|
||||
factorB: { module: string; metric: string; label: string };
|
||||
coefficient: number;
|
||||
sampleSize: number;
|
||||
direction: 'positive' | 'negative';
|
||||
sentence: string;
|
||||
computedAt: string;
|
||||
}
|
||||
|
|
@ -2,11 +2,12 @@
|
|||
* Context Document Generator — Produces a ~500 token text snapshot
|
||||
* of the user's current state for use as an LLM system prompt.
|
||||
*
|
||||
* Combines DaySnapshot + Streaks into a structured markdown string
|
||||
* that any LLM tier (local Gemma or cloud) can reason over.
|
||||
* Combines DaySnapshot + Streaks + Memory + Correlations into a
|
||||
* structured markdown string that any LLM tier can reason over.
|
||||
*/
|
||||
|
||||
import type { DaySnapshot, StreakInfo } from './types';
|
||||
import type { MemoryFact, Correlation } from '$lib/companion/memory/types';
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
|
|
@ -19,11 +20,18 @@ function formatTime(iso: string): string {
|
|||
/**
|
||||
* Generate a concise user context document.
|
||||
*
|
||||
* @param day - Today's snapshot
|
||||
* @param streaks - Current streak info
|
||||
* @returns Markdown string (~300-500 tokens)
|
||||
* @param day - Today's snapshot
|
||||
* @param streaks - Current streak info
|
||||
* @param memory - Extracted user patterns (optional)
|
||||
* @param correlations - Cross-module correlations (optional)
|
||||
* @returns Markdown string (~300-600 tokens)
|
||||
*/
|
||||
export function generateContextDocument(day: DaySnapshot, streaks: StreakInfo[]): string {
|
||||
export function generateContextDocument(
|
||||
day: DaySnapshot,
|
||||
streaks: StreakInfo[],
|
||||
memory: MemoryFact[] = [],
|
||||
correlations: Correlation[] = []
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`## Nutzer-Kontext (${day.date})\n`);
|
||||
|
|
@ -103,5 +111,22 @@ export function generateContextDocument(day: DaySnapshot, streaks: StreakInfo[])
|
|||
}
|
||||
}
|
||||
|
||||
// ── Memory (Patterns & Preferences) ────────────────
|
||||
const highConfMemory = memory.filter((m) => m.confidence >= 0.5);
|
||||
if (highConfMemory.length > 0) {
|
||||
lines.push('\n### Bekannte Muster');
|
||||
for (const m of highConfMemory.slice(0, 6)) {
|
||||
lines.push(`- ${m.content}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Correlations ────────────────────────────────────
|
||||
if (correlations.length > 0) {
|
||||
lines.push('\n### Zusammenhaenge');
|
||||
for (const c of correlations.slice(0, 3)) {
|
||||
lines.push(`- ${c.sentence} (r=${c.coefficient})`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
|
|
|||
220
apps/mana/apps/web/src/lib/data/projections/correlations.ts
Normal file
220
apps/mana/apps/web/src/lib/data/projections/correlations.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/**
|
||||
* Correlation Engine — Finds statistical relationships between daily
|
||||
* metrics from different modules.
|
||||
*
|
||||
* Computes Pearson correlation coefficients between pairs of daily
|
||||
* aggregates (e.g. "water intake" vs "tasks completed"). Only
|
||||
* correlations with |r| >= 0.3 and enough data points (>= 14 days)
|
||||
* are returned.
|
||||
*
|
||||
* All computation is local — no server, no LLM.
|
||||
*/
|
||||
|
||||
import { queryEvents } from '$lib/data/events/event-store';
|
||||
import type { Correlation } from '$lib/companion/memory/types';
|
||||
|
||||
const MIN_DAYS = 14;
|
||||
const MIN_ABS_R = 0.3;
|
||||
const LOOKBACK_DAYS = 60;
|
||||
|
||||
// ── Metric Definitions ──────────────────────────────
|
||||
|
||||
interface MetricDef {
|
||||
id: string;
|
||||
module: string;
|
||||
label: string;
|
||||
/** Extract a daily value from events for a given date */
|
||||
extract: (dayEvents: DayEventMap) => number;
|
||||
}
|
||||
|
||||
type DayEventMap = Map<string, { type: string; payload: Record<string, unknown> }[]>;
|
||||
|
||||
function buildDayEventMap(
|
||||
events: { type: string; payload: unknown; meta: { timestamp: string } }[]
|
||||
): DayEventMap {
|
||||
const map: DayEventMap = new Map();
|
||||
for (const e of events) {
|
||||
const date = e.meta.timestamp.split('T')[0];
|
||||
if (!map.has(date)) map.set(date, []);
|
||||
map.get(date)!.push({ type: e.type, payload: e.payload as Record<string, unknown> });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const METRICS: MetricDef[] = [
|
||||
{
|
||||
id: 'todo:completed',
|
||||
module: 'todo',
|
||||
label: 'Tasks erledigt',
|
||||
extract: (days) => countByType(days, 'TaskCompleted'),
|
||||
},
|
||||
{
|
||||
id: 'drink:water_ml',
|
||||
module: 'drink',
|
||||
label: 'Wasser (ml)',
|
||||
extract: (days) =>
|
||||
sumByTypeField(days, 'DrinkLogged', 'quantityMl', (p) => p.drinkType === 'water'),
|
||||
},
|
||||
{
|
||||
id: 'drink:coffee_count',
|
||||
module: 'drink',
|
||||
label: 'Kaffee (Tassen)',
|
||||
extract: (days) => countByType(days, 'DrinkLogged', (p) => p.drinkType === 'coffee'),
|
||||
},
|
||||
{
|
||||
id: 'nutriphi:calories',
|
||||
module: 'nutriphi',
|
||||
label: 'Kalorien',
|
||||
extract: (days) => sumByTypeField(days, 'MealLogged', 'calories'),
|
||||
},
|
||||
{
|
||||
id: 'nutriphi:meals',
|
||||
module: 'nutriphi',
|
||||
label: 'Mahlzeiten',
|
||||
extract: (days) => countByType(days, 'MealLogged'),
|
||||
},
|
||||
{
|
||||
id: 'calendar:events',
|
||||
module: 'calendar',
|
||||
label: 'Termine',
|
||||
extract: (days) => countByType(days, 'CalendarEventCreated'),
|
||||
},
|
||||
{
|
||||
id: 'places:visits',
|
||||
module: 'places',
|
||||
label: 'Orte besucht',
|
||||
extract: (days) => countByType(days, 'PlaceVisited'),
|
||||
},
|
||||
];
|
||||
|
||||
function countByType(
|
||||
days: DayEventMap,
|
||||
eventType: string,
|
||||
filter?: (payload: Record<string, unknown>) => boolean
|
||||
): number {
|
||||
let count = 0;
|
||||
for (const [, events] of days) {
|
||||
for (const e of events) {
|
||||
if (e.type === eventType && (!filter || filter(e.payload))) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function sumByTypeField(
|
||||
days: DayEventMap,
|
||||
eventType: string,
|
||||
field: string,
|
||||
filter?: (payload: Record<string, unknown>) => boolean
|
||||
): number {
|
||||
let sum = 0;
|
||||
for (const [, events] of days) {
|
||||
for (const e of events) {
|
||||
if (e.type === eventType && (!filter || filter(e.payload))) {
|
||||
const val = e.payload[field];
|
||||
if (typeof val === 'number') sum += val;
|
||||
}
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
// ── Pearson Correlation ─────────────────────────────
|
||||
|
||||
function pearson(xs: number[], ys: number[]): number {
|
||||
const n = xs.length;
|
||||
if (n < 3) return 0;
|
||||
|
||||
let sumX = 0,
|
||||
sumY = 0,
|
||||
sumXY = 0,
|
||||
sumX2 = 0,
|
||||
sumY2 = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
sumX += xs[i];
|
||||
sumY += ys[i];
|
||||
sumXY += xs[i] * ys[i];
|
||||
sumX2 += xs[i] * xs[i];
|
||||
sumY2 += ys[i] * ys[i];
|
||||
}
|
||||
|
||||
const denom = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
|
||||
if (denom === 0) return 0;
|
||||
|
||||
return (n * sumXY - sumX * sumY) / denom;
|
||||
}
|
||||
|
||||
// ── Sentence Generation ─────────────────────────────
|
||||
|
||||
function generateSentence(labelA: string, labelB: string, r: number): string {
|
||||
const direction = r > 0 ? 'mehr' : 'weniger';
|
||||
const strength = Math.abs(r) > 0.6 ? 'deutlich' : 'etwas';
|
||||
return `An Tagen mit mehr ${labelA} hast du ${strength} ${direction} ${labelB}`;
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute correlations between all metric pairs.
|
||||
* Returns only significant correlations (|r| >= 0.3, >= 14 days).
|
||||
*/
|
||||
export async function computeCorrelations(): Promise<Correlation[]> {
|
||||
const since = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
|
||||
const allEvents = await queryEvents({ since, limit: 5000 });
|
||||
|
||||
if (allEvents.length < 20) return [];
|
||||
|
||||
const dayMap = buildDayEventMap(allEvents);
|
||||
const dates = [...dayMap.keys()].sort();
|
||||
|
||||
if (dates.length < MIN_DAYS) return [];
|
||||
|
||||
// Build daily metric vectors
|
||||
const metricVectors: Map<string, number[]> = new Map();
|
||||
for (const metric of METRICS) {
|
||||
const values: number[] = [];
|
||||
for (const date of dates) {
|
||||
const singleDayMap: DayEventMap = new Map([[date, dayMap.get(date) ?? []]]);
|
||||
values.push(metric.extract(singleDayMap));
|
||||
}
|
||||
// Skip metrics that are all zeros
|
||||
if (values.some((v) => v > 0)) {
|
||||
metricVectors.set(metric.id, values);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute pairwise correlations
|
||||
const correlations: Correlation[] = [];
|
||||
const metricIds = [...metricVectors.keys()];
|
||||
|
||||
for (let i = 0; i < metricIds.length; i++) {
|
||||
for (let j = i + 1; j < metricIds.length; j++) {
|
||||
const idA = metricIds[i];
|
||||
const idB = metricIds[j];
|
||||
const metricA = METRICS.find((m) => m.id === idA)!;
|
||||
const metricB = METRICS.find((m) => m.id === idB)!;
|
||||
|
||||
// Skip same-module correlations (trivially correlated)
|
||||
if (metricA.module === metricB.module) continue;
|
||||
|
||||
const xs = metricVectors.get(idA)!;
|
||||
const ys = metricVectors.get(idB)!;
|
||||
const r = pearson(xs, ys);
|
||||
|
||||
if (Math.abs(r) >= MIN_ABS_R) {
|
||||
correlations.push({
|
||||
id: `corr:${idA}:${idB}`,
|
||||
factorA: { module: metricA.module, metric: idA, label: metricA.label },
|
||||
factorB: { module: metricB.module, metric: idB, label: metricB.label },
|
||||
coefficient: Math.round(r * 100) / 100,
|
||||
sampleSize: dates.length,
|
||||
direction: r > 0 ? 'positive' : 'negative',
|
||||
sentence: generateSentence(metricA.label, metricB.label, r),
|
||||
computedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return correlations.sort((a, b) => Math.abs(b.coefficient) - Math.abs(a.coefficient));
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export { useDaySnapshot } from './day-snapshot';
|
||||
export { useStreaks } from './streaks';
|
||||
export { computeCorrelations } from './correlations';
|
||||
export { generateContextDocument } from './context-document';
|
||||
export type { DaySnapshot, StreakInfo, TaskSummary, EventSummary } from './types';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue