feat(ai-context): Phase A.4 — context-aware AI messages with LLM fallback

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.
This commit is contained in:
saravanakumardb1 2026-03-31 23:46:00 -07:00
parent 29a48025eb
commit c80c1e4462
5 changed files with 207 additions and 9 deletions

View File

@ -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<string | null> {
// 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<ContextMessageResult> {
// 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',
};
}

View File

@ -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);

View File

@ -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,

View File

@ -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=<ISO>&end=<ISO>&minSlotMinutes=<N>` — 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)

View File

@ -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<EnrichedMessageResult> {
// 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' };
}