From c80c1e4462f1fa66fef06a5891fba74a55c9f32c Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 31 Mar 2026 23:46:00 -0700 Subject: [PATCH] =?UTF-8?q?feat(ai-context):=20Phase=20A.4=20=E2=80=94=20c?= =?UTF-8?q?ontext-aware=20AI=20messages=20with=20LLM=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AI-enriched context message generation: - backend/src/lib/ai-context.ts: LLM-powered context generator with keyword fallback - Calls extraction-service timer-context task when EXTRACTION_SERVICE_URL is set - Falls back to keyword rules, then generic message - Gated behind isFeatureEnabled('ai_context_messages.enabled') - backend/src/lib/config.ts: Add EXTRACTION_SERVICE_URL env var - backend/src/server.ts: POST /api/context-message route - web/src/lib/context-messages.ts: fetchEnrichedMessage() with graceful degradation - Updated AGENTIC_AI_ROADMAP.md checkboxes for Phase A.1 + A.2 All 219 backend tests + 394 web tests pass. No breaking changes. --- backend/src/lib/ai-context.ts | 132 ++++++++++++++++++++++++++++++++ backend/src/lib/config.ts | 1 + backend/src/server.ts | 17 ++++ docs/AGENTIC_AI_ROADMAP.md | 19 ++--- web/src/lib/context-messages.ts | 47 ++++++++++++ 5 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 backend/src/lib/ai-context.ts diff --git a/backend/src/lib/ai-context.ts b/backend/src/lib/ai-context.ts new file mode 100644 index 0000000..ae7883a --- /dev/null +++ b/backend/src/lib/ai-context.ts @@ -0,0 +1,132 @@ +/** + * AI-powered context message generator (Phase A.4). + * + * Accepts timer metadata + user context. Returns an enriched prep message. + * Falls back to keyword rules if LLM is unavailable or the feature flag is off. + * + * Gate: isFeatureEnabled('ai_context_messages.enabled') + */ + +import { isFeatureEnabled } from './feature-flags.js'; +import { config } from './config.js'; + +// ── Keyword fallback (same rules as web/src/lib/context-messages.ts) ── + +interface ContextRule { + keywords: string[]; + messages: string[]; +} + +const CONTEXT_RULES: ContextRule[] = [ + { keywords: ['meeting', 'standup', 'sync', 'huddle', 'scrum', '1:1'], messages: ['Review your agenda'] }, + { keywords: ['call', 'phone', 'dial'], messages: ['Have the number ready'] }, + { keywords: ['presentation', 'demo', 'pitch'], messages: ['Run through your slides'] }, + { keywords: ['flight', 'plane', 'airport'], messages: ['Check in online'] }, + { keywords: ['doctor', 'dentist', 'appointment', 'clinic'], messages: ['Bring your insurance card'] }, + { keywords: ['workout', 'exercise', 'gym', 'run', 'yoga'], messages: ['Hydrate beforehand'] }, + { keywords: ['deadline', 'due', 'submit'], messages: ['Final review before submitting'] }, + { keywords: ['focus', 'deep work'], messages: ['Close unnecessary tabs'] }, + { keywords: ['cook', 'bake', 'recipe'], messages: ['Gather your ingredients'] }, + { keywords: ['sleep', 'bed', 'bedtime'], messages: ['Start winding down'] }, +]; + +function keywordFallback(label: string): string | null { + const lower = label.toLowerCase(); + for (const rule of CONTEXT_RULES) { + if (rule.keywords.some(kw => lower.includes(kw))) { + return rule.messages[0]; + } + } + return null; +} + +// ── Input / Output types ── + +export interface ContextMessageInput { + timerLabel: string; + category?: string; + urgency?: string; + minutesBefore: number; + timeOfDay?: string; + recentTimerLabels?: string[]; +} + +export interface ContextMessageResult { + message: string; + source: 'llm' | 'keyword' | 'generic'; +} + +// ── LLM enrichment (extraction-service or ollama-client) ── + +// TODO(AGENTIC-6): Replace this stub with a real LLM call via +// @bytelyst/extraction client (timer-context task) or @bytelyst/ollama-client. +// For now we build the prompt and return a simulated enriched message. +async function llmEnrich(input: ContextMessageInput): Promise { + // Only attempt if extraction service URL is configured + const extractionUrl = config.EXTRACTION_SERVICE_URL; + if (!extractionUrl) return null; + + try { + const prompt = buildPrompt(input); + const res = await fetch(`${extractionUrl}/api/extract`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + task: 'timer-context', + input: prompt, + options: { maxTokens: 100 }, + }), + signal: AbortSignal.timeout(5_000), + }); + + if (!res.ok) return null; + + const data = await res.json() as { result?: string }; + return data.result ?? null; + } catch { + return null; + } +} + +function buildPrompt(input: ContextMessageInput): string { + const parts = [ + `Timer: "${input.timerLabel}" fires in ${input.minutesBefore} minutes.`, + ]; + if (input.category) parts.push(`Category: ${input.category}.`); + if (input.urgency) parts.push(`Urgency: ${input.urgency}.`); + if (input.timeOfDay) parts.push(`Time of day: ${input.timeOfDay}.`); + if (input.recentTimerLabels?.length) { + parts.push(`Recent timers: ${input.recentTimerLabels.slice(0, 5).join(', ')}.`); + } + parts.push('Give a short, actionable prep suggestion (1-2 sentences max).'); + return parts.join(' '); +} + +// ── Main function ── + +export async function generateContextMessage( + input: ContextMessageInput +): Promise { + // 1. Try LLM enrichment if feature flag is on + if (isFeatureEnabled('ai_context_messages.enabled')) { + const llmMessage = await llmEnrich(input); + if (llmMessage) { + return { message: llmMessage, source: 'llm' }; + } + } + + // 2. Fall back to keyword rules + const keywordMessage = keywordFallback(input.timerLabel); + if (keywordMessage) { + return { message: keywordMessage, source: 'keyword' }; + } + + // 3. Generic fallback + const minutesStr = input.minutesBefore >= 60 + ? `${Math.floor(input.minutesBefore / 60)}h ${input.minutesBefore % 60}m` + : `${input.minutesBefore}m`; + return { + message: `${input.timerLabel} is coming up in ${minutesStr.trim()} — get ready!`, + source: 'generic', + }; +} diff --git a/backend/src/lib/config.ts b/backend/src/lib/config.ts index 9b3fbeb..a3e1c7e 100644 --- a/backend/src/lib/config.ts +++ b/backend/src/lib/config.ts @@ -14,6 +14,7 @@ const envSchema = baseBackendConfigSchema.extend({ FIELD_ENCRYPT_KEY: z.string().optional(), FIELD_ENCRYPT_MEK_NAME: z.string().default('chronomind-mek'), AZURE_KEYVAULT_URL: z.string().optional(), + EXTRACTION_SERVICE_URL: z.string().optional(), }); export const config = envSchema.parse(process.env); diff --git a/backend/src/server.ts b/backend/src/server.ts index 8857216..8b061de 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -18,6 +18,7 @@ import { initDatastore } from './lib/datastore.js'; import { initEncryption } from './lib/field-encrypt.js'; import { config } from './lib/config.js'; import { getAllFlags } from './lib/feature-flags.js'; +import { generateContextMessage } from './lib/ai-context.js'; import { getBufferedEvents, flushEvents } from './lib/telemetry.js'; import { PRODUCT_ID, productConfig } from './lib/product-config.js'; @@ -56,6 +57,22 @@ await app.register(sharedTimerRoutes, { prefix: '/api' }); await app.register(webhookRoutes, { prefix: '/api' }); await app.register(agentActionRoutes, { prefix: '/api' }); +// ── Phase A.4: Context-aware AI messages ───────────────────────── +app.post('/api/context-message', async req => { + const body = req.body as { + timerLabel: string; + category?: string; + urgency?: string; + minutesBefore: number; + timeOfDay?: string; + recentTimerLabels?: string[]; + }; + if (!body.timerLabel || typeof body.minutesBefore !== 'number') { + return { message: 'Get ready!', source: 'generic' as const }; + } + return generateContextMessage(body); +}); + // ── Bootstrap (no auth) ────────────────────────────────────────── app.get('/api/bootstrap', async () => ({ productId: productConfig.productId, diff --git a/docs/AGENTIC_AI_ROADMAP.md b/docs/AGENTIC_AI_ROADMAP.md index 2333843..80b3ee2 100644 --- a/docs/AGENTIC_AI_ROADMAP.md +++ b/docs/AGENTIC_AI_ROADMAP.md @@ -162,32 +162,33 @@ These proxy to `chronomind-backend` (port 4011) via `chronomind-client.ts`. Add 4 new endpoint groups to the product backend. These are plain REST routes — NOT MCP tools: -- [ ] `backend/src/modules/timers/routes.ts` — add reschedule + availability endpoints (commit: ) +- [x] `backend/src/modules/timers/routes.ts` — add reschedule + availability endpoints (commit: 686f5fb) - `POST /api/timers/:id/reschedule` — shift timer by delta or to new time - `GET /api/timers/availability?start=&end=&minSlotMinutes=` — find free slots - Gate behind `isFeatureEnabled('mcp.enabled')` flag -- [ ] `backend/src/modules/routines/routes.ts` — add start-routine endpoint (commit: ) +- [x] `backend/src/modules/routines/routes.ts` — add start-routine endpoint (commit: 686f5fb) - `POST /api/routines/:id/start` — start a routine template (changes status → running) - Gate behind `isFeatureEnabled('mcp.enabled')` flag -- [ ] Tests for new timer + routine endpoints (commit: ) +- [x] Tests for new timer + routine endpoints (commit: 29a4802) ### A.2 — Agent Action Audit Trail (New Backend Module) -- [ ] `backend/src/modules/agent-actions/types.ts` — Zod schemas (commit: ) +- [x] `backend/src/modules/agent-actions/types.ts` — Zod schemas (commit: 29a4802) - Fields: `id`, `userId`, `productId`, `actorId`, `actorType` (agent/user/mcp), `toolName`, `actionType`, `state` (proposed/approved/applied/rejected), `reason`, `payload`, `createdAt` - Every Cosmos doc includes `productId: "chronomind"` -- [ ] `backend/src/modules/agent-actions/repository.ts` — CRUD (commit: ) +- [x] `backend/src/modules/agent-actions/repository.ts` — CRUD (commit: 29a4802) - Container: `cm_agent_actions`, partition: `/userId` - Uses `@bytelyst/datastore` `getCollection()` — never direct Cosmos SDK -- [ ] `backend/src/modules/agent-actions/routes.ts` — REST endpoints (commit: ) +- [x] `backend/src/modules/agent-actions/routes.ts` — REST endpoints (commit: 29a4802) - `GET /api/agent-actions` — list (filterable by state, actorId, toolName) + - `POST /api/agent-actions` — create action (used by MCP tools) - `POST /api/agent-actions/:id/approve` — approve proposed action - `POST /api/agent-actions/:id/reject` — reject proposed action - `POST /api/agent-actions/batch-approve` — batch approve by actorId - Gate behind `isFeatureEnabled('agent_inbox.enabled')` flag -- [ ] Register `agentActionRoutes` in `server.ts` (commit: ) -- [ ] Register `cm_agent_actions` container in `cosmos-init.ts` (commit: ) -- [ ] Agent action tests (commit: ) +- [x] Register `agentActionRoutes` in `server.ts` (commit: 29a4802) +- [x] Register `cm_agent_actions` container in `cosmos-init.ts` (commit: 29a4802) +- [x] Agent action tests — 22 tests (commit: 29a4802) ### A.3 — Extend Centralized MCP Server (common-plat) diff --git a/web/src/lib/context-messages.ts b/web/src/lib/context-messages.ts index 8e93215..4c2b3ee 100644 --- a/web/src/lib/context-messages.ts +++ b/web/src/lib/context-messages.ts @@ -169,3 +169,50 @@ export function hasContextMatch(label: string): boolean { export function getContextRules(): ContextRule[] { return [...CONTEXT_RULES]; } + +// ── Phase A.4: AI-enriched context messages ───────────────────── + +export interface EnrichedMessageResult { + message: string; + source: 'llm' | 'keyword' | 'generic'; +} + +/** + * Fetch an AI-enriched context message from the backend. + * Falls back to local keyword rules if the backend is unreachable. + * + * Graceful degradation: keyword rules remain primary, LLM messages + * shown as "AI suggestion" in the UI. + */ +export async function fetchEnrichedMessage(params: { + timerLabel: string; + category?: string; + urgency?: string; + minutesBefore: number; + timeOfDay?: string; + recentTimerLabels?: string[]; +}): Promise { + // TODO(AGENTIC-7): Use the backend URL from product-config or auth-api + // instead of relative path, once backend is always available. + try { + const backendUrl = process.env.NEXT_PUBLIC_CHRONOMIND_BACKEND_URL ?? 'http://localhost:4011'; + const res = await fetch(`${backendUrl}/api/context-message`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + signal: AbortSignal.timeout(3_000), + }); + if (res.ok) { + return await res.json() as EnrichedMessageResult; + } + } catch { + // Fall through to local fallback + } + + // Local fallback — keyword rules + const keywordMsg = getContextMessage(params.timerLabel); + if (keywordMsg) { + return { message: keywordMsg, source: 'keyword' }; + } + return { message: `${params.timerLabel} is coming up — get ready!`, source: 'generic' }; +}