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:
parent
29a48025eb
commit
c80c1e4462
132
backend/src/lib/ai-context.ts
Normal file
132
backend/src/lib/ai-context.ts
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ const envSchema = baseBackendConfigSchema.extend({
|
|||||||
FIELD_ENCRYPT_KEY: z.string().optional(),
|
FIELD_ENCRYPT_KEY: z.string().optional(),
|
||||||
FIELD_ENCRYPT_MEK_NAME: z.string().default('chronomind-mek'),
|
FIELD_ENCRYPT_MEK_NAME: z.string().default('chronomind-mek'),
|
||||||
AZURE_KEYVAULT_URL: z.string().optional(),
|
AZURE_KEYVAULT_URL: z.string().optional(),
|
||||||
|
EXTRACTION_SERVICE_URL: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const config = envSchema.parse(process.env);
|
export const config = envSchema.parse(process.env);
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { initDatastore } from './lib/datastore.js';
|
|||||||
import { initEncryption } from './lib/field-encrypt.js';
|
import { initEncryption } from './lib/field-encrypt.js';
|
||||||
import { config } from './lib/config.js';
|
import { config } from './lib/config.js';
|
||||||
import { getAllFlags } from './lib/feature-flags.js';
|
import { getAllFlags } from './lib/feature-flags.js';
|
||||||
|
import { generateContextMessage } from './lib/ai-context.js';
|
||||||
import { getBufferedEvents, flushEvents } from './lib/telemetry.js';
|
import { getBufferedEvents, flushEvents } from './lib/telemetry.js';
|
||||||
import { PRODUCT_ID, productConfig } from './lib/product-config.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(webhookRoutes, { prefix: '/api' });
|
||||||
await app.register(agentActionRoutes, { 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) ──────────────────────────────────────────
|
// ── Bootstrap (no auth) ──────────────────────────────────────────
|
||||||
app.get('/api/bootstrap', async () => ({
|
app.get('/api/bootstrap', async () => ({
|
||||||
productId: productConfig.productId,
|
productId: productConfig.productId,
|
||||||
|
|||||||
@ -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:
|
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
|
- `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
|
- `GET /api/timers/availability?start=<ISO>&end=<ISO>&minSlotMinutes=<N>` — find free slots
|
||||||
- Gate behind `isFeatureEnabled('mcp.enabled')` flag
|
- 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)
|
- `POST /api/routines/:id/start` — start a routine template (changes status → running)
|
||||||
- Gate behind `isFeatureEnabled('mcp.enabled')` flag
|
- 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)
|
### 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`
|
- 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"`
|
- 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`
|
- Container: `cm_agent_actions`, partition: `/userId`
|
||||||
- Uses `@bytelyst/datastore` `getCollection()` — never direct Cosmos SDK
|
- 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)
|
- `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/approve` — approve proposed action
|
||||||
- `POST /api/agent-actions/:id/reject` — reject proposed action
|
- `POST /api/agent-actions/:id/reject` — reject proposed action
|
||||||
- `POST /api/agent-actions/batch-approve` — batch approve by actorId
|
- `POST /api/agent-actions/batch-approve` — batch approve by actorId
|
||||||
- Gate behind `isFeatureEnabled('agent_inbox.enabled')` flag
|
- Gate behind `isFeatureEnabled('agent_inbox.enabled')` flag
|
||||||
- [ ] Register `agentActionRoutes` in `server.ts` (commit: )
|
- [x] Register `agentActionRoutes` in `server.ts` (commit: 29a4802)
|
||||||
- [ ] Register `cm_agent_actions` container in `cosmos-init.ts` (commit: )
|
- [x] Register `cm_agent_actions` container in `cosmos-init.ts` (commit: 29a4802)
|
||||||
- [ ] Agent action tests (commit: )
|
- [x] Agent action tests — 22 tests (commit: 29a4802)
|
||||||
|
|
||||||
### A.3 — Extend Centralized MCP Server (common-plat)
|
### A.3 — Extend Centralized MCP Server (common-plat)
|
||||||
|
|
||||||
|
|||||||
@ -169,3 +169,50 @@ export function hasContextMatch(label: string): boolean {
|
|||||||
export function getContextRules(): ContextRule[] {
|
export function getContextRules(): ContextRule[] {
|
||||||
return [...CONTEXT_RULES];
|
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' };
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user