feat(platform-service): add memory-items API backed by Cosmos
This commit is contained in:
parent
cb728d3dfe
commit
17c41e8441
@ -85,7 +85,8 @@ Voice capture pipeline
|
||||
|
||||
Text capture
|
||||
|
||||
- [ ] **P0** — Persist raw text to `memory_items` Cosmos container (currently in-memory)
|
||||
- [x] **P0** — Backend: persist raw text to `memory_items` Cosmos container (`platform-service` `POST /api/memory-items`)
|
||||
- [ ] **P0** — Mobile: call `platform-service` `POST /api/memory-items` during capture/triage flow
|
||||
|
||||
Image/screenshot capture
|
||||
|
||||
@ -94,7 +95,8 @@ Image/screenshot capture
|
||||
|
||||
Triage persistence
|
||||
|
||||
- [ ] **P0** — Store structured `MemoryItem` in Cosmos DB with all triage fields (currently in-memory)
|
||||
- [x] **P0** — Backend: store structured `MemoryItem` in Cosmos DB with triage fields (`platform-service` `memory_items`)
|
||||
- [ ] **P0** — Mobile: wire triage confirmation to send `triageResult` + `brainIds` to backend (`POST /api/memory-items`)
|
||||
|
||||
Widgets
|
||||
|
||||
|
||||
@ -8,6 +8,11 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
||||
notification_prefs: { partitionKeyPath: '/userId' },
|
||||
audit_log: { partitionKeyPath: '/category', defaultTtl: 90 * 86400 },
|
||||
feature_flags: { partitionKeyPath: '/id' },
|
||||
// Mobile capture primitives (MindLyst-style).
|
||||
memory_items: { partitionKeyPath: '/userId' },
|
||||
daily_briefs: { partitionKeyPath: '/userId' },
|
||||
reflections: { partitionKeyPath: '/userId' },
|
||||
brain_insights: { partitionKeyPath: '/userId' },
|
||||
};
|
||||
|
||||
export async function initCosmosIfNeeded(): Promise<void> {
|
||||
|
||||
63
services/platform-service/src/modules/memory/memory.test.ts
Normal file
63
services/platform-service/src/modules/memory/memory.test.ts
Normal file
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Tests for memory item schemas.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CreateMemoryItemSchema,
|
||||
ListMemoryItemsQuerySchema,
|
||||
PatchMemoryItemSchema,
|
||||
ReassignMemoryItemSchema,
|
||||
} from './types.js';
|
||||
|
||||
describe('ListMemoryItemsQuerySchema', () => {
|
||||
it('accepts defaults', () => {
|
||||
const result = ListMemoryItemsQuerySchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.limit).toBe(50);
|
||||
expect(result.data.offset).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects huge limit', () => {
|
||||
const result = ListMemoryItemsQuerySchema.safeParse({ limit: 9999 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateMemoryItemSchema', () => {
|
||||
it('requires rawContent', () => {
|
||||
const result = CreateMemoryItemSchema.safeParse({ sourceType: 'text' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts minimal valid payload', () => {
|
||||
const result = CreateMemoryItemSchema.safeParse({ rawContent: 'hello world' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.sourceType).toBe('text');
|
||||
expect(result.data.captureSurface).toBe('app');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReassignMemoryItemSchema', () => {
|
||||
it('accepts newBrainId', () => {
|
||||
const result = ReassignMemoryItemSchema.safeParse({ newBrainId: 'work' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PatchMemoryItemSchema', () => {
|
||||
it('requires reminderAt for set_reminder at route level', () => {
|
||||
const result = PatchMemoryItemSchema.safeParse({ action: 'set_reminder' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid action', () => {
|
||||
const result = PatchMemoryItemSchema.safeParse({ action: 'nope' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
101
services/platform-service/src/modules/memory/repository.ts
Normal file
101
services/platform-service/src/modules/memory/repository.ts
Normal file
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Memory items repository — Cosmos DB CRUD.
|
||||
*
|
||||
* Container: memory_items (partition: /userId)
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import type { MemoryItemDoc } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('memory_items');
|
||||
}
|
||||
|
||||
export type ListMemoryItemsQuery = {
|
||||
productId: string;
|
||||
userId: string;
|
||||
brainId?: string;
|
||||
filter?: 'forgotten' | 'completed_today';
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export async function list(query: ListMemoryItemsQuery): Promise<{ items: MemoryItemDoc[] }> {
|
||||
const conditions: string[] = ['c.userId = @userId', 'c.productId = @productId'];
|
||||
const params: { name: string; value: string | number | boolean }[] = [
|
||||
{ name: '@userId', value: query.userId },
|
||||
{ name: '@productId', value: query.productId },
|
||||
];
|
||||
|
||||
if (query.brainId) {
|
||||
conditions.push('ARRAY_CONTAINS(c.brainIds, @brainId)');
|
||||
params.push({ name: '@brainId', value: query.brainId });
|
||||
}
|
||||
|
||||
if (query.filter === 'forgotten') {
|
||||
const before = new Date(Date.now() - 48 * 3600 * 1000).toISOString();
|
||||
conditions.push('c.actedOn = false');
|
||||
conditions.push('c.createdAt < @before');
|
||||
params.push({ name: '@before', value: before });
|
||||
}
|
||||
|
||||
if (query.filter === 'completed_today') {
|
||||
const todayPrefix = new Date().toISOString().slice(0, 10);
|
||||
conditions.push('c.actedOn = true');
|
||||
conditions.push('STARTSWITH(c.actedOnAt, @todayPrefix)');
|
||||
params.push({ name: '@todayPrefix', value: todayPrefix });
|
||||
}
|
||||
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
|
||||
const { resources } = await container()
|
||||
.items.query<MemoryItemDoc>(
|
||||
{
|
||||
query: `SELECT * FROM c ${where} ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit`,
|
||||
parameters: [
|
||||
...params,
|
||||
{ name: '@offset', value: query.offset },
|
||||
{ name: '@limit', value: query.limit },
|
||||
],
|
||||
},
|
||||
{ partitionKey: query.userId }
|
||||
)
|
||||
.fetchAll();
|
||||
|
||||
return { items: resources ?? [] };
|
||||
}
|
||||
|
||||
export async function getById(
|
||||
id: string,
|
||||
userId: string,
|
||||
productId: string
|
||||
): Promise<MemoryItemDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, userId).read<MemoryItemDoc>();
|
||||
if (!resource) return null;
|
||||
if (resource.productId !== productId) return null;
|
||||
return resource;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(doc: MemoryItemDoc): Promise<MemoryItemDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as MemoryItemDoc;
|
||||
}
|
||||
|
||||
export async function replace(doc: MemoryItemDoc): Promise<MemoryItemDoc> {
|
||||
const { resource } = await container().item(doc.id, doc.userId).replace(doc);
|
||||
return resource as MemoryItemDoc;
|
||||
}
|
||||
|
||||
export async function remove(id: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
await container().item(id, userId).delete();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
170
services/platform-service/src/modules/memory/routes.ts
Normal file
170
services/platform-service/src/modules/memory/routes.ts
Normal file
@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Memory items REST endpoints.
|
||||
*
|
||||
* GET /memory-items — list (optional brainId/filter)
|
||||
* POST /memory-items — create
|
||||
* PUT /memory-items/:id/reassign — reassign to another brain
|
||||
* PATCH /memory-items/:id — mark done/undone, increment nudge, set reminder
|
||||
* DELETE /memory-items/:id — delete
|
||||
*
|
||||
* Container: memory_items (partition key: /userId)
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { DEFAULT_PRODUCT_ID } from '../../lib/product-config.js';
|
||||
import { BadRequestError, NotFoundError } from '../../lib/errors.js';
|
||||
import { extractAuth } from '../../lib/auth.js';
|
||||
import * as repo from './repository.js';
|
||||
import {
|
||||
CreateMemoryItemSchema,
|
||||
ListMemoryItemsQuerySchema,
|
||||
PatchMemoryItemSchema,
|
||||
ReassignMemoryItemSchema,
|
||||
type MemoryItemDoc,
|
||||
type TriageResult,
|
||||
} from './types.js';
|
||||
|
||||
function defaultTriage(rawContent: string): TriageResult {
|
||||
return {
|
||||
contentType: 'memory',
|
||||
summary: rawContent.slice(0, 200),
|
||||
urgencyScore: 0.3,
|
||||
emotionScore: 0,
|
||||
confidenceScore: 0.5,
|
||||
suggestedBrainId: 'global',
|
||||
entities: [],
|
||||
suggestedActions: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function memoryRoutes(app: FastifyInstance) {
|
||||
// List memory items
|
||||
app.get('/memory-items', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const parsed = ListMemoryItemsQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const q = parsed.data;
|
||||
const pid = q.productId || DEFAULT_PRODUCT_ID;
|
||||
|
||||
const { items } = await repo.list({
|
||||
productId: pid,
|
||||
userId: auth.sub,
|
||||
brainId: q.brainId,
|
||||
filter: q.filter,
|
||||
limit: q.limit,
|
||||
offset: q.offset,
|
||||
});
|
||||
|
||||
return { items, limit: q.limit, offset: q.offset };
|
||||
});
|
||||
|
||||
// Create memory item
|
||||
app.post('/memory-items', async (req, reply) => {
|
||||
const auth = await extractAuth(req);
|
||||
const parsed = CreateMemoryItemSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
const pid = input.productId || DEFAULT_PRODUCT_ID;
|
||||
const now = new Date().toISOString();
|
||||
const triage = input.triageResult ?? defaultTriage(input.rawContent);
|
||||
const brainIds = input.brainIds && input.brainIds.length > 0 ? input.brainIds : [triage.suggestedBrainId];
|
||||
|
||||
const doc: MemoryItemDoc = {
|
||||
id: `mem_${Date.now()}_${randomUUID()}`,
|
||||
productId: pid,
|
||||
userId: auth.sub,
|
||||
sourceType: input.sourceType,
|
||||
captureSurface: input.captureSurface,
|
||||
rawContent: input.rawContent,
|
||||
triageResult: triage,
|
||||
brainIds,
|
||||
...(input.reminderAt && { reminderAt: input.reminderAt }),
|
||||
actedOn: false,
|
||||
actedOnAt: null,
|
||||
nudgeCount: 0,
|
||||
userCorrection: null,
|
||||
isSensitive: input.isSensitive ?? false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const created = await repo.create(doc);
|
||||
reply.code(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
// Reassign
|
||||
app.put('/memory-items/:id/reassign', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = ReassignMemoryItemSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const pid = (req.query as { productId?: string }).productId || DEFAULT_PRODUCT_ID;
|
||||
const item = await repo.getById(id, auth.sub, pid);
|
||||
if (!item) throw new NotFoundError('Memory item not found');
|
||||
|
||||
const updated: MemoryItemDoc = {
|
||||
...item,
|
||||
brainIds: [parsed.data.newBrainId],
|
||||
userCorrection: parsed.data.newBrainId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return repo.replace(updated);
|
||||
});
|
||||
|
||||
// Patch actions
|
||||
app.patch('/memory-items/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = PatchMemoryItemSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const pid = (req.query as { productId?: string }).productId || DEFAULT_PRODUCT_ID;
|
||||
const item = await repo.getById(id, auth.sub, pid);
|
||||
if (!item) throw new NotFoundError('Memory item not found');
|
||||
|
||||
const action = parsed.data.action;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
let updated: MemoryItemDoc = { ...item, updatedAt: now };
|
||||
if (action === 'mark_done') {
|
||||
updated = { ...updated, actedOn: true, actedOnAt: now };
|
||||
} else if (action === 'mark_undone') {
|
||||
updated = { ...updated, actedOn: false, actedOnAt: null };
|
||||
} else if (action === 'increment_nudge') {
|
||||
updated = { ...updated, nudgeCount: item.nudgeCount + 1 };
|
||||
} else if (action === 'set_reminder') {
|
||||
if (!parsed.data.reminderAt) {
|
||||
throw new BadRequestError('reminderAt is required for set_reminder');
|
||||
}
|
||||
updated = { ...updated, reminderAt: parsed.data.reminderAt };
|
||||
}
|
||||
|
||||
return repo.replace(updated);
|
||||
});
|
||||
|
||||
// Delete
|
||||
app.delete('/memory-items/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const pid = (req.query as { productId?: string }).productId || DEFAULT_PRODUCT_ID;
|
||||
|
||||
const item = await repo.getById(id, auth.sub, pid);
|
||||
if (!item) throw new NotFoundError('Memory item not found');
|
||||
|
||||
await repo.remove(id, auth.sub);
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
90
services/platform-service/src/modules/memory/types.ts
Normal file
90
services/platform-service/src/modules/memory/types.ts
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* MindLyst-style "memory items" — persisted capture + triage results.
|
||||
*
|
||||
* Container: memory_items (partition key: /userId)
|
||||
*
|
||||
* This is intentionally product-agnostic: every doc includes productId.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SourceTypeSchema = z.enum(['voice', 'image', 'link', 'email', 'text']);
|
||||
|
||||
export const CaptureSurfaceSchema = z.enum([
|
||||
'app',
|
||||
'widget',
|
||||
'share_sheet',
|
||||
'siri',
|
||||
'email',
|
||||
'web',
|
||||
'notification_reply',
|
||||
]);
|
||||
|
||||
export const ContentTypeSchema = z.enum(['task', 'reminder', 'memory', 'idea', 'risk', 'reference']);
|
||||
|
||||
export const TriageResultSchema = z.object({
|
||||
contentType: ContentTypeSchema,
|
||||
summary: z.string().min(1).max(2000),
|
||||
urgencyScore: z.number().min(0).max(1),
|
||||
emotionScore: z.number().min(-1).max(1),
|
||||
confidenceScore: z.number().min(0).max(1),
|
||||
suggestedBrainId: z.string().min(1).max(128),
|
||||
entities: z.array(z.string().min(1).max(128)).default([]),
|
||||
suggestedActions: z.array(z.string().min(1).max(256)).default([]),
|
||||
});
|
||||
|
||||
export const ListMemoryItemsQuerySchema = z.object({
|
||||
productId: z.string().min(1).max(64).optional(),
|
||||
brainId: z.string().min(1).max(128).optional(),
|
||||
filter: z.enum(['forgotten', 'completed_today']).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
offset: z.coerce.number().int().min(0).max(50_000).default(0),
|
||||
});
|
||||
|
||||
export const CreateMemoryItemSchema = z.object({
|
||||
productId: z.string().min(1).max(64).optional(),
|
||||
sourceType: SourceTypeSchema.default('text'),
|
||||
captureSurface: CaptureSurfaceSchema.default('app'),
|
||||
rawContent: z.string().min(1).max(50_000),
|
||||
triageResult: TriageResultSchema.optional(),
|
||||
brainIds: z.array(z.string().min(1).max(128)).optional(),
|
||||
isSensitive: z.boolean().optional(),
|
||||
reminderAt: z
|
||||
.string()
|
||||
.refine(v => !Number.isNaN(Date.parse(v)), 'reminderAt must be an ISO date string')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ReassignMemoryItemSchema = z.object({
|
||||
newBrainId: z.string().min(1).max(128),
|
||||
});
|
||||
|
||||
export const PatchMemoryItemSchema = z.object({
|
||||
action: z.enum(['mark_done', 'mark_undone', 'increment_nudge', 'set_reminder']),
|
||||
reminderAt: z
|
||||
.string()
|
||||
.refine(v => !Number.isNaN(Date.parse(v)), 'reminderAt must be an ISO date string')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TriageResult = z.infer<typeof TriageResultSchema>;
|
||||
|
||||
export type MemoryItemDoc = {
|
||||
id: string;
|
||||
productId: string;
|
||||
userId: string;
|
||||
sourceType: z.infer<typeof SourceTypeSchema>;
|
||||
captureSurface: z.infer<typeof CaptureSurfaceSchema>;
|
||||
rawContent: string;
|
||||
triageResult: TriageResult;
|
||||
brainIds: string[];
|
||||
reminderAt?: string;
|
||||
actedOn: boolean;
|
||||
actedOnAt: string | null;
|
||||
nudgeCount: number;
|
||||
userCorrection: string | null;
|
||||
isSensitive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@ -39,6 +39,7 @@ import { stripeRoutes } from './modules/stripe/routes.js';
|
||||
import { itemRoutes } from './modules/items/routes.js';
|
||||
import { commentRoutes } from './modules/comments/routes.js';
|
||||
import { voteRoutes } from './modules/votes/routes.js';
|
||||
import { memoryRoutes } from './modules/memory/routes.js';
|
||||
import { publicRoutes } from './modules/public/routes.js';
|
||||
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||
import { config } from './lib/config.js';
|
||||
@ -97,6 +98,8 @@ await app.register(stripeRoutes, { prefix: '/api' });
|
||||
await app.register(itemRoutes, { prefix: '/api' });
|
||||
await app.register(commentRoutes, { prefix: '/api' });
|
||||
await app.register(voteRoutes, { prefix: '/api' });
|
||||
// Mobile capture modules
|
||||
await app.register(memoryRoutes, { prefix: '/api' });
|
||||
// Public routes — no auth, registered at top level
|
||||
await app.register(publicRoutes, { prefix: '/api' });
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user